Add WIP sound generation
This commit is contained in:
parent
cb1df6f29e
commit
34ac200437
10 changed files with 1542 additions and 0 deletions
176
src/audio/garden-audio-energy.ts
Normal file
176
src/audio/garden-audio-energy.ts
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
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'];
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
public endGesture(now: number): void {
|
||||
if (this.currentGesture && !this.currentGesture.isErasing) {
|
||||
this.addGestureTail(this.currentGesture, now);
|
||||
}
|
||||
|
||||
this.isGestureActive = false;
|
||||
this.currentGesture = null;
|
||||
}
|
||||
|
||||
public recordStroke(distancePixels: number, strokeEnergy: number, now: number): void {
|
||||
if (!this.currentGesture || this.currentGesture.isErasing) {
|
||||
this.currentGesture = createGesture(now, false);
|
||||
}
|
||||
|
||||
this.currentGesture.lastAt = now;
|
||||
this.currentGesture.distancePixels += distancePixels;
|
||||
this.currentGesture.peakEnergy = Math.max(
|
||||
this.currentGesture.peakEnergy,
|
||||
strokeEnergy
|
||||
);
|
||||
}
|
||||
|
||||
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 silence(): void {
|
||||
this.targetEnergy = 0;
|
||||
this.gestureTails = [];
|
||||
}
|
||||
|
||||
public update(now: number): void {
|
||||
if (this.lastEnergyUpdateAt <= 0) {
|
||||
this.lastEnergyUpdateAt = now;
|
||||
return;
|
||||
}
|
||||
|
||||
const elapsedSeconds = Math.max(0, now - this.lastEnergyUpdateAt);
|
||||
this.lastEnergyUpdateAt = now;
|
||||
this.targetEnergy *= Math.exp(-elapsedSeconds / 0.75);
|
||||
this.trimGestureTails(now);
|
||||
|
||||
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 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;
|
||||
|
||||
return clamp01(
|
||||
this.energy * 0.58 + this.getTailActivityAt(time) * 0.72 + activeGesture
|
||||
);
|
||||
}
|
||||
|
||||
public reset(): void {
|
||||
this.isGestureActive = false;
|
||||
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,
|
||||
});
|
||||
168
src/audio/garden-audio-graph.ts
Normal file
168
src/audio/garden-audio-graph.ts
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
import { clamp } from '../utils/clamp';
|
||||
import { GardenAudioConfig, GardenAudioVibeProfile } from './garden-audio-config';
|
||||
|
||||
export class GardenAudioGraph {
|
||||
public context: AudioContext | null = null;
|
||||
public eventBus: GainNode | null = null;
|
||||
public delayInput: GainNode | null = null;
|
||||
public noiseBuffer: AudioBuffer | null = null;
|
||||
|
||||
private masterGain: GainNode | null = null;
|
||||
private delayNode: DelayNode | null = null;
|
||||
private delayFeedback: GainNode | null = null;
|
||||
private delayOutput: GainNode | null = null;
|
||||
|
||||
public constructor(private readonly config: GardenAudioConfig) {}
|
||||
|
||||
public ensureContext(canCreate: boolean): AudioContext | null {
|
||||
if (this.context) {
|
||||
return this.context;
|
||||
}
|
||||
|
||||
if (!canCreate) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const context = new AudioContext({ latencyHint: 'interactive' });
|
||||
const masterGain = context.createGain();
|
||||
const highPass = context.createBiquadFilter();
|
||||
const compressor = context.createDynamicsCompressor();
|
||||
|
||||
masterGain.gain.value = 0;
|
||||
highPass.type = 'highpass';
|
||||
highPass.frequency.value = this.config.highPassFrequencyHz;
|
||||
compressor.threshold.value = this.config.compressor.thresholdDb;
|
||||
compressor.knee.value = this.config.compressor.kneeDb;
|
||||
compressor.ratio.value = this.config.compressor.ratio;
|
||||
compressor.attack.value = this.config.compressor.attackSeconds;
|
||||
compressor.release.value = this.config.compressor.releaseSeconds;
|
||||
|
||||
masterGain.connect(highPass);
|
||||
highPass.connect(compressor);
|
||||
compressor.connect(context.destination);
|
||||
|
||||
this.context = context;
|
||||
this.masterGain = masterGain;
|
||||
this.noiseBuffer = this.createNoiseBuffer(context);
|
||||
this.createDelay(context, masterGain);
|
||||
this.createBuses(context, masterGain);
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
public setMasterGain(targetGain: number, timeConstantSeconds: number): void {
|
||||
if (!this.context || !this.masterGain) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.masterGain.gain.setTargetAtTime(
|
||||
targetGain,
|
||||
this.context.currentTime,
|
||||
timeConstantSeconds
|
||||
);
|
||||
}
|
||||
|
||||
public applyDelayProfile(profile: GardenAudioVibeProfile): void {
|
||||
if (!this.context || !this.delayNode) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.delayNode.delayTime.setTargetAtTime(
|
||||
this.config.delay.timeSeconds * profile.delayTimeMultiplier,
|
||||
this.context.currentTime,
|
||||
0.12
|
||||
);
|
||||
}
|
||||
|
||||
public updateDelay(profile: GardenAudioVibeProfile, activity: number): void {
|
||||
if (!this.context || !this.delayNode || !this.delayFeedback || !this.delayOutput) {
|
||||
return;
|
||||
}
|
||||
|
||||
const now = this.context.currentTime;
|
||||
this.delayNode.delayTime.setTargetAtTime(
|
||||
this.config.delay.timeSeconds * profile.delayTimeMultiplier,
|
||||
now,
|
||||
0.12
|
||||
);
|
||||
this.delayFeedback.gain.setTargetAtTime(
|
||||
this.config.delay.enabled
|
||||
? clamp(this.config.delay.feedback + activity * 0.08, 0.04, 0.32)
|
||||
: 0,
|
||||
now,
|
||||
this.config.updateRampSeconds
|
||||
);
|
||||
this.delayOutput.gain.setTargetAtTime(
|
||||
this.config.delay.enabled ? this.config.delay.wetGain * (0.65 + activity * 0.5) : 0,
|
||||
now,
|
||||
this.config.updateRampSeconds
|
||||
);
|
||||
}
|
||||
|
||||
public async close(): Promise<void> {
|
||||
const context = this.context;
|
||||
if (!context) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.masterGain && context.state !== 'closed') {
|
||||
this.masterGain.gain.setTargetAtTime(0.0001, context.currentTime, 0.015);
|
||||
}
|
||||
|
||||
this.clearNodes();
|
||||
|
||||
if (context.state !== 'closed') {
|
||||
await context.close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
private createDelay(context: AudioContext, masterGain: GainNode): void {
|
||||
const delayInput = context.createGain();
|
||||
const delayNode = context.createDelay(2);
|
||||
const delayFeedback = context.createGain();
|
||||
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;
|
||||
|
||||
delayInput.connect(delayNode);
|
||||
delayNode.connect(delayFeedback);
|
||||
delayFeedback.connect(delayNode);
|
||||
delayNode.connect(delayOutput);
|
||||
delayOutput.connect(masterGain);
|
||||
|
||||
this.delayInput = delayInput;
|
||||
this.delayNode = delayNode;
|
||||
this.delayFeedback = delayFeedback;
|
||||
this.delayOutput = delayOutput;
|
||||
}
|
||||
|
||||
private createBuses(context: AudioContext, masterGain: GainNode): void {
|
||||
this.eventBus = context.createGain();
|
||||
this.eventBus.gain.value = 1;
|
||||
this.eventBus.connect(masterGain);
|
||||
}
|
||||
|
||||
private createNoiseBuffer(context: AudioContext): AudioBuffer {
|
||||
const buffer = context.createBuffer(1, context.sampleRate, context.sampleRate);
|
||||
const data = buffer.getChannelData(0);
|
||||
|
||||
for (let index = 0; index < data.length; index++) {
|
||||
data[index] = Math.random() * 2 - 1;
|
||||
}
|
||||
|
||||
return buffer;
|
||||
}
|
||||
|
||||
private clearNodes(): void {
|
||||
this.context = null;
|
||||
this.eventBus = null;
|
||||
this.delayInput = null;
|
||||
this.noiseBuffer = null;
|
||||
this.masterGain = null;
|
||||
this.delayNode = null;
|
||||
this.delayFeedback = null;
|
||||
this.delayOutput = null;
|
||||
}
|
||||
}
|
||||
60
src/audio/garden-audio-input.ts
Normal file
60
src/audio/garden-audio-input.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import { clamp01 } from '../utils/clamp';
|
||||
import { GardenAudioStroke } from './garden-audio-types';
|
||||
|
||||
export interface GardenAudioStrokeMetrics {
|
||||
distancePixels: number;
|
||||
pressure: number;
|
||||
speedAmount: number;
|
||||
effectiveEnergy: number;
|
||||
}
|
||||
|
||||
export const getStrokeMetrics = (
|
||||
stroke: GardenAudioStroke,
|
||||
speedForFullEnergyPixelsPerSecond: number,
|
||||
fallbackPressure: number
|
||||
): GardenAudioStrokeMetrics => {
|
||||
const dx = stroke.to[0] - stroke.from[0];
|
||||
const dy = stroke.to[1] - stroke.from[1];
|
||||
const distancePixels = Math.hypot(dx, dy);
|
||||
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);
|
||||
|
||||
return {
|
||||
distancePixels,
|
||||
pressure,
|
||||
speedAmount,
|
||||
effectiveEnergy,
|
||||
};
|
||||
};
|
||||
|
||||
const getStrokeVelocity = (stroke: GardenAudioStroke, distancePixels: number): number => {
|
||||
if (
|
||||
stroke.velocityPixelsPerSecond !== undefined &&
|
||||
Number.isFinite(stroke.velocityPixelsPerSecond) &&
|
||||
stroke.velocityPixelsPerSecond >= 0
|
||||
) {
|
||||
return stroke.velocityPixelsPerSecond;
|
||||
}
|
||||
|
||||
return distancePixels / (1 / 60);
|
||||
};
|
||||
|
||||
const getPressureAmount = (
|
||||
stroke: GardenAudioStroke,
|
||||
fallbackPressure: number
|
||||
): number => {
|
||||
if (
|
||||
stroke.pressure !== undefined &&
|
||||
Number.isFinite(stroke.pressure) &&
|
||||
stroke.pressure > 0
|
||||
) {
|
||||
return clamp01(stroke.pressure);
|
||||
}
|
||||
|
||||
return stroke.pointerType === 'pen'
|
||||
? Math.max(0.56, clamp01(fallbackPressure))
|
||||
: clamp01(fallbackPressure);
|
||||
};
|
||||
52
src/audio/garden-audio-music.ts
Normal file
52
src/audio/garden-audio-music.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import { clamp } from '../utils/clamp';
|
||||
import { VibePreset } from '../vibes';
|
||||
import {
|
||||
GardenAudioChord,
|
||||
GardenAudioConfig,
|
||||
GardenAudioVibeProfile,
|
||||
} from './garden-audio-config';
|
||||
import { GardenAudioColorIndex } from './garden-audio-types';
|
||||
|
||||
export const normalizeColorIndex = (index: number): GardenAudioColorIndex =>
|
||||
Math.max(0, Math.min(2, Math.round(index))) as GardenAudioColorIndex;
|
||||
|
||||
export const clampMidi = (midi: number, min: number, max: number): number =>
|
||||
Math.round(clamp(midi, min, max));
|
||||
|
||||
export const getVibeProfile = (
|
||||
config: GardenAudioConfig,
|
||||
vibe: VibePreset
|
||||
): GardenAudioVibeProfile =>
|
||||
config.vibes[vibe.id] ??
|
||||
config.vibes[config.fallbackVibeId] ??
|
||||
Object.values(config.vibes)[0];
|
||||
|
||||
export const getChordAtStep = (
|
||||
config: GardenAudioConfig,
|
||||
profile: GardenAudioVibeProfile,
|
||||
stepIndex: number
|
||||
): GardenAudioChord => {
|
||||
const barIndex = Math.floor(stepIndex / config.rhythm.stepsPerBar);
|
||||
return profile.progression[barIndex % profile.progression.length];
|
||||
};
|
||||
|
||||
export const getChordIntervals = (
|
||||
chord: GardenAudioChord,
|
||||
openVoicing: boolean
|
||||
): Array<number> => {
|
||||
if (openVoicing) {
|
||||
return chord.quality === 'major' ? [0, 7, 12, 16] : [0, 7, 12, 15];
|
||||
}
|
||||
|
||||
return chord.quality === 'major' ? [0, 4, 7, 12, 16] : [0, 3, 7, 12, 15];
|
||||
};
|
||||
|
||||
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 * 12;
|
||||
};
|
||||
282
src/audio/garden-audio-score.ts
Normal file
282
src/audio/garden-audio-score.ts
Normal file
|
|
@ -0,0 +1,282 @@
|
|||
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;
|
||||
}
|
||||
}
|
||||
63
src/audio/garden-audio-types.ts
Normal file
63
src/audio/garden-audio-types.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import { VibePreset } from '../vibes';
|
||||
|
||||
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 {
|
||||
vibe: VibePreset;
|
||||
from: ArrayLike<number>;
|
||||
to: ArrayLike<number>;
|
||||
canvasSize: ArrayLike<number>;
|
||||
colorIndex: number;
|
||||
isErasing: boolean;
|
||||
pressure?: number;
|
||||
velocityPixelsPerSecond?: number;
|
||||
eraserSizePixels?: number;
|
||||
pointerType?: string;
|
||||
}
|
||||
|
||||
export interface GardenAudioStartOptions {
|
||||
userGesture?: boolean;
|
||||
}
|
||||
|
||||
export interface LoadedPianoSample {
|
||||
midi: number;
|
||||
buffer: AudioBuffer;
|
||||
}
|
||||
|
||||
export interface ActivePianoVoice {
|
||||
gain: GainNode;
|
||||
source: AudioBufferSourceNode;
|
||||
stopAt: number;
|
||||
}
|
||||
|
||||
export interface PianoNote {
|
||||
midi: number;
|
||||
velocity: number;
|
||||
startTime: number;
|
||||
durationSeconds: number;
|
||||
pan: number;
|
||||
delaySend?: number;
|
||||
lowpassHz?: number;
|
||||
}
|
||||
|
||||
export interface NoiseBurst {
|
||||
startTime: number;
|
||||
durationSeconds: number;
|
||||
gain: number;
|
||||
filterHz: number;
|
||||
pan: number;
|
||||
}
|
||||
144
src/audio/garden-audio.test.ts
Normal file
144
src/audio/garden-audio.test.ts
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { VIBE_PRESETS } from '../vibes';
|
||||
import { GardenAudio } from './garden-audio';
|
||||
import { gardenAudioConfig, GardenAudioConfig } from './garden-audio-config';
|
||||
|
||||
const calls = {
|
||||
constructed: 0,
|
||||
resumed: 0,
|
||||
};
|
||||
|
||||
let contextState: AudioContextState = 'suspended';
|
||||
|
||||
class FakeAudioParam {
|
||||
public value = 0;
|
||||
public setTargetAtTime = vi.fn();
|
||||
public setValueAtTime = vi.fn();
|
||||
public exponentialRampToValueAtTime = vi.fn();
|
||||
public cancelScheduledValues = vi.fn();
|
||||
}
|
||||
|
||||
class FakeAudioNode {
|
||||
public readonly gain = new FakeAudioParam();
|
||||
public readonly frequency = new FakeAudioParam();
|
||||
public readonly threshold = new FakeAudioParam();
|
||||
public readonly knee = new FakeAudioParam();
|
||||
public readonly ratio = new FakeAudioParam();
|
||||
public readonly attack = new FakeAudioParam();
|
||||
public readonly release = new FakeAudioParam();
|
||||
public readonly delayTime = new FakeAudioParam();
|
||||
public type = '';
|
||||
public connect = vi.fn();
|
||||
public disconnect = vi.fn();
|
||||
}
|
||||
|
||||
class FakeAudioBuffer {
|
||||
private readonly data: Float32Array;
|
||||
|
||||
public constructor(length: number) {
|
||||
this.data = new Float32Array(length);
|
||||
}
|
||||
|
||||
public getChannelData(): Float32Array {
|
||||
return this.data;
|
||||
}
|
||||
}
|
||||
|
||||
class FakeAudioContext {
|
||||
public readonly currentTime = 1;
|
||||
public readonly sampleRate = 16;
|
||||
public readonly destination = new FakeAudioNode() as unknown as AudioDestinationNode;
|
||||
|
||||
public constructor() {
|
||||
calls.constructed += 1;
|
||||
}
|
||||
|
||||
public get state(): AudioContextState {
|
||||
return contextState;
|
||||
}
|
||||
|
||||
public set state(state: AudioContextState) {
|
||||
contextState = state;
|
||||
}
|
||||
|
||||
public createGain(): GainNode {
|
||||
return new FakeAudioNode() as unknown as GainNode;
|
||||
}
|
||||
|
||||
public createBiquadFilter(): BiquadFilterNode {
|
||||
return new FakeAudioNode() as unknown as BiquadFilterNode;
|
||||
}
|
||||
|
||||
public createDynamicsCompressor(): DynamicsCompressorNode {
|
||||
return new FakeAudioNode() as unknown as DynamicsCompressorNode;
|
||||
}
|
||||
|
||||
public createDelay(): DelayNode {
|
||||
return new FakeAudioNode() as unknown as DelayNode;
|
||||
}
|
||||
|
||||
public createBuffer(_channels: number, length: number): AudioBuffer {
|
||||
return new FakeAudioBuffer(length) as unknown as AudioBuffer;
|
||||
}
|
||||
|
||||
public async resume(): Promise<void> {
|
||||
calls.resumed += 1;
|
||||
contextState = 'running';
|
||||
}
|
||||
}
|
||||
|
||||
const makeConfig = (): GardenAudioConfig => ({
|
||||
...gardenAudioConfig,
|
||||
piano: {
|
||||
...gardenAudioConfig.piano,
|
||||
preloadOnStart: false,
|
||||
},
|
||||
});
|
||||
|
||||
describe('GardenAudio startup policy', () => {
|
||||
beforeEach(() => {
|
||||
calls.constructed = 0;
|
||||
calls.resumed = 0;
|
||||
contextState = 'suspended';
|
||||
vi.stubGlobal('AudioContext', FakeAudioContext);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('does not create an AudioContext from passive audio paths', () => {
|
||||
const audio = new GardenAudio(makeConfig());
|
||||
const vibe = VIBE_PRESETS[0];
|
||||
|
||||
audio.start(vibe);
|
||||
audio.stroke({
|
||||
vibe,
|
||||
from: [0, 0],
|
||||
to: [12, 0],
|
||||
canvasSize: [100, 100],
|
||||
colorIndex: 0,
|
||||
isErasing: false,
|
||||
});
|
||||
|
||||
expect(calls.constructed).toBe(0);
|
||||
});
|
||||
|
||||
it('only resumes a suspended context from a user gesture start', () => {
|
||||
const audio = new GardenAudio(makeConfig());
|
||||
const vibe = VIBE_PRESETS[0];
|
||||
|
||||
audio.start(vibe, { userGesture: true });
|
||||
|
||||
expect(calls.constructed).toBe(1);
|
||||
expect(calls.resumed).toBe(1);
|
||||
expect(contextState).toBe('running');
|
||||
|
||||
contextState = 'suspended';
|
||||
audio.start(vibe);
|
||||
audio.setMuted(false);
|
||||
|
||||
expect(calls.resumed).toBe(1);
|
||||
});
|
||||
});
|
||||
386
src/audio/garden-audio.ts
Normal file
386
src/audio/garden-audio.ts
Normal file
|
|
@ -0,0 +1,386 @@
|
|||
import { clamp, clamp01 } from '../utils/clamp';
|
||||
import { VibePreset } from '../vibes';
|
||||
import { GardenAudioConfig } from './garden-audio-config';
|
||||
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 { NoiseBurstPlayer } from './noise-burst-player';
|
||||
import { PianoSampler } from './piano-sampler';
|
||||
|
||||
export type {
|
||||
GardenAudioSnapshot,
|
||||
GardenAudioStartOptions,
|
||||
GardenAudioStroke,
|
||||
} from './garden-audio-types';
|
||||
|
||||
export class GardenAudio {
|
||||
private readonly graph: GardenAudioGraph;
|
||||
private readonly piano: PianoSampler;
|
||||
private readonly noise: NoiseBurstPlayer;
|
||||
private readonly energy: GardenAudioEnergy;
|
||||
private readonly score: GardenAudioScore;
|
||||
|
||||
private currentVibeId: string | null = null;
|
||||
private hasStarted = false;
|
||||
private isDestroyed = false;
|
||||
private isMuted = 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;
|
||||
|
||||
public constructor(private readonly config: GardenAudioConfig) {
|
||||
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));
|
||||
}
|
||||
|
||||
public start(vibe: VibePreset, options: GardenAudioStartOptions = {}): void {
|
||||
if (!this.config.enabled || this.isDestroyed || this.isMuted) {
|
||||
return;
|
||||
}
|
||||
|
||||
const context = this.graph.ensureContext(options.userGesture === true);
|
||||
if (!context) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (context.state === 'suspended') {
|
||||
if (options.userGesture !== true) {
|
||||
return;
|
||||
}
|
||||
void context.resume().catch(() => undefined);
|
||||
}
|
||||
|
||||
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.graph.setMasterGain(this.config.masterVolume, this.config.fadeInSeconds);
|
||||
|
||||
if (this.config.piano.preloadOnStart) {
|
||||
void this.piano.load(context);
|
||||
}
|
||||
}
|
||||
|
||||
public changeVibe(vibe: VibePreset, options: GardenAudioStartOptions = {}): void {
|
||||
const previousVibeId = this.currentVibeId;
|
||||
this.start(vibe, options);
|
||||
|
||||
const context = this.graph.context;
|
||||
if (
|
||||
context &&
|
||||
(context.state === 'running' || options.userGesture === true) &&
|
||||
!this.isMuted &&
|
||||
!this.isDestroyed &&
|
||||
previousVibeId !== null &&
|
||||
previousVibeId !== vibe.id
|
||||
) {
|
||||
this.playVibeChangeStinger(vibe);
|
||||
}
|
||||
}
|
||||
|
||||
public setMuted(isMuted: boolean): void {
|
||||
this.isMuted = isMuted;
|
||||
this.graph.setMasterGain(
|
||||
isMuted ? 0.0001 : this.config.masterVolume,
|
||||
isMuted ? 0.02 : this.config.fadeInSeconds
|
||||
);
|
||||
}
|
||||
|
||||
public beginGesture(): void {
|
||||
const context = this.graph.context;
|
||||
if (!context) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.energy.beginGesture(context.currentTime);
|
||||
}
|
||||
|
||||
public endGesture(): void {
|
||||
const context = this.graph.context;
|
||||
if (!context) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.energy.endGesture(context.currentTime);
|
||||
}
|
||||
|
||||
public rememberColor(colorIndex: number): void {
|
||||
this.selectedColorIndex = normalizeColorIndex(colorIndex);
|
||||
}
|
||||
|
||||
public update(snapshot: GardenAudioSnapshot): void {
|
||||
const context = this.graph.context;
|
||||
if (!this.hasStarted || !context || this.isMuted) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.applyVibe(snapshot.vibe);
|
||||
this.selectedColorIndex = normalizeColorIndex(snapshot.selectedColorIndex);
|
||||
this.energy.update(context.currentTime);
|
||||
|
||||
if (snapshot.isErasing) {
|
||||
this.energy.silence();
|
||||
this.piano.fadeActive(context.currentTime);
|
||||
this.updateDelay(snapshot);
|
||||
return;
|
||||
}
|
||||
|
||||
this.scheduleRhythm(snapshot.vibe);
|
||||
this.updateDelay(snapshot);
|
||||
}
|
||||
|
||||
public stroke(stroke: GardenAudioStroke): void {
|
||||
if (!this.config.enabled || this.isDestroyed || this.isMuted) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.start(stroke.vibe);
|
||||
const context = this.graph.context;
|
||||
if (!context) {
|
||||
return;
|
||||
}
|
||||
|
||||
const metrics = getStrokeMetrics(
|
||||
stroke,
|
||||
this.config.rhythm.speedForFullEnergyPixelsPerSecond,
|
||||
this.config.input.pressureFallback
|
||||
);
|
||||
const now = context.currentTime;
|
||||
|
||||
this.selectedColorIndex = normalizeColorIndex(stroke.colorIndex);
|
||||
|
||||
if (stroke.isErasing) {
|
||||
this.energy.recordEraserStroke(now);
|
||||
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),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public async destroy(): Promise<void> {
|
||||
this.isDestroyed = true;
|
||||
await this.graph.close();
|
||||
|
||||
this.piano.reset();
|
||||
this.energy.reset();
|
||||
this.currentVibeId = null;
|
||||
this.hasStarted = 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) {
|
||||
return;
|
||||
}
|
||||
|
||||
const now = context.currentTime;
|
||||
if (now - this.lastVibeStingerAt < 0.45) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.lastVibeStingerAt = now;
|
||||
this.score.playVibeChangeStinger(vibe, now);
|
||||
}
|
||||
|
||||
private playEraser(
|
||||
stroke: GardenAudioStroke,
|
||||
speedAmount: number,
|
||||
pressure: number,
|
||||
now: number
|
||||
): void {
|
||||
if (!this.config.eraser.enabled || !this.graph.context) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sizeAmount = clamp01(
|
||||
(stroke.eraserSizePixels ?? 96) / Math.max(1, stroke.canvasSize[0] * 0.18)
|
||||
);
|
||||
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);
|
||||
|
||||
if (now - this.lastEraserAt >= this.config.eraser.minIntervalSeconds) {
|
||||
this.lastEraserAt = now;
|
||||
this.noise.play({
|
||||
startTime: now,
|
||||
durationSeconds: 0.08,
|
||||
gain:
|
||||
this.config.eraser.noiseGain *
|
||||
(0.45 + speedAmount * 0.38 + pressure * 0.24 + sizeAmount * 0.18),
|
||||
filterHz,
|
||||
pan: clamp(x * 2 - 1, -1, 1),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private updateDelay(snapshot: GardenAudioSnapshot): void {
|
||||
const context = this.graph.context;
|
||||
if (!context) {
|
||||
return;
|
||||
}
|
||||
|
||||
const profile = getVibeProfile(this.config, snapshot.vibe);
|
||||
const activity = snapshot.isErasing
|
||||
? 0.12
|
||||
: this.getSettledActivity(
|
||||
this.energy.getActivityAt(context.currentTime),
|
||||
context.currentTime
|
||||
);
|
||||
this.graph.updateDelay(profile, activity);
|
||||
}
|
||||
|
||||
private applyVibe(vibe: VibePreset): void {
|
||||
if (!this.graph.context || this.currentVibeId === vibe.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
49
src/audio/noise-burst-player.ts
Normal file
49
src/audio/noise-burst-player.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import { GardenAudioGraph } from './garden-audio-graph';
|
||||
import { NoiseBurst } from './garden-audio-types';
|
||||
|
||||
export class NoiseBurstPlayer {
|
||||
public constructor(private readonly graph: GardenAudioGraph) {}
|
||||
|
||||
public play({ startTime, durationSeconds, gain, filterHz, pan }: NoiseBurst): void {
|
||||
const { context, eventBus, noiseBuffer } = this.graph;
|
||||
if (!context || !eventBus || !noiseBuffer) {
|
||||
return;
|
||||
}
|
||||
|
||||
const scheduledStart = Math.max(context.currentTime + 0.002, startTime);
|
||||
const source = context.createBufferSource();
|
||||
const filter = context.createBiquadFilter();
|
||||
const envelope = context.createGain();
|
||||
const panner = context.createStereoPanner();
|
||||
const stopAt = scheduledStart + durationSeconds;
|
||||
|
||||
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
|
||||
);
|
||||
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.stop(stopAt);
|
||||
source.addEventListener(
|
||||
'ended',
|
||||
() => {
|
||||
source.disconnect();
|
||||
filter.disconnect();
|
||||
envelope.disconnect();
|
||||
panner.disconnect();
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
}
|
||||
}
|
||||
162
src/audio/piano-sampler.ts
Normal file
162
src/audio/piano-sampler.ts
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
import { clamp, clamp01 } from '../utils/clamp';
|
||||
import { GardenAudioConfig } from './garden-audio-config';
|
||||
import { GardenAudioGraph } from './garden-audio-graph';
|
||||
import { ActivePianoVoice, LoadedPianoSample, PianoNote } from './garden-audio-types';
|
||||
import { pianoSampleDefinitions } from './piano-samples';
|
||||
|
||||
export class PianoSampler {
|
||||
private sampleLoadPromise: Promise<void> | null = null;
|
||||
private samples: Array<LoadedPianoSample> = [];
|
||||
private activeVoices: Array<ActivePianoVoice> = [];
|
||||
|
||||
public constructor(
|
||||
private readonly config: GardenAudioConfig,
|
||||
private readonly graph: GardenAudioGraph
|
||||
) {}
|
||||
|
||||
public async load(context: AudioContext): Promise<void> {
|
||||
if (this.sampleLoadPromise) {
|
||||
return this.sampleLoadPromise;
|
||||
}
|
||||
|
||||
this.sampleLoadPromise = Promise.all(
|
||||
pianoSampleDefinitions.map(async (sample) => {
|
||||
const response = await fetch(sample.url);
|
||||
const audioData = await response.arrayBuffer();
|
||||
const buffer = await context.decodeAudioData(audioData);
|
||||
return { midi: sample.midi, buffer };
|
||||
})
|
||||
)
|
||||
.then((samples) => {
|
||||
this.samples = samples.sort((a, b) => a.midi - b.midi);
|
||||
})
|
||||
.catch(() => {
|
||||
this.samples = [];
|
||||
});
|
||||
|
||||
return this.sampleLoadPromise;
|
||||
}
|
||||
|
||||
public play({
|
||||
midi,
|
||||
velocity,
|
||||
startTime,
|
||||
durationSeconds,
|
||||
pan,
|
||||
delaySend = 0,
|
||||
lowpassHz = this.config.piano.lowpassHz,
|
||||
}: PianoNote): void {
|
||||
const { context, eventBus, delayInput } = this.graph;
|
||||
if (!context || !eventBus || this.samples.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sample = this.findNearestSample(midi);
|
||||
if (!sample) {
|
||||
return;
|
||||
}
|
||||
|
||||
const scheduledStart = Math.max(context.currentTime + 0.002, startTime);
|
||||
const noteVelocity = clamp01(velocity);
|
||||
const noteGainValue = Math.max(0.0001, this.config.piano.gain * noteVelocity);
|
||||
const sustainSeconds =
|
||||
this.config.piano.sustainSeconds * (0.45 + noteVelocity * 0.55);
|
||||
const sustainAt = scheduledStart + Math.max(0.08, 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 panner = context.createStereoPanner();
|
||||
let sendGain: GainNode | null = null;
|
||||
|
||||
this.trimActiveVoices(scheduledStart);
|
||||
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);
|
||||
}
|
||||
|
||||
source.buffer = sample.buffer;
|
||||
source.playbackRate.setValueAtTime(
|
||||
Math.pow(2, (midi - sample.midi) / 12),
|
||||
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)
|
||||
);
|
||||
gain.gain.setTargetAtTime(0.0001, releaseAt, releaseSeconds);
|
||||
panner.pan.setValueAtTime(clamp(pan, -1, 1), scheduledStart);
|
||||
|
||||
source.connect(filter);
|
||||
filter.connect(gain);
|
||||
gain.connect(panner);
|
||||
panner.connect(eventBus);
|
||||
|
||||
if (delayInput && this.config.delay.enabled && delaySend > 0) {
|
||||
sendGain = context.createGain();
|
||||
sendGain.gain.value = delaySend;
|
||||
panner.connect(sendGain);
|
||||
sendGain.connect(delayInput);
|
||||
}
|
||||
|
||||
source.start(scheduledStart);
|
||||
source.stop(stopAt + 0.05);
|
||||
this.activeVoices.push({ gain, source, stopAt });
|
||||
|
||||
source.addEventListener(
|
||||
'ended',
|
||||
() => {
|
||||
source.disconnect();
|
||||
filter.disconnect();
|
||||
gain.disconnect();
|
||||
panner.disconnect();
|
||||
sendGain?.disconnect();
|
||||
this.activeVoices = this.activeVoices.filter((voice) => voice.gain !== gain);
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
}
|
||||
|
||||
public fadeActive(now: number): 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);
|
||||
try {
|
||||
voice.source.stop(now + 0.28);
|
||||
} catch {
|
||||
// The source may already have a stop time scheduled.
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public reset(): void {
|
||||
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 trimActiveVoices(now: number): void {
|
||||
this.activeVoices = this.activeVoices.filter((voice) => voice.stopAt > now);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue