import { useEffect, useRef, useState } from 'react'; import { Volume2, VolumeX } from 'lucide-react'; type AudioStreamPlayerProps = { wsUrl: string; onError?: (message: string) => void; }; const SAMPLE_RATE = 44100; const CHANNELS = 2; const buildWsUrl = (wsPath: string) => { const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const token = localStorage.getItem('BEARER_TOKEN') ?? ''; const separator = wsPath.includes('?') ? '&' : '?'; return `${protocol}//${window.location.host}${wsPath}${separator}token=${encodeURIComponent(token)}`; }; const WORKLET_CODE = ` class PCMProcessor extends AudioWorkletProcessor { constructor() { super(); this.buffer = new Float32Array(0); this.port.onmessage = (e) => { const incoming = e.data; const merged = new Float32Array(this.buffer.length + incoming.length); merged.set(this.buffer); merged.set(incoming, this.buffer.length); this.buffer = merged; const max = ${SAMPLE_RATE * CHANNELS * 2}; if (this.buffer.length > max) { this.buffer = this.buffer.slice(this.buffer.length - max); } }; } process(inputs, outputs) { const output = outputs[0]; if (!output || output.length === 0) return true; const channels = output.length; const frameSize = output[0].length; const samplesNeeded = frameSize * channels; if (this.buffer.length >= samplesNeeded) { for (let i = 0; i < frameSize; i++) { for (let ch = 0; ch < channels; ch++) { output[ch][i] = this.buffer[i * channels + ch]; } } this.buffer = this.buffer.slice(samplesNeeded); } else { for (let ch = 0; ch < channels; ch++) { output[ch].fill(0); } } return true; } } registerProcessor('pcm-processor', PCMProcessor); `; const workletBlobUrl = URL.createObjectURL(new Blob([WORKLET_CODE], { type: 'application/javascript' })); export const AudioStreamPlayer = ({ wsUrl, onError }: AudioStreamPlayerProps) => { const [muted, setMuted] = useState(false); const [started, setStarted] = useState(false); const ctxRef = useRef(null); const nodeRef = useRef(null); const wsRef = useRef(null); const gainRef = useRef(null); // The effect below runs once per `wsUrl` and registers listeners that outlive every render after it, so a // named `onError` dependency would either tear the stream down on each render or freeze the first render's // callback. A ref is the third option: one stream, current callback. const onErrorRef = useRef(onError); onErrorRef.current = onError; useEffect(() => { let disposed = false; let audioCtx: AudioContext | null = null; const init = async () => { try { audioCtx = new AudioContext({ sampleRate: SAMPLE_RATE }); ctxRef.current = audioCtx; await audioCtx.audioWorklet.addModule(workletBlobUrl); if (disposed) { audioCtx.close(); return; } const workletNode = new AudioWorkletNode(audioCtx, 'pcm-processor', { outputChannelCount: [CHANNELS], }); nodeRef.current = workletNode; const gainNode = audioCtx.createGain(); gainRef.current = gainNode; workletNode.connect(gainNode); gainNode.connect(audioCtx.destination); const ws = new WebSocket(buildWsUrl(wsUrl)); ws.binaryType = 'arraybuffer'; wsRef.current = ws; ws.addEventListener('open', () => { if (!disposed) setStarted(true); }); ws.addEventListener('message', (ev) => { if (disposed || !(ev.data instanceof ArrayBuffer)) return; // Resume context if suspended (autoplay policy — will unlock on user gesture) if (audioCtx && audioCtx.state === 'suspended') { audioCtx.resume(); } const int16 = new Int16Array(ev.data); const float32 = new Float32Array(int16.length); for (let i = 0; i < int16.length; i++) { float32[i] = int16[i]! / 32768; } workletNode.port.postMessage(float32); }); ws.addEventListener('error', () => { if (!disposed) onErrorRef.current?.('Audio stream connection failed'); }); ws.addEventListener('close', () => { if (!disposed) setStarted(false); }); } catch (err) { if (!disposed) { onErrorRef.current?.(err instanceof Error ? err.message : 'Audio playback failed'); } } }; init(); return () => { disposed = true; try { wsRef.current?.close(); } catch { /* ignore */ } wsRef.current = null; try { nodeRef.current?.disconnect(); } catch { /* ignore */ } nodeRef.current = null; try { audioCtx?.close(); } catch { /* ignore */ } ctxRef.current = null; gainRef.current = null; }; }, [wsUrl]); useEffect(() => { if (gainRef.current) { gainRef.current.gain.value = muted ? 0 : 1; } }, [muted]); return ( ); };