362 lines
16 KiB
Python
362 lines
16 KiB
Python
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) |