156 lines
3.7 KiB
JavaScript
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
|
|
};
|
|
}
|
|
}
|