From bfe62d70784fb89c038ee049126bc4f9bc0ad764 Mon Sep 17 00:00:00 2001 From: devsyncwrld Date: Mon, 31 Aug 2026 11:59:51 -0300 Subject: [PATCH] feat(voice): mic test with loopback in voice settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The level meter only measured a stream a call had already opened, so settings offered no way to check a mic before joining — the panel said as much. Add startMicTest/stopMicTest on AudioManager: the processed input bus is routed to the master output through a dedicated gain node, so the loopback can be disconnected precisely. Settings had deliberately never opened the mic itself; a mic test cannot honour that, so the test hands the mic back when it stops. Releasing needs two independent guards, because the user may join a call mid-test: AudioManager only stops the exact stream it opened (identity check, not a flag), and the caller must consent — the UI reads the call state, which AudioManager cannot, as it does not import stores. Unmounting mid-test tears the loopback down too. --- packages/web/src/audio/AudioManager.ts | 63 +++++++++++++++++++ .../settingsPanels/AudioInputSection.tsx | 61 +++++++++++++++++- 2 files changed, 121 insertions(+), 3 deletions(-) diff --git a/packages/web/src/audio/AudioManager.ts b/packages/web/src/audio/AudioManager.ts index 0b18ff6b..e950f56b 100644 --- a/packages/web/src/audio/AudioManager.ts +++ b/packages/web/src/audio/AudioManager.ts @@ -32,6 +32,10 @@ export class AudioManager { private rnnoiseReady = false; private keepAliveOscillator: OscillatorNode | null = null; + // Mic test (settings → Voice). See startMicTest(). + private micTestGain: GainNode | null = null; + private micTestStream: MediaStream | null = null; + // Cached `getUserMedia` denial. After a NotAllowedError, subsequent // `setInputDevice` calls (e.g. `useLiveKit.syncMic` racing the user's // tap on a denial prompt) re-throw the cached error WITHOUT issuing a @@ -567,6 +571,65 @@ export class AudioManager { osc.stop(now + 0.45); } + /** + * Mic test: routes the processed input bus to the speakers so the user hears + * themselves, outside of any call. + * + * Settings deliberately never opened the mic on their own — the level meter + * only measures a stream that a call had already established. A mic test + * cannot honour that, so this is the one path that opens it, and + * `stopMicTest` hands it back rather than leaving the mic indicator lit. + * + * Returns false when the mic could not be opened (denied, unplugged). + */ + async startMicTest(): Promise { + if (this.micTestGain) return true; + const ctx = this.ensureContext(); + await this.resumeContext(); + + const hadStream = this.hasActiveStream(); + if (!hadStream) { + const stream = await this.setInputDevice(this.currentInputDeviceId); + if (!stream) return false; + // Remember the exact stream we opened, so stopMicTest only ever stops + // that one — never a stream something else established meanwhile. + this.micTestStream = this.currentStream; + } + + this.micTestGain = ctx.createGain(); + this.inputGain!.connect(this.micTestGain); + this.micTestGain.connect(this.getMasterOutput()); + return true; + } + + /** + * Tears down the loopback. + * + * @param allowRelease Whether the mic may be handed back. Only the caller + * knows whether a call has started since the test began — AudioManager + * does not read stores — so releasing needs its consent as well as our own + * record that this test is what opened the stream. + */ + stopMicTest(allowRelease: boolean): void { + if (!this.micTestGain) return; + try { this.inputGain?.disconnect(this.micTestGain); } catch { /* graph already torn down */ } + try { this.micTestGain.disconnect(); } catch { /* already detached */ } + this.micTestGain = null; + + if (allowRelease && this.micTestStream && this.currentStream === this.micTestStream) { + // Detach listeners before stopping (see `_setInputDeviceImpl`). + const tracks = this.currentStream.getTracks(); + tracks.forEach(t => { t.onended = null; }); + tracks.forEach(t => t.stop()); + this.currentStream = null; + } + this.micTestStream = null; + } + + isMicTestActive(): boolean { + return this.micTestGain !== null; + } + getContext(): AudioContext | null { return this.ctx; } diff --git a/packages/web/src/components/modals/settingsPanels/AudioInputSection.tsx b/packages/web/src/components/modals/settingsPanels/AudioInputSection.tsx index c11a9e28..7c6e106f 100644 --- a/packages/web/src/components/modals/settingsPanels/AudioInputSection.tsx +++ b/packages/web/src/components/modals/settingsPanels/AudioInputSection.tsx @@ -20,6 +20,8 @@ export function AudioInputSection() { // then join voice and expect the meter / resolved-default hint to come // alive without reopening the panel. const [audioCtxGen, setAudioCtxGen] = useState(0); + const [micTesting, setMicTesting] = useState(false); + const [micTestError, setMicTestError] = useState(''); const dropdownRef = useRef(null); const animFrameRef = useRef(0); @@ -77,7 +79,39 @@ export function AudioInputSection() { stopped = true; if (animFrameRef.current) cancelAnimationFrame(animFrameRef.current); }; - }, [permState, audioCtxGen]); + }, [permState, audioCtxGen, micTesting]); + + // Subscribed (not a one-off getState) so the hint text below tracks the call + // state live. The release decision itself reads getState() at the moment of + // stopping, which is when it must be accurate. + const isLiveKitConnected = useVoiceStore((s) => s.isLiveKitConnected); + + const toggleMicTest = async () => { + const am = AudioManager.getInstance(); + if (micTesting) { + am.stopMicTest(!useVoiceStore.getState().isLiveKitConnected); + setMicTesting(false); + return; + } + setMicTestError(''); + const ok = await am.startMicTest(); + if (!ok) { + setMicTestError('Could not open the microphone. Check the device and its permission.'); + return; + } + setMicTesting(true); + }; + + // Leaving the panel mid-test must not leave the loopback running or the mic + // held open. + useEffect(() => { + return () => { + const am = AudioManager.getInstance(); + if (am.isMicTestActive()) { + am.stopMicTest(!useVoiceStore.getState().isLiveKitConnected); + } + }; + }, []); // Track the resolved upstream deviceId for the "Currently using: X" hint. // Re-runs on `audioCtxGen` because the resolved-default ID is only known @@ -213,9 +247,30 @@ export function AudioInputSection() { /> ))} -
- The level meter activates once you join a voice channel. +
+ + + {micTesting + ? 'Playing your mic back to you — say something.' + : isLiveKitConnected + ? 'The level meter is live while you are in a call.' + : 'Test your mic without joining a call.'} +
+ {micTestError && ( +
{micTestError}
+ )}