diff --git a/LifeOS/install/LIFEOS/DOCUMENTATION/Notifications/KokoroVoiceBackend.md b/LifeOS/install/LIFEOS/DOCUMENTATION/Notifications/KokoroVoiceBackend.md new file mode 100644 index 0000000000..6e9a27cfb9 --- /dev/null +++ b/LifeOS/install/LIFEOS/DOCUMENTATION/Notifications/KokoroVoiceBackend.md @@ -0,0 +1,104 @@ +# 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/hexgrad/kokoro) +via [kokoro-js](https://www.npmjs.com/package/kokoro-js): 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. + +The footprint is deliberately small: no Python, no resident service. One opt-in +npm package (`kokoro-js`, TypeScript/ONNX), a daemon that is lazy-started on the +first utterance and exits itself when idle, and a ~90MB model fetched once to a +local cache. With `LIFEOS_VOICE_BACKEND` unset, nothing is installed, spawned, +or changed. + +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 local daemon +(`kokoro_daemon.ts`, Bun/TypeScript) that synthesizes + plays the audio locally, +returning `200` on completion. `voice.ts` **lazy-starts the daemon on the first +utterance** and the daemon **exits itself after 10 idle minutes** +(`LIFEOS_KOKORO_IDLE_SECONDS`), so nothing stays resident and no LaunchAgent or +systemd unit is needed. If synthesis fails the error is logged - voice fails +safe, everything else keeps working. + +## Setup + +1. **Install the one opt-in dependency** (kept out of Pulse's default install): + ```bash + cd ~/.claude/LIFEOS/PULSE && bun add kokoro-js + ``` + The Kokoro model (~90MB, quantized ONNX) downloads automatically on the first + utterance into `$KOKORO_CACHE` (default `~/.cache/lifeos-voice`), so the very + first spoken notification takes longer. +2. **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. + +You can also run the daemon manually to try it or pre-download the model: +```bash +bun ~/.claude/LIFEOS/PULSE/VoiceServer/kokoro_daemon.ts +# GET /health → "ok" POST /speak {"text":"hello"} → speaks +``` + +> Audio plays via `afplay` (macOS) by default. On Linux, set +> `LIFEOS_KOKORO_PLAYER=aplay` (or `paplay`) in the Pulse environment. +> `LIFEOS_KOKORO_IDLE_SECONDS=0` disables the idle exit if you prefer keeping +> the model warm indefinitely. + +## 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//.claude/LIFEOS/TOOLS/voice-mute-toggle.sh +``` + +`voice-mute-toggle.sh` is a dependency-free bash flip of the same state file, +made for hotkey daemons: skhd runs with a minimal `PATH` (no `~/.bun` or brew), +so pointing it at bun tends to break. `VoiceMute.ts` remains the richer CLI. + +Gotchas worth knowing: +- **Use absolute paths** - even for the shell script, for the same `PATH` reason. +- **`~/.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 fbba57e164..5a5aac8ce7 100755 --- a/LifeOS/install/LIFEOS/LIFEOS_StatusLine.sh +++ b/LifeOS/install/LIFEOS/LIFEOS_StatusLine.sh @@ -292,6 +292,13 @@ fi # DA name comes from the DA_NAME env var (harness-exported), default Assistant 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}" @@ -1632,7 +1639,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 @@ -1674,13 +1681,13 @@ _hdr_loc_plain="${_hdr_loc_plain}${location_city}" _hdr_ascent="" [ -n "$ascent_chip" ] && _hdr_ascent=" ${SLATE_600}│${RESET} ${ascent_chip}" 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}${_hdr_ascent}\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}${_hdr_ascent}\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_daemon.ts b/LifeOS/install/LIFEOS/PULSE/VoiceServer/kokoro_daemon.ts new file mode 100644 index 0000000000..f9b0822244 --- /dev/null +++ b/LifeOS/install/LIFEOS/PULSE/VoiceServer/kokoro_daemon.ts @@ -0,0 +1,121 @@ +#!/usr/bin/env bun +/** + * Kokoro TTS daemon — a fully-local, private voice backend for LifeOS. + * + * The private alternative to the cloud ElevenLabs path: no API key, and no text + * or audio ever leaves the machine. Loads the Kokoro ONNX model once (via + * kokoro-js) and serves synthesis over localhost so utterances skip cold-start. + * + * GET /health -> "ok" + * POST /speak {text, voice?, speed?} -> synthesize + play; 200 on completion + * + * Not a resident service: voice.ts lazy-spawns this on the first utterance and + * the daemon exits on its own after LIFEOS_KOKORO_IDLE_SECONDS (default 600) + * without a request. No LaunchAgent, no Python — `bun add kokoro-js` in + * PULSE/ is the only extra dependency, and the model (~90MB) is fetched to + * KOKORO_CACHE on first synthesis. Setup: + * DOCUMENTATION/Notifications/KokoroVoiceBackend.md. + */ +import { tmpdir } from "node:os" +import { join } from "node:path" +import { unlink } from "node:fs/promises" + +const PORT = Number(process.env.LIFEOS_KOKORO_PORT || "7791") +const DEFAULT_VOICE = process.env.LIFEOS_KOKORO_VOICE || "af_bella" +// Audio player, env-overridable for non-macOS (e.g. "aplay" / "paplay" on Linux). +const PLAYER = (process.env.LIFEOS_KOKORO_PLAYER || "afplay").split(" ") +const IDLE_SECONDS = Number(process.env.LIFEOS_KOKORO_IDLE_SECONDS || "600") +const CACHE = process.env.KOKORO_CACHE || join(process.env.HOME ?? "", ".cache", "lifeos-voice") +const MODEL_ID = process.env.LIFEOS_KOKORO_MODEL || "onnx-community/Kokoro-82M-v1.0-ONNX" + +// ── Idle shutdown ── +// The daemon owns its own lifetime: any request resets the timer; expiry exits +// cleanly and the next utterance lazy-spawns a fresh one. Nothing stays resident. +let idleTimer: ReturnType | undefined +function touchIdle(): void { + if (idleTimer) clearTimeout(idleTimer) + if (IDLE_SECONDS > 0) { + idleTimer = setTimeout(() => { + console.log(`[kokoro-daemon] idle ${IDLE_SECONDS}s, exiting`) + process.exit(0) + }, IDLE_SECONDS * 1000) + } +} + +// ── Model ── +// Dynamic import keeps kokoro-js out of PULSE's install footprint: it is only +// required (and its ONNX runtime only loaded) when someone opts into this backend. +let ttsPromise: Promise | undefined +async function getTTS(): Promise { + ttsPromise ??= (async () => { + let KokoroTTS: any + let hfEnv: any + try { + ;({ KokoroTTS } = await import("kokoro-js")) + ;({ env: hfEnv } = await import("@huggingface/transformers")) + } catch { + throw new Error("kokoro-js not installed — run `bun add kokoro-js` in LIFEOS/PULSE/") + } + console.log("[kokoro-daemon] loading model (one time)...") + // Cache outside node_modules (transformers.js's default), where a reinstall + // would silently re-download the ~90MB model. kokoro-js doesn't forward + // cache_dir, so set it on the transformers.js env it uses. + hfEnv.cacheDir = CACHE + const tts = await KokoroTTS.from_pretrained(MODEL_ID, { dtype: "q8" }) + console.log("[kokoro-daemon] model warm") + return tts + })() + return ttsPromise +} + +// ── Serialized synth + playback ── +// One utterance at a time: overlapping notifications queue instead of talking +// over each other. The chain also serializes model access. +let queue: Promise = Promise.resolve() +function speak(text: string, voice: string, speed: number): Promise { + const job = queue.catch(() => {}).then(async () => { + const tts = await getTTS() + const audio = await tts.generate(text, { voice, speed }) + const out = join(tmpdir(), `lifeos-kokoro-${Date.now()}-${process.pid}.wav`) + try { + await audio.save(out) + const proc = Bun.spawn([...PLAYER, out], { stdout: "ignore", stderr: "ignore" }) + if ((await proc.exited) !== 0) throw new Error(`audio player exited ${proc.exited}`) + } finally { + await unlink(out).catch(() => {}) + } + }) + queue = job + return job +} + +touchIdle() +Bun.serve({ + hostname: "127.0.0.1", + port: PORT, + // Playback of a queued long utterance can exceed Bun's default 10s request timeout. + idleTimeout: 120, + async fetch(req: Request): Promise { + touchIdle() + const { pathname } = new URL(req.url) + if (req.method === "GET" && pathname === "/health") return new Response("ok") + if (req.method === "POST" && pathname === "/speak") { + try { + const payload = (await req.json().catch(() => ({}))) as { + text?: string + voice?: string + speed?: number + } + const text = (payload.text ?? "").trim() + if (!text) return new Response("no text", { status: 400 }) + await speak(text, payload.voice || DEFAULT_VOICE, Number(payload.speed) || 1.0) + touchIdle() // count idle from playback end, not request start + return new Response("ok") + } catch (err) { + return new Response(err instanceof Error ? err.message : String(err), { status: 500 }) + } + } + return new Response("not found", { status: 404 }) + }, +}) +console.log(`[kokoro-daemon] listening on 127.0.0.1:${PORT} (idle-exit ${IDLE_SECONDS}s)`) diff --git a/LifeOS/install/LIFEOS/PULSE/VoiceServer/voice.ts b/LifeOS/install/LIFEOS/PULSE/VoiceServer/voice.ts index 0f0d2d3df3..07197c0f0a 100644 --- a/LifeOS/install/LIFEOS/PULSE/VoiceServer/voice.ts +++ b/LifeOS/install/LIFEOS/PULSE/VoiceServer/voice.ts @@ -20,6 +20,68 @@ import { log } from "../lib" import { disambiguateHomographs } from "../lib/homographs" import { homedir } from "node:os"; +// ── 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 +// Kokoro daemon on localhost that synthesizes + plays the audio and returns 200 on +// completion. The daemon is not a resident service: it is lazy-spawned here on the +// first utterance and exits itself after an idle timeout (see kokoro_daemon.ts). +// See DOCUMENTATION/Notifications/KokoroVoiceBackend.md for setup. +const KOKORO_SPAWN_TIMEOUT_MS = 30_000 // first-ever run also downloads the model, so be generous + +async function kokoroSpeakOnce(message: string, port: string): Promise { + const voiceName = process.env.LIFEOS_KOKORO_VOICE || "af_bella" + return fetch(`http://127.0.0.1:${port}/speak`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ text: message, voice: voiceName }), + }) +} + +async function ensureKokoroDaemon(port: string): Promise { + const health = `http://127.0.0.1:${port}/health` + if (await fetch(health).then((r) => r.ok).catch(() => false)) return + const daemonPath = join(import.meta.dir, "kokoro_daemon.ts") + const child = spawn(process.execPath, [daemonPath], { + detached: true, + stdio: "ignore", + env: process.env, + }) + child.unref() + const deadline = Date.now() + KOKORO_SPAWN_TIMEOUT_MS + while (Date.now() < deadline) { + if (await fetch(health).then((r) => r.ok).catch(() => false)) return + await new Promise((resolve) => setTimeout(resolve, 250)) + } + throw new Error("kokoro daemon did not become healthy (is kokoro-js installed? see KokoroVoiceBackend.md)") +} + +async function playKokoroVoice(message: string): Promise { + const port = process.env.LIFEOS_KOKORO_PORT || "7791" + let res = await kokoroSpeakOnce(message, port).catch(() => undefined) + if (!res) { + // Daemon not running — lazy-start it, then retry the utterance once. + await ensureKokoroDaemon(port) + res = await kokoroSpeakOnce(message, port) + } + if (!res.ok) throw new Error(`kokoro daemon returned ${res.status}: ${await res.text().catch(() => "")}`) +} + // ── Public Config Interface ── export interface VoiceConfig { @@ -528,7 +590,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) diff --git a/LifeOS/install/LIFEOS/TOOLS/voice-mute-toggle.sh b/LifeOS/install/LIFEOS/TOOLS/voice-mute-toggle.sh new file mode 100755 index 0000000000..ab3ef90218 --- /dev/null +++ b/LifeOS/install/LIFEOS/TOOLS/voice-mute-toggle.sh @@ -0,0 +1,14 @@ +#!/bin/bash +# Voice mute toggle — pure-bash flip of the canonical mute state file. +# Made for hotkey daemons (skhd, Keyboard Maestro, Stream Deck): they run with a +# minimal PATH, so this needs no bun/node and touches only the state file that +# voice.ts reads on every notification and the statusline renders as 🔊/🔇. +# TOOLS/VoiceMute.ts remains the richer CLI (on|off|toggle|status). +STATE_DIR="$HOME/.claude/LIFEOS/PULSE/state" +STATE_FILE="$STATE_DIR/voice-mute.json" +mkdir -p "$STATE_DIR" +if grep -q '"muted"[[:space:]]*:[[:space:]]*true' "$STATE_FILE" 2>/dev/null; then + printf '{"muted": false}\n' > "$STATE_FILE" +else + printf '{"muted": true}\n' > "$STATE_FILE" +fi