.
Some checks failed
Deploy to Pages / build (pull_request) Failing after 3m15s

This commit is contained in:
Andras Schmelczer 2026-05-13 22:13:15 +01:00
commit 2347ecd201
71 changed files with 3779 additions and 1586 deletions

View file

@ -1,13 +1,13 @@
import { appConfig } from '../config';
export type GardenAudioChordQuality = 'major' | 'minor';
type GardenAudioChordQuality = 'major' | 'minor';
export interface GardenAudioChord {
rootOffset: number;
quality: GardenAudioChordQuality;
}
export interface GardenAudioColorVoice {
interface GardenAudioColorVoice {
scaleDegreeOffset: number;
velocityMultiplier: number;
panOffset: number;

View file

@ -1,4 +1,4 @@
import { appConfig } from '../config';
import type { GardenAudioEngineConfig } from '../config';
import { clamp01 } from '../utils/clamp';
const STROKE_IMMEDIATE_ACTIVITY_SCALE = 0.85;
@ -9,6 +9,8 @@ export class GardenAudioEnergy {
private targetEnergy = 0;
private lastEnergyUpdateAt = 0;
public constructor(private readonly engineConfig: GardenAudioEngineConfig) {}
public beginGesture(now: number): void {
this.isGestureActive = true;
this.lastEnergyUpdateAt = now;
@ -46,15 +48,15 @@ export class GardenAudioEnergy {
const elapsedSeconds = Math.max(0, now - this.lastEnergyUpdateAt);
this.lastEnergyUpdateAt = now;
this.targetEnergy *= Math.exp(
-elapsedSeconds / appConfig.audioEngine.energy.strokeDecaySeconds
-elapsedSeconds / this.engineConfig.energy.strokeDecaySeconds
);
const target = this.isGestureActive ? this.targetEnergy : 0;
let timeConstant = appConfig.audioEngine.energy.decaySeconds;
let timeConstant = this.engineConfig.energy.decaySeconds;
if (!this.isGestureActive) {
timeConstant = appConfig.audioEngine.energy.releaseSeconds;
timeConstant = this.engineConfig.energy.releaseSeconds;
} else if (target > this.energy) {
timeConstant = appConfig.audioEngine.energy.attackSeconds;
timeConstant = this.engineConfig.energy.attackSeconds;
}
const amount = 1 - Math.exp(-elapsedSeconds / timeConstant);
this.energy += (target - this.energy) * amount;

View file

@ -1,4 +1,4 @@
import { appConfig } from '../config';
import type { GardenAudioEngineConfig } from '../config';
import { clamp } from '../utils/clamp';
import { GardenAudioConfig, GardenAudioVibeProfile } from './garden-audio-config';
@ -14,7 +14,10 @@ export class GardenAudioGraph {
private delayOutput: GainNode | null = null;
private hasUnlocked = false;
public constructor(private readonly config: GardenAudioConfig) {}
public constructor(
private readonly config: GardenAudioConfig,
private readonly engineConfig: GardenAudioEngineConfig
) {}
public ensureContext(canCreate: boolean): AudioContext | null {
if (this.context) {
@ -62,8 +65,8 @@ export class GardenAudioGraph {
const buffer = this.context.createBuffer(
1,
appConfig.audioEngine.graph.unlockBufferLength,
appConfig.audioEngine.graph.unlockSampleRate
this.engineConfig.graph.unlockBufferLength,
this.engineConfig.graph.unlockSampleRate
);
const source = this.context.createBufferSource();
source.buffer = buffer;
@ -92,7 +95,7 @@ export class GardenAudioGraph {
this.delayNode.delayTime.setTargetAtTime(
this.config.delay.timeSeconds * profile.delayTimeMultiplier,
this.context.currentTime,
appConfig.audioEngine.graph.delayTimeRampSeconds
this.engineConfig.graph.delayTimeRampSeconds
);
}
@ -105,22 +108,22 @@ export class GardenAudioGraph {
this.delayNode.delayTime.setTargetAtTime(
this.config.delay.timeSeconds * profile.delayTimeMultiplier,
now,
appConfig.audioEngine.graph.delayTimeRampSeconds
this.engineConfig.graph.delayTimeRampSeconds
);
this.delayFeedback.gain.setTargetAtTime(
clamp(
this.config.delay.feedback +
activity * appConfig.audioEngine.graph.delayActivityFeedbackWeight,
appConfig.audioEngine.graph.delayFeedbackMin,
appConfig.audioEngine.graph.delayFeedbackMax
activity * this.engineConfig.graph.delayActivityFeedbackWeight,
this.engineConfig.graph.delayFeedbackMin,
this.engineConfig.graph.delayFeedbackMax
),
now,
this.config.updateRampSeconds
);
this.delayOutput.gain.setTargetAtTime(
this.config.delay.wetGain *
(appConfig.audioEngine.graph.delayOutputBase +
activity * appConfig.audioEngine.graph.delayOutputActivityWeight),
(this.engineConfig.graph.delayOutputBase +
activity * this.engineConfig.graph.delayOutputActivityWeight),
now,
this.config.updateRampSeconds
);
@ -134,9 +137,9 @@ export class GardenAudioGraph {
if (this.masterGain && context.state !== 'closed') {
this.masterGain.gain.setTargetAtTime(
appConfig.audioEngine.graph.closeGain,
this.engineConfig.graph.closeGain,
context.currentTime,
appConfig.audioEngine.graph.closeRampSeconds
this.engineConfig.graph.closeRampSeconds
);
}
@ -171,7 +174,7 @@ export class GardenAudioGraph {
private createBuses(context: AudioContext, masterGain: GainNode): void {
this.eventBus = context.createGain();
this.eventBus.gain.value = appConfig.audioEngine.graph.eventBusGain;
this.eventBus.gain.value = this.engineConfig.graph.eventBusGain;
this.eventBus.connect(masterGain);
}
@ -181,9 +184,9 @@ export class GardenAudioGraph {
for (let index = 0; index < data.length; index++) {
data[index] =
appConfig.audioEngine.graph.noiseMin +
this.engineConfig.graph.noiseMin +
Math.random() *
(appConfig.audioEngine.graph.noiseMax - appConfig.audioEngine.graph.noiseMin);
(this.engineConfig.graph.noiseMax - this.engineConfig.graph.noiseMin);
}
return buffer;

View file

@ -1,4 +1,4 @@
import { appConfig } from '../config';
import type { GardenAudioEngineConfig } from '../config';
import { clamp01 } from '../utils/clamp';
import { GardenAudioStroke } from './garden-audio-types';
@ -12,24 +12,25 @@ export interface GardenAudioStrokeMetrics {
export const getStrokeMetrics = (
stroke: GardenAudioStroke,
speedForFullEnergyPixelsPerSecond: number,
fallbackPressure: number
fallbackPressure: number,
inputConfig: GardenAudioEngineConfig['input']
): GardenAudioStrokeMetrics => {
const dx = stroke.to[0] - stroke.from[0];
const dy = stroke.to[1] - stroke.from[1];
const distancePixels = Math.hypot(dx, dy);
const speedPixelsPerSecond = getStrokeVelocity(stroke, distancePixels);
const pressure = getPressureAmount(stroke, fallbackPressure);
const speedPixelsPerSecond = getStrokeVelocity(stroke, distancePixels, inputConfig);
const pressure = getPressureAmount(stroke, fallbackPressure, inputConfig);
const speedAmount = clamp01(speedPixelsPerSecond / speedForFullEnergyPixelsPerSecond);
const strokeEnergy = clamp01(
appConfig.audioEngine.input.strokeEnergyBase +
speedAmount * appConfig.audioEngine.input.strokeEnergySpeedWeight +
pressure * appConfig.audioEngine.input.strokeEnergyPressureWeight
inputConfig.strokeEnergyBase +
speedAmount * inputConfig.strokeEnergySpeedWeight +
pressure * inputConfig.strokeEnergyPressureWeight
);
const effectiveEnergy =
strokeEnergy *
(appConfig.audioEngine.input.distanceEnergyBase +
clamp01(distancePixels / appConfig.audioEngine.input.distanceForFullEnergyPixels) *
appConfig.audioEngine.input.distanceEnergyScale);
(inputConfig.distanceEnergyBase +
clamp01(distancePixels / inputConfig.distanceForFullEnergyPixels) *
inputConfig.distanceEnergyScale);
return {
distancePixels,
@ -39,7 +40,11 @@ export const getStrokeMetrics = (
};
};
const getStrokeVelocity = (stroke: GardenAudioStroke, distancePixels: number): number => {
const getStrokeVelocity = (
stroke: GardenAudioStroke,
distancePixels: number,
inputConfig: GardenAudioEngineConfig['input']
): number => {
if (
stroke.velocityPixelsPerSecond !== undefined &&
Number.isFinite(stroke.velocityPixelsPerSecond) &&
@ -48,12 +53,13 @@ const getStrokeVelocity = (stroke: GardenAudioStroke, distancePixels: number): n
return stroke.velocityPixelsPerSecond;
}
return distancePixels / appConfig.audioEngine.input.fallbackFrameSeconds;
return distancePixels / inputConfig.fallbackFrameSeconds;
};
const getPressureAmount = (
stroke: GardenAudioStroke,
fallbackPressure: number
fallbackPressure: number,
inputConfig: GardenAudioEngineConfig['input']
): number => {
if (
stroke.pressure !== undefined &&
@ -64,6 +70,6 @@ const getPressureAmount = (
}
return stroke.pointerType === 'pen'
? Math.max(appConfig.audioEngine.input.penMinPressure, clamp01(fallbackPressure))
? Math.max(inputConfig.penMinPressure, clamp01(fallbackPressure))
: clamp01(fallbackPressure);
};

View file

@ -1,4 +1,3 @@
import { clamp } from '../utils/clamp';
import { VibePreset } from '../vibes';
import {
GardenAudioChord,
@ -10,9 +9,6 @@ import { GardenAudioColorIndex } from './garden-audio-types';
export const normalizeColorIndex = (index: number): GardenAudioColorIndex =>
Math.max(0, Math.min(2, Math.round(index))) as GardenAudioColorIndex;
export const clampMidi = (midi: number, min: number, max: number): number =>
Math.round(clamp(midi, min, max));
export const getVibeProfile = (
config: GardenAudioConfig,
vibe: VibePreset
@ -21,15 +17,6 @@ export const getVibeProfile = (
config.vibes[config.fallbackVibeId] ??
Object.values(config.vibes)[0];
export const getChordAtStep = (
config: GardenAudioConfig,
profile: GardenAudioVibeProfile,
stepIndex: number
): GardenAudioChord => {
const barIndex = Math.floor(stepIndex / config.rhythm.stepsPerBar);
return profile.progression[barIndex % profile.progression.length];
};
export const getChordIntervals = (
chord: GardenAudioChord,
openVoicing: boolean

View file

@ -6,6 +6,7 @@ export interface GardenAudioSnapshot {
vibe: VibePreset;
selectedColorIndex: number;
isErasing: boolean;
mirrorSegmentCount?: number;
}
export interface GardenAudioStroke {
@ -18,6 +19,15 @@ export interface GardenAudioStroke {
pressure?: number;
velocityPixelsPerSecond?: number;
eraserSizePixels?: number;
mirrorSegmentCount?: number;
pointerType?: string;
}
export interface GardenAudioTouchDown {
vibe: VibePreset;
colorIndex: number;
mirrorSegmentCount?: number;
pressure?: number;
pointerType?: string;
}

View file

@ -4,13 +4,14 @@ import { VibePreset } from '../vibes';
import { GardenAudioConfig } from './garden-audio-config';
import { GardenAudioEnergy } from './garden-audio-energy';
import { GardenAudioGraph } from './garden-audio-graph';
import { getStrokeMetrics } from './garden-audio-input';
import { GardenAudioStrokeMetrics, getStrokeMetrics } from './garden-audio-input';
import { getVibeProfile, normalizeColorIndex } from './garden-audio-music';
import type {
GardenAudioColorIndex,
GardenAudioSnapshot,
GardenAudioStartOptions,
GardenAudioStroke,
GardenAudioTouchDown,
} from './garden-audio-types';
import { GenerativePianoEngine } from './generative-piano';
import { NoiseBurstPlayer } from './noise-burst-player';
@ -20,6 +21,7 @@ export type {
GardenAudioSnapshot,
GardenAudioStartOptions,
GardenAudioStroke,
GardenAudioTouchDown,
} from './garden-audio-types';
export class GardenAudio {
@ -35,6 +37,7 @@ export class GardenAudio {
private isMuted = false;
private isGestureActive = false;
private selectedColorIndex: GardenAudioColorIndex = 0;
private hasQueuedPianoLoad = false;
private lastEraserAt = Number.NEGATIVE_INFINITY;
private lastVibeStingerAt = Number.NEGATIVE_INFINITY;
@ -70,9 +73,21 @@ export class GardenAudio {
this.hasStarted = true;
this.applyVibe(vibe);
this.pianoEngine.prime(context.currentTime);
this.graph.setMasterGain(this.config.masterVolume, this.config.fadeInSeconds);
this.graph.setMasterGain(
this.config.masterVolume,
options.userGesture === true
? appConfig.audioEngine.muteRampSeconds
: this.config.fadeInSeconds
);
void this.piano.load(context);
if (!this.hasQueuedPianoLoad) {
this.hasQueuedPianoLoad = true;
void this.piano.load(context).then(() => {
if (this.graph.context === context && !this.isDestroyed) {
this.pianoEngine.cue(context.currentTime);
}
});
}
}
public changeVibe(vibe: VibePreset, options: GardenAudioStartOptions = {}): void {
@ -108,19 +123,38 @@ export class GardenAudio {
this.isGestureActive = true;
this.energy.beginGesture(context.currentTime);
this.pianoEngine.beginGesture(context.currentTime);
this.pianoEngine.beginGesture();
}
public endGesture(): void {
const context = this.graph.context;
this.isGestureActive = false;
this.energy.endGesture();
this.pianoEngine.endGesture();
if (!context) {
}
public touchDown(touch: GardenAudioTouchDown): void {
if (this.isDestroyed || this.isMuted) {
return;
}
this.piano.fadeActive(context.currentTime, appConfig.audioEngine.gestureFadeSeconds);
const context = this.graph.context;
if (!context || !this.isGestureActive) {
return;
}
this.selectedColorIndex = normalizeColorIndex(touch.colorIndex);
const mirrorAmount = this.getMirrorAmount(touch.mirrorSegmentCount ?? 1);
const pressure = this.getTouchPressure(touch.pressure, touch.pointerType);
const strength = clamp01(0.36 + pressure * 0.34 + mirrorAmount * 0.22);
this.energy.recordStroke(strength, context.currentTime);
this.pianoEngine.recordTouchDown({
vibe: touch.vibe,
now: context.currentTime,
strength,
selectedColorIndex: this.selectedColorIndex,
mirrorAmount,
});
}
public update(snapshot: GardenAudioSnapshot): void {
@ -135,22 +169,14 @@ export class GardenAudio {
if (snapshot.isErasing) {
this.energy.silence();
this.piano.fadeActive(
context.currentTime,
appConfig.audioEngine.gestureFadeSeconds
);
this.updateDelay(snapshot);
return;
}
if (this.isGestureActive) {
this.pianoEngine.renderLookahead({
vibe: snapshot.vibe,
now: context.currentTime,
activity: this.energy.getActivity(),
selectedColorIndex: this.selectedColorIndex,
});
}
this.pianoEngine.renderLookahead({
vibe: snapshot.vibe,
now: context.currentTime,
activity: snapshot.isErasing ? 0 : this.energy.getLevel(),
selectedColorIndex: this.selectedColorIndex,
});
this.updateDelay(snapshot);
}
@ -183,9 +209,16 @@ export class GardenAudio {
return;
}
const strokeEnergy = metrics.effectiveEnergy;
const mirrorAmount = this.getMirrorAmount(stroke.mirrorSegmentCount ?? 1);
const strokeEnergy = this.getStrokeMusicActivity(stroke, metrics, mirrorAmount);
this.energy.recordStroke(strokeEnergy, now);
this.pianoEngine.wake(now);
this.pianoEngine.recordStroke({
vibe: stroke.vibe,
now,
activity: strokeEnergy,
selectedColorIndex: this.selectedColorIndex,
mirrorAmount,
});
}
public async destroy(): Promise<void> {
@ -199,6 +232,7 @@ export class GardenAudio {
this.hasStarted = false;
this.isGestureActive = false;
this.selectedColorIndex = 0;
this.hasQueuedPianoLoad = false;
this.lastEraserAt = Number.NEGATIVE_INFINITY;
this.lastVibeStingerAt = Number.NEGATIVE_INFINITY;
}
@ -285,5 +319,62 @@ export class GardenAudio {
this.currentVibeId = vibe.id;
this.graph.applyDelayProfile(getVibeProfile(this.config, vibe));
this.pianoEngine.cue(this.graph.context.currentTime);
}
private getMirrorAmount(mirrorSegmentCount: number): number {
const maxMirrorSegmentCount = Math.max(1, appConfig.simulation.maxMirrorSegmentCount);
const segmentCount = clamp(
Number.isFinite(mirrorSegmentCount) ? mirrorSegmentCount : 1,
1,
maxMirrorSegmentCount
);
if (maxMirrorSegmentCount <= 1) {
return 0;
}
return clamp01((segmentCount - 1) / (maxMirrorSegmentCount - 1));
}
private getStrokeMusicActivity(
stroke: GardenAudioStroke,
metrics: GardenAudioStrokeMetrics,
mirrorAmount: number
): number {
const speedRatio =
(stroke.velocityPixelsPerSecond ?? 0) /
Math.max(1, this.config.rhythm.speedForFullEnergyPixelsPerSecond);
const speedDrive = smoothstep(0.35, 1.1, speedRatio);
const speedOverdrive = smoothstep(1.15, 1.8, speedRatio);
const distanceDrive = smoothstep(10, 90, metrics.distancePixels);
const baseStroke = clamp01(
0.08 + speedDrive * 0.5 + metrics.pressure * 0.2 + distanceDrive * 0.22
);
const mirrorWild = smoothstep(0.45, 0.9, mirrorAmount);
const maniaDrive = speedOverdrive * smoothstep(0.62, 0.82, baseStroke);
const maniaBoost = maniaDrive * (0.18 + mirrorWild * 0.62);
return clamp01(
baseStroke * (0.68 + mirrorAmount * 0.3) +
0.025 +
mirrorAmount * 0.045 +
maniaBoost
);
}
private getTouchPressure(pressure: number | undefined, pointerType?: string): number {
if (pressure !== undefined && Number.isFinite(pressure) && pressure > 0) {
return clamp01(pressure);
}
return pointerType === 'pen'
? Math.max(appConfig.audioEngine.input.penMinPressure, this.config.input.pressureFallback)
: this.config.input.pressureFallback;
}
}
const smoothstep = (edge0: number, edge1: number, value: number): number => {
const amount = clamp01((value - edge0) / (edge1 - edge0));
return amount * amount * (3 - 2 * amount);
};

View file

@ -14,104 +14,220 @@ const makeEngine = () => {
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,
selectedColorIndex = 0,
bars = 4
bars = 8
) => {
engine.renderLookahead({
vibe: VIBE_PRESETS[0],
now: 0,
activity,
selectedColorIndex: selectedColorIndex as 0 | 1 | 2,
lookaheadSeconds:
(60 / gardenAudioConfig.rhythm.bpm) *
Math.round(
gardenAudioConfig.rhythm.stepsPerBar / gardenAudioConfig.rhythm.stepsPerBeat
) *
bars,
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('does not emit notes below the sparse activity threshold', () => {
it('plays quiet background music even when the garden is idle', () => {
const { engine, notes } = makeEngine();
renderBars(engine, gardenAudioConfig.rhythm.sparseActivity - 0.01);
expect(notes).toHaveLength(0);
});
it('keeps drawing notes on beat starts', () => {
const { engine, notes } = makeEngine();
const beatSeconds = 60 / gardenAudioConfig.rhythm.bpm;
const startDelaySeconds = 0.02;
renderBars(engine, 1, 1);
renderBars(engine, 0);
expect(notes.length).toBeGreaterThan(0);
notes.forEach((note) => {
const beatsFromStart = (note.startTime - startDelaySeconds) / beatSeconds;
expect(notes.some((note) => note.durationSeconds > getBeatSeconds() * 12)).toBe(
true
);
expect(Math.max(...notes.map((note) => note.velocity))).toBeLessThan(0.16);
});
it('keeps the background sparse instead of filling every beat', () => {
const { engine, notes } = makeEngine();
renderBars(engine, 0, 1, 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, 1, 8);
renderBars(active.engine, 1, 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('varies density with activity without exceeding one note per beat', () => {
const low = makeEngine();
const high = makeEngine();
renderBars(low.engine, gardenAudioConfig.rhythm.sparseActivity + 0.03, 1);
renderBars(high.engine, 1, 1);
expect(high.notes.length).toBeGreaterThan(low.notes.length);
expect(high.notes.length).toBeLessThanOrEqual(16);
});
it('wakes every color with a prompt first note at low activity', () => {
it('uses color pools with multiple notes instead of one key per color', () => {
([0, 1, 2] as const).forEach((selectedColorIndex) => {
const { engine, notes } = makeEngine();
const now = 4;
engine.beginGesture(1);
engine.wake(now);
engine.renderLookahead({
vibe: VIBE_PRESETS[0],
now,
activity: gardenAudioConfig.rhythm.sparseActivity + 0.01,
selectedColorIndex,
lookaheadSeconds: 0.08,
});
renderBars(engine, 1, selectedColorIndex, 16);
expect(notes).toHaveLength(1);
expect(notes[0].startTime).toBeCloseTo(now + 0.02);
expect(new Set(notes.map((note) => note.midi)).size).toBeGreaterThan(3);
});
});
it('uses different color roles for register and pan', () => {
const anchor = makeEngine();
const spark = makeEngine();
it('keeps the upper color higher and wider than the lower color', () => {
const lower = makeEngine();
const upper = makeEngine();
renderBars(anchor.engine, 1, 0);
renderBars(spark.engine, 1, 2);
renderBars(lower.engine, 1, 0, 16);
renderBars(upper.engine, 1, 2, 16);
expect(average(spark.notes.map((note) => note.midi))).toBeGreaterThan(
average(anchor.notes.map((note) => note.midi))
expect(average(upper.notes.map((note) => note.midi))).toBeGreaterThan(
average(lower.notes.map((note) => note.midi))
);
expect(average(spark.notes.map((note) => note.pan))).toBeGreaterThan(
average(anchor.notes.map((note) => note.pan))
expect(average(upper.notes.map((note) => note.pan))).toBeGreaterThan(
average(lower.notes.map((note) => note.pan))
);
});
it('is deterministic for the same phrase inputs', () => {
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,
selectedColorIndex: 1,
lookaheadSeconds: 12,
});
layered.engine.beginGesture();
layered.engine.recordStroke({
vibe: VIBE_PRESETS[0],
now,
activity: 0.85,
selectedColorIndex: 1,
mirrorAmount: 0.45,
});
layered.engine.renderLookahead({
vibe: VIBE_PRESETS[0],
now,
activity: 0.35,
selectedColorIndex: 1,
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('makes brush phrase layers denser at higher mirror amounts', () => {
const lowMirror = makeEngine();
const highMirror = makeEngine();
const now = 4;
lowMirror.engine.beginGesture();
lowMirror.engine.recordStroke({
vibe: VIBE_PRESETS[0],
now,
activity: 0.85,
selectedColorIndex: 2,
mirrorAmount: 0,
});
lowMirror.engine.renderLookahead({
vibe: VIBE_PRESETS[0],
now,
activity: 0.35,
selectedColorIndex: 2,
lookaheadSeconds: 9,
});
highMirror.engine.beginGesture();
highMirror.engine.recordStroke({
vibe: VIBE_PRESETS[0],
now,
activity: 0.85,
selectedColorIndex: 2,
mirrorAmount: 1,
});
highMirror.engine.renderLookahead({
vibe: VIBE_PRESETS[0],
now,
activity: 0.35,
selectedColorIndex: 2,
lookaheadSeconds: 9,
});
expect(highMirror.notes.length).toBeGreaterThan(lowMirror.notes.length);
});
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,
selectedColorIndex: 1,
});
engine.recordStroke({
vibe: VIBE_PRESETS[0],
now: now + 1,
activity: 0.95,
selectedColorIndex: 1,
});
expect(notes).toHaveLength(1);
expect(notes[0].startTime).toBe(now);
engine.recordStroke({
vibe: VIBE_PRESETS[0],
now: now + 6,
activity: 0.95,
selectedColorIndex: 1,
});
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, 2);
renderBars(second.engine, 0.78, 2);
renderBars(first.engine, 0.78, 2, 16);
renderBars(second.engine, 0.78, 2, 16);
expect(second.notes).toEqual(first.notes);
});

File diff suppressed because it is too large Load diff

View file

@ -1,9 +1,12 @@
import { appConfig } from '../config';
import type { GardenAudioEngineConfig } from '../config';
import { GardenAudioGraph } from './garden-audio-graph';
import { NoiseBurst } from './garden-audio-types';
export class NoiseBurstPlayer {
public constructor(private readonly graph: GardenAudioGraph) {}
public constructor(
private readonly engineConfig: GardenAudioEngineConfig,
private readonly graph: GardenAudioGraph
) {}
public play({ startTime, durationSeconds, gain, filterHz, pan }: NoiseBurst): void {
const { context, eventBus, noiseBuffer } = this.graph;
@ -12,7 +15,7 @@ export class NoiseBurstPlayer {
}
const scheduledStart = Math.max(
context.currentTime + appConfig.audioEngine.noiseBurst.scheduleAheadSeconds,
context.currentTime + this.engineConfig.noiseBurst.scheduleAheadSeconds,
startTime
);
const source = context.createBufferSource();
@ -24,17 +27,17 @@ export class NoiseBurstPlayer {
source.buffer = noiseBuffer;
filter.type = 'bandpass';
filter.frequency.setValueAtTime(filterHz, scheduledStart);
filter.Q.value = appConfig.audioEngine.noiseBurst.filterQ;
filter.Q.value = this.engineConfig.noiseBurst.filterQ;
envelope.gain.setValueAtTime(
appConfig.audioEngine.noiseBurst.silentGain,
this.engineConfig.noiseBurst.silentGain,
scheduledStart
);
envelope.gain.exponentialRampToValueAtTime(
Math.max(appConfig.audioEngine.noiseBurst.silentGain, gain),
scheduledStart + appConfig.audioEngine.noiseBurst.attackSeconds
Math.max(this.engineConfig.noiseBurst.silentGain, gain),
scheduledStart + this.engineConfig.noiseBurst.attackSeconds
);
envelope.gain.exponentialRampToValueAtTime(
appConfig.audioEngine.noiseBurst.silentGain,
this.engineConfig.noiseBurst.silentGain,
stopAt
);
panner.pan.setValueAtTime(pan, scheduledStart);
@ -45,7 +48,7 @@ export class NoiseBurstPlayer {
panner.connect(eventBus);
source.start(
scheduledStart,
Math.random() * appConfig.audioEngine.noiseBurst.offsetRandomSeconds
Math.random() * this.engineConfig.noiseBurst.offsetRandomSeconds
);
source.stop(stopAt);
source.addEventListener(

View file

@ -1,4 +1,4 @@
import { appConfig } from '../config';
import type { GardenAudioEngineConfig } from '../config';
import { clamp, clamp01 } from '../utils/clamp';
import { GardenAudioConfig } from './garden-audio-config';
import { GardenAudioGraph } from './garden-audio-graph';
@ -12,6 +12,7 @@ export class PianoSampler {
public constructor(
private readonly config: GardenAudioConfig,
private readonly engineConfig: GardenAudioEngineConfig,
private readonly graph: GardenAudioGraph
) {}
@ -51,31 +52,39 @@ export class PianoSampler {
lowpassHz = this.config.piano.lowpassHz,
}: PianoNote): void {
const { context, eventBus, delayInput } = this.graph;
if (!context || !eventBus || this.samples.length === 0) {
if (!context || !eventBus) {
return;
}
const sample = this.findNearestSample(midi);
if (!sample) {
this.playFallbackPluck({
midi,
velocity,
startTime,
durationSeconds,
pan,
delaySend,
lowpassHz,
});
return;
}
const scheduledStart = Math.max(
context.currentTime + appConfig.audioEngine.piano.scheduleAheadSeconds,
context.currentTime + this.engineConfig.piano.scheduleAheadSeconds,
startTime
);
const noteVelocity = clamp01(velocity);
const noteGainValue = Math.max(
appConfig.audioEngine.piano.minGain,
this.engineConfig.piano.minGain,
this.config.piano.gain * noteVelocity
);
const sustainSeconds =
this.config.piano.sustainSeconds *
(appConfig.audioEngine.piano.sustainBase +
noteVelocity * appConfig.audioEngine.piano.sustainVelocityRange);
(this.engineConfig.piano.sustainBase +
noteVelocity * this.engineConfig.piano.sustainVelocityRange);
const sustainAt =
scheduledStart +
Math.max(appConfig.audioEngine.piano.minDurationSeconds, durationSeconds);
scheduledStart + Math.max(this.engineConfig.piano.minDurationSeconds, durationSeconds);
const releaseAt = sustainAt + sustainSeconds;
const releaseSeconds = this.config.piano.releaseSeconds;
const stopAt = releaseAt + releaseSeconds;
@ -90,20 +99,18 @@ export class PianoSampler {
const oldest = this.activeVoices.shift();
oldest?.gain.gain.cancelScheduledValues(scheduledStart);
oldest?.gain.gain.setTargetAtTime(
appConfig.audioEngine.piano.minGain,
this.engineConfig.piano.minGain,
scheduledStart,
appConfig.audioEngine.piano.voiceStealFadeSeconds
);
oldest?.source.stop(
scheduledStart + appConfig.audioEngine.piano.voiceStealStopSeconds
this.engineConfig.piano.voiceStealFadeSeconds
);
oldest?.source.stop(scheduledStart + this.engineConfig.piano.voiceStealStopSeconds);
}
source.buffer = sample.buffer;
source.playbackRate.setValueAtTime(
Math.pow(
2,
(midi - sample.midi) / appConfig.audioEngine.piano.pitchSemitonesPerOctave
(midi - sample.midi) / this.engineConfig.piano.pitchSemitonesPerOctave
),
scheduledStart
);
@ -111,30 +118,30 @@ export class PianoSampler {
filter.frequency.setValueAtTime(
clamp(
lowpassHz,
appConfig.audioEngine.piano.lowpassMinHz,
appConfig.audioEngine.piano.lowpassMaxHz
this.engineConfig.piano.lowpassMinHz,
this.engineConfig.piano.lowpassMaxHz
),
scheduledStart
);
filter.Q.value = appConfig.audioEngine.piano.filterQ;
gain.gain.setValueAtTime(appConfig.audioEngine.piano.minGain, scheduledStart);
filter.Q.value = this.engineConfig.piano.filterQ;
gain.gain.setValueAtTime(this.engineConfig.piano.minGain, scheduledStart);
gain.gain.exponentialRampToValueAtTime(
noteGainValue,
scheduledStart + appConfig.audioEngine.piano.gainAttackSeconds
scheduledStart + this.engineConfig.piano.gainAttackSeconds
);
gain.gain.setTargetAtTime(
Math.max(
appConfig.audioEngine.piano.minGain,
this.engineConfig.piano.minGain,
noteGainValue * this.config.piano.sustainLevel
),
sustainAt,
Math.max(
appConfig.audioEngine.piano.minFadeSeconds,
sustainSeconds * appConfig.audioEngine.piano.sustainBase
this.engineConfig.piano.minFadeSeconds,
sustainSeconds * this.engineConfig.piano.sustainBase
)
);
gain.gain.setTargetAtTime(
appConfig.audioEngine.piano.minGain,
this.engineConfig.piano.minGain,
releaseAt,
releaseSeconds
);
@ -153,7 +160,7 @@ export class PianoSampler {
}
source.start(scheduledStart);
source.stop(stopAt + appConfig.audioEngine.piano.tailStopExtraSeconds);
source.stop(stopAt + this.engineConfig.piano.tailStopExtraSeconds);
this.activeVoices.push({ gain, source, startAt: scheduledStart, stopAt });
source.addEventListener(
@ -170,41 +177,6 @@ export class PianoSampler {
);
}
public fadeActive(
now: number,
fadeSeconds = appConfig.audioEngine.piano.defaultFadeSeconds
): void {
this.activeVoices.forEach((voice) => {
voice.gain.gain.cancelScheduledValues(now);
if (voice.startAt > now) {
voice.gain.gain.setValueAtTime(appConfig.audioEngine.piano.minGain, now);
voice.stopAt = now;
try {
voice.source.stop(now);
} catch {
// The source may already have a stop time scheduled.
}
return;
}
const stopAt = Math.min(voice.stopAt, now + fadeSeconds);
voice.gain.gain.setTargetAtTime(
appConfig.audioEngine.piano.minGain,
now,
Math.max(
appConfig.audioEngine.piano.minFadeSeconds,
fadeSeconds * appConfig.audioEngine.piano.fadeTimeConstantRatio
)
);
voice.stopAt = stopAt;
try {
voice.source.stop(stopAt);
} catch {
// The source may already have a stop time scheduled.
}
});
}
public reset(): void {
this.sampleLoadPromise = null;
this.samples = [];
@ -224,4 +196,90 @@ export class PianoSampler {
private trimActiveVoices(now: number): void {
this.activeVoices = this.activeVoices.filter((voice) => voice.stopAt > now);
}
private playFallbackPluck({
midi,
velocity,
startTime,
durationSeconds,
pan,
delaySend = 0,
lowpassHz = this.config.piano.lowpassHz,
}: PianoNote): void {
const { context, eventBus, delayInput } = this.graph;
if (!context || !eventBus) {
return;
}
const scheduledStart = Math.max(
context.currentTime + this.engineConfig.piano.scheduleAheadSeconds,
startTime
);
const oscillator = context.createOscillator();
const filter = context.createBiquadFilter();
const gain = context.createGain();
const panner = context.createStereoPanner();
let sendGain: GainNode | null = null;
const noteVelocity = clamp01(velocity);
const noteGainValue = Math.max(
this.engineConfig.piano.minGain,
this.config.piano.gain * noteVelocity * 0.42
);
const releaseAt =
scheduledStart + Math.max(this.engineConfig.piano.minDurationSeconds, durationSeconds);
const stopAt = releaseAt + this.config.piano.releaseSeconds;
oscillator.type = 'triangle';
oscillator.frequency.setValueAtTime(
440 * Math.pow(2, (midi - 69) / appConfig.audioEngine.piano.pitchSemitonesPerOctave),
scheduledStart
);
filter.type = 'lowpass';
filter.frequency.setValueAtTime(
clamp(
lowpassHz * 0.72,
this.engineConfig.piano.lowpassMinHz,
this.engineConfig.piano.lowpassMaxHz
),
scheduledStart
);
filter.Q.value = this.engineConfig.piano.filterQ;
gain.gain.setValueAtTime(this.engineConfig.piano.minGain, scheduledStart);
gain.gain.exponentialRampToValueAtTime(
noteGainValue,
scheduledStart + this.engineConfig.piano.gainAttackSeconds
);
gain.gain.setTargetAtTime(
this.engineConfig.piano.minGain,
releaseAt,
this.config.piano.releaseSeconds
);
panner.pan.setValueAtTime(clamp(pan, -1, 1), scheduledStart);
oscillator.connect(filter);
filter.connect(gain);
gain.connect(panner);
panner.connect(eventBus);
if (delayInput && delaySend > 0) {
sendGain = context.createGain();
sendGain.gain.value = delaySend * 0.5;
panner.connect(sendGain);
sendGain.connect(delayInput);
}
oscillator.start(scheduledStart);
oscillator.stop(stopAt + this.engineConfig.piano.tailStopExtraSeconds);
oscillator.addEventListener(
'ended',
() => {
oscillator.disconnect();
filter.disconnect();
gain.disconnect();
panner.disconnect();
sendGain?.disconnect();
},
{ once: true }
);
}
}

View file

@ -1,4 +1,4 @@
export interface PianoSampleDefinition {
interface PianoSampleDefinition {
midi: number;
url: string;
}