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
Original file line number Diff line number Diff line change
@@ -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/<you>/.bun/bin/bun /Users/<you>/.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.
13 changes: 10 additions & 3 deletions LifeOS/install/LIFEOS/LIFEOS_StatusLine.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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}"

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"

Expand Down
92 changes: 92 additions & 0 deletions LifeOS/install/LIFEOS/PULSE/VoiceServer/kokoro_server.py
Original file line number Diff line number Diff line change
@@ -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()
47 changes: 46 additions & 1 deletion LifeOS/install/LIFEOS/PULSE/VoiceServer/voice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
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 {
Expand Down Expand Up @@ -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

Expand Down
69 changes: 69 additions & 0 deletions LifeOS/install/LIFEOS/TOOLS/VoiceMute.ts
Original file line number Diff line number Diff line change
@@ -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)