diff --git a/.github/workflows/ci-linux.yml b/.github/workflows/ci-linux.yml index d7bbfa4..b7e77b6 100644 --- a/.github/workflows/ci-linux.yml +++ b/.github/workflows/ci-linux.yml @@ -1,6 +1,4 @@ -# Compile the Tauri shell on Linux. -# Intentionally --no-bundle: installers would be a mute window until the -# Node sidecar is packaged. This job only proves the crate + UI build. +# Linux CI: AppImage with sidecar inside. macOS/Windows stay --no-bundle. name: ci-linux on: @@ -13,26 +11,30 @@ concurrency: group: ci-${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true +env: + APPIMAGE_EXTRACT_AND_RUN: "1" + jobs: build: - name: build (ubuntu-22.04) - runs-on: ubuntu-22.04 + name: appimage (ubuntu-24.04) + runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - name: Install Linux WebKitGTK deps run: | sudo apt-get update - sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf + sudo apt-get install -y libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v7 with: - node-version: 20 + node-version: 26 cache: npm cache-dependency-path: | package-lock.json app/package-lock.json + sidecar/package-lock.json - uses: dtolnay/rust-toolchain@stable @@ -45,8 +47,8 @@ jobs: npm ci npm --prefix app ci - - name: Compile shell (no bundle) - run: npm run build:ci + - name: Bundle AppImage + run: npm run build:appimage - - name: Check release binary - run: test -f src-tauri/target/release/sayit + - name: Check AppImage + run: ls -lh src-tauri/target/release/bundle/appimage/*.AppImage diff --git a/.github/workflows/ci-macos.yml b/.github/workflows/ci-macos.yml index 55864f3..8825f65 100644 --- a/.github/workflows/ci-macos.yml +++ b/.github/workflows/ci-macos.yml @@ -1,6 +1,5 @@ # Compile the Tauri shell on macOS. -# Intentionally --no-bundle: installers would be a mute window until the -# Node sidecar is packaged. This job only proves the crate + UI build. +# PRs stay --no-bundle. macOS installers are not shipped yet. name: ci-macos on: @@ -44,4 +43,4 @@ jobs: run: npm run build:ci - name: Check release binary - run: test -f src-tauri/target/release/sayit + run: test -f src-tauri/target/release/sayit-desktop diff --git a/.github/workflows/ci-windows.yml b/.github/workflows/ci-windows.yml index 799fc63..8370a22 100644 --- a/.github/workflows/ci-windows.yml +++ b/.github/workflows/ci-windows.yml @@ -1,6 +1,5 @@ # Compile the Tauri shell on Windows. -# Intentionally --no-bundle: installers would be a mute window until the -# Node sidecar is packaged. This job only proves the crate + UI build. +# PRs stay --no-bundle. Windows installers are not shipped yet. name: ci-windows on: @@ -40,8 +39,12 @@ jobs: npm ci npm --prefix app ci + - name: Ensure sidecar-bundle exists + shell: bash + run: mkdir -p src-tauri/sidecar-bundle + - name: Compile shell (no bundle) run: npm run build:ci - name: Check release binary - run: test -f src-tauri/target/release/sayit.exe + run: test -f src-tauri/target/release/sayit-desktop.exe diff --git a/.github/workflows/release-linux.yml b/.github/workflows/release-linux.yml new file mode 100644 index 0000000..e0d4649 --- /dev/null +++ b/.github/workflows/release-linux.yml @@ -0,0 +1,72 @@ +# Build Linux GUI packages with the Node sidecar inside. +# PR CI stays --no-bundle; this job is tags (and manual dispatch) only. +name: release-linux + +on: + push: + tags: ["v*"] + workflow_dispatch: + +permissions: + contents: write + +concurrency: + group: release-linux-${{ github.ref }} + cancel-in-progress: true + +jobs: + linux: + name: deb + rpm (ubuntu-24.04) + runs-on: ubuntu-24.04 + + steps: + - uses: actions/checkout@v7 + + - name: Install Linux WebKitGTK + rpm tooling + run: | + sudo apt-get update + sudo apt-get install -y libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf rpm + + - uses: actions/setup-node@v7 + with: + node-version: 26 + cache: npm + cache-dependency-path: | + package-lock.json + app/package-lock.json + sidecar/package-lock.json + + - uses: dtolnay/rust-toolchain@stable + + - uses: swatinem/rust-cache@v2 + with: + workspaces: ./src-tauri -> target + + - name: Install JS deps + run: | + npm ci + npm --prefix app ci + + - name: Bundle deb and rpm + run: npx tauri build -b deb,rpm + + - uses: actions/upload-artifact@v7 + with: + name: linux-packages + path: | + src-tauri/target/release/bundle/deb/*.deb + src-tauri/target/release/bundle/rpm/*.rpm + + - name: Publish GitHub Release + if: startsWith(github.ref, 'refs/tags/') + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh release create "${{ github.ref_name }}" \ + src-tauri/target/release/bundle/deb/*.deb \ + src-tauri/target/release/bundle/rpm/*.rpm \ + --generate-notes --verify-tag \ + || gh release upload "${{ github.ref_name }}" \ + src-tauri/target/release/bundle/deb/*.deb \ + src-tauri/target/release/bundle/rpm/*.rpm \ + --clobber diff --git a/.gitignore b/.gitignore index 35ab0d5..10bb79e 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,10 @@ app/build/ app/.svelte-kit/ src-tauri/target/ src-tauri/gen/ +src-tauri/sidecar-bundle/node_modules/ +src-tauri/sidecar-bundle/src/ +src-tauri/sidecar-bundle/package.json +src-tauri/sidecar-bundle/package-lock.json *.log # OS / editor junk @@ -14,3 +18,4 @@ Thumbs.db .idea/ .vscode/ .codegraph/ +.zcode/ diff --git a/AGENTS.md b/AGENTS.md index 0ac4d57..74c4427 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,8 +7,9 @@ Crucial reminders for future sessions. See LINUX.md for full architecture. - **Node ≥ 20**, Rust, **mpv** (playback), `wl-paste`/`xclip`/`xsel` (clipboard). - Root deps: `@tauri-apps/cli`. App and sidecar have their own `package.json`. - Dev: `npm run dev` starts sidecar + `tauri dev`. `beforeDevCommand` runs from project root with `--prefix app`. -- CI: `npm run build:ci` (`tauri build --no-bundle`) on Ubuntu 22.04, macOS, Windows. Do not add bundling to that job until the sidecar is inside the package. -- Sidecar search order: `$SAYIT_SIDECAR_DIR` → `~/.local/share/sayit/sidecar`. +- CI: `npm run build:ci` (`tauri build --no-bundle`) on PRs (Ubuntu 24.04, Node 26). Local GUI package is `npm run build:linux` (`.deb` only). `npm run build:appimage` / `npm run build:rpm` for the other formats. Tag workflow still ships deb+rpm. `beforeBundleCommand` stages `src-tauri/sidecar-bundle/`. +- GUI binary is `sayit-desktop`. CLI remains `sayit`. +- Sidecar search order: `$SAYIT_SIDECAR_DIR` → bundled resources → `~/.local/share/sayit/sidecar`. ## Gotchas diff --git a/LINUX.md b/LINUX.md index 92cd571..6b45be0 100644 --- a/LINUX.md +++ b/LINUX.md @@ -131,7 +131,8 @@ read_clipboard()` ↔ `sayit-clipboard.sh`). |---|---| | `~/.config/sayit/token` | API token, 0600 — shared by app, CLI, scripts | | `~/.config/sayit/settings.json` | port, host, voice, speed, model, unload timeout | -| `~/.local/share/sayit/sidecar/` | installed sidecar (by `scripts/setup-sidecar.sh`) | +| `~/.local/share/sayit/sidecar/` | installed sidecar (by `scripts/setup-sidecar.sh` / `install.sh`) | +| GUI package `$RESOURCE/sidecar/` | sidecar tree inside the `.deb` / `.rpm` (see `spawn_sidecar`) | | `~/.local/share/sayit/history.json` | last 200 entries, references WAV files | | `~/.cache/sayit/models/` | HF model cache (passed as `cache_dir` to kokoro-js) | | `~/.cache/sayit/audio/` | synthesized WAVs (deleted with history entries) | @@ -146,14 +147,19 @@ disk. Anything that needs the API should resolve the token the same way. - `build.rs` + `tauri-build` exist because `tauri::generate_context!()` requires `OUT_DIR` — removing them breaks the build with a confusing macro error. -- `beforeDevCommand`/`beforeBuildCommand` run from the **project root**, so - they use `--prefix app`; `frontendDist` is relative to `src-tauri/`, so it - keeps `../app/build`. Mixing these up produces an ENOENT two directories - away from the real problem. +- `beforeDevCommand`/`beforeBuildCommand`/`beforeBundleCommand` run from the + **project root**, so they use `--prefix app` / `scripts/…`; `frontendDist` + is relative to `src-tauri/`, so it keeps `../app/build`. Mixing these up + produces an ENOENT two directories away from the real problem. - The tray icon has a 1×1 transparent fallback (`Image::new_owned`) because `default_window_icon()` can be `None` in some bundling configurations. - CSP in `tauri.conf.json` must keep `connect-src http://127.0.0.1:7878` or the webview can't reach the sidecar. +- GUI packages (`npm run build:linux`) run `scripts/prepare-sidecar-bundle.sh` + then embed that tree as `bundle.resources` → `sidecar/`. The window binary + is `sayit-desktop` so it never shadows the CLI `sayit`. Spawn order: + `$SAYIT_SIDECAR_DIR` → bundled resources → `~/.local/share/sayit/sidecar`. + If 7878 is already healthy, the GUI does not spawn a second engine. ## 8. Known limitations (vs the original) diff --git a/README.md b/README.md index 9dc4b24..f476ab0 100644 --- a/README.md +++ b/README.md @@ -4,87 +4,107 @@ [![CI macOS](https://img.shields.io/github/actions/workflow/status/ildella/sayit/ci-macos.yml?branch=master&label=macOS)](https://github.com/ildella/sayit/actions/workflows/ci-macos.yml) [![CI Windows](https://img.shields.io/github/actions/workflow/status/ildella/sayit/ci-windows.yml?branch=master&label=Windows)](https://github.com/ildella/sayit/actions/workflows/ci-windows.yml) -Private, local text-to-speech, cross-platform. Say It turns copied text into -speech with open models running entirely on your machine — your text and -generated audio never leave your computer. +Private, local text-to-speech. Say It turns copied text into speech with open +models running entirely on your machine — your text and generated audio never +leave your computer. + +This is a Linux-first port of [callebtc/sayit](https://github.com/callebtc/sayit) +(macOS / Apple silicon). It keeps the original architecture and CLI while +replacing Apple-specific layers with Tauri, Svelte, and kokoro-js.

