Skip to content
Open
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
116 changes: 75 additions & 41 deletions resources/js/lib/voice-notes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,48 +185,81 @@ export async function startVoiceRecording(): Promise<VoiceRecording> {

// 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<number>((resolve, reject) => {
recorder.onstart = () => resolve(performance.now());
recorder.onerror = () =>
reject(new VoiceNoteError('fatal: could not start recording'));
});
const stopped = new Promise<Blob>((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<number>;
let stopped: Promise<Blob>;
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<number>((resolve, reject) => {
activeRecorder.onstart = () => resolve(performance.now());
activeRecorder.onerror = () =>
reject(new VoiceNoteError('fatal: could not start recording'));
});
stopped = new Promise<Blob>((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;

Expand All @@ -247,8 +280,13 @@ export async function startVoiceRecording(): Promise<VoiceRecording> {
Math.round(performance.now() - startedAt),
);

recorder.stop();
release();
try {
if (activeRecorder.state !== 'inactive') {
activeRecorder.stop();
}
} finally {
release();
}

const recording = await stopped;

Expand All @@ -270,10 +308,6 @@ export async function startVoiceRecording(): Promise<VoiceRecording> {
: wavDraftOf(recording);
},
cancel: () => {
if (recorder.state !== 'inactive') {
recorder.stop();
}

release();
},
};
Expand Down
146 changes: 146 additions & 0 deletions tests/Unit/VoiceNoteSetup.test.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
}
});
}