Skip to content

feat(director): a coda — the beat that knows the song happened [spec 04] - #200

Merged
wine-fall merged 2 commits into
mainfrom
zachg-0903--coda-beat
Sep 3, 2026
Merged

feat(director): a coda — the beat that knows the song happened [spec 04]#200
wine-fall merged 2 commits into
mainfrom
zachg-0903--coda-beat

Conversation

@wine-fall

Copy link
Copy Markdown
Owner

Second of two. PR #199 fixed the way into a song; this is the way out.

Stacked on #199 — it branches from zachg-0903--ducking-into-song, not main, because #199 was still awaiting merge. Merge #199 first and this PR's diff collapses to its own commit.

Why the post-song beat could never mention the song

Spec 04's look-ahead already removed the music→talk dead air — the beat after a song airs warm off the buffer. It could not make that beat about the song, and the reason is structural: the buffer is held at depth N and topped up after every consumed beat, so by the time a track starts the buffer is already full and the refill fired at the song's start is a no-op. Every post-song beat is therefore written before the song exists. And GROUNDING_RULES (correctly) forbade it to say anything that turns false when the music ends — so its only safe move was to pretend the song had not happened.

Song stops. Silence. Talk comes in cold, from somewhere else.

The coda

One beat per track that knows the track happened. Generated at the song's start, once the stream is confirmed audible and the announce is recorded, so its context carries the song and its intro. One extra nextTalks(1) per song — that is the whole added cost.

Cued coda, and the cue gives permission, not an assignment: answer the song, say why it followed the stretch of talk before it, or say nothing about it and simply carry on. Introducing or reviewing the track is explicitly not the job — a beat that had to review every song would be the "up next" formula again, wearing the other end. MUSIC_OUTLASTS_RULE ("say nothing that turns false when the music above ends") is split out of GROUNDING_RULES and omitted for this one cue; every other red line still stands.

Its own slot, not talkAhead — the depth-N invariant and its refill arithmetic are untouched. The slot holds a beat that can go on air this instant, never a promise: it is filled only once synthesis has come back with a real clip, so neither exit can make a boundary wait on TTS, and a coda whose synthesis failed simply does not exist (the ready buffered beat airs instead).

Two ways out.

  1. Riding the outro — when the track's length is known, with probability CODA_RIDE_P = 0.5, the coda goes out 8-12 s before the end (uniform), racing the song's completion inside runVoice. The engine ducks the tail under it exactly as it ducks the head under an announce, so the radio talks over the fade instead of waiting for silence. Aired like a reply, not like a segment: recorded and printed, but no emitState('talk') — the track is still playing and the front-end must keep naming it.
  2. The head of the queue — otherwise (length unknown, coin missed, track shorter than the lead, no coda ready) the song ends and the coda is unshifted to the front of talkAhead. The buffer momentarily holds N + 1; prefetchTalk's >= tolerates that and simply does not refill until it drains.

Chance is injected. DirectorDeps.random (default Math.random) is the only entry point for the coin and the lead, so both are pinned in tests. A talkback steer discards the coda with the rest of the look-ahead; a new track clears the slot; nothing re-fires a coda after a reply (a reply already speaks to the song it is ducked over — deliberate omission, noted in the spec).

Tests

  • director-music.test.ts — generated at the song's start with cue coda and the announce in its context; airs at the head of the queue when the song ends; rides the outro with an injected random (and keeps the front-end on music while it does); falls back to the post-song head when the length is unknown and when the coin misses; a typed line discards it; each track gets its own; and it never reaches the boundary unless it is ready to play.
  • prompts.test.ts — the coda prompt carries its cue text and not MUSIC_OUTLASTS_RULE, while every other prompt still does, on both the batched and the single-beat path.
  • The four look-ahead tests that assert the exact post-song sequence were updated, not weakened: they now pin the coda and the beat buffered behind it (['talk one', 'the coda', 'talk two']), which is a stronger statement about the boundary than before.

pnpm test 1301 passed, pnpm run typecheck clean, oxlint clean.

Peer review (codex gpt-5.6-sol, xhigh): 4 findings, 4 applied, 0 dismissed — a coda handed to the queue as an unsettled promise could block the boundary it exists to smooth (P1); the coda's context was given the queued-but-unaired beats it airs ahead of; prefetchCoda bumped the epoch without clearing the slot; and FIND_MUSIC_CONTRACT restated the announce wording instead of rendering the shared ANNOUNCE_FIELD_DESCRIPTION (#199's own "one text, not two" claim, now actually true — asserted by string identity).

Not verified here: the coda's tone, whether the outro ride lands as talking-over-a-fade, and whether a 50/50 mix of the two exits reads as variety. Appended to the checklist on #198.

Implements specs/spec04/04-no-dead-air.md §3.3-C (constants beside the ducking knobs in specs/spec03/03-02-ducking.md §6.1).

AI coding brief

Original request — 把 talk↔music 两个过场做顺(ducking 听感), second half: the way out of a song. Design settled with the user before the build, including the coda's own slot, the two exits, the ride probability and lead range, and the decision to tag spec 04 rather than open a new spec.

Manual interventions — none during the build. Two designer's calls were taken mid-build against the letter of the brief, both from review evidence: the coda's context does not carry the queued look-ahead beats (the brief said it should, but the coda airs ahead of them), and the slot holds a resolved clip rather than a pending one (so the "never block the air" rule cannot be violated by a slow TTS).

Retro — same lesson as #199, one level up: the findings were all about when a value exists, not what it says — a promise stored where a value was assumed, an epoch bumped without clearing what it guards, context assembled for one ordering and consumed in another. A brief that says "for every slot you add, state what is in it at each of: fired, resolved, consumed, discarded, and replaced" would have caught three of the four before the review round.

🤖 Generated with Claude Code

https://claude.ai/code/session_011L93sAUxB3SMTzMRFscUy3

Base automatically changed from zachg-0903--ducking-into-song to main September 3, 2026 07:21
wine-fall and others added 2 commits September 3, 2026 15:21
The way OUT of a song was a beat written before the song existed. The
look-ahead is held at depth N and topped up after every consumed beat, so by
the time a track starts the buffer is already full: the refill fired at the
song's start is a no-op, and the post-song beat was composed one or two
boundaries earlier. GROUNDING_RULES then (correctly) forbade it to say anything
that turns false when the music ends, so its only safe move was to pretend the
song had not happened. Song stops, silence, talk comes in cold.

- one coda per track, generated once the stream is confirmed audible and the
  announce is recorded, cued 'coda' so its prompt gives permission rather than
  an assignment: answer the song, say why it followed that stretch of talk, or
  say nothing about it and carry on. Reviewing the track is explicitly not the
  job. MUSIC_OUTLASTS_RULE is split out of GROUNDING_RULES and omitted for this
  one cue; every other red line still stands.
- its own slot, not talkAhead: the depth invariant and its arithmetic are
  untouched. The slot holds a beat that can go on air this instant — never a
  promise — so neither exit can make a boundary wait on TTS.
- half the time (CODA_RIDE_P) it rides the outro, 8-12s before the end, racing
  the song's completion in runVoice; the engine ducks the tail under it exactly
  as it ducks the head under an announce. Recorded like a reply rather than
  aired as a segment, so the front-end keeps naming the still-playing track.
  Otherwise it goes to the head of the queue when the song ends.
- chance enters through DirectorDeps.random, so the coin and the lead are
  pinned in tests. A steer discards the coda with the rest of the look-ahead;
  a new track clears it; no coda is re-fired after a reply.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011L93sAUxB3SMTzMRFscUy3
The offline-render tests build their graph from async work a render cannot wait
on from the inside — a voice clip's read + decode, the fake stream's chunk
scheduling — and then slept a flat 25ms before startRendering. That is a race
the moment the runner is loaded: the coda tests added enough concurrent real-
timer work to starve it, and the render began on a graph whose duck had not
happened yet, reading full gain where the duck plateau was asserted.

Yield twenty short turns instead of guessing one long one, so a starved loop
gets that many chances to run the pending continuations. Four consecutive
full-suite runs green, where one in two was red before.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011L93sAUxB3SMTzMRFscUy3
@wine-fall
wine-fall force-pushed the zachg-0903--coda-beat branch from 6cf1ff2 to 5f8d310 Compare September 3, 2026 07:32
@wine-fall
wine-fall merged commit 6056228 into main Sep 3, 2026
3 checks passed
@wine-fall
wine-fall deleted the zachg-0903--coda-beat branch September 3, 2026 07:33
wine-fall added a commit that referenced this pull request Sep 4, 2026
Six factual corrections from the codex review, all of them places where the
document overstated what it knew:

