From 01bc144d33e56002a2852ac59c213c7fbb697609 Mon Sep 17 00:00:00 2001 From: konstantin-paulus Date: Wed, 18 Sep 2024 18:26:47 +0200 Subject: [PATCH 01/13] added common util tests --- src/utils/common.spec.ts | 58 ++++++++++++++++++++++++++++++++++++++-- src/utils/common.ts | 9 ------- 2 files changed, 56 insertions(+), 11 deletions(-) diff --git a/src/utils/common.spec.ts b/src/utils/common.spec.ts index fd8f7ed..d504d5c 100644 --- a/src/utils/common.spec.ts +++ b/src/utils/common.spec.ts @@ -5,8 +5,8 @@ * Public License, v. 2.0 that can be found in the LICENSE file. */ -import { describe, expect, it } from 'vitest'; -import { capitalize, groupBy, randInt, splitAt, toHex } from '.'; +import { describe, expect, it, vi } from 'vitest'; +import { assert, capitalize, debounce, groupBy, isClass, randInt, sleep, splitAt, toHex, uid } from '.'; describe('The common utils', () => { it.each([ @@ -83,4 +83,58 @@ describe('The common utils', () => { expect(randInt(1, 5)).toBeLessThanOrEqual(5); } }); + + it('should assert a condition (assert)', () => { + expect(() => assert(1)).not.toThrowError(); + expect(() => assert(0)).toThrowError(); + }); + + it('should debounce a method (debunce)', async () => { + const fn = vi.fn(); + + const debouncedFn = debounce(() => fn(performance.now()), 10); + + debouncedFn(); + debouncedFn(); + + await sleep(20); + + expect(fn).toBeCalledTimes(1); + + debouncedFn(); + await sleep(11); + debouncedFn(); + + await sleep(20); + + expect(fn).toBeCalledTimes(3); + + expect(fn.mock.calls[0][0]).toBeTypeOf('number'); + }); + + it('should be able to detect classes (isClass)', () => { + expect(isClass(5)).toBe(false); + expect(isClass(() => false)).toBe(false); + expect(isClass('')).toBe(false); + expect(isClass(new class { }())).toBe(false); + expect(isClass(class { })).toBe(true); + }); + + it('should be able to capitalize strings (capitalize)', () => { + expect(capitalize('hello World')).toBe('Hello World'); + }); + + it('should be able to generate short uids (uid)', () => { + expect(uid()).toBeTypeOf('string'); + expect(uid()?.length).toBe(8); + expect(uid()).not.toBe(uid()); + }); + + it('should not sleep if the number is less than 0 (sleep)', async () => { + const now = performance.now(); + + await sleep(-10); + + expect(performance.now() -now).toBeLessThan(1); + }); }); diff --git a/src/utils/common.ts b/src/utils/common.ts index 2c0f8d0..9aabaca 100644 --- a/src/utils/common.ts +++ b/src/utils/common.ts @@ -93,15 +93,6 @@ export function arraymove(arr: any[], fromIndex: number, toIndex: number) { arr.splice(toIndex, 0, element); } -/** - * Timer that rejects after n seconds - */ -export async function rejectIn(seconds: number) { - await new Promise((_resolve, reject: any) => - setTimeout(() => reject('took too long'), seconds * 1000), - ); -} - /** * Short unique id (not as secure as uuid 4 though) */ From 633d6a74eec00b33b58e8ca81a4f8575f73b5c2c Mon Sep 17 00:00:00 2001 From: konstantin-paulus Date: Wed, 18 Sep 2024 18:27:01 +0200 Subject: [PATCH 02/13] added browser util tests --- src/utils/browser.spec.ts | 78 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 src/utils/browser.spec.ts diff --git a/src/utils/browser.spec.ts b/src/utils/browser.spec.ts new file mode 100644 index 0000000..40c04c8 --- /dev/null +++ b/src/utils/browser.spec.ts @@ -0,0 +1,78 @@ +/** + * Copyright (c) 2024 The Diffusion Studio Authors + * + * This Source Code Form is subject to the terms of the Mozilla + * Public License, v. 2.0 that can be found in the LICENSE file. + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { downloadObject, showFileDialog } from './browser'; + +describe('The browser utils', () => { + const a = document.createElement('a'); + const input = document.createElement('input'); + + const clickSpy = vi.spyOn(a, 'click'); + const removeSpy = vi.spyOn(a, 'remove'); + const createSpy = vi.spyOn(document, 'createElement').mockImplementation((tag: string) => { + expect(tag).toBeTypeOf('string'); + + if (tag == 'a') { + return a; + } else { + return input; + } + }); + + beforeEach(() => { + clickSpy.mockClear(); + removeSpy.mockClear(); + createSpy.mockClear(); + }) + + it('should download an object from an url (downloadObject)', async () => { + downloadObject('https://myurl.com/example.mp4'); + expect(a.download).toBe('untitled'); + expect(a.href).toBe('https://myurl.com/example.mp4'); + + expect(createSpy).toBeCalledTimes(1); + expect(clickSpy).toBeCalledTimes(1); + expect(removeSpy).toBeCalledTimes(1); + }); + + it('should download an a blob with custom name (downloadObject)', async () => { + downloadObject(new Blob(), 'temp.mp4'); + expect(a.download).toBe('temp.mp4'); + expect(a.href).toBe('blob:chrome://new-tab-page/3dc0f2b7-7773-4cd4-a397-2e43b1bba7cd'); + + expect(createSpy).toBeCalledTimes(1); + expect(clickSpy).toBeCalledTimes(1); + expect(removeSpy).toBeCalledTimes(1); + }); + + it('should download base 64 encoded image data (downloadObject)', async () => { + const img = "data:image/svg+xml;base64,"; + downloadObject(img, 'temp.mp4'); + expect(a.download).toBe('temp.svg'); + expect(a.href).toBe('blob:chrome://new-tab-page/3dc0f2b7-7773-4cd4-a397-2e43b1bba7cd'); + + expect(createSpy).toBeCalledTimes(1); + expect(clickSpy).toBeCalledTimes(1); + expect(removeSpy).toBeCalledTimes(1); + }); + + it('should show a save dialog (showFileDialog)', async () => { + const clickSpy = vi.spyOn(input, 'click'); + + const promise = showFileDialog('video/mp4', false); + input.dispatchEvent(new Event('change')); + await promise; + + expect(input.type).toBe('file'); + expect(input.accept).toBe('video/mp4'); + expect(input.multiple).toBe(false); + + expect(createSpy).toBeCalledTimes(1); + expect(clickSpy).toBeCalledTimes(1); + }); +}); From c528574f11c4c8effa61cb837ae18d4ab13eafd9 Mon Sep 17 00:00:00 2001 From: konstantin-paulus Date: Thu, 19 Sep 2024 11:33:32 +0200 Subject: [PATCH 03/13] added audio util unit tests --- src/utils/audio.spec.ts | 481 ++++++++++++++++++++++++++++++++++++++++ src/utils/audio.ts | 24 +- 2 files changed, 495 insertions(+), 10 deletions(-) create mode 100644 src/utils/audio.spec.ts diff --git a/src/utils/audio.spec.ts b/src/utils/audio.spec.ts new file mode 100644 index 0000000..871cfdb --- /dev/null +++ b/src/utils/audio.spec.ts @@ -0,0 +1,481 @@ +/** + * Copyright (c) 2024 The Diffusion Studio Authors + * + * This Source Code Form is subject to the terms of the Mozilla + * Public License, v. 2.0 that can be found in the LICENSE file. + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { audioBufferToWav, blobToMonoBuffer, bufferToF32Planar, bufferToI16Interleaved, floatTo16BitPCM, interleave, resampleBuffer } from './audio'; + +describe('interleave', () => { + // Mock the AudioBuffer object + function createMockAudioBuffer(numberOfChannels: number, channelData: Float32Array[]): AudioBuffer { + return { + numberOfChannels, + getChannelData: vi.fn((channelIndex: number) => channelData[channelIndex]), + length: channelData.length > 0 ? channelData[0].length : 0, // Safely handle zero channels + } as unknown as AudioBuffer; + } + + it('should return single channel data as-is when there is only one channel', () => { + const channelData = new Float32Array([1, 2, 3, 4]); + const mockAudioBuffer = createMockAudioBuffer(1, [channelData]); + + const result = interleave(mockAudioBuffer); + + expect(result).toEqual(channelData); // Should return the same array for single channel + }); + + it('should interleave two-channel audio correctly', () => { + const leftChannel = new Float32Array([1, 3, 5, 7]); + const rightChannel = new Float32Array([2, 4, 6, 8]); + const mockAudioBuffer = createMockAudioBuffer(2, [leftChannel, rightChannel]); + + const result = interleave(mockAudioBuffer); + + expect(result).toEqual(new Float32Array([1, 2, 3, 4, 5, 6, 7, 8])); + }); + + it('should interleave three-channel audio correctly', () => { + const channel1 = new Float32Array([1, 4, 7]); + const channel2 = new Float32Array([2, 5, 8]); + const channel3 = new Float32Array([3, 6, 9]); + const mockAudioBuffer = createMockAudioBuffer(3, [channel1, channel2, channel3]); + + const result = interleave(mockAudioBuffer); + + expect(result).toEqual(new Float32Array([1, 2, 3, 4, 5, 6, 7, 8, 9])); + }); + + it('should handle audio buffer with different channel lengths gracefully', () => { + const channel1 = new Float32Array([1, 3]); + const channel2 = new Float32Array([2, 4, 6]); + const mockAudioBuffer = createMockAudioBuffer(2, [channel1, channel2]); + + const result = interleave(mockAudioBuffer); + + expect(result).toEqual(new Float32Array([1, 2, 3, 4, 0, 6])); // Filling with 0 as undefined behavior + }); + + it('should return an empty array if the audio buffer has no channels', () => { + const mockAudioBuffer = createMockAudioBuffer(0, []); + + const result = interleave(mockAudioBuffer); + + expect(result).toEqual(new Float32Array([])); // Empty buffer results in an empty array + }); +}); + +describe('audioBufferToWav', () => { + const sampleRate = 44100; + const monoData = new Float32Array([0.5, -0.5, 0.25, -0.25]); + const stereoDataL = new Float32Array([0.5, 0.25]); + const stereoDataR = new Float32Array([-0.5, -0.25]); + + const createMockAudioBuffer = (numberOfChannels: number, channelData: Float32Array[]) => { + return { + numberOfChannels, + length: channelData[0].length, + sampleRate, + getChannelData: vi.fn((channel: number) => channelData[channel]), + } as any as AudioBuffer; + }; + + it('should correctly convert a mono AudioBuffer to a WAV blob', () => { + const mockAudioBuffer = createMockAudioBuffer(1, [monoData]); + + const wavBlob = audioBufferToWav(mockAudioBuffer); + + expect(wavBlob).toBeInstanceOf(Blob); + expect(wavBlob.type).toBe('audio/wav'); + }); + + it('should correctly convert a stereo AudioBuffer to a WAV blob', () => { + const mockAudioBuffer = createMockAudioBuffer(2, [stereoDataL, stereoDataR]); + + const wavBlob = audioBufferToWav(mockAudioBuffer); + + expect(wavBlob).toBeInstanceOf(Blob); + expect(wavBlob.type).toBe('audio/wav'); + }); + + it('should interleave stereo AudioBuffer data correctly', () => { + const mockAudioBuffer = createMockAudioBuffer(2, [stereoDataL, stereoDataR]); + + const interleaved = interleave(mockAudioBuffer); + + expect(interleaved).toEqual(new Float32Array([0.5, -0.5, 0.25, -0.25])); + }); + + it('should interleave mono AudioBuffer data correctly (no interleaving)', () => { + const mockAudioBuffer = createMockAudioBuffer(1, [monoData]); + + const interleaved = interleave(mockAudioBuffer); + + expect(interleaved).toEqual(monoData); + }); + + it('should write correct PCM data to the DataView', () => { + const mockAudioBuffer = createMockAudioBuffer(1, [monoData]); + const interleavedData = interleave(mockAudioBuffer); + + const arrayBuffer = new ArrayBuffer(interleavedData.length * 2); + const view = new DataView(arrayBuffer); + + floatTo16BitPCM(view, interleavedData, 0); + + // Verify that the data is correctly converted to 16-bit PCM + expect(view.getInt16(0, true)).toBe(16383); // 0.5 * 32767 (max value for 16-bit PCM) + expect(view.getInt16(2, true)).toBe(-16384); // -0.5 * 32768 (min value for 16-bit PCM) + expect(view.getInt16(4, true)).toBe(8191); // 0.25 * 32767 + expect(view.getInt16(6, true)).toBe(-8192); // -0.25 * 32768 + }); + + it('should write correct WAV headers and PCM data', async () => { + const mockAudioBuffer = createMockAudioBuffer(1, [monoData]); + const wavBlob = audioBufferToWav(mockAudioBuffer); + + // Use FileReader to read the blob as an ArrayBuffer + const reader = new FileReader(); + + const arrayBufferPromise = new Promise((resolve, reject) => { + reader.onloadend = () => resolve(reader.result as ArrayBuffer); + reader.onerror = reject; + }); + + reader.readAsArrayBuffer(wavBlob); + + const buffer = await arrayBufferPromise; + const view = new DataView(buffer); + + // Check the RIFF header + expect(String.fromCharCode(view.getUint8(0))).toBe('R'); + expect(String.fromCharCode(view.getUint8(1))).toBe('I'); + expect(String.fromCharCode(view.getUint8(2))).toBe('F'); + expect(String.fromCharCode(view.getUint8(3))).toBe('F'); + + // Check the WAVE header + expect(String.fromCharCode(view.getUint8(8))).toBe('W'); + expect(String.fromCharCode(view.getUint8(9))).toBe('A'); + expect(String.fromCharCode(view.getUint8(10))).toBe('V'); + expect(String.fromCharCode(view.getUint8(11))).toBe('E'); + + // Additional header checks (optional, as per the WAV specification) + // Example: Check 'fmt ' chunk and audio format info + expect(String.fromCharCode(view.getUint8(12))).toBe('f'); + expect(String.fromCharCode(view.getUint8(13))).toBe('m'); + expect(String.fromCharCode(view.getUint8(14))).toBe('t'); + expect(view.getUint16(20, true)).toBe(1); // Audio format: PCM + }); +}); + +describe('bufferToF32Planar', () => { + // Mock the AudioBuffer for testing + function createMockAudioBuffer(channels: number, length: number, data: number[][]): AudioBuffer { + const audioBuffer = { + numberOfChannels: channels, + length: length, + getChannelData: (channel: number) => { + return new Float32Array(data[channel]); + } + } as AudioBuffer; + return audioBuffer; + } + + it('should correctly convert a mono channel AudioBuffer to a Float32Array', () => { + const mockData = [[0.1, 0.2, 0.3]]; + const buffer = createMockAudioBuffer(1, 3, mockData); + + const result = bufferToF32Planar(buffer); + + expect(result).toEqual(new Float32Array([0.1, 0.2, 0.3])); + }); + + it('should correctly convert a stereo (2 channels) AudioBuffer to a Float32Array', () => { + const mockData = [ + [0.1, 0.2, 0.3], + [0.4, 0.5, 0.6] + ]; + const buffer = createMockAudioBuffer(2, 3, mockData); + + const result = bufferToF32Planar(buffer); + + expect(result).toEqual(new Float32Array([0.1, 0.2, 0.3, 0.4, 0.5, 0.6])); + }); + + it('should return an empty Float32Array for an empty AudioBuffer', () => { + const buffer = createMockAudioBuffer(1, 0, [[]]); + + const result = bufferToF32Planar(buffer); + + expect(result).toEqual(new Float32Array()); + }); + + it('should handle multiple channels and varying data lengths correctly', () => { + // Ensure both channels have the same length by padding with zeros if necessary + const mockData = [ + [0.1, 0.2], // 2 samples + [0.3, 0.4, 0.5] // 3 samples + ]; + + // The buffer length should be the same for all channels (3 in this case) + const paddedData = mockData.map(data => { + const padded = new Array(3).fill(0); // Pad to length 3 + data.forEach((val, idx) => padded[idx] = val); + return padded; + }); + + const buffer = createMockAudioBuffer(2, 3, paddedData); + + const result = bufferToF32Planar(buffer); + + // The expected result should include the padded zeros + expect(result).toEqual(new Float32Array([0.1, 0.2, 0, 0.3, 0.4, 0.5])); + }); +}); + +describe('bufferToI16Interleaved', () => { + const createMockAudioBuffer = (numberOfChannels: number, channelData: Float32Array[], sampleRate = 44100) => { + return { + numberOfChannels, + length: channelData[0].length, + sampleRate, + getChannelData: vi.fn((channel: number) => channelData[channel]), + } as any as AudioBuffer; + }; + + it('should handle a single channel correctly', () => { + const audioData = [new Float32Array([0.5, -0.5, 0])]; // Simulate mono audio buffer with 3 samples + const mockAudioBuffer = createMockAudioBuffer(1, audioData); + + const result = bufferToI16Interleaved(mockAudioBuffer); + + expect(result).toEqual(new Int16Array([16383, -16383, 0])); // Expected interleaved 16-bit PCM values + }); + + it('should handle two channels correctly', () => { + const audioData = [ + new Float32Array([0.5, -0.5, 0]), // Channel 1 + new Float32Array([-1, 1, 0.25]), // Channel 2 + ]; + const mockAudioBuffer = createMockAudioBuffer(2, audioData); + + const result = bufferToI16Interleaved(mockAudioBuffer); + + // Expected interleaved format for stereo: [Ch1Sample1, Ch2Sample1, Ch1Sample2, Ch2Sample2, ...] + expect(result).toEqual(new Int16Array([16383, -32767, -16383, 32767, 0, 8191])); + }); + + it('should handle empty audio buffer', () => { + const audioData: Float32Array[] = [new Float32Array([])]; // No samples + const mockAudioBuffer = createMockAudioBuffer(1, audioData); + + const result = bufferToI16Interleaved(mockAudioBuffer); + + expect(result).toEqual(new Int16Array([])); // Expect empty array + }); + + it('should handle multi-channel audio', () => { + const audioData = [ + new Float32Array([1, -1]), // Channel 1 + new Float32Array([-0.5, 0]), // Channel 2 + new Float32Array([0.25, -0.25]), // Channel 3 + ]; + const mockAudioBuffer = createMockAudioBuffer(3, audioData); + + const result = bufferToI16Interleaved(mockAudioBuffer); + + // Expected interleaved format for 3 channels: [Ch1Sample1, Ch2Sample1, Ch3Sample1, Ch1Sample2, Ch2Sample2, Ch3Sample2, ...] + expect(result).toEqual(new Int16Array([32767, -16383, 8191, -32767, 0, -8191])); + }); + + it('should correctly clamp values outside of the [-1, 1] range', () => { + const audioData = [new Float32Array([1.5, -2, 0.75])]; // Values out of the [-1, 1] range + const mockAudioBuffer = createMockAudioBuffer(1, audioData); + + const result = bufferToI16Interleaved(mockAudioBuffer); + + // Values above 1 should be clamped to 32767, below -1 clamped to -32767 + expect(result).toEqual(new Int16Array([32767, -32767, 24575])); // 0.75 * 32767 ≈ 24575 + }); +}); + +describe('blobToMonoBuffer', () => { + // Mock for AudioBuffer + class MockAudioBuffer { + numberOfChannels: number; + length: number; + sampleRate: number; + private channels: Float32Array[]; + + constructor(numberOfChannels: number, length: number, sampleRate: number) { + this.numberOfChannels = numberOfChannels; + this.length = length; + this.sampleRate = sampleRate; + this.channels = Array(numberOfChannels) + .fill(null) + .map(() => new Float32Array(length)); + } + + getChannelData(channel: number) { + return this.channels[channel]; + } + } + + // Mock for OfflineAudioContext + class MockOfflineAudioContext { + sampleRate: number; + length: number; + + constructor({ sampleRate, length }: { sampleRate: number; length: number }) { + this.sampleRate = sampleRate; + this.length = length; + } + + createBuffer(numberOfChannels: number, length: number, sampleRate: number) { + return new MockAudioBuffer(numberOfChannels, length, sampleRate); + } + + async decodeAudioData(_: ArrayBuffer) { + // Always return a 2-channel buffer for simplicity + return new MockAudioBuffer(2, 100, this.sampleRate); + } + } + + it('merges stereo to mono', async () => { + const mockBlob = { + arrayBuffer: async () => new ArrayBuffer(100) // Mock arrayBuffer method + }; + + // Mock the OfflineAudioContext within the scope of this test + const createBufferSpy = vi.spyOn(MockOfflineAudioContext.prototype, 'createBuffer'); + const decodeAudioDataSpy = vi.spyOn(MockOfflineAudioContext.prototype, 'decodeAudioData'); + + // Inject the mock class instead of the real OfflineAudioContext + vi.stubGlobal('OfflineAudioContext', MockOfflineAudioContext); + + const result = await blobToMonoBuffer(mockBlob as unknown as Blob, 44100); + + // Validate the outcome + expect(result.numberOfChannels).toBe(1); + expect(result.length).toBe(100); + expect(result.sampleRate).toBe(44100); + expect(createBufferSpy).toHaveBeenCalled(); + expect(decodeAudioDataSpy).toHaveBeenCalled(); + + // Restore the original context after test + vi.restoreAllMocks(); + }); + + it('returns the original buffer if mono', async () => { + const mockBlob = { + arrayBuffer: async () => new ArrayBuffer(100) // Mock arrayBuffer method + }; + + // Mock the OfflineAudioContext and modify the decoded buffer to mono + const decodeAudioDataSpy = vi.spyOn(MockOfflineAudioContext.prototype, 'decodeAudioData') + .mockResolvedValueOnce(new MockAudioBuffer(1, 100, 44100)); + + vi.stubGlobal('OfflineAudioContext', MockOfflineAudioContext); + + const result = await blobToMonoBuffer(mockBlob as unknown as Blob, 44100); + + // Validate the buffer is mono and the function handled it correctly + expect(result.numberOfChannels).toBe(1); + expect(result.length).toBe(100); + expect(result.sampleRate).toBe(44100); + expect(decodeAudioDataSpy).toHaveBeenCalled(); + + // Restore the original context + vi.restoreAllMocks(); + }); +}); + +describe('resampleBuffer', () => { + let OfflineAudioContextMock: any; + + // Setup before each test + beforeEach(() => { + // Mocking OfflineAudioContext constructor + OfflineAudioContextMock = vi.fn(() => ({ + createBuffer: vi.fn((numberOfChannels, length, sampleRate) => ({ + numberOfChannels, + length, + sampleRate, + getChannelData: vi.fn(() => new Float32Array(length)), + })), + })); + + // Mocking it for the local context of this test suite + vi.spyOn(window, 'OfflineAudioContext').mockImplementation(OfflineAudioContextMock); + }); + + // Clean up after each test + afterEach(() => { + vi.restoreAllMocks(); // Restore original implementations + }); + + // Helper function to create a mock AudioBuffer + const createMockAudioBuffer = (sampleRate: number, duration: number, numberOfChannels: number) => { + const length = Math.floor(sampleRate * duration); + return { + sampleRate, + duration, + numberOfChannels, + length, + getChannelData: vi.fn(() => new Float32Array(length)), + } as unknown as AudioBuffer; + }; + + it('should return the same buffer if sample rate and number of channels match', () => { + const buffer = createMockAudioBuffer(44100, 2, 2); // 44100 Hz, 2 seconds, 2 channels + const result = resampleBuffer(buffer, 44100, 2); + + expect(result).toBe(buffer); // Should return the same buffer + }); + + it('should resample the buffer to a different sample rate', () => { + const buffer = createMockAudioBuffer(44100, 2, 2); // Original buffer with 44100 Hz, 2 channels + const newSampleRate = 22050; // Resampling to 22050 Hz + const result = resampleBuffer(buffer, newSampleRate, 2); + + expect(result.sampleRate).toBe(newSampleRate); + expect(result.length).toBeLessThan(buffer.length); // Length should be less than original + expect(result.numberOfChannels).toBe(2); + }); + + it('should change the number of channels if requested', () => { + const buffer = createMockAudioBuffer(44100, 2, 1); // 44100 Hz, 2 seconds, 1 channel + const result = resampleBuffer(buffer, 44100, 2); // Change to 2 channels + + expect(result.numberOfChannels).toBe(2); // Should now have 2 channels + expect(result.length).toBe(buffer.length); + }); + + it('should properly interpolate audio data when downsampling', () => { + const buffer = createMockAudioBuffer(44100, 1, 1); // 44100 Hz, 1 second, 1 channel + const result = resampleBuffer(buffer, 22050, 1); // Downsample to 22050 Hz + + expect(result.sampleRate).toBe(22050); + expect(result.length).toBe(Math.floor(buffer.duration * 22050)); + }); + + it('should create a buffer of the correct length', () => { + const buffer = createMockAudioBuffer(44100, 3, 2); // 3-second buffer at 44100 Hz + const newSampleRate = 48000; // Resample to 48000 Hz + const result = resampleBuffer(buffer, newSampleRate, 2); + + const expectedLength = Math.floor(buffer.duration * newSampleRate); + expect(result.length).toBe(expectedLength); + }); + + it('should handle edge case when the original buffer has a length of 0', () => { + const buffer = createMockAudioBuffer(44100, 0, 2); // Empty buffer + + const result = resampleBuffer(buffer, 48000, 2); + expect(result.length).toBe(0); + expect(result.sampleRate).toBe(48000); + expect(result.numberOfChannels).toBe(2); + }); +}); diff --git a/src/utils/audio.ts b/src/utils/audio.ts index 5a4b0e6..c0fd7c1 100644 --- a/src/utils/audio.ts +++ b/src/utils/audio.ts @@ -19,18 +19,18 @@ export function interleave(input: AudioBuffer): Float32Array { for (let i = 0; i < input.numberOfChannels; i++) { channels.push(input.getChannelData(i)); } - const length = channels.reduce((prev, channelData) => prev + channelData.length, 0); - const result = new Float32Array(length); + + const maxLength = Math.max(...channels.map(channelData => channelData.length)); + const result = new Float32Array(maxLength * input.numberOfChannels); let index = 0; let inputIndex = 0; // for 2 channels its like: [L[0], R[0], L[1], R[1], ... , L[n], R[n]] - while (index < length) { + while (inputIndex < maxLength) { channels.forEach((channelData) => { - result[index++] = channelData[inputIndex]; + result[index++] = channelData[inputIndex] !== undefined ? channelData[inputIndex] : 0; }); - inputIndex++; } @@ -40,7 +40,7 @@ export function interleave(input: AudioBuffer): Float32Array { /** * Writes a string to a DataView at the specified offset. */ -export function stringToDataView(dataview: DataView, offset: number, header: string): void { +function stringToDataView(dataview: DataView, offset: number, header: string): void { for (let i = 0; i < header.length; i++) { dataview.setUint8(offset + i, header.charCodeAt(i)); } @@ -66,7 +66,7 @@ export function floatTo16BitPCM( * * Returns a DataView containing the WAV headers and file content. */ -export function writeWavHeaders( +function writeWavHeaders( buffer: Float32Array, numOfChannels: number, sampleRate: number, @@ -124,7 +124,7 @@ export function bufferToF32Planar(input: AudioBuffer): Float32Array { for (let i = 0; i < input.numberOfChannels; i++) { const data = input.getChannelData(i); result.set(data, offset); - offset = data.length; + offset += data.length; } return result; @@ -140,7 +140,12 @@ export function bufferToI16Interleaved(audioBuffer: AudioBuffer): Int16Array { for (let i = 0; i < length; i++) { for (let channel = 0; channel < numberOfChannels; channel++) { - const sample = audioBuffer.getChannelData(channel)[i] * 32767; // Convert float [-1,1] to 16-bit PCM + let sample = audioBuffer.getChannelData(channel)[i] * 32767; // Convert float [-1,1] to 16-bit PCM + + // Clamp values to the Int16 range + if (sample > 32767) sample = 32767; + if (sample < -32767) sample = -32767; + interleaved[i * numberOfChannels + channel] = sample; } } @@ -148,7 +153,6 @@ export function bufferToI16Interleaved(audioBuffer: AudioBuffer): Int16Array { return interleaved; } - /** * Merges the channels of the audio blob into a mono AudioBuffer */ From 6980b813b2a630b98b2799770ac9c9c5e0990489 Mon Sep 17 00:00:00 2001 From: konstantin-paulus Date: Thu, 19 Sep 2024 12:46:24 +0200 Subject: [PATCH 04/13] added track unit tests --- src/tracks/caption/preset.solar.spec.ts | 69 +++++++++++++++++++++++ src/tracks/caption/preset.verdant.spec.ts | 67 ++++++++++++++++++++++ src/tracks/caption/preset.whisper.spec.ts | 67 ++++++++++++++++++++++ src/tracks/track/track.spec.ts | 42 ++++++++++++++ 4 files changed, 245 insertions(+) create mode 100644 src/tracks/caption/preset.solar.spec.ts create mode 100644 src/tracks/caption/preset.verdant.spec.ts create mode 100644 src/tracks/caption/preset.whisper.spec.ts diff --git a/src/tracks/caption/preset.solar.spec.ts b/src/tracks/caption/preset.solar.spec.ts new file mode 100644 index 0000000..c9be8ee --- /dev/null +++ b/src/tracks/caption/preset.solar.spec.ts @@ -0,0 +1,69 @@ +/** + * Copyright (c) 2024 The Diffusion Studio Authors + * + * This Source Code Form is subject to the terms of the Mozilla + * Public License, v. 2.0 that can be found in the LICENSE file. + */ + +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { Composition } from '../../composition'; +import { MediaClip, TextClip } from '../../clips'; +import { Transcript, Word, WordGroup } from '../../models'; +import { CaptionTrack } from './caption'; +import { SolarCaptionPreset } from './preset.solar'; +import { GlowFilter } from 'pixi-filters'; + +describe('The SolarCaptionPreset', () => { + const mockFn = vi.fn(); + Object.assign(document, { fonts: { add: mockFn } }); + + let composition: Composition; + let track: CaptionTrack; + + beforeEach(() => { + composition = new Composition(); + track = composition.createTrack('caption'); + }); + + it('should apply complex clips to the track', async () => { + await track + .from(new MediaClip({ transcript })) + .generate(SolarCaptionPreset); + + expect(track.clips.length).toBe(13); + expect(track.clips[0]).toBeInstanceOf(TextClip); + expect(track.clips[0].start.frames).toBe(0); + expect(track.clips[0].text).toBe('Lorem'); + expect(track.clips[0].filters).toBeInstanceOf(GlowFilter); + }); + + it('should not apply clips if the transcript or composition is not devined', async () => { + await expect(() => track + .from(new MediaClip()) + .generate(SolarCaptionPreset)).rejects.toThrowError(); + + await expect(() => new CaptionTrack() + .from(new MediaClip({ transcript })) + .generate(SolarCaptionPreset)).rejects.toThrowError(); + }); +}); + +const transcript = new Transcript([ + new WordGroup([ + new Word('Lorem', 0, 1e3), + new Word('Ipsum', 2e3, 3e3), + new Word('is', 4e3, 5e3), + new Word('simply', 6e3, 7e3), + new Word('dummy', 8e3, 9e3), + new Word('text', 10e3, 11e3), + new Word('of', 12e3, 13e3), + new Word('the', 14e3, 15e3), + new Word('printing', 16e3, 17e3), + new Word('and', 18e3, 19e3), + new Word('typesetting', 20e3, 21e3), + new Word('industry', 22e3, 23e3), + ]), + new WordGroup([ + new Word('Lorem', 24e3, 25e3), + ]), +]); diff --git a/src/tracks/caption/preset.verdant.spec.ts b/src/tracks/caption/preset.verdant.spec.ts new file mode 100644 index 0000000..beb598b --- /dev/null +++ b/src/tracks/caption/preset.verdant.spec.ts @@ -0,0 +1,67 @@ +/** + * Copyright (c) 2024 The Diffusion Studio Authors + * + * This Source Code Form is subject to the terms of the Mozilla + * Public License, v. 2.0 that can be found in the LICENSE file. + */ + +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { Composition } from '../../composition'; +import { ComplexTextClip, MediaClip } from '../../clips'; +import { Transcript, Word, WordGroup } from '../../models'; +import { CaptionTrack } from './caption'; +import { VerdantCaptionPreset } from './preset.verdant'; + +describe('The VerdantCaptionPreset', () => { + const mockFn = vi.fn(); + Object.assign(document, { fonts: { add: mockFn } }); + + let composition: Composition; + let track: CaptionTrack; + + beforeEach(() => { + composition = new Composition(); + track = composition.createTrack('caption'); + }); + + it('should apply complex clips to the track', async () => { + await track + .from(new MediaClip({ transcript })) + .generate(VerdantCaptionPreset); + + expect(track.clips.length).toBe(13); + expect(track.clips[0]).toBeInstanceOf(ComplexTextClip); + expect(track.clips[0].start.frames).toBe(0); + expect(track.clips[0].text).toBe('Lorem'); + }); + + it('should not apply clips if the transcript or composition is not devined', async () => { + await expect(() => track + .from(new MediaClip()) + .generate(VerdantCaptionPreset)).rejects.toThrowError(); + + await expect(() => new CaptionTrack() + .from(new MediaClip({ transcript })) + .generate(VerdantCaptionPreset)).rejects.toThrowError(); + }); +}); + +const transcript = new Transcript([ + new WordGroup([ + new Word('Lorem', 0, 1e3), + new Word('Ipsum', 2e3, 3e3), + new Word('is', 4e3, 5e3), + new Word('simply', 6e3, 7e3), + new Word('dummy', 8e3, 9e3), + new Word('text', 10e3, 11e3), + new Word('of', 12e3, 13e3), + new Word('the', 14e3, 15e3), + new Word('printing', 16e3, 17e3), + new Word('and', 18e3, 19e3), + new Word('typesetting', 20e3, 21e3), + new Word('industry', 22e3, 23e3), + ]), + new WordGroup([ + new Word('Lorem', 24e3, 25e3), + ]), +]); diff --git a/src/tracks/caption/preset.whisper.spec.ts b/src/tracks/caption/preset.whisper.spec.ts new file mode 100644 index 0000000..5bbf0e7 --- /dev/null +++ b/src/tracks/caption/preset.whisper.spec.ts @@ -0,0 +1,67 @@ +/** + * Copyright (c) 2024 The Diffusion Studio Authors + * + * This Source Code Form is subject to the terms of the Mozilla + * Public License, v. 2.0 that can be found in the LICENSE file. + */ + +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { Composition } from '../../composition'; +import { ComplexTextClip, MediaClip } from '../../clips'; +import { Transcript, Word, WordGroup } from '../../models'; +import { CaptionTrack } from './caption'; +import { WhisperCaptionPreset } from './preset.whisper'; + +describe('The WhisperCaptionPreset', () => { + const mockFn = vi.fn(); + Object.assign(document, { fonts: { add: mockFn } }); + + let composition: Composition; + let track: CaptionTrack; + + beforeEach(() => { + composition = new Composition(); + track = composition.createTrack('caption'); + }); + + it('should apply complex clips to the track', async () => { + await track + .from(new MediaClip({ transcript })) + .generate(WhisperCaptionPreset); + + expect(track.clips.length).toBe(13); + expect(track.clips[0]).toBeInstanceOf(ComplexTextClip); + expect(track.clips[0].start.frames).toBe(0); + expect(track.clips[0].text).toBe('Lorem Ipsum is simply'); + }); + + it('should not apply clips if the transcript or composition is not devined', async () => { + await expect(() => track + .from(new MediaClip()) + .generate(WhisperCaptionPreset)).rejects.toThrowError(); + + await expect(() => new CaptionTrack() + .from(new MediaClip({ transcript })) + .generate(WhisperCaptionPreset)).rejects.toThrowError(); + }); +}); + +const transcript = new Transcript([ + new WordGroup([ + new Word('Lorem', 0, 1e3), + new Word('Ipsum', 2e3, 3e3), + new Word('is', 4e3, 5e3), + new Word('simply', 6e3, 7e3), + new Word('dummy', 8e3, 9e3), + new Word('text', 10e3, 11e3), + new Word('of', 12e3, 13e3), + new Word('the', 14e3, 15e3), + new Word('printing', 16e3, 17e3), + new Word('and', 18e3, 19e3), + new Word('typesetting', 20e3, 21e3), + new Word('industry', 22e3, 23e3), + ]), + new WordGroup([ + new Word('Lorem', 24e3, 25e3), + ]), +]); diff --git a/src/tracks/track/track.spec.ts b/src/tracks/track/track.spec.ts index 0dc2ca0..de91d74 100644 --- a/src/tracks/track/track.spec.ts +++ b/src/tracks/track/track.spec.ts @@ -290,6 +290,48 @@ describe('The Track Object', () => { expect(track.clips.at(0)?.stop.frames).toBe(10); }); + it('should switch from stack to default', async () => { + track.stacked(); + + await track.add(new Clip({ stop: 12})); + await track.add(new Clip({ stop: 24})); + + expect(track.clips.length).toBe(2); + expect(track.stop.frames).toBe(36); + + track.stacked(false); + + // should not be realigned + await track.add(new Clip({ start: 72, stop: 99 })); + + expect(track.stop.frames).toBe(99); + }); + + it('should apply values to all clips', async () => { + track.stacked(); + + await track.add(new Clip({ stop: 12})); + await track.add(new Clip({ stop: 24})); + + track.apply(clip => clip.set({ name: 'foo' })); + + expect(track.clips[0].name).toBe('foo'); + expect(track.clips[1].name).toBe('foo'); + }); + + it('should offset all clips by a given frame numer', async () => { + await track.add(new Clip({ start: 6, stop: 12})); + await track.add(new Clip({ start: 15, stop: 24})); + + track.offsetBy(6); + + expect(track.clips[0].start.frames).toBe(12); + expect(track.clips[0].stop.frames).toBe(18); + + expect(track.clips[1].start.frames).toBe(21); + expect(track.clips[1].stop.frames).toBe(30); + }); + it('should be be able to add a base clip with offset', async () => { const clip = new Clip({ stop: 30 }); From ebc8a70e398c9ceb2dd8337ba1217eeba4577308 Mon Sep 17 00:00:00 2001 From: konstantin-paulus Date: Thu, 19 Sep 2024 20:39:09 +0200 Subject: [PATCH 05/13] added source unit tests --- src/sources/audio.spec.ts | 58 ++++++++++++++++ src/sources/html.spec.ts | 48 ++++++++++++++ src/sources/html.utils.spec.ts | 117 +++++++++++++++++++++++++++++++++ src/sources/html.utils.ts | 3 +- src/sources/image.spec.ts | 22 +++++++ src/sources/source.spec.ts | 75 +++++++++++++++++++++ src/sources/source.ts | 2 +- src/sources/video.spec.ts | 56 ++++++++++++++++ 8 files changed, 379 insertions(+), 2 deletions(-) create mode 100644 src/sources/audio.spec.ts create mode 100644 src/sources/html.utils.spec.ts create mode 100644 src/sources/image.spec.ts diff --git a/src/sources/audio.spec.ts b/src/sources/audio.spec.ts new file mode 100644 index 0000000..8413a59 --- /dev/null +++ b/src/sources/audio.spec.ts @@ -0,0 +1,58 @@ +/** + * Copyright (c) 2024 The Diffusion Studio Authors + * + * This Source Code Form is subject to the terms of the Mozilla + * Public License, v. 2.0 that can be found in the LICENSE file. + */ + +import { describe, it, vi, beforeEach, expect } from 'vitest'; +import { AudioSource } from './audio'; // Import the AudioSource class + +// Mocking the OfflineAudioContext class +class MockOfflineAudioContext { + constructor(public numberOfChannels: number, public length: number, public sampleRate: number) { } + + decodeAudioData(arrayBuffer: ArrayBuffer): Promise { + const audioBuffer = { + duration: 5, // Mock duration + sampleRate: this.sampleRate, + getChannelData: () => new Float32Array(5000), // Return a dummy Float32Array + } as any as AudioBuffer; + return Promise.resolve(audioBuffer); + } +} + +vi.stubGlobal('OfflineAudioContext', MockOfflineAudioContext); // Stub the global OfflineAudioContext + +describe('AudioSource', () => { + let audioSource: AudioSource; + + beforeEach(() => { + audioSource = new AudioSource(); + audioSource.file = new File([], 'audio.mp3', { type: 'audio/mp3' }); + }); + + it('should decode an audio buffer correctly', async () => { + const buffer = await audioSource.decode(2, 44100); + expect(buffer.duration).toBe(5); // Mock duration + expect(buffer.sampleRate).toBe(44100); + expect(audioSource.audioBuffer).toBe(buffer); + expect(audioSource.duration.seconds).toBe(5); // Ensure duration is set + }); + + it('should create a thumbnail with correct DOM elements', async () => { + const thumbnail = await audioSource.thumbnail(60, 50, 0); + + // Check if the thumbnail is a div + expect(thumbnail.tagName).toBe('DIV'); + expect(thumbnail.className).toContain('audio-samples'); + + // Check if it has the right number of children + expect(thumbnail.children.length).toBe(60); + + // Check if each child has the correct class + for (const child of thumbnail.children) { + expect(child.className).toContain('audio-sample-item'); + } + }); +}); diff --git a/src/sources/html.spec.ts b/src/sources/html.spec.ts index c8cca5f..d12419d 100644 --- a/src/sources/html.spec.ts +++ b/src/sources/html.spec.ts @@ -7,6 +7,8 @@ import { describe, expect, it, vi } from 'vitest'; import { HtmlSource } from './html'; +import { setFetchMockReturnValue } from '../../vitest.mocks'; +import { sleep } from '../utils'; describe('The Html Source Object', () => { it('should create an object url when the iframe loads', async () => { @@ -43,6 +45,52 @@ describe('The Html Source Object', () => { await expect(() => source.from(file)).rejects.toThrowError(); expect(evtMock).toHaveBeenCalledTimes(1); }); + + it('should have a valid document getter', async () => { + const source = new HtmlSource(); + + expect(source.document).toBeTruthy(); + }); + + it('should create an object url after the fetch has been completed', async () => { + const resetFetch = setFetchMockReturnValue({ + ok: true, + blob: async () => { + await sleep(10); + return new Blob([], { type: 'text/html' }); + }, + }); + + const source = new HtmlSource(); + + mockIframeValid(source); + + source.from('https://external.html'); + + expect(source.objectURL).toBeUndefined(); + + const url = await source.createObjectURL(); + + expect(url).toBe("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg'%3E%3C/svg%3E"); + + expect(source.objectURL).toBeDefined(); + + resetFetch(); + }); + + it('should retrun a video as thumbnail', async () => { + const file = new File([], 'test.html', { type: 'text/html' }); + const source = new HtmlSource(); + + mockIframeValid(source); + mockDocumentValid(source); + + await source.from(file); + + const thumbnail = await source.thumbnail(); + + expect(thumbnail).toBeInstanceOf(Image); + }); }); function mockIframeValid(source: HtmlSource) { diff --git a/src/sources/html.utils.spec.ts b/src/sources/html.utils.spec.ts new file mode 100644 index 0000000..0c9858b --- /dev/null +++ b/src/sources/html.utils.spec.ts @@ -0,0 +1,117 @@ +/** + * Copyright (c) 2024 The Diffusion Studio Authors + * + * This Source Code Form is subject to the terms of the Mozilla + * Public License, v. 2.0 that can be found in the LICENSE file. + */ + +import { describe, expect, it, vi } from 'vitest'; +import { documentToSvgImageUrl, fontToBas64Url } from './html.utils'; +import { setFetchMockReturnValue } from '../../vitest.mocks'; + +describe('documentToSvgImageUrl', () => { + it('should return empty SVG if document is not provided', () => { + const result = documentToSvgImageUrl(); + expect(result).toBe("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg'%3E%3C/svg%3E"); + }); + + it('should return empty SVG if document has no body', () => { + const mockDocument = document.implementation.createDocument('', '', null); + const result = documentToSvgImageUrl(mockDocument); + expect(result).toBe("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg'%3E%3C/svg%3E"); + }); + + it('should return valid SVG when document has body and style', () => { + const mockDocument = document.implementation.createHTMLDocument('Test Document'); + const body = mockDocument.body; + const style = mockDocument.createElement('style'); + style.textContent = 'body { background: red; }'; + mockDocument.head.appendChild(style); + body.innerHTML = '
Hello World
'; + + const result = documentToSvgImageUrl(mockDocument); + + // Check if result starts with valid data URI and contains parts of the body and style + expect(result).toContain('data:image/svg+xml;base64,'); + const decodedSvg = atob(result.split(',')[1]); + expect(decodedSvg).toContain('Hello World'); + expect(decodedSvg).toContain('body { background: red; }'); + }); + + it('should return valid SVG when document has body but no style', () => { + const mockDocument = document.implementation.createHTMLDocument('Test Document'); + const body = mockDocument.body; + body.innerHTML = '
Hello World
'; + + const result = documentToSvgImageUrl(mockDocument); + + // Check if result starts with valid data URI and contains parts of the body + expect(result).toContain('data:image/svg+xml;base64,'); + const decodedSvg = atob(result.split(',')[1]); + expect(decodedSvg).toContain('Hello World'); + expect(decodedSvg).not.toContain('