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 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; } } diff --git a/test/web-adapter.test.js b/test/web-adapter.test.js index 17b42eb..b3d15b1 100644 --- a/test/web-adapter.test.js +++ b/test/web-adapter.test.js @@ -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); +});