diff --git a/packages/experimental/speech-to-text/README.i18n.yaml b/packages/experimental/speech-to-text/README.i18n.yaml index 2aa9341cd0..855300b8f1 100644 --- a/packages/experimental/speech-to-text/README.i18n.yaml +++ b/packages/experimental/speech-to-text/README.i18n.yaml @@ -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 diff --git a/packages/experimental/speech-to-text/README.md b/packages/experimental/speech-to-text/README.md index 64202aba4a..3dafb5c7cf 100644 --- a/packages/experimental/speech-to-text/README.md +++ b/packages/experimental/speech-to-text/README.md @@ -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. diff --git a/packages/experimental/speech-to-text/README.zh.md b/packages/experimental/speech-to-text/README.zh.md index 38466f18a0..d665e5c05f 100644 --- a/packages/experimental/speech-to-text/README.zh.md +++ b/packages/experimental/speech-to-text/README.zh.md @@ -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。两者均在解码采样前拒绝不一致的头部和长度。 diff --git a/packages/experimental/speech-to-text/package.json b/packages/experimental/speech-to-text/package.json index e517471e13..b32066e0d7 100644 --- a/packages/experimental/speech-to-text/package.json +++ b/packages/experimental/speech-to-text/package.json @@ -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:^" }, diff --git a/packages/experimental/speech-to-text/src/index.ts b/packages/experimental/speech-to-text/src/index.ts index b0db14b83a..ba4bb4fc9a 100644 --- a/packages/experimental/speech-to-text/src/index.ts +++ b/packages/experimental/speech-to-text/src/index.ts @@ -41,12 +41,12 @@ export default class SpeechToText extends Service { private readonly providers = new Map() 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')) diff --git a/packages/experimental/speech-to-text/tests/configuration.spec.ts b/packages/experimental/speech-to-text/tests/configuration.spec.ts new file mode 100644 index 0000000000..05a2b97f35 --- /dev/null +++ b/packages/experimental/speech-to-text/tests/configuration.spec.ts @@ -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> | 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 }) } + } +}) diff --git a/packages/experimental/speech-to-text/tests/fixtures/selection.patch.yml b/packages/experimental/speech-to-text/tests/fixtures/selection.patch.yml new file mode 100644 index 0000000000..693db59237 --- /dev/null +++ b/packages/experimental/speech-to-text/tests/fixtures/selection.patch.yml @@ -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 diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0e50c53e52..7b59cf8646 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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