Ctrl K
ComunidadGuías…

scripts/narrate_local.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"""Optional host-side, offline Kokoro narration. No cloud account or plugin access."""
from __future__ import annotations

import argparse
import hashlib
import json
import math
import os
import re
import sys
import tempfile
from pathlib import Path

ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))

from core.paths import discard  # noqa: E402

MODEL_SHA256 = "beb0d1848dee9a49da392cc3df26958d46cfa35d321edf434f52949153f0df3a"
VOICES_SHA256 = "bca610b8308e8d99f32e6fe4197e7ec01679264efed0cac9140fe9c29f1fbf7d"


def digest(path: Path) -> str:
    with path.open("rb") as stream:
        return hashlib.file_digest(stream, "sha256").hexdigest()


def main() -> None:
    parser = argparse.ArgumentParser(description=__doc__)
    source = parser.add_mutually_exclusive_group(required=True)
    source.add_argument("--text-file", type=Path)
    source.add_argument("--scenes", type=Path, help="JSON array of id/narration scene objects")
    parser.add_argument("--output-dir", required=True, type=Path)
    parser.add_argument("--model-dir", required=True, type=Path)
    parser.add_argument("--voice", default="af_heart")
    parser.add_argument("--speed", type=float, default=1.03)
    args = parser.parse_args()
    if not math.isfinite(args.speed) or not 0.5 <= args.speed <= 2:
        parser.error("Speed must be between 0.5 and 2.")
    if args.text_file:
        scenes = [{"id": "narration", "narration": args.text_file.read_text(encoding="utf-8")}]
    else:
        scenes = json.loads(args.scenes.read_text(encoding="utf-8"))
    if not isinstance(scenes, list):
        parser.error("Scenes must be a JSON array.")
    spoken = []
    identifiers = set()
    for scene in scenes:
        if not isinstance(scene, dict):
            parser.error("Every scene must be an object.")
        if not scene.get("narration"):
            continue
        identifier = scene.get("id")
        text = scene["narration"]
        if not isinstance(identifier, str) or not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_-]{0,79}", identifier):
            parser.error("Scene IDs must be simple filenames.")
        if identifier in identifiers or not isinstance(text, str) or not text.strip():
            parser.error("Use unique IDs and nonempty narration text.")
        identifiers.add(identifier)
        spoken.append((identifier, text))
    if not spoken:
        parser.error("No narration text was supplied.")
    model = args.model_dir / "kokoro-v1.0.onnx"
    voices = args.model_dir / "voices-v1.0.bin"
    if not model.is_file() or not voices.is_file():
        parser.error("Download the model-files-v1.1 assets first; see docs/LOCAL_NARRATION.md.")
    model_hash, voices_hash = digest(model), digest(voices)
    if model_hash != MODEL_SHA256 or voices_hash != VOICES_SHA256:
        parser.error("Model assets do not match the documented release digests.")
    # Optional dependencies are imported only for an explicit narration request.
    import numpy as np
    import onnxruntime as runtime
    import soundfile as sf
    from kokoro_onnx import Kokoro

    options = runtime.SessionOptions()
    options.intra_op_num_threads = 4
    options.inter_op_num_threads = 1
    session = runtime.InferenceSession(str(model), sess_options=options, providers=["CPUExecutionProvider"])
    engine = Kokoro.from_session(session, str(voices))
    if args.voice not in engine.voices:
        parser.error("The requested voice is not in this local voice pack.")
    args.output_dir.mkdir(parents=True, exist_ok=True)
    cache_path = args.output_dir / "narration-cache.json"
    try:
        cache = json.loads(cache_path.read_text(encoding="utf-8"))
    except (OSError, ValueError):
        cache = {}
    if not isinstance(cache, dict):
        cache = {}
    results = []
    for identifier, text in spoken:
        output = args.output_dir / (identifier + ".wav")
        request = {"text": text, "voice": args.voice, "speed": args.speed,
                   "model": model_hash, "voices": voices_hash, "runtime": "kokoro-onnx-0.6.1"}
        if cache.get(identifier) != request or not output.is_file():
            samples, rate = engine.create(text, voice=args.voice, speed=args.speed, lang="en-us")
            if not len(samples) or not np.isfinite(samples).all() or np.max(np.abs(samples)) < 0.001:
                raise RuntimeError("Narration produced invalid or silent audio.")
            fd, name = tempfile.mkstemp(prefix=".narration-", suffix=".wav", dir=args.output_dir)
            os.close(fd)
            temporary = Path(name)
            try:
                sf.write(temporary, samples, rate, format="WAV", subtype="PCM_16")
                temporary.replace(output)
            finally:
                discard(temporary)
            cache[identifier] = request
            cache_path.write_text(json.dumps(cache, indent=2), encoding="utf-8")
        info = sf.info(output)
        results.append({"id": identifier, "path": str(output), "duration_seconds": info.duration,
                        "sha256": digest(output)})
        print(identifier, round(info.duration, 2), "seconds", flush=True)
    manifest = {"engine": "Kokoro-82M / kokoro-onnx", "device": "CPU", "voice": args.voice,
                "speed": args.speed, "cloud_requests": 0, "api_key_required": False,
                "model_license": "Apache-2.0", "runtime_license": "MIT", "model_sha256": model_hash,
                "voices_sha256": voices_hash, "scenes": results}
    (args.output_dir / "narration-manifest.json").write_text(json.dumps(manifest, indent=2), encoding="utf-8")


if __name__ == "__main__":
    main()