Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 9 additions & 8 deletions docs/TURN_TAKING_STATE_MACHINE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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. |
Expand Down
56 changes: 28 additions & 28 deletions src/core.js
Original file line number Diff line number Diff line change
@@ -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)) {
Expand Down Expand Up @@ -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();
}
Expand All @@ -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,
Expand Down
45 changes: 44 additions & 1 deletion test/core-state-engine.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -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');
Expand Down
Loading