Merge pull request #4949 from deepseek-harness/worktree/fix-voice-language-settings
fix(voice-input): persist recognition language settings
This commit is contained in:
commit
108f767a89
8 files changed
+82
-6
No files matched your search
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/experimental/speech-to-text/README.md
|
||||
README.md: 64202aba4ae7e0ffc47c86243d818caa8f85a9e4
|
||||
README.zh.md: 38466f18a0670a252958c6b51c17457fcf80f14c
|
||||
README.md: 3dafb5c7cfb9c3946be329e6e67b876ddebfb322
|
||||
README.zh.md: d665e5c05f7c220ecd838108b97ae886af7d952a
|
||||
@@ -37,7 +37,7 @@ Load through the [voice input bundle](../voice-input-bundle/README.md), or compo
|
||||
|
||||
`resolve()` captures the provider instance, recording and language. `transcribe()` rejects a withdrawn or replaced registration. A registration disposer closes admission, aborts accepted requests and joins provider settlement; providers must honor cancellation. No fallback selects a different recognizer or uploads audio. No runtime invariant companion is published because the registry is the sole source of provider and preparation observations.
|
||||
|
||||
`defaultProvider` and `language` are volatile Config fields: `configure()` writes the supplied fields into this plugin's profile entry through the `settings` service, and the running instance reads the updated values without remounting; composition defaults apply until an override is saved. `configure()` fails without `settings` or a profile entry, and rejects a language unsupported by the selected provider before saving. Providers advertise accepted language hints through `languages`; `resolve()` validates the selected hint before transcription. Provider-owned preparation is observed through complete `SpeechSnapshot` values; closing an observer never cancels preparation.
|
||||
`defaultProvider` and `language` are volatile Config fields: `configure()` writes the supplied fields into this plugin's profile entry through the `settings` service, and the running instance reads the updated values without remounting; composition defaults apply until an override is saved. Settings addresses the entry by its configured id (`entry.options.id`), without the Loader's Include path. `configure()` fails without `settings` or a profile entry, and rejects a language unsupported by the selected provider before saving. Providers advertise accepted language hints through `languages`; `resolve()` validates the selected hint before transcription. Provider-owned preparation is observed through complete `SpeechSnapshot` values; closing an observer never cancels preparation.
|
||||
|
||||
The `./wave` helper validates canonical 16 kHz mono PCM16 WAV for the Remote consumer and the native recognition process. Both reject inconsistent headers and lengths before decoding samples.
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ kind: "package-reference"
|
||||
|
||||
`resolve()` 捕获 Provider 实例、录音和语言。`transcribe()` 拒绝已撤销或替换的注册。注册的清理函数先拒绝新请求,再取消已接收的请求并等待 Provider 完成;Provider 必须响应取消。服务不会回退到其他识别器或上传音频。不发布运行时不变量伴随模块,因为注册表是 Provider 与准备状态观测的唯一来源。
|
||||
|
||||
`defaultProvider` 与 `language` 是 volatile Config 字段:`configure()` 通过 `settings` 服务把传入的字段写入本插件的 profile 条目,运行中的实例无需重挂载即可读到更新值;保存覆盖值之前使用组合配置默认值。没有 `settings` 或 profile 条目时 `configure()` 失败,并在保存前拒绝所选 Provider 不支持的语言。Provider 通过 `languages` 公布支持的语言提示;`resolve()` 在转写前校验所选提示。观察者通过完整 `SpeechSnapshot` 接收 Provider 拥有的准备状态;关闭观察者不会取消准备。
|
||||
`defaultProvider` 与 `language` 是 volatile Config 字段:`configure()` 通过 `settings` 服务把传入的字段写入本插件的 profile 条目,运行中的实例无需重挂载即可读到更新值;保存覆盖值之前使用组合配置默认值。Settings 使用条目配置中的 id(`entry.options.id`)定位条目,不包含 Loader 的 Include 路径。没有 `settings` 或 profile 条目时 `configure()` 失败,并在保存前拒绝所选 Provider 不支持的语言。Provider 通过 `languages` 公布支持的语言提示;`resolve()` 在转写前校验所选提示。观察者通过完整 `SpeechSnapshot` 接收 Provider 拥有的准备状态;关闭观察者不会取消准备。
|
||||
|
||||
`./wave` 辅助函数为 Remote 消费者与原生识别进程校验规范的 16 kHz 单声道 PCM16 WAV。两者均在解码采样前拒绝不一致的头部和长度。
|
||||
|
||||
|
||||
@@ -46,6 +46,8 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"@deepseek-ai/dsh-app-boot": "workspace:^",
|
||||
"@deepseek-ai/dsh-config-editor": "workspace:^",
|
||||
"@deepseek-ai/dsh-settings": "workspace:^",
|
||||
"@deepseek-ai/cordis-plugin-loader": "workspace:^"
|
||||
},
|
||||
|
||||
@@ -41,12 +41,12 @@ export default class SpeechToText extends Service {
|
||||
private readonly providers = new Map<SpeechProviderId, Registration>()
|
||||
private readonly listeners = new Set<() => void>()
|
||||
private readonly lifetime = new AbortController()
|
||||
/** Profile entry `configure()` writes to; absent when the plugin was mounted without Loader. */
|
||||
/** Profile-local entry id used by Settings; absent when the plugin was mounted without Loader. */
|
||||
private readonly entryId: string | undefined
|
||||
|
||||
constructor(ctx: Context, private readonly config: Config) {
|
||||
super(ctx, 'speechToText')
|
||||
this.entryId = ctx.fiber.entry?.id
|
||||
this.entryId = ctx.fiber.entry?.options.id
|
||||
ctx.on('loader/volatile-update', () => { this.changed() })
|
||||
ctx.effect(() => async () => {
|
||||
this.lifetime.abort(new Error('Speech service disposed'))
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
/** Speech preferences persisted through the profile's Loader and Settings services. */
|
||||
import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { expect, it } from 'vitest'
|
||||
import { boot, initProfile, readProfilePatches, type ProfileContext } from '@deepseek-ai/dsh-app-boot'
|
||||
import ConfigEditor from '@deepseek-ai/dsh-config-editor'
|
||||
import Settings from '@deepseek-ai/dsh-settings'
|
||||
import SpeechToText from '../src/index.ts'
|
||||
import type { SpeechProviderId } from '../src/types.ts'
|
||||
|
||||
it('saves language and provider preferences without remounting and restores them at restart', async () => {
|
||||
const home = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-speech-configuration-')))
|
||||
let ctx: Awaited<ReturnType<typeof boot>> | undefined
|
||||
try {
|
||||
const dir = join(home, 'profiles', 'test')
|
||||
initProfile(dir, ['test-bundle'])
|
||||
const bundle = join(dir, 'node_modules', 'test-bundle')
|
||||
mkdirSync(bundle, { recursive: true })
|
||||
writeFileSync(join(home, 'package.json'), '{"name":"test-installation"}\n')
|
||||
writeFileSync(join(bundle, 'package.json'), JSON.stringify({ name: 'test-bundle', version: '1.0.0', dsh: { bundle: { patch: 'cordis.patch.yml' } } }))
|
||||
writeFileSync(join(bundle, 'cordis.patch.yml'), readFileSync(new URL('./fixtures/selection.patch.yml', import.meta.url)))
|
||||
writeFileSync(join(dir, 'cordis.yml'), '[]\n')
|
||||
const profile: ProfileContext = {
|
||||
name: 'test', startedBundles: ['test-bundle'], dir, patchPath: join(dir, 'cordis.patch.yml'),
|
||||
installAnchor: join(home, 'package.json'), cwd: home, home, overlays: [], telemetryDisabledEnv: undefined,
|
||||
}
|
||||
const start = () => boot('test', join(dir, 'cordis.yml'), readProfilePatches('test', profile), (root) => {
|
||||
root.provide('profileContext', profile)
|
||||
Object.assign(root.loader.builtins, { editor: ConfigEditor, settings: Settings, speech: SpeechToText })
|
||||
})
|
||||
ctx = await start()
|
||||
const speech = ctx.get('speechToText')!
|
||||
const languages = ['auto', 'zh', 'en', 'yue', 'ja', 'ko']
|
||||
for (const id of ['sensevoice-local', 'another-provider']) speech.register({
|
||||
info: { id: id as SpeechProviderId, name: id, location: 'host-local', languages },
|
||||
transcribe: async () => { throw new Error('Changing preferences must not transcribe audio') },
|
||||
})
|
||||
const entry = ctx.configEditor.entries().find(row => row.options.id === 'speech-to-text')!
|
||||
expect(entry.id).toBe('include:speech-to-text')
|
||||
const fiber = entry.fiber
|
||||
for (const language of [...languages, 'zh']) {
|
||||
await speech.configure({ language })
|
||||
expect(speech.snapshot().selection).toEqual({ providerId: 'sensevoice-local', language })
|
||||
}
|
||||
await speech.configure({ providerId: 'another-provider' as SpeechProviderId })
|
||||
expect(speech.snapshot().selection).toEqual({ providerId: 'another-provider', language: 'zh' })
|
||||
expect(entry.fiber === fiber).toBe(true)
|
||||
const saved = readFileSync(profile.patchPath, 'utf8')
|
||||
expect(saved).toContain('id: speech-to-text')
|
||||
expect(saved).toContain('defaultProvider: another-provider')
|
||||
expect(saved).toContain('language: zh')
|
||||
await ctx.fiber.dispose()
|
||||
ctx = await start()
|
||||
expect(ctx.get('speechToText')!.snapshot().selection).toEqual({ providerId: 'another-provider', language: 'zh' })
|
||||
} finally {
|
||||
try { await ctx?.fiber.dispose() } finally { rmSync(home, { recursive: true, force: true }) }
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,9 @@
|
||||
- insert:
|
||||
- id: config-editor
|
||||
name: cordis:editor
|
||||
- id: settings
|
||||
name: cordis:settings
|
||||
- id: speech-to-text
|
||||
name: cordis:speech
|
||||
config:
|
||||
defaultProvider: sensevoice-local
|
||||
Generated
+6
@@ -7512,6 +7512,12 @@ importers:
|
||||
'@deepseek-ai/cordis-plugin-loader':
|
||||
specifier: workspace:^
|
||||
version: link:../../../vendor/loader
|
||||
'@deepseek-ai/dsh-app-boot':
|
||||
specifier: workspace:^
|
||||
version: link:../../boot/app-boot
|
||||
'@deepseek-ai/dsh-config-editor':
|
||||
specifier: workspace:^
|
||||
version: link:../../boot/config-editor
|
||||
'@deepseek-ai/dsh-settings':
|
||||
specifier: workspace:^
|
||||
version: link:../../settings/settings
|
||||
|
||||
Reference in new issue
Block a user