From 34ac2004377adfa762a531e7fcce956fc21b611b Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Sun, 10 May 2026 15:26:44 +0100 Subject: [PATCH] Add WIP sound generation --- src/audio/garden-audio-energy.ts | 176 ++++++++++++++ src/audio/garden-audio-graph.ts | 168 ++++++++++++++ src/audio/garden-audio-input.ts | 60 +++++ src/audio/garden-audio-music.ts | 52 +++++ src/audio/garden-audio-score.ts | 282 ++++++++++++++++++++++ src/audio/garden-audio-types.ts | 63 +++++ src/audio/garden-audio.test.ts | 144 ++++++++++++ src/audio/garden-audio.ts | 386 +++++++++++++++++++++++++++++++ src/audio/noise-burst-player.ts | 49 ++++ src/audio/piano-sampler.ts | 162 +++++++++++++ 10 files changed, 1542 insertions(+) create mode 100644 src/audio/garden-audio-energy.ts create mode 100644 src/audio/garden-audio-graph.ts create mode 100644 src/audio/garden-audio-input.ts create mode 100644 src/audio/garden-audio-music.ts create mode 100644 src/audio/garden-audio-score.ts create mode 100644 src/audio/garden-audio-types.ts create mode 100644 src/audio/garden-audio.test.ts create mode 100644 src/audio/garden-audio.ts create mode 100644 src/audio/noise-burst-player.ts create mode 100644 src/audio/piano-sampler.ts diff --git a/src/audio/garden-audio-energy.ts b/src/audio/garden-audio-energy.ts new file mode 100644 index 0000000..5f932c1 --- /dev/null +++ b/src/audio/garden-audio-energy.ts @@ -0,0 +1,176 @@ +import { clamp01 } from '../utils/clamp'; +import { GardenAudioConfig } from './garden-audio-config'; + +interface GardenGestureState { + startedAt: number; + lastAt: number; + distancePixels: number; + peakEnergy: number; + isErasing: boolean; +} + +interface GestureTail { + startedAt: number; + durationSeconds: number; + level: number; +} + +type GardenAudioRhythmConfig = GardenAudioConfig['rhythm']; + +export class GardenAudioEnergy { + private isGestureActive = false; + private energy = 0; + private targetEnergy = 0; + private lastEnergyUpdateAt = 0; + private currentGesture: GardenGestureState | null = null; + private gestureTails: Array = []; + + public constructor(private readonly rhythm: GardenAudioRhythmConfig) {} + + public beginGesture(now: number): void { + this.isGestureActive = true; + this.currentGesture = createGesture(now, false); + } + + public endGesture(now: number): void { + if (this.currentGesture && !this.currentGesture.isErasing) { + this.addGestureTail(this.currentGesture, now); + } + + this.isGestureActive = false; + this.currentGesture = null; + } + + public recordStroke(distancePixels: number, strokeEnergy: number, now: number): void { + if (!this.currentGesture || this.currentGesture.isErasing) { + this.currentGesture = createGesture(now, false); + } + + this.currentGesture.lastAt = now; + this.currentGesture.distancePixels += distancePixels; + this.currentGesture.peakEnergy = Math.max( + this.currentGesture.peakEnergy, + strokeEnergy + ); + } + + public recordEraserStroke(now: number): void { + this.currentGesture = this.currentGesture ?? createGesture(now, true); + this.currentGesture.isErasing = true; + } + + public raiseTarget(strokeEnergy: number): void { + this.targetEnergy = Math.max(this.targetEnergy, strokeEnergy); + } + + public silence(): void { + this.targetEnergy = 0; + this.gestureTails = []; + } + + public update(now: number): void { + if (this.lastEnergyUpdateAt <= 0) { + this.lastEnergyUpdateAt = now; + return; + } + + const elapsedSeconds = Math.max(0, now - this.lastEnergyUpdateAt); + this.lastEnergyUpdateAt = now; + this.targetEnergy *= Math.exp(-elapsedSeconds / 0.75); + this.trimGestureTails(now); + + const activeGestureFloor = + this.isGestureActive && this.currentGesture && !this.currentGesture.isErasing + ? 0.04 + this.getGestureAmount(this.currentGesture, now) * 0.3 + : 0; + const target = Math.max(activeGestureFloor, this.targetEnergy); + const timeConstant = target > this.energy ? 0.08 : 0.55; + const amount = 1 - Math.exp(-elapsedSeconds / timeConstant); + this.energy += (target - this.energy) * amount; + } + + public getActivityAt(time: number): number { + const activeGesture = + this.isGestureActive && this.currentGesture && !this.currentGesture.isErasing + ? 0.08 + this.getGestureAmount(this.currentGesture, time) * 0.34 + : 0; + + return clamp01( + this.energy * 0.58 + this.getTailActivityAt(time) * 0.72 + activeGesture + ); + } + + public reset(): void { + this.isGestureActive = false; + this.energy = 0; + this.targetEnergy = 0; + this.lastEnergyUpdateAt = 0; + this.currentGesture = null; + this.gestureTails = []; + } + + private addGestureTail(gesture: GardenGestureState, now: number): void { + const durationAmount = clamp01( + (gesture.lastAt - gesture.startedAt) / this.rhythm.tailDurationForMaxSeconds + ); + const distanceAmount = clamp01( + gesture.distancePixels / this.rhythm.tailDistanceForMaxPixels + ); + const gestureAmount = Math.max(distanceAmount, durationAmount * 0.9); + const tailAmount = clamp01(gestureAmount * (0.74 + gesture.peakEnergy * 0.26)); + + if (tailAmount <= 0.015) { + return; + } + + this.gestureTails.push({ + startedAt: now, + durationSeconds: + this.rhythm.minTailSeconds + + (this.rhythm.maxTailSeconds - this.rhythm.minTailSeconds) * tailAmount, + level: (0.05 + tailAmount * 0.5) * (0.72 + gesture.peakEnergy * 0.28), + }); + this.trimGestureTails(now); + } + + private getGestureAmount(gesture: GardenGestureState, now: number): number { + const durationAmount = clamp01( + (now - gesture.startedAt) / this.rhythm.tailDurationForMaxSeconds + ); + const distanceAmount = clamp01( + gesture.distancePixels / this.rhythm.tailDistanceForMaxPixels + ); + + return clamp01( + distanceAmount * 0.62 + durationAmount * 0.24 + gesture.peakEnergy * 0.14 + ); + } + + private getTailActivityAt(time: number): number { + const decayPower = Math.max(0.2, this.rhythm.tailDecayPower); + + return this.gestureTails.reduce((activity, tail) => { + const elapsedSeconds = time - tail.startedAt; + if (elapsedSeconds < 0 || elapsedSeconds >= tail.durationSeconds) { + return activity; + } + + const remaining = clamp01(1 - elapsedSeconds / tail.durationSeconds); + return Math.max(activity, tail.level * Math.pow(remaining, decayPower)); + }, 0); + } + + private trimGestureTails(now: number): void { + this.gestureTails = this.gestureTails.filter( + (tail) => now - tail.startedAt < tail.durationSeconds + ); + } +} + +const createGesture = (now: number, isErasing: boolean): GardenGestureState => ({ + startedAt: now, + lastAt: now, + distancePixels: 0, + peakEnergy: 0, + isErasing, +}); diff --git a/src/audio/garden-audio-graph.ts b/src/audio/garden-audio-graph.ts new file mode 100644 index 0000000..2de952b --- /dev/null +++ b/src/audio/garden-audio-graph.ts @@ -0,0 +1,168 @@ +import { clamp } from '../utils/clamp'; +import { GardenAudioConfig, GardenAudioVibeProfile } from './garden-audio-config'; + +export class GardenAudioGraph { + public context: AudioContext | null = null; + public eventBus: GainNode | null = null; + public delayInput: GainNode | null = null; + public noiseBuffer: AudioBuffer | null = null; + + private masterGain: GainNode | null = null; + private delayNode: DelayNode | null = null; + private delayFeedback: GainNode | null = null; + private delayOutput: GainNode | null = null; + + public constructor(private readonly config: GardenAudioConfig) {} + + public ensureContext(canCreate: boolean): AudioContext | null { + if (this.context) { + return this.context; + } + + if (!canCreate) { + return null; + } + + const context = new AudioContext({ latencyHint: 'interactive' }); + const masterGain = context.createGain(); + const highPass = context.createBiquadFilter(); + const compressor = context.createDynamicsCompressor(); + + masterGain.gain.value = 0; + highPass.type = 'highpass'; + highPass.frequency.value = this.config.highPassFrequencyHz; + compressor.threshold.value = this.config.compressor.thresholdDb; + compressor.knee.value = this.config.compressor.kneeDb; + compressor.ratio.value = this.config.compressor.ratio; + compressor.attack.value = this.config.compressor.attackSeconds; + compressor.release.value = this.config.compressor.releaseSeconds; + + masterGain.connect(highPass); + highPass.connect(compressor); + compressor.connect(context.destination); + + this.context = context; + this.masterGain = masterGain; + this.noiseBuffer = this.createNoiseBuffer(context); + this.createDelay(context, masterGain); + this.createBuses(context, masterGain); + + return context; + } + + public setMasterGain(targetGain: number, timeConstantSeconds: number): void { + if (!this.context || !this.masterGain) { + return; + } + + this.masterGain.gain.setTargetAtTime( + targetGain, + this.context.currentTime, + timeConstantSeconds + ); + } + + public applyDelayProfile(profile: GardenAudioVibeProfile): void { + if (!this.context || !this.delayNode) { + return; + } + + this.delayNode.delayTime.setTargetAtTime( + this.config.delay.timeSeconds * profile.delayTimeMultiplier, + this.context.currentTime, + 0.12 + ); + } + + public updateDelay(profile: GardenAudioVibeProfile, activity: number): void { + if (!this.context || !this.delayNode || !this.delayFeedback || !this.delayOutput) { + return; + } + + const now = this.context.currentTime; + this.delayNode.delayTime.setTargetAtTime( + this.config.delay.timeSeconds * profile.delayTimeMultiplier, + now, + 0.12 + ); + this.delayFeedback.gain.setTargetAtTime( + this.config.delay.enabled + ? clamp(this.config.delay.feedback + activity * 0.08, 0.04, 0.32) + : 0, + now, + this.config.updateRampSeconds + ); + this.delayOutput.gain.setTargetAtTime( + this.config.delay.enabled ? this.config.delay.wetGain * (0.65 + activity * 0.5) : 0, + now, + this.config.updateRampSeconds + ); + } + + public async close(): Promise { + const context = this.context; + if (!context) { + return; + } + + if (this.masterGain && context.state !== 'closed') { + this.masterGain.gain.setTargetAtTime(0.0001, context.currentTime, 0.015); + } + + this.clearNodes(); + + if (context.state !== 'closed') { + await context.close().catch(() => undefined); + } + } + + private createDelay(context: AudioContext, masterGain: GainNode): void { + const delayInput = context.createGain(); + const delayNode = context.createDelay(2); + const delayFeedback = context.createGain(); + const delayOutput = context.createGain(); + + delayNode.delayTime.value = this.config.delay.timeSeconds; + delayFeedback.gain.value = this.config.delay.enabled ? this.config.delay.feedback : 0; + delayOutput.gain.value = this.config.delay.enabled ? this.config.delay.wetGain : 0; + + delayInput.connect(delayNode); + delayNode.connect(delayFeedback); + delayFeedback.connect(delayNode); + delayNode.connect(delayOutput); + delayOutput.connect(masterGain); + + this.delayInput = delayInput; + this.delayNode = delayNode; + this.delayFeedback = delayFeedback; + this.delayOutput = delayOutput; + } + + private createBuses(context: AudioContext, masterGain: GainNode): void { + this.eventBus = context.createGain(); + this.eventBus.gain.value = 1; + this.eventBus.connect(masterGain); + } + + private createNoiseBuffer(context: AudioContext): AudioBuffer { + const buffer = context.createBuffer(1, context.sampleRate, context.sampleRate); + const data = buffer.getChannelData(0); + + for (let index = 0; index < data.length; index++) { + data[index] = Math.random() * 2 - 1; + } + + return buffer; + } + + private clearNodes(): void { + this.context = null; + this.eventBus = null; + this.delayInput = null; + this.noiseBuffer = null; + this.masterGain = null; + this.delayNode = null; + this.delayFeedback = null; + this.delayOutput = null; + } +} diff --git a/src/audio/garden-audio-input.ts b/src/audio/garden-audio-input.ts new file mode 100644 index 0000000..ee9d96c --- /dev/null +++ b/src/audio/garden-audio-input.ts @@ -0,0 +1,60 @@ +import { clamp01 } from '../utils/clamp'; +import { GardenAudioStroke } from './garden-audio-types'; + +export interface GardenAudioStrokeMetrics { + distancePixels: number; + pressure: number; + speedAmount: number; + effectiveEnergy: number; +} + +export const getStrokeMetrics = ( + stroke: GardenAudioStroke, + speedForFullEnergyPixelsPerSecond: number, + fallbackPressure: number +): GardenAudioStrokeMetrics => { + const dx = stroke.to[0] - stroke.from[0]; + const dy = stroke.to[1] - stroke.from[1]; + const distancePixels = Math.hypot(dx, dy); + const speedPixelsPerSecond = getStrokeVelocity(stroke, distancePixels); + const pressure = getPressureAmount(stroke, fallbackPressure); + const speedAmount = clamp01(speedPixelsPerSecond / speedForFullEnergyPixelsPerSecond); + const strokeEnergy = clamp01(0.18 + speedAmount * 0.62 + pressure * 0.22); + const effectiveEnergy = strokeEnergy * (0.25 + clamp01(distancePixels / 140) * 0.75); + + return { + distancePixels, + pressure, + speedAmount, + effectiveEnergy, + }; +}; + +const getStrokeVelocity = (stroke: GardenAudioStroke, distancePixels: number): number => { + if ( + stroke.velocityPixelsPerSecond !== undefined && + Number.isFinite(stroke.velocityPixelsPerSecond) && + stroke.velocityPixelsPerSecond >= 0 + ) { + return stroke.velocityPixelsPerSecond; + } + + return distancePixels / (1 / 60); +}; + +const getPressureAmount = ( + stroke: GardenAudioStroke, + fallbackPressure: number +): number => { + if ( + stroke.pressure !== undefined && + Number.isFinite(stroke.pressure) && + stroke.pressure > 0 + ) { + return clamp01(stroke.pressure); + } + + return stroke.pointerType === 'pen' + ? Math.max(0.56, clamp01(fallbackPressure)) + : clamp01(fallbackPressure); +}; diff --git a/src/audio/garden-audio-music.ts b/src/audio/garden-audio-music.ts new file mode 100644 index 0000000..f3d12c8 --- /dev/null +++ b/src/audio/garden-audio-music.ts @@ -0,0 +1,52 @@ +import { clamp } from '../utils/clamp'; +import { VibePreset } from '../vibes'; +import { + GardenAudioChord, + GardenAudioConfig, + GardenAudioVibeProfile, +} from './garden-audio-config'; +import { GardenAudioColorIndex } from './garden-audio-types'; + +export const normalizeColorIndex = (index: number): GardenAudioColorIndex => + Math.max(0, Math.min(2, Math.round(index))) as GardenAudioColorIndex; + +export const clampMidi = (midi: number, min: number, max: number): number => + Math.round(clamp(midi, min, max)); + +export const getVibeProfile = ( + config: GardenAudioConfig, + vibe: VibePreset +): GardenAudioVibeProfile => + config.vibes[vibe.id] ?? + config.vibes[config.fallbackVibeId] ?? + Object.values(config.vibes)[0]; + +export const getChordAtStep = ( + config: GardenAudioConfig, + profile: GardenAudioVibeProfile, + stepIndex: number +): GardenAudioChord => { + const barIndex = Math.floor(stepIndex / config.rhythm.stepsPerBar); + return profile.progression[barIndex % profile.progression.length]; +}; + +export const getChordIntervals = ( + chord: GardenAudioChord, + openVoicing: boolean +): Array => { + if (openVoicing) { + return chord.quality === 'major' ? [0, 7, 12, 16] : [0, 7, 12, 15]; + } + + return chord.quality === 'major' ? [0, 4, 7, 12, 16] : [0, 3, 7, 12, 15]; +}; + +export const degreeToSemitone = ( + profile: GardenAudioVibeProfile, + degree: number +): number => { + const scaleIndex = + ((degree % profile.scale.length) + profile.scale.length) % profile.scale.length; + const octave = Math.floor(degree / profile.scale.length); + return profile.scale[scaleIndex] + octave * 12; +}; diff --git a/src/audio/garden-audio-score.ts b/src/audio/garden-audio-score.ts new file mode 100644 index 0000000..62ce4c0 --- /dev/null +++ b/src/audio/garden-audio-score.ts @@ -0,0 +1,282 @@ +import { clamp, clamp01 } from '../utils/clamp'; +import { VibePreset } from '../vibes'; +import { + GardenAudioChord, + GardenAudioConfig, + GardenAudioVibeProfile, +} from './garden-audio-config'; +import { + clampMidi, + degreeToSemitone, + getChordAtStep, + getChordIntervals, + getVibeProfile, +} from './garden-audio-music'; +import { + GardenAudioColorIndex, + GardenAudioStroke, + PianoNote, +} from './garden-audio-types'; + +interface RhythmStepRequest { + vibe: VibePreset; + stepIndex: number; + startTime: number; + activity: number; + selectedColorIndex: GardenAudioColorIndex; +} + +interface StrokeTapRequest { + stroke: GardenAudioStroke; + energy: number; + now: number; + selectedColorIndex: GardenAudioColorIndex; + stepIndex: number; + rhythmAnchorTime: number; + stepDurationSeconds: number; +} + +export class GardenAudioScore { + public constructor( + private readonly config: GardenAudioConfig, + private readonly playNote: (note: PianoNote) => void + ) {} + + public playRhythmStep({ + vibe, + stepIndex, + startTime, + activity, + selectedColorIndex, + }: RhythmStepRequest): void { + const profile = getVibeProfile(this.config, vibe); + const stepInBar = stepIndex % this.config.rhythm.stepsPerBar; + if (activity < this.config.rhythm.sparseActivity) { + return; + } + + const chord = getChordAtStep(this.config, profile, stepIndex); + if (stepInBar === 0 && activity < this.config.rhythm.bassActivity) { + this.playRootAnchor(profile, chord, startTime, activity); + } + + if ( + this.config.rhythm.bassSteps.includes(stepInBar) && + activity >= this.config.rhythm.bassActivity + ) { + this.playBassNote(profile, chord, startTime, activity); + } + + if ( + this.config.rhythm.chordSteps.includes(stepInBar) && + activity >= this.config.rhythm.arpeggioActivity + ) { + this.playBrokenChord(profile, chord, startTime, activity, stepInBar === 0); + } + + if (this.shouldPlayMelodyStep(stepInBar, activity)) { + this.playMelodyNote( + profile, + chord, + stepIndex, + startTime, + activity, + selectedColorIndex + ); + } + } + + public playStrokeTap({ + stroke, + energy, + now, + selectedColorIndex, + stepIndex, + rhythmAnchorTime, + stepDurationSeconds, + }: StrokeTapRequest): void { + const profile = getVibeProfile(this.config, stroke.vibe); + const colorVoice = this.config.colorVoices[selectedColorIndex]; + const chord = getChordAtStep(this.config, profile, stepIndex); + const intervals = getChordIntervals(chord, false); + const x = clamp01(stroke.to[0] / Math.max(1, stroke.canvasSize[0])); + const y = 1 - clamp01(stroke.to[1] / Math.max(1, stroke.canvasSize[1])); + const rawRegister = y < 0.35 ? 0 : y > 0.72 ? 2 : 1; + const register = + energy < this.config.rhythm.arpeggioActivity + ? Math.min(rawRegister, 1) + : rawRegister; + const interval = + intervals[ + (register + colorVoice.scaleDegreeOffset + selectedColorIndex) % intervals.length + ]; + + this.playNote({ + midi: clampMidi( + profile.rootMidi + + chord.rootOffset + + interval + + (energy < this.config.rhythm.arpeggioActivity ? 5 : 12) + + register * 5 + + (energy >= this.config.rhythm.fullChordActivity + ? colorVoice.octaveOffset * 7 + : 0), + 50, + energy < this.config.rhythm.arpeggioActivity ? 76 : 88 + ), + velocity: (0.26 + energy * 0.34) * colorVoice.velocityMultiplier, + durationSeconds: 1.05 + energy * 0.45, + pan: clamp(x * 2 - 1 + colorVoice.panOffset * 0.6, -0.85, 0.85), + delaySend: 0.014, + lowpassHz: this.config.piano.lowpassHz * profile.brightness, + startTime: this.getNearGridTime(now, rhythmAnchorTime, stepDurationSeconds), + }); + } + + public playVibeChangeStinger(vibe: VibePreset, now: number): void { + const profile = getVibeProfile(this.config, vibe); + const chord = profile.progression[0]; + const intervals = getChordIntervals(chord, true); + + intervals.forEach((interval, index) => { + this.playNote({ + midi: clampMidi(profile.rootMidi + chord.rootOffset + interval, 48, 84), + velocity: 0.3 * Math.pow(0.9, index), + durationSeconds: 1.4, + pan: clamp(-0.22 + index * 0.14, -0.5, 0.5), + delaySend: 0.016, + lowpassHz: this.config.piano.lowpassHz * profile.brightness, + startTime: now + index * 0.045, + }); + }); + } + + private shouldPlayMelodyStep(stepInBar: number, activity: number): boolean { + if (activity < this.config.rhythm.sparseActivity) { + return false; + } + + if (activity < this.config.rhythm.arpeggioActivity) { + return stepInBar === 10; + } + + if (activity < this.config.rhythm.fullChordActivity) { + return stepInBar === 6 || stepInBar === 10; + } + + return this.config.rhythm.melodySteps.includes(stepInBar); + } + + private playRootAnchor( + profile: GardenAudioVibeProfile, + chord: GardenAudioChord, + startTime: number, + activity: number + ): void { + this.playNote({ + midi: clampMidi(profile.rootMidi + chord.rootOffset, 43, 64), + velocity: 0.13 + activity * 0.14, + durationSeconds: 1.35, + pan: -0.06, + delaySend: 0.004, + lowpassHz: this.config.piano.lowpassHz * profile.brightness * 0.78, + startTime, + }); + } + + private playBassNote( + profile: GardenAudioVibeProfile, + chord: GardenAudioChord, + startTime: number, + activity: number + ): void { + this.playNote({ + midi: clampMidi(profile.rootMidi + chord.rootOffset - 12, 36, 58), + velocity: 0.24 + activity * 0.18, + durationSeconds: 1.8, + pan: -0.08, + delaySend: 0.006, + startTime, + }); + } + + private playBrokenChord( + profile: GardenAudioVibeProfile, + chord: GardenAudioChord, + startTime: number, + activity: number, + isAccent: boolean + ): void { + const intervals = getChordIntervals(chord, true); + const velocity = (isAccent ? 0.22 : 0.16) + activity * 0.16; + const baseMidi = profile.rootMidi + chord.rootOffset; + const isFullChord = activity >= this.config.rhythm.fullChordActivity; + const noteCount = isFullChord ? intervals.length : activity >= 0.46 ? 3 : 2; + const staggerSeconds = isFullChord ? 0.018 : 0.056; + + intervals.slice(0, noteCount).forEach((interval, index) => { + this.playNote({ + midi: clampMidi(baseMidi + interval, 48, 78), + velocity: velocity * Math.pow(0.9, index), + durationSeconds: isFullChord ? (isAccent ? 1.9 : 1.35) : 1.08, + pan: clamp(-0.16 + index * 0.1, -0.45, 0.45), + delaySend: 0.012, + lowpassHz: this.config.piano.lowpassHz * profile.brightness, + startTime: startTime + index * staggerSeconds, + }); + }); + } + + private playMelodyNote( + profile: GardenAudioVibeProfile, + chord: GardenAudioChord, + stepIndex: number, + startTime: number, + activity: number, + selectedColorIndex: GardenAudioColorIndex + ): void { + const colorVoice = this.config.colorVoices[selectedColorIndex]; + const patternIndex = + Math.floor(stepIndex / 2) + colorVoice.scaleDegreeOffset + selectedColorIndex; + const scaleDegree = + this.config.rhythm.melodyPattern[ + patternIndex % this.config.rhythm.melodyPattern.length + ] + colorVoice.scaleDegreeOffset; + const stepInBeat = stepIndex % this.config.rhythm.stepsPerBeat; + const semitoneOffset = + stepInBeat === 0 + ? chord.rootOffset + + getChordIntervals(chord, false)[(patternIndex + selectedColorIndex) % 3] + : degreeToSemitone(profile, scaleDegree); + const registerLift = activity < this.config.rhythm.arpeggioActivity ? 0 : 12; + const colorOctaveLift = + activity >= this.config.rhythm.fullChordActivity ? colorVoice.octaveOffset * 12 : 0; + + this.playNote({ + midi: clampMidi( + profile.rootMidi + semitoneOffset + registerLift + colorOctaveLift, + activity < this.config.rhythm.arpeggioActivity ? 50 : 57, + activity < this.config.rhythm.arpeggioActivity ? 76 : 91 + ), + velocity: + (0.2 + activity * 0.32) * + colorVoice.velocityMultiplier * + (stepInBeat === 0 ? 1.08 : 1), + durationSeconds: 0.92 + activity * 0.38, + pan: colorVoice.panOffset, + delaySend: 0.018, + lowpassHz: this.config.piano.lowpassHz * profile.brightness, + startTime, + }); + } + + private getNearGridTime( + now: number, + rhythmAnchorTime: number, + stepDurationSeconds: number + ): number { + const stepCount = Math.ceil((now - rhythmAnchorTime) / stepDurationSeconds); + const gridTime = rhythmAnchorTime + Math.max(0, stepCount) * stepDurationSeconds; + return gridTime - now <= 0.1 ? gridTime : now + 0.012; + } +} diff --git a/src/audio/garden-audio-types.ts b/src/audio/garden-audio-types.ts new file mode 100644 index 0000000..6e45d8e --- /dev/null +++ b/src/audio/garden-audio-types.ts @@ -0,0 +1,63 @@ +import { VibePreset } from '../vibes'; + +export type GardenAudioColorIndex = 0 | 1 | 2; + +export interface GardenAudioSnapshot { + vibe: VibePreset; + activeAgentCount: number; + agentBudgetMax: number; + selectedColorIndex: number; + isErasing: boolean; + introProgress: number; + moveSpeed: number; + diffusionRateTrails: number; + decayRateTrails: number; + brushEffectDuration: number; + clarity: number; +} + +export interface GardenAudioStroke { + vibe: VibePreset; + from: ArrayLike; + to: ArrayLike; + canvasSize: ArrayLike; + colorIndex: number; + isErasing: boolean; + pressure?: number; + velocityPixelsPerSecond?: number; + eraserSizePixels?: number; + pointerType?: string; +} + +export interface GardenAudioStartOptions { + userGesture?: boolean; +} + +export interface LoadedPianoSample { + midi: number; + buffer: AudioBuffer; +} + +export interface ActivePianoVoice { + gain: GainNode; + source: AudioBufferSourceNode; + stopAt: number; +} + +export interface PianoNote { + midi: number; + velocity: number; + startTime: number; + durationSeconds: number; + pan: number; + delaySend?: number; + lowpassHz?: number; +} + +export interface NoiseBurst { + startTime: number; + durationSeconds: number; + gain: number; + filterHz: number; + pan: number; +} diff --git a/src/audio/garden-audio.test.ts b/src/audio/garden-audio.test.ts new file mode 100644 index 0000000..cab92a0 --- /dev/null +++ b/src/audio/garden-audio.test.ts @@ -0,0 +1,144 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { VIBE_PRESETS } from '../vibes'; +import { GardenAudio } from './garden-audio'; +import { gardenAudioConfig, GardenAudioConfig } from './garden-audio-config'; + +const calls = { + constructed: 0, + resumed: 0, +}; + +let contextState: AudioContextState = 'suspended'; + +class FakeAudioParam { + public value = 0; + public setTargetAtTime = vi.fn(); + public setValueAtTime = vi.fn(); + public exponentialRampToValueAtTime = vi.fn(); + public cancelScheduledValues = vi.fn(); +} + +class FakeAudioNode { + public readonly gain = new FakeAudioParam(); + public readonly frequency = new FakeAudioParam(); + public readonly threshold = new FakeAudioParam(); + public readonly knee = new FakeAudioParam(); + public readonly ratio = new FakeAudioParam(); + public readonly attack = new FakeAudioParam(); + public readonly release = new FakeAudioParam(); + public readonly delayTime = new FakeAudioParam(); + public type = ''; + public connect = vi.fn(); + public disconnect = vi.fn(); +} + +class FakeAudioBuffer { + private readonly data: Float32Array; + + public constructor(length: number) { + this.data = new Float32Array(length); + } + + public getChannelData(): Float32Array { + return this.data; + } +} + +class FakeAudioContext { + public readonly currentTime = 1; + public readonly sampleRate = 16; + public readonly destination = new FakeAudioNode() as unknown as AudioDestinationNode; + + public constructor() { + calls.constructed += 1; + } + + public get state(): AudioContextState { + return contextState; + } + + public set state(state: AudioContextState) { + contextState = state; + } + + public createGain(): GainNode { + return new FakeAudioNode() as unknown as GainNode; + } + + public createBiquadFilter(): BiquadFilterNode { + return new FakeAudioNode() as unknown as BiquadFilterNode; + } + + public createDynamicsCompressor(): DynamicsCompressorNode { + return new FakeAudioNode() as unknown as DynamicsCompressorNode; + } + + public createDelay(): DelayNode { + return new FakeAudioNode() as unknown as DelayNode; + } + + public createBuffer(_channels: number, length: number): AudioBuffer { + return new FakeAudioBuffer(length) as unknown as AudioBuffer; + } + + public async resume(): Promise { + calls.resumed += 1; + contextState = 'running'; + } +} + +const makeConfig = (): GardenAudioConfig => ({ + ...gardenAudioConfig, + piano: { + ...gardenAudioConfig.piano, + preloadOnStart: false, + }, +}); + +describe('GardenAudio startup policy', () => { + beforeEach(() => { + calls.constructed = 0; + calls.resumed = 0; + contextState = 'suspended'; + vi.stubGlobal('AudioContext', FakeAudioContext); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('does not create an AudioContext from passive audio paths', () => { + const audio = new GardenAudio(makeConfig()); + const vibe = VIBE_PRESETS[0]; + + audio.start(vibe); + audio.stroke({ + vibe, + from: [0, 0], + to: [12, 0], + canvasSize: [100, 100], + colorIndex: 0, + isErasing: false, + }); + + expect(calls.constructed).toBe(0); + }); + + it('only resumes a suspended context from a user gesture start', () => { + const audio = new GardenAudio(makeConfig()); + const vibe = VIBE_PRESETS[0]; + + audio.start(vibe, { userGesture: true }); + + expect(calls.constructed).toBe(1); + expect(calls.resumed).toBe(1); + expect(contextState).toBe('running'); + + contextState = 'suspended'; + audio.start(vibe); + audio.setMuted(false); + + expect(calls.resumed).toBe(1); + }); +}); diff --git a/src/audio/garden-audio.ts b/src/audio/garden-audio.ts new file mode 100644 index 0000000..0949142 --- /dev/null +++ b/src/audio/garden-audio.ts @@ -0,0 +1,386 @@ +import { clamp, clamp01 } from '../utils/clamp'; +import { VibePreset } from '../vibes'; +import { GardenAudioConfig } from './garden-audio-config'; +import { GardenAudioEnergy } from './garden-audio-energy'; +import { GardenAudioGraph } from './garden-audio-graph'; +import { getStrokeMetrics } from './garden-audio-input'; +import { getVibeProfile, normalizeColorIndex } from './garden-audio-music'; +import { GardenAudioScore } from './garden-audio-score'; +import type { + GardenAudioColorIndex, + GardenAudioSnapshot, + GardenAudioStartOptions, + GardenAudioStroke, +} from './garden-audio-types'; +import { NoiseBurstPlayer } from './noise-burst-player'; +import { PianoSampler } from './piano-sampler'; + +export type { + GardenAudioSnapshot, + GardenAudioStartOptions, + GardenAudioStroke, +} from './garden-audio-types'; + +export class GardenAudio { + private readonly graph: GardenAudioGraph; + private readonly piano: PianoSampler; + private readonly noise: NoiseBurstPlayer; + private readonly energy: GardenAudioEnergy; + private readonly score: GardenAudioScore; + + private currentVibeId: string | null = null; + private hasStarted = false; + private isDestroyed = false; + private isMuted = false; + private selectedColorIndex: GardenAudioColorIndex = 0; + private rhythmAnchorTime: number | null = null; + private startedAt: number | null = null; + private nextStepAt = 0; + private stepIndex = 0; + private lastTapAt = Number.NEGATIVE_INFINITY; + private lastEraserAt = Number.NEGATIVE_INFINITY; + private lastVibeStingerAt = Number.NEGATIVE_INFINITY; + + public constructor(private readonly config: GardenAudioConfig) { + this.graph = new GardenAudioGraph(config); + this.piano = new PianoSampler(config, this.graph); + this.noise = new NoiseBurstPlayer(this.graph); + this.energy = new GardenAudioEnergy(config.rhythm); + this.score = new GardenAudioScore(config, (note) => this.piano.play(note)); + } + + public start(vibe: VibePreset, options: GardenAudioStartOptions = {}): void { + if (!this.config.enabled || this.isDestroyed || this.isMuted) { + return; + } + + const context = this.graph.ensureContext(options.userGesture === true); + if (!context) { + return; + } + + if (context.state === 'suspended') { + if (options.userGesture !== true) { + return; + } + void context.resume().catch(() => undefined); + } + + this.hasStarted = true; + this.rhythmAnchorTime ??= context.currentTime; + this.startedAt ??= context.currentTime; + this.applyVibe(vibe); + if (this.nextStepAt <= 0) { + this.nextStepAt = context.currentTime + 0.02; + } + this.graph.setMasterGain(this.config.masterVolume, this.config.fadeInSeconds); + + if (this.config.piano.preloadOnStart) { + void this.piano.load(context); + } + } + + public changeVibe(vibe: VibePreset, options: GardenAudioStartOptions = {}): void { + const previousVibeId = this.currentVibeId; + this.start(vibe, options); + + const context = this.graph.context; + if ( + context && + (context.state === 'running' || options.userGesture === true) && + !this.isMuted && + !this.isDestroyed && + previousVibeId !== null && + previousVibeId !== vibe.id + ) { + this.playVibeChangeStinger(vibe); + } + } + + public setMuted(isMuted: boolean): void { + this.isMuted = isMuted; + this.graph.setMasterGain( + isMuted ? 0.0001 : this.config.masterVolume, + isMuted ? 0.02 : this.config.fadeInSeconds + ); + } + + public beginGesture(): void { + const context = this.graph.context; + if (!context) { + return; + } + + this.energy.beginGesture(context.currentTime); + } + + public endGesture(): void { + const context = this.graph.context; + if (!context) { + return; + } + + this.energy.endGesture(context.currentTime); + } + + public rememberColor(colorIndex: number): void { + this.selectedColorIndex = normalizeColorIndex(colorIndex); + } + + public update(snapshot: GardenAudioSnapshot): void { + const context = this.graph.context; + if (!this.hasStarted || !context || this.isMuted) { + return; + } + + this.applyVibe(snapshot.vibe); + this.selectedColorIndex = normalizeColorIndex(snapshot.selectedColorIndex); + this.energy.update(context.currentTime); + + if (snapshot.isErasing) { + this.energy.silence(); + this.piano.fadeActive(context.currentTime); + this.updateDelay(snapshot); + return; + } + + this.scheduleRhythm(snapshot.vibe); + this.updateDelay(snapshot); + } + + public stroke(stroke: GardenAudioStroke): void { + if (!this.config.enabled || this.isDestroyed || this.isMuted) { + return; + } + + this.start(stroke.vibe); + const context = this.graph.context; + if (!context) { + return; + } + + const metrics = getStrokeMetrics( + stroke, + this.config.rhythm.speedForFullEnergyPixelsPerSecond, + this.config.input.pressureFallback + ); + const now = context.currentTime; + + this.selectedColorIndex = normalizeColorIndex(stroke.colorIndex); + + if (stroke.isErasing) { + this.energy.recordEraserStroke(now); + this.playEraser(stroke, metrics.speedAmount, metrics.pressure, now); + return; + } + + const strokeEnergy = metrics.effectiveEnergy * this.getStartupEnergyScale(now); + this.energy.recordStroke(metrics.distancePixels, strokeEnergy, now); + if (metrics.distancePixels >= 2.5) { + this.energy.raiseTarget(strokeEnergy); + } + + if ( + metrics.distancePixels >= 2.5 && + now - this.lastTapAt >= this.getTapIntervalSeconds(now) + ) { + this.lastTapAt = now; + this.score.playStrokeTap({ + stroke, + energy: strokeEnergy, + now, + selectedColorIndex: this.selectedColorIndex, + stepIndex: this.stepIndex, + rhythmAnchorTime: this.getRhythmAnchorTime(now), + stepDurationSeconds: this.getStepDurationSeconds(now), + }); + } + } + + public async destroy(): Promise { + this.isDestroyed = true; + await this.graph.close(); + + this.piano.reset(); + this.energy.reset(); + this.currentVibeId = null; + this.hasStarted = false; + this.selectedColorIndex = 0; + this.rhythmAnchorTime = null; + this.startedAt = null; + this.nextStepAt = 0; + this.stepIndex = 0; + this.lastTapAt = Number.NEGATIVE_INFINITY; + this.lastEraserAt = Number.NEGATIVE_INFINITY; + this.lastVibeStingerAt = Number.NEGATIVE_INFINITY; + } + + private scheduleRhythm(vibe: VibePreset): void { + const context = this.graph.context; + if (!context || !this.graph.eventBus) { + return; + } + + const now = context.currentTime; + const stepSeconds = this.getStepDurationSeconds(now); + if (this.nextStepAt <= 0 || this.nextStepAt < now - stepSeconds) { + this.nextStepAt = now + 0.02; + } + + const lookaheadEnd = now + this.config.rhythm.lookaheadSeconds; + while (this.nextStepAt <= lookaheadEnd) { + const swingOffset = + this.stepIndex % 2 === 1 ? stepSeconds * this.config.rhythm.swing : 0; + const startTime = this.nextStepAt + swingOffset; + this.score.playRhythmStep({ + vibe, + stepIndex: this.stepIndex, + startTime, + activity: this.getSettledActivity( + this.energy.getActivityAt(startTime), + startTime + ), + selectedColorIndex: this.selectedColorIndex, + }); + this.nextStepAt += stepSeconds; + this.stepIndex += 1; + } + } + + private playVibeChangeStinger(vibe: VibePreset): void { + const context = this.graph.context; + if (!context) { + return; + } + + const now = context.currentTime; + if (now - this.lastVibeStingerAt < 0.45) { + return; + } + + this.lastVibeStingerAt = now; + this.score.playVibeChangeStinger(vibe, now); + } + + private playEraser( + stroke: GardenAudioStroke, + speedAmount: number, + pressure: number, + now: number + ): void { + if (!this.config.eraser.enabled || !this.graph.context) { + return; + } + + const sizeAmount = clamp01( + (stroke.eraserSizePixels ?? 96) / Math.max(1, stroke.canvasSize[0] * 0.18) + ); + const x = clamp01(stroke.to[0] / Math.max(1, stroke.canvasSize[0])); + const filterHz = + this.config.eraser.filterMinHz + + (this.config.eraser.filterMaxHz - this.config.eraser.filterMinHz) * + clamp01(speedAmount * 0.58 + pressure * 0.26 + sizeAmount * 0.16); + + if (now - this.lastEraserAt >= this.config.eraser.minIntervalSeconds) { + this.lastEraserAt = now; + this.noise.play({ + startTime: now, + durationSeconds: 0.08, + gain: + this.config.eraser.noiseGain * + (0.45 + speedAmount * 0.38 + pressure * 0.24 + sizeAmount * 0.18), + filterHz, + pan: clamp(x * 2 - 1, -1, 1), + }); + } + } + + private updateDelay(snapshot: GardenAudioSnapshot): void { + const context = this.graph.context; + if (!context) { + return; + } + + const profile = getVibeProfile(this.config, snapshot.vibe); + const activity = snapshot.isErasing + ? 0.12 + : this.getSettledActivity( + this.energy.getActivityAt(context.currentTime), + context.currentTime + ); + this.graph.updateDelay(profile, activity); + } + + private applyVibe(vibe: VibePreset): void { + if (!this.graph.context || this.currentVibeId === vibe.id) { + return; + } + + this.currentVibeId = vibe.id; + this.graph.applyDelayProfile(getVibeProfile(this.config, vibe)); + } + + private getStepDurationSeconds(time: number): number { + const baseStepSeconds = 60 / this.config.rhythm.bpm / this.config.rhythm.stepsPerBeat; + return baseStepSeconds * this.getStartupTempoMultiplier(time); + } + + private getRhythmAnchorTime(now: number): number { + this.rhythmAnchorTime ??= now; + return this.rhythmAnchorTime; + } + + private getTapIntervalSeconds(time: number): number { + return ( + this.config.rhythm.minTapIntervalSeconds * + this.getStartupTapIntervalMultiplier(time) + ); + } + + private getSettledActivity(activity: number, time: number): number { + return Math.min(activity, this.getStartupActivityCeiling(time)); + } + + private getStartupEnergyScale(time: number): number { + return this.interpolateStartupValue( + this.config.startup.initialEnergyMultiplier, + 1, + time + ); + } + + private getStartupActivityCeiling(time: number): number { + return this.interpolateStartupValue( + this.config.startup.initialActivityCeiling, + 1, + time + ); + } + + private getStartupTempoMultiplier(time: number): number { + return this.interpolateStartupValue( + this.config.startup.initialTempoMultiplier, + 1, + time + ); + } + + private getStartupTapIntervalMultiplier(time: number): number { + return this.interpolateStartupValue( + this.config.startup.initialTapIntervalMultiplier, + 1, + time + ); + } + + private interpolateStartupValue(from: number, to: number, time: number): number { + const durationSeconds = this.config.startup.calmDurationSeconds; + if (this.startedAt === null || durationSeconds <= 0) { + return to; + } + + const progress = clamp01((time - this.startedAt) / durationSeconds); + const easedProgress = progress * progress * (3 - 2 * progress); + return from + (to - from) * easedProgress; + } +} diff --git a/src/audio/noise-burst-player.ts b/src/audio/noise-burst-player.ts new file mode 100644 index 0000000..ca68c9d --- /dev/null +++ b/src/audio/noise-burst-player.ts @@ -0,0 +1,49 @@ +import { GardenAudioGraph } from './garden-audio-graph'; +import { NoiseBurst } from './garden-audio-types'; + +export class NoiseBurstPlayer { + public constructor(private readonly graph: GardenAudioGraph) {} + + public play({ startTime, durationSeconds, gain, filterHz, pan }: NoiseBurst): void { + const { context, eventBus, noiseBuffer } = this.graph; + if (!context || !eventBus || !noiseBuffer) { + return; + } + + const scheduledStart = Math.max(context.currentTime + 0.002, startTime); + const source = context.createBufferSource(); + const filter = context.createBiquadFilter(); + const envelope = context.createGain(); + const panner = context.createStereoPanner(); + const stopAt = scheduledStart + durationSeconds; + + source.buffer = noiseBuffer; + filter.type = 'bandpass'; + filter.frequency.setValueAtTime(filterHz, scheduledStart); + filter.Q.value = 1.4; + envelope.gain.setValueAtTime(0.0001, scheduledStart); + envelope.gain.exponentialRampToValueAtTime( + Math.max(0.0001, gain), + scheduledStart + 0.004 + ); + envelope.gain.exponentialRampToValueAtTime(0.0001, stopAt); + panner.pan.setValueAtTime(pan, scheduledStart); + + source.connect(filter); + filter.connect(envelope); + envelope.connect(panner); + panner.connect(eventBus); + source.start(scheduledStart, Math.random() * 0.4); + source.stop(stopAt); + source.addEventListener( + 'ended', + () => { + source.disconnect(); + filter.disconnect(); + envelope.disconnect(); + panner.disconnect(); + }, + { once: true } + ); + } +} diff --git a/src/audio/piano-sampler.ts b/src/audio/piano-sampler.ts new file mode 100644 index 0000000..388829d --- /dev/null +++ b/src/audio/piano-sampler.ts @@ -0,0 +1,162 @@ +import { clamp, clamp01 } from '../utils/clamp'; +import { GardenAudioConfig } from './garden-audio-config'; +import { GardenAudioGraph } from './garden-audio-graph'; +import { ActivePianoVoice, LoadedPianoSample, PianoNote } from './garden-audio-types'; +import { pianoSampleDefinitions } from './piano-samples'; + +export class PianoSampler { + private sampleLoadPromise: Promise | null = null; + private samples: Array = []; + private activeVoices: Array = []; + + public constructor( + private readonly config: GardenAudioConfig, + private readonly graph: GardenAudioGraph + ) {} + + public async load(context: AudioContext): Promise { + if (this.sampleLoadPromise) { + return this.sampleLoadPromise; + } + + this.sampleLoadPromise = Promise.all( + pianoSampleDefinitions.map(async (sample) => { + const response = await fetch(sample.url); + const audioData = await response.arrayBuffer(); + const buffer = await context.decodeAudioData(audioData); + return { midi: sample.midi, buffer }; + }) + ) + .then((samples) => { + this.samples = samples.sort((a, b) => a.midi - b.midi); + }) + .catch(() => { + this.samples = []; + }); + + return this.sampleLoadPromise; + } + + public play({ + midi, + velocity, + startTime, + durationSeconds, + pan, + delaySend = 0, + lowpassHz = this.config.piano.lowpassHz, + }: PianoNote): void { + const { context, eventBus, delayInput } = this.graph; + if (!context || !eventBus || this.samples.length === 0) { + return; + } + + const sample = this.findNearestSample(midi); + if (!sample) { + return; + } + + const scheduledStart = Math.max(context.currentTime + 0.002, startTime); + const noteVelocity = clamp01(velocity); + const noteGainValue = Math.max(0.0001, this.config.piano.gain * noteVelocity); + const sustainSeconds = + this.config.piano.sustainSeconds * (0.45 + noteVelocity * 0.55); + const sustainAt = scheduledStart + Math.max(0.08, durationSeconds); + const releaseAt = sustainAt + sustainSeconds; + const releaseSeconds = this.config.piano.releaseSeconds; + const stopAt = releaseAt + releaseSeconds; + const source = context.createBufferSource(); + const filter = context.createBiquadFilter(); + const gain = context.createGain(); + const panner = context.createStereoPanner(); + let sendGain: GainNode | null = null; + + this.trimActiveVoices(scheduledStart); + while (this.activeVoices.length >= this.config.piano.maxVoices) { + const oldest = this.activeVoices.shift(); + oldest?.gain.gain.cancelScheduledValues(scheduledStart); + oldest?.gain.gain.setTargetAtTime(0.0001, scheduledStart, 0.025); + oldest?.source.stop(scheduledStart + 0.05); + } + + source.buffer = sample.buffer; + source.playbackRate.setValueAtTime( + Math.pow(2, (midi - sample.midi) / 12), + scheduledStart + ); + filter.type = 'lowpass'; + filter.frequency.setValueAtTime(clamp(lowpassHz, 1400, 12000), scheduledStart); + filter.Q.value = 0.7; + gain.gain.setValueAtTime(0.0001, scheduledStart); + gain.gain.exponentialRampToValueAtTime(noteGainValue, scheduledStart + 0.006); + gain.gain.setTargetAtTime( + Math.max(0.0001, noteGainValue * this.config.piano.sustainLevel), + sustainAt, + Math.max(0.04, sustainSeconds * 0.45) + ); + gain.gain.setTargetAtTime(0.0001, releaseAt, releaseSeconds); + panner.pan.setValueAtTime(clamp(pan, -1, 1), scheduledStart); + + source.connect(filter); + filter.connect(gain); + gain.connect(panner); + panner.connect(eventBus); + + if (delayInput && this.config.delay.enabled && delaySend > 0) { + sendGain = context.createGain(); + sendGain.gain.value = delaySend; + panner.connect(sendGain); + sendGain.connect(delayInput); + } + + source.start(scheduledStart); + source.stop(stopAt + 0.05); + this.activeVoices.push({ gain, source, stopAt }); + + source.addEventListener( + 'ended', + () => { + source.disconnect(); + filter.disconnect(); + gain.disconnect(); + panner.disconnect(); + sendGain?.disconnect(); + this.activeVoices = this.activeVoices.filter((voice) => voice.gain !== gain); + }, + { once: true } + ); + } + + public fadeActive(now: number): void { + this.activeVoices.forEach((voice) => { + voice.gain.gain.cancelScheduledValues(now); + voice.gain.gain.setTargetAtTime(0.0001, now, 0.035); + voice.stopAt = Math.min(voice.stopAt, now + 0.28); + try { + voice.source.stop(now + 0.28); + } catch { + // The source may already have a stop time scheduled. + } + }); + } + + public reset(): void { + this.sampleLoadPromise = null; + this.samples = []; + this.activeVoices = []; + } + + private findNearestSample(midi: number): LoadedPianoSample | null { + if (this.samples.length === 0) { + return null; + } + + return this.samples.reduce((nearest, sample) => + Math.abs(sample.midi - midi) < Math.abs(nearest.midi - midi) ? sample : nearest + ); + } + + private trimActiveVoices(now: number): void { + this.activeVoices = this.activeVoices.filter((voice) => voice.stopAt > now); + } +}