Keep local voice memory out of the daemon (#1217)

* Move local speech work out of daemon

Run local speech models in a lazily spawned worker so native model memory and blocking synthesis/transcription work stay out of the daemon process. The worker exits after idle time so local speech RSS can be reclaimed.

* Update supervisor stop test for process title
This commit is contained in:
Mohamed Boudra
2026-05-29 13:27:28 +08:00
committed by GitHub
parent cdcf3d8d55
commit 04fb0f9b82
8 changed files with 1279 additions and 199 deletions
@@ -6,7 +6,7 @@
*/
import assert from "node:assert";
import { spawn, spawnSync, type ChildProcess } from "node:child_process";
import { spawn, type ChildProcess } from "node:child_process";
import { mkdtemp, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
@@ -59,15 +59,6 @@ async function readPidLockState(paseoHome: string): Promise<PidLockState> {
}
}
function readProcessCommand(pid: number): string | null {
const result = spawnSync("ps", ["-p", String(pid), "-o", "command="], { encoding: "utf8" });
if (result.status !== 0 || result.error) {
return null;
}
const command = result.stdout.trim();
return command.length > 0 ? command : null;
}
interface DaemonStatus {
localDaemon: string | null;
pid: number | null;
@@ -168,11 +159,10 @@ try {
assert(isProcessRunning(daemonPid), "daemon process should be running");
const pidLockBeforeStop = await readPidLockState(paseoHome);
assert.strictEqual(pidLockBeforeStop.pid, daemonPid, "pid lock should match status pid");
const command = readProcessCommand(daemonPid);
assert(command !== null, "pid lock pid should resolve to a running process command");
assert(
command.includes("supervisor-entrypoint.ts") || command.includes("supervisor-entrypoint.js"),
`pid lock pid should be supervisor-entrypoint process, got: ${command}`,
assert.strictEqual(
daemonPid,
supervisorProcess.pid,
"pid lock pid should be the supervisor-entrypoint process",
);
console.log(`✓ dev daemon started with daemon pid ${daemonPid}\n`);
@@ -13,6 +13,8 @@ import { runSupervisor } from "./supervisor.js";
import { resolveSupervisorLogFile } from "./supervisor-log-config.js";
import { applySherpaLoaderEnv } from "../src/server/speech/providers/local/sherpa/sherpa-runtime-env.js";
process.title = "Paseo Supervisor";
interface DaemonRunnerConfig {
devMode: boolean;
workerArgs: string[];
@@ -4,6 +4,8 @@ import { resolvePaseoHome } from "./paseo-home.js";
import { createRootLogger } from "./logger.js";
import type { DaemonLifecycleIntent } from "./bootstrap.js";
process.title = "Paseo Daemon";
type SupervisorLifecycleMessage =
| {
type: "paseo:shutdown";
@@ -5,7 +5,6 @@ import type { SpeechToTextProvider, TextToSpeechProvider } from "../../speech-pr
import type { RequestedSpeechProviders } from "../../speech-types.js";
import type { TurnDetectionProvider } from "../../turn-detection-provider.js";
import {
getLocalSpeechModelDir,
DEFAULT_LOCAL_STT_MODEL,
DEFAULT_LOCAL_TTS_MODEL,
LocalSttModelIdSchema,
@@ -14,16 +13,12 @@ import {
type LocalSttModelId,
type LocalTtsModelId,
} from "./models.js";
import { SherpaOfflineRecognizerEngine } from "./sherpa/sherpa-offline-recognizer.js";
import { SherpaOnnxParakeetSTT } from "./sherpa/sherpa-parakeet-stt.js";
import { SherpaParakeetRealtimeTranscriptionSession } from "./sherpa/sherpa-parakeet-realtime-session.js";
import { SherpaOnnxTTS } from "./sherpa/sherpa-tts.js";
import {
ensureSileroVadModel,
SherpaSileroTurnDetectionProvider,
} from "./sherpa/silero-vad-provider.js";
type LocalSttEngine = SherpaOfflineRecognizerEngine;
LocalSpeechWorkerClient,
WorkerBackedSpeechToTextProvider,
WorkerBackedTextToSpeechProvider,
WorkerBackedTurnDetectionProvider,
} from "./worker-client.js";
interface ResolvedLocalModels {
dictationLocalSttModel: LocalSttModelId;
@@ -50,10 +45,6 @@ export interface InitializedLocalSpeech {
cleanup: () => void;
}
function buildModelDownloadHint(modelId: LocalSpeechModelId): string {
return `Use 'paseo speech download --model ${modelId}' to download this model.`;
}
function resolveConfiguredLocalModels(speechConfig: PaseoSpeechConfig | null): ResolvedLocalModels {
return {
dictationLocalSttModel: LocalSttModelIdSchema.parse(
@@ -104,131 +95,43 @@ function computeRequiredLocalModelIds(params: {
return Array.from(ids);
}
async function createLocalSttEngine(params: {
modelId: LocalSttModelId;
modelsDir: string;
logger: Logger;
}): Promise<LocalSttEngine> {
const { modelId, modelsDir, logger } = params;
const modelDir = getLocalSpeechModelDir(modelsDir, modelId);
return new SherpaOfflineRecognizerEngine(
{
model: {
kind: "nemo_transducer",
encoder: `${modelDir}/encoder.int8.onnx`,
decoder: `${modelDir}/decoder.int8.onnx`,
joiner: `${modelDir}/joiner.int8.onnx`,
tokens: `${modelDir}/tokens.txt`,
},
numThreads: 2,
debug: 0,
},
logger,
);
}
type LocalConfig = NonNullable<PaseoSpeechConfig["local"]>;
function isLocalProviderEnabled(provider: { enabled?: boolean; provider: string }): boolean {
return provider.enabled !== false && provider.provider === "local";
}
async function initializeLocalTurnDetection(
localConfig: LocalConfig | null,
logger: Logger,
): Promise<TurnDetectionProvider> {
let vadModelPath: string | undefined;
if (localConfig) {
try {
vadModelPath = await ensureSileroVadModel(localConfig.modelsDir, logger);
} catch (err) {
logger.warn({ err }, "Failed to provision Silero VAD model, falling back to bundled");
}
}
return new SherpaSileroTurnDetectionProvider({ modelPath: vadModelPath }, logger);
function warnLocalConfigMissing(logger: Logger, feature: string): void {
logger.warn(
{ configured: false },
`Local ${feature} selected but local provider config is missing; ${feature} will be unavailable`,
);
}
async function initializeLocalVoiceStt(params: {
localConfig: LocalConfig | null;
modelId: LocalSttModelId;
logger: Logger;
getLocalSttEngine: (modelId: LocalSttModelId) => Promise<LocalSttEngine | null>;
}): Promise<SpeechToTextProvider | null> {
const { localConfig, modelId, logger, getLocalSttEngine } = params;
if (!localConfig) {
logger.warn(
{ configured: false },
"Local STT selected for voice but local provider config is missing; STT will be unavailable",
);
return null;
}
const voiceEngine = await getLocalSttEngine(modelId);
return voiceEngine ? new SherpaOnnxParakeetSTT({ engine: voiceEngine }, logger) : null;
function initializeLocalTurnDetection(params: {
client: LocalSpeechWorkerClient;
}): TurnDetectionProvider {
const { client } = params;
return new WorkerBackedTurnDetectionProvider(client);
}
async function initializeLocalDictationStt(params: {
localConfig: LocalConfig | null;
modelId: LocalSttModelId;
logger: Logger;
getLocalSttEngine: (modelId: LocalSttModelId) => Promise<LocalSttEngine | null>;
}): Promise<SpeechToTextProvider | null> {
const { localConfig, modelId, logger, getLocalSttEngine } = params;
if (!localConfig) {
logger.warn(
{ configured: false },
"Local STT selected for dictation but local provider config is missing; dictation STT will be unavailable",
);
return null;
}
const dictationEngine = await getLocalSttEngine(modelId);
if (dictationEngine) {
return {
id: "local",
createSession: () =>
new SherpaParakeetRealtimeTranscriptionSession({ engine: dictationEngine }),
};
}
return null;
function initializeLocalVoiceStt(params: {
client: LocalSpeechWorkerClient;
}): SpeechToTextProvider {
const { client } = params;
return new WorkerBackedSpeechToTextProvider(client, "voiceStt");
}
async function initializeLocalVoiceTts(params: {
localConfig: LocalConfig | null;
speechConfig: PaseoSpeechConfig | null;
localModels: ResolvedLocalModels;
logger: Logger;
}): Promise<TextToSpeechProvider | null> {
const { localConfig, speechConfig, localModels, logger } = params;
if (!localConfig) {
logger.warn(
{ configured: false },
"Local TTS selected for voice but local provider config is missing; TTS will be unavailable",
);
return null;
}
try {
const modelDir = getLocalSpeechModelDir(localConfig.modelsDir, localModels.voiceLocalTtsModel);
return new SherpaOnnxTTS(
{
preset: localModels.voiceLocalTtsModel,
modelDir,
speakerId: speechConfig?.local?.models.voiceTtsSpeakerId,
speed: speechConfig?.local?.models.voiceTtsSpeed,
},
logger,
);
} catch (err) {
logger.warn(
{
err,
modelsDir: localConfig.modelsDir,
modelId: localModels.voiceLocalTtsModel,
hint: buildModelDownloadHint(localModels.voiceLocalTtsModel),
},
"Local TTS engine unavailable",
);
return null;
}
function initializeLocalDictationStt(params: {
client: LocalSpeechWorkerClient;
}): SpeechToTextProvider {
const { client } = params;
return new WorkerBackedSpeechToTextProvider(client, "dictationStt");
}
function initializeLocalVoiceTts(params: {
client: LocalSpeechWorkerClient;
}): TextToSpeechProvider {
const { client } = params;
return new WorkerBackedTextToSpeechProvider(client);
}
export async function initializeLocalSpeechServices(params: {
@@ -251,80 +154,56 @@ export async function initializeLocalSpeechServices(params: {
models: localModels,
});
const localSttEngines = new Map<LocalSttModelId, LocalSttEngine>();
const getLocalSttEngine = async (modelId: LocalSttModelId): Promise<LocalSttEngine | null> => {
const existing = localSttEngines.get(modelId);
if (existing) {
return existing;
}
if (!localConfig) {
return null;
}
try {
const created = await createLocalSttEngine({
modelId,
modelsDir: localConfig.modelsDir,
logger,
});
localSttEngines.set(modelId, created);
return created;
} catch (err) {
logger.warn(
{
err,
const workerClient = localConfig
? new LocalSpeechWorkerClient({
config: {
modelsDir: localConfig.modelsDir,
modelId,
hint: buildModelDownloadHint(modelId),
voiceSttModel: localModels.voiceLocalSttModel,
dictationSttModel: localModels.dictationLocalSttModel,
voiceTtsModel: localModels.voiceLocalTtsModel,
voiceTtsSpeakerId: speechConfig?.local?.models.voiceTtsSpeakerId,
voiceTtsSpeed: speechConfig?.local?.models.voiceTtsSpeed,
},
"Local STT engine unavailable",
);
return null;
}
};
})
: null;
if (isLocalProviderEnabled(providers.voiceTurnDetection)) {
turnDetectionService = await initializeLocalTurnDetection(localConfig, logger);
if (workerClient) {
turnDetectionService = initializeLocalTurnDetection({ client: workerClient });
} else {
warnLocalConfigMissing(logger, "turn detection");
}
}
if (isLocalProviderEnabled(providers.voiceStt)) {
sttService = await initializeLocalVoiceStt({
localConfig,
modelId: localModels.voiceLocalSttModel,
logger,
getLocalSttEngine,
});
if (workerClient) {
sttService = initializeLocalVoiceStt({ client: workerClient });
} else {
warnLocalConfigMissing(logger, "voice STT");
}
}
if (isLocalProviderEnabled(providers.dictationStt)) {
dictationSttService = await initializeLocalDictationStt({
localConfig,
modelId: localModels.dictationLocalSttModel,
logger,
getLocalSttEngine,
});
if (workerClient) {
dictationSttService = initializeLocalDictationStt({ client: workerClient });
} else {
warnLocalConfigMissing(logger, "dictation STT");
}
}
if (isLocalProviderEnabled(providers.voiceTts)) {
localVoiceTtsProvider = await initializeLocalVoiceTts({
localConfig,
speechConfig,
localModels,
logger,
});
if (workerClient) {
localVoiceTtsProvider = initializeLocalVoiceTts({ client: workerClient });
} else {
warnLocalConfigMissing(logger, "voice TTS");
}
if (localVoiceTtsProvider) {
ttsService = localVoiceTtsProvider;
}
}
const cleanup = () => {
const maybeFreeable = localVoiceTtsProvider as unknown as { free?: () => void } | null;
if (typeof maybeFreeable?.free === "function") {
maybeFreeable.free();
}
for (const engine of localSttEngines.values()) {
engine.free();
}
workerClient?.shutdown();
};
return {
@@ -0,0 +1,212 @@
import { EventEmitter } from "node:events";
import { once } from "node:events";
import pino from "pino";
import { describe, expect, it } from "vitest";
import {
LocalSpeechWorkerClient,
WorkerBackedSpeechToTextProvider,
WorkerBackedTextToSpeechProvider,
WorkerBackedTurnDetectionProvider,
} from "./worker-client.js";
import type {
LocalSpeechWorkerRequest,
LocalSpeechWorkerToParentMessage,
} from "./worker-protocol.js";
class FakeLocalSpeechWorker extends EventEmitter {
public connected = true;
public killed = false;
public readonly sent: LocalSpeechWorkerRequest[] = [];
public disconnects = 0;
public kills = 0;
send(message: LocalSpeechWorkerRequest, callback: (error: Error | null) => void): boolean {
this.sent.push(message);
queueMicrotask(() => callback(null));
return true;
}
disconnect(): void {
this.disconnects++;
this.connected = false;
}
kill(): boolean {
this.kills++;
this.killed = true;
this.connected = false;
return true;
}
respond(request: LocalSpeechWorkerRequest, result?: unknown): void {
this.emit("message", {
type: "response",
requestId: request.requestId,
ok: true,
result,
} satisfies LocalSpeechWorkerToParentMessage);
}
emitWorkerMessage(message: LocalSpeechWorkerToParentMessage): void {
this.emit("message", message);
}
}
function createClient(options?: { idleTtlMs?: number }) {
const workers: FakeLocalSpeechWorker[] = [];
const client = new LocalSpeechWorkerClient({
config: {
modelsDir: "/tmp/models",
voiceSttModel: "parakeet-tdt-0.6b-v2-int8",
dictationSttModel: "parakeet-tdt-0.6b-v2-int8",
voiceTtsModel: "kokoro-en-v0_19",
},
requestTimeoutMs: 1000,
idleTtlMs: options?.idleTtlMs ?? 1000,
forkWorker: () => {
const worker = new FakeLocalSpeechWorker();
workers.push(worker);
return worker;
},
});
return { client, workers };
}
async function waitForMicrotasks(): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, 0));
}
describe("LocalSpeechWorkerClient", () => {
it("does not spawn the worker until first local speech use", () => {
const { workers } = createClient();
expect(workers).toHaveLength(0);
});
it("sends TTS requests through the worker and returns the audio stream", async () => {
const { client, workers } = createClient();
const provider = new WorkerBackedTextToSpeechProvider(client);
const pending = provider.synthesizeSpeech("hello");
expect(workers).toHaveLength(1);
const request = workers[0].sent[0];
expect(request).toMatchObject({
type: "tts.synthesize",
text: "hello",
config: {
modelsDir: "/tmp/models",
voiceTtsModel: "kokoro-en-v0_19",
},
});
workers[0].respond(request, {
audio: Buffer.from([1, 2, 3, 4]),
format: "pcm;rate=24000",
});
const result = await pending;
const chunks: Buffer[] = [];
for await (const chunk of result.stream) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
}
expect(result.format).toBe("pcm;rate=24000");
expect(Buffer.concat(chunks)).toEqual(Buffer.from([1, 2, 3, 4]));
});
it("forwards STT session audio and transcript events through IPC", async () => {
const { client, workers } = createClient();
const provider = new WorkerBackedSpeechToTextProvider(client, "voiceStt");
const session = provider.createSession({ logger: pino({ level: "silent" }) });
const transcriptPromise = once(session as EventEmitter, "transcript");
const committedPromise = once(session as EventEmitter, "committed");
const connect = session.connect();
const createRequest = workers[0].sent[0];
expect(createRequest).toMatchObject({ type: "session.create", kind: "voiceStt" });
workers[0].respond(createRequest, { requiredSampleRate: 16000 });
await connect;
session.appendPcm16(Buffer.from([9, 8, 7, 6]));
await waitForMicrotasks();
expect(workers[0].sent[1]).toMatchObject({
type: "session.append",
sessionId: createRequest.sessionId,
audio: Buffer.from([9, 8, 7, 6]),
});
session.commit();
await waitForMicrotasks();
expect(workers[0].sent[2]).toMatchObject({
type: "session.commit",
sessionId: createRequest.sessionId,
});
workers[0].emitWorkerMessage({
type: "session.committed",
sessionId: createRequest.sessionId,
payload: { segmentId: "seg-1", previousSegmentId: null },
});
workers[0].emitWorkerMessage({
type: "session.transcript",
sessionId: createRequest.sessionId,
payload: { segmentId: "seg-1", transcript: "hello", isFinal: true },
});
await expect(committedPromise).resolves.toEqual([
{ segmentId: "seg-1", previousSegmentId: null },
]);
await expect(transcriptPromise).resolves.toEqual([
{ segmentId: "seg-1", transcript: "hello", isFinal: true },
]);
});
it("forwards VAD session events through the shared worker", async () => {
const { client, workers } = createClient();
const provider = new WorkerBackedTurnDetectionProvider(client);
const session = provider.createSession({ logger: pino({ level: "silent" }) });
const startedPromise = once(session as EventEmitter, "speech_started");
const stoppedPromise = once(session as EventEmitter, "speech_stopped");
const connect = session.connect();
const createRequest = workers[0].sent[0];
expect(createRequest).toMatchObject({ type: "session.create", kind: "vad" });
workers[0].respond(createRequest, { requiredSampleRate: 16000 });
await connect;
workers[0].emitWorkerMessage({
type: "session.speech_started",
sessionId: createRequest.sessionId,
});
workers[0].emitWorkerMessage({
type: "session.speech_stopped",
sessionId: createRequest.sessionId,
});
await expect(startedPromise).resolves.toEqual([]);
await expect(stoppedPromise).resolves.toEqual([]);
});
it("kills an idle worker and respawns on later use", async () => {
const { client, workers } = createClient({ idleTtlMs: 5 });
const first = client.synthesizeSpeech("first");
workers[0].respond(workers[0].sent[0], {
audio: Buffer.from([1]),
format: "pcm;rate=24000",
});
await first;
await new Promise((resolve) => setTimeout(resolve, 20));
expect(workers[0].kills).toBe(1);
const second = client.synthesizeSpeech("second");
expect(workers).toHaveLength(2);
workers[1].respond(workers[1].sent[0], {
audio: Buffer.from([2]),
format: "pcm;rate=24000",
});
await second;
});
});
@@ -0,0 +1,538 @@
import { fork } from "node:child_process";
import { randomUUID } from "node:crypto";
import { EventEmitter } from "node:events";
import { Readable } from "node:stream";
import { fileURLToPath } from "node:url";
import type pino from "pino";
import type {
SpeechStreamResult,
SpeechToTextProvider,
StreamingTranscriptionSession,
TextToSpeechProvider,
} from "../../speech-provider.js";
import type { TurnDetectionProvider, TurnDetectionSession } from "../../turn-detection-provider.js";
import { applySherpaLoaderEnv } from "./sherpa/sherpa-runtime-env.js";
import type {
LocalSpeechCreateSessionResult,
LocalSpeechSessionKind,
LocalSpeechTranscriptionResult,
LocalSpeechTtsResult,
LocalSpeechWorkerConfig,
LocalSpeechWorkerRequest,
LocalSpeechWorkerResponse,
LocalSpeechWorkerToParentMessage,
} from "./worker-protocol.js";
const DEFAULT_REQUEST_TIMEOUT_MS = 30000;
const DEFAULT_IDLE_TTL_MS = 5 * 60 * 1000;
const DEFAULT_LOCAL_SAMPLE_RATE = 16000;
type LocalSpeechWorkerRequestInput = LocalSpeechWorkerRequest extends infer Request
? Request extends LocalSpeechWorkerRequest
? Omit<Request, "requestId">
: never
: never;
interface LocalSpeechWorkerProcess {
connected: boolean;
killed: boolean;
send(message: LocalSpeechWorkerRequest, callback: (error: Error | null) => void): boolean;
disconnect(): void;
kill(): boolean;
on(event: "message", listener: (message: LocalSpeechWorkerToParentMessage) => void): this;
on(event: "exit", listener: (code: number | null, signal: NodeJS.Signals | null) => void): this;
}
interface PendingRequest {
resolve: (value: unknown) => void;
reject: (error: Error) => void;
timeout: ReturnType<typeof setTimeout>;
}
interface LocalSpeechWorkerClientOptions {
config: LocalSpeechWorkerConfig;
requestTimeoutMs?: number;
idleTtlMs?: number;
forkWorker?: () => LocalSpeechWorkerProcess;
}
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 forkLocalSpeechWorker(): LocalSpeechWorkerProcess {
const env = { ...process.env };
applySherpaLoaderEnv(env);
return fork(fileURLToPath(resolveWorkerUrl()), [], {
env,
execArgv: resolveWorkerExecArgv(),
serialization: "advanced",
stdio: ["ignore", "ignore", "inherit", "ipc"],
}) as LocalSpeechWorkerProcess;
}
function isResponse(
message: LocalSpeechWorkerToParentMessage,
): message is LocalSpeechWorkerResponse {
return message.type === "response";
}
export class LocalSpeechWorkerClient {
private readonly config: LocalSpeechWorkerConfig;
private readonly requestTimeoutMs: number;
private readonly idleTtlMs: number;
private readonly forkWorker: () => LocalSpeechWorkerProcess;
private readonly pendingRequests = new Map<string, PendingRequest>();
private readonly activeSessionIds = new Set<string>();
private readonly sessionEmitters = new Map<string, EventEmitter>();
private worker: LocalSpeechWorkerProcess | null = null;
private inFlightRequests = 0;
private idleTimer: ReturnType<typeof setTimeout> | null = null;
constructor(options: LocalSpeechWorkerClientOptions) {
this.config = options.config;
this.requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
this.idleTtlMs = options.idleTtlMs ?? DEFAULT_IDLE_TTL_MS;
this.forkWorker = options.forkWorker ?? forkLocalSpeechWorker;
}
async synthesizeSpeech(text: string): Promise<SpeechStreamResult> {
const result = await this.sendRequest<LocalSpeechTtsResult>({
type: "tts.synthesize",
config: this.config,
text,
});
return {
stream: Readable.from([result.audio]),
format: result.format,
};
}
transcribeVoice(audio: Buffer, format: string): Promise<LocalSpeechTranscriptionResult> {
return this.sendRequest<LocalSpeechTranscriptionResult>({
type: "stt.transcribe",
config: this.config,
model: "voice",
audio,
format,
});
}
async createSession(
kind: LocalSpeechSessionKind,
emitter: EventEmitter,
): Promise<{ sessionId: string; requiredSampleRate: number }> {
const sessionId = randomUUID();
this.activeSessionIds.add(sessionId);
this.sessionEmitters.set(sessionId, emitter);
try {
const result = await this.sendRequest<LocalSpeechCreateSessionResult>({
type: "session.create",
config: this.config,
sessionId,
kind,
});
return { sessionId, requiredSampleRate: result.requiredSampleRate };
} catch (err) {
this.activeSessionIds.delete(sessionId);
this.sessionEmitters.delete(sessionId);
this.scheduleIdleShutdownIfReady();
throw err;
}
}
appendSessionAudio(sessionId: string, audio: Buffer): void {
void this.sendRequest({ type: "session.append", sessionId, audio }).catch((err) => {
this.emitSessionError(sessionId, err);
});
}
commitSession(sessionId: string): void {
void this.sendRequest({ type: "session.commit", sessionId }).catch((err) => {
this.emitSessionError(sessionId, err);
});
}
clearSession(sessionId: string): void {
void this.sendRequest({ type: "session.clear", sessionId }).catch((err) => {
this.emitSessionError(sessionId, err);
});
}
flushSession(sessionId: string): void {
void this.sendRequest({ type: "session.flush", sessionId }).catch((err) => {
this.emitSessionError(sessionId, err);
});
}
resetSession(sessionId: string): void {
void this.sendRequest({ type: "session.reset", sessionId }).catch((err) => {
this.emitSessionError(sessionId, err);
});
}
closeSession(sessionId: string): void {
this.activeSessionIds.delete(sessionId);
this.sessionEmitters.delete(sessionId);
void this.sendRequest({ type: "session.close", sessionId }).catch(() => {
// Closing is best-effort; the parent already dropped the session.
});
this.scheduleIdleShutdownIfReady();
}
shutdown(): void {
this.clearIdleTimer();
this.rejectAllPending(new Error("Local speech worker shut down"));
this.activeSessionIds.clear();
this.sessionEmitters.clear();
const worker = this.worker;
this.worker = null;
if (worker && !worker.killed) {
try {
worker.disconnect();
} catch {
// ignore
}
try {
worker.kill();
} catch {
// ignore
}
}
}
private sendRequest<T = void>(input: LocalSpeechWorkerRequestInput): Promise<T> {
const worker = this.ensureWorker();
const requestId = randomUUID();
const message = { ...input, requestId } as LocalSpeechWorkerRequest;
this.inFlightRequests++;
this.clearIdleTimer();
return new Promise<T>((resolve, reject) => {
const timeout = setTimeout(() => {
this.pendingRequests.delete(requestId);
this.inFlightRequests = Math.max(0, this.inFlightRequests - 1);
this.scheduleIdleShutdownIfReady();
reject(new Error(`Local speech worker request timed out: ${input.type}`));
}, this.requestTimeoutMs);
this.pendingRequests.set(requestId, {
resolve: (value) => resolve(value as T),
reject,
timeout,
});
const sent = worker.send(message, (error) => {
if (!error) {
return;
}
const pending = this.pendingRequests.get(requestId);
if (!pending) {
return;
}
clearTimeout(pending.timeout);
this.pendingRequests.delete(requestId);
this.inFlightRequests = Math.max(0, this.inFlightRequests - 1);
this.scheduleIdleShutdownIfReady();
pending.reject(error);
});
if (!sent) {
const pending = this.pendingRequests.get(requestId);
if (pending) {
clearTimeout(pending.timeout);
this.pendingRequests.delete(requestId);
this.inFlightRequests = Math.max(0, this.inFlightRequests - 1);
this.scheduleIdleShutdownIfReady();
pending.reject(new Error("Local speech worker IPC channel is not writable"));
}
}
});
}
private ensureWorker(): LocalSpeechWorkerProcess {
if (this.worker && !this.worker.killed && this.worker.connected) {
return this.worker;
}
const worker = this.forkWorker();
this.worker = worker;
worker.on("message", (message) => this.handleWorkerMessage(message));
worker.on("exit", () => this.handleWorkerExit());
return worker;
}
private handleWorkerMessage(message: LocalSpeechWorkerToParentMessage): void {
if (isResponse(message)) {
const pending = this.pendingRequests.get(message.requestId);
if (!pending) {
return;
}
clearTimeout(pending.timeout);
this.pendingRequests.delete(message.requestId);
this.inFlightRequests = Math.max(0, this.inFlightRequests - 1);
this.scheduleIdleShutdownIfReady();
if (message.ok) {
pending.resolve(message.result);
} else {
pending.reject(new Error(message.error));
}
return;
}
const emitter = this.sessionEmitters.get(message.sessionId);
if (!emitter) {
return;
}
switch (message.type) {
case "session.committed":
emitter.emit("committed", message.payload);
return;
case "session.transcript":
emitter.emit("transcript", message.payload);
return;
case "session.speech_started":
emitter.emit("speech_started");
return;
case "session.speech_stopped":
emitter.emit("speech_stopped");
return;
case "session.error":
emitter.emit("error", new Error(message.error));
return;
}
}
private handleWorkerExit(): void {
this.worker = null;
this.clearIdleTimer();
this.rejectAllPending(new Error("Local speech worker exited"));
for (const [sessionId, emitter] of this.sessionEmitters) {
if (this.activeSessionIds.has(sessionId)) {
emitter.emit("error", new Error("Local speech worker exited"));
}
}
this.activeSessionIds.clear();
this.sessionEmitters.clear();
this.inFlightRequests = 0;
}
private rejectAllPending(error: Error): void {
for (const [requestId, pending] of this.pendingRequests) {
clearTimeout(pending.timeout);
pending.reject(error);
this.pendingRequests.delete(requestId);
}
}
private emitSessionError(sessionId: string, error: unknown): void {
const emitter = this.sessionEmitters.get(sessionId);
if (!emitter) {
return;
}
emitter.emit("error", error instanceof Error ? error : new Error(String(error)));
}
private scheduleIdleShutdownIfReady(): void {
if (!this.worker || this.inFlightRequests > 0 || this.activeSessionIds.size > 0) {
return;
}
this.clearIdleTimer();
this.idleTimer = setTimeout(() => {
if (this.inFlightRequests === 0 && this.activeSessionIds.size === 0) {
this.shutdown();
}
}, this.idleTtlMs);
}
private clearIdleTimer(): void {
if (this.idleTimer) {
clearTimeout(this.idleTimer);
this.idleTimer = null;
}
}
}
export class WorkerBackedTextToSpeechProvider implements TextToSpeechProvider {
constructor(private readonly client: LocalSpeechWorkerClient) {}
synthesizeSpeech(text: string): Promise<SpeechStreamResult> {
return this.client.synthesizeSpeech(text);
}
}
export class WorkerBackedSpeechToTextProvider implements SpeechToTextProvider {
public readonly id = "local" as const;
constructor(
private readonly client: LocalSpeechWorkerClient,
private readonly kind: Extract<LocalSpeechSessionKind, "voiceStt" | "dictationStt">,
) {}
createSession(_params: {
logger: pino.Logger;
language?: string;
prompt?: string;
}): StreamingTranscriptionSession {
return new WorkerBackedTranscriptionSession(this.client, this.kind);
}
}
export class WorkerBackedTurnDetectionProvider implements TurnDetectionProvider {
public readonly id = "local" as const;
constructor(private readonly client: LocalSpeechWorkerClient) {}
createSession(_params: { logger: pino.Logger }): TurnDetectionSession {
return new WorkerBackedTurnDetectionSession(this.client);
}
}
class WorkerBackedTranscriptionSession
extends EventEmitter
implements StreamingTranscriptionSession
{
public requiredSampleRate = DEFAULT_LOCAL_SAMPLE_RATE;
private connectedSessionId: string | null = null;
private connecting: Promise<void> | null = null;
constructor(
private readonly client: LocalSpeechWorkerClient,
private readonly kind: Extract<LocalSpeechSessionKind, "voiceStt" | "dictationStt">,
) {
super();
}
async connect(): Promise<void> {
if (this.connectedSessionId) {
return;
}
if (!this.connecting) {
this.connecting = this.connectRemoteSession();
}
await this.connecting;
}
private async connectRemoteSession(): Promise<void> {
try {
const result = await this.client.createSession(this.kind, this);
this.connectedSessionId = result.sessionId;
this.requiredSampleRate = result.requiredSampleRate;
} finally {
this.connecting = null;
}
}
appendPcm16(pcm16le: Buffer): void {
const sessionId = this.connectedSessionId;
if (!sessionId) {
this.emit("error", new Error("Local STT session not connected"));
return;
}
this.client.appendSessionAudio(sessionId, pcm16le);
}
commit(): void {
const sessionId = this.connectedSessionId;
if (!sessionId) {
this.emit("error", new Error("Local STT session not connected"));
return;
}
this.client.commitSession(sessionId);
}
clear(): void {
const sessionId = this.connectedSessionId;
if (sessionId) {
this.client.clearSession(sessionId);
}
}
close(): void {
const sessionId = this.connectedSessionId;
this.connectedSessionId = null;
if (sessionId) {
this.client.closeSession(sessionId);
}
}
}
class WorkerBackedTurnDetectionSession extends EventEmitter implements TurnDetectionSession {
public requiredSampleRate = DEFAULT_LOCAL_SAMPLE_RATE;
private connectedSessionId: string | null = null;
private connecting: Promise<void> | null = null;
constructor(private readonly client: LocalSpeechWorkerClient) {
super();
}
async connect(): Promise<void> {
if (this.connectedSessionId) {
return;
}
if (!this.connecting) {
this.connecting = this.connectRemoteSession();
}
await this.connecting;
}
private async connectRemoteSession(): Promise<void> {
try {
const result = await this.client.createSession("vad", this);
this.connectedSessionId = result.sessionId;
this.requiredSampleRate = result.requiredSampleRate;
} finally {
this.connecting = null;
}
}
appendPcm16(pcm16le: Buffer): void {
const sessionId = this.connectedSessionId;
if (!sessionId) {
this.emit("error", new Error("Local turn-detection session not connected"));
return;
}
this.client.appendSessionAudio(sessionId, pcm16le);
}
flush(): void {
const sessionId = this.connectedSessionId;
if (sessionId) {
this.client.flushSession(sessionId);
}
}
reset(): void {
const sessionId = this.connectedSessionId;
if (sessionId) {
this.client.resetSession(sessionId);
}
}
close(): void {
const sessionId = this.connectedSessionId;
this.connectedSessionId = null;
if (sessionId) {
this.client.closeSession(sessionId);
}
}
}
@@ -0,0 +1,335 @@
import pino from "pino";
import type { StreamingTranscriptionSession } from "../../speech-provider.js";
import type { TurnDetectionSession } from "../../turn-detection-provider.js";
import { getLocalSpeechModelDir, type LocalSttModelId, type LocalTtsModelId } from "./models.js";
import { SherpaOfflineRecognizerEngine } from "./sherpa/sherpa-offline-recognizer.js";
import { SherpaOnnxParakeetSTT } from "./sherpa/sherpa-parakeet-stt.js";
import { SherpaParakeetRealtimeTranscriptionSession } from "./sherpa/sherpa-parakeet-realtime-session.js";
import { SherpaOnnxTTS } from "./sherpa/sherpa-tts.js";
import {
ensureSileroVadModel,
SherpaSileroTurnDetectionProvider,
} from "./sherpa/silero-vad-provider.js";
import type {
LocalSpeechWorkerConfig,
LocalSpeechWorkerRequest,
LocalSpeechWorkerToParentMessage,
} from "./worker-protocol.js";
process.title = "Paseo Voice";
type LocalSttEngine = SherpaOfflineRecognizerEngine;
const logger = pino({
level: process.env.PASEO_LOG_LEVEL ?? "info",
}).child({ module: "speech", component: "local-worker" });
const sttEngines = new Map<string, LocalSttEngine>();
const sttProviders = new Map<string, SherpaOnnxParakeetSTT>();
const ttsProviders = new Map<string, SherpaOnnxTTS>();
const sessions = new Map<string, StreamingTranscriptionSession | TurnDetectionSession>();
const unsubscribeBySessionId = new Map<string, Array<() => void>>();
let ipcClosing = false;
function sendToParent(message: LocalSpeechWorkerToParentMessage): void {
if (ipcClosing || !process.connected || !process.send) {
return;
}
try {
process.send(message, (error) => {
if (error) {
ipcClosing = true;
}
});
} catch {
ipcClosing = true;
}
}
function sttModelId(
config: LocalSpeechWorkerConfig,
model: "voice" | "dictation",
): LocalSttModelId {
return (model === "voice" ? config.voiceSttModel : config.dictationSttModel) as LocalSttModelId;
}
function ttsModelId(config: LocalSpeechWorkerConfig): LocalTtsModelId {
return config.voiceTtsModel as LocalTtsModelId;
}
function sttEngineKey(config: LocalSpeechWorkerConfig, modelId: LocalSttModelId): string {
return `${config.modelsDir}:${modelId}`;
}
function ttsKey(config: LocalSpeechWorkerConfig): string {
return [
config.modelsDir,
config.voiceTtsModel,
config.voiceTtsSpeakerId ?? 0,
config.voiceTtsSpeed ?? 1,
].join(":");
}
function getSttEngine(
config: LocalSpeechWorkerConfig,
model: "voice" | "dictation",
): LocalSttEngine {
const modelId = sttModelId(config, model);
const key = sttEngineKey(config, modelId);
const existing = sttEngines.get(key);
if (existing) {
return existing;
}
const modelDir = getLocalSpeechModelDir(config.modelsDir, modelId);
const created = new SherpaOfflineRecognizerEngine(
{
model: {
kind: "nemo_transducer",
encoder: `${modelDir}/encoder.int8.onnx`,
decoder: `${modelDir}/decoder.int8.onnx`,
joiner: `${modelDir}/joiner.int8.onnx`,
tokens: `${modelDir}/tokens.txt`,
},
numThreads: 2,
debug: 0,
},
logger,
);
sttEngines.set(key, created);
return created;
}
function getSttProvider(
config: LocalSpeechWorkerConfig,
model: "voice" | "dictation",
): SherpaOnnxParakeetSTT {
const modelId = sttModelId(config, model);
const key = sttEngineKey(config, modelId);
const existing = sttProviders.get(key);
if (existing) {
return existing;
}
const created = new SherpaOnnxParakeetSTT({ engine: getSttEngine(config, model) }, logger);
sttProviders.set(key, created);
return created;
}
function getTtsProvider(config: LocalSpeechWorkerConfig): SherpaOnnxTTS {
const key = ttsKey(config);
const existing = ttsProviders.get(key);
if (existing) {
return existing;
}
const modelDir = getLocalSpeechModelDir(config.modelsDir, ttsModelId(config));
const created = new SherpaOnnxTTS(
{
preset: ttsModelId(config),
modelDir,
speakerId: config.voiceTtsSpeakerId,
speed: config.voiceTtsSpeed,
},
logger,
);
ttsProviders.set(key, created);
return created;
}
function cleanupSession(sessionId: string): void {
const unsubscribe = unsubscribeBySessionId.get(sessionId);
if (unsubscribe) {
for (const fn of unsubscribe) {
try {
fn();
} catch {
// ignore
}
}
}
unsubscribeBySessionId.delete(sessionId);
const session = sessions.get(sessionId);
sessions.delete(sessionId);
try {
session?.close();
} catch {
// ignore
}
}
function trackTranscriptionSession(
sessionId: string,
session: StreamingTranscriptionSession,
): void {
session.on("committed", (payload) => {
sendToParent({ type: "session.committed", sessionId, payload });
});
session.on("transcript", (payload) => {
sendToParent({ type: "session.transcript", sessionId, payload });
});
session.on("error", (err) => {
sendToParent({
type: "session.error",
sessionId,
error: err instanceof Error ? err.message : String(err),
});
});
unsubscribeBySessionId.set(sessionId, []);
}
function trackTurnDetectionSession(sessionId: string, session: TurnDetectionSession): void {
session.on("speech_started", () => {
sendToParent({ type: "session.speech_started", sessionId });
});
session.on("speech_stopped", () => {
sendToParent({ type: "session.speech_stopped", sessionId });
});
session.on("error", (err) => {
sendToParent({
type: "session.error",
sessionId,
error: err instanceof Error ? err.message : String(err),
});
});
unsubscribeBySessionId.set(sessionId, []);
}
async function createSession(
message: Extract<LocalSpeechWorkerRequest, { type: "session.create" }>,
) {
cleanupSession(message.sessionId);
if (message.kind === "vad") {
let vadModelPath: string | undefined;
try {
vadModelPath = await ensureSileroVadModel(message.config.modelsDir, logger);
} catch (err) {
logger.warn({ err }, "Failed to provision Silero VAD model, falling back to bundled");
}
const provider = new SherpaSileroTurnDetectionProvider({ modelPath: vadModelPath }, logger);
const session = provider.createSession({ logger });
trackTurnDetectionSession(message.sessionId, session);
await session.connect();
sessions.set(message.sessionId, session);
return { requiredSampleRate: session.requiredSampleRate };
}
const model = message.kind === "voiceStt" ? "voice" : "dictation";
const engine = getSttEngine(message.config, model);
const session =
message.kind === "voiceStt"
? getSttProvider(message.config, "voice").createSession({ logger })
: new SherpaParakeetRealtimeTranscriptionSession({ engine });
trackTranscriptionSession(message.sessionId, session);
await session.connect();
sessions.set(message.sessionId, session);
return { requiredSampleRate: session.requiredSampleRate };
}
function sendOk(requestId: string, result?: unknown): void {
sendToParent({ type: "response", requestId, ok: true, result });
}
type LocalSpeechWorkerSessionRequest = Extract<
LocalSpeechWorkerRequest,
{
type:
| "session.append"
| "session.commit"
| "session.clear"
| "session.flush"
| "session.reset"
| "session.close";
}
>;
function handleSessionRequest(message: LocalSpeechWorkerSessionRequest): void {
if (message.type === "session.close") {
cleanupSession(message.sessionId);
sendOk(message.requestId);
return;
}
const session = sessions.get(message.sessionId);
switch (message.type) {
case "session.append":
session?.appendPcm16(message.audio);
break;
case "session.commit":
if (session && "commit" in session) {
session.commit();
}
break;
case "session.clear":
if (session && "clear" in session) {
session.clear();
}
break;
case "session.flush":
if (session && "flush" in session) {
session.flush();
}
break;
case "session.reset":
if (session && "reset" in session) {
session.reset();
}
break;
}
sendOk(message.requestId);
}
async function handleRequest(message: LocalSpeechWorkerRequest): Promise<void> {
if (message.type === "tts.synthesize") {
const result = await getTtsProvider(message.config).synthesizeSpeech(message.text);
const chunks: Buffer[] = [];
for await (const chunk of result.stream) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
}
sendOk(message.requestId, { audio: Buffer.concat(chunks), format: result.format });
return;
}
if (message.type === "stt.transcribe") {
const result = await getSttProvider(message.config, message.model).transcribeAudio(
message.audio,
message.format,
);
sendOk(message.requestId, result);
return;
}
if (message.type === "session.create") {
const result = await createSession(message);
sendOk(message.requestId, result);
return;
}
handleSessionRequest(message);
}
process.on("message", (message: LocalSpeechWorkerRequest) => {
void handleRequest(message).catch((error: unknown) => {
sendToParent({
type: "response",
requestId: message.requestId,
ok: false,
error: error instanceof Error ? error.message : "Local speech worker request failed",
});
});
});
process.once("disconnect", () => {
ipcClosing = true;
for (const sessionId of Array.from(sessions.keys())) {
cleanupSession(sessionId);
}
for (const tts of ttsProviders.values()) {
try {
tts.free();
} catch {
// ignore
}
}
for (const engine of sttEngines.values()) {
engine.free();
}
});
@@ -0,0 +1,122 @@
import type {
StreamingTranscriptionCommittedEvent,
StreamingTranscriptionEvent,
TranscriptionResult,
} from "../../speech-provider.js";
export interface LocalSpeechWorkerConfig {
modelsDir: string;
voiceSttModel: string;
dictationSttModel: string;
voiceTtsModel: string;
voiceTtsSpeakerId?: number;
voiceTtsSpeed?: number;
}
export type LocalSpeechSessionKind = "voiceStt" | "dictationStt" | "vad";
export type LocalSpeechWorkerRequest =
| {
type: "tts.synthesize";
requestId: string;
config: LocalSpeechWorkerConfig;
text: string;
}
| {
type: "stt.transcribe";
requestId: string;
config: LocalSpeechWorkerConfig;
model: "voice" | "dictation";
audio: Buffer;
format: string;
}
| {
type: "session.create";
requestId: string;
config: LocalSpeechWorkerConfig;
sessionId: string;
kind: LocalSpeechSessionKind;
}
| {
type: "session.append";
requestId: string;
sessionId: string;
audio: Buffer;
}
| {
type: "session.commit";
requestId: string;
sessionId: string;
}
| {
type: "session.clear";
requestId: string;
sessionId: string;
}
| {
type: "session.flush";
requestId: string;
sessionId: string;
}
| {
type: "session.reset";
requestId: string;
sessionId: string;
}
| {
type: "session.close";
requestId: string;
sessionId: string;
};
export type LocalSpeechWorkerResponse =
| {
type: "response";
requestId: string;
ok: true;
result?: unknown;
}
| {
type: "response";
requestId: string;
ok: false;
error: string;
};
export type LocalSpeechWorkerEvent =
| {
type: "session.committed";
sessionId: string;
payload: StreamingTranscriptionCommittedEvent;
}
| {
type: "session.transcript";
sessionId: string;
payload: StreamingTranscriptionEvent;
}
| {
type: "session.speech_started";
sessionId: string;
}
| {
type: "session.speech_stopped";
sessionId: string;
}
| {
type: "session.error";
sessionId: string;
error: string;
};
export type LocalSpeechWorkerToParentMessage = LocalSpeechWorkerResponse | LocalSpeechWorkerEvent;
export interface LocalSpeechCreateSessionResult {
requiredSampleRate: number;
}
export interface LocalSpeechTtsResult {
audio: Buffer;
format: string;
}
export type LocalSpeechTranscriptionResult = TranscriptionResult;