Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 22 additions & 3 deletions packages/bookkit/src/bookkit/audiobook.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
59 changes: 47 additions & 12 deletions packages/bookkit/tests/test_audiobook.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
Loading