fix(voice): start the compiled recognizer worker on Windows (#1427)

This commit is contained in:
程序员阿江-Relakkes
2026-10-03 18:11:14 +08:00
committed by GitHub
parent fd42b0ca05
commit 363e59dda9
5 changed files with 92 additions and 5 deletions
+6 -2
View File
@@ -42,14 +42,18 @@ function post(port: number, body: Uint8Array, headers: Record<string, string>) {
})
}
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.
@@ -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()
}
}
@@ -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<number | null>((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)
})
})
@@ -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
}
}
/**
@@ -101,14 +101,19 @@ describe('createTranscriber', () => {
describe('worker process', () => {
let dir: string
let child: ChildProcessWithoutNullStreams | undefined
let closed: Promise<void> | 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<number | null>(resolve => child!.once('exit', c => resolve(c)))