Merge pull request #5729 from deepseek-harness/feat/voice-input-device

feat(voice-input): select microphone with native input level
This commit is contained in:
Yudong Han authored and GitHub committed 2026-10-08 12:28:15 +08:00
commit 647b2b5f3b
22 files changed
+929 -43

No files matched your search

+93 -7
View File
@@ -62,10 +62,27 @@ it.skipIf(webSnapshotMode() === 'record')('guides voice setup, records from stan
resources.browser = browser
const page = await newEnglishPage(browser), tripwire = watchConsole(page)
await page.addInitScript(() => {
const holder = window as Window & { voiceTestRecorder?: MediaRecorder }
const NativeRecorder = window.MediaRecorder
window.MediaRecorder = class extends NativeRecorder {
constructor(stream: MediaStream, options?: MediaRecorderOptions) { super(stream, options); holder.voiceTestRecorder = this }
const holder = window as Window & {
voiceTestStreams?: MediaStream[]
voiceTestExcluded?: string[]
voiceTestNames?: Record<string, string>
}
holder.voiceTestStreams = []
holder.voiceTestExcluded = []
holder.voiceTestNames = {}
const enumerateDevices = navigator.mediaDevices.enumerateDevices.bind(navigator.mediaDevices)
navigator.mediaDevices.enumerateDevices = async () => (await enumerateDevices())
.filter(device => !holder.voiceTestExcluded!.includes(device.deviceId))
.map((device) => {
const label = holder.voiceTestNames![device.deviceId]
if (label) Object.defineProperty(device, 'label', { value: label })
return device
})
const getUserMedia = navigator.mediaDevices.getUserMedia.bind(navigator.mediaDevices)
navigator.mediaDevices.getUserMedia = async (constraints) => {
const stream = await getUserMedia(constraints)
holder.voiceTestStreams!.push(stream)
return stream
}
})
await page.goto(scaffold.authenticatedUrl)
@@ -120,8 +137,75 @@ it.skipIf(webSnapshotMode() === 'record')('guides voice setup, records from stan
expect(recognize).not.toHaveBeenCalled()
preparation = { phase: 'standby' }
for (const listener of readinessListeners) listener()
const devicePicker = page.getByRole('combobox', { name: 'Input device', exact: true })
const level = page.getByRole('img', { name: 'Microphone input level', exact: true })
await level.waitFor()
const devices = await page.evaluate(async () => (await navigator.mediaDevices.enumerateDevices())
.filter(device => device.kind === 'audioinput' && device.deviceId !== 'default'))
const selectedDevice = devices[0]!
await devicePicker.selectOption(selectedDevice.deviceId)
await expect.poll(() => devicePicker.inputValue()).toBe(selectedDevice.deviceId)
await expect.poll(() => page.evaluate(() => {
const streams = (window as Window & { voiceTestStreams?: MediaStream[] }).voiceTestStreams!
return streams.filter(stream => stream.getTracks().some(track => track.readyState === 'live')).length
})).toBe(1)
await compareOrRefreshGolden(fileURLToPath(new URL('../../../snapshots/web/voice-input/device.expected.md', import.meta.url)),
await captureStableAria(page, '[data-voice-input-device]', scaffold.workspaceCwd), webSnapshotMode())
const acquisitions = await page.evaluate(() => (window as Window & { voiceTestStreams?: MediaStream[] }).voiceTestStreams!.length)
const otherDevice = devices[1]!
for (const excluded of [[otherDevice.deviceId], []]) {
await page.evaluate((ids) => {
(window as Window & { voiceTestExcluded?: string[] }).voiceTestExcluded = ids
navigator.mediaDevices.dispatchEvent(new Event('devicechange'))
}, excluded)
await expect.poll(() => page.getByRole('option', { name: otherDevice.label, exact: true }).count()).toBe(excluded.length ? 0 : 1)
expect(await page.evaluate(() => (window as Window & { voiceTestStreams?: MediaStream[] }).voiceTestStreams!.length)).toBe(acquisitions)
await level.waitFor()
}
await page.evaluate((id) => {
const holder = window as Window & { voiceTestExcluded?: string[]; voiceTestStreams?: MediaStream[] }
holder.voiceTestExcluded = [id]
const track = holder.voiceTestStreams!.at(-1)!.getAudioTracks()[0]!
track.stop(); track.dispatchEvent(new Event('ended'))
navigator.mediaDevices.dispatchEvent(new Event('devicechange'))
}, selectedDevice.deviceId)
const missing = page.getByRole('option', { name: `${selectedDevice.label} — Unavailable`, exact: true })
await expect.poll(() => missing.count()).toBe(1)
expect(await missing.isDisabled()).toBe(true)
await level.waitFor({ state: 'hidden' })
expect(await devicePicker.evaluate(element => (element as HTMLSelectElement).selectedOptions[0]?.textContent)).toContain('Unavailable')
await compareOrRefreshGolden(fileURLToPath(new URL('../../../snapshots/web/voice-input/device-missing.expected.md', import.meta.url)),
await captureStableAria(page, '[data-voice-input-device]', scaffold.workspaceCwd), webSnapshotMode())
await page.evaluate(() => {
(window as Window & { voiceTestExcluded?: string[] }).voiceTestExcluded = []
navigator.mediaDevices.dispatchEvent(new Event('devicechange'))
})
await expect.poll(() => devicePicker.evaluate(element => (element as HTMLSelectElement).selectedOptions[0]?.textContent))
.not.toContain('Unavailable')
await level.waitFor()
expect(await page.evaluate(() => (window as Window & { voiceTestStreams?: MediaStream[] })
.voiceTestStreams!.length)).toBe(acquisitions + 1)
await devicePicker.blur()
await level.waitFor()
const longName = 'USB studio microphone — conference room recording input'
await page.evaluate(({ id, label }) => {
(window as Window & { voiceTestNames?: Record<string, string> }).voiceTestNames![id] = label
navigator.mediaDevices.dispatchEvent(new Event('devicechange'))
}, { id: selectedDevice.deviceId, label: longName })
await expect.poll(() => devicePicker.evaluate(element => (element as HTMLSelectElement).selectedOptions[0]?.textContent)).toBe(longName)
await page.setViewportSize({ width: 360, height: 900 })
await level.waitFor()
await devicePicker.scrollIntoViewIfNeeded()
await compareOrRefreshGolden(fileURLToPath(new URL('../../../snapshots/web/voice-input/device-long.expected.md', import.meta.url)),
await captureStableAria(page, '[data-voice-input-device]', scaffold.workspaceCwd), webSnapshotMode())
const pickerBounds = (await devicePicker.boundingBox())!, levelBounds = (await level.boundingBox())!
expect(levelBounds.x).toBeGreaterThan(pickerBounds.x + pickerBounds.width / 2)
expect(levelBounds.x + levelBounds.width).toBeLessThan(pickerBounds.x + pickerBounds.width)
await page.setViewportSize({ width: 1280, height: 900 })
await page.getByRole('button', { name: 'New session', exact: true }).filter({ hasText: 'New Session' }).click()
await mic.waitFor()
await expect.poll(() => page.evaluate(() => (window as Window & { voiceTestStreams?: MediaStream[] }).voiceTestStreams!
.every(stream => stream.getTracks().every(track => track.readyState === 'ended')))).toBe(true)
await page.getByRole('button', { name: 'Plugins', exact: true }).click()
await page.locator('[data-plugin-package="@deepseek-ai/dsh-experimental-voice-input-bundle"]').waitFor()
expect(await page.locator('[data-plugin-detail]').count()).toBe(0)
@@ -134,6 +218,8 @@ it.skipIf(webSnapshotMode() === 'record')('guides voice setup, records from stan
await input.fill(prefix)
await mic.click()
await page.getByRole('button', { name: 'Stop and transcribe', exact: true }).waitFor()
expect(await page.evaluate(() => (window as Window & { voiceTestStreams?: MediaStream[] }).voiceTestStreams!
.at(-1)!.getAudioTracks()[0]!.getSettings().deviceId)).toBe(selectedDevice.deviceId)
await compareOrRefreshGolden(recordingExpected,
await captureStableAria(page, '[data-composer-card]', scaffold.workspaceCwd), webSnapshotMode())
expect(await page.getByRole('dialog').count()).toBe(0)
@@ -187,11 +273,11 @@ it.skipIf(webSnapshotMode() === 'record')('guides voice setup, records from stan
await mic.click()
await stop.waitFor()
await page.evaluate(() => {
const recorder = (window as Window & { voiceTestRecorder?: MediaRecorder }).voiceTestRecorder
if (!recorder) throw new Error('No active test microphone')
recorder.dispatchEvent(new Event('error'))
const track = (window as Window & { voiceTestStreams?: MediaStream[] }).voiceTestStreams!.at(-1)!.getAudioTracks()[0]!
track.stop(); track.dispatchEvent(new Event('ended'))
})
await page.getByRole('button', { name: 'Record again', exact: true }).waitFor()
await page.evaluate(() => { navigator.mediaDevices.dispatchEvent(new Event('devicechange')) })
expect(await stop.count()).toBe(0)
await compareOrRefreshGolden(interruptedExpected,
await captureStableAria(page, '[data-composer-card]', scaffold.workspaceCwd), webSnapshotMode())
@@ -15,11 +15,11 @@
en: d152484eb41ac6b4
zh: 364c80ee00a374ff
/deepseek-ai-dsh-experimental-client-ui-voice-input/use-this-package:
en: 44f25ad14ceda123
zh: 43c4680be42af1cf
en: 4db1c67430cfa691
zh: 484808eeff0d7692
/deepseek-ai-dsh-experimental-client-ui-voice-input/understand-the-implementation:
en: b3a7bb1a4f0b3c1e
zh: 233d1c5ea2b16de8
en: 8fe96eeef9afe7d7
zh: 11936f647ded39eb
/deepseek-ai-dsh-experimental-client-ui-voice-input/further-exploration:
en: 5c64426add9d79d6
zh: 4e06aa2ead8d2ccd
@@ -27,6 +27,8 @@ This optional browser plugin adds an outline microphone icon between the model s
Enable the [voice input bundle](../voice-input-bundle/README.md) from Plugins. After cache inspection, missing models trigger a setup prompt with Go to setup and Later actions. Go to setup opens bundle details; Download and prepare starts installation there. Intact cached models need no prompt or repeated installation. Before recognition is ready, guidance appears only on click, with no microphone tooltip. Clicking the microphone opens the same guidance dialog; its action opens the voice bundle details without recording or downloading. Cache inspection, preparation in progress, failures and disconnected services instead offer navigation to status, progress or error details. The dialog moves focus to its navigation action and keeps Tab traversal inside it; Later or Escape restores the previous focus without changing or submitting the draft. Before preparation, local providers can show their estimated disk, memory and setup-time requirements. The preparation summary starts collapsed; expand it to inspect completed, current and pending steps. Once ready, click the microphone, allow access and click Stop to transcribe. Cancel or Escape discards capture. Recognition feedback stays inside the toolbar. Provider and language preferences are saved from bundle details through the Settings service. Browser microphone access requires HTTPS or loopback and operating-system permission.
Use the native **Input device** selector in bundle details to follow the system default or select a browser microphone. System default shows the current device name when available. While bundle details are active, the selected input's live level remains visible at the right of the field; selecting another input restarts the preview for that device. Hiding or leaving the page, or disabling the plugin, releases the preview, including late permission grants. The browser remembers the device for subsequent recordings independently of Host recognition settings. Device insertion and removal refresh the options without interrupting an available selected input. A disconnected selection retains its name and position as a disabled option with an Unavailable label. Reconnecting while the page is active restores its preview; choosing another input removes the unavailable option. Interrupted recordings require an explicit restart. Permission denial keeps other choices accessible; an explicitly selected device never silently falls back to another input.
Before downloading or retrying, **Model download source** offers Automatic and the origins advertised by the Host. SenseVoice normally offers Hugging Face and HF-Mirror; a fixed private deployment exposes only its configured origin. Manual selection uses only that source, while Automatic preserves the provider’s comparison and fallback policy. The choice is retained for retries in the current card, without changing deployment configuration or recognition preferences. Source controls are unavailable during preparation and hidden when resources are ready.
-----
@@ -37,7 +39,7 @@ Before downloading or retrying, **Model download source** offers Automatic and t
<details>
<summary>Maintainer details — click to expand</summary>
Native MediaRecorder captures audio and Web Audio converts it to the Host PCM format after flushing the final recorder chunks. Window blur during the microphone permission request does not cancel capture; blur during recording does. Capture failures immediately end the recording UI and offer retry inside the toolbar. Cancellation, failure and plugin disposal share one resource-release promise; the plugin retains ownership until AudioContext closure settles. The original editor selection carries a draft revision: changed drafts retain the transcript for explicit insertion or discard beside the microphone. Session changes, hiding the page during capture and plugin disposal invalidate late results and release tracks. One plugin-owned readiness subscription serves the composer, installation prompt and details. These views use the existing Slot lifecycle and own no preparation tasks.
Native MediaRecorder captures audio and Web Audio converts it to the Host PCM format after flushing the final recorder chunks. Input-level previews use Web Audio without creating a MediaRecorder. Window blur during the microphone permission request does not cancel capture; blur during recording does. Capture failures immediately end the recording UI and offer retry inside the toolbar. Cancellation, failure and plugin disposal share one resource-release promise; the plugin retains ownership until AudioContext closure settles. The original editor selection carries a draft revision: changed drafts retain the transcript for explicit insertion or discard beside the microphone. Session changes, hiding the page during capture and plugin disposal invalidate late results and release tracks. One plugin-owned readiness subscription serves the composer, installation prompt and details. These views use the existing Slot lifecycle and own no preparation tasks.
</details>
@@ -27,6 +27,8 @@ kind: "package-reference"
在插件管理页启用[语音输入 Bundle](../voice-input-bundle/README.zh.md)。缓存检查后,缺少模型时弹出安装引导,可选择“前往安装”或“稍后”。“前往安装”打开 Bundle 详情,再点击“下载并准备”才开始安装。缓存完整时不弹引导,也无需重复安装。识别未就绪时不显示麦克风悬停提示,点击后才打开同一个引导弹窗;点击引导按钮进入语音 Bundle 详情,不会录音或下载。缓存检查、正在准备、准备失败或服务断连时,弹窗改为引导查看状态、进度或错误详情。弹窗将焦点移到导航按钮,并将 Tab 切换约束在弹窗内;选择“稍后”或按 Escape 关闭时恢复此前焦点,不改变或提交草稿。准备前,本地 Provider 可展示磁盘、内存与安装时间的估算。准备摘要默认折叠;展开后可查看已完成、当前和未开始的步骤。就绪后点击麦克风,允许访问,再点击停止以转写。取消或 Escape 丢弃录音。识别反馈留在工具栏内部。Provider 和语言偏好在 Bundle 详情中通过 Settings 服务保存。浏览器麦克风访问需要 HTTPS 或环回地址,以及操作系统权限。
在 Bundle 详情中使用原生**输入设备**选择器,跟随系统默认设备或选择浏览器可用的麦克风。系统默认在名称可用时显示当前设备名。Bundle 详情处于活动状态时,已选输入的实时电平持续显示在字段右侧;切换设备会为该设备重新启动预览。隐藏或离开页面、禁用插件时会释放预览,包括随后才批准的麦克风授权。浏览器会记住设备选择并用于后续录音,与 Host 的识别设置分别保存。插入和拔出设备会刷新选项,不打断仍然可用的已选输入。已选设备断开后会作为禁用选项保留名称和位置,并显示“不可用”。页面处于活动状态时,重新连接会恢复预览;改选其他输入后移除不可用选项。录音中断后需要手动重新开始。权限被拒绝时仍可选择其他设备;明确指定的设备不会静默回退到其他输入。
下载或重试前,**模型下载源**提供自动选择及 Host 公布的源。SenseVoice 默认提供 Hugging Face 和 HF-Mirror;固定私有部署仅显示配置的源。手动选择只使用指定源,自动选择沿用提供方的比较与回退策略。当前卡片中的重试会保留选择,不修改部署配置或识别偏好。准备期间不能改源,资源就绪后隐藏源选择器。
-----
@@ -37,7 +39,7 @@ kind: "package-reference"
<details>
<summary>维护者信息 — 点击展开</summary>
原生 MediaRecorder 捕获音频,等待最后一块数据后由 Web Audio 转换为 Host 的 PCM 格式。等待麦克风授权时窗口失焦不会取消采集;录音期间失焦会取消。采集失败立即结束录音界面,并在活动栏内提供重试。取消、失败和插件释放共享同一个资源释放 Promise;插件保留所有权,直到 AudioContext 关闭任务结束。原编辑器选区携带草稿版本:草稿变化时,转写文字保留在麦克风旁,供显式插入或丢弃。切换 Session、录音期间隐藏页面和释放插件会使迟到结果失效并释放轨道。插件拥有的一份准备状态订阅服务于输入框、安装引导和详情。这些视图使用既有 Slot 生命周期,不拥有准备任务。
原生 MediaRecorder 捕获音频,等待最后一块数据后由 Web Audio 转换为 Host 的 PCM 格式。输入电平预览使用 Web Audio,不创建 MediaRecorder。等待麦克风授权时窗口失焦不会取消采集;录音期间失焦会取消。采集失败立即结束录音界面,并在活动栏内提供重试。取消、失败和插件释放共享同一个资源释放 Promise;插件保留所有权,直到 AudioContext 关闭任务结束。原编辑器选区携带草稿版本:草稿变化时,转写文字保留在麦克风旁,供显式插入或丢弃。切换 Session、录音期间隐藏页面和释放插件会使迟到结果失效并释放轨道。插件拥有的一份准备状态订阅服务于输入框、安装引导和详情。这些视图使用既有 Slot 生命周期,不拥有准备任务。
</details>
@@ -0,0 +1,140 @@
/** Device selection owns microphone previews while its page is active. */
import { useEffect, useRef, useState } from 'react'
import type { InjectFace, PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
import type { VoiceInputInjected } from './VoiceInput.tsx'
import { RecordingError, type Recording } from './audio.ts'
import type { MicrophoneDevice } from './microphone-device.ts'
import { failureText } from './failure-text.ts'
import { NS } from './locales.ts'
import css from './VoiceInput.module.css'
interface DeviceChoice extends MicrophoneDevice { unavailable?: boolean }
function InputLevel({ recording, label }: { recording: Recording; label: string }) {
const meter = useRef<HTMLSpanElement>(null)
useEffect(() => {
const bars = Array.from((meter.current as HTMLSpanElement).children)
let frame: number
const draw = (): void => {
const decibels = 20 * Math.log10(Math.max(recording.amplitude(), 0.00001))
const count = Math.ceil(Math.max(0, Math.min(1, (decibels + 60) / 60)) * bars.length)
for (const [index, bar] of bars.entries()) bar.setAttribute('data-active', String(index < count))
frame = requestAnimationFrame(draw)
}
draw()
return () => { cancelAnimationFrame(frame) }
}, [recording])
return <span ref={meter} className={css.inputLevel} role="img" aria-label={label}>
{Array.from({ length: 8 }, (_, index) => <span key={index} />)}
</span>
}
/** Render the selected input and preview its measured level without recording audio. */
export function MicrophonePicker({ useMicrophoneDevice, selectMicrophone, createRecording, t }:
Pick<InjectFace<VoiceInputInjected>, 'useMicrophoneDevice' | 'selectMicrophone' | 'createRecording'> & PropsLocale<typeof NS>) {
const selected = useMicrophoneDevice(value => value)
const [devices, setDevices] = useState<DeviceChoice[]>([
{ id: selected.id, label: selected.id === '' ? '' : selected.label },
])
const [preview, setPreview] = useState<Recording>(), [error, setError] = useState<{ failure: unknown }>()
useEffect(() => {
const media = (navigator as Partial<Navigator>).mediaDevices
const lifetime = new AbortController()
let capture: Recording | undefined, enumeration = 0
const release = (): void => {
if (capture) void capture.dispose().catch(() => undefined)
capture = undefined
}
setPreview(undefined); setError(undefined)
const fail = (failure: unknown): void => {
if (lifetime.signal.aborted) return
setError({ failure })
}
const acquire = async (): Promise<void> => {
const recording = createRecording()
capture = recording
const interrupted = (failure: RecordingError): void => {
if (capture !== recording || lifetime.signal.aborted) return
release(); setPreview(undefined); fail(failure)
void refresh()
}
try {
await recording.preview(interrupted)
if (capture !== recording || lifetime.signal.aborted) return
setPreview(recording); setError(undefined)
void refresh()
} catch (failure) {
if (capture !== recording || lifetime.signal.aborted) return
release(); setPreview(undefined); fail(failure)
}
}
const refresh = async (): Promise<void> => {
const request = ++enumeration
try {
if (!media) throw new RecordingError('unavailable')
const inputs = (await media.enumerateDevices()).filter(device => device.kind === 'audioinput')
if (lifetime.signal.aborted || request !== enumeration) return
// Before permission, browsers may hide device ids and labels; absence then does not establish disconnection.
const known = inputs.length === 0 || inputs.some(device => device.label !== '')
const missing = selected.id === '' ? inputs.length === 0 : known && !inputs.some(device => device.deviceId === selected.id)
const system = inputs.find(device => device.deviceId === 'default')
const activeDefault = selected.id === '' ? capture?.deviceInfo() : undefined
const groupId = activeDefault?.groupId || system?.groupId
const physicalDefault = groupId ? inputs.find(device => device.groupId === groupId
&& device.deviceId !== 'default' && device.deviceId !== 'communications') : undefined
const defaultLabel = physicalDefault?.label || activeDefault?.label || system?.label || ''
// Our system row replaces default; communications keeps the OS's separate default communications input.
const choices: DeviceChoice[] = [{ id: '', label: defaultLabel, unavailable: inputs.length === 0 },
...inputs.filter(device => device.deviceId !== '' && device.deviceId !== 'default').map(device => ({
id: device.deviceId, label: device.label,
}))]
setDevices((previous) => {
const next = [...choices]
if (selected.id !== '' && !next.some(device => device.id === selected.id)) {
const knownDevice = previous.find(device => device.id === selected.id)
next.splice(Math.max(1, previous.findIndex(device => device.id === selected.id)), 0,
{ id: selected.id, label: knownDevice?.label ?? '', unavailable: missing })
}
return next
})
if (missing) { release(); setPreview(undefined); setError(undefined) }
else if (!document.hidden && !capture) void acquire()
} catch (failure) { if (request === enumeration) fail(failure) }
}
const changed = (): void => { void refresh() }
const hidden = (): void => {
if (document.hidden) { release(); setPreview(undefined) }
else changed()
}
media?.addEventListener('devicechange', changed, { signal: lifetime.signal })
document.addEventListener('visibilitychange', hidden, { signal: lifetime.signal })
changed()
return () => {
lifetime.abort()
release()
}
}, [selected.id, createRecording])
const choices = devices.filter(device => !device.unavailable || device.id === selected.id || device.id === '')
const missing = choices.find(device => device.id === selected.id)?.unavailable
const deviceName = (device: MicrophoneDevice, index: number): string => {
if (device.id === '') return device.label ? t('systemMicrophoneNamed', { name: device.label }) : t('systemMicrophone')
return device.label || (device.id === selected.id ? selected.label : '')
|| (index > 0 ? t('unnamedMicrophone', { number: String(index) }) : t('unnamedSelectedMicrophone'))
}
return <div className={css.deviceRow} data-voice-input-device>
<label htmlFor="voice-input-device">{t('inputDevice')}</label>
<span className={css.deviceField}>
<select id="voice-input-device" value={selected.id} className={preview ? css.deviceSelectMeter : undefined}
onChange={(event) => {
const device = choices.find(item => item.id === event.target.value) as DeviceChoice
selectMicrophone({ id: device.id, label: device.id === '' ? '' : device.label })
}}>
{choices.map((device, index) => <option key={device.id} value={device.id} disabled={device.unavailable}>
{deviceName(device, index)}{device.unavailable ? ` — ${t('deviceUnavailable')}` : ''}
</option>)}
</select>
{preview && !missing && <span className={css.deviceMeter}><InputLevel recording={preview} label={t('inputLevel')} /></span>}
</span>
{error && <span className={css.deviceError} role="alert">{failureText(error.failure, t)}</span>}
</div>
}
@@ -4,6 +4,7 @@ import { Button, DisclosureRow, IconChevronDownOutlineRegular, StateDot, type St
import type { InjectFace, PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
import type { SpeechPreparationState, SpeechProviderId, SpeechProviderView, SpeechSelectionPatch } from '@deepseek-ai/dsh-experimental-speech-to-text/types'
import type { VoiceInputInjected } from './VoiceInput.tsx'
import { MicrophonePicker } from './MicrophonePicker.tsx'
import { NS } from './locales.ts'
import css from './VoiceInput.module.css'
@@ -129,7 +130,7 @@ export function PreparationCard({ provider, connected, prepare, cancelPreparatio
/** Recognition preferences and preparation cards shared by plugin details and Settings. */
export function VoicePreparation({ useSpeechReadiness, ...props }: Pick<InjectFace<VoiceInputInjected>,
'useSpeechReadiness' | 'configure' | 'prepare' | 'cancelPreparation'> & PropsLocale<typeof NS>) {
'useSpeechReadiness' | 'configure' | 'prepare' | 'cancelPreparation' | 'createRecording' | 'useMicrophoneDevice' | 'selectMicrophone'> & PropsLocale<typeof NS>) {
const readiness = useSpeechReadiness(value => value), catalog = readiness.catalog
const [saving, setSaving] = useState(false), [error, setError] = useState('')
const configure = async (patch: SpeechSelectionPatch): Promise<void> => {
@@ -143,6 +144,7 @@ export function VoicePreparation({ useSpeechReadiness, ...props }: Pick<InjectFa
}
return <div>
{catalog && <div className={css.preferences}>
<MicrophonePicker {...props} />
<label>{props.t('provider')}<select value={catalog.selection.providerId} disabled={!readiness.connected || saving}
onChange={(event) => { void configure({ providerId: event.target.value as SpeechProviderId }) }}>
{catalog.providers.map(provider => <option key={provider.id} value={provider.id}>{provider.name}</option>)}
@@ -25,7 +25,7 @@
.stepProgress { display: flex; flex-direction: column; gap: 6px; margin-top: 6px; }
.stepProgress small { font-size: 11px; color: var(--dsw-alias-label-tertiary); overflow-wrap: anywhere; }
.preferences { display: grid; gap: 12px; font-size: 13px; margin: 18px 0; }
.preferences label, .sourceChoice label { display: grid; grid-template-columns: 100px minmax(120px, 280px); align-items: center; gap: 12px; }
.deviceRow, .preferences label, .sourceChoice label { display: grid; grid-template-columns: 100px minmax(120px, 280px); align-items: center; gap: 12px; }
.preferences select, .sourceChoice select { border: 0.5px solid var(--dsw-alias-border-l1); border-radius: 6px; padding: 6px 10px; background: transparent; color: inherit; font: inherit; }
.sourceChoice { display: grid; gap: 6px; }
.preferences p { margin: 0; }
@@ -37,3 +37,16 @@
.setupEstimate dt { color: var(--dsw-alias-label-tertiary); }
.setupEstimate dd { margin: 0; }
.setupEstimate small { color: var(--dsw-alias-label-tertiary); font-size: 12px; }
.deviceField { position: relative; display: block; min-width: 0; }
.deviceField select { width: 100%; }
.deviceField select.deviceSelectMeter { padding-right: 72px; }
.deviceMeter { position: absolute; top: 50%; right: 24px; display: inline-flex; transform: translateY(-50%); pointer-events: none; }
.deviceError { grid-column: 2; min-width: 0; color: var(--dsw-alias-state-error-primary); overflow-wrap: anywhere; font-size: 12px; }
.inputLevel { display: inline-flex; align-items: center; gap: 3px; flex: none; }
.inputLevel > span { width: 3px; height: 12px; border-radius: 1px; background: var(--dsw-alias-border-l1); }
.inputLevel > span[data-active='true'] { background: var(--dsw-alias-state-success-primary); }
@media (max-width: 480px) {
.deviceRow, .preferences label, .sourceChoice label { grid-template-columns: minmax(0, 1fr); gap: 6px; }
.deviceError { grid-column: 1; }
}
@@ -5,7 +5,9 @@ import type { TokenSpan } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { TranscriptionRequest } from '@deepseek-ai/dsh-experimental-api-speech-to-text/types'
import type { SpeechPreparationOptions, SpeechProviderId, SpeechSelection, SpeechSelectionPatch, Transcript } from '@deepseek-ai/dsh-experimental-speech-to-text/types'
import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol'
import { RecordingError, audioBase64, type Recording } from './audio.ts'
import { audioBase64, type Recording } from './audio.ts'
import { failureText } from './failure-text.ts'
import type { MicrophoneDevice } from './microphone-device.ts'
import type { SpeechReadiness } from './readiness.ts'
import { Waveform } from './Waveform.tsx'
import { VoiceSetupDialog } from './VoiceSetupDialog.tsx'
@@ -19,6 +21,8 @@ export interface VoiceInputActions {
openSettings: () => void
/** @returns one microphone operation owned by the plugin lifecycle. */
createRecording: () => Recording
/** Remember the browser microphone used by previews and subsequent recordings. */
selectMicrophone: (device: MicrophoneDevice) => void
transcribe: (request: TranscriptionRequest, signal: AbortSignal) => Promise<RemoteResult<Transcript>>
prepare: (providerId: SpeechProviderId, options?: SpeechPreparationOptions) => Promise<void>
cancelPreparation: (providerId: SpeechProviderId) => Promise<void>
@@ -27,7 +31,7 @@ export interface VoiceInputActions {
/** Entry-injected Host readiness and microphone operations. */
export interface VoiceInputInjected extends VoiceInputActions {
hooks: { speechReadiness: HostObservable<SpeechReadiness> }
hooks: { speechReadiness: HostObservable<SpeechReadiness>; microphoneDevice: HostObservable<MicrophoneDevice> }
}
/** Composer-owned expansion and draft actions; preferences stay in plugin settings. */
@@ -95,8 +99,6 @@ export function VoiceInput({ sessionId, inputActions, locked, onActiveChange,
}, [sessionId])
const feedback = (text: string): void => { setMessage(text); setPhase('feedback') }
const failureText = (failure: unknown): string => failure instanceof RecordingError ? t(failure.kind)
: t('failed', { message: failure instanceof Error ? failure.message : String(failure) })
const finish = async (): Promise<void> => {
const active = current.current
if (!active || active.phase !== 'recording') return
@@ -115,7 +117,7 @@ export function VoiceInput({ sessionId, inputActions, locked, onActiveChange,
setPhase('idle')
} catch (failure) {
await disposeRecording(active.capture)
if (run === generation.current) feedback(failureText(failure))
if (run === generation.current) feedback(failureText(failure, t))
} finally { if (run === generation.current) current.current = undefined }
}
const start = async (): Promise<void> => {
@@ -131,7 +133,7 @@ export function VoiceInput({ sessionId, inputActions, locked, onActiveChange,
if (run !== generation.current || current.current !== active || active.phase === 'transcribing') return
current.current = undefined
clearTimeout(active.timer); active.abort.abort()
feedback(failureText(failure))
feedback(failureText(failure, t))
})
if (run !== generation.current || current.current !== active) return
active.phase = 'recording'
@@ -139,7 +141,7 @@ export function VoiceInput({ sessionId, inputActions, locked, onActiveChange,
active.timer = setTimeout(() => { void finish() }, active.maxDurationSeconds * 1000)
} catch (failure) {
await disposeRecording(active.capture)
if (run === generation.current) { current.current = undefined; feedback(failureText(failure)) }
if (run === generation.current) { current.current = undefined; feedback(failureText(failure, t)) }
}
}
if (!expanded) return <>
@@ -2,7 +2,7 @@
/** Capture failure whose message is localized by the caller. */
export class RecordingError extends Error {
constructor(readonly kind: 'unavailable' | 'permission' | 'empty' | 'cancelled' | 'interrupted') { super(kind); this.name = 'RecordingError' }
constructor(readonly kind: 'unavailable' | 'permission' | 'empty' | 'cancelled' | 'interrupted' | 'deviceMissing') { super(kind); this.name = 'RecordingError' }
}
/**
@@ -48,21 +48,25 @@ export class Recording {
private readonly lifetime = new AbortController()
private disposal: Promise<void> | undefined
constructor(private readonly onDispose: () => void) {}
constructor(private readonly onDispose: () => void, private readonly deviceId = '') {}
/**
* Acquire the microphone for this recording.
* @param onError - receives failures during capture, before asynchronous resource release finishes.
* @returns after capture starts; a cancelled permission grant immediately releases its tracks.
* Acquire the selected microphone without recording or playing audio.
* @param onError - receives device interruptions before resource release completes.
* @returns after the level analyser starts; cancelled grants release their tracks immediately.
*/
async start(onError?: (error: RecordingError) => void): Promise<void> {
async preview(onError?: (error: RecordingError) => void): Promise<void> {
const devices = (navigator as Partial<Navigator>).mediaDevices
if (!devices || typeof MediaRecorder === 'undefined') throw new RecordingError('unavailable')
if (!devices) throw new RecordingError('unavailable')
let stream: MediaStream
try {
stream = await devices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true }, video: false })
stream = await devices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true,
...(this.deviceId === '' ? {} : { deviceId: { exact: this.deviceId } }) }, video: false })
} catch (error) {
if (error instanceof DOMException && error.name === 'NotAllowedError') throw new RecordingError('permission')
if (error instanceof DOMException) {
if (error.name === 'NotAllowedError') throw new RecordingError('permission')
if (error.name === 'NotFoundError' || error.name === 'OverconstrainedError') throw new RecordingError('deviceMissing')
}
throw error
}
if (this.lifetime.signal.aborted) { stream.getTracks().forEach((track) => { track.stop() }); throw new RecordingError('cancelled') }
@@ -72,19 +76,37 @@ export class Recording {
this.analyser = this.context.createAnalyser()
this.analyser.fftSize = this.samples.length
this.context.createMediaStreamSource(stream).connect(this.analyser)
this.recorder = new MediaRecorder(stream)
for (const track of stream.getTracks()) track.addEventListener('ended', () => {
this.interrupted(onError)
}, { signal: this.lifetime.signal })
} catch (error) { await this.dispose(); throw error }
}
/**
* Acquire the selected microphone and record audio for transcription.
* @param onError - receives failures during capture, before asynchronous resource release finishes.
* @returns after capture starts; a cancelled permission grant immediately releases its tracks.
*/
async start(onError?: (error: RecordingError) => void): Promise<void> {
if (typeof MediaRecorder === 'undefined') throw new RecordingError('unavailable')
await this.preview(onError)
if (this.lifetime.signal.aborted) throw new RecordingError('cancelled')
try {
this.recorder = new MediaRecorder(this.stream as MediaStream)
this.recorder.ondataavailable = (event) => { if (!this.lifetime.signal.aborted && event.data.size > 0) this.chunks.push(event.data) }
this.recorder.onerror = () => {
if (this.lifetime.signal.aborted) return
void this.dispose().catch(() => undefined)
try { onError?.(new RecordingError('interrupted')) } catch (error) {
console.error('Speech recording error handler failed', error)
}
}
this.recorder.onerror = () => { this.interrupted(onError) }
this.recorder.start()
} catch (error) { await this.dispose(); throw error }
}
private interrupted(onError: ((error: RecordingError) => void) | undefined): void {
if (this.lifetime.signal.aborted) return
void this.dispose().catch(() => undefined)
try { onError?.(new RecordingError('interrupted')) } catch (error) {
console.error('Speech recording error handler failed', error)
}
}
/**
* Read the live microphone signal.
* @returns the measured RMS level, or zero outside capture.
@@ -97,6 +119,15 @@ export class Recording {
return Math.sqrt(sum / this.samples.length)
}
/**
* Identify the input actually acquired by this operation.
* @returns the audio track's name and device group, or undefined outside capture.
*/
deviceInfo(): Pick<MediaDeviceInfo, 'label' | 'groupId'> | undefined {
const track = this.stream?.getAudioTracks()[0]
return track ? { label: track.label, groupId: track.getSettings().groupId ?? '' } : undefined
}
/**
* Finish capture and resample the recording.
* @param maxDurationSeconds - truncate timer overshoot to the Host limit.
@@ -0,0 +1,15 @@
/** Locale-owned messages shared by microphone preview and dictation. */
import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
import { RecordingError } from './audio.ts'
import type { NS } from './locales.ts'
/**
* Translate a capture or transcription failure.
* @param failure - the rejected value.
* @param t - the active voice-input translator.
* @returns localized failure text.
*/
export function failureText(failure: unknown, t: PropsLocale<typeof NS>['t']): string {
return failure instanceof RecordingError ? t(failure.kind)
: t('failed', { message: failure instanceof Error ? failure.message : String(failure) })
}
@@ -82,6 +82,14 @@ export const zh = {
discard: '丢弃识别文字',
transcribingShort: '识别中…', wakingShort: '唤醒中…',
provider: '识别服务', language: '识别语言',
inputDevice: '输入设备',
systemMicrophone: '系统默认',
systemMicrophoneNamed: '系统默认({name})',
unnamedSelectedMicrophone: '麦克风',
unnamedMicrophone: '麦克风 {number}',
inputLevel: '麦克风输入电平',
deviceUnavailable: '不可用',
deviceMissing: '麦克风不可用,请选择其他输入设备',
auto: '自动识别', zh: '中文', en: '英语', yue: '粤语', ja: '日语', ko: '韩语',
start: '开始录音', stop: '停止并识别', cancel: '取消', insert: '插入文字',
loading: '正在读取识别服务…', requesting: '请允许使用麦克风…', recording: '正在录音…',
@@ -178,6 +186,14 @@ export const en: Record<VoiceKey, string> = {
discard: 'Discard transcript',
transcribingShort: 'Transcribing…', wakingShort: 'Waking…',
provider: 'Recognition service', language: 'Recognition language',
inputDevice: 'Input device',
systemMicrophone: 'System default',
systemMicrophoneNamed: 'System default ({name})',
unnamedSelectedMicrophone: 'Microphone',
unnamedMicrophone: 'Microphone {number}',
inputLevel: 'Microphone input level',
deviceUnavailable: 'Unavailable',
deviceMissing: 'Microphone unavailable. Choose another input device.',
auto: 'Detect automatically', zh: 'Chinese', en: 'English', yue: 'Cantonese', ja: 'Japanese', ko: 'Korean',
start: 'Start recording', stop: 'Stop and transcribe', cancel: 'Cancel', insert: 'Insert text',
loading: 'Loading recognition services…', requesting: 'Allow microphone access to continue…', recording: 'Recording…',
@@ -0,0 +1,20 @@
/** Browser-local microphone preference shared by settings previews and dictation. */
import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store'
/** An empty id follows the system input; labels store browser names without localized placeholders. */
export interface MicrophoneDevice {
readonly id: string
readonly label: string
}
/**
* Restore the browser preference independently of Host recognition settings.
* @returns validated device selection, persisted through the shared Client store.
*/
export function createMicrophoneDeviceStore(): SnapshotStore<MicrophoneDevice> {
const store = createSnapshotStore<MicrophoneDevice>({ id: '', label: '' }, { persist: { name: 'dsh.voice-input.microphone' } })
const saved: unknown = store.getSnapshot()
if (!saved || typeof saved !== 'object' || !('id' in saved) || typeof saved.id !== 'string'
|| !('label' in saved) || typeof saved.label !== 'string') store.set({ id: '', label: '' })
return store
}
@@ -8,6 +8,7 @@ import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { TypertRemoteContribution } from '@deepseek-ai/dsh-typert-protocol'
import { VoiceInput, type VoiceInputInjected } from './VoiceInput.tsx'
import { Recording } from './audio.ts'
import { createMicrophoneDeviceStore } from './microphone-device.ts'
import { en, NS, zh } from './locales.ts'
import type {} from '@deepseek-ai/dsh-client-ui-plugin-manager/client'
import { observeReadiness } from './readiness.ts'
@@ -19,14 +20,16 @@ export const inject = ['remote', 'slots', 'locale', 'pluginNavigation']
function registerUi(ctx: Context): void {
ctx.effect(() => ctx.locale.register(NS, { zh, en }))
const recordings = new Set<Recording>()
const microphone = createMicrophoneDeviceStore()
const readiness = observeReadiness(ctx)
ctx.effect(() => readiness.dispose)
ctx.effect(() => async () => { await Promise.all([...recordings].map(recording => recording.dispose())) })
const actions: VoiceInputInjected = {
openSettings: () => { ctx.pluginNavigation.openBundle('@deepseek-ai/dsh-experimental-voice-input-bundle') },
hooks: { speechReadiness: readiness.state },
hooks: { speechReadiness: readiness.state, microphoneDevice: microphone },
selectMicrophone: (device) => { microphone.set(device) },
createRecording: () => {
const recording = new Recording(() => { recordings.delete(recording) })
const recording = new Recording(() => { recordings.delete(recording) }, microphone.getSnapshot().id)
recordings.add(recording)
return recording
},
@@ -36,13 +36,18 @@ export function captureFixture(options: { empty?: boolean; recorderError?: boole
const offline = vi.fn(function Offline(_channels: number, _frames: number, _rate: number) {
return { destination: {}, createBufferSource: () => ({ buffer: null, connect() {}, start() {} }), startRendering: rendering }
})
vi.stubGlobal('navigator', { mediaDevices: { getUserMedia: async () => ({ getTracks: () => [{ stop: trackStop }] }) } })
const track = new EventTarget()
const audioTrack = Object.assign(track, { stop: trackStop, label: 'Fixture microphone',
getSettings: (): MediaTrackSettings => ({ groupId: 'fixture-input' }) })
const stream = { getTracks: () => [audioTrack], getAudioTracks: () => [audioTrack] }
const getUserMedia = vi.fn(async (_constraints: MediaStreamConstraints) => stream)
vi.stubGlobal('navigator', { mediaDevices: { getUserMedia } })
vi.stubGlobal('MediaRecorder', Recorder)
vi.stubGlobal('AudioContext', function Audio() { return { state: 'running', close, decodeAudioData: decoding,
createMediaStreamSource: () => ({ connect: vi.fn() }),
createAnalyser: () => ({ fftSize: 256, getFloatTimeDomainData: (buffer: Float32Array) => { buffer.fill(0.25) } }),
} })
vi.stubGlobal('OfflineAudioContext', offline)
return { recording: new Recording(disposed), trackStop, close, disposed, decoding, rendering, offline,
return { recording: new Recording(disposed), getUserMedia, stream, track, trackStop, close, disposed, decoding, rendering, offline,
failRecorder: () => { failRecorder() } }
}
@@ -20,6 +20,7 @@ it('reports unavailable recording and denied permission', async () => {
vi.stubGlobal('navigator', {})
const recording = new Recording(() => {})
await expect(recording.start()).rejects.toMatchObject({ kind: 'unavailable' })
await expect(recording.preview()).rejects.toMatchObject({ kind: 'unavailable' })
vi.stubGlobal('MediaRecorder', function RecorderStub() {})
vi.stubGlobal('navigator', { mediaDevices: { getUserMedia: async () => { throw new DOMException('denied', 'NotAllowedError') } } })
await expect(recording.start()).rejects.toMatchObject({ kind: 'permission' })
@@ -134,3 +135,60 @@ it('contains a failing interruption callback and still releases the microphone',
expect(report).toHaveBeenCalledWith('Speech recording error handler failed', error)
} finally { report.mockRestore(); await b.recording.dispose() }
})
it('previews an exact microphone without creating a recorder and releases it when disconnected', async () => {
const b = captureFixture({ constructError: true }), failure = vi.fn()
const preview = new Recording(() => {}, 'usb-microphone')
expect(preview.deviceInfo()).toBeUndefined()
try {
await preview.preview(failure)
expect(preview.deviceInfo()).toEqual({ label: 'Fixture microphone', groupId: 'fixture-input' })
vi.spyOn(b.stream.getAudioTracks()[0]!, 'getSettings').mockReturnValue({})
expect(preview.deviceInfo()).toEqual({ label: 'Fixture microphone', groupId: '' })
expect(b.getUserMedia).toHaveBeenCalledWith({ audio: { deviceId: { exact: 'usb-microphone' },
echoCancellation: true, noiseSuppression: true }, video: false })
expect(preview.amplitude()).toBe(0.25)
b.track.dispatchEvent(new Event('ended'))
expect(failure).toHaveBeenCalledWith(expect.objectContaining({ kind: 'interrupted' }))
expect(b.trackStop).toHaveBeenCalledOnce()
expect(preview.amplitude()).toBe(0)
} finally { await preview.dispose() }
})
it.each(['NotFoundError', 'OverconstrainedError'])('reports an unavailable selected microphone on %s without falling back', async (name) => {
const b = captureFixture(), recording = new Recording(() => {}, 'removed')
b.getUserMedia.mockRejectedValueOnce(new DOMException('missing device', name))
try {
await expect(recording.start()).rejects.toMatchObject({ kind: 'deviceMissing' })
expect(b.getUserMedia).toHaveBeenCalledOnce()
} finally { await recording.dispose() }
})
it('releases the microphone if audio analysis cannot initialize', async () => {
const b = captureFixture()
vi.stubGlobal('AudioContext', function AudioUnavailable() { throw new Error('audio unavailable') })
try {
await expect(b.recording.preview()).rejects.toThrow('audio unavailable')
expect(b.trackStop).toHaveBeenCalledOnce()
} finally { await b.recording.dispose() }
})
it('preserves browser device-busy errors', async () => {
const b = captureFixture(), error = new DOMException('busy', 'NotReadableError')
b.getUserMedia.mockRejectedValueOnce(error)
try { await expect(b.recording.preview()).rejects.toBe(error) } finally { await b.recording.dispose() }
})
it('does not create a recorder after cancellation at preview completion', async () => {
const b = captureFixture(), ready = Promise.withResolvers<undefined>(), proceed = Promise.withResolvers<undefined>()
const preview = b.recording.preview.bind(b.recording)
vi.spyOn(b.recording, 'preview').mockImplementation(async (onError) => { await preview(onError); ready.resolve(undefined); await proceed.promise })
const starting = b.recording.start(), rejected = expect(starting).rejects.toMatchObject({ kind: 'cancelled' })
try {
await ready.promise
await b.recording.dispose()
proceed.resolve(undefined)
await rejected
expect(b.trackStop).toHaveBeenCalledOnce()
} finally { proceed.resolve(undefined); await b.recording.dispose() }
})
@@ -0,0 +1,466 @@
// @vitest-environment jsdom
/** Page-active capture, selected input routing, and late permissions use real audio ownership. */
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { bindSnapshotSelector, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
import { en as commonEn } from '@deepseek-ai/dsh-client-locale/src/locales/en.ts'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { MicrophonePicker } from '../src/client/MicrophonePicker.tsx'
import { createMicrophoneDeviceStore } from '../src/client/microphone-device.ts'
import { Recording, RecordingError } from '../src/client/audio.ts'
import { en, zh } from '../src/client/locales.ts'
import { captureFixture } from './audio-fixture.client.ts'
const t = makeTranslate(zh, commonZh)
beforeEach(() => {
localStorage.clear()
vi.stubGlobal('requestAnimationFrame', vi.fn(() => 1))
vi.stubGlobal('cancelAnimationFrame', vi.fn())
})
afterEach(() => { cleanup(); localStorage.clear(); vi.unstubAllGlobals() })
function fixture() {
const audio = captureFixture(), selection = createMicrophoneDeviceStore()
const enumerateDevices = vi.fn(async () => [
{ kind: 'audioinput', deviceId: 'default', label: 'Default' },
{ kind: 'audioinput', deviceId: 'usb', label: 'USB microphone' },
{ kind: 'audioinput', deviceId: 'internal', label: 'Built-in microphone' },
{ kind: 'audiooutput', deviceId: 'speakers', label: 'Speakers' },
])
const media = Object.assign(new EventTarget(), { getUserMedia: audio.getUserMedia, enumerateDevices })
vi.stubGlobal('navigator', { mediaDevices: media })
const recordings: Recording[] = []
const props = { t, useMicrophoneDevice: bindSnapshotSelector(selection),
selectMicrophone: (device: { id: string; label: string }) => { selection.set(device) },
createRecording: () => {
const capture = new Recording(() => {}, selection.getSnapshot().id)
recordings.push(capture)
return capture
} }
const view = render(<MicrophonePicker {...props} />)
return { ...audio, ...view, media, enumerateDevices, recordings, props, selection }
}
function selector(): HTMLSelectElement {
return screen.getByRole<HTMLSelectElement>('combobox', { name: zh.inputDevice })
}
async function waitForPreview(): Promise<void> {
await screen.findByRole('img', { name: zh.inputLevel })
}
it('captures while the page is active, keeps the meter through field blur, and remembers the selected input', async () => {
const b = fixture()
try {
await waitForPreview()
expect(screen.queryByRole('option', { name: 'Speakers' })).toBeNull()
expect(screen.getByRole('img', { name: zh.inputLevel })).toBeTruthy()
fireEvent.change(selector(), { target: { value: 'usb' } })
await waitFor(() => { expect(b.getUserMedia).toHaveBeenLastCalledWith({ audio: {
echoCancellation: true, noiseSuppression: true, deviceId: { exact: 'usb' },
}, video: false }) })
expect(b.trackStop).toHaveBeenCalledOnce()
await screen.findByRole('img', { name: zh.inputLevel })
expect(selector().selectedOptions[0]?.textContent).toBe('USB microphone')
expect(document.querySelectorAll('[data-active="true"]').length).toBeGreaterThan(0)
const stops = b.trackStop.mock.calls.length
fireEvent.blur(selector())
expect(screen.getByRole('img', { name: zh.inputLevel })).toBeTruthy()
expect(b.trackStop).toHaveBeenCalledTimes(stops)
expect(createMicrophoneDeviceStore().getSnapshot()).toEqual({ id: 'usb', label: 'USB microphone' })
b.unmount()
expect(b.trackStop).toHaveBeenCalledTimes(stops + 1)
const recording = b.props.createRecording()
await recording.start()
expect(b.getUserMedia.mock.calls.at(-1)?.[0].audio).toMatchObject({ deviceId: { exact: 'usb' } })
} finally { b.unmount(); await Promise.all(b.recordings.map(recording => recording.dispose())) }
})
it('releases a permission grant arriving after the page closes', async () => {
const b = fixture(), granted = Promise.withResolvers<typeof b.stream>()
b.getUserMedia.mockReturnValueOnce(granted.promise)
try {
await waitFor(() => { expect(b.getUserMedia).toHaveBeenCalledOnce() })
b.unmount()
await act(async () => { granted.resolve(b.stream) })
expect(b.trackStop).toHaveBeenCalledOnce()
expect(screen.queryByRole('img', { name: zh.inputLevel })).toBeNull()
} finally { b.unmount(); await Promise.all(b.recordings.map(recording => recording.dispose())) }
})
it('keeps device choices available after permission denial', async () => {
const b = fixture()
b.getUserMedia.mockRejectedValueOnce(new DOMException('denied', 'NotAllowedError'))
try {
await screen.findByText(zh.permission)
fireEvent.change(selector(), { target: { value: 'usb' } })
await screen.findByRole('img', { name: zh.inputLevel })
expect(screen.queryByText(zh.permission)).toBeNull()
} finally { b.unmount(); await Promise.all(b.recordings.map(recording => recording.dispose())) }
})
it.each([null, 'usb', { id: 3, label: 'USB' }, { id: 'usb' }])('ignores invalid persisted microphone fields: %j', (saved) => {
localStorage.setItem('dsh.voice-input.microphone', JSON.stringify(saved))
expect(createMicrophoneDeviceStore().getSnapshot()).toEqual({ id: '', label: '' })
})
it('stops preview on page hiding and releases tracks even when AudioContext closure fails', async () => {
const b = fixture()
const hidden = vi.spyOn(document, 'hidden', 'get')
try {
await waitForPreview()
hidden.mockReturnValue(false)
fireEvent(document, new Event('visibilitychange'))
expect(screen.getByRole('img', { name: zh.inputLevel })).toBeTruthy()
b.close.mockRejectedValueOnce(new Error('device closed'))
hidden.mockReturnValue(true)
fireEvent(document, new Event('visibilitychange'))
await waitFor(() => { expect(screen.queryByRole('img', { name: zh.inputLevel })).toBeNull() })
expect(b.trackStop).toHaveBeenCalledOnce()
hidden.mockReturnValue(false)
fireEvent(document, new Event('visibilitychange'))
await screen.findByRole('img', { name: zh.inputLevel })
expect(b.getUserMedia).toHaveBeenCalledTimes(2)
} finally {
hidden.mockRestore(); b.unmount()
await Promise.allSettled(b.recordings.map(recording => recording.dispose()))
}
})
it.each([new Error('device busy'), 'device busy'])('shows capture failures without preventing another device choice: %s', async (failure) => {
const b = fixture()
b.getUserMedia.mockRejectedValueOnce(failure)
b.enumerateDevices.mockResolvedValue([{ kind: 'audioinput', deviceId: 'unnamed', label: '' }])
try {
await act(async () => { b.media.dispatchEvent(new Event('devicechange')) })
await screen.findByRole('option', { name: '麦克风 1' })
await screen.findByText('语音识别失败:device busy')
} finally { b.unmount(); await Promise.all(b.recordings.map(recording => recording.dispose())) }
})
it('reports unavailable browser capture and enumeration failures beside the field', async () => {
const b = fixture()
try {
vi.stubGlobal('navigator', {})
b.props.createRecording = () => new Recording(() => {}, b.selection.getSnapshot().id)
b.rerender(<MicrophonePicker {...b.props} />)
await screen.findByText(zh.unavailable)
vi.stubGlobal('navigator', { mediaDevices: b.media })
b.enumerateDevices.mockRejectedValueOnce(new Error('enumeration failed'))
b.props.createRecording = () => new Recording(() => {}, b.selection.getSnapshot().id)
b.rerender(<MicrophonePicker {...b.props} />)
await screen.findByText('语音识别失败:enumeration failed')
} finally { b.unmount(); await Promise.all(b.recordings.map(recording => recording.dispose())) }
})
it('ignores device enumeration that settles after the page closes', async () => {
const b = fixture(), result = Promise.withResolvers<Awaited<ReturnType<typeof b.enumerateDevices>>>()
try {
await waitForPreview()
b.enumerateDevices.mockReturnValueOnce(result.promise)
const enumerations = b.enumerateDevices.mock.calls.length
act(() => { b.media.dispatchEvent(new Event('devicechange')) })
await waitFor(() => { expect(b.enumerateDevices).toHaveBeenCalledTimes(enumerations + 1) })
const acquisitions = b.getUserMedia.mock.calls.length
b.unmount()
await act(async () => { result.resolve([{ kind: 'audioinput', deviceId: 'late', label: 'Late microphone' }]) })
expect(screen.queryByRole('option', { name: 'Late microphone' })).toBeNull()
expect(b.getUserMedia).toHaveBeenCalledTimes(acquisitions)
} finally { b.unmount(); await Promise.all(b.recordings.map(recording => recording.dispose())) }
})
it('does not publish a preview when the component unmounts at acquisition completion', async () => {
const b = fixture(), acquired = Promise.withResolvers<undefined>(), release = Promise.withResolvers<undefined>()
const createRecording = b.props.createRecording
b.props.createRecording = () => {
const capture = createRecording(), preview = capture.preview.bind(capture)
vi.spyOn(capture, 'preview').mockImplementation(async (onError) => { await preview(onError); acquired.resolve(undefined); await release.promise })
return capture
}
b.rerender(<MicrophonePicker {...b.props} />)
try {
await act(async () => { await acquired.promise })
const enumerations = b.enumerateDevices.mock.calls.length
b.unmount()
await act(async () => { release.resolve(undefined) })
expect(b.trackStop).toHaveBeenCalledOnce()
expect(b.enumerateDevices).toHaveBeenCalledTimes(enumerations)
} finally { release.resolve(undefined); await Promise.all(b.recordings.map(recording => recording.dispose())) }
})
it('ignores enumeration failure and a queued interruption after unmount', async () => {
const b = fixture(), pending = Promise.withResolvers<Awaited<ReturnType<typeof b.enumerateDevices>>>()
let interrupted: ((error: RecordingError) => void) | undefined
const createRecording = b.props.createRecording
b.props.createRecording = () => {
const capture = createRecording(), preview = capture.preview.bind(capture)
vi.spyOn(capture, 'preview').mockImplementation(async (onError) => { interrupted = onError; await preview(onError) })
return capture
}
b.rerender(<MicrophonePicker {...b.props} />)
try {
await waitForPreview()
b.enumerateDevices.mockReturnValueOnce(pending.promise)
act(() => { b.media.dispatchEvent(new Event('devicechange')) })
const acquisitions = b.getUserMedia.mock.calls.length
b.unmount()
await act(async () => { pending.reject(new Error('Late enumeration')); interrupted!(new RecordingError('interrupted')) })
expect(b.getUserMedia).toHaveBeenCalledTimes(acquisitions)
expect(b.trackStop).toHaveBeenCalledOnce()
} finally { pending.resolve([]); b.unmount(); await Promise.all(b.recordings.map(recording => recording.dispose())) }
})
async function selectUsb(b: ReturnType<typeof fixture>): Promise<void> {
await waitForPreview()
fireEvent.change(selector(), { target: { value: 'usb' } })
await screen.findByRole('img', { name: zh.inputLevel })
await act(async () => {})
expect(b.selection.getSnapshot().id).toBe('usb')
}
it('inserts and removes unused devices without restarting the selected microphone', async () => {
const b = fixture()
try {
await selectUsb(b)
const existing = await b.enumerateDevices(), acquisitions = b.getUserMedia.mock.calls.length, stops = b.trackStop.mock.calls.length
b.enumerateDevices.mockResolvedValue([...existing, { kind: 'audioinput', deviceId: 'new', label: 'New microphone' }])
await act(async () => { b.media.dispatchEvent(new Event('devicechange')) })
expect(screen.getByRole('option', { name: 'New microphone' })).toBeTruthy()
b.enumerateDevices.mockResolvedValue(existing.filter(device => device.deviceId !== 'internal'))
await act(async () => { b.media.dispatchEvent(new Event('devicechange')) })
expect(screen.queryByRole('option', { name: 'Built-in microphone' })).toBeNull()
expect(b.getUserMedia).toHaveBeenCalledTimes(acquisitions)
expect(b.trackStop).toHaveBeenCalledTimes(stops)
expect(selector().selectedOptions[0]?.textContent).toBe('USB microphone')
expect(screen.getByRole('img', { name: zh.inputLevel })).toBeTruthy()
} finally { b.unmount(); await Promise.all(b.recordings.map(recording => recording.dispose())) }
})
it.each(['track-first', 'devices-first'])('retains the disconnected selection and recovers its preview (%s)', async (order) => {
const b = fixture()
try {
await selectUsb(b)
const existing = await b.enumerateDevices(), acquisitions = b.getUserMedia.mock.calls.length, stops = b.trackStop.mock.calls.length
const index = screen.getAllByRole('option').findIndex(item => item.textContent?.includes('USB microphone'))
b.enumerateDevices.mockResolvedValue(existing.filter(device => device.deviceId !== 'usb'))
await act(async () => {
if (order === 'track-first') b.track.dispatchEvent(new Event('ended'))
b.media.dispatchEvent(new Event('devicechange'))
})
if (order === 'devices-first') await act(async () => { b.track.dispatchEvent(new Event('ended')) })
const unavailable = screen.getByRole<HTMLOptionElement>('option', { name: `USB microphone — ${zh.deviceUnavailable}` })
expect(unavailable.disabled).toBe(true)
expect(screen.getAllByRole('option')[index]).toBe(unavailable)
expect(screen.queryByRole('img', { name: zh.inputLevel })).toBeNull()
expect(b.trackStop).toHaveBeenCalledTimes(stops + 1)
expect(b.selection.getSnapshot()).toEqual({ id: 'usb', label: 'USB microphone' })
fireEvent.change(selector(), { target: { value: 'usb' } })
expect(b.getUserMedia).toHaveBeenCalledTimes(acquisitions)
b.enumerateDevices.mockResolvedValue(existing)
await act(async () => { b.media.dispatchEvent(new Event('devicechange')) })
await screen.findByRole('img', { name: zh.inputLevel })
expect(b.getUserMedia).toHaveBeenCalledTimes(acquisitions + 1)
expect(b.getUserMedia.mock.calls.at(-1)?.[0].audio).toMatchObject({ deviceId: { exact: 'usb' } })
} finally { b.unmount(); await Promise.all(b.recordings.map(recording => recording.dispose())) }
})
it('keeps preview active across blur and reacquires it after reconnect', async () => {
const b = fixture()
try {
await selectUsb(b)
const existing = await b.enumerateDevices(), acquisitions = b.getUserMedia.mock.calls.length
fireEvent.blur(selector())
expect(screen.getByRole('img', { name: zh.inputLevel })).toBeTruthy()
b.enumerateDevices.mockResolvedValue(existing.filter(device => device.deviceId !== 'usb'))
await act(async () => { b.media.dispatchEvent(new Event('devicechange')) })
expect(selector().selectedOptions[0]?.textContent).toContain(zh.deviceUnavailable)
b.enumerateDevices.mockResolvedValue(existing)
await act(async () => { b.media.dispatchEvent(new Event('devicechange')) })
expect(selector().selectedOptions[0]?.textContent).toBe('USB microphone')
await screen.findByRole('img', { name: zh.inputLevel })
expect(b.getUserMedia).toHaveBeenCalledTimes(acquisitions + 1)
} finally { b.unmount(); await Promise.all(b.recordings.map(recording => recording.dispose())) }
})
it('forgets the missing row when another microphone is selected and does not switch back on reconnect', async () => {
const b = fixture()
try {
await selectUsb(b)
const existing = await b.enumerateDevices()
b.enumerateDevices.mockResolvedValue(existing.filter(device => device.deviceId !== 'usb'))
await act(async () => { b.media.dispatchEvent(new Event('devicechange')) })
fireEvent.change(selector(), { target: { value: 'internal' } })
await screen.findByRole('img', { name: zh.inputLevel })
expect(screen.queryByRole('option', { name: /USB microphone/ })).toBeNull()
const acquisitions = b.getUserMedia.mock.calls.length
b.enumerateDevices.mockResolvedValue(existing)
await act(async () => { b.media.dispatchEvent(new Event('devicechange')) })
expect(screen.getByRole('option', { name: 'USB microphone' })).toBeTruthy()
expect(b.selection.getSnapshot().id).toBe('internal')
expect(b.getUserMedia).toHaveBeenCalledTimes(acquisitions)
} finally { b.unmount(); await Promise.all(b.recordings.map(recording => recording.dispose())) }
})
it.each(['resolve', 'reject'])('ignores an older enumeration after a newer hot-plug result (%s)', async (settle) => {
const b = fixture(), pending = Promise.withResolvers<Awaited<ReturnType<typeof b.enumerateDevices>>>()
try {
await selectUsb(b)
const existing = await b.enumerateDevices()
b.enumerateDevices.mockReturnValueOnce(pending.promise)
act(() => { b.media.dispatchEvent(new Event('devicechange')) })
b.enumerateDevices.mockResolvedValue(existing.filter(device => device.deviceId !== 'usb'))
await act(async () => { b.media.dispatchEvent(new Event('devicechange')) })
await act(async () => { if (settle === 'resolve') pending.resolve(existing); else pending.reject(new Error('Stale failure')) })
expect(screen.getByRole('option', { name: `USB microphone — ${zh.deviceUnavailable}` })).toBeTruthy()
expect(screen.queryByRole('img', { name: zh.inputLevel })).toBeNull()
expect(screen.queryByText('语音识别失败:Stale failure')).toBeNull()
} finally { pending.resolve([]); b.unmount(); await Promise.all(b.recordings.map(recording => recording.dispose())) }
})
it('releases a late grant after unplugging the requested device and allows a fresh reconnect', async () => {
const b = fixture(), grant = Promise.withResolvers<typeof b.stream>()
try {
await waitForPreview()
const existing = await b.enumerateDevices()
b.getUserMedia.mockReturnValueOnce(grant.promise)
fireEvent.change(selector(), { target: { value: 'usb' } })
await waitFor(() => { expect(b.getUserMedia).toHaveBeenCalledTimes(2) })
b.enumerateDevices.mockResolvedValue(existing.filter(device => device.deviceId !== 'usb'))
await act(async () => { b.media.dispatchEvent(new Event('devicechange')) })
b.enumerateDevices.mockResolvedValue(existing)
await act(async () => { b.media.dispatchEvent(new Event('devicechange')) })
const stops = b.trackStop.mock.calls.length
await act(async () => { grant.resolve(b.stream) })
expect(b.trackStop).toHaveBeenCalledTimes(stops + 1)
await screen.findByRole('img', { name: zh.inputLevel })
} finally { grant.resolve(b.stream); b.unmount(); await Promise.all(b.recordings.map(recording => recording.dispose())) }
})
it('requests permission for a remembered device when enumeration hides its identity', async () => {
const b = fixture()
try {
b.enumerateDevices.mockResolvedValue([{ kind: 'audioinput', deviceId: '', label: '' }])
act(() => { b.selection.set({ id: 'usb', label: 'USB microphone' }) })
await waitForPreview()
expect(screen.queryByText(zh.deviceUnavailable)).toBeNull()
} finally { b.unmount(); await Promise.all(b.recordings.map(recording => recording.dispose())) }
})
it('recovers system-default preview after its track ends and shows unavailable when no input remains', async () => {
const b = fixture()
try {
await waitForPreview()
await act(async () => { b.track.dispatchEvent(new Event('ended')) })
expect(b.getUserMedia).toHaveBeenCalledTimes(2)
expect(b.getUserMedia.mock.calls.at(-1)?.[0].audio).not.toHaveProperty('deviceId')
b.enumerateDevices.mockResolvedValue([])
await act(async () => { b.media.dispatchEvent(new Event('devicechange')) })
const unavailable = screen.getByRole<HTMLOptionElement>('option', { name: `系统默认(Fixture microphone) — ${zh.deviceUnavailable}` })
expect(unavailable.disabled).toBe(true)
expect(screen.queryByRole('img', { name: zh.inputLevel })).toBeNull()
} finally { b.unmount(); await Promise.all(b.recordings.map(recording => recording.dispose())) }
})
it('updates locale text without re-enumerating or restarting the active microphone', async () => {
const b = fixture()
try {
await waitForPreview()
const acquisitions = b.getUserMedia.mock.calls.length, enumerations = b.enumerateDevices.mock.calls.length
b.rerender(<MicrophonePicker {...b.props} t={makeTranslate(en, commonEn)} />)
expect(screen.getByRole<HTMLSelectElement>('combobox', { name: en.inputDevice }).selectedOptions[0]?.textContent)
.toBe('System default (Fixture microphone)')
expect(b.getUserMedia).toHaveBeenCalledTimes(acquisitions)
expect(b.enumerateDevices).toHaveBeenCalledTimes(enumerations)
expect(b.trackStop).not.toHaveBeenCalled()
} finally { b.unmount(); await Promise.all(b.recordings.map(recording => recording.dispose())) }
})
it('persists raw unnamed labels and translates a retained permission failure', async () => {
const b = fixture()
b.enumerateDevices.mockResolvedValue([{ kind: 'audioinput', deviceId: 'unnamed', label: '' }])
b.getUserMedia.mockRejectedValue(new DOMException('denied', 'NotAllowedError'))
try {
await act(async () => { b.media.dispatchEvent(new Event('devicechange')) })
await screen.findByRole('option', { name: '麦克风 1' })
fireEvent.change(selector(), { target: { value: 'unnamed' } })
await screen.findByText(zh.permission)
expect(b.selection.getSnapshot()).toEqual({ id: 'unnamed', label: '' })
const acquisitions = b.getUserMedia.mock.calls.length
b.rerender(<MicrophonePicker {...b.props} t={makeTranslate(en, commonEn)} />)
expect(screen.getByRole<HTMLSelectElement>('combobox', { name: en.inputDevice }).selectedOptions[0]?.textContent).toBe('Microphone 1')
expect(screen.getByText(en.permission)).toBeTruthy()
expect(b.getUserMedia).toHaveBeenCalledTimes(acquisitions)
} finally { b.unmount(); await Promise.all(b.recordings.map(recording => recording.dispose())) }
})
it('shows a localized name while a restored unnamed selection awaits enumeration', async () => {
localStorage.setItem('dsh.voice-input.microphone', JSON.stringify({ id: 'usb', label: '' }))
const b = fixture()
try {
expect(selector().selectedOptions[0]?.textContent).toBe(zh.unnamedSelectedMicrophone)
await waitFor(() => { expect(selector().selectedOptions[0]?.textContent).toBe('USB microphone') })
await waitFor(() => { expect(b.getUserMedia).toHaveBeenCalledOnce() })
} finally { b.unmount(); await Promise.all(b.recordings.map(recording => recording.dispose())) }
})
it('shows refreshed names in the field and retains them on disconnect', async () => {
const b = fixture()
try {
await selectUsb(b)
const acquisitions = b.getUserMedia.mock.calls.length
b.enumerateDevices.mockResolvedValue([{ kind: 'audioinput', deviceId: 'usb', label: 'Renamed microphone' }])
await act(async () => { b.media.dispatchEvent(new Event('devicechange')) })
expect(selector().selectedOptions[0]?.textContent).toBe('Renamed microphone')
expect(b.getUserMedia).toHaveBeenCalledTimes(acquisitions)
b.enumerateDevices.mockResolvedValue([])
await act(async () => { b.media.dispatchEvent(new Event('devicechange')) })
expect(selector().selectedOptions[0]?.textContent).toBe(`Renamed microphone — ${zh.deviceUnavailable}`)
} finally { b.unmount(); await Promise.all(b.recordings.map(recording => recording.dispose())) }
})
it('keeps the active system device name and the communications choice', async () => {
const b = fixture()
const inputs = [
{ kind: 'audioinput', deviceId: 'default', groupId: 'internal', label: 'Default alias' },
{ kind: 'audioinput', deviceId: 'internal', groupId: 'internal', label: 'Built-in microphone' },
{ kind: 'audioinput', deviceId: 'communications', groupId: 'headset', label: 'Communications headset' },
]
b.enumerateDevices.mockResolvedValue(inputs)
try {
await act(async () => { b.media.dispatchEvent(new Event('devicechange')) })
expect(selector().selectedOptions[0]?.textContent).toBe('系统默认(Fixture microphone)')
await waitForPreview()
await screen.findByRole('option', { name: 'Communications headset' })
fireEvent.change(selector(), { target: { value: 'communications' } })
await screen.findByRole('img', { name: zh.inputLevel })
expect(b.getUserMedia.mock.calls.at(-1)?.[0].audio).toMatchObject({ deviceId: { exact: 'communications' } })
expect(screen.queryByRole('option', { name: 'Default alias' })).toBeNull()
} finally { b.unmount(); await Promise.all(b.recordings.map(recording => recording.dispose())) }
})
it('uses the acquired device group to show its physical name without the browser default prefix', async () => {
const b = fixture()
b.stream.getAudioTracks()[0]!.label = 'Default - Active microphone'
const devices = [
{ kind: 'audioinput', deviceId: 'default', groupId: 'other-input', label: 'Default - Other microphone' },
{ kind: 'audioinput', deviceId: 'other', groupId: 'other-input', label: 'Other microphone' },
{ kind: 'audioinput', deviceId: 'active', groupId: 'fixture-input', label: 'Active microphone' },
]
b.enumerateDevices.mockResolvedValue(devices)
try {
await act(async () => { b.media.dispatchEvent(new Event('devicechange')) })
expect(selector().selectedOptions[0]?.textContent).toBe('系统默认(Active microphone)')
expect(b.getUserMedia).toHaveBeenCalledOnce()
} finally { b.unmount(); await Promise.all(b.recordings.map(recording => recording.dispose())) }
})
it('does not restore a previous default device name and persists only the system preference', async () => {
localStorage.setItem('dsh.voice-input.microphone', JSON.stringify({ id: '', label: 'Previous default microphone' }))
const b = fixture()
try {
expect(selector().selectedOptions[0]?.textContent).toBe(zh.systemMicrophone)
await waitForPreview()
fireEvent.change(selector(), { target: { value: '' } })
expect(b.selection.getSnapshot()).toEqual({ id: '', label: '' })
expect(b.getUserMedia).toHaveBeenCalledOnce()
} finally { b.unmount(); await Promise.all(b.recordings.map(recording => recording.dispose())) }
})
@@ -26,6 +26,7 @@ vi.mock('../src/client/readiness.ts', () => ({ observeReadiness: () => ({
function assertVoiceActions(value: Record<string, unknown>): asserts value is Record<string, unknown> & VoiceInputInjected {
assert(typeof value.openSettings === 'function')
assert(typeof value.createRecording === 'function')
assert(typeof value.selectMicrophone === 'function')
assert(typeof value.configure === 'function')
assert(typeof value.prepare === 'function')
assert(typeof value.cancelPreparation === 'function')
@@ -128,9 +129,12 @@ it('joins the same audio closure when cancellation overlaps Client plugin withdr
const entry = b.ctx.slots.entries('conversation.input.activity').find(item => item.component === VoiceInput)!
const actions = entry.inject!()
assertVoiceActions(actions)
actions.selectMicrophone({ id: 'usb', label: 'USB microphone' })
expect(actions.hooks.microphoneDevice.getSnapshot()).toEqual({ id: 'usb', label: 'USB microphone' })
const recording = actions.createRecording()
assert(recording instanceof Recording)
await recording.start()
expect(audio.getUserMedia.mock.calls.at(-1)?.[0].audio).toMatchObject({ deviceId: { exact: 'usb' } })
cancelled = recording.dispose()
const originalDispose = recording.dispose.bind(recording), joined = Promise.withResolvers<{ pending: Promise<void> }>()
vi.spyOn(recording, 'dispose').mockImplementationOnce(() => {
@@ -9,6 +9,7 @@ import { afterEach, expect, it, vi } from 'vitest'
import { PreparationCard, VoicePreparation } from '../src/client/PreparationCard.tsx'
import type { VoiceInputProps } from '../src/client/VoiceInput.tsx'
import type { SpeechReadiness } from '../src/client/readiness.ts'
import { Recording } from '../src/client/audio.ts'
import { zh } from '../src/client/locales.ts'
afterEach(() => { cleanup(); vi.useRealTimers() })
@@ -117,6 +118,8 @@ it('persists settings and reports disconnection in the detail card', async () =>
} })
const configure = vi.fn<VoiceInputProps['configure']>(async () => {})
const props = { useSpeechReadiness: bindSnapshotSelector(store), configure, t,
useMicrophoneDevice: bindSnapshotSelector(createSnapshotStore({ id: '', label: '' })),
selectMicrophone: vi.fn(), createRecording: () => new Recording(() => {}),
prepare: vi.fn(async () => {}), cancelPreparation: vi.fn(async () => {}) }
const view = render(<VoicePreparation {...props} />)
fireEvent.change(screen.getByLabelText(zh.provider), { target: { value: 'cloud' } })
@@ -32,6 +32,7 @@ function fixture(recording?: Recording) {
async () => ({ ok: true, value: transcript }))
const props: VoiceInputProps = { sessionId: 'one' as SessionId, inputActions, transcribe, locked: false, onActiveChange: vi.fn(),
openSettings: vi.fn(), prepare: vi.fn(async () => {}), cancelPreparation: vi.fn(async () => {}), configure: vi.fn(async () => {}),
selectMicrophone: vi.fn(), useMicrophoneDevice: bindSnapshotSelector(createSnapshotStore({ id: '', label: '' })),
useSpeechReadiness: bindSnapshotSelector(readiness), createRecording: () => recording ?? capture,
t: makeTranslate(zh, commonZh) }
const view = render(<VoiceInput {...props} />)
@@ -0,0 +1,6 @@
- text: Input device
- combobox "Input device":
- option "System default (Fake Default Audio Input)"
- option "USB studio microphone — conference room recording input" [selected]
- option "Fake Audio Input 2"
- img "Microphone input level"
@@ -0,0 +1,5 @@
- text: Input device
- combobox "Input device":
- option "System default (Fake Default Audio Input)"
- option "Fake Audio Input 1 — Unavailable" [disabled] [selected]
- option "Fake Audio Input 2"
@@ -0,0 +1,6 @@
- text: Input device
- combobox "Input device":
- option "System default (Fake Default Audio Input)"
- option "Fake Audio Input 1" [selected]
- option "Fake Audio Input 2"
- img "Microphone input level"