Files
UmbraAudio/scripts/model_coordinator.py
B.D. Murphy c86e323d7a Initial commit: UmbraAudio audiobook generation program
Quality-first multi-pass audiobook generation from English manuscripts.

Includes:
- umbraaudio/ core package (pipeline, config, voices, web review UI)
- scripts/ CLI utilities + model download script
- tts_server/ remote TTS server
- specs/ design docs, tests/, pyproject.toml + uv.lock
- Qwen3-TTS git submodule (pinned 022e286) with local demo.py UI patch
  saved at scripts/qwen3_tts_demo_umbra_patch.patch
- .env with runtime config/secrets

Excluded (see .gitignore): models/ (downloaded via scripts/download_models.sh),
output/, logs/, .venv, .umbra runtime data, and book projects
(DealHunterShip_*), which are committed separately.
2026-08-30 07:58:24 -07:00

116 lines
3.8 KiB
Python

"""CLI for the model coordinator.
Ensures only the model needed by the current pipeline stage is resident in
memory, freeing maximum GPU/RAM for whichever model is about to work.
Usage:
python scripts/model_coordinator.py status
python scripts/model_coordinator.py switch \\
voice_design|chapter_analysis|utterance_generation|idle
python scripts/model_coordinator.py unload-all
"""
from __future__ import annotations
import argparse
import json
import logging
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from rich.console import Console
from rich.table import Table
from umbraaudio.config import load_config
from umbraaudio.voices.model_coordinator import ModelCoordinator, PipelineStage
logger = logging.getLogger(__name__)
console = Console()
def _render_status(state: dict) -> None:
qwen_model = state.get("qwen_model") or "—"
lm_loaded = state.get("lm_loaded") or []
lm_str = ", ".join(lm_loaded) if lm_loaded else "—"
table = Table(title="Model Coordinator Status")
table.add_column("Server", style="bold cyan")
table.add_column("State", style="bold green")
table.add_row("Qwen TTS server", state.get("qwen_server", "unknown"))
table.add_row("Qwen current model", qwen_model)
table.add_row(" voicedesign", "loaded" if state.get("voicedesign_loaded") else "unloaded")
table.add_row(" base", "loaded" if state.get("base_loaded") else "unloaded")
table.add_row("LM Studio loaded", lm_str)
console.print(table)
def cmd_status(coord: ModelCoordinator, _args: argparse.Namespace) -> int:
_render_status(coord.status())
return 0
def cmd_switch(coord: ModelCoordinator, args: argparse.Namespace) -> int:
try:
stage = PipelineStage(args.stage)
except ValueError:
console.print(
f"[red]Unknown stage '{args.stage}'. "
f"Choose from: {', '.join(s.value for s in PipelineStage)}[/red]"
)
return 2
console.print(f"Switching to stage [bold]{stage.value}[/bold]...")
result = coord.switch_to(stage)
if not result.get("ok"):
console.print(f"[red]Failed: {result.get('error', 'unknown error')}[/red]")
console.print("Before:", json.dumps(result.get("before", {}), indent=2))
return 1
console.print("[green]OK[/green]")
_render_status(result["after"])
return 0
def cmd_unload_all(coord: ModelCoordinator, _args: argparse.Namespace) -> int:
console.print("Unloading all models...")
result = coord.switch_to(PipelineStage.IDLE)
if not result.get("ok"):
console.print(f"[red]Failed: {result.get('error', 'unknown error')}[/red]")
return 1
console.print("[green]All models unloaded[/green]")
_render_status(result["after"])
return 0
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Switch which models are resident for the current pipeline stage."
)
sub = parser.add_subparsers(dest="command", required=True)
p_status = sub.add_parser("status", help="Show which models are currently loaded")
p_status.set_defaults(func=cmd_status)
p_switch = sub.add_parser("switch", help="Switch to a pipeline stage's model set")
p_switch.add_argument(
"stage",
choices=[s.value for s in PipelineStage],
help="Which stage's models should be resident",
)
p_switch.set_defaults(func=cmd_switch)
p_unload = sub.add_parser("unload-all", help="Unload all models from both servers")
p_unload.set_defaults(func=cmd_unload_all)
return parser
def main(argv: list[str] | None = None) -> int:
logging.basicConfig(level=logging.WARNING, format="%(levelname)s %(message)s")
args = build_parser().parse_args(argv)
coord = ModelCoordinator(load_config())
return args.func(coord, args)
if __name__ == "__main__":
sys.exit(main())