Spaces:
Running on Zero
Running on Zero
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()
|