diff --git a/.gitignore b/.gitignore index ce09c9b..c755f42 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ dist examples/dist rust/target coverage +pnpm-lock.yaml # tests __tests__/integration/**/*-diff.png diff --git a/__tests__/api-integration.test.ts b/__tests__/api-integration.test.ts new file mode 100644 index 0000000..186872f --- /dev/null +++ b/__tests__/api-integration.test.ts @@ -0,0 +1,162 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { Audio } from '../src/Audio'; +import { Effect } from '../src/effects'; + +globalThis.AudioContext = class { + destination = {}; + createMediaElementSource() { + return { connect: vi.fn() }; + } + createAnalyser() { + return { + connect: vi.fn(), + fftSize: 0, + frequencyBinCount: 256, + getByteFrequencyData: vi.fn() + }; + } + decodeAudioData() { + return Promise.resolve({ + numberOfChannels: 2, + sampleRate: 44100, + length: 1000, + getChannelData: () => new Float32Array(1000).fill(0.1) + }); + } +} as any; + +globalThis.Worker = class { + postMessage() {} + terminate() {} + onmessage = null; + onerror = null; + addEventListener() {} + removeEventListener() {} + dispatchEvent() { return true; } +} as any; + +describe('Audio API Integration Test', () => { + let canvas: HTMLCanvasElement; + let audioElement: HTMLMediaElement; + let mockEffect: Effect; + + beforeEach(() => { + canvas = document.createElement('canvas'); + audioElement = document.createElement('audio'); + + mockEffect = { + init: vi.fn().mockResolvedValue(undefined), + resize: vi.fn(), + frame: vi.fn(), + update: vi.fn(), + destroy: vi.fn() + }; + }); + + it('should create an instance of Audio', () => { + const audio = new Audio({ canvas }); + expect(audio).toBeInstanceOf(Audio); + + const audioWithData = new Audio({ canvas, data: audioElement }); + expect(audioWithData['options'].data).toBe(audioElement); + + const audioWithEffect = new Audio({ canvas, effect: mockEffect }); + expect(audioWithEffect['options'].effect).toBe(mockEffect); + expect(mockEffect.init).toHaveBeenCalledWith(canvas); + }); + + it('should set the audio data', () => { + const audio = new Audio({ canvas }); + const result = audio.data(audioElement); + + expect(result).toBe(audio); + expect(audio['options'].data).toBe(audioElement); + expect(audio['analyser']).toBeDefined(); + }); + + it('should set the effect', async () => { + const audio = new Audio({ canvas }); + const result = audio.effect(mockEffect); + + expect(result).toBe(audio); + expect(audio['options'].effect).toBe(mockEffect); + expect(mockEffect.init).toHaveBeenCalledWith(canvas); + + const newEffect: Effect = { + init: vi.fn().mockResolvedValue(undefined), + resize: vi.fn(), + frame: vi.fn(), + update: vi.fn(), + destroy: vi.fn() + }; + + audio.effect(newEffect); + expect(mockEffect.destroy).toHaveBeenCalled(); + expect(audio['options'].effect).toBe(newEffect); + }); + + it('should update the effect style', () => { + const audio = new Audio({ canvas, effect: mockEffect }); + const options = { exposure: 0.3 }; + const result = audio.style(options); + + expect(result).toBe(audio); + expect(mockEffect.update).toHaveBeenCalledWith(options); + }); + + it('should play the audio and animation', async () => { + const audio = new Audio({ canvas, effect: mockEffect }); + + let frameCallCount = 0; + audio.onframe = () => { frameCallCount++; }; + + await audio.play(); + await new Promise(resolve => setTimeout(resolve, 50)); + + expect(audio['timer']).toBeDefined(); + expect(frameCallCount).toBeGreaterThan(0); + expect(mockEffect.frame).toHaveBeenCalled(); + }); + + it('should resize the canvas', () => { + const audio = new Audio({ canvas, effect: mockEffect }); + audio.resize(800, 600); + + expect(canvas.width).toBe(800 * window.devicePixelRatio); + expect(canvas.height).toBe(600 * window.devicePixelRatio); + + expect(mockEffect.resize).toHaveBeenCalledWith( + canvas.width, + canvas.height + ); + }); + + it('should clean up resources', () => { + const mockDisconnect = vi.fn(); + const audio = new Audio({ canvas, effect: mockEffect }); + audio['analyser'] = { disconnect: mockDisconnect } as any; + audio['timer'] = 111; + + audio.destroy(); + expect(mockEffect.destroy).toHaveBeenCalled(); + expect(mockDisconnect).toHaveBeenCalled(); + }); + + it('should classify the genre of the audio', async () => { + const audio = new Audio({ canvas }); + audio.classifyGenre = () => Promise.resolve({ + classifyLabel: 'rock', + classifyTime: 123, + classifyOutput: 4 + }); + + const file = new File(['dummy'], 'test.mp3', { type: 'audio/mp3' }); + const fileList = { 0: file, length: 1 } as unknown as FileList; + const result = await audio.classifyGenre(fileList); + expect(result).toEqual({ + classifyLabel: 'rock', + classifyTime: 123, + classifyOutput: 4 + }); + }); +}); \ No newline at end of file diff --git a/examples/demos/alienorb.ts b/examples/demos/alienorb.ts new file mode 100644 index 0000000..bc07028 --- /dev/null +++ b/examples/demos/alienorb.ts @@ -0,0 +1,25 @@ +import * as lil from 'lil-gui'; +import { Audio, AlienOrb } from '../../src'; + +export function render(audio: Audio, gui: lil.GUI) { + const shaderCompilerPath = new URL( + '/public/glsl_wgsl_compiler_bg.wasm', + import.meta.url, + ).href; + const effect = new AlienOrb(shaderCompilerPath); + + const folder = gui.addFolder('style'); + const config = { + fft: 1.00, + exposure: 0.20, + }; + + folder.add(config, 'fft', 0, 1).onChange((fft: number) => { + audio.style({ fft }); + }); + folder.add(config, 'exposure', 0, 1).onChange((exposure: number) => { + audio.style({ exposure }); + }); + + return [effect, folder]; +} diff --git a/examples/demos/bifurcation.ts b/examples/demos/bifurcation.ts new file mode 100644 index 0000000..7b09128 --- /dev/null +++ b/examples/demos/bifurcation.ts @@ -0,0 +1,69 @@ +import * as lil from 'lil-gui'; +import { Audio, Bifurcation } from '../../src'; + +export function render(audio: Audio, gui: lil.GUI) { + const shaderCompilerPath = new URL( + '/public/glsl_wgsl_compiler_bg.wasm', + import.meta.url, + ).href; + const effect = new Bifurcation(shaderCompilerPath); + + const folder = gui.addFolder('style'); + const config = { + radius: 0.306, + samples: 0.226, + accumulation: 0.890, + noiseAnimation: 1, + exposure: 0.238, + beta: 0.425, + alpha: 0.301, + betaAnim: 0.019, + betaS: 0.430, + gamma: 1, + epsilon: 0.224, + betaA: 0.325, + betaB: 0.301, + }; + + folder.add(config, 'radius', 0, 1).onChange((radius: number) => { + audio.style({ radius }); + }); + folder.add(config, 'samples', 0, 1).onChange((samples: number) => { + audio.style({ samples }); + }); + folder.add(config, 'accumulation', 0, 1).onChange((accumulation: number) => { + audio.style({ accumulation }); + }); + folder.add(config, 'noiseAnimation', 0, 1).onChange((noiseAnimation: number) => { + audio.style({ noiseAnimation }); + }); + folder.add(config, 'exposure', 0, 1).onChange((exposure: number) => { + audio.style({ exposure }); + }); + folder.add(config, 'beta', 0, 1).onChange((beta: number) => { + audio.style({ beta }); + }); + folder.add(config, 'alpha', 0, 1).onChange((alpha: number) => { + audio.style({ alpha }); + }); + folder.add(config, 'betaAnim', 0, 1).onChange((betaAnim: number) => { + audio.style({ betaAnim }); + }); + folder.add(config, 'betaS', 0, 1).onChange((betaS: number) => { + audio.style({ betaS }); + }); + folder.add(config, 'gamma', 0, 1).onChange((gamma: number) => { + audio.style({ gamma }); + }); + folder.add(config, 'epsilon', 0, 1).onChange((epsilon: number) => { + audio.style({ epsilon }); + }); + folder.add(config, 'betaA', 0, 1).onChange((betaA: number) => { + audio.style({ betaA }); + }); + folder.add(config, 'betaB', 0, 1).onChange((betaB: number) => { + audio.style({ betaB }); + }); + + return [effect, folder]; +} diff --git a/examples/demos/blackhole.ts b/examples/demos/blackhole.ts index de33fa5..c1be42f 100644 --- a/examples/demos/blackhole.ts +++ b/examples/demos/blackhole.ts @@ -8,7 +8,7 @@ export function render(audio: Audio, gui: lil.GUI) { ).href; const effect = new BlackHole(shaderCompilerPath); - const folder = gui.addFolder('effect'); + const folder = gui.addFolder('style'); const config = { radius: 1, timeStep: 0.039, diff --git a/examples/demos/flame.ts b/examples/demos/flame.ts new file mode 100644 index 0000000..4aa8eb8 --- /dev/null +++ b/examples/demos/flame.ts @@ -0,0 +1,65 @@ +import * as lil from 'lil-gui'; +import { Audio, Flame } from '../../src'; + +export function render(audio: Audio, gui: lil.GUI) { + const shaderCompilerPath = new URL( + '/public/glsl_wgsl_compiler_bg.wasm', + import.meta.url, + ).href; + const effect = new Flame(shaderCompilerPath); + + const folder = gui.addFolder('style'); + const config = { + radius: 0.220, + samples: 2.198, + accumulation: 0.791, + noiseAnimation: 1, + exposure: 1.532, + pa: 0, + pb: 0.55, + pc: 0.1, + pd: 4.718, + pe: 0.761, + pf: 0.945, + dt: 0.170, + }; + + folder.add(config, 'radius', 0, 1).onChange((radius: number) => { + audio.style({ radius }); + }); + folder.add(config, 'samples', 0, 32).onChange((samples: number) => { + audio.style({ samples }); + }); + folder.add(config, 'accumulation', 0, 1).onChange((accumulation: number) => { + audio.style({ accumulation }); + }); + folder.add(config, 'noiseAnimation', 0, 1).onChange((noiseAnimation: number) => { + audio.style({ noiseAnimation }); + }); + folder.add(config, 'exposure', 0, 2).onChange((exposure: number) => { + audio.style({ exposure }); + }); + folder.add(config, 'pa', 0, 1).onChange((pa: number) => { + audio.style({ pa }); + }); + folder.add(config, 'pb', 0, 1).onChange((pb: number) => { + audio.style({ pb }); + }); + folder.add(config, 'pc', 0, 1).onChange((pc: number) => { + audio.style({ pc }); + }); + folder.add(config, 'pd', 0, 6).onChange((pd: number) => { + audio.style({ pd }); + }); + folder.add(config, 'pe', 0, 5).onChange((pe: number) => { + audio.style({ pe }); + }); + folder.add(config, 'pf', 0, 1).onChange((pf: number) => { + audio.style({ pf }); + }); + folder.add(config, 'dt', 0, 1).onChange((dt: number) => { + audio.style({ dt }); + }); + + return [effect, folder]; +} \ No newline at end of file diff --git a/examples/demos/galaxy.ts b/examples/demos/galaxy.ts new file mode 100644 index 0000000..7bc3f5b --- /dev/null +++ b/examples/demos/galaxy.ts @@ -0,0 +1,49 @@ +import * as lil from 'lil-gui'; +import { Audio, Galaxy } from '../../src'; + +export function render(audio: Audio, gui: lil.GUI) { + const shaderCompilerPath = new URL( + '/public/glsl_wgsl_compiler_bg.wasm', + import.meta.url, + ).href; + const effect = new Galaxy(shaderCompilerPath); + + const folder = gui.addFolder('style'); + const config = { + radius: 0.45, + samples: 0, + noiseAnimation: 0.627, + bulbPower: 0.503, + exposure: 0.182, + powerDelta: 0.9, + gamma: 0.9, + animationSpeed: 1, + }; + + folder.add(config, 'radius', 0, 1).onChange((radius: number) => { + audio.style({ radius }); + }); + folder.add(config, 'samples', 0, 1).onChange((samples: number) => { + audio.style({ samples }); + }); + folder.add(config, 'noiseAnimation', 0, 1).onChange((noiseAnimation: number) => { + audio.style({ noiseAnimation }); + }); + folder.add(config, 'bulbPower', 0, 1).onChange((bulbPower: number) => { + audio.style({ bulbPower }); + }); + folder.add(config, 'exposure', 0, 1).onChange((exposure: number) => { + audio.style({ exposure }); + }); + folder.add(config, 'powerDelta', 0, 1).onChange((powerDelta: number) => { + audio.style({ powerDelta }); + }); + folder.add(config, 'gamma', 0, 1).onChange((gamma: number) => { + audio.style({ gamma }); + }); + folder.add(config, 'animationSpeed', 0, 1).onChange((animationSpeed: number) => { + audio.style({ animationSpeed }); + }); + + return [effect, folder]; +} diff --git a/examples/demos/index.ts b/examples/demos/index.ts index bc7712e..310b4b3 100644 --- a/examples/demos/index.ts +++ b/examples/demos/index.ts @@ -1,3 +1,12 @@ export { render as GPUSine } from './sine'; export { render as GPUStardust } from './stardust'; export { render as GPUBlackHole } from './blackhole'; +export { render as GPUBifurcation } from './bifurcation'; +export { render as GPUFlame } from './flame'; +export { render as GPUPlasmaCoil } from './plasmacoil'; +export { render as GPULightingStorm } from './lightingstorm'; +export { render as GPUGalaxy } from './galaxy'; +export { render as GPUSpiralA } from './sprialA'; +export { render as GPUSprialB } from './sprialB'; +export { render as GPUUniverse } from './universe'; +export { render as GPUAlienOrb } from './alienorb'; diff --git a/examples/demos/lightingstorm.ts b/examples/demos/lightingstorm.ts new file mode 100644 index 0000000..7b4442f --- /dev/null +++ b/examples/demos/lightingstorm.ts @@ -0,0 +1,69 @@ +import * as lil from 'lil-gui'; +import { Audio, LightingStorm } from '../../src'; + +export function render(audio: Audio, gui: lil.GUI) { + const shaderCompilerPath = new URL( + '/public/glsl_wgsl_compiler_bg.wasm', + import.meta.url, + ).href; + const effect = new LightingStorm(shaderCompilerPath); + + const folder = gui.addFolder('style'); + const config = { + radius: 0.220, + samples: 2.198, + accumulation: 0.791, + noiseAnimation: 1, + exposure: 1.532, + pa: 0, + pb: 0.55, + pc: 0.1, + pd: 4.718, + pe: 0.761, + pf: 0.945, + dt: 0.170, + time: 5.000, + }; + + folder.add(config, 'radius', 0, 1).onChange((radius: number) => { + audio.style({ radius }); + }); + folder.add(config, 'samples', 0, 32).onChange((samples: number) => { + audio.style({ samples }); + }); + folder.add(config, 'accumulation', 0, 1).onChange((accumulation: number) => { + audio.style({ accumulation }); + }); + folder.add(config, 'noiseAnimation', 0, 1).onChange((noiseAnimation: number) => { + audio.style({ noiseAnimation }); + }); + folder.add(config, 'exposure', 0, 2).onChange((exposure: number) => { + audio.style({ exposure }); + }); + folder.add(config, 'pa', 0, 1).onChange((pa: number) => { + audio.style({ pa }); + }); + folder.add(config, 'pb', 0, 1).onChange((pb: number) => { + audio.style({ pb }); + }); + folder.add(config, 'pc', 0, 1).onChange((pc: number) => { + audio.style({ pc }); + }); + folder.add(config, 'pd', 0, 6).onChange((pd: number) => { + audio.style({ pd }); + }); + folder.add(config, 'pe', 0, 5).onChange((pe: number) => { + audio.style({ pe }); + }); + folder.add(config, 'pf', 0, 1).onChange((pf: number) => { + audio.style({ pf }); + }); + folder.add(config, 'dt', 0, 1).onChange((dt: number) => { + audio.style({ dt }); + }); + folder.add(config, 'time', 0, 10).onChange((time: number) => { + audio.style({ time }); + }); + + return [effect, folder]; +} \ No newline at end of file diff --git a/examples/demos/plasmacoil.ts b/examples/demos/plasmacoil.ts new file mode 100644 index 0000000..bae89c0 --- /dev/null +++ b/examples/demos/plasmacoil.ts @@ -0,0 +1,65 @@ +import * as lil from 'lil-gui'; +import { Audio, PlasmaCoil } from '../../src'; + +export function render(audio: Audio, gui: lil.GUI) { + const shaderCompilerPath = new URL( + '/public/glsl_wgsl_compiler_bg.wasm', + import.meta.url, + ).href; + const effect = new PlasmaCoil(shaderCompilerPath); + + const folder = gui.addFolder('style'); + const config = { + radius: 0.220, + samples: 2.198, + accumulation: 0.791, + noiseAnimation: 1, + exposure: 1.532, + pa: 0, + pb: 0.55, + pc: 0.1, + pd: 4.718, + pe: 0.761, + pf: 0.945, + dt: 0.170, + }; + + folder.add(config, 'radius', 0, 1).onChange((radius: number) => { + audio.style({ radius }); + }); + folder.add(config, 'samples', 0, 32).onChange((samples: number) => { + audio.style({ samples }); + }); + folder.add(config, 'accumulation', 0, 1).onChange((accumulation: number) => { + audio.style({ accumulation }); + }); + folder.add(config, 'noiseAnimation', 0, 1).onChange((noiseAnimation: number) => { + audio.style({ noiseAnimation }); + }); + folder.add(config, 'exposure', 0, 2).onChange((exposure: number) => { + audio.style({ exposure }); + }); + folder.add(config, 'pa', 0, 1).onChange((pa: number) => { + audio.style({ pa }); + }); + folder.add(config, 'pb', 0, 1).onChange((pb: number) => { + audio.style({ pb }); + }); + folder.add(config, 'pc', 0, 1).onChange((pc: number) => { + audio.style({ pc }); + }); + folder.add(config, 'pd', 0, 6).onChange((pd: number) => { + audio.style({ pd }); + }); + folder.add(config, 'pe', 0, 5).onChange((pe: number) => { + audio.style({ pe }); + }); + folder.add(config, 'pf', 0, 1).onChange((pf: number) => { + audio.style({ pf }); + }); + folder.add(config, 'dt', 0, 1).onChange((dt: number) => { + audio.style({ dt }); + }); + + return [effect, folder]; +} \ No newline at end of file diff --git a/examples/demos/sine.ts b/examples/demos/sine.ts index bb2ab6f..763a2b8 100644 --- a/examples/demos/sine.ts +++ b/examples/demos/sine.ts @@ -8,18 +8,19 @@ export function render(audio: Audio, gui: lil.GUI) { ).href; const effect = new Sine(shaderCompilerPath); - const folder = gui.addFolder('effect'); + const folder = gui.addFolder('style'); const config = { - radius: 6, + radius: 0.2, sinea: 1, sineb: 1, speed: 0.885, blur: 0, samples: 0.001, mode: 0, + exposure: 0.200, }; - folder.add(config, 'radius', 0, 10).onChange((radius: number) => { + folder.add(config, 'radius', 0, 1).onChange((radius: number) => { audio.style({ radius }); }); folder.add(config, 'sinea', 0, 1).onChange((sinea: number) => { @@ -34,6 +35,9 @@ export function render(audio: Audio, gui: lil.GUI) { folder.add(config, 'blur', 0, 1).onChange((blur: number) => { audio.style({ blur }); }); + folder.add(config, 'exposure', 0, 1).onChange((exposure: number) => { + audio.style({ exposure }); + }); return [effect, folder]; } diff --git a/examples/demos/sprialA.ts b/examples/demos/sprialA.ts new file mode 100644 index 0000000..111a73d --- /dev/null +++ b/examples/demos/sprialA.ts @@ -0,0 +1,37 @@ +import * as lil from 'lil-gui'; +import { Audio, SpiralA } from '../../src'; + +export function render(audio: Audio, gui: lil.GUI) { + const shaderCompilerPath = new URL( + '/public/glsl_wgsl_compiler_bg.wasm', + import.meta.url, + ).href; + const effect = new SpiralA(shaderCompilerPath); + + const folder = gui.addFolder('style'); + const config = { + timeSpeed: 0.4, + zWarpSize: 0.2, + objectSize: 0.4, + waveSize: 0.4, + exposure: 0.3, + }; + + folder.add(config, 'timeSpeed', 0, 1).onChange((timeSpeed: number) => { + audio.style({ timeSpeed }); + }); + folder.add(config, 'zWarpSize', 0, 1).onChange((zWarpSize: number) => { + audio.style({ zWarpSize }); + }); + folder.add(config, 'objectSize', 0, 1).onChange((objectSize: number) => { + audio.style({ objectSize }); + }); + folder.add(config, 'waveSize', 0, 1).onChange((waveSize: number) => { + audio.style({ waveSize }); + }); + folder.add(config, 'exposure', 0, 1).onChange((exposure: number) => { + audio.style({ exposure }); + }); + + return [effect, folder]; +} diff --git a/examples/demos/sprialB.ts b/examples/demos/sprialB.ts new file mode 100644 index 0000000..5088c89 --- /dev/null +++ b/examples/demos/sprialB.ts @@ -0,0 +1,33 @@ +import * as lil from 'lil-gui'; +import { Audio, SpiralB } from '../../src'; + +export function render(audio: Audio, gui: lil.GUI) { + const shaderCompilerPath = new URL( + '/public/glsl_wgsl_compiler_bg.wasm', + import.meta.url, + ).href; + const effect = new SpiralB(shaderCompilerPath); + + const folder = gui.addFolder('style'); + const config = { + speed: 0.500, + objectSize: 0.700, + waveSize: 0.500, + exposure: 0.200, + }; + + folder.add(config, 'speed', 0, 1).onChange((speed: number) => { + audio.style({ speed }); + }); + folder.add(config, 'objectSize', 0, 1).onChange((objectSize: number) => { + audio.style({ objectSize }); + }); + folder.add(config, 'waveSize', 0, 1).onChange((waveSize: number) => { + audio.style({ waveSize }); + }); + folder.add(config, 'exposure', 0, 1).onChange((exposure: number) => { + audio.style({ exposure }); + }); + + return [effect, folder]; +} diff --git a/examples/demos/stardust.ts b/examples/demos/stardust.ts index 0458add..cd83d36 100644 --- a/examples/demos/stardust.ts +++ b/examples/demos/stardust.ts @@ -8,7 +8,7 @@ export function render(audio: Audio, gui: lil.GUI) { ).href; const effect = new Stardust(shaderCompilerPath); - const folder = gui.addFolder('effect'); + const folder = gui.addFolder('style'); const config = { radius: 2.27, blurRadius: 0.053, diff --git a/examples/demos/universe.ts b/examples/demos/universe.ts new file mode 100644 index 0000000..58d1ae6 --- /dev/null +++ b/examples/demos/universe.ts @@ -0,0 +1,25 @@ +import * as lil from 'lil-gui'; +import { Audio, Universe } from '../../src'; + +export function render(audio: Audio, gui: lil.GUI) { + const shaderCompilerPath = new URL( + '/public/glsl_wgsl_compiler_bg.wasm', + import.meta.url, + ).href; + const effect = new Universe(shaderCompilerPath); + + const folder = gui.addFolder('style'); + const config = { + fft: 1.00, + exposure: 0.30, + }; + + folder.add(config, 'fft', 0, 1).onChange((fft: number) => { + audio.style({ fft }); + }); + folder.add(config, 'exposure', 0, 1).onChange((exposure: number) => { + audio.style({ exposure }); + }); + + return [effect, folder]; +} diff --git a/examples/index.html b/examples/index.html index 53e693f..60d5132 100644 --- a/examples/index.html +++ b/examples/index.html @@ -210,6 +210,11 @@
+