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,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.