- Line 1: speaking over a ducked track is not the missing piece — the lead-in
  (#199) and the coda (#200) both do it today. What is left in #163 is the
  autonomous mid-track beat, so the line and its table cell say that instead.
- Line 5: a yt-dlp auth failure comes back through `provider.resolve` and is
  caught by `submit_pick` as "pick another" — it never reaches the stream
  probe, so an expired cookie is a silent stream of rejected candidates, not
  "one more dead probe".
- Line 5: a free Spotify account does have a stream (the desktop app, ads and
  on-demand limits — DESIGN §5); the refusal is that murmur cannot conduct it,
  the external-player duck path being out of scope in spec 03-02.
- Line 5: says outright that it does not reopen DESIGN §8's v1 exclusion.
- Line 0: the crash-report offer landed 08-31, after the 08-25 repro, so it
  cannot have eaten that line; the repro conditions are the August boot state,
  and the offer is a separate new reader worth its own check.
- Line 0: #187 did touch `LineQueue` (hasReader + the IpcHost echo flow), so
  the claim narrows to the take/peek semantics the plain path uses.

Peer review (codex gpt-5.6-sol): 6 findings, 6 applied, 0 dismissed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Sx3J45TyoScFDneRF7qX14
wine-fall added a commit that referenced this pull request Sep 4, 2026
… the lines by (#204)

* docs(roadmap): the login the catalogue asks for, and an order to read the lines by

Adds a sixth line and a priority column.

The new line 5 — an opt-in, guided login for an auth-gated source — is not a
new want: DESIGN.md §5 excluded NetEase (login cookie, VIP) and Spotify (no
no-app-no-membership path; librespot needs Premium) with reasons, and spec
03-01 deferred it as one work item naming cliamp as the credential reference.
What is new is that yt-dlp, already murmur's only provider, ships the
netease:* extractors and takes --cookies / --cookies-from-browser, so the
NetEase half is a credential reaching the provider we run, not a second
provider. Spotify gets the seam and an honest refusal.

The P column gives the lines an order the row numbers no longer carry: line 0
blocks every by-ear judgement above it, lines 1/2/5 change what murmur is,
line 3 is reliability, line 4 is distribution. The Tracked-as cells catch up
with what has landed since the file was written (the lead-in half of line 1,
line 2 built and unmerged).

Line 0 also records a status check: no fix has landed for the dropped first
line, the settled guard predates the repro, and a stub run neither reproduces
nor clears it — the suspected seam is unconfirmed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Sx3J45TyoScFDneRF7qX14

* test(memory): the reload survivor is read on the clock it was written on

`PersistentMemoryStore.forget`'s reload assertion reopened the store with the
real clock while the rows under it were written on a frozen 2026-09-01, so the
48h recent window in `load()` dropped them for age. It passed on the day it
landed and went red on 2026-09-04 with nothing changed — a calendar failure,
not a regression. The reopen now reads the same clock as the writes, which is
what the assertion was ever about: the row survived `forget`, not the window.

Found on CI for the roadmap PR, which touches no code.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Sx3J45TyoScFDneRF7qX14

* docs(roadmap): the claims the reviewer could not verify, corrected

Six factual corrections from the codex review, all of them places where the
document overstated what it knew:

- Line 1: speaking over a ducked track is not the missing piece — the lead-in
  (#199) and the coda (#200) both do it today. What is left in #163 is the
  autonomous mid-track beat, so the line and its table cell say that instead.
- Line 5: a yt-dlp auth failure comes back through `provider.resolve` and is
  caught by `submit_pick` as "pick another" — it never reaches the stream
  probe, so an expired cookie is a silent stream of rejected candidates, not
  "one more dead probe".
- Line 5: a free Spotify account does have a stream (the desktop app, ads and
  on-demand limits — DESIGN §5); the refusal is that murmur cannot conduct it,
  the external-player duck path being out of scope in spec 03-02.
- Line 5: says outright that it does not reopen DESIGN §8's v1 exclusion.
- Line 0: the crash-report offer landed 08-31, after the 08-25 repro, so it
  cannot have eaten that line; the repro conditions are the August boot state,
  and the offer is a separate new reader worth its own check.
- Line 0: #187 did touch `LineQueue` (hasReader + the IpcHost echo flow), so
  the claim narrows to the take/peek semantics the plain path uses.

Peer review (codex gpt-5.6-sol): 6 findings, 6 applied, 0 dismissed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Sx3J45TyoScFDneRF7qX14

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant