,
This commit is contained in:
parent
70423851ba
commit
1fe5015056
55 changed files with 2036 additions and 685 deletions
|
|
@ -26,14 +26,6 @@ export interface GardenAudioConfig {
|
|||
fadeInSeconds: number;
|
||||
updateRampSeconds: number;
|
||||
highPassFrequencyHz: number;
|
||||
fallbackVibeId: string;
|
||||
compressor: {
|
||||
thresholdDb: number;
|
||||
kneeDb: number;
|
||||
ratio: number;
|
||||
attackSeconds: number;
|
||||
releaseSeconds: number;
|
||||
};
|
||||
delay: {
|
||||
timeSeconds: number;
|
||||
feedback: number;
|
||||
|
|
@ -47,9 +39,6 @@ export interface GardenAudioConfig {
|
|||
releaseSeconds: number;
|
||||
lowpassHz: number;
|
||||
};
|
||||
input: {
|
||||
pressureFallback: number;
|
||||
};
|
||||
rhythm: {
|
||||
bpm: number;
|
||||
stepsPerBeat: number;
|
||||
|
|
|
|||
|
|
@ -2,6 +2,9 @@ import type { GardenAudioEngineConfig } from '../config';
|
|||
import { clamp } from '../utils/clamp';
|
||||
import { GardenAudioConfig, GardenAudioVibeProfile } from './garden-audio-config';
|
||||
|
||||
const UNLOCK_TICK_SECONDS = 0.035;
|
||||
const UNLOCK_TICK_FREQUENCY_HZ = 440;
|
||||
|
||||
export class GardenAudioGraph {
|
||||
public context: AudioContext | null = null;
|
||||
public eventBus: GainNode | null = null;
|
||||
|
|
@ -12,7 +15,6 @@ export class GardenAudioGraph {
|
|||
private delayNode: DelayNode | null = null;
|
||||
private delayFeedback: GainNode | null = null;
|
||||
private delayOutput: GainNode | null = null;
|
||||
private hasUnlocked = false;
|
||||
|
||||
public constructor(
|
||||
private readonly config: GardenAudioConfig,
|
||||
|
|
@ -28,23 +30,26 @@ export class GardenAudioGraph {
|
|||
return null;
|
||||
}
|
||||
|
||||
const context = new AudioContext({ latencyHint: 'interactive' });
|
||||
const AudioContextConstructor = globalThis.AudioContext;
|
||||
if (!AudioContextConstructor) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let context: AudioContext;
|
||||
try {
|
||||
context = new AudioContextConstructor({ latencyHint: 'interactive' });
|
||||
} catch {
|
||||
context = new AudioContextConstructor();
|
||||
}
|
||||
const masterGain = context.createGain();
|
||||
const highPass = context.createBiquadFilter();
|
||||
const compressor = context.createDynamicsCompressor();
|
||||
|
||||
masterGain.gain.value = 0;
|
||||
highPass.type = 'highpass';
|
||||
highPass.frequency.value = this.config.highPassFrequencyHz;
|
||||
compressor.threshold.value = this.config.compressor.thresholdDb;
|
||||
compressor.knee.value = this.config.compressor.kneeDb;
|
||||
compressor.ratio.value = this.config.compressor.ratio;
|
||||
compressor.attack.value = this.config.compressor.attackSeconds;
|
||||
compressor.release.value = this.config.compressor.releaseSeconds;
|
||||
|
||||
masterGain.connect(highPass);
|
||||
highPass.connect(compressor);
|
||||
compressor.connect(context.destination);
|
||||
highPass.connect(context.destination);
|
||||
|
||||
this.context = context;
|
||||
this.masterGain = masterGain;
|
||||
|
|
@ -55,24 +60,37 @@ export class GardenAudioGraph {
|
|||
return context;
|
||||
}
|
||||
|
||||
// iOS WebKit (Safari + Chrome iOS) only fully unlocks audio output once
|
||||
// a buffer source has been started inside a user-gesture handler. Calling
|
||||
// resume() alone leaves the context "running" but silent.
|
||||
// iOS WebKit can report "running" while the hardware output is still silent.
|
||||
// A very short, nearly inaudible oscillator in the gesture stack is more
|
||||
// reliable than a fully silent buffer on recent Safari versions.
|
||||
public unlock(): void {
|
||||
if (!this.context || this.hasUnlocked) {
|
||||
if (!this.context) {
|
||||
return;
|
||||
}
|
||||
|
||||
const buffer = this.context.createBuffer(
|
||||
1,
|
||||
this.engineConfig.graph.unlockBufferLength,
|
||||
this.engineConfig.graph.unlockSampleRate
|
||||
const now = this.context.currentTime;
|
||||
const source = this.context.createOscillator();
|
||||
const gain = this.context.createGain();
|
||||
|
||||
source.type = 'sine';
|
||||
source.frequency.setValueAtTime(UNLOCK_TICK_FREQUENCY_HZ, now);
|
||||
gain.gain.setValueAtTime(this.engineConfig.piano.minGain, now);
|
||||
gain.gain.exponentialRampToValueAtTime(
|
||||
this.engineConfig.piano.minGain,
|
||||
now + UNLOCK_TICK_SECONDS
|
||||
);
|
||||
source.connect(gain);
|
||||
gain.connect(this.context.destination);
|
||||
source.start(now);
|
||||
source.stop(now + UNLOCK_TICK_SECONDS);
|
||||
source.addEventListener(
|
||||
'ended',
|
||||
() => {
|
||||
source.disconnect();
|
||||
gain.disconnect();
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
const source = this.context.createBufferSource();
|
||||
source.buffer = buffer;
|
||||
source.connect(this.context.destination);
|
||||
source.start(0);
|
||||
this.hasUnlocked = true;
|
||||
}
|
||||
|
||||
public setMasterGain(targetGain: number, timeConstantSeconds: number): void {
|
||||
|
|
@ -201,6 +219,5 @@ export class GardenAudioGraph {
|
|||
this.delayNode = null;
|
||||
this.delayFeedback = null;
|
||||
this.delayOutput = null;
|
||||
this.hasUnlocked = false;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,14 +13,13 @@ export interface GardenAudioStrokeMetrics {
|
|||
export const getStrokeMetrics = (
|
||||
stroke: GardenAudioStroke,
|
||||
speedForFullEnergyPixelsPerSecond: number,
|
||||
fallbackPressure: number,
|
||||
inputConfig: GardenAudioEngineConfig['input']
|
||||
): GardenAudioStrokeMetrics => {
|
||||
const dx = stroke.to[0] - stroke.from[0];
|
||||
const dy = stroke.to[1] - stroke.from[1];
|
||||
const distancePixels = Math.hypot(dx, dy);
|
||||
const speedPixelsPerSecond = getStrokeVelocity(stroke, distancePixels, inputConfig);
|
||||
const pressure = getPressureAmount(stroke, fallbackPressure, inputConfig);
|
||||
const pressure = getPressureAmount(stroke);
|
||||
const speedAmount = clamp01(speedPixelsPerSecond / speedForFullEnergyPixelsPerSecond);
|
||||
const strokeEnergy = clamp01(
|
||||
inputConfig.strokeEnergyBase +
|
||||
|
|
@ -58,11 +57,7 @@ const getStrokeVelocity = (
|
|||
return distancePixels / inputConfig.fallbackFrameSeconds;
|
||||
};
|
||||
|
||||
const getPressureAmount = (
|
||||
stroke: GardenAudioStroke,
|
||||
fallbackPressure: number,
|
||||
inputConfig: GardenAudioEngineConfig['input']
|
||||
): number => {
|
||||
const getPressureAmount = (stroke: GardenAudioStroke): number => {
|
||||
if (
|
||||
stroke.pressure !== undefined &&
|
||||
Number.isFinite(stroke.pressure) &&
|
||||
|
|
@ -71,7 +66,5 @@ const getPressureAmount = (
|
|||
return clamp01(stroke.pressure);
|
||||
}
|
||||
|
||||
return stroke.pointerType === 'pen'
|
||||
? Math.max(inputConfig.penMinPressure, clamp01(fallbackPressure))
|
||||
: clamp01(fallbackPressure);
|
||||
return 0;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -12,10 +12,14 @@ export const normalizeColorIndex = (index: number): GardenAudioColorIndex =>
|
|||
export const getVibeProfile = (
|
||||
config: GardenAudioConfig,
|
||||
vibe: VibePreset
|
||||
): GardenAudioVibeProfile =>
|
||||
config.vibes[vibe.id] ??
|
||||
config.vibes[config.fallbackVibeId] ??
|
||||
Object.values(config.vibes)[0];
|
||||
): GardenAudioVibeProfile => {
|
||||
const profile = config.vibes[vibe.id];
|
||||
if (!profile) {
|
||||
throw new Error(`Missing audio profile for vibe "${vibe.id}"`);
|
||||
}
|
||||
|
||||
return profile;
|
||||
};
|
||||
|
||||
export const getChordIntervals = (
|
||||
chord: GardenAudioChord,
|
||||
|
|
|
|||
|
|
@ -21,7 +21,6 @@ export interface GardenAudioStroke {
|
|||
elapsedSeconds?: number;
|
||||
eraserSizePixels?: number;
|
||||
mirrorSegmentCount?: number;
|
||||
pointerType?: string;
|
||||
}
|
||||
|
||||
export interface GardenAudioTouchDown {
|
||||
|
|
@ -31,7 +30,6 @@ export interface GardenAudioTouchDown {
|
|||
canvasSize?: ArrayLike<number>;
|
||||
mirrorSegmentCount?: number;
|
||||
pressure?: number;
|
||||
pointerType?: string;
|
||||
}
|
||||
|
||||
export interface GardenAudioStartOptions {
|
||||
|
|
@ -45,7 +43,7 @@ export interface LoadedPianoSample {
|
|||
|
||||
export interface ActivePianoVoice {
|
||||
gain: GainNode;
|
||||
source: AudioBufferSourceNode;
|
||||
source: AudioScheduledSourceNode;
|
||||
startAt: number;
|
||||
stopAt: number;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,17 +1,26 @@
|
|||
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>;
|
||||
stop: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
const calls = {
|
||||
constructed: 0,
|
||||
resumed: 0,
|
||||
sourcesStarted: 0,
|
||||
sources: [] as Array<FakeScheduledSourceNode>,
|
||||
};
|
||||
|
||||
let contextState: AudioContextState = 'suspended';
|
||||
let resumeError: Error | null = null;
|
||||
|
||||
class FakeAudioParam {
|
||||
public value = 0;
|
||||
|
|
@ -24,6 +33,7 @@ class FakeAudioParam {
|
|||
class FakeAudioNode {
|
||||
public readonly gain = new FakeAudioParam();
|
||||
public readonly frequency = new FakeAudioParam();
|
||||
public readonly playbackRate = new FakeAudioParam();
|
||||
public readonly Q = new FakeAudioParam();
|
||||
public readonly threshold = new FakeAudioParam();
|
||||
public readonly knee = new FakeAudioParam();
|
||||
|
|
@ -54,6 +64,7 @@ class FakeAudioContext {
|
|||
public readonly currentTime = 1;
|
||||
public readonly sampleRate = 16;
|
||||
public readonly destination = new FakeAudioNode() as unknown as AudioDestinationNode;
|
||||
public readonly decodeAudioData = vi.fn(async () => ({}) as AudioBuffer);
|
||||
|
||||
public constructor() {
|
||||
calls.constructed += 1;
|
||||
|
|
@ -75,10 +86,6 @@ class FakeAudioContext {
|
|||
return new FakeAudioNode() as unknown as BiquadFilterNode;
|
||||
}
|
||||
|
||||
public createDynamicsCompressor(): DynamicsCompressorNode {
|
||||
return new FakeAudioNode() as unknown as DynamicsCompressorNode;
|
||||
}
|
||||
|
||||
public createDelay(): DelayNode {
|
||||
return new FakeAudioNode() as unknown as DelayNode;
|
||||
}
|
||||
|
|
@ -100,13 +107,30 @@ class FakeAudioContext {
|
|||
node.buffer = null;
|
||||
node.start = vi.fn(() => {
|
||||
calls.sourcesStarted += 1;
|
||||
});
|
||||
node.stop = vi.fn();
|
||||
}) as unknown as typeof node.start;
|
||||
node.stop = vi.fn() as unknown as typeof node.stop;
|
||||
calls.sources.push(node as unknown as FakeScheduledSourceNode);
|
||||
return node;
|
||||
}
|
||||
|
||||
public createOscillator(): OscillatorNode {
|
||||
const node = new FakeAudioNode() as unknown as OscillatorNode & {
|
||||
start: () => void;
|
||||
stop: () => void;
|
||||
};
|
||||
node.start = vi.fn(() => {
|
||||
calls.sourcesStarted += 1;
|
||||
}) as unknown as typeof node.start;
|
||||
node.stop = vi.fn() as unknown as typeof node.stop;
|
||||
calls.sources.push(node as unknown as FakeScheduledSourceNode);
|
||||
return node;
|
||||
}
|
||||
|
||||
public async resume(): Promise<void> {
|
||||
calls.resumed += 1;
|
||||
if (resumeError) {
|
||||
throw resumeError;
|
||||
}
|
||||
contextState = 'running';
|
||||
}
|
||||
}
|
||||
|
|
@ -120,13 +144,18 @@ describe('GardenAudio startup policy', () => {
|
|||
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')));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
resetPianoSampleCacheForTest();
|
||||
});
|
||||
|
||||
it('does not create an AudioContext from passive audio paths', () => {
|
||||
|
|
@ -171,7 +200,27 @@ describe('GardenAudio startup policy', () => {
|
|||
expect(calls.resumed).toBe(1);
|
||||
});
|
||||
|
||||
it('skips cold piano fallback while preserving eraser noise', () => {
|
||||
it('reports AudioContext resume failures as warnings', async () => {
|
||||
const audio = new GardenAudio(
|
||||
makeConfig(),
|
||||
appConfig.audioEngine,
|
||||
appConfig.simulation.maxMirrorSegmentCount
|
||||
);
|
||||
const vibe = VIBE_PRESETS[0];
|
||||
resumeError = new Error('resume rejected');
|
||||
const addException = vi.spyOn(ErrorHandler, 'addException');
|
||||
|
||||
audio.start(vibe, { userGesture: true });
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(addException).toHaveBeenCalledWith(resumeError, {
|
||||
fallbackMessage: 'Could not resume audio playback.',
|
||||
severity: Severity.WARNING,
|
||||
});
|
||||
});
|
||||
|
||||
it('stays silent without piano samples while preserving eraser noise', () => {
|
||||
const audio = new GardenAudio(
|
||||
makeConfig(),
|
||||
appConfig.audioEngine,
|
||||
|
|
@ -217,4 +266,51 @@ describe('GardenAudio startup policy', () => {
|
|||
|
||||
expect(calls.sourcesStarted).toBe(2);
|
||||
});
|
||||
|
||||
it('quickly stops active piano voices when the vibe changes', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () => ({
|
||||
arrayBuffer: async () => new ArrayBuffer(8),
|
||||
ok: true,
|
||||
}))
|
||||
);
|
||||
await loadPianoSamples(new FakeAudioContext() as unknown as AudioContext);
|
||||
|
||||
const audio = new GardenAudio(
|
||||
makeConfig(),
|
||||
appConfig.audioEngine,
|
||||
appConfig.simulation.maxMirrorSegmentCount
|
||||
);
|
||||
const vibe = VIBE_PRESETS[0];
|
||||
|
||||
audio.start(vibe, { userGesture: true });
|
||||
audio.beginGesture();
|
||||
audio.touchDown({
|
||||
vibe,
|
||||
colorIndex: 1,
|
||||
position: [30, 40],
|
||||
canvasSize: [100, 100],
|
||||
pressure: 0.7,
|
||||
});
|
||||
|
||||
const activePianoSources = calls.sources.filter(
|
||||
(source) => source.stop.mock.calls.length === 1
|
||||
);
|
||||
expect(activePianoSources.length).toBeGreaterThan(0);
|
||||
|
||||
const stopCounts = activePianoSources.map((source) => source.stop.mock.calls.length);
|
||||
audio.changeVibe(VIBE_PRESETS[1], { userGesture: true });
|
||||
|
||||
const stoppedVoices = activePianoSources.filter(
|
||||
(source, index) => source.stop.mock.calls.length === stopCounts[index] + 1
|
||||
);
|
||||
expect(stoppedVoices.length).toBeGreaterThan(0);
|
||||
stoppedVoices.forEach((source) => {
|
||||
expect(source.stop.mock.calls.at(-1)?.[0]).toBeCloseTo(
|
||||
1 + appConfig.audioEngine.piano.voiceStealStopSeconds,
|
||||
3
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import type { GardenAudioEngineConfig } from '../config';
|
||||
import { clamp, clamp01 } from '../utils/clamp';
|
||||
import { ErrorHandler, Severity } from '../utils/error-handler';
|
||||
import { VibePreset } from '../vibes';
|
||||
import { GardenAudioConfig } from './garden-audio-config';
|
||||
import { GardenAudioEnergy } from './garden-audio-energy';
|
||||
|
|
@ -71,40 +72,66 @@ export class GardenAudio {
|
|||
return;
|
||||
}
|
||||
|
||||
const startupRampSeconds =
|
||||
options.userGesture === true
|
||||
? this.engineConfig.muteRampSeconds
|
||||
: this.config.fadeInSeconds;
|
||||
const needsResume = context.state !== 'running' && context.state !== 'closed';
|
||||
let resumePromise: Promise<void> | null = null;
|
||||
|
||||
if (needsResume) {
|
||||
if (options.userGesture !== true) {
|
||||
return;
|
||||
}
|
||||
resumePromise = context.resume();
|
||||
}
|
||||
|
||||
if (options.userGesture === true) {
|
||||
this.graph.unlock();
|
||||
}
|
||||
|
||||
if (context.state === 'suspended') {
|
||||
if (options.userGesture !== true) {
|
||||
return;
|
||||
}
|
||||
void context.resume().catch(() => undefined);
|
||||
if (resumePromise) {
|
||||
void resumePromise
|
||||
.then(() => {
|
||||
if (this.graph.context === context && !this.isDestroyed && !this.isMuted) {
|
||||
this.graph.unlock();
|
||||
this.graph.setMasterGain(this.config.masterVolume, startupRampSeconds);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
ErrorHandler.addException(error, {
|
||||
fallbackMessage: 'Could not resume audio playback.',
|
||||
severity: Severity.WARNING,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
this.hasStarted = true;
|
||||
this.applyVibe(vibe);
|
||||
this.pianoEngine.prime(context.currentTime);
|
||||
this.graph.setMasterGain(
|
||||
this.config.masterVolume,
|
||||
options.userGesture === true
|
||||
? this.engineConfig.muteRampSeconds
|
||||
: this.config.fadeInSeconds
|
||||
);
|
||||
this.graph.setMasterGain(this.config.masterVolume, startupRampSeconds);
|
||||
|
||||
if (!this.hasQueuedPianoLoad) {
|
||||
this.hasQueuedPianoLoad = true;
|
||||
void this.piano.load(context).then(() => {
|
||||
if (this.graph.context === context && !this.isDestroyed) {
|
||||
this.pianoEngine.cue(context.currentTime);
|
||||
}
|
||||
});
|
||||
void this.piano
|
||||
.load(context)
|
||||
.then(() => {
|
||||
if (this.graph.context === context && !this.isDestroyed) {
|
||||
this.pianoEngine.cue(context.currentTime);
|
||||
}
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
public changeVibe(vibe: VibePreset, options: GardenAudioStartOptions = {}): void {
|
||||
const previousVibeId = this.currentVibeId;
|
||||
this.start(vibe, options);
|
||||
const didChangeVibe = previousVibeId !== null && previousVibeId !== vibe.id;
|
||||
|
||||
if (didChangeVibe) {
|
||||
this.piano.stopAll();
|
||||
}
|
||||
|
||||
const context = this.graph.context;
|
||||
if (
|
||||
|
|
@ -112,8 +139,7 @@ export class GardenAudio {
|
|||
(context.state === 'running' || options.userGesture === true) &&
|
||||
!this.isMuted &&
|
||||
!this.isDestroyed &&
|
||||
previousVibeId !== null &&
|
||||
previousVibeId !== vibe.id
|
||||
didChangeVibe
|
||||
) {
|
||||
this.playVibeChangeStinger(vibe);
|
||||
}
|
||||
|
|
@ -158,7 +184,7 @@ export class GardenAudio {
|
|||
|
||||
this.selectedColorIndex = normalizeColorIndex(touch.colorIndex);
|
||||
const mirrorAmount = this.getMirrorAmount(touch.mirrorSegmentCount ?? 1);
|
||||
const pressure = this.getTouchPressure(touch.pressure, touch.pointerType);
|
||||
const pressure = this.getPressure(touch.pressure);
|
||||
const strength = clamp01(0.36 + pressure * 0.34 + mirrorAmount * 0.22);
|
||||
const frame = this.gestureState.recordTouchDown({
|
||||
touch,
|
||||
|
|
@ -225,7 +251,6 @@ export class GardenAudio {
|
|||
const metrics = getStrokeMetrics(
|
||||
stroke,
|
||||
this.config.rhythm.speedForFullEnergyPixelsPerSecond,
|
||||
this.config.input.pressureFallback,
|
||||
this.engineConfig.input
|
||||
);
|
||||
const now = context.currentTime;
|
||||
|
|
@ -375,16 +400,11 @@ export class GardenAudio {
|
|||
return clamp01((segmentCount - 1) / (maxMirrorSegmentCount - 1));
|
||||
}
|
||||
|
||||
private getTouchPressure(pressure: number | undefined, pointerType?: string): number {
|
||||
private getPressure(pressure: number | undefined): number {
|
||||
if (pressure !== undefined && Number.isFinite(pressure) && pressure > 0) {
|
||||
return clamp01(pressure);
|
||||
}
|
||||
|
||||
return pointerType === 'pen'
|
||||
? Math.max(
|
||||
this.engineConfig.input.penMinPressure,
|
||||
this.config.input.pressureFallback
|
||||
)
|
||||
: this.config.input.pressureFallback;
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
126
src/audio/piano-sampler.test.ts
Normal file
126
src/audio/piano-sampler.test.ts
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { appConfig } from '../config';
|
||||
import { gardenAudioConfig } from './garden-audio-config';
|
||||
import type { GardenAudioGraph } from './garden-audio-graph';
|
||||
import { PianoSampler } from './piano-sampler';
|
||||
import { pianoSampleDefinitions, resetPianoSampleCacheForTest } from './piano-samples';
|
||||
|
||||
const calls = {
|
||||
bufferSourcesStarted: 0,
|
||||
};
|
||||
|
||||
class FakeAudioParam {
|
||||
public value = 0;
|
||||
public setTargetAtTime = vi.fn();
|
||||
public setValueAtTime = vi.fn();
|
||||
public exponentialRampToValueAtTime = vi.fn();
|
||||
public cancelScheduledValues = vi.fn();
|
||||
}
|
||||
|
||||
class FakeAudioNode {
|
||||
public readonly gain = new FakeAudioParam();
|
||||
public readonly frequency = new FakeAudioParam();
|
||||
public readonly playbackRate = new FakeAudioParam();
|
||||
public readonly Q = new FakeAudioParam();
|
||||
public readonly pan = new FakeAudioParam();
|
||||
public buffer: AudioBuffer | null = null;
|
||||
public type = '';
|
||||
public addEventListener = vi.fn();
|
||||
public connect = vi.fn();
|
||||
public disconnect = vi.fn();
|
||||
public start = vi.fn();
|
||||
public stop = vi.fn();
|
||||
}
|
||||
|
||||
class FakeAudioContext {
|
||||
public readonly currentTime = 1;
|
||||
public readonly decodeAudioData = vi.fn(async () => ({}) as AudioBuffer);
|
||||
|
||||
public createGain(): GainNode {
|
||||
return new FakeAudioNode() as unknown as GainNode;
|
||||
}
|
||||
|
||||
public createBiquadFilter(): BiquadFilterNode {
|
||||
return new FakeAudioNode() as unknown as BiquadFilterNode;
|
||||
}
|
||||
|
||||
public createStereoPanner(): StereoPannerNode {
|
||||
return new FakeAudioNode() as unknown as StereoPannerNode;
|
||||
}
|
||||
|
||||
public createBufferSource(): AudioBufferSourceNode {
|
||||
const node = new FakeAudioNode() as unknown as AudioBufferSourceNode & {
|
||||
start: () => void;
|
||||
stop: () => void;
|
||||
};
|
||||
node.start = vi.fn(() => {
|
||||
calls.bufferSourcesStarted += 1;
|
||||
});
|
||||
node.stop = vi.fn();
|
||||
return node;
|
||||
}
|
||||
}
|
||||
|
||||
const makeSampler = (context: AudioContext): PianoSampler => {
|
||||
const eventBus = new FakeAudioNode() as unknown as GainNode;
|
||||
const graph = {
|
||||
context,
|
||||
delayInput: null,
|
||||
eventBus,
|
||||
} as unknown as GardenAudioGraph;
|
||||
|
||||
return new PianoSampler(gardenAudioConfig, appConfig.audioEngine, graph);
|
||||
};
|
||||
|
||||
describe('PianoSampler', () => {
|
||||
beforeEach(() => {
|
||||
calls.bufferSourcesStarted = 0;
|
||||
resetPianoSampleCacheForTest();
|
||||
});
|
||||
|
||||
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 fetch = vi.fn(async () => {
|
||||
return {
|
||||
arrayBuffer: async () => new ArrayBuffer(8),
|
||||
ok: true,
|
||||
} as Response;
|
||||
});
|
||||
vi.stubGlobal('fetch', fetch);
|
||||
|
||||
await sampler.load(context);
|
||||
sampler.play({
|
||||
durationSeconds: 0.2,
|
||||
midi: 60,
|
||||
pan: 0,
|
||||
startTime: context.currentTime,
|
||||
velocity: 0.5,
|
||||
});
|
||||
|
||||
expect(fetch).toHaveBeenCalledTimes(pianoSampleDefinitions.length);
|
||||
expect(context.decodeAudioData).toHaveBeenCalledTimes(pianoSampleDefinitions.length);
|
||||
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);
|
||||
|
||||
sampler.play({
|
||||
durationSeconds: 0.2,
|
||||
midi: 60,
|
||||
pan: 0,
|
||||
startTime: context.currentTime,
|
||||
velocity: 0.5,
|
||||
});
|
||||
|
||||
expect(calls.bufferSourcesStarted).toBe(0);
|
||||
});
|
||||
});
|
||||
|
|
@ -3,7 +3,7 @@ import { clamp, clamp01 } from '../utils/clamp';
|
|||
import { GardenAudioConfig } from './garden-audio-config';
|
||||
import { GardenAudioGraph } from './garden-audio-graph';
|
||||
import { ActivePianoVoice, LoadedPianoSample, PianoNote } from './garden-audio-types';
|
||||
import { pianoSampleDefinitions } from './piano-samples';
|
||||
import { getLoadedPianoSamples, loadPianoSamples } from './piano-samples';
|
||||
|
||||
export class PianoSampler {
|
||||
private sampleLoadPromise: Promise<void> | null = null;
|
||||
|
|
@ -16,28 +16,20 @@ export class PianoSampler {
|
|||
private readonly graph: GardenAudioGraph
|
||||
) {}
|
||||
|
||||
public async load(context: AudioContext): Promise<void> {
|
||||
public load(context: BaseAudioContext): Promise<void> {
|
||||
const loadedSamples = getLoadedPianoSamples();
|
||||
if (loadedSamples) {
|
||||
this.setSamples(loadedSamples);
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
if (this.sampleLoadPromise) {
|
||||
return this.sampleLoadPromise;
|
||||
}
|
||||
|
||||
this.sampleLoadPromise = Promise.all(
|
||||
pianoSampleDefinitions.map(async (sample) => {
|
||||
const response = await fetch(sample.url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Unable to load piano sample ${sample.url}`);
|
||||
}
|
||||
const audioData = await response.arrayBuffer();
|
||||
const buffer = await context.decodeAudioData(audioData);
|
||||
return { midi: sample.midi, buffer };
|
||||
})
|
||||
)
|
||||
.then((samples) => {
|
||||
this.samples = samples.sort((a, b) => a.midi - b.midi);
|
||||
})
|
||||
.catch(() => {
|
||||
this.samples = [];
|
||||
});
|
||||
this.sampleLoadPromise = loadPianoSamples(context).then((samples) => {
|
||||
this.setSamples(samples);
|
||||
});
|
||||
|
||||
return this.sampleLoadPromise;
|
||||
}
|
||||
|
|
@ -89,13 +81,9 @@ export class PianoSampler {
|
|||
this.trimActiveVoices(scheduledStart);
|
||||
while (this.activeVoices.length >= this.config.piano.maxVoices) {
|
||||
const oldest = this.activeVoices.shift();
|
||||
oldest?.gain.gain.cancelScheduledValues(scheduledStart);
|
||||
oldest?.gain.gain.setTargetAtTime(
|
||||
this.engineConfig.piano.minGain,
|
||||
scheduledStart,
|
||||
this.engineConfig.piano.voiceStealFadeSeconds
|
||||
);
|
||||
oldest?.source.stop(scheduledStart + this.engineConfig.piano.voiceStealStopSeconds);
|
||||
if (oldest) {
|
||||
this.stopVoice(oldest, scheduledStart);
|
||||
}
|
||||
}
|
||||
|
||||
source.buffer = sample.buffer;
|
||||
|
|
@ -162,6 +150,21 @@ export class PianoSampler {
|
|||
);
|
||||
}
|
||||
|
||||
public stopAll(): void {
|
||||
const context = this.graph.context;
|
||||
if (!context) {
|
||||
this.activeVoices = [];
|
||||
return;
|
||||
}
|
||||
|
||||
const now = context.currentTime;
|
||||
|
||||
this.activeVoices.forEach((voice) => {
|
||||
this.stopVoice(voice, now);
|
||||
});
|
||||
this.activeVoices = [];
|
||||
}
|
||||
|
||||
public reset(): void {
|
||||
this.sampleLoadPromise = null;
|
||||
this.samples = [];
|
||||
|
|
@ -181,4 +184,25 @@ export class PianoSampler {
|
|||
private trimActiveVoices(now: number): void {
|
||||
this.activeVoices = this.activeVoices.filter((voice) => voice.stopAt > now);
|
||||
}
|
||||
|
||||
private stopVoice(voice: ActivePianoVoice, now: number): void {
|
||||
const stopAt = now + this.engineConfig.piano.voiceStealStopSeconds;
|
||||
|
||||
voice.gain.gain.cancelScheduledValues(now);
|
||||
voice.gain.gain.setTargetAtTime(
|
||||
this.engineConfig.piano.minGain,
|
||||
now,
|
||||
this.engineConfig.piano.voiceStealFadeSeconds
|
||||
);
|
||||
voice.stopAt = stopAt;
|
||||
try {
|
||||
voice.source.stop(stopAt);
|
||||
} catch {
|
||||
// The voice may already have ended; either way it is no longer part of the mix.
|
||||
}
|
||||
}
|
||||
|
||||
private setSamples(samples: Array<LoadedPianoSample>): void {
|
||||
this.samples = samples.slice().sort((a, b) => a.midi - b.midi);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,16 @@
|
|||
interface PianoSampleDefinition {
|
||||
import type { LoadedPianoSample } from './garden-audio-types';
|
||||
|
||||
export interface PianoSampleDefinition {
|
||||
midi: number;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface PianoSampleLoadProgress {
|
||||
loadedCount: number;
|
||||
totalCount: number;
|
||||
sample?: PianoSampleDefinition;
|
||||
}
|
||||
|
||||
const sampleBaseUrl = `${import.meta.env.BASE_URL}audio/piano/`;
|
||||
|
||||
const sampleFiles: Array<[fileName: string, midi: number]> = [
|
||||
|
|
@ -44,3 +52,94 @@ export const pianoSampleDefinitions: Array<PianoSampleDefinition> = sampleFiles
|
|||
url: `${sampleBaseUrl}${fileName}`,
|
||||
}))
|
||||
.sort((a, b) => a.midi - b.midi);
|
||||
|
||||
let loadedPianoSamples: Array<LoadedPianoSample> | null = null;
|
||||
let pianoSampleLoadPromise: Promise<Array<LoadedPianoSample>> | null = null;
|
||||
|
||||
export const preloadPianoSamples = (
|
||||
onProgress?: (progress: PianoSampleLoadProgress) => void
|
||||
): Promise<Array<LoadedPianoSample>> => {
|
||||
const OfflineAudioContextConstructor =
|
||||
globalThis.OfflineAudioContext ?? globalThis.webkitOfflineAudioContext;
|
||||
|
||||
if (!OfflineAudioContextConstructor) {
|
||||
return Promise.reject(
|
||||
new Error('OfflineAudioContext is required to preload piano samples.')
|
||||
);
|
||||
}
|
||||
|
||||
const decodeContext = new OfflineAudioContextConstructor(1, 1, 44_100);
|
||||
return loadPianoSamples(decodeContext, onProgress);
|
||||
};
|
||||
|
||||
export const loadPianoSamples = (
|
||||
decodeContext: BaseAudioContext,
|
||||
onProgress?: (progress: PianoSampleLoadProgress) => void
|
||||
): Promise<Array<LoadedPianoSample>> => {
|
||||
if (loadedPianoSamples) {
|
||||
onProgress?.({
|
||||
loadedCount: loadedPianoSamples.length,
|
||||
totalCount: pianoSampleDefinitions.length,
|
||||
});
|
||||
return Promise.resolve([...loadedPianoSamples]);
|
||||
}
|
||||
|
||||
if (pianoSampleLoadPromise) {
|
||||
return pianoSampleLoadPromise;
|
||||
}
|
||||
|
||||
let loadedCount = 0;
|
||||
const totalCount = pianoSampleDefinitions.length;
|
||||
onProgress?.({ loadedCount, totalCount });
|
||||
|
||||
pianoSampleLoadPromise = Promise.all(
|
||||
pianoSampleDefinitions.map(async (sample) => {
|
||||
const loadedSample = await loadPianoSample(decodeContext, sample);
|
||||
loadedCount += 1;
|
||||
onProgress?.({ loadedCount, totalCount, sample });
|
||||
return loadedSample;
|
||||
})
|
||||
).then(
|
||||
(samples) => {
|
||||
loadedPianoSamples = samples.slice().sort((a, b) => a.midi - b.midi);
|
||||
return [...loadedPianoSamples];
|
||||
},
|
||||
(error: unknown) => {
|
||||
pianoSampleLoadPromise = null;
|
||||
throw error;
|
||||
}
|
||||
);
|
||||
|
||||
return pianoSampleLoadPromise;
|
||||
};
|
||||
|
||||
export const getLoadedPianoSamples = (): Array<LoadedPianoSample> | null =>
|
||||
loadedPianoSamples ? [...loadedPianoSamples] : null;
|
||||
|
||||
export const resetPianoSampleCacheForTest = (): void => {
|
||||
loadedPianoSamples = null;
|
||||
pianoSampleLoadPromise = null;
|
||||
};
|
||||
|
||||
const loadPianoSample = async (
|
||||
decodeContext: BaseAudioContext,
|
||||
sample: PianoSampleDefinition
|
||||
): Promise<LoadedPianoSample> => {
|
||||
const response = await fetch(sample.url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Unable to load piano sample ${sample.url}`);
|
||||
}
|
||||
|
||||
const audioData = await response.arrayBuffer();
|
||||
const buffer = await decodeAudioData(decodeContext, audioData);
|
||||
return { midi: sample.midi, buffer };
|
||||
};
|
||||
|
||||
const decodeAudioData = (
|
||||
decodeContext: BaseAudioContext,
|
||||
audioData: ArrayBuffer
|
||||
): Promise<AudioBuffer> =>
|
||||
new Promise((resolve, reject) => {
|
||||
const decodePromise = decodeContext.decodeAudioData(audioData, resolve, reject);
|
||||
decodePromise?.then(resolve, reject);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue