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 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>
|
||||||
|
|||||||
Reference in New Issue
Block a user