185 lines
6.5 KiB
Python
185 lines
6.5 KiB
Python
# Copyright 2026 Umbra Tools (umbratools.com)
|
|
# SPDX-License-Identifier: Apache-2.0
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
from pathlib import Path
|
|
|
|
from umbraaudio.config import load_config
|
|
from umbraaudio.voices.model_coordinator import ModelCoordinator, PipelineStage
|
|
from umbraaudio.voices.qwen_tts import QwenTTSEngine, build_voice_description
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def _load_speakers(data_dir: Path) -> list[dict]:
|
|
path = data_dir / "speakers.json"
|
|
if not path.exists():
|
|
raise FileNotFoundError(f"speakers.json not found: {path}")
|
|
with open(path) as f:
|
|
data = json.load(f)
|
|
return data.get("speakers", [])
|
|
|
|
|
|
def _load_profiles(data_dir: Path) -> dict[str, dict]:
|
|
"""Load voice profiles from voice_profiles.json, falling back to project.db.
|
|
|
|
voice_profiles.json is the canonical source; it contains the full
|
|
voice_description for every speaker. The DB characters table may be
|
|
empty for some projects.
|
|
"""
|
|
profiles_path = data_dir / "voice_profiles.json"
|
|
if profiles_path.exists():
|
|
with open(profiles_path) as f:
|
|
data = json.load(f)
|
|
if data:
|
|
result = {}
|
|
for name, profile in data.items():
|
|
result[name] = {
|
|
"profile_json": json.dumps(profile),
|
|
"voice_description": profile.get("voice_description"),
|
|
}
|
|
return result
|
|
|
|
import sqlite3
|
|
db_path = data_dir / "project.db"
|
|
if not db_path.exists():
|
|
return {}
|
|
db = sqlite3.connect(str(db_path))
|
|
db.row_factory = sqlite3.Row
|
|
pid = db.execute("SELECT id FROM project ORDER BY id LIMIT 1").fetchone()["id"]
|
|
rows = db.execute(
|
|
"SELECT name, profile_json, voice_description FROM characters WHERE project_id=?",
|
|
(pid,),
|
|
).fetchall()
|
|
db.close()
|
|
result = {}
|
|
for r in rows:
|
|
result[r["name"]] = {
|
|
"profile_json": r["profile_json"],
|
|
"voice_description": r["voice_description"],
|
|
}
|
|
return result
|
|
|
|
|
|
def generate_all_voice_references(
|
|
project_dir: Path | None = None,
|
|
force: bool = False,
|
|
) -> None:
|
|
project_dir = project_dir or Path(".")
|
|
cfg = load_config(project_dir)
|
|
|
|
coordinator = ModelCoordinator(cfg)
|
|
if not coordinator.switch_to(PipelineStage.VOICE_DESIGN).get("ok"):
|
|
raise RuntimeError("Failed to switch models to VOICE_DESIGN stage")
|
|
|
|
speakers = _load_speakers(cfg.data_dir)
|
|
db_profiles = _load_profiles(cfg.data_dir)
|
|
|
|
voices_dir = cfg.data_dir / "voices"
|
|
voices_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
tts = QwenTTSEngine(
|
|
device=cfg.tts.device,
|
|
sample_rate=cfg.tts.sample_rate,
|
|
voicedesign_model=cfg.tts.voicedesign_model,
|
|
base_model=cfg.tts.base_model,
|
|
qwen_tts_url=cfg.tts.qwen_tts_url,
|
|
)
|
|
|
|
narrator_name = None
|
|
for s in speakers:
|
|
if s.get("is_narrator"):
|
|
narrator_name = s["primary_name"]
|
|
break
|
|
|
|
generated = 0
|
|
for s in speakers:
|
|
name = s["primary_name"]
|
|
is_narrator = s.get("is_narrator", False)
|
|
ref_name = name.lower().replace(" ", "_") + "_ref.wav"
|
|
ref_path = voices_dir / ref_name
|
|
|
|
if ref_path.exists() and not force:
|
|
logger.info("Skipping %s (exists)", name)
|
|
continue
|
|
|
|
db_info = db_profiles.get(name, {})
|
|
voice_desc_override = db_info.get("voice_description")
|
|
|
|
if voice_desc_override and not force:
|
|
voice_desc = voice_desc_override
|
|
elif is_narrator:
|
|
voice_desc = (
|
|
"A deep, resonant male voice with a calm, authoritative tone. "
|
|
"Rich baritone timbre, slow and deliberate pacing, "
|
|
"like a late-night radio host. Very controlled and even."
|
|
)
|
|
else:
|
|
profile_data = {}
|
|
if db_info.get("profile_json"):
|
|
try:
|
|
profile_data = json.loads(db_info["profile_json"])
|
|
except (json.JSONDecodeError, TypeError):
|
|
pass
|
|
voice_desc = build_voice_description(
|
|
character_name=name,
|
|
profile=profile_data if profile_data else None,
|
|
gender=s.get("gender") or (profile_data or {}).get("gender"),
|
|
energy=(profile_data or {}).get("energy"),
|
|
timbre=(profile_data or {}).get("timbre"),
|
|
pitch_register=(profile_data or {}).get("pitch_register"),
|
|
)
|
|
|
|
sample_text = _sample_text(name, is_narrator, s.get("demographics", {}))
|
|
|
|
logger.info("[%d/%d] %s: %s", generated + 1, len(speakers), name, voice_desc[:70])
|
|
try:
|
|
tts.generate_voice_reference(
|
|
text=sample_text,
|
|
output_path=ref_path,
|
|
voice_description=voice_desc,
|
|
)
|
|
generated += 1
|
|
logger.info(" -> %s", ref_path.name)
|
|
except Exception as e:
|
|
logger.error(" FAILED %s: %s", name, e)
|
|
|
|
tts.unload_all()
|
|
coordinator.switch_to(PipelineStage.IDLE)
|
|
logger.info("Generated %d/%d voice references", generated, len(speakers))
|
|
|
|
|
|
# Rainbow Passage - phonetically balanced paragraph for voice samples
|
|
# Source: Grant Fairbanks, Voice and Articulation Drillbook (1960)
|
|
# Covers all English phonemes including r-colored vowels
|
|
RAINBOW_PASSAGE = (
|
|
"When the sunlight strikes raindrops in the air, they act as a prism "
|
|
"and form a rainbow. The rainbow is a division of white light into many "
|
|
"beautiful colors. These take the shape of a long round arch, with its "
|
|
"path high above, and its two ends apparently beyond the horizon. There "
|
|
"is, according to legend, a boiling pot of gold at one end. People look, "
|
|
"but no one ever finds it. When a man looks for something beyond his "
|
|
"reach, his friends say he is looking for the pot of gold at the end of "
|
|
"the rainbow."
|
|
)
|
|
|
|
|
|
def _sample_text(name: str, is_narrator: bool, demographics: dict) -> str:
|
|
return RAINBOW_PASSAGE
|
|
|
|
|
|
if __name__ == "__main__":
|
|
import argparse
|
|
|
|
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
|
|
parser = argparse.ArgumentParser(description="Generate voice references for all speakers")
|
|
parser.add_argument(
|
|
"--project", type=str, default=None, help="Project name or path"
|
|
)
|
|
parser.add_argument("--force", action="store_true", help="Regenerate existing references")
|
|
args = parser.parse_args()
|
|
generate_all_voice_references(args.project, force=args.force)
|