| from dataclasses import dataclass |
| import math |
|
|
| import numpy as np |
|
|
| from .config import TTSConfig |
| from .normalizer import normalize_text, text_to_symbols |
|
|
|
|
| @dataclass(frozen=True) |
| class VoiceProfile: |
| pitch_scale: float |
| formant_scale: float |
| brightness: float |
|
|
|
|
| VOICE_PROFILES = { |
| "neutral": VoiceProfile(pitch_scale=1.0, formant_scale=1.0, brightness=1.0), |
| "bright": VoiceProfile(pitch_scale=1.2, formant_scale=1.1, brightness=1.15), |
| "deep": VoiceProfile(pitch_scale=0.82, formant_scale=0.9, brightness=0.85), |
| } |
|
|
|
|
| VOWELS = { |
| "a": (800, 1200, 2500), |
| "e": (530, 1850, 2500), |
| "i": (300, 2200, 2900), |
| "o": (500, 900, 2400), |
| "u": (350, 800, 2200), |
| "A": (650, 1600, 2550), |
| "I": (320, 2400, 3000), |
| "U": (380, 1000, 2300), |
| "W": (450, 1100, 2350), |
| } |
|
|
| FRICATIVES = set("fszhvjxSFT") |
| STOPS = set("pbtdkgcqC") |
| NASALS = set("mn") |
| LIQUIDS = set("lrwy") |
|
|
|
|
| class TinyTTSSynthesizer: |
| def __init__(self, config: TTSConfig | None = None): |
| self.config = config or TTSConfig() |
|
|
| def synthesize( |
| self, |
| text: str, |
| voice: str = "neutral", |
| speed: float = 1.0, |
| pitch_shift: float = 0.0, |
| ) -> tuple[int, np.ndarray, str]: |
| normalized = normalize_text(text) |
| symbols = text_to_symbols(text) |
| profile = VOICE_PROFILES.get(voice, VOICE_PROFILES["neutral"]) |
|
|
| pieces: list[np.ndarray] = [] |
| for symbol in symbols: |
| segment = self._render_symbol( |
| symbol=symbol, |
| profile=profile, |
| speed=max(speed, 0.1), |
| pitch_shift=pitch_shift, |
| ) |
| if segment.size: |
| pieces.append(segment) |
|
|
| if not pieces: |
| pieces.append(self._silence(0.25)) |
|
|
| audio = pieces[0] |
| for piece in pieces[1:]: |
| audio = self._crossfade(audio, piece) |
|
|
| peak = np.max(np.abs(audio)) |
| if peak > 0: |
| audio = (audio / peak) * self.config.amplitude |
|
|
| return self.config.sample_rate, audio.astype(np.float32), normalized |
|
|
| def _render_symbol( |
| self, |
| symbol: str, |
| profile: VoiceProfile, |
| speed: float, |
| pitch_shift: float, |
| ) -> np.ndarray: |
| if symbol == " ": |
| return self._silence(self.config.pause_duration_ms / 1000 / speed) |
| if symbol == "|": |
| return self._silence((self.config.pause_duration_ms * 2.2) / 1000 / speed) |
| if symbol in VOWELS: |
| return self._vowel(symbol, profile, speed, pitch_shift) |
| if symbol in FRICATIVES: |
| return self._fricative(profile, speed) |
| if symbol in STOPS: |
| return self._stop(profile, speed) |
| if symbol in NASALS: |
| return self._nasal(profile, speed, pitch_shift) |
| if symbol in LIQUIDS: |
| return self._liquid(profile, speed, pitch_shift) |
| if symbol.isdigit(): |
| return self._digit(symbol, profile, speed, pitch_shift) |
| return self._soft_noise(speed) |
|
|
| def _vowel( |
| self, |
| symbol: str, |
| profile: VoiceProfile, |
| speed: float, |
| pitch_shift: float, |
| ) -> np.ndarray: |
| duration = self._duration(1.0, speed) |
| t = self._timeline(duration) |
| pitch = self.config.base_pitch_hz * profile.pitch_scale * (1.0 + pitch_shift) |
| formants = [f * profile.formant_scale for f in VOWELS[symbol]] |
| source = ( |
| np.sin(2 * math.pi * pitch * t) |
| + 0.35 * np.sin(2 * math.pi * pitch * 2.0 * t) |
| + 0.18 * np.sin(2 * math.pi * pitch * 3.0 * t) |
| ) |
| resonance = ( |
| 0.42 * np.sin(2 * math.pi * formants[0] * t) |
| + 0.22 * np.sin(2 * math.pi * formants[1] * t) |
| + 0.12 * np.sin(2 * math.pi * formants[2] * t) |
| ) |
| envelope = self._adsr(len(t), attack=0.08, decay=0.12, sustain=0.82, release=0.18) |
| return (0.7 * source + 0.5 * resonance) * envelope |
|
|
| def _fricative(self, profile: VoiceProfile, speed: float) -> np.ndarray: |
| duration = self._duration(0.8, speed) |
| n = self._num_samples(duration) |
| noise = np.random.uniform(-1.0, 1.0, n) |
| tilt = np.concatenate(([noise[0]], np.diff(noise))) |
| mix = 0.65 * tilt + 0.35 * noise * profile.brightness |
| envelope = self._adsr(n, attack=0.02, decay=0.05, sustain=0.6, release=0.2) |
| return mix * envelope * 0.7 |
|
|
| def _stop(self, profile: VoiceProfile, speed: float) -> np.ndarray: |
| closure = self._silence(0.035 / speed) |
| burst = self._fricative(profile, speed)[: self._num_samples(0.04 / speed)] |
| return np.concatenate([closure, burst]) |
|
|
| def _nasal( |
| self, |
| profile: VoiceProfile, |
| speed: float, |
| pitch_shift: float, |
| ) -> np.ndarray: |
| duration = self._duration(0.9, speed) |
| t = self._timeline(duration) |
| pitch = self.config.base_pitch_hz * 0.92 * profile.pitch_scale * (1.0 + pitch_shift) |
| signal = ( |
| np.sin(2 * math.pi * pitch * t) |
| + 0.28 * np.sin(2 * math.pi * 280 * profile.formant_scale * t) |
| + 0.12 * np.sin(2 * math.pi * 900 * profile.formant_scale * t) |
| ) |
| envelope = self._adsr(len(t), attack=0.05, decay=0.08, sustain=0.72, release=0.2) |
| return signal * envelope * 0.7 |
|
|
| def _liquid( |
| self, |
| profile: VoiceProfile, |
| speed: float, |
| pitch_shift: float, |
| ) -> np.ndarray: |
| duration = self._duration(0.75, speed) |
| t = self._timeline(duration) |
| pitch = self.config.base_pitch_hz * 1.05 * profile.pitch_scale * (1.0 + pitch_shift) |
| glide = np.linspace(0.95, 1.05, len(t)) |
| signal = ( |
| np.sin(2 * math.pi * pitch * glide * t) |
| + 0.22 * np.sin(2 * math.pi * 700 * profile.formant_scale * t) |
| + 0.1 * np.sin(2 * math.pi * 1500 * profile.formant_scale * t) |
| ) |
| envelope = self._adsr(len(t), attack=0.04, decay=0.08, sustain=0.7, release=0.18) |
| return signal * envelope * 0.65 |
|
|
| def _digit( |
| self, |
| symbol: str, |
| profile: VoiceProfile, |
| speed: float, |
| pitch_shift: float, |
| ) -> np.ndarray: |
| names = { |
| "0": "zero", |
| "1": "one", |
| "2": "two", |
| "3": "three", |
| "4": "four", |
| "5": "five", |
| "6": "six", |
| "7": "seven", |
| "8": "eight", |
| "9": "nine", |
| } |
| chunks = [self._render_symbol(s, profile, speed, pitch_shift) for s in text_to_symbols(names[symbol])] |
| result = chunks[0] if chunks else self._silence(0.08) |
| for chunk in chunks[1:]: |
| result = self._crossfade(result, chunk) |
| return result |
|
|
| def _soft_noise(self, speed: float) -> np.ndarray: |
| duration = self._duration(0.45, speed) |
| n = self._num_samples(duration) |
| noise = np.random.uniform(-0.3, 0.3, n) |
| envelope = self._adsr(n, attack=0.03, decay=0.1, sustain=0.2, release=0.12) |
| return noise * envelope |
|
|
| def _crossfade(self, left: np.ndarray, right: np.ndarray) -> np.ndarray: |
| fade = min( |
| int(self.config.sample_rate * self.config.crossfade_ms / 1000), |
| len(left), |
| len(right), |
| ) |
| if fade <= 0: |
| return np.concatenate([left, right]) |
|
|
| curve_out = np.linspace(1.0, 0.0, fade) |
| curve_in = np.linspace(0.0, 1.0, fade) |
| mixed = left[-fade:] * curve_out + right[:fade] * curve_in |
| return np.concatenate([left[:-fade], mixed, right[fade:]]) |
|
|
| def _duration(self, scale: float, speed: float) -> float: |
| base = self.config.symbol_duration_ms / 1000 |
| return max(0.03, (base * scale) / speed) |
|
|
| def _num_samples(self, duration: float) -> int: |
| return max(1, int(self.config.sample_rate * duration)) |
|
|
| def _timeline(self, duration: float) -> np.ndarray: |
| return np.linspace(0.0, duration, self._num_samples(duration), endpoint=False) |
|
|
| def _silence(self, duration: float) -> np.ndarray: |
| return np.zeros(self._num_samples(duration), dtype=np.float32) |
|
|
| def _adsr( |
| self, |
| n: int, |
| attack: float, |
| decay: float, |
| sustain: float, |
| release: float, |
| ) -> np.ndarray: |
| attack_n = max(1, int(n * attack)) |
| decay_n = max(1, int(n * decay)) |
| release_n = max(1, int(n * release)) |
| sustain_n = max(1, n - attack_n - decay_n - release_n) |
|
|
| attack_curve = np.linspace(0.0, 1.0, attack_n, endpoint=False) |
| decay_curve = np.linspace(1.0, sustain, decay_n, endpoint=False) |
| sustain_curve = np.full(sustain_n, sustain) |
| release_curve = np.linspace(sustain, 0.0, release_n, endpoint=True) |
| envelope = np.concatenate([attack_curve, decay_curve, sustain_curve, release_curve]) |
| if len(envelope) < n: |
| envelope = np.pad(envelope, (0, n - len(envelope))) |
| return envelope[:n] |
|
|