feat(voice): mic test with loopback in voice settings

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.
This commit is contained in:
2026-08-31 11:59:51 -03:00
parent 37407a5ecd
commit bfe62d7078
2 changed files with 121 additions and 3 deletions
+63
View File
@@ -32,6 +32,10 @@ export class AudioManager {
private rnnoiseReady = false; private rnnoiseReady = false;
private keepAliveOscillator: OscillatorNode | null = null; 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 // Cached `getUserMedia` denial. After a NotAllowedError, subsequent
// `setInputDevice` calls (e.g. `useLiveKit.syncMic` racing the user's // `setInputDevice` calls (e.g. `useLiveKit.syncMic` racing the user's
// tap on a denial prompt) re-throw the cached error WITHOUT issuing a // 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); 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<boolean> {
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 { getContext(): AudioContext | null {
return this.ctx; return this.ctx;
} }
@@ -20,6 +20,8 @@ export function AudioInputSection() {
// then join voice and expect the meter / resolved-default hint to come // then join voice and expect the meter / resolved-default hint to come
// alive without reopening the panel. // alive without reopening the panel.
const [audioCtxGen, setAudioCtxGen] = useState(0); const [audioCtxGen, setAudioCtxGen] = useState(0);
const [micTesting, setMicTesting] = useState(false);
const [micTestError, setMicTestError] = useState('');
const dropdownRef = useRef<HTMLDivElement>(null); const dropdownRef = useRef<HTMLDivElement>(null);
const animFrameRef = useRef<number>(0); const animFrameRef = useRef<number>(0);
@@ -77,7 +79,39 @@ export function AudioInputSection() {
stopped = true; stopped = true;
if (animFrameRef.current) cancelAnimationFrame(animFrameRef.current); 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. // Track the resolved upstream deviceId for the "Currently using: X" hint.
// Re-runs on `audioCtxGen` because the resolved-default ID is only known // Re-runs on `audioCtxGen` because the resolved-default ID is only known
@@ -213,9 +247,30 @@ export function AudioInputSection() {
/> />
))} ))}
</div> </div>
<div className="text-xs text-txt-tertiary mt-1.5"> <div className="flex items-center gap-3 mt-3">
The level meter activates once you join a voice channel. <button
type="button"
onClick={() => void toggleMicTest()}
disabled={permState !== 'granted'}
className={`px-3 py-1.5 rounded-md text-[13px] font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed ${
micTesting
? 'bg-interactive-muted text-txt-primary hover:brightness-110'
: 'bg-accent-primary text-white hover:brightness-110'
}`}
>
{micTesting ? 'Stop Testing' : "Let's Check"}
</button>
<span className="text-xs text-txt-tertiary">
{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.'}
</span>
</div> </div>
{micTestError && (
<div className="text-xs text-txt-danger mt-1.5">{micTestError}</div>
)}
</div> </div>
</div> </div>
</SectionShell> </SectionShell>