Add WIP sound generation

This commit is contained in:
Andras Schmelczer 2026-05-10 15:26:44 +01:00
parent cb1df6f29e
commit 34ac200437
10 changed files with 1542 additions and 0 deletions

View file

@ -0,0 +1,176 @@
import { clamp01 } from '../utils/clamp';
import { GardenAudioConfig } from './garden-audio-config';
interface GardenGestureState {
startedAt: number;
lastAt: number;
distancePixels: number;
peakEnergy: number;
isErasing: boolean;
}
interface GestureTail {
startedAt: number;
durationSeconds: number;
level: number;
}
type GardenAudioRhythmConfig = GardenAudioConfig['rhythm'];
export class GardenAudioEnergy {
private isGestureActive = false;
private energy = 0;
private targetEnergy = 0;
private lastEnergyUpdateAt = 0;
private currentGesture: GardenGestureState | null = null;
private gestureTails: Array<GestureTail> = [];
public constructor(private readonly rhythm: GardenAudioRhythmConfig) {}
public beginGesture(now: number): void {
this.isGestureActive = true;
this.currentGesture = createGesture(now, false);
}
public endGesture(now: number): void {
if (this.currentGesture && !this.currentGesture.isErasing) {
this.addGestureTail(this.currentGesture, now);
}
this.isGestureActive = false;
this.currentGesture = null;
}
public recordStroke(distancePixels: number, strokeEnergy: number, now: number): void {
if (!this.currentGesture || this.currentGesture.isErasing) {
this.currentGesture = createGesture(now, false);
}
this.currentGesture.lastAt = now;
this.currentGesture.distancePixels += distancePixels;
this.currentGesture.peakEnergy = Math.max(
this.currentGesture.peakEnergy,
strokeEnergy
);
}
public recordEraserStroke(now: number): void {
this.currentGesture = this.currentGesture ?? createGesture(now, true);
this.currentGesture.isErasing = true;
}
public raiseTarget(strokeEnergy: number): void {
this.targetEnergy = Math.max(this.targetEnergy, strokeEnergy);
}
public silence(): void {
this.targetEnergy = 0;
this.gestureTails = [];
}
public update(now: number): void {
if (this.lastEnergyUpdateAt <= 0) {
this.lastEnergyUpdateAt = now;
return;
}
const elapsedSeconds = Math.max(0, now - this.lastEnergyUpdateAt);
this.lastEnergyUpdateAt = now;
this.targetEnergy *= Math.exp(-elapsedSeconds / 0.75);
this.trimGestureTails(now);
const activeGestureFloor =
this.isGestureActive && this.currentGesture && !this.currentGesture.isErasing
? 0.04 + this.getGestureAmount(this.currentGesture, now) * 0.3
: 0;
const target = Math.max(activeGestureFloor, this.targetEnergy);
const timeConstant = target > this.energy ? 0.08 : 0.55;
const amount = 1 - Math.exp(-elapsedSeconds / timeConstant);
this.energy += (target - this.energy) * amount;
}
public getActivityAt(time: number): number {
const activeGesture =
this.isGestureActive && this.currentGesture && !this.currentGesture.isErasing
? 0.08 + this.getGestureAmount(this.currentGesture, time) * 0.34
: 0;
return clamp01(
this.energy * 0.58 + this.getTailActivityAt(time) * 0.72 + activeGesture
);
}
public reset(): void {
this.isGestureActive = false;
this.energy = 0;
this.targetEnergy = 0;
this.lastEnergyUpdateAt = 0;
this.currentGesture = null;
this.gestureTails = [];
}
private addGestureTail(gesture: GardenGestureState, now: number): void {
const durationAmount = clamp01(
(gesture.lastAt - gesture.startedAt) / this.rhythm.tailDurationForMaxSeconds
);
const distanceAmount = clamp01(
gesture.distancePixels / this.rhythm.tailDistanceForMaxPixels
);
const gestureAmount = Math.max(distanceAmount, durationAmount * 0.9);
const tailAmount = clamp01(gestureAmount * (0.74 + gesture.peakEnergy * 0.26));
if (tailAmount <= 0.015) {
return;
}
this.gestureTails.push({
startedAt: now,
durationSeconds:
this.rhythm.minTailSeconds +
(this.rhythm.maxTailSeconds - this.rhythm.minTailSeconds) * tailAmount,
level: (0.05 + tailAmount * 0.5) * (0.72 + gesture.peakEnergy * 0.28),
});
this.trimGestureTails(now);
}
private getGestureAmount(gesture: GardenGestureState, now: number): number {
const durationAmount = clamp01(
(now - gesture.startedAt) / this.rhythm.tailDurationForMaxSeconds
);
const distanceAmount = clamp01(
gesture.distancePixels / this.rhythm.tailDistanceForMaxPixels
);
return clamp01(
distanceAmount * 0.62 + durationAmount * 0.24 + gesture.peakEnergy * 0.14
);
}
private getTailActivityAt(time: number): number {
const decayPower = Math.max(0.2, this.rhythm.tailDecayPower);
return this.gestureTails.reduce((activity, tail) => {
const elapsedSeconds = time - tail.startedAt;
if (elapsedSeconds < 0 || elapsedSeconds >= tail.durationSeconds) {
return activity;
}
const remaining = clamp01(1 - elapsedSeconds / tail.durationSeconds);
return Math.max(activity, tail.level * Math.pow(remaining, decayPower));
}, 0);
}
private trimGestureTails(now: number): void {
this.gestureTails = this.gestureTails.filter(
(tail) => now - tail.startedAt < tail.durationSeconds
);
}
}
const createGesture = (now: number, isErasing: boolean): GardenGestureState => ({
startedAt: now,
lastAt: now,
distancePixels: 0,
peakEnergy: 0,
isErasing,
});

View file

@ -0,0 +1,168 @@
import { clamp } from '../utils/clamp';
import { GardenAudioConfig, GardenAudioVibeProfile } from './garden-audio-config';
export class GardenAudioGraph {
public context: AudioContext | null = null;
public eventBus: GainNode | null = null;
public delayInput: GainNode | null = null;
public noiseBuffer: AudioBuffer | null = null;
private masterGain: GainNode | null = null;
private delayNode: DelayNode | null = null;
private delayFeedback: GainNode | null = null;
private delayOutput: GainNode | null = null;
public constructor(private readonly config: GardenAudioConfig) {}
public ensureContext(canCreate: boolean): AudioContext | null {
if (this.context) {
return this.context;
}
if (!canCreate) {
return null;
}
const context = new AudioContext({ latencyHint: 'interactive' });
const masterGain = context.createGain();
const highPass = context.createBiquadFilter();
const compressor = context.createDynamicsCompressor();
masterGain.gain.value = 0;
highPass.type = 'highpass';
highPass.frequency.value = this.config.highPassFrequencyHz;
compressor.threshold.value = this.config.compressor.thresholdDb;
compressor.knee.value = this.config.compressor.kneeDb;
compressor.ratio.value = this.config.compressor.ratio;
compressor.attack.value = this.config.compressor.attackSeconds;
compressor.release.value = this.config.compressor.releaseSeconds;
masterGain.connect(highPass);
highPass.connect(compressor);
compressor.connect(context.destination);
this.context = context;
this.masterGain = masterGain;
this.noiseBuffer = this.createNoiseBuffer(context);
this.createDelay(context, masterGain);
this.createBuses(context, masterGain);
return context;
}
public setMasterGain(targetGain: number, timeConstantSeconds: number): void {
if (!this.context || !this.masterGain) {
return;
}
this.masterGain.gain.setTargetAtTime(
targetGain,
this.context.currentTime,
timeConstantSeconds
);
}
public applyDelayProfile(profile: GardenAudioVibeProfile): void {
if (!this.context || !this.delayNode) {
return;
}
this.delayNode.delayTime.setTargetAtTime(
this.config.delay.timeSeconds * profile.delayTimeMultiplier,
this.context.currentTime,
0.12
);
}
public updateDelay(profile: GardenAudioVibeProfile, activity: number): void {
if (!this.context || !this.delayNode || !this.delayFeedback || !this.delayOutput) {
return;
}
const now = this.context.currentTime;
this.delayNode.delayTime.setTargetAtTime(
this.config.delay.timeSeconds * profile.delayTimeMultiplier,
now,
0.12
);
this.delayFeedback.gain.setTargetAtTime(
this.config.delay.enabled
? clamp(this.config.delay.feedback + activity * 0.08, 0.04, 0.32)
: 0,
now,
this.config.updateRampSeconds
);
this.delayOutput.gain.setTargetAtTime(
this.config.delay.enabled ? this.config.delay.wetGain * (0.65 + activity * 0.5) : 0,
now,
this.config.updateRampSeconds
);
}
public async close(): Promise<void> {
const context = this.context;
if (!context) {
return;
}
if (this.masterGain && context.state !== 'closed') {
this.masterGain.gain.setTargetAtTime(0.0001, context.currentTime, 0.015);
}
this.clearNodes();
if (context.state !== 'closed') {
await context.close().catch(() => undefined);
}
}
private createDelay(context: AudioContext, masterGain: GainNode): void {
const delayInput = context.createGain();
const delayNode = context.createDelay(2);
const delayFeedback = context.createGain();
const delayOutput = context.createGain();
delayNode.delayTime.value = this.config.delay.timeSeconds;
delayFeedback.gain.value = this.config.delay.enabled ? this.config.delay.feedback : 0;
delayOutput.gain.value = this.config.delay.enabled ? this.config.delay.wetGain : 0;
delayInput.connect(delayNode);
delayNode.connect(delayFeedback);
delayFeedback.connect(delayNode);
delayNode.connect(delayOutput);
delayOutput.connect(masterGain);
this.delayInput = delayInput;
this.delayNode = delayNode;
this.delayFeedback = delayFeedback;
this.delayOutput = delayOutput;
}
private createBuses(context: AudioContext, masterGain: GainNode): void {
this.eventBus = context.createGain();
this.eventBus.gain.value = 1;
this.eventBus.connect(masterGain);
}
private createNoiseBuffer(context: AudioContext): AudioBuffer {
const buffer = context.createBuffer(1, context.sampleRate, context.sampleRate);
const data = buffer.getChannelData(0);
for (let index = 0; index < data.length; index++) {
data[index] = Math.random() * 2 - 1;
}
return buffer;
}
private clearNodes(): void {
this.context = null;
this.eventBus = null;
this.delayInput = null;
this.noiseBuffer = null;
this.masterGain = null;
this.delayNode = null;
this.delayFeedback = null;
this.delayOutput = null;
}
}

View file

