Initial commit: Qwen3-TTS Console Assistant implementation
This commit is contained in:
266
main.py
Normal file
266
main.py
Normal file
@@ -0,0 +1,266 @@
|
||||
import sys
|
||||
import os
|
||||
import time
|
||||
import sounddevice as sd
|
||||
import numpy as np
|
||||
import yaml
|
||||
|
||||
# Добавляем текущую директорию в путь для импорта
|
||||
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
|
||||
from tts_engine import TTSEngine
|
||||
|
||||
# --- Функции записи и воспроизведения ---
|
||||
|
||||
def get_rms(data):
|
||||
"""Вычисление среднеквадратичного уровня громкости"""
|
||||
return np.sqrt(np.mean(data**2))
|
||||
|
||||
def record_sample_interactive(engine):
|
||||
cfg = engine.config['recording']
|
||||
sr = cfg['sample_rate']
|
||||
channels = cfg['channels']
|
||||
|
||||
print("\n--- 🎙 Запись нового сэмпла ---")
|
||||
name = input("Имя сэмпла (латиница, без пробелов, Enter для auto): ").strip()
|
||||
if not name: name = f"speaker_{int(time.time())}"
|
||||
|
||||
# Очистка имени от недопустимых символов
|
||||
name = "".join(c for c in name if c.isalnum() or c in ('_', '-')).strip()
|
||||
|
||||
text_prompt = input("Фраза для чтения (будет сохранена как текст сэмпла): ").strip()
|
||||
if not text_prompt:
|
||||
print("❌ Текст сэмпла обязателен для качественного клонирования.")
|
||||
return None
|
||||
|
||||
save_dir = os.path.join(engine.config['storage']['sample_dir'], name)
|
||||
if os.path.exists(save_dir):
|
||||
print(f"⚠️ Сэмпл с именем '{name}' уже существует. Перезапись.")
|
||||
|
||||
print("\n🎤 НАЧАЛО ЗАПИСИ. Говорите сейчас!")
|
||||
print(f"⏳ Запись остановится автоматически после паузы в {cfg['silence_duration']} сек.")
|
||||
|
||||
frames = []
|
||||
silence_counter = 0
|
||||
start_time = time.time()
|
||||
|
||||
try:
|
||||
with sd.InputStream(samplerate=sr, channels=channels, dtype='float32') as stream:
|
||||
while True:
|
||||
data, overflowed = stream.read(1024)
|
||||
if overflowed:
|
||||
print("⚠️ Buffer overflow")
|
||||
|
||||
frames.append(data.copy())
|
||||
|
||||
# Логика VAD (Voice Activity Detection)
|
||||
rms = get_rms(data)
|
||||
|
||||
# Если звук тихий
|
||||
if rms < cfg['silence_threshold']:
|
||||
silence_counter += len(data) / sr
|
||||
else:
|
||||
silence_counter = 0 # Сброс при наличии голоса
|
||||
|
||||
# Авто-стоп
|
||||
current_duration = time.time() - start_time
|
||||
if silence_counter >= cfg['silence_duration'] and current_duration > cfg['min_duration']:
|
||||
print("\n🛑 Тишина обнаружена. Остановка записи.")
|
||||
break
|
||||
|
||||
except KeyboardInterrupt:
|
||||
print("\nЗапись прервана вручную.")
|
||||
|
||||
# Обработка и сохранение
|
||||
recording = np.concatenate(frames, axis=0)
|
||||
|
||||
# Создаем папку и сохраняем
|
||||
os.makedirs(save_dir, exist_ok=True)
|
||||
|
||||
audio_path = os.path.join(save_dir, "audio.wav")
|
||||
sf.write(audio_path, recording, sr)
|
||||
|
||||
prompt_path = os.path.join(save_dir, "prompt.txt")
|
||||
with open(prompt_path, 'w', encoding='utf-8') as f:
|
||||
f.write(text_prompt)
|
||||
|
||||
print(f"✅ Сэмпл сохранен: {save_dir}")
|
||||
return save_dir
|
||||
|
||||
def select_sample_ui(engine):
|
||||
samples = engine.get_available_samples()
|
||||
if not samples:
|
||||
print("Нет сохраненных сэмплов. Сначала запишите один.")
|
||||
return None
|
||||
|
||||
print("\n--- 📂 Выберите сэмпл ---")
|
||||
for i, s in enumerate(samples):
|
||||
txt_preview = s['prompt'][:40] + "..." if len(s['prompt']) > 40 else s['prompt']
|
||||
print(f"[{i+1}] {s['name']} : \"{txt_preview}\"")
|
||||
|
||||
try:
|
||||
idx = int(input("Номер: ")) - 1
|
||||
if 0 <= idx < len(samples):
|
||||
return samples[idx]['path']
|
||||
except ValueError:
|
||||
pass
|
||||
print("Неверный выбор.")
|
||||
return None
|
||||
|
||||
def select_audio_device():
|
||||
print("\n--- 🔊 Выбор устройства вывода ---")
|
||||
devices = sd.query_devices()
|
||||
output_devices = []
|
||||
|
||||
for i, dev in enumerate(devices):
|
||||
if dev['max_output_channels'] > 0:
|
||||
output_devices.append((i, dev['name']))
|
||||
|
||||
for idx, name in output_devices:
|
||||
default_marker = " (DEFAULT)" if idx == sd.default.device[1] else ""
|
||||
print(f"[{idx}] {name}{default_marker}")
|
||||
|
||||
print("\nВведите ID устройства или Enter для использования по умолчанию.")
|
||||
choice = input(">> ").strip()
|
||||
|
||||
if choice.isdigit():
|
||||
return int(choice)
|
||||
return None
|
||||
|
||||
def play_audio(filepath, device_id=None):
|
||||
try:
|
||||
data, sr = sf.read(filepath, dtype='float32')
|
||||
sd.play(data, sr, device=device_id)
|
||||
print(f"▶️ Воспроизведение: {os.path.basename(filepath)}")
|
||||
# sd.wait() # Раскомментировать, если нужно блокировать консоль до конца воспроизведения
|
||||
except Exception as e:
|
||||
print(f"❌ Ошибка воспроизведения: {e}")
|
||||
|
||||
# --- Главное меню ---
|
||||
|
||||
def main():
|
||||
print("Инициализация движка...")
|
||||
try:
|
||||
engine = TTSEngine("config.yaml")
|
||||
except Exception as e:
|
||||
print(f"Критическая ошибка инициализации: {e}")
|
||||
return
|
||||
|
||||
current_sample_path = None
|
||||
output_device = None
|
||||
|
||||
while True:
|
||||
print("\n" + "="*40)
|
||||
print(" QWEN3-TTS CONSOLE (Full Version)")
|
||||
print("="*40)
|
||||
print("1. 🎙 Управление сэмплами")
|
||||
print("2. 🗣 Синтез речи")
|
||||
print("3. 📁 История (Прослушивание/Чтение)")
|
||||
print("4. ⚙️ Настройки")
|
||||
print("0. Выход")
|
||||
|
||||
choice = input(">> ").strip()
|
||||
|
||||
if choice == '1':
|
||||
print("\n--- Управление сэмплами ---")
|
||||
print("1. Записать новый сэмпл")
|
||||
print("2. Вырать существующий")
|
||||
print("3. Сбросить текущий выбор")
|
||||
sub = input(">> ").strip()
|
||||
|
||||
if sub == '1':
|
||||
path = record_sample_interactive(engine)
|
||||
if path: current_sample_path = path
|
||||
elif sub == '2':
|
||||
path = select_sample_ui(engine)
|
||||
if path:
|
||||
current_sample_path = path
|
||||
print(f"✅ Выбран сэмпл: {path}")
|
||||
elif sub == '3':
|
||||
current_sample_path = None
|
||||
print("Сброшено. Будет использован голос по умолчанию.")
|
||||
|
||||
elif choice == '2':
|
||||
text = input("\nВведите текст: ").strip()
|
||||
if not text: continue
|
||||
|
||||
print("\nРежим синтеза:")
|
||||
print(f"1. Клонирование (Сэмпл: {'Да' if current_sample_path else 'Нет'})")
|
||||
print("2. Описание голоса (Voice Design)")
|
||||
print("3. Стандартный голос")
|
||||
mode = input(">> ").strip()
|
||||
|
||||
try:
|
||||
start_t = time.time()
|
||||
wavs, sr = None, None
|
||||
|
||||
if mode == '1':
|
||||
if not current_sample_path:
|
||||
print("❌ Ошибка: Сэмпл не выбран!")
|
||||
continue
|
||||
wavs, sr = engine.generate_with_sample(text, current_sample_path)
|
||||
|
||||
elif mode == '2':
|
||||
desc = input("Описание голоса (напр. 'Добрый женский голос'): ").strip()
|
||||
if not desc: desc = "Neutral voice"
|
||||
wavs, sr = engine.generate_with_description(text, desc)
|
||||
|
||||
elif mode == '3':
|
||||
wavs, sr = engine.generate_standard(text)
|
||||
|
||||
if wavs is not None:
|
||||
# Сохранение
|
||||
saved_path = engine.save_result(text, wavs, sr)
|
||||
elapsed = time.time() - start_t
|
||||
print(f"\n✅ Успешно за {elapsed:.2f} сек.")
|
||||
print(f"📁 Файл: {saved_path}")
|
||||
|
||||
# Вопрос о воспроизведении
|
||||
ans = input("Воспроизвести сейчас? (y/n): ").strip().lower()
|
||||
if ans == 'y':
|
||||
play_audio(saved_path, output_device)
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Ошибка генерации: {e}")
|
||||
|
||||
elif choice == '3':
|
||||
history = engine.get_history()
|
||||
if not history:
|
||||
print("\n📂 Папка out пуста.")
|
||||
continue
|
||||
|
||||
print(f"\n--- 📂 История ({len(history)} файлов) ---")
|
||||
# Выводим последние 10
|
||||
for i, item in enumerate(history[:10]):
|
||||
print(f"[{i+1}] {item['filename']}")
|
||||
print(f" Текст: {item['text'][:50]}...")
|
||||
|
||||
if len(history) > 10:
|
||||
print("... (показаны последние 10)")
|
||||
|
||||
print("\nВведите номер для прослушивания/чтения или Enter.")
|
||||
sel = input(">> ").strip()
|
||||
if sel.isdigit():
|
||||
idx = int(sel) - 1
|
||||
# Ищем в полном списке, но отображаем 10
|
||||
# Для простоты берем индекс из отображаемого списка (0-9)
|
||||
# Но правильнее из полного списка history
|
||||
if 0 <= idx < len(history):
|
||||
item = history[idx]
|
||||
print(f"\n▶️ Файл: {item['filename']}")
|
||||
print(f"📝 Текст:\n{item['text']}")
|
||||
print("-" * 30)
|
||||
play_audio(item['wav_path'], output_device)
|
||||
|
||||
elif choice == '4':
|
||||
output_device = select_audio_device()
|
||||
if output_device:
|
||||
print(f"Установлено устройство вывода: {sd.query_devices(output_device)['name']}")
|
||||
else:
|
||||
print("Используется системное устройство по умолчанию.")
|
||||
|
||||
elif choice == '0':
|
||||
print("Выход...")
|
||||
break
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user