File size: 8,769 Bytes
73bb0d9
f54c4bb
 
 
 
1ad803f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1526872
73bb0d9
f54c4bb
2072e4a
 
f54c4bb
73bb0d9
 
 
 
 
 
 
 
 
75b4d64
73bb0d9
2072e4a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
73bb0d9
 
 
 
 
f54c4bb
 
 
73bb0d9
 
 
f54c4bb
 
 
 
 
 
 
 
 
73bb0d9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2072e4a
 
 
 
 
 
 
 
f54c4bb
 
 
73bb0d9
f54c4bb
2072e4a
 
 
 
73bb0d9
9fcc86f
73bb0d9
f54c4bb
 
73bb0d9
 
 
 
 
 
 
 
 
 
f54c4bb
 
 
 
 
 
2072e4a
 
 
 
f54c4bb
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
import glob
import os
import subprocess
import tempfile

# ВАЖНО: для Space на тарифе ZeroGPU HF теперь при старте контейнера
# сканирует код на наличие хотя бы одной функции с @spaces.GPU — если её
# нет, падает "Runtime error: No @spaces.GPU function detected during
# startup" (раньше хватало просто импорта spaces, сейчас уже нет).
# Реальная модель у нас работает на CPU, GPU не нужен, поэтому заводим
# функцию-пустышку только ради прохождения этой проверки — она нигде не
# вызывается, так что GPU-квота ZeroGPU не тратится.
import spaces


@spaces.GPU
def _zerogpu_startup_probe():
    """Никогда не вызывается — нужна только чтобы Space прошёл валидацию
    ZeroGPU на старте (см. комментарий выше)."""
    return None

import gigaam
import gradio as gr
from faster_whisper import WhisperModel
from faster_whisper.audio import decode_audio

# Модель грузим ОДИН раз при старте контейнера, не на каждый запрос.
# v3_e2e_ctc — версия с пунктуацией и нормализацией текста (не голый поток
# слов без знаков препинания) и CTC-декодер (быстрее RNNT, для транскрибации
# голосовых в чате этого достаточно).
# Никакого @spaces.GPU нигде не используем — Space создан на тарифе ZeroGPU
# (сейчас у бесплатных аккаунтов HF нет варианта CPU Basic при создании),
# но раз GPU нигде не запрашивается, приложение работает на обычном CPU
# этого же контейнера и не трогает дневную квоту GPU-секунд. GigaAM
# компактная (220-240М параметров), GPU ей и не нужен.
MODEL = gigaam.load_model("v3_e2e_ctc", device="cpu")

# GigaAM обучена только на русской речи — на других языках она не "не
# понимает" в смысле пустого результата, а галлюцинирует русский текст по
# созвучию (особенно опасно для карточки "о чём песня" — см. index.js:
# ошибочная "русская" расшифровка иностранного текста песни ломает весь
# последующий ответ модели). Поэтому перед прогоном через GigaAM отдельно
# определяем язык лёгкой моделью faster-whisper (tiny, только
# language-id — полную транскрибацию ею не делаем, для этого дальше в
# пайплайне бота уже есть Groq Whisper). tiny достаточно для language-id:
# точность определения языка у tiny почти не хуже, чем у крупных моделей,
# в отличие от точности самой транскрибации.
LANG_ID_MODEL = WhisperModel("tiny", device="cpu", compute_type="int8")

# Ниже этого порога уверенности определённому языку не доверяем и всё
# равно отдаём на GigaAM — так безопаснее для коротких/тихих голосовых,
# где language-id менее надёжен, чем ошибочно отбраковывать русскую речь.
LANG_ID_MIN_CONFIDENCE = 0.5


def _detect_language(wav_path: str) -> tuple[str, float]:
    """Определяет язык по первому 30-секундному сегменту WAV (больше и не
    нужно — language-id у Whisper всегда смотрит только на первый сегмент,
    длинное аудио тут не даёт точности, только замедляет)."""
    audio = decode_audio(wav_path, sampling_rate=16000)
    language, probability, _ = LANG_ID_MODEL.detect_language(audio)
    return language, probability


