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)
164 lines
5.5 KiB
JavaScript
164 lines
5.5 KiB
JavaScript
/**
|
|
* Downloads and extracts local sherpa-onnx STT model archives.
|
|
* Archives (.tar.bz2) come from the k2-fsa GitHub releases and are extracted
|
|
* with the system `tar` into the speech-models directory.
|
|
*/
|
|
|
|
import { createWriteStream } from 'fs';
|
|
import { mkdir, rename, rm, stat } from 'fs/promises';
|
|
import path from 'path';
|
|
import { Readable } from 'stream';
|
|
import { pipeline } from 'stream/promises';
|
|
import { spawn } from 'child_process';
|
|
|
|
import { getLocalSttModelSpec } from './model-catalog.js';
|
|
|
|
async function hasRequiredFiles(modelDir, requiredFiles) {
|
|
const results = await Promise.all(
|
|
requiredFiles.map(async (rel) => {
|
|
try {
|
|
const s = await stat(path.join(modelDir, rel));
|
|
if (s.isDirectory()) {
|
|
return true;
|
|
}
|
|
return s.isFile() && s.size > 0;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}),
|
|
);
|
|
return results.every(Boolean);
|
|
}
|
|
|
|
async function downloadToFile(url, outputPath, onProgress) {
|
|
const res = await fetch(url);
|
|
if (!res.ok) {
|
|
throw new Error(`Failed to download ${url}: ${res.status} ${res.statusText}`);
|
|
}
|
|
if (!res.body) {
|
|
throw new Error(`Failed to download ${url}: missing response body`);
|
|
}
|
|
|
|
const totalBytes = Number.parseInt(res.headers.get('content-length') || '', 10) || null;
|
|
let downloadedBytes = 0;
|
|
|
|
const tmpPath = `${outputPath}.tmp-${Date.now()}`;
|
|
await mkdir(path.dirname(outputPath), { recursive: true });
|
|
|
|
const nodeStream = Readable.fromWeb(res.body);
|
|
if (typeof onProgress === 'function') {
|
|
nodeStream.on('data', (chunk) => {
|
|
downloadedBytes += chunk.length;
|
|
onProgress(downloadedBytes, totalBytes);
|
|
});
|
|
}
|
|
|
|
try {
|
|
await pipeline(nodeStream, createWriteStream(tmpPath));
|
|
await rename(tmpPath, outputPath);
|
|
} catch (error) {
|
|
await rm(tmpPath, { force: true }).catch(() => undefined);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function extractTarArchive(archivePath, destDir) {
|
|
await mkdir(destDir, { recursive: true });
|
|
|
|
await new Promise((resolve, reject) => {
|
|
const child = spawn('tar', ['xf', archivePath, '-C', destDir], {
|
|
stdio: 'ignore',
|
|
windowsHide: true,
|
|
});
|
|
child.on('error', reject);
|
|
child.on('exit', (code) => {
|
|
if (code === 0) {
|
|
resolve();
|
|
} else {
|
|
reject(new Error(`tar exited with code ${code}`));
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
async function isNonEmptyFile(filePath) {
|
|
try {
|
|
const s = await stat(filePath);
|
|
return s.isFile() && s.size > 0;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check whether a model is fully installed (all required files present).
|
|
* @param {string} modelsDir
|
|
* @param {string} modelId
|
|
* @returns {Promise<boolean>}
|
|
*/
|
|
export async function isLocalSttModelInstalled(modelsDir, modelId) {
|
|
const spec = getLocalSttModelSpec(modelId);
|
|
return hasRequiredFiles(path.join(modelsDir, spec.extractedDir), spec.requiredFiles);
|
|
}
|
|
|
|
/**
|
|
* Ensure a model is downloaded and extracted. Resolves with the model dir.
|
|
*
|
|
* Extraction is staged: the archive unpacks into a temporary directory and is
|
|
* verified before being renamed into place. An interrupted or failed tar must
|
|
* never leave partial files at the final path — the installed check only
|
|
* verifies file presence, so a truncated .onnx there would be treated as an
|
|
* installed model forever ("Protobuf parsing failed" at load time).
|
|
*
|
|
* @param {{ modelsDir: string, modelId: string,
|
|
* onProgress?: (downloadedBytes: number, totalBytes: number | null) => void }} options
|
|
* @returns {Promise<string>}
|
|
*/
|
|
export async function ensureLocalSttModel({ modelsDir, modelId, onProgress }) {
|
|
const spec = getLocalSttModelSpec(modelId);
|
|
const modelDir = path.join(modelsDir, spec.extractedDir);
|
|
if (await hasRequiredFiles(modelDir, spec.requiredFiles)) {
|
|
return modelDir;
|
|
}
|
|
|
|
// A directory that exists but fails the required-files check is a partial
|
|
// extraction from an earlier interrupted attempt — remove it before retrying.
|
|
await rm(modelDir, { recursive: true, force: true }).catch(() => undefined);
|
|
|
|
const downloadsDir = path.join(modelsDir, '.downloads');
|
|
const archiveFilename = path.basename(new URL(spec.archiveUrl).pathname);
|
|
const archivePath = path.join(downloadsDir, archiveFilename);
|
|
|
|
if (!(await isNonEmptyFile(archivePath))) {
|
|
await downloadToFile(spec.archiveUrl, archivePath, onProgress);
|
|
}
|
|
|
|
const stagingDir = path.join(modelsDir, `.staging-${spec.extractedDir}-${Date.now()}`);
|
|
try {
|
|
await extractTarArchive(archivePath, stagingDir);
|
|
|
|
const stagedModelDir = path.join(stagingDir, spec.extractedDir);
|
|
if (!(await hasRequiredFiles(stagedModelDir, spec.requiredFiles))) {
|
|
// Bad archive (truncated download / corrupt cache): drop it so the next
|
|
// attempt re-downloads instead of re-extracting the same broken bytes.
|
|
await rm(archivePath, { force: true }).catch(() => undefined);
|
|
throw new Error(
|
|
`Extracted ${archiveFilename}, but required model files are missing or empty. The archive was discarded; retry to re-download.`,
|
|
);
|
|
}
|
|
|
|
await rename(stagedModelDir, modelDir);
|
|
} catch (error) {
|
|
await rm(stagingDir, { recursive: true, force: true }).catch(() => undefined);
|
|
// Any extraction failure means the cached archive can't be trusted
|
|
// (corrupt bz2, truncated download). Discard it so retry re-downloads.
|
|
await rm(archivePath, { force: true }).catch(() => undefined);
|
|
throw error;
|
|
}
|
|
await rm(stagingDir, { recursive: true, force: true }).catch(() => undefined);
|
|
|
|
await rm(archivePath, { force: true }).catch(() => undefined);
|
|
|
|
return modelDir;
|
|
}
|