Flatten skill category directory structure

This commit is contained in:
2026-05-20 17:04:50 +08:00
parent 0dd552a6f7
commit 63f2baa4bd
354 changed files with 0 additions and 0 deletions
@@ -0,0 +1,86 @@
# Phase 0: Import Book
## Workflow
1. Ask the user to provide a book file (supports .txt / .md / .pdf / .epub)
2. Read file contents:
- `.txt` / `.md`: Read directly
- `.pdf`: Use the `Read` tool (supports PDF reading)
- `.epub`: Use `Bash` tool to run Python parsing (see below)
3. Save parsed plain text to `source/book.txt`
4. Confirm with the user that content was read correctly (show a preview of the first few hundred characters)
## State File
After import, write `.audiobook-state.json` using Python `json.dumps()`.
**`skillScriptsPath` must be the real absolute path** — not a placeholder. Derive it 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`, then append `/scripts`:
`/Users/alice/.hub/skills/audiobook/scripts`
That string is your `skill_scripts_path`. Use it literally — do not guess or reconstruct from memory.
```python
import json, os, sys
from pathlib import Path
# Replace the string below with the path you derived above — do this BEFORE running the script.
# Example: "/Users/alice/.hub/skills/audiobook/scripts"
skill_scripts_path = "/FILL_IN_BEFORE_RUNNING/audiobook/scripts"
# Verify the path exists before continuing
if not Path(skill_scripts_path).is_dir():
print(f"ERROR: skillScriptsPath does not exist: {skill_scripts_path}", file=sys.stderr)
sys.exit(1)
# Locate the state file (project-relative, not skill-relative)
audiobook_root = Path(os.getcwd()) / ".audiobook"
book_dir = audiobook_root / "<book_name>"
book_dir.mkdir(parents=True, exist_ok=True)
state = {
"currentStep": "parse",
"bookName": "<book_name>",
"sourceFile": "source/book.txt",
"skillScriptsPath": skill_scripts_path,
"totalChapters": 0,
"characters": [],
"voiceMapping": {
"narrator": {"voiceId": None, "speed": 0.9},
"characters": {}
},
"chaptersCompleted": [],
"currentChapter": 0
}
(book_dir / ".audiobook-state.json").write_text(json.dumps(state, ensure_ascii=False, indent=2))
print(f"State file written. skillScriptsPath={skill_scripts_path}")
```
**Before running**: replace `skill_scripts_path` with the actual value derived above. The `print` at the end confirms what was written.
## EPUB Parsing
```python
import zipfile
from html.parser import HTMLParser
class TextExtractor(HTMLParser):
def __init__(self):
super().__init__()
self.text = []
self.skip = False
def handle_starttag(self, tag, attrs):
if tag in ('script', 'style'):
self.skip = True
def handle_endtag(self, tag):
if tag in ('script', 'style'):
self.skip = False
if tag in ('p', 'div', 'br', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6'):
self.text.append('\n')
def handle_data(self, data):
if not self.skip:
self.text.append(data)
```
@@ -0,0 +1,157 @@
# Phase 1: Text Analysis
## Script Setup (Run Once Per Session)
```bash
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`, derive the path from the `<location>` entry for the audiobook skill in your `available_skills` list (strip `file://` and `/SKILL.md`, append `/scripts`) and patch the state file — same procedure as Phase 3/4.
---
## 1.1 Chapter Splitting
Automatically identify chapter boundaries:
- Common chapter markers: `Chapter X`, `CHAPTER X`, `Part X`, numbered headings, etc.
- For Chinese text: `第X章`, `第X节`, etc.
- If automatic identification fails, ask the user to specify the delimiter
- Generate `analysis/chapters.md` recording each chapter's title and position range
## 1.2 Character & Dialogue Identification
Three sequential steps: **regex pre-processing** (deterministic) → **character pre-identification** (LLM) → **LLM segmentation** (speaker attribution).
---
### Step A: Regex Pre-processing (MANDATORY script)
**Executor:** Bundled script — no LLM needed. **Run it directly, do NOT rewrite it.**
**Input:** `source/book.txt`
**Output:** `analysis/chapter_{N}/preprocessed.txt` — each line contains either pure narration or pure dialogue, never both.
```bash
python3 $AUDIOBOOK_SCRIPTS/preprocess_chapter.py <chapter_number>
```
**This script is MANDATORY — do NOT skip it or write your own regex.**
---
### Step A2: Character Pre-identification (LLM)
**Before segmenting**, scan the **full chapter text** to build a character list. This gives the LLM global context for speaker attribution.
**Executor:** LLM via `text_generation` MCP tool.
**Input:** `preprocessed.txt` from Step A.
**Output:** `analysis/characters.md` — format see `references/characters-example.md`.
**Prompt template for character identification:**
```
Read the following chapter text and identify ALL speaking characters.
For each character, provide:
1. Name (as it appears in the text)
2. Gender (if determinable)
3. Brief description (role, relationship to other characters)
4. Example dialogue line (one quote from the text)
Rules:
- Only list characters who SPEAK (have quoted dialogue)
- The narrator is NOT a character
- If a character is referred to by multiple names/titles, note all variants
- Pay attention to attribution phrases like "X said", "X replied", "X asked"
Text:
{preprocessed_text}
```
Write `analysis/characters.md` with the results. This character list will be used in Step B for speaker attribution.
---
### Step B: LLM Segmentation
**Executor:** LLM via `text_generation` MCP tool.
**Input:** `preprocessed.txt` from Step A + `characters.md` from Step A2.
**Output:** `analysis/chapter_{N}/segments.json` — format see `references/segments-example.json`.
The LLM's task is to:
1. Mark each line as `narration` or `dialogue` (Step A already split them, but the LLM decides the **type** — e.g., a quoted letter may be narration)
2. For dialogue lines, identify who is speaking using the character list from Step A2
**Prompt template for segmentation:**
```
You are segmenting a chapter for audiobook production.
Known characters in this chapter:
{characters_list}
For each line of the preprocessed text, output a JSON segment:
- Narration (anything outside quotes, or quoted non-speech like letters/signs):
{"type": "narration", "voice": "narrator", "text": "..."}
- Dialogue (a character speaking):
{"type": "dialogue", "voice": "<character name>", "character": "<character name>", "text": "..."}
Speaker identification rules:
- Use surrounding attribution ("张叔说", "she replied") to determine the speaker
- In alternating two-person conversations, track the turn-taking pattern
- If the previous line is narration containing a character name + speech verb, the next dialogue belongs to that character
- Mark unattributable dialogue as voice: "unknown", character: "unknown"
- Use the EXACT character name from the characters list above
Text to segment:
{preprocessed_text}
```
**Attribution Validation (CRITICAL):**
After segmentation, verify all speaker attributions:
1. Check against story context — who is speaking to whom?
2. For 2-speaker conversations, verify the alternating pattern is consistent
3. Correct any wrong attributions before proceeding
**Output files:**
- `analysis/chapter_{N}/segments.json`**IMPORTANT: write with Python `json.dump()`, not Bash echo/heredoc** (Chinese curly quotes `""` break hand-built JSON)
**segments.json must include a `voice` field on EVERY segment:**
- Narration segments: `"voice": "narrator"`
- Dialogue segments: `"voice": "<character name>"` (same value as the `character` field)
- This `voice` field is the **single source of truth** for voice assignment in all downstream phases. See `references/segments-example.json` for the exact format.
**Before writing**, ensure directories exist:
```python
from pathlib import Path
(BOOK_DIR / "analysis" / f"chapter_{N}").mkdir(parents=True, exist_ok=True)
```
---
### Step C: Attribution Validation Script (MANDATORY)
After writing `segments.json`, run the bundled validation script to detect common attribution errors:
```bash
python3 $AUDIOBOOK_SCRIPTS/validate_segments.py <chapter_number>
```
The script checks: unknown speakers, missing `voice`/`character` fields, and flags runs of 3+ consecutive same-speaker dialogue for review.
**This script is MANDATORY — run it after every `segments.json` is written.** If ERRORs appear, fix the segments before proceeding to Phase 2.
@@ -0,0 +1,105 @@
# Phase 2: Text Rewriting (Adding Pause Markers)
## Script Setup (Run Once Per Session)
```bash
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`, derive the path from the `<location>` entry for the audiobook skill in your `available_skills` list (strip `file://` and `/SKILL.md`, append `/scripts`) and patch the state file — same procedure as Phase 3/4.
---
## Input / Output
- **Input:** `analysis/chapter_{N}/segments.json` from Phase 1 (each segment is already unambiguously narration or dialogue).
- **Output:** `scripts/chapter_{N}.md` — the same segments with `<#X#>` pause markers inserted. See `references/chapter-script-example.md` for the complete format.
## Pause Marker Syntax
Use the TTS engine's native `<#X#>` pause syntax, where X is pause duration in seconds (supports decimals).
**CRITICAL: The format is `<#0.5#>`, NOT `<#0.5s#>`. Never add `s` after the number. The `s` suffix causes TTS to read the marker as literal text instead of pausing, resulting in audio 3x longer than expected.**
- Correct: `<#0.5#>`, `<#1.2#>`, `<#0.25#>`
- WRONG: `<#0.5s#>`, `<#1.2s#>`, `<#0.25s#>`
- WRONG: `<#0.3-0.5#>`, `<#0.5~0.8#>`**never use ranges, pick one value**
## Pause Rules
**Narration** (includes attribution phrases like "she said," — these are narration segments per Phase 1):
| Scenario | Marker Example | Description |
|----------|---------------|-------------|
| Normal period/full stop | `<#0.6#>` | Natural inter-sentence pause |
| Paragraph end | `<#1.5#>` | Paragraph transition |
| Scene change | `<#2#>` | Environment/time transition |
| Ellipsis | `<#1.2#>` | Dramatic pause effect |
| Exclamation/question mark | `<#0.4#>` | Slightly shorter than period, maintains emotional continuity |
| Comma | `<#0.25#>` | Light breath (only add in long sentences) |
| After attribution phrase | `<#0.3#>` | Pause at end of "he said," before the next segment begins |
**Dialogue:**
| Scenario | Marker Example | Description |
|----------|---------------|-------------|
| End of dialogue line | `<#0.4#>` | Brief pause after line |
| Dialogue to narration transition | `<#0.8#>` | From dialogue back to narration |
| Between dialogue lines (same scene) | `<#0.6#>` | Two characters alternating |
| Hesitation within dialogue | `<#0.8#>` | Character's inner hesitation |
## Output Format
Phase 2 has two sub-steps:
1. **LLM adds pause markers** to segment text (update `segments.json` in-place with `<#X#>` markers)
2. **Python script generates `chapter_N.md`** from the updated `segments.json` — this guarantees `voice:` headers come directly from data, not LLM generation
### Sub-step 1: LLM adds pause markers
Read `segments.json`, add `<#X#>` pause markers to each segment's `text` field according to the Pause Rules above, and write the updated segments back to the same file.
**Do NOT generate `chapter_N.md` directly.** Only update the `text` field in `segments.json`.
### Sub-step 1.5: Validate pause markers (MANDATORY script)
After adding pause markers, run validation to catch and auto-fix malformed markers:
```bash
python3 $AUDIOBOOK_SCRIPTS/validate_pause_markers.py <chapter_number>
```
The script auto-fixes common LLM mistakes:
- `<#0.6>``<#0.6#>` (missing closing `#`)
- `<#0.6s#>``<#0.6#>` (spurious `s` suffix)
- `<# 0.6 #>``<#0.6#>` (whitespace around number)
- `<#0.3-0.5#>``<#0.4#>` (range → average)
If unfixable markers remain, the script exits with error — fix manually before proceeding.
### Sub-step 2: Generate chapter script
After pause markers are validated, run the bundled script to generate `chapter_N.md`:
```bash
python3 $AUDIOBOOK_SCRIPTS/generate_chapter_script.py <chapter_number>
```
The script reads `segments.json` and writes `chapter_N.md` with `voice:` headers directly from the `voice` field — no LLM generation involved.
**This script is MANDATORY — do NOT skip it or write `chapter_N.md` by hand.**
@@ -0,0 +1,298 @@
# Phase 3: Voice Preview & Feedback
## Script Setup (Run Once Per Session)
Read `skillScriptsPath` from the state file — written by Phase 0 with the skill's real install path:
```bash
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:
```python
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.
---
## Speed Rules (Reference)
Speed is assigned **by role**, not by the voice's natural characteristics:
| Role | Default Speed |
|------|--------------|
| Narrator | **0.9** |
| All characters (actors) | **1.0** |
User may override via the preview page feedback buttons.
---
## Workflow
Execute these steps **in this exact order**:
```
Step 1: Get voice candidates → Step 2: Extract preview text
→ Step 3: Generate preview samples → Step 4: Build preview data JSON
→ Step 5: Launch preview page (BLOCKING) → Step 6: Handle feedback
```
---
## Step 1: Get Voice Candidates
Read character list from `analysis/characters.md`. Call `get_voice_id` **once per role** — one for narrator, one per character.
### Voice Selection Priority (filter layer by layer)
1. **Language match** (hard): Chinese books → Chinese voices only. English books → English voices only.
2. **Gender match** (hard): Male characters → male voices. Female characters → female voices.
3. **Age match** (hard): Elderly → aged/gravelly. Young → bright/energetic. Middle-aged → steady/mature.
4. **Personality match** (soft): Best style match (cheerful / calm / authoritative, etc.)
### Narrator Voice Rules
- Use **storyteller/narrator-type voices** suitable for sustained narration
- Must be easy to listen to without fatigue (this is the "baseline" voice)
- **Narrator and character voices must not overlap**
### Calling Pattern
```
# ✅ CORRECT — one call per role, returns a list of all matching voices
# then YOU pick 3 candidates from the returned list
get_voice_id(user_requirement="English male narrator storytelling calm warm")
get_voice_id(user_requirement="English female young warm gentle emotional")
# ❌ WRONG — calling 3 times per role to "get 3 candidates"
# get_voice_id already returns multiple results; one call is enough
get_voice_id(user_requirement="English male narrator warm")
get_voice_id(user_requirement="English male narrator calm")
get_voice_id(user_requirement="English male narrator cinematic")
# ❌ WRONG — combining all roles into one call
get_voice_id(user_requirement="English audiobook: (1) narrator; (2) female young; (3) male middle-aged")
```
**Concurrency:** `get_voice_id` is mostly a local cache lookup, but max **4 parallel calls** to be safe.
**Fallback:** If no results, consult `references/voices-backup-overseas.md`.
> **Note:** `female-chengshu` (mature female voice) has known audio artifact issues — **do not use**.
### Output
Select **exactly 3 candidates** (1 primary + 2 alternates) per role. Candidates should differ in style to give the user real choice. Each needs: `voice_id`, `name`, `description`.
---
## Step 2: Extract Preview Text
Preview samples should be **~10 seconds** of speech (roughly 2540 words for English narration, 1525 words for dialogue). Do NOT use long paragraphs.
Good examples:
```
# Narrator (~10s): 1 atmospheric sentence
"The museum closed at six.<#0.5#> By six fifteen, the last guard had finished his rounds<#0.3#> and the motion sensors armed themselves with a faint electronic chirp.<#0.6#>"
# Character dialogue (~8s): 1-2 punchy lines
"What are you doing in the building, Margaret?<#0.3#> Your department doesn't have after-hours access this week.<#0.5#>"
```
---
## Step 3: Generate Preview Samples
Generate one preview audio per **candidate voice** per role. Use the **same text** for all candidates of the same role (for fair A/B comparison).
### Step 3.1: Build Preview Generation Plan
**Before any `audios_generation` call**, write `voice_samples/preview_plan.json`.
**Do NOT add `filename` fields** — the `print_preview_calls.py` script auto-assigns normalized filenames (pattern: `preview_{role}_{index}`).
```json
[
{
"role": "narrator",
"sample_text": "<#0.2#>The museum closed at six...<#0.2#>",
"speed": 0.9,
"candidates": [
{ "voice_id": "English_CaptivatingStoryteller", "voice_name": "Captivating Storyteller" },
{ "voice_id": "English_WarmNarrator", "voice_name": "Warm Narrator" },
{ "voice_id": "English_DeepVoice", "voice_name": "Deep Voice" }
]
},
{
"role": "alice",
"sample_text": "What a beautiful day.",
"speed": 1.0,
"candidates": [
{ "voice_id": "English_Kind-heartedGirl", "voice_name": "Kind Girl" },
{ "voice_id": "English_GentleWoman", "voice_name": "Gentle Woman" },
{ "voice_id": "English_WarmFemale", "voice_name": "Warm Female" }
]
}
]
```
**Validation before proceeding:**
- Every role must have **exactly 3 candidates** with distinct `voice_id` values
- Narrator candidates must NOT overlap with any character's candidates
- `sample_text` must be identical for all candidates within the same role
### Step 3.2: Generate preview samples (MANDATORY script)
Run the bundled script to print all `audios_generation` calls grouped by wave:
```bash
python3 $AUDIOBOOK_SCRIPTS/print_preview_calls.py
```
The script auto-assigns `filename` fields (pattern: `preview_{role}_{index}`) if missing and writes them back to `preview_plan.json`.
Then fire the printed calls wave by wave:
- 1 text per call, max 3 calls per wave — **fire all calls in a wave simultaneously (in parallel), NOT one by one**
- Wait for ALL calls in a wave to complete before the next wave
- If any call fails, sleep 30 seconds then continue to the next wave. Do NOT retry inline.
**DO NOT** batch multiple candidates into one call or skip the `filenames` parameter.
### Step 3.3: Map blobs to candidates (MANDATORY script)
After all calls complete, run the bundled script to match generated blobs back to candidates via `filename`:
```bash
python3 $AUDIOBOOK_SCRIPTS/match_preview_blobs.py
```
The script reads `preview_plan.json` + `assets.json`, matches by filename (last-write-wins), and updates `preview_plan.json` with `blob_path` for each candidate.
### Step 3.4: Organize preview files (MANDATORY script)
Move preview audio files from the project root into `voice_samples/samples/` and update `assets.json` paths:
```bash
python3 $AUDIOBOOK_SCRIPTS/organize_preview_files.py
```
The script reads `preview_plan.json` for filenames, moves `.mp3` and `_subtitle.json` files, and updates `assets.json` path fields. Idempotent — safe to re-run.
---
## Step 4: Build Preview Data JSON (MANDATORY script)
Run the bundled script to generate `preview_data.json` from `preview_plan.json` (with `blob_path` from Step 3.3):
```bash
python3 $AUDIOBOOK_SCRIPTS/build_preview_data.py [--lang zh]
```
If `--lang` is omitted, auto-detects from `.audiobook-state.json` or book content. Set `--lang zh` when the user communicates in Chinese, `--lang en` otherwise.
The script reads `preview_plan.json`, transfers `blob_path` to `samplePath`, and validates all audio files exist.
**Do NOT construct preview_data.json by hand.** Always use this script.
---
## Step 5: Launch Preview Page
**⚠️ This is the most commonly skipped step. Do NOT skip it.**
This is a **blocking operation** — the script runs a local server and waits for the user to submit feedback on the page.
### Action sequence (all three are required):
**1. Send notification to the user:**
> All voice preview samples are ready! Opening the Voice Preview page now.
> On the page you can switch voices, mark speed/volume, add notes, and click Confirm & Continue when done.
(Use Chinese if the user communicates in Chinese. Adapt the message naturally — no need to copy a template verbatim.)
**2. Launch the preview server (BLOCKING call):**
```bash
python3 $AUDIOBOOK_SCRIPTS/render_preview.py /path/to/preview_data.json
```
The script prints `PREVIEW_URL=http://127.0.0.1:<port>`, opens the browser, and **blocks until the user clicks "Confirm & Continue"**. After submission, it writes `voice_settings.json` and `feedback.md` to the same directory as the input JSON, then exits.
**3. After the script exits**, read the generated `voice_settings.json` and proceed to Step 6.
> **Note on canvas**: `audios_generation` auto-registers every generated audio into `assets.json`, so preview samples appear on the canvas. This is expected and acceptable — they will be visually superseded by the final chapter audio.
---
## Step 6: Feedback Handling
### Speed/Volume Feedback
The preview page uses radio-style toggles:
- Speed: `"slower"` / `"ok"` / `"faster"`
- Volume: `"louder"` / `"ok"` / `"quieter"`
**Speed adjustment: ±0.03 per step** (e.g. "faster" → 0.9 → 0.93, "slower" → 0.9 → 0.87).
### Submit behavior
- **Empty feedback**: LGTM — save selections to `voice_settings.json`, proceed to Phase 4
- **With feedback text**:
- Speed/volume tweak → apply new parameters, offer to regenerate
- New voice request → re-query `get_voice_id`, regenerate, refresh preview
- Other → handle based on content
### voice_settings.json Format
The `label` field must match the voice tag used in `scripts/chapter_N.md` (e.g., `voice: zhang_shu`).
```json
{
"pauseDensity": "medium",
"narrator": {
"voiceId": "Chinese (Mandarin)_Lyrical_Voice",
"speed": "ok",
"volume": "ok"
},
"characters": [
{
"name": "张叔",
"label": "zhang_shu",
"voiceId": "Chinese (Mandarin)_Humorous_Elder",
"speed": "ok",
"volume": "ok"
}
]
}
```
**Important:** Field name is `voiceId` (camelCase) — this matches what the HTML preview page saves.
@@ -0,0 +1,319 @@
# 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:
```bash
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:
```python
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:
1. Reads the chapter script (preserving ALL `<#X#>` markers from Phase 2)
2. Parses segment delimiters and headers
3. Maps voice labels to voice_ids via `voice_settings.json`
4. Applies 4.2 head/tail padding
5. Outputs `generation_plan.json`
6. **Prints every wave's calls — use these directly for Step 2**
```bash
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:
```bash
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):
```bash
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:
1. **`html.unescape()`** — the chapter script (Phase 2 output) may store pause markers as `&lt;#0.6#&gt;` (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.
2. **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 `&lt;` / `&gt;` 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:
```bash
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)
```bash
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:
```bash
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:**
1. Run Step 3 (blob matching) + Step 3.5 (verification) to identify all failures
2. Run the Failure & Retry script above — it reads `generation_plan.json` and prints exact calls
3. Sleep 20 seconds (let system recover from transient issues)
4. Fire the printed calls (max 3 per wave) — do NOT modify the parameters
5. 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
```bash
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 in `assets.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:
```bash
python3 $AUDIOBOOK_SCRIPTS/audio_check.py /path/to/exported_chapter.mp3
```
> **ffmpeg dependency:** `audio_check.py` and `generate_assembly_report.py` both shell out to `ffmpeg` / `ffprobe`. If `ffmpeg` is not on `$PATH`, prepend the binary location:
> ```bash
> 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
```bash
python3 $AUDIOBOOK_SCRIPTS/generate_assembly_report.py <chapter_number>
```
> **ffmpeg dependency:** This script uses `ffprobe` to compute timestamps. If `ffprobe` is 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"):
1. Look up index 14 in `generation_plan.json` for `voice_id`, `speed`, `text`, `filename`
2. Regenerate: `audios_generation(texts=[new_text], voice_id=..., speed=..., filenames=[filename])`
3. Re-run Step 3 blob matching to pick up the new blob (latest entry with same filename wins)
4. Rewrite `manifest.txt` from updated plan
5. Re-concatenate via editing sub-agent
6. 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:**
1. Tell the user how many chapters remain and ask if they want to continue generating them
2. For each remaining chapter, follow the full 4.24.6 workflow in order:
- Build generation plan → Fire TTS waves → Match blobs → Verify → Organize → Write manifest → Concatenate → Quality check → Assembly report
3. After each chapter completes, update `chaptersCompleted` in `.audiobook-state.json`
4. 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