# Нативный model.transcribe() ограничен 25 секундами аудио за вызов.
# Официальный обход (.transcribe_longform) тянет за собой pyannote.audio
# и токен Hugging Face — ради простой последовательной нарезки заводить
# это не стоит, поэтому режем длинные голосовые сами через ffmpeg.
CHUNK_SECONDS = 20


def _to_wav(src_path: str) -> str:
    """Конвертирует присланный файл (у Telegram голосовые — .ogg/opus, но
    подойдёт любой формат, который понимает ffmpeg — mp3, flac и т.д.) в
    16kHz mono WAV. Модели нужен предсказуемый формат на входе."""
    dst_path = tempfile.mktemp(suffix=".wav")
    subprocess.run(
        ["ffmpeg", "-y", "-i", src_path, "-ar", "16000", "-ac", "1", dst_path],
        check=True,
        capture_output=True,
    )
    return dst_path


def _split_into_chunks(wav_path: str) -> list[str]:
    """Режет WAV на куски по CHUNK_SECONDS через ffmpeg segment muxer.
    Простая последовательная нарезка без перехлёста — изредка может
    срезать слово ровно на границе куска, но для голосовых в чате это
    приемлемый компромисс против того, чтобы тянуть pyannote+HF-токен
    только ради длинных сообщений."""
    out_dir = tempfile.mkdtemp()
    pattern = os.path.join(out_dir, "chunk_%03d.wav")
    subprocess.run(
        [
            "ffmpeg", "-y", "-i", wav_path,
            "-f", "segment", "-segment_time", str(CHUNK_SECONDS),
            "-c", "copy", pattern,
        ],
        check=True,
        capture_output=True,
    )
    return sorted(glob.glob(os.path.join(out_dir, "chunk_*.wav")))


def transcribe(audio_path: str, force_giga: bool = False) -> str:
    """force_giga=True пропускает language-id и всегда гонит через GigaAM —
    для ручного выбора движка на стороне бота ("Жень текст giga"). В
    остальных случаях (auto) сначала проверяем язык и на не-русском сразу
    возвращаем "" — вызывающий код в боте трактует пустой ответ как "GigaAM
    не справился" и молча уходит на Groq Whisper (см. transcribeViaGigaAM в
    index.js), поэтому отдельный сигнал "чужой язык" наружу прокидывать не
    нужно, пустой строки достаточно."""
    if not audio_path:
        return ""
    wav_path = _to_wav(audio_path)
    chunks: list[str] = []
    try:
        if not force_giga:
            language, probability = _detect_language(wav_path)
            if language != "ru" or probability < LANG_ID_MIN_CONFIDENCE:
                return ""
        chunks = _split_into_chunks(wav_path)
        parts = [str(MODEL.transcribe(chunk)).strip() for chunk in chunks]
        return " ".join(p for p in parts if p).strip()
    finally:
        os.remove(wav_path)
        for c in chunks:
            try:
                os.remove(c)
            except OSError:
                pass
        if chunks:
            try:
                os.rmdir(os.path.dirname(chunks[0]))
            except OSError:
                pass


# api_name фиксирует имя эндпоинта — так со стороны бота адрес вызова
# предсказуем (/call/transcribe) и не завязан на автогенерируемое имя.
demo = gr.Interface(
    fn=transcribe,
    inputs=[
        gr.Audio(type="filepath", label="Аудио"),
        gr.Checkbox(value=False, label="Принудительно GigaAM (без проверки языка)"),
    ],
    outputs=gr.Textbox(label="Транскрипция"),
    title="GigaAM-v3 — распознавание речи (RU)",
    api_name="transcribe",
)

if __name__ == "__main__":
    demo.launch()