11 KiB
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:
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.
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)
- Language match (hard): Chinese books → Chinese voices only. English books → English voices only.
- Gender match (hard): Male characters → male voices. Female characters → female voices.
- Age match (hard): Elderly → aged/gravelly. Young → bright/energetic. Middle-aged → steady/mature.
- 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}).
[
{
"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_idvalues - Narrator candidates must NOT overlap with any character's candidates
sample_textmust 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:
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:
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:
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):
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):
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_generationauto-registers every generated audio intoassets.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).
{
"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.