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 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<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 {
return this.ctx;
}