diff --git a/AGENTS.md b/AGENTS.md index 0ac4d57..8c50763 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,7 +17,18 @@ Crucial reminders for future sessions. See LINUX.md for full architecture. - **ONNX postinstall scripts**: `onnxruntime-node`, `sharp`, `protobufjs` need install-scripts approval after fresh install. - **Svelte `$state` naming collision**: `store.svelte.js` exports `state`, which conflicts with the `$state` rune in components. `+page.svelte` imports it as `appState` — keep it that way, or rename the store export. - **CORS** is `*` on loopback + token — don't tighten without handling vite dev server. -- **Engine boundary**: `engine.js` is the kokoro-js isolation layer; don't leak types past it. +- **Engine boundary**: `engine.js` (kokoro-js) and `piper.js` are the only engine + isolation layers; don't leak kokoro-js / onnxruntime / espeak types past them. + `tts.js` is the router the server talks to. +- **Audio helpers**: `audio.js` (`chunkText`, `f32ToPcm16`, `buildWav`) is shared by + both engines — add cross-engine logic there, not in a single engine file. +- **Piper needs `espeak-ng` CLI**: non-English G2P runs `espeak-ng --ipa` (distro + package, like mpv). Not Python, not a build step. If missing, synthesis fails + with `tts.espeak_missing`. Required only for Piper voices, not Kokoro. + Live Piper smoke is manual; sidecar unit tests mock espeak and ONNX. +- **Piper bakes speed into the WAV** (`length_scale = 1/speed`). Play those + files at mpv speed `1` (`playbackSpeed()` in `tts.js`) or Italian will + double-stretch. - **Models come from `sidecar/src/catalog.json`** — do not hardcode Hugging Face ids in the UI. ## Invariants (from LINUX.md §9) diff --git a/LINUX.md b/LINUX.md index 92cd571..a87ab98 100644 --- a/LINUX.md +++ b/LINUX.md @@ -23,38 +23,41 @@ the original's architecture, CLI surface, and privacy guarantees. | macOS Services menu | `sayit-clipboard.sh`, bound as a DE custom shortcut | DE-agnostic equivalent of a system service | | `sayit` CLI | Identical CLI surface (`cli/sayit.js`) | Deliberate parity: same commands, same behavior | -## 2. Why kokoro-js, and why it's the only engine +## 2. Two engines: Kokoro (English) and Piper ONNX (other languages) Hard constraint from the maintainer: **the sidecar must be pure JavaScript — -no Python**. That eliminates the entire Piper/Coqui/Chatterbox/Qwen3-TTS -ecosystem, which is Python-bound. - -Kokoro-82M is the one high-quality open TTS model with a production-grade JS -runtime (`kokoro-js`, via `@huggingface/transformers` + `onnxruntime-node`, -q8-quantized ONNX, CPU-only, ~90 MB download). It is therefore the **sole -engine** in this port. Consequences: - -- **No voice cloning** — Kokoro doesn't support it. The original's Voice - Studio feature is absent by necessity, not by choice. -- **No other models** (Qwen3-TTS, Chatterbox, OmniVoice are MLX or Python). -- The engine is isolated behind `sidecar/src/engine.js` (`synthesize()`, - `engineState()`, `VOICES`) so a second backend (e.g. a future ONNX export - of another model, or an optional native Piper binding) can slot in without - touching the server, player, or UI. **Do not leak kokoro-js types past - this module.** - -Long-text handling: Kokoro can't ingest arbitrary length, so `engine.js` -chunks at sentence boundaries (~400 chars), synthesizes chunk-by-chunk -(progress events over SSE), and **concatenates raw PCM16 and rewrites the WAV -header in pure JS** — no ffmpeg dependency. Sample rate is taken from the -model output, not hardcoded (except as fallback). +no Python**. That still rules out Coqui/Chatterbox/Qwen3-TTS and the official +Python Piper stack. It does **not** rule out Piper's **ONNX weights**: those +are plain VITS graphs we run on the same `onnxruntime-node` as Kokoro. + +- **Kokoro-82M** (`kokoro-js`) is the English engine: q8/q4 ONNX, CPU-only, + ~90 MB. Isolated in `sidecar/src/engine.js`. **Do not leak kokoro-js types + past this module.** +- **Piper ONNX** is the multilingual engine (Italian first: Paola / Riccardo). + Isolated in `sidecar/src/piper.js`. G2P is the system `espeak-ng` CLI + (`--ipa`), same class of distro dependency as mpv — not a build step, not + Python. **Do not leak onnxruntime / espeak types past this module.** +- `sidecar/src/tts.js` is the router the server talks to. Adding a language + later is a catalog row + an `espeak-ng` voice name. + +Consequences: + +- **No voice cloning** — neither engine supports it. +- **No GPU / MIGraphX** — Kokoro is 82M; a native AMD runtime buys nothing + the user can hear. Revisit only if a heavy TTS lands. +- **No Python Piper** — we download `.onnx` + `.onnx.json` from + `rhasspy/piper-voices` and feed phoneme ids ourselves. + +Long-text handling lives in `sidecar/src/audio.js` (shared): chunk at +sentence boundaries (~400 chars), concatenate PCM16, rewrite the WAV header +in pure JS. Piper reads `audio.sample_rate` from the model JSON (typically +16–22 kHz); Kokoro is 24 kHz. Model lifecycle: a **catalog** (`sidecar/src/catalog.json`) lists engines we -can actually run (today: Kokoro q8 and q4). Install is explicit -(`POST /v1/models/:id/install`); first speak does **not** download. One -model stays in memory and unloads after N idle minutes -(`unloadAfterMinutes`). The Settings list is a marketplace; Speak shows -onboarding when zero models are installed. +can actually run. Install is explicit (`POST /v1/models/:id/install`); first +speak does **not** download. One model stays in memory and unloads after N +idle minutes (`unloadAfterMinutes`). The Settings list is a marketplace; +Speak shows onboarding when zero models are installed. ## 3. Service architecture: HTTP + token, not direct embedding @@ -157,13 +160,14 @@ disk. Anything that needs the API should resolve the token the same way. ## 8. Known limitations (vs the original) -1. Kokoro family only (q8 / q4 ONNX); no voice cloning, no MLX/Python families (§2). Marketplace UI is ready for more catalog rows later. +1. Two ONNX families only (Kokoro q8/q4 for English; Piper CPU for other + languages). No voice cloning, no MLX/Python families, no GPU (§2). 2. No selection capture, clipboard only (§5). 3. Global hotkey X11-only inside the app; Wayland needs the DE-bound script (§5). -4. English voices only. kokoro-js ships Italian/ES/FR/PT voice *bins* but - its phonemizer WASM is English-only, so those ids fail at generate. - The `VOICES` table in `engine.js` lists what actually works. +4. Kokoro remains English-only (its phonemizer WASM is `en*`). Italian is + Piper + system `espeak-ng`. Live Piper smoke is manual; unit tests mock + espeak and ONNX. 5. Single in-flight job; no queue (§3). 6. Linux only — nothing here is tested on macOS/Windows, though the sidecar and CLI are platform-agnostic in principle (mpv/aplay are the @@ -174,7 +178,8 @@ disk. Anything that needs the API should resolve the token the same way. - **No Python, no new native build steps** in the sidecar. If a feature needs one, it doesn't belong in the sidecar. - **Loopback + token** on the HTTP API, always. -- **engine.js is a boundary** — engine-agnostic interface out, kokoro-js in. +- **engine.js and piper.js are boundaries** — `tts.js` routes; do not leak + kokoro-js / onnxruntime / espeak types past those modules. - **Models come from the catalog** — do not hardcode Hugging Face ids in the UI. - **Offline after first model download**; no analytics, no telemetry, no passive clipboard monitoring (the original's privacy posture is part of diff --git a/README.md b/README.md index fb1a421..bf30bf8 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ swapping every Apple-specific layer for portable equivalents: | macOS original | This port | | ------------------------- | ------------------------------------------- | | SwiftUI menu-bar app | Tauri v2 + SvelteKit 2 / Svelte 5 tray app | -| MLX Audio (Apple silicon) | **kokoro-js** — Kokoro-82M on onnxruntime-node, pure JavaScript | +| MLX Audio (Apple silicon) | **kokoro-js** (English) + **Piper ONNX** (other languages), both on onnxruntime-node | | XPC | Token-protected REST API on 127.0.0.1:7878 + SSE | | Accessibility selection | Clipboard hotkey (see Wayland notes below) | | macOS Services | `sayit-clipboard`, bindable in any DE | @@ -41,15 +41,16 @@ Porting notes for macOS/Windows contributors are welcome — see ``` ┌──────────────┐ REST + SSE, Bearer token ┌──────────────────┐ │ Tauri v2 app │ ◄──────────────────────────► │ sidecar (Node) │ -│ SvelteKit UI │ │ kokoro-js engine │ +│ SvelteKit UI │ │ kokoro + piper │ │ sayit CLI │ ◄──────────────────────────► │ mpv playback │ │ sayit-clipboard │ history, models │ └──────────────┘ └──────────────────┘ ``` -- **sidecar/** — per-user service: synthesis (Kokoro ONNX via kokoro-js), - playback via mpv's JSON IPC (pause / seek / speed / volume), history, model catalog, - settings. One model in memory, unloaded after 10 idle minutes (configurable). +- **sidecar/** — per-user service: synthesis (Kokoro for English; Piper ONNX + for other languages, Italian first), playback via mpv's JSON IPC + (pause / seek / speed / volume), history, model catalog, settings. One + model in memory, unloaded after 10 idle minutes (configurable). - **app/** — SvelteKit 2 + Svelte 5 UI: speak box, transport, history, voices, Settings marketplace for models, onboarding when none are installed. - **cli/sayit.js** — `sayit "text"`, `printf … | sayit`, `sayit status`, @@ -61,6 +62,8 @@ Porting notes for macOS/Windows contributors are welcome — see Requirements: Node ≥ 20, npm, and **mpv** for playback (falls back to `aplay`). Clipboard tools (`wl-paste` / `xclip` / `xsel`) only if you want the hotkey. +**espeak-ng** is optional and only required for Piper (non-English) voices +(`sudo apt install espeak-ng` or your distro equivalent). ```sh curl -fsSL https://raw.githubusercontent.com/ildella/sayit/master/scripts/install.sh | bash -s -- --systemd diff --git a/app/src/routes/+page.svelte b/app/src/routes/+page.svelte index afafa0a..0b62409 100644 --- a/app/src/routes/+page.svelte +++ b/app/src/routes/+page.svelte @@ -14,7 +14,8 @@ // onMount: a second initStore() used to return before voices existed, so // the - {#each appState.voices as v (v.id)} - + {#each voiceGroups as [lang, vs] (lang)} + + {#each vs as v (v.id)} + + {/each} + {/each} @@ -276,7 +293,7 @@
{m.displayName} {#if m.stability === 'recommended'}Recommended{/if} -
{formatBytes(m.estimatedDiskBytes)} · {m.license} · {m.family}
+
{formatBytes(m.estimatedDiskBytes)} · {m.license} · {m.family} · {(m.languages || []).join(', ')}
{#if m.error}
{m.error}
{/if} {#if m.state === 'downloading' || m.state === 'canceling'}
{m.state === 'canceling' ? 'Canceling…' : 'Downloading…'}
@@ -310,8 +327,12 @@ value={appState.settings.voice} onchange={(e) => api.saveSettings({ voice: e.target.value }).then((s) => (appState.settings = s))} > - {#each appState.voices as v (v.id)} - + {#each voiceGroups as [lang, vs] (lang)} + + {#each vs as v (v.id)} + + {/each} + {/each} diff --git a/sidecar/src/audio.js b/sidecar/src/audio.js new file mode 100644 index 0000000..ccd19f8 --- /dev/null +++ b/sidecar/src/audio.js @@ -0,0 +1,51 @@ +// Audio helpers shared by every synthesis engine (kokoro, piper, …). + +export const MAX_CHUNK_CHARS = 400; + +/** Split text into speakable chunks at sentence boundaries. */ +export function chunkText(text) { + const clean = text.replace(/\s+/g, ' ').trim(); + if (!clean) return []; + const sentences = clean.match(/[^.!?;:]+[.!?;:]*\s*/g) || [clean]; + const chunks = []; + let buf = ''; + for (const s of sentences) { + if (buf.length + s.length > MAX_CHUNK_CHARS && buf) { + chunks.push(buf.trim()); + buf = s; + } else { + buf += s; + } + } + if (buf.trim()) chunks.push(buf.trim()); + return chunks; +} + +/** Float32 samples (-1..1) to 16-bit LE PCM mono. */ +export function f32ToPcm16(f32) { + const pcm = Buffer.alloc(f32.length * 2); + for (let j = 0; j < f32.length; j++) { + const s = Math.max(-1, Math.min(1, f32[j])); + pcm.writeInt16LE(Math.round(s * 32767), j * 2); + } + return pcm; +} + +/** Wrap 16-bit PCM mono in a RIFF/WAVE header. */ +export function buildWav(pcm, sampleRate) { + const header = Buffer.alloc(44); + header.write('RIFF', 0); + header.writeUInt32LE(36 + pcm.length, 4); + header.write('WAVE', 8); + header.write('fmt ', 12); + header.writeUInt32LE(16, 16); + header.writeUInt16LE(1, 20); // PCM + header.writeUInt16LE(1, 22); // mono + header.writeUInt32LE(sampleRate, 24); + header.writeUInt32LE(sampleRate * 2, 28); // byte rate + header.writeUInt16LE(2, 32); // block align + header.writeUInt16LE(16, 34); // bits + header.write('data', 36); + header.writeUInt32LE(pcm.length, 40); + return Buffer.concat([header, pcm]); +} diff --git a/sidecar/src/catalog.js b/sidecar/src/catalog.js index 3416696..adc443b 100644 --- a/sidecar/src/catalog.js +++ b/sidecar/src/catalog.js @@ -16,6 +16,10 @@ export function loadCatalog(source) { for (const key of REQUIRED) { if (!model[key]) throw new Error(`Catalog model missing ${key}`); } + // Piper-style single-voice rows must say where the ONNX lives in the repo. + if (model.engine === 'piper-onnx' && !model.voicePath) { + throw new Error(`Catalog model ${model.id} missing voicePath`); + } if (ids.has(model.id)) throw new Error(`Duplicate catalog id ${model.id}`); ids.add(model.id); } diff --git a/sidecar/src/catalog.json b/sidecar/src/catalog.json index d62a14c..fb98cbc 100644 --- a/sidecar/src/catalog.json +++ b/sidecar/src/catalog.json @@ -26,6 +26,38 @@ "estimatedDiskBytes": 50000000, "license": "Apache-2.0", "stability": "available" + }, + { + "id": "piper-it-paola", + "displayName": "Piper Italiano (Paola)", + "family": "piper", + "engine": "piper-onnx", + "repository": "rhasspy/piper-voices", + "revision": "v1.0.0", + "voicePath": "it/it_IT/paola/medium/it_IT-paola-medium.onnx", + "dtype": "onnx", + "languages": ["it"], + "gender": "female", + "defaultVoice": "piper_it_paola", + "estimatedDiskBytes": 65000000, + "license": "MIT", + "stability": "recommended" + }, + { + "id": "piper-it-riccardo", + "displayName": "Piper Italiano (Riccardo)", + "family": "piper", + "engine": "piper-onnx", + "repository": "rhasspy/piper-voices", + "revision": "v1.0.0", + "voicePath": "it/it_IT/riccardo/x_low/it_IT-riccardo-x_low.onnx", + "dtype": "onnx", + "languages": ["it"], + "gender": "male", + "defaultVoice": "piper_it_riccardo", + "estimatedDiskBytes": 25000000, + "license": "MIT", + "stability": "available" } ] } diff --git a/sidecar/src/engine.js b/sidecar/src/engine.js index 22c893c..30fba58 100644 --- a/sidecar/src/engine.js +++ b/sidecar/src/engine.js @@ -2,6 +2,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { MODELS_DIR, AUDIO_DIR, getSettings } from './config.js'; import { getModel } from './catalog.js'; +import { chunkText, f32ToPcm16, buildWav } from './audio.js'; /** * Synthesis engine. Wraps kokoro-js (Kokoro-82M running on onnxruntime-node, @@ -26,7 +27,6 @@ export const VOICES = { }; const SAMPLE_RATE = 24000; -const MAX_CHUNK_CHARS = 400; let tts = null; let loadingPromise = null; @@ -114,42 +114,7 @@ function touch() { } /** Split text into speakable chunks at sentence boundaries. */ -export function chunkText(text) { - const clean = text.replace(/\s+/g, ' ').trim(); - if (!clean) return []; - const sentences = clean.match(/[^.!?;:]+[.!?;:]*\s*/g) || [clean]; - const chunks = []; - let buf = ''; - for (const s of sentences) { - if (buf.length + s.length > MAX_CHUNK_CHARS && buf) { - chunks.push(buf.trim()); - buf = s; - } else { - buf += s; - } - } - if (buf.trim()) chunks.push(buf.trim()); - return chunks; -} - -/** Write a 16-bit PCM mono WAV. */ -function buildWav(pcm, sampleRate) { - const header = Buffer.alloc(44); - header.write('RIFF', 0); - header.writeUInt32LE(36 + pcm.length, 4); - header.write('WAVE', 8); - header.write('fmt ', 12); - header.writeUInt32LE(16, 16); - header.writeUInt16LE(1, 20); // PCM - header.writeUInt16LE(1, 22); // mono - header.writeUInt32LE(sampleRate, 24); - header.writeUInt32LE(sampleRate * 2, 28); // byte rate - header.writeUInt16LE(2, 32); // block align - header.writeUInt16LE(16, 34); // bits - header.write('data', 36); - header.writeUInt32LE(pcm.length, 40); - return Buffer.concat([header, pcm]); -} +export { chunkText } from './audio.js'; /** * Synthesize text to a single WAV file. Long text is chunked, synthesized @@ -170,12 +135,7 @@ export async function synthesize(text, { voice, speed = 1.0, onProgress, signal const audio = await model.generate(chunks[i], { voice: voice || getSettings().voice }); if (audio.sampling_rate) samplingRate = audio.sampling_rate; // RawAudio: { audio: Float32Array, sampling_rate }. Convert to PCM16. - const f32 = audio.audio; - const pcm = Buffer.alloc(f32.length * 2); - for (let j = 0; j < f32.length; j++) { - const s = Math.max(-1, Math.min(1, f32[j])); - pcm.writeInt16LE(Math.round(s * 32767), j * 2); - } + const pcm = f32ToPcm16(audio.audio); pcmParts.push(pcm); onProgress?.({ stage: 'synthesizing', chunk: i + 1, totalChunks: chunks.length, text: chunks[i] }); } diff --git a/sidecar/src/models.js b/sidecar/src/models.js index 7e8f38e..c31650b 100644 --- a/sidecar/src/models.js +++ b/sidecar/src/models.js @@ -1,6 +1,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { loadCatalog, getModel } from './catalog.js'; +import { piperDir, piperFiles } from './piper.js'; const BUSY = new Set(['queued', 'downloading', 'verifying', 'canceling']); @@ -9,6 +10,8 @@ export function createModelStore({ catalog = loadCatalog(), getActiveId, setActiveId, + getVoice, + setVoice, installFiles, onChange: onChangeArg, unloadEngine, @@ -37,6 +40,10 @@ export function createModelStore({ function filesLookInstalled(model) { if (fs.existsSync(markerPath(model.id))) return true; + // Piper rows: two explicit files in models/piper//. + if (model.voicePath) { + return piperFiles(model, modelsDir).every((p) => fs.existsSync(p)); + } const files = walkFiles(repoCacheDir(model.repository)); const onnx = files.filter((f) => f.endsWith('.onnx')); if (onnx.length === 0) return false; @@ -164,6 +171,12 @@ export function createModelStore({ } const prev = getActiveId(); setActiveId(id); + if (setVoice && model.defaultVoice) { + const current = getVoice?.(); + const owner = catalog.find((m) => m.defaultVoice === current); + const family = owner?.family ?? 'kokoro'; + if (family !== model.family) setVoice(model.defaultVoice); + } if (prev !== id) unloadEngine?.(); emit(); return snapshot(); @@ -178,6 +191,13 @@ export function createModelStore({ } const marker = markerPath(id); if (fs.existsSync(marker)) fs.unlinkSync(marker); + if (model.voicePath) { + // Piper rows own a dedicated directory — no shared-cache refcounting. + fs.rmSync(piperDir(model, modelsDir), { recursive: true, force: true }); + runtime.delete(id); + emit(); + return snapshot(); + } // Only delete the shared HF cache if no other installed SKU of this repo remains. const stillNeeded = catalog.some( (m) => m.id !== id && m.repository === model.repository && filesLookInstalled(m), diff --git a/sidecar/src/phonemes.js b/sidecar/src/phonemes.js new file mode 100644 index 0000000..f57e8f9 --- /dev/null +++ b/sidecar/src/phonemes.js @@ -0,0 +1,84 @@ +import { execFile } from 'node:child_process'; + +/** + * Grapheme-to-phoneme for non-English engines. Piper models consume espeak-ng + * IPA; kokoro-js's bundled phonemizer is English-only, so Piper voices get + * their phonemes from the system espeak-ng binary (a distro package, same + * class of runtime dependency as mpv — not Python, not a build step). + */ + +// Catalog language code -> espeak-ng voice name. +const ESPEAK_VOICES = { + it: 'it', + es: 'es', + 'pt-br': 'pt-br', + fr: 'fr-fr', +}; + +export function espeakVoiceFor(lang) { + return ESPEAK_VOICES[lang] || lang; +} + +function runEspeak(args, { timeoutMs = 30_000 } = {}) { + return new Promise((resolve, reject) => { + execFile('espeak-ng', args, { timeout: timeoutMs }, (err, stdout, stderr) => { + if (err) { + if (err.code === 'ENOENT') { + err.code = 'tts.espeak_missing'; + err.message = 'espeak-ng is not installed (needed for Piper voices). Install it with your package manager (e.g. sudo apt install espeak-ng).'; + } else { + err.code = 'tts.espeak_failed'; + } + reject(err); + } else { + resolve(stdout); + } + }); + }); +} + +export async function hasEspeak(run = runEspeak) { + try { + await run(['--version']); + return true; + } catch { + return false; + } +} + +const ESPEAK_MISSING = 'espeak-ng is not installed (needed for Piper voices). Install it with your package manager (e.g. sudo apt install espeak-ng).'; + +/** Text -> IPA string via espeak-ng. `run` is injectable for tests. */ +export async function espeakPhonemize(text, lang, run = runEspeak) { + try { + const out = await run(['--ipa', '-q', '-v', espeakVoiceFor(lang), text]); + return out.replace(/\s+/g, ' ').trim(); + } catch (err) { + if (err.code === 'ENOENT' || err.code === 'tts.espeak_missing') { + err.code = 'tts.espeak_missing'; + err.message = ESPEAK_MISSING; + } else { + err.code = err.code || 'tts.espeak_failed'; + } + throw err; + } +} + +/** + * IPA -> Piper phoneme ids, following piper's convention: BOS '^', then each + * symbol's ids followed by a PAD '0', closing with EOS '$'. Symbols missing + * from the model's phoneme_id_map (espeak version drift, tie bars) are + * dropped rather than fatal — a skipped symbol degrades one phone, an + * exception kills the whole utterance. + */ +export function toIds(ipa, idMap) { + const ids = [...(idMap['^'] ?? [])]; + const pad = idMap['0'] ?? [0]; + for (const sym of ipa) { + const mapped = idMap[sym]; + if (!mapped) continue; + ids.push(...mapped, ...pad); + } + ids.push(...(idMap['$'] ?? [])); + return ids; +} diff --git a/sidecar/src/piper.js b/sidecar/src/piper.js new file mode 100644 index 0000000..803cbde --- /dev/null +++ b/sidecar/src/piper.js @@ -0,0 +1,182 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { MODELS_DIR, AUDIO_DIR, getSettings } from './config.js'; +import { loadCatalog } from './catalog.js'; +import { chunkText, f32ToPcm16, buildWav } from './audio.js'; +import { espeakPhonemize, toIds } from './phonemes.js'; + +/** + * Piper TTS engine (VITS models from rhasspy/piper-voices, plain ONNX run on + * onnxruntime-node — CPU only). Kept behind the same engine boundary as + * kokoro-js: nothing here leaks past synthesize()/voice metadata. + */ + +const DEFAULT_SCALES = { noise: 0.667, noiseW: 0.8 }; + +export function piperDir(model, modelsDir = MODELS_DIR) { + return path.join(modelsDir, 'piper', model.id); +} + +/** [onnxPath, configJsonPath] for a piper catalog model. */ +export function piperFiles(model, modelsDir = MODELS_DIR) { + const base = path.basename(model.voicePath); + const dir = piperDir(model, modelsDir); + return [path.join(dir, base), path.join(dir, `${base}.json`)]; +} + +// --- Install ---------------------------------------------------------------- + +async function downloadTo(url, dest, { signal, onProgress }) { + const res = await fetch(url, { signal }); + if (!res.ok) throw new Error(`Download failed (${res.status}): ${url}`); + const total = Number(res.headers.get('content-length')) || null; + const out = fs.createWriteStream(dest); + let received = 0; + const reader = res.body.getReader(); + for (;;) { + if (signal?.aborted) { out.destroy(); throw new Error('Install canceled'); } + const { done, value } = await reader.read(); + if (done) break; + received += value.length; + if (!out.write(Buffer.from(value))) { + await new Promise((resolve) => out.once('drain', resolve)); + } + if (total) onProgress?.({ status: 'progress', loaded: received, total, file: path.basename(dest) }); + } + out.end(); + await new Promise((resolve, reject) => { out.on('finish', resolve); out.on('error', reject); }); + onProgress?.({ status: 'done', file: path.basename(dest) }); +} + +/** HF resolve URLs for the model's onnx + json pair. */ +export function piperUrls(model) { + const revision = model.revision || 'main'; + return ['', '.json'].map((suffix) => + `https://huggingface.co/${model.repository}/resolve/${revision}/${model.voicePath}${suffix}?download=true`); +} + +export async function installPiperFiles({ model, modelsDir, signal, onProgress }) { + const dir = piperDir(model, modelsDir); + fs.mkdirSync(dir, { recursive: true }); + for (const [suffix, url] of ['', '.json'].map((suffix, i) => [suffix, piperUrls(model)[i]])) { + await downloadTo(url, path.join(dir, path.basename(model.voicePath + suffix)), { signal, onProgress }); + } +} + +// --- Inference -------------------------------------------------------------- + +const voices = new Map(); // model.id -> { session, config, timer, lastUsed } + +export function unloadPiper() { + for (const entry of voices.values()) clearTimeout(entry.timer); + voices.clear(); +} + +export function piperState() { + let lastUsed = 0; + for (const entry of voices.values()) { + if (entry.lastUsed > lastUsed) lastUsed = entry.lastUsed; + } + return { loaded: voices.size > 0, lastUsed: lastUsed || null }; +} + +function touch(entry, unloadAfterMinutes) { + entry.lastUsed = Date.now(); + clearTimeout(entry.timer); + if (unloadAfterMinutes > 0) { + entry.timer = setTimeout(() => voices.delete(entry.id), unloadAfterMinutes * 60_000); + entry.timer.unref(); + } +} + +async function loadVoice(model) { + const cached = voices.get(model.id); + if (cached) return cached; + const [onnxPath, jsonPath] = piperFiles(model); + for (const p of [onnxPath, jsonPath]) { + if (!fs.existsSync(p)) { + const err = new Error('Model is not installed'); + err.code = 'model.not_installed'; + throw err; + } + } + const ort = await import('onnxruntime-node'); + const config = JSON.parse(fs.readFileSync(jsonPath, 'utf8')); + const session = await ort.InferenceSession.create(onnxPath); + const names = new Set(session.inputNames ?? []); + for (const need of ['input', 'input_lengths', 'scales']) { + if (names.size && !names.has(need)) { + const err = new Error(`Piper model is missing ONNX input "${need}"`); + err.code = 'tts.piper_inputs'; + throw err; + } + } + const entry = { id: model.id, session, config, timer: null, lastUsed: 0 }; + voices.set(model.id, entry); + return entry; +} + +/** + * Synthesize text to a single WAV file (same contract as engine.synthesize). + * `load` and `phonemize` are injectable for tests. + */ +export async function synthesize(text, { + model, + speed = 1.0, + onProgress, + signal, + audioDir = AUDIO_DIR, + load = loadVoice, + phonemize = espeakPhonemize, + unloadAfterMinutes = getSettings().unloadAfterMinutes, +} = {}) { + const ort = await import('onnxruntime-node'); + const { session, config } = await load(model); + const idMap = config.phoneme_id_map; + const sampleRate = config.audio?.sample_rate ?? 22050; + const lang = model.languages[0]; + const chunks = chunkText(text); + if (chunks.length === 0) throw new Error('Nothing to speak'); + + const pcmParts = []; + for (let i = 0; i < chunks.length; i++) { + if (signal?.aborted) throw new Error('Synthesis aborted'); + const ipa = await phonemize(chunks[i], lang); + const ids = toIds(ipa, idMap); + const feed = { + input: new ort.Tensor('int64', BigInt64Array.from(ids.map(BigInt)), [1, ids.length]), + input_lengths: new ort.Tensor('int64', BigInt64Array.from([BigInt(ids.length)]), [1]), + // Piper scales: noise, length (inverse of speed), noise_w. + scales: new ort.Tensor('float32', + Float32Array.from([DEFAULT_SCALES.noise, 1 / (speed || 1), DEFAULT_SCALES.noiseW]), [3]), + }; + const results = await session.run(feed); + const audio = results.audio ?? results.output ?? Object.values(results)[0]; + pcmParts.push(f32ToPcm16(audio.data)); + onProgress?.({ stage: 'synthesizing', chunk: i + 1, totalChunks: chunks.length, text: chunks[i] }); + } + touch(voices.get(model.id) ?? { id: model.id }, unloadAfterMinutes); + + const pcm = Buffer.concat(pcmParts); + fs.mkdirSync(audioDir, { recursive: true }); + const file = path.join(audioDir, `sayit-${Date.now()}.wav`); + fs.writeFileSync(file, buildWav(pcm, sampleRate)); + return { file, chunks, durationSec: pcm.length / 2 / sampleRate, speed }; +} + +// --- Voice registry --------------------------------------------------------- + +/** Voice metadata for every piper catalog row, keyed by voice id. */ +export function piperVoices(catalog = loadCatalog()) { + const out = {}; + for (const m of catalog) { + if (m.engine !== 'piper-onnx') continue; + out[m.defaultVoice] = { + name: (m.displayName || m.id).replace(/^Piper\s+/, ''), + lang: m.languages[0], + gender: m.gender || 'unknown', + family: 'piper', + }; + } + return out; +} diff --git a/sidecar/src/server.js b/sidecar/src/server.js index dd31cdf..74a467c 100644 --- a/sidecar/src/server.js +++ b/sidecar/src/server.js @@ -1,9 +1,10 @@ import http from 'node:http'; import fs from 'node:fs'; -import { synthesize, engineState, VOICES } from './engine.js'; +import { synthesize, engineState, resolveVoice, voiceRegistry, playbackSpeed } from './tts.js'; import { player } from './player.js'; import { addHistory, listHistory, getHistory, deleteHistory } from './history.js'; import { getToken, getSettings, saveSettings } from './config.js'; +import { getModel } from './catalog.js'; import { modelStore } from './store.js'; /** @@ -58,13 +59,14 @@ async function speak(text, { voice, speed } = {}) { }); if (abort.signal.aborted) return { aborted: true }; - const effectiveSpeed = speed || getSettings().speed; - await player.play(result.file, { speed: effectiveSpeed, volume: getSettings().volume }); + const requestedSpeed = speed || getSettings().speed; + const playSpeed = playbackSpeed(getModel(getSettings().model).engine, requestedSpeed); + await player.play(result.file, { speed: playSpeed, volume: getSettings().volume }); job.phase = 'playing'; broadcast('job', { phase: job.phase, text, file: result.file }); const entry = addHistory({ - text, voice: voice || getSettings().voice, speed: effectiveSpeed, + text, voice: voice || getSettings().voice, speed: playSpeed, file: result.file, durationSec: result.durationSec, }); broadcast('history', entry); @@ -121,6 +123,15 @@ export function createServer() { const { text, voice, speed } = await readBody(req); if (!text || !text.trim()) return json(res, 400, { error: 'text is required' }); try { + const active = getModel(getSettings().model); + const v = resolveVoice(voice, active); + if (!voice && v.id !== getSettings().voice) saveSettings({ voice: v.id }); + if (v.family !== active.family) { + return json(res, 409, { + error: `Voice "${v.id}" belongs to the ${v.family} engine but the active model is ${active.family}. Select the matching model first.`, + code: 'voice.model_mismatch', + }); + } if (!modelStore.isInstalled(getSettings().model)) { return json(res, 409, { error: 'Model is not installed', code: 'model.not_installed' }); } @@ -167,7 +178,7 @@ export function createServer() { } case 'GET /v1/voices': - return json(res, 200, Object.entries(VOICES).map(([id, v]) => ({ id, ...v }))); + return json(res, 200, Object.entries(voiceRegistry()).map(([id, v]) => ({ id, ...v }))); case 'GET /v1/models': return json(res, 200, modelStore.listModels()); diff --git a/sidecar/src/store.js b/sidecar/src/store.js index a29aa7c..340a8c9 100644 --- a/sidecar/src/store.js +++ b/sidecar/src/store.js @@ -1,13 +1,17 @@ import { MODELS_DIR, getSettings, saveSettings } from './config.js'; import { createModelStore } from './models.js'; import { bindModelStore, installKokoroFiles, unloadEngine } from './engine.js'; +import { installPiperFiles, unloadPiper } from './piper.js'; export const modelStore = createModelStore({ modelsDir: MODELS_DIR, getActiveId: () => getSettings().model, setActiveId: (id) => saveSettings({ model: id }), - installFiles: installKokoroFiles, - unloadEngine, + getVoice: () => getSettings().voice, + setVoice: (voice) => saveSettings({ voice }), + installFiles: (args) => + args.model.engine === 'piper-onnx' ? installPiperFiles(args) : installKokoroFiles(args), + unloadEngine: () => { unloadEngine(); unloadPiper(); }, }); bindModelStore(modelStore); diff --git a/sidecar/src/tts.js b/sidecar/src/tts.js new file mode 100644 index 0000000..d1de5b7 --- /dev/null +++ b/sidecar/src/tts.js @@ -0,0 +1,57 @@ +import { getSettings } from './config.js'; +import { getModel } from './catalog.js'; +import { synthesize as kokoroSynthesize, engineState as kokoroState, VOICES } from './engine.js'; +import { synthesize as piperSynthesize, piperVoices, piperState } from './piper.js'; + +/** + * Engine router: picks the synthesis backend for the active catalog model and + * exposes the merged voice registry. server.js talks only to this module — + * kokoro-js and piper stay swappable behind it. + */ + +export function engineState() { + const settings = getSettings(); + const model = getModel(settings.model); + if (model.engine === 'piper-onnx') { + const p = piperState(); + return { loaded: p.loaded, loading: false, model: settings.model, lastUsed: p.lastUsed }; + } + return kokoroState(); +} + +/** Piper bakes speed into the WAV (VITS length_scale). Do not stretch again in mpv. */ +export function playbackSpeed(engine, requested) { + if (engine === 'piper-onnx') return 1; + return requested; +} + +export function voiceRegistry(catalog) { + return { + ...Object.fromEntries(Object.entries(VOICES).map(([id, v]) => [id, { ...v, family: 'kokoro' }])), + ...piperVoices(catalog), + }; +} + +const REGISTRY = voiceRegistry(); + +export function resolveVoice(voice, model = getModel(getSettings().model)) { + const id = voice || getSettings().voice; + const meta = REGISTRY[id]; + if (meta) return { id, ...meta }; + // Stale ids (e.g. kokoro Italian bins like im_nicola) fall back to the + // active model's default instead of blocking speak. + const fallback = model?.defaultVoice; + const fb = fallback && REGISTRY[fallback]; + if (fb) return { id: fallback, ...fb }; + const err = new Error(`Unknown voice: ${id}`); + err.code = 'voice.unknown'; + throw err; +} + +export async function synthesize(text, opts = {}) { + const model = getModel(getSettings().model); + if (model.engine === 'piper-onnx') { + return piperSynthesize(text, { ...opts, model }); + } + return kokoroSynthesize(text, opts); +} diff --git a/sidecar/test/catalog.test.js b/sidecar/test/catalog.test.js index bf714db..e12a95b 100644 --- a/sidecar/test/catalog.test.js +++ b/sidecar/test/catalog.test.js @@ -4,7 +4,7 @@ import { loadCatalog, getModel } from '../src/catalog.js'; test('catalog lists kokoro q8 as recommended and q4 as available', () => { const models = loadCatalog(); - assert.equal(models.length, 2); + assert.equal(models.length, 4); const q8 = getModel('kokoro-q8', models); const q4 = getModel('kokoro-q4', models); assert.equal(q8.stability, 'recommended'); @@ -13,6 +13,30 @@ test('catalog lists kokoro q8 as recommended and q4 as available', () => { assert.equal(q4.engine, 'kokoro-js'); }); +test('piper rows carry voicePath, revision and italian language', () => { + const models = loadCatalog(); + const paola = getModel('piper-it-paola', models); + const riccardo = getModel('piper-it-riccardo', models); + assert.equal(paola.engine, 'piper-onnx'); + assert.equal(paola.voicePath, 'it/it_IT/paola/medium/it_IT-paola-medium.onnx'); + assert.equal(paola.revision, 'v1.0.0'); + assert.deepEqual(paola.languages, ['it']); + assert.equal(paola.gender, 'female'); + assert.equal(riccardo.stability, 'available'); + assert.equal(riccardo.gender, 'male'); +}); + +test('piper row without voicePath is rejected', () => { + const bad = { + schemaVersion: 1, + models: [{ + id: 'piper-broken', displayName: 'x', family: 'piper', engine: 'piper-onnx', + repository: 'rhasspy/piper-voices', dtype: 'onnx', + }], + }; + assert.throws(() => loadCatalog(bad), /missing voicePath/); +}); + test('unknown catalog id fails', () => { assert.throws(() => getModel('nope', loadCatalog()), /Unknown model: nope/); }); diff --git a/sidecar/test/models.test.js b/sidecar/test/models.test.js index a674bfe..acef9e2 100644 --- a/sidecar/test/models.test.js +++ b/sidecar/test/models.test.js @@ -10,13 +10,16 @@ function tmpDir() { return fs.mkdtempSync(path.join(os.tmpdir(), 'sayit-models-')); } -function store(dir, { active = 'kokoro-q8', installFiles, unload } = {}) { +function store(dir, { active = 'kokoro-q8', voice = 'af_heart', installFiles, unload } = {}) { let activeId = active; - return createModelStore({ + let activeVoice = voice; + const s = createModelStore({ modelsDir: dir, catalog: loadCatalog(), getActiveId: () => activeId, setActiveId: (id) => { activeId = id; }, + getVoice: () => activeVoice, + setVoice: (v) => { activeVoice = v; }, installFiles: installFiles ?? (async ({ model }) => { const p = path.join(dir, '.installed'); fs.mkdirSync(p, { recursive: true }); @@ -24,11 +27,13 @@ function store(dir, { active = 'kokoro-q8', installFiles, unload } = {}) { }), unloadEngine: unload, }); + s.activeVoice = () => activeVoice; + return s; } -test('list starts with both SKUs not installed', () => { +test('list starts with every SKU not installed', () => { const listed = store(tmpDir()).listModels(); - assert.equal(listed.length, 2); + assert.equal(listed.length, 4); assert.ok(listed.every((m) => m.state === 'notInstalled')); assert.ok(listed.every((m) => m.active === false)); }); @@ -100,3 +105,73 @@ test('legacy q8 onnx cache counts as installed', () => { assert.equal(listed.find((m) => m.id === 'kokoro-q8').state, 'installed'); assert.equal(listed.find((m) => m.id === 'kokoro-q4').state, 'notInstalled'); }); + +test('piper install writes onnx+json and counts as installed', async () => { + const dir = tmpDir(); + const s = store(dir, { + installFiles: async ({ model }) => { + const piperDir = path.join(dir, 'piper', model.id); + fs.mkdirSync(piperDir, { recursive: true }); + const base = path.basename(model.voicePath); + fs.writeFileSync(path.join(piperDir, base), 'onnx'); + fs.writeFileSync(path.join(piperDir, `${base}.json`), '{}'); + }, + }); + await s.install('piper-it-paola'); + const paola = s.listModels().find((m) => m.id === 'piper-it-paola'); + assert.equal(paola.state, 'installed'); + s.select('piper-it-paola'); + assert.equal(s.listModels().find((m) => m.id === 'piper-it-paola').active, true); +}); + +test('select piper model switches voice to that family', async () => { + const dir = tmpDir(); + const s = store(dir, { + installFiles: async ({ model }) => { + if (model.voicePath) { + const piperDir = path.join(dir, 'piper', model.id); + fs.mkdirSync(piperDir, { recursive: true }); + const base = path.basename(model.voicePath); + fs.writeFileSync(path.join(piperDir, base), 'onnx'); + fs.writeFileSync(path.join(piperDir, `${base}.json`), '{}'); + return; + } + const p = path.join(dir, '.installed'); + fs.mkdirSync(p, { recursive: true }); + fs.writeFileSync(path.join(p, model.id), 'ok'); + }, + }); + await s.install('kokoro-q8'); + await s.install('piper-it-paola'); + s.select('kokoro-q8'); + assert.equal(s.activeVoice(), 'af_heart'); + s.select('piper-it-paola'); + assert.equal(s.activeVoice(), 'piper_it_paola'); + s.select('kokoro-q8'); + assert.equal(s.activeVoice(), 'af_heart'); +}); + +test('piper without config json is not installed', () => { + const dir = tmpDir(); + const piperDir = path.join(dir, 'piper', 'piper-it-paola'); + fs.mkdirSync(piperDir, { recursive: true }); + fs.writeFileSync(path.join(piperDir, 'it_IT-paola-medium.onnx'), 'onnx'); + assert.equal(store(dir).listModels().find((m) => m.id === 'piper-it-paola').state, 'notInstalled'); +}); + +test('remove piper model deletes its directory', async () => { + const dir = tmpDir(); + const piperDir = path.join(dir, 'piper', 'piper-it-paola'); + const s = store(dir, { + installFiles: async ({ model }) => { + fs.mkdirSync(path.join(dir, 'piper', model.id), { recursive: true }); + const base = path.basename(model.voicePath); + fs.writeFileSync(path.join(dir, 'piper', model.id, base), 'onnx'); + fs.writeFileSync(path.join(dir, 'piper', model.id, `${base}.json`), '{}'); + }, + }); + await s.install('piper-it-paola'); + s.remove('piper-it-paola'); + assert.equal(fs.existsSync(piperDir), false); + assert.equal(s.listModels().find((m) => m.id === 'piper-it-paola').state, 'notInstalled'); +}); diff --git a/sidecar/test/phonemes.test.js b/sidecar/test/phonemes.test.js new file mode 100644 index 0000000..57a412d --- /dev/null +++ b/sidecar/test/phonemes.test.js @@ -0,0 +1,46 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { espeakVoiceFor, espeakPhonemize, toIds, hasEspeak } from '../src/phonemes.js'; + +const MAP = { '^': [1], $: [2], '0': [0], a: [10], b: [11], ' ': [30] }; + +test('espeak voice mapping covers pilot languages and passes through unknown', () => { + assert.equal(espeakVoiceFor('it'), 'it'); + assert.equal(espeakVoiceFor('pt-br'), 'pt-br'); + assert.equal(espeakVoiceFor('de'), 'de'); +}); + +test('espeakPhonemize normalizes whitespace via injected runner', async () => { + const run = async (args) => { + assert.deepEqual(args.slice(0, 4), ['--ipa', '-q', '-v', 'it']); + return 'tˈʃao\n come stai\n'; + }; + assert.equal(await espeakPhonemize('ciao come stai', 'it', run), 'tˈʃao come stai'); +}); + +test('toIds wraps in BOS/EOS, pads per symbol, drops unmapped symbols', () => { + const ids = toIds('aXb a', MAP); + assert.deepEqual(ids, [1, 10, 0, 11, 0, 30, 0, 10, 0, 2]); +}); + +test('toIds on empty input still yields BOS/EOS', () => { + assert.deepEqual(toIds('', MAP), [1, 2]); +}); + +test('hasEspeak reports missing binary as typed error source', async () => { + const fail = () => { const e = new Error('spawn espeak-ng ENOENT'); e.code = 'ENOENT'; throw e; }; + assert.equal(await hasEspeak(fail), false); + assert.equal(await hasEspeak(async () => 'eSpeak NG 1.52'), true); +}); + +test('missing espeak-ng is a typed install hint', async () => { + const fail = async () => { + const e = new Error('spawn espeak-ng ENOENT'); + e.code = 'ENOENT'; + throw e; + }; + await assert.rejects( + () => espeakPhonemize('ciao', 'it', fail), + (err) => err.code === 'tts.espeak_missing' && /apt install espeak-ng/.test(err.message), + ); +}); diff --git a/sidecar/test/piper.test.js b/sidecar/test/piper.test.js new file mode 100644 index 0000000..e180a2d --- /dev/null +++ b/sidecar/test/piper.test.js @@ -0,0 +1,102 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { synthesize, piperVoices, piperFiles, piperUrls } from '../src/piper.js'; +import { loadCatalog } from '../src/catalog.js'; +import { chunkText, buildWav, f32ToPcm16 } from '../src/audio.js'; + +const PAOLA = loadCatalog().find((m) => m.id === 'piper-it-paola'); + +const CONFIG = { + audio: { sample_rate: 22050 }, + phoneme_id_map: { '^': [1], $: [2], '0': [0], c: [10], i: [11], 'a': [12], 'o': [13], ' ': [30] }, +}; + +function fakeLoad(calls) { + return async (model) => { + calls.push(model.id); + return { + config: CONFIG, + session: { + async run(feed) { + calls.push({ feed }); + const n = Number(feed.input_lengths[0] ?? feed.input_lengths?.data?.[0] ?? 5); + // Echo the ids count as sample count so duration math is checkable. + const samples = new Float32Array(Math.max(n, 4)); + samples.fill(0.5); + return { audio: { data: samples } }; + }, + }, + }; + }; +} + +function fakePhonemize(calls) { + return async (text, lang) => { + calls.push({ text, lang }); + return 'ciao'; + }; +} + +test('synthesize runs per chunk, writes WAV at model sample rate', async () => { + const calls = []; + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sayit-piper-')); + const audioDir = path.join(dir, 'audio'); + const out = await synthesize('ciao ciao.', { + model: PAOLA, + speed: 1.5, + audioDir, + load: fakeLoad(calls), + phonemize: fakePhonemize(calls), + unloadAfterMinutes: 0, + }); + assert.ok(fs.existsSync(out.file)); + const wav = fs.readFileSync(out.file); + assert.equal(wav.readUInt32LE(24), 22050); // sample rate from config + assert.equal(out.chunks.length, 1); + const phonemizeCalls = calls.filter((c) => c.lang); + assert.deepEqual(phonemizeCalls.map((c) => c.lang), ['it']); + const feedCall = calls.find((c) => c.feed); + assert.ok(feedCall); + assert.equal(feedCall.feed.scales.dims?.[0], 3); + assert.ok(Math.abs(feedCall.feed.scales.data[1] - 1 / 1.5) < 1e-6); +}); + +test('chunkText splits long input at sentence boundaries', () => { + const text = Array(30).fill('Ciao come stai.').join(' '); + const chunks = chunkText(text); + assert.ok(chunks.length > 1); + assert.ok(chunks.every((c) => c.length <= 400)); +}); + +test('buildWav header math for 16-bit mono', () => { + const pcm = f32ToPcm16(Float32Array.from([0, 0.5, -0.5, 1])); + assert.equal(pcm.length, 8); + const wav = buildWav(pcm, 22050); + assert.equal(wav.length, 44 + 8); + assert.equal(wav.toString('ascii', 0, 4), 'RIFF'); + assert.equal(wav.readUInt32LE(28), 22050 * 2); // byte rate +}); + +test('piperVoices exposes one voice per catalog row with family tag', () => { + const v = piperVoices(); + assert.deepEqual(Object.keys(v).sort(), ['piper_it_paola', 'piper_it_riccardo']); + assert.equal(v.piper_it_paola.lang, 'it'); + assert.equal(v.piper_it_paola.family, 'piper'); + assert.equal(v.piper_it_paola.gender, 'female'); +}); + +test('piperFiles derives onnx + json paths under piper/', () => { + const [onnx, json] = piperFiles(PAOLA, '/models'); + assert.equal(onnx, path.join('/models', 'piper', 'piper-it-paola', 'it_IT-paola-medium.onnx')); + assert.equal(json, onnx + '.json'); +}); + +test('piperUrls builds HF resolve URLs for both files', () => { + assert.deepEqual(piperUrls(PAOLA), [ + 'https://huggingface.co/rhasspy/piper-voices/resolve/v1.0.0/it/it_IT/paola/medium/it_IT-paola-medium.onnx?download=true', + 'https://huggingface.co/rhasspy/piper-voices/resolve/v1.0.0/it/it_IT/paola/medium/it_IT-paola-medium.onnx.json?download=true', + ]); +}); diff --git a/sidecar/test/tts.test.js b/sidecar/test/tts.test.js new file mode 100644 index 0000000..ae7f72b --- /dev/null +++ b/sidecar/test/tts.test.js @@ -0,0 +1,32 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { voiceRegistry, resolveVoice, playbackSpeed } from '../src/tts.js'; +import { loadCatalog } from '../src/catalog.js'; + +test('registry merges kokoro and piper voices with family tags', () => { + const r = voiceRegistry(loadCatalog()); + assert.equal(r.af_heart.family, 'kokoro'); + assert.equal(r.af_heart.lang, 'en-us'); + assert.equal(r.piper_it_paola.family, 'piper'); + assert.equal(r.piper_it_paola.lang, 'it'); + assert.ok(Object.keys(r).length >= 13); +}); + +test('piper playback speed is 1 so mpv does not double-stretch', () => { + assert.equal(playbackSpeed('piper-onnx', 2), 1); + assert.equal(playbackSpeed('kokoro-js', 2), 2); +}); + +test('resolveVoice falls back to settings default and rejects unknown', () => { + assert.equal(resolveVoice('piper_it_paola').id, 'piper_it_paola'); + assert.throws( + () => resolveVoice('nope', { defaultVoice: 'missing' }), + (err) => err.code === 'voice.unknown', + ); +}); + +test('stale kokoro italian id falls back to active piper voice', () => { + const v = resolveVoice('im_nicola', { defaultVoice: 'piper_it_riccardo' }); + assert.equal(v.id, 'piper_it_riccardo'); + assert.equal(v.family, 'piper'); +});