import socket import json import time import wave import sys import select import os import logging import pyaudio import io from app.core.config import get_settings # Получаем настройки из конфигурации config = get_settings() tts_config = config.tts audio_settings = config.audio_settings # Настройки из конфигурации HOST = tts_config.host PORT = tts_config.port MAX_WAIT = tts_config.timeout PAUSE_DURATION = tts_config.pause_duration POST_STOP_WAIT = tts_config.post_stop_wait SAVE_TTS_FILES = tts_config.save_TTS_files PLAYBACK_DEVICE = audio_settings.playback_device # Директория для сохранения TTS файлов TTS_SAVE_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "recordings", "TTS") if not os.path.exists(TTS_SAVE_DIR): os.makedirs(TTS_SAVE_DIR, exist_ok=True) # Список доступных голосов VOICES = [ "ru_RU-denis-medium", "ru_RU-irina-medium", "ru_RU-dmitri-medium", "ru_RU-ruslan-medium" ] DEFAULT_VOICE = tts_config.voice # Глобальные переменные для аудиопотока PAUDIO = None AUDIO_STREAM = None DEFAULT_AUDIO_PARAMS = {"rate": 22050, "width": 2, "channels": 1} # Используем логгер из основного приложения logger = logging.getLogger('VADApp') def initialize_audio(device_index=PLAYBACK_DEVICE): """Инициализирует аудиоустройство и воспроизводит Hello.wav.""" global PAUDIO, AUDIO_STREAM try: PAUDIO = pyaudio.PyAudio() logger.info("Доступные аудиоустройства:") for i in range(PAUDIO.get_device_count()): device_info = PAUDIO.get_device_info_by_index(i) logger.info("Устройство %d: %s (выходные каналы: %d)", i, device_info['name'], device_info['maxOutputChannels']) AUDIO_STREAM = PAUDIO.open( format=PAUDIO.get_format_from_width(DEFAULT_AUDIO_PARAMS["width"]), channels=DEFAULT_AUDIO_PARAMS["channels"], rate=DEFAULT_AUDIO_PARAMS["rate"], output=True, output_device_index=device_index, frames_per_buffer=4096 ) logger.info("Открыт аудиопоток: частота=%d, ширина=%d, каналы=%d, устройство=%d", DEFAULT_AUDIO_PARAMS["rate"], DEFAULT_AUDIO_PARAMS["width"], DEFAULT_AUDIO_PARAMS["channels"], device_index) # Воспроизведение Hello.wav try: with wave.open("Hello.wav", 'rb') as wf: if (wf.getframerate() != DEFAULT_AUDIO_PARAMS["rate"] or wf.getsampwidth() != DEFAULT_AUDIO_PARAMS["width"] or wf.getnchannels() != DEFAULT_AUDIO_PARAMS["channels"]): logger.warning("Параметры Hello.wav не соответствуют: частота=%d, ширина=%d, каналы=%d", wf.getframerate(), wf.getsampwidth(), wf.getnchannels()) else: logger.info("Воспроизведение Hello.wav") data = wf.readframes(1024) while data: AUDIO_STREAM.write(data) data = wf.readframes(1024) # Ожидание завершения воспроизведения while AUDIO_STREAM.get_write_available() < AUDIO_STREAM._frames_per_buffer: time.sleep(0.01) logger.info("Завершено воспроизведение Hello.wav") except FileNotFoundError: logger.error("Файл Hello.wav не найден") except Exception as e: logger.error("Ошибка воспроизведения Hello.wav: %s", e) except Exception as e: logger.error("Не удалось инициализировать аудио: %s", e) if AUDIO_STREAM: AUDIO_STREAM.stop_stream() AUDIO_STREAM.close() AUDIO_STREAM = None if PAUDIO: PAUDIO.terminate() PAUDIO = None def close_audio(): """Закрывает аудиопоток и PyAudio.""" global PAUDIO, AUDIO_STREAM if AUDIO_STREAM: try: AUDIO_STREAM.stop_stream() AUDIO_STREAM.close() logger.debug("Аудиопоток закрыт") except Exception as e: logger.error("Ошибка закрытия аудиопотока: %s", e) AUDIO_STREAM = None if PAUDIO: try: PAUDIO.terminate() logger.debug("PyAudio завершен") except Exception as e: logger.error("Ошибка завершения PyAudio: %s", e) PAUDIO = None def play_audio_stream(stream, audio_data): """Воспроизводит аудиоданные через существующий аудиопоток. Args: stream: Открытый аудиопоток PyAudio. audio_data: Бинарные аудиоданные для воспроизведения. """ if not audio_data: logger.warning("Пустые аудиоданные, пропуск воспроизведения") return try: stream.write(audio_data) logger.debug("Воспроизведен аудиофрагмент: %d байт", len(audio_data)) except Exception as e: logger.error("Ошибка воспроизведения: %s", e) def send_msg(sock, typ, data_obj=None, payload=b""): """Отправляет сообщение в формате Wyoming Protocol.""" try: data_bytes = json.dumps(data_obj, ensure_ascii=False).encode('utf-8') if data_obj else b'' header = {"type": typ, "data_length": len(data_bytes), "payload_length": len(payload)} sock.sendall(json.dumps(header, separators=(',', ':')).encode('utf-8') + b'\n') if data_bytes: sock.sendall(data_bytes) if payload: sock.sendall(payload) logger.debug("Отправлено: %s (длина_данных=%d, длина_полезной_нагрузки=%d)", typ, len(data_bytes), len(payload)) except socket.error as e: logger.error("Ошибка отправки сообщения: %s", e) raise def recv_into_buffer(sock, buf, timeout): """Читает данные из сокета в буфер.""" r, _, _ = select.select([sock], [], [], timeout) if not r: return 0 # Нет данных, но не ошибка data = sock.recv(8192) if not data: return -1 # EOF buf.extend(data) logger.debug("Получено %d байт (размер буфера: %d)", len(data), len(buf)) return len(data) def pop_line_from_buffer(buf): """Извлекает JSON-линию из буфера.""" idx = buf.find(b'\n') if idx == -1: return None line = bytes(buf[:idx]) del buf[:idx+1] try: return line.decode('utf-8') except UnicodeDecodeError as e: logger.warning("Ошибка декодирования UTF-8: %s", e) return None def read_exact_from_buffer(sock, buf, n, deadline): """Читает ровно n байт из буфера.""" while len(buf) < n: timeout = max(0.0, deadline - time.time()) if timeout <= 0: raise TimeoutError(f"Таймаут чтения (требуется %d, получено %d)", n, len(buf)) recv_len = recv_into_buffer(sock, buf, timeout) if recv_len == -1: raise ConnectionError("Соединение закрыто сервером") data = bytes(buf[:n]) del buf[:n] return data def add_pause(audio_buf, rate, width=2, duration=PAUSE_DURATION): """Добавляет паузу (нулевые байты) в аудио буфер.""" samples = int(rate * duration) bytes_per_sample = width pause_bytes = b'\x00' * (samples * bytes_per_sample) audio_buf.extend(pause_bytes) logger.debug("Добавлена пауза: %d байт (%s с при %d Гц, ширина=%d)", len(pause_bytes), duration, rate, width) def get_next_filename(prefix="tts_"): """Получает уникальное имя файла для сохранения TTS вывода.""" os.makedirs(TTS_SAVE_DIR, exist_ok=True) i = 0 while True: filename = os.path.join(TTS_SAVE_DIR, f"{prefix}{i:03d}.wav") if not os.path.exists(filename): return filename i += 1 def synthesize_stream(text, voice_name=None, out_filename="out.wav"): """Отправляет текст на сервер Piper TTS, сохраняет и воспроизводит аудио.""" sock = socket.create_connection((HOST, PORT), timeout=10) sock.settimeout(None) buf = bytearray() try: # Отправляем потоковый запрос send_msg(sock, "synthesize-start", {"voice": {"name": voice_name}} if voice_name else {}) send_msg(sock, "synthesize-chunk", {"text": text}) send_msg(sock, "synthesize", {"text": text}) # Для обратной совместимости send_msg(sock, "synthesize-stop", None) audio_buf = bytearray() meta = {"rate": 22050, "width": 2, "channels": 1} deadline = time.time() + MAX_WAIT chunk_count = 0 last_activity = time.time() while time.time() < deadline: line = pop_line_from_buffer(buf) if line is None: recv_len = recv_into_buffer(sock, buf, 0.1) if recv_len == 0: if time.time() - last_activity > POST_STOP_WAIT: logger.debug("Больше нет данных, завершение") break continue elif recv_len == -1: logger.debug("Соединение закрыто сервером") break last_activity = time.time() continue last_activity = time.time() logger.debug("Заголовок сервера: %s", line) try: hdr = json.loads(line) except json.JSONDecodeError as e: logger.warning("Ошибка разбора JSON: %s", e) continue typ = hdr.get("type") dl = int(hdr.get("data_length", 0)) pl = int(hdr.get("payload_length", 0)) data_json = None if dl: try: data_bytes = read_exact_from_buffer(sock, buf, dl, deadline) data_json = json.loads(data_bytes.decode('utf-8')) logger.debug("Данные: %s", data_json) except Exception as e: logger.warning("Ошибка чтения данных: %s", e) data_json = None if typ == "audio-start" and data_json: meta["rate"] = int(data_json.get("rate", meta["rate"])) meta["width"] = int(data_json.get("width", meta["width"])) meta["channels"] = int(data_json.get("channels", meta["channels"])) logger.info("Получены аудио метаданные: частота=%d, ширина=%d, каналы=%d", meta["rate"], meta["width"], meta["channels"]) # Проверка совместимости параметров с открытым потоком if AUDIO_STREAM: if (meta["rate"] != DEFAULT_AUDIO_PARAMS["rate"] or meta["width"] != DEFAULT_AUDIO_PARAMS["width"] or meta["channels"] != DEFAULT_AUDIO_PARAMS["channels"]): logger.warning("Несоответствие аудио параметров: ожидалось частота=%d, ширина=%d, каналы=%d; получено частота=%d, ширина=%d, каналы=%d", DEFAULT_AUDIO_PARAMS["rate"], DEFAULT_AUDIO_PARAMS["width"], DEFAULT_AUDIO_PARAMS["channels"], meta["rate"], meta["width"], meta["channels"]) return None # Прерываем, так как поток несовместим if pl: try: chunk = read_exact_from_buffer(sock, buf, pl, deadline) audio_buf.extend(chunk) chunk_count += 1 logger.debug("Получен аудио фрагмент %d (%d байт, всего %d)", chunk_count, len(chunk), len(audio_buf)) if AUDIO_STREAM: play_audio_stream(AUDIO_STREAM, chunk) except Exception as e: logger.warning("Ошибка чтения полезной нагрузки: %s", e) if typ == "audio-stop": logger.debug("Audio-stop: добавление паузы между предложениями") add_pause(audio_buf, meta["rate"], meta["width"]) post_deadline = time.time() + POST_STOP_WAIT while time.time() < post_deadline: recv_len = recv_into_buffer(sock, buf, 0.1) if recv_len == 0: continue elif recv_len == -1: break break last_activity = time.time() elif typ == "error": logger.error("Ошибка сервера: %s", data_json) break send_msg(sock, "synthesize-stopped", None) # Ожидание завершения воспроизведения if AUDIO_STREAM: try: while AUDIO_STREAM.get_write_available() < AUDIO_STREAM._frames_per_buffer: time.sleep(0.01) logger.debug("Буфер воспроизведения очищен") except Exception as e: logger.error("Ошибка очистки буфера воспроизведения: %s", e) if audio_buf and SAVE_TTS_FILES: try: with wave.open(out_filename, "wb") as wf: wf.setnchannels(int(meta["channels"])) wf.setsampwidth(int(meta["width"])) wf.setframerate(int(meta["rate"])) wf.writeframes(bytes(audio_buf)) logger.info("Сохранено: %s (всего аудио байт: %d, фрагментов: %d)", out_filename, len(audio_buf), chunk_count) return out_filename except Exception as e: logger.error("Не удалось сохранить аудио: %s", e) return None else: logger.debug("Аудио не сохранено (SAVE_TTS_FILES=%s)", SAVE_TTS_FILES) return None except Exception as e: logger.error("Error: %s", e) return None finally: sock.close() def synthesize_text(text, voice_name=None, out_filename=None): """ Синтезирует речь из текста, воспроизводит и возвращает путь к аудиофайлу. Args: text (str): Текст для синтеза voice_name (str, optional): Имя голоса. По умолчанию используется голос из конфигурации. out_filename (str, optional): Имя выходного файла. По умолчанию генерируется автоматически. Returns: str: Путь к созданному аудиофайлу или None в случае ошибки """ if not voice_name: voice_name = DEFAULT_VOICE if not out_filename: out_filename = get_next_filename() logger.info(f"Синтезирую текст: '{text}' с голосом {voice_name} в файл {out_filename}") return synthesize_stream(text, voice_name=voice_name, out_filename=out_filename)