This commit is contained in:
Andras Schmelczer 2026-05-13 21:07:10 +01:00
commit 39b0160064
136 changed files with 7129 additions and 1950 deletions

View file

@ -1,3 +1,5 @@
import { appConfig } from '../config';
export type GardenAudioChordQuality = 'major' | 'minor';
export interface GardenAudioChord {
@ -7,7 +9,6 @@ export interface GardenAudioChord {
export interface GardenAudioColorVoice {
scaleDegreeOffset: number;
octaveOffset: number;
velocityMultiplier: number;
panOffset: number;
}
@ -21,19 +22,11 @@ export interface GardenAudioVibeProfile {
}
export interface GardenAudioConfig {
enabled: boolean;
masterVolume: number;
fadeInSeconds: number;
updateRampSeconds: number;
highPassFrequencyHz: number;
fallbackVibeId: string;
startup: {
calmDurationSeconds: number;
initialTempoMultiplier: number;
initialEnergyMultiplier: number;
initialActivityCeiling: number;
initialTapIntervalMultiplier: number;
};
compressor: {
thresholdDb: number;
kneeDb: number;
@ -42,7 +35,6 @@ export interface GardenAudioConfig {
releaseSeconds: number;
};
delay: {
enabled: boolean;
timeSeconds: number;
feedback: number;
wetGain: number;
@ -54,7 +46,6 @@ export interface GardenAudioConfig {
sustainLevel: number;
releaseSeconds: number;
lowpassHz: number;
preloadOnStart: boolean;
};
input: {
pressureFallback: number;
@ -64,25 +55,10 @@ export interface GardenAudioConfig {
stepsPerBeat: number;
stepsPerBar: number;
lookaheadSeconds: number;
swing: number;
minTailSeconds: number;
maxTailSeconds: number;
tailDistanceForMaxPixels: number;
tailDurationForMaxSeconds: number;
tailDecayPower: number;
minTapIntervalSeconds: number;
speedForFullEnergyPixelsPerSecond: number;
sparseActivity: number;
arpeggioActivity: number;
fullChordActivity: number;
bassActivity: number;
melodySteps: Array<number>;
chordSteps: Array<number>;
bassSteps: Array<number>;
melodyPattern: Array<number>;
};
eraser: {
enabled: boolean;
minIntervalSeconds: number;
noiseGain: number;
filterMinHz: number;
@ -92,163 +68,4 @@ export interface GardenAudioConfig {
vibes: Record<string, GardenAudioVibeProfile>;
}
const majorProgression: Array<GardenAudioChord> = [
{ rootOffset: 0, quality: 'major' },
{ rootOffset: 9, quality: 'minor' },
{ rootOffset: 5, quality: 'major' },
{ rootOffset: 7, quality: 'major' },
];
const minorProgression: Array<GardenAudioChord> = [
{ rootOffset: 0, quality: 'minor' },
{ rootOffset: 8, quality: 'major' },
{ rootOffset: 3, quality: 'major' },
{ rootOffset: 10, quality: 'major' },
];
const majorPentatonic = [0, 2, 4, 7, 9];
const minorPentatonic = [0, 3, 5, 7, 10];
export const gardenAudioConfig: GardenAudioConfig = {
enabled: true,
masterVolume: 0.32,
fadeInSeconds: 0.45,
updateRampSeconds: 0.08,
highPassFrequencyHz: 45,
fallbackVibeId: 'candy-rain',
startup: {
calmDurationSeconds: 6,
initialTempoMultiplier: 1.18,
initialEnergyMultiplier: 0.62,
initialActivityCeiling: 0.52,
initialTapIntervalMultiplier: 2.2,
},
compressor: {
thresholdDb: -18,
kneeDb: 18,
ratio: 2.4,
attackSeconds: 0.006,
releaseSeconds: 0.18,
},
delay: {
enabled: true,
timeSeconds: 0.42,
feedback: 0.12,
wetGain: 0.048,
},
piano: {
maxVoices: 32,
gain: 0.42,
sustainSeconds: 0.52,
sustainLevel: 0.34,
releaseSeconds: 0.16,
lowpassHz: 9000,
preloadOnStart: true,
},
input: {
pressureFallback: 0.48,
},
rhythm: {
bpm: 82,
stepsPerBeat: 4,
stepsPerBar: 16,
lookaheadSeconds: 0.14,
swing: 0.08,
minTailSeconds: 0.45,
maxTailSeconds: 7.2,
tailDistanceForMaxPixels: 1400,
tailDurationForMaxSeconds: 3.8,
tailDecayPower: 1.85,
minTapIntervalSeconds: 0.16,
speedForFullEnergyPixelsPerSecond: 1800,
sparseActivity: 0.1,
arpeggioActivity: 0.32,
fullChordActivity: 0.62,
bassActivity: 0.48,
melodySteps: [0, 3, 6, 10, 12, 14],
chordSteps: [0, 8],
bassSteps: [0],
melodyPattern: [0, 2, 4, 5, 4, 2, 1, 3],
},
eraser: {
enabled: true,
minIntervalSeconds: 0.12,
noiseGain: 0.028,
filterMinHz: 650,
filterMaxHz: 3600,
},
colorVoices: [
{
scaleDegreeOffset: 0,
octaveOffset: 0,
velocityMultiplier: 0.92,
panOffset: -0.14,
},
{
scaleDegreeOffset: 1,
octaveOffset: 0,
velocityMultiplier: 1,
panOffset: 0,
},
{
scaleDegreeOffset: 2,
octaveOffset: 1,
velocityMultiplier: 0.86,
panOffset: 0.14,
},
],
vibes: {
'candy-rain': {
rootMidi: 57,
scale: majorPentatonic,
brightness: 1.04,
delayTimeMultiplier: 0.92,
progression: majorProgression,
},
'sunlit-moss': {
rootMidi: 53,
scale: majorPentatonic,
brightness: 0.92,
delayTimeMultiplier: 1.08,
progression: [
{ rootOffset: 0, quality: 'major' },
{ rootOffset: 7, quality: 'major' },
{ rootOffset: 9, quality: 'minor' },
{ rootOffset: 5, quality: 'major' },
],
},
'coral-tide': {
rootMidi: 50,
scale: minorPentatonic,
brightness: 1,
delayTimeMultiplier: 1.12,
progression: minorProgression,
},
'moon-orchid': {
rootMidi: 49,
scale: minorPentatonic,
brightness: 0.9,
delayTimeMultiplier: 1.24,
progression: minorProgression,
},
'peach-neon': {
rootMidi: 56,
scale: majorPentatonic,
brightness: 1.08,
delayTimeMultiplier: 0.86,
progression: majorProgression,
},
'frost-bloom': {
rootMidi: 62,
scale: majorPentatonic,
brightness: 0.88,
delayTimeMultiplier: 1.32,
progression: [
{ rootOffset: 0, quality: 'major' },
{ rootOffset: 5, quality: 'major' },
{ rootOffset: 9, quality: 'minor' },
{ rootOffset: 7, quality: 'major' },
],
},
},
};
export const gardenAudioConfig: GardenAudioConfig = appConfig.audio;

View file

@ -0,0 +1,48 @@
import { describe, expect, it } from 'vitest';
import { GardenAudioEnergy } from './garden-audio-energy';
describe('GardenAudioEnergy', () => {
it('suspends activity but keeps a fading level when the gesture ends', () => {
const energy = new GardenAudioEnergy();
energy.beginGesture(0);
energy.recordStroke(0.8, 0.1);
energy.update(0.1);
energy.update(0.2);
const levelBeforeLift = energy.getLevel();
expect(energy.getActivity()).toBeGreaterThan(0);
energy.endGesture();
expect(energy.getActivity()).toBe(0);
expect(energy.getLevel()).toBe(levelBeforeLift);
energy.update(0.3);
expect(energy.getLevel()).toBeLessThan(levelBeforeLift);
expect(energy.getLevel()).toBeGreaterThan(0);
});
it('uses recent stroke intensity rather than gesture duration alone', () => {
const energy = new GardenAudioEnergy();
energy.beginGesture(0);
energy.recordStroke(1, 0.1);
energy.update(0.1);
energy.update(0.2);
const activeLevel = energy.getActivity();
energy.update(1.2);
expect(energy.getActivity()).toBeLessThan(activeLevel);
});
it('raises activity immediately when a stroke is recorded', () => {
const energy = new GardenAudioEnergy();
energy.beginGesture(0);
energy.recordStroke(0.12, 0.05);
expect(energy.getActivity()).toBeGreaterThan(0.09);
});
});

View file

@ -1,71 +1,40 @@
import { appConfig } from '../config';
import { clamp01 } from '../utils/clamp';
import { GardenAudioConfig } from './garden-audio-config';
interface GardenGestureState {
startedAt: number;
lastAt: number;
distancePixels: number;
peakEnergy: number;
isErasing: boolean;
}
interface GestureTail {
startedAt: number;
durationSeconds: number;
level: number;
}
type GardenAudioRhythmConfig = GardenAudioConfig['rhythm'];
const STROKE_IMMEDIATE_ACTIVITY_SCALE = 0.85;
export class GardenAudioEnergy {
private isGestureActive = false;
private energy = 0;
private targetEnergy = 0;
private lastEnergyUpdateAt = 0;
private currentGesture: GardenGestureState | null = null;
private gestureTails: Array<GestureTail> = [];
public constructor(private readonly rhythm: GardenAudioRhythmConfig) {}
public beginGesture(now: number): void {
this.isGestureActive = true;
this.currentGesture = createGesture(now, false);
this.lastEnergyUpdateAt = now;
}
public endGesture(now: number): void {
if (this.currentGesture && !this.currentGesture.isErasing) {
this.addGestureTail(this.currentGesture, now);
}
public endGesture(): void {
this.isGestureActive = false;
this.currentGesture = null;
this.targetEnergy = 0;
}
public recordStroke(distancePixels: number, strokeEnergy: number, now: number): void {
if (!this.currentGesture || this.currentGesture.isErasing) {
this.currentGesture = createGesture(now, false);
public recordStroke(strokeEnergy: number, now: number): void {
const energy = clamp01(strokeEnergy);
this.targetEnergy = Math.max(this.targetEnergy, energy);
if (this.isGestureActive) {
this.energy = Math.max(this.energy, energy * STROKE_IMMEDIATE_ACTIVITY_SCALE);
}
this.currentGesture.lastAt = now;
this.currentGesture.distancePixels += distancePixels;
this.currentGesture.peakEnergy = Math.max(
this.currentGesture.peakEnergy,
strokeEnergy
);
this.lastEnergyUpdateAt ||= now;
}
public recordEraserStroke(now: number): void {
this.currentGesture = this.currentGesture ?? createGesture(now, true);
this.currentGesture.isErasing = true;
}
public raiseTarget(strokeEnergy: number): void {
this.targetEnergy = Math.max(this.targetEnergy, strokeEnergy);
public recordEraserStroke(): void {
this.targetEnergy = 0;
}
public silence(): void {
this.targetEnergy = 0;
this.gestureTails = [];
this.energy = 0;
}
public update(now: number): void {
@ -76,28 +45,31 @@ export class GardenAudioEnergy {
const elapsedSeconds = Math.max(0, now - this.lastEnergyUpdateAt);
this.lastEnergyUpdateAt = now;
this.targetEnergy *= Math.exp(-elapsedSeconds / 0.75);
this.trimGestureTails(now);
this.targetEnergy *= Math.exp(
-elapsedSeconds / appConfig.audioEngine.energy.strokeDecaySeconds
);
const activeGestureFloor =
this.isGestureActive && this.currentGesture && !this.currentGesture.isErasing
? 0.04 + this.getGestureAmount(this.currentGesture, now) * 0.3
: 0;
const target = Math.max(activeGestureFloor, this.targetEnergy);
const timeConstant = target > this.energy ? 0.08 : 0.55;
const target = this.isGestureActive ? this.targetEnergy : 0;
let timeConstant = appConfig.audioEngine.energy.decaySeconds;
if (!this.isGestureActive) {
timeConstant = appConfig.audioEngine.energy.releaseSeconds;
} else if (target > this.energy) {
timeConstant = appConfig.audioEngine.energy.attackSeconds;
}
const amount = 1 - Math.exp(-elapsedSeconds / timeConstant);
this.energy += (target - this.energy) * amount;
}
public getActivityAt(time: number): number {
const activeGesture =
this.isGestureActive && this.currentGesture && !this.currentGesture.isErasing
? 0.08 + this.getGestureAmount(this.currentGesture, time) * 0.34
: 0;
public getActivity(): number {
if (!this.isGestureActive) {
return 0;
}
return clamp01(
this.energy * 0.58 + this.getTailActivityAt(time) * 0.72 + activeGesture
);
return this.getLevel();
}
public getLevel(): number {
return clamp01(this.energy);
}
public reset(): void {
@ -105,72 +77,5 @@ export class GardenAudioEnergy {
this.energy = 0;
this.targetEnergy = 0;
this.lastEnergyUpdateAt = 0;
this.currentGesture = null;
this.gestureTails = [];
}
private addGestureTail(gesture: GardenGestureState, now: number): void {
const durationAmount = clamp01(
(gesture.lastAt - gesture.startedAt) / this.rhythm.tailDurationForMaxSeconds
);
const distanceAmount = clamp01(
gesture.distancePixels / this.rhythm.tailDistanceForMaxPixels
);
const gestureAmount = Math.max(distanceAmount, durationAmount * 0.9);
const tailAmount = clamp01(gestureAmount * (0.74 + gesture.peakEnergy * 0.26));
if (tailAmount <= 0.015) {
return;
}
this.gestureTails.push({
startedAt: now,
durationSeconds:
this.rhythm.minTailSeconds +
(this.rhythm.maxTailSeconds - this.rhythm.minTailSeconds) * tailAmount,
level: (0.05 + tailAmount * 0.5) * (0.72 + gesture.peakEnergy * 0.28),
});
this.trimGestureTails(now);
}
private getGestureAmount(gesture: GardenGestureState, now: number): number {
const durationAmount = clamp01(
(now - gesture.startedAt) / this.rhythm.tailDurationForMaxSeconds
);
const distanceAmount = clamp01(
gesture.distancePixels / this.rhythm.tailDistanceForMaxPixels
);
return clamp01(
distanceAmount * 0.62 + durationAmount * 0.24 + gesture.peakEnergy * 0.14
);
}
private getTailActivityAt(time: number): number {
const decayPower = Math.max(0.2, this.rhythm.tailDecayPower);
return this.gestureTails.reduce((activity, tail) => {
const elapsedSeconds = time - tail.startedAt;
if (elapsedSeconds < 0 || elapsedSeconds >= tail.durationSeconds) {
return activity;
}
const remaining = clamp01(1 - elapsedSeconds / tail.durationSeconds);
return Math.max(activity, tail.level * Math.pow(remaining, decayPower));
}, 0);
}
private trimGestureTails(now: number): void {
this.gestureTails = this.gestureTails.filter(
(tail) => now - tail.startedAt < tail.durationSeconds
);
}
}
const createGesture = (now: number, isErasing: boolean): GardenGestureState => ({
startedAt: now,
lastAt: now,
distancePixels: 0,
peakEnergy: 0,
isErasing,
});

View file

@ -1,3 +1,4 @@
import { appConfig } from '../config';
import { clamp } from '../utils/clamp';
import { GardenAudioConfig, GardenAudioVibeProfile } from './garden-audio-config';
@ -11,6 +12,7 @@ export class GardenAudioGraph {
private delayNode: DelayNode | null = null;
private delayFeedback: GainNode | null = null;
private delayOutput: GainNode | null = null;
private hasUnlocked = false;
public constructor(private readonly config: GardenAudioConfig) {}
@ -50,6 +52,26 @@ export class GardenAudioGraph {
return context;
}
// iOS WebKit (Safari + Chrome iOS) only fully unlocks audio output once
// a buffer source has been started inside a user-gesture handler. Calling
// resume() alone leaves the context "running" but silent.
public unlock(): void {
if (!this.context || this.hasUnlocked) {
return;
}
const buffer = this.context.createBuffer(
1,
appConfig.audioEngine.graph.unlockBufferLength,
appConfig.audioEngine.graph.unlockSampleRate
);
const source = this.context.createBufferSource();
source.buffer = buffer;
source.connect(this.context.destination);
source.start(0);
this.hasUnlocked = true;
}
public setMasterGain(targetGain: number, timeConstantSeconds: number): void {
if (!this.context || !this.masterGain) {
return;
@ -70,7 +92,7 @@ export class GardenAudioGraph {
this.delayNode.delayTime.setTargetAtTime(
this.config.delay.timeSeconds * profile.delayTimeMultiplier,
this.context.currentTime,
0.12
appConfig.audioEngine.graph.delayTimeRampSeconds
);
}
@ -83,17 +105,22 @@ export class GardenAudioGraph {
this.delayNode.delayTime.setTargetAtTime(
this.config.delay.timeSeconds * profile.delayTimeMultiplier,
now,
0.12
appConfig.audioEngine.graph.delayTimeRampSeconds
);
this.delayFeedback.gain.setTargetAtTime(
this.config.delay.enabled
? clamp(this.config.delay.feedback + activity * 0.08, 0.04, 0.32)
: 0,
clamp(
this.config.delay.feedback +
activity * appConfig.audioEngine.graph.delayActivityFeedbackWeight,
appConfig.audioEngine.graph.delayFeedbackMin,
appConfig.audioEngine.graph.delayFeedbackMax
),
now,
this.config.updateRampSeconds
);
this.delayOutput.gain.setTargetAtTime(
this.config.delay.enabled ? this.config.delay.wetGain * (0.65 + activity * 0.5) : 0,
this.config.delay.wetGain *
(appConfig.audioEngine.graph.delayOutputBase +
activity * appConfig.audioEngine.graph.delayOutputActivityWeight),
now,
this.config.updateRampSeconds
);
@ -106,7 +133,11 @@ export class GardenAudioGraph {
}
if (this.masterGain && context.state !== 'closed') {
this.masterGain.gain.setTargetAtTime(0.0001, context.currentTime, 0.015);
this.masterGain.gain.setTargetAtTime(
appConfig.audioEngine.graph.closeGain,
context.currentTime,
appConfig.audioEngine.graph.closeRampSeconds
);
}
this.clearNodes();
@ -123,8 +154,8 @@ export class GardenAudioGraph {
const delayOutput = context.createGain();
delayNode.delayTime.value = this.config.delay.timeSeconds;
delayFeedback.gain.value = this.config.delay.enabled ? this.config.delay.feedback : 0;
delayOutput.gain.value = this.config.delay.enabled ? this.config.delay.wetGain : 0;
delayFeedback.gain.value = this.config.delay.feedback;
delayOutput.gain.value = this.config.delay.wetGain;
delayInput.connect(delayNode);
delayNode.connect(delayFeedback);
@ -140,7 +171,7 @@ export class GardenAudioGraph {
private createBuses(context: AudioContext, masterGain: GainNode): void {
this.eventBus = context.createGain();
this.eventBus.gain.value = 1;
this.eventBus.gain.value = appConfig.audioEngine.graph.eventBusGain;
this.eventBus.connect(masterGain);
}
@ -149,7 +180,10 @@ export class GardenAudioGraph {
const data = buffer.getChannelData(0);
for (let index = 0; index < data.length; index++) {
data[index] = Math.random() * 2 - 1;
data[index] =
appConfig.audioEngine.graph.noiseMin +
Math.random() *
(appConfig.audioEngine.graph.noiseMax - appConfig.audioEngine.graph.noiseMin);
}
return buffer;
@ -164,5 +198,6 @@ export class GardenAudioGraph {
this.delayNode = null;
this.delayFeedback = null;
this.delayOutput = null;
this.hasUnlocked = false;
}
}

View file

@ -1,3 +1,4 @@
import { appConfig } from '../config';
import { clamp01 } from '../utils/clamp';
import { GardenAudioStroke } from './garden-audio-types';
@ -19,8 +20,16 @@ export const getStrokeMetrics = (
const speedPixelsPerSecond = getStrokeVelocity(stroke, distancePixels);
const pressure = getPressureAmount(stroke, fallbackPressure);
const speedAmount = clamp01(speedPixelsPerSecond / speedForFullEnergyPixelsPerSecond);
const strokeEnergy = clamp01(0.18 + speedAmount * 0.62 + pressure * 0.22);
const effectiveEnergy = strokeEnergy * (0.25 + clamp01(distancePixels / 140) * 0.75);
const strokeEnergy = clamp01(
appConfig.audioEngine.input.strokeEnergyBase +
speedAmount * appConfig.audioEngine.input.strokeEnergySpeedWeight +
pressure * appConfig.audioEngine.input.strokeEnergyPressureWeight
);
const effectiveEnergy =
strokeEnergy *
(appConfig.audioEngine.input.distanceEnergyBase +
clamp01(distancePixels / appConfig.audioEngine.input.distanceForFullEnergyPixels) *
appConfig.audioEngine.input.distanceEnergyScale);
return {
distancePixels,
@ -39,7 +48,7 @@ const getStrokeVelocity = (stroke: GardenAudioStroke, distancePixels: number): n
return stroke.velocityPixelsPerSecond;
}
return distancePixels / (1 / 60);
return distancePixels / appConfig.audioEngine.input.fallbackFrameSeconds;
};
const getPressureAmount = (
@ -55,6 +64,6 @@ const getPressureAmount = (
}
return stroke.pointerType === 'pen'
? Math.max(0.56, clamp01(fallbackPressure))
? Math.max(appConfig.audioEngine.input.penMinPressure, clamp01(fallbackPressure))
: clamp01(fallbackPressure);
};

View file

@ -1,282 +0,0 @@
import { clamp, clamp01 } from '../utils/clamp';
import { VibePreset } from '../vibes';
import {
GardenAudioChord,
GardenAudioConfig,
GardenAudioVibeProfile,
} from './garden-audio-config';
import {
clampMidi,
degreeToSemitone,
getChordAtStep,
getChordIntervals,
getVibeProfile,
} from './garden-audio-music';
import {
GardenAudioColorIndex,
GardenAudioStroke,
PianoNote,
} from './garden-audio-types';
interface RhythmStepRequest {
vibe: VibePreset;
stepIndex: number;
startTime: number;
activity: number;
selectedColorIndex: GardenAudioColorIndex;
}
interface StrokeTapRequest {
stroke: GardenAudioStroke;
energy: number;
now: number;
selectedColorIndex: GardenAudioColorIndex;
stepIndex: number;
rhythmAnchorTime: number;
stepDurationSeconds: number;
}
export class GardenAudioScore {
public constructor(
private readonly config: GardenAudioConfig,
private readonly playNote: (note: PianoNote) => void
) {}
public playRhythmStep({
vibe,
stepIndex,
startTime,
activity,
selectedColorIndex,
}: RhythmStepRequest): void {
const profile = getVibeProfile(this.config, vibe);
const stepInBar = stepIndex % this.config.rhythm.stepsPerBar;
if (activity < this.config.rhythm.sparseActivity) {
return;
}
const chord = getChordAtStep(this.config, profile, stepIndex);
if (stepInBar === 0 && activity < this.config.rhythm.bassActivity) {
this.playRootAnchor(profile, chord, startTime, activity);
}
if (
this.config.rhythm.bassSteps.includes(stepInBar) &&
activity >= this.config.rhythm.bassActivity
) {
this.playBassNote(profile, chord, startTime, activity);
}
if (
this.config.rhythm.chordSteps.includes(stepInBar) &&
activity >= this.config.rhythm.arpeggioActivity
) {
this.playBrokenChord(profile, chord, startTime, activity, stepInBar === 0);
}
if (this.shouldPlayMelodyStep(stepInBar, activity)) {
this.playMelodyNote(
profile,
chord,
stepIndex,
startTime,
activity,
selectedColorIndex
);
}
}
public playStrokeTap({
stroke,
energy,
now,
selectedColorIndex,
stepIndex,
rhythmAnchorTime,
stepDurationSeconds,
}: StrokeTapRequest): void {
const profile = getVibeProfile(this.config, stroke.vibe);
const colorVoice = this.config.colorVoices[selectedColorIndex];
const chord = getChordAtStep(this.config, profile, stepIndex);
const intervals = getChordIntervals(chord, false);
const x = clamp01(stroke.to[0] / Math.max(1, stroke.canvasSize[0]));
const y = 1 - clamp01(stroke.to[1] / Math.max(1, stroke.canvasSize[1]));
const rawRegister = y < 0.35 ? 0 : y > 0.72 ? 2 : 1;
const register =
energy < this.config.rhythm.arpeggioActivity
? Math.min(rawRegister, 1)
: rawRegister;
const interval =
intervals[
(register + colorVoice.scaleDegreeOffset + selectedColorIndex) % intervals.length
];
this.playNote({
midi: clampMidi(
profile.rootMidi +
chord.rootOffset +
interval +
(energy < this.config.rhythm.arpeggioActivity ? 5 : 12) +
register * 5 +
(energy >= this.config.rhythm.fullChordActivity
? colorVoice.octaveOffset * 7
: 0),
50,
energy < this.config.rhythm.arpeggioActivity ? 76 : 88
),
velocity: (0.26 + energy * 0.34) * colorVoice.velocityMultiplier,
durationSeconds: 1.05 + energy * 0.45,
pan: clamp(x * 2 - 1 + colorVoice.panOffset * 0.6, -0.85, 0.85),
delaySend: 0.014,
lowpassHz: this.config.piano.lowpassHz * profile.brightness,
startTime: this.getNearGridTime(now, rhythmAnchorTime, stepDurationSeconds),
});
}
public playVibeChangeStinger(vibe: VibePreset, now: number): void {
const profile = getVibeProfile(this.config, vibe);
const chord = profile.progression[0];
const intervals = getChordIntervals(chord, true);
intervals.forEach((interval, index) => {
this.playNote({
midi: clampMidi(profile.rootMidi + chord.rootOffset + interval, 48, 84),
velocity: 0.3 * Math.pow(0.9, index),
durationSeconds: 1.4,
pan: clamp(-0.22 + index * 0.14, -0.5, 0.5),
delaySend: 0.016,
lowpassHz: this.config.piano.lowpassHz * profile.brightness,
startTime: now + index * 0.045,
});
});
}
private shouldPlayMelodyStep(stepInBar: number, activity: number): boolean {
if (activity < this.config.rhythm.sparseActivity) {
return false;
}
if (activity < this.config.rhythm.arpeggioActivity) {
return stepInBar === 10;
}
if (activity < this.config.rhythm.fullChordActivity) {
return stepInBar === 6 || stepInBar === 10;
}
return this.config.rhythm.melodySteps.includes(stepInBar);
}
private playRootAnchor(
profile: GardenAudioVibeProfile,
chord: GardenAudioChord,
startTime: number,
activity: number
): void {
this.playNote({
midi: clampMidi(profile.rootMidi + chord.rootOffset, 43, 64),
velocity: 0.13 + activity * 0.14,
durationSeconds: 1.35,
pan: -0.06,
delaySend: 0.004,
lowpassHz: this.config.piano.lowpassHz * profile.brightness * 0.78,
startTime,
});
}
private playBassNote(
profile: GardenAudioVibeProfile,
chord: GardenAudioChord,
startTime: number,
activity: number
): void {
this.playNote({
midi: clampMidi(profile.rootMidi + chord.rootOffset - 12, 36, 58),
velocity: 0.24 + activity * 0.18,
durationSeconds: 1.8,
pan: -0.08,
delaySend: 0.006,
startTime,
});
}
private playBrokenChord(
profile: GardenAudioVibeProfile,
chord: GardenAudioChord,
startTime: number,
activity: number,
isAccent: boolean
): void {
const intervals = getChordIntervals(chord, true);
const velocity = (isAccent ? 0.22 : 0.16) + activity * 0.16;
const baseMidi = profile.rootMidi + chord.rootOffset;
const isFullChord = activity >= this.config.rhythm.fullChordActivity;
const noteCount = isFullChord ? intervals.length : activity >= 0.46 ? 3 : 2;
const staggerSeconds = isFullChord ? 0.018 : 0.056;
intervals.slice(0, noteCount).forEach((interval, index) => {
this.playNote({
midi: clampMidi(baseMidi + interval, 48, 78),
velocity: velocity * Math.pow(0.9, index),
durationSeconds: isFullChord ? (isAccent ? 1.9 : 1.35) : 1.08,
pan: clamp(-0.16 + index * 0.1, -0.45, 0.45),
delaySend: 0.012,
lowpassHz: this.config.piano.lowpassHz * profile.brightness,
startTime: startTime + index * staggerSeconds,
});
});
}
private playMelodyNote(
profile: GardenAudioVibeProfile,
chord: GardenAudioChord,
stepIndex: number,
startTime: number,
activity: number,
selectedColorIndex: GardenAudioColorIndex
): void {
const colorVoice = this.config.colorVoices[selectedColorIndex];
const patternIndex =
Math.floor(stepIndex / 2) + colorVoice.scaleDegreeOffset + selectedColorIndex;
const scaleDegree =
this.config.rhythm.melodyPattern[
patternIndex % this.config.rhythm.melodyPattern.length
] + colorVoice.scaleDegreeOffset;
const stepInBeat = stepIndex % this.config.rhythm.stepsPerBeat;
const semitoneOffset =
stepInBeat === 0
? chord.rootOffset +
getChordIntervals(chord, false)[(patternIndex + selectedColorIndex) % 3]
: degreeToSemitone(profile, scaleDegree);
const registerLift = activity < this.config.rhythm.arpeggioActivity ? 0 : 12;
const colorOctaveLift =
activity >= this.config.rhythm.fullChordActivity ? colorVoice.octaveOffset * 12 : 0;
this.playNote({
midi: clampMidi(
profile.rootMidi + semitoneOffset + registerLift + colorOctaveLift,
activity < this.config.rhythm.arpeggioActivity ? 50 : 57,
activity < this.config.rhythm.arpeggioActivity ? 76 : 91
),
velocity:
(0.2 + activity * 0.32) *
colorVoice.velocityMultiplier *
(stepInBeat === 0 ? 1.08 : 1),
durationSeconds: 0.92 + activity * 0.38,
pan: colorVoice.panOffset,
delaySend: 0.018,
lowpassHz: this.config.piano.lowpassHz * profile.brightness,
startTime,
});
}
private getNearGridTime(
now: number,
rhythmAnchorTime: number,
stepDurationSeconds: number
): number {
const stepCount = Math.ceil((now - rhythmAnchorTime) / stepDurationSeconds);
const gridTime = rhythmAnchorTime + Math.max(0, stepCount) * stepDurationSeconds;
return gridTime - now <= 0.1 ? gridTime : now + 0.012;
}
}

View file

@ -4,16 +4,8 @@ export type GardenAudioColorIndex = 0 | 1 | 2;
export interface GardenAudioSnapshot {
vibe: VibePreset;
activeAgentCount: number;
agentBudgetMax: number;
selectedColorIndex: number;
isErasing: boolean;
introProgress: number;
moveSpeed: number;
diffusionRateTrails: number;
decayRateTrails: number;
brushEffectDuration: number;
clarity: number;
}
export interface GardenAudioStroke {
@ -41,6 +33,7 @@ export interface LoadedPianoSample {
export interface ActivePianoVoice {
gain: GainNode;
source: AudioBufferSourceNode;
startAt: number;
stopAt: number;
}

View file

@ -82,6 +82,18 @@ class FakeAudioContext {
return new FakeAudioBuffer(length) as unknown as AudioBuffer;
}
public createBufferSource(): AudioBufferSourceNode {
const node = new FakeAudioNode() as unknown as AudioBufferSourceNode & {
buffer: AudioBuffer | null;
start: () => void;
stop: () => void;
};
node.buffer = null;
node.start = vi.fn();
node.stop = vi.fn();
return node;
}
public async resume(): Promise<void> {
calls.resumed += 1;
contextState = 'running';
@ -90,10 +102,6 @@ class FakeAudioContext {
const makeConfig = (): GardenAudioConfig => ({
...gardenAudioConfig,
piano: {
...gardenAudioConfig.piano,
preloadOnStart: false,
},
});
describe('GardenAudio startup policy', () => {
@ -102,6 +110,7 @@ describe('GardenAudio startup policy', () => {
calls.resumed = 0;
contextState = 'suspended';
vi.stubGlobal('AudioContext', FakeAudioContext);
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('not loaded in tests')));
});
afterEach(() => {

View file

@ -1,3 +1,4 @@
import { appConfig } from '../config';
import { clamp, clamp01 } from '../utils/clamp';
import { VibePreset } from '../vibes';
import { GardenAudioConfig } from './garden-audio-config';
@ -5,13 +6,13 @@ import { GardenAudioEnergy } from './garden-audio-energy';
import { GardenAudioGraph } from './garden-audio-graph';
import { getStrokeMetrics } from './garden-audio-input';
import { getVibeProfile, normalizeColorIndex } from './garden-audio-music';
import { GardenAudioScore } from './garden-audio-score';
import type {
GardenAudioColorIndex,
GardenAudioSnapshot,
GardenAudioStartOptions,
GardenAudioStroke,
} from './garden-audio-types';
import { GenerativePianoEngine } from './generative-piano';
import { NoiseBurstPlayer } from './noise-burst-player';
import { PianoSampler } from './piano-sampler';
@ -26,18 +27,14 @@ export class GardenAudio {
private readonly piano: PianoSampler;
private readonly noise: NoiseBurstPlayer;
private readonly energy: GardenAudioEnergy;
private readonly score: GardenAudioScore;
private readonly pianoEngine: GenerativePianoEngine;
private currentVibeId: string | null = null;
private hasStarted = false;
private isDestroyed = false;
private isMuted = false;
private isGestureActive = false;
private selectedColorIndex: GardenAudioColorIndex = 0;
private rhythmAnchorTime: number | null = null;
private startedAt: number | null = null;
private nextStepAt = 0;
private stepIndex = 0;
private lastTapAt = Number.NEGATIVE_INFINITY;
private lastEraserAt = Number.NEGATIVE_INFINITY;
private lastVibeStingerAt = Number.NEGATIVE_INFINITY;
@ -45,12 +42,12 @@ export class GardenAudio {
this.graph = new GardenAudioGraph(config);
this.piano = new PianoSampler(config, this.graph);
this.noise = new NoiseBurstPlayer(this.graph);
this.energy = new GardenAudioEnergy(config.rhythm);
this.score = new GardenAudioScore(config, (note) => this.piano.play(note));
this.energy = new GardenAudioEnergy();
this.pianoEngine = new GenerativePianoEngine(config, (note) => this.piano.play(note));
}
public start(vibe: VibePreset, options: GardenAudioStartOptions = {}): void {
if (!this.config.enabled || this.isDestroyed || this.isMuted) {
if (this.isDestroyed || this.isMuted) {
return;
}
@ -59,6 +56,10 @@ export class GardenAudio {
return;
}
if (options.userGesture === true) {
this.graph.unlock();
}
if (context.state === 'suspended') {
if (options.userGesture !== true) {
return;
@ -67,17 +68,11 @@ export class GardenAudio {
}
this.hasStarted = true;
this.rhythmAnchorTime ??= context.currentTime;
this.startedAt ??= context.currentTime;
this.applyVibe(vibe);
if (this.nextStepAt <= 0) {
this.nextStepAt = context.currentTime + 0.02;
}
this.pianoEngine.prime(context.currentTime);
this.graph.setMasterGain(this.config.masterVolume, this.config.fadeInSeconds);
if (this.config.piano.preloadOnStart) {
void this.piano.load(context);
}
void this.piano.load(context);
}
public changeVibe(vibe: VibePreset, options: GardenAudioStartOptions = {}): void {
@ -100,8 +95,8 @@ export class GardenAudio {
public setMuted(isMuted: boolean): void {
this.isMuted = isMuted;
this.graph.setMasterGain(
isMuted ? 0.0001 : this.config.masterVolume,
isMuted ? 0.02 : this.config.fadeInSeconds
isMuted ? appConfig.audioEngine.muteGain : this.config.masterVolume,
isMuted ? appConfig.audioEngine.muteRampSeconds : this.config.fadeInSeconds
);
}
@ -111,20 +106,21 @@ export class GardenAudio {
return;
}
this.isGestureActive = true;
this.energy.beginGesture(context.currentTime);
this.pianoEngine.beginGesture(context.currentTime);
}
public endGesture(): void {
const context = this.graph.context;
this.isGestureActive = false;
this.energy.endGesture();
this.pianoEngine.endGesture();
if (!context) {
return;
}
this.energy.endGesture(context.currentTime);
}
public rememberColor(colorIndex: number): void {
this.selectedColorIndex = normalizeColorIndex(colorIndex);
this.piano.fadeActive(context.currentTime, appConfig.audioEngine.gestureFadeSeconds);
}
public update(snapshot: GardenAudioSnapshot): void {
@ -139,17 +135,27 @@ export class GardenAudio {
if (snapshot.isErasing) {
this.energy.silence();
this.piano.fadeActive(context.currentTime);
this.piano.fadeActive(
context.currentTime,
appConfig.audioEngine.gestureFadeSeconds
);
this.updateDelay(snapshot);
return;
}
this.scheduleRhythm(snapshot.vibe);
if (this.isGestureActive) {
this.pianoEngine.renderLookahead({
vibe: snapshot.vibe,
now: context.currentTime,
activity: this.energy.getActivity(),
selectedColorIndex: this.selectedColorIndex,
});
}
this.updateDelay(snapshot);
}
public stroke(stroke: GardenAudioStroke): void {
if (!this.config.enabled || this.isDestroyed || this.isMuted) {
if (this.isDestroyed || this.isMuted) {
return;
}
@ -158,6 +164,9 @@ export class GardenAudio {
if (!context) {
return;
}
if (!this.isGestureActive) {
return;
}
const metrics = getStrokeMetrics(
stroke,
@ -169,32 +178,14 @@ export class GardenAudio {
this.selectedColorIndex = normalizeColorIndex(stroke.colorIndex);
if (stroke.isErasing) {
this.energy.recordEraserStroke(now);
this.energy.recordEraserStroke();
this.playEraser(stroke, metrics.speedAmount, metrics.pressure, now);
return;
}
const strokeEnergy = metrics.effectiveEnergy * this.getStartupEnergyScale(now);
this.energy.recordStroke(metrics.distancePixels, strokeEnergy, now);
if (metrics.distancePixels >= 2.5) {
this.energy.raiseTarget(strokeEnergy);
}
if (
metrics.distancePixels >= 2.5 &&
now - this.lastTapAt >= this.getTapIntervalSeconds(now)
) {
this.lastTapAt = now;
this.score.playStrokeTap({
stroke,
energy: strokeEnergy,
now,
selectedColorIndex: this.selectedColorIndex,
stepIndex: this.stepIndex,
rhythmAnchorTime: this.getRhythmAnchorTime(now),
stepDurationSeconds: this.getStepDurationSeconds(now),
});
}
const strokeEnergy = metrics.effectiveEnergy;
this.energy.recordStroke(strokeEnergy, now);
this.pianoEngine.wake(now);
}
public async destroy(): Promise<void> {
@ -203,50 +194,15 @@ export class GardenAudio {
this.piano.reset();
this.energy.reset();
this.pianoEngine.reset();
this.currentVibeId = null;
this.hasStarted = false;
this.isGestureActive = false;
this.selectedColorIndex = 0;
this.rhythmAnchorTime = null;
this.startedAt = null;
this.nextStepAt = 0;
this.stepIndex = 0;
this.lastTapAt = Number.NEGATIVE_INFINITY;
this.lastEraserAt = Number.NEGATIVE_INFINITY;
this.lastVibeStingerAt = Number.NEGATIVE_INFINITY;
}
private scheduleRhythm(vibe: VibePreset): void {
const context = this.graph.context;
if (!context || !this.graph.eventBus) {
return;
}
const now = context.currentTime;
const stepSeconds = this.getStepDurationSeconds(now);
if (this.nextStepAt <= 0 || this.nextStepAt < now - stepSeconds) {
this.nextStepAt = now + 0.02;
}
const lookaheadEnd = now + this.config.rhythm.lookaheadSeconds;
while (this.nextStepAt <= lookaheadEnd) {
const swingOffset =
this.stepIndex % 2 === 1 ? stepSeconds * this.config.rhythm.swing : 0;
const startTime = this.nextStepAt + swingOffset;
this.score.playRhythmStep({
vibe,
stepIndex: this.stepIndex,
startTime,
activity: this.getSettledActivity(
this.energy.getActivityAt(startTime),
startTime
),
selectedColorIndex: this.selectedColorIndex,
});
this.nextStepAt += stepSeconds;
this.stepIndex += 1;
}
}
private playVibeChangeStinger(vibe: VibePreset): void {
const context = this.graph.context;
if (!context) {
@ -254,12 +210,15 @@ export class GardenAudio {
}
const now = context.currentTime;
if (now - this.lastVibeStingerAt < 0.45) {
if (
now - this.lastVibeStingerAt <
appConfig.audioEngine.vibeChangeStingerMinIntervalSeconds
) {
return;
}
this.lastVibeStingerAt = now;
this.score.playVibeChangeStinger(vibe, now);
this.pianoEngine.playVibeChangeStinger(vibe, now);
}
private playEraser(
@ -268,27 +227,38 @@ export class GardenAudio {
pressure: number,
now: number
): void {
if (!this.config.eraser.enabled || !this.graph.context) {
if (!this.graph.context) {
return;
}
const sizeAmount = clamp01(
(stroke.eraserSizePixels ?? 96) / Math.max(1, stroke.canvasSize[0] * 0.18)
(stroke.eraserSizePixels ?? appConfig.audioEngine.eraser.defaultSizePixels) /
Math.max(
1,
stroke.canvasSize[0] * appConfig.audioEngine.eraser.canvasWidthRatioForFullSize
)
);
const x = clamp01(stroke.to[0] / Math.max(1, stroke.canvasSize[0]));
const filterHz =
this.config.eraser.filterMinHz +
(this.config.eraser.filterMaxHz - this.config.eraser.filterMinHz) *
clamp01(speedAmount * 0.58 + pressure * 0.26 + sizeAmount * 0.16);
clamp01(
speedAmount * appConfig.audioEngine.eraser.filterSpeedWeight +
pressure * appConfig.audioEngine.eraser.filterPressureWeight +
sizeAmount * appConfig.audioEngine.eraser.filterSizeWeight
);
if (now - this.lastEraserAt >= this.config.eraser.minIntervalSeconds) {
this.lastEraserAt = now;
this.noise.play({
startTime: now,
durationSeconds: 0.08,
durationSeconds: appConfig.audioEngine.eraser.durationSeconds,
gain:
this.config.eraser.noiseGain *
(0.45 + speedAmount * 0.38 + pressure * 0.24 + sizeAmount * 0.18),
(appConfig.audioEngine.eraser.gainBase +
speedAmount * appConfig.audioEngine.eraser.gainSpeedWeight +
pressure * appConfig.audioEngine.eraser.gainPressureWeight +
sizeAmount * appConfig.audioEngine.eraser.gainSizeWeight),
filterHz,
pan: clamp(x * 2 - 1, -1, 1),
});
@ -303,11 +273,8 @@ export class GardenAudio {
const profile = getVibeProfile(this.config, snapshot.vibe);
const activity = snapshot.isErasing
? 0.12
: this.getSettledActivity(
this.energy.getActivityAt(context.currentTime),
context.currentTime
);
? appConfig.audioEngine.delay.erasingActivity
: this.energy.getLevel();
this.graph.updateDelay(profile, activity);
}
@ -319,68 +286,4 @@ export class GardenAudio {
this.currentVibeId = vibe.id;
this.graph.applyDelayProfile(getVibeProfile(this.config, vibe));
}
private getStepDurationSeconds(time: number): number {
const baseStepSeconds = 60 / this.config.rhythm.bpm / this.config.rhythm.stepsPerBeat;
return baseStepSeconds * this.getStartupTempoMultiplier(time);
}
private getRhythmAnchorTime(now: number): number {
this.rhythmAnchorTime ??= now;
return this.rhythmAnchorTime;
}
private getTapIntervalSeconds(time: number): number {
return (
this.config.rhythm.minTapIntervalSeconds *
this.getStartupTapIntervalMultiplier(time)
);
}
private getSettledActivity(activity: number, time: number): number {
return Math.min(activity, this.getStartupActivityCeiling(time));
}
private getStartupEnergyScale(time: number): number {
return this.interpolateStartupValue(
this.config.startup.initialEnergyMultiplier,
1,
time
);
}
private getStartupActivityCeiling(time: number): number {
return this.interpolateStartupValue(
this.config.startup.initialActivityCeiling,
1,
time
);
}
private getStartupTempoMultiplier(time: number): number {
return this.interpolateStartupValue(
this.config.startup.initialTempoMultiplier,
1,
time
);
}
private getStartupTapIntervalMultiplier(time: number): number {
return this.interpolateStartupValue(
this.config.startup.initialTapIntervalMultiplier,
1,
time
);
}
private interpolateStartupValue(from: number, to: number, time: number): number {
const durationSeconds = this.config.startup.calmDurationSeconds;
if (this.startedAt === null || durationSeconds <= 0) {
return to;
}
const progress = clamp01((time - this.startedAt) / durationSeconds);
const easedProgress = progress * progress * (3 - 2 * progress);
return from + (to - from) * easedProgress;
}
}

View file

@ -0,0 +1,118 @@
import { describe, expect, it } from 'vitest';
import { VIBE_PRESETS } from '../vibes';
import { gardenAudioConfig } from './garden-audio-config';
import { PianoNote } from './garden-audio-types';
import { GenerativePianoEngine } from './generative-piano';
const makeEngine = () => {
const notes: Array<PianoNote> = [];
const engine = new GenerativePianoEngine(gardenAudioConfig, (note) => {
notes.push(note);
});
return { engine, notes };
};
const renderBars = (
engine: GenerativePianoEngine,
activity: number,
selectedColorIndex = 0,
bars = 4
) => {
engine.renderLookahead({
vibe: VIBE_PRESETS[0],
now: 0,
activity,
selectedColorIndex: selectedColorIndex as 0 | 1 | 2,
lookaheadSeconds:
(60 / gardenAudioConfig.rhythm.bpm) *
Math.round(
gardenAudioConfig.rhythm.stepsPerBar / gardenAudioConfig.rhythm.stepsPerBeat
) *
bars,
});
};
const average = (values: Array<number>): number =>
values.reduce((sum, value) => sum + value, 0) / values.length;
describe('GenerativePianoEngine', () => {
it('does not emit notes below the sparse activity threshold', () => {
const { engine, notes } = makeEngine();
renderBars(engine, gardenAudioConfig.rhythm.sparseActivity - 0.01);
expect(notes).toHaveLength(0);
});
it('keeps drawing notes on beat starts', () => {
const { engine, notes } = makeEngine();
const beatSeconds = 60 / gardenAudioConfig.rhythm.bpm;
const startDelaySeconds = 0.02;
renderBars(engine, 1, 1);
expect(notes.length).toBeGreaterThan(0);
notes.forEach((note) => {
const beatsFromStart = (note.startTime - startDelaySeconds) / beatSeconds;
expect(Math.abs(beatsFromStart - Math.round(beatsFromStart))).toBeLessThan(0.001);
});
});
it('varies density with activity without exceeding one note per beat', () => {
const low = makeEngine();
const high = makeEngine();
renderBars(low.engine, gardenAudioConfig.rhythm.sparseActivity + 0.03, 1);
renderBars(high.engine, 1, 1);
expect(high.notes.length).toBeGreaterThan(low.notes.length);
expect(high.notes.length).toBeLessThanOrEqual(16);
});
it('wakes every color with a prompt first note at low activity', () => {
([0, 1, 2] as const).forEach((selectedColorIndex) => {
const { engine, notes } = makeEngine();
const now = 4;
engine.beginGesture(1);
engine.wake(now);
engine.renderLookahead({
vibe: VIBE_PRESETS[0],
now,
activity: gardenAudioConfig.rhythm.sparseActivity + 0.01,
selectedColorIndex,
lookaheadSeconds: 0.08,
});
expect(notes).toHaveLength(1);
expect(notes[0].startTime).toBeCloseTo(now + 0.02);
});
});
it('uses different color roles for register and pan', () => {
const anchor = makeEngine();
const spark = makeEngine();
renderBars(anchor.engine, 1, 0);
renderBars(spark.engine, 1, 2);
expect(average(spark.notes.map((note) => note.midi))).toBeGreaterThan(
average(anchor.notes.map((note) => note.midi))
);
expect(average(spark.notes.map((note) => note.pan))).toBeGreaterThan(
average(anchor.notes.map((note) => note.pan))
);
});
it('is deterministic for the same phrase inputs', () => {
const first = makeEngine();
const second = makeEngine();
renderBars(first.engine, 0.78, 2);
renderBars(second.engine, 0.78, 2);
expect(second.notes).toEqual(first.notes);
});
});

View file

@ -0,0 +1,674 @@
import { appConfig } from '../config';
import { clamp, clamp01 } from '../utils/clamp';
import { VibePreset } from '../vibes';
import {
GardenAudioChord,
GardenAudioConfig,
GardenAudioVibeProfile,
} from './garden-audio-config';
import {
clampMidi,
degreeToSemitone,
getChordAtStep,
getChordIntervals,
getVibeProfile,
} from './garden-audio-music';
import { GardenAudioColorIndex, PianoNote } from './garden-audio-types';
interface RenderLookaheadRequest {
vibe: VibePreset;
now: number;
activity: number;
selectedColorIndex: GardenAudioColorIndex;
lookaheadSeconds?: number;
}
interface PianoRole {
name: string;
midiMin: number;
midiMax: number;
preferredMidi: number;
pan: number;
delaySend: number;
velocityBase: number;
velocityActivityScale: number;
durationBase: number;
durationActivityScale: number;
downbeatDurationBoost: number;
lowBeats: ReadonlyArray<number>;
mediumBeats: ReadonlyArray<number>;
highBeats: ReadonlyArray<number>;
lowPhraseOffset: number;
lowPhraseSpacing: number;
lowKeepChance: number;
mediumKeepChance: number;
highKeepChance: number;
}
interface PitchCandidate {
midi: number;
preference: number;
}
const COLOR_ROLES: [PianoRole, PianoRole, PianoRole] = [
{
name: 'anchor',
midiMin: 45,
midiMax: 64,
preferredMidi: 53,
pan: -0.28,
delaySend: 0.012,
velocityBase: 0.12,
velocityActivityScale: 0.15,
durationBase: 1.2,
durationActivityScale: 0.75,
downbeatDurationBoost: 0.32,
lowBeats: [0, 2],
mediumBeats: [0, 2],
highBeats: [0, 2],
lowPhraseOffset: 0,
lowPhraseSpacing: 4,
lowKeepChance: 1,
mediumKeepChance: 0.92,
highKeepChance: 1,
},
{
name: 'body',
midiMin: 55,
midiMax: 74,
preferredMidi: 64,
pan: 0,
delaySend: 0.009,
velocityBase: 0.115,
velocityActivityScale: 0.18,
durationBase: 0.86,
durationActivityScale: 0.54,
downbeatDurationBoost: 0.18,
lowBeats: [0, 2],
mediumBeats: [0, 2],
highBeats: [0, 1, 2, 3],
lowPhraseOffset: 0,
lowPhraseSpacing: 4,
lowKeepChance: 0.86,
mediumKeepChance: 0.86,
highKeepChance: 0.9,
},
{
name: 'spark',
midiMin: 67,
midiMax: 84,
preferredMidi: 76,
pan: 0.28,
delaySend: 0.018,
velocityBase: 0.09,
velocityActivityScale: 0.14,
durationBase: 0.48,
durationActivityScale: 0.32,
downbeatDurationBoost: 0,
lowBeats: [0, 2],
mediumBeats: [1, 3],
highBeats: [0, 1, 2, 3],
lowPhraseOffset: 0,
lowPhraseSpacing: 4,
lowKeepChance: 0.76,
mediumKeepChance: 0.8,
highKeepChance: 0.78,
},
];
const PHRASE_BAR_COUNT = 4;
const PACE_MIN = 0.96;
const PACE_MAX = 1.08;
const PACE_RAMP_SECONDS = 2.8;
const FIRST_NOTE_MAX_WAIT_SECONDS = 0.09;
const NOTE_SCORE_PREFERENCE_WEIGHT = 1.8;
const NOTE_SCORE_REGISTER_WEIGHT = 0.28;
const STINGER_SPACING_SECONDS = 0.07;
const STINGER_DURATION_SECONDS = 1.45;
const PHRASE_CONTOURS: ReadonlyArray<ReadonlyArray<number>> = [
[0, 0, 1, 0, -1, 0, 1, 0],
[0, 1, 2, 1, 0, -1, 0, 1],
[1, 0, -1, 0, 1, 2, 1, 0],
[0, -1, 0, 1, 0, 1, 2, 1],
];
export class GenerativePianoEngine {
private nextStepAt: number | null = null;
private stepIndex = 0;
private pace = 1;
private lastPaceUpdateAt: number | null = null;
private isWaitingForFirstStrokeNote = false;
private readonly lastMidiByColor: [number | null, number | null, number | null] = [
null,
null,
null,
];
public constructor(
private readonly config: GardenAudioConfig,
private readonly playNote: (note: PianoNote) => void
) {}
public prime(now: number): void {
if (this.nextStepAt === null) {
this.nextStepAt = now + appConfig.audioEngine.startDelaySeconds;
}
this.lastPaceUpdateAt ??= now;
}
public beginGesture(now: number): void {
this.isWaitingForFirstStrokeNote = true;
this.wake(now);
}
public endGesture(): void {
this.isWaitingForFirstStrokeNote = false;
}
public wake(now: number): void {
this.lastPaceUpdateAt ??= now;
if (
!this.isWaitingForFirstStrokeNote &&
this.nextStepAt !== null &&
this.nextStepAt - now <= FIRST_NOTE_MAX_WAIT_SECONDS
) {
return;
}
this.nextStepAt = now + appConfig.audioEngine.startDelaySeconds;
this.stepIndex = this.getNextDownbeatStepIndex();
}
public renderLookahead({
vibe,
now,
activity,
selectedColorIndex,
lookaheadSeconds = this.config.rhythm.lookaheadSeconds,
}: RenderLookaheadRequest): void {
this.prime(now);
const normalizedActivity = clamp01(activity);
this.updatePace(now, normalizedActivity);
const stepSeconds = this.getStepDurationSeconds();
this.skipLateSteps(now, stepSeconds);
if (this.nextStepAt === null) {
return;
}
const profile = getVibeProfile(this.config, vibe);
const lookaheadEnd = now + lookaheadSeconds;
while (this.nextStepAt <= lookaheadEnd) {
this.renderStep({
vibe,
profile,
stepIndex: this.stepIndex,
startTime: this.nextStepAt,
activity: normalizedActivity,
selectedColorIndex,
});
this.nextStepAt += stepSeconds;
this.stepIndex += 1;
}
}
public playVibeChangeStinger(vibe: VibePreset, now: number): void {
const profile = getVibeProfile(this.config, vibe);
const chord = profile.progression[0];
const intervals = getChordIntervals(chord, true);
const rootMidi = profile.rootMidi + chord.rootOffset;
const notes = [
{
midi: clampMidi(rootMidi + intervals[0], 48, 60),
velocity: 0.16,
pan: -0.24,
delaySend: 0.014,
},
{
midi: clampMidi(rootMidi + intervals[2], 60, 72),
velocity: 0.13,
pan: 0.02,
delaySend: 0.016,
},
{
midi: clampMidi(rootMidi + intervals[3], 67, 84),
velocity: 0.105,
pan: 0.26,
delaySend: 0.02,
},
];
notes.forEach((note, index) => {
this.playNote({
...note,
durationSeconds: STINGER_DURATION_SECONDS,
lowpassHz: this.getLowpassHz(profile, note.midi, 0.28),
startTime: now + index * STINGER_SPACING_SECONDS,
});
});
}
public reset(): void {
this.nextStepAt = null;
this.stepIndex = 0;
this.pace = 1;
this.lastPaceUpdateAt = null;
this.isWaitingForFirstStrokeNote = false;
this.lastMidiByColor[0] = null;
this.lastMidiByColor[1] = null;
this.lastMidiByColor[2] = null;
}
private renderStep({
vibe,
profile,
stepIndex,
startTime,
activity,
selectedColorIndex,
}: {
vibe: VibePreset;
profile: GardenAudioVibeProfile;
stepIndex: number;
startTime: number;
activity: number;
selectedColorIndex: GardenAudioColorIndex;
}): void {
if (
activity < this.config.rhythm.sparseActivity ||
stepIndex % this.config.rhythm.stepsPerBeat !== 0
) {
return;
}
const role = COLOR_ROLES[selectedColorIndex];
const stepInBar = stepIndex % this.config.rhythm.stepsPerBar;
const beatsPerBar = this.getBeatsPerBar();
const beatInBar =
Math.floor(stepInBar / this.config.rhythm.stepsPerBeat) % beatsPerBar;
const barIndex = Math.floor(stepIndex / this.config.rhythm.stepsPerBar);
const phraseIndex = Math.floor(barIndex / PHRASE_BAR_COUNT);
const barInPhrase = barIndex % PHRASE_BAR_COUNT;
if (
!this.shouldPlayBeat({
vibeId: vibe.id,
role,
activity,
beatInBar,
beatsPerBar,
barInPhrase,
phraseIndex,
})
) {
return;
}
const note = this.createNote({
vibeId: vibe.id,
profile,
role,
stepIndex,
startTime,
activity,
selectedColorIndex,
beatInBar,
beatsPerBar,
barInPhrase,
phraseIndex,
});
this.lastMidiByColor[selectedColorIndex] = note.midi;
this.isWaitingForFirstStrokeNote = false;
this.playNote(note);
}
private createNote({
vibeId,
profile,
role,
stepIndex,
startTime,
activity,
selectedColorIndex,
beatInBar,
beatsPerBar,
barInPhrase,
phraseIndex,
}: {
vibeId: string;
profile: GardenAudioVibeProfile;
role: PianoRole;
stepIndex: number;
startTime: number;
activity: number;
selectedColorIndex: GardenAudioColorIndex;
beatInBar: number;
beatsPerBar: number;
barInPhrase: number;
phraseIndex: number;
}): PianoNote {
const colorVoice = this.config.colorVoices[selectedColorIndex];
const expression = this.getExpression(activity);
const midi = this.chooseMidi({
vibeId,
profile,
role,
stepIndex,
selectedColorIndex,
beatInBar,
beatsPerBar,
barInPhrase,
phraseIndex,
});
const beatAccent = beatInBar === 0 ? 1.08 : beatInBar === 2 ? 0.97 : 0.9;
const phrasePosition =
(barInPhrase * beatsPerBar + beatInBar) / (PHRASE_BAR_COUNT * beatsPerBar);
const phraseSwell = 0.88 + Math.sin(phrasePosition * Math.PI) * 0.12;
return {
midi,
velocity: clamp(
(role.velocityBase + expression * role.velocityActivityScale) *
colorVoice.velocityMultiplier *
beatAccent *
phraseSwell,
0.06,
0.52
),
durationSeconds:
role.durationBase +
expression * role.durationActivityScale +
(beatInBar === 0 ? role.downbeatDurationBoost : 0),
pan: clamp(role.pan + colorVoice.panOffset * 0.45, -1, 1),
delaySend: clamp(role.delaySend * (1.15 - expression * 0.4), 0, 0.04),
lowpassHz: this.getLowpassHz(profile, midi, expression),
startTime,
};
}
private chooseMidi({
vibeId,
profile,
role,
stepIndex,
selectedColorIndex,
beatInBar,
beatsPerBar,
barInPhrase,
phraseIndex,
}: {
vibeId: string;
profile: GardenAudioVibeProfile;
role: PianoRole;
stepIndex: number;
selectedColorIndex: GardenAudioColorIndex;
beatInBar: number;
beatsPerBar: number;
barInPhrase: number;
phraseIndex: number;
}): number {
const chord = getChordAtStep(this.config, profile, stepIndex);
const rootMidi = profile.rootMidi + chord.rootOffset;
const offsets = this.getPitchOffsets({
vibeId,
profile,
chord,
selectedColorIndex,
beatInBar,
beatsPerBar,
barInPhrase,
phraseIndex,
});
const previousMidi = this.lastMidiByColor[selectedColorIndex] ?? role.preferredMidi;
const candidates = this.getCandidates(rootMidi, offsets, role);
if (candidates.length === 0) {
return clampMidi(rootMidi + offsets[0], role.midiMin, role.midiMax);
}
return candidates.reduce((best, candidate) =>
this.scoreCandidate(candidate, role, previousMidi) <
this.scoreCandidate(best, role, previousMidi)
? candidate
: best
).midi;
}
private getPitchOffsets({
vibeId,
profile,
chord,
selectedColorIndex,
beatInBar,
beatsPerBar,
barInPhrase,
phraseIndex,
}: {
vibeId: string;
profile: GardenAudioVibeProfile;
chord: GardenAudioChord;
selectedColorIndex: GardenAudioColorIndex;
beatInBar: number;
beatsPerBar: number;
barInPhrase: number;
phraseIndex: number;
}): Array<number> {
const chordIntervals = getChordIntervals(chord, false);
const phraseBeat = barInPhrase * beatsPerBar + beatInBar;
const contour = this.getPhraseContour(
vibeId,
phraseIndex,
selectedColorIndex,
phraseBeat
);
const colorVoice = this.config.colorVoices[selectedColorIndex];
if (selectedColorIndex === 0) {
return beatInBar === 0 ? [0, 7, 12] : [7, 0, 12];
}
if (selectedColorIndex === 1) {
return [
chordIntervals[1],
chordIntervals[2],
degreeToSemitone(profile, 2 + colorVoice.scaleDegreeOffset + contour),
chordIntervals[1] + 12,
];
}
const degreeBase = 2 + colorVoice.scaleDegreeOffset + contour;
return [
degreeToSemitone(profile, degreeBase),
degreeToSemitone(profile, degreeBase + 2),
12 + degreeToSemitone(profile, degreeBase - 1),
12 + degreeToSemitone(profile, degreeBase + 1),
];
}
private getCandidates(
rootMidi: number,
offsets: ReadonlyArray<number>,
role: PianoRole
): Array<PitchCandidate> {
const candidates: Array<PitchCandidate> = [];
offsets.forEach((offset, preference) => {
for (let octave = -3; octave <= 3; octave += 1) {
const midi = rootMidi + offset + octave * 12;
if (midi >= role.midiMin && midi <= role.midiMax) {
candidates.push({ midi: Math.round(midi), preference });
}
}
});
return candidates;
}
private scoreCandidate(
candidate: PitchCandidate,
role: PianoRole,
previousMidi: number
): number {
return (
Math.abs(candidate.midi - previousMidi) +
Math.abs(candidate.midi - role.preferredMidi) * NOTE_SCORE_REGISTER_WEIGHT +
candidate.preference * NOTE_SCORE_PREFERENCE_WEIGHT
);
}
private shouldPlayBeat({
vibeId,
role,
activity,
beatInBar,
beatsPerBar,
barInPhrase,
phraseIndex,
}: {
vibeId: string;
role: PianoRole;
activity: number;
beatInBar: number;
beatsPerBar: number;
barInPhrase: number;
phraseIndex: number;
}): boolean {
const expression = this.getExpression(activity);
const beats =
expression < 0.34
? role.lowBeats
: expression < 0.68
? role.mediumBeats
: role.highBeats;
if (!beats.includes(beatInBar)) {
return false;
}
const phraseBeat = barInPhrase * beatsPerBar + beatInBar;
if (
expression < 0.34 &&
phraseBeat % role.lowPhraseSpacing !== role.lowPhraseOffset
) {
return false;
}
const keepChance =
expression < 0.34
? role.lowKeepChance
: expression < 0.68
? role.mediumKeepChance
: role.highKeepChance;
const gate = hashUnit(vibeId, role.name, phraseIndex, barInPhrase, beatInBar);
if (this.isWaitingForFirstStrokeNote && beatInBar === 0) {
return true;
}
if (beatInBar === 0 && role.name !== 'spark' && expression >= 0.52) {
return true;
}
return gate <= keepChance;
}
private getPhraseContour(
vibeId: string,
phraseIndex: number,
selectedColorIndex: GardenAudioColorIndex,
phraseBeat: number
): number {
if (selectedColorIndex === 0) {
return 0;
}
const contourIndex =
hashInt(vibeId, phraseIndex, selectedColorIndex) % PHRASE_CONTOURS.length;
const contour = PHRASE_CONTOURS[contourIndex];
return contour[phraseBeat % contour.length];
}
private getLowpassHz(
profile: GardenAudioVibeProfile,
midi: number,
expression: number
): number {
const midiLift = clamp01((midi - 48) / 36) * 1100;
return clamp(
this.config.piano.lowpassHz * profile.brightness * (0.44 + expression * 0.44) +
midiLift,
appConfig.audioEngine.piano.lowpassMinHz,
appConfig.audioEngine.piano.lowpassMaxHz
);
}
private updatePace(now: number, activity: number): void {
if (this.lastPaceUpdateAt === null) {
this.lastPaceUpdateAt = now;
return;
}
const elapsedSeconds = Math.max(0, now - this.lastPaceUpdateAt);
this.lastPaceUpdateAt = now;
const targetPace = PACE_MIN + (PACE_MAX - PACE_MIN) * this.getExpression(activity);
const amount = 1 - Math.exp(-elapsedSeconds / PACE_RAMP_SECONDS);
this.pace += (targetPace - this.pace) * amount;
}
private skipLateSteps(now: number, stepSeconds: number): void {
if (this.nextStepAt === null) {
return;
}
const earliestStart = now + appConfig.audioEngine.piano.scheduleAheadSeconds;
if (this.nextStepAt >= earliestStart) {
return;
}
const skippedSteps = Math.floor((earliestStart - this.nextStepAt) / stepSeconds) + 1;
this.nextStepAt += skippedSteps * stepSeconds;
this.stepIndex += skippedSteps;
}
private getExpression(activity: number): number {
return clamp01(
(activity - this.config.rhythm.sparseActivity) /
(1 - this.config.rhythm.sparseActivity)
);
}
private getStepDurationSeconds(): number {
return 60 / this.config.rhythm.bpm / this.config.rhythm.stepsPerBeat / this.pace;
}
private getNextDownbeatStepIndex(): number {
const stepInBar = this.stepIndex % this.config.rhythm.stepsPerBar;
if (stepInBar === 0) {
return this.stepIndex;
}
return this.stepIndex + this.config.rhythm.stepsPerBar - stepInBar;
}
private getBeatsPerBar(): number {
return Math.max(
1,
Math.round(this.config.rhythm.stepsPerBar / this.config.rhythm.stepsPerBeat)
);
}
}
const hashUnit = (...parts: Array<number | string>): number =>
hashInt(...parts) / 0xffffffff;
const hashInt = (...parts: Array<number | string>): number => {
let hash = 2166136261;
const input = parts.join(':');
for (let index = 0; index < input.length; index += 1) {
hash ^= input.charCodeAt(index);
hash = Math.imul(hash, 16777619);
}
return hash >>> 0;
};

View file

@ -1,3 +1,4 @@
import { appConfig } from '../config';
import { GardenAudioGraph } from './garden-audio-graph';
import { NoiseBurst } from './garden-audio-types';
@ -10,7 +11,10 @@ export class NoiseBurstPlayer {
return;
}
const scheduledStart = Math.max(context.currentTime + 0.002, startTime);
const scheduledStart = Math.max(
context.currentTime + appConfig.audioEngine.noiseBurst.scheduleAheadSeconds,
startTime
);
const source = context.createBufferSource();
const filter = context.createBiquadFilter();
const envelope = context.createGain();
@ -20,20 +24,29 @@ export class NoiseBurstPlayer {
source.buffer = noiseBuffer;
filter.type = 'bandpass';
filter.frequency.setValueAtTime(filterHz, scheduledStart);
filter.Q.value = 1.4;
envelope.gain.setValueAtTime(0.0001, scheduledStart);
envelope.gain.exponentialRampToValueAtTime(
Math.max(0.0001, gain),
scheduledStart + 0.004
filter.Q.value = appConfig.audioEngine.noiseBurst.filterQ;
envelope.gain.setValueAtTime(
appConfig.audioEngine.noiseBurst.silentGain,
scheduledStart
);
envelope.gain.exponentialRampToValueAtTime(
Math.max(appConfig.audioEngine.noiseBurst.silentGain, gain),
scheduledStart + appConfig.audioEngine.noiseBurst.attackSeconds
);
envelope.gain.exponentialRampToValueAtTime(
appConfig.audioEngine.noiseBurst.silentGain,
stopAt
);
envelope.gain.exponentialRampToValueAtTime(0.0001, stopAt);
panner.pan.setValueAtTime(pan, scheduledStart);
source.connect(filter);
filter.connect(envelope);
envelope.connect(panner);
panner.connect(eventBus);
source.start(scheduledStart, Math.random() * 0.4);
source.start(
scheduledStart,
Math.random() * appConfig.audioEngine.noiseBurst.offsetRandomSeconds
);
source.stop(stopAt);
source.addEventListener(
'ended',

View file

@ -1,3 +1,4 @@
import { appConfig } from '../config';
import { clamp, clamp01 } from '../utils/clamp';
import { GardenAudioConfig } from './garden-audio-config';
import { GardenAudioGraph } from './garden-audio-graph';
@ -22,6 +23,9 @@ export class PianoSampler {
this.sampleLoadPromise = Promise.all(
pianoSampleDefinitions.map(async (sample) => {
const response = await fetch(sample.url);
if (!response.ok) {
throw new Error(`Unable to load piano sample ${sample.url}`);
}
const audioData = await response.arrayBuffer();
const buffer = await context.decodeAudioData(audioData);
return { midi: sample.midi, buffer };
@ -56,12 +60,22 @@ export class PianoSampler {
return;
}
const scheduledStart = Math.max(context.currentTime + 0.002, startTime);
const scheduledStart = Math.max(
context.currentTime + appConfig.audioEngine.piano.scheduleAheadSeconds,
startTime
);
const noteVelocity = clamp01(velocity);
const noteGainValue = Math.max(0.0001, this.config.piano.gain * noteVelocity);
const noteGainValue = Math.max(
appConfig.audioEngine.piano.minGain,
this.config.piano.gain * noteVelocity
);
const sustainSeconds =
this.config.piano.sustainSeconds * (0.45 + noteVelocity * 0.55);
const sustainAt = scheduledStart + Math.max(0.08, durationSeconds);
this.config.piano.sustainSeconds *
(appConfig.audioEngine.piano.sustainBase +
noteVelocity * appConfig.audioEngine.piano.sustainVelocityRange);
const sustainAt =
scheduledStart +
Math.max(appConfig.audioEngine.piano.minDurationSeconds, durationSeconds);
const releaseAt = sustainAt + sustainSeconds;
const releaseSeconds = this.config.piano.releaseSeconds;
const stopAt = releaseAt + releaseSeconds;
@ -75,26 +89,55 @@ export class PianoSampler {
while (this.activeVoices.length >= this.config.piano.maxVoices) {
const oldest = this.activeVoices.shift();
oldest?.gain.gain.cancelScheduledValues(scheduledStart);
oldest?.gain.gain.setTargetAtTime(0.0001, scheduledStart, 0.025);
oldest?.source.stop(scheduledStart + 0.05);
oldest?.gain.gain.setTargetAtTime(
appConfig.audioEngine.piano.minGain,
scheduledStart,
appConfig.audioEngine.piano.voiceStealFadeSeconds
);
oldest?.source.stop(
scheduledStart + appConfig.audioEngine.piano.voiceStealStopSeconds
);
}
source.buffer = sample.buffer;
source.playbackRate.setValueAtTime(
Math.pow(2, (midi - sample.midi) / 12),
Math.pow(
2,
(midi - sample.midi) / appConfig.audioEngine.piano.pitchSemitonesPerOctave
),
scheduledStart
);
filter.type = 'lowpass';
filter.frequency.setValueAtTime(clamp(lowpassHz, 1400, 12000), scheduledStart);
filter.Q.value = 0.7;
gain.gain.setValueAtTime(0.0001, scheduledStart);
gain.gain.exponentialRampToValueAtTime(noteGainValue, scheduledStart + 0.006);
gain.gain.setTargetAtTime(
Math.max(0.0001, noteGainValue * this.config.piano.sustainLevel),
sustainAt,
Math.max(0.04, sustainSeconds * 0.45)
filter.frequency.setValueAtTime(
clamp(
lowpassHz,
appConfig.audioEngine.piano.lowpassMinHz,
appConfig.audioEngine.piano.lowpassMaxHz
),
scheduledStart
);
filter.Q.value = appConfig.audioEngine.piano.filterQ;
gain.gain.setValueAtTime(appConfig.audioEngine.piano.minGain, scheduledStart);
gain.gain.exponentialRampToValueAtTime(
noteGainValue,
scheduledStart + appConfig.audioEngine.piano.gainAttackSeconds
);
gain.gain.setTargetAtTime(
Math.max(
appConfig.audioEngine.piano.minGain,
noteGainValue * this.config.piano.sustainLevel
),
sustainAt,
Math.max(
appConfig.audioEngine.piano.minFadeSeconds,
sustainSeconds * appConfig.audioEngine.piano.sustainBase
)
);
gain.gain.setTargetAtTime(
appConfig.audioEngine.piano.minGain,
releaseAt,
releaseSeconds
);
gain.gain.setTargetAtTime(0.0001, releaseAt, releaseSeconds);
panner.pan.setValueAtTime(clamp(pan, -1, 1), scheduledStart);
source.connect(filter);
@ -102,7 +145,7 @@ export class PianoSampler {
gain.connect(panner);
panner.connect(eventBus);
if (delayInput && this.config.delay.enabled && delaySend > 0) {
if (delayInput && delaySend > 0) {
sendGain = context.createGain();
sendGain.gain.value = delaySend;
panner.connect(sendGain);
@ -110,8 +153,8 @@ export class PianoSampler {
}
source.start(scheduledStart);
source.stop(stopAt + 0.05);
this.activeVoices.push({ gain, source, stopAt });
source.stop(stopAt + appConfig.audioEngine.piano.tailStopExtraSeconds);
this.activeVoices.push({ gain, source, startAt: scheduledStart, stopAt });
source.addEventListener(
'ended',
@ -127,13 +170,35 @@ export class PianoSampler {
);
}
public fadeActive(now: number): void {
public fadeActive(
now: number,
fadeSeconds = appConfig.audioEngine.piano.defaultFadeSeconds
): void {
this.activeVoices.forEach((voice) => {
voice.gain.gain.cancelScheduledValues(now);
voice.gain.gain.setTargetAtTime(0.0001, now, 0.035);
voice.stopAt = Math.min(voice.stopAt, now + 0.28);
if (voice.startAt > now) {
voice.gain.gain.setValueAtTime(appConfig.audioEngine.piano.minGain, now);
voice.stopAt = now;
try {
voice.source.stop(now);
} catch {
// The source may already have a stop time scheduled.
}
return;
}
const stopAt = Math.min(voice.stopAt, now + fadeSeconds);
voice.gain.gain.setTargetAtTime(
appConfig.audioEngine.piano.minGain,
now,
Math.max(
appConfig.audioEngine.piano.minFadeSeconds,
fadeSeconds * appConfig.audioEngine.piano.fadeTimeConstantRatio
)
);
voice.stopAt = stopAt;
try {
voice.source.stop(now + 0.28);
voice.source.stop(stopAt);
} catch {
// The source may already have a stop time scheduled.
}

View file

@ -6,36 +6,36 @@ export interface PianoSampleDefinition {
const sampleBaseUrl = `${import.meta.env.BASE_URL}audio/piano/`;
const sampleFiles: Array<[fileName: string, midi: number]> = [
['A0v12.ogg', 21],
['C1v12.ogg', 24],
['Dsharp1v12.ogg', 27],
['Fsharp1v12.ogg', 30],
['A1v12.ogg', 33],
['C2v12.ogg', 36],
['Dsharp2v12.ogg', 39],
['Fsharp2v12.ogg', 42],
['A2v12.ogg', 45],
['C3v12.ogg', 48],
['Dsharp3v12.ogg', 51],
['Fsharp3v12.ogg', 54],
['A3v12.ogg', 57],
['C4v12.ogg', 60],
['Dsharp4v12.ogg', 63],
['Fsharp4v12.ogg', 66],
['A4v12.ogg', 69],
['C5v12.ogg', 72],
['Dsharp5v12.ogg', 75],
['Fsharp5v12.ogg', 78],
['A5v12.ogg', 81],
['C6v12.ogg', 84],
['Dsharp6v12.ogg', 87],
['Fsharp6v12.ogg', 90],
['A6v12.ogg', 93],
['C7v12.ogg', 96],
['Dsharp7v12.ogg', 99],
['Fsharp7v12.ogg', 102],
['A7v12.ogg', 105],
['C8v12.ogg', 108],
['A0v12.m4a', 21],
['C1v12.m4a', 24],
['Dsharp1v12.m4a', 27],
['Fsharp1v12.m4a', 30],
['A1v12.m4a', 33],
['C2v12.m4a', 36],
['Dsharp2v12.m4a', 39],
['Fsharp2v12.m4a', 42],
['A2v12.m4a', 45],
['C3v12.m4a', 48],
['Dsharp3v12.m4a', 51],
['Fsharp3v12.m4a', 54],
['A3v12.m4a', 57],
['C4v12.m4a', 60],
['Dsharp4v12.m4a', 63],
['Fsharp4v12.m4a', 66],
['A4v12.m4a', 69],
['C5v12.m4a', 72],
['Dsharp5v12.m4a', 75],
['Fsharp5v12.m4a', 78],
['A5v12.m4a', 81],
['C6v12.m4a', 84],
['Dsharp6v12.m4a', 87],
['Fsharp6v12.m4a', 90],
['A6v12.m4a', 93],
['C7v12.m4a', 96],
['Dsharp7v12.m4a', 99],
['Fsharp7v12.m4a', 102],
['A7v12.m4a', 105],
['C8v12.m4a', 108],
];
export const pianoSampleDefinitions: Array<PianoSampleDefinition> = sampleFiles