Initial commit

This commit is contained in:
2025-12-25 12:01:35 +03:00
commit c574533f63
85 changed files with 6027 additions and 0 deletions
+362
View File
@@ -0,0 +1,362 @@
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)