Files
Bohdan Triapitsyn 391f938334 feat(voice): match local and macOS voices to the language of the text
Text-to-speech picked one voice regardless of what language a reply was in.
A dependency-free language detector (script, marker letters, function words)
now decides the language of the whole message once; with the new
"Match the voice to the language of the text" setting the local provider
switches to a catalog model for that language (Kokoro zh/en and Piper models
for 12 languages, downloaded on first use like the existing model) and macOS
say switches to an installed voice whose locale matches. The local voice
picker lists voices of every installed model, and the settings show which
language models are on disk.

The Ukrainian Piper medium build is a character-level model that sherpa-onnx
turns into noise, so the espeak-based Lada build is used instead.

Claude-Session: https://claude.ai/code/session_017TK5JAYDfT3Fotc23UEg98
2026-08-30 02:24:22 +03:00

200 lines
5.8 KiB
JavaScript

/**
* Dictation local-speech worker process.
*
* Hosts the sherpa-onnx native inference (Parakeet STT) in a separate process
* so ONNX decoding never blocks the main OpenChamber server. Communicates
* with the parent over child_process IPC (advanced serialization, so Buffers
* survive the trip as Uint8Array).
*
* Request/response protocol (parent -> worker):
* { type: 'session.create', requestId, sessionId, modelsDir, modelId }
* { type: 'session.append', requestId, sessionId, audio }
* { type: 'session.commit' | 'session.clear' | 'session.close', requestId, sessionId }
* Worker -> parent:
* { type: 'response', requestId, ok, result?, error? }
* { type: 'session.committed' | 'session.transcript' | 'session.error', sessionId, ... }
*/
import {
SherpaOfflineRecognizerEngine,
SherpaSegmentTranscriptionSession,
} from './sherpa-recognizer.js';
import { SherpaTtsEngine } from './sherpa-tts.js';
import { getLocalSttModelDir, getLocalSttModelSpec } from './model-catalog.js';
import { pcm16ToWav } from '../audio.js';
import path from 'path';
process.title = 'OpenChamber Dictation';
const engines = new Map();
const ttsEngines = new Map();
const sessions = new Map();
let ipcClosing = false;
function sendToParent(message) {
if (ipcClosing || !process.connected || !process.send) {
return;
}
try {
process.send(message, (error) => {
if (error) {
ipcClosing = true;
}
});
} catch {
ipcClosing = true;
}
}
function sendOk(requestId, result) {
sendToParent({ type: 'response', requestId, ok: true, ...(result !== undefined ? { result } : {}) });
}
function getEngine(modelsDir, modelId) {
const key = `${modelsDir}:${modelId}`;
const existing = engines.get(key);
if (existing) {
return existing;
}
const modelDir = getLocalSttModelDir(modelsDir, modelId);
const spec = getLocalSttModelSpec(modelId);
const created = new SherpaOfflineRecognizerEngine({
type: spec.type,
encoder: path.join(modelDir, spec.files.encoder),
decoder: path.join(modelDir, spec.files.decoder),
...(spec.files.joiner ? { joiner: path.join(modelDir, spec.files.joiner) } : {}),
tokens: path.join(modelDir, spec.files.tokens),
numThreads: 2,
});
engines.set(key, created);
return created;
}
function cleanupSession(sessionId) {
const session = sessions.get(sessionId);
sessions.delete(sessionId);
try {
session?.close();
} catch {
// ignore
}
}
function toBuffer(audio) {
if (Buffer.isBuffer(audio)) {
return audio;
}
if (audio instanceof Uint8Array) {
return Buffer.from(audio.buffer, audio.byteOffset, audio.byteLength);
}
if (audio && typeof audio === 'object' && audio.type === 'Buffer' && Array.isArray(audio.data)) {
return Buffer.from(audio.data);
}
throw new Error('Unsupported audio payload in dictation worker');
}
function getTtsEngine(modelsDir, modelId) {
const key = `${modelsDir}:${modelId}`;
const existing = ttsEngines.get(key);
if (existing) {
return existing;
}
const spec = getLocalSttModelSpec(modelId);
const created = new SherpaTtsEngine({
modelDir: getLocalSttModelDir(modelsDir, modelId),
type: spec.type,
files: spec.files,
lexicon: spec.lexicon,
numThreads: 2,
});
ttsEngines.set(key, created);
return created;
}
async function handleRequest(message) {
switch (message.type) {
case 'tts.synthesize': {
const engine = getTtsEngine(message.modelsDir, message.modelId);
const { pcm16, sampleRate } = engine.synthesize(message.text, {
speakerId: message.speakerId,
speed: message.speed,
});
sendOk(message.requestId, {
audio: pcm16ToWav(pcm16, sampleRate),
format: 'audio/wav',
});
return;
}
case 'session.create': {
cleanupSession(message.sessionId);
const engine = getEngine(message.modelsDir, message.modelId);
const session = new SherpaSegmentTranscriptionSession({ engine });
session.on('committed', (payload) => {
sendToParent({ type: 'session.committed', sessionId: message.sessionId, payload });
});
session.on('transcript', (payload) => {
sendToParent({ type: 'session.transcript', sessionId: message.sessionId, payload });
});
session.on('error', (err) => {
sendToParent({
type: 'session.error',
sessionId: message.sessionId,
error: err instanceof Error ? err.message : String(err),
});
});
await session.connect();
sessions.set(message.sessionId, session);
sendOk(message.requestId, { requiredSampleRate: session.requiredSampleRate });
return;
}
case 'session.append': {
sessions.get(message.sessionId)?.appendPcm16(toBuffer(message.audio));
sendOk(message.requestId);
return;
}
case 'session.commit': {
sessions.get(message.sessionId)?.commit();
sendOk(message.requestId);
return;
}
case 'session.clear': {
sessions.get(message.sessionId)?.clear();
sendOk(message.requestId);
return;
}
case 'session.close': {
cleanupSession(message.sessionId);
sendOk(message.requestId);
return;
}
default: {
throw new Error(`Unknown dictation worker request: ${message?.type}`);
}
}
}
process.on('message', (message) => {
void handleRequest(message).catch((error) => {
sendToParent({
type: 'response',
requestId: message?.requestId,
ok: false,
error: error instanceof Error ? error.message : 'Dictation worker request failed',
});
});
});
process.once('disconnect', () => {
ipcClosing = true;
for (const sessionId of Array.from(sessions.keys())) {
cleanupSession(sessionId);
}
for (const engine of engines.values()) {
engine.free();
}
for (const tts of ttsEngines.values()) {
tts.free();
}
process.exit(0);
});