Merge feature/ai-style-postproc into dev

Optional bandpass + short reverb on Silero TTS output. Off by default
(TTS_EFFECTS_ENABLED in config.py). Preview script in utils/.
This commit is contained in:
Bossiara13 2026-04-23 02:17:02 +03:00
commit e86906801a
5 changed files with 185 additions and 6 deletions

View file

@ -63,6 +63,27 @@ python main.py
- `COMMAND_MIN_LISTEN_MS` — минимальное время записи, чтобы не закрыться мгновенно (1000 мс).
- `COMMAND_MAX_LISTEN_MS` — жёсткий потолок (15000 мс).
## Эффекты под AI-ассистента
Silero звучит естественно, но иногда хочется лёгкой «AI-окраски» в духе J.A.R.V.I.S. — узкую полосу, еле заметную реверберацию, при желании небольшой сдвиг высоты. Это тонкий постпроцессинг, голос и sample rate не меняются.
По умолчанию эффекты **выключены**. Включаются в `config.py`:
- `TTS_EFFECTS_ENABLED` — главный тумблер (`False` по умолчанию).
- `TTS_BANDPASS_LOW_HZ` / `TTS_BANDPASS_HIGH_HZ` — полоса пропускания (по умолчанию 2007000 Гц). Сужение полосы даёт ощущение «голоса по радио».
- `TTS_REVERB_WET` (0..1) и `TTS_REVERB_DECAY_MS` — доля мокрого сигнала и длительность хвоста короткой реверберации (по умолчанию 0.20 и 100 мс).
- `TTS_PITCH_SEMITONES` — сдвиг высоты в полутонах (по умолчанию 0, разумный диапазон ±2).
Фильтры реализованы на `scipy.signal` (IIR, sosfiltfilt — нулевая фазовая задержка), реверберация — несколько затухающих задержанных копий, pitch — ресэмплинг по линейной интерполяции. Всё in-process, без временных файлов и лишних зависимостей.
Быстрое A/B сравнение перед тем как переключать тумблер:
```bash
.venv\Scripts\python.exe utils\preview_effects.py
```
Скрипт сгенерирует `sound/_preview_silero_raw.wav` и `sound/_preview_silero_fx.wav` — откройте оба в плеере и решите, нравится ли эффект. Параметры fx-версии берутся из текущего `config.py` (независимо от `TTS_EFFECTS_ENABLED`).
## Схема commands.yaml
Каждая команда — это пара `phrases` + `action`:

View file

@ -34,3 +34,10 @@ COMMAND_END_SILENCE_MS = 1200
COMMAND_MIN_SPEECH_MS = 500
COMMAND_MIN_LISTEN_MS = 1000
COMMAND_MAX_LISTEN_MS = 15000
TTS_EFFECTS_ENABLED = False
TTS_BANDPASS_LOW_HZ = 200
TTS_BANDPASS_HIGH_HZ = 7000
TTS_REVERB_WET = 0.20
TTS_REVERB_DECAY_MS = 100
TTS_PITCH_SEMITONES = 0

71
effects.py Normal file
View file

@ -0,0 +1,71 @@
import numpy as np
from scipy.signal import butter, sosfiltfilt
def _ensure_float32_mono(audio):
arr = np.asarray(audio, dtype=np.float32)
if arr.ndim > 1:
arr = arr.mean(axis=1).astype(np.float32)
return arr
def apply_bandpass(audio, sr, low_hz=200, high_hz=7000, order=4):
arr = _ensure_float32_mono(audio)
nyq = 0.5 * sr
low = max(low_hz, 1) / nyq
high = min(high_hz, int(nyq) - 1) / nyq
if not (0 < low < high < 1):
return arr
sos = butter(order, [low, high], btype='band', output='sos')
return sosfiltfilt(sos, arr).astype(np.float32)
def apply_reverb(audio, sr, wet=0.20, decay_ms=100, taps=4):
arr = _ensure_float32_mono(audio)
if wet <= 0 or decay_ms <= 0:
return arr
dry = max(0.0, 1.0 - wet)
out = dry * arr
gap = max(1, int(sr * (decay_ms / 1000.0) / taps))
gain = wet
for i in range(1, taps + 1):
delay = gap * i
gain *= 0.6
if delay >= len(arr):
break
padded = np.zeros_like(arr)
padded[delay:] = arr[:len(arr) - delay]
out += gain * padded
peak = float(np.max(np.abs(out))) if out.size else 0.0
if peak > 0.99:
out *= 0.99 / peak
return out.astype(np.float32)
def apply_pitch(audio, sr, semitones=0):
arr = _ensure_float32_mono(audio)
if semitones == 0:
return arr
factor = 2.0 ** (semitones / 12.0)
n_out = int(round(len(arr) / factor))
if n_out <= 1:
return arr
src_idx = np.linspace(0, len(arr) - 1, num=n_out).astype(np.float32)
lo = np.floor(src_idx).astype(np.int64)
hi = np.clip(lo + 1, 0, len(arr) - 1)
frac = (src_idx - lo).astype(np.float32)
resampled = (1.0 - frac) * arr[lo] + frac * arr[hi]
return resampled.astype(np.float32)
def process(audio, sr, bandpass=None, reverb=None, pitch_semitones=0):
out = _ensure_float32_mono(audio)
if bandpass is not None:
low, high = bandpass
out = apply_bandpass(out, sr, low, high)
if reverb is not None:
wet, decay_ms = reverb
out = apply_reverb(out, sr, wet=wet, decay_ms=decay_ms)
if pitch_semitones:
out = apply_pitch(out, sr, semitones=pitch_semitones)
return out

