bec9411af3
Five workers built output URIs with inline f-strings, so the bucket-per-artifact layout was spread across worker_tts, worker_identity, worker_crop, worker_layers and worker_render. Moving a class between buckets meant a grep. They are now templates in transport.py, formatted at each call site. Three of those workers also each reimplemented the same parse to recover manga_id and chapter_id from an input uri, because the orchestrator does not send them. That is transport.ids_from_uri now, and it raises on a uri too short to carry the ids rather than returning a wrong pair. ruff.toml makes `ruff check .` exit 0, so CI can gate on it and a new finding means a new defect. Fixed: an implicit Optional in 8 signatures, an unparenthesized implicit concatenation in the ASS filter list, 5 subprocess.run calls now saying check=False out loud, an unused import, a duplicate exception handler and a non-executable shebang. Every rule left off carries its reason in ruff.toml. The ASYNC rules are off because ffmpeg on the event loop is real and already recorded at caveats/audit-open.md#blocking-event-loop. It needs a refactor per handler, not a lint fix. Checked: transport, collage, bubble_detect, test_vision_parse, worker_crop, worker_scene, worker_script, worker_identity, worker_tts, session_manager, worker_vision and worker_render self-checks all pass. worker_layers still fails on a missing legacy/qwen_layered_workflow.json, which predates this branch. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
130 lines
4.2 KiB
Python
Executable File
130 lines
4.2 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Interactively generate and choose a dots.tts narrator reference."""
|
|
|
|
import argparse
|
|
import os
|
|
from pathlib import Path
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
|
|
|
|
DEFAULT_TEXT = "The story continues as our hero steps forward into the unknown."
|
|
DEFAULT_MODEL = "rednote-hilab/dots.tts-base"
|
|
DEFAULT_VOICE_DIR = Path("~/.cache/manga-tts").expanduser()
|
|
|
|
|
|
def load_model(model_name: str):
|
|
# Match worker_tts.py's workaround for the tokenizer bundled with dots.tts.
|
|
import transformers
|
|
|
|
original = transformers.AutoTokenizer.from_pretrained.__func__
|
|
transformers.AutoTokenizer.from_pretrained = classmethod(
|
|
lambda cls, *args, **kwargs: original(
|
|
cls, *args, **{"fix_mistral_regex": True, **kwargs}
|
|
)
|
|
)
|
|
# Match worker_tts.py's torch/torchaudio minor-mismatch bypass too.
|
|
import torch
|
|
from importlib import metadata
|
|
|
|
real_version = metadata.version
|
|
metadata.version = (
|
|
lambda name: torch.__version__ if name == "torchaudio" else real_version(name)
|
|
)
|
|
try:
|
|
from dots_tts.runtime import DotsTtsRuntime
|
|
finally:
|
|
metadata.version = real_version
|
|
|
|
return DotsTtsRuntime.from_pretrained(model_name, precision="bfloat16")
|
|
|
|
|
|
def write_wav(result, path: Path) -> None:
|
|
import numpy as np
|
|
import soundfile as sf
|
|
|
|
audio = result["audio"]
|
|
if hasattr(audio, "detach"):
|
|
audio = audio.detach().cpu().numpy()
|
|
sf.write(path, np.asarray(audio, dtype="float32").squeeze(),
|
|
result["sample_rate"], subtype="PCM_16")
|
|
|
|
|
|
def play(path: Path) -> None:
|
|
players = (
|
|
("ffplay", "-nodisp", "-autoexit", "-loglevel", "error"),
|
|
("aplay", "-q"),
|
|
("paplay",),
|
|
)
|
|
for command in players:
|
|
if shutil.which(command[0]):
|
|
subprocess.run([*command, str(path)], check=False)
|
|
return
|
|
print(f"No ffplay, aplay, or paplay found; listen manually: {path}")
|
|
|
|
|
|
def choice() -> str:
|
|
while True:
|
|
answer = input("[p]ick / p[a]rk / [s]kip: ").strip().lower()
|
|
aliases = {"p": "pick", "pick": "pick", "a": "park", "park": "park",
|
|
"s": "skip", "skip": "skip"}
|
|
if answer in aliases:
|
|
return aliases[answer]
|
|
print("Enter p, a, or s.")
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--text", default=os.environ.get("VOICE_REF_TEXT", DEFAULT_TEXT),
|
|
help="sample text to speak")
|
|
parser.add_argument("--model", default=DEFAULT_MODEL)
|
|
parser.add_argument("--voice-dir", type=Path, default=DEFAULT_VOICE_DIR)
|
|
args = parser.parse_args()
|
|
|
|
voice_dir = args.voice_dir.expanduser()
|
|
parked_dir = voice_dir / "parked"
|
|
voice_dir.mkdir(parents=True, exist_ok=True)
|
|
parked_dir.mkdir(parents=True, exist_ok=True)
|
|
candidate = voice_dir / "voice_candidate.wav"
|
|
selected = voice_dir / "narrator_ref.wav"
|
|
|
|
print(f"Loading {args.model} ...")
|
|
model = load_model(args.model)
|
|
print(f"Reference text: {args.text!r}")
|
|
|
|
try:
|
|
while True:
|
|
print("\nGenerating a new voice ...")
|
|
write_wav(model.generate(text=args.text), candidate)
|
|
play(candidate)
|
|
|
|
action = choice()
|
|
if action == "pick":
|
|
os.replace(candidate, selected)
|
|
(voice_dir / "narrator_ref.txt").write_text(args.text + "\n")
|
|
print(f"Selected: {selected}")
|
|
return 0
|
|
if action == "park":
|
|
stamp = time.strftime("%Y%m%d-%H%M%S")
|
|
parked = parked_dir / f"voice-{stamp}.wav"
|
|
suffix = 2
|
|
while parked.exists():
|
|
parked = parked_dir / f"voice-{stamp}-{suffix}.wav"
|
|
suffix += 1
|
|
os.replace(candidate, parked)
|
|
parked.with_suffix(".txt").write_text(args.text + "\n")
|
|
print(f"Parked: {parked}")
|
|
else:
|
|
candidate.unlink(missing_ok=True)
|
|
print("Skipped.")
|
|
except (KeyboardInterrupt, EOFError):
|
|
candidate.unlink(missing_ok=True)
|
|
print("\nStopped without changing the selected voice.")
|
|
return 130
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|