diff --git a/HANDOFF.md b/HANDOFF.md
new file mode 100644
index 0000000..73c0055
--- /dev/null
+++ b/HANDOFF.md
@@ -0,0 +1,176 @@
+# Smart Home Tablet — Handoff
+
+Документ для следующей сессии. Где что лежит, как деплоить, что сделано.
+
+---
+
+## Что это
+
+Дашборд для планшета на стене: `https://tablet.digital-home.site`.
+Next.js 14 App Router, TypeScript, standalone Docker build.
+
+Контейнер живёт на сервере `cosmo@192.168.31.60`, образ собирается self-hosted Gitea Actions runner'ом и пушится через `docker stop/rm/run` (без compose для этого сервиса).
+
+---
+
+## Где файлы
+
+### Локально (рабочая копия)
+```
+/tmp/tablet-work/smart-home-tablet/
+```
+Это git-clone из `https://git.digital-home.site/daniil/smart-home-tablet`. **Ремоут уже настроен с токеном в URL** — `git push origin main` работает без пароля.
+
+### На сервере (192.168.31.60)
+```
+/opt/digital-home/smart-home-tablet/ # код (что задеплоено)
+/opt/digital-home/smart-home-tablet-data/ # JSON-стораджи (volume → /data в контейнере)
+ ├─ tablet-notes.json
+ ├─ tablet-timers.json
+ └─ tablet-countdowns.json
+/opt/digital-home/tablet.env # секреты (HA_TOKEN, ELEVENLABS_*, VOICE_API_KEY, и т.д.)
+```
+
+### Workflow деплоя
+```
+/opt/digital-home/smart-home-tablet/.gitea/workflows/deploy.yml
+```
+Триггер: push в `main`. Делает `git pull → docker build → stop/rm/run`. Контейнер: `tablet-yfh53kixpwkjlo4zibglx4n2` на порту 3006, traefik роутит `tablet.digital-home.site` → 3000 внутри.
+
+---
+
+## Как деплоить
+
+```bash
+cd /tmp/tablet-work/smart-home-tablet
+# правки...
+NODE_OPTIONS="--max-old-space-size=4096" npm run build # tsc на дефолтном heap падает OOM
+git add -A && git commit -m "..." && git push origin main
+```
+
+Дальше Gitea Actions сам пересоберёт. Как проверить, что задеплоилось:
+```bash
+ssh -i ~/.ssh/id_ed25519 cosmo@192.168.31.60 \
+ "docker ps --filter name=tablet --format '{{.CreatedAt}}'"
+curl -sk -o /dev/null -w "%{http_code} %{time_total}s\n" https://tablet.digital-home.site/
+```
+
+Свежий `CreatedAt` = новый билд поднялся.
+
+---
+
+## SSH
+
+```bash
+ssh -i ~/.ssh/id_ed25519 cosmo@192.168.31.60
+```
+Ключ `~/.ssh/id_ed25519` авторизован. **Не** `daniil@192.168.31.103` — туда с этой машины ключ не положен.
+
+---
+
+## Голосовой ассистент
+
+Tablet — **только UI**. Слой, отвечающий за wake-word/STT/LLM, живёт **снаружи** (на 192.168.31.103, плюс ElevenLabs через прокси `192.168.31.103:8888`).
+
+Поток событий:
+```
+Внешний Python-агент ──POST──▶ /api/voice/event (auth: VOICE_API_KEY)
+ │
+ ▼
+ voiceBus (in-memory EventEmitter)
+ │
+ ▼
+ Браузер ◀──SSE──── /api/voice/stream
+ │
+ ▼
+
+```
+
+### Файлы голосового стека (в этом репо)
+```
+app/api/voice/
+ ├─ event/route.ts # POST: принимает {event, agent, text} от Python-агента
+ ├─ stream/route.ts # GET (SSE): отдаёт события в браузер
+ ├─ tts/route.ts # POST: проксирует в ElevenLabs (cosmo / lusya голоса)
+ ├─ timer/route.ts # голос-операции с таймерами
+ └─ tools/ # tool-calls от LLM (открыть таб, поставить таймер, ...)
+
+lib/voice-bus.ts # EventEmitter, шарится между event и stream
+lib/voice-tools.ts # описание tools для LLM
+
+components/VoiceOverlay.tsx # Siri-style орб, рендерит wake/listening/command/response/error
+```
+
+Состояния (см. `VoiceOverlay.tsx`): `idle | wake | listening | command | response | error`. Два агента (`cosmo` / `lusya`) — каждому своя цветовая пара (фиолетовая / розовая) и свой ElevenLabs voice ID. Голоса в `tablet.env`: `COSMO_TTS_VOICE`, `LUSYA_TTS_VOICE`.
+
+Агент сам шлёт `idle` когда закончил; в overlay есть safety-таймер 60с на случай падения агента.
+
+**Где сам Python-агент** — на `192.168.31.103` (рабочая машина пользователя). Этот репо его не содержит и не деплоит.
+
+---
+
+## Структура UI
+
+```
+app/
+ layout.tsx, page.tsx # Home: погода + комнаты + транспорт + ноуты + календарь
+ globals.css # дизайн-токены: --surface-*, --data-*, --space-*, утилиты .num/.grain/...
+ api/
+ auth/ ha/ weather/ transport/
+ notes/ calendar/ tasks/ savings/
+ countdowns/ voice/
+
+components/
+ TopBar.tsx # часы, погода-чип, HA-статус (44px hit-zone), сенсор-чип
+ RoomTabs.tsx # переключатель Home/Зал/Кухня/Спальня
+ DeviceCard.tsx # лампы/тв/кондей/пуриф — accent по типу из DEVICE_ACCENT
+ TransportWidget.tsx # трамваи, цвета через --data-good/info/danger
+ WeatherAnimation.tsx # SVG-анимация по condition
+ NotesTab.tsx # стикеры (drag-to-trash, --data-* цвета)
+ CalendarTab.tsx # 3 модалки, у всех data-swipe-ignore
+ TimerHomeWidget.tsx, TimerModal.tsx, TimerWidget.tsx
+ Sidebar.tsx
+ VoiceOverlay.tsx # см. секцию выше
+ FocusCard.tsx # ⚠ написан, но НЕ используется на Home (см. ниже)
+ CountdownCard.tsx # ⚠ написан, но НЕ используется на Home
+```
+
+### Свайп между табами
+В `app/page.tsx` — handler на `` через `onPointerDown/Up`, ref `swipeStart`. Условия срабатывания: `|dx|>|dy|*1.6 && |dx|>90 && (dt<600 || |dx|>160)`. Bail-out если `e.target.closest('[data-swipe-ignore]')` — этот атрибут стоит на всех fixed-overlay (TimerModal, VoiceOverlay, модалки CalendarTab, weather day modal, draggable note).
+
+---
+
+## Что было сделано в прошлой сессии
+
+1. **CSS-рефакторинг** — `app/globals.css`: добавлены семантические токены `--data-cool/info/good/warm/hot/danger/rose/violet/mood` + их `-bg` варианты через `color-mix(in srgb, ...)` для тёмной и `.light` темы. `--space-1..6`, `--touch-min: 44px`, `--touch-comfy: 56px`. Утилиты: `.num`, `.num-display` (tabular-nums slashed-zero), `.grain` (SVG-noise через `::after`), `.hit-zone`, `.eyebrow`, `.focus-card`, `.divider`.
+2. **Tap-targets подняты до 44px** — TopBar (HA-dot обёрнут в hit-zone), NotesTab (плюс/удалить), CalendarTab (close/nav/list), TimerHomeWidget.
+3. **Иконки/цвета** через токены вместо хардкода — TransportWidget (route.color = var(--data-*)), DeviceCard (DEVICE_ACCENT по типу), TopBar-сенсоры.
+4. **Свайп между табами** — добавлен в `app/page.tsx`, c bail-out по `[data-swipe-ignore]`.
+5. **Forecast day buttons / WeatherDayModal** — увеличены тапы и шрифты.
+6. **API `/api/countdowns`** — GET/POST/PUT/DELETE, бэкенд `/data/tablet-countdowns.json`. Дефолтная запись: Токио 2026-10-15.
+7. **FocusCard.tsx + CountdownCard.tsx** написаны, но **с Home убраны** по фидбеку: пользователю не понравилось, что виджет погоды стал меньше трамваев и пропали `feelsLike` / `humidity` / `WeatherAnimation`. Сейчас на Home — старый weather-hero с display-цифрой 76px, фоновой `WeatherAnimation size=180` (opacity 0.14), inline `WeatherAnimation size=60`, и тремя `.eyebrow + .num` блоками снизу (Ощущается/Влажность/Ветер).
+
+### Что НЕ делать без явной просьбы
+- **Не возвращать FocusCard на Home** и **не показывать CountdownCard на Home** — пользователь явно отверг.
+- **Не делать виджеты Habits и Pulse** — пользователь сказал "пока что их не добавляем".
+
+### Что осталось как orphan-файлы (есть, но не подключено)
+- `components/FocusCard.tsx` — context-engine с приоритетами bill-due / event-upcoming / tram-imminent / morning-outfit / countdown / night / quiet. Можно использовать в будущем, но Home не место.
+- `components/CountdownCard.tsx` — ротация записей из `/api/countdowns` каждые 8с.
+
+---
+
+## Известные грабли
+
+- **`tsc --noEmit` ловит OOM** на дефолтном heap — всегда `NODE_OPTIONS="--max-old-space-size=4096"`.
+- **`lucide-react`**: нет экспорта `Tram`, есть `TramFront`. Грепать `node_modules/lucide-react/dist/lucide-react.d.ts` если сомневаешься.
+- **Git push с локальной машины**: ремоут уже c токеном, не сбрасывать (`git remote -v` покажет `https://@git.digital-home.site/...`). Если потерялся — взять с сервера: `ssh cosmo@192.168.31.60 'cat /opt/digital-home/smart-home-tablet/.git/config'`.
+- **Volume `/data`** в контейнере — это `/opt/digital-home/smart-home-tablet-data/` на хосте. JSON-сторадж (notes/timers/countdowns) переживает редеплой только потому, что лежит там.
+
+---
+
+## Последние коммиты сессии
+- `e328055` — большой CSS / tap-target / swipe / FocusCard / CountdownCard / countdowns API
+- `a97dd11` — revert Home: вернул weather-hero с анимацией и деталями, убрал CountdownCard с главной
+
+Оба задеплоены, прод 200 OK.
diff --git a/components/VoiceController.tsx b/components/VoiceController.tsx
index 16c6c69..a4036f2 100644
--- a/components/VoiceController.tsx
+++ b/components/VoiceController.tsx
@@ -1,21 +1,27 @@
'use client'
/**
- * Push-to-talk кнопка-микрофон. Тап → MicVAD ловит речь → автостоп по тишине →
- * /api/voice/stt → /api/voice/chat. Отправляет локальные voice-local события
- * для VoiceOverlay (wake/listening/command), финальный response приходит
- * через SSE с сервера.
+ * Голосовой контроллер.
*
- * Когда добавим wake-word (Шаг 3) — этот же код переиспользуется, только
- * стартовать VAD будет автоматически по детекту wake-слова.
+ * UX:
+ * - Idle: кнопка-микрофон (перечёркнут). Тап = «активировать ассистента» (нужен
+ * user gesture чтобы AudioContext стартанул).
+ * - Active: загружаются wake-модели (один раз) → запускается wake-word listener
+ * на постоянный фон. Кнопка горит фиолетовым, говорит «Космо».
+ * - Wake-word triggered → MicVAD стартует → onSpeechEnd → STT → chat → TTS.
+ * - Параллельно тап на кнопку = ручной trigger (как раньше) если wake не
+ * срабатывает или wake тренировка ещё слабая.
+ * - Tap во время Active → выключает wake и mic полностью.
*/
import { useEffect, useRef, useState } from 'react'
import { Mic, MicOff } from 'lucide-react'
+import { WakeWordDetector } from '@/lib/wake-word'
type Agent = 'cosmo' | 'lusya'
-type ControllerState = 'idle' | 'loading' | 'active' | 'busy' | 'error'
+type ControllerState = 'idle' | 'loading' | 'listening' | 'recording' | 'busy' | 'error'
-const AGENT: Agent = 'cosmo' // на этом этапе всегда Cosmo; Люся через wake-word на Шаге 3
+const AGENT: Agent = 'cosmo'
+const WAKE_THRESHOLD = 0.5
function emitLocal(event: string, agent: Agent, text?: string) {
window.dispatchEvent(
@@ -25,26 +31,23 @@ function emitLocal(event: string, agent: Agent, text?: string) {
)
}
-// Float32Array @ 16kHz → WAV blob (mono, 16-bit PCM).
function floatToWav(audio: Float32Array, sampleRate = 16000): Blob {
const numSamples = audio.length
const buffer = new ArrayBuffer(44 + numSamples * 2)
const view = new DataView(buffer)
- // RIFF header
writeStr(view, 0, 'RIFF')
view.setUint32(4, 36 + numSamples * 2, true)
writeStr(view, 8, 'WAVE')
writeStr(view, 12, 'fmt ')
- view.setUint32(16, 16, true) // fmt chunk size
- view.setUint16(20, 1, true) // PCM
- view.setUint16(22, 1, true) // channels
+ view.setUint32(16, 16, true)
+ view.setUint16(20, 1, true)
+ view.setUint16(22, 1, true)
view.setUint32(24, sampleRate, true)
- view.setUint32(28, sampleRate * 2, true) // byte rate
- view.setUint16(32, 2, true) // block align
- view.setUint16(34, 16, true) // bits per sample
+ view.setUint32(28, sampleRate * 2, true)
+ view.setUint16(32, 2, true)
+ view.setUint16(34, 16, true)
writeStr(view, 36, 'data')
view.setUint32(40, numSamples * 2, true)
- // PCM samples
let offset = 44
for (let i = 0; i < numSamples; i++, offset += 2) {
const s = Math.max(-1, Math.min(1, audio[i]))
@@ -59,23 +62,26 @@ function writeStr(view: DataView, offset: number, s: string) {
export default function VoiceController() {
const [state, setState] = useState('idle')
+ const wakeRef = useRef(null)
const vadRef = useRef(null)
const busyRef = useRef(false)
- // Cleanup on unmount
useEffect(() => {
return () => {
try { vadRef.current?.destroy?.() } catch {}
+ try { wakeRef.current?.stop?.() } catch {}
vadRef.current = null
+ wakeRef.current = null
}
}, [])
+ // Обрабатываем результат VAD-захвата фразы и шлём по pipeline.
const handleSpeechEnd = async (audio: Float32Array) => {
if (busyRef.current) return
- if (audio.length < 16000 * 0.4) return // <0.4с — мусор/эхо
+ if (audio.length < 16000 * 0.4) return
busyRef.current = true
setState('busy')
- emitLocal('listening', AGENT) // показываем что обрабатываем
+ emitLocal('listening', AGENT)
try {
const wav = floatToWav(audio, 16000)
@@ -91,45 +97,25 @@ export default function VoiceController() {
emitLocal('idle', AGENT)
return
}
-
- // Chat-эндпоинт сам эмитит command/response через voice-bus → SSE → orb.
const chatResp = await fetch('/api/voice/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: userText, agent: AGENT }),
})
if (!chatResp.ok) throw new Error(`chat ${chatResp.status}`)
- // Ответ уже зачитывается через SSE — здесь больше делать нечего.
} catch (e) {
console.error('[voice] pipeline error:', e)
emitLocal('error', AGENT, 'Не получилось')
} finally {
busyRef.current = false
- setState((s) => (s === 'busy' ? 'active' : s))
+ // После обработки — возвращаем wake-режим (если активен)
+ try { wakeRef.current?.resume?.() } catch {}
+ setState((s) => (s === 'busy' ? 'listening' : s))
}
}
- const start = async () => {
- if (state !== 'idle' && state !== 'error') return
- setState('loading')
-
- // 1. Сначала отдельно проверяем mic — иначе ошибка VAD маскирует разрешения.
- try {
- const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
- // VAD откроет свой stream; этот закрываем, мы только проверяли разрешение.
- stream.getTracks().forEach((t) => t.stop())
- } catch (e: any) {
- console.error('[voice] mic permission failed:', e?.name, e?.message)
- setState('error')
- const reason = e?.name === 'NotAllowedError' ? 'Нет доступа к микрофону'
- : e?.name === 'NotFoundError' ? 'Микрофон не найден'
- : 'Микрофон не открылся'
- emitLocal('error', AGENT, reason)
- return
- }
-
- // 2. Инициализируем VAD. Single-threaded WASM — без COOP/COEP headers
- // threaded режим всё равно не заработает, плюс не нужна SharedArrayBuffer.
+ // Создание VAD по запросу: либо после wake-детекта, либо после ручного тапа.
+ const startVAD = async () => {
try {
const { MicVAD } = await import('@ricky0123/vad-web')
const vad = await MicVAD.new({
@@ -151,7 +137,6 @@ export default function VoiceController() {
})
vadRef.current = vad
vad.start()
- setState('active')
} catch (e: any) {
console.error('[voice] VAD init failed:', e?.name, e?.message, e)
setState('error')
@@ -159,27 +144,110 @@ export default function VoiceController() {
}
}
- const stop = () => {
+ const onWakeDetected = async (score: number) => {
+ console.log(`[wake] cosmo score=${score.toFixed(3)}`)
+ if (busyRef.current) return
+ // Пауза wake чтобы VAD-инициализация и команда не триггерили wake снова на эхе.
+ try { wakeRef.current?.pause?.() } catch {}
+ setState('recording')
+ emitLocal('wake', AGENT)
+ // Если VAD ещё не готов — создаём; иначе reset+start.
+ if (!vadRef.current) {
+ await startVAD()
+ } else {
+ try { vadRef.current.start?.() } catch {}
+ }
+ }
+
+ const start = async () => {
+ if (state !== 'idle' && state !== 'error') return
+ setState('loading')
+
+ // 1. Запрос разрешения на микрофон отдельно
+ try {
+ const probe = await navigator.mediaDevices.getUserMedia({ audio: true })
+ probe.getTracks().forEach((t) => t.stop())
+ } catch (e: any) {
+ console.error('[voice] mic permission failed:', e?.name, e?.message)
+ setState('error')
+ emitLocal('error', AGENT, e?.name === 'NotAllowedError' ? 'Нет доступа к микрофону' : 'Микрофон не открылся')
+ return
+ }
+
+ // 2. Запуск wake-word
+ try {
+ const wake = new WakeWordDetector({
+ modelPath: '/wake/cosmo.onnx',
+ threshold: WAKE_THRESHOLD,
+ onWake: (s) => onWakeDetected(s),
+ // onScore: (s) => { if (s > 0.1) console.log('[wake] score', s.toFixed(3)) },
+ onError: (e) => console.warn('[wake] error', e),
+ })
+ await wake.start()
+ wakeRef.current = wake
+ setState('listening')
+ } catch (e: any) {
+ console.error('[wake] init failed:', e)
+ setState('error')
+ emitLocal('error', AGENT, `Wake: ${e?.message?.slice(0, 60) || 'init'}`)
+ }
+ }
+
+ const stop = async () => {
try { vadRef.current?.pause?.() } catch {}
try { vadRef.current?.destroy?.() } catch {}
vadRef.current = null
+ try { await wakeRef.current?.stop?.() } catch {}
+ wakeRef.current = null
setState('idle')
emitLocal('idle', AGENT)
}
- const onTap = () => {
- if (state === 'idle' || state === 'error') start()
- else stop()
+ // Долгий тап = ручной триггер (как раньше push-to-talk). Короткий — toggle вкл/выкл.
+ // Для простоты сейчас: короткий тап в idle = активация; короткий тап в active = выкл.
+ const onTap = async () => {
+ if (state === 'idle' || state === 'error') {
+ await start()
+ } else if (state === 'listening') {
+ // ручной trigger — эмулируем wake-event
+ onWakeDetected(1.0)
+ } else {
+ await stop()
+ }
}
- const isActive = state === 'active' || state === 'busy'
+ const onLongPress = async () => {
+ // Длинный тап всегда выключает (на случай если случайно зашли в плохое состояние)
+ await stop()
+ }
+
+ // primitive long-press detection
+ const pressTimer = useRef | null>(null)
+ const longPressed = useRef(false)
+ const onPointerDown = () => {
+ longPressed.current = false
+ pressTimer.current = setTimeout(() => {
+ longPressed.current = true
+ onLongPress()
+ }, 700)
+ }
+ const onPointerUp = () => {
+ if (pressTimer.current) clearTimeout(pressTimer.current)
+ pressTimer.current = null
+ if (!longPressed.current) onTap()
+ }
+
+ const isActive = state === 'listening' || state === 'recording' || state === 'busy'
const isLoading = state === 'loading'
return (