#!/usr/bin/env python3 """Silero TTS v5 GUI — синтез русской речи.""" import os import sys import threading import time import tkinter as tk from tkinter import ttk import soundfile as sf SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) if SCRIPT_DIR not in sys.path: sys.path.insert(0, SCRIPT_DIR) from tts import SileroTTS, SPEAKER_ALIASES # noqa: E402 # ─── Цвета (в стиле ASR GUI) ────────────────────────────────────────── BG = '#1a1a2e' FG = '#e0e0e0' TEXT_BG = '#16213e' BTN_BG = '#0f3460' BTN_STOP = '#e94560' BTN_CLR = '#555' ACCENT = '#00d2ff' # ─── TTS ─────────────────────────────────────────────────────────────── _tts = None def get_tts(): global _tts if _tts is None: _tts = SileroTTS(use_accentor=True) return _tts # ─── Приложение ──────────────────────────────────────────────────────── class TTSApp: def __init__(self): self.root = tk.Tk() self.root.title('Silero TTS v5 — Русский синтез речи') self.root.geometry('800x650') self.root.configure(bg=BG) self._speaker_var = tk.StringVar(value='ru_eduard') self._status_var = tk.StringVar(value='Загрузка...') self._build_ui() self._bind_events() # Загрузка модели в фоне self.root.after(100, self._load_model) def _build_ui(self): # ── Верхняя панель: заголовок ── header = tk.Label( self.root, text='🎙️ Silero TTS v5 — Синтез русской речи', font=('Arial', 16, 'bold'), bg=BG, fg=ACCENT, ) header.pack(fill=tk.X, padx=15, pady=(15, 5)) # ── Текстовое поле ввода ── self.text = tk.Text( self.root, font=('Arial', 16), bg=TEXT_BG, fg=FG, relief=tk.FLAT, border=0, height=6, wrap=tk.WORD, insertbackground=FG, ) self.text.pack(fill=tk.BOTH, expand=True, padx=15, pady=10) self.text.insert('1.0', 'Введите текст для озвучивания...') self.text.bind('', self._on_focus_in) # ── Статус ── self.status = tk.Label( self.root, textvariable=self._status_var, font=('Arial', 12), bg=BG, fg='#aaa', ) self.status.pack(fill=tk.X, padx=15) # ── Панель управления ── ctrl = tk.Frame(self.root, bg=BG) ctrl.pack(fill=tk.X, padx=15, pady=(10, 5)) # Голос tk.Label(ctrl, text='Голос:', font=('Arial', 13), bg=BG, fg=FG).pack(side=tk.LEFT, padx=(0, 5)) self.speaker_combo = ttk.Combobox( ctrl, textvariable=self._speaker_var, font=('Arial', 12), state='readonly', width=20, ) self.speaker_combo.pack(side=tk.LEFT, padx=(0, 15)) # Кнопки self.btn_speak = tk.Button( ctrl, text='🔊 СИНТЕЗ', font=('Arial', 18, 'bold'), bg=BTN_BG, fg='white', relief=tk.FLAT, cursor='hand2', height=1, padx=20, command=self._click_speak, ) self.btn_speak.pack(side=tk.LEFT, padx=5) self.btn_save = tk.Button( ctrl, text='💾 СОХРАНИТЬ', font=('Arial', 18, 'bold'), bg=BTN_BG, fg='white', relief=tk.FLAT, cursor='hand2', height=1, padx=20, command=self._click_save, ) self.btn_save.pack(side=tk.LEFT, padx=5) self.btn_stop = tk.Button( ctrl, text='⏹ СТОП', font=('Arial', 18, 'bold'), bg=BTN_STOP, fg='white', relief=tk.FLAT, cursor='hand2', state=tk.DISABLED, height=1, padx=20, command=self._click_stop, ) self.btn_stop.pack(side=tk.LEFT, padx=5) self.btn_clear = tk.Button( ctrl, text='Очистить', font=('Arial', 14), bg=BTN_CLR, fg='white', relief=tk.FLAT, cursor='hand2', height=1, command=self._click_clear, ) self.btn_clear.pack(side=tk.LEFT, padx=5) # ── Информационная панель внизу ── info_frame = tk.Frame(self.root, bg=BG) info_frame.pack(fill=tk.X, padx=15, pady=(5, 15)) self.info_text = tk.Label( info_frame, text=('Модель: v5_cis_base | 48 kHz | WAV | ' 'Ударения: ✓ | RTF ~0.04 CPU'), font=('Arial', 10), bg=BG, fg='#888', ) self.info_text.pack() self._saved_audio = None # последний синтезированный аудио def _bind_events(self): self.root.protocol('WM_DELETE_WINDOW', self._on_close) self.root.bind('', lambda e: self._click_speak()) self.root.bind('', lambda e: self._click_save()) def _on_focus_in(self, event): if self.text.get('1.0', 'end-1c') == 'Введите текст для озвучивания...': self.text.delete('1.0', tk.END) def _load_model(self): def load(): try: tts = get_tts() speakers = tts.speakers display_names = [] for s in speakers: aliases = [k for k, v in SPEAKER_ALIASES.items() if v == s] label = s if aliases: label += f' ({", ".join(aliases)})' display_names.append(label) self.root.after(0, lambda: self._on_model_ready(display_names)) except Exception as e: self.root.after(0, lambda: self._status_var.set( f'Ошибка загрузки: {e}')) threading.Thread(target=load, daemon=True).start() def _on_model_ready(self, display_names): self.speaker_combo['values'] = display_names self.speaker_combo.current(display_names.index( next(n for n in display_names if n.startswith('ru_eduard')) )) self._status_var.set('Готов. Введите текст и нажмите СИНТЕЗ') self.btn_speak.config(state=tk.NORMAL) self.btn_save.config(state=tk.NORMAL) def _get_speaker(self) -> str: selection = self.speaker_combo.get() return selection.split()[0] # берём только ru_имя до скобок def _synthesize(self, text: str, speaker: str) -> float: """Синтезировать и вернуть длительность аудио.""" t0 = time.perf_counter() self._saved_audio = get_tts().speak(text, speaker=speaker) dt = time.perf_counter() - t0 duration = len(self._saved_audio) / get_tts().sample_rate return dt, duration def _click_speak(self): text = self.text.get('1.0', 'end-1c').strip() if not text or text == 'Введите текст для озвучивания...': self._status_var.set('Введите текст для синтеза') return speaker = self._get_speaker() self._set_busy(True, f'🔊 Синтезирую: {speaker}...') self.root.update() def task(): try: dt, duration = self._synthesize(text, speaker) # Воспроизвести import sounddevice as sd sd.play(self._saved_audio, get_tts().sample_rate) sd.wait() self.root.after(0, lambda: self._on_done(dt, duration, speaker)) except Exception as e: self.root.after(0, lambda: self._on_error(str(e))) threading.Thread(target=task, daemon=True).start() def _click_save(self): text = self.text.get('1.0', 'end-1c').strip() if not text or text == 'Введите текст для озвучивания...': self._status_var.set('Введите текст для синтеза') return speaker = self._get_speaker() self._set_busy(True, f'💾 Синтезирую: {speaker}...') self.root.update() def task(): try: dt, duration = self._synthesize(text, speaker) # Сохранить fname = f'tts_{speaker}_{int(time.time())}.wav' sf.write(fname, self._saved_audio, get_tts().sample_rate) fsize = os.path.getsize(fname) self.root.after(0, lambda: self._on_saved( fname, fsize, dt, duration, speaker)) except Exception as e: self.root.after(0, lambda: self._on_error(str(e))) threading.Thread(target=task, daemon=True).start() def _click_stop(self): import sounddevice as sd sd.stop() self._set_busy(False, '⏹ Воспроизведение остановлено') def _click_clear(self): self.text.delete('1.0', tk.END) self._status_var.set('Готов') def _set_busy(self, busy: bool, msg: str = ''): state = tk.DISABLED if busy else tk.NORMAL self.btn_speak.config(state=state) self.btn_save.config(state=state) self.btn_stop.config(state=tk.NORMAL if busy else tk.DISABLED) if msg: self._status_var.set(msg) self.root.update() def _on_done(self, dt: float, duration: float, speaker: str): rtf = dt / duration if duration > 0 else 0 self._set_busy(False) self._status_var.set( f'✅ Воспроизведено | {speaker} | {duration:.2f}s | ' f'{dt:.3f}s | RTF {rtf:.3f} (×{1/rtf:.1f})' ) def _on_saved(self, fname: str, fsize: int, dt: float, duration: float, speaker: str): rtf = dt / duration if duration > 0 else 0 self._set_busy(False) status = ( f'💾 Сохранено: {fname} ({fsize/1024:.0f} KB) | ' f'{speaker} | {duration:.2f}s | {dt:.3f}s | RTF {rtf:.3f}' ) self._status_var.set(status) # Копируем имя файла в буфер обмена self.root.clipboard_clear() self.root.clipboard_append(fname) def _on_error(self, err: str): self._set_busy(False) self._status_var.set(f'❌ Ошибка: {err}') def _on_close(self): import sounddevice as sd sd.stop() self.root.destroy() def run(self): self.root.mainloop() # ─── Main ────────────────────────────────────────────────────────────── def main(): app = TTSApp() app.run() if __name__ == '__main__': main()