Ctrl K
ComunidadGuías…

transcription/cloud.py

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

Archivo original ↗Preguntar a KiBot
"""Transcription online, on the user's own API key.

Local Whisper (transcription/transcriber.py) is the default and this does not
touch it. This is the opt-in for a PC too slow to transcribe long videos: the
audio is sent, in chunks, to the provider the user chose, with their key.

The result has to be indistinguishable from a local transcript to everything
downstream, so it is written to the same cache file in the same shape:
{"video_id", "language", "segments": [{"start", "end", "text", "words"}]},
with words as {"start", "end", "word"} and punctuation on the words, in
absolute seconds from the start of the video. Captions, filler cuts, the
editor's word tools, sentence snapping and translation all read that.

Only providers that return word timings are offered: without them captions
would drift and word editing would vanish. A part where nothing is said (a
stream's music-only intro: measured, 10 minutes of one) has no words and
that's fine; a part with text but no word timings is the model's limit.

Whisper makes words up where nobody speaks: "Thank you." 640 times in one
two-hour stream, "谢谢大家" and the like, one in every quiet stretch. Local
Whisper never shows them because faster-whisper runs a voice detector
(Silero) first. The same detector runs here, on each part before it is
sent: a part where nobody speaks isn't sent at all (and isn't paid for),
and a word the model returns outside the speech it found is dropped.
Nothing falls back to local Whisper; a failing provider fails the job, in
plain words.
"""

import base64
import json
import re
import subprocess
import tempfile
from pathlib import Path

from core import cancel, progress
from core.binaries import ffmpeg, ffprobe
from core.models import Segment
from llm.providers import keys
from llm.providers.base import LLMError
from llm.providers.catalog import get
from llm.providers.http import send

# A three-second spoken phrase, for checking a voice model the app has not
# seen return word timings before (check_model).
PROBE = Path(__file__).resolve().parent / "assets" / "probe.mp3"

OVERLAP = 2.0          # seconds of audio shared by neighbouring chunks
SPEECH_PAD = 0.2       # a word counts as spoken this close to detected speech
PAUSE = 0.6            # a gap this long between words starts a new segment
MAX_SEGMENT = 15.0     # and no segment runs longer than this
STT_TIMEOUT = 300
_SENTENCE_END = re.compile(r"[.!?…。!?]['\")\]]*$")


