You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
voice_mode should describe the engine that actually produced the audio. On a lines-cast episode it currently describes an episode-level voice that no segment used: a Surface Tension episode rendered 100 % through ref_audio clones reports "voice_mode": "preset". Decide what the field should say for a cast episode, implement it, and keep the daily show's and Frontier Commits' records byte-identical.
Context
Found while landing #177 (#189 flagged it under "Not done"; reproduced again on 2026-08-26 against main at ebc3c0c).
This is not a cosmetic nit, because it defeats the field's stated purpose. resolve_voice_mode's own docstring:
The label can read "Ryan" while voice_instruct routes to VoiceDesign, so the label alone lies about what the listener hears. Operators read the SHIPPED line to catch voice regressions, so the mode is reported truthfully alongside it
voice_mode exists specifically so the report cannot lie about what the listener hears. On a lines-cast show the mode itself now lies, and it lies in the direction that hides a regression: an episode that silently fell back to presets and one that rendered every line from a clone produce the samepreset record.
The mechanism is that resolve_voice_mode(voice_instruct, ref_audio) is fed only from resolve_voice(manifest), which reads the episode voice keys. It never sees manifest["cast"], which is what actually decides every line's engine in a lines segment. Surface Tension's manifests set "voice": "Ryan" purely as the fallback for a hypothetical plain-text segment — there are none — so the reported pair is Ryan / preset while every line rendered mode=clone.
Note record["voice"] is misleading in the same breath and should be decided together: "Ryan" now names both a bundled preset and a cast persona backed by refs/ryan.wav. The label happens to read plausibly while meaning the wrong one of the two.
This is the direct descendant of #12 (Fix voice label when voice_instruct overrides preset/house), which fixed exactly this class of divergence for the previous voice mode to arrive.
Reproduction
Build a manifest with a cast of clip entries (st_write.cast_map()) and one lines segment.
skills/daily-podcast/render.py:673 — resolve_voice_mode, whose two arguments carry no cast information. The docstring quoted above is the contract this issue restores.
skills/daily-podcast/render.py:639 — resolve_voice, the episode-level resolution that feeds it.
skills/daily-podcast/render.py:4565 — the call site, and :4568 where record["voice"] / record["voice_mode"] are set. manifest is in scope here, so the cast is reachable without threading a new argument through.
skills/daily-podcast/render.py:538 — RUN_LOG_FIELDS, where both fields live. Records are built from _new_run_record() and every line must carry the full key set.
tests/test_render.py:164-177 — the three existing resolve_voice_mode tests. None is cast-aware; new cases go beside them.
skills/daily-podcast/SKILL.md:537 — the documented operator audit jq -r '"\(.timestamp) \(.voice) (\(.voice_mode))"' ~/.config/daily-podcast/runs.jsonl. Whatever value you choose has to remain useful in that one-liner.
skills/daily-podcast/SKILL.md:514 and :731 — the documented example record and the final-JSON contract; both need updating if the taxonomy grows.
CLAUDE.md → the run-log invariant (append-only, stable schema, a new field is APPENDED to RUN_LOG_FIELDS and left null elsewhere, never added ad hoc at one call site).
Constraints
The daily show and Frontier Commits must produce byte-identical records. Both are single-voice, cast-less shows; assert this rather than assuming it.
Decide the mixed case explicitly. A cast may legitimately mix preset and clip entries. "clone" is only honest if every member is a clip; a mixed cast needs either a distinct value or a documented rule. Do not silently report the mode of whichever member happens to sort first.
Do not widen the taxonomy carelessly.clone / design / preset are consumed by the documented jq audit and by anything reading runs.jsonl. Growing it is allowed, but the new value must be documented in SKILL.md's example record and in the final-JSON contract, in the same commit.
Do not break the run-log schema. If a new field is preferable to overloading voice_mode (e.g. recording the cast's engines separately), append it to RUN_LOG_FIELDS and leave it null on every path that does not set it.
voice_instruct + cast still dies. That combination is already rejected in validate_manifest; this issue must not create a path around it.
Repo conventions: failing test first, test and fix in the same commit, conventional commit prefix, full pytest + ruff check . + ruff format --check . green with counts in the PR body.
Acceptance criteria
A fully-cloned lines episode reports a voice_mode that does not say preset, proven by a test that fails against today's code.
A cast mixing preset and clip entries reports something documented and unambiguous — the chosen rule is stated in SKILL.md, not just in code.
The daily show's and Frontier Commits' run records are unchanged, asserted explicitly.
record["voice"] is either fixed alongside or explicitly left as-is with the reason recorded in the PR — it currently reads as a preset name on a clone-rendered show.
Any new value or field appears in RUN_LOG_FIELDS, in SKILL.md's example record (:514), and in the final-JSON contract (:731), in the same commit.
The documented jq audit at SKILL.md:537 still produces a useful line for all four shows.
Full pytest + ruff check . + ruff format --check . green with counts in the PR body.
Out of scope
Changing how any voice is resolved or rendered. This is a reporting fix; resolve_voice's four-mode precedence and resolve_cast_voice's behaviour both stay exactly as they are.
The SHIPPED line format in any SKILL.md's unattended-run section. Those lines do not currently carry voice_mode, and adding it is a separate call.
Backfilling existing runs.jsonl records. The log is append-only history; a wrong old line stays.
Reasoning guidance
The mixed-cast case is the only real decision here and it is worth settling before writing code — everything else follows from it. Prioritise getting that rule right and documented over the size of the diff.
Task
voice_modeshould describe the engine that actually produced the audio. On alines-cast episode it currently describes an episode-level voice that no segment used: a Surface Tension episode rendered 100 % throughref_audioclones reports"voice_mode": "preset". Decide what the field should say for a cast episode, implement it, and keep the daily show's and Frontier Commits' records byte-identical.Context
Found while landing #177 (#189 flagged it under "Not done"; reproduced again on 2026-08-26 against
mainatebc3c0c).This is not a cosmetic nit, because it defeats the field's stated purpose.
resolve_voice_mode's own docstring:voice_modeexists specifically so the report cannot lie about what the listener hears. On a lines-cast show the mode itself now lies, and it lies in the direction that hides a regression: an episode that silently fell back to presets and one that rendered every line from a clone produce the samepresetrecord.The mechanism is that
resolve_voice_mode(voice_instruct, ref_audio)is fed only fromresolve_voice(manifest), which reads the episode voice keys. It never seesmanifest["cast"], which is what actually decides every line's engine in alinessegment. Surface Tension's manifests set"voice": "Ryan"purely as the fallback for a hypothetical plain-textsegment — there are none — so the reported pair isRyan/presetwhile every line renderedmode=clone.Note
record["voice"]is misleading in the same breath and should be decided together:"Ryan"now names both a bundled preset and a cast persona backed byrefs/ryan.wav. The label happens to read plausibly while meaning the wrong one of the two.This is the direct descendant of #12 (Fix voice label when voice_instruct overrides preset/house), which fixed exactly this class of divergence for the previous voice mode to arrive.
Reproduction
castof clip entries (st_write.cast_map()) and onelinessegment.python3 skills/daily-podcast/render.py --manifest <m> --workdir <w> --dry-runmode=clone, and the final JSON says:Pointers
skills/daily-podcast/render.py:673—resolve_voice_mode, whose two arguments carry no cast information. The docstring quoted above is the contract this issue restores.skills/daily-podcast/render.py:639—resolve_voice, the episode-level resolution that feeds it.skills/daily-podcast/render.py:4565— the call site, and:4568whererecord["voice"]/record["voice_mode"]are set.manifestis in scope here, so the cast is reachable without threading a new argument through.skills/daily-podcast/render.py:538—RUN_LOG_FIELDS, where both fields live. Records are built from_new_run_record()and every line must carry the full key set.skills/daily-podcast/render.py—resolve_cast_voice(added by feat(surface-tension): recorded cast clips, show art, and the render path they needed (#177) #189), which already resolves one cast entry into mode/label/references/fingerprint. It is the natural source of truth for what a cast member actually renders as; do not re-derive it.tests/test_render.py:164-177— the three existingresolve_voice_modetests. None is cast-aware; new cases go beside them.skills/daily-podcast/SKILL.md:537— the documented operator auditjq -r '"\(.timestamp) \(.voice) (\(.voice_mode))"' ~/.config/daily-podcast/runs.jsonl. Whatever value you choose has to remain useful in that one-liner.skills/daily-podcast/SKILL.md:514and:731— the documented example record and the final-JSON contract; both need updating if the taxonomy grows.CLAUDE.md→ the run-log invariant (append-only, stable schema, a new field is APPENDED toRUN_LOG_FIELDSand left null elsewhere, never added ad hoc at one call site).Constraints
"clone"is only honest if every member is a clip; a mixed cast needs either a distinct value or a documented rule. Do not silently report the mode of whichever member happens to sort first.clone/design/presetare consumed by the documentedjqaudit and by anything readingruns.jsonl. Growing it is allowed, but the new value must be documented in SKILL.md's example record and in the final-JSON contract, in the same commit.voice_mode(e.g. recording the cast's engines separately), append it toRUN_LOG_FIELDSand leave it null on every path that does not set it.voice_instruct+ cast still dies. That combination is already rejected invalidate_manifest; this issue must not create a path around it.pytest+ruff check .+ruff format --check .green with counts in the PR body.Acceptance criteria
linesepisode reports avoice_modethat does not saypreset, proven by a test that fails against today's code.record["voice"]is either fixed alongside or explicitly left as-is with the reason recorded in the PR — it currently reads as a preset name on a clone-rendered show.RUN_LOG_FIELDS, in SKILL.md's example record (:514), and in the final-JSON contract (:731), in the same commit.jqaudit at SKILL.md:537 still produces a useful line for all four shows.pytest+ruff check .+ruff format --check .green with counts in the PR body.Out of scope
resolve_voice's four-mode precedence andresolve_cast_voice's behaviour both stay exactly as they are.SHIPPEDline format in any SKILL.md's unattended-run section. Those lines do not currently carryvoice_mode, and adding it is a separate call.runs.jsonlrecords. The log is append-only history; a wrong old line stays.Reasoning guidance
The mixed-cast case is the only real decision here and it is worth settling before writing code — everything else follows from it. Prioritise getting that rule right and documented over the size of the diff.