37
tts.py
View file

@ -1,8 +1,12 @@
import time
import numpy as np
import sounddevice as sd
import torch
import config
import effects
language = 'ru'
model_id = 'ru_v3'
sample_rate = 48000 # 48000
@ -19,18 +23,39 @@ model, _ = torch.hub.load(repo_or_dir='snakers4/silero-models',
model.to(device)
# воспроизводим
def va_speak(what: str):
def _post_process(audio):
if not getattr(config, 'TTS_EFFECTS_ENABLED', False):
return audio
arr = np.asarray(audio, dtype=np.float32)
low = getattr(config, 'TTS_BANDPASS_LOW_HZ', 0)
high = getattr(config, 'TTS_BANDPASS_HIGH_HZ', 0)
bandpass = (low, high) if low and high and high > low else None
wet = float(getattr(config, 'TTS_REVERB_WET', 0.0))
decay = int(getattr(config, 'TTS_REVERB_DECAY_MS', 0))
reverb = (wet, decay) if wet > 0 and decay > 0 else None
pitch = int(getattr(config, 'TTS_PITCH_SEMITONES', 0))
return effects.process(arr, sample_rate,
bandpass=bandpass,
reverb=reverb,
pitch_semitones=pitch)
def synthesize(what: str):
audio = model.apply_tts(text=what + "..",
speaker=speaker,
sample_rate=sample_rate,
put_accent=put_accent,
put_yo=put_yo)
return _post_process(audio)
def va_speak(what: str):
audio = synthesize(what)
sd.play(audio, sample_rate * 1.05)
time.sleep((len(audio) / sample_rate) + 0.5)
sd.stop()
# sd.play(audio, sample_rate)
# time.sleep(len(audio) / sample_rate)
# sd.stop()

55
utils/preview_effects.py Normal file
View file

@ -0,0 +1,55 @@
import os
import sys
import numpy as np
import soundfile as sf
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import config
import effects
import tts
PHRASE = "Приветствую, сэр. Все системы в норме."
OUT_RAW = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
'sound', '_preview_silero_raw.wav')
OUT_FX = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
'sound', '_preview_silero_fx.wav')
def _render_raw(text: str) -> np.ndarray:
audio = tts.model.apply_tts(text=text + "..",
speaker=tts.speaker,
sample_rate=tts.sample_rate,
put_accent=tts.put_accent,
put_yo=tts.put_yo)
return np.asarray(audio, dtype=np.float32)
def _render_fx(raw: np.ndarray) -> np.ndarray:
return effects.process(
raw, tts.sample_rate,
bandpass=(config.TTS_BANDPASS_LOW_HZ, config.TTS_BANDPASS_HIGH_HZ),
reverb=(config.TTS_REVERB_WET, config.TTS_REVERB_DECAY_MS),
pitch_semitones=config.TTS_PITCH_SEMITONES,
)
def main():
print(f"Synthesizing: {PHRASE!r}")
raw = _render_raw(PHRASE)
fx = _render_fx(raw)
sf.write(OUT_RAW, raw, tts.sample_rate, subtype='PCM_16')
sf.write(OUT_FX, fx, tts.sample_rate, subtype='PCM_16')
dur_raw = len(raw) / tts.sample_rate
dur_fx = len(fx) / tts.sample_rate
print(f"raw: {OUT_RAW} ({len(raw)} samples, {dur_raw:.2f}s)")
print(f"fx : {OUT_FX} ({len(fx)} samples, {dur_fx:.2f}s)")
print("Open both in a media player and A/B compare.")
if __name__ == '__main__':
main()