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:
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user