diff --git a/index.html b/index.html
index 1414388..126b34c 100644
--- a/index.html
+++ b/index.html
@@ -4,14 +4,14 @@
@@ -22,7 +22,7 @@
@@ -35,7 +35,7 @@
@@ -46,7 +46,7 @@
"@type": "WebApplication",
"name": "Fleeting Garden",
"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",
"applicationCategory": "DesignApplication",
"operatingSystem": "Any",
@@ -91,7 +91,7 @@
Fleeting Garden
- 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.
@@ -139,24 +139,19 @@
>
-
- A garden is what we tend; the wild is what we get the moment we look away.
- Both happen here at once. Your strokes plant colour, small agents follow them,
- branch off, and slowly rewrite the patch you laid down into something you
- didn't quite plan.
+
+ Draw into a field of particles and watch the simulation fold your marks back into motion.
-
Three swatches plant the line; the eraser carves a clearing.
-
The mirror folds one gesture into many.
-
The arrows change the season.
+
Choose one of three colour swatches, then drag to draw.
+
Use the eraser to clear space and reshape the field.
+
The mirror repeats each gesture across the canvas.
+
The arrows switch the current atmosphere.
-
- Built with WebGPU, running locally in your browser. More of my work at
- schmelczer.dev.
+
+ My implementation of physarum simulation introduces drawing and procedurally generated piano for a more immersive experience. Learn more about my work at schmelczer.dev
diff --git a/src/audio/garden-audio-config.ts b/src/audio/garden-audio-config.ts
index b5fbc1d..37e5ace 100644
--- a/src/audio/garden-audio-config.ts
+++ b/src/audio/garden-audio-config.ts
@@ -1,6 +1,7 @@
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;
type GardenAudioChordQuality = 'major' | 'minor' | 'sus2' | 'sus4';
@@ -58,17 +59,33 @@ export const createGardenAudioConfig = () => ({
timeRampSeconds: 0.12,
},
piano: {
- maxVoices: 24,
- gain: 0.48,
+ maxVoices: 48,
+ gain: 0.78,
sustainSeconds: 0.42,
sustainLevel: 0.26,
- releaseSeconds: 0.34,
- lowpassHz: 7000,
- gainAttackSeconds: 0.006,
- lowpassMaxHz: 12000,
- lowpassMinHz: 1400,
+ releaseSeconds: 0.62,
+ lowpassHz: 9500,
+ gainAttackSeconds: 0.003,
+ lowpassMaxHz: 16000,
+ lowpassMinHz: 900,
sustainBase: 0.45,
sustainVelocityRange: 0.55,
+ releaseSampleGain: 0.035,
+ releaseSampleVelocityBase: 0.45,
+ releaseSampleVelocityRange: 0.55,
+ roomSend: 0.18,
+ velocityLayerCurve: 0.72,
+ velocityLayerMax: 0.26,
+ velocityLayerMin: 0.035,
+ voiceDecayEstimateSeconds: 1.9,
+ },
+ room: {
+ decaySeconds: 1.65,
+ highPassHz: 120,
+ lowPassHz: 8200,
+ preDelaySeconds: 0.018,
+ sendGain: 1,
+ wetGain: 0.11,
},
rhythm: {
idleIntensity: defaultGardenAudioVibeSettings.idleIntensity,
diff --git a/src/audio/garden-audio-graph.ts b/src/audio/garden-audio-graph.ts
index a288465..a08ac58 100644
--- a/src/audio/garden-audio-graph.ts
+++ b/src/audio/garden-audio-graph.ts
@@ -28,11 +28,11 @@ const graphTuning = {
latencyHint: 'interactive',
outputFilterType: 'highpass',
compressor: {
- thresholdDb: -18,
+ thresholdDb: -17,
kneeDb: 18,
- ratio: 2.1,
- attackSeconds: 0.018,
- releaseSeconds: 0.18,
+ ratio: 2.2,
+ attackSeconds: 0.014,
+ releaseSeconds: 0.28,
},
} as const;
const delayFilterTuning = {
@@ -45,6 +45,7 @@ export class GardenAudioGraph {
public context: AudioContext | null = null;
public eventBus: GainNode | null = null;
public delayInput: GainNode | null = null;
+ public roomInput: GainNode | null = null;
public noiseBus: GainNode | null = null;
public noiseBuffer: AudioBuffer | null = null;
@@ -87,10 +88,12 @@ export class GardenAudioGraph {
const context = new AudioContextConstructor({
latencyHint: graphTuning.latencyHint,
});
+ const outputBus = context.createGain();
const masterGain = context.createGain();
const highPass = context.createBiquadFilter();
const compressor = context.createDynamicsCompressor();
+ outputBus.gain.value = 1;
masterGain.gain.value = 0;
highPass.type = graphTuning.outputFilterType;
highPass.frequency.value = outputHighPassFrequencyHz;
@@ -100,15 +103,18 @@ export class GardenAudioGraph {
compressor.attack.value = graphTuning.compressor.attackSeconds;
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);
- compressor.connect(context.destination);
+ compressor.connect(masterGain);
+ masterGain.connect(context.destination);
this.context = context;
this.masterGain = masterGain;
this.noiseBuffer = this.createNoiseBuffer(context);
- this.createDelay(context, masterGain);
- this.createBuses(context, masterGain);
+ this.createDelay(context, outputBus);
+ this.createRoom(context, outputBus);
+ this.createBuses(context, outputBus);
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 delayNode = context.createDelay(graphTuning.delayMaxSeconds);
const delayFeedback = context.createGain();
@@ -250,7 +256,7 @@ export class GardenAudioGraph {
delayFeedback.connect(delayNode);
delayNode.connect(returnLowPass);
returnLowPass.connect(delayOutput);
- delayOutput.connect(masterGain);
+ delayOutput.connect(outputBus);
this.delayInput = delayInput;
this.delayNode = delayNode;
@@ -258,10 +264,37 @@ export class GardenAudioGraph {
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();
eventBus.gain.value = graphTuning.eventBusGain;
- eventBus.connect(masterGain);
+ eventBus.connect(outputBus);
this.eventBus = eventBus;
this.pianoBuses.clear();
@@ -328,10 +361,34 @@ export class GardenAudioGraph {
return buffer;
}
+ private createRoomImpulse(context: AudioContext): AudioBuffer {
+ const sampleCount = Math.max(
+ 1,
+ Math.floor(context.sampleRate * this.config.room.decaySeconds)
+ );
+ const impulse = context.createBuffer(2, sampleCount, context.sampleRate);
+
+ for (let channel = 0; channel < impulse.numberOfChannels; channel += 1) {
+ const data = impulse.getChannelData(channel);
+ for (let index = 0; index < sampleCount; index += 1) {
+ const position = index / sampleCount;
+ const decay = Math.pow(1 - position, 2.35);
+ const earlyReflection =
+ index % Math.max(1, Math.floor(context.sampleRate * 0.011)) === 0
+ ? 0.18 * (1 - position)
+ : 0;
+ data[index] = (Math.random() * 2 - 1 + earlyReflection) * decay;
+ }
+ }
+
+ return impulse;
+ }
+
private clearNodes(): void {
this.context = null;
this.eventBus = null;
this.delayInput = null;
+ this.roomInput = null;
this.noiseBus = null;
this.noiseBuffer = null;
this.masterGain = null;
diff --git a/src/audio/garden-audio-types.ts b/src/audio/garden-audio-types.ts
index fecbcf8..ade78a4 100644
--- a/src/audio/garden-audio-types.ts
+++ b/src/audio/garden-audio-types.ts
@@ -14,11 +14,22 @@ export interface GardenAudioStroke {
elapsedSeconds: number;
}
-export interface LoadedPianoSample {
+export interface LoadedPianoStrikeSample {
+ midi: number;
+ velocityLayer: number;
+ buffer: AudioBuffer;
+}
+
+export interface LoadedPianoReleaseSample {
midi: number;
buffer: AudioBuffer;
}
+export interface LoadedPianoSamples {
+ releases: Array;
+ strikes: Array;
+}
+
export interface PianoNote {
midi: number;
velocity: number;
diff --git a/src/audio/garden-audio.ts b/src/audio/garden-audio.ts
index 3aa4c88..d2178cc 100644
--- a/src/audio/garden-audio.ts
+++ b/src/audio/garden-audio.ts
@@ -1,7 +1,8 @@
import { ErrorHandler, Severity } from '../utils/error-handler';
-import { clamp01 } from '../utils/math';
+import { clamp } from '../utils/math';
import type { VibeId, VibePreset } from '../vibes';
import {
+ MAX_AUDIO_VOLUME,
SILENT_AUDIO_GAIN,
type GardenAudioConfig,
type GardenAudioVibeProfile,
@@ -49,7 +50,7 @@ export class GardenAudio {
private hasLoadedPiano = false;
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.piano = new PianoSampler(config, this.graph);
this.noise = new NoiseBurstPlayer(this.graph);
@@ -228,7 +229,7 @@ export class GardenAudio {
}
public setMasterVolume(masterVolume: number): void {
- this.masterVolume = clamp01(masterVolume);
+ this.masterVolume = clamp(masterVolume, 0, MAX_AUDIO_VOLUME);
if (!this.isMuted) {
this.graph.setMasterGain(this.masterVolume, this.config.updateRampSeconds);
}
@@ -396,7 +397,7 @@ export class GardenAudio {
return;
}
- const distanceActivity = clamp01(activity);
+ const distanceActivity = clamp(activity, 0, 1);
if (distanceActivity <= 0) {
return;
}
diff --git a/src/audio/piano-sampler.ts b/src/audio/piano-sampler.ts
index 1a0c77c..5e0b771 100644
--- a/src/audio/piano-sampler.ts
+++ b/src/audio/piano-sampler.ts
@@ -2,32 +2,56 @@ import { clamp, clamp01 } from '../utils/math';
import type { GardenAudioConfig } from './garden-audio-config';
import type { GardenAudioGraph } from './garden-audio-graph';
import { PITCH_SEMITONES_PER_OCTAVE } from './garden-audio-music';
-import type { LoadedPianoSample, PianoNote } from './garden-audio-types';
+import type {
+ LoadedPianoReleaseSample,
+ LoadedPianoSamples,
+ LoadedPianoStrikeSample,
+ PianoNote,
+} from './garden-audio-types';
import { getLoadedPianoSamples, loadPianoSamples } from './piano-samples';
export const PIANO_SCHEDULE_AHEAD_SECONDS = 0.002;
interface ActivePianoVoice {
gain: GainNode;
- source: AudioScheduledSourceNode;
+ peakGain: number;
+ releaseAt: number;
+ sources: Array;
+ startedAt: number;
+ stopAt: number;
+}
+
+interface SelectedPianoStrikeSample {
+ gainScale: number;
+ sample: LoadedPianoStrikeSample;
+}
+
+interface ScheduledReleaseSample {
+ gainValue: number;
+ source: AudioBufferSourceNode;
+ startTime: number;
stopAt: number;
}
const pianoSamplerTuning = {
filterType: 'lowpass',
- filterQ: 0.7,
+ filterQ: 0.45,
minDurationSeconds: 0.08,
minFadeSeconds: 0.08,
minGain: 0.0001,
- releaseTimeConstantCount: 5,
- tailStopExtraSeconds: 0.05,
- voiceStealFadeSeconds: 0.025,
- voiceStealStopSeconds: 0.05,
+ releaseSampleAttackSeconds: 0.006,
+ releaseSampleDecaySeconds: 0.18,
+ releaseTimeConstantCount: 6,
+ tailStopExtraSeconds: 0.08,
+ voiceStealFadeSeconds: 0.045,
+ voiceStealStopSeconds: 0.09,
} as const;
export class PianoSampler {
- private samples: Array = [];
private activeVoices: Array = [];
+ private releaseSamples: Array = [];
+ private strikeSamples: Array = [];
+ private velocityLayers: Array = [];
public constructor(
private readonly config: GardenAudioConfig,
@@ -35,7 +59,7 @@ export class PianoSampler {
) {}
public load(context: BaseAudioContext): Promise {
- if (this.samples.length > 0) {
+ if (this.strikeSamples.length > 0) {
return Promise.resolve();
}
@@ -67,8 +91,9 @@ export class PianoSampler {
return;
}
- const sample = this.findNearestSample(midi);
- if (!sample) {
+ const noteVelocity = clamp01(velocity);
+ const selectedSamples = this.selectStrikeSamples(midi, noteVelocity);
+ if (selectedSamples.length === 0) {
return;
}
@@ -76,7 +101,6 @@ export class PianoSampler {
context.currentTime + PIANO_SCHEDULE_AHEAD_SECONDS,
startTime
);
- const noteVelocity = clamp01(velocity);
const noteGainValue = this.computeNoteGain(noteVelocity);
const sustainSeconds =
profileSustainSeconds *
@@ -88,45 +112,36 @@ export class PianoSampler {
const stopAt =
releaseAt +
this.config.piano.releaseSeconds * pianoSamplerTuning.releaseTimeConstantCount;
- const source = context.createBufferSource();
-
- source.buffer = sample.buffer;
- source.playbackRate.setValueAtTime(
- Math.pow(2, (midi - sample.midi) / PITCH_SEMITONES_PER_OCTAVE),
- scheduledStart
- );
+ const strikeSources = selectedSamples.map(({ gainScale, sample }) => ({
+ gainScale,
+ source: this.createSource(
+ context,
+ sample.buffer,
+ midi,
+ sample.midi,
+ scheduledStart
+ ),
+ }));
+ const releaseSample = this.createReleaseSample({
+ context,
+ midi,
+ noteVelocity,
+ releaseAt,
+ });
this.scheduleVoice({
- source,
- scheduledStart,
- stopAt,
- pan,
- lowpassHz,
delaySend,
eventBus,
- configureGainEnvelope: (gain) => {
- gain.gain.setValueAtTime(pianoSamplerTuning.minGain, scheduledStart);
- gain.gain.exponentialRampToValueAtTime(
- noteGainValue,
- scheduledStart + this.config.piano.gainAttackSeconds
- );
- gain.gain.setTargetAtTime(
- Math.max(
- pianoSamplerTuning.minGain,
- noteGainValue * this.config.piano.sustainLevel
- ),
- sustainAt,
- Math.max(
- pianoSamplerTuning.minFadeSeconds,
- sustainSeconds * this.config.piano.sustainBase
- )
- );
- gain.gain.setTargetAtTime(
- pianoSamplerTuning.minGain,
- releaseAt,
- this.config.piano.releaseSeconds
- );
- },
+ lowpassHz,
+ noteGainValue,
+ pan,
+ releaseAt,
+ releaseSample,
+ scheduledStart,
+ stopAt: releaseSample ? Math.max(stopAt, releaseSample.stopAt) : stopAt,
+ strikeSources,
+ sustainAt,
+ sustainSeconds,
});
}
@@ -146,30 +161,40 @@ export class PianoSampler {
}
public reset(): void {
- this.samples = [];
+ this.releaseSamples = [];
+ this.strikeSamples = [];
+ this.velocityLayers = [];
this.activeVoices = [];
}
private scheduleVoice({
- source,
+ strikeSources,
+ releaseSample,
scheduledStart,
+ sustainAt,
+ sustainSeconds,
+ releaseAt,
stopAt,
pan,
lowpassHz,
delaySend,
eventBus,
- configureGainEnvelope,
+ noteGainValue,
}: {
- source: AudioScheduledSourceNode;
- scheduledStart: number;
- stopAt: number;
- pan: number;
- lowpassHz: number;
delaySend: number;
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 {
- const { context, delayInput } = this.graph;
+ const { context, delayInput, roomInput } = this.graph;
if (!context) {
return;
}
@@ -177,15 +202,18 @@ export class PianoSampler {
const filter = context.createBiquadFilter();
const gain = context.createGain();
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);
while (this.activeVoices.length >= this.config.piano.maxVoices) {
- const oldest = this.activeVoices.shift();
- if (!oldest) {
- break;
- }
- this.stopVoice(oldest, scheduledStart);
+ this.stealQuietestVoice(scheduledStart);
}
filter.type = pianoSamplerTuning.filterType;
@@ -195,48 +223,352 @@ export class PianoSampler {
);
filter.Q.value = pianoSamplerTuning.filterQ;
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);
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) {
- sendGain = context.createGain();
- sendGain.gain.value = delaySend;
- panner.connect(sendGain);
- sendGain.connect(delayInput);
+ delaySendGain = context.createGain();
+ delaySendGain.gain.value = delaySend;
+ panner.connect(delaySendGain);
+ delaySendGain.connect(delayInput);
}
- source.start(scheduledStart);
- source.stop(stopAt + pianoSamplerTuning.tailStopExtraSeconds);
- this.activeVoices.push({ gain, source, stopAt });
+ if (roomInput && this.config.piano.roomSend > 0) {
+ roomSendGain = context.createGain();
+ roomSendGain.gain.value = this.config.piano.roomSend;
+ panner.connect(roomSendGain);
+ roomSendGain.connect(roomInput);
+ }
- source.addEventListener(
- 'ended',
- () => {
- source.disconnect();
- filter.disconnect();
- gain.disconnect();
- panner.disconnect();
- sendGain?.disconnect();
- this.activeVoices = this.activeVoices.filter((voice) => voice.gain !== gain);
- },
- { once: true }
+ const sources = [
+ ...strikeSources.map(({ source }) => source),
+ ...(releaseSample ? [releaseSample.source] : []),
+ ];
+
+ strikeSources.forEach(({ source }) => {
+ source.start(scheduledStart);
+ source.stop(stopAt + pianoSamplerTuning.tailStopExtraSeconds);
+ });
+ if (releaseSample) {
+ releaseSample.source.start(releaseSample.startTime);
+ releaseSample.source.stop(releaseSample.stopAt);
+ }
+
+ const voice: ActivePianoVoice = {
+ gain,
+ peakGain: noteGainValue,
+ releaseAt,
+ sources,
+ startedAt: scheduledStart,
+ stopAt,
+ };
+ this.activeVoices.push(voice);
+
+ this.cleanupVoiceWhenSourcesEnd({
+ delaySendGain,
+ filter,
+ gain,
+ panner,
+ releaseGain,
+ roomSendGain,
+ sourceGains,
+ sources,
+ voice,
+ });
+ }
+
+ private configureGainEnvelope({
+ gain,
+ noteGainValue,
+ releaseAt,
+ scheduledStart,
+ sustainAt,
+ sustainSeconds,
+ }: {
+ gain: GainNode;
+ noteGainValue: number;
+ releaseAt: number;
+ scheduledStart: number;
+ sustainAt: number;
+ sustainSeconds: number;
+ }): void {
+ gain.gain.setValueAtTime(pianoSamplerTuning.minGain, scheduledStart);
+ gain.gain.exponentialRampToValueAtTime(
+ noteGainValue,
+ scheduledStart + this.config.piano.gainAttackSeconds
);
+ gain.gain.setTargetAtTime(
+ Math.max(
+ pianoSamplerTuning.minGain,
+ noteGainValue * this.config.piano.sustainLevel
+ ),
+ sustainAt,
+ Math.max(
+ pianoSamplerTuning.minFadeSeconds,
+ sustainSeconds * this.config.piano.sustainBase
+ )
+ );
+ gain.gain.setTargetAtTime(
+ pianoSamplerTuning.minGain,
+ releaseAt,
+ this.config.piano.releaseSeconds
+ );
+ }
+
+ private configureReleaseEnvelope(
+ releaseGain: GainNode,
+ releaseSample: ScheduledReleaseSample
+ ): void {
+ releaseGain.gain.setValueAtTime(pianoSamplerTuning.minGain, releaseSample.startTime);
+ releaseGain.gain.exponentialRampToValueAtTime(
+ releaseSample.gainValue,
+ releaseSample.startTime + pianoSamplerTuning.releaseSampleAttackSeconds
+ );
+ releaseGain.gain.setTargetAtTime(
+ pianoSamplerTuning.minGain,
+ releaseSample.startTime + pianoSamplerTuning.releaseSampleAttackSeconds,
+ pianoSamplerTuning.releaseSampleDecaySeconds
+ );
+ }
+
+ private createSource(
+ context: BaseAudioContext,
+ buffer: AudioBuffer,
+ midi: number,
+ sampleMidi: number,
+ scheduledStart: number
+ ): AudioBufferSourceNode {
+ const source = context.createBufferSource();
+ source.buffer = buffer;
+ source.playbackRate.setValueAtTime(
+ Math.pow(2, (midi - sampleMidi) / PITCH_SEMITONES_PER_OCTAVE),
+ scheduledStart
+ );
+ return source;
+ }
+
+ private createReleaseSample({
+ context,
+ midi,
+ noteVelocity,
+ releaseAt,
+ }: {
+ context: BaseAudioContext;
+ midi: number;
+ noteVelocity: number;
+ releaseAt: number;
+ }): ScheduledReleaseSample | null {
+ const sample = this.findNearestReleaseSample(midi);
+ if (!sample) {
+ return null;
+ }
+
+ const source = this.createSource(
+ context,
+ sample.buffer,
+ midi,
+ sample.midi,
+ releaseAt
+ );
+ const gainValue =
+ this.config.piano.releaseSampleGain *
+ (this.config.piano.releaseSampleVelocityBase +
+ noteVelocity * this.config.piano.releaseSampleVelocityRange);
+
+ return {
+ gainValue: Math.max(pianoSamplerTuning.minGain, gainValue),
+ source,
+ startTime: releaseAt,
+ stopAt:
+ releaseAt + sample.buffer.duration + pianoSamplerTuning.tailStopExtraSeconds,
+ };
+ }
+
+ private cleanupVoiceWhenSourcesEnd({
+ sources,
+ sourceGains,
+ filter,
+ gain,
+ releaseGain,
+ panner,
+ delaySendGain,
+ roomSendGain,
+ voice,
+ }: {
+ delaySendGain: GainNode | null;
+ filter: BiquadFilterNode;
+ gain: GainNode;
+ panner: StereoPannerNode;
+ releaseGain: GainNode | null;
+ roomSendGain: GainNode | null;
+ sourceGains: Array;
+ sources: Array;
+ 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 {
return Math.max(pianoSamplerTuning.minGain, this.config.piano.gain * velocity);
}
- private findNearestSample(midi: number): LoadedPianoSample | null {
- if (this.samples.length === 0) {
+ private selectStrikeSamples(
+ midi: number,
+ noteVelocity: number
+ ): Array {
+ 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 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
);
}
@@ -245,6 +577,33 @@ export class PianoSampler {
this.activeVoices = this.activeVoices.filter((voice) => voice.stopAt > now);
}
+ private stealQuietestVoice(now: number): void {
+ const quietestVoice = this.activeVoices.reduce(
+ (quietest, voice) =>
+ quietest === null ||
+ this.getVoiceActivityScore(voice, now) < this.getVoiceActivityScore(quietest, now)
+ ? voice
+ : quietest,
+ null
+ );
+ if (!quietestVoice) {
+ return;
+ }
+
+ this.stopVoice(quietestVoice, now);
+ this.activeVoices = this.activeVoices.filter((voice) => voice !== quietestVoice);
+ }
+
+ private getVoiceActivityScore(voice: ActivePianoVoice, now: number): number {
+ const ageSeconds = Math.max(0, now - voice.startedAt);
+ const releasedScale = now >= voice.releaseAt ? 0.28 : 1;
+ return (
+ voice.peakGain *
+ releasedScale *
+ Math.exp(-ageSeconds / this.config.piano.voiceDecayEstimateSeconds)
+ );
+ }
+
private stopVoice(voice: ActivePianoVoice, now: number): void {
const stopAt = now + pianoSamplerTuning.voiceStealStopSeconds;
@@ -254,11 +613,23 @@ export class PianoSampler {
now,
pianoSamplerTuning.voiceStealFadeSeconds
);
+ voice.sources.forEach((source) => {
+ try {
+ source.stop(stopAt);
+ } catch {
+ // The source may already have ended naturally.
+ }
+ });
voice.stopAt = stopAt;
- voice.source.stop(stopAt);
}
- private setSamples(samples: Array): void {
- this.samples = samples.slice().sort((a, b) => a.midi - b.midi);
+ private setSamples(samples: LoadedPianoSamples): void {
+ this.releaseSamples = samples.releases.slice().sort((a, b) => a.midi - b.midi);
+ this.strikeSamples = samples.strikes
+ .slice()
+ .sort((a, b) => a.midi - b.midi || a.velocityLayer - b.velocityLayer);
+ this.velocityLayers = [
+ ...new Set(this.strikeSamples.map((sample) => sample.velocityLayer)),
+ ].sort((a, b) => a - b);
}
}
diff --git a/src/audio/piano-samples.ts b/src/audio/piano-samples.ts
index 569eca4..2d2bc25 100644
--- a/src/audio/piano-samples.ts
+++ b/src/audio/piano-samples.ts
@@ -1,40 +1,26 @@
-import type { LoadedPianoSample } from './garden-audio-types';
-import a0SampleUrl from './samples/A0v12.m4a?url&no-inline';
-import a1SampleUrl from './samples/A1v12.m4a?url&no-inline';
-import a2SampleUrl from './samples/A2v12.m4a?url&no-inline';
-import a3SampleUrl from './samples/A3v12.m4a?url&no-inline';
-import a4SampleUrl from './samples/A4v12.m4a?url&no-inline';
-import a5SampleUrl from './samples/A5v12.m4a?url&no-inline';
-import a6SampleUrl from './samples/A6v12.m4a?url&no-inline';
-import a7SampleUrl from './samples/A7v12.m4a?url&no-inline';
-import c1SampleUrl from './samples/C1v12.m4a?url&no-inline';
-import c2SampleUrl from './samples/C2v12.m4a?url&no-inline';
-import c3SampleUrl from './samples/C3v12.m4a?url&no-inline';
-import c4SampleUrl from './samples/C4v12.m4a?url&no-inline';
-import c5SampleUrl from './samples/C5v12.m4a?url&no-inline';
-import c6SampleUrl from './samples/C6v12.m4a?url&no-inline';
-import c7SampleUrl from './samples/C7v12.m4a?url&no-inline';
-import c8SampleUrl from './samples/C8v12.m4a?url&no-inline';
-import dSharp1SampleUrl from './samples/Dsharp1v12.m4a?url&no-inline';
-import dSharp2SampleUrl from './samples/Dsharp2v12.m4a?url&no-inline';
-import dSharp3SampleUrl from './samples/Dsharp3v12.m4a?url&no-inline';
-import dSharp4SampleUrl from './samples/Dsharp4v12.m4a?url&no-inline';
-import dSharp5SampleUrl from './samples/Dsharp5v12.m4a?url&no-inline';
-import dSharp6SampleUrl from './samples/Dsharp6v12.m4a?url&no-inline';
-import dSharp7SampleUrl from './samples/Dsharp7v12.m4a?url&no-inline';
-import fSharp1SampleUrl from './samples/Fsharp1v12.m4a?url&no-inline';
-import fSharp2SampleUrl from './samples/Fsharp2v12.m4a?url&no-inline';
-import fSharp3SampleUrl from './samples/Fsharp3v12.m4a?url&no-inline';
-import fSharp4SampleUrl from './samples/Fsharp4v12.m4a?url&no-inline';
-import fSharp5SampleUrl from './samples/Fsharp5v12.m4a?url&no-inline';
-import fSharp6SampleUrl from './samples/Fsharp6v12.m4a?url&no-inline';
-import fSharp7SampleUrl from './samples/Fsharp7v12.m4a?url&no-inline';
+import type {
+ LoadedPianoReleaseSample,
+ LoadedPianoSamples,
+ LoadedPianoStrikeSample,
+} from './garden-audio-types';
-interface PianoSampleDefinition {
- note: string;
+interface PianoStrikeSampleDefinition {
+ kind: 'strike';
+ midi: number;
+ path: string;
+ url: string;
+ velocityLayer: number;
+}
+
+interface PianoReleaseSampleDefinition {
+ kind: 'release';
+ midi: number;
+ path: string;
url: string;
}
+type PianoSampleDefinition = PianoStrikeSampleDefinition | PianoReleaseSampleDefinition;
+
export interface PianoSampleLoadProgress {
failedCount: number;
loadedCount: number;
@@ -42,54 +28,28 @@ export interface PianoSampleLoadProgress {
totalCount: number;
}
-const pianoSampleDefinitions: Array = [
- { url: a0SampleUrl, note: 'A0' },
- { url: c1SampleUrl, note: 'C1' },
- { url: dSharp1SampleUrl, note: 'Dsharp1' },
- { url: fSharp1SampleUrl, note: 'Fsharp1' },
- { url: a1SampleUrl, note: 'A1' },
- { url: c2SampleUrl, note: 'C2' },
- { url: dSharp2SampleUrl, note: 'Dsharp2' },
- { url: fSharp2SampleUrl, note: 'Fsharp2' },
- { url: a2SampleUrl, note: 'A2' },
- { url: c3SampleUrl, note: 'C3' },
- { url: dSharp3SampleUrl, note: 'Dsharp3' },
- { url: fSharp3SampleUrl, note: 'Fsharp3' },
- { url: a3SampleUrl, note: 'A3' },
- { url: c4SampleUrl, note: 'C4' },
- { url: dSharp4SampleUrl, note: 'Dsharp4' },
- { url: fSharp4SampleUrl, note: 'Fsharp4' },
- { url: a4SampleUrl, note: 'A4' },
- { url: c5SampleUrl, note: 'C5' },
- { url: dSharp5SampleUrl, note: 'Dsharp5' },
- { url: fSharp5SampleUrl, note: 'Fsharp5' },
- { url: a5SampleUrl, note: 'A5' },
- { url: c6SampleUrl, note: 'C6' },
- { url: dSharp6SampleUrl, note: 'Dsharp6' },
- { url: fSharp6SampleUrl, note: 'Fsharp6' },
- { url: a6SampleUrl, note: 'A6' },
- { url: c7SampleUrl, note: 'C7' },
- { url: dSharp7SampleUrl, note: 'Dsharp7' },
- { url: fSharp7SampleUrl, note: 'Fsharp7' },
- { url: a7SampleUrl, note: 'A7' },
- { url: c8SampleUrl, note: 'C8' },
-];
+const pianoSampleModules = import.meta.glob('./samples/*.m4a', {
+ eager: true,
+ import: 'default',
+ query: '?url&no-inline',
+}) as Record;
+const pianoSampleDefinitions = getPianoSampleDefinitions(pianoSampleModules);
-let loadedPianoSamples: Array | null = null;
-let pianoSampleLoadPromise: Promise> | null = null;
+let loadedPianoSamples: LoadedPianoSamples | null = null;
+let pianoSampleLoadPromise: Promise | null = null;
let lastPianoSampleProgress: PianoSampleLoadProgress | null = null;
const pianoSampleProgressListeners = new Set<
(progress: PianoSampleLoadProgress) => void
>();
const sampleLoadTuning = {
- concurrency: 4,
+ concurrency: 6,
sampleTimeoutMs: 15_000,
};
export const preloadPianoSamples = (
onProgress?: (progress: PianoSampleLoadProgress) => void
-): Promise> => {
+): Promise => {
const OfflineAudioContextConstructor = globalThis.OfflineAudioContext;
if (!OfflineAudioContextConstructor) {
@@ -106,18 +66,19 @@ export const preloadPianoSamples = (
export const loadPianoSamples = (
decodeContext: BaseAudioContext,
onProgress?: (progress: PianoSampleLoadProgress) => void
-): Promise> => {
+): Promise => {
const unsubscribeProgress = subscribeToPianoSampleProgress(onProgress);
if (loadedPianoSamples) {
emitPianoSampleProgress({
failedCount: 0,
- loadedCount: loadedPianoSamples.length,
- settledCount: loadedPianoSamples.length,
+ loadedCount: loadedPianoSamples.strikes.length + loadedPianoSamples.releases.length,
+ settledCount:
+ loadedPianoSamples.strikes.length + loadedPianoSamples.releases.length,
totalCount: pianoSampleDefinitions.length,
});
unsubscribeProgress();
- return Promise.resolve([...loadedPianoSamples]);
+ return Promise.resolve(cloneLoadedPianoSamples(loadedPianoSamples));
}
if (pianoSampleLoadPromise) {
@@ -151,13 +112,15 @@ export const loadPianoSamples = (
)
.then(
(samples) => {
- loadedPianoSamples = samples.sort((a, b) => a.midi - b.midi);
- if (loadedPianoSamples.length !== pianoSampleDefinitions.length) {
+ loadedPianoSamples = sortLoadedPianoSamples(samples);
+ const loadedCount =
+ loadedPianoSamples.strikes.length + loadedPianoSamples.releases.length;
+ if (loadedCount !== pianoSampleDefinitions.length) {
throw new Error(
- `Loaded ${loadedPianoSamples.length}/${pianoSampleDefinitions.length} piano samples.`
+ `Loaded ${loadedCount}/${pianoSampleDefinitions.length} piano samples.`
);
}
- return [...loadedPianoSamples];
+ return cloneLoadedPianoSamples(loadedPianoSamples);
},
(error: unknown) => {
pianoSampleLoadPromise = null;
@@ -170,29 +133,38 @@ export const loadPianoSamples = (
return pianoSampleLoadPromise;
};
-export const getLoadedPianoSamples = (): Array | null =>
- loadedPianoSamples ? [...loadedPianoSamples] : null;
+export const getLoadedPianoSamples = (): LoadedPianoSamples | null =>
+ loadedPianoSamples ? cloneLoadedPianoSamples(loadedPianoSamples) : null;
const loadPianoSample = async (
decodeContext: BaseAudioContext,
sample: PianoSampleDefinition,
signal: AbortSignal
-): Promise => {
+): Promise => {
const response = await fetch(sample.url, { signal });
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 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 (
samples: Array,
- loadSample: (sample: PianoSampleDefinition) => Promise
-): Promise> => {
- const results: Array = [];
+ loadSample: (
+ sample: PianoSampleDefinition
+ ) => Promise
+): Promise> => {
+ const results: Array = [];
for (let index = 0; index < samples.length; 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));
};
-const getPianoSamplePath = (sample: PianoSampleDefinition): string =>
- `./samples/${sample.note}v12.m4a`;
+function getPianoSampleDefinitions(
+ modules: Record
+): Array {
+ 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 => {
- const match = /^(?[A-G])(?sharp)?(?\d+)$/.exec(sample.note);
+function getPianoSampleDefinition(path: string, url: string): PianoSampleDefinition {
+ const filename = path.split('/').pop() ?? path;
+ const strikeMatch = /^(?[A-G](?:sharp)?\d+)v(?\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(?\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 = /^(?[A-G])(?sharp)?(?\d+)$/.exec(note);
if (!match?.groups) {
- throw new Error(`Invalid piano sample note ${sample.note}`);
+ throw new Error(`Invalid piano sample note ${note}`);
}
const semitoneByName: Record = {
@@ -268,4 +277,25 @@ const getMidiForPianoSample = (sample: PianoSampleDefinition): number => {
const octave = Number(match.groups.octave);
const semitone = semitoneByName[match.groups.name] + (match.groups.accidental ? 1 : 0);
return (octave + 1) * 12 + semitone;
-};
+}
+
+function getMidiForReleaseSample(releaseIndex: number): number {
+ const pianoLowestMidi = 21;
+ return pianoLowestMidi + releaseIndex - 1;
+}
+
+const sortLoadedPianoSamples = (
+ samples: Array
+): 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],
+});
diff --git a/src/audio/samples/A0v12.m4a b/src/audio/samples/A0v12.m4a
index db06fc3..71c0564 100644
Binary files a/src/audio/samples/A0v12.m4a and b/src/audio/samples/A0v12.m4a differ
diff --git a/src/audio/samples/A0v16.m4a b/src/audio/samples/A0v16.m4a
new file mode 100644
index 0000000..aae9230
Binary files /dev/null and b/src/audio/samples/A0v16.m4a differ
diff --git a/src/audio/samples/A0v4.m4a b/src/audio/samples/A0v4.m4a
new file mode 100644
index 0000000..0679ac0
Binary files /dev/null and b/src/audio/samples/A0v4.m4a differ
diff --git a/src/audio/samples/A0v8.m4a b/src/audio/samples/A0v8.m4a
new file mode 100644
index 0000000..99195dd
Binary files /dev/null and b/src/audio/samples/A0v8.m4a differ
diff --git a/src/audio/samples/A1v12.m4a b/src/audio/samples/A1v12.m4a
index f1ed488..4d47370 100644
Binary files a/src/audio/samples/A1v12.m4a and b/src/audio/samples/A1v12.m4a differ
diff --git a/src/audio/samples/A1v16.m4a b/src/audio/samples/A1v16.m4a
new file mode 100644
index 0000000..a85872d
Binary files /dev/null and b/src/audio/samples/A1v16.m4a differ
diff --git a/src/audio/samples/A1v4.m4a b/src/audio/samples/A1v4.m4a
new file mode 100644
index 0000000..8095af9
Binary files /dev/null and b/src/audio/samples/A1v4.m4a differ
diff --git a/src/audio/samples/A1v8.m4a b/src/audio/samples/A1v8.m4a
new file mode 100644
index 0000000..de6d334
Binary files /dev/null and b/src/audio/samples/A1v8.m4a differ
diff --git a/src/audio/samples/A2v12.m4a b/src/audio/samples/A2v12.m4a
index 52df725..2ac10d3 100644
Binary files a/src/audio/samples/A2v12.m4a and b/src/audio/samples/A2v12.m4a differ
diff --git a/src/audio/samples/A2v16.m4a b/src/audio/samples/A2v16.m4a
new file mode 100644
index 0000000..8aa478c
Binary files /dev/null and b/src/audio/samples/A2v16.m4a differ
diff --git a/src/audio/samples/A2v4.m4a b/src/audio/samples/A2v4.m4a
new file mode 100644
index 0000000..f241c2e
Binary files /dev/null and b/src/audio/samples/A2v4.m4a differ
diff --git a/src/audio/samples/A2v8.m4a b/src/audio/samples/A2v8.m4a
new file mode 100644
index 0000000..ceaca7f
Binary files /dev/null and b/src/audio/samples/A2v8.m4a differ
diff --git a/src/audio/samples/A3v12.m4a b/src/audio/samples/A3v12.m4a
index 707a766..10cc72c 100644
Binary files a/src/audio/samples/A3v12.m4a and b/src/audio/samples/A3v12.m4a differ
diff --git a/src/audio/samples/A3v16.m4a b/src/audio/samples/A3v16.m4a
new file mode 100644
index 0000000..a100c90
Binary files /dev/null and b/src/audio/samples/A3v16.m4a differ
diff --git a/src/audio/samples/A3v4.m4a b/src/audio/samples/A3v4.m4a
new file mode 100644
index 0000000..0eae496
Binary files /dev/null and b/src/audio/samples/A3v4.m4a differ
diff --git a/src/audio/samples/A3v8.m4a b/src/audio/samples/A3v8.m4a
new file mode 100644
index 0000000..2158608
Binary files /dev/null and b/src/audio/samples/A3v8.m4a differ
diff --git a/src/audio/samples/A4v12.m4a b/src/audio/samples/A4v12.m4a
index 679bcff..7fe52f4 100644
Binary files a/src/audio/samples/A4v12.m4a and b/src/audio/samples/A4v12.m4a differ
diff --git a/src/audio/samples/A4v16.m4a b/src/audio/samples/A4v16.m4a
new file mode 100644
index 0000000..4d20781
Binary files /dev/null and b/src/audio/samples/A4v16.m4a differ
diff --git a/src/audio/samples/A4v4.m4a b/src/audio/samples/A4v4.m4a
new file mode 100644
index 0000000..4e55c94
Binary files /dev/null and b/src/audio/samples/A4v4.m4a differ
diff --git a/src/audio/samples/A4v8.m4a b/src/audio/samples/A4v8.m4a
new file mode 100644
index 0000000..335fa93
Binary files /dev/null and b/src/audio/samples/A4v8.m4a differ
diff --git a/src/audio/samples/A5v12.m4a b/src/audio/samples/A5v12.m4a
index 4a2c896..9d3d280 100644
Binary files a/src/audio/samples/A5v12.m4a and b/src/audio/samples/A5v12.m4a differ
diff --git a/src/audio/samples/A5v16.m4a b/src/audio/samples/A5v16.m4a
new file mode 100644
index 0000000..cd7c62f
Binary files /dev/null and b/src/audio/samples/A5v16.m4a differ
diff --git a/src/audio/samples/A5v4.m4a b/src/audio/samples/A5v4.m4a
new file mode 100644
index 0000000..c68176e
Binary files /dev/null and b/src/audio/samples/A5v4.m4a differ
diff --git a/src/audio/samples/A5v8.m4a b/src/audio/samples/A5v8.m4a
new file mode 100644
index 0000000..0afb0b9
Binary files /dev/null and b/src/audio/samples/A5v8.m4a differ
diff --git a/src/audio/samples/A6v12.m4a b/src/audio/samples/A6v12.m4a
index abbd605..9b8644d 100644
Binary files a/src/audio/samples/A6v12.m4a and b/src/audio/samples/A6v12.m4a differ
diff --git a/src/audio/samples/A6v16.m4a b/src/audio/samples/A6v16.m4a
new file mode 100644
index 0000000..26bcc55
Binary files /dev/null and b/src/audio/samples/A6v16.m4a differ
diff --git a/src/audio/samples/A6v4.m4a b/src/audio/samples/A6v4.m4a
new file mode 100644
index 0000000..6d37b98
Binary files /dev/null and b/src/audio/samples/A6v4.m4a differ
diff --git a/src/audio/samples/A6v8.m4a b/src/audio/samples/A6v8.m4a
new file mode 100644
index 0000000..bd89797
Binary files /dev/null and b/src/audio/samples/A6v8.m4a differ
diff --git a/src/audio/samples/A7v12.m4a b/src/audio/samples/A7v12.m4a
index 3fd6829..2a4193b 100644
Binary files a/src/audio/samples/A7v12.m4a and b/src/audio/samples/A7v12.m4a differ
diff --git a/src/audio/samples/A7v16.m4a b/src/audio/samples/A7v16.m4a
new file mode 100644
index 0000000..c0f0e52
Binary files /dev/null and b/src/audio/samples/A7v16.m4a differ
diff --git a/src/audio/samples/A7v4.m4a b/src/audio/samples/A7v4.m4a
new file mode 100644
index 0000000..2106892
Binary files /dev/null and b/src/audio/samples/A7v4.m4a differ
diff --git a/src/audio/samples/A7v8.m4a b/src/audio/samples/A7v8.m4a
new file mode 100644
index 0000000..a25a530
Binary files /dev/null and b/src/audio/samples/A7v8.m4a differ
diff --git a/src/audio/samples/C1v12.m4a b/src/audio/samples/C1v12.m4a
index 59d5f61..d94fd2e 100644
Binary files a/src/audio/samples/C1v12.m4a and b/src/audio/samples/C1v12.m4a differ
diff --git a/src/audio/samples/C1v16.m4a b/src/audio/samples/C1v16.m4a
new file mode 100644
index 0000000..f6c995d
Binary files /dev/null and b/src/audio/samples/C1v16.m4a differ
diff --git a/src/audio/samples/C1v4.m4a b/src/audio/samples/C1v4.m4a
new file mode 100644
index 0000000..5c2d043
Binary files /dev/null and b/src/audio/samples/C1v4.m4a differ
diff --git a/src/audio/samples/C1v8.m4a b/src/audio/samples/C1v8.m4a
new file mode 100644
index 0000000..febaf24
Binary files /dev/null and b/src/audio/samples/C1v8.m4a differ
diff --git a/src/audio/samples/C2v12.m4a b/src/audio/samples/C2v12.m4a
index 9b636f9..44bda1a 100644
Binary files a/src/audio/samples/C2v12.m4a and b/src/audio/samples/C2v12.m4a differ
diff --git a/src/audio/samples/C2v16.m4a b/src/audio/samples/C2v16.m4a
new file mode 100644
index 0000000..bb729d8
Binary files /dev/null and b/src/audio/samples/C2v16.m4a differ
diff --git a/src/audio/samples/C2v4.m4a b/src/audio/samples/C2v4.m4a
new file mode 100644
index 0000000..9ce27c9
Binary files /dev/null and b/src/audio/samples/C2v4.m4a differ
diff --git a/src/audio/samples/C2v8.m4a b/src/audio/samples/C2v8.m4a
new file mode 100644
index 0000000..3cd4b1c
Binary files /dev/null and b/src/audio/samples/C2v8.m4a differ
diff --git a/src/audio/samples/C3v12.m4a b/src/audio/samples/C3v12.m4a
index e891e16..a7c9af0 100644
Binary files a/src/audio/samples/C3v12.m4a and b/src/audio/samples/C3v12.m4a differ
diff --git a/src/audio/samples/C3v16.m4a b/src/audio/samples/C3v16.m4a
new file mode 100644
index 0000000..d6ad787
Binary files /dev/null and b/src/audio/samples/C3v16.m4a differ
diff --git a/src/audio/samples/C3v4.m4a b/src/audio/samples/C3v4.m4a
new file mode 100644
index 0000000..e850712
Binary files /dev/null and b/src/audio/samples/C3v4.m4a differ
diff --git a/src/audio/samples/C3v8.m4a b/src/audio/samples/C3v8.m4a
new file mode 100644
index 0000000..e781b6b
Binary files /dev/null and b/src/audio/samples/C3v8.m4a differ
diff --git a/src/audio/samples/C4v12.m4a b/src/audio/samples/C4v12.m4a
index 6061dc5..fb37963 100644
Binary files a/src/audio/samples/C4v12.m4a and b/src/audio/samples/C4v12.m4a differ
diff --git a/src/audio/samples/C4v16.m4a b/src/audio/samples/C4v16.m4a
new file mode 100644
index 0000000..eed90fa
Binary files /dev/null and b/src/audio/samples/C4v16.m4a differ
diff --git a/src/audio/samples/C4v4.m4a b/src/audio/samples/C4v4.m4a
new file mode 100644
index 0000000..7f1994f
Binary files /dev/null and b/src/audio/samples/C4v4.m4a differ
diff --git a/src/audio/samples/C4v8.m4a b/src/audio/samples/C4v8.m4a
new file mode 100644
index 0000000..9a45ff8
Binary files /dev/null and b/src/audio/samples/C4v8.m4a differ
diff --git a/src/audio/samples/C5v12.m4a b/src/audio/samples/C5v12.m4a
index a6d8898..3c08107 100644
Binary files a/src/audio/samples/C5v12.m4a and b/src/audio/samples/C5v12.m4a differ
diff --git a/src/audio/samples/C5v16.m4a b/src/audio/samples/C5v16.m4a
new file mode 100644
index 0000000..24edb5a
Binary files /dev/null and b/src/audio/samples/C5v16.m4a differ
diff --git a/src/audio/samples/C5v4.m4a b/src/audio/samples/C5v4.m4a
new file mode 100644
index 0000000..130ea2e
Binary files /dev/null and b/src/audio/samples/C5v4.m4a differ
diff --git a/src/audio/samples/C5v8.m4a b/src/audio/samples/C5v8.m4a
new file mode 100644
index 0000000..403b22d
Binary files /dev/null and b/src/audio/samples/C5v8.m4a differ
diff --git a/src/audio/samples/C6v12.m4a b/src/audio/samples/C6v12.m4a
index 745a4d6..d8268ff 100644
Binary files a/src/audio/samples/C6v12.m4a and b/src/audio/samples/C6v12.m4a differ
diff --git a/src/audio/samples/C6v16.m4a b/src/audio/samples/C6v16.m4a
new file mode 100644
index 0000000..8305844
Binary files /dev/null and b/src/audio/samples/C6v16.m4a differ
diff --git a/src/audio/samples/C6v4.m4a b/src/audio/samples/C6v4.m4a
new file mode 100644
index 0000000..8646b5b
Binary files /dev/null and b/src/audio/samples/C6v4.m4a differ
diff --git a/src/audio/samples/C6v8.m4a b/src/audio/samples/C6v8.m4a
new file mode 100644
index 0000000..f85e6a1
Binary files /dev/null and b/src/audio/samples/C6v8.m4a differ
diff --git a/src/audio/samples/C7v12.m4a b/src/audio/samples/C7v12.m4a
index 6470854..d3bcee5 100644
Binary files a/src/audio/samples/C7v12.m4a and b/src/audio/samples/C7v12.m4a differ
diff --git a/src/audio/samples/C7v16.m4a b/src/audio/samples/C7v16.m4a
new file mode 100644
index 0000000..0211360
Binary files /dev/null and b/src/audio/samples/C7v16.m4a differ
diff --git a/src/audio/samples/C7v4.m4a b/src/audio/samples/C7v4.m4a
new file mode 100644
index 0000000..a2e2d2d
Binary files /dev/null and b/src/audio/samples/C7v4.m4a differ
diff --git a/src/audio/samples/C7v8.m4a b/src/audio/samples/C7v8.m4a
new file mode 100644
index 0000000..6113888
Binary files /dev/null and b/src/audio/samples/C7v8.m4a differ
diff --git a/src/audio/samples/C8v12.m4a b/src/audio/samples/C8v12.m4a
index dfbbfd1..584f5ff 100644
Binary files a/src/audio/samples/C8v12.m4a and b/src/audio/samples/C8v12.m4a differ
diff --git a/src/audio/samples/C8v16.m4a b/src/audio/samples/C8v16.m4a
new file mode 100644
index 0000000..b8d2aa8
Binary files /dev/null and b/src/audio/samples/C8v16.m4a differ
diff --git a/src/audio/samples/C8v4.m4a b/src/audio/samples/C8v4.m4a
new file mode 100644
index 0000000..ff91df5
Binary files /dev/null and b/src/audio/samples/C8v4.m4a differ
diff --git a/src/audio/samples/C8v8.m4a b/src/audio/samples/C8v8.m4a
new file mode 100644
index 0000000..32e8a04
Binary files /dev/null and b/src/audio/samples/C8v8.m4a differ
diff --git a/src/audio/samples/Dsharp1v12.m4a b/src/audio/samples/Dsharp1v12.m4a
index 22d0924..0f7d897 100644
Binary files a/src/audio/samples/Dsharp1v12.m4a and b/src/audio/samples/Dsharp1v12.m4a differ
diff --git a/src/audio/samples/Dsharp1v16.m4a b/src/audio/samples/Dsharp1v16.m4a
new file mode 100644
index 0000000..fe422e2
Binary files /dev/null and b/src/audio/samples/Dsharp1v16.m4a differ
diff --git a/src/audio/samples/Dsharp1v4.m4a b/src/audio/samples/Dsharp1v4.m4a
new file mode 100644
index 0000000..03e7208
Binary files /dev/null and b/src/audio/samples/Dsharp1v4.m4a differ
diff --git a/src/audio/samples/Dsharp1v8.m4a b/src/audio/samples/Dsharp1v8.m4a
new file mode 100644
index 0000000..b5de786
Binary files /dev/null and b/src/audio/samples/Dsharp1v8.m4a differ
diff --git a/src/audio/samples/Dsharp2v12.m4a b/src/audio/samples/Dsharp2v12.m4a
index f25db22..a2392da 100644
Binary files a/src/audio/samples/Dsharp2v12.m4a and b/src/audio/samples/Dsharp2v12.m4a differ
diff --git a/src/audio/samples/Dsharp2v16.m4a b/src/audio/samples/Dsharp2v16.m4a
new file mode 100644
index 0000000..264716e
Binary files /dev/null and b/src/audio/samples/Dsharp2v16.m4a differ
diff --git a/src/audio/samples/Dsharp2v4.m4a b/src/audio/samples/Dsharp2v4.m4a
new file mode 100644
index 0000000..6984fbc
Binary files /dev/null and b/src/audio/samples/Dsharp2v4.m4a differ
diff --git a/src/audio/samples/Dsharp2v8.m4a b/src/audio/samples/Dsharp2v8.m4a
new file mode 100644
index 0000000..0461578
Binary files /dev/null and b/src/audio/samples/Dsharp2v8.m4a differ
diff --git a/src/audio/samples/Dsharp3v12.m4a b/src/audio/samples/Dsharp3v12.m4a
index 7e09558..f4d7398 100644
Binary files a/src/audio/samples/Dsharp3v12.m4a and b/src/audio/samples/Dsharp3v12.m4a differ
diff --git a/src/audio/samples/Dsharp3v16.m4a b/src/audio/samples/Dsharp3v16.m4a
new file mode 100644
index 0000000..3a48ed5
Binary files /dev/null and b/src/audio/samples/Dsharp3v16.m4a differ
diff --git a/src/audio/samples/Dsharp3v4.m4a b/src/audio/samples/Dsharp3v4.m4a
new file mode 100644
index 0000000..a3d205d
Binary files /dev/null and b/src/audio/samples/Dsharp3v4.m4a differ
diff --git a/src/audio/samples/Dsharp3v8.m4a b/src/audio/samples/Dsharp3v8.m4a
new file mode 100644
index 0000000..17b16e1
Binary files /dev/null and b/src/audio/samples/Dsharp3v8.m4a differ
diff --git a/src/audio/samples/Dsharp4v12.m4a b/src/audio/samples/Dsharp4v12.m4a
index d670fbb..42fbf3a 100644
Binary files a/src/audio/samples/Dsharp4v12.m4a and b/src/audio/samples/Dsharp4v12.m4a differ
diff --git a/src/audio/samples/Dsharp4v16.m4a b/src/audio/samples/Dsharp4v16.m4a
new file mode 100644
index 0000000..2c2cd57
Binary files /dev/null and b/src/audio/samples/Dsharp4v16.m4a differ
diff --git a/src/audio/samples/Dsharp4v4.m4a b/src/audio/samples/Dsharp4v4.m4a
new file mode 100644
index 0000000..9591c46
Binary files /dev/null and b/src/audio/samples/Dsharp4v4.m4a differ
diff --git a/src/audio/samples/Dsharp4v8.m4a b/src/audio/samples/Dsharp4v8.m4a
new file mode 100644
index 0000000..734a779
Binary files /dev/null and b/src/audio/samples/Dsharp4v8.m4a differ
diff --git a/src/audio/samples/Dsharp5v12.m4a b/src/audio/samples/Dsharp5v12.m4a
index cdbd7b8..9083dd0 100644
Binary files a/src/audio/samples/Dsharp5v12.m4a and b/src/audio/samples/Dsharp5v12.m4a differ
diff --git a/src/audio/samples/Dsharp5v16.m4a b/src/audio/samples/Dsharp5v16.m4a
new file mode 100644
index 0000000..33bfab4
Binary files /dev/null and b/src/audio/samples/Dsharp5v16.m4a differ
diff --git a/src/audio/samples/Dsharp5v4.m4a b/src/audio/samples/Dsharp5v4.m4a
new file mode 100644
index 0000000..fa6c930
Binary files /dev/null and b/src/audio/samples/Dsharp5v4.m4a differ
diff --git a/src/audio/samples/Dsharp5v8.m4a b/src/audio/samples/Dsharp5v8.m4a
new file mode 100644
index 0000000..21dce22
Binary files /dev/null and b/src/audio/samples/Dsharp5v8.m4a differ
diff --git a/src/audio/samples/Dsharp6v12.m4a b/src/audio/samples/Dsharp6v12.m4a
index b5ff787..aaf59ee 100644
Binary files a/src/audio/samples/Dsharp6v12.m4a and b/src/audio/samples/Dsharp6v12.m4a differ
diff --git a/src/audio/samples/Dsharp6v16.m4a b/src/audio/samples/Dsharp6v16.m4a
new file mode 100644
index 0000000..58762f4
Binary files /dev/null and b/src/audio/samples/Dsharp6v16.m4a differ
diff --git a/src/audio/samples/Dsharp6v4.m4a b/src/audio/samples/Dsharp6v4.m4a
new file mode 100644
index 0000000..b617ba3
Binary files /dev/null and b/src/audio/samples/Dsharp6v4.m4a differ
diff --git a/src/audio/samples/Dsharp6v8.m4a b/src/audio/samples/Dsharp6v8.m4a
new file mode 100644
index 0000000..a7704ea
Binary files /dev/null and b/src/audio/samples/Dsharp6v8.m4a differ
diff --git a/src/audio/samples/Dsharp7v12.m4a b/src/audio/samples/Dsharp7v12.m4a
index a9b6cda..950034f 100644
Binary files a/src/audio/samples/Dsharp7v12.m4a and b/src/audio/samples/Dsharp7v12.m4a differ
diff --git a/src/audio/samples/Dsharp7v16.m4a b/src/audio/samples/Dsharp7v16.m4a
new file mode 100644
index 0000000..81667cb
Binary files /dev/null and b/src/audio/samples/Dsharp7v16.m4a differ
diff --git a/src/audio/samples/Dsharp7v4.m4a b/src/audio/samples/Dsharp7v4.m4a
new file mode 100644
index 0000000..d49a972
Binary files /dev/null and b/src/audio/samples/Dsharp7v4.m4a differ
diff --git a/src/audio/samples/Dsharp7v8.m4a b/src/audio/samples/Dsharp7v8.m4a
new file mode 100644
index 0000000..c57ebae
Binary files /dev/null and b/src/audio/samples/Dsharp7v8.m4a differ
diff --git a/src/audio/samples/Fsharp1v12.m4a b/src/audio/samples/Fsharp1v12.m4a
index 752590f..3b941c8 100644
Binary files a/src/audio/samples/Fsharp1v12.m4a and b/src/audio/samples/Fsharp1v12.m4a differ
diff --git a/src/audio/samples/Fsharp1v16.m4a b/src/audio/samples/Fsharp1v16.m4a
new file mode 100644
index 0000000..71c269b
Binary files /dev/null and b/src/audio/samples/Fsharp1v16.m4a differ
diff --git a/src/audio/samples/Fsharp1v4.m4a b/src/audio/samples/Fsharp1v4.m4a
new file mode 100644
index 0000000..c12ccb1
Binary files /dev/null and b/src/audio/samples/Fsharp1v4.m4a differ
diff --git a/src/audio/samples/Fsharp1v8.m4a b/src/audio/samples/Fsharp1v8.m4a
new file mode 100644
index 0000000..799e5ee
Binary files /dev/null and b/src/audio/samples/Fsharp1v8.m4a differ
diff --git a/src/audio/samples/Fsharp2v12.m4a b/src/audio/samples/Fsharp2v12.m4a
index 3477cb8..2779ee3 100644
Binary files a/src/audio/samples/Fsharp2v12.m4a and b/src/audio/samples/Fsharp2v12.m4a differ
diff --git a/src/audio/samples/Fsharp2v16.m4a b/src/audio/samples/Fsharp2v16.m4a
new file mode 100644
index 0000000..219ab97
Binary files /dev/null and b/src/audio/samples/Fsharp2v16.m4a differ
diff --git a/src/audio/samples/Fsharp2v4.m4a b/src/audio/samples/Fsharp2v4.m4a
new file mode 100644
index 0000000..ca98511
Binary files /dev/null and b/src/audio/samples/Fsharp2v4.m4a differ
diff --git a/src/audio/samples/Fsharp2v8.m4a b/src/audio/samples/Fsharp2v8.m4a
new file mode 100644
index 0000000..e3a9480
Binary files /dev/null and b/src/audio/samples/Fsharp2v8.m4a differ
diff --git a/src/audio/samples/Fsharp3v12.m4a b/src/audio/samples/Fsharp3v12.m4a
index d36f8dd..85424f5 100644
Binary files a/src/audio/samples/Fsharp3v12.m4a and b/src/audio/samples/Fsharp3v12.m4a differ
diff --git a/src/audio/samples/Fsharp3v16.m4a b/src/audio/samples/Fsharp3v16.m4a
new file mode 100644
index 0000000..939721f
Binary files /dev/null and b/src/audio/samples/Fsharp3v16.m4a differ
diff --git a/src/audio/samples/Fsharp3v4.m4a b/src/audio/samples/Fsharp3v4.m4a
new file mode 100644
index 0000000..d64957d
Binary files /dev/null and b/src/audio/samples/Fsharp3v4.m4a differ
diff --git a/src/audio/samples/Fsharp3v8.m4a b/src/audio/samples/Fsharp3v8.m4a
new file mode 100644
index 0000000..5dd56f5
Binary files /dev/null and b/src/audio/samples/Fsharp3v8.m4a differ
diff --git a/src/audio/samples/Fsharp4v12.m4a b/src/audio/samples/Fsharp4v12.m4a
index 21df1e2..81309f1 100644
Binary files a/src/audio/samples/Fsharp4v12.m4a and b/src/audio/samples/Fsharp4v12.m4a differ
diff --git a/src/audio/samples/Fsharp4v16.m4a b/src/audio/samples/Fsharp4v16.m4a
new file mode 100644
index 0000000..2b60946
Binary files /dev/null and b/src/audio/samples/Fsharp4v16.m4a differ
diff --git a/src/audio/samples/Fsharp4v4.m4a b/src/audio/samples/Fsharp4v4.m4a
new file mode 100644
index 0000000..553e183
Binary files /dev/null and b/src/audio/samples/Fsharp4v4.m4a differ
diff --git a/src/audio/samples/Fsharp4v8.m4a b/src/audio/samples/Fsharp4v8.m4a
new file mode 100644
index 0000000..3d9454b
Binary files /dev/null and b/src/audio/samples/Fsharp4v8.m4a differ
diff --git a/src/audio/samples/Fsharp5v12.m4a b/src/audio/samples/Fsharp5v12.m4a
index 1105dfb..d09214c 100644
Binary files a/src/audio/samples/Fsharp5v12.m4a and b/src/audio/samples/Fsharp5v12.m4a differ
diff --git a/src/audio/samples/Fsharp5v16.m4a b/src/audio/samples/Fsharp5v16.m4a
new file mode 100644
index 0000000..b43b919
Binary files /dev/null and b/src/audio/samples/Fsharp5v16.m4a differ
diff --git a/src/audio/samples/Fsharp5v4.m4a b/src/audio/samples/Fsharp5v4.m4a
new file mode 100644
index 0000000..c3eaac2
Binary files /dev/null and b/src/audio/samples/Fsharp5v4.m4a differ
diff --git a/src/audio/samples/Fsharp5v8.m4a b/src/audio/samples/Fsharp5v8.m4a
new file mode 100644
index 0000000..79fb2f6
Binary files /dev/null and b/src/audio/samples/Fsharp5v8.m4a differ
diff --git a/src/audio/samples/Fsharp6v12.m4a b/src/audio/samples/Fsharp6v12.m4a
index d141d41..b92adb7 100644
Binary files a/src/audio/samples/Fsharp6v12.m4a and b/src/audio/samples/Fsharp6v12.m4a differ
diff --git a/src/audio/samples/Fsharp6v16.m4a b/src/audio/samples/Fsharp6v16.m4a
new file mode 100644
index 0000000..9b228c5
Binary files /dev/null and b/src/audio/samples/Fsharp6v16.m4a differ
diff --git a/src/audio/samples/Fsharp6v4.m4a b/src/audio/samples/Fsharp6v4.m4a
new file mode 100644
index 0000000..c9a1691
Binary files /dev/null and b/src/audio/samples/Fsharp6v4.m4a differ
diff --git a/src/audio/samples/Fsharp6v8.m4a b/src/audio/samples/Fsharp6v8.m4a
new file mode 100644
index 0000000..7c122bd
Binary files /dev/null and b/src/audio/samples/Fsharp6v8.m4a differ
diff --git a/src/audio/samples/Fsharp7v12.m4a b/src/audio/samples/Fsharp7v12.m4a
index d69ac59..2ad18ba 100644
Binary files a/src/audio/samples/Fsharp7v12.m4a and b/src/audio/samples/Fsharp7v12.m4a differ
diff --git a/src/audio/samples/Fsharp7v16.m4a b/src/audio/samples/Fsharp7v16.m4a
new file mode 100644
index 0000000..e4644a2
Binary files /dev/null and b/src/audio/samples/Fsharp7v16.m4a differ
diff --git a/src/audio/samples/Fsharp7v4.m4a b/src/audio/samples/Fsharp7v4.m4a
new file mode 100644
index 0000000..f3f9787
Binary files /dev/null and b/src/audio/samples/Fsharp7v4.m4a differ
diff --git a/src/audio/samples/Fsharp7v8.m4a b/src/audio/samples/Fsharp7v8.m4a
new file mode 100644
index 0000000..d9020b6
Binary files /dev/null and b/src/audio/samples/Fsharp7v8.m4a differ
diff --git a/src/audio/samples/README.md b/src/audio/samples/README.md
index bdde746..b37247b 100644
--- a/src/audio/samples/README.md
+++ b/src/audio/samples/README.md
@@ -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
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
License: https://creativecommons.org/licenses/by/3.0/
-Checked-in subset: velocity layer `v12`, every minor-third anchor from A0
-through C8: A, C, Dsharp, and Fsharp for octaves 1-7, plus A0, A7, and C8.
-The app derives MIDI values from those note names in `piano-samples.ts`.
+Checked-in subset: velocity layers `v4`, `v8`, `v12`, and `v16` at the
+available Salamander strike anchors, plus all 88 release samples. The strike
+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
-transcode each selected sample to AAC/M4A without renaming the note/velocity
-stem. The expected output filenames are `v12.m4a`, for example
-`C4v12.m4a`.
+Repro notes: start from the matching OGG files in the source packages and
+transcode each selected sample to AAC/M4A at 192 kbps without renaming the
+note/velocity or release stem. Replace `#` with `sharp` in filenames for URL
+compatibility. Expected output filenames include `v.m4a`, for
+example `C4v16.m4a`, and `rel.m4a`, for example `rel40.m4a`.
diff --git a/src/audio/samples/rel1.m4a b/src/audio/samples/rel1.m4a
new file mode 100644
index 0000000..8cf2d5b
Binary files /dev/null and b/src/audio/samples/rel1.m4a differ
diff --git a/src/audio/samples/rel10.m4a b/src/audio/samples/rel10.m4a
new file mode 100644
index 0000000..6cad512
Binary files /dev/null and b/src/audio/samples/rel10.m4a differ
diff --git a/src/audio/samples/rel11.m4a b/src/audio/samples/rel11.m4a
new file mode 100644
index 0000000..0b386b3
Binary files /dev/null and b/src/audio/samples/rel11.m4a differ
diff --git a/src/audio/samples/rel12.m4a b/src/audio/samples/rel12.m4a
new file mode 100644
index 0000000..c870544
Binary files /dev/null and b/src/audio/samples/rel12.m4a differ
diff --git a/src/audio/samples/rel13.m4a b/src/audio/samples/rel13.m4a
new file mode 100644
index 0000000..5849362
Binary files /dev/null and b/src/audio/samples/rel13.m4a differ
diff --git a/src/audio/samples/rel14.m4a b/src/audio/samples/rel14.m4a
new file mode 100644
index 0000000..0dfc5a0
Binary files /dev/null and b/src/audio/samples/rel14.m4a differ
diff --git a/src/audio/samples/rel15.m4a b/src/audio/samples/rel15.m4a
new file mode 100644
index 0000000..54b6acf
Binary files /dev/null and b/src/audio/samples/rel15.m4a differ
diff --git a/src/audio/samples/rel16.m4a b/src/audio/samples/rel16.m4a
new file mode 100644
index 0000000..863f1ff
Binary files /dev/null and b/src/audio/samples/rel16.m4a differ
diff --git a/src/audio/samples/rel17.m4a b/src/audio/samples/rel17.m4a
new file mode 100644
index 0000000..591ee71
Binary files /dev/null and b/src/audio/samples/rel17.m4a differ
diff --git a/src/audio/samples/rel18.m4a b/src/audio/samples/rel18.m4a
new file mode 100644
index 0000000..77533bf
Binary files /dev/null and b/src/audio/samples/rel18.m4a differ
diff --git a/src/audio/samples/rel19.m4a b/src/audio/samples/rel19.m4a
new file mode 100644
index 0000000..89c7b27
Binary files /dev/null and b/src/audio/samples/rel19.m4a differ
diff --git a/src/audio/samples/rel2.m4a b/src/audio/samples/rel2.m4a
new file mode 100644
index 0000000..dd5ab64
Binary files /dev/null and b/src/audio/samples/rel2.m4a differ
diff --git a/src/audio/samples/rel20.m4a b/src/audio/samples/rel20.m4a
new file mode 100644
index 0000000..874cbfe
Binary files /dev/null and b/src/audio/samples/rel20.m4a differ
diff --git a/src/audio/samples/rel21.m4a b/src/audio/samples/rel21.m4a
new file mode 100644
index 0000000..daafbef
Binary files /dev/null and b/src/audio/samples/rel21.m4a differ
diff --git a/src/audio/samples/rel22.m4a b/src/audio/samples/rel22.m4a
new file mode 100644
index 0000000..0dbe681
Binary files /dev/null and b/src/audio/samples/rel22.m4a differ
diff --git a/src/audio/samples/rel23.m4a b/src/audio/samples/rel23.m4a
new file mode 100644
index 0000000..ff51cbc
Binary files /dev/null and b/src/audio/samples/rel23.m4a differ
diff --git a/src/audio/samples/rel24.m4a b/src/audio/samples/rel24.m4a
new file mode 100644
index 0000000..b5515e5
Binary files /dev/null and b/src/audio/samples/rel24.m4a differ
diff --git a/src/audio/samples/rel25.m4a b/src/audio/samples/rel25.m4a
new file mode 100644
index 0000000..12ccd06
Binary files /dev/null and b/src/audio/samples/rel25.m4a differ
diff --git a/src/audio/samples/rel26.m4a b/src/audio/samples/rel26.m4a
new file mode 100644
index 0000000..81e3075
Binary files /dev/null and b/src/audio/samples/rel26.m4a differ
diff --git a/src/audio/samples/rel27.m4a b/src/audio/samples/rel27.m4a
new file mode 100644
index 0000000..25ebe1e
Binary files /dev/null and b/src/audio/samples/rel27.m4a differ
diff --git a/src/audio/samples/rel28.m4a b/src/audio/samples/rel28.m4a
new file mode 100644
index 0000000..d49226d
Binary files /dev/null and b/src/audio/samples/rel28.m4a differ
diff --git a/src/audio/samples/rel29.m4a b/src/audio/samples/rel29.m4a
new file mode 100644
index 0000000..6fd7267
Binary files /dev/null and b/src/audio/samples/rel29.m4a differ
diff --git a/src/audio/samples/rel3.m4a b/src/audio/samples/rel3.m4a
new file mode 100644
index 0000000..9d67e09
Binary files /dev/null and b/src/audio/samples/rel3.m4a differ
diff --git a/src/audio/samples/rel30.m4a b/src/audio/samples/rel30.m4a
new file mode 100644
index 0000000..3312f64
Binary files /dev/null and b/src/audio/samples/rel30.m4a differ
diff --git a/src/audio/samples/rel31.m4a b/src/audio/samples/rel31.m4a
new file mode 100644
index 0000000..2543ce7
Binary files /dev/null and b/src/audio/samples/rel31.m4a differ
diff --git a/src/audio/samples/rel32.m4a b/src/audio/samples/rel32.m4a
new file mode 100644
index 0000000..b06a0f9
Binary files /dev/null and b/src/audio/samples/rel32.m4a differ
diff --git a/src/audio/samples/rel33.m4a b/src/audio/samples/rel33.m4a
new file mode 100644
index 0000000..40de0b5
Binary files /dev/null and b/src/audio/samples/rel33.m4a differ
diff --git a/src/audio/samples/rel34.m4a b/src/audio/samples/rel34.m4a
new file mode 100644
index 0000000..52761b1
Binary files /dev/null and b/src/audio/samples/rel34.m4a differ
diff --git a/src/audio/samples/rel35.m4a b/src/audio/samples/rel35.m4a
new file mode 100644
index 0000000..3603cf2
Binary files /dev/null and b/src/audio/samples/rel35.m4a differ
diff --git a/src/audio/samples/rel36.m4a b/src/audio/samples/rel36.m4a
new file mode 100644
index 0000000..e16e5f4
Binary files /dev/null and b/src/audio/samples/rel36.m4a differ
diff --git a/src/audio/samples/rel37.m4a b/src/audio/samples/rel37.m4a
new file mode 100644
index 0000000..c0c316c
Binary files /dev/null and b/src/audio/samples/rel37.m4a differ
diff --git a/src/audio/samples/rel38.m4a b/src/audio/samples/rel38.m4a
new file mode 100644
index 0000000..4b1496f
Binary files /dev/null and b/src/audio/samples/rel38.m4a differ
diff --git a/src/audio/samples/rel39.m4a b/src/audio/samples/rel39.m4a
new file mode 100644
index 0000000..af13d13
Binary files /dev/null and b/src/audio/samples/rel39.m4a differ
diff --git a/src/audio/samples/rel4.m4a b/src/audio/samples/rel4.m4a
new file mode 100644
index 0000000..addaa1f
Binary files /dev/null and b/src/audio/samples/rel4.m4a differ
diff --git a/src/audio/samples/rel40.m4a b/src/audio/samples/rel40.m4a
new file mode 100644
index 0000000..41fb5b8
Binary files /dev/null and b/src/audio/samples/rel40.m4a differ
diff --git a/src/audio/samples/rel41.m4a b/src/audio/samples/rel41.m4a
new file mode 100644
index 0000000..bfa11c8
Binary files /dev/null and b/src/audio/samples/rel41.m4a differ
diff --git a/src/audio/samples/rel42.m4a b/src/audio/samples/rel42.m4a
new file mode 100644
index 0000000..d6439fa
Binary files /dev/null and b/src/audio/samples/rel42.m4a differ
diff --git a/src/audio/samples/rel43.m4a b/src/audio/samples/rel43.m4a
new file mode 100644
index 0000000..5b94878
Binary files /dev/null and b/src/audio/samples/rel43.m4a differ
diff --git a/src/audio/samples/rel44.m4a b/src/audio/samples/rel44.m4a
new file mode 100644
index 0000000..ba6b1d3
Binary files /dev/null and b/src/audio/samples/rel44.m4a differ
diff --git a/src/audio/samples/rel45.m4a b/src/audio/samples/rel45.m4a
new file mode 100644
index 0000000..4eab6bc
Binary files /dev/null and b/src/audio/samples/rel45.m4a differ
diff --git a/src/audio/samples/rel46.m4a b/src/audio/samples/rel46.m4a
new file mode 100644
index 0000000..42a0fb3
Binary files /dev/null and b/src/audio/samples/rel46.m4a differ
diff --git a/src/audio/samples/rel47.m4a b/src/audio/samples/rel47.m4a
new file mode 100644
index 0000000..b7fa1a7
Binary files /dev/null and b/src/audio/samples/rel47.m4a differ
diff --git a/src/audio/samples/rel48.m4a b/src/audio/samples/rel48.m4a
new file mode 100644
index 0000000..3234c08
Binary files /dev/null and b/src/audio/samples/rel48.m4a differ
diff --git a/src/audio/samples/rel49.m4a b/src/audio/samples/rel49.m4a
new file mode 100644
index 0000000..6e49637
Binary files /dev/null and b/src/audio/samples/rel49.m4a differ
diff --git a/src/audio/samples/rel5.m4a b/src/audio/samples/rel5.m4a
new file mode 100644
index 0000000..17c9856
Binary files /dev/null and b/src/audio/samples/rel5.m4a differ
diff --git a/src/audio/samples/rel50.m4a b/src/audio/samples/rel50.m4a
new file mode 100644
index 0000000..dd01182
Binary files /dev/null and b/src/audio/samples/rel50.m4a differ
diff --git a/src/audio/samples/rel51.m4a b/src/audio/samples/rel51.m4a
new file mode 100644
index 0000000..c875276
Binary files /dev/null and b/src/audio/samples/rel51.m4a differ
diff --git a/src/audio/samples/rel52.m4a b/src/audio/samples/rel52.m4a
new file mode 100644
index 0000000..d49ebb4
Binary files /dev/null and b/src/audio/samples/rel52.m4a differ
diff --git a/src/audio/samples/rel53.m4a b/src/audio/samples/rel53.m4a
new file mode 100644
index 0000000..d599e0d
Binary files /dev/null and b/src/audio/samples/rel53.m4a differ
diff --git a/src/audio/samples/rel54.m4a b/src/audio/samples/rel54.m4a
new file mode 100644
index 0000000..7dc6bd5
Binary files /dev/null and b/src/audio/samples/rel54.m4a differ
diff --git a/src/audio/samples/rel55.m4a b/src/audio/samples/rel55.m4a
new file mode 100644
index 0000000..4534017
Binary files /dev/null and b/src/audio/samples/rel55.m4a differ
diff --git a/src/audio/samples/rel56.m4a b/src/audio/samples/rel56.m4a
new file mode 100644
index 0000000..aac1106
Binary files /dev/null and b/src/audio/samples/rel56.m4a differ
diff --git a/src/audio/samples/rel57.m4a b/src/audio/samples/rel57.m4a
new file mode 100644
index 0000000..a6b637b
Binary files /dev/null and b/src/audio/samples/rel57.m4a differ
diff --git a/src/audio/samples/rel58.m4a b/src/audio/samples/rel58.m4a
new file mode 100644
index 0000000..311e6bc
Binary files /dev/null and b/src/audio/samples/rel58.m4a differ
diff --git a/src/audio/samples/rel59.m4a b/src/audio/samples/rel59.m4a
new file mode 100644
index 0000000..6dc7ddc
Binary files /dev/null and b/src/audio/samples/rel59.m4a differ
diff --git a/src/audio/samples/rel6.m4a b/src/audio/samples/rel6.m4a
new file mode 100644
index 0000000..e805093
Binary files /dev/null and b/src/audio/samples/rel6.m4a differ
diff --git a/src/audio/samples/rel60.m4a b/src/audio/samples/rel60.m4a
new file mode 100644
index 0000000..60568da
Binary files /dev/null and b/src/audio/samples/rel60.m4a differ
diff --git a/src/audio/samples/rel61.m4a b/src/audio/samples/rel61.m4a
new file mode 100644
index 0000000..42e57d3
Binary files /dev/null and b/src/audio/samples/rel61.m4a differ
diff --git a/src/audio/samples/rel62.m4a b/src/audio/samples/rel62.m4a
new file mode 100644
index 0000000..386bc6f
Binary files /dev/null and b/src/audio/samples/rel62.m4a differ
diff --git a/src/audio/samples/rel63.m4a b/src/audio/samples/rel63.m4a
new file mode 100644
index 0000000..a2a5fc5
Binary files /dev/null and b/src/audio/samples/rel63.m4a differ
diff --git a/src/audio/samples/rel64.m4a b/src/audio/samples/rel64.m4a
new file mode 100644
index 0000000..b0795b0
Binary files /dev/null and b/src/audio/samples/rel64.m4a differ
diff --git a/src/audio/samples/rel65.m4a b/src/audio/samples/rel65.m4a
new file mode 100644
index 0000000..c51e877
Binary files /dev/null and b/src/audio/samples/rel65.m4a differ
diff --git a/src/audio/samples/rel66.m4a b/src/audio/samples/rel66.m4a
new file mode 100644
index 0000000..7307df4
Binary files /dev/null and b/src/audio/samples/rel66.m4a differ
diff --git a/src/audio/samples/rel67.m4a b/src/audio/samples/rel67.m4a
new file mode 100644
index 0000000..ede7dd2
Binary files /dev/null and b/src/audio/samples/rel67.m4a differ
diff --git a/src/audio/samples/rel68.m4a b/src/audio/samples/rel68.m4a
new file mode 100644
index 0000000..25d123f
Binary files /dev/null and b/src/audio/samples/rel68.m4a differ
diff --git a/src/audio/samples/rel69.m4a b/src/audio/samples/rel69.m4a
new file mode 100644
index 0000000..1102c0e
Binary files /dev/null and b/src/audio/samples/rel69.m4a differ
diff --git a/src/audio/samples/rel7.m4a b/src/audio/samples/rel7.m4a
new file mode 100644
index 0000000..91f5ef6
Binary files /dev/null and b/src/audio/samples/rel7.m4a differ
diff --git a/src/audio/samples/rel70.m4a b/src/audio/samples/rel70.m4a
new file mode 100644
index 0000000..7771d47
Binary files /dev/null and b/src/audio/samples/rel70.m4a differ
diff --git a/src/audio/samples/rel71.m4a b/src/audio/samples/rel71.m4a
new file mode 100644
index 0000000..03fcfd4
Binary files /dev/null and b/src/audio/samples/rel71.m4a differ
diff --git a/src/audio/samples/rel72.m4a b/src/audio/samples/rel72.m4a
new file mode 100644
index 0000000..f945bb9
Binary files /dev/null and b/src/audio/samples/rel72.m4a differ
diff --git a/src/audio/samples/rel73.m4a b/src/audio/samples/rel73.m4a
new file mode 100644
index 0000000..104ba8d
Binary files /dev/null and b/src/audio/samples/rel73.m4a differ
diff --git a/src/audio/samples/rel74.m4a b/src/audio/samples/rel74.m4a
new file mode 100644
index 0000000..88c6f8d
Binary files /dev/null and b/src/audio/samples/rel74.m4a differ
diff --git a/src/audio/samples/rel75.m4a b/src/audio/samples/rel75.m4a
new file mode 100644
index 0000000..8e32dd2
Binary files /dev/null and b/src/audio/samples/rel75.m4a differ
diff --git a/src/audio/samples/rel76.m4a b/src/audio/samples/rel76.m4a
new file mode 100644
index 0000000..e95f09d
Binary files /dev/null and b/src/audio/samples/rel76.m4a differ
diff --git a/src/audio/samples/rel77.m4a b/src/audio/samples/rel77.m4a
new file mode 100644
index 0000000..dab0b10
Binary files /dev/null and b/src/audio/samples/rel77.m4a differ
diff --git a/src/audio/samples/rel78.m4a b/src/audio/samples/rel78.m4a
new file mode 100644
index 0000000..0c9808c
Binary files /dev/null and b/src/audio/samples/rel78.m4a differ
diff --git a/src/audio/samples/rel79.m4a b/src/audio/samples/rel79.m4a
new file mode 100644
index 0000000..75d2f1e
Binary files /dev/null and b/src/audio/samples/rel79.m4a differ
diff --git a/src/audio/samples/rel8.m4a b/src/audio/samples/rel8.m4a
new file mode 100644
index 0000000..69c5036
Binary files /dev/null and b/src/audio/samples/rel8.m4a differ
diff --git a/src/audio/samples/rel80.m4a b/src/audio/samples/rel80.m4a
new file mode 100644
index 0000000..f43ae99
Binary files /dev/null and b/src/audio/samples/rel80.m4a differ
diff --git a/src/audio/samples/rel81.m4a b/src/audio/samples/rel81.m4a
new file mode 100644
index 0000000..7a6198f
Binary files /dev/null and b/src/audio/samples/rel81.m4a differ
diff --git a/src/audio/samples/rel82.m4a b/src/audio/samples/rel82.m4a
new file mode 100644
index 0000000..19a1c8e
Binary files /dev/null and b/src/audio/samples/rel82.m4a differ
diff --git a/src/audio/samples/rel83.m4a b/src/audio/samples/rel83.m4a
new file mode 100644
index 0000000..78ca8a2
Binary files /dev/null and b/src/audio/samples/rel83.m4a differ
diff --git a/src/audio/samples/rel84.m4a b/src/audio/samples/rel84.m4a
new file mode 100644
index 0000000..3f4d8c5
Binary files /dev/null and b/src/audio/samples/rel84.m4a differ
diff --git a/src/audio/samples/rel85.m4a b/src/audio/samples/rel85.m4a
new file mode 100644
index 0000000..afa6e8e
Binary files /dev/null and b/src/audio/samples/rel85.m4a differ
diff --git a/src/audio/samples/rel86.m4a b/src/audio/samples/rel86.m4a
new file mode 100644
index 0000000..26977b1
Binary files /dev/null and b/src/audio/samples/rel86.m4a differ
diff --git a/src/audio/samples/rel87.m4a b/src/audio/samples/rel87.m4a
new file mode 100644
index 0000000..666d1d8
Binary files /dev/null and b/src/audio/samples/rel87.m4a differ
diff --git a/src/audio/samples/rel88.m4a b/src/audio/samples/rel88.m4a
new file mode 100644
index 0000000..6045663
Binary files /dev/null and b/src/audio/samples/rel88.m4a differ
diff --git a/src/audio/samples/rel9.m4a b/src/audio/samples/rel9.m4a
new file mode 100644
index 0000000..1d2be34
Binary files /dev/null and b/src/audio/samples/rel9.m4a differ
diff --git a/src/config/eraser-size.ts b/src/config/eraser-size.ts
new file mode 100644
index 0000000..008bee7
--- /dev/null
+++ b/src/config/eraser-size.ts
@@ -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));
diff --git a/src/game-loop/eraser-preview.ts b/src/game-loop/eraser-preview.ts
index fefd93c..493d497 100644
--- a/src/game-loop/eraser-preview.ts
+++ b/src/game-loop/eraser-preview.ts
@@ -1,3 +1,4 @@
+import { getEffectiveEraserSize } from '../config/eraser-size';
import { settings } from '../settings';
export class EraserPreview {
@@ -49,9 +50,15 @@ export class EraserPreview {
};
}
- if (this.previousSize !== settings.eraserSize) {
- this.element.style.setProperty('--eraser-preview-size', `${settings.eraserSize}px`);
- this.previousSize = settings.eraserSize;
+ const rect = this.canvas.getBoundingClientRect();
+ const size = getEffectiveEraserSize(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 (
@@ -63,7 +70,6 @@ export class EraserPreview {
return;
}
- const rect = this.canvas.getBoundingClientRect();
const left = `${this.previewClientPosition.x - rect.left}px`;
const top = `${this.previewClientPosition.y - rect.top}px`;
if (this.previousLeft !== left) {
diff --git a/src/game-loop/game-loop.ts b/src/game-loop/game-loop.ts
index 25d2323..d53a73c 100644
--- a/src/game-loop/game-loop.ts
+++ b/src/game-loop/game-loop.ts
@@ -2,6 +2,7 @@ import { vec2 } from 'gl-matrix';
import { GardenAudio } from '../audio/garden-audio';
import { createGardenAudioConfig } from '../audio/garden-audio-config';
+import { getEffectiveEraserSize, getElementCssPixelSize } from '../config/eraser-size';
import { activeVibe, settings } from '../settings';
import { DeltaTimeCalculator } from '../utils/delta-time-calculator';
import { rgbColorToCss, type RgbColor } from '../utils/rgb-color';
@@ -209,7 +210,11 @@ export default class GameLoop {
const runtimeSettings = { ...settings };
const introProgress = this.introPrompt.progress;
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 accentColor =
channelColors[runtimeSettings.selectedColorIndex] ?? channelColors[0];
diff --git a/src/page/audio-control.ts b/src/page/audio-control.ts
index 159c6c2..0918eda 100644
--- a/src/page/audio-control.ts
+++ b/src/page/audio-control.ts
@@ -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 { readBrowserStorage, writeBrowserStorage } from '../utils/browser-storage';
import { queryRequiredElement } from '../utils/dom';
-import { clamp01 } from '../utils/math';
const AUDIO_MUTED_STORAGE_KEY = 'fleeting-garden:audio-muted';
const AUDIO_VOLUME_STORAGE_KEY = 'fleeting-garden:audio-volume';
const AUDIO_VOLUME_MIN = 0;
-const AUDIO_VOLUME_MAX = 1;
+const AUDIO_VOLUME_MAX = MAX_AUDIO_VOLUME;
const AUDIO_VOLUME_STEP = 0.01;
const clampAudioVolume = (value: number): number => {
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 => {
@@ -83,6 +82,7 @@ export class AudioControl {
this.audioVolume = clampAudioVolume(this.audioVolume);
const isEffectivelyMuted = this.isMuted;
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.setAttribute('aria-pressed', String(isEffectivelyMuted));
@@ -102,7 +102,10 @@ export class AudioControl {
this.volumeControl.title = isEffectivelyMuted
? `Muted, ${volumePercent}% volume`
: `${volumePercent}% volume`;
- this.volumeControl.style.setProperty('--volume-progress', `${volumePercent}%`);
+ this.volumeControl.style.setProperty(
+ '--volume-progress',
+ `${volumeProgressPercent}%`
+ );
const game = this.options.getGame();
game?.setAudioVolume(this.audioVolume);
diff --git a/src/page/eraser-size-control.test.ts b/src/page/eraser-size-control.test.ts
index 1d93778..372c63e 100644
--- a/src/page/eraser-size-control.test.ts
+++ b/src/page/eraser-size-control.test.ts
@@ -3,9 +3,11 @@ import { describe, expect, it } from 'vitest';
import {
ERASER_SIZE_MAX,
ERASER_SIZE_MIN,
+ getEffectiveEraserSize,
getEraserSizeFromSliderRatio,
+ getEraserSizeMaxForCssSize,
getEraserSliderRatioFromSize,
-} from './eraser-size-control';
+} from '../config/eraser-size';
describe('eraser size slider mapping', () => {
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(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
+ );
+ });
});
diff --git a/src/page/eraser-size-control.ts b/src/page/eraser-size-control.ts
index 8f93d51..beb2d0f 100644
--- a/src/page/eraser-size-control.ts
+++ b/src/page/eraser-size-control.ts
@@ -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 { DEFAULT_ERASER_SIZE, settings } from '../settings';
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_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_MAX = 1;
const ERASER_SLIDER_STEP = 0.001;
-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));
-};
-
-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));
+const clampStoredEraserSize = (value: number): number =>
+ clampEraserSize(value, ERASER_SIZE_MAX, DEFAULT_ERASER_SIZE);
interface EraserSizeControlOptions {
getGame: () => GameLoop | null;
@@ -48,6 +33,7 @@ export class EraserSizeControl {
HTMLLabelElement
);
private readonly slider = queryRequiredElement('.eraser-size-slider', HTMLInputElement);
+ private readonly canvas = queryRequiredElement('canvas', HTMLCanvasElement);
private isActive = false;
public constructor(private readonly options: EraserSizeControlOptions) {
@@ -55,7 +41,10 @@ export class EraserSizeControl {
this.control.addEventListener('click', this.activate);
this.slider.addEventListener('focus', this.activate);
this.slider.addEventListener('input', () => {
- settings.eraserSize = getEraserSizeFromSliderRatio(Number(this.slider.value));
+ settings.eraserSize = getEraserSizeFromSliderRatio(
+ Number(this.slider.value),
+ this.getResponsiveMaxSize()
+ );
this.activate();
this.render();
this.options.onChange();
@@ -63,19 +52,21 @@ export class EraserSizeControl {
}
public render(): void {
- const size = clampEraserSize(settings.eraserSize);
- if (settings.eraserSize !== size) {
- settings.eraserSize = size;
+ const maxSize = this.getResponsiveMaxSize();
+ const storedSize = clampStoredEraserSize(settings.eraserSize);
+ 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.max = ERASER_SLIDER_MAX.toString();
this.slider.step = ERASER_SLIDER_STEP.toString();
this.slider.value = sliderRatio.toString();
this.slider.setAttribute('aria-valuetext', `${size}px`);
- const sizeRatio = getEraserSizeRatio(size);
+ const sizeRatio = getEraserSizeRatio(size, maxSize);
const scale =
ERASER_CONTROL_SCALE_MIN +
(ERASER_CONTROL_SCALE_MAX - ERASER_CONTROL_SCALE_MIN) * sizeRatio;
@@ -95,6 +86,10 @@ export class EraserSizeControl {
this.options.onActivate();
};
+ private getResponsiveMaxSize(): number {
+ return getEraserSizeMaxForCssSize(getElementCssPixelSize(this.canvas));
+ }
+
private syncActiveState(): void {
this.control.classList.toggle('active', this.isActive);
this.slider.setAttribute(
diff --git a/src/style/_loading.scss b/src/style/_loading.scss
index 925f915..d9a519b 100644
--- a/src/style/_loading.scss
+++ b/src/style/_loading.scss
@@ -52,7 +52,7 @@
> .splash-description {
margin: 0;
- max-width: 28ch;
+ max-width: 70ch;
color: rgb(255 255 255 / 80%);
font-size: 15px;
font-weight: 400;
diff --git a/src/style/_panels.scss b/src/style/_panels.scss
index 3e92b02..029a044 100644
--- a/src/style/_panels.scss
+++ b/src/style/_panels.scss
@@ -2,13 +2,10 @@
html > body > aside.control-dock > .info-page {
width: min(100%, 520px);
- max-height: min(62vh, 480px);
- max-height: min(62dvh, 480px);
+ max-height: 200vh;
+ max-height: 200dvh;
margin: 0 auto 10px;
- overflow-x: hidden;
- overflow-y: auto;
- overscroll-behavior: contain;
- touch-action: pan-y;
+ overflow: hidden;
border: 1px solid rgb(255 255 255 / 46%);
border-radius: 8px;
background:
@@ -20,26 +17,12 @@ html > body > aside.control-dock > .info-page {
0 16px 42px rgb(0 0 0 / 30%),
0 2px 10px rgb(0 0 0 / 18%);
backdrop-filter: blur(16px) saturate(118%);
- scrollbar-width: thin;
- scrollbar-color: rgb(69 98 88 / 62%) transparent;
- -webkit-overflow-scrolling: touch;
transition:
max-height var(--transition-time-long),
opacity var(--transition-time-long),
transform 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 {
outline: 2px solid rgb(17 56 45);
outline-offset: 3px;
@@ -150,9 +133,8 @@ html > body > aside.control-dock > .info-page {
line-height: 1.18;
}
- .info-page__lede,
- .info-page__notes,
- .info-page__meta {
+ .info-page__main,
+ .info-page__notes {
max-width: 56ch;
overflow-wrap: break-word;
color: rgb(25 35 32);
@@ -160,8 +142,7 @@ html > body > aside.control-dock > .info-page {
line-height: 1.56;
}
- .info-page__lede,
- .info-page__meta {
+ .info-page__main {
margin-bottom: 0;
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 {
color: rgb(0 83 105);
font-weight: 700;
@@ -225,45 +193,16 @@ html > body > aside.control-dock > .info-page {
@include on-small-screen {
width: min(100%, 520px);
- max-height: min(58vh, 500px);
- max-height: min(58dvh, 500px);
.info-page__content {
gap: 0.75rem;
padding: 14px;
}
- .info-page__lede,
+ .info-page__main,
.info-page__notes {
font-size: 0.95rem;
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)
- )
- )
- );
}
}
diff --git a/src/style/common.scss b/src/style/common.scss
index 23c82aa..9996bc5 100644
--- a/src/style/common.scss
+++ b/src/style/common.scss
@@ -16,15 +16,24 @@
html {
height: 100%;
+ overscroll-behavior: none;
touch-action: manipulation;
+ user-select: none;
-webkit-font-smoothing: antialiased;
+ -webkit-touch-callout: none;
+ -webkit-user-select: none;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
}
body {
font-family: 'Open Sans', sans-serif;
+ overscroll-behavior: none;
touch-action: manipulation;
+ user-select: none;
+ -webkit-tap-highlight-color: transparent;
+ -webkit-touch-callout: none;
+ -webkit-user-select: none;
}
.visually-hidden {