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

@@ -16,6 +16,8 @@
import { useEffect, useRef, useState } from 'react'
import { Mic, MicOff } from 'lucide-react'
import { WakeWordDetector } from '@/lib/wake-word'
import { floatToWav } from '@/lib/audio-wav'
import { vlog, vwarn, verror } from '@/lib/debug'
type Agent = 'cosmo' | 'lusya'
type ControllerState = 'idle' | 'loading' | 'listening' | 'recording' | 'busy' | 'error'
@@ -31,35 +33,6 @@ function emitLocal(event: string, agent: Agent, text?: string) {
)
}
function floatToWav(audio: Float32Array, sampleRate = 16000): Blob {
const numSamples = audio.length
const buffer = new ArrayBuffer(44 + numSamples * 2)
const view = new DataView(buffer)
writeStr(view, 0, 'RIFF')
view.setUint32(4, 36 + numSamples * 2, true)
writeStr(view, 8, 'WAVE')
writeStr(view, 12, 'fmt ')
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)
view.setUint16(32, 2, true)
view.setUint16(34, 16, true)
writeStr(view, 36, 'data')
view.setUint32(40, numSamples * 2, true)
let offset = 44
for (let i = 0; i < numSamples; i++, offset += 2) {
const s = Math.max(-1, Math.min(1, audio[i]))
view.setInt16(offset, s < 0 ? s * 0x8000 : s * 0x7fff, true)
}
return new Blob([buffer], { type: 'audio/wav' })
}
function writeStr(view: DataView, offset: number, s: string) {
for (let i = 0; i < s.length; i++) view.setUint8(offset + i, s.charCodeAt(i))
}
export default function VoiceController() {
const [state, setState] = useState<ControllerState>('idle')
const wakeRef = useRef<WakeWordDetector | null>(null)
@@ -67,12 +40,12 @@ export default function VoiceController() {
const busyRef = useRef(false)
useEffect(() => {
console.log('[VoiceController] mounted, state=idle, ждём тап на микрофон')
vlog('[VoiceController] mounted, state=idle, ждём тап на микрофон')
// Кнопка X в overlay шлёт voice-cancel → ставим VAD на паузу
// (НЕ destroy — иначе следующий wake снова будет ждать 1-2с на инициализацию).
const onCancel = () => {
console.log('[voice] cancel — пауза VAD')
vlog('[voice] cancel — пауза VAD')
try { vadRef.current?.pause?.() } catch {}
busyRef.current = false
try { wakeRef.current?.resume?.() } catch {}
@@ -119,7 +92,7 @@ export default function VoiceController() {
})
if (!chatResp.ok) throw new Error(`chat ${chatResp.status}`)
} catch (e) {
console.error('[voice] pipeline error:', e)
verror('[voice] pipeline error:', e)
emitLocal('error', AGENT, 'Не получилось')
} finally {
busyRef.current = false
@@ -154,16 +127,16 @@ export default function VoiceController() {
})
vadRef.current = vad
// Не вызываем start — ждём пока wake-word триггернёт.
console.log('[voice] VAD preloaded (paused)')
vlog('[voice] VAD preloaded (paused)')
} catch (e: any) {
console.error('[voice] VAD init failed:', e?.name, e?.message, e)
verror('[voice] VAD init failed:', e?.name, e?.message, e)
// Не вырубаем wake — может на ручной trigger ещё попробуем
emitLocal('error', AGENT, `VAD: ${e?.message?.slice(0, 60) || 'init'}`)
}
}
const onWakeDetected = async (score: number) => {
console.log(`[wake] cosmo score=${score.toFixed(3)}`)
vlog(`[wake] cosmo score=${score.toFixed(3)}`)
if (busyRef.current) return
// Пауза wake чтобы VAD-инициализация и команда не триггерили wake снова на эхе.
try { wakeRef.current?.pause?.() } catch {}
@@ -190,9 +163,9 @@ export default function VoiceController() {
}
const ctx: AudioContext | undefined = w.__voicePlaybackCtx
if (ctx && ctx.state === 'suspended') await ctx.resume()
console.log('[voice] playback AudioContext state=', ctx?.state)
vlog('[voice] playback AudioContext state=', ctx?.state)
} catch (e: any) {
console.warn('[voice] AudioContext init failed:', e?.message)
vwarn('[voice] AudioContext init failed:', e?.message)
}
// 1. Запрос разрешения на микрофон отдельно
@@ -200,7 +173,7 @@ export default function VoiceController() {
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)
verror('[voice] mic permission failed:', e?.name, e?.message)
setState('error')
emitLocal('error', AGENT, e?.name === 'NotAllowedError' ? 'Нет доступа к микрофону' : 'Микрофон не открылся')
return
@@ -220,12 +193,12 @@ export default function VoiceController() {
if (s > maxScore) maxScore = s
scoreCount++
if (scoreCount % 25 === 0) {
console.log(`[wake] alive · max score за окно=${maxScore.toFixed(3)} · scoreCount=${scoreCount}`)
vlog(`[wake] alive · max score за окно=${maxScore.toFixed(3)} · scoreCount=${scoreCount}`)
maxScore = 0
}
if (s > 0.15) console.log(`[wake] score=${s.toFixed(3)}`)
if (s > 0.15) vlog(`[wake] score=${s.toFixed(3)}`)
},
onError: (e) => console.warn('[wake] error', e),
onError: (e) => vwarn('[wake] error', e),
})
await wake.start()
wakeRef.current = wake
@@ -233,7 +206,7 @@ export default function VoiceController() {
// VAD НЕ прелоадим — его второй getUserMedia мешает wake-word audio.
// Грузится при первом wake (~1-2с), но дальше переиспользуется (см. handleSpeechEnd).
} catch (e: any) {
console.error('[wake] init failed:', e)
verror('[wake] init failed:', e)
setState('error')
emitLocal('error', AGENT, `Wake: ${e?.message?.slice(0, 60) || 'init'}`)
}
@@ -252,7 +225,7 @@ export default function VoiceController() {
// Долгий тап = ручной триггер (как раньше push-to-talk). Короткий — toggle вкл/выкл.
// Для простоты сейчас: короткий тап в idle = активация; короткий тап в active = выкл.
const onTap = async () => {
console.log(`[VoiceController] tap! state=${state}`)
vlog(`[VoiceController] tap! state=${state}`)
if (state === 'idle' || state === 'error') {
await start()
} else if (state === 'listening') {