def transcribe(video_path: Path, video_id: str, transcript_dir: Path, online: dict,
               language: str | None = None) -> list[Segment]:
    provider = str(online.get("backend") or "")
    spec = get(provider)
    if spec is None or not spec.stt:
        raise LLMError("not_configured", f"'{provider}' can't transcribe. Choose another in Settings → AI.")
    spec, key = keys.resolve(online.get("data_dir") or "data", spec)
    if not key:
        raise LLMError("not_configured", f"No {spec.label} API key is saved. Add yours in Settings → AI.")
    model = str(online.get("model") or spec.stt["models"][0])
    length = float(spec.stt["chunk_seconds"])

    duration = _duration(video_path)
    starts = [i * length for i in range(max(1, int(-(-duration // length))))]
    print(f"  Transcribing online with {spec.label} ({model}), {len(starts)} part(s)...")

    words: list[dict] = []
    detected = ""
    silent = made_up = 0
    with tempfile.TemporaryDirectory(prefix="ck-stt-") as tmp:
        for i, start in enumerate(starts):
            cancel.check_active()
            progress.emit(stage="transcribe", fraction=i / len(starts))
            clip_from = max(0.0, start - OVERLAP)
            audio = _extract(video_path, Path(tmp) / f"part{i}.mp3", clip_from, length + 2 * OVERLAP)
            speech = _speech_in(audio)
            if speech == []:
                silent += 1
                continue
            part_words, part_language = _request(spec, key, model, audio, language)
            if speech is not None:
                kept = [w for w in part_words if _spoken(w, speech)]
                made_up += len(part_words) - len(kept)
                part_words = kept
            detected = detected or part_language
            # A word belongs to the part its start falls in; the overlap is
            # context for the model, so words at a boundary are not cut off.
            end = start + length if i < len(starts) - 1 else float("inf")
            for w in part_words:
                absolute = {**w, "start": round(w["start"] + clip_from, 2), "end": round(w["end"] + clip_from, 2)}
                if start <= absolute["start"] < end:
                    words.append(absolute)
    progress.emit(stage="transcribe", fraction=1.0)
    if silent or made_up:
        print(f"      Nobody speaks in {silent} part(s), not sent; {made_up} word(s) outside speech dropped")

    segments = group_words(words)
    lang = (language or normalize_language(detected) or "en").lower()
    transcript_dir.mkdir(parents=True, exist_ok=True)
    payload = {"video_id": video_id, "language": lang, "source": f"{provider}/{model}",
               "segments": [vars(s) for s in segments]}
    (transcript_dir / f"{video_id}.json").write_text(json.dumps(payload), encoding="utf-8")
    # The repetition guard runs in transcriber.transcribe(), on these the same
    # as on a local transcript; importing it here would make a cycle.
    print(f"      {len(segments)} segments ({lang})")
    return segments


def check_model(spec, key: str, model: str) -> tuple[bool, str]:
    """Does this voice model return the word timings captions need?

    One real request with the short test clip, through exactly the code a job
    uses, so a yes here means a yes in a job. Only ever run because the user
    picked the model; it costs a tiny fraction of a cent on their key.
    """
    try:
        words, _language = _request(spec, key, model, PROBE, "en")
    except LLMError as e:
        return False, e.message
    timed = [w for w in words if w["end"] > w["start"]]
    if not timed:
        return False, (f"{model} didn't return word timings, which captions and word editing "
                       "need. Choose another voice model.")
    return True, f"{model} works: it returned word timings for the test clip."


# ---- where someone speaks --------------------------------------------------------


def _speech_in(audio: Path) -> list[tuple[float, float]] | None:
    """Where someone speaks in a part, in seconds from its start, by the voice
    detector local Whisper uses (Silero, through faster-whisper, with its
    defaults). None when it can't run: then every word is kept, as before."""
    try:
        from faster_whisper.audio import decode_audio
        from faster_whisper.vad import VadOptions, get_speech_timestamps
    except ImportError:
        return None
    try:
        samples = decode_audio(str(audio), sampling_rate=16000)
        found = get_speech_timestamps(samples, VadOptions(), sampling_rate=16000)
    except Exception as e:  # a part it can't read is sent as before
        print(f"      (couldn't check a part for speech: {e})")
        return None
    return [(t["start"] / 16000, t["end"] / 16000) for t in found]


def _spoken(word: dict, speech: list[tuple[float, float]]) -> bool:
    return any(word["start"] < end + SPEECH_PAD and word["end"] > start - SPEECH_PAD for start, end in speech)


# ---- one request per provider format -------------------------------------------


def _request(spec, key: str, model: str, audio: Path, language: str | None) -> tuple[list[dict], str]:
    fmt = spec.stt["format"]
    data = audio.read_bytes()
    if fmt == "openrouter":
        body = {"model": model, "response_format": "verbose_json",
                "timestamp_granularities": ["segment", "word"],
                "input_audio": {"data": base64.b64encode(data).decode("ascii"), "format": "mp3"}}
        if language:
            body["language"] = language
        answer = send(spec, key, "POST", "/audio/transcriptions", json_body=body, timeout=STT_TIMEOUT)
        return _whisper_words(answer), str(answer.get("language") or "")
    if fmt == "openai":
        form = [("model", model), ("response_format", "verbose_json"),
                ("timestamp_granularities[]", "word"), ("timestamp_granularities[]", "segment")]
        if language:
            form.append(("language", language))
        answer = send(spec, key, "POST", "/audio/transcriptions", data=form,
                      files={"file": (audio.name, data, "audio/mpeg")}, timeout=STT_TIMEOUT)
        return _whisper_words(answer), str(answer.get("language") or "")
    if fmt == "xai":
        form = {"model": model, "format": "true"}
        if language:
            form["language"] = language
        answer = send(spec, key, "POST", "/stt", data=form,
                      files={"file": (audio.name, data, "audio/mpeg")}, timeout=STT_TIMEOUT)
        words = [{"start": float(w["start"]), "end": float(w["end"]), "word": str(w.get("text") or "").strip()}
                 for w in answer.get("words") or [] if isinstance(w, dict) and str(w.get("text") or "").strip()]
        return words, str(answer.get("language") or "")
    raise LLMError("not_configured", f"{spec.label} transcription is not set up in this version.")


def _whisper_words(answer: dict) -> list[dict]:
    """Words from a Whisper verbose_json answer, with the segment text's
    punctuation put back: whisper-1 returns bare words ("there", not
    "there."), and captions and sentence grouping need the punctuation.
    Empty for a part where nothing is said; text without word timings is
    refused."""
    words = [{"start": float(w["start"]), "end": float(w["end"]), "word": str(w.get("word") or "").strip()}
             for w in answer.get("words") or [] if isinstance(w, dict) and str(w.get("word") or "").strip()]
    if not words:
        segments = [s for s in answer.get("segments") or [] if isinstance(s, dict)]
        said = str(answer.get("text") or "").strip() or any(str(s.get("text") or "").strip() for s in segments)
        if not said:
            return []   # nothing said in this part: music, a "starting soon" screen
        raise LLMError("bad_response", "The transcription came back without word timings, which "
                                       "captions need. Choose a different transcription model.")
    return _punctuate(words, answer.get("segments") or [])


def _bare(token: str) -> str:
    return re.sub(r"[^\w]", "", token.lower())


def _punctuate(words: list[dict], segments: list[dict]) -> list[dict]:
    tokens = [t for s in segments if isinstance(s, dict) for t in str(s.get("text") or "").split()]
    j = 0
    for w in words:
        if re.search(r"[^\w\s]", w["word"]):
            continue  # already punctuated
        for k in range(j, min(j + 4, len(tokens))):  # a short look-ahead keeps a miss from derailing it
            if _bare(tokens[k]) == _bare(w["word"]):
                w["word"] = tokens[k]
                j = k + 1
                break
    return words


# ---- shaping --------------------------------------------------------------------


def group_words(words: list[dict]) -> list[Segment]:
    """Whisper-like segments from timed words: a new one at a pause, after the
    end of a sentence, or once one has run MAX_SEGMENT seconds."""
    segments: list[Segment] = []
    current: list[dict] = []
    for w in sorted(words, key=lambda x: x["start"]):
        if current and (w["start"] - current[-1]["end"] >= PAUSE
                        or _SENTENCE_END.search(current[-1]["word"])
                        or w["end"] - current[0]["start"] > MAX_SEGMENT):
            segments.append(_segment(current))
            current = []
        current.append(w)
    if current:
        segments.append(_segment(current))
    return segments


def _segment(words: list[dict]) -> Segment:
    return Segment(
        start=round(words[0]["start"], 2),
        end=round(words[-1]["end"], 2),
        text=" ".join(w["word"] for w in words).strip(),
        words=[{"start": w["start"], "end": w["end"], "word": w["word"]} for w in words],
    )


def normalize_language(value: str) -> str:
    """"en" stays "en"; "english" (whisper-1) becomes "en"."""
    text = (value or "").strip().lower()
    if re.fullmatch(r"[a-z]{2,3}", text):
        return text
    from multilingual.languages import LANGUAGES

    names = {english.split(" ")[0].lower(): code for code, (english, _native, _prompt) in LANGUAGES.items()}
    names.update({"mandarin": "zh", "tagalog": "tl", "bangla": "bn"})
    return names.get(text.split(" ")[0], "")


# ---- audio ------------------------------------------------------------------------


def _duration(video_path: Path) -> float:
    out = subprocess.run(
        [ffprobe(), "-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", str(video_path)],
        capture_output=True, text=True,
    ).stdout.strip()
    try:
        return max(1.0, float(out))
    except ValueError:
        return 1.0


def _extract(video_path: Path, out: Path, start: float, seconds: float) -> Path:
    """Mono 16 kHz speech-quality MP3: about 2.4 MB per ten minutes, inside
    every provider's upload limit, and a format they all accept."""
    result = subprocess.run(
        [ffmpeg(), "-v", "error", "-y", "-ss", f"{start:.2f}", "-t", f"{seconds:.2f}",
         "-i", str(video_path), "-vn", "-ac", "1", "-ar", "16000", "-c:a", "libmp3lame", "-b:a", "32k", str(out)],
        capture_output=True, text=True,
    )
    if result.returncode != 0 or not out.exists():
        raise RuntimeError(f"Could not extract audio for transcription: {result.stderr.strip()[:200]}")
    return out