More clean up

This commit is contained in:
Andras Schmelczer 2026-05-20 21:03:41 +01:00
commit f03da42b5e
43 changed files with 818 additions and 1076 deletions

View file

@ -1,3 +1,4 @@
import { DEFAULT_AUDIO_VOLUME } from '../app-constants';
import type { PianoNoteRole } from './garden-audio-types';
type GardenAudioChordQuality = 'major' | 'minor';
@ -7,81 +8,106 @@ export interface GardenAudioChord {
quality: GardenAudioChordQuality;
}
export interface GardenAudioVibeProfile {
export interface GardenAudioVibeSettings {
idleIntensity: number;
bpm: number;
rampUpIntensity: number;
rampUpTime: number;
noteLength: number;
notePitchOffset: number;
brightness: number;
}
export interface GardenAudioVibeProfile extends GardenAudioVibeSettings {
rootMidi: number;
scale: Array<number>;
brightness: number;
delayTimeMultiplier: number;
progression: Array<GardenAudioChord>;
}
export interface GardenAudioConfig {
masterVolume: number;
fadeInSeconds: number;
updateRampSeconds: number;
export const createGardenAudioConfig = () => ({
masterVolume: DEFAULT_AUDIO_VOLUME,
fadeInSeconds: 0.45,
updateRampSeconds: 0.08,
delay: {
timeSeconds: number;
feedback: number;
wetGain: number;
erasingActivity: number;
activityFeedbackWeight: number;
feedbackMax: number;
feedbackMin: number;
outputActivityWeight: number;
outputBase: number;
outputActivityDuck: number;
timeRampSeconds: number;
};
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,
outputActivityDuck: 0.28,
timeRampSeconds: 0.12,
},
piano: {
maxVoices: number;
gain: number;
sustainSeconds: number;
sustainLevel: number;
releaseSeconds: number;
lowpassHz: number;
gainAttackSeconds: number;
lowpassMaxHz: number;
lowpassMinHz: number;
sustainBase: number;
sustainVelocityRange: number;
};
maxVoices: 24,
gain: 0.48,
sustainSeconds: 0.42,
sustainLevel: 0.32,
releaseSeconds: 0.24,
lowpassHz: 7600,
gainAttackSeconds: 0.006,
lowpassMaxHz: 12000,
lowpassMinHz: 1400,
sustainBase: 0.45,
sustainVelocityRange: 0.55,
},
rhythm: {
bpm: number;
stepsPerBeat: number;
stepsPerBar: number;
sparseActivity: number;
};
idleIntensity: 0.08,
bpm: 74,
stepsPerBeat: 4,
stepsPerBar: 16,
sparseActivity: 0.055,
},
eraser: {
minIntervalSeconds: number;
noiseGain: number;
filterMinHz: number;
filterMaxHz: number;
durationSeconds: number;
pan: number;
pianoActivity: number;
};
minIntervalSeconds: 0.12,
noiseGain: 0.028,
filterMinHz: 650,
filterMaxHz: 3600,
durationSeconds: 0.08,
pan: 0,
pianoActivity: 0,
},
energy: {
attackSeconds: number;
decaySeconds: number;
immediateActivityScale: number;
releaseSeconds: number;
strokeDecaySeconds: number;
};
attackSeconds: 0.08,
decaySeconds: 0.9,
immediateActivityScale: 0.85,
releaseSeconds: 1.15,
strokeDecaySeconds: 0.32,
},
graph: {
pianoBusGains: Record<PianoNoteRole, number>;
pianoBusActivityDucking: Record<PianoNoteRole, number>;
noiseBusGain: number;
};
pianoBusGains: {
pad: 0.86,
support: 0.94,
texture: 0.88,
gesture: 1,
brush: 0.9,
stinger: 0.92,
} satisfies Record<PianoNoteRole, number>,
pianoBusActivityDucking: {
pad: 0.42,
support: 0.18,
texture: -0.06,
gesture: 0,
brush: -0.08,
stinger: 0,
} satisfies Record<PianoNoteRole, number>,
noiseBusGain: 0.72,
},
input: {
fullActivitySpeed: number;
activityNoiseFloorSpeed: number;
activityCurve: number;
activitySoftCeiling: number;
activityAttackSeconds: number;
activityReleaseSeconds: number;
minAudibleDistance: number;
manicActivityThreshold: number;
manicReleaseThreshold: number;
maniaSmoothingSeconds: number;
};
}
fullActivitySpeed: 0.86,
activityNoiseFloorSpeed: 0.025,
activityCurve: 0.74,
activitySoftCeiling: 0.96,
activityAttackSeconds: 0.055,
activityReleaseSeconds: 0.2,
minAudibleDistance: 0.0025,
manicActivityThreshold: 0.9,
manicReleaseThreshold: 0.76,
maniaSmoothingSeconds: 0.12,
},
});
export type GardenAudioConfig = ReturnType<typeof createGardenAudioConfig>;

View file

@ -1,5 +1,5 @@
import { approach, clamp01 } from '../utils/math';
import type { GardenAudioConfig } from './garden-audio-config';
import type { GardenAudioConfig, GardenAudioVibeProfile } from './garden-audio-config';
export class GardenAudioEnergy {
private isGestureActive = false;
@ -19,13 +19,10 @@ export class GardenAudioEnergy {
this.targetEnergy = 0;
}
public recordStroke(strokeEnergy: number): void {
public recordStroke(strokeEnergy: number, profile: GardenAudioVibeProfile): void {
this.targetEnergy = Math.max(this.targetEnergy, strokeEnergy);
if (this.isGestureActive) {
this.energy = Math.max(
this.energy,
strokeEnergy * this.config.energy.immediateActivityScale
);
this.energy = Math.max(this.energy, strokeEnergy * profile.rampUpIntensity);
}
}
@ -34,7 +31,7 @@ export class GardenAudioEnergy {
this.energy = 0;
}
public update(now: number): void {
public update(now: number, profile: GardenAudioVibeProfile): void {
if (this.lastEnergyUpdateAt <= 0) {
this.lastEnergyUpdateAt = now;
return;
@ -51,7 +48,7 @@ export class GardenAudioEnergy {
if (!this.isGestureActive) {
timeConstant = this.config.energy.releaseSeconds;
} else if (target > this.energy) {
timeConstant = this.config.energy.attackSeconds;
timeConstant = profile.rampUpTime;
}
this.energy = approach(this.energy, target, elapsedSeconds, timeConstant);
}

View file

@ -1,9 +1,17 @@
import { clamp } from '../utils/math';
import type { GardenAudioConfig, GardenAudioVibeProfile } from './garden-audio-config';
import type { GardenAudioConfig } from './garden-audio-config';
import type { PianoNoteRole } from './garden-audio-types';
type NavigatorWithAudioSession = Navigator & {
audioSession?: { type: 'auto' | 'playback' | 'ambient' | 'transient' | 'transient-solo' | 'play-and-record' };
audioSession?: {
type:
| 'auto'
| 'playback'
| 'ambient'
| 'transient'
| 'transient-solo'
| 'play-and-record';
};
};
const outputHighPassFrequencyHz = 45;
@ -113,19 +121,19 @@ export class GardenAudioGraph {
);
}
public applyDelayProfile(profile: GardenAudioVibeProfile): void {
public applyDelayProfile(): void {
if (!this.context || !this.delayNode) {
return;
}
this.delayNode.delayTime.setTargetAtTime(
this.config.delay.timeSeconds * profile.delayTimeMultiplier,
this.config.delay.timeSeconds,
this.context.currentTime,
this.config.delay.timeRampSeconds
);
}
public updateDelay(profile: GardenAudioVibeProfile, activity: number): void {
public updateDelay(activity: number): void {
if (!this.context || !this.delayNode || !this.delayFeedback || !this.delayOutput) {
return;
}
@ -133,7 +141,7 @@ export class GardenAudioGraph {
const now = this.context.currentTime;
const normalizedActivity = clamp(activity, 0, 1);
this.delayNode.delayTime.setTargetAtTime(
this.config.delay.timeSeconds * profile.delayTimeMultiplier,
this.config.delay.timeSeconds,
now,
this.config.delay.timeRampSeconds
);

View file

@ -1,6 +1,34 @@
import type { VibePreset } from '../vibes';
import type { GardenAudioVibeProfile } from './garden-audio-config';
import type { GardenAudioChord, GardenAudioVibeProfile } from './garden-audio-config';
export const PITCH_SEMITONES_PER_OCTAVE = 12;
export const getVibeProfile = (vibe: VibePreset): GardenAudioVibeProfile => vibe.audio;
const DEFAULT_PROGRESSION: ReadonlyArray<GardenAudioChord> = [
{ rootOffset: 0, quality: 'major' },
{ rootOffset: 9, quality: 'minor' },
{ rootOffset: 5, quality: 'major' },
{ rootOffset: 7, quality: 'major' },
];
const DEFAULT_ROOT_MIDI = 57;
const DEFAULT_SCALE: ReadonlyArray<number> = [0, 2, 4, 7, 9];
const profileCache = new WeakMap<VibePreset, GardenAudioVibeProfile>();
export const getVibeProfile = (vibe: VibePreset): GardenAudioVibeProfile => {
let profile = profileCache.get(vibe);
if (!profile) {
profile = {
...vibe.audio,
rootMidi: DEFAULT_ROOT_MIDI + vibe.audio.notePitchOffset,
scale: DEFAULT_SCALE as Array<number>,
progression: DEFAULT_PROGRESSION as Array<GardenAudioChord>,
};
profileCache.set(vibe, profile);
return profile;
}
Object.assign(profile, vibe.audio);
profile.rootMidi = DEFAULT_ROOT_MIDI + vibe.audio.notePitchOffset;
return profile;
};

View file

@ -32,6 +32,7 @@ export interface PianoNote {
role?: PianoNoteRole;
delaySend?: number;
lowpassHz?: number;
sustainSeconds?: number;
}
export type PianoNoteRole =

View file

@ -114,7 +114,7 @@ export class GardenAudio {
this.lifecycle = 'started';
this.applyVibe(vibe);
this.pianoEngine.prime(context.currentTime);
this.pianoEngine.prime(context.currentTime, getVibeProfile(vibe));
this.graph.setMasterGain(this.masterVolume, startupRampSeconds);
const pianoLoad = this.piano.loadIfIdle(context);
@ -122,7 +122,7 @@ export class GardenAudio {
void pianoLoad
.then(() => {
if (this.graph.context === context && this.lifecycle !== 'destroyed') {
this.pianoEngine.cue(context.currentTime);
this.pianoEngine.cue(context.currentTime, getVibeProfile(vibe));
}
})
.catch((error) => {
@ -207,7 +207,8 @@ export class GardenAudio {
}
this.applyVibe(snapshot.vibe);
this.energy.update(context.currentTime);
const profile = getVibeProfile(snapshot.vibe);
this.energy.update(context.currentTime, profile);
if (snapshot.isErasing) {
this.energy.silence();
@ -254,7 +255,8 @@ export class GardenAudio {
return;
}
this.energy.recordStroke(strokeEnergy);
const profile = getVibeProfile(stroke.vibe);
this.energy.recordStroke(strokeEnergy, profile);
this.pianoEngine.recordStroke({
vibe: stroke.vibe,
now,
@ -347,11 +349,10 @@ export class GardenAudio {
return;
}
const profile = getVibeProfile(snapshot.vibe);
const activity = snapshot.isErasing
? this.config.delay.erasingActivity
: this.energy.getLevel();
this.graph.updateDelay(profile, activity);
this.graph.updateDelay(activity);
}
private applyVibe(vibe: VibePreset): void {
@ -360,7 +361,8 @@ export class GardenAudio {
}
this.currentVibeId = vibe.id;
this.graph.applyDelayProfile(getVibeProfile(vibe));
this.pianoEngine.cue(this.graph.context.currentTime);
const profile = getVibeProfile(vibe);
this.graph.applyDelayProfile();
this.pianoEngine.cue(this.graph.context.currentTime, profile);
}
}

View file

@ -37,10 +37,7 @@ const getChordIntervals = (
: chordVoicings.minorClosed;
};
const degreeToSemitone = (
profile: GardenAudioVibeProfile,
degree: number
): number => {
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);
@ -96,6 +93,7 @@ interface BrushPhraseLayer {
export class GenerativePianoEngine {
private nextBeatStep: number | null = null;
private timelineStartedAt: number | null = null;
private activeProfile: GardenAudioVibeProfile | null = null;
private isWaitingForGestureAccent = false;
private lastGestureAccentAt = Number.NEGATIVE_INFINITY;
private lastStrokeAccentStep = Number.NEGATIVE_INFINITY;
@ -124,13 +122,17 @@ export class GenerativePianoEngine {
return generativePianoTuning;
}
public prime(now: number): void {
public prime(now: number, profile: GardenAudioVibeProfile): void {
this.activeProfile = profile;
this.timelineStartedAt ??= now;
this.nextBeatStep ??= 0;
this.nextBrushStreamStep ??= 0;
}
public cue(now: number): void {
public cue(now: number, profile?: GardenAudioVibeProfile): void {
if (profile) {
this.activeProfile = profile;
}
this.nextBeatStep = 0;
this.timelineStartedAt = now;
this.nextBrushStreamStep = 0;
@ -148,10 +150,9 @@ export class GenerativePianoEngine {
}
public release(vibe: VibePreset, now: number): number {
this.prime(now);
this.isWaitingForGestureAccent = false;
const profile = getVibeProfile(vibe);
this.prime(now, profile);
this.isWaitingForGestureAccent = false;
const releaseStep = this.getReleaseResolutionStep(now);
const releaseStart = Math.max(now, this.getTimeForStep(releaseStep));
@ -198,7 +199,8 @@ export class GenerativePianoEngine {
activity,
maniaAmount = 0,
}: StrokeAccentRequest): void {
this.prime(now);
const profile = getVibeProfile(vibe);
this.prime(now, profile);
const strength = clamp01(activity);
const normalizedManiaAmount = clamp01(maniaAmount);
const styleIndex = this.getStyleIndex(now);
@ -242,14 +244,14 @@ export class GenerativePianoEngine {
activity,
lookaheadSeconds = GENERATIVE_LOOKAHEAD_SECONDS,
}: RenderLookaheadRequest): void {
this.prime(now);
const profile = getVibeProfile(vibe);
this.prime(now, profile);
this.skipLateBeats(now);
if (this.nextBeatStep === null) {
return;
}
const profile = getVibeProfile(vibe);
const lookaheadEnd = now + lookaheadSeconds;
while (this.getTimeForStep(this.nextBeatStep) <= lookaheadEnd) {
const beatIndex = this.getBeatIndexForStep(this.nextBeatStep);
@ -286,7 +288,7 @@ export class GenerativePianoEngine {
{ baseMidi: rootMidi, offsets },
this.generation.padRegisters[index]
);
this.playNote({
this.playProfileNote(profile, {
midi,
velocity: stinger.velocities[index],
pan: stinger.pans[index],
@ -302,6 +304,7 @@ export class GenerativePianoEngine {
public reset(): void {
this.nextBeatStep = null;
this.timelineStartedAt = null;
this.activeProfile = null;
this.isWaitingForGestureAccent = false;
this.lastGestureAccentAt = Number.NEGATIVE_INFINITY;
this.lastStrokeAccentStep = Number.NEGATIVE_INFINITY;
@ -314,6 +317,13 @@ export class GenerativePianoEngine {
this.brushStreamNoteCountsByBar.clear();
}
private playProfileNote(profile: GardenAudioVibeProfile, note: PianoNote): void {
this.playNote({
...note,
sustainSeconds: profile.noteLength,
});
}
private renderBeat({
profile,
beatIndex,
@ -398,7 +408,7 @@ export class GenerativePianoEngine {
false
);
this.lastPadMidiByVoice[index] = midi;
this.playNote({
this.playProfileNote(profile, {
midi,
velocity:
velocity + expression * this.generation.padChord.expressionVelocityWeight,
@ -433,8 +443,13 @@ export class GenerativePianoEngine {
offsetsByVoice.forEach((offsets, index) => {
const register = this.generation.padRegisters[index];
const midi = this.chooseMidi({ baseMidi: rootMidi, offsets }, register, null, false);
this.playNote({
const midi = this.chooseMidi(
{ baseMidi: rootMidi, offsets },
register,
null,
false
);
this.playProfileNote(profile, {
midi,
velocity: release.velocities[index],
startTime: startTime + index * release.strumSeconds,
@ -470,7 +485,7 @@ export class GenerativePianoEngine {
);
this.lastMidiByStyle[styleIndex] = midi;
this.playNote({
this.playProfileNote(profile, {
midi,
velocity:
(this.generation.supportNote.velocityBase +
@ -516,7 +531,7 @@ export class GenerativePianoEngine {
);
this.lastMidiByStyle[styleIndex] = midi;
this.playNote({
this.playProfileNote(profile, {
midi,
velocity:
(this.generation.textureNote.velocityBase +
@ -563,7 +578,7 @@ export class GenerativePianoEngine {
);
this.lastMidiByStyle[styleIndex] = midi;
this.playNote({
this.playProfileNote(profile, {
midi,
velocity:
(this.generation.gestureAccent.velocityBase +
@ -608,7 +623,7 @@ export class GenerativePianoEngine {
this.lastMidiByStyle[styleIndex] = midi;
this.lastBrushStreamMidi = midi;
this.playNote({
this.playProfileNote(profile, {
midi,
velocity:
(this.generation.touchNote.velocityBase +
@ -818,7 +833,7 @@ export class GenerativePianoEngine {
this.lastBrushStreamMidi = midi;
this.lastMidiByStyle[styleIndex] = midi;
this.playNote({
this.playProfileNote(profile, {
midi,
velocity:
(this.generation.brushStream.velocityBase +
@ -851,7 +866,7 @@ export class GenerativePianoEngine {
this.generation.brushStreamEcho.maxMidi
? midi + this.generation.brushStreamEcho.octaveSemitones
: midi - this.generation.brushStreamEcho.octaveSemitones;
this.playNote({
this.playProfileNote(profile, {
midi: echoMidi,
velocity:
(this.generation.brushStreamEcho.velocityBase +
@ -1159,14 +1174,18 @@ export class GenerativePianoEngine {
}
private getExpression(activity: number): number {
const liftedActivity = Math.max(
activity,
this.activeProfile?.idleIntensity ?? this.config.rhythm.idleIntensity
);
return clamp01(
(activity - this.config.rhythm.sparseActivity) /
(liftedActivity - this.config.rhythm.sparseActivity) /
(1 - this.config.rhythm.sparseActivity)
);
}
private getBeatDurationSeconds(): number {
return 60 / this.config.rhythm.bpm;
return 60 / (this.activeProfile?.bpm ?? this.config.rhythm.bpm);
}
private getStepDurationSeconds(): number {

View file

@ -72,6 +72,7 @@ export class PianoSampler {
role,
delaySend = 0,
lowpassHz = this.config.piano.lowpassHz,
sustainSeconds: profileSustainSeconds = this.config.piano.sustainSeconds,
}: PianoNote): void {
const { context } = this.graph;
const eventBus = this.graph.getPianoBus(role);
@ -92,12 +93,11 @@ export class PianoSampler {
this.config.piano.gain * noteVelocity
);
const sustainSeconds =
this.config.piano.sustainSeconds *
profileSustainSeconds *
(this.config.piano.sustainBase +
noteVelocity * this.config.piano.sustainVelocityRange);
const sustainAt =
scheduledStart +
Math.max(pianoSamplerTuning.minDurationSeconds, durationSeconds);
scheduledStart + Math.max(pianoSamplerTuning.minDurationSeconds, durationSeconds);
const releaseAt = sustainAt + sustainSeconds;
const stopAt = releaseAt + this.config.piano.releaseSeconds;
const source = context.createBufferSource();
@ -150,7 +150,7 @@ export class PianoSampler {
const releaseAt =
scheduledStart +
clamp(
durationSeconds + this.config.piano.sustainSeconds * 0.5,
durationSeconds + profileSustainSeconds * 0.5,
pianoSamplerTuning.minDurationSeconds,
pianoSamplerTuning.synthMaxDurationSeconds
);