From 208819b75418e299fab991f247028ff13829fe2d Mon Sep 17 00:00:00 2001 From: HotSauceHacker Date: Sat, 11 Jul 2026 16:07:12 -0400 Subject: [PATCH] feat(voice): add Kokoro local-TTS backend, live mute toggle, statusline indicator Adds a fully-local voice backend (Kokoro) as a private alternative to the cloud ElevenLabs path: no API key, and no text or audio leaves the machine. Opt-in via PAI_VOICE_BACKEND=kokoro; ElevenLabs behavior is unchanged when unset. - voice.ts: playKokoroVoice() POSTs to a warm local daemon, selected ahead of ElevenLabs. Adds a live mute gate (voice-mute.json) read per notification that silences TTS only; desktop notifications still fire; fail-open. - kokoro_server.py: warm Kokoro daemon (GET /health, POST /speak); audio player is env-configurable (PAI_KOKORO_PLAYER) for non-macOS. - TOOLS/VoiceMute.ts: on/off/toggle/status CLI, bindable to a keyboard shortcut. - LIFEOS_StatusLine.sh: mute glyph read live from the same state file. - DOCUMENTATION/Notifications/KokoroVoiceBackend.md: setup and hotkey guide. --- .../Notifications/KokoroVoiceBackend.md | 87 ++++++++++++++++++ LifeOS/install/LIFEOS/LIFEOS_StatusLine.sh | 13 ++- .../LIFEOS/PULSE/VoiceServer/kokoro_server.py | 92 +++++++++++++++++++ .../install/LIFEOS/PULSE/VoiceServer/voice.ts | 47 +++++++++- LifeOS/install/LIFEOS/TOOLS/VoiceMute.ts | 69 ++++++++++++++ 5 files changed, 304 insertions(+), 4 deletions(-) create mode 100644 LifeOS/install/LIFEOS/DOCUMENTATION/Notifications/KokoroVoiceBackend.md create mode 100644 LifeOS/install/LIFEOS/PULSE/VoiceServer/kokoro_server.py create mode 100644 LifeOS/install/LIFEOS/TOOLS/VoiceMute.ts diff --git a/LifeOS/install/LIFEOS/DOCUMENTATION/Notifications/KokoroVoiceBackend.md b/LifeOS/install/LIFEOS/DOCUMENTATION/Notifications/KokoroVoiceBackend.md new file mode 100644 index 0000000000..49c1282201 --- /dev/null +++ b/LifeOS/install/LIFEOS/DOCUMENTATION/Notifications/KokoroVoiceBackend.md @@ -0,0 +1,87 @@ +# Kokoro Voice Backend - private, local TTS (alternative to ElevenLabs) + +LifeOS voice (`PULSE/VoiceServer/voice.ts`) ships with an ElevenLabs backend, +which sends the text of every notification to a cloud API and needs an API key. +This adds a **fully-local** alternative powered by [Kokoro](https://github.com/thewh1teagent/kokoro-onnx): +no API key, and no text or audio ever leaves the machine - a better fit for the +privacy posture many people want from a personal AI. + +It also adds two small quality-of-life pieces built on the same state file: a +**live mute toggle** (bindable to a keyboard shortcut) and a **statusline +indicator** (🔊 / 🔇). + +--- + +## How it works + +`voice.ts` selects the backend from an env var, in `sendNotification()`: + +``` +LIFEOS_VOICE_BACKEND=kokoro → POST http://127.0.0.1:$LIFEOS_KOKORO_PORT/speak (local) +(unset / anything else) → ElevenLabs (unchanged; requires elevenlabs_api_key) +``` + +The Kokoro path POSTs `{ text, voice }` to a small warm daemon +(`kokoro_server.py`) that keeps the model resident and synthesizes + plays the +audio locally, returning `200` on completion. If the daemon is down the request +errors and is logged - voice fails safe, everything else keeps working. + +## Setup + +1. **Install the Python deps and model files:** + ```bash + pip install kokoro-onnx soundfile + # Download the model into $KOKORO_CACHE (default ~/.cache/lifeos-voice): + # kokoro-v0_19.onnx and voices.bin (see the kokoro-onnx repo) + ``` +2. **Run the daemon** (keep it resident - a LaunchAgent on macOS, or a systemd + user unit on Linux): + ```bash + python3 ~/.claude/LIFEOS/PULSE/VoiceServer/kokoro_server.py + # GET /health → "ok" POST /speak {"text":"hello"} → speaks + ``` +3. **Point LifeOS at it** by setting these in the Pulse process environment + (e.g. the `com.lifeos.pulse` LaunchAgent's `EnvironmentVariables`, so the + running Pulse process actually sees them): + ``` + LIFEOS_VOICE_BACKEND=kokoro + LIFEOS_KOKORO_VOICE=af_bella # any Kokoro voice + LIFEOS_KOKORO_PORT=7791 + ``` + Restart Pulse. `/notify` now speaks locally. + +> Audio plays via `afplay` (macOS) by default. On Linux, set +> `LIFEOS_KOKORO_PLAYER=aplay` (or `paplay`) in the daemon's environment. + +## Live mute toggle + +`TOOLS/VoiceMute.ts` flips `PULSE/state/voice-mute.json`, which `voice.ts` reads +on **every** notification (no restart) and silences TTS while still returning +normally - desktop notifications are unaffected. + +```bash +bun ~/.claude/LIFEOS/TOOLS/VoiceMute.ts toggle # on | off | toggle | status +``` + +## Statusline indicator + +`LIFEOS_StatusLine.sh` renders a speaker glyph next to the LifeOS header, read +live from the same state file: **🔊** audible / **🔇** muted. + +## Optional: a keyboard shortcut (macOS, skhd) + +Bind the toggle to a hotkey with [skhd](https://github.com/koekeishiya/skhd): + +``` +# ~/.config/skhd/skhdrc (this path takes priority over ~/.skhdrc) +cmd + shift - m : /Users//.bun/bin/bun /Users//.claude/LIFEOS/TOOLS/VoiceMute.ts toggle +``` + +Gotchas worth knowing: +- **Use absolute paths** - skhd runs with a minimal `PATH` (no `~/.bun` or brew). +- **`~/.config/skhd/skhdrc` shadows `~/.skhdrc`** - if a hotkey seems to ignore + your edits, you're probably editing the wrong file. +- **macOS "Secure Keyboard Entry"** (a checkbox in your terminal's app menu, not + System Settings) blocks *all* hotkey daemons from capturing keys - turn it off + if the binding never fires. +- Grant skhd **Accessibility** permission on first use. diff --git a/LifeOS/install/LIFEOS/LIFEOS_StatusLine.sh b/LifeOS/install/LIFEOS/LIFEOS_StatusLine.sh index 55200c5f21..c58982b535 100755 --- a/LifeOS/install/LIFEOS/LIFEOS_StatusLine.sh +++ b/LifeOS/install/LIFEOS/LIFEOS_StatusLine.sh @@ -212,6 +212,13 @@ printf '%s' "$input" > "$LIFEOS_DIR/MEMORY/STATE/statusline-stdin-debug.json" 2> # Get DA name from settings (single source of truth) DA_NAME="${DA_NAME:-Assistant}" +# Voice mute indicator: 🔊 audible / 🔇 muted, read live from voice-mute.json (cheap grep, no jq). +VOICE_GLYPH="🔊" +if [ -f "$HOME/.claude/LIFEOS/PULSE/state/voice-mute.json" ] && \ + grep -q '"muted"[[:space:]]*:[[:space:]]*true' "$HOME/.claude/LIFEOS/PULSE/state/voice-mute.json" 2>/dev/null; then + VOICE_GLYPH="🔇" +fi + # Get user timezone from settings (for reset time display) USER_TZ="${USER_TZ:-UTC}" @@ -1136,7 +1143,7 @@ if [ "$MODE" != "normal" ]; then ;; mini) # Line 1: branding + location/time - printf "${SLATE_600}──${RESET} ${LIFEOS_A}${LIFEOS_LOGO}${RESET} ${LIFEOS_P}LI${LIFEOS_A}FE${LIFEOS_I}OS${RESET} ${SLATE_600}──${RESET} ${LIFEOS_CITY}${location_city}${RESET} ${SLATE_600}│${RESET} ${LIFEOS_TIME}${current_time}${RESET} ${SLATE_600}│${RESET} ${LIFEOS_WEATHER}${weather_str}${RESET} + printf "${SLATE_600}──${RESET} ${LIFEOS_A}${LIFEOS_LOGO}${RESET} ${LIFEOS_P}LI${LIFEOS_A}FE${LIFEOS_I}OS${RESET} ${VOICE_GLYPH} ${SLATE_600}──${RESET} ${LIFEOS_CITY}${location_city}${RESET} ${SLATE_600}│${RESET} ${LIFEOS_TIME}${current_time}${RESET} ${SLATE_600}│${RESET} ${LIFEOS_WEATHER}${weather_str}${RESET} " # Line 2: context bar (compact) _bar_w=20 @@ -1176,13 +1183,13 @@ _hdr_loc_plain="${_hdr_loc_plain}${location_city}" [ -n "$location_state" ] && _hdr_loc_plain="${_hdr_loc_plain}, ${location_state}" [ -z "$_hdr_loc_plain" ] && _hdr_loc_plain="—" if [ -n "$session_display" ]; then - printf "${LIFEOS_P}LI${LIFEOS_A}FE${LIFEOS_I}OS${RESET} ${SLATE_600}│${RESET} ${_hdr_loc} ${LIFEOS_TIME}${current_time}${RESET} ${LIFEOS_WEATHER}${weather_str}${RESET} ${SLATE_600}│${RESET} ${LIFEOS_SESSION}${session_display}${RESET}\n" + printf "${LIFEOS_P}LI${LIFEOS_A}FE${LIFEOS_I}OS${RESET} ${VOICE_GLYPH} ${SLATE_600}│${RESET} ${_hdr_loc} ${LIFEOS_TIME}${current_time}${RESET} ${LIFEOS_WEATHER}${weather_str}${RESET} ${SLATE_600}│${RESET} ${LIFEOS_SESSION}${session_display}${RESET}\n" else _hdr_left="LIFEOS │ ${_hdr_loc_plain} ${current_time} ${weather_str} " _hdr_fill=$((content_width - ${#_hdr_left})) [ "$_hdr_fill" -lt 2 ] && _hdr_fill=2 _hdr_dashes=$(_repeat_chars "$_hdr_fill" "─") - printf "${LIFEOS_P}LI${LIFEOS_A}FE${LIFEOS_I}OS${RESET} ${SLATE_600}│${RESET} ${_hdr_loc} ${LIFEOS_TIME}${current_time}${RESET} ${LIFEOS_WEATHER}${weather_str}${RESET} ${SLATE_600}${_hdr_dashes}${RESET}\n" + printf "${LIFEOS_P}LI${LIFEOS_A}FE${LIFEOS_I}OS${RESET} ${VOICE_GLYPH} ${SLATE_600}│${RESET} ${_hdr_loc} ${LIFEOS_TIME}${current_time}${RESET} ${LIFEOS_WEATHER}${weather_str}${RESET} ${SLATE_600}${_hdr_dashes}${RESET}\n" fi printf "${SLATE_600}%s${RESET}\n" "$SEP_DASHED" diff --git a/LifeOS/install/LIFEOS/PULSE/VoiceServer/kokoro_server.py b/LifeOS/install/LIFEOS/PULSE/VoiceServer/kokoro_server.py new file mode 100644 index 0000000000..c329a839aa --- /dev/null +++ b/LifeOS/install/LIFEOS/PULSE/VoiceServer/kokoro_server.py @@ -0,0 +1,92 @@ +#!/usr/bin/env python3 +"""Warm Kokoro TTS daemon - a fully-local, private voice backend for LifeOS. + +Loads the Kokoro ONNX model ONCE at startup and keeps it resident, then serves +synthesis over localhost so each utterance skips model cold-start. This is the +private alternative to the cloud ElevenLabs path: no API key, and no audio or +text ever leaves the machine. + + GET /health -> "ok" + POST /speak {text, voice?, speed?} -> synthesize + play (afplay); 200 on completion + +Single model, serialized playback (one utterance at a time) via a lock. +PULSE/VoiceServer/voice.ts POSTs here when LIFEOS_VOICE_BACKEND=kokoro. + +Setup: DOCUMENTATION/Notifications/KokoroVoiceBackend.md. +Requires: pip install kokoro-onnx soundfile; the model files +(kokoro-v0_19.onnx, voices.bin) in $KOKORO_CACHE. Plays via `afplay` (macOS) by +default; set LIFEOS_KOKORO_PLAYER (e.g. "aplay" / "paplay") on Linux. +""" +import os +import json +import tempfile +import subprocess +import threading +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +from kokoro_onnx import Kokoro +import soundfile as sf + +CACHE = os.environ.get("KOKORO_CACHE", os.path.expanduser("~/.cache/lifeos-voice")) +PORT = int(os.environ.get("LIFEOS_KOKORO_PORT", "7791")) +DEFAULT_VOICE = os.environ.get("LIFEOS_KOKORO_VOICE", "af_bella") +# Audio player, env-overridable for non-macOS (e.g. "aplay" / "paplay" on Linux). +PLAYER = os.environ.get("LIFEOS_KOKORO_PLAYER", "afplay").split() + +print("[kokoro-server] loading model (one time)...", flush=True) +KOKORO = Kokoro(f"{CACHE}/kokoro-v0_19.onnx", f"{CACHE}/voices.bin") +print(f"[kokoro-server] model warm; listening on 127.0.0.1:{PORT}", flush=True) + +_speak_lock = threading.Lock() + + +def speak(text: str, voice: str, speed: float) -> None: + with _speak_lock: # model isn't thread-safe; one synth+play at a time + samples, sr = KOKORO.create(text, voice=voice, speed=speed, lang="en-us") + with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as f: + out = f.name + try: + sf.write(out, samples, sr) + subprocess.run([*PLAYER, out], check=True) + finally: + try: + os.unlink(out) + except OSError: + pass + + +class Handler(BaseHTTPRequestHandler): + def log_message(self, *args): # silence default request logging + pass + + def _reply(self, code: int, body: bytes = b""): + self.send_response(code) + self.send_header("content-length", str(len(body))) + self.end_headers() + if body: + self.wfile.write(body) + + def do_GET(self): + self._reply(200, b"ok") if self.path == "/health" else self._reply(404) + + def do_POST(self): + if self.path != "/speak": + self._reply(404) + return + try: + n = int(self.headers.get("content-length", 0) or 0) + payload = json.loads(self.rfile.read(n) or b"{}") + text = (payload.get("text") or "").strip() + if not text: + self._reply(400, b"no text") + return + voice = payload.get("voice") or DEFAULT_VOICE + speed = float(payload.get("speed") or 1.0) + speak(text, voice, speed) + self._reply(200, b"ok") + except Exception as e: # noqa: BLE001 - report so voice.ts can handle it + self._reply(500, str(e).encode()) + + +if __name__ == "__main__": + ThreadingHTTPServer(("127.0.0.1", PORT), Handler).serve_forever() diff --git a/LifeOS/install/LIFEOS/PULSE/VoiceServer/voice.ts b/LifeOS/install/LIFEOS/PULSE/VoiceServer/voice.ts index d72402090f..2d5d7f74b7 100644 --- a/LifeOS/install/LIFEOS/PULSE/VoiceServer/voice.ts +++ b/LifeOS/install/LIFEOS/PULSE/VoiceServer/voice.ts @@ -19,6 +19,37 @@ import { existsSync, readFileSync } from "fs" import { log } from "../lib" import { disambiguateHomographs } from "../lib/homographs" +// ── Live mute gate ── +// Read on every notification so an external toggle (e.g. a keyboard shortcut) +// takes effect with no restart. Muting silences TTS audio only, desktop +// notifications still show. Fail-open: a missing/corrupt state file = not muted. +// Written by TOOLS/VoiceMute.ts; surfaced in the statusline as 🔇 / 🔊. +const VOICE_MUTE_FILE = join(process.env.HOME ?? "", ".claude", "LIFEOS", "PULSE", "state", "voice-mute.json") + +function isVoiceMuted(): boolean { + try { + return JSON.parse(readFileSync(VOICE_MUTE_FILE, "utf-8"))?.muted === true + } catch { + return false + } +} + +// ── Kokoro local-TTS backend ── +// Used when LIFEOS_VOICE_BACKEND=kokoro: a fully-local, private alternative to the +// cloud ElevenLabs path (no API key, no data leaves the machine). POSTs to a warm +// Kokoro daemon on localhost that synthesizes + plays the audio and returns 200 on +// completion. See DOCUMENTATION/Notifications/KokoroVoiceBackend.md for setup. +async function playKokoroVoice(message: string): Promise { + const voiceName = process.env.LIFEOS_KOKORO_VOICE || "af_bella" + const port = process.env.LIFEOS_KOKORO_PORT || "7791" + const res = await fetch(`http://127.0.0.1:${port}/speak`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ text: message, voice: voiceName }), + }) + if (!res.ok) throw new Error(`kokoro daemon returned ${res.status}`) +} + // ── Public Config Interface ── export interface VoiceConfig { @@ -418,7 +449,21 @@ async function sendNotification( let voicePlayed = false let voiceError: string | undefined - if (voiceEnabled && moduleConfig.elevenlabs_api_key) { + // Live mute gate: silences TTS while still returning normally so callers and + // desktop notifications are unaffected. + const muted = voiceEnabled && isVoiceMuted() + if (muted) log("info", "Voice: muted (voice-mute.json), skipping TTS") + + // Kokoro local backend takes priority when selected; ElevenLabs is the fallback. + if (voiceEnabled && !muted && process.env.LIFEOS_VOICE_BACKEND === "kokoro") { + try { + await playKokoroVoice(safeMessage) + voicePlayed = true + } catch (err) { + voiceError = err instanceof Error ? err.message : String(err) + log("error", "Voice: Kokoro backend failed", { error: voiceError }) + } + } else if (voiceEnabled && !muted && moduleConfig.elevenlabs_api_key) { try { const voice = voiceId || defaultVoiceId diff --git a/LifeOS/install/LIFEOS/TOOLS/VoiceMute.ts b/LifeOS/install/LIFEOS/TOOLS/VoiceMute.ts new file mode 100644 index 0000000000..ebb5ca16af --- /dev/null +++ b/LifeOS/install/LIFEOS/TOOLS/VoiceMute.ts @@ -0,0 +1,69 @@ +#!/usr/bin/env bun +/** + * VoiceMute.ts - toggle the DA's live voice mute. + * + * Writes ~/.claude/LIFEOS/PULSE/state/voice-mute.json, which + * PULSE/VoiceServer/voice.ts reads on every /notify call - no Pulse restart + * needed, the gate is live-read. LIFEOS_StatusLine.sh renders 🔇 / 🔊 from the + * same file. Muting silences TTS audio only; desktop notifications still show. + * + * Usage: + * bun ~/.claude/LIFEOS/TOOLS/VoiceMute.ts on # mute (silence TTS) + * bun ~/.claude/LIFEOS/TOOLS/VoiceMute.ts off # unmute + * bun ~/.claude/LIFEOS/TOOLS/VoiceMute.ts toggle # flip current state + * bun ~/.claude/LIFEOS/TOOLS/VoiceMute.ts status # print current state + * + * Bind `toggle` to a keyboard shortcut for a live mute hotkey - see + * DOCUMENTATION/Notifications/KokoroVoiceBackend.md. + * + * Exit code mirrors mute state for scripts: 0 = unmuted, 1 = muted (status only). + */ +import { readFileSync, writeFileSync, mkdirSync } from "node:fs" +import { dirname, join } from "node:path" + +const STATE_FILE = join(process.env.HOME ?? "", ".claude", "LIFEOS", "PULSE", "state", "voice-mute.json") + +function readState(): boolean { + try { + return JSON.parse(readFileSync(STATE_FILE, "utf-8"))?.muted === true + } catch { + return false + } +} + +function writeState(muted: boolean): void { + mkdirSync(dirname(STATE_FILE), { recursive: true }) + writeFileSync(STATE_FILE, JSON.stringify({ muted, updated: new Date().toISOString() }, null, 2)) +} + +function indicator(muted: boolean): string { + return muted ? "🔇 muted" : "🔊 audible" +} + +const cmd = (process.argv[2] || "status").toLowerCase() +const current = readState() + +let next: boolean +switch (cmd) { + case "on": + case "mute": + next = true + break + case "off": + case "unmute": + next = false + break + case "toggle": + next = !current + break + case "status": + console.log(indicator(current)) + process.exit(current ? 1 : 0) + default: + console.error(`Unknown command: ${cmd}. Use on | off | toggle | status.`) + process.exit(2) +} + +if (next !== current) writeState(next) +console.log(indicator(next)) +process.exit(next ? 1 : 0)