958 lines
27 KiB
TypeScript
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]);
|
|
}
|
|
}
|