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
@@ -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>