1086 lines
38 KiB
Python
1086 lines
38 KiB
Python
# Copyright 2026 Umbra Tools (umbratools.com)
|
|
# SPDX-License-Identifier: Apache-2.0
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
from pathlib import Path
|
|
|
|
import click
|
|
from rich.console import Console
|
|
from rich.table import Table
|
|
|
|
from umbraaudio.config import PROJECT_BASE, load_config
|
|
|
|
console = Console()
|
|
|
|
|
|
@click.group()
|
|
@click.option("--project", "-p", "project_name", type=str, default=None, help="Project name or slug")
|
|
@click.pass_context
|
|
def main(ctx: click.Context, project_name: str | None) -> None:
|
|
"""UmbraAudio — Quality-first audiobook generation."""
|
|
ctx.ensure_object(dict)
|
|
ctx.obj["project_name"] = project_name
|
|
|
|
|
|
@main.command()
|
|
@click.argument("manuscript", type=click.Path(exists=True))
|
|
@click.option("--name", "-n", help="Project name")
|
|
@click.pass_context
|
|
def init(ctx: click.Context, manuscript: str, name: str | None) -> None:
|
|
"""Initialize a new project from a manuscript file."""
|
|
from rich.progress import Progress, SpinnerColumn, TextColumn
|
|
|
|
from umbraaudio.config import PROJECT_BASE, slugify_project_name
|
|
from umbraaudio.database import Database
|
|
from umbraaudio.parsers import detect_format, parse_manuscript
|
|
|
|
manuscript_path = Path(manuscript)
|
|
project_name = name or slugify_project_name(manuscript_path.name)
|
|
project_dir = PROJECT_BASE / project_name
|
|
cfg = load_config(project_name)
|
|
|
|
source_format = detect_format(manuscript_path)
|
|
|
|
console.print(f"[bold green]Initializing project:[/] {project_name}")
|
|
console.print(f" Source: {manuscript_path}")
|
|
console.print(f" Format: {source_format}")
|
|
console.print(f" Location: {project_dir}")
|
|
|
|
with Progress(SpinnerColumn(), TextColumn("{task.description}")) as progress:
|
|
task = progress.add_task("Parsing manuscript...", total=None)
|
|
chapters = parse_manuscript(manuscript_path, source_format)
|
|
progress.update(task, description=f"Parsed {len(chapters)} chapters")
|
|
|
|
async def _create() -> int:
|
|
from umbraaudio.voices.registry import VoiceRegistry
|
|
|
|
db = Database(cfg.db_path)
|
|
await db.connect()
|
|
project_id = await db.create_project(
|
|
name=project_name,
|
|
source_path=str(manuscript_path.resolve()),
|
|
source_format=source_format,
|
|
)
|
|
registry = VoiceRegistry(cfg.voice_registry_path)
|
|
await registry.connect()
|
|
|
|
for i, ch in enumerate(chapters):
|
|
await db.insert_chapter(
|
|
project_id=project_id,
|
|
index=i,
|
|
title=ch.get("title"),
|
|
source_text=ch["text"],
|
|
word_count=len(ch["text"].split()),
|
|
)
|
|
await db.update_project_status(project_id, "parsed")
|
|
await registry.close()
|
|
await db.close()
|
|
return project_id
|
|
|
|
project_id = asyncio.run(_create())
|
|
|
|
console.print(f"\n[bold green]Project created:[/] ID={project_id}")
|
|
console.print(f" Data directory: {cfg.data_dir}")
|
|
console.print(f" Chapters: {len(chapters)}")
|
|
console.print(f"\nNext step: [bold]umbraaudio discover -p {project_name}[/]")
|
|
|
|
|
|
@main.command()
|
|
@click.option("--output", "-o", type=click.Path(), help="Output JSON file path")
|
|
@click.option("--max-chars", type=int, default=80000, help="Max characters to send to LLM")
|
|
@click.pass_context
|
|
def discover(ctx: click.Context, output: str | None, max_chars: int) -> None:
|
|
"""Discover all speakers in the book (Pass 0: Cast Discovery)."""
|
|
|
|
project_dir = ctx.obj["project_name"]
|
|
cfg = load_config(project_dir)
|
|
|
|
console.print("[bold blue]Pass 0: Cast Discovery[/]")
|
|
|
|
async def _discover() -> None:
|
|
from umbraaudio.analysis.character_discovery import CharacterDiscoverer
|
|
from umbraaudio.database import Database
|
|
|
|
db = Database(cfg.db_path)
|
|
await db.connect()
|
|
|
|
pid = await db.active_project_id()
|
|
project = await db.get_project(pid)
|
|
if not project:
|
|
console.print("[red]No project found. Run 'init' first.[/]")
|
|
return
|
|
|
|
chapters = await db.get_chapters(pid)
|
|
if not chapters:
|
|
console.print("[red]No chapters found.[/]")
|
|
return
|
|
|
|
console.print(f" [dim]Analyzing {len(chapters)} chapters...[/]")
|
|
|
|
discoverer = CharacterDiscoverer(cfg.llm, data_dir=cfg.data_dir)
|
|
result = await discoverer.discover_speakers(
|
|
chapters,
|
|
book_title=project.get("name", ""),
|
|
max_chars=max_chars,
|
|
)
|
|
|
|
output_path = Path(output) if output else cfg.data_dir / "speakers.json"
|
|
result.save(output_path)
|
|
|
|
await db.close()
|
|
|
|
console.print(f"\n[bold green]Discovery complete:[/] {len(result.speakers)} speakers found")
|
|
console.print(f" Saved to: {output_path}")
|
|
|
|
table = Table(title="Discovered Speakers")
|
|
table.add_column("Speaker", style="cyan")
|
|
table.add_column("All Names", style="dim")
|
|
table.add_column("Gender")
|
|
table.add_column("Type")
|
|
table.add_column("Description", style="dim", max_width=40)
|
|
|
|
for speaker in result.speakers:
|
|
speaker_type = (
|
|
"Narrator" if speaker.is_narrator else "Group" if speaker.is_group else "Character"
|
|
)
|
|
names_str = ", ".join(speaker.all_names[:5])
|
|
if len(speaker.all_names) > 5:
|
|
names_str += f" (+{len(speaker.all_names) - 5} more)"
|
|
table.add_row(
|
|
speaker.primary_name,
|
|
names_str,
|
|
speaker.demographics.gender,
|
|
speaker_type,
|
|
speaker.description[:60] + "..."
|
|
if len(speaker.description) > 60
|
|
else speaker.description,
|
|
)
|
|
|
|
console.print(table)
|
|
|
|
narrators = [s for s in result.speakers if s.is_narrator]
|
|
characters = [s for s in result.speakers if not s.is_narrator and not s.is_group]
|
|
groups = [s for s in result.speakers if s.is_group]
|
|
|
|
console.print(
|
|
f"\n [green]{len(narrators)}[/] narrator(s), [green]{len(characters)}[/] character(s), [green]{len(groups)}[/] group(s)"
|
|
)
|
|
console.print(f"\nNext step: [bold]umbraaudio analyze -p {project_dir}[/]")
|
|
|
|
asyncio.run(_discover())
|
|
|
|
|
|
@main.command()
|
|
@click.pass_context
|
|
def analyze(ctx: click.Context) -> None:
|
|
"""Run Pass 1: Manuscript analysis (characters, dialogue, emotions)."""
|
|
|
|
project_dir = ctx.obj["project_name"]
|
|
cfg = load_config(project_dir)
|
|
|
|
console.print("[bold blue]Pass 1: Manuscript Analysis[/]")
|
|
|
|
async def _analyze() -> None:
|
|
import logging
|
|
|
|
from umbraaudio.analysis.booknlp_engine import BookNLPEngine
|
|
from umbraaudio.analysis.character_profiler import CharacterProfiler
|
|
from umbraaudio.analysis.pronunciation import PronunciationGenerator
|
|
from umbraaudio.database import Database
|
|
from umbraaudio.orchestrator import _merge_characters
|
|
from umbraaudio.voices.registry import VoiceRegistry
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
db = Database(cfg.db_path)
|
|
await db.connect()
|
|
|
|
registry = VoiceRegistry(cfg.voice_registry_path)
|
|
await registry.connect()
|
|
|
|
pid = await db.active_project_id()
|
|
project = await db.get_project(pid)
|
|
if not project:
|
|
console.print("[red]No project found. Run 'init' first.[/]")
|
|
return
|
|
|
|
chapters = await db.get_chapters(pid)
|
|
if not chapters:
|
|
console.print("[red]No chapters found.[/]")
|
|
return
|
|
|
|
full_text = "\n\n".join(ch["source_text"] for ch in chapters)
|
|
|
|
console.print(" [dim]BookNLP analysis...[/]")
|
|
booknlp = BookNLPEngine()
|
|
analysis_dir = cfg.data_dir / "analysis"
|
|
try:
|
|
result = booknlp.analyze(full_text, analysis_dir)
|
|
except Exception as e:
|
|
console.print(f" [yellow]BookNLP failed: {e}. Using fallback.[/]")
|
|
result = {"characters": [], "quotes": []}
|
|
|
|
raw_characters = result.get("characters", [])
|
|
quotes = result.get("quotes", [])
|
|
|
|
merged_list = _merge_characters(raw_characters, quotes)
|
|
char_names = [c["name"] for c in merged_list[:30]]
|
|
|
|
console.print(f" [dim]Found {len(char_names)} characters[/]")
|
|
|
|
console.print(" [dim]Character profiling (checking voice registry)...[/]")
|
|
profiler = CharacterProfiler(cfg.llm)
|
|
char_id_map: dict[str, int] = {}
|
|
|
|
for cname in char_names:
|
|
existing_entry = await registry.find_by_name(cname)
|
|
|
|
if existing_entry:
|
|
console.print(f" [dim]• {cname} found in voice registry, linking[/]")
|
|
char_id = await db.insert_character(
|
|
project_id=pid,
|
|
name=cname,
|
|
registry_id=existing_entry["id"],
|
|
)
|
|
char_id_map[cname] = char_id
|
|
await registry.link_to_project(existing_entry["id"], pid, role_in_book="")
|
|
continue
|
|
|
|
ctx_text = "\n".join(
|
|
merged_list[
|
|
next((i for i, c in enumerate(merged_list) if c["name"] == cname), 0)
|
|
].get("context_snippets", [])
|
|
)
|
|
if not ctx_text:
|
|
for ch in chapters:
|
|
text = ch["source_text"]
|
|
idx = text.find(cname)
|
|
if idx >= 0:
|
|
start = max(0, idx - 100)
|
|
end = min(len(text), idx + 200)
|
|
ctx_text = text[start:end]
|
|
break
|
|
|
|
char_id = await db.insert_character(
|
|
project_id=pid,
|
|
name=cname,
|
|
is_narrator=(cname == char_names[0] if char_names else False),
|
|
)
|
|
char_id_map[cname] = char_id
|
|
|
|
try:
|
|
profile = await profiler.profile_character(
|
|
name=cname,
|
|
aliases=[],
|
|
context=ctx_text or f"Character {cname}.",
|
|
)
|
|
|
|
registry_id, _ = await registry.find_or_create(
|
|
profile, source_project=project["name"]
|
|
)
|
|
await db.conn.execute(
|
|
"UPDATE characters SET registry_id = ? WHERE id = ?",
|
|
(registry_id, char_id),
|
|
)
|
|
|
|
vd = profile.voice_description
|
|
if isinstance(vd, dict):
|
|
vd = json.dumps(vd)
|
|
await db.update_character_profile(
|
|
char_id,
|
|
profile_json=profile.to_dict(),
|
|
voice_description=vd,
|
|
)
|
|
console.print(f" [green]✓[/] {cname}")
|
|
except Exception as e:
|
|
console.print(f" [yellow]! {cname}: {e}[/]")
|
|
logger.warning("Profiling failed for %s: %s", cname, e)
|
|
|
|
console.print(" [dim]Pronunciation guide...[/]")
|
|
pron = PronunciationGenerator(cfg.llm)
|
|
unusual = pron.find_unusual_terms(full_text)
|
|
unusual = [t for t in unusual if t not in merged_list][:20]
|
|
console.print(f" [dim]Found {len(unusual)} unusual terms[/]")
|
|
|
|
if unusual:
|
|
try:
|
|
guide = await profiler.generate_pronunciation_guide(unusual, full_text[:3000])
|
|
for term, phonetic in guide.items():
|
|
await db.insert_pronunciation(1, term, phonetic)
|
|
console.print(f" [green]✓[/] {len(guide)} pronunciations added")
|
|
except Exception as e:
|
|
console.print(f" [yellow]! Pronunciation guide failed: {e}[/]")
|
|
logger.warning("Pronunciation guide failed: %s", e)
|
|
|
|
await db.update_project_status(1, "analyzed")
|
|
await registry.close()
|
|
await db.close()
|
|
|
|
asyncio.run(_analyze())
|
|
console.print("[bold green]Analysis complete.[/]")
|
|
|
|
|
|
@main.command()
|
|
@click.pass_context
|
|
def voices(ctx: click.Context) -> None:
|
|
"""Run Pass 2: Generate unique voices for each character."""
|
|
from rich.progress import Progress, SpinnerColumn, TextColumn
|
|
|
|
project_dir = ctx.obj["project_name"]
|
|
cfg = load_config(project_dir)
|
|
|
|
console.print("[bold blue]Pass 2: Voice Design[/]")
|
|
|
|
async def _voices() -> None:
|
|
from umbraaudio.analysis import CharacterProfile
|
|
from umbraaudio.database import Database
|
|
from umbraaudio.voices.registry import VoiceRegistry
|
|
from umbraaudio.voices.voice_designer import VoiceDesigner
|
|
|
|
db = Database(cfg.db_path)
|
|
await db.connect()
|
|
registry = VoiceRegistry(cfg.voice_registry_path)
|
|
await registry.connect()
|
|
|
|
pid = await db.active_project_id()
|
|
characters = await db.get_characters(pid)
|
|
characters_to_voice = [
|
|
c for c in characters if not c["is_narrator"] and c["voice_description"]
|
|
]
|
|
|
|
if not characters_to_voice:
|
|
console.print("[yellow]No characters to voice. Run 'analyze' first.[/]")
|
|
await db.close()
|
|
return
|
|
|
|
console.print(f" [dim]Generating voices for {len(characters_to_voice)} characters...[/]")
|
|
|
|
designer = VoiceDesigner(cfg.tts)
|
|
|
|
with Progress(SpinnerColumn(), TextColumn("{task.description}")) as progress:
|
|
task = progress.add_task(
|
|
"Generating voice references...",
|
|
total=len(characters_to_voice),
|
|
)
|
|
|
|
for char in characters_to_voice:
|
|
if char.get("voice_ref_path") and Path(char["voice_ref_path"]).exists():
|
|
progress.update(
|
|
task,
|
|
advance=1,
|
|
description=f"Skipping {char['name']} (exists)",
|
|
)
|
|
continue
|
|
|
|
try:
|
|
cp = CharacterProfile(
|
|
name=char["name"],
|
|
voice_description=char["voice_description"],
|
|
is_narrator=bool(char["is_narrator"]),
|
|
energy=char.get("energy", "measured"),
|
|
pitch_register=char.get("pitch_register", "mid"),
|
|
timbre=char.get("timbre", "clear"),
|
|
)
|
|
ref_path = designer.generate_voice_reference(cp, cfg.voices_dir)
|
|
embedding = designer.extract_embedding(ref_path)
|
|
await db.update_character_voice(char["id"], str(ref_path), embedding)
|
|
|
|
if char.get("registry_id"):
|
|
await registry.update_voice_ref(
|
|
char["registry_id"], str(ref_path), embedding
|
|
)
|
|
progress.update(task, advance=1, description=f"✓ {char['name']}")
|
|
except Exception as e:
|
|
console.print(f" [red]✗ {char['name']}: {e}[/]")
|
|
progress.update(task, advance=1)
|
|
|
|
await registry.close()
|
|
await db.close()
|
|
console.print("[bold green]Voice design complete.[/]")
|
|
|
|
asyncio.run(_voices())
|
|
|
|
|
|
@main.command()
|
|
@click.pass_context
|
|
def map_audio(ctx: click.Context) -> None:
|
|
"""Run Pass 3: Map manuscript to audio generation instructions."""
|
|
from rich.progress import Progress, SpinnerColumn, TextColumn
|
|
|
|
project_dir = ctx.obj["project_name"]
|
|
cfg = load_config(project_dir)
|
|
|
|
console.print("[bold blue]Pass 3: Audio Mapping[/]")
|
|
|
|
async def _map_audio() -> None:
|
|
from umbraaudio.analysis.ambiguity import detect_ambiguities
|
|
from umbraaudio.database import Database
|
|
from umbraaudio.mapping.manifest import build_generation_manifest, save_manifest
|
|
from umbraaudio.mapping.segmenter import Segmenter
|
|
|
|
db = Database(cfg.db_path)
|
|
await db.connect()
|
|
|
|
pid = await db.active_project_id()
|
|
chapters = await db.get_chapters(pid)
|
|
characters = await db.get_characters(pid)
|
|
pronunciation_guide = await db.get_pronunciation_guide(pid)
|
|
|
|
characters_map = {}
|
|
for c in characters:
|
|
characters_map[c["name"]] = {
|
|
"voice_ref_path": c.get("voice_ref_path"),
|
|
"voice_description": c.get("voice_description"),
|
|
}
|
|
|
|
segmenter = Segmenter()
|
|
|
|
alias_of = {
|
|
"Hunter": "Deal Hunter",
|
|
"Captain Weston": "Captain",
|
|
"The captain": "Captain",
|
|
"the captain": "Captain",
|
|
"Princess": "Princess Kainda",
|
|
"the princess": "Princess Kainda",
|
|
"Kainda": "Princess Kainda",
|
|
"Vicky": "Aunt Vicky",
|
|
"the king": "Adric",
|
|
}
|
|
real_character_names = []
|
|
seen_canonical = set()
|
|
for c in characters:
|
|
if c["is_narrator"]:
|
|
continue
|
|
canonical = alias_of.get(c["name"], c["name"])
|
|
if canonical not in seen_canonical:
|
|
seen_canonical.add(canonical)
|
|
real_character_names.append(c["name"])
|
|
|
|
total_ambiguities = 0
|
|
with Progress(SpinnerColumn(), TextColumn("{task.description}")) as progress:
|
|
task = progress.add_task("Segmenting chapters...", total=len(chapters))
|
|
|
|
for ch in chapters:
|
|
utterances = segmenter.segment(ch["source_text"], ch["id"], real_character_names, chapter_title=ch.get("title"))
|
|
|
|
for u in utterances:
|
|
if u.get("character_name") is None:
|
|
u["character_name"] = "narrator"
|
|
|
|
flags = detect_ambiguities(utterances, real_character_names, ch.get("title", ""))
|
|
if flags:
|
|
total_ambiguities += len(flags)
|
|
console.print(
|
|
f" [yellow]⚠ Ch{ch['id']}: {len(flags)} ambiguous attributions[/]"
|
|
)
|
|
for flag in flags[:3]:
|
|
console.print(
|
|
f" [dim]utt#{flag.utterance_index}: "
|
|
f"{flag.reason} (confidence={flag.confidence:.2f})[/]"
|
|
)
|
|
|
|
manifest = build_generation_manifest(
|
|
ch["id"],
|
|
utterances,
|
|
characters_map,
|
|
pronunciation_guide,
|
|
)
|
|
|
|
manifest_path = cfg.manifests_dir / f"ch{ch['id']}.json"
|
|
save_manifest(manifest, manifest_path)
|
|
|
|
progress.update(
|
|
task,
|
|
advance=1,
|
|
description=f"Ch{ch['id']}: {len(utterances)} utterances",
|
|
)
|
|
|
|
if total_ambiguities > 0:
|
|
console.print(
|
|
f"\n [yellow]{total_ambiguities} ambiguous attributions flagged for review[/]"
|
|
)
|
|
console.print(" Run [bold]umbraaudio review[/] to review flagged utterances")
|
|
|
|
await db.close()
|
|
console.print("[bold green]Audio mapping complete.[/]")
|
|
|
|
asyncio.run(_map_audio())
|
|
|
|
|
|
@main.command()
|
|
@click.option("--chapter", "-c", type=int, help="Chapter number to test")
|
|
@click.pass_context
|
|
def test(ctx: click.Context, chapter: int | None) -> None:
|
|
"""Run Pass 4: Generate test and check quality."""
|
|
project_dir = ctx.obj["project_name"]
|
|
cfg = load_config(project_dir)
|
|
|
|
console.print("[bold blue]Pass 4: Test Generation[/]")
|
|
|
|
async def _test() -> None:
|
|
from umbraaudio.database import Database
|
|
from umbraaudio.generation.cache import GenerationCache
|
|
from umbraaudio.generation.chapter import ChapterGenerator
|
|
from umbraaudio.mapping.manifest import load_manifest
|
|
from umbraaudio.voices.qwen_tts import QwenTTSEngine
|
|
|
|
db = Database(cfg.db_path)
|
|
await db.connect()
|
|
|
|
pid = await db.active_project_id()
|
|
chapters = await db.get_chapters(pid)
|
|
if not chapters:
|
|
console.print("[red]No chapters found.[/]")
|
|
await db.close()
|
|
return
|
|
|
|
ch_idx = (chapter - 1) if chapter else 0
|
|
if ch_idx >= len(chapters):
|
|
console.print(f"[red]Chapter {chapter} not found.[/]")
|
|
await db.close()
|
|
return
|
|
|
|
ch = chapters[ch_idx]
|
|
manifest_path = cfg.manifests_dir / f"ch{ch['id']}.json"
|
|
if not manifest_path.exists():
|
|
console.print("[red]Manifest not found. Run 'map-audio' first.[/]")
|
|
await db.close()
|
|
return
|
|
|
|
manifest = load_manifest(manifest_path)
|
|
utt_count = len(manifest.get("utterances", []))
|
|
console.print(f" [dim]Testing chapter {ch['id']}: {utt_count} utterances[/]")
|
|
|
|
tts = QwenTTSEngine(
|
|
device=cfg.tts.device,
|
|
sample_rate=cfg.tts.sample_rate,
|
|
qwen_tts_url=cfg.tts.qwen_tts_url,
|
|
)
|
|
|
|
from umbraaudio.orchestrator import _author_watermark_bits
|
|
|
|
wm_bits = _author_watermark_bits("B.D. Murphy")
|
|
|
|
cache = GenerationCache(cfg.data_dir / "cache")
|
|
generator = ChapterGenerator(tts, cfg, cache=cache, watermark_bits=wm_bits)
|
|
|
|
output_dir = cfg.audio_dir / f"ch{ch['id']}"
|
|
result = generator.generate_chapter(manifest, output_dir)
|
|
|
|
console.print(f" [green]✓ Generated {result['total']} files[/]")
|
|
if result["failed"]:
|
|
console.print(f" [red]✗ {len(result['failed'])} failed[/]")
|
|
|
|
await db.close()
|
|
console.print("[bold green]Test generation complete.[/]")
|
|
|
|
asyncio.run(_test())
|
|
|
|
|
|
@main.command()
|
|
@click.option("--chapters", "-c", default="all", help="Chapters to generate (all, 1-5, 3,7)")
|
|
@click.pass_context
|
|
def generate(ctx: click.Context, chapters: str) -> None:
|
|
"""Run Pass 5: Full audiobook generation."""
|
|
from rich.progress import Progress, SpinnerColumn, TextColumn
|
|
|
|
project_dir = ctx.obj["project_name"]
|
|
cfg = load_config(project_dir)
|
|
|
|
console.print("[bold blue]Pass 5: Full Generation[/]")
|
|
|
|
async def _generate() -> None:
|
|
from umbraaudio.assembly.normalize import AudioNormalizer
|
|
from umbraaudio.database import Database
|
|
from umbraaudio.generation.cache import GenerationCache
|
|
from umbraaudio.generation.chapter import ChapterGenerator
|
|
from umbraaudio.mapping.manifest import load_manifest
|
|
from umbraaudio.orchestrator import _author_watermark_bits
|
|
from umbraaudio.voices.qwen_tts import QwenTTSEngine
|
|
|
|
db = Database(cfg.db_path)
|
|
await db.connect()
|
|
|
|
pid = await db.active_project_id()
|
|
chapters_db = await db.get_chapters(pid)
|
|
if not chapters_db:
|
|
console.print("[red]No chapters found.[/]")
|
|
await db.close()
|
|
return
|
|
|
|
tts = QwenTTSEngine(
|
|
device=cfg.tts.device,
|
|
sample_rate=cfg.tts.sample_rate,
|
|
qwen_tts_url=cfg.tts.qwen_tts_url,
|
|
)
|
|
|
|
wm_bits = _author_watermark_bits("B.D. Murphy")
|
|
cache = GenerationCache(cfg.data_dir / "cache")
|
|
normalizer = AudioNormalizer(
|
|
target_rms_db=cfg.audio.target_rms_db,
|
|
peak_limit_db=cfg.audio.peak_limit_db,
|
|
sample_rate=cfg.tts.sample_rate,
|
|
)
|
|
generator = ChapterGenerator(
|
|
tts,
|
|
cfg,
|
|
cache=cache,
|
|
normalizer=normalizer,
|
|
watermark_bits=wm_bits,
|
|
author_name="B.D. Murphy",
|
|
)
|
|
|
|
characters = await db.get_characters(pid)
|
|
speaker_profiles = {}
|
|
for c in characters:
|
|
speaker_profiles[c["name"]] = {
|
|
"energy": c.get("energy", "measured"),
|
|
"voice_description": c.get("voice_description"),
|
|
}
|
|
|
|
chapter_range = _parse_chapter_range(chapters, len(chapters_db))
|
|
|
|
with Progress(SpinnerColumn(), TextColumn("{task.description}")) as progress:
|
|
task = progress.add_task("Generating audio...", total=len(list(chapter_range)))
|
|
|
|
for i in chapter_range:
|
|
ch = chapters_db[i]
|
|
manifest_path = cfg.manifests_dir / f"ch{ch['id']}.json"
|
|
if not manifest_path.exists():
|
|
progress.update(
|
|
task,
|
|
advance=1,
|
|
description=f"Skipping ch{ch['id']} (no manifest)",
|
|
)
|
|
continue
|
|
|
|
manifest = load_manifest(manifest_path)
|
|
output_dir = cfg.audio_dir / f"ch{ch['id']}"
|
|
normalized_dir = cfg.data_dir / "audio_normalized" / f"ch{ch['id']}"
|
|
|
|
result = generator.generate_chapter(
|
|
manifest,
|
|
output_dir,
|
|
normalized_dir,
|
|
speaker_profiles=speaker_profiles,
|
|
)
|
|
progress.update(
|
|
task,
|
|
advance=1,
|
|
description=(
|
|
f"Ch{ch['id']}: {result['total']} generated, {len(result['failed'])} failed"
|
|
),
|
|
)
|
|
|
|
await db.close()
|
|
console.print("[bold green]Full generation complete.[/]")
|
|
|
|
asyncio.run(_generate())
|
|
|
|
|
|
@main.command()
|
|
@click.option("--format", "-f", "formats", multiple=True, default=["m4b", "mp3"])
|
|
@click.option("--title", "-t", default="", help="Book title for M4B metadata")
|
|
@click.option("--author", "-a", default="", help="Author name for M4B metadata")
|
|
@click.pass_context
|
|
def export(ctx: click.Context, formats: tuple[str, ...], title: str, author: str) -> None:
|
|
"""Export the final audiobook in desired formats."""
|
|
project_dir = ctx.obj["project_name"]
|
|
cfg = load_config(project_dir)
|
|
|
|
console.print("[bold blue]Export[/]")
|
|
console.print(f" Formats: {', '.join(formats)}")
|
|
|
|
from umbraaudio.assembly.export import AudiobookExporter
|
|
|
|
exporter = AudiobookExporter()
|
|
|
|
audio_dir = cfg.data_dir / "audio_normalized"
|
|
if not audio_dir.exists():
|
|
audio_dir = cfg.audio_dir
|
|
|
|
chapter_files = sorted(audio_dir.glob("*/ch*_u*.wav"))
|
|
|
|
if not chapter_files:
|
|
console.print("[yellow]No audio files found to export.[/]")
|
|
return
|
|
|
|
for fmt in formats:
|
|
if fmt == "mp3":
|
|
mp3_files = exporter.export_mp3(chapter_files, cfg.export_dir / "mp3")
|
|
console.print(f" [green]✓ Exported {len(mp3_files)} MP3 files[/]")
|
|
elif fmt == "wav":
|
|
wav_files = exporter.export_wav(chapter_files, cfg.export_dir / "wav")
|
|
console.print(f" [green]✓ Exported {len(wav_files)} WAV files[/]")
|
|
elif fmt == "m4b":
|
|
try:
|
|
m4b_path = cfg.export_dir / "audiobook.m4b"
|
|
exporter.export_m4b(
|
|
chapter_files,
|
|
m4b_path,
|
|
title=title,
|
|
author=author,
|
|
)
|
|
console.print(f" [green]✓ Exported M4B: {m4b_path}[/]")
|
|
except Exception as e:
|
|
console.print(f" [red]✗ M4B export failed: {e}[/]")
|
|
|
|
|
|
@main.command()
|
|
@click.option("--chapters", "-c", default="all", help="Chapters (all, 1-5, 3,7)")
|
|
@click.option("--author", "-a", default="B.D. Murphy", help="Author name for watermark")
|
|
@click.option("--allow-voice-collisions", is_flag=True, help="Skip voice collision gate")
|
|
@click.pass_context
|
|
def pipeline(ctx: click.Context, chapters: str, author: str, allow_voice_collisions: bool) -> None:
|
|
"""Full orchestrated pipeline."""
|
|
import json as _json
|
|
|
|
project_dir = ctx.obj["project_name"]
|
|
cfg = load_config(project_dir)
|
|
|
|
console.print("[bold blue]UmbraAudio Full Pipeline[/]")
|
|
console.print(f" Author: {author}")
|
|
console.print(f" Chapters: {chapters}")
|
|
if allow_voice_collisions:
|
|
console.print(" [yellow]Voice collision gate: DISABLED[/]")
|
|
console.print()
|
|
|
|
from umbraaudio.orchestrator import run_pipeline
|
|
|
|
results = asyncio.run(
|
|
run_pipeline(
|
|
project_dir,
|
|
chapters=chapters,
|
|
author_name=author,
|
|
allow_voice_collisions=allow_voice_collisions,
|
|
)
|
|
)
|
|
|
|
console.print()
|
|
console.print("[bold blue]Pipeline Summary[/]")
|
|
|
|
summary = results.get("summary", {})
|
|
table = Table(title="Results")
|
|
table.add_column("Metric", style="dim")
|
|
table.add_column("Value")
|
|
table.add_row("Project", summary.get("project", "N/A"))
|
|
table.add_row("Characters", str(summary.get("characters", 0)))
|
|
table.add_row("Chapters", str(summary.get("chapters", 0)))
|
|
table.add_row("Utterances", str(summary.get("utterances_total", 0)))
|
|
table.add_row("Audio Generated", str(summary.get("audio_generated", 0)))
|
|
table.add_row("Voice Refs", str(summary.get("voice_refs", 0)))
|
|
table.add_row("Defects", str(summary.get("defects", 0)))
|
|
table.add_row("Ambiguity Flags", str(summary.get("ambiguity_flags", 0)))
|
|
console.print(table)
|
|
|
|
defects = results.get("defects", [])
|
|
if defects:
|
|
console.print()
|
|
console.print("[bold red]Defects:[/]")
|
|
defect_table = Table()
|
|
defect_table.add_column("Stage", style="dim")
|
|
defect_table.add_column("Severity")
|
|
defect_table.add_column("Message")
|
|
for d in defects:
|
|
if d["severity"] == "CRITICAL":
|
|
severity_style = "red"
|
|
elif d["severity"] == "WARNING":
|
|
severity_style = "yellow"
|
|
else:
|
|
severity_style = "dim"
|
|
defect_table.add_row(
|
|
d["stage"],
|
|
f"[{severity_style}]{d['severity']}[/]",
|
|
d["message"][:80],
|
|
)
|
|
console.print(defect_table)
|
|
|
|
ambiguity_flags = results.get("ambiguity_flags", [])
|
|
if ambiguity_flags:
|
|
console.print()
|
|
console.print(f"[bold yellow]{len(ambiguity_flags)} ambiguous utterances flagged[/]")
|
|
console.print(" Run [bold]umbraaudio review[/] to review flagged utterances")
|
|
|
|
defects_path = cfg.data_dir / "defects.json"
|
|
defects_path.parent.mkdir(parents=True, exist_ok=True)
|
|
with open(defects_path, "w") as f:
|
|
_json.dump(
|
|
{"defects": defects, "ambiguity_flags": ambiguity_flags, "summary": summary},
|
|
f,
|
|
indent=2,
|
|
)
|
|
console.print(f"\n[dim]Results logged to {defects_path}[/]")
|
|
|
|
|
|
@main.command()
|
|
@click.pass_context
|
|
def status(ctx: click.Context) -> None:
|
|
"""Show project status and progress."""
|
|
project_dir = ctx.obj["project_name"]
|
|
cfg = load_config(project_dir)
|
|
|
|
from umbraaudio.database import Database
|
|
|
|
async def _status() -> None:
|
|
db = Database(cfg.db_path)
|
|
await db.connect()
|
|
|
|
cursor = await db.conn.execute("SELECT * FROM project ORDER BY id DESC LIMIT 1")
|
|
project = await cursor.fetchone()
|
|
|
|
if not project:
|
|
console.print("[yellow]No project found.[/]")
|
|
await db.close()
|
|
return
|
|
|
|
p = dict(project)
|
|
table = Table(title=f"Project: {p['name']}")
|
|
table.add_column("Field", style="dim")
|
|
table.add_column("Value")
|
|
table.add_row("ID", str(p["id"]))
|
|
table.add_row("Source", p["source_path"])
|
|
table.add_row("Format", p["source_format"])
|
|
table.add_row("Status", f"[bold]{p['status']}[/]")
|
|
table.add_row("Created", p["created_at"])
|
|
|
|
chapters = await db.get_chapters(p["id"])
|
|
table.add_row("Chapters", str(len(chapters)))
|
|
|
|
characters = await db.get_characters(p["id"])
|
|
table.add_row("Characters", str(len(characters)))
|
|
|
|
console.print(table)
|
|
await db.close()
|
|
|
|
asyncio.run(_status())
|
|
|
|
|
|
@main.command()
|
|
@click.option("--host", default="127.0.0.1")
|
|
@click.option("--port", default=8420, type=int)
|
|
@click.pass_context
|
|
def review(ctx: click.Context, host: str, port: int) -> None:
|
|
"""Launch web review UI for voice and quality approval."""
|
|
project_name = ctx.obj["project_name"]
|
|
if project_name:
|
|
cfg = load_config(project_name)
|
|
else:
|
|
from umbraaudio.config import AppConfig
|
|
cfg = AppConfig()
|
|
|
|
console.print(f"[bold blue]Starting review UI at http://{host}:{port}[/]")
|
|
if project_name:
|
|
console.print(f" Project: [cyan]{project_name}[/]")
|
|
else:
|
|
console.print(" No project selected — project list will be shown")
|
|
|
|
import uvicorn
|
|
|
|
from umbraaudio.web import create_app
|
|
|
|
app = create_app(cfg)
|
|
uvicorn.run(app, host=host, port=port)
|
|
|
|
|
|
@main.group()
|
|
@click.pass_context
|
|
def voice_registry(ctx: click.Context) -> None:
|
|
"""Manage the shared voice registry."""
|
|
|
|
|
|
@voice_registry.command("list")
|
|
@click.pass_context
|
|
def registry_list(ctx: click.Context) -> None:
|
|
"""List all voices in the shared registry."""
|
|
cfg = load_config(ctx.obj["project_name"])
|
|
|
|
async def _list() -> None:
|
|
from umbraaudio.voices.registry import VoiceRegistry
|
|
|
|
registry = VoiceRegistry(cfg.voice_registry_path)
|
|
await registry.connect()
|
|
|
|
all_voices = await registry.get_all_voiced()
|
|
|
|
if not all_voices:
|
|
console.print("[yellow]No voiced characters in registry.[/]")
|
|
await registry.close()
|
|
return
|
|
|
|
table = Table(title=f"Voice Registry ({len(all_voices)} voices)")
|
|
table.add_column("Character", style="cyan")
|
|
table.add_column("Gender")
|
|
table.add_column("Energy")
|
|
table.add_column("Timbre")
|
|
table.add_column("Pitch")
|
|
table.add_column("Status")
|
|
table.add_column("Voice Ref")
|
|
|
|
for v in all_voices:
|
|
ref = v.get("voice_ref_path", "")
|
|
ref_short = Path(ref).name if ref else "-"
|
|
table.add_row(
|
|
v["character_name"],
|
|
v.get("gender", "-"),
|
|
v.get("energy", "-"),
|
|
v.get("timbre", "-"),
|
|
v.get("pitch_register", "-"),
|
|
v.get("status", "-"),
|
|
ref_short,
|
|
)
|
|
console.print(table)
|
|
await registry.close()
|
|
|
|
asyncio.run(_list())
|
|
|
|
|
|
@voice_registry.command("search")
|
|
@click.argument("query")
|
|
@click.pass_context
|
|
def registry_search(ctx: click.Context, query: str) -> None:
|
|
"""Search the voice registry by name or alias."""
|
|
cfg = load_config(ctx.obj["project_name"])
|
|
|
|
async def _search() -> None:
|
|
from umbraaudio.voices.registry import VoiceRegistry
|
|
|
|
registry = VoiceRegistry(cfg.voice_registry_path)
|
|
await registry.connect()
|
|
|
|
results = await registry.search(query)
|
|
|
|
if not results:
|
|
console.print(f"[yellow]No matches for '{query}'[/]")
|
|
await registry.close()
|
|
return
|
|
|
|
table = Table(title=f"Search results for '{query}'")
|
|
table.add_column("Character", style="cyan")
|
|
table.add_column("Status")
|
|
table.add_column("Voice Ref")
|
|
|
|
for r in results:
|
|
ref = r.get("voice_ref_path", "")
|
|
table.add_row(
|
|
r["character_name"],
|
|
r.get("status", "-"),
|
|
Path(ref).name if ref else "-",
|
|
)
|
|
console.print(table)
|
|
await registry.close()
|
|
|
|
asyncio.run(_search())
|
|
|
|
|
|
@main.command("projects")
|
|
def projects_list() -> None:
|
|
"""List all available projects."""
|
|
from umbraaudio.config import list_projects
|
|
|
|
projects = list_projects()
|
|
if not projects:
|
|
console.print("[yellow]No projects found.[/]")
|
|
console.print(f" Expected projects in: [cyan]{PROJECT_BASE}[/]")
|
|
return
|
|
|
|
table = Table(title=f"Projects ({len(projects)})")
|
|
table.add_column("Slug", style="cyan")
|
|
table.add_column("Name")
|
|
for p in projects:
|
|
table.add_row(str(p["slug"]), str(p["name"]))
|
|
console.print(table)
|
|
|
|
|
|
@main.command("migrate")
|
|
@click.argument("project_slug")
|
|
@click.option("--manuscript", "-m", type=click.Path(exists=True), help="Manuscript file to move into project")
|
|
@click.option("--name", "-n", help="Display name for the project")
|
|
@click.option("--force", is_flag=True, help="Overwrite existing project")
|
|
def migrate(project_slug: str, manuscript: str | None, name: str | None, force: bool) -> None:
|
|
"""Migrate a legacy .umbra/ project to the new multi-project layout.
|
|
|
|
Moves the .umbra/ directory from CWD into PROJECT_BASE/{project_slug}/.umbra/
|
|
and optionally moves a manuscript file alongside it.
|
|
"""
|
|
import shutil
|
|
|
|
from umbraaudio.config import PROJECT_BASE
|
|
|
|
source_umbra = Path(".") / ".umbra"
|
|
dest_project = PROJECT_BASE / project_slug
|
|
dest_umbra = dest_project / ".umbra"
|
|
|
|
if not source_umbra.exists():
|
|
console.print("[red]No .umbra/ directory found in current directory.[/]")
|
|
return
|
|
|
|
if dest_umbra.exists() and not force:
|
|
console.print(f"[red]Project already exists at {dest_project}[/]")
|
|
console.print(" Use --force to overwrite")
|
|
return
|
|
|
|
console.print(f"[bold blue]Migrating project:[/] {project_slug}")
|
|
console.print(f" Source: {source_umbra.resolve()}")
|
|
console.print(f" Destination: {dest_umbra}")
|
|
|
|
dest_project.mkdir(parents=True, exist_ok=True)
|
|
dest_umbra.mkdir(parents=True, exist_ok=True)
|
|
|
|
for item in source_umbra.iterdir():
|
|
dest = dest_umbra / item.name
|
|
if item.is_dir():
|
|
shutil.copytree(item, dest, dirs_exist_ok=True)
|
|
else:
|
|
shutil.copy2(item, dest)
|
|
|
|
if manuscript:
|
|
manuscript_path = Path(manuscript)
|
|
if manuscript_path.exists():
|
|
dest_manuscript = dest_project / manuscript_path.name
|
|
shutil.copy2(manuscript_path, dest_manuscript)
|
|
console.print(f" Manuscript: {dest_manuscript}")
|
|
|
|
db_path = dest_umbra / "project.db"
|
|
if db_path.exists():
|
|
import sqlite3
|
|
|
|
conn = sqlite3.connect(str(db_path))
|
|
display_name = name or project_slug
|
|
conn.execute(
|
|
"UPDATE project SET name=?, source_path=? WHERE id=(SELECT MIN(id) FROM project)",
|
|
(display_name, str(dest_project)),
|
|
)
|
|
conn.commit()
|
|
conn.close()
|
|
|
|
console.print("\n[bold green]Migration complete.[/]")
|
|
console.print(f" Use: [bold]umbraaudio -p {project_slug} review[/]")
|
|
|
|
|
|
def _parse_chapter_range(chapters_str: str, total: int) -> range:
|
|
if chapters_str == "all":
|
|
return range(total)
|
|
parts = chapters_str.split(",")
|
|
indices: list[int] = []
|
|
for part in parts:
|
|
part = part.strip()
|
|
if "-" in part:
|
|
start, end = part.split("-", 1)
|
|
start_i = max(0, int(start) - 1)
|
|
end_i = min(total, int(end))
|
|
indices.extend(range(start_i, end_i))
|
|
else:
|
|
idx = int(part) - 1
|
|
if 0 <= idx < total:
|
|
indices.append(idx)
|
|
if not indices:
|
|
return range(0)
|
|
return range(min(indices), max(indices) + 1)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|