384 lines
17 KiB
Python
384 lines
17 KiB
Python
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
|
||
import io
|
||
import base64
|
||
|
||
# Для генерации графиков на сервере без UI!
|
||
import matplotlib
|
||
matplotlib.use('Agg') # Для работы без GUI
|
||
import matplotlib.pyplot as plt
|
||
import pandas as pd
|
||
|
||
VOICE_COMMANDS_RESPONSE_TO_STORE = 3
|
||
|
||
class VoiceService:
|
||
def __init__(self, samplerate=48000):
|
||
self.model_path = None
|
||
self.samplerate = samplerate
|
||
self.model = None
|
||
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.metrics_data = {
|
||
"graphs": [], # Массив Base64 строчек с HTML-декором
|
||
"text": "Анализ пока не выполнялся или недостаточно данных..."
|
||
}
|
||
self.lock = threading.Lock()
|
||
self._stop_event = threading.Event()
|
||
self.abs_logs_path = None
|
||
|
||
# Переменная для отслеживания состояния загрузки, чтобы не грузить модель дважды
|
||
self.is_loading_model = False
|
||
|
||
def load_model(self, path):
|
||
"""Загружает модель Vosk, если она еще не загружена или путь изменился."""
|
||
if not path or not path.strip():
|
||
return
|
||
|
||
# Если путь не изменился и модель уже есть, ничего не делаем
|
||
if self.model_path == path and self.model is not None:
|
||
return
|
||
|
||
if self.is_loading_model:
|
||
return
|
||
|
||
try:
|
||
self.is_loading_model = True
|
||
if not os.path.exists(path):
|
||
print(f"❌ Vosk: Папка с моделью не найдена по пути: {path}")
|
||
return
|
||
|
||
print(f"⏳ Vosk: Начинается фоновая загрузка модели из {path}...")
|
||
# Принудительно останавливаем сессию, если подменяем модель на ходу
|
||
if self.is_running:
|
||
self.stop_session()
|
||
|
||
from vosk import Model
|
||
self.model = Model(path)
|
||
self.model_path = path
|
||
print("✅ Vosk: Модель успешно загружена и готова к работе.")
|
||
except Exception as e:
|
||
print(f"❌ Vosk: Ошибка при загрузке модели: {e}")
|
||
self.model = None
|
||
self.model_path = None
|
||
finally:
|
||
self.is_loading_model = False
|
||
|
||
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 -> 10:20:35.456] GM: текст
|
||
pattern = re.compile(r"\[(\d{2}:\d{2}:\d{2}(?:\.\d+)?)\s*(?:->|-->)\s*(\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:
|
||
start_time, end_time, role, text = match.groups()
|
||
new_history.append({
|
||
'role': role,
|
||
'text': text,
|
||
'startTime': start_time,
|
||
'endTime': end_time
|
||
})
|
||
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.model is None:
|
||
print("⚠️ Vosk: Попытка старта транскрибации, но модель еще не загружена или путь не указан в настройках Obsidian!")
|
||
return
|
||
|
||
if self.is_running: return
|
||
|
||
# Очистка вкладок при новом старте
|
||
if not continue_last:
|
||
self.responses["regular"].clear()
|
||
self.responses["commands"].clear()
|
||
self.metrics_data = {"graphs": [], "text": "Ожидание достаточного количества данных..."}
|
||
|
||
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)
|
||
|
||
rec_states = {
|
||
"GM": {"start": datetime.datetime.now()},
|
||
"PCs": {"start": datetime.datetime.now()}
|
||
}
|
||
|
||
# 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:
|
||
end_dt = datetime.datetime.now()
|
||
start_dt = rec_states[role]["start"]
|
||
|
||
# Форматируем с миллисекундами
|
||
start_str = start_dt.strftime("%H:%M:%S.%f")[:-3]
|
||
end_str = end_dt.strftime("%H:%M:%S.%f")[:-3]
|
||
|
||
# Обновляем старт для следующей фразы
|
||
rec_states[role]["start"] = end_dt
|
||
|
||
# 1. Запись в файл с двумя метками
|
||
entry = f"[{start_str} --> {end_str}] {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,
|
||
'startTime': start_str,
|
||
'endTime': end_str
|
||
})
|
||
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
|
||
|
||
def generate_metrics_graphs(self):
|
||
"""Анализирует history и генерирует графики (Matplotlib -> Base64)"""
|
||
with self.history_lock:
|
||
if not self.history: return
|
||
history_copy = list(self.history)
|
||
|
||
if len(history_copy) < 3:
|
||
return
|
||
|
||
try:
|
||
df = pd.DataFrame(history_copy)
|
||
df['tokens'] = df['text'].apply(lambda x: len(str(x).split()))
|
||
df['chars'] = df['text'].apply(lambda x: len(str(x)))
|
||
|
||
stats = df.groupby('role').agg(
|
||
chars_total=('chars', 'sum'),
|
||
tokens_total=('tokens', 'sum'),
|
||
replica_count=('text', 'count')
|
||
)
|
||
|
||
total_tokens = stats['tokens_total'].sum()
|
||
stats['percent_tokens'] = (stats['tokens_total'] / total_tokens * 100).fillna(0)
|
||
stats['avg_len'] = (stats['tokens_total'] / stats['replica_count']).fillna(0)
|
||
|
||
# Расчет WPM (Скорость речи)
|
||
wpm_data = {}
|
||
for role in stats.index:
|
||
role_df = df[df['role'] == role]
|
||
total_duration_sec = 0.0
|
||
for _, row in role_df.iterrows():
|
||
try:
|
||
t_format = "%H:%M:%S.%f" if "." in row['startTime'] else "%H:%M:%S"
|
||
e_format = "%H:%M:%S.%f" if "." in row['endTime'] else "%H:%M:%S"
|
||
t1 = datetime.datetime.strptime(row['startTime'], t_format)
|
||
t2 = datetime.datetime.strptime(row['endTime'], e_format)
|
||
total_duration_sec += (t2 - t1).total_seconds()
|
||
except: pass
|
||
duration_min = total_duration_sec / 60.0
|
||
wpm_data[role] = stats.loc[role, 'tokens_total'] / max(duration_min, 0.05)
|
||
|
||
generated_images = []
|
||
|
||
def fig_to_base64(fig):
|
||
buf = io.BytesIO()
|
||
fig.savefig(buf, format='png', bbox_inches='tight', dpi=100)
|
||
plt.close(fig)
|
||
buf.seek(0)
|
||
return base64.b64encode(buf.read()).decode('utf-8')
|
||
|
||
# --- ПЕРВАЯ КАРТИНКА: Распределение (слева) и Средняя длина (справа) ---
|
||
fig1, (ax1_1, ax1_2) = plt.subplots(1, 2, figsize=(12, 5))
|
||
|
||
# Слева: Распределение реплик (%)
|
||
ax1_1.barh(stats.index, stats["percent_tokens"], color="skyblue", alpha=0.7)
|
||
ax1_1.set_title("Распределение реплик (по словам, %)")
|
||
ax1_1.set_xlim(0, 100)
|
||
ax1_1.grid(axis="x", linestyle="--", alpha=0.6)
|
||
|
||
# Справа: Средняя длина
|
||
ax1_2.bar(stats.index, stats["avg_len"], color="teal", alpha=0.6)
|
||
ax1_2.set_title("Средняя длина (слов на реплику)")
|
||
|
||
fig1.tight_layout()
|
||
generated_images.append(f'<img src="data:image/png;base64,{fig_to_base64(fig1)}" style="max-width:100%; border-radius: 8px; margin-bottom: 12px;"/>')
|
||
|
||
# --- ВТОРАЯ КАРТИНКА: Общее кол-во (слева) и Скорость речи (справа) ---
|
||
fig2, (ax2_1, ax2_2) = plt.subplots(1, 2, figsize=(12, 5))
|
||
|
||
# Слева: Общее количество реплик
|
||
ax2_1.bar(stats.index, stats["replica_count"], color="coral", alpha=0.6)
|
||
ax2_1.set_title("Общее кол-во реплик")
|
||
|
||
# Справа: Скорость речи (WPM)
|
||
roles_list = stats.index.tolist()
|
||
wpm_values = [wpm_data.get(r, 0) for r in roles_list]
|
||
ax2_2.bar(roles_list, wpm_values, color='#9b59b6', alpha=0.7)
|
||
ax2_2.set_title("Скорость речи (WPM)")
|
||
ax2_2.set_ylabel("Слов в минуту")
|
||
ax2_2.grid(axis='y', linestyle='--', alpha=0.6)
|
||
|
||
fig2.tight_layout()
|
||
generated_images.append(f'<img src="data:image/png;base64,{fig_to_base64(fig2)}" style="max-width:100%; border-radius: 8px; margin-bottom: 12px;"/>')
|
||
|
||
self.metrics_data["graphs"] = generated_images
|
||
print("✅ Аналитика (сдвоенные графики) обновлена.")
|
||
|
||
except Exception as e:
|
||
print(f"❌ Ошибка генерации графиков: {e}") |