- Say It Linux desktop app — Speak tab + Say It desktop app — Speak tab

-This is a multi-platform port of [callebtc/sayit](https://github.com/callebtc/sayit) -(macOS / Apple silicon), keeping its architecture and CLI surface while -swapping every Apple-specific layer for portable equivalents: +Linux (X11 and Wayland) is built and tested. macOS and Windows should compile; +help wanted. + +## Highlights -| 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 | -| 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 | -| `sayit` CLI | Same CLI, same commands | +- **Speak from anywhere.** Copy text and press the clipboard hotkey + (**Ctrl+Alt+V** on X11), or bind `sayit-clipboard` as a custom shortcut in + your desktop environment — the reliable path on Wayland. +- **A desktop player.** Tray window to speak, pause, seek, change playback + speed, and revisit history without leaving your current app. +- **Open models.** Download supported Kokoro-82M weights in the app or CLI + (`kokoro-q8` ~90 MB, or `kokoro-q4`). Speak never downloads on its own. +- **Efficient model loading.** Only one model is kept in memory, and it is + unloaded after a configurable idle period (ten minutes by default). +- **Local by design.** Synthesis works offline after model download. There is + no analytics, cloud inference, or passive clipboard monitoring. +- **Hear your coding agent work.** The bundled + [Say It agent skill](skills/sayit/SKILL.md) provides live, hands-free spoken + progress updates while an agent works. -No Python anywhere. The sidecar is Node ≥ 20, the UI is Svelte, the shell is Rust. +## Getting started -## Platform status +Two products, one engine. The CLI installer never needs a window. The GUI +package includes its own copy of the sidecar and talks to whatever is already +on port 7878. -| Platform | Status | -| -------- | ------ | -| Linux | ✅ Built and tested (X11 & Wayland) | -| macOS | 🔶 Should build — help wanted | -| Windows | 🔶 Should build — help wanted | +### CLI (no window) -The Tauri v2 shell is cross-platform by design; only the playback helper -(`mpv`, with `aplay` fallback) and clipboard tools are POSIX-flavored today. -Porting notes for macOS/Windows contributors are welcome — see -[LINUX.md](LINUX.md) for how the port is put together and why. +Needs **Node ≥ 20**, npm, and **mpv** (`aplay` is a limited fallback). +Clipboard tools (`wl-paste`, `xclip`, or `xsel`) only if you want the hotkey. -## Quick start +```sh +curl -fsSL https://raw.githubusercontent.com/ildella/sayit/master/scripts/install.sh | bash -s -- --systemd +``` -Requirements: Node ≥ 20, npm, and **mpv** for playback (falls back to `aplay`). -Clipboard tools (`wl-paste` / `xclip` / `xsel`) only if you want the hotkey. +Or from a clone: `bash scripts/install.sh --systemd`. Omit `--systemd` to +start the daemon once without enabling it. Put `~/.local/bin` on your `PATH`. -### 1. Install +Then download a model (once, then the app stays offline) and speak: ```sh -curl -fsSL https://raw.githubusercontent.com/ildella/sayit/master/scripts/install.sh | bash -s -- --systemd +sayit models install kokoro-q8 --use +sayit "Hello from Say It" ``` -Or from a clone of this repo: +Or copy text and run `sayit-clipboard` (bind that in your desktop +shortcuts). Speak returns an error until a catalog model is installed +and selected. + +### Desktop app + +The Linux GUI is an **AppImage**. It embeds the sidecar. Needs **Node ≥ 20** +and **mpv** on the machine (same as the CLI). No sudo. + +Download it from +[Releases](https://github.com/ildella/sayit/releases) (or the Linux CI +artifact), then: ```sh -bash scripts/install.sh # sidecar + sayit CLI -bash scripts/install.sh --systemd # + start automatically at login +chmod +x SayIt-*.AppImage +./SayIt-*.AppImage ``` -That puts the sidecar in `~/.local/share/sayit/sidecar` and the **CLI** at -`~/.local/bin/sayit`, then starts the daemon. Ensure `~/.local/bin` is on -your `PATH`. Download a catalog model before speaking; after that the app -stays offline. +The window binary is `sayit-desktop`; it does not replace the CLI `sayit`. +You can run both: whoever starts first owns port 7878; the other connects. -After pulling updates, re-run `scripts/setup-sidecar.sh` (or `npm run setup`) -and restart the service. The GUI and CLI talk to whatever is already on -port 7878 — an old sidecar will look “stuck” or reject speak. +### Terminal -### 2. Run the CLI +The install includes a `sayit` CLI for speech, playback, models, and +automation: ```sh -sayit models install kokoro-q8 --use # once, ~90 MB -sayit "Hello from Say It" # speak -sayit volume 0 # silence; 1 = normal, 2 = boost -sayit service status # is the daemon running? +sayit "Read this aloud" +printf 'Read standard input' | sayit +sayit status +sayit pause +sayit resume +sayit volume 0 # silence; 1 = normal, 2 = boost +sayit service status +sayit skill path ``` -`sayit` talks to the sidecar on `127.0.0.1:7878`. If the daemon is down: -`sayit service start` (or `systemctl --user start sayit` if you used -`--systemd`). +Run `sayit --help` for all commands. The CLI talks to the sidecar on +`127.0.0.1:7878`. If the daemon is down: `sayit service start` (or +`systemctl --user start sayit` after `--systemd`). -### 3. Install the agent skill +### Coding-agent voice mode -The [skill](skills/sayit/SKILL.md) is from [callebtc/sayit](https://github.com/callebtc/sayit); -this port only installs it next to the Linux CLI. After `install.sh` (or -`npm run setup`): +After install: ```sh sayit skill install @@ -105,67 +125,51 @@ cp "$(sayit skill path)" ~/.claude/skills/sayit/SKILL.md ``` Re-run `sayit skill install` after upgrading Say It. The sidecar must be -running (`sayit service start`) and a model installed before speech works. - -### 4. Run the desktop app +running and a model installed before speech works. -From a clone, after the sidecar is installed (`install.sh` or `npm run setup`): +### Wayland vs X11 -```sh -npm install # once: @tauri-apps/cli -npm --prefix app install # once: SvelteKit UI -npm run dev # sidecar + Tauri window -``` - -`npm run dev` (or `npm run tauri dev`) opens the tray/window. If the daemon -is already up, Tauri connects to it instead of spawning a second one. - -```sh -npm run tauri build # .deb / AppImage (Linux shell only) -``` +In-app global shortcuts (**Ctrl+Alt+V**) work on X11. Wayland compositors +block them — bind a **custom shortcut** in your desktop settings to +`sayit-clipboard` (installed next to the CLI). There is no cross-compositor +API for another app's *selection* (not clipboard); on X11 you can point +`sayit-clipboard` at `xclip -o` (PRIMARY) instead. -The `.deb` installs a **Say It** launcher and `/usr/bin/sayit` (the GUI). -The setup script also puts the **CLI** at `~/.local/bin/sayit`. If your PATH -lists `~/.local/bin` first, the GNOME icon or `sayit status` may run the CLI -instead of the window. Launch the GUI with `/usr/bin/sayit`, the CLI with -`~/.local/bin/sayit`. +## Build from source -### 5. Which engine is running? +Contributors: live UI. The end-user GUI package is the AppImage. -There is one synthesis engine today: **Kokoro-82M** via kokoro-js / -onnxruntime-node (CPU). What *does* vary is the **catalog model** (q8 vs q4) -and whether it is loaded in memory. +You need Rust and [Tauri's prerequisites](https://v2.tauri.app/start/prerequisites/) +as well as Node ≥ 20 and mpv. ```sh -sayit status -``` - -Example: - -``` -state: idle -engine: loaded (kokoro-q8) -sidecar: 0.x.x +git clone https://github.com/ildella/sayit.git && cd sayit +npm run setup # sidecar + CLI into ~/.local (same as install.sh) +npm install # @tauri-apps/cli +npm --prefix app install # SvelteKit UI +npm run dev # live UI (developer loop, not a distribution) ``` -- **engine** — `loaded` / `loading…` / `unloaded`, and the active model id - (`kokoro-q8` or `kokoro-q4`). Unloaded after idle timeout; the next speak - loads it again. -- **sidecar** — installed sidecar version (and a protocol mismatch warning - if the CLI is newer than the daemon). - -List every catalog row and which one is **active**: +If a daemon is already listening on port 7878, Tauri connects to it instead +of spawning a second one. ```sh -sayit models +npm run build:appimage # AppImage with sidecar inside (Linux CI) +npm run build:linux # .deb (optional) +npm run build:ci # compile the shell, skip installers (macOS/Windows CI) ``` -In the GUI: **Settings → Models**. The row marked in use is the engine -the next speak will load. There is no separate GPU / ROCm / CUDA switch in -this port — onnxruntime-node runs on CPU. +After pulling updates, re-run `npm run setup` and restart the service so a +CLI install is not talking to a stale sidecar. ## Architecture +The SvelteKit frontend (in a Tauri v2 tray shell) is separate from a +per-user Node sidecar that owns model downloads, synthesis, playback, and +history. The app, CLI, and `sayit-clipboard` talk to that service over a +token-protected REST + SSE API bound to `127.0.0.1:7878`. There is no +Python; synthesis is **Kokoro-82M** via kokoro-js / onnxruntime-node (CPU). + ``` ┌──────────────┐ REST + SSE, Bearer token ┌──────────────────┐ │ Tauri v2 app │ ◄──────────────────────────► │ sidecar (Node) │ @@ -175,168 +179,17 @@ this port — onnxruntime-node runs on CPU. └──────────────┘ └──────────────────┘ ``` -- **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). -- **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`, - `pause`, `resume`, `stop`, `seek`, `speed`, `volume`, `voices`, `models`, `history`, `replay`, `skill path`, `skill install`. -- **src-tauri/** — tray icon, global hotkey (Ctrl+Alt+V speaks clipboard), - spawns the sidecar, hands the API token to the webview. - -## Models and first run - -Speech models are a **catalog**, not a silent download on first speak. - -- **Onboarding:** if nothing is installed, the Speak tab shows the recommended model (Kokoro q8, ~90 MB) and **Download and Use**. Deleting the last model brings that screen back. -- **Marketplace:** Settings → Models lists every catalog entry we can run today (`kokoro-q8` and `kokoro-q4`). Download, Download and Use, cancel, Use, Delete (not the active model). -- **Progress:** the UI shows downloading / canceling. Byte-level percent is not wired yet (kokoro-js does not expose a reliable byte callback). -- **Speak never downloads.** `POST /v1/speak` returns 409 until a model is installed and selected. - -CLI: - -```sh -sayit models -sayit models install kokoro-q8 --use -sayit models select kokoro-q4 -sayit models rm kokoro-q4 -``` - -Weights land in `~/.cache/sayit/models`. After that the app stays offline. Adding another ONNX family later is a catalog row, not a new Settings screen. - -## Playback speed - -- **Speak tab slider:** 0.5×–2.5× in 0.25 steps (the default-speed setting uses the same range). -- **Player − / + buttons:** ±0.25 per press while playing, clamped to 0.5×–4.0×. -- **CLI:** `sayit speed <0.5-4>` accepts any value in the range, not just the steps. -- Pitch is preserved at every speed (mpv `scaletempo`); at 1× the audio plays - untouched, and history replays apply speed only at playback — the stored - audio file is always the original. - -## Setup (development) - -Requirements: Node ≥ 20, npm, **mpv** (recommended; falls back to `aplay`), -and `wl-paste` (Wayland) or `xclip`/`xsel` (X11) for clipboard features. -Rust + Tauri prerequisites only if you build the app. - -```sh -git clone https://github.com/ildella/sayit && cd sayit - -# 1. Install sidecar + CLI into ~/.local/share/sayit and ~/.local/bin -npm run setup # or: sh scripts/setup-sidecar.sh - -# 2a. Run the sidecar standalone… -cd ~/.local/share/sayit/sidecar && npm start - -# 2b. …or as a systemd user service -cp scripts/sayit.service ~/.config/systemd/user/ -systemctl --user enable --now sayit -``` - -### Desktop app - -```sh -npm install # root: pulls @tauri-apps/cli -npm --prefix app install -npm run tauri dev # dev: vite on :1420 + sidecar auto-spawn -npm run tauri build # produces .deb / AppImage (Linux shell only) -npm run build:ci # compile the shell, skip installers -``` - -CI (`.github/workflows/ci.yml`) runs `build:ci` on Ubuntu 22.04, macOS, and -Windows. That only proves the crate and UI compile — the TTS sidecar is still -installed separately via `scripts/install.sh`, so those binaries are not a -shippable app. - -In dev, the app finds the sidecar via `$SAYIT_SIDECAR_DIR` or the installed -copy in `~/.local/share/sayit/sidecar`; if a service is already listening on -7878 it just connects. - -## Wayland vs X11 - -Global shortcut registration (Ctrl+Alt+V inside the app) works on X11. -On Wayland, compositors block app-registered global shortcuts — the reliable -path is a **custom shortcut in your desktop settings** bound to -`sayit-clipboard` (installed by the setup script). Reading another app's -*selection* (not clipboard) has no cross-compositor API on Wayland; on X11 -you can adapt `sayit-clipboard` to use `xclip -o` (PRIMARY) instead. - -## Config & data - -| Path | Content | -| ---- | ------- | -| `~/.config/sayit/token` | API token (0600), shared by app/CLI/scripts | -| `~/.config/sayit/settings.json` | port, voice, speed, volume (0–2), unload timeout | -| `~/.local/share/sayit/history.json` | spoken history (last 200) | -| `~/.cache/sayit/models` | downloaded models | -| `~/.cache/sayit/audio` | synthesized WAVs | -| `~/.cache/sayit/sidecar.log` | sidecar stdout/stderr (when spawned by the GUI) | -| `~/.cache/sayit/sidecar.pid` | pid of the running sidecar, used by health/recovery | - -## Troubleshooting - -**`error: Model is not installed`** — download once: - -```sh -sayit models install kokoro-q8 --use -``` - -Speak never fetches weights by itself. - -**Empty Voice menu, red connection dot, or Speak stuck on Synthesizing** — -the UI is talking to an outdated sidecar. The GUI handles this on its own: -it verifies sidecar health and protocol at launch and every 30 seconds, and -retires + respawns a stale one (a process it cannot attribute to this install -is never touched). One caveat: if the stale sidecar is the systemd unit itself, -`Restart=on-failure` brings it back while the GUI waits for the port — stop -the unit first (`systemctl --user stop sayit`), let the GUI win, then either -keep the GUI-managed sidecar or refresh the install and start the unit again. -Without the GUI — daemon-only setups — stop the unit, refresh the install, -start again: - -```sh -systemctl --user stop sayit -# pkill alone is not enough if the user unit is enabled — systemd will respawn it -ss -ltnp | grep 7878 || echo '7878 libero' -./scripts/setup-sidecar.sh -systemctl --user start sayit # or: ~/.local/bin/sayit service start -sayit models -sayit status -``` - -Logs: `journalctl --user -u sayit -f` and `~/.cache/sayit/sidecar.log`. - -**`sayit status` opens the desktop window** — PATH hit `/usr/bin/sayit` (GUI). -Use `~/.local/bin/sayit status`. - -## API (v1) - -`GET /v1/status` · `GET /v1/health` (liveness + sidecar version/protocol) · -`POST /v1/speak|pause|resume|stop|seek|speed|volume` · -`GET /v1/voices|models|history|settings` · -`POST /v1/models/:id/install|select` · `DELETE /v1/models/:id[/install]` · -`POST /v1/history/replay` · -`DELETE /v1/history/:id` · `GET /v1/events` (SSE) — all behind -`Authorization: Bearer `. - -## Differences from the original - -- **Kokoro only, for now.** Qwen3-TTS / Chatterbox / OmniVoice are MLX- or - Python-bound; kokoro-js is the one solid pure-JS engine today. The engine - layer (`sidecar/src/engine.js`) is isolated so a second backend (e.g. - ONNX exports of other models) can slot in. -- **No voice cloning yet.** Kokoro has no cloning support. -- **No selection capture on Linux/Wayland** (see Wayland vs X11). +How the port maps onto the original (XPC → loopback HTTP, MLX → kokoro-js, +selection → clipboard), filesystem layout, and invariants are in +[LINUX.md](LINUX.md). ## Acknowledgments -Say It was created by [callebtc](https://github.com/callebtc) as a beautiful, +Say It was created by [callebtc](https://github.com/callebtc) as a privacy-first macOS app. This project exists thanks to his generosity in -releasing it under MIT — thank you! If you want the original Apple-silicon -experience (MLX Audio, voice cloning, Voice Studio), use -[callebtc/sayit](https://github.com/callebtc/sayit). This port reuses its -architecture, API surface, and CLI design. +releasing it under MIT. For the original Apple-silicon experience (MLX Audio, +voice cloning, Voice Studio), use +[callebtc/sayit](https://github.com/callebtc/sayit). ## License diff --git a/package.json b/package.json index a3dbd56..54dbcef 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,9 @@ "dev": "npm --prefix sidecar run start & npm run tauri dev", "build:app": "npm --prefix app run build", "build:ci": "tauri build --no-bundle --ci", + "build:linux": "tauri build -b deb", + "build:appimage": "tauri build -b appimage", + "build:rpm": "tauri build -b rpm", "setup": "sh scripts/setup-sidecar.sh", "tauri": "tauri" }, diff --git a/scripts/install.sh b/scripts/install.sh old mode 100644 new mode 100755 index 696d395..2afae79 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -5,16 +5,14 @@ # bash scripts/install.sh # service + sayit CLI # bash scripts/install.sh --systemd # also start automatically at login # -# The same script becomes a one-liner once the repo is published: -# curl -fsSL /raw//scripts/install.sh | bash -s -- --systemd -# In piped mode it downloads the project tarball from SAYIT_REPO_URL. +# Piped one-liner (no clone): +# curl -fsSL https://raw.githubusercontent.com/ildella/sayit/master/scripts/install.sh | bash -s -- --systemd +# Override with SAYIT_REPO_URL / SAYIT_REPO_BRANCH if needed. set -eu # --- Config ----------------------------------------------------------------- -# Piped installs fetch the project from here. Point this at the public repo -# when publishing; local checkouts never need it. -REPO_URL="${SAYIT_REPO_URL:-}" -REPO_BRANCH="${SAYIT_REPO_BRANCH:-main}" +REPO_URL="${SAYIT_REPO_URL:-https://github.com/ildella/sayit}" +REPO_BRANCH="${SAYIT_REPO_BRANCH:-master}" SYSTEMD=no for arg in "$@"; do diff --git a/scripts/prepare-sidecar-bundle.sh b/scripts/prepare-sidecar-bundle.sh new file mode 100755 index 0000000..a2ab95e --- /dev/null +++ b/scripts/prepare-sidecar-bundle.sh @@ -0,0 +1,49 @@ +#!/bin/sh +# Stage a production sidecar tree for Tauri resources (GUI .deb / .rpm). +# Invoked by beforeBundleCommand — not by `tauri dev` or `build:ci`. +set -eu + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +SRC="$ROOT/sidecar" +DEST="$ROOT/src-tauri/sidecar-bundle" + +if [ ! -f "$SRC/package.json" ] || [ ! -f "$SRC/package-lock.json" ]; then + echo "prepare-sidecar-bundle: missing sidecar/package.json or package-lock.json" >&2 + exit 1 +fi + +rm -rf "$DEST" +mkdir -p "$DEST" +cp "$SRC/package.json" "$SRC/package-lock.json" "$DEST/" +cp -R "$SRC/src" "$DEST/src" + +cd "$DEST" +# Isolate from ~/.npmrc; package.json allowScripts still permits onnxruntime-node. +if ! npm_config_allow_scripts= npm ci --omit=dev --userconfig /dev/null; then + echo "prepare-sidecar-bundle: npm ci failed" >&2 + exit 1 +fi + +if [ ! -f "$DEST/src/index.js" ]; then + echo "prepare-sidecar-bundle: src/index.js missing after copy" >&2 + exit 1 +fi + +# onnxruntime-node ships every OS/arch plus CUDA plugins. linuxdeploy walks +# those ELF files, then dies on libcublasLt. Keep this host only. +case "$(uname -m)" in + x86_64) ort_arch=x64 ;; + aarch64|arm64) ort_arch=arm64 ;; + *) ort_arch=x64 ;; +esac +ort="$DEST/node_modules/onnxruntime-node/bin/napi-v3" +if [ -d "$ort" ]; then + find "$ort" -mindepth 1 -maxdepth 1 -type d ! -name linux -exec rm -rf {} + + if [ -d "$ort/linux" ]; then + find "$ort/linux" -mindepth 1 -maxdepth 1 -type d ! -name "$ort_arch" -exec rm -rf {} + + fi + rm -f "$ort/linux/$ort_arch"/libonnxruntime_providers_cuda.so \ + "$ort/linux/$ort_arch"/libonnxruntime_providers_tensorrt.so +fi + +echo "prepare-sidecar-bundle: $DEST" diff --git a/src-tauri/sidecar-bundle/.keep b/src-tauri/sidecar-bundle/.keep new file mode 100644 index 0000000..e69de29 diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 09e78de..26d9fd7 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -7,12 +7,16 @@ use std::path::PathBuf; use std::process::{Child, Command, Stdio}; use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::{Arc, Mutex}; +use std::sync::{Arc, Mutex, OnceLock}; use std::time::{Duration, Instant}; use tauri::menu::{Menu, MenuItem}; +use tauri::path::BaseDirectory; use tauri::tray::TrayIconBuilder; use tauri::{AppHandle, Manager}; + +/// Sidecar tree shipped inside the GUI package (`bundle.resources` → `sidecar/`). +static BUNDLED_SIDECAR_DIR: OnceLock = OnceLock::new(); use tauri_plugin_global_shortcut::{GlobalShortcutExt, ShortcutState}; /// Must match `PROTOCOL_VERSION` in sidecar/src/config.js. A sidecar serving @@ -83,18 +87,29 @@ fn speak_clipboard() { } } +fn remember_bundled_sidecar(app: &tauri::App) { + let Ok(index) = app + .path() + .resolve("sidecar/src/index.js", BaseDirectory::Resource) + else { + return; + }; + if !index.is_file() { + return; + } + if let Some(dir) = index.parent().and_then(|src| src.parent()) { + let _ = BUNDLED_SIDECAR_DIR.set(dir.to_path_buf()); + } +} + /// Locate and spawn the sidecar. Search order: /// 1. $SAYIT_SIDECAR_DIR (dev) -/// 2. ~/.local/share/sayit/sidecar (installed by scripts/setup-sidecar.sh) +/// 2. bundled resources (GUI .deb / .rpm) +/// 3. ~/.local/share/sayit/sidecar (CLI install.sh) fn spawn_sidecar() -> Option { - let dir = std::env::var("SAYIT_SIDECAR_DIR") - .map(PathBuf::from) - .ok() - .filter(|p| p.join("src/index.js").exists()) - .or_else(|| { - let p = data_dir().join("sidecar"); - p.join("src/index.js").exists().then_some(p) - })?; + let dir = sidecar_dirs() + .into_iter() + .find(|p| p.join("src/index.js").is_file())?; let node = std::env::var("SAYIT_NODE").unwrap_or_else(|_| "node".to_string()); let log_path = dirs::cache_dir() @@ -165,6 +180,9 @@ fn sidecar_dirs() -> Vec { if let Ok(dir) = std::env::var("SAYIT_SIDECAR_DIR") { dirs.push(PathBuf::from(dir)); } + if let Some(dir) = BUNDLED_SIDECAR_DIR.get() { + dirs.push(dir.clone()); + } dirs.push(data_dir().join("sidecar")); dirs } @@ -356,16 +374,6 @@ fn main() { let sidecar = Arc::new(Mutex::new(None::)); let shutdown = Arc::new(AtomicBool::new(false)); - // Short startup wait: the watcher retries with a longer budget, so a slow - // or unattributable recovery must not delay the window here. - recover_sidecar(&sidecar, Duration::from_secs(5)); - - std::thread::spawn({ - let sidecar = Arc::clone(&sidecar); - let shutdown = Arc::clone(&shutdown); - move || recovery_watcher(sidecar, shutdown) - }); - tauri::Builder::default() .plugin( tauri_plugin_global_shortcut::Builder::new() @@ -377,33 +385,47 @@ fn main() { }) .build(), ) - .setup(|app| { - app.global_shortcut() - .register("Ctrl+Alt+V".parse::()?)?; - - let show = MenuItem::with_id(app, "show", "Show Say It", true, None::<&str>)?; - let quit = MenuItem::with_id(app, "quit", "Quit", true, None::<&str>)?; - let menu = Menu::with_items(app, &[&show, &quit])?; - - TrayIconBuilder::new() - .menu(&menu) - .tooltip("Say It") - .icon(app.default_window_icon().cloned().unwrap_or_else(|| { - tauri::image::Image::new_owned(vec![0u8; 4], 1, 1) - })) - .on_menu_event(|app: &AppHandle, event| match event.id.as_ref() { - "show" => { - if let Some(win) = app.get_webview_window("main") { - let _ = win.show(); - let _ = win.set_focus(); + .setup({ + let sidecar = Arc::clone(&sidecar); + let shutdown = Arc::clone(&shutdown); + move |app| { + remember_bundled_sidecar(app); + // Short wait: the watcher retries with a longer budget, so a slow + // or unattributable recovery must not delay the window here. + recover_sidecar(&sidecar, Duration::from_secs(5)); + std::thread::spawn({ + let sidecar = Arc::clone(&sidecar); + let shutdown = Arc::clone(&shutdown); + move || recovery_watcher(sidecar, shutdown) + }); + + app.global_shortcut() + .register("Ctrl+Alt+V".parse::()?)?; + + let show = MenuItem::with_id(app, "show", "Show Say It", true, None::<&str>)?; + let quit = MenuItem::with_id(app, "quit", "Quit", true, None::<&str>)?; + let menu = Menu::with_items(app, &[&show, &quit])?; + + TrayIconBuilder::new() + .menu(&menu) + .tooltip("Say It") + .icon(app.default_window_icon().cloned().unwrap_or_else(|| { + tauri::image::Image::new_owned(vec![0u8; 4], 1, 1) + })) + .on_menu_event(|app: &AppHandle, event| match event.id.as_ref() { + "show" => { + if let Some(win) = app.get_webview_window("main") { + let _ = win.show(); + let _ = win.set_focus(); + } } - } - "quit" => app.exit(0), - _ => {} - }) - .build(app)?; + "quit" => app.exit(0), + _ => {} + }) + .build(app)?; - Ok(()) + Ok(()) + } }) .invoke_handler(tauri::generate_handler![get_token]) .build(tauri::generate_context!()) diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index ce0cb72..2b326e5 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,13 +1,15 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "SayIt", + "mainBinaryName": "sayit-desktop", "version": "0.1.0", "identifier": "dev.sayit.linux", "build": { "frontendDist": "../app/build", "devUrl": "http://localhost:1420", "beforeDevCommand": "npm --prefix app run dev", - "beforeBuildCommand": "npm --prefix app run build" + "beforeBuildCommand": "node -e \"require('fs').mkdirSync('src-tauri/sidecar-bundle',{recursive:true})\" && npm --prefix app run build", + "beforeBundleCommand": "sh scripts/prepare-sidecar-bundle.sh" }, "app": { "withGlobalTauri": true, @@ -28,8 +30,10 @@ }, "bundle": { "active": true, - "targets": ["deb", "appimage"], + "targets": ["deb"], "category": "Utility", + "homepage": "https://github.com/ildella/sayit", + "licenseFile": "../LICENSE", "shortDescription": "Private, local text-to-speech", "longDescription": "Say It turns copied text into speech with open models running locally.", "icon": [ @@ -38,6 +42,9 @@ "icons/256x256.png", "icons/icon.png", "icons/icon.ico" - ] + ], + "resources": { + "sidecar-bundle": "sidecar" + } } }