From bd6bc294adc4c541ea1c8822f5bf10148765c972 Mon Sep 17 00:00:00 2001 From: Bossiara13 <236771060+DmitryBykov-ISPO@users.noreply.github.com> Date: Wed, 22 Apr 2026 19:58:20 +0300 Subject: [PATCH 1/4] feat: add tts effects module (bandpass + reverb + optional pitch) --- effects.py | 71 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 effects.py diff --git a/effects.py b/effects.py new file mode 100644 index 0000000..93d5e12 --- /dev/null +++ b/effects.py @@ -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 From 64ad7b963ed7662a4efdf3057929a2cfed5d6209 Mon Sep 17 00:00:00 2001 From: Bossiara13 <236771060+DmitryBykov-ISPO@users.noreply.github.com> Date: Wed, 22 Apr 2026 19:58:42 +0300 Subject: [PATCH 2/4] feat: wire effects into va_speak behind config.TTS_EFFECTS_ENABLED --- config.py | 7 +++++++ tts.py | 37 +++++++++++++++++++++++++++++++------ 2 files changed, 38 insertions(+), 6 deletions(-) diff --git a/config.py b/config.py index 825444d..4ba0240 100644 --- a/config.py +++ b/config.py @@ -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 diff --git a/tts.py b/tts.py index 29bbdd1..9930298 100644 --- a/tts.py +++ b/tts.py @@ -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() From 34f5cd4f5ae7a35f8d75abfc552b43ba72a5d14d Mon Sep 17 00:00:00 2001 From: Bossiara13 <236771060+DmitryBykov-ISPO@users.noreply.github.com> Date: Wed, 22 Apr 2026 19:59:45 +0300 Subject: [PATCH 3/4] chore: add utils/preview_effects.py for A/B comparison --- utils/preview_effects.py | 55 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 utils/preview_effects.py diff --git a/utils/preview_effects.py b/utils/preview_effects.py new file mode 100644 index 0000000..86de5e0 --- /dev/null +++ b/utils/preview_effects.py @@ -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() From e5903d807628a8a02ec61305d7a6599035be8d67 Mon Sep 17 00:00:00 2001 From: Bossiara13 <236771060+DmitryBykov-ISPO@users.noreply.github.com> Date: Wed, 22 Apr 2026 20:00:04 +0300 Subject: [PATCH 4/4] =?UTF-8?q?docs:=20README=20=E2=80=94=20TTS=20effects?= =?UTF-8?q?=20section?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/README.md b/README.md index 7e41728..fa19a5d 100644 --- a/README.md +++ b/README.md @@ -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` — полоса пропускания (по умолчанию 200–7000 Гц). Сужение полосы даёт ощущение «голоса по радио». +- `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`: