first commit
This commit is contained in:
@@ -0,0 +1,76 @@
|
||||
# Модуль 5: горячие клавиши + машина состояний + память
|
||||
|
||||
Сердце ассистента: сшивает STT (М1), brain (М4), TTS (М3), аудио (М2)
|
||||
в цельную программу с тремя способами управления.
|
||||
|
||||
## Управление (три режима)
|
||||
|
||||
| Режим | Как управлять | Где использовать |
|
||||
|---|---|---|
|
||||
| **Свободный** (`test_free.py`) | просто говори; пауза 2 с = фраза окончена (Silero-VAD) | целевой для отца |
|
||||
| **Клавиши-консоль** (`test_assistant.py`) | Enter/s/r/q (имитация) | разработка в Linux |
|
||||
| **Глобальные клавиши** (`--backend keys`) | space/esc/enter из любого окна | Windows-сборка |
|
||||
|
||||
## Машина состояний
|
||||
|
||||
```
|
||||
idle ─▶ listening ─▶ thinking ─▶ speaking ─▶ idle
|
||||
▲ │ │ │
|
||||
└─────────┴────────────┴───────────┘ («Замолчи» из любого)
|
||||
```
|
||||
|
||||
- Говорит, а отец начал говорить → речь мгновенно стихает, новая фраза обрабатывается.
|
||||
- «Замолчи» гасит речь/отменяет запись/отбрасывает «думание» (счётчик поколений:
|
||||
устаревшие ответы никогда не прозвучат).
|
||||
- «Повтори» — последняя фраза (в idle).
|
||||
|
||||
## Долговременная память (data/memory.md)
|
||||
|
||||
- Слово прощания («до свидания», «прощай», «пока»…) → мгновенный прощальный ответ +
|
||||
фоновым запросом модель формулирует конспект разговора → дописывается в файл.
|
||||
- При старте конспект вшивается в системный промпт — ассистент «помнит» прошлые разговоры.
|
||||
- Файл — обычный текст с датами, можно править руками; при переполнении (>8000 симв.)
|
||||
старые записи сжимаются автоматически.
|
||||
- Память не критична к сбоям: ошибка сети при конспекте просто логируется.
|
||||
|
||||
## Файлы
|
||||
|
||||
| Файл | Назначение |
|
||||
|---|---|
|
||||
| `modules/hotkeys/` | бэкенды клавиш: `console.py` (Linux), `keyboard_hook.py` (Windows), общий контракт `KeyEvents` |
|
||||
| `modules/assistant/assistant.py` | машина состояний, конвейер STT→LLM→TTS, перебивание |
|
||||
| `modules/assistant/memory.py` | файл-конспект между сессиями |
|
||||
| `modules/assistant/farewell.py` | детект прощания + саммари диалога |
|
||||
| `modules/audio_io/handsfree.py` | свободный режим: Silero-VAD в потоке, конец фразы по паузе |
|
||||
| `test_assistant.py` | клавиши-консоль / глобальные клавиши |
|
||||
| `test_free.py` | свободный режим (говори без кнопок) |
|
||||
|
||||
## Запуск
|
||||
|
||||
```bash
|
||||
# свободный режим (целевой):
|
||||
.venv/bin/python modules/assistant/test_free.py
|
||||
.venv/bin/python modules/assistant/test_free.py --silence 2.5 # подольше пауза
|
||||
|
||||
# клавишный режим (Linux-разработка):
|
||||
.venv/bin/python modules/assistant/test_assistant.py
|
||||
```
|
||||
|
||||
Клавиши Windows-сборки настраиваются в .env: `HOTKEY_TALK/HOTKEY_STOP/HOTKEY_REPEAT`.
|
||||
|
||||
## Критерии приёмки — статус
|
||||
|
||||
1. ✅ Обычный цикл (живой тест юзером, 2026-09-11).
|
||||
2. ✅ Контекст диалога внутри сессии.
|
||||
3. ✅ «Замолчи» мгновенно; перебивание речи новым вопросом.
|
||||
4. ✅ «Повтори».
|
||||
5. ✅ Память между сессиями (мок-тест); живой тест двух запусков — на юзере.
|
||||
6. 🟡 Свободный режим VAD — код готов, живой тест после установки silero-vad (CPU-torch).
|
||||
|
||||
## Известные детали
|
||||
|
||||
- Silero-VAD требует torch; для разработки ставим CPU-вариант (~200 МБ):
|
||||
`pip install --index-url https://download.pytorch.org/whl/cpu torch silero-vad`.
|
||||
В Windows-сборке заменим на ONNX-детектор (2 МБ, без torch).
|
||||
- suppress-механизм: на время ответа микрофон глушится, чтобы не слышать саму себя;
|
||||
при перебивании отцом suppress снимается мгновенно.
|
||||
@@ -0,0 +1,6 @@
|
||||
"""Модуль 5: assistant — машина состояний, сшивающая все модули в ассистента."""
|
||||
from __future__ import annotations
|
||||
|
||||
from .assistant import Assistant
|
||||
|
||||
__all__ = ["Assistant"]
|
||||
@@ -0,0 +1,422 @@
|
||||
"""Машина состояний голосового ассистента (сердце Модуля 5).
|
||||
|
||||
Состояния и переходы:
|
||||
|
||||
idle ──talk_down──▶ listening ──talk_up──▶ thinking ──готово──▶ speaking ──конец──▶ idle
|
||||
▲ │ │ │
|
||||
└────── stop ────────┴───────────────────────┴────────────────────┘
|
||||
|
||||
Правила по ТЗ:
|
||||
- если ассистент ГОВОРИТ, а отец нажал «Слушай» — речь мгновенно стихает
|
||||
и начинается запись (одним нажатием);
|
||||
- «Замолчи» гасит речь / отменяет запись / отбрасывает «думание»;
|
||||
- «Повтори» — последняя фраза ассистента (в состоянии idle);
|
||||
- удержание клавиши «Слушай» короче min_seconds отбрасывается.
|
||||
|
||||
Тяжёлая работа (STT→LLM→TTS) идёт в фоновом потоке; устаревшие результаты
|
||||
отбрасываются по счётчику поколений _generation (если отец прервал и спросил
|
||||
заново — старый ответ не прозвучит).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Callable, Optional, Tuple
|
||||
|
||||
import numpy as np
|
||||
|
||||
from .errors import GENERAL_CUE, UNCLEAR_CUE, UNCLEAR_LIMIT, classify_error, cue_path
|
||||
from .farewell import FAREWELL_REPLY, is_farewell, summarize_and_save
|
||||
from .memory import MemoryStore
|
||||
from .wake_word import WakeWordWatcher, matches_wake_word
|
||||
|
||||
|
||||
def resample_for_player(audio: np.ndarray, source_rate: int, target_rate: int) -> np.ndarray:
|
||||
"""Чанк в другой частоте (для склейки); edge всегда 24k — на всякий случай."""
|
||||
from ..audio_io.resample import resample_to_16k
|
||||
return resample_to_16k(audio, source_rate, target_rate)
|
||||
|
||||
|
||||
class Assistant:
|
||||
IDLE = "idle" # ждёт нажатия
|
||||
LISTENING = "listening" # запись голоса
|
||||
THINKING = "thinking" # STT → LLM → TTS
|
||||
SPEAKING = "speaking" # воспроизведение ответа
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
stt, brain, tts, # провайдеры модулей 1/4/3
|
||||
recorder, player, # модуль 2
|
||||
memory: Optional[MemoryStore] = None, # долговременная память (М5.1)
|
||||
history_limit: int = 10, # реплик диалога в контексте (2×N сообщений)
|
||||
on_state_change: Optional[Callable[[str, str], None]] = None, # для earcons М7
|
||||
on_log: Optional[Callable[[str], None]] = None,
|
||||
) -> None:
|
||||
self.stt = stt
|
||||
self.brain = brain
|
||||
self.tts = tts
|
||||
self.recorder = recorder
|
||||
self.player = player
|
||||
self.memory = memory or MemoryStore()
|
||||
self.history_limit = history_limit
|
||||
self.session_dialog: list[dict] = [] # реплики текущего разговора (для конспекта)
|
||||
self.on_state_change = on_state_change or (lambda state, note: None)
|
||||
self.on_log = on_log or (lambda msg: print(f" {msg}"))
|
||||
# Долговременная память → в системный промпт LLM при каждом вопросе
|
||||
memory_block = self.memory.as_prompt_block()
|
||||
if memory_block:
|
||||
self.brain.context_block = memory_block
|
||||
self._log(f"Память загружена ({len(self.memory.load())} симв.)")
|
||||
|
||||
self.history: list[dict] = []
|
||||
self._state = self.IDLE
|
||||
self._lock = threading.Lock()
|
||||
self._generation = 0 # инкремент при прерываниях; устаревшие воркеры молчат
|
||||
self._last_speech: Optional[Tuple[np.ndarray, int]] = None
|
||||
# Свободный режим (HandsFreeRecorder): событие «фраза закончена» от VAD
|
||||
self._vad_mode = hasattr(recorder, "suppress") # duck-typing: HandsFreeRecorder
|
||||
if self._vad_mode:
|
||||
recorder.on_phrase = self._on_vad_phrase # публичный атрибут — переписываем
|
||||
# СЕАНС ДИАЛОГА (пробел-тумблер): вне сеанса микрофон глушится
|
||||
self._in_dialog = False
|
||||
self._cues_dir = Path(__file__).resolve().parents[2] / "assets" / "earcons"
|
||||
self._unclear_streak = 0 # неразборчивых фраз подряд (после UNCLEAR_LIMIT — куи)
|
||||
# Wake-word: вне диалога слушаем тихо и ждём «Злата» (или своё из .env)
|
||||
self.wake_word = WakeWordWatcher(on_detected=self._open_dialog)
|
||||
if self._vad_mode:
|
||||
recorder.suppress(False) # слушаем сразу — ждём wake-word
|
||||
# Прогрев TTS-соединения и аудио-выхода в фоне (первый ответ звучит быстрее)
|
||||
threading.Thread(target=self._warmup, daemon=True).start()
|
||||
|
||||
def _warmup(self) -> None:
|
||||
"""Разогреть TTS (DNS/TLS до сервиса синтеза) и аудио-выход — беззвучно."""
|
||||
try:
|
||||
warm = getattr(self.tts, "warmup", None)
|
||||
if warm:
|
||||
warm()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
import numpy as _np
|
||||
self.player.play(_np.zeros(1600, dtype=_np.float32), 24000) # 67 мс тишины
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ------------------------------------------------------------------ state
|
||||
@property
|
||||
def state(self) -> str:
|
||||
return self._state
|
||||
|
||||
def _set_state(self, new_state: str, note: str = "") -> None:
|
||||
with self._lock:
|
||||
self._state = new_state
|
||||
self.on_state_change(new_state, note)
|
||||
|
||||
def _log(self, msg: str) -> None:
|
||||
self.on_log(msg)
|
||||
|
||||
# ------------------------------------------------- сеанс диалога (пробел)
|
||||
def on_dialog_toggle(self) -> None:
|
||||
"""ПРОБЕЛ: начать диалог или завершить его (куи-файлы — мгновенно)."""
|
||||
if self._in_dialog:
|
||||
self._close_dialog(play_end_cue=True)
|
||||
else:
|
||||
self._open_dialog()
|
||||
|
||||
def _open_dialog(self) -> None:
|
||||
if self._state == self.SPEAKING:
|
||||
self.player.stop()
|
||||
if self._state == self.THINKING:
|
||||
self._generation += 1 # старое «думание» отменяется
|
||||
self._in_dialog = True
|
||||
self._set_state(self.IDLE, f"Диалог начат (wake-word «{self.wake_word.wake_word}»)")
|
||||
if self._vad_mode:
|
||||
# микрофон слушает, но на время куи глушим (чтобы не услышать саму себя)
|
||||
self.recorder.suppress(True)
|
||||
self._log("Диалог начат (wake-word)")
|
||||
self._play_cue("dialog_start.wav", unpause_after=True)
|
||||
|
||||
def _close_dialog(self, play_end_cue: bool) -> None:
|
||||
if not self._in_dialog:
|
||||
return
|
||||
self._generation += 1 # всё текущее (запись/думание/речь) отменяется
|
||||
self.player.stop()
|
||||
if self._vad_mode:
|
||||
self.recorder.suppress(False) # сбрасываем недозапись и слушаем снова
|
||||
self._in_dialog = False
|
||||
self._set_state(self.IDLE, f"Диалог завершён — жду «{self.wake_word.wake_word}»")
|
||||
self._log("Диалог завершён — в режиме ожидания wake-word")
|
||||
if play_end_cue:
|
||||
self._play_cue("dialog_end.wav")
|
||||
|
||||
def _play_cue(self, filename: str, unpause_after: bool = False) -> None:
|
||||
"""Мгновенно проиграть готовый wav-куи (без синтеза)."""
|
||||
path = self._cues_dir / filename
|
||||
if not path.is_file():
|
||||
self._log(f"(куи-файл не найден: {filename})")
|
||||
if unpause_after and self._vad_mode:
|
||||
self.recorder.suppress(False)
|
||||
return
|
||||
|
||||
def _watch() -> None:
|
||||
while self.player.is_playing:
|
||||
time.sleep(0.05)
|
||||
if unpause_after and self._in_dialog and self._vad_mode:
|
||||
self.recorder.suppress(False) # куи прозвучал — слушаем отца
|
||||
|
||||
self.player.play_file(str(path))
|
||||
threading.Thread(target=_watch, daemon=True).start()
|
||||
|
||||
# ----------------------------------------------------- свободный режим
|
||||
def _on_vad_phrase(self, audio: np.ndarray, samplerate: int) -> None:
|
||||
"""VAD прислал законченную фразу (пауза тишины после речи).
|
||||
|
||||
Вне диалога: STT → если фраза начинается с wake-word («Злата») —
|
||||
открыть диалог. Иначе молча игнорируем (фоновый разговор не будит).
|
||||
В диалоге — обрабатываем как вопрос (перебивание работает).
|
||||
"""
|
||||
if not self._in_dialog:
|
||||
self._generation += 1
|
||||
generation = self._generation
|
||||
self._set_state(self.LISTENING, "жду wake-word")
|
||||
threading.Thread(
|
||||
target=self._wake_check, args=(audio, generation), daemon=True
|
||||
).start()
|
||||
return
|
||||
state = self._state
|
||||
if state == self.SPEAKING:
|
||||
self.player.stop()
|
||||
if state == self.THINKING:
|
||||
self._generation += 1
|
||||
self._log("Перебиваю размышление новой фразой")
|
||||
self._generation += 1
|
||||
generation = self._generation
|
||||
self._set_state(self.THINKING)
|
||||
threading.Thread(target=self._pipeline, args=(audio, generation), daemon=True).start()
|
||||
|
||||
def _wake_check(self, audio: np.ndarray, generation: int) -> None:
|
||||
"""Проверка фразы на wake-word (вне диалога). Дешёво: только STT."""
|
||||
try:
|
||||
t0 = time.perf_counter()
|
||||
res = self.stt.transcribe(audio)
|
||||
t_stt = time.perf_counter() - t0
|
||||
text = res.text.strip()
|
||||
if generation != self._generation:
|
||||
return
|
||||
if matches_wake_word(text, self.wake_word.wake_word):
|
||||
self._log(f"Wake-word услышан ({t_stt:.1f} c): {text!r}")
|
||||
self._open_dialog()
|
||||
else:
|
||||
# чужая речь/телевизор — молча, без реакций
|
||||
self._log(f"(вне диалога фраза мимо wake-word: {text[:40]!r})")
|
||||
self._set_state(self.IDLE, "жду wake-word")
|
||||
except Exception as exc:
|
||||
self._log(f"wake-check: {exc}")
|
||||
|
||||
def _speak(self, audio: np.ndarray, samplerate: int) -> None:
|
||||
"""Озвучить и вернуть состояние в idle по окончании (или после прерывания)."""
|
||||
generation = self._generation
|
||||
self._set_state(self.SPEAKING)
|
||||
# пока говорим — микрофон не слушает (не слышим сами себя из динамиков)
|
||||
if self._vad_mode:
|
||||
self.recorder.suppress(True)
|
||||
self.player.play(audio, samplerate)
|
||||
|
||||
def _watch() -> None:
|
||||
while self.player.is_playing:
|
||||
time.sleep(0.05)
|
||||
# сюда попадаем и при player.stop() — тогда state уже IDLE
|
||||
if generation == self._generation and self._state == self.SPEAKING:
|
||||
self._set_state(self.IDLE)
|
||||
if self._vad_mode and generation == self._generation:
|
||||
self.recorder.suppress(False) # снова слушаем
|
||||
|
||||
threading.Thread(target=_watch, daemon=True).start()
|
||||
|
||||
# ------------------------------------------------------------- события
|
||||
def on_talk_down(self) -> None:
|
||||
"""Клавиша «Слушай» нажата."""
|
||||
state = self._state
|
||||
|
||||
if state == self.SPEAKING:
|
||||
self.player.stop() # мгновенно стихаем (по ТЗ)
|
||||
if state == self.THINKING:
|
||||
self._generation += 1 # текущий ответ станет устаревшим
|
||||
self._log("Прервал размышление — начинаю запись")
|
||||
|
||||
if state in (self.IDLE, self.SPEAKING, self.THINKING):
|
||||
try:
|
||||
self.recorder.start()
|
||||
except RuntimeError as exc:
|
||||
self._set_state(self.IDLE, f"микрофон недоступен: {exc}")
|
||||
return
|
||||
self._set_state(self.LISTENING)
|
||||
self._log("Слушаю…")
|
||||
# в LISTENING повторный down игнорируем (автодубли клавиши)
|
||||
|
||||
def on_talk_up(self) -> None:
|
||||
"""Клавиша «Слушай» отпущена — запись закончена, обрабатываем."""
|
||||
if self._state != self.LISTENING:
|
||||
return
|
||||
|
||||
audio = self.recorder.stop()
|
||||
if len(audio) == 0:
|
||||
self._set_state(self.IDLE, "Слишком коротко — ничего не записал")
|
||||
return
|
||||
|
||||
self._generation += 1
|
||||
generation = self._generation
|
||||
self._set_state(self.THINKING)
|
||||
threading.Thread(target=self._pipeline, args=(audio, generation), daemon=True).start()
|
||||
|
||||
def on_stop(self) -> None:
|
||||
"""Клавиша «Замолчи» — мгновенно тишина."""
|
||||
state = self._state
|
||||
self._generation += 1 # отбрасываем все текущие работы
|
||||
if state == self.SPEAKING:
|
||||
self.player.stop()
|
||||
self._log("Прервал речь")
|
||||
elif state == self.LISTENING:
|
||||
self.recorder.cancel()
|
||||
self._log("Запись отменена")
|
||||
elif state == self.THINKING:
|
||||
self._log("Размышление отменено")
|
||||
self._set_state(self.IDLE)
|
||||
|
||||
def on_repeat(self) -> None:
|
||||
"""Клавиша «Повтори» — снова озвучить последний ответ."""
|
||||
if self._state != self.IDLE:
|
||||
self._log("«Повтори» работает только в режиме ожидания")
|
||||
return
|
||||
if not self._last_speech:
|
||||
self._log("Пока нечего повторять")
|
||||
return
|
||||
audio, samplerate = self._last_speech
|
||||
self._log("Повторяю последний ответ")
|
||||
self._speak(audio, samplerate)
|
||||
|
||||
# ------------------------------------------------------------ конвейер
|
||||
def _pipeline(self, audio: np.ndarray, generation: int) -> None:
|
||||
"""STT → LLM → TTS → озвучка (фоновый поток). Тайминги этапов — в лог."""
|
||||
try:
|
||||
t0 = time.perf_counter()
|
||||
stt_res = self.stt.transcribe(audio)
|
||||
t_stt = time.perf_counter() - t0
|
||||
|
||||
question = stt_res.text.strip()
|
||||
if generation != self._generation:
|
||||
return # устарело (прервали)
|
||||
if not question or len(question) < 2:
|
||||
self._handle_unclear("Речь не распознана")
|
||||
return
|
||||
self._unclear_streak = 0 # фраза распознана — счётчик в ноль
|
||||
self._log(f"Вы: {question}")
|
||||
|
||||
# Прощание: отвечаем сразу, пишем конспект и ЗАКРЫВАЕМ сеанс диалога
|
||||
if is_farewell(question) and self.session_dialog:
|
||||
self._log("Прощание — записываю разговор в память")
|
||||
threading.Thread(
|
||||
target=summarize_and_save,
|
||||
args=(self.brain, self.session_dialog + [{"role": "user", "text": question}], self.memory),
|
||||
kwargs={"on_error": lambda msg: self._log(msg)},
|
||||
daemon=True,
|
||||
).start()
|
||||
answer = FAREWELL_REPLY
|
||||
answer_for_history = answer
|
||||
llm = None
|
||||
t_llm = 0.0
|
||||
else:
|
||||
t0 = time.perf_counter()
|
||||
llm = self.brain.ask(question, history=self.history)
|
||||
t_llm = time.perf_counter() - t0
|
||||
if generation != self._generation:
|
||||
return
|
||||
answer = llm.cleaned_text # для ушей (без служебных маркеров)
|
||||
answer_for_history = llm.raw_text # маркер [ЧАСТЬ i ИЗ n] остаётся — модель помнит часть
|
||||
if not answer:
|
||||
answer = "Извините, я задумалась и забыла, что хотела сказать. Спросите ещё раз."
|
||||
answer_for_history = answer
|
||||
self._log(f"Ассистент ({t_llm:.1f} с): {answer}")
|
||||
|
||||
# История и конспект-диалог пополняются одинаково для обоих путей озвучки
|
||||
turn = [
|
||||
{"role": "user", "text": question},
|
||||
{"role": "assistant", "text": answer_for_history if not is_farewell(question) else answer},
|
||||
]
|
||||
self.history += turn
|
||||
self.history = self.history[-self.history_limit:]
|
||||
self.session_dialog += turn
|
||||
|
||||
# «Отвечаю» — сразу (синтез идёт далее в фоне, звук стартует по готовности)
|
||||
self._log("Отвечаю...")
|
||||
|
||||
# Потоковая озвучка: чанки-предложения по мере готовности.
|
||||
# Если синтезатор не умеет stream() — обычный путь (целиком).
|
||||
streamer = getattr(self.tts, "stream", None)
|
||||
if streamer is not None and generation == self._generation:
|
||||
self._set_state(self.SPEAKING)
|
||||
if self._vad_mode:
|
||||
self.recorder.suppress(True)
|
||||
self._last_speech = None # заполним по чанкам
|
||||
|
||||
pause = getattr(self.tts, "_pause", 0.0)
|
||||
sr_prev = None
|
||||
for audio, sr in streamer(answer):
|
||||
if generation != self._generation: # перебили — гасим поток
|
||||
self.player.stop()
|
||||
break
|
||||
piece = audio
|
||||
if sr_prev is not None and sr != sr_prev:
|
||||
piece = resample_for_player(audio, sr, sr_prev) # Rare: edge всегда 24k
|
||||
sr_prev = sr
|
||||
if self._last_speech is None:
|
||||
self._last_speech = (piece, sr)
|
||||
else:
|
||||
prev, prev_sr = self._last_speech
|
||||
gap = np.zeros(int(pause * prev_sr), dtype=np.float32)
|
||||
self._last_speech = (np.concatenate([prev, gap, piece]), sr)
|
||||
self.player.play(piece, sr)
|
||||
# ждём конца чанка (если перебили — generation сместится и выйдем)
|
||||
while self.player.is_playing and generation == self._generation:
|
||||
time.sleep(0.05)
|
||||
if generation != self._generation:
|
||||
break
|
||||
if generation == self._generation:
|
||||
self._set_state(self.IDLE)
|
||||
# Прощание: после ответа закрываем сеанс (микрофон глушится)
|
||||
if is_farewell(question) and self._in_dialog:
|
||||
self._close_dialog(play_end_cue=False)
|
||||
elif self._vad_mode:
|
||||
self.recorder.suppress(False)
|
||||
return
|
||||
|
||||
t0 = time.perf_counter()
|
||||
speech = self.tts.synthesize(answer)
|
||||
t_tts = time.perf_counter() - t0
|
||||
if generation != self._generation:
|
||||
return
|
||||
|
||||
self._last_speech = (speech.audio, speech.samplerate)
|
||||
|
||||
self._speak(speech.audio, speech.samplerate)
|
||||
t_play = getattr(self.player, "last_start_latency", 0.0)
|
||||
self._log(f"тайминг: распознавание {t_stt:.1f} | модель {t_llm:.1f} | "
|
||||
f"синтез {t_tts:.1f} | старт звука {t_play:.1f}")
|
||||
except Exception as exc: # сеть, API, TTS — что угодно
|
||||
if generation == self._generation:
|
||||
self._log(f"Ошибка: {exc}")
|
||||
cue = classify_error(exc)
|
||||
self._set_state(self.IDLE, f"Ошибка: {cue}")
|
||||
self._play_cue(cue)
|
||||
|
||||
# --------------------------------------------------- неразборчивая речь
|
||||
def _handle_unclear(self, note: str) -> None:
|
||||
"""Фраза не распознана: счётчик подряд; после UNCLEAR_LIMIT — куи и сброс."""
|
||||
self._unclear_streak += 1
|
||||
self._log(f"{note} (неразборчивых подряд: {self._unclear_streak})")
|
||||
self._set_state(self.IDLE, note)
|
||||
if self._unclear_streak >= UNCLEAR_LIMIT:
|
||||
self._log("Много неразборчивых подряд — напоминаю про микрофон")
|
||||
self._unclear_streak = 0
|
||||
self._play_cue(UNCLEAR_CUE)
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Классификация ошибок пайплайна → голосовые куи (текст + wav-файл).
|
||||
|
||||
Отец должен СЛЫШАТЬ, что случилось: нет сети / лимит / что-то ещё.
|
||||
Куи — готовые wav из assets/earcons (мгновенно, без синтеза в момент ошибки).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
CUES_DIR = Path(__file__).resolve().parents[2] / "assets" / "earcons"
|
||||
|
||||
# Паттерны текста ошибки → куи
|
||||
_PATTERNS: list[tuple[str, str]] = [
|
||||
(r"Connection|ConnectError|Network|Timeout|timed out|getaddrinfo|SSLError|RemoteProtocol", "error_network.wav"),
|
||||
(r"429|Rate limit|rate_limit|quota|Quota|billing|insufficient", "error_limit.wav"),
|
||||
(r"401|403|Unauthorized|PermissionDenied|forbidden", "error_limit.wav"),
|
||||
]
|
||||
|
||||
UNCLEAR_CUE = "unclear_question.wav"
|
||||
GENERAL_CUE = "error_general.wav"
|
||||
UNCLEAR_LIMIT = 5 # после N неразборчивых подряд — голосовое сообщение и сброс
|
||||
|
||||
|
||||
def classify_error(exc: BaseException | str) -> str:
|
||||
"""Текст ошибки → имя wav-куи в assets/earcons."""
|
||||
text = str(exc)
|
||||
for pattern, cue in _PATTERNS:
|
||||
if re.search(pattern, text):
|
||||
return cue
|
||||
return GENERAL_CUE
|
||||
|
||||
|
||||
def cue_path(cue: str) -> Path:
|
||||
return CUES_DIR / cue
|
||||
@@ -0,0 +1,57 @@
|
||||
"""Конец разговора: прощание + запись конспекта в долговременную память.
|
||||
|
||||
Триггер — слово прощания во фразе отца («до свидания», «прощай», ...).
|
||||
Ассистент отвечает прощальной фразой, затем ОТДЕЛЬНЫМ запросом просит модель
|
||||
сформулировать конспект разговора и дописывает его в data/memory.md.
|
||||
Конспект самоформулируется моделью (как предложил юзер), мы только сохраняем.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import threading
|
||||
from typing import List, Optional
|
||||
|
||||
from .memory import MemoryStore
|
||||
|
||||
# Фразы-триггеры прощания (регистронезависимо, как часть слова «пока» тоже сработает)
|
||||
_FAREWELL = re.compile(r"\b(до свидания|прощай|всего доброго|бай|пока)\b", re.I)
|
||||
|
||||
FAREWELL_REPLY = "До свидания! Я всё запомнила и буду ждать нового разговора. Всего вам доброго!"
|
||||
|
||||
SUMMARY_PROMPT = (
|
||||
"Кратко сформулируй (3–5 простых предложений) что важно запомнить из этого "
|
||||
"разговора на будущее: о чём человек спрашивал, что для него важно, "
|
||||
"какие были договорённости или просьбы. Только сам конспект, без вступлений."
|
||||
)
|
||||
|
||||
|
||||
def is_farewell(text: str) -> bool:
|
||||
return bool(_FAREWELL.search(text or ""))
|
||||
|
||||
|
||||
def make_summary_prompt(dialog: List[dict]) -> str:
|
||||
"""Собрать текст диалога в промпт для конспекта."""
|
||||
lines = [f"{'Человек' if m['role'] == 'user' else 'Ассистент'}: {m['text']}"
|
||||
for m in dialog]
|
||||
return "\n".join([SUMMARY_PROMPT, ""] + lines)
|
||||
|
||||
|
||||
def summarize_and_save(brain, dialog: List[dict], memory: MemoryStore,
|
||||
on_error: Optional[callable] = None,
|
||||
done: Optional[callable] = None) -> None:
|
||||
"""Сформулировать конспект диалога и дописать в память (фоновая работа)."""
|
||||
def _work() -> None:
|
||||
try:
|
||||
result = brain.ask(make_summary_prompt(dialog), history=[]) # без системного контекста ассистента
|
||||
if result.cleaned_text:
|
||||
memory.append(result.cleaned_text)
|
||||
except Exception as exc:
|
||||
# Память не критична, но тихо терять ошибку плохо при отладке
|
||||
if on_error:
|
||||
on_error(f"Не смог записать память: {exc}")
|
||||
|
||||
if done is None:
|
||||
threading.Thread(target=_work, daemon=True).start()
|
||||
else:
|
||||
_work()
|
||||
done()
|
||||
@@ -0,0 +1,92 @@
|
||||
"""Долговременная память ассистента — файл-конспект между сессиями.
|
||||
|
||||
Принцип (идея юзера 2026-09-11):
|
||||
- в конце разговора («до свидания») модель просится сформулировать конспект,
|
||||
он ДОПОЛНЯЕТСЯ в текстовый файл data/memory.md;
|
||||
- при старте конспект кладётся в системный промпт — ассистент «помнит» прошлые
|
||||
разговоры (кто отец, что обсуждали, договорённости).
|
||||
|
||||
Файл — простой читаемый текст: можно посмотреть и поправить руками.
|
||||
Хранить сам диалог не нужно — конспект компактнее и токены дешевле.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
|
||||
|
||||
class MemoryStore:
|
||||
def __init__(self, path: str | Path = "data/memory.md", max_chars: int = 8000) -> None:
|
||||
self._path = Path(path)
|
||||
self._max_chars = max_chars # ограничение конспекта (защита от бесконечного роста)
|
||||
self._path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# ------------------------------------------------------------------ чтение
|
||||
@property
|
||||
def path(self) -> Path:
|
||||
return self._path
|
||||
|
||||
def load(self) -> str:
|
||||
"""Конспект целиком (пустая строка, если памяти ещё нет)."""
|
||||
if not self._path.is_file():
|
||||
return ""
|
||||
return self._path.read_text(encoding="utf-8").strip()
|
||||
|
||||
def as_prompt_block(self) -> str:
|
||||
"""Блок для системного промпта (или пустая строка)."""
|
||||
memory = self.load()
|
||||
if not memory:
|
||||
return ""
|
||||
return (
|
||||
"Вот что ты помнишь из прошлых разговоров с этим человеком "
|
||||
"(его долговременная память):\n"
|
||||
f"{memory}\n"
|
||||
"Используй это естественно, без упоминания «записей» и «памяти»."
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------ запись
|
||||
def append(self, summary: str) -> None:
|
||||
"""Дописать новый конспект с датой, не превышая лимит объёма."""
|
||||
summary = summary.strip()
|
||||
if not summary:
|
||||
return
|
||||
|
||||
header = f"\n\n## {time.strftime('%d.%m.%Y')}\n"
|
||||
current = self.load()
|
||||
new_total = len(current) + len(header) + len(summary)
|
||||
|
||||
if new_total > self._max_chars:
|
||||
# Файл распух: сжимаем старые записи в одну итоговую строку
|
||||
current = self._condense(current)
|
||||
|
||||
with self._path.open("a", encoding="utf-8") as f:
|
||||
if current and not current.endswith("\n"):
|
||||
f.write("\n")
|
||||
f.write(header + summary)
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Стереть память (для отладки)."""
|
||||
self._path.write_text("", encoding="utf-8")
|
||||
|
||||
# ------------------------------------------------------------------ внутреннее
|
||||
def _condense(self, text: str) -> str:
|
||||
"""Сжать конспект: оставить последние записи целиком, старые — краткой выжимкой.
|
||||
|
||||
Сжатие делаем механически (первые предложения старых записей); качество
|
||||
semantique-сжатия критично только для очень длинной памяти, а это редкость.
|
||||
"""
|
||||
sections: List[str] = [s.strip() for s in re.split(r"\n## ", "\n" + text) if s.strip()]
|
||||
if len(sections) <= 1:
|
||||
return text[: self._max_chars // 2]
|
||||
|
||||
keep_recent = sections[-4:] # последние 4 записи — целиком
|
||||
old = "\n".join(sections[:-4])
|
||||
condensed = "\n".join(
|
||||
"• " + " ".join(re.split(r"(?<=[.!?]) ", chunk)[:2])
|
||||
for chunk in old.split("•") if chunk.strip()
|
||||
)
|
||||
merged = ("— Итоги прошлых записей: " + condensed + "\n\n"
|
||||
+ "\n## ".join(keep_recent))
|
||||
return merged[: self._max_chars]
|
||||
@@ -0,0 +1,108 @@
|
||||
"""Тест Модуля 5: горячие клавиши + машина состояний = ЖИВОЙ ассистент.
|
||||
|
||||
Запуск:
|
||||
python modules/assistant/test_assistant.py # console-бэкенд (Linux, без root)
|
||||
python modules/assistant/test_assistant.py --backend keys # глобальные клавиши (Windows; Linux — root)
|
||||
|
||||
Консольный режим (имитация трёх клавиш):
|
||||
Enter — нажать «Слушай» (первый раз), отпустить (второй раз) — аналог удержания
|
||||
s + Enter — «Замолчи»
|
||||
r + Enter — «Повтори»
|
||||
q + Enter — выход
|
||||
|
||||
Критерии приёмки Модуля 5:
|
||||
1. Пустой вопрос: Enter → молчание → Enter → «Слишком коротко».
|
||||
2. Обычный цикл: Enter → вопрос → Enter → ответ голосом (диалог помнит контекст).
|
||||
3. «Замолчи» во время речи — звук стихает мгновенно (цель < 100 мс).
|
||||
4. «Повтори» — последняя фраза звучит снова.
|
||||
5. Нажать «Слушай» ВО ВРЕМЯ речи — речь стихла, запись началась.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
from modules.assistant import Assistant
|
||||
from modules.audio_io import Player, Recorder
|
||||
from modules.brain import get_provider as get_brain
|
||||
from modules.hotkeys import KeyEvents, get_backend
|
||||
from modules.stt import get_provider as get_stt
|
||||
from modules.tts import make_tts
|
||||
except ImportError:
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||
from modules.assistant import Assistant
|
||||
from modules.audio_io import Player, Recorder
|
||||
from modules.brain import get_provider as get_brain
|
||||
from modules.hotkeys import KeyEvents, get_backend
|
||||
from modules.stt import get_provider as get_stt
|
||||
from modules.tts import make_tts
|
||||
|
||||
STATES_RU = {
|
||||
"idle": "⏸ ожидание",
|
||||
"listening": "🎙 слушаю",
|
||||
"thinking": "🧠 думаю…",
|
||||
"speaking": "🔊 говорю",
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Живой ассистент (Модуль 5)")
|
||||
parser.add_argument("--backend", default="console", choices=["console", "keys"])
|
||||
parser.add_argument("--stt-model", default="small")
|
||||
args = parser.parse_args()
|
||||
|
||||
print("Загружаю модули…")
|
||||
stt = get_stt("faster-whisper", model_size=args.stt_model)
|
||||
brain = get_brain("openrouter") # модель из .env
|
||||
tts = make_tts() # провайдер/скорость из .env (TTS_PROVIDER, TTS_RATE)
|
||||
recorder = Recorder()
|
||||
player = Player()
|
||||
|
||||
def on_state(state: str, note: str) -> None:
|
||||
line = STATES_RU.get(state, state)
|
||||
print(f"\n[{line}]" + (f" {note}" if note else ""))
|
||||
|
||||
assistant = Assistant(stt, brain, tts, recorder, player, on_state_change=on_state)
|
||||
|
||||
quit_event = threading.Event()
|
||||
|
||||
events = KeyEvents(
|
||||
on_talk_down=assistant.on_talk_down,
|
||||
on_talk_up=assistant.on_talk_up,
|
||||
on_stop=assistant.on_stop,
|
||||
on_repeat=assistant.on_repeat,
|
||||
on_quit=lambda: quit_event.set(),
|
||||
)
|
||||
|
||||
if args.backend == "keys":
|
||||
print("\nГлобальные клавиши: «Слушай» = space (удерживать), "
|
||||
"«Замолчи» = esc, «Повтори» = enter. Ctrl+C — выход.")
|
||||
print("(На Linux этот режим требует root: sudo -E .venv/bin/python …)")
|
||||
else:
|
||||
print("\nКонсольные клавиши (имитация удержания):")
|
||||
print(" Enter — нажать «Слушай», ещё Enter — отпустить (начать обработку)")
|
||||
print(" s + Enter — «Замолчи» | r + Enter — «Повтори» | q + Enter — выход")
|
||||
|
||||
backend = get_backend(args.backend, events)
|
||||
backend.start()
|
||||
|
||||
try:
|
||||
while not quit_event.wait(timeout=0.2):
|
||||
pass
|
||||
except KeyboardInterrupt:
|
||||
print("\n(Ctrl+C)")
|
||||
finally:
|
||||
backend.stop()
|
||||
try:
|
||||
player.stop() # тишина при выходе
|
||||
except Exception:
|
||||
pass
|
||||
print("Выход.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,128 @@
|
||||
"""Тест М5.3: свободный режим с ПРОБЕЛОМ-тумблером диалога.
|
||||
|
||||
Запуск:
|
||||
python modules/assistant/test_free.py
|
||||
python modules/assistant/test_free.py --silence 1.5 # короче пауза конца фразы
|
||||
|
||||
Схема (целевая для отца):
|
||||
ПРОБЕЛ (в консоли: d + Enter) — НАЧАТЬ диалог: куи «Ассистент готов к диалогу»
|
||||
→ просто говори, пауза 2 c = фраза закончена → ответ.
|
||||
ПРОБЕЛ в диалоге — ЗАВЕРШИТЬ: куи «Спасибо. До новых встреч».
|
||||
Или скажи «до свидания» — то же самое + запомнит разговор.
|
||||
Вне диалога микрофон заглушен. Выход: q + Enter.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
from modules.assistant import Assistant
|
||||
from modules.audio_io import HandsFreeRecorder, Player
|
||||
from modules.brain import get_provider as get_brain
|
||||
from modules.hotkeys import KeyEvents, get_backend
|
||||
from modules.stt import get_provider as get_stt
|
||||
from modules.tts import make_tts
|
||||
except ImportError:
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||
from modules.assistant import Assistant
|
||||
from modules.audio_io import HandsFreeRecorder, Player
|
||||
from modules.brain import get_provider as get_brain
|
||||
from modules.hotkeys import KeyEvents, get_backend
|
||||
from modules.stt import get_provider as get_stt
|
||||
from modules.tts import make_tts
|
||||
|
||||
VAD_STATE_RU = {
|
||||
"listening": "👂 слушаю тебя",
|
||||
"speech_start": "🎙 начало фразы",
|
||||
"speech_end": "✅ фраза собрана, обрабатываю",
|
||||
"too_short": "⚠ слишком коротко — пропускаю",
|
||||
"paused": "⏸ не слушаю (сама говорю)",
|
||||
}
|
||||
|
||||
ASSISTANT_STATE_RU = {
|
||||
"idle": "⏸ ожидание",
|
||||
"listening": "🎙 запись",
|
||||
"thinking": "🧠 думаю…",
|
||||
"speaking": "🔊 говорю",
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
p = argparse.ArgumentParser(description="Свободный режим ассистента (VAD)")
|
||||
p.add_argument("--silence", type=float, default=2.0,
|
||||
help="пауза тишины, считающаяся концом фразы (сек)")
|
||||
p.add_argument("--threshold", type=float, default=0.5,
|
||||
help="порог VAD: больше — строже, меньше — чувствительнее")
|
||||
p.add_argument("--min-speech", type=float, default=0.3,
|
||||
help="минимальная доля голоса во фразе, сек")
|
||||
p.add_argument("--stt-model", default="small")
|
||||
args = p.parse_args()
|
||||
|
||||
print("Загружаю модули…")
|
||||
stt = get_stt("faster-whisper", model_size=args.stt_model)
|
||||
brain = get_brain("openrouter")
|
||||
tts = make_tts() # провайдер/скорость из .env (TTS_PROVIDER, TTS_RATE)
|
||||
player = Player()
|
||||
|
||||
quit_event = threading.Event()
|
||||
|
||||
def on_vad_state(state: str) -> None:
|
||||
print(f"\n [микрофон] {VAD_STATE_RU.get(state, state)}")
|
||||
|
||||
def on_assistant_state(state: str, note: str) -> None:
|
||||
print(f"\n[{ASSISTANT_STATE_RU.get(state, state)}]" + (f" {note}" if note else ""))
|
||||
|
||||
def on_log(msg: str) -> None:
|
||||
print(f" {msg}")
|
||||
|
||||
recorder = HandsFreeRecorder(
|
||||
silence_sec=args.silence,
|
||||
speech_threshold=args.threshold,
|
||||
min_speech_sec=args.min_speech,
|
||||
on_state=on_vad_state,
|
||||
on_error=lambda m: print(f" [ОШИБКА] {m}"),
|
||||
)
|
||||
assistant = Assistant(stt, brain, tts, recorder, player,
|
||||
on_state_change=on_assistant_state, on_log=on_log)
|
||||
|
||||
print(f"\nУПРАВЛЕНИЕ (голосом, без клавиатуры):")
|
||||
print(f" «{assistant.wake_word.wake_word.capitalize()}» — начать диалог (куи «готова к диалогу»)")
|
||||
print(" «До свидания» — завершить диалог (куи «до новых встреч»)")
|
||||
print(" Вне диалога ассистент слушает тихо и отвечает только на wake-word.")
|
||||
print(" Консоль: s — «Замолчи», r — «Повтори», ПРОБЕЛ — тоже тумблер диалога, q — выход.\n")
|
||||
|
||||
quit_event = threading.Event()
|
||||
|
||||
events = KeyEvents(
|
||||
on_talk_down=lambda: None,
|
||||
on_talk_up=lambda: None,
|
||||
on_stop=assistant.on_stop,
|
||||
on_repeat=assistant.on_repeat,
|
||||
on_quit=lambda: quit_event.set(),
|
||||
on_dialog_toggle=assistant.on_dialog_toggle,
|
||||
)
|
||||
backend = get_backend("console", events)
|
||||
backend.start()
|
||||
recorder.start() # микрофон открыт, но заглушен до начала диалога
|
||||
|
||||
try:
|
||||
while not quit_event.wait(timeout=0.2):
|
||||
pass
|
||||
except KeyboardInterrupt:
|
||||
print("\n(Ctrl+C)")
|
||||
finally:
|
||||
backend.stop()
|
||||
recorder.stop()
|
||||
try:
|
||||
player.stop()
|
||||
except Exception:
|
||||
pass
|
||||
print("Выход.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,82 @@
|
||||
"""Wake-word — голосовое «будильное слово» вместо клавиши (М5.4).
|
||||
|
||||
Отец говорит wake-word (например «Злата») → куи «Ассистент готов к диалогу»
|
||||
→ свободный диалог → «до свидания» → снова ждёт слова.
|
||||
|
||||
Выбор слова (требования юзера): уникальное, редкое в бытовой речи,
|
||||
однозначно распознаётся STT, простое для пожилого человека. По умолчанию
|
||||
«Злата» (whisper пишет его без вариантов); настраивается в .env WAKE_WORD.
|
||||
|
||||
Защита от ложных срабатываний (слово из чужого разговора/ТВ):
|
||||
- матч только по НАЧАЛУ фразы (первое слово), не по вхождению внутрь;
|
||||
- нормализация: без пунктуации, нижний регистр, ё→е;
|
||||
- совпадение по префиксу не менее min_prefix_len символов — устойчиво к
|
||||
«Злат/Злата/златочка»;
|
||||
- опционально max_edit_distance — допуск опечатки распознавания.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import List, Optional
|
||||
|
||||
DEFAULT_WAKE_WORD = "марта"
|
||||
_MIN_PREFIX = 4 # «злат» уже считается (обрубок «зла…» от микрофона)
|
||||
_PART_MARKER_RE = re.compile(r"[^\wа-яё]+", re.IGNORECASE)
|
||||
|
||||
|
||||
def normalize(text: str) -> List[str]:
|
||||
"""Фраза → список слов (нижний регистр, без пунктуации, ё→е).
|
||||
|
||||
Дефис внутри слова склеивается («Ага-то» → «агато») — whisper любит
|
||||
разбивать имена слогами через дефис.
|
||||
"""
|
||||
text = text.lower().replace("ё", "е")
|
||||
text = re.sub(r"(\w)-(\w)", r"\1\2", text) # ага-то → агато
|
||||
return [w for w in re.split(r"[^\w]+", text) if w]
|
||||
|
||||
|
||||
def matches_wake_word(transcript: str, wake_word: str = DEFAULT_WAKE_WORD) -> bool:
|
||||
"""True, если фраза НАЧИНАЕТСЯ с wake-word (целое слово, с допуском).
|
||||
|
||||
Whisper любит ломать имена: «Ага-то», «А гата» — поэтому проверяем
|
||||
первое слово И склейку первых двух. «Агата»/«агат» (обрубок) триггерят;
|
||||
«агатовые украшения» — нет: после корня продолжение слова.
|
||||
"""
|
||||
words = normalize(transcript)
|
||||
if not words:
|
||||
return False
|
||||
ww = normalize(wake_word)[0] if normalize(wake_word) else ""
|
||||
if not ww:
|
||||
return False
|
||||
candidates = [words[0]]
|
||||
if len(words) >= 2:
|
||||
candidates.append(words[0] + words[1]) # «а гата» → «агата»
|
||||
for first in candidates:
|
||||
if first == ww:
|
||||
return True
|
||||
if len(ww) >= _MIN_PREFIX and ww.startswith(first) and len(first) >= _MIN_PREFIX:
|
||||
return True
|
||||
if abs(len(first) - len(ww)) <= 1 and len(ww) >= _MIN_PREFIX \
|
||||
and first[: len(ww) - 1] == ww[: len(ww) - 1]:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
class WakeWordWatcher:
|
||||
"""Проверяет распознанные фразы на наличие wake-word в режиме ожидания."""
|
||||
|
||||
def __init__(self, wake_word: Optional[str] = None, on_detected: Optional[callable] = None) -> None:
|
||||
from ..brain.config import get_secret
|
||||
self.wake_word = wake_word or get_secret("WAKE_WORD") or DEFAULT_WAKE_WORD
|
||||
self.on_detected = on_detected or (lambda: None)
|
||||
|
||||
def feed_transcript(self, transcript: str) -> bool:
|
||||
"""Распознанная фраза в режиме ожидания. True → wake-word сработал."""
|
||||
if matches_wake_word(transcript, self.wake_word):
|
||||
self.on_detected()
|
||||
return True
|
||||
return False
|
||||
|
||||
@property
|
||||
def hint(self) -> str:
|
||||
return f"Скажите «{self.wake_word.capitalize()}», чтобы начать диалог."
|
||||
Reference in New Issue
Block a user