From da7fdf7c6e5c824238e0b3a3c0d459b4cef40f0f Mon Sep 17 00:00:00 2001 From: Punyapal Shah <53343069+MrPunyapal@users.noreply.github.com> Date: Thu, 5 Nov 2026 01:57:17 +0530 Subject: [PATCH] fix: release microphone when voice recorder setup fails --- resources/js/lib/voice-notes.ts | 116 +++++++++++++++--------- tests/Unit/VoiceNoteSetup.test.ts | 146 ++++++++++++++++++++++++++++++ 2 files changed, 221 insertions(+), 41 deletions(-) create mode 100644 tests/Unit/VoiceNoteSetup.test.ts diff --git a/resources/js/lib/voice-notes.ts b/resources/js/lib/voice-notes.ts index 1b8448dcb5..17261d4af0 100644 --- a/resources/js/lib/voice-notes.ts +++ b/resources/js/lib/voice-notes.ts @@ -185,48 +185,81 @@ export async function startVoiceRecording(): Promise { // The recorder starts before anything else is set up, since opening the level meter's audio context can take a // moment, and every one of them would be cut from the start of the note. - const recorder = new MediaRecorder( - stream, - MediaRecorder.isTypeSupported(opusType) - ? { mimeType: opusType, audioBitsPerSecond: opusBitsPerSecond } - : {}, - ); - const chunks: Blob[] = []; - const started = new Promise((resolve, reject) => { - recorder.onstart = () => resolve(performance.now()); - recorder.onerror = () => - reject(new VoiceNoteError('fatal: could not start recording')); - }); - const stopped = new Promise((resolve) => { - recorder.ondataavailable = (event) => chunks.push(event.data); - recorder.onstop = () => - resolve(new Blob(chunks, { type: recorder.mimeType })); - }); + let recorder: MediaRecorder | null = null; + let meter: AudioContext | undefined; + let sampling: number | undefined; - recorder.start(); + const release = () => { + if (sampling !== undefined) { + window.clearInterval(sampling); + } - const meter = new AudioContext(); - const analyser = meter.createAnalyser(); - const frame = new Float32Array(analyser.fftSize); + try { + if (recorder !== null && recorder.state !== 'inactive') { + recorder.stop(); + } + } catch { + // Stopping the recorder must not prevent releasing the microphone. + } - meter.createMediaStreamSource(stream).connect(analyser); - void meter.resume().catch(() => undefined); + stream.getTracks().forEach((track) => { + try { + track.stop(); + } catch { + // Release the remaining tracks even if one cannot be stopped. + } + }); + void meter?.close().catch(() => undefined); + }; - const level = () => { - analyser.getFloatTimeDomainData(frame); + const chunks: Blob[] = []; + let started: Promise; + let stopped: Promise; + let level: () => number; + let activeRecorder: MediaRecorder; + const levels: number[] = []; - return Math.sqrt( - frame.reduce((peak, sample) => Math.max(peak, Math.abs(sample)), 0), + try { + activeRecorder = new MediaRecorder( + stream, + MediaRecorder.isTypeSupported(opusType) + ? { mimeType: opusType, audioBitsPerSecond: opusBitsPerSecond } + : {}, ); - }; - const levels: number[] = []; - const sampling = window.setInterval(() => levels.push(level()), 50); + recorder = activeRecorder; + started = new Promise((resolve, reject) => { + activeRecorder.onstart = () => resolve(performance.now()); + activeRecorder.onerror = () => + reject(new VoiceNoteError('fatal: could not start recording')); + }); + stopped = new Promise((resolve) => { + activeRecorder.ondataavailable = (event) => chunks.push(event.data); + activeRecorder.onstop = () => + resolve(new Blob(chunks, { type: activeRecorder.mimeType })); + }); - const release = () => { - window.clearInterval(sampling); - stream.getTracks().forEach((track) => track.stop()); - void meter.close().catch(() => undefined); - }; + activeRecorder.start(); + meter = new AudioContext(); + const analyser = meter.createAnalyser(); + const frame = new Float32Array(analyser.fftSize); + meter.createMediaStreamSource(stream).connect(analyser); + void meter.resume().catch(() => undefined); + level = () => { + analyser.getFloatTimeDomainData(frame); + + return Math.sqrt( + frame.reduce( + (peak, sample) => Math.max(peak, Math.abs(sample)), + 0, + ), + ); + }; + sampling = window.setInterval(() => levels.push(level()), 50); + } catch (error) { + release(); + + throw error; + } let startedAt: number; @@ -247,8 +280,13 @@ export async function startVoiceRecording(): Promise { Math.round(performance.now() - startedAt), ); - recorder.stop(); - release(); + try { + if (activeRecorder.state !== 'inactive') { + activeRecorder.stop(); + } + } finally { + release(); + } const recording = await stopped; @@ -270,10 +308,6 @@ export async function startVoiceRecording(): Promise { : wavDraftOf(recording); }, cancel: () => { - if (recorder.state !== 'inactive') { - recorder.stop(); - } - release(); }, }; diff --git a/tests/Unit/VoiceNoteSetup.test.ts b/tests/Unit/VoiceNoteSetup.test.ts new file mode 100644 index 0000000000..6ca9aec281 --- /dev/null +++ b/tests/Unit/VoiceNoteSetup.test.ts @@ -0,0 +1,146 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { startVoiceRecording } from '../../resources/js/lib/voice-notes.ts'; + +const failures = [ + 'recorder construction', + 'recorder start', + 'audio context construction', + 'analyser setup', + 'stream wiring', +] as const; + +for (const failure of failures) { + void test(`releases recording resources when ${failure} fails`, async () => { + const originals = { + navigator: Object.getOwnPropertyDescriptor(globalThis, 'navigator'), + MediaRecorder: Object.getOwnPropertyDescriptor( + globalThis, + 'MediaRecorder', + ), + AudioContext: Object.getOwnPropertyDescriptor( + globalThis, + 'AudioContext', + ), + }; + let stoppedTracks = 0; + let stoppedRecorder = 0; + let closedContexts = 0; + const stream = { + getTracks: () => [ + { + stop: () => { + stoppedTracks++; + if (failure === 'recorder start') { + throw new Error('track release failed'); + } + }, + }, + { stop: () => stoppedTracks++ }, + ], + }; + + class Recorder { + static isTypeSupported() { + return true; + } + state = 'inactive'; + onstart: (() => void) | null = null; + onerror: (() => void) | null = null; + ondataavailable: ((event: { data: Blob }) => void) | null = null; + onstop: (() => void) | null = null; + + constructor() { + if (failure === 'recorder construction') { + throw new Error('recorder unavailable'); + } + } + + start() { + this.state = 'recording'; + if (failure === 'recorder start') { + throw new Error('recorder start failed'); + } + } + + stop() { + this.state = 'inactive'; + stoppedRecorder++; + if (failure === 'recorder start') { + throw new Error('recorder stop failed'); + } + } + } + + class Meter { + constructor() { + if (failure === 'audio context construction') { + throw new Error('audio context unavailable'); + } + } + + createAnalyser() { + if (failure === 'analyser setup') { + throw new Error('analyser unavailable'); + } + + return { + fftSize: 1, + getFloatTimeDomainData: () => undefined, + }; + } + + createMediaStreamSource() { + return { + connect: () => { + if (failure === 'stream wiring') { + throw new Error('stream wiring failed'); + } + }, + }; + } + + resume() { + return Promise.resolve(); + } + close() { + closedContexts++; + return Promise.resolve(); + } + } + + try { + Object.defineProperty(globalThis, 'navigator', { + configurable: true, + value: { mediaDevices: { getUserMedia: async () => stream } }, + }); + Object.defineProperty(globalThis, 'MediaRecorder', { + configurable: true, + value: Recorder, + }); + Object.defineProperty(globalThis, 'AudioContext', { + configurable: true, + value: Meter, + }); + + await assert.rejects(startVoiceRecording()); + assert.equal(stoppedTracks, 2); + assert.equal( + stoppedRecorder, + failure === 'recorder construction' ? 0 : 1, + ); + assert.equal( + closedContexts, + ['analyser setup', 'stream wiring'].includes(failure) ? 1 : 0, + ); + } finally { + for (const [name, descriptor] of Object.entries(originals)) { + if (descriptor === undefined) { + Reflect.deleteProperty(globalThis, name); + } else { + Object.defineProperty(globalThis, name, descriptor); + } + } + } + }); +}