190 lines
5.5 KiB
TypeScript
190 lines
5.5 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
|
|
import { VIBE_PRESETS } from '../vibes';
|
|
import { gardenAudioConfig } from './garden-audio-config';
|
|
import { PianoNote } from './garden-audio-types';
|
|
import { GenerativePianoEngine } from './generative-piano';
|
|
|
|
const makeEngine = () => {
|
|
const notes: Array<PianoNote> = [];
|
|
const engine = new GenerativePianoEngine(gardenAudioConfig, (note) => {
|
|
notes.push(note);
|
|
});
|
|
|
|
return { engine, notes };
|
|
};
|
|
|
|
const getBeatSeconds = (): number => 60 / gardenAudioConfig.rhythm.bpm;
|
|
|
|
const getBeatsPerBar = (): number =>
|
|
Math.round(
|
|
gardenAudioConfig.rhythm.stepsPerBar / gardenAudioConfig.rhythm.stepsPerBeat
|
|
);
|
|
|
|
const renderBars = (
|
|
engine: GenerativePianoEngine,
|
|
activity: number,
|
|
bars = 8,
|
|
now = 0
|
|
) => {
|
|
engine.renderLookahead({
|
|
vibe: VIBE_PRESETS[0],
|
|
now,
|
|
activity,
|
|
lookaheadSeconds: getBeatSeconds() * getBeatsPerBar() * bars,
|
|
});
|
|
};
|
|
|
|
const average = (values: Array<number>): number =>
|
|
values.reduce((sum, value) => sum + value, 0) / values.length;
|
|
|
|
const uniqueStartTimes = (notes: Array<PianoNote>): Array<string> =>
|
|
Array.from(new Set(notes.map((note) => note.startTime.toFixed(3))));
|
|
|
|
const countNotesBetween = (
|
|
notes: Array<PianoNote>,
|
|
startSeconds: number,
|
|
endSeconds: number
|
|
): number =>
|
|
notes.filter((note) => note.startTime >= startSeconds && note.startTime < endSeconds)
|
|
.length;
|
|
|
|
describe('GenerativePianoEngine', () => {
|
|
it('plays quiet background music even when the garden is idle', () => {
|
|
const { engine, notes } = makeEngine();
|
|
|
|
renderBars(engine, 0);
|
|
|
|
expect(notes.length).toBeGreaterThan(0);
|
|
expect(notes.some((note) => note.durationSeconds > getBeatSeconds() * 6)).toBe(true);
|
|
expect(Math.max(...notes.map((note) => note.velocity))).toBeLessThan(0.12);
|
|
});
|
|
|
|
it('keeps the background sparse instead of filling every beat', () => {
|
|
const { engine, notes } = makeEngine();
|
|
|
|
renderBars(engine, 0, 4);
|
|
|
|
expect(uniqueStartTimes(notes).length).toBeLessThan(8);
|
|
});
|
|
|
|
it('lets activity add density without changing the beat grid', () => {
|
|
const idle = makeEngine();
|
|
const active = makeEngine();
|
|
const startDelaySeconds = 0.02;
|
|
|
|
renderBars(idle.engine, 0, 8);
|
|
renderBars(active.engine, 1, 8);
|
|
|
|
expect(active.notes.length).toBeGreaterThan(idle.notes.length);
|
|
active.notes.forEach((note) => {
|
|
const beatsFromStart = (note.startTime - startDelaySeconds) / getBeatSeconds();
|
|
expect(Math.abs(beatsFromStart - Math.round(beatsFromStart))).toBeLessThan(0.001);
|
|
});
|
|
});
|
|
|
|
it('uses style pools with multiple notes instead of one repeating key', () => {
|
|
const { engine, notes } = makeEngine();
|
|
|
|
renderBars(engine, 1, 16);
|
|
|
|
expect(new Set(notes.map((note) => note.midi)).size).toBeGreaterThan(3);
|
|
});
|
|
|
|
it('changes musical style over time without a color change', () => {
|
|
const { engine, notes } = makeEngine();
|
|
|
|
renderBars(engine, 1, 32);
|
|
|
|
const styleWindows = [
|
|
notes.filter((note) => note.startTime >= 0 && note.startTime < 8),
|
|
notes.filter((note) => note.startTime >= 8 && note.startTime < 16),
|
|
notes.filter((note) => note.startTime >= 16 && note.startTime < 24),
|
|
];
|
|
const averageMidiByWindow = styleWindows.map((windowNotes) =>
|
|
Math.round(average(windowNotes.map((note) => note.midi)))
|
|
);
|
|
const averagePanByWindow = styleWindows.map((windowNotes) =>
|
|
Number(average(windowNotes.map((note) => note.pan)).toFixed(2))
|
|
);
|
|
|
|
expect(styleWindows.every((windowNotes) => windowNotes.length > 0)).toBe(true);
|
|
expect(new Set(averageMidiByWindow).size).toBeGreaterThan(1);
|
|
expect(new Set(averagePanByWindow).size).toBeGreaterThan(1);
|
|
});
|
|
|
|
it('starts a fading brush phrase layer with each new brush gesture', () => {
|
|
const baseline = makeEngine();
|
|
const layered = makeEngine();
|
|
const now = 4;
|
|
|
|
baseline.engine.renderLookahead({
|
|
vibe: VIBE_PRESETS[0],
|
|
now,
|
|
activity: 0.35,
|
|
lookaheadSeconds: 12,
|
|
});
|
|
|
|
layered.engine.beginGesture();
|
|
layered.engine.recordStroke({
|
|
vibe: VIBE_PRESETS[0],
|
|
now,
|
|
activity: 0.85,
|
|
});
|
|
layered.engine.renderLookahead({
|
|
vibe: VIBE_PRESETS[0],
|
|
now,
|
|
activity: 0.35,
|
|
lookaheadSeconds: 12,
|
|
});
|
|
|
|
const earlyExtra =
|
|
countNotesBetween(layered.notes, now + 1, now + 5) -
|
|
countNotesBetween(baseline.notes, now + 1, now + 5);
|
|
const lateExtra =
|
|
countNotesBetween(layered.notes, now + 10.5, now + 12) -
|
|
countNotesBetween(baseline.notes, now + 10.5, now + 12);
|
|
|
|
expect(earlyExtra).toBeGreaterThan(2);
|
|
expect(lateExtra).toBe(0);
|
|
});
|
|
|
|
it('plays one immediate touch note and throttles later stroke accents', () => {
|
|
const { engine, notes } = makeEngine();
|
|
const now = 4;
|
|
|
|
engine.beginGesture();
|
|
engine.recordStroke({
|
|
vibe: VIBE_PRESETS[0],
|
|
now,
|
|
activity: 0.9,
|
|
});
|
|
engine.recordStroke({
|
|
vibe: VIBE_PRESETS[0],
|
|
now: now + 1,
|
|
activity: 0.95,
|
|
});
|
|
|
|
expect(notes).toHaveLength(1);
|
|
expect(notes[0].startTime).toBe(now);
|
|
|
|
engine.recordStroke({
|
|
vibe: VIBE_PRESETS[0],
|
|
now: now + 6,
|
|
activity: 0.95,
|
|
});
|
|
|
|
expect(notes).toHaveLength(2);
|
|
expect(new Set(notes.map((note) => note.midi)).size).toBeGreaterThan(1);
|
|
});
|
|
|
|
it('is deterministic for the same musical inputs', () => {
|
|
const first = makeEngine();
|
|
const second = makeEngine();
|
|
|
|
renderBars(first.engine, 0.78, 16);
|
|
renderBars(second.engine, 0.78, 16);
|
|
|
|
expect(second.notes).toEqual(first.notes);
|
|
});
|
|
});
|