more cleaning up

This commit is contained in:
Andras Schmelczer 2026-05-16 20:45:42 +01:00
commit 560398fefb
110 changed files with 931 additions and 2645 deletions

View file

@ -7,7 +7,7 @@ export interface GardenAudioChord {
quality: GardenAudioChordQuality;
}
interface GardenAudioColorVoice {
interface GardenAudioStyleVoice {
scaleDegreeOffset: number;
velocityMultiplier: number;
panOffset: number;
@ -20,13 +20,14 @@ export interface GardenAudioRegister {
pan: number;
}
export interface GardenAudioColorPool extends GardenAudioRegister {
export interface GardenAudioStylePool extends GardenAudioRegister {
scaleDegrees: Array<number>;
}
interface GardenAudioGenerativePianoConfig {
colorPools: [GardenAudioColorPool, GardenAudioColorPool, GardenAudioColorPool];
stylePools: [GardenAudioStylePool, GardenAudioStylePool, GardenAudioStylePool];
padRegisters: [GardenAudioRegister, GardenAudioRegister, GardenAudioRegister];
styleRotationSeconds: number;
chordBars: number;
supportBarSpacing: number;
supportBarOffset: number;
@ -38,16 +39,14 @@ interface GardenAudioGenerativePianoConfig {
noteScorePreferenceWeight: number;
noteScoreRegisterWeight: number;
noteScoreRepeatPenalty: number;
gestureAccentSpacingSeconds: number;
gestureAccentMinIntervalSeconds: number;
strokeAccentMinIntervalSeconds: number;
strokeAccentThreshold: number;
stingerSpacingSeconds: number;
stingerDurationSeconds: number;
stingerSpacingSeconds: number;
maxBrushPhraseLayers: number;
brushLayerBaseSeconds: number;
brushLayerEnergySeconds: number;
brushLayerMirrorSeconds: number;
brushLayerMinIntensity: number;
brushStreamIdleIntervalBeats: number;
brushStreamActiveIntervalBeats: number;
@ -137,12 +136,10 @@ export interface GardenAudioConfig {
unlockTickSeconds: number;
};
input: {
activeActivityThreshold: number;
distanceWindowForFullActivityPixels: number;
distanceWindowSeconds: number;
fallbackFrameSeconds: number;
manicActivityThreshold: number;
manicModeThreshold: number;
};
muteGain: number;
muteRampSeconds: number;
@ -156,8 +153,7 @@ export interface GardenAudioConfig {
startDelaySeconds: number;
vibeChangeStingerMinIntervalSeconds: number;
generativePiano: GardenAudioGenerativePianoConfig;
colorVoices: [GardenAudioColorVoice, GardenAudioColorVoice, GardenAudioColorVoice];
vibes: Record<string, GardenAudioVibeProfile>;
styleVoices: [GardenAudioStyleVoice, GardenAudioStyleVoice, GardenAudioStyleVoice];
}
export const gardenAudioConfig: GardenAudioConfig = appConfig.audio;

View file

@ -2,10 +2,7 @@ import { clamp01 } from '../utils/clamp';
import type { GardenAudioConfig } from './garden-audio-config';
import type { GardenAudioStrokeMetrics } from './garden-audio-input';
type GardenAudioGestureMode = 'calm' | 'active' | 'manic' | 'afterglow';
interface GardenAudioGestureFrame {
mode: GardenAudioGestureMode;
activity: number;
maniaAmount: number;
}
@ -15,44 +12,18 @@ interface GestureDistanceSample {
distancePixels: number;
}
const DEFAULT_FRAME: GardenAudioGestureFrame = {
mode: 'calm',
activity: 0,
maniaAmount: 0,
};
export class GardenAudioGestureState {
private readonly samples: Array<GestureDistanceSample> = [];
private gestureClockSeconds = 0;
private peakActivity = 0;
private lastFrame: GardenAudioGestureFrame = DEFAULT_FRAME;
public constructor(private readonly inputConfig: GardenAudioConfig['input']) {}
public beginGesture(): void {
this.samples.length = 0;
this.gestureClockSeconds = 0;
this.peakActivity = 0;
this.lastFrame = DEFAULT_FRAME;
this.reset();
}
public endGesture(): GardenAudioGestureFrame {
this.samples.length = 0;
this.gestureClockSeconds = 0;
this.lastFrame = {
...DEFAULT_FRAME,
mode:
this.peakActivity >= this.inputConfig.activeActivityThreshold
? 'afterglow'
: 'calm',
};
this.peakActivity = 0;
return this.lastFrame;
}
public recordTouchDown(): GardenAudioGestureFrame {
this.lastFrame = DEFAULT_FRAME;
return this.lastFrame;
public endGesture(): void {
this.reset();
}
public recordStroke({
@ -77,28 +48,16 @@ export class GardenAudioGestureState {
const activity = clamp01(
windowDistancePixels / this.inputConfig.distanceWindowForFullActivityPixels
);
const maniaAmount = smoothstep(this.inputConfig.manicActivityThreshold, 1, activity);
this.peakActivity = Math.max(this.peakActivity, activity);
this.lastFrame = {
...DEFAULT_FRAME,
mode: this.getMode(activity, maniaAmount),
return {
activity,
maniaAmount,
maniaAmount: smoothstep(this.inputConfig.manicActivityThreshold, 1, activity),
};
return this.lastFrame;
}
public getFrame(): GardenAudioGestureFrame {
return this.lastFrame;
}
public reset(): void {
this.samples.length = 0;
this.gestureClockSeconds = 0;
this.peakActivity = 0;
this.lastFrame = DEFAULT_FRAME;
}
private trimSamples(): void {
@ -107,14 +66,6 @@ export class GardenAudioGestureState {
this.samples.shift();
}
}
private getMode(activity: number, maniaAmount: number): GardenAudioGestureMode {
if (maniaAmount >= this.inputConfig.manicModeThreshold) {
return 'manic';
}
return activity >= this.inputConfig.activeActivityThreshold ? 'active' : 'calm';
}
}
const smoothstep = (edge0: number, edge1: number, value: number): number => {

View file

@ -1,25 +1,7 @@
import { VibePreset } from '../vibes';
import {
GardenAudioChord,
GardenAudioConfig,
GardenAudioVibeProfile,
} from './garden-audio-config';
import { GardenAudioColorIndex } from './garden-audio-types';
import { GardenAudioChord, GardenAudioVibeProfile } from './garden-audio-config';
export const normalizeColorIndex = (index: number): GardenAudioColorIndex =>
Math.max(0, Math.min(2, Math.round(index))) as GardenAudioColorIndex;
export const getVibeProfile = (
config: GardenAudioConfig,
vibe: VibePreset
): GardenAudioVibeProfile => {
const profile = config.vibes[vibe.id];
if (!profile) {
throw new Error(`Missing audio profile for vibe "${vibe.id}"`);
}
return profile;
};
export const getVibeProfile = (vibe: VibePreset): GardenAudioVibeProfile => vibe.audio;
export const getChordIntervals = (
chord: GardenAudioChord,

View file

@ -1,10 +1,7 @@
import { VibePreset } from '../vibes';
export type GardenAudioColorIndex = 0 | 1 | 2;
export interface GardenAudioSnapshot {
vibe: VibePreset;
selectedColorIndex: number;
isErasing: boolean;
}
@ -12,15 +9,10 @@ export interface GardenAudioStroke {
vibe: VibePreset;
from: ArrayLike<number>;
to: ArrayLike<number>;
colorIndex: number;
isErasing: boolean;
elapsedSeconds?: number;
}
export interface GardenAudioTouchDown {
colorIndex: number;
}
export interface GardenAudioStartOptions {
userGesture?: boolean;
}
@ -33,7 +25,6 @@ export interface LoadedPianoSample {
export interface ActivePianoVoice {
gain: GainNode;
source: AudioScheduledSourceNode;
startAt: number;
stopAt: number;
}

View file

@ -1,11 +1,8 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { appConfig } from '../config';
import { ErrorHandler, Severity } from '../utils/error-handler';
import { VIBE_PRESETS } from '../vibes';
import { GardenAudio } from './garden-audio';
import { gardenAudioConfig, GardenAudioConfig } from './garden-audio-config';
import { loadPianoSamples, resetPianoSampleCacheForTest } from './piano-samples';
type FakeScheduledSourceNode = {
start: ReturnType<typeof vi.fn>;
@ -21,6 +18,10 @@ const calls = {
let contextState: AudioContextState = 'suspended';
let resumeError: Error | null = null;
let ErrorHandler: typeof import('../utils/error-handler').ErrorHandler;
let GardenAudio: typeof import('./garden-audio').GardenAudio;
let loadPianoSamples: typeof import('./piano-samples').loadPianoSamples;
let Severity: typeof import('../utils/error-handler').Severity;
class FakeAudioParam {
public value = 0;
@ -140,14 +141,18 @@ const makeConfig = (): GardenAudioConfig => ({
});
describe('GardenAudio startup policy', () => {
beforeEach(() => {
beforeEach(async () => {
vi.resetModules();
({ ErrorHandler, Severity } = await import('../utils/error-handler'));
({ GardenAudio } = await import('./garden-audio'));
({ loadPianoSamples } = await import('./piano-samples'));
calls.constructed = 0;
calls.resumed = 0;
calls.sourcesStarted = 0;
calls.sources = [];
contextState = 'suspended';
resumeError = null;
resetPianoSampleCacheForTest();
vi.stubGlobal('AudioContext', FakeAudioContext);
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('not loaded in tests')));
});
@ -155,7 +160,6 @@ describe('GardenAudio startup policy', () => {
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
resetPianoSampleCacheForTest();
});
it('does not create an AudioContext from passive audio paths', () => {
@ -167,7 +171,6 @@ describe('GardenAudio startup policy', () => {
vibe,
from: [0, 0],
to: [12, 0],
colorIndex: 0,
isErasing: false,
});
@ -215,14 +218,10 @@ describe('GardenAudio startup policy', () => {
expect(calls.sourcesStarted).toBe(1);
audio.beginGesture();
audio.touchDown({
colorIndex: 1,
});
audio.stroke({
vibe,
from: [30, 40],
to: [60, 60],
colorIndex: 1,
isErasing: false,
elapsedSeconds: 0.05,
});
@ -233,7 +232,6 @@ describe('GardenAudio startup policy', () => {
vibe,
from: [60, 60],
to: [75, 80],
colorIndex: 1,
isErasing: true,
elapsedSeconds: 0.05,
});
@ -256,14 +254,10 @@ describe('GardenAudio startup policy', () => {
audio.start(vibe, { userGesture: true });
audio.beginGesture();
audio.touchDown({
colorIndex: 1,
});
audio.stroke({
vibe,
from: [30, 40],
to: [90, 40],
colorIndex: 1,
elapsedSeconds: 0.05,
isErasing: false,
});

View file

@ -6,13 +6,11 @@ import { GardenAudioEnergy } from './garden-audio-energy';
import { GardenAudioGestureState } from './garden-audio-gesture-state';
import { GardenAudioGraph } from './garden-audio-graph';
import { getStrokeMetrics } from './garden-audio-input';
import { getVibeProfile, normalizeColorIndex } from './garden-audio-music';
import { getVibeProfile } from './garden-audio-music';
import type {
GardenAudioColorIndex,
GardenAudioSnapshot,
GardenAudioStartOptions,
GardenAudioStroke,
GardenAudioTouchDown,
} from './garden-audio-types';
import { GenerativePianoEngine } from './generative-piano';
import { NoiseBurstPlayer } from './noise-burst-player';
@ -22,7 +20,6 @@ export type {
GardenAudioSnapshot,
GardenAudioStartOptions,
GardenAudioStroke,
GardenAudioTouchDown,
} from './garden-audio-types';
export class GardenAudio {
@ -38,7 +35,6 @@ export class GardenAudio {
private isDestroyed = false;
private isMuted = false;
private isGestureActive = false;
private selectedColorIndex: GardenAudioColorIndex = 0;
private hasQueuedPianoLoad = false;
private lastEraserAt = Number.NEGATIVE_INFINITY;
private lastVibeStingerAt = Number.NEGATIVE_INFINITY;
@ -162,20 +158,6 @@ export class GardenAudio {
this.pianoEngine.endGesture();
}
public touchDown(touch: GardenAudioTouchDown): void {
if (this.isDestroyed || this.isMuted) {
return;
}
const context = this.graph.context;
if (!context || !this.isGestureActive) {
return;
}
this.selectedColorIndex = normalizeColorIndex(touch.colorIndex);
this.gestureState.recordTouchDown();
}
public update(snapshot: GardenAudioSnapshot): void {
const context = this.graph.context;
if (!this.hasStarted || !context || this.isMuted) {
@ -183,7 +165,6 @@ export class GardenAudio {
}
this.applyVibe(snapshot.vibe);
this.selectedColorIndex = normalizeColorIndex(snapshot.selectedColorIndex);
this.energy.update(context.currentTime);
if (snapshot.isErasing) {
@ -194,7 +175,6 @@ export class GardenAudio {
vibe: snapshot.vibe,
now: context.currentTime,
activity: snapshot.isErasing ? 0 : this.energy.getLevel(),
selectedColorIndex: this.selectedColorIndex,
});
this.updateDelay(snapshot);
}
@ -216,7 +196,6 @@ export class GardenAudio {
const metrics = getStrokeMetrics(stroke, this.config.input);
const now = context.currentTime;
this.selectedColorIndex = normalizeColorIndex(stroke.colorIndex);
const frame = this.gestureState.recordStroke({ metrics });
const strokeEnergy = frame.activity;
@ -231,7 +210,6 @@ export class GardenAudio {
vibe: stroke.vibe,
now,
activity: strokeEnergy,
selectedColorIndex: this.selectedColorIndex,
maniaAmount: frame.maniaAmount,
});
}
@ -247,7 +225,6 @@ export class GardenAudio {
this.currentVibeId = null;
this.hasStarted = false;
this.isGestureActive = false;
this.selectedColorIndex = 0;
this.hasQueuedPianoLoad = false;
this.lastEraserAt = Number.NEGATIVE_INFINITY;
this.lastVibeStingerAt = Number.NEGATIVE_INFINITY;
@ -301,7 +278,7 @@ export class GardenAudio {
return;
}
const profile = getVibeProfile(this.config, snapshot.vibe);
const profile = getVibeProfile(snapshot.vibe);
const activity = snapshot.isErasing
? this.config.delay.erasingActivity
: this.energy.getLevel();
@ -314,7 +291,7 @@ export class GardenAudio {
}
this.currentVibeId = vibe.id;
this.graph.applyDelayProfile(getVibeProfile(this.config, vibe));
this.graph.applyDelayProfile(getVibeProfile(vibe));
this.pianoEngine.cue(this.graph.context.currentTime);
}
}

View file

@ -24,14 +24,13 @@ const getBeatsPerBar = (): number =>
const renderBars = (
engine: GenerativePianoEngine,
activity: number,
selectedColorIndex = 0,
bars = 8
bars = 8,
now = 0
) => {
engine.renderLookahead({
vibe: VIBE_PRESETS[0],
now: 0,
now,
activity,
selectedColorIndex: selectedColorIndex as 0 | 1 | 2,
lookaheadSeconds: getBeatSeconds() * getBeatsPerBar() * bars,
});
};
@ -64,7 +63,7 @@ describe('GenerativePianoEngine', () => {
it('keeps the background sparse instead of filling every beat', () => {
const { engine, notes } = makeEngine();
renderBars(engine, 0, 1, 4);
renderBars(engine, 0, 4);
expect(uniqueStartTimes(notes).length).toBeLessThan(8);
});
@ -74,8 +73,8 @@ describe('GenerativePianoEngine', () => {
const active = makeEngine();
const startDelaySeconds = 0.02;
renderBars(idle.engine, 0, 1, 8);
renderBars(active.engine, 1, 1, 8);
renderBars(idle.engine, 0, 8);
renderBars(active.engine, 1, 8);
expect(active.notes.length).toBeGreaterThan(idle.notes.length);
active.notes.forEach((note) => {
@ -84,29 +83,34 @@ describe('GenerativePianoEngine', () => {
});
});
it('uses color pools with multiple notes instead of one key per color', () => {
([0, 1, 2] as const).forEach((selectedColorIndex) => {
const { engine, notes } = makeEngine();
it('uses style pools with multiple notes instead of one repeating key', () => {
const { engine, notes } = makeEngine();
renderBars(engine, 1, selectedColorIndex, 16);
renderBars(engine, 1, 16);
expect(new Set(notes.map((note) => note.midi)).size).toBeGreaterThan(3);
});
expect(new Set(notes.map((note) => note.midi)).size).toBeGreaterThan(3);
});
it('keeps the upper color higher and wider than the lower color', () => {
const lower = makeEngine();
const upper = makeEngine();
it('changes musical style over time without a color change', () => {
const { engine, notes } = makeEngine();
renderBars(lower.engine, 1, 0, 16);
renderBars(upper.engine, 1, 2, 16);
renderBars(engine, 1, 32);
expect(average(upper.notes.map((note) => note.midi))).toBeGreaterThan(
average(lower.notes.map((note) => note.midi))
const styleWindows = [
notes.filter((note) => note.startTime >= 0 && note.startTime < 8),
notes.filter((note) => note.startTime >= 8 && note.startTime < 16),
notes.filter((note) => note.startTime >= 16 && note.startTime < 24),
];
const averageMidiByWindow = styleWindows.map((windowNotes) =>
Math.round(average(windowNotes.map((note) => note.midi)))
);
expect(average(upper.notes.map((note) => note.pan))).toBeGreaterThan(
average(lower.notes.map((note) => note.pan))
const averagePanByWindow = styleWindows.map((windowNotes) =>
Number(average(windowNotes.map((note) => note.pan)).toFixed(2))
);
expect(styleWindows.every((windowNotes) => windowNotes.length > 0)).toBe(true);
expect(new Set(averageMidiByWindow).size).toBeGreaterThan(1);
expect(new Set(averagePanByWindow).size).toBeGreaterThan(1);
});
it('starts a fading brush phrase layer with each new brush gesture', () => {
@ -118,7 +122,6 @@ describe('GenerativePianoEngine', () => {
vibe: VIBE_PRESETS[0],
now,
activity: 0.35,
selectedColorIndex: 1,
lookaheadSeconds: 12,
});
@ -127,14 +130,11 @@ describe('GenerativePianoEngine', () => {
vibe: VIBE_PRESETS[0],
now,
activity: 0.85,
selectedColorIndex: 1,
mirrorAmount: 0.45,
});
layered.engine.renderLookahead({
vibe: VIBE_PRESETS[0],
now,
activity: 0.35,
selectedColorIndex: 1,
lookaheadSeconds: 12,
});
@ -149,46 +149,6 @@ describe('GenerativePianoEngine', () => {
expect(lateExtra).toBe(0);
});
it('makes brush phrase layers denser at higher mirror amounts', () => {
const lowMirror = makeEngine();
const highMirror = makeEngine();
const now = 4;
lowMirror.engine.beginGesture();
lowMirror.engine.recordStroke({
vibe: VIBE_PRESETS[0],
now,
activity: 0.85,
selectedColorIndex: 2,
mirrorAmount: 0,
});
lowMirror.engine.renderLookahead({
vibe: VIBE_PRESETS[0],
now,
activity: 0.35,
selectedColorIndex: 2,
lookaheadSeconds: 9,
});
highMirror.engine.beginGesture();
highMirror.engine.recordStroke({
vibe: VIBE_PRESETS[0],
now,
activity: 0.85,
selectedColorIndex: 2,
mirrorAmount: 1,
});
highMirror.engine.renderLookahead({
vibe: VIBE_PRESETS[0],
now,
activity: 0.35,
selectedColorIndex: 2,
lookaheadSeconds: 9,
});
expect(highMirror.notes.length).toBeGreaterThan(lowMirror.notes.length);
});
it('plays one immediate touch note and throttles later stroke accents', () => {
const { engine, notes } = makeEngine();
const now = 4;
@ -198,13 +158,11 @@ describe('GenerativePianoEngine', () => {
vibe: VIBE_PRESETS[0],
now,
activity: 0.9,
selectedColorIndex: 1,
});
engine.recordStroke({
vibe: VIBE_PRESETS[0],
now: now + 1,
activity: 0.95,
selectedColorIndex: 1,
});
expect(notes).toHaveLength(1);
@ -214,7 +172,6 @@ describe('GenerativePianoEngine', () => {
vibe: VIBE_PRESETS[0],
now: now + 6,
activity: 0.95,
selectedColorIndex: 1,
});
expect(notes).toHaveLength(2);
@ -225,8 +182,8 @@ describe('GenerativePianoEngine', () => {
const first = makeEngine();
const second = makeEngine();
renderBars(first.engine, 0.78, 2, 16);
renderBars(second.engine, 0.78, 2, 16);
renderBars(first.engine, 0.78, 16);
renderBars(second.engine, 0.78, 16);
expect(second.notes).toEqual(first.notes);
});

View file

@ -2,9 +2,9 @@ import { clamp, clamp01 } from '../utils/clamp';
import { VibePreset } from '../vibes';
import {
GardenAudioChord,
GardenAudioColorPool,
GardenAudioConfig,
GardenAudioRegister,
GardenAudioStylePool,
GardenAudioVibeProfile,
} from './garden-audio-config';
import {
@ -12,13 +12,14 @@ import {
getChordIntervals,
getVibeProfile,
} from './garden-audio-music';
import { GardenAudioColorIndex, PianoNote } from './garden-audio-types';
import { PianoNote } from './garden-audio-types';
type GardenAudioStyleIndex = 0 | 1 | 2;
interface RenderLookaheadRequest {
vibe: VibePreset;
now: number;
activity: number;
selectedColorIndex: GardenAudioColorIndex;
lookaheadSeconds?: number;
}
@ -26,14 +27,6 @@ interface StrokeAccentRequest {
vibe: VibePreset;
now: number;
activity: number;
selectedColorIndex: GardenAudioColorIndex;
mirrorAmount?: number;
panBias?: number;
registerBias?: number;
brightnessBias?: number;
contour?: number;
pressureAmount?: number;
pressureDelta?: number;
maniaAmount?: number;
}
@ -41,14 +34,6 @@ interface TouchDownRequest {
vibe: VibePreset;
now: number;
strength: number;
selectedColorIndex: GardenAudioColorIndex;
mirrorAmount?: number;
panBias?: number;
registerBias?: number;
brightnessBias?: number;
contour?: number;
pressureAmount?: number;
pressureDelta?: number;
maniaAmount?: number;
}
@ -66,16 +51,9 @@ interface BrushPhraseLayer {
vibe: VibePreset;
startedAt: number;
expiresAt: number;
selectedColorIndex: GardenAudioColorIndex;
styleIndex: GardenAudioStyleIndex;
energy: number;
mirrorAmount: number;
motifOffsets: Array<number>;
panBias: number;
registerBias: number;
brightnessBias: number;
contour: number;
pressureAmount: number;
pressureDelta: number;
maniaAmount: number;
}
@ -86,7 +64,7 @@ export class GenerativePianoEngine {
private isWaitingForGestureAccent = false;
private lastGestureAccentAt = Number.NEGATIVE_INFINITY;
private lastStrokeAccentAt = Number.NEGATIVE_INFINITY;
private readonly lastMidiByColor: [number | null, number | null, number | null] = [
private readonly lastMidiByStyle: [number | null, number | null, number | null] = [
null,
null,
null,
@ -130,31 +108,15 @@ export class GenerativePianoEngine {
this.isWaitingForGestureAccent = false;
}
public recordTouchDown({
private recordTouchDown({
vibe,
now,
strength,
selectedColorIndex,
mirrorAmount = 0,
panBias = 0,
registerBias = 0,
brightnessBias = 0.5,
contour = 0,
pressureAmount = 0,
pressureDelta = 0,
maniaAmount = 0,
}: TouchDownRequest): void {
const normalizedStrength = clamp01(strength);
const normalizedMirrorAmount = clamp01(mirrorAmount);
const normalizedMotif = this.normalizeMotif({
panBias,
registerBias,
brightnessBias,
contour,
pressureAmount,
pressureDelta,
maniaAmount,
});
const normalizedManiaAmount = clamp01(maniaAmount);
const styleIndex = this.getStyleIndex(now);
this.isWaitingForGestureAccent = false;
this.lastGestureAccentAt = now;
@ -163,18 +125,14 @@ export class GenerativePianoEngine {
vibe,
now,
strength: normalizedStrength,
selectedColorIndex,
mirrorAmount: normalizedMirrorAmount,
...normalizedMotif,
styleIndex,
maniaAmount: normalizedManiaAmount,
});
this.playTouchNote({
vibe,
now,
selectedColorIndex,
styleIndex,
strength: normalizedStrength,
panBias: normalizedMotif.panBias,
registerBias: normalizedMotif.registerBias,
brightnessBias: normalizedMotif.brightnessBias,
});
}
@ -182,27 +140,11 @@ export class GenerativePianoEngine {
vibe,
now,
activity,
selectedColorIndex,
mirrorAmount = 0,
panBias = 0,
registerBias = 0,
brightnessBias = 0.5,
contour = 0,
pressureAmount = 0,
pressureDelta = 0,
maniaAmount = 0,
}: StrokeAccentRequest): void {
const strength = clamp01(activity);
const normalizedMirrorAmount = clamp01(mirrorAmount);
const normalizedMotif = this.normalizeMotif({
panBias,
registerBias,
brightnessBias,
contour,
pressureAmount,
pressureDelta,
maniaAmount,
});
const normalizedManiaAmount = clamp01(maniaAmount);
const styleIndex = this.getStyleIndex(now);
if (
this.isWaitingForGestureAccent &&
@ -212,9 +154,7 @@ export class GenerativePianoEngine {
vibe,
now,
strength,
selectedColorIndex,
mirrorAmount: normalizedMirrorAmount,
...normalizedMotif,
maniaAmount: normalizedManiaAmount,
});
return;
}
@ -223,16 +163,15 @@ export class GenerativePianoEngine {
this.updateBrushPhraseLayer({
now,
strength,
selectedColorIndex,
mirrorAmount: normalizedMirrorAmount,
...normalizedMotif,
styleIndex,
maniaAmount: normalizedManiaAmount,
});
if (
strength >= this.generation.strokeAccentThreshold &&
now - this.lastStrokeAccentAt >= this.generation.strokeAccentMinIntervalSeconds
) {
this.lastStrokeAccentAt = now;
this.playGestureAccent(vibe, now, selectedColorIndex, strength, 1);
this.playGestureAccent(vibe, now, styleIndex, strength);
}
}
@ -240,7 +179,6 @@ export class GenerativePianoEngine {
vibe,
now,
activity,
selectedColorIndex,
lookaheadSeconds = this.config.rhythm.lookaheadSeconds,
}: RenderLookaheadRequest): void {
this.prime(now);
@ -250,7 +188,7 @@ export class GenerativePianoEngine {
return;
}
const profile = getVibeProfile(this.config, vibe);
const profile = getVibeProfile(vibe);
const lookaheadEnd = now + lookaheadSeconds;
while (this.nextBeatAt <= lookaheadEnd) {
this.renderBeat({
@ -258,7 +196,6 @@ export class GenerativePianoEngine {
beatIndex: this.beatIndex,
startTime: this.nextBeatAt,
expression: this.getExpression(activity),
selectedColorIndex,
});
this.nextBeatAt += this.getBeatDurationSeconds();
this.beatIndex += 1;
@ -268,12 +205,11 @@ export class GenerativePianoEngine {
now,
lookaheadEnd,
activity,
selectedColorIndex,
});
}
public playVibeChangeStinger(vibe: VibePreset, now: number): void {
const profile = getVibeProfile(this.config, vibe);
const profile = getVibeProfile(vibe);
const chord = this.getChord(profile, 0);
const intervals = getChordIntervals(chord, true);
const rootMidi = profile.rootMidi + chord.rootOffset;
@ -324,9 +260,9 @@ export class GenerativePianoEngine {
this.isWaitingForGestureAccent = false;
this.lastGestureAccentAt = Number.NEGATIVE_INFINITY;
this.lastStrokeAccentAt = Number.NEGATIVE_INFINITY;
this.lastMidiByColor[0] = null;
this.lastMidiByColor[1] = null;
this.lastMidiByColor[2] = null;
this.lastMidiByStyle[0] = null;
this.lastMidiByStyle[1] = null;
this.lastMidiByStyle[2] = null;
this.brushPhraseLayers = [];
this.nextBrushStreamAt = null;
this.brushStreamNoteIndex = 0;
@ -338,31 +274,30 @@ export class GenerativePianoEngine {
beatIndex,
startTime,
expression,
selectedColorIndex,
}: {
profile: GardenAudioVibeProfile;
beatIndex: number;
startTime: number;
expression: number;
selectedColorIndex: GardenAudioColorIndex;
}): 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, selectedColorIndex);
this.playSupportNote(profile, barIndex, startTime, expression, styleIndex);
}
if (
beatInBar === this.generation.textureBeat &&
this.shouldPlayTexture(expression, barIndex)
) {
this.playTextureNote(profile, barIndex, startTime, expression, selectedColorIndex);
this.playTextureNote(profile, barIndex, startTime, expression, styleIndex);
}
if (
@ -374,7 +309,7 @@ export class GenerativePianoEngine {
barIndex + 1,
startTime,
expression * 0.9,
selectedColorIndex
styleIndex
);
}
}
@ -429,31 +364,31 @@ export class GenerativePianoEngine {
barIndex: number,
startTime: number,
expression: number,
selectedColorIndex: GardenAudioColorIndex
styleIndex: GardenAudioStyleIndex
): void {
const pool = this.generation.colorPools[selectedColorIndex];
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, selectedColorIndex),
offsets: this.getSupportOffsets(chordIntervals, styleIndex),
},
pool,
this.lastMidiByColor[selectedColorIndex],
this.lastMidiByStyle[styleIndex],
true
);
this.lastMidiByColor[selectedColorIndex] = midi;
this.lastMidiByStyle[styleIndex] = midi;
this.playNote({
midi,
velocity:
(0.105 + expression * 0.07) *
this.config.colorVoices[selectedColorIndex].velocityMultiplier,
this.config.styleVoices[styleIndex].velocityMultiplier,
startTime,
durationSeconds: 1.35 + expression * 0.4,
pan: this.getColorPan(selectedColorIndex),
pan: this.getStylePan(styleIndex),
delaySend: 0.016 + expression * 0.006,
lowpassHz: this.getLowpassHz(profile, midi, expression * 0.7),
});
@ -464,29 +399,29 @@ export class GenerativePianoEngine {
barIndex: number,
startTime: number,
expression: number,
selectedColorIndex: GardenAudioColorIndex
styleIndex: GardenAudioStyleIndex
): void {
const pool = this.generation.colorPools[selectedColorIndex];
const degrees = this.rotate(pool.scaleDegrees, barIndex + selectedColorIndex);
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.lastMidiByColor[selectedColorIndex],
this.lastMidiByStyle[styleIndex],
true
);
this.lastMidiByColor[selectedColorIndex] = midi;
this.lastMidiByStyle[styleIndex] = midi;
this.playNote({
midi,
velocity:
(0.09 + expression * 0.08) *
this.config.colorVoices[selectedColorIndex].velocityMultiplier,
this.config.styleVoices[styleIndex].velocityMultiplier,
startTime,
durationSeconds: 0.62 + expression * 0.24,
pan: this.getColorPan(selectedColorIndex),
pan: this.getStylePan(styleIndex),
delaySend: 0.016 + expression * 0.006,
lowpassHz: this.getLowpassHz(profile, midi, expression),
});
@ -495,95 +430,74 @@ export class GenerativePianoEngine {
private playGestureAccent(
vibe: VibePreset,
now: number,
selectedColorIndex: GardenAudioColorIndex,
strength: number,
noteCount: number
styleIndex: GardenAudioStyleIndex,
strength: number
): void {
const profile = getVibeProfile(this.config, vibe);
const pool = this.generation.colorPools[selectedColorIndex];
const profile = getVibeProfile(vibe);
const pool = this.generation.stylePools[styleIndex];
const degrees = this.rotate(pool.scaleDegrees, Math.round(strength * 3));
for (let index = 0; index < noteCount; index += 1) {
const midi = this.chooseMidi(
{
baseMidi: profile.rootMidi,
offsets: degrees
.slice(index)
.concat(degrees.slice(0, index))
.map((degree) => degreeToSemitone(profile, degree)),
},
pool,
this.lastMidiByColor[selectedColorIndex],
true
);
const midi = this.chooseMidi(
{
baseMidi: profile.rootMidi,
offsets: degrees.map((degree) => degreeToSemitone(profile, degree)),
},
pool,
this.lastMidiByStyle[styleIndex],
true
);
this.lastMidiByColor[selectedColorIndex] = midi;
this.playNote({
midi,
velocity:
(0.12 + strength * 0.09) *
this.config.colorVoices[selectedColorIndex].velocityMultiplier,
startTime:
now +
this.config.startDelaySeconds +
index * this.generation.gestureAccentSpacingSeconds,
durationSeconds: 0.48 + strength * 0.22,
pan: this.getColorPan(selectedColorIndex),
delaySend: 0.012,
lowpassHz: this.getLowpassHz(profile, midi, strength),
});
}
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,
selectedColorIndex,
styleIndex,
strength,
panBias,
registerBias,
brightnessBias,
}: {
vibe: VibePreset;
now: number;
selectedColorIndex: GardenAudioColorIndex;
styleIndex: GardenAudioStyleIndex;
strength: number;
panBias: number;
registerBias: number;
brightnessBias: number;
}): void {
const profile = getVibeProfile(this.config, vibe);
const pool = this.generation.colorPools[selectedColorIndex];
const register = this.getBiasedRegister(pool, registerBias, 0);
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, selectedColorIndex),
offsets: this.getSupportOffsets(chordIntervals, styleIndex),
},
register,
this.lastMidiByColor[selectedColorIndex],
this.lastMidiByStyle[styleIndex],
true
);
this.lastMidiByColor[selectedColorIndex] = midi;
this.lastMidiByStyle[styleIndex] = midi;
this.lastBrushStreamMidi = midi;
this.playNote({
midi,
velocity:
(0.14 + strength * 0.11) *
this.config.colorVoices[selectedColorIndex].velocityMultiplier,
(0.14 + strength * 0.11) * this.config.styleVoices[styleIndex].velocityMultiplier,
startTime: now,
durationSeconds: 0.55 + strength * 0.18,
pan: this.getLayerPan(selectedColorIndex, panBias, 0, 0),
pan: this.getStylePan(styleIndex),
delaySend: 0.006,
lowpassHz: this.getLowpassHz(
profile,
midi,
clamp01(0.45 + strength * 0.35 + brightnessBias * 0.2)
),
lowpassHz: this.getLowpassHz(profile, midi, clamp01(0.55 + strength * 0.35)),
});
}
@ -591,52 +505,26 @@ export class GenerativePianoEngine {
vibe,
now,
strength,
selectedColorIndex,
mirrorAmount,
panBias,
registerBias,
brightnessBias,
contour,
pressureAmount,
pressureDelta,
styleIndex,
maniaAmount,
}: {
vibe: VibePreset;
now: number;
strength: number;
selectedColorIndex: GardenAudioColorIndex;
mirrorAmount: number;
panBias: number;
registerBias: number;
brightnessBias: number;
contour: number;
pressureAmount: number;
pressureDelta: number;
styleIndex: GardenAudioStyleIndex;
maniaAmount: number;
}): void {
const lifetimeSeconds =
this.generation.brushLayerBaseSeconds +
strength * this.generation.brushLayerEnergySeconds +
mirrorAmount * this.generation.brushLayerMirrorSeconds;
strength * this.generation.brushLayerEnergySeconds;
this.brushPhraseLayers.push({
vibe,
startedAt: now,
expiresAt: now + lifetimeSeconds,
selectedColorIndex,
styleIndex,
energy: strength,
mirrorAmount,
motifOffsets: this.getInitialMotifOffsets({
selectedColorIndex,
registerBias,
contour,
}),
panBias,
registerBias,
brightnessBias,
contour,
pressureAmount,
pressureDelta,
motifOffsets: [styleIndex - 1],
maniaAmount,
});
@ -650,26 +538,12 @@ export class GenerativePianoEngine {
private updateBrushPhraseLayer({
now,
strength,
selectedColorIndex,
mirrorAmount,
panBias,
registerBias,
brightnessBias,
contour,
pressureAmount,
pressureDelta,
styleIndex,
maniaAmount,
}: {
now: number;
strength: number;
selectedColorIndex: GardenAudioColorIndex;
mirrorAmount: number;
panBias: number;
registerBias: number;
brightnessBias: number;
contour: number;
pressureAmount: number;
pressureDelta: number;
styleIndex: GardenAudioStyleIndex;
maniaAmount: number;
}): void {
const layer = this.brushPhraseLayers[this.brushPhraseLayers.length - 1];
@ -677,20 +551,10 @@ export class GenerativePianoEngine {
return;
}
const followAmount = 0.24 + clamp01(strength) * 0.24;
layer.selectedColorIndex = selectedColorIndex;
layer.styleIndex = styleIndex;
layer.energy = Math.max(layer.energy * 0.94, strength);
layer.mirrorAmount = Math.max(layer.mirrorAmount * 0.96, mirrorAmount);
layer.panBias = mix(layer.panBias, panBias, followAmount);
layer.registerBias = mix(layer.registerBias, registerBias, followAmount);
layer.brightnessBias = mix(layer.brightnessBias, brightnessBias, followAmount);
layer.contour = mix(layer.contour, contour, followAmount);
layer.pressureAmount = mix(layer.pressureAmount, pressureAmount, followAmount);
layer.pressureDelta = pressureDelta;
layer.maniaAmount = Math.max(layer.maniaAmount * 0.92, maniaAmount);
layer.motifOffsets.push(
this.getMotifOffset({ registerBias, contour, pressureDelta, strength })
);
layer.motifOffsets.push(this.getMotifOffset(strength));
if (layer.motifOffsets.length > this.generation.brushMotifMaxSteps) {
layer.motifOffsets = layer.motifOffsets.slice(-this.generation.brushMotifMaxSteps);
}
@ -701,13 +565,11 @@ export class GenerativePianoEngine {
now,
lookaheadEnd,
activity,
selectedColorIndex,
}: {
vibe: VibePreset;
now: number;
lookaheadEnd: number;
activity: number;
selectedColorIndex: GardenAudioColorIndex;
}): void {
const earliestStart = now + this.config.piano.scheduleAheadSeconds;
this.nextBrushStreamAt ??= now + this.config.startDelaySeconds;
@ -729,7 +591,7 @@ export class GenerativePianoEngine {
vibe,
startTime: this.nextBrushStreamAt,
intensity: frame.intensity,
selectedColorIndex: frame.selectedColorIndex ?? selectedColorIndex,
styleIndex: this.getStyleIndex(this.nextBrushStreamAt),
layer: frame.layer,
});
}
@ -742,23 +604,19 @@ export class GenerativePianoEngine {
vibe,
startTime,
intensity,
selectedColorIndex,
styleIndex,
layer,
}: {
vibe: VibePreset;
startTime: number;
intensity: number;
selectedColorIndex: GardenAudioColorIndex;
styleIndex: GardenAudioStyleIndex;
layer: BrushPhraseLayer | null;
}): void {
const profile = getVibeProfile(this.config, vibe);
const pool = this.generation.colorPools[selectedColorIndex];
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,
layer?.registerBias ?? 0,
maniaAmount * 0.45
);
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;
@ -766,44 +624,35 @@ export class GenerativePianoEngine {
const source = useChordTone
? {
baseMidi: rootMidi,
offsets: this.getSupportOffsets(chordIntervals, selectedColorIndex),
offsets: this.getSupportOffsets(chordIntervals, styleIndex),
}
: {
baseMidi: profile.rootMidi,
offsets: this.getBrushMotifDegrees({
layer,
pool,
selectedColorIndex,
styleIndex,
}).map((degree) => degreeToSemitone(profile, degree)),
};
const midi = this.chooseMidi(source, register, this.lastBrushStreamMidi, true);
const pan = this.getLayerPan(
selectedColorIndex,
layer?.panBias ?? 0,
maniaAmount,
layer?.mirrorAmount ?? 0
);
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 +
(layer?.mirrorAmount ?? 0) * 0.004 -
maniaAmount * 0.006,
0.012 + intensity * 0.011 - maniaAmount * 0.006,
0.006,
0.032
);
this.lastBrushStreamMidi = midi;
this.lastMidiByColor[selectedColorIndex] = midi;
this.lastMidiByStyle[styleIndex] = midi;
this.playNote({
midi,
velocity:
(0.1 + intensity * 0.13) *
this.config.colorVoices[selectedColorIndex].velocityMultiplier,
(0.1 + intensity * 0.13) * this.config.styleVoices[styleIndex].velocityMultiplier,
startTime,
durationSeconds,
pan,
@ -811,12 +660,7 @@ export class GenerativePianoEngine {
lowpassHz: this.getLowpassHz(
profile,
midi,
clamp01(
0.32 +
intensity * 0.48 +
(layer?.brightnessBias ?? 0.5) * 0.14 +
maniaAmount * 0.18
)
clamp01(0.39 + intensity * 0.48 + maniaAmount * 0.18)
),
});
@ -829,11 +673,8 @@ export class GenerativePianoEngine {
midi: echoMidi,
velocity:
(0.045 + intensity * 0.05) *
this.config.colorVoices[selectedColorIndex].velocityMultiplier,
startTime:
startTime +
this.generation.brushMotifCanonDelaySeconds +
(layer?.mirrorAmount ?? 0) * 0.04,
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),
@ -847,7 +688,6 @@ export class GenerativePianoEngine {
activity: number
): {
intensity: number;
selectedColorIndex: GardenAudioColorIndex | null;
layer: BrushPhraseLayer | null;
} {
const layerStates = this.brushPhraseLayers.map((layer) => ({
@ -855,7 +695,7 @@ export class GenerativePianoEngine {
intensity:
layer.energy *
this.getBrushPhraseFade(layer, startTime) *
(0.8 + layer.mirrorAmount * 0.45 + layer.maniaAmount * 0.42),
(0.8 + layer.maniaAmount * 0.42),
}));
const dominant = layerStates.reduce<{
layer: BrushPhraseLayer;
@ -875,7 +715,6 @@ export class GenerativePianoEngine {
intensity: clamp01(
activity * 0.42 + layeredIntensity + (dominant?.layer.maniaAmount ?? 0) * 0.18
),
selectedColorIndex: dominant?.layer.selectedColorIndex ?? null,
layer: dominant?.layer ?? null,
};
}
@ -898,98 +737,28 @@ export class GenerativePianoEngine {
return clamp01(1 - ageSeconds / Math.max(0.001, lifetimeSeconds));
}
private normalizeMotif({
panBias,
registerBias,
brightnessBias,
contour,
pressureAmount,
pressureDelta,
maniaAmount,
}: {
panBias: number;
registerBias: number;
brightnessBias: number;
contour: number;
pressureAmount: number;
pressureDelta: number;
maniaAmount: number;
}): {
panBias: number;
registerBias: number;
brightnessBias: number;
contour: number;
pressureAmount: number;
pressureDelta: number;
maniaAmount: number;
} {
return {
panBias: clamp(panBias, -1, 1),
registerBias: clamp(registerBias, -1, 1),
brightnessBias: clamp01(brightnessBias),
contour: clamp(contour, -1, 1),
pressureAmount: clamp01(pressureAmount),
pressureDelta: clamp(pressureDelta, -1, 1),
maniaAmount: clamp01(maniaAmount),
};
}
private getInitialMotifOffsets({
selectedColorIndex,
registerBias,
contour,
}: {
selectedColorIndex: GardenAudioColorIndex;
registerBias: number;
contour: number;
}): Array<number> {
const start = selectedColorIndex - 1 + Math.round(registerBias);
const motion = contour > 0.2 ? 1 : contour < -0.2 ? -1 : 0;
return [start, start + motion, start + motion * 2, start + motion];
}
private getMotifOffset({
registerBias,
contour,
pressureDelta,
strength,
}: {
registerBias: number;
contour: number;
pressureDelta: number;
strength: number;
}): number {
const contourStep = contour > 0.3 ? 1 : contour < -0.3 ? -1 : 0;
const registerStep = Math.round(registerBias * 2);
const pressureStep = pressureDelta > 0.08 ? 1 : pressureDelta < -0.08 ? -1 : 0;
private getMotifOffset(strength: number): number {
const energyStep = strength >= 0.82 ? 1 : strength >= 0.55 ? 0 : -1;
return clamp(contourStep + registerStep + pressureStep + energyStep, -3, 4);
return clamp(energyStep, -3, 4);
}
private getBrushMotifDegrees({
layer,
pool,
selectedColorIndex,
styleIndex,
}: {
layer: BrushPhraseLayer | null;
pool: GardenAudioColorPool;
selectedColorIndex: GardenAudioColorIndex;
pool: GardenAudioStylePool;
styleIndex: GardenAudioStyleIndex;
}): Array<number> {
const colorOffset = this.config.colorVoices[selectedColorIndex].scaleDegreeOffset;
const styleOffset = this.config.styleVoices[styleIndex].scaleDegreeOffset;
if (!layer || layer.motifOffsets.length === 0) {
return this.rotate(pool.scaleDegrees, this.brushStreamNoteIndex + colorOffset);
return this.rotate(pool.scaleDegrees, this.brushStreamNoteIndex + styleOffset);
}
const motifOffset =
layer.motifOffsets[this.brushStreamNoteIndex % layer.motifOffsets.length];
const contourOffset =
layer.contour > 0.28
? this.brushStreamNoteIndex % 3
: layer.contour < -0.28
? -(this.brushStreamNoteIndex % 3)
: 0;
const pressureLift = layer.pressureAmount > 0.68 ? 1 : 0;
const baseOffset = colorOffset + motifOffset + contourOffset + pressureLift;
const baseOffset = styleOffset + motifOffset;
return this.rotate(
pool.scaleDegrees.map((degree) => degree + baseOffset),
@ -999,10 +768,9 @@ export class GenerativePianoEngine {
private getBiasedRegister(
register: GardenAudioRegister,
registerBias: number,
maniaAmount: number
): GardenAudioRegister {
const shift = Math.round(registerBias * 7 + maniaAmount * 4);
const shift = Math.round(maniaAmount * 4);
const midiMin = clamp(register.midiMin + shift, 36, 86);
const midiMax = clamp(register.midiMax + shift, midiMin + 4, 91);
@ -1014,26 +782,6 @@ export class GenerativePianoEngine {
};
}
private getLayerPan(
selectedColorIndex: GardenAudioColorIndex,
panBias: number,
maniaAmount: number,
mirrorAmount: number
): number {
const shimmer =
maniaAmount > 0.4
? Math.sin(this.brushStreamNoteIndex * Math.PI * 0.5) * mirrorAmount * 0.14
: 0;
return clamp(
this.getColorPan(selectedColorIndex) +
panBias * (0.18 + maniaAmount * 0.42) +
shimmer,
-1,
1
);
}
private chooseMidi(
pitchSource: PitchSource,
register: GardenAudioRegister,
@ -1113,13 +861,13 @@ export class GenerativePianoEngine {
private getSupportOffsets(
chordIntervals: ReadonlyArray<number>,
selectedColorIndex: GardenAudioColorIndex
styleIndex: GardenAudioStyleIndex
): Array<number> {
if (selectedColorIndex === 0) {
if (styleIndex === 0) {
return [0, chordIntervals[2], 12];
}
if (selectedColorIndex === 1) {
if (styleIndex === 1) {
return [chordIntervals[1], chordIntervals[2], 0, 12];
}
@ -1138,10 +886,19 @@ export class GenerativePianoEngine {
return Math.floor(elapsedSeconds / this.getBarDurationSeconds());
}
private getColorPan(selectedColorIndex: GardenAudioColorIndex): number {
const pool = this.generation.colorPools[selectedColorIndex];
const colorVoice = this.config.colorVoices[selectedColorIndex];
return clamp(pool.pan + colorVoice.panOffset * 0.35, -1, 1);
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(
@ -1199,6 +956,3 @@ export class GenerativePianoEngine {
return values.map((_, index) => values[(index + offset) % values.length]);
}
}
const mix = (from: number, to: number, amount: number): number =>
from + (to - from) * clamp01(amount);

View file

@ -2,12 +2,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { gardenAudioConfig } from './garden-audio-config';
import type { GardenAudioGraph } from './garden-audio-graph';
import { PianoSampler } from './piano-sampler';
import { pianoSampleDefinitions, resetPianoSampleCacheForTest } from './piano-samples';
import type { PianoSampler } from './piano-sampler';
const calls = {
bufferSourcesStarted: 0,
};
const sampleCount = 30;
class FakeAudioParam {
public value = 0;
@ -61,7 +61,8 @@ class FakeAudioContext {
}
}
const makeSampler = (context: AudioContext): PianoSampler => {
const makeSampler = async (context: AudioContext): Promise<PianoSampler> => {
const { PianoSampler } = await import('./piano-sampler');
const eventBus = new FakeAudioNode() as unknown as GainNode;
const graph = {
context,
@ -75,17 +76,16 @@ const makeSampler = (context: AudioContext): PianoSampler => {
describe('PianoSampler', () => {
beforeEach(() => {
calls.bufferSourcesStarted = 0;
resetPianoSampleCacheForTest();
vi.resetModules();
});
afterEach(() => {
vi.unstubAllGlobals();
resetPianoSampleCacheForTest();
});
it('loads every piano sample before playback', async () => {
const context = new FakeAudioContext() as unknown as AudioContext;
const sampler = makeSampler(context);
const sampler = await makeSampler(context);
const fetch = vi.fn(async () => {
return {
arrayBuffer: async () => new ArrayBuffer(8),
@ -103,23 +103,23 @@ describe('PianoSampler', () => {
velocity: 0.5,
});
expect(fetch).toHaveBeenCalledTimes(pianoSampleDefinitions.length);
expect(context.decodeAudioData).toHaveBeenCalledTimes(pianoSampleDefinitions.length);
expect(fetch).toHaveBeenCalledTimes(sampleCount);
expect(context.decodeAudioData).toHaveBeenCalledTimes(sampleCount);
expect(calls.bufferSourcesStarted).toBe(1);
});
it('stays silent when no decoded sample is available', () => {
const context = new FakeAudioContext() as unknown as AudioContext;
const sampler = makeSampler(context);
return makeSampler(context).then((sampler) => {
sampler.play({
durationSeconds: 0.2,
midi: 60,
pan: 0,
startTime: context.currentTime,
velocity: 0.5,
});
sampler.play({
durationSeconds: 0.2,
midi: 60,
pan: 0,
startTime: context.currentTime,
velocity: 0.5,
expect(calls.bufferSourcesStarted).toBe(0);
});
expect(calls.bufferSourcesStarted).toBe(0);
});
});

View file

@ -124,7 +124,7 @@ export class PianoSampler {
source.start(scheduledStart);
source.stop(stopAt + this.config.piano.tailStopExtraSeconds);
this.activeVoices.push({ gain, source, startAt: scheduledStart, stopAt });
this.activeVoices.push({ gain, source, stopAt });
source.addEventListener(
'ended',

View file

@ -1,6 +1,6 @@
import type { LoadedPianoSample } from './garden-audio-types';
export interface PianoSampleDefinition {
interface PianoSampleDefinition {
midi: number;
url: string;
}
@ -46,7 +46,7 @@ const sampleFiles: Array<[fileName: string, midi: number]> = [
['C8v12.m4a', 108],
];
export const pianoSampleDefinitions: Array<PianoSampleDefinition> = sampleFiles
const pianoSampleDefinitions: Array<PianoSampleDefinition> = sampleFiles
.map(([fileName, midi]) => ({
midi,
url: `${sampleBaseUrl}${fileName}`,
@ -116,11 +116,6 @@ export const loadPianoSamples = (
export const getLoadedPianoSamples = (): Array<LoadedPianoSample> | null =>
loadedPianoSamples ? [...loadedPianoSamples] : null;
export const resetPianoSampleCacheForTest = (): void => {
loadedPianoSamples = null;
pianoSampleLoadPromise = null;
};
const loadPianoSample = async (
decodeContext: BaseAudioContext,
sample: PianoSampleDefinition