diff --git a/docs/API.md b/docs/API.md index d7e6642..ec12df5 100644 --- a/docs/API.md +++ b/docs/API.md @@ -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 diff --git a/src/web.js b/src/web.js index 19c1b2e..44f5062 100644 --- a/src/web.js +++ b/src/web.js @@ -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; @@ -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 }; } } diff --git a/test/web-adapter.test.js b/test/web-adapter.test.js index a5edc3e..17b42eb 100644 --- a/test/web-adapter.test.js +++ b/test/web-adapter.test.js @@ -40,8 +40,14 @@ class FakeAnalyser { } class FakeSourceNode { + constructor() { + this.disconnected = false; + } + connect() {} - disconnect() {} + disconnect() { + this.disconnected = true; + } } class FakeAudioContext { @@ -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'); +});