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

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

Expand Down
121 changes: 121 additions & 0 deletions LifeOS/install/LIFEOS/PULSE/VoiceServer/kokoro_daemon.ts
Original file line number Diff line number Diff line change
@@ -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<typeof setTimeout> | 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<any> | undefined
async function getTTS(): Promise<any> {
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<void> = Promise.resolve()
function speak(text: string, voice: string, speed: number): Promise<void> {
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<Response> {
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)`)
78 changes: 77 additions & 1 deletion LifeOS/install/LIFEOS/PULSE/VoiceServer/voice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Response> {
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<void> {
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<void> {
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 {
Expand Down Expand Up @@ -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

Expand Down
Loading
Loading