WIP
This commit is contained in:
parent
34ac200437
commit
39b0160064
136 changed files with 7129 additions and 1950 deletions
674
src/audio/generative-piano.ts
Normal file
674
src/audio/generative-piano.ts
Normal 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;
|
||||
};
|
||||
Loading…
Reference in a new issue