This commit is contained in:
Andras Schmelczer 2026-05-19 21:03:53 +01:00
commit 7c70f15e49
65 changed files with 1122 additions and 1906 deletions

View file

@ -1,38 +0,0 @@
import { clamp } from '../utils/math';
import { isIosLike } from './audio-platform';
interface AudioPanNode {
input: AudioNode;
output: AudioNode;
disconnect: () => void;
}
type AudioContextWithOptionalPanner = AudioContext & {
createStereoPanner?: () => StereoPannerNode;
};
export const createAudioPanNode = (
context: AudioContext,
pan: number,
startTime: number
): AudioPanNode => {
const createStereoPanner = (context as AudioContextWithOptionalPanner)
.createStereoPanner;
if (!isIosLike() && typeof createStereoPanner === 'function') {
const panner = createStereoPanner.call(context);
panner.pan.setValueAtTime(clamp(pan, -1, 1), startTime);
return {
input: panner,
output: panner,
disconnect: () => panner.disconnect(),
};
}
const passthrough = context.createGain();
return {
input: passthrough,
output: passthrough,
disconnect: () => passthrough.disconnect(),
};
};

View file

@ -1,3 +0,0 @@
export const isIosLike = (): boolean =>
/iPad|iPhone|iPod/.test(navigator.userAgent) ||
(navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);

View file

@ -1,4 +1,3 @@
import { appConfig } from '../config';
import type { PianoNoteRole } from './garden-audio-types';
type GardenAudioChordQuality = 'major' | 'minor';
@ -86,5 +85,3 @@ export interface GardenAudioConfig {
maniaSmoothingSeconds: number;
};
}
export const gardenAudioConfig: GardenAudioConfig = appConfig.audio;

View file

