Files
openchamber/packages/web/server/lib/dictation/local/sherpa-recognizer.js
T
Bohdan Triapitsyn de1b85ac56 feat(voice): first-class voice input and local TTS across web, desktop, and mobile (#2018)
Complete rebuild of voice input on a server-authoritative streaming
architecture, replacing the legacy Web Speech / whole-blob / WASM engines
and the dead voice-agent layer (~4k lines removed).

Speech-to-text (dictation):
- Client streams 16 kHz mono PCM16 chunks over /api/dictation/ws with
  seq/ack ordering; buffered audio is retained and replayed on reconnect
- Server transcribes and streams live partial transcripts back;
  segments auto-commit every ~15s with silence suppression and adaptive
  finalization timeouts
- Local provider (default, zero config): sherpa-onnx models in a forked
  worker process — auto-download with progress, staged extraction with
  verification, corrupt-model auto-recovery, idle shutdown after 5 min
- Model catalog with settings picker (accuracy/speed ratings, sizes,
  download/delete): Parakeet TDT v2 (English) and v3 (25 European
  languages, auto-detected), Whisper base and tiny (multilingual, light)
- OpenAI-compatible provider for any Whisper endpoint
- Composer overlay with live transcript, volume meter, timer, and
  cancel / insert / insert-and-send actions; failed transcriptions keep
  their audio for retry or accepting the partial text as-is
- Configurable keyboard shortcut (default mod+alt+v) toggles dictation;
  Enter confirms and Escape cancels while recording
- Overlay is pixel-aligned with the composer (measured footer height,
  matching paddings/typography/gaps) — no layout shift when toggling

Text-to-speech:
- Local Kokoro provider (English, 11 voices) synthesized in the same
  worker via /api/dictation/tts/speak, managed by the shared model
  pipeline; sentence-pipelined playback keeps time-to-first-audio at
  ~1 sentence regardless of message length, and stop cancels in-flight
  synthesis
- Sanitizer keeps inline-code content (strips backticks only), reads
  interword slashes aloud, and removes only absolute file paths

Settings:
- Voice page unified: a single read-aloud toggle owns all playback
  options (the confusing "Enable Voice Mode" is gone); a new "Enable
  voice input" toggle (default on, persisted to settings.json) hides
  the composer mic entirely when disabled

Mobile and transport:
- iOS/Android microphone permissions added (dictation was previously
  impossible on mobile)
- Fixed Android WebSocket upgrades: the Capacitor WebView origin
  (https://localhost) was missing from the packaged-client allowlist,
  403-ing every WS connection — root cause of the old mobile SSE lock,
  which is now removed for all transports

Security and conventions:
- All HTTP routes sit behind the global /api auth gate; the WS upgrade
  explicitly validates the UI session and origin, with oc_url_token
  narrowly allowlisted and covered by tests; the dictation socket mints
  a fresh URL token before connecting
- Routes register before the generic OpenCode proxy; the client goes
  through runtimeFetch/getRuntimeUrlResolver, and runtime switches
  reset the dictation socket
- VS Code deliberately reports dictation as unavailable (no server
  process in that runtime)

CI: workflow Node bumped 20 -> 22 to match the repo engines and fix
better-sqlite3 installs broken by node-gyp@latest on Node 20.

New dependency: sherpa-onnx-node (prebuilt N-API; macOS/Linux x64+arm64,
Windows x64 — Windows-on-ARM falls back to the OpenAI-compatible provider)
2026-07-04 02:48:07 +03:00

278 lines
8.0 KiB
JavaScript

/**
* Sherpa-onnx offline recognizer engine (NeMo transducer / Parakeet) plus a
* realtime streaming transcription session that re-decodes the accumulated
* segment audio on a throttle to produce live partial transcripts.
*
* Runs inside the dictation worker process only — never load the native
* addon in the main server process.
*/
import { EventEmitter } from 'events';
import { existsSync } from 'fs';
import { randomUUID } from 'crypto';
import { loadSherpaOnnxNode } from './sherpa-loader.js';
import { pcm16lePeakAbs, pcm16leToFloat32 } from '../audio.js';
function assertFileExists(filePath, label) {
if (!existsSync(filePath)) {
throw new Error(`Missing ${label}: ${filePath}`);
}
}
export class SherpaOfflineRecognizerEngine {
/**
* @param {{ type: 'nemo_transducer' | 'whisper',
* encoder: string, decoder: string, joiner?: string, tokens: string,
* numThreads?: number }} config
*/
constructor(config) {
assertFileExists(config.encoder, 'offline encoder');
assertFileExists(config.decoder, 'offline decoder');
if (config.type === 'nemo_transducer') {
assertFileExists(config.joiner, 'offline joiner');
}
assertFileExists(config.tokens, 'tokens');
const sherpa = loadSherpaOnnxNode();
const modelConfig =
config.type === 'whisper'
? {
whisper: {
encoder: config.encoder,
decoder: config.decoder,
// Empty language auto-detects for multilingual Whisper exports.
language: '',
task: 'transcribe',
tailPaddings: -1,
},
tokens: config.tokens,
modelType: 'whisper',
numThreads: config.numThreads ?? 2,
provider: 'cpu',
debug: 0,
}
: {
transducer: {
encoder: config.encoder,
decoder: config.decoder,
joiner: config.joiner,
},
tokens: config.tokens,
modelType: 'nemo_transducer',
numThreads: config.numThreads ?? 2,
provider: 'cpu',
debug: 0,
};
const recognizerConfig = {
featConfig: {
sampleRate: 16000,
featureDim: 80,
},
modelConfig,
decodingMethod: 'greedy_search',
maxActivePaths: 4,
};
this.recognizer = new sherpa.OfflineRecognizer(recognizerConfig);
const sr = this.recognizer?.config?.featConfig?.sampleRate;
this.sampleRate =
typeof sr === 'number' && Number.isFinite(sr) && sr > 0
? sr
: recognizerConfig.featConfig.sampleRate;
}
createStream() {
return this.recognizer.createStream();
}
acceptWaveform(stream, sampleRate, samples) {
if (!stream || typeof stream.acceptWaveform !== 'function') {
throw new Error('Unexpected sherpa offline stream: missing acceptWaveform()');
}
// sherpa-onnx-node expects acceptWaveform({ samples, sampleRate });
// the WASM build expects acceptWaveform(sampleRate, samples).
if (stream.acceptWaveform.length <= 1) {
stream.acceptWaveform({ samples, sampleRate });
} else {
stream.acceptWaveform(sampleRate, samples);
}
}
/**
* Decode a full PCM16 segment and return its text.
* Applies auto-gain when the peak is low so quiet microphones still decode.
* @param {Buffer} pcm16
* @returns {string}
*/
decodePcm16(pcm16) {
if (pcm16.length === 0) {
return '';
}
const peak = pcm16lePeakAbs(pcm16);
const peakFloat = peak / 32768.0;
const targetPeak = 0.6;
const maxGain = 50;
const gain =
peakFloat > 0 && peakFloat < targetPeak ? Math.min(maxGain, targetPeak / peakFloat) : 1;
const stream = this.createStream();
try {
const floatSamples = pcm16leToFloat32(pcm16, gain);
this.acceptWaveform(stream, this.sampleRate, floatSamples);
this.recognizer.decode(stream);
const result = this.recognizer.getResult(stream);
const text =
typeof result === 'object' && result && 'text' in result ? result.text : result;
return String(text ?? '').trim();
} finally {
try {
stream.free?.();
} catch {
// ignore
}
}
}
free() {
try {
this.recognizer?.free?.();
} catch {
// ignore
}
}
}
/**
* Streaming transcription session backed by the offline recognizer.
* Accumulates the current segment's PCM and re-decodes it at most every
* `minDecodeIntervalMs` to emit non-final partial transcripts; `commit()`
* finalizes the segment and starts a new one.
*
* Implements the StreamingTranscriptionSession contract used by
* DictationStreamManager.
*/
export class SherpaRealtimeTranscriptionSession extends EventEmitter {
/**
* @param {{ engine: SherpaOfflineRecognizerEngine, minDecodeIntervalMs?: number }} params
*/
constructor({ engine, minDecodeIntervalMs }) {
super();
this.engine = engine;
this.requiredSampleRate = engine.sampleRate;
this.minDecodeIntervalMs = minDecodeIntervalMs ?? 350;
this.connected = false;
this.currentSegmentId = null;
this.previousSegmentId = null;
this.lastPartialText = '';
this.pcm16 = Buffer.alloc(0);
this.lastDecodeAt = 0;
this.decoding = false;
this.pendingDecode = false;
}
async connect() {
if (this.connected) {
return;
}
this.currentSegmentId = randomUUID();
this.connected = true;
}
appendPcm16(chunk) {
if (!this.connected || !this.currentSegmentId) {
this.emit('error', new Error('Sherpa realtime session not connected'));
return;
}
this.pcm16 = this.pcm16.length === 0 ? chunk : Buffer.concat([this.pcm16, chunk]);
this.maybeDecode(false).catch((err) => {
this.emit('error', err instanceof Error ? err : new Error(String(err)));
});
}
commit() {
if (!this.connected || !this.currentSegmentId) {
this.emit('error', new Error('Sherpa realtime session not connected'));
return;
}
void (async () => {
try {
await this.maybeDecode(true);
const finalText = this.lastPartialText;
const segmentId = this.currentSegmentId;
const previousSegmentId = this.previousSegmentId;
this.emit('committed', { segmentId, previousSegmentId });
this.emit('transcript', { segmentId, transcript: finalText, isFinal: true });
this.previousSegmentId = segmentId;
this.currentSegmentId = randomUUID();
this.lastPartialText = '';
this.pcm16 = Buffer.alloc(0);
} catch (err) {
this.emit('error', err instanceof Error ? err : new Error(String(err)));
}
})();
}
clear() {
if (!this.connected) {
return;
}
this.pcm16 = Buffer.alloc(0);
this.currentSegmentId = randomUUID();
this.lastPartialText = '';
}
close() {
this.connected = false;
this.currentSegmentId = null;
this.pcm16 = Buffer.alloc(0);
}
async maybeDecode(force) {
if (!this.connected || !this.currentSegmentId) {
return;
}
const now = Date.now();
if (!force && now - this.lastDecodeAt < this.minDecodeIntervalMs) {
return;
}
if (this.decoding) {
this.pendingDecode = true;
return;
}
this.decoding = true;
try {
const decodeStartedAt = Date.now();
const text = this.engine.decodePcm16(this.pcm16);
this.lastDecodeAt = Date.now();
// Adaptive throttle: on slow hardware (or heavy models) re-decoding the
// growing segment every 350ms would monopolize the worker. Space partial
// decodes to ~1.5x the observed decode time.
this.minDecodeIntervalMs = Math.max(350, (this.lastDecodeAt - decodeStartedAt) * 1.5);
if (text !== this.lastPartialText) {
this.lastPartialText = text;
this.emit('transcript', {
segmentId: this.currentSegmentId,
transcript: text,
isFinal: false,
});
}
} finally {
this.decoding = false;
if (this.pendingDecode) {
this.pendingDecode = false;
await this.maybeDecode(true);
}
}
}
}