feat(voice): add mic input device selector and stabilize media capture for composer (#1616)

This commit is contained in:
YellowSnnowmann
2026-05-13 16:35:24 +05:30
committed by GitHub
parent 3e5a8c341c
commit 10a726d007
6 changed files with 326 additions and 70 deletions
+10 -1
View File
@@ -1372,7 +1372,16 @@ pub fn run() {
}
};
if let Some(path) = fake_camera_arg {
args.push(("--use-fake-device-for-media-stream", None));
// `--use-file-for-fake-video-capture` alone (CEF 146 / Chromium 128+)
// injects the Y4M as the video capture source without replacing the
// audio capture device. The old belt-and-suspenders flag
// `--use-fake-device-for-media-stream` is deliberately omitted here:
// it replaced ALL media capture devices — including audio — with fake
// ones, causing a sine-wave test tone (beeping) to be recorded instead
// of the real microphone whenever `getUserMedia({audio:true})` was
// called from the main app WebView (e.g. the mascot voice composer).
// `--use-fake-ui-for-media-stream` is kept so Meet's permission prompt
// is auto-granted without interrupting the join flow.
args.push(("--use-fake-ui-for-media-stream", None));
args.push(("--use-file-for-fake-video-capture", Some(path)));
}
@@ -147,14 +147,45 @@ describe('MicCloudComposer', () => {
expect(opts).toEqual({ language: 'es' });
});
it('surfaces a permission-denied error via onError', async () => {
getUserMediaMock.mockRejectedValueOnce(new Error('NotAllowed'));
it('surfaces a permission-denied error via onError for NotAllowedError', async () => {
const err = Object.assign(new DOMException('', 'NotAllowedError'));
getUserMediaMock.mockRejectedValueOnce(err);
const onError = vi.fn();
render(<MicCloudComposer disabled={false} onSubmit={vi.fn()} onError={onError} />);
fireEvent.click(screen.getByRole('button', { name: /start recording/i }));
await waitFor(() => expect(onError).toHaveBeenCalledWith(expect.stringMatching(/permission/i)));
});
it('surfaces a device-unavailable error for OverconstrainedError', async () => {
const err = new DOMException('', 'OverconstrainedError');
getUserMediaMock.mockRejectedValueOnce(err);
const onError = vi.fn();
render(<MicCloudComposer disabled={false} onSubmit={vi.fn()} onError={onError} />);
fireEvent.click(screen.getByRole('button', { name: /start recording/i }));
await waitFor(() =>
expect(onError).toHaveBeenCalledWith(expect.stringMatching(/unavailable/i))
);
});
it('surfaces an in-use error for NotReadableError', async () => {
const err = new DOMException('', 'NotReadableError');
getUserMediaMock.mockRejectedValueOnce(err);
const onError = vi.fn();
render(<MicCloudComposer disabled={false} onSubmit={vi.fn()} onError={onError} />);
fireEvent.click(screen.getByRole('button', { name: /start recording/i }));
await waitFor(() => expect(onError).toHaveBeenCalledWith(expect.stringMatching(/in use/i)));
});
it('surfaces a generic error for non-DOMException getUserMedia failures', async () => {
getUserMediaMock.mockRejectedValueOnce(new Error('some other error'));
const onError = vi.fn();
render(<MicCloudComposer disabled={false} onSubmit={vi.fn()} onError={onError} />);
fireEvent.click(screen.getByRole('button', { name: /start recording/i }));
await waitFor(() =>
expect(onError).toHaveBeenCalledWith(expect.stringMatching(/microphone error/i))
);
});
it('falls back to wav re-encode when the native attempt fails', async () => {
transcribeCloudMock
.mockRejectedValueOnce(new Error('codec not accepted'))
@@ -288,4 +319,164 @@ describe('MicCloudComposer', () => {
expect(removeSpy).toHaveBeenCalledWith('keydown', expect.any(Function));
removeSpy.mockRestore();
});
// ── Device selector (showDeviceSelector) ─────────────────────────────────
it('enumerates devices on mount when showDeviceSelector is true', async () => {
const enumerateDevicesMock = vi.fn().mockResolvedValue([
{ kind: 'audioinput', deviceId: 'dev1', label: 'Built-in Mic' },
{ kind: 'audioinput', deviceId: 'dev2', label: 'USB Headset' },
{ kind: 'videoinput', deviceId: 'cam1', label: 'Camera' },
]);
Object.defineProperty(globalThis.navigator, 'mediaDevices', {
value: { getUserMedia: getUserMediaMock, enumerateDevices: enumerateDevicesMock },
configurable: true,
writable: true,
});
render(<MicCloudComposer disabled={false} onSubmit={vi.fn()} showDeviceSelector />);
await waitFor(() => expect(enumerateDevicesMock).toHaveBeenCalled());
expect(await screen.findByRole('combobox', { name: /microphone device/i })).toBeInTheDocument();
expect(screen.getByText('Built-in Mic')).toBeInTheDocument();
expect(screen.getByText('USB Headset')).toBeInTheDocument();
// Video input must not appear
expect(screen.queryByText('Camera')).not.toBeInTheDocument();
});
it('does not show the selector when showDeviceSelector is false (default)', async () => {
const enumerateDevicesMock = vi.fn().mockResolvedValue([
{ kind: 'audioinput', deviceId: 'dev1', label: 'Built-in Mic' },
{ kind: 'audioinput', deviceId: 'dev2', label: 'USB Headset' },
]);
Object.defineProperty(globalThis.navigator, 'mediaDevices', {
value: { getUserMedia: getUserMediaMock, enumerateDevices: enumerateDevicesMock },
configurable: true,
writable: true,
});
render(<MicCloudComposer disabled={false} onSubmit={vi.fn()} />);
await waitFor(() => {
expect(
screen.queryByRole('combobox', { name: /microphone device/i })
).not.toBeInTheDocument();
expect(enumerateDevicesMock).not.toHaveBeenCalled();
});
});
it('shows the selector disabled when only one device is available', async () => {
const enumerateDevicesMock = vi
.fn()
.mockResolvedValue([{ kind: 'audioinput', deviceId: 'dev1', label: 'Built-in Mic' }]);
Object.defineProperty(globalThis.navigator, 'mediaDevices', {
value: { getUserMedia: getUserMediaMock, enumerateDevices: enumerateDevicesMock },
configurable: true,
writable: true,
});
render(<MicCloudComposer disabled={false} onSubmit={vi.fn()} showDeviceSelector />);
const select = await screen.findByRole('combobox', { name: /microphone device/i });
expect(select).toBeInTheDocument();
expect(select).toBeDisabled();
});
it('falls back to "Microphone N" label when browser hides labels before permission', async () => {
const enumerateDevicesMock = vi.fn().mockResolvedValue([
{ kind: 'audioinput', deviceId: 'dev1', label: '' },
{ kind: 'audioinput', deviceId: 'dev2', label: '' },
]);
Object.defineProperty(globalThis.navigator, 'mediaDevices', {
value: { getUserMedia: getUserMediaMock, enumerateDevices: enumerateDevicesMock },
configurable: true,
writable: true,
});
render(<MicCloudComposer disabled={false} onSubmit={vi.fn()} showDeviceSelector />);
await waitFor(() => expect(screen.queryByRole('combobox')).toBeInTheDocument());
expect(screen.getByText('Microphone 1')).toBeInTheDocument();
expect(screen.getByText('Microphone 2')).toBeInTheDocument();
});
it('passes the selected deviceId as an exact constraint to getUserMedia', async () => {
const enumerateDevicesMock = vi.fn().mockResolvedValue([
{ kind: 'audioinput', deviceId: 'dev1', label: 'Built-in Mic' },
{ kind: 'audioinput', deviceId: 'dev2', label: 'USB Headset' },
]);
transcribeCloudMock.mockResolvedValueOnce('hello');
Object.defineProperty(globalThis.navigator, 'mediaDevices', {
value: { getUserMedia: getUserMediaMock, enumerateDevices: enumerateDevicesMock },
configurable: true,
writable: true,
});
render(<MicCloudComposer disabled={false} onSubmit={vi.fn()} showDeviceSelector />);
// Wait for the selector to appear and pick the second device
const select = await screen.findByRole('combobox', { name: /microphone device/i });
fireEvent.change(select, { target: { value: 'dev2' } });
fireEvent.click(screen.getByRole('button', { name: /start recording/i }));
await waitFor(() => expect(getUserMediaMock).toHaveBeenCalled());
expect(getUserMediaMock).toHaveBeenCalledWith(
expect.objectContaining({ audio: expect.objectContaining({ deviceId: { exact: 'dev2' } }) })
);
});
it('refreshes device labels after getUserMedia permission is granted', async () => {
const enumerateDevicesMock = vi
.fn()
// First call (on mount): labels hidden
.mockResolvedValueOnce([
{ kind: 'audioinput', deviceId: 'dev1', label: '' },
{ kind: 'audioinput', deviceId: 'dev2', label: '' },
])
// Second call (post-permission): real labels
.mockResolvedValueOnce([
{ kind: 'audioinput', deviceId: 'dev1', label: 'Built-in Mic' },
{ kind: 'audioinput', deviceId: 'dev2', label: 'USB Headset' },
]);
transcribeCloudMock.mockResolvedValueOnce('ok');
Object.defineProperty(globalThis.navigator, 'mediaDevices', {
value: { getUserMedia: getUserMediaMock, enumerateDevices: enumerateDevicesMock },
configurable: true,
writable: true,
});
render(<MicCloudComposer disabled={false} onSubmit={vi.fn()} showDeviceSelector />);
// Mount enumerate ran — labels are blank placeholders
await waitFor(() => expect(screen.queryByRole('combobox')).toBeInTheDocument());
expect(screen.getByText('Microphone 1')).toBeInTheDocument();
// Start recording → triggers the post-permission refresh
fireEvent.click(screen.getByRole('button', { name: /start recording/i }));
await waitFor(() =>
expect(screen.getByRole('button', { name: /stop recording and send/i })).toBeInTheDocument()
);
// After permission, real labels should now be visible
await waitFor(() => expect(screen.getByText('Built-in Mic')).toBeInTheDocument());
expect(screen.getByText('USB Headset')).toBeInTheDocument();
});
it('handles enumerateDevices throwing gracefully (no crash, selector hidden)', async () => {
const enumerateDevicesMock = vi.fn().mockRejectedValue(new Error('NotAllowed'));
Object.defineProperty(globalThis.navigator, 'mediaDevices', {
value: { getUserMedia: getUserMediaMock, enumerateDevices: enumerateDevicesMock },
configurable: true,
writable: true,
});
render(<MicCloudComposer disabled={false} onSubmit={vi.fn()} showDeviceSelector />);
await waitFor(() => expect(enumerateDevicesMock).toHaveBeenCalled());
// Selector requires >1 device; error yields 0 → selector stays hidden
expect(screen.queryByRole('combobox', { name: /microphone device/i })).not.toBeInTheDocument();
// Composer still functional
expect(screen.getByText('Tap and speak')).toBeInTheDocument();
});
});
+120 -44
View File
@@ -4,6 +4,12 @@ import { useEffect, useRef, useState } from 'react';
import { transcribeCloud } from './voice/sttClient';
import { encodeBlobToWav } from './voice/wavEncoder';
/** Minimal descriptor for an audio input device. */
interface AudioInputDevice {
deviceId: string;
label: string;
}
const composerLog = debug('human:mic-composer');
/** MIME types MediaRecorder will be asked to use, in priority order.
@@ -34,6 +40,8 @@ export interface MicCloudComposerProps {
* passing a hint is meaningfully more accurate than auto-detect on
* short utterances. Set to empty string to let Scribe auto-detect. */
language?: string;
/** Show a microphone device selector beneath the button. Defaults to false. */
showDeviceSelector?: boolean;
}
type RecordingState = 'idle' | 'recording' | 'transcribing';
@@ -52,8 +60,11 @@ export function MicCloudComposer({
onSubmit,
onError,
language = 'en',
showDeviceSelector = false,
}: MicCloudComposerProps) {
const [state, setState] = useState<RecordingState>('idle');
const [devices, setDevices] = useState<AudioInputDevice[]>([]);
const [selectedDeviceId, setSelectedDeviceId] = useState<string>('');
const recorderRef = useRef<MediaRecorder | null>(null);
const streamRef = useRef<MediaStream | null>(null);
const chunksRef = useRef<Blob[]>([]);
@@ -85,6 +96,33 @@ export function MicCloudComposer({
};
}, []);
// Enumerate audio input devices when the selector is shown, and refresh the
// list after the user grants mic permission (labels are hidden until then).
useEffect(() => {
if (!showDeviceSelector) return;
async function loadDevices() {
if (!navigator.mediaDevices?.enumerateDevices) return;
try {
const all = await navigator.mediaDevices.enumerateDevices();
const inputs = all
.filter(d => d.kind === 'audioinput')
.map((d, i) => ({ deviceId: d.deviceId, label: d.label || `Microphone ${i + 1}` }));
setDevices(inputs);
// Keep the selected device valid; fall back to default.
setSelectedDeviceId(prev =>
inputs.some(d => d.deviceId === prev) ? prev : (inputs[0]?.deviceId ?? '')
);
composerLog('enumerated %d audio inputs', inputs.length);
} catch (err) {
composerLog('enumerateDevices failed: %s', err);
}
}
void loadDevices();
const onDeviceChange = () => void loadDevices();
navigator.mediaDevices?.addEventListener?.('devicechange', onDeviceChange);
return () => navigator.mediaDevices?.removeEventListener?.('devicechange', onDeviceChange);
}, [showDeviceSelector]);
// Spacebar = tap-to-toggle (#1471). Scoped to whatever surface mounts
// this composer — today only the Human agent page. The listener lives
// on the window so the user doesn't have to click the mascot stage
@@ -196,6 +234,7 @@ export function MicCloudComposer({
// transcription drops words in our flow)
stream = await navigator.mediaDevices.getUserMedia({
audio: {
...(selectedDeviceId ? { deviceId: { exact: selectedDeviceId } } : {}),
channelCount: 1,
sampleRate: 48000,
echoCancellation: true,
@@ -203,11 +242,32 @@ export function MicCloudComposer({
autoGainControl: true,
},
});
// After the first successful grant, refresh device labels (they are
// blank until the user has given permission).
if (showDeviceSelector) {
const all = await navigator.mediaDevices.enumerateDevices();
const inputs = all
.filter(d => d.kind === 'audioinput')
.map((d, i) => ({ deviceId: d.deviceId, label: d.label || `Microphone ${i + 1}` }));
setDevices(inputs);
}
} catch (err) {
startInFlightRef.current = false;
const msg = err instanceof Error ? err.message : String(err);
composerLog('getUserMedia rejected: %s', msg);
onError?.(`Microphone permission denied: ${msg}`);
if (err instanceof DOMException) {
if (err.name === 'NotAllowedError' || err.name === 'SecurityError') {
onError?.(`Microphone permission denied: ${msg}`);
} else if (err.name === 'NotFoundError' || err.name === 'OverconstrainedError') {
onError?.('Selected microphone is unavailable — try a different device.');
} else if (err.name === 'NotReadableError') {
onError?.('Microphone is in use by another application.');
} else {
onError?.(`Microphone error: ${msg}`);
}
} else {
onError?.(`Microphone error: ${msg}`);
}
return;
}
@@ -365,50 +425,66 @@ export function MicCloudComposer({
: 'Tap and speak';
return (
<div className="flex items-center justify-center gap-3">
<button
type="button"
aria-label={isRecording ? 'Stop recording and send' : 'Start recording'}
onClick={() => (isRecording ? stopRecording() : void startRecording())}
disabled={buttonDisabled}
className={`relative w-14 h-14 flex items-center justify-center rounded-full text-white shadow-soft transition-colors disabled:opacity-40 disabled:cursor-not-allowed ${
isRecording ? 'bg-coral-500 hover:bg-coral-400' : 'bg-primary-500 hover:bg-primary-600'
}`}>
{isRecording && (
<span className="absolute inset-0 rounded-full bg-coral-500/40 animate-ping" />
)}
{isBusy ? (
<svg className="w-5 h-5 animate-spin" fill="none" viewBox="0 0 24 24">
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
<div className="flex flex-col items-center gap-2">
{showDeviceSelector && devices.length > 0 && (
<select
aria-label="Microphone device"
value={selectedDeviceId}
onChange={e => setSelectedDeviceId(e.target.value)}
disabled={state !== 'idle' || devices.length <= 1}
className="text-xs text-stone-600 bg-stone-100 border border-stone-200 rounded px-2 py-1 max-w-[220px] truncate disabled:opacity-50">
{devices.map(d => (
<option key={d.deviceId} value={d.deviceId}>
{d.label}
</option>
))}
</select>
)}
<div className="flex items-center justify-center gap-3">
<button
type="button"
aria-label={isRecording ? 'Stop recording and send' : 'Start recording'}
onClick={() => (isRecording ? stopRecording() : void startRecording())}
disabled={buttonDisabled}
className={`relative w-14 h-14 flex items-center justify-center rounded-full text-white shadow-soft transition-colors disabled:opacity-40 disabled:cursor-not-allowed ${
isRecording ? 'bg-coral-500 hover:bg-coral-400' : 'bg-primary-500 hover:bg-primary-600'
}`}>
{isRecording && (
<span className="absolute inset-0 rounded-full bg-coral-500/40 animate-ping" />
)}
{isBusy ? (
<svg className="w-5 h-5 animate-spin" fill="none" viewBox="0 0 24 24">
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
) : (
<svg
className="relative w-6 h-6"
fill="none"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
) : (
<svg
className="relative w-6 h-6"
fill="none"
stroke="currentColor"
strokeWidth={1.8}
viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 18.75a6 6 0 006-6v-1.5m-6 7.5a6 6 0 01-6-6v-1.5m6 7.5v3.75m-3.75 0h7.5M12 15.75a3 3 0 01-3-3V4.5a3 3 0 116 0v8.25a3 3 0 01-3 3z"
/>
</svg>
)}
</button>
<span className="text-xs text-stone-500 select-none">{label}</span>
strokeWidth={1.8}
viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 18.75a6 6 0 006-6v-1.5m-6 7.5a6 6 0 01-6-6v-1.5m6 7.5v3.75m-3.75 0h7.5M12 15.75a3 3 0 01-3-3V4.5a3 3 0 116 0v8.25a3 3 0 01-3 3z"
/>
</svg>
)}
</button>
<span className="text-xs text-stone-500 select-none">{label}</span>
</div>
</div>
);
}
+1
View File
@@ -1676,6 +1676,7 @@ const Conversations = ({ variant = 'page', composer = 'text' }: ConversationsPro
disabled={composerInteractionBlocked || !selectedThreadId}
onSubmit={text => handleSendMessage(text)}
onError={message => setSendError(chatSendError('voice_transcription', message))}
showDeviceSelector
/>
) : inputMode === 'text' ? (
<div className="flex items-end gap-3">
+2 -2
View File
@@ -137,8 +137,8 @@ impl UnifiedMemory {
// We run the ALTER TABLE statements directly on `conn` before wrapping it in Arc<Mutex>.
// SQL arrays are defined as constants in profile.rs to avoid duplication.
{
use super::profile::{PHASE3_COLUMNS_SQL, PHASE3_INDEXES_SQL};
for sql in PHASE3_COLUMNS_SQL.iter().chain(PHASE3_INDEXES_SQL.iter()) {
use super::profile::PHASE3_COLUMNS_SQL;
for sql in PHASE3_COLUMNS_SQL.iter() {
match conn.execute(sql, []) {
Ok(_) => tracing::debug!("[profile:init] applied: {sql}"),
Err(e) => {
@@ -44,12 +44,6 @@ CREATE TABLE IF NOT EXISTS user_profile (
CREATE INDEX IF NOT EXISTS idx_profile_type
ON user_profile(facet_type);
CREATE INDEX IF NOT EXISTS idx_profile_state
ON user_profile(state);
CREATE INDEX IF NOT EXISTS idx_profile_class
ON user_profile(class);
"#;
/// Phase 3 ALTER TABLE statements for adding new columns to existing databases.
@@ -65,14 +59,6 @@ pub const PHASE3_COLUMNS_SQL: &[&str] = &[
"ALTER TABLE user_profile ADD COLUMN cue_families_json TEXT",
];
/// Phase 3 index creation statements. Idempotent (IF NOT EXISTS).
///
/// Shared between `migrate_profile_schema` and `init.rs`.
pub const PHASE3_INDEXES_SQL: &[&str] = &[
"CREATE INDEX IF NOT EXISTS idx_profile_state ON user_profile(state)",
"CREATE INDEX IF NOT EXISTS idx_profile_class ON user_profile(class)",
];
/// Idempotent schema migration for existing databases.
///
/// New installs get the full schema from `PROFILE_INIT_SQL`. Existing databases
@@ -100,13 +86,6 @@ pub fn migrate_profile_schema(conn: &Arc<Mutex<Connection>>) {
}
}
}
// Ensure the new indexes exist (idempotent — IF NOT EXISTS).
for sql in PHASE3_INDEXES_SQL {
if let Err(e) = conn.execute(sql, []) {
tracing::warn!("[profile] index creation failed (non-fatal): {sql}: {e}");
}
}
}
// ── FacetState ───────────────────────────────────────────────────────────────