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; } interface BrushPhraseLayer { vibe: VibePreset; startedAt: number; expiresAt: number; styleIndex: GardenAudioStyleIndex; energy: number; motifOffsets: Array; 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 = []; 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 { 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 { const candidates: Array = []; 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, styleIndex: GardenAudioStyleIndex ): Array { 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(values: ReadonlyArray, offset: number): Array { return values.map((_, index) => values[(index + offset) % values.length]); } }