From 363e59dda9fbd086d8ef53f403f93fc1393c474a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F-Relakkes?= Date: Sat, 3 Oct 2026 18:11:14 +0800 Subject: [PATCH] fix(voice): start the compiled recognizer worker on Windows (#1427) --- desktop/sidecars/voiceWorker.test.ts | 8 +++- .../__fixtures__/compiledRecognizer.ts | 23 +++++++++ .../voice/sensevoice/recognizer.test.ts | 47 ++++++++++++++++++- .../services/voice/sensevoice/recognizer.ts | 10 +++- .../services/voice/sensevoice/worker.test.ts | 9 +++- 5 files changed, 92 insertions(+), 5 deletions(-) create mode 100644 src/server/services/voice/sensevoice/__fixtures__/compiledRecognizer.ts diff --git a/desktop/sidecars/voiceWorker.test.ts b/desktop/sidecars/voiceWorker.test.ts index 85dbc92a..51f87358 100644 --- a/desktop/sidecars/voiceWorker.test.ts +++ b/desktop/sidecars/voiceWorker.test.ts @@ -42,14 +42,18 @@ function post(port: number, body: Uint8Array, headers: Record) { }) } -describe.skipIf(process.platform === 'win32')('compiled desktop voice worker routing', () => { +describe('compiled desktop voice worker routing', () => { it('boots the real merged entrypoint as a voice worker, loads the runtime by absolute path and serves the private socket', async () => { const directory = await realpath(await mkdtemp(path.join(tmpdir(), 'cc-haha-sidecar-voice-worker-'))) - const executable = path.join(directory, 'claude-sidecar-voice-fixture') + const executable = path.join(directory, `claude-sidecar-voice-fixture${process.platform === 'win32' ? '.exe' : ''}`) const env: NodeJS.ProcessEnv = { PATH: process.env.PATH, HOME: directory, + USERPROFILE: directory, TMPDIR: directory, + TMP: directory, + TEMP: directory, + SystemRoot: process.env.SystemRoot, CLAUDE_CONFIG_DIR: path.join(directory, '.claude'), BUN_OPTIONS: '--no-env-file', // preload.ts would chdir here. The worker must never load it. diff --git a/src/server/services/voice/sensevoice/__fixtures__/compiledRecognizer.ts b/src/server/services/voice/sensevoice/__fixtures__/compiledRecognizer.ts new file mode 100644 index 00000000..deb8fcae --- /dev/null +++ b/src/server/services/voice/sensevoice/__fixtures__/compiledRecognizer.ts @@ -0,0 +1,23 @@ +/** Exercises the production relaunch path without a real model or user state. */ +import { readFile } from 'node:fs/promises' +import { SenseVoiceRecognizer } from '../recognizer.js' +import type { WorkerConfig } from '../protocol.js' +import { runVoiceWorker } from '../worker.js' +import { makeWav } from './wav.js' + +if (process.argv[2] === '--voice-worker') { + await runVoiceWorker() +} else { + const config = JSON.parse(await readFile(process.argv[2]!, 'utf8')) as WorkerConfig + const recognizer = new SenseVoiceRecognizer({ + workerConfig: () => config, + cwd: config.runtimeRoot, + startupTimeoutMs: 2000, + }) + try { + console.log(JSON.stringify(await recognizer.transcribe(makeWav(1), 'zh', new AbortController().signal))) + console.log(JSON.stringify(await recognizer.transcribe(makeWav(0.5), 'en', new AbortController().signal))) + } finally { + await recognizer.dispose() + } +} diff --git a/src/server/services/voice/sensevoice/recognizer.test.ts b/src/server/services/voice/sensevoice/recognizer.test.ts index 37130a23..21fe7322 100644 --- a/src/server/services/voice/sensevoice/recognizer.test.ts +++ b/src/server/services/voice/sensevoice/recognizer.test.ts @@ -8,7 +8,7 @@ import { VoiceServiceError } from '../errors.js' import type { WorkerConfig } from './protocol.js' import { installFakeSherpa } from './__fixtures__/fakeSherpa.js' import { makeWav } from './__fixtures__/wav.js' -import { SenseVoiceRecognizer, isBundledWorkerHost, type RecognizerOptions, type SpawnWorker } from './recognizer.js' +import { SenseVoiceRecognizer, isBundledWorkerHost, workerEnvironment, type RecognizerOptions, type SpawnWorker } from './recognizer.js' const fakeWorker = fileURLToPath(new URL('./__fixtures__/fakeWorker.mjs', import.meta.url)) const wav = new Uint8Array(64) @@ -250,6 +250,45 @@ describe('SenseVoiceRecognizer', () => { }) describe('default worker launch', () => { + it('relaunches a compiled executable as a worker and transcribes successive recordings', async () => { + await installFakeSherpa(dir) + const executable = join(dir, `voice-recognizer${process.platform === 'win32' ? '.exe' : ''}`) + const build = await Bun.build({ + entrypoints: [fileURLToPath(new URL('./__fixtures__/compiledRecognizer.ts', import.meta.url))], + target: 'bun', + compile: { outfile: executable }, + }) + expect(build.success).toBe(true) + if (process.platform === 'darwin') { + for (const args of [['--remove-signature', executable], ['--sign', '-', '--force', '--timestamp=none', executable]]) { + const signing = Bun.spawn(['/usr/bin/codesign', ...args], { stdout: 'ignore', stderr: 'pipe' }) + expect(await signing.exited).toBe(0) + } + } + const configPath = join(dir, 'config.json') + await writeFile(configPath, JSON.stringify({ ...config(), maxAudioBytes: 200_000 })) + const child = spawn(executable, [configPath], { + cwd: dir, + env: workerEnvironment({ HOME: dir, USERPROFILE: dir, TMPDIR: dir, TMP: dir, TEMP: dir, CLAUDE_CONFIG_DIR: join(dir, '.claude') }), + stdio: 'pipe', + windowsHide: true, + timeout: 10_000, + }) + let stdout = '' + let stderr = '' + child.stdout.on('data', chunk => { stdout += String(chunk) }) + child.stderr.on('data', chunk => { stderr += String(chunk) }) + const code = await new Promise((resolve, reject) => { + child.once('error', reject) + child.once('close', resolve) + }) + expect({ code, stderr }).toEqual({ code: 0, stderr: '' }) + expect(stdout.trim().split('\n').map(line => JSON.parse(line))).toMatchObject([ + { text: 'zh:16000', audioSeconds: 1 }, + { text: 'en:8000', audioSeconds: 0.5 }, + ]) + }, 30_000) + it('runs the source worker entry with Bun when not compiled, passing config and token through the environment', async () => { await installFakeSherpa(dir) // No spawnWorker override: this is the launch path the server uses from source. @@ -266,7 +305,13 @@ describe('default worker launch', () => { it('detects compiled executables by their virtual module URLs', () => { expect(isBundledWorkerHost('file:///$bunfs/root/claude-sidecar', false)).toBe(true) expect(isBundledWorkerHost('file:///B:/~BUN/root/claude-sidecar.exe', false)).toBe(true) + expect(isBundledWorkerHost('file:///B:/%7EBUN/root/claude-sidecar.exe', false)).toBe(true) + expect(isBundledWorkerHost('file:///B:/%7eBUN/root/claude-sidecar.exe', false)).toBe(true) expect(isBundledWorkerHost('file:///repo/src/server/services/voice/sensevoice/recognizer.ts', false)).toBe(false) + expect(isBundledWorkerHost('file:///C:/project/~BUN-tools/recognizer.ts', false)).toBe(false) + expect(isBundledWorkerHost('https://example.test/B:/%7EBUN/root/recognizer.ts', false)).toBe(false) + expect(isBundledWorkerHost('file:///B:/%ZZ/root/recognizer.ts', false)).toBe(false) + expect(isBundledWorkerHost('not a file URL', false)).toBe(false) expect(isBundledWorkerHost('file:///repo/src/x.ts', true)).toBe(true) }) }) diff --git a/src/server/services/voice/sensevoice/recognizer.ts b/src/server/services/voice/sensevoice/recognizer.ts index ca3b4410..61f3fd0a 100644 --- a/src/server/services/voice/sensevoice/recognizer.ts +++ b/src/server/services/voice/sensevoice/recognizer.ts @@ -58,7 +58,15 @@ const RESPONSE_LIMIT_BYTES = 128 * 1024 * file system (`/$bunfs/...` on POSIX, `B:/~BUN/...` on Windows). */ export function isBundledWorkerHost(moduleUrl: string = import.meta.url, bundledMode: boolean = isInBundledMode()): boolean { - return bundledMode || moduleUrl.startsWith('file:///$bunfs/') || /^file:\/\/\/[A-Za-z]:\/~BUN\//.test(moduleUrl) + if (bundledMode) return true + try { + const url = new URL(moduleUrl) + // Windows Bun encodes ~BUN as %7EBUN, even when embeddedFiles is empty. + const modulePath = decodeURIComponent(url.pathname) + return url.protocol === 'file:' && (modulePath.startsWith('/$bunfs/') || /^\/[A-Za-z]:\/~BUN\//.test(modulePath)) + } catch { + return false + } } /** diff --git a/src/server/services/voice/sensevoice/worker.test.ts b/src/server/services/voice/sensevoice/worker.test.ts index 29d47fae..b2f4bfe2 100644 --- a/src/server/services/voice/sensevoice/worker.test.ts +++ b/src/server/services/voice/sensevoice/worker.test.ts @@ -101,14 +101,19 @@ describe('createTranscriber', () => { describe('worker process', () => { let dir: string let child: ChildProcessWithoutNullStreams | undefined + let closed: Promise | undefined beforeEach(async () => { dir = await mkdtemp(join(tmpdir(), 'voice-worker-')) child = undefined + closed = undefined }) afterEach(async () => { - child?.kill('SIGKILL') + if (child && child.exitCode === null && child.signalCode === null) { + child.kill('SIGKILL') + } + await closed await rm(dir, { recursive: true, force: true }) }) @@ -122,6 +127,7 @@ describe('worker process', () => { [WORKER_TOKEN_ENV]: token, }, }) + closed = new Promise(resolve => child!.once('close', () => resolve())) let stderr = '' child.stderr.on('data', chunk => { stderr += String(chunk) }) return new Promise((resolve, reject) => { @@ -191,6 +197,7 @@ describe('worker process', () => { stdio: 'pipe', env: { PATH: process.env.PATH ?? '', [WORKER_CONFIG_ENV]: JSON.stringify(baseConfig(dir)) }, }) + closed = new Promise(resolve => child!.once('close', () => resolve())) let stderr = '' child.stderr.on('data', chunk => { stderr += String(chunk) }) const code = await new Promise(resolve => child!.once('exit', c => resolve(c)))