15 KiB
Phase 4: Chapter Audio Generation
Script Setup (Run Once Per Session)
Read skillScriptsPath from the state file — written by Phase 0 with the skill's real install path:
export AUDIOBOOK_SCRIPTS=$(python3 -c "
import json, os
from pathlib import Path
audiobook_root = Path(os.getcwd()) / '.audiobook'
hits = list(audiobook_root.glob('*/.audiobook-state.json')) if audiobook_root.exists() else []
if not hits:
print('ERROR: state file not found — run Phase 0 first'); exit(1)
data = json.loads(hits[0].read_text())
val = data.get('skillScriptsPath', '').strip()
if not val:
print('ERROR: skillScriptsPath missing from state — re-run Phase 0'); exit(1)
if not Path(val).is_dir():
print(f'ERROR: skillScriptsPath does not exist: {val}'); exit(1)
print(val)
")
echo "AUDIOBOOK_SCRIPTS=$AUDIOBOOK_SCRIPTS"
A clean path (no ERROR) means scripts are ready.
If you see skillScriptsPath missing from state: Do NOT re-run Phase 0 just for this. Instead, derive the path directly from the available_skills list visible in your system context — find the <location> entry for the audiobook skill (e.g. file:///Users/alice/.hub/skills/audiobook/SKILL.md), strip file:// and /SKILL.md, append /scripts. Then patch the state file:
import json
from pathlib import Path
audiobook_root = Path(os.getcwd()) / ".audiobook"
state_path = next(audiobook_root.glob("*/.audiobook-state.json"))
state = json.loads(state_path.read_text())
state["skillScriptsPath"] = "/derived/path/to/audiobook/scripts" # replace with actual
state_path.write_text(json.dumps(state, ensure_ascii=False, indent=2))
Then re-run the Script Setup block above.
4.1 Core Principle: TTS Built-in Silence
Never add silence or crossfade via ffmpeg in post-processing. All silence must come from <#X#> pause markers in the TTS text. TTS-generated silence shares the same noise floor and audio characteristics as the speech, preventing waveform discontinuities at splice points.
4.2 TTS Text Silence Padding
Each segment's TTS text needs head/tail silence padding. This is applied automatically by the Step 1 script — do NOT manually edit segment text.
The chapter script from Phase 2 already contains inline <#X#> pause markers. Step 1 preserves ALL existing markers and adds head/tail padding on top.
Padding rules (applied automatically in Step 1):
| Position | Head Padding | Tail Padding |
|---|---|---|
| First segment of book | <#2#> |
<#0.2#> |
| Last segment of book | <#0.2#> |
<#3#> |
| Chapter ending (not last) | <#0.2#> |
<#2#> |
| Normal segment | <#0.2#> |
<#0.2#> |
4.3 TTS Generation (Parallel with Pre-Indexed Plan)
The ONLY rule that matters: 1 text per call, max 3 calls per wave. Run every script below — do NOT skip any.
Generation uses parallel audios_generation calls with a pre-indexed generation plan to guarantee correct playback order regardless of completion timing.
Each segment from the chapter script becomes one entry in the generation plan — no merging or grouping.
Step 1: Parse Chapter Script & Build Generation Plan (MANDATORY script)
This script does everything:
- Reads the chapter script (preserving ALL
<#X#>markers from Phase 2) - Parses segment delimiters and headers
- Maps voice labels to voice_ids via
voice_settings.json - Applies 4.2 head/tail padding
- Outputs
generation_plan.json - Prints every wave's calls — use these directly for Step 2
python3 $AUDIOBOOK_SCRIPTS/build_generation_plan.py <chapter_number>
The script auto-detects chapter title from chapters.md and first/last chapter from .audiobook-state.json. It also validates voice assignment and warns if dialogue segments fall back to narrator voice.
This script is MANDATORY — run it first, then use its output for Step 2.
Step 2: Fire TTS calls wave by wave
Use the exact calls printed by Step 1. Each call has exactly 1 text and 1 filename.
Rules:
- Fire up to 3 calls per wave (copy the calls from Step 1 output) — fire all 3 simultaneously (in parallel), NOT one by one
- Wait for ALL calls in a wave to complete before starting the next wave
- No sleep needed between successful waves
- If any call fails → sleep 30 seconds, then continue to the next wave. Do NOT retry the failed call inline. All failures are handled after Step 3.5 by the Failure & Retry script, which reads parameters from
generation_plan.json(guaranteed correct). Inline retry risks using wrong parameters from LLM context.
Example (from Step 1 output):
# Wave 1 (fire these 3 calls in parallel):
audios_generation(texts=["<#0.2#>She opened the door.<#0.5#>"], voice_id="narrator", speed=0.9, filenames=["ch1_idx_0"])
audios_generation(texts=["Hello!"], voice_id="alice", speed=1.0, filenames=["ch1_idx_1"])
audios_generation(texts=["<#0.2#>He waved back.<#0.2#>"], voice_id="narrator", speed=0.9, filenames=["ch1_idx_2"])
# Wait for wave 1 to complete. Then wave 2:
audios_generation(texts=["Good morning."], voice_id="bob", speed=1.0, filenames=["ch1_idx_3"])
...
DO NOT:
- Put multiple texts in one call:
texts=["a", "b", "c"]is WRONG - Invent your own filenames — use the
ch{N}_idx_{M}from Step 1 - Skip Step 1 and construct calls yourself
Step 3: Match blobs to plan (MANDATORY script)
After all TTS calls complete, run this to map generated audio files back to plan entries:
python3 $AUDIOBOOK_SCRIPTS/match_blobs.py <chapter_number>
The script reads generation_plan.json + assets.json, matches by filename (last-write-wins for retries), and updates generation_plan.json with blob_path for each entry.
Step 3.5: Verify blob content (MANDATORY script)
Verify every matched blob's metadata matches the plan (catches wrong voice_id or text from misfired calls):
python3 $AUDIOBOOK_SCRIPTS/verify_blobs.py <chapter_number>
The script compares voice_id and cleaned text from assets.json metadata against generation_plan.json. Mismatched entries have their blob_path cleared so the Retry script picks them up.
How clean_text() works (important for debugging false positives):
The script normalises both sides before comparing:
html.unescape()— the chapter script (Phase 2 output) may store pause markers as<#0.6#>(HTML-escaped) rather than<#0.6#>. The plan inherits this encoding. The TTS metadata always stores raw markers. Unescaping before stripping ensures both sides match.- Smart-quote → ASCII normalisation — the plan may contain Unicode curly quotes (
"/"/'/'). The TTS engine normalises these to straight ASCII quotes ("/') in stored metadata. Both sides are normalised before comparison.
If you see unexpected "text mismatch" failures on Step 3.5, check generation_plan.json for < / > or \u201c / \u201d — these indicate the source script was HTML-escaped or contained smart quotes. The script handles both automatically; no manual fix needed.
Step 3.6: Organize segment files (MANDATORY script)
Move segment audio files from the project root into audio/chapter_{N}/segments/ and update assets.json paths:
python3 $AUDIOBOOK_SCRIPTS/organize_segment_files.py <chapter_number>
The script reads generation_plan.json for filenames, moves .mp3 and _subtitle.json files, and updates assets.json path fields. Idempotent — safe to re-run after retries.
Step 4: Write manifest (MANDATORY script)
python3 $AUDIOBOOK_SCRIPTS/write_manifest.py <chapter_number>
The script reads generation_plan.json, writes manifest.txt in playback order, and verifies all blob files exist on disk.
Failure & Retry (MANDATORY script)
If Step 3 reports unmatched segments, run this to get retry calls:
python3 $AUDIOBOOK_SCRIPTS/print_retry_calls.py <chapter_number>
The script cleans old workspace .mp3 files (prevents gateway dedup suffix), then prints exact retry calls grouped by wave (max 3 per wave).
Retry procedure:
- Run Step 3 (blob matching) + Step 3.5 (verification) to identify all failures
- Run the Failure & Retry script above — it reads
generation_plan.jsonand prints exact calls - Sleep 20 seconds (let system recover from transient issues)
- Fire the printed calls (max 3 per wave) — do NOT modify the parameters
- Re-run Step 3 + Step 3.5 → re-run this script → repeat until all matched
CRITICAL: Never construct retry calls yourself. Always use this script's output. The script reads parameters from generation_plan.json, ensuring text, voice_id, speed, and filename are all correct. Manual retry from memory is the #1 cause of wrong audio content.
Never skip a failed segment. The manifest requires every segment to have a blob_path.
TTS text limits
- Each text should not exceed 2500 characters
- If a single segment is too long, split by sentence into sub-segments connected with
<#0.2#> - Sub-segments share the same voice_id and get consecutive indices in the plan
4.4 Concatenation & Export
Step 1: Verify manifest
python3 $AUDIOBOOK_SCRIPTS/write_manifest.py <chapter_number>
This is the same script as Step 4 — it writes manifest.txt and verifies every file exists. Run it again here to confirm nothing changed between generation and concatenation.
Step 2: Delegate to editing sub-agent
Pass the exact ordered file list from manifest.txt to the editing sub-agent. Do NOT re-sort or re-order — the manifest is the single source of truth.
请帮我拼接以下音频片段成一个完整的章节文件。
不需要 crossfade、fade 或 DC removal — 直接拼接即可。
片段列表(按顺序排列,不要重新排序):
1. /abs/path/to/.hilo/.blobs/{uuid1}.mp3
2. /abs/path/to/.hilo/.blobs/{uuid2}.mp3
3. /abs/path/to/.hilo/.blobs/{uuid3}.mp3
...
格式要求:MP3, 44100Hz, 256kbps
输出文件名:chapter_{N}.mp3
完成后请返回输出文件路径和时长。
Important:
- Input file list MUST come from
manifest.txt - Number each file to make order unambiguous
- Do NOT specify an output path — let the editing agent decide
- The editing sub-agent automatically handles canvas registration (writes to both project root and
.hilo/.blobs/, registers inassets.json)
Filename rules
- Individual segments:
ch{N}_idx_{M}(generated by Step 1 script, do not change) - Final chapter audio:
chapter_{N}.mp3 - Never use Chinese characters in any audio filename — canvas registration silently fails
4.5 Audio Quality Check
After each chapter's final audio is exported, run the quality checker:
python3 $AUDIOBOOK_SCRIPTS/audio_check.py /path/to/exported_chapter.mp3
ffmpeg dependency:
audio_check.pyandgenerate_assembly_report.pyboth shell out toffmpeg/ffprobe. Ifffmpegis not on$PATH, prepend the binary location:PATH="/opt/homebrew/Cellar/ffmpeg/8.1/bin:$PATH" python3 $AUDIOBOOK_SCRIPTS/audio_check.py ...Find the binary with:
find /usr /opt /Users -name "ffmpeg" -type f 2>/dev/null | head -3
If issues are found, delegate fixes to the editing sub-agent:
- Pops/clicks: Apply adeclick/adeclip filter
- DC offset > 1%: Apply dcshift correction
- Clipping: Reduce volume and regenerate the affected segments
Energy spikes are normal: The quality checker reports "energy spikes" at segment boundaries (silence → speech onset from hard-cut concatenation). These are expected and require no action — they are not audio artifacts.
4.6 Assembly Report
After the quality check passes, generate a markdown assembly report. This is the single source of truth for users to inspect, locate, and request re-edits of any segment.
What it contains
A markdown table with one row per segment:
| Column | Content |
|---|---|
# |
Segment index (primary identifier for re-edit requests) |
Start / End |
Precise timestamps hh:mm:ss.ss via ffprobe |
Voice ID |
The actual voice_id used |
Local File |
Root-directory filename (matches canvas display) |
Text |
Clean spoken text with <#X#> markers stripped |
Generation script
python3 $AUDIOBOOK_SCRIPTS/generate_assembly_report.py <chapter_number>
ffmpeg dependency: This script uses
ffprobeto compute timestamps. Ifffprobeis not on$PATH, prepend the binary location (same as 4.5 above).
The script reads generation_plan.json, uses ffprobe to compute timestamps, maps blob refs to local filenames via assets.json, and writes {book_name}_chapter_{N}_assembly.md to the project root. Auto-detects language for tips text.
Canvas note: The canvas does not render markdown tables — only header lines are previewed. Users can open the assembly report in any markdown viewer for the full table.
Re-edit workflow
When the user requests changes (e.g. "segment #14 sounds too fast"):
- Look up index 14 in
generation_plan.jsonforvoice_id,speed,text,filename - Regenerate:
audios_generation(texts=[new_text], voice_id=..., speed=..., filenames=[filename]) - Re-run Step 3 blob matching to pick up the new blob (latest entry with same filename wins)
- Rewrite
manifest.txtfrom updated plan - Re-concatenate via editing sub-agent
- Re-run quality check and regenerate assembly report
4.7 Final User Message (REQUIRED)
After the assembly report is generated, send a completion message. Match the user's language.
Include:
- Final audio filename (UUID on canvas)
- Duration
- Cast list (narrator + characters)
- Assembly report filename
- How to request fixes (mention segment # number)
- Options: generate next chapter / re-edit a segment
4.8 Multi-Chapter Generation
Note: True background/parallel chapter generation is not currently supported. Chapters are processed one at a time (sequentially) to ensure quality and allow user review between chapters.
After the first chapter is confirmed by the user:
- Tell the user how many chapters remain and ask if they want to continue generating them
- For each remaining chapter, follow the full 4.2–4.6 workflow in order:
- Build generation plan → Fire TTS waves → Match blobs → Verify → Organize → Write manifest → Concatenate → Quality check → Assembly report
- After each chapter completes, update
chaptersCompletedin.audiobook-state.json - Notify the user that chapter N is done, show duration and assembly report filename, then ask whether to proceed to the next chapter or stop
Error Handling
- TTS generation failure: Sleep 20s then retry. Never skip — keep retrying until success.
- File parsing failure: Prompt the user to check file format
- Audio concatenation failure: Check file paths and formats