@ -0,0 +1,60 @@
import { clamp01 } from '../utils/clamp';
import { GardenAudioStroke } from './garden-audio-types';
export interface GardenAudioStrokeMetrics {
distancePixels: number;
pressure: number;
speedAmount: number;
effectiveEnergy: number;
}
export const getStrokeMetrics = (
stroke: GardenAudioStroke,
speedForFullEnergyPixelsPerSecond: number,
fallbackPressure: number
): 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 speedAmount = clamp01(speedPixelsPerSecond / speedForFullEnergyPixelsPerSecond);
const strokeEnergy = clamp01(0.18 + speedAmount * 0.62 + pressure * 0.22);
const effectiveEnergy = strokeEnergy * (0.25 + clamp01(distancePixels / 140) * 0.75);
return {
distancePixels,
pressure,
speedAmount,
effectiveEnergy,
};
};
const getStrokeVelocity = (stroke: GardenAudioStroke, distancePixels: number): number => {
if (
stroke.velocityPixelsPerSecond !== undefined &&
Number.isFinite(stroke.velocityPixelsPerSecond) &&
stroke.velocityPixelsPerSecond >= 0
) {
return stroke.velocityPixelsPerSecond;
}
return distancePixels / (1 / 60);
};
const getPressureAmount = (
stroke: GardenAudioStroke,
fallbackPressure: number
): number => {
if (
stroke.pressure !== undefined &&
Number.isFinite(stroke.pressure) &&
stroke.pressure > 0
) {
return clamp01(stroke.pressure);
}
return stroke.pointerType === 'pen'
? Math.max(0.56, clamp01(fallbackPressure))
: clamp01(fallbackPressure);
};

View file

@ -0,0 +1,52 @@
import { clamp } from '../utils/clamp';
import { VibePreset } from '../vibes';
import {
GardenAudioChord,
GardenAudioConfig,
GardenAudioVibeProfile,
} from './garden-audio-config';
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
): GardenAudioVibeProfile =>
config.vibes[vibe.id] ??
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
): Array<number> => {
if (openVoicing) {
return chord.quality === 'major' ? [0, 7, 12, 16] : [0, 7, 12, 15];
}
return chord.quality === 'major' ? [0, 4, 7, 12, 16] : [0, 3, 7, 12, 15];
};
export const degreeToSemitone = (
profile: GardenAudioVibeProfile,
degree: number
): number => {
const scaleIndex =
((degree % profile.scale.length) + profile.scale.length) % profile.scale.length;
const octave = Math.floor(degree / profile.scale.length);
return profile.scale[scaleIndex] + octave * 12;
};

View file

@ -0,0 +1,282 @@
import { clamp, clamp01 } from '../utils/clamp';
import { VibePreset } from '../vibes';
import {
GardenAudioChord,
GardenAudioConfig,
GardenAudioVibeProfile,
} from './garden-audio-config';
import {
clampMidi,
degreeToSemitone,
getChordAtStep,
getChordIntervals,
getVibeProfile,
} from './garden-audio-music';
import {
GardenAudioColorIndex,
GardenAudioStroke,
PianoNote,
} from './garden-audio-types';
interface RhythmStepRequest {
vibe: VibePreset;
stepIndex: number;
startTime: number;
activity: number;
selectedColorIndex: GardenAudioColorIndex;
}
interface StrokeTapRequest {
stroke: GardenAudioStroke;
energy: number;
now: number;
selectedColorIndex: GardenAudioColorIndex;
stepIndex: number;
rhythmAnchorTime: number;
stepDurationSeconds: number;
}
export class GardenAudioScore {
public constructor(
private readonly config: GardenAudioConfig,
private readonly playNote: (note: PianoNote) => void
) {}
public playRhythmStep({
vibe,
stepIndex,
startTime,
activity,
selectedColorIndex,
}: RhythmStepRequest): void {
const profile = getVibeProfile(this.config, vibe);
const stepInBar = stepIndex % this.config.rhythm.stepsPerBar;
if (activity < this.config.rhythm.sparseActivity) {
return;
}
const chord = getChordAtStep(this.config, profile, stepIndex);
if (stepInBar === 0 && activity < this.config.rhythm.bassActivity) {
this.playRootAnchor(profile, chord, startTime, activity);
}
if (
this.config.rhythm.bassSteps.includes(stepInBar) &&
activity >= this.config.rhythm.bassActivity
) {
this.playBassNote(profile, chord, startTime, activity);
}
if (
this.config.rhythm.chordSteps.includes(stepInBar) &&
activity >= this.config.rhythm.arpeggioActivity
) {
this.playBrokenChord(profile, chord, startTime, activity, stepInBar === 0);
}
if (this.shouldPlayMelodyStep(stepInBar, activity)) {
this.playMelodyNote(
profile,
chord,
stepIndex,
startTime,
activity,
selectedColorIndex
);
}
}
public playStrokeTap({
stroke,
energy,
now,
selectedColorIndex,
stepIndex,
rhythmAnchorTime,
stepDurationSeconds,
}: StrokeTapRequest): void {
const profile = getVibeProfile(this.config, stroke.vibe);
const colorVoice = this.config.colorVoices[selectedColorIndex];
const chord = getChordAtStep(this.config, profile, stepIndex);
const intervals = getChordIntervals(chord, false);
const x = clamp01(stroke.to[0] / Math.max(1, stroke.canvasSize[0]));
const y = 1 - clamp01(stroke.to[1] / Math.max(1, stroke.canvasSize[1]));
const rawRegister = y < 0.35 ? 0 : y > 0.72 ? 2 : 1;
const register =
energy < this.config.rhythm.arpeggioActivity
? Math.min(rawRegister, 1)
: rawRegister;
const interval =
intervals[
(register + colorVoice.scaleDegreeOffset + selectedColorIndex) % intervals.length
];
this.playNote({
midi: clampMidi(
profile.rootMidi +
chord.rootOffset +
interval +
(energy < this.config.rhythm.arpeggioActivity ? 5 : 12) +
register * 5 +
(energy >= this.config.rhythm.fullChordActivity
? colorVoice.octaveOffset * 7
: 0),
50,
energy < this.config.rhythm.arpeggioActivity ? 76 : 88
),
velocity: (0.26 + energy * 0.34) * colorVoice.velocityMultiplier,
durationSeconds: 1.05 + energy * 0.45,
pan: clamp(x * 2 - 1 + colorVoice.panOffset * 0.6, -0.85, 0.85),
delaySend: 0.014,
lowpassHz: this.config.piano.lowpassHz * profile.brightness,
startTime: this.getNearGridTime(now, rhythmAnchorTime, stepDurationSeconds),
});
}
public playVibeChangeStinger(vibe: VibePreset, now: number): void {
const profile = getVibeProfile(this.config, vibe);
const chord = profile.progression[0];
const intervals = getChordIntervals(chord, true);
intervals.forEach((interval, index) => {
this.playNote({
midi: clampMidi(profile.rootMidi + chord.rootOffset + interval, 48, 84),
velocity: 0.3 * Math.pow(0.9, index),
durationSeconds: 1.4,
pan: clamp(-0.22 + index * 0.14, -0.5, 0.5),
delaySend: 0.016,
lowpassHz: this.config.piano.lowpassHz * profile.brightness,
startTime: now + index * 0.045,
});
});
}
private shouldPlayMelodyStep(stepInBar: number, activity: number): boolean {
if (activity < this.config.rhythm.sparseActivity) {
return false;
}
if (activity < this.config.rhythm.arpeggioActivity) {
return stepInBar === 10;
}
if (activity < this.config.rhythm.fullChordActivity) {
return stepInBar === 6 || stepInBar === 10;
}
return this.config.rhythm.melodySteps.includes(stepInBar);
}
private playRootAnchor(
profile: GardenAudioVibeProfile,
chord: GardenAudioChord,
startTime: number,
activity: number
): void {
this.playNote({
midi: clampMidi(profile.rootMidi + chord.rootOffset, 43, 64),
velocity: 0.13 + activity * 0.14,
durationSeconds: 1.35,
pan: -0.06,
delaySend: 0.004,
lowpassHz: this.config.piano.lowpassHz * profile.brightness * 0.78,
startTime,
});
}
private playBassNote(
profile: GardenAudioVibeProfile,
chord: GardenAudioChord,
startTime: number,
activity: number
): void {
this.playNote({
midi: clampMidi(profile.rootMidi + chord.rootOffset - 12, 36, 58),
velocity: 0.24 + activity * 0.18,
durationSeconds: 1.8,
pan: -0.08,
delaySend: 0.006,
startTime,
});
}
private playBrokenChord(
profile: GardenAudioVibeProfile,
chord: GardenAudioChord,
startTime: number,
activity: number,
isAccent: boolean
): void {
const intervals = getChordIntervals(chord, true);
const velocity = (isAccent ? 0.22 : 0.16) + activity * 0.16;
const baseMidi = profile.rootMidi + chord.rootOffset;
const isFullChord = activity >= this.config.rhythm.fullChordActivity;
const noteCount = isFullChord ? intervals.length : activity >= 0.46 ? 3 : 2;
const staggerSeconds = isFullChord ? 0.018 : 0.056;
intervals.slice(0, noteCount).forEach((interval, index) => {
this.playNote({
midi: clampMidi(baseMidi + interval, 48, 78),
velocity: velocity * Math.pow(0.9, index),
durationSeconds: isFullChord ? (isAccent ? 1.9 : 1.35) : 1.08,
pan: clamp(-0.16 + index * 0.1, -0.45, 0.45),
delaySend: 0.012,
lowpassHz: this.config.piano.lowpassHz * profile.brightness,
startTime: startTime + index * staggerSeconds,
});
});
}
private playMelodyNote(
profile: GardenAudioVibeProfile,
chord: GardenAudioChord,
stepIndex: number,
startTime: number,
activity: number,
selectedColorIndex: GardenAudioColorIndex
): void {
const colorVoice = this.config.colorVoices[selectedColorIndex];
const patternIndex =
Math.floor(stepIndex / 2) + colorVoice.scaleDegreeOffset + selectedColorIndex;
const scaleDegree =
this.config.rhythm.melodyPattern[
patternIndex % this.config.rhythm.melodyPattern.length
] + colorVoice.scaleDegreeOffset;
const stepInBeat = stepIndex % this.config.rhythm.stepsPerBeat;
const semitoneOffset =
stepInBeat === 0
? chord.rootOffset +
getChordIntervals(chord, false)[(patternIndex + selectedColorIndex) % 3]
: degreeToSemitone(profile, scaleDegree);
const registerLift = activity < this.config.rhythm.arpeggioActivity ? 0 : 12;
const colorOctaveLift =
activity >= this.config.rhythm.fullChordActivity ? colorVoice.octaveOffset * 12 : 0;
this.playNote({
midi: clampMidi(
profile.rootMidi + semitoneOffset + registerLift + colorOctaveLift,
activity < this.config.rhythm.arpeggioActivity ? 50 : 57,
activity < this.config.rhythm.arpeggioActivity ? 76 : 91
),
velocity:
(0.2 + activity * 0.32) *
colorVoice.velocityMultiplier *
(stepInBeat === 0 ? 1.08 : 1),
durationSeconds: 0.92 + activity * 0.38,
pan: colorVoice.panOffset,
delaySend: 0.018,
lowpassHz: this.config.piano.lowpassHz * profile.brightness,
startTime,
});
}
private getNearGridTime(
now: number,
rhythmAnchorTime: number,
stepDurationSeconds: number
): number {
const stepCount = Math.ceil((now - rhythmAnchorTime) / stepDurationSeconds);
const gridTime = rhythmAnchorTime + Math.max(0, stepCount) * stepDurationSeconds;
return gridTime - now <= 0.1 ? gridTime : now + 0.012;
}
}

View file

@ -0,0 +1,63 @@
import { VibePreset } from '../vibes';
export type GardenAudioColorIndex = 0 | 1 | 2;
export interface GardenAudioSnapshot {
vibe: VibePreset;
activeAgentCount: number;
agentBudgetMax: number;
selectedColorIndex: number;
isErasing: boolean;
introProgress: number;
moveSpeed: number;
diffusionRateTrails: number;
decayRateTrails: number;
brushEffectDuration: number;
clarity: number;
}
export interface GardenAudioStroke {
vibe: VibePreset;
from: ArrayLike<number>;
to: ArrayLike<number>;
canvasSize: ArrayLike<number>;
colorIndex: number;
isErasing: boolean;
pressure?: number;
velocityPixelsPerSecond?: number;
eraserSizePixels?: number;
pointerType?: string;
}
export interface GardenAudioStartOptions {
userGesture?: boolean;
}
export interface LoadedPianoSample {
midi: number;
buffer: AudioBuffer;
}
export interface ActivePianoVoice {
gain: GainNode;
source: AudioBufferSourceNode;
stopAt: number;
}
export interface PianoNote {
midi: number;
velocity: number;
startTime: number;
durationSeconds: number;
pan: number;
delaySend?: number;
lowpassHz?: number;
}
export interface NoiseBurst {
startTime: number;
durationSeconds: number;
gain: number;
filterHz: number;
pan: number;
}

View file

@ -0,0 +1,144 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { VIBE_PRESETS } from '../vibes';
import { GardenAudio } from './garden-audio';
import { gardenAudioConfig, GardenAudioConfig } from './garden-audio-config';
const calls = {
constructed: 0,
resumed: 0,
};
let contextState: AudioContextState = 'suspended';
class FakeAudioParam {
public value = 0;
public setTargetAtTime = vi.fn();
public setValueAtTime = vi.fn();
public exponentialRampToValueAtTime = vi.fn();
public cancelScheduledValues = vi.fn();
}
class FakeAudioNode {
public readonly gain = new FakeAudioParam();
public readonly frequency = new FakeAudioParam();
public readonly threshold = new FakeAudioParam();
public readonly knee = new FakeAudioParam();
public readonly ratio = new FakeAudioParam();
public readonly attack = new FakeAudioParam();
public readonly release = new FakeAudioParam();
public readonly delayTime = new FakeAudioParam();
public type = '';
public connect = vi.fn();
public disconnect = vi.fn();
}
class FakeAudioBuffer {
private readonly data: Float32Array;
public constructor(length: number) {
this.data = new Float32Array(length);
}
public getChannelData(): Float32Array {
return this.data;
}
}
class FakeAudioContext {
public readonly currentTime = 1;
public readonly sampleRate = 16;
public readonly destination = new FakeAudioNode() as unknown as AudioDestinationNode;
public constructor() {
calls.constructed += 1;
}
public get state(): AudioContextState {
return contextState;
}
public set state(state: AudioContextState) {
contextState = state;
}
public createGain(): GainNode {
return new FakeAudioNode() as unknown as GainNode;
}
public createBiquadFilter(): BiquadFilterNode {
return new FakeAudioNode() as unknown as BiquadFilterNode;
}
public createDynamicsCompressor(): DynamicsCompressorNode {
return new FakeAudioNode() as unknown as DynamicsCompressorNode;
}
public createDelay(): DelayNode {
return new FakeAudioNode() as unknown as DelayNode;
}
public createBuffer(_channels: number, length: number): AudioBuffer {
return new FakeAudioBuffer(length) as unknown as AudioBuffer;
}
public async resume(): Promise<void> {
calls.resumed += 1;
contextState = 'running';
}
}
const makeConfig = (): GardenAudioConfig => ({
...gardenAudioConfig,
piano: {
...gardenAudioConfig.piano,
preloadOnStart: false,
},
});
describe('GardenAudio startup policy', () => {
beforeEach(() => {
calls.constructed = 0;
calls.resumed = 0;
contextState = 'suspended';
vi.stubGlobal('AudioContext', FakeAudioContext);
});
afterEach(() => {
vi.unstubAllGlobals();
});
it('does not create an AudioContext from passive audio paths', () => {
const audio = new GardenAudio(makeConfig());
const vibe = VIBE_PRESETS[0];
audio.start(vibe);
audio.stroke({
vibe,
from: [0, 0],
to: [12, 0],
canvasSize: [100, 100],
colorIndex: 0,
isErasing: false,
});
expect(calls.constructed).toBe(0);
});
it('only resumes a suspended context from a user gesture start', () => {
const audio = new GardenAudio(makeConfig());
const vibe = VIBE_PRESETS[0];
audio.start(vibe, { userGesture: true });
expect(calls.constructed).toBe(1);
expect(calls.resumed).toBe(1);
expect(contextState).toBe('running');
contextState = 'suspended';
audio.start(vibe);
audio.setMuted(false);
expect(calls.resumed).toBe(1);
});
});

386
src/audio/garden-audio.ts Normal file
View file

@ -0,0 +1,386 @@
import { clamp, clamp01 } from '../utils/clamp';
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 { getVibeProfile, normalizeColorIndex } from './garden-audio-music';
import { GardenAudioScore } from './garden-audio-score';
import type {
GardenAudioColorIndex,
GardenAudioSnapshot,
GardenAudioStartOptions,
GardenAudioStroke,
} from './garden-audio-types';
import { NoiseBurstPlayer } from './noise-burst-player';
import { PianoSampler } from './piano-sampler';
export type {
GardenAudioSnapshot,
GardenAudioStartOptions,
GardenAudioStroke,
} from './garden-audio-types';
export class GardenAudio {
private readonly graph: GardenAudioGraph;
private readonly piano: PianoSampler;
private readonly noise: NoiseBurstPlayer;
private readonly energy: GardenAudioEnergy;
private readonly score: GardenAudioScore;
private currentVibeId: string | null = null;
private hasStarted = false;
private isDestroyed = false;
private isMuted = false;
private selectedColorIndex: GardenAudioColorIndex = 0;
private rhythmAnchorTime: number | null = null;
private startedAt: number | null = null;
private nextStepAt = 0;
private stepIndex = 0;
private lastTapAt = Number.NEGATIVE_INFINITY;
private lastEraserAt = Number.NEGATIVE_INFINITY;
private lastVibeStingerAt = Number.NEGATIVE_INFINITY;
public constructor(private readonly config: GardenAudioConfig) {
this.graph = new GardenAudioGraph(config);
this.piano = new PianoSampler(config, this.graph);
this.noise = new NoiseBurstPlayer(this.graph);
this.energy = new GardenAudioEnergy(config.rhythm);
this.score = new GardenAudioScore(config, (note) => this.piano.play(note));
}
public start(vibe: VibePreset, options: GardenAudioStartOptions = {}): void {
if (!this.config.enabled || this.isDestroyed || this.isMuted) {
return;
}
const context = this.graph.ensureContext(options.userGesture === true);
if (!context) {
return;
}
if (context.state === 'suspended') {
if (options.userGesture !== true) {
return;
}
void context.resume().catch(() => undefined);
}
this.hasStarted = true;
this.rhythmAnchorTime ??= context.currentTime;
this.startedAt ??= context.currentTime;
this.applyVibe(vibe);
if (this.nextStepAt <= 0) {
this.nextStepAt = context.currentTime + 0.02;
}
this.graph.setMasterGain(this.config.masterVolume, this.config.fadeInSeconds);
if (this.config.piano.preloadOnStart) {
void this.piano.load(context);
}
}
public changeVibe(vibe: VibePreset, options: GardenAudioStartOptions = {}): void {
const previousVibeId = this.currentVibeId;
this.start(vibe, options);
const context = this.graph.context;
if (
context &&
(context.state === 'running' || options.userGesture === true) &&
!this.isMuted &&
!this.isDestroyed &&
previousVibeId !== null &&
previousVibeId !== vibe.id
) {
this.playVibeChangeStinger(vibe);
}
}
public setMuted(isMuted: boolean): void {
this.isMuted = isMuted;
this.graph.setMasterGain(
isMuted ? 0.0001 : this.config.masterVolume,
isMuted ? 0.02 : this.config.fadeInSeconds
);
}
public beginGesture(): void {
const context = this.graph.context;
if (!context) {
return;
}
this.energy.beginGesture(context.currentTime);
}
public endGesture(): void {
const context = this.graph.context;
if (!context) {
return;
}
this.energy.endGesture(context.currentTime);
}
public rememberColor(colorIndex: number): void {
this.selectedColorIndex = normalizeColorIndex(colorIndex);
}
public update(snapshot: GardenAudioSnapshot): void {
const context = this.graph.context;
if (!this.hasStarted || !context || this.isMuted) {
return;
}
this.applyVibe(snapshot.vibe);
this.selectedColorIndex = normalizeColorIndex(snapshot.selectedColorIndex);
this.energy.update(context.currentTime);
if (snapshot.isErasing) {
this.energy.silence();
this.piano.fadeActive(context.currentTime);
this.updateDelay(snapshot);
return;
}
this.scheduleRhythm(snapshot.vibe);
this.updateDelay(snapshot);
}
public stroke(stroke: GardenAudioStroke): void {
if (!this.config.enabled || this.isDestroyed || this.isMuted) {
return;
}
this.start(stroke.vibe);
const context = this.graph.context;
if (!context) {
return;
}
const metrics = getStrokeMetrics(
stroke,
this.config.rhythm.speedForFullEnergyPixelsPerSecond,
this.config.input.pressureFallback
);
const now = context.currentTime;
this.selectedColorIndex = normalizeColorIndex(stroke.colorIndex);
if (stroke.isErasing) {
this.energy.recordEraserStroke(now);
this.playEraser(stroke, metrics.speedAmount, metrics.pressure, now);
return;
}
const strokeEnergy = metrics.effectiveEnergy * this.getStartupEnergyScale(now);
this.energy.recordStroke(metrics.distancePixels, strokeEnergy, now);
if (metrics.distancePixels >= 2.5) {
this.energy.raiseTarget(strokeEnergy);
}
if (
metrics.distancePixels >= 2.5 &&
now - this.lastTapAt >= this.getTapIntervalSeconds(now)
) {
this.lastTapAt = now;
this.score.playStrokeTap({
stroke,
energy: strokeEnergy,
now,
selectedColorIndex: this.selectedColorIndex,
stepIndex: this.stepIndex,
rhythmAnchorTime: this.getRhythmAnchorTime(now),
stepDurationSeconds: this.getStepDurationSeconds(now),
});
}
}
public async destroy(): Promise<void> {
this.isDestroyed = true;
await this.graph.close();
this.piano.reset();
this.energy.reset();
this.currentVibeId = null;
this.hasStarted = false;
this.selectedColorIndex = 0;
this.rhythmAnchorTime = null;
this.startedAt = null;
this.nextStepAt = 0;
this.stepIndex = 0;
this.lastTapAt = Number.NEGATIVE_INFINITY;
this.lastEraserAt = Number.NEGATIVE_INFINITY;
this.lastVibeStingerAt = Number.NEGATIVE_INFINITY;
}
private scheduleRhythm(vibe: VibePreset): void {
const context = this.graph.context;
if (!context || !this.graph.eventBus) {
return;
}
const now = context.currentTime;
const stepSeconds = this.getStepDurationSeconds(now);
if (this.nextStepAt <= 0 || this.nextStepAt < now - stepSeconds) {
this.nextStepAt = now + 0.02;
}
const lookaheadEnd = now + this.config.rhythm.lookaheadSeconds;
while (this.nextStepAt <= lookaheadEnd) {
const swingOffset =
this.stepIndex % 2 === 1 ? stepSeconds * this.config.rhythm.swing : 0;
const startTime = this.nextStepAt + swingOffset;
this.score.playRhythmStep({
vibe,
stepIndex: this.stepIndex,
startTime,
activity: this.getSettledActivity(
this.energy.getActivityAt(startTime),
startTime
),
selectedColorIndex: this.selectedColorIndex,
});
this.nextStepAt += stepSeconds;
this.stepIndex += 1;
}
}
private playVibeChangeStinger(vibe: VibePreset): void {
const context = this.graph.context;
if (!context) {
return;
}
const now = context.currentTime;
if (now - this.lastVibeStingerAt < 0.45) {
return;
}
this.lastVibeStingerAt = now;
this.score.playVibeChangeStinger(vibe, now);
}
private playEraser(
stroke: GardenAudioStroke,
speedAmount: number,
pressure: number,
now: number
): void {
if (!this.config.eraser.enabled || !this.graph.context) {
return;
}
const sizeAmount = clamp01(
(stroke.eraserSizePixels ?? 96) / Math.max(1, stroke.canvasSize[0] * 0.18)
);
const x = clamp01(stroke.to[0] / Math.max(1, stroke.canvasSize[0]));
const filterHz =
this.config.eraser.filterMinHz +
(this.config.eraser.filterMaxHz - this.config.eraser.filterMinHz) *
clamp01(speedAmount * 0.58 + pressure * 0.26 + sizeAmount * 0.16);
if (now - this.lastEraserAt >= this.config.eraser.minIntervalSeconds) {
this.lastEraserAt = now;
this.noise.play({
startTime: now,
durationSeconds: 0.08,
gain:
this.config.eraser.noiseGain *
(0.45 + speedAmount * 0.38 + pressure * 0.24 + sizeAmount * 0.18),
filterHz,
pan: clamp(x * 2 - 1, -1, 1),
});
}
}
private updateDelay(snapshot: GardenAudioSnapshot): void {
const context = this.graph.context;
if (!context) {
return;
}
const profile = getVibeProfile(this.config, snapshot.vibe);
const activity = snapshot.isErasing
? 0.12
: this.getSettledActivity(
this.energy.getActivityAt(context.currentTime),
context.currentTime
);
this.graph.updateDelay(profile, activity);
}
private applyVibe(vibe: VibePreset): void {
if (!this.graph.context || this.currentVibeId === vibe.id) {
return;
}
this.currentVibeId = vibe.id;
this.graph.applyDelayProfile(getVibeProfile(this.config, vibe));
}
private getStepDurationSeconds(time: number): number {
const baseStepSeconds = 60 / this.config.rhythm.bpm / this.config.rhythm.stepsPerBeat;
return baseStepSeconds * this.getStartupTempoMultiplier(time);
}
private getRhythmAnchorTime(now: number): number {
this.rhythmAnchorTime ??= now;
return this.rhythmAnchorTime;
}
private getTapIntervalSeconds(time: number): number {
return (
this.config.rhythm.minTapIntervalSeconds *
this.getStartupTapIntervalMultiplier(time)
);
}
private getSettledActivity(activity: number, time: number): number {
return Math.min(activity, this.getStartupActivityCeiling(time));
}
private getStartupEnergyScale(time: number): number {
return this.interpolateStartupValue(
this.config.startup.initialEnergyMultiplier,
1,
time
);
}
private getStartupActivityCeiling(time: number): number {
return this.interpolateStartupValue(
this.config.startup.initialActivityCeiling,
1,
time
);
}
private getStartupTempoMultiplier(time: number): number {
return this.interpolateStartupValue(
this.config.startup.initialTempoMultiplier,
1,
time
);
}
private getStartupTapIntervalMultiplier(time: number): number {
return this.interpolateStartupValue(
this.config.startup.initialTapIntervalMultiplier,
1,
time
);
}
private interpolateStartupValue(from: number, to: number, time: number): number {
const durationSeconds = this.config.startup.calmDurationSeconds;
if (this.startedAt === null || durationSeconds <= 0) {
return to;
}
const progress = clamp01((time - this.startedAt) / durationSeconds);
const easedProgress = progress * progress * (3 - 2 * progress);
return from + (to - from) * easedProgress;
}
}

View file

@ -0,0 +1,49 @@
import { GardenAudioGraph } from './garden-audio-graph';
import { NoiseBurst } from './garden-audio-types';
export class NoiseBurstPlayer {
public constructor(private readonly graph: GardenAudioGraph) {}
public play({ startTime, durationSeconds, gain, filterHz, pan }: NoiseBurst): void {
const { context, eventBus, noiseBuffer } = this.graph;
if (!context || !eventBus || !noiseBuffer) {
return;
}
const scheduledStart = Math.max(context.currentTime + 0.002, startTime);
const source = context.createBufferSource();
const filter = context.createBiquadFilter();
const envelope = context.createGain();
const panner = context.createStereoPanner();
const stopAt = scheduledStart + durationSeconds;
source.buffer = noiseBuffer;
filter.type = 'bandpass';
filter.frequency.setValueAtTime(filterHz, scheduledStart);
filter.Q.value = 1.4;
envelope.gain.setValueAtTime(0.0001, scheduledStart);
envelope.gain.exponentialRampToValueAtTime(
Math.max(0.0001, gain),
scheduledStart + 0.004
);
envelope.gain.exponentialRampToValueAtTime(0.0001, stopAt);
panner.pan.setValueAtTime(pan, scheduledStart);
source.connect(filter);
filter.connect(envelope);
envelope.connect(panner);
panner.connect(eventBus);
source.start(scheduledStart, Math.random() * 0.4);
source.stop(stopAt);
source.addEventListener(
'ended',
() => {
source.disconnect();
filter.disconnect();
envelope.disconnect();
panner.disconnect();
},
{ once: true }
);
}
}

162
src/audio/piano-sampler.ts Normal file
View file

@ -0,0 +1,162 @@
import { clamp, clamp01 } from '../utils/clamp';
import { GardenAudioConfig } from './garden-audio-config';
import { GardenAudioGraph } from './garden-audio-graph';
import { ActivePianoVoice, LoadedPianoSample, PianoNote } from './garden-audio-types';
import { pianoSampleDefinitions } from './piano-samples';
export class PianoSampler {
private sampleLoadPromise: Promise<void> | null = null;
private samples: Array<LoadedPianoSample> = [];
private activeVoices: Array<ActivePianoVoice> = [];
public constructor(
private readonly config: GardenAudioConfig,
private readonly graph: GardenAudioGraph
) {}
public async load(context: AudioContext): Promise<void> {
if (this.sampleLoadPromise) {
return this.sampleLoadPromise;
}
this.sampleLoadPromise = Promise.all(
pianoSampleDefinitions.map(async (sample) => {
const response = await fetch(sample.url);
const audioData = await response.arrayBuffer();
const buffer = await context.decodeAudioData(audioData);
return { midi: sample.midi, buffer };
})
)
.then((samples) => {
this.samples = samples.sort((a, b) => a.midi - b.midi);
})
.catch(() => {
this.samples = [];
});
return this.sampleLoadPromise;
}
public play({
midi,
velocity,
startTime,
durationSeconds,
pan,
delaySend = 0,
lowpassHz = this.config.piano.lowpassHz,
}: PianoNote): void {
const { context, eventBus, delayInput } = this.graph;
if (!context || !eventBus || this.samples.length === 0) {
return;
}
const sample = this.findNearestSample(midi);
if (!sample) {
return;
}
const scheduledStart = Math.max(context.currentTime + 0.002, startTime);
const noteVelocity = clamp01(velocity);
const noteGainValue = Math.max(0.0001, this.config.piano.gain * noteVelocity);
const sustainSeconds =
this.config.piano.sustainSeconds * (0.45 + noteVelocity * 0.55);
const sustainAt = scheduledStart + Math.max(0.08, durationSeconds);
const releaseAt = sustainAt + sustainSeconds;
const releaseSeconds = this.config.piano.releaseSeconds;
const stopAt = releaseAt + releaseSeconds;
const source = context.createBufferSource();
const filter = context.createBiquadFilter();
const gain = context.createGain();
const panner = context.createStereoPanner();
let sendGain: GainNode | null = null;
this.trimActiveVoices(scheduledStart);
while (this.activeVoices.length >= this.config.piano.maxVoices) {
const oldest = this.activeVoices.shift();
oldest?.gain.gain.cancelScheduledValues(scheduledStart);
oldest?.gain.gain.setTargetAtTime(0.0001, scheduledStart, 0.025);
oldest?.source.stop(scheduledStart + 0.05);
}
source.buffer = sample.buffer;
source.playbackRate.setValueAtTime(
Math.pow(2, (midi - sample.midi) / 12),
scheduledStart
);
filter.type = 'lowpass';
filter.frequency.setValueAtTime(clamp(lowpassHz, 1400, 12000), scheduledStart);
filter.Q.value = 0.7;
gain.gain.setValueAtTime(0.0001, scheduledStart);
gain.gain.exponentialRampToValueAtTime(noteGainValue, scheduledStart + 0.006);
gain.gain.setTargetAtTime(
Math.max(0.0001, noteGainValue * this.config.piano.sustainLevel),
sustainAt,
Math.max(0.04, sustainSeconds * 0.45)
);
gain.gain.setTargetAtTime(0.0001, releaseAt, releaseSeconds);
panner.pan.setValueAtTime(clamp(pan, -1, 1), scheduledStart);
source.connect(filter);
filter.connect(gain);
gain.connect(panner);
panner.connect(eventBus);
if (delayInput && this.config.delay.enabled && delaySend > 0) {
sendGain = context.createGain();
sendGain.gain.value = delaySend;
panner.connect(sendGain);
sendGain.connect(delayInput);
}
source.start(scheduledStart);
source.stop(stopAt + 0.05);
this.activeVoices.push({ gain, source, stopAt });
source.addEventListener(
'ended',
() => {
source.disconnect();
filter.disconnect();
gain.disconnect();
panner.disconnect();
sendGain?.disconnect();
this.activeVoices = this.activeVoices.filter((voice) => voice.gain !== gain);
},
{ once: true }
);
}
public fadeActive(now: number): void {
this.activeVoices.forEach((voice) => {
voice.gain.gain.cancelScheduledValues(now);
voice.gain.gain.setTargetAtTime(0.0001, now, 0.035);
voice.stopAt = Math.min(voice.stopAt, now + 0.28);
try {
voice.source.stop(now + 0.28);
} catch {
// The source may already have a stop time scheduled.
}
});
}
public reset(): void {
this.sampleLoadPromise = null;
this.samples = [];
this.activeVoices = [];
}
private findNearestSample(midi: number): LoadedPianoSample | null {
if (this.samples.length === 0) {
return null;
}
return this.samples.reduce((nearest, sample) =>
Math.abs(sample.midi - midi) < Math.abs(nearest.midi - midi) ? sample : nearest
);
}
private trimActiveVoices(now: number): void {
this.activeVoices = this.activeVoices.filter((voice) => voice.stopAt > now);
}
}