Compare commits
9 commits
asch/chang
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 1a195e89b8 | |||
| 271ab7443c | |||
| 8d3ccd6639 | |||
| ff924676c0 | |||
| 4bf67b47cb | |||
| 4e24df1511 | |||
| 0fddad6b45 | |||
| 79638d5fa4 | |||
| f863588060 |
260 changed files with 1451 additions and 974 deletions
5
assets/icons/close.svg
Normal file
5
assets/icons/close.svg
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
<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>
|
||||||
|
After Width: | Height: | Size: 179 B |
74
index.html
74
index.html
|
|
@ -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,viewport-fit=cover"
|
content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no,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="Tend it while you can. The garden returns to weather either way. A WebGPU drawing toy in your browser."
|
content="Draw colour into a canvas that keeps moving. Your strokes become paths for life that splits, drifts, and redraws the surface over time."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<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="Tend it while you can. The garden returns to weather either way. A WebGPU drawing toy in your browser."
|
content="Draw colour into a canvas that keeps moving. Your strokes become paths for life that splits, drifts, and redraws the surface over time."
|
||||||
/>
|
/>
|
||||||
<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="Tend it while you can. The garden returns to weather either way. A WebGPU drawing toy in your browser."
|
content="Draw colour into a canvas that keeps moving. Your strokes become paths for life that splits, drifts, and redraws the surface over time."
|
||||||
/>
|
/>
|
||||||
<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": "Tend it while you can. The garden returns to weather either way. A WebGPU drawing toy in your browser.",
|
"description": "Draw colour into a canvas that keeps moving. Your strokes become paths for life that splits, drifts, and redraws the surface over time.",
|
||||||
"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,7 +91,8 @@
|
||||||
<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">
|
||||||
Tend it while you can. The garden returns to weather either way.
|
Draw colour into a canvas that keeps moving. Your strokes become paths for
|
||||||
|
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>
|
||||||
|
|
@ -118,35 +119,54 @@
|
||||||
id="info-panel"
|
id="info-panel"
|
||||||
class="hidden info-page"
|
class="hidden info-page"
|
||||||
role="region"
|
role="region"
|
||||||
aria-label="About panel"
|
aria-labelledby="info-panel-title"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
tabindex="-1"
|
tabindex="-1"
|
||||||
inert
|
inert
|
||||||
>
|
>
|
||||||
<section>
|
<div class="info-page__content">
|
||||||
<h1>Fleeting Garden</h1>
|
<header class="info-page__header">
|
||||||
<p>
|
<span class="info-page__mark" aria-hidden="true"></span>
|
||||||
A garden is what we tend; the wild is what we get the moment we look away.
|
<div class="info-page__heading">
|
||||||
Both happen here at once. Your strokes plant colour, small agents follow them,
|
<p class="info-page__eyebrow">About</p>
|
||||||
branch off, and slowly rewrite the patch you laid down into something you
|
<h2 id="info-panel-title">Fleeting Garden</h2>
|
||||||
didn't quite plan.
|
</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.
|
||||||
</p>
|
</p>
|
||||||
<p>
|
|
||||||
Three swatches plant the line. The eraser carves a clearing. The mirror folds
|
<ul class="info-page__notes">
|
||||||
one gesture into many, like footpaths around a hidden well.
|
<li>Choose one of three colour swatches, then drag to draw.</li>
|
||||||
</p>
|
<li>Use the eraser to clear space and reshape the field.</li>
|
||||||
<p>
|
<li>The mirror repeats each gesture across the canvas.</li>
|
||||||
Switch vibes to change the season; your shapes stay, the light moves. Add or
|
<li>The arrows switch the current atmosphere.</li>
|
||||||
quiet the piano. Restart when you want a fresh field. Take a snapshot if you
|
</ul>
|
||||||
want to keep one particular instant of weather.
|
|
||||||
</p>
|
<p class="info-page__main">
|
||||||
<p>
|
My implementation of
|
||||||
Built with WebGPU, running locally in your browser. More of my work at
|
<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
|
||||||
<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>
|
||||||
</section>
|
</div>
|
||||||
</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: 301 KiB After Width: | Height: | Size: 164 KiB |
|
|
@ -4,9 +4,13 @@ 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 = {}) => {
|
||||||
|
|
@ -24,10 +28,10 @@ export const initAnalytics = () => {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
plausibleInit({
|
plausibleInit({
|
||||||
domain: appConfig.analytics.domain,
|
domain: ANALYTICS_DOMAIN,
|
||||||
endpoint: appConfig.analytics.endpoint,
|
endpoint: ANALYTICS_ENDPOINT,
|
||||||
autoCapturePageviews: appConfig.analytics.autoCapturePageviews,
|
autoCapturePageviews: ANALYTICS_AUTO_CAPTURE_PAGEVIEWS,
|
||||||
logging: appConfig.analytics.logging,
|
logging: ANALYTICS_LOGGING,
|
||||||
});
|
});
|
||||||
isInitialized = true;
|
isInitialized = true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import type { PianoNoteRole } from './garden-audio-types';
|
import type { PianoNoteRole } from './garden-audio-types';
|
||||||
|
|
||||||
export const DEFAULT_AUDIO_VOLUME = 0.5;
|
export const DEFAULT_AUDIO_VOLUME = 0.65;
|
||||||
|
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';
|
||||||
|
|
@ -58,17 +59,33 @@ export const createGardenAudioConfig = () => ({
|
||||||
timeRampSeconds: 0.12,
|
timeRampSeconds: 0.12,
|
||||||
},
|
},
|
||||||
piano: {
|
piano: {
|
||||||
maxVoices: 24,
|
maxVoices: 48,
|
||||||
gain: 0.48,
|
gain: 0.78,
|
||||||
sustainSeconds: 0.42,
|
sustainSeconds: 0.42,
|
||||||
sustainLevel: 0.26,
|
sustainLevel: 0.26,
|
||||||
releaseSeconds: 0.34,
|
releaseSeconds: 0.62,
|
||||||
lowpassHz: 7000,
|
lowpassHz: 9500,
|
||||||
gainAttackSeconds: 0.006,
|
gainAttackSeconds: 0.003,
|
||||||
lowpassMaxHz: 12000,
|
lowpassMaxHz: 16000,
|
||||||
lowpassMinHz: 1400,
|
lowpassMinHz: 900,
|
||||||
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,
|
||||||
|
|
|
||||||
|
|
@ -28,11 +28,11 @@ const graphTuning = {
|
||||||
latencyHint: 'interactive',
|
latencyHint: 'interactive',
|
||||||
outputFilterType: 'highpass',
|
outputFilterType: 'highpass',
|
||||||
compressor: {
|
compressor: {
|
||||||
thresholdDb: -18,
|
thresholdDb: -17,
|
||||||
kneeDb: 18,
|
kneeDb: 18,
|
||||||
ratio: 2.1,
|
ratio: 2.2,
|
||||||
attackSeconds: 0.018,
|
attackSeconds: 0.014,
|
||||||
releaseSeconds: 0.18,
|
releaseSeconds: 0.28,
|
||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
const delayFilterTuning = {
|
const delayFilterTuning = {
|
||||||
|
|
@ -45,6 +45,7 @@ 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;
|
||||||
|
|
||||||
|
|
@ -87,10 +88,12 @@ 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;
|
||||||
|
|
@ -100,15 +103,18 @@ 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;
|
||||||
|
|
||||||
masterGain.connect(highPass);
|
// Keep peak control independent from the user's volume slider.
|
||||||
|
outputBus.connect(highPass);
|
||||||
highPass.connect(compressor);
|
highPass.connect(compressor);
|
||||||
compressor.connect(context.destination);
|
compressor.connect(masterGain);
|
||||||
|
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, masterGain);
|
this.createDelay(context, outputBus);
|
||||||
this.createBuses(context, masterGain);
|
this.createRoom(context, outputBus);
|
||||||
|
this.createBuses(context, outputBus);
|
||||||
|
|
||||||
return context;
|
return context;
|
||||||
}
|
}
|
||||||
|
|
@ -224,7 +230,7 @@ export class GardenAudioGraph {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private createDelay(context: AudioContext, masterGain: GainNode): void {
|
private createDelay(context: AudioContext, outputBus: 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();
|
||||||
|
|
@ -250,7 +256,7 @@ export class GardenAudioGraph {
|
||||||
delayFeedback.connect(delayNode);
|
delayFeedback.connect(delayNode);
|
||||||
delayNode.connect(returnLowPass);
|
delayNode.connect(returnLowPass);
|
||||||
returnLowPass.connect(delayOutput);
|
returnLowPass.connect(delayOutput);
|
||||||
delayOutput.connect(masterGain);
|
delayOutput.connect(outputBus);
|
||||||
|
|
||||||
this.delayInput = delayInput;
|
this.delayInput = delayInput;
|
||||||
this.delayNode = delayNode;
|
this.delayNode = delayNode;
|
||||||
|
|
@ -258,10 +264,37 @@ export class GardenAudioGraph {
|
||||||
this.delayOutput = delayOutput;
|
this.delayOutput = delayOutput;
|
||||||
}
|
}
|
||||||
|
|
||||||
private createBuses(context: AudioContext, masterGain: GainNode): void {
|
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 {
|
||||||
const eventBus = context.createGain();
|
const eventBus = context.createGain();
|
||||||
eventBus.gain.value = graphTuning.eventBusGain;
|
eventBus.gain.value = graphTuning.eventBusGain;
|
||||||
eventBus.connect(masterGain);
|
eventBus.connect(outputBus);
|
||||||
this.eventBus = eventBus;
|
this.eventBus = eventBus;
|
||||||
this.pianoBuses.clear();
|
this.pianoBuses.clear();
|
||||||
|
|
||||||
|
|
@ -328,10 +361,34 @@ 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;
|
||||||
|
|
|
||||||
|
|
@ -14,11 +14,22 @@ export interface GardenAudioStroke {
|
||||||
elapsedSeconds: number;
|
elapsedSeconds: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LoadedPianoSample {
|
export interface LoadedPianoStrikeSample {
|
||||||
|
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;
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
import { ErrorHandler, Severity } from '../utils/error-handler';
|
import { ErrorHandler, Severity } from '../utils/error-handler';
|
||||||
import { clamp01 } from '../utils/math';
|
import { clamp } 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,
|
||||||
|
|
@ -49,7 +50,7 @@ export class GardenAudio {
|
||||||
private hasLoadedPiano = false;
|
private hasLoadedPiano = false;
|
||||||
|
|
||||||
public constructor(private readonly config: GardenAudioConfig) {
|
public constructor(private readonly config: GardenAudioConfig) {
|
||||||
this.masterVolume = clamp01(config.masterVolume);
|
this.masterVolume = clamp(config.masterVolume, 0, MAX_AUDIO_VOLUME);
|
||||||
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);
|
||||||
|
|
@ -228,7 +229,7 @@ export class GardenAudio {
|
||||||
}
|
}
|
||||||
|
|
||||||
public setMasterVolume(masterVolume: number): void {
|
public setMasterVolume(masterVolume: number): void {
|
||||||
this.masterVolume = clamp01(masterVolume);
|
this.masterVolume = clamp(masterVolume, 0, MAX_AUDIO_VOLUME);
|
||||||
if (!this.isMuted) {
|
if (!this.isMuted) {
|
||||||
this.graph.setMasterGain(this.masterVolume, this.config.updateRampSeconds);
|
this.graph.setMasterGain(this.masterVolume, this.config.updateRampSeconds);
|
||||||
}
|
}
|
||||||
|
|
@ -396,7 +397,7 @@ export class GardenAudio {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const distanceActivity = clamp01(activity);
|
const distanceActivity = clamp(activity, 0, 1);
|
||||||
if (distanceActivity <= 0) {
|
if (distanceActivity <= 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,32 +2,56 @@ 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 { LoadedPianoSample, PianoNote } from './garden-audio-types';
|
import type {
|
||||||
|
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;
|
||||||
source: AudioScheduledSourceNode;
|
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;
|
||||||
stopAt: number;
|
stopAt: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const pianoSamplerTuning = {
|
const pianoSamplerTuning = {
|
||||||
filterType: 'lowpass',
|
filterType: 'lowpass',
|
||||||
filterQ: 0.7,
|
filterQ: 0.45,
|
||||||
minDurationSeconds: 0.08,
|
minDurationSeconds: 0.08,
|
||||||
minFadeSeconds: 0.08,
|
minFadeSeconds: 0.08,
|
||||||
minGain: 0.0001,
|
minGain: 0.0001,
|
||||||
releaseTimeConstantCount: 5,
|
releaseSampleAttackSeconds: 0.006,
|
||||||
tailStopExtraSeconds: 0.05,
|
releaseSampleDecaySeconds: 0.18,
|
||||||
voiceStealFadeSeconds: 0.025,
|
releaseTimeConstantCount: 6,
|
||||||
voiceStealStopSeconds: 0.05,
|
tailStopExtraSeconds: 0.08,
|
||||||
|
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,
|
||||||
|
|
@ -35,7 +59,7 @@ export class PianoSampler {
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public load(context: BaseAudioContext): Promise<void> {
|
public load(context: BaseAudioContext): Promise<void> {
|
||||||
if (this.samples.length > 0) {
|
if (this.strikeSamples.length > 0) {
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -67,8 +91,9 @@ export class PianoSampler {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const sample = this.findNearestSample(midi);
|
const noteVelocity = clamp01(velocity);
|
||||||
if (!sample) {
|
const selectedSamples = this.selectStrikeSamples(midi, noteVelocity);
|
||||||
|
if (selectedSamples.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -76,7 +101,6 @@ 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 *
|
||||||
|
|
@ -88,23 +112,208 @@ export class PianoSampler {
|
||||||
const stopAt =
|
const stopAt =
|
||||||
releaseAt +
|
releaseAt +
|
||||||
this.config.piano.releaseSeconds * pianoSamplerTuning.releaseTimeConstantCount;
|
this.config.piano.releaseSeconds * pianoSamplerTuning.releaseTimeConstantCount;
|
||||||
const source = context.createBufferSource();
|
const strikeSources = selectedSamples.map(({ gainScale, sample }) => ({
|
||||||
|
gainScale,
|
||||||
source.buffer = sample.buffer;
|
source: this.createSource(
|
||||||
source.playbackRate.setValueAtTime(
|
context,
|
||||||
Math.pow(2, (midi - sample.midi) / PITCH_SEMITONES_PER_OCTAVE),
|
sample.buffer,
|
||||||
|
midi,
|
||||||
|
sample.midi,
|
||||||
scheduledStart
|
scheduledStart
|
||||||
);
|
),
|
||||||
|
}));
|
||||||
|
const releaseSample = this.createReleaseSample({
|
||||||
|
context,
|
||||||
|
midi,
|
||||||
|
noteVelocity,
|
||||||
|
releaseAt,
|
||||||
|
});
|
||||||
|
|
||||||
this.scheduleVoice({
|
this.scheduleVoice({
|
||||||
source,
|
delaySend,
|
||||||
|
eventBus,
|
||||||
|
lowpassHz,
|
||||||
|
noteGainValue,
|
||||||
|
pan,
|
||||||
|
releaseAt,
|
||||||
|
releaseSample,
|
||||||
scheduledStart,
|
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,
|
stopAt,
|
||||||
pan,
|
pan,
|
||||||
lowpassHz,
|
lowpassHz,
|
||||||
delaySend,
|
delaySend,
|
||||||
eventBus,
|
eventBus,
|
||||||
configureGainEnvelope: (gain) => {
|
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
|
||||||
|
);
|
||||||
|
filter.Q.value = pianoSamplerTuning.filterQ;
|
||||||
|
panner.pan.setValueAtTime(clamp(pan, -1, 1), scheduledStart);
|
||||||
|
this.configureGainEnvelope({
|
||||||
|
gain,
|
||||||
|
noteGainValue,
|
||||||
|
releaseAt,
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
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.setValueAtTime(pianoSamplerTuning.minGain, scheduledStart);
|
||||||
gain.gain.exponentialRampToValueAtTime(
|
gain.gain.exponentialRampToValueAtTime(
|
||||||
noteGainValue,
|
noteGainValue,
|
||||||
|
|
@ -126,117 +335,240 @@ export class PianoSampler {
|
||||||
releaseAt,
|
releaseAt,
|
||||||
this.config.piano.releaseSeconds
|
this.config.piano.releaseSeconds
|
||||||
);
|
);
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public stopAll(): void {
|
private configureReleaseEnvelope(
|
||||||
const context = this.graph.context;
|
releaseGain: GainNode,
|
||||||
if (!context) {
|
releaseSample: ScheduledReleaseSample
|
||||||
this.activeVoices = [];
|
): void {
|
||||||
return;
|
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
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const now = context.currentTime;
|
private createSource(
|
||||||
|
context: BaseAudioContext,
|
||||||
this.activeVoices.forEach((voice) => {
|
buffer: AudioBuffer,
|
||||||
this.stopVoice(voice, now);
|
midi: number,
|
||||||
});
|
sampleMidi: number,
|
||||||
this.activeVoices = [];
|
scheduledStart: number
|
||||||
}
|
): AudioBufferSourceNode {
|
||||||
|
const source = context.createBufferSource();
|
||||||
public reset(): void {
|
source.buffer = buffer;
|
||||||
this.samples = [];
|
source.playbackRate.setValueAtTime(
|
||||||
this.activeVoices = [];
|
Math.pow(2, (midi - sampleMidi) / PITCH_SEMITONES_PER_OCTAVE),
|
||||||
}
|
|
||||||
|
|
||||||
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
|
scheduledStart
|
||||||
);
|
);
|
||||||
filter.Q.value = pianoSamplerTuning.filterQ;
|
return source;
|
||||||
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);
|
private createReleaseSample({
|
||||||
source.stop(stopAt + pianoSamplerTuning.tailStopExtraSeconds);
|
context,
|
||||||
this.activeVoices.push({ gain, source, stopAt });
|
midi,
|
||||||
|
noteVelocity,
|
||||||
|
releaseAt,
|
||||||
|
}: {
|
||||||
|
context: BaseAudioContext;
|
||||||
|
midi: number;
|
||||||
|
noteVelocity: number;
|
||||||
|
releaseAt: number;
|
||||||
|
}): ScheduledReleaseSample | null {
|
||||||
|
const sample = this.findNearestReleaseSample(midi);
|
||||||
|
if (!sample) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
source.addEventListener(
|
const source = this.createSource(
|
||||||
'ended',
|
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.disconnect();
|
source.disconnect();
|
||||||
|
});
|
||||||
|
sourceGains.forEach((sourceGain) => {
|
||||||
|
sourceGain.disconnect();
|
||||||
|
});
|
||||||
filter.disconnect();
|
filter.disconnect();
|
||||||
gain.disconnect();
|
gain.disconnect();
|
||||||
|
releaseGain?.disconnect();
|
||||||
panner.disconnect();
|
panner.disconnect();
|
||||||
sendGain?.disconnect();
|
delaySendGain?.disconnect();
|
||||||
this.activeVoices = this.activeVoices.filter((voice) => voice.gain !== gain);
|
roomSendGain?.disconnect();
|
||||||
},
|
this.activeVoices = this.activeVoices.filter(
|
||||||
{ once: true }
|
(activeVoice) => activeVoice !== voice
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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 findNearestSample(midi: number): LoadedPianoSample | null {
|
private selectStrikeSamples(
|
||||||
if (this.samples.length === 0) {
|
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) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.samples.reduce((nearest, sample) =>
|
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) =>
|
||||||
Math.abs(sample.midi - midi) < Math.abs(nearest.midi - midi) ? sample : nearest
|
Math.abs(sample.midi - midi) < Math.abs(nearest.midi - midi) ? sample : nearest
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -245,6 +577,33 @@ 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;
|
||||||
|
|
||||||
|
|
@ -254,11 +613,23 @@ 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: Array<LoadedPianoSample>): void {
|
private setSamples(samples: LoadedPianoSamples): void {
|
||||||
this.samples = samples.slice().sort((a, b) => a.midi - b.midi);
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,40 +1,26 @@
|
||||||
import type { LoadedPianoSample } from './garden-audio-types';
|
import type {
|
||||||
import a0SampleUrl from './samples/A0v12.m4a?url&no-inline';
|
LoadedPianoReleaseSample,
|
||||||
import a1SampleUrl from './samples/A1v12.m4a?url&no-inline';
|
LoadedPianoSamples,
|
||||||
import a2SampleUrl from './samples/A2v12.m4a?url&no-inline';
|
LoadedPianoStrikeSample,
|
||||||
import a3SampleUrl from './samples/A3v12.m4a?url&no-inline';
|
} from './garden-audio-types';
|
||||||
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 PianoSampleDefinition {
|
interface PianoStrikeSampleDefinition {
|
||||||
note: string;
|
kind: 'strike';
|
||||||
|
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;
|
||||||
|
|
@ -42,54 +28,28 @@ export interface PianoSampleLoadProgress {
|
||||||
totalCount: number;
|
totalCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const pianoSampleDefinitions: Array<PianoSampleDefinition> = [
|
const pianoSampleModules = import.meta.glob('./samples/*.m4a', {
|
||||||
{ url: a0SampleUrl, note: 'A0' },
|
eager: true,
|
||||||
{ url: c1SampleUrl, note: 'C1' },
|
import: 'default',
|
||||||
{ url: dSharp1SampleUrl, note: 'Dsharp1' },
|
query: '?url&no-inline',
|
||||||
{ url: fSharp1SampleUrl, note: 'Fsharp1' },
|
}) as Record<string, string>;
|
||||||
{ url: a1SampleUrl, note: 'A1' },
|
const pianoSampleDefinitions = getPianoSampleDefinitions(pianoSampleModules);
|
||||||
{ 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: Array<LoadedPianoSample> | null = null;
|
let loadedPianoSamples: LoadedPianoSamples | null = null;
|
||||||
let pianoSampleLoadPromise: Promise<Array<LoadedPianoSample>> | null = null;
|
let pianoSampleLoadPromise: Promise<LoadedPianoSamples> | 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: 4,
|
concurrency: 6,
|
||||||
sampleTimeoutMs: 15_000,
|
sampleTimeoutMs: 15_000,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const preloadPianoSamples = (
|
export const preloadPianoSamples = (
|
||||||
onProgress?: (progress: PianoSampleLoadProgress) => void
|
onProgress?: (progress: PianoSampleLoadProgress) => void
|
||||||
): Promise<Array<LoadedPianoSample>> => {
|
): Promise<LoadedPianoSamples> => {
|
||||||
const OfflineAudioContextConstructor = globalThis.OfflineAudioContext;
|
const OfflineAudioContextConstructor = globalThis.OfflineAudioContext;
|
||||||
|
|
||||||
if (!OfflineAudioContextConstructor) {
|
if (!OfflineAudioContextConstructor) {
|
||||||
|
|
@ -106,18 +66,19 @@ export const preloadPianoSamples = (
|
||||||
export const loadPianoSamples = (
|
export const loadPianoSamples = (
|
||||||
decodeContext: BaseAudioContext,
|
decodeContext: BaseAudioContext,
|
||||||
onProgress?: (progress: PianoSampleLoadProgress) => void
|
onProgress?: (progress: PianoSampleLoadProgress) => void
|
||||||
): Promise<Array<LoadedPianoSample>> => {
|
): Promise<LoadedPianoSamples> => {
|
||||||
const unsubscribeProgress = subscribeToPianoSampleProgress(onProgress);
|
const unsubscribeProgress = subscribeToPianoSampleProgress(onProgress);
|
||||||
|
|
||||||
if (loadedPianoSamples) {
|
if (loadedPianoSamples) {
|
||||||
emitPianoSampleProgress({
|
emitPianoSampleProgress({
|
||||||
failedCount: 0,
|
failedCount: 0,
|
||||||
loadedCount: loadedPianoSamples.length,
|
loadedCount: loadedPianoSamples.strikes.length + loadedPianoSamples.releases.length,
|
||||||
settledCount: loadedPianoSamples.length,
|
settledCount:
|
||||||
|
loadedPianoSamples.strikes.length + loadedPianoSamples.releases.length,
|
||||||
totalCount: pianoSampleDefinitions.length,
|
totalCount: pianoSampleDefinitions.length,
|
||||||
});
|
});
|
||||||
unsubscribeProgress();
|
unsubscribeProgress();
|
||||||
return Promise.resolve([...loadedPianoSamples]);
|
return Promise.resolve(cloneLoadedPianoSamples(loadedPianoSamples));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pianoSampleLoadPromise) {
|
if (pianoSampleLoadPromise) {
|
||||||
|
|
@ -151,13 +112,15 @@ export const loadPianoSamples = (
|
||||||
)
|
)
|
||||||
.then(
|
.then(
|
||||||
(samples) => {
|
(samples) => {
|
||||||
loadedPianoSamples = samples.sort((a, b) => a.midi - b.midi);
|
loadedPianoSamples = sortLoadedPianoSamples(samples);
|
||||||
if (loadedPianoSamples.length !== pianoSampleDefinitions.length) {
|
const loadedCount =
|
||||||
|
loadedPianoSamples.strikes.length + loadedPianoSamples.releases.length;
|
||||||
|
if (loadedCount !== pianoSampleDefinitions.length) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Loaded ${loadedPianoSamples.length}/${pianoSampleDefinitions.length} piano samples.`
|
`Loaded ${loadedCount}/${pianoSampleDefinitions.length} piano samples.`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return [...loadedPianoSamples];
|
return cloneLoadedPianoSamples(loadedPianoSamples);
|
||||||
},
|
},
|
||||||
(error: unknown) => {
|
(error: unknown) => {
|
||||||
pianoSampleLoadPromise = null;
|
pianoSampleLoadPromise = null;
|
||||||
|
|
@ -170,29 +133,38 @@ export const loadPianoSamples = (
|
||||||
return pianoSampleLoadPromise;
|
return pianoSampleLoadPromise;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getLoadedPianoSamples = (): Array<LoadedPianoSample> | null =>
|
export const getLoadedPianoSamples = (): LoadedPianoSamples | null =>
|
||||||
loadedPianoSamples ? [...loadedPianoSamples] : null;
|
loadedPianoSamples ? cloneLoadedPianoSamples(loadedPianoSamples) : null;
|
||||||
|
|
||||||
const loadPianoSample = async (
|
const loadPianoSample = async (
|
||||||
decodeContext: BaseAudioContext,
|
decodeContext: BaseAudioContext,
|
||||||
sample: PianoSampleDefinition,
|
sample: PianoSampleDefinition,
|
||||||
signal: AbortSignal
|
signal: AbortSignal
|
||||||
): Promise<LoadedPianoSample> => {
|
): Promise<LoadedPianoStrikeSample | LoadedPianoReleaseSample> => {
|
||||||
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 ${getPianoSamplePath(sample)}`);
|
throw new Error(`Unable to load piano sample ${sample.path}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const audioData = await response.arrayBuffer();
|
const audioData = await response.arrayBuffer();
|
||||||
const buffer = await decodeContext.decodeAudioData(audioData);
|
const buffer = await decodeContext.decodeAudioData(audioData);
|
||||||
return { midi: getMidiForPianoSample(sample), buffer };
|
if (sample.kind === 'strike') {
|
||||||
|
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: (sample: PianoSampleDefinition) => Promise<LoadedPianoSample>
|
loadSample: (
|
||||||
): Promise<Array<LoadedPianoSample>> => {
|
sample: PianoSampleDefinition
|
||||||
const results: Array<LoadedPianoSample> = [];
|
) => Promise<LoadedPianoStrikeSample | LoadedPianoReleaseSample>
|
||||||
|
): 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);
|
||||||
|
|
@ -247,13 +219,50 @@ const emitPianoSampleProgress = (progress: PianoSampleLoadProgress): void => {
|
||||||
pianoSampleProgressListeners.forEach((listener) => listener(progress));
|
pianoSampleProgressListeners.forEach((listener) => listener(progress));
|
||||||
};
|
};
|
||||||
|
|
||||||
const getPianoSamplePath = (sample: PianoSampleDefinition): string =>
|
function getPianoSampleDefinitions(
|
||||||
`./samples/${sample.note}v12.m4a`;
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
const getMidiForPianoSample = (sample: PianoSampleDefinition): number => {
|
function getPianoSampleDefinition(path: string, url: string): PianoSampleDefinition {
|
||||||
const match = /^(?<name>[A-G])(?<accidental>sharp)?(?<octave>\d+)$/.exec(sample.note);
|
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);
|
||||||
if (!match?.groups) {
|
if (!match?.groups) {
|
||||||
throw new Error(`Invalid piano sample note ${sample.note}`);
|
throw new Error(`Invalid piano sample note ${note}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const semitoneByName: Record<string, number> = {
|
const semitoneByName: Record<string, number> = {
|
||||||
|
|
@ -268,4 +277,25 @@ const getMidiForPianoSample = (sample: PianoSampleDefinition): 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.
BIN
src/audio/samples/A0v16.m4a
Normal file
BIN
src/audio/samples/A0v16.m4a
Normal file
Binary file not shown.
BIN
src/audio/samples/A0v4.m4a
Normal file
BIN
src/audio/samples/A0v4.m4a
Normal file
Binary file not shown.
BIN
src/audio/samples/A0v8.m4a
Normal file
BIN
src/audio/samples/A0v8.m4a
Normal file
Binary file not shown.
Binary file not shown.
BIN
src/audio/samples/A1v16.m4a
Normal file
BIN
src/audio/samples/A1v16.m4a
Normal file
Binary file not shown.
BIN
src/audio/samples/A1v4.m4a
Normal file
BIN
src/audio/samples/A1v4.m4a
Normal file
Binary file not shown.
BIN
src/audio/samples/A1v8.m4a
Normal file
BIN
src/audio/samples/A1v8.m4a
Normal file
Binary file not shown.
Binary file not shown.
BIN
src/audio/samples/A2v16.m4a
Normal file
BIN
src/audio/samples/A2v16.m4a
Normal file
Binary file not shown.
BIN
src/audio/samples/A2v4.m4a
Normal file
BIN
src/audio/samples/A2v4.m4a
Normal file
Binary file not shown.
BIN
src/audio/samples/A2v8.m4a
Normal file
BIN
src/audio/samples/A2v8.m4a
Normal file
Binary file not shown.
Binary file not shown.
BIN
src/audio/samples/A3v16.m4a
Normal file
BIN
src/audio/samples/A3v16.m4a
Normal file
Binary file not shown.
BIN
src/audio/samples/A3v4.m4a
Normal file
BIN
src/audio/samples/A3v4.m4a
Normal file
Binary file not shown.
BIN
src/audio/samples/A3v8.m4a
Normal file
BIN
src/audio/samples/A3v8.m4a
Normal file
Binary file not shown.
Binary file not shown.
BIN
src/audio/samples/A4v16.m4a
Normal file
BIN
src/audio/samples/A4v16.m4a
Normal file
Binary file not shown.
BIN
src/audio/samples/A4v4.m4a
Normal file
BIN
src/audio/samples/A4v4.m4a
Normal file
Binary file not shown.
BIN
src/audio/samples/A4v8.m4a
Normal file
BIN
src/audio/samples/A4v8.m4a
Normal file
Binary file not shown.
Binary file not shown.
BIN
src/audio/samples/A5v16.m4a
Normal file
BIN
src/audio/samples/A5v16.m4a
Normal file
Binary file not shown.
BIN
src/audio/samples/A5v4.m4a
Normal file
BIN
src/audio/samples/A5v4.m4a
Normal file
Binary file not shown.
BIN
src/audio/samples/A5v8.m4a
Normal file
BIN
src/audio/samples/A5v8.m4a
Normal file
Binary file not shown.
Binary file not shown.
BIN
src/audio/samples/A6v16.m4a
Normal file
BIN
src/audio/samples/A6v16.m4a
Normal file
Binary file not shown.
BIN
src/audio/samples/A6v4.m4a
Normal file
BIN
src/audio/samples/A6v4.m4a
Normal file
Binary file not shown.
BIN
src/audio/samples/A6v8.m4a
Normal file
BIN
src/audio/samples/A6v8.m4a
Normal file
Binary file not shown.
Binary file not shown.
BIN
src/audio/samples/A7v16.m4a
Normal file
BIN
src/audio/samples/A7v16.m4a
Normal file
Binary file not shown.
BIN
src/audio/samples/A7v4.m4a
Normal file
BIN
src/audio/samples/A7v4.m4a
Normal file
Binary file not shown.
BIN
src/audio/samples/A7v8.m4a
Normal file
BIN
src/audio/samples/A7v8.m4a
Normal file
Binary file not shown.
Binary file not shown.
BIN
src/audio/samples/C1v16.m4a
Normal file
BIN
src/audio/samples/C1v16.m4a
Normal file
Binary file not shown.
BIN
src/audio/samples/C1v4.m4a
Normal file
BIN
src/audio/samples/C1v4.m4a
Normal file
Binary file not shown.
BIN
src/audio/samples/C1v8.m4a
Normal file
BIN
src/audio/samples/C1v8.m4a
Normal file
Binary file not shown.
Binary file not shown.
BIN
src/audio/samples/C2v16.m4a
Normal file
BIN
src/audio/samples/C2v16.m4a
Normal file
Binary file not shown.
BIN
src/audio/samples/C2v4.m4a
Normal file
BIN
src/audio/samples/C2v4.m4a
Normal file
Binary file not shown.
BIN
src/audio/samples/C2v8.m4a
Normal file
BIN
src/audio/samples/C2v8.m4a
Normal file
Binary file not shown.
Binary file not shown.
BIN
src/audio/samples/C3v16.m4a
Normal file
BIN
src/audio/samples/C3v16.m4a
Normal file
Binary file not shown.
BIN
src/audio/samples/C3v4.m4a
Normal file
BIN
src/audio/samples/C3v4.m4a
Normal file
Binary file not shown.
BIN
src/audio/samples/C3v8.m4a
Normal file
BIN
src/audio/samples/C3v8.m4a
Normal file
Binary file not shown.
Binary file not shown.
BIN
src/audio/samples/C4v16.m4a
Normal file
BIN
src/audio/samples/C4v16.m4a
Normal file
Binary file not shown.
BIN
src/audio/samples/C4v4.m4a
Normal file
BIN
src/audio/samples/C4v4.m4a
Normal file
Binary file not shown.
BIN
src/audio/samples/C4v8.m4a
Normal file
BIN
src/audio/samples/C4v8.m4a
Normal file
Binary file not shown.
Binary file not shown.
BIN
src/audio/samples/C5v16.m4a
Normal file
BIN
src/audio/samples/C5v16.m4a
Normal file
Binary file not shown.
BIN
src/audio/samples/C5v4.m4a
Normal file
BIN
src/audio/samples/C5v4.m4a
Normal file
Binary file not shown.
BIN
src/audio/samples/C5v8.m4a
Normal file
BIN
src/audio/samples/C5v8.m4a
Normal file
Binary file not shown.
Binary file not shown.
BIN
src/audio/samples/C6v16.m4a
Normal file
BIN
src/audio/samples/C6v16.m4a
Normal file
Binary file not shown.
BIN
src/audio/samples/C6v4.m4a
Normal file
BIN
src/audio/samples/C6v4.m4a
Normal file
Binary file not shown.
BIN
src/audio/samples/C6v8.m4a
Normal file
BIN
src/audio/samples/C6v8.m4a
Normal file
Binary file not shown.
Binary file not shown.
BIN
src/audio/samples/C7v16.m4a
Normal file
BIN
src/audio/samples/C7v16.m4a
Normal file
Binary file not shown.
BIN
src/audio/samples/C7v4.m4a
Normal file
BIN
src/audio/samples/C7v4.m4a
Normal file
Binary file not shown.
BIN
src/audio/samples/C7v8.m4a
Normal file
BIN
src/audio/samples/C7v8.m4a
Normal file
Binary file not shown.
Binary file not shown.
BIN
src/audio/samples/C8v16.m4a
Normal file
BIN
src/audio/samples/C8v16.m4a
Normal file
Binary file not shown.
BIN
src/audio/samples/C8v4.m4a
Normal file
BIN
src/audio/samples/C8v4.m4a
Normal file
Binary file not shown.
BIN
src/audio/samples/C8v8.m4a
Normal file
BIN
src/audio/samples/C8v8.m4a
Normal file
Binary file not shown.
Binary file not shown.
BIN
src/audio/samples/Dsharp1v16.m4a
Normal file
BIN
src/audio/samples/Dsharp1v16.m4a
Normal file
Binary file not shown.
BIN
src/audio/samples/Dsharp1v4.m4a
Normal file
BIN
src/audio/samples/Dsharp1v4.m4a
Normal file
Binary file not shown.
BIN
src/audio/samples/Dsharp1v8.m4a
Normal file
BIN
src/audio/samples/Dsharp1v8.m4a
Normal file
Binary file not shown.
Binary file not shown.
BIN
src/audio/samples/Dsharp2v16.m4a
Normal file
BIN
src/audio/samples/Dsharp2v16.m4a
Normal file
Binary file not shown.
BIN
src/audio/samples/Dsharp2v4.m4a
Normal file
BIN
src/audio/samples/Dsharp2v4.m4a
Normal file
Binary file not shown.
BIN
src/audio/samples/Dsharp2v8.m4a
Normal file
BIN
src/audio/samples/Dsharp2v8.m4a
Normal file
Binary file not shown.
Binary file not shown.
BIN
src/audio/samples/Dsharp3v16.m4a
Normal file
BIN
src/audio/samples/Dsharp3v16.m4a
Normal file
Binary file not shown.
BIN
src/audio/samples/Dsharp3v4.m4a
Normal file
BIN
src/audio/samples/Dsharp3v4.m4a
Normal file
Binary file not shown.
BIN
src/audio/samples/Dsharp3v8.m4a
Normal file
BIN
src/audio/samples/Dsharp3v8.m4a
Normal file
Binary file not shown.
Binary file not shown.
BIN
src/audio/samples/Dsharp4v16.m4a
Normal file
BIN
src/audio/samples/Dsharp4v16.m4a
Normal file
Binary file not shown.
BIN
src/audio/samples/Dsharp4v4.m4a
Normal file
BIN
src/audio/samples/Dsharp4v4.m4a
Normal file
Binary file not shown.
BIN
src/audio/samples/Dsharp4v8.m4a
Normal file
BIN
src/audio/samples/Dsharp4v8.m4a
Normal file
Binary file not shown.
Binary file not shown.
BIN
src/audio/samples/Dsharp5v16.m4a
Normal file
BIN
src/audio/samples/Dsharp5v16.m4a
Normal file
Binary file not shown.
BIN
src/audio/samples/Dsharp5v4.m4a
Normal file
BIN
src/audio/samples/Dsharp5v4.m4a
Normal file
Binary file not shown.
BIN
src/audio/samples/Dsharp5v8.m4a
Normal file
BIN
src/audio/samples/Dsharp5v8.m4a
Normal file
Binary file not shown.
Binary file not shown.
BIN
src/audio/samples/Dsharp6v16.m4a
Normal file
BIN
src/audio/samples/Dsharp6v16.m4a
Normal file
Binary file not shown.
BIN
src/audio/samples/Dsharp6v4.m4a
Normal file
BIN
src/audio/samples/Dsharp6v4.m4a
Normal file
Binary file not shown.
BIN
src/audio/samples/Dsharp6v8.m4a
Normal file
BIN
src/audio/samples/Dsharp6v8.m4a
Normal file
Binary file not shown.
Binary file not shown.
BIN
src/audio/samples/Dsharp7v16.m4a
Normal file
BIN
src/audio/samples/Dsharp7v16.m4a
Normal file
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