diff --git a/docs/TURN_TAKING_STATE_MACHINE.md b/docs/TURN_TAKING_STATE_MACHINE.md index 30a5e2a..c4b6758 100644 --- a/docs/TURN_TAKING_STATE_MACHINE.md +++ b/docs/TURN_TAKING_STATE_MACHINE.md @@ -105,10 +105,10 @@ Required fields: `mode`, `speechThreshold`, `noiseFloorThreshold`, `minSpeechMs` ### VAD/open microphone - `armed` + qualifying speech -> `user_speaking` -- `armed` + level below `speechThreshold` but above `noiseFloorThreshold` -> `noise_gated` +- `armed` + level below `speechThreshold` but at or above `noiseFloorThreshold` -> `noise_gated` - `noise_gated` + qualifying speech -> `user_speaking` -- `user_speaking` + silence for `silenceMs` -> `cooldown` -- `cooldown` after `cooldownMs` -> `armed` +- `user_speaking` + level below `speechThreshold` for `silenceMs` -> `cooldown` (the idle noise floor does not extend an active utterance) +- after `cooldownMs`, the next input is evaluated normally from the open-microphone gate ### Wake-hook diff --git a/src/core.js b/src/core.js index 5d3d234..cbc8771 100644 --- a/src/core.js +++ b/src/core.js @@ -271,19 +271,6 @@ export class BargeKitEngine { return this.getSnapshot(); } - if (normalized.level >= this.config.noiseFloorThreshold) { - this.candidateSpeech = null; - this.emitter.emit('bargekit.input.noise_gated', { - type: 'bargekit.input.noise_gated', - timestamp, - level: normalized.level - }); - if (!this.activeSpeech) { - this.#setState('noise_gated', timestamp, 'mic.level'); - } - return this.getSnapshot(); - } - this.candidateSpeech = null; if (this.activeSpeech) { if (this.lastBelowThresholdAt === 0) { @@ -299,6 +286,16 @@ export class BargeKitEngine { return this.getSnapshot(); } + if (normalized.level >= this.config.noiseFloorThreshold) { + this.emitter.emit('bargekit.input.noise_gated', { + type: 'bargekit.input.noise_gated', + timestamp, + level: normalized.level + }); + this.#setState('noise_gated', timestamp, 'mic.level'); + return this.getSnapshot(); + } + if (!this.agentOutputActive) { this.#setState('listening', timestamp, 'mic.level'); } diff --git a/test/core-state-engine.test.js b/test/core-state-engine.test.js index 7e1e8f8..5346146 100644 --- a/test/core-state-engine.test.js +++ b/test/core-state-engine.test.js @@ -56,6 +56,38 @@ test('engine noise-gates keyboard-like bursts below speech threshold', () => { assert.ok(events.every((event) => event.level >= 0.18 && event.level < 0.6)); }); +test('active speech ends after sustained sub-speech noise at the noise floor', () => { + const engine = createBargeKit({ + mode: 'vad', + speechThreshold: 0.5, + noiseFloorThreshold: 0.1, + minSpeechMs: 20, + debounceMs: 20, + silenceMs: 100, + cooldownMs: 40 + }); + const ends = collect(engine, 'bargekit.user_speech.ended'); + + engine.start(0); + engine.ingestLevel({ timestamp: 0, level: 0.8 }); + engine.ingestLevel({ timestamp: 20, level: 0.8 }); + engine.ingestLevel({ timestamp: 80, level: 0.2 }); + engine.ingestLevel({ timestamp: 180, level: 0.2 }); + + assert.equal(ends.length, 1); + assert.equal(ends[0].reason, 'vad.speech.end'); + assert.equal(ends[0].endedAt, 180); + assert.equal(engine.getSnapshot().state, 'cooldown'); + + engine.ingestLevel({ timestamp: 200, level: 0.2 }); + assert.equal(ends.length, 1); + assert.equal(engine.getSnapshot().state, 'cooldown'); + + engine.ingestLevel({ timestamp: 220, level: 0.05 }); + assert.equal(ends.length, 1); + assert.equal(engine.getSnapshot().state, 'listening'); +}); + test('push-to-talk mode ignores speech until push is held', () => { const engine = createBargeKit({ mode: 'push_to_talk', minSpeechMs: 80, debounceMs: 40 }); const starts = collect(engine, 'bargekit.user_speech.started');