llm-agent-backend/app/voice_service.py

223 lines
9.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import os
import json
import threading
import datetime
import time
import numpy as np
from vosk import Model, KaldiRecognizer
import sounddevice as sd
import uuid
from collections import deque
VOICE_COMMANDS_RESPONSE_TO_STORE = 3
class VoiceService:
def __init__(self, model_path, samplerate=48000):
self.model_path = model_path
self.samplerate = samplerate
self.model = Model(model_path)
self.is_running = False
self.current_file = None
self.history = []
self.history_lock = threading.Lock()
self.last_processed_timestamp = 0.0
self.responses = {
"regular": deque(maxlen=VOICE_COMMANDS_RESPONSE_TO_STORE),
"commands": deque(maxlen=VOICE_COMMANDS_RESPONSE_TO_STORE)
}
self.lock = threading.Lock()
self._stop_event = threading.Event()
self.abs_logs_path = None
def update_logs_config(self, abs_path):
"""Обновление путей из настроек Obsidian."""
self.abs_logs_path = abs_path
def _load_history_from_file(self):
"""Парсит существующий файл лога в коллекцию self.history."""
if not self.current_file or not os.path.exists(self.current_file):
return
import re
# Регулярка для парсинга строки типа: [10:20:30.123] GM: текст реплики
pattern = re.compile(r"\[(\d{2}:\d{2}:\d{2}(?:\.\d+)?)\]\s+(GM|PCs):\s+(.*)")
new_history = []
try:
with open(self.current_file, "r", encoding="utf-8") as f:
for line in f:
match = pattern.match(line.strip())
if match:
timestamp, role, text = match.groups()
new_history.append({
'role': role,
'text': text,
'time': timestamp
})
with self.history_lock:
self.history = new_history
print(f"Loaded {len(self.history)} events from existing log.")
except Exception as e:
print(f"Error loading history from file: {e}")
def start_session(self, continue_last=False):
if self.is_running: return
log_dir = self.abs_logs_path if self.abs_logs_path else "audio-logs"
if not os.path.exists(log_dir): os.makedirs(log_dir)
if continue_last:
files = sorted([f for f in os.listdir(log_dir) if f.endswith(".md")])
if files:
self.current_file = os.path.join(log_dir, files[-1])
# Загружаем старые реплики в память
self._load_history_from_file()
else:
self.current_file = self._create_new_file(log_dir)
else:
self.current_file = self._create_new_file(log_dir)
with self.history_lock:
self.history = [] # Сбрасываем историю для новой сессии
self.is_running = True
self._stop_event.clear()
threading.Thread(target=self._run_transcription, daemon=True).start()
def _create_new_file(self, log_dir):
name = datetime.datetime.now().strftime("%Y-%m-%d_%H-%M-%S.md")
path = os.path.join(log_dir, name)
with open(path, "w", encoding="utf-8") as f:
f.write(f"--- Session Start: {name} ---\n\n")
return path
def stop_session(self):
self.is_running = False
self._stop_event.set()
def _run_transcription(self):
# 1. Поиск устройств (строго перед запуском потока)
id_gm, id_pcs = None, None
devices = sd.query_devices()
hostapis = sd.query_hostapis()
mme_api_index = next((i for i, h in enumerate(hostapis) if h['name'] == 'MME'), 0)
for i, d in enumerate(devices):
# Проверяем, что устройство относится к MME и содержит нужное имя
if d['hostapi'] == mme_api_index:
if "Voicemeeter Out B1" in d['name']: id_gm = i
if "Voicemeeter Out B2" in d['name']: id_pcs = i
if id_gm is None or id_pcs is None:
# Если MME не нашел, пробуем любой
for i, d in enumerate(devices):
if "Voicemeeter Out B1" in d['name']: id_gm = i
if "Voicemeeter Out B2" in d['name']: id_pcs = i
# 2. Создаем рекогнайзеры ВНУТРИ потока
rec_gm = KaldiRecognizer(self.model, self.samplerate)
rec_pcs = KaldiRecognizer(self.model, self.samplerate)
# 3. Фабрика колбэков
def create_callback(rec, role):
def callback_func(indata, frames, time_info, status):
if status:
print(f"Audio Status ({role}): {status}")
if np.max(np.abs(indata)) < 0.001:
return
if rec.AcceptWaveform(bytes(indata)):
res = json.loads(rec.Result())
text = res.get('text', '')
if text:
now = datetime.datetime.now().strftime("%H:%M:%S")
# 1. Запись в файл (как и было)
entry = f"[{now}] {role}: {text}\n"
try:
with self.lock:
with open(self.current_file, "a", encoding="utf-8") as f:
f.write(entry)
except Exception as e:
print(f"File write error: {e}")
# 2. ЗАПИСЬ В ПАМЯТЬ
with self.history_lock:
self.history.append({
'role': role,
'text': text,
'time': now
})
return callback_func
# 4. Запуск стримов
try:
# Используем те же параметры, что в рабочем демо
with sd.RawInputStream(samplerate=self.samplerate,
blocksize=8000,
device=id_gm,
dtype='int16',
channels=1,
callback=create_callback(rec_gm, "GM")), \
sd.RawInputStream(samplerate=self.samplerate,
blocksize=8000,
device=id_pcs,
dtype='int16',
channels=1,
callback=create_callback(rec_pcs, "PCs")):
print(f"✅🎤 Транскрибация запущена (IDs: {id_gm}, {id_pcs})")
while not self._stop_event.is_set():
self._stop_event.wait(1.0) # Экономнее чем sleep
except Exception as e:
print(f"❌🎤 Критическая ошибка транскрибации: {e}")
finally:
self.is_running = False
print("🏁🎤 Сессия транскрибации завершена")
def get_context_by_limit(self, limit, separator = '', order='normal'):
"""
Собирает реплики с конца, пока не превысит лимит символов.
Возвращает склеенную строку всех ролей.
"""
with self.history_lock:
if not self.history:
return ""
selected_phrases = []
current_length = 0
# Идем с конца списка
for item in reversed(self.history):
phrase = f"{item['role']}: {item['text']}\n"
selected_phrases.append(phrase)
current_length += len(phrase)
# Если превысили лимит, останавливаемся
if current_length >= limit:
break
if order == 'normal':
# Возвращаем в хронологическом порядке (как в чате)
return separator.join(reversed(selected_phrases))
else:
# Возвращаем как есть (новые сверху)
return separator.join(selected_phrases)
def get_new_gm_text(self, start_index):
"""Возвращает склеенный текст GM и новый индекс последнего элемента."""
with self.history_lock:
current_max_index = len(self.history) - 1
if start_index >= current_max_index:
return "", start_index
# Собираем только реплики GM, начиная с start_index + 1
new_phrases = [
item['text']
for item in self.history[start_index + 1:]
if item['role'] == "GM"
]
return " ".join(new_phrases), current_max_index