diff options
Diffstat (limited to 'apps/web/src/voiceover/__tests__')
| -rw-r--r-- | apps/web/src/voiceover/__tests__/devices.test.ts | 72 | ||||
| -rw-r--r-- | apps/web/src/voiceover/__tests__/enhancements.test.ts | 133 |
2 files changed, 205 insertions, 0 deletions
diff --git a/apps/web/src/voiceover/__tests__/devices.test.ts b/apps/web/src/voiceover/__tests__/devices.test.ts new file mode 100644 index 00000000..524e8c3f --- /dev/null +++ b/apps/web/src/voiceover/__tests__/devices.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, test } from "bun:test"; +import { + DEFAULT_AUDIO_INPUT_DEVICE_ID, + buildVoiceOverAudioConstraints, + normalizeSelectedDeviceId, + type VoiceOverInputDevice, +} from "@/voiceover/devices"; + +const devices: VoiceOverInputDevice[] = [ + { + deviceId: DEFAULT_AUDIO_INPUT_DEVICE_ID, + label: "System default microphone", + isDefault: true, + }, + { + deviceId: "blue-yeti-snowball", + label: "Blue Snowball", + isDefault: false, + }, +]; + +describe("buildVoiceOverAudioConstraints", () => { + test("uses default processing constraints without an exact device", () => { + const constraints = buildVoiceOverAudioConstraints({ deviceId: null }); + expect(constraints.audio).toEqual({ + echoCancellation: true, + noiseSuppression: true, + autoGainControl: true, + }); + }); + + test("adds an exact device constraint for a selected microphone", () => { + const constraints = buildVoiceOverAudioConstraints({ + deviceId: "blue-yeti-snowball", + }); + expect(constraints.audio).toEqual({ + deviceId: { exact: "blue-yeti-snowball" }, + echoCancellation: true, + noiseSuppression: true, + autoGainControl: true, + }); + }); +}); + +describe("normalizeSelectedDeviceId", () => { + test("keeps the selected device when it is still present", () => { + expect( + normalizeSelectedDeviceId({ + deviceId: "blue-yeti-snowball", + devices, + }), + ).toBe("blue-yeti-snowball"); + }); + + test("clears a selected device when it is no longer present", () => { + expect( + normalizeSelectedDeviceId({ + deviceId: "missing", + devices, + }), + ).toBeNull(); + }); + + test("keeps the browser default device id", () => { + expect( + normalizeSelectedDeviceId({ + deviceId: DEFAULT_AUDIO_INPUT_DEVICE_ID, + devices: [], + }), + ).toBe(DEFAULT_AUDIO_INPUT_DEVICE_ID); + }); +}); diff --git a/apps/web/src/voiceover/__tests__/enhancements.test.ts b/apps/web/src/voiceover/__tests__/enhancements.test.ts new file mode 100644 index 00000000..bc5b4b7c --- /dev/null +++ b/apps/web/src/voiceover/__tests__/enhancements.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, test } from "bun:test"; +import { + DEFAULT_VOICE_OVER_ENHANCEMENTS, + encodeAudioBufferToWav, + getAudioBufferPeak, + hasEnabledVoiceOverEnhancement, + type VoiceOverEnhancementSettings, +} from "@/voiceover/enhancements"; + +/* eslint-disable opencut/prefer-object-params -- Browser AudioBuffer methods are positional. */ +class FakeAudioBuffer implements AudioBuffer { + duration: number; + length: number; + numberOfChannels: number; + sampleRate: number; + + constructor( + private readonly channels: Array<Float32Array<ArrayBuffer>>, + sampleRate: number, + ) { + this.length = channels[0]?.length ?? 0; + this.numberOfChannels = channels.length; + this.sampleRate = sampleRate; + this.duration = this.length / sampleRate; + } + + copyFromChannel( + destination: Float32Array<ArrayBuffer>, + channelNumber: number, + bufferOffset = 0, + ): void { + destination.set( + this.getChannelData(channelNumber).slice( + bufferOffset, + bufferOffset + destination.length, + ), + ); + } + + copyToChannel( + source: Float32Array<ArrayBuffer>, + channelNumber: number, + bufferOffset = 0, + ): void { + this.getChannelData(channelNumber).set(source, bufferOffset); + } + + getChannelData(channel: number): Float32Array<ArrayBuffer> { + const data = this.channels[channel]; + if (!data) { + throw new Error(`Missing channel ${channel}`); + } + return data; + } +} +/* eslint-enable opencut/prefer-object-params */ + +describe("hasEnabledVoiceOverEnhancement", () => { + test("detects enabled defaults", () => { + expect( + hasEnabledVoiceOverEnhancement({ + settings: DEFAULT_VOICE_OVER_ENHANCEMENTS, + }), + ).toBe(true); + }); + + test("detects an all-disabled chain", () => { + const settings: VoiceOverEnhancementSettings = { + voiceBoost: false, + noiseGate: false, + highPass: false, + deEss: false, + limiter: false, + normalize: false, + }; + expect(hasEnabledVoiceOverEnhancement({ settings })).toBe(false); + }); +}); + +describe("getAudioBufferPeak", () => { + test("returns the maximum absolute sample", () => { + const buffer = new FakeAudioBuffer( + [ + createSamples([0, -0.25, 0.5]), + createSamples([0.1, -0.8, 0.2]), + ], + 48000, + ); + + expect(getAudioBufferPeak({ buffer })).toBeCloseTo(0.8); + }); +}); + +describe("encodeAudioBufferToWav", () => { + test("writes a 16-bit PCM WAV header", async () => { + const buffer = new FakeAudioBuffer( + [createSamples([0, 0.5, -0.5])], + 44100, + ); + + const blob = encodeAudioBufferToWav({ buffer }); + const view = new DataView(await blob.arrayBuffer()); + + expect(blob.type).toBe("audio/wav"); + expect(blob.size).toBe(50); + expect(readAscii({ view, offset: 0, length: 4 })).toBe("RIFF"); + expect(readAscii({ view, offset: 8, length: 4 })).toBe("WAVE"); + expect(view.getUint16(22, true)).toBe(1); + expect(view.getUint32(24, true)).toBe(44100); + expect(view.getUint16(34, true)).toBe(16); + expect(readAscii({ view, offset: 36, length: 4 })).toBe("data"); + }); +}); + +function readAscii({ + view, + offset, + length, +}: { + view: DataView; + offset: number; + length: number; +}): string { + return Array.from({ length }, (_, index) => + String.fromCharCode(view.getUint8(offset + index)), + ).join(""); +} + +function createSamples(values: number[]): Float32Array<ArrayBuffer> { + const samples = new Float32Array(new ArrayBuffer(values.length * 4)); + samples.set(values); + return samples; +} |
