mirror of
https://github.com/NanmiCoder/claude-code-haha.git
synced 2026-10-10 03:43:11 +08:00
fix(voice): start the compiled recognizer worker on Windows (#1427)
This commit is contained in:
@@ -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)))
|
||||
|
||||
Reference in New Issue
Block a user