@ -19,20 +19,14 @@ export class GardenAudioEnergy {
this.targetEnergy = 0;
}
public recordStroke(strokeEnergy: number, now: number): void {
const energy = clamp01(strokeEnergy);
this.targetEnergy = Math.max(this.targetEnergy, energy);
public recordStroke(strokeEnergy: number): void {
this.targetEnergy = Math.max(this.targetEnergy, strokeEnergy);
if (this.isGestureActive) {
this.energy = Math.max(
this.energy,
energy * this.config.energy.immediateActivityScale
strokeEnergy * this.config.energy.immediateActivityScale
);
}
this.lastEnergyUpdateAt ||= now;
}
public recordEraserStroke(): void {
this.targetEnergy = 0;
}
public silence(): void {
@ -46,7 +40,7 @@ export class GardenAudioEnergy {
return;
}
const elapsedSeconds = Math.max(0, now - this.lastEnergyUpdateAt);
const elapsedSeconds = now - this.lastEnergyUpdateAt;
this.lastEnergyUpdateAt = now;
this.targetEnergy *= Math.exp(
-elapsedSeconds / this.config.energy.strokeDecaySeconds
@ -62,14 +56,6 @@ export class GardenAudioEnergy {
this.energy = approach(this.energy, target, elapsedSeconds, timeConstant);
}
public getActivity(): number {
if (!this.isGestureActive) {
return 0;
}
return this.getLevel();
}
public getLevel(): number {
return clamp01(this.energy);
}

View file

@ -8,28 +8,17 @@ interface GardenAudioGestureFrame {
}
export class GardenAudioGestureState {
private gestureClockSeconds = 0;
private activity = 0;
private maniaAmount = 0;
private isManic = false;
public constructor(private readonly inputConfig: GardenAudioConfig['input']) {}
public beginGesture(): void {
this.reset();
}
public endGesture(): void {
this.reset();
}
public recordStroke({
metrics,
}: {
metrics: GardenAudioStrokeMetrics;
}): GardenAudioGestureFrame {
this.gestureClockSeconds += metrics.elapsedSeconds;
const targetActivity = this.getTargetActivity(metrics);
const activityTimeConstant =
targetActivity > this.activity
@ -65,29 +54,21 @@ export class GardenAudioGestureState {
}
public reset(): void {
this.gestureClockSeconds = 0;
this.activity = 0;
this.maniaAmount = 0;
this.isManic = false;
}
private getTargetActivity(metrics: GardenAudioStrokeMetrics): number {
const normalizedSpeed = Math.max(0, metrics.normalizedSpeed);
const activeSpeed = Math.max(
this.inputConfig.activityNoiseFloorSpeed,
this.inputConfig.fullActivitySpeed
);
const speedRange =
this.inputConfig.fullActivitySpeed - this.inputConfig.activityNoiseFloorSpeed;
const speedAmount = clamp01(
(normalizedSpeed - this.inputConfig.activityNoiseFloorSpeed) /
(activeSpeed - this.inputConfig.activityNoiseFloorSpeed)
(metrics.normalizedSpeed - this.inputConfig.activityNoiseFloorSpeed) / speedRange
);
const distanceAmount = clamp01(
metrics.normalizedDistance / this.inputConfig.minAudibleDistance
);
const activity = Math.pow(
speedAmount,
Math.max(0.001, this.inputConfig.activityCurve)
);
const activity = Math.pow(speedAmount, this.inputConfig.activityCurve);
return clamp(activity * distanceAmount, 0, this.inputConfig.activitySoftCeiling);
}

View file

@ -1,9 +1,13 @@
import { clamp } from '../utils/math';
import { isIosLike } from './audio-platform';
import type { GardenAudioConfig, GardenAudioVibeProfile } from './garden-audio-config';
import type { PianoNoteRole } from './garden-audio-types';
type NavigatorWithAudioSession = Navigator & {
audioSession?: { type: 'auto' | 'playback' | 'ambient' | 'transient' | 'transient-solo' | 'play-and-record' };
};
const outputHighPassFrequencyHz = 45;
const noiseBufferDurationSeconds = 1;
const graphTuning = {
closeGain: 0.0001,
closeRampSeconds: 0.015,
@ -13,12 +17,6 @@ const graphTuning = {
noiseMin: -1,
latencyHint: 'interactive' as AudioContextLatencyCategory,
outputFilterType: 'highpass' as BiquadFilterType,
noiseBufferChannels: 1,
noiseBufferDurationSeconds: 1,
unlockTickFrequencyHz: 440,
unlockTickGain: 0.0001,
unlockTickSeconds: 0.025,
unlockTickType: 'sine' as OscillatorType,
compressor: {
thresholdDb: -18,
kneeDb: 18,
@ -44,8 +42,10 @@ export class GardenAudioGraph {
private delayNode: DelayNode | null = null;
private delayFeedback: GainNode | null = null;
private delayOutput: GainNode | null = null;
private mediaStreamDestination: MediaStreamAudioDestinationNode | null = null;
private mediaStreamElement: HTMLAudioElement | null = null;
private lastPianoBusActivity = 0;
private pianoBusGainScale = 1;
private pianoBusGainScaleAutomationUntil = 0;
private pianoBusGainScaleTimeConstantSeconds = 0;
private readonly pianoBuses = new Map<PianoNoteRole, GainNode>();
public constructor(private readonly config: GardenAudioConfig) {}
@ -64,20 +64,20 @@ export class GardenAudioGraph {
return null;
}
let context: AudioContext;
try {
context = new AudioContextConstructor({
latencyHint: graphTuning.latencyHint,
});
} catch {
context = new AudioContextConstructor();
// Tells iOS to treat this as media playback, so the hardware ringer/mute
// switch does not silence Web Audio output. No-op on browsers without the
// Audio Session API.
const audioSession = (navigator as NavigatorWithAudioSession).audioSession;
if (audioSession) {
audioSession.type = 'playback';
}
const context = new AudioContextConstructor({
latencyHint: graphTuning.latencyHint,
});
const masterGain = context.createGain();
const highPass = context.createBiquadFilter();
const compressor = context.createDynamicsCompressor();
const mediaStreamDestination = isIosLike()
? context.createMediaStreamDestination()
: null;
masterGain.gain.value = 0;
highPass.type = graphTuning.outputFilterType;
@ -90,15 +90,10 @@ export class GardenAudioGraph {
masterGain.connect(highPass);
highPass.connect(compressor);
if (mediaStreamDestination) {
compressor.connect(mediaStreamDestination);
} else {
compressor.connect(context.destination);
}
compressor.connect(context.destination);
this.context = context;
this.masterGain = masterGain;
this.mediaStreamDestination = mediaStreamDestination;
this.noiseBuffer = this.createNoiseBuffer(context);
this.createDelay(context, masterGain);
this.createBuses(context, masterGain);
@ -106,35 +101,6 @@ export class GardenAudioGraph {
return context;
}
// iOS WebKit can report "running" while the hardware output is still silent.
// A very short, nearly inaudible oscillator in the gesture stack is more
// reliable than a fully silent buffer on recent Safari versions.
public unlock(): void {
if (!this.context) {
return;
}
const now = this.context.currentTime;
const source = this.context.createOscillator();
const gain = this.context.createGain();
source.type = graphTuning.unlockTickType;
source.frequency.setValueAtTime(graphTuning.unlockTickFrequencyHz, now);
gain.gain.setValueAtTime(graphTuning.unlockTickGain, now);
source.connect(gain);
gain.connect(this.context.destination);
source.start(now);
source.stop(now + graphTuning.unlockTickSeconds);
source.addEventListener(
'ended',
() => {
source.disconnect();
gain.disconnect();
},
{ once: true }
);
}
public setMasterGain(targetGain: number, timeConstantSeconds: number): void {
if (!this.context || !this.masterGain) {
return;
@ -147,38 +113,6 @@ export class GardenAudioGraph {
);
}
public startMediaElementOutput(): void {
if (!this.mediaStreamDestination) {
return;
}
const mediaElement = this.ensureMediaStreamElement();
const playPromise = mediaElement.play();
void playPromise?.catch(() => undefined);
}
private ensureMediaStreamElement(): HTMLAudioElement {
if (this.mediaStreamElement) {
return this.mediaStreamElement;
}
const mediaElement = document.createElement('audio');
mediaElement.autoplay = true;
mediaElement.volume = 1;
mediaElement.setAttribute('playsinline', '');
mediaElement.setAttribute('aria-hidden', 'true');
mediaElement.style.position = 'fixed';
mediaElement.style.width = '1px';
mediaElement.style.height = '1px';
mediaElement.style.opacity = '0';
mediaElement.style.pointerEvents = 'none';
mediaElement.style.left = '-9999px';
mediaElement.srcObject = this.mediaStreamDestination?.stream ?? null;
document.body.append(mediaElement);
this.mediaStreamElement = mediaElement;
return mediaElement;
}
public applyDelayProfile(profile: GardenAudioVibeProfile): void {
if (!this.context || !this.delayNode) {
return;
@ -228,6 +162,20 @@ export class GardenAudioGraph {
return this.pianoBuses.get(role ?? 'gesture') ?? this.eventBus;
}
public setPianoBusGainScale(targetScale: number, timeConstantSeconds: number): void {
if (!this.context) {
this.pianoBusGainScale = clamp(targetScale, 0, 1);
return;
}
const now = this.context.currentTime;
this.pianoBusGainScale = clamp(targetScale, 0, 1);
this.pianoBusGainScaleTimeConstantSeconds = timeConstantSeconds;
this.pianoBusGainScaleAutomationUntil = now + timeConstantSeconds * 4;
this.updatePianoBusGains(this.lastPianoBusActivity, now, timeConstantSeconds);
}
public async close(): Promise<void> {
const context = this.context;
if (!context) {
@ -304,27 +252,33 @@ export class GardenAudioGraph {
this.noiseBus.connect(eventBus);
}
private updatePianoBusGains(activity: number, now: number): void {
private updatePianoBusGains(
activity: number,
now: number,
timeConstantSeconds?: number
): void {
const effectiveTimeConstantSeconds =
timeConstantSeconds ??
(now < this.pianoBusGainScaleAutomationUntil
? this.pianoBusGainScaleTimeConstantSeconds
: this.config.updateRampSeconds);
this.lastPianoBusActivity = activity;
this.pianoBuses.forEach((bus, role) => {
const baseGain = this.config.graph.pianoBusGains[role];
const ducking = this.config.graph.pianoBusActivityDucking[role];
bus.gain.setTargetAtTime(
Math.max(0, baseGain * (1 - activity * ducking)),
Math.max(0, baseGain * (1 - activity * ducking) * this.pianoBusGainScale),
now,
this.config.updateRampSeconds
effectiveTimeConstantSeconds
);
});
}
private createNoiseBuffer(context: AudioContext): AudioBuffer {
const buffer = context.createBuffer(
appPositiveInteger(graphTuning.noiseBufferChannels),
Math.max(
1,
Math.floor(
context.sampleRate * Math.max(0.001, graphTuning.noiseBufferDurationSeconds)
)
),
1,
Math.floor(context.sampleRate * noiseBufferDurationSeconds),
context.sampleRate
);
const data = buffer.getChannelData(0);
@ -348,16 +302,10 @@ export class GardenAudioGraph {
this.delayNode = null;
this.delayFeedback = null;
this.delayOutput = null;
this.mediaStreamDestination = null;
if (this.mediaStreamElement) {
this.mediaStreamElement.pause();
this.mediaStreamElement.srcObject = null;
this.mediaStreamElement.remove();
this.mediaStreamElement = null;
}
this.lastPianoBusActivity = 0;
this.pianoBusGainScale = 1;
this.pianoBusGainScaleAutomationUntil = 0;
this.pianoBusGainScaleTimeConstantSeconds = 0;
this.pianoBuses.clear();
}
}
const appPositiveInteger = (value: number): number =>
Math.max(1, Math.floor(Number.isFinite(value) ? value : 1));

View file

@ -1,11 +1,8 @@
import type { GardenAudioStroke } from './garden-audio-types';
const fallbackNormalizationPixels = 1000;
const fallbackFrameSeconds = 1 / 60;
const minElapsedSeconds = 0.001;
export interface GardenAudioStrokeMetrics {
distancePixels: number;
elapsedSeconds: number;
normalizedDistance: number;
normalizedSpeed: number;
@ -15,42 +12,13 @@ export const getStrokeMetrics = (stroke: GardenAudioStroke): GardenAudioStrokeMe
const dx = stroke.to[0] - stroke.from[0];
const dy = stroke.to[1] - stroke.from[1];
const distancePixels = Math.hypot(dx, dy);
const elapsedSeconds = getElapsedSeconds(stroke);
const normalizedDistance = distancePixels / getStrokeNormalizationPixels(stroke);
const elapsedSeconds = Math.max(minElapsedSeconds, stroke.elapsedSeconds ?? 0);
const normalizationPixels = Math.max(1, Math.min(stroke.canvasSize[0], stroke.canvasSize[1]));
const normalizedDistance = distancePixels / normalizationPixels;
return {
distancePixels,
elapsedSeconds,
normalizedDistance,
normalizedSpeed: normalizedDistance / elapsedSeconds,
};
};
const getElapsedSeconds = (stroke: GardenAudioStroke): number => {
if (
stroke.elapsedSeconds !== undefined &&
Number.isFinite(stroke.elapsedSeconds) &&
stroke.elapsedSeconds > 0
) {
return Math.max(minElapsedSeconds, stroke.elapsedSeconds);
}
return fallbackFrameSeconds;
};
const getStrokeNormalizationPixels = (stroke: GardenAudioStroke): number => {
const width = stroke.canvasSize?.[0];
const height = stroke.canvasSize?.[1];
if (
width !== undefined &&
height !== undefined &&
Number.isFinite(width) &&
Number.isFinite(height) &&
width > 0 &&
height > 0
) {
return Math.max(1, Math.min(width, height));
}
return fallbackNormalizationPixels;
};

View file

@ -1,36 +1,6 @@
import type { VibePreset } from '../vibes';
import type { GardenAudioChord, GardenAudioVibeProfile } from './garden-audio-config';
import type { GardenAudioVibeProfile } from './garden-audio-config';
export const PITCH_SEMITONES_PER_OCTAVE = 12;
const chordVoicings = {
majorOpen: [0, 7, 12, 16],
minorOpen: [0, 7, 12, 15],
majorClosed: [0, 4, 7, 12, 16],
minorClosed: [0, 3, 7, 12, 15],
};
export const getVibeProfile = (vibe: VibePreset): GardenAudioVibeProfile => vibe.audio;
export const getChordIntervals = (
chord: GardenAudioChord,
openVoicing: boolean
): Array<number> => {
if (openVoicing) {
return chord.quality === 'major' ? chordVoicings.majorOpen : chordVoicings.minorOpen;
}
return chord.quality === 'major'
? chordVoicings.majorClosed
: chordVoicings.minorClosed;
};
export const degreeToSemitone = (
profile: GardenAudioVibeProfile,
degree: number
): number => {
const scaleIndex =
((degree % profile.scale.length) + profile.scale.length) % profile.scale.length;
const octave = Math.floor(degree / profile.scale.length);
return profile.scale[scaleIndex] + octave * PITCH_SEMITONES_PER_OCTAVE;
};

View file

@ -1,3 +0,0 @@
export const GENERATIVE_LOOKAHEAD_SECONDS = 0.3;
export const GENERATIVE_START_DELAY_SECONDS = 0.02;
export const PIANO_SCHEDULE_AHEAD_SECONDS = 0.002;

View file

@ -9,9 +9,9 @@ export interface GardenAudioStroke {
vibe: VibePreset;
from: ArrayLike<number>;
to: ArrayLike<number>;
canvasSize?: ArrayLike<number>;
canvasSize: ArrayLike<number>;
isErasing: boolean;
elapsedSeconds?: number;
elapsedSeconds: number;
}
export interface GardenAudioStartOptions {
@ -23,12 +23,6 @@ export interface LoadedPianoSample {
buffer: AudioBuffer;
}
export interface ActivePianoVoice {
gain: GainNode;
source: AudioScheduledSourceNode;
stopAt: number;
}
export interface PianoNote {
midi: number;
velocity: number;

View file

@ -16,18 +16,12 @@ import { GenerativePianoEngine } from './generative-piano';
import { NoiseBurstPlayer } from './noise-burst-player';
import { PianoSampler } from './piano-sampler';
export type {
GardenAudioSnapshot,
GardenAudioStartOptions,
GardenAudioStroke,
} from './garden-audio-types';
type AudioLifecycle = 'idle' | 'started' | 'destroyed';
const muteGain = 0.0001;
const muteRampSeconds = 0.02;
const brushUpPianoFinishSeconds = 1.2;
const brushUpPianoFadeSeconds = 1.1;
const brushUpPianoBusFadeSeconds = 2.4;
const brushUpPianoBusFadeSettleSeconds = 3.2;
const vibeChangeStingerMinIntervalSeconds = 0.45;
export class GardenAudio {
@ -40,12 +34,12 @@ export class GardenAudio {
private currentVibeId: VibeId | null = null;
private lifecycle: AudioLifecycle = 'idle';
private isReleasingPiano = false;
private isMuted = false;
private isGestureActive = false;
private isPianoStoppedAfterGesture = false;
private fadePianoAfter: number | null = null;
private fadePianoAt: number | null = null;
private masterVolume: number;
private stopPianoAfter: number | null = null;
private stopPianoAt: number | null = null;
private lastEraserAt = Number.NEGATIVE_INFINITY;
private lastVibeStingerAt = Number.NEGATIVE_INFINITY;
@ -75,33 +69,16 @@ export class GardenAudio {
? muteRampSeconds
: this.config.fadeInSeconds;
const needsResume = context.state !== 'running' && context.state !== 'closed';
let resumePromise: Promise<void> | null = null;
if (isUserGesture) {
this.graph.startMediaElementOutput();
this.graph.unlock();
}
if (needsResume) {
if (!isUserGesture) {
return;
}
resumePromise = context.resume();
}
if (isUserGesture) {
this.graph.unlock();
}
if (resumePromise) {
void resumePromise
void context
.resume()
.then(() => {
if (this.graph.context === context && this.lifecycle !== 'destroyed') {
this.graph.unlock();
this.completeStart(vibe, {
context,
startupRampSeconds,
});
this.completeStart(vibe, { context, startupRampSeconds });
}
})
.catch((error) => {
@ -113,10 +90,7 @@ export class GardenAudio {
return;
}
this.completeStart(vibe, {
context,
startupRampSeconds,
});
this.completeStart(vibe, { context, startupRampSeconds });
}
private completeStart(
@ -207,23 +181,21 @@ export class GardenAudio {
}
this.isGestureActive = true;
this.isPianoStoppedAfterGesture = false;
this.fadePianoAfter = null;
this.stopPianoAfter = null;
this.gestureState.beginGesture();
this.isReleasingPiano = false;
this.fadePianoAt = null;
this.stopPianoAt = null;
this.graph.setPianoBusGainScale(1, this.config.fadeInSeconds);
this.gestureState.reset();
this.energy.beginGesture(context.currentTime);
this.pianoEngine.beginGesture();
}
public endGesture(): void {
this.gestureState.endGesture();
this.gestureState.reset();
this.isGestureActive = false;
const context = this.graph.context;
this.isPianoStoppedAfterGesture = true;
this.fadePianoAfter = context
? context.currentTime + brushUpPianoFinishSeconds
: null;
this.stopPianoAfter = null;
this.isReleasingPiano = true;
this.fadePianoAt = null;
this.stopPianoAt = null;
this.energy.endGesture();
this.pianoEngine.endGesture();
}
@ -241,17 +213,8 @@ export class GardenAudio {
this.energy.silence();
}
if (!this.isGestureActive && this.isPianoStoppedAfterGesture) {
if (this.fadePianoAfter !== null && context.currentTime >= this.fadePianoAfter) {
this.piano.fadeAll(brushUpPianoFadeSeconds);
this.fadePianoAfter = null;
this.stopPianoAfter = context.currentTime + brushUpPianoFadeSeconds;
}
if (this.stopPianoAfter !== null && context.currentTime >= this.stopPianoAfter) {
this.piano.stopAll();
this.pianoEngine.reset();
this.stopPianoAfter = null;
}
if (!this.isGestureActive && this.isReleasingPiano) {
this.updatePianoRelease(snapshot.vibe, context.currentTime);
this.updateDelay(snapshot);
return;
}
@ -287,12 +250,11 @@ export class GardenAudio {
const strokeEnergy = frame.activity;
if (stroke.isErasing) {
this.energy.recordEraserStroke();
this.playEraser(strokeEnergy, now);
return;
}
this.energy.recordStroke(strokeEnergy, now);
this.energy.recordStroke(strokeEnergy);
this.pianoEngine.recordStroke({
vibe: stroke.vibe,
now,
@ -311,9 +273,9 @@ export class GardenAudio {
this.pianoEngine.reset();
this.currentVibeId = null;
this.isGestureActive = false;
this.isPianoStoppedAfterGesture = false;
this.fadePianoAfter = null;
this.stopPianoAfter = null;
this.isReleasingPiano = false;
this.fadePianoAt = null;
this.stopPianoAt = null;
this.lastEraserAt = Number.NEGATIVE_INFINITY;
this.lastVibeStingerAt = Number.NEGATIVE_INFINITY;
}
@ -333,6 +295,25 @@ export class GardenAudio {
this.pianoEngine.playVibeChangeStinger(vibe, now);
}
private updatePianoRelease(vibe: VibePreset, now: number): void {
if (this.fadePianoAt === null && this.stopPianoAt === null) {
this.fadePianoAt = this.pianoEngine.release(vibe, now);
}
if (this.fadePianoAt !== null && now >= this.fadePianoAt) {
this.graph.setPianoBusGainScale(0, brushUpPianoBusFadeSeconds);
this.fadePianoAt = null;
this.stopPianoAt = now + brushUpPianoBusFadeSettleSeconds;
}
if (this.stopPianoAt !== null && now >= this.stopPianoAt) {
this.piano.stopAll();
this.pianoEngine.reset();
this.stopPianoAt = null;
this.isReleasingPiano = false;
}
}
private playEraser(activity: number, now: number): void {
if (!this.graph.context) {
return;

View file

@ -23,6 +23,16 @@ interface GenerativePianoTuning {
pans: [number, number, number];
delaySends: [number, number, number];
lowpassExpression: number;
noteDurationSeconds: number;
spacingSeconds: number;
};
releaseResolution: {
durationSeconds: number;
fadeAfterSeconds: number;
velocities: [number, number, number];
delaySend: number;
lowpassExpression: number;
strumSeconds: number;
};
highActivityExtra: {
barOffset: number;
@ -67,7 +77,6 @@ interface GenerativePianoTuning {
delaySend: number;
};
touchNote: {
registerBiasManiaAmount: number;
velocityBase: number;
velocityStrengthWeight: number;
durationBaseSeconds: number;
@ -80,7 +89,6 @@ interface GenerativePianoTuning {
initialMotifOffset: number;
energyDecaySeconds: number;
maniaDecaySeconds: number;
fadeMinimumLifetimeSeconds: number;
layerIntensityBase: number;
layerIntensityManiaWeight: number;
frameActivityWeight: number;
@ -106,7 +114,6 @@ interface GenerativePianoTuning {
lowpassBaseExpression: number;
lowpassIntensityWeight: number;
lowpassManiaWeight: number;
manicThreshold: number;
intenseThreshold: number;
activeThreshold: number;
};
@ -133,8 +140,6 @@ interface GenerativePianoTuning {
highOffset: number;
mediumOffset: number;
lowOffset: number;
minOffset: number;
maxOffset: number;
};
registerBias: {
maniaShiftSemitones: number;
@ -171,8 +176,6 @@ interface GenerativePianoTuning {
gestureAccentMinIntervalSeconds: number;
strokeAccentMinSteps: number;
strokeAccentThreshold: number;
stingerDurationSeconds: number;
stingerSpacingSeconds: number;
maxBrushPhraseLayers: number;
maxBrushStreamNotesPerBar: number;
brushLayerBaseSeconds: number;
@ -181,7 +184,6 @@ interface GenerativePianoTuning {
brushStreamIdleIntervalBeats: number;
brushStreamActiveIntervalBeats: number;
brushStreamIntenseIntervalBeats: number;
brushStreamManicIntervalBeats: number;
brushMotifMaxSteps: number;
brushMotifCanonDelaySeconds: number;
padDurationBarScale: number;
@ -236,6 +238,16 @@ export const generativePianoTuning: GenerativePianoTuning = {
pans: [-0.16, 0, 0.16],
delaySends: [0.012, 0.014, 0.016],
lowpassExpression: 0.35,
noteDurationSeconds: 1.1,
spacingSeconds: 0.08,
},
releaseResolution: {
durationSeconds: 3.4,
fadeAfterSeconds: 2.4,
velocities: [0.064, 0.05, 0.038],
delaySend: 0.018,
lowpassExpression: 0.34,
strumSeconds: 0.055,
},
highActivityExtra: {
barOffset: 1,
@ -284,7 +296,6 @@ export const generativePianoTuning: GenerativePianoTuning = {
delaySend: 0.012,
},
touchNote: {
registerBiasManiaAmount: 0,
velocityBase: 0.14,
velocityStrengthWeight: 0.11,
durationBaseSeconds: 0.55,
@ -297,7 +308,6 @@ export const generativePianoTuning: GenerativePianoTuning = {
initialMotifOffset: -1,
energyDecaySeconds: 0.72,
maniaDecaySeconds: 0.54,
fadeMinimumLifetimeSeconds: 0.001,
layerIntensityBase: 0.8,
layerIntensityManiaWeight: 0.42,
frameActivityWeight: 0.42,
@ -323,7 +333,6 @@ export const generativePianoTuning: GenerativePianoTuning = {
lowpassBaseExpression: 0.39,
lowpassIntensityWeight: 0.48,
lowpassManiaWeight: 0.18,
manicThreshold: 0.85,
intenseThreshold: 0.62,
activeThreshold: 0.34,
},
@ -350,8 +359,6 @@ export const generativePianoTuning: GenerativePianoTuning = {
highOffset: 1,
mediumOffset: 0,
lowOffset: -1,
minOffset: -3,
maxOffset: 4,
},
registerBias: {
maniaShiftSemitones: 4,
@ -388,8 +395,6 @@ export const generativePianoTuning: GenerativePianoTuning = {
gestureAccentMinIntervalSeconds: 2.5,
strokeAccentMinSteps: 12,
strokeAccentThreshold: 0.58,
stingerSpacingSeconds: 0.08,
stingerDurationSeconds: 1.1,
maxBrushPhraseLayers: 3,
maxBrushStreamNotesPerBar: 9,
brushLayerBaseSeconds: 5.5,
@ -398,7 +403,6 @@ export const generativePianoTuning: GenerativePianoTuning = {
brushStreamIdleIntervalBeats: 2,
brushStreamActiveIntervalBeats: 1,
brushStreamIntenseIntervalBeats: 0.5,
brushStreamManicIntervalBeats: 0.5,
brushMotifMaxSteps: 8,
brushMotifCanonDelaySeconds: 0.055,
padDurationBarScale: 0.46,

View file

@ -5,17 +5,7 @@ import type {
GardenAudioConfig,
GardenAudioVibeProfile,
} from './garden-audio-config';
import {
degreeToSemitone,
getChordIntervals,
getVibeProfile,
PITCH_SEMITONES_PER_OCTAVE,
} from './garden-audio-music';
import {
GENERATIVE_LOOKAHEAD_SECONDS,
GENERATIVE_START_DELAY_SECONDS,
PIANO_SCHEDULE_AHEAD_SECONDS,
} from './garden-audio-scheduling';
import { getVibeProfile, PITCH_SEMITONES_PER_OCTAVE } from './garden-audio-music';
import type { PianoNote } from './garden-audio-types';
import {
generativePianoTuning,
@ -23,6 +13,39 @@ import {
type GardenAudioRegister,
type GardenAudioStylePool,
} from './generative-piano-tuning';
import { PIANO_SCHEDULE_AHEAD_SECONDS } from './piano-sampler';
const GENERATIVE_LOOKAHEAD_SECONDS = 0.3;
const GENERATIVE_START_DELAY_SECONDS = 0.02;
const chordVoicings = {
majorOpen: [0, 7, 12, 16],
minorOpen: [0, 7, 12, 15],
majorClosed: [0, 4, 7, 12, 16],
minorClosed: [0, 3, 7, 12, 15],
};
const getChordIntervals = (
chord: GardenAudioChord,
openVoicing: boolean
): Array<number> => {
if (openVoicing) {
return chord.quality === 'major' ? chordVoicings.majorOpen : chordVoicings.minorOpen;
}
return chord.quality === 'major'
? chordVoicings.majorClosed
: chordVoicings.minorClosed;
};
const degreeToSemitone = (
profile: GardenAudioVibeProfile,
degree: number
): number => {
const scaleIndex =
((degree % profile.scale.length) + profile.scale.length) % profile.scale.length;
const octave = Math.floor(degree / profile.scale.length);
return profile.scale[scaleIndex] + octave * PITCH_SEMITONES_PER_OCTAVE;
};
type GardenAudioStyleIndex = 0 | 1 | 2;
@ -124,6 +147,23 @@ export class GenerativePianoEngine {
this.isWaitingForGestureAccent = false;
}
public release(vibe: VibePreset, now: number): number {
this.prime(now);
this.isWaitingForGestureAccent = false;
const profile = getVibeProfile(vibe);
const releaseStep = this.getReleaseResolutionStep(now);
const releaseStart = Math.max(now, this.getTimeForStep(releaseStep));
this.playReleaseResolution(profile, releaseStep, releaseStart);
this.nextBeatStep = null;
this.nextBrushStreamStep = null;
this.brushPhraseLayers = [];
this.brushStreamNoteCountsByBar.clear();
return releaseStart + this.generation.releaseResolution.fadeAfterSeconds;
}
private recordTouchDown({
vibe,
now,
@ -234,47 +274,27 @@ export class GenerativePianoEngine {
const chord = this.getChord(profile, 0);
const intervals = getChordIntervals(chord, true);
const rootMidi = profile.rootMidi + chord.rootOffset;
const notes = [
{
midi: this.chooseMidi(
{ baseMidi: rootMidi, offsets: [0] },
this.generation.padRegisters[0]
),
velocity: this.generation.vibeChangeStinger.velocities[0],
pan: this.generation.vibeChangeStinger.pans[0],
delaySend: this.generation.vibeChangeStinger.delaySends[0],
},
{
midi: this.chooseMidi(
{ baseMidi: rootMidi, offsets: [intervals[1], intervals[2]] },
this.generation.padRegisters[1]
),
velocity: this.generation.vibeChangeStinger.velocities[1],
pan: this.generation.vibeChangeStinger.pans[1],
delaySend: this.generation.vibeChangeStinger.delaySends[1],
},
{
midi: this.chooseMidi(
{ baseMidi: rootMidi, offsets: [intervals[3], intervals[2]] },
this.generation.padRegisters[2]
),
velocity: this.generation.vibeChangeStinger.velocities[2],
pan: this.generation.vibeChangeStinger.pans[2],
delaySend: this.generation.vibeChangeStinger.delaySends[2],
},
const stinger = this.generation.vibeChangeStinger;
const offsetsByVoice: ReadonlyArray<ReadonlyArray<number>> = [
[0],
[intervals[1], intervals[2]],
[intervals[3], intervals[2]],
];
notes.forEach((note, index) => {
offsetsByVoice.forEach((offsets, index) => {
const midi = this.chooseMidi(
{ baseMidi: rootMidi, offsets },
this.generation.padRegisters[index]
);
this.playNote({
...note,
durationSeconds: this.generation.stingerDurationSeconds,
midi,
velocity: stinger.velocities[index],
pan: stinger.pans[index],
delaySend: stinger.delaySends[index],
durationSeconds: stinger.noteDurationSeconds,
role: 'stinger',
lowpassHz: this.getLowpassHz(
profile,
note.midi,
this.generation.vibeChangeStinger.lowpassExpression
),
startTime: now + index * this.generation.stingerSpacingSeconds,
lowpassHz: this.getLowpassHz(profile, midi, stinger.lowpassExpression),
startTime: now + index * stinger.spacingSeconds,
});
});
}
@ -285,12 +305,8 @@ export class GenerativePianoEngine {
this.isWaitingForGestureAccent = false;
this.lastGestureAccentAt = Number.NEGATIVE_INFINITY;
this.lastStrokeAccentStep = Number.NEGATIVE_INFINITY;
this.lastMidiByStyle[0] = null;
this.lastMidiByStyle[1] = null;
this.lastMidiByStyle[2] = null;
this.lastPadMidiByVoice[0] = null;
this.lastPadMidiByVoice[1] = null;
this.lastPadMidiByVoice[2] = null;
this.lastMidiByStyle.fill(null);
this.lastPadMidiByVoice.fill(null);
this.brushPhraseLayers = [];
this.nextBrushStreamStep = null;
this.brushStreamNoteIndex = 0;
@ -400,6 +416,37 @@ export class GenerativePianoEngine {
});
}
private playReleaseResolution(
profile: GardenAudioVibeProfile,
stepIndex: number,
startTime: number
): void {
const chord = this.getChord(profile, this.getBarIndexForStep(stepIndex));
const intervals = getChordIntervals(chord, true);
const rootMidi = profile.rootMidi + chord.rootOffset;
const release = this.generation.releaseResolution;
const offsetsByVoice: ReadonlyArray<ReadonlyArray<number>> = [
[0],
[intervals[1], intervals[2]],
[intervals[3], intervals[2]],
];
offsetsByVoice.forEach((offsets, index) => {
const register = this.generation.padRegisters[index];
const midi = this.chooseMidi({ baseMidi: rootMidi, offsets }, register, null, false);
this.playNote({
midi,
velocity: release.velocities[index],
startTime: startTime + index * release.strumSeconds,
durationSeconds: release.durationSeconds,
pan: register.pan,
role: 'pad',
delaySend: release.delaySend,
lowpassHz: this.getLowpassHz(profile, midi, release.lowpassExpression),
});
});
}
private playSupportNote(
profile: GardenAudioVibeProfile,
barIndex: number,
@ -546,10 +593,6 @@ export class GenerativePianoEngine {
}): void {
const profile = getVibeProfile(vibe);
const pool = this.generation.stylePools[styleIndex];
const register = this.getBiasedRegister(
pool,
this.generation.touchNote.registerBiasManiaAmount
);
const chord = this.getChord(profile, this.getGlobalBarIndex(now));
const chordIntervals = getChordIntervals(chord, false);
const rootMidi = profile.rootMidi + chord.rootOffset;
@ -558,7 +601,7 @@ export class GenerativePianoEngine {
baseMidi: rootMidi,
offsets: this.getSupportOffsets(chordIntervals, styleIndex),
},
register,
pool,
this.lastMidiByStyle[styleIndex],
true
);
@ -877,41 +920,26 @@ export class GenerativePianoEngine {
private getBrushStreamIntervalSteps(intensity: number): number {
const intervalBeats =
intensity >= this.generation.brushStream.manicThreshold
? this.generation.brushStreamManicIntervalBeats
: intensity >= this.generation.brushStream.intenseThreshold
? this.generation.brushStreamIntenseIntervalBeats
: intensity >= this.generation.brushStream.activeThreshold
? this.generation.brushStreamActiveIntervalBeats
: this.generation.brushStreamIdleIntervalBeats;
intensity >= this.generation.brushStream.intenseThreshold
? this.generation.brushStreamIntenseIntervalBeats
: intensity >= this.generation.brushStream.activeThreshold
? this.generation.brushStreamActiveIntervalBeats
: this.generation.brushStreamIdleIntervalBeats;
return Math.max(1, Math.round(intervalBeats * this.config.rhythm.stepsPerBeat));
}
private getBrushPhraseFade(layer: BrushPhraseLayer, startTime: number): number {
const lifetimeSeconds = layer.expiresAt - layer.startedAt;
const lifetimeSeconds = Math.max(0.001, layer.expiresAt - layer.startedAt);
const ageSeconds = startTime - layer.startedAt;
return clamp01(
1 -
ageSeconds /
Math.max(
this.generation.brushPhrase.fadeMinimumLifetimeSeconds,
lifetimeSeconds
)
);
return clamp01(1 - ageSeconds / lifetimeSeconds);
}
private getMotifOffset(strength: number): number {
const energyStep =
strength >= this.generation.brushMotif.highThreshold
? this.generation.brushMotif.highOffset
: strength >= this.generation.brushMotif.mediumThreshold
? this.generation.brushMotif.mediumOffset
: this.generation.brushMotif.lowOffset;
return clamp(
energyStep,
this.generation.brushMotif.minOffset,
this.generation.brushMotif.maxOffset
);
return strength >= this.generation.brushMotif.highThreshold
? this.generation.brushMotif.highOffset
: strength >= this.generation.brushMotif.mediumThreshold
? this.generation.brushMotif.mediumOffset
: this.generation.brushMotif.lowOffset;
}
private getBrushMotifDegrees({
@ -1185,6 +1213,19 @@ export class GenerativePianoEngine {
);
}
private getReleaseResolutionStep(startTime: number): number {
const currentStep = this.getStepIndexAtTime(startTime);
const nextBeatStep =
Math.ceil((currentStep + 1) / this.config.rhythm.stepsPerBeat) *
this.config.rhythm.stepsPerBeat;
const nextBarStep =
Math.ceil((currentStep + 1) / this.config.rhythm.stepsPerBar) *
this.config.rhythm.stepsPerBar;
const barIsClose = nextBarStep - currentStep <= this.config.rhythm.stepsPerBeat * 2;
return barIsClose ? nextBarStep : nextBeatStep;
}
private getNextBarTimeAt(startTime: number): number {
const nextBarStep =
Math.ceil(this.getStepIndexAtTime(startTime) / this.config.rhythm.stepsPerBar) *

View file

@ -1,4 +1,4 @@
import { createAudioPanNode } from './audio-pan-node';
import { clamp } from '../utils/math';
import type { GardenAudioGraph } from './garden-audio-graph';
import type { NoiseBurst } from './garden-audio-types';
@ -15,9 +15,8 @@ export class NoiseBurstPlayer {
public constructor(private readonly graph: GardenAudioGraph) {}
public play({ startTime, durationSeconds, gain, filterHz, pan }: NoiseBurst): void {
const { context, eventBus, noiseBus, noiseBuffer } = this.graph;
const outputBus = noiseBus ?? eventBus;
if (!context || !outputBus || !noiseBuffer) {
const { context, noiseBus, noiseBuffer } = this.graph;
if (!context || !noiseBus || !noiseBuffer) {
return;
}
@ -28,7 +27,7 @@ export class NoiseBurstPlayer {
const source = context.createBufferSource();
const filter = context.createBiquadFilter();
const envelope = context.createGain();
const panNode = createAudioPanNode(context, pan, scheduledStart);
const panner = context.createStereoPanner();
const stopAt = scheduledStart + durationSeconds;
source.buffer = noiseBuffer;
@ -41,10 +40,11 @@ export class NoiseBurstPlayer {
scheduledStart + noiseBurstTuning.attackSeconds
);
envelope.gain.exponentialRampToValueAtTime(noiseBurstTuning.silentGain, stopAt);
panner.pan.setValueAtTime(clamp(pan, -1, 1), scheduledStart);
source.connect(filter);
filter.connect(envelope);
envelope.connect(panNode.input);
panNode.output.connect(outputBus);
envelope.connect(panner);
panner.connect(noiseBus);
source.start(scheduledStart, Math.random() * noiseBurstTuning.offsetRandomSeconds);
source.stop(stopAt);
source.addEventListener(
@ -53,7 +53,7 @@ export class NoiseBurstPlayer {
source.disconnect();
filter.disconnect();
envelope.disconnect();
panNode.disconnect();
panner.disconnect();
},
{ once: true }
);

View file

@ -1,18 +1,20 @@
import { clamp, clamp01 } from '../utils/math';
import { createAudioPanNode } from './audio-pan-node';
import type { GardenAudioConfig } from './garden-audio-config';
import type { GardenAudioGraph } from './garden-audio-graph';
import { PITCH_SEMITONES_PER_OCTAVE } from './garden-audio-music';
import { PIANO_SCHEDULE_AHEAD_SECONDS } from './garden-audio-scheduling';
import type {
ActivePianoVoice,
LoadedPianoSample,
PianoNote,
} from './garden-audio-types';
import type { LoadedPianoSample, PianoNote } from './garden-audio-types';
import { getLoadedPianoSamples, loadPianoSamples } from './piano-samples';
export const PIANO_SCHEDULE_AHEAD_SECONDS = 0.002;
type PianoLoadState = 'idle' | 'loading' | 'loaded';
interface ActivePianoVoice {
gain: GainNode;
source: AudioScheduledSourceNode;
stopAt: number;
}
const pianoSamplerTuning = {
filterType: 'lowpass' as BiquadFilterType,
filterQ: 0.7,
@ -29,7 +31,6 @@ const pianoSamplerTuning = {
export class PianoSampler {
private loadState: PianoLoadState = 'idle';
private sampleLoadPromise: Promise<void> | null = null;
private samples: Array<LoadedPianoSample> = [];
private activeVoices: Array<ActivePianoVoice> = [];
@ -38,9 +39,9 @@ export class PianoSampler {
private readonly graph: GardenAudioGraph
) {}
public load(context: BaseAudioContext): Promise<void> {
if (this.loadState === 'loaded') {
return Promise.resolve();
public loadIfIdle(context: BaseAudioContext): Promise<void> | null {
if (this.loadState !== 'idle') {
return null;
}
const loadedSamples = getLoadedPianoSamples();
@ -50,33 +51,16 @@ export class PianoSampler {
return Promise.resolve();
}
if (this.sampleLoadPromise) {
return this.sampleLoadPromise;
}
this.loadState = 'loading';
this.sampleLoadPromise = loadPianoSamples(context, undefined, {
forceReload: true,
})
return loadPianoSamples(context)
.then((samples) => {
this.setSamples(samples);
this.loadState = 'loaded';
})
.catch((error) => {
this.loadState = 'idle';
this.sampleLoadPromise = null;
throw error;
});
return this.sampleLoadPromise;
}
public loadIfIdle(context: BaseAudioContext): Promise<void> | null {
if (this.loadState !== 'idle') {
return null;
}
return this.load(context);
}
public play({
@ -89,115 +73,114 @@ export class PianoSampler {
delaySend = 0,
lowpassHz = this.config.piano.lowpassHz,
}: PianoNote): void {
const { context, delayInput } = this.graph;
const { context } = this.graph;
const eventBus = this.graph.getPianoBus(role);
if (!context || !eventBus) {
return;
}
const sample = this.findNearestSample(midi);
if (!sample) {
this.playSynthFallback({
midi,
velocity,
startTime,
durationSeconds,
pan,
role,
delaySend,
lowpassHz,
});
return;
}
const scheduledStart = Math.max(
context.currentTime + PIANO_SCHEDULE_AHEAD_SECONDS,
startTime
);
const noteVelocity = clamp01(velocity);
const sample = this.findNearestSample(midi);
if (sample) {
const noteGainValue = Math.max(
pianoSamplerTuning.minGain,
this.config.piano.gain * noteVelocity
);
const sustainSeconds =
this.config.piano.sustainSeconds *
(this.config.piano.sustainBase +
noteVelocity * this.config.piano.sustainVelocityRange);
const sustainAt =
scheduledStart +
Math.max(pianoSamplerTuning.minDurationSeconds, durationSeconds);
const releaseAt = sustainAt + sustainSeconds;
const stopAt = releaseAt + this.config.piano.releaseSeconds;
const source = context.createBufferSource();
source.buffer = sample.buffer;
source.playbackRate.setValueAtTime(
Math.pow(2, (midi - sample.midi) / PITCH_SEMITONES_PER_OCTAVE),
scheduledStart
);
this.scheduleVoice({
source,
scheduledStart,
stopAt,
pan,
lowpassHz,
delaySend,
eventBus,
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
);
},
});
return;
}
const noteGainValue = Math.max(
pianoSamplerTuning.minGain,
this.config.piano.gain * noteVelocity
this.config.piano.gain * noteVelocity * pianoSamplerTuning.synthGainScale
);
const sustainSeconds =
this.config.piano.sustainSeconds *
(this.config.piano.sustainBase +
noteVelocity * this.config.piano.sustainVelocityRange);
const sustainAt =
scheduledStart + Math.max(pianoSamplerTuning.minDurationSeconds, durationSeconds);
const releaseAt = sustainAt + sustainSeconds;
const releaseSeconds = this.config.piano.releaseSeconds;
const stopAt = releaseAt + releaseSeconds;
const source = context.createBufferSource();
const filter = context.createBiquadFilter();
const gain = context.createGain();
const panNode = createAudioPanNode(context, pan, scheduledStart);
let sendGain: GainNode | null = null;
const releaseAt =
scheduledStart +
clamp(
durationSeconds + this.config.piano.sustainSeconds * 0.5,
pianoSamplerTuning.minDurationSeconds,
pianoSamplerTuning.synthMaxDurationSeconds
);
const stopAt = releaseAt + this.config.piano.releaseSeconds;
const source = context.createOscillator();
this.trimActiveVoices(scheduledStart);
while (this.activeVoices.length >= this.config.piano.maxVoices) {
const oldest = this.activeVoices.shift();
if (oldest) {
this.stopVoice(oldest, scheduledStart);
}
}
source.type = pianoSamplerTuning.synthOscillatorType;
source.frequency.setValueAtTime(getMidiFrequency(midi), scheduledStart);
source.buffer = sample.buffer;
source.playbackRate.setValueAtTime(
Math.pow(2, (midi - sample.midi) / PITCH_SEMITONES_PER_OCTAVE),
scheduledStart
);
filter.type = pianoSamplerTuning.filterType;
filter.frequency.setValueAtTime(
clamp(lowpassHz, this.config.piano.lowpassMinHz, this.config.piano.lowpassMaxHz),
scheduledStart
);
filter.Q.value = pianoSamplerTuning.filterQ;
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, releaseSeconds);
source.connect(filter);
filter.connect(gain);
gain.connect(panNode.input);
panNode.output.connect(eventBus);
if (delayInput && delaySend > 0) {
sendGain = context.createGain();
sendGain.gain.value = delaySend;
panNode.output.connect(sendGain);
sendGain.connect(delayInput);
}
source.start(scheduledStart);
source.stop(stopAt + pianoSamplerTuning.tailStopExtraSeconds);
this.activeVoices.push({ gain, source, stopAt });
source.addEventListener(
'ended',
() => {
source.disconnect();
filter.disconnect();
gain.disconnect();
panNode.disconnect();
sendGain?.disconnect();
this.activeVoices = this.activeVoices.filter((voice) => voice.gain !== gain);
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(
pianoSamplerTuning.minGain,
releaseAt,
this.config.piano.releaseSeconds
);
},
{ once: true }
);
});
}
public stopAll(): void {
@ -215,114 +198,64 @@ export class PianoSampler {
this.activeVoices = [];
}
public fadeAll(fadeSeconds: number): void {
const context = this.graph.context;
if (!context) {
this.activeVoices = [];
return;
}
const now = context.currentTime;
const fadeDurationSeconds = Math.max(pianoSamplerTuning.minFadeSeconds, fadeSeconds);
this.trimActiveVoices(now);
this.activeVoices.forEach((voice) => {
this.fadeVoice(voice, now, fadeDurationSeconds);
});
}
public reset(): void {
this.loadState = 'idle';
this.sampleLoadPromise = null;
this.samples = [];
this.activeVoices = [];
}
private findNearestSample(midi: number): LoadedPianoSample | null {
if (this.samples.length === 0) {
return null;
}
return this.samples.reduce((nearest, sample) =>
Math.abs(sample.midi - midi) < Math.abs(nearest.midi - midi) ? sample : nearest
);
}
private playSynthFallback({
midi,
velocity,
startTime,
durationSeconds,
private scheduleVoice({
source,
scheduledStart,
stopAt,
pan,
role,
delaySend = 0,
lowpassHz = this.config.piano.lowpassHz,
}: PianoNote): void {
lowpassHz,
delaySend,
eventBus,
configureGainEnvelope,
}: {
source: AudioScheduledSourceNode;
scheduledStart: number;
stopAt: number;
pan: number;
lowpassHz: number;
delaySend: number;
eventBus: GainNode;
configureGainEnvelope: (gain: GainNode) => void;
}): void {
const { context, delayInput } = this.graph;
const eventBus = this.graph.getPianoBus(role);
if (!context || !eventBus) {
if (!context) {
return;
}
const scheduledStart = Math.max(
context.currentTime + PIANO_SCHEDULE_AHEAD_SECONDS,
startTime
);
const noteVelocity = clamp01(velocity);
const noteGainValue = Math.max(
pianoSamplerTuning.minGain,
this.config.piano.gain * noteVelocity * pianoSamplerTuning.synthGainScale
);
const releaseAt =
scheduledStart +
clamp(
durationSeconds + this.config.piano.sustainSeconds * 0.5,
pianoSamplerTuning.minDurationSeconds,
pianoSamplerTuning.synthMaxDurationSeconds
);
const stopAt = releaseAt + this.config.piano.releaseSeconds;
const source = context.createOscillator();
const filter = context.createBiquadFilter();
const gain = context.createGain();
const panNode = createAudioPanNode(context, pan, scheduledStart);
const panner = context.createStereoPanner();
let sendGain: GainNode | null = null;
this.trimActiveVoices(scheduledStart);
while (this.activeVoices.length >= this.config.piano.maxVoices) {
const oldest = this.activeVoices.shift();
if (oldest) {
this.stopVoice(oldest, scheduledStart);
}
this.stopVoice(this.activeVoices.shift() as ActivePianoVoice, scheduledStart);
}
source.type = pianoSamplerTuning.synthOscillatorType;
source.frequency.setValueAtTime(getMidiFrequency(midi), scheduledStart);
filter.type = pianoSamplerTuning.filterType;
filter.frequency.setValueAtTime(
clamp(lowpassHz, this.config.piano.lowpassMinHz, this.config.piano.lowpassMaxHz),
scheduledStart
);
filter.Q.value = pianoSamplerTuning.filterQ;
gain.gain.setValueAtTime(pianoSamplerTuning.minGain, scheduledStart);
gain.gain.exponentialRampToValueAtTime(
noteGainValue,
scheduledStart + this.config.piano.gainAttackSeconds
);
gain.gain.setTargetAtTime(
pianoSamplerTuning.minGain,
releaseAt,
this.config.piano.releaseSeconds
);
panner.pan.setValueAtTime(clamp(pan, -1, 1), scheduledStart);
configureGainEnvelope(gain);
source.connect(filter);
filter.connect(gain);
gain.connect(panNode.input);
panNode.output.connect(eventBus);
gain.connect(panner);
panner.connect(eventBus);
if (delayInput && delaySend > 0) {
sendGain = context.createGain();
sendGain.gain.value = delaySend;
panNode.output.connect(sendGain);
panner.connect(sendGain);
sendGain.connect(delayInput);
}
@ -336,7 +269,7 @@ export class PianoSampler {
source.disconnect();
filter.disconnect();
gain.disconnect();
panNode.disconnect();
panner.disconnect();
sendGain?.disconnect();
this.activeVoices = this.activeVoices.filter((voice) => voice.gain !== gain);
},
@ -344,6 +277,16 @@ export class PianoSampler {
);
}
private findNearestSample(midi: number): LoadedPianoSample | null {
if (this.samples.length === 0) {
return null;
}
return this.samples.reduce((nearest, sample) =>
Math.abs(sample.midi - midi) < Math.abs(nearest.midi - midi) ? sample : nearest
);
}
private trimActiveVoices(now: number): void {
this.activeVoices = this.activeVoices.filter((voice) => voice.stopAt > now);
}
@ -358,32 +301,7 @@ export class PianoSampler {
pianoSamplerTuning.voiceStealFadeSeconds
);
voice.stopAt = stopAt;
try {
voice.source.stop(stopAt);
} catch {
// The voice may already have ended; either way it is no longer part of the mix.
}
}
private fadeVoice(
voice: ActivePianoVoice,
now: number,
fadeDurationSeconds: number
): void {
const stopAt = Math.min(voice.stopAt, now + fadeDurationSeconds);
voice.gain.gain.cancelScheduledValues(now);
voice.gain.gain.setTargetAtTime(
pianoSamplerTuning.minGain,
now,
Math.max(0.001, fadeDurationSeconds / 4)
);
voice.stopAt = stopAt;
try {
voice.source.stop(stopAt + pianoSamplerTuning.tailStopExtraSeconds);
} catch {
// The voice may already have ended; either way it is fading out of the mix.
}
voice.source.stop(stopAt);
}
private setSamples(samples: Array<LoadedPianoSample>): void {

View file

@ -8,7 +8,6 @@ interface PianoSampleDefinition {
export interface PianoSampleLoadProgress {
loadedCount: number;
totalCount: number;
sample?: PianoSampleDefinition;
}
const sampleFiles: Array<[fileName: string, midi: number]> = [
@ -45,11 +44,6 @@ const sampleFiles: Array<[fileName: string, midi: number]> = [
];
const sampleBaseUrl = `${import.meta.env.BASE_URL}audio/`;
const preloadDecode = {
channels: 2,
frames: 128,
sampleRateHz: 48_000,
};
const pianoSampleDefinitions: Array<PianoSampleDefinition> = sampleFiles
.map(([fileName, midi]) => ({
@ -61,10 +55,6 @@ const pianoSampleDefinitions: Array<PianoSampleDefinition> = sampleFiles
let loadedPianoSamples: Array<LoadedPianoSample> | null = null;
let pianoSampleLoadPromise: Promise<Array<LoadedPianoSample>> | null = null;
interface PianoSampleLoadOptions {
forceReload?: boolean;
}
const sampleLoadTuning = {
concurrency: 4,
sampleTimeoutMs: 15_000,
@ -81,18 +71,14 @@ export const preloadPianoSamples = (
);
}
const decodeContext = new OfflineAudioContextConstructor(
preloadDecode.channels,
preloadDecode.frames,
preloadDecode.sampleRateHz
);
// Decoding ignores these, but the constructor demands real numbers.
const decodeContext = new OfflineAudioContextConstructor(1, 1, 48_000);
return loadPianoSamples(decodeContext, onProgress);
};
export const loadPianoSamples = (
decodeContext: BaseAudioContext,
onProgress?: (progress: PianoSampleLoadProgress) => void,
options: PianoSampleLoadOptions = {}
onProgress?: (progress: PianoSampleLoadProgress) => void
): Promise<Array<LoadedPianoSample>> => {
if (loadedPianoSamples) {
onProgress?.({
@ -102,7 +88,7 @@ export const loadPianoSamples = (
return Promise.resolve([...loadedPianoSamples]);
}
if (pianoSampleLoadPromise && options.forceReload !== true) {
if (pianoSampleLoadPromise) {
return pianoSampleLoadPromise;
}
@ -115,12 +101,12 @@ export const loadPianoSamples = (
async (sample) => {
try {
return await withTimeout(
loadPianoSample(decodeContext, sample),
(signal) => loadPianoSample(decodeContext, sample, signal),
sampleLoadTuning.sampleTimeoutMs
);
} finally {
loadedCount += 1;
onProgress?.({ loadedCount, totalCount, sample });
onProgress?.({ loadedCount, totalCount });
}
}
).then(
@ -147,15 +133,16 @@ export const getLoadedPianoSamples = (): Array<LoadedPianoSample> | null =>
const loadPianoSample = async (
decodeContext: BaseAudioContext,
sample: PianoSampleDefinition
sample: PianoSampleDefinition,
signal: AbortSignal
): Promise<LoadedPianoSample> => {
const response = await fetch(sample.url);
const response = await fetch(sample.url, { signal });
if (!response.ok) {
throw new Error(`Unable to load piano sample ${sample.url}`);
}
const audioData = await response.arrayBuffer();
const buffer = await decodeAudioData(decodeContext, audioData);
const buffer = await decodeContext.decodeAudioData(audioData);
return { midi: sample.midi, buffer };
};
@ -178,13 +165,18 @@ const loadPianoSampleBatch = async (
return results;
};
const withTimeout = <T>(promise: Promise<T>, timeoutMs: number): Promise<T> =>
const withTimeout = <T>(
operation: (signal: AbortSignal) => Promise<T>,
timeoutMs: number
): Promise<T> =>
new Promise((resolve, reject) => {
const controller = new AbortController();
const timeout = globalThis.setTimeout(() => {
controller.abort();
reject(new Error('Timed out while loading a piano sample.'));
}, timeoutMs);
promise.then(
operation(controller.signal).then(
(value) => {
globalThis.clearTimeout(timeout);
resolve(value);
@ -195,12 +187,3 @@ const withTimeout = <T>(promise: Promise<T>, timeoutMs: number): Promise<T> =>
}
);
});
const decodeAudioData = (
decodeContext: BaseAudioContext,
audioData: ArrayBuffer
): Promise<AudioBuffer> =>
new Promise((resolve, reject) => {
const decodePromise = decodeContext.decodeAudioData(audioData, resolve, reject);
decodePromise?.then(resolve, reject);
});