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
2 changes: 1 addition & 1 deletion docs/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ The ducking controller turns barge-in events into hold/duck/cancel/resume decisi
import { createWebMicrophoneAdapter } from '@bargekit/core/web';
```

The browser adapter requests microphone permission explicitly, samples analyser levels, and does not record or upload audio.
The browser adapter requests microphone permission explicitly, samples analyser levels, and does not record or upload audio. `start()` is failure-atomic: if setup fails after permission is granted, it stops acquired tracks, clears timers, disconnects the audio graph, closes the audio context, and releases its resource references before rejecting with the classified microphone error.

## Observability and tuning

Expand Down
56 changes: 41 additions & 15 deletions src/web.js
Original file line number Diff line number Diff line change
Expand Up @@ -87,11 +87,50 @@ export class WebMicrophoneAdapter {
return { started: true, constraints };
} catch (error) {
const code = classifyMediaError(error);
this.engine.raiseError(new Error(code), this.now());
await this.cleanupResources();
try {
this.engine.raiseError(new Error(code), this.now());
} catch {}
throw Object.assign(new Error(code), { cause: error });
}
}

async cleanupResources() {
if (this.intervalId !== null) {
try {
this.clearIntervalRef(this.intervalId);
} catch {}
}

try {
this.sourceNode?.disconnect?.();
} catch {}
try {
this.analyser?.disconnect?.();
} catch {}

let tracks = [];
try {
tracks = this.stream?.getTracks?.() ?? [];
} catch {}
for (const track of tracks) {
try {
track.stop();
} catch {}
}

try {
await this.audioContext?.close?.();
} catch {}

this.stream = null;
this.audioContext = null;
this.sourceNode = null;
this.analyser = null;
this.intervalId = null;
this.reuseBuffer = new Float32Array(0);
}

sampleOnce(timestamp = this.now()) {
if (!this.analyser) {
return null;
Expand All @@ -104,21 +143,8 @@ export class WebMicrophoneAdapter {
}

async stop() {
if (this.intervalId) {
this.clearIntervalRef(this.intervalId);
this.intervalId = null;
}

this.sourceNode?.disconnect?.();
this.analyser?.disconnect?.();
this.stream?.getTracks?.().forEach((track) => track.stop());
await this.audioContext?.close?.();
await this.cleanupResources();
this.engine.stop(this.now());

this.stream = null;
this.audioContext = null;
this.sourceNode = null;
this.analyser = null;
return { stopped: true };
}
}
Expand Down
79 changes: 78 additions & 1 deletion test/web-adapter.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,14 @@ class FakeAnalyser {
}

class FakeSourceNode {
constructor() {
this.disconnected = false;
}

connect() {}
disconnect() {}
disconnect() {
this.disconnected = true;
}
}

class FakeAudioContext {
Expand Down Expand Up @@ -133,3 +139,74 @@ test('web adapter surfaces permission denied errors', async () => {
await assert.rejects(() => adapter.start(), /permission_denied/);
assert.equal(engine.getSnapshot().state, 'error');
});

test('web adapter releases the stream when AudioContext construction fails', async () => {
const engine = createBargeKit();
const stream = new FakeStream();
const adapter = createWebMicrophoneAdapter({
engine,
navigatorRef: {
mediaDevices: { async getUserMedia() { return stream; } }
},
AudioContextCtor: class {
constructor() {
throw new Error('context construction failed');
}
}
});

await assert.rejects(
() => adapter.start(),
(error) => error.message === 'microphone_error' && error.cause.message === 'context construction failed'
);
assert.equal(stream.getTracks()[0].stopped, true);
assert.equal(adapter.stream, null);
assert.equal(adapter.audioContext, null);
assert.equal(adapter.sourceNode, null);
assert.equal(adapter.analyser, null);
assert.equal(adapter.intervalId, null);
assert.equal(engine.getSnapshot().state, 'error');
});

test('web adapter tears down graph and leaves no timer after late start failure', async () => {
const engine = createBargeKit();
const stream = new FakeStream();
const analyser = new FakeAnalyser();
const context = new FakeAudioContext(analyser);
const source = new FakeSourceNode();
context.createMediaStreamSource = () => source;
context.createAnalyser = () => {
throw new Error('analyser setup failed');
};
const activeTimers = [];
const adapter = createWebMicrophoneAdapter({
engine,
navigatorRef: {
mediaDevices: { async getUserMedia() { return stream; } }
},
AudioContextCtor: class { constructor() { return context; } },
setIntervalRef(handler) {
activeTimers.push(handler);
return 0;
},
clearIntervalRef(timer) {
assert.equal(timer, 0);
activeTimers.length = 0;
}
});

await assert.rejects(
() => adapter.start(),
(error) => error.message === 'microphone_error' && error.cause.message === 'analyser setup failed'
);
assert.equal(stream.getTracks()[0].stopped, true);
assert.equal(source.disconnected, true);
assert.equal(context.closed, true);
assert.equal(activeTimers.length, 0);
assert.equal(adapter.stream, null);
assert.equal(adapter.audioContext, null);
assert.equal(adapter.sourceNode, null);
assert.equal(adapter.analyser, null);
assert.equal(adapter.intervalId, null);
assert.equal(engine.getSnapshot().state, 'error');
});
Loading