Flatten skill category directory structure
This commit is contained in:
@@ -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 25–40 words for English narration, 15–25 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 `<#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.
|
||||
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 `<` / `>` 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.2–4.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
|
||||
Reference in New Issue
Block a user