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