/** * 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 }; }