| |
| """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 |
|
|
| |
|
|
| BG = '#1a1a2e' |
| FG = '#e0e0e0' |
| TEXT_BG = '#16213e' |
| BTN_BG = '#0f3460' |
| BTN_STOP = '#e94560' |
| BTN_CLR = '#555' |
| ACCENT = '#00d2ff' |
|
|
| |
|
|
| _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('<FocusIn>', 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('<Control-Return>', lambda e: self._click_speak()) |
| self.root.bind('<Control-s>', 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] |
|
|
| 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() |
|
|
|
|
| |
|
|
| def main(): |
| app = TTSApp() |
| app.run() |
|
|
|
|
| if __name__ == '__main__': |
| main() |
|
|