This commit is contained in:
Andras Schmelczer 2026-05-17 17:21:49 +01:00
commit d6a8f898d1
27 changed files with 751 additions and 354 deletions

View file

@ -94,8 +94,6 @@ interface GardenAudioGenerativePianoConfig {
};
brushPhrase: {
initialMotifOffset: number;
energyRetain: number;
maniaRetain: number;
energyDecaySeconds: number;
maniaDecaySeconds: number;
fadeMinimumLifetimeSeconds: number;
@ -165,7 +163,6 @@ interface GardenAudioGenerativePianoConfig {
min: number;
max: number;
};
styleRotationMinSeconds: number;
stylePanOffsetScale: number;
lowpass: {
midiBase: number;
@ -174,7 +171,6 @@ interface GardenAudioGenerativePianoConfig {
expressionBase: number;
expressionWeight: number;
};
styleRotationSeconds: number;
styleRotationBars: number;
chordBars: number;
supportBarSpacing: number;
@ -189,7 +185,6 @@ interface GardenAudioGenerativePianoConfig {
noteScoreChordToneWeight: number;
noteScoreRepeatPenalty: number;
gestureAccentMinIntervalSeconds: number;
strokeAccentMinIntervalSeconds: number;
strokeAccentMinSteps: number;
strokeAccentThreshold: number;
stingerDurationSeconds: number;
@ -315,8 +310,6 @@ export interface GardenAudioConfig {
};
};
input: {
distanceWindowForFullActivityPixels: number;
distanceWindowSeconds: number;
fallbackFrameSeconds: number;
fullActivitySpeed: number;
activityNoiseFloorSpeed: number;

View file

@ -1,6 +1,8 @@
import type { GardenAudioConfig } from './garden-audio-config';
import type { GardenAudioStroke } from './garden-audio-types';
const fallbackNormalizationPixels = 1000;
export interface GardenAudioStrokeMetrics {
distancePixels: number;
elapsedSeconds: number;
@ -16,8 +18,7 @@ export const getStrokeMetrics = (
const dy = stroke.to[1] - stroke.from[1];
const distancePixels = Math.hypot(dx, dy);
const elapsedSeconds = getElapsedSeconds(stroke, inputConfig);
const normalizedDistance =
distancePixels / getStrokeNormalizationPixels(stroke, inputConfig);
const normalizedDistance = distancePixels / getStrokeNormalizationPixels(stroke);
return {
distancePixels,
@ -42,10 +43,7 @@ const getElapsedSeconds = (
return inputConfig.fallbackFrameSeconds;
};
const getStrokeNormalizationPixels = (
stroke: GardenAudioStroke,
inputConfig: GardenAudioConfig['input']
): number => {
const getStrokeNormalizationPixels = (stroke: GardenAudioStroke): number => {
const width = stroke.canvasSize?.[0];
const height = stroke.canvasSize?.[1];
if (
@ -59,5 +57,5 @@ const getStrokeNormalizationPixels = (
return Math.max(1, Math.min(width, height));
}
return Math.max(1, inputConfig.distanceWindowForFullActivityPixels);
return fallbackNormalizationPixels;
};

View file

@ -1,6 +1,6 @@
import { clamp01 } from '../utils/clamp';
import { ErrorHandler, Severity } from '../utils/error-handler';
import { VibePreset } from '../vibes';
import type { VibeId, VibePreset } from '../vibes';
import { GardenAudioConfig } from './garden-audio-config';
import { GardenAudioEnergy } from './garden-audio-energy';
import { GardenAudioGestureState } from './garden-audio-gesture-state';
@ -22,6 +22,8 @@ export type {
GardenAudioStroke,
} from './garden-audio-types';
type AudioLifecycle = 'idle' | 'started' | 'destroyed';
export class GardenAudio {
private readonly graph: GardenAudioGraph;
private readonly piano: PianoSampler;
@ -30,13 +32,11 @@ export class GardenAudio {
private readonly gestureState: GardenAudioGestureState;
private readonly pianoEngine: GenerativePianoEngine;
private currentVibeId: string | null = null;
private hasStarted = false;
private isDestroyed = false;
private currentVibeId: VibeId | null = null;
private lifecycle: AudioLifecycle = 'idle';
private isMuted = false;
private masterVolume: number;
private isGestureActive = false;
private hasQueuedPianoLoad = false;
private masterVolume: number;
private lastEraserAt = Number.NEGATIVE_INFINITY;
private lastVibeStingerAt = Number.NEGATIVE_INFINITY;
@ -51,7 +51,7 @@ export class GardenAudio {
}
public start(vibe: VibePreset, options: GardenAudioStartOptions = {}): void {
if (this.isDestroyed || this.isMuted) {
if (this.lifecycle === 'destroyed' || this.isMuted) {
return;
}
@ -81,7 +81,11 @@ export class GardenAudio {
if (resumePromise) {
void resumePromise
.then(() => {
if (this.graph.context === context && !this.isDestroyed && !this.isMuted) {
if (
this.graph.context === context &&
this.lifecycle !== 'destroyed' &&
!this.isMuted
) {
this.graph.unlock();
this.graph.setMasterGain(this.masterVolume, startupRampSeconds);
}
@ -94,17 +98,16 @@ export class GardenAudio {
});
}
this.hasStarted = true;
this.lifecycle = 'started';
this.applyVibe(vibe);
this.pianoEngine.prime(context.currentTime);
this.graph.setMasterGain(this.masterVolume, startupRampSeconds);
if (!this.hasQueuedPianoLoad) {
this.hasQueuedPianoLoad = true;
void this.piano
.load(context)
const pianoLoad = this.piano.loadIfIdle(context);
if (pianoLoad) {
void pianoLoad
.then(() => {
if (this.graph.context === context && !this.isDestroyed) {
if (this.graph.context === context && this.lifecycle !== 'destroyed') {
this.pianoEngine.cue(context.currentTime);
}
})
@ -126,7 +129,7 @@ export class GardenAudio {
context &&
(context.state === 'running' || options.userGesture === true) &&
!this.isMuted &&
!this.isDestroyed &&
this.lifecycle !== 'destroyed' &&
didChangeVibe
) {
this.playVibeChangeStinger(vibe);
@ -173,7 +176,7 @@ export class GardenAudio {
public update(snapshot: GardenAudioSnapshot): void {
const context = this.graph.context;
if (!this.hasStarted || !context || this.isMuted) {
if (this.lifecycle !== 'started' || !context || this.isMuted) {
return;
}
@ -195,7 +198,7 @@ export class GardenAudio {
}
public stroke(stroke: GardenAudioStroke): void {
if (this.isDestroyed || this.isMuted) {
if (this.lifecycle === 'destroyed' || this.isMuted) {
return;
}
@ -230,7 +233,7 @@ export class GardenAudio {
}
public async destroy(): Promise<void> {
this.isDestroyed = true;
this.lifecycle = 'destroyed';
await this.graph.close();
this.piano.reset();
@ -238,9 +241,7 @@ export class GardenAudio {
this.gestureState.reset();
this.pianoEngine.reset();
this.currentVibeId = null;
this.hasStarted = false;
this.isGestureActive = false;
this.hasQueuedPianoLoad = false;
this.lastEraserAt = Number.NEGATIVE_INFINITY;
this.lastVibeStingerAt = Number.NEGATIVE_INFINITY;
}

View file

@ -109,6 +109,47 @@ describe('PianoSampler', () => {
expect(calls.bufferSourcesStarted).toBe(1);
});
it('only queues a piano load when the sampler is idle', async () => {
const context = new FakeAudioContext() as unknown as AudioContext;
const sampler = await makeSampler(context);
const fetch = vi.fn(async () => {
return {
arrayBuffer: async () => new ArrayBuffer(8),
ok: true,
} as Response;
});
vi.stubGlobal('fetch', fetch);
const firstLoad = sampler.loadIfIdle(context);
const secondLoad = sampler.loadIfIdle(context);
expect(firstLoad).toBeInstanceOf(Promise);
expect(secondLoad).toBeNull();
await firstLoad;
expect(sampler.loadIfIdle(context)).toBeNull();
expect(fetch).toHaveBeenCalledTimes(sampleCount);
});
it('allows loading to be retried after a load failure', async () => {
const context = new FakeAudioContext() as unknown as AudioContext;
const sampler = await makeSampler(context);
const fetch = vi
.fn()
.mockRejectedValueOnce(new Error('load failed'))
.mockResolvedValue({
arrayBuffer: async () => new ArrayBuffer(8),
ok: true,
} as Response);
vi.stubGlobal('fetch', fetch);
await expect(sampler.loadIfIdle(context)).rejects.toThrow('load failed');
await expect(sampler.loadIfIdle(context)).resolves.toBeUndefined();
expect(fetch).toHaveBeenCalledTimes(sampleCount * 2);
});
it('stays silent when no decoded sample is available', () => {
const context = new FakeAudioContext() as unknown as AudioContext;
return makeSampler(context).then((sampler) => {

View file

@ -4,7 +4,10 @@ import { GardenAudioGraph } from './garden-audio-graph';
import { ActivePianoVoice, LoadedPianoSample, PianoNote } from './garden-audio-types';
import { getLoadedPianoSamples, loadPianoSamples } from './piano-samples';
type PianoLoadState = 'idle' | 'loading' | 'loaded';
export class PianoSampler {
private loadState: PianoLoadState = 'idle';
private sampleLoadPromise: Promise<void> | null = null;
private samples: Array<LoadedPianoSample> = [];
private activeVoices: Array<ActivePianoVoice> = [];
@ -15,9 +18,14 @@ export class PianoSampler {
) {}
public load(context: BaseAudioContext): Promise<void> {
if (this.loadState === 'loaded') {
return Promise.resolve();
}
const loadedSamples = getLoadedPianoSamples();
if (loadedSamples) {
this.setSamples(loadedSamples);
this.loadState = 'loaded';
return Promise.resolve();
}
@ -25,13 +33,29 @@ export class PianoSampler {
return this.sampleLoadPromise;
}
this.sampleLoadPromise = loadPianoSamples(context).then((samples) => {
this.setSamples(samples);
});
this.loadState = 'loading';
this.sampleLoadPromise = loadPianoSamples(context)
.then((samples) => {
this.setSamples(samples);
this.loadState = 'loaded';
})
.catch((error) => {
this.loadState = 'idle';
this.sampleLoadPromise = null;
throw error;
});
return this.sampleLoadPromise;
}
public loadIfIdle(context: BaseAudioContext): Promise<void> | null {
if (this.loadState !== 'idle') {
return null;
}
return this.load(context);
}
public play({
midi,
velocity,
@ -158,6 +182,7 @@ export class PianoSampler {
}
public reset(): void {
this.loadState = 'idle';
this.sampleLoadPromise = null;
this.samples = [];
this.activeVoices = [];