From 2d0360870cd61ed70886b14b609ffb72ea1ac30b Mon Sep 17 00:00:00 2001 From: yudshj Date: Mon, 5 Oct 2026 16:28:30 +0800 Subject: [PATCH 1/6] feat(voice-input): select microphone with menu-only input levels --- apps/web/tests/voice-input.e2e.ts | 38 +++- .../client-ui-voice-input/README.i18n.yaml | 4 +- .../client-ui-voice-input/README.md | 2 + .../client-ui-voice-input/README.zh.md | 2 + .../src/client/MicrophonePicker.tsx | 93 +++++++++ .../src/client/PreparationCard.tsx | 4 +- .../src/client/VoiceInput.module.css | 14 ++ .../src/client/VoiceInput.tsx | 5 +- .../client-ui-voice-input/src/client/audio.ts | 56 ++++-- .../src/client/locales.ts | 10 + .../src/client/microphone-device.ts | 20 ++ .../client-ui-voice-input/src/client/mount.ts | 7 +- .../tests/audio-fixture.client.ts | 7 +- .../tests/audio.client.spec.ts | 53 +++++ .../tests/microphone-picker.client.spec.tsx | 182 ++++++++++++++++++ .../tests/preparation.client.spec.tsx | 3 + .../tests/voice-input.client.spec.tsx | 1 + snapshots/web/voice-input/device.expected.md | 6 + 18 files changed, 481 insertions(+), 26 deletions(-) create mode 100644 packages/experimental/client-ui-voice-input/src/client/MicrophonePicker.tsx create mode 100644 packages/experimental/client-ui-voice-input/src/client/microphone-device.ts create mode 100644 packages/experimental/client-ui-voice-input/tests/microphone-picker.client.spec.tsx create mode 100644 snapshots/web/voice-input/device.expected.md diff --git a/apps/web/tests/voice-input.e2e.ts b/apps/web/tests/voice-input.e2e.ts index 82634fa7eb..853cd1508e 100644 --- a/apps/web/tests/voice-input.e2e.ts +++ b/apps/web/tests/voice-input.e2e.ts @@ -62,7 +62,14 @@ 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 holder = window as Window & { voiceTestRecorder?: MediaRecorder; voiceTestStreams?: MediaStream[] } + holder.voiceTestStreams = [] + const getUserMedia = navigator.mediaDevices.getUserMedia.bind(navigator.mediaDevices) + navigator.mediaDevices.getUserMedia = async (constraints) => { + const stream = await getUserMedia(constraints) + holder.voiceTestStreams!.push(stream) + return stream + } const NativeRecorder = window.MediaRecorder window.MediaRecorder = class extends NativeRecorder { constructor(stream: MediaStream, options?: MediaRecorderOptions) { super(stream, options); holder.voiceTestRecorder = this } @@ -120,6 +127,33 @@ 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('button', { name: 'Input device', exact: true }) + await devicePicker.click() + 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 page.getByRole('menuitem', { name: selectedDevice.label, exact: true }).click() + await page.getByRole('menuitem', { name: `${selectedDevice.label} Microphone input level`, exact: true }).waitFor() + 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, '[role="menu"]', scaffold.workspaceCwd), webSnapshotMode()) + for (const width of [1280, 360]) { + await page.setViewportSize({ width, height: 900 }) + await expect.poll(async () => { + const bounds = await page.getByRole('menu').boundingBox() + return bounds !== null && bounds.x >= 0 && bounds.x + bounds.width <= width + }).toBe(true) + } + await page.keyboard.press('Escape') + await level.waitFor({ state: 'hidden' }) + await expect.poll(() => page.evaluate(() => (window as Window & { voiceTestStreams?: MediaStream[] }).voiceTestStreams! + .every(stream => stream.getTracks().every(track => track.readyState === 'ended')))).toBe(true) + 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 page.getByRole('button', { name: 'Plugins', exact: true }).click() @@ -134,6 +168,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) diff --git a/packages/experimental/client-ui-voice-input/README.i18n.yaml b/packages/experimental/client-ui-voice-input/README.i18n.yaml index dd577cb202..3ca00b2971 100644 --- a/packages/experimental/client-ui-voice-input/README.i18n.yaml +++ b/packages/experimental/client-ui-voice-input/README.i18n.yaml @@ -15,8 +15,8 @@ en: d152484eb41ac6b4 zh: 364c80ee00a374ff /deepseek-ai-dsh-experimental-client-ui-voice-input/use-this-package: - en: 44f25ad14ceda123 - zh: 43c4680be42af1cf + en: ff0d98de2970f93d + zh: c234972ea1803afb /deepseek-ai-dsh-experimental-client-ui-voice-input/understand-the-implementation: en: b3a7bb1a4f0b3c1e zh: 233d1c5ea2b16de8 diff --git a/packages/experimental/client-ui-voice-input/README.md b/packages/experimental/client-ui-voice-input/README.md index ea4b42a6fe..457341427e 100644 --- a/packages/experimental/client-ui-voice-input/README.md +++ b/packages/experimental/client-ui-voice-input/README.md @@ -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. +Choose **Input device** in bundle details to follow the system default or select a browser microphone. The selected input shows its live level only while the menu is open; selecting another input keeps the menu open for comparison. Closing the menu, hiding 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. A missing device or denied permission keeps the menu available for another choice; recording never silently substitutes a different device. + 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. ----- diff --git a/packages/experimental/client-ui-voice-input/README.zh.md b/packages/experimental/client-ui-voice-input/README.zh.md index ab742a6043..b1861742fd 100644 --- a/packages/experimental/client-ui-voice-input/README.zh.md +++ b/packages/experimental/client-ui-voice-input/README.zh.md @@ -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 详情的**输入设备**中选择系统默认或浏览器可用的麦克风。菜单展开时,选中设备旁显示实时输入电平;切换设备后菜单保持展开,便于比较。关闭菜单、隐藏页面或禁用插件会释放预览,包括随后才批准的麦克风授权。浏览器会记住设备选择并用于后续录音,与 Host 的识别设置分别保存。设备缺失或权限被拒绝时,菜单仍可用于重新选择;录音不会静默改用其他设备。 + 下载或重试前,**模型下载源**提供自动选择及 Host 公布的源。SenseVoice 默认提供 Hugging Face 和 HF-Mirror;固定私有部署仅显示配置的源。手动选择只使用指定源,自动选择沿用提供方的比较与回退策略。当前卡片中的重试会保留选择,不修改部署配置或识别偏好。准备期间不能改源,资源就绪后隐藏源选择器。 ----- diff --git a/packages/experimental/client-ui-voice-input/src/client/MicrophonePicker.tsx b/packages/experimental/client-ui-voice-input/src/client/MicrophonePicker.tsx new file mode 100644 index 0000000000..57c2eb55c7 --- /dev/null +++ b/packages/experimental/client-ui-voice-input/src/client/MicrophonePicker.tsx @@ -0,0 +1,93 @@ +/** Device selection owns microphone previews only while its portaled menu is open. */ +import { useEffect, useRef, useState } from 'react' +import { IconCheckOutlineRegular, IconChevronDownOutlineRegular, Menu, StateDot, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives' +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 { NS } from './locales.ts' +import css from './VoiceInput.module.css' + +function InputLevel({ recording, label }: { recording: Recording; label: string }) { + const meter = useRef(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 + {Array.from({ length: 8 }, (_, index) => )} + +} + +/** Render the selected input and preview its measured level without recording audio. */ +export function MicrophonePicker({ useMicrophoneDevice, selectMicrophone, createRecording, t }: + Pick, 'useMicrophoneDevice' | 'selectMicrophone' | 'createRecording'> & PropsLocale) { + const selected = useMicrophoneDevice(value => value) + const [open, setOpen] = useState(false), [devices, setDevices] = useState([]) + const [preview, setPreview] = useState(), [error, setError] = useState(''), [revision, setRevision] = useState(0) + useEffect(() => { + if (!open) return + const capture = createRecording(), media = (navigator as Partial).mediaDevices + const lifetime = new AbortController() + const release = (): void => { void capture.dispose().catch(() => undefined) } + setPreview(undefined); setError('') + const fail = (failure: unknown): void => { + if (lifetime.signal.aborted) return + release(); setPreview(undefined) + setError(failure instanceof RecordingError ? t(failure.kind) : t('failed', { + message: failure instanceof Error ? failure.message : String(failure), + })) + } + const refresh = async (): Promise => { + if (!media) throw new RecordingError('unavailable') + const inputs = (await media.enumerateDevices()).filter(device => device.kind === 'audioinput' && device.deviceId !== '' && device.deviceId !== 'default') + if (!lifetime.signal.aborted) setDevices(inputs.map((device, index) => ({ id: device.deviceId, + label: device.label || t('unnamedMicrophone', { number: String(index + 1) }) }))) + } + void (async () => { + try { + await capture.preview(fail) + if (!lifetime.signal.aborted) setPreview(capture) + } catch (failure) { fail(failure) } + if (lifetime.signal.aborted) return + try { await refresh() } catch (failure) { fail(failure) } + })() + const changed = (): void => { setRevision(value => value + 1) } + const hidden = (): void => { if (document.hidden) setOpen(false) } + media?.addEventListener('devicechange', changed, { signal: lifetime.signal }) + document.addEventListener('visibilitychange', hidden, { signal: lifetime.signal }) + return () => { + lifetime.abort() + release() + } + }, [open, selected.id, revision, createRecording, t]) + const choices = [{ id: '', label: t('systemMicrophone') }, ...devices] + const title = selected.id === '' ? t('systemMicrophone') : selected.label + return
+ {t('inputDevice')} + { setOpen(false) }} portal autoFocus dense selection="fill" + className={css.deviceAnchor} listClassName={css.deviceMenu} selectedId={selected.id} + items={choices.map(device => ({ id: device.id, + icon: {device.id === selected.id && }, + label: + {device.label} + {device.id === selected.id && (preview ? + : !error && )} + , + }))} + onSelect={(id) => { selectMicrophone(choices.find(item => item.id === id) as MicrophoneDevice) }} + footer={error ? [{ id: 'error', type: 'label', text: error }] : []} + anchor={} /> +
+} diff --git a/packages/experimental/client-ui-voice-input/src/client/PreparationCard.tsx b/packages/experimental/client-ui-voice-input/src/client/PreparationCard.tsx index 8d9fcaff5e..bdff8e4b85 100644 --- a/packages/experimental/client-ui-voice-input/src/client/PreparationCard.tsx +++ b/packages/experimental/client-ui-voice-input/src/client/PreparationCard.tsx @@ -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, - 'useSpeechReadiness' | 'configure' | 'prepare' | 'cancelPreparation'> & PropsLocale) { + 'useSpeechReadiness' | 'configure' | 'prepare' | 'cancelPreparation' | 'createRecording' | 'useMicrophoneDevice' | 'selectMicrophone'> & PropsLocale) { const readiness = useSpeechReadiness(value => value), catalog = readiness.catalog const [saving, setSaving] = useState(false), [error, setError] = useState('') const configure = async (patch: SpeechSelectionPatch): Promise => { @@ -143,6 +144,7 @@ export function VoicePreparation({ useSpeechReadiness, ...props }: Pick {catalog &&
+
} diff --git a/packages/experimental/client-ui-voice-input/src/client/VoiceInput.module.css b/packages/experimental/client-ui-voice-input/src/client/VoiceInput.module.css index 84f15cd8c8..715007b36a 100644 --- a/packages/experimental/client-ui-voice-input/src/client/VoiceInput.module.css +++ b/packages/experimental/client-ui-voice-input/src/client/VoiceInput.module.css @@ -38,20 +38,15 @@ .setupEstimate dd { margin: 0; } .setupEstimate small { color: var(--dsw-alias-label-tertiary); font-size: 12px; } -.deviceAnchor { display: block; min-width: 0; } -.deviceTrigger { display: flex; align-items: center; justify-content: space-between; gap: 12px; width: 100%; min-width: 0; border: 0.5px solid var(--dsw-alias-border-l1); border-radius: 6px; padding: 6px 10px; background: transparent; color: inherit; font: inherit; cursor: pointer; } -.deviceMenu { width: 320px; } -.deviceMenu button:disabled { opacity: 1; color: var(--dsw-alias-label-secondary); cursor: default; } -.deviceCheck { display: inline-flex; width: 16px; } -.deviceOption { display: flex; align-items: center; justify-content: space-between; gap: 12px; min-width: 0; } -.deviceName { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; } -.deviceName[data-scrolling='true'] { text-overflow: clip; } -.deviceName[data-scrolling='true'] > span { display: inline-block; } -.deviceName[data-unavailable='true'], .deviceStatus { color: var(--dsw-alias-label-secondary); } -.deviceStatus { flex: none; 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; } } diff --git a/packages/experimental/client-ui-voice-input/tests/device-name.client.spec.tsx b/packages/experimental/client-ui-voice-input/tests/device-name.client.spec.tsx deleted file mode 100644 index ac21de09b4..0000000000 --- a/packages/experimental/client-ui-voice-input/tests/device-name.client.spec.tsx +++ /dev/null @@ -1,72 +0,0 @@ -// @vitest-environment jsdom -/** Hover motion is bounded by the label width and stops with its owning element. */ -import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' -import { afterEach, beforeEach, expect, it, vi } from 'vitest' -import { DeviceName } from '../src/client/DeviceName.tsx' - -beforeEach(() => { - vi.useFakeTimers() - vi.stubGlobal('matchMedia', vi.fn(() => ({ matches: false }))) -}) -afterEach(() => { cleanup(); vi.useRealTimers(); vi.unstubAllGlobals(); vi.restoreAllMocks() }) - -function fixture(width = 220) { - const view = render() - const inner = screen.getByText('Long microphone name'), outer = inner.parentElement! - vi.spyOn(inner, 'getBoundingClientRect').mockReturnValue(new DOMRect(0, 0, width, 20)) - Object.defineProperty(outer, 'clientWidth', { value: 100 }) - const cancel = vi.fn(), animate = vi.fn(() => ({ cancel })) - Object.defineProperty(inner, 'animate', { value: animate }) - return { ...view, outer, inner, cancel, animate } -} - -it('waits 100ms and reveals only the overflow at a fixed speed with pauses at both ends', () => { - const b = fixture() - fireEvent.pointerEnter(b.outer) - act(() => { vi.advanceTimersByTime(99) }) - expect(b.animate).not.toHaveBeenCalled() - act(() => { vi.advanceTimersByTime(1) }) - expect(b.animate).toHaveBeenCalledWith([ - { transform: 'translateX(0)', offset: 0 }, - { transform: 'translateX(-120px)', offset: 4000 / 9200 }, - { transform: 'translateX(-120px)', offset: 4600 / 9200 }, - { transform: 'translateX(0)', offset: 8600 / 9200 }, - { transform: 'translateX(0)', offset: 1 }, - ], { duration: 9200, iterations: Infinity, easing: 'linear' }) - expect(b.outer.dataset.scrolling).toBe('true') - fireEvent.pointerLeave(b.outer) - expect(b.cancel).toHaveBeenCalledOnce() - expect(b.outer.dataset.scrolling).toBeUndefined() -}) - -it('does not start after an early pointer exit or for a name that fits', () => { - const b = fixture(80) - fireEvent.pointerEnter(b.outer) - fireEvent.pointerLeave(b.outer) - act(() => { vi.advanceTimersByTime(100) }) - expect(b.animate).not.toHaveBeenCalled() - fireEvent.pointerEnter(b.outer) - act(() => { vi.advanceTimersByTime(100) }) - expect(b.animate).not.toHaveBeenCalled() -}) - -it('keeps reduced-motion labels static', () => { - vi.stubGlobal('matchMedia', vi.fn(() => ({ matches: true }))) - const b = fixture() - fireEvent.pointerEnter(b.outer) - act(() => { vi.advanceTimersByTime(100) }) - expect(b.animate).not.toHaveBeenCalled() -}) - -it('cancels motion when the device name changes and cancels pending work on unmount', () => { - const b = fixture() - fireEvent.pointerEnter(b.outer) - act(() => { vi.advanceTimersByTime(100) }) - b.rerender() - expect(b.cancel).toHaveBeenCalledOnce() - expect(b.outer.dataset.scrolling).toBeUndefined() - fireEvent.pointerEnter(b.outer) - b.unmount() - act(() => { vi.advanceTimersByTime(100) }) - expect(b.animate).toHaveBeenCalledOnce() -}) diff --git a/packages/experimental/client-ui-voice-input/tests/microphone-picker.client.spec.tsx b/packages/experimental/client-ui-voice-input/tests/microphone-picker.client.spec.tsx index ee7dec9273..5f2374ba83 100644 --- a/packages/experimental/client-ui-voice-input/tests/microphone-picker.client.spec.tsx +++ b/packages/experimental/client-ui-voice-input/tests/microphone-picker.client.spec.tsx @@ -1,6 +1,6 @@ // @vitest-environment jsdom -/** Menu-only capture, selected input routing, and late permissions use real audio ownership. */ -import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react' +/** Focus-only 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' @@ -40,30 +40,33 @@ function fixture() { const view = render() return { ...audio, ...view, media, enumerateDevices, recordings, props, selection } } -async function open(): Promise { - fireEvent.click(screen.getByRole('button', { name: zh.inputDevice })) - await screen.findByRole('menuitem', { name: 'USB microphone' }) +function selector(): HTMLSelectElement { + return screen.getByRole('combobox', { name: zh.inputDevice }) } -it('captures only while open, moves the meter to the selected input, and remembers it for recording', async () => { +async function focusPicker(): Promise { + fireEvent.focus(selector()) + await screen.findByRole('img', { name: zh.inputLevel }) +} + +it('captures only while focused, shows the meter in the field, and remembers the selected input for recording', async () => { const b = fixture() try { expect(b.getUserMedia).not.toHaveBeenCalled() - await open() - expect(screen.queryByRole('menuitem', { name: 'Speakers' })).toBeNull() - expect(within(screen.getByRole('menuitem', { name: new RegExp(zh.systemMicrophone) })).getByRole('img', { name: zh.inputLevel })).toBeTruthy() - fireEvent.click(screen.getByRole('menuitem', { name: 'USB microphone' })) + await focusPicker() + 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 waitFor(() => { expect(within(screen.getByRole('menuitem', { name: /USB microphone/ })).getByRole('img', { name: zh.inputLevel })).toBeTruthy() }) - expect(screen.getAllByRole('img', { name: zh.inputLevel })).toHaveLength(1) + await screen.findByRole('img', { name: zh.inputLevel }) + expect(selector().selectedOptions[0]?.textContent).toBe('USB microphone') expect(document.querySelectorAll('[data-active="true"]').length).toBeGreaterThan(0) - fireEvent.keyDown(screen.getByRole('menuitem', { name: /USB microphone/ }), { key: 'Escape' }) - await waitFor(() => { expect(screen.queryByRole('menu')).toBeNull() }) + fireEvent.blur(selector()) + await waitFor(() => { expect(screen.queryByRole('img', { name: zh.inputLevel })).toBeNull() }) expect(b.trackStop).toHaveBeenCalledTimes(2) - expect(screen.queryByRole('img', { name: zh.inputLevel })).toBeNull() expect(createMicrophoneDeviceStore().getSnapshot()).toEqual({ id: 'usb', label: 'USB microphone' }) const recording = b.props.createRecording() await recording.start() @@ -71,13 +74,13 @@ it('captures only while open, moves the meter to the selected input, and remembe } finally { b.unmount(); await Promise.all(b.recordings.map(recording => recording.dispose())) } }) -it('releases a permission grant arriving after the menu closes', async () => { +it('releases a permission grant arriving after the field loses focus', async () => { const b = fixture(), granted = Promise.withResolvers() b.getUserMedia.mockReturnValueOnce(granted.promise) try { - fireEvent.click(screen.getByRole('button', { name: zh.inputDevice })) + fireEvent.focus(selector()) await waitFor(() => { expect(b.getUserMedia).toHaveBeenCalledOnce() }) - fireEvent.click(screen.getByRole('button', { name: zh.inputDevice })) + fireEvent.blur(selector()) await act(async () => { granted.resolve(b.stream) }) expect(b.trackStop).toHaveBeenCalledOnce() expect(screen.queryByRole('img', { name: zh.inputLevel })).toBeNull() @@ -88,9 +91,9 @@ it('keeps device choices available after permission denial', async () => { const b = fixture() b.getUserMedia.mockRejectedValueOnce(new DOMException('denied', 'NotAllowedError')) try { - await open() - expect(screen.getByText(zh.permission)).toBeTruthy() - fireEvent.click(screen.getByRole('menuitem', { name: 'USB microphone' })) + fireEvent.focus(selector()) + 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())) } @@ -101,18 +104,18 @@ it.each([null, 'usb', { id: 3, label: 'USB' }, { id: 'usb' }])('ignores invalid expect(createMicrophoneDeviceStore().getSnapshot()).toEqual({ id: '', label: '' }) }) -it('closes on page hiding and releases tracks even when AudioContext closure fails', async () => { +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 open() + await focusPicker() hidden.mockReturnValue(false) fireEvent(document, new Event('visibilitychange')) - expect(screen.getByRole('menu')).toBeTruthy() + 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('menu')).toBeNull() }) + await waitFor(() => { expect(screen.queryByRole('img', { name: zh.inputLevel })).toBeNull() }) expect(b.trackStop).toHaveBeenCalledOnce() } finally { hidden.mockRestore(); b.unmount() @@ -125,35 +128,36 @@ it.each([new Error('device busy'), 'device busy'])('shows capture failures witho b.getUserMedia.mockRejectedValueOnce(failure) b.enumerateDevices.mockResolvedValueOnce([{ kind: 'audioinput', deviceId: 'unnamed', label: '' }]) try { - fireEvent.click(screen.getByRole('button', { name: zh.inputDevice })) - await screen.findByRole('menuitem', { name: '麦克风 1' }) - expect(screen.getByText('语音识别失败:device busy')).toBeTruthy() + fireEvent.focus(selector()) + 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 inside the menu', async () => { +it('reports unavailable browser capture and enumeration failures beside the field', async () => { const b = fixture() try { vi.stubGlobal('navigator', {}) - fireEvent.click(screen.getByRole('button', { name: zh.inputDevice })) + b.props.createRecording = () => new Recording(() => {}, b.selection.getSnapshot().id) + b.rerender() await screen.findByText(zh.unavailable) - fireEvent.click(screen.getByRole('button', { name: zh.inputDevice })) vi.stubGlobal('navigator', { mediaDevices: b.media }) b.enumerateDevices.mockRejectedValueOnce(new Error('enumeration failed')) - fireEvent.click(screen.getByRole('button', { name: zh.inputDevice })) + b.props.createRecording = () => new Recording(() => {}, b.selection.getSnapshot().id) + b.rerender() 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 menu closes', async () => { +it('ignores device enumeration that settles after the field loses focus', async () => { const b = fixture(), result = Promise.withResolvers>>() b.enumerateDevices.mockReturnValueOnce(result.promise) try { - fireEvent.click(screen.getByRole('button', { name: zh.inputDevice })) + fireEvent.focus(selector()) await waitFor(() => { expect(b.enumerateDevices).toHaveBeenCalledTimes(2) }) - fireEvent.click(screen.getByRole('button', { name: zh.inputDevice })) + fireEvent.blur(selector()) await act(async () => { result.resolve([{ kind: 'audioinput', deviceId: 'late', label: 'Late microphone' }]) }) - expect(screen.queryByText('Late microphone')).toBeNull() + expect(screen.queryByRole('option', { name: 'Late microphone' })).toBeNull() expect(b.getUserMedia).not.toHaveBeenCalled() } finally { b.unmount(); await Promise.all(b.recordings.map(recording => recording.dispose())) } }) @@ -168,7 +172,7 @@ it('does not publish a preview when the component unmounts at acquisition comple } b.rerender() try { - fireEvent.click(screen.getByRole('button', { name: zh.inputDevice })) + fireEvent.focus(selector()) await act(async () => { await acquired.promise }) const enumerations = b.enumerateDevices.mock.calls.length b.unmount() @@ -189,7 +193,7 @@ it('ignores enumeration failure and a queued interruption after unmount', async } b.rerender() try { - await open() + await focusPicker() b.enumerateDevices.mockReturnValueOnce(pending.promise) act(() => { b.media.dispatchEvent(new Event('devicechange')) }) const acquisitions = b.getUserMedia.mock.calls.length @@ -201,9 +205,9 @@ it('ignores enumeration failure and a queued interruption after unmount', async }) async function selectUsb(b: ReturnType): Promise { - await open() - fireEvent.click(screen.getByRole('menuitem', { name: 'USB microphone' })) - await screen.findByRole('menuitem', { name: new RegExp(`^USB microphone\\s*${zh.inputLevel}$`) }) + await focusPicker() + fireEvent.change(selector(), { target: { value: 'usb' } }) + await screen.findByRole('img', { name: zh.inputLevel }) await act(async () => {}) expect(b.selection.getSnapshot().id).toBe('usb') } @@ -215,13 +219,14 @@ it('inserts and removes unused devices without restarting the selected microphon 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('menuitem', { name: 'New microphone' })).toBeTruthy() + 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('menuitem', { name: 'Built-in microphone' })).toBeNull() + expect(screen.queryByRole('option', { name: 'Built-in microphone' })).toBeNull() expect(b.getUserMedia).toHaveBeenCalledTimes(acquisitions) expect(b.trackStop).toHaveBeenCalledTimes(stops) - expect(screen.getByRole('menuitem', { name: new RegExp(`^USB microphone\\s*${zh.inputLevel}$`) })).toBeTruthy() + 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())) } }) @@ -230,41 +235,41 @@ it.each(['track-first', 'devices-first'])('retains the disconnected selection an 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('menuitem').findIndex(item => item.textContent?.includes('USB microphone')) + 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('menuitem', { name: new RegExp(`^USB microphone\\s*${zh.deviceUnavailable}$`) }) - expect((unavailable as HTMLButtonElement).disabled).toBe(true) - expect(screen.getAllByRole('menuitem')[index]).toBe(unavailable) + const unavailable = screen.getByRole('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.click(unavailable) + fireEvent.change(selector(), { target: { value: 'usb' } }) expect(b.getUserMedia).toHaveBeenCalledTimes(acquisitions) b.enumerateDevices.mockResolvedValue(existing) await act(async () => { b.media.dispatchEvent(new Event('devicechange')) }) - expect(screen.getByRole('menuitem', { name: new RegExp(`^USB microphone\\s*${zh.inputLevel}$`) })).toBeTruthy() + 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('updates a closed picker on disconnect and reconnect without acquiring audio', async () => { +it('updates an unfocused picker on disconnect and reconnect without acquiring audio', async () => { const b = fixture() try { await selectUsb(b) const existing = await b.enumerateDevices(), acquisitions = b.getUserMedia.mock.calls.length - fireEvent.click(screen.getByRole('button', { name: zh.inputDevice })) + fireEvent.blur(selector()) b.enumerateDevices.mockResolvedValue(existing.filter(device => device.deviceId !== 'usb')) await act(async () => { b.media.dispatchEvent(new Event('devicechange')) }) - expect(screen.getByRole('button', { name: zh.inputDevice }).textContent).toContain(zh.deviceUnavailable) + expect(selector().selectedOptions[0]?.textContent).toContain(zh.deviceUnavailable) b.enumerateDevices.mockResolvedValue(existing) await act(async () => { b.media.dispatchEvent(new Event('devicechange')) }) - expect(screen.queryByText(zh.deviceUnavailable)).toBeNull() + expect(selector().selectedOptions[0]?.textContent).toBe('USB microphone') expect(b.getUserMedia).toHaveBeenCalledTimes(acquisitions) expect(screen.queryByRole('img', { name: zh.inputLevel })).toBeNull() } finally { b.unmount(); await Promise.all(b.recordings.map(recording => recording.dispose())) } @@ -277,13 +282,13 @@ it('forgets the missing row when another microphone is selected and does not swi const existing = await b.enumerateDevices() b.enumerateDevices.mockResolvedValue(existing.filter(device => device.deviceId !== 'usb')) await act(async () => { b.media.dispatchEvent(new Event('devicechange')) }) - fireEvent.click(screen.getByRole('menuitem', { name: 'Built-in microphone' })) - await screen.findByRole('menuitem', { name: new RegExp(`^Built-in microphone\\s*${zh.inputLevel}$`) }) - expect(screen.queryByText('USB microphone')).toBeNull() + 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('menuitem', { name: 'USB microphone' })).toBeTruthy() + 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())) } @@ -299,7 +304,7 @@ it.each(['resolve', 'reject'])('ignores an older enumeration after a newer hot-p 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('menuitem', { name: new RegExp(`^USB microphone\\s*${zh.deviceUnavailable}$`) })).toBeTruthy() + 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())) } @@ -308,10 +313,10 @@ it.each(['resolve', 'reject'])('ignores an older enumeration after a newer hot-p it('releases a late grant after unplugging the requested device and allows a fresh reconnect', async () => { const b = fixture(), grant = Promise.withResolvers() try { - await open() + await focusPicker() const existing = await b.enumerateDevices() b.getUserMedia.mockReturnValueOnce(grant.promise) - fireEvent.click(screen.getByRole('menuitem', { name: 'USB microphone' })) + 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')) }) @@ -320,7 +325,7 @@ it('releases a late grant after unplugging the requested device and allows a fre const stops = b.trackStop.mock.calls.length await act(async () => { grant.resolve(b.stream) }) expect(b.trackStop).toHaveBeenCalledTimes(stops + 1) - expect(screen.getByRole('menuitem', { name: new RegExp(`^USB microphone\\s*${zh.inputLevel}$`) })).toBeTruthy() + await screen.findByRole('img', { name: zh.inputLevel }) } finally { grant.resolve(b.stream); b.unmount(); await Promise.all(b.recordings.map(recording => recording.dispose())) } }) @@ -329,8 +334,7 @@ it('requests permission for a remembered device when enumeration hides its ident try { b.enumerateDevices.mockResolvedValue([{ kind: 'audioinput', deviceId: '', label: '' }]) act(() => { b.selection.set({ id: 'usb', label: 'USB microphone' }) }) - fireEvent.click(screen.getByRole('button', { name: zh.inputDevice })) - await screen.findByRole('menuitem', { name: new RegExp(`^USB microphone\\s*${zh.inputLevel}$`) }) + await focusPicker() expect(screen.queryByText(zh.deviceUnavailable)).toBeNull() } finally { b.unmount(); await Promise.all(b.recordings.map(recording => recording.dispose())) } }) @@ -338,25 +342,26 @@ it('requests permission for a remembered device when enumeration hides its ident it('recovers system-default preview after its track ends and shows unavailable when no input remains', async () => { const b = fixture() try { - await open() + await focusPicker() 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('menuitem', { name: new RegExp(`^系统默认(Fixture microphone)\\s*${zh.deviceUnavailable}$`) }) + const unavailable = screen.getByRole('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 open microphone', async () => { +it('updates locale text without re-enumerating or restarting the focused microphone', async () => { const b = fixture() try { - await open() + await focusPicker() const acquisitions = b.getUserMedia.mock.calls.length, enumerations = b.enumerateDevices.mock.calls.length b.rerender() - expect(screen.getByRole('button', { name: en.inputDevice }).textContent).toContain('System default (Fixture microphone)') + expect(screen.getByRole('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() @@ -368,13 +373,14 @@ it('persists raw unnamed labels and translates a retained permission failure', a b.enumerateDevices.mockResolvedValue([{ kind: 'audioinput', deviceId: 'unnamed', label: '' }]) b.getUserMedia.mockRejectedValue(new DOMException('denied', 'NotAllowedError')) try { - fireEvent.click(screen.getByRole('button', { name: zh.inputDevice })) - fireEvent.click(await screen.findByRole('menuitem', { name: '麦克风 1' })) + fireEvent.focus(selector()) + 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() - expect(screen.getByRole('button', { name: en.inputDevice }).textContent).toBe('Microphone 1') + expect(screen.getByRole('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())) } @@ -384,24 +390,24 @@ it('shows a localized name while a restored unnamed selection awaits enumeration localStorage.setItem('dsh.voice-input.microphone', JSON.stringify({ id: 'usb', label: '' })) const b = fixture() try { - expect(screen.getByRole('button', { name: zh.inputDevice }).textContent).toBe(zh.unnamedSelectedMicrophone) - await waitFor(() => { expect(screen.getByRole('button', { name: zh.inputDevice }).textContent).toBe('USB microphone') }) + expect(selector().selectedOptions[0]?.textContent).toBe(zh.unnamedSelectedMicrophone) + await waitFor(() => { expect(selector().selectedOptions[0]?.textContent).toBe('USB microphone') }) expect(b.getUserMedia).not.toHaveBeenCalled() } finally { b.unmount(); await Promise.all(b.recordings.map(recording => recording.dispose())) } }) -it('shows refreshed names in the trigger and retains them on disconnect', async () => { +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(screen.getByRole('button', { name: zh.inputDevice }).textContent).toBe('Renamed microphone') + 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(screen.getByRole('button', { name: zh.inputDevice }).textContent).toBe(`Renamed microphone${zh.deviceUnavailable}`) + expect(selector().selectedOptions[0]?.textContent).toBe(`Renamed microphone — ${zh.deviceUnavailable}`) } finally { b.unmount(); await Promise.all(b.recordings.map(recording => recording.dispose())) } }) @@ -415,13 +421,14 @@ it('shows an enumerated system default without capture and keeps the communicati b.enumerateDevices.mockResolvedValue(inputs) try { await act(async () => { b.media.dispatchEvent(new Event('devicechange')) }) - expect(screen.getByRole('button', { name: zh.inputDevice }).textContent).toBe('系统默认(Built-in microphone)') + expect(selector().selectedOptions[0]?.textContent).toBe('系统默认(Built-in microphone)') expect(b.getUserMedia).not.toHaveBeenCalled() - fireEvent.click(screen.getByRole('button', { name: zh.inputDevice })) - fireEvent.click(await screen.findByRole('menuitem', { name: 'Communications headset' })) + fireEvent.focus(selector()) + 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('menuitem', { name: 'Default alias' })).toBeNull() + expect(screen.queryByRole('option', { name: 'Default alias' })).toBeNull() } finally { b.unmount(); await Promise.all(b.recordings.map(recording => recording.dispose())) } }) @@ -436,9 +443,9 @@ it('uses the acquired device group to show its physical name without the browser b.enumerateDevices.mockResolvedValue(devices) try { await act(async () => { b.media.dispatchEvent(new Event('devicechange')) }) - expect(screen.getByRole('button', { name: zh.inputDevice }).textContent).toBe('系统默认(Other microphone)') - fireEvent.click(screen.getByRole('button', { name: zh.inputDevice })) - await waitFor(() => { expect(screen.getByRole('button', { name: zh.inputDevice }).textContent).toBe('系统默认(Active microphone)') }) + expect(selector().selectedOptions[0]?.textContent).toBe('系统默认(Other microphone)') + fireEvent.focus(selector()) + await waitFor(() => { expect(selector().selectedOptions[0]?.textContent).toBe('系统默认(Active microphone)') }) expect(b.getUserMedia).toHaveBeenCalledOnce() } finally { b.unmount(); await Promise.all(b.recordings.map(recording => recording.dispose())) } }) @@ -447,9 +454,9 @@ it('does not restore a previous default device name and persists only the system localStorage.setItem('dsh.voice-input.microphone', JSON.stringify({ id: '', label: 'Previous default microphone' })) const b = fixture() try { - expect(screen.getByRole('button', { name: zh.inputDevice }).textContent).toBe(zh.systemMicrophone) - await open() - fireEvent.click(screen.getByRole('menuitem', { name: /系统默认/ })) + expect(selector().selectedOptions[0]?.textContent).toBe(zh.systemMicrophone) + await focusPicker() + 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())) } diff --git a/snapshots/web/voice-input/device-long.expected.md b/snapshots/web/voice-input/device-long.expected.md index f2e9c574c8..32e1e65434 100644 --- a/snapshots/web/voice-input/device-long.expected.md +++ b/snapshots/web/voice-input/device-long.expected.md @@ -1,6 +1,6 @@ -- menu: - - menuitem "System default (Fake Default Audio Input)" - - menuitem "USB studio microphone — conference room recording input Microphone input level": - - text: USB studio microphone — conference room recording input - - img "Microphone input level" - - menuitem "Fake Audio Input 2" +- 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" diff --git a/snapshots/web/voice-input/device-missing.expected.md b/snapshots/web/voice-input/device-missing.expected.md index d4333f9441..dbdbb99468 100644 --- a/snapshots/web/voice-input/device-missing.expected.md +++ b/snapshots/web/voice-input/device-missing.expected.md @@ -1,4 +1,5 @@ -- menu: - - menuitem "System default (Fake Default Audio Input)" - - menuitem "Fake Audio Input 1 Unavailable" [disabled] - - menuitem "Fake Audio Input 2" +- 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" diff --git a/snapshots/web/voice-input/device.expected.md b/snapshots/web/voice-input/device.expected.md index 915a1895ba..84782b3db9 100644 --- a/snapshots/web/voice-input/device.expected.md +++ b/snapshots/web/voice-input/device.expected.md @@ -1,6 +1,6 @@ -- menu: - - menuitem "System default (Fake Default Audio Input)" - - menuitem "Fake Audio Input 1 Microphone input level": - - text: Fake Audio Input 1 - - img "Microphone input level" - - menuitem "Fake Audio Input 2" +- 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" From 57d829f9b2f6df6e4ba053af82e3aa384603f978 Mon Sep 17 00:00:00 2001 From: yudshj Date: Thu, 8 Oct 2026 12:00:37 +0800 Subject: [PATCH 6/6] fix(voice-input): keep device level active on page --- apps/web/tests/voice-input.e2e.ts | 15 ++-- .../client-ui-voice-input/README.i18n.yaml | 4 +- .../client-ui-voice-input/README.md | 2 +- .../client-ui-voice-input/README.zh.md | 2 +- .../src/client/MicrophonePicker.tsx | 14 +-- .../tests/microphone-picker.client.spec.tsx | 87 ++++++++++--------- 6 files changed, 63 insertions(+), 61 deletions(-) diff --git a/apps/web/tests/voice-input.e2e.ts b/apps/web/tests/voice-input.e2e.ts index a74c87a836..133bcb416a 100644 --- a/apps/web/tests/voice-input.e2e.ts +++ b/apps/web/tests/voice-input.e2e.ts @@ -138,7 +138,6 @@ it.skipIf(webSnapshotMode() === 'record')('guides voice setup, records from stan preparation = { phase: 'standby' } for (const listener of readinessListeners) listener() const devicePicker = page.getByRole('combobox', { name: 'Input device', exact: true }) - await devicePicker.focus() const level = page.getByRole('img', { name: 'Microphone input level', exact: true }) await level.waitFor() const devices = await page.evaluate(async () => (await navigator.mediaDevices.enumerateDevices()) @@ -177,20 +176,17 @@ it.skipIf(webSnapshotMode() === 'record')('guides voice setup, records from stan 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 devicePicker.blur() - await level.waitFor({ state: 'hidden' }) - await expect.poll(() => page.evaluate(() => (window as Window & { voiceTestStreams?: MediaStream[] }).voiceTestStreams! - .every(stream => stream.getTracks().every(track => track.readyState === 'ended')))).toBe(true) 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') - expect(await page.evaluate(() => (window as Window & { voiceTestStreams?: MediaStream[] }).voiceTestStreams!.length)).toBe(acquisitions) - await devicePicker.focus() 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 }).voiceTestNames![id] = label @@ -198,17 +194,18 @@ it.skipIf(webSnapshotMode() === 'record')('guides voice setup, records from stan }, { 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 devicePicker.focus() 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 devicePicker.blur() 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) diff --git a/packages/experimental/client-ui-voice-input/README.i18n.yaml b/packages/experimental/client-ui-voice-input/README.i18n.yaml index 755c087ab6..c906ffc9c7 100644 --- a/packages/experimental/client-ui-voice-input/README.i18n.yaml +++ b/packages/experimental/client-ui-voice-input/README.i18n.yaml @@ -15,8 +15,8 @@ en: d152484eb41ac6b4 zh: 364c80ee00a374ff /deepseek-ai-dsh-experimental-client-ui-voice-input/use-this-package: - en: 3a3bdf2bd94f3afc - zh: b72a34cfe23a59d7 + en: 4db1c67430cfa691 + zh: 484808eeff0d7692 /deepseek-ai-dsh-experimental-client-ui-voice-input/understand-the-implementation: en: 8fe96eeef9afe7d7 zh: 11936f647ded39eb diff --git a/packages/experimental/client-ui-voice-input/README.md b/packages/experimental/client-ui-voice-input/README.md index 79a3ba5c18..3eba1c3825 100644 --- a/packages/experimental/client-ui-voice-input/README.md +++ b/packages/experimental/client-ui-voice-input/README.md @@ -27,7 +27,7 @@ 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 the selector is focused, the selected input's live level appears at the right of the field; selecting another input restarts the preview for that device. Leaving the field, hiding 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 restores its preview while the selector remains focused; 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. +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. diff --git a/packages/experimental/client-ui-voice-input/README.zh.md b/packages/experimental/client-ui-voice-input/README.zh.md index 8d26dbc0c5..b7e9164a02 100644 --- a/packages/experimental/client-ui-voice-input/README.zh.md +++ b/packages/experimental/client-ui-voice-input/README.zh.md @@ -27,7 +27,7 @@ kind: "package-reference" 在插件管理页启用[语音输入 Bundle](../voice-input-bundle/README.zh.md)。缓存检查后,缺少模型时弹出安装引导,可选择“前往安装”或“稍后”。“前往安装”打开 Bundle 详情,再点击“下载并准备”才开始安装。缓存完整时不弹引导,也无需重复安装。识别未就绪时不显示麦克风悬停提示,点击后才打开同一个引导弹窗;点击引导按钮进入语音 Bundle 详情,不会录音或下载。缓存检查、正在准备、准备失败或服务断连时,弹窗改为引导查看状态、进度或错误详情。弹窗将焦点移到导航按钮,并将 Tab 切换约束在弹窗内;选择“稍后”或按 Escape 关闭时恢复此前焦点,不改变或提交草稿。准备前,本地 Provider 可展示磁盘、内存与安装时间的估算。准备摘要默认折叠;展开后可查看已完成、当前和未开始的步骤。就绪后点击麦克风,允许访问,再点击停止以转写。取消或 Escape 丢弃录音。识别反馈留在工具栏内部。Provider 和语言偏好在 Bundle 详情中通过 Settings 服务保存。浏览器麦克风访问需要 HTTPS 或环回地址,以及操作系统权限。 -在 Bundle 详情中使用原生**输入设备**选择器,跟随系统默认设备或选择浏览器可用的麦克风。系统默认在名称可用时显示当前设备名。选择器获得焦点后,已选输入的实时电平显示在字段右侧;切换设备会为该设备重新启动预览。移出字段、隐藏页面或禁用插件会释放预览,包括随后才批准的麦克风授权。浏览器会记住设备选择并用于后续录音,与 Host 的识别设置分别保存。插入和拔出设备会刷新选项,不打断仍然可用的已选输入。已选设备断开后会作为禁用选项保留名称和位置,并显示“不可用”。选择器仍有焦点时,重新连接会恢复预览;改选其他输入后移除不可用选项。录音中断后需要手动重新开始。权限被拒绝时仍可选择其他设备;明确指定的设备不会静默回退到其他输入。 +在 Bundle 详情中使用原生**输入设备**选择器,跟随系统默认设备或选择浏览器可用的麦克风。系统默认在名称可用时显示当前设备名。Bundle 详情处于活动状态时,已选输入的实时电平持续显示在字段右侧;切换设备会为该设备重新启动预览。隐藏或离开页面、禁用插件时会释放预览,包括随后才批准的麦克风授权。浏览器会记住设备选择并用于后续录音,与 Host 的识别设置分别保存。插入和拔出设备会刷新选项,不打断仍然可用的已选输入。已选设备断开后会作为禁用选项保留名称和位置,并显示“不可用”。页面处于活动状态时,重新连接会恢复预览;改选其他输入后移除不可用选项。录音中断后需要手动重新开始。权限被拒绝时仍可选择其他设备;明确指定的设备不会静默回退到其他输入。 下载或重试前,**模型下载源**提供自动选择及 Host 公布的源。SenseVoice 默认提供 Hugging Face 和 HF-Mirror;固定私有部署仅显示配置的源。手动选择只使用指定源,自动选择沿用提供方的比较与回退策略。当前卡片中的重试会保留选择,不修改部署配置或识别偏好。准备期间不能改源,资源就绪后隐藏源选择器。 diff --git a/packages/experimental/client-ui-voice-input/src/client/MicrophonePicker.tsx b/packages/experimental/client-ui-voice-input/src/client/MicrophonePicker.tsx index e07eee3058..b73f237113 100644 --- a/packages/experimental/client-ui-voice-input/src/client/MicrophonePicker.tsx +++ b/packages/experimental/client-ui-voice-input/src/client/MicrophonePicker.tsx @@ -1,4 +1,4 @@ -/** Device selection owns microphone previews only while its native field is focused. */ +/** 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' @@ -33,7 +33,7 @@ function InputLevel({ recording, label }: { recording: Recording; label: string export function MicrophonePicker({ useMicrophoneDevice, selectMicrophone, createRecording, t }: Pick, 'useMicrophoneDevice' | 'selectMicrophone' | 'createRecording'> & PropsLocale) { const selected = useMicrophoneDevice(value => value) - const [focused, setFocused] = useState(false), [devices, setDevices] = useState([ + const [devices, setDevices] = useState([ { id: selected.id, label: selected.id === '' ? '' : selected.label }, ]) const [preview, setPreview] = useState(), [error, setError] = useState<{ failure: unknown }>() @@ -98,11 +98,14 @@ export function MicrophonePicker({ useMicrophoneDevice, selectMicrophone, create return next }) if (missing) { release(); setPreview(undefined); setError(undefined) } - else if (focused && !document.hidden && !capture) void acquire() + 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) setFocused(false); else changed() } + 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() @@ -110,7 +113,7 @@ export function MicrophonePicker({ useMicrophoneDevice, selectMicrophone, create lifetime.abort() release() } - }, [focused, selected.id, createRecording]) + }, [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 => { @@ -122,7 +125,6 @@ export function MicrophonePicker({ useMicrophoneDevice, selectMicrophone, create