folx-tv/client/replit_integrations/audio/useAudioPlayback.ts
2026-02-28 20:36:50 +00:00

106 lines
3.4 KiB
TypeScript

/**
* 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<number, string[]>();
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<PlaybackState>("idle");
const ctxRef = useRef<AudioContext | null>(null);
const workletRef = useRef<AudioWorkletNode | null>(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 };
}