FIX state: Curent in worked state

This commit is contained in:
2026-03-23 20:08:04 +03:00
parent 5b79cfeb71
commit 9af128ffc6
4 changed files with 364 additions and 187 deletions

View File

@@ -22,9 +22,19 @@ models:
generation: generation:
default_language: "Russian" default_language: "Russian"
default_speaker: "Chelsie" default_speaker: "serena"
device: "auto" device: "auto"
dtype: "bfloat16" dtype: "float16"
# Настройки для VoiceDesign
voice_design:
# Тестовая фраза для предпрослушки голоса
# Используется в пункте "3. Предпрослушка VoiceDesign"
test_phrase: "Привет! Это тестовая фраза. Я готов помочь тебе с любой задачей. Как тебе мой новый голос?"
# Альтернативные варианты (можно раскомментировать):
# test_phrase: "Здравствуй! Меня зовут... ну, пока у меня нет имени. Но звучу я классно, правда?"
# test_phrase: "Добрый день. Это короткая демонстрация синтезированной речи. Спасибо за внимание."
recording: recording:
sample_rate: 16000 sample_rate: 16000

235
main.py
View File

@@ -1,11 +1,11 @@
import sys import sys
import os import os
import time import time
import sounddevice as sd import sounddevice as sd
import soundfile as sf
import numpy as np import numpy as np
import yaml import yaml
# Добавляем текущую директорию в путь для импорта
sys.path.append(os.path.dirname(os.path.abspath(__file__))) sys.path.append(os.path.dirname(os.path.abspath(__file__)))
from tts_engine import TTSEngine from tts_engine import TTSEngine
@@ -20,24 +20,24 @@ def record_sample_interactive(engine):
sr = cfg['sample_rate'] sr = cfg['sample_rate']
channels = cfg['channels'] channels = cfg['channels']
print("\n--- 🎙 Запись нового сэмпла ---") print("\n--- [REC] Запись нового сэмпла ---")
name = input("Имя сэмпла (латиница, без пробелов, Enter для auto): ").strip() name = input("Имя сэмпла (латиница, без пробелов, Enter для auto): ").strip()
if not name: name = f"speaker_{int(time.time())}" if not name: name = f"speaker_{int(time.time())}"
# Очистка имени от недопустимых символов # Очистка имени
name = "".join(c for c in name if c.isalnum() or c in ('_', '-')).strip() name = "".join(c for c in name if c.isalnum() or c in ('_', '-')).strip()
text_prompt = input("Фраза для чтения (будет сохранена как текст сэмпла): ").strip() text_prompt = input("Фраза для чтения (сохранится как текст сэмпла): ").strip()
if not text_prompt: if not text_prompt:
print(" Текст сэмпла обязателен для качественного клонирования.") print("[!] Текст сэмпла обязателен для качественного клонирования.")
return None return None
save_dir = os.path.join(engine.config['storage']['sample_dir'], name) save_dir = os.path.join(engine.config['storage']['sample_dir'], name)
if os.path.exists(save_dir): if os.path.exists(save_dir):
print(f"⚠️ Сэмпл с именем '{name}' уже существует. Перезапись.") print(f"[!] Сэмпл с именем '{name}' уже существует. Перезапись.")
print("\n🎤 НАЧАЛО ЗАПИСИ. Говорите сейчас!") print("\n>>> НАЧАЛО ЗАПИСИ. Говорите сейчас!")
print(f" Запись остановится автоматически после паузы в {cfg['silence_duration']} сек.") print(f">>> Запись остановится автоматически после паузы в {cfg['silence_duration']} сек.")
frames = [] frames = []
silence_counter = 0 silence_counter = 0
@@ -48,32 +48,28 @@ def record_sample_interactive(engine):
while True: while True:
data, overflowed = stream.read(1024) data, overflowed = stream.read(1024)
if overflowed: if overflowed:
print("⚠️ Buffer overflow") print("[!] Buffer overflow")
frames.append(data.copy()) frames.append(data.copy())
# Логика VAD (Voice Activity Detection) # Логика VAD
rms = get_rms(data) rms = get_rms(data)
# Если звук тихий
if rms < cfg['silence_threshold']: if rms < cfg['silence_threshold']:
silence_counter += len(data) / sr silence_counter += len(data) / sr
else: else:
silence_counter = 0 # Сброс при наличии голоса silence_counter = 0
# Авто-стоп
current_duration = time.time() - start_time current_duration = time.time() - start_time
if silence_counter >= cfg['silence_duration'] and current_duration > cfg['min_duration']: if silence_counter >= cfg['silence_duration'] and current_duration > cfg['min_duration']:
print("\n🛑 Тишина обнаружена. Остановка записи.") print("\n[STOP] Тишина обнаружена. Остановка записи.")
break break
except KeyboardInterrupt: except KeyboardInterrupt:
print("\nЗапись прервана вручную.") print("\n[!] Запись прервана вручную.")
# Обработка и сохранение # Обработка и сохранение
recording = np.concatenate(frames, axis=0) recording = np.concatenate(frames, axis=0)
# Создаем папку и сохраняем
os.makedirs(save_dir, exist_ok=True) os.makedirs(save_dir, exist_ok=True)
audio_path = os.path.join(save_dir, "audio.wav") audio_path = os.path.join(save_dir, "audio.wav")
@@ -83,16 +79,16 @@ def record_sample_interactive(engine):
with open(prompt_path, 'w', encoding='utf-8') as f: with open(prompt_path, 'w', encoding='utf-8') as f:
f.write(text_prompt) f.write(text_prompt)
print(f" Сэмпл сохранен: {save_dir}") print(f"[+] Сэмпл сохранен: {save_dir}")
return save_dir return save_dir
def select_sample_ui(engine): def select_sample_ui(engine):
samples = engine.get_available_samples() samples = engine.get_available_samples()
if not samples: if not samples:
print("Нет сохраненных сэмплов. Сначала запишите один.") print("[!] Нет сохраненных сэмплов. Сначала запишите один.")
return None return None
print("\n--- 📂 Выберите сэмпл ---") print("\n--- Выберите сэмпл ---")
for i, s in enumerate(samples): for i, s in enumerate(samples):
txt_preview = s['prompt'][:40] + "..." if len(s['prompt']) > 40 else s['prompt'] txt_preview = s['prompt'][:40] + "..." if len(s['prompt']) > 40 else s['prompt']
print(f"[{i+1}] {s['name']} : \"{txt_preview}\"") print(f"[{i+1}] {s['name']} : \"{txt_preview}\"")
@@ -103,11 +99,11 @@ def select_sample_ui(engine):
return samples[idx]['path'] return samples[idx]['path']
except ValueError: except ValueError:
pass pass
print("Неверный выбор.") print("[!] Неверный выбор.")
return None return None
def select_audio_device(): def select_audio_device():
print("\n--- 🔊 Выбор устройства вывода ---") print("\n--- Выбор устройства вывода ---")
devices = sd.query_devices() devices = sd.query_devices()
output_devices = [] output_devices = []
@@ -130,14 +126,25 @@ def play_audio(filepath, device_id=None):
try: try:
data, sr = sf.read(filepath, dtype='float32') data, sr = sf.read(filepath, dtype='float32')
sd.play(data, sr, device=device_id) sd.play(data, sr, device=device_id)
print(f"▶️ Воспроизведение: {os.path.basename(filepath)}") print(f">>> Воспроизведение: {os.path.basename(filepath)}")
# sd.wait() # Раскомментировать, если нужно блокировать консоль до конца воспроизведения
except Exception as e: except Exception as e:
print(f" Ошибка воспроизведения: {e}") print(f"[!] Ошибка воспроизведения: {e}")
def get_test_phrase(engine):
"""Получает тестовую фразу из конфига или возвращает дефолт"""
return engine.config.get('voice_design', {}).get('test_phrase',
'Привет! Это тестовая фраза для проверки нового голоса.')
# --- Главное меню --- # --- Главное меню ---
def main(): def main():
# Попытка установить UTF-8 для консоли Windows
if sys.platform == 'win32':
try:
sys.stdout.reconfigure(encoding='utf-8')
except:
pass
print("Инициализация движка...") print("Инициализация движка...")
try: try:
engine = TTSEngine("config.yaml") engine = TTSEngine("config.yaml")
@@ -149,21 +156,24 @@ def main():
output_device = None output_device = None
while True: while True:
print("\n" + "="*40) print("\n" + "="*50)
print(" QWEN3-TTS CONSOLE (Full Version)") print(" QWEN3-TTS CONSOLE")
print("="*40) print("="*50)
print("1. 🎙 Управление сэмплами") print("1. Управление сэмплами (Запись/Выбор)")
print("2. 🗣 Синтез речи") print("2. Синтез речи")
print("3. 📁 История (Прослушивание/Чтение)") print("3. История (Прослушивание/Чтение)")
print("4. ⚙️ Настройки") print("4. Список стандартных голосов (CustomVoice)")
print("5. Загрузить/Проверить модели (кэширование)")
print("6. Настройки (Устройство вывода)")
print("0. Выход") print("0. Выход")
choice = input(">> ").strip() choice = input(">> ").strip()
# 1. Управление сэмплами
if choice == '1': if choice == '1':
print("\n--- Управление сэмплами ---") print("\n--- Управление сэмплами ---")
print("1. Записать новый сэмпл") print("1. Записать новый сэмпл")
print("2. Вырать существующий") print("2. Выбрать существующий")
print("3. Сбросить текущий выбор") print("3. Сбросить текущий выбор")
sub = input(">> ").strip() sub = input(">> ").strip()
@@ -174,62 +184,148 @@ def main():
path = select_sample_ui(engine) path = select_sample_ui(engine)
if path: if path:
current_sample_path = path current_sample_path = path
print(f" Выбран сэмпл: {path}") print(f"[+] Выбран сэмпл: {path}")
elif sub == '3': elif sub == '3':
current_sample_path = None current_sample_path = None
print("Сброшено. Будет использован голос по умолчанию.") print("[i] Сброшено. Будет использован голос по умолчанию.")
# 2. Синтез речи
elif choice == '2': elif choice == '2':
text = input("\nВведите текст: ").strip()
if not text: continue
print("\nРежим синтеза:") print("\nРежим синтеза:")
print(f"1. Клонирование (Сэмпл: {'Да' if current_sample_path else 'Нет'})") print(f"1. Клонирование (Сэмпл: {'Да' if current_sample_path else 'Нет'})")
print("2. Описание голоса (Voice Design)") print("2. Voice Design (описание + клонирование)")
print("3. Стандартный голос") print("3. Предпрослушка VoiceDesign (только генерация)")
print("4. Стандартный голос")
mode = input(">> ").strip() mode = input(">> ").strip()
# Для режимов 1, 2, 4 нужен ввод текста
# Для режима 3 используем тестовую фразу из конфига
text = None
if mode in ['1', '2', '4']:
text = input("\nВведите текст: ").strip()
if not text:
continue
elif mode == '3':
test_phrase = get_test_phrase(engine)
print(f"\nТестовая фраза из конфига: \"{test_phrase}\"")
use_custom = input("Использовать свой текст? (y/n): ").strip().lower()
if use_custom == 'y':
text = input("Введите текст: ").strip()
if not text:
continue
else:
text = test_phrase
else:
continue
try: try:
start_t = time.time() start_t = time.time()
wavs, sr = None, None wavs, sr = None, None
if mode == '1': if mode == '1':
if not current_sample_path: if not current_sample_path:
print(" Ошибка: Сэмпл не выбран!") print("[!] Ошибка: Сэмпл не выбран!")
continue continue
wavs, sr = engine.generate_with_sample(text, current_sample_path) wavs, sr = engine.generate_with_sample(text, current_sample_path)
elif mode == '2': elif mode == '2':
desc = input("Описание голоса (напр. 'Добрый женский голос'): ").strip() desc = input("Описание голоса (ТОЛЬКО АНГЛИЙСКИЙ!): ").strip()
if not desc:
desc = "A neutral clear voice with moderate pace" # дефолт на английском
if not desc: desc = "Neutral voice" if not desc: desc = "Neutral voice"
wavs, sr = engine.generate_with_description(text, desc) wavs, sr = engine.generate_with_description(text, desc)
elif mode == '3': elif mode == '3':
wavs, sr = engine.generate_standard(text) # Предпрослушка VoiceDesign
desc = input("Описание голоса (напр. 'Злой робот'): ").strip()
if not desc: desc = "Neutral voice"
print(f"\n🎨 Генерация VoiceDesign: '{desc}'")
wavs, sr = engine.generate_voice_design_only(text, desc)
# Автовоспроизведение
print("🎵 Воспроизведение...")
temp_path = os.path.join(engine.config['storage']['output_dir'], "_temp_preview.wav")
sf.write(temp_path, wavs[0], sr)
play_audio(temp_path, output_device)
# Меню после предпрослушки
print("\n--- Что дальше? ---")
print("1. Сгенерировать полный текст этим голосом (Design + Clone)")
print("2. Попробовать другое описание")
print("3. Сохранить результат")
print("4. Вернуться в меню")
next_action = input(">> ").strip()
if next_action == '1':
full_text = input("\nВведите полный текст: ").strip()
if full_text:
print(f"\n🔄 Генерация полного текста...")
start_t = time.time()
wavs, sr = engine.generate_with_description(full_text, desc)
else:
continue
elif next_action == '2':
continue
elif next_action == '3':
# Сохраняем текущий результат (тестовую фразу)
pass # wavs уже содержит аудио, сохранится ниже
else:
continue
elif mode == '4':
# Получаем список доступных спикеров
print("\nЗагрузка списка стандартных голосов...")
speakers = engine.get_custom_speakers_list()
selected_speaker = None
if speakers:
print(f"\n--- Доступные голоса ({len(speakers)}) ---")
for i, spk in enumerate(speakers):
marker = " (DEFAULT)" if spk == engine.config['generation']['default_speaker'] else ""
print(f"[{i+1}] {spk}{marker}")
print(f"\nВведите номер голоса (1-{len(speakers)}) или Enter для default:")
spk_choice = input(">> ").strip()
if spk_choice.isdigit():
spk_idx = int(spk_choice) - 1
if 0 <= spk_idx < len(speakers):
selected_speaker = speakers[spk_idx]
print(f"[+] Выбран голос: {selected_speaker}")
else:
print("[!] Неверный номер, используется default.")
else:
print("[i] Используется голос по умолчанию.")
else:
print("[!] Не удалось получить список голосов, используется default.")
wavs, sr = engine.generate_standard(text, speaker=selected_speaker)
if wavs is not None: if wavs is not None:
# Сохранение # Сохранение
saved_path = engine.save_result(text, wavs, sr) saved_path = engine.save_result(text, wavs, sr)
elapsed = time.time() - start_t elapsed = time.time() - start_t
print(f"\n Успешно за {elapsed:.2f} сек.") print(f"\n[+] Успешно за {elapsed:.2f} сек.")
print(f"📁 Файл: {saved_path}") print(f"[+] Файл: {saved_path}")
# Вопрос о воспроизведении # Для режима 3 (предпрослушка) уже воспроизвели, спрашиваем повторно только для других
ans = input("Воспроизвести сейчас? (y/n): ").strip().lower() if mode != '3':
if ans == 'y': ans = input("Воспроизвести сейчас? (y/n): ").strip().lower()
play_audio(saved_path, output_device) if ans == 'y':
play_audio(saved_path, output_device)
except Exception as e: except Exception as e:
print(f" Ошибка генерации: {e}") print(f"[!] Ошибка генерации: {e}")
# 3. История
elif choice == '3': elif choice == '3':
history = engine.get_history() history = engine.get_history()
if not history: if not history:
print("\n📂 Папка out пуста.") print("\n[i] Папка out пуста.")
continue continue
print(f"\n--- 📂 История ({len(history)} файлов) ---") print(f"\n--- История ({len(history)} файлов) ---")
# Выводим последние 10
for i, item in enumerate(history[:10]): for i, item in enumerate(history[:10]):
print(f"[{i+1}] {item['filename']}") print(f"[{i+1}] {item['filename']}")
print(f" Текст: {item['text'][:50]}...") print(f" Текст: {item['text'][:50]}...")
@@ -241,22 +337,39 @@ def main():
sel = input(">> ").strip() sel = input(">> ").strip()
if sel.isdigit(): if sel.isdigit():
idx = int(sel) - 1 idx = int(sel) - 1
# Ищем в полном списке, но отображаем 10
# Для простоты берем индекс из отображаемого списка (0-9)
# Но правильнее из полного списка history
if 0 <= idx < len(history): if 0 <= idx < len(history):
item = history[idx] item = history[idx]
print(f"\n▶️ Файл: {item['filename']}") print(f"\n>>> Файл: {item['filename']}")
print(f"📝 Текст:\n{item['text']}") print(f"Текст:\n{item['text']}")
print("-" * 30) print("-" * 30)
play_audio(item['wav_path'], output_device) play_audio(item['wav_path'], output_device)
# 4. Список стандартных голосов
elif choice == '4': elif choice == '4':
print("\nЗагрузка списка голосов из CustomVoice модели...")
try:
speakers = engine.get_custom_speakers_list()
if speakers:
print("\n--- Доступные голоса ---")
for spk in speakers:
print(f"- {spk}")
print("\n(Один из них используется в режиме 'Стандартный голос')")
else:
print("[!] Список пуст или модель не загружена.")
except Exception as e:
print(f"[!] Ошибка: {e}")
# 5. Принудительное скачивание моделей
elif choice == '5':
engine.download_all_models()
# 6. Настройки
elif choice == '6':
output_device = select_audio_device() output_device = select_audio_device()
if output_device: if output_device:
print(f"Установлено устройство вывода: {sd.query_devices(output_device)['name']}") print(f"[+] Установлено устройство вывода: {sd.query_devices(output_device)['name']}")
else: else:
print("Используется системное устройство по умолчанию.") print("[i] Используется системное устройство по умолчанию.")
elif choice == '0': elif choice == '0':
print("Выход...") print("Выход...")

View File

@@ -7,3 +7,5 @@ huggingface_hub
transformers transformers
accelerate accelerate
qwen-tts qwen-tts
bitsandbytes
hf_xet

View File

@@ -1,21 +1,20 @@
import torch import torch
import soundfile as sf import soundfile as sf
import yaml import yaml
import os import os
import time import time
import datetime import datetime
import numpy as np import numpy as np
import gc
from pathlib import Path from pathlib import Path
from typing import Optional, List, Dict, Tuple from typing import Optional, List, Dict, Tuple
from huggingface_hub import snapshot_download from huggingface_hub import snapshot_download
# --- # ---
# Блок импорта модели. # Блок импорта модели.
# Если у вас установлен отдельный пакет qwen-tts:
try: try:
from qwen_tts import Qwen3TTSModel from qwen_tts import Qwen3TTSModel
except ImportError: except ImportError:
# Заглушка для тестирования без реальной модели (эмуляция)
print("WARNING: qwen_tts not found. Using Mock Model for testing.") print("WARNING: qwen_tts not found. Using Mock Model for testing.")
class Qwen3TTSModel: class Qwen3TTSModel:
@staticmethod @staticmethod
@@ -28,7 +27,6 @@ except ImportError:
def generate_voice_clone(self, text, **kwargs): def generate_voice_clone(self, text, **kwargs):
print(f"[Mock] Generating voice clone for: {text[:30]}...") print(f"[Mock] Generating voice clone for: {text[:30]}...")
# Возвращаем пустой массив нужной размерности
sr = 24000 sr = 24000
duration = len(text) * 0.1 duration = len(text) * 0.1
return np.random.rand(1, int(sr * duration)).astype(np.float32), sr return np.random.rand(1, int(sr * duration)).astype(np.float32), sr
@@ -38,6 +36,9 @@ except ImportError:
def generate_voice_design(self, text, **kwargs): def generate_voice_design(self, text, **kwargs):
return self.generate_voice_clone(text, **kwargs) return self.generate_voice_clone(text, **kwargs)
def get_supported_speakers(self):
return ["Chelsie", "Dylan", "Eric", "Serena", "Vivian", "Aiden", "Ryan"]
# --- # ---
class TTSEngine: class TTSEngine:
@@ -46,10 +47,17 @@ class TTSEngine:
self.config = yaml.safe_load(f) self.config = yaml.safe_load(f)
self.models = {} self.models = {}
self.current_model_type = None
try: try:
self.dtype = getattr(torch, self.config['generation']['dtype']) self.dtype = getattr(torch, self.config['generation']['dtype'])
except AttributeError: except AttributeError:
self.dtype = torch.float32 self.dtype = torch.float16 # По умолчанию FP16
if torch.cuda.is_available():
gpu_mem = torch.cuda.get_device_properties(0).total_memory / (1024**3)
print(f"📊 GPU: {torch.cuda.get_device_name(0)}")
print(f"📊 GPU Memory: {gpu_mem:.1f} GB")
# Инициализация папок # Инициализация папок
Path(self.config['storage']['model_path']).mkdir(parents=True, exist_ok=True) Path(self.config['storage']['model_path']).mkdir(parents=True, exist_ok=True)
@@ -57,52 +65,91 @@ class TTSEngine:
Path(self.config['storage']['output_dir']).mkdir(parents=True, exist_ok=True) Path(self.config['storage']['output_dir']).mkdir(parents=True, exist_ok=True)
def _resolve_model(self, model_key: str) -> str: def _resolve_model(self, model_key: str) -> str:
""" """Умная загрузка моделей"""
Умная загрузка моделей:
1. Абсолютный путь -> использовать его.
2. Локальный путь внутри model_path -> использовать.
3. Скачать с HF в model_path.
"""
model_cfg_value = self.config['models'][model_key] model_cfg_value = self.config['models'][model_key]
base_model_path = self.config['storage']['model_path'] base_model_path = self.config['storage']['model_path']
# 1. Если это абсолютный путь или файл уже существует по этому пути
if os.path.isabs(model_cfg_value) or os.path.exists(model_cfg_value): if os.path.isabs(model_cfg_value) or os.path.exists(model_cfg_value):
print(f"📂 Model [{model_key}]: Using direct path {model_cfg_value}")
return model_cfg_value return model_cfg_value
# 2. Формируем путь внутри хранилища
# Используем имя репозития как имя папки (замена / на _ если нужно, или сохранение структуры)
folder_name = model_cfg_value.split('/')[-1] folder_name = model_cfg_value.split('/')[-1]
local_path = os.path.join(base_model_path, folder_name) local_path = os.path.join(base_model_path, folder_name)
if os.path.exists(local_path) and os.listdir(local_path): if os.path.exists(local_path) and os.listdir(local_path):
print(f"📂 Model [{model_key}]: Found locally at {local_path}")
return local_path return local_path
# 3. Скачивание с Hugging Face print(f"⬇️ Downloading {model_key}...")
print(f"⬇️ Model [{model_key}]: Not found. Downloading from HF to {local_path}...")
try: try:
snapshot_download( snapshot_download(repo_id=model_cfg_value, local_dir=local_path, local_dir_use_symlinks=False)
repo_id=model_cfg_value,
local_dir=local_path,
local_dir_use_symlinks=False
)
print(f"✅ Model [{model_key}]: Downloaded.")
return local_path return local_path
except Exception as e: except Exception as e:
print(f"❌ Error downloading model {model_cfg_value}: {e}") raise RuntimeError(f"Failed to load model {model_key}: {e}")
raise RuntimeError(f"Failed to load model {model_key}")
def _unload_other_models(self, keep_model_type: str):
"""Выгружает все модели кроме указанной"""
for mtype in list(self.models.keys()):
if mtype != keep_model_type and mtype in self.models:
print(f"🗑️ Unloading model [{mtype}] to free memory...")
del self.models[mtype]
gc.collect()
if torch.cuda.is_available():
torch.cuda.empty_cache()
def _get_model(self, model_type: str): def _get_model(self, model_type: str):
if model_type not in self.models: # Если модель уже загружена — возвращаем её
model_path = self._resolve_model(model_type) if model_type in self.models:
print(f"🚀 Loading model [{model_type}] into memory...") return self.models[model_type]
# Выгружаем другие модели чтобы освободить память
self._unload_other_models(model_type)
model_path = self._resolve_model(model_type)
print(f"🚀 Loading model [{model_type}]...")
if torch.cuda.is_available():
torch.cuda.empty_cache()
free_before = (torch.cuda.get_device_properties(0).total_memory - torch.cuda.memory_allocated()) / (1024**3)
print(f"📊 Free GPU memory before load: {free_before:.2f} GB")
try:
# Стратегия 1: Пробуем загрузить всё на GPU в FP16
print(f"⚙️ Trying FP16 on GPU...")
self.models[model_type] = Qwen3TTSModel.from_pretrained( self.models[model_type] = Qwen3TTSModel.from_pretrained(
model_path, model_path,
device_map=self.config['generation']['device'], dtype=torch.float16,
torch_dtype=self.dtype device_map="cuda:0",
low_cpu_mem_usage=True
) )
print(f"✅ Model [{model_type}] loaded on GPU (FP16)")
except RuntimeError as e:
if "out of memory" in str(e).lower():
print(f"⚠️ GPU OOM, trying CPU offloading...")
torch.cuda.empty_cache()
gc.collect()
# Стратегия 2: Используем accelerate с offloading
# Но при этом избегаем bitsandbytes который вызывает pickle ошибку
print(f"⚙️ Using accelerate with CPU offloading (FP16)...")
# Ограничиваем память GPU чтобы force offloading
max_memory = {0: "3GiB", "cpu": "28GiB"} # Оставляем 3GB для одной модели
self.models[model_type] = Qwen3TTSModel.from_pretrained(
model_path,
dtype=torch.float16,
device_map="auto",
max_memory=max_memory,
low_cpu_mem_usage=True
)
print(f"✅ Model [{model_type}] loaded with CPU offloading")
else:
raise e
if torch.cuda.is_available():
allocated = torch.cuda.memory_allocated() / (1024**3)
print(f"📊 GPU memory allocated: {allocated:.2f} GB")
return self.models[model_type] return self.models[model_type]
def get_available_samples(self) -> List[Dict[str, str]]: def get_available_samples(self) -> List[Dict[str, str]]:
@@ -120,15 +167,10 @@ class TTSEngine:
if os.path.exists(prompt_path): if os.path.exists(prompt_path):
with open(prompt_path, 'r', encoding='utf-8') as f: with open(prompt_path, 'r', encoding='utf-8') as f:
prompt = f.read().strip() prompt = f.read().strip()
samples.append({ samples.append({"name": name, "path": full_path, "prompt": prompt})
"name": name,
"path": full_path,
"prompt": prompt
})
return samples return samples
def generate_with_sample(self, text: str, sample_path: str) -> Tuple[np.ndarray, int]: def generate_with_sample(self, text: str, sample_path: str) -> Tuple[np.ndarray, int]:
"""Режим 1: Клонирование по сэмплу"""
model = self._get_model('base') model = self._get_model('base')
audio_file = os.path.join(sample_path, "audio.wav") audio_file = os.path.join(sample_path, "audio.wav")
@@ -139,14 +181,9 @@ class TTSEngine:
with open(prompt_file, 'r', encoding='utf-8') as f: with open(prompt_file, 'r', encoding='utf-8') as f:
ref_text = f.read().strip() ref_text = f.read().strip()
print(f"🎤 Cloning voice from: {sample_path}") print(f"🎤 Cloning voice...")
# Создаем промпт клонирования
prompt = model.create_voice_clone_prompt(
ref_audio=audio_file,
ref_text=ref_text
)
prompt = model.create_voice_clone_prompt(ref_audio=audio_file, ref_text=ref_text)
wavs, sr = model.generate_voice_clone( wavs, sr = model.generate_voice_clone(
text=text, text=text,
language=self.config['generation']['default_language'], language=self.config['generation']['default_language'],
@@ -155,24 +192,21 @@ class TTSEngine:
return wavs, sr return wavs, sr
def generate_with_description(self, text: str, description: str) -> Tuple[np.ndarray, int]: def generate_with_description(self, text: str, description: str) -> Tuple[np.ndarray, int]:
"""Режим 2: Генерация голоса по описанию (Design -> Clone)"""
print(f"🎨 Designing voice: '{description}'") print(f"🎨 Designing voice: '{description}'")
# Шаг А: Генерируем референс через VoiceDesign # Генерируем референс через VoiceDesign
vd_model = self._get_model('voice_design') vd_model = self._get_model('voice_design')
ref_text = text[:100] if len(text) > 100 else text ref_text = text[:100] if len(text) > 100 else text
# Генерируем сэмпл для будущего клонирования
ref_wavs, ref_sr = vd_model.generate_voice_design( ref_wavs, ref_sr = vd_model.generate_voice_design(
text=ref_text, text=ref_text,
language=self.config['generation']['default_language'], language=self.config['generation']['default_language'],
instruct=description instruct=description
) )
# Шаг Б: Клонируем этот сгенерированный голос через Base модель # Переключаемся на Base (VoiceDesign автоматически выгрузится)
base_model = self._get_model('base') base_model = self._get_model('base')
# Передаем tuple (numpy_array, sr) как ref_audio
prompt = base_model.create_voice_clone_prompt( prompt = base_model.create_voice_clone_prompt(
ref_audio=(ref_wavs[0], ref_sr), ref_audio=(ref_wavs[0], ref_sr),
ref_text=ref_text ref_text=ref_text
@@ -185,12 +219,24 @@ class TTSEngine:
) )
return wavs, sr return wavs, sr
def generate_voice_design_only(self, text: str, description: str) -> Tuple[np.ndarray, int]:
"""Режим предпрослушки: только VoiceDesign без клонирования"""
print(f"🎨 VoiceDesign preview: '{description}'")
model = self._get_model('voice_design')
wavs, sr = model.generate_voice_design(
text=text,
language=self.config['generation']['default_language'],
instruct=description
)
return wavs, sr
def generate_standard(self, text: str, speaker: str = None) -> Tuple[np.ndarray, int]: def generate_standard(self, text: str, speaker: str = None) -> Tuple[np.ndarray, int]:
"""Режим 3: Стандартный голос"""
model = self._get_model('custom_voice') model = self._get_model('custom_voice')
speaker = speaker or self.config['generation']['default_speaker'] speaker = speaker or self.config['generation']['default_speaker']
print(f"🗣️ Using built-in speaker: {speaker}") print(f"🗣️ Using speaker: {speaker}")
wavs, sr = model.generate_custom_voice( wavs, sr = model.generate_custom_voice(
text=text, text=text,
language=self.config['generation']['default_language'], language=self.config['generation']['default_language'],
@@ -198,8 +244,25 @@ class TTSEngine:
) )
return wavs, sr return wavs, sr
def download_all_models(self):
print("\n--- Checking models ---")
for key in ['base', 'voice_design', 'custom_voice']:
try:
self._resolve_model(key)
print(f"{key}: OK")
except Exception as e:
print(f"{key}: {e}")
def get_custom_speakers_list(self):
try:
model = self._get_model('custom_voice')
speakers = model.get_supported_speakers()
return list(speakers) if hasattr(speakers, '__iter__') else speakers
except Exception as e:
print(f"Error: {e}")
return ["Chelsie", "Dylan", "Eric", "Serena", "Vivian", "Aiden", "Ryan"]
def save_result(self, text: str, wavs: np.ndarray, sr: int) -> str: def save_result(self, text: str, wavs: np.ndarray, sr: int) -> str:
"""Сохраняет WAV и TXT в папку out"""
out_dir = self.config['storage']['output_dir'] out_dir = self.config['storage']['output_dir']
timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"speech_{timestamp}" filename = f"speech_{timestamp}"
@@ -207,23 +270,17 @@ class TTSEngine:
wav_path = os.path.join(out_dir, f"{filename}.wav") wav_path = os.path.join(out_dir, f"{filename}.wav")
txt_path = os.path.join(out_dir, f"{filename}.txt") txt_path = os.path.join(out_dir, f"{filename}.txt")
# Сохраняем аудио
sf.write(wav_path, wavs[0], sr) sf.write(wav_path, wavs[0], sr)
# Сохраняем текст
with open(txt_path, 'w', encoding='utf-8') as f: with open(txt_path, 'w', encoding='utf-8') as f:
f.write(text) f.write(text)
return wav_path return wav_path
def get_history(self) -> List[Dict[str, str]]: def get_history(self) -> List[Dict[str, str]]:
"""Возвращает список сгенерированных файлов"""
out_dir = self.config['storage']['output_dir'] out_dir = self.config['storage']['output_dir']
history = [] history = []
if not os.path.exists(out_dir): return history if not os.path.exists(out_dir): return history
files = sorted(os.listdir(out_dir), reverse=True) for f in sorted(os.listdir(out_dir), reverse=True):
for f in files:
if f.endswith(".wav"): if f.endswith(".wav"):
base_name = f[:-4] base_name = f[:-4]
txt_path = os.path.join(out_dir, f"{base_name}.txt") txt_path = os.path.join(out_dir, f"{base_name}.txt")
@@ -234,10 +291,5 @@ class TTSEngine:
with open(txt_path, 'r', encoding='utf-8') as file: with open(txt_path, 'r', encoding='utf-8') as file:
text_content = file.read() text_content = file.read()
history.append({ history.append({"filename": f, "wav_path": wav_path, "txt_path": txt_path, "text": text_content})
"filename": f,
"wav_path": wav_path,
"txt_path": txt_path,
"text": text_content
})
return history return history