Compare commits

..

No commits in common. "main" and "asch/changes" have entirely different histories.

260 changed files with 976 additions and 1453 deletions

View file

@ -1,5 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path
d="M6.4 5 12 10.6 17.6 5 19 6.4 13.4 12 19 17.6 17.6 19 12 13.4 6.4 19 5 17.6 10.6 12 5 6.4z"
/>
</svg>

Before

Width:  |  Height:  |  Size: 179 B

View file

@ -4,14 +4,14 @@
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta <meta
name="viewport" name="viewport"
content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no,viewport-fit=cover" content="width=device-width,initial-scale=1,viewport-fit=cover"
/> />
<meta name="theme-color" content="#10151f" /> <meta name="theme-color" content="#10151f" />
<meta name="robots" content="index,follow" /> <meta name="robots" content="index,follow" />
<meta name="author" content="Andras Schmelczer" /> <meta name="author" content="Andras Schmelczer" />
<meta <meta
name="description" name="description"
content="Draw colour into a canvas that keeps moving. Your strokes become paths for life that splits, drifts, and redraws the surface over time." content="Tend it while you can. The garden returns to weather either way. A WebGPU drawing toy in your browser."
/> />
<link rel="canonical" href="https://schmelczer.dev/fleeting/" /> <link rel="canonical" href="https://schmelczer.dev/fleeting/" />
@ -22,7 +22,7 @@
<meta property="og:locale" content="en_US" /> <meta property="og:locale" content="en_US" />
<meta <meta
property="og:description" property="og:description"
content="Draw colour into a canvas that keeps moving. Your strokes become paths for life that splits, drifts, and redraws the surface over time." content="Tend it while you can. The garden returns to weather either way. A WebGPU drawing toy in your browser."
/> />
<meta property="og:url" content="https://schmelczer.dev/fleeting/" /> <meta property="og:url" content="https://schmelczer.dev/fleeting/" />
<meta property="og:image" content="https://schmelczer.dev/fleeting/og-image.jpg" /> <meta property="og:image" content="https://schmelczer.dev/fleeting/og-image.jpg" />
@ -35,7 +35,7 @@
<meta name="twitter:title" content="Fleeting Garden" /> <meta name="twitter:title" content="Fleeting Garden" />
<meta <meta
name="twitter:description" name="twitter:description"
content="Draw colour into a canvas that keeps moving. Your strokes become paths for life that splits, drifts, and redraws the surface over time." content="Tend it while you can. The garden returns to weather either way. A WebGPU drawing toy in your browser."
/> />
<meta name="twitter:image" content="https://schmelczer.dev/fleeting/og-image.jpg" /> <meta name="twitter:image" content="https://schmelczer.dev/fleeting/og-image.jpg" />
<meta name="twitter:image:alt" content="Fleeting Garden social preview image." /> <meta name="twitter:image:alt" content="Fleeting Garden social preview image." />
@ -46,7 +46,7 @@
"@type": "WebApplication", "@type": "WebApplication",
"name": "Fleeting Garden", "name": "Fleeting Garden",
"url": "https://schmelczer.dev/fleeting/", "url": "https://schmelczer.dev/fleeting/",
"description": "Draw colour into a canvas that keeps moving. Your strokes become paths for life that splits, drifts, and redraws the surface over time.", "description": "Tend it while you can. The garden returns to weather either way. A WebGPU drawing toy in your browser.",
"image": "https://schmelczer.dev/fleeting/og-image.jpg", "image": "https://schmelczer.dev/fleeting/og-image.jpg",
"applicationCategory": "DesignApplication", "applicationCategory": "DesignApplication",
"operatingSystem": "Any", "operatingSystem": "Any",
@ -91,8 +91,7 @@
<div class="splash" data-visible="true"> <div class="splash" data-visible="true">
<h1 class="splash-title">Fleeting Garden</h1> <h1 class="splash-title">Fleeting Garden</h1>
<p class="splash-description"> <p class="splash-description">
Draw colour into a canvas that keeps moving. Your strokes become paths for Tend it while you can. The garden returns to weather either way.
life that splits, drifts, and redraws the surface over time.
</p> </p>
<button class="start-button" type="button" disabled>Start</button> <button class="start-button" type="button" disabled>Start</button>
</div> </div>
@ -119,54 +118,35 @@
id="info-panel" id="info-panel"
class="hidden info-page" class="hidden info-page"
role="region" role="region"
aria-labelledby="info-panel-title" aria-label="About panel"
aria-hidden="true" aria-hidden="true"
tabindex="-1" tabindex="-1"
inert inert
> >
<div class="info-page__content"> <section>
<header class="info-page__header"> <h1>Fleeting Garden</h1>
<span class="info-page__mark" aria-hidden="true"></span> <p>
<div class="info-page__heading"> A garden is what we tend; the wild is what we get the moment we look away.
<p class="info-page__eyebrow">About</p> Both happen here at once. Your strokes plant colour, small agents follow them,
<h2 id="info-panel-title">Fleeting Garden</h2> branch off, and slowly rewrite the patch you laid down into something you
</div> didn't quite plan.
<button
class="info-page__close"
data-control="info-close"
type="button"
aria-label="Close about panel"
title="Close"
></button>
</header>
<p class="info-page__main">
Draw into a field of particles and watch the simulation fold your marks back
into motion.
</p> </p>
<p>
<ul class="info-page__notes"> Three swatches plant the line. The eraser carves a clearing. The mirror folds
<li>Choose one of three colour swatches, then drag to draw.</li> one gesture into many, like footpaths around a hidden well.
<li>Use the eraser to clear space and reshape the field.</li> </p>
<li>The mirror repeats each gesture across the canvas.</li> <p>
<li>The arrows switch the current atmosphere.</li> Switch vibes to change the season; your shapes stay, the light moves. Add or
</ul> quiet the piano. Restart when you want a fresh field. Take a snapshot if you
want to keep one particular instant of weather.
<p class="info-page__main"> </p>
My implementation of <p>
<a Built with WebGPU, running locally in your browser. More of my work at
href="https://cargocollective.com/sagejenson/physarum"
target="_blank"
rel="noopener"
>physarum simulation</a
>
introduces drawing and procedurally generated piano for a more immersive
experience. Learn more about my work at
<a href="https://schmelczer.dev" target="_blank" rel="noopener" <a href="https://schmelczer.dev" target="_blank" rel="noopener"
>schmelczer.dev</a >schmelczer.dev</a
> >.
</p> </p>
</div> </section>
</section> </section>
<div class="toolbar-row" role="toolbar" aria-label="Garden toolbar"> <div class="toolbar-row" role="toolbar" aria-label="Garden toolbar">

Binary file not shown.

Before

Width:  |  Height:  |  Size: 164 KiB

After

Width:  |  Height:  |  Size: 301 KiB

Before After
Before After

View file

