Parakeet is an offline model trained on whole utterances, so re-decoding the growing buffer to animate a live transcript cost O(n^2) work for a result the final decode replaced. Sessions now decode once per committed segment, and the composer shows a scrolling waveform of the mic level instead of running text. Long dictations split at a pause once past 60s (hard cap 90s) instead of on a blind 15s timer, so cuts no longer land mid-word. Committed segments decode while the user is still speaking: a 185s dictation returns 4.1s after stop instead of 11.0s, with identical text (816 vs 817 words). Also fixes two ways the stream manager could silently drop transcribed audio. It now counts the commits it issued instead of trusting the session's echoed events, so a commit still in flight when the client finishes can no longer be left out of the final text. And segment byte/peak accounting is reset where the commit is issued rather than when the event arrives, which could mistake the tail of a dictation for silence and clear it.
198 lines
5.8 KiB
JavaScript
198 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),
|
|
files: spec.files,
|
|
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);
|
|
});
|