first commit

This commit is contained in:
workD12
2026-09-11 19:47:15 +03:00
commit 32754a1fe2
56 changed files with 3836 additions and 0 deletions
+76
View File
@@ -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 снимается мгновенно.
+6
View File
@@ -0,0 +1,6 @@
"""Модуль 5: assistant — машина состояний, сшивающая все модули в ассистента."""
from __future__ import annotations
from .assistant import Assistant
__all__ = ["Assistant"]
+422
View File
@@ -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)
+35
View File
@@ -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
+57
View File
@@ -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()
+92
View File
@@ -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]
+108
View File
@@ -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())
+128
View File
@@ -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())
+82
View File
@@ -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()}», чтобы начать диалог."