@ -4,13 +4,9 @@ import {
type PlausibleEventOptions, type PlausibleEventOptions,
} from '@plausible-analytics/tracker'; } from '@plausible-analytics/tracker';
import { appConfig } from './config';
import type { VibeId } from './vibes'; import type { VibeId } from './vibes';
const ANALYTICS_AUTO_CAPTURE_PAGEVIEWS = true;
const ANALYTICS_DOMAIN = 'schmelczer.dev/fleeting';
const ANALYTICS_ENDPOINT = 'https://stats.schmelczer.dev/status';
const ANALYTICS_LOGGING = import.meta.env.DEV;
let isInitialized = false; let isInitialized = false;
const track = (eventName: string, options: PlausibleEventOptions = {}) => { const track = (eventName: string, options: PlausibleEventOptions = {}) => {
@ -28,10 +24,10 @@ export const initAnalytics = () => {
try { try {
plausibleInit({ plausibleInit({
domain: ANALYTICS_DOMAIN, domain: appConfig.analytics.domain,
endpoint: ANALYTICS_ENDPOINT, endpoint: appConfig.analytics.endpoint,
autoCapturePageviews: ANALYTICS_AUTO_CAPTURE_PAGEVIEWS, autoCapturePageviews: appConfig.analytics.autoCapturePageviews,
logging: ANALYTICS_LOGGING, logging: appConfig.analytics.logging,
}); });
isInitialized = true; isInitialized = true;
} catch (error) { } catch (error) {

View file

@ -1,7 +1,6 @@
import type { PianoNoteRole } from './garden-audio-types'; import type { PianoNoteRole } from './garden-audio-types';
export const DEFAULT_AUDIO_VOLUME = 0.65; export const DEFAULT_AUDIO_VOLUME = 0.5;
export const MAX_AUDIO_VOLUME = 1.5;
export const SILENT_AUDIO_GAIN = 0.0001; export const SILENT_AUDIO_GAIN = 0.0001;
type GardenAudioChordQuality = 'major' | 'minor' | 'sus2' | 'sus4'; type GardenAudioChordQuality = 'major' | 'minor' | 'sus2' | 'sus4';
@ -59,33 +58,17 @@ export const createGardenAudioConfig = () => ({
timeRampSeconds: 0.12, timeRampSeconds: 0.12,
}, },
piano: { piano: {
maxVoices: 48, maxVoices: 24,
gain: 0.78, gain: 0.48,
sustainSeconds: 0.42, sustainSeconds: 0.42,
sustainLevel: 0.26, sustainLevel: 0.26,
releaseSeconds: 0.62, releaseSeconds: 0.34,
lowpassHz: 9500, lowpassHz: 7000,
gainAttackSeconds: 0.003, gainAttackSeconds: 0.006,
lowpassMaxHz: 16000, lowpassMaxHz: 12000,
lowpassMinHz: 900, lowpassMinHz: 1400,
sustainBase: 0.45, sustainBase: 0.45,
sustainVelocityRange: 0.55, sustainVelocityRange: 0.55,
releaseSampleGain: 0.035,
releaseSampleVelocityBase: 0.45,
releaseSampleVelocityRange: 0.55,
roomSend: 0.18,
velocityLayerCurve: 0.72,
velocityLayerMax: 0.26,
velocityLayerMin: 0.035,
voiceDecayEstimateSeconds: 1.9,
},
room: {
decaySeconds: 1.65,
highPassHz: 120,
lowPassHz: 8200,
preDelaySeconds: 0.018,
sendGain: 1,
wetGain: 0.11,
}, },
rhythm: { rhythm: {
idleIntensity: defaultGardenAudioVibeSettings.idleIntensity, idleIntensity: defaultGardenAudioVibeSettings.idleIntensity,

View file

@ -28,11 +28,11 @@ const graphTuning = {
latencyHint: 'interactive', latencyHint: 'interactive',
outputFilterType: 'highpass', outputFilterType: 'highpass',
compressor: { compressor: {
thresholdDb: -17, thresholdDb: -18,
kneeDb: 18, kneeDb: 18,
ratio: 2.2, ratio: 2.1,
attackSeconds: 0.014, attackSeconds: 0.018,
releaseSeconds: 0.28, releaseSeconds: 0.18,
}, },
} as const; } as const;
const delayFilterTuning = { const delayFilterTuning = {
@ -45,7 +45,6 @@ export class GardenAudioGraph {
public context: AudioContext | null = null; public context: AudioContext | null = null;
public eventBus: GainNode | null = null; public eventBus: GainNode | null = null;
public delayInput: GainNode | null = null; public delayInput: GainNode | null = null;
public roomInput: GainNode | null = null;
public noiseBus: GainNode | null = null; public noiseBus: GainNode | null = null;
public noiseBuffer: AudioBuffer | null = null; public noiseBuffer: AudioBuffer | null = null;
@ -88,12 +87,10 @@ export class GardenAudioGraph {
const context = new AudioContextConstructor({ const context = new AudioContextConstructor({
latencyHint: graphTuning.latencyHint, latencyHint: graphTuning.latencyHint,
}); });
const outputBus = context.createGain();
const masterGain = context.createGain(); const masterGain = context.createGain();
const highPass = context.createBiquadFilter(); const highPass = context.createBiquadFilter();
const compressor = context.createDynamicsCompressor(); const compressor = context.createDynamicsCompressor();
outputBus.gain.value = 1;
masterGain.gain.value = 0; masterGain.gain.value = 0;
highPass.type = graphTuning.outputFilterType; highPass.type = graphTuning.outputFilterType;
highPass.frequency.value = outputHighPassFrequencyHz; highPass.frequency.value = outputHighPassFrequencyHz;
@ -103,18 +100,15 @@ export class GardenAudioGraph {
compressor.attack.value = graphTuning.compressor.attackSeconds; compressor.attack.value = graphTuning.compressor.attackSeconds;
compressor.release.value = graphTuning.compressor.releaseSeconds; compressor.release.value = graphTuning.compressor.releaseSeconds;
// Keep peak control independent from the user's volume slider. masterGain.connect(highPass);
outputBus.connect(highPass);
highPass.connect(compressor); highPass.connect(compressor);
compressor.connect(masterGain); compressor.connect(context.destination);
masterGain.connect(context.destination);
this.context = context; this.context = context;
this.masterGain = masterGain; this.masterGain = masterGain;
this.noiseBuffer = this.createNoiseBuffer(context); this.noiseBuffer = this.createNoiseBuffer(context);
this.createDelay(context, outputBus); this.createDelay(context, masterGain);
this.createRoom(context, outputBus); this.createBuses(context, masterGain);
this.createBuses(context, outputBus);
return context; return context;
} }
@ -230,7 +224,7 @@ export class GardenAudioGraph {
} }
} }
private createDelay(context: AudioContext, outputBus: GainNode): void { private createDelay(context: AudioContext, masterGain: GainNode): void {
const delayInput = context.createGain(); const delayInput = context.createGain();
const delayNode = context.createDelay(graphTuning.delayMaxSeconds); const delayNode = context.createDelay(graphTuning.delayMaxSeconds);
const delayFeedback = context.createGain(); const delayFeedback = context.createGain();
@ -256,7 +250,7 @@ export class GardenAudioGraph {
delayFeedback.connect(delayNode); delayFeedback.connect(delayNode);
delayNode.connect(returnLowPass); delayNode.connect(returnLowPass);
returnLowPass.connect(delayOutput); returnLowPass.connect(delayOutput);
delayOutput.connect(outputBus); delayOutput.connect(masterGain);
this.delayInput = delayInput; this.delayInput = delayInput;
this.delayNode = delayNode; this.delayNode = delayNode;
@ -264,37 +258,10 @@ export class GardenAudioGraph {
this.delayOutput = delayOutput; this.delayOutput = delayOutput;
} }
private createRoom(context: AudioContext, outputBus: GainNode): void { private createBuses(context: AudioContext, masterGain: GainNode): void {
const roomInput = context.createGain();
const preDelay = context.createDelay(0.08);
const convolver = context.createConvolver();
const highPass = context.createBiquadFilter();
const lowPass = context.createBiquadFilter();
const roomOutput = context.createGain();
roomInput.gain.value = this.config.room.sendGain;
preDelay.delayTime.value = this.config.room.preDelaySeconds;
convolver.buffer = this.createRoomImpulse(context);
highPass.type = 'highpass';
highPass.frequency.value = this.config.room.highPassHz;
lowPass.type = 'lowpass';
lowPass.frequency.value = this.config.room.lowPassHz;
roomOutput.gain.value = this.config.room.wetGain;
roomInput.connect(preDelay);
preDelay.connect(convolver);
convolver.connect(highPass);
highPass.connect(lowPass);
lowPass.connect(roomOutput);
roomOutput.connect(outputBus);
this.roomInput = roomInput;
}
private createBuses(context: AudioContext, outputBus: GainNode): void {
const eventBus = context.createGain(); const eventBus = context.createGain();
eventBus.gain.value = graphTuning.eventBusGain; eventBus.gain.value = graphTuning.eventBusGain;
eventBus.connect(outputBus); eventBus.connect(masterGain);
this.eventBus = eventBus; this.eventBus = eventBus;
this.pianoBuses.clear(); this.pianoBuses.clear();
@ -361,34 +328,10 @@ export class GardenAudioGraph {
return buffer; return buffer;
} }
private createRoomImpulse(context: AudioContext): AudioBuffer {
const sampleCount = Math.max(
1,
Math.floor(context.sampleRate * this.config.room.decaySeconds)
);
const impulse = context.createBuffer(2, sampleCount, context.sampleRate);
for (let channel = 0; channel < impulse.numberOfChannels; channel += 1) {
const data = impulse.getChannelData(channel);
for (let index = 0; index < sampleCount; index += 1) {
const position = index / sampleCount;
const decay = Math.pow(1 - position, 2.35);
const earlyReflection =
index % Math.max(1, Math.floor(context.sampleRate * 0.011)) === 0
? 0.18 * (1 - position)
: 0;
data[index] = (Math.random() * 2 - 1 + earlyReflection) * decay;
}
}
return impulse;
}
private clearNodes(): void { private clearNodes(): void {
this.context = null; this.context = null;
this.eventBus = null; this.eventBus = null;
this.delayInput = null; this.delayInput = null;
this.roomInput = null;
this.noiseBus = null; this.noiseBus = null;
this.noiseBuffer = null; this.noiseBuffer = null;
this.masterGain = null; this.masterGain = null;

View file

@ -14,22 +14,11 @@ export interface GardenAudioStroke {
elapsedSeconds: number; elapsedSeconds: number;
} }
export interface LoadedPianoStrikeSample { export interface LoadedPianoSample {
midi: number;
velocityLayer: number;
buffer: AudioBuffer;
}
export interface LoadedPianoReleaseSample {
midi: number; midi: number;
buffer: AudioBuffer; buffer: AudioBuffer;
} }
export interface LoadedPianoSamples {
releases: Array<LoadedPianoReleaseSample>;
strikes: Array<LoadedPianoStrikeSample>;
}
export interface PianoNote { export interface PianoNote {
midi: number; midi: number;
velocity: number; velocity: number;

View file

@ -1,8 +1,7 @@
import { ErrorHandler, Severity } from '../utils/error-handler'; import { ErrorHandler, Severity } from '../utils/error-handler';
import { clamp } from '../utils/math'; import { clamp01 } from '../utils/math';
import type { VibeId, VibePreset } from '../vibes'; import type { VibeId, VibePreset } from '../vibes';
import { import {
MAX_AUDIO_VOLUME,
SILENT_AUDIO_GAIN, SILENT_AUDIO_GAIN,
type GardenAudioConfig, type GardenAudioConfig,
type GardenAudioVibeProfile, type GardenAudioVibeProfile,
@ -50,7 +49,7 @@ export class GardenAudio {
private hasLoadedPiano = false; private hasLoadedPiano = false;
public constructor(private readonly config: GardenAudioConfig) { public constructor(private readonly config: GardenAudioConfig) {
this.masterVolume = clamp(config.masterVolume, 0, MAX_AUDIO_VOLUME); this.masterVolume = clamp01(config.masterVolume);
this.graph = new GardenAudioGraph(config); this.graph = new GardenAudioGraph(config);
this.piano = new PianoSampler(config, this.graph); this.piano = new PianoSampler(config, this.graph);
this.noise = new NoiseBurstPlayer(this.graph); this.noise = new NoiseBurstPlayer(this.graph);
@ -229,7 +228,7 @@ export class GardenAudio {
} }
public setMasterVolume(masterVolume: number): void { public setMasterVolume(masterVolume: number): void {
this.masterVolume = clamp(masterVolume, 0, MAX_AUDIO_VOLUME); this.masterVolume = clamp01(masterVolume);
if (!this.isMuted) { if (!this.isMuted) {
this.graph.setMasterGain(this.masterVolume, this.config.updateRampSeconds); this.graph.setMasterGain(this.masterVolume, this.config.updateRampSeconds);
} }
@ -397,7 +396,7 @@ export class GardenAudio {
return; return;
} }
const distanceActivity = clamp(activity, 0, 1); const distanceActivity = clamp01(activity);
if (distanceActivity <= 0) { if (distanceActivity <= 0) {
return; return;
} }

View file

@ -2,56 +2,32 @@ import { clamp, clamp01 } from '../utils/math';
import type { GardenAudioConfig } from './garden-audio-config'; import type { GardenAudioConfig } from './garden-audio-config';
import type { GardenAudioGraph } from './garden-audio-graph'; import type { GardenAudioGraph } from './garden-audio-graph';
import { PITCH_SEMITONES_PER_OCTAVE } from './garden-audio-music'; import { PITCH_SEMITONES_PER_OCTAVE } from './garden-audio-music';
import type { import type { LoadedPianoSample, PianoNote } from './garden-audio-types';
LoadedPianoReleaseSample,
LoadedPianoSamples,
LoadedPianoStrikeSample,
PianoNote,
} from './garden-audio-types';
import { getLoadedPianoSamples, loadPianoSamples } from './piano-samples'; import { getLoadedPianoSamples, loadPianoSamples } from './piano-samples';
export const PIANO_SCHEDULE_AHEAD_SECONDS = 0.002; export const PIANO_SCHEDULE_AHEAD_SECONDS = 0.002;
interface ActivePianoVoice { interface ActivePianoVoice {
gain: GainNode; gain: GainNode;
peakGain: number; source: AudioScheduledSourceNode;
releaseAt: number;
sources: Array<AudioBufferSourceNode>;
startedAt: number;
stopAt: number;
}
interface SelectedPianoStrikeSample {
gainScale: number;
sample: LoadedPianoStrikeSample;
}
interface ScheduledReleaseSample {
gainValue: number;
source: AudioBufferSourceNode;
startTime: number;
stopAt: number; stopAt: number;
} }
const pianoSamplerTuning = { const pianoSamplerTuning = {
filterType: 'lowpass', filterType: 'lowpass',
filterQ: 0.45, filterQ: 0.7,
minDurationSeconds: 0.08, minDurationSeconds: 0.08,
minFadeSeconds: 0.08, minFadeSeconds: 0.08,
minGain: 0.0001, minGain: 0.0001,
releaseSampleAttackSeconds: 0.006, releaseTimeConstantCount: 5,
releaseSampleDecaySeconds: 0.18, tailStopExtraSeconds: 0.05,
releaseTimeConstantCount: 6, voiceStealFadeSeconds: 0.025,
tailStopExtraSeconds: 0.08, voiceStealStopSeconds: 0.05,
voiceStealFadeSeconds: 0.045,
voiceStealStopSeconds: 0.09,
} as const; } as const;
export class PianoSampler { export class PianoSampler {
private samples: Array<LoadedPianoSample> = [];
private activeVoices: Array<ActivePianoVoice> = []; private activeVoices: Array<ActivePianoVoice> = [];
private releaseSamples: Array<LoadedPianoReleaseSample> = [];
private strikeSamples: Array<LoadedPianoStrikeSample> = [];
private velocityLayers: Array<number> = [];
public constructor( public constructor(
private readonly config: GardenAudioConfig, private readonly config: GardenAudioConfig,
@ -59,7 +35,7 @@ export class PianoSampler {
) {} ) {}
public load(context: BaseAudioContext): Promise<void> { public load(context: BaseAudioContext): Promise<void> {
if (this.strikeSamples.length > 0) { if (this.samples.length > 0) {
return Promise.resolve(); return Promise.resolve();
} }
@ -91,9 +67,8 @@ export class PianoSampler {
return; return;
} }
const noteVelocity = clamp01(velocity); const sample = this.findNearestSample(midi);
const selectedSamples = this.selectStrikeSamples(midi, noteVelocity); if (!sample) {
if (selectedSamples.length === 0) {
return; return;
} }
@ -101,6 +76,7 @@ export class PianoSampler {
context.currentTime + PIANO_SCHEDULE_AHEAD_SECONDS, context.currentTime + PIANO_SCHEDULE_AHEAD_SECONDS,
startTime startTime
); );
const noteVelocity = clamp01(velocity);
const noteGainValue = this.computeNoteGain(noteVelocity); const noteGainValue = this.computeNoteGain(noteVelocity);
const sustainSeconds = const sustainSeconds =
profileSustainSeconds * profileSustainSeconds *
@ -112,208 +88,23 @@ export class PianoSampler {
const stopAt = const stopAt =
releaseAt + releaseAt +
this.config.piano.releaseSeconds * pianoSamplerTuning.releaseTimeConstantCount; this.config.piano.releaseSeconds * pianoSamplerTuning.releaseTimeConstantCount;
const strikeSources = selectedSamples.map(({ gainScale, sample }) => ({ const source = context.createBufferSource();
gainScale,
source: this.createSource(
context,
sample.buffer,
midi,
sample.midi,
scheduledStart
),
}));
const releaseSample = this.createReleaseSample({
context,
midi,
noteVelocity,
releaseAt,
});
this.scheduleVoice({ source.buffer = sample.buffer;
delaySend, source.playbackRate.setValueAtTime(
eventBus, Math.pow(2, (midi - sample.midi) / PITCH_SEMITONES_PER_OCTAVE),
lowpassHz,
noteGainValue,
pan,
releaseAt,
releaseSample,
scheduledStart,
stopAt: releaseSample ? Math.max(stopAt, releaseSample.stopAt) : stopAt,
strikeSources,
sustainAt,
sustainSeconds,
});
}
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.releaseSamples = [];
this.strikeSamples = [];
this.velocityLayers = [];
this.activeVoices = [];
}
private scheduleVoice({
strikeSources,
releaseSample,
scheduledStart,
sustainAt,
sustainSeconds,
releaseAt,
stopAt,
pan,
lowpassHz,
delaySend,
eventBus,
noteGainValue,
}: {
delaySend: number;
eventBus: GainNode;
lowpassHz: number;
noteGainValue: number;
pan: number;
releaseAt: number;
releaseSample: ScheduledReleaseSample | null;
scheduledStart: number;
stopAt: number;
strikeSources: Array<{ gainScale: number; source: AudioBufferSourceNode }>;
sustainAt: number;
sustainSeconds: number;
}): void {
const { context, delayInput, roomInput } = this.graph;
if (!context) {
return;
}
const filter = context.createBiquadFilter();
const gain = context.createGain();
const panner = context.createStereoPanner();
const sourceGains = strikeSources.map(({ gainScale }) => {
const sourceGain = context.createGain();
sourceGain.gain.value = gainScale;
return sourceGain;
});
let delaySendGain: GainNode | null = null;
let roomSendGain: GainNode | null = null;
let releaseGain: GainNode | null = null;
this.trimActiveVoices(scheduledStart);
while (this.activeVoices.length >= this.config.piano.maxVoices) {
this.stealQuietestVoice(scheduledStart);
}
filter.type = pianoSamplerTuning.filterType;
filter.frequency.setValueAtTime(
clamp(lowpassHz, this.config.piano.lowpassMinHz, this.config.piano.lowpassMaxHz),
scheduledStart scheduledStart
); );
filter.Q.value = pianoSamplerTuning.filterQ;
panner.pan.setValueAtTime(clamp(pan, -1, 1), scheduledStart); this.scheduleVoice({
this.configureGainEnvelope({ source,
gain,
noteGainValue,
releaseAt,
scheduledStart, scheduledStart,
sustainAt,
sustainSeconds,
});
strikeSources.forEach(({ source }, index) => {
source.connect(sourceGains[index]);
sourceGains[index].connect(filter);
});
filter.connect(gain);
gain.connect(panner);
if (releaseSample) {
releaseGain = context.createGain();
releaseSample.source.connect(releaseGain);
releaseGain.connect(panner);
this.configureReleaseEnvelope(releaseGain, releaseSample);
}
panner.connect(eventBus);
if (delayInput && delaySend > 0) {
delaySendGain = context.createGain();
delaySendGain.gain.value = delaySend;
panner.connect(delaySendGain);
delaySendGain.connect(delayInput);
}
if (roomInput && this.config.piano.roomSend > 0) {
roomSendGain = context.createGain();
roomSendGain.gain.value = this.config.piano.roomSend;
panner.connect(roomSendGain);
roomSendGain.connect(roomInput);
}
const sources = [
...strikeSources.map(({ source }) => source),
...(releaseSample ? [releaseSample.source] : []),
];
strikeSources.forEach(({ source }) => {
source.start(scheduledStart);
source.stop(stopAt + pianoSamplerTuning.tailStopExtraSeconds);
});
if (releaseSample) {
releaseSample.source.start(releaseSample.startTime);
releaseSample.source.stop(releaseSample.stopAt);
}
const voice: ActivePianoVoice = {
gain,
peakGain: noteGainValue,
releaseAt,
sources,
startedAt: scheduledStart,
stopAt, stopAt,
}; pan,
this.activeVoices.push(voice); lowpassHz,
delaySend,
this.cleanupVoiceWhenSourcesEnd({ eventBus,
delaySendGain, configureGainEnvelope: (gain) => {
filter,
gain,
panner,
releaseGain,
roomSendGain,
sourceGains,
sources,
voice,
});
}
private configureGainEnvelope({
gain,
noteGainValue,
releaseAt,
scheduledStart,
sustainAt,
sustainSeconds,
}: {
gain: GainNode;
noteGainValue: number;
releaseAt: number;
scheduledStart: number;
sustainAt: number;
sustainSeconds: number;
}): void {
gain.gain.setValueAtTime(pianoSamplerTuning.minGain, scheduledStart); gain.gain.setValueAtTime(pianoSamplerTuning.minGain, scheduledStart);
gain.gain.exponentialRampToValueAtTime( gain.gain.exponentialRampToValueAtTime(
noteGainValue, noteGainValue,
@ -335,240 +126,117 @@ export class PianoSampler {
releaseAt, releaseAt,
this.config.piano.releaseSeconds this.config.piano.releaseSeconds
); );
},
});
} }
private configureReleaseEnvelope( public stopAll(): void {
releaseGain: GainNode, const context = this.graph.context;
releaseSample: ScheduledReleaseSample if (!context) {
): void { this.activeVoices = [];
releaseGain.gain.setValueAtTime(pianoSamplerTuning.minGain, releaseSample.startTime);
releaseGain.gain.exponentialRampToValueAtTime(
releaseSample.gainValue,
releaseSample.startTime + pianoSamplerTuning.releaseSampleAttackSeconds
);
releaseGain.gain.setTargetAtTime(
pianoSamplerTuning.minGain,
releaseSample.startTime + pianoSamplerTuning.releaseSampleAttackSeconds,
pianoSamplerTuning.releaseSampleDecaySeconds
);
}
private createSource(
context: BaseAudioContext,
buffer: AudioBuffer,
midi: number,
sampleMidi: number,
scheduledStart: number
): AudioBufferSourceNode {
const source = context.createBufferSource();
source.buffer = buffer;
source.playbackRate.setValueAtTime(
Math.pow(2, (midi - sampleMidi) / PITCH_SEMITONES_PER_OCTAVE),
scheduledStart
);
return source;
}
private createReleaseSample({
context,
midi,
noteVelocity,
releaseAt,
}: {
context: BaseAudioContext;
midi: number;
noteVelocity: number;
releaseAt: number;
}): ScheduledReleaseSample | null {
const sample = this.findNearestReleaseSample(midi);
if (!sample) {
return null;
}
const source = this.createSource(
context,
sample.buffer,
midi,
sample.midi,
releaseAt
);
const gainValue =
this.config.piano.releaseSampleGain *
(this.config.piano.releaseSampleVelocityBase +
noteVelocity * this.config.piano.releaseSampleVelocityRange);
return {
gainValue: Math.max(pianoSamplerTuning.minGain, gainValue),
source,
startTime: releaseAt,
stopAt:
releaseAt + sample.buffer.duration + pianoSamplerTuning.tailStopExtraSeconds,
};
}
private cleanupVoiceWhenSourcesEnd({
sources,
sourceGains,
filter,
gain,
releaseGain,
panner,
delaySendGain,
roomSendGain,
voice,
}: {
delaySendGain: GainNode | null;
filter: BiquadFilterNode;
gain: GainNode;
panner: StereoPannerNode;
releaseGain: GainNode | null;
roomSendGain: GainNode | null;
sourceGains: Array<GainNode>;
sources: Array<AudioBufferSourceNode>;
voice: ActivePianoVoice;
}): void {
let remainingSources = sources.length;
const cleanup = (): void => {
remainingSources -= 1;
if (remainingSources > 0) {
return; return;
} }
sources.forEach((source) => { const now = context.currentTime;
this.activeVoices.forEach((voice) => {
this.stopVoice(voice, now);
});
this.activeVoices = [];
}
public reset(): void {
this.samples = [];
this.activeVoices = [];
}
private scheduleVoice({
source,
scheduledStart,
stopAt,
pan,
lowpassHz,
delaySend,
eventBus,
configureGainEnvelope,
}: {
source: AudioScheduledSourceNode;
scheduledStart: number;
stopAt: number;
pan: number;
lowpassHz: number;
delaySend: number;
eventBus: GainNode;
configureGainEnvelope: (gain: GainNode) => void;
}): void {
const { context, delayInput } = this.graph;
if (!context) {
return;
}
const filter = context.createBiquadFilter();
const gain = context.createGain();
const panner = context.createStereoPanner();
let sendGain: GainNode | null = null;
this.trimActiveVoices(scheduledStart);
while (this.activeVoices.length >= this.config.piano.maxVoices) {
const oldest = this.activeVoices.shift();
if (!oldest) {
break;
}
this.stopVoice(oldest, scheduledStart);
}
filter.type = pianoSamplerTuning.filterType;
filter.frequency.setValueAtTime(
clamp(lowpassHz, this.config.piano.lowpassMinHz, this.config.piano.lowpassMaxHz),
scheduledStart
);
filter.Q.value = pianoSamplerTuning.filterQ;
panner.pan.setValueAtTime(clamp(pan, -1, 1), scheduledStart);
configureGainEnvelope(gain);
source.connect(filter);
filter.connect(gain);
gain.connect(panner);
panner.connect(eventBus);
if (delayInput && delaySend > 0) {
sendGain = context.createGain();
sendGain.gain.value = delaySend;
panner.connect(sendGain);
sendGain.connect(delayInput);
}
source.start(scheduledStart);
source.stop(stopAt + pianoSamplerTuning.tailStopExtraSeconds);
this.activeVoices.push({ gain, source, stopAt });
source.addEventListener(
'ended',
() => {
source.disconnect(); source.disconnect();
});
sourceGains.forEach((sourceGain) => {
sourceGain.disconnect();
});
filter.disconnect(); filter.disconnect();
gain.disconnect(); gain.disconnect();
releaseGain?.disconnect();
panner.disconnect(); panner.disconnect();
delaySendGain?.disconnect(); sendGain?.disconnect();
roomSendGain?.disconnect(); this.activeVoices = this.activeVoices.filter((voice) => voice.gain !== gain);
this.activeVoices = this.activeVoices.filter( },
(activeVoice) => activeVoice !== voice { once: true }
); );
};
sources.forEach((source) => {
source.addEventListener('ended', cleanup, { once: true });
});
} }
private computeNoteGain(velocity: number): number { private computeNoteGain(velocity: number): number {
return Math.max(pianoSamplerTuning.minGain, this.config.piano.gain * velocity); return Math.max(pianoSamplerTuning.minGain, this.config.piano.gain * velocity);
} }
private selectStrikeSamples( private findNearestSample(midi: number): LoadedPianoSample | null {
midi: number, if (this.samples.length === 0) {
noteVelocity: number
): Array<SelectedPianoStrikeSample> {
if (this.strikeSamples.length === 0 || this.velocityLayers.length === 0) {
return [];
}
const targetLayer = this.getTargetVelocityLayer(noteVelocity);
const layerPair = this.getVelocityLayerPair(targetLayer);
if (!layerPair) {
return [];
}
const lowerSample = this.findNearestStrikeSample(midi, layerPair.lower);
const upperSample =
layerPair.upper === layerPair.lower
? null
: this.findNearestStrikeSample(midi, layerPair.upper);
if (!lowerSample && !upperSample) {
return [];
}
if (!upperSample || layerPair.blend <= 0) {
return lowerSample ? [{ gainScale: 1, sample: lowerSample }] : [];
}
if (!lowerSample || layerPair.blend >= 1) {
return [{ gainScale: 1, sample: upperSample }];
}
return [
{
gainScale: Math.sqrt(1 - layerPair.blend),
sample: lowerSample,
},
{
gainScale: Math.sqrt(layerPair.blend),
sample: upperSample,
},
];
}
private getTargetVelocityLayer(noteVelocity: number): number {
const firstLayer = this.velocityLayers[0];
const lastLayer = this.velocityLayers[this.velocityLayers.length - 1];
const velocityRange = Math.max(
0.001,
this.config.piano.velocityLayerMax - this.config.piano.velocityLayerMin
);
const normalizedVelocity = clamp01(
(noteVelocity - this.config.piano.velocityLayerMin) / velocityRange
);
const curvedVelocity = Math.pow(
normalizedVelocity,
this.config.piano.velocityLayerCurve
);
return firstLayer + (lastLayer - firstLayer) * curvedVelocity;
}
private getVelocityLayerPair(
targetLayer: number
): { blend: number; lower: number; upper: number } | null {
const firstLayer = this.velocityLayers[0];
if (firstLayer === undefined) {
return null;
}
if (targetLayer <= firstLayer) {
return { blend: 0, lower: firstLayer, upper: firstLayer };
}
for (let index = 1; index < this.velocityLayers.length; index += 1) {
const upper = this.velocityLayers[index];
const lower = this.velocityLayers[index - 1];
if (targetLayer <= upper) {
return {
blend: (targetLayer - lower) / (upper - lower),
lower,
upper,
};
}
}
const lastLayer = this.velocityLayers[this.velocityLayers.length - 1];
return { blend: 0, lower: lastLayer, upper: lastLayer };
}
private findNearestStrikeSample(
midi: number,
velocityLayer: number
): LoadedPianoStrikeSample | null {
const layerSamples = this.strikeSamples.filter(
(sample) => sample.velocityLayer === velocityLayer
);
if (layerSamples.length === 0) {
return null; return null;
} }
return layerSamples.reduce((nearest, sample) => return this.samples.reduce((nearest, sample) =>
Math.abs(sample.midi - midi) < Math.abs(nearest.midi - midi) ? sample : nearest
);
}
private findNearestReleaseSample(midi: number): LoadedPianoReleaseSample | null {
if (this.releaseSamples.length === 0) {
return null;
}
return this.releaseSamples.reduce((nearest, sample) =>
Math.abs(sample.midi - midi) < Math.abs(nearest.midi - midi) ? sample : nearest Math.abs(sample.midi - midi) < Math.abs(nearest.midi - midi) ? sample : nearest
); );
} }
@ -577,33 +245,6 @@ export class PianoSampler {
this.activeVoices = this.activeVoices.filter((voice) => voice.stopAt > now); this.activeVoices = this.activeVoices.filter((voice) => voice.stopAt > now);
} }
private stealQuietestVoice(now: number): void {
const quietestVoice = this.activeVoices.reduce<ActivePianoVoice | null>(
(quietest, voice) =>
quietest === null ||
this.getVoiceActivityScore(voice, now) < this.getVoiceActivityScore(quietest, now)
? voice
: quietest,
null
);
if (!quietestVoice) {
return;
}
this.stopVoice(quietestVoice, now);
this.activeVoices = this.activeVoices.filter((voice) => voice !== quietestVoice);
}
private getVoiceActivityScore(voice: ActivePianoVoice, now: number): number {
const ageSeconds = Math.max(0, now - voice.startedAt);
const releasedScale = now >= voice.releaseAt ? 0.28 : 1;
return (
voice.peakGain *
releasedScale *
Math.exp(-ageSeconds / this.config.piano.voiceDecayEstimateSeconds)
);
}
private stopVoice(voice: ActivePianoVoice, now: number): void { private stopVoice(voice: ActivePianoVoice, now: number): void {
const stopAt = now + pianoSamplerTuning.voiceStealStopSeconds; const stopAt = now + pianoSamplerTuning.voiceStealStopSeconds;
@ -613,23 +254,11 @@ export class PianoSampler {
now, now,
pianoSamplerTuning.voiceStealFadeSeconds pianoSamplerTuning.voiceStealFadeSeconds
); );
voice.sources.forEach((source) => {
try {
source.stop(stopAt);
} catch {
// The source may already have ended naturally.
}
});
voice.stopAt = stopAt; voice.stopAt = stopAt;
voice.source.stop(stopAt);
} }
private setSamples(samples: LoadedPianoSamples): void { private setSamples(samples: Array<LoadedPianoSample>): void {
this.releaseSamples = samples.releases.slice().sort((a, b) => a.midi - b.midi); this.samples = samples.slice().sort((a, b) => a.midi - b.midi);
this.strikeSamples = samples.strikes
.slice()
.sort((a, b) => a.midi - b.midi || a.velocityLayer - b.velocityLayer);
this.velocityLayers = [
...new Set(this.strikeSamples.map((sample) => sample.velocityLayer)),
].sort((a, b) => a - b);
} }
} }

View file

@ -1,26 +1,40 @@
import type { import type { LoadedPianoSample } from './garden-audio-types';
LoadedPianoReleaseSample, import a0SampleUrl from './samples/A0v12.m4a?url&no-inline';
LoadedPianoSamples, import a1SampleUrl from './samples/A1v12.m4a?url&no-inline';
LoadedPianoStrikeSample, import a2SampleUrl from './samples/A2v12.m4a?url&no-inline';
} from './garden-audio-types'; import a3SampleUrl from './samples/A3v12.m4a?url&no-inline';
import a4SampleUrl from './samples/A4v12.m4a?url&no-inline';
import a5SampleUrl from './samples/A5v12.m4a?url&no-inline';
import a6SampleUrl from './samples/A6v12.m4a?url&no-inline';
import a7SampleUrl from './samples/A7v12.m4a?url&no-inline';
import c1SampleUrl from './samples/C1v12.m4a?url&no-inline';
import c2SampleUrl from './samples/C2v12.m4a?url&no-inline';
import c3SampleUrl from './samples/C3v12.m4a?url&no-inline';
import c4SampleUrl from './samples/C4v12.m4a?url&no-inline';
import c5SampleUrl from './samples/C5v12.m4a?url&no-inline';
import c6SampleUrl from './samples/C6v12.m4a?url&no-inline';
import c7SampleUrl from './samples/C7v12.m4a?url&no-inline';
import c8SampleUrl from './samples/C8v12.m4a?url&no-inline';
import dSharp1SampleUrl from './samples/Dsharp1v12.m4a?url&no-inline';
import dSharp2SampleUrl from './samples/Dsharp2v12.m4a?url&no-inline';
import dSharp3SampleUrl from './samples/Dsharp3v12.m4a?url&no-inline';
import dSharp4SampleUrl from './samples/Dsharp4v12.m4a?url&no-inline';
import dSharp5SampleUrl from './samples/Dsharp5v12.m4a?url&no-inline';
import dSharp6SampleUrl from './samples/Dsharp6v12.m4a?url&no-inline';
import dSharp7SampleUrl from './samples/Dsharp7v12.m4a?url&no-inline';
import fSharp1SampleUrl from './samples/Fsharp1v12.m4a?url&no-inline';
import fSharp2SampleUrl from './samples/Fsharp2v12.m4a?url&no-inline';
import fSharp3SampleUrl from './samples/Fsharp3v12.m4a?url&no-inline';
import fSharp4SampleUrl from './samples/Fsharp4v12.m4a?url&no-inline';
import fSharp5SampleUrl from './samples/Fsharp5v12.m4a?url&no-inline';
import fSharp6SampleUrl from './samples/Fsharp6v12.m4a?url&no-inline';
import fSharp7SampleUrl from './samples/Fsharp7v12.m4a?url&no-inline';
interface PianoStrikeSampleDefinition { interface PianoSampleDefinition {
kind: 'strike'; note: string;
midi: number;
path: string;
url: string;
velocityLayer: number;
}
interface PianoReleaseSampleDefinition {
kind: 'release';
midi: number;
path: string;
url: string; url: string;
} }
type PianoSampleDefinition = PianoStrikeSampleDefinition | PianoReleaseSampleDefinition;
export interface PianoSampleLoadProgress { export interface PianoSampleLoadProgress {
failedCount: number; failedCount: number;
loadedCount: number; loadedCount: number;
@ -28,28 +42,54 @@ export interface PianoSampleLoadProgress {
totalCount: number; totalCount: number;
} }
const pianoSampleModules = import.meta.glob('./samples/*.m4a', { const pianoSampleDefinitions: Array<PianoSampleDefinition> = [
eager: true, { url: a0SampleUrl, note: 'A0' },
import: 'default', { url: c1SampleUrl, note: 'C1' },
query: '?url&no-inline', { url: dSharp1SampleUrl, note: 'Dsharp1' },
}) as Record<string, string>; { url: fSharp1SampleUrl, note: 'Fsharp1' },
const pianoSampleDefinitions = getPianoSampleDefinitions(pianoSampleModules); { url: a1SampleUrl, note: 'A1' },
{ url: c2SampleUrl, note: 'C2' },
{ url: dSharp2SampleUrl, note: 'Dsharp2' },
{ url: fSharp2SampleUrl, note: 'Fsharp2' },
{ url: a2SampleUrl, note: 'A2' },
{ url: c3SampleUrl, note: 'C3' },
{ url: dSharp3SampleUrl, note: 'Dsharp3' },
{ url: fSharp3SampleUrl, note: 'Fsharp3' },
{ url: a3SampleUrl, note: 'A3' },
{ url: c4SampleUrl, note: 'C4' },
{ url: dSharp4SampleUrl, note: 'Dsharp4' },
{ url: fSharp4SampleUrl, note: 'Fsharp4' },
{ url: a4SampleUrl, note: 'A4' },
{ url: c5SampleUrl, note: 'C5' },
{ url: dSharp5SampleUrl, note: 'Dsharp5' },
{ url: fSharp5SampleUrl, note: 'Fsharp5' },
{ url: a5SampleUrl, note: 'A5' },
{ url: c6SampleUrl, note: 'C6' },
{ url: dSharp6SampleUrl, note: 'Dsharp6' },
{ url: fSharp6SampleUrl, note: 'Fsharp6' },
{ url: a6SampleUrl, note: 'A6' },
{ url: c7SampleUrl, note: 'C7' },
{ url: dSharp7SampleUrl, note: 'Dsharp7' },
{ url: fSharp7SampleUrl, note: 'Fsharp7' },
{ url: a7SampleUrl, note: 'A7' },
{ url: c8SampleUrl, note: 'C8' },
];
let loadedPianoSamples: LoadedPianoSamples | null = null; let loadedPianoSamples: Array<LoadedPianoSample> | null = null;
let pianoSampleLoadPromise: Promise<LoadedPianoSamples> | null = null; let pianoSampleLoadPromise: Promise<Array<LoadedPianoSample>> | null = null;
let lastPianoSampleProgress: PianoSampleLoadProgress | null = null; let lastPianoSampleProgress: PianoSampleLoadProgress | null = null;
const pianoSampleProgressListeners = new Set< const pianoSampleProgressListeners = new Set<
(progress: PianoSampleLoadProgress) => void (progress: PianoSampleLoadProgress) => void
>(); >();
const sampleLoadTuning = { const sampleLoadTuning = {
concurrency: 6, concurrency: 4,
sampleTimeoutMs: 15_000, sampleTimeoutMs: 15_000,
}; };
export const preloadPianoSamples = ( export const preloadPianoSamples = (
onProgress?: (progress: PianoSampleLoadProgress) => void onProgress?: (progress: PianoSampleLoadProgress) => void
): Promise<LoadedPianoSamples> => { ): Promise<Array<LoadedPianoSample>> => {
const OfflineAudioContextConstructor = globalThis.OfflineAudioContext; const OfflineAudioContextConstructor = globalThis.OfflineAudioContext;
if (!OfflineAudioContextConstructor) { if (!OfflineAudioContextConstructor) {
@ -66,19 +106,18 @@ export const preloadPianoSamples = (
export const loadPianoSamples = ( export const loadPianoSamples = (
decodeContext: BaseAudioContext, decodeContext: BaseAudioContext,
onProgress?: (progress: PianoSampleLoadProgress) => void onProgress?: (progress: PianoSampleLoadProgress) => void
): Promise<LoadedPianoSamples> => { ): Promise<Array<LoadedPianoSample>> => {
const unsubscribeProgress = subscribeToPianoSampleProgress(onProgress); const unsubscribeProgress = subscribeToPianoSampleProgress(onProgress);
if (loadedPianoSamples) { if (loadedPianoSamples) {
emitPianoSampleProgress({ emitPianoSampleProgress({
failedCount: 0, failedCount: 0,
loadedCount: loadedPianoSamples.strikes.length + loadedPianoSamples.releases.length, loadedCount: loadedPianoSamples.length,
settledCount: settledCount: loadedPianoSamples.length,
loadedPianoSamples.strikes.length + loadedPianoSamples.releases.length,
totalCount: pianoSampleDefinitions.length, totalCount: pianoSampleDefinitions.length,
}); });
unsubscribeProgress(); unsubscribeProgress();
return Promise.resolve(cloneLoadedPianoSamples(loadedPianoSamples)); return Promise.resolve([...loadedPianoSamples]);
} }
if (pianoSampleLoadPromise) { if (pianoSampleLoadPromise) {
@ -112,15 +151,13 @@ export const loadPianoSamples = (
) )
.then( .then(
(samples) => { (samples) => {
loadedPianoSamples = sortLoadedPianoSamples(samples); loadedPianoSamples = samples.sort((a, b) => a.midi - b.midi);
const loadedCount = if (loadedPianoSamples.length !== pianoSampleDefinitions.length) {
loadedPianoSamples.strikes.length + loadedPianoSamples.releases.length;
if (loadedCount !== pianoSampleDefinitions.length) {
throw new Error( throw new Error(
`Loaded ${loadedCount}/${pianoSampleDefinitions.length} piano samples.` `Loaded ${loadedPianoSamples.length}/${pianoSampleDefinitions.length} piano samples.`
); );
} }
return cloneLoadedPianoSamples(loadedPianoSamples); return [...loadedPianoSamples];
}, },
(error: unknown) => { (error: unknown) => {
pianoSampleLoadPromise = null; pianoSampleLoadPromise = null;
@ -133,38 +170,29 @@ export const loadPianoSamples = (
return pianoSampleLoadPromise; return pianoSampleLoadPromise;
}; };
export const getLoadedPianoSamples = (): LoadedPianoSamples | null => export const getLoadedPianoSamples = (): Array<LoadedPianoSample> | null =>
loadedPianoSamples ? cloneLoadedPianoSamples(loadedPianoSamples) : null; loadedPianoSamples ? [...loadedPianoSamples] : null;
const loadPianoSample = async ( const loadPianoSample = async (
decodeContext: BaseAudioContext, decodeContext: BaseAudioContext,
sample: PianoSampleDefinition, sample: PianoSampleDefinition,
signal: AbortSignal signal: AbortSignal
): Promise<LoadedPianoStrikeSample | LoadedPianoReleaseSample> => { ): Promise<LoadedPianoSample> => {
const response = await fetch(sample.url, { signal }); const response = await fetch(sample.url, { signal });
if (!response.ok) { if (!response.ok) {
throw new Error(`Unable to load piano sample ${sample.path}`); throw new Error(`Unable to load piano sample ${getPianoSamplePath(sample)}`);
} }
const audioData = await response.arrayBuffer(); const audioData = await response.arrayBuffer();
const buffer = await decodeContext.decodeAudioData(audioData); const buffer = await decodeContext.decodeAudioData(audioData);
if (sample.kind === 'strike') { return { midi: getMidiForPianoSample(sample), buffer };
return {
buffer,
midi: sample.midi,
velocityLayer: sample.velocityLayer,
};
}
return { buffer, midi: sample.midi };
}; };
const loadPianoSampleBatch = async ( const loadPianoSampleBatch = async (
samples: Array<PianoSampleDefinition>, samples: Array<PianoSampleDefinition>,
loadSample: ( loadSample: (sample: PianoSampleDefinition) => Promise<LoadedPianoSample>
sample: PianoSampleDefinition ): Promise<Array<LoadedPianoSample>> => {
) => Promise<LoadedPianoStrikeSample | LoadedPianoReleaseSample> const results: Array<LoadedPianoSample> = [];
): Promise<Array<LoadedPianoStrikeSample | LoadedPianoReleaseSample>> => {
const results: Array<LoadedPianoStrikeSample | LoadedPianoReleaseSample> = [];
for (let index = 0; index < samples.length; index += sampleLoadTuning.concurrency) { for (let index = 0; index < samples.length; index += sampleLoadTuning.concurrency) {
const batch = samples.slice(index, index + sampleLoadTuning.concurrency); const batch = samples.slice(index, index + sampleLoadTuning.concurrency);
@ -219,50 +247,13 @@ const emitPianoSampleProgress = (progress: PianoSampleLoadProgress): void => {
pianoSampleProgressListeners.forEach((listener) => listener(progress)); pianoSampleProgressListeners.forEach((listener) => listener(progress));
}; };
function getPianoSampleDefinitions( const getPianoSamplePath = (sample: PianoSampleDefinition): string =>
modules: Record<string, string> `./samples/${sample.note}v12.m4a`;
): Array<PianoSampleDefinition> {
return Object.entries(modules)
.map(([path, url]) => getPianoSampleDefinition(path, url))
.sort((a, b) => a.midi - b.midi || getSampleSortValue(a) - getSampleSortValue(b));
}
function getPianoSampleDefinition(path: string, url: string): PianoSampleDefinition { const getMidiForPianoSample = (sample: PianoSampleDefinition): number => {
const filename = path.split('/').pop() ?? path; const match = /^(?<name>[A-G])(?<accidental>sharp)?(?<octave>\d+)$/.exec(sample.note);
const strikeMatch = /^(?<note>[A-G](?:sharp)?\d+)v(?<velocityLayer>\d+)\.m4a$/.exec(
filename
);
if (strikeMatch?.groups) {
return {
kind: 'strike',
midi: getMidiForPianoSampleNote(strikeMatch.groups.note),
path,
url,
velocityLayer: Number(strikeMatch.groups.velocityLayer),
};
}
const releaseMatch = /^rel(?<releaseIndex>\d+)\.m4a$/.exec(filename);
if (releaseMatch?.groups) {
return {
kind: 'release',
midi: getMidiForReleaseSample(Number(releaseMatch.groups.releaseIndex)),
path,
url,
};
}
throw new Error(`Invalid piano sample filename ${path}`);
}
function getSampleSortValue(sample: PianoSampleDefinition): number {
return sample.kind === 'strike' ? sample.velocityLayer : Number.MAX_SAFE_INTEGER;
}
function getMidiForPianoSampleNote(note: string): number {
const match = /^(?<name>[A-G])(?<accidental>sharp)?(?<octave>\d+)$/.exec(note);
if (!match?.groups) { if (!match?.groups) {
throw new Error(`Invalid piano sample note ${note}`); throw new Error(`Invalid piano sample note ${sample.note}`);
} }
const semitoneByName: Record<string, number> = { const semitoneByName: Record<string, number> = {
@ -277,25 +268,4 @@ function getMidiForPianoSampleNote(note: string): number {
const octave = Number(match.groups.octave); const octave = Number(match.groups.octave);
const semitone = semitoneByName[match.groups.name] + (match.groups.accidental ? 1 : 0); const semitone = semitoneByName[match.groups.name] + (match.groups.accidental ? 1 : 0);
return (octave + 1) * 12 + semitone; return (octave + 1) * 12 + semitone;
} };
function getMidiForReleaseSample(releaseIndex: number): number {
const pianoLowestMidi = 21;
return pianoLowestMidi + releaseIndex - 1;
}
const sortLoadedPianoSamples = (
samples: Array<LoadedPianoStrikeSample | LoadedPianoReleaseSample>
): LoadedPianoSamples => ({
releases: samples
.filter((sample): sample is LoadedPianoReleaseSample => !('velocityLayer' in sample))
.sort((a, b) => a.midi - b.midi),
strikes: samples
.filter((sample): sample is LoadedPianoStrikeSample => 'velocityLayer' in sample)
.sort((a, b) => a.midi - b.midi || a.velocityLayer - b.velocityLayer),
});
const cloneLoadedPianoSamples = (samples: LoadedPianoSamples): LoadedPianoSamples => ({
releases: [...samples.releases],
strikes: [...samples.strikes],
});

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Some files were not shown because too many files have changed in this diff Show more