Compare commits
4 commits
0fddad6b45
...
8d3ccd6639
| Author | SHA1 | Date | |
|---|---|---|---|
| 8d3ccd6639 | |||
| ff924676c0 | |||
| 4bf67b47cb | |||
| 4e24df1511 |
47 changed files with 890 additions and 344 deletions
33
index.html
33
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="Plant colour, fold gestures with mirrors, and watch small agents turn each brushstroke into a shifting WebGPU garden."
|
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="Plant colour, fold gestures with mirrors, and watch small agents turn each brushstroke into a shifting WebGPU garden."
|
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="Plant colour, fold gestures with mirrors, and watch small agents turn each brushstroke into a shifting WebGPU garden."
|
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": "Plant colour, fold gestures with mirrors, and watch small agents turn each brushstroke into a shifting WebGPU garden.",
|
"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,7 @@
|
||||||
<div class="splash" data-visible="true">
|
<div class="splash" data-visible="true">
|
||||||
<h1 class="splash-title">Fleeting Garden</h1>
|
<h1 class="splash-title">Fleeting Garden</h1>
|
||||||
<p class="splash-description">
|
<p class="splash-description">
|
||||||
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>
|
||||||
|
|
@ -139,24 +139,19 @@
|
||||||
></button>
|
></button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<p class="info-page__lede">
|
<p class="info-page__main">
|
||||||
A garden is what we tend; the wild is what we get the moment we look away.
|
Draw into a field of particles and watch the simulation fold your marks back into motion.
|
||||||
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>
|
</p>
|
||||||
|
|
||||||
<ul class="info-page__notes">
|
<ul class="info-page__notes">
|
||||||
<li>Three swatches plant the line; the eraser carves a clearing.</li>
|
<li>Choose one of three colour swatches, then drag to draw.</li>
|
||||||
<li>The mirror folds one gesture into many.</li>
|
<li>Use the eraser to clear space and reshape the field.</li>
|
||||||
<li>The arrows change the season.</li>
|
<li>The mirror repeats each gesture across the canvas.</li>
|
||||||
|
<li>The arrows switch the current atmosphere.</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<p class="info-page__meta">
|
<p class="info-page__main">
|
||||||
Built with WebGPU, running locally in your browser. More of my work at
|
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 <a href="https://schmelczer.dev" target="_blank" rel="noopener">schmelczer.dev</a>
|
||||||
<a href="https://schmelczer.dev" target="_blank" rel="noopener"
|
|
||||||
>schmelczer.dev</a
|
|
||||||
>.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
|
||||||
|
|
@ -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,45 +112,36 @@ 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,
|
||||||
scheduledStart
|
midi,
|
||||||
);
|
sample.midi,
|
||||||
|
scheduledStart
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
const releaseSample = this.createReleaseSample({
|
||||||
|
context,
|
||||||
|
midi,
|
||||||
|
noteVelocity,
|
||||||
|
releaseAt,
|
||||||
|
});
|
||||||
|
|
||||||
this.scheduleVoice({
|
this.scheduleVoice({
|
||||||
source,
|
|
||||||
scheduledStart,
|
|
||||||
stopAt,
|
|
||||||
pan,
|
|
||||||
lowpassHz,
|
|
||||||
delaySend,
|
delaySend,
|
||||||
eventBus,
|
eventBus,
|
||||||
configureGainEnvelope: (gain) => {
|
lowpassHz,
|
||||||
gain.gain.setValueAtTime(pianoSamplerTuning.minGain, scheduledStart);
|
noteGainValue,
|
||||||
gain.gain.exponentialRampToValueAtTime(
|
pan,
|
||||||
noteGainValue,
|
releaseAt,
|
||||||
scheduledStart + this.config.piano.gainAttackSeconds
|
releaseSample,
|
||||||
);
|
scheduledStart,
|
||||||
gain.gain.setTargetAtTime(
|
stopAt: releaseSample ? Math.max(stopAt, releaseSample.stopAt) : stopAt,
|
||||||
Math.max(
|
strikeSources,
|
||||||
pianoSamplerTuning.minGain,
|
sustainAt,
|
||||||
noteGainValue * this.config.piano.sustainLevel
|
sustainSeconds,
|
||||||
),
|
|
||||||
sustainAt,
|
|
||||||
Math.max(
|
|
||||||
pianoSamplerTuning.minFadeSeconds,
|
|
||||||
sustainSeconds * this.config.piano.sustainBase
|
|
||||||
)
|
|
||||||
);
|
|
||||||
gain.gain.setTargetAtTime(
|
|
||||||
pianoSamplerTuning.minGain,
|
|
||||||
releaseAt,
|
|
||||||
this.config.piano.releaseSeconds
|
|
||||||
);
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -146,30 +161,40 @@ export class PianoSampler {
|
||||||
}
|
}
|
||||||
|
|
||||||
public reset(): void {
|
public reset(): void {
|
||||||
this.samples = [];
|
this.releaseSamples = [];
|
||||||
|
this.strikeSamples = [];
|
||||||
|
this.velocityLayers = [];
|
||||||
this.activeVoices = [];
|
this.activeVoices = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
private scheduleVoice({
|
private scheduleVoice({
|
||||||
source,
|
strikeSources,
|
||||||
|
releaseSample,
|
||||||
scheduledStart,
|
scheduledStart,
|
||||||
|
sustainAt,
|
||||||
|
sustainSeconds,
|
||||||
|
releaseAt,
|
||||||
stopAt,
|
stopAt,
|
||||||
pan,
|
pan,
|
||||||
lowpassHz,
|
lowpassHz,
|
||||||
delaySend,
|
delaySend,
|
||||||
eventBus,
|
eventBus,
|
||||||
configureGainEnvelope,
|
noteGainValue,
|
||||||
}: {
|
}: {
|
||||||
source: AudioScheduledSourceNode;
|
|
||||||
scheduledStart: number;
|
|
||||||
stopAt: number;
|
|
||||||
pan: number;
|
|
||||||
lowpassHz: number;
|
|
||||||
delaySend: number;
|
delaySend: number;
|
||||||
eventBus: GainNode;
|
eventBus: GainNode;
|
||||||
configureGainEnvelope: (gain: GainNode) => void;
|
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 {
|
}): void {
|
||||||
const { context, delayInput } = this.graph;
|
const { context, delayInput, roomInput } = this.graph;
|
||||||
if (!context) {
|
if (!context) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -177,15 +202,18 @@ export class PianoSampler {
|
||||||
const filter = context.createBiquadFilter();
|
const filter = context.createBiquadFilter();
|
||||||
const gain = context.createGain();
|
const gain = context.createGain();
|
||||||
const panner = context.createStereoPanner();
|
const panner = context.createStereoPanner();
|
||||||
let sendGain: GainNode | null = null;
|
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);
|
this.trimActiveVoices(scheduledStart);
|
||||||
while (this.activeVoices.length >= this.config.piano.maxVoices) {
|
while (this.activeVoices.length >= this.config.piano.maxVoices) {
|
||||||
const oldest = this.activeVoices.shift();
|
this.stealQuietestVoice(scheduledStart);
|
||||||
if (!oldest) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
this.stopVoice(oldest, scheduledStart);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
filter.type = pianoSamplerTuning.filterType;
|
filter.type = pianoSamplerTuning.filterType;
|
||||||
|
|
@ -195,48 +223,352 @@ export class PianoSampler {
|
||||||
);
|
);
|
||||||
filter.Q.value = pianoSamplerTuning.filterQ;
|
filter.Q.value = pianoSamplerTuning.filterQ;
|
||||||
panner.pan.setValueAtTime(clamp(pan, -1, 1), scheduledStart);
|
panner.pan.setValueAtTime(clamp(pan, -1, 1), scheduledStart);
|
||||||
configureGainEnvelope(gain);
|
this.configureGainEnvelope({
|
||||||
|
gain,
|
||||||
|
noteGainValue,
|
||||||
|
releaseAt,
|
||||||
|
scheduledStart,
|
||||||
|
sustainAt,
|
||||||
|
sustainSeconds,
|
||||||
|
});
|
||||||
|
|
||||||
source.connect(filter);
|
strikeSources.forEach(({ source }, index) => {
|
||||||
|
source.connect(sourceGains[index]);
|
||||||
|
sourceGains[index].connect(filter);
|
||||||
|
});
|
||||||
filter.connect(gain);
|
filter.connect(gain);
|
||||||
gain.connect(panner);
|
gain.connect(panner);
|
||||||
|
|
||||||
|
if (releaseSample) {
|
||||||
|
releaseGain = context.createGain();
|
||||||
|
releaseSample.source.connect(releaseGain);
|
||||||
|
releaseGain.connect(panner);
|
||||||
|
this.configureReleaseEnvelope(releaseGain, releaseSample);
|
||||||
|
}
|
||||||
|
|
||||||
panner.connect(eventBus);
|
panner.connect(eventBus);
|
||||||
|
|
||||||
if (delayInput && delaySend > 0) {
|
if (delayInput && delaySend > 0) {
|
||||||
sendGain = context.createGain();
|
delaySendGain = context.createGain();
|
||||||
sendGain.gain.value = delaySend;
|
delaySendGain.gain.value = delaySend;
|
||||||
panner.connect(sendGain);
|
panner.connect(delaySendGain);
|
||||||
sendGain.connect(delayInput);
|
delaySendGain.connect(delayInput);
|
||||||
}
|
}
|
||||||
|
|
||||||
source.start(scheduledStart);
|
if (roomInput && this.config.piano.roomSend > 0) {
|
||||||
source.stop(stopAt + pianoSamplerTuning.tailStopExtraSeconds);
|
roomSendGain = context.createGain();
|
||||||
this.activeVoices.push({ gain, source, stopAt });
|
roomSendGain.gain.value = this.config.piano.roomSend;
|
||||||
|
panner.connect(roomSendGain);
|
||||||
|
roomSendGain.connect(roomInput);
|
||||||
|
}
|
||||||
|
|
||||||
source.addEventListener(
|
const sources = [
|
||||||
'ended',
|
...strikeSources.map(({ source }) => source),
|
||||||
() => {
|
...(releaseSample ? [releaseSample.source] : []),
|
||||||
source.disconnect();
|
];
|
||||||
filter.disconnect();
|
|
||||||
gain.disconnect();
|
strikeSources.forEach(({ source }) => {
|
||||||
panner.disconnect();
|
source.start(scheduledStart);
|
||||||
sendGain?.disconnect();
|
source.stop(stopAt + pianoSamplerTuning.tailStopExtraSeconds);
|
||||||
this.activeVoices = this.activeVoices.filter((voice) => voice.gain !== gain);
|
});
|
||||||
},
|
if (releaseSample) {
|
||||||
{ once: true }
|
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.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 });
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
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.
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.
|
|
@ -2,15 +2,25 @@ Piano samples are Salamander Grand Piano V3 samples by Alexander Holm,
|
||||||
transcoded from OGG Vorbis to AAC M4A for iOS browser playback and distributed
|
transcoded from OGG Vorbis to AAC M4A for iOS browser playback and distributed
|
||||||
under CC BY 3.0.
|
under CC BY 3.0.
|
||||||
|
|
||||||
Source package: @audio-samples/piano-velocity12
|
Source packages:
|
||||||
|
|
||||||
|
- @audio-samples/piano-velocity4
|
||||||
|
- @audio-samples/piano-velocity8
|
||||||
|
- @audio-samples/piano-velocity12
|
||||||
|
- @audio-samples/piano-velocity16
|
||||||
|
- @audio-samples/piano-release
|
||||||
|
|
||||||
Source recording: https://archive.org/details/SalamanderGrandPianoV3
|
Source recording: https://archive.org/details/SalamanderGrandPianoV3
|
||||||
License: https://creativecommons.org/licenses/by/3.0/
|
License: https://creativecommons.org/licenses/by/3.0/
|
||||||
|
|
||||||
Checked-in subset: velocity layer `v12`, every minor-third anchor from A0
|
Checked-in subset: velocity layers `v4`, `v8`, `v12`, and `v16` at the
|
||||||
through C8: A, C, Dsharp, and Fsharp for octaves 1-7, plus A0, A7, and C8.
|
available Salamander strike anchors, plus all 88 release samples. The strike
|
||||||
The app derives MIDI values from those note names in `piano-samples.ts`.
|
anchors are A, C, Dsharp, and Fsharp for octaves 1-7, plus A0, A7, and C8.
|
||||||
|
The app derives strike MIDI values and velocity layers from filenames in
|
||||||
|
`piano-samples.ts`; release sample `rel1` maps to A0 and `rel88` maps to C8.
|
||||||
|
|
||||||
Repro notes: start from the matching `v12` OGG files in the source package and
|
Repro notes: start from the matching OGG files in the source packages and
|
||||||
transcode each selected sample to AAC/M4A without renaming the note/velocity
|
transcode each selected sample to AAC/M4A at 192 kbps without renaming the
|
||||||
stem. The expected output filenames are `<note>v12.m4a`, for example
|
note/velocity or release stem. Replace `#` with `sharp` in filenames for URL
|
||||||
`C4v12.m4a`.
|
compatibility. Expected output filenames include `<note>v<layer>.m4a`, for
|
||||||
|
example `C4v16.m4a`, and `rel<index>.m4a`, for example `rel40.m4a`.
|
||||||
|
|
|
||||||
84
src/config/eraser-size.ts
Normal file
84
src/config/eraser-size.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
export interface CssPixelSize {
|
||||||
|
height: number;
|
||||||
|
width: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ERASER_SIZE_MIN = 24;
|
||||||
|
export const ERASER_SIZE_MAX = 480;
|
||||||
|
|
||||||
|
const ERASER_MAX_SHORT_SIDE_RATIO = 0.55;
|
||||||
|
|
||||||
|
const getNormalizedEraserSizeMax = (maxSize: number): number => {
|
||||||
|
const safeMaxSize = Number.isFinite(maxSize) ? Math.floor(maxSize) : ERASER_SIZE_MAX;
|
||||||
|
return Math.max(ERASER_SIZE_MIN, Math.min(ERASER_SIZE_MAX, safeMaxSize));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getElementCssPixelSize = (element: Element): CssPixelSize => {
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
const { clientHeight = 0, clientWidth = 0 } = element as HTMLElement;
|
||||||
|
return {
|
||||||
|
height: rect.height || clientHeight,
|
||||||
|
width: rect.width || clientWidth,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getEraserSizeMaxForCssSize = ({ height, width }: CssPixelSize): number => {
|
||||||
|
const shortestSide = Math.min(height, width);
|
||||||
|
if (!Number.isFinite(shortestSide) || shortestSide <= 0) {
|
||||||
|
return ERASER_SIZE_MAX;
|
||||||
|
}
|
||||||
|
|
||||||
|
return getNormalizedEraserSizeMax(
|
||||||
|
Math.floor(shortestSide * ERASER_MAX_SHORT_SIDE_RATIO)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const clampEraserSize = (
|
||||||
|
value: number,
|
||||||
|
maxSize = ERASER_SIZE_MAX,
|
||||||
|
fallbackSize = ERASER_SIZE_MIN
|
||||||
|
): number => {
|
||||||
|
const max = getNormalizedEraserSizeMax(maxSize);
|
||||||
|
const fallback = Number.isFinite(fallbackSize) ? fallbackSize : ERASER_SIZE_MIN;
|
||||||
|
const safeValue = Number.isFinite(value) ? value : fallback;
|
||||||
|
return Math.min(max, Math.max(ERASER_SIZE_MIN, Math.round(safeValue)));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getEffectiveEraserSize = (
|
||||||
|
size: number,
|
||||||
|
cssSize: CssPixelSize,
|
||||||
|
fallbackSize = ERASER_SIZE_MIN
|
||||||
|
): number => clampEraserSize(size, getEraserSizeMaxForCssSize(cssSize), fallbackSize);
|
||||||
|
|
||||||
|
export const getEraserSizeRatio = (size: number, maxSize = ERASER_SIZE_MAX): number => {
|
||||||
|
const max = getNormalizedEraserSizeMax(maxSize);
|
||||||
|
if (max === ERASER_SIZE_MIN) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (clampEraserSize(size, max) - ERASER_SIZE_MIN) / (max - ERASER_SIZE_MIN);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ERASER_SLIDER_MIN = 0;
|
||||||
|
const ERASER_SLIDER_MAX = 1;
|
||||||
|
|
||||||
|
const clampSliderRatio = (value: number): number => {
|
||||||
|
const safeValue = Number.isFinite(value) ? value : ERASER_SLIDER_MIN;
|
||||||
|
return Math.min(ERASER_SLIDER_MAX, Math.max(ERASER_SLIDER_MIN, safeValue));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getEraserSizeFromSliderRatio = (
|
||||||
|
sliderRatio: number,
|
||||||
|
maxSize = ERASER_SIZE_MAX
|
||||||
|
): number => {
|
||||||
|
const max = getNormalizedEraserSizeMax(maxSize);
|
||||||
|
return clampEraserSize(
|
||||||
|
ERASER_SIZE_MIN + (max - ERASER_SIZE_MIN) * clampSliderRatio(sliderRatio) ** 2,
|
||||||
|
max
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getEraserSliderRatioFromSize = (
|
||||||
|
size: number,
|
||||||
|
maxSize = ERASER_SIZE_MAX
|
||||||
|
): number => Math.sqrt(getEraserSizeRatio(size, maxSize));
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { getEffectiveEraserSize } from '../config/eraser-size';
|
||||||
import { settings } from '../settings';
|
import { settings } from '../settings';
|
||||||
|
|
||||||
export class EraserPreview {
|
export class EraserPreview {
|
||||||
|
|
@ -49,9 +50,15 @@ export class EraserPreview {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.previousSize !== settings.eraserSize) {
|
const rect = this.canvas.getBoundingClientRect();
|
||||||
this.element.style.setProperty('--eraser-preview-size', `${settings.eraserSize}px`);
|
const size = getEffectiveEraserSize(settings.eraserSize, {
|
||||||
this.previousSize = settings.eraserSize;
|
height: rect.height || this.canvas.clientHeight,
|
||||||
|
width: rect.width || this.canvas.clientWidth,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (this.previousSize !== size) {
|
||||||
|
this.element.style.setProperty('--eraser-preview-size', `${size}px`);
|
||||||
|
this.previousSize = size;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
|
|
@ -63,7 +70,6 @@ export class EraserPreview {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const rect = this.canvas.getBoundingClientRect();
|
|
||||||
const left = `${this.previewClientPosition.x - rect.left}px`;
|
const left = `${this.previewClientPosition.x - rect.left}px`;
|
||||||
const top = `${this.previewClientPosition.y - rect.top}px`;
|
const top = `${this.previewClientPosition.y - rect.top}px`;
|
||||||
if (this.previousLeft !== left) {
|
if (this.previousLeft !== left) {
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { vec2 } from 'gl-matrix';
|
||||||
|
|
||||||
import { GardenAudio } from '../audio/garden-audio';
|
import { GardenAudio } from '../audio/garden-audio';
|
||||||
import { createGardenAudioConfig } from '../audio/garden-audio-config';
|
import { createGardenAudioConfig } from '../audio/garden-audio-config';
|
||||||
|
import { getEffectiveEraserSize, getElementCssPixelSize } from '../config/eraser-size';
|
||||||
import { activeVibe, settings } from '../settings';
|
import { activeVibe, settings } from '../settings';
|
||||||
import { DeltaTimeCalculator } from '../utils/delta-time-calculator';
|
import { DeltaTimeCalculator } from '../utils/delta-time-calculator';
|
||||||
import { rgbColorToCss, type RgbColor } from '../utils/rgb-color';
|
import { rgbColorToCss, type RgbColor } from '../utils/rgb-color';
|
||||||
|
|
@ -209,7 +210,11 @@ export default class GameLoop {
|
||||||
const runtimeSettings = { ...settings };
|
const runtimeSettings = { ...settings };
|
||||||
const introProgress = this.introPrompt.progress;
|
const introProgress = this.introPrompt.progress;
|
||||||
const canvasPixelRatio = this.canvasPixelRatio;
|
const canvasPixelRatio = this.canvasPixelRatio;
|
||||||
const eraserPixelSize = runtimeSettings.eraserSize * canvasPixelRatio;
|
const eraserCssSize = getEffectiveEraserSize(
|
||||||
|
runtimeSettings.eraserSize,
|
||||||
|
getElementCssPixelSize(this.canvas)
|
||||||
|
);
|
||||||
|
const eraserPixelSize = eraserCssSize * canvasPixelRatio;
|
||||||
const isErasing = this.pointerInput.isEraseMode;
|
const isErasing = this.pointerInput.isEraseMode;
|
||||||
const accentColor =
|
const accentColor =
|
||||||
channelColors[runtimeSettings.selectedColorIndex] ?? channelColors[0];
|
channelColors[runtimeSettings.selectedColorIndex] ?? channelColors[0];
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,17 @@
|
||||||
import { DEFAULT_AUDIO_VOLUME } from '../audio/garden-audio-config';
|
import { DEFAULT_AUDIO_VOLUME, MAX_AUDIO_VOLUME } from '../audio/garden-audio-config';
|
||||||
import type GameLoop from '../game-loop/game-loop';
|
import type GameLoop from '../game-loop/game-loop';
|
||||||
import { readBrowserStorage, writeBrowserStorage } from '../utils/browser-storage';
|
import { readBrowserStorage, writeBrowserStorage } from '../utils/browser-storage';
|
||||||
import { queryRequiredElement } from '../utils/dom';
|
import { queryRequiredElement } from '../utils/dom';
|
||||||
import { clamp01 } from '../utils/math';
|
|
||||||
|
|
||||||
const AUDIO_MUTED_STORAGE_KEY = 'fleeting-garden:audio-muted';
|
const AUDIO_MUTED_STORAGE_KEY = 'fleeting-garden:audio-muted';
|
||||||
const AUDIO_VOLUME_STORAGE_KEY = 'fleeting-garden:audio-volume';
|
const AUDIO_VOLUME_STORAGE_KEY = 'fleeting-garden:audio-volume';
|
||||||
const AUDIO_VOLUME_MIN = 0;
|
const AUDIO_VOLUME_MIN = 0;
|
||||||
const AUDIO_VOLUME_MAX = 1;
|
const AUDIO_VOLUME_MAX = MAX_AUDIO_VOLUME;
|
||||||
const AUDIO_VOLUME_STEP = 0.01;
|
const AUDIO_VOLUME_STEP = 0.01;
|
||||||
|
|
||||||
const clampAudioVolume = (value: number): number => {
|
const clampAudioVolume = (value: number): number => {
|
||||||
const safeValue = Number.isFinite(value) ? value : DEFAULT_AUDIO_VOLUME;
|
const safeValue = Number.isFinite(value) ? value : DEFAULT_AUDIO_VOLUME;
|
||||||
return Math.min(AUDIO_VOLUME_MAX, Math.max(AUDIO_VOLUME_MIN, clamp01(safeValue)));
|
return Math.min(AUDIO_VOLUME_MAX, Math.max(AUDIO_VOLUME_MIN, safeValue));
|
||||||
};
|
};
|
||||||
|
|
||||||
const readInitialAudioVolume = (): number => {
|
const readInitialAudioVolume = (): number => {
|
||||||
|
|
@ -83,6 +82,7 @@ export class AudioControl {
|
||||||
this.audioVolume = clampAudioVolume(this.audioVolume);
|
this.audioVolume = clampAudioVolume(this.audioVolume);
|
||||||
const isEffectivelyMuted = this.isMuted;
|
const isEffectivelyMuted = this.isMuted;
|
||||||
const volumePercent = Math.round(this.audioVolume * 100);
|
const volumePercent = Math.round(this.audioVolume * 100);
|
||||||
|
const volumeProgressPercent = Math.round((this.audioVolume / AUDIO_VOLUME_MAX) * 100);
|
||||||
|
|
||||||
this.soundButton.classList.toggle('muted', isEffectivelyMuted);
|
this.soundButton.classList.toggle('muted', isEffectivelyMuted);
|
||||||
this.soundButton.setAttribute('aria-pressed', String(isEffectivelyMuted));
|
this.soundButton.setAttribute('aria-pressed', String(isEffectivelyMuted));
|
||||||
|
|
@ -102,7 +102,10 @@ export class AudioControl {
|
||||||
this.volumeControl.title = isEffectivelyMuted
|
this.volumeControl.title = isEffectivelyMuted
|
||||||
? `Muted, ${volumePercent}% volume`
|
? `Muted, ${volumePercent}% volume`
|
||||||
: `${volumePercent}% volume`;
|
: `${volumePercent}% volume`;
|
||||||
this.volumeControl.style.setProperty('--volume-progress', `${volumePercent}%`);
|
this.volumeControl.style.setProperty(
|
||||||
|
'--volume-progress',
|
||||||
|
`${volumeProgressPercent}%`
|
||||||
|
);
|
||||||
|
|
||||||
const game = this.options.getGame();
|
const game = this.options.getGame();
|
||||||
game?.setAudioVolume(this.audioVolume);
|
game?.setAudioVolume(this.audioVolume);
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,11 @@ import { describe, expect, it } from 'vitest';
|
||||||
import {
|
import {
|
||||||
ERASER_SIZE_MAX,
|
ERASER_SIZE_MAX,
|
||||||
ERASER_SIZE_MIN,
|
ERASER_SIZE_MIN,
|
||||||
|
getEffectiveEraserSize,
|
||||||
getEraserSizeFromSliderRatio,
|
getEraserSizeFromSliderRatio,
|
||||||
|
getEraserSizeMaxForCssSize,
|
||||||
getEraserSliderRatioFromSize,
|
getEraserSliderRatioFromSize,
|
||||||
} from './eraser-size-control';
|
} from '../config/eraser-size';
|
||||||
|
|
||||||
describe('eraser size slider mapping', () => {
|
describe('eraser size slider mapping', () => {
|
||||||
it('maps slider position quadratically to eraser size', () => {
|
it('maps slider position quadratically to eraser size', () => {
|
||||||
|
|
@ -23,4 +25,15 @@ describe('eraser size slider mapping', () => {
|
||||||
expect(getEraserSliderRatioFromSize(quarterRangeSize)).toBe(0.5);
|
expect(getEraserSliderRatioFromSize(quarterRangeSize)).toBe(0.5);
|
||||||
expect(getEraserSliderRatioFromSize(ERASER_SIZE_MAX)).toBe(1);
|
expect(getEraserSliderRatioFromSize(ERASER_SIZE_MAX)).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('uses a responsive max size on small canvases', () => {
|
||||||
|
const mobileMax = getEraserSizeMaxForCssSize({ height: 640, width: 390 });
|
||||||
|
|
||||||
|
expect(mobileMax).toBeLessThan(ERASER_SIZE_MAX);
|
||||||
|
expect(getEraserSizeFromSliderRatio(1, mobileMax)).toBe(mobileMax);
|
||||||
|
expect(getEraserSliderRatioFromSize(ERASER_SIZE_MAX, mobileMax)).toBe(1);
|
||||||
|
expect(getEffectiveEraserSize(ERASER_SIZE_MAX, { height: 640, width: 390 })).toBe(
|
||||||
|
mobileMax
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,40 +1,25 @@
|
||||||
|
import {
|
||||||
|
clampEraserSize,
|
||||||
|
ERASER_SIZE_MAX,
|
||||||
|
getElementCssPixelSize,
|
||||||
|
getEraserSizeFromSliderRatio,
|
||||||
|
getEraserSizeMaxForCssSize,
|
||||||
|
getEraserSizeRatio,
|
||||||
|
getEraserSliderRatioFromSize,
|
||||||
|
} from '../config/eraser-size';
|
||||||
import type GameLoop from '../game-loop/game-loop';
|
import type GameLoop from '../game-loop/game-loop';
|
||||||
import { DEFAULT_ERASER_SIZE, settings } from '../settings';
|
import { DEFAULT_ERASER_SIZE, settings } from '../settings';
|
||||||
import { queryRequiredElement } from '../utils/dom';
|
import { queryRequiredElement } from '../utils/dom';
|
||||||
|
|
||||||
export const ERASER_SIZE_MIN = 24;
|
|
||||||
export const ERASER_SIZE_MAX = 480;
|
|
||||||
|
|
||||||
const ERASER_CONTROL_SCALE_MIN = 0.74;
|
const ERASER_CONTROL_SCALE_MIN = 0.74;
|
||||||
const ERASER_CONTROL_SCALE_MAX = 1.34;
|
const ERASER_CONTROL_SCALE_MAX = 1.34;
|
||||||
|
|
||||||
const clampEraserSize = (value: number): number => {
|
|
||||||
const safeValue = Number.isFinite(value) ? value : DEFAULT_ERASER_SIZE;
|
|
||||||
return Math.min(ERASER_SIZE_MAX, Math.max(ERASER_SIZE_MIN, Math.round(safeValue)));
|
|
||||||
};
|
|
||||||
|
|
||||||
const ERASER_SLIDER_MIN = 0;
|
const ERASER_SLIDER_MIN = 0;
|
||||||
const ERASER_SLIDER_MAX = 1;
|
const ERASER_SLIDER_MAX = 1;
|
||||||
const ERASER_SLIDER_STEP = 0.001;
|
const ERASER_SLIDER_STEP = 0.001;
|
||||||
|
|
||||||
const clampSliderRatio = (value: number): number => {
|
const clampStoredEraserSize = (value: number): number =>
|
||||||
const safeValue = Number.isFinite(value) ? value : ERASER_SLIDER_MIN;
|
clampEraserSize(value, ERASER_SIZE_MAX, DEFAULT_ERASER_SIZE);
|
||||||
return Math.min(ERASER_SLIDER_MAX, Math.max(ERASER_SLIDER_MIN, safeValue));
|
|
||||||
};
|
|
||||||
|
|
||||||
const getEraserSizeRatio = (size: number): number => {
|
|
||||||
return (clampEraserSize(size) - ERASER_SIZE_MIN) / (ERASER_SIZE_MAX - ERASER_SIZE_MIN);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getEraserSizeFromSliderRatio = (sliderRatio: number): number => {
|
|
||||||
return clampEraserSize(
|
|
||||||
ERASER_SIZE_MIN +
|
|
||||||
(ERASER_SIZE_MAX - ERASER_SIZE_MIN) * clampSliderRatio(sliderRatio) ** 2
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getEraserSliderRatioFromSize = (size: number): number =>
|
|
||||||
Math.sqrt(getEraserSizeRatio(size));
|
|
||||||
|
|
||||||
interface EraserSizeControlOptions {
|
interface EraserSizeControlOptions {
|
||||||
getGame: () => GameLoop | null;
|
getGame: () => GameLoop | null;
|
||||||
|
|
@ -48,6 +33,7 @@ export class EraserSizeControl {
|
||||||
HTMLLabelElement
|
HTMLLabelElement
|
||||||
);
|
);
|
||||||
private readonly slider = queryRequiredElement('.eraser-size-slider', HTMLInputElement);
|
private readonly slider = queryRequiredElement('.eraser-size-slider', HTMLInputElement);
|
||||||
|
private readonly canvas = queryRequiredElement('canvas', HTMLCanvasElement);
|
||||||
private isActive = false;
|
private isActive = false;
|
||||||
|
|
||||||
public constructor(private readonly options: EraserSizeControlOptions) {
|
public constructor(private readonly options: EraserSizeControlOptions) {
|
||||||
|
|
@ -55,7 +41,10 @@ export class EraserSizeControl {
|
||||||
this.control.addEventListener('click', this.activate);
|
this.control.addEventListener('click', this.activate);
|
||||||
this.slider.addEventListener('focus', this.activate);
|
this.slider.addEventListener('focus', this.activate);
|
||||||
this.slider.addEventListener('input', () => {
|
this.slider.addEventListener('input', () => {
|
||||||
settings.eraserSize = getEraserSizeFromSliderRatio(Number(this.slider.value));
|
settings.eraserSize = getEraserSizeFromSliderRatio(
|
||||||
|
Number(this.slider.value),
|
||||||
|
this.getResponsiveMaxSize()
|
||||||
|
);
|
||||||
this.activate();
|
this.activate();
|
||||||
this.render();
|
this.render();
|
||||||
this.options.onChange();
|
this.options.onChange();
|
||||||
|
|
@ -63,19 +52,21 @@ export class EraserSizeControl {
|
||||||
}
|
}
|
||||||
|
|
||||||
public render(): void {
|
public render(): void {
|
||||||
const size = clampEraserSize(settings.eraserSize);
|
const maxSize = this.getResponsiveMaxSize();
|
||||||
if (settings.eraserSize !== size) {
|
const storedSize = clampStoredEraserSize(settings.eraserSize);
|
||||||
settings.eraserSize = size;
|
if (settings.eraserSize !== storedSize) {
|
||||||
|
settings.eraserSize = storedSize;
|
||||||
}
|
}
|
||||||
|
|
||||||
const sliderRatio = getEraserSliderRatioFromSize(size);
|
const size = clampEraserSize(storedSize, maxSize, DEFAULT_ERASER_SIZE);
|
||||||
|
const sliderRatio = getEraserSliderRatioFromSize(size, maxSize);
|
||||||
this.slider.min = ERASER_SLIDER_MIN.toString();
|
this.slider.min = ERASER_SLIDER_MIN.toString();
|
||||||
this.slider.max = ERASER_SLIDER_MAX.toString();
|
this.slider.max = ERASER_SLIDER_MAX.toString();
|
||||||
this.slider.step = ERASER_SLIDER_STEP.toString();
|
this.slider.step = ERASER_SLIDER_STEP.toString();
|
||||||
this.slider.value = sliderRatio.toString();
|
this.slider.value = sliderRatio.toString();
|
||||||
this.slider.setAttribute('aria-valuetext', `${size}px`);
|
this.slider.setAttribute('aria-valuetext', `${size}px`);
|
||||||
|
|
||||||
const sizeRatio = getEraserSizeRatio(size);
|
const sizeRatio = getEraserSizeRatio(size, maxSize);
|
||||||
const scale =
|
const scale =
|
||||||
ERASER_CONTROL_SCALE_MIN +
|
ERASER_CONTROL_SCALE_MIN +
|
||||||
(ERASER_CONTROL_SCALE_MAX - ERASER_CONTROL_SCALE_MIN) * sizeRatio;
|
(ERASER_CONTROL_SCALE_MAX - ERASER_CONTROL_SCALE_MIN) * sizeRatio;
|
||||||
|
|
@ -95,6 +86,10 @@ export class EraserSizeControl {
|
||||||
this.options.onActivate();
|
this.options.onActivate();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private getResponsiveMaxSize(): number {
|
||||||
|
return getEraserSizeMaxForCssSize(getElementCssPixelSize(this.canvas));
|
||||||
|
}
|
||||||
|
|
||||||
private syncActiveState(): void {
|
private syncActiveState(): void {
|
||||||
this.control.classList.toggle('active', this.isActive);
|
this.control.classList.toggle('active', this.isActive);
|
||||||
this.slider.setAttribute(
|
this.slider.setAttribute(
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,7 @@
|
||||||
|
|
||||||
> .splash-description {
|
> .splash-description {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
max-width: 28ch;
|
max-width: 70ch;
|
||||||
color: rgb(255 255 255 / 80%);
|
color: rgb(255 255 255 / 80%);
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,10 @@
|
||||||
|
|
||||||
html > body > aside.control-dock > .info-page {
|
html > body > aside.control-dock > .info-page {
|
||||||
width: min(100%, 520px);
|
width: min(100%, 520px);
|
||||||
max-height: min(62vh, 480px);
|
max-height: 200vh;
|
||||||
max-height: min(62dvh, 480px);
|
max-height: 200dvh;
|
||||||
margin: 0 auto 10px;
|
margin: 0 auto 10px;
|
||||||
overflow-x: hidden;
|
overflow: hidden;
|
||||||
overflow-y: auto;
|
|
||||||
overscroll-behavior: contain;
|
|
||||||
touch-action: pan-y;
|
|
||||||
border: 1px solid rgb(255 255 255 / 46%);
|
border: 1px solid rgb(255 255 255 / 46%);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background:
|
background:
|
||||||
|
|
@ -20,26 +17,12 @@ html > body > aside.control-dock > .info-page {
|
||||||
0 16px 42px rgb(0 0 0 / 30%),
|
0 16px 42px rgb(0 0 0 / 30%),
|
||||||
0 2px 10px rgb(0 0 0 / 18%);
|
0 2px 10px rgb(0 0 0 / 18%);
|
||||||
backdrop-filter: blur(16px) saturate(118%);
|
backdrop-filter: blur(16px) saturate(118%);
|
||||||
scrollbar-width: thin;
|
|
||||||
scrollbar-color: rgb(69 98 88 / 62%) transparent;
|
|
||||||
-webkit-overflow-scrolling: touch;
|
|
||||||
transition:
|
transition:
|
||||||
max-height var(--transition-time-long),
|
max-height var(--transition-time-long),
|
||||||
opacity var(--transition-time-long),
|
opacity var(--transition-time-long),
|
||||||
transform var(--transition-time-long),
|
transform var(--transition-time-long),
|
||||||
margin-bottom var(--transition-time-long);
|
margin-bottom var(--transition-time-long);
|
||||||
|
|
||||||
&::-webkit-scrollbar-track,
|
|
||||||
&::-webkit-scrollbar {
|
|
||||||
background-color: transparent;
|
|
||||||
width: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&::-webkit-scrollbar-thumb {
|
|
||||||
background-color: rgb(69 98 88 / 62%);
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:focus-visible {
|
&:focus-visible {
|
||||||
outline: 2px solid rgb(17 56 45);
|
outline: 2px solid rgb(17 56 45);
|
||||||
outline-offset: 3px;
|
outline-offset: 3px;
|
||||||
|
|
@ -150,9 +133,8 @@ html > body > aside.control-dock > .info-page {
|
||||||
line-height: 1.18;
|
line-height: 1.18;
|
||||||
}
|
}
|
||||||
|
|
||||||
.info-page__lede,
|
.info-page__main,
|
||||||
.info-page__notes,
|
.info-page__notes {
|
||||||
.info-page__meta {
|
|
||||||
max-width: 56ch;
|
max-width: 56ch;
|
||||||
overflow-wrap: break-word;
|
overflow-wrap: break-word;
|
||||||
color: rgb(25 35 32);
|
color: rgb(25 35 32);
|
||||||
|
|
@ -160,8 +142,7 @@ html > body > aside.control-dock > .info-page {
|
||||||
line-height: 1.56;
|
line-height: 1.56;
|
||||||
}
|
}
|
||||||
|
|
||||||
.info-page__lede,
|
.info-page__main {
|
||||||
.info-page__meta {
|
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
hyphens: auto;
|
hyphens: auto;
|
||||||
}
|
}
|
||||||
|
|
@ -183,19 +164,6 @@ html > body > aside.control-dock > .info-page {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.info-page__meta {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0.35rem;
|
|
||||||
align-items: baseline;
|
|
||||||
margin-top: 0.2rem;
|
|
||||||
padding-top: 0.75rem;
|
|
||||||
border-top: 1px solid rgb(43 66 57 / 16%);
|
|
||||||
color: rgb(67 82 77);
|
|
||||||
font-size: 0.8rem;
|
|
||||||
line-height: 1.45;
|
|
||||||
}
|
|
||||||
|
|
||||||
a {
|
a {
|
||||||
color: rgb(0 83 105);
|
color: rgb(0 83 105);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
|
|
@ -225,45 +193,16 @@ html > body > aside.control-dock > .info-page {
|
||||||
|
|
||||||
@include on-small-screen {
|
@include on-small-screen {
|
||||||
width: min(100%, 520px);
|
width: min(100%, 520px);
|
||||||
max-height: min(58vh, 500px);
|
|
||||||
max-height: min(58dvh, 500px);
|
|
||||||
|
|
||||||
.info-page__content {
|
.info-page__content {
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
padding: 14px;
|
padding: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.info-page__lede,
|
.info-page__main,
|
||||||
.info-page__notes {
|
.info-page__notes {
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
line-height: 1.52;
|
line-height: 1.52;
|
||||||
}
|
}
|
||||||
|
|
||||||
.info-page__meta {
|
|
||||||
font-size: 0.875rem;
|
|
||||||
line-height: 1.45;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-height: 420px) {
|
|
||||||
max-height: min(
|
|
||||||
58vh,
|
|
||||||
max(
|
|
||||||
10rem,
|
|
||||||
calc(
|
|
||||||
100vh - 168px - env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
max-height: min(
|
|
||||||
58dvh,
|
|
||||||
max(
|
|
||||||
10rem,
|
|
||||||
calc(
|
|
||||||
100dvh -
|
|
||||||
168px - env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,15 +16,24 @@
|
||||||
|
|
||||||
html {
|
html {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
overscroll-behavior: none;
|
||||||
touch-action: manipulation;
|
touch-action: manipulation;
|
||||||
|
user-select: none;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-webkit-touch-callout: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
text-rendering: optimizeLegibility;
|
text-rendering: optimizeLegibility;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: 'Open Sans', sans-serif;
|
font-family: 'Open Sans', sans-serif;
|
||||||
|
overscroll-behavior: none;
|
||||||
touch-action: manipulation;
|
touch-action: manipulation;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
-webkit-touch-callout: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.visually-hidden {
|
.visually-hidden {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue