WIP
This commit is contained in:
parent
34ac200437
commit
39b0160064
136 changed files with 7129 additions and 1950 deletions
|
|
@ -1,3 +1,5 @@
|
|||
import { appConfig } from '../config';
|
||||
|
||||
export type GardenAudioChordQuality = 'major' | 'minor';
|
||||
|
||||
export interface GardenAudioChord {
|
||||
|
|
@ -7,7 +9,6 @@ export interface GardenAudioChord {
|
|||
|
||||
export interface GardenAudioColorVoice {
|
||||
scaleDegreeOffset: number;
|
||||
octaveOffset: number;
|
||||
velocityMultiplier: number;
|
||||
panOffset: number;
|
||||
}
|
||||
|
|
@ -21,19 +22,11 @@ export interface GardenAudioVibeProfile {
|
|||
}
|
||||
|
||||
export interface GardenAudioConfig {
|
||||
enabled: boolean;
|
||||
masterVolume: number;
|
||||
fadeInSeconds: number;
|
||||
updateRampSeconds: number;
|
||||
highPassFrequencyHz: number;
|
||||
fallbackVibeId: string;
|
||||
startup: {
|
||||
calmDurationSeconds: number;
|
||||
initialTempoMultiplier: number;
|
||||
initialEnergyMultiplier: number;
|
||||
initialActivityCeiling: number;
|
||||
initialTapIntervalMultiplier: number;
|
||||
};
|
||||
compressor: {
|
||||
thresholdDb: number;
|
||||
kneeDb: number;
|
||||
|
|
@ -42,7 +35,6 @@ export interface GardenAudioConfig {
|
|||
releaseSeconds: number;
|
||||
};
|
||||
delay: {
|
||||
enabled: boolean;
|
||||
timeSeconds: number;
|
||||
feedback: number;
|
||||
wetGain: number;
|
||||
|
|
@ -54,7 +46,6 @@ export interface GardenAudioConfig {
|
|||
sustainLevel: number;
|
||||
releaseSeconds: number;
|
||||
lowpassHz: number;
|
||||
preloadOnStart: boolean;
|
||||
};
|
||||
input: {
|
||||
pressureFallback: number;
|
||||
|
|
@ -64,25 +55,10 @@ export interface GardenAudioConfig {
|
|||
stepsPerBeat: number;
|
||||
stepsPerBar: number;
|
||||
lookaheadSeconds: number;
|
||||
swing: number;
|
||||
minTailSeconds: number;
|
||||
maxTailSeconds: number;
|
||||
tailDistanceForMaxPixels: number;
|
||||
tailDurationForMaxSeconds: number;
|
||||
tailDecayPower: number;
|
||||
minTapIntervalSeconds: number;
|
||||
speedForFullEnergyPixelsPerSecond: number;
|
||||
sparseActivity: number;
|
||||
arpeggioActivity: number;
|
||||
fullChordActivity: number;
|
||||
bassActivity: number;
|
||||
melodySteps: Array<number>;
|
||||
chordSteps: Array<number>;
|
||||
bassSteps: Array<number>;
|
||||
melodyPattern: Array<number>;
|
||||
};
|
||||
eraser: {
|
||||
enabled: boolean;
|
||||
minIntervalSeconds: number;
|
||||
noiseGain: number;
|
||||
filterMinHz: number;
|
||||
|
|
@ -92,163 +68,4 @@ export interface GardenAudioConfig {
|
|||
vibes: Record<string, GardenAudioVibeProfile>;
|
||||
}
|
||||
|
||||
const majorProgression: Array<GardenAudioChord> = [
|
||||
{ rootOffset: 0, quality: 'major' },
|
||||
{ rootOffset: 9, quality: 'minor' },
|
||||
{ rootOffset: 5, quality: 'major' },
|
||||
{ rootOffset: 7, quality: 'major' },
|
||||
];
|
||||
|
||||
const minorProgression: Array<GardenAudioChord> = [
|
||||
{ rootOffset: 0, quality: 'minor' },
|
||||
{ rootOffset: 8, quality: 'major' },
|
||||
{ rootOffset: 3, quality: 'major' },
|
||||
{ rootOffset: 10, quality: 'major' },
|
||||
];
|
||||
|
||||
const majorPentatonic = [0, 2, 4, 7, 9];
|
||||
const minorPentatonic = [0, 3, 5, 7, 10];
|
||||
|
||||
export const gardenAudioConfig: GardenAudioConfig = {
|
||||
enabled: true,
|
||||
masterVolume: 0.32,
|
||||
fadeInSeconds: 0.45,
|
||||
updateRampSeconds: 0.08,
|
||||
highPassFrequencyHz: 45,
|
||||
fallbackVibeId: 'candy-rain',
|
||||
startup: {
|
||||
calmDurationSeconds: 6,
|
||||
initialTempoMultiplier: 1.18,
|
||||
initialEnergyMultiplier: 0.62,
|
||||
initialActivityCeiling: 0.52,
|
||||
initialTapIntervalMultiplier: 2.2,
|
||||
},
|
||||
compressor: {
|
||||
thresholdDb: -18,
|
||||
kneeDb: 18,
|
||||
ratio: 2.4,
|
||||
attackSeconds: 0.006,
|
||||
releaseSeconds: 0.18,
|
||||
},
|
||||
delay: {
|
||||
enabled: true,
|
||||
timeSeconds: 0.42,
|
||||
feedback: 0.12,
|
||||
wetGain: 0.048,
|
||||
},
|
||||
piano: {
|
||||
maxVoices: 32,
|
||||
gain: 0.42,
|
||||
sustainSeconds: 0.52,
|
||||
sustainLevel: 0.34,
|
||||
releaseSeconds: 0.16,
|
||||
lowpassHz: 9000,
|
||||
preloadOnStart: true,
|
||||
},
|
||||
input: {
|
||||
pressureFallback: 0.48,
|
||||
},
|
||||
rhythm: {
|
||||
bpm: 82,
|
||||
stepsPerBeat: 4,
|
||||
stepsPerBar: 16,
|
||||
lookaheadSeconds: 0.14,
|
||||
swing: 0.08,
|
||||
minTailSeconds: 0.45,
|
||||
maxTailSeconds: 7.2,
|
||||
tailDistanceForMaxPixels: 1400,
|
||||
tailDurationForMaxSeconds: 3.8,
|
||||
tailDecayPower: 1.85,
|
||||
minTapIntervalSeconds: 0.16,
|
||||
speedForFullEnergyPixelsPerSecond: 1800,
|
||||
sparseActivity: 0.1,
|
||||
arpeggioActivity: 0.32,
|
||||
fullChordActivity: 0.62,
|
||||
bassActivity: 0.48,
|
||||
melodySteps: [0, 3, 6, 10, 12, 14],
|
||||
chordSteps: [0, 8],
|
||||
bassSteps: [0],
|
||||
melodyPattern: [0, 2, 4, 5, 4, 2, 1, 3],
|
||||
},
|
||||
eraser: {
|
||||
enabled: true,
|
||||
minIntervalSeconds: 0.12,
|
||||
noiseGain: 0.028,
|
||||
filterMinHz: 650,
|
||||
filterMaxHz: 3600,
|
||||
},
|
||||
colorVoices: [
|
||||
{
|
||||
scaleDegreeOffset: 0,
|
||||
octaveOffset: 0,
|
||||
velocityMultiplier: 0.92,
|
||||
panOffset: -0.14,
|
||||
},
|
||||
{
|
||||
scaleDegreeOffset: 1,
|
||||
octaveOffset: 0,
|
||||
velocityMultiplier: 1,
|
||||
panOffset: 0,
|
||||
},
|
||||
{
|
||||
scaleDegreeOffset: 2,
|
||||
octaveOffset: 1,
|
||||
velocityMultiplier: 0.86,
|
||||
panOffset: 0.14,
|
||||
},
|
||||
],
|
||||
vibes: {
|
||||
'candy-rain': {
|
||||
rootMidi: 57,
|
||||
scale: majorPentatonic,
|
||||
brightness: 1.04,
|
||||
delayTimeMultiplier: 0.92,
|
||||
progression: majorProgression,
|
||||
},
|
||||
'sunlit-moss': {
|
||||
rootMidi: 53,
|
||||
scale: majorPentatonic,
|
||||
brightness: 0.92,
|
||||
delayTimeMultiplier: 1.08,
|
||||
progression: [
|
||||
{ rootOffset: 0, quality: 'major' },
|
||||
{ rootOffset: 7, quality: 'major' },
|
||||
{ rootOffset: 9, quality: 'minor' },
|
||||
{ rootOffset: 5, quality: 'major' },
|
||||
],
|
||||
},
|
||||
'coral-tide': {
|
||||
rootMidi: 50,
|
||||
scale: minorPentatonic,
|
||||
brightness: 1,
|
||||
delayTimeMultiplier: 1.12,
|
||||
progression: minorProgression,
|
||||
},
|
||||
'moon-orchid': {
|
||||
rootMidi: 49,
|
||||
scale: minorPentatonic,
|
||||
brightness: 0.9,
|
||||
delayTimeMultiplier: 1.24,
|
||||
progression: minorProgression,
|
||||
},
|
||||
'peach-neon': {
|
||||
rootMidi: 56,
|
||||
scale: majorPentatonic,
|
||||
brightness: 1.08,
|
||||
delayTimeMultiplier: 0.86,
|
||||
progression: majorProgression,
|
||||
},
|
||||
'frost-bloom': {
|
||||
rootMidi: 62,
|
||||
scale: majorPentatonic,
|
||||
brightness: 0.88,
|
||||
delayTimeMultiplier: 1.32,
|
||||
progression: [
|
||||
{ rootOffset: 0, quality: 'major' },
|
||||
{ rootOffset: 5, quality: 'major' },
|
||||
{ rootOffset: 9, quality: 'minor' },
|
||||
{ rootOffset: 7, quality: 'major' },
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
export const gardenAudioConfig: GardenAudioConfig = appConfig.audio;
|
||||
|
|
|
|||
48
src/audio/garden-audio-energy.test.ts
Normal file
48
src/audio/garden-audio-energy.test.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { GardenAudioEnergy } from './garden-audio-energy';
|
||||
|
||||
describe('GardenAudioEnergy', () => {
|
||||
it('suspends activity but keeps a fading level when the gesture ends', () => {
|
||||
const energy = new GardenAudioEnergy();
|
||||
|
||||
energy.beginGesture(0);
|
||||
energy.recordStroke(0.8, 0.1);
|
||||
energy.update(0.1);
|
||||
energy.update(0.2);
|
||||
|
||||
const levelBeforeLift = energy.getLevel();
|
||||
expect(energy.getActivity()).toBeGreaterThan(0);
|
||||
|
||||
energy.endGesture();
|
||||
|
||||
expect(energy.getActivity()).toBe(0);
|
||||
expect(energy.getLevel()).toBe(levelBeforeLift);
|
||||
energy.update(0.3);
|
||||
expect(energy.getLevel()).toBeLessThan(levelBeforeLift);
|
||||
expect(energy.getLevel()).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('uses recent stroke intensity rather than gesture duration alone', () => {
|
||||
const energy = new GardenAudioEnergy();
|
||||
|
||||
energy.beginGesture(0);
|
||||
energy.recordStroke(1, 0.1);
|
||||
energy.update(0.1);
|
||||
energy.update(0.2);
|
||||
const activeLevel = energy.getActivity();
|
||||
|
||||
energy.update(1.2);
|
||||
|
||||
expect(energy.getActivity()).toBeLessThan(activeLevel);
|
||||
});
|
||||
|
||||
it('raises activity immediately when a stroke is recorded', () => {
|
||||
const energy = new GardenAudioEnergy();
|
||||
|
||||
energy.beginGesture(0);
|
||||
energy.recordStroke(0.12, 0.05);
|
||||
|
||||
expect(energy.getActivity()).toBeGreaterThan(0.09);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,71 +1,40 @@
|
|||
import { appConfig } from '../config';
|
||||
import { clamp01 } from '../utils/clamp';
|
||||
import { GardenAudioConfig } from './garden-audio-config';
|
||||
|
||||
interface GardenGestureState {
|
||||
startedAt: number;
|
||||
lastAt: number;
|
||||
distancePixels: number;
|
||||
peakEnergy: number;
|
||||
isErasing: boolean;
|
||||
}
|
||||
|
||||
interface GestureTail {
|
||||
startedAt: number;
|
||||
durationSeconds: number;
|
||||
level: number;
|
||||
}
|
||||
|
||||
type GardenAudioRhythmConfig = GardenAudioConfig['rhythm'];
|
||||
const STROKE_IMMEDIATE_ACTIVITY_SCALE = 0.85;
|
||||
|
||||
export class GardenAudioEnergy {
|
||||
private isGestureActive = false;
|
||||
private energy = 0;
|
||||
private targetEnergy = 0;
|
||||
private lastEnergyUpdateAt = 0;
|
||||
private currentGesture: GardenGestureState | null = null;
|
||||
private gestureTails: Array<GestureTail> = [];
|
||||
|
||||
public constructor(private readonly rhythm: GardenAudioRhythmConfig) {}
|
||||
|
||||
public beginGesture(now: number): void {
|
||||
this.isGestureActive = true;
|
||||
this.currentGesture = createGesture(now, false);
|
||||
this.lastEnergyUpdateAt = now;
|
||||
}
|
||||
|
||||
public endGesture(now: number): void {
|
||||
if (this.currentGesture && !this.currentGesture.isErasing) {
|
||||
this.addGestureTail(this.currentGesture, now);
|
||||
}
|
||||
|
||||
public endGesture(): void {
|
||||
this.isGestureActive = false;
|
||||
this.currentGesture = null;
|
||||
this.targetEnergy = 0;
|
||||
}
|
||||
|
||||
public recordStroke(distancePixels: number, strokeEnergy: number, now: number): void {
|
||||
if (!this.currentGesture || this.currentGesture.isErasing) {
|
||||
this.currentGesture = createGesture(now, false);
|
||||
public recordStroke(strokeEnergy: number, now: number): void {
|
||||
const energy = clamp01(strokeEnergy);
|
||||
this.targetEnergy = Math.max(this.targetEnergy, energy);
|
||||
if (this.isGestureActive) {
|
||||
this.energy = Math.max(this.energy, energy * STROKE_IMMEDIATE_ACTIVITY_SCALE);
|
||||
}
|
||||
|
||||
this.currentGesture.lastAt = now;
|
||||
this.currentGesture.distancePixels += distancePixels;
|
||||
this.currentGesture.peakEnergy = Math.max(
|
||||
this.currentGesture.peakEnergy,
|
||||
strokeEnergy
|
||||
);
|
||||
this.lastEnergyUpdateAt ||= now;
|
||||
}
|
||||
|
||||
public recordEraserStroke(now: number): void {
|
||||
this.currentGesture = this.currentGesture ?? createGesture(now, true);
|
||||
this.currentGesture.isErasing = true;
|
||||
}
|
||||
|
||||
public raiseTarget(strokeEnergy: number): void {
|
||||
this.targetEnergy = Math.max(this.targetEnergy, strokeEnergy);
|
||||
public recordEraserStroke(): void {
|
||||
this.targetEnergy = 0;
|
||||
}
|
||||
|
||||
public silence(): void {
|
||||
this.targetEnergy = 0;
|
||||
this.gestureTails = [];
|
||||
this.energy = 0;
|
||||
}
|
||||
|
||||
public update(now: number): void {
|
||||
|
|
@ -76,28 +45,31 @@ export class GardenAudioEnergy {
|
|||
|
||||
const elapsedSeconds = Math.max(0, now - this.lastEnergyUpdateAt);
|
||||
this.lastEnergyUpdateAt = now;
|
||||
this.targetEnergy *= Math.exp(-elapsedSeconds / 0.75);
|
||||
this.trimGestureTails(now);
|
||||
this.targetEnergy *= Math.exp(
|
||||
-elapsedSeconds / appConfig.audioEngine.energy.strokeDecaySeconds
|
||||
);
|
||||
|
||||
const activeGestureFloor =
|
||||
this.isGestureActive && this.currentGesture && !this.currentGesture.isErasing
|
||||
? 0.04 + this.getGestureAmount(this.currentGesture, now) * 0.3
|
||||
: 0;
|
||||
const target = Math.max(activeGestureFloor, this.targetEnergy);
|
||||
const timeConstant = target > this.energy ? 0.08 : 0.55;
|
||||
const target = this.isGestureActive ? this.targetEnergy : 0;
|
||||
let timeConstant = appConfig.audioEngine.energy.decaySeconds;
|
||||
if (!this.isGestureActive) {
|
||||
timeConstant = appConfig.audioEngine.energy.releaseSeconds;
|
||||
} else if (target > this.energy) {
|
||||
timeConstant = appConfig.audioEngine.energy.attackSeconds;
|
||||
}
|
||||
const amount = 1 - Math.exp(-elapsedSeconds / timeConstant);
|
||||
this.energy += (target - this.energy) * amount;
|
||||
}
|
||||
|
||||
public getActivityAt(time: number): number {
|
||||
const activeGesture =
|
||||
this.isGestureActive && this.currentGesture && !this.currentGesture.isErasing
|
||||
? 0.08 + this.getGestureAmount(this.currentGesture, time) * 0.34
|
||||
: 0;
|
||||
public getActivity(): number {
|
||||
if (!this.isGestureActive) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return clamp01(
|
||||
this.energy * 0.58 + this.getTailActivityAt(time) * 0.72 + activeGesture
|
||||
);
|
||||
return this.getLevel();
|
||||
}
|
||||
|
||||
public getLevel(): number {
|
||||
return clamp01(this.energy);
|
||||
}
|
||||
|
||||
public reset(): void {
|
||||
|
|
@ -105,72 +77,5 @@ export class GardenAudioEnergy {
|
|||
this.energy = 0;
|
||||
this.targetEnergy = 0;
|
||||
this.lastEnergyUpdateAt = 0;
|
||||
this.currentGesture = null;
|
||||
this.gestureTails = [];
|
||||
}
|
||||
|
||||
private addGestureTail(gesture: GardenGestureState, now: number): void {
|
||||
const durationAmount = clamp01(
|
||||
(gesture.lastAt - gesture.startedAt) / this.rhythm.tailDurationForMaxSeconds
|
||||
);
|
||||
const distanceAmount = clamp01(
|
||||
gesture.distancePixels / this.rhythm.tailDistanceForMaxPixels
|
||||
);
|
||||
const gestureAmount = Math.max(distanceAmount, durationAmount * 0.9);
|
||||
const tailAmount = clamp01(gestureAmount * (0.74 + gesture.peakEnergy * 0.26));
|
||||
|
||||
if (tailAmount <= 0.015) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.gestureTails.push({
|
||||
startedAt: now,
|
||||
durationSeconds:
|
||||
this.rhythm.minTailSeconds +
|
||||
(this.rhythm.maxTailSeconds - this.rhythm.minTailSeconds) * tailAmount,
|
||||
level: (0.05 + tailAmount * 0.5) * (0.72 + gesture.peakEnergy * 0.28),
|
||||
});
|
||||
this.trimGestureTails(now);
|
||||
}
|
||||
|
||||
private getGestureAmount(gesture: GardenGestureState, now: number): number {
|
||||
const durationAmount = clamp01(
|
||||
(now - gesture.startedAt) / this.rhythm.tailDurationForMaxSeconds
|
||||
);
|
||||
const distanceAmount = clamp01(
|
||||
gesture.distancePixels / this.rhythm.tailDistanceForMaxPixels
|
||||
);
|
||||
|
||||
return clamp01(
|
||||
distanceAmount * 0.62 + durationAmount * 0.24 + gesture.peakEnergy * 0.14
|
||||
);
|
||||
}
|
||||
|
||||
private getTailActivityAt(time: number): number {
|
||||
const decayPower = Math.max(0.2, this.rhythm.tailDecayPower);
|
||||
|
||||
return this.gestureTails.reduce((activity, tail) => {
|
||||
const elapsedSeconds = time - tail.startedAt;
|
||||
if (elapsedSeconds < 0 || elapsedSeconds >= tail.durationSeconds) {
|
||||
return activity;
|
||||
}
|
||||
|
||||
const remaining = clamp01(1 - elapsedSeconds / tail.durationSeconds);
|
||||
return Math.max(activity, tail.level * Math.pow(remaining, decayPower));
|
||||
}, 0);
|
||||
}
|
||||
|
||||
private trimGestureTails(now: number): void {
|
||||
this.gestureTails = this.gestureTails.filter(
|
||||
(tail) => now - tail.startedAt < tail.durationSeconds
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const createGesture = (now: number, isErasing: boolean): GardenGestureState => ({
|
||||
startedAt: now,
|
||||
lastAt: now,
|
||||
distancePixels: 0,
|
||||
peakEnergy: 0,
|
||||
isErasing,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { appConfig } from '../config';
|
||||
import { clamp } from '../utils/clamp';
|
||||
import { GardenAudioConfig, GardenAudioVibeProfile } from './garden-audio-config';
|
||||
|
||||
|
|
@ -11,6 +12,7 @@ export class GardenAudioGraph {
|
|||
private delayNode: DelayNode | null = null;
|
||||
private delayFeedback: GainNode | null = null;
|
||||
private delayOutput: GainNode | null = null;
|
||||
private hasUnlocked = false;
|
||||
|
||||
public constructor(private readonly config: GardenAudioConfig) {}
|
||||
|
||||
|
|
@ -50,6 +52,26 @@ export class GardenAudioGraph {
|
|||
return context;
|
||||
}
|
||||
|
||||
// iOS WebKit (Safari + Chrome iOS) only fully unlocks audio output once
|
||||
// a buffer source has been started inside a user-gesture handler. Calling
|
||||
// resume() alone leaves the context "running" but silent.
|
||||
public unlock(): void {
|
||||
if (!this.context || this.hasUnlocked) {
|
||||
return;
|
||||
}
|
||||
|
||||
const buffer = this.context.createBuffer(
|
||||
1,
|
||||
appConfig.audioEngine.graph.unlockBufferLength,
|
||||
appConfig.audioEngine.graph.unlockSampleRate
|
||||
);
|
||||
const source = this.context.createBufferSource();
|
||||
source.buffer = buffer;
|
||||
source.connect(this.context.destination);
|
||||
source.start(0);
|
||||
this.hasUnlocked = true;
|
||||
}
|
||||
|
||||
public setMasterGain(targetGain: number, timeConstantSeconds: number): void {
|
||||
if (!this.context || !this.masterGain) {
|
||||
return;
|
||||
|
|
@ -70,7 +92,7 @@ export class GardenAudioGraph {
|
|||
this.delayNode.delayTime.setTargetAtTime(
|
||||
this.config.delay.timeSeconds * profile.delayTimeMultiplier,
|
||||
this.context.currentTime,
|
||||
0.12
|
||||
appConfig.audioEngine.graph.delayTimeRampSeconds
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -83,17 +105,22 @@ export class GardenAudioGraph {
|
|||
this.delayNode.delayTime.setTargetAtTime(
|
||||
this.config.delay.timeSeconds * profile.delayTimeMultiplier,
|
||||
now,
|
||||
0.12
|
||||
appConfig.audioEngine.graph.delayTimeRampSeconds
|
||||
);
|
||||
this.delayFeedback.gain.setTargetAtTime(
|
||||
this.config.delay.enabled
|
||||
? clamp(this.config.delay.feedback + activity * 0.08, 0.04, 0.32)
|
||||
: 0,
|
||||
clamp(
|
||||
this.config.delay.feedback +
|
||||
activity * appConfig.audioEngine.graph.delayActivityFeedbackWeight,
|
||||
appConfig.audioEngine.graph.delayFeedbackMin,
|
||||
appConfig.audioEngine.graph.delayFeedbackMax
|
||||
),
|
||||
now,
|
||||
this.config.updateRampSeconds
|
||||
);
|
||||
this.delayOutput.gain.setTargetAtTime(
|
||||
this.config.delay.enabled ? this.config.delay.wetGain * (0.65 + activity * 0.5) : 0,
|
||||
this.config.delay.wetGain *
|
||||
(appConfig.audioEngine.graph.delayOutputBase +
|
||||
activity * appConfig.audioEngine.graph.delayOutputActivityWeight),
|
||||
now,
|
||||
this.config.updateRampSeconds
|
||||
);
|
||||
|
|
@ -106,7 +133,11 @@ export class GardenAudioGraph {
|
|||
}
|
||||
|
||||
if (this.masterGain && context.state !== 'closed') {
|
||||
this.masterGain.gain.setTargetAtTime(0.0001, context.currentTime, 0.015);
|
||||
this.masterGain.gain.setTargetAtTime(
|
||||
appConfig.audioEngine.graph.closeGain,
|
||||
context.currentTime,
|
||||
appConfig.audioEngine.graph.closeRampSeconds
|
||||
);
|
||||
}
|
||||
|
||||
this.clearNodes();
|
||||
|
|
@ -123,8 +154,8 @@ export class GardenAudioGraph {
|
|||
const delayOutput = context.createGain();
|
||||
|
||||
delayNode.delayTime.value = this.config.delay.timeSeconds;
|
||||
delayFeedback.gain.value = this.config.delay.enabled ? this.config.delay.feedback : 0;
|
||||
delayOutput.gain.value = this.config.delay.enabled ? this.config.delay.wetGain : 0;
|
||||
delayFeedback.gain.value = this.config.delay.feedback;
|
||||
delayOutput.gain.value = this.config.delay.wetGain;
|
||||
|
||||
delayInput.connect(delayNode);
|
||||
delayNode.connect(delayFeedback);
|
||||
|
|
@ -140,7 +171,7 @@ export class GardenAudioGraph {
|
|||
|
||||
private createBuses(context: AudioContext, masterGain: GainNode): void {
|
||||
this.eventBus = context.createGain();
|
||||
this.eventBus.gain.value = 1;
|
||||
this.eventBus.gain.value = appConfig.audioEngine.graph.eventBusGain;
|
||||
this.eventBus.connect(masterGain);
|
||||
}
|
||||
|
||||
|
|
@ -149,7 +180,10 @@ export class GardenAudioGraph {
|
|||
const data = buffer.getChannelData(0);
|
||||
|
||||
for (let index = 0; index < data.length; index++) {
|
||||
data[index] = Math.random() * 2 - 1;
|
||||
data[index] =
|
||||
appConfig.audioEngine.graph.noiseMin +
|
||||
Math.random() *
|
||||
(appConfig.audioEngine.graph.noiseMax - appConfig.audioEngine.graph.noiseMin);
|
||||
}
|
||||
|
||||
return buffer;
|
||||
|
|
@ -164,5 +198,6 @@ export class GardenAudioGraph {
|
|||
this.delayNode = null;
|
||||
this.delayFeedback = null;
|
||||
this.delayOutput = null;
|
||||
this.hasUnlocked = false;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { appConfig } from '../config';
|
||||
import { clamp01 } from '../utils/clamp';
|
||||
import { GardenAudioStroke } from './garden-audio-types';
|
||||
|
||||
|
|
@ -19,8 +20,16 @@ export const getStrokeMetrics = (
|
|||
const speedPixelsPerSecond = getStrokeVelocity(stroke, distancePixels);
|
||||
const pressure = getPressureAmount(stroke, fallbackPressure);
|
||||
const speedAmount = clamp01(speedPixelsPerSecond / speedForFullEnergyPixelsPerSecond);
|
||||
const strokeEnergy = clamp01(0.18 + speedAmount * 0.62 + pressure * 0.22);
|
||||
const effectiveEnergy = strokeEnergy * (0.25 + clamp01(distancePixels / 140) * 0.75);
|
||||
const strokeEnergy = clamp01(
|
||||
appConfig.audioEngine.input.strokeEnergyBase +
|
||||
speedAmount * appConfig.audioEngine.input.strokeEnergySpeedWeight +
|
||||
pressure * appConfig.audioEngine.input.strokeEnergyPressureWeight
|
||||
);
|
||||
const effectiveEnergy =
|
||||
strokeEnergy *
|
||||
(appConfig.audioEngine.input.distanceEnergyBase +
|
||||
clamp01(distancePixels / appConfig.audioEngine.input.distanceForFullEnergyPixels) *
|
||||
appConfig.audioEngine.input.distanceEnergyScale);
|
||||
|
||||
return {
|
||||
distancePixels,
|
||||
|
|
@ -39,7 +48,7 @@ const getStrokeVelocity = (stroke: GardenAudioStroke, distancePixels: number): n
|
|||
return stroke.velocityPixelsPerSecond;
|
||||
}
|
||||
|
||||
return distancePixels / (1 / 60);
|
||||
return distancePixels / appConfig.audioEngine.input.fallbackFrameSeconds;
|
||||
};
|
||||
|
||||
const getPressureAmount = (
|
||||
|
|
@ -55,6 +64,6 @@ const getPressureAmount = (
|
|||
}
|
||||
|
||||
return stroke.pointerType === 'pen'
|
||||
? Math.max(0.56, clamp01(fallbackPressure))
|
||||
? Math.max(appConfig.audioEngine.input.penMinPressure, clamp01(fallbackPressure))
|
||||
: clamp01(fallbackPressure);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,282 +0,0 @@
|
|||
import { clamp, clamp01 } from '../utils/clamp';
|
||||
import { VibePreset } from '../vibes';
|
||||
import {
|
||||
GardenAudioChord,
|
||||
GardenAudioConfig,
|
||||
GardenAudioVibeProfile,
|
||||
} from './garden-audio-config';
|
||||
import {
|
||||
clampMidi,
|
||||
degreeToSemitone,
|
||||
getChordAtStep,
|
||||
getChordIntervals,
|
||||
getVibeProfile,
|
||||
} from './garden-audio-music';
|
||||
import {
|
||||
GardenAudioColorIndex,
|
||||
GardenAudioStroke,
|
||||
PianoNote,
|
||||
} from './garden-audio-types';
|
||||
|
||||
interface RhythmStepRequest {
|
||||
vibe: VibePreset;
|
||||
stepIndex: number;
|
||||
startTime: number;
|
||||
activity: number;
|
||||
selectedColorIndex: GardenAudioColorIndex;
|
||||
}
|
||||
|
||||
interface StrokeTapRequest {
|
||||
stroke: GardenAudioStroke;
|
||||
energy: number;
|
||||
now: number;
|
||||
selectedColorIndex: GardenAudioColorIndex;
|
||||
stepIndex: number;
|
||||
rhythmAnchorTime: number;
|
||||
stepDurationSeconds: number;
|
||||
}
|
||||
|
||||
export class GardenAudioScore {
|
||||
public constructor(
|
||||
private readonly config: GardenAudioConfig,
|
||||
private readonly playNote: (note: PianoNote) => void
|
||||
) {}
|
||||
|
||||
public playRhythmStep({
|
||||
vibe,
|
||||
stepIndex,
|
||||
startTime,
|
||||
activity,
|
||||
selectedColorIndex,
|
||||
}: RhythmStepRequest): void {
|
||||
const profile = getVibeProfile(this.config, vibe);
|
||||
const stepInBar = stepIndex % this.config.rhythm.stepsPerBar;
|
||||
if (activity < this.config.rhythm.sparseActivity) {
|
||||
return;
|
||||
}
|
||||
|
||||
const chord = getChordAtStep(this.config, profile, stepIndex);
|
||||
if (stepInBar === 0 && activity < this.config.rhythm.bassActivity) {
|
||||
this.playRootAnchor(profile, chord, startTime, activity);
|
||||
}
|
||||
|
||||
if (
|
||||
this.config.rhythm.bassSteps.includes(stepInBar) &&
|
||||
activity >= this.config.rhythm.bassActivity
|
||||
) {
|
||||
this.playBassNote(profile, chord, startTime, activity);
|
||||
}
|
||||
|
||||
if (
|
||||
this.config.rhythm.chordSteps.includes(stepInBar) &&
|
||||
activity >= this.config.rhythm.arpeggioActivity
|
||||
) {
|
||||
this.playBrokenChord(profile, chord, startTime, activity, stepInBar === 0);
|
||||
}
|
||||
|
||||
if (this.shouldPlayMelodyStep(stepInBar, activity)) {
|
||||
this.playMelodyNote(
|
||||
profile,
|
||||
chord,
|
||||
stepIndex,
|
||||
startTime,
|
||||
activity,
|
||||
selectedColorIndex
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public playStrokeTap({
|
||||
stroke,
|
||||
energy,
|
||||
now,
|
||||
selectedColorIndex,
|
||||
stepIndex,
|
||||
rhythmAnchorTime,
|
||||
stepDurationSeconds,
|
||||
}: StrokeTapRequest): void {
|
||||
const profile = getVibeProfile(this.config, stroke.vibe);
|
||||
const colorVoice = this.config.colorVoices[selectedColorIndex];
|
||||
const chord = getChordAtStep(this.config, profile, stepIndex);
|
||||
const intervals = getChordIntervals(chord, false);
|
||||
const x = clamp01(stroke.to[0] / Math.max(1, stroke.canvasSize[0]));
|
||||
const y = 1 - clamp01(stroke.to[1] / Math.max(1, stroke.canvasSize[1]));
|
||||
const rawRegister = y < 0.35 ? 0 : y > 0.72 ? 2 : 1;
|
||||
const register =
|
||||
energy < this.config.rhythm.arpeggioActivity
|
||||
? Math.min(rawRegister, 1)
|
||||
: rawRegister;
|
||||
const interval =
|
||||
intervals[
|
||||
(register + colorVoice.scaleDegreeOffset + selectedColorIndex) % intervals.length
|
||||
];
|
||||
|
||||
this.playNote({
|
||||
midi: clampMidi(
|
||||
profile.rootMidi +
|
||||
chord.rootOffset +
|
||||
interval +
|
||||
(energy < this.config.rhythm.arpeggioActivity ? 5 : 12) +
|
||||
register * 5 +
|
||||
(energy >= this.config.rhythm.fullChordActivity
|
||||
? colorVoice.octaveOffset * 7
|
||||
: 0),
|
||||
50,
|
||||
energy < this.config.rhythm.arpeggioActivity ? 76 : 88
|
||||
),
|
||||
velocity: (0.26 + energy * 0.34) * colorVoice.velocityMultiplier,
|
||||
durationSeconds: 1.05 + energy * 0.45,
|
||||
pan: clamp(x * 2 - 1 + colorVoice.panOffset * 0.6, -0.85, 0.85),
|
||||
delaySend: 0.014,
|
||||
lowpassHz: this.config.piano.lowpassHz * profile.brightness,
|
||||
startTime: this.getNearGridTime(now, rhythmAnchorTime, stepDurationSeconds),
|
||||
});
|
||||
}
|
||||
|
||||
public playVibeChangeStinger(vibe: VibePreset, now: number): void {
|
||||
const profile = getVibeProfile(this.config, vibe);
|
||||
const chord = profile.progression[0];
|
||||
const intervals = getChordIntervals(chord, true);
|
||||
|
||||
intervals.forEach((interval, index) => {
|
||||
this.playNote({
|
||||
midi: clampMidi(profile.rootMidi + chord.rootOffset + interval, 48, 84),
|
||||
velocity: 0.3 * Math.pow(0.9, index),
|
||||
durationSeconds: 1.4,
|
||||
pan: clamp(-0.22 + index * 0.14, -0.5, 0.5),
|
||||
delaySend: 0.016,
|
||||
lowpassHz: this.config.piano.lowpassHz * profile.brightness,
|
||||
startTime: now + index * 0.045,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private shouldPlayMelodyStep(stepInBar: number, activity: number): boolean {
|
||||
if (activity < this.config.rhythm.sparseActivity) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (activity < this.config.rhythm.arpeggioActivity) {
|
||||
return stepInBar === 10;
|
||||
}
|
||||
|
||||
if (activity < this.config.rhythm.fullChordActivity) {
|
||||
return stepInBar === 6 || stepInBar === 10;
|
||||
}
|
||||
|
||||
return this.config.rhythm.melodySteps.includes(stepInBar);
|
||||
}
|
||||
|
||||
private playRootAnchor(
|
||||
profile: GardenAudioVibeProfile,
|
||||
chord: GardenAudioChord,
|
||||
startTime: number,
|
||||
activity: number
|
||||
): void {
|
||||
this.playNote({
|
||||
midi: clampMidi(profile.rootMidi + chord.rootOffset, 43, 64),
|
||||
velocity: 0.13 + activity * 0.14,
|
||||
durationSeconds: 1.35,
|
||||
pan: -0.06,
|
||||
delaySend: 0.004,
|
||||
lowpassHz: this.config.piano.lowpassHz * profile.brightness * 0.78,
|
||||
startTime,
|
||||
});
|
||||
}
|
||||
|
||||
private playBassNote(
|
||||
profile: GardenAudioVibeProfile,
|
||||
chord: GardenAudioChord,
|
||||
startTime: number,
|
||||
activity: number
|
||||
): void {
|
||||
this.playNote({
|
||||
midi: clampMidi(profile.rootMidi + chord.rootOffset - 12, 36, 58),
|
||||
velocity: 0.24 + activity * 0.18,
|
||||
durationSeconds: 1.8,
|
||||
pan: -0.08,
|
||||
delaySend: 0.006,
|
||||
startTime,
|
||||
});
|
||||
}
|
||||
|
||||
private playBrokenChord(
|
||||
profile: GardenAudioVibeProfile,
|
||||
chord: GardenAudioChord,
|
||||
startTime: number,
|
||||
activity: number,
|
||||
isAccent: boolean
|
||||
): void {
|
||||
const intervals = getChordIntervals(chord, true);
|
||||
const velocity = (isAccent ? 0.22 : 0.16) + activity * 0.16;
|
||||
const baseMidi = profile.rootMidi + chord.rootOffset;
|
||||
const isFullChord = activity >= this.config.rhythm.fullChordActivity;
|
||||
const noteCount = isFullChord ? intervals.length : activity >= 0.46 ? 3 : 2;
|
||||
const staggerSeconds = isFullChord ? 0.018 : 0.056;
|
||||
|
||||
intervals.slice(0, noteCount).forEach((interval, index) => {
|
||||
this.playNote({
|
||||
midi: clampMidi(baseMidi + interval, 48, 78),
|
||||
velocity: velocity * Math.pow(0.9, index),
|
||||
durationSeconds: isFullChord ? (isAccent ? 1.9 : 1.35) : 1.08,
|
||||
pan: clamp(-0.16 + index * 0.1, -0.45, 0.45),
|
||||
delaySend: 0.012,
|
||||
lowpassHz: this.config.piano.lowpassHz * profile.brightness,
|
||||
startTime: startTime + index * staggerSeconds,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private playMelodyNote(
|
||||
profile: GardenAudioVibeProfile,
|
||||
chord: GardenAudioChord,
|
||||
stepIndex: number,
|
||||
startTime: number,
|
||||
activity: number,
|
||||
selectedColorIndex: GardenAudioColorIndex
|
||||
): void {
|
||||
const colorVoice = this.config.colorVoices[selectedColorIndex];
|
||||
const patternIndex =
|
||||
Math.floor(stepIndex / 2) + colorVoice.scaleDegreeOffset + selectedColorIndex;
|
||||
const scaleDegree =
|
||||
this.config.rhythm.melodyPattern[
|
||||
patternIndex % this.config.rhythm.melodyPattern.length
|
||||
] + colorVoice.scaleDegreeOffset;
|
||||
const stepInBeat = stepIndex % this.config.rhythm.stepsPerBeat;
|
||||
const semitoneOffset =
|
||||
stepInBeat === 0
|
||||
? chord.rootOffset +
|
||||
getChordIntervals(chord, false)[(patternIndex + selectedColorIndex) % 3]
|
||||
: degreeToSemitone(profile, scaleDegree);
|
||||
const registerLift = activity < this.config.rhythm.arpeggioActivity ? 0 : 12;
|
||||
const colorOctaveLift =
|
||||
activity >= this.config.rhythm.fullChordActivity ? colorVoice.octaveOffset * 12 : 0;
|
||||
|
||||
this.playNote({
|
||||
midi: clampMidi(
|
||||
profile.rootMidi + semitoneOffset + registerLift + colorOctaveLift,
|
||||
activity < this.config.rhythm.arpeggioActivity ? 50 : 57,
|
||||
activity < this.config.rhythm.arpeggioActivity ? 76 : 91
|
||||
),
|
||||
velocity:
|
||||
(0.2 + activity * 0.32) *
|
||||
colorVoice.velocityMultiplier *
|
||||
(stepInBeat === 0 ? 1.08 : 1),
|
||||
durationSeconds: 0.92 + activity * 0.38,
|
||||
pan: colorVoice.panOffset,
|
||||
delaySend: 0.018,
|
||||
lowpassHz: this.config.piano.lowpassHz * profile.brightness,
|
||||
startTime,
|
||||
});
|
||||
}
|
||||
|
||||
private getNearGridTime(
|
||||
now: number,
|
||||
rhythmAnchorTime: number,
|
||||
stepDurationSeconds: number
|
||||
): number {
|
||||
const stepCount = Math.ceil((now - rhythmAnchorTime) / stepDurationSeconds);
|
||||
const gridTime = rhythmAnchorTime + Math.max(0, stepCount) * stepDurationSeconds;
|
||||
return gridTime - now <= 0.1 ? gridTime : now + 0.012;
|
||||
}
|
||||
}
|
||||
|
|
@ -4,16 +4,8 @@ export type GardenAudioColorIndex = 0 | 1 | 2;
|
|||
|
||||
export interface GardenAudioSnapshot {
|
||||
vibe: VibePreset;
|
||||
activeAgentCount: number;
|
||||
agentBudgetMax: number;
|
||||
selectedColorIndex: number;
|
||||
isErasing: boolean;
|
||||
introProgress: number;
|
||||
moveSpeed: number;
|
||||
diffusionRateTrails: number;
|
||||
decayRateTrails: number;
|
||||
brushEffectDuration: number;
|
||||
clarity: number;
|
||||
}
|
||||
|
||||
export interface GardenAudioStroke {
|
||||
|
|
@ -41,6 +33,7 @@ export interface LoadedPianoSample {
|
|||
export interface ActivePianoVoice {
|
||||
gain: GainNode;
|
||||
source: AudioBufferSourceNode;
|
||||
startAt: number;
|
||||
stopAt: number;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -82,6 +82,18 @@ class FakeAudioContext {
|
|||
return new FakeAudioBuffer(length) as unknown as AudioBuffer;
|
||||
}
|
||||
|
||||
public createBufferSource(): AudioBufferSourceNode {
|
||||
const node = new FakeAudioNode() as unknown as AudioBufferSourceNode & {
|
||||
buffer: AudioBuffer | null;
|
||||
start: () => void;
|
||||
stop: () => void;
|
||||
};
|
||||
node.buffer = null;
|
||||
node.start = vi.fn();
|
||||
node.stop = vi.fn();
|
||||
return node;
|
||||
}
|
||||
|
||||
public async resume(): Promise<void> {
|
||||
calls.resumed += 1;
|
||||
contextState = 'running';
|
||||
|
|
@ -90,10 +102,6 @@ class FakeAudioContext {
|
|||
|
||||
const makeConfig = (): GardenAudioConfig => ({
|
||||
...gardenAudioConfig,
|
||||
piano: {
|
||||
...gardenAudioConfig.piano,
|
||||
preloadOnStart: false,
|
||||
},
|
||||
});
|
||||
|
||||
describe('GardenAudio startup policy', () => {
|
||||
|
|
@ -102,6 +110,7 @@ describe('GardenAudio startup policy', () => {
|
|||
calls.resumed = 0;
|
||||
contextState = 'suspended';
|
||||
vi.stubGlobal('AudioContext', FakeAudioContext);
|
||||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('not loaded in tests')));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { appConfig } from '../config';
|
||||
import { clamp, clamp01 } from '../utils/clamp';
|
||||
import { VibePreset } from '../vibes';
|
||||
import { GardenAudioConfig } from './garden-audio-config';
|
||||
|
|
@ -5,13 +6,13 @@ import { GardenAudioEnergy } from './garden-audio-energy';
|
|||
import { GardenAudioGraph } from './garden-audio-graph';
|
||||
import { getStrokeMetrics } from './garden-audio-input';
|
||||
import { getVibeProfile, normalizeColorIndex } from './garden-audio-music';
|
||||
import { GardenAudioScore } from './garden-audio-score';
|
||||
import type {
|
||||
GardenAudioColorIndex,
|
||||
GardenAudioSnapshot,
|
||||
GardenAudioStartOptions,
|
||||
GardenAudioStroke,
|
||||
} from './garden-audio-types';
|
||||
import { GenerativePianoEngine } from './generative-piano';
|
||||
import { NoiseBurstPlayer } from './noise-burst-player';
|
||||
import { PianoSampler } from './piano-sampler';
|
||||
|
||||
|
|
@ -26,18 +27,14 @@ export class GardenAudio {
|
|||
private readonly piano: PianoSampler;
|
||||
private readonly noise: NoiseBurstPlayer;
|
||||
private readonly energy: GardenAudioEnergy;
|
||||
private readonly score: GardenAudioScore;
|
||||
private readonly pianoEngine: GenerativePianoEngine;
|
||||
|
||||
private currentVibeId: string | null = null;
|
||||
private hasStarted = false;
|
||||
private isDestroyed = false;
|
||||
private isMuted = false;
|
||||
private isGestureActive = false;
|
||||
private selectedColorIndex: GardenAudioColorIndex = 0;
|
||||
private rhythmAnchorTime: number | null = null;
|
||||
private startedAt: number | null = null;
|
||||
private nextStepAt = 0;
|
||||
private stepIndex = 0;
|
||||
private lastTapAt = Number.NEGATIVE_INFINITY;
|
||||
private lastEraserAt = Number.NEGATIVE_INFINITY;
|
||||
private lastVibeStingerAt = Number.NEGATIVE_INFINITY;
|
||||
|
||||
|
|
@ -45,12 +42,12 @@ export class GardenAudio {
|
|||
this.graph = new GardenAudioGraph(config);
|
||||
this.piano = new PianoSampler(config, this.graph);
|
||||
this.noise = new NoiseBurstPlayer(this.graph);
|
||||
this.energy = new GardenAudioEnergy(config.rhythm);
|
||||
this.score = new GardenAudioScore(config, (note) => this.piano.play(note));
|
||||
this.energy = new GardenAudioEnergy();
|
||||
this.pianoEngine = new GenerativePianoEngine(config, (note) => this.piano.play(note));
|
||||
}
|
||||
|
||||
public start(vibe: VibePreset, options: GardenAudioStartOptions = {}): void {
|
||||
if (!this.config.enabled || this.isDestroyed || this.isMuted) {
|
||||
if (this.isDestroyed || this.isMuted) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -59,6 +56,10 @@ export class GardenAudio {
|
|||
return;
|
||||
}
|
||||
|
||||
if (options.userGesture === true) {
|
||||
this.graph.unlock();
|
||||
}
|
||||
|
||||
if (context.state === 'suspended') {
|
||||
if (options.userGesture !== true) {
|
||||
return;
|
||||
|
|
@ -67,17 +68,11 @@ export class GardenAudio {
|
|||
}
|
||||
|
||||
this.hasStarted = true;
|
||||
this.rhythmAnchorTime ??= context.currentTime;
|
||||
this.startedAt ??= context.currentTime;
|
||||
this.applyVibe(vibe);
|
||||
if (this.nextStepAt <= 0) {
|
||||
this.nextStepAt = context.currentTime + 0.02;
|
||||
}
|
||||
this.pianoEngine.prime(context.currentTime);
|
||||
this.graph.setMasterGain(this.config.masterVolume, this.config.fadeInSeconds);
|
||||
|
||||
if (this.config.piano.preloadOnStart) {
|
||||
void this.piano.load(context);
|
||||
}
|
||||
void this.piano.load(context);
|
||||
}
|
||||
|
||||
public changeVibe(vibe: VibePreset, options: GardenAudioStartOptions = {}): void {
|
||||
|
|
@ -100,8 +95,8 @@ export class GardenAudio {
|
|||
public setMuted(isMuted: boolean): void {
|
||||
this.isMuted = isMuted;
|
||||
this.graph.setMasterGain(
|
||||
isMuted ? 0.0001 : this.config.masterVolume,
|
||||
isMuted ? 0.02 : this.config.fadeInSeconds
|
||||
isMuted ? appConfig.audioEngine.muteGain : this.config.masterVolume,
|
||||
isMuted ? appConfig.audioEngine.muteRampSeconds : this.config.fadeInSeconds
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -111,20 +106,21 @@ export class GardenAudio {
|
|||
return;
|
||||
}
|
||||
|
||||
this.isGestureActive = true;
|
||||
this.energy.beginGesture(context.currentTime);
|
||||
this.pianoEngine.beginGesture(context.currentTime);
|
||||
}
|
||||
|
||||
public endGesture(): void {
|
||||
const context = this.graph.context;
|
||||
this.isGestureActive = false;
|
||||
this.energy.endGesture();
|
||||
this.pianoEngine.endGesture();
|
||||
if (!context) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.energy.endGesture(context.currentTime);
|
||||
}
|
||||
|
||||
public rememberColor(colorIndex: number): void {
|
||||
this.selectedColorIndex = normalizeColorIndex(colorIndex);
|
||||
this.piano.fadeActive(context.currentTime, appConfig.audioEngine.gestureFadeSeconds);
|
||||
}
|
||||
|
||||
public update(snapshot: GardenAudioSnapshot): void {
|
||||
|
|
@ -139,17 +135,27 @@ export class GardenAudio {
|
|||
|
||||
if (snapshot.isErasing) {
|
||||
this.energy.silence();
|
||||
this.piano.fadeActive(context.currentTime);
|
||||
this.piano.fadeActive(
|
||||
context.currentTime,
|
||||
appConfig.audioEngine.gestureFadeSeconds
|
||||
);
|
||||
this.updateDelay(snapshot);
|
||||
return;
|
||||
}
|
||||
|
||||
this.scheduleRhythm(snapshot.vibe);
|
||||
if (this.isGestureActive) {
|
||||
this.pianoEngine.renderLookahead({
|
||||
vibe: snapshot.vibe,
|
||||
now: context.currentTime,
|
||||
activity: this.energy.getActivity(),
|
||||
selectedColorIndex: this.selectedColorIndex,
|
||||
});
|
||||
}
|
||||
this.updateDelay(snapshot);
|
||||
}
|
||||
|
||||
public stroke(stroke: GardenAudioStroke): void {
|
||||
if (!this.config.enabled || this.isDestroyed || this.isMuted) {
|
||||
if (this.isDestroyed || this.isMuted) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -158,6 +164,9 @@ export class GardenAudio {
|
|||
if (!context) {
|
||||
return;
|
||||
}
|
||||
if (!this.isGestureActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
const metrics = getStrokeMetrics(
|
||||
stroke,
|
||||
|
|
@ -169,32 +178,14 @@ export class GardenAudio {
|
|||
this.selectedColorIndex = normalizeColorIndex(stroke.colorIndex);
|
||||
|
||||
if (stroke.isErasing) {
|
||||
this.energy.recordEraserStroke(now);
|
||||
this.energy.recordEraserStroke();
|
||||
this.playEraser(stroke, metrics.speedAmount, metrics.pressure, now);
|
||||
return;
|
||||
}
|
||||
|
||||
const strokeEnergy = metrics.effectiveEnergy * this.getStartupEnergyScale(now);
|
||||
this.energy.recordStroke(metrics.distancePixels, strokeEnergy, now);
|
||||
if (metrics.distancePixels >= 2.5) {
|
||||
this.energy.raiseTarget(strokeEnergy);
|
||||
}
|
||||
|
||||
if (
|
||||
metrics.distancePixels >= 2.5 &&
|
||||
now - this.lastTapAt >= this.getTapIntervalSeconds(now)
|
||||
) {
|
||||
this.lastTapAt = now;
|
||||
this.score.playStrokeTap({
|
||||
stroke,
|
||||
energy: strokeEnergy,
|
||||
now,
|
||||
selectedColorIndex: this.selectedColorIndex,
|
||||
stepIndex: this.stepIndex,
|
||||
rhythmAnchorTime: this.getRhythmAnchorTime(now),
|
||||
stepDurationSeconds: this.getStepDurationSeconds(now),
|
||||
});
|
||||
}
|
||||
const strokeEnergy = metrics.effectiveEnergy;
|
||||
this.energy.recordStroke(strokeEnergy, now);
|
||||
this.pianoEngine.wake(now);
|
||||
}
|
||||
|
||||
public async destroy(): Promise<void> {
|
||||
|
|
@ -203,50 +194,15 @@ export class GardenAudio {
|
|||
|
||||
this.piano.reset();
|
||||
this.energy.reset();
|
||||
this.pianoEngine.reset();
|
||||
this.currentVibeId = null;
|
||||
this.hasStarted = false;
|
||||
this.isGestureActive = false;
|
||||
this.selectedColorIndex = 0;
|
||||
this.rhythmAnchorTime = null;
|
||||
this.startedAt = null;
|
||||
this.nextStepAt = 0;
|
||||
this.stepIndex = 0;
|
||||
this.lastTapAt = Number.NEGATIVE_INFINITY;
|
||||
this.lastEraserAt = Number.NEGATIVE_INFINITY;
|
||||
this.lastVibeStingerAt = Number.NEGATIVE_INFINITY;
|
||||
}
|
||||
|
||||
private scheduleRhythm(vibe: VibePreset): void {
|
||||
const context = this.graph.context;
|
||||
if (!context || !this.graph.eventBus) {
|
||||
return;
|
||||
}
|
||||
|
||||
const now = context.currentTime;
|
||||
const stepSeconds = this.getStepDurationSeconds(now);
|
||||
if (this.nextStepAt <= 0 || this.nextStepAt < now - stepSeconds) {
|
||||
this.nextStepAt = now + 0.02;
|
||||
}
|
||||
|
||||
const lookaheadEnd = now + this.config.rhythm.lookaheadSeconds;
|
||||
while (this.nextStepAt <= lookaheadEnd) {
|
||||
const swingOffset =
|
||||
this.stepIndex % 2 === 1 ? stepSeconds * this.config.rhythm.swing : 0;
|
||||
const startTime = this.nextStepAt + swingOffset;
|
||||
this.score.playRhythmStep({
|
||||
vibe,
|
||||
stepIndex: this.stepIndex,
|
||||
startTime,
|
||||
activity: this.getSettledActivity(
|
||||
this.energy.getActivityAt(startTime),
|
||||
startTime
|
||||
),
|
||||
selectedColorIndex: this.selectedColorIndex,
|
||||
});
|
||||
this.nextStepAt += stepSeconds;
|
||||
this.stepIndex += 1;
|
||||
}
|
||||
}
|
||||
|
||||
private playVibeChangeStinger(vibe: VibePreset): void {
|
||||
const context = this.graph.context;
|
||||
if (!context) {
|
||||
|
|
@ -254,12 +210,15 @@ export class GardenAudio {
|
|||
}
|
||||
|
||||
const now = context.currentTime;
|
||||
if (now - this.lastVibeStingerAt < 0.45) {
|
||||
if (
|
||||
now - this.lastVibeStingerAt <
|
||||
appConfig.audioEngine.vibeChangeStingerMinIntervalSeconds
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.lastVibeStingerAt = now;
|
||||
this.score.playVibeChangeStinger(vibe, now);
|
||||
this.pianoEngine.playVibeChangeStinger(vibe, now);
|
||||
}
|
||||
|
||||
private playEraser(
|
||||
|
|
@ -268,27 +227,38 @@ export class GardenAudio {
|
|||
pressure: number,
|
||||
now: number
|
||||
): void {
|
||||
if (!this.config.eraser.enabled || !this.graph.context) {
|
||||
if (!this.graph.context) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sizeAmount = clamp01(
|
||||
(stroke.eraserSizePixels ?? 96) / Math.max(1, stroke.canvasSize[0] * 0.18)
|
||||
(stroke.eraserSizePixels ?? appConfig.audioEngine.eraser.defaultSizePixels) /
|
||||
Math.max(
|
||||
1,
|
||||
stroke.canvasSize[0] * appConfig.audioEngine.eraser.canvasWidthRatioForFullSize
|
||||
)
|
||||
);
|
||||
const x = clamp01(stroke.to[0] / Math.max(1, stroke.canvasSize[0]));
|
||||
const filterHz =
|
||||
this.config.eraser.filterMinHz +
|
||||
(this.config.eraser.filterMaxHz - this.config.eraser.filterMinHz) *
|
||||
clamp01(speedAmount * 0.58 + pressure * 0.26 + sizeAmount * 0.16);
|
||||
clamp01(
|
||||
speedAmount * appConfig.audioEngine.eraser.filterSpeedWeight +
|
||||
pressure * appConfig.audioEngine.eraser.filterPressureWeight +
|
||||
sizeAmount * appConfig.audioEngine.eraser.filterSizeWeight
|
||||
);
|
||||
|
||||
if (now - this.lastEraserAt >= this.config.eraser.minIntervalSeconds) {
|
||||
this.lastEraserAt = now;
|
||||
this.noise.play({
|
||||
startTime: now,
|
||||
durationSeconds: 0.08,
|
||||
durationSeconds: appConfig.audioEngine.eraser.durationSeconds,
|
||||
gain:
|
||||
this.config.eraser.noiseGain *
|
||||
(0.45 + speedAmount * 0.38 + pressure * 0.24 + sizeAmount * 0.18),
|
||||
(appConfig.audioEngine.eraser.gainBase +
|
||||
speedAmount * appConfig.audioEngine.eraser.gainSpeedWeight +
|
||||
pressure * appConfig.audioEngine.eraser.gainPressureWeight +
|
||||
sizeAmount * appConfig.audioEngine.eraser.gainSizeWeight),
|
||||
filterHz,
|
||||
pan: clamp(x * 2 - 1, -1, 1),
|
||||
});
|
||||
|
|
@ -303,11 +273,8 @@ export class GardenAudio {
|
|||
|
||||
const profile = getVibeProfile(this.config, snapshot.vibe);
|
||||
const activity = snapshot.isErasing
|
||||
? 0.12
|
||||
: this.getSettledActivity(
|
||||
this.energy.getActivityAt(context.currentTime),
|
||||
context.currentTime
|
||||
);
|
||||
? appConfig.audioEngine.delay.erasingActivity
|
||||
: this.energy.getLevel();
|
||||
this.graph.updateDelay(profile, activity);
|
||||
}
|
||||
|
||||
|
|
@ -319,68 +286,4 @@ export class GardenAudio {
|
|||
this.currentVibeId = vibe.id;
|
||||
this.graph.applyDelayProfile(getVibeProfile(this.config, vibe));
|
||||
}
|
||||
|
||||
private getStepDurationSeconds(time: number): number {
|
||||
const baseStepSeconds = 60 / this.config.rhythm.bpm / this.config.rhythm.stepsPerBeat;
|
||||
return baseStepSeconds * this.getStartupTempoMultiplier(time);
|
||||
}
|
||||
|
||||
private getRhythmAnchorTime(now: number): number {
|
||||
this.rhythmAnchorTime ??= now;
|
||||
return this.rhythmAnchorTime;
|
||||
}
|
||||
|
||||
private getTapIntervalSeconds(time: number): number {
|
||||
return (
|
||||
this.config.rhythm.minTapIntervalSeconds *
|
||||
this.getStartupTapIntervalMultiplier(time)
|
||||
);
|
||||
}
|
||||
|
||||
private getSettledActivity(activity: number, time: number): number {
|
||||
return Math.min(activity, this.getStartupActivityCeiling(time));
|
||||
}
|
||||
|
||||
private getStartupEnergyScale(time: number): number {
|
||||
return this.interpolateStartupValue(
|
||||
this.config.startup.initialEnergyMultiplier,
|
||||
1,
|
||||
time
|
||||
);
|
||||
}
|
||||
|
||||
private getStartupActivityCeiling(time: number): number {
|
||||
return this.interpolateStartupValue(
|
||||
this.config.startup.initialActivityCeiling,
|
||||
1,
|
||||
time
|
||||
);
|
||||
}
|
||||
|
||||
private getStartupTempoMultiplier(time: number): number {
|
||||
return this.interpolateStartupValue(
|
||||
this.config.startup.initialTempoMultiplier,
|
||||
1,
|
||||
time
|
||||
);
|
||||
}
|
||||
|
||||
private getStartupTapIntervalMultiplier(time: number): number {
|
||||
return this.interpolateStartupValue(
|
||||
this.config.startup.initialTapIntervalMultiplier,
|
||||
1,
|
||||
time
|
||||
);
|
||||
}
|
||||
|
||||
private interpolateStartupValue(from: number, to: number, time: number): number {
|
||||
const durationSeconds = this.config.startup.calmDurationSeconds;
|
||||
if (this.startedAt === null || durationSeconds <= 0) {
|
||||
return to;
|
||||
}
|
||||
|
||||
const progress = clamp01((time - this.startedAt) / durationSeconds);
|
||||
const easedProgress = progress * progress * (3 - 2 * progress);
|
||||
return from + (to - from) * easedProgress;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
118
src/audio/generative-piano.test.ts
Normal file
118
src/audio/generative-piano.test.ts
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { VIBE_PRESETS } from '../vibes';
|
||||
import { gardenAudioConfig } from './garden-audio-config';
|
||||
import { PianoNote } from './garden-audio-types';
|
||||
import { GenerativePianoEngine } from './generative-piano';
|
||||
|
||||
const makeEngine = () => {
|
||||
const notes: Array<PianoNote> = [];
|
||||
const engine = new GenerativePianoEngine(gardenAudioConfig, (note) => {
|
||||
notes.push(note);
|
||||
});
|
||||
|
||||
return { engine, notes };
|
||||
};
|
||||
|
||||
const renderBars = (
|
||||
engine: GenerativePianoEngine,
|
||||
activity: number,
|
||||
selectedColorIndex = 0,
|
||||
bars = 4
|
||||
) => {
|
||||
engine.renderLookahead({
|
||||
vibe: VIBE_PRESETS[0],
|
||||
now: 0,
|
||||
activity,
|
||||
selectedColorIndex: selectedColorIndex as 0 | 1 | 2,
|
||||
lookaheadSeconds:
|
||||
(60 / gardenAudioConfig.rhythm.bpm) *
|
||||
Math.round(
|
||||
gardenAudioConfig.rhythm.stepsPerBar / gardenAudioConfig.rhythm.stepsPerBeat
|
||||
) *
|
||||
bars,
|
||||
});
|
||||
};
|
||||
|
||||
const average = (values: Array<number>): number =>
|
||||
values.reduce((sum, value) => sum + value, 0) / values.length;
|
||||
|
||||
describe('GenerativePianoEngine', () => {
|
||||
it('does not emit notes below the sparse activity threshold', () => {
|
||||
const { engine, notes } = makeEngine();
|
||||
|
||||
renderBars(engine, gardenAudioConfig.rhythm.sparseActivity - 0.01);
|
||||
|
||||
expect(notes).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('keeps drawing notes on beat starts', () => {
|
||||
const { engine, notes } = makeEngine();
|
||||
const beatSeconds = 60 / gardenAudioConfig.rhythm.bpm;
|
||||
const startDelaySeconds = 0.02;
|
||||
|
||||
renderBars(engine, 1, 1);
|
||||
|
||||
expect(notes.length).toBeGreaterThan(0);
|
||||
notes.forEach((note) => {
|
||||
const beatsFromStart = (note.startTime - startDelaySeconds) / beatSeconds;
|
||||
expect(Math.abs(beatsFromStart - Math.round(beatsFromStart))).toBeLessThan(0.001);
|
||||
});
|
||||
});
|
||||
|
||||
it('varies density with activity without exceeding one note per beat', () => {
|
||||
const low = makeEngine();
|
||||
const high = makeEngine();
|
||||
|
||||
renderBars(low.engine, gardenAudioConfig.rhythm.sparseActivity + 0.03, 1);
|
||||
renderBars(high.engine, 1, 1);
|
||||
|
||||
expect(high.notes.length).toBeGreaterThan(low.notes.length);
|
||||
expect(high.notes.length).toBeLessThanOrEqual(16);
|
||||
});
|
||||
|
||||
it('wakes every color with a prompt first note at low activity', () => {
|
||||
([0, 1, 2] as const).forEach((selectedColorIndex) => {
|
||||
const { engine, notes } = makeEngine();
|
||||
const now = 4;
|
||||
|
||||
engine.beginGesture(1);
|
||||
engine.wake(now);
|
||||
engine.renderLookahead({
|
||||
vibe: VIBE_PRESETS[0],
|
||||
now,
|
||||
activity: gardenAudioConfig.rhythm.sparseActivity + 0.01,
|
||||
selectedColorIndex,
|
||||
lookaheadSeconds: 0.08,
|
||||
});
|
||||
|
||||
expect(notes).toHaveLength(1);
|
||||
expect(notes[0].startTime).toBeCloseTo(now + 0.02);
|
||||
});
|
||||
});
|
||||
|
||||
it('uses different color roles for register and pan', () => {
|
||||
const anchor = makeEngine();
|
||||
const spark = makeEngine();
|
||||
|
||||
renderBars(anchor.engine, 1, 0);
|
||||
renderBars(spark.engine, 1, 2);
|
||||
|
||||
expect(average(spark.notes.map((note) => note.midi))).toBeGreaterThan(
|
||||
average(anchor.notes.map((note) => note.midi))
|
||||
);
|
||||
expect(average(spark.notes.map((note) => note.pan))).toBeGreaterThan(
|
||||
average(anchor.notes.map((note) => note.pan))
|
||||
);
|
||||
});
|
||||
|
||||
it('is deterministic for the same phrase inputs', () => {
|
||||
const first = makeEngine();
|
||||
const second = makeEngine();
|
||||
|
||||
renderBars(first.engine, 0.78, 2);
|
||||
renderBars(second.engine, 0.78, 2);
|
||||
|
||||
expect(second.notes).toEqual(first.notes);
|
||||
});
|
||||
});
|
||||
674
src/audio/generative-piano.ts
Normal file
674
src/audio/generative-piano.ts
Normal file
|
|
@ -0,0 +1,674 @@
|
|||
import { appConfig } from '../config';
|
||||
import { clamp, clamp01 } from '../utils/clamp';
|
||||
import { VibePreset } from '../vibes';
|
||||
import {
|
||||
GardenAudioChord,
|
||||
GardenAudioConfig,
|
||||
GardenAudioVibeProfile,
|
||||
} from './garden-audio-config';
|
||||
import {
|
||||
clampMidi,
|
||||
degreeToSemitone,
|
||||
getChordAtStep,
|
||||
getChordIntervals,
|
||||
getVibeProfile,
|
||||
} from './garden-audio-music';
|
||||
import { GardenAudioColorIndex, PianoNote } from './garden-audio-types';
|
||||
|
||||
interface RenderLookaheadRequest {
|
||||
vibe: VibePreset;
|
||||
now: number;
|
||||
activity: number;
|
||||
selectedColorIndex: GardenAudioColorIndex;
|
||||
lookaheadSeconds?: number;
|
||||
}
|
||||
|
||||
interface PianoRole {
|
||||
name: string;
|
||||
midiMin: number;
|
||||
midiMax: number;
|
||||
preferredMidi: number;
|
||||
pan: number;
|
||||
delaySend: number;
|
||||
velocityBase: number;
|
||||
velocityActivityScale: number;
|
||||
durationBase: number;
|
||||
durationActivityScale: number;
|
||||
downbeatDurationBoost: number;
|
||||
lowBeats: ReadonlyArray<number>;
|
||||
mediumBeats: ReadonlyArray<number>;
|
||||
highBeats: ReadonlyArray<number>;
|
||||
lowPhraseOffset: number;
|
||||
lowPhraseSpacing: number;
|
||||
lowKeepChance: number;
|
||||
mediumKeepChance: number;
|
||||
highKeepChance: number;
|
||||
}
|
||||
|
||||
interface PitchCandidate {
|
||||
midi: number;
|
||||
preference: number;
|
||||
}
|
||||
|
||||
const COLOR_ROLES: [PianoRole, PianoRole, PianoRole] = [
|
||||
{
|
||||
name: 'anchor',
|
||||
midiMin: 45,
|
||||
midiMax: 64,
|
||||
preferredMidi: 53,
|
||||
pan: -0.28,
|
||||
delaySend: 0.012,
|
||||
velocityBase: 0.12,
|
||||
velocityActivityScale: 0.15,
|
||||
durationBase: 1.2,
|
||||
durationActivityScale: 0.75,
|
||||
downbeatDurationBoost: 0.32,
|
||||
lowBeats: [0, 2],
|
||||
mediumBeats: [0, 2],
|
||||
highBeats: [0, 2],
|
||||
lowPhraseOffset: 0,
|
||||
lowPhraseSpacing: 4,
|
||||
lowKeepChance: 1,
|
||||
mediumKeepChance: 0.92,
|
||||
highKeepChance: 1,
|
||||
},
|
||||
{
|
||||
name: 'body',
|
||||
midiMin: 55,
|
||||
midiMax: 74,
|
||||
preferredMidi: 64,
|
||||
pan: 0,
|
||||
delaySend: 0.009,
|
||||
velocityBase: 0.115,
|
||||
velocityActivityScale: 0.18,
|
||||
durationBase: 0.86,
|
||||
durationActivityScale: 0.54,
|
||||
downbeatDurationBoost: 0.18,
|
||||
lowBeats: [0, 2],
|
||||
mediumBeats: [0, 2],
|
||||
highBeats: [0, 1, 2, 3],
|
||||
lowPhraseOffset: 0,
|
||||
lowPhraseSpacing: 4,
|
||||
lowKeepChance: 0.86,
|
||||
mediumKeepChance: 0.86,
|
||||
highKeepChance: 0.9,
|
||||
},
|
||||
{
|
||||
name: 'spark',
|
||||
midiMin: 67,
|
||||
midiMax: 84,
|
||||
preferredMidi: 76,
|
||||
pan: 0.28,
|
||||
delaySend: 0.018,
|
||||
velocityBase: 0.09,
|
||||
velocityActivityScale: 0.14,
|
||||
durationBase: 0.48,
|
||||
durationActivityScale: 0.32,
|
||||
downbeatDurationBoost: 0,
|
||||
lowBeats: [0, 2],
|
||||
mediumBeats: [1, 3],
|
||||
highBeats: [0, 1, 2, 3],
|
||||
lowPhraseOffset: 0,
|
||||
lowPhraseSpacing: 4,
|
||||
lowKeepChance: 0.76,
|
||||
mediumKeepChance: 0.8,
|
||||
highKeepChance: 0.78,
|
||||
},
|
||||
];
|
||||
|
||||
const PHRASE_BAR_COUNT = 4;
|
||||
const PACE_MIN = 0.96;
|
||||
const PACE_MAX = 1.08;
|
||||
const PACE_RAMP_SECONDS = 2.8;
|
||||
const FIRST_NOTE_MAX_WAIT_SECONDS = 0.09;
|
||||
const NOTE_SCORE_PREFERENCE_WEIGHT = 1.8;
|
||||
const NOTE_SCORE_REGISTER_WEIGHT = 0.28;
|
||||
const STINGER_SPACING_SECONDS = 0.07;
|
||||
const STINGER_DURATION_SECONDS = 1.45;
|
||||
const PHRASE_CONTOURS: ReadonlyArray<ReadonlyArray<number>> = [
|
||||
[0, 0, 1, 0, -1, 0, 1, 0],
|
||||
[0, 1, 2, 1, 0, -1, 0, 1],
|
||||
[1, 0, -1, 0, 1, 2, 1, 0],
|
||||
[0, -1, 0, 1, 0, 1, 2, 1],
|
||||
];
|
||||
|
||||
export class GenerativePianoEngine {
|
||||
private nextStepAt: number | null = null;
|
||||
private stepIndex = 0;
|
||||
private pace = 1;
|
||||
private lastPaceUpdateAt: number | null = null;
|
||||
private isWaitingForFirstStrokeNote = false;
|
||||
private readonly lastMidiByColor: [number | null, number | null, number | null] = [
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
];
|
||||
|
||||
public constructor(
|
||||
private readonly config: GardenAudioConfig,
|
||||
private readonly playNote: (note: PianoNote) => void
|
||||
) {}
|
||||
|
||||
public prime(now: number): void {
|
||||
if (this.nextStepAt === null) {
|
||||
this.nextStepAt = now + appConfig.audioEngine.startDelaySeconds;
|
||||
}
|
||||
this.lastPaceUpdateAt ??= now;
|
||||
}
|
||||
|
||||
public beginGesture(now: number): void {
|
||||
this.isWaitingForFirstStrokeNote = true;
|
||||
this.wake(now);
|
||||
}
|
||||
|
||||
public endGesture(): void {
|
||||
this.isWaitingForFirstStrokeNote = false;
|
||||
}
|
||||
|
||||
public wake(now: number): void {
|
||||
this.lastPaceUpdateAt ??= now;
|
||||
if (
|
||||
!this.isWaitingForFirstStrokeNote &&
|
||||
this.nextStepAt !== null &&
|
||||
this.nextStepAt - now <= FIRST_NOTE_MAX_WAIT_SECONDS
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.nextStepAt = now + appConfig.audioEngine.startDelaySeconds;
|
||||
this.stepIndex = this.getNextDownbeatStepIndex();
|
||||
}
|
||||
|
||||
public renderLookahead({
|
||||
vibe,
|
||||
now,
|
||||
activity,
|
||||
selectedColorIndex,
|
||||
lookaheadSeconds = this.config.rhythm.lookaheadSeconds,
|
||||
}: RenderLookaheadRequest): void {
|
||||
this.prime(now);
|
||||
const normalizedActivity = clamp01(activity);
|
||||
|
||||
this.updatePace(now, normalizedActivity);
|
||||
const stepSeconds = this.getStepDurationSeconds();
|
||||
this.skipLateSteps(now, stepSeconds);
|
||||
|
||||
if (this.nextStepAt === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const profile = getVibeProfile(this.config, vibe);
|
||||
const lookaheadEnd = now + lookaheadSeconds;
|
||||
while (this.nextStepAt <= lookaheadEnd) {
|
||||
this.renderStep({
|
||||
vibe,
|
||||
profile,
|
||||
stepIndex: this.stepIndex,
|
||||
startTime: this.nextStepAt,
|
||||
activity: normalizedActivity,
|
||||
selectedColorIndex,
|
||||
});
|
||||
this.nextStepAt += stepSeconds;
|
||||
this.stepIndex += 1;
|
||||
}
|
||||
}
|
||||
|
||||
public playVibeChangeStinger(vibe: VibePreset, now: number): void {
|
||||
const profile = getVibeProfile(this.config, vibe);
|
||||
const chord = profile.progression[0];
|
||||
const intervals = getChordIntervals(chord, true);
|
||||
const rootMidi = profile.rootMidi + chord.rootOffset;
|
||||
const notes = [
|
||||
{
|
||||
midi: clampMidi(rootMidi + intervals[0], 48, 60),
|
||||
velocity: 0.16,
|
||||
pan: -0.24,
|
||||
delaySend: 0.014,
|
||||
},
|
||||
{
|
||||
midi: clampMidi(rootMidi + intervals[2], 60, 72),
|
||||
velocity: 0.13,
|
||||
pan: 0.02,
|
||||
delaySend: 0.016,
|
||||
},
|
||||
{
|
||||
midi: clampMidi(rootMidi + intervals[3], 67, 84),
|
||||
velocity: 0.105,
|
||||
pan: 0.26,
|
||||
delaySend: 0.02,
|
||||
},
|
||||
];
|
||||
|
||||
notes.forEach((note, index) => {
|
||||
this.playNote({
|
||||
...note,
|
||||
durationSeconds: STINGER_DURATION_SECONDS,
|
||||
lowpassHz: this.getLowpassHz(profile, note.midi, 0.28),
|
||||
startTime: now + index * STINGER_SPACING_SECONDS,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public reset(): void {
|
||||
this.nextStepAt = null;
|
||||
this.stepIndex = 0;
|
||||
this.pace = 1;
|
||||
this.lastPaceUpdateAt = null;
|
||||
this.isWaitingForFirstStrokeNote = false;
|
||||
this.lastMidiByColor[0] = null;
|
||||
this.lastMidiByColor[1] = null;
|
||||
this.lastMidiByColor[2] = null;
|
||||
}
|
||||
|
||||
private renderStep({
|
||||
vibe,
|
||||
profile,
|
||||
stepIndex,
|
||||
startTime,
|
||||
activity,
|
||||
selectedColorIndex,
|
||||
}: {
|
||||
vibe: VibePreset;
|
||||
profile: GardenAudioVibeProfile;
|
||||
stepIndex: number;
|
||||
startTime: number;
|
||||
activity: number;
|
||||
selectedColorIndex: GardenAudioColorIndex;
|
||||
}): void {
|
||||
if (
|
||||
activity < this.config.rhythm.sparseActivity ||
|
||||
stepIndex % this.config.rhythm.stepsPerBeat !== 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const role = COLOR_ROLES[selectedColorIndex];
|
||||
const stepInBar = stepIndex % this.config.rhythm.stepsPerBar;
|
||||
const beatsPerBar = this.getBeatsPerBar();
|
||||
const beatInBar =
|
||||
Math.floor(stepInBar / this.config.rhythm.stepsPerBeat) % beatsPerBar;
|
||||
const barIndex = Math.floor(stepIndex / this.config.rhythm.stepsPerBar);
|
||||
const phraseIndex = Math.floor(barIndex / PHRASE_BAR_COUNT);
|
||||
const barInPhrase = barIndex % PHRASE_BAR_COUNT;
|
||||
|
||||
if (
|
||||
!this.shouldPlayBeat({
|
||||
vibeId: vibe.id,
|
||||
role,
|
||||
activity,
|
||||
beatInBar,
|
||||
beatsPerBar,
|
||||
barInPhrase,
|
||||
phraseIndex,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const note = this.createNote({
|
||||
vibeId: vibe.id,
|
||||
profile,
|
||||
role,
|
||||
stepIndex,
|
||||
startTime,
|
||||
activity,
|
||||
selectedColorIndex,
|
||||
beatInBar,
|
||||
beatsPerBar,
|
||||
barInPhrase,
|
||||
phraseIndex,
|
||||
});
|
||||
|
||||
this.lastMidiByColor[selectedColorIndex] = note.midi;
|
||||
this.isWaitingForFirstStrokeNote = false;
|
||||
this.playNote(note);
|
||||
}
|
||||
|
||||
private createNote({
|
||||
vibeId,
|
||||
profile,
|
||||
role,
|
||||
stepIndex,
|
||||
startTime,
|
||||
activity,
|
||||
selectedColorIndex,
|
||||
beatInBar,
|
||||
beatsPerBar,
|
||||
barInPhrase,
|
||||
phraseIndex,
|
||||
}: {
|
||||
vibeId: string;
|
||||
profile: GardenAudioVibeProfile;
|
||||
role: PianoRole;
|
||||
stepIndex: number;
|
||||
startTime: number;
|
||||
activity: number;
|
||||
selectedColorIndex: GardenAudioColorIndex;
|
||||
beatInBar: number;
|
||||
beatsPerBar: number;
|
||||
barInPhrase: number;
|
||||
phraseIndex: number;
|
||||
}): PianoNote {
|
||||
const colorVoice = this.config.colorVoices[selectedColorIndex];
|
||||
const expression = this.getExpression(activity);
|
||||
const midi = this.chooseMidi({
|
||||
vibeId,
|
||||
profile,
|
||||
role,
|
||||
stepIndex,
|
||||
selectedColorIndex,
|
||||
beatInBar,
|
||||
beatsPerBar,
|
||||
barInPhrase,
|
||||
phraseIndex,
|
||||
});
|
||||
const beatAccent = beatInBar === 0 ? 1.08 : beatInBar === 2 ? 0.97 : 0.9;
|
||||
const phrasePosition =
|
||||
(barInPhrase * beatsPerBar + beatInBar) / (PHRASE_BAR_COUNT * beatsPerBar);
|
||||
const phraseSwell = 0.88 + Math.sin(phrasePosition * Math.PI) * 0.12;
|
||||
|
||||
return {
|
||||
midi,
|
||||
velocity: clamp(
|
||||
(role.velocityBase + expression * role.velocityActivityScale) *
|
||||
colorVoice.velocityMultiplier *
|
||||
beatAccent *
|
||||
phraseSwell,
|
||||
0.06,
|
||||
0.52
|
||||
),
|
||||
durationSeconds:
|
||||
role.durationBase +
|
||||
expression * role.durationActivityScale +
|
||||
(beatInBar === 0 ? role.downbeatDurationBoost : 0),
|
||||
pan: clamp(role.pan + colorVoice.panOffset * 0.45, -1, 1),
|
||||
delaySend: clamp(role.delaySend * (1.15 - expression * 0.4), 0, 0.04),
|
||||
lowpassHz: this.getLowpassHz(profile, midi, expression),
|
||||
startTime,
|
||||
};
|
||||
}
|
||||
|
||||
private chooseMidi({
|
||||
vibeId,
|
||||
profile,
|
||||
role,
|
||||
stepIndex,
|
||||
selectedColorIndex,
|
||||
beatInBar,
|
||||
beatsPerBar,
|
||||
barInPhrase,
|
||||
phraseIndex,
|
||||
}: {
|
||||
vibeId: string;
|
||||
profile: GardenAudioVibeProfile;
|
||||
role: PianoRole;
|
||||
stepIndex: number;
|
||||
selectedColorIndex: GardenAudioColorIndex;
|
||||
beatInBar: number;
|
||||
beatsPerBar: number;
|
||||
barInPhrase: number;
|
||||
phraseIndex: number;
|
||||
}): number {
|
||||
const chord = getChordAtStep(this.config, profile, stepIndex);
|
||||
const rootMidi = profile.rootMidi + chord.rootOffset;
|
||||
const offsets = this.getPitchOffsets({
|
||||
vibeId,
|
||||
profile,
|
||||
chord,
|
||||
selectedColorIndex,
|
||||
beatInBar,
|
||||
beatsPerBar,
|
||||
barInPhrase,
|
||||
phraseIndex,
|
||||
});
|
||||
const previousMidi = this.lastMidiByColor[selectedColorIndex] ?? role.preferredMidi;
|
||||
const candidates = this.getCandidates(rootMidi, offsets, role);
|
||||
|
||||
if (candidates.length === 0) {
|
||||
return clampMidi(rootMidi + offsets[0], role.midiMin, role.midiMax);
|
||||
}
|
||||
|
||||
return candidates.reduce((best, candidate) =>
|
||||
this.scoreCandidate(candidate, role, previousMidi) <
|
||||
this.scoreCandidate(best, role, previousMidi)
|
||||
? candidate
|
||||
: best
|
||||
).midi;
|
||||
}
|
||||
|
||||
private getPitchOffsets({
|
||||
vibeId,
|
||||
profile,
|
||||
chord,
|
||||
selectedColorIndex,
|
||||
beatInBar,
|
||||
beatsPerBar,
|
||||
barInPhrase,
|
||||
phraseIndex,
|
||||
}: {
|
||||
vibeId: string;
|
||||
profile: GardenAudioVibeProfile;
|
||||
chord: GardenAudioChord;
|
||||
selectedColorIndex: GardenAudioColorIndex;
|
||||
beatInBar: number;
|
||||
beatsPerBar: number;
|
||||
barInPhrase: number;
|
||||
phraseIndex: number;
|
||||
}): Array<number> {
|
||||
const chordIntervals = getChordIntervals(chord, false);
|
||||
const phraseBeat = barInPhrase * beatsPerBar + beatInBar;
|
||||
const contour = this.getPhraseContour(
|
||||
vibeId,
|
||||
phraseIndex,
|
||||
selectedColorIndex,
|
||||
phraseBeat
|
||||
);
|
||||
const colorVoice = this.config.colorVoices[selectedColorIndex];
|
||||
|
||||
if (selectedColorIndex === 0) {
|
||||
return beatInBar === 0 ? [0, 7, 12] : [7, 0, 12];
|
||||
}
|
||||
|
||||
if (selectedColorIndex === 1) {
|
||||
return [
|
||||
chordIntervals[1],
|
||||
chordIntervals[2],
|
||||
degreeToSemitone(profile, 2 + colorVoice.scaleDegreeOffset + contour),
|
||||
chordIntervals[1] + 12,
|
||||
];
|
||||
}
|
||||
|
||||
const degreeBase = 2 + colorVoice.scaleDegreeOffset + contour;
|
||||
return [
|
||||
degreeToSemitone(profile, degreeBase),
|
||||
degreeToSemitone(profile, degreeBase + 2),
|
||||
12 + degreeToSemitone(profile, degreeBase - 1),
|
||||
12 + degreeToSemitone(profile, degreeBase + 1),
|
||||
];
|
||||
}
|
||||
|
||||
private getCandidates(
|
||||
rootMidi: number,
|
||||
offsets: ReadonlyArray<number>,
|
||||
role: PianoRole
|
||||
): Array<PitchCandidate> {
|
||||
const candidates: Array<PitchCandidate> = [];
|
||||
|
||||
offsets.forEach((offset, preference) => {
|
||||
for (let octave = -3; octave <= 3; octave += 1) {
|
||||
const midi = rootMidi + offset + octave * 12;
|
||||
if (midi >= role.midiMin && midi <= role.midiMax) {
|
||||
candidates.push({ midi: Math.round(midi), preference });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return candidates;
|
||||
}
|
||||
|
||||
private scoreCandidate(
|
||||
candidate: PitchCandidate,
|
||||
role: PianoRole,
|
||||
previousMidi: number
|
||||
): number {
|
||||
return (
|
||||
Math.abs(candidate.midi - previousMidi) +
|
||||
Math.abs(candidate.midi - role.preferredMidi) * NOTE_SCORE_REGISTER_WEIGHT +
|
||||
candidate.preference * NOTE_SCORE_PREFERENCE_WEIGHT
|
||||
);
|
||||
}
|
||||
|
||||
private shouldPlayBeat({
|
||||
vibeId,
|
||||
role,
|
||||
activity,
|
||||
beatInBar,
|
||||
beatsPerBar,
|
||||
barInPhrase,
|
||||
phraseIndex,
|
||||
}: {
|
||||
vibeId: string;
|
||||
role: PianoRole;
|
||||
activity: number;
|
||||
beatInBar: number;
|
||||
beatsPerBar: number;
|
||||
barInPhrase: number;
|
||||
phraseIndex: number;
|
||||
}): boolean {
|
||||
const expression = this.getExpression(activity);
|
||||
const beats =
|
||||
expression < 0.34
|
||||
? role.lowBeats
|
||||
: expression < 0.68
|
||||
? role.mediumBeats
|
||||
: role.highBeats;
|
||||
|
||||
if (!beats.includes(beatInBar)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const phraseBeat = barInPhrase * beatsPerBar + beatInBar;
|
||||
if (
|
||||
expression < 0.34 &&
|
||||
phraseBeat % role.lowPhraseSpacing !== role.lowPhraseOffset
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const keepChance =
|
||||
expression < 0.34
|
||||
? role.lowKeepChance
|
||||
: expression < 0.68
|
||||
? role.mediumKeepChance
|
||||
: role.highKeepChance;
|
||||
const gate = hashUnit(vibeId, role.name, phraseIndex, barInPhrase, beatInBar);
|
||||
|
||||
if (this.isWaitingForFirstStrokeNote && beatInBar === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (beatInBar === 0 && role.name !== 'spark' && expression >= 0.52) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return gate <= keepChance;
|
||||
}
|
||||
|
||||
private getPhraseContour(
|
||||
vibeId: string,
|
||||
phraseIndex: number,
|
||||
selectedColorIndex: GardenAudioColorIndex,
|
||||
phraseBeat: number
|
||||
): number {
|
||||
if (selectedColorIndex === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const contourIndex =
|
||||
hashInt(vibeId, phraseIndex, selectedColorIndex) % PHRASE_CONTOURS.length;
|
||||
const contour = PHRASE_CONTOURS[contourIndex];
|
||||
return contour[phraseBeat % contour.length];
|
||||
}
|
||||
|
||||
private getLowpassHz(
|
||||
profile: GardenAudioVibeProfile,
|
||||
midi: number,
|
||||
expression: number
|
||||
): number {
|
||||
const midiLift = clamp01((midi - 48) / 36) * 1100;
|
||||
return clamp(
|
||||
this.config.piano.lowpassHz * profile.brightness * (0.44 + expression * 0.44) +
|
||||
midiLift,
|
||||
appConfig.audioEngine.piano.lowpassMinHz,
|
||||
appConfig.audioEngine.piano.lowpassMaxHz
|
||||
);
|
||||
}
|
||||
|
||||
private updatePace(now: number, activity: number): void {
|
||||
if (this.lastPaceUpdateAt === null) {
|
||||
this.lastPaceUpdateAt = now;
|
||||
return;
|
||||
}
|
||||
|
||||
const elapsedSeconds = Math.max(0, now - this.lastPaceUpdateAt);
|
||||
this.lastPaceUpdateAt = now;
|
||||
const targetPace = PACE_MIN + (PACE_MAX - PACE_MIN) * this.getExpression(activity);
|
||||
const amount = 1 - Math.exp(-elapsedSeconds / PACE_RAMP_SECONDS);
|
||||
this.pace += (targetPace - this.pace) * amount;
|
||||
}
|
||||
|
||||
private skipLateSteps(now: number, stepSeconds: number): void {
|
||||
if (this.nextStepAt === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const earliestStart = now + appConfig.audioEngine.piano.scheduleAheadSeconds;
|
||||
if (this.nextStepAt >= earliestStart) {
|
||||
return;
|
||||
}
|
||||
|
||||
const skippedSteps = Math.floor((earliestStart - this.nextStepAt) / stepSeconds) + 1;
|
||||
this.nextStepAt += skippedSteps * stepSeconds;
|
||||
this.stepIndex += skippedSteps;
|
||||
}
|
||||
|
||||
private getExpression(activity: number): number {
|
||||
return clamp01(
|
||||
(activity - this.config.rhythm.sparseActivity) /
|
||||
(1 - this.config.rhythm.sparseActivity)
|
||||
);
|
||||
}
|
||||
|
||||
private getStepDurationSeconds(): number {
|
||||
return 60 / this.config.rhythm.bpm / this.config.rhythm.stepsPerBeat / this.pace;
|
||||
}
|
||||
|
||||
private getNextDownbeatStepIndex(): number {
|
||||
const stepInBar = this.stepIndex % this.config.rhythm.stepsPerBar;
|
||||
if (stepInBar === 0) {
|
||||
return this.stepIndex;
|
||||
}
|
||||
|
||||
return this.stepIndex + this.config.rhythm.stepsPerBar - stepInBar;
|
||||
}
|
||||
|
||||
private getBeatsPerBar(): number {
|
||||
return Math.max(
|
||||
1,
|
||||
Math.round(this.config.rhythm.stepsPerBar / this.config.rhythm.stepsPerBeat)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const hashUnit = (...parts: Array<number | string>): number =>
|
||||
hashInt(...parts) / 0xffffffff;
|
||||
|
||||
const hashInt = (...parts: Array<number | string>): number => {
|
||||
let hash = 2166136261;
|
||||
const input = parts.join(':');
|
||||
for (let index = 0; index < input.length; index += 1) {
|
||||
hash ^= input.charCodeAt(index);
|
||||
hash = Math.imul(hash, 16777619);
|
||||
}
|
||||
return hash >>> 0;
|
||||
};
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
import { appConfig } from '../config';
|
||||
import { GardenAudioGraph } from './garden-audio-graph';
|
||||
import { NoiseBurst } from './garden-audio-types';
|
||||
|
||||
|
|
@ -10,7 +11,10 @@ export class NoiseBurstPlayer {
|
|||
return;
|
||||
}
|
||||
|
||||
const scheduledStart = Math.max(context.currentTime + 0.002, startTime);
|
||||
const scheduledStart = Math.max(
|
||||
context.currentTime + appConfig.audioEngine.noiseBurst.scheduleAheadSeconds,
|
||||
startTime
|
||||
);
|
||||
const source = context.createBufferSource();
|
||||
const filter = context.createBiquadFilter();
|
||||
const envelope = context.createGain();
|
||||
|
|
@ -20,20 +24,29 @@ export class NoiseBurstPlayer {
|
|||
source.buffer = noiseBuffer;
|
||||
filter.type = 'bandpass';
|
||||
filter.frequency.setValueAtTime(filterHz, scheduledStart);
|
||||
filter.Q.value = 1.4;
|
||||
envelope.gain.setValueAtTime(0.0001, scheduledStart);
|
||||
envelope.gain.exponentialRampToValueAtTime(
|
||||
Math.max(0.0001, gain),
|
||||
scheduledStart + 0.004
|
||||
filter.Q.value = appConfig.audioEngine.noiseBurst.filterQ;
|
||||
envelope.gain.setValueAtTime(
|
||||
appConfig.audioEngine.noiseBurst.silentGain,
|
||||
scheduledStart
|
||||
);
|
||||
envelope.gain.exponentialRampToValueAtTime(
|
||||
Math.max(appConfig.audioEngine.noiseBurst.silentGain, gain),
|
||||
scheduledStart + appConfig.audioEngine.noiseBurst.attackSeconds
|
||||
);
|
||||
envelope.gain.exponentialRampToValueAtTime(
|
||||
appConfig.audioEngine.noiseBurst.silentGain,
|
||||
stopAt
|
||||
);
|
||||
envelope.gain.exponentialRampToValueAtTime(0.0001, stopAt);
|
||||
panner.pan.setValueAtTime(pan, scheduledStart);
|
||||
|
||||
source.connect(filter);
|
||||
filter.connect(envelope);
|
||||
envelope.connect(panner);
|
||||
panner.connect(eventBus);
|
||||
source.start(scheduledStart, Math.random() * 0.4);
|
||||
source.start(
|
||||
scheduledStart,
|
||||
Math.random() * appConfig.audioEngine.noiseBurst.offsetRandomSeconds
|
||||
);
|
||||
source.stop(stopAt);
|
||||
source.addEventListener(
|
||||
'ended',
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { appConfig } from '../config';
|
||||
import { clamp, clamp01 } from '../utils/clamp';
|
||||
import { GardenAudioConfig } from './garden-audio-config';
|
||||
import { GardenAudioGraph } from './garden-audio-graph';
|
||||
|
|
@ -22,6 +23,9 @@ export class PianoSampler {
|
|||
this.sampleLoadPromise = Promise.all(
|
||||
pianoSampleDefinitions.map(async (sample) => {
|
||||
const response = await fetch(sample.url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Unable to load piano sample ${sample.url}`);
|
||||
}
|
||||
const audioData = await response.arrayBuffer();
|
||||
const buffer = await context.decodeAudioData(audioData);
|
||||
return { midi: sample.midi, buffer };
|
||||
|
|
@ -56,12 +60,22 @@ export class PianoSampler {
|
|||
return;
|
||||
}
|
||||
|
||||
const scheduledStart = Math.max(context.currentTime + 0.002, startTime);
|
||||
const scheduledStart = Math.max(
|
||||
context.currentTime + appConfig.audioEngine.piano.scheduleAheadSeconds,
|
||||
startTime
|
||||
);
|
||||
const noteVelocity = clamp01(velocity);
|
||||
const noteGainValue = Math.max(0.0001, this.config.piano.gain * noteVelocity);
|
||||
const noteGainValue = Math.max(
|
||||
appConfig.audioEngine.piano.minGain,
|
||||
this.config.piano.gain * noteVelocity
|
||||
);
|
||||
const sustainSeconds =
|
||||
this.config.piano.sustainSeconds * (0.45 + noteVelocity * 0.55);
|
||||
const sustainAt = scheduledStart + Math.max(0.08, durationSeconds);
|
||||
this.config.piano.sustainSeconds *
|
||||
(appConfig.audioEngine.piano.sustainBase +
|
||||
noteVelocity * appConfig.audioEngine.piano.sustainVelocityRange);
|
||||
const sustainAt =
|
||||
scheduledStart +
|
||||
Math.max(appConfig.audioEngine.piano.minDurationSeconds, durationSeconds);
|
||||
const releaseAt = sustainAt + sustainSeconds;
|
||||
const releaseSeconds = this.config.piano.releaseSeconds;
|
||||
const stopAt = releaseAt + releaseSeconds;
|
||||
|
|
@ -75,26 +89,55 @@ export class PianoSampler {
|
|||
while (this.activeVoices.length >= this.config.piano.maxVoices) {
|
||||
const oldest = this.activeVoices.shift();
|
||||
oldest?.gain.gain.cancelScheduledValues(scheduledStart);
|
||||
oldest?.gain.gain.setTargetAtTime(0.0001, scheduledStart, 0.025);
|
||||
oldest?.source.stop(scheduledStart + 0.05);
|
||||
oldest?.gain.gain.setTargetAtTime(
|
||||
appConfig.audioEngine.piano.minGain,
|
||||
scheduledStart,
|
||||
appConfig.audioEngine.piano.voiceStealFadeSeconds
|
||||
);
|
||||
oldest?.source.stop(
|
||||
scheduledStart + appConfig.audioEngine.piano.voiceStealStopSeconds
|
||||
);
|
||||
}
|
||||
|
||||
source.buffer = sample.buffer;
|
||||
source.playbackRate.setValueAtTime(
|
||||
Math.pow(2, (midi - sample.midi) / 12),
|
||||
Math.pow(
|
||||
2,
|
||||
(midi - sample.midi) / appConfig.audioEngine.piano.pitchSemitonesPerOctave
|
||||
),
|
||||
scheduledStart
|
||||
);
|
||||
filter.type = 'lowpass';
|
||||
filter.frequency.setValueAtTime(clamp(lowpassHz, 1400, 12000), scheduledStart);
|
||||
filter.Q.value = 0.7;
|
||||
gain.gain.setValueAtTime(0.0001, scheduledStart);
|
||||
gain.gain.exponentialRampToValueAtTime(noteGainValue, scheduledStart + 0.006);
|
||||
gain.gain.setTargetAtTime(
|
||||
Math.max(0.0001, noteGainValue * this.config.piano.sustainLevel),
|
||||
sustainAt,
|
||||
Math.max(0.04, sustainSeconds * 0.45)
|
||||
filter.frequency.setValueAtTime(
|
||||
clamp(
|
||||
lowpassHz,
|
||||
appConfig.audioEngine.piano.lowpassMinHz,
|
||||
appConfig.audioEngine.piano.lowpassMaxHz
|
||||
),
|
||||
scheduledStart
|
||||
);
|
||||
filter.Q.value = appConfig.audioEngine.piano.filterQ;
|
||||
gain.gain.setValueAtTime(appConfig.audioEngine.piano.minGain, scheduledStart);
|
||||
gain.gain.exponentialRampToValueAtTime(
|
||||
noteGainValue,
|
||||
scheduledStart + appConfig.audioEngine.piano.gainAttackSeconds
|
||||
);
|
||||
gain.gain.setTargetAtTime(
|
||||
Math.max(
|
||||
appConfig.audioEngine.piano.minGain,
|
||||
noteGainValue * this.config.piano.sustainLevel
|
||||
),
|
||||
sustainAt,
|
||||
Math.max(
|
||||
appConfig.audioEngine.piano.minFadeSeconds,
|
||||
sustainSeconds * appConfig.audioEngine.piano.sustainBase
|
||||
)
|
||||
);
|
||||
gain.gain.setTargetAtTime(
|
||||
appConfig.audioEngine.piano.minGain,
|
||||
releaseAt,
|
||||
releaseSeconds
|
||||
);
|
||||
gain.gain.setTargetAtTime(0.0001, releaseAt, releaseSeconds);
|
||||
panner.pan.setValueAtTime(clamp(pan, -1, 1), scheduledStart);
|
||||
|
||||
source.connect(filter);
|
||||
|
|
@ -102,7 +145,7 @@ export class PianoSampler {
|
|||
gain.connect(panner);
|
||||
panner.connect(eventBus);
|
||||
|
||||
if (delayInput && this.config.delay.enabled && delaySend > 0) {
|
||||
if (delayInput && delaySend > 0) {
|
||||
sendGain = context.createGain();
|
||||
sendGain.gain.value = delaySend;
|
||||
panner.connect(sendGain);
|
||||
|
|
@ -110,8 +153,8 @@ export class PianoSampler {
|
|||
}
|
||||
|
||||
source.start(scheduledStart);
|
||||
source.stop(stopAt + 0.05);
|
||||
this.activeVoices.push({ gain, source, stopAt });
|
||||
source.stop(stopAt + appConfig.audioEngine.piano.tailStopExtraSeconds);
|
||||
this.activeVoices.push({ gain, source, startAt: scheduledStart, stopAt });
|
||||
|
||||
source.addEventListener(
|
||||
'ended',
|
||||
|
|
@ -127,13 +170,35 @@ export class PianoSampler {
|
|||
);
|
||||
}
|
||||
|
||||
public fadeActive(now: number): void {
|
||||
public fadeActive(
|
||||
now: number,
|
||||
fadeSeconds = appConfig.audioEngine.piano.defaultFadeSeconds
|
||||
): void {
|
||||
this.activeVoices.forEach((voice) => {
|
||||
voice.gain.gain.cancelScheduledValues(now);
|
||||
voice.gain.gain.setTargetAtTime(0.0001, now, 0.035);
|
||||
voice.stopAt = Math.min(voice.stopAt, now + 0.28);
|
||||
if (voice.startAt > now) {
|
||||
voice.gain.gain.setValueAtTime(appConfig.audioEngine.piano.minGain, now);
|
||||
voice.stopAt = now;
|
||||
try {
|
||||
voice.source.stop(now);
|
||||
} catch {
|
||||
// The source may already have a stop time scheduled.
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const stopAt = Math.min(voice.stopAt, now + fadeSeconds);
|
||||
voice.gain.gain.setTargetAtTime(
|
||||
appConfig.audioEngine.piano.minGain,
|
||||
now,
|
||||
Math.max(
|
||||
appConfig.audioEngine.piano.minFadeSeconds,
|
||||
fadeSeconds * appConfig.audioEngine.piano.fadeTimeConstantRatio
|
||||
)
|
||||
);
|
||||
voice.stopAt = stopAt;
|
||||
try {
|
||||
voice.source.stop(now + 0.28);
|
||||
voice.source.stop(stopAt);
|
||||
} catch {
|
||||
// The source may already have a stop time scheduled.
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,36 +6,36 @@ export interface PianoSampleDefinition {
|
|||
const sampleBaseUrl = `${import.meta.env.BASE_URL}audio/piano/`;
|
||||
|
||||
const sampleFiles: Array<[fileName: string, midi: number]> = [
|
||||
['A0v12.ogg', 21],
|
||||
['C1v12.ogg', 24],
|
||||
['Dsharp1v12.ogg', 27],
|
||||
['Fsharp1v12.ogg', 30],
|
||||
['A1v12.ogg', 33],
|
||||
['C2v12.ogg', 36],
|
||||
['Dsharp2v12.ogg', 39],
|
||||
['Fsharp2v12.ogg', 42],
|
||||
['A2v12.ogg', 45],
|
||||
['C3v12.ogg', 48],
|
||||
['Dsharp3v12.ogg', 51],
|
||||
['Fsharp3v12.ogg', 54],
|
||||
['A3v12.ogg', 57],
|
||||
['C4v12.ogg', 60],
|
||||
['Dsharp4v12.ogg', 63],
|
||||
['Fsharp4v12.ogg', 66],
|
||||
['A4v12.ogg', 69],
|
||||
['C5v12.ogg', 72],
|
||||
['Dsharp5v12.ogg', 75],
|
||||
['Fsharp5v12.ogg', 78],
|
||||
['A5v12.ogg', 81],
|
||||
['C6v12.ogg', 84],
|
||||
['Dsharp6v12.ogg', 87],
|
||||
['Fsharp6v12.ogg', 90],
|
||||
['A6v12.ogg', 93],
|
||||
['C7v12.ogg', 96],
|
||||
['Dsharp7v12.ogg', 99],
|
||||
['Fsharp7v12.ogg', 102],
|
||||
['A7v12.ogg', 105],
|
||||
['C8v12.ogg', 108],
|
||||
['A0v12.m4a', 21],
|
||||
['C1v12.m4a', 24],
|
||||
['Dsharp1v12.m4a', 27],
|
||||
['Fsharp1v12.m4a', 30],
|
||||
['A1v12.m4a', 33],
|
||||
['C2v12.m4a', 36],
|
||||
['Dsharp2v12.m4a', 39],
|
||||
['Fsharp2v12.m4a', 42],
|
||||
['A2v12.m4a', 45],
|
||||
['C3v12.m4a', 48],
|
||||
['Dsharp3v12.m4a', 51],
|
||||
['Fsharp3v12.m4a', 54],
|
||||
['A3v12.m4a', 57],
|
||||
['C4v12.m4a', 60],
|
||||
['Dsharp4v12.m4a', 63],
|
||||
['Fsharp4v12.m4a', 66],
|
||||
['A4v12.m4a', 69],
|
||||
['C5v12.m4a', 72],
|
||||
['Dsharp5v12.m4a', 75],
|
||||
['Fsharp5v12.m4a', 78],
|
||||
['A5v12.m4a', 81],
|
||||
['C6v12.m4a', 84],
|
||||
['Dsharp6v12.m4a', 87],
|
||||
['Fsharp6v12.m4a', 90],
|
||||
['A6v12.m4a', 93],
|
||||
['C7v12.m4a', 96],
|
||||
['Dsharp7v12.m4a', 99],
|
||||
['Fsharp7v12.m4a', 102],
|
||||
['A7v12.m4a', 105],
|
||||
['C8v12.m4a', 108],
|
||||
];
|
||||
|
||||
export const pianoSampleDefinitions: Array<PianoSampleDefinition> = sampleFiles
|
||||
|
|
|
|||
Loading…
Reference in a new issue