mirror of
https://git.quad4.io/RNS-Things/MeshChatX.git
synced 2026-04-25 17:32:09 +00:00
150 lines
5.4 KiB
JavaScript
150 lines
5.4 KiB
JavaScript
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import MicrophoneRecorder from "@/js/MicrophoneRecorder";
|
|
|
|
function installFakeAudioContext({ sampleRate = 48000 } = {}) {
|
|
const processorNode = {
|
|
onaudioprocess: null,
|
|
connect: vi.fn(),
|
|
disconnect: vi.fn(),
|
|
};
|
|
const sourceNode = {
|
|
connect: vi.fn(),
|
|
disconnect: vi.fn(),
|
|
};
|
|
const ctx = {
|
|
sampleRate,
|
|
destination: {},
|
|
resume: vi.fn().mockResolvedValue(undefined),
|
|
close: vi.fn().mockResolvedValue(undefined),
|
|
createMediaStreamSource: vi.fn(() => sourceNode),
|
|
createScriptProcessor: vi.fn(() => processorNode),
|
|
};
|
|
const original = globalThis.AudioContext;
|
|
globalThis.AudioContext = vi.fn(function FakeAudioContext() {
|
|
return ctx;
|
|
});
|
|
return {
|
|
ctx,
|
|
processorNode,
|
|
sourceNode,
|
|
restore() {
|
|
if (typeof original === "undefined") {
|
|
Reflect.deleteProperty(globalThis, "AudioContext");
|
|
} else {
|
|
globalThis.AudioContext = original;
|
|
}
|
|
},
|
|
};
|
|
}
|
|
|
|
function installMediaDevices(getUserMedia) {
|
|
const original = Object.getOwnPropertyDescriptor(navigator, "mediaDevices");
|
|
Object.defineProperty(navigator, "mediaDevices", {
|
|
configurable: true,
|
|
value: { getUserMedia },
|
|
});
|
|
return () => {
|
|
if (original) {
|
|
Object.defineProperty(navigator, "mediaDevices", original);
|
|
} else {
|
|
Reflect.deleteProperty(navigator, "mediaDevices");
|
|
}
|
|
};
|
|
}
|
|
|
|
describe("MicrophoneRecorder", () => {
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it("returns false when mediaDevices API is unavailable", async () => {
|
|
const recorder = new MicrophoneRecorder();
|
|
const restore = installMediaDevices(undefined);
|
|
Object.defineProperty(navigator, "mediaDevices", {
|
|
configurable: true,
|
|
value: undefined,
|
|
});
|
|
|
|
try {
|
|
await expect(recorder.start()).resolves.toBe(false);
|
|
} finally {
|
|
restore();
|
|
}
|
|
});
|
|
|
|
it("returns false when AudioContext is unavailable", async () => {
|
|
const recorder = new MicrophoneRecorder();
|
|
const getUserMedia = vi.fn().mockResolvedValue({
|
|
getTracks: () => [{ stop: vi.fn() }],
|
|
});
|
|
const restoreMedia = installMediaDevices(getUserMedia);
|
|
|
|
const originalAC = globalThis.AudioContext;
|
|
const originalWebkitAC = globalThis.webkitAudioContext;
|
|
Reflect.deleteProperty(globalThis, "AudioContext");
|
|
Reflect.deleteProperty(globalThis, "webkitAudioContext");
|
|
|
|
try {
|
|
await expect(recorder.start()).resolves.toBe(false);
|
|
expect(getUserMedia).not.toHaveBeenCalled();
|
|
} finally {
|
|
if (typeof originalAC !== "undefined") globalThis.AudioContext = originalAC;
|
|
if (typeof originalWebkitAC !== "undefined") globalThis.webkitAudioContext = originalWebkitAC;
|
|
restoreMedia();
|
|
}
|
|
});
|
|
|
|
it("rejects stop() before start()", async () => {
|
|
const recorder = new MicrophoneRecorder();
|
|
await expect(recorder.stop()).rejects.toThrow("Cannot stop recording before start()");
|
|
});
|
|
|
|
it("captures PCM samples and returns a WAV/PCM blob on stop", async () => {
|
|
const stopTrack = vi.fn();
|
|
const getUserMedia = vi.fn().mockResolvedValue({
|
|
getTracks: () => [{ stop: stopTrack }],
|
|
});
|
|
const restoreMedia = installMediaDevices(getUserMedia);
|
|
const audio = installFakeAudioContext({ sampleRate: 48000 });
|
|
|
|
try {
|
|
const recorder = new MicrophoneRecorder();
|
|
await expect(recorder.start()).resolves.toBe(true);
|
|
|
|
expect(audio.ctx.createMediaStreamSource).toHaveBeenCalledTimes(1);
|
|
expect(audio.ctx.createScriptProcessor).toHaveBeenCalledWith(4096, 1, 1);
|
|
expect(audio.sourceNode.connect).toHaveBeenCalledWith(audio.processorNode);
|
|
expect(audio.processorNode.connect).toHaveBeenCalledWith(audio.ctx.destination);
|
|
|
|
const frame = new Float32Array(1024);
|
|
for (let i = 0; i < frame.length; i++) {
|
|
frame[i] = Math.sin((i / frame.length) * Math.PI * 2) * 0.5;
|
|
}
|
|
audio.processorNode.onaudioprocess({
|
|
inputBuffer: { getChannelData: () => frame },
|
|
});
|
|
|
|
const blob = await recorder.stop();
|
|
expect(blob).toBeInstanceOf(Blob);
|
|
expect(blob.type).toBe("audio/wav");
|
|
|
|
const buffer = await blob.arrayBuffer();
|
|
const view = new DataView(buffer);
|
|
const magic = String.fromCharCode(view.getUint8(0), view.getUint8(1), view.getUint8(2), view.getUint8(3));
|
|
const wave = String.fromCharCode(view.getUint8(8), view.getUint8(9), view.getUint8(10), view.getUint8(11));
|
|
expect(magic).toBe("RIFF");
|
|
expect(wave).toBe("WAVE");
|
|
expect(view.getUint16(20, true)).toBe(1);
|
|
expect(view.getUint16(22, true)).toBe(1);
|
|
expect(view.getUint32(24, true)).toBe(48000);
|
|
expect(view.getUint16(34, true)).toBe(16);
|
|
expect(buffer.byteLength).toBe(44 + frame.length * 2);
|
|
expect(stopTrack).toHaveBeenCalledTimes(1);
|
|
expect(audio.ctx.close).toHaveBeenCalledTimes(1);
|
|
} finally {
|
|
audio.restore();
|
|
restoreMedia();
|
|
}
|
|
});
|
|
});
|