first commit
This commit is contained in:
@@ -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}")
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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())
|
||||
Reference in New Issue
Block a user