diff --git a/.forgejo/workflows/deploy.yml b/.forgejo/workflows/deploy.yml index ac38248..bbf03a4 100644 --- a/.forgejo/workflows/deploy.yml +++ b/.forgejo/workflows/deploy.yml @@ -57,7 +57,6 @@ jobs: - name: Copy build to host pages mount if: github.event_name == 'push' && github.ref == 'refs/heads/main' - uses: http://forgejo:3000/andras/ci-actions/deploy-pages@main - with: - source: dist - target: fleeting + run: | + apt update && apt install -y rsync + rsync -a --delete dist/ /pages/fleeting diff --git a/index.html b/index.html index 53428ea..1414388 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": "Draw colour into a canvas that keeps moving. Your strokes become paths for life that splits, drifts, and redraws the surface over time.", + "description": "Plant colour, fold gestures with mirrors, and watch small agents turn each brushstroke into a shifting WebGPU garden.", "image": "https://schmelczer.dev/fleeting/og-image.jpg", "applicationCategory": "DesignApplication", "operatingSystem": "Any", @@ -91,8 +91,7 @@

Fleeting Garden

- Draw colour into a canvas that keeps moving. Your strokes become paths for - life that splits, drifts, and redraws the surface over time. + Tend it while you can. The garden returns to weather either way.

@@ -140,31 +139,24 @@ > -

- Draw into a field of particles and watch the simulation fold your marks back - into motion. +

+ 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.

-

- My implementation of - physarum simulation - introduces drawing and procedurally generated piano for a more immersive - experience. Learn more about my work at +

+ Built with WebGPU, running locally in your browser. More of my work at schmelczer.dev + >.

diff --git a/src/audio/garden-audio-config.ts b/src/audio/garden-audio-config.ts index 37e5ace..b5fbc1d 100644 --- a/src/audio/garden-audio-config.ts +++ b/src/audio/garden-audio-config.ts @@ -1,7 +1,6 @@ import type { PianoNoteRole } from './garden-audio-types'; -export const DEFAULT_AUDIO_VOLUME = 0.65; -export const MAX_AUDIO_VOLUME = 1.5; +export const DEFAULT_AUDIO_VOLUME = 0.5; export const SILENT_AUDIO_GAIN = 0.0001; type GardenAudioChordQuality = 'major' | 'minor' | 'sus2' | 'sus4'; @@ -59,33 +58,17 @@ export const createGardenAudioConfig = () => ({ timeRampSeconds: 0.12, }, piano: { - maxVoices: 48, - gain: 0.78, + maxVoices: 24, + gain: 0.48, sustainSeconds: 0.42, sustainLevel: 0.26, - releaseSeconds: 0.62, - lowpassHz: 9500, - gainAttackSeconds: 0.003, - lowpassMaxHz: 16000, - lowpassMinHz: 900, + releaseSeconds: 0.34, + lowpassHz: 7000, + gainAttackSeconds: 0.006, + lowpassMaxHz: 12000, + lowpassMinHz: 1400, sustainBase: 0.45, sustainVelocityRange: 0.55, - releaseSampleGain: 0.035, - releaseSampleVelocityBase: 0.45, - releaseSampleVelocityRange: 0.55, - roomSend: 0.18, - velocityLayerCurve: 0.72, - velocityLayerMax: 0.26, - velocityLayerMin: 0.035, - voiceDecayEstimateSeconds: 1.9, - }, - room: { - decaySeconds: 1.65, - highPassHz: 120, - lowPassHz: 8200, - preDelaySeconds: 0.018, - sendGain: 1, - wetGain: 0.11, }, rhythm: { idleIntensity: defaultGardenAudioVibeSettings.idleIntensity, diff --git a/src/audio/garden-audio-graph.ts b/src/audio/garden-audio-graph.ts index a08ac58..a288465 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: -17, + thresholdDb: -18, kneeDb: 18, - ratio: 2.2, - attackSeconds: 0.014, - releaseSeconds: 0.28, + ratio: 2.1, + attackSeconds: 0.018, + releaseSeconds: 0.18, }, } as const; const delayFilterTuning = { @@ -45,7 +45,6 @@ export class GardenAudioGraph { public context: AudioContext | null = null; public eventBus: GainNode | null = null; public delayInput: GainNode | null = null; - public roomInput: GainNode | null = null; public noiseBus: GainNode | null = null; public noiseBuffer: AudioBuffer | null = null; @@ -88,12 +87,10 @@ export class GardenAudioGraph { const context = new AudioContextConstructor({ latencyHint: graphTuning.latencyHint, }); - const outputBus = context.createGain(); const masterGain = context.createGain(); const highPass = context.createBiquadFilter(); const compressor = context.createDynamicsCompressor(); - outputBus.gain.value = 1; masterGain.gain.value = 0; highPass.type = graphTuning.outputFilterType; highPass.frequency.value = outputHighPassFrequencyHz; @@ -103,18 +100,15 @@ export class GardenAudioGraph { compressor.attack.value = graphTuning.compressor.attackSeconds; compressor.release.value = graphTuning.compressor.releaseSeconds; - // Keep peak control independent from the user's volume slider. - outputBus.connect(highPass); + masterGain.connect(highPass); highPass.connect(compressor); - compressor.connect(masterGain); - masterGain.connect(context.destination); + compressor.connect(context.destination); this.context = context; this.masterGain = masterGain; this.noiseBuffer = this.createNoiseBuffer(context); - this.createDelay(context, outputBus); - this.createRoom(context, outputBus); - this.createBuses(context, outputBus); + this.createDelay(context, masterGain); + this.createBuses(context, masterGain); return context; } @@ -230,7 +224,7 @@ export class GardenAudioGraph { } } - private createDelay(context: AudioContext, outputBus: GainNode): void { + private createDelay(context: AudioContext, masterGain: GainNode): void { const delayInput = context.createGain(); const delayNode = context.createDelay(graphTuning.delayMaxSeconds); const delayFeedback = context.createGain(); @@ -256,7 +250,7 @@ export class GardenAudioGraph { delayFeedback.connect(delayNode); delayNode.connect(returnLowPass); returnLowPass.connect(delayOutput); - delayOutput.connect(outputBus); + delayOutput.connect(masterGain); this.delayInput = delayInput; this.delayNode = delayNode; @@ -264,37 +258,10 @@ export class GardenAudioGraph { this.delayOutput = delayOutput; } - private createRoom(context: AudioContext, outputBus: GainNode): void { - const roomInput = context.createGain(); - const preDelay = context.createDelay(0.08); - const convolver = context.createConvolver(); - const highPass = context.createBiquadFilter(); - const lowPass = context.createBiquadFilter(); - const roomOutput = context.createGain(); - - roomInput.gain.value = this.config.room.sendGain; - preDelay.delayTime.value = this.config.room.preDelaySeconds; - convolver.buffer = this.createRoomImpulse(context); - highPass.type = 'highpass'; - highPass.frequency.value = this.config.room.highPassHz; - lowPass.type = 'lowpass'; - lowPass.frequency.value = this.config.room.lowPassHz; - roomOutput.gain.value = this.config.room.wetGain; - - roomInput.connect(preDelay); - preDelay.connect(convolver); - convolver.connect(highPass); - highPass.connect(lowPass); - lowPass.connect(roomOutput); - roomOutput.connect(outputBus); - - this.roomInput = roomInput; - } - - private createBuses(context: AudioContext, outputBus: GainNode): void { + private createBuses(context: AudioContext, masterGain: GainNode): void { const eventBus = context.createGain(); eventBus.gain.value = graphTuning.eventBusGain; - eventBus.connect(outputBus); + eventBus.connect(masterGain); this.eventBus = eventBus; this.pianoBuses.clear(); @@ -361,34 +328,10 @@ export class GardenAudioGraph { return buffer; } - private createRoomImpulse(context: AudioContext): AudioBuffer { - const sampleCount = Math.max( - 1, - Math.floor(context.sampleRate * this.config.room.decaySeconds) - ); - const impulse = context.createBuffer(2, sampleCount, context.sampleRate); - - for (let channel = 0; channel < impulse.numberOfChannels; channel += 1) { - const data = impulse.getChannelData(channel); - for (let index = 0; index < sampleCount; index += 1) { - const position = index / sampleCount; - const decay = Math.pow(1 - position, 2.35); - const earlyReflection = - index % Math.max(1, Math.floor(context.sampleRate * 0.011)) === 0 - ? 0.18 * (1 - position) - : 0; - data[index] = (Math.random() * 2 - 1 + earlyReflection) * decay; - } - } - - return impulse; - } - private clearNodes(): void { this.context = null; this.eventBus = null; this.delayInput = null; - this.roomInput = null; this.noiseBus = null; this.noiseBuffer = null; this.masterGain = null; diff --git a/src/audio/garden-audio-types.ts b/src/audio/garden-audio-types.ts index ade78a4..fecbcf8 100644 --- a/src/audio/garden-audio-types.ts +++ b/src/audio/garden-audio-types.ts @@ -14,22 +14,11 @@ export interface GardenAudioStroke { elapsedSeconds: number; } -export interface LoadedPianoStrikeSample { - midi: number; - velocityLayer: number; - buffer: AudioBuffer; -} - -export interface LoadedPianoReleaseSample { +export interface LoadedPianoSample { midi: number; buffer: AudioBuffer; } -export interface LoadedPianoSamples { - releases: Array; - strikes: Array; -} - export interface PianoNote { midi: number; velocity: number; diff --git a/src/audio/garden-audio.ts b/src/audio/garden-audio.ts index d2178cc..3aa4c88 100644 --- a/src/audio/garden-audio.ts +++ b/src/audio/garden-audio.ts @@ -1,8 +1,7 @@ import { ErrorHandler, Severity } from '../utils/error-handler'; -import { clamp } from '../utils/math'; +import { clamp01 } from '../utils/math'; import type { VibeId, VibePreset } from '../vibes'; import { - MAX_AUDIO_VOLUME, SILENT_AUDIO_GAIN, type GardenAudioConfig, type GardenAudioVibeProfile, @@ -50,7 +49,7 @@ export class GardenAudio { private hasLoadedPiano = false; public constructor(private readonly config: GardenAudioConfig) { - this.masterVolume = clamp(config.masterVolume, 0, MAX_AUDIO_VOLUME); + this.masterVolume = clamp01(config.masterVolume); this.graph = new GardenAudioGraph(config); this.piano = new PianoSampler(config, this.graph); this.noise = new NoiseBurstPlayer(this.graph); @@ -229,7 +228,7 @@ export class GardenAudio { } public setMasterVolume(masterVolume: number): void { - this.masterVolume = clamp(masterVolume, 0, MAX_AUDIO_VOLUME); + this.masterVolume = clamp01(masterVolume); if (!this.isMuted) { this.graph.setMasterGain(this.masterVolume, this.config.updateRampSeconds); } @@ -397,7 +396,7 @@ export class GardenAudio { return; } - const distanceActivity = clamp(activity, 0, 1); + const distanceActivity = clamp01(activity); if (distanceActivity <= 0) { return; } diff --git a/src/audio/piano-sampler.ts b/src/audio/piano-sampler.ts index 8c27e33..1a0c77c 100644 --- a/src/audio/piano-sampler.ts +++ b/src/audio/piano-sampler.ts @@ -2,57 +2,32 @@ import { clamp, clamp01 } from '../utils/math'; import type { GardenAudioConfig } from './garden-audio-config'; import type { GardenAudioGraph } from './garden-audio-graph'; import { PITCH_SEMITONES_PER_OCTAVE } from './garden-audio-music'; -import type { - LoadedPianoReleaseSample, - LoadedPianoSamples, - LoadedPianoStrikeSample, - PianoNote, -} from './garden-audio-types'; -import { ensurePianoSamplesLoading, subscribeToPianoSamples } from './piano-samples'; +import type { LoadedPianoSample, PianoNote } from './garden-audio-types'; +import { getLoadedPianoSamples, loadPianoSamples } from './piano-samples'; export const PIANO_SCHEDULE_AHEAD_SECONDS = 0.002; interface ActivePianoVoice { gain: GainNode; - peakGain: number; - releaseAt: number; - sources: Array; - startedAt: number; - stopAt: number; -} - -interface SelectedPianoStrikeSample { - gainScale: number; - sample: LoadedPianoStrikeSample; -} - -interface ScheduledReleaseSample { - gainValue: number; - source: AudioBufferSourceNode; - startTime: number; + source: AudioScheduledSourceNode; stopAt: number; } const pianoSamplerTuning = { filterType: 'lowpass', - filterQ: 0.45, + filterQ: 0.7, minDurationSeconds: 0.08, minFadeSeconds: 0.08, minGain: 0.0001, - releaseSampleAttackSeconds: 0.006, - releaseSampleDecaySeconds: 0.18, - releaseTimeConstantCount: 6, - tailStopExtraSeconds: 0.08, - voiceStealFadeSeconds: 0.045, - voiceStealStopSeconds: 0.09, + releaseTimeConstantCount: 5, + tailStopExtraSeconds: 0.05, + voiceStealFadeSeconds: 0.025, + voiceStealStopSeconds: 0.05, } as const; export class PianoSampler { + private samples: Array = []; private activeVoices: Array = []; - private releaseSamples: Array = []; - private strikeSamples: Array = []; - private velocityLayers: Array = []; - private unsubscribeFromSampleUpdates: (() => void) | null = null; public constructor( private readonly config: GardenAudioConfig, @@ -60,17 +35,19 @@ export class PianoSampler { ) {} public load(context: BaseAudioContext): Promise { - // The subscription applies further velocity layers and release samples - // as they finish loading in the background. - this.unsubscribeFromSampleUpdates ??= subscribeToPianoSamples((samples) => { - this.setSamples(samples); - }); - - if (this.strikeSamples.length > 0) { + if (this.samples.length > 0) { return Promise.resolve(); } - return ensurePianoSamplesLoading(context); + const loadedSamples = getLoadedPianoSamples(); + if (loadedSamples) { + this.setSamples(loadedSamples); + return Promise.resolve(); + } + + return loadPianoSamples(context).then((samples) => { + this.setSamples(samples); + }); } public play({ @@ -90,9 +67,8 @@ export class PianoSampler { return; } - const noteVelocity = clamp01(velocity); - const selectedSamples = this.selectStrikeSamples(midi, noteVelocity); - if (selectedSamples.length === 0) { + const sample = this.findNearestSample(midi); + if (!sample) { return; } @@ -100,6 +76,7 @@ export class PianoSampler { context.currentTime + PIANO_SCHEDULE_AHEAD_SECONDS, startTime ); + const noteVelocity = clamp01(velocity); const noteGainValue = this.computeNoteGain(noteVelocity); const sustainSeconds = profileSustainSeconds * @@ -111,36 +88,45 @@ export class PianoSampler { const stopAt = releaseAt + this.config.piano.releaseSeconds * pianoSamplerTuning.releaseTimeConstantCount; - const strikeSources = selectedSamples.map(({ gainScale, sample }) => ({ - gainScale, - source: this.createSource( - context, - sample.buffer, - midi, - sample.midi, - scheduledStart - ), - })); - const releaseSample = this.createReleaseSample({ - context, - midi, - noteVelocity, - releaseAt, - }); + const source = context.createBufferSource(); + + source.buffer = sample.buffer; + source.playbackRate.setValueAtTime( + Math.pow(2, (midi - sample.midi) / PITCH_SEMITONES_PER_OCTAVE), + scheduledStart + ); this.scheduleVoice({ + source, + scheduledStart, + stopAt, + pan, + lowpassHz, delaySend, eventBus, - lowpassHz, - noteGainValue, - pan, - releaseAt, - releaseSample, - scheduledStart, - stopAt: releaseSample ? Math.max(stopAt, releaseSample.stopAt) : stopAt, - strikeSources, - sustainAt, - sustainSeconds, + configureGainEnvelope: (gain) => { + gain.gain.setValueAtTime(pianoSamplerTuning.minGain, scheduledStart); + gain.gain.exponentialRampToValueAtTime( + noteGainValue, + scheduledStart + this.config.piano.gainAttackSeconds + ); + gain.gain.setTargetAtTime( + Math.max( + pianoSamplerTuning.minGain, + noteGainValue * this.config.piano.sustainLevel + ), + sustainAt, + Math.max( + pianoSamplerTuning.minFadeSeconds, + sustainSeconds * this.config.piano.sustainBase + ) + ); + gain.gain.setTargetAtTime( + pianoSamplerTuning.minGain, + releaseAt, + this.config.piano.releaseSeconds + ); + }, }); } @@ -160,42 +146,30 @@ export class PianoSampler { } public reset(): void { - this.unsubscribeFromSampleUpdates?.(); - this.unsubscribeFromSampleUpdates = null; - this.releaseSamples = []; - this.strikeSamples = []; - this.velocityLayers = []; + this.samples = []; this.activeVoices = []; } private scheduleVoice({ - strikeSources, - releaseSample, + source, scheduledStart, - sustainAt, - sustainSeconds, - releaseAt, stopAt, pan, lowpassHz, delaySend, eventBus, - noteGainValue, + configureGainEnvelope, }: { - delaySend: number; - eventBus: GainNode; - lowpassHz: number; - noteGainValue: number; - pan: number; - releaseAt: number; - releaseSample: ScheduledReleaseSample | null; + source: AudioScheduledSourceNode; scheduledStart: number; stopAt: number; - strikeSources: Array<{ gainScale: number; source: AudioBufferSourceNode }>; - sustainAt: number; - sustainSeconds: number; + pan: number; + lowpassHz: number; + delaySend: number; + eventBus: GainNode; + configureGainEnvelope: (gain: GainNode) => void; }): void { - const { context, delayInput, roomInput } = this.graph; + const { context, delayInput } = this.graph; if (!context) { return; } @@ -203,18 +177,15 @@ export class PianoSampler { const filter = context.createBiquadFilter(); const gain = context.createGain(); const panner = context.createStereoPanner(); - const sourceGains = strikeSources.map(({ gainScale }) => { - const sourceGain = context.createGain(); - sourceGain.gain.value = gainScale; - return sourceGain; - }); - let delaySendGain: GainNode | null = null; - let roomSendGain: GainNode | null = null; - let releaseGain: GainNode | null = null; + let sendGain: GainNode | null = null; this.trimActiveVoices(scheduledStart); while (this.activeVoices.length >= this.config.piano.maxVoices) { - this.stealQuietestVoice(scheduledStart); + const oldest = this.activeVoices.shift(); + if (!oldest) { + break; + } + this.stopVoice(oldest, scheduledStart); } filter.type = pianoSamplerTuning.filterType; @@ -224,352 +195,48 @@ export class PianoSampler { ); filter.Q.value = pianoSamplerTuning.filterQ; panner.pan.setValueAtTime(clamp(pan, -1, 1), scheduledStart); - this.configureGainEnvelope({ - gain, - noteGainValue, - releaseAt, - scheduledStart, - sustainAt, - sustainSeconds, - }); + configureGainEnvelope(gain); - strikeSources.forEach(({ source }, index) => { - source.connect(sourceGains[index]); - sourceGains[index].connect(filter); - }); + source.connect(filter); filter.connect(gain); gain.connect(panner); - - if (releaseSample) { - releaseGain = context.createGain(); - releaseSample.source.connect(releaseGain); - releaseGain.connect(panner); - this.configureReleaseEnvelope(releaseGain, releaseSample); - } - panner.connect(eventBus); if (delayInput && delaySend > 0) { - delaySendGain = context.createGain(); - delaySendGain.gain.value = delaySend; - panner.connect(delaySendGain); - delaySendGain.connect(delayInput); + sendGain = context.createGain(); + sendGain.gain.value = delaySend; + panner.connect(sendGain); + sendGain.connect(delayInput); } - if (roomInput && this.config.piano.roomSend > 0) { - roomSendGain = context.createGain(); - roomSendGain.gain.value = this.config.piano.roomSend; - panner.connect(roomSendGain); - roomSendGain.connect(roomInput); - } + source.start(scheduledStart); + source.stop(stopAt + pianoSamplerTuning.tailStopExtraSeconds); + this.activeVoices.push({ gain, source, stopAt }); - const sources = [ - ...strikeSources.map(({ source }) => source), - ...(releaseSample ? [releaseSample.source] : []), - ]; - - strikeSources.forEach(({ source }) => { - source.start(scheduledStart); - source.stop(stopAt + pianoSamplerTuning.tailStopExtraSeconds); - }); - if (releaseSample) { - releaseSample.source.start(releaseSample.startTime); - releaseSample.source.stop(releaseSample.stopAt); - } - - const voice: ActivePianoVoice = { - gain, - peakGain: noteGainValue, - releaseAt, - sources, - startedAt: scheduledStart, - stopAt, - }; - this.activeVoices.push(voice); - - this.cleanupVoiceWhenSourcesEnd({ - delaySendGain, - filter, - gain, - panner, - releaseGain, - roomSendGain, - sourceGains, - sources, - voice, - }); - } - - private configureGainEnvelope({ - gain, - noteGainValue, - releaseAt, - scheduledStart, - sustainAt, - sustainSeconds, - }: { - gain: GainNode; - noteGainValue: number; - releaseAt: number; - scheduledStart: number; - sustainAt: number; - sustainSeconds: number; - }): void { - gain.gain.setValueAtTime(pianoSamplerTuning.minGain, scheduledStart); - gain.gain.exponentialRampToValueAtTime( - noteGainValue, - scheduledStart + this.config.piano.gainAttackSeconds - ); - gain.gain.setTargetAtTime( - Math.max( - pianoSamplerTuning.minGain, - noteGainValue * this.config.piano.sustainLevel - ), - sustainAt, - Math.max( - pianoSamplerTuning.minFadeSeconds, - sustainSeconds * this.config.piano.sustainBase - ) - ); - gain.gain.setTargetAtTime( - pianoSamplerTuning.minGain, - releaseAt, - this.config.piano.releaseSeconds - ); - } - - private configureReleaseEnvelope( - releaseGain: GainNode, - releaseSample: ScheduledReleaseSample - ): void { - releaseGain.gain.setValueAtTime(pianoSamplerTuning.minGain, releaseSample.startTime); - releaseGain.gain.exponentialRampToValueAtTime( - releaseSample.gainValue, - releaseSample.startTime + pianoSamplerTuning.releaseSampleAttackSeconds - ); - releaseGain.gain.setTargetAtTime( - pianoSamplerTuning.minGain, - releaseSample.startTime + pianoSamplerTuning.releaseSampleAttackSeconds, - pianoSamplerTuning.releaseSampleDecaySeconds - ); - } - - private createSource( - context: BaseAudioContext, - buffer: AudioBuffer, - midi: number, - sampleMidi: number, - scheduledStart: number - ): AudioBufferSourceNode { - const source = context.createBufferSource(); - source.buffer = buffer; - source.playbackRate.setValueAtTime( - Math.pow(2, (midi - sampleMidi) / PITCH_SEMITONES_PER_OCTAVE), - scheduledStart - ); - return source; - } - - private createReleaseSample({ - context, - midi, - noteVelocity, - releaseAt, - }: { - context: BaseAudioContext; - midi: number; - noteVelocity: number; - releaseAt: number; - }): ScheduledReleaseSample | null { - const sample = this.findNearestReleaseSample(midi); - if (!sample) { - return null; - } - - const source = this.createSource( - context, - sample.buffer, - midi, - sample.midi, - releaseAt - ); - const gainValue = - this.config.piano.releaseSampleGain * - (this.config.piano.releaseSampleVelocityBase + - noteVelocity * this.config.piano.releaseSampleVelocityRange); - - return { - gainValue: Math.max(pianoSamplerTuning.minGain, gainValue), - source, - startTime: releaseAt, - stopAt: - releaseAt + sample.buffer.duration + pianoSamplerTuning.tailStopExtraSeconds, - }; - } - - private cleanupVoiceWhenSourcesEnd({ - sources, - sourceGains, - filter, - gain, - releaseGain, - panner, - delaySendGain, - roomSendGain, - voice, - }: { - delaySendGain: GainNode | null; - filter: BiquadFilterNode; - gain: GainNode; - panner: StereoPannerNode; - releaseGain: GainNode | null; - roomSendGain: GainNode | null; - sourceGains: Array; - sources: Array; - voice: ActivePianoVoice; - }): void { - let remainingSources = sources.length; - const cleanup = (): void => { - remainingSources -= 1; - if (remainingSources > 0) { - return; - } - - sources.forEach((source) => { + source.addEventListener( + 'ended', + () => { source.disconnect(); - }); - sourceGains.forEach((sourceGain) => { - sourceGain.disconnect(); - }); - filter.disconnect(); - gain.disconnect(); - releaseGain?.disconnect(); - panner.disconnect(); - delaySendGain?.disconnect(); - roomSendGain?.disconnect(); - this.activeVoices = this.activeVoices.filter( - (activeVoice) => activeVoice !== voice - ); - }; - - sources.forEach((source) => { - source.addEventListener('ended', cleanup, { once: true }); - }); + filter.disconnect(); + gain.disconnect(); + panner.disconnect(); + sendGain?.disconnect(); + this.activeVoices = this.activeVoices.filter((voice) => voice.gain !== gain); + }, + { once: true } + ); } private computeNoteGain(velocity: number): number { return Math.max(pianoSamplerTuning.minGain, this.config.piano.gain * velocity); } - private selectStrikeSamples( - midi: number, - noteVelocity: number - ): Array { - if (this.strikeSamples.length === 0 || this.velocityLayers.length === 0) { - return []; - } - - const targetLayer = this.getTargetVelocityLayer(noteVelocity); - const layerPair = this.getVelocityLayerPair(targetLayer); - if (!layerPair) { - return []; - } - - const lowerSample = this.findNearestStrikeSample(midi, layerPair.lower); - const upperSample = - layerPair.upper === layerPair.lower - ? null - : this.findNearestStrikeSample(midi, layerPair.upper); - - if (!lowerSample && !upperSample) { - return []; - } - if (!upperSample || layerPair.blend <= 0) { - return lowerSample ? [{ gainScale: 1, sample: lowerSample }] : []; - } - if (!lowerSample || layerPair.blend >= 1) { - return [{ gainScale: 1, sample: upperSample }]; - } - - return [ - { - gainScale: Math.sqrt(1 - layerPair.blend), - sample: lowerSample, - }, - { - gainScale: Math.sqrt(layerPair.blend), - sample: upperSample, - }, - ]; - } - - private getTargetVelocityLayer(noteVelocity: number): number { - const firstLayer = this.velocityLayers[0]; - const lastLayer = this.velocityLayers[this.velocityLayers.length - 1]; - const velocityRange = Math.max( - 0.001, - this.config.piano.velocityLayerMax - this.config.piano.velocityLayerMin - ); - const normalizedVelocity = clamp01( - (noteVelocity - this.config.piano.velocityLayerMin) / velocityRange - ); - const curvedVelocity = Math.pow( - normalizedVelocity, - this.config.piano.velocityLayerCurve - ); - - return firstLayer + (lastLayer - firstLayer) * curvedVelocity; - } - - private getVelocityLayerPair( - targetLayer: number - ): { blend: number; lower: number; upper: number } | null { - const firstLayer = this.velocityLayers[0]; - if (firstLayer === undefined) { - return null; - } - if (targetLayer <= firstLayer) { - return { blend: 0, lower: firstLayer, upper: firstLayer }; - } - - for (let index = 1; index < this.velocityLayers.length; index += 1) { - const upper = this.velocityLayers[index]; - const lower = this.velocityLayers[index - 1]; - if (targetLayer <= upper) { - return { - blend: (targetLayer - lower) / (upper - lower), - lower, - upper, - }; - } - } - - const lastLayer = this.velocityLayers[this.velocityLayers.length - 1]; - return { blend: 0, lower: lastLayer, upper: lastLayer }; - } - - private findNearestStrikeSample( - midi: number, - velocityLayer: number - ): LoadedPianoStrikeSample | null { - const layerSamples = this.strikeSamples.filter( - (sample) => sample.velocityLayer === velocityLayer - ); - if (layerSamples.length === 0) { + private findNearestSample(midi: number): LoadedPianoSample | null { + if (this.samples.length === 0) { return null; } - return layerSamples.reduce((nearest, sample) => - Math.abs(sample.midi - midi) < Math.abs(nearest.midi - midi) ? sample : nearest - ); - } - - private findNearestReleaseSample(midi: number): LoadedPianoReleaseSample | null { - if (this.releaseSamples.length === 0) { - return null; - } - - return this.releaseSamples.reduce((nearest, sample) => + return this.samples.reduce((nearest, sample) => Math.abs(sample.midi - midi) < Math.abs(nearest.midi - midi) ? sample : nearest ); } @@ -578,33 +245,6 @@ export class PianoSampler { this.activeVoices = this.activeVoices.filter((voice) => voice.stopAt > now); } - private stealQuietestVoice(now: number): void { - const quietestVoice = this.activeVoices.reduce( - (quietest, voice) => - quietest === null || - this.getVoiceActivityScore(voice, now) < this.getVoiceActivityScore(quietest, now) - ? voice - : quietest, - null - ); - if (!quietestVoice) { - return; - } - - this.stopVoice(quietestVoice, now); - this.activeVoices = this.activeVoices.filter((voice) => voice !== quietestVoice); - } - - private getVoiceActivityScore(voice: ActivePianoVoice, now: number): number { - const ageSeconds = Math.max(0, now - voice.startedAt); - const releasedScale = now >= voice.releaseAt ? 0.28 : 1; - return ( - voice.peakGain * - releasedScale * - Math.exp(-ageSeconds / this.config.piano.voiceDecayEstimateSeconds) - ); - } - private stopVoice(voice: ActivePianoVoice, now: number): void { const stopAt = now + pianoSamplerTuning.voiceStealStopSeconds; @@ -614,23 +254,11 @@ export class PianoSampler { now, pianoSamplerTuning.voiceStealFadeSeconds ); - voice.sources.forEach((source) => { - try { - source.stop(stopAt); - } catch { - // The source may already have ended naturally. - } - }); voice.stopAt = stopAt; + voice.source.stop(stopAt); } - private setSamples(samples: LoadedPianoSamples): void { - this.releaseSamples = samples.releases.slice().sort((a, b) => a.midi - b.midi); - this.strikeSamples = samples.strikes - .slice() - .sort((a, b) => a.midi - b.midi || a.velocityLayer - b.velocityLayer); - this.velocityLayers = [ - ...new Set(this.strikeSamples.map((sample) => sample.velocityLayer)), - ].sort((a, b) => a - b); + private setSamples(samples: Array): void { + this.samples = samples.slice().sort((a, b) => a.midi - b.midi); } } diff --git a/src/audio/piano-samples.ts b/src/audio/piano-samples.ts index 560ed67..569eca4 100644 --- a/src/audio/piano-samples.ts +++ b/src/audio/piano-samples.ts @@ -1,29 +1,40 @@ -import { ErrorHandler, Severity } from '../utils/error-handler'; -import type { - LoadedPianoReleaseSample, - LoadedPianoSamples, - LoadedPianoStrikeSample, -} from './garden-audio-types'; +import type { LoadedPianoSample } from './garden-audio-types'; +import a0SampleUrl from './samples/A0v12.m4a?url&no-inline'; +import a1SampleUrl from './samples/A1v12.m4a?url&no-inline'; +import a2SampleUrl from './samples/A2v12.m4a?url&no-inline'; +import a3SampleUrl from './samples/A3v12.m4a?url&no-inline'; +import a4SampleUrl from './samples/A4v12.m4a?url&no-inline'; +import a5SampleUrl from './samples/A5v12.m4a?url&no-inline'; +import a6SampleUrl from './samples/A6v12.m4a?url&no-inline'; +import a7SampleUrl from './samples/A7v12.m4a?url&no-inline'; +import c1SampleUrl from './samples/C1v12.m4a?url&no-inline'; +import c2SampleUrl from './samples/C2v12.m4a?url&no-inline'; +import c3SampleUrl from './samples/C3v12.m4a?url&no-inline'; +import c4SampleUrl from './samples/C4v12.m4a?url&no-inline'; +import c5SampleUrl from './samples/C5v12.m4a?url&no-inline'; +import c6SampleUrl from './samples/C6v12.m4a?url&no-inline'; +import c7SampleUrl from './samples/C7v12.m4a?url&no-inline'; +import c8SampleUrl from './samples/C8v12.m4a?url&no-inline'; +import dSharp1SampleUrl from './samples/Dsharp1v12.m4a?url&no-inline'; +import dSharp2SampleUrl from './samples/Dsharp2v12.m4a?url&no-inline'; +import dSharp3SampleUrl from './samples/Dsharp3v12.m4a?url&no-inline'; +import dSharp4SampleUrl from './samples/Dsharp4v12.m4a?url&no-inline'; +import dSharp5SampleUrl from './samples/Dsharp5v12.m4a?url&no-inline'; +import dSharp6SampleUrl from './samples/Dsharp6v12.m4a?url&no-inline'; +import dSharp7SampleUrl from './samples/Dsharp7v12.m4a?url&no-inline'; +import fSharp1SampleUrl from './samples/Fsharp1v12.m4a?url&no-inline'; +import fSharp2SampleUrl from './samples/Fsharp2v12.m4a?url&no-inline'; +import fSharp3SampleUrl from './samples/Fsharp3v12.m4a?url&no-inline'; +import fSharp4SampleUrl from './samples/Fsharp4v12.m4a?url&no-inline'; +import fSharp5SampleUrl from './samples/Fsharp5v12.m4a?url&no-inline'; +import fSharp6SampleUrl from './samples/Fsharp6v12.m4a?url&no-inline'; +import fSharp7SampleUrl from './samples/Fsharp7v12.m4a?url&no-inline'; -interface PianoStrikeSampleDefinition { - kind: 'strike'; - midi: number; - path: string; - url: string; - velocityLayer: number; -} - -interface PianoReleaseSampleDefinition { - kind: 'release'; - midi: number; - path: string; +interface PianoSampleDefinition { + note: string; url: string; } -type PianoSampleDefinition = PianoStrikeSampleDefinition | PianoReleaseSampleDefinition; - -type LoadedPianoSample = LoadedPianoStrikeSample | LoadedPianoReleaseSample; - export interface PianoSampleLoadProgress { failedCount: number; loadedCount: number; @@ -31,44 +42,54 @@ export interface PianoSampleLoadProgress { totalCount: number; } -const pianoSampleModules = import.meta.glob('./samples/*.m4a', { - eager: true, - import: 'default', - query: '?url&no-inline', -}) as Record; -const pianoSampleDefinitions = getPianoSampleDefinitions(pianoSampleModules); +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' }, +]; -// The sampler degrades gracefully on a partial set: it picks the nearest -// available strike per note, blends whatever velocity layers exist, and -// treats release samples as optional. The core velocity layer is therefore -// enough to start playing; the remaining layers and the release samples -// stream in afterwards, published per completed layer so nearest-sample -// lookups never see a half-loaded layer. -const coreVelocityLayer = 8; -const corePianoSampleDefinitions = pianoSampleDefinitions.filter( - (definition) => - definition.kind === 'strike' && definition.velocityLayer === coreVelocityLayer -); -const backgroundPianoSamplePhases = getBackgroundPianoSamplePhases(); - -const loadedSamplesByPath = new Map(); -let publishedSamples: LoadedPianoSamples | null = null; -let coreSampleLoadPromise: Promise | null = null; -let isLoadingBackgroundSamples = false; -const sampleSubscribers = new Set<(samples: LoadedPianoSamples) => void>(); +let loadedPianoSamples: Array | null = null; +let pianoSampleLoadPromise: Promise> | null = null; let lastPianoSampleProgress: PianoSampleLoadProgress | null = null; const pianoSampleProgressListeners = new Set< (progress: PianoSampleLoadProgress) => void >(); const sampleLoadTuning = { - concurrency: 6, + concurrency: 4, sampleTimeoutMs: 15_000, }; export const preloadPianoSamples = ( onProgress?: (progress: PianoSampleLoadProgress) => void -): Promise => { +): Promise> => { const OfflineAudioContextConstructor = globalThis.OfflineAudioContext; if (!OfflineAudioContextConstructor) { @@ -79,178 +100,107 @@ export const preloadPianoSamples = ( // Decoding ignores these, but the constructor demands real numbers. const decodeContext = new OfflineAudioContextConstructor(1, 1, 48_000); - return ensurePianoSamplesLoading(decodeContext, onProgress); + return loadPianoSamples(decodeContext, onProgress); }; -/** - * Starts loading samples if needed and resolves once the core velocity layer - * is playable. Failed samples are retried on the next call; background layers - * keep streaming in after resolution. - */ -export const ensurePianoSamplesLoading = ( +export const loadPianoSamples = ( decodeContext: BaseAudioContext, onProgress?: (progress: PianoSampleLoadProgress) => void -): Promise => { +): Promise> => { const unsubscribeProgress = subscribeToPianoSampleProgress(onProgress); - coreSampleLoadPromise ??= loadCoreSamples(decodeContext).catch((error: unknown) => { - coreSampleLoadPromise = null; - throw error; - }); - coreSampleLoadPromise.then( - () => { - startBackgroundLoading(decodeContext); - }, - () => undefined - ); - - return coreSampleLoadPromise.finally(unsubscribeProgress); -}; - -/** - * Calls the listener with the currently published samples (if any) and again - * whenever another batch finishes loading. Returns an unsubscribe function. - */ -export const subscribeToPianoSamples = ( - listener: (samples: LoadedPianoSamples) => void -): (() => void) => { - sampleSubscribers.add(listener); - if (publishedSamples) { - listener(cloneLoadedPianoSamples(publishedSamples)); + if (loadedPianoSamples) { + emitPianoSampleProgress({ + failedCount: 0, + loadedCount: loadedPianoSamples.length, + settledCount: loadedPianoSamples.length, + totalCount: pianoSampleDefinitions.length, + }); + unsubscribeProgress(); + return Promise.resolve([...loadedPianoSamples]); } - return () => { - sampleSubscribers.delete(listener); - }; -}; -const loadCoreSamples = async (decodeContext: BaseAudioContext): Promise => { - const pending = corePianoSampleDefinitions.filter( - (definition) => !loadedSamplesByPath.has(definition.path) - ); - const totalCount = corePianoSampleDefinitions.length; - let loadedCount = totalCount - pending.length; - let settledCount = loadedCount; + if (pianoSampleLoadPromise) { + return pianoSampleLoadPromise.finally(unsubscribeProgress); + } + + let loadedCount = 0; let failedCount = 0; + let settledCount = 0; + const totalCount = pianoSampleDefinitions.length; emitPianoSampleProgress({ failedCount, loadedCount, settledCount, totalCount }); - await loadSamplesWithConcurrency(pending, async (definition) => { - try { - await loadPianoSample(decodeContext, definition); - loadedCount += 1; - } catch (error) { - failedCount += 1; - throw error; - } finally { - settledCount += 1; - emitPianoSampleProgress({ failedCount, loadedCount, settledCount, totalCount }); + pianoSampleLoadPromise = loadPianoSampleBatch( + pianoSampleDefinitions, + async (sample) => { + try { + const loadedSample = await withTimeout( + (signal) => loadPianoSample(decodeContext, sample, signal), + sampleLoadTuning.sampleTimeoutMs + ); + loadedCount += 1; + return loadedSample; + } catch (error) { + failedCount += 1; + throw error; + } finally { + settledCount += 1; + emitPianoSampleProgress({ failedCount, loadedCount, settledCount, totalCount }); + } } - }); + ) + .then( + (samples) => { + loadedPianoSamples = samples.sort((a, b) => a.midi - b.midi); + if (loadedPianoSamples.length !== pianoSampleDefinitions.length) { + throw new Error( + `Loaded ${loadedPianoSamples.length}/${pianoSampleDefinitions.length} piano samples.` + ); + } + return [...loadedPianoSamples]; + }, + (error: unknown) => { + pianoSampleLoadPromise = null; + pianoSampleProgressListeners.clear(); + throw error; + } + ) + .finally(unsubscribeProgress); - publishLoadedSamples(); + return pianoSampleLoadPromise; }; -const startBackgroundLoading = (decodeContext: BaseAudioContext): void => { - if ( - isLoadingBackgroundSamples || - loadedSamplesByPath.size === pianoSampleDefinitions.length - ) { - return; - } - - isLoadingBackgroundSamples = true; - loadBackgroundSamples(decodeContext).then( - () => { - isLoadingBackgroundSamples = false; - }, - (error: unknown) => { - // The piano stays playable on the samples loaded so far; the next - // ensurePianoSamplesLoading call retries whatever is still missing. - isLoadingBackgroundSamples = false; - ErrorHandler.addException(error, { - fallbackMessage: 'Could not load all piano samples.', - severity: Severity.WARNING, - }); - } - ); -}; - -const loadBackgroundSamples = async (decodeContext: BaseAudioContext): Promise => { - for (const phaseDefinitions of backgroundPianoSamplePhases) { - const pending = phaseDefinitions.filter( - (definition) => !loadedSamplesByPath.has(definition.path) - ); - if (pending.length === 0) { - continue; - } - - await loadSamplesWithConcurrency(pending, (definition) => - loadPianoSample(decodeContext, definition) - ); - publishLoadedSamples(); - } -}; +export const getLoadedPianoSamples = (): Array | null => + loadedPianoSamples ? [...loadedPianoSamples] : null; const loadPianoSample = async ( - decodeContext: BaseAudioContext, - sample: PianoSampleDefinition -): Promise => { - const loadedSample = await withTimeout( - (signal) => fetchAndDecodePianoSample(decodeContext, sample, signal), - sampleLoadTuning.sampleTimeoutMs - ); - loadedSamplesByPath.set(sample.path, loadedSample); -}; - -const fetchAndDecodePianoSample = async ( decodeContext: BaseAudioContext, sample: PianoSampleDefinition, signal: AbortSignal ): Promise => { const response = await fetch(sample.url, { signal }); if (!response.ok) { - throw new Error(`Unable to load piano sample ${sample.path}`); + throw new Error(`Unable to load piano sample ${getPianoSamplePath(sample)}`); } const audioData = await response.arrayBuffer(); const buffer = await decodeContext.decodeAudioData(audioData); - if (sample.kind === 'strike') { - return { - buffer, - midi: sample.midi, - velocityLayer: sample.velocityLayer, - }; - } - return { buffer, midi: sample.midi }; + return { midi: getMidiForPianoSample(sample), buffer }; }; -/** - * Runs the loads through a sliding window of workers so one slow sample only - * occupies a single slot instead of stalling a whole batch. - */ -const loadSamplesWithConcurrency = async ( +const loadPianoSampleBatch = async ( samples: Array, - loadSample: (sample: PianoSampleDefinition) => Promise -): Promise => { - let nextIndex = 0; - const workerCount = Math.min(sampleLoadTuning.concurrency, samples.length); + loadSample: (sample: PianoSampleDefinition) => Promise +): Promise> => { + const results: Array = []; - await Promise.all( - Array.from({ length: workerCount }, async () => { - while (nextIndex < samples.length) { - const sample = samples[nextIndex]; - nextIndex += 1; - await loadSample(sample); - } - }) - ); -}; + for (let index = 0; index < samples.length; index += sampleLoadTuning.concurrency) { + const batch = samples.slice(index, index + sampleLoadTuning.concurrency); + const batchResults = await Promise.all(batch.map((sample) => loadSample(sample))); + results.push(...batchResults); + } -const publishLoadedSamples = (): void => { - const samples = sortLoadedPianoSamples([...loadedSamplesByPath.values()]); - publishedSamples = samples; - sampleSubscribers.forEach((listener) => { - listener(cloneLoadedPianoSamples(samples)); - }); + return results; }; const withTimeout = ( @@ -297,73 +247,13 @@ const emitPianoSampleProgress = (progress: PianoSampleLoadProgress): void => { pianoSampleProgressListeners.forEach((listener) => listener(progress)); }; -function getBackgroundPianoSamplePhases(): Array> { - const strikes = pianoSampleDefinitions.filter( - (definition) => definition.kind === 'strike' - ); - const releases = pianoSampleDefinitions.filter( - (definition) => definition.kind === 'release' - ); - // Layers closest to the core layer first: they are blended in soonest by - // getVelocityLayerPair; ties resolve to the louder layer. - const remainingLayers = [...new Set(strikes.map((strike) => strike.velocityLayer))] - .filter((layer) => layer !== coreVelocityLayer) - .sort( - (a, b) => Math.abs(a - coreVelocityLayer) - Math.abs(b - coreVelocityLayer) || b - a - ); +const getPianoSamplePath = (sample: PianoSampleDefinition): string => + `./samples/${sample.note}v12.m4a`; - return [ - releases, - ...remainingLayers.map((layer) => - strikes.filter((strike) => strike.velocityLayer === layer) - ), - ]; -} - -function getPianoSampleDefinitions( - modules: Record -): Array { - return Object.entries(modules) - .map(([path, url]) => getPianoSampleDefinition(path, url)) - .sort((a, b) => a.midi - b.midi || getSampleSortValue(a) - getSampleSortValue(b)); -} - -function getPianoSampleDefinition(path: string, url: string): PianoSampleDefinition { - const filename = path.split('/').pop() ?? path; - const strikeMatch = /^(?[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); +const getMidiForPianoSample = (sample: PianoSampleDefinition): number => { + const match = /^(?[A-G])(?sharp)?(?\d+)$/.exec(sample.note); if (!match?.groups) { - throw new Error(`Invalid piano sample note ${note}`); + throw new Error(`Invalid piano sample note ${sample.note}`); } const semitoneByName: Record = { @@ -378,25 +268,4 @@ function getMidiForPianoSampleNote(note: string): number { const octave = Number(match.groups.octave); const semitone = semitoneByName[match.groups.name] + (match.groups.accidental ? 1 : 0); return (octave + 1) * 12 + semitone; -} - -function getMidiForReleaseSample(releaseIndex: number): number { - const pianoLowestMidi = 21; - return pianoLowestMidi + releaseIndex - 1; -} - -const sortLoadedPianoSamples = ( - samples: Array -): 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 c733dcd..db06fc3 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 deleted file mode 100644 index 78e1d90..0000000 Binary files a/src/audio/samples/A0v16.m4a and /dev/null differ diff --git a/src/audio/samples/A0v4.m4a b/src/audio/samples/A0v4.m4a deleted file mode 100644 index a28725d..0000000 Binary files a/src/audio/samples/A0v4.m4a and /dev/null differ diff --git a/src/audio/samples/A0v8.m4a b/src/audio/samples/A0v8.m4a deleted file mode 100644 index e2825c1..0000000 Binary files a/src/audio/samples/A0v8.m4a and /dev/null differ diff --git a/src/audio/samples/A1v12.m4a b/src/audio/samples/A1v12.m4a index e6647b9..f1ed488 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 deleted file mode 100644 index d417db4..0000000 Binary files a/src/audio/samples/A1v16.m4a and /dev/null differ diff --git a/src/audio/samples/A1v4.m4a b/src/audio/samples/A1v4.m4a deleted file mode 100644 index 21d91f2..0000000 Binary files a/src/audio/samples/A1v4.m4a and /dev/null differ diff --git a/src/audio/samples/A1v8.m4a b/src/audio/samples/A1v8.m4a deleted file mode 100644 index e4067ef..0000000 Binary files a/src/audio/samples/A1v8.m4a and /dev/null differ diff --git a/src/audio/samples/A2v12.m4a b/src/audio/samples/A2v12.m4a index c16b3a3..52df725 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 deleted file mode 100644 index f4cd1db..0000000 Binary files a/src/audio/samples/A2v16.m4a and /dev/null differ diff --git a/src/audio/samples/A2v4.m4a b/src/audio/samples/A2v4.m4a deleted file mode 100644 index 43eb37c..0000000 Binary files a/src/audio/samples/A2v4.m4a and /dev/null differ diff --git a/src/audio/samples/A2v8.m4a b/src/audio/samples/A2v8.m4a deleted file mode 100644 index 963c496..0000000 Binary files a/src/audio/samples/A2v8.m4a and /dev/null differ diff --git a/src/audio/samples/A3v12.m4a b/src/audio/samples/A3v12.m4a index d0ae013..707a766 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 deleted file mode 100644 index 987d2b6..0000000 Binary files a/src/audio/samples/A3v16.m4a and /dev/null differ diff --git a/src/audio/samples/A3v4.m4a b/src/audio/samples/A3v4.m4a deleted file mode 100644 index ea30e35..0000000 Binary files a/src/audio/samples/A3v4.m4a and /dev/null differ diff --git a/src/audio/samples/A3v8.m4a b/src/audio/samples/A3v8.m4a deleted file mode 100644 index 25cc90b..0000000 Binary files a/src/audio/samples/A3v8.m4a and /dev/null differ diff --git a/src/audio/samples/A4v12.m4a b/src/audio/samples/A4v12.m4a index 8815c27..679bcff 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 deleted file mode 100644 index b2477ab..0000000 Binary files a/src/audio/samples/A4v16.m4a and /dev/null differ diff --git a/src/audio/samples/A4v4.m4a b/src/audio/samples/A4v4.m4a deleted file mode 100644 index eb74c3b..0000000 Binary files a/src/audio/samples/A4v4.m4a and /dev/null differ diff --git a/src/audio/samples/A4v8.m4a b/src/audio/samples/A4v8.m4a deleted file mode 100644 index ca065e4..0000000 Binary files a/src/audio/samples/A4v8.m4a and /dev/null differ diff --git a/src/audio/samples/A5v12.m4a b/src/audio/samples/A5v12.m4a index 13b02e1..4a2c896 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 deleted file mode 100644 index 8fc549d..0000000 Binary files a/src/audio/samples/A5v16.m4a and /dev/null differ diff --git a/src/audio/samples/A5v4.m4a b/src/audio/samples/A5v4.m4a deleted file mode 100644 index cc9e540..0000000 Binary files a/src/audio/samples/A5v4.m4a and /dev/null differ diff --git a/src/audio/samples/A5v8.m4a b/src/audio/samples/A5v8.m4a deleted file mode 100644 index 23bed94..0000000 Binary files a/src/audio/samples/A5v8.m4a and /dev/null differ diff --git a/src/audio/samples/A6v12.m4a b/src/audio/samples/A6v12.m4a index ac46371..abbd605 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 deleted file mode 100644 index 3b1a140..0000000 Binary files a/src/audio/samples/A6v16.m4a and /dev/null differ diff --git a/src/audio/samples/A6v4.m4a b/src/audio/samples/A6v4.m4a deleted file mode 100644 index ea0d0e6..0000000 Binary files a/src/audio/samples/A6v4.m4a and /dev/null differ diff --git a/src/audio/samples/A6v8.m4a b/src/audio/samples/A6v8.m4a deleted file mode 100644 index 0b1b4c8..0000000 Binary files a/src/audio/samples/A6v8.m4a and /dev/null differ diff --git a/src/audio/samples/A7v12.m4a b/src/audio/samples/A7v12.m4a index 1f66482..3fd6829 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 deleted file mode 100644 index 737a5c9..0000000 Binary files a/src/audio/samples/A7v16.m4a and /dev/null differ diff --git a/src/audio/samples/A7v4.m4a b/src/audio/samples/A7v4.m4a deleted file mode 100644 index 974e91e..0000000 Binary files a/src/audio/samples/A7v4.m4a and /dev/null differ diff --git a/src/audio/samples/A7v8.m4a b/src/audio/samples/A7v8.m4a deleted file mode 100644 index e380911..0000000 Binary files a/src/audio/samples/A7v8.m4a and /dev/null differ diff --git a/src/audio/samples/C1v12.m4a b/src/audio/samples/C1v12.m4a index c147d3a..59d5f61 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 deleted file mode 100644 index 26e48e8..0000000 Binary files a/src/audio/samples/C1v16.m4a and /dev/null differ diff --git a/src/audio/samples/C1v4.m4a b/src/audio/samples/C1v4.m4a deleted file mode 100644 index 842adf1..0000000 Binary files a/src/audio/samples/C1v4.m4a and /dev/null differ diff --git a/src/audio/samples/C1v8.m4a b/src/audio/samples/C1v8.m4a deleted file mode 100644 index 8fffad8..0000000 Binary files a/src/audio/samples/C1v8.m4a and /dev/null differ diff --git a/src/audio/samples/C2v12.m4a b/src/audio/samples/C2v12.m4a index ba32f35..9b636f9 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 deleted file mode 100644 index 664c985..0000000 Binary files a/src/audio/samples/C2v16.m4a and /dev/null differ diff --git a/src/audio/samples/C2v4.m4a b/src/audio/samples/C2v4.m4a deleted file mode 100644 index 07932a9..0000000 Binary files a/src/audio/samples/C2v4.m4a and /dev/null differ diff --git a/src/audio/samples/C2v8.m4a b/src/audio/samples/C2v8.m4a deleted file mode 100644 index 36d87d3..0000000 Binary files a/src/audio/samples/C2v8.m4a and /dev/null differ diff --git a/src/audio/samples/C3v12.m4a b/src/audio/samples/C3v12.m4a index 079efeb..e891e16 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 deleted file mode 100644 index af99f33..0000000 Binary files a/src/audio/samples/C3v16.m4a and /dev/null differ diff --git a/src/audio/samples/C3v4.m4a b/src/audio/samples/C3v4.m4a deleted file mode 100644 index f2c2ac8..0000000 Binary files a/src/audio/samples/C3v4.m4a and /dev/null differ diff --git a/src/audio/samples/C3v8.m4a b/src/audio/samples/C3v8.m4a deleted file mode 100644 index 616a5da..0000000 Binary files a/src/audio/samples/C3v8.m4a and /dev/null differ diff --git a/src/audio/samples/C4v12.m4a b/src/audio/samples/C4v12.m4a index df63a28..6061dc5 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 deleted file mode 100644 index bd1d761..0000000 Binary files a/src/audio/samples/C4v16.m4a and /dev/null differ diff --git a/src/audio/samples/C4v4.m4a b/src/audio/samples/C4v4.m4a deleted file mode 100644 index 073958e..0000000 Binary files a/src/audio/samples/C4v4.m4a and /dev/null differ diff --git a/src/audio/samples/C4v8.m4a b/src/audio/samples/C4v8.m4a deleted file mode 100644 index 501e2aa..0000000 Binary files a/src/audio/samples/C4v8.m4a and /dev/null differ diff --git a/src/audio/samples/C5v12.m4a b/src/audio/samples/C5v12.m4a index f67436d..a6d8898 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 deleted file mode 100644 index cbc307c..0000000 Binary files a/src/audio/samples/C5v16.m4a and /dev/null differ diff --git a/src/audio/samples/C5v4.m4a b/src/audio/samples/C5v4.m4a deleted file mode 100644 index bb18ba7..0000000 Binary files a/src/audio/samples/C5v4.m4a and /dev/null differ diff --git a/src/audio/samples/C5v8.m4a b/src/audio/samples/C5v8.m4a deleted file mode 100644 index fd700ed..0000000 Binary files a/src/audio/samples/C5v8.m4a and /dev/null differ diff --git a/src/audio/samples/C6v12.m4a b/src/audio/samples/C6v12.m4a index 2e89c33..745a4d6 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 deleted file mode 100644 index 3e284aa..0000000 Binary files a/src/audio/samples/C6v16.m4a and /dev/null differ diff --git a/src/audio/samples/C6v4.m4a b/src/audio/samples/C6v4.m4a deleted file mode 100644 index f810164..0000000 Binary files a/src/audio/samples/C6v4.m4a and /dev/null differ diff --git a/src/audio/samples/C6v8.m4a b/src/audio/samples/C6v8.m4a deleted file mode 100644 index 78a6b7f..0000000 Binary files a/src/audio/samples/C6v8.m4a and /dev/null differ diff --git a/src/audio/samples/C7v12.m4a b/src/audio/samples/C7v12.m4a index f767403..6470854 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 deleted file mode 100644 index b3a3da1..0000000 Binary files a/src/audio/samples/C7v16.m4a and /dev/null differ diff --git a/src/audio/samples/C7v4.m4a b/src/audio/samples/C7v4.m4a deleted file mode 100644 index a89c62b..0000000 Binary files a/src/audio/samples/C7v4.m4a and /dev/null differ diff --git a/src/audio/samples/C7v8.m4a b/src/audio/samples/C7v8.m4a deleted file mode 100644 index 291820e..0000000 Binary files a/src/audio/samples/C7v8.m4a and /dev/null differ diff --git a/src/audio/samples/C8v12.m4a b/src/audio/samples/C8v12.m4a index 08ddfb2..dfbbfd1 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 deleted file mode 100644 index 7532240..0000000 Binary files a/src/audio/samples/C8v16.m4a and /dev/null differ diff --git a/src/audio/samples/C8v4.m4a b/src/audio/samples/C8v4.m4a deleted file mode 100644 index 0376be1..0000000 Binary files a/src/audio/samples/C8v4.m4a and /dev/null differ diff --git a/src/audio/samples/C8v8.m4a b/src/audio/samples/C8v8.m4a deleted file mode 100644 index c05557d..0000000 Binary files a/src/audio/samples/C8v8.m4a and /dev/null differ diff --git a/src/audio/samples/Dsharp1v12.m4a b/src/audio/samples/Dsharp1v12.m4a index 4f9f343..22d0924 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 deleted file mode 100644 index 24164b1..0000000 Binary files a/src/audio/samples/Dsharp1v16.m4a and /dev/null differ diff --git a/src/audio/samples/Dsharp1v4.m4a b/src/audio/samples/Dsharp1v4.m4a deleted file mode 100644 index c8065ee..0000000 Binary files a/src/audio/samples/Dsharp1v4.m4a and /dev/null differ diff --git a/src/audio/samples/Dsharp1v8.m4a b/src/audio/samples/Dsharp1v8.m4a deleted file mode 100644 index c500458..0000000 Binary files a/src/audio/samples/Dsharp1v8.m4a and /dev/null differ diff --git a/src/audio/samples/Dsharp2v12.m4a b/src/audio/samples/Dsharp2v12.m4a index e2e6cb1..f25db22 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 deleted file mode 100644 index 3ec1be1..0000000 Binary files a/src/audio/samples/Dsharp2v16.m4a and /dev/null differ diff --git a/src/audio/samples/Dsharp2v4.m4a b/src/audio/samples/Dsharp2v4.m4a deleted file mode 100644 index 98abd9b..0000000 Binary files a/src/audio/samples/Dsharp2v4.m4a and /dev/null differ diff --git a/src/audio/samples/Dsharp2v8.m4a b/src/audio/samples/Dsharp2v8.m4a deleted file mode 100644 index 3427210..0000000 Binary files a/src/audio/samples/Dsharp2v8.m4a and /dev/null differ diff --git a/src/audio/samples/Dsharp3v12.m4a b/src/audio/samples/Dsharp3v12.m4a index 659bcc6..7e09558 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 deleted file mode 100644 index 3f11fc9..0000000 Binary files a/src/audio/samples/Dsharp3v16.m4a and /dev/null differ diff --git a/src/audio/samples/Dsharp3v4.m4a b/src/audio/samples/Dsharp3v4.m4a deleted file mode 100644 index e9b749a..0000000 Binary files a/src/audio/samples/Dsharp3v4.m4a and /dev/null differ diff --git a/src/audio/samples/Dsharp3v8.m4a b/src/audio/samples/Dsharp3v8.m4a deleted file mode 100644 index 4fe08ce..0000000 Binary files a/src/audio/samples/Dsharp3v8.m4a and /dev/null differ diff --git a/src/audio/samples/Dsharp4v12.m4a b/src/audio/samples/Dsharp4v12.m4a index 65f0191..d670fbb 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 deleted file mode 100644 index 15e0377..0000000 Binary files a/src/audio/samples/Dsharp4v16.m4a and /dev/null differ diff --git a/src/audio/samples/Dsharp4v4.m4a b/src/audio/samples/Dsharp4v4.m4a deleted file mode 100644 index bac5a82..0000000 Binary files a/src/audio/samples/Dsharp4v4.m4a and /dev/null differ diff --git a/src/audio/samples/Dsharp4v8.m4a b/src/audio/samples/Dsharp4v8.m4a deleted file mode 100644 index d55028b..0000000 Binary files a/src/audio/samples/Dsharp4v8.m4a and /dev/null differ diff --git a/src/audio/samples/Dsharp5v12.m4a b/src/audio/samples/Dsharp5v12.m4a index 3ea57fd..cdbd7b8 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 deleted file mode 100644 index ef8cc27..0000000 Binary files a/src/audio/samples/Dsharp5v16.m4a and /dev/null differ diff --git a/src/audio/samples/Dsharp5v4.m4a b/src/audio/samples/Dsharp5v4.m4a deleted file mode 100644 index 7eef1cf..0000000 Binary files a/src/audio/samples/Dsharp5v4.m4a and /dev/null differ diff --git a/src/audio/samples/Dsharp5v8.m4a b/src/audio/samples/Dsharp5v8.m4a deleted file mode 100644 index d4a86d5..0000000 Binary files a/src/audio/samples/Dsharp5v8.m4a and /dev/null differ diff --git a/src/audio/samples/Dsharp6v12.m4a b/src/audio/samples/Dsharp6v12.m4a index d328b32..b5ff787 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 deleted file mode 100644 index 25c28bb..0000000 Binary files a/src/audio/samples/Dsharp6v16.m4a and /dev/null differ diff --git a/src/audio/samples/Dsharp6v4.m4a b/src/audio/samples/Dsharp6v4.m4a deleted file mode 100644 index 152a9b9..0000000 Binary files a/src/audio/samples/Dsharp6v4.m4a and /dev/null differ diff --git a/src/audio/samples/Dsharp6v8.m4a b/src/audio/samples/Dsharp6v8.m4a deleted file mode 100644 index bcb9da8..0000000 Binary files a/src/audio/samples/Dsharp6v8.m4a and /dev/null differ diff --git a/src/audio/samples/Dsharp7v12.m4a b/src/audio/samples/Dsharp7v12.m4a index 1fdb2af..a9b6cda 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 deleted file mode 100644 index 03ba714..0000000 Binary files a/src/audio/samples/Dsharp7v16.m4a and /dev/null differ diff --git a/src/audio/samples/Dsharp7v4.m4a b/src/audio/samples/Dsharp7v4.m4a deleted file mode 100644 index 43bb385..0000000 Binary files a/src/audio/samples/Dsharp7v4.m4a and /dev/null differ diff --git a/src/audio/samples/Dsharp7v8.m4a b/src/audio/samples/Dsharp7v8.m4a deleted file mode 100644 index 5221103..0000000 Binary files a/src/audio/samples/Dsharp7v8.m4a and /dev/null differ diff --git a/src/audio/samples/Fsharp1v12.m4a b/src/audio/samples/Fsharp1v12.m4a index 369d073..752590f 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 deleted file mode 100644 index a325dbe..0000000 Binary files a/src/audio/samples/Fsharp1v16.m4a and /dev/null differ diff --git a/src/audio/samples/Fsharp1v4.m4a b/src/audio/samples/Fsharp1v4.m4a deleted file mode 100644 index 0de868d..0000000 Binary files a/src/audio/samples/Fsharp1v4.m4a and /dev/null differ diff --git a/src/audio/samples/Fsharp1v8.m4a b/src/audio/samples/Fsharp1v8.m4a deleted file mode 100644 index b251a0e..0000000 Binary files a/src/audio/samples/Fsharp1v8.m4a and /dev/null differ diff --git a/src/audio/samples/Fsharp2v12.m4a b/src/audio/samples/Fsharp2v12.m4a index 7fd354e..3477cb8 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 deleted file mode 100644 index 478084b..0000000 Binary files a/src/audio/samples/Fsharp2v16.m4a and /dev/null differ diff --git a/src/audio/samples/Fsharp2v4.m4a b/src/audio/samples/Fsharp2v4.m4a deleted file mode 100644 index c1bb3c6..0000000 Binary files a/src/audio/samples/Fsharp2v4.m4a and /dev/null differ diff --git a/src/audio/samples/Fsharp2v8.m4a b/src/audio/samples/Fsharp2v8.m4a deleted file mode 100644 index 1d82f4a..0000000 Binary files a/src/audio/samples/Fsharp2v8.m4a and /dev/null differ diff --git a/src/audio/samples/Fsharp3v12.m4a b/src/audio/samples/Fsharp3v12.m4a index 6a8637a..d36f8dd 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 deleted file mode 100644 index 733b52b..0000000 Binary files a/src/audio/samples/Fsharp3v16.m4a and /dev/null differ diff --git a/src/audio/samples/Fsharp3v4.m4a b/src/audio/samples/Fsharp3v4.m4a deleted file mode 100644 index d8baf75..0000000 Binary files a/src/audio/samples/Fsharp3v4.m4a and /dev/null differ diff --git a/src/audio/samples/Fsharp3v8.m4a b/src/audio/samples/Fsharp3v8.m4a deleted file mode 100644 index bc64a87..0000000 Binary files a/src/audio/samples/Fsharp3v8.m4a and /dev/null differ diff --git a/src/audio/samples/Fsharp4v12.m4a b/src/audio/samples/Fsharp4v12.m4a index b45f7ed..21df1e2 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 deleted file mode 100644 index 9475647..0000000 Binary files a/src/audio/samples/Fsharp4v16.m4a and /dev/null differ diff --git a/src/audio/samples/Fsharp4v4.m4a b/src/audio/samples/Fsharp4v4.m4a deleted file mode 100644 index 51c8c80..0000000 Binary files a/src/audio/samples/Fsharp4v4.m4a and /dev/null differ diff --git a/src/audio/samples/Fsharp4v8.m4a b/src/audio/samples/Fsharp4v8.m4a deleted file mode 100644 index da96c97..0000000 Binary files a/src/audio/samples/Fsharp4v8.m4a and /dev/null differ diff --git a/src/audio/samples/Fsharp5v12.m4a b/src/audio/samples/Fsharp5v12.m4a index 65131a3..1105dfb 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 deleted file mode 100644 index 7b652dd..0000000 Binary files a/src/audio/samples/Fsharp5v16.m4a and /dev/null differ diff --git a/src/audio/samples/Fsharp5v4.m4a b/src/audio/samples/Fsharp5v4.m4a deleted file mode 100644 index b01b1e1..0000000 Binary files a/src/audio/samples/Fsharp5v4.m4a and /dev/null differ diff --git a/src/audio/samples/Fsharp5v8.m4a b/src/audio/samples/Fsharp5v8.m4a deleted file mode 100644 index a8ce180..0000000 Binary files a/src/audio/samples/Fsharp5v8.m4a and /dev/null differ diff --git a/src/audio/samples/Fsharp6v12.m4a b/src/audio/samples/Fsharp6v12.m4a index 47a5929..d141d41 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 deleted file mode 100644 index 6375cec..0000000 Binary files a/src/audio/samples/Fsharp6v16.m4a and /dev/null differ diff --git a/src/audio/samples/Fsharp6v4.m4a b/src/audio/samples/Fsharp6v4.m4a deleted file mode 100644 index 63367e6..0000000 Binary files a/src/audio/samples/Fsharp6v4.m4a and /dev/null differ diff --git a/src/audio/samples/Fsharp6v8.m4a b/src/audio/samples/Fsharp6v8.m4a deleted file mode 100644 index 342c344..0000000 Binary files a/src/audio/samples/Fsharp6v8.m4a and /dev/null differ diff --git a/src/audio/samples/Fsharp7v12.m4a b/src/audio/samples/Fsharp7v12.m4a index e360c30..d69ac59 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 deleted file mode 100644 index dc2407f..0000000 Binary files a/src/audio/samples/Fsharp7v16.m4a and /dev/null differ diff --git a/src/audio/samples/Fsharp7v4.m4a b/src/audio/samples/Fsharp7v4.m4a deleted file mode 100644 index b108ee7..0000000 Binary files a/src/audio/samples/Fsharp7v4.m4a and /dev/null differ diff --git a/src/audio/samples/Fsharp7v8.m4a b/src/audio/samples/Fsharp7v8.m4a deleted file mode 100644 index f87360e..0000000 Binary files a/src/audio/samples/Fsharp7v8.m4a and /dev/null differ diff --git a/src/audio/samples/README.md b/src/audio/samples/README.md index c03cf89..bdde746 100644 --- a/src/audio/samples/README.md +++ b/src/audio/samples/README.md @@ -2,29 +2,15 @@ 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 packages: - -- @audio-samples/piano-velocity4 -- @audio-samples/piano-velocity8 -- @audio-samples/piano-velocity12 -- @audio-samples/piano-velocity16 -- @audio-samples/piano-release - +Source package: @audio-samples/piano-velocity12 Source recording: https://archive.org/details/SalamanderGrandPianoV3 License: https://creativecommons.org/licenses/by/3.0/ -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. +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`. -Repro notes: start from the matching OGG files in the source packages -(~88 kbps Vorbis, 48 kHz stereo) and transcode each selected sample to -AAC/M4A at 96 kbps (`ffmpeg -i .ogg -c:a aac -b:a 96k -movflags -+faststart .m4a`) without renaming the note/velocity or release stem. -Strike samples longer than 12 seconds are faded out from 10 s to 12 s and cut -there (`-af afade=t=out:st=10:d=2 -t 12`); the sampler's own gain envelope -masks the shortened tail. 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`. +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`. diff --git a/src/audio/samples/rel1.m4a b/src/audio/samples/rel1.m4a deleted file mode 100644 index 7ca5353..0000000 Binary files a/src/audio/samples/rel1.m4a and /dev/null differ diff --git a/src/audio/samples/rel10.m4a b/src/audio/samples/rel10.m4a deleted file mode 100644 index 992a39b..0000000 Binary files a/src/audio/samples/rel10.m4a and /dev/null differ diff --git a/src/audio/samples/rel11.m4a b/src/audio/samples/rel11.m4a deleted file mode 100644 index f4d300a..0000000 Binary files a/src/audio/samples/rel11.m4a and /dev/null differ diff --git a/src/audio/samples/rel12.m4a b/src/audio/samples/rel12.m4a deleted file mode 100644 index 1644c37..0000000 Binary files a/src/audio/samples/rel12.m4a and /dev/null differ diff --git a/src/audio/samples/rel13.m4a b/src/audio/samples/rel13.m4a deleted file mode 100644 index 4c42cea..0000000 Binary files a/src/audio/samples/rel13.m4a and /dev/null differ diff --git a/src/audio/samples/rel14.m4a b/src/audio/samples/rel14.m4a deleted file mode 100644 index ecd4ea8..0000000 Binary files a/src/audio/samples/rel14.m4a and /dev/null differ diff --git a/src/audio/samples/rel15.m4a b/src/audio/samples/rel15.m4a deleted file mode 100644 index 48ed55f..0000000 Binary files a/src/audio/samples/rel15.m4a and /dev/null differ diff --git a/src/audio/samples/rel16.m4a b/src/audio/samples/rel16.m4a deleted file mode 100644 index 166368c..0000000 Binary files a/src/audio/samples/rel16.m4a and /dev/null differ diff --git a/src/audio/samples/rel17.m4a b/src/audio/samples/rel17.m4a deleted file mode 100644 index b1754c9..0000000 Binary files a/src/audio/samples/rel17.m4a and /dev/null differ diff --git a/src/audio/samples/rel18.m4a b/src/audio/samples/rel18.m4a deleted file mode 100644 index d737fea..0000000 Binary files a/src/audio/samples/rel18.m4a and /dev/null differ diff --git a/src/audio/samples/rel19.m4a b/src/audio/samples/rel19.m4a deleted file mode 100644 index a996d25..0000000 Binary files a/src/audio/samples/rel19.m4a and /dev/null differ diff --git a/src/audio/samples/rel2.m4a b/src/audio/samples/rel2.m4a deleted file mode 100644 index 83923c8..0000000 Binary files a/src/audio/samples/rel2.m4a and /dev/null differ diff --git a/src/audio/samples/rel20.m4a b/src/audio/samples/rel20.m4a deleted file mode 100644 index b35bfc0..0000000 Binary files a/src/audio/samples/rel20.m4a and /dev/null differ diff --git a/src/audio/samples/rel21.m4a b/src/audio/samples/rel21.m4a deleted file mode 100644 index 0a39888..0000000 Binary files a/src/audio/samples/rel21.m4a and /dev/null differ diff --git a/src/audio/samples/rel22.m4a b/src/audio/samples/rel22.m4a deleted file mode 100644 index 24b8799..0000000 Binary files a/src/audio/samples/rel22.m4a and /dev/null differ diff --git a/src/audio/samples/rel23.m4a b/src/audio/samples/rel23.m4a deleted file mode 100644 index c39910c..0000000 Binary files a/src/audio/samples/rel23.m4a and /dev/null differ diff --git a/src/audio/samples/rel24.m4a b/src/audio/samples/rel24.m4a deleted file mode 100644 index 5854d9f..0000000 Binary files a/src/audio/samples/rel24.m4a and /dev/null differ diff --git a/src/audio/samples/rel25.m4a b/src/audio/samples/rel25.m4a deleted file mode 100644 index c8c9eea..0000000 Binary files a/src/audio/samples/rel25.m4a and /dev/null differ diff --git a/src/audio/samples/rel26.m4a b/src/audio/samples/rel26.m4a deleted file mode 100644 index 8c93258..0000000 Binary files a/src/audio/samples/rel26.m4a and /dev/null differ diff --git a/src/audio/samples/rel27.m4a b/src/audio/samples/rel27.m4a deleted file mode 100644 index 1625f68..0000000 Binary files a/src/audio/samples/rel27.m4a and /dev/null differ diff --git a/src/audio/samples/rel28.m4a b/src/audio/samples/rel28.m4a deleted file mode 100644 index dbde742..0000000 Binary files a/src/audio/samples/rel28.m4a and /dev/null differ diff --git a/src/audio/samples/rel29.m4a b/src/audio/samples/rel29.m4a deleted file mode 100644 index ab47e2a..0000000 Binary files a/src/audio/samples/rel29.m4a and /dev/null differ diff --git a/src/audio/samples/rel3.m4a b/src/audio/samples/rel3.m4a deleted file mode 100644 index b0088de..0000000 Binary files a/src/audio/samples/rel3.m4a and /dev/null differ diff --git a/src/audio/samples/rel30.m4a b/src/audio/samples/rel30.m4a deleted file mode 100644 index 29b9cad..0000000 Binary files a/src/audio/samples/rel30.m4a and /dev/null differ diff --git a/src/audio/samples/rel31.m4a b/src/audio/samples/rel31.m4a deleted file mode 100644 index beb8a0e..0000000 Binary files a/src/audio/samples/rel31.m4a and /dev/null differ diff --git a/src/audio/samples/rel32.m4a b/src/audio/samples/rel32.m4a deleted file mode 100644 index 5aa4f29..0000000 Binary files a/src/audio/samples/rel32.m4a and /dev/null differ diff --git a/src/audio/samples/rel33.m4a b/src/audio/samples/rel33.m4a deleted file mode 100644 index 92777bf..0000000 Binary files a/src/audio/samples/rel33.m4a and /dev/null differ diff --git a/src/audio/samples/rel34.m4a b/src/audio/samples/rel34.m4a deleted file mode 100644 index e10a06a..0000000 Binary files a/src/audio/samples/rel34.m4a and /dev/null differ diff --git a/src/audio/samples/rel35.m4a b/src/audio/samples/rel35.m4a deleted file mode 100644 index 23d44e2..0000000 Binary files a/src/audio/samples/rel35.m4a and /dev/null differ diff --git a/src/audio/samples/rel36.m4a b/src/audio/samples/rel36.m4a deleted file mode 100644 index c44f552..0000000 Binary files a/src/audio/samples/rel36.m4a and /dev/null differ diff --git a/src/audio/samples/rel37.m4a b/src/audio/samples/rel37.m4a deleted file mode 100644 index 65590ff..0000000 Binary files a/src/audio/samples/rel37.m4a and /dev/null differ diff --git a/src/audio/samples/rel38.m4a b/src/audio/samples/rel38.m4a deleted file mode 100644 index 9d23d5f..0000000 Binary files a/src/audio/samples/rel38.m4a and /dev/null differ diff --git a/src/audio/samples/rel39.m4a b/src/audio/samples/rel39.m4a deleted file mode 100644 index 15b84cc..0000000 Binary files a/src/audio/samples/rel39.m4a and /dev/null differ diff --git a/src/audio/samples/rel4.m4a b/src/audio/samples/rel4.m4a deleted file mode 100644 index 3632881..0000000 Binary files a/src/audio/samples/rel4.m4a and /dev/null differ diff --git a/src/audio/samples/rel40.m4a b/src/audio/samples/rel40.m4a deleted file mode 100644 index 61d5273..0000000 Binary files a/src/audio/samples/rel40.m4a and /dev/null differ diff --git a/src/audio/samples/rel41.m4a b/src/audio/samples/rel41.m4a deleted file mode 100644 index a047553..0000000 Binary files a/src/audio/samples/rel41.m4a and /dev/null differ diff --git a/src/audio/samples/rel42.m4a b/src/audio/samples/rel42.m4a deleted file mode 100644 index d6479d0..0000000 Binary files a/src/audio/samples/rel42.m4a and /dev/null differ diff --git a/src/audio/samples/rel43.m4a b/src/audio/samples/rel43.m4a deleted file mode 100644 index cd24d07..0000000 Binary files a/src/audio/samples/rel43.m4a and /dev/null differ diff --git a/src/audio/samples/rel44.m4a b/src/audio/samples/rel44.m4a deleted file mode 100644 index d72260e..0000000 Binary files a/src/audio/samples/rel44.m4a and /dev/null differ diff --git a/src/audio/samples/rel45.m4a b/src/audio/samples/rel45.m4a deleted file mode 100644 index fe53f29..0000000 Binary files a/src/audio/samples/rel45.m4a and /dev/null differ diff --git a/src/audio/samples/rel46.m4a b/src/audio/samples/rel46.m4a deleted file mode 100644 index e1b1ae0..0000000 Binary files a/src/audio/samples/rel46.m4a and /dev/null differ diff --git a/src/audio/samples/rel47.m4a b/src/audio/samples/rel47.m4a deleted file mode 100644 index de4a71c..0000000 Binary files a/src/audio/samples/rel47.m4a and /dev/null differ diff --git a/src/audio/samples/rel48.m4a b/src/audio/samples/rel48.m4a deleted file mode 100644 index bb05607..0000000 Binary files a/src/audio/samples/rel48.m4a and /dev/null differ diff --git a/src/audio/samples/rel49.m4a b/src/audio/samples/rel49.m4a deleted file mode 100644 index 100b2ca..0000000 Binary files a/src/audio/samples/rel49.m4a and /dev/null differ diff --git a/src/audio/samples/rel5.m4a b/src/audio/samples/rel5.m4a deleted file mode 100644 index c202e32..0000000 Binary files a/src/audio/samples/rel5.m4a and /dev/null differ diff --git a/src/audio/samples/rel50.m4a b/src/audio/samples/rel50.m4a deleted file mode 100644 index 970b66f..0000000 Binary files a/src/audio/samples/rel50.m4a and /dev/null differ diff --git a/src/audio/samples/rel51.m4a b/src/audio/samples/rel51.m4a deleted file mode 100644 index aeabd22..0000000 Binary files a/src/audio/samples/rel51.m4a and /dev/null differ diff --git a/src/audio/samples/rel52.m4a b/src/audio/samples/rel52.m4a deleted file mode 100644 index 5b73c36..0000000 Binary files a/src/audio/samples/rel52.m4a and /dev/null differ diff --git a/src/audio/samples/rel53.m4a b/src/audio/samples/rel53.m4a deleted file mode 100644 index 3e57830..0000000 Binary files a/src/audio/samples/rel53.m4a and /dev/null differ diff --git a/src/audio/samples/rel54.m4a b/src/audio/samples/rel54.m4a deleted file mode 100644 index fe67f73..0000000 Binary files a/src/audio/samples/rel54.m4a and /dev/null differ diff --git a/src/audio/samples/rel55.m4a b/src/audio/samples/rel55.m4a deleted file mode 100644 index 9f58822..0000000 Binary files a/src/audio/samples/rel55.m4a and /dev/null differ diff --git a/src/audio/samples/rel56.m4a b/src/audio/samples/rel56.m4a deleted file mode 100644 index 2964944..0000000 Binary files a/src/audio/samples/rel56.m4a and /dev/null differ diff --git a/src/audio/samples/rel57.m4a b/src/audio/samples/rel57.m4a deleted file mode 100644 index b78f42d..0000000 Binary files a/src/audio/samples/rel57.m4a and /dev/null differ diff --git a/src/audio/samples/rel58.m4a b/src/audio/samples/rel58.m4a deleted file mode 100644 index 9f6c81d..0000000 Binary files a/src/audio/samples/rel58.m4a and /dev/null differ diff --git a/src/audio/samples/rel59.m4a b/src/audio/samples/rel59.m4a deleted file mode 100644 index 5a34bdd..0000000 Binary files a/src/audio/samples/rel59.m4a and /dev/null differ diff --git a/src/audio/samples/rel6.m4a b/src/audio/samples/rel6.m4a deleted file mode 100644 index ba233eb..0000000 Binary files a/src/audio/samples/rel6.m4a and /dev/null differ diff --git a/src/audio/samples/rel60.m4a b/src/audio/samples/rel60.m4a deleted file mode 100644 index 86c4124..0000000 Binary files a/src/audio/samples/rel60.m4a and /dev/null differ diff --git a/src/audio/samples/rel61.m4a b/src/audio/samples/rel61.m4a deleted file mode 100644 index a5edf7c..0000000 Binary files a/src/audio/samples/rel61.m4a and /dev/null differ diff --git a/src/audio/samples/rel62.m4a b/src/audio/samples/rel62.m4a deleted file mode 100644 index 88fb141..0000000 Binary files a/src/audio/samples/rel62.m4a and /dev/null differ diff --git a/src/audio/samples/rel63.m4a b/src/audio/samples/rel63.m4a deleted file mode 100644 index afcdba7..0000000 Binary files a/src/audio/samples/rel63.m4a and /dev/null differ diff --git a/src/audio/samples/rel64.m4a b/src/audio/samples/rel64.m4a deleted file mode 100644 index d0056b1..0000000 Binary files a/src/audio/samples/rel64.m4a and /dev/null differ diff --git a/src/audio/samples/rel65.m4a b/src/audio/samples/rel65.m4a deleted file mode 100644 index 0d0ea07..0000000 Binary files a/src/audio/samples/rel65.m4a and /dev/null differ diff --git a/src/audio/samples/rel66.m4a b/src/audio/samples/rel66.m4a deleted file mode 100644 index 53286fd..0000000 Binary files a/src/audio/samples/rel66.m4a and /dev/null differ diff --git a/src/audio/samples/rel67.m4a b/src/audio/samples/rel67.m4a deleted file mode 100644 index 98d3127..0000000 Binary files a/src/audio/samples/rel67.m4a and /dev/null differ diff --git a/src/audio/samples/rel68.m4a b/src/audio/samples/rel68.m4a deleted file mode 100644 index 74600b5..0000000 Binary files a/src/audio/samples/rel68.m4a and /dev/null differ diff --git a/src/audio/samples/rel69.m4a b/src/audio/samples/rel69.m4a deleted file mode 100644 index 092ef5c..0000000 Binary files a/src/audio/samples/rel69.m4a and /dev/null differ diff --git a/src/audio/samples/rel7.m4a b/src/audio/samples/rel7.m4a deleted file mode 100644 index d86edfb..0000000 Binary files a/src/audio/samples/rel7.m4a and /dev/null differ diff --git a/src/audio/samples/rel70.m4a b/src/audio/samples/rel70.m4a deleted file mode 100644 index 4d92658..0000000 Binary files a/src/audio/samples/rel70.m4a and /dev/null differ diff --git a/src/audio/samples/rel71.m4a b/src/audio/samples/rel71.m4a deleted file mode 100644 index 77c6bab..0000000 Binary files a/src/audio/samples/rel71.m4a and /dev/null differ diff --git a/src/audio/samples/rel72.m4a b/src/audio/samples/rel72.m4a deleted file mode 100644 index 5b83641..0000000 Binary files a/src/audio/samples/rel72.m4a and /dev/null differ diff --git a/src/audio/samples/rel73.m4a b/src/audio/samples/rel73.m4a deleted file mode 100644 index 7a5d28d..0000000 Binary files a/src/audio/samples/rel73.m4a and /dev/null differ diff --git a/src/audio/samples/rel74.m4a b/src/audio/samples/rel74.m4a deleted file mode 100644 index 6493a9d..0000000 Binary files a/src/audio/samples/rel74.m4a and /dev/null differ diff --git a/src/audio/samples/rel75.m4a b/src/audio/samples/rel75.m4a deleted file mode 100644 index 4397728..0000000 Binary files a/src/audio/samples/rel75.m4a and /dev/null differ diff --git a/src/audio/samples/rel76.m4a b/src/audio/samples/rel76.m4a deleted file mode 100644 index df7c27c..0000000 Binary files a/src/audio/samples/rel76.m4a and /dev/null differ diff --git a/src/audio/samples/rel77.m4a b/src/audio/samples/rel77.m4a deleted file mode 100644 index 478f1d0..0000000 Binary files a/src/audio/samples/rel77.m4a and /dev/null differ diff --git a/src/audio/samples/rel78.m4a b/src/audio/samples/rel78.m4a deleted file mode 100644 index 662dc2c..0000000 Binary files a/src/audio/samples/rel78.m4a and /dev/null differ diff --git a/src/audio/samples/rel79.m4a b/src/audio/samples/rel79.m4a deleted file mode 100644 index 9184127..0000000 Binary files a/src/audio/samples/rel79.m4a and /dev/null differ diff --git a/src/audio/samples/rel8.m4a b/src/audio/samples/rel8.m4a deleted file mode 100644 index d2b9530..0000000 Binary files a/src/audio/samples/rel8.m4a and /dev/null differ diff --git a/src/audio/samples/rel80.m4a b/src/audio/samples/rel80.m4a deleted file mode 100644 index 4013bdf..0000000 Binary files a/src/audio/samples/rel80.m4a and /dev/null differ diff --git a/src/audio/samples/rel81.m4a b/src/audio/samples/rel81.m4a deleted file mode 100644 index a365a87..0000000 Binary files a/src/audio/samples/rel81.m4a and /dev/null differ diff --git a/src/audio/samples/rel82.m4a b/src/audio/samples/rel82.m4a deleted file mode 100644 index 19bb91e..0000000 Binary files a/src/audio/samples/rel82.m4a and /dev/null differ diff --git a/src/audio/samples/rel83.m4a b/src/audio/samples/rel83.m4a deleted file mode 100644 index c6f5838..0000000 Binary files a/src/audio/samples/rel83.m4a and /dev/null differ diff --git a/src/audio/samples/rel84.m4a b/src/audio/samples/rel84.m4a deleted file mode 100644 index 5a26fb4..0000000 Binary files a/src/audio/samples/rel84.m4a and /dev/null differ diff --git a/src/audio/samples/rel85.m4a b/src/audio/samples/rel85.m4a deleted file mode 100644 index edbe215..0000000 Binary files a/src/audio/samples/rel85.m4a and /dev/null differ diff --git a/src/audio/samples/rel86.m4a b/src/audio/samples/rel86.m4a deleted file mode 100644 index 990ba31..0000000 Binary files a/src/audio/samples/rel86.m4a and /dev/null differ diff --git a/src/audio/samples/rel87.m4a b/src/audio/samples/rel87.m4a deleted file mode 100644 index 4f98e79..0000000 Binary files a/src/audio/samples/rel87.m4a and /dev/null differ diff --git a/src/audio/samples/rel88.m4a b/src/audio/samples/rel88.m4a deleted file mode 100644 index bc4f12b..0000000 Binary files a/src/audio/samples/rel88.m4a and /dev/null differ diff --git a/src/audio/samples/rel9.m4a b/src/audio/samples/rel9.m4a deleted file mode 100644 index 7c0e4cf..0000000 Binary files a/src/audio/samples/rel9.m4a and /dev/null differ diff --git a/src/config/eraser-size.ts b/src/config/eraser-size.ts deleted file mode 100644 index 008bee7..0000000 --- a/src/config/eraser-size.ts +++ /dev/null @@ -1,84 +0,0 @@ -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 493d497..fefd93c 100644 --- a/src/game-loop/eraser-preview.ts +++ b/src/game-loop/eraser-preview.ts @@ -1,4 +1,3 @@ -import { getEffectiveEraserSize } from '../config/eraser-size'; import { settings } from '../settings'; export class EraserPreview { @@ -50,15 +49,9 @@ export class EraserPreview { }; } - 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 (this.previousSize !== settings.eraserSize) { + this.element.style.setProperty('--eraser-preview-size', `${settings.eraserSize}px`); + this.previousSize = settings.eraserSize; } if ( @@ -70,6 +63,7 @@ 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 d53a73c..25d2323 100644 --- a/src/game-loop/game-loop.ts +++ b/src/game-loop/game-loop.ts @@ -2,7 +2,6 @@ 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'; @@ -210,11 +209,7 @@ export default class GameLoop { const runtimeSettings = { ...settings }; const introProgress = this.introPrompt.progress; const canvasPixelRatio = this.canvasPixelRatio; - const eraserCssSize = getEffectiveEraserSize( - runtimeSettings.eraserSize, - getElementCssPixelSize(this.canvas) - ); - const eraserPixelSize = eraserCssSize * canvasPixelRatio; + const eraserPixelSize = runtimeSettings.eraserSize * canvasPixelRatio; const isErasing = this.pointerInput.isEraseMode; const accentColor = channelColors[runtimeSettings.selectedColorIndex] ?? channelColors[0]; diff --git a/src/index.ts b/src/index.ts index d1f347a..79b5251 100644 --- a/src/index.ts +++ b/src/index.ts @@ -156,10 +156,8 @@ const main = async () => { } }); - // Only the core velocity layer loads before Start is enabled so the first - // audible piano note always uses the sampler; the remaining layers and - // release samples stream in the background. The Start tap still unlocks - // the AudioContext. + // Samples load before Start is enabled so the first audible piano note + // always uses the sampler. The Start tap still unlocks the AudioContext. splash.showLoadingBar(); const fontsReady = document.fonts.ready.catch((error) => { ErrorHandler.addException(error, { diff --git a/src/page/audio-control.ts b/src/page/audio-control.ts index 0918eda..159c6c2 100644 --- a/src/page/audio-control.ts +++ b/src/page/audio-control.ts @@ -1,17 +1,18 @@ -import { DEFAULT_AUDIO_VOLUME, MAX_AUDIO_VOLUME } from '../audio/garden-audio-config'; +import { DEFAULT_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 = MAX_AUDIO_VOLUME; +const AUDIO_VOLUME_MAX = 1; 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, safeValue)); + return Math.min(AUDIO_VOLUME_MAX, Math.max(AUDIO_VOLUME_MIN, clamp01(safeValue))); }; const readInitialAudioVolume = (): number => { @@ -82,7 +83,6 @@ 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,10 +102,7 @@ export class AudioControl { this.volumeControl.title = isEffectivelyMuted ? `Muted, ${volumePercent}% volume` : `${volumePercent}% volume`; - this.volumeControl.style.setProperty( - '--volume-progress', - `${volumeProgressPercent}%` - ); + this.volumeControl.style.setProperty('--volume-progress', `${volumePercent}%`); 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 372c63e..1d93778 100644 --- a/src/page/eraser-size-control.test.ts +++ b/src/page/eraser-size-control.test.ts @@ -3,11 +3,9 @@ import { describe, expect, it } from 'vitest'; import { ERASER_SIZE_MAX, ERASER_SIZE_MIN, - getEffectiveEraserSize, getEraserSizeFromSliderRatio, - getEraserSizeMaxForCssSize, getEraserSliderRatioFromSize, -} from '../config/eraser-size'; +} from './eraser-size-control'; describe('eraser size slider mapping', () => { it('maps slider position quadratically to eraser size', () => { @@ -25,15 +23,4 @@ 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 beb2d0f..8f93d51 100644 --- a/src/page/eraser-size-control.ts +++ b/src/page/eraser-size-control.ts @@ -1,25 +1,40 @@ -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 clampStoredEraserSize = (value: number): number => - clampEraserSize(value, ERASER_SIZE_MAX, DEFAULT_ERASER_SIZE); +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)); interface EraserSizeControlOptions { getGame: () => GameLoop | null; @@ -33,7 +48,6 @@ 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) { @@ -41,10 +55,7 @@ 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), - this.getResponsiveMaxSize() - ); + settings.eraserSize = getEraserSizeFromSliderRatio(Number(this.slider.value)); this.activate(); this.render(); this.options.onChange(); @@ -52,21 +63,19 @@ export class EraserSizeControl { } public render(): void { - const maxSize = this.getResponsiveMaxSize(); - const storedSize = clampStoredEraserSize(settings.eraserSize); - if (settings.eraserSize !== storedSize) { - settings.eraserSize = storedSize; + const size = clampEraserSize(settings.eraserSize); + if (settings.eraserSize !== size) { + settings.eraserSize = size; } - const size = clampEraserSize(storedSize, maxSize, DEFAULT_ERASER_SIZE); - const sliderRatio = getEraserSliderRatioFromSize(size, maxSize); + const sliderRatio = getEraserSliderRatioFromSize(size); 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, maxSize); + const sizeRatio = getEraserSizeRatio(size); const scale = ERASER_CONTROL_SCALE_MIN + (ERASER_CONTROL_SCALE_MAX - ERASER_CONTROL_SCALE_MIN) * sizeRatio; @@ -86,10 +95,6 @@ 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 d9a519b..925f915 100644 --- a/src/style/_loading.scss +++ b/src/style/_loading.scss @@ -52,7 +52,7 @@ > .splash-description { margin: 0; - max-width: 70ch; + max-width: 28ch; 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 029a044..3e92b02 100644 --- a/src/style/_panels.scss +++ b/src/style/_panels.scss @@ -2,10 +2,13 @@ html > body > aside.control-dock > .info-page { width: min(100%, 520px); - max-height: 200vh; - max-height: 200dvh; + max-height: min(62vh, 480px); + max-height: min(62dvh, 480px); margin: 0 auto 10px; - overflow: hidden; + overflow-x: hidden; + overflow-y: auto; + overscroll-behavior: contain; + touch-action: pan-y; border: 1px solid rgb(255 255 255 / 46%); border-radius: 8px; background: @@ -17,12 +20,26 @@ 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; @@ -133,8 +150,9 @@ html > body > aside.control-dock > .info-page { line-height: 1.18; } - .info-page__main, - .info-page__notes { + .info-page__lede, + .info-page__notes, + .info-page__meta { max-width: 56ch; overflow-wrap: break-word; color: rgb(25 35 32); @@ -142,7 +160,8 @@ html > body > aside.control-dock > .info-page { line-height: 1.56; } - .info-page__main { + .info-page__lede, + .info-page__meta { margin-bottom: 0; hyphens: auto; } @@ -164,6 +183,19 @@ 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; @@ -193,16 +225,45 @@ 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__main, + .info-page__lede, .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 9996bc5..23c82aa 100644 --- a/src/style/common.scss +++ b/src/style/common.scss @@ -16,24 +16,15 @@ 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 {