diff --git a/docs/TURN_TAKING_STATE_MACHINE.md b/docs/TURN_TAKING_STATE_MACHINE.md index c4b6758..0df7160 100644 --- a/docs/TURN_TAKING_STATE_MACHINE.md +++ b/docs/TURN_TAKING_STATE_MACHINE.md @@ -120,11 +120,12 @@ Required fields: `mode`, `speechThreshold`, `noiseFloorThreshold`, `minSpeechMs` ### Agent output and barge-in - `armed` + `agent.output.start` -> `agent_speaking` -- `agent_speaking` + qualifying user speech when barge-in enabled -> `barge_pending` -- `barge_pending` emits `bargekit.barge_in.requested` -- if `duckOutput` -> emit `bargekit.output.duck_requested` -- if `cancelOutput` -> emit `bargekit.output.cancel_requested` -- `barge_pending` + sustained speech -> `interrupted` +- `agent_speaking` + an above-threshold sample when barge-in is enabled -> `barge_pending` +- `barge_pending` + a drop below `speechThreshold` before `max(minSpeechMs, debounceMs)` -> `agent_speaking`, with no barge-in or output-control event +- `barge_pending` + speech sustained for `max(minSpeechMs, debounceMs)` emits exactly one `bargekit.barge_in.requested` +- if `duckOutput` -> also emit `bargekit.output.duck_requested` +- if `cancelOutput` -> also emit `bargekit.output.cancel_requested` +- after those configured events, qualified sustained speech -> `interrupted` - `interrupted` may proceed to `user_speaking` once output ends or host policy releases the turn ### Half-duplex hold @@ -148,9 +149,9 @@ Required fields: `mode`, `speechThreshold`, `noiseFloorThreshold`, `minSpeechMs` | `bargekit.state.changed` | Any state transition occurs. | | `bargekit.user_speech.started` | User speech segment starts. | | `bargekit.user_speech.ended` | User speech segment ends. | -| `bargekit.barge_in.requested` | Barge-in threshold is satisfied while agent audio is active. | -| `bargekit.output.duck_requested` | Output should duck for interruption. | -| `bargekit.output.cancel_requested` | Output should stop for interruption. | +| `bargekit.barge_in.requested` | Speech has lasted `max(minSpeechMs, debounceMs)` while agent audio is active. | +| `bargekit.output.duck_requested` | The qualifying barge-in requests reduced output volume when `duckOutput` is enabled. | +| `bargekit.output.cancel_requested` | The qualifying barge-in requests active-output cancellation when `cancelOutput` is enabled. | | `bargekit.input.muted` | Input is muted by host/policy. | | `bargekit.input.noise_gated` | Input is suppressed as noise/echo. | | `bargekit.input.duplex_hold` | Half-duplex policy is holding microphone activity during agent output. | diff --git a/src/core.js b/src/core.js index cbc8771..f819eca 100644 --- a/src/core.js +++ b/src/core.js @@ -1,4 +1,4 @@ -import { EVENTS, MODES, STATES, canRequestBargeIn, mergeConfig } from './contracts.js'; +import { EVENTS, MODES, STATES, mergeConfig } from './contracts.js'; function assertKnownMode(mode) { if (!MODES.includes(mode)) { @@ -296,9 +296,7 @@ export class BargeKitEngine { return this.getSnapshot(); } - if (!this.agentOutputActive) { - this.#setState('listening', timestamp, 'mic.level'); - } + this.#setState(this.agentOutputActive ? 'agent_speaking' : 'listening', timestamp, 'mic.level'); return this.getSnapshot(); } @@ -317,35 +315,37 @@ export class BargeKitEngine { this.candidateSpeech.peakLevel = Math.max(this.candidateSpeech.peakLevel, sample.level); } - if (this.agentOutputActive && canRequestBargeIn(this.config, this.state) && this.config.bargeIn.whileAgentSpeaking) { - this.emitter.emit('bargekit.barge_in.requested', { - type: 'bargekit.barge_in.requested', - timestamp, - level: sample.level, - policy: structuredClone(this.config.bargeIn) - }); - - if (this.config.bargeIn.duckOutput) { - this.emitter.emit('bargekit.output.duck_requested', { - type: 'bargekit.output.duck_requested', + const speechDuration = timestamp - this.candidateSpeech.startedAt; + if (!this.activeSpeech && speechDuration >= requiredWindow) { + if ( + this.agentOutputActive && + this.config.bargeIn.enabled && + this.config.bargeIn.whileAgentSpeaking + ) { + this.emitter.emit('bargekit.barge_in.requested', { + type: 'bargekit.barge_in.requested', timestamp, - level: sample.level + level: sample.level, + policy: structuredClone(this.config.bargeIn) }); - } - if (this.config.bargeIn.cancelOutput) { - this.emitter.emit('bargekit.output.cancel_requested', { - type: 'bargekit.output.cancel_requested', - timestamp, - level: sample.level - }); + if (this.config.bargeIn.duckOutput) { + this.emitter.emit('bargekit.output.duck_requested', { + type: 'bargekit.output.duck_requested', + timestamp, + level: sample.level + }); + } + + if (this.config.bargeIn.cancelOutput) { + this.emitter.emit('bargekit.output.cancel_requested', { + type: 'bargekit.output.cancel_requested', + timestamp, + level: sample.level + }); + } } - this.#setState('barge_pending', timestamp, 'vad.speech.start'); - } - - const speechDuration = timestamp - this.candidateSpeech.startedAt; - if (!this.activeSpeech && speechDuration >= requiredWindow) { this.activeSpeech = { startedAt: this.candidateSpeech.startedAt, detectedAt: timestamp, diff --git a/test/core-state-engine.test.js b/test/core-state-engine.test.js index 5346146..3d6edf9 100644 --- a/test/core-state-engine.test.js +++ b/test/core-state-engine.test.js @@ -121,7 +121,27 @@ test('wake-hook mode requires wake signal before speech can open gate', () => { assert.equal(starts.length, 1); }); -test('barge-in requests fire while agent output is active', () => { +test('transient speech does not request barge-in and restores agent output state', () => { + const engine = createBargeKit({ mode: 'vad', minSpeechMs: 120, debounceMs: 80 }); + const barges = collect(engine, 'bargekit.barge_in.requested'); + const ducks = collect(engine, 'bargekit.output.duck_requested'); + const cancels = collect(engine, 'bargekit.output.cancel_requested'); + + engine.start(0); + engine.setAgentSpeaking(true, 1, { token: 'agent-turn-1' }); + engine.ingestLevel({ timestamp: 2, level: 0.9 }); + + assert.equal(engine.getSnapshot().state, 'barge_pending'); + assert.deepEqual([barges.length, ducks.length, cancels.length], [0, 0, 0]); + + engine.ingestLevel({ timestamp: 3, level: 0 }); + + assert.deepEqual([barges.length, ducks.length, cancels.length], [0, 0, 0]); + assert.equal(engine.getSnapshot().state, 'agent_speaking'); + assert.equal(engine.getSnapshot().candidateSpeech, null); +}); + +test('sustained speech requests barge-in once while agent output is active', () => { const engine = createBargeKit({ mode: 'vad', minSpeechMs: 80, debounceMs: 40 }); const barges = collect(engine, 'bargekit.barge_in.requested'); const ducks = collect(engine, 'bargekit.output.duck_requested'); @@ -138,6 +158,29 @@ test('barge-in requests fire while agent output is active', () => { assert.equal(engine.getSnapshot().state, 'interrupted'); }); +test('barge-in output controls honor disabled duck and cancel policy', () => { + const engine = createBargeKit({ + mode: 'vad', + minSpeechMs: 80, + debounceMs: 40, + bargeIn: { duckOutput: false, cancelOutput: false } + }); + const barges = collect(engine, 'bargekit.barge_in.requested'); + const ducks = collect(engine, 'bargekit.output.duck_requested'); + const cancels = collect(engine, 'bargekit.output.cancel_requested'); + + engine.start(0); + engine.setAgentSpeaking(true, 0); + engine.ingestLevel({ timestamp: 20, level: 0.8 }); + engine.ingestLevel({ timestamp: 100, level: 0.8 }); + engine.ingestLevel({ timestamp: 140, level: 0.8 }); + + assert.equal(barges.length, 1); + assert.equal(ducks.length, 0); + assert.equal(cancels.length, 0); + assert.equal(engine.getSnapshot().state, 'interrupted'); +}); + test('muted input suppresses speech and emits muted state', () => { const engine = createBargeKit(); const mutedEvents = collect(engine, 'bargekit.input.muted');