From 62b2a928c7349da2263e8de79d2351ae3365901b Mon Sep 17 00:00:00 2001 From: Alfonso Sastre Date: Thu, 3 Sep 2026 19:21:08 +0200 Subject: [PATCH] episode.yaml: the timeline follows the script, the cast survives episode.yaml carries two things with opposite requirements. `voices` is the cast sheet an author hand-tunes and must survive regeneration -- there is a test protecting that, and it is right. `timeline` is derived from the script and must follow it. Preserving the whole file protected both, so deleting a paragraph left its id in the timeline while script.json moved on. `assemble` reads the timeline, so it went on splicing in audio for a line the manuscript no longer had: after an editorial pass that cut twenty paragraphs, ten of seventeen chapters narrated prose the book had removed, and one was also missing two lines that were in the script. Nothing reported any of it -- the render succeeded, the durations looked plausible, and the voice-line manifest said zero stale because every surviving line did match its text. The tell was a count: 716 script lines against 734 voice files. Now a non-forced run merges -- the timeline is rewritten from the plan, an existing cast is kept, and a character the manuscript has introduced since the last run gets a default voice rather than none. --force still overwrites both. The file is only rewritten when something actually changed. The test that failed asserted the whole file was preserved, which is the assumption behind the bug rather than a guard against it. Replaced with three: the cast survives a non-forced run, the timeline follows the script, and --force overwrites the cast too. Co-Authored-By: Claude Opus 5 --- packages/bookkit/src/bookkit/audiobook.py | 25 ++++++++-- packages/bookkit/tests/test_audiobook.py | 59 ++++++++++++++++++----- 2 files changed, 69 insertions(+), 15 deletions(-) diff --git a/packages/bookkit/src/bookkit/audiobook.py b/packages/bookkit/src/bookkit/audiobook.py index 2b5c236..2fc9b4d 100644 --- a/packages/bookkit/src/bookkit/audiobook.py +++ b/packages/bookkit/src/bookkit/audiobook.py @@ -418,13 +418,32 @@ def write_project(plan: AudiobookPlan, dest: Path, *, force: bool = False) -> li "timeline": episode.timeline, } validate_episode(doc) # same contract the renderer loads — see above - # episode.yaml is different in kind: it is the cast sheet, and an author - # is expected to hand-tune it (a real voice per character). It is left - # alone once it exists, and only --force overwrites it. + # episode.yaml carries two things with opposite requirements. `voices` is + # the cast sheet an author hand-tunes and must survive regeneration. + # `timeline` is derived from the script and must follow it: preserving + # the whole file meant that deleting a paragraph left its id in the + # timeline, so `assemble` went on splicing in audio for a line the + # manuscript no longer had -- a chapter that narrated prose its own book + # had cut, with nothing anywhere reporting it. episode_path = ep_dir / "episode.yaml" if force or not episode_path.exists(): episode_path.write_text( yaml.dump(doc, allow_unicode=True, sort_keys=False), encoding="utf-8" ) written.append(f"{episode.name}/episode.yaml") + else: + existing = yaml.safe_load(episode_path.read_text(encoding="utf-8")) or {} + merged = dict(existing) + merged["timeline"] = doc["timeline"] + # A character the manuscript introduced since the last run needs a + # voice; one the author has already cast keeps theirs. + cast = dict(doc["voices"]) + cast.update(existing.get("voices") or {}) + merged["voices"] = cast + if merged != existing: + validate_episode(merged) + episode_path.write_text( + yaml.dump(merged, allow_unicode=True, sort_keys=False), encoding="utf-8" + ) + written.append(f"{episode.name}/episode.yaml") return written diff --git a/packages/bookkit/tests/test_audiobook.py b/packages/bookkit/tests/test_audiobook.py index c2e829a..41c6a23 100644 --- a/packages/bookkit/tests/test_audiobook.py +++ b/packages/bookkit/tests/test_audiobook.py @@ -210,25 +210,60 @@ def test_write_project_emits_podcastkit_layout(tmp_path): assert episode["voices"]["NARRATOR"]["backend"] == "kokoro" -def test_write_project_preserves_existing_unless_forced(tmp_path): - config = _make_book(tmp_path, [("chapters/01.md", "Hello world.")]) - plan = plan_audiobook(config, tmp_path) +def test_write_project_preserves_the_cast_but_refreshes_the_timeline(tmp_path): + """episode.yaml carries two things with opposite requirements. + + `voices` is the cast sheet an author hand-tunes and must survive + regeneration. `timeline` is derived from the script and must follow it: + preserving the whole file meant deleting a paragraph left its id in the + timeline, and `assemble` went on splicing in audio for a line the + manuscript no longer had. + """ + config = _make_book( + tmp_path, [("chapters/01.md", "First para.\n\nSecond para.\n\nThird para.")] + ) dest = tmp_path / "out" - write_project(plan, dest) + write_project(plan_audiobook(config, tmp_path), dest) - # Author hand-tunes the episode (casts a real voice). ep_path = dest / "chapter_01" / "episode.yaml" - ep_path.write_text("title: tuned\n", encoding="utf-8") + doc = yaml.safe_load(ep_path.read_text(encoding="utf-8")) + before = len(doc["timeline"]) + # the author casts a real voice + doc["voices"]["NARRATOR"]["voice_id"] = "a-real-voice" + ep_path.write_text(yaml.dump(doc, allow_unicode=True, sort_keys=False), encoding="utf-8") + + # the manuscript loses a paragraph + (tmp_path / "chapters" / "01.md").write_text("First para.\n\nSecond para.", encoding="utf-8") + write_project(plan_audiobook(config, tmp_path), dest) - # Re-run without force: untouched. - written = write_project(plan, dest) + after = yaml.safe_load(ep_path.read_text(encoding="utf-8")) + assert after["voices"]["NARRATOR"]["voice_id"] == "a-real-voice", "the cast must survive" + assert len(after["timeline"]) < before, "the timeline must follow the script" + script = json.loads((dest / "chapter_01" / "script.json").read_text(encoding="utf-8")) + assert [t["id"] for t in after["timeline"]] == [line["id"] for line in script] + + +def test_an_unchanged_episode_is_not_rewritten(tmp_path): + config = _make_book(tmp_path, [("chapters/01.md", "First para.\n\nSecond para.")]) + dest = tmp_path / "out" + write_project(plan_audiobook(config, tmp_path), dest) + written = write_project(plan_audiobook(config, tmp_path), dest) assert "chapter_01/episode.yaml" not in written - assert ep_path.read_text(encoding="utf-8") == "title: tuned\n" - # With force: overwritten. - written = write_project(plan, dest, force=True) + +def test_force_overwrites_the_cast_too(tmp_path): + config = _make_book(tmp_path, [("chapters/01.md", "First para.\n\nSecond para.")]) + dest = tmp_path / "out" + write_project(plan_audiobook(config, tmp_path), dest) + ep_path = dest / "chapter_01" / "episode.yaml" + doc = yaml.safe_load(ep_path.read_text(encoding="utf-8")) + doc["voices"]["NARRATOR"]["voice_id"] = "a-real-voice" + ep_path.write_text(yaml.dump(doc, allow_unicode=True, sort_keys=False), encoding="utf-8") + + written = write_project(plan_audiobook(config, tmp_path), dest, force=True) assert "chapter_01/episode.yaml" in written - assert "tuned" not in ep_path.read_text(encoding="utf-8") + after = yaml.safe_load(ep_path.read_text(encoding="utf-8")) + assert after["voices"]["NARRATOR"]["voice_id"] != "a-real-voice" def test_write_project_unicode_roundtrip(tmp_path):