Compare commits
No commits in common. "main" and "asch/changes" have entirely different histories.
main
...
asch/chang
261 changed files with 1044 additions and 1630 deletions
|
|
@ -57,7 +57,6 @@ jobs:
|
|||
|
||||
- name: Copy build to host pages mount
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
uses: http://forgejo:3000/andras/ci-actions/deploy-pages@main
|
||||
with:
|
||||
source: dist
|
||||
target: fleeting
|
||||
run: |
|
||||
apt update && apt install -y rsync
|
||||
rsync -a --delete dist/ /pages/fleeting
|
||||
|
|
|
|||
|
|
@ -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 |
74
index.html
74
index.html
|
|
@ -4,14 +4,14 @@
|
|||
<meta charset="utf-8" />
|
||||
<meta
|
||||
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="robots" content="index,follow" />
|
||||
<meta name="author" content="Andras Schmelczer" />
|
||||
<meta
|
||||
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/" />
|
||||
|
|
@ -22,7 +22,7 @@
|
|||
<meta property="og:locale" content="en_US" />
|
||||
<meta
|
||||
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:image" content="https://schmelczer.dev/fleeting/og-image.jpg" />
|
||||
|
|
@ -35,7 +35,7 @@
|
|||
<meta name="twitter:title" content="Fleeting Garden" />
|
||||
<meta
|
||||
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:alt" content="Fleeting Garden social preview image." />
|
||||
|
|
@ -46,7 +46,7 @@
|
|||
"@type": "WebApplication",
|
||||
"name": "Fleeting Garden",
|
||||
"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",
|
||||
"applicationCategory": "DesignApplication",
|
||||
"operatingSystem": "Any",
|
||||
|
|
@ -91,8 +91,7 @@
|
|||
<div class="splash" data-visible="true">
|
||||
<h1 class="splash-title">Fleeting Garden</h1>
|
||||
<p class="splash-description">
|
||||
Draw colour into a canvas that keeps moving. Your strokes become paths for
|
||||
life that splits, drifts, and redraws the surface over time.
|
||||
Tend it while you can. The garden returns to weather either way.
|
||||
</p>
|
||||
<button class="start-button" type="button" disabled>Start</button>
|
||||
</div>
|
||||
|
|
@ -119,54 +118,35 @@
|
|||
id="info-panel"
|
||||
class="hidden info-page"
|
||||
role="region"
|
||||
aria-labelledby="info-panel-title"
|
||||
aria-label="About panel"
|
||||
aria-hidden="true"
|
||||
tabindex="-1"
|
||||
inert
|
||||
>
|
||||
<div class="info-page__content">
|
||||
<header class="info-page__header">
|
||||
<span class="info-page__mark" aria-hidden="true"></span>
|
||||
<div class="info-page__heading">
|
||||
<p class="info-page__eyebrow">About</p>
|
||||
<h2 id="info-panel-title">Fleeting Garden</h2>
|
||||
</div>
|
||||
<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.
|
||||
<section>
|
||||
<h1>Fleeting Garden</h1>
|
||||
<p>
|
||||
A garden is what we tend; the wild is what we get the moment we look away.
|
||||
Both happen here at once. Your strokes plant colour, small agents follow them,
|
||||
branch off, and slowly rewrite the patch you laid down into something you
|
||||
didn't quite plan.
|
||||
</p>
|
||||
|
||||
<ul class="info-page__notes">
|
||||
<li>Choose one of three colour swatches, then drag to draw.</li>
|
||||
<li>Use the eraser to clear space and reshape the field.</li>
|
||||
<li>The mirror repeats each gesture across the canvas.</li>
|
||||
<li>The arrows switch the current atmosphere.</li>
|
||||
</ul>
|
||||
|
||||
<p class="info-page__main">
|
||||
My implementation of
|
||||
<a
|
||||
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
|
||||
<p>
|
||||
Three swatches plant the line. The eraser carves a clearing. The mirror folds
|
||||
one gesture into many, like footpaths around a hidden well.
|
||||
</p>
|
||||
<p>
|
||||
Switch vibes to change the season; your shapes stay, the light moves. Add or
|
||||
quiet the piano. Restart when you want a fresh field. Take a snapshot if you
|
||||
want to keep one particular instant of weather.
|
||||
</p>
|
||||
<p>
|
||||
Built with WebGPU, running locally in your browser. More of my work at
|
||||
<a href="https://schmelczer.dev" target="_blank" rel="noopener"
|
||||
>schmelczer.dev</a
|
||||
>
|
||||
>.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<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 |
|
|
@ -4,13 +4,9 @@ import {
|
|||
type PlausibleEventOptions,
|
||||
} from '@plausible-analytics/tracker';
|
||||
|
||||
import { appConfig } from './config';
|
||||
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;
|
||||
|
||||
const track = (eventName: string, options: PlausibleEventOptions = {}) => {
|
||||
|
|
@ -28,10 +24,10 @@ export const initAnalytics = () => {
|
|||
|
||||
try {
|
||||
plausibleInit({
|
||||
domain: ANALYTICS_DOMAIN,
|
||||
endpoint: ANALYTICS_ENDPOINT,
|
||||
autoCapturePageviews: ANALYTICS_AUTO_CAPTURE_PAGEVIEWS,
|
||||
logging: ANALYTICS_LOGGING,
|
||||
domain: appConfig.analytics.domain,
|
||||
endpoint: appConfig.analytics.endpoint,
|
||||
autoCapturePageviews: appConfig.analytics.autoCapturePageviews,
|
||||
logging: appConfig.analytics.logging,
|
||||
});
|
||||
isInitialized = true;
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import type { PianoNoteRole } from './garden-audio-types';
|
||||
|
||||
export const DEFAULT_AUDIO_VOLUME = 0.65;
|
||||
export const MAX_AUDIO_VOLUME = 1.5;
|
||||
export const DEFAULT_AUDIO_VOLUME = 0.5;
|
||||
export const SILENT_AUDIO_GAIN = 0.0001;
|
||||
|
||||
type GardenAudioChordQuality = 'major' | 'minor' | 'sus2' | 'sus4';
|
||||
|
|
@ -59,33 +58,17 @@ export const createGardenAudioConfig = () => ({
|
|||
timeRampSeconds: 0.12,
|
||||
},
|
||||
piano: {
|
||||
maxVoices: 48,
|
||||
gain: 0.78,
|
||||
maxVoices: 24,
|
||||
gain: 0.48,
|
||||
sustainSeconds: 0.42,
|
||||
sustainLevel: 0.26,
|
||||
releaseSeconds: 0.62,
|
||||
lowpassHz: 9500,
|
||||
gainAttackSeconds: 0.003,
|
||||
lowpassMaxHz: 16000,
|
||||
lowpassMinHz: 900,
|
||||
releaseSeconds: 0.34,
|
||||
lowpassHz: 7000,
|
||||
gainAttackSeconds: 0.006,
|
||||
lowpassMaxHz: 12000,
|
||||
lowpassMinHz: 1400,
|
||||
sustainBase: 0.45,
|
||||
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: {
|
||||
idleIntensity: defaultGardenAudioVibeSettings.idleIntensity,
|
||||
|
|
|
|||
|
|
@ -28,11 +28,11 @@ const graphTuning = {
|
|||
latencyHint: 'interactive',
|
||||
outputFilterType: 'highpass',
|
||||
compressor: {
|
||||
thresholdDb: -17,
|
||||
thresholdDb: -18,
|
||||
kneeDb: 18,
|
||||
ratio: 2.2,
|
||||
attackSeconds: 0.014,
|
||||
releaseSeconds: 0.28,
|
||||
ratio: 2.1,
|
||||
attackSeconds: 0.018,
|
||||
releaseSeconds: 0.18,
|
||||
},
|
||||
} as const;
|
||||
const delayFilterTuning = {
|
||||
|
|
@ -45,7 +45,6 @@ export class GardenAudioGraph {
|
|||
public context: AudioContext | null = null;
|
||||
public eventBus: GainNode | null = null;
|
||||
public delayInput: GainNode | null = null;
|
||||
public roomInput: GainNode | null = null;
|
||||
public noiseBus: GainNode | null = null;
|
||||
public noiseBuffer: AudioBuffer | null = null;
|
||||
|
||||
|
|
@ -88,12 +87,10 @@ export class GardenAudioGraph {
|
|||
const context = new AudioContextConstructor({
|
||||
latencyHint: graphTuning.latencyHint,
|
||||
});
|
||||
const outputBus = context.createGain();
|
||||
const masterGain = context.createGain();
|
||||
const highPass = context.createBiquadFilter();
|
||||
const compressor = context.createDynamicsCompressor();
|
||||
|
||||
outputBus.gain.value = 1;
|
||||
masterGain.gain.value = 0;
|
||||
highPass.type = graphTuning.outputFilterType;
|
||||
highPass.frequency.value = outputHighPassFrequencyHz;
|
||||
|
|
@ -103,18 +100,15 @@ export class GardenAudioGraph {
|
|||
compressor.attack.value = graphTuning.compressor.attackSeconds;
|
||||
compressor.release.value = graphTuning.compressor.releaseSeconds;
|
||||
|
||||
// Keep peak control independent from the user's volume slider.
|
||||
outputBus.connect(highPass);
|
||||
masterGain.connect(highPass);
|
||||
highPass.connect(compressor);
|
||||
compressor.connect(masterGain);
|
||||
masterGain.connect(context.destination);
|
||||
compressor.connect(context.destination);
|
||||
|
||||
this.context = context;
|
||||
this.masterGain = masterGain;
|
||||
this.noiseBuffer = this.createNoiseBuffer(context);
|
||||
this.createDelay(context, outputBus);
|
||||
this.createRoom(context, outputBus);
|
||||
this.createBuses(context, outputBus);
|
||||
this.createDelay(context, masterGain);
|
||||
this.createBuses(context, masterGain);
|
||||
|
||||
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 delayNode = context.createDelay(graphTuning.delayMaxSeconds);
|
||||
const delayFeedback = context.createGain();
|
||||
|
|
@ -256,7 +250,7 @@ export class GardenAudioGraph {
|
|||
delayFeedback.connect(delayNode);
|
||||
delayNode.connect(returnLowPass);
|
||||
returnLowPass.connect(delayOutput);
|
||||
delayOutput.connect(outputBus);
|
||||
delayOutput.connect(masterGain);
|
||||
|
||||
this.delayInput = delayInput;
|
||||
this.delayNode = delayNode;
|
||||
|
|
@ -264,37 +258,10 @@ export class GardenAudioGraph {
|
|||
this.delayOutput = delayOutput;
|
||||
}
|
||||
|
||||
private createRoom(context: AudioContext, outputBus: 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 {
|
||||
private createBuses(context: AudioContext, masterGain: GainNode): void {
|
||||
const eventBus = context.createGain();
|
||||
eventBus.gain.value = graphTuning.eventBusGain;
|
||||
eventBus.connect(outputBus);
|
||||
eventBus.connect(masterGain);
|
||||
this.eventBus = eventBus;
|
||||
this.pianoBuses.clear();
|
||||
|
||||
|
|
@ -361,34 +328,10 @@ export class GardenAudioGraph {
|
|||
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 {
|
||||
this.context = null;
|
||||
this.eventBus = null;
|
||||
this.delayInput = null;
|
||||
this.roomInput = null;
|
||||
this.noiseBus = null;
|
||||
this.noiseBuffer = null;
|
||||
this.masterGain = null;
|
||||
|
|
|
|||
|
|
@ -14,22 +14,11 @@ export interface GardenAudioStroke {
|
|||
elapsedSeconds: number;
|
||||
}
|
||||
|
||||
export interface LoadedPianoStrikeSample {
|
||||
midi: number;
|
||||
velocityLayer: number;
|
||||
buffer: AudioBuffer;
|
||||
}
|
||||
|
||||
export interface LoadedPianoReleaseSample {
|
||||
export interface LoadedPianoSample {
|
||||
midi: number;
|
||||
buffer: AudioBuffer;
|
||||
}
|
||||
|
||||
export interface LoadedPianoSamples {
|
||||
releases: Array<LoadedPianoReleaseSample>;
|
||||
strikes: Array<LoadedPianoStrikeSample>;
|
||||
}
|
||||
|
||||
export interface PianoNote {
|
||||
midi: number;
|
||||
velocity: number;
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
import { ErrorHandler, Severity } from '../utils/error-handler';
|
||||
import { clamp } from '../utils/math';
|
||||
import { clamp01 } from '../utils/math';
|
||||
import type { VibeId, VibePreset } from '../vibes';
|
||||
import {
|
||||
MAX_AUDIO_VOLUME,
|
||||
SILENT_AUDIO_GAIN,
|
||||
type GardenAudioConfig,
|
||||
type GardenAudioVibeProfile,
|
||||
|
|
@ -50,7 +49,7 @@ export class GardenAudio {
|
|||
private hasLoadedPiano = false;
|
||||
|
||||
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.piano = new PianoSampler(config, this.graph);
|
||||
this.noise = new NoiseBurstPlayer(this.graph);
|
||||
|
|
@ -229,7 +228,7 @@ export class GardenAudio {
|
|||
}
|
||||
|
||||
public setMasterVolume(masterVolume: number): void {
|
||||
this.masterVolume = clamp(masterVolume, 0, MAX_AUDIO_VOLUME);
|
||||
this.masterVolume = clamp01(masterVolume);
|
||||
if (!this.isMuted) {
|
||||
this.graph.setMasterGain(this.masterVolume, this.config.updateRampSeconds);
|
||||
}
|
||||
|
|
@ -397,7 +396,7 @@ export class GardenAudio {
|
|||
return;
|
||||
}
|
||||
|
||||
const distanceActivity = clamp(activity, 0, 1);
|
||||
const distanceActivity = clamp01(activity);
|
||||
if (distanceActivity <= 0) {
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,57 +2,32 @@ import { clamp, clamp01 } from '../utils/math';
|
|||
import type { GardenAudioConfig } from './garden-audio-config';
|
||||
import type { GardenAudioGraph } from './garden-audio-graph';
|
||||
import { PITCH_SEMITONES_PER_OCTAVE } from './garden-audio-music';
|
||||
import type {
|
||||
LoadedPianoReleaseSample,
|
||||
LoadedPianoSamples,
|
||||
LoadedPianoStrikeSample,
|
||||
PianoNote,
|
||||
} from './garden-audio-types';
|
||||
import { ensurePianoSamplesLoading, subscribeToPianoSamples } from './piano-samples';
|
||||
import type { LoadedPianoSample, PianoNote } from './garden-audio-types';
|
||||
import { getLoadedPianoSamples, loadPianoSamples } from './piano-samples';
|
||||
|
||||
export const PIANO_SCHEDULE_AHEAD_SECONDS = 0.002;
|
||||
|
||||
interface ActivePianoVoice {
|
||||
gain: GainNode;
|
||||
peakGain: number;
|
||||
releaseAt: number;
|
||||
sources: Array<AudioBufferSourceNode>;
|
||||
startedAt: number;
|
||||
stopAt: number;
|
||||
}
|
||||
|
||||
interface SelectedPianoStrikeSample {
|
||||
gainScale: number;
|
||||
sample: LoadedPianoStrikeSample;
|
||||
}
|
||||
|
||||
interface ScheduledReleaseSample {
|
||||
gainValue: number;
|
||||
source: AudioBufferSourceNode;
|
||||
startTime: number;
|
||||
source: AudioScheduledSourceNode;
|
||||
stopAt: number;
|
||||
}
|
||||
|
||||
const pianoSamplerTuning = {
|
||||
filterType: 'lowpass',
|
||||
filterQ: 0.45,
|
||||
filterQ: 0.7,
|
||||
minDurationSeconds: 0.08,
|
||||
minFadeSeconds: 0.08,
|
||||
minGain: 0.0001,
|
||||
releaseSampleAttackSeconds: 0.006,
|
||||
releaseSampleDecaySeconds: 0.18,
|
||||
releaseTimeConstantCount: 6,
|
||||
tailStopExtraSeconds: 0.08,
|
||||
voiceStealFadeSeconds: 0.045,
|
||||
voiceStealStopSeconds: 0.09,
|
||||
releaseTimeConstantCount: 5,
|
||||
tailStopExtraSeconds: 0.05,
|
||||
voiceStealFadeSeconds: 0.025,
|
||||
voiceStealStopSeconds: 0.05,
|
||||
} as const;
|
||||
|
||||
export class PianoSampler {
|
||||
private samples: Array<LoadedPianoSample> = [];
|
||||
private activeVoices: Array<ActivePianoVoice> = [];
|
||||
private releaseSamples: Array<LoadedPianoReleaseSample> = [];
|
||||
private strikeSamples: Array<LoadedPianoStrikeSample> = [];
|
||||
private velocityLayers: Array<number> = [];
|
||||
private unsubscribeFromSampleUpdates: (() => void) | null = null;
|
||||
|
||||
public constructor(
|
||||
private readonly config: GardenAudioConfig,
|
||||
|
|
@ -60,17 +35,19 @@ export class PianoSampler {
|
|||
) {}
|
||||
|
||||
public load(context: BaseAudioContext): Promise<void> {
|
||||
// The subscription applies further velocity layers and release samples
|
||||
// as they finish loading in the background.
|
||||
this.unsubscribeFromSampleUpdates ??= subscribeToPianoSamples((samples) => {
|
||||
this.setSamples(samples);
|
||||
});
|
||||
|
||||
if (this.strikeSamples.length > 0) {
|
||||
if (this.samples.length > 0) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
return ensurePianoSamplesLoading(context);
|
||||
const loadedSamples = getLoadedPianoSamples();
|
||||
if (loadedSamples) {
|
||||
this.setSamples(loadedSamples);
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
return loadPianoSamples(context).then((samples) => {
|
||||
this.setSamples(samples);
|
||||
});
|
||||
}
|
||||
|
||||
public play({
|
||||
|
|
@ -90,9 +67,8 @@ export class PianoSampler {
|
|||
return;
|
||||
}
|
||||
|
||||
const noteVelocity = clamp01(velocity);
|
||||
const selectedSamples = this.selectStrikeSamples(midi, noteVelocity);
|
||||
if (selectedSamples.length === 0) {
|
||||
const sample = this.findNearestSample(midi);
|
||||
if (!sample) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -100,6 +76,7 @@ export class PianoSampler {
|
|||
context.currentTime + PIANO_SCHEDULE_AHEAD_SECONDS,
|
||||
startTime
|
||||
);
|
||||
const noteVelocity = clamp01(velocity);
|
||||
const noteGainValue = this.computeNoteGain(noteVelocity);
|
||||
const sustainSeconds =
|
||||
profileSustainSeconds *
|
||||
|
|
@ -111,36 +88,45 @@ export class PianoSampler {
|
|||
const stopAt =
|
||||
releaseAt +
|
||||
this.config.piano.releaseSeconds * pianoSamplerTuning.releaseTimeConstantCount;
|
||||
const strikeSources = selectedSamples.map(({ gainScale, sample }) => ({
|
||||
gainScale,
|
||||
source: this.createSource(
|
||||
context,
|
||||
sample.buffer,
|
||||
midi,
|
||||
sample.midi,
|
||||
scheduledStart
|
||||
),
|
||||
}));
|
||||
const releaseSample = this.createReleaseSample({
|
||||
context,
|
||||
midi,
|
||||
noteVelocity,
|
||||
releaseAt,
|
||||
});
|
||||
const source = context.createBufferSource();
|
||||
|
||||
source.buffer = sample.buffer;
|
||||
source.playbackRate.setValueAtTime(
|
||||
Math.pow(2, (midi - sample.midi) / PITCH_SEMITONES_PER_OCTAVE),
|
||||
scheduledStart
|
||||
);
|
||||
|
||||
this.scheduleVoice({
|
||||
source,
|
||||
scheduledStart,
|
||||
stopAt,
|
||||
pan,
|
||||
lowpassHz,
|
||||
delaySend,
|
||||
eventBus,
|
||||
lowpassHz,
|
||||
noteGainValue,
|
||||
pan,
|
||||
releaseAt,
|
||||
releaseSample,
|
||||
scheduledStart,
|
||||
stopAt: releaseSample ? Math.max(stopAt, releaseSample.stopAt) : stopAt,
|
||||
strikeSources,
|
||||
sustainAt,
|
||||
sustainSeconds,
|
||||
configureGainEnvelope: (gain) => {
|
||||
gain.gain.setValueAtTime(pianoSamplerTuning.minGain, scheduledStart);
|
||||
gain.gain.exponentialRampToValueAtTime(
|
||||
noteGainValue,
|
||||
scheduledStart + this.config.piano.gainAttackSeconds
|
||||
);
|
||||
gain.gain.setTargetAtTime(
|
||||
Math.max(
|
||||
pianoSamplerTuning.minGain,
|
||||
noteGainValue * this.config.piano.sustainLevel
|
||||
),
|
||||
sustainAt,
|
||||
Math.max(
|
||||
pianoSamplerTuning.minFadeSeconds,
|
||||
sustainSeconds * this.config.piano.sustainBase
|
||||
)
|
||||
);
|
||||
gain.gain.setTargetAtTime(
|
||||
pianoSamplerTuning.minGain,
|
||||
releaseAt,
|
||||
this.config.piano.releaseSeconds
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -160,42 +146,30 @@ export class PianoSampler {
|
|||
}
|
||||
|
||||
public reset(): void {
|
||||
this.unsubscribeFromSampleUpdates?.();
|
||||
this.unsubscribeFromSampleUpdates = null;
|
||||
this.releaseSamples = [];
|
||||
this.strikeSamples = [];
|
||||
this.velocityLayers = [];
|
||||
this.samples = [];
|
||||
this.activeVoices = [];
|
||||
}
|
||||
|
||||
private scheduleVoice({
|
||||
strikeSources,
|
||||
releaseSample,
|
||||
source,
|
||||
scheduledStart,
|
||||
sustainAt,
|
||||
sustainSeconds,
|
||||
releaseAt,
|
||||
stopAt,
|
||||
pan,
|
||||
lowpassHz,
|
||||
delaySend,
|
||||
eventBus,
|
||||
noteGainValue,
|
||||
configureGainEnvelope,
|
||||
}: {
|
||||
delaySend: number;
|
||||
eventBus: GainNode;
|
||||
lowpassHz: number;
|
||||
noteGainValue: number;
|
||||
pan: number;
|
||||
releaseAt: number;
|
||||
releaseSample: ScheduledReleaseSample | null;
|
||||
source: AudioScheduledSourceNode;
|
||||
scheduledStart: number;
|
||||
stopAt: number;
|
||||
strikeSources: Array<{ gainScale: number; source: AudioBufferSourceNode }>;
|
||||
sustainAt: number;
|
||||
sustainSeconds: number;
|
||||
pan: number;
|
||||
lowpassHz: number;
|
||||
delaySend: number;
|
||||
eventBus: GainNode;
|
||||
configureGainEnvelope: (gain: GainNode) => void;
|
||||
}): void {
|
||||
const { context, delayInput, roomInput } = this.graph;
|
||||
const { context, delayInput } = this.graph;
|
||||
if (!context) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -203,18 +177,15 @@ export class PianoSampler {
|
|||
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;
|
||||
let sendGain: GainNode | null = null;
|
||||
|
||||
this.trimActiveVoices(scheduledStart);
|
||||
while (this.activeVoices.length >= this.config.piano.maxVoices) {
|
||||
this.stealQuietestVoice(scheduledStart);
|
||||
const oldest = this.activeVoices.shift();
|
||||
if (!oldest) {
|
||||
break;
|
||||
}
|
||||
this.stopVoice(oldest, scheduledStart);
|
||||
}
|
||||
|
||||
filter.type = pianoSamplerTuning.filterType;
|
||||
|
|
@ -224,352 +195,48 @@ export class PianoSampler {
|
|||
);
|
||||
filter.Q.value = pianoSamplerTuning.filterQ;
|
||||
panner.pan.setValueAtTime(clamp(pan, -1, 1), scheduledStart);
|
||||
this.configureGainEnvelope({
|
||||
gain,
|
||||
noteGainValue,
|
||||
releaseAt,
|
||||
scheduledStart,
|
||||
sustainAt,
|
||||
sustainSeconds,
|
||||
});
|
||||
configureGainEnvelope(gain);
|
||||
|
||||
strikeSources.forEach(({ source }, index) => {
|
||||
source.connect(sourceGains[index]);
|
||||
sourceGains[index].connect(filter);
|
||||
});
|
||||
source.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);
|
||||
sendGain = context.createGain();
|
||||
sendGain.gain.value = delaySend;
|
||||
panner.connect(sendGain);
|
||||
sendGain.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);
|
||||
}
|
||||
source.start(scheduledStart);
|
||||
source.stop(stopAt + pianoSamplerTuning.tailStopExtraSeconds);
|
||||
this.activeVoices.push({ gain, source, stopAt });
|
||||
|
||||
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,
|
||||
};
|
||||
this.activeVoices.push(voice);
|
||||
|
||||
this.cleanupVoiceWhenSourcesEnd({
|
||||
delaySendGain,
|
||||
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.exponentialRampToValueAtTime(
|
||||
noteGainValue,
|
||||
scheduledStart + this.config.piano.gainAttackSeconds
|
||||
);
|
||||
gain.gain.setTargetAtTime(
|
||||
Math.max(
|
||||
pianoSamplerTuning.minGain,
|
||||
noteGainValue * this.config.piano.sustainLevel
|
||||
),
|
||||
sustainAt,
|
||||
Math.max(
|
||||
pianoSamplerTuning.minFadeSeconds,
|
||||
sustainSeconds * this.config.piano.sustainBase
|
||||
)
|
||||
);
|
||||
gain.gain.setTargetAtTime(
|
||||
pianoSamplerTuning.minGain,
|
||||
releaseAt,
|
||||
this.config.piano.releaseSeconds
|
||||
);
|
||||
}
|
||||
|
||||
private configureReleaseEnvelope(
|
||||
releaseGain: GainNode,
|
||||
releaseSample: ScheduledReleaseSample
|
||||
): void {
|
||||
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;
|
||||
}
|
||||
|
||||
sources.forEach((source) => {
|
||||
source.addEventListener(
|
||||
'ended',
|
||||
() => {
|
||||
source.disconnect();
|
||||
});
|
||||
sourceGains.forEach((sourceGain) => {
|
||||
sourceGain.disconnect();
|
||||
});
|
||||
filter.disconnect();
|
||||
gain.disconnect();
|
||||
releaseGain?.disconnect();
|
||||
panner.disconnect();
|
||||
delaySendGain?.disconnect();
|
||||
roomSendGain?.disconnect();
|
||||
this.activeVoices = this.activeVoices.filter(
|
||||
(activeVoice) => activeVoice !== voice
|
||||
);
|
||||
};
|
||||
|
||||
sources.forEach((source) => {
|
||||
source.addEventListener('ended', cleanup, { once: true });
|
||||
});
|
||||
filter.disconnect();
|
||||
gain.disconnect();
|
||||
panner.disconnect();
|
||||
sendGain?.disconnect();
|
||||
this.activeVoices = this.activeVoices.filter((voice) => voice.gain !== gain);
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
}
|
||||
|
||||
private computeNoteGain(velocity: number): number {
|
||||
return Math.max(pianoSamplerTuning.minGain, this.config.piano.gain * velocity);
|
||||
}
|
||||
|
||||
private selectStrikeSamples(
|
||||
midi: number,
|
||||
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) {
|
||||
private findNearestSample(midi: number): LoadedPianoSample | null {
|
||||
if (this.samples.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return layerSamples.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) =>
|
||||
return this.samples.reduce((nearest, sample) =>
|
||||
Math.abs(sample.midi - midi) < Math.abs(nearest.midi - midi) ? sample : nearest
|
||||
);
|
||||
}
|
||||
|
|
@ -578,33 +245,6 @@ export class PianoSampler {
|
|||
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 {
|
||||
const stopAt = now + pianoSamplerTuning.voiceStealStopSeconds;
|
||||
|
||||
|
|
@ -614,23 +254,11 @@ export class PianoSampler {
|
|||
now,
|
||||
pianoSamplerTuning.voiceStealFadeSeconds
|
||||
);
|
||||
voice.sources.forEach((source) => {
|
||||
try {
|
||||
source.stop(stopAt);
|
||||
} catch {
|
||||
// The source may already have ended naturally.
|
||||
}
|
||||
});
|
||||
voice.stopAt = stopAt;
|
||||
voice.source.stop(stopAt);
|
||||
}
|
||||
|
||||
private setSamples(samples: LoadedPianoSamples): void {
|
||||
this.releaseSamples = samples.releases.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);
|
||||
private setSamples(samples: Array<LoadedPianoSample>): void {
|
||||
this.samples = samples.slice().sort((a, b) => a.midi - b.midi);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,29 +1,40 @@
|
|||
import { ErrorHandler, Severity } from '../utils/error-handler';
|
||||
import type {
|
||||
LoadedPianoReleaseSample,
|
||||
LoadedPianoSamples,
|
||||
LoadedPianoStrikeSample,
|
||||
} from './garden-audio-types';
|
||||
import type { LoadedPianoSample } from './garden-audio-types';
|
||||
import a0SampleUrl from './samples/A0v12.m4a?url&no-inline';
|
||||
import a1SampleUrl from './samples/A1v12.m4a?url&no-inline';
|
||||
import a2SampleUrl from './samples/A2v12.m4a?url&no-inline';
|
||||
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 {
|
||||
kind: 'strike';
|
||||
midi: number;
|
||||
path: string;
|
||||
url: string;
|
||||
velocityLayer: number;
|
||||
}
|
||||
|
||||
interface PianoReleaseSampleDefinition {
|
||||
kind: 'release';
|
||||
midi: number;
|
||||
path: string;
|
||||
interface PianoSampleDefinition {
|
||||
note: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
type PianoSampleDefinition = PianoStrikeSampleDefinition | PianoReleaseSampleDefinition;
|
||||
|
||||
type LoadedPianoSample = LoadedPianoStrikeSample | LoadedPianoReleaseSample;
|
||||
|
||||
export interface PianoSampleLoadProgress {
|
||||
failedCount: number;
|
||||
loadedCount: number;
|
||||
|
|
@ -31,44 +42,54 @@ export interface PianoSampleLoadProgress {
|
|||
totalCount: number;
|
||||
}
|
||||
|
||||
const pianoSampleModules = import.meta.glob('./samples/*.m4a', {
|
||||
eager: true,
|
||||
import: 'default',
|
||||
query: '?url&no-inline',
|
||||
}) as Record<string, string>;
|
||||
const pianoSampleDefinitions = getPianoSampleDefinitions(pianoSampleModules);
|
||||
const pianoSampleDefinitions: Array<PianoSampleDefinition> = [
|
||||
{ url: a0SampleUrl, note: 'A0' },
|
||||
{ url: c1SampleUrl, note: 'C1' },
|
||||
{ url: dSharp1SampleUrl, note: 'Dsharp1' },
|
||||
{ url: fSharp1SampleUrl, note: 'Fsharp1' },
|
||||
{ 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' },
|
||||
];
|
||||
|
||||
// The sampler degrades gracefully on a partial set: it picks the nearest
|
||||
// available strike per note, blends whatever velocity layers exist, and
|
||||
// treats release samples as optional. The core velocity layer is therefore
|
||||
// enough to start playing; the remaining layers and the release samples
|
||||
// stream in afterwards, published per completed layer so nearest-sample
|
||||
// lookups never see a half-loaded layer.
|
||||
const coreVelocityLayer = 8;
|
||||
const corePianoSampleDefinitions = pianoSampleDefinitions.filter(
|
||||
(definition) =>
|
||||
definition.kind === 'strike' && definition.velocityLayer === coreVelocityLayer
|
||||
);
|
||||
const backgroundPianoSamplePhases = getBackgroundPianoSamplePhases();
|
||||
|
||||
const loadedSamplesByPath = new Map<string, LoadedPianoSample>();
|
||||
let publishedSamples: LoadedPianoSamples | null = null;
|
||||
let coreSampleLoadPromise: Promise<void> | null = null;
|
||||
let isLoadingBackgroundSamples = false;
|
||||
const sampleSubscribers = new Set<(samples: LoadedPianoSamples) => void>();
|
||||
let loadedPianoSamples: Array<LoadedPianoSample> | null = null;
|
||||
let pianoSampleLoadPromise: Promise<Array<LoadedPianoSample>> | null = null;
|
||||
let lastPianoSampleProgress: PianoSampleLoadProgress | null = null;
|
||||
const pianoSampleProgressListeners = new Set<
|
||||
(progress: PianoSampleLoadProgress) => void
|
||||
>();
|
||||
|
||||
const sampleLoadTuning = {
|
||||
concurrency: 6,
|
||||
concurrency: 4,
|
||||
sampleTimeoutMs: 15_000,
|
||||
};
|
||||
|
||||
export const preloadPianoSamples = (
|
||||
onProgress?: (progress: PianoSampleLoadProgress) => void
|
||||
): Promise<void> => {
|
||||
): Promise<Array<LoadedPianoSample>> => {
|
||||
const OfflineAudioContextConstructor = globalThis.OfflineAudioContext;
|
||||
|
||||
if (!OfflineAudioContextConstructor) {
|
||||
|
|
@ -79,178 +100,107 @@ export const preloadPianoSamples = (
|
|||
|
||||
// Decoding ignores these, but the constructor demands real numbers.
|
||||
const decodeContext = new OfflineAudioContextConstructor(1, 1, 48_000);
|
||||
return ensurePianoSamplesLoading(decodeContext, onProgress);
|
||||
return loadPianoSamples(decodeContext, onProgress);
|
||||
};
|
||||
|
||||
/**
|
||||
* Starts loading samples if needed and resolves once the core velocity layer
|
||||
* is playable. Failed samples are retried on the next call; background layers
|
||||
* keep streaming in after resolution.
|
||||
*/
|
||||
export const ensurePianoSamplesLoading = (
|
||||
export const loadPianoSamples = (
|
||||
decodeContext: BaseAudioContext,
|
||||
onProgress?: (progress: PianoSampleLoadProgress) => void
|
||||
): Promise<void> => {
|
||||
): Promise<Array<LoadedPianoSample>> => {
|
||||
const unsubscribeProgress = subscribeToPianoSampleProgress(onProgress);
|
||||
|
||||
coreSampleLoadPromise ??= loadCoreSamples(decodeContext).catch((error: unknown) => {
|
||||
coreSampleLoadPromise = null;
|
||||
throw error;
|
||||
});
|
||||
coreSampleLoadPromise.then(
|
||||
() => {
|
||||
startBackgroundLoading(decodeContext);
|
||||
},
|
||||
() => undefined
|
||||
);
|
||||
|
||||
return coreSampleLoadPromise.finally(unsubscribeProgress);
|
||||
};
|
||||
|
||||
/**
|
||||
* Calls the listener with the currently published samples (if any) and again
|
||||
* whenever another batch finishes loading. Returns an unsubscribe function.
|
||||
*/
|
||||
export const subscribeToPianoSamples = (
|
||||
listener: (samples: LoadedPianoSamples) => void
|
||||
): (() => void) => {
|
||||
sampleSubscribers.add(listener);
|
||||
if (publishedSamples) {
|
||||
listener(cloneLoadedPianoSamples(publishedSamples));
|
||||
if (loadedPianoSamples) {
|
||||
emitPianoSampleProgress({
|
||||
failedCount: 0,
|
||||
loadedCount: loadedPianoSamples.length,
|
||||
settledCount: loadedPianoSamples.length,
|
||||
totalCount: pianoSampleDefinitions.length,
|
||||
});
|
||||
unsubscribeProgress();
|
||||
return Promise.resolve([...loadedPianoSamples]);
|
||||
}
|
||||
return () => {
|
||||
sampleSubscribers.delete(listener);
|
||||
};
|
||||
};
|
||||
|
||||
const loadCoreSamples = async (decodeContext: BaseAudioContext): Promise<void> => {
|
||||
const pending = corePianoSampleDefinitions.filter(
|
||||
(definition) => !loadedSamplesByPath.has(definition.path)
|
||||
);
|
||||
const totalCount = corePianoSampleDefinitions.length;
|
||||
let loadedCount = totalCount - pending.length;
|
||||
let settledCount = loadedCount;
|
||||
if (pianoSampleLoadPromise) {
|
||||
return pianoSampleLoadPromise.finally(unsubscribeProgress);
|
||||
}
|
||||
|
||||
let loadedCount = 0;
|
||||
let failedCount = 0;
|
||||
let settledCount = 0;
|
||||
const totalCount = pianoSampleDefinitions.length;
|
||||
emitPianoSampleProgress({ failedCount, loadedCount, settledCount, totalCount });
|
||||
|
||||
await loadSamplesWithConcurrency(pending, async (definition) => {
|
||||
try {
|
||||
await loadPianoSample(decodeContext, definition);
|
||||
loadedCount += 1;
|
||||
} catch (error) {
|
||||
failedCount += 1;
|
||||
throw error;
|
||||
} finally {
|
||||
settledCount += 1;
|
||||
emitPianoSampleProgress({ failedCount, loadedCount, settledCount, totalCount });
|
||||
pianoSampleLoadPromise = loadPianoSampleBatch(
|
||||
pianoSampleDefinitions,
|
||||
async (sample) => {
|
||||
try {
|
||||
const loadedSample = await withTimeout(
|
||||
(signal) => loadPianoSample(decodeContext, sample, signal),
|
||||
sampleLoadTuning.sampleTimeoutMs
|
||||
);
|
||||
loadedCount += 1;
|
||||
return loadedSample;
|
||||
} catch (error) {
|
||||
failedCount += 1;
|
||||
throw error;
|
||||
} finally {
|
||||
settledCount += 1;
|
||||
emitPianoSampleProgress({ failedCount, loadedCount, settledCount, totalCount });
|
||||
}
|
||||
}
|
||||
});
|
||||
)
|
||||
.then(
|
||||
(samples) => {
|
||||
loadedPianoSamples = samples.sort((a, b) => a.midi - b.midi);
|
||||
if (loadedPianoSamples.length !== pianoSampleDefinitions.length) {
|
||||
throw new Error(
|
||||
`Loaded ${loadedPianoSamples.length}/${pianoSampleDefinitions.length} piano samples.`
|
||||
);
|
||||
}
|
||||
return [...loadedPianoSamples];
|
||||
},
|
||||
(error: unknown) => {
|
||||
pianoSampleLoadPromise = null;
|
||||
pianoSampleProgressListeners.clear();
|
||||
throw error;
|
||||
}
|
||||
)
|
||||
.finally(unsubscribeProgress);
|
||||
|
||||
publishLoadedSamples();
|
||||
return pianoSampleLoadPromise;
|
||||
};
|
||||
|
||||
const startBackgroundLoading = (decodeContext: BaseAudioContext): void => {
|
||||
if (
|
||||
isLoadingBackgroundSamples ||
|
||||
loadedSamplesByPath.size === pianoSampleDefinitions.length
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
isLoadingBackgroundSamples = true;
|
||||
loadBackgroundSamples(decodeContext).then(
|
||||
() => {
|
||||
isLoadingBackgroundSamples = false;
|
||||
},
|
||||
(error: unknown) => {
|
||||
// The piano stays playable on the samples loaded so far; the next
|
||||
// ensurePianoSamplesLoading call retries whatever is still missing.
|
||||
isLoadingBackgroundSamples = false;
|
||||
ErrorHandler.addException(error, {
|
||||
fallbackMessage: 'Could not load all piano samples.',
|
||||
severity: Severity.WARNING,
|
||||
});
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const loadBackgroundSamples = async (decodeContext: BaseAudioContext): Promise<void> => {
|
||||
for (const phaseDefinitions of backgroundPianoSamplePhases) {
|
||||
const pending = phaseDefinitions.filter(
|
||||
(definition) => !loadedSamplesByPath.has(definition.path)
|
||||
);
|
||||
if (pending.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await loadSamplesWithConcurrency(pending, (definition) =>
|
||||
loadPianoSample(decodeContext, definition)
|
||||
);
|
||||
publishLoadedSamples();
|
||||
}
|
||||
};
|
||||
export const getLoadedPianoSamples = (): Array<LoadedPianoSample> | null =>
|
||||
loadedPianoSamples ? [...loadedPianoSamples] : null;
|
||||
|
||||
const loadPianoSample = async (
|
||||
decodeContext: BaseAudioContext,
|
||||
sample: PianoSampleDefinition
|
||||
): Promise<void> => {
|
||||
const loadedSample = await withTimeout(
|
||||
(signal) => fetchAndDecodePianoSample(decodeContext, sample, signal),
|
||||
sampleLoadTuning.sampleTimeoutMs
|
||||
);
|
||||
loadedSamplesByPath.set(sample.path, loadedSample);
|
||||
};
|
||||
|
||||
const fetchAndDecodePianoSample = async (
|
||||
decodeContext: BaseAudioContext,
|
||||
sample: PianoSampleDefinition,
|
||||
signal: AbortSignal
|
||||
): Promise<LoadedPianoSample> => {
|
||||
const response = await fetch(sample.url, { signal });
|
||||
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 buffer = await decodeContext.decodeAudioData(audioData);
|
||||
if (sample.kind === 'strike') {
|
||||
return {
|
||||
buffer,
|
||||
midi: sample.midi,
|
||||
velocityLayer: sample.velocityLayer,
|
||||
};
|
||||
}
|
||||
return { buffer, midi: sample.midi };
|
||||
return { midi: getMidiForPianoSample(sample), buffer };
|
||||
};
|
||||
|
||||
/**
|
||||
* Runs the loads through a sliding window of workers so one slow sample only
|
||||
* occupies a single slot instead of stalling a whole batch.
|
||||
*/
|
||||
const loadSamplesWithConcurrency = async (
|
||||
const loadPianoSampleBatch = async (
|
||||
samples: Array<PianoSampleDefinition>,
|
||||
loadSample: (sample: PianoSampleDefinition) => Promise<void>
|
||||
): Promise<void> => {
|
||||
let nextIndex = 0;
|
||||
const workerCount = Math.min(sampleLoadTuning.concurrency, samples.length);
|
||||
loadSample: (sample: PianoSampleDefinition) => Promise<LoadedPianoSample>
|
||||
): Promise<Array<LoadedPianoSample>> => {
|
||||
const results: Array<LoadedPianoSample> = [];
|
||||
|
||||
await Promise.all(
|
||||
Array.from({ length: workerCount }, async () => {
|
||||
while (nextIndex < samples.length) {
|
||||
const sample = samples[nextIndex];
|
||||
nextIndex += 1;
|
||||
await loadSample(sample);
|
||||
}
|
||||
})
|
||||
);
|
||||
};
|
||||
for (let index = 0; index < samples.length; index += sampleLoadTuning.concurrency) {
|
||||
const batch = samples.slice(index, index + sampleLoadTuning.concurrency);
|
||||
const batchResults = await Promise.all(batch.map((sample) => loadSample(sample)));
|
||||
results.push(...batchResults);
|
||||
}
|
||||
|
||||
const publishLoadedSamples = (): void => {
|
||||
const samples = sortLoadedPianoSamples([...loadedSamplesByPath.values()]);
|
||||
publishedSamples = samples;
|
||||
sampleSubscribers.forEach((listener) => {
|
||||
listener(cloneLoadedPianoSamples(samples));
|
||||
});
|
||||
return results;
|
||||
};
|
||||
|
||||
const withTimeout = <T>(
|
||||
|
|
@ -297,73 +247,13 @@ const emitPianoSampleProgress = (progress: PianoSampleLoadProgress): void => {
|
|||
pianoSampleProgressListeners.forEach((listener) => listener(progress));
|
||||
};
|
||||
|
||||
function getBackgroundPianoSamplePhases(): Array<Array<PianoSampleDefinition>> {
|
||||
const strikes = pianoSampleDefinitions.filter(
|
||||
(definition) => definition.kind === 'strike'
|
||||
);
|
||||
const releases = pianoSampleDefinitions.filter(
|
||||
(definition) => definition.kind === 'release'
|
||||
);
|
||||
// Layers closest to the core layer first: they are blended in soonest by
|
||||
// getVelocityLayerPair; ties resolve to the louder layer.
|
||||
const remainingLayers = [...new Set(strikes.map((strike) => strike.velocityLayer))]
|
||||
.filter((layer) => layer !== coreVelocityLayer)
|
||||
.sort(
|
||||
(a, b) => Math.abs(a - coreVelocityLayer) - Math.abs(b - coreVelocityLayer) || b - a
|
||||
);
|
||||
const getPianoSamplePath = (sample: PianoSampleDefinition): string =>
|
||||
`./samples/${sample.note}v12.m4a`;
|
||||
|
||||
return [
|
||||
releases,
|
||||
...remainingLayers.map((layer) =>
|
||||
strikes.filter((strike) => strike.velocityLayer === layer)
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
function getPianoSampleDefinitions(
|
||||
modules: Record<string, string>
|
||||
): 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 filename = path.split('/').pop() ?? path;
|
||||
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);
|
||||
const getMidiForPianoSample = (sample: PianoSampleDefinition): number => {
|
||||
const match = /^(?<name>[A-G])(?<accidental>sharp)?(?<octave>\d+)$/.exec(sample.note);
|
||||
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> = {
|
||||
|
|
@ -378,25 +268,4 @@ function getMidiForPianoSampleNote(note: string): number {
|
|||
const octave = Number(match.groups.octave);
|
||||
const semitone = semitoneByName[match.groups.name] + (match.groups.accidental ? 1 : 0);
|
||||
return (octave + 1) * 12 + semitone;
|
||||
}
|
||||
|
||||
function getMidiForReleaseSample(releaseIndex: number): number {
|
||||
const pianoLowestMidi = 21;
|
||||
return pianoLowestMidi + releaseIndex - 1;
|
||||
}
|
||||
|
||||
const sortLoadedPianoSamples = (
|
||||
samples: Array<LoadedPianoSample>
|
||||
): 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.
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue