This commit is contained in:
Andras Schmelczer 2026-05-16 16:15:54 +01:00
parent ce383ce34c
commit d2da0d1617
25 changed files with 531 additions and 1036 deletions

View file

@ -13,6 +13,51 @@ interface GardenAudioColorVoice {
panOffset: number;
}
export interface GardenAudioRegister {
midiMin: number;
midiMax: number;
preferredMidi: number;
pan: number;
}
export interface GardenAudioColorPool extends GardenAudioRegister {
scaleDegrees: Array<number>;
}
interface GardenAudioGenerativePianoConfig {
colorPools: [GardenAudioColorPool, GardenAudioColorPool, GardenAudioColorPool];
padRegisters: [GardenAudioRegister, GardenAudioRegister, GardenAudioRegister];
chordBars: number;
supportBarSpacing: number;
supportBarOffset: number;
idleTextureBarSpacing: number;
mediumTextureBarSpacing: number;
textureBeat: number;
highActivityExtraBeat: number;
highActivityExtraThreshold: number;
noteScorePreferenceWeight: number;
noteScoreRegisterWeight: number;
noteScoreRepeatPenalty: number;
gestureAccentSpacingSeconds: number;
gestureAccentMinIntervalSeconds: number;
strokeAccentMinIntervalSeconds: number;
strokeAccentThreshold: number;
stingerSpacingSeconds: number;
stingerDurationSeconds: number;
maxBrushPhraseLayers: number;
brushLayerBaseSeconds: number;
brushLayerEnergySeconds: number;
brushLayerMirrorSeconds: number;
brushLayerMinIntensity: number;
brushStreamIdleIntervalBeats: number;
brushStreamActiveIntervalBeats: number;
brushStreamIntenseIntervalBeats: number;
brushStreamManicIntervalBeats: number;
brushMotifMaxSteps: number;
brushMotifCanonDelaySeconds: number;
padDurationBarScale: number;
}
export interface GardenAudioVibeProfile {
rootMidi: number;
scale: Array<number>;
@ -30,6 +75,13 @@ export interface GardenAudioConfig {
timeSeconds: number;
feedback: number;
wetGain: number;
erasingActivity: number;
activityFeedbackWeight: number;
feedbackMax: number;
feedbackMin: number;
outputActivityWeight: number;
outputBase: number;
timeRampSeconds: number;
};
piano: {
maxVoices: number;
@ -38,13 +90,26 @@ export interface GardenAudioConfig {
sustainLevel: number;
releaseSeconds: number;
lowpassHz: number;
filterQ: number;
gainAttackSeconds: number;
lowpassMaxHz: number;
lowpassMinHz: number;
minDurationSeconds: number;
minFadeSeconds: number;
minGain: number;
pitchSemitonesPerOctave: number;
scheduleAheadSeconds: number;
sustainBase: number;
sustainVelocityRange: number;
tailStopExtraSeconds: number;
voiceStealFadeSeconds: number;
voiceStealStopSeconds: number;
};
rhythm: {
bpm: number;
stepsPerBeat: number;
stepsPerBar: number;
lookaheadSeconds: number;
speedForFullEnergyPixelsPerSecond: number;
sparseActivity: number;
};
eraser: {
@ -52,7 +117,45 @@ export interface GardenAudioConfig {
noiseGain: number;
filterMinHz: number;
filterMaxHz: number;
durationSeconds: number;
};
energy: {
attackSeconds: number;
decaySeconds: number;
immediateActivityScale: number;
releaseSeconds: number;
strokeDecaySeconds: number;
};
graph: {
closeGain: number;
closeRampSeconds: number;
delayMaxSeconds: number;
eventBusGain: number;
noiseMax: number;
noiseMin: number;
unlockTickFrequencyHz: number;
unlockTickSeconds: number;
};
input: {
activeActivityThreshold: number;
distanceWindowForFullActivityPixels: number;
distanceWindowSeconds: number;
fallbackFrameSeconds: number;
manicActivityThreshold: number;
manicModeThreshold: number;
};
muteGain: number;
muteRampSeconds: number;
noiseBurst: {
attackSeconds: number;
filterQ: number;
offsetRandomSeconds: number;
scheduleAheadSeconds: number;
silentGain: number;
};
startDelaySeconds: number;
vibeChangeStingerMinIntervalSeconds: number;
generativePiano: GardenAudioGenerativePianoConfig;
colorVoices: [GardenAudioColorVoice, GardenAudioColorVoice, GardenAudioColorVoice];
vibes: Record<string, GardenAudioVibeProfile>;
}

View file

@ -1,11 +1,11 @@
import { describe, expect, it } from 'vitest';
import { appConfig } from '../config';
import { gardenAudioConfig } from './garden-audio-config';
import { GardenAudioEnergy } from './garden-audio-energy';
describe('GardenAudioEnergy', () => {
it('suspends activity but keeps a fading level when the gesture ends', () => {
const energy = new GardenAudioEnergy(appConfig.audioEngine);
const energy = new GardenAudioEnergy(gardenAudioConfig);
energy.beginGesture(0);
energy.recordStroke(0.8, 0.1);
@ -25,7 +25,7 @@ describe('GardenAudioEnergy', () => {
});
it('uses recent stroke intensity rather than gesture duration alone', () => {
const energy = new GardenAudioEnergy(appConfig.audioEngine);
const energy = new GardenAudioEnergy(gardenAudioConfig);
energy.beginGesture(0);
energy.recordStroke(1, 0.1);
@ -39,7 +39,7 @@ describe('GardenAudioEnergy', () => {
});
it('raises activity immediately when a stroke is recorded', () => {
const energy = new GardenAudioEnergy(appConfig.audioEngine);
const energy = new GardenAudioEnergy(gardenAudioConfig);
energy.beginGesture(0);
energy.recordStroke(0.12, 0.05);

View file

@ -1,7 +1,5 @@
import type { GardenAudioEngineConfig } from '../config';
import { clamp01 } from '../utils/clamp';
const STROKE_IMMEDIATE_ACTIVITY_SCALE = 0.85;
import type { GardenAudioConfig } from './garden-audio-config';
export class GardenAudioEnergy {
private isGestureActive = false;
@ -9,7 +7,7 @@ export class GardenAudioEnergy {
private targetEnergy = 0;
private lastEnergyUpdateAt = 0;
public constructor(private readonly engineConfig: GardenAudioEngineConfig) {}
public constructor(private readonly config: GardenAudioConfig) {}
public beginGesture(now: number): void {
this.isGestureActive = true;
@ -25,7 +23,10 @@ export class GardenAudioEnergy {
const energy = clamp01(strokeEnergy);
this.targetEnergy = Math.max(this.targetEnergy, energy);
if (this.isGestureActive) {
this.energy = Math.max(this.energy, energy * STROKE_IMMEDIATE_ACTIVITY_SCALE);
this.energy = Math.max(
this.energy,
energy * this.config.energy.immediateActivityScale
);
}
this.lastEnergyUpdateAt ||= now;
}
@ -48,15 +49,15 @@ export class GardenAudioEnergy {
const elapsedSeconds = Math.max(0, now - this.lastEnergyUpdateAt);
this.lastEnergyUpdateAt = now;
this.targetEnergy *= Math.exp(
-elapsedSeconds / this.engineConfig.energy.strokeDecaySeconds
-elapsedSeconds / this.config.energy.strokeDecaySeconds
);
const target = this.isGestureActive ? this.targetEnergy : 0;
let timeConstant = this.engineConfig.energy.decaySeconds;
let timeConstant = this.config.energy.decaySeconds;
if (!this.isGestureActive) {
timeConstant = this.engineConfig.energy.releaseSeconds;
timeConstant = this.config.energy.releaseSeconds;
} else if (target > this.energy) {
timeConstant = this.engineConfig.energy.attackSeconds;
timeConstant = this.config.energy.attackSeconds;
}
const amount = 1 - Math.exp(-elapsedSeconds / timeConstant);
this.energy += (target - this.energy) * amount;

View file

@ -1,10 +1,5 @@
import type { GardenAudioEngineConfig } from '../config';
import { clamp, clamp01 } from '../utils/clamp';
import type {
GardenAudioColorIndex,
GardenAudioStroke,
GardenAudioTouchDown,
} from './garden-audio-types';
import { clamp01 } from '../utils/clamp';
import type { GardenAudioConfig } from './garden-audio-config';
import type { GardenAudioStrokeMetrics } from './garden-audio-input';
type GardenAudioGestureMode = 'calm' | 'active' | 'manic' | 'afterglow';
@ -13,216 +8,83 @@ interface GardenAudioGestureFrame {
mode: GardenAudioGestureMode;
activity: number;
maniaAmount: number;
panBias: number;
registerBias: number;
brightnessBias: number;
contour: number;
pressure: number;
pressureDelta: number;
mirrorAmount: number;
speedAmount: number;
}
interface GestureSample {
interface GestureDistanceSample {
at: number;
speed: number;
acceleration: number;
distancePixels: number;
turned: boolean;
}
const WINDOW_SECONDS = 0.75;
const BIN_SECONDS = 0.05;
const MIN_TURN_DEGREES = 55;
const MIN_TURN_DISTANCE_PIXELS = 6;
const DEFAULT_FRAME: GardenAudioGestureFrame = {
mode: 'calm',
activity: 0,
maniaAmount: 0,
panBias: 0,
registerBias: 0,
brightnessBias: 0,
contour: 0,
pressure: 0,
pressureDelta: 0,
mirrorAmount: 0,
speedAmount: 0,
};
export class GardenAudioGestureState {
private readonly samples: Array<GestureSample> = [];
private readonly samples: Array<GestureDistanceSample> = [];
private gestureClockSeconds = 0;
private isGestureActive = false;
private previousPressure = 0;
private previousVelocityPixelsPerSecond = 0;
private previousVector: [number, number] | null = null;
private maniaAmount = 0;
private peakActivity = 0;
private lastFrame: GardenAudioGestureFrame = DEFAULT_FRAME;
public constructor(
private readonly speedForFullEnergyPixelsPerSecond: number,
private readonly inputConfig: GardenAudioEngineConfig['input']
) {}
public constructor(private readonly inputConfig: GardenAudioConfig['input']) {}
public beginGesture(): void {
this.samples.length = 0;
this.gestureClockSeconds = 0;
this.isGestureActive = true;
this.previousPressure = 0;
this.previousVelocityPixelsPerSecond = 0;
this.previousVector = null;
this.maniaAmount = 0;
this.peakActivity = 0;
this.lastFrame = DEFAULT_FRAME;
}
public endGesture(): GardenAudioGestureFrame {
this.isGestureActive = false;
this.samples.length = 0;
this.previousVector = null;
this.previousVelocityPixelsPerSecond = 0;
this.maniaAmount = 0;
this.gestureClockSeconds = 0;
this.lastFrame = {
...this.lastFrame,
mode: this.peakActivity >= 0.42 ? 'afterglow' : 'calm',
activity: 0,
maniaAmount: 0,
speedAmount: 0,
...DEFAULT_FRAME,
mode:
this.peakActivity >= this.inputConfig.activeActivityThreshold
? 'afterglow'
: 'calm',
};
this.peakActivity = 0;
return this.lastFrame;
}
public recordTouchDown({
touch,
colorIndex,
mirrorAmount,
pressure,
strength,
}: {
touch: GardenAudioTouchDown;
colorIndex: GardenAudioColorIndex;
mirrorAmount: number;
pressure: number;
strength: number;
}): GardenAudioGestureFrame {
const spatial = getSpatialBias(touch.position, touch.canvasSize);
const normalizedStrength = clamp01(strength);
this.previousPressure = pressure;
this.peakActivity = Math.max(this.peakActivity, normalizedStrength);
this.lastFrame = {
mode: normalizedStrength >= 0.38 ? 'active' : 'calm',
activity: normalizedStrength,
maniaAmount: 0,
panBias: spatial.panBias,
registerBias: spatial.registerBias,
brightnessBias: spatial.brightnessBias,
contour: colorIndex === 2 ? 0.25 : colorIndex === 0 ? -0.15 : 0,
pressure,
pressureDelta: 0,
mirrorAmount,
speedAmount: 0,
};
public recordTouchDown(): GardenAudioGestureFrame {
this.lastFrame = DEFAULT_FRAME;
return this.lastFrame;
}
public recordStroke({
stroke,
metrics,
mirrorAmount,
}: {
stroke: GardenAudioStroke;
metrics: GardenAudioStrokeMetrics;
mirrorAmount: number;
}): GardenAudioGestureFrame {
const elapsedSeconds = this.getElapsedSeconds(stroke);
this.gestureClockSeconds += elapsedSeconds;
this.gestureClockSeconds += metrics.elapsedSeconds;
const dx = stroke.to[0] - stroke.from[0];
const dy = stroke.to[1] - stroke.from[1];
const distancePixels = metrics.distancePixels;
const speedRatio =
metrics.speedPixelsPerSecond /
Math.max(1, this.speedForFullEnergyPixelsPerSecond);
const speed = smoothstep(0.45, 1.2, speedRatio);
const acceleration = smoothstep(
3,
12,
Math.abs(metrics.speedPixelsPerSecond - this.previousVelocityPixelsPerSecond) /
(Math.max(1, this.speedForFullEnergyPixelsPerSecond) * elapsedSeconds)
);
const currentVector: [number, number] =
distancePixels > 0.001 ? [dx / distancePixels, dy / distancePixels] : [0, 0];
const turned = this.getTurned(currentVector, distancePixels, metrics.speedAmount);
const spatial = getSpatialBias(stroke.to, stroke.canvasSize);
const pressureDelta = clamp(metrics.pressure - this.previousPressure, -1, 1);
const contour = distancePixels > 0.001 ? clamp(-dy / distancePixels, -1, 1) : 0;
if (distancePixels > 0.5) {
if (metrics.distancePixels > 0) {
this.samples.push({
at: this.gestureClockSeconds,
speed,
acceleration,
distancePixels,
turned,
distancePixels: metrics.distancePixels,
});
}
this.trimSamples();
const features = this.getWindowFeatures();
const distanceFeature = smoothstep(10, 90, metrics.distancePixels);
const normalIntensity = clamp01(
0.1 +
features.speed * 0.46 +
metrics.pressure * 0.2 +
distanceFeature * 0.16 +
mirrorAmount * 0.08
const windowDistancePixels = this.samples.reduce(
(total, sample) => total + sample.distancePixels,
0
);
const hasKineticChange = features.acceleration > 0.35 || features.turns > 0.35;
const maniaGate =
!stroke.isErasing &&
this.isGestureActive &&
this.gestureClockSeconds > 0.2 &&
features.pathPixels > 60 &&
features.speed > 0.45 &&
hasKineticChange;
const maniaEvidence = maniaGate
? clamp01(
features.speed * 0.34 +
features.acceleration * 0.26 +
features.strokeFrequency * 0.2 +
features.turns * 0.2
) *
(1 + mirrorAmount * 0.22)
: 0;
const maniaTarget = smoothstep(0.55, 0.85, maniaEvidence);
const timeConstant = maniaTarget > this.maniaAmount ? 0.12 : 0.65;
const maniaMove = 1 - Math.exp(-elapsedSeconds / timeConstant);
const activity = clamp01(
windowDistancePixels / this.inputConfig.distanceWindowForFullActivityPixels
);
const maniaAmount = smoothstep(this.inputConfig.manicActivityThreshold, 1, activity);
this.maniaAmount += (maniaTarget - this.maniaAmount) * maniaMove;
this.previousPressure = metrics.pressure;
this.previousVelocityPixelsPerSecond = metrics.speedPixelsPerSecond;
this.previousVector = currentVector;
const activity = clamp01(normalIntensity + this.maniaAmount * 0.28);
this.peakActivity = Math.max(this.peakActivity, activity);
this.lastFrame = {
mode: this.getMode(activity, this.maniaAmount),
...DEFAULT_FRAME,
mode: this.getMode(activity, maniaAmount),
activity,
maniaAmount: clamp01(this.maniaAmount),
panBias: spatial.panBias,
registerBias: spatial.registerBias,
brightnessBias: clamp01(
spatial.brightnessBias * 0.65 + metrics.pressure * 0.2 + speed * 0.15
),
contour,
pressure: metrics.pressure,
pressureDelta,
mirrorAmount,
speedAmount: metrics.speedAmount,
maniaAmount,
};
return this.lastFrame;
@ -235,150 +97,26 @@ export class GardenAudioGestureState {
public reset(): void {
this.samples.length = 0;
this.gestureClockSeconds = 0;
this.isGestureActive = false;
this.previousPressure = 0;
this.previousVelocityPixelsPerSecond = 0;
this.previousVector = null;
this.maniaAmount = 0;
this.peakActivity = 0;
this.lastFrame = DEFAULT_FRAME;
}
private getElapsedSeconds(stroke: GardenAudioStroke): number {
if (
stroke.elapsedSeconds !== undefined &&
Number.isFinite(stroke.elapsedSeconds) &&
stroke.elapsedSeconds > 0
) {
return clamp(stroke.elapsedSeconds, 0.001, 0.15);
}
return this.inputConfig.fallbackFrameSeconds;
}
private getTurned(
currentVector: [number, number],
distancePixels: number,
speedAmount: number
): boolean {
if (
!this.previousVector ||
distancePixels <= MIN_TURN_DISTANCE_PIXELS ||
speedAmount <= 0.35
) {
return false;
}
const dot = clamp(
this.previousVector[0] * currentVector[0] +
this.previousVector[1] * currentVector[1],
-1,
1
);
const degrees = (Math.acos(dot) * 180) / Math.PI;
return degrees > MIN_TURN_DEGREES;
}
private trimSamples(): void {
const earliest = this.gestureClockSeconds - WINDOW_SECONDS;
const earliest = this.gestureClockSeconds - this.inputConfig.distanceWindowSeconds;
while (this.samples.length > 0 && this.samples[0].at < earliest) {
this.samples.shift();
}
}
private getWindowFeatures(): {
speed: number;
acceleration: number;
strokeFrequency: number;
turns: number;
pathPixels: number;
} {
if (this.samples.length === 0) {
return {
speed: 0,
acceleration: 0,
strokeFrequency: 0,
turns: 0,
pathPixels: 0,
};
}
const first = this.samples[0];
const last = this.samples[this.samples.length - 1];
const spanSeconds = clamp(last.at - first.at, 0.2, WINDOW_SECONDS);
const bins = new Set<number>();
let pathPixels = 0;
let turnCount = 0;
this.samples.forEach((sample) => {
if (sample.distancePixels > 1) {
bins.add(Math.floor(sample.at / BIN_SECONDS));
}
if (sample.turned) {
turnCount += 1;
}
pathPixels += sample.distancePixels;
});
return {
speed: percentile(this.samples.map((sample) => sample.speed), 0.75),
acceleration: percentile(
this.samples.map((sample) => sample.acceleration),
0.75
),
strokeFrequency: smoothstep(6, 14, bins.size / spanSeconds),
turns: smoothstep(2, 7, turnCount / spanSeconds),
pathPixels,
};
}
private getMode(activity: number, maniaAmount: number): GardenAudioGestureMode {
if (maniaAmount >= 0.72) {
if (maniaAmount >= this.inputConfig.manicModeThreshold) {
return 'manic';
}
return activity >= 0.38 ? 'active' : 'calm';
return activity >= this.inputConfig.activeActivityThreshold ? 'active' : 'calm';
}
}
const getSpatialBias = (
position: ArrayLike<number> | undefined,
canvasSize: ArrayLike<number> | undefined
): {
panBias: number;
registerBias: number;
brightnessBias: number;
} => {
if (!position || !canvasSize) {
return {
panBias: 0,
registerBias: 0,
brightnessBias: 0.5,
};
}
const width = Math.max(1, canvasSize[0]);
const height = Math.max(1, canvasSize[1]);
const x = clamp01(position[0] / width);
const y = clamp01(position[1] / height);
return {
panBias: clamp(x * 2 - 1, -1, 1),
registerBias: clamp(1 - y * 2, -1, 1),
brightnessBias: clamp01(1 - y * 0.72),
};
};
const percentile = (values: Array<number>, amount: number): number => {
if (values.length === 0) {
return 0;
}
const sorted = [...values].sort((a, b) => a - b);
const index = clamp(Math.floor((sorted.length - 1) * amount), 0, sorted.length - 1);
return sorted[index];
};
const smoothstep = (edge0: number, edge1: number, value: number): number => {
const amount = clamp01((value - edge0) / (edge1 - edge0));
return amount * amount * (3 - 2 * amount);

View file

@ -1,10 +1,6 @@
import type { GardenAudioEngineConfig } from '../config';
import { clamp } from '../utils/clamp';
import { GardenAudioConfig, GardenAudioVibeProfile } from './garden-audio-config';
const UNLOCK_TICK_SECONDS = 0.035;
const UNLOCK_TICK_FREQUENCY_HZ = 440;
export class GardenAudioGraph {
public context: AudioContext | null = null;
public eventBus: GainNode | null = null;
@ -16,10 +12,7 @@ export class GardenAudioGraph {
private delayFeedback: GainNode | null = null;
private delayOutput: GainNode | null = null;
public constructor(
private readonly config: GardenAudioConfig,
private readonly engineConfig: GardenAudioEngineConfig
) {}
public constructor(private readonly config: GardenAudioConfig) {}
public ensureContext(canCreate: boolean): AudioContext | null {
if (this.context) {
@ -73,16 +66,16 @@ export class GardenAudioGraph {
const gain = this.context.createGain();
source.type = 'sine';
source.frequency.setValueAtTime(UNLOCK_TICK_FREQUENCY_HZ, now);
gain.gain.setValueAtTime(this.engineConfig.piano.minGain, now);
source.frequency.setValueAtTime(this.config.graph.unlockTickFrequencyHz, now);
gain.gain.setValueAtTime(this.config.piano.minGain, now);
gain.gain.exponentialRampToValueAtTime(
this.engineConfig.piano.minGain,
now + UNLOCK_TICK_SECONDS
this.config.piano.minGain,
now + this.config.graph.unlockTickSeconds
);
source.connect(gain);
gain.connect(this.context.destination);
source.start(now);
source.stop(now + UNLOCK_TICK_SECONDS);
source.stop(now + this.config.graph.unlockTickSeconds);
source.addEventListener(
'ended',
() => {
@ -113,7 +106,7 @@ export class GardenAudioGraph {
this.delayNode.delayTime.setTargetAtTime(
this.config.delay.timeSeconds * profile.delayTimeMultiplier,
this.context.currentTime,
this.engineConfig.graph.delayTimeRampSeconds
this.config.delay.timeRampSeconds
);
}
@ -126,22 +119,21 @@ export class GardenAudioGraph {
this.delayNode.delayTime.setTargetAtTime(
this.config.delay.timeSeconds * profile.delayTimeMultiplier,
now,
this.engineConfig.graph.delayTimeRampSeconds
this.config.delay.timeRampSeconds
);
this.delayFeedback.gain.setTargetAtTime(
clamp(
this.config.delay.feedback +
activity * this.engineConfig.graph.delayActivityFeedbackWeight,
this.engineConfig.graph.delayFeedbackMin,
this.engineConfig.graph.delayFeedbackMax
this.config.delay.feedback + activity * this.config.delay.activityFeedbackWeight,
this.config.delay.feedbackMin,
this.config.delay.feedbackMax
),
now,
this.config.updateRampSeconds
);
this.delayOutput.gain.setTargetAtTime(
this.config.delay.wetGain *
(this.engineConfig.graph.delayOutputBase +
activity * this.engineConfig.graph.delayOutputActivityWeight),
(this.config.delay.outputBase +
activity * this.config.delay.outputActivityWeight),
now,
this.config.updateRampSeconds
);
@ -155,9 +147,9 @@ export class GardenAudioGraph {
if (this.masterGain && context.state !== 'closed') {
this.masterGain.gain.setTargetAtTime(
this.engineConfig.graph.closeGain,
this.config.graph.closeGain,
context.currentTime,
this.engineConfig.graph.closeRampSeconds
this.config.graph.closeRampSeconds
);
}
@ -170,7 +162,7 @@ export class GardenAudioGraph {
private createDelay(context: AudioContext, masterGain: GainNode): void {
const delayInput = context.createGain();
const delayNode = context.createDelay(2);
const delayNode = context.createDelay(this.config.graph.delayMaxSeconds);
const delayFeedback = context.createGain();
const delayOutput = context.createGain();
@ -192,7 +184,7 @@ export class GardenAudioGraph {
private createBuses(context: AudioContext, masterGain: GainNode): void {
this.eventBus = context.createGain();
this.eventBus.gain.value = this.engineConfig.graph.eventBusGain;
this.eventBus.gain.value = this.config.graph.eventBusGain;
this.eventBus.connect(masterGain);
}
@ -202,9 +194,8 @@ export class GardenAudioGraph {
for (let index = 0; index < data.length; index++) {
data[index] =
this.engineConfig.graph.noiseMin +
Math.random() *
(this.engineConfig.graph.noiseMax - this.engineConfig.graph.noiseMin);
this.config.graph.noiseMin +
Math.random() * (this.config.graph.noiseMax - this.config.graph.noiseMin);
}
return buffer;

View file

@ -1,70 +1,35 @@
import type { GardenAudioEngineConfig } from '../config';
import { clamp01 } from '../utils/clamp';
import { GardenAudioStroke } from './garden-audio-types';
import type { GardenAudioConfig } from './garden-audio-config';
import type { GardenAudioStroke } from './garden-audio-types';
export interface GardenAudioStrokeMetrics {
distancePixels: number;
pressure: number;
speedPixelsPerSecond: number;
speedAmount: number;
effectiveEnergy: number;
elapsedSeconds: number;
}
export const getStrokeMetrics = (
stroke: GardenAudioStroke,
speedForFullEnergyPixelsPerSecond: number,
inputConfig: GardenAudioEngineConfig['input']
inputConfig: GardenAudioConfig['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, inputConfig);
const pressure = getPressureAmount(stroke);
const speedAmount = clamp01(speedPixelsPerSecond / speedForFullEnergyPixelsPerSecond);
const strokeEnergy = clamp01(
inputConfig.strokeEnergyBase +
speedAmount * inputConfig.strokeEnergySpeedWeight +
pressure * inputConfig.strokeEnergyPressureWeight
);
const effectiveEnergy =
strokeEnergy *
(inputConfig.distanceEnergyBase +
clamp01(distancePixels / inputConfig.distanceForFullEnergyPixels) *
inputConfig.distanceEnergyScale);
return {
distancePixels,
pressure,
speedPixelsPerSecond,
speedAmount,
effectiveEnergy,
distancePixels: Math.hypot(dx, dy),
elapsedSeconds: getElapsedSeconds(stroke, inputConfig),
};
};
const getStrokeVelocity = (
const getElapsedSeconds = (
stroke: GardenAudioStroke,
distancePixels: number,
inputConfig: GardenAudioEngineConfig['input']
inputConfig: GardenAudioConfig['input']
): number => {
if (
stroke.velocityPixelsPerSecond !== undefined &&
Number.isFinite(stroke.velocityPixelsPerSecond) &&
stroke.velocityPixelsPerSecond >= 0
stroke.elapsedSeconds !== undefined &&
Number.isFinite(stroke.elapsedSeconds) &&
stroke.elapsedSeconds > 0
) {
return stroke.velocityPixelsPerSecond;
return Math.max(0.001, stroke.elapsedSeconds);
}
return distancePixels / inputConfig.fallbackFrameSeconds;
};
const getPressureAmount = (stroke: GardenAudioStroke): number => {
if (
stroke.pressure !== undefined &&
Number.isFinite(stroke.pressure) &&
stroke.pressure > 0
) {
return clamp01(stroke.pressure);
}
return 0;
return inputConfig.fallbackFrameSeconds;
};

View file

@ -6,30 +6,19 @@ export interface GardenAudioSnapshot {
vibe: VibePreset;
selectedColorIndex: number;
isErasing: boolean;
mirrorSegmentCount?: number;
}
export interface GardenAudioStroke {
vibe: VibePreset;
from: ArrayLike<number>;
to: ArrayLike<number>;
canvasSize: ArrayLike<number>;
colorIndex: number;
isErasing: boolean;
pressure?: number;
velocityPixelsPerSecond?: number;
elapsedSeconds?: number;
eraserSizePixels?: number;
mirrorSegmentCount?: number;
}
export interface GardenAudioTouchDown {
vibe: VibePreset;
colorIndex: number;
position?: ArrayLike<number>;
canvasSize?: ArrayLike<number>;
mirrorSegmentCount?: number;
pressure?: number;
}
export interface GardenAudioStartOptions {

View file

@ -159,11 +159,7 @@ describe('GardenAudio startup policy', () => {
});
it('does not create an AudioContext from passive audio paths', () => {
const audio = new GardenAudio(
makeConfig(),
appConfig.audioEngine,
appConfig.simulation.maxMirrorSegmentCount
);
const audio = new GardenAudio(makeConfig());
const vibe = VIBE_PRESETS[0];
audio.start(vibe);
@ -171,7 +167,6 @@ describe('GardenAudio startup policy', () => {
vibe,
from: [0, 0],
to: [12, 0],
canvasSize: [100, 100],
colorIndex: 0,
isErasing: false,
});
@ -180,11 +175,7 @@ describe('GardenAudio startup policy', () => {
});
it('only resumes a suspended context from a user gesture start', () => {
const audio = new GardenAudio(
makeConfig(),
appConfig.audioEngine,
appConfig.simulation.maxMirrorSegmentCount
);
const audio = new GardenAudio(makeConfig());
const vibe = VIBE_PRESETS[0];
audio.start(vibe, { userGesture: true });
@ -201,11 +192,7 @@ describe('GardenAudio startup policy', () => {
});
it('reports AudioContext resume failures as warnings', async () => {
const audio = new GardenAudio(
makeConfig(),
appConfig.audioEngine,
appConfig.simulation.maxMirrorSegmentCount
);
const audio = new GardenAudio(makeConfig());
const vibe = VIBE_PRESETS[0];
resumeError = new Error('resume rejected');
const addException = vi.spyOn(ErrorHandler, 'addException');
@ -221,11 +208,7 @@ describe('GardenAudio startup policy', () => {
});
it('stays silent without piano samples while preserving eraser noise', () => {
const audio = new GardenAudio(
makeConfig(),
appConfig.audioEngine,
appConfig.simulation.maxMirrorSegmentCount
);
const audio = new GardenAudio(makeConfig());
const vibe = VIBE_PRESETS[0];
audio.start(vibe, { userGesture: true });
@ -233,21 +216,15 @@ describe('GardenAudio startup policy', () => {
audio.beginGesture();
audio.touchDown({
vibe,
colorIndex: 1,
position: [30, 40],
canvasSize: [100, 100],
pressure: 0.7,
});
audio.stroke({
vibe,
from: [30, 40],
to: [60, 60],
canvasSize: [100, 100],
colorIndex: 1,
isErasing: false,
pressure: 0.7,
velocityPixelsPerSecond: 1600,
elapsedSeconds: 0.05,
});
expect(calls.sourcesStarted).toBe(1);
@ -256,12 +233,9 @@ describe('GardenAudio startup policy', () => {
vibe,
from: [60, 60],
to: [75, 80],
canvasSize: [100, 100],
colorIndex: 1,
eraserSizePixels: 30,
isErasing: true,
pressure: 0.7,
velocityPixelsPerSecond: 1200,
elapsedSeconds: 0.05,
});
expect(calls.sourcesStarted).toBe(2);
@ -277,21 +251,21 @@ describe('GardenAudio startup policy', () => {
);
await loadPianoSamples(new FakeAudioContext() as unknown as AudioContext);
const audio = new GardenAudio(
makeConfig(),
appConfig.audioEngine,
appConfig.simulation.maxMirrorSegmentCount
);
const audio = new GardenAudio(makeConfig());
const vibe = VIBE_PRESETS[0];
audio.start(vibe, { userGesture: true });
audio.beginGesture();
audio.touchDown({
vibe,
colorIndex: 1,
position: [30, 40],
canvasSize: [100, 100],
pressure: 0.7,
});
audio.stroke({
vibe,
from: [30, 40],
to: [90, 40],
colorIndex: 1,
elapsedSeconds: 0.05,
isErasing: false,
});
const activePianoSources = calls.sources.filter(
@ -308,7 +282,7 @@ describe('GardenAudio startup policy', () => {
expect(stoppedVoices.length).toBeGreaterThan(0);
stoppedVoices.forEach((source) => {
expect(source.stop.mock.calls.at(-1)?.[0]).toBeCloseTo(
1 + appConfig.audioEngine.piano.voiceStealStopSeconds,
1 + appConfig.audio.piano.voiceStealStopSeconds,
3
);
});

View file

@ -1,5 +1,4 @@
import type { GardenAudioEngineConfig } from '../config';
import { clamp, clamp01 } from '../utils/clamp';
import { clamp01 } from '../utils/clamp';
import { ErrorHandler, Severity } from '../utils/error-handler';
import { VibePreset } from '../vibes';
import { GardenAudioConfig } from './garden-audio-config';
@ -44,22 +43,13 @@ export class GardenAudio {
private lastEraserAt = Number.NEGATIVE_INFINITY;
private lastVibeStingerAt = Number.NEGATIVE_INFINITY;
public constructor(
private readonly config: GardenAudioConfig,
private readonly engineConfig: GardenAudioEngineConfig,
private readonly maxMirrorSegmentCount: number
) {
this.graph = new GardenAudioGraph(config, engineConfig);
this.piano = new PianoSampler(config, engineConfig, this.graph);
this.noise = new NoiseBurstPlayer(engineConfig, this.graph);
this.energy = new GardenAudioEnergy(engineConfig);
this.gestureState = new GardenAudioGestureState(
config.rhythm.speedForFullEnergyPixelsPerSecond,
engineConfig.input
);
this.pianoEngine = new GenerativePianoEngine(config, engineConfig, (note) =>
this.piano.play(note)
);
public constructor(private readonly config: GardenAudioConfig) {
this.graph = new GardenAudioGraph(config);
this.piano = new PianoSampler(config, this.graph);
this.noise = new NoiseBurstPlayer(config, this.graph);
this.energy = new GardenAudioEnergy(config);
this.gestureState = new GardenAudioGestureState(config.input);
this.pianoEngine = new GenerativePianoEngine(config, (note) => this.piano.play(note));
}
public start(vibe: VibePreset, options: GardenAudioStartOptions = {}): void {
@ -74,7 +64,7 @@ export class GardenAudio {
const startupRampSeconds =
options.userGesture === true
? this.engineConfig.muteRampSeconds
? this.config.muteRampSeconds
: this.config.fadeInSeconds;
const needsResume = context.state !== 'running' && context.state !== 'closed';
let resumePromise: Promise<void> | null = null;
@ -148,8 +138,8 @@ export class GardenAudio {
public setMuted(isMuted: boolean): void {
this.isMuted = isMuted;
this.graph.setMasterGain(
isMuted ? this.engineConfig.muteGain : this.config.masterVolume,
isMuted ? this.engineConfig.muteRampSeconds : this.config.fadeInSeconds
isMuted ? this.config.muteGain : this.config.masterVolume,
isMuted ? this.config.muteRampSeconds : this.config.fadeInSeconds
);
}
@ -183,32 +173,7 @@ export class GardenAudio {
}
this.selectedColorIndex = normalizeColorIndex(touch.colorIndex);
const mirrorAmount = this.getMirrorAmount(touch.mirrorSegmentCount ?? 1);
const pressure = this.getPressure(touch.pressure);
const strength = clamp01(0.36 + pressure * 0.34 + mirrorAmount * 0.22);
const frame = this.gestureState.recordTouchDown({
touch,
colorIndex: this.selectedColorIndex,
mirrorAmount,
pressure,
strength,
});
this.energy.recordStroke(strength, context.currentTime);
this.pianoEngine.recordTouchDown({
vibe: touch.vibe,
now: context.currentTime,
strength,
selectedColorIndex: this.selectedColorIndex,
mirrorAmount,
panBias: frame.panBias,
registerBias: frame.registerBias,
brightnessBias: frame.brightnessBias,
contour: frame.contour,
pressureAmount: frame.pressure,
pressureDelta: frame.pressureDelta,
maniaAmount: frame.maniaAmount,
});
this.gestureState.recordTouchDown();
}
public update(snapshot: GardenAudioSnapshot): void {
@ -248,37 +213,25 @@ export class GardenAudio {
return;
}
const metrics = getStrokeMetrics(
stroke,
this.config.rhythm.speedForFullEnergyPixelsPerSecond,
this.engineConfig.input
);
const metrics = getStrokeMetrics(stroke, this.config.input);
const now = context.currentTime;
this.selectedColorIndex = normalizeColorIndex(stroke.colorIndex);
const frame = this.gestureState.recordStroke({ metrics });
const strokeEnergy = frame.activity;
if (stroke.isErasing) {
this.energy.recordEraserStroke();
this.playEraser(stroke, metrics.speedAmount, metrics.pressure, now);
this.playEraser(strokeEnergy, now);
return;
}
const mirrorAmount = this.getMirrorAmount(stroke.mirrorSegmentCount ?? 1);
const frame = this.gestureState.recordStroke({ stroke, metrics, mirrorAmount });
const strokeEnergy = frame.activity;
this.energy.recordStroke(strokeEnergy, now);
this.pianoEngine.recordStroke({
vibe: stroke.vibe,
now,
activity: strokeEnergy,
selectedColorIndex: this.selectedColorIndex,
mirrorAmount,
panBias: frame.panBias,
registerBias: frame.registerBias,
brightnessBias: frame.brightnessBias,
contour: frame.contour,
pressureAmount: frame.pressure,
pressureDelta: frame.pressureDelta,
maniaAmount: frame.maniaAmount,
});
}
@ -307,10 +260,7 @@ export class GardenAudio {
}
const now = context.currentTime;
if (
now - this.lastVibeStingerAt <
this.engineConfig.vibeChangeStingerMinIntervalSeconds
) {
if (now - this.lastVibeStingerAt < this.config.vibeChangeStingerMinIntervalSeconds) {
return;
}
@ -318,46 +268,29 @@ export class GardenAudio {
this.pianoEngine.playVibeChangeStinger(vibe, now);
}
private playEraser(
stroke: GardenAudioStroke,
speedAmount: number,
pressure: number,
now: number
): void {
private playEraser(activity: number, now: number): void {
if (!this.graph.context) {
return;
}
const sizeAmount = clamp01(
(stroke.eraserSizePixels ?? this.engineConfig.eraser.defaultSizePixels) /
Math.max(
1,
stroke.canvasSize[0] * this.engineConfig.eraser.canvasWidthRatioForFullSize
)
);
const x = clamp01(stroke.to[0] / Math.max(1, stroke.canvasSize[0]));
const distanceActivity = clamp01(activity);
if (distanceActivity <= 0) {
return;
}
const filterHz =
this.config.eraser.filterMinHz +
(this.config.eraser.filterMaxHz - this.config.eraser.filterMinHz) *
clamp01(
speedAmount * this.engineConfig.eraser.filterSpeedWeight +
pressure * this.engineConfig.eraser.filterPressureWeight +
sizeAmount * this.engineConfig.eraser.filterSizeWeight
);
distanceActivity;
if (now - this.lastEraserAt >= this.config.eraser.minIntervalSeconds) {
this.lastEraserAt = now;
this.noise.play({
startTime: now,
durationSeconds: this.engineConfig.eraser.durationSeconds,
gain:
this.config.eraser.noiseGain *
(this.engineConfig.eraser.gainBase +
speedAmount * this.engineConfig.eraser.gainSpeedWeight +
pressure * this.engineConfig.eraser.gainPressureWeight +
sizeAmount * this.engineConfig.eraser.gainSizeWeight),
durationSeconds: this.config.eraser.durationSeconds,
gain: this.config.eraser.noiseGain * distanceActivity,
filterHz,
pan: clamp(x * 2 - 1, -1, 1),
pan: 0,
});
}
}
@ -370,7 +303,7 @@ export class GardenAudio {
const profile = getVibeProfile(this.config, snapshot.vibe);
const activity = snapshot.isErasing
? this.engineConfig.delay.erasingActivity
? this.config.delay.erasingActivity
: this.energy.getLevel();
this.graph.updateDelay(profile, activity);
}
@ -384,27 +317,4 @@ export class GardenAudio {
this.graph.applyDelayProfile(getVibeProfile(this.config, vibe));
this.pianoEngine.cue(this.graph.context.currentTime);
}
private getMirrorAmount(mirrorSegmentCount: number): number {
const maxMirrorSegmentCount = Math.max(1, this.maxMirrorSegmentCount);
const segmentCount = clamp(
Number.isFinite(mirrorSegmentCount) ? mirrorSegmentCount : 1,
1,
maxMirrorSegmentCount
);
if (maxMirrorSegmentCount <= 1) {
return 0;
}
return clamp01((segmentCount - 1) / (maxMirrorSegmentCount - 1));
}
private getPressure(pressure: number | undefined): number {
if (pressure !== undefined && Number.isFinite(pressure) && pressure > 0) {
return clamp01(pressure);
}
return 0;
}
}

View file

@ -1,6 +1,5 @@
import { describe, expect, it } from 'vitest';
import { appConfig } from '../config';
import { VIBE_PRESETS } from '../vibes';
import { gardenAudioConfig } from './garden-audio-config';
import { PianoNote } from './garden-audio-types';
@ -8,13 +7,9 @@ import { GenerativePianoEngine } from './generative-piano';
const makeEngine = () => {
const notes: Array<PianoNote> = [];
const engine = new GenerativePianoEngine(
gardenAudioConfig,
appConfig.audioEngine,
(note) => {
notes.push(note);
}
);
const engine = new GenerativePianoEngine(gardenAudioConfig, (note) => {
notes.push(note);
});
return { engine, notes };
};

View file

@ -1,9 +1,10 @@
import type { GardenAudioEngineConfig } from '../config';
import { clamp, clamp01 } from '../utils/clamp';
import { VibePreset } from '../vibes';
import {
GardenAudioChord,
GardenAudioColorPool,
GardenAudioConfig,
GardenAudioRegister,
GardenAudioVibeProfile,
} from './garden-audio-config';
import {
@ -51,17 +52,6 @@ interface TouchDownRequest {
maniaAmount?: number;
}
interface Register {
midiMin: number;
midiMax: number;
preferredMidi: number;
pan: number;
}
interface ColorPool extends Register {
scaleDegrees: ReadonlyArray<number>;
}
interface PitchCandidate {
midi: number;
preference: number;
@ -89,81 +79,6 @@ interface BrushPhraseLayer {
maniaAmount: number;
}
const COLOR_POOLS: [ColorPool, ColorPool, ColorPool] = [
{
midiMin: 48,
midiMax: 67,
preferredMidi: 55,
pan: -0.18,
scaleDegrees: [0, 1, 2, 4],
},
{
midiMin: 55,
midiMax: 74,
preferredMidi: 63,
pan: 0,
scaleDegrees: [1, 2, 3, 5],
},
{
midiMin: 62,
midiMax: 81,
preferredMidi: 72,
pan: 0.18,
scaleDegrees: [2, 3, 4, 6],
},
];
const PAD_REGISTERS: [Register, Register, Register] = [
{
midiMin: 40,
midiMax: 55,
preferredMidi: 48,
pan: -0.12,
},
{
midiMin: 48,
midiMax: 64,
preferredMidi: 55,
pan: 0.08,
},
{
midiMin: 58,
midiMax: 76,
preferredMidi: 67,
pan: 0.2,
},
];
const CHORD_BARS = 4;
const SUPPORT_BAR_SPACING = 2;
const SUPPORT_BAR_OFFSET = 1;
const IDLE_TEXTURE_BAR_SPACING = 2;
const MEDIUM_TEXTURE_BAR_SPACING = 1;
const TEXTURE_BEAT = 2;
const HIGH_ACTIVITY_EXTRA_BEAT = 3;
const HIGH_ACTIVITY_EXTRA_THRESHOLD = 0.45;
const NOTE_SCORE_PREFERENCE_WEIGHT = 1.8;
const NOTE_SCORE_REGISTER_WEIGHT = 0.28;
const NOTE_SCORE_REPEAT_PENALTY = 3.2;
const GESTURE_ACCENT_SPACING_SECONDS = 0.26;
const GESTURE_ACCENT_MIN_INTERVAL_SECONDS = 2.5;
const STROKE_ACCENT_MIN_INTERVAL_SECONDS = 3.2;
const STROKE_ACCENT_THRESHOLD = 0.58;
const STINGER_SPACING_SECONDS = 0.08;
const STINGER_DURATION_SECONDS = 1.1;
const MAX_BRUSH_PHRASE_LAYERS = 5;
const BRUSH_LAYER_BASE_SECONDS = 5.5;
const BRUSH_LAYER_ENERGY_SECONDS = 2.5;
const BRUSH_LAYER_MIRROR_SECONDS = 3;
const BRUSH_LAYER_MIN_INTENSITY = 0.08;
const BRUSH_STREAM_IDLE_INTERVAL_BEATS = 2;
const BRUSH_STREAM_ACTIVE_INTERVAL_BEATS = 1;
const BRUSH_STREAM_INTENSE_INTERVAL_BEATS = 0.5;
const BRUSH_STREAM_MANIC_INTERVAL_BEATS = 0.25;
const BRUSH_MOTIF_MAX_STEPS = 8;
const BRUSH_MOTIF_CANON_DELAY_SECONDS = 0.055;
const PAD_DURATION_BAR_SCALE = 0.46;
export class GenerativePianoEngine {
private nextBeatAt: number | null = null;
private timelineStartedAt: number | null = null;
@ -183,23 +98,26 @@ export class GenerativePianoEngine {
public constructor(
private readonly config: GardenAudioConfig,
private readonly engineConfig: GardenAudioEngineConfig,
private readonly playNote: (note: PianoNote) => void
) {}
private get generation(): GardenAudioConfig['generativePiano'] {
return this.config.generativePiano;
}
public prime(now: number): void {
if (this.nextBeatAt === null) {
this.nextBeatAt = now + this.engineConfig.startDelaySeconds;
this.nextBeatAt = now + this.config.startDelaySeconds;
}
this.timelineStartedAt ??= now;
this.nextBrushStreamAt ??= now + this.engineConfig.startDelaySeconds;
this.nextBrushStreamAt ??= now + this.config.startDelaySeconds;
}
public cue(now: number): void {
this.nextBeatAt = now + this.engineConfig.startDelaySeconds;
this.nextBeatAt = now + this.config.startDelaySeconds;
this.timelineStartedAt = now;
this.beatIndex = 0;
this.nextBrushStreamAt = now + this.engineConfig.startDelaySeconds;
this.nextBrushStreamAt = now + this.config.startDelaySeconds;
this.brushStreamNoteIndex = 0;
this.lastBrushStreamMidi = null;
}
@ -288,7 +206,7 @@ export class GenerativePianoEngine {
if (
this.isWaitingForGestureAccent &&
now - this.lastGestureAccentAt >= GESTURE_ACCENT_MIN_INTERVAL_SECONDS
now - this.lastGestureAccentAt >= this.generation.gestureAccentMinIntervalSeconds
) {
this.recordTouchDown({
vibe,
@ -310,8 +228,8 @@ export class GenerativePianoEngine {
...normalizedMotif,
});
if (
strength >= STROKE_ACCENT_THRESHOLD &&
now - this.lastStrokeAccentAt >= STROKE_ACCENT_MIN_INTERVAL_SECONDS
strength >= this.generation.strokeAccentThreshold &&
now - this.lastStrokeAccentAt >= this.generation.strokeAccentMinIntervalSeconds
) {
this.lastStrokeAccentAt = now;
this.playGestureAccent(vibe, now, selectedColorIndex, strength, 1);
@ -361,7 +279,10 @@ export class GenerativePianoEngine {
const rootMidi = profile.rootMidi + chord.rootOffset;
const notes = [
{
midi: this.chooseMidi({ baseMidi: rootMidi, offsets: [0] }, PAD_REGISTERS[0]),
midi: this.chooseMidi(
{ baseMidi: rootMidi, offsets: [0] },
this.generation.padRegisters[0]
),
velocity: 0.1,
pan: -0.16,
delaySend: 0.012,
@ -369,7 +290,7 @@ export class GenerativePianoEngine {
{
midi: this.chooseMidi(
{ baseMidi: rootMidi, offsets: [intervals[1], intervals[2]] },
PAD_REGISTERS[1]
this.generation.padRegisters[1]
),
velocity: 0.085,
pan: 0,
@ -378,7 +299,7 @@ export class GenerativePianoEngine {
{
midi: this.chooseMidi(
{ baseMidi: rootMidi, offsets: [intervals[3], intervals[2]] },
PAD_REGISTERS[2]
this.generation.padRegisters[2]
),
velocity: 0.07,
pan: 0.16,
@ -389,9 +310,9 @@ export class GenerativePianoEngine {
notes.forEach((note, index) => {
this.playNote({
...note,
durationSeconds: STINGER_DURATION_SECONDS,
durationSeconds: this.generation.stingerDurationSeconds,
lowpassHz: this.getLowpassHz(profile, note.midi, 0.35),
startTime: now + index * STINGER_SPACING_SECONDS,
startTime: now + index * this.generation.stingerSpacingSeconds,
});
});
}
@ -429,7 +350,7 @@ export class GenerativePianoEngine {
const beatInBar = beatIndex % beatsPerBar;
const barIndex = Math.floor(beatIndex / beatsPerBar);
if (beatInBar === 0 && barIndex % CHORD_BARS === 0) {
if (beatInBar === 0 && barIndex % this.generation.chordBars === 0) {
this.playPadChord(profile, barIndex, startTime, expression);
}
@ -437,13 +358,16 @@ export class GenerativePianoEngine {
this.playSupportNote(profile, barIndex, startTime, expression, selectedColorIndex);
}
if (beatInBar === TEXTURE_BEAT && this.shouldPlayTexture(expression, barIndex)) {
if (
beatInBar === this.generation.textureBeat &&
this.shouldPlayTexture(expression, barIndex)
) {
this.playTextureNote(profile, barIndex, startTime, expression, selectedColorIndex);
}
if (
beatInBar === HIGH_ACTIVITY_EXTRA_BEAT &&
expression >= HIGH_ACTIVITY_EXTRA_THRESHOLD
beatInBar === this.generation.highActivityExtraBeat &&
expression >= this.generation.highActivityExtraThreshold
) {
this.playTextureNote(
profile,
@ -464,21 +388,24 @@ export class GenerativePianoEngine {
const chord = this.getChord(profile, barIndex);
const intervals = getChordIntervals(chord, true);
const rootMidi = profile.rootMidi + chord.rootOffset;
const durationSeconds = this.getBarDurationSeconds() * CHORD_BARS * PAD_DURATION_BAR_SCALE;
const durationSeconds =
this.getBarDurationSeconds() *
this.generation.chordBars *
this.generation.padDurationBarScale;
const notes = [
{
source: { baseMidi: rootMidi, offsets: [0] },
register: PAD_REGISTERS[0],
register: this.generation.padRegisters[0],
velocity: 0.052,
},
{
source: { baseMidi: rootMidi, offsets: [intervals[1]] },
register: PAD_REGISTERS[1],
register: this.generation.padRegisters[1],
velocity: 0.041,
},
{
source: { baseMidi: rootMidi, offsets: [intervals[3], intervals[2]] },
register: PAD_REGISTERS[2],
register: this.generation.padRegisters[2],
velocity: 0.033,
},
];
@ -504,7 +431,7 @@ export class GenerativePianoEngine {
expression: number,
selectedColorIndex: GardenAudioColorIndex
): void {
const pool = COLOR_POOLS[selectedColorIndex];
const pool = this.generation.colorPools[selectedColorIndex];
const chord = this.getChord(profile, barIndex);
const chordIntervals = getChordIntervals(chord, false);
const rootMidi = profile.rootMidi + chord.rootOffset;
@ -539,7 +466,7 @@ export class GenerativePianoEngine {
expression: number,
selectedColorIndex: GardenAudioColorIndex
): void {
const pool = COLOR_POOLS[selectedColorIndex];
const pool = this.generation.colorPools[selectedColorIndex];
const degrees = this.rotate(pool.scaleDegrees, barIndex + selectedColorIndex);
const midi = this.chooseMidi(
{
@ -573,7 +500,7 @@ export class GenerativePianoEngine {
noteCount: number
): void {
const profile = getVibeProfile(this.config, vibe);
const pool = COLOR_POOLS[selectedColorIndex];
const pool = this.generation.colorPools[selectedColorIndex];
const degrees = this.rotate(pool.scaleDegrees, Math.round(strength * 3));
for (let index = 0; index < noteCount; index += 1) {
@ -598,8 +525,8 @@ export class GenerativePianoEngine {
this.config.colorVoices[selectedColorIndex].velocityMultiplier,
startTime:
now +
this.engineConfig.startDelaySeconds +
index * GESTURE_ACCENT_SPACING_SECONDS,
this.config.startDelaySeconds +
index * this.generation.gestureAccentSpacingSeconds,
durationSeconds: 0.48 + strength * 0.22,
pan: this.getColorPan(selectedColorIndex),
delaySend: 0.012,
@ -626,7 +553,7 @@ export class GenerativePianoEngine {
brightnessBias: number;
}): void {
const profile = getVibeProfile(this.config, vibe);
const pool = COLOR_POOLS[selectedColorIndex];
const pool = this.generation.colorPools[selectedColorIndex];
const register = this.getBiasedRegister(pool, registerBias, 0);
const chord = this.getChord(profile, this.getGlobalBarIndex(now));
const chordIntervals = getChordIntervals(chord, false);
@ -688,9 +615,9 @@ export class GenerativePianoEngine {
maniaAmount: number;
}): void {
const lifetimeSeconds =
BRUSH_LAYER_BASE_SECONDS +
strength * BRUSH_LAYER_ENERGY_SECONDS +
mirrorAmount * BRUSH_LAYER_MIRROR_SECONDS;
this.generation.brushLayerBaseSeconds +
strength * this.generation.brushLayerEnergySeconds +
mirrorAmount * this.generation.brushLayerMirrorSeconds;
this.brushPhraseLayers.push({
vibe,
@ -713,8 +640,10 @@ export class GenerativePianoEngine {
maniaAmount,
});
if (this.brushPhraseLayers.length > MAX_BRUSH_PHRASE_LAYERS) {
this.brushPhraseLayers = this.brushPhraseLayers.slice(-MAX_BRUSH_PHRASE_LAYERS);
if (this.brushPhraseLayers.length > this.generation.maxBrushPhraseLayers) {
this.brushPhraseLayers = this.brushPhraseLayers.slice(
-this.generation.maxBrushPhraseLayers
);
}
}
@ -762,8 +691,8 @@ export class GenerativePianoEngine {
layer.motifOffsets.push(
this.getMotifOffset({ registerBias, contour, pressureDelta, strength })
);
if (layer.motifOffsets.length > BRUSH_MOTIF_MAX_STEPS) {
layer.motifOffsets = layer.motifOffsets.slice(-BRUSH_MOTIF_MAX_STEPS);
if (layer.motifOffsets.length > this.generation.brushMotifMaxSteps) {
layer.motifOffsets = layer.motifOffsets.slice(-this.generation.brushMotifMaxSteps);
}
}
@ -780,8 +709,8 @@ export class GenerativePianoEngine {
activity: number;
selectedColorIndex: GardenAudioColorIndex;
}): void {
const earliestStart = now + this.engineConfig.piano.scheduleAheadSeconds;
this.nextBrushStreamAt ??= now + this.engineConfig.startDelaySeconds;
const earliestStart = now + this.config.piano.scheduleAheadSeconds;
this.nextBrushStreamAt ??= now + this.config.startDelaySeconds;
this.brushPhraseLayers = this.brushPhraseLayers.filter(
(layer) => layer.expiresAt > earliestStart
@ -795,7 +724,7 @@ export class GenerativePianoEngine {
while (this.nextBrushStreamAt <= lookaheadEnd) {
const frame = this.getBrushStreamFrame(this.nextBrushStreamAt, activity);
if (frame.intensity >= BRUSH_LAYER_MIN_INTENSITY) {
if (frame.intensity >= this.generation.brushLayerMinIntensity) {
this.playBrushStreamNote({
vibe,
startTime: this.nextBrushStreamAt,
@ -823,7 +752,7 @@ export class GenerativePianoEngine {
layer: BrushPhraseLayer | null;
}): void {
const profile = getVibeProfile(this.config, vibe);
const pool = COLOR_POOLS[selectedColorIndex];
const pool = this.generation.colorPools[selectedColorIndex];
const maniaAmount = layer?.maniaAmount ?? clamp01((intensity - 0.82) / 0.18);
const register = this.getBiasedRegister(
pool,
@ -860,7 +789,10 @@ export class GenerativePianoEngine {
0.62
);
const delaySend = clamp(
0.012 + intensity * 0.011 + (layer?.mirrorAmount ?? 0) * 0.004 - maniaAmount * 0.006,
0.012 +
intensity * 0.011 +
(layer?.mirrorAmount ?? 0) * 0.004 -
maniaAmount * 0.006,
0.006,
0.032
);
@ -888,7 +820,10 @@ export class GenerativePianoEngine {
),
});
if (maniaAmount >= 0.62 && (this.brushStreamNoteIndex % 2 === 1 || intensity >= 0.9)) {
if (
maniaAmount >= 0.62 &&
(this.brushStreamNoteIndex % 2 === 1 || intensity >= 0.9)
) {
const echoMidi = midi + 12 <= 88 ? midi + 12 : midi - 12;
this.playNote({
midi: echoMidi,
@ -897,7 +832,7 @@ export class GenerativePianoEngine {
this.config.colorVoices[selectedColorIndex].velocityMultiplier,
startTime:
startTime +
BRUSH_MOTIF_CANON_DELAY_SECONDS +
this.generation.brushMotifCanonDelaySeconds +
(layer?.mirrorAmount ?? 0) * 0.04,
durationSeconds: Math.max(0.11, durationSeconds * 0.68),
pan: clamp(-pan * 0.75, -1, 1),
@ -948,12 +883,12 @@ export class GenerativePianoEngine {
private getBrushStreamIntervalSeconds(intensity: number): number {
const intervalBeats =
intensity >= 0.85
? BRUSH_STREAM_MANIC_INTERVAL_BEATS
? this.generation.brushStreamManicIntervalBeats
: intensity >= 0.62
? BRUSH_STREAM_INTENSE_INTERVAL_BEATS
? this.generation.brushStreamIntenseIntervalBeats
: intensity >= 0.34
? BRUSH_STREAM_ACTIVE_INTERVAL_BEATS
: BRUSH_STREAM_IDLE_INTERVAL_BEATS;
? this.generation.brushStreamActiveIntervalBeats
: this.generation.brushStreamIdleIntervalBeats;
return this.getBeatDurationSeconds() * intervalBeats;
}
@ -1037,7 +972,7 @@ export class GenerativePianoEngine {
selectedColorIndex,
}: {
layer: BrushPhraseLayer | null;
pool: ColorPool;
pool: GardenAudioColorPool;
selectedColorIndex: GardenAudioColorIndex;
}): Array<number> {
const colorOffset = this.config.colorVoices[selectedColorIndex].scaleDegreeOffset;
@ -1063,10 +998,10 @@ export class GenerativePianoEngine {
}
private getBiasedRegister(
register: Register,
register: GardenAudioRegister,
registerBias: number,
maniaAmount: number
): Register {
): GardenAudioRegister {
const shift = Math.round(registerBias * 7 + maniaAmount * 4);
const midiMin = clamp(register.midiMin + shift, 36, 86);
const midiMax = clamp(register.midiMax + shift, midiMin + 4, 91);
@ -1101,7 +1036,7 @@ export class GenerativePianoEngine {
private chooseMidi(
pitchSource: PitchSource,
register: Register,
register: GardenAudioRegister,
previousMidi: number | null = null,
avoidRepeat = false
): number {
@ -1122,7 +1057,7 @@ export class GenerativePianoEngine {
private getCandidates(
pitchSource: PitchSource,
register: Register
register: GardenAudioRegister
): Array<PitchCandidate> {
const candidates: Array<PitchCandidate> = [];
@ -1140,15 +1075,18 @@ export class GenerativePianoEngine {
private scoreCandidate(
candidate: PitchCandidate,
register: Register,
register: GardenAudioRegister,
previousMidi: number,
avoidRepeat: boolean
): number {
return (
Math.abs(candidate.midi - previousMidi) +
Math.abs(candidate.midi - register.preferredMidi) * NOTE_SCORE_REGISTER_WEIGHT +
candidate.preference * NOTE_SCORE_PREFERENCE_WEIGHT +
(avoidRepeat && candidate.midi === previousMidi ? NOTE_SCORE_REPEAT_PENALTY : 0)
Math.abs(candidate.midi - register.preferredMidi) *
this.generation.noteScoreRegisterWeight +
candidate.preference * this.generation.noteScorePreferenceWeight +
(avoidRepeat && candidate.midi === previousMidi
? this.generation.noteScoreRepeatPenalty
: 0)
);
}
@ -1157,15 +1095,17 @@ export class GenerativePianoEngine {
return true;
}
return barIndex % SUPPORT_BAR_SPACING === SUPPORT_BAR_OFFSET;
return (
barIndex % this.generation.supportBarSpacing === this.generation.supportBarOffset
);
}
private shouldPlayTexture(expression: number, barIndex: number): boolean {
const spacing =
expression < 0.35
? IDLE_TEXTURE_BAR_SPACING
? this.generation.idleTextureBarSpacing
: expression < 0.7
? MEDIUM_TEXTURE_BAR_SPACING
? this.generation.mediumTextureBarSpacing
: 1;
return barIndex % spacing === (spacing === 1 ? 0 : 1);
@ -1188,7 +1128,7 @@ export class GenerativePianoEngine {
private getChord(profile: GardenAudioVibeProfile, barIndex: number): GardenAudioChord {
const progressionIndex =
Math.floor(barIndex / CHORD_BARS) % profile.progression.length;
Math.floor(barIndex / this.generation.chordBars) % profile.progression.length;
return profile.progression[progressionIndex];
}
@ -1199,7 +1139,7 @@ export class GenerativePianoEngine {
}
private getColorPan(selectedColorIndex: GardenAudioColorIndex): number {
const pool = COLOR_POOLS[selectedColorIndex];
const pool = this.generation.colorPools[selectedColorIndex];
const colorVoice = this.config.colorVoices[selectedColorIndex];
return clamp(pool.pan + colorVoice.panOffset * 0.35, -1, 1);
}
@ -1213,8 +1153,8 @@ export class GenerativePianoEngine {
return clamp(
this.config.piano.lowpassHz * profile.brightness * (0.58 + expression * 0.32) +
midiLift,
this.engineConfig.piano.lowpassMinHz,
this.engineConfig.piano.lowpassMaxHz
this.config.piano.lowpassMinHz,
this.config.piano.lowpassMaxHz
);
}
@ -1223,7 +1163,7 @@ export class GenerativePianoEngine {
return;
}
const earliestStart = now + this.engineConfig.piano.scheduleAheadSeconds;
const earliestStart = now + this.config.piano.scheduleAheadSeconds;
if (this.nextBeatAt >= earliestStart) {
return;
}

View file

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

View file

@ -1,6 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { appConfig } from '../config';
import { gardenAudioConfig } from './garden-audio-config';
import type { GardenAudioGraph } from './garden-audio-graph';
import { PianoSampler } from './piano-sampler';
@ -70,7 +69,7 @@ const makeSampler = (context: AudioContext): PianoSampler => {
eventBus,
} as unknown as GardenAudioGraph;
return new PianoSampler(gardenAudioConfig, appConfig.audioEngine, graph);
return new PianoSampler(gardenAudioConfig, graph);
};
describe('PianoSampler', () => {

View file

@ -1,4 +1,3 @@
import type { GardenAudioEngineConfig } from '../config';
import { clamp, clamp01 } from '../utils/clamp';
import { GardenAudioConfig } from './garden-audio-config';
import { GardenAudioGraph } from './garden-audio-graph';
@ -12,7 +11,6 @@ export class PianoSampler {
public constructor(
private readonly config: GardenAudioConfig,
private readonly engineConfig: GardenAudioEngineConfig,
private readonly graph: GardenAudioGraph
) {}
@ -54,21 +52,20 @@ export class PianoSampler {
}
const scheduledStart = Math.max(
context.currentTime + this.engineConfig.piano.scheduleAheadSeconds,
context.currentTime + this.config.piano.scheduleAheadSeconds,
startTime
);
const noteVelocity = clamp01(velocity);
const noteGainValue = Math.max(
this.engineConfig.piano.minGain,
this.config.piano.minGain,
this.config.piano.gain * noteVelocity
);
const sustainSeconds =
this.config.piano.sustainSeconds *
(this.engineConfig.piano.sustainBase +
noteVelocity * this.engineConfig.piano.sustainVelocityRange);
(this.config.piano.sustainBase +
noteVelocity * this.config.piano.sustainVelocityRange);
const sustainAt =
scheduledStart +
Math.max(this.engineConfig.piano.minDurationSeconds, durationSeconds);
scheduledStart + Math.max(this.config.piano.minDurationSeconds, durationSeconds);
const releaseAt = sustainAt + sustainSeconds;
const releaseSeconds = this.config.piano.releaseSeconds;
const stopAt = releaseAt + releaseSeconds;
@ -88,36 +85,29 @@ export class PianoSampler {
source.buffer = sample.buffer;
source.playbackRate.setValueAtTime(
Math.pow(2, (midi - sample.midi) / this.engineConfig.piano.pitchSemitonesPerOctave),
Math.pow(2, (midi - sample.midi) / this.config.piano.pitchSemitonesPerOctave),
scheduledStart
);
filter.type = 'lowpass';
filter.frequency.setValueAtTime(
clamp(
lowpassHz,
this.engineConfig.piano.lowpassMinHz,
this.engineConfig.piano.lowpassMaxHz
),
clamp(lowpassHz, this.config.piano.lowpassMinHz, this.config.piano.lowpassMaxHz),
scheduledStart
);
filter.Q.value = this.engineConfig.piano.filterQ;
gain.gain.setValueAtTime(this.engineConfig.piano.minGain, scheduledStart);
filter.Q.value = this.config.piano.filterQ;
gain.gain.setValueAtTime(this.config.piano.minGain, scheduledStart);
gain.gain.exponentialRampToValueAtTime(
noteGainValue,
scheduledStart + this.engineConfig.piano.gainAttackSeconds
scheduledStart + this.config.piano.gainAttackSeconds
);
gain.gain.setTargetAtTime(
Math.max(
this.engineConfig.piano.minGain,
noteGainValue * this.config.piano.sustainLevel
),
Math.max(this.config.piano.minGain, noteGainValue * this.config.piano.sustainLevel),
sustainAt,
Math.max(
this.engineConfig.piano.minFadeSeconds,
sustainSeconds * this.engineConfig.piano.sustainBase
this.config.piano.minFadeSeconds,
sustainSeconds * this.config.piano.sustainBase
)
);
gain.gain.setTargetAtTime(this.engineConfig.piano.minGain, releaseAt, releaseSeconds);
gain.gain.setTargetAtTime(this.config.piano.minGain, releaseAt, releaseSeconds);
panner.pan.setValueAtTime(clamp(pan, -1, 1), scheduledStart);
source.connect(filter);
@ -133,7 +123,7 @@ export class PianoSampler {
}
source.start(scheduledStart);
source.stop(stopAt + this.engineConfig.piano.tailStopExtraSeconds);
source.stop(stopAt + this.config.piano.tailStopExtraSeconds);
this.activeVoices.push({ gain, source, startAt: scheduledStart, stopAt });
source.addEventListener(
@ -186,13 +176,13 @@ export class PianoSampler {
}
private stopVoice(voice: ActivePianoVoice, now: number): void {
const stopAt = now + this.engineConfig.piano.voiceStealStopSeconds;
const stopAt = now + this.config.piano.voiceStealStopSeconds;
voice.gain.gain.cancelScheduledValues(now);
voice.gain.gain.setTargetAtTime(
this.engineConfig.piano.minGain,
this.config.piano.minGain,
now,
this.engineConfig.piano.voiceStealFadeSeconds
this.config.piano.voiceStealFadeSeconds
);
voice.stopAt = stopAt;
try {

View file

@ -1,10 +1,10 @@
import { ADAPTIVE_AGENT_CAP_MAX } from './config/agent-budget';
import { runtimeSettings } from './config/runtime-settings';
import type { GardenAppConfig } from './config/types';
import { audioVibes, defaultVibeId, vibePresets } from './config/vibe-presets';
export type {
GardenAppConfig,
GardenAudioEngineConfig,
GardenRuntimeSettings,
NumberControlConfig,
VibePreset,
@ -20,6 +20,13 @@ export const appConfig = {
timeSeconds: 0.46,
feedback: 0.12,
wetGain: 0.044,
erasingActivity: 0.12,
activityFeedbackWeight: 0.08,
feedbackMax: 0.32,
feedbackMin: 0.04,
outputActivityWeight: 0.5,
outputBase: 0.65,
timeRampSeconds: 0.12,
},
piano: {
maxVoices: 24,
@ -28,13 +35,26 @@ export const appConfig = {
sustainLevel: 0.32,
releaseSeconds: 0.24,
lowpassHz: 7600,
filterQ: 0.7,
gainAttackSeconds: 0.006,
lowpassMaxHz: 12000,
lowpassMinHz: 1400,
minDurationSeconds: 0.08,
minFadeSeconds: 0.08,
minGain: 0.0001,
pitchSemitonesPerOctave: 12,
scheduleAheadSeconds: 0.002,
sustainBase: 0.45,
sustainVelocityRange: 0.55,
tailStopExtraSeconds: 0.05,
voiceStealFadeSeconds: 0.025,
voiceStealStopSeconds: 0.05,
},
rhythm: {
bpm: 74,
stepsPerBeat: 4,
stepsPerBar: 16,
lookaheadSeconds: 0.3,
speedForFullEnergyPixelsPerSecond: 1800,
sparseActivity: 0.055,
},
eraser: {
@ -42,6 +62,117 @@ export const appConfig = {
noiseGain: 0.028,
filterMinHz: 650,
filterMaxHz: 3600,
durationSeconds: 0.08,
},
energy: {
attackSeconds: 0.08,
decaySeconds: 0.9,
immediateActivityScale: 0.85,
releaseSeconds: 1.15,
strokeDecaySeconds: 0.32,
},
graph: {
closeGain: 0.0001,
closeRampSeconds: 0.015,
delayMaxSeconds: 2,
eventBusGain: 1,
noiseMax: 1,
noiseMin: -1,
unlockTickFrequencyHz: 440,
unlockTickSeconds: 0.035,
},
input: {
activeActivityThreshold: 0.38,
distanceWindowForFullActivityPixels: 140,
distanceWindowSeconds: 0.5,
fallbackFrameSeconds: 1 / 60,
manicActivityThreshold: 0.82,
manicModeThreshold: 0.72,
},
muteGain: 0.0001,
muteRampSeconds: 0.02,
noiseBurst: {
attackSeconds: 0.004,
filterQ: 1.4,
offsetRandomSeconds: 0.4,
scheduleAheadSeconds: 0.002,
silentGain: 0.0001,
},
startDelaySeconds: 0.02,
vibeChangeStingerMinIntervalSeconds: 0.45,
generativePiano: {
colorPools: [
{
midiMin: 48,
midiMax: 67,
preferredMidi: 55,
pan: -0.18,
scaleDegrees: [0, 1, 2, 4],
},
{
midiMin: 55,
midiMax: 74,
preferredMidi: 63,
pan: 0,
scaleDegrees: [1, 2, 3, 5],
},
{
midiMin: 62,
midiMax: 81,
preferredMidi: 72,
pan: 0.18,
scaleDegrees: [2, 3, 4, 6],
},
],
padRegisters: [
{
midiMin: 40,
midiMax: 55,
preferredMidi: 48,
pan: -0.12,
},
{
midiMin: 48,
midiMax: 64,
preferredMidi: 55,
pan: 0.08,
},
{
midiMin: 58,
midiMax: 76,
preferredMidi: 67,
pan: 0.2,
},
],
chordBars: 4,
supportBarSpacing: 2,
supportBarOffset: 1,
idleTextureBarSpacing: 2,
mediumTextureBarSpacing: 1,
textureBeat: 2,
highActivityExtraBeat: 3,
highActivityExtraThreshold: 0.45,
noteScorePreferenceWeight: 1.8,
noteScoreRegisterWeight: 0.28,
noteScoreRepeatPenalty: 3.2,
gestureAccentSpacingSeconds: 0.26,
gestureAccentMinIntervalSeconds: 2.5,
strokeAccentMinIntervalSeconds: 3.2,
strokeAccentThreshold: 0.58,
stingerSpacingSeconds: 0.08,
stingerDurationSeconds: 1.1,
maxBrushPhraseLayers: 5,
brushLayerBaseSeconds: 5.5,
brushLayerEnergySeconds: 2.5,
brushLayerMirrorSeconds: 3,
brushLayerMinIntensity: 0.08,
brushStreamIdleIntervalBeats: 2,
brushStreamActiveIntervalBeats: 1,
brushStreamIntenseIntervalBeats: 0.5,
brushStreamManicIntervalBeats: 0.25,
brushMotifMaxSteps: 8,
brushMotifCanonDelaySeconds: 0.055,
padDurationBarScale: 0.46,
},
colorVoices: [
{
@ -62,80 +193,6 @@ export const appConfig = {
],
vibes: audioVibes,
},
audioEngine: {
energy: {
attackSeconds: 0.08,
decaySeconds: 0.9,
releaseSeconds: 1.15,
strokeDecaySeconds: 0.32,
},
eraser: {
canvasWidthRatioForFullSize: 0.18,
defaultSizePixels: 96,
durationSeconds: 0.08,
filterPressureWeight: 0.26,
filterSizeWeight: 0.16,
filterSpeedWeight: 0.58,
gainBase: 0.45,
gainPressureWeight: 0.24,
gainSizeWeight: 0.18,
gainSpeedWeight: 0.38,
},
delay: {
erasingActivity: 0.12,
},
graph: {
closeGain: 0.0001,
closeRampSeconds: 0.015,
delayActivityFeedbackWeight: 0.08,
delayFeedbackMax: 0.32,
delayFeedbackMin: 0.04,
delayOutputActivityWeight: 0.5,
delayOutputBase: 0.65,
delayTimeRampSeconds: 0.12,
eventBusGain: 1,
noiseMax: 1,
noiseMin: -1,
unlockBufferLength: 1,
unlockSampleRate: 22050,
},
input: {
distanceEnergyBase: 0.34,
distanceEnergyScale: 0.66,
distanceForFullEnergyPixels: 140,
fallbackFrameSeconds: 1 / 60,
strokeEnergyBase: 0.18,
strokeEnergyPressureWeight: 0.22,
strokeEnergySpeedWeight: 0.62,
},
muteGain: 0.0001,
muteRampSeconds: 0.02,
noiseBurst: {
attackSeconds: 0.004,
filterQ: 1.4,
offsetRandomSeconds: 0.4,
scheduleAheadSeconds: 0.002,
silentGain: 0.0001,
},
piano: {
filterQ: 0.7,
gainAttackSeconds: 0.006,
lowpassMaxHz: 12000,
lowpassMinHz: 1400,
minDurationSeconds: 0.08,
minFadeSeconds: 0.08,
minGain: 0.0001,
pitchSemitonesPerOctave: 12,
scheduleAheadSeconds: 0.002,
sustainBase: 0.45,
sustainVelocityRange: 0.55,
tailStopExtraSeconds: 0.05,
voiceStealFadeSeconds: 0.025,
voiceStealStopSeconds: 0.05,
},
startDelaySeconds: 0.02,
vibeChangeStingerMinIntervalSeconds: 0.45,
},
deltaTime: {
fpsExponentialDecayStrength: 0.01,
maxDeltaTimeSeconds: 1 / 30,
@ -173,13 +230,14 @@ export const appConfig = {
simulation: {
budget: {
adaptiveCapDecreaseAgentsPerSecond: 50_000,
adaptiveCapMax: ADAPTIVE_AGENT_CAP_MAX,
adaptiveCapMin: 500_000,
fpsHeadroom: 0.95,
fpsSmoothingNew: 0.06,
fpsSmoothingRetain: 0.94,
},
brushEffectFramesPerSecond: 60,
globalAgentCap: 10_000_000,
globalAgentCap: ADAPTIVE_AGENT_CAP_MAX,
initialAgentCount: 180_000,
intro: {
angleJitterRadians: Math.PI * 0.08,
@ -227,10 +285,6 @@ export const appConfig = {
audioMutedKey: 'fleeting-garden:audio-muted',
vibeKey: 'fleeting-garden:vibe',
},
telemetry: {
enabled: false,
intervalMs: 1000,
},
toolbar: {
eraser: {
controlScaleMax: 1.34,

View file

@ -1,3 +1,4 @@
import { ADAPTIVE_AGENT_CAP_MAX } from './agent-budget';
import {
colorInteractionControl,
defaultColorInteractionSettings,
@ -34,8 +35,6 @@ export const runtimeSettings: GardenAppConfig['runtimeSettings'] = {
brushSizeVariation: 0.5,
startColorHue: 200,
simulatedDelayMs: 0,
},
controls: {
@ -43,7 +42,7 @@ export const runtimeSettings: GardenAppConfig['runtimeSettings'] = {
folder: 'Runtime',
integer: true,
min: 500_000,
max: 10_000_000,
max: ADAPTIVE_AGENT_CAP_MAX,
step: 50_000,
},
agentCount: {
@ -176,12 +175,6 @@ export const runtimeSettings: GardenAppConfig['runtimeSettings'] = {
max: 1,
step: 0.001,
},
startColorHue: {
folder: 'Render',
min: 0,
max: 360,
step: 1,
},
turnSpeed: {
folder: 'Agent',
min: 1,

View file

@ -78,80 +78,6 @@ type RuntimeSettingControlConfig = {
export interface GardenAppConfig {
audio: GardenAudioConfig;
audioEngine: {
energy: {
attackSeconds: number;
decaySeconds: number;
releaseSeconds: number;
strokeDecaySeconds: number;
};
eraser: {
canvasWidthRatioForFullSize: number;
defaultSizePixels: number;
durationSeconds: number;
filterPressureWeight: number;
filterSizeWeight: number;
filterSpeedWeight: number;
gainBase: number;
gainPressureWeight: number;
gainSizeWeight: number;
gainSpeedWeight: number;
};
delay: {
erasingActivity: number;
};
graph: {
closeGain: number;
closeRampSeconds: number;
delayActivityFeedbackWeight: number;
delayFeedbackMax: number;
delayFeedbackMin: number;
delayOutputActivityWeight: number;
delayOutputBase: number;
delayTimeRampSeconds: number;
eventBusGain: number;
noiseMax: number;
noiseMin: number;
unlockBufferLength: number;
unlockSampleRate: number;
};
input: {
distanceEnergyBase: number;
distanceEnergyScale: number;
distanceForFullEnergyPixels: number;
fallbackFrameSeconds: number;
strokeEnergyBase: number;
strokeEnergyPressureWeight: number;
strokeEnergySpeedWeight: number;
};
muteGain: number;
muteRampSeconds: number;
noiseBurst: {
attackSeconds: number;
filterQ: number;
offsetRandomSeconds: number;
scheduleAheadSeconds: number;
silentGain: number;
};
piano: {
filterQ: number;
gainAttackSeconds: number;
lowpassMaxHz: number;
lowpassMinHz: number;
minDurationSeconds: number;
minFadeSeconds: number;
minGain: number;
pitchSemitonesPerOctave: number;
scheduleAheadSeconds: number;
sustainBase: number;
sustainVelocityRange: number;
tailStopExtraSeconds: number;
voiceStealFadeSeconds: number;
voiceStealStopSeconds: number;
};
startDelaySeconds: number;
vibeChangeStingerMinIntervalSeconds: number;
};
deltaTime: {
fpsExponentialDecayStrength: number;
maxDeltaTimeSeconds: number;
@ -192,6 +118,7 @@ export interface GardenAppConfig {
simulation: {
budget: {
adaptiveCapDecreaseAgentsPerSecond: number;
adaptiveCapMax: number;
adaptiveCapMin: number;
fpsHeadroom: number;
fpsSmoothingNew: number;
@ -246,10 +173,6 @@ export interface GardenAppConfig {
audioMutedKey: string;
vibeKey: string;
};
telemetry: {
enabled: boolean;
intervalMs: number;
};
toolbar: {
eraser: {
controlScaleMax: number;
@ -277,5 +200,3 @@ export interface GardenAppConfig {
presets: Array<VibePreset>;
};
}
export type GardenAudioEngineConfig = GardenAppConfig['audioEngine'];

View file

@ -1,6 +1,11 @@
import { vec2 } from 'gl-matrix';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { appConfig } from '../config';
import { AgentGenerationPipeline } from '../pipelines/agents/agent-generation/agent-generation-pipeline';
import { settings } from '../settings';
import { AgentPopulation } from './agent-population';
vi.hoisted(() => {
Object.defineProperty(globalThis, 'localStorage', {
configurable: true,
@ -11,11 +16,6 @@ vi.hoisted(() => {
});
});
import { appConfig } from '../config';
import { AgentGenerationPipeline } from '../pipelines/agents/agent-generation/agent-generation-pipeline';
import { settings } from '../settings';
import { AgentPopulation } from './agent-population';
const originalAgentBudgetMax = settings.agentBudgetMax;
const originalBrushSize = settings.brushSize;
const originalSelectedColorIndex = settings.selectedColorIndex;
@ -63,10 +63,35 @@ describe('AgentPopulation adaptive budget', () => {
expect(settings.agentBudgetMax).toBeGreaterThan(1_000_000);
expect(population.activeAgentCount).toBeGreaterThan(1_000_000);
expect(settings.agentBudgetMax).toBeLessThanOrEqual(
appConfig.simulation.globalAgentCap
appConfig.simulation.budget.adaptiveCapMax
);
});
it('does not grow the cap above the adaptive max agent count', () => {
const population = createPopulation();
const maxAgentCount = appConfig.simulation.budget.adaptiveCapMax;
settings.agentBudgetMax = maxAgentCount - 1;
setPopulationActiveCount(population, maxAgentCount - 1);
population.growBudget(1 / 60, 60, 60);
population.spawnStrokeAgents(vec2.fromValues(0, 0), vec2.fromValues(16, 0));
expect(settings.agentBudgetMax).toBe(maxAgentCount);
expect(population.activeAgentCount).toBe(maxAgentCount);
});
it('clamps a manually raised cap before adding agents', () => {
const population = createPopulation();
const maxAgentCount = appConfig.simulation.budget.adaptiveCapMax;
settings.agentBudgetMax = maxAgentCount + 1_000;
setPopulationActiveCount(population, maxAgentCount);
population.spawnStrokeAgents(vec2.fromValues(0, 0), vec2.fromValues(16, 0));
expect(settings.agentBudgetMax).toBe(maxAgentCount);
expect(population.activeAgentCount).toBe(maxAgentCount);
});
it('decreases the cap and active count slowly when FPS falls below the threshold', () => {
const population = createPopulation();
setPopulationActiveCount(population, 1_000_000);
@ -74,8 +99,6 @@ describe('AgentPopulation adaptive budget', () => {
population.growBudget(10, 50, 60);
expect(settings.agentBudgetMax).toBe(appConfig.simulation.budget.adaptiveCapMin);
expect(population.activeAgentCount).toBe(
appConfig.simulation.budget.adaptiveCapMin
);
expect(population.activeAgentCount).toBe(appConfig.simulation.budget.adaptiveCapMin);
});
});

View file

@ -12,6 +12,7 @@ const INITIAL_AGENT_COUNT = appConfig.simulation.initialAgentCount;
const MIN_STROKE_AGENT_COUNT = appConfig.simulation.stroke.minAgentCount;
const MAX_STROKE_AGENT_COUNT = appConfig.simulation.stroke.maxAgentCount;
const STROKE_AGENT_DENSITY_MULTIPLIER = appConfig.simulation.stroke.densityMultiplier;
const ADAPTIVE_CAP_MAX = appConfig.simulation.budget.adaptiveCapMax;
const ADAPTIVE_CAP_MIN = appConfig.simulation.budget.adaptiveCapMin;
const ADAPTIVE_CAP_DECREASE_AGENTS_PER_SECOND =
appConfig.simulation.budget.adaptiveCapDecreaseAgentsPerSecond;
@ -129,6 +130,7 @@ export class AgentPopulation {
}
const count = data.length / AGENT_FLOAT_COUNT;
settings.agentBudgetMax = this.clampAdaptiveCap(settings.agentBudgetMax);
this.expandAdaptiveCapForPendingAgents(count);
const available = Math.max(0, settings.agentBudgetMax - this.activeCount);
@ -214,8 +216,9 @@ export class AgentPopulation {
private clampAdaptiveCap(value: number): number {
const pipelineCap = Math.max(0, Math.floor(this.pipeline.maxAgentCount));
const minCap = Math.min(ADAPTIVE_CAP_MIN, pipelineCap);
const maxCap = Math.min(ADAPTIVE_CAP_MAX, pipelineCap);
const minCap = Math.min(ADAPTIVE_CAP_MIN, maxCap);
const finiteValue = Number.isFinite(value) ? value : minCap;
return Math.min(pipelineCap, Math.max(minCap, Math.round(finiteValue)));
return Math.min(maxCap, Math.max(minCap, Math.round(finiteValue)));
}
}

View file

@ -1,14 +1,5 @@
import { appConfig } from '../config';
interface TelemetrySnapshot {
frameCpuStartedAt: number;
encodeCpuMs: number;
activeAgentCount: number;
agentBudgetMax: number;
canvas: HTMLCanvasElement;
devicePixelRatio: number;
}
const COMMON_DISPLAY_REFRESH_RATES = [
50, 60, 72, 75, 90, 100, 120, 144, 165, 180, 240,
] as const;
@ -22,20 +13,11 @@ export class FramePerformance {
public displayRefreshFps = 60;
public readonly refreshTargetFps = 60;
private lastTelemetryAt = 0;
private previousFrameTime: DOMHighResTimeStamp | null = null;
private hasConfirmedDisplayRefreshFps = false;
private pendingDisplayRefreshFps = 0;
private pendingDisplayRefreshFrameCount = 0;
public markCpuStart(): number {
return appConfig.telemetry.enabled ? performance.now() : 0;
}
public measureSince(startedAt: number): number {
return appConfig.telemetry.enabled ? performance.now() - startedAt : 0;
}
public update(time: DOMHighResTimeStamp): void {
const previous = this.previousFrameTime;
this.previousFrameTime = time;
@ -56,39 +38,6 @@ export class FramePerformance {
fps * appConfig.simulation.budget.fpsSmoothingNew;
}
public renderTelemetry({
frameCpuStartedAt,
encodeCpuMs,
activeAgentCount,
agentBudgetMax,
canvas,
devicePixelRatio,
}: TelemetrySnapshot): void {
if (!appConfig.telemetry.enabled) {
return;
}
const now = performance.now();
if (now - this.lastTelemetryAt < appConfig.telemetry.intervalMs) {
return;
}
this.lastTelemetryAt = now;
console.debug('Fleeting Garden telemetry', {
fps: Math.round(this.latestFps),
smoothedFps: Math.round(this.smoothedFps),
refreshTargetFps: Math.round(this.refreshTargetFps),
displayRefreshFps: Math.round(this.displayRefreshFps),
activeAgentCount,
agentBudgetMax,
canvasWidth: canvas.width,
canvasHeight: canvas.height,
dpr: devicePixelRatio,
frameCpuMs: now - frameCpuStartedAt,
encodeCpuMs,
});
}
private updateDisplayRefreshEstimate(fps: number): void {
const displayRefreshFps = this.snapDisplayRefreshRate(fps);
if (displayRefreshFps === null) {

View file

@ -4,6 +4,4 @@ export interface GameLoopSettings {
simulatedDelayMs: number;
selectedColorIndex: number;
spawnPerPixel: number;
startColorHue: number;
}

View file

@ -23,11 +23,7 @@ export default class GameLoop {
private static readonly DEV_STATS_INTERVAL_MS = 250;
private readonly resources: GameLoopResources;
private readonly audio = new GardenAudio(
gardenAudioConfig,
appConfig.audioEngine,
appConfig.simulation.maxMirrorSegmentCount
);
private readonly audio = new GardenAudio(gardenAudioConfig);
private readonly renderInputs = new RenderInputCache();
private readonly introPrompt: IntroPrompt;
private readonly eraserPreview: EraserPreview;
@ -68,7 +64,6 @@ export default class GameLoop {
eraserAgentPipeline: this.resources.eraserAgentPipeline,
eraserTexturePipeline: this.resources.eraserTexturePipeline,
eraserPreview: this.eraserPreview,
getCanvasSize: () => this.canvasSize,
getDevicePixelRatio: () => this.devicePixelRatio,
getMirrorSegmentCount: () => this.mirrorSegmentCount,
onStartDrawing: () => this.introPrompt.markStartedDrawing(),
@ -167,7 +162,6 @@ export default class GameLoop {
return;
}
const frameCpuStartedAt = this.framePerformance.markCpuStart();
const deltaTime = this.deltaTimeCalculator.calculateDeltaTimeInSeconds(time);
this.framePerformance.update(time);
this.agentPopulation.growBudget(
@ -194,7 +188,6 @@ export default class GameLoop {
vibe: activeVibe,
selectedColorIndex: settings.selectedColorIndex,
isErasing,
mirrorSegmentCount: this.mirrorSegmentCount,
});
this.resources.setFrameParameters({
@ -212,24 +205,14 @@ export default class GameLoop {
eraserPixelSize,
});
const encodeCpuStartedAt = this.framePerformance.markCpuStart();
this.resources.executeFrame(
isErasing,
this.toolbarContrastMonitor.takeReadbackRequest(time)
);
const encodeCpuMs = this.framePerformance.measureSince(encodeCpuStartedAt);
this.pointerInput.clearSwipesIfIdle();
await this.agentPopulation.compactAfterErase(this.pointerInput.isSwipeActive);
this.framePerformance.renderTelemetry({
frameCpuStartedAt,
encodeCpuMs,
activeAgentCount: this.agentPopulation.activeAgentCount,
agentBudgetMax: settings.agentBudgetMax,
canvas: this.canvas,
devicePixelRatio: this.devicePixelRatio,
});
this.updateDevStats(time);
if (settings.simulatedDelayMs > 0) {

View file

@ -118,7 +118,6 @@ const createPointerInput = async () => {
eraserAgentPipeline,
eraserPreview,
eraserTexturePipeline,
getCanvasSize: () => [canvas.width, canvas.height],
getDevicePixelRatio: () => 1,
getMirrorSegmentCount: () => 1,
onEraseGestureEnded,
@ -185,9 +184,7 @@ describe('GardenPointerInput drawing startup', () => {
expect(audio.beginGesture).toHaveBeenCalledTimes(1);
expect(audio.touchDown).toHaveBeenCalledWith(
expect.objectContaining({
canvasSize: [300, 200],
colorIndex: 0,
position: expect.any(Float32Array),
})
);
expect(audio.stroke).not.toHaveBeenCalled();

View file

@ -16,7 +16,6 @@ interface GardenPointerInputOptions {
eraserAgentPipeline: EraserAgentPipeline;
eraserTexturePipeline: EraserTexturePipeline;
eraserPreview: EraserPreview;
getCanvasSize: () => vec2;
getDevicePixelRatio: () => number;
getMirrorSegmentCount: () => number;
onStartDrawing: () => void;
@ -32,7 +31,6 @@ export class GardenPointerInput {
private activePointerId: number | null = null;
private lastPointerPosition: vec2 | null = null;
private lastPointerEventTimeMs: number | null = null;
private lastPointerPressure = 0.5;
private smoothedStrokePoints: Array<vec2> = [];
private lastSmoothedBrushPosition: vec2 | null = null;
private isErasing = false;
@ -109,18 +107,12 @@ export class GardenPointerInput {
return;
}
const position = this.getCanvasPointerPosition(event);
if (event.pointerType !== 'touch') {
this.options.audio.start(activeVibe, { userGesture: true });
}
this.options.audio.beginGesture();
this.options.audio.touchDown({
vibe: activeVibe,
colorIndex: settings.selectedColorIndex,
position,
canvasSize: this.options.getCanvasSize(),
mirrorSegmentCount: this.options.getMirrorSegmentCount(),
pressure: this.getPointerPressure(event),
});
this.options.onStartDrawing();
this.activePointerId = event.pointerId;
@ -131,7 +123,6 @@ export class GardenPointerInput {
this.lastPointerPosition = null;
this.lastPointerEventTimeMs = null;
this.clearSmoothedStroke();
this.lastPointerPressure = this.getPointerPressure(event);
this.addSwipeAt(event, { emitAudio: false });
};
@ -178,7 +169,6 @@ export class GardenPointerInput {
};
private addSwipeAt(event: PointerEvent, options: { emitAudio?: boolean } = {}): void {
const devicePixelRatio = this.options.getDevicePixelRatio();
const position = this.getCanvasPointerPosition(event);
const previousPosition = this.lastPointerPosition ?? position;
const previousTimeMs = this.lastPointerEventTimeMs ?? event.timeStamp;
@ -186,10 +176,6 @@ export class GardenPointerInput {
appConfig.deltaTime.minDeltaTimeSeconds,
(event.timeStamp - previousTimeMs) / 1000
);
const distancePixels = vec2.distance(previousPosition, position);
const velocityPixelsPerSecond = distancePixels / elapsedSeconds;
const pressure = this.getPointerPressure(event);
this.lastPointerPressure = pressure > 0 ? pressure : this.lastPointerPressure;
const segments = this.isErasing
? [{ from: previousPosition, to: position }]
@ -214,14 +200,9 @@ export class GardenPointerInput {
vibe: activeVibe,
from: previousPosition,
to: position,
canvasSize: this.options.getCanvasSize(),
colorIndex: settings.selectedColorIndex,
isErasing: this.isErasing,
pressure: pressure > 0 ? pressure : this.lastPointerPressure,
velocityPixelsPerSecond,
elapsedSeconds,
eraserSizePixels: settings.eraserSize * devicePixelRatio,
mirrorSegmentCount: this.options.getMirrorSegmentCount(),
});
}
this.lastPointerPosition = position;
@ -363,14 +344,6 @@ export class GardenPointerInput {
return segments;
}
private getPointerPressure(event: PointerEvent): number {
if (Number.isFinite(event.pressure) && event.pressure > 0) {
return Math.min(1, Math.max(0, event.pressure));
}
return 0;
}
}
const rotatePointAround = (point: vec2, center: vec2, angle: number): vec2 => {
@ -409,5 +382,4 @@ const getBrushCurveResolution = (): number => {
const isSamePointerSample = (left: PointerEvent, right: PointerEvent): boolean =>
left.clientX === right.clientX &&
left.clientY === right.clientY &&
left.pressure === right.pressure &&
left.buttons === right.buttons;

View file

@ -48,10 +48,20 @@ fn fragment(@location(0) uv: vec2<f32>) -> @location(0) vec4<f32> {
let color = max(normalizedTraceColor, brushColor * (1.2 + brushStrength * 1.6));
let strength = clamp(max(max(max(strengths.r, strengths.g), strengths.b), brushStrength), 0, 1);
let background = getTexturedBackground(uv);
return vec4(mix(settings.backgroundColor, clamp(color, vec3(0), vec3(1)), strength), 1);
return vec4(mix(background, clamp(color, vec3(0), vec3(1)), strength), 1);
}
fn clarity(strength: f32) -> f32 {
return pow(clamp(strength, 0, 1), settings.clarity);
}
fn getTexturedBackground(uv: vec2<f32>) -> vec3<f32> {
let noiseSize = vec2<f32>(textureDimensions(noise, 0));
let pixel = floor(uv * state.size);
let noiseCoord = vec2<i32>(fract(pixel / noiseSize) * noiseSize);
let grain = textureLoad(noise, noiseCoord, 0).r - 0.5;
return clamp(settings.backgroundColor + vec3(grain * 0.018), vec3(0), vec3(1));
}