Popiai-skill仓库
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

199 lines
7.0 KiB

#!/usr/bin/env python3
"""Phase 4 Step 1: Parse chapter script and build generation_plan.json.
Usage: python3 scripts/build_generation_plan.py <chapter_number>
Auto-detects chapter title from analysis/chapters.md,
and first/last chapter from .audiobook-state.json.
"""
import json, re, sys, os
from pathlib import Path
PROJECT_DIR = Path(os.getcwd())
AUDIOBOOK_ROOT = PROJECT_DIR / ".audiobook"
BOOK_DIR = next(d for d in sorted(AUDIOBOOK_ROOT.iterdir()) if d.is_dir() and (d / ".audiobook-state.json").exists())
CHAPTER_N = int(sys.argv[1]) if len(sys.argv) > 1 else 1
# Load state for auto-detection
state = json.loads((BOOK_DIR / ".audiobook-state.json").read_text(encoding="utf-8"))
BOOK_NAME = state.get("bookName", "")
total_chapters = state.get("totalChapters", 0)
IS_FIRST_CHAPTER = (CHAPTER_N == 1)
IS_LAST_CHAPTER = (CHAPTER_N == total_chapters) if total_chapters > 0 else False
# Auto-detect chapter title from chapters.md
CHAPTER_TITLE = ""
chapters_path = BOOK_DIR / "analysis" / "chapters.md"
if chapters_path.exists():
for line in chapters_path.read_text(encoding="utf-8").splitlines():
# Match patterns like "## Chapter 1: Title" or "## 第一章 标题"
m = re.match(rf'^##\s+.*?{CHAPTER_N}[.::\s]+(.*)', line)
if m:
CHAPTER_TITLE = m.group(0).lstrip("#").strip()
break
# Also match "## N. Title"
m = re.match(rf'^##\s+{CHAPTER_N}\.\s+(.*)', line)
if m:
CHAPTER_TITLE = m.group(0).lstrip("#").strip()
break
print(f"Chapter {CHAPTER_N}: title='{CHAPTER_TITLE}', first={IS_FIRST_CHAPTER}, last={IS_LAST_CHAPTER}")
def parse_speed(raw_speed, base=1.0):
"""Parse speed setting from preview feedback: faster=+0.03, slower=-0.03"""
if raw_speed == "faster":
return min(base + 0.03, 1.2)
elif raw_speed == "slower":
return max(base - 0.03, 0.5)
return base
# --- 1. Load voice mapping from voice_settings.json ---
vs = json.loads((BOOK_DIR / "voice_samples" / "voice_settings.json").read_text())
voice_map = {} # voice label (lowercase) -> { voice_id, speed }
# Narrator mapping
voice_map["narrator"] = {
"voice_id": vs["narrator"]["voiceId"],
"speed": parse_speed(vs["narrator"].get("speed", "ok"), base=0.9),
}
# Character mapping: index by BOTH label and name (Chinese name fallback)
for ch in vs.get("characters", []):
label = ch.get("label", ch["name"]).lower()
entry = {
"voice_id": ch["voiceId"],
"speed": parse_speed(ch.get("speed", "ok")),
}
voice_map[label] = entry
# Also map by original name so "voice: 程野" matches even if label is "chengye"
name_key = ch["name"].lower()
if name_key != label:
voice_map[name_key] = entry
# --- 2. Parse chapter script — supports BOTH delimiter formats ---
script_path = BOOK_DIR / "scripts" / f"chapter_{CHAPTER_N}.md"
raw = script_path.read_text(encoding="utf-8")
# Auto-detect format: ---segment--- (canonical) or ## Segment N (variant)
if re.search(r'^---segment---\s*$', raw, re.MULTILINE):
parts = re.split(r'^---segment---\s*$', raw, flags=re.MULTILINE)
parts = parts[1:] # drop everything before the first ---segment---
elif re.search(r'^## Segment\s+\d+', raw, re.MULTILINE):
parts = re.split(r'^## Segment\s+\d+[^\n]*$', raw, flags=re.MULTILINE)
parts = parts[1:] # drop everything before the first ## Segment
else:
raise ValueError(
f"Cannot detect segment format in {script_path}. "
f"Expected '---segment---' or '## Segment N' delimiters."
)
segments = []
for part in parts:
lines = part.strip().splitlines()
if not lines:
continue
headers = {}
body_start = 0
for j, line in enumerate(lines):
m = re.match(r'^(type|voice):\s*(.+)$', line.strip())
if m:
headers[m.group(1)] = m.group(2).strip()
body_start = j + 1
elif line.strip() == "":
continue
else:
body_start = j
break
body_lines = lines[body_start:]
text = "\n".join(body_lines).strip()
if not text:
continue
voice_label = headers.get("voice", "narrator").lower()
mapping = voice_map.get(voice_label, voice_map.get("narrator"))
segments.append({
"type": headers.get("type", "narration"),
"voice_label": voice_label,
"voice_id": mapping["voice_id"],
"speed": mapping["speed"],
"text": text,
})
# --- Voice assignment validation ---
narrator_vid = voice_map["narrator"]["voice_id"]
mismatched = [s for s in segments if s["type"] == "dialogue" and s["voice_id"] == narrator_vid]
if mismatched:
print(f"WARNING: {len(mismatched)} dialogue segments using narrator voice (likely wrong speaker mapping):")
for s in mismatched:
print(f" voice_label='{s['voice_label']}', text='{s['text'][:40]}...'")
print(f"Available voice_map keys: {list(voice_map.keys())}")
print("Check voice_settings.json character names/labels match segments.json voice values")
# --- 3. Prepend chapter title segment (read aloud by narrator) ---
HAS_TITLE = bool(BOOK_NAME and BOOK_NAME.strip() and CHAPTER_TITLE and CHAPTER_TITLE.strip())
if HAS_TITLE:
title_text = f"<#2#>{BOOK_NAME}<#1.5#>{CHAPTER_TITLE}"
segments.insert(0, {
"type": "narration",
"voice_label": "narrator",
"voice_id": voice_map["narrator"]["voice_id"],
"speed": voice_map["narrator"]["speed"],
"text": title_text,
})
# --- 4. Apply 4.2 head/tail padding & build generation plan ---
total = len(segments)
plan = []
for i, seg in enumerate(segments):
raw_text = seg["text"]
is_title = HAS_TITLE and (i == 0)
# Head padding
if is_title:
head = ""
elif i == 0 and not HAS_TITLE and IS_FIRST_CHAPTER:
head = "<#2#>"
else:
head = "<#0.2#>"
# Tail padding
if i == total - 1 and IS_LAST_CHAPTER:
tail = "<#3#>"
elif i == total - 1:
tail = "<#2#>"
else:
tail = "<#0.2#>"
plan.append({
"index": i,
"filename": f"ch{CHAPTER_N}_idx_{i}",
"voice_id": seg["voice_id"],
"speed": seg["speed"],
"text": f"{head}{raw_text}{tail}",
"blob_path": None,
})
plan_path = BOOK_DIR / "audio" / f"chapter_{CHAPTER_N}" / "generation_plan.json"
plan_path.parent.mkdir(parents=True, exist_ok=True)
with open(plan_path, "w", encoding="utf-8") as f:
json.dump(plan, f, ensure_ascii=False, indent=2)
# --- 5. Print waves (copy-paste these calls into Step 2) ---
MAX_CALLS_PER_WAVE = 3
waves = [plan[i:i+MAX_CALLS_PER_WAVE] for i in range(0, len(plan), MAX_CALLS_PER_WAVE)]
print(f"\n=== Generation Plan: {len(plan)} segments, {len(waves)} waves ===\n")
for w_i, wave in enumerate(waves):
print(f"--- Wave {w_i+1} ({len(wave)} calls) ---")
for entry in wave:
print(f" audios_generation(")
print(f" texts=[{repr(entry['text'])}],")
print(f" voice_id={repr(entry['voice_id'])},")
print(f" speed={entry['speed']},")
print(f" filenames=[{repr(entry['filename'])}]")
print(f" )")
print()