Add generative garden audio
This commit is contained in:
parent
f8294934fd
commit
c2efb33683
45 changed files with 3730 additions and 0 deletions
127
src/audio/garden-audio-config.ts
Normal file
127
src/audio/garden-audio-config.ts
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
import type { PianoNoteRole } from './garden-audio-types';
|
||||
|
||||
export const DEFAULT_AUDIO_VOLUME = 0.5;
|
||||
export const SILENT_AUDIO_GAIN = 0.0001;
|
||||
|
||||
type GardenAudioChordQuality = 'major' | 'minor' | 'sus2' | 'sus4';
|
||||
|
||||
export interface GardenAudioChord {
|
||||
rootOffset: number;
|
||||
quality: GardenAudioChordQuality;
|
||||
}
|
||||
|
||||
export interface GardenAudioVibeSettings {
|
||||
idleIntensity: number;
|
||||
bpm: number;
|
||||
rampUpIntensity: number;
|
||||
rampUpTime: number;
|
||||
noteLength: number;
|
||||
notePitchOffset: number;
|
||||
brightness: number;
|
||||
scale?: Array<number>;
|
||||
progression?: Array<GardenAudioChord>;
|
||||
}
|
||||
|
||||
export interface GardenAudioVibeProfile extends GardenAudioVibeSettings {
|
||||
rootMidi: number;
|
||||
scale: Array<number>;
|
||||
progression: Array<GardenAudioChord>;
|
||||
}
|
||||
|
||||
export const defaultGardenAudioVibeSettings: GardenAudioVibeSettings = {
|
||||
idleIntensity: 0.08,
|
||||
bpm: 74,
|
||||
rampUpIntensity: 0.85,
|
||||
rampUpTime: 0.08,
|
||||
noteLength: 0.42,
|
||||
notePitchOffset: 0,
|
||||
brightness: 1,
|
||||
};
|
||||
|
||||
export const createGardenAudioConfig = () => ({
|
||||
masterVolume: DEFAULT_AUDIO_VOLUME,
|
||||
fadeInSeconds: 0.45,
|
||||
updateRampSeconds: 0.08,
|
||||
delay: {
|
||||
timeBeats: 0.5,
|
||||
timeMinSeconds: 0.18,
|
||||
timeMaxSeconds: 0.72,
|
||||
feedback: 0.12,
|
||||
wetGain: 0.044,
|
||||
erasingActivity: 0.12,
|
||||
activityFeedbackWeight: 0.08,
|
||||
feedbackMax: 0.32,
|
||||
feedbackMin: 0.04,
|
||||
outputActivityWeight: 0.5,
|
||||
outputBase: 0.65,
|
||||
outputActivityDuck: 0.28,
|
||||
timeRampSeconds: 0.12,
|
||||
},
|
||||
piano: {
|
||||
maxVoices: 24,
|
||||
gain: 0.48,
|
||||
sustainSeconds: 0.42,
|
||||
sustainLevel: 0.26,
|
||||
releaseSeconds: 0.34,
|
||||
lowpassHz: 7000,
|
||||
gainAttackSeconds: 0.006,
|
||||
lowpassMaxHz: 12000,
|
||||
lowpassMinHz: 1400,
|
||||
sustainBase: 0.45,
|
||||
sustainVelocityRange: 0.55,
|
||||
},
|
||||
rhythm: {
|
||||
idleIntensity: defaultGardenAudioVibeSettings.idleIntensity,
|
||||
bpm: defaultGardenAudioVibeSettings.bpm,
|
||||
stepsPerBeat: 4,
|
||||
stepsPerBar: 16,
|
||||
sparseActivity: 0.055,
|
||||
},
|
||||
eraser: {
|
||||
minIntervalSeconds: 0.12,
|
||||
noiseGain: 0.028,
|
||||
filterMinHz: 650,
|
||||
filterMaxHz: 3600,
|
||||
durationSeconds: 0.08,
|
||||
pan: 0,
|
||||
pianoActivity: 0,
|
||||
},
|
||||
energy: {
|
||||
decaySeconds: 0.9,
|
||||
releaseSeconds: 1.15,
|
||||
strokeDecaySeconds: 0.32,
|
||||
},
|
||||
graph: {
|
||||
pianoBusGains: {
|
||||
pad: 0.86,
|
||||
support: 0.94,
|
||||
texture: 0.88,
|
||||
gesture: 1,
|
||||
brush: 0.9,
|
||||
stinger: 0.92,
|
||||
} satisfies Record<PianoNoteRole, number>,
|
||||
pianoBusActivityDucking: {
|
||||
pad: 0.42,
|
||||
support: 0.18,
|
||||
texture: -0.06,
|
||||
gesture: 0,
|
||||
brush: -0.08,
|
||||
stinger: 0,
|
||||
} satisfies Record<PianoNoteRole, number>,
|
||||
noiseBusGain: 0.72,
|
||||
},
|
||||
input: {
|
||||
fullActivitySpeed: 0.86,
|
||||
activityNoiseFloorSpeed: 0.025,
|
||||
activityCurve: 0.74,
|
||||
activitySoftCeiling: 0.96,
|
||||
activityAttackSeconds: 0.055,
|
||||
activityReleaseSeconds: 0.2,
|
||||
minAudibleDistance: 0.0025,
|
||||
manicActivityThreshold: 0.9,
|
||||
manicReleaseThreshold: 0.76,
|
||||
maniaSmoothingSeconds: 0.12,
|
||||
},
|
||||
});
|
||||
|
||||
export type GardenAudioConfig = ReturnType<typeof createGardenAudioConfig>;
|
||||
66
src/audio/garden-audio-energy.ts
Normal file
66
src/audio/garden-audio-energy.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import { approach, clamp01 } from '../utils/math';
|
||||
import type { GardenAudioConfig, GardenAudioVibeProfile } from './garden-audio-config';
|
||||
|
||||
export class GardenAudioEnergy {
|
||||
private isGestureActive = false;
|
||||
private energy = 0;
|
||||
private targetEnergy = 0;
|
||||
private lastEnergyUpdateAt = 0;
|
||||
|
||||
public constructor(private readonly config: GardenAudioConfig) {}
|
||||
|
||||
public beginGesture(now: number): void {
|
||||
this.isGestureActive = true;
|
||||
this.lastEnergyUpdateAt = now;
|
||||
}
|
||||
|
||||
public endGesture(): void {
|
||||
this.isGestureActive = false;
|
||||
this.targetEnergy = 0;
|
||||
}
|
||||
|
||||
public recordStroke(strokeEnergy: number, profile: GardenAudioVibeProfile): void {
|
||||
this.targetEnergy = Math.max(this.targetEnergy, strokeEnergy);
|
||||
if (this.isGestureActive) {
|
||||
this.energy = Math.max(this.energy, strokeEnergy * profile.rampUpIntensity);
|
||||
}
|
||||
}
|
||||
|
||||
public silence(): void {
|
||||
this.targetEnergy = 0;
|
||||
this.energy = 0;
|
||||
}
|
||||
|
||||
public update(now: number, profile: GardenAudioVibeProfile): void {
|
||||
if (this.lastEnergyUpdateAt <= 0) {
|
||||
this.lastEnergyUpdateAt = now;
|
||||
return;
|
||||
}
|
||||
|
||||
const elapsedSeconds = now - this.lastEnergyUpdateAt;
|
||||
this.lastEnergyUpdateAt = now;
|
||||
this.targetEnergy *= Math.exp(
|
||||
-elapsedSeconds / this.config.energy.strokeDecaySeconds
|
||||
);
|
||||
|
||||
const target = this.isGestureActive ? this.targetEnergy : 0;
|
||||
let timeConstant = this.config.energy.decaySeconds;
|
||||
if (!this.isGestureActive) {
|
||||
timeConstant = this.config.energy.releaseSeconds;
|
||||
} else if (target > this.energy) {
|
||||
timeConstant = profile.rampUpTime;
|
||||
}
|
||||
this.energy = approach(this.energy, target, elapsedSeconds, timeConstant);
|
||||
}
|
||||
|
||||
public getLevel(): number {
|
||||
return clamp01(this.energy);
|
||||
}
|
||||
|
||||
public reset(): void {
|
||||
this.isGestureActive = false;
|
||||
this.energy = 0;
|
||||
this.targetEnergy = 0;
|
||||
this.lastEnergyUpdateAt = 0;
|
||||
}
|
||||
}
|
||||
75
src/audio/garden-audio-gesture-state.ts
Normal file
75
src/audio/garden-audio-gesture-state.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
import { approach, clamp, clamp01, smoothstep } from '../utils/math';
|
||||
import type { GardenAudioConfig } from './garden-audio-config';
|
||||
import type { GardenAudioStrokeMetrics } from './garden-audio-input';
|
||||
|
||||
interface GardenAudioGestureFrame {
|
||||
activity: number;
|
||||
maniaAmount: number;
|
||||
}
|
||||
|
||||
export class GardenAudioGestureState {
|
||||
private activity = 0;
|
||||
private maniaAmount = 0;
|
||||
private isManic = false;
|
||||
|
||||
public constructor(private readonly inputConfig: GardenAudioConfig['input']) {}
|
||||
|
||||
public recordStroke({
|
||||
metrics,
|
||||
}: {
|
||||
metrics: GardenAudioStrokeMetrics;
|
||||
}): GardenAudioGestureFrame {
|
||||
const targetActivity = this.getTargetActivity(metrics);
|
||||
const activityTimeConstant =
|
||||
targetActivity > this.activity
|
||||
? this.inputConfig.activityAttackSeconds
|
||||
: this.inputConfig.activityReleaseSeconds;
|
||||
this.activity = approach(
|
||||
this.activity,
|
||||
targetActivity,
|
||||
metrics.elapsedSeconds,
|
||||
activityTimeConstant
|
||||
);
|
||||
|
||||
if (this.activity >= this.inputConfig.manicActivityThreshold) {
|
||||
this.isManic = true;
|
||||
} else if (this.activity <= this.inputConfig.manicReleaseThreshold) {
|
||||
this.isManic = false;
|
||||
}
|
||||
|
||||
const maniaTarget = this.isManic
|
||||
? smoothstep(this.inputConfig.manicReleaseThreshold, 1, this.activity)
|
||||
: 0;
|
||||
this.maniaAmount = approach(
|
||||
this.maniaAmount,
|
||||
maniaTarget,
|
||||
metrics.elapsedSeconds,
|
||||
this.inputConfig.maniaSmoothingSeconds
|
||||
);
|
||||
|
||||
return {
|
||||
activity: this.activity,
|
||||
maniaAmount: this.maniaAmount,
|
||||
};
|
||||
}
|
||||
|
||||
public reset(): void {
|
||||
this.activity = 0;
|
||||
this.maniaAmount = 0;
|
||||
this.isManic = false;
|
||||
}
|
||||
|
||||
private getTargetActivity(metrics: GardenAudioStrokeMetrics): number {
|
||||
const speedRange =
|
||||
this.inputConfig.fullActivitySpeed - this.inputConfig.activityNoiseFloorSpeed;
|
||||
const speedAmount = clamp01(
|
||||
(metrics.normalizedSpeed - this.inputConfig.activityNoiseFloorSpeed) / speedRange
|
||||
);
|
||||
const distanceAmount = clamp01(
|
||||
metrics.normalizedDistance / this.inputConfig.minAudibleDistance
|
||||
);
|
||||
const activity = Math.pow(speedAmount, this.inputConfig.activityCurve);
|
||||
|
||||
return clamp(activity * distanceAmount, 0, this.inputConfig.activitySoftCeiling);
|
||||
}
|
||||
}
|
||||
347
src/audio/garden-audio-graph.ts
Normal file
347
src/audio/garden-audio-graph.ts
Normal file
|
|
@ -0,0 +1,347 @@
|
|||
import { clamp } from '../utils/math';
|
||||
import { SILENT_AUDIO_GAIN, type GardenAudioConfig } from './garden-audio-config';
|
||||
import type { PianoNoteRole } from './garden-audio-types';
|
||||
|
||||
type AudioSessionType = NonNullable<NavigatorWithAudioSession['audioSession']>['type'];
|
||||
|
||||
type NavigatorWithAudioSession = Navigator & {
|
||||
audioSession?: {
|
||||
type:
|
||||
| 'auto'
|
||||
| 'playback'
|
||||
| 'ambient'
|
||||
| 'transient'
|
||||
| 'transient-solo'
|
||||
| 'play-and-record';
|
||||
};
|
||||
};
|
||||
|
||||
const outputHighPassFrequencyHz = 45;
|
||||
const noiseBufferDurationSeconds = 1;
|
||||
const graphTuning = {
|
||||
closeGain: SILENT_AUDIO_GAIN,
|
||||
closeRampSeconds: 0.015,
|
||||
delayMaxSeconds: 2,
|
||||
eventBusGain: 1,
|
||||
noiseMax: 1,
|
||||
noiseMin: -1,
|
||||
latencyHint: 'interactive',
|
||||
outputFilterType: 'highpass',
|
||||
compressor: {
|
||||
thresholdDb: -18,
|
||||
kneeDb: 18,
|
||||
ratio: 2.1,
|
||||
attackSeconds: 0.018,
|
||||
releaseSeconds: 0.18,
|
||||
},
|
||||
} as const;
|
||||
const delayFilterTuning = {
|
||||
feedbackHighPassHz: 180,
|
||||
feedbackLowPassHz: 5200,
|
||||
returnLowPassHz: 6200,
|
||||
};
|
||||
|
||||
export class GardenAudioGraph {
|
||||
public context: AudioContext | null = null;
|
||||
public eventBus: GainNode | null = null;
|
||||
public delayInput: GainNode | null = null;
|
||||
public noiseBus: 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;
|
||||
private lastPianoBusActivity = 0;
|
||||
private pianoBusGainScale = 1;
|
||||
private pianoBusGainScaleAutomationUntil = 0;
|
||||
private pianoBusGainScaleTimeConstantSeconds = 0;
|
||||
private previousAudioSessionType: AudioSessionType | null = null;
|
||||
private readonly pianoBuses = new Map<PianoNoteRole, GainNode>();
|
||||
|
||||
public constructor(private readonly config: GardenAudioConfig) {}
|
||||
|
||||
public ensureContext(canCreate: boolean): AudioContext | null {
|
||||
if (this.context) {
|
||||
return this.context;
|
||||
}
|
||||
|
||||
if (!canCreate) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const AudioContextConstructor = globalThis.AudioContext;
|
||||
if (!AudioContextConstructor) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Tells iOS to treat this as media playback, so the hardware ringer/mute
|
||||
// switch does not silence Web Audio output. No-op on browsers without the
|
||||
// Audio Session API.
|
||||
const audioSession = (navigator as NavigatorWithAudioSession).audioSession;
|
||||
if (audioSession) {
|
||||
this.previousAudioSessionType ??= audioSession.type;
|
||||
audioSession.type = 'playback';
|
||||
}
|
||||
|
||||
const context = new AudioContextConstructor({
|
||||
latencyHint: graphTuning.latencyHint,
|
||||
});
|
||||
const masterGain = context.createGain();
|
||||
const highPass = context.createBiquadFilter();
|
||||
const compressor = context.createDynamicsCompressor();
|
||||
|
||||
masterGain.gain.value = 0;
|
||||
highPass.type = graphTuning.outputFilterType;
|
||||
highPass.frequency.value = outputHighPassFrequencyHz;
|
||||
compressor.threshold.value = graphTuning.compressor.thresholdDb;
|
||||
compressor.knee.value = graphTuning.compressor.kneeDb;
|
||||
compressor.ratio.value = graphTuning.compressor.ratio;
|
||||
compressor.attack.value = graphTuning.compressor.attackSeconds;
|
||||
compressor.release.value = graphTuning.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(bpm: number): void {
|
||||
if (!this.context || !this.delayNode) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.delayNode.delayTime.setTargetAtTime(
|
||||
this.getDelayTimeSecondsForBpm(bpm),
|
||||
this.context.currentTime,
|
||||
this.config.delay.timeRampSeconds
|
||||
);
|
||||
}
|
||||
|
||||
public updateDelay(activity: number, bpm: number): void {
|
||||
if (!this.context || !this.delayNode || !this.delayFeedback || !this.delayOutput) {
|
||||
return;
|
||||
}
|
||||
|
||||
const now = this.context.currentTime;
|
||||
const normalizedActivity = clamp(activity, 0, 1);
|
||||
this.delayNode.delayTime.setTargetAtTime(
|
||||
this.getDelayTimeSecondsForBpm(bpm),
|
||||
now,
|
||||
this.config.delay.timeRampSeconds
|
||||
);
|
||||
this.delayFeedback.gain.setTargetAtTime(
|
||||
clamp(
|
||||
this.config.delay.feedback +
|
||||
normalizedActivity * this.config.delay.activityFeedbackWeight,
|
||||
this.config.delay.feedbackMin,
|
||||
this.config.delay.feedbackMax
|
||||
),
|
||||
now,
|
||||
this.config.updateRampSeconds
|
||||
);
|
||||
this.delayOutput.gain.setTargetAtTime(
|
||||
this.config.delay.wetGain *
|
||||
(this.config.delay.outputBase +
|
||||
normalizedActivity * this.config.delay.outputActivityWeight) *
|
||||
(1 - normalizedActivity * this.config.delay.outputActivityDuck),
|
||||
now,
|
||||
this.config.updateRampSeconds
|
||||
);
|
||||
this.updatePianoBusGains(normalizedActivity, now);
|
||||
}
|
||||
|
||||
public getPianoBus(role: PianoNoteRole | undefined): GainNode | null {
|
||||
return this.pianoBuses.get(role ?? 'gesture') ?? this.eventBus;
|
||||
}
|
||||
|
||||
public setPianoBusGainScale(targetScale: number, timeConstantSeconds: number): void {
|
||||
if (!this.context) {
|
||||
this.pianoBusGainScale = clamp(targetScale, 0, 1);
|
||||
return;
|
||||
}
|
||||
|
||||
const now = this.context.currentTime;
|
||||
|
||||
this.pianoBusGainScale = clamp(targetScale, 0, 1);
|
||||
this.pianoBusGainScaleTimeConstantSeconds = timeConstantSeconds;
|
||||
this.pianoBusGainScaleAutomationUntil = now + timeConstantSeconds * 4;
|
||||
this.updatePianoBusGains(this.lastPianoBusActivity, now, timeConstantSeconds);
|
||||
}
|
||||
|
||||
public async close(): Promise<void> {
|
||||
const context = this.context;
|
||||
if (!context) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.masterGain && context.state !== 'closed') {
|
||||
this.masterGain.gain.setTargetAtTime(
|
||||
graphTuning.closeGain,
|
||||
context.currentTime,
|
||||
graphTuning.closeRampSeconds
|
||||
);
|
||||
}
|
||||
|
||||
this.clearNodes();
|
||||
|
||||
if (context.state !== 'closed') {
|
||||
await context.close().catch(() => undefined);
|
||||
}
|
||||
|
||||
this.restoreAudioSessionType();
|
||||
}
|
||||
|
||||
private restoreAudioSessionType(): void {
|
||||
const previousType = this.previousAudioSessionType;
|
||||
this.previousAudioSessionType = null;
|
||||
if (previousType === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const audioSession = (navigator as NavigatorWithAudioSession).audioSession;
|
||||
if (audioSession) {
|
||||
audioSession.type = previousType;
|
||||
}
|
||||
}
|
||||
|
||||
private createDelay(context: AudioContext, masterGain: GainNode): void {
|
||||
const delayInput = context.createGain();
|
||||
const delayNode = context.createDelay(graphTuning.delayMaxSeconds);
|
||||
const delayFeedback = context.createGain();
|
||||
const delayOutput = context.createGain();
|
||||
const feedbackHighPass = context.createBiquadFilter();
|
||||
const feedbackLowPass = context.createBiquadFilter();
|
||||
const returnLowPass = context.createBiquadFilter();
|
||||
|
||||
delayNode.delayTime.value = this.getDelayTimeSecondsForBpm(this.config.rhythm.bpm);
|
||||
delayFeedback.gain.value = this.config.delay.feedback;
|
||||
delayOutput.gain.value = this.config.delay.wetGain;
|
||||
feedbackHighPass.type = 'highpass';
|
||||
feedbackHighPass.frequency.value = delayFilterTuning.feedbackHighPassHz;
|
||||
feedbackLowPass.type = 'lowpass';
|
||||
feedbackLowPass.frequency.value = delayFilterTuning.feedbackLowPassHz;
|
||||
returnLowPass.type = 'lowpass';
|
||||
returnLowPass.frequency.value = delayFilterTuning.returnLowPassHz;
|
||||
|
||||
delayInput.connect(delayNode);
|
||||
delayNode.connect(feedbackHighPass);
|
||||
feedbackHighPass.connect(feedbackLowPass);
|
||||
feedbackLowPass.connect(delayFeedback);
|
||||
delayFeedback.connect(delayNode);
|
||||
delayNode.connect(returnLowPass);
|
||||
returnLowPass.connect(delayOutput);
|
||||
delayOutput.connect(masterGain);
|
||||
|
||||
this.delayInput = delayInput;
|
||||
this.delayNode = delayNode;
|
||||
this.delayFeedback = delayFeedback;
|
||||
this.delayOutput = delayOutput;
|
||||
}
|
||||
|
||||
private createBuses(context: AudioContext, masterGain: GainNode): void {
|
||||
const eventBus = context.createGain();
|
||||
eventBus.gain.value = graphTuning.eventBusGain;
|
||||
eventBus.connect(masterGain);
|
||||
this.eventBus = eventBus;
|
||||
this.pianoBuses.clear();
|
||||
|
||||
(Object.keys(this.config.graph.pianoBusGains) as Array<PianoNoteRole>).forEach(
|
||||
(role) => {
|
||||
const bus = context.createGain();
|
||||
bus.gain.value = this.config.graph.pianoBusGains[role];
|
||||
bus.connect(eventBus);
|
||||
this.pianoBuses.set(role, bus);
|
||||
}
|
||||
);
|
||||
|
||||
this.noiseBus = context.createGain();
|
||||
this.noiseBus.gain.value = this.config.graph.noiseBusGain;
|
||||
this.noiseBus.connect(eventBus);
|
||||
}
|
||||
|
||||
private updatePianoBusGains(
|
||||
activity: number,
|
||||
now: number,
|
||||
timeConstantSeconds?: number
|
||||
): void {
|
||||
const effectiveTimeConstantSeconds =
|
||||
timeConstantSeconds ??
|
||||
(now < this.pianoBusGainScaleAutomationUntil
|
||||
? this.pianoBusGainScaleTimeConstantSeconds
|
||||
: this.config.updateRampSeconds);
|
||||
|
||||
this.lastPianoBusActivity = activity;
|
||||
this.pianoBuses.forEach((bus, role) => {
|
||||
const baseGain = this.config.graph.pianoBusGains[role];
|
||||
const ducking = this.config.graph.pianoBusActivityDucking[role];
|
||||
bus.gain.setTargetAtTime(
|
||||
Math.max(0, baseGain * (1 - activity * ducking) * this.pianoBusGainScale),
|
||||
now,
|
||||
effectiveTimeConstantSeconds
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
private getDelayTimeSecondsForBpm(bpm: number): number {
|
||||
const safeBpm = Number.isFinite(bpm) ? Math.max(1, bpm) : this.config.rhythm.bpm;
|
||||
return clamp(
|
||||
(60 / safeBpm) * this.config.delay.timeBeats,
|
||||
this.config.delay.timeMinSeconds,
|
||||
this.config.delay.timeMaxSeconds
|
||||
);
|
||||
}
|
||||
|
||||
private createNoiseBuffer(context: AudioContext): AudioBuffer {
|
||||
const buffer = context.createBuffer(
|
||||
1,
|
||||
Math.floor(context.sampleRate * noiseBufferDurationSeconds),
|
||||
context.sampleRate
|
||||
);
|
||||
const data = buffer.getChannelData(0);
|
||||
|
||||
for (let index = 0; index < data.length; index++) {
|
||||
data[index] =
|
||||
graphTuning.noiseMin +
|
||||
Math.random() * (graphTuning.noiseMax - graphTuning.noiseMin);
|
||||
}
|
||||
|
||||
return buffer;
|
||||
}
|
||||
|
||||
private clearNodes(): void {
|
||||
this.context = null;
|
||||
this.eventBus = null;
|
||||
this.delayInput = null;
|
||||
this.noiseBus = null;
|
||||
this.noiseBuffer = null;
|
||||
this.masterGain = null;
|
||||
this.delayNode = null;
|
||||
this.delayFeedback = null;
|
||||
this.delayOutput = null;
|
||||
this.lastPianoBusActivity = 0;
|
||||
this.pianoBusGainScale = 1;
|
||||
this.pianoBusGainScaleAutomationUntil = 0;
|
||||
this.pianoBusGainScaleTimeConstantSeconds = 0;
|
||||
this.pianoBuses.clear();
|
||||
}
|
||||
}
|
||||
27
src/audio/garden-audio-input.ts
Normal file
27
src/audio/garden-audio-input.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import type { GardenAudioStroke } from './garden-audio-types';
|
||||
|
||||
const minElapsedSeconds = 0.001;
|
||||
|
||||
export interface GardenAudioStrokeMetrics {
|
||||
elapsedSeconds: number;
|
||||
normalizedDistance: number;
|
||||
normalizedSpeed: number;
|
||||
}
|
||||
|
||||
export const getStrokeMetrics = (stroke: GardenAudioStroke): GardenAudioStrokeMetrics => {
|
||||
const dx = stroke.to[0] - stroke.from[0];
|
||||
const dy = stroke.to[1] - stroke.from[1];
|
||||
const distancePixels = Math.hypot(dx, dy);
|
||||
const elapsedSeconds = Math.max(minElapsedSeconds, stroke.elapsedSeconds ?? 0);
|
||||
const normalizationPixels = Math.max(
|
||||
1,
|
||||
Math.min(stroke.canvasSize[0], stroke.canvasSize[1])
|
||||
);
|
||||
const normalizedDistance = distancePixels / normalizationPixels;
|
||||
|
||||
return {
|
||||
elapsedSeconds,
|
||||
normalizedDistance,
|
||||
normalizedSpeed: normalizedDistance / elapsedSeconds,
|
||||
};
|
||||
};
|
||||
33
src/audio/garden-audio-music.ts
Normal file
33
src/audio/garden-audio-music.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import type { VibePreset } from '../vibes';
|
||||
import type { GardenAudioChord, GardenAudioVibeProfile } from './garden-audio-config';
|
||||
|
||||
export const PITCH_SEMITONES_PER_OCTAVE = 12;
|
||||
|
||||
const DEFAULT_PROGRESSION: ReadonlyArray<GardenAudioChord> = [
|
||||
{ rootOffset: 0, quality: 'major' },
|
||||
{ rootOffset: 9, quality: 'minor' },
|
||||
{ rootOffset: 5, quality: 'major' },
|
||||
{ rootOffset: 7, quality: 'major' },
|
||||
];
|
||||
|
||||
const DEFAULT_ROOT_MIDI = 57;
|
||||
const DEFAULT_SCALE: ReadonlyArray<number> = [0, 2, 4, 7, 9];
|
||||
|
||||
const getProfileScale = (vibe: VibePreset): Array<number> => {
|
||||
const scale = vibe.audio.scale?.length ? vibe.audio.scale : DEFAULT_SCALE;
|
||||
return [...scale];
|
||||
};
|
||||
|
||||
const getProfileProgression = (vibe: VibePreset): Array<GardenAudioChord> =>
|
||||
(vibe.audio.progression?.length ? vibe.audio.progression : DEFAULT_PROGRESSION).map(
|
||||
(chord) => ({ ...chord })
|
||||
);
|
||||
|
||||
export const getVibeProfile = (vibe: VibePreset): GardenAudioVibeProfile => {
|
||||
return {
|
||||
...vibe.audio,
|
||||
rootMidi: DEFAULT_ROOT_MIDI + vibe.audio.notePitchOffset,
|
||||
scale: getProfileScale(vibe),
|
||||
progression: getProfileProgression(vibe),
|
||||
};
|
||||
};
|
||||
48
src/audio/garden-audio-types.ts
Normal file
48
src/audio/garden-audio-types.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import type { VibePreset } from '../vibes';
|
||||
|
||||
export interface GardenAudioSnapshot {
|
||||
vibe: VibePreset;
|
||||
isErasing: boolean;
|
||||
}
|
||||
|
||||
export interface GardenAudioStroke {
|
||||
vibe: VibePreset;
|
||||
from: ArrayLike<number>;
|
||||
to: ArrayLike<number>;
|
||||
canvasSize: ArrayLike<number>;
|
||||
isErasing: boolean;
|
||||
elapsedSeconds: number;
|
||||
}
|
||||
|
||||
export interface LoadedPianoSample {
|
||||
midi: number;
|
||||
buffer: AudioBuffer;
|
||||
}
|
||||
|
||||
export interface PianoNote {
|
||||
midi: number;
|
||||
velocity: number;
|
||||
startTime: number;
|
||||
durationSeconds: number;
|
||||
pan: number;
|
||||
role?: PianoNoteRole;
|
||||
delaySend?: number;
|
||||
lowpassHz?: number;
|
||||
sustainSeconds?: number;
|
||||
}
|
||||
|
||||
export type PianoNoteRole =
|
||||
| 'pad'
|
||||
| 'support'
|
||||
| 'texture'
|
||||
| 'gesture'
|
||||
| 'brush'
|
||||
| 'stinger';
|
||||
|
||||
export interface NoiseBurst {
|
||||
startTime: number;
|
||||
durationSeconds: number;
|
||||
gain: number;
|
||||
filterHz: number;
|
||||
pan: number;
|
||||
}
|
||||
448
src/audio/garden-audio.ts
Normal file
448
src/audio/garden-audio.ts
Normal file
|
|
@ -0,0 +1,448 @@
|
|||
import { ErrorHandler, Severity } from '../utils/error-handler';
|
||||
import { clamp01 } from '../utils/math';
|
||||
import type { VibeId, VibePreset } from '../vibes';
|
||||
import {
|
||||
SILENT_AUDIO_GAIN,
|
||||
type GardenAudioConfig,
|
||||
type GardenAudioVibeProfile,
|
||||
} from './garden-audio-config';
|
||||
import { GardenAudioEnergy } from './garden-audio-energy';
|
||||
import { GardenAudioGestureState } from './garden-audio-gesture-state';
|
||||
import { GardenAudioGraph } from './garden-audio-graph';
|
||||
import { getStrokeMetrics } from './garden-audio-input';
|
||||
import { getVibeProfile } from './garden-audio-music';
|
||||
import type { GardenAudioSnapshot, GardenAudioStroke } from './garden-audio-types';
|
||||
import { GenerativePianoEngine } from './generative-piano';
|
||||
import { NoiseBurstPlayer } from './noise-burst-player';
|
||||
import { PianoSampler } from './piano-sampler';
|
||||
|
||||
type AudioLifecycle = 'idle' | 'started' | 'destroyed';
|
||||
type PianoReleasePhase =
|
||||
| { kind: 'idle' }
|
||||
| { kind: 'awaiting-fade' }
|
||||
| { kind: 'scheduled-fade'; fadeAt: number }
|
||||
| { kind: 'settling'; stopAt: number };
|
||||
|
||||
const muteRampSeconds = 0.02;
|
||||
const brushUpPianoBusFadeSeconds = 2.4;
|
||||
const brushUpPianoBusFadeSettleSeconds = 3.2;
|
||||
const vibeChangeStingerMinIntervalSeconds = 0.45;
|
||||
|
||||
export class GardenAudio {
|
||||
private readonly graph: GardenAudioGraph;
|
||||
private readonly piano: PianoSampler;
|
||||
private readonly noise: NoiseBurstPlayer;
|
||||
private readonly energy: GardenAudioEnergy;
|
||||
private readonly gestureState: GardenAudioGestureState;
|
||||
private readonly pianoEngine: GenerativePianoEngine;
|
||||
|
||||
private currentVibeId: VibeId | null = null;
|
||||
private currentVibe: VibePreset | null = null;
|
||||
private lifecycle: AudioLifecycle = 'idle';
|
||||
private pianoReleasePhase: PianoReleasePhase = { kind: 'idle' };
|
||||
private isMuted = false;
|
||||
private isGestureActive = false;
|
||||
private masterVolume: number;
|
||||
private lastEraserAt = Number.NEGATIVE_INFINITY;
|
||||
private lastVibeStingerAt = Number.NEGATIVE_INFINITY;
|
||||
private startRequestId = 0;
|
||||
private hasLoadedPiano = false;
|
||||
|
||||
public constructor(private readonly config: GardenAudioConfig) {
|
||||
this.masterVolume = clamp01(config.masterVolume);
|
||||
this.graph = new GardenAudioGraph(config);
|
||||
this.piano = new PianoSampler(config, this.graph);
|
||||
this.noise = new NoiseBurstPlayer(this.graph);
|
||||
this.energy = new GardenAudioEnergy(config);
|
||||
this.gestureState = new GardenAudioGestureState(config.input);
|
||||
this.pianoEngine = new GenerativePianoEngine(config, (note) => this.piano.play(note));
|
||||
}
|
||||
|
||||
public start(vibe: VibePreset, options: { userGesture?: boolean } = {}): void {
|
||||
const isUserGesture = options.userGesture === true;
|
||||
|
||||
if (this.lifecycle === 'destroyed') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
this.lifecycle === 'started' &&
|
||||
this.currentVibeId === vibe.id &&
|
||||
this.graph.context?.state === 'running' &&
|
||||
this.hasLoadedPiano
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const context = this.graph.ensureContext(isUserGesture);
|
||||
if (!context) {
|
||||
return;
|
||||
}
|
||||
|
||||
const startupRampSeconds = isUserGesture
|
||||
? muteRampSeconds
|
||||
: this.config.fadeInSeconds;
|
||||
const needsResume = context.state !== 'running' && context.state !== 'closed';
|
||||
const startRequestId = ++this.startRequestId;
|
||||
|
||||
if (needsResume) {
|
||||
if (!isUserGesture) {
|
||||
return;
|
||||
}
|
||||
void context
|
||||
.resume()
|
||||
.then(() => {
|
||||
if (this.graph.context === context && this.lifecycle !== 'destroyed') {
|
||||
this.completeStart(vibe, { context, startupRampSeconds, startRequestId });
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
ErrorHandler.addException(error, {
|
||||
fallbackMessage: 'Could not resume audio playback.',
|
||||
severity: Severity.WARNING,
|
||||
});
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.completeStart(vibe, { context, startupRampSeconds, startRequestId });
|
||||
}
|
||||
|
||||
private completeStart(
|
||||
vibe: VibePreset,
|
||||
{
|
||||
context,
|
||||
startRequestId,
|
||||
startupRampSeconds,
|
||||
}: {
|
||||
context: AudioContext;
|
||||
startRequestId: number;
|
||||
startupRampSeconds: number;
|
||||
}
|
||||
): void {
|
||||
if (this.graph.context !== context || this.lifecycle === 'destroyed') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.isMuted) {
|
||||
this.activateMutedStart(vibe, context);
|
||||
this.graph.setMasterGain(SILENT_AUDIO_GAIN, muteRampSeconds);
|
||||
return;
|
||||
}
|
||||
|
||||
void this.piano
|
||||
.load(context)
|
||||
.then(() => {
|
||||
if (!this.canCompleteStart(context, startRequestId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.activateStart(vibe, context, startupRampSeconds, true);
|
||||
})
|
||||
.catch((error) => {
|
||||
if (this.canCompleteStart(context, startRequestId)) {
|
||||
this.activateStart(vibe, context, startupRampSeconds, false);
|
||||
}
|
||||
ErrorHandler.addException(error, {
|
||||
fallbackMessage: 'Could not load piano samples.',
|
||||
severity: Severity.WARNING,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private canCompleteStart(context: AudioContext, startRequestId: number): boolean {
|
||||
return (
|
||||
this.graph.context === context &&
|
||||
this.lifecycle !== 'destroyed' &&
|
||||
!this.isMuted &&
|
||||
this.startRequestId === startRequestId
|
||||
);
|
||||
}
|
||||
|
||||
private activateStart(
|
||||
vibe: VibePreset,
|
||||
context: AudioContext,
|
||||
startupRampSeconds: number,
|
||||
cuePiano: boolean
|
||||
): void {
|
||||
this.lifecycle = 'started';
|
||||
this.currentVibeId = vibe.id;
|
||||
this.currentVibe = vibe;
|
||||
const profile = getVibeProfile(vibe);
|
||||
this.graph.applyDelayProfile(profile.bpm);
|
||||
this.graph.setMasterGain(this.masterVolume, startupRampSeconds);
|
||||
|
||||
if (cuePiano) {
|
||||
this.hasLoadedPiano = true;
|
||||
this.pianoEngine.cue(context.currentTime, profile);
|
||||
}
|
||||
}
|
||||
|
||||
private activateMutedStart(vibe: VibePreset, context: AudioContext): void {
|
||||
this.lifecycle = 'started';
|
||||
this.currentVibeId = vibe.id;
|
||||
this.currentVibe = vibe;
|
||||
this.hasLoadedPiano = false;
|
||||
this.graph.applyDelayProfile(getVibeProfile(vibe).bpm);
|
||||
if (this.graph.context === context) {
|
||||
this.pianoEngine.reset();
|
||||
}
|
||||
}
|
||||
|
||||
public changeVibe(vibe: VibePreset, options: { userGesture?: boolean } = {}): void {
|
||||
const previousVibeId = this.currentVibeId;
|
||||
this.start(vibe, options);
|
||||
const didChangeVibe = previousVibeId !== null && previousVibeId !== vibe.id;
|
||||
|
||||
if (didChangeVibe) {
|
||||
this.piano.stopAll();
|
||||
this.hasLoadedPiano = false;
|
||||
}
|
||||
|
||||
const context = this.graph.context;
|
||||
if (
|
||||
context &&
|
||||
(context.state === 'running' || options.userGesture === true) &&
|
||||
!this.isMuted &&
|
||||
this.lifecycle !== 'destroyed' &&
|
||||
didChangeVibe
|
||||
) {
|
||||
this.playVibeChangeStinger(vibe);
|
||||
}
|
||||
}
|
||||
|
||||
public setMuted(isMuted: boolean): void {
|
||||
if (this.isMuted === isMuted) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isMuted = isMuted;
|
||||
this.graph.setMasterGain(
|
||||
isMuted ? SILENT_AUDIO_GAIN : this.masterVolume,
|
||||
isMuted ? muteRampSeconds : this.config.fadeInSeconds
|
||||
);
|
||||
|
||||
if (!isMuted && this.currentVibe && !this.hasLoadedPiano) {
|
||||
this.start(this.currentVibe);
|
||||
}
|
||||
}
|
||||
|
||||
public setMasterVolume(masterVolume: number): void {
|
||||
this.masterVolume = clamp01(masterVolume);
|
||||
if (!this.isMuted) {
|
||||
this.graph.setMasterGain(this.masterVolume, this.config.updateRampSeconds);
|
||||
}
|
||||
}
|
||||
|
||||
public beginGesture(): void {
|
||||
const context = this.graph.context;
|
||||
if (!context) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isGestureActive = true;
|
||||
this.pianoReleasePhase = { kind: 'idle' };
|
||||
this.graph.setPianoBusGainScale(1, this.config.fadeInSeconds);
|
||||
this.gestureState.reset();
|
||||
this.energy.beginGesture(context.currentTime);
|
||||
this.pianoEngine.beginGesture();
|
||||
}
|
||||
|
||||
public endGesture(): void {
|
||||
this.gestureState.reset();
|
||||
this.isGestureActive = false;
|
||||
this.pianoReleasePhase = { kind: 'awaiting-fade' };
|
||||
this.energy.endGesture();
|
||||
this.pianoEngine.endGesture();
|
||||
}
|
||||
|
||||
public update(snapshot: GardenAudioSnapshot): void {
|
||||
const context = this.graph.context;
|
||||
if (this.lifecycle !== 'started' || !context || this.isMuted) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.applyVibe(snapshot.vibe);
|
||||
const profile = getVibeProfile(snapshot.vibe);
|
||||
this.energy.update(context.currentTime, profile);
|
||||
|
||||
if (snapshot.isErasing) {
|
||||
this.energy.silence();
|
||||
}
|
||||
|
||||
if (!this.isGestureActive && this.pianoReleasePhase.kind !== 'idle') {
|
||||
this.updatePianoRelease(snapshot.vibe, context.currentTime);
|
||||
this.updateDelay(snapshot, profile);
|
||||
return;
|
||||
}
|
||||
|
||||
this.pianoEngine.renderLookahead({
|
||||
vibe: snapshot.vibe,
|
||||
now: context.currentTime,
|
||||
activity: snapshot.isErasing
|
||||
? this.config.eraser.pianoActivity
|
||||
: this.energy.getLevel(),
|
||||
});
|
||||
this.updateDelay(snapshot, profile);
|
||||
}
|
||||
|
||||
public stroke(stroke: GardenAudioStroke): void {
|
||||
if (this.lifecycle !== 'started' || this.isMuted) {
|
||||
return;
|
||||
}
|
||||
|
||||
const context = this.graph.context;
|
||||
if (!context) {
|
||||
return;
|
||||
}
|
||||
if (!this.isGestureActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
const metrics = getStrokeMetrics(stroke);
|
||||
const now = context.currentTime;
|
||||
|
||||
const frame = this.gestureState.recordStroke({ metrics });
|
||||
const strokeEnergy = frame.activity;
|
||||
|
||||
if (stroke.isErasing) {
|
||||
this.playEraser(strokeEnergy, now);
|
||||
return;
|
||||
}
|
||||
|
||||
const profile = getVibeProfile(stroke.vibe);
|
||||
this.energy.recordStroke(strokeEnergy, profile);
|
||||
this.pianoEngine.recordStroke({
|
||||
vibe: stroke.vibe,
|
||||
now,
|
||||
activity: strokeEnergy,
|
||||
maniaAmount: frame.maniaAmount,
|
||||
});
|
||||
}
|
||||
|
||||
public async destroy(): Promise<void> {
|
||||
this.lifecycle = 'destroyed';
|
||||
await this.graph.close();
|
||||
|
||||
this.piano.reset();
|
||||
this.hasLoadedPiano = false;
|
||||
this.energy.reset();
|
||||
this.gestureState.reset();
|
||||
this.pianoEngine.reset();
|
||||
this.currentVibeId = null;
|
||||
this.currentVibe = null;
|
||||
this.isGestureActive = false;
|
||||
this.pianoReleasePhase = { kind: 'idle' };
|
||||
this.lastEraserAt = Number.NEGATIVE_INFINITY;
|
||||
this.lastVibeStingerAt = Number.NEGATIVE_INFINITY;
|
||||
}
|
||||
|
||||
private playVibeChangeStinger(vibe: VibePreset): void {
|
||||
const context = this.graph.context;
|
||||
if (!context) {
|
||||
return;
|
||||
}
|
||||
|
||||
const now = context.currentTime;
|
||||
if (now - this.lastVibeStingerAt < vibeChangeStingerMinIntervalSeconds) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.lastVibeStingerAt = now;
|
||||
this.pianoEngine.playVibeChangeStinger(vibe, now);
|
||||
}
|
||||
|
||||
private updatePianoRelease(vibe: VibePreset, now: number): void {
|
||||
if (this.pianoReleasePhase.kind === 'awaiting-fade') {
|
||||
const fadeAt = this.pianoEngine.release(vibe, now);
|
||||
if (now < fadeAt) {
|
||||
this.pianoReleasePhase = { kind: 'scheduled-fade', fadeAt };
|
||||
return;
|
||||
}
|
||||
|
||||
this.graph.setPianoBusGainScale(0, brushUpPianoBusFadeSeconds);
|
||||
this.pianoReleasePhase = {
|
||||
kind: 'settling',
|
||||
stopAt: now + brushUpPianoBusFadeSettleSeconds,
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
this.pianoReleasePhase.kind === 'scheduled-fade' &&
|
||||
now >= this.pianoReleasePhase.fadeAt
|
||||
) {
|
||||
this.graph.setPianoBusGainScale(0, brushUpPianoBusFadeSeconds);
|
||||
this.pianoReleasePhase = {
|
||||
kind: 'settling',
|
||||
stopAt: now + brushUpPianoBusFadeSettleSeconds,
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
this.pianoReleasePhase.kind === 'settling' &&
|
||||
now >= this.pianoReleasePhase.stopAt
|
||||
) {
|
||||
this.piano.stopAll();
|
||||
this.pianoEngine.reset();
|
||||
this.hasLoadedPiano = false;
|
||||
this.pianoReleasePhase = { kind: 'idle' };
|
||||
}
|
||||
}
|
||||
|
||||
private playEraser(activity: number, now: number): void {
|
||||
if (!this.graph.context) {
|
||||
return;
|
||||
}
|
||||
|
||||
const distanceActivity = clamp01(activity);
|
||||
if (distanceActivity <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const filterHz =
|
||||
this.config.eraser.filterMinHz +
|
||||
(this.config.eraser.filterMaxHz - this.config.eraser.filterMinHz) *
|
||||
distanceActivity;
|
||||
|
||||
if (now - this.lastEraserAt >= this.config.eraser.minIntervalSeconds) {
|
||||
this.lastEraserAt = now;
|
||||
this.noise.play({
|
||||
startTime: now,
|
||||
durationSeconds: this.config.eraser.durationSeconds,
|
||||
gain: this.config.eraser.noiseGain * distanceActivity,
|
||||
filterHz,
|
||||
pan: this.config.eraser.pan,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private updateDelay(
|
||||
snapshot: GardenAudioSnapshot,
|
||||
profile: GardenAudioVibeProfile
|
||||
): void {
|
||||
const context = this.graph.context;
|
||||
if (!context) {
|
||||
return;
|
||||
}
|
||||
|
||||
const activity = snapshot.isErasing
|
||||
? this.config.delay.erasingActivity
|
||||
: this.energy.getLevel();
|
||||
this.graph.updateDelay(activity, profile.bpm);
|
||||
}
|
||||
|
||||
private applyVibe(vibe: VibePreset): void {
|
||||
if (!this.graph.context || this.currentVibeId === vibe.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.currentVibeId = vibe.id;
|
||||
this.currentVibe = vibe;
|
||||
const profile = getVibeProfile(vibe);
|
||||
this.graph.applyDelayProfile(profile.bpm);
|
||||
this.pianoEngine.cue(this.graph.context.currentTime, profile);
|
||||
this.hasLoadedPiano = true;
|
||||
}
|
||||
}
|
||||
443
src/audio/generative-piano-tuning.ts
Normal file
443
src/audio/generative-piano-tuning.ts
Normal file
|
|
@ -0,0 +1,443 @@
|
|||
export interface GardenAudioRegister {
|
||||
midiMin: number;
|
||||
midiMax: number;
|
||||
preferredMidi: number;
|
||||
pan: number;
|
||||
}
|
||||
|
||||
export interface GardenAudioStylePool extends GardenAudioRegister {
|
||||
scaleDegrees: Array<number>;
|
||||
}
|
||||
|
||||
interface GardenAudioStyleVoice {
|
||||
scaleDegreeOffset: number;
|
||||
velocityMultiplier: number;
|
||||
panOffset: number;
|
||||
}
|
||||
|
||||
interface GenerativePianoTuning {
|
||||
stylePools: [GardenAudioStylePool, GardenAudioStylePool, GardenAudioStylePool];
|
||||
padRegisters: [GardenAudioRegister, GardenAudioRegister, GardenAudioRegister];
|
||||
vibeChangeStinger: {
|
||||
velocities: [number, number, number];
|
||||
pans: [number, number, number];
|
||||
delaySends: [number, number, number];
|
||||
lowpassExpression: number;
|
||||
noteDurationSeconds: number;
|
||||
spacingSeconds: number;
|
||||
};
|
||||
releaseResolution: {
|
||||
durationSeconds: number;
|
||||
fadeAfterSeconds: number;
|
||||
velocities: [number, number, number];
|
||||
delaySend: number;
|
||||
lowpassExpression: number;
|
||||
strumSeconds: number;
|
||||
};
|
||||
highActivityExtra: {
|
||||
barOffset: number;
|
||||
expressionMultiplier: number;
|
||||
};
|
||||
padChord: {
|
||||
velocities: [number, number, number];
|
||||
expressionVelocityWeight: number;
|
||||
delaySend: number;
|
||||
lowpassExpressionWeight: number;
|
||||
};
|
||||
supportNote: {
|
||||
velocityBase: number;
|
||||
velocityExpressionWeight: number;
|
||||
durationBaseSeconds: number;
|
||||
durationExpressionSeconds: number;
|
||||
delaySendBase: number;
|
||||
delaySendExpressionWeight: number;
|
||||
lowpassExpressionWeight: number;
|
||||
expressionThreshold: number;
|
||||
offsetsByStyle: [Array<number>, Array<number>, Array<number>];
|
||||
};
|
||||
textureNote: {
|
||||
velocityBase: number;
|
||||
velocityExpressionWeight: number;
|
||||
durationBaseSeconds: number;
|
||||
durationExpressionSeconds: number;
|
||||
delaySendBase: number;
|
||||
delaySendExpressionWeight: number;
|
||||
idleExpressionThreshold: number;
|
||||
mediumExpressionThreshold: number;
|
||||
intenseSpacing: number;
|
||||
idlePhase: number;
|
||||
};
|
||||
gestureAccent: {
|
||||
rotationStrengthMultiplier: number;
|
||||
quantizeStepLookahead: number;
|
||||
velocityBase: number;
|
||||
velocityStrengthWeight: number;
|
||||
durationBaseSeconds: number;
|
||||
durationStrengthSeconds: number;
|
||||
delaySend: number;
|
||||
};
|
||||
touchNote: {
|
||||
velocityBase: number;
|
||||
velocityStrengthWeight: number;
|
||||
durationBaseSeconds: number;
|
||||
durationStrengthSeconds: number;
|
||||
delaySend: number;
|
||||
lowpassBaseExpression: number;
|
||||
lowpassStrengthWeight: number;
|
||||
};
|
||||
brushPhrase: {
|
||||
initialMotifOffset: number;
|
||||
energyDecaySeconds: number;
|
||||
maniaDecaySeconds: number;
|
||||
layerIntensityBase: number;
|
||||
layerIntensityManiaWeight: number;
|
||||
frameActivityWeight: number;
|
||||
frameManiaWeight: number;
|
||||
};
|
||||
brushStream: {
|
||||
inferredManiaThreshold: number;
|
||||
inferredManiaRange: number;
|
||||
registerManiaShift: number;
|
||||
chordToneEverySteps: number;
|
||||
durationBaseSeconds: number;
|
||||
durationIntensitySeconds: number;
|
||||
durationManiaSeconds: number;
|
||||
durationMinSeconds: number;
|
||||
durationMaxSeconds: number;
|
||||
delaySendBase: number;
|
||||
delaySendIntensityWeight: number;
|
||||
delaySendManiaWeight: number;
|
||||
delaySendMin: number;
|
||||
delaySendMax: number;
|
||||
velocityBase: number;
|
||||
velocityIntensityWeight: number;
|
||||
lowpassBaseExpression: number;
|
||||
lowpassIntensityWeight: number;
|
||||
lowpassManiaWeight: number;
|
||||
intenseThreshold: number;
|
||||
activeThreshold: number;
|
||||
};
|
||||
brushStreamEcho: {
|
||||
maniaThreshold: number;
|
||||
stepModulo: number;
|
||||
stepRemainder: number;
|
||||
intensityThreshold: number;
|
||||
octaveSemitones: number;
|
||||
maxMidi: number;
|
||||
velocityBase: number;
|
||||
velocityIntensityWeight: number;
|
||||
durationMinSeconds: number;
|
||||
durationScale: number;
|
||||
panScale: number;
|
||||
delaySendMin: number;
|
||||
delaySendScale: number;
|
||||
lowpassBaseExpression: number;
|
||||
lowpassManiaWeight: number;
|
||||
};
|
||||
brushMotif: {
|
||||
highThreshold: number;
|
||||
mediumThreshold: number;
|
||||
highOffset: number;
|
||||
mediumOffset: number;
|
||||
lowOffset: number;
|
||||
};
|
||||
registerBias: {
|
||||
maniaShiftSemitones: number;
|
||||
midiMin: number;
|
||||
midiMaxForMin: number;
|
||||
minimumSpan: number;
|
||||
midiMax: number;
|
||||
};
|
||||
candidateOctaveSearch: {
|
||||
min: number;
|
||||
max: number;
|
||||
};
|
||||
stereoWidth: {
|
||||
idle: number;
|
||||
active: number;
|
||||
intense: number;
|
||||
intenseThreshold: number;
|
||||
};
|
||||
stylePanOffsetScale: number;
|
||||
lowpass: {
|
||||
midiBase: number;
|
||||
midiRange: number;
|
||||
midiLiftHz: number;
|
||||
expressionBase: number;
|
||||
expressionWeight: number;
|
||||
};
|
||||
styleRotationBars: number;
|
||||
chordBars: number;
|
||||
supportBarSpacing: number;
|
||||
supportBarOffset: number;
|
||||
idleTextureBarSpacing: number;
|
||||
mediumTextureBarSpacing: number;
|
||||
textureBeat: number;
|
||||
highActivityExtraBeat: number;
|
||||
highActivityExtraThreshold: number;
|
||||
noteScorePreferenceWeight: number;
|
||||
noteScoreRegisterWeight: number;
|
||||
noteScoreChordToneWeight: number;
|
||||
noteScoreRepeatPenalty: number;
|
||||
gestureAccentMinIntervalSeconds: number;
|
||||
strokeAccentMinSteps: number;
|
||||
strokeAccentThreshold: number;
|
||||
maxBrushPhraseLayers: number;
|
||||
maxBrushStreamNotesPerBar: number;
|
||||
brushLayerBaseSeconds: number;
|
||||
brushLayerEnergySeconds: number;
|
||||
brushLayerMinIntensity: number;
|
||||
brushStreamIdleIntervalBeats: number;
|
||||
brushStreamActiveIntervalBeats: number;
|
||||
brushStreamIntenseIntervalBeats: number;
|
||||
brushMotifMaxSteps: number;
|
||||
brushMotifCanonDelaySeconds: number;
|
||||
padDurationBarScale: number;
|
||||
}
|
||||
|
||||
export const generativePianoTuning: GenerativePianoTuning = {
|
||||
stylePools: [
|
||||
{
|
||||
midiMin: 48,
|
||||
midiMax: 67,
|
||||
preferredMidi: 55,
|
||||
pan: -0.18,
|
||||
scaleDegrees: [0, 1, 2, 4],
|
||||
},
|
||||
{
|
||||
midiMin: 55,
|
||||
midiMax: 74,
|
||||
preferredMidi: 63,
|
||||
pan: 0,
|
||||
scaleDegrees: [1, 2, 3, 5],
|
||||
},
|
||||
{
|
||||
midiMin: 62,
|
||||
midiMax: 78,
|
||||
preferredMidi: 70,
|
||||
pan: 0.18,
|
||||
scaleDegrees: [2, 3, 4, 6],
|
||||
},
|
||||
],
|
||||
padRegisters: [
|
||||
{
|
||||
midiMin: 40,
|
||||
midiMax: 55,
|
||||
preferredMidi: 48,
|
||||
pan: -0.12,
|
||||
},
|
||||
{
|
||||
midiMin: 48,
|
||||
midiMax: 64,
|
||||
preferredMidi: 55,
|
||||
pan: 0.08,
|
||||
},
|
||||
{
|
||||
midiMin: 58,
|
||||
midiMax: 76,
|
||||
preferredMidi: 67,
|
||||
pan: 0.2,
|
||||
},
|
||||
],
|
||||
vibeChangeStinger: {
|
||||
velocities: [0.1, 0.085, 0.07],
|
||||
pans: [-0.16, 0, 0.16],
|
||||
delaySends: [0.012, 0.014, 0.016],
|
||||
lowpassExpression: 0.35,
|
||||
noteDurationSeconds: 1.1,
|
||||
spacingSeconds: 0.08,
|
||||
},
|
||||
releaseResolution: {
|
||||
durationSeconds: 3.4,
|
||||
fadeAfterSeconds: 2.4,
|
||||
velocities: [0.064, 0.05, 0.038],
|
||||
delaySend: 0.018,
|
||||
lowpassExpression: 0.34,
|
||||
strumSeconds: 0.055,
|
||||
},
|
||||
highActivityExtra: {
|
||||
barOffset: 1,
|
||||
expressionMultiplier: 0.9,
|
||||
},
|
||||
padChord: {
|
||||
velocities: [0.046, 0.036, 0.029],
|
||||
expressionVelocityWeight: 0.018,
|
||||
delaySend: 0.008,
|
||||
lowpassExpressionWeight: 0.24,
|
||||
},
|
||||
supportNote: {
|
||||
velocityBase: 0.105,
|
||||
velocityExpressionWeight: 0.07,
|
||||
durationBaseSeconds: 1.35,
|
||||
durationExpressionSeconds: 0.4,
|
||||
delaySendBase: 0.016,
|
||||
delaySendExpressionWeight: 0.006,
|
||||
lowpassExpressionWeight: 0.7,
|
||||
expressionThreshold: 0.55,
|
||||
offsetsByStyle: [
|
||||
[0, 2, 12],
|
||||
[1, 2, 0, 12],
|
||||
[2, 12, 3, 13],
|
||||
],
|
||||
},
|
||||
textureNote: {
|
||||
velocityBase: 0.09,
|
||||
velocityExpressionWeight: 0.08,
|
||||
durationBaseSeconds: 0.62,
|
||||
durationExpressionSeconds: 0.24,
|
||||
delaySendBase: 0.016,
|
||||
delaySendExpressionWeight: 0.006,
|
||||
idleExpressionThreshold: 0.35,
|
||||
mediumExpressionThreshold: 0.7,
|
||||
intenseSpacing: 1,
|
||||
idlePhase: 1,
|
||||
},
|
||||
gestureAccent: {
|
||||
rotationStrengthMultiplier: 3,
|
||||
quantizeStepLookahead: 1,
|
||||
velocityBase: 0.12,
|
||||
velocityStrengthWeight: 0.09,
|
||||
durationBaseSeconds: 0.48,
|
||||
durationStrengthSeconds: 0.22,
|
||||
delaySend: 0.012,
|
||||
},
|
||||
touchNote: {
|
||||
velocityBase: 0.14,
|
||||
velocityStrengthWeight: 0.11,
|
||||
durationBaseSeconds: 0.55,
|
||||
durationStrengthSeconds: 0.18,
|
||||
delaySend: 0.006,
|
||||
lowpassBaseExpression: 0.55,
|
||||
lowpassStrengthWeight: 0.35,
|
||||
},
|
||||
brushPhrase: {
|
||||
initialMotifOffset: -1,
|
||||
energyDecaySeconds: 0.72,
|
||||
maniaDecaySeconds: 0.54,
|
||||
layerIntensityBase: 0.8,
|
||||
layerIntensityManiaWeight: 0.42,
|
||||
frameActivityWeight: 0.42,
|
||||
frameManiaWeight: 0.18,
|
||||
},
|
||||
brushStream: {
|
||||
inferredManiaThreshold: 0.82,
|
||||
inferredManiaRange: 0.18,
|
||||
registerManiaShift: 0.3,
|
||||
chordToneEverySteps: 4,
|
||||
durationBaseSeconds: 0.48,
|
||||
durationIntensitySeconds: 0.08,
|
||||
durationManiaSeconds: 0.34,
|
||||
durationMinSeconds: 0.14,
|
||||
durationMaxSeconds: 0.62,
|
||||
delaySendBase: 0.012,
|
||||
delaySendIntensityWeight: 0.011,
|
||||
delaySendManiaWeight: 0.006,
|
||||
delaySendMin: 0.006,
|
||||
delaySendMax: 0.032,
|
||||
velocityBase: 0.1,
|
||||
velocityIntensityWeight: 0.1,
|
||||
lowpassBaseExpression: 0.39,
|
||||
lowpassIntensityWeight: 0.48,
|
||||
lowpassManiaWeight: 0.18,
|
||||
intenseThreshold: 0.68,
|
||||
activeThreshold: 0.34,
|
||||
},
|
||||
brushStreamEcho: {
|
||||
maniaThreshold: 0.92,
|
||||
stepModulo: 3,
|
||||
stepRemainder: 1,
|
||||
intensityThreshold: 0.95,
|
||||
octaveSemitones: 12,
|
||||
maxMidi: 84,
|
||||
velocityBase: 0.035,
|
||||
velocityIntensityWeight: 0.04,
|
||||
durationMinSeconds: 0.11,
|
||||
durationScale: 0.68,
|
||||
panScale: -0.75,
|
||||
delaySendMin: 0.006,
|
||||
delaySendScale: 0.72,
|
||||
lowpassBaseExpression: 0.62,
|
||||
lowpassManiaWeight: 0.24,
|
||||
},
|
||||
brushMotif: {
|
||||
highThreshold: 0.82,
|
||||
mediumThreshold: 0.55,
|
||||
highOffset: 1,
|
||||
mediumOffset: 0,
|
||||
lowOffset: -1,
|
||||
},
|
||||
registerBias: {
|
||||
maniaShiftSemitones: 2,
|
||||
midiMin: 36,
|
||||
midiMaxForMin: 86,
|
||||
minimumSpan: 4,
|
||||
midiMax: 91,
|
||||
},
|
||||
candidateOctaveSearch: {
|
||||
min: -3,
|
||||
max: 3,
|
||||
},
|
||||
stereoWidth: {
|
||||
idle: 0.46,
|
||||
active: 0.9,
|
||||
intense: 1.16,
|
||||
intenseThreshold: 0.72,
|
||||
},
|
||||
stylePanOffsetScale: 0.35,
|
||||
lowpass: {
|
||||
midiBase: 48,
|
||||
midiRange: 33,
|
||||
midiLiftHz: 500,
|
||||
expressionBase: 0.58,
|
||||
expressionWeight: 0.32,
|
||||
},
|
||||
styleRotationBars: 2,
|
||||
chordBars: 4,
|
||||
supportBarSpacing: 2,
|
||||
supportBarOffset: 1,
|
||||
idleTextureBarSpacing: 2,
|
||||
mediumTextureBarSpacing: 1,
|
||||
textureBeat: 2,
|
||||
highActivityExtraBeat: 3,
|
||||
highActivityExtraThreshold: 0.45,
|
||||
noteScorePreferenceWeight: 1.8,
|
||||
noteScoreRegisterWeight: 0.28,
|
||||
noteScoreChordToneWeight: 0.75,
|
||||
noteScoreRepeatPenalty: 3.2,
|
||||
gestureAccentMinIntervalSeconds: 2.5,
|
||||
strokeAccentMinSteps: 12,
|
||||
strokeAccentThreshold: 0.58,
|
||||
maxBrushPhraseLayers: 3,
|
||||
maxBrushStreamNotesPerBar: 7,
|
||||
brushLayerBaseSeconds: 5.5,
|
||||
brushLayerEnergySeconds: 2.5,
|
||||
brushLayerMinIntensity: 0.12,
|
||||
brushStreamIdleIntervalBeats: 2,
|
||||
brushStreamActiveIntervalBeats: 1,
|
||||
brushStreamIntenseIntervalBeats: 0.75,
|
||||
brushMotifMaxSteps: 8,
|
||||
brushMotifCanonDelaySeconds: 0.055,
|
||||
padDurationBarScale: 0.82,
|
||||
};
|
||||
|
||||
export const styleVoices: [
|
||||
GardenAudioStyleVoice,
|
||||
GardenAudioStyleVoice,
|
||||
GardenAudioStyleVoice,
|
||||
] = [
|
||||
{
|
||||
scaleDegreeOffset: 0,
|
||||
velocityMultiplier: 0.92,
|
||||
panOffset: -0.14,
|
||||
},
|
||||
{
|
||||
scaleDegreeOffset: 1,
|
||||
velocityMultiplier: 1,
|
||||
panOffset: 0,
|
||||
},
|
||||
{
|
||||
scaleDegreeOffset: 2,
|
||||
velocityMultiplier: 0.86,
|
||||
panOffset: 0.14,
|
||||
},
|
||||
];
|
||||
1349
src/audio/generative-piano.ts
Normal file
1349
src/audio/generative-piano.ts
Normal file
File diff suppressed because it is too large
Load diff
64
src/audio/noise-burst-player.ts
Normal file
64
src/audio/noise-burst-player.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import { clamp } from '../utils/math';
|
||||
import type { GardenAudioGraph } from './garden-audio-graph';
|
||||
import type { NoiseBurst } from './garden-audio-types';
|
||||
|
||||
const noiseBurstTuning = {
|
||||
attackSeconds: 0.004,
|
||||
filterQ: 1.4,
|
||||
offsetRandomSeconds: 0.4,
|
||||
scheduleAheadSeconds: 0.002,
|
||||
silentGain: 0.0001,
|
||||
filterType: 'bandpass',
|
||||
} as const;
|
||||
|
||||
export class NoiseBurstPlayer {
|
||||
public constructor(private readonly graph: GardenAudioGraph) {}
|
||||
|
||||
public play({ startTime, durationSeconds, gain, filterHz, pan }: NoiseBurst): void {
|
||||
const { context, noiseBus, noiseBuffer } = this.graph;
|
||||
if (!context || !noiseBus || !noiseBuffer) {
|
||||
return;
|
||||
}
|
||||
|
||||
const scheduledStart = Math.max(
|
||||
context.currentTime + noiseBurstTuning.scheduleAheadSeconds,
|
||||
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 = noiseBurstTuning.filterType;
|
||||
filter.frequency.setValueAtTime(filterHz, scheduledStart);
|
||||
filter.Q.value = noiseBurstTuning.filterQ;
|
||||
envelope.gain.setValueAtTime(noiseBurstTuning.silentGain, scheduledStart);
|
||||
envelope.gain.exponentialRampToValueAtTime(
|
||||
Math.max(noiseBurstTuning.silentGain, gain),
|
||||
scheduledStart + noiseBurstTuning.attackSeconds
|
||||
);
|
||||
envelope.gain.exponentialRampToValueAtTime(noiseBurstTuning.silentGain, stopAt);
|
||||
panner.pan.setValueAtTime(clamp(pan, -1, 1), scheduledStart);
|
||||
source.connect(filter);
|
||||
filter.connect(envelope);
|
||||
envelope.connect(panner);
|
||||
panner.connect(noiseBus);
|
||||
const maxOffsetSeconds = Math.max(0, noiseBuffer.duration - durationSeconds);
|
||||
const offsetSeconds =
|
||||
Math.random() * Math.min(noiseBurstTuning.offsetRandomSeconds, maxOffsetSeconds);
|
||||
source.start(scheduledStart, offsetSeconds);
|
||||
source.stop(stopAt);
|
||||
source.addEventListener(
|
||||
'ended',
|
||||
() => {
|
||||
source.disconnect();
|
||||
filter.disconnect();
|
||||
envelope.disconnect();
|
||||
panner.disconnect();
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
}
|
||||
}
|
||||
264
src/audio/piano-sampler.ts
Normal file
264
src/audio/piano-sampler.ts
Normal file
|
|
@ -0,0 +1,264 @@
|
|||
import { clamp, clamp01 } from '../utils/math';
|
||||
import type { GardenAudioConfig } from './garden-audio-config';
|
||||
import type { GardenAudioGraph } from './garden-audio-graph';
|
||||
import { PITCH_SEMITONES_PER_OCTAVE } from './garden-audio-music';
|
||||
import type { LoadedPianoSample, PianoNote } from './garden-audio-types';
|
||||
import { getLoadedPianoSamples, loadPianoSamples } from './piano-samples';
|
||||
|
||||
export const PIANO_SCHEDULE_AHEAD_SECONDS = 0.002;
|
||||
|
||||
interface ActivePianoVoice {
|
||||
gain: GainNode;
|
||||
source: AudioScheduledSourceNode;
|
||||
stopAt: number;
|
||||
}
|
||||
|
||||
const pianoSamplerTuning = {
|
||||
filterType: 'lowpass',
|
||||
filterQ: 0.7,
|
||||
minDurationSeconds: 0.08,
|
||||
minFadeSeconds: 0.08,
|
||||
minGain: 0.0001,
|
||||
releaseTimeConstantCount: 5,
|
||||
tailStopExtraSeconds: 0.05,
|
||||
voiceStealFadeSeconds: 0.025,
|
||||
voiceStealStopSeconds: 0.05,
|
||||
} as const;
|
||||
|
||||
export class PianoSampler {
|
||||
private samples: Array<LoadedPianoSample> = [];
|
||||
private activeVoices: Array<ActivePianoVoice> = [];
|
||||
|
||||
public constructor(
|
||||
private readonly config: GardenAudioConfig,
|
||||
private readonly graph: GardenAudioGraph
|
||||
) {}
|
||||
|
||||
public load(context: BaseAudioContext): Promise<void> {
|
||||
if (this.samples.length > 0) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
const loadedSamples = getLoadedPianoSamples();
|
||||
if (loadedSamples) {
|
||||
this.setSamples(loadedSamples);
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
return loadPianoSamples(context).then((samples) => {
|
||||
this.setSamples(samples);
|
||||
});
|
||||
}
|
||||
|
||||
public play({
|
||||
midi,
|
||||
velocity,
|
||||
startTime,
|
||||
durationSeconds,
|
||||
pan,
|
||||
role,
|
||||
delaySend = 0,
|
||||
lowpassHz = this.config.piano.lowpassHz,
|
||||
sustainSeconds: profileSustainSeconds = this.config.piano.sustainSeconds,
|
||||
}: PianoNote): void {
|
||||
const { context } = this.graph;
|
||||
const eventBus = this.graph.getPianoBus(role);
|
||||
if (!context || !eventBus) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sample = this.findNearestSample(midi);
|
||||
if (!sample) {
|
||||
return;
|
||||
}
|
||||
|
||||
const scheduledStart = Math.max(
|
||||
context.currentTime + PIANO_SCHEDULE_AHEAD_SECONDS,
|
||||
startTime
|
||||
);
|
||||
const noteVelocity = clamp01(velocity);
|
||||
const noteGainValue = this.computeNoteGain(noteVelocity);
|
||||
const sustainSeconds =
|
||||
profileSustainSeconds *
|
||||
(this.config.piano.sustainBase +
|
||||
noteVelocity * this.config.piano.sustainVelocityRange);
|
||||
const sustainAt =
|
||||
scheduledStart + Math.max(pianoSamplerTuning.minDurationSeconds, durationSeconds);
|
||||
const releaseAt = sustainAt + sustainSeconds;
|
||||
const stopAt =
|
||||
releaseAt +
|
||||
this.config.piano.releaseSeconds * pianoSamplerTuning.releaseTimeConstantCount;
|
||||
const source = context.createBufferSource();
|
||||
|
||||
source.buffer = sample.buffer;
|
||||
source.playbackRate.setValueAtTime(
|
||||
Math.pow(2, (midi - sample.midi) / PITCH_SEMITONES_PER_OCTAVE),
|
||||
scheduledStart
|
||||
);
|
||||
|
||||
this.scheduleVoice({
|
||||
source,
|
||||
scheduledStart,
|
||||
stopAt,
|
||||
pan,
|
||||
lowpassHz,
|
||||
delaySend,
|
||||
eventBus,
|
||||
configureGainEnvelope: (gain) => {
|
||||
gain.gain.setValueAtTime(pianoSamplerTuning.minGain, scheduledStart);
|
||||
gain.gain.exponentialRampToValueAtTime(
|
||||
noteGainValue,
|
||||
scheduledStart + this.config.piano.gainAttackSeconds
|
||||
);
|
||||
gain.gain.setTargetAtTime(
|
||||
Math.max(
|
||||
pianoSamplerTuning.minGain,
|
||||
noteGainValue * this.config.piano.sustainLevel
|
||||
),
|
||||
sustainAt,
|
||||
Math.max(
|
||||
pianoSamplerTuning.minFadeSeconds,
|
||||
sustainSeconds * this.config.piano.sustainBase
|
||||
)
|
||||
);
|
||||
gain.gain.setTargetAtTime(
|
||||
pianoSamplerTuning.minGain,
|
||||
releaseAt,
|
||||
this.config.piano.releaseSeconds
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
public stopAll(): void {
|
||||
const context = this.graph.context;
|
||||
if (!context) {
|
||||
this.activeVoices = [];
|
||||
return;
|
||||
}
|
||||
|
||||
const now = context.currentTime;
|
||||
|
||||
this.activeVoices.forEach((voice) => {
|
||||
this.stopVoice(voice, now);
|
||||
});
|
||||
this.activeVoices = [];
|
||||
}
|
||||
|
||||
public reset(): void {
|
||||
this.samples = [];
|
||||
this.activeVoices = [];
|
||||
}
|
||||
|
||||
private scheduleVoice({
|
||||
source,
|
||||
scheduledStart,
|
||||
stopAt,
|
||||
pan,
|
||||
lowpassHz,
|
||||
delaySend,
|
||||
eventBus,
|
||||
configureGainEnvelope,
|
||||
}: {
|
||||
source: AudioScheduledSourceNode;
|
||||
scheduledStart: number;
|
||||
stopAt: number;
|
||||
pan: number;
|
||||
lowpassHz: number;
|
||||
delaySend: number;
|
||||
eventBus: GainNode;
|
||||
configureGainEnvelope: (gain: GainNode) => void;
|
||||
}): void {
|
||||
const { context, delayInput } = this.graph;
|
||||
if (!context) {
|
||||
return;
|
||||
}
|
||||
|
||||
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();
|
||||
if (!oldest) {
|
||||
break;
|
||||
}
|
||||
this.stopVoice(oldest, scheduledStart);
|
||||
}
|
||||
|
||||
filter.type = pianoSamplerTuning.filterType;
|
||||
filter.frequency.setValueAtTime(
|
||||
clamp(lowpassHz, this.config.piano.lowpassMinHz, this.config.piano.lowpassMaxHz),
|
||||
scheduledStart
|
||||
);
|
||||
filter.Q.value = pianoSamplerTuning.filterQ;
|
||||
panner.pan.setValueAtTime(clamp(pan, -1, 1), scheduledStart);
|
||||
configureGainEnvelope(gain);
|
||||
|
||||
source.connect(filter);
|
||||
filter.connect(gain);
|
||||
gain.connect(panner);
|
||||
panner.connect(eventBus);
|
||||
|
||||
if (delayInput && delaySend > 0) {
|
||||
sendGain = context.createGain();
|
||||
sendGain.gain.value = delaySend;
|
||||
panner.connect(sendGain);
|
||||
sendGain.connect(delayInput);
|
||||
}
|
||||
|
||||
source.start(scheduledStart);
|
||||
source.stop(stopAt + pianoSamplerTuning.tailStopExtraSeconds);
|
||||
this.activeVoices.push({ gain, source, stopAt });
|
||||
|
||||
source.addEventListener(
|
||||
'ended',
|
||||
() => {
|
||||
source.disconnect();
|
||||
filter.disconnect();
|
||||
gain.disconnect();
|
||||
panner.disconnect();
|
||||
sendGain?.disconnect();
|
||||
this.activeVoices = this.activeVoices.filter((voice) => voice.gain !== gain);
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
}
|
||||
|
||||
private computeNoteGain(velocity: number): number {
|
||||
return Math.max(pianoSamplerTuning.minGain, this.config.piano.gain * velocity);
|
||||
}
|
||||
|
||||
private findNearestSample(midi: number): LoadedPianoSample | null {
|
||||
if (this.samples.length === 0) {
|
||||
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);
|
||||
}
|
||||
|
||||
private stopVoice(voice: ActivePianoVoice, now: number): void {
|
||||
const stopAt = now + pianoSamplerTuning.voiceStealStopSeconds;
|
||||
|
||||
voice.gain.gain.cancelScheduledValues(now);
|
||||
voice.gain.gain.setTargetAtTime(
|
||||
pianoSamplerTuning.minGain,
|
||||
now,
|
||||
pianoSamplerTuning.voiceStealFadeSeconds
|
||||
);
|
||||
voice.stopAt = stopAt;
|
||||
voice.source.stop(stopAt);
|
||||
}
|
||||
|
||||
private setSamples(samples: Array<LoadedPianoSample>): void {
|
||||
this.samples = samples.slice().sort((a, b) => a.midi - b.midi);
|
||||
}
|
||||
}
|
||||
271
src/audio/piano-samples.ts
Normal file
271
src/audio/piano-samples.ts
Normal file
|
|
@ -0,0 +1,271 @@
|
|||
import type { LoadedPianoSample } from './garden-audio-types';
|
||||
import a0SampleUrl from './samples/A0v12.m4a?url&no-inline';
|
||||
import a1SampleUrl from './samples/A1v12.m4a?url&no-inline';
|
||||
import a2SampleUrl from './samples/A2v12.m4a?url&no-inline';
|
||||
import a3SampleUrl from './samples/A3v12.m4a?url&no-inline';
|
||||
import a4SampleUrl from './samples/A4v12.m4a?url&no-inline';
|
||||
import a5SampleUrl from './samples/A5v12.m4a?url&no-inline';
|
||||
import a6SampleUrl from './samples/A6v12.m4a?url&no-inline';
|
||||
import a7SampleUrl from './samples/A7v12.m4a?url&no-inline';
|
||||
import c1SampleUrl from './samples/C1v12.m4a?url&no-inline';
|
||||
import c2SampleUrl from './samples/C2v12.m4a?url&no-inline';
|
||||
import c3SampleUrl from './samples/C3v12.m4a?url&no-inline';
|
||||
import c4SampleUrl from './samples/C4v12.m4a?url&no-inline';
|
||||
import c5SampleUrl from './samples/C5v12.m4a?url&no-inline';
|
||||
import c6SampleUrl from './samples/C6v12.m4a?url&no-inline';
|
||||
import c7SampleUrl from './samples/C7v12.m4a?url&no-inline';
|
||||
import c8SampleUrl from './samples/C8v12.m4a?url&no-inline';
|
||||
import dSharp1SampleUrl from './samples/Dsharp1v12.m4a?url&no-inline';
|
||||
import dSharp2SampleUrl from './samples/Dsharp2v12.m4a?url&no-inline';
|
||||
import dSharp3SampleUrl from './samples/Dsharp3v12.m4a?url&no-inline';
|
||||
import dSharp4SampleUrl from './samples/Dsharp4v12.m4a?url&no-inline';
|
||||
import dSharp5SampleUrl from './samples/Dsharp5v12.m4a?url&no-inline';
|
||||
import dSharp6SampleUrl from './samples/Dsharp6v12.m4a?url&no-inline';
|
||||
import dSharp7SampleUrl from './samples/Dsharp7v12.m4a?url&no-inline';
|
||||
import fSharp1SampleUrl from './samples/Fsharp1v12.m4a?url&no-inline';
|
||||
import fSharp2SampleUrl from './samples/Fsharp2v12.m4a?url&no-inline';
|
||||
import fSharp3SampleUrl from './samples/Fsharp3v12.m4a?url&no-inline';
|
||||
import fSharp4SampleUrl from './samples/Fsharp4v12.m4a?url&no-inline';
|
||||
import fSharp5SampleUrl from './samples/Fsharp5v12.m4a?url&no-inline';
|
||||
import fSharp6SampleUrl from './samples/Fsharp6v12.m4a?url&no-inline';
|
||||
import fSharp7SampleUrl from './samples/Fsharp7v12.m4a?url&no-inline';
|
||||
|
||||
interface PianoSampleDefinition {
|
||||
note: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface PianoSampleLoadProgress {
|
||||
failedCount: number;
|
||||
loadedCount: number;
|
||||
settledCount: number;
|
||||
totalCount: number;
|
||||
}
|
||||
|
||||
const pianoSampleDefinitions: Array<PianoSampleDefinition> = [
|
||||
{ url: a0SampleUrl, note: 'A0' },
|
||||
{ url: c1SampleUrl, note: 'C1' },
|
||||
{ url: dSharp1SampleUrl, note: 'Dsharp1' },
|
||||
{ url: fSharp1SampleUrl, note: 'Fsharp1' },
|
||||
{ url: a1SampleUrl, note: 'A1' },
|
||||
{ url: c2SampleUrl, note: 'C2' },
|
||||
{ url: dSharp2SampleUrl, note: 'Dsharp2' },
|
||||
{ url: fSharp2SampleUrl, note: 'Fsharp2' },
|
||||
{ url: a2SampleUrl, note: 'A2' },
|
||||
{ url: c3SampleUrl, note: 'C3' },
|
||||
{ url: dSharp3SampleUrl, note: 'Dsharp3' },
|
||||
{ url: fSharp3SampleUrl, note: 'Fsharp3' },
|
||||
{ url: a3SampleUrl, note: 'A3' },
|
||||
{ url: c4SampleUrl, note: 'C4' },
|
||||
{ url: dSharp4SampleUrl, note: 'Dsharp4' },
|
||||
{ url: fSharp4SampleUrl, note: 'Fsharp4' },
|
||||
{ url: a4SampleUrl, note: 'A4' },
|
||||
{ url: c5SampleUrl, note: 'C5' },
|
||||
{ url: dSharp5SampleUrl, note: 'Dsharp5' },
|
||||
{ url: fSharp5SampleUrl, note: 'Fsharp5' },
|
||||
{ url: a5SampleUrl, note: 'A5' },
|
||||
{ url: c6SampleUrl, note: 'C6' },
|
||||
{ url: dSharp6SampleUrl, note: 'Dsharp6' },
|
||||
{ url: fSharp6SampleUrl, note: 'Fsharp6' },
|
||||
{ url: a6SampleUrl, note: 'A6' },
|
||||
{ url: c7SampleUrl, note: 'C7' },
|
||||
{ url: dSharp7SampleUrl, note: 'Dsharp7' },
|
||||
{ url: fSharp7SampleUrl, note: 'Fsharp7' },
|
||||
{ url: a7SampleUrl, note: 'A7' },
|
||||
{ url: c8SampleUrl, note: 'C8' },
|
||||
];
|
||||
|
||||
let loadedPianoSamples: Array<LoadedPianoSample> | null = null;
|
||||
let pianoSampleLoadPromise: Promise<Array<LoadedPianoSample>> | null = null;
|
||||
let lastPianoSampleProgress: PianoSampleLoadProgress | null = null;
|
||||
const pianoSampleProgressListeners = new Set<
|
||||
(progress: PianoSampleLoadProgress) => void
|
||||
>();
|
||||
|
||||
const sampleLoadTuning = {
|
||||
concurrency: 4,
|
||||
sampleTimeoutMs: 15_000,
|
||||
};
|
||||
|
||||
export const preloadPianoSamples = (
|
||||
onProgress?: (progress: PianoSampleLoadProgress) => void
|
||||
): Promise<Array<LoadedPianoSample>> => {
|
||||
const OfflineAudioContextConstructor = globalThis.OfflineAudioContext;
|
||||
|
||||
if (!OfflineAudioContextConstructor) {
|
||||
return Promise.reject(
|
||||
new Error('OfflineAudioContext is required to preload piano samples.')
|
||||
);
|
||||
}
|
||||
|
||||
// Decoding ignores these, but the constructor demands real numbers.
|
||||
const decodeContext = new OfflineAudioContextConstructor(1, 1, 48_000);
|
||||
return loadPianoSamples(decodeContext, onProgress);
|
||||
};
|
||||
|
||||
export const loadPianoSamples = (
|
||||
decodeContext: BaseAudioContext,
|
||||
onProgress?: (progress: PianoSampleLoadProgress) => void
|
||||
): Promise<Array<LoadedPianoSample>> => {
|
||||
const unsubscribeProgress = subscribeToPianoSampleProgress(onProgress);
|
||||
|
||||
if (loadedPianoSamples) {
|
||||
emitPianoSampleProgress({
|
||||
failedCount: 0,
|
||||
loadedCount: loadedPianoSamples.length,
|
||||
settledCount: loadedPianoSamples.length,
|
||||
totalCount: pianoSampleDefinitions.length,
|
||||
});
|
||||
unsubscribeProgress();
|
||||
return Promise.resolve([...loadedPianoSamples]);
|
||||
}
|
||||
|
||||
if (pianoSampleLoadPromise) {
|
||||
return pianoSampleLoadPromise.finally(unsubscribeProgress);
|
||||
}
|
||||
|
||||
let loadedCount = 0;
|
||||
let failedCount = 0;
|
||||
let settledCount = 0;
|
||||
const totalCount = pianoSampleDefinitions.length;
|
||||
emitPianoSampleProgress({ failedCount, loadedCount, settledCount, totalCount });
|
||||
|
||||
pianoSampleLoadPromise = loadPianoSampleBatch(
|
||||
pianoSampleDefinitions,
|
||||
async (sample) => {
|
||||
try {
|
||||
const loadedSample = await withTimeout(
|
||||
(signal) => loadPianoSample(decodeContext, sample, signal),
|
||||
sampleLoadTuning.sampleTimeoutMs
|
||||
);
|
||||
loadedCount += 1;
|
||||
return loadedSample;
|
||||
} catch (error) {
|
||||
failedCount += 1;
|
||||
throw error;
|
||||
} finally {
|
||||
settledCount += 1;
|
||||
emitPianoSampleProgress({ failedCount, loadedCount, settledCount, totalCount });
|
||||
}
|
||||
}
|
||||
)
|
||||
.then(
|
||||
(samples) => {
|
||||
loadedPianoSamples = samples.sort((a, b) => a.midi - b.midi);
|
||||
if (loadedPianoSamples.length !== pianoSampleDefinitions.length) {
|
||||
throw new Error(
|
||||
`Loaded ${loadedPianoSamples.length}/${pianoSampleDefinitions.length} piano samples.`
|
||||
);
|
||||
}
|
||||
return [...loadedPianoSamples];
|
||||
},
|
||||
(error: unknown) => {
|
||||
pianoSampleLoadPromise = null;
|
||||
pianoSampleProgressListeners.clear();
|
||||
throw error;
|
||||
}
|
||||
)
|
||||
.finally(unsubscribeProgress);
|
||||
|
||||
return pianoSampleLoadPromise;
|
||||
};
|
||||
|
||||
export const getLoadedPianoSamples = (): Array<LoadedPianoSample> | null =>
|
||||
loadedPianoSamples ? [...loadedPianoSamples] : null;
|
||||
|
||||
const loadPianoSample = async (
|
||||
decodeContext: BaseAudioContext,
|
||||
sample: PianoSampleDefinition,
|
||||
signal: AbortSignal
|
||||
): Promise<LoadedPianoSample> => {
|
||||
const response = await fetch(sample.url, { signal });
|
||||
if (!response.ok) {
|
||||
throw new Error(`Unable to load piano sample ${getPianoSamplePath(sample)}`);
|
||||
}
|
||||
|
||||
const audioData = await response.arrayBuffer();
|
||||
const buffer = await decodeContext.decodeAudioData(audioData);
|
||||
return { midi: getMidiForPianoSample(sample), buffer };
|
||||
};
|
||||
|
||||
const loadPianoSampleBatch = async (
|
||||
samples: Array<PianoSampleDefinition>,
|
||||
loadSample: (sample: PianoSampleDefinition) => Promise<LoadedPianoSample>
|
||||
): Promise<Array<LoadedPianoSample>> => {
|
||||
const results: Array<LoadedPianoSample> = [];
|
||||
|
||||
for (let index = 0; index < samples.length; index += sampleLoadTuning.concurrency) {
|
||||
const batch = samples.slice(index, index + sampleLoadTuning.concurrency);
|
||||
const batchResults = await Promise.all(batch.map((sample) => loadSample(sample)));
|
||||
results.push(...batchResults);
|
||||
}
|
||||
|
||||
return results;
|
||||
};
|
||||
|
||||
const withTimeout = <T>(
|
||||
operation: (signal: AbortSignal) => Promise<T>,
|
||||
timeoutMs: number
|
||||
): Promise<T> =>
|
||||
new Promise((resolve, reject) => {
|
||||
const controller = new AbortController();
|
||||
const timeout = globalThis.setTimeout(() => {
|
||||
controller.abort();
|
||||
reject(new Error('Timed out while loading a piano sample.'));
|
||||
}, timeoutMs);
|
||||
|
||||
operation(controller.signal).then(
|
||||
(value) => {
|
||||
globalThis.clearTimeout(timeout);
|
||||
resolve(value);
|
||||
},
|
||||
(error: unknown) => {
|
||||
globalThis.clearTimeout(timeout);
|
||||
reject(error);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
const subscribeToPianoSampleProgress = (
|
||||
onProgress: ((progress: PianoSampleLoadProgress) => void) | undefined
|
||||
): (() => void) => {
|
||||
if (!onProgress) {
|
||||
return () => undefined;
|
||||
}
|
||||
|
||||
pianoSampleProgressListeners.add(onProgress);
|
||||
if (lastPianoSampleProgress) {
|
||||
onProgress(lastPianoSampleProgress);
|
||||
}
|
||||
return () => {
|
||||
pianoSampleProgressListeners.delete(onProgress);
|
||||
};
|
||||
};
|
||||
|
||||
const emitPianoSampleProgress = (progress: PianoSampleLoadProgress): void => {
|
||||
lastPianoSampleProgress = progress;
|
||||
pianoSampleProgressListeners.forEach((listener) => listener(progress));
|
||||
};
|
||||
|
||||
const getPianoSamplePath = (sample: PianoSampleDefinition): string =>
|
||||
`./samples/${sample.note}v12.m4a`;
|
||||
|
||||
const getMidiForPianoSample = (sample: PianoSampleDefinition): number => {
|
||||
const match = /^(?<name>[A-G])(?<accidental>sharp)?(?<octave>\d+)$/.exec(sample.note);
|
||||
if (!match?.groups) {
|
||||
throw new Error(`Invalid piano sample note ${sample.note}`);
|
||||
}
|
||||
|
||||
const semitoneByName: Record<string, number> = {
|
||||
C: 0,
|
||||
D: 2,
|
||||
E: 4,
|
||||
F: 5,
|
||||
G: 7,
|
||||
A: 9,
|
||||
B: 11,
|
||||
};
|
||||
const octave = Number(match.groups.octave);
|
||||
const semitone = semitoneByName[match.groups.name] + (match.groups.accidental ? 1 : 0);
|
||||
return (octave + 1) * 12 + semitone;
|
||||
};
|
||||
BIN
src/audio/samples/A0v12.m4a
Normal file
BIN
src/audio/samples/A0v12.m4a
Normal file
Binary file not shown.
BIN
src/audio/samples/A1v12.m4a
Normal file
BIN
src/audio/samples/A1v12.m4a
Normal file
Binary file not shown.
BIN
src/audio/samples/A2v12.m4a
Normal file
BIN
src/audio/samples/A2v12.m4a
Normal file
Binary file not shown.
BIN
src/audio/samples/A3v12.m4a
Normal file
BIN
src/audio/samples/A3v12.m4a
Normal file
Binary file not shown.
BIN
src/audio/samples/A4v12.m4a
Normal file
BIN
src/audio/samples/A4v12.m4a
Normal file
Binary file not shown.
BIN
src/audio/samples/A5v12.m4a
Normal file
BIN
src/audio/samples/A5v12.m4a
Normal file
Binary file not shown.
BIN
src/audio/samples/A6v12.m4a
Normal file
BIN
src/audio/samples/A6v12.m4a
Normal file
Binary file not shown.
BIN
src/audio/samples/A7v12.m4a
Normal file
BIN
src/audio/samples/A7v12.m4a
Normal file
Binary file not shown.
BIN
src/audio/samples/C1v12.m4a
Normal file
BIN
src/audio/samples/C1v12.m4a
Normal file
Binary file not shown.
BIN
src/audio/samples/C2v12.m4a
Normal file
BIN
src/audio/samples/C2v12.m4a
Normal file
Binary file not shown.
BIN
src/audio/samples/C3v12.m4a
Normal file
BIN
src/audio/samples/C3v12.m4a
Normal file
Binary file not shown.
BIN
src/audio/samples/C4v12.m4a
Normal file
BIN
src/audio/samples/C4v12.m4a
Normal file
Binary file not shown.
BIN
src/audio/samples/C5v12.m4a
Normal file
BIN
src/audio/samples/C5v12.m4a
Normal file
Binary file not shown.
BIN
src/audio/samples/C6v12.m4a
Normal file
BIN
src/audio/samples/C6v12.m4a
Normal file
Binary file not shown.
BIN
src/audio/samples/C7v12.m4a
Normal file
BIN
src/audio/samples/C7v12.m4a
Normal file
Binary file not shown.
BIN
src/audio/samples/C8v12.m4a
Normal file
BIN
src/audio/samples/C8v12.m4a
Normal file
Binary file not shown.
BIN
src/audio/samples/Dsharp1v12.m4a
Normal file
BIN
src/audio/samples/Dsharp1v12.m4a
Normal file
Binary file not shown.
BIN
src/audio/samples/Dsharp2v12.m4a
Normal file
BIN
src/audio/samples/Dsharp2v12.m4a
Normal file
Binary file not shown.
BIN
src/audio/samples/Dsharp3v12.m4a
Normal file
BIN
src/audio/samples/Dsharp3v12.m4a
Normal file
Binary file not shown.
BIN
src/audio/samples/Dsharp4v12.m4a
Normal file
BIN
src/audio/samples/Dsharp4v12.m4a
Normal file
Binary file not shown.
BIN
src/audio/samples/Dsharp5v12.m4a
Normal file
BIN
src/audio/samples/Dsharp5v12.m4a
Normal file
Binary file not shown.
BIN
src/audio/samples/Dsharp6v12.m4a
Normal file
BIN
src/audio/samples/Dsharp6v12.m4a
Normal file
Binary file not shown.
BIN
src/audio/samples/Dsharp7v12.m4a
Normal file
BIN
src/audio/samples/Dsharp7v12.m4a
Normal file
Binary file not shown.
BIN
src/audio/samples/Fsharp1v12.m4a
Normal file
BIN
src/audio/samples/Fsharp1v12.m4a
Normal file
Binary file not shown.
BIN
src/audio/samples/Fsharp2v12.m4a
Normal file
BIN
src/audio/samples/Fsharp2v12.m4a
Normal file
Binary file not shown.
BIN
src/audio/samples/Fsharp3v12.m4a
Normal file
BIN
src/audio/samples/Fsharp3v12.m4a
Normal file
Binary file not shown.
BIN
src/audio/samples/Fsharp4v12.m4a
Normal file
BIN
src/audio/samples/Fsharp4v12.m4a
Normal file
Binary file not shown.
BIN
src/audio/samples/Fsharp5v12.m4a
Normal file
BIN
src/audio/samples/Fsharp5v12.m4a
Normal file
Binary file not shown.
BIN
src/audio/samples/Fsharp6v12.m4a
Normal file
BIN
src/audio/samples/Fsharp6v12.m4a
Normal file
Binary file not shown.
BIN
src/audio/samples/Fsharp7v12.m4a
Normal file
BIN
src/audio/samples/Fsharp7v12.m4a
Normal file
Binary file not shown.
16
src/audio/samples/README.md
Normal file
16
src/audio/samples/README.md
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
Piano samples are Salamander Grand Piano V3 samples by Alexander Holm,
|
||||
transcoded from OGG Vorbis to AAC M4A for iOS browser playback and distributed
|
||||
under CC BY 3.0.
|
||||
|
||||
Source package: @audio-samples/piano-velocity12
|
||||
Source recording: https://archive.org/details/SalamanderGrandPianoV3
|
||||
License: https://creativecommons.org/licenses/by/3.0/
|
||||
|
||||
Checked-in subset: velocity layer `v12`, every minor-third anchor from A0
|
||||
through C8: A, C, Dsharp, and Fsharp for octaves 1-7, plus A0, A7, and C8.
|
||||
The app derives MIDI values from those note names in `piano-samples.ts`.
|
||||
|
||||
Repro notes: start from the matching `v12` OGG files in the source package and
|
||||
transcode each selected sample to AAC/M4A without renaming the note/velocity
|
||||
stem. The expected output filenames are `<note>v12.m4a`, for example
|
||||
`C4v12.m4a`.
|
||||
152
src/page/audio-control.ts
Normal file
152
src/page/audio-control.ts
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
import { appConfig } from '../config';
|
||||
import type GameLoop from '../game-loop/game-loop';
|
||||
import { readBrowserStorage, writeBrowserStorage } from '../utils/browser-storage';
|
||||
import { queryRequiredElement } from '../utils/dom';
|
||||
import { clamp01 } from '../utils/math';
|
||||
|
||||
const clampAudioVolume = (value: number): number => {
|
||||
const { default: defaultVolume, max, min } = appConfig.toolbar.volume;
|
||||
const safeValue = Number.isFinite(value) ? value : defaultVolume;
|
||||
return Math.min(max, Math.max(min, clamp01(safeValue)));
|
||||
};
|
||||
|
||||
const readInitialAudioVolume = (): number => {
|
||||
const storedVolume = readBrowserStorage(appConfig.storage.audioVolumeKey);
|
||||
return storedVolume === null
|
||||
? appConfig.toolbar.volume.default
|
||||
: clampAudioVolume(Number(storedVolume));
|
||||
};
|
||||
|
||||
const formatStoredAudioVolume = (volume: number): string =>
|
||||
clampAudioVolume(volume).toFixed(2);
|
||||
|
||||
const STORED_MUTED_TRUE = '1';
|
||||
const STORED_MUTED_FALSE = '0';
|
||||
|
||||
interface AudioControlOptions {
|
||||
getGame: () => GameLoop | null;
|
||||
hasStarted: () => boolean;
|
||||
startButton: HTMLElement;
|
||||
}
|
||||
|
||||
export class AudioControl {
|
||||
private readonly soundButton = queryRequiredElement(
|
||||
'[data-control="sound"]',
|
||||
HTMLButtonElement
|
||||
);
|
||||
private readonly volumeControl = queryRequiredElement(
|
||||
'.volume-control',
|
||||
HTMLLabelElement
|
||||
);
|
||||
private readonly volumeSlider = queryRequiredElement(
|
||||
'.volume-slider',
|
||||
HTMLInputElement
|
||||
);
|
||||
|
||||
private audioVolume = readInitialAudioVolume();
|
||||
private isMutedState =
|
||||
readBrowserStorage(appConfig.storage.audioMutedKey) === STORED_MUTED_TRUE ||
|
||||
this.audioVolume <= 0;
|
||||
|
||||
public constructor(private readonly options: AudioControlOptions) {
|
||||
this.soundButton.addEventListener('click', this.onToggleMute);
|
||||
this.volumeSlider.addEventListener('input', this.onVolumeInput);
|
||||
|
||||
const passiveCaptureOptions = { capture: true, passive: true } as const;
|
||||
const captureOptions = { capture: true } as const;
|
||||
(
|
||||
[
|
||||
['touchstart', passiveCaptureOptions],
|
||||
['pointerdown', passiveCaptureOptions],
|
||||
['touchend', passiveCaptureOptions],
|
||||
['pointerup', passiveCaptureOptions],
|
||||
['click', captureOptions],
|
||||
['keydown', captureOptions],
|
||||
] satisfies Array<[keyof WindowEventMap, AddEventListenerOptions]>
|
||||
).forEach(([event, opts]) => {
|
||||
window.addEventListener(event, this.onUserGesture, opts);
|
||||
});
|
||||
|
||||
this.render();
|
||||
}
|
||||
|
||||
public get isMuted(): boolean {
|
||||
return this.isMutedState || this.audioVolume <= 0;
|
||||
}
|
||||
|
||||
public render(): void {
|
||||
this.audioVolume = clampAudioVolume(this.audioVolume);
|
||||
const isEffectivelyMuted = this.isMuted;
|
||||
const volumePercent = Math.round(this.audioVolume * 100);
|
||||
|
||||
this.soundButton.classList.toggle('muted', isEffectivelyMuted);
|
||||
this.soundButton.setAttribute('aria-pressed', String(isEffectivelyMuted));
|
||||
const muteLabel = isEffectivelyMuted ? 'Unmute audio' : 'Mute audio';
|
||||
this.soundButton.setAttribute('aria-label', muteLabel);
|
||||
this.soundButton.title = muteLabel;
|
||||
|
||||
this.volumeSlider.min = appConfig.toolbar.volume.min.toString();
|
||||
this.volumeSlider.max = appConfig.toolbar.volume.max.toString();
|
||||
this.volumeSlider.step = appConfig.toolbar.volume.step.toString();
|
||||
this.volumeSlider.value = formatStoredAudioVolume(this.audioVolume);
|
||||
this.volumeSlider.setAttribute(
|
||||
'aria-valuetext',
|
||||
isEffectivelyMuted ? `Muted, ${volumePercent}%` : `${volumePercent}%`
|
||||
);
|
||||
this.volumeControl.classList.toggle('muted', isEffectivelyMuted);
|
||||
this.volumeControl.title = isEffectivelyMuted
|
||||
? `Muted, ${volumePercent}% volume`
|
||||
: `${volumePercent}% volume`;
|
||||
this.volumeControl.style.setProperty('--volume-progress', `${volumePercent}%`);
|
||||
|
||||
const game = this.options.getGame();
|
||||
game?.setAudioVolume(this.audioVolume);
|
||||
game?.setAudioMuted(isEffectivelyMuted);
|
||||
}
|
||||
|
||||
private readonly onToggleMute = () => {
|
||||
const shouldUnmute = this.isMutedState || this.audioVolume <= 0;
|
||||
if (shouldUnmute && this.audioVolume <= 0) {
|
||||
this.audioVolume = appConfig.toolbar.volume.default;
|
||||
}
|
||||
this.isMutedState = !shouldUnmute;
|
||||
this.persist();
|
||||
this.render();
|
||||
if (!this.isMutedState) {
|
||||
this.options.getGame()?.startAudio(true);
|
||||
}
|
||||
};
|
||||
|
||||
private readonly onVolumeInput = () => {
|
||||
this.audioVolume = clampAudioVolume(Number(this.volumeSlider.value));
|
||||
this.isMutedState = this.audioVolume <= 0;
|
||||
this.persist();
|
||||
this.render();
|
||||
if (!this.isMutedState) {
|
||||
this.options.getGame()?.startAudio(true);
|
||||
}
|
||||
};
|
||||
|
||||
private readonly onUserGesture = (event: Event) => {
|
||||
if (
|
||||
!this.options.hasStarted() ||
|
||||
this.isMutedState ||
|
||||
(event.target instanceof Node && this.options.startButton.contains(event.target)) ||
|
||||
(event.target instanceof Node && this.soundButton.contains(event.target))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.options.getGame()?.startAudio(true);
|
||||
};
|
||||
|
||||
private persist(): void {
|
||||
writeBrowserStorage(
|
||||
appConfig.storage.audioMutedKey,
|
||||
this.isMutedState ? STORED_MUTED_TRUE : STORED_MUTED_FALSE
|
||||
);
|
||||
writeBrowserStorage(
|
||||
appConfig.storage.audioVolumeKey,
|
||||
formatStoredAudioVolume(this.audioVolume)
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue