This commit is contained in:
Andras Schmelczer 2026-05-16 16:15:54 +01:00
commit d2da0d1617
25 changed files with 521 additions and 1026 deletions

View file

@ -1,9 +1,10 @@
import type { GardenAudioEngineConfig } from '../config';
import { clamp, clamp01 } from '../utils/clamp';
import { VibePreset } from '../vibes';
import {
GardenAudioChord,
GardenAudioColorPool,
GardenAudioConfig,
GardenAudioRegister,
GardenAudioVibeProfile,
} from './garden-audio-config';
import {
@ -51,17 +52,6 @@ interface TouchDownRequest {
maniaAmount?: number;
}
interface Register {
midiMin: number;
midiMax: number;
preferredMidi: number;
pan: number;
}
interface ColorPool extends Register {
scaleDegrees: ReadonlyArray<number>;
}
interface PitchCandidate {
midi: number;
preference: number;
@ -89,81 +79,6 @@ interface BrushPhraseLayer {
maniaAmount: number;
}
const COLOR_POOLS: [ColorPool, ColorPool, ColorPool] = [
{
midiMin: 48,
midiMax: 67,
preferredMidi: 55,
pan: -0.18,
scaleDegrees: [0, 1, 2, 4],
},
{
midiMin: 55,
midiMax: 74,
preferredMidi: 63,
pan: 0,
scaleDegrees: [1, 2, 3, 5],
},
{
midiMin: 62,
midiMax: 81,
preferredMidi: 72,
pan: 0.18,
scaleDegrees: [2, 3, 4, 6],
},
];
const PAD_REGISTERS: [Register, Register, Register] = [
{
midiMin: 40,
midiMax: 55,
preferredMidi: 48,
pan: -0.12,
},
{
midiMin: 48,
midiMax: 64,
preferredMidi: 55,
pan: 0.08,
},
{
midiMin: 58,
midiMax: 76,
preferredMidi: 67,
pan: 0.2,
},
];
const CHORD_BARS = 4;
const SUPPORT_BAR_SPACING = 2;
const SUPPORT_BAR_OFFSET = 1;
const IDLE_TEXTURE_BAR_SPACING = 2;
const MEDIUM_TEXTURE_BAR_SPACING = 1;
const TEXTURE_BEAT = 2;
const HIGH_ACTIVITY_EXTRA_BEAT = 3;
const HIGH_ACTIVITY_EXTRA_THRESHOLD = 0.45;
const NOTE_SCORE_PREFERENCE_WEIGHT = 1.8;
const NOTE_SCORE_REGISTER_WEIGHT = 0.28;
const NOTE_SCORE_REPEAT_PENALTY = 3.2;
const GESTURE_ACCENT_SPACING_SECONDS = 0.26;
const GESTURE_ACCENT_MIN_INTERVAL_SECONDS = 2.5;
const STROKE_ACCENT_MIN_INTERVAL_SECONDS = 3.2;
const STROKE_ACCENT_THRESHOLD = 0.58;
const STINGER_SPACING_SECONDS = 0.08;
const STINGER_DURATION_SECONDS = 1.1;
const MAX_BRUSH_PHRASE_LAYERS = 5;
const BRUSH_LAYER_BASE_SECONDS = 5.5;
const BRUSH_LAYER_ENERGY_SECONDS = 2.5;
const BRUSH_LAYER_MIRROR_SECONDS = 3;
const BRUSH_LAYER_MIN_INTENSITY = 0.08;
const BRUSH_STREAM_IDLE_INTERVAL_BEATS = 2;
const BRUSH_STREAM_ACTIVE_INTERVAL_BEATS = 1;
const BRUSH_STREAM_INTENSE_INTERVAL_BEATS = 0.5;
const BRUSH_STREAM_MANIC_INTERVAL_BEATS = 0.25;
const BRUSH_MOTIF_MAX_STEPS = 8;
const BRUSH_MOTIF_CANON_DELAY_SECONDS = 0.055;
const PAD_DURATION_BAR_SCALE = 0.46;
export class GenerativePianoEngine {
private nextBeatAt: number | null = null;
private timelineStartedAt: number | null = null;
@ -183,23 +98,26 @@ export class GenerativePianoEngine {
public constructor(
private readonly config: GardenAudioConfig,
private readonly engineConfig: GardenAudioEngineConfig,
private readonly playNote: (note: PianoNote) => void
) {}
private get generation(): GardenAudioConfig['generativePiano'] {
return this.config.generativePiano;
}
public prime(now: number): void {
if (this.nextBeatAt === null) {
this.nextBeatAt = now + this.engineConfig.startDelaySeconds;
this.nextBeatAt = now + this.config.startDelaySeconds;
}
this.timelineStartedAt ??= now;
this.nextBrushStreamAt ??= now + this.engineConfig.startDelaySeconds;
this.nextBrushStreamAt ??= now + this.config.startDelaySeconds;
}
public cue(now: number): void {
this.nextBeatAt = now + this.engineConfig.startDelaySeconds;
this.nextBeatAt = now + this.config.startDelaySeconds;
this.timelineStartedAt = now;
this.beatIndex = 0;
this.nextBrushStreamAt = now + this.engineConfig.startDelaySeconds;
this.nextBrushStreamAt = now + this.config.startDelaySeconds;
this.brushStreamNoteIndex = 0;
this.lastBrushStreamMidi = null;
}
@ -288,7 +206,7 @@ export class GenerativePianoEngine {
if (
this.isWaitingForGestureAccent &&
now - this.lastGestureAccentAt >= GESTURE_ACCENT_MIN_INTERVAL_SECONDS
now - this.lastGestureAccentAt >= this.generation.gestureAccentMinIntervalSeconds
) {
this.recordTouchDown({
vibe,
@ -310,8 +228,8 @@ export class GenerativePianoEngine {
...normalizedMotif,
});
if (
strength >= STROKE_ACCENT_THRESHOLD &&
now - this.lastStrokeAccentAt >= STROKE_ACCENT_MIN_INTERVAL_SECONDS
strength >= this.generation.strokeAccentThreshold &&
now - this.lastStrokeAccentAt >= this.generation.strokeAccentMinIntervalSeconds
) {
this.lastStrokeAccentAt = now;
this.playGestureAccent(vibe, now, selectedColorIndex, strength, 1);
@ -361,7 +279,10 @@ export class GenerativePianoEngine {
const rootMidi = profile.rootMidi + chord.rootOffset;
const notes = [
{
midi: this.chooseMidi({ baseMidi: rootMidi, offsets: [0] }, PAD_REGISTERS[0]),
midi: this.chooseMidi(
{ baseMidi: rootMidi, offsets: [0] },
this.generation.padRegisters[0]
),
velocity: 0.1,
pan: -0.16,
delaySend: 0.012,
@ -369,7 +290,7 @@ export class GenerativePianoEngine {
{
midi: this.chooseMidi(
{ baseMidi: rootMidi, offsets: [intervals[1], intervals[2]] },
PAD_REGISTERS[1]
this.generation.padRegisters[1]
),
velocity: 0.085,
pan: 0,
@ -378,7 +299,7 @@ export class GenerativePianoEngine {
{
midi: this.chooseMidi(
{ baseMidi: rootMidi, offsets: [intervals[3], intervals[2]] },
PAD_REGISTERS[2]
this.generation.padRegisters[2]
),
velocity: 0.07,
pan: 0.16,
@ -389,9 +310,9 @@ export class GenerativePianoEngine {
notes.forEach((note, index) => {
this.playNote({
...note,
durationSeconds: STINGER_DURATION_SECONDS,
durationSeconds: this.generation.stingerDurationSeconds,
lowpassHz: this.getLowpassHz(profile, note.midi, 0.35),
startTime: now + index * STINGER_SPACING_SECONDS,
startTime: now + index * this.generation.stingerSpacingSeconds,
});
});
}
@ -429,7 +350,7 @@ export class GenerativePianoEngine {
const beatInBar = beatIndex % beatsPerBar;
const barIndex = Math.floor(beatIndex / beatsPerBar);
if (beatInBar === 0 && barIndex % CHORD_BARS === 0) {
if (beatInBar === 0 && barIndex % this.generation.chordBars === 0) {
this.playPadChord(profile, barIndex, startTime, expression);
}
@ -437,13 +358,16 @@ export class GenerativePianoEngine {
this.playSupportNote(profile, barIndex, startTime, expression, selectedColorIndex);
}
if (beatInBar === TEXTURE_BEAT && this.shouldPlayTexture(expression, barIndex)) {
if (
beatInBar === this.generation.textureBeat &&
this.shouldPlayTexture(expression, barIndex)
) {
this.playTextureNote(profile, barIndex, startTime, expression, selectedColorIndex);
}
if (
beatInBar === HIGH_ACTIVITY_EXTRA_BEAT &&
expression >= HIGH_ACTIVITY_EXTRA_THRESHOLD
beatInBar === this.generation.highActivityExtraBeat &&
expression >= this.generation.highActivityExtraThreshold
) {
this.playTextureNote(
profile,
@ -464,21 +388,24 @@ export class GenerativePianoEngine {
const chord = this.getChord(profile, barIndex);
const intervals = getChordIntervals(chord, true);
const rootMidi = profile.rootMidi + chord.rootOffset;
const durationSeconds = this.getBarDurationSeconds() * CHORD_BARS * PAD_DURATION_BAR_SCALE;
const durationSeconds =
this.getBarDurationSeconds() *
this.generation.chordBars *
this.generation.padDurationBarScale;
const notes = [
{
source: { baseMidi: rootMidi, offsets: [0] },
register: PAD_REGISTERS[0],
register: this.generation.padRegisters[0],
velocity: 0.052,
},
{
source: { baseMidi: rootMidi, offsets: [intervals[1]] },
register: PAD_REGISTERS[1],
register: this.generation.padRegisters[1],
velocity: 0.041,
},
{
source: { baseMidi: rootMidi, offsets: [intervals[3], intervals[2]] },
register: PAD_REGISTERS[2],
register: this.generation.padRegisters[2],
velocity: 0.033,
},
];
@ -504,7 +431,7 @@ export class GenerativePianoEngine {
expression: number,
selectedColorIndex: GardenAudioColorIndex
): void {
const pool = COLOR_POOLS[selectedColorIndex];
const pool = this.generation.colorPools[selectedColorIndex];
const chord = this.getChord(profile, barIndex);
const chordIntervals = getChordIntervals(chord, false);
const rootMidi = profile.rootMidi + chord.rootOffset;
@ -539,7 +466,7 @@ export class GenerativePianoEngine {
expression: number,
selectedColorIndex: GardenAudioColorIndex
): void {
const pool = COLOR_POOLS[selectedColorIndex];
const pool = this.generation.colorPools[selectedColorIndex];
const degrees = this.rotate(pool.scaleDegrees, barIndex + selectedColorIndex);
const midi = this.chooseMidi(
{
@ -573,7 +500,7 @@ export class GenerativePianoEngine {
noteCount: number
): void {
const profile = getVibeProfile(this.config, vibe);
const pool = COLOR_POOLS[selectedColorIndex];
const pool = this.generation.colorPools[selectedColorIndex];
const degrees = this.rotate(pool.scaleDegrees, Math.round(strength * 3));
for (let index = 0; index < noteCount; index += 1) {
@ -598,8 +525,8 @@ export class GenerativePianoEngine {
this.config.colorVoices[selectedColorIndex].velocityMultiplier,
startTime:
now +
this.engineConfig.startDelaySeconds +
index * GESTURE_ACCENT_SPACING_SECONDS,
this.config.startDelaySeconds +
index * this.generation.gestureAccentSpacingSeconds,
durationSeconds: 0.48 + strength * 0.22,
pan: this.getColorPan(selectedColorIndex),
delaySend: 0.012,
@ -626,7 +553,7 @@ export class GenerativePianoEngine {
brightnessBias: number;
}): void {
const profile = getVibeProfile(this.config, vibe);
const pool = COLOR_POOLS[selectedColorIndex];
const pool = this.generation.colorPools[selectedColorIndex];
const register = this.getBiasedRegister(pool, registerBias, 0);
const chord = this.getChord(profile, this.getGlobalBarIndex(now));
const chordIntervals = getChordIntervals(chord, false);
@ -688,9 +615,9 @@ export class GenerativePianoEngine {
maniaAmount: number;
}): void {
const lifetimeSeconds =
BRUSH_LAYER_BASE_SECONDS +
strength * BRUSH_LAYER_ENERGY_SECONDS +
mirrorAmount * BRUSH_LAYER_MIRROR_SECONDS;
this.generation.brushLayerBaseSeconds +
strength * this.generation.brushLayerEnergySeconds +
mirrorAmount * this.generation.brushLayerMirrorSeconds;
this.brushPhraseLayers.push({
vibe,
@ -713,8 +640,10 @@ export class GenerativePianoEngine {
maniaAmount,
});
if (this.brushPhraseLayers.length > MAX_BRUSH_PHRASE_LAYERS) {
this.brushPhraseLayers = this.brushPhraseLayers.slice(-MAX_BRUSH_PHRASE_LAYERS);
if (this.brushPhraseLayers.length > this.generation.maxBrushPhraseLayers) {
this.brushPhraseLayers = this.brushPhraseLayers.slice(
-this.generation.maxBrushPhraseLayers
);
}
}
@ -762,8 +691,8 @@ export class GenerativePianoEngine {
layer.motifOffsets.push(
this.getMotifOffset({ registerBias, contour, pressureDelta, strength })
);
if (layer.motifOffsets.length > BRUSH_MOTIF_MAX_STEPS) {
layer.motifOffsets = layer.motifOffsets.slice(-BRUSH_MOTIF_MAX_STEPS);
if (layer.motifOffsets.length > this.generation.brushMotifMaxSteps) {
layer.motifOffsets = layer.motifOffsets.slice(-this.generation.brushMotifMaxSteps);
}
}
@ -780,8 +709,8 @@ export class GenerativePianoEngine {
activity: number;
selectedColorIndex: GardenAudioColorIndex;
}): void {
const earliestStart = now + this.engineConfig.piano.scheduleAheadSeconds;
this.nextBrushStreamAt ??= now + this.engineConfig.startDelaySeconds;
const earliestStart = now + this.config.piano.scheduleAheadSeconds;
this.nextBrushStreamAt ??= now + this.config.startDelaySeconds;
this.brushPhraseLayers = this.brushPhraseLayers.filter(
(layer) => layer.expiresAt > earliestStart
@ -795,7 +724,7 @@ export class GenerativePianoEngine {
while (this.nextBrushStreamAt <= lookaheadEnd) {
const frame = this.getBrushStreamFrame(this.nextBrushStreamAt, activity);
if (frame.intensity >= BRUSH_LAYER_MIN_INTENSITY) {
if (frame.intensity >= this.generation.brushLayerMinIntensity) {
this.playBrushStreamNote({
vibe,
startTime: this.nextBrushStreamAt,
@ -823,7 +752,7 @@ export class GenerativePianoEngine {
layer: BrushPhraseLayer | null;
}): void {
const profile = getVibeProfile(this.config, vibe);
const pool = COLOR_POOLS[selectedColorIndex];
const pool = this.generation.colorPools[selectedColorIndex];
const maniaAmount = layer?.maniaAmount ?? clamp01((intensity - 0.82) / 0.18);
const register = this.getBiasedRegister(
pool,
@ -860,7 +789,10 @@ export class GenerativePianoEngine {
0.62
);
const delaySend = clamp(
0.012 + intensity * 0.011 + (layer?.mirrorAmount ?? 0) * 0.004 - maniaAmount * 0.006,
0.012 +
intensity * 0.011 +
(layer?.mirrorAmount ?? 0) * 0.004 -
maniaAmount * 0.006,
0.006,
0.032
);
@ -888,7 +820,10 @@ export class GenerativePianoEngine {
),
});
if (maniaAmount >= 0.62 && (this.brushStreamNoteIndex % 2 === 1 || intensity >= 0.9)) {
if (
maniaAmount >= 0.62 &&
(this.brushStreamNoteIndex % 2 === 1 || intensity >= 0.9)
) {
const echoMidi = midi + 12 <= 88 ? midi + 12 : midi - 12;
this.playNote({
midi: echoMidi,
@ -897,7 +832,7 @@ export class GenerativePianoEngine {
this.config.colorVoices[selectedColorIndex].velocityMultiplier,
startTime:
startTime +
BRUSH_MOTIF_CANON_DELAY_SECONDS +
this.generation.brushMotifCanonDelaySeconds +
(layer?.mirrorAmount ?? 0) * 0.04,
durationSeconds: Math.max(0.11, durationSeconds * 0.68),
pan: clamp(-pan * 0.75, -1, 1),
@ -948,12 +883,12 @@ export class GenerativePianoEngine {
private getBrushStreamIntervalSeconds(intensity: number): number {
const intervalBeats =
intensity >= 0.85
? BRUSH_STREAM_MANIC_INTERVAL_BEATS
? this.generation.brushStreamManicIntervalBeats
: intensity >= 0.62
? BRUSH_STREAM_INTENSE_INTERVAL_BEATS
? this.generation.brushStreamIntenseIntervalBeats
: intensity >= 0.34
? BRUSH_STREAM_ACTIVE_INTERVAL_BEATS
: BRUSH_STREAM_IDLE_INTERVAL_BEATS;
? this.generation.brushStreamActiveIntervalBeats
: this.generation.brushStreamIdleIntervalBeats;
return this.getBeatDurationSeconds() * intervalBeats;
}
@ -1037,7 +972,7 @@ export class GenerativePianoEngine {
selectedColorIndex,
}: {
layer: BrushPhraseLayer | null;
pool: ColorPool;
pool: GardenAudioColorPool;
selectedColorIndex: GardenAudioColorIndex;
}): Array<number> {
const colorOffset = this.config.colorVoices[selectedColorIndex].scaleDegreeOffset;
@ -1063,10 +998,10 @@ export class GenerativePianoEngine {
}
private getBiasedRegister(
register: Register,
register: GardenAudioRegister,
registerBias: number,
maniaAmount: number
): Register {
): GardenAudioRegister {
const shift = Math.round(registerBias * 7 + maniaAmount * 4);
const midiMin = clamp(register.midiMin + shift, 36, 86);
const midiMax = clamp(register.midiMax + shift, midiMin + 4, 91);
@ -1101,7 +1036,7 @@ export class GenerativePianoEngine {
private chooseMidi(
pitchSource: PitchSource,
register: Register,
register: GardenAudioRegister,
previousMidi: number | null = null,
avoidRepeat = false
): number {
@ -1122,7 +1057,7 @@ export class GenerativePianoEngine {
private getCandidates(
pitchSource: PitchSource,
register: Register
register: GardenAudioRegister
): Array<PitchCandidate> {
const candidates: Array<PitchCandidate> = [];
@ -1140,15 +1075,18 @@ export class GenerativePianoEngine {
private scoreCandidate(
candidate: PitchCandidate,
register: Register,
register: GardenAudioRegister,
previousMidi: number,
avoidRepeat: boolean
): number {
return (
Math.abs(candidate.midi - previousMidi) +
Math.abs(candidate.midi - register.preferredMidi) * NOTE_SCORE_REGISTER_WEIGHT +
candidate.preference * NOTE_SCORE_PREFERENCE_WEIGHT +
(avoidRepeat && candidate.midi === previousMidi ? NOTE_SCORE_REPEAT_PENALTY : 0)
Math.abs(candidate.midi - register.preferredMidi) *
this.generation.noteScoreRegisterWeight +
candidate.preference * this.generation.noteScorePreferenceWeight +
(avoidRepeat && candidate.midi === previousMidi
? this.generation.noteScoreRepeatPenalty
: 0)
);
}
@ -1157,15 +1095,17 @@ export class GenerativePianoEngine {
return true;
}
return barIndex % SUPPORT_BAR_SPACING === SUPPORT_BAR_OFFSET;
return (
barIndex % this.generation.supportBarSpacing === this.generation.supportBarOffset
);
}
private shouldPlayTexture(expression: number, barIndex: number): boolean {
const spacing =
expression < 0.35
? IDLE_TEXTURE_BAR_SPACING
? this.generation.idleTextureBarSpacing
: expression < 0.7
? MEDIUM_TEXTURE_BAR_SPACING
? this.generation.mediumTextureBarSpacing
: 1;
return barIndex % spacing === (spacing === 1 ? 0 : 1);
@ -1188,7 +1128,7 @@ export class GenerativePianoEngine {
private getChord(profile: GardenAudioVibeProfile, barIndex: number): GardenAudioChord {
const progressionIndex =
Math.floor(barIndex / CHORD_BARS) % profile.progression.length;
Math.floor(barIndex / this.generation.chordBars) % profile.progression.length;
return profile.progression[progressionIndex];
}
@ -1199,7 +1139,7 @@ export class GenerativePianoEngine {
}
private getColorPan(selectedColorIndex: GardenAudioColorIndex): number {
const pool = COLOR_POOLS[selectedColorIndex];
const pool = this.generation.colorPools[selectedColorIndex];
const colorVoice = this.config.colorVoices[selectedColorIndex];
return clamp(pool.pan + colorVoice.panOffset * 0.35, -1, 1);
}
@ -1213,8 +1153,8 @@ export class GenerativePianoEngine {
return clamp(
this.config.piano.lowpassHz * profile.brightness * (0.58 + expression * 0.32) +
midiLift,
this.engineConfig.piano.lowpassMinHz,
this.engineConfig.piano.lowpassMaxHz
this.config.piano.lowpassMinHz,
this.config.piano.lowpassMaxHz
);
}
@ -1223,7 +1163,7 @@ export class GenerativePianoEngine {
return;
}
const earliestStart = now + this.engineConfig.piano.scheduleAheadSeconds;
const earliestStart = now + this.config.piano.scheduleAheadSeconds;
if (this.nextBeatAt >= earliestStart) {
return;
}