import { useEffect, useRef, useState } from 'react'; interface AudioSource { sourceNode: AudioNode; ctx: AudioContext; } /** * Creates an AudioContext and source node from either a MediaStream (live recording) * or an HTMLAudioElement (review playback). * * Important: `createMediaElementSource` can only be called once per element, * so we cache the source per element instance. */ export function useAudioSource( source: MediaStream | HTMLAudioElement | null, ): AudioSource | null { const [audioSource, setAudioSource] = useState(null); const elementSourceCache = useRef< WeakMap< HTMLAudioElement, { sourceNode: MediaElementAudioSourceNode; ctx: AudioContext } > >(new WeakMap()); useEffect(() => { // Build the source node (creating an AudioContext as needed) plus optional // teardown for contexts we own; the branches converge on one setState. let result: AudioSource | null = null; let cleanup: (() => void) | undefined; if (source instanceof MediaStream) { const ctx = new AudioContext(); ctx.resume(); const sourceNode = ctx.createMediaStreamSource(source); result = { sourceNode, ctx }; cleanup = () => ctx.close(); } else if (source) { // HTMLAudioElement — createMediaElementSource can only be called once per // element, so reuse a cached context/node when we have one. const cached = elementSourceCache.current.get(source); if (cached) { cached.ctx.resume(); result = cached; } else { const ctx = new AudioContext(); ctx.resume(); const sourceNode = ctx.createMediaElementSource(source); // Connect element source to destination so audio is still audible sourceNode.connect(ctx.destination); elementSourceCache.current.set(source, { sourceNode, ctx }); result = { sourceNode, ctx }; cleanup = () => { ctx.close(); elementSourceCache.current.delete(source); }; } } // Publishing an imperatively-created Web Audio node — external-resource // sync, not a re-render cascade. // eslint-disable-next-line react-hooks/set-state-in-effect setAudioSource(result); return cleanup; }, [source]); return audioSource; }