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;
|
||||
}
|
||||
|
||||
@@ -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<HTMLDivElement>(null);
|
||||
const animFrameRef = useRef<number>(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() {
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="text-xs text-txt-tertiary mt-1.5">
|
||||
The level meter activates once you join a voice channel.
|
||||
<div className="flex items-center gap-3 mt-3">
|
||||
<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>
|
||||
{micTestError && (
|
||||
<div className="text-xs text-txt-danger mt-1.5">{micTestError}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</SectionShell>
|
||||
|
||||
Reference in New Issue
Block a user