This commit is contained in:
Andras Schmelczer 2026-05-13 21:07:10 +01:00
commit 39b0160064
136 changed files with 7129 additions and 1950 deletions

View file

@ -0,0 +1,674 @@
import { appConfig } from '../config';
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, PianoNote } from './garden-audio-types';
interface RenderLookaheadRequest {
vibe: VibePreset;
now: number;
activity: number;
selectedColorIndex: GardenAudioColorIndex;
lookaheadSeconds?: number;
}
interface PianoRole {
name: string;
midiMin: number;
midiMax: number;
preferredMidi: number;
pan: number;
delaySend: number;
velocityBase: number;
velocityActivityScale: number;
durationBase: number;
durationActivityScale: number;
downbeatDurationBoost: number;
lowBeats: ReadonlyArray<number>;
mediumBeats: ReadonlyArray<number>;
highBeats: ReadonlyArray<number>;
lowPhraseOffset: number;
lowPhraseSpacing: number;
lowKeepChance: number;
mediumKeepChance: number;
highKeepChance: number;
}
interface PitchCandidate {
midi: number;
preference: number;
}
const COLOR_ROLES: [PianoRole, PianoRole, PianoRole] = [
{
name: 'anchor',
midiMin: 45,
midiMax: 64,
preferredMidi: 53,
pan: -0.28,
delaySend: 0.012,
velocityBase: 0.12,
velocityActivityScale: 0.15,
durationBase: 1.2,
durationActivityScale: 0.75,
downbeatDurationBoost: 0.32,
lowBeats: [0, 2],
mediumBeats: [0, 2],
highBeats: [0, 2],
lowPhraseOffset: 0,
lowPhraseSpacing: 4,
lowKeepChance: 1,
mediumKeepChance: 0.92,
highKeepChance: 1,
},
{
name: 'body',
midiMin: 55,
midiMax: 74,
preferredMidi: 64,
pan: 0,
delaySend: 0.009,
velocityBase: 0.115,
velocityActivityScale: 0.18,
durationBase: 0.86,
durationActivityScale: 0.54,
downbeatDurationBoost: 0.18,
lowBeats: [0, 2],
mediumBeats: [0, 2],
highBeats: [0, 1, 2, 3],
lowPhraseOffset: 0,
lowPhraseSpacing: 4,
lowKeepChance: 0.86,
mediumKeepChance: 0.86,
highKeepChance: 0.9,
},
{
name: 'spark',
midiMin: 67,
midiMax: 84,
preferredMidi: 76,
pan: 0.28,
delaySend: 0.018,
velocityBase: 0.09,
velocityActivityScale: 0.14,
durationBase: 0.48,
durationActivityScale: 0.32,
downbeatDurationBoost: 0,
lowBeats: [0, 2],
mediumBeats: [1, 3],
highBeats: [0, 1, 2, 3],
lowPhraseOffset: 0,
lowPhraseSpacing: 4,
lowKeepChance: 0.76,
mediumKeepChance: 0.8,
highKeepChance: 0.78,
},
];
const PHRASE_BAR_COUNT = 4;
const PACE_MIN = 0.96;
const PACE_MAX = 1.08;
const PACE_RAMP_SECONDS = 2.8;
const FIRST_NOTE_MAX_WAIT_SECONDS = 0.09;
const NOTE_SCORE_PREFERENCE_WEIGHT = 1.8;
const NOTE_SCORE_REGISTER_WEIGHT = 0.28;
const STINGER_SPACING_SECONDS = 0.07;
const STINGER_DURATION_SECONDS = 1.45;
const PHRASE_CONTOURS: ReadonlyArray<ReadonlyArray<number>> = [
[0, 0, 1, 0, -1, 0, 1, 0],
[0, 1, 2, 1, 0, -1, 0, 1],
[1, 0, -1, 0, 1, 2, 1, 0],
[0, -1, 0, 1, 0, 1, 2, 1],
];
export class GenerativePianoEngine {
private nextStepAt: number | null = null;
private stepIndex = 0;
private pace = 1;
private lastPaceUpdateAt: number | null = null;
private isWaitingForFirstStrokeNote = false;
private readonly lastMidiByColor: [number | null, number | null, number | null] = [
null,
null,
null,
];
public constructor(
private readonly config: GardenAudioConfig,
private readonly playNote: (note: PianoNote) => void
) {}
public prime(now: number): void {
if (this.nextStepAt === null) {
this.nextStepAt = now + appConfig.audioEngine.startDelaySeconds;
}
this.lastPaceUpdateAt ??= now;
}
public beginGesture(now: number): void {
this.isWaitingForFirstStrokeNote = true;
this.wake(now);
}
public endGesture(): void {
this.isWaitingForFirstStrokeNote = false;
}
public wake(now: number): void {
this.lastPaceUpdateAt ??= now;
if (
!this.isWaitingForFirstStrokeNote &&
this.nextStepAt !== null &&
this.nextStepAt - now <= FIRST_NOTE_MAX_WAIT_SECONDS
) {
return;
}
this.nextStepAt = now + appConfig.audioEngine.startDelaySeconds;
this.stepIndex = this.getNextDownbeatStepIndex();
}
public renderLookahead({
vibe,
now,
activity,
selectedColorIndex,
lookaheadSeconds = this.config.rhythm.lookaheadSeconds,
}: RenderLookaheadRequest): void {
this.prime(now);
const normalizedActivity = clamp01(activity);
this.updatePace(now, normalizedActivity);
const stepSeconds = this.getStepDurationSeconds();
this.skipLateSteps(now, stepSeconds);
if (this.nextStepAt === null) {
return;
}
const profile = getVibeProfile(this.config, vibe);
const lookaheadEnd = now + lookaheadSeconds;
while (this.nextStepAt <= lookaheadEnd) {
this.renderStep({
vibe,
profile,
stepIndex: this.stepIndex,
startTime: this.nextStepAt,
activity: normalizedActivity,
selectedColorIndex,
});
this.nextStepAt += stepSeconds;
this.stepIndex += 1;
}
}
public playVibeChangeStinger(vibe: VibePreset, now: number): void {
const profile = getVibeProfile(this.config, vibe);
const chord = profile.progression[0];
const intervals = getChordIntervals(chord, true);
const rootMidi = profile.rootMidi + chord.rootOffset;
const notes = [
{
midi: clampMidi(rootMidi + intervals[0], 48, 60),
velocity: 0.16,
pan: -0.24,
delaySend: 0.014,
},
{
midi: clampMidi(rootMidi + intervals[2], 60, 72),
velocity: 0.13,
pan: 0.02,
delaySend: 0.016,
},
{
midi: clampMidi(rootMidi + intervals[3], 67, 84),
velocity: 0.105,
pan: 0.26,
delaySend: 0.02,
},
];
notes.forEach((note, index) => {
this.playNote({
...note,
durationSeconds: STINGER_DURATION_SECONDS,
lowpassHz: this.getLowpassHz(profile, note.midi, 0.28),
startTime: now + index * STINGER_SPACING_SECONDS,
});
});
}
public reset(): void {
this.nextStepAt = null;
this.stepIndex = 0;
this.pace = 1;
this.lastPaceUpdateAt = null;
this.isWaitingForFirstStrokeNote = false;
this.lastMidiByColor[0] = null;
this.lastMidiByColor[1] = null;
this.lastMidiByColor[2] = null;
}
private renderStep({
vibe,
profile,
stepIndex,
startTime,
activity,
selectedColorIndex,
}: {
vibe: VibePreset;
profile: GardenAudioVibeProfile;
stepIndex: number;
startTime: number;
activity: number;
selectedColorIndex: GardenAudioColorIndex;
}): void {
if (
activity < this.config.rhythm.sparseActivity ||
stepIndex % this.config.rhythm.stepsPerBeat !== 0
) {
return;
}
const role = COLOR_ROLES[selectedColorIndex];
const stepInBar = stepIndex % this.config.rhythm.stepsPerBar;
const beatsPerBar = this.getBeatsPerBar();
const beatInBar =
Math.floor(stepInBar / this.config.rhythm.stepsPerBeat) % beatsPerBar;
const barIndex = Math.floor(stepIndex / this.config.rhythm.stepsPerBar);
const phraseIndex = Math.floor(barIndex / PHRASE_BAR_COUNT);
const barInPhrase = barIndex % PHRASE_BAR_COUNT;
if (
!this.shouldPlayBeat({
vibeId: vibe.id,
role,
activity,
beatInBar,
beatsPerBar,
barInPhrase,
phraseIndex,
})
) {
return;
}
const note = this.createNote({
vibeId: vibe.id,
profile,
role,
stepIndex,
startTime,
activity,
selectedColorIndex,
beatInBar,
beatsPerBar,
barInPhrase,
phraseIndex,
});
this.lastMidiByColor[selectedColorIndex] = note.midi;
this.isWaitingForFirstStrokeNote = false;
this.playNote(note);
}
private createNote({
vibeId,
profile,
role,
stepIndex,
startTime,
activity,
selectedColorIndex,
beatInBar,
beatsPerBar,
barInPhrase,
phraseIndex,
}: {
vibeId: string;
profile: GardenAudioVibeProfile;
role: PianoRole;
stepIndex: number;
startTime: number;
activity: number;
selectedColorIndex: GardenAudioColorIndex;
beatInBar: number;
beatsPerBar: number;
barInPhrase: number;
phraseIndex: number;
}): PianoNote {
const colorVoice = this.config.colorVoices[selectedColorIndex];
const expression = this.getExpression(activity);
const midi = this.chooseMidi({
vibeId,
profile,
role,
stepIndex,
selectedColorIndex,
beatInBar,
beatsPerBar,
barInPhrase,
phraseIndex,
});
const beatAccent = beatInBar === 0 ? 1.08 : beatInBar === 2 ? 0.97 : 0.9;
const phrasePosition =
(barInPhrase * beatsPerBar + beatInBar) / (PHRASE_BAR_COUNT * beatsPerBar);
const phraseSwell = 0.88 + Math.sin(phrasePosition * Math.PI) * 0.12;
return {
midi,
velocity: clamp(
(role.velocityBase + expression * role.velocityActivityScale) *
colorVoice.velocityMultiplier *
beatAccent *
phraseSwell,
0.06,
0.52
),
durationSeconds:
role.durationBase +
expression * role.durationActivityScale +
(beatInBar === 0 ? role.downbeatDurationBoost : 0),
pan: clamp(role.pan + colorVoice.panOffset * 0.45, -1, 1),
delaySend: clamp(role.delaySend * (1.15 - expression * 0.4), 0, 0.04),
lowpassHz: this.getLowpassHz(profile, midi, expression),
startTime,
};
}
private chooseMidi({
vibeId,
profile,
role,
stepIndex,
selectedColorIndex,
beatInBar,
beatsPerBar,
barInPhrase,
phraseIndex,
}: {
vibeId: string;
profile: GardenAudioVibeProfile;
role: PianoRole;
stepIndex: number;
selectedColorIndex: GardenAudioColorIndex;
beatInBar: number;
beatsPerBar: number;
barInPhrase: number;
phraseIndex: number;
}): number {
const chord = getChordAtStep(this.config, profile, stepIndex);
const rootMidi = profile.rootMidi + chord.rootOffset;
const offsets = this.getPitchOffsets({
vibeId,
profile,
chord,
selectedColorIndex,
beatInBar,
beatsPerBar,
barInPhrase,
phraseIndex,
});
const previousMidi = this.lastMidiByColor[selectedColorIndex] ?? role.preferredMidi;
const candidates = this.getCandidates(rootMidi, offsets, role);
if (candidates.length === 0) {
return clampMidi(rootMidi + offsets[0], role.midiMin, role.midiMax);
}
return candidates.reduce((best, candidate) =>
this.scoreCandidate(candidate, role, previousMidi) <
this.scoreCandidate(best, role, previousMidi)
? candidate
: best
).midi;
}
private getPitchOffsets({
vibeId,
profile,
chord,
selectedColorIndex,
beatInBar,
beatsPerBar,
barInPhrase,
phraseIndex,
}: {
vibeId: string;
profile: GardenAudioVibeProfile;
chord: GardenAudioChord;
selectedColorIndex: GardenAudioColorIndex;
beatInBar: number;
beatsPerBar: number;
barInPhrase: number;
phraseIndex: number;
}): Array<number> {
const chordIntervals = getChordIntervals(chord, false);
const phraseBeat = barInPhrase * beatsPerBar + beatInBar;
const contour = this.getPhraseContour(
vibeId,
phraseIndex,
selectedColorIndex,
phraseBeat
);
const colorVoice = this.config.colorVoices[selectedColorIndex];
if (selectedColorIndex === 0) {
return beatInBar === 0 ? [0, 7, 12] : [7, 0, 12];
}
if (selectedColorIndex === 1) {
return [
chordIntervals[1],
chordIntervals[2],
degreeToSemitone(profile, 2 + colorVoice.scaleDegreeOffset + contour),
chordIntervals[1] + 12,
];
}
const degreeBase = 2 + colorVoice.scaleDegreeOffset + contour;
return [
degreeToSemitone(profile, degreeBase),
degreeToSemitone(profile, degreeBase + 2),
12 + degreeToSemitone(profile, degreeBase - 1),
12 + degreeToSemitone(profile, degreeBase + 1),
];
}
private getCandidates(
rootMidi: number,
offsets: ReadonlyArray<number>,
role: PianoRole
): Array<PitchCandidate> {
const candidates: Array<PitchCandidate> = [];
offsets.forEach((offset, preference) => {
for (let octave = -3; octave <= 3; octave += 1) {
const midi = rootMidi + offset + octave * 12;
if (midi >= role.midiMin && midi <= role.midiMax) {
candidates.push({ midi: Math.round(midi), preference });
}
}
});
return candidates;
}
private scoreCandidate(
candidate: PitchCandidate,
role: PianoRole,
previousMidi: number
): number {
return (
Math.abs(candidate.midi - previousMidi) +
Math.abs(candidate.midi - role.preferredMidi) * NOTE_SCORE_REGISTER_WEIGHT +
candidate.preference * NOTE_SCORE_PREFERENCE_WEIGHT
);
}
private shouldPlayBeat({
vibeId,
role,
activity,
beatInBar,
beatsPerBar,
barInPhrase,
phraseIndex,
}: {
vibeId: string;
role: PianoRole;
activity: number;
beatInBar: number;
beatsPerBar: number;
barInPhrase: number;
phraseIndex: number;
}): boolean {
const expression = this.getExpression(activity);
const beats =
expression < 0.34
? role.lowBeats
: expression < 0.68
? role.mediumBeats
: role.highBeats;
if (!beats.includes(beatInBar)) {
return false;
}
const phraseBeat = barInPhrase * beatsPerBar + beatInBar;
if (
expression < 0.34 &&
phraseBeat % role.lowPhraseSpacing !== role.lowPhraseOffset
) {
return false;
}
const keepChance =
expression < 0.34
? role.lowKeepChance
: expression < 0.68
? role.mediumKeepChance
: role.highKeepChance;
const gate = hashUnit(vibeId, role.name, phraseIndex, barInPhrase, beatInBar);
if (this.isWaitingForFirstStrokeNote && beatInBar === 0) {
return true;
}
if (beatInBar === 0 && role.name !== 'spark' && expression >= 0.52) {
return true;
}
return gate <= keepChance;
}
private getPhraseContour(
vibeId: string,
phraseIndex: number,
selectedColorIndex: GardenAudioColorIndex,
phraseBeat: number
): number {
if (selectedColorIndex === 0) {
return 0;
}
const contourIndex =
hashInt(vibeId, phraseIndex, selectedColorIndex) % PHRASE_CONTOURS.length;
const contour = PHRASE_CONTOURS[contourIndex];
return contour[phraseBeat % contour.length];
}
private getLowpassHz(
profile: GardenAudioVibeProfile,
midi: number,
expression: number
): number {
const midiLift = clamp01((midi - 48) / 36) * 1100;
return clamp(
this.config.piano.lowpassHz * profile.brightness * (0.44 + expression * 0.44) +
midiLift,
appConfig.audioEngine.piano.lowpassMinHz,
appConfig.audioEngine.piano.lowpassMaxHz
);
}
private updatePace(now: number, activity: number): void {
if (this.lastPaceUpdateAt === null) {
this.lastPaceUpdateAt = now;
return;
}
const elapsedSeconds = Math.max(0, now - this.lastPaceUpdateAt);
this.lastPaceUpdateAt = now;
const targetPace = PACE_MIN + (PACE_MAX - PACE_MIN) * this.getExpression(activity);
const amount = 1 - Math.exp(-elapsedSeconds / PACE_RAMP_SECONDS);
this.pace += (targetPace - this.pace) * amount;
}
private skipLateSteps(now: number, stepSeconds: number): void {
if (this.nextStepAt === null) {
return;
}
const earliestStart = now + appConfig.audioEngine.piano.scheduleAheadSeconds;
if (this.nextStepAt >= earliestStart) {
return;
}
const skippedSteps = Math.floor((earliestStart - this.nextStepAt) / stepSeconds) + 1;
this.nextStepAt += skippedSteps * stepSeconds;
this.stepIndex += skippedSteps;
}
private getExpression(activity: number): number {
return clamp01(
(activity - this.config.rhythm.sparseActivity) /
(1 - this.config.rhythm.sparseActivity)
);
}
private getStepDurationSeconds(): number {
return 60 / this.config.rhythm.bpm / this.config.rhythm.stepsPerBeat / this.pace;
}
private getNextDownbeatStepIndex(): number {
const stepInBar = this.stepIndex % this.config.rhythm.stepsPerBar;
if (stepInBar === 0) {
return this.stepIndex;
}
return this.stepIndex + this.config.rhythm.stepsPerBar - stepInBar;
}
private getBeatsPerBar(): number {
return Math.max(
1,
Math.round(this.config.rhythm.stepsPerBar / this.config.rhythm.stepsPerBeat)
);
}
}
const hashUnit = (...parts: Array<number | string>): number =>
hashInt(...parts) / 0xffffffff;
const hashInt = (...parts: Array<number | string>): number => {
let hash = 2166136261;
const input = parts.join(':');
for (let index = 0; index < input.length; index += 1) {
hash ^= input.charCodeAt(index);
hash = Math.imul(hash, 16777619);
}
return hash >>> 0;
};