first commit
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
# Модуль 1: STT (речь → текст)
|
||||
|
||||
Обёртка над **faster-whisper** с интерфейсом «провайдер»: если для ПК отца понадобится
|
||||
другой распознаватель (vosk), он добавляется новым классом без изменения остального проекта.
|
||||
|
||||
## Файлы
|
||||
|
||||
| Файл | Назначение |
|
||||
|---|---|
|
||||
| `base.py` | Интерфейс `SttProvider`, результат `TranscriptResult` |
|
||||
| `provider_faster_whisper.py` | Реализация на faster-whisper (CPU, int8) |
|
||||
| `test_stt.py` | Тест-скрипт: файл / генерация фразы / микрофон |
|
||||
| `samples/` | Тестовые аудио (создаются при запуске, в git не входят) |
|
||||
|
||||
## Использование из кода
|
||||
|
||||
```python
|
||||
from modules.stt import get_provider
|
||||
|
||||
stt = get_provider("faster-whisper", model_size="small") # tiny|base|small|medium
|
||||
result = stt.transcribe("phrase.wav") # путь к файлу (wav/mp3/flac)
|
||||
result = stt.transcribe(np_array_float32_16k) # или массив с микрофона
|
||||
print(result.text, result.processing_sec)
|
||||
```
|
||||
|
||||
## Что замеряет тест
|
||||
|
||||
- **processing_sec** — время распознавания (без загрузки модели);
|
||||
- **RTF** (realtime factor) — обработка/длина аудио; RTF < 1 — быстрее реального времени;
|
||||
- при `--repeat N` — лучший (минимальный) прогон.
|
||||
|
||||
## Критерии приёмки Модуля 1
|
||||
|
||||
1. Тестовая фраза распознаётся дословно (allow punctuation/case).
|
||||
2. Фраза ~5 c обрабатывается за <= 3 c (RTF <= 0.6) на `small`/int8/CPU.
|
||||
3. Если нет — пробуем `base`, фиксируем цифры; если и `base` медленный — добавляем Vosk-провайдер.
|
||||
|
||||
## Решение по скорости (замер 2026-09-10, Linux, фраза 8.9 c, edge-tts голос)
|
||||
|
||||
| Модель | Обработка | RTF | Точность |
|
||||
|---|---|---|---|
|
||||
| **small** | **1.17 c** | **0.13** | дословно ✅ |
|
||||
| base | 0.54 c | 0.06 | 2 ошибки ("выпить чаю" → "выпечаю") |
|
||||
| tiny | 0.39 c | 0.04 | 4 ошибки ("тестовы", "выпечаю") |
|
||||
|
||||
**Вердикт: faster-whisper `small` (int8, CPU, beam=1, VAD on)** — RTF 0.13 даёт запас ×3
|
||||
даже на слабом ПК; качество base/tiny недостаточно для пожилого пользователя.
|
||||
Загрузка модели при старте: ~2–5 c (после скачивания), кэш в `~/.cache/huggingface`.
|
||||
|
||||
Память по факту не замерена — при желании: `/usr/bin/time -v .venv/bin/python modules/stt/test_stt.py --make-sample`.
|
||||
|
||||
## Известные ограничения
|
||||
|
||||
- Первый запуск качает модель с HuggingFace (small ≈ 460 МБ). Если недоступно:
|
||||
`export HF_ENDPOINT=https://hf-mirror.com` перед запуском.
|
||||
- Запись с микрофона идёт на 16 кГц: если запись звучит «с ускорением», у устройства
|
||||
проблемы с ресемплингом — сообщи, добавим ресемплинг явно.
|
||||
@@ -0,0 +1,39 @@
|
||||
"""STT-модуль: речь → текст.
|
||||
|
||||
Использование из других модулей:
|
||||
|
||||
from modules.stt import get_provider
|
||||
|
||||
stt = get_provider("faster-whisper", model_size="small")
|
||||
result = stt.transcribe("phrase.wav")
|
||||
print(result.text)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from .base import SAMPLE_RATE, AudioInput, SttProvider, TranscriptResult
|
||||
from .provider_faster_whisper import FasterWhisperProvider
|
||||
|
||||
__all__ = [
|
||||
"SAMPLE_RATE",
|
||||
"AudioInput",
|
||||
"SttProvider",
|
||||
"TranscriptResult",
|
||||
"FasterWhisperProvider",
|
||||
"get_provider",
|
||||
]
|
||||
|
||||
_PROVIDERS = {
|
||||
FasterWhisperProvider.name: FasterWhisperProvider,
|
||||
# "vosk" добавим, если faster-whisper окажется тяжёлым для ПК отца (замер в Модуле 1)
|
||||
}
|
||||
|
||||
|
||||
def get_provider(name: str, **kwargs) -> SttProvider:
|
||||
"""Фабрика провайдеров по имени (выбор из конфига)."""
|
||||
try:
|
||||
provider_cls = _PROVIDERS[name]
|
||||
except KeyError:
|
||||
raise ValueError(
|
||||
f"Неизвестный STT-провайдер: {name!r}. Доступны: {sorted(_PROVIDERS)}"
|
||||
) from None
|
||||
return provider_cls(**kwargs)
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Базовый интерфейс STT-модуля (речь → текст).
|
||||
|
||||
Любой распознаватель (faster-whisper, vosk, ...) реализует SttProvider,
|
||||
чтобы остальной проект не зависел от конкретной библиотеки.
|
||||
Замена провайдера = правка одной строки в конфиге.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Union
|
||||
|
||||
import numpy as np
|
||||
|
||||
# Что умеет принимать transcribe(): путь к файлу ЛИБО готовый массив float32
|
||||
AudioInput = Union[str, Path, np.ndarray]
|
||||
|
||||
# Все провайдеры ждут 16 кГц mono float32 ([-1 .. 1])
|
||||
SAMPLE_RATE = 16000
|
||||
|
||||
|
||||
@dataclass
|
||||
class TranscriptResult:
|
||||
"""Результат распознавания одной фразы."""
|
||||
|
||||
text: str
|
||||
duration_sec: float # длина аудио в секундах
|
||||
processing_sec: float # сколько времени считали
|
||||
language: str = "ru"
|
||||
language_probability: float = 0.0
|
||||
|
||||
@property
|
||||
def realtime_factor(self) -> float:
|
||||
"""processing / duration. Значение < 1.0 — быстрее реального времени."""
|
||||
if self.duration_sec <= 0:
|
||||
return float("inf")
|
||||
return self.processing_sec / self.duration_sec
|
||||
|
||||
def __str__(self) -> str:
|
||||
return (
|
||||
f"[{self.duration_sec:.1f} c аудио → {self.processing_sec:.2f} c, "
|
||||
f"RTF {self.realtime_factor:.2f}] {self.text!r}"
|
||||
)
|
||||
|
||||
|
||||
class SttProvider(ABC):
|
||||
"""Интерфейс провайдера распознавания речи."""
|
||||
|
||||
name: str = "base"
|
||||
|
||||
@abstractmethod
|
||||
def transcribe(self, audio: AudioInput, sample_rate: int = SAMPLE_RATE) -> TranscriptResult:
|
||||
"""Распознать речь.
|
||||
|
||||
audio — путь к файлу (wav/mp3/flac, декодирование внутри) ИЛИ
|
||||
numpy-массив float32 mono с частотой sample_rate.
|
||||
"""
|
||||
|
||||
def close(self) -> None:
|
||||
"""Освободить ресурсы (по умолчанию — ничего)."""
|
||||
@@ -0,0 +1,83 @@
|
||||
"""Провайдер STT на faster-whisper (CTranslate2).
|
||||
|
||||
Модель автоматически скачивается с HuggingFace при первом запуске
|
||||
и кэшируется в ~/.cache/huggingface (small ≈ 460 МБ).
|
||||
|
||||
Если скачивание из HuggingFace недоступно/медленное, можно указать зеркало:
|
||||
export HF_ENDPOINT=https://hf-mirror.com
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import numpy as np
|
||||
from faster_whisper import WhisperModel
|
||||
|
||||
from .base import SAMPLE_RATE, AudioInput, SttProvider, TranscriptResult
|
||||
|
||||
|
||||
class FasterWhisperProvider(SttProvider):
|
||||
name = "faster-whisper"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
model_size: str = "small", # tiny | base | small | medium | large-v3
|
||||
device: str = "cpu", # cpu | cuda
|
||||
compute_type: str = "int8", # int8 на CPU — быстро и мало памяти
|
||||
language: str = "ru", # фиксируем язык: детекция не нужна
|
||||
beam_size: int = 1, # 1 = жадный поиск, заметно быстрее
|
||||
vad_filter: bool = True, # Silero-VAD внутри: отрезает тишину
|
||||
cpu_threads: int = 0, # 0 = по умолчанию движка
|
||||
) -> None:
|
||||
self._language = language
|
||||
self._beam_size = beam_size
|
||||
self._vad_filter = vad_filter
|
||||
|
||||
t0 = time.perf_counter()
|
||||
self._model = WhisperModel(
|
||||
model_size,
|
||||
device=device,
|
||||
compute_type=compute_type,
|
||||
cpu_threads=cpu_threads,
|
||||
)
|
||||
# Время загрузки (и первого скачивания) модели — отдельно от распознавания.
|
||||
self.load_sec = time.perf_counter() - t0
|
||||
|
||||
def transcribe(self, audio: AudioInput, sample_rate: int = SAMPLE_RATE) -> TranscriptResult:
|
||||
duration: Optional[float] = None
|
||||
|
||||
if isinstance(audio, np.ndarray):
|
||||
samples = np.asarray(audio, dtype=np.float32)
|
||||
if samples.ndim > 1: # (frames, channels) → mono
|
||||
samples = samples.mean(axis=1)
|
||||
duration = len(samples) / sample_rate
|
||||
source: AudioInput = samples
|
||||
else:
|
||||
path = Path(audio)
|
||||
if not path.is_file():
|
||||
raise FileNotFoundError(f"Аудио-файл не найден: {path}")
|
||||
source = str(path)
|
||||
|
||||
t0 = time.perf_counter()
|
||||
segments, info = self._model.transcribe(
|
||||
source,
|
||||
language=self._language,
|
||||
beam_size=self._beam_size,
|
||||
vad_filter=self._vad_filter,
|
||||
)
|
||||
text = "".join(segment.text for segment in segments)
|
||||
text = " ".join(text.split()) # нормализуем пробелы между сегментами
|
||||
processing = time.perf_counter() - t0
|
||||
|
||||
if duration is None: # для файлов длина известна после transcribe
|
||||
duration = float(getattr(info, "duration", 0.0) or 0.0)
|
||||
|
||||
return TranscriptResult(
|
||||
text=text,
|
||||
duration_sec=duration,
|
||||
processing_sec=processing,
|
||||
language=info.language or self._language,
|
||||
language_probability=float(info.language_probability or 0.0),
|
||||
)
|
||||
@@ -0,0 +1,137 @@
|
||||
"""Тест STT-модуля (Модуль 1).
|
||||
|
||||
Запуск:
|
||||
python modules/stt/test_stt.py --make-sample # сгенерировать sample.mp3 (edge-tts) и распознать
|
||||
python modules/stt/test_stt.py modules/stt/samples/sample.mp3
|
||||
python modules/stt/test_stt.py --record 5 # 5 секунд с микрофона → распознать
|
||||
python modules/stt/test_stt.py --model base <file> # лёгкая модель для слабого ПК
|
||||
python modules/stt/test_stt.py --repeat 3 <file> # замерить скорость несколькими прогонами
|
||||
|
||||
Критерий приёмки: фраза распознана верно, обработка <= 3 c на фразе ~5 c.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import numpy as np
|
||||
|
||||
try:
|
||||
from modules.stt import get_provider
|
||||
except ImportError: # запуск как обычного скрипта: добавляем корень проекта в путь
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||
from modules.stt import get_provider
|
||||
|
||||
SAMPLES_DIR = Path(__file__).resolve().parent / "samples"
|
||||
SAMPLE_PHRASE = (
|
||||
"Привет! Это тестовый голосовой ассистент. "
|
||||
"Сегодня хорошая погода, и я хочу выпить чаю."
|
||||
)
|
||||
DEFAULT_VOICE = "ru-RU-SvetlanaNeural"
|
||||
|
||||
|
||||
def make_sample(path: Path, voice: str) -> None:
|
||||
"""Синтез русской фразы через edge-tts — удобный источник тестового аудио."""
|
||||
import edge_tts
|
||||
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
async def _save() -> None:
|
||||
await edge_tts.Communicate(SAMPLE_PHRASE, voice).save(str(path))
|
||||
|
||||
print(f"Генерирую {path.name} (голос {voice})...")
|
||||
asyncio.run(_save())
|
||||
|
||||
|
||||
def record(seconds: int, sample_rate: int = 16000) -> np.ndarray:
|
||||
"""Запись с микрофона по умолчанию → float32 mono, 16 кГц."""
|
||||
import sounddevice as sd
|
||||
import soundfile as sf
|
||||
|
||||
print(f"\nЗапись {seconds} с. Приготовьтесь:")
|
||||
for i in (3, 2, 1):
|
||||
print(f" {i}...")
|
||||
time.sleep(0.6)
|
||||
print(" >>> ГОВОРИТЕ <<<")
|
||||
raw = sd.rec(int(seconds * sample_rate), samplerate=sample_rate, channels=1, dtype="int16")
|
||||
sd.wait()
|
||||
audio = raw.astype(np.float32) / 32768.0
|
||||
audio = audio.mean(axis=1) # (frames, 1) → mono
|
||||
wav = SAMPLES_DIR / "recording.wav"
|
||||
sf.write(str(wav), audio, sample_rate)
|
||||
print(f"Сохранено: {wav}")
|
||||
return audio
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
p = argparse.ArgumentParser(description="Тест STT-модуля (faster-whisper)")
|
||||
p.add_argument("audio", nargs="?", help="аудио-файл (wav/mp3/flac)")
|
||||
p.add_argument("--make-sample", action="store_true",
|
||||
help="сгенерировать sample.mp3 (edge-tts) и распознать его")
|
||||
p.add_argument("--voice", default=DEFAULT_VOICE, help="голос edge-tts для --make-sample")
|
||||
p.add_argument("--record", type=int, default=0, metavar="SEC",
|
||||
help="записать SEC секунд с микрофона и распознать")
|
||||
p.add_argument("--model", default="small", help="tiny | base | small | medium")
|
||||
p.add_argument("--device", default="cpu", help="cpu | cuda")
|
||||
p.add_argument("--compute", default="int8", help="int8 | float16 | float32")
|
||||
p.add_argument("--beam", type=int, default=1, help="beam size (1 = быстрее)")
|
||||
p.add_argument("--no-vad", action="store_true", help="не отрезать тишину")
|
||||
p.add_argument("--repeat", type=int, default=1, help="число прогонов для замера")
|
||||
return p.parse_args()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
|
||||
audio_array: Optional[np.ndarray] = None
|
||||
target: Optional[Path] = None
|
||||
|
||||
if args.record:
|
||||
audio_array = record(args.record)
|
||||
elif args.make_sample:
|
||||
target = SAMPLES_DIR / "sample.mp3"
|
||||
if not target.exists():
|
||||
make_sample(target, args.voice)
|
||||
else:
|
||||
print(f"Использую существующий {target}")
|
||||
elif args.audio:
|
||||
target = Path(args.audio)
|
||||
else:
|
||||
print(__doc__)
|
||||
return 1
|
||||
|
||||
print(f"\nЗагружаю модель {args.model!r} ({args.compute}, {args.device})...")
|
||||
print("Первый запуск скачивает модель с HuggingFace (small ≈ 460 МБ), это нормально.")
|
||||
stt = get_provider(
|
||||
"faster-whisper",
|
||||
model_size=args.model,
|
||||
device=args.device,
|
||||
compute_type=args.compute,
|
||||
beam_size=args.beam,
|
||||
vad_filter=not args.no_vad,
|
||||
)
|
||||
print(f"Модель готова за {stt.load_sec:.1f} с.\n")
|
||||
|
||||
results = []
|
||||
for i in range(1, args.repeat + 1):
|
||||
source = audio_array if audio_array is not None else target
|
||||
res = stt.transcribe(source)
|
||||
results.append(res)
|
||||
print(f"[прогон {i}/{args.repeat}] {res}")
|
||||
|
||||
best = min(results, key=lambda r: r.processing_sec)
|
||||
print("\n=== ИТОГ ===")
|
||||
print(f"Текст: {best.text or '(пусто — нечего распознавать)'}")
|
||||
print(f"Аудио {best.duration_sec:.1f} c → обработка {best.processing_sec:.2f} c "
|
||||
f"(RTF {best.realtime_factor:.2f})")
|
||||
print(f"Язык: {best.language} (уверенность {best.language_probability:.2f})")
|
||||
passed = bool(best.text.strip())
|
||||
print("ПРИЁМКА:", "ПРОЙДЕНА ✅" if passed else "ПРОВАЛЕНА ❌ (пустой текст)")
|
||||
return 0 if passed else 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user