Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .claude/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
90 changes: 88 additions & 2 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

128 changes: 128 additions & 0 deletions src/engine/render.audio.test.ts
Original file line number Diff line number Diff line change
@@ -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<AudioBuffer> {
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<typeof mixer.synth.noteOn> | 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<Float32Array> {
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)
})
})
2 changes: 1 addition & 1 deletion tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
}
Loading
Loading