/** * 狗叫检测器 * 基于 PCM 数据实时检测「汪」声 */ export default class BarkDetector { constructor(options = {}) { this.volumeThreshold = options.volumeThreshold || 0.03; this.minBarkDuration = options.minBarkDuration || 0.08; this.maxBarkDuration = options.maxBarkDuration || 0.6; this.minZCR = options.minZCR || 0.03; this.maxZCR = options.maxZCR || 0.35; this.comboWindow = options.comboWindow || 1000; this.state = 'SILENT'; this.stateStartTime = 0; this.lastBarkTime = 0; this.combo = 0; this.volume = 0; this.isBark = false; this.pushPower = 0; this.noiseBaseline = 0; this.baselineFrames = 0; this.onBark = options.onBark || null; } feed(pcm, sampleRate, time) { if (!pcm || pcm.length === 0) return; const now = time || Date.now(); let sum = 0; for (let i = 0; i < pcm.length; i++) { const v = pcm[i] / 32768; sum += v * v; } const rms = Math.sqrt(sum / pcm.length); let zcrCount = 0; for (let i = 1; i < pcm.length; i++) { if ((pcm[i] >= 0) !== (pcm[i - 1] >= 0)) { zcrCount++; } } const zcr = zcrCount / pcm.length; if (this.baselineFrames < 20) { this.noiseBaseline = this.noiseBaseline * 0.8 + rms * 0.2; this.baselineFrames++; this.volume = rms; return; } const adaptiveThreshold = Math.max(this.volumeThreshold, this.noiseBaseline + 0.03); let diffSum = 0; const totalSum = sum || 1; for (let i = 1; i < pcm.length; i++) { const diff = (pcm[i] - pcm[i - 1]) / 32768; diffSum += diff * diff; } const highFreqRatio = diffSum / totalSum; this.volume = rms; const isAboveThreshold = rms > adaptiveThreshold; const isValidBarkSound = zcr >= this.minZCR && zcr <= this.maxZCR && highFreqRatio > 0.1; this.isBark = false; this.pushPower = 0; switch (this.state) { case 'SILENT': if (isAboveThreshold && isValidBarkSound) { this.state = 'ATTACK'; this.stateStartTime = now; } break; case 'ATTACK': if (isAboveThreshold && isValidBarkSound) { this.state = 'SUSTAIN'; } else if (!isAboveThreshold) { this.state = 'SILENT'; } break; case 'SUSTAIN': if (!isAboveThreshold) { this.state = 'RELEASE'; } break; case 'RELEASE': { const duration = (now - this.stateStartTime) / 1000; if (duration >= this.minBarkDuration && duration <= this.maxBarkDuration) { this._triggerBark(rms, now); } this.state = 'SILENT'; } break; } if (this.state === 'SUSTAIN' || this.state === 'ATTACK') { const duration = (now - this.stateStartTime) / 1000; if (duration > this.maxBarkDuration) { this.state = 'SILENT'; } } } _triggerBark(volume, now) { if (now - this.lastBarkTime < this.comboWindow) { this.combo++; } else { this.combo = 1; } this.lastBarkTime = now; this.isBark = true; const comboBonus = 1 + (this.combo - 1) * 0.2; this.pushPower = Math.min(volume * comboBonus * 3, 0.25); if (this.onBark) { this.onBark({ volume, combo: this.combo, pushPower: this.pushPower, time: now }); } } reset() { this.state = 'SILENT'; this.combo = 0; this.volume = 0; this.isBark = false; this.pushPower = 0; this.baselineFrames = 0; this.noiseBaseline = 0; } getStatus() { return { volume: this.volume, isBark: this.isBark, combo: this.combo, pushPower: this.pushPower, state: this.state }; } }