Fix local speech worker audio transport

Worker IPC now carries plain byte payloads instead of Node Buffer objects, so provider code receives process-local Buffers instead of transport-shaped views.
This commit is contained in:
Mohamed Boudra
2026-05-29 17:09:01 +07:00
parent fcd93a23ac
commit 7ed0ce97df
7 changed files with 347 additions and 14 deletions
@@ -0,0 +1,15 @@
import { describe, expect, it } from "vitest";
import { bufferToWorkerBytes, workerBytesToBuffer } from "./worker-bytes.js";
describe("local speech worker bytes", () => {
it("turns Buffer views into process-local byte payloads", () => {
const source = Buffer.from([0, 1, 2, 3, 4]).subarray(1, 5);
expect(source.byteOffset % 2).toBe(1);
const received = workerBytesToBuffer(bufferToWorkerBytes(source));
expect(received).toEqual(Buffer.from([1, 2, 3, 4]));
expect(received.byteOffset).toBe(0);
});
});
@@ -0,0 +1,11 @@
export type LocalSpeechWorkerBytes = ArrayBuffer;
export function bufferToWorkerBytes(buffer: Buffer): LocalSpeechWorkerBytes {
const bytes = new Uint8Array(buffer.byteLength);
bytes.set(buffer);
return bytes.buffer;
}
export function workerBytesToBuffer(bytes: LocalSpeechWorkerBytes): Buffer {
return Buffer.from(bytes);
}
@@ -13,6 +13,7 @@ import type {
LocalSpeechWorkerRequest, LocalSpeechWorkerRequest,
LocalSpeechWorkerToParentMessage, LocalSpeechWorkerToParentMessage,
} from "./worker-protocol.js"; } from "./worker-protocol.js";
import { bufferToWorkerBytes, workerBytesToBuffer } from "./worker-bytes.js";
class FakeLocalSpeechWorker extends EventEmitter { class FakeLocalSpeechWorker extends EventEmitter {
public connected = true; public connected = true;
@@ -101,7 +102,7 @@ describe("LocalSpeechWorkerClient", () => {
}); });
workers[0].respond(request, { workers[0].respond(request, {
audio: Buffer.from([1, 2, 3, 4]), audio: bufferToWorkerBytes(Buffer.from([1, 2, 3, 4])),
format: "pcm;rate=24000", format: "pcm;rate=24000",
}); });
@@ -130,11 +131,16 @@ describe("LocalSpeechWorkerClient", () => {
session.appendPcm16(Buffer.from([9, 8, 7, 6])); session.appendPcm16(Buffer.from([9, 8, 7, 6]));
await waitForMicrotasks(); await waitForMicrotasks();
expect(workers[0].sent[1]).toMatchObject({ const appendRequest = workers[0].sent[1];
expect(appendRequest).toMatchObject({
type: "session.append", type: "session.append",
sessionId: createRequest.sessionId, sessionId: createRequest.sessionId,
audio: Buffer.from([9, 8, 7, 6]),
}); });
if (appendRequest.type !== "session.append") {
throw new Error("Expected session.append request");
}
expect(Buffer.from(appendRequest.audio)).toEqual(Buffer.from([9, 8, 7, 6]));
expect(workerBytesToBuffer(appendRequest.audio).byteOffset).toBe(0);
session.commit(); session.commit();
await waitForMicrotasks(); await waitForMicrotasks();
@@ -193,7 +199,7 @@ describe("LocalSpeechWorkerClient", () => {
const first = client.synthesizeSpeech("first"); const first = client.synthesizeSpeech("first");
workers[0].respond(workers[0].sent[0], { workers[0].respond(workers[0].sent[0], {
audio: Buffer.from([1]), audio: bufferToWorkerBytes(Buffer.from([1])),
format: "pcm;rate=24000", format: "pcm;rate=24000",
}); });
await first; await first;
@@ -204,7 +210,7 @@ describe("LocalSpeechWorkerClient", () => {
const second = client.synthesizeSpeech("second"); const second = client.synthesizeSpeech("second");
expect(workers).toHaveLength(2); expect(workers).toHaveLength(2);
workers[1].respond(workers[1].sent[0], { workers[1].respond(workers[1].sent[0], {
audio: Buffer.from([2]), audio: bufferToWorkerBytes(Buffer.from([2])),
format: "pcm;rate=24000", format: "pcm;rate=24000",
}); });
await second; await second;
@@ -23,6 +23,7 @@ import type {
LocalSpeechWorkerResponse, LocalSpeechWorkerResponse,
LocalSpeechWorkerToParentMessage, LocalSpeechWorkerToParentMessage,
} from "./worker-protocol.js"; } from "./worker-protocol.js";
import { bufferToWorkerBytes, workerBytesToBuffer } from "./worker-bytes.js";
const DEFAULT_REQUEST_TIMEOUT_MS = 30000; const DEFAULT_REQUEST_TIMEOUT_MS = 30000;
const DEFAULT_IDLE_TTL_MS = 5 * 60 * 1000; const DEFAULT_IDLE_TTL_MS = 5 * 60 * 1000;
@@ -125,7 +126,7 @@ export class LocalSpeechWorkerClient {
text, text,
}); });
return { return {
stream: Readable.from([result.audio]), stream: Readable.from([workerBytesToBuffer(result.audio)]),
format: result.format, format: result.format,
}; };
} }
@@ -135,7 +136,7 @@ export class LocalSpeechWorkerClient {
type: "stt.transcribe", type: "stt.transcribe",
config: this.config, config: this.config,
model: "voice", model: "voice",
audio, audio: bufferToWorkerBytes(audio),
format, format,
}); });
} }
@@ -164,7 +165,11 @@ export class LocalSpeechWorkerClient {
} }
appendSessionAudio(sessionId: string, audio: Buffer): void { appendSessionAudio(sessionId: string, audio: Buffer): void {
void this.sendRequest({ type: "session.append", sessionId, audio }).catch((err) => { void this.sendRequest({
type: "session.append",
sessionId,
audio: bufferToWorkerBytes(audio),
}).catch((err) => {
this.emitSessionError(sessionId, err); this.emitSessionError(sessionId, err);
}); });
} }
@@ -0,0 +1,291 @@
import { fork, type ChildProcess } from "node:child_process";
import { existsSync } from "node:fs";
import { readFile } from "node:fs/promises";
import { randomUUID } from "node:crypto";
import { homedir } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { expect, test } from "vitest";
import { parsePcm16MonoWav, wordSimilarity } from "../../../test-utils/dictation-e2e.js";
import { ensureSherpaOnnxModels } from "./sherpa/model-downloader.js";
import { applySherpaLoaderEnv } from "./sherpa/sherpa-runtime-env.js";
import type {
LocalSpeechWorkerConfig,
LocalSpeechWorkerRequest,
LocalSpeechWorkerToParentMessage,
} from "./worker-protocol.js";
import { bufferToWorkerBytes } from "./worker-bytes.js";
const modelsDir =
process.env.PASEO_LOCAL_MODELS_DIR ?? path.join(homedir(), ".paseo", "models", "local-speech");
const shouldDownload = process.env.PASEO_SPEECH_E2E_DOWNLOAD === "1";
const workerSpeechTest = hasParakeetModel(modelsDir) || shouldDownload ? test : test.skip;
function hasParakeetModel(dir: string): boolean {
return (
existsSync(path.join(dir, "sherpa-onnx-nemo-parakeet-tdt-0.6b-v2-int8", "encoder.int8.onnx")) &&
existsSync(path.join(dir, "sherpa-onnx-nemo-parakeet-tdt-0.6b-v2-int8", "tokens.txt"))
);
}
function fixturePath(fileName: string): string {
return path.resolve(process.cwd(), "..", "app", "e2e", "fixtures", fileName);
}
function resolveWorkerUrl(): URL {
const currentUrl = import.meta.url;
if (currentUrl.endsWith(".ts")) {
return new URL("./worker-process.ts", currentUrl);
}
return new URL("./worker-process.js", currentUrl);
}
function resolveWorkerExecArgv(): string[] {
if (!import.meta.url.endsWith(".ts")) {
return [];
}
const loaderUrl = new URL("../../../../terminal/terminal-ts-loader.mjs", import.meta.url).href;
const importSource = [
'import { register } from "node:module";',
'import { pathToFileURL } from "node:url";',
`register(${JSON.stringify(loaderUrl)}, pathToFileURL("./"));`,
].join(" ");
return [
"--experimental-strip-types",
"--import",
`data:text/javascript,${encodeURIComponent(importSource)}`,
];
}
function forkWorker(): ChildProcess {
const env = { ...process.env, PASEO_LOG_LEVEL: "silent" };
applySherpaLoaderEnv(env);
const worker = fork(fileURLToPath(resolveWorkerUrl()), [], {
env,
execArgv: resolveWorkerExecArgv(),
serialization: "advanced",
stdio: ["ignore", "ignore", "pipe", "ipc"],
});
worker.setMaxListeners(100);
return worker;
}
workerSpeechTest(
"transcribes PCM through the real local speech worker process",
async () => {
if (!hasParakeetModel(modelsDir)) {
await ensureSherpaOnnxModels({
modelsDir,
modelIds: ["parakeet-tdt-0.6b-v2-int8"],
});
}
const worker = forkWorker();
let stderr = "";
worker.stderr?.on("data", (chunk) => {
stderr += Buffer.isBuffer(chunk) ? chunk.toString("utf8") : String(chunk);
});
const messages: LocalSpeechWorkerToParentMessage[] = [];
worker.on("message", (message: LocalSpeechWorkerToParentMessage) => {
messages.push(message);
});
try {
const config: LocalSpeechWorkerConfig = {
modelsDir,
voiceSttModel: "parakeet-tdt-0.6b-v2-int8",
dictationSttModel: "parakeet-tdt-0.6b-v2-int8",
voiceTtsModel: "kokoro-en-v0_19",
};
const sessionId = randomUUID();
await sendRequest(
worker,
messages,
{
type: "session.create",
config,
sessionId,
kind: "dictationStt",
},
() => stderr,
);
const wav = await readFile(fixturePath("recording.wav"));
const { sampleRate, pcm16 } = parsePcm16MonoWav(wav);
expect(sampleRate).toBe(16000);
const chunkBytes = 3200;
const appendPromises: Promise<unknown>[] = [];
for (let offset = 0; offset < pcm16.length; offset += chunkBytes) {
const audio = pcm16.subarray(offset, Math.min(pcm16.length, offset + chunkBytes));
appendPromises.push(
sendRequest(
worker,
messages,
{
type: "session.append",
sessionId,
audio: bufferToWorkerBytes(audio),
},
() => stderr,
),
);
}
await Promise.all(appendPromises);
await sendRequest(
worker,
messages,
{
type: "session.commit",
sessionId,
},
() => stderr,
);
const finalTranscript = await waitForFinalTranscript(worker, messages, sessionId, stderr);
const baseline = await readFile(fixturePath("recording.baseline.txt"), "utf8");
expect(wordSimilarity(finalTranscript, baseline)).toBeGreaterThan(0.45);
} finally {
if (worker.connected) {
worker.disconnect();
}
if (!worker.killed) {
worker.kill();
}
}
},
120_000,
);
type RequestInput = LocalSpeechWorkerRequest extends infer Request
? Request extends LocalSpeechWorkerRequest
? Omit<Request, "requestId">
: never
: never;
async function sendRequest(
worker: ChildProcess,
messages: LocalSpeechWorkerToParentMessage[],
input: RequestInput,
getStderr: () => string,
): Promise<unknown> {
const requestId = randomUUID();
const message = { ...input, requestId } as LocalSpeechWorkerRequest;
worker.send(message);
return waitForResponse(worker, messages, requestId, getStderr);
}
function waitForResponse(
worker: ChildProcess,
messages: LocalSpeechWorkerToParentMessage[],
requestId: string,
getStderr: () => string,
): Promise<unknown> {
const existing = messages.find(
(message) => message.type === "response" && message.requestId === requestId,
);
if (existing?.type === "response") {
if (existing.ok) {
return Promise.resolve(existing.result);
}
return Promise.reject(new Error(existing.error));
}
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
cleanup();
reject(new Error(`Timed out waiting for local speech worker response: ${requestId}`));
}, 30_000);
const onMessage = (message: LocalSpeechWorkerToParentMessage) => {
if (message.type !== "response" || message.requestId !== requestId) {
return;
}
cleanup();
if (message.ok) {
resolve(message.result);
} else {
reject(new Error(message.error));
}
};
const onExit = (code: number | null, signal: NodeJS.Signals | null) => {
cleanup();
reject(
new Error(
`Local speech worker exited before response: code=${code} signal=${signal}\n${getStderr()}`,
),
);
};
function cleanup() {
clearTimeout(timeout);
worker.off("message", onMessage);
worker.off("exit", onExit);
}
worker.on("message", onMessage);
worker.once("exit", onExit);
});
}
function waitForFinalTranscript(
worker: ChildProcess,
messages: LocalSpeechWorkerToParentMessage[],
sessionId: string,
stderr: string,
): Promise<string> {
const existing = messages.find(
(message) =>
message.type === "session.transcript" &&
message.sessionId === sessionId &&
message.payload.isFinal,
);
if (existing?.type === "session.transcript") {
return Promise.resolve(existing.payload.transcript);
}
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
cleanup();
reject(new Error(`Timed out waiting for final transcript. Worker stderr:\n${stderr}`));
}, 60_000);
const onMessage = (message: LocalSpeechWorkerToParentMessage) => {
if (message.type === "session.error" && message.sessionId === sessionId) {
cleanup();
reject(new Error(message.error));
return;
}
if (
message.type !== "session.transcript" ||
message.sessionId !== sessionId ||
!message.payload.isFinal
) {
return;
}
cleanup();
resolve(message.payload.transcript);
};
const onExit = (code: number | null, signal: NodeJS.Signals | null) => {
cleanup();
reject(
new Error(
`Local speech worker exited before final transcript: code=${code} signal=${signal}\n${stderr}`,
),
);
};
function cleanup() {
clearTimeout(timeout);
worker.off("message", onMessage);
worker.off("exit", onExit);
}
worker.on("message", onMessage);
worker.once("exit", onExit);
});
}
@@ -16,6 +16,7 @@ import type {
LocalSpeechWorkerRequest, LocalSpeechWorkerRequest,
LocalSpeechWorkerToParentMessage, LocalSpeechWorkerToParentMessage,
} from "./worker-protocol.js"; } from "./worker-protocol.js";
import { bufferToWorkerBytes, workerBytesToBuffer } from "./worker-bytes.js";
process.title = "Paseo Voice"; process.title = "Paseo Voice";
@@ -251,7 +252,7 @@ function handleSessionRequest(message: LocalSpeechWorkerSessionRequest): void {
const session = sessions.get(message.sessionId); const session = sessions.get(message.sessionId);
switch (message.type) { switch (message.type) {
case "session.append": case "session.append":
session?.appendPcm16(message.audio); session?.appendPcm16(workerBytesToBuffer(message.audio));
break; break;
case "session.commit": case "session.commit":
if (session && "commit" in session) { if (session && "commit" in session) {
@@ -284,13 +285,16 @@ async function handleRequest(message: LocalSpeechWorkerRequest): Promise<void> {
for await (const chunk of result.stream) { for await (const chunk of result.stream) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
} }
sendOk(message.requestId, { audio: Buffer.concat(chunks), format: result.format }); sendOk(message.requestId, {
audio: bufferToWorkerBytes(Buffer.concat(chunks)),
format: result.format,
});
return; return;
} }
if (message.type === "stt.transcribe") { if (message.type === "stt.transcribe") {
const result = await getSttProvider(message.config, message.model).transcribeAudio( const result = await getSttProvider(message.config, message.model).transcribeAudio(
message.audio, workerBytesToBuffer(message.audio),
message.format, message.format,
); );
sendOk(message.requestId, result); sendOk(message.requestId, result);
@@ -3,6 +3,7 @@ import type {
StreamingTranscriptionEvent, StreamingTranscriptionEvent,
TranscriptionResult, TranscriptionResult,
} from "../../speech-provider.js"; } from "../../speech-provider.js";
import type { LocalSpeechWorkerBytes } from "./worker-bytes.js";
export interface LocalSpeechWorkerConfig { export interface LocalSpeechWorkerConfig {
modelsDir: string; modelsDir: string;
@@ -27,7 +28,7 @@ export type LocalSpeechWorkerRequest =
requestId: string; requestId: string;
config: LocalSpeechWorkerConfig; config: LocalSpeechWorkerConfig;
model: "voice" | "dictation"; model: "voice" | "dictation";
audio: Buffer; audio: LocalSpeechWorkerBytes;
format: string; format: string;
} }
| { | {
@@ -41,7 +42,7 @@ export type LocalSpeechWorkerRequest =
type: "session.append"; type: "session.append";
requestId: string; requestId: string;
sessionId: string; sessionId: string;
audio: Buffer; audio: LocalSpeechWorkerBytes;
} }
| { | {
type: "session.commit"; type: "session.commit";
@@ -115,7 +116,7 @@ export interface LocalSpeechCreateSessionResult {
} }
export interface LocalSpeechTtsResult { export interface LocalSpeechTtsResult {
audio: Buffer; audio: LocalSpeechWorkerBytes;
format: string; format: string;
} }