sure
This commit is contained in:
parent
ced0ac56f3
commit
d6a8f898d1
27 changed files with 751 additions and 354 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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 = [];
|
||||
|
|
|
|||
Loading…
Reference in a new issue