Files
UmbraAudio/umbraaudio/cli.py
2026-10-18 19:01:04 -07:00

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()