chore(voice): security, cleanup, resilience
All checks were successful
Deploy / deploy (push) Successful in 1m47s
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:
@@ -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') {
|
||||
|
||||
Reference in New Issue
Block a user