This commit is contained in:
Andras Schmelczer 2026-05-16 16:15:54 +01:00
commit d2da0d1617
25 changed files with 521 additions and 1026 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 {