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.
119 lines
3.5 KiB
Python
119 lines
3.5 KiB
Python
#!/usr/bin/env python3
|
|
"""Run LLM emotion/tone analysis on existing utterances without re-segmenting."""
|
|
from __future__ import annotations
|
|
|
|
import sqlite3
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
|
|
|
from umbraaudio.config import load_config
|
|
from umbraaudio.mapping.emotion import EmotionMapper
|
|
from umbraaudio.voices.model_coordinator import ModelCoordinator, PipelineStage
|
|
|
|
MODEL = "google/gemma-4-e4b"
|
|
|
|
|
|
def main():
|
|
import argparse
|
|
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument(
|
|
"--project",
|
|
type=str,
|
|
default=None,
|
|
help="Project name or path",
|
|
)
|
|
parser.add_argument(
|
|
"--chapters",
|
|
type=str,
|
|
default=None,
|
|
help="Comma-separated chapter IDs (e.g. '5,6,7')",
|
|
)
|
|
parser.add_argument(
|
|
"--dry-run",
|
|
action="store_true",
|
|
help="Print stats without writing to DB",
|
|
)
|
|
args = parser.parse_args()
|
|
|
|
cfg = load_config(args.project)
|
|
coordinator = ModelCoordinator(cfg)
|
|
if not coordinator.switch_to(
|
|
PipelineStage.CHAPTER_ANALYSIS, lm_model=MODEL
|
|
).get("ok"):
|
|
print("Failed to switch models to CHAPTER_ANALYSIS stage")
|
|
sys.exit(1)
|
|
|
|
from openai import OpenAI
|
|
|
|
client = OpenAI(base_url=cfg.llm.lmstudio_base_url, api_key="lm-studio")
|
|
mapper = EmotionMapper(llm_client=client, model=MODEL)
|
|
|
|
db_path = cfg.db_path
|
|
db = sqlite3.connect(str(db_path))
|
|
db.row_factory = sqlite3.Row
|
|
|
|
if args.chapters:
|
|
chapter_ids = [int(x.strip()) for x in args.chapters.split(",")]
|
|
placeholders = ",".join("?" * len(chapter_ids))
|
|
chapters = db.execute(
|
|
f"SELECT id, title, chapter_index, source_text FROM chapters "
|
|
f"WHERE id IN ({placeholders}) ORDER BY chapter_index",
|
|
chapter_ids,
|
|
).fetchall()
|
|
else:
|
|
chapters = db.execute(
|
|
"SELECT id, title, chapter_index, source_text FROM chapters ORDER BY chapter_index"
|
|
).fetchall()
|
|
|
|
print(f"Found {len(chapters)} chapters to analyze")
|
|
print(f"Model: {MODEL}")
|
|
|
|
total = 0
|
|
for ch in chapters:
|
|
ch_id = ch["id"]
|
|
title = ch["title"] or f"Chapter {ch['chapter_index']}"
|
|
|
|
utts = db.execute(
|
|
"SELECT id, utterance_index, text, type, speaker_name "
|
|
"FROM utterances WHERE chapter_id=? ORDER BY utterance_index",
|
|
(ch_id,),
|
|
).fetchall()
|
|
if not utts:
|
|
print(f" {title}: no utterances, skipping")
|
|
continue
|
|
|
|
utt_list = [dict(u) for u in utts]
|
|
context = (ch["source_text"] or "")[:500]
|
|
emotions = mapper.analyze_utterances(utt_list, context)
|
|
|
|
if args.dry_run:
|
|
print(f" {title} (id={ch_id}): {len(emotions)} emotions returned")
|
|
total += len(emotions)
|
|
continue
|
|
|
|
for u, emo in zip(utt_list, emotions, strict=False):
|
|
db.execute(
|
|
"UPDATE utterances SET emotion=?, pace=?, instruct=? WHERE id=?",
|
|
(
|
|
emo.get("emotion", "neutral"),
|
|
emo.get("pace", "moderate"),
|
|
emo.get("instruct", ""),
|
|
u["id"],
|
|
),
|
|
)
|
|
db.commit()
|
|
total += len(emotions)
|
|
print(f" {title} (id={ch_id}): analyzed {len(emotions)} utterances")
|
|
|
|
db.close()
|
|
coordinator.switch_to(PipelineStage.IDLE)
|
|
print(f"\nTotal: {total} utterances analyzed")
|
|
print("Done!")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|