From 5ae65036de94c4c7385c93ce508b2d7f6ee94e71 Mon Sep 17 00:00:00 2001 From: Roger Chappel Date: Fri, 14 Aug 2026 02:36:41 +1000 Subject: [PATCH 1/4] test(web): cover repeated and overlapping starts --- test/web-adapter.test.js | 87 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) diff --git a/test/web-adapter.test.js b/test/web-adapter.test.js index 17b42eb..25e4b81 100644 --- a/test/web-adapter.test.js +++ b/test/web-adapter.test.js @@ -210,3 +210,90 @@ 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); +}); + +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); +}); From 77b21c95860e54a63abe8e2ff56ed83e2b628191 Mon Sep 17 00:00:00 2001 From: Roger Chappel Date: Fri, 14 Aug 2026 02:37:15 +1000 Subject: [PATCH 2/4] fix(web): serialize microphone lifecycle transitions --- src/web.js | 67 +++++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 61 insertions(+), 6 deletions(-) diff --git a/src/web.js b/src/web.js index 44f5062..b779fe7 100644 --- a/src/web.js +++ b/src/web.js @@ -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'); } @@ -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 {} @@ -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; } } From a1f6bb234e87ae4d23b3b98d640763851c32d9b6 Mon Sep 17 00:00:00 2001 From: Roger Chappel Date: Fri, 14 Aug 2026 02:37:37 +1000 Subject: [PATCH 3/4] test(web): verify restart after complete teardown --- test/web-adapter.test.js | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/test/web-adapter.test.js b/test/web-adapter.test.js index 25e4b81..b3d15b1 100644 --- a/test/web-adapter.test.js +++ b/test/web-adapter.test.js @@ -253,6 +253,17 @@ test('web adapter reuses an active session across sequential start calls', async 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 () => { From 4ac6ebd8e36b6dd10c1338d536d13fa462b9a6ee Mon Sep 17 00:00:00 2001 From: Roger Chappel Date: Fri, 14 Aug 2026 02:38:09 +1000 Subject: [PATCH 4/4] docs(web): define idempotent lifecycle contract --- docs/API.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/API.md b/docs/API.md index ec12df5..60dba92 100644 --- a/docs/API.md +++ b/docs/API.md @@ -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