Compare commits
9 commits
asch/chang
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 1a195e89b8 | |||
| 271ab7443c | |||
| 8d3ccd6639 | |||
| ff924676c0 | |||
| 4bf67b47cb | |||
| 4e24df1511 | |||
| 0fddad6b45 | |||
| 79638d5fa4 | |||
| f863588060 |
260 changed files with 1451 additions and 974 deletions
5
assets/icons/close.svg
Normal file
5
assets/icons/close.svg
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M6.4 5 12 10.6 17.6 5 19 6.4 13.4 12 19 17.6 17.6 19 12 13.4 6.4 19 5 17.6 10.6 12 5 6.4z"
|
||||
/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 179 B |
74
index.html
74
index.html
|
|
@ -4,14 +4,14 @@
|
|||
<meta charset="utf-8" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width,initial-scale=1,viewport-fit=cover"
|
||||
content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no,viewport-fit=cover"
|
||||
/>
|
||||
<meta name="theme-color" content="#10151f" />
|
||||
<meta name="robots" content="index,follow" />
|
||||
<meta name="author" content="Andras Schmelczer" />
|
||||
<meta
|
||||
name="description"
|
||||
content="Tend it while you can. The garden returns to weather either way. A WebGPU drawing toy in your browser."
|
||||
content="Draw colour into a canvas that keeps moving. Your strokes become paths for life that splits, drifts, and redraws the surface over time."
|
||||
/>
|
||||
|
||||
<link rel="canonical" href="https://schmelczer.dev/fleeting/" />
|
||||
|
|
@ -22,7 +22,7 @@
|
|||
<meta property="og:locale" content="en_US" />
|
||||
<meta
|
||||
property="og:description"
|
||||
content="Tend it while you can. The garden returns to weather either way. A WebGPU drawing toy in your browser."
|
||||
content="Draw colour into a canvas that keeps moving. Your strokes become paths for life that splits, drifts, and redraws the surface over time."
|
||||
/>
|
||||
<meta property="og:url" content="https://schmelczer.dev/fleeting/" />
|
||||
<meta property="og:image" content="https://schmelczer.dev/fleeting/og-image.jpg" />
|
||||
|
|
@ -35,7 +35,7 @@
|
|||
<meta name="twitter:title" content="Fleeting Garden" />
|
||||
<meta
|
||||
name="twitter:description"
|
||||
content="Tend it while you can. The garden returns to weather either way. A WebGPU drawing toy in your browser."
|
||||
content="Draw colour into a canvas that keeps moving. Your strokes become paths for life that splits, drifts, and redraws the surface over time."
|
||||
/>
|
||||
<meta name="twitter:image" content="https://schmelczer.dev/fleeting/og-image.jpg" />
|
||||
<meta name="twitter:image:alt" content="Fleeting Garden social preview image." />
|
||||
|
|
@ -46,7 +46,7 @@
|
|||
"@type": "WebApplication",
|
||||
"name": "Fleeting Garden",
|
||||
"url": "https://schmelczer.dev/fleeting/",
|
||||
"description": "Tend it while you can. The garden returns to weather either way. A WebGPU drawing toy in your browser.",
|
||||
"description": "Draw colour into a canvas that keeps moving. Your strokes become paths for life that splits, drifts, and redraws the surface over time.",
|
||||
"image": "https://schmelczer.dev/fleeting/og-image.jpg",
|
||||
"applicationCategory": "DesignApplication",
|
||||
"operatingSystem": "Any",
|
||||
|
|
@ -91,7 +91,8 @@
|
|||
<div class="splash" data-visible="true">
|
||||
<h1 class="splash-title">Fleeting Garden</h1>
|
||||
<p class="splash-description">
|
||||
Tend it while you can. The garden returns to weather either way.
|
||||
Draw colour into a canvas that keeps moving. Your strokes become paths for
|
||||
life that splits, drifts, and redraws the surface over time.
|
||||
</p>
|
||||
<button class="start-button" type="button" disabled>Start</button>
|
||||
</div>
|
||||
|
|
@ -118,35 +119,54 @@
|
|||
id="info-panel"
|
||||
class="hidden info-page"
|
||||
role="region"
|
||||
aria-label="About panel"
|
||||
aria-labelledby="info-panel-title"
|
||||
aria-hidden="true"
|
||||
tabindex="-1"
|
||||
inert
|
||||
>
|
||||
<section>
|
||||
<h1>Fleeting Garden</h1>
|
||||
<p>
|
||||
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.
|
||||
<div class="info-page__content">
|
||||
<header class="info-page__header">
|
||||
<span class="info-page__mark" aria-hidden="true"></span>
|
||||
<div class="info-page__heading">
|
||||
<p class="info-page__eyebrow">About</p>
|
||||
<h2 id="info-panel-title">Fleeting Garden</h2>
|
||||
</div>
|
||||
<button
|
||||
class="info-page__close"
|
||||
data-control="info-close"
|
||||
type="button"
|
||||
aria-label="Close about panel"
|
||||
title="Close"
|
||||
></button>
|
||||
</header>
|
||||
|
||||
<p class="info-page__main">
|
||||
Draw into a field of particles and watch the simulation fold your marks back
|
||||
into motion.
|
||||
</p>
|
||||
<p>
|
||||
Three swatches plant the line. The eraser carves a clearing. The mirror folds
|
||||
one gesture into many, like footpaths around a hidden well.
|
||||
</p>
|
||||
<p>
|
||||
Switch vibes to change the season; your shapes stay, the light moves. Add or
|
||||
quiet the piano. Restart when you want a fresh field. Take a snapshot if you
|
||||
want to keep one particular instant of weather.
|
||||
</p>
|
||||
<p>
|
||||
Built with WebGPU, running locally in your browser. More of my work at
|
||||
|
||||
<ul class="info-page__notes">
|
||||
<li>Choose one of three colour swatches, then drag to draw.</li>
|
||||
<li>Use the eraser to clear space and reshape the field.</li>
|
||||
<li>The mirror repeats each gesture across the canvas.</li>
|
||||
<li>The arrows switch the current atmosphere.</li>
|
||||
</ul>
|
||||
|
||||
<p class="info-page__main">
|
||||
My implementation of
|
||||
<a
|
||||
href="https://cargocollective.com/sagejenson/physarum"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>physarum simulation</a
|
||||
>
|
||||
introduces drawing and procedurally generated piano for a more immersive
|
||||
experience. Learn more about my work at
|
||||
<a href="https://schmelczer.dev" target="_blank" rel="noopener"
|
||||
>schmelczer.dev</a
|
||||
>.
|
||||
>
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="toolbar-row" role="toolbar" aria-label="Garden toolbar">
|
||||
|
|
|
|||
Binary file not shown.
|
Before Width: | Height: | Size: 301 KiB After Width: | Height: | Size: 164 KiB |
|
|
@ -4,9 +4,13 @@ import {
|
|||
type PlausibleEventOptions,
|
||||
} from '@plausible-analytics/tracker';
|
||||
|
||||
import { appConfig } from './config';
|
||||
import type { VibeId } from './vibes';
|
||||
|
||||
const ANALYTICS_AUTO_CAPTURE_PAGEVIEWS = true;
|
||||
const ANALYTICS_DOMAIN = 'schmelczer.dev/fleeting';
|
||||
const ANALYTICS_ENDPOINT = 'https://stats.schmelczer.dev/status';
|
||||
const ANALYTICS_LOGGING = import.meta.env.DEV;
|
||||
|
||||
let isInitialized = false;
|
||||
|
||||
const track = (eventName: string, options: PlausibleEventOptions = {}) => {
|
||||
|
|
@ -24,10 +28,10 @@ export const initAnalytics = () => {
|
|||
|
||||
try {
|
||||
plausibleInit({
|
||||
domain: appConfig.analytics.domain,
|
||||
endpoint: appConfig.analytics.endpoint,
|
||||
autoCapturePageviews: appConfig.analytics.autoCapturePageviews,
|
||||
logging: appConfig.analytics.logging,
|
||||
domain: ANALYTICS_DOMAIN,
|
||||
endpoint: ANALYTICS_ENDPOINT,
|
||||
autoCapturePageviews: ANALYTICS_AUTO_CAPTURE_PAGEVIEWS,
|
||||
logging: ANALYTICS_LOGGING,
|
||||
});
|
||||
isInitialized = true;
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<LoadedPianoReleaseSample>;
|
||||
strikes: Array<LoadedPianoStrikeSample>;
|
||||
}
|
||||
|
||||
export interface PianoNote {
|
||||
midi: number;
|
||||
velocity: number;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<AudioBufferSourceNode>;
|
||||
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<LoadedPianoSample> = [];
|
||||
private activeVoices: Array<ActivePianoVoice> = [];
|
||||
private releaseSamples: Array<LoadedPianoReleaseSample> = [];
|
||||
private strikeSamples: Array<LoadedPianoStrikeSample> = [];
|
||||
private velocityLayers: Array<number> = [];
|
||||
|
||||
public constructor(
|
||||
private readonly config: GardenAudioConfig,
|
||||
|
|
@ -35,7 +59,7 @@ export class PianoSampler {
|
|||
) {}
|
||||
|
||||
public load(context: BaseAudioContext): Promise<void> {
|
||||
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<GainNode>;
|
||||
sources: Array<AudioBufferSourceNode>;
|
||||
voice: ActivePianoVoice;
|
||||
}): void {
|
||||
let remainingSources = sources.length;
|
||||
const cleanup = (): void => {
|
||||
remainingSources -= 1;
|
||||
if (remainingSources > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
sources.forEach((source) => {
|
||||
source.disconnect();
|
||||
});
|
||||
sourceGains.forEach((sourceGain) => {
|
||||
sourceGain.disconnect();
|
||||
});
|
||||
filter.disconnect();
|
||||
gain.disconnect();
|
||||
releaseGain?.disconnect();
|
||||
panner.disconnect();
|
||||
delaySendGain?.disconnect();
|
||||
roomSendGain?.disconnect();
|
||||
this.activeVoices = this.activeVoices.filter(
|
||||
(activeVoice) => activeVoice !== voice
|
||||
);
|
||||
};
|
||||
|
||||
sources.forEach((source) => {
|
||||
source.addEventListener('ended', cleanup, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
private computeNoteGain(velocity: number): number {
|
||||
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<SelectedPianoStrikeSample> {
|
||||
if (this.strikeSamples.length === 0 || this.velocityLayers.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const targetLayer = this.getTargetVelocityLayer(noteVelocity);
|
||||
const layerPair = this.getVelocityLayerPair(targetLayer);
|
||||
if (!layerPair) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const lowerSample = this.findNearestStrikeSample(midi, layerPair.lower);
|
||||
const upperSample =
|
||||
layerPair.upper === layerPair.lower
|
||||
? null
|
||||
: this.findNearestStrikeSample(midi, layerPair.upper);
|
||||
|
||||
if (!lowerSample && !upperSample) {
|
||||
return [];
|
||||
}
|
||||
if (!upperSample || layerPair.blend <= 0) {
|
||||
return lowerSample ? [{ gainScale: 1, sample: lowerSample }] : [];
|
||||
}
|
||||
if (!lowerSample || layerPair.blend >= 1) {
|
||||
return [{ gainScale: 1, sample: upperSample }];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
gainScale: Math.sqrt(1 - layerPair.blend),
|
||||
sample: lowerSample,
|
||||
},
|
||||
{
|
||||
gainScale: Math.sqrt(layerPair.blend),
|
||||
sample: upperSample,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
private getTargetVelocityLayer(noteVelocity: number): number {
|
||||
const firstLayer = this.velocityLayers[0];
|
||||
const lastLayer = this.velocityLayers[this.velocityLayers.length - 1];
|
||||
const velocityRange = Math.max(
|
||||
0.001,
|
||||
this.config.piano.velocityLayerMax - this.config.piano.velocityLayerMin
|
||||
);
|
||||
const normalizedVelocity = clamp01(
|
||||
(noteVelocity - this.config.piano.velocityLayerMin) / velocityRange
|
||||
);
|
||||
const curvedVelocity = Math.pow(
|
||||
normalizedVelocity,
|
||||
this.config.piano.velocityLayerCurve
|
||||
);
|
||||
|
||||
return firstLayer + (lastLayer - firstLayer) * curvedVelocity;
|
||||
}
|
||||
|
||||
private getVelocityLayerPair(
|
||||
targetLayer: number
|
||||
): { blend: number; lower: number; upper: number } | null {
|
||||
const firstLayer = this.velocityLayers[0];
|
||||
if (firstLayer === undefined) {
|
||||
return null;
|
||||
}
|
||||
if (targetLayer <= firstLayer) {
|
||||
return { blend: 0, lower: firstLayer, upper: firstLayer };
|
||||
}
|
||||
|
||||
for (let index = 1; index < this.velocityLayers.length; index += 1) {
|
||||
const upper = this.velocityLayers[index];
|
||||
const lower = this.velocityLayers[index - 1];
|
||||
if (targetLayer <= upper) {
|
||||
return {
|
||||
blend: (targetLayer - lower) / (upper - lower),
|
||||
lower,
|
||||
upper,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const lastLayer = this.velocityLayers[this.velocityLayers.length - 1];
|
||||
return { blend: 0, lower: lastLayer, upper: lastLayer };
|
||||
}
|
||||
|
||||
private findNearestStrikeSample(
|
||||
midi: number,
|
||||
velocityLayer: number
|
||||
): LoadedPianoStrikeSample | null {
|
||||
const layerSamples = this.strikeSamples.filter(
|
||||
(sample) => sample.velocityLayer === velocityLayer
|
||||
);
|
||||
if (layerSamples.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return 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<ActivePianoVoice | null>(
|
||||
(quietest, voice) =>
|
||||
quietest === null ||
|
||||
this.getVoiceActivityScore(voice, now) < this.getVoiceActivityScore(quietest, now)
|
||||
? voice
|
||||
: quietest,
|
||||
null
|
||||
);
|
||||
if (!quietestVoice) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.stopVoice(quietestVoice, now);
|
||||
this.activeVoices = this.activeVoices.filter((voice) => voice !== quietestVoice);
|
||||
}
|
||||
|
||||
private getVoiceActivityScore(voice: ActivePianoVoice, now: number): number {
|
||||
const ageSeconds = Math.max(0, now - voice.startedAt);
|
||||
const releasedScale = now >= voice.releaseAt ? 0.28 : 1;
|
||||
return (
|
||||
voice.peakGain *
|
||||
releasedScale *
|
||||
Math.exp(-ageSeconds / this.config.piano.voiceDecayEstimateSeconds)
|
||||
);
|
||||
}
|
||||
|
||||
private stopVoice(voice: ActivePianoVoice, now: number): void {
|
||||
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<LoadedPianoSample>): 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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<PianoSampleDefinition> = [
|
||||
{ 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<string, string>;
|
||||
const pianoSampleDefinitions = getPianoSampleDefinitions(pianoSampleModules);
|
||||
|
||||
let loadedPianoSamples: Array<LoadedPianoSample> | null = null;
|
||||
let pianoSampleLoadPromise: Promise<Array<LoadedPianoSample>> | null = null;
|
||||
let loadedPianoSamples: LoadedPianoSamples | null = null;
|
||||
let pianoSampleLoadPromise: Promise<LoadedPianoSamples> | 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<Array<LoadedPianoSample>> => {
|
||||
): Promise<LoadedPianoSamples> => {
|
||||
const OfflineAudioContextConstructor = globalThis.OfflineAudioContext;
|
||||
|
||||
if (!OfflineAudioContextConstructor) {
|
||||
|
|
@ -106,18 +66,19 @@ export const preloadPianoSamples = (
|
|||
export const loadPianoSamples = (
|
||||
decodeContext: BaseAudioContext,
|
||||
onProgress?: (progress: PianoSampleLoadProgress) => void
|
||||
): Promise<Array<LoadedPianoSample>> => {
|
||||
): Promise<LoadedPianoSamples> => {
|
||||
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<LoadedPianoSample> | null =>
|
||||
loadedPianoSamples ? [...loadedPianoSamples] : null;
|
||||
export const getLoadedPianoSamples = (): LoadedPianoSamples | null =>
|
||||
loadedPianoSamples ? cloneLoadedPianoSamples(loadedPianoSamples) : null;
|
||||
|
||||
const loadPianoSample = async (
|
||||
decodeContext: BaseAudioContext,
|
||||
sample: PianoSampleDefinition,
|
||||
signal: AbortSignal
|
||||
): Promise<LoadedPianoSample> => {
|
||||
): Promise<LoadedPianoStrikeSample | LoadedPianoReleaseSample> => {
|
||||
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<PianoSampleDefinition>,
|
||||
loadSample: (sample: PianoSampleDefinition) => Promise<LoadedPianoSample>
|
||||
): Promise<Array<LoadedPianoSample>> => {
|
||||
const results: Array<LoadedPianoSample> = [];
|
||||
loadSample: (
|
||||
sample: PianoSampleDefinition
|
||||
) => Promise<LoadedPianoStrikeSample | LoadedPianoReleaseSample>
|
||||
): Promise<Array<LoadedPianoStrikeSample | LoadedPianoReleaseSample>> => {
|
||||
const results: Array<LoadedPianoStrikeSample | LoadedPianoReleaseSample> = [];
|
||||
|
||||
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<string, string>
|
||||
): Array<PianoSampleDefinition> {
|
||||
return Object.entries(modules)
|
||||
.map(([path, url]) => getPianoSampleDefinition(path, url))
|
||||
.sort((a, b) => a.midi - b.midi || getSampleSortValue(a) - getSampleSortValue(b));
|
||||
}
|
||||
|
||||
const getMidiForPianoSample = (sample: PianoSampleDefinition): number => {
|
||||
const match = /^(?<name>[A-G])(?<accidental>sharp)?(?<octave>\d+)$/.exec(sample.note);
|
||||
function getPianoSampleDefinition(path: string, url: string): PianoSampleDefinition {
|
||||
const filename = path.split('/').pop() ?? path;
|
||||
const strikeMatch = /^(?<note>[A-G](?:sharp)?\d+)v(?<velocityLayer>\d+)\.m4a$/.exec(
|
||||
filename
|
||||
);
|
||||
if (strikeMatch?.groups) {
|
||||
return {
|
||||
kind: 'strike',
|
||||
midi: getMidiForPianoSampleNote(strikeMatch.groups.note),
|
||||
path,
|
||||
url,
|
||||
velocityLayer: Number(strikeMatch.groups.velocityLayer),
|
||||
};
|
||||
}
|
||||
|
||||
const releaseMatch = /^rel(?<releaseIndex>\d+)\.m4a$/.exec(filename);
|
||||
if (releaseMatch?.groups) {
|
||||
return {
|
||||
kind: 'release',
|
||||
midi: getMidiForReleaseSample(Number(releaseMatch.groups.releaseIndex)),
|
||||
path,
|
||||
url,
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(`Invalid piano sample filename ${path}`);
|
||||
}
|
||||
|
||||
function getSampleSortValue(sample: PianoSampleDefinition): number {
|
||||
return sample.kind === 'strike' ? sample.velocityLayer : Number.MAX_SAFE_INTEGER;
|
||||
}
|
||||
|
||||
function getMidiForPianoSampleNote(note: string): number {
|
||||
const match = /^(?<name>[A-G])(?<accidental>sharp)?(?<octave>\d+)$/.exec(note);
|
||||
if (!match?.groups) {
|
||||
throw new Error(`Invalid piano sample note ${sample.note}`);
|
||||
throw new Error(`Invalid piano sample note ${note}`);
|
||||
}
|
||||
|
||||
const semitoneByName: Record<string, number> = {
|
||||
|
|
@ -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<LoadedPianoStrikeSample | LoadedPianoReleaseSample>
|
||||
): LoadedPianoSamples => ({
|
||||
releases: samples
|
||||
.filter((sample): sample is LoadedPianoReleaseSample => !('velocityLayer' in sample))
|
||||
.sort((a, b) => a.midi - b.midi),
|
||||
strikes: samples
|
||||
.filter((sample): sample is LoadedPianoStrikeSample => 'velocityLayer' in sample)
|
||||
.sort((a, b) => a.midi - b.midi || a.velocityLayer - b.velocityLayer),
|
||||
});
|
||||
|
||||
const cloneLoadedPianoSamples = (samples: LoadedPianoSamples): LoadedPianoSamples => ({
|
||||
releases: [...samples.releases],
|
||||
strikes: [...samples.strikes],
|
||||
});
|
||||
|
|
|
|||
Binary file not shown.
BIN
src/audio/samples/A0v16.m4a
Normal file
BIN
src/audio/samples/A0v16.m4a
Normal file
Binary file not shown.
BIN
src/audio/samples/A0v4.m4a
Normal file
BIN
src/audio/samples/A0v4.m4a
Normal file
Binary file not shown.
BIN
src/audio/samples/A0v8.m4a
Normal file
BIN
src/audio/samples/A0v8.m4a
Normal file
Binary file not shown.
Binary file not shown.
BIN
src/audio/samples/A1v16.m4a
Normal file
BIN
src/audio/samples/A1v16.m4a
Normal file
Binary file not shown.
BIN
src/audio/samples/A1v4.m4a
Normal file
BIN
src/audio/samples/A1v4.m4a
Normal file
Binary file not shown.
BIN
src/audio/samples/A1v8.m4a
Normal file
BIN
src/audio/samples/A1v8.m4a
Normal file
Binary file not shown.
Binary file not shown.
BIN
src/audio/samples/A2v16.m4a
Normal file
BIN
src/audio/samples/A2v16.m4a
Normal file
Binary file not shown.
BIN
src/audio/samples/A2v4.m4a
Normal file
BIN
src/audio/samples/A2v4.m4a
Normal file
Binary file not shown.
BIN
src/audio/samples/A2v8.m4a
Normal file
BIN
src/audio/samples/A2v8.m4a
Normal file
Binary file not shown.
Binary file not shown.
BIN
src/audio/samples/A3v16.m4a
Normal file
BIN
src/audio/samples/A3v16.m4a
Normal file
Binary file not shown.
BIN
src/audio/samples/A3v4.m4a
Normal file
BIN
src/audio/samples/A3v4.m4a
Normal file
Binary file not shown.
BIN
src/audio/samples/A3v8.m4a
Normal file
BIN
src/audio/samples/A3v8.m4a
Normal file
Binary file not shown.
Binary file not shown.
BIN
src/audio/samples/A4v16.m4a
Normal file
BIN
src/audio/samples/A4v16.m4a
Normal file
Binary file not shown.
BIN
src/audio/samples/A4v4.m4a
Normal file
BIN
src/audio/samples/A4v4.m4a
Normal file
Binary file not shown.
BIN
src/audio/samples/A4v8.m4a
Normal file
BIN
src/audio/samples/A4v8.m4a
Normal file
Binary file not shown.
Binary file not shown.
BIN
src/audio/samples/A5v16.m4a
Normal file
BIN
src/audio/samples/A5v16.m4a
Normal file
Binary file not shown.
BIN
src/audio/samples/A5v4.m4a
Normal file
BIN
src/audio/samples/A5v4.m4a
Normal file
Binary file not shown.
BIN
src/audio/samples/A5v8.m4a
Normal file
BIN
src/audio/samples/A5v8.m4a
Normal file
Binary file not shown.
Binary file not shown.
BIN
src/audio/samples/A6v16.m4a
Normal file
BIN
src/audio/samples/A6v16.m4a
Normal file
Binary file not shown.
BIN
src/audio/samples/A6v4.m4a
Normal file
BIN
src/audio/samples/A6v4.m4a
Normal file
Binary file not shown.
BIN
src/audio/samples/A6v8.m4a
Normal file
BIN
src/audio/samples/A6v8.m4a
Normal file
Binary file not shown.
Binary file not shown.
BIN
src/audio/samples/A7v16.m4a
Normal file
BIN
src/audio/samples/A7v16.m4a
Normal file
Binary file not shown.
BIN
src/audio/samples/A7v4.m4a
Normal file
BIN
src/audio/samples/A7v4.m4a
Normal file
Binary file not shown.
BIN
src/audio/samples/A7v8.m4a
Normal file
BIN
src/audio/samples/A7v8.m4a
Normal file
Binary file not shown.
Binary file not shown.
BIN
src/audio/samples/C1v16.m4a
Normal file
BIN
src/audio/samples/C1v16.m4a
Normal file
Binary file not shown.
BIN
src/audio/samples/C1v4.m4a
Normal file
BIN
src/audio/samples/C1v4.m4a
Normal file
Binary file not shown.
BIN
src/audio/samples/C1v8.m4a
Normal file
BIN
src/audio/samples/C1v8.m4a
Normal file
Binary file not shown.
Binary file not shown.
BIN
src/audio/samples/C2v16.m4a
Normal file
BIN
src/audio/samples/C2v16.m4a
Normal file
Binary file not shown.
BIN
src/audio/samples/C2v4.m4a
Normal file
BIN
src/audio/samples/C2v4.m4a
Normal file
Binary file not shown.
BIN
src/audio/samples/C2v8.m4a
Normal file
BIN
src/audio/samples/C2v8.m4a
Normal file
Binary file not shown.
Binary file not shown.
BIN
src/audio/samples/C3v16.m4a
Normal file
BIN
src/audio/samples/C3v16.m4a
Normal file
Binary file not shown.
BIN
src/audio/samples/C3v4.m4a
Normal file
BIN
src/audio/samples/C3v4.m4a
Normal file
Binary file not shown.
BIN
src/audio/samples/C3v8.m4a
Normal file
BIN
src/audio/samples/C3v8.m4a
Normal file
Binary file not shown.
Binary file not shown.
BIN
src/audio/samples/C4v16.m4a
Normal file
BIN
src/audio/samples/C4v16.m4a
Normal file
Binary file not shown.
BIN
src/audio/samples/C4v4.m4a
Normal file
BIN
src/audio/samples/C4v4.m4a
Normal file
Binary file not shown.
BIN
src/audio/samples/C4v8.m4a
Normal file
BIN
src/audio/samples/C4v8.m4a
Normal file
Binary file not shown.
Binary file not shown.
BIN
src/audio/samples/C5v16.m4a
Normal file
BIN
src/audio/samples/C5v16.m4a
Normal file
Binary file not shown.
BIN
src/audio/samples/C5v4.m4a
Normal file
BIN
src/audio/samples/C5v4.m4a
Normal file
Binary file not shown.
BIN
src/audio/samples/C5v8.m4a
Normal file
BIN
src/audio/samples/C5v8.m4a
Normal file
Binary file not shown.
Binary file not shown.
BIN
src/audio/samples/C6v16.m4a
Normal file
BIN
src/audio/samples/C6v16.m4a
Normal file
Binary file not shown.
BIN
src/audio/samples/C6v4.m4a
Normal file
BIN
src/audio/samples/C6v4.m4a
Normal file
Binary file not shown.
BIN
src/audio/samples/C6v8.m4a
Normal file
BIN
src/audio/samples/C6v8.m4a
Normal file
Binary file not shown.
Binary file not shown.
BIN
src/audio/samples/C7v16.m4a
Normal file
BIN
src/audio/samples/C7v16.m4a
Normal file
Binary file not shown.
BIN
src/audio/samples/C7v4.m4a
Normal file
BIN
src/audio/samples/C7v4.m4a
Normal file
Binary file not shown.
BIN
src/audio/samples/C7v8.m4a
Normal file
BIN
src/audio/samples/C7v8.m4a
Normal file
Binary file not shown.
Binary file not shown.
BIN
src/audio/samples/C8v16.m4a
Normal file
BIN
src/audio/samples/C8v16.m4a
Normal file
Binary file not shown.
BIN
src/audio/samples/C8v4.m4a
Normal file
BIN
src/audio/samples/C8v4.m4a
Normal file
Binary file not shown.
BIN
src/audio/samples/C8v8.m4a
Normal file
BIN
src/audio/samples/C8v8.m4a
Normal file
Binary file not shown.
Binary file not shown.
BIN
src/audio/samples/Dsharp1v16.m4a
Normal file
BIN
src/audio/samples/Dsharp1v16.m4a
Normal file
Binary file not shown.
BIN
src/audio/samples/Dsharp1v4.m4a
Normal file
BIN
src/audio/samples/Dsharp1v4.m4a
Normal file
Binary file not shown.
BIN
src/audio/samples/Dsharp1v8.m4a
Normal file
BIN
src/audio/samples/Dsharp1v8.m4a
Normal file
Binary file not shown.
Binary file not shown.
BIN
src/audio/samples/Dsharp2v16.m4a
Normal file
BIN
src/audio/samples/Dsharp2v16.m4a
Normal file
Binary file not shown.
BIN
src/audio/samples/Dsharp2v4.m4a
Normal file
BIN
src/audio/samples/Dsharp2v4.m4a
Normal file
Binary file not shown.
BIN
src/audio/samples/Dsharp2v8.m4a
Normal file
BIN
src/audio/samples/Dsharp2v8.m4a
Normal file
Binary file not shown.
Binary file not shown.
BIN
src/audio/samples/Dsharp3v16.m4a
Normal file
BIN
src/audio/samples/Dsharp3v16.m4a
Normal file
Binary file not shown.
BIN
src/audio/samples/Dsharp3v4.m4a
Normal file
BIN
src/audio/samples/Dsharp3v4.m4a
Normal file
Binary file not shown.
BIN
src/audio/samples/Dsharp3v8.m4a
Normal file
BIN
src/audio/samples/Dsharp3v8.m4a
Normal file
Binary file not shown.
Binary file not shown.
BIN
src/audio/samples/Dsharp4v16.m4a
Normal file
BIN
src/audio/samples/Dsharp4v16.m4a
Normal file
Binary file not shown.
BIN
src/audio/samples/Dsharp4v4.m4a
Normal file
BIN
src/audio/samples/Dsharp4v4.m4a
Normal file
Binary file not shown.
BIN
src/audio/samples/Dsharp4v8.m4a
Normal file
BIN
src/audio/samples/Dsharp4v8.m4a
Normal file
Binary file not shown.
Binary file not shown.
BIN
src/audio/samples/Dsharp5v16.m4a
Normal file
BIN
src/audio/samples/Dsharp5v16.m4a
Normal file
Binary file not shown.
BIN
src/audio/samples/Dsharp5v4.m4a
Normal file
BIN
src/audio/samples/Dsharp5v4.m4a
Normal file
Binary file not shown.
BIN
src/audio/samples/Dsharp5v8.m4a
Normal file
BIN
src/audio/samples/Dsharp5v8.m4a
Normal file
Binary file not shown.
Binary file not shown.
BIN
src/audio/samples/Dsharp6v16.m4a
Normal file
BIN
src/audio/samples/Dsharp6v16.m4a
Normal file
Binary file not shown.
BIN
src/audio/samples/Dsharp6v4.m4a
Normal file
BIN
src/audio/samples/Dsharp6v4.m4a
Normal file
Binary file not shown.
BIN
src/audio/samples/Dsharp6v8.m4a
Normal file
BIN
src/audio/samples/Dsharp6v8.m4a
Normal file
Binary file not shown.
Binary file not shown.
BIN
src/audio/samples/Dsharp7v16.m4a
Normal file
BIN
src/audio/samples/Dsharp7v16.m4a
Normal file
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue