From 948a8ae9d0c339d1821558d5dc3474ecd410a8be Mon Sep 17 00:00:00 2001 From: brsbl <57682038+brsbl@users.noreply.github.com> Date: Sun, 4 Oct 2026 05:12:12 -0700 Subject: [PATCH] Add audio render tests in real Chrome Vitest's browser mode runs src/**/*.audio.test.ts in Chromium, where OfflineAudioContext exists, and renders the engine faster than real time: - each ready-made loop is audible, finite, and below clipping - kick, snare, and hi-hat each sound, and a new kick chokes the last - a released synth note falls silent - the delay echoes a dotted eighth after the hit CI runs them before the Playwright smoke tests. Co-Authored-By: Claude Opus 5.5 --- .claude/CLAUDE.md | 3 +- .github/workflows/ci.yml | 3 + README.md | 1 + package.json | 4 +- pnpm-lock.yaml | 90 +++++++++++++++++++++- src/engine/render.audio.test.ts | 128 ++++++++++++++++++++++++++++++++ tsconfig.json | 2 +- vite.config.ts | 4 +- vitest.audio.config.ts | 19 +++++ 9 files changed, 247 insertions(+), 7 deletions(-) create mode 100644 src/engine/render.audio.test.ts create mode 100644 vitest.audio.config.ts diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 961eb6c..c326cf8 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -36,7 +36,8 @@ pnpm build pnpm lint pnpm typecheck pnpm test # Vitest: logic and component tests (jsdom, no Web Audio) +pnpm test:audio # Vitest in real Chrome: renders the engine offline and measures it pnpm test:e2e # Playwright: the production build in real Chrome ``` -CI runs lint, typecheck, test, build, and the browser smoke tests on every PR. +CI runs lint, typecheck, test, build, the audio render tests, and the browser smoke tests on every PR. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e48b4bd..9ac68df 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,6 +43,9 @@ jobs: - name: Install Chrome for browser tests run: pnpm exec playwright install --with-deps chromium + - name: Audio render tests + run: pnpm test:audio + - name: Browser smoke test run: pnpm test:e2e diff --git a/README.md b/README.md index e04d3d9..caaca2c 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,7 @@ Its step row, knobs, firing color, and share-link field follow automatically. | `pnpm lint` | ESLint | | `pnpm typecheck` | TypeScript | | `pnpm test` | Vitest unit and component tests | +| `pnpm test:audio` | Audio render tests: the engine rendered offline in Chrome | | `pnpm test:e2e` | Playwright smoke tests of the production build in Chrome | ## Credits diff --git a/package.json b/package.json index f69b603..04a11d2 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,8 @@ "lint": "eslint src", "test": "vitest run", "test:watch": "vitest", - "test:e2e": "playwright test" + "test:e2e": "playwright test", + "test:audio": "vitest run --config vitest.audio.config.ts" }, "dependencies": { "react": "^19.3.0", @@ -25,6 +26,7 @@ "@types/react": "^19.3.0", "@types/react-dom": "^19.3.0", "@vitejs/plugin-react": "^5.2.0", + "@vitest/browser-playwright": "4.0.15", "agentation": "^1.3.2", "eslint": "^9.39.1", "eslint-plugin-react": "^7.37.5", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e4a4e3c..c459e8d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -39,6 +39,9 @@ importers: '@vitejs/plugin-react': specifier: ^5.2.0 version: 5.2.0(vite@7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)) + '@vitest/browser-playwright': + specifier: 4.0.15 + version: 4.0.15(playwright@1.63.0)(vite@7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2))(vitest@4.0.15) agentation: specifier: ^1.3.2 version: 1.3.2(react-dom@19.3.0(react@19.3.0))(react@19.3.0) @@ -65,7 +68,7 @@ importers: version: 7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2) vitest: specifier: ^4.0.15 - version: 4.0.15(@types/node@24.10.2)(jiti@2.6.1)(jsdom@27.3.0(postcss@8.5.6))(lightningcss@1.30.2) + version: 4.0.15(@types/node@24.10.2)(@vitest/browser-playwright@4.0.15)(jiti@2.6.1)(jsdom@27.3.0(postcss@8.5.6))(lightningcss@1.30.2) packages: @@ -509,6 +512,9 @@ packages: engines: {node: '>=20'} hasBin: true + '@polka/url@1.0.0-next.29': + resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} + '@rolldown/pluginutils@1.0.0-rc.3': resolution: {integrity: sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==} @@ -757,6 +763,17 @@ packages: peerDependencies: vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + '@vitest/browser-playwright@4.0.15': + resolution: {integrity: sha512-94yVpDbb+ykiT7mK6ToonGnq2GIHEQGBTZTAzGxBGQXcVNCh54YKC2/WkfaDzxy0m6Kgw05kq3FYHKHu+wRdIA==} + peerDependencies: + playwright: '*' + vitest: 4.0.15 + + '@vitest/browser@4.0.15': + resolution: {integrity: sha512-zedtczX688KehaIaAv7m25CeDLb0gBtAOa2Oi1G1cqvSO5aLSVfH6lpZMJLW8BKYuWMxLQc9/5GYoM+jgvGIrw==} + peerDependencies: + vitest: 4.0.15 + '@vitest/expect@4.0.15': resolution: {integrity: sha512-Gfyva9/GxPAWXIWjyGDli9O+waHDC0Q0jaLdFP1qPAUUfo1FEXPXUfUkp3eZA0sSq340vPycSyOlYUeM15Ft1w==} @@ -1569,6 +1586,10 @@ packages: minimatch@3.1.2: resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} + mrmime@2.0.1: + resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} + engines: {node: '>=10'} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -1658,6 +1679,10 @@ packages: resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} engines: {node: '>=12'} + pixelmatch@7.1.0: + resolution: {integrity: sha512-1wrVzJ2STrpmONHKBy228LM1b84msXDUoAzVEl0R8Mz4Ce6EPr+IVtxm8+yvrqLYMHswREkjYFaMxnyGnaY3Ng==} + hasBin: true + playwright-core@1.63.0: resolution: {integrity: sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==} engines: {node: '>=20'} @@ -1668,6 +1693,10 @@ packages: engines: {node: '>=20'} hasBin: true + pngjs@7.0.0: + resolution: {integrity: sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==} + engines: {node: '>=14.19.0'} + possible-typed-array-names@1.1.0: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} @@ -1809,6 +1838,10 @@ packages: siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + sirv@3.0.2: + resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==} + engines: {node: '>=18'} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -1883,6 +1916,10 @@ packages: resolution: {integrity: sha512-8PWx8tvC4jDB39BQw1m4x8y5MH1BcQ5xHeL2n7UVFulMPH/3Q0uiamahFJ3lXA0zO2SUyRXuVVbWSDmstlt9YA==} hasBin: true + totalist@3.0.1: + resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} + engines: {node: '>=6'} + tough-cookie@6.0.0: resolution: {integrity: sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==} engines: {node: '>=16'} @@ -2529,6 +2566,8 @@ snapshots: dependencies: playwright: 1.63.0 + '@polka/url@1.0.0-next.29': {} + '@rolldown/pluginutils@1.0.0-rc.3': {} '@rollup/rollup-android-arm-eabi@4.53.3': @@ -2783,6 +2822,36 @@ snapshots: transitivePeerDependencies: - supports-color + '@vitest/browser-playwright@4.0.15(playwright@1.63.0)(vite@7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2))(vitest@4.0.15)': + dependencies: + '@vitest/browser': 4.0.15(vite@7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2))(vitest@4.0.15) + '@vitest/mocker': 4.0.15(vite@7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)) + playwright: 1.63.0 + tinyrainbow: 3.0.3 + vitest: 4.0.15(@types/node@24.10.2)(@vitest/browser-playwright@4.0.15)(jiti@2.6.1)(jsdom@27.3.0(postcss@8.5.6))(lightningcss@1.30.2) + transitivePeerDependencies: + - bufferutil + - msw + - utf-8-validate + - vite + + '@vitest/browser@4.0.15(vite@7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2))(vitest@4.0.15)': + dependencies: + '@vitest/mocker': 4.0.15(vite@7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)) + '@vitest/utils': 4.0.15 + magic-string: 0.30.21 + pixelmatch: 7.1.0 + pngjs: 7.0.0 + sirv: 3.0.2 + tinyrainbow: 3.0.3 + vitest: 4.0.15(@types/node@24.10.2)(@vitest/browser-playwright@4.0.15)(jiti@2.6.1)(jsdom@27.3.0(postcss@8.5.6))(lightningcss@1.30.2) + ws: 8.18.3 + transitivePeerDependencies: + - bufferutil + - msw + - utf-8-validate + - vite + '@vitest/expect@4.0.15': dependencies: '@standard-schema/spec': 1.0.0 @@ -3748,6 +3817,8 @@ snapshots: dependencies: brace-expansion: 1.1.12 + mrmime@2.0.1: {} + ms@2.1.3: {} nanoid@3.3.11: {} @@ -3837,12 +3908,18 @@ snapshots: picomatch@4.0.3: {} + pixelmatch@7.1.0: + dependencies: + pngjs: 7.0.0 + playwright-core@1.63.0: {} playwright@1.63.0: dependencies: playwright-core: 1.63.0 + pngjs@7.0.0: {} + possible-typed-array-names@1.1.0: {} postcss@8.5.6: @@ -4032,6 +4109,12 @@ snapshots: siginfo@2.0.0: {} + sirv@3.0.2: + dependencies: + '@polka/url': 1.0.0-next.29 + mrmime: 2.0.1 + totalist: 3.0.1 + source-map-js@1.2.1: {} stackback@0.0.2: {} @@ -4118,6 +4201,8 @@ snapshots: dependencies: tldts-core: 7.0.19 + totalist@3.0.1: {} + tough-cookie@6.0.0: dependencies: tldts: 7.0.19 @@ -4214,7 +4299,7 @@ snapshots: jiti: 2.6.1 lightningcss: 1.30.2 - vitest@4.0.15(@types/node@24.10.2)(jiti@2.6.1)(jsdom@27.3.0(postcss@8.5.6))(lightningcss@1.30.2): + vitest@4.0.15(@types/node@24.10.2)(@vitest/browser-playwright@4.0.15)(jiti@2.6.1)(jsdom@27.3.0(postcss@8.5.6))(lightningcss@1.30.2): dependencies: '@vitest/expect': 4.0.15 '@vitest/mocker': 4.0.15(vite@7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)) @@ -4238,6 +4323,7 @@ snapshots: why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 24.10.2 + '@vitest/browser-playwright': 4.0.15(playwright@1.63.0)(vite@7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2))(vitest@4.0.15) jsdom: 27.3.0(postcss@8.5.6) transitivePeerDependencies: - jiti diff --git a/src/engine/render.audio.test.ts b/src/engine/render.audio.test.ts new file mode 100644 index 0000000..6ef4e65 --- /dev/null +++ b/src/engine/render.audio.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it } from 'vitest' +import { DRUMS, noteByName } from '../instruments' +import { PRESETS } from '../session/library' +import type { Session } from '../session/schema' +import { decodeSession } from '../session/url' +import { arpIndex, arpPlaysOnStep } from './arpeggiator' +import { stepSeconds } from './clock' +import { playDrum, type DrumVoice } from './drums' +import { buildMixer } from './mixer' +import { Synth } from './synth' + +const SAMPLE_RATE = 44100 + +const peakOf = (samples: Float32Array) => samples.reduce((max, s) => Math.max(max, Math.abs(s)), 0) +const rmsDb = (samples: Float32Array) => 10 * Math.log10(samples.reduce((sum, s) => sum + s * s, 0) / samples.length) +const slice = (buffer: AudioBuffer, from: number, to: number) => buffer.getChannelData(0).subarray(Math.floor(from * SAMPLE_RATE), Math.floor(to * SAMPLE_RATE)) + +/** + * Renders `bars` of a session through the real mixer, scheduling steps the way + * the engine does: drums per step (kicks duck the pump), arp notes on the gate grid. + */ +async function renderSession(session: Session, bars = 2): Promise { + const step = stepSeconds(session.bpm) + const ctx = new OfflineAudioContext(2, Math.ceil(SAMPLE_RATE * (bars * 16 * step + 1)), SAMPLE_RATE) + const mixer = buildMixer(ctx, DRUMS) + for (const d of DRUMS) mixer.channels.get(d.id)!.set(session.drums[d.id].volume, session.drums[d.id].delay) + mixer.synth.setVolume(session.synth.volume) + mixer.synth.setWaveform(session.synth.waveform) + mixer.echo.setStepSeconds(step) + + const { synth } = session + let last: ReturnType | null = null + let count = 0 + for (let n = 0; n < bars * 16; n++) { + const time = 0.05 + n * step + const i = n % 16 + for (const d of DRUMS) { + if (!session.drums[d.id].steps[i]) continue + playDrum(d.voice, ctx, mixer.channels.get(d.id)!.input, time, session.drums[d.id].decay) + if (d.voice === 'kick') mixer.pump.duck(time) + } + if (arpPlaysOnStep(i, synth.steps[i], synth.rate) && synth.notes.length > 0) { + const note = noteByName(synth.notes[arpIndex(synth.mode, count++, synth.notes.length)])! + if (last) mixer.synth.release(last, time) + last = mixer.synth.noteOn(note.freq * 2 ** synth.octave, time) + } + } + return ctx.startRendering() +} + +async function renderHit(voice: DrumVoice, seconds = 1): Promise { + const ctx = new OfflineAudioContext(1, SAMPLE_RATE * seconds, SAMPLE_RATE) + playDrum(voice, ctx, ctx.destination, 0) + return (await ctx.startRendering()).getChannelData(0) +} + +describe('ready-made loops', () => { + for (const preset of PRESETS) { + it(`${preset.name} renders audible, finite, and below clipping`, async () => { + const buffer = await renderSession(decodeSession(preset.link)) + for (let ch = 0; ch < buffer.numberOfChannels; ch++) { + const samples = buffer.getChannelData(ch) + expect(samples.every(Number.isFinite)).toBe(true) + expect(peakOf(samples)).toBeLessThan(1) + expect(rmsDb(samples)).toBeGreaterThan(-30) + } + }) + } +}) + +describe('drum voices', () => { + it.each([ + ['kick', 0.5], + ['snare', 0.3], + ['hihat', 0.1], + ] as const)('%s sounds', async (voice, minPeak) => { + expect(peakOf(await renderHit(voice))).toBeGreaterThan(minPeak) + }) + + it('a new kick chokes the last one, like the 808', async () => { + const ctx = new OfflineAudioContext(1, SAMPLE_RATE * 1.5, SAMPLE_RATE) + const out = ctx.createGain() + out.connect(ctx.destination) + playDrum('kick', ctx, out, 0, 1) + playDrum('kick', ctx, out, 0.5, 1) + const both = await ctx.startRendering() + + const solo = new OfflineAudioContext(1, SAMPLE_RATE * 1.5, SAMPLE_RATE) + playDrum('kick', solo, solo.destination, 0.5, 1) + const second = await solo.startRendering() + + // Once the second kick lands, only it should be sounding. + const after = slice(both, 0.6, 1.2) + const secondOnly = slice(second, 0.6, 1.2) + const leftover = after.map((s, i) => s - secondOnly[i]) + expect(rmsDb(leftover)).toBeLessThan(-60) + }) +}) + +describe('synth', () => { + it('falls silent after a released note', async () => { + // The synth on its own: the mixer's reverb would still ring after the note ends. + const ctx = new OfflineAudioContext(2, SAMPLE_RATE * 1.5, SAMPLE_RATE) + const synth = new Synth(ctx, ctx.destination, ctx.createGain()) + synth.setVolume(0.7) + const voice = synth.noteOn(noteByName('A3')!.freq, 0.05) + synth.release(voice, 0.5) + const buffer = await ctx.startRendering() + expect(rmsDb(slice(buffer, 0.1, 0.4))).toBeGreaterThan(-30) + expect(rmsDb(slice(buffer, 1.0, 1.5))).toBeLessThan(-60) + }) +}) + +describe('delay', () => { + it('echoes a dotted eighth after the hit', async () => { + const bpm = 120 + const ctx = new OfflineAudioContext(2, SAMPLE_RATE * 1.2, SAMPLE_RATE) + const mixer = buildMixer(ctx, DRUMS) + mixer.echo.setStepSeconds(stepSeconds(bpm)) + // Straight into the delay, so only the echo reaches the output. + playDrum('snare', ctx, mixer.echo.input, 0.05, 0.25) + const buffer = await ctx.startRendering() + + const echoAt = 0.05 + 3 * stepSeconds(bpm) // three sixteenths = 0.375 s + expect(peakOf(slice(buffer, 0, echoAt - 0.02))).toBeLessThan(0.01) + expect(peakOf(slice(buffer, echoAt, echoAt + 0.06))).toBeGreaterThan(0.02) + }) +}) diff --git a/tsconfig.json b/tsconfig.json index 33326c8..cd3222c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -14,5 +14,5 @@ "skipLibCheck": true, "noEmit": true }, - "include": ["src", "e2e", "vite.config.ts", "playwright.config.ts"] + "include": ["src", "e2e", "vite.config.ts", "vitest.audio.config.ts", "playwright.config.ts"] } diff --git a/vite.config.ts b/vite.config.ts index 608b574..2e334c1 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -6,7 +6,7 @@ export default defineConfig({ test: { environment: 'jsdom', setupFiles: './src/test/setup.ts', - // Browser tests in e2e/ run in real Chrome through Playwright, not here. - exclude: [...configDefaults.exclude, 'e2e/**'], + // Browser tests in e2e/ (Playwright) and audio renders (*.audio.test.ts, vitest.audio.config.ts) run in real Chrome, not here. + exclude: [...configDefaults.exclude, 'e2e/**', '**/*.audio.test.ts'], }, }) diff --git a/vitest.audio.config.ts b/vitest.audio.config.ts new file mode 100644 index 0000000..8543052 --- /dev/null +++ b/vitest.audio.config.ts @@ -0,0 +1,19 @@ +import { playwright } from '@vitest/browser-playwright' +import { defineConfig } from 'vitest/config' + +/** + * Audio render tests: Vitest in real Chromium, where OfflineAudioContext + * exists (jsdom has no Web Audio). They render the engine's graph faster than + * real time and measure the result. + */ +export default defineConfig({ + test: { + include: ['src/**/*.audio.test.ts'], + browser: { + enabled: true, + headless: true, + provider: playwright(), + instances: [{ browser: 'chromium' }], + }, + }, +})