diff --git a/index.html b/index.html index 1414388..126b34c 100644 --- a/index.html +++ b/index.html @@ -4,14 +4,14 @@ @@ -22,7 +22,7 @@ @@ -35,7 +35,7 @@ @@ -46,7 +46,7 @@ "@type": "WebApplication", "name": "Fleeting Garden", "url": "https://schmelczer.dev/fleeting/", - "description": "Plant colour, fold gestures with mirrors, and watch small agents turn each brushstroke into a shifting WebGPU garden.", + "description": "Draw colour into a canvas that keeps moving. Your strokes become paths for life that splits, drifts, and redraws the surface over time.", "image": "https://schmelczer.dev/fleeting/og-image.jpg", "applicationCategory": "DesignApplication", "operatingSystem": "Any", @@ -91,7 +91,7 @@

Fleeting Garden

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

@@ -139,24 +139,19 @@ > -

- A garden is what we tend; the wild is what we get the moment we look away. - Both happen here at once. Your strokes plant colour, small agents follow them, - branch off, and slowly rewrite the patch you laid down into something you - didn't quite plan. +

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

-

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

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

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