Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
71 changes: 38 additions & 33 deletions LINUX.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
13 changes: 8 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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`,
Expand All @@ -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
Expand Down
37 changes: 29 additions & 8 deletions app/src/routes/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@
// onMount: a second initStore() used to return before voices existed, so
// the <select> stayed blank. Only overwrite when the current id is missing.
$effect(() => {
const known = appState.voices;
const family = models.find((m) => m.active)?.family;
const known = appState.voices.filter((v) => !family || !v.family || v.family === family);
if (!known.length) return;
if (!known.some((v) => v.id === voice)) {
const fallback = appState.settings.voice;
Expand All @@ -30,6 +31,18 @@
appState.player.duration > 0 ? (appState.player.position / appState.player.duration) * 100 : 0
);
const models = $derived(Array.isArray(appState.models) ? appState.models : []);
// Group voices by language for <optgroup> pickers (en-us, en-gb, it, …).
// Only show voices that belong to the active model's family.
const voiceGroups = $derived.by(() => {
const groups = new Map();
const family = activeModel?.family;
for (const v of appState.voices) {
if (family && v.family && v.family !== family) continue;
if (!groups.has(v.lang)) groups.set(v.lang, []);
groups.get(v.lang).push(v);
}
return [...groups.entries()];
});
const installedModels = $derived(models.filter((m) => m.state === 'installed'));
const needsModel = $derived(installedModels.length === 0);
const recommended = $derived(models.find((m) => m.stability === 'recommended') || models[0]);
Expand Down Expand Up @@ -145,12 +158,12 @@
{#if needsModel && recommended}
<div class="onboard">
<h3>Download a speech model</h3>
<p class="dim">Nothing is installed yet. Download Kokoro to start speaking. This is a one-time download; after that the app stays offline.</p>
<p class="dim">Nothing is installed yet. Download a model to start speaking. This is a one-time download; after that the app stays offline.</p>
<div class="model-card">
<div>
<strong>{recommended.displayName}</strong>
{#if recommended.stability === 'recommended'}<span class="badge">Recommended</span>{/if}
<div class="dim meta">{formatBytes(recommended.estimatedDiskBytes)} · {recommended.license} · {recommended.family}</div>
<div class="dim meta">{formatBytes(recommended.estimatedDiskBytes)} · {recommended.license} · {recommended.family} · {(recommended.languages || []).join(', ')}</div>
{#if recommended.error}<div class="err-line">{recommended.error}</div>{/if}
{#if recommended.state === 'downloading' || recommended.state === 'canceling'}
<div class="dim">Downloading…</div>
Expand Down Expand Up @@ -178,8 +191,12 @@
<label>
Voice
<select bind:value={voice}>
{#each appState.voices as v (v.id)}
<option value={v.id}>{v.name} ({v.lang})</option>
{#each voiceGroups as [lang, vs] (lang)}
<optgroup label={lang}>
{#each vs as v (v.id)}
<option value={v.id}>{v.name}</option>
{/each}
</optgroup>
{/each}
</select>
</label>
Expand Down Expand Up @@ -276,7 +293,7 @@
<div>
<strong>{m.displayName}</strong>
{#if m.stability === 'recommended'}<span class="badge">Recommended</span>{/if}
<div class="dim meta">{formatBytes(m.estimatedDiskBytes)} · {m.license} · {m.family}</div>
<div class="dim meta">{formatBytes(m.estimatedDiskBytes)} · {m.license} · {m.family} · {(m.languages || []).join(', ')}</div>
{#if m.error}<div class="err-line">{m.error}</div>{/if}
{#if m.state === 'downloading' || m.state === 'canceling'}
<div class="dim">{m.state === 'canceling' ? 'Canceling…' : 'Downloading…'}</div>
Expand Down Expand Up @@ -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)}
<option value={v.id}>{v.name} ({v.lang})</option>
{#each voiceGroups as [lang, vs] (lang)}
<optgroup label={lang}>
{#each vs as v (v.id)}
<option value={v.id}>{v.name} ({lang})</option>
{/each}
</optgroup>
{/each}
</select>
</label>
Expand Down
51 changes: 51 additions & 0 deletions sidecar/src/audio.js
Original file line number Diff line number Diff line change
@@ -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]);
}
4 changes: 4 additions & 0 deletions sidecar/src/catalog.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
32 changes: 32 additions & 0 deletions sidecar/src/catalog.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
]
}
Loading
Loading