fleeting-garden/src/audio/generative-piano.ts
2026-05-16 20:45:42 +01:00

958 lines
27 KiB
TypeScript

import { clamp, clamp01 } from '../utils/clamp';
import { VibePreset } from '../vibes';
import {
GardenAudioChord,
GardenAudioConfig,
GardenAudioRegister,
GardenAudioStylePool,
GardenAudioVibeProfile,
} from './garden-audio-config';
import {
degreeToSemitone,
getChordIntervals,
getVibeProfile,
} from './garden-audio-music';
import { PianoNote } from './garden-audio-types';
type GardenAudioStyleIndex = 0 | 1 | 2;
interface RenderLookaheadRequest {
vibe: VibePreset;
now: number;
activity: number;
lookaheadSeconds?: number;
}
interface StrokeAccentRequest {
vibe: VibePreset;
now: number;
activity: number;
maniaAmount?: number;
}
interface TouchDownRequest {
vibe: VibePreset;
now: number;
strength: number;
maniaAmount?: number;
}
interface PitchCandidate {
midi: number;
preference: number;
}
interface PitchSource {
baseMidi: number;
offsets: ReadonlyArray<number>;
}
interface BrushPhraseLayer {
vibe: VibePreset;
startedAt: number;
expiresAt: number;
styleIndex: GardenAudioStyleIndex;
energy: number;
motifOffsets: Array<number>;
maniaAmount: number;
}
export class GenerativePianoEngine {
private nextBeatAt: number | null = null;
private timelineStartedAt: number | null = null;
private beatIndex = 0;
private isWaitingForGestureAccent = false;
private lastGestureAccentAt = Number.NEGATIVE_INFINITY;
private lastStrokeAccentAt = Number.NEGATIVE_INFINITY;
private readonly lastMidiByStyle: [number | null, number | null, number | null] = [
null,
null,
null,
];
private brushPhraseLayers: Array<BrushPhraseLayer> = [];
private nextBrushStreamAt: number | null = null;
private brushStreamNoteIndex = 0;
private lastBrushStreamMidi: number | null = null;
public constructor(
private readonly config: GardenAudioConfig,
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.config.startDelaySeconds;
}
this.timelineStartedAt ??= now;
this.nextBrushStreamAt ??= now + this.config.startDelaySeconds;
}
public cue(now: number): void {
this.nextBeatAt = now + this.config.startDelaySeconds;
this.timelineStartedAt = now;
this.beatIndex = 0;
this.nextBrushStreamAt = now + this.config.startDelaySeconds;
this.brushStreamNoteIndex = 0;
this.lastBrushStreamMidi = null;
}
public beginGesture(): void {
this.isWaitingForGestureAccent = true;
}
public endGesture(): void {
this.isWaitingForGestureAccent = false;
}
private recordTouchDown({
vibe,
now,
strength,
maniaAmount = 0,
}: TouchDownRequest): void {
const normalizedStrength = clamp01(strength);
const normalizedManiaAmount = clamp01(maniaAmount);
const styleIndex = this.getStyleIndex(now);
this.isWaitingForGestureAccent = false;
this.lastGestureAccentAt = now;
this.lastStrokeAccentAt = now;
this.startBrushPhraseLayer({
vibe,
now,
strength: normalizedStrength,
styleIndex,
maniaAmount: normalizedManiaAmount,
});
this.playTouchNote({
vibe,
now,
styleIndex,
strength: normalizedStrength,
});
}
public recordStroke({
vibe,
now,
activity,
maniaAmount = 0,
}: StrokeAccentRequest): void {
const strength = clamp01(activity);
const normalizedManiaAmount = clamp01(maniaAmount);
const styleIndex = this.getStyleIndex(now);
if (
this.isWaitingForGestureAccent &&
now - this.lastGestureAccentAt >= this.generation.gestureAccentMinIntervalSeconds
) {
this.recordTouchDown({
vibe,
now,
strength,
maniaAmount: normalizedManiaAmount,
});
return;
}
this.isWaitingForGestureAccent = false;
this.updateBrushPhraseLayer({
now,
strength,
styleIndex,
maniaAmount: normalizedManiaAmount,
});
if (
strength >= this.generation.strokeAccentThreshold &&
now - this.lastStrokeAccentAt >= this.generation.strokeAccentMinIntervalSeconds
) {
this.lastStrokeAccentAt = now;
this.playGestureAccent(vibe, now, styleIndex, strength);
}
}
public renderLookahead({
vibe,
now,
activity,
lookaheadSeconds = this.config.rhythm.lookaheadSeconds,
}: RenderLookaheadRequest): void {
this.prime(now);
this.skipLateBeats(now, this.getBeatDurationSeconds());
if (this.nextBeatAt === null) {
return;
}
const profile = getVibeProfile(vibe);
const lookaheadEnd = now + lookaheadSeconds;
while (this.nextBeatAt <= lookaheadEnd) {
this.renderBeat({
profile,
beatIndex: this.beatIndex,
startTime: this.nextBeatAt,
expression: this.getExpression(activity),
});
this.nextBeatAt += this.getBeatDurationSeconds();
this.beatIndex += 1;
}
this.renderBrushPhraseLayers({
vibe,
now,
lookaheadEnd,
activity,
});
}
public playVibeChangeStinger(vibe: VibePreset, now: number): void {
const profile = getVibeProfile(vibe);
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: 0.1,
pan: -0.16,
delaySend: 0.012,
},
{
midi: this.chooseMidi(
{ baseMidi: rootMidi, offsets: [intervals[1], intervals[2]] },
this.generation.padRegisters[1]
),
velocity: 0.085,
pan: 0,
delaySend: 0.014,
},
{
midi: this.chooseMidi(
{ baseMidi: rootMidi, offsets: [intervals[3], intervals[2]] },
this.generation.padRegisters[2]
),
velocity: 0.07,
pan: 0.16,
delaySend: 0.016,
},
];
notes.forEach((note, index) => {
this.playNote({
...note,
durationSeconds: this.generation.stingerDurationSeconds,
lowpassHz: this.getLowpassHz(profile, note.midi, 0.35),
startTime: now + index * this.generation.stingerSpacingSeconds,
});
});
}
public reset(): void {
this.nextBeatAt = null;
this.timelineStartedAt = null;
this.beatIndex = 0;
this.isWaitingForGestureAccent = false;
this.lastGestureAccentAt = Number.NEGATIVE_INFINITY;
this.lastStrokeAccentAt = Number.NEGATIVE_INFINITY;
this.lastMidiByStyle[0] = null;
this.lastMidiByStyle[1] = null;
this.lastMidiByStyle[2] = null;
this.brushPhraseLayers = [];
this.nextBrushStreamAt = null;
this.brushStreamNoteIndex = 0;
this.lastBrushStreamMidi = null;
}
private renderBeat({
profile,
beatIndex,
startTime,
expression,
}: {
profile: GardenAudioVibeProfile;
beatIndex: number;
startTime: number;
expression: number;
}): void {
const beatsPerBar = this.getBeatsPerBar();
const beatInBar = beatIndex % beatsPerBar;
const barIndex = Math.floor(beatIndex / beatsPerBar);
const styleIndex = this.getStyleIndex(startTime);
if (beatInBar === 0 && barIndex % this.generation.chordBars === 0) {
this.playPadChord(profile, barIndex, startTime, expression);
}
if (beatInBar === 0 && this.shouldPlaySupport(expression, barIndex)) {
this.playSupportNote(profile, barIndex, startTime, expression, styleIndex);
}
if (
beatInBar === this.generation.textureBeat &&
this.shouldPlayTexture(expression, barIndex)
) {
this.playTextureNote(profile, barIndex, startTime, expression, styleIndex);
}
if (
beatInBar === this.generation.highActivityExtraBeat &&
expression >= this.generation.highActivityExtraThreshold
) {
this.playTextureNote(
profile,
barIndex + 1,
startTime,
expression * 0.9,
styleIndex
);
}
}
private playPadChord(
profile: GardenAudioVibeProfile,
barIndex: number,
startTime: number,
expression: number
): void {
const chord = this.getChord(profile, barIndex);
const intervals = getChordIntervals(chord, true);
const rootMidi = profile.rootMidi + chord.rootOffset;
const durationSeconds =
this.getBarDurationSeconds() *
this.generation.chordBars *
this.generation.padDurationBarScale;
const notes = [
{
source: { baseMidi: rootMidi, offsets: [0] },
register: this.generation.padRegisters[0],
velocity: 0.052,
},
{
source: { baseMidi: rootMidi, offsets: [intervals[1]] },
register: this.generation.padRegisters[1],
velocity: 0.041,
},
{
source: { baseMidi: rootMidi, offsets: [intervals[3], intervals[2]] },
register: this.generation.padRegisters[2],
velocity: 0.033,
},
];
notes.forEach(({ source, register, velocity }) => {
const midi = this.chooseMidi(source, register);
this.playNote({
midi,
velocity: velocity + expression * 0.02,
startTime,
durationSeconds,
pan: register.pan,
delaySend: 0.008,
lowpassHz: this.getLowpassHz(profile, midi, expression * 0.28),
});
});
}
private playSupportNote(
profile: GardenAudioVibeProfile,
barIndex: number,
startTime: number,
expression: number,
styleIndex: GardenAudioStyleIndex
): void {
const pool = this.generation.stylePools[styleIndex];
const chord = this.getChord(profile, barIndex);
const chordIntervals = getChordIntervals(chord, false);
const rootMidi = profile.rootMidi + chord.rootOffset;
const midi = this.chooseMidi(
{
baseMidi: rootMidi,
offsets: this.getSupportOffsets(chordIntervals, styleIndex),
},
pool,
this.lastMidiByStyle[styleIndex],
true
);
this.lastMidiByStyle[styleIndex] = midi;
this.playNote({
midi,
velocity:
(0.105 + expression * 0.07) *
this.config.styleVoices[styleIndex].velocityMultiplier,
startTime,
durationSeconds: 1.35 + expression * 0.4,
pan: this.getStylePan(styleIndex),
delaySend: 0.016 + expression * 0.006,
lowpassHz: this.getLowpassHz(profile, midi, expression * 0.7),
});
}
private playTextureNote(
profile: GardenAudioVibeProfile,
barIndex: number,
startTime: number,
expression: number,
styleIndex: GardenAudioStyleIndex
): void {
const pool = this.generation.stylePools[styleIndex];
const degrees = this.rotate(pool.scaleDegrees, barIndex + styleIndex);
const midi = this.chooseMidi(
{
baseMidi: profile.rootMidi,
offsets: degrees.map((degree) => degreeToSemitone(profile, degree)),
},
pool,
this.lastMidiByStyle[styleIndex],
true
);
this.lastMidiByStyle[styleIndex] = midi;
this.playNote({
midi,
velocity:
(0.09 + expression * 0.08) *
this.config.styleVoices[styleIndex].velocityMultiplier,
startTime,
durationSeconds: 0.62 + expression * 0.24,
pan: this.getStylePan(styleIndex),
delaySend: 0.016 + expression * 0.006,
lowpassHz: this.getLowpassHz(profile, midi, expression),
});
}
private playGestureAccent(
vibe: VibePreset,
now: number,
styleIndex: GardenAudioStyleIndex,
strength: number
): void {
const profile = getVibeProfile(vibe);
const pool = this.generation.stylePools[styleIndex];
const degrees = this.rotate(pool.scaleDegrees, Math.round(strength * 3));
const midi = this.chooseMidi(
{
baseMidi: profile.rootMidi,
offsets: degrees.map((degree) => degreeToSemitone(profile, degree)),
},
pool,
this.lastMidiByStyle[styleIndex],
true
);
this.lastMidiByStyle[styleIndex] = midi;
this.playNote({
midi,
velocity:
(0.12 + strength * 0.09) * this.config.styleVoices[styleIndex].velocityMultiplier,
startTime: now + this.config.startDelaySeconds,
durationSeconds: 0.48 + strength * 0.22,
pan: this.getStylePan(styleIndex),
delaySend: 0.012,
lowpassHz: this.getLowpassHz(profile, midi, strength),
});
}
private playTouchNote({
vibe,
now,
styleIndex,
strength,
}: {
vibe: VibePreset;
now: number;
styleIndex: GardenAudioStyleIndex;
strength: number;
}): void {
const profile = getVibeProfile(vibe);
const pool = this.generation.stylePools[styleIndex];
const register = this.getBiasedRegister(pool, 0);
const chord = this.getChord(profile, this.getGlobalBarIndex(now));
const chordIntervals = getChordIntervals(chord, false);
const rootMidi = profile.rootMidi + chord.rootOffset;
const midi = this.chooseMidi(
{
baseMidi: rootMidi,
offsets: this.getSupportOffsets(chordIntervals, styleIndex),
},
register,
this.lastMidiByStyle[styleIndex],
true
);
this.lastMidiByStyle[styleIndex] = midi;
this.lastBrushStreamMidi = midi;
this.playNote({
midi,
velocity:
(0.14 + strength * 0.11) * this.config.styleVoices[styleIndex].velocityMultiplier,
startTime: now,
durationSeconds: 0.55 + strength * 0.18,
pan: this.getStylePan(styleIndex),
delaySend: 0.006,
lowpassHz: this.getLowpassHz(profile, midi, clamp01(0.55 + strength * 0.35)),
});
}
private startBrushPhraseLayer({
vibe,
now,
strength,
styleIndex,
maniaAmount,
}: {
vibe: VibePreset;
now: number;
strength: number;
styleIndex: GardenAudioStyleIndex;
maniaAmount: number;
}): void {
const lifetimeSeconds =
this.generation.brushLayerBaseSeconds +
strength * this.generation.brushLayerEnergySeconds;
this.brushPhraseLayers.push({
vibe,
startedAt: now,
expiresAt: now + lifetimeSeconds,
styleIndex,
energy: strength,
motifOffsets: [styleIndex - 1],
maniaAmount,
});
if (this.brushPhraseLayers.length > this.generation.maxBrushPhraseLayers) {
this.brushPhraseLayers = this.brushPhraseLayers.slice(
-this.generation.maxBrushPhraseLayers
);
}
}
private updateBrushPhraseLayer({
now,
strength,
styleIndex,
maniaAmount,
}: {
now: number;
strength: number;
styleIndex: GardenAudioStyleIndex;
maniaAmount: number;
}): void {
const layer = this.brushPhraseLayers[this.brushPhraseLayers.length - 1];
if (!layer || layer.expiresAt <= now) {
return;
}
layer.styleIndex = styleIndex;
layer.energy = Math.max(layer.energy * 0.94, strength);
layer.maniaAmount = Math.max(layer.maniaAmount * 0.92, maniaAmount);
layer.motifOffsets.push(this.getMotifOffset(strength));
if (layer.motifOffsets.length > this.generation.brushMotifMaxSteps) {
layer.motifOffsets = layer.motifOffsets.slice(-this.generation.brushMotifMaxSteps);
}
}
private renderBrushPhraseLayers({
vibe,
now,
lookaheadEnd,
activity,
}: {
vibe: VibePreset;
now: number;
lookaheadEnd: number;
activity: number;
}): void {
const earliestStart = now + this.config.piano.scheduleAheadSeconds;
this.nextBrushStreamAt ??= now + this.config.startDelaySeconds;
this.brushPhraseLayers = this.brushPhraseLayers.filter(
(layer) => layer.expiresAt > earliestStart
);
while (this.nextBrushStreamAt < earliestStart) {
const frame = this.getBrushStreamFrame(this.nextBrushStreamAt, activity);
this.nextBrushStreamAt += this.getBrushStreamIntervalSeconds(frame.intensity);
this.brushStreamNoteIndex += 1;
}
while (this.nextBrushStreamAt <= lookaheadEnd) {
const frame = this.getBrushStreamFrame(this.nextBrushStreamAt, activity);
if (frame.intensity >= this.generation.brushLayerMinIntensity) {
this.playBrushStreamNote({
vibe,
startTime: this.nextBrushStreamAt,
intensity: frame.intensity,
styleIndex: this.getStyleIndex(this.nextBrushStreamAt),
layer: frame.layer,
});
}
this.nextBrushStreamAt += this.getBrushStreamIntervalSeconds(frame.intensity);
this.brushStreamNoteIndex += 1;
}
}
private playBrushStreamNote({
vibe,
startTime,
intensity,
styleIndex,
layer,
}: {
vibe: VibePreset;
startTime: number;
intensity: number;
styleIndex: GardenAudioStyleIndex;
layer: BrushPhraseLayer | null;
}): void {
const profile = getVibeProfile(vibe);
const pool = this.generation.stylePools[styleIndex];
const maniaAmount = layer?.maniaAmount ?? clamp01((intensity - 0.82) / 0.18);
const register = this.getBiasedRegister(pool, maniaAmount * 0.45);
const chord = this.getChord(profile, this.getGlobalBarIndex(startTime));
const chordIntervals = getChordIntervals(chord, false);
const rootMidi = profile.rootMidi + chord.rootOffset;
const useChordTone = this.brushStreamNoteIndex % 4 === 0;
const source = useChordTone
? {
baseMidi: rootMidi,
offsets: this.getSupportOffsets(chordIntervals, styleIndex),
}
: {
baseMidi: profile.rootMidi,
offsets: this.getBrushMotifDegrees({
layer,
pool,
styleIndex,
}).map((degree) => degreeToSemitone(profile, degree)),
};
const midi = this.chooseMidi(source, register, this.lastBrushStreamMidi, true);
const pan = this.getStylePan(styleIndex);
const durationSeconds = clamp(
0.48 + intensity * 0.08 - maniaAmount * 0.34,
0.14,
0.62
);
const delaySend = clamp(
0.012 + intensity * 0.011 - maniaAmount * 0.006,
0.006,
0.032
);
this.lastBrushStreamMidi = midi;
this.lastMidiByStyle[styleIndex] = midi;
this.playNote({
midi,
velocity:
(0.1 + intensity * 0.13) * this.config.styleVoices[styleIndex].velocityMultiplier,
startTime,
durationSeconds,
pan,
delaySend,
lowpassHz: this.getLowpassHz(
profile,
midi,
clamp01(0.39 + intensity * 0.48 + maniaAmount * 0.18)
),
});
if (
maniaAmount >= 0.62 &&
(this.brushStreamNoteIndex % 2 === 1 || intensity >= 0.9)
) {
const echoMidi = midi + 12 <= 88 ? midi + 12 : midi - 12;
this.playNote({
midi: echoMidi,
velocity:
(0.045 + intensity * 0.05) *
this.config.styleVoices[styleIndex].velocityMultiplier,
startTime: startTime + this.generation.brushMotifCanonDelaySeconds,
durationSeconds: Math.max(0.11, durationSeconds * 0.68),
pan: clamp(-pan * 0.75, -1, 1),
delaySend: Math.max(0.006, delaySend * 0.72),
lowpassHz: this.getLowpassHz(profile, echoMidi, 0.62 + maniaAmount * 0.24),
});
}
}
private getBrushStreamFrame(
startTime: number,
activity: number
): {
intensity: number;
layer: BrushPhraseLayer | null;
} {
const layerStates = this.brushPhraseLayers.map((layer) => ({
layer,
intensity:
layer.energy *
this.getBrushPhraseFade(layer, startTime) *
(0.8 + layer.maniaAmount * 0.42),
}));
const dominant = layerStates.reduce<{
layer: BrushPhraseLayer;
intensity: number;
} | null>((best, state) => {
if (state.intensity <= 0) {
return best;
}
return best === null || state.intensity > best.intensity ? state : best;
}, null);
const layeredIntensity = layerStates.reduce(
(sum, state) => sum + Math.max(0, state.intensity),
0
);
return {
intensity: clamp01(
activity * 0.42 + layeredIntensity + (dominant?.layer.maniaAmount ?? 0) * 0.18
),
layer: dominant?.layer ?? null,
};
}
private getBrushStreamIntervalSeconds(intensity: number): number {
const intervalBeats =
intensity >= 0.85
? this.generation.brushStreamManicIntervalBeats
: intensity >= 0.62
? this.generation.brushStreamIntenseIntervalBeats
: intensity >= 0.34
? this.generation.brushStreamActiveIntervalBeats
: this.generation.brushStreamIdleIntervalBeats;
return this.getBeatDurationSeconds() * intervalBeats;
}
private getBrushPhraseFade(layer: BrushPhraseLayer, startTime: number): number {
const lifetimeSeconds = layer.expiresAt - layer.startedAt;
const ageSeconds = startTime - layer.startedAt;
return clamp01(1 - ageSeconds / Math.max(0.001, lifetimeSeconds));
}
private getMotifOffset(strength: number): number {
const energyStep = strength >= 0.82 ? 1 : strength >= 0.55 ? 0 : -1;
return clamp(energyStep, -3, 4);
}
private getBrushMotifDegrees({
layer,
pool,
styleIndex,
}: {
layer: BrushPhraseLayer | null;
pool: GardenAudioStylePool;
styleIndex: GardenAudioStyleIndex;
}): Array<number> {
const styleOffset = this.config.styleVoices[styleIndex].scaleDegreeOffset;
if (!layer || layer.motifOffsets.length === 0) {
return this.rotate(pool.scaleDegrees, this.brushStreamNoteIndex + styleOffset);
}
const motifOffset =
layer.motifOffsets[this.brushStreamNoteIndex % layer.motifOffsets.length];
const baseOffset = styleOffset + motifOffset;
return this.rotate(
pool.scaleDegrees.map((degree) => degree + baseOffset),
this.brushStreamNoteIndex
);
}
private getBiasedRegister(
register: GardenAudioRegister,
maniaAmount: number
): GardenAudioRegister {
const shift = Math.round(maniaAmount * 4);
const midiMin = clamp(register.midiMin + shift, 36, 86);
const midiMax = clamp(register.midiMax + shift, midiMin + 4, 91);
return {
midiMin,
midiMax,
preferredMidi: clamp(register.preferredMidi + shift, midiMin, midiMax),
pan: register.pan,
};
}
private chooseMidi(
pitchSource: PitchSource,
register: GardenAudioRegister,
previousMidi: number | null = null,
avoidRepeat = false
): number {
const candidates = this.getCandidates(pitchSource, register);
const referenceMidi = previousMidi ?? register.preferredMidi;
if (candidates.length === 0) {
return register.preferredMidi;
}
return candidates.reduce((best, candidate) =>
this.scoreCandidate(candidate, register, referenceMidi, avoidRepeat) <
this.scoreCandidate(best, register, referenceMidi, avoidRepeat)
? candidate
: best
).midi;
}
private getCandidates(
pitchSource: PitchSource,
register: GardenAudioRegister
): Array<PitchCandidate> {
const candidates: Array<PitchCandidate> = [];
pitchSource.offsets.forEach((offset, preference) => {
for (let octave = -3; octave <= 3; octave += 1) {
const midi = pitchSource.baseMidi + offset + octave * 12;
if (midi >= register.midiMin && midi <= register.midiMax) {
candidates.push({ midi: Math.round(midi), preference });
}
}
});
return candidates;
}
private scoreCandidate(
candidate: PitchCandidate,
register: GardenAudioRegister,
previousMidi: number,
avoidRepeat: boolean
): number {
return (
Math.abs(candidate.midi - previousMidi) +
Math.abs(candidate.midi - register.preferredMidi) *
this.generation.noteScoreRegisterWeight +
candidate.preference * this.generation.noteScorePreferenceWeight +
(avoidRepeat && candidate.midi === previousMidi
? this.generation.noteScoreRepeatPenalty
: 0)
);
}
private shouldPlaySupport(expression: number, barIndex: number): boolean {
if (expression >= 0.55) {
return true;
}
return (
barIndex % this.generation.supportBarSpacing === this.generation.supportBarOffset
);
}
private shouldPlayTexture(expression: number, barIndex: number): boolean {
const spacing =
expression < 0.35
? this.generation.idleTextureBarSpacing
: expression < 0.7
? this.generation.mediumTextureBarSpacing
: 1;
return barIndex % spacing === (spacing === 1 ? 0 : 1);
}
private getSupportOffsets(
chordIntervals: ReadonlyArray<number>,
styleIndex: GardenAudioStyleIndex
): Array<number> {
if (styleIndex === 0) {
return [0, chordIntervals[2], 12];
}
if (styleIndex === 1) {
return [chordIntervals[1], chordIntervals[2], 0, 12];
}
return [chordIntervals[2], 12, chordIntervals[3], chordIntervals[1] + 12];
}
private getChord(profile: GardenAudioVibeProfile, barIndex: number): GardenAudioChord {
const progressionIndex =
Math.floor(barIndex / this.generation.chordBars) % profile.progression.length;
return profile.progression[progressionIndex];
}
private getGlobalBarIndex(startTime: number): number {
const timelineStartedAt = this.timelineStartedAt ?? startTime;
const elapsedSeconds = Math.max(0, startTime - timelineStartedAt);
return Math.floor(elapsedSeconds / this.getBarDurationSeconds());
}
private getStyleIndex(startTime: number): GardenAudioStyleIndex {
const timelineStartedAt = this.timelineStartedAt ?? startTime;
const elapsedSeconds = Math.max(0, startTime - timelineStartedAt);
const styleCount = this.generation.stylePools.length;
const rotationSeconds = Math.max(0.001, this.generation.styleRotationSeconds);
return (Math.floor(elapsedSeconds / rotationSeconds) %
styleCount) as GardenAudioStyleIndex;
}
private getStylePan(styleIndex: GardenAudioStyleIndex): number {
const pool = this.generation.stylePools[styleIndex];
const styleVoice = this.config.styleVoices[styleIndex];
return clamp(pool.pan + styleVoice.panOffset * 0.35, -1, 1);
}
private getLowpassHz(
profile: GardenAudioVibeProfile,
midi: number,
expression: number
): number {
const midiLift = clamp01((midi - 48) / 33) * 720;
return clamp(
this.config.piano.lowpassHz * profile.brightness * (0.58 + expression * 0.32) +
midiLift,
this.config.piano.lowpassMinHz,
this.config.piano.lowpassMaxHz
);
}
private skipLateBeats(now: number, beatSeconds: number): void {
if (this.nextBeatAt === null) {
return;
}
const earliestStart = now + this.config.piano.scheduleAheadSeconds;
if (this.nextBeatAt >= earliestStart) {
return;
}
const skippedBeats = Math.floor((earliestStart - this.nextBeatAt) / beatSeconds) + 1;
this.nextBeatAt += skippedBeats * beatSeconds;
this.beatIndex += skippedBeats;
}
private getExpression(activity: number): number {
return clamp01(
(activity - this.config.rhythm.sparseActivity) /
(1 - this.config.rhythm.sparseActivity)
);
}
private getBeatDurationSeconds(): number {
return 60 / this.config.rhythm.bpm;
}
private getBarDurationSeconds(): number {
return this.getBeatDurationSeconds() * this.getBeatsPerBar();
}
private getBeatsPerBar(): number {
return Math.max(
1,
Math.round(this.config.rhythm.stepsPerBar / this.config.rhythm.stepsPerBeat)
);
}
private rotate<T>(values: ReadonlyArray<T>, offset: number): Array<T> {
return values.map((_, index) => values[(index + offset) % values.length]);
}
}