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
4 changes: 3 additions & 1 deletion docs/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,9 @@ 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. `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.
The browser adapter requests microphone permission explicitly, samples analyser levels, and does not record or upload audio. Calls to `start()` are idempotent for one active lifecycle: overlapping calls share the same startup promise, and later calls while active return the original result without requesting another stream or applying new options. Call `stop()` before starting with different options. If `stop()` overlaps startup, it waits for startup to settle and then releases that session; concurrent stops share the same teardown.

Startup is failure-atomic: if setup fails after permission is granted, the adapter 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. After either a completed stop or a failed start, `start()` can create a fresh session.

## Observability and tuning

Expand Down
67 changes: 61 additions & 6 deletions src/web.js
Original file line number Diff line number Diff line change
Expand Up @@ -58,9 +58,38 @@ export class WebMicrophoneAdapter {
this.analyser = null;
this.intervalId = null;
this.reuseBuffer = new Float32Array(0);
this.startPromise = null;
this.stopPromise = null;
this.startResult = null;
}

async start(options = {}) {
start(options = {}) {
if (this.stopPromise) {
return this.stopPromise.then(() => this.start(options));
}

if (this.startResult) {
return Promise.resolve(this.startResult);
}

if (this.startPromise) {
return this.startPromise;
}

const operation = this.startSession(options);
this.startPromise = operation;
operation.then(
() => {
if (this.startPromise === operation) this.startPromise = null;
},
() => {
if (this.startPromise === operation) this.startPromise = null;
}
);
return operation;
}

async startSession(options) {
if (!this.navigatorRef?.mediaDevices?.getUserMedia) {
throw new Error('getUserMedia is not available in this environment');
}
Expand All @@ -84,10 +113,12 @@ export class WebMicrophoneAdapter {
this.sourceNode.connect(this.analyser);
this.engine.start(this.now());
this.intervalId = this.setIntervalRef(() => this.sampleOnce(), this.config.frameIntervalMs);
return { started: true, constraints };
this.startResult = { started: true, constraints };
return this.startResult;
} catch (error) {
const code = classifyMediaError(error);
await this.cleanupResources();
this.startResult = null;
try {
this.engine.raiseError(new Error(code), this.now());
} catch {}
Expand Down Expand Up @@ -142,10 +173,34 @@ export class WebMicrophoneAdapter {
return { timestamp, level };
}

async stop() {
await this.cleanupResources();
this.engine.stop(this.now());
return { stopped: true };
stop() {
if (this.stopPromise) {
return this.stopPromise;
}

const pendingStart = this.startPromise;
const operation = (async () => {
if (pendingStart) {
try {
await pendingStart;
} catch {}
}
await this.cleanupResources();
this.startResult = null;
this.engine.stop(this.now());
return { stopped: true };
})();

this.stopPromise = operation;
operation.then(
() => {
if (this.stopPromise === operation) this.stopPromise = null;
},
() => {
if (this.stopPromise === operation) this.stopPromise = null;
}
);
return operation;
}
}

Expand Down
98 changes: 98 additions & 0 deletions test/web-adapter.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -210,3 +210,101 @@ test('web adapter tears down graph and leaves no timer after late start failure'
assert.equal(adapter.intervalId, null);
assert.equal(engine.getSnapshot().state, 'error');
});

test('web adapter reuses an active session across sequential start calls', async () => {
const streams = [];
const contexts = [];
const activeTimers = new Set();
const adapter = createWebMicrophoneAdapter({
navigatorRef: {
mediaDevices: {
async getUserMedia() {
const stream = new FakeStream();
streams.push(stream);
return stream;
}
}
},
AudioContextCtor: class {
constructor() {
const context = new FakeAudioContext(new FakeAnalyser());
contexts.push(context);
return context;
}
},
setIntervalRef(handler) {
activeTimers.add(handler);
return handler;
},
clearIntervalRef(handler) {
activeTimers.delete(handler);
}
});

const first = await adapter.start();
const second = await adapter.start({ audio: { echoCancellation: false } });

assert.strictEqual(second, first);
assert.equal(streams.length, 1);
assert.equal(contexts.length, 1);
assert.equal(activeTimers.size, 1);

await adapter.stop();
assert.equal(streams[0].tracks[0].stopped, true);
assert.equal(contexts[0].closed, true);
assert.equal(activeTimers.size, 0);

const restarted = await adapter.start();
assert.notStrictEqual(restarted, first);
assert.equal(streams.length, 2);
assert.equal(contexts.length, 2);
assert.equal(activeTimers.size, 1);

await adapter.stop();
assert.equal(streams[1].tracks[0].stopped, true);
assert.equal(contexts[1].closed, true);
assert.equal(activeTimers.size, 0);
});

test('web adapter shares overlapping startup and stop releases the acquired session', async () => {
let resolvePermission;
let permissionRequests = 0;
const stream = new FakeStream();
const context = new FakeAudioContext(new FakeAnalyser());
const activeTimers = new Set();
const adapter = createWebMicrophoneAdapter({
navigatorRef: {
mediaDevices: {
getUserMedia() {
permissionRequests += 1;
return new Promise((resolve) => { resolvePermission = resolve; });
}
}
},
AudioContextCtor: class { constructor() { return context; } },
setIntervalRef(handler) {
activeTimers.add(handler);
return handler;
},
clearIntervalRef(handler) {
activeTimers.delete(handler);
}
});

const firstStart = adapter.start();
const secondStart = adapter.start();
const stop = adapter.stop();
assert.equal(permissionRequests, 1);

resolvePermission(stream);
const [first, second] = await Promise.all([firstStart, secondStart]);
assert.strictEqual(second, first);
await stop;

assert.equal(stream.tracks[0].stopped, true);
assert.equal(context.closed, true);
assert.equal(activeTimers.size, 0);
assert.equal(adapter.stream, null);
assert.equal(adapter.audioContext, null);
assert.equal(adapter.intervalId, null);
});
Loading