feat(voice): wake-word «Космо» в браузере (Шаг 3)
All checks were successful
Deploy / deploy (push) Successful in 6m33s
All checks were successful
Deploy / deploy (push) Successful in 6m33s
openWakeWord pipeline на onnxruntime-web прямо на планшете. Цепочка: mic (16kHz, AudioWorklet) → melspectrogram.onnx → embedding_model.onnx (sliding 76-frame window, stride 8) → cosmo.onnx → score 0..1. Триггер при score≥0.5 → запускается тот же VAD-flow что и push-to-talk. - public/wake/ — cosmo.onnx (custom-trained на голос Даниила) + melspectrogram.onnx + embedding_model.onnx (~2.9MB вместе). - lib/wake-word.ts — WakeWordDetector class. ort грузится через <script src=/vad/ort.wasm.min.js> на клиенте — обход проблемы next-swc с парсингом import.meta.url в onnxruntime-web .mjs билдах. - VoiceController: тап = активация (нужен для AudioContext user-gesture), далее непрерывное слушание wake-word; на детект → MicVAD флоу. Долгий тап = выкл. Ручной тап остаётся как fallback. После деплоя Python-агент на .103 не нужен — можно архивировать home-voice-assistant. На .103 остаётся только ElevenLabs прокси :8888.
This commit is contained in:
176
HANDOFF.md
Normal file
176
HANDOFF.md
Normal file
@@ -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
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
<VoiceOverlay />
|
||||||
|
```
|
||||||
|
|
||||||
|
### Файлы голосового стека (в этом репо)
|
||||||
|
```
|
||||||
|
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 на `<main>` через `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://<token>@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.
|
||||||
@@ -1,21 +1,27 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Push-to-talk кнопка-микрофон. Тап → MicVAD ловит речь → автостоп по тишине →
|
* Голосовой контроллер.
|
||||||
* /api/voice/stt → /api/voice/chat. Отправляет локальные voice-local события
|
|
||||||
* для VoiceOverlay (wake/listening/command), финальный response приходит
|
|
||||||
* через SSE с сервера.
|
|
||||||
*
|
*
|
||||||
* Когда добавим wake-word (Шаг 3) — этот же код переиспользуется, только
|
* UX:
|
||||||
* стартовать VAD будет автоматически по детекту wake-слова.
|
* - 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 { useEffect, useRef, useState } from 'react'
|
||||||
import { Mic, MicOff } from 'lucide-react'
|
import { Mic, MicOff } from 'lucide-react'
|
||||||
|
import { WakeWordDetector } from '@/lib/wake-word'
|
||||||
|
|
||||||
type Agent = 'cosmo' | 'lusya'
|
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) {
|
function emitLocal(event: string, agent: Agent, text?: string) {
|
||||||
window.dispatchEvent(
|
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 {
|
function floatToWav(audio: Float32Array, sampleRate = 16000): Blob {
|
||||||
const numSamples = audio.length
|
const numSamples = audio.length
|
||||||
const buffer = new ArrayBuffer(44 + numSamples * 2)
|
const buffer = new ArrayBuffer(44 + numSamples * 2)
|
||||||
const view = new DataView(buffer)
|
const view = new DataView(buffer)
|
||||||
// RIFF header
|
|
||||||
writeStr(view, 0, 'RIFF')
|
writeStr(view, 0, 'RIFF')
|
||||||
view.setUint32(4, 36 + numSamples * 2, true)
|
view.setUint32(4, 36 + numSamples * 2, true)
|
||||||
writeStr(view, 8, 'WAVE')
|
writeStr(view, 8, 'WAVE')
|
||||||
writeStr(view, 12, 'fmt ')
|
writeStr(view, 12, 'fmt ')
|
||||||
view.setUint32(16, 16, true) // fmt chunk size
|
view.setUint32(16, 16, true)
|
||||||
view.setUint16(20, 1, true) // PCM
|
view.setUint16(20, 1, true)
|
||||||
view.setUint16(22, 1, true) // channels
|
view.setUint16(22, 1, true)
|
||||||
view.setUint32(24, sampleRate, true)
|
view.setUint32(24, sampleRate, true)
|
||||||
view.setUint32(28, sampleRate * 2, true) // byte rate
|
view.setUint32(28, sampleRate * 2, true)
|
||||||
view.setUint16(32, 2, true) // block align
|
view.setUint16(32, 2, true)
|
||||||
view.setUint16(34, 16, true) // bits per sample
|
view.setUint16(34, 16, true)
|
||||||
writeStr(view, 36, 'data')
|
writeStr(view, 36, 'data')
|
||||||
view.setUint32(40, numSamples * 2, true)
|
view.setUint32(40, numSamples * 2, true)
|
||||||
// PCM samples
|
|
||||||
let offset = 44
|
let offset = 44
|
||||||
for (let i = 0; i < numSamples; i++, offset += 2) {
|
for (let i = 0; i < numSamples; i++, offset += 2) {
|
||||||
const s = Math.max(-1, Math.min(1, audio[i]))
|
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() {
|
export default function VoiceController() {
|
||||||
const [state, setState] = useState<ControllerState>('idle')
|
const [state, setState] = useState<ControllerState>('idle')
|
||||||
|
const wakeRef = useRef<WakeWordDetector | null>(null)
|
||||||
const vadRef = useRef<any>(null)
|
const vadRef = useRef<any>(null)
|
||||||
const busyRef = useRef(false)
|
const busyRef = useRef(false)
|
||||||
|
|
||||||
// Cleanup on unmount
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
try { vadRef.current?.destroy?.() } catch {}
|
try { vadRef.current?.destroy?.() } catch {}
|
||||||
|
try { wakeRef.current?.stop?.() } catch {}
|
||||||
vadRef.current = null
|
vadRef.current = null
|
||||||
|
wakeRef.current = null
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// Обрабатываем результат VAD-захвата фразы и шлём по pipeline.
|
||||||
const handleSpeechEnd = async (audio: Float32Array) => {
|
const handleSpeechEnd = async (audio: Float32Array) => {
|
||||||
if (busyRef.current) return
|
if (busyRef.current) return
|
||||||
if (audio.length < 16000 * 0.4) return // <0.4с — мусор/эхо
|
if (audio.length < 16000 * 0.4) return
|
||||||
busyRef.current = true
|
busyRef.current = true
|
||||||
setState('busy')
|
setState('busy')
|
||||||
emitLocal('listening', AGENT) // показываем что обрабатываем
|
emitLocal('listening', AGENT)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const wav = floatToWav(audio, 16000)
|
const wav = floatToWav(audio, 16000)
|
||||||
@@ -91,45 +97,25 @@ export default function VoiceController() {
|
|||||||
emitLocal('idle', AGENT)
|
emitLocal('idle', AGENT)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Chat-эндпоинт сам эмитит command/response через voice-bus → SSE → orb.
|
|
||||||
const chatResp = await fetch('/api/voice/chat', {
|
const chatResp = await fetch('/api/voice/chat', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ text: userText, agent: AGENT }),
|
body: JSON.stringify({ text: userText, agent: AGENT }),
|
||||||
})
|
})
|
||||||
if (!chatResp.ok) throw new Error(`chat ${chatResp.status}`)
|
if (!chatResp.ok) throw new Error(`chat ${chatResp.status}`)
|
||||||
// Ответ уже зачитывается через SSE — здесь больше делать нечего.
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('[voice] pipeline error:', e)
|
console.error('[voice] pipeline error:', e)
|
||||||
emitLocal('error', AGENT, 'Не получилось')
|
emitLocal('error', AGENT, 'Не получилось')
|
||||||
} finally {
|
} finally {
|
||||||
busyRef.current = false
|
busyRef.current = false
|
||||||
setState((s) => (s === 'busy' ? 'active' : s))
|
// После обработки — возвращаем wake-режим (если активен)
|
||||||
|
try { wakeRef.current?.resume?.() } catch {}
|
||||||
|
setState((s) => (s === 'busy' ? 'listening' : s))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const start = async () => {
|
// Создание VAD по запросу: либо после wake-детекта, либо после ручного тапа.
|
||||||
if (state !== 'idle' && state !== 'error') return
|
const startVAD = async () => {
|
||||||
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.
|
|
||||||
try {
|
try {
|
||||||
const { MicVAD } = await import('@ricky0123/vad-web')
|
const { MicVAD } = await import('@ricky0123/vad-web')
|
||||||
const vad = await MicVAD.new({
|
const vad = await MicVAD.new({
|
||||||
@@ -151,7 +137,6 @@ export default function VoiceController() {
|
|||||||
})
|
})
|
||||||
vadRef.current = vad
|
vadRef.current = vad
|
||||||
vad.start()
|
vad.start()
|
||||||
setState('active')
|
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
console.error('[voice] VAD init failed:', e?.name, e?.message, e)
|
console.error('[voice] VAD init failed:', e?.name, e?.message, e)
|
||||||
setState('error')
|
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?.pause?.() } catch {}
|
||||||
try { vadRef.current?.destroy?.() } catch {}
|
try { vadRef.current?.destroy?.() } catch {}
|
||||||
vadRef.current = null
|
vadRef.current = null
|
||||||
|
try { await wakeRef.current?.stop?.() } catch {}
|
||||||
|
wakeRef.current = null
|
||||||
setState('idle')
|
setState('idle')
|
||||||
emitLocal('idle', AGENT)
|
emitLocal('idle', AGENT)
|
||||||
}
|
}
|
||||||
|
|
||||||
const onTap = () => {
|
// Долгий тап = ручной триггер (как раньше push-to-talk). Короткий — toggle вкл/выкл.
|
||||||
if (state === 'idle' || state === 'error') start()
|
// Для простоты сейчас: короткий тап в idle = активация; короткий тап в active = выкл.
|
||||||
else stop()
|
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<ReturnType<typeof setTimeout> | 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'
|
const isLoading = state === 'loading'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
onClick={onTap}
|
onPointerDown={onPointerDown}
|
||||||
|
onPointerUp={onPointerUp}
|
||||||
|
onPointerCancel={() => { if (pressTimer.current) clearTimeout(pressTimer.current); pressTimer.current = null }}
|
||||||
data-swipe-ignore
|
data-swipe-ignore
|
||||||
aria-label={isActive ? 'Выключить микрофон' : 'Включить микрофон'}
|
aria-label={isActive ? 'Выключить ассистента' : 'Активировать ассистента'}
|
||||||
|
title={isActive ? 'Скажи «Космо» · долгий тап = выкл' : 'Тап = активировать'}
|
||||||
style={{
|
style={{
|
||||||
position: 'fixed',
|
position: 'fixed',
|
||||||
right: 24,
|
right: 24,
|
||||||
|
|||||||
240
lib/wake-word.ts
Normal file
240
lib/wake-word.ts
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
/**
|
||||||
|
* openWakeWord pipeline в браузере.
|
||||||
|
*
|
||||||
|
* Цепочка: 1280-семпловый audio chunk @ 16kHz
|
||||||
|
* → melspectrogram.onnx → ~8 новых mel-фреймов
|
||||||
|
* → embedding_model.onnx (sliding 76-frame window, stride 8) → 96-D embedding
|
||||||
|
* → cosmo.onnx (классификатор по последним 16 embedding'ам) → score 0..1
|
||||||
|
* → score > threshold ⇒ onWake()
|
||||||
|
*
|
||||||
|
* Audio capture через AudioWorklet (`/wake/wake-capture-worklet.js`).
|
||||||
|
* ONNX inference на main thread через onnxruntime-web (WASM, single-thread).
|
||||||
|
*/
|
||||||
|
// onnxruntime-web .mjs builds используют top-level import.meta.url, что
|
||||||
|
// не парсится next-swc. Загружаем CJS-сборку через <script> тег → window.ort.
|
||||||
|
// Это обходит webpack полностью.
|
||||||
|
declare global { interface Window { ort?: any } }
|
||||||
|
|
||||||
|
const ORT_SCRIPT_URL = '/vad/ort.wasm.min.js'
|
||||||
|
let _ortLoadPromise: Promise<any> | null = null
|
||||||
|
|
||||||
|
async function getOrt(): Promise<any> {
|
||||||
|
if (typeof window === 'undefined') throw new Error('wake-word is client-only')
|
||||||
|
if (window.ort) return window.ort
|
||||||
|
if (_ortLoadPromise) return _ortLoadPromise
|
||||||
|
_ortLoadPromise = new Promise((resolve, reject) => {
|
||||||
|
const existing = document.querySelector(`script[src="${ORT_SCRIPT_URL}"]`)
|
||||||
|
if (existing) {
|
||||||
|
existing.addEventListener('load', () => resolve(window.ort))
|
||||||
|
existing.addEventListener('error', () => reject(new Error('ort script failed')))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const s = document.createElement('script')
|
||||||
|
s.src = ORT_SCRIPT_URL
|
||||||
|
s.async = true
|
||||||
|
s.onload = () => {
|
||||||
|
const ort = window.ort
|
||||||
|
if (!ort) return reject(new Error('ort not defined after load'))
|
||||||
|
ort.env.wasm.numThreads = 1
|
||||||
|
ort.env.wasm.simd = true
|
||||||
|
ort.env.wasm.wasmPaths = '/vad/'
|
||||||
|
resolve(ort)
|
||||||
|
}
|
||||||
|
s.onerror = () => reject(new Error('ort script load error'))
|
||||||
|
document.head.appendChild(s)
|
||||||
|
})
|
||||||
|
return _ortLoadPromise
|
||||||
|
}
|
||||||
|
|
||||||
|
// Параметры pipeline (как в openwakeword)
|
||||||
|
const AUDIO_CHUNK = 1280 // 80мс @ 16kHz
|
||||||
|
const MEL_BINS = 32
|
||||||
|
const MEL_WINDOW = 76 // фреймов на embedding
|
||||||
|
const MEL_STRIDE = 8 // шаг в фреймах
|
||||||
|
const EMB_DIM = 96
|
||||||
|
const EMB_WINDOW = 16 // последние 16 embedding'ов идут в classifier
|
||||||
|
|
||||||
|
export interface WakeWordOptions {
|
||||||
|
modelPath: string // путь к classifier (cosmo.onnx)
|
||||||
|
melPath?: string // /wake/melspectrogram.onnx
|
||||||
|
embPath?: string // /wake/embedding_model.onnx
|
||||||
|
workletPath?: string // /wake/wake-capture-worklet.js
|
||||||
|
threshold?: number // 0..1, по умолчанию 0.5
|
||||||
|
cooldownMs?: number // dead-time после успешного wake, 2000ms по умолчанию
|
||||||
|
onWake: (score: number) => void
|
||||||
|
onScore?: (score: number) => void // опц. для отладки
|
||||||
|
onError?: (e: Error) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export class WakeWordDetector {
|
||||||
|
private opts: Required<Omit<WakeWordOptions, 'onScore' | 'onError'>> & Pick<WakeWordOptions, 'onScore' | 'onError'>
|
||||||
|
private ctx: AudioContext | null = null
|
||||||
|
private stream: MediaStream | null = null
|
||||||
|
private source: MediaStreamAudioSourceNode | null = null
|
||||||
|
private worklet: AudioWorkletNode | null = null
|
||||||
|
private mel: any = null
|
||||||
|
private emb: any = null
|
||||||
|
private cls: any = null
|
||||||
|
// I/O имена тензоров — тащим из session.input/outputNames.
|
||||||
|
private melInName = ''
|
||||||
|
private melOutName = ''
|
||||||
|
private embInName = ''
|
||||||
|
private embOutName = ''
|
||||||
|
private clsInName = ''
|
||||||
|
private clsOutName = ''
|
||||||
|
// Кольцевые буферы
|
||||||
|
private melBuf: Float32Array = new Float32Array(0) // flatten [T*32]
|
||||||
|
private melFrames = 0
|
||||||
|
private embBuf: Float32Array[] = [] // массив 96-D векторов
|
||||||
|
private cooldownChunks = 0
|
||||||
|
private running = false
|
||||||
|
|
||||||
|
constructor(options: WakeWordOptions) {
|
||||||
|
this.opts = {
|
||||||
|
melPath: '/wake/melspectrogram.onnx',
|
||||||
|
embPath: '/wake/embedding_model.onnx',
|
||||||
|
workletPath: '/wake/wake-capture-worklet.js',
|
||||||
|
threshold: 0.5,
|
||||||
|
cooldownMs: 2000,
|
||||||
|
...options,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async start(externalStream?: MediaStream): Promise<void> {
|
||||||
|
if (this.running) return
|
||||||
|
const ort = await getOrt()
|
||||||
|
|
||||||
|
// 1. Загружаем модели параллельно (до user gesture, чтобы AudioContext не висел)
|
||||||
|
const [mel, emb, cls] = await Promise.all([
|
||||||
|
ort.InferenceSession.create(this.opts.melPath, { executionProviders: ['wasm'] }),
|
||||||
|
ort.InferenceSession.create(this.opts.embPath, { executionProviders: ['wasm'] }),
|
||||||
|
ort.InferenceSession.create(this.opts.modelPath, { executionProviders: ['wasm'] }),
|
||||||
|
])
|
||||||
|
this.mel = mel
|
||||||
|
this.emb = emb
|
||||||
|
this.cls = cls
|
||||||
|
this.melInName = mel.inputNames[0]; this.melOutName = mel.outputNames[0]
|
||||||
|
this.embInName = emb.inputNames[0]; this.embOutName = emb.outputNames[0]
|
||||||
|
this.clsInName = cls.inputNames[0]; this.clsOutName = cls.outputNames[0]
|
||||||
|
|
||||||
|
// 2. Audio context @ 16kHz (если браузер не уважит — обработаем на стороне)
|
||||||
|
this.ctx = new AudioContext({ sampleRate: 16000 })
|
||||||
|
if (this.ctx.state === 'suspended') await this.ctx.resume()
|
||||||
|
if (this.ctx.sampleRate !== 16000) {
|
||||||
|
// На некоторых платформах sampleRate может не получиться. Не валим — ниже даунсэмпл-ниже не делаем,
|
||||||
|
// openWakeWord не выдержит другую частоту. Сообщим в onError, но попробуем работать.
|
||||||
|
this.opts.onError?.(new Error(`AudioContext sampleRate=${this.ctx.sampleRate}, нужен 16000`))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Mic stream
|
||||||
|
this.stream = externalStream ?? await navigator.mediaDevices.getUserMedia({
|
||||||
|
audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: false },
|
||||||
|
})
|
||||||
|
|
||||||
|
// 4. AudioWorklet
|
||||||
|
await this.ctx.audioWorklet.addModule(this.opts.workletPath)
|
||||||
|
this.source = this.ctx.createMediaStreamSource(this.stream)
|
||||||
|
this.worklet = new AudioWorkletNode(this.ctx, 'wake-capture')
|
||||||
|
this.worklet.port.onmessage = (e) => this.onChunk(e.data as Float32Array)
|
||||||
|
this.source.connect(this.worklet)
|
||||||
|
// Worklet не подключается к destination → не звучит в колонках.
|
||||||
|
|
||||||
|
this.running = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async stop(): Promise<void> {
|
||||||
|
this.running = false
|
||||||
|
try { this.worklet?.disconnect() } catch {}
|
||||||
|
try { this.source?.disconnect() } catch {}
|
||||||
|
try { this.stream?.getTracks().forEach((t) => t.stop()) } catch {}
|
||||||
|
try { await this.ctx?.close() } catch {}
|
||||||
|
this.worklet = null; this.source = null; this.stream = null; this.ctx = null
|
||||||
|
this.melBuf = new Float32Array(0); this.melFrames = 0; this.embBuf = []
|
||||||
|
this.cooldownChunks = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/** На время записи команды — отключаем wake-обработку, не освобождая ресурсы. */
|
||||||
|
pause() { this.running = false }
|
||||||
|
resume() {
|
||||||
|
if (this.mel && this.emb && this.cls && this.ctx) {
|
||||||
|
this.running = true
|
||||||
|
// Сбрасываем буферы — иначе хвост старого аудио вызовет ложный wake.
|
||||||
|
this.melBuf = new Float32Array(0); this.melFrames = 0; this.embBuf = []
|
||||||
|
this.cooldownChunks = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async onChunk(chunk: Float32Array) {
|
||||||
|
if (!this.running || !this.mel || !this.emb || !this.cls) return
|
||||||
|
if (this.cooldownChunks > 0) { this.cooldownChunks--; return }
|
||||||
|
|
||||||
|
const ort = await getOrt()
|
||||||
|
|
||||||
|
// openWakeWord ожидает float32 в range int16 (≈ ×32768)
|
||||||
|
const audio = new Float32Array(AUDIO_CHUNK)
|
||||||
|
for (let i = 0; i < AUDIO_CHUNK; i++) audio[i] = chunk[i] * 32768
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Mel-spectrogram
|
||||||
|
const melTensor = new ort.Tensor('float32', audio, [1, AUDIO_CHUNK])
|
||||||
|
const melOut = await this.mel.run({ [this.melInName]: melTensor })
|
||||||
|
const melData = melOut[this.melOutName].data as Float32Array
|
||||||
|
const melDims = melOut[this.melOutName].dims as readonly number[]
|
||||||
|
// Ожидается [1, T, 32] (или [1, 1, T, 32] / [T, 32]). Извлекаем T фреймов по 32 бина.
|
||||||
|
const newFrames = melDims.length === 4 ? melDims[2] : melDims.length === 3 ? melDims[1] : melDims[0]
|
||||||
|
const expected = newFrames * MEL_BINS
|
||||||
|
if (melData.length < expected) return
|
||||||
|
// Скейлинг как в openwakeword: x/10 + 2
|
||||||
|
const scaled = new Float32Array(expected)
|
||||||
|
for (let i = 0; i < expected; i++) scaled[i] = melData[i] / 10 + 2
|
||||||
|
|
||||||
|
// Append к mel-буферу (flatten)
|
||||||
|
const merged = new Float32Array(this.melBuf.length + scaled.length)
|
||||||
|
merged.set(this.melBuf); merged.set(scaled, this.melBuf.length)
|
||||||
|
this.melBuf = merged
|
||||||
|
this.melFrames += newFrames
|
||||||
|
|
||||||
|
// 2. Sliding embedding — пока хватает на одно окно, считаем и сдвигаем
|
||||||
|
while (this.melFrames >= MEL_WINDOW) {
|
||||||
|
const window = this.melBuf.subarray(0, MEL_WINDOW * MEL_BINS)
|
||||||
|
// Embedding model: вход [1, 76, 32, 1]
|
||||||
|
const embInput = new Float32Array(MEL_WINDOW * MEL_BINS)
|
||||||
|
embInput.set(window)
|
||||||
|
const embTensor = new (ort as any).Tensor('float32', embInput, [1, MEL_WINDOW, MEL_BINS, 1])
|
||||||
|
const embOut = await this.emb.run({ [this.embInName]: embTensor })
|
||||||
|
const embData = embOut[this.embOutName].data as Float32Array
|
||||||
|
// embedding ожидается длины 96 (последние EMB_DIM)
|
||||||
|
const e = new Float32Array(EMB_DIM)
|
||||||
|
e.set(embData.slice(-EMB_DIM))
|
||||||
|
this.embBuf.push(e)
|
||||||
|
if (this.embBuf.length > EMB_WINDOW + 4) this.embBuf.shift()
|
||||||
|
|
||||||
|
// Сдвигаем mel-буфер на MEL_STRIDE фреймов
|
||||||
|
this.melBuf = this.melBuf.slice(MEL_STRIDE * MEL_BINS)
|
||||||
|
this.melFrames -= MEL_STRIDE
|
||||||
|
|
||||||
|
// 3. Classifier
|
||||||
|
if (this.embBuf.length >= EMB_WINDOW) {
|
||||||
|
const last = this.embBuf.slice(-EMB_WINDOW)
|
||||||
|
const flat = new Float32Array(EMB_WINDOW * EMB_DIM)
|
||||||
|
for (let i = 0; i < EMB_WINDOW; i++) flat.set(last[i], i * EMB_DIM)
|
||||||
|
const clsTensor = new (ort as any).Tensor('float32', flat, [1, EMB_WINDOW, EMB_DIM])
|
||||||
|
const clsOut = await this.cls.run({ [this.clsInName]: clsTensor })
|
||||||
|
const score = (clsOut[this.clsOutName].data as Float32Array)[0]
|
||||||
|
this.opts.onScore?.(score)
|
||||||
|
if (score >= this.opts.threshold) {
|
||||||
|
const cooldownChunks = Math.ceil(this.opts.cooldownMs / 80)
|
||||||
|
this.cooldownChunks = cooldownChunks
|
||||||
|
this.embBuf = [] // сброс — не зацикливаем wake
|
||||||
|
this.melBuf = new Float32Array(0); this.melFrames = 0
|
||||||
|
this.opts.onWake(score)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[wake-word] chunk error:', e)
|
||||||
|
this.opts.onError?.(e as Error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
998
package-lock.json
generated
998
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -28,6 +28,7 @@
|
|||||||
"@types/react": "^18",
|
"@types/react": "^18",
|
||||||
"@types/react-dom": "^18",
|
"@types/react-dom": "^18",
|
||||||
"autoprefixer": "^10.0.1",
|
"autoprefixer": "^10.0.1",
|
||||||
|
"null-loader": "^4.0.1",
|
||||||
"postcss": "^8",
|
"postcss": "^8",
|
||||||
"tailwindcss": "^3.4.1",
|
"tailwindcss": "^3.4.1",
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
|
|||||||
8
public/vad/ort.wasm.min.js
vendored
Normal file
8
public/vad/ort.wasm.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
BIN
public/wake/cosmo.onnx
Normal file
BIN
public/wake/cosmo.onnx
Normal file
Binary file not shown.
BIN
public/wake/embedding_model.onnx
Normal file
BIN
public/wake/embedding_model.onnx
Normal file
Binary file not shown.
BIN
public/wake/melspectrogram.onnx
Normal file
BIN
public/wake/melspectrogram.onnx
Normal file
Binary file not shown.
23
public/wake/wake-capture-worklet.js
Normal file
23
public/wake/wake-capture-worklet.js
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
// AudioWorklet, который буферизует входной поток и шлёт чанки по 1280 семплов
|
||||||
|
// (80 мс при 16kHz) в main thread. Любая входная длина допустима — буферизуется.
|
||||||
|
class WakeCaptureProcessor extends AudioWorkletProcessor {
|
||||||
|
constructor() {
|
||||||
|
super()
|
||||||
|
this.buf = new Float32Array(0)
|
||||||
|
this.target = 1280
|
||||||
|
}
|
||||||
|
process(inputs) {
|
||||||
|
const ch = inputs[0] && inputs[0][0]
|
||||||
|
if (!ch || ch.length === 0) return true
|
||||||
|
const merged = new Float32Array(this.buf.length + ch.length)
|
||||||
|
merged.set(this.buf)
|
||||||
|
merged.set(ch, this.buf.length)
|
||||||
|
this.buf = merged
|
||||||
|
while (this.buf.length >= this.target) {
|
||||||
|
this.port.postMessage(this.buf.slice(0, this.target))
|
||||||
|
this.buf = this.buf.slice(this.target)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
registerProcessor('wake-capture', WakeCaptureProcessor)
|
||||||
Reference in New Issue
Block a user