Initial commit: полный проект my_speaker
This commit is contained in:
+362
@@ -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)
|
||||
Reference in New Issue
Block a user