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
+24
View File
@@ -0,0 +1,24 @@
"""Модуль 2: audio_io — запись и воспроизведение звука.
Кроссплатформенно (Linux для разработки, Windows — цель).
Состав: Recorder (push-to-talk буфер в RAM), Player (мгновенный stop = «Замолчи»),
ресемплинг в 16 кГц mono для STT.
"""
from __future__ import annotations
from .player import Player
from .recorder import Recorder
from .resample import resample_to_16k
from .handsfree import HandsFreeRecorder
__all__ = ["Player", "Recorder", "HandsFreeRecorder", "resample_to_16k", "list_devices"]
def list_devices() -> None:
"""Печать аудио-устройств (вход/выход) — для диагностики."""
import sounddevice as sd
print(sd.query_devices())
default_in = sd.default.device[0]
default_out = sd.default.device[1]
print(f"\nПо умолчанию: вход={default_in}, выход={default_out}")
+34
View File
@@ -0,0 +1,34 @@
"""Декодирование сжатого аудио (mp3/ogg/...) в float32.
Использует PyAV — ffmpeg-библиотеки вкомпилированы в пакет av
(он уже есть как зависимость faster-whisper), системный ffmpeg не нужен.
"""
from __future__ import annotations
import io
from typing import Tuple
import av
import numpy as np
def decode_audio_bytes(data: bytes) -> Tuple[np.ndarray, int]:
"""Байты аудиофайла → (float32 mono [-1..1], samplerate)."""
container = av.open(io.BytesIO(data))
try:
stream = container.streams.audio[0]
samplerate = int(stream.rate)
resampler = av.AudioResampler(format="flt", layout="mono")
pieces = []
for frame in container.decode(stream):
for out in resampler.resample(frame):
arr = out.to_ndarray()
if arr.ndim == 2: # (channels, samples) → mono
arr = arr[0] if arr.shape[0] == 1 else arr.mean(axis=0)
pieces.append(np.asarray(arr, dtype=np.float32))
finally:
container.close()
if not pieces:
return np.zeros(0, dtype=np.float32), samplerate
return np.concatenate(pieces), samplerate
+207
View File
@@ -0,0 +1,207 @@
"""HandsFreeRecorder — свободный голосовой режим БЕЗ клавиш (М5.2).
Постоянно слушает микрофон; решение «речь/тишина» принимает Silero-VAD:
- началась речь → копим аудио (с pre-buffer 0.4 c, чтобы не терять первые слова);
- тишина silence_sec (по умолчанию 2.0 — «пауза 2 секунды» юзера) → фраза
закончена → вызов self.on_phrase(audio, samplerate);
- короче min_speech_sec — шум, отбраковка (событие on_state("discard"));
- длиннее max_seconds — принудительный срез;
- на время ответа ассистента прослушивание ставится на паузу (suppress),
чтобы микрофон не слышал её из динамиков.
Колбэки — публичные атрибуты: их можно переприсвоить и извне
(Assistant присваивает recorder.on_phrase = ...).
Фильтр шума: голосом считается окно, у которого prob VAD выше порога И
RMS выше noise_gate (Silero на белом шуме даёт ~0.85 — одной вероятности мало).
"""
from __future__ import annotations
import time
import warnings
from typing import Callable, List, Optional
import numpy as np
import sounddevice as sd
from .resample import TARGET_RATE, resample_to_16k
_VAD_WINDOW = 512 # 32 мс при 16 кГц — родной размер окна Silero
_PRE_BUFFER_SEC = 0.4 # буфер ДО начала речи (не терять начало фразы)
class HandsFreeRecorder:
def __init__(
self,
samplerate: int = TARGET_RATE,
device: Optional[int] = None,
silence_sec: float = 2.0,
speech_threshold: float = 0.5,
min_speech_sec: float = 0.3,
max_seconds: float = 30.0,
noise_gate_rms: float = 0.006, # ниже RMS — окно считается тишиной, как бы VAD ни хотел
on_phrase: Optional[Callable[[np.ndarray, int], None]] = None,
on_state: Optional[Callable[[str], None]] = None, # listening|speech_start|speech_end|too_short|paused
on_error: Optional[Callable[[str], None]] = None,
debug: bool = False,
) -> None:
self._sr = samplerate
self._device = device
self._silence_sec = silence_sec
self._threshold = speech_threshold
self._min_speech_sec = min_speech_sec
self._max_seconds = max_seconds
self._noise_gate = noise_gate_rms
# Публичные колбэки: Assistant переприсваивает on_phrase — так и задумано
self.on_phrase: Callable[[np.ndarray, int], None] = on_phrase or (lambda a, s: None)
self.on_state: Callable[[str], None] = on_state or (lambda s: None)
self.on_error: Callable[[str], None] = on_error or (lambda m: print(f" [VAD] {m}"))
self._vad = None
self._stream = None
self._running = False
self._suppress = False
self._recording = False
self._chunks: List[np.ndarray] = []
self._pre_buffer: List[np.ndarray] = []
self._speech_frames = 0
self._silence_frames = 0
self._native_sr = samplerate
self._carry: np.ndarray = np.zeros(0, dtype=np.float32) # хвост между колбэками
# ---------------------------------------------------------------- lifecycle
def start(self) -> None:
"""Открыть микрофон и слушать. Ошибки уходят в on_error (не роняют программу)."""
try:
warnings.filterwarnings("ignore", category=Warning)
from silero_vad import load_silero_vad
self._vad = load_silero_vad()
stream = self._open(self._sr)
if stream is None:
self._native_sr = int(
sd.query_devices(self._device, "input")["default_samplerate"]
)
stream = self._open(self._native_sr, required=True)
else:
self._native_sr = self._sr
stream.start()
self._stream = stream
self._running = True
self.on_state("listening")
except Exception as exc:
self.on_error(f"Свободный режим не запустился: {exc}")
def stop(self) -> None:
self._running = False
stream, self._stream = self._stream, None
if stream is not None:
try:
stream.stop()
stream.close()
except Exception:
pass
# ---------------------------------------------------------------- режим
@property
def is_listening(self) -> bool:
return self._running and not self._suppress
def suppress(self, on: bool) -> None:
"""Пауза прослушивания (пока сами говорим) + сброс недозаписи."""
if self._suppress == on:
return
self._suppress = on
self.on_state("paused" if on else "listening")
if on:
self._recording = False
self._chunks = []
self._pre_buffer = []
self._carry = np.zeros(0, dtype=np.float32)
# ---------------------------------------------------------------- внутреннее
def _open(self, samplerate: int, required: bool = False):
try:
return sd.InputStream(
samplerate=samplerate,
device=self._device,
channels=1,
dtype="float32",
blocksize=512 if samplerate == 16000 else 0,
callback=self._on_audio,
)
except Exception as exc:
if required:
raise RuntimeError(f"Не удалось открыть микрофон: {exc}") from exc
return None
def _vad_prob(self, window: np.ndarray) -> float:
"""Уверенность VAD для окна (0..1). JIT-модель требует torch.Tensor."""
import torch
with torch.no_grad():
return float(self._vad(torch.from_numpy(window), 16000))
def _on_audio(self, indata, frames, time_info, status) -> None: # noqa: ANN001
if status:
self.on_error(str(status))
if self._suppress or self._vad is None:
return
mono = indata[:, 0].copy()
if self._native_sr != 16000:
mono16 = resample_to_16k(mono, self._native_sr)
else:
mono16 = mono
# накопитель между колбэками: окно 512 всегда полное, хвост не теряется
buf = np.concatenate([self._carry, mono16])
n = len(buf) // _VAD_WINDOW
for i in range(n):
self._process_window(buf[i * _VAD_WINDOW:(i + 1) * _VAD_WINDOW])
self._carry = buf[n * _VAD_WINDOW:]
def _process_window(self, window: np.ndarray) -> None:
rms = float(np.sqrt(np.mean(np.square(window))))
voice = self._vad_prob(window) > self._threshold and rms > self._noise_gate
if voice:
if not self._recording:
self._recording = True
self._chunks = list(self._pre_buffer) if self._pre_buffer else []
self._pre_buffer = []
self._speech_frames = 0
self.on_state("speech_start")
self._chunks.append(window)
self._speech_frames += len(window)
self._silence_frames = 0
if self._speech_frames >= int(self._max_seconds * 16000):
self._finalize()
return
if self._recording:
self._chunks.append(window)
self._silence_frames += len(window)
if self._silence_frames >= int(self._silence_sec * 16000):
self._finalize()
else:
self._pre_buffer.append(window)
if len(self._pre_buffer) > int(_PRE_BUFFER_SEC * 16000 / _VAD_WINDOW):
self._pre_buffer.pop(0)
def _finalize(self) -> None:
chunks, self._chunks = self._chunks, []
self._recording = False
self._pre_buffer = []
speech_samples = self._speech_frames
self._speech_frames = 0
self._silence_frames = 0
total = sum(len(c) for c in chunks)
# Валидна именно ДОЛЯ голоса: суммарный буфер всегда ≥ паузе тишины
if speech_samples < int(self._min_speech_sec * 16000):
self.on_state("too_short") # видимая отбраковка, не тихая
return
self.on_state("speech_end")
self.on_phrase(np.concatenate(chunks).astype(np.float32), 16000)
+89
View File
@@ -0,0 +1,89 @@
"""Player — воспроизведение с мгновенной остановкой.
Клавиша «Замолчи» = player.stop(): sounddevice.stop() прерывает вывод
в течение ~миллисекунд, очередь очищается.
Воспроизведение идёт в фоновом потоке; is_playing отражает состояние.
last_start_latency — сколько времени занял вызов sd.play() (открытие потока
вывода до первого блока): замеряется для диагностики латентности ответа.
"""
from __future__ import annotations
import threading
import time
from pathlib import Path
from typing import Optional, Union
import numpy as np
import sounddevice as sd
import soundfile as sf
class Player:
def __init__(self, device: Optional[int] = None, volume: float = 1.0) -> None:
self._device = device
self.volume = volume # 0.0 .. 2.0
self._lock = threading.Lock()
self._playing = False
self._thread: Optional[threading.Thread] = None
self.last_start_latency = 0.0 # сек: длительность вызова sd.play()
@property
def is_playing(self) -> bool:
return self._playing
def play(self, data: np.ndarray, samplerate: int, blocking: bool = False,
lead_silence_sec: float = 0.1) -> None:
"""Воспроизвести float32 mono/stereo. Не блокирует (если blocking=False).
lead_silence_sec — тишина в начале: защита от «съедания» первого слова
при открытии потока вывода (недозаполненный буфер на некоторых ALSA/Pulse).
"""
audio = np.asarray(data, dtype=np.float32)
if audio.ndim == 1:
audio = audio[:, np.newaxis] # (frames,) → (frames, 1)
if lead_silence_sec > 0:
pad = np.zeros((int(lead_silence_sec * samplerate), audio.shape[1]),
dtype=np.float32)
audio = np.concatenate([pad, audio])
gain = float(np.clip(self.volume, 0.0, 2.0))
if gain != 1.0:
audio = np.clip(audio * gain, -1.0, 1.0)
with self._lock:
# Новая команда play отменяет предыдущую (накладывать нельзя)
sd.stop()
def _run() -> None:
try:
t0 = time.perf_counter()
sd.play(audio, samplerate, device=self._device)
self.last_start_latency = time.perf_counter() - t0
sd.wait() # вернётся сразу после stop() или конца аудио
finally:
self._playing = False
self._playing = True
if blocking:
_run()
else:
self._thread = threading.Thread(target=_run, daemon=True)
self._thread.start()
def play_file(self, path: Union[str, Path], blocking: bool = False) -> None:
"""Воспроизвести wav/flac/ogg с диска."""
path = Path(path)
data, sr = sf.read(str(path), dtype="float32", always_2d=False)
self.play(data, sr, blocking=blocking)
def stop(self) -> None:
"""Мгновенно заглушить воспроизведение (клавиша «Замолчи»)."""
with self._lock:
sd.stop()
self._playing = False
def wait(self) -> None:
"""Дождаться конца текущего воспроизведения."""
t = self._thread
if t is not None and t.is_alive():
t.join()
+145
View File
@@ -0,0 +1,145 @@
"""Recorder — push-to-talk запись в RAM.
Схема работы в будущем ассистенте:
клавиша «Слушай» нажата → recorder.start()
клавиша отпущена → audio = recorder.stop() → STT
Особенности:
- пробует открыть вход на 16 кГц; если устройство не умеет — пишет в нативной
частоте и ресемплирует в 16 кГц при stop() (нужно STT);
- поток в фоновом потоке PortAudio, данные копируются в список блоков;
- слишком короткие записи (< min_seconds) возвращаются пустыми — защита от
случайных кликов;
- потоко-безопасность через threading.Lock.
"""
from __future__ import annotations
import threading
import time
from typing import Callable, List, Optional
import numpy as np
import sounddevice as sd
from .resample import TARGET_RATE, resample_to_16k
class Recorder:
def __init__(
self,
samplerate: int = TARGET_RATE,
device: Optional[int] = None,
channels: int = 1,
blocksize: int = 800, # 50 мс при 16 кГц
max_seconds: float = 60.0, # защита от «забытой» клавиши
min_seconds: float = 0.3, # короче — считаем пустой записью
on_overrun: Optional[Callable[[str], None]] = None, # колбэк о проблемах
) -> None:
self._target_sr = samplerate
self._device = device
self._channels = channels
self._blocksize = blocksize
self._max_seconds = max_seconds
self._min_seconds = min_seconds
self._on_overrun = on_overrun
self._frames: List[np.ndarray] = []
self._stream: Optional[sd.InputStream] = None
self._native_sr: int = samplerate
self._started_at: float = 0.0
self._lock = threading.Lock()
# --- состояние -------------------------------------------------------
@property
def is_recording(self) -> bool:
return self._stream is not None and self._stream.active
@property
def duration(self) -> float:
"""Длительность текущей записи в секундах (0 если не пишем)."""
if not self.is_recording:
return 0.0
return time.perf_counter() - self._started_at
@property
def native_samplerate(self) -> int:
"""Частота, на которой реально шло устройство (до ресемплинга)."""
return self._native_sr
# --- управление ------------------------------------------------------
def _callback(self, indata, frames, time_info, status) -> None: # noqa: ANN001
if status and self._on_overrun:
self._on_overrun(str(status))
self._frames.append(indata.copy())
def start(self) -> None:
"""Начать запись. Бросает RuntimeError, если запись уже идёт."""
if self.is_recording:
raise RuntimeError("Запись уже идёт")
with self._lock:
self._frames = []
stream = self._try_open(self._target_sr)
if stream is None:
# Устройство не поддерживает 16 кГц — пишем в нативной и ресемплируем при stop()
self._native_sr = int(
sd.query_devices(self._device, "input")["default_samplerate"]
)
stream = self._try_open(self._native_sr, required=True)
else:
self._native_sr = self._target_sr
stream.start()
self._stream = stream
self._started_at = time.perf_counter()
def stop(self) -> np.ndarray:
"""Остановить запись и вернуть float32 mono, 16 кГц, [-1..1]."""
stream, self._stream = self._stream, None
if stream is None:
return np.zeros(0, dtype=np.float32)
stream.stop()
stream.close()
with self._lock:
blocks = self._frames
self._frames = []
audio = np.concatenate(blocks, axis=0) if blocks else np.zeros((0, self._channels))
mono = audio.mean(axis=1) if audio.ndim > 1 else audio
mono = np.clip(mono, -1.0, 1.0).astype(np.float32)
if self._native_sr != self._target_sr:
mono = resample_to_16k(mono, self._native_sr, self._target_sr)
min_len = int(self._min_seconds * self._target_sr)
if len(mono) < min_len:
return np.zeros(0, dtype=np.float32) # слишком коротко — пусто
return mono
def cancel(self) -> None:
"""Остановить запись, выбросив данные (например, нажали «Замолчи»)."""
stream, self._stream = self._stream, None
if stream is not None:
stream.stop()
stream.close()
with self._lock:
self._frames = []
# --- внутреннее ------------------------------------------------------
def _try_open(self, samplerate: int, required: bool = False) -> Optional[sd.InputStream]:
try:
return sd.InputStream(
samplerate=samplerate,
device=self._device,
channels=self._channels,
dtype="float32",
blocksize=self._blocksize,
callback=self._callback,
)
except (sd.PortAudioError, OSError) as exc:
if required:
raise RuntimeError(f"Не удалось открыть входное устройство: {exc}") from exc
return None
+34
View File
@@ -0,0 +1,34 @@
"""Ресемплинг аудио (линейная интерполяция) — 16 кГц mono для STT.
Для речи линейной интерполяции достаточно; качество-loss заметно меньше,
чем выигрыш от простоты и отсутствия зависимостей.
"""
from __future__ import annotations
import numpy as np
TARGET_RATE = 16000
def resample_to_16k(data: np.ndarray, source_rate: int, target_rate: int = TARGET_RATE) -> np.ndarray:
"""float32 mono → float32 mono с частотой target_rate."""
if source_rate == target_rate:
return np.asarray(data, dtype=np.float32)
x = np.asarray(data, dtype=np.float32)
if x.ndim > 1:
x = x.mean(axis=1)
if len(x) == 0:
return x
n_out = int(round(len(x) * target_rate / source_rate))
if n_out <= 1:
return np.zeros(max(n_out, 0), dtype=np.float32)
# Позиции выходных сэмплов во входной шкале (линейная интерполяция)
pos = np.linspace(0.0, len(x) - 1.0, num=n_out, dtype=np.float64)
i0 = pos.astype(np.int64)
i1 = np.minimum(i0 + 1, len(x) - 1)
frac = (pos - i0).astype(np.float32)
out = x[i0] * (1.0 - frac) + x[i1] * frac
return np.clip(out, -1.0, 1.0)
+132
View File
@@ -0,0 +1,132 @@
"""Тест Модуля 2: audio_io (запись / воспроизведение / ресемплинг / мгновенный stop).
Запуск:
python modules/audio_io/test_audio_io.py devices # список аудио-устройств
python modules/audio_io/test_audio_io.py auto # автоматический смоук (без участия человека)
python modules/audio_io/test_audio_io.py talk # ИНТЕРАКТИВНЫЙ: push-to-talk через Enter
Режим talk:
Enter → начать запись (говори), Enter → закончить и услышать себя,
затем записанное уходит в STT (проверка склейки Модуль 2 + Модуль 1).
"""
from __future__ import annotations
import sys
import time
from pathlib import Path
import numpy as np
try:
from modules.audio_io import Player, Recorder, list_devices, resample_to_16k
except ImportError:
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from modules.audio_io import Player, Recorder, list_devices, resample_to_16k
SAMPLES_DIR = Path(__file__).resolve().parent / "samples"
# --------------------------------------------------------------------------
def smoke_test() -> None:
"""Автоматический смоук: ресемплинг, тоны, мгновенная остановка, короткая запись."""
print("== 1. Ресемплинг ==")
t = np.linspace(0.0, 1.0, 44100, endpoint=False, dtype=np.float32)
sine44k = 0.5 * np.sin(2 * np.pi * 440.0 * t)
out16k = resample_to_16k(sine44k, 44100)
assert len(out16k) == 16000, f"длина {len(out16k)} != 16000"
print(f" 44100 Гц ({len(sine44k)} сэмплов) → 16000 Гц ({len(out16k)}) OK")
# оценка частоты тона после ресемплинга (через число пересечений нуля)
zero_cross = np.count_nonzero(np.diff(np.signbit(out16k)))
freq = zero_cross / 2.0
print(f" тон после ресемплинга: ~{freq:.0f} Гц (ожидалось 440) OK")
print("== 2. Воспроизведение и мгновенный stop ==")
player = Player()
long_tone = 0.3 * np.sin(2 * np.pi * 440.0 * np.linspace(0, 5, 5 * 48000, endpoint=False))
player.play(long_tone, 48000)
time.sleep(0.8)
assert player.is_playing, "тон должен играть"
t0 = time.perf_counter()
player.stop()
stop_ms = (time.perf_counter() - t0) * 1000
print(f" stop() сработал за {stop_ms:.0f} мс (цель < 100 мс)")
assert not player.is_playing
print("== 3. Запись реального устройства (2 с, без участия человека) ==")
rec = Recorder(min_seconds=0.1)
rec.start()
time.sleep(2.0)
audio = rec.stop()
rms = float(np.sqrt(np.mean(np.square(audio)))) if len(audio) else 0.0
print(f" записано {len(audio)/16000:.1f} c, RMS={rms:.4f} (0 = тишина/нет данных)")
if len(audio) == 0:
print(" ВНИМАНИЕ: устройство вернуло слишком мало данных")
print("== 4. Короткая запись отбрасывается (защита) ==")
rec2 = Recorder(min_seconds=0.5)
rec2.start()
time.sleep(0.05)
empty = rec2.stop()
assert len(empty) == 0, "короткая запись должна возвращать пустой массив"
print(" OK: 50 мс → пусто")
print("\nСМОУК ПРОЙДЕН ✅")
# --------------------------------------------------------------------------
def talk_test() -> None:
"""Интерактивный push-to-talk: Enter — говорить, Enter — стоп, потом плеер и STT."""
from modules.stt import get_provider
print("Загружаю STT (faster-whisper small)...")
stt = get_provider("faster-whisper", model_size="small")
player = Player()
rec = Recorder()
while True:
print("\n[Enter] удержи-режим: нажми Enter и ГОВОРИ, затем Enter — стоп. "
"Пустая строка после фразы — выход.")
cmd = input("> ")
if cmd.strip() == "":
print("Выход.")
return
rec.start()
input("…запись идёт, Enter = закончить ")
audio = rec.stop()
if len(audio) == 0:
print("Слишком коротко — отброшено. Ещё раз.")
continue
print(f"Записано {len(audio)/16000:.1f} c (частота устройства {rec.native_samplerate} Гц).")
wav = Path(__file__).resolve().parent / "samples" / "ptt_last.wav"
wav.parent.mkdir(parents=True, exist_ok=True)
import soundfile as sf
sf.write(str(wav), audio, 16000)
print(f"Сохранено: {wav}")
print("Воспроизвожу твой голос (проверь качество, Esc/Enter — прервать)...")
player.play(audio, 16000)
try:
input(" (Enter — если хочешь прервать звук) ")
player.stop()
except KeyboardInterrupt:
player.stop()
res = stt.transcribe(audio)
print(f"STT: {res}")
def main() -> int:
mode = sys.argv[1] if len(sys.argv) > 1 else "auto"
if mode == "devices":
list_devices()
elif mode == "talk":
talk_test()
else:
smoke_test()
return 0
if __name__ == "__main__":
sys.exit(main())