first commit

This commit is contained in:
workD12
2026-09-11 19:47:15 +03:00
commit 32754a1fe2
56 changed files with 3836 additions and 0 deletions
+57
View File
@@ -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 кГц: если запись звучит «с ускорением», у устройства
проблемы с ресемплингом — сообщи, добавим ресемплинг явно.
+39
View File
@@ -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)
+61
View File
@@ -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:
"""Освободить ресурсы (по умолчанию — ничего)."""
+83
View File
@@ -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),
)
+137
View File
@@ -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())