Skip to content

Make voice_mode describe the cast, not the unused episode fallback #191

Description

@schmug

Task

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 same preset 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

  1. Build a manifest with a cast of clip entries (st_write.cast_map()) and one lines segment.
  2. python3 skills/daily-podcast/render.py --manifest <m> --workdir <w> --dry-run
  • Expected: the record describes clone-rendered audio.
  • Actual: every line logs mode=clone, and the final JSON says:
[1/1] line 1/14 rendering (65 chars, voice=ethan, mode=clone)...
[1/1] line 2/14 rendering (57 chars, voice=ryan,  mode=clone)...
...
  "voice": "Ryan",
  "voice_mode": "preset",

Pointers

  • skills/daily-podcast/render.py:673resolve_voice_mode, whose two arguments carry no cast information. The docstring quoted above is the contract this issue restores.
  • skills/daily-podcast/render.py:639resolve_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:538RUN_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.pyresolve_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 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions