From d2da0d1617c4d802ee8ee8023c08b35201348d3e Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Sat, 16 May 2026 16:15:54 +0100 Subject: [PATCH] lgtm --- src/audio/garden-audio-config.ts | 105 +++++++- src/audio/garden-audio-energy.test.ts | 8 +- src/audio/garden-audio-energy.ts | 19 +- src/audio/garden-audio-gesture-state.ts | 322 +++--------------------- src/audio/garden-audio-graph.ts | 47 ++-- src/audio/garden-audio-input.ts | 61 +---- src/audio/garden-audio-types.ts | 11 - src/audio/garden-audio.test.ts | 58 ++--- src/audio/garden-audio.ts | 146 +++-------- src/audio/generative-piano.test.ts | 11 +- src/audio/generative-piano.ts | 238 +++++++----------- src/audio/noise-burst-player.ts | 21 +- src/audio/piano-sampler.test.ts | 3 +- src/audio/piano-sampler.ts | 46 ++-- src/config.ts | 216 ++++++++++------ src/config/runtime-settings.ts | 11 +- src/config/types.ts | 81 +----- src/game-loop/agent-population.test.ts | 41 ++- src/game-loop/agent-population.ts | 7 +- src/game-loop/frame-performance.ts | 51 ---- src/game-loop/game-loop-settings.ts | 2 - src/game-loop/game-loop.ts | 19 +- src/game-loop/pointer-input.test.ts | 3 - src/game-loop/pointer-input.ts | 28 --- src/pipelines/render/render.wgsl | 12 +- 25 files changed, 531 insertions(+), 1036 deletions(-) diff --git a/src/audio/garden-audio-config.ts b/src/audio/garden-audio-config.ts index 4751bf4..ca87857 100644 --- a/src/audio/garden-audio-config.ts +++ b/src/audio/garden-audio-config.ts @@ -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; +} + +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; @@ -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; } diff --git a/src/audio/garden-audio-energy.test.ts b/src/audio/garden-audio-energy.test.ts index 39f8051..ac6a6be 100644 --- a/src/audio/garden-audio-energy.test.ts +++ b/src/audio/garden-audio-energy.test.ts @@ -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); diff --git a/src/audio/garden-audio-energy.ts b/src/audio/garden-audio-energy.ts index 99bbc0d..67640c5 100644 --- a/src/audio/garden-audio-energy.ts +++ b/src/audio/garden-audio-energy.ts @@ -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; diff --git a/src/audio/garden-audio-gesture-state.ts b/src/audio/garden-audio-gesture-state.ts index ce5aa4f..e86b29f 100644 --- a/src/audio/garden-audio-gesture-state.ts +++ b/src/audio/garden-audio-gesture-state.ts @@ -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 = []; + private readonly samples: Array = []; 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(); - 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 | undefined, - canvasSize: ArrayLike | 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, 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); diff --git a/src/audio/garden-audio-graph.ts b/src/audio/garden-audio-graph.ts index 61b9cdf..0364c21 100644 --- a/src/audio/garden-audio-graph.ts +++ b/src/audio/garden-audio-graph.ts @@ -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; diff --git a/src/audio/garden-audio-input.ts b/src/audio/garden-audio-input.ts index 6e9ab09..fb38f93 100644 --- a/src/audio/garden-audio-input.ts +++ b/src/audio/garden-audio-input.ts @@ -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; }; diff --git a/src/audio/garden-audio-types.ts b/src/audio/garden-audio-types.ts index 8f03c41..b3f2240 100644 --- a/src/audio/garden-audio-types.ts +++ b/src/audio/garden-audio-types.ts @@ -6,30 +6,19 @@ export interface GardenAudioSnapshot { vibe: VibePreset; selectedColorIndex: number; isErasing: boolean; - mirrorSegmentCount?: number; } export interface GardenAudioStroke { vibe: VibePreset; from: ArrayLike; to: ArrayLike; - canvasSize: ArrayLike; colorIndex: number; isErasing: boolean; - pressure?: number; - velocityPixelsPerSecond?: number; elapsedSeconds?: number; - eraserSizePixels?: number; - mirrorSegmentCount?: number; } export interface GardenAudioTouchDown { - vibe: VibePreset; colorIndex: number; - position?: ArrayLike; - canvasSize?: ArrayLike; - mirrorSegmentCount?: number; - pressure?: number; } export interface GardenAudioStartOptions { diff --git a/src/audio/garden-audio.test.ts b/src/audio/garden-audio.test.ts index 7b48e60..b3687ad 100644 --- a/src/audio/garden-audio.test.ts +++ b/src/audio/garden-audio.test.ts @@ -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 ); }); diff --git a/src/audio/garden-audio.ts b/src/audio/garden-audio.ts index ea54e63..abda120 100644 --- a/src/audio/garden-audio.ts +++ b/src/audio/garden-audio.ts @@ -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 | 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; - } } diff --git a/src/audio/generative-piano.test.ts b/src/audio/generative-piano.test.ts index 90a65ad..b43c102 100644 --- a/src/audio/generative-piano.test.ts +++ b/src/audio/generative-piano.test.ts @@ -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 = []; - const engine = new GenerativePianoEngine( - gardenAudioConfig, - appConfig.audioEngine, - (note) => { - notes.push(note); - } - ); + const engine = new GenerativePianoEngine(gardenAudioConfig, (note) => { + notes.push(note); + }); return { engine, notes }; }; diff --git a/src/audio/generative-piano.ts b/src/audio/generative-piano.ts index 2e3ce1e..d3c512f 100644 --- a/src/audio/generative-piano.ts +++ b/src/audio/generative-piano.ts @@ -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; -} - 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 { 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 { const candidates: Array = []; @@ -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; } diff --git a/src/audio/noise-burst-player.ts b/src/audio/noise-burst-player.ts index 31b3045..9b1341e 100644 --- a/src/audio/noise-burst-player.ts +++ b/src/audio/noise-burst-player.ts @@ -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( diff --git a/src/audio/piano-sampler.test.ts b/src/audio/piano-sampler.test.ts index 24c71f9..66f9ade 100644 --- a/src/audio/piano-sampler.test.ts +++ b/src/audio/piano-sampler.test.ts @@ -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', () => { diff --git a/src/audio/piano-sampler.ts b/src/audio/piano-sampler.ts index 3320b73..3ec03d2 100644 --- a/src/audio/piano-sampler.ts +++ b/src/audio/piano-sampler.ts @@ -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 { diff --git a/src/config.ts b/src/config.ts index feacf71..c7e7c33 100644 --- a/src/config.ts +++ b/src/config.ts @@ -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, diff --git a/src/config/runtime-settings.ts b/src/config/runtime-settings.ts index 8e2c9b3..904829d 100644 --- a/src/config/runtime-settings.ts +++ b/src/config/runtime-settings.ts @@ -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, diff --git a/src/config/types.ts b/src/config/types.ts index 44d586b..673aa93 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -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; }; } - -export type GardenAudioEngineConfig = GardenAppConfig['audioEngine']; diff --git a/src/game-loop/agent-population.test.ts b/src/game-loop/agent-population.test.ts index 43e5917..362651e 100644 --- a/src/game-loop/agent-population.test.ts +++ b/src/game-loop/agent-population.test.ts @@ -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); }); }); diff --git a/src/game-loop/agent-population.ts b/src/game-loop/agent-population.ts index ca47029..766cf71 100644 --- a/src/game-loop/agent-population.ts +++ b/src/game-loop/agent-population.ts @@ -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))); } } diff --git a/src/game-loop/frame-performance.ts b/src/game-loop/frame-performance.ts index 0127abe..3bd7888 100644 --- a/src/game-loop/frame-performance.ts +++ b/src/game-loop/frame-performance.ts @@ -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) { diff --git a/src/game-loop/game-loop-settings.ts b/src/game-loop/game-loop-settings.ts index 58ffd55..ef9dbdf 100644 --- a/src/game-loop/game-loop-settings.ts +++ b/src/game-loop/game-loop-settings.ts @@ -4,6 +4,4 @@ export interface GameLoopSettings { simulatedDelayMs: number; selectedColorIndex: number; spawnPerPixel: number; - - startColorHue: number; } diff --git a/src/game-loop/game-loop.ts b/src/game-loop/game-loop.ts index 4b51f10..6dc37f0 100644 --- a/src/game-loop/game-loop.ts +++ b/src/game-loop/game-loop.ts @@ -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) { diff --git a/src/game-loop/pointer-input.test.ts b/src/game-loop/pointer-input.test.ts index dbe7381..454bf74 100644 --- a/src/game-loop/pointer-input.test.ts +++ b/src/game-loop/pointer-input.test.ts @@ -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(); diff --git a/src/game-loop/pointer-input.ts b/src/game-loop/pointer-input.ts index 8cee375..538e3cf 100644 --- a/src/game-loop/pointer-input.ts +++ b/src/game-loop/pointer-input.ts @@ -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 = []; 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; diff --git a/src/pipelines/render/render.wgsl b/src/pipelines/render/render.wgsl index 0e8f860..839a496 100644 --- a/src/pipelines/render/render.wgsl +++ b/src/pipelines/render/render.wgsl @@ -48,10 +48,20 @@ fn fragment(@location(0) uv: vec2) -> @location(0) vec4 { 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) -> vec3 { + let noiseSize = vec2(textureDimensions(noise, 0)); + let pixel = floor(uv * state.size); + let noiseCoord = vec2(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)); +}