From 1e7223884aadd812b2280395df68588b83dcd22e Mon Sep 17 00:00:00 2001 From: Roger Chappel Date: Fri, 14 Aug 2026 17:58:40 +1000 Subject: [PATCH 1/3] test: cover active speech ending on background noise --- test/core-state-engine.test.js | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/test/core-state-engine.test.js b/test/core-state-engine.test.js index 7e1e8f8..8d160cd 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: 120, level: 0.2 }); + + assert.equal(ends.length, 1); + assert.equal(ends[0].reason, 'vad.speech.end'); + assert.equal(ends[0].endedAt, 120); + assert.equal(engine.getSnapshot().state, 'cooldown'); + + engine.ingestLevel({ timestamp: 140, level: 0.2 }); + assert.equal(ends.length, 1); + assert.equal(engine.getSnapshot().state, 'cooldown'); + + engine.ingestLevel({ timestamp: 160, 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'); From f587cd98c1f191a8883eb96dc8c8d30be096d767 Mon Sep 17 00:00:00 2001 From: Roger Chappel Date: Fri, 14 Aug 2026 17:59:28 +1000 Subject: [PATCH 2/3] fix: let background noise close active speech --- src/core.js | 23 ++++++++++------------- test/core-state-engine.test.js | 8 ++++---- 2 files changed, 14 insertions(+), 17 deletions(-) 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 8d160cd..5346146 100644 --- a/test/core-state-engine.test.js +++ b/test/core-state-engine.test.js @@ -72,18 +72,18 @@ test('active speech ends after sustained sub-speech noise at the noise floor', ( engine.ingestLevel({ timestamp: 0, level: 0.8 }); engine.ingestLevel({ timestamp: 20, level: 0.8 }); engine.ingestLevel({ timestamp: 80, level: 0.2 }); - engine.ingestLevel({ timestamp: 120, 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, 120); + assert.equal(ends[0].endedAt, 180); assert.equal(engine.getSnapshot().state, 'cooldown'); - engine.ingestLevel({ timestamp: 140, level: 0.2 }); + engine.ingestLevel({ timestamp: 200, level: 0.2 }); assert.equal(ends.length, 1); assert.equal(engine.getSnapshot().state, 'cooldown'); - engine.ingestLevel({ timestamp: 160, level: 0.05 }); + engine.ingestLevel({ timestamp: 220, level: 0.05 }); assert.equal(ends.length, 1); assert.equal(engine.getSnapshot().state, 'listening'); }); From 1a2c2dca29644e340b0068ac65ac356038b6e4d5 Mon Sep 17 00:00:00 2001 From: Roger Chappel Date: Fri, 14 Aug 2026 17:59:43 +1000 Subject: [PATCH 3/3] docs: clarify silence and idle noise gating --- docs/TURN_TAKING_STATE_MACHINE.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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