From f7adf234a9655cc1a48caaebe0c32f009719c174 Mon Sep 17 00:00:00 2001 From: arshlibruh-code Date: Mon, 13 Jul 2026 03:27:26 +0530 Subject: [PATCH] feat: add voice inbox and return briefing --- .gitignore | 2 + CLAUDE.md | 6 +- build-packet.html | 1 + controller.html | 31 +++++++ daemon.py | 197 +++++++++++++++++++++++++++++++++++++++- field-guide.html | 3 +- smoke.sh | 18 ++++ test_daemon_contract.py | 54 +++++++++++ 8 files changed, 304 insertions(+), 8 deletions(-) diff --git a/.gitignore b/.gitignore index cfcb35c..554c7c0 100644 --- a/.gitignore +++ b/.gitignore @@ -10,4 +10,6 @@ bench/ .clips/ .turns.json .timeline.json +.voice-inbox.json +.media-duck-lease.json voices/ diff --git a/CLAUDE.md b/CLAUDE.md index 1b55085..af315de 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. @@ -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. diff --git a/build-packet.html b/build-packet.html index 77408ca..fb2beb2 100644 --- a/build-packet.html +++ b/build-packet.html @@ -618,6 +618,7 @@

let-there-be-voice

ConfigGET/POST /config backed by config.json in the repo root, hot-applied, no restart. Daemon constants become defaults.Core
VoicesGET /voices lists the catalog; POST /audition speaks one fixed sentence in a chosen voice. Voice states cached so switching stays cheap.Core
DuckingOptional Spotify fade via osascript plus Browser YouTube page-volume ducking through the companion Chromium extension. Each target saves its current level, fades to duck_target_volume before playback, and restores in _finish(). No global volume, no tab mute, no pause/resume.Core
+
Voice inboxOptional away mode prepares and persists agent replies without playback. POST /inbox/return 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.Shipped
DataGET /log/tail returns recent log entries for the histogram plus the last raw/cleaned pair for the inspector.Core
RestartPOST /restart (2026-07-10): daemon acks, logs restart_requested, spawns a detached shell that sleeps 300ms then execs a fresh daemon.py, and the handler calls os._exit(0). Picks up any code or config change without a manual kill. Controller button polls /health and reloads once it answers.Core
RulesHouse 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.)Hard
diff --git a/controller.html b/controller.html index 4814896..4fb667b 100644 --- a/controller.html +++ b/controller.html @@ -334,6 +334,12 @@

let-there-be-voice

fade media during speech, restore after +
+ + + + no updates waiting +
@@ -808,6 +814,8 @@

let-there-be-voice

$("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; @@ -1231,6 +1239,27 @@

let-there-be-voice

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; @@ -1305,6 +1334,7 @@

let-there-be-voice

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; @@ -1337,6 +1367,7 @@

let-there-be-voice

drawHisto(); renderLedger(); if (t.stages) renderInspector(t.stages); await pollHealth(); + await refreshInbox(); setInterval(pollHealth, 10000); connectSSE(); addEventListener("resize", drawHisto); diff --git a/daemon.py b/daemon.py index 051e92a..f13b05c 100644 --- a/daemon.py +++ b/daemon.py @@ -51,6 +51,8 @@ CONFIG_PATH = ROOT / "config.json" TURN_STATE_PATH = ROOT / ".turns.json" TIMELINE_PATH = ROOT / ".timeline.json" +INBOX_PATH = ROOT / ".voice-inbox.json" +DUCK_LEASE_PATH = ROOT / ".media-duck-lease.json" CONTROLLER_PATH = ROOT / "controller.html" RING_DIR = ROOT / ".clips" VOICES_DIR = ROOT / "voices" @@ -319,6 +321,7 @@ def all_voices() -> list: "privacy_sentinel_enabled": {"default": True, "kind": "bool"}, "radio_bulletins_enabled": {"default": True, "kind": "bool"}, "radio_batch_window_ms": {"default": 450, "kind": "int", "min": 0, "max": 3000}, + "voice_inbox_enabled": {"default": False, "kind": "bool"}, "duck_target_volume": {"default": 25, "kind": "int", "min": 0, "max": 100}, "duck_fade_ms": {"default": 400, "kind": "int", "min": 0, "max": 3000}, "duck_restore_delay_ms": {"default": 150, "kind": "int", "min": 0, "max": 3000}, @@ -484,6 +487,7 @@ def doctor_snapshot() -> dict: recent_projects = {} turn_records = {} timeline_events = [] +voice_inbox = [] prefix_counter = 0 clip_ring = OrderedDict() @@ -645,9 +649,87 @@ def recap_text(cwd: str | None = None) -> str: prefix += facts[-1].rstrip(".") + "." return trim_to_words(prefix, 38) + +def load_voice_inbox() -> None: + global voice_inbox + try: + raw = json.loads(INBOX_PATH.read_text()) if INBOX_PATH.exists() else [] + voice_inbox = raw[-100:] if isinstance(raw, list) else [] + except Exception: + voice_inbox = [] + append_log("inbox_error", action="load") + + +def save_voice_inbox() -> None: + try: + INBOX_PATH.write_text(json.dumps(voice_inbox[-100:], indent=2) + "\n") + except Exception: + append_log("inbox_error", action="save") + + +def inbox_add(cwd: str | None, text: str, meta: dict, intent: str) -> None: + entry = { + "ts": round(time.time(), 3), + "cwd": cwd, + "project": project_name(cwd) or "project", + "text": trim_to_words(text, 42), + "intent": intent, + "verification": meta.get("verification") or "unknown", + "tests": meta.get("verification_tests") or [], + "semantic": meta.get("semantic") or [], + } + with state_lock: + voice_inbox.append(entry) + del voice_inbox[:-100] + save_voice_inbox() + append_log("inbox_add", project=entry["project"], intent=intent) + publish({"event": "inbox", "count": len(voice_inbox)}) + + +def inbox_briefing(entries: list[dict]) -> str: + if not entries: + return "You're caught up. No agent updates are waiting." + latest_by_project = {} + for entry in entries: + latest_by_project[entry.get("project") or "project"] = entry + ordered = sorted( + latest_by_project.values(), + key=lambda item: item.get("intent") not in {"blocker", "needs_input", "warning"}, + ) + clauses = [] + for entry in ordered[:5]: + text = str(entry.get("text") or "update ready").strip().rstrip(".") + clauses.append(f"{entry.get('project') or 'project'}: {text}") + opening = f"{len(entries)} update{'s' if len(entries) != 1 else ''} while you were away. " + return trim_to_words(opening + ". ".join(clauses) + ".", 72) + + +def drain_voice_inbox() -> tuple[str, int]: + with state_lock: + entries = list(voice_inbox) + voice_inbox.clear() + save_voice_inbox() + text = inbox_briefing(entries) + append_log( + "return_briefing", + count=len(entries), + projects=sorted({e.get("project") for e in entries if e.get("project")}), + ) + publish({"event": "inbox", "count": 0}) + return text, len(entries) + + +def should_hold_for_inbox(kind: str, prepared: bool = False) -> bool: + return bool(config.get("voice_inbox_enabled") and kind == "reply" and not prepared) + config = {key: spec["default"] for key, spec in CONFIG_SPEC.items()} duck_state = { - "spotify": {"did_duck": False, "saved_volume": None}, + "spotify": { + "did_duck": False, + "saved_volume": None, + "ducked_at": None, + "restoring": False, + }, "browser_youtube": {"active": False, "generation": 0}, } duck_lock = threading.Lock() @@ -1267,9 +1349,19 @@ def spotify_duck_on() -> None: current = spotify_volume() if current is None: return - target = int(config["duck_target_volume"]) + target = min(current, int(config["duck_target_volume"])) + if target == current: + return duck_state["spotify"]["saved_volume"] = current duck_state["spotify"]["did_duck"] = True + duck_state["spotify"]["ducked_at"] = time.monotonic() + duck_state["spotify"]["restoring"] = False + try: + DUCK_LEASE_PATH.write_text( + json.dumps({"app": "Spotify", "saved_volume": current, "ts": time.time()}) + "\n" + ) + except OSError: + append_log("duck_error", detail="could not persist media restoration lease") fade_spotify_volume(current, target, int(config["duck_fade_ms"])) append_log("duck_on", app="Spotify", from_volume=current, to_volume=target) @@ -1278,22 +1370,86 @@ def spotify_duck_off() -> None: with duck_lock: did_duck = bool(duck_state["spotify"]["did_duck"]) saved = duck_state["spotify"]["saved_volume"] - duck_state["spotify"]["did_duck"] = False - duck_state["spotify"]["saved_volume"] = None + if duck_state["spotify"].get("restoring"): + return + if did_duck and saved is not None: + duck_state["spotify"]["restoring"] = True if not did_duck or saved is None: return delay = int(config["duck_restore_delay_ms"]) / 1000 if delay: time.sleep(delay) if not spotify_running(): + with duck_lock: + duck_state["spotify"]["restoring"] = False return current = spotify_volume() if current is None: + with duck_lock: + duck_state["spotify"]["restoring"] = False return fade_spotify_volume(current, int(saved), int(config["duck_fade_ms"])) + with duck_lock: + duck_state["spotify"]["did_duck"] = False + duck_state["spotify"]["saved_volume"] = None + duck_state["spotify"]["ducked_at"] = None + duck_state["spotify"]["restoring"] = False + DUCK_LEASE_PATH.unlink(missing_ok=True) append_log("duck_off", app="Spotify", from_volume=current, to_volume=int(saved)) +def recover_media_state() -> bool: + if not DUCK_LEASE_PATH.exists(): + return False + try: + lease = json.loads(DUCK_LEASE_PATH.read_text()) + saved = int(lease["saved_volume"]) + except Exception: + append_log("duck_error", detail="invalid media restoration lease") + DUCK_LEASE_PATH.unlink(missing_ok=True) + return False + with duck_lock: + duck_state["spotify"].update( + { + "did_duck": True, + "saved_volume": saved, + "ducked_at": time.monotonic() - 6, + "restoring": False, + } + ) + if not spotify_running(): + return False + current = spotify_volume() + if current is None: + return False + fade_spotify_volume(current, saved, int(config["duck_fade_ms"])) + with duck_lock: + duck_state["spotify"].update( + { + "did_duck": False, + "saved_volume": None, + "ducked_at": None, + "restoring": False, + } + ) + DUCK_LEASE_PATH.unlink(missing_ok=True) + append_log("duck_recovered", app="Spotify", from_volume=current, to_volume=saved) + return True + + +def media_restore_watchdog() -> None: + while True: + time.sleep(1) + with duck_lock: + did_duck = bool(duck_state["spotify"]["did_duck"]) + ducked_at = duck_state["spotify"].get("ducked_at") + with state_lock: + playing = bool(active_player and active_player.poll() is None) + if did_duck and not playing and ducked_at and time.monotonic() - ducked_at > 5: + append_log("duck_watchdog", action="restore") + spotify_duck_off() + + def browser_duck_on() -> None: if not config.get("browser_youtube_ducking_enabled"): return @@ -1762,7 +1918,8 @@ def process_job(job: dict) -> None: if not text and not earcon_only: append_log("skip_empty", cwd=cwd, **meta) return - if in_quiet_hours(): + hold_for_inbox = should_hold_for_inbox(kind, bool(job.get("prepared"))) + if in_quiet_hours() and not hold_for_inbox: append_log("quiet_skip", cwd=cwd, kind=kind) return prefix, prefixed = "", False @@ -1810,6 +1967,10 @@ def process_job(job: dict) -> None: return if kind == "reply" and not job.get("prepared"): remember_timeline(cwd, meta, intent, build_signal) + if hold_for_inbox: + inbox_add(cwd, text, meta, intent) + append_log("inbox_hold", cwd=cwd, project=project_name(cwd), intent=intent) + return voice = voice_for_job(job) engine = engine_by_name(job.get("engine")) cue, cue_meta = earcon_pcm( @@ -2195,6 +2356,13 @@ def do_GET(self): if route == "/timeline": json_response(self, 200, {"ok": True, "events": timeline_events[-50:], "recap": recap_text()}) return + if route == "/inbox": + json_response( + self, + 200, + {"ok": True, "count": len(voice_inbox), "entries": voice_inbox[-50:]}, + ) + return json_response(self, 404, {"error": "not_found"}) except Exception: log_error("do_GET") @@ -2213,6 +2381,22 @@ def do_POST(self): ok, result = backchannel(data.get("command", "")) json_response(self, 200 if ok else 400, {"ok": ok, "result": result}) return + if self.path == "/inbox/return": + text, count = drain_voice_inbox() + gen = enqueue_speak(text, None, kind="audition", prepared=True) + json_response( + self, + 202, + {"ok": True, "count": count, "text": text, "generation": gen}, + ) + return + if self.path == "/inbox/clear": + with state_lock: + voice_inbox.clear() + save_voice_inbox() + publish({"event": "inbox", "count": 0}) + json_response(self, 200, {"ok": True, "count": 0}) + return if self.path == "/turn/start": data = read_json(self) cwd = data.get("cwd") @@ -2382,11 +2566,14 @@ def main() -> int: load_config() load_turn_records() load_timeline() + load_voice_inbox() try: server = ThreadingHTTPServer((HOST, PORT), Handler) except OSError: return 0 + recover_media_state() threading.Thread(target=idle_watch, daemon=True).start() + threading.Thread(target=media_restore_watchdog, daemon=True).start() server.serve_forever() return 0 diff --git a/field-guide.html b/field-guide.html index 0a2e028..02e0536 100644 --- a/field-guide.html +++ b/field-guide.html @@ -571,10 +571,11 @@

Controller

Open the pageThe daemon serves a control panel in your browser. It updates live as the voice speaks, no refreshing.
The stageWhen a reply is spoken, you see its real waveform draw itself with a playhead sweeping along, and the text scrolling by.
The mixing deskDrag a line across a chart of your past replies to set how long an answer can be before it gets summarized. You aim it by eye against real data.
-
Media duckingIf enabled, Spotify and Browser YouTube can fade down only while TTS is actually playing, then return to their previous levels.
+
Media duckingIf enabled, Spotify and Browser YouTube fade down only while TTS is playing. A restoration lease repairs Spotify after an interrupted daemon, and the browser adapter restores from daemon state.
The shootoutCompare installed TTS engines on the same sentence, replay each clip, run blind A/B, and keep the winner without the page making decisions for you.
The ledgerEvery reply is a dot on a timeline, one lane per project. Click any dot to hear it again. Several projects finishing together can collapse into one spoken radio bulletin.
Attention cuesShort repo and intent sounds tell you where a line came from and whether it is a success, warning, blocker, or request. The spoken summary stays brief.
+
Voice inboxEnable it when stepping away. Agent replies accumulate locally without speaking; press I'm back for one concise return briefing.
BackchannelRepeat, slow down, speed up, or switch to brief mode from the controller or CLI.
diff --git a/smoke.sh b/smoke.sh index cc9e08b..2e1a62e 100755 --- a/smoke.sh +++ b/smoke.sh @@ -248,5 +248,23 @@ BROWSER_GOT=$(curl -sf -m 2 "$BASE/config" | "$PY" -c 'import json,sys; print(st curl -sf -m 2 -X POST "$BASE/config" -d "{\"browser_youtube_ducking_enabled\": $BROWSER_DUCK}" >/dev/null if [ "$BROWSER_GOT" = "true" ]; then pass "browser duck config"; else fail "browser duck config"; fi +INBOX_ENABLED=$(curl -sf -m 2 "$BASE/config" | "$PY" -c 'import json,sys; print(str(json.load(sys.stdin)["config"]["voice_inbox_enabled"]).lower())') +curl -sf -m 2 -X POST "$BASE/config" -d '{"voice_inbox_enabled": true}' >/dev/null +curl -sf -m 2 -X POST "$BASE/inbox/clear" -d '{}' >/dev/null +curl -sf -m 2 -X POST "$BASE/config" -d '{"volume": 0}' >/dev/null +SINCE=$("$PY" -c "import time; print(time.time())") +curl -sf -m 2 -X POST "$BASE/speak" -d '{"text":"Inbox smoke completed with checks passed.","cwd":"/tmp/ltbv-inbox"}' >/dev/null +INBOX_COUNT=0 +for _ in $(seq 1 40); do + INBOX_COUNT=$(curl -sf -m 2 "$BASE/inbox" | "$PY" -c 'import json,sys; print(json.load(sys.stdin)["count"])') + [ "$INBOX_COUNT" -gt 0 ] && break + sleep 1 +done +HELD_SPEAKS=$(log_count speak "$SINCE") +BRIEF_COUNT=$(curl -sf -m 2 -X POST "$BASE/inbox/return" -d '{}' | "$PY" -c 'import json,sys; print(json.load(sys.stdin)["count"])') +curl -sf -m 2 -X POST "$BASE/config" -d "{\"voice_inbox_enabled\": $INBOX_ENABLED}" >/dev/null +restore_volume +if [ "$INBOX_COUNT" = "1" ] && [ "$BRIEF_COUNT" = "1" ] && [ "$HELD_SPEAKS" -eq 0 ]; then pass "voice inbox briefing"; else fail "voice inbox briefing"; fi + if [ "$FAIL" = 0 ]; then echo "SMOKE PASS"; else echo "SMOKE FAIL"; fi exit "$FAIL" diff --git a/test_daemon_contract.py b/test_daemon_contract.py index 2eb9112..1df1471 100644 --- a/test_daemon_contract.py +++ b/test_daemon_contract.py @@ -14,6 +14,7 @@ def setUp(self): daemon.worker_running = False daemon.generation = 0 daemon.timeline_events.clear() + daemon.voice_inbox.clear() def test_same_repo_parallel_turns_get_distinct_jobs(self): with mock.patch.object(daemon.threading, "Thread"): @@ -85,6 +86,59 @@ def test_recap_contains_only_structured_arc(self): self.assertIn("earlier failure was resolved", recap.lower()) self.assertIn("queue now preserves parallel turns", recap.lower()) + def test_voice_inbox_persists_and_drains_one_briefing(self): + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "inbox.json" + with mock.patch.object(daemon, "INBOX_PATH", path), \ + mock.patch.object(daemon, "append_log"), \ + mock.patch.object(daemon, "publish"): + daemon.inbox_add( + "/repo", + "Smoke passed and the queue now preserves both turns.", + {"verification": "passed", "verification_tests": ["smoke"]}, + "success", + ) + self.assertEqual(json.loads(path.read_text())[0]["project"], "repo") + text, count = daemon.drain_voice_inbox() + self.assertEqual(count, 1) + self.assertIn("repo", text) + self.assertIn("Smoke passed", text) + self.assertEqual(daemon.voice_inbox, []) + + def test_inbox_only_holds_normal_agent_replies(self): + original = daemon.config["voice_inbox_enabled"] + try: + daemon.config["voice_inbox_enabled"] = True + self.assertTrue(daemon.should_hold_for_inbox("reply")) + self.assertFalse(daemon.should_hold_for_inbox("notification")) + self.assertFalse(daemon.should_hold_for_inbox("reply", prepared=True)) + finally: + daemon.config["voice_inbox_enabled"] = original + + def test_media_lease_recovers_saved_spotify_volume(self): + with tempfile.TemporaryDirectory() as tmp: + lease = Path(tmp) / "lease.json" + lease.write_text(json.dumps({"saved_volume": 72})) + with mock.patch.object(daemon, "DUCK_LEASE_PATH", lease), \ + mock.patch.object(daemon, "spotify_running", return_value=True), \ + mock.patch.object(daemon, "spotify_volume", return_value=15), \ + mock.patch.object(daemon, "fade_spotify_volume") as fade, \ + mock.patch.object(daemon, "append_log"): + self.assertTrue(daemon.recover_media_state()) + fade.assert_called_once_with(15, 72, daemon.config["duck_fade_ms"]) + self.assertFalse(lease.exists()) + + def test_spotify_duck_never_raises_quiet_media(self): + with tempfile.TemporaryDirectory() as tmp: + lease = Path(tmp) / "lease.json" + with mock.patch.object(daemon, "DUCK_LEASE_PATH", lease), \ + mock.patch.object(daemon, "spotify_running", return_value=True), \ + mock.patch.object(daemon, "spotify_volume", return_value=10), \ + mock.patch.object(daemon, "fade_spotify_volume") as fade: + daemon.spotify_duck_on() + fade.assert_not_called() + self.assertFalse(lease.exists()) + if __name__ == "__main__": unittest.main()