chore(voice): security, cleanup, resilience
All checks were successful
Deploy / deploy (push) Successful in 1m47s

Безопасность:
- Rate-limit на /api/voice/chat (20/мин per cookie/IP, env VOICE_RATE_LIMIT).
  Защищает от случайных циклов и утечки PIN.
- Усечение user prompt'а до 4000 символов в /api/voice/chat.
- Tool-loop защита от циклов: если LLM дважды просит тот же tool с теми же
  args — прерываем (раньше мог уйти в бесконечный цикл при tool error'ах).

Чистка кода:
- lib/debug.ts — vlog/vwarn/verror гейтят браузерные логи за
  NEXT_PUBLIC_VOICE_DEBUG=1 (или localStorage 'voice-debug=1').
  Серверные console.log оставлены — полезны в Docker logs.
- lib/audio-wav.ts — вынесена дублированная floatToWav из VoiceController.
- Удалены orphan компоненты FocusCard.tsx и CountdownCard.tsx
  (не подключены, отвергнуты по UX-фидбеку).

Resilience:
- WakeWordDetector: drop-on-busy в onChunk — на медленных устройствах
  (Android, бюджетный CPU) backlog inference больше не копится.
- voice-history fallback на /tmp/voice-history если /data не примонтирован
  (локальная разработка / нестандартная конфигурация).
This commit is contained in:
Cosmo
2026-04-27 12:44:18 +00:00
parent 3211d62198
commit 05b300d472
9 changed files with 169 additions and 683 deletions

View File

@@ -1,3 +1,5 @@
import { vlog, verror } from './debug'
/**
* openWakeWord pipeline в браузере.
*
@@ -88,6 +90,7 @@ export class WakeWordDetector {
private embBuf: Float32Array[] = [] // массив 96-D векторов
private cooldownChunks = 0
private running = false
private processing = false // ONNX inference в полёте — drop-on-busy
constructor(options: WakeWordOptions) {
this.opts = {
@@ -102,10 +105,10 @@ export class WakeWordDetector {
async start(externalStream?: MediaStream): Promise<void> {
if (this.running) return
console.log('[wake] start: loading ort + models')
vlog('[wake] start: loading ort + models')
const t0 = performance.now()
const ort = await getOrt()
console.log(`[wake] ort ready in ${(performance.now() - t0).toFixed(0)}ms`)
vlog(`[wake] ort ready in ${(performance.now() - t0).toFixed(0)}ms`)
// 1. Загружаем модели параллельно (до user gesture, чтобы AudioContext не висел)
const [mel, emb, cls] = await Promise.all([
@@ -119,18 +122,14 @@ export class WakeWordDetector {
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]
console.log(`[wake] models loaded in ${(performance.now() - t0).toFixed(0)}ms`,
{ mel: { in: this.melInName, out: this.melOutName },
emb: { in: this.embInName, out: this.embOutName },
cls: { in: this.clsInName, out: this.clsOutName } })
vlog(`[wake] models loaded in ${(performance.now() - t0).toFixed(0)}ms`)
// 2. Audio context @ 16kHz (если браузер не уважит — обработаем на стороне)
this.ctx = new AudioContext({ sampleRate: 16000 })
if (this.ctx.state === 'suspended') await this.ctx.resume()
console.log(`[wake] AudioContext sampleRate=${this.ctx.sampleRate} state=${this.ctx.state}`)
vlog(`[wake] AudioContext sampleRate=${this.ctx.sampleRate} state=${this.ctx.state}`)
if (this.ctx.sampleRate !== 16000) {
console.warn(`[wake] AudioContext sampleRate=${this.ctx.sampleRate}, ожидается 16000 — wake-word скорее всего не сработает`)
this.opts.onError?.(new Error(`AudioContext sampleRate=${this.ctx.sampleRate}`))
this.opts.onError?.(new Error(`AudioContext sampleRate=${this.ctx.sampleRate}, нужен 16000`))
}
// 3. Mic stream
@@ -144,7 +143,7 @@ export class WakeWordDetector {
this.worklet = new AudioWorkletNode(this.ctx, 'wake-capture')
let chunkCount = 0
this.worklet.port.onmessage = (e) => {
if (chunkCount === 0) console.log('[wake] first audio chunk received')
if (chunkCount === 0) vlog('[wake] first audio chunk received')
chunkCount++
this.onChunk(e.data as Float32Array)
}
@@ -152,7 +151,7 @@ export class WakeWordDetector {
// Worklet не подключается к destination → не звучит в колонках.
this.running = true
console.log('[wake] running')
vlog('[wake] running')
}
async stop(): Promise<void> {
@@ -180,6 +179,10 @@ export class WakeWordDetector {
private async onChunk(chunk: Float32Array) {
if (!this.running || !this.mel || !this.emb || !this.cls) return
if (this.cooldownChunks > 0) { this.cooldownChunks--; return }
// Drop-on-busy: если предыдущий chunk ещё считается, пропускаем новый.
// Иначе на медленных устройствах backlog растёт и реакция запаздывает.
if (this.processing) return
this.processing = true
const ort = await getOrt()
@@ -246,8 +249,10 @@ export class WakeWordDetector {
}
}
} catch (e) {
console.error('[wake-word] chunk error:', e)
verror('[wake-word] chunk error:', e)
this.opts.onError?.(e as Error)
} finally {
this.processing = false
}
}
}