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:
commit
e86906801a
5 changed files with 185 additions and 6 deletions
21
README.md
21
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`:
|
||||
|
|
|
|||
|
|
@ -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
71
effects.py
Normal 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
37
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()
|
||||
|
|
|
|||
55
utils/preview_effects.py
Normal file
55
utils/preview_effects.py
Normal 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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue