lgtm
This commit is contained in:
parent
ce383ce34c
commit
d2da0d1617
25 changed files with 521 additions and 1026 deletions
|
|
@ -13,6 +13,51 @@ interface GardenAudioColorVoice {
|
||||||
panOffset: number;
|
panOffset: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface GardenAudioRegister {
|
||||||
|
midiMin: number;
|
||||||
|
midiMax: number;
|
||||||
|
preferredMidi: number;
|
||||||
|
pan: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GardenAudioColorPool extends GardenAudioRegister {
|
||||||
|
scaleDegrees: Array<number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GardenAudioGenerativePianoConfig {
|
||||||
|
colorPools: [GardenAudioColorPool, GardenAudioColorPool, GardenAudioColorPool];
|
||||||
|
padRegisters: [GardenAudioRegister, GardenAudioRegister, GardenAudioRegister];
|
||||||
|
chordBars: number;
|
||||||
|
supportBarSpacing: number;
|
||||||
|
supportBarOffset: number;
|
||||||
|
idleTextureBarSpacing: number;
|
||||||
|
mediumTextureBarSpacing: number;
|
||||||
|
textureBeat: number;
|
||||||
|
highActivityExtraBeat: number;
|
||||||
|
highActivityExtraThreshold: number;
|
||||||
|
noteScorePreferenceWeight: number;
|
||||||
|
noteScoreRegisterWeight: number;
|
||||||
|
noteScoreRepeatPenalty: number;
|
||||||
|
gestureAccentSpacingSeconds: number;
|
||||||
|
gestureAccentMinIntervalSeconds: number;
|
||||||
|
strokeAccentMinIntervalSeconds: number;
|
||||||
|
strokeAccentThreshold: number;
|
||||||
|
stingerSpacingSeconds: number;
|
||||||
|
stingerDurationSeconds: number;
|
||||||
|
maxBrushPhraseLayers: number;
|
||||||
|
brushLayerBaseSeconds: number;
|
||||||
|
brushLayerEnergySeconds: number;
|
||||||
|
brushLayerMirrorSeconds: number;
|
||||||
|
brushLayerMinIntensity: number;
|
||||||
|
brushStreamIdleIntervalBeats: number;
|
||||||
|
brushStreamActiveIntervalBeats: number;
|
||||||
|
brushStreamIntenseIntervalBeats: number;
|
||||||
|
brushStreamManicIntervalBeats: number;
|
||||||
|
brushMotifMaxSteps: number;
|
||||||
|
brushMotifCanonDelaySeconds: number;
|
||||||
|
padDurationBarScale: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface GardenAudioVibeProfile {
|
export interface GardenAudioVibeProfile {
|
||||||
rootMidi: number;
|
rootMidi: number;
|
||||||
scale: Array<number>;
|
scale: Array<number>;
|
||||||
|
|
@ -30,6 +75,13 @@ export interface GardenAudioConfig {
|
||||||
timeSeconds: number;
|
timeSeconds: number;
|
||||||
feedback: number;
|
feedback: number;
|
||||||
wetGain: number;
|
wetGain: number;
|
||||||
|
erasingActivity: number;
|
||||||
|
activityFeedbackWeight: number;
|
||||||
|
feedbackMax: number;
|
||||||
|
feedbackMin: number;
|
||||||
|
outputActivityWeight: number;
|
||||||
|
outputBase: number;
|
||||||
|
timeRampSeconds: number;
|
||||||
};
|
};
|
||||||
piano: {
|
piano: {
|
||||||
maxVoices: number;
|
maxVoices: number;
|
||||||
|
|
@ -38,13 +90,26 @@ export interface GardenAudioConfig {
|
||||||
sustainLevel: number;
|
sustainLevel: number;
|
||||||
releaseSeconds: number;
|
releaseSeconds: number;
|
||||||
lowpassHz: number;
|
lowpassHz: number;
|
||||||
|
filterQ: number;
|
||||||
|
gainAttackSeconds: number;
|
||||||
|
lowpassMaxHz: number;
|
||||||
|
lowpassMinHz: number;
|
||||||
|
minDurationSeconds: number;
|
||||||
|
minFadeSeconds: number;
|
||||||
|
minGain: number;
|
||||||
|
pitchSemitonesPerOctave: number;
|
||||||
|
scheduleAheadSeconds: number;
|
||||||
|
sustainBase: number;
|
||||||
|
sustainVelocityRange: number;
|
||||||
|
tailStopExtraSeconds: number;
|
||||||
|
voiceStealFadeSeconds: number;
|
||||||
|
voiceStealStopSeconds: number;
|
||||||
};
|
};
|
||||||
rhythm: {
|
rhythm: {
|
||||||
bpm: number;
|
bpm: number;
|
||||||
stepsPerBeat: number;
|
stepsPerBeat: number;
|
||||||
stepsPerBar: number;
|
stepsPerBar: number;
|
||||||
lookaheadSeconds: number;
|
lookaheadSeconds: number;
|
||||||
speedForFullEnergyPixelsPerSecond: number;
|
|
||||||
sparseActivity: number;
|
sparseActivity: number;
|
||||||
};
|
};
|
||||||
eraser: {
|
eraser: {
|
||||||
|
|
@ -52,7 +117,45 @@ export interface GardenAudioConfig {
|
||||||
noiseGain: number;
|
noiseGain: number;
|
||||||
filterMinHz: number;
|
filterMinHz: number;
|
||||||
filterMaxHz: number;
|
filterMaxHz: number;
|
||||||
|
durationSeconds: number;
|
||||||
};
|
};
|
||||||
|
energy: {
|
||||||
|
attackSeconds: number;
|
||||||
|
decaySeconds: number;
|
||||||
|
immediateActivityScale: number;
|
||||||
|
releaseSeconds: number;
|
||||||
|
strokeDecaySeconds: number;
|
||||||
|
};
|
||||||
|
graph: {
|
||||||
|
closeGain: number;
|
||||||
|
closeRampSeconds: number;
|
||||||
|
delayMaxSeconds: number;
|
||||||
|
eventBusGain: number;
|
||||||
|
noiseMax: number;
|
||||||
|
noiseMin: number;
|
||||||
|
unlockTickFrequencyHz: number;
|
||||||
|
unlockTickSeconds: number;
|
||||||
|
};
|
||||||
|
input: {
|
||||||
|
activeActivityThreshold: number;
|
||||||
|
distanceWindowForFullActivityPixels: number;
|
||||||
|
distanceWindowSeconds: number;
|
||||||
|
fallbackFrameSeconds: number;
|
||||||
|
manicActivityThreshold: number;
|
||||||
|
manicModeThreshold: number;
|
||||||
|
};
|
||||||
|
muteGain: number;
|
||||||
|
muteRampSeconds: number;
|
||||||
|
noiseBurst: {
|
||||||
|
attackSeconds: number;
|
||||||
|
filterQ: number;
|
||||||
|
offsetRandomSeconds: number;
|
||||||
|
scheduleAheadSeconds: number;
|
||||||
|
silentGain: number;
|
||||||
|
};
|
||||||
|
startDelaySeconds: number;
|
||||||
|
vibeChangeStingerMinIntervalSeconds: number;
|
||||||
|
generativePiano: GardenAudioGenerativePianoConfig;
|
||||||
colorVoices: [GardenAudioColorVoice, GardenAudioColorVoice, GardenAudioColorVoice];
|
colorVoices: [GardenAudioColorVoice, GardenAudioColorVoice, GardenAudioColorVoice];
|
||||||
vibes: Record<string, GardenAudioVibeProfile>;
|
vibes: Record<string, GardenAudioVibeProfile>;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import { appConfig } from '../config';
|
import { gardenAudioConfig } from './garden-audio-config';
|
||||||
import { GardenAudioEnergy } from './garden-audio-energy';
|
import { GardenAudioEnergy } from './garden-audio-energy';
|
||||||
|
|
||||||
describe('GardenAudioEnergy', () => {
|
describe('GardenAudioEnergy', () => {
|
||||||
it('suspends activity but keeps a fading level when the gesture ends', () => {
|
it('suspends activity but keeps a fading level when the gesture ends', () => {
|
||||||
const energy = new GardenAudioEnergy(appConfig.audioEngine);
|
const energy = new GardenAudioEnergy(gardenAudioConfig);
|
||||||
|
|
||||||
energy.beginGesture(0);
|
energy.beginGesture(0);
|
||||||
energy.recordStroke(0.8, 0.1);
|
energy.recordStroke(0.8, 0.1);
|
||||||
|
|
@ -25,7 +25,7 @@ describe('GardenAudioEnergy', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('uses recent stroke intensity rather than gesture duration alone', () => {
|
it('uses recent stroke intensity rather than gesture duration alone', () => {
|
||||||
const energy = new GardenAudioEnergy(appConfig.audioEngine);
|
const energy = new GardenAudioEnergy(gardenAudioConfig);
|
||||||
|
|
||||||
energy.beginGesture(0);
|
energy.beginGesture(0);
|
||||||
energy.recordStroke(1, 0.1);
|
energy.recordStroke(1, 0.1);
|
||||||
|
|
@ -39,7 +39,7 @@ describe('GardenAudioEnergy', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('raises activity immediately when a stroke is recorded', () => {
|
it('raises activity immediately when a stroke is recorded', () => {
|
||||||
const energy = new GardenAudioEnergy(appConfig.audioEngine);
|
const energy = new GardenAudioEnergy(gardenAudioConfig);
|
||||||
|
|
||||||
energy.beginGesture(0);
|
energy.beginGesture(0);
|
||||||
energy.recordStroke(0.12, 0.05);
|
energy.recordStroke(0.12, 0.05);
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,5 @@
|
||||||
import type { GardenAudioEngineConfig } from '../config';
|
|
||||||
import { clamp01 } from '../utils/clamp';
|
import { clamp01 } from '../utils/clamp';
|
||||||
|
import type { GardenAudioConfig } from './garden-audio-config';
|
||||||
const STROKE_IMMEDIATE_ACTIVITY_SCALE = 0.85;
|
|
||||||
|
|
||||||
export class GardenAudioEnergy {
|
export class GardenAudioEnergy {
|
||||||
private isGestureActive = false;
|
private isGestureActive = false;
|
||||||
|
|
@ -9,7 +7,7 @@ export class GardenAudioEnergy {
|
||||||
private targetEnergy = 0;
|
private targetEnergy = 0;
|
||||||
private lastEnergyUpdateAt = 0;
|
private lastEnergyUpdateAt = 0;
|
||||||
|
|
||||||
public constructor(private readonly engineConfig: GardenAudioEngineConfig) {}
|
public constructor(private readonly config: GardenAudioConfig) {}
|
||||||
|
|
||||||
public beginGesture(now: number): void {
|
public beginGesture(now: number): void {
|
||||||
this.isGestureActive = true;
|
this.isGestureActive = true;
|
||||||
|
|
@ -25,7 +23,10 @@ export class GardenAudioEnergy {
|
||||||
const energy = clamp01(strokeEnergy);
|
const energy = clamp01(strokeEnergy);
|
||||||
this.targetEnergy = Math.max(this.targetEnergy, energy);
|
this.targetEnergy = Math.max(this.targetEnergy, energy);
|
||||||
if (this.isGestureActive) {
|
if (this.isGestureActive) {
|
||||||
this.energy = Math.max(this.energy, energy * STROKE_IMMEDIATE_ACTIVITY_SCALE);
|
this.energy = Math.max(
|
||||||
|
this.energy,
|
||||||
|
energy * this.config.energy.immediateActivityScale
|
||||||
|
);
|
||||||
}
|
}
|
||||||
this.lastEnergyUpdateAt ||= now;
|
this.lastEnergyUpdateAt ||= now;
|
||||||
}
|
}
|
||||||
|
|
@ -48,15 +49,15 @@ export class GardenAudioEnergy {
|
||||||
const elapsedSeconds = Math.max(0, now - this.lastEnergyUpdateAt);
|
const elapsedSeconds = Math.max(0, now - this.lastEnergyUpdateAt);
|
||||||
this.lastEnergyUpdateAt = now;
|
this.lastEnergyUpdateAt = now;
|
||||||
this.targetEnergy *= Math.exp(
|
this.targetEnergy *= Math.exp(
|
||||||
-elapsedSeconds / this.engineConfig.energy.strokeDecaySeconds
|
-elapsedSeconds / this.config.energy.strokeDecaySeconds
|
||||||
);
|
);
|
||||||
|
|
||||||
const target = this.isGestureActive ? this.targetEnergy : 0;
|
const target = this.isGestureActive ? this.targetEnergy : 0;
|
||||||
let timeConstant = this.engineConfig.energy.decaySeconds;
|
let timeConstant = this.config.energy.decaySeconds;
|
||||||
if (!this.isGestureActive) {
|
if (!this.isGestureActive) {
|
||||||
timeConstant = this.engineConfig.energy.releaseSeconds;
|
timeConstant = this.config.energy.releaseSeconds;
|
||||||
} else if (target > this.energy) {
|
} else if (target > this.energy) {
|
||||||
timeConstant = this.engineConfig.energy.attackSeconds;
|
timeConstant = this.config.energy.attackSeconds;
|
||||||
}
|
}
|
||||||
const amount = 1 - Math.exp(-elapsedSeconds / timeConstant);
|
const amount = 1 - Math.exp(-elapsedSeconds / timeConstant);
|
||||||
this.energy += (target - this.energy) * amount;
|
this.energy += (target - this.energy) * amount;
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,5 @@
|
||||||
import type { GardenAudioEngineConfig } from '../config';
|
import { clamp01 } from '../utils/clamp';
|
||||||
import { clamp, clamp01 } from '../utils/clamp';
|
import type { GardenAudioConfig } from './garden-audio-config';
|
||||||
import type {
|
|
||||||
GardenAudioColorIndex,
|
|
||||||
GardenAudioStroke,
|
|
||||||
GardenAudioTouchDown,
|
|
||||||
} from './garden-audio-types';
|
|
||||||
import type { GardenAudioStrokeMetrics } from './garden-audio-input';
|
import type { GardenAudioStrokeMetrics } from './garden-audio-input';
|
||||||
|
|
||||||
type GardenAudioGestureMode = 'calm' | 'active' | 'manic' | 'afterglow';
|
type GardenAudioGestureMode = 'calm' | 'active' | 'manic' | 'afterglow';
|
||||||
|
|
@ -13,216 +8,83 @@ interface GardenAudioGestureFrame {
|
||||||
mode: GardenAudioGestureMode;
|
mode: GardenAudioGestureMode;
|
||||||
activity: number;
|
activity: number;
|
||||||
maniaAmount: number;
|
maniaAmount: number;
|
||||||
panBias: number;
|
|
||||||
registerBias: number;
|
|
||||||
brightnessBias: number;
|
|
||||||
contour: number;
|
|
||||||
pressure: number;
|
|
||||||
pressureDelta: number;
|
|
||||||
mirrorAmount: number;
|
|
||||||
speedAmount: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface GestureSample {
|
interface GestureDistanceSample {
|
||||||
at: number;
|
at: number;
|
||||||
speed: number;
|
|
||||||
acceleration: number;
|
|
||||||
distancePixels: number;
|
distancePixels: number;
|
||||||
turned: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const WINDOW_SECONDS = 0.75;
|
|
||||||
const BIN_SECONDS = 0.05;
|
|
||||||
const MIN_TURN_DEGREES = 55;
|
|
||||||
const MIN_TURN_DISTANCE_PIXELS = 6;
|
|
||||||
|
|
||||||
const DEFAULT_FRAME: GardenAudioGestureFrame = {
|
const DEFAULT_FRAME: GardenAudioGestureFrame = {
|
||||||
mode: 'calm',
|
mode: 'calm',
|
||||||
activity: 0,
|
activity: 0,
|
||||||
maniaAmount: 0,
|
maniaAmount: 0,
|
||||||
panBias: 0,
|
|
||||||
registerBias: 0,
|
|
||||||
brightnessBias: 0,
|
|
||||||
contour: 0,
|
|
||||||
pressure: 0,
|
|
||||||
pressureDelta: 0,
|
|
||||||
mirrorAmount: 0,
|
|
||||||
speedAmount: 0,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export class GardenAudioGestureState {
|
export class GardenAudioGestureState {
|
||||||
private readonly samples: Array<GestureSample> = [];
|
private readonly samples: Array<GestureDistanceSample> = [];
|
||||||
private gestureClockSeconds = 0;
|
private gestureClockSeconds = 0;
|
||||||
private isGestureActive = false;
|
|
||||||
private previousPressure = 0;
|
|
||||||
private previousVelocityPixelsPerSecond = 0;
|
|
||||||
private previousVector: [number, number] | null = null;
|
|
||||||
private maniaAmount = 0;
|
|
||||||
private peakActivity = 0;
|
private peakActivity = 0;
|
||||||
private lastFrame: GardenAudioGestureFrame = DEFAULT_FRAME;
|
private lastFrame: GardenAudioGestureFrame = DEFAULT_FRAME;
|
||||||
|
|
||||||
public constructor(
|
public constructor(private readonly inputConfig: GardenAudioConfig['input']) {}
|
||||||
private readonly speedForFullEnergyPixelsPerSecond: number,
|
|
||||||
private readonly inputConfig: GardenAudioEngineConfig['input']
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public beginGesture(): void {
|
public beginGesture(): void {
|
||||||
this.samples.length = 0;
|
this.samples.length = 0;
|
||||||
this.gestureClockSeconds = 0;
|
this.gestureClockSeconds = 0;
|
||||||
this.isGestureActive = true;
|
|
||||||
this.previousPressure = 0;
|
|
||||||
this.previousVelocityPixelsPerSecond = 0;
|
|
||||||
this.previousVector = null;
|
|
||||||
this.maniaAmount = 0;
|
|
||||||
this.peakActivity = 0;
|
this.peakActivity = 0;
|
||||||
this.lastFrame = DEFAULT_FRAME;
|
this.lastFrame = DEFAULT_FRAME;
|
||||||
}
|
}
|
||||||
|
|
||||||
public endGesture(): GardenAudioGestureFrame {
|
public endGesture(): GardenAudioGestureFrame {
|
||||||
this.isGestureActive = false;
|
|
||||||
this.samples.length = 0;
|
this.samples.length = 0;
|
||||||
this.previousVector = null;
|
this.gestureClockSeconds = 0;
|
||||||
this.previousVelocityPixelsPerSecond = 0;
|
|
||||||
this.maniaAmount = 0;
|
|
||||||
this.lastFrame = {
|
this.lastFrame = {
|
||||||
...this.lastFrame,
|
...DEFAULT_FRAME,
|
||||||
mode: this.peakActivity >= 0.42 ? 'afterglow' : 'calm',
|
mode:
|
||||||
activity: 0,
|
this.peakActivity >= this.inputConfig.activeActivityThreshold
|
||||||
maniaAmount: 0,
|
? 'afterglow'
|
||||||
speedAmount: 0,
|
: 'calm',
|
||||||
};
|
};
|
||||||
|
this.peakActivity = 0;
|
||||||
return this.lastFrame;
|
return this.lastFrame;
|
||||||
}
|
}
|
||||||
|
|
||||||
public recordTouchDown({
|
public recordTouchDown(): GardenAudioGestureFrame {
|
||||||
touch,
|
this.lastFrame = DEFAULT_FRAME;
|
||||||
colorIndex,
|
|
||||||
mirrorAmount,
|
|
||||||
pressure,
|
|
||||||
strength,
|
|
||||||
}: {
|
|
||||||
touch: GardenAudioTouchDown;
|
|
||||||
colorIndex: GardenAudioColorIndex;
|
|
||||||
mirrorAmount: number;
|
|
||||||
pressure: number;
|
|
||||||
strength: number;
|
|
||||||
}): GardenAudioGestureFrame {
|
|
||||||
const spatial = getSpatialBias(touch.position, touch.canvasSize);
|
|
||||||
const normalizedStrength = clamp01(strength);
|
|
||||||
|
|
||||||
this.previousPressure = pressure;
|
|
||||||
this.peakActivity = Math.max(this.peakActivity, normalizedStrength);
|
|
||||||
this.lastFrame = {
|
|
||||||
mode: normalizedStrength >= 0.38 ? 'active' : 'calm',
|
|
||||||
activity: normalizedStrength,
|
|
||||||
maniaAmount: 0,
|
|
||||||
panBias: spatial.panBias,
|
|
||||||
registerBias: spatial.registerBias,
|
|
||||||
brightnessBias: spatial.brightnessBias,
|
|
||||||
contour: colorIndex === 2 ? 0.25 : colorIndex === 0 ? -0.15 : 0,
|
|
||||||
pressure,
|
|
||||||
pressureDelta: 0,
|
|
||||||
mirrorAmount,
|
|
||||||
speedAmount: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
return this.lastFrame;
|
return this.lastFrame;
|
||||||
}
|
}
|
||||||
|
|
||||||
public recordStroke({
|
public recordStroke({
|
||||||
stroke,
|
|
||||||
metrics,
|
metrics,
|
||||||
mirrorAmount,
|
|
||||||
}: {
|
}: {
|
||||||
stroke: GardenAudioStroke;
|
|
||||||
metrics: GardenAudioStrokeMetrics;
|
metrics: GardenAudioStrokeMetrics;
|
||||||
mirrorAmount: number;
|
|
||||||
}): GardenAudioGestureFrame {
|
}): GardenAudioGestureFrame {
|
||||||
const elapsedSeconds = this.getElapsedSeconds(stroke);
|
this.gestureClockSeconds += metrics.elapsedSeconds;
|
||||||
this.gestureClockSeconds += elapsedSeconds;
|
|
||||||
|
|
||||||
const dx = stroke.to[0] - stroke.from[0];
|
if (metrics.distancePixels > 0) {
|
||||||
const dy = stroke.to[1] - stroke.from[1];
|
|
||||||
const distancePixels = metrics.distancePixels;
|
|
||||||
const speedRatio =
|
|
||||||
metrics.speedPixelsPerSecond /
|
|
||||||
Math.max(1, this.speedForFullEnergyPixelsPerSecond);
|
|
||||||
const speed = smoothstep(0.45, 1.2, speedRatio);
|
|
||||||
const acceleration = smoothstep(
|
|
||||||
3,
|
|
||||||
12,
|
|
||||||
Math.abs(metrics.speedPixelsPerSecond - this.previousVelocityPixelsPerSecond) /
|
|
||||||
(Math.max(1, this.speedForFullEnergyPixelsPerSecond) * elapsedSeconds)
|
|
||||||
);
|
|
||||||
const currentVector: [number, number] =
|
|
||||||
distancePixels > 0.001 ? [dx / distancePixels, dy / distancePixels] : [0, 0];
|
|
||||||
const turned = this.getTurned(currentVector, distancePixels, metrics.speedAmount);
|
|
||||||
const spatial = getSpatialBias(stroke.to, stroke.canvasSize);
|
|
||||||
const pressureDelta = clamp(metrics.pressure - this.previousPressure, -1, 1);
|
|
||||||
const contour = distancePixels > 0.001 ? clamp(-dy / distancePixels, -1, 1) : 0;
|
|
||||||
|
|
||||||
if (distancePixels > 0.5) {
|
|
||||||
this.samples.push({
|
this.samples.push({
|
||||||
at: this.gestureClockSeconds,
|
at: this.gestureClockSeconds,
|
||||||
speed,
|
distancePixels: metrics.distancePixels,
|
||||||
acceleration,
|
|
||||||
distancePixels,
|
|
||||||
turned,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
this.trimSamples();
|
this.trimSamples();
|
||||||
|
|
||||||
const features = this.getWindowFeatures();
|
const windowDistancePixels = this.samples.reduce(
|
||||||
const distanceFeature = smoothstep(10, 90, metrics.distancePixels);
|
(total, sample) => total + sample.distancePixels,
|
||||||
const normalIntensity = clamp01(
|
0
|
||||||
0.1 +
|
|
||||||
features.speed * 0.46 +
|
|
||||||
metrics.pressure * 0.2 +
|
|
||||||
distanceFeature * 0.16 +
|
|
||||||
mirrorAmount * 0.08
|
|
||||||
);
|
);
|
||||||
const hasKineticChange = features.acceleration > 0.35 || features.turns > 0.35;
|
const activity = clamp01(
|
||||||
const maniaGate =
|
windowDistancePixels / this.inputConfig.distanceWindowForFullActivityPixels
|
||||||
!stroke.isErasing &&
|
);
|
||||||
this.isGestureActive &&
|
const maniaAmount = smoothstep(this.inputConfig.manicActivityThreshold, 1, activity);
|
||||||
this.gestureClockSeconds > 0.2 &&
|
|
||||||
features.pathPixels > 60 &&
|
|
||||||
features.speed > 0.45 &&
|
|
||||||
hasKineticChange;
|
|
||||||
const maniaEvidence = maniaGate
|
|
||||||
? clamp01(
|
|
||||||
features.speed * 0.34 +
|
|
||||||
features.acceleration * 0.26 +
|
|
||||||
features.strokeFrequency * 0.2 +
|
|
||||||
features.turns * 0.2
|
|
||||||
) *
|
|
||||||
(1 + mirrorAmount * 0.22)
|
|
||||||
: 0;
|
|
||||||
const maniaTarget = smoothstep(0.55, 0.85, maniaEvidence);
|
|
||||||
const timeConstant = maniaTarget > this.maniaAmount ? 0.12 : 0.65;
|
|
||||||
const maniaMove = 1 - Math.exp(-elapsedSeconds / timeConstant);
|
|
||||||
|
|
||||||
this.maniaAmount += (maniaTarget - this.maniaAmount) * maniaMove;
|
|
||||||
this.previousPressure = metrics.pressure;
|
|
||||||
this.previousVelocityPixelsPerSecond = metrics.speedPixelsPerSecond;
|
|
||||||
this.previousVector = currentVector;
|
|
||||||
|
|
||||||
const activity = clamp01(normalIntensity + this.maniaAmount * 0.28);
|
|
||||||
this.peakActivity = Math.max(this.peakActivity, activity);
|
this.peakActivity = Math.max(this.peakActivity, activity);
|
||||||
this.lastFrame = {
|
this.lastFrame = {
|
||||||
mode: this.getMode(activity, this.maniaAmount),
|
...DEFAULT_FRAME,
|
||||||
|
mode: this.getMode(activity, maniaAmount),
|
||||||
activity,
|
activity,
|
||||||
maniaAmount: clamp01(this.maniaAmount),
|
maniaAmount,
|
||||||
panBias: spatial.panBias,
|
|
||||||
registerBias: spatial.registerBias,
|
|
||||||
brightnessBias: clamp01(
|
|
||||||
spatial.brightnessBias * 0.65 + metrics.pressure * 0.2 + speed * 0.15
|
|
||||||
),
|
|
||||||
contour,
|
|
||||||
pressure: metrics.pressure,
|
|
||||||
pressureDelta,
|
|
||||||
mirrorAmount,
|
|
||||||
speedAmount: metrics.speedAmount,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return this.lastFrame;
|
return this.lastFrame;
|
||||||
|
|
@ -235,150 +97,26 @@ export class GardenAudioGestureState {
|
||||||
public reset(): void {
|
public reset(): void {
|
||||||
this.samples.length = 0;
|
this.samples.length = 0;
|
||||||
this.gestureClockSeconds = 0;
|
this.gestureClockSeconds = 0;
|
||||||
this.isGestureActive = false;
|
|
||||||
this.previousPressure = 0;
|
|
||||||
this.previousVelocityPixelsPerSecond = 0;
|
|
||||||
this.previousVector = null;
|
|
||||||
this.maniaAmount = 0;
|
|
||||||
this.peakActivity = 0;
|
this.peakActivity = 0;
|
||||||
this.lastFrame = DEFAULT_FRAME;
|
this.lastFrame = DEFAULT_FRAME;
|
||||||
}
|
}
|
||||||
|
|
||||||
private getElapsedSeconds(stroke: GardenAudioStroke): number {
|
|
||||||
if (
|
|
||||||
stroke.elapsedSeconds !== undefined &&
|
|
||||||
Number.isFinite(stroke.elapsedSeconds) &&
|
|
||||||
stroke.elapsedSeconds > 0
|
|
||||||
) {
|
|
||||||
return clamp(stroke.elapsedSeconds, 0.001, 0.15);
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.inputConfig.fallbackFrameSeconds;
|
|
||||||
}
|
|
||||||
|
|
||||||
private getTurned(
|
|
||||||
currentVector: [number, number],
|
|
||||||
distancePixels: number,
|
|
||||||
speedAmount: number
|
|
||||||
): boolean {
|
|
||||||
if (
|
|
||||||
!this.previousVector ||
|
|
||||||
distancePixels <= MIN_TURN_DISTANCE_PIXELS ||
|
|
||||||
speedAmount <= 0.35
|
|
||||||
) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const dot = clamp(
|
|
||||||
this.previousVector[0] * currentVector[0] +
|
|
||||||
this.previousVector[1] * currentVector[1],
|
|
||||||
-1,
|
|
||||||
1
|
|
||||||
);
|
|
||||||
const degrees = (Math.acos(dot) * 180) / Math.PI;
|
|
||||||
return degrees > MIN_TURN_DEGREES;
|
|
||||||
}
|
|
||||||
|
|
||||||
private trimSamples(): void {
|
private trimSamples(): void {
|
||||||
const earliest = this.gestureClockSeconds - WINDOW_SECONDS;
|
const earliest = this.gestureClockSeconds - this.inputConfig.distanceWindowSeconds;
|
||||||
while (this.samples.length > 0 && this.samples[0].at < earliest) {
|
while (this.samples.length > 0 && this.samples[0].at < earliest) {
|
||||||
this.samples.shift();
|
this.samples.shift();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private getWindowFeatures(): {
|
|
||||||
speed: number;
|
|
||||||
acceleration: number;
|
|
||||||
strokeFrequency: number;
|
|
||||||
turns: number;
|
|
||||||
pathPixels: number;
|
|
||||||
} {
|
|
||||||
if (this.samples.length === 0) {
|
|
||||||
return {
|
|
||||||
speed: 0,
|
|
||||||
acceleration: 0,
|
|
||||||
strokeFrequency: 0,
|
|
||||||
turns: 0,
|
|
||||||
pathPixels: 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const first = this.samples[0];
|
|
||||||
const last = this.samples[this.samples.length - 1];
|
|
||||||
const spanSeconds = clamp(last.at - first.at, 0.2, WINDOW_SECONDS);
|
|
||||||
const bins = new Set<number>();
|
|
||||||
let pathPixels = 0;
|
|
||||||
let turnCount = 0;
|
|
||||||
|
|
||||||
this.samples.forEach((sample) => {
|
|
||||||
if (sample.distancePixels > 1) {
|
|
||||||
bins.add(Math.floor(sample.at / BIN_SECONDS));
|
|
||||||
}
|
|
||||||
if (sample.turned) {
|
|
||||||
turnCount += 1;
|
|
||||||
}
|
|
||||||
pathPixels += sample.distancePixels;
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
speed: percentile(this.samples.map((sample) => sample.speed), 0.75),
|
|
||||||
acceleration: percentile(
|
|
||||||
this.samples.map((sample) => sample.acceleration),
|
|
||||||
0.75
|
|
||||||
),
|
|
||||||
strokeFrequency: smoothstep(6, 14, bins.size / spanSeconds),
|
|
||||||
turns: smoothstep(2, 7, turnCount / spanSeconds),
|
|
||||||
pathPixels,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private getMode(activity: number, maniaAmount: number): GardenAudioGestureMode {
|
private getMode(activity: number, maniaAmount: number): GardenAudioGestureMode {
|
||||||
if (maniaAmount >= 0.72) {
|
if (maniaAmount >= this.inputConfig.manicModeThreshold) {
|
||||||
return 'manic';
|
return 'manic';
|
||||||
}
|
}
|
||||||
|
|
||||||
return activity >= 0.38 ? 'active' : 'calm';
|
return activity >= this.inputConfig.activeActivityThreshold ? 'active' : 'calm';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const getSpatialBias = (
|
|
||||||
position: ArrayLike<number> | undefined,
|
|
||||||
canvasSize: ArrayLike<number> | undefined
|
|
||||||
): {
|
|
||||||
panBias: number;
|
|
||||||
registerBias: number;
|
|
||||||
brightnessBias: number;
|
|
||||||
} => {
|
|
||||||
if (!position || !canvasSize) {
|
|
||||||
return {
|
|
||||||
panBias: 0,
|
|
||||||
registerBias: 0,
|
|
||||||
brightnessBias: 0.5,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const width = Math.max(1, canvasSize[0]);
|
|
||||||
const height = Math.max(1, canvasSize[1]);
|
|
||||||
const x = clamp01(position[0] / width);
|
|
||||||
const y = clamp01(position[1] / height);
|
|
||||||
|
|
||||||
return {
|
|
||||||
panBias: clamp(x * 2 - 1, -1, 1),
|
|
||||||
registerBias: clamp(1 - y * 2, -1, 1),
|
|
||||||
brightnessBias: clamp01(1 - y * 0.72),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const percentile = (values: Array<number>, amount: number): number => {
|
|
||||||
if (values.length === 0) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
const sorted = [...values].sort((a, b) => a - b);
|
|
||||||
const index = clamp(Math.floor((sorted.length - 1) * amount), 0, sorted.length - 1);
|
|
||||||
return sorted[index];
|
|
||||||
};
|
|
||||||
|
|
||||||
const smoothstep = (edge0: number, edge1: number, value: number): number => {
|
const smoothstep = (edge0: number, edge1: number, value: number): number => {
|
||||||
const amount = clamp01((value - edge0) / (edge1 - edge0));
|
const amount = clamp01((value - edge0) / (edge1 - edge0));
|
||||||
return amount * amount * (3 - 2 * amount);
|
return amount * amount * (3 - 2 * amount);
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,6 @@
|
||||||
import type { GardenAudioEngineConfig } from '../config';
|
|
||||||
import { clamp } from '../utils/clamp';
|
import { clamp } from '../utils/clamp';
|
||||||
import { GardenAudioConfig, GardenAudioVibeProfile } from './garden-audio-config';
|
import { GardenAudioConfig, GardenAudioVibeProfile } from './garden-audio-config';
|
||||||
|
|
||||||
const UNLOCK_TICK_SECONDS = 0.035;
|
|
||||||
const UNLOCK_TICK_FREQUENCY_HZ = 440;
|
|
||||||
|
|
||||||
export class GardenAudioGraph {
|
export class GardenAudioGraph {
|
||||||
public context: AudioContext | null = null;
|
public context: AudioContext | null = null;
|
||||||
public eventBus: GainNode | null = null;
|
public eventBus: GainNode | null = null;
|
||||||
|
|
@ -16,10 +12,7 @@ export class GardenAudioGraph {
|
||||||
private delayFeedback: GainNode | null = null;
|
private delayFeedback: GainNode | null = null;
|
||||||
private delayOutput: GainNode | null = null;
|
private delayOutput: GainNode | null = null;
|
||||||
|
|
||||||
public constructor(
|
public constructor(private readonly config: GardenAudioConfig) {}
|
||||||
private readonly config: GardenAudioConfig,
|
|
||||||
private readonly engineConfig: GardenAudioEngineConfig
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public ensureContext(canCreate: boolean): AudioContext | null {
|
public ensureContext(canCreate: boolean): AudioContext | null {
|
||||||
if (this.context) {
|
if (this.context) {
|
||||||
|
|
@ -73,16 +66,16 @@ export class GardenAudioGraph {
|
||||||
const gain = this.context.createGain();
|
const gain = this.context.createGain();
|
||||||
|
|
||||||
source.type = 'sine';
|
source.type = 'sine';
|
||||||
source.frequency.setValueAtTime(UNLOCK_TICK_FREQUENCY_HZ, now);
|
source.frequency.setValueAtTime(this.config.graph.unlockTickFrequencyHz, now);
|
||||||
gain.gain.setValueAtTime(this.engineConfig.piano.minGain, now);
|
gain.gain.setValueAtTime(this.config.piano.minGain, now);
|
||||||
gain.gain.exponentialRampToValueAtTime(
|
gain.gain.exponentialRampToValueAtTime(
|
||||||
this.engineConfig.piano.minGain,
|
this.config.piano.minGain,
|
||||||
now + UNLOCK_TICK_SECONDS
|
now + this.config.graph.unlockTickSeconds
|
||||||
);
|
);
|
||||||
source.connect(gain);
|
source.connect(gain);
|
||||||
gain.connect(this.context.destination);
|
gain.connect(this.context.destination);
|
||||||
source.start(now);
|
source.start(now);
|
||||||
source.stop(now + UNLOCK_TICK_SECONDS);
|
source.stop(now + this.config.graph.unlockTickSeconds);
|
||||||
source.addEventListener(
|
source.addEventListener(
|
||||||
'ended',
|
'ended',
|
||||||
() => {
|
() => {
|
||||||
|
|
@ -113,7 +106,7 @@ export class GardenAudioGraph {
|
||||||
this.delayNode.delayTime.setTargetAtTime(
|
this.delayNode.delayTime.setTargetAtTime(
|
||||||
this.config.delay.timeSeconds * profile.delayTimeMultiplier,
|
this.config.delay.timeSeconds * profile.delayTimeMultiplier,
|
||||||
this.context.currentTime,
|
this.context.currentTime,
|
||||||
this.engineConfig.graph.delayTimeRampSeconds
|
this.config.delay.timeRampSeconds
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -126,22 +119,21 @@ export class GardenAudioGraph {
|
||||||
this.delayNode.delayTime.setTargetAtTime(
|
this.delayNode.delayTime.setTargetAtTime(
|
||||||
this.config.delay.timeSeconds * profile.delayTimeMultiplier,
|
this.config.delay.timeSeconds * profile.delayTimeMultiplier,
|
||||||
now,
|
now,
|
||||||
this.engineConfig.graph.delayTimeRampSeconds
|
this.config.delay.timeRampSeconds
|
||||||
);
|
);
|
||||||
this.delayFeedback.gain.setTargetAtTime(
|
this.delayFeedback.gain.setTargetAtTime(
|
||||||
clamp(
|
clamp(
|
||||||
this.config.delay.feedback +
|
this.config.delay.feedback + activity * this.config.delay.activityFeedbackWeight,
|
||||||
activity * this.engineConfig.graph.delayActivityFeedbackWeight,
|
this.config.delay.feedbackMin,
|
||||||
this.engineConfig.graph.delayFeedbackMin,
|
this.config.delay.feedbackMax
|
||||||
this.engineConfig.graph.delayFeedbackMax
|
|
||||||
),
|
),
|
||||||
now,
|
now,
|
||||||
this.config.updateRampSeconds
|
this.config.updateRampSeconds
|
||||||
);
|
);
|
||||||
this.delayOutput.gain.setTargetAtTime(
|
this.delayOutput.gain.setTargetAtTime(
|
||||||
this.config.delay.wetGain *
|
this.config.delay.wetGain *
|
||||||
(this.engineConfig.graph.delayOutputBase +
|
(this.config.delay.outputBase +
|
||||||
activity * this.engineConfig.graph.delayOutputActivityWeight),
|
activity * this.config.delay.outputActivityWeight),
|
||||||
now,
|
now,
|
||||||
this.config.updateRampSeconds
|
this.config.updateRampSeconds
|
||||||
);
|
);
|
||||||
|
|
@ -155,9 +147,9 @@ export class GardenAudioGraph {
|
||||||
|
|
||||||
if (this.masterGain && context.state !== 'closed') {
|
if (this.masterGain && context.state !== 'closed') {
|
||||||
this.masterGain.gain.setTargetAtTime(
|
this.masterGain.gain.setTargetAtTime(
|
||||||
this.engineConfig.graph.closeGain,
|
this.config.graph.closeGain,
|
||||||
context.currentTime,
|
context.currentTime,
|
||||||
this.engineConfig.graph.closeRampSeconds
|
this.config.graph.closeRampSeconds
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -170,7 +162,7 @@ export class GardenAudioGraph {
|
||||||
|
|
||||||
private createDelay(context: AudioContext, masterGain: GainNode): void {
|
private createDelay(context: AudioContext, masterGain: GainNode): void {
|
||||||
const delayInput = context.createGain();
|
const delayInput = context.createGain();
|
||||||
const delayNode = context.createDelay(2);
|
const delayNode = context.createDelay(this.config.graph.delayMaxSeconds);
|
||||||
const delayFeedback = context.createGain();
|
const delayFeedback = context.createGain();
|
||||||
const delayOutput = context.createGain();
|
const delayOutput = context.createGain();
|
||||||
|
|
||||||
|
|
@ -192,7 +184,7 @@ export class GardenAudioGraph {
|
||||||
|
|
||||||
private createBuses(context: AudioContext, masterGain: GainNode): void {
|
private createBuses(context: AudioContext, masterGain: GainNode): void {
|
||||||
this.eventBus = context.createGain();
|
this.eventBus = context.createGain();
|
||||||
this.eventBus.gain.value = this.engineConfig.graph.eventBusGain;
|
this.eventBus.gain.value = this.config.graph.eventBusGain;
|
||||||
this.eventBus.connect(masterGain);
|
this.eventBus.connect(masterGain);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -202,9 +194,8 @@ export class GardenAudioGraph {
|
||||||
|
|
||||||
for (let index = 0; index < data.length; index++) {
|
for (let index = 0; index < data.length; index++) {
|
||||||
data[index] =
|
data[index] =
|
||||||
this.engineConfig.graph.noiseMin +
|
this.config.graph.noiseMin +
|
||||||
Math.random() *
|
Math.random() * (this.config.graph.noiseMax - this.config.graph.noiseMin);
|
||||||
(this.engineConfig.graph.noiseMax - this.engineConfig.graph.noiseMin);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return buffer;
|
return buffer;
|
||||||
|
|
|
||||||
|
|
@ -1,70 +1,35 @@
|
||||||
import type { GardenAudioEngineConfig } from '../config';
|
import type { GardenAudioConfig } from './garden-audio-config';
|
||||||
import { clamp01 } from '../utils/clamp';
|
import type { GardenAudioStroke } from './garden-audio-types';
|
||||||
import { GardenAudioStroke } from './garden-audio-types';
|
|
||||||
|
|
||||||
export interface GardenAudioStrokeMetrics {
|
export interface GardenAudioStrokeMetrics {
|
||||||
distancePixels: number;
|
distancePixels: number;
|
||||||
pressure: number;
|
elapsedSeconds: number;
|
||||||
speedPixelsPerSecond: number;
|
|
||||||
speedAmount: number;
|
|
||||||
effectiveEnergy: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getStrokeMetrics = (
|
export const getStrokeMetrics = (
|
||||||
stroke: GardenAudioStroke,
|
stroke: GardenAudioStroke,
|
||||||
speedForFullEnergyPixelsPerSecond: number,
|
inputConfig: GardenAudioConfig['input']
|
||||||
inputConfig: GardenAudioEngineConfig['input']
|
|
||||||
): GardenAudioStrokeMetrics => {
|
): GardenAudioStrokeMetrics => {
|
||||||
const dx = stroke.to[0] - stroke.from[0];
|
const dx = stroke.to[0] - stroke.from[0];
|
||||||
const dy = stroke.to[1] - stroke.from[1];
|
const dy = stroke.to[1] - stroke.from[1];
|
||||||
const distancePixels = Math.hypot(dx, dy);
|
|
||||||
const speedPixelsPerSecond = getStrokeVelocity(stroke, distancePixels, inputConfig);
|
|
||||||
const pressure = getPressureAmount(stroke);
|
|
||||||
const speedAmount = clamp01(speedPixelsPerSecond / speedForFullEnergyPixelsPerSecond);
|
|
||||||
const strokeEnergy = clamp01(
|
|
||||||
inputConfig.strokeEnergyBase +
|
|
||||||
speedAmount * inputConfig.strokeEnergySpeedWeight +
|
|
||||||
pressure * inputConfig.strokeEnergyPressureWeight
|
|
||||||
);
|
|
||||||
const effectiveEnergy =
|
|
||||||
strokeEnergy *
|
|
||||||
(inputConfig.distanceEnergyBase +
|
|
||||||
clamp01(distancePixels / inputConfig.distanceForFullEnergyPixels) *
|
|
||||||
inputConfig.distanceEnergyScale);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
distancePixels,
|
distancePixels: Math.hypot(dx, dy),
|
||||||
pressure,
|
elapsedSeconds: getElapsedSeconds(stroke, inputConfig),
|
||||||
speedPixelsPerSecond,
|
|
||||||
speedAmount,
|
|
||||||
effectiveEnergy,
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const getStrokeVelocity = (
|
const getElapsedSeconds = (
|
||||||
stroke: GardenAudioStroke,
|
stroke: GardenAudioStroke,
|
||||||
distancePixels: number,
|
inputConfig: GardenAudioConfig['input']
|
||||||
inputConfig: GardenAudioEngineConfig['input']
|
|
||||||
): number => {
|
): number => {
|
||||||
if (
|
if (
|
||||||
stroke.velocityPixelsPerSecond !== undefined &&
|
stroke.elapsedSeconds !== undefined &&
|
||||||
Number.isFinite(stroke.velocityPixelsPerSecond) &&
|
Number.isFinite(stroke.elapsedSeconds) &&
|
||||||
stroke.velocityPixelsPerSecond >= 0
|
stroke.elapsedSeconds > 0
|
||||||
) {
|
) {
|
||||||
return stroke.velocityPixelsPerSecond;
|
return Math.max(0.001, stroke.elapsedSeconds);
|
||||||
}
|
}
|
||||||
|
|
||||||
return distancePixels / inputConfig.fallbackFrameSeconds;
|
return inputConfig.fallbackFrameSeconds;
|
||||||
};
|
|
||||||
|
|
||||||
const getPressureAmount = (stroke: GardenAudioStroke): number => {
|
|
||||||
if (
|
|
||||||
stroke.pressure !== undefined &&
|
|
||||||
Number.isFinite(stroke.pressure) &&
|
|
||||||
stroke.pressure > 0
|
|
||||||
) {
|
|
||||||
return clamp01(stroke.pressure);
|
|
||||||
}
|
|
||||||
|
|
||||||
return 0;
|
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -6,30 +6,19 @@ export interface GardenAudioSnapshot {
|
||||||
vibe: VibePreset;
|
vibe: VibePreset;
|
||||||
selectedColorIndex: number;
|
selectedColorIndex: number;
|
||||||
isErasing: boolean;
|
isErasing: boolean;
|
||||||
mirrorSegmentCount?: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GardenAudioStroke {
|
export interface GardenAudioStroke {
|
||||||
vibe: VibePreset;
|
vibe: VibePreset;
|
||||||
from: ArrayLike<number>;
|
from: ArrayLike<number>;
|
||||||
to: ArrayLike<number>;
|
to: ArrayLike<number>;
|
||||||
canvasSize: ArrayLike<number>;
|
|
||||||
colorIndex: number;
|
colorIndex: number;
|
||||||
isErasing: boolean;
|
isErasing: boolean;
|
||||||
pressure?: number;
|
|
||||||
velocityPixelsPerSecond?: number;
|
|
||||||
elapsedSeconds?: number;
|
elapsedSeconds?: number;
|
||||||
eraserSizePixels?: number;
|
|
||||||
mirrorSegmentCount?: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GardenAudioTouchDown {
|
export interface GardenAudioTouchDown {
|
||||||
vibe: VibePreset;
|
|
||||||
colorIndex: number;
|
colorIndex: number;
|
||||||
position?: ArrayLike<number>;
|
|
||||||
canvasSize?: ArrayLike<number>;
|
|
||||||
mirrorSegmentCount?: number;
|
|
||||||
pressure?: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GardenAudioStartOptions {
|
export interface GardenAudioStartOptions {
|
||||||
|
|
|
||||||
|
|
@ -159,11 +159,7 @@ describe('GardenAudio startup policy', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not create an AudioContext from passive audio paths', () => {
|
it('does not create an AudioContext from passive audio paths', () => {
|
||||||
const audio = new GardenAudio(
|
const audio = new GardenAudio(makeConfig());
|
||||||
makeConfig(),
|
|
||||||
appConfig.audioEngine,
|
|
||||||
appConfig.simulation.maxMirrorSegmentCount
|
|
||||||
);
|
|
||||||
const vibe = VIBE_PRESETS[0];
|
const vibe = VIBE_PRESETS[0];
|
||||||
|
|
||||||
audio.start(vibe);
|
audio.start(vibe);
|
||||||
|
|
@ -171,7 +167,6 @@ describe('GardenAudio startup policy', () => {
|
||||||
vibe,
|
vibe,
|
||||||
from: [0, 0],
|
from: [0, 0],
|
||||||
to: [12, 0],
|
to: [12, 0],
|
||||||
canvasSize: [100, 100],
|
|
||||||
colorIndex: 0,
|
colorIndex: 0,
|
||||||
isErasing: false,
|
isErasing: false,
|
||||||
});
|
});
|
||||||
|
|
@ -180,11 +175,7 @@ describe('GardenAudio startup policy', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('only resumes a suspended context from a user gesture start', () => {
|
it('only resumes a suspended context from a user gesture start', () => {
|
||||||
const audio = new GardenAudio(
|
const audio = new GardenAudio(makeConfig());
|
||||||
makeConfig(),
|
|
||||||
appConfig.audioEngine,
|
|
||||||
appConfig.simulation.maxMirrorSegmentCount
|
|
||||||
);
|
|
||||||
const vibe = VIBE_PRESETS[0];
|
const vibe = VIBE_PRESETS[0];
|
||||||
|
|
||||||
audio.start(vibe, { userGesture: true });
|
audio.start(vibe, { userGesture: true });
|
||||||
|
|
@ -201,11 +192,7 @@ describe('GardenAudio startup policy', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('reports AudioContext resume failures as warnings', async () => {
|
it('reports AudioContext resume failures as warnings', async () => {
|
||||||
const audio = new GardenAudio(
|
const audio = new GardenAudio(makeConfig());
|
||||||
makeConfig(),
|
|
||||||
appConfig.audioEngine,
|
|
||||||
appConfig.simulation.maxMirrorSegmentCount
|
|
||||||
);
|
|
||||||
const vibe = VIBE_PRESETS[0];
|
const vibe = VIBE_PRESETS[0];
|
||||||
resumeError = new Error('resume rejected');
|
resumeError = new Error('resume rejected');
|
||||||
const addException = vi.spyOn(ErrorHandler, 'addException');
|
const addException = vi.spyOn(ErrorHandler, 'addException');
|
||||||
|
|
@ -221,11 +208,7 @@ describe('GardenAudio startup policy', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('stays silent without piano samples while preserving eraser noise', () => {
|
it('stays silent without piano samples while preserving eraser noise', () => {
|
||||||
const audio = new GardenAudio(
|
const audio = new GardenAudio(makeConfig());
|
||||||
makeConfig(),
|
|
||||||
appConfig.audioEngine,
|
|
||||||
appConfig.simulation.maxMirrorSegmentCount
|
|
||||||
);
|
|
||||||
const vibe = VIBE_PRESETS[0];
|
const vibe = VIBE_PRESETS[0];
|
||||||
|
|
||||||
audio.start(vibe, { userGesture: true });
|
audio.start(vibe, { userGesture: true });
|
||||||
|
|
@ -233,21 +216,15 @@ describe('GardenAudio startup policy', () => {
|
||||||
|
|
||||||
audio.beginGesture();
|
audio.beginGesture();
|
||||||
audio.touchDown({
|
audio.touchDown({
|
||||||
vibe,
|
|
||||||
colorIndex: 1,
|
colorIndex: 1,
|
||||||
position: [30, 40],
|
|
||||||
canvasSize: [100, 100],
|
|
||||||
pressure: 0.7,
|
|
||||||
});
|
});
|
||||||
audio.stroke({
|
audio.stroke({
|
||||||
vibe,
|
vibe,
|
||||||
from: [30, 40],
|
from: [30, 40],
|
||||||
to: [60, 60],
|
to: [60, 60],
|
||||||
canvasSize: [100, 100],
|
|
||||||
colorIndex: 1,
|
colorIndex: 1,
|
||||||
isErasing: false,
|
isErasing: false,
|
||||||
pressure: 0.7,
|
elapsedSeconds: 0.05,
|
||||||
velocityPixelsPerSecond: 1600,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(calls.sourcesStarted).toBe(1);
|
expect(calls.sourcesStarted).toBe(1);
|
||||||
|
|
@ -256,12 +233,9 @@ describe('GardenAudio startup policy', () => {
|
||||||
vibe,
|
vibe,
|
||||||
from: [60, 60],
|
from: [60, 60],
|
||||||
to: [75, 80],
|
to: [75, 80],
|
||||||
canvasSize: [100, 100],
|
|
||||||
colorIndex: 1,
|
colorIndex: 1,
|
||||||
eraserSizePixels: 30,
|
|
||||||
isErasing: true,
|
isErasing: true,
|
||||||
pressure: 0.7,
|
elapsedSeconds: 0.05,
|
||||||
velocityPixelsPerSecond: 1200,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(calls.sourcesStarted).toBe(2);
|
expect(calls.sourcesStarted).toBe(2);
|
||||||
|
|
@ -277,21 +251,21 @@ describe('GardenAudio startup policy', () => {
|
||||||
);
|
);
|
||||||
await loadPianoSamples(new FakeAudioContext() as unknown as AudioContext);
|
await loadPianoSamples(new FakeAudioContext() as unknown as AudioContext);
|
||||||
|
|
||||||
const audio = new GardenAudio(
|
const audio = new GardenAudio(makeConfig());
|
||||||
makeConfig(),
|
|
||||||
appConfig.audioEngine,
|
|
||||||
appConfig.simulation.maxMirrorSegmentCount
|
|
||||||
);
|
|
||||||
const vibe = VIBE_PRESETS[0];
|
const vibe = VIBE_PRESETS[0];
|
||||||
|
|
||||||
audio.start(vibe, { userGesture: true });
|
audio.start(vibe, { userGesture: true });
|
||||||
audio.beginGesture();
|
audio.beginGesture();
|
||||||
audio.touchDown({
|
audio.touchDown({
|
||||||
vibe,
|
|
||||||
colorIndex: 1,
|
colorIndex: 1,
|
||||||
position: [30, 40],
|
});
|
||||||
canvasSize: [100, 100],
|
audio.stroke({
|
||||||
pressure: 0.7,
|
vibe,
|
||||||
|
from: [30, 40],
|
||||||
|
to: [90, 40],
|
||||||
|
colorIndex: 1,
|
||||||
|
elapsedSeconds: 0.05,
|
||||||
|
isErasing: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const activePianoSources = calls.sources.filter(
|
const activePianoSources = calls.sources.filter(
|
||||||
|
|
@ -308,7 +282,7 @@ describe('GardenAudio startup policy', () => {
|
||||||
expect(stoppedVoices.length).toBeGreaterThan(0);
|
expect(stoppedVoices.length).toBeGreaterThan(0);
|
||||||
stoppedVoices.forEach((source) => {
|
stoppedVoices.forEach((source) => {
|
||||||
expect(source.stop.mock.calls.at(-1)?.[0]).toBeCloseTo(
|
expect(source.stop.mock.calls.at(-1)?.[0]).toBeCloseTo(
|
||||||
1 + appConfig.audioEngine.piano.voiceStealStopSeconds,
|
1 + appConfig.audio.piano.voiceStealStopSeconds,
|
||||||
3
|
3
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import type { GardenAudioEngineConfig } from '../config';
|
import { clamp01 } from '../utils/clamp';
|
||||||
import { clamp, clamp01 } from '../utils/clamp';
|
|
||||||
import { ErrorHandler, Severity } from '../utils/error-handler';
|
import { ErrorHandler, Severity } from '../utils/error-handler';
|
||||||
import { VibePreset } from '../vibes';
|
import { VibePreset } from '../vibes';
|
||||||
import { GardenAudioConfig } from './garden-audio-config';
|
import { GardenAudioConfig } from './garden-audio-config';
|
||||||
|
|
@ -44,22 +43,13 @@ export class GardenAudio {
|
||||||
private lastEraserAt = Number.NEGATIVE_INFINITY;
|
private lastEraserAt = Number.NEGATIVE_INFINITY;
|
||||||
private lastVibeStingerAt = Number.NEGATIVE_INFINITY;
|
private lastVibeStingerAt = Number.NEGATIVE_INFINITY;
|
||||||
|
|
||||||
public constructor(
|
public constructor(private readonly config: GardenAudioConfig) {
|
||||||
private readonly config: GardenAudioConfig,
|
this.graph = new GardenAudioGraph(config);
|
||||||
private readonly engineConfig: GardenAudioEngineConfig,
|
this.piano = new PianoSampler(config, this.graph);
|
||||||
private readonly maxMirrorSegmentCount: number
|
this.noise = new NoiseBurstPlayer(config, this.graph);
|
||||||
) {
|
this.energy = new GardenAudioEnergy(config);
|
||||||
this.graph = new GardenAudioGraph(config, engineConfig);
|
this.gestureState = new GardenAudioGestureState(config.input);
|
||||||
this.piano = new PianoSampler(config, engineConfig, this.graph);
|
this.pianoEngine = new GenerativePianoEngine(config, (note) => this.piano.play(note));
|
||||||
this.noise = new NoiseBurstPlayer(engineConfig, this.graph);
|
|
||||||
this.energy = new GardenAudioEnergy(engineConfig);
|
|
||||||
this.gestureState = new GardenAudioGestureState(
|
|
||||||
config.rhythm.speedForFullEnergyPixelsPerSecond,
|
|
||||||
engineConfig.input
|
|
||||||
);
|
|
||||||
this.pianoEngine = new GenerativePianoEngine(config, engineConfig, (note) =>
|
|
||||||
this.piano.play(note)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public start(vibe: VibePreset, options: GardenAudioStartOptions = {}): void {
|
public start(vibe: VibePreset, options: GardenAudioStartOptions = {}): void {
|
||||||
|
|
@ -74,7 +64,7 @@ export class GardenAudio {
|
||||||
|
|
||||||
const startupRampSeconds =
|
const startupRampSeconds =
|
||||||
options.userGesture === true
|
options.userGesture === true
|
||||||
? this.engineConfig.muteRampSeconds
|
? this.config.muteRampSeconds
|
||||||
: this.config.fadeInSeconds;
|
: this.config.fadeInSeconds;
|
||||||
const needsResume = context.state !== 'running' && context.state !== 'closed';
|
const needsResume = context.state !== 'running' && context.state !== 'closed';
|
||||||
let resumePromise: Promise<void> | null = null;
|
let resumePromise: Promise<void> | null = null;
|
||||||
|
|
@ -148,8 +138,8 @@ export class GardenAudio {
|
||||||
public setMuted(isMuted: boolean): void {
|
public setMuted(isMuted: boolean): void {
|
||||||
this.isMuted = isMuted;
|
this.isMuted = isMuted;
|
||||||
this.graph.setMasterGain(
|
this.graph.setMasterGain(
|
||||||
isMuted ? this.engineConfig.muteGain : this.config.masterVolume,
|
isMuted ? this.config.muteGain : this.config.masterVolume,
|
||||||
isMuted ? this.engineConfig.muteRampSeconds : this.config.fadeInSeconds
|
isMuted ? this.config.muteRampSeconds : this.config.fadeInSeconds
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -183,32 +173,7 @@ export class GardenAudio {
|
||||||
}
|
}
|
||||||
|
|
||||||
this.selectedColorIndex = normalizeColorIndex(touch.colorIndex);
|
this.selectedColorIndex = normalizeColorIndex(touch.colorIndex);
|
||||||
const mirrorAmount = this.getMirrorAmount(touch.mirrorSegmentCount ?? 1);
|
this.gestureState.recordTouchDown();
|
||||||
const pressure = this.getPressure(touch.pressure);
|
|
||||||
const strength = clamp01(0.36 + pressure * 0.34 + mirrorAmount * 0.22);
|
|
||||||
const frame = this.gestureState.recordTouchDown({
|
|
||||||
touch,
|
|
||||||
colorIndex: this.selectedColorIndex,
|
|
||||||
mirrorAmount,
|
|
||||||
pressure,
|
|
||||||
strength,
|
|
||||||
});
|
|
||||||
|
|
||||||
this.energy.recordStroke(strength, context.currentTime);
|
|
||||||
this.pianoEngine.recordTouchDown({
|
|
||||||
vibe: touch.vibe,
|
|
||||||
now: context.currentTime,
|
|
||||||
strength,
|
|
||||||
selectedColorIndex: this.selectedColorIndex,
|
|
||||||
mirrorAmount,
|
|
||||||
panBias: frame.panBias,
|
|
||||||
registerBias: frame.registerBias,
|
|
||||||
brightnessBias: frame.brightnessBias,
|
|
||||||
contour: frame.contour,
|
|
||||||
pressureAmount: frame.pressure,
|
|
||||||
pressureDelta: frame.pressureDelta,
|
|
||||||
maniaAmount: frame.maniaAmount,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public update(snapshot: GardenAudioSnapshot): void {
|
public update(snapshot: GardenAudioSnapshot): void {
|
||||||
|
|
@ -248,37 +213,25 @@ export class GardenAudio {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const metrics = getStrokeMetrics(
|
const metrics = getStrokeMetrics(stroke, this.config.input);
|
||||||
stroke,
|
|
||||||
this.config.rhythm.speedForFullEnergyPixelsPerSecond,
|
|
||||||
this.engineConfig.input
|
|
||||||
);
|
|
||||||
const now = context.currentTime;
|
const now = context.currentTime;
|
||||||
|
|
||||||
this.selectedColorIndex = normalizeColorIndex(stroke.colorIndex);
|
this.selectedColorIndex = normalizeColorIndex(stroke.colorIndex);
|
||||||
|
const frame = this.gestureState.recordStroke({ metrics });
|
||||||
|
const strokeEnergy = frame.activity;
|
||||||
|
|
||||||
if (stroke.isErasing) {
|
if (stroke.isErasing) {
|
||||||
this.energy.recordEraserStroke();
|
this.energy.recordEraserStroke();
|
||||||
this.playEraser(stroke, metrics.speedAmount, metrics.pressure, now);
|
this.playEraser(strokeEnergy, now);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const mirrorAmount = this.getMirrorAmount(stroke.mirrorSegmentCount ?? 1);
|
|
||||||
const frame = this.gestureState.recordStroke({ stroke, metrics, mirrorAmount });
|
|
||||||
const strokeEnergy = frame.activity;
|
|
||||||
this.energy.recordStroke(strokeEnergy, now);
|
this.energy.recordStroke(strokeEnergy, now);
|
||||||
this.pianoEngine.recordStroke({
|
this.pianoEngine.recordStroke({
|
||||||
vibe: stroke.vibe,
|
vibe: stroke.vibe,
|
||||||
now,
|
now,
|
||||||
activity: strokeEnergy,
|
activity: strokeEnergy,
|
||||||
selectedColorIndex: this.selectedColorIndex,
|
selectedColorIndex: this.selectedColorIndex,
|
||||||
mirrorAmount,
|
|
||||||
panBias: frame.panBias,
|
|
||||||
registerBias: frame.registerBias,
|
|
||||||
brightnessBias: frame.brightnessBias,
|
|
||||||
contour: frame.contour,
|
|
||||||
pressureAmount: frame.pressure,
|
|
||||||
pressureDelta: frame.pressureDelta,
|
|
||||||
maniaAmount: frame.maniaAmount,
|
maniaAmount: frame.maniaAmount,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -307,10 +260,7 @@ export class GardenAudio {
|
||||||
}
|
}
|
||||||
|
|
||||||
const now = context.currentTime;
|
const now = context.currentTime;
|
||||||
if (
|
if (now - this.lastVibeStingerAt < this.config.vibeChangeStingerMinIntervalSeconds) {
|
||||||
now - this.lastVibeStingerAt <
|
|
||||||
this.engineConfig.vibeChangeStingerMinIntervalSeconds
|
|
||||||
) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -318,46 +268,29 @@ export class GardenAudio {
|
||||||
this.pianoEngine.playVibeChangeStinger(vibe, now);
|
this.pianoEngine.playVibeChangeStinger(vibe, now);
|
||||||
}
|
}
|
||||||
|
|
||||||
private playEraser(
|
private playEraser(activity: number, now: number): void {
|
||||||
stroke: GardenAudioStroke,
|
|
||||||
speedAmount: number,
|
|
||||||
pressure: number,
|
|
||||||
now: number
|
|
||||||
): void {
|
|
||||||
if (!this.graph.context) {
|
if (!this.graph.context) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const sizeAmount = clamp01(
|
const distanceActivity = clamp01(activity);
|
||||||
(stroke.eraserSizePixels ?? this.engineConfig.eraser.defaultSizePixels) /
|
if (distanceActivity <= 0) {
|
||||||
Math.max(
|
return;
|
||||||
1,
|
}
|
||||||
stroke.canvasSize[0] * this.engineConfig.eraser.canvasWidthRatioForFullSize
|
|
||||||
)
|
|
||||||
);
|
|
||||||
const x = clamp01(stroke.to[0] / Math.max(1, stroke.canvasSize[0]));
|
|
||||||
const filterHz =
|
const filterHz =
|
||||||
this.config.eraser.filterMinHz +
|
this.config.eraser.filterMinHz +
|
||||||
(this.config.eraser.filterMaxHz - this.config.eraser.filterMinHz) *
|
(this.config.eraser.filterMaxHz - this.config.eraser.filterMinHz) *
|
||||||
clamp01(
|
distanceActivity;
|
||||||
speedAmount * this.engineConfig.eraser.filterSpeedWeight +
|
|
||||||
pressure * this.engineConfig.eraser.filterPressureWeight +
|
|
||||||
sizeAmount * this.engineConfig.eraser.filterSizeWeight
|
|
||||||
);
|
|
||||||
|
|
||||||
if (now - this.lastEraserAt >= this.config.eraser.minIntervalSeconds) {
|
if (now - this.lastEraserAt >= this.config.eraser.minIntervalSeconds) {
|
||||||
this.lastEraserAt = now;
|
this.lastEraserAt = now;
|
||||||
this.noise.play({
|
this.noise.play({
|
||||||
startTime: now,
|
startTime: now,
|
||||||
durationSeconds: this.engineConfig.eraser.durationSeconds,
|
durationSeconds: this.config.eraser.durationSeconds,
|
||||||
gain:
|
gain: this.config.eraser.noiseGain * distanceActivity,
|
||||||
this.config.eraser.noiseGain *
|
|
||||||
(this.engineConfig.eraser.gainBase +
|
|
||||||
speedAmount * this.engineConfig.eraser.gainSpeedWeight +
|
|
||||||
pressure * this.engineConfig.eraser.gainPressureWeight +
|
|
||||||
sizeAmount * this.engineConfig.eraser.gainSizeWeight),
|
|
||||||
filterHz,
|
filterHz,
|
||||||
pan: clamp(x * 2 - 1, -1, 1),
|
pan: 0,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -370,7 +303,7 @@ export class GardenAudio {
|
||||||
|
|
||||||
const profile = getVibeProfile(this.config, snapshot.vibe);
|
const profile = getVibeProfile(this.config, snapshot.vibe);
|
||||||
const activity = snapshot.isErasing
|
const activity = snapshot.isErasing
|
||||||
? this.engineConfig.delay.erasingActivity
|
? this.config.delay.erasingActivity
|
||||||
: this.energy.getLevel();
|
: this.energy.getLevel();
|
||||||
this.graph.updateDelay(profile, activity);
|
this.graph.updateDelay(profile, activity);
|
||||||
}
|
}
|
||||||
|
|
@ -384,27 +317,4 @@ export class GardenAudio {
|
||||||
this.graph.applyDelayProfile(getVibeProfile(this.config, vibe));
|
this.graph.applyDelayProfile(getVibeProfile(this.config, vibe));
|
||||||
this.pianoEngine.cue(this.graph.context.currentTime);
|
this.pianoEngine.cue(this.graph.context.currentTime);
|
||||||
}
|
}
|
||||||
|
|
||||||
private getMirrorAmount(mirrorSegmentCount: number): number {
|
|
||||||
const maxMirrorSegmentCount = Math.max(1, this.maxMirrorSegmentCount);
|
|
||||||
const segmentCount = clamp(
|
|
||||||
Number.isFinite(mirrorSegmentCount) ? mirrorSegmentCount : 1,
|
|
||||||
1,
|
|
||||||
maxMirrorSegmentCount
|
|
||||||
);
|
|
||||||
|
|
||||||
if (maxMirrorSegmentCount <= 1) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
return clamp01((segmentCount - 1) / (maxMirrorSegmentCount - 1));
|
|
||||||
}
|
|
||||||
|
|
||||||
private getPressure(pressure: number | undefined): number {
|
|
||||||
if (pressure !== undefined && Number.isFinite(pressure) && pressure > 0) {
|
|
||||||
return clamp01(pressure);
|
|
||||||
}
|
|
||||||
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import { appConfig } from '../config';
|
|
||||||
import { VIBE_PRESETS } from '../vibes';
|
import { VIBE_PRESETS } from '../vibes';
|
||||||
import { gardenAudioConfig } from './garden-audio-config';
|
import { gardenAudioConfig } from './garden-audio-config';
|
||||||
import { PianoNote } from './garden-audio-types';
|
import { PianoNote } from './garden-audio-types';
|
||||||
|
|
@ -8,13 +7,9 @@ import { GenerativePianoEngine } from './generative-piano';
|
||||||
|
|
||||||
const makeEngine = () => {
|
const makeEngine = () => {
|
||||||
const notes: Array<PianoNote> = [];
|
const notes: Array<PianoNote> = [];
|
||||||
const engine = new GenerativePianoEngine(
|
const engine = new GenerativePianoEngine(gardenAudioConfig, (note) => {
|
||||||
gardenAudioConfig,
|
|
||||||
appConfig.audioEngine,
|
|
||||||
(note) => {
|
|
||||||
notes.push(note);
|
notes.push(note);
|
||||||
}
|
});
|
||||||
);
|
|
||||||
|
|
||||||
return { engine, notes };
|
return { engine, notes };
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
import type { GardenAudioEngineConfig } from '../config';
|
|
||||||
import { clamp, clamp01 } from '../utils/clamp';
|
import { clamp, clamp01 } from '../utils/clamp';
|
||||||
import { VibePreset } from '../vibes';
|
import { VibePreset } from '../vibes';
|
||||||
import {
|
import {
|
||||||
GardenAudioChord,
|
GardenAudioChord,
|
||||||
|
GardenAudioColorPool,
|
||||||
GardenAudioConfig,
|
GardenAudioConfig,
|
||||||
|
GardenAudioRegister,
|
||||||
GardenAudioVibeProfile,
|
GardenAudioVibeProfile,
|
||||||
} from './garden-audio-config';
|
} from './garden-audio-config';
|
||||||
import {
|
import {
|
||||||
|
|
@ -51,17 +52,6 @@ interface TouchDownRequest {
|
||||||
maniaAmount?: number;
|
maniaAmount?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Register {
|
|
||||||
midiMin: number;
|
|
||||||
midiMax: number;
|
|
||||||
preferredMidi: number;
|
|
||||||
pan: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ColorPool extends Register {
|
|
||||||
scaleDegrees: ReadonlyArray<number>;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PitchCandidate {
|
interface PitchCandidate {
|
||||||
midi: number;
|
midi: number;
|
||||||
preference: number;
|
preference: number;
|
||||||
|
|
@ -89,81 +79,6 @@ interface BrushPhraseLayer {
|
||||||
maniaAmount: number;
|
maniaAmount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const COLOR_POOLS: [ColorPool, ColorPool, ColorPool] = [
|
|
||||||
{
|
|
||||||
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: 81,
|
|
||||||
preferredMidi: 72,
|
|
||||||
pan: 0.18,
|
|
||||||
scaleDegrees: [2, 3, 4, 6],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const PAD_REGISTERS: [Register, Register, Register] = [
|
|
||||||
{
|
|
||||||
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,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const CHORD_BARS = 4;
|
|
||||||
const SUPPORT_BAR_SPACING = 2;
|
|
||||||
const SUPPORT_BAR_OFFSET = 1;
|
|
||||||
const IDLE_TEXTURE_BAR_SPACING = 2;
|
|
||||||
const MEDIUM_TEXTURE_BAR_SPACING = 1;
|
|
||||||
const TEXTURE_BEAT = 2;
|
|
||||||
const HIGH_ACTIVITY_EXTRA_BEAT = 3;
|
|
||||||
const HIGH_ACTIVITY_EXTRA_THRESHOLD = 0.45;
|
|
||||||
const NOTE_SCORE_PREFERENCE_WEIGHT = 1.8;
|
|
||||||
const NOTE_SCORE_REGISTER_WEIGHT = 0.28;
|
|
||||||
const NOTE_SCORE_REPEAT_PENALTY = 3.2;
|
|
||||||
const GESTURE_ACCENT_SPACING_SECONDS = 0.26;
|
|
||||||
const GESTURE_ACCENT_MIN_INTERVAL_SECONDS = 2.5;
|
|
||||||
const STROKE_ACCENT_MIN_INTERVAL_SECONDS = 3.2;
|
|
||||||
const STROKE_ACCENT_THRESHOLD = 0.58;
|
|
||||||
const STINGER_SPACING_SECONDS = 0.08;
|
|
||||||
const STINGER_DURATION_SECONDS = 1.1;
|
|
||||||
const MAX_BRUSH_PHRASE_LAYERS = 5;
|
|
||||||
const BRUSH_LAYER_BASE_SECONDS = 5.5;
|
|
||||||
const BRUSH_LAYER_ENERGY_SECONDS = 2.5;
|
|
||||||
const BRUSH_LAYER_MIRROR_SECONDS = 3;
|
|
||||||
const BRUSH_LAYER_MIN_INTENSITY = 0.08;
|
|
||||||
const BRUSH_STREAM_IDLE_INTERVAL_BEATS = 2;
|
|
||||||
const BRUSH_STREAM_ACTIVE_INTERVAL_BEATS = 1;
|
|
||||||
const BRUSH_STREAM_INTENSE_INTERVAL_BEATS = 0.5;
|
|
||||||
const BRUSH_STREAM_MANIC_INTERVAL_BEATS = 0.25;
|
|
||||||
const BRUSH_MOTIF_MAX_STEPS = 8;
|
|
||||||
const BRUSH_MOTIF_CANON_DELAY_SECONDS = 0.055;
|
|
||||||
const PAD_DURATION_BAR_SCALE = 0.46;
|
|
||||||
|
|
||||||
export class GenerativePianoEngine {
|
export class GenerativePianoEngine {
|
||||||
private nextBeatAt: number | null = null;
|
private nextBeatAt: number | null = null;
|
||||||
private timelineStartedAt: number | null = null;
|
private timelineStartedAt: number | null = null;
|
||||||
|
|
@ -183,23 +98,26 @@ export class GenerativePianoEngine {
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly config: GardenAudioConfig,
|
private readonly config: GardenAudioConfig,
|
||||||
private readonly engineConfig: GardenAudioEngineConfig,
|
|
||||||
private readonly playNote: (note: PianoNote) => void
|
private readonly playNote: (note: PianoNote) => void
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
private get generation(): GardenAudioConfig['generativePiano'] {
|
||||||
|
return this.config.generativePiano;
|
||||||
|
}
|
||||||
|
|
||||||
public prime(now: number): void {
|
public prime(now: number): void {
|
||||||
if (this.nextBeatAt === null) {
|
if (this.nextBeatAt === null) {
|
||||||
this.nextBeatAt = now + this.engineConfig.startDelaySeconds;
|
this.nextBeatAt = now + this.config.startDelaySeconds;
|
||||||
}
|
}
|
||||||
this.timelineStartedAt ??= now;
|
this.timelineStartedAt ??= now;
|
||||||
this.nextBrushStreamAt ??= now + this.engineConfig.startDelaySeconds;
|
this.nextBrushStreamAt ??= now + this.config.startDelaySeconds;
|
||||||
}
|
}
|
||||||
|
|
||||||
public cue(now: number): void {
|
public cue(now: number): void {
|
||||||
this.nextBeatAt = now + this.engineConfig.startDelaySeconds;
|
this.nextBeatAt = now + this.config.startDelaySeconds;
|
||||||
this.timelineStartedAt = now;
|
this.timelineStartedAt = now;
|
||||||
this.beatIndex = 0;
|
this.beatIndex = 0;
|
||||||
this.nextBrushStreamAt = now + this.engineConfig.startDelaySeconds;
|
this.nextBrushStreamAt = now + this.config.startDelaySeconds;
|
||||||
this.brushStreamNoteIndex = 0;
|
this.brushStreamNoteIndex = 0;
|
||||||
this.lastBrushStreamMidi = null;
|
this.lastBrushStreamMidi = null;
|
||||||
}
|
}
|
||||||
|
|
@ -288,7 +206,7 @@ export class GenerativePianoEngine {
|
||||||
|
|
||||||
if (
|
if (
|
||||||
this.isWaitingForGestureAccent &&
|
this.isWaitingForGestureAccent &&
|
||||||
now - this.lastGestureAccentAt >= GESTURE_ACCENT_MIN_INTERVAL_SECONDS
|
now - this.lastGestureAccentAt >= this.generation.gestureAccentMinIntervalSeconds
|
||||||
) {
|
) {
|
||||||
this.recordTouchDown({
|
this.recordTouchDown({
|
||||||
vibe,
|
vibe,
|
||||||
|
|
@ -310,8 +228,8 @@ export class GenerativePianoEngine {
|
||||||
...normalizedMotif,
|
...normalizedMotif,
|
||||||
});
|
});
|
||||||
if (
|
if (
|
||||||
strength >= STROKE_ACCENT_THRESHOLD &&
|
strength >= this.generation.strokeAccentThreshold &&
|
||||||
now - this.lastStrokeAccentAt >= STROKE_ACCENT_MIN_INTERVAL_SECONDS
|
now - this.lastStrokeAccentAt >= this.generation.strokeAccentMinIntervalSeconds
|
||||||
) {
|
) {
|
||||||
this.lastStrokeAccentAt = now;
|
this.lastStrokeAccentAt = now;
|
||||||
this.playGestureAccent(vibe, now, selectedColorIndex, strength, 1);
|
this.playGestureAccent(vibe, now, selectedColorIndex, strength, 1);
|
||||||
|
|
@ -361,7 +279,10 @@ export class GenerativePianoEngine {
|
||||||
const rootMidi = profile.rootMidi + chord.rootOffset;
|
const rootMidi = profile.rootMidi + chord.rootOffset;
|
||||||
const notes = [
|
const notes = [
|
||||||
{
|
{
|
||||||
midi: this.chooseMidi({ baseMidi: rootMidi, offsets: [0] }, PAD_REGISTERS[0]),
|
midi: this.chooseMidi(
|
||||||
|
{ baseMidi: rootMidi, offsets: [0] },
|
||||||
|
this.generation.padRegisters[0]
|
||||||
|
),
|
||||||
velocity: 0.1,
|
velocity: 0.1,
|
||||||
pan: -0.16,
|
pan: -0.16,
|
||||||
delaySend: 0.012,
|
delaySend: 0.012,
|
||||||
|
|
@ -369,7 +290,7 @@ export class GenerativePianoEngine {
|
||||||
{
|
{
|
||||||
midi: this.chooseMidi(
|
midi: this.chooseMidi(
|
||||||
{ baseMidi: rootMidi, offsets: [intervals[1], intervals[2]] },
|
{ baseMidi: rootMidi, offsets: [intervals[1], intervals[2]] },
|
||||||
PAD_REGISTERS[1]
|
this.generation.padRegisters[1]
|
||||||
),
|
),
|
||||||
velocity: 0.085,
|
velocity: 0.085,
|
||||||
pan: 0,
|
pan: 0,
|
||||||
|
|
@ -378,7 +299,7 @@ export class GenerativePianoEngine {
|
||||||
{
|
{
|
||||||
midi: this.chooseMidi(
|
midi: this.chooseMidi(
|
||||||
{ baseMidi: rootMidi, offsets: [intervals[3], intervals[2]] },
|
{ baseMidi: rootMidi, offsets: [intervals[3], intervals[2]] },
|
||||||
PAD_REGISTERS[2]
|
this.generation.padRegisters[2]
|
||||||
),
|
),
|
||||||
velocity: 0.07,
|
velocity: 0.07,
|
||||||
pan: 0.16,
|
pan: 0.16,
|
||||||
|
|
@ -389,9 +310,9 @@ export class GenerativePianoEngine {
|
||||||
notes.forEach((note, index) => {
|
notes.forEach((note, index) => {
|
||||||
this.playNote({
|
this.playNote({
|
||||||
...note,
|
...note,
|
||||||
durationSeconds: STINGER_DURATION_SECONDS,
|
durationSeconds: this.generation.stingerDurationSeconds,
|
||||||
lowpassHz: this.getLowpassHz(profile, note.midi, 0.35),
|
lowpassHz: this.getLowpassHz(profile, note.midi, 0.35),
|
||||||
startTime: now + index * STINGER_SPACING_SECONDS,
|
startTime: now + index * this.generation.stingerSpacingSeconds,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -429,7 +350,7 @@ export class GenerativePianoEngine {
|
||||||
const beatInBar = beatIndex % beatsPerBar;
|
const beatInBar = beatIndex % beatsPerBar;
|
||||||
const barIndex = Math.floor(beatIndex / beatsPerBar);
|
const barIndex = Math.floor(beatIndex / beatsPerBar);
|
||||||
|
|
||||||
if (beatInBar === 0 && barIndex % CHORD_BARS === 0) {
|
if (beatInBar === 0 && barIndex % this.generation.chordBars === 0) {
|
||||||
this.playPadChord(profile, barIndex, startTime, expression);
|
this.playPadChord(profile, barIndex, startTime, expression);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -437,13 +358,16 @@ export class GenerativePianoEngine {
|
||||||
this.playSupportNote(profile, barIndex, startTime, expression, selectedColorIndex);
|
this.playSupportNote(profile, barIndex, startTime, expression, selectedColorIndex);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (beatInBar === TEXTURE_BEAT && this.shouldPlayTexture(expression, barIndex)) {
|
if (
|
||||||
|
beatInBar === this.generation.textureBeat &&
|
||||||
|
this.shouldPlayTexture(expression, barIndex)
|
||||||
|
) {
|
||||||
this.playTextureNote(profile, barIndex, startTime, expression, selectedColorIndex);
|
this.playTextureNote(profile, barIndex, startTime, expression, selectedColorIndex);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
beatInBar === HIGH_ACTIVITY_EXTRA_BEAT &&
|
beatInBar === this.generation.highActivityExtraBeat &&
|
||||||
expression >= HIGH_ACTIVITY_EXTRA_THRESHOLD
|
expression >= this.generation.highActivityExtraThreshold
|
||||||
) {
|
) {
|
||||||
this.playTextureNote(
|
this.playTextureNote(
|
||||||
profile,
|
profile,
|
||||||
|
|
@ -464,21 +388,24 @@ export class GenerativePianoEngine {
|
||||||
const chord = this.getChord(profile, barIndex);
|
const chord = this.getChord(profile, barIndex);
|
||||||
const intervals = getChordIntervals(chord, true);
|
const intervals = getChordIntervals(chord, true);
|
||||||
const rootMidi = profile.rootMidi + chord.rootOffset;
|
const rootMidi = profile.rootMidi + chord.rootOffset;
|
||||||
const durationSeconds = this.getBarDurationSeconds() * CHORD_BARS * PAD_DURATION_BAR_SCALE;
|
const durationSeconds =
|
||||||
|
this.getBarDurationSeconds() *
|
||||||
|
this.generation.chordBars *
|
||||||
|
this.generation.padDurationBarScale;
|
||||||
const notes = [
|
const notes = [
|
||||||
{
|
{
|
||||||
source: { baseMidi: rootMidi, offsets: [0] },
|
source: { baseMidi: rootMidi, offsets: [0] },
|
||||||
register: PAD_REGISTERS[0],
|
register: this.generation.padRegisters[0],
|
||||||
velocity: 0.052,
|
velocity: 0.052,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
source: { baseMidi: rootMidi, offsets: [intervals[1]] },
|
source: { baseMidi: rootMidi, offsets: [intervals[1]] },
|
||||||
register: PAD_REGISTERS[1],
|
register: this.generation.padRegisters[1],
|
||||||
velocity: 0.041,
|
velocity: 0.041,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
source: { baseMidi: rootMidi, offsets: [intervals[3], intervals[2]] },
|
source: { baseMidi: rootMidi, offsets: [intervals[3], intervals[2]] },
|
||||||
register: PAD_REGISTERS[2],
|
register: this.generation.padRegisters[2],
|
||||||
velocity: 0.033,
|
velocity: 0.033,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
@ -504,7 +431,7 @@ export class GenerativePianoEngine {
|
||||||
expression: number,
|
expression: number,
|
||||||
selectedColorIndex: GardenAudioColorIndex
|
selectedColorIndex: GardenAudioColorIndex
|
||||||
): void {
|
): void {
|
||||||
const pool = COLOR_POOLS[selectedColorIndex];
|
const pool = this.generation.colorPools[selectedColorIndex];
|
||||||
const chord = this.getChord(profile, barIndex);
|
const chord = this.getChord(profile, barIndex);
|
||||||
const chordIntervals = getChordIntervals(chord, false);
|
const chordIntervals = getChordIntervals(chord, false);
|
||||||
const rootMidi = profile.rootMidi + chord.rootOffset;
|
const rootMidi = profile.rootMidi + chord.rootOffset;
|
||||||
|
|
@ -539,7 +466,7 @@ export class GenerativePianoEngine {
|
||||||
expression: number,
|
expression: number,
|
||||||
selectedColorIndex: GardenAudioColorIndex
|
selectedColorIndex: GardenAudioColorIndex
|
||||||
): void {
|
): void {
|
||||||
const pool = COLOR_POOLS[selectedColorIndex];
|
const pool = this.generation.colorPools[selectedColorIndex];
|
||||||
const degrees = this.rotate(pool.scaleDegrees, barIndex + selectedColorIndex);
|
const degrees = this.rotate(pool.scaleDegrees, barIndex + selectedColorIndex);
|
||||||
const midi = this.chooseMidi(
|
const midi = this.chooseMidi(
|
||||||
{
|
{
|
||||||
|
|
@ -573,7 +500,7 @@ export class GenerativePianoEngine {
|
||||||
noteCount: number
|
noteCount: number
|
||||||
): void {
|
): void {
|
||||||
const profile = getVibeProfile(this.config, vibe);
|
const profile = getVibeProfile(this.config, vibe);
|
||||||
const pool = COLOR_POOLS[selectedColorIndex];
|
const pool = this.generation.colorPools[selectedColorIndex];
|
||||||
const degrees = this.rotate(pool.scaleDegrees, Math.round(strength * 3));
|
const degrees = this.rotate(pool.scaleDegrees, Math.round(strength * 3));
|
||||||
|
|
||||||
for (let index = 0; index < noteCount; index += 1) {
|
for (let index = 0; index < noteCount; index += 1) {
|
||||||
|
|
@ -598,8 +525,8 @@ export class GenerativePianoEngine {
|
||||||
this.config.colorVoices[selectedColorIndex].velocityMultiplier,
|
this.config.colorVoices[selectedColorIndex].velocityMultiplier,
|
||||||
startTime:
|
startTime:
|
||||||
now +
|
now +
|
||||||
this.engineConfig.startDelaySeconds +
|
this.config.startDelaySeconds +
|
||||||
index * GESTURE_ACCENT_SPACING_SECONDS,
|
index * this.generation.gestureAccentSpacingSeconds,
|
||||||
durationSeconds: 0.48 + strength * 0.22,
|
durationSeconds: 0.48 + strength * 0.22,
|
||||||
pan: this.getColorPan(selectedColorIndex),
|
pan: this.getColorPan(selectedColorIndex),
|
||||||
delaySend: 0.012,
|
delaySend: 0.012,
|
||||||
|
|
@ -626,7 +553,7 @@ export class GenerativePianoEngine {
|
||||||
brightnessBias: number;
|
brightnessBias: number;
|
||||||
}): void {
|
}): void {
|
||||||
const profile = getVibeProfile(this.config, vibe);
|
const profile = getVibeProfile(this.config, vibe);
|
||||||
const pool = COLOR_POOLS[selectedColorIndex];
|
const pool = this.generation.colorPools[selectedColorIndex];
|
||||||
const register = this.getBiasedRegister(pool, registerBias, 0);
|
const register = this.getBiasedRegister(pool, registerBias, 0);
|
||||||
const chord = this.getChord(profile, this.getGlobalBarIndex(now));
|
const chord = this.getChord(profile, this.getGlobalBarIndex(now));
|
||||||
const chordIntervals = getChordIntervals(chord, false);
|
const chordIntervals = getChordIntervals(chord, false);
|
||||||
|
|
@ -688,9 +615,9 @@ export class GenerativePianoEngine {
|
||||||
maniaAmount: number;
|
maniaAmount: number;
|
||||||
}): void {
|
}): void {
|
||||||
const lifetimeSeconds =
|
const lifetimeSeconds =
|
||||||
BRUSH_LAYER_BASE_SECONDS +
|
this.generation.brushLayerBaseSeconds +
|
||||||
strength * BRUSH_LAYER_ENERGY_SECONDS +
|
strength * this.generation.brushLayerEnergySeconds +
|
||||||
mirrorAmount * BRUSH_LAYER_MIRROR_SECONDS;
|
mirrorAmount * this.generation.brushLayerMirrorSeconds;
|
||||||
|
|
||||||
this.brushPhraseLayers.push({
|
this.brushPhraseLayers.push({
|
||||||
vibe,
|
vibe,
|
||||||
|
|
@ -713,8 +640,10 @@ export class GenerativePianoEngine {
|
||||||
maniaAmount,
|
maniaAmount,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (this.brushPhraseLayers.length > MAX_BRUSH_PHRASE_LAYERS) {
|
if (this.brushPhraseLayers.length > this.generation.maxBrushPhraseLayers) {
|
||||||
this.brushPhraseLayers = this.brushPhraseLayers.slice(-MAX_BRUSH_PHRASE_LAYERS);
|
this.brushPhraseLayers = this.brushPhraseLayers.slice(
|
||||||
|
-this.generation.maxBrushPhraseLayers
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -762,8 +691,8 @@ export class GenerativePianoEngine {
|
||||||
layer.motifOffsets.push(
|
layer.motifOffsets.push(
|
||||||
this.getMotifOffset({ registerBias, contour, pressureDelta, strength })
|
this.getMotifOffset({ registerBias, contour, pressureDelta, strength })
|
||||||
);
|
);
|
||||||
if (layer.motifOffsets.length > BRUSH_MOTIF_MAX_STEPS) {
|
if (layer.motifOffsets.length > this.generation.brushMotifMaxSteps) {
|
||||||
layer.motifOffsets = layer.motifOffsets.slice(-BRUSH_MOTIF_MAX_STEPS);
|
layer.motifOffsets = layer.motifOffsets.slice(-this.generation.brushMotifMaxSteps);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -780,8 +709,8 @@ export class GenerativePianoEngine {
|
||||||
activity: number;
|
activity: number;
|
||||||
selectedColorIndex: GardenAudioColorIndex;
|
selectedColorIndex: GardenAudioColorIndex;
|
||||||
}): void {
|
}): void {
|
||||||
const earliestStart = now + this.engineConfig.piano.scheduleAheadSeconds;
|
const earliestStart = now + this.config.piano.scheduleAheadSeconds;
|
||||||
this.nextBrushStreamAt ??= now + this.engineConfig.startDelaySeconds;
|
this.nextBrushStreamAt ??= now + this.config.startDelaySeconds;
|
||||||
|
|
||||||
this.brushPhraseLayers = this.brushPhraseLayers.filter(
|
this.brushPhraseLayers = this.brushPhraseLayers.filter(
|
||||||
(layer) => layer.expiresAt > earliestStart
|
(layer) => layer.expiresAt > earliestStart
|
||||||
|
|
@ -795,7 +724,7 @@ export class GenerativePianoEngine {
|
||||||
|
|
||||||
while (this.nextBrushStreamAt <= lookaheadEnd) {
|
while (this.nextBrushStreamAt <= lookaheadEnd) {
|
||||||
const frame = this.getBrushStreamFrame(this.nextBrushStreamAt, activity);
|
const frame = this.getBrushStreamFrame(this.nextBrushStreamAt, activity);
|
||||||
if (frame.intensity >= BRUSH_LAYER_MIN_INTENSITY) {
|
if (frame.intensity >= this.generation.brushLayerMinIntensity) {
|
||||||
this.playBrushStreamNote({
|
this.playBrushStreamNote({
|
||||||
vibe,
|
vibe,
|
||||||
startTime: this.nextBrushStreamAt,
|
startTime: this.nextBrushStreamAt,
|
||||||
|
|
@ -823,7 +752,7 @@ export class GenerativePianoEngine {
|
||||||
layer: BrushPhraseLayer | null;
|
layer: BrushPhraseLayer | null;
|
||||||
}): void {
|
}): void {
|
||||||
const profile = getVibeProfile(this.config, vibe);
|
const profile = getVibeProfile(this.config, vibe);
|
||||||
const pool = COLOR_POOLS[selectedColorIndex];
|
const pool = this.generation.colorPools[selectedColorIndex];
|
||||||
const maniaAmount = layer?.maniaAmount ?? clamp01((intensity - 0.82) / 0.18);
|
const maniaAmount = layer?.maniaAmount ?? clamp01((intensity - 0.82) / 0.18);
|
||||||
const register = this.getBiasedRegister(
|
const register = this.getBiasedRegister(
|
||||||
pool,
|
pool,
|
||||||
|
|
@ -860,7 +789,10 @@ export class GenerativePianoEngine {
|
||||||
0.62
|
0.62
|
||||||
);
|
);
|
||||||
const delaySend = clamp(
|
const delaySend = clamp(
|
||||||
0.012 + intensity * 0.011 + (layer?.mirrorAmount ?? 0) * 0.004 - maniaAmount * 0.006,
|
0.012 +
|
||||||
|
intensity * 0.011 +
|
||||||
|
(layer?.mirrorAmount ?? 0) * 0.004 -
|
||||||
|
maniaAmount * 0.006,
|
||||||
0.006,
|
0.006,
|
||||||
0.032
|
0.032
|
||||||
);
|
);
|
||||||
|
|
@ -888,7 +820,10 @@ export class GenerativePianoEngine {
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (maniaAmount >= 0.62 && (this.brushStreamNoteIndex % 2 === 1 || intensity >= 0.9)) {
|
if (
|
||||||
|
maniaAmount >= 0.62 &&
|
||||||
|
(this.brushStreamNoteIndex % 2 === 1 || intensity >= 0.9)
|
||||||
|
) {
|
||||||
const echoMidi = midi + 12 <= 88 ? midi + 12 : midi - 12;
|
const echoMidi = midi + 12 <= 88 ? midi + 12 : midi - 12;
|
||||||
this.playNote({
|
this.playNote({
|
||||||
midi: echoMidi,
|
midi: echoMidi,
|
||||||
|
|
@ -897,7 +832,7 @@ export class GenerativePianoEngine {
|
||||||
this.config.colorVoices[selectedColorIndex].velocityMultiplier,
|
this.config.colorVoices[selectedColorIndex].velocityMultiplier,
|
||||||
startTime:
|
startTime:
|
||||||
startTime +
|
startTime +
|
||||||
BRUSH_MOTIF_CANON_DELAY_SECONDS +
|
this.generation.brushMotifCanonDelaySeconds +
|
||||||
(layer?.mirrorAmount ?? 0) * 0.04,
|
(layer?.mirrorAmount ?? 0) * 0.04,
|
||||||
durationSeconds: Math.max(0.11, durationSeconds * 0.68),
|
durationSeconds: Math.max(0.11, durationSeconds * 0.68),
|
||||||
pan: clamp(-pan * 0.75, -1, 1),
|
pan: clamp(-pan * 0.75, -1, 1),
|
||||||
|
|
@ -948,12 +883,12 @@ export class GenerativePianoEngine {
|
||||||
private getBrushStreamIntervalSeconds(intensity: number): number {
|
private getBrushStreamIntervalSeconds(intensity: number): number {
|
||||||
const intervalBeats =
|
const intervalBeats =
|
||||||
intensity >= 0.85
|
intensity >= 0.85
|
||||||
? BRUSH_STREAM_MANIC_INTERVAL_BEATS
|
? this.generation.brushStreamManicIntervalBeats
|
||||||
: intensity >= 0.62
|
: intensity >= 0.62
|
||||||
? BRUSH_STREAM_INTENSE_INTERVAL_BEATS
|
? this.generation.brushStreamIntenseIntervalBeats
|
||||||
: intensity >= 0.34
|
: intensity >= 0.34
|
||||||
? BRUSH_STREAM_ACTIVE_INTERVAL_BEATS
|
? this.generation.brushStreamActiveIntervalBeats
|
||||||
: BRUSH_STREAM_IDLE_INTERVAL_BEATS;
|
: this.generation.brushStreamIdleIntervalBeats;
|
||||||
return this.getBeatDurationSeconds() * intervalBeats;
|
return this.getBeatDurationSeconds() * intervalBeats;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1037,7 +972,7 @@ export class GenerativePianoEngine {
|
||||||
selectedColorIndex,
|
selectedColorIndex,
|
||||||
}: {
|
}: {
|
||||||
layer: BrushPhraseLayer | null;
|
layer: BrushPhraseLayer | null;
|
||||||
pool: ColorPool;
|
pool: GardenAudioColorPool;
|
||||||
selectedColorIndex: GardenAudioColorIndex;
|
selectedColorIndex: GardenAudioColorIndex;
|
||||||
}): Array<number> {
|
}): Array<number> {
|
||||||
const colorOffset = this.config.colorVoices[selectedColorIndex].scaleDegreeOffset;
|
const colorOffset = this.config.colorVoices[selectedColorIndex].scaleDegreeOffset;
|
||||||
|
|
@ -1063,10 +998,10 @@ export class GenerativePianoEngine {
|
||||||
}
|
}
|
||||||
|
|
||||||
private getBiasedRegister(
|
private getBiasedRegister(
|
||||||
register: Register,
|
register: GardenAudioRegister,
|
||||||
registerBias: number,
|
registerBias: number,
|
||||||
maniaAmount: number
|
maniaAmount: number
|
||||||
): Register {
|
): GardenAudioRegister {
|
||||||
const shift = Math.round(registerBias * 7 + maniaAmount * 4);
|
const shift = Math.round(registerBias * 7 + maniaAmount * 4);
|
||||||
const midiMin = clamp(register.midiMin + shift, 36, 86);
|
const midiMin = clamp(register.midiMin + shift, 36, 86);
|
||||||
const midiMax = clamp(register.midiMax + shift, midiMin + 4, 91);
|
const midiMax = clamp(register.midiMax + shift, midiMin + 4, 91);
|
||||||
|
|
@ -1101,7 +1036,7 @@ export class GenerativePianoEngine {
|
||||||
|
|
||||||
private chooseMidi(
|
private chooseMidi(
|
||||||
pitchSource: PitchSource,
|
pitchSource: PitchSource,
|
||||||
register: Register,
|
register: GardenAudioRegister,
|
||||||
previousMidi: number | null = null,
|
previousMidi: number | null = null,
|
||||||
avoidRepeat = false
|
avoidRepeat = false
|
||||||
): number {
|
): number {
|
||||||
|
|
@ -1122,7 +1057,7 @@ export class GenerativePianoEngine {
|
||||||
|
|
||||||
private getCandidates(
|
private getCandidates(
|
||||||
pitchSource: PitchSource,
|
pitchSource: PitchSource,
|
||||||
register: Register
|
register: GardenAudioRegister
|
||||||
): Array<PitchCandidate> {
|
): Array<PitchCandidate> {
|
||||||
const candidates: Array<PitchCandidate> = [];
|
const candidates: Array<PitchCandidate> = [];
|
||||||
|
|
||||||
|
|
@ -1140,15 +1075,18 @@ export class GenerativePianoEngine {
|
||||||
|
|
||||||
private scoreCandidate(
|
private scoreCandidate(
|
||||||
candidate: PitchCandidate,
|
candidate: PitchCandidate,
|
||||||
register: Register,
|
register: GardenAudioRegister,
|
||||||
previousMidi: number,
|
previousMidi: number,
|
||||||
avoidRepeat: boolean
|
avoidRepeat: boolean
|
||||||
): number {
|
): number {
|
||||||
return (
|
return (
|
||||||
Math.abs(candidate.midi - previousMidi) +
|
Math.abs(candidate.midi - previousMidi) +
|
||||||
Math.abs(candidate.midi - register.preferredMidi) * NOTE_SCORE_REGISTER_WEIGHT +
|
Math.abs(candidate.midi - register.preferredMidi) *
|
||||||
candidate.preference * NOTE_SCORE_PREFERENCE_WEIGHT +
|
this.generation.noteScoreRegisterWeight +
|
||||||
(avoidRepeat && candidate.midi === previousMidi ? NOTE_SCORE_REPEAT_PENALTY : 0)
|
candidate.preference * this.generation.noteScorePreferenceWeight +
|
||||||
|
(avoidRepeat && candidate.midi === previousMidi
|
||||||
|
? this.generation.noteScoreRepeatPenalty
|
||||||
|
: 0)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1157,15 +1095,17 @@ export class GenerativePianoEngine {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
return barIndex % SUPPORT_BAR_SPACING === SUPPORT_BAR_OFFSET;
|
return (
|
||||||
|
barIndex % this.generation.supportBarSpacing === this.generation.supportBarOffset
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private shouldPlayTexture(expression: number, barIndex: number): boolean {
|
private shouldPlayTexture(expression: number, barIndex: number): boolean {
|
||||||
const spacing =
|
const spacing =
|
||||||
expression < 0.35
|
expression < 0.35
|
||||||
? IDLE_TEXTURE_BAR_SPACING
|
? this.generation.idleTextureBarSpacing
|
||||||
: expression < 0.7
|
: expression < 0.7
|
||||||
? MEDIUM_TEXTURE_BAR_SPACING
|
? this.generation.mediumTextureBarSpacing
|
||||||
: 1;
|
: 1;
|
||||||
|
|
||||||
return barIndex % spacing === (spacing === 1 ? 0 : 1);
|
return barIndex % spacing === (spacing === 1 ? 0 : 1);
|
||||||
|
|
@ -1188,7 +1128,7 @@ export class GenerativePianoEngine {
|
||||||
|
|
||||||
private getChord(profile: GardenAudioVibeProfile, barIndex: number): GardenAudioChord {
|
private getChord(profile: GardenAudioVibeProfile, barIndex: number): GardenAudioChord {
|
||||||
const progressionIndex =
|
const progressionIndex =
|
||||||
Math.floor(barIndex / CHORD_BARS) % profile.progression.length;
|
Math.floor(barIndex / this.generation.chordBars) % profile.progression.length;
|
||||||
return profile.progression[progressionIndex];
|
return profile.progression[progressionIndex];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1199,7 +1139,7 @@ export class GenerativePianoEngine {
|
||||||
}
|
}
|
||||||
|
|
||||||
private getColorPan(selectedColorIndex: GardenAudioColorIndex): number {
|
private getColorPan(selectedColorIndex: GardenAudioColorIndex): number {
|
||||||
const pool = COLOR_POOLS[selectedColorIndex];
|
const pool = this.generation.colorPools[selectedColorIndex];
|
||||||
const colorVoice = this.config.colorVoices[selectedColorIndex];
|
const colorVoice = this.config.colorVoices[selectedColorIndex];
|
||||||
return clamp(pool.pan + colorVoice.panOffset * 0.35, -1, 1);
|
return clamp(pool.pan + colorVoice.panOffset * 0.35, -1, 1);
|
||||||
}
|
}
|
||||||
|
|
@ -1213,8 +1153,8 @@ export class GenerativePianoEngine {
|
||||||
return clamp(
|
return clamp(
|
||||||
this.config.piano.lowpassHz * profile.brightness * (0.58 + expression * 0.32) +
|
this.config.piano.lowpassHz * profile.brightness * (0.58 + expression * 0.32) +
|
||||||
midiLift,
|
midiLift,
|
||||||
this.engineConfig.piano.lowpassMinHz,
|
this.config.piano.lowpassMinHz,
|
||||||
this.engineConfig.piano.lowpassMaxHz
|
this.config.piano.lowpassMaxHz
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1223,7 +1163,7 @@ export class GenerativePianoEngine {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const earliestStart = now + this.engineConfig.piano.scheduleAheadSeconds;
|
const earliestStart = now + this.config.piano.scheduleAheadSeconds;
|
||||||
if (this.nextBeatAt >= earliestStart) {
|
if (this.nextBeatAt >= earliestStart) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
import type { GardenAudioEngineConfig } from '../config';
|
import type { GardenAudioConfig } from './garden-audio-config';
|
||||||
import { GardenAudioGraph } from './garden-audio-graph';
|
import { GardenAudioGraph } from './garden-audio-graph';
|
||||||
import { NoiseBurst } from './garden-audio-types';
|
import { NoiseBurst } from './garden-audio-types';
|
||||||
|
|
||||||
export class NoiseBurstPlayer {
|
export class NoiseBurstPlayer {
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly engineConfig: GardenAudioEngineConfig,
|
private readonly config: GardenAudioConfig,
|
||||||
private readonly graph: GardenAudioGraph
|
private readonly graph: GardenAudioGraph
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
|
@ -15,7 +15,7 @@ export class NoiseBurstPlayer {
|
||||||
}
|
}
|
||||||
|
|
||||||
const scheduledStart = Math.max(
|
const scheduledStart = Math.max(
|
||||||
context.currentTime + this.engineConfig.noiseBurst.scheduleAheadSeconds,
|
context.currentTime + this.config.noiseBurst.scheduleAheadSeconds,
|
||||||
startTime
|
startTime
|
||||||
);
|
);
|
||||||
const source = context.createBufferSource();
|
const source = context.createBufferSource();
|
||||||
|
|
@ -27,16 +27,13 @@ export class NoiseBurstPlayer {
|
||||||
source.buffer = noiseBuffer;
|
source.buffer = noiseBuffer;
|
||||||
filter.type = 'bandpass';
|
filter.type = 'bandpass';
|
||||||
filter.frequency.setValueAtTime(filterHz, scheduledStart);
|
filter.frequency.setValueAtTime(filterHz, scheduledStart);
|
||||||
filter.Q.value = this.engineConfig.noiseBurst.filterQ;
|
filter.Q.value = this.config.noiseBurst.filterQ;
|
||||||
envelope.gain.setValueAtTime(this.engineConfig.noiseBurst.silentGain, scheduledStart);
|
envelope.gain.setValueAtTime(this.config.noiseBurst.silentGain, scheduledStart);
|
||||||
envelope.gain.exponentialRampToValueAtTime(
|
envelope.gain.exponentialRampToValueAtTime(
|
||||||
Math.max(this.engineConfig.noiseBurst.silentGain, gain),
|
Math.max(this.config.noiseBurst.silentGain, gain),
|
||||||
scheduledStart + this.engineConfig.noiseBurst.attackSeconds
|
scheduledStart + this.config.noiseBurst.attackSeconds
|
||||||
);
|
|
||||||
envelope.gain.exponentialRampToValueAtTime(
|
|
||||||
this.engineConfig.noiseBurst.silentGain,
|
|
||||||
stopAt
|
|
||||||
);
|
);
|
||||||
|
envelope.gain.exponentialRampToValueAtTime(this.config.noiseBurst.silentGain, stopAt);
|
||||||
panner.pan.setValueAtTime(pan, scheduledStart);
|
panner.pan.setValueAtTime(pan, scheduledStart);
|
||||||
|
|
||||||
source.connect(filter);
|
source.connect(filter);
|
||||||
|
|
@ -45,7 +42,7 @@ export class NoiseBurstPlayer {
|
||||||
panner.connect(eventBus);
|
panner.connect(eventBus);
|
||||||
source.start(
|
source.start(
|
||||||
scheduledStart,
|
scheduledStart,
|
||||||
Math.random() * this.engineConfig.noiseBurst.offsetRandomSeconds
|
Math.random() * this.config.noiseBurst.offsetRandomSeconds
|
||||||
);
|
);
|
||||||
source.stop(stopAt);
|
source.stop(stopAt);
|
||||||
source.addEventListener(
|
source.addEventListener(
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
import { appConfig } from '../config';
|
|
||||||
import { gardenAudioConfig } from './garden-audio-config';
|
import { gardenAudioConfig } from './garden-audio-config';
|
||||||
import type { GardenAudioGraph } from './garden-audio-graph';
|
import type { GardenAudioGraph } from './garden-audio-graph';
|
||||||
import { PianoSampler } from './piano-sampler';
|
import { PianoSampler } from './piano-sampler';
|
||||||
|
|
@ -70,7 +69,7 @@ const makeSampler = (context: AudioContext): PianoSampler => {
|
||||||
eventBus,
|
eventBus,
|
||||||
} as unknown as GardenAudioGraph;
|
} as unknown as GardenAudioGraph;
|
||||||
|
|
||||||
return new PianoSampler(gardenAudioConfig, appConfig.audioEngine, graph);
|
return new PianoSampler(gardenAudioConfig, graph);
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('PianoSampler', () => {
|
describe('PianoSampler', () => {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
import type { GardenAudioEngineConfig } from '../config';
|
|
||||||
import { clamp, clamp01 } from '../utils/clamp';
|
import { clamp, clamp01 } from '../utils/clamp';
|
||||||
import { GardenAudioConfig } from './garden-audio-config';
|
import { GardenAudioConfig } from './garden-audio-config';
|
||||||
import { GardenAudioGraph } from './garden-audio-graph';
|
import { GardenAudioGraph } from './garden-audio-graph';
|
||||||
|
|
@ -12,7 +11,6 @@ export class PianoSampler {
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly config: GardenAudioConfig,
|
private readonly config: GardenAudioConfig,
|
||||||
private readonly engineConfig: GardenAudioEngineConfig,
|
|
||||||
private readonly graph: GardenAudioGraph
|
private readonly graph: GardenAudioGraph
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
|
@ -54,21 +52,20 @@ export class PianoSampler {
|
||||||
}
|
}
|
||||||
|
|
||||||
const scheduledStart = Math.max(
|
const scheduledStart = Math.max(
|
||||||
context.currentTime + this.engineConfig.piano.scheduleAheadSeconds,
|
context.currentTime + this.config.piano.scheduleAheadSeconds,
|
||||||
startTime
|
startTime
|
||||||
);
|
);
|
||||||
const noteVelocity = clamp01(velocity);
|
const noteVelocity = clamp01(velocity);
|
||||||
const noteGainValue = Math.max(
|
const noteGainValue = Math.max(
|
||||||
this.engineConfig.piano.minGain,
|
this.config.piano.minGain,
|
||||||
this.config.piano.gain * noteVelocity
|
this.config.piano.gain * noteVelocity
|
||||||
);
|
);
|
||||||
const sustainSeconds =
|
const sustainSeconds =
|
||||||
this.config.piano.sustainSeconds *
|
this.config.piano.sustainSeconds *
|
||||||
(this.engineConfig.piano.sustainBase +
|
(this.config.piano.sustainBase +
|
||||||
noteVelocity * this.engineConfig.piano.sustainVelocityRange);
|
noteVelocity * this.config.piano.sustainVelocityRange);
|
||||||
const sustainAt =
|
const sustainAt =
|
||||||
scheduledStart +
|
scheduledStart + Math.max(this.config.piano.minDurationSeconds, durationSeconds);
|
||||||
Math.max(this.engineConfig.piano.minDurationSeconds, durationSeconds);
|
|
||||||
const releaseAt = sustainAt + sustainSeconds;
|
const releaseAt = sustainAt + sustainSeconds;
|
||||||
const releaseSeconds = this.config.piano.releaseSeconds;
|
const releaseSeconds = this.config.piano.releaseSeconds;
|
||||||
const stopAt = releaseAt + releaseSeconds;
|
const stopAt = releaseAt + releaseSeconds;
|
||||||
|
|
@ -88,36 +85,29 @@ export class PianoSampler {
|
||||||
|
|
||||||
source.buffer = sample.buffer;
|
source.buffer = sample.buffer;
|
||||||
source.playbackRate.setValueAtTime(
|
source.playbackRate.setValueAtTime(
|
||||||
Math.pow(2, (midi - sample.midi) / this.engineConfig.piano.pitchSemitonesPerOctave),
|
Math.pow(2, (midi - sample.midi) / this.config.piano.pitchSemitonesPerOctave),
|
||||||
scheduledStart
|
scheduledStart
|
||||||
);
|
);
|
||||||
filter.type = 'lowpass';
|
filter.type = 'lowpass';
|
||||||
filter.frequency.setValueAtTime(
|
filter.frequency.setValueAtTime(
|
||||||
clamp(
|
clamp(lowpassHz, this.config.piano.lowpassMinHz, this.config.piano.lowpassMaxHz),
|
||||||
lowpassHz,
|
|
||||||
this.engineConfig.piano.lowpassMinHz,
|
|
||||||
this.engineConfig.piano.lowpassMaxHz
|
|
||||||
),
|
|
||||||
scheduledStart
|
scheduledStart
|
||||||
);
|
);
|
||||||
filter.Q.value = this.engineConfig.piano.filterQ;
|
filter.Q.value = this.config.piano.filterQ;
|
||||||
gain.gain.setValueAtTime(this.engineConfig.piano.minGain, scheduledStart);
|
gain.gain.setValueAtTime(this.config.piano.minGain, scheduledStart);
|
||||||
gain.gain.exponentialRampToValueAtTime(
|
gain.gain.exponentialRampToValueAtTime(
|
||||||
noteGainValue,
|
noteGainValue,
|
||||||
scheduledStart + this.engineConfig.piano.gainAttackSeconds
|
scheduledStart + this.config.piano.gainAttackSeconds
|
||||||
);
|
);
|
||||||
gain.gain.setTargetAtTime(
|
gain.gain.setTargetAtTime(
|
||||||
Math.max(
|
Math.max(this.config.piano.minGain, noteGainValue * this.config.piano.sustainLevel),
|
||||||
this.engineConfig.piano.minGain,
|
|
||||||
noteGainValue * this.config.piano.sustainLevel
|
|
||||||
),
|
|
||||||
sustainAt,
|
sustainAt,
|
||||||
Math.max(
|
Math.max(
|
||||||
this.engineConfig.piano.minFadeSeconds,
|
this.config.piano.minFadeSeconds,
|
||||||
sustainSeconds * this.engineConfig.piano.sustainBase
|
sustainSeconds * this.config.piano.sustainBase
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
gain.gain.setTargetAtTime(this.engineConfig.piano.minGain, releaseAt, releaseSeconds);
|
gain.gain.setTargetAtTime(this.config.piano.minGain, releaseAt, releaseSeconds);
|
||||||
panner.pan.setValueAtTime(clamp(pan, -1, 1), scheduledStart);
|
panner.pan.setValueAtTime(clamp(pan, -1, 1), scheduledStart);
|
||||||
|
|
||||||
source.connect(filter);
|
source.connect(filter);
|
||||||
|
|
@ -133,7 +123,7 @@ export class PianoSampler {
|
||||||
}
|
}
|
||||||
|
|
||||||
source.start(scheduledStart);
|
source.start(scheduledStart);
|
||||||
source.stop(stopAt + this.engineConfig.piano.tailStopExtraSeconds);
|
source.stop(stopAt + this.config.piano.tailStopExtraSeconds);
|
||||||
this.activeVoices.push({ gain, source, startAt: scheduledStart, stopAt });
|
this.activeVoices.push({ gain, source, startAt: scheduledStart, stopAt });
|
||||||
|
|
||||||
source.addEventListener(
|
source.addEventListener(
|
||||||
|
|
@ -186,13 +176,13 @@ export class PianoSampler {
|
||||||
}
|
}
|
||||||
|
|
||||||
private stopVoice(voice: ActivePianoVoice, now: number): void {
|
private stopVoice(voice: ActivePianoVoice, now: number): void {
|
||||||
const stopAt = now + this.engineConfig.piano.voiceStealStopSeconds;
|
const stopAt = now + this.config.piano.voiceStealStopSeconds;
|
||||||
|
|
||||||
voice.gain.gain.cancelScheduledValues(now);
|
voice.gain.gain.cancelScheduledValues(now);
|
||||||
voice.gain.gain.setTargetAtTime(
|
voice.gain.gain.setTargetAtTime(
|
||||||
this.engineConfig.piano.minGain,
|
this.config.piano.minGain,
|
||||||
now,
|
now,
|
||||||
this.engineConfig.piano.voiceStealFadeSeconds
|
this.config.piano.voiceStealFadeSeconds
|
||||||
);
|
);
|
||||||
voice.stopAt = stopAt;
|
voice.stopAt = stopAt;
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
216
src/config.ts
216
src/config.ts
|
|
@ -1,10 +1,10 @@
|
||||||
|
import { ADAPTIVE_AGENT_CAP_MAX } from './config/agent-budget';
|
||||||
import { runtimeSettings } from './config/runtime-settings';
|
import { runtimeSettings } from './config/runtime-settings';
|
||||||
import type { GardenAppConfig } from './config/types';
|
import type { GardenAppConfig } from './config/types';
|
||||||
import { audioVibes, defaultVibeId, vibePresets } from './config/vibe-presets';
|
import { audioVibes, defaultVibeId, vibePresets } from './config/vibe-presets';
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
GardenAppConfig,
|
GardenAppConfig,
|
||||||
GardenAudioEngineConfig,
|
|
||||||
GardenRuntimeSettings,
|
GardenRuntimeSettings,
|
||||||
NumberControlConfig,
|
NumberControlConfig,
|
||||||
VibePreset,
|
VibePreset,
|
||||||
|
|
@ -20,6 +20,13 @@ export const appConfig = {
|
||||||
timeSeconds: 0.46,
|
timeSeconds: 0.46,
|
||||||
feedback: 0.12,
|
feedback: 0.12,
|
||||||
wetGain: 0.044,
|
wetGain: 0.044,
|
||||||
|
erasingActivity: 0.12,
|
||||||
|
activityFeedbackWeight: 0.08,
|
||||||
|
feedbackMax: 0.32,
|
||||||
|
feedbackMin: 0.04,
|
||||||
|
outputActivityWeight: 0.5,
|
||||||
|
outputBase: 0.65,
|
||||||
|
timeRampSeconds: 0.12,
|
||||||
},
|
},
|
||||||
piano: {
|
piano: {
|
||||||
maxVoices: 24,
|
maxVoices: 24,
|
||||||
|
|
@ -28,13 +35,26 @@ export const appConfig = {
|
||||||
sustainLevel: 0.32,
|
sustainLevel: 0.32,
|
||||||
releaseSeconds: 0.24,
|
releaseSeconds: 0.24,
|
||||||
lowpassHz: 7600,
|
lowpassHz: 7600,
|
||||||
|
filterQ: 0.7,
|
||||||
|
gainAttackSeconds: 0.006,
|
||||||
|
lowpassMaxHz: 12000,
|
||||||
|
lowpassMinHz: 1400,
|
||||||
|
minDurationSeconds: 0.08,
|
||||||
|
minFadeSeconds: 0.08,
|
||||||
|
minGain: 0.0001,
|
||||||
|
pitchSemitonesPerOctave: 12,
|
||||||
|
scheduleAheadSeconds: 0.002,
|
||||||
|
sustainBase: 0.45,
|
||||||
|
sustainVelocityRange: 0.55,
|
||||||
|
tailStopExtraSeconds: 0.05,
|
||||||
|
voiceStealFadeSeconds: 0.025,
|
||||||
|
voiceStealStopSeconds: 0.05,
|
||||||
},
|
},
|
||||||
rhythm: {
|
rhythm: {
|
||||||
bpm: 74,
|
bpm: 74,
|
||||||
stepsPerBeat: 4,
|
stepsPerBeat: 4,
|
||||||
stepsPerBar: 16,
|
stepsPerBar: 16,
|
||||||
lookaheadSeconds: 0.3,
|
lookaheadSeconds: 0.3,
|
||||||
speedForFullEnergyPixelsPerSecond: 1800,
|
|
||||||
sparseActivity: 0.055,
|
sparseActivity: 0.055,
|
||||||
},
|
},
|
||||||
eraser: {
|
eraser: {
|
||||||
|
|
@ -42,6 +62,117 @@ export const appConfig = {
|
||||||
noiseGain: 0.028,
|
noiseGain: 0.028,
|
||||||
filterMinHz: 650,
|
filterMinHz: 650,
|
||||||
filterMaxHz: 3600,
|
filterMaxHz: 3600,
|
||||||
|
durationSeconds: 0.08,
|
||||||
|
},
|
||||||
|
energy: {
|
||||||
|
attackSeconds: 0.08,
|
||||||
|
decaySeconds: 0.9,
|
||||||
|
immediateActivityScale: 0.85,
|
||||||
|
releaseSeconds: 1.15,
|
||||||
|
strokeDecaySeconds: 0.32,
|
||||||
|
},
|
||||||
|
graph: {
|
||||||
|
closeGain: 0.0001,
|
||||||
|
closeRampSeconds: 0.015,
|
||||||
|
delayMaxSeconds: 2,
|
||||||
|
eventBusGain: 1,
|
||||||
|
noiseMax: 1,
|
||||||
|
noiseMin: -1,
|
||||||
|
unlockTickFrequencyHz: 440,
|
||||||
|
unlockTickSeconds: 0.035,
|
||||||
|
},
|
||||||
|
input: {
|
||||||
|
activeActivityThreshold: 0.38,
|
||||||
|
distanceWindowForFullActivityPixels: 140,
|
||||||
|
distanceWindowSeconds: 0.5,
|
||||||
|
fallbackFrameSeconds: 1 / 60,
|
||||||
|
manicActivityThreshold: 0.82,
|
||||||
|
manicModeThreshold: 0.72,
|
||||||
|
},
|
||||||
|
muteGain: 0.0001,
|
||||||
|
muteRampSeconds: 0.02,
|
||||||
|
noiseBurst: {
|
||||||
|
attackSeconds: 0.004,
|
||||||
|
filterQ: 1.4,
|
||||||
|
offsetRandomSeconds: 0.4,
|
||||||
|
scheduleAheadSeconds: 0.002,
|
||||||
|
silentGain: 0.0001,
|
||||||
|
},
|
||||||
|
startDelaySeconds: 0.02,
|
||||||
|
vibeChangeStingerMinIntervalSeconds: 0.45,
|
||||||
|
generativePiano: {
|
||||||
|
colorPools: [
|
||||||
|
{
|
||||||
|
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: 81,
|
||||||
|
preferredMidi: 72,
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
chordBars: 4,
|
||||||
|
supportBarSpacing: 2,
|
||||||
|
supportBarOffset: 1,
|
||||||
|
idleTextureBarSpacing: 2,
|
||||||
|
mediumTextureBarSpacing: 1,
|
||||||
|
textureBeat: 2,
|
||||||
|
highActivityExtraBeat: 3,
|
||||||
|
highActivityExtraThreshold: 0.45,
|
||||||
|
noteScorePreferenceWeight: 1.8,
|
||||||
|
noteScoreRegisterWeight: 0.28,
|
||||||
|
noteScoreRepeatPenalty: 3.2,
|
||||||
|
gestureAccentSpacingSeconds: 0.26,
|
||||||
|
gestureAccentMinIntervalSeconds: 2.5,
|
||||||
|
strokeAccentMinIntervalSeconds: 3.2,
|
||||||
|
strokeAccentThreshold: 0.58,
|
||||||
|
stingerSpacingSeconds: 0.08,
|
||||||
|
stingerDurationSeconds: 1.1,
|
||||||
|
maxBrushPhraseLayers: 5,
|
||||||
|
brushLayerBaseSeconds: 5.5,
|
||||||
|
brushLayerEnergySeconds: 2.5,
|
||||||
|
brushLayerMirrorSeconds: 3,
|
||||||
|
brushLayerMinIntensity: 0.08,
|
||||||
|
brushStreamIdleIntervalBeats: 2,
|
||||||
|
brushStreamActiveIntervalBeats: 1,
|
||||||
|
brushStreamIntenseIntervalBeats: 0.5,
|
||||||
|
brushStreamManicIntervalBeats: 0.25,
|
||||||
|
brushMotifMaxSteps: 8,
|
||||||
|
brushMotifCanonDelaySeconds: 0.055,
|
||||||
|
padDurationBarScale: 0.46,
|
||||||
},
|
},
|
||||||
colorVoices: [
|
colorVoices: [
|
||||||
{
|
{
|
||||||
|
|
@ -62,80 +193,6 @@ export const appConfig = {
|
||||||
],
|
],
|
||||||
vibes: audioVibes,
|
vibes: audioVibes,
|
||||||
},
|
},
|
||||||
audioEngine: {
|
|
||||||
energy: {
|
|
||||||
attackSeconds: 0.08,
|
|
||||||
decaySeconds: 0.9,
|
|
||||||
releaseSeconds: 1.15,
|
|
||||||
strokeDecaySeconds: 0.32,
|
|
||||||
},
|
|
||||||
eraser: {
|
|
||||||
canvasWidthRatioForFullSize: 0.18,
|
|
||||||
defaultSizePixels: 96,
|
|
||||||
durationSeconds: 0.08,
|
|
||||||
filterPressureWeight: 0.26,
|
|
||||||
filterSizeWeight: 0.16,
|
|
||||||
filterSpeedWeight: 0.58,
|
|
||||||
gainBase: 0.45,
|
|
||||||
gainPressureWeight: 0.24,
|
|
||||||
gainSizeWeight: 0.18,
|
|
||||||
gainSpeedWeight: 0.38,
|
|
||||||
},
|
|
||||||
delay: {
|
|
||||||
erasingActivity: 0.12,
|
|
||||||
},
|
|
||||||
graph: {
|
|
||||||
closeGain: 0.0001,
|
|
||||||
closeRampSeconds: 0.015,
|
|
||||||
delayActivityFeedbackWeight: 0.08,
|
|
||||||
delayFeedbackMax: 0.32,
|
|
||||||
delayFeedbackMin: 0.04,
|
|
||||||
delayOutputActivityWeight: 0.5,
|
|
||||||
delayOutputBase: 0.65,
|
|
||||||
delayTimeRampSeconds: 0.12,
|
|
||||||
eventBusGain: 1,
|
|
||||||
noiseMax: 1,
|
|
||||||
noiseMin: -1,
|
|
||||||
unlockBufferLength: 1,
|
|
||||||
unlockSampleRate: 22050,
|
|
||||||
},
|
|
||||||
input: {
|
|
||||||
distanceEnergyBase: 0.34,
|
|
||||||
distanceEnergyScale: 0.66,
|
|
||||||
distanceForFullEnergyPixels: 140,
|
|
||||||
fallbackFrameSeconds: 1 / 60,
|
|
||||||
strokeEnergyBase: 0.18,
|
|
||||||
strokeEnergyPressureWeight: 0.22,
|
|
||||||
strokeEnergySpeedWeight: 0.62,
|
|
||||||
},
|
|
||||||
muteGain: 0.0001,
|
|
||||||
muteRampSeconds: 0.02,
|
|
||||||
noiseBurst: {
|
|
||||||
attackSeconds: 0.004,
|
|
||||||
filterQ: 1.4,
|
|
||||||
offsetRandomSeconds: 0.4,
|
|
||||||
scheduleAheadSeconds: 0.002,
|
|
||||||
silentGain: 0.0001,
|
|
||||||
},
|
|
||||||
piano: {
|
|
||||||
filterQ: 0.7,
|
|
||||||
gainAttackSeconds: 0.006,
|
|
||||||
lowpassMaxHz: 12000,
|
|
||||||
lowpassMinHz: 1400,
|
|
||||||
minDurationSeconds: 0.08,
|
|
||||||
minFadeSeconds: 0.08,
|
|
||||||
minGain: 0.0001,
|
|
||||||
pitchSemitonesPerOctave: 12,
|
|
||||||
scheduleAheadSeconds: 0.002,
|
|
||||||
sustainBase: 0.45,
|
|
||||||
sustainVelocityRange: 0.55,
|
|
||||||
tailStopExtraSeconds: 0.05,
|
|
||||||
voiceStealFadeSeconds: 0.025,
|
|
||||||
voiceStealStopSeconds: 0.05,
|
|
||||||
},
|
|
||||||
startDelaySeconds: 0.02,
|
|
||||||
vibeChangeStingerMinIntervalSeconds: 0.45,
|
|
||||||
},
|
|
||||||
deltaTime: {
|
deltaTime: {
|
||||||
fpsExponentialDecayStrength: 0.01,
|
fpsExponentialDecayStrength: 0.01,
|
||||||
maxDeltaTimeSeconds: 1 / 30,
|
maxDeltaTimeSeconds: 1 / 30,
|
||||||
|
|
@ -173,13 +230,14 @@ export const appConfig = {
|
||||||
simulation: {
|
simulation: {
|
||||||
budget: {
|
budget: {
|
||||||
adaptiveCapDecreaseAgentsPerSecond: 50_000,
|
adaptiveCapDecreaseAgentsPerSecond: 50_000,
|
||||||
|
adaptiveCapMax: ADAPTIVE_AGENT_CAP_MAX,
|
||||||
adaptiveCapMin: 500_000,
|
adaptiveCapMin: 500_000,
|
||||||
fpsHeadroom: 0.95,
|
fpsHeadroom: 0.95,
|
||||||
fpsSmoothingNew: 0.06,
|
fpsSmoothingNew: 0.06,
|
||||||
fpsSmoothingRetain: 0.94,
|
fpsSmoothingRetain: 0.94,
|
||||||
},
|
},
|
||||||
brushEffectFramesPerSecond: 60,
|
brushEffectFramesPerSecond: 60,
|
||||||
globalAgentCap: 10_000_000,
|
globalAgentCap: ADAPTIVE_AGENT_CAP_MAX,
|
||||||
initialAgentCount: 180_000,
|
initialAgentCount: 180_000,
|
||||||
intro: {
|
intro: {
|
||||||
angleJitterRadians: Math.PI * 0.08,
|
angleJitterRadians: Math.PI * 0.08,
|
||||||
|
|
@ -227,10 +285,6 @@ export const appConfig = {
|
||||||
audioMutedKey: 'fleeting-garden:audio-muted',
|
audioMutedKey: 'fleeting-garden:audio-muted',
|
||||||
vibeKey: 'fleeting-garden:vibe',
|
vibeKey: 'fleeting-garden:vibe',
|
||||||
},
|
},
|
||||||
telemetry: {
|
|
||||||
enabled: false,
|
|
||||||
intervalMs: 1000,
|
|
||||||
},
|
|
||||||
toolbar: {
|
toolbar: {
|
||||||
eraser: {
|
eraser: {
|
||||||
controlScaleMax: 1.34,
|
controlScaleMax: 1.34,
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { ADAPTIVE_AGENT_CAP_MAX } from './agent-budget';
|
||||||
import {
|
import {
|
||||||
colorInteractionControl,
|
colorInteractionControl,
|
||||||
defaultColorInteractionSettings,
|
defaultColorInteractionSettings,
|
||||||
|
|
@ -34,8 +35,6 @@ export const runtimeSettings: GardenAppConfig['runtimeSettings'] = {
|
||||||
|
|
||||||
brushSizeVariation: 0.5,
|
brushSizeVariation: 0.5,
|
||||||
|
|
||||||
startColorHue: 200,
|
|
||||||
|
|
||||||
simulatedDelayMs: 0,
|
simulatedDelayMs: 0,
|
||||||
},
|
},
|
||||||
controls: {
|
controls: {
|
||||||
|
|
@ -43,7 +42,7 @@ export const runtimeSettings: GardenAppConfig['runtimeSettings'] = {
|
||||||
folder: 'Runtime',
|
folder: 'Runtime',
|
||||||
integer: true,
|
integer: true,
|
||||||
min: 500_000,
|
min: 500_000,
|
||||||
max: 10_000_000,
|
max: ADAPTIVE_AGENT_CAP_MAX,
|
||||||
step: 50_000,
|
step: 50_000,
|
||||||
},
|
},
|
||||||
agentCount: {
|
agentCount: {
|
||||||
|
|
@ -176,12 +175,6 @@ export const runtimeSettings: GardenAppConfig['runtimeSettings'] = {
|
||||||
max: 1,
|
max: 1,
|
||||||
step: 0.001,
|
step: 0.001,
|
||||||
},
|
},
|
||||||
startColorHue: {
|
|
||||||
folder: 'Render',
|
|
||||||
min: 0,
|
|
||||||
max: 360,
|
|
||||||
step: 1,
|
|
||||||
},
|
|
||||||
turnSpeed: {
|
turnSpeed: {
|
||||||
folder: 'Agent',
|
folder: 'Agent',
|
||||||
min: 1,
|
min: 1,
|
||||||
|
|
|
||||||
|
|
@ -78,80 +78,6 @@ type RuntimeSettingControlConfig = {
|
||||||
|
|
||||||
export interface GardenAppConfig {
|
export interface GardenAppConfig {
|
||||||
audio: GardenAudioConfig;
|
audio: GardenAudioConfig;
|
||||||
audioEngine: {
|
|
||||||
energy: {
|
|
||||||
attackSeconds: number;
|
|
||||||
decaySeconds: number;
|
|
||||||
releaseSeconds: number;
|
|
||||||
strokeDecaySeconds: number;
|
|
||||||
};
|
|
||||||
eraser: {
|
|
||||||
canvasWidthRatioForFullSize: number;
|
|
||||||
defaultSizePixels: number;
|
|
||||||
durationSeconds: number;
|
|
||||||
filterPressureWeight: number;
|
|
||||||
filterSizeWeight: number;
|
|
||||||
filterSpeedWeight: number;
|
|
||||||
gainBase: number;
|
|
||||||
gainPressureWeight: number;
|
|
||||||
gainSizeWeight: number;
|
|
||||||
gainSpeedWeight: number;
|
|
||||||
};
|
|
||||||
delay: {
|
|
||||||
erasingActivity: number;
|
|
||||||
};
|
|
||||||
graph: {
|
|
||||||
closeGain: number;
|
|
||||||
closeRampSeconds: number;
|
|
||||||
delayActivityFeedbackWeight: number;
|
|
||||||
delayFeedbackMax: number;
|
|
||||||
delayFeedbackMin: number;
|
|
||||||
delayOutputActivityWeight: number;
|
|
||||||
delayOutputBase: number;
|
|
||||||
delayTimeRampSeconds: number;
|
|
||||||
eventBusGain: number;
|
|
||||||
noiseMax: number;
|
|
||||||
noiseMin: number;
|
|
||||||
unlockBufferLength: number;
|
|
||||||
unlockSampleRate: number;
|
|
||||||
};
|
|
||||||
input: {
|
|
||||||
distanceEnergyBase: number;
|
|
||||||
distanceEnergyScale: number;
|
|
||||||
distanceForFullEnergyPixels: number;
|
|
||||||
fallbackFrameSeconds: number;
|
|
||||||
strokeEnergyBase: number;
|
|
||||||
strokeEnergyPressureWeight: number;
|
|
||||||
strokeEnergySpeedWeight: number;
|
|
||||||
};
|
|
||||||
muteGain: number;
|
|
||||||
muteRampSeconds: number;
|
|
||||||
noiseBurst: {
|
|
||||||
attackSeconds: number;
|
|
||||||
filterQ: number;
|
|
||||||
offsetRandomSeconds: number;
|
|
||||||
scheduleAheadSeconds: number;
|
|
||||||
silentGain: number;
|
|
||||||
};
|
|
||||||
piano: {
|
|
||||||
filterQ: number;
|
|
||||||
gainAttackSeconds: number;
|
|
||||||
lowpassMaxHz: number;
|
|
||||||
lowpassMinHz: number;
|
|
||||||
minDurationSeconds: number;
|
|
||||||
minFadeSeconds: number;
|
|
||||||
minGain: number;
|
|
||||||
pitchSemitonesPerOctave: number;
|
|
||||||
scheduleAheadSeconds: number;
|
|
||||||
sustainBase: number;
|
|
||||||
sustainVelocityRange: number;
|
|
||||||
tailStopExtraSeconds: number;
|
|
||||||
voiceStealFadeSeconds: number;
|
|
||||||
voiceStealStopSeconds: number;
|
|
||||||
};
|
|
||||||
startDelaySeconds: number;
|
|
||||||
vibeChangeStingerMinIntervalSeconds: number;
|
|
||||||
};
|
|
||||||
deltaTime: {
|
deltaTime: {
|
||||||
fpsExponentialDecayStrength: number;
|
fpsExponentialDecayStrength: number;
|
||||||
maxDeltaTimeSeconds: number;
|
maxDeltaTimeSeconds: number;
|
||||||
|
|
@ -192,6 +118,7 @@ export interface GardenAppConfig {
|
||||||
simulation: {
|
simulation: {
|
||||||
budget: {
|
budget: {
|
||||||
adaptiveCapDecreaseAgentsPerSecond: number;
|
adaptiveCapDecreaseAgentsPerSecond: number;
|
||||||
|
adaptiveCapMax: number;
|
||||||
adaptiveCapMin: number;
|
adaptiveCapMin: number;
|
||||||
fpsHeadroom: number;
|
fpsHeadroom: number;
|
||||||
fpsSmoothingNew: number;
|
fpsSmoothingNew: number;
|
||||||
|
|
@ -246,10 +173,6 @@ export interface GardenAppConfig {
|
||||||
audioMutedKey: string;
|
audioMutedKey: string;
|
||||||
vibeKey: string;
|
vibeKey: string;
|
||||||
};
|
};
|
||||||
telemetry: {
|
|
||||||
enabled: boolean;
|
|
||||||
intervalMs: number;
|
|
||||||
};
|
|
||||||
toolbar: {
|
toolbar: {
|
||||||
eraser: {
|
eraser: {
|
||||||
controlScaleMax: number;
|
controlScaleMax: number;
|
||||||
|
|
@ -277,5 +200,3 @@ export interface GardenAppConfig {
|
||||||
presets: Array<VibePreset>;
|
presets: Array<VibePreset>;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export type GardenAudioEngineConfig = GardenAppConfig['audioEngine'];
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,11 @@
|
||||||
import { vec2 } from 'gl-matrix';
|
import { vec2 } from 'gl-matrix';
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { appConfig } from '../config';
|
||||||
|
import { AgentGenerationPipeline } from '../pipelines/agents/agent-generation/agent-generation-pipeline';
|
||||||
|
import { settings } from '../settings';
|
||||||
|
import { AgentPopulation } from './agent-population';
|
||||||
|
|
||||||
vi.hoisted(() => {
|
vi.hoisted(() => {
|
||||||
Object.defineProperty(globalThis, 'localStorage', {
|
Object.defineProperty(globalThis, 'localStorage', {
|
||||||
configurable: true,
|
configurable: true,
|
||||||
|
|
@ -11,11 +16,6 @@ vi.hoisted(() => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
import { appConfig } from '../config';
|
|
||||||
import { AgentGenerationPipeline } from '../pipelines/agents/agent-generation/agent-generation-pipeline';
|
|
||||||
import { settings } from '../settings';
|
|
||||||
import { AgentPopulation } from './agent-population';
|
|
||||||
|
|
||||||
const originalAgentBudgetMax = settings.agentBudgetMax;
|
const originalAgentBudgetMax = settings.agentBudgetMax;
|
||||||
const originalBrushSize = settings.brushSize;
|
const originalBrushSize = settings.brushSize;
|
||||||
const originalSelectedColorIndex = settings.selectedColorIndex;
|
const originalSelectedColorIndex = settings.selectedColorIndex;
|
||||||
|
|
@ -63,10 +63,35 @@ describe('AgentPopulation adaptive budget', () => {
|
||||||
expect(settings.agentBudgetMax).toBeGreaterThan(1_000_000);
|
expect(settings.agentBudgetMax).toBeGreaterThan(1_000_000);
|
||||||
expect(population.activeAgentCount).toBeGreaterThan(1_000_000);
|
expect(population.activeAgentCount).toBeGreaterThan(1_000_000);
|
||||||
expect(settings.agentBudgetMax).toBeLessThanOrEqual(
|
expect(settings.agentBudgetMax).toBeLessThanOrEqual(
|
||||||
appConfig.simulation.globalAgentCap
|
appConfig.simulation.budget.adaptiveCapMax
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('does not grow the cap above the adaptive max agent count', () => {
|
||||||
|
const population = createPopulation();
|
||||||
|
const maxAgentCount = appConfig.simulation.budget.adaptiveCapMax;
|
||||||
|
settings.agentBudgetMax = maxAgentCount - 1;
|
||||||
|
setPopulationActiveCount(population, maxAgentCount - 1);
|
||||||
|
|
||||||
|
population.growBudget(1 / 60, 60, 60);
|
||||||
|
population.spawnStrokeAgents(vec2.fromValues(0, 0), vec2.fromValues(16, 0));
|
||||||
|
|
||||||
|
expect(settings.agentBudgetMax).toBe(maxAgentCount);
|
||||||
|
expect(population.activeAgentCount).toBe(maxAgentCount);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clamps a manually raised cap before adding agents', () => {
|
||||||
|
const population = createPopulation();
|
||||||
|
const maxAgentCount = appConfig.simulation.budget.adaptiveCapMax;
|
||||||
|
settings.agentBudgetMax = maxAgentCount + 1_000;
|
||||||
|
setPopulationActiveCount(population, maxAgentCount);
|
||||||
|
|
||||||
|
population.spawnStrokeAgents(vec2.fromValues(0, 0), vec2.fromValues(16, 0));
|
||||||
|
|
||||||
|
expect(settings.agentBudgetMax).toBe(maxAgentCount);
|
||||||
|
expect(population.activeAgentCount).toBe(maxAgentCount);
|
||||||
|
});
|
||||||
|
|
||||||
it('decreases the cap and active count slowly when FPS falls below the threshold', () => {
|
it('decreases the cap and active count slowly when FPS falls below the threshold', () => {
|
||||||
const population = createPopulation();
|
const population = createPopulation();
|
||||||
setPopulationActiveCount(population, 1_000_000);
|
setPopulationActiveCount(population, 1_000_000);
|
||||||
|
|
@ -74,8 +99,6 @@ describe('AgentPopulation adaptive budget', () => {
|
||||||
population.growBudget(10, 50, 60);
|
population.growBudget(10, 50, 60);
|
||||||
|
|
||||||
expect(settings.agentBudgetMax).toBe(appConfig.simulation.budget.adaptiveCapMin);
|
expect(settings.agentBudgetMax).toBe(appConfig.simulation.budget.adaptiveCapMin);
|
||||||
expect(population.activeAgentCount).toBe(
|
expect(population.activeAgentCount).toBe(appConfig.simulation.budget.adaptiveCapMin);
|
||||||
appConfig.simulation.budget.adaptiveCapMin
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ const INITIAL_AGENT_COUNT = appConfig.simulation.initialAgentCount;
|
||||||
const MIN_STROKE_AGENT_COUNT = appConfig.simulation.stroke.minAgentCount;
|
const MIN_STROKE_AGENT_COUNT = appConfig.simulation.stroke.minAgentCount;
|
||||||
const MAX_STROKE_AGENT_COUNT = appConfig.simulation.stroke.maxAgentCount;
|
const MAX_STROKE_AGENT_COUNT = appConfig.simulation.stroke.maxAgentCount;
|
||||||
const STROKE_AGENT_DENSITY_MULTIPLIER = appConfig.simulation.stroke.densityMultiplier;
|
const STROKE_AGENT_DENSITY_MULTIPLIER = appConfig.simulation.stroke.densityMultiplier;
|
||||||
|
const ADAPTIVE_CAP_MAX = appConfig.simulation.budget.adaptiveCapMax;
|
||||||
const ADAPTIVE_CAP_MIN = appConfig.simulation.budget.adaptiveCapMin;
|
const ADAPTIVE_CAP_MIN = appConfig.simulation.budget.adaptiveCapMin;
|
||||||
const ADAPTIVE_CAP_DECREASE_AGENTS_PER_SECOND =
|
const ADAPTIVE_CAP_DECREASE_AGENTS_PER_SECOND =
|
||||||
appConfig.simulation.budget.adaptiveCapDecreaseAgentsPerSecond;
|
appConfig.simulation.budget.adaptiveCapDecreaseAgentsPerSecond;
|
||||||
|
|
@ -129,6 +130,7 @@ export class AgentPopulation {
|
||||||
}
|
}
|
||||||
|
|
||||||
const count = data.length / AGENT_FLOAT_COUNT;
|
const count = data.length / AGENT_FLOAT_COUNT;
|
||||||
|
settings.agentBudgetMax = this.clampAdaptiveCap(settings.agentBudgetMax);
|
||||||
this.expandAdaptiveCapForPendingAgents(count);
|
this.expandAdaptiveCapForPendingAgents(count);
|
||||||
|
|
||||||
const available = Math.max(0, settings.agentBudgetMax - this.activeCount);
|
const available = Math.max(0, settings.agentBudgetMax - this.activeCount);
|
||||||
|
|
@ -214,8 +216,9 @@ export class AgentPopulation {
|
||||||
|
|
||||||
private clampAdaptiveCap(value: number): number {
|
private clampAdaptiveCap(value: number): number {
|
||||||
const pipelineCap = Math.max(0, Math.floor(this.pipeline.maxAgentCount));
|
const pipelineCap = Math.max(0, Math.floor(this.pipeline.maxAgentCount));
|
||||||
const minCap = Math.min(ADAPTIVE_CAP_MIN, pipelineCap);
|
const maxCap = Math.min(ADAPTIVE_CAP_MAX, pipelineCap);
|
||||||
|
const minCap = Math.min(ADAPTIVE_CAP_MIN, maxCap);
|
||||||
const finiteValue = Number.isFinite(value) ? value : minCap;
|
const finiteValue = Number.isFinite(value) ? value : minCap;
|
||||||
return Math.min(pipelineCap, Math.max(minCap, Math.round(finiteValue)));
|
return Math.min(maxCap, Math.max(minCap, Math.round(finiteValue)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,5 @@
|
||||||
import { appConfig } from '../config';
|
import { appConfig } from '../config';
|
||||||
|
|
||||||
interface TelemetrySnapshot {
|
|
||||||
frameCpuStartedAt: number;
|
|
||||||
encodeCpuMs: number;
|
|
||||||
activeAgentCount: number;
|
|
||||||
agentBudgetMax: number;
|
|
||||||
canvas: HTMLCanvasElement;
|
|
||||||
devicePixelRatio: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const COMMON_DISPLAY_REFRESH_RATES = [
|
const COMMON_DISPLAY_REFRESH_RATES = [
|
||||||
50, 60, 72, 75, 90, 100, 120, 144, 165, 180, 240,
|
50, 60, 72, 75, 90, 100, 120, 144, 165, 180, 240,
|
||||||
] as const;
|
] as const;
|
||||||
|
|
@ -22,20 +13,11 @@ export class FramePerformance {
|
||||||
public displayRefreshFps = 60;
|
public displayRefreshFps = 60;
|
||||||
public readonly refreshTargetFps = 60;
|
public readonly refreshTargetFps = 60;
|
||||||
|
|
||||||
private lastTelemetryAt = 0;
|
|
||||||
private previousFrameTime: DOMHighResTimeStamp | null = null;
|
private previousFrameTime: DOMHighResTimeStamp | null = null;
|
||||||
private hasConfirmedDisplayRefreshFps = false;
|
private hasConfirmedDisplayRefreshFps = false;
|
||||||
private pendingDisplayRefreshFps = 0;
|
private pendingDisplayRefreshFps = 0;
|
||||||
private pendingDisplayRefreshFrameCount = 0;
|
private pendingDisplayRefreshFrameCount = 0;
|
||||||
|
|
||||||
public markCpuStart(): number {
|
|
||||||
return appConfig.telemetry.enabled ? performance.now() : 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
public measureSince(startedAt: number): number {
|
|
||||||
return appConfig.telemetry.enabled ? performance.now() - startedAt : 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
public update(time: DOMHighResTimeStamp): void {
|
public update(time: DOMHighResTimeStamp): void {
|
||||||
const previous = this.previousFrameTime;
|
const previous = this.previousFrameTime;
|
||||||
this.previousFrameTime = time;
|
this.previousFrameTime = time;
|
||||||
|
|
@ -56,39 +38,6 @@ export class FramePerformance {
|
||||||
fps * appConfig.simulation.budget.fpsSmoothingNew;
|
fps * appConfig.simulation.budget.fpsSmoothingNew;
|
||||||
}
|
}
|
||||||
|
|
||||||
public renderTelemetry({
|
|
||||||
frameCpuStartedAt,
|
|
||||||
encodeCpuMs,
|
|
||||||
activeAgentCount,
|
|
||||||
agentBudgetMax,
|
|
||||||
canvas,
|
|
||||||
devicePixelRatio,
|
|
||||||
}: TelemetrySnapshot): void {
|
|
||||||
if (!appConfig.telemetry.enabled) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const now = performance.now();
|
|
||||||
if (now - this.lastTelemetryAt < appConfig.telemetry.intervalMs) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.lastTelemetryAt = now;
|
|
||||||
console.debug('Fleeting Garden telemetry', {
|
|
||||||
fps: Math.round(this.latestFps),
|
|
||||||
smoothedFps: Math.round(this.smoothedFps),
|
|
||||||
refreshTargetFps: Math.round(this.refreshTargetFps),
|
|
||||||
displayRefreshFps: Math.round(this.displayRefreshFps),
|
|
||||||
activeAgentCount,
|
|
||||||
agentBudgetMax,
|
|
||||||
canvasWidth: canvas.width,
|
|
||||||
canvasHeight: canvas.height,
|
|
||||||
dpr: devicePixelRatio,
|
|
||||||
frameCpuMs: now - frameCpuStartedAt,
|
|
||||||
encodeCpuMs,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private updateDisplayRefreshEstimate(fps: number): void {
|
private updateDisplayRefreshEstimate(fps: number): void {
|
||||||
const displayRefreshFps = this.snapDisplayRefreshRate(fps);
|
const displayRefreshFps = this.snapDisplayRefreshRate(fps);
|
||||||
if (displayRefreshFps === null) {
|
if (displayRefreshFps === null) {
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,4 @@ export interface GameLoopSettings {
|
||||||
simulatedDelayMs: number;
|
simulatedDelayMs: number;
|
||||||
selectedColorIndex: number;
|
selectedColorIndex: number;
|
||||||
spawnPerPixel: number;
|
spawnPerPixel: number;
|
||||||
|
|
||||||
startColorHue: number;
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,11 +23,7 @@ export default class GameLoop {
|
||||||
private static readonly DEV_STATS_INTERVAL_MS = 250;
|
private static readonly DEV_STATS_INTERVAL_MS = 250;
|
||||||
|
|
||||||
private readonly resources: GameLoopResources;
|
private readonly resources: GameLoopResources;
|
||||||
private readonly audio = new GardenAudio(
|
private readonly audio = new GardenAudio(gardenAudioConfig);
|
||||||
gardenAudioConfig,
|
|
||||||
appConfig.audioEngine,
|
|
||||||
appConfig.simulation.maxMirrorSegmentCount
|
|
||||||
);
|
|
||||||
private readonly renderInputs = new RenderInputCache();
|
private readonly renderInputs = new RenderInputCache();
|
||||||
private readonly introPrompt: IntroPrompt;
|
private readonly introPrompt: IntroPrompt;
|
||||||
private readonly eraserPreview: EraserPreview;
|
private readonly eraserPreview: EraserPreview;
|
||||||
|
|
@ -68,7 +64,6 @@ export default class GameLoop {
|
||||||
eraserAgentPipeline: this.resources.eraserAgentPipeline,
|
eraserAgentPipeline: this.resources.eraserAgentPipeline,
|
||||||
eraserTexturePipeline: this.resources.eraserTexturePipeline,
|
eraserTexturePipeline: this.resources.eraserTexturePipeline,
|
||||||
eraserPreview: this.eraserPreview,
|
eraserPreview: this.eraserPreview,
|
||||||
getCanvasSize: () => this.canvasSize,
|
|
||||||
getDevicePixelRatio: () => this.devicePixelRatio,
|
getDevicePixelRatio: () => this.devicePixelRatio,
|
||||||
getMirrorSegmentCount: () => this.mirrorSegmentCount,
|
getMirrorSegmentCount: () => this.mirrorSegmentCount,
|
||||||
onStartDrawing: () => this.introPrompt.markStartedDrawing(),
|
onStartDrawing: () => this.introPrompt.markStartedDrawing(),
|
||||||
|
|
@ -167,7 +162,6 @@ export default class GameLoop {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const frameCpuStartedAt = this.framePerformance.markCpuStart();
|
|
||||||
const deltaTime = this.deltaTimeCalculator.calculateDeltaTimeInSeconds(time);
|
const deltaTime = this.deltaTimeCalculator.calculateDeltaTimeInSeconds(time);
|
||||||
this.framePerformance.update(time);
|
this.framePerformance.update(time);
|
||||||
this.agentPopulation.growBudget(
|
this.agentPopulation.growBudget(
|
||||||
|
|
@ -194,7 +188,6 @@ export default class GameLoop {
|
||||||
vibe: activeVibe,
|
vibe: activeVibe,
|
||||||
selectedColorIndex: settings.selectedColorIndex,
|
selectedColorIndex: settings.selectedColorIndex,
|
||||||
isErasing,
|
isErasing,
|
||||||
mirrorSegmentCount: this.mirrorSegmentCount,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
this.resources.setFrameParameters({
|
this.resources.setFrameParameters({
|
||||||
|
|
@ -212,24 +205,14 @@ export default class GameLoop {
|
||||||
eraserPixelSize,
|
eraserPixelSize,
|
||||||
});
|
});
|
||||||
|
|
||||||
const encodeCpuStartedAt = this.framePerformance.markCpuStart();
|
|
||||||
this.resources.executeFrame(
|
this.resources.executeFrame(
|
||||||
isErasing,
|
isErasing,
|
||||||
this.toolbarContrastMonitor.takeReadbackRequest(time)
|
this.toolbarContrastMonitor.takeReadbackRequest(time)
|
||||||
);
|
);
|
||||||
const encodeCpuMs = this.framePerformance.measureSince(encodeCpuStartedAt);
|
|
||||||
|
|
||||||
this.pointerInput.clearSwipesIfIdle();
|
this.pointerInput.clearSwipesIfIdle();
|
||||||
await this.agentPopulation.compactAfterErase(this.pointerInput.isSwipeActive);
|
await this.agentPopulation.compactAfterErase(this.pointerInput.isSwipeActive);
|
||||||
|
|
||||||
this.framePerformance.renderTelemetry({
|
|
||||||
frameCpuStartedAt,
|
|
||||||
encodeCpuMs,
|
|
||||||
activeAgentCount: this.agentPopulation.activeAgentCount,
|
|
||||||
agentBudgetMax: settings.agentBudgetMax,
|
|
||||||
canvas: this.canvas,
|
|
||||||
devicePixelRatio: this.devicePixelRatio,
|
|
||||||
});
|
|
||||||
this.updateDevStats(time);
|
this.updateDevStats(time);
|
||||||
|
|
||||||
if (settings.simulatedDelayMs > 0) {
|
if (settings.simulatedDelayMs > 0) {
|
||||||
|
|
|
||||||
|
|
@ -118,7 +118,6 @@ const createPointerInput = async () => {
|
||||||
eraserAgentPipeline,
|
eraserAgentPipeline,
|
||||||
eraserPreview,
|
eraserPreview,
|
||||||
eraserTexturePipeline,
|
eraserTexturePipeline,
|
||||||
getCanvasSize: () => [canvas.width, canvas.height],
|
|
||||||
getDevicePixelRatio: () => 1,
|
getDevicePixelRatio: () => 1,
|
||||||
getMirrorSegmentCount: () => 1,
|
getMirrorSegmentCount: () => 1,
|
||||||
onEraseGestureEnded,
|
onEraseGestureEnded,
|
||||||
|
|
@ -185,9 +184,7 @@ describe('GardenPointerInput drawing startup', () => {
|
||||||
expect(audio.beginGesture).toHaveBeenCalledTimes(1);
|
expect(audio.beginGesture).toHaveBeenCalledTimes(1);
|
||||||
expect(audio.touchDown).toHaveBeenCalledWith(
|
expect(audio.touchDown).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
canvasSize: [300, 200],
|
|
||||||
colorIndex: 0,
|
colorIndex: 0,
|
||||||
position: expect.any(Float32Array),
|
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
expect(audio.stroke).not.toHaveBeenCalled();
|
expect(audio.stroke).not.toHaveBeenCalled();
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,6 @@ interface GardenPointerInputOptions {
|
||||||
eraserAgentPipeline: EraserAgentPipeline;
|
eraserAgentPipeline: EraserAgentPipeline;
|
||||||
eraserTexturePipeline: EraserTexturePipeline;
|
eraserTexturePipeline: EraserTexturePipeline;
|
||||||
eraserPreview: EraserPreview;
|
eraserPreview: EraserPreview;
|
||||||
getCanvasSize: () => vec2;
|
|
||||||
getDevicePixelRatio: () => number;
|
getDevicePixelRatio: () => number;
|
||||||
getMirrorSegmentCount: () => number;
|
getMirrorSegmentCount: () => number;
|
||||||
onStartDrawing: () => void;
|
onStartDrawing: () => void;
|
||||||
|
|
@ -32,7 +31,6 @@ export class GardenPointerInput {
|
||||||
private activePointerId: number | null = null;
|
private activePointerId: number | null = null;
|
||||||
private lastPointerPosition: vec2 | null = null;
|
private lastPointerPosition: vec2 | null = null;
|
||||||
private lastPointerEventTimeMs: number | null = null;
|
private lastPointerEventTimeMs: number | null = null;
|
||||||
private lastPointerPressure = 0.5;
|
|
||||||
private smoothedStrokePoints: Array<vec2> = [];
|
private smoothedStrokePoints: Array<vec2> = [];
|
||||||
private lastSmoothedBrushPosition: vec2 | null = null;
|
private lastSmoothedBrushPosition: vec2 | null = null;
|
||||||
private isErasing = false;
|
private isErasing = false;
|
||||||
|
|
@ -109,18 +107,12 @@ export class GardenPointerInput {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const position = this.getCanvasPointerPosition(event);
|
|
||||||
if (event.pointerType !== 'touch') {
|
if (event.pointerType !== 'touch') {
|
||||||
this.options.audio.start(activeVibe, { userGesture: true });
|
this.options.audio.start(activeVibe, { userGesture: true });
|
||||||
}
|
}
|
||||||
this.options.audio.beginGesture();
|
this.options.audio.beginGesture();
|
||||||
this.options.audio.touchDown({
|
this.options.audio.touchDown({
|
||||||
vibe: activeVibe,
|
|
||||||
colorIndex: settings.selectedColorIndex,
|
colorIndex: settings.selectedColorIndex,
|
||||||
position,
|
|
||||||
canvasSize: this.options.getCanvasSize(),
|
|
||||||
mirrorSegmentCount: this.options.getMirrorSegmentCount(),
|
|
||||||
pressure: this.getPointerPressure(event),
|
|
||||||
});
|
});
|
||||||
this.options.onStartDrawing();
|
this.options.onStartDrawing();
|
||||||
this.activePointerId = event.pointerId;
|
this.activePointerId = event.pointerId;
|
||||||
|
|
@ -131,7 +123,6 @@ export class GardenPointerInput {
|
||||||
this.lastPointerPosition = null;
|
this.lastPointerPosition = null;
|
||||||
this.lastPointerEventTimeMs = null;
|
this.lastPointerEventTimeMs = null;
|
||||||
this.clearSmoothedStroke();
|
this.clearSmoothedStroke();
|
||||||
this.lastPointerPressure = this.getPointerPressure(event);
|
|
||||||
this.addSwipeAt(event, { emitAudio: false });
|
this.addSwipeAt(event, { emitAudio: false });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -178,7 +169,6 @@ export class GardenPointerInput {
|
||||||
};
|
};
|
||||||
|
|
||||||
private addSwipeAt(event: PointerEvent, options: { emitAudio?: boolean } = {}): void {
|
private addSwipeAt(event: PointerEvent, options: { emitAudio?: boolean } = {}): void {
|
||||||
const devicePixelRatio = this.options.getDevicePixelRatio();
|
|
||||||
const position = this.getCanvasPointerPosition(event);
|
const position = this.getCanvasPointerPosition(event);
|
||||||
const previousPosition = this.lastPointerPosition ?? position;
|
const previousPosition = this.lastPointerPosition ?? position;
|
||||||
const previousTimeMs = this.lastPointerEventTimeMs ?? event.timeStamp;
|
const previousTimeMs = this.lastPointerEventTimeMs ?? event.timeStamp;
|
||||||
|
|
@ -186,10 +176,6 @@ export class GardenPointerInput {
|
||||||
appConfig.deltaTime.minDeltaTimeSeconds,
|
appConfig.deltaTime.minDeltaTimeSeconds,
|
||||||
(event.timeStamp - previousTimeMs) / 1000
|
(event.timeStamp - previousTimeMs) / 1000
|
||||||
);
|
);
|
||||||
const distancePixels = vec2.distance(previousPosition, position);
|
|
||||||
const velocityPixelsPerSecond = distancePixels / elapsedSeconds;
|
|
||||||
const pressure = this.getPointerPressure(event);
|
|
||||||
this.lastPointerPressure = pressure > 0 ? pressure : this.lastPointerPressure;
|
|
||||||
|
|
||||||
const segments = this.isErasing
|
const segments = this.isErasing
|
||||||
? [{ from: previousPosition, to: position }]
|
? [{ from: previousPosition, to: position }]
|
||||||
|
|
@ -214,14 +200,9 @@ export class GardenPointerInput {
|
||||||
vibe: activeVibe,
|
vibe: activeVibe,
|
||||||
from: previousPosition,
|
from: previousPosition,
|
||||||
to: position,
|
to: position,
|
||||||
canvasSize: this.options.getCanvasSize(),
|
|
||||||
colorIndex: settings.selectedColorIndex,
|
colorIndex: settings.selectedColorIndex,
|
||||||
isErasing: this.isErasing,
|
isErasing: this.isErasing,
|
||||||
pressure: pressure > 0 ? pressure : this.lastPointerPressure,
|
|
||||||
velocityPixelsPerSecond,
|
|
||||||
elapsedSeconds,
|
elapsedSeconds,
|
||||||
eraserSizePixels: settings.eraserSize * devicePixelRatio,
|
|
||||||
mirrorSegmentCount: this.options.getMirrorSegmentCount(),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
this.lastPointerPosition = position;
|
this.lastPointerPosition = position;
|
||||||
|
|
@ -363,14 +344,6 @@ export class GardenPointerInput {
|
||||||
|
|
||||||
return segments;
|
return segments;
|
||||||
}
|
}
|
||||||
|
|
||||||
private getPointerPressure(event: PointerEvent): number {
|
|
||||||
if (Number.isFinite(event.pressure) && event.pressure > 0) {
|
|
||||||
return Math.min(1, Math.max(0, event.pressure));
|
|
||||||
}
|
|
||||||
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const rotatePointAround = (point: vec2, center: vec2, angle: number): vec2 => {
|
const rotatePointAround = (point: vec2, center: vec2, angle: number): vec2 => {
|
||||||
|
|
@ -409,5 +382,4 @@ const getBrushCurveResolution = (): number => {
|
||||||
const isSamePointerSample = (left: PointerEvent, right: PointerEvent): boolean =>
|
const isSamePointerSample = (left: PointerEvent, right: PointerEvent): boolean =>
|
||||||
left.clientX === right.clientX &&
|
left.clientX === right.clientX &&
|
||||||
left.clientY === right.clientY &&
|
left.clientY === right.clientY &&
|
||||||
left.pressure === right.pressure &&
|
|
||||||
left.buttons === right.buttons;
|
left.buttons === right.buttons;
|
||||||
|
|
|
||||||
|
|
@ -48,10 +48,20 @@ fn fragment(@location(0) uv: vec2<f32>) -> @location(0) vec4<f32> {
|
||||||
let color = max(normalizedTraceColor, brushColor * (1.2 + brushStrength * 1.6));
|
let color = max(normalizedTraceColor, brushColor * (1.2 + brushStrength * 1.6));
|
||||||
|
|
||||||
let strength = clamp(max(max(max(strengths.r, strengths.g), strengths.b), brushStrength), 0, 1);
|
let strength = clamp(max(max(max(strengths.r, strengths.g), strengths.b), brushStrength), 0, 1);
|
||||||
|
let background = getTexturedBackground(uv);
|
||||||
|
|
||||||
return vec4(mix(settings.backgroundColor, clamp(color, vec3(0), vec3(1)), strength), 1);
|
return vec4(mix(background, clamp(color, vec3(0), vec3(1)), strength), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn clarity(strength: f32) -> f32 {
|
fn clarity(strength: f32) -> f32 {
|
||||||
return pow(clamp(strength, 0, 1), settings.clarity);
|
return pow(clamp(strength, 0, 1), settings.clarity);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn getTexturedBackground(uv: vec2<f32>) -> vec3<f32> {
|
||||||
|
let noiseSize = vec2<f32>(textureDimensions(noise, 0));
|
||||||
|
let pixel = floor(uv * state.size);
|
||||||
|
let noiseCoord = vec2<i32>(fract(pixel / noiseSize) * noiseSize);
|
||||||
|
let grain = textureLoad(noise, noiseCoord, 0).r - 0.5;
|
||||||
|
|
||||||
|
return clamp(settings.backgroundColor + vec3(grain * 0.018), vec3(0), vec3(1));
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue