Skip to content
Merged
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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,6 @@ bench/
.clips/
.turns.json
.timeline.json
.voice-inbox.json
.media-duck-lease.json
voices/
6 changes: 4 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ Repo-local notes for Claude and Codex.
- Prompt text is redacted and held in memory only. Restart recovery persists compact request intent, turn identity, transcript location, and the Git baseline in `.turns.json`.
- Spoken claims are checked against actual local test commands and outcomes found in Claude/Codex transcripts. Diagnostics shows intent and verification metadata, never prompt or transcript bodies.
- Attributable Git diffs are translated into behavioral facts. `.timeline.json` keeps only compact outcomes for session-arc narration and `./voice recap`; it stores no prompt, reply, or source text.
- Optional Voice Inbox mode persists prepared replies in `.voice-inbox.json` without speaking them. `I'm back` drains them into one local return briefing.
- Condensing is local-only through Ollama, with `none` as the deterministic fallback.
- Replies over `max_direct_chars` (default 400) get condensed by the configured summarizer; on failure it speaks the first sentence with a short note.
- Markdown-table replies send the raw full response to the summarizer first, so the voice gets a conversational table summary instead of row-by-row noise. If summarization fails, a mostly-table reply becomes a spoken invite to look.
Expand All @@ -37,13 +38,14 @@ Repo-local notes for Claude and Codex.
- Quiet hours (`quiet_hours` in config) accept speech but keep silent, logged as `quiet_skip`.
- Empty `last_assistant_message` falls back to parsing `transcript_path`.
- Playback via `afplay` honoring `rate` and `volume` config. The daemon exits after `idle_exit_s` idle.
- Optional ducking targets Spotify and Browser/YouTube independently. Browser/YouTube is controlled by the companion Chromium extension through `GET /browser/duck`.
- Optional ducking targets Spotify and Browser/YouTube independently. Browser/YouTube is controlled by the companion Chromium extension through `GET /browser/duck`. Spotify writes `.media-duck-lease.json` before fading so startup and the watchdog can restore interrupted playback state.
- Errors land in `.voice.log` as `event: "error"`. The log self-caps around 500 lines.
- Repo earcons, intent cues, build-result sonification, adaptive brevity, pronunciation dictionaries, and privacy redaction are local defaults. A repo can override speech using `.ltbv/pronounce.json`.
- When multiple projects finish together, the daemon can speak one radio bulletin naming each project. Backchannel commands are available through `/backchannel` and the CLI.

## Endpoints
- `GET /` controller, `GET /health` (includes live Git state), `GET /doctor`, `GET /config`, `GET /voices`, `GET /engines`, `GET /log/tail?limit=N`.
- `GET /` controller, `GET /health` (includes live Git state), `GET /doctor`, `GET /config`, `GET /voices`, `GET /engines`, `GET /inbox`, `GET /log/tail?limit=N`.
- `POST /inbox/return` drains and speaks one briefing; `POST /inbox/clear` discards waiting entries.
- `POST /speak`, `POST /stop`, `POST /turn/start`, `POST /backchannel {command}`, `POST /config`, `POST /engine {name}`, `POST /bench {text, voice?}`, `POST /audition {voice}`, `POST /recondense`.
- Warm shootout on 2026-07-06, same sentence: Pocket RTF 0.160, TTFA 0.135s, synth 0.459s, duration 2.880s. Kokoro RTF 0.096, TTFA 0.420s, synth 0.420s, duration 4.375s.

Expand Down
1 change: 1 addition & 0 deletions build-packet.html
Original file line number Diff line number Diff line change
Expand Up @@ -618,6 +618,7 @@ <h1>let-there-be-voice</h1>
<div class="filter-row"><b>Config</b><span><code>GET/POST /config</code> backed by <code>config.json</code> in the repo root, hot-applied, no restart. Daemon constants become defaults.</span><span class="tag blue">Core</span></div>
<div class="filter-row"><b>Voices</b><span><code>GET /voices</code> lists the catalog; <code>POST /audition</code> speaks one fixed sentence in a chosen voice. Voice states cached so switching stays cheap.</span><span class="tag blue">Core</span></div>
<div class="filter-row"><b>Ducking</b><span>Optional Spotify fade via <code>osascript</code> plus Browser YouTube page-volume ducking through the companion Chromium extension. Each target saves its current level, fades to <code>duck_target_volume</code> before playback, and restores in <code>_finish()</code>. No global volume, no tab mute, no pause/resume.</span><span class="tag blue">Core</span></div>
<div class="filter-row"><b>Voice inbox</b><span>Optional away mode prepares and persists agent replies without playback. <code>POST /inbox/return</code> drains the queue into one concise return briefing. Spotify ducking writes a restoration lease before fading and repairs interrupted state at startup or through the watchdog.</span><span class="tag">Shipped</span></div>
<div class="filter-row"><b>Data</b><span><code>GET /log/tail</code> returns recent log entries for the histogram plus the last raw/cleaned pair for the inspector.</span><span class="tag blue">Core</span></div>
<div class="filter-row"><b>Restart</b><span><code>POST /restart</code> (2026-07-10): daemon acks, logs <code>restart_requested</code>, spawns a detached shell that sleeps <span class="num">300ms</span> then execs a fresh <code>daemon.py</code>, and the handler calls <code>os._exit(0)</code>. Picks up any code or config change without a manual kill. Controller button polls <code>/health</code> and reloads once it answers.</span><span class="tag blue">Core</span></div>
<div class="filter-row"><b>Rules</b><span>House skin: dark grey, radius 0, Inter + Share Tech Mono, compact. No framework, stdlib-only daemon side. The controller is a remote, not a brain. (Amended: controller v2 added an SSE live layer, see Locked Decisions.)</span><span class="tag red">Hard</span></div>
Expand Down
31 changes: 31 additions & 0 deletions controller.html
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,12 @@ <h1>let-there-be-voice</h1>
<button class="btn toggle" id="browser-duck-toggle">browser / youtube duck off</button>
<span class="hint">fade media during speech, restore after</span>
</div>
<div class="row-actions">
<button class="btn toggle" id="inbox-toggle">voice inbox off</button>
<button class="btn" id="return-briefing">I'm back</button>
<button class="btn" id="inbox-clear">clear</button>
<span class="hint" id="inbox-state">no updates waiting</span>
</div>
<div class="row-actions">
<button class="btn" data-backchannel="repeat">repeat</button>
<button class="btn" data-backchannel="slower">slower</button>
Expand Down Expand Up @@ -808,6 +814,8 @@ <h1>let-there-be-voice</h1>
$("duck-toggle").textContent = cfg.ducking_enabled ? "spotify duck on" : "spotify duck off";
$("browser-duck-toggle").classList.toggle("on", !!cfg.browser_youtube_ducking_enabled);
$("browser-duck-toggle").textContent = cfg.browser_youtube_ducking_enabled ? "browser / youtube duck on" : "browser / youtube duck off";
$("inbox-toggle").classList.toggle("on", !!cfg.voice_inbox_enabled);
$("inbox-toggle").textContent = cfg.voice_inbox_enabled ? "voice inbox on" : "voice inbox off";
}
function attachScrub(el, k) {
let x0 = 0, v0 = 0, live = null, moved = false, pid = null;
Expand Down Expand Up @@ -1231,6 +1239,27 @@ <h1>let-there-be-voice</h1>
renderDucking();
pushConfig({ browser_youtube_ducking_enabled: cfg.browser_youtube_ducking_enabled }, "saved-desk", "err-desk");
};
async function refreshInbox() {
try {
const data = await api("/inbox");
$("inbox-state").textContent = data.count
? `${data.count} update${data.count === 1 ? "" : "s"} waiting`
: "no updates waiting";
} catch { $("inbox-state").textContent = "inbox unavailable"; }
}
$("inbox-toggle").onclick = () => {
cfg.voice_inbox_enabled = !cfg.voice_inbox_enabled;
renderDucking();
pushConfig({ voice_inbox_enabled: cfg.voice_inbox_enabled }, "saved-desk", "err-desk");
};
$("return-briefing").onclick = async () => {
await api("/inbox/return", {});
refreshInbox();
};
$("inbox-clear").onclick = async () => {
await api("/inbox/clear", {});
refreshInbox();
};
for (const button of document.querySelectorAll("[data-backchannel]")) {
button.onclick = async () => {
button.disabled = true;
Expand Down Expand Up @@ -1305,6 +1334,7 @@ <h1>let-there-be-voice</h1>
if (ev.event === "now") { setNow(ev.on ? ev : null); return; }
if (ev.event === "wave") { startStage(ev.peaks, ev.duration); return; }
if (ev.event === "mute") { setMuteState(!!ev.muted); return; }
if (ev.event === "inbox") { refreshInbox(); return; }
if (ev.event === "config") {
api("/config").then((c) => {
cfg = c.config;
Expand Down Expand Up @@ -1337,6 +1367,7 @@ <h1>let-there-be-voice</h1>
drawHisto(); renderLedger();
if (t.stages) renderInspector(t.stages);
await pollHealth();
await refreshInbox();
setInterval(pollHealth, 10000);
connectSSE();
addEventListener("resize", drawHisto);
Expand Down
Loading
Loading