diff --git a/.replit b/.replit index ce0a7da..6437e37 100644 --- a/.replit +++ b/.replit @@ -44,3 +44,6 @@ waitForPort = 5000 [userenv.shared] BUNNY_LIBRARY_ID = "476412" BUNNY_CDN_HOST = "vz-7982dfc4-cc8.b-cdn.net" + +[agent] +integrations = ["javascript_openai_ai_integrations:2.0.0"] diff --git a/attached_assets/image_1772310288253.png b/attached_assets/image_1772310288253.png new file mode 100644 index 0000000..41fbb42 Binary files /dev/null and b/attached_assets/image_1772310288253.png differ diff --git a/client/replit_integrations/audio/audio-playback-worklet.js b/client/replit_integrations/audio/audio-playback-worklet.js new file mode 100644 index 0000000..41288a2 --- /dev/null +++ b/client/replit_integrations/audio/audio-playback-worklet.js @@ -0,0 +1,112 @@ +/** + * Reusable AudioWorklet for streaming PCM16 audio playback. + * Place in public/ folder and load via audioContext.audioWorklet.addModule() + */ +class RingBuffer { + constructor(initialCapacity) { + this.capacity = initialCapacity; + this.buffer = new Float32Array(initialCapacity); + this.readIndex = 0; + this.writeIndex = 0; + this.availableData = 0; + } + + push(data) { + const len = data.length; + // Auto-grow if needed + while (this.availableData + len > this.capacity) { + this.grow(); + } + for (let i = 0; i < len; i++) { + this.buffer[this.writeIndex] = data[i]; + this.writeIndex = (this.writeIndex + 1) % this.capacity; + this.availableData++; + } + } + + grow() { + const newCapacity = this.capacity * 2; + const newBuffer = new Float32Array(newCapacity); + // Copy existing data maintaining order + for (let i = 0; i < this.availableData; i++) { + const srcIndex = (this.readIndex + i) % this.capacity; + newBuffer[i] = this.buffer[srcIndex]; + } + this.buffer = newBuffer; + this.readIndex = 0; + this.writeIndex = this.availableData; + this.capacity = newCapacity; + } + + pull(outputBuffer) { + const len = outputBuffer.length; + const available = Math.min(len, this.availableData); + for (let i = 0; i < available; i++) { + outputBuffer[i] = this.buffer[this.readIndex]; + this.readIndex = (this.readIndex + 1) % this.capacity; + } + // Pad remaining with silence + for (let i = available; i < len; i++) { + outputBuffer[i] = 0; + } + this.availableData -= available; + return available > 0; + } + + available() { + return this.availableData; + } + + clear() { + this.readIndex = 0; + this.writeIndex = 0; + this.availableData = 0; + } +} + +class AudioPlaybackProcessor extends AudioWorkletProcessor { + constructor() { + super(); + this.ringBuffer = new RingBuffer(24000 * 30); // 30s initial capacity + this.isPlaying = false; + this.streamComplete = false; + + this.port.onmessage = (event) => { + const { type, samples } = event.data; + if (type === "audio") { + this.ringBuffer.push(samples); + this.isPlaying = true; + } else if (type === "clear") { + this.ringBuffer.clear(); + this.isPlaying = false; + this.streamComplete = false; + } else if (type === "streamComplete") { + this.streamComplete = true; + } else if (type === "stop") { + this.isPlaying = false; + this.streamComplete = false; + } + }; + } + + process(inputs, outputs) { + const output = outputs[0]; + if (!output || output.length === 0) return true; + + const channel = output[0]; + if (this.isPlaying) { + this.ringBuffer.pull(channel); + if (this.streamComplete && this.ringBuffer.available() === 0) { + this.isPlaying = false; + this.streamComplete = false; + this.port.postMessage({ type: "ended" }); + } + } else { + channel.fill(0); + } + return true; + } +} + +registerProcessor("audio-playback-processor", AudioPlaybackProcessor); + diff --git a/client/replit_integrations/audio/audio-utils.ts b/client/replit_integrations/audio/audio-utils.ts new file mode 100644 index 0000000..909dfab --- /dev/null +++ b/client/replit_integrations/audio/audio-utils.ts @@ -0,0 +1,36 @@ +/** + * Audio utility functions for voice chat. + * Handles PCM16 decoding and AudioContext initialization. + */ + +/** + * Decode base64 PCM16 audio to Float32Array for Web Audio API + */ +export function decodePCM16ToFloat32(base64Audio: string): Float32Array { + const raw = atob(base64Audio); + const bytes = new Uint8Array(raw.length); + for (let i = 0; i < raw.length; i++) { + bytes[i] = raw.charCodeAt(i); + } + const pcm16 = new Int16Array(bytes.buffer); + const float32 = new Float32Array(pcm16.length); + for (let i = 0; i < pcm16.length; i++) { + float32[i] = pcm16[i] / 32768; + } + return float32; +} + +/** + * Create and initialize AudioContext with worklet + */ +export async function createAudioPlaybackContext( + workletPath = "/audio-playback-worklet.js", + sampleRate = 24000 +): Promise<{ ctx: AudioContext; worklet: AudioWorkletNode }> { + const ctx = new AudioContext({ sampleRate }); + await ctx.audioWorklet.addModule(workletPath); + const worklet = new AudioWorkletNode(ctx, "audio-playback-processor"); + worklet.connect(ctx.destination); + return { ctx, worklet }; +} + diff --git a/client/replit_integrations/audio/index.ts b/client/replit_integrations/audio/index.ts new file mode 100644 index 0000000..4862b3f --- /dev/null +++ b/client/replit_integrations/audio/index.ts @@ -0,0 +1,45 @@ +/** + * Voice chat client utilities for Replit AI Integrations. + * + * Usage: + * 1. Copy audio-playback-worklet.js to your public/ folder + * 2. Import and use the React hooks in your components + * + * Example: + * ```tsx + * import { useVoiceRecorder, useVoiceStream } from "./audio"; + * + * function VoiceChat() { + * const [transcript, setTranscript] = useState(""); + * const recorder = useVoiceRecorder(); + * const stream = useVoiceStream({ + * onTranscript: (_, full) => setTranscript(full), + * onComplete: (text) => console.log("Done:", text), + * }); + * + * const handleClick = async () => { + * if (recorder.state === "recording") { + * const blob = await recorder.stopRecording(); + * await stream.streamVoiceResponse("/api/voice-conversations/1/messages", blob); + * } else { + * await recorder.startRecording(); + * } + * }; + * + * return ( + *
+ * + *

{transcript}

+ *
+ * ); + * } + * ``` + */ + +export { decodePCM16ToFloat32, createAudioPlaybackContext } from "./audio-utils"; +export { useVoiceRecorder, type RecordingState } from "./useVoiceRecorder"; +export { useAudioPlayback, type PlaybackState } from "./useAudioPlayback"; +export { useVoiceStream } from "./useVoiceStream"; + diff --git a/client/replit_integrations/audio/useAudioPlayback.ts b/client/replit_integrations/audio/useAudioPlayback.ts new file mode 100644 index 0000000..dd0963d --- /dev/null +++ b/client/replit_integrations/audio/useAudioPlayback.ts @@ -0,0 +1,105 @@ +/** + * React hook for streaming audio playback using AudioWorklet. + * Supports real-time PCM16 audio streaming from SSE responses. + * Includes sequence buffer for reordering out-of-order chunks. + */ +import { useRef, useCallback, useState } from "react"; +import { decodePCM16ToFloat32 } from "./audio-utils"; + +export type PlaybackState = "idle" | "playing" | "ended"; + +/** + * Reorders audio chunks that may arrive out of sequence. + * Buffers chunks until they can be played in correct order. + * + * Example: If chunks arrive as seq 2, seq 0, seq 1: + * - seq 2 arrives → buffered (waiting for seq 0) + * - seq 0 arrives → played immediately, then check buffer + * - seq 1 arrives → played immediately (seq 0 done), seq 2 now plays + */ +class SequenceBuffer { + private pending = new Map(); + private nextSeq = 0; + + /** Add chunk with sequence number, returns chunks ready to play in order */ + push(seq: number, data: string): string[] { + // Store the chunk under its sequence number + if (!this.pending.has(seq)) { + this.pending.set(seq, []); + } + this.pending.get(seq)!.push(data); + + // Drain consecutive ready sequences + const ready: string[] = []; + while (this.pending.has(this.nextSeq)) { + ready.push(...this.pending.get(this.nextSeq)!); + this.pending.delete(this.nextSeq); + this.nextSeq++; + } + return ready; + } + + reset() { + this.pending.clear(); + this.nextSeq = 0; + } +} + +export function useAudioPlayback(workletPath = "/audio-playback-worklet.js") { + const [state, setState] = useState("idle"); + const ctxRef = useRef(null); + const workletRef = useRef(null); + const readyRef = useRef(false); + const seqBufferRef = useRef(new SequenceBuffer()); + + const init = useCallback(async () => { + if (readyRef.current) return; + + const ctx = new AudioContext({ sampleRate: 24000 }); + await ctx.audioWorklet.addModule(workletPath); + const worklet = new AudioWorkletNode(ctx, "audio-playback-processor"); + worklet.connect(ctx.destination); + + worklet.port.onmessage = (e) => { + if (e.data.type === "ended") setState("idle"); + }; + + ctxRef.current = ctx; + workletRef.current = worklet; + readyRef.current = true; + }, [workletPath]); + + /** Push audio directly (no sequencing) - for simple streaming */ + const pushAudio = useCallback((base64Audio: string) => { + if (!workletRef.current) return; + const samples = decodePCM16ToFloat32(base64Audio); + workletRef.current.port.postMessage({ type: "audio", samples }); + setState("playing"); + }, []); + + /** Push audio with sequence number - reorders before playback */ + const pushSequencedAudio = useCallback((seq: number, base64Audio: string) => { + if (!workletRef.current) return; + + const readyChunks = seqBufferRef.current.push(seq, base64Audio); + for (const chunk of readyChunks) { + const samples = decodePCM16ToFloat32(chunk); + workletRef.current.port.postMessage({ type: "audio", samples }); + } + if (readyChunks.length > 0) { + setState("playing"); + } + }, []); + + const signalComplete = useCallback(() => { + workletRef.current?.port.postMessage({ type: "streamComplete" }); + }, []); + + const clear = useCallback(() => { + workletRef.current?.port.postMessage({ type: "clear" }); + seqBufferRef.current.reset(); + setState("idle"); + }, []); + + return { state, init, pushAudio, pushSequencedAudio, signalComplete, clear }; +} diff --git a/client/replit_integrations/audio/useVoiceRecorder.ts b/client/replit_integrations/audio/useVoiceRecorder.ts new file mode 100644 index 0000000..8bbf86e --- /dev/null +++ b/client/replit_integrations/audio/useVoiceRecorder.ts @@ -0,0 +1,52 @@ +/** + * React hook for voice recording using MediaRecorder API. + * Records audio in WebM/Opus format for efficient streaming. + */ +import { useRef, useCallback, useState } from "react"; + +export type RecordingState = "idle" | "recording" | "stopped"; + +export function useVoiceRecorder() { + const [state, setState] = useState("idle"); + const mediaRecorderRef = useRef(null); + const chunksRef = useRef([]); + + const startRecording = useCallback(async (): Promise => { + const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); + const recorder = new MediaRecorder(stream, { + mimeType: "audio/webm;codecs=opus", + }); + + mediaRecorderRef.current = recorder; + chunksRef.current = []; + + recorder.ondataavailable = (e) => { + if (e.data.size > 0) chunksRef.current.push(e.data); + }; + + recorder.start(100); // Collect chunks every 100ms + setState("recording"); + }, []); + + const stopRecording = useCallback((): Promise => { + return new Promise((resolve) => { + const recorder = mediaRecorderRef.current; + if (!recorder || recorder.state !== "recording") { + resolve(new Blob()); + return; + } + + recorder.onstop = () => { + const blob = new Blob(chunksRef.current, { type: "audio/webm" }); + recorder.stream.getTracks().forEach((t) => t.stop()); + setState("stopped"); + resolve(blob); + }; + + recorder.stop(); + }); + }, []); + + return { state, startRecording, stopRecording }; +} + diff --git a/client/replit_integrations/audio/useVoiceStream.ts b/client/replit_integrations/audio/useVoiceStream.ts new file mode 100644 index 0000000..8466ce8 --- /dev/null +++ b/client/replit_integrations/audio/useVoiceStream.ts @@ -0,0 +1,91 @@ +/** + * React hook for handling SSE voice streaming responses. + * Converts audio blob to base64 and sends as JSON to match server expectations. + */ +import { useCallback } from "react"; +import { useAudioPlayback } from "./useAudioPlayback"; + +interface StreamCallbacks { + onUserTranscript?: (text: string) => void; + onTranscript?: (text: string, full: string) => void; + onComplete?: (transcript: string) => void; + onError?: (error: Error) => void; +} + +export function useVoiceStream(callbacks: StreamCallbacks = {}) { + const playback = useAudioPlayback(); + + const streamVoiceResponse = useCallback( + async (url: string, audioBlob: Blob) => { + await playback.init(); + playback.clear(); + + // Convert blob to base64 for JSON body (server expects express.json()) + const base64Audio = await new Promise((resolve) => { + const fileReader = new FileReader(); + fileReader.onload = () => { + const result = fileReader.result as string; + resolve(result.split(",")[1]); // Remove data URL prefix + }; + fileReader.readAsDataURL(audioBlob); + }); + + const response = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ audio: base64Audio }), + }); + if (!response.ok) throw new Error("Voice request failed"); + + const streamReader = response.body?.getReader(); + if (!streamReader) throw new Error("No response body"); + + const decoder = new TextDecoder(); + let buffer = ""; + let fullTranscript = ""; + + while (true) { + const { done, value } = await streamReader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() || ""; + + for (const line of lines) { + if (!line.startsWith("data: ")) continue; + + try { + const event = JSON.parse(line.slice(6)); + + switch (event.type) { + case "user_transcript": + callbacks.onUserTranscript?.(event.data); + break; + case "transcript": + fullTranscript += event.data; + callbacks.onTranscript?.(event.data, fullTranscript); + break; + case "audio": + playback.pushAudio(event.data); + break; + case "done": + playback.signalComplete(); + callbacks.onComplete?.(fullTranscript); + break; + case "error": + throw new Error(event.error); + } + } catch (e) { + if (!(e instanceof SyntaxError)) { + callbacks.onError?.(e as Error); + } + } + } + } + }, + [playback, callbacks] + ); + + return { streamVoiceResponse, playbackState: playback.state }; +} diff --git a/client/src/pages/home.tsx b/client/src/pages/home.tsx index 182dd8c..fd7fe7d 100644 --- a/client/src/pages/home.tsx +++ b/client/src/pages/home.tsx @@ -42,8 +42,8 @@ function HeroCard({ article }: { article: Article }) { const isVideo = article.category === "Video"; return ( -
-
+
+
{article.title} {isVideo && (
@@ -77,8 +77,8 @@ function GalleryHeroCard({ images }: { images: GalleryImage[] }) { return ( -
-
+
+
{images[idx].fileName}
diff --git a/client/src/pages/horoscope.tsx b/client/src/pages/horoscope.tsx index 387b9c5..27294a9 100644 --- a/client/src/pages/horoscope.tsx +++ b/client/src/pages/horoscope.tsx @@ -1,4 +1,5 @@ import { useState, useEffect, useRef } from "react"; +import { useQuery } from "@tanstack/react-query"; import { Link, useParams } from "wouter"; import { Star, @@ -15,6 +16,7 @@ import { Moon, Sun, ArrowRight, + Loader2, } from "lucide-react"; import Header from "@/components/header"; import Footer from "@/components/footer"; @@ -23,12 +25,24 @@ import { SIGNS, ELEMENT_COLORS, ASTRO_EVENTS, - getHoroscope, + getHoroscope as getStaticHoroscope, getRating, getLuckyNumbers, getDailyColor, } from "@/lib/horoscope-data"; +interface AIHoroscope { + signIndex: number; + signName: string; + general: string; + love: string; + career: string; + health: string; + tip: string; + weekly: string; + monthly: string; +} + function StarRating({ count }: { count: number }) { return (
@@ -103,7 +117,7 @@ function AstroEventsSection() { ); } -function SignGrid({ onSelect, selectedIndex }: { onSelect: (i: number) => void; selectedIndex: number | null }) { +function SignGrid({ onSelect, selectedIndex, aiHoroscopes }: { onSelect: (i: number) => void; selectedIndex: number | null; aiHoroscopes: AIHoroscope[] }) { return (

@@ -113,7 +127,8 @@ function SignGrid({ onSelect, selectedIndex }: { onSelect: (i: number) => void;
{SIGNS.map((sign, i) => { const ec = ELEMENT_COLORS[sign.element]; - const horoscope = getHoroscope(i); + const aiH = aiHoroscopes.find(h => h.signIndex === i); + const horoscope = aiH || getStaticHoroscope(i); const isSelected = selectedIndex === i; return (