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:
35
lib/audio-wav.ts
Normal file
35
lib/audio-wav.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Float32 [-1, 1] PCM → WAV blob (mono, 16-bit, заданная частота).
|
||||
* Используется браузером для отправки записанной фразы в STT.
|
||||
*/
|
||||
|
||||
export 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) // fmt chunk size
|
||||
view.setUint16(20, 1, true) // PCM
|
||||
view.setUint16(22, 1, true) // channels
|
||||
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
|
||||
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): void {
|
||||
for (let i = 0; i < s.length; i++) view.setUint8(offset + i, s.charCodeAt(i))
|
||||
}
|
||||
36
lib/debug.ts
Normal file
36
lib/debug.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Гейт для браузерных дебаг-логов голосового стека.
|
||||
*
|
||||
* Production: логи молчат. Чтобы включить — выставить
|
||||
* `NEXT_PUBLIC_VOICE_DEBUG=1` в env (на этапе билда). На клиенте
|
||||
* также можно временно включить `localStorage.setItem('voice-debug', '1')`.
|
||||
*
|
||||
* Серверные логи (`/api/voice/chat`, `/api/voice/stt`) НЕ пропускаются
|
||||
* через эту функцию — они полезны в Docker logs и не утекают в браузер.
|
||||
*/
|
||||
|
||||
const ENV_ENABLED = process.env.NEXT_PUBLIC_VOICE_DEBUG === '1'
|
||||
|
||||
function runtimeEnabled(): boolean {
|
||||
if (ENV_ENABLED) return true
|
||||
if (typeof window === 'undefined') return false
|
||||
try {
|
||||
return window.localStorage?.getItem('voice-debug') === '1'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function vlog(...args: any[]): void {
|
||||
if (runtimeEnabled()) console.log(...args)
|
||||
}
|
||||
|
||||
export function vwarn(...args: any[]): void {
|
||||
// Warnings оставляем всегда — это анонимные ошибки, не PII.
|
||||
console.warn(...args)
|
||||
}
|
||||
|
||||
export function verror(...args: any[]): void {
|
||||
// Errors всегда — нам нужно знать о реальных падениях.
|
||||
console.error(...args)
|
||||
}
|
||||
@@ -1,11 +1,19 @@
|
||||
/**
|
||||
* История диалога per-agent per-day. Файлы в /data/voice-history/{agent}-{date}.json.
|
||||
* /data — это volume контейнера (на хосте /opt/digital-home/smart-home-tablet-data/).
|
||||
*
|
||||
* Fallback: если /data не существует (локальная разработка) — пишем в /tmp/voice-history.
|
||||
*/
|
||||
import { promises as fs } from 'node:fs'
|
||||
import { existsSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
const DATA_DIR = process.env.VOICE_HISTORY_DIR || '/data/voice-history'
|
||||
const PRIMARY_DIR = process.env.VOICE_HISTORY_DIR || '/data/voice-history'
|
||||
const DATA_DIR = (() => {
|
||||
// Проверяем существование родителя (/data) — без него запись упадёт ENOENT.
|
||||
const parent = path.dirname(PRIMARY_DIR)
|
||||
return existsSync(parent) ? PRIMARY_DIR : '/tmp/voice-history'
|
||||
})()
|
||||
const MAX_HISTORY = parseInt(process.env.VOICE_MAX_HISTORY || '40', 10)
|
||||
|
||||
export type HistoryMessage = {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user