wangwang/js/barkDetector.js

156 lines
3.7 KiB
JavaScript

/**
* 狗叫检测器
* 基于 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
};
}
}