This commit is contained in:
Andras Schmelczer 2026-05-19 21:03:53 +01:00
commit 7c70f15e49
65 changed files with 1122 additions and 1906 deletions

View file

@ -5,17 +5,7 @@ import type {
GardenAudioConfig,
GardenAudioVibeProfile,
} from './garden-audio-config';
import {
degreeToSemitone,
getChordIntervals,
getVibeProfile,
PITCH_SEMITONES_PER_OCTAVE,
} from './garden-audio-music';
import {
GENERATIVE_LOOKAHEAD_SECONDS,
GENERATIVE_START_DELAY_SECONDS,
PIANO_SCHEDULE_AHEAD_SECONDS,
} from './garden-audio-scheduling';
import { getVibeProfile, PITCH_SEMITONES_PER_OCTAVE } from './garden-audio-music';
import type { PianoNote } from './garden-audio-types';
import {
generativePianoTuning,
@ -23,6 +13,39 @@ import {
type GardenAudioRegister,
type GardenAudioStylePool,
} from './generative-piano-tuning';
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 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 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 * PITCH_SEMITONES_PER_OCTAVE;
};
type GardenAudioStyleIndex = 0 | 1 | 2;
@ -124,6 +147,23 @@ export class GenerativePianoEngine {
this.isWaitingForGestureAccent = false;
}
public release(vibe: VibePreset, now: number): number {
this.prime(now);
this.isWaitingForGestureAccent = false;
const profile = getVibeProfile(vibe);
const releaseStep = this.getReleaseResolutionStep(now);
const releaseStart = Math.max(now, this.getTimeForStep(releaseStep));
this.playReleaseResolution(profile, releaseStep, releaseStart);
this.nextBeatStep = null;
this.nextBrushStreamStep = null;
this.brushPhraseLayers = [];
this.brushStreamNoteCountsByBar.clear();
return releaseStart + this.generation.releaseResolution.fadeAfterSeconds;
}
private recordTouchDown({
vibe,
now,
@ -234,47 +274,27 @@ export class GenerativePianoEngine {
const chord = this.getChord(profile, 0);
const intervals = getChordIntervals(chord, true);
const rootMidi = profile.rootMidi + chord.rootOffset;
const notes = [
{
midi: this.chooseMidi(
{ baseMidi: rootMidi, offsets: [0] },
this.generation.padRegisters[0]
),
velocity: this.generation.vibeChangeStinger.velocities[0],
pan: this.generation.vibeChangeStinger.pans[0],
delaySend: this.generation.vibeChangeStinger.delaySends[0],
},
{
midi: this.chooseMidi(
{ baseMidi: rootMidi, offsets: [intervals[1], intervals[2]] },
this.generation.padRegisters[1]
),
velocity: this.generation.vibeChangeStinger.velocities[1],
pan: this.generation.vibeChangeStinger.pans[1],
delaySend: this.generation.vibeChangeStinger.delaySends[1],
},
{
midi: this.chooseMidi(
{ baseMidi: rootMidi, offsets: [intervals[3], intervals[2]] },
this.generation.padRegisters[2]
),
velocity: this.generation.vibeChangeStinger.velocities[2],
pan: this.generation.vibeChangeStinger.pans[2],
delaySend: this.generation.vibeChangeStinger.delaySends[2],
},
const stinger = this.generation.vibeChangeStinger;
const offsetsByVoice: ReadonlyArray<ReadonlyArray<number>> = [
[0],
[intervals[1], intervals[2]],
[intervals[3], intervals[2]],
];
notes.forEach((note, index) => {
offsetsByVoice.forEach((offsets, index) => {
const midi = this.chooseMidi(
{ baseMidi: rootMidi, offsets },
this.generation.padRegisters[index]
);
this.playNote({
...note,
durationSeconds: this.generation.stingerDurationSeconds,
midi,
velocity: stinger.velocities[index],
pan: stinger.pans[index],
delaySend: stinger.delaySends[index],
durationSeconds: stinger.noteDurationSeconds,
role: 'stinger',
lowpassHz: this.getLowpassHz(
profile,
note.midi,
this.generation.vibeChangeStinger.lowpassExpression
),
startTime: now + index * this.generation.stingerSpacingSeconds,
lowpassHz: this.getLowpassHz(profile, midi, stinger.lowpassExpression),
startTime: now + index * stinger.spacingSeconds,
});
});
}
@ -285,12 +305,8 @@ export class GenerativePianoEngine {
this.isWaitingForGestureAccent = false;
this.lastGestureAccentAt = Number.NEGATIVE_INFINITY;
this.lastStrokeAccentStep = Number.NEGATIVE_INFINITY;
this.lastMidiByStyle[0] = null;
this.lastMidiByStyle[1] = null;
this.lastMidiByStyle[2] = null;
this.lastPadMidiByVoice[0] = null;
this.lastPadMidiByVoice[1] = null;
this.lastPadMidiByVoice[2] = null;
this.lastMidiByStyle.fill(null);
this.lastPadMidiByVoice.fill(null);
this.brushPhraseLayers = [];
this.nextBrushStreamStep = null;
this.brushStreamNoteIndex = 0;
@ -400,6 +416,37 @@ export class GenerativePianoEngine {
});
}
private playReleaseResolution(
profile: GardenAudioVibeProfile,
stepIndex: number,
startTime: number
): void {
const chord = this.getChord(profile, this.getBarIndexForStep(stepIndex));
const intervals = getChordIntervals(chord, true);
const rootMidi = profile.rootMidi + chord.rootOffset;
const release = this.generation.releaseResolution;
const offsetsByVoice: ReadonlyArray<ReadonlyArray<number>> = [
[0],
[intervals[1], intervals[2]],
[intervals[3], intervals[2]],
];
offsetsByVoice.forEach((offsets, index) => {
const register = this.generation.padRegisters[index];
const midi = this.chooseMidi({ baseMidi: rootMidi, offsets }, register, null, false);
this.playNote({
midi,
velocity: release.velocities[index],
startTime: startTime + index * release.strumSeconds,
durationSeconds: release.durationSeconds,
pan: register.pan,
role: 'pad',
delaySend: release.delaySend,
lowpassHz: this.getLowpassHz(profile, midi, release.lowpassExpression),
});
});
}
private playSupportNote(
profile: GardenAudioVibeProfile,
barIndex: number,
@ -546,10 +593,6 @@ export class GenerativePianoEngine {
}): void {
const profile = getVibeProfile(vibe);
const pool = this.generation.stylePools[styleIndex];
const register = this.getBiasedRegister(
pool,
this.generation.touchNote.registerBiasManiaAmount
);
const chord = this.getChord(profile, this.getGlobalBarIndex(now));
const chordIntervals = getChordIntervals(chord, false);
const rootMidi = profile.rootMidi + chord.rootOffset;
@ -558,7 +601,7 @@ export class GenerativePianoEngine {
baseMidi: rootMidi,
offsets: this.getSupportOffsets(chordIntervals, styleIndex),
},
register,
pool,
this.lastMidiByStyle[styleIndex],
true
);
@ -877,41 +920,26 @@ export class GenerativePianoEngine {
private getBrushStreamIntervalSteps(intensity: number): number {
const intervalBeats =
intensity >= this.generation.brushStream.manicThreshold
? this.generation.brushStreamManicIntervalBeats
: intensity >= this.generation.brushStream.intenseThreshold
? this.generation.brushStreamIntenseIntervalBeats
: intensity >= this.generation.brushStream.activeThreshold
? this.generation.brushStreamActiveIntervalBeats
: this.generation.brushStreamIdleIntervalBeats;
intensity >= this.generation.brushStream.intenseThreshold
? this.generation.brushStreamIntenseIntervalBeats
: intensity >= this.generation.brushStream.activeThreshold
? this.generation.brushStreamActiveIntervalBeats
: this.generation.brushStreamIdleIntervalBeats;
return Math.max(1, Math.round(intervalBeats * this.config.rhythm.stepsPerBeat));
}
private getBrushPhraseFade(layer: BrushPhraseLayer, startTime: number): number {
const lifetimeSeconds = layer.expiresAt - layer.startedAt;
const lifetimeSeconds = Math.max(0.001, layer.expiresAt - layer.startedAt);
const ageSeconds = startTime - layer.startedAt;
return clamp01(
1 -
ageSeconds /
Math.max(
this.generation.brushPhrase.fadeMinimumLifetimeSeconds,
lifetimeSeconds
)
);
return clamp01(1 - ageSeconds / lifetimeSeconds);
}
private getMotifOffset(strength: number): number {
const energyStep =
strength >= this.generation.brushMotif.highThreshold
? this.generation.brushMotif.highOffset
: strength >= this.generation.brushMotif.mediumThreshold
? this.generation.brushMotif.mediumOffset
: this.generation.brushMotif.lowOffset;
return clamp(
energyStep,
this.generation.brushMotif.minOffset,
this.generation.brushMotif.maxOffset
);
return strength >= this.generation.brushMotif.highThreshold
? this.generation.brushMotif.highOffset
: strength >= this.generation.brushMotif.mediumThreshold
? this.generation.brushMotif.mediumOffset
: this.generation.brushMotif.lowOffset;
}
private getBrushMotifDegrees({
@ -1185,6 +1213,19 @@ export class GenerativePianoEngine {
);
}
private getReleaseResolutionStep(startTime: number): number {
const currentStep = this.getStepIndexAtTime(startTime);
const nextBeatStep =
Math.ceil((currentStep + 1) / this.config.rhythm.stepsPerBeat) *
this.config.rhythm.stepsPerBeat;
const nextBarStep =
Math.ceil((currentStep + 1) / this.config.rhythm.stepsPerBar) *
this.config.rhythm.stepsPerBar;
const barIsClose = nextBarStep - currentStep <= this.config.rhythm.stepsPerBeat * 2;
return barIsClose ? nextBarStep : nextBeatStep;
}
private getNextBarTimeAt(startTime: number): number {
const nextBarStep =
Math.ceil(this.getStepIndexAtTime(startTime) / this.config.rhythm.stepsPerBar) *