diff --git a/specs/STATUS.md b/specs/STATUS.md index 96af81c..f9d6638 100644 --- a/specs/STATUS.md +++ b/specs/STATUS.md @@ -3,7 +3,7 @@ _The single source of truth for "what are we building right now." Read it at the start of any build task. Update it when the focus moves; date-stamp it._ -_Last updated: 2026-07-14_ +_Last updated: 2026-07-15_ - **Milestone: L0 + L1 — code-complete.** L0 = specs `01-core-loop` + `02-voice-provider`; L1 = adds `03-01-brain-harness` + `03-02-ducking` (+ the @@ -11,14 +11,20 @@ _Last updated: 2026-07-14_ - **Current focus: cold-start / responsiveness — `spec 04` (no-dead-air), pulled forward.** A `make dev-fishaudio` measurement showed boot is fast (~3.5s) but the first-music wait is long (~76s: forced opening talk segments + - a ~45s cold music search). We are attacking it in PRs: + a ~45s cold music search). Attacked in PRs: - **shipped (PR #24)**: spec 01 §3.3 — `Steer` + prepare-then-barge-in interjection (no dead-air on talk-back); groundwork for the look-ahead. - - **building**: spec 04 slice 1 — **music-pick prefetch** (overlap the ~45s - find-and-pull with the opening talk). - - **next**: spec 04 slice 2 — **talk look-ahead** (one Brain call → N scripts, - parallel TTS, buffered) — adds `Brain.next_talks`. -- **Open: by-ear / sensory acceptance** (both the L0/L1 "sounds human, feels like - radio" pass, and PR #24's gapless-barge-in feel) — owed once the TUI is ready; - needs a real listening pass, not an assertion. -- Later specs (04 slice 2, 05–09) are expected to change as we learn — not frozen. + - **shipped (PR #25)**: spec 04 slice 1 — **music-pick prefetch** (overlap the + ~45s find-and-pull with the opening talk). + - **shipped (PRs #26, #29)**: spec 04 slice 2 — **talk look-ahead** (batched + `Brain.next_talks` via the `emit_talk_beats` harness tool; parallel TTS). + - **building**: spec 04 §3.3 — **talk look-ahead survives music** (depth-2 + buffer, refilled when drained — including during a song — so the music→talk + boundary has no Brain/synth wait; bounded retry + dev-log on the refill path). +- **Open: end-to-end latency measurement.** Acceptance so far is mechanism-level + (fakes prove the buffers work); the motivating ~76s first-music wait has **not** + been re-measured on a real run. Owed: a `make dev-fishaudio` before/after. +- **Open: by-ear / sensory acceptance** (L0/L1 "sounds human, feels like radio", + PR #24's gapless-barge-in feel) — owed once the TUI is ready; a real listening + pass, not an assertion. +- Later specs (05–09) are expected to change as we learn — not frozen. diff --git a/specs/spec04/04-no-dead-air.md b/specs/spec04/04-no-dead-air.md index dd91272..73f5020 100644 --- a/specs/spec04/04-no-dead-air.md +++ b/specs/spec04/04-no-dead-air.md @@ -94,18 +94,38 @@ finished) `next_track` task: model reliably fills a clean N-item array is an eval-track concern, not a unit assertion — DESIGN §10.3.) -The Director keeps a **single-ahead buffer** (one pre-synthesized segment): -- **Empty buffer:** call `next_talks(2)`, synthesize both beats **in parallel** - (`asyncio.gather`), air beat 1, buffer beat 2's clip. -- **Non-empty buffer:** air the buffered beat directly — **no Brain call, no - synthesis** on the segment's critical path (the inter-segment latency is gone). -- **Music prefetch** (§3.1) still fires with the text of whatever is airing. - -A typed line (`Steer`, talkback) **discards** the buffer: the buffered beat was -generated before the user turn, so it is stale (spec 01 §3.3 rule) — dropped, and -the next segment regenerates fresh. The buffer is also cleared on shutdown. -`count` is a call parameter (default 2), not a config knob — deepen only if -measurement shows a remaining gap (§6). +The Director keeps a **depth-`N` look-ahead buffer** (`N` = `_TALK_LOOKAHEAD`, +default **2**) of pre-synthesized beats, kept **topped up to `N`** like the music +slot (§3.1). Held at `N` — not drained-then-refilled — the next talk is always +ready, including across intervening music. +- **Consume:** a talk segment pops the front beat and airs it — **no Brain call, + no synthesis** on the critical path (the inter-segment latency is gone). +- **Refill (`_prefetch_talk`):** fire-and-forget, at most one in flight (mirrors + `_prefetch_music`). When the buffer is **below `N`**, one batched + `next_talks(need)` for the shortfall, its beats synthesized **in parallel** (each + an independent synth task), appended (capped at `N`). Fired after a consumed beat + is recorded **and at the start of a music segment**, so the buffer stays full and + the refill's Brain+synth overlap whatever is on air (the post-song talk airs + warm). **Coherence:** the refill passes the queued-but-unaired beats into the + context as prior `radio` turns — the buffered text lives in the Director, so the + stateless Brain is told what is *already queued*, not only what has aired and + been recorded, and continues the monologue instead of duplicating it. +- **Cold fallback:** an empty buffer (first-ever segment, or a post-steer regen) + does a `next_talks(N)` inline, airs beat 1, buffers the rest — correctness never + depends on a warm buffer. +- **Survives music (design change from the single-slot slice-2):** a song no + longer **discards** the buffer. A song is the ideal window to *prepare* the next + talk, not a reason to drop it. This is what removes the music→talk dead air. +- **Resilience:** the refill's `next_talks` and each synth **retry** (bounded, + `_LOOKAHEAD_ATTEMPTS`) before degrading — a failed batch loses the look-ahead + that round, a failed synth loses that one beat, never the radio. Every important + stage (refill fired, batch size, retries, failures) is logged to the dev log. + +A typed line (`Steer`, talkback) **discards** the buffer + cancels an in-flight +refill: the buffered beats were generated before the user turn, so they are stale +(spec 01 §3.3 rule) — dropped, and the next segment regenerates fresh. The buffer +and any in-flight refill are also settled on shutdown. `N` is a module constant, +not a config knob — deepen only if measurement shows a remaining gap (§6). --- @@ -128,11 +148,26 @@ measurement shows a remaining gap (§6). 2. **slice 1:** with no prefetch available, the music branch still resolves a pick (cold fallback) and behaves exactly as pre-spec-04. 3. **slice 1:** no prefetch task outlives the Director (clean shutdown / `/quit`). -4. **slice 2:** after a `next_talks(2)` call, segment *k+1* airs from the buffer - with **no** Brain call and **no** synthesis on its critical path (verified on - fakes: the second segment plays without a second `next_talks` / `synthesize`); - a talkback `Steer` discards the buffer; `parse_talk_beats` degrades a - malformed / missing tool result to empty, and the Brain falls back to a single beat. +4. **slice 2:** a buffered beat airs with **no** Brain call and **no** synthesis on + its critical path (verified on fakes: the next segment plays without a fresh + `next_talks` / `synthesize`); a talkback `Steer` discards the buffer; + `parse_talk_beats` degrades a malformed / missing tool result to empty, and the + Brain falls back to a single beat. +5. **slice 2 (survives music):** a talk beat buffered before a music segment airs + **after** the song (not regenerated cold at the music→talk boundary) — the + music→talk transition has no Brain/synth wait. Verified on fakes: with a song + between two talks, the pre-buffered beat is the one that airs post-song. +6. **slice 2 (depth 2):** the buffer is held at depth `N` (not drained-then- + refilled) — a beat buffered before **two** consecutive music segments still airs + after them, and each post-song talk airs a warm buffered beat. +7. **slice 2 (coherent refill):** a top-up refill fires with the queued-but-unaired + beat in its context (as a prior `radio` turn), so it continues the monologue + rather than duplicating the buffered beat. Verified on fakes: the refill's + `next_talks` context contains the queued beat's text. +8. **slice 2 (resilience):** a transient `next_talks` failure and a transient + synth failure are retried (the look-ahead still fills / the beat still airs); + exhausted retries degrade (look-ahead skipped / beat skipped) without crashing + the loop. --- diff --git a/src/murmur/director.py b/src/murmur/director.py index f76f4e5..bb38b58 100644 --- a/src/murmur/director.py +++ b/src/murmur/director.py @@ -56,11 +56,16 @@ _QUIT_COMMAND = "/quit" -# spec 04 slice 2: beats per batched Brain call. One airs now, the rest are -# buffered look-ahead (a call parameter, not a config knob — deepen only if -# measurement shows a remaining gap). +# spec 04 §3.3: talk look-ahead buffer depth — pre-synthesized beats kept ready +# so the next talk airs with no Brain/synth wait, even across music. Depth 2 +# covers the next two music completions. A module constant, not a config knob — +# deepen only if measurement shows a remaining gap (§6). _TALK_LOOKAHEAD = 2 +# spec 04 §3.3: bounded attempts for a look-ahead Brain/synth call before it +# degrades (lose the look-ahead / the one beat, never the radio). +_LOOKAHEAD_ATTEMPTS = 2 + SteerIntent = Literal["quit", "talkback"] @@ -111,20 +116,29 @@ def __init__( # spec 04 slice 1: the next music pick, found in the background so its # latency overlaps talk. Single-slot (one pick ahead); None = empty. self._pending_pick: asyncio.Task[TrackPick | None] | None = None - # spec 04 slice 2: pre-generated talk beats (from a batched next_talks), - # each an in-flight synth task, so the next talk airs with no Brain/synth - # wait. Discarded on a talkback steer (stale) and on shutdown. + # spec 04 §3.3: pre-synthesized talk beats (depth _TALK_LOOKAHEAD), each an + # in-flight synth task, so the next talk airs with no Brain/synth wait — + # even across music. Discarded on a talkback steer (stale) and on shutdown. self._talk_ahead: list[tuple[str, asyncio.Task[AudioClip | None]]] = [] + # The single in-flight refill that tops the buffer back up (mirrors the + # single-slot _pending_pick). None = no refill running. + self._talk_fill: asyncio.Task[None] | None = None if music is not None: if not isinstance(player, MixingPlayer): raise ValueError("music wiring requires a player with play_music") self._mixing = player - def _context(self) -> ContextPack: - return ContextPack( - persona=self._persona, - recent=self._memory.recent(self._config.recent_window), - ) + def _context(self, pending: list[str] | None = None) -> ContextPack: + """The context pack for a Brain call. ``pending`` are already-queued but + not-yet-aired look-ahead beats (spec 04 §3.3): they are appended to the + recent transcript as the host's own turns so a refill continues *after* + them instead of regenerating the same beat — the buffered text is right + here in the Director, so the stateless Brain is told what is already + queued rather than only what has aired (and been recorded).""" + recent = list(self._memory.recent(self._config.recent_window)) + if pending: + recent += [Turn("radio", text) for text in pending] + return ContextPack(persona=self._persona, recent=recent) def _recent_lines(self) -> list[str]: return [ @@ -153,10 +167,13 @@ async def run(self, *, max_segments: int | None = None) -> None: if not last and not self._quit: await self._gap() finally: - # No orphaned work: settle the in-flight pick + buffered talk synths. + # No orphaned work: settle the in-flight pick, the buffered talk + # synths, and the refill (which _discard only cancels, never awaits). await _settle(self._pending_pick) self._pending_pick = None await self._discard_talk_ahead() + await _settle(self._talk_fill) + self._talk_fill = None # -- segments ------------------------------------------------------------- @@ -172,20 +189,38 @@ async def _talk_segment(self) -> None: # an interjection's reply sees this segment in context. self._cli.on_radio_segment(text) self._memory.record(Turn("radio", text)) + # Refill AFTER recording so the top-up's context already includes this + # just-aired beat (plus the queued beats it passes as pending) — the batch + # continues the monologue instead of duplicating it (spec 04 §3.3). + self._prefetch_talk() await self._run_voice(asyncio.ensure_future(self._player.play(clip))) async def _next_talk_clip(self) -> tuple[str, AudioClip | None]: """The next talk beat to air. From the look-ahead buffer if primed (its - synth already ran during the previous playback — await is near-instant), - else a fresh batch: one ``next_talks`` call, air beat 1, and buffer the - rest as background synth tasks that render during beat 1's playback - (spec 04 §3.2).""" + synth already ran behind the prior audio — await is near-instant); the + refill is fired by the caller after this beat is recorded. Else cold: one + ``next_talks`` batch, air beat 1, buffer the rest (spec 04 §3.3).""" + # A refill fired during the previous audio (a song, or the last segment) + # may still be in flight — prefer its result over a cold call, both to air + # warm and to avoid a double-generate race (a cold call racing the refill + # would double-append). Await it; a refill cancelled out from under us (a + # superseded refill, e.g. by a steer) falls through to cold, while our own + # cancellation (shutdown) propagates. An exhausted refill just leaves the + # buffer empty (it never raises — it degrades in _generate_talks), so we + # fall through to the cold path, which retries once more. + fill = self._talk_fill + if not self._talk_ahead and fill is not None and not fill.done(): + try: + await fill + except asyncio.CancelledError: + if not fill.cancelled(): + raise # our own cancellation (shutdown) — propagate if self._talk_ahead: text, task = self._talk_ahead.pop(0) self._prefetch_music(latest=text) return text, await task with _log.timed("talk") as t: - texts = await self._brain.next_talks(self._context(), _TALK_LOOKAHEAD) + texts = await self._generate_talks(_TALK_LOOKAHEAD) t["beats"] = len(texts) if not texts: return "", None @@ -203,9 +238,77 @@ async def _next_talk_clip(self) -> tuple[str, AudioClip | None]: first_clip = await self._synthesize_or_skip(first) return first, first_clip + def _prefetch_talk(self) -> None: + """spec 04 §3.3: keep the look-ahead **topped up to ``_TALK_LOOKAHEAD``** — + fire-and-forget, at most one refill in flight (mirrors ``_prefetch_music``). + No-op if the buffer is already full or a refill is running. Fired after a + talk beat is recorded AND at a music segment's start, so the buffer stays + full (no drain-to-empty oscillation) and the next talk always airs warm, + even across music.""" + if len(self._talk_ahead) >= _TALK_LOOKAHEAD: + return + if self._talk_fill is not None and not self._talk_fill.done(): + return + self._talk_fill = asyncio.ensure_future(self._fill_talk()) + + async def _fill_talk(self) -> None: + """Background refill of the shortfall to ``_TALK_LOOKAHEAD``: one batched + ``next_talks`` whose context carries the beats **already queued** (so it + continues the monologue, never duplicates it), beats synthesized **in + parallel** (each an independent synth task), appended to the buffer. A + background task — it never raises: a failed batch is logged and leaves the + buffer short (the next ``_prefetch_talk`` retries).""" + need = _TALK_LOOKAHEAD - len(self._talk_ahead) + if need <= 0: + return + pending = [text for text, _ in self._talk_ahead] + with _log.timed("talk.prefetch", need=need) as t: + texts = await self._generate_talks(need, pending) + t["beats"] = len(texts) + # No await between here and the appends, so this is atomic against a + # concurrent consume. Cap at the depth in case a consume shifted the buffer + # during the await — never overshoot. + for text in texts: + if len(self._talk_ahead) >= _TALK_LOOKAHEAD: + break + self._talk_ahead.append( + (text, asyncio.ensure_future(self._synthesize_or_skip(text))) + ) + + async def _generate_talks( + self, need: int, pending: list[str] | None = None + ) -> list[str]: + """Batched look-ahead generation with bounded retry; ``[]`` on ultimate + failure (degrade — lose the look-ahead this round, never crash the radio). + ``pending`` are the queued-but-unaired beats, fed into the context so the + batch continues after them. Every attempt/failure is logged (spec 04 §3.3).""" + for attempt in range(1, _LOOKAHEAD_ATTEMPTS + 1): + try: + return await self._brain.next_talks(self._context(pending), need) + except asyncio.CancelledError: + raise + except Exception as exc: + if attempt < _LOOKAHEAD_ATTEMPTS: + _log.warn( + f"next_talks failed (attempt {attempt}/{_LOOKAHEAD_ATTEMPTS}); retrying", + exc=exc, + ) + continue + _log.warn("next_talks failed; look-ahead skipped this round", exc=exc) + return [] + return [] # unreachable for _LOOKAHEAD_ATTEMPTS >= 1 — satisfies typing + async def _discard_talk_ahead(self) -> None: - """Drop the buffered look-ahead, cancelling in-flight synths. Called when - a talkback steer makes it stale (spec 04 §3.2) and on shutdown.""" + """Drop the buffered look-ahead and cancel an in-flight refill (spec 04 + §3.3). Called when a talkback steer makes them stale, and on shutdown. + + The buffered synth tasks (local, fast) are settled here. The refill's + Brain call is only **cancelled, never awaited on this path** — a talkback + reply must not wait on a background prefetch's teardown (spec 01 §3.3 + user-priority). It settles on the next refill (its slot is free once + cancelled) or in ``run``'s shutdown finally.""" + if self._talk_fill is not None and not self._talk_fill.done(): + self._talk_fill.cancel() tasks = [task for _, task in self._talk_ahead] self._talk_ahead = [] await _settle(*tasks) @@ -220,18 +323,29 @@ async def _gap(self) -> None: await self._run_voice(None, steer=steer) async def _synthesize_or_skip(self, text: str) -> AudioClip | None: - """Synthesize with degradation: a TTS failure skips this one spoken - segment (info line; nothing aired or recorded) instead of crashing the - radio — same principle as the music branch's fallback. Found live: a + """Synthesize with bounded retry then degradation (spec 04 §3.3): a + transient TTS failure is retried; an exhausted failure skips this one + spoken segment (info line; nothing aired or recorded) instead of crashing + the radio — same principle as the music branch's fallback. Found live: a single bad utterance used to unwind the whole loop.""" - try: - return await self._voice.synthesize(text) - except asyncio.CancelledError: - raise - except Exception as exc: - self._cli.info(f"voice synthesis failed ({exc}); skipping this segment.") - _log.warn("voice synthesis failed; segment skipped", exc=exc) - return None + for attempt in range(1, _LOOKAHEAD_ATTEMPTS + 1): + try: + return await self._voice.synthesize(text) + except asyncio.CancelledError: + raise + except Exception as exc: + if attempt < _LOOKAHEAD_ATTEMPTS: + _log.warn( + f"voice synthesis failed (attempt {attempt}/{_LOOKAHEAD_ATTEMPTS}); retrying", + exc=exc, + ) + continue + self._cli.info( + f"voice synthesis failed ({exc}); skipping this segment." + ) + _log.warn("voice synthesis failed; segment skipped", exc=exc) + return None + return None # unreachable (the loop returns or degrades) — satisfies typing async def _wants_music(self) -> bool: if self._music is None or self._cadence is None: @@ -294,11 +408,13 @@ async def _play_music_segment(self) -> bool: self._cli.info("music: nothing suitable found; back to talk.") return False - # A song is going on air: the buffered talk look-ahead was generated - # as the immediate consecutive talk (no song between), so it is now - # stale — drop it + its synth so it neither airs post-song nor competes - # with the intro (spec 04 §3.2). - await self._discard_talk_ahead() + # A song is going on air: the talk look-ahead SURVIVES it and is topped + # up during it (spec 04 §3.3). A song is the ideal window to prepare the + # next talk — its whole duration overlaps the refill's Brain+synth — so + # the post-song talk airs warm instead of regenerating cold into dead + # air. (Pre-§3.3 the song discarded the buffer; that left the music->talk + # boundary cold.) + self._prefetch_talk() announce_clip: AudioClip | None = None if pick.announce: diff --git a/tests/fakes.py b/tests/fakes.py index 9a48265..72fdf1c 100644 --- a/tests/fakes.py +++ b/tests/fakes.py @@ -17,17 +17,29 @@ class FakeBrain: reply "in composition" long enough for a queued line to merge into it (the delay is *before* recording, so a merged-away reply leaves no trace).""" - def __init__(self, respond_delay: float = 0.0) -> None: + def __init__( + self, respond_delay: float = 0.0, next_talks_fail_times: int = 0 + ) -> None: self.talk_count = 0 self.responded_to: list[str] = [] - self.batch_counts: list[int] = [] # one entry per next_talks call + self.batch_counts: list[int] = [] # one entry per successful next_talks call + # recent-turn texts seen by each next_talks call — lets a test assert the + # queued look-ahead beats were threaded into the refill context (§3.3). + self.talk_contexts: list[list[str]] = [] self._respond_delay = respond_delay + # Raise from the first N next_talks calls, then succeed — exercises the + # Director's bounded look-ahead retry (spec 04 §3.3). + self._next_talks_fail = next_talks_fail_times async def next_talk(self, ctx: ContextPack) -> str: self.talk_count += 1 return f"talk-{self.talk_count}" async def next_talks(self, ctx: ContextPack, count: int = 2) -> list[str]: + if self._next_talks_fail > 0: + self._next_talks_fail -= 1 + raise RuntimeError("transient next_talks failure") + self.talk_contexts.append([t.text for t in ctx.recent]) self.batch_counts.append(count) out: list[str] = [] for _ in range(count): @@ -53,12 +65,16 @@ def __init__( fail_on: list[str] | None = None, events: list[tuple[str, str]] | None = None, synth_delay: float = 0.0, + transient_fail_on: dict[str, int] | None = None, ) -> None: self.started = False self.closed = False self._fail_on = set(fail_on or []) self._events = events self._synth_delay = synth_delay + # text -> number of leading failures before it synthesizes: a transient + # TTS fault the Director's bounded retry should ride out (spec 04 §3.3). + self._transient = dict(transient_fail_on or {}) async def start(self) -> None: self.started = True @@ -66,6 +82,9 @@ async def start(self) -> None: async def synthesize(self, text: str, *, scenario: str = "broadcast") -> AudioClip: if text in self._fail_on: raise RuntimeError(f"sidecar synthesize failed: {text!r}") + if self._transient.get(text, 0) > 0: + self._transient[text] -= 1 + raise RuntimeError(f"transient synthesize failure: {text!r}") if self._synth_delay: # keep a reply "rendering" so a line can merge in await asyncio.sleep(self._synth_delay) if self._events is not None: diff --git a/tests/test_director.py b/tests/test_director.py index 73da334..33c0f10 100644 --- a/tests/test_director.py +++ b/tests/test_director.py @@ -56,44 +56,104 @@ async def go(): asyncio.run(go()) -def test_talk_lookahead_two_segments_from_one_brain_call(): - """spec 04 slice 2: one next_talks(2) call feeds two aired segments — the - second plays from the buffer with no second Brain call.""" +def test_talk_lookahead_second_segment_from_the_buffer(): + """spec 04 §3.3: the first (cold) batch is a next_talks(2); the second segment + airs the buffered beat (talk-2) — a cold call at seg2 would have produced a + fresh higher-numbered beat, so airing talk-2 proves the buffer was consumed.""" async def go(): director, cli, brain, player, memory = _make() await director.run(max_segments=2) - assert brain.batch_counts == [2] # a single batch produced both segments + assert brain.batch_counts[0] == 2 # the cold path opens with a batch assert cli.radio == ["talk-1", "talk-2"] assert player.played == ["fake:talk-1", "fake:talk-2"] asyncio.run(go()) +def test_refill_context_carries_the_queued_beat(): + """spec 04 §3.3: the top-up refill feeds the queued-but-unaired beat into the + Brain context (as a prior 'radio' turn), so the batch continues after it + rather than duplicating it — the fix for stale-context duplication.""" + + async def go(): + # play_delay keeps talk-1 on air long enough for the (instant-fake) refill + # to run its next_talks before the bounded run exits. + director, cli, brain, player, memory = _make(play_delay=0.05) + await director.run(max_segments=1) + # Call 0 is the cold batch (empty context); call 1 is the top-up, whose + # context carries the just-aired talk-1 (recorded) AND the queued talk-2. + assert brain.talk_contexts[0] == [] + assert len(brain.talk_contexts) >= 2 + assert "talk-1" in brain.talk_contexts[1] + assert "talk-2" in brain.talk_contexts[1] + + asyncio.run(go()) + + def test_steer_discards_the_talk_lookahead(): """A typed line discards the buffered look-ahead — it predates the user turn, - so the next segment regenerates fresh (spec 04 §3.2 / spec 01 §3.3).""" + so the next segment regenerates fresh (spec 04 §3.3 / spec 01 §3.3).""" async def go(): director, cli, brain, player, memory = _make(lines=["hi"], play_delay=0.1) await director.run(max_segments=2) - # seg1 batched [talk-1, talk-2]; "hi" drops the buffered talk-2; seg2 is - # a fresh batch -> talk-3, NOT the stale talk-2. + # "hi" drops the buffered talk-2; the resume airs a fresh beat, never the + # stale one (the exact number depends on how much the background refill + # generated before the discard — assert the invariant, not the count). assert "talk-2" not in cli.radio - assert cli.radio == ["talk-1", "reply:hi", "talk-3"] - assert brain.batch_counts == [2, 2] # two batches (the first was discarded) + assert cli.radio[:2] == ["talk-1", "reply:hi"] + assert len(cli.radio) == 3 and cli.radio[2].startswith("talk-") asyncio.run(go()) def test_talk_lookahead_settled_on_shutdown(): - """A buffered look-ahead synth must not outlive the loop (/quit before it is - consumed).""" + """A buffered look-ahead synth / in-flight refill must not outlive the loop + (/quit before it is consumed).""" async def go(): director, cli, brain, player, memory = _make(lines=["/quit"], play_delay=0.1) await director.run(max_segments=None) - assert director._talk_ahead == [] # buffered synth task settled, not orphaned + assert director._talk_ahead == [] # buffered synth tasks settled + assert director._talk_fill is None # in-flight refill cancelled + cleared + + asyncio.run(go()) + + +def test_talk_refill_retries_a_transient_brain_failure(): + """spec 04 §3.3: a transient next_talks failure is retried, so the look-ahead + still fills and the segment airs (without retry the cold gen would degrade to + a skipped segment).""" + + async def go(): + director, cli, brain, player, memory = _make() + director._brain = FakeBrain(next_talks_fail_times=1) # type: ignore[attr-defined] + await director.run(max_segments=1) + assert cli.radio == ["talk-1"] # first attempt raised, retry produced it + + asyncio.run(go()) + + +def test_talk_synth_retries_a_transient_failure(): + """spec 04 §3.3: a transient TTS failure on the aired beat is retried, so the + beat still airs (without retry it would be skipped).""" + + async def go(): + cli = FakeCli() + player = FakePlayer() + director = Director( + config=replace(Config.default(), inter_segment_gap=0.0), + persona="p", + brain=FakeBrain(), + voice=FakeVoice(transient_fail_on={"talk-1": 1}), + player=player, + memory=InProcessMemoryStore(), + cli_host=cli, + ) + await director.run(max_segments=1) + assert cli.radio == ["talk-1"] # failed once, retried, then aired + assert player.played == ["fake:talk-1"] asyncio.run(go()) @@ -108,22 +168,22 @@ async def go(): await director.run(max_segments=2) assert brain.responded_to == ["hello"] assert cli.user == ["hello"] - # talk-1 (interrupted) -> reply -> talk-3 (resumed). talk-2 was the - # buffered look-ahead, discarded by the interjection (spec 04 §3.2), so - # the resume regenerates fresh. - assert cli.radio == ["talk-1", "reply:hello", "talk-3"] + # talk-1 (interrupted) -> reply -> a FRESH talk (resumed). talk-2 was the + # buffered look-ahead, discarded by the interjection (spec 04 §3.3), so the + # resume regenerates fresh (never the stale talk-2; the exact number + # depends on how far the background refill got before the discard). + assert cli.radio[:2] == ["talk-1", "reply:hello"] + assert len(cli.radio) == 3 + assert cli.radio[2].startswith("talk-") and cli.radio[2] != "talk-2" assert [t.role for t in memory.recent(10)] == [ "radio", "user", "radio", "radio", ] - assert [t.text for t in memory.recent(10)] == [ - "talk-1", - "hello", - "reply:hello", - "talk-3", - ] + texts = [t.text for t in memory.recent(10)] + assert texts[:3] == ["talk-1", "hello", "reply:hello"] + assert texts[3].startswith("talk-") and texts[3] != "talk-2" assert player.stops >= 1 # playback was stopped for the interjection asyncio.run(go()) @@ -192,9 +252,11 @@ async def go(): ) await director.run(max_segments=2) assert brain.responded_to == ["hello"] - # the reply never aired; talk-1 then the resumed segment (talk-3 — talk-2 - # was the buffered look-ahead, discarded by the interjection). - assert cli.radio == ["talk-1", "talk-3"] + # the reply never aired; talk-1 then a FRESH resumed segment (talk-2 was + # the buffered look-ahead, discarded by the interjection — spec 04 §3.3). + assert cli.radio[0] == "talk-1" + assert len(cli.radio) == 2 + assert cli.radio[1].startswith("talk-") and cli.radio[1] != "talk-2" assert any("synthesis failed" in m for m in cli.infos) asyncio.run(go()) diff --git a/tests/test_director_music.py b/tests/test_director_music.py index 5b2ca9a..2f7dc6d 100644 --- a/tests/test_director_music.py +++ b/tests/test_director_music.py @@ -70,12 +70,10 @@ def test_cadence_schedules_music_and_announce_rides_over_it(): async def go(): director, cli, brain, engine, programmer, memory = _make(picks=[_pick()]) await director.run(max_segments=3) - # every_n=1: talk, then music, then talk again (counter reset). The song - # intervening discards the buffered talk look-ahead (talk-2, a - # consecutive-talk continuation is stale after a song), so the post-song - # talk regenerates fresh -> talk-3 (spec 04 §3.2). - assert cli.radio == ["talk-1", "up next: T1", "talk-3"] - assert "talk-2" not in cli.radio # the stale buffered beat never aired + # every_n=1: talk, then music, then talk again (counter reset). The talk + # look-ahead SURVIVES the song (spec 04 §3.3) — the buffered talk-2 airs + # after the song, warm, instead of regenerating cold into dead air. + assert cli.radio == ["talk-1", "up next: T1", "talk-2"] assert [c.source for c in engine.music_played] == ["stream:r1"] # The announce was spoken (played on the voice channel) AFTER the # music started, so it rides the ducked song head. @@ -83,12 +81,49 @@ async def go(): assert [t.text for t in memory.recent(10)] == [ "talk-1", "up next: T1", - "talk-3", + "talk-2", ] asyncio.run(go()) +def test_talk_lookahead_survives_a_song_no_cold_regen(): + """spec 04 §3.3: a beat buffered before a music segment airs AFTER the song + (talk-2), not a cold-regenerated fresh beat — so the music->talk boundary has + no Brain/synth wait. (Pre-§3.3 the song discarded the buffer and the post-song + talk was a cold call.)""" + + async def go(): + director, cli, brain, engine, programmer, memory = _make( + picks=[_pick(announce=None)], cadence=EveryNCadence(n=1) + ) + await director.run(max_segments=3) # talk, music (no announce), talk + assert [c.source for c in engine.music_played] == ["stream:r1"] + assert cli.radio == ["talk-1", "talk-2"] # talk-2 was buffered pre-song + + asyncio.run(go()) + + +def test_talk_lookahead_depth_two_covers_two_music_completions(): + """spec 04 §3.3: depth-2 buffer — across TWO music completions, each post-song + talk airs a warm buffered beat (talk-2 after song 1, talk-3 after song 2), with + no cold regeneration at either music->talk boundary.""" + + async def go(): + director, cli, brain, engine, programmer, memory = _make( + picks=[_pick(ref="r1", announce=None), _pick(ref="r2", announce=None)], + cadence=ScriptedCadence(["talk", "music", "talk", "music", "talk"]), + ) + await director.run(max_segments=5) + assert [c.source for c in engine.music_played] == ["stream:r1", "stream:r2"] + # talk-1 (seg1) buffers talk-2; song1; talk-2 airs warm (seg3), its record + # triggers the next batch (talk-3,…); song2; talk-3 airs warm (seg5). The + # low, gap-free numbering proves both post-song talks came from the buffer. + assert cli.radio == ["talk-1", "talk-2", "talk-3"] + + asyncio.run(go()) + + def test_music_context_carries_recent_turns_and_persona(): async def go(): director, cli, brain, engine, programmer, memory = _make(picks=[_pick()])