Getting there

This commit is contained in:
Andras Schmelczer 2026-05-22 07:54:38 +01:00
commit f300dbd394
43 changed files with 1213 additions and 459 deletions

View file

@ -1,9 +1,11 @@
import { DEFAULT_AUDIO_VOLUME } from '../consts';
import type { PianoNoteRole } from './garden-audio-types';
type GardenAudioChordQuality = 'major' | 'minor' | 'sus2' | 'sus4';
export interface GardenAudioChord {
rootOffset: number;
quality: 'major' | 'minor';
quality: GardenAudioChordQuality;
}
export interface GardenAudioVibeSettings {
@ -14,6 +16,8 @@ export interface GardenAudioVibeSettings {
noteLength: number;
notePitchOffset: number;
brightness: number;
scale?: Array<number>;
progression?: Array<GardenAudioChord>;
}
export interface GardenAudioVibeProfile extends GardenAudioVibeSettings {
@ -37,7 +41,9 @@ export const createGardenAudioConfig = () => ({
fadeInSeconds: 0.45,
updateRampSeconds: 0.08,
delay: {
timeSeconds: 0.405,
timeBeats: 0.5,
timeMinSeconds: 0.18,
timeMaxSeconds: 0.72,
feedback: 0.12,
wetGain: 0.044,
erasingActivity: 0.12,

View file

@ -121,19 +121,19 @@ export class GardenAudioGraph {
);
}
public applyDelayProfile(): void {
public applyDelayProfile(bpm: number): void {
if (!this.context || !this.delayNode) {
return;
}
this.delayNode.delayTime.setTargetAtTime(
this.config.delay.timeSeconds,
this.getDelayTimeSecondsForBpm(bpm),
this.context.currentTime,
this.config.delay.timeRampSeconds
);
}
public updateDelay(activity: number): void {
public updateDelay(activity: number, bpm: number): void {
if (!this.context || !this.delayNode || !this.delayFeedback || !this.delayOutput) {
return;
}
@ -141,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,
this.getDelayTimeSecondsForBpm(bpm),
now,
this.config.delay.timeRampSeconds
);
@ -214,7 +214,7 @@ export class GardenAudioGraph {
const feedbackLowPass = context.createBiquadFilter();
const returnLowPass = context.createBiquadFilter();
delayNode.delayTime.value = this.config.delay.timeSeconds;
delayNode.delayTime.value = this.getDelayTimeSecondsForBpm(this.config.rhythm.bpm);
delayFeedback.gain.value = this.config.delay.feedback;
delayOutput.gain.value = this.config.delay.wetGain;
feedbackHighPass.type = 'highpass';
@ -283,6 +283,15 @@ export class GardenAudioGraph {
});
}
private getDelayTimeSecondsForBpm(bpm: number): number {
const safeBpm = Number.isFinite(bpm) ? Math.max(1, bpm) : this.config.rhythm.bpm;
return clamp(
(60 / safeBpm) * this.config.delay.timeBeats,
this.config.delay.timeMinSeconds,
this.config.delay.timeMaxSeconds
);
}
private createNoiseBuffer(context: AudioContext): AudioBuffer {
const buffer = context.createBuffer(
1,

View file

@ -15,14 +15,24 @@ const DEFAULT_SCALE: ReadonlyArray<number> = [0, 2, 4, 7, 9];
const profileCache = new WeakMap<VibePreset, GardenAudioVibeProfile>();
const getProfileScale = (vibe: VibePreset): Array<number> => {
const scale = vibe.audio.scale?.length ? vibe.audio.scale : DEFAULT_SCALE;
return [...scale];
};
const getProfileProgression = (vibe: VibePreset): Array<GardenAudioChord> =>
(vibe.audio.progression?.length ? vibe.audio.progression : DEFAULT_PROGRESSION).map(
(chord) => ({ ...chord })
);
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>,
scale: getProfileScale(vibe),
progression: getProfileProgression(vibe),
};
profileCache.set(vibe, profile);
return profile;
@ -30,5 +40,7 @@ export const getVibeProfile = (vibe: VibePreset): GardenAudioVibeProfile => {
Object.assign(profile, vibe.audio);
profile.rootMidi = DEFAULT_ROOT_MIDI + vibe.audio.notePitchOffset;
profile.scale = getProfileScale(vibe);
profile.progression = getProfileProgression(vibe);
return profile;
};

View file

@ -1,7 +1,7 @@
import { ErrorHandler, Severity } from '../utils/error-handler';
import { clamp01 } from '../utils/math';
import type { VibeId, VibePreset } from '../vibes';
import type { GardenAudioConfig } from './garden-audio-config';
import type { GardenAudioConfig, GardenAudioVibeProfile } from './garden-audio-config';
import { GardenAudioEnergy } from './garden-audio-energy';
import { GardenAudioGestureState } from './garden-audio-gesture-state';
import { GardenAudioGraph } from './garden-audio-graph';
@ -155,11 +155,12 @@ export class GardenAudio {
): void {
this.lifecycle = 'started';
this.currentVibeId = vibe.id;
this.graph.applyDelayProfile();
const profile = getVibeProfile(vibe);
this.graph.applyDelayProfile(profile.bpm);
this.graph.setMasterGain(this.masterVolume, startupRampSeconds);
if (cuePiano) {
this.pianoEngine.cue(context.currentTime, getVibeProfile(vibe));
this.pianoEngine.cue(context.currentTime, profile);
}
}
@ -245,7 +246,7 @@ export class GardenAudio {
if (!this.isGestureActive && this.isReleasingPiano) {
this.updatePianoRelease(snapshot.vibe, context.currentTime);
this.updateDelay(snapshot);
this.updateDelay(snapshot, profile);
return;
}
@ -256,7 +257,7 @@ export class GardenAudio {
? this.config.eraser.pianoActivity
: this.energy.getLevel(),
});
this.updateDelay(snapshot);
this.updateDelay(snapshot, profile);
}
public stroke(stroke: GardenAudioStroke): void {
@ -371,7 +372,10 @@ export class GardenAudio {
}
}
private updateDelay(snapshot: GardenAudioSnapshot): void {
private updateDelay(
snapshot: GardenAudioSnapshot,
profile: GardenAudioVibeProfile
): void {
const context = this.graph.context;
if (!context) {
return;
@ -380,7 +384,7 @@ export class GardenAudio {
const activity = snapshot.isErasing
? this.config.delay.erasingActivity
: this.energy.getLevel();
this.graph.updateDelay(activity);
this.graph.updateDelay(activity, profile.bpm);
}
private applyVibe(vibe: VibePreset): void {
@ -390,7 +394,7 @@ export class GardenAudio {
this.currentVibeId = vibe.id;
const profile = getVibeProfile(vibe);
this.graph.applyDelayProfile();
this.graph.applyDelayProfile(profile.bpm);
this.pianoEngine.cue(this.graph.context.currentTime, profile);
}
}

View file

@ -152,6 +152,12 @@ interface GenerativePianoTuning {
min: number;
max: number;
};
stereoWidth: {
idle: number;
active: number;
intense: number;
intenseThreshold: number;
};
stylePanOffsetScale: number;
lowpass: {
midiBase: number;
@ -371,6 +377,12 @@ export const generativePianoTuning: GenerativePianoTuning = {
min: -3,
max: 3,
},
stereoWidth: {
idle: 0.46,
active: 0.9,
intense: 1.16,
intenseThreshold: 0.72,
},
stylePanOffsetScale: 0.35,
lowpass: {
midiBase: 48,

View file

@ -18,23 +18,34 @@ import { PIANO_SCHEDULE_AHEAD_SECONDS } from './piano-sampler';
const GENERATIVE_LOOKAHEAD_SECONDS = 0.3;
const GENERATIVE_START_DELAY_SECONDS = 0.02;
const chordVoicings = {
majorOpen: [0, 7, 12, 16],
minorOpen: [0, 7, 12, 15],
majorClosed: [0, 4, 7, 12, 16],
minorClosed: [0, 3, 7, 12, 15],
const chordVoicings: Record<
GardenAudioChord['quality'],
{ closed: Array<number>; open: Array<number> }
> = {
major: {
closed: [0, 4, 7, 12, 16],
open: [0, 7, 12, 16],
},
minor: {
closed: [0, 3, 7, 12, 15],
open: [0, 7, 12, 15],
},
sus2: {
closed: [0, 2, 7, 12, 14],
open: [0, 7, 12, 14],
},
sus4: {
closed: [0, 5, 7, 12, 17],
open: [0, 7, 12, 17],
},
};
const getChordIntervals = (
chord: GardenAudioChord,
openVoicing: boolean
): Array<number> => {
if (openVoicing) {
return chord.quality === 'major' ? chordVoicings.majorOpen : chordVoicings.minorOpen;
}
return chord.quality === 'major'
? chordVoicings.majorClosed
: chordVoicings.minorClosed;
const voicing = chordVoicings[chord.quality];
return openVoicing ? voicing.open : voicing.closed;
};
const degreeToSemitone = (profile: GardenAudioVibeProfile, degree: number): number => {
@ -406,7 +417,7 @@ export class GenerativePianoEngine {
velocity + expression * generativePianoTuning.padChord.expressionVelocityWeight,
startTime,
durationSeconds,
pan: register.pan,
pan: this.getActivityPan(register.pan, expression),
role: 'pad',
delaySend: generativePianoTuning.padChord.delaySend,
lowpassHz: this.getLowpassHz(
@ -446,7 +457,7 @@ export class GenerativePianoEngine {
velocity: release.velocities[index],
startTime: startTime + index * release.strumSeconds,
durationSeconds: release.durationSeconds,
pan: register.pan,
pan: this.getActivityPan(register.pan, 0),
role: 'pad',
delaySend: release.delaySend,
lowpassHz: this.getLowpassHz(profile, midi, release.lowpassExpression),
@ -487,7 +498,7 @@ export class GenerativePianoEngine {
durationSeconds:
generativePianoTuning.supportNote.durationBaseSeconds +
expression * generativePianoTuning.supportNote.durationExpressionSeconds,
pan: this.getStylePan(styleIndex),
pan: this.getStylePan(styleIndex, expression),
role: 'support',
delaySend:
generativePianoTuning.supportNote.delaySendBase +
@ -533,7 +544,7 @@ export class GenerativePianoEngine {
durationSeconds:
generativePianoTuning.textureNote.durationBaseSeconds +
expression * generativePianoTuning.textureNote.durationExpressionSeconds,
pan: this.getStylePan(styleIndex),
pan: this.getStylePan(styleIndex, expression),
role: 'texture',
delaySend:
generativePianoTuning.textureNote.delaySendBase +
@ -582,7 +593,7 @@ export class GenerativePianoEngine {
durationSeconds:
generativePianoTuning.gestureAccent.durationBaseSeconds +
strength * generativePianoTuning.gestureAccent.durationStrengthSeconds,
pan: this.getStylePan(styleIndex),
pan: this.getStylePan(styleIndex, strength),
role: 'gesture',
delaySend: generativePianoTuning.gestureAccent.delaySend,
lowpassHz: this.getLowpassHz(profile, midi, strength),
@ -627,7 +638,7 @@ export class GenerativePianoEngine {
durationSeconds:
generativePianoTuning.touchNote.durationBaseSeconds +
strength * generativePianoTuning.touchNote.durationStrengthSeconds,
pan: this.getStylePan(styleIndex),
pan: this.getStylePan(styleIndex, strength),
role: 'gesture',
delaySend: generativePianoTuning.touchNote.delaySend,
lowpassHz: this.getLowpassHz(
@ -813,7 +824,7 @@ export class GenerativePianoEngine {
chordOffsets: this.getChordOffsets(chord, chordIntervals),
};
const midi = this.chooseMidi(source, register, this.lastBrushStreamMidi, true);
const pan = this.getStylePan(styleIndex);
const pan = this.getStylePan(styleIndex, intensity);
const durationSeconds = clamp(
generativePianoTuning.brushStream.durationBaseSeconds +
intensity * generativePianoTuning.brushStream.durationIntensitySeconds -
@ -1128,16 +1139,29 @@ export class GenerativePianoEngine {
styleCount) as GardenAudioStyleIndex;
}
private getStylePan(styleIndex: GardenAudioStyleIndex): number {
private getStylePan(styleIndex: GardenAudioStyleIndex, activity: number): number {
const pool = generativePianoTuning.stylePools[styleIndex];
const styleVoice = styleVoices[styleIndex];
return clamp(
return this.getActivityPan(
pool.pan + styleVoice.panOffset * generativePianoTuning.stylePanOffsetScale,
-1,
1
activity
);
}
private getActivityPan(pan: number, activity: number): number {
const { active, idle, intense, intenseThreshold } = generativePianoTuning.stereoWidth;
const normalizedActivity = clamp01(activity);
const safeThreshold = clamp(intenseThreshold, 0.001, 0.999);
const width =
normalizedActivity < safeThreshold
? idle + ((active - idle) * normalizedActivity) / safeThreshold
: active +
((intense - active) * (normalizedActivity - safeThreshold)) /
(1 - safeThreshold);
return clamp(pan * width, -1, 1);
}
private getLowpassHz(
profile: GardenAudioVibeProfile,
midi: number,