v good
Some checks failed
Deploy to Pages / build (pull_request) Failing after 1m56s

This commit is contained in:
Andras Schmelczer 2026-05-16 13:46:19 +01:00
commit 10a81ba474
45 changed files with 1974 additions and 490 deletions

View file

@ -1,10 +1,11 @@
import { describe, expect, it } from 'vitest';
import { appConfig } from '../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();
const energy = new GardenAudioEnergy(appConfig.audioEngine);
energy.beginGesture(0);
energy.recordStroke(0.8, 0.1);
@ -24,7 +25,7 @@ describe('GardenAudioEnergy', () => {
});
it('uses recent stroke intensity rather than gesture duration alone', () => {
const energy = new GardenAudioEnergy();
const energy = new GardenAudioEnergy(appConfig.audioEngine);
energy.beginGesture(0);
energy.recordStroke(1, 0.1);
@ -38,7 +39,7 @@ describe('GardenAudioEnergy', () => {
});
it('raises activity immediately when a stroke is recorded', () => {
const energy = new GardenAudioEnergy();
const energy = new GardenAudioEnergy(appConfig.audioEngine);
energy.beginGesture(0);
energy.recordStroke(0.12, 0.05);

View file

@ -0,0 +1,385 @@
import type { GardenAudioEngineConfig } from '../config';
import { clamp, clamp01 } from '../utils/clamp';
import type {
GardenAudioColorIndex,
GardenAudioStroke,
GardenAudioTouchDown,
} from './garden-audio-types';
import type { GardenAudioStrokeMetrics } from './garden-audio-input';
type GardenAudioGestureMode = 'calm' | 'active' | 'manic' | 'afterglow';
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 {
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 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 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.lastFrame = {
...this.lastFrame,
mode: this.peakActivity >= 0.42 ? 'afterglow' : 'calm',
activity: 0,
maniaAmount: 0,
speedAmount: 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,
};
return this.lastFrame;
}
public recordStroke({
stroke,
metrics,
mirrorAmount,
}: {
stroke: GardenAudioStroke;
metrics: GardenAudioStrokeMetrics;
mirrorAmount: number;
}): GardenAudioGestureFrame {
const elapsedSeconds = this.getElapsedSeconds(stroke);
this.gestureClockSeconds += 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) {
this.samples.push({
at: this.gestureClockSeconds,
speed,
acceleration,
distancePixels,
turned,
});
}
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 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);
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),
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,
};
return this.lastFrame;
}
public getFrame(): GardenAudioGestureFrame {
return this.lastFrame;
}
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;
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) {
return 'manic';
}
return activity >= 0.38 ? '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);
};

View file

@ -5,6 +5,7 @@ import { GardenAudioStroke } from './garden-audio-types';
export interface GardenAudioStrokeMetrics {
distancePixels: number;
pressure: number;
speedPixelsPerSecond: number;
speedAmount: number;
effectiveEnergy: number;
}
@ -35,6 +36,7 @@ export const getStrokeMetrics = (
return {
distancePixels,
pressure,
speedPixelsPerSecond,
speedAmount,
effectiveEnergy,
};

View file

@ -18,6 +18,7 @@ export interface GardenAudioStroke {
isErasing: boolean;
pressure?: number;
velocityPixelsPerSecond?: number;
elapsedSeconds?: number;
eraserSizePixels?: number;
mirrorSegmentCount?: number;
pointerType?: string;
@ -26,6 +27,8 @@ export interface GardenAudioStroke {
export interface GardenAudioTouchDown {
vibe: VibePreset;
colorIndex: number;
position?: ArrayLike<number>;
canvasSize?: ArrayLike<number>;
mirrorSegmentCount?: number;
pressure?: number;
pointerType?: string;

View file

@ -1,5 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { appConfig } from '../config';
import { VIBE_PRESETS } from '../vibes';
import { GardenAudio } from './garden-audio';
import { gardenAudioConfig, GardenAudioConfig } from './garden-audio-config';
@ -7,6 +8,7 @@ import { gardenAudioConfig, GardenAudioConfig } from './garden-audio-config';
const calls = {
constructed: 0,
resumed: 0,
sourcesStarted: 0,
};
let contextState: AudioContextState = 'suspended';
@ -22,13 +24,16 @@ class FakeAudioParam {
class FakeAudioNode {
public readonly gain = new FakeAudioParam();
public readonly frequency = new FakeAudioParam();
public readonly Q = new FakeAudioParam();
public readonly threshold = new FakeAudioParam();
public readonly knee = new FakeAudioParam();
public readonly ratio = new FakeAudioParam();
public readonly attack = new FakeAudioParam();
public readonly release = new FakeAudioParam();
public readonly delayTime = new FakeAudioParam();
public readonly pan = new FakeAudioParam();
public type = '';
public addEventListener = vi.fn();
public connect = vi.fn();
public disconnect = vi.fn();
}
@ -78,6 +83,10 @@ class FakeAudioContext {
return new FakeAudioNode() as unknown as DelayNode;
}
public createStereoPanner(): StereoPannerNode {
return new FakeAudioNode() as unknown as StereoPannerNode;
}
public createBuffer(_channels: number, length: number): AudioBuffer {
return new FakeAudioBuffer(length) as unknown as AudioBuffer;
}
@ -89,7 +98,9 @@ class FakeAudioContext {
stop: () => void;
};
node.buffer = null;
node.start = vi.fn();
node.start = vi.fn(() => {
calls.sourcesStarted += 1;
});
node.stop = vi.fn();
return node;
}
@ -108,6 +119,7 @@ describe('GardenAudio startup policy', () => {
beforeEach(() => {
calls.constructed = 0;
calls.resumed = 0;
calls.sourcesStarted = 0;
contextState = 'suspended';
vi.stubGlobal('AudioContext', FakeAudioContext);
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('not loaded in tests')));
@ -118,7 +130,11 @@ describe('GardenAudio startup policy', () => {
});
it('does not create an AudioContext from passive audio paths', () => {
const audio = new GardenAudio(makeConfig());
const audio = new GardenAudio(
makeConfig(),
appConfig.audioEngine,
appConfig.simulation.maxMirrorSegmentCount
);
const vibe = VIBE_PRESETS[0];
audio.start(vibe);
@ -135,7 +151,11 @@ describe('GardenAudio startup policy', () => {
});
it('only resumes a suspended context from a user gesture start', () => {
const audio = new GardenAudio(makeConfig());
const audio = new GardenAudio(
makeConfig(),
appConfig.audioEngine,
appConfig.simulation.maxMirrorSegmentCount
);
const vibe = VIBE_PRESETS[0];
audio.start(vibe, { userGesture: true });
@ -150,4 +170,51 @@ describe('GardenAudio startup policy', () => {
expect(calls.resumed).toBe(1);
});
it('skips cold piano fallback while preserving eraser noise', () => {
const audio = new GardenAudio(
makeConfig(),
appConfig.audioEngine,
appConfig.simulation.maxMirrorSegmentCount
);
const vibe = VIBE_PRESETS[0];
audio.start(vibe, { userGesture: true });
expect(calls.sourcesStarted).toBe(1);
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,
});
expect(calls.sourcesStarted).toBe(1);
audio.stroke({
vibe,
from: [60, 60],
to: [75, 80],
canvasSize: [100, 100],
colorIndex: 1,
eraserSizePixels: 30,
isErasing: true,
pressure: 0.7,
velocityPixelsPerSecond: 1200,
});
expect(calls.sourcesStarted).toBe(2);
});
});

View file

@ -1,10 +1,11 @@
import { appConfig } from '../config';
import type { GardenAudioEngineConfig } from '../config';
import { clamp, clamp01 } from '../utils/clamp';
import { VibePreset } from '../vibes';
import { GardenAudioConfig } from './garden-audio-config';
import { GardenAudioEnergy } from './garden-audio-energy';
import { GardenAudioGestureState } from './garden-audio-gesture-state';
import { GardenAudioGraph } from './garden-audio-graph';
import { GardenAudioStrokeMetrics, getStrokeMetrics } from './garden-audio-input';
import { getStrokeMetrics } from './garden-audio-input';
import { getVibeProfile, normalizeColorIndex } from './garden-audio-music';
import type {
GardenAudioColorIndex,
@ -29,6 +30,7 @@ export class GardenAudio {
private readonly piano: PianoSampler;
private readonly noise: NoiseBurstPlayer;
private readonly energy: GardenAudioEnergy;
private readonly gestureState: GardenAudioGestureState;
private readonly pianoEngine: GenerativePianoEngine;
private currentVibeId: string | null = null;
@ -41,12 +43,22 @@ export class GardenAudio {
private lastEraserAt = Number.NEGATIVE_INFINITY;
private lastVibeStingerAt = Number.NEGATIVE_INFINITY;
public constructor(private readonly config: GardenAudioConfig) {
this.graph = new GardenAudioGraph(config);
this.piano = new PianoSampler(config, this.graph);
this.noise = new NoiseBurstPlayer(this.graph);
this.energy = new GardenAudioEnergy();
this.pianoEngine = new GenerativePianoEngine(config, (note) => this.piano.play(note));
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 start(vibe: VibePreset, options: GardenAudioStartOptions = {}): void {
@ -76,7 +88,7 @@ export class GardenAudio {
this.graph.setMasterGain(
this.config.masterVolume,
options.userGesture === true
? appConfig.audioEngine.muteRampSeconds
? this.engineConfig.muteRampSeconds
: this.config.fadeInSeconds
);
@ -110,8 +122,8 @@ export class GardenAudio {
public setMuted(isMuted: boolean): void {
this.isMuted = isMuted;
this.graph.setMasterGain(
isMuted ? appConfig.audioEngine.muteGain : this.config.masterVolume,
isMuted ? appConfig.audioEngine.muteRampSeconds : this.config.fadeInSeconds
isMuted ? this.engineConfig.muteGain : this.config.masterVolume,
isMuted ? this.engineConfig.muteRampSeconds : this.config.fadeInSeconds
);
}
@ -122,11 +134,13 @@ export class GardenAudio {
}
this.isGestureActive = true;
this.gestureState.beginGesture();
this.energy.beginGesture(context.currentTime);
this.pianoEngine.beginGesture();
}
public endGesture(): void {
this.gestureState.endGesture();
this.isGestureActive = false;
this.energy.endGesture();
this.pianoEngine.endGesture();
@ -146,6 +160,13 @@ export class GardenAudio {
const mirrorAmount = this.getMirrorAmount(touch.mirrorSegmentCount ?? 1);
const pressure = this.getTouchPressure(touch.pressure, touch.pointerType);
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({
@ -154,6 +175,13 @@ export class GardenAudio {
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,
});
}
@ -197,7 +225,8 @@ export class GardenAudio {
const metrics = getStrokeMetrics(
stroke,
this.config.rhythm.speedForFullEnergyPixelsPerSecond,
this.config.input.pressureFallback
this.config.input.pressureFallback,
this.engineConfig.input
);
const now = context.currentTime;
@ -210,7 +239,8 @@ export class GardenAudio {
}
const mirrorAmount = this.getMirrorAmount(stroke.mirrorSegmentCount ?? 1);
const strokeEnergy = this.getStrokeMusicActivity(stroke, metrics, mirrorAmount);
const frame = this.gestureState.recordStroke({ stroke, metrics, mirrorAmount });
const strokeEnergy = frame.activity;
this.energy.recordStroke(strokeEnergy, now);
this.pianoEngine.recordStroke({
vibe: stroke.vibe,
@ -218,6 +248,13 @@ export class GardenAudio {
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,
});
}
@ -227,6 +264,7 @@ export class GardenAudio {
this.piano.reset();
this.energy.reset();
this.gestureState.reset();
this.pianoEngine.reset();
this.currentVibeId = null;
this.hasStarted = false;
@ -246,7 +284,7 @@ export class GardenAudio {
const now = context.currentTime;
if (
now - this.lastVibeStingerAt <
appConfig.audioEngine.vibeChangeStingerMinIntervalSeconds
this.engineConfig.vibeChangeStingerMinIntervalSeconds
) {
return;
}
@ -266,10 +304,10 @@ export class GardenAudio {
}
const sizeAmount = clamp01(
(stroke.eraserSizePixels ?? appConfig.audioEngine.eraser.defaultSizePixels) /
(stroke.eraserSizePixels ?? this.engineConfig.eraser.defaultSizePixels) /
Math.max(
1,
stroke.canvasSize[0] * appConfig.audioEngine.eraser.canvasWidthRatioForFullSize
stroke.canvasSize[0] * this.engineConfig.eraser.canvasWidthRatioForFullSize
)
);
const x = clamp01(stroke.to[0] / Math.max(1, stroke.canvasSize[0]));
@ -277,22 +315,22 @@ export class GardenAudio {
this.config.eraser.filterMinHz +
(this.config.eraser.filterMaxHz - this.config.eraser.filterMinHz) *
clamp01(
speedAmount * appConfig.audioEngine.eraser.filterSpeedWeight +
pressure * appConfig.audioEngine.eraser.filterPressureWeight +
sizeAmount * appConfig.audioEngine.eraser.filterSizeWeight
speedAmount * this.engineConfig.eraser.filterSpeedWeight +
pressure * this.engineConfig.eraser.filterPressureWeight +
sizeAmount * this.engineConfig.eraser.filterSizeWeight
);
if (now - this.lastEraserAt >= this.config.eraser.minIntervalSeconds) {
this.lastEraserAt = now;
this.noise.play({
startTime: now,
durationSeconds: appConfig.audioEngine.eraser.durationSeconds,
durationSeconds: this.engineConfig.eraser.durationSeconds,
gain:
this.config.eraser.noiseGain *
(appConfig.audioEngine.eraser.gainBase +
speedAmount * appConfig.audioEngine.eraser.gainSpeedWeight +
pressure * appConfig.audioEngine.eraser.gainPressureWeight +
sizeAmount * appConfig.audioEngine.eraser.gainSizeWeight),
(this.engineConfig.eraser.gainBase +
speedAmount * this.engineConfig.eraser.gainSpeedWeight +
pressure * this.engineConfig.eraser.gainPressureWeight +
sizeAmount * this.engineConfig.eraser.gainSizeWeight),
filterHz,
pan: clamp(x * 2 - 1, -1, 1),
});
@ -307,7 +345,7 @@ export class GardenAudio {
const profile = getVibeProfile(this.config, snapshot.vibe);
const activity = snapshot.isErasing
? appConfig.audioEngine.delay.erasingActivity
? this.engineConfig.delay.erasingActivity
: this.energy.getLevel();
this.graph.updateDelay(profile, activity);
}
@ -323,7 +361,7 @@ export class GardenAudio {
}
private getMirrorAmount(mirrorSegmentCount: number): number {
const maxMirrorSegmentCount = Math.max(1, appConfig.simulation.maxMirrorSegmentCount);
const maxMirrorSegmentCount = Math.max(1, this.maxMirrorSegmentCount);
const segmentCount = clamp(
Number.isFinite(mirrorSegmentCount) ? mirrorSegmentCount : 1,
1,
@ -337,44 +375,16 @@ export class GardenAudio {
return clamp01((segmentCount - 1) / (maxMirrorSegmentCount - 1));
}
private getStrokeMusicActivity(
stroke: GardenAudioStroke,
metrics: GardenAudioStrokeMetrics,
mirrorAmount: number
): number {
const speedRatio =
(stroke.velocityPixelsPerSecond ?? 0) /
Math.max(1, this.config.rhythm.speedForFullEnergyPixelsPerSecond);
const speedDrive = smoothstep(0.35, 1.1, speedRatio);
const speedOverdrive = smoothstep(1.15, 1.8, speedRatio);
const distanceDrive = smoothstep(10, 90, metrics.distancePixels);
const baseStroke = clamp01(
0.08 + speedDrive * 0.5 + metrics.pressure * 0.2 + distanceDrive * 0.22
);
const mirrorWild = smoothstep(0.45, 0.9, mirrorAmount);
const maniaDrive = speedOverdrive * smoothstep(0.62, 0.82, baseStroke);
const maniaBoost = maniaDrive * (0.18 + mirrorWild * 0.62);
return clamp01(
baseStroke * (0.68 + mirrorAmount * 0.3) +
0.025 +
mirrorAmount * 0.045 +
maniaBoost
);
}
private getTouchPressure(pressure: number | undefined, pointerType?: string): number {
if (pressure !== undefined && Number.isFinite(pressure) && pressure > 0) {
return clamp01(pressure);
}
return pointerType === 'pen'
? Math.max(appConfig.audioEngine.input.penMinPressure, this.config.input.pressureFallback)
? Math.max(
this.engineConfig.input.penMinPressure,
this.config.input.pressureFallback
)
: this.config.input.pressureFallback;
}
}
const smoothstep = (edge0: number, edge1: number, value: number): number => {
const amount = clamp01((value - edge0) / (edge1 - edge0));
return amount * amount * (3 - 2 * amount);
};

View file

@ -1,5 +1,6 @@
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';
@ -7,9 +8,13 @@ import { GenerativePianoEngine } from './generative-piano';
const makeEngine = () => {
const notes: Array<PianoNote> = [];
const engine = new GenerativePianoEngine(gardenAudioConfig, (note) => {
notes.push(note);
});
const engine = new GenerativePianoEngine(
gardenAudioConfig,
appConfig.audioEngine,
(note) => {
notes.push(note);
}
);
return { engine, notes };
};
@ -17,7 +22,9 @@ const makeEngine = () => {
const getBeatSeconds = (): number => 60 / gardenAudioConfig.rhythm.bpm;
const getBeatsPerBar = (): number =>
Math.round(gardenAudioConfig.rhythm.stepsPerBar / gardenAudioConfig.rhythm.stepsPerBeat);
Math.round(
gardenAudioConfig.rhythm.stepsPerBar / gardenAudioConfig.rhythm.stepsPerBeat
);
const renderBars = (
engine: GenerativePianoEngine,
@ -45,9 +52,8 @@ const countNotesBetween = (
startSeconds: number,
endSeconds: number
): number =>
notes.filter(
(note) => note.startTime >= startSeconds && note.startTime < endSeconds
).length;
notes.filter((note) => note.startTime >= startSeconds && note.startTime < endSeconds)
.length;
describe('GenerativePianoEngine', () => {
it('plays quiet background music even when the garden is idle', () => {
@ -56,10 +62,8 @@ describe('GenerativePianoEngine', () => {
renderBars(engine, 0);
expect(notes.length).toBeGreaterThan(0);
expect(notes.some((note) => note.durationSeconds > getBeatSeconds() * 12)).toBe(
true
);
expect(Math.max(...notes.map((note) => note.velocity))).toBeLessThan(0.16);
expect(notes.some((note) => note.durationSeconds > getBeatSeconds() * 6)).toBe(true);
expect(Math.max(...notes.map((note) => note.velocity))).toBeLessThan(0.12);
});
it('keeps the background sparse instead of filling every beat', () => {

View file

@ -1,4 +1,4 @@
import { appConfig } from '../config';
import type { GardenAudioEngineConfig } from '../config';
import { clamp, clamp01 } from '../utils/clamp';
import { VibePreset } from '../vibes';
import {
@ -6,7 +6,11 @@ import {
GardenAudioConfig,
GardenAudioVibeProfile,
} from './garden-audio-config';
import { degreeToSemitone, getChordIntervals, getVibeProfile } from './garden-audio-music';
import {
degreeToSemitone,
getChordIntervals,
getVibeProfile,
} from './garden-audio-music';
import { GardenAudioColorIndex, PianoNote } from './garden-audio-types';
interface RenderLookaheadRequest {
@ -23,6 +27,13 @@ interface StrokeAccentRequest {
activity: number;
selectedColorIndex: GardenAudioColorIndex;
mirrorAmount?: number;
panBias?: number;
registerBias?: number;
brightnessBias?: number;
contour?: number;
pressureAmount?: number;
pressureDelta?: number;
maniaAmount?: number;
}
interface TouchDownRequest {
@ -31,6 +42,13 @@ interface TouchDownRequest {
strength: number;
selectedColorIndex: GardenAudioColorIndex;
mirrorAmount?: number;
panBias?: number;
registerBias?: number;
brightnessBias?: number;
contour?: number;
pressureAmount?: number;
pressureDelta?: number;
maniaAmount?: number;
}
interface Register {
@ -61,6 +79,14 @@ interface BrushPhraseLayer {
selectedColorIndex: GardenAudioColorIndex;
energy: number;
mirrorAmount: number;
motifOffsets: Array<number>;
panBias: number;
registerBias: number;
brightnessBias: number;
contour: number;
pressureAmount: number;
pressureDelta: number;
maniaAmount: number;
}
const COLOR_POOLS: [ColorPool, ColorPool, ColorPool] = [
@ -134,6 +160,9 @@ 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;
@ -154,22 +183,23 @@ export class GenerativePianoEngine {
public constructor(
private readonly config: GardenAudioConfig,
private readonly engineConfig: GardenAudioEngineConfig,
private readonly playNote: (note: PianoNote) => void
) {}
public prime(now: number): void {
if (this.nextBeatAt === null) {
this.nextBeatAt = now + appConfig.audioEngine.startDelaySeconds;
this.nextBeatAt = now + this.engineConfig.startDelaySeconds;
}
this.timelineStartedAt ??= now;
this.nextBrushStreamAt ??= now + appConfig.audioEngine.startDelaySeconds;
this.nextBrushStreamAt ??= now + this.engineConfig.startDelaySeconds;
}
public cue(now: number): void {
this.nextBeatAt = now + appConfig.audioEngine.startDelaySeconds;
this.nextBeatAt = now + this.engineConfig.startDelaySeconds;
this.timelineStartedAt = now;
this.beatIndex = 0;
this.nextBrushStreamAt = now + appConfig.audioEngine.startDelaySeconds;
this.nextBrushStreamAt = now + this.engineConfig.startDelaySeconds;
this.brushStreamNoteIndex = 0;
this.lastBrushStreamMidi = null;
}
@ -188,9 +218,25 @@ export class GenerativePianoEngine {
strength,
selectedColorIndex,
mirrorAmount = 0,
panBias = 0,
registerBias = 0,
brightnessBias = 0.5,
contour = 0,
pressureAmount = 0,
pressureDelta = 0,
maniaAmount = 0,
}: TouchDownRequest): void {
const normalizedStrength = clamp01(strength);
const normalizedMirrorAmount = clamp01(mirrorAmount);
const normalizedMotif = this.normalizeMotif({
panBias,
registerBias,
brightnessBias,
contour,
pressureAmount,
pressureDelta,
maniaAmount,
});
this.isWaitingForGestureAccent = false;
this.lastGestureAccentAt = now;
@ -201,8 +247,17 @@ export class GenerativePianoEngine {
strength: normalizedStrength,
selectedColorIndex,
mirrorAmount: normalizedMirrorAmount,
...normalizedMotif,
});
this.playTouchNote({
vibe,
now,
selectedColorIndex,
strength: normalizedStrength,
panBias: normalizedMotif.panBias,
registerBias: normalizedMotif.registerBias,
brightnessBias: normalizedMotif.brightnessBias,
});
this.playTouchNote(vibe, now, selectedColorIndex, normalizedStrength);
}
public recordStroke({
@ -211,9 +266,25 @@ export class GenerativePianoEngine {
activity,
selectedColorIndex,
mirrorAmount = 0,
panBias = 0,
registerBias = 0,
brightnessBias = 0.5,
contour = 0,
pressureAmount = 0,
pressureDelta = 0,
maniaAmount = 0,
}: StrokeAccentRequest): void {
const strength = clamp01(activity);
const normalizedMirrorAmount = clamp01(mirrorAmount);
const normalizedMotif = this.normalizeMotif({
panBias,
registerBias,
brightnessBias,
contour,
pressureAmount,
pressureDelta,
maniaAmount,
});
if (
this.isWaitingForGestureAccent &&
@ -225,11 +296,19 @@ export class GenerativePianoEngine {
strength,
selectedColorIndex,
mirrorAmount: normalizedMirrorAmount,
...normalizedMotif,
});
return;
}
this.isWaitingForGestureAccent = false;
this.updateBrushPhraseLayer({
now,
strength,
selectedColorIndex,
mirrorAmount: normalizedMirrorAmount,
...normalizedMotif,
});
if (
strength >= STROKE_ACCENT_THRESHOLD &&
now - this.lastStrokeAccentAt >= STROKE_ACCENT_MIN_INTERVAL_SECONDS
@ -385,22 +464,22 @@ 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 * 0.88;
const durationSeconds = this.getBarDurationSeconds() * CHORD_BARS * PAD_DURATION_BAR_SCALE;
const notes = [
{
source: { baseMidi: rootMidi, offsets: [0] },
register: PAD_REGISTERS[0],
velocity: 0.082,
velocity: 0.052,
},
{
source: { baseMidi: rootMidi, offsets: [intervals[1]] },
register: PAD_REGISTERS[1],
velocity: 0.064,
velocity: 0.041,
},
{
source: { baseMidi: rootMidi, offsets: [intervals[3], intervals[2]] },
register: PAD_REGISTERS[2],
velocity: 0.052,
velocity: 0.033,
},
];
@ -412,8 +491,8 @@ export class GenerativePianoEngine {
startTime,
durationSeconds,
pan: register.pan,
delaySend: 0.018,
lowpassHz: this.getLowpassHz(profile, midi, expression * 0.45),
delaySend: 0.008,
lowpassHz: this.getLowpassHz(profile, midi, expression * 0.28),
});
});
}
@ -519,7 +598,7 @@ export class GenerativePianoEngine {
this.config.colorVoices[selectedColorIndex].velocityMultiplier,
startTime:
now +
appConfig.audioEngine.startDelaySeconds +
this.engineConfig.startDelaySeconds +
index * GESTURE_ACCENT_SPACING_SECONDS,
durationSeconds: 0.48 + strength * 0.22,
pan: this.getColorPan(selectedColorIndex),
@ -529,14 +608,26 @@ export class GenerativePianoEngine {
}
}
private playTouchNote(
vibe: VibePreset,
now: number,
selectedColorIndex: GardenAudioColorIndex,
strength: number
): void {
private playTouchNote({
vibe,
now,
selectedColorIndex,
strength,
panBias,
registerBias,
brightnessBias,
}: {
vibe: VibePreset;
now: number;
selectedColorIndex: GardenAudioColorIndex;
strength: number;
panBias: number;
registerBias: number;
brightnessBias: number;
}): void {
const profile = getVibeProfile(this.config, vibe);
const pool = COLOR_POOLS[selectedColorIndex];
const register = this.getBiasedRegister(pool, registerBias, 0);
const chord = this.getChord(profile, this.getGlobalBarIndex(now));
const chordIntervals = getChordIntervals(chord, false);
const rootMidi = profile.rootMidi + chord.rootOffset;
@ -545,7 +636,7 @@ export class GenerativePianoEngine {
baseMidi: rootMidi,
offsets: this.getSupportOffsets(chordIntervals, selectedColorIndex),
},
pool,
register,
this.lastMidiByColor[selectedColorIndex],
true
);
@ -559,9 +650,13 @@ export class GenerativePianoEngine {
this.config.colorVoices[selectedColorIndex].velocityMultiplier,
startTime: now,
durationSeconds: 0.55 + strength * 0.18,
pan: this.getColorPan(selectedColorIndex),
pan: this.getLayerPan(selectedColorIndex, panBias, 0, 0),
delaySend: 0.006,
lowpassHz: this.getLowpassHz(profile, midi, clamp01(0.45 + strength * 0.45)),
lowpassHz: this.getLowpassHz(
profile,
midi,
clamp01(0.45 + strength * 0.35 + brightnessBias * 0.2)
),
});
}
@ -571,12 +666,26 @@ export class GenerativePianoEngine {
strength,
selectedColorIndex,
mirrorAmount,
panBias,
registerBias,
brightnessBias,
contour,
pressureAmount,
pressureDelta,
maniaAmount,
}: {
vibe: VibePreset;
now: number;
strength: number;
selectedColorIndex: GardenAudioColorIndex;
mirrorAmount: number;
panBias: number;
registerBias: number;
brightnessBias: number;
contour: number;
pressureAmount: number;
pressureDelta: number;
maniaAmount: number;
}): void {
const lifetimeSeconds =
BRUSH_LAYER_BASE_SECONDS +
@ -590,6 +699,18 @@ export class GenerativePianoEngine {
selectedColorIndex,
energy: strength,
mirrorAmount,
motifOffsets: this.getInitialMotifOffsets({
selectedColorIndex,
registerBias,
contour,
}),
panBias,
registerBias,
brightnessBias,
contour,
pressureAmount,
pressureDelta,
maniaAmount,
});
if (this.brushPhraseLayers.length > MAX_BRUSH_PHRASE_LAYERS) {
@ -597,6 +718,55 @@ export class GenerativePianoEngine {
}
}
private updateBrushPhraseLayer({
now,
strength,
selectedColorIndex,
mirrorAmount,
panBias,
registerBias,
brightnessBias,
contour,
pressureAmount,
pressureDelta,
maniaAmount,
}: {
now: number;
strength: number;
selectedColorIndex: GardenAudioColorIndex;
mirrorAmount: number;
panBias: number;
registerBias: number;
brightnessBias: number;
contour: number;
pressureAmount: number;
pressureDelta: number;
maniaAmount: number;
}): void {
const layer = this.brushPhraseLayers[this.brushPhraseLayers.length - 1];
if (!layer || layer.expiresAt <= now) {
return;
}
const followAmount = 0.24 + clamp01(strength) * 0.24;
layer.selectedColorIndex = selectedColorIndex;
layer.energy = Math.max(layer.energy * 0.94, strength);
layer.mirrorAmount = Math.max(layer.mirrorAmount * 0.96, mirrorAmount);
layer.panBias = mix(layer.panBias, panBias, followAmount);
layer.registerBias = mix(layer.registerBias, registerBias, followAmount);
layer.brightnessBias = mix(layer.brightnessBias, brightnessBias, followAmount);
layer.contour = mix(layer.contour, contour, followAmount);
layer.pressureAmount = mix(layer.pressureAmount, pressureAmount, followAmount);
layer.pressureDelta = pressureDelta;
layer.maniaAmount = Math.max(layer.maniaAmount * 0.92, maniaAmount);
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);
}
}
private renderBrushPhraseLayers({
vibe,
now,
@ -610,8 +780,8 @@ export class GenerativePianoEngine {
activity: number;
selectedColorIndex: GardenAudioColorIndex;
}): void {
const earliestStart = now + appConfig.audioEngine.piano.scheduleAheadSeconds;
this.nextBrushStreamAt ??= now + appConfig.audioEngine.startDelaySeconds;
const earliestStart = now + this.engineConfig.piano.scheduleAheadSeconds;
this.nextBrushStreamAt ??= now + this.engineConfig.startDelaySeconds;
this.brushPhraseLayers = this.brushPhraseLayers.filter(
(layer) => layer.expiresAt > earliestStart
@ -631,6 +801,7 @@ export class GenerativePianoEngine {
startTime: this.nextBrushStreamAt,
intensity: frame.intensity,
selectedColorIndex: frame.selectedColorIndex ?? selectedColorIndex,
layer: frame.layer,
});
}
this.nextBrushStreamAt += this.getBrushStreamIntervalSeconds(frame.intensity);
@ -643,14 +814,22 @@ export class GenerativePianoEngine {
startTime,
intensity,
selectedColorIndex,
layer,
}: {
vibe: VibePreset;
startTime: number;
intensity: number;
selectedColorIndex: GardenAudioColorIndex;
layer: BrushPhraseLayer | null;
}): void {
const profile = getVibeProfile(this.config, vibe);
const pool = COLOR_POOLS[selectedColorIndex];
const maniaAmount = layer?.maniaAmount ?? clamp01((intensity - 0.82) / 0.18);
const register = this.getBiasedRegister(
pool,
layer?.registerBias ?? 0,
maniaAmount * 0.45
);
const chord = this.getChord(profile, this.getGlobalBarIndex(startTime));
const chordIntervals = getChordIntervals(chord, false);
const rootMidi = profile.rootMidi + chord.rootOffset;
@ -662,12 +841,29 @@ export class GenerativePianoEngine {
}
: {
baseMidi: profile.rootMidi,
offsets: this.rotate(
pool.scaleDegrees,
this.brushStreamNoteIndex + selectedColorIndex
).map((degree) => degreeToSemitone(profile, degree)),
offsets: this.getBrushMotifDegrees({
layer,
pool,
selectedColorIndex,
}).map((degree) => degreeToSemitone(profile, degree)),
};
const midi = this.chooseMidi(source, pool, this.lastBrushStreamMidi, true);
const midi = this.chooseMidi(source, register, this.lastBrushStreamMidi, true);
const pan = this.getLayerPan(
selectedColorIndex,
layer?.panBias ?? 0,
maniaAmount,
layer?.mirrorAmount ?? 0
);
const durationSeconds = clamp(
0.48 + intensity * 0.08 - maniaAmount * 0.34,
0.14,
0.62
);
const delaySend = clamp(
0.012 + intensity * 0.011 + (layer?.mirrorAmount ?? 0) * 0.004 - maniaAmount * 0.006,
0.006,
0.032
);
this.lastBrushStreamMidi = midi;
this.lastMidiByColor[selectedColorIndex] = midi;
@ -677,11 +873,38 @@ export class GenerativePianoEngine {
(0.1 + intensity * 0.13) *
this.config.colorVoices[selectedColorIndex].velocityMultiplier,
startTime,
durationSeconds: 0.42 + intensity * 0.22,
pan: this.getColorPan(selectedColorIndex),
delaySend: 0.012 + intensity * 0.01,
lowpassHz: this.getLowpassHz(profile, midi, clamp01(0.35 + intensity * 0.65)),
durationSeconds,
pan,
delaySend,
lowpassHz: this.getLowpassHz(
profile,
midi,
clamp01(
0.32 +
intensity * 0.48 +
(layer?.brightnessBias ?? 0.5) * 0.14 +
maniaAmount * 0.18
)
),
});
if (maniaAmount >= 0.62 && (this.brushStreamNoteIndex % 2 === 1 || intensity >= 0.9)) {
const echoMidi = midi + 12 <= 88 ? midi + 12 : midi - 12;
this.playNote({
midi: echoMidi,
velocity:
(0.045 + intensity * 0.05) *
this.config.colorVoices[selectedColorIndex].velocityMultiplier,
startTime:
startTime +
BRUSH_MOTIF_CANON_DELAY_SECONDS +
(layer?.mirrorAmount ?? 0) * 0.04,
durationSeconds: Math.max(0.11, durationSeconds * 0.68),
pan: clamp(-pan * 0.75, -1, 1),
delaySend: Math.max(0.006, delaySend * 0.72),
lowpassHz: this.getLowpassHz(profile, echoMidi, 0.62 + maniaAmount * 0.24),
});
}
}
private getBrushStreamFrame(
@ -690,17 +913,19 @@ export class GenerativePianoEngine {
): {
intensity: number;
selectedColorIndex: GardenAudioColorIndex | null;
layer: BrushPhraseLayer | null;
} {
const layerStates = this.brushPhraseLayers.map((layer) => ({
layer,
intensity:
layer.energy *
this.getBrushPhraseFade(layer, startTime) *
(0.8 + layer.mirrorAmount * 0.45),
(0.8 + layer.mirrorAmount * 0.45 + layer.maniaAmount * 0.42),
}));
const dominant = layerStates.reduce<
{ layer: BrushPhraseLayer; intensity: number } | null
>((best, state) => {
const dominant = layerStates.reduce<{
layer: BrushPhraseLayer;
intensity: number;
} | null>((best, state) => {
if (state.intensity <= 0) {
return best;
}
@ -712,8 +937,11 @@ export class GenerativePianoEngine {
);
return {
intensity: clamp01(activity * 0.45 + layeredIntensity),
intensity: clamp01(
activity * 0.42 + layeredIntensity + (dominant?.layer.maniaAmount ?? 0) * 0.18
),
selectedColorIndex: dominant?.layer.selectedColorIndex ?? null,
layer: dominant?.layer ?? null,
};
}
@ -735,6 +963,142 @@ export class GenerativePianoEngine {
return clamp01(1 - ageSeconds / Math.max(0.001, lifetimeSeconds));
}
private normalizeMotif({
panBias,
registerBias,
brightnessBias,
contour,
pressureAmount,
pressureDelta,
maniaAmount,
}: {
panBias: number;
registerBias: number;
brightnessBias: number;
contour: number;
pressureAmount: number;
pressureDelta: number;
maniaAmount: number;
}): {
panBias: number;
registerBias: number;
brightnessBias: number;
contour: number;
pressureAmount: number;
pressureDelta: number;
maniaAmount: number;
} {
return {
panBias: clamp(panBias, -1, 1),
registerBias: clamp(registerBias, -1, 1),
brightnessBias: clamp01(brightnessBias),
contour: clamp(contour, -1, 1),
pressureAmount: clamp01(pressureAmount),
pressureDelta: clamp(pressureDelta, -1, 1),
maniaAmount: clamp01(maniaAmount),
};
}
private getInitialMotifOffsets({
selectedColorIndex,
registerBias,
contour,
}: {
selectedColorIndex: GardenAudioColorIndex;
registerBias: number;
contour: number;
}): Array<number> {
const start = selectedColorIndex - 1 + Math.round(registerBias);
const motion = contour > 0.2 ? 1 : contour < -0.2 ? -1 : 0;
return [start, start + motion, start + motion * 2, start + motion];
}
private getMotifOffset({
registerBias,
contour,
pressureDelta,
strength,
}: {
registerBias: number;
contour: number;
pressureDelta: number;
strength: number;
}): number {
const contourStep = contour > 0.3 ? 1 : contour < -0.3 ? -1 : 0;
const registerStep = Math.round(registerBias * 2);
const pressureStep = pressureDelta > 0.08 ? 1 : pressureDelta < -0.08 ? -1 : 0;
const energyStep = strength >= 0.82 ? 1 : strength >= 0.55 ? 0 : -1;
return clamp(contourStep + registerStep + pressureStep + energyStep, -3, 4);
}
private getBrushMotifDegrees({
layer,
pool,
selectedColorIndex,
}: {
layer: BrushPhraseLayer | null;
pool: ColorPool;
selectedColorIndex: GardenAudioColorIndex;
}): Array<number> {
const colorOffset = this.config.colorVoices[selectedColorIndex].scaleDegreeOffset;
if (!layer || layer.motifOffsets.length === 0) {
return this.rotate(pool.scaleDegrees, this.brushStreamNoteIndex + colorOffset);
}
const motifOffset =
layer.motifOffsets[this.brushStreamNoteIndex % layer.motifOffsets.length];
const contourOffset =
layer.contour > 0.28
? this.brushStreamNoteIndex % 3
: layer.contour < -0.28
? -(this.brushStreamNoteIndex % 3)
: 0;
const pressureLift = layer.pressureAmount > 0.68 ? 1 : 0;
const baseOffset = colorOffset + motifOffset + contourOffset + pressureLift;
return this.rotate(
pool.scaleDegrees.map((degree) => degree + baseOffset),
this.brushStreamNoteIndex
);
}
private getBiasedRegister(
register: Register,
registerBias: number,
maniaAmount: number
): Register {
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);
return {
midiMin,
midiMax,
preferredMidi: clamp(register.preferredMidi + shift, midiMin, midiMax),
pan: register.pan,
};
}
private getLayerPan(
selectedColorIndex: GardenAudioColorIndex,
panBias: number,
maniaAmount: number,
mirrorAmount: number
): number {
const shimmer =
maniaAmount > 0.4
? Math.sin(this.brushStreamNoteIndex * Math.PI * 0.5) * mirrorAmount * 0.14
: 0;
return clamp(
this.getColorPan(selectedColorIndex) +
panBias * (0.18 + maniaAmount * 0.42) +
shimmer,
-1,
1
);
}
private chooseMidi(
pitchSource: PitchSource,
register: Register,
@ -822,10 +1186,7 @@ export class GenerativePianoEngine {
return [chordIntervals[2], 12, chordIntervals[3], chordIntervals[1] + 12];
}
private getChord(
profile: GardenAudioVibeProfile,
barIndex: number
): GardenAudioChord {
private getChord(profile: GardenAudioVibeProfile, barIndex: number): GardenAudioChord {
const progressionIndex =
Math.floor(barIndex / CHORD_BARS) % profile.progression.length;
return profile.progression[progressionIndex];
@ -852,8 +1213,8 @@ export class GenerativePianoEngine {
return clamp(
this.config.piano.lowpassHz * profile.brightness * (0.58 + expression * 0.32) +
midiLift,
appConfig.audioEngine.piano.lowpassMinHz,
appConfig.audioEngine.piano.lowpassMaxHz
this.engineConfig.piano.lowpassMinHz,
this.engineConfig.piano.lowpassMaxHz
);
}
@ -862,7 +1223,7 @@ export class GenerativePianoEngine {
return;
}
const earliestStart = now + appConfig.audioEngine.piano.scheduleAheadSeconds;
const earliestStart = now + this.engineConfig.piano.scheduleAheadSeconds;
if (this.nextBeatAt >= earliestStart) {
return;
}
@ -898,3 +1259,6 @@ export class GenerativePianoEngine {
return values.map((_, index) => values[(index + offset) % values.length]);
}
}
const mix = (from: number, to: number, amount: number): number =>
from + (to - from) * clamp01(amount);

View file

@ -28,10 +28,7 @@ export class NoiseBurstPlayer {
filter.type = 'bandpass';
filter.frequency.setValueAtTime(filterHz, scheduledStart);
filter.Q.value = this.engineConfig.noiseBurst.filterQ;
envelope.gain.setValueAtTime(
this.engineConfig.noiseBurst.silentGain,
scheduledStart
);
envelope.gain.setValueAtTime(this.engineConfig.noiseBurst.silentGain, scheduledStart);
envelope.gain.exponentialRampToValueAtTime(
Math.max(this.engineConfig.noiseBurst.silentGain, gain),
scheduledStart + this.engineConfig.noiseBurst.attackSeconds

View file

@ -58,15 +58,6 @@ export class PianoSampler {
const sample = this.findNearestSample(midi);
if (!sample) {
this.playFallbackPluck({
midi,
velocity,
startTime,
durationSeconds,
pan,
delaySend,
lowpassHz,
});
return;
}
@ -84,7 +75,8 @@ export class PianoSampler {
(this.engineConfig.piano.sustainBase +
noteVelocity * this.engineConfig.piano.sustainVelocityRange);
const sustainAt =
scheduledStart + Math.max(this.engineConfig.piano.minDurationSeconds, durationSeconds);
scheduledStart +
Math.max(this.engineConfig.piano.minDurationSeconds, durationSeconds);
const releaseAt = sustainAt + sustainSeconds;
const releaseSeconds = this.config.piano.releaseSeconds;
const stopAt = releaseAt + releaseSeconds;
@ -108,10 +100,7 @@ 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.engineConfig.piano.pitchSemitonesPerOctave),
scheduledStart
);
filter.type = 'lowpass';
@ -140,11 +129,7 @@ export class PianoSampler {
sustainSeconds * this.engineConfig.piano.sustainBase
)
);
gain.gain.setTargetAtTime(
this.engineConfig.piano.minGain,
releaseAt,
releaseSeconds
);
gain.gain.setTargetAtTime(this.engineConfig.piano.minGain, releaseAt, releaseSeconds);
panner.pan.setValueAtTime(clamp(pan, -1, 1), scheduledStart);
source.connect(filter);
@ -196,90 +181,4 @@ export class PianoSampler {
private trimActiveVoices(now: number): void {
this.activeVoices = this.activeVoices.filter((voice) => voice.stopAt > now);
}
private playFallbackPluck({
midi,
velocity,
startTime,
durationSeconds,
pan,
delaySend = 0,
lowpassHz = this.config.piano.lowpassHz,
}: PianoNote): void {
const { context, eventBus, delayInput } = this.graph;
if (!context || !eventBus) {
return;
}
const scheduledStart = Math.max(
context.currentTime + this.engineConfig.piano.scheduleAheadSeconds,
startTime
);
const oscillator = context.createOscillator();
const filter = context.createBiquadFilter();
const gain = context.createGain();
const panner = context.createStereoPanner();
let sendGain: GainNode | null = null;
const noteVelocity = clamp01(velocity);
const noteGainValue = Math.max(
this.engineConfig.piano.minGain,
this.config.piano.gain * noteVelocity * 0.42
);
const releaseAt =
scheduledStart + Math.max(this.engineConfig.piano.minDurationSeconds, durationSeconds);
const stopAt = releaseAt + this.config.piano.releaseSeconds;
oscillator.type = 'triangle';
oscillator.frequency.setValueAtTime(
440 * Math.pow(2, (midi - 69) / appConfig.audioEngine.piano.pitchSemitonesPerOctave),
scheduledStart
);
filter.type = 'lowpass';
filter.frequency.setValueAtTime(
clamp(
lowpassHz * 0.72,
this.engineConfig.piano.lowpassMinHz,
this.engineConfig.piano.lowpassMaxHz
),
scheduledStart
);
filter.Q.value = this.engineConfig.piano.filterQ;
gain.gain.setValueAtTime(this.engineConfig.piano.minGain, scheduledStart);
gain.gain.exponentialRampToValueAtTime(
noteGainValue,
scheduledStart + this.engineConfig.piano.gainAttackSeconds
);
gain.gain.setTargetAtTime(
this.engineConfig.piano.minGain,
releaseAt,
this.config.piano.releaseSeconds
);
panner.pan.setValueAtTime(clamp(pan, -1, 1), scheduledStart);
oscillator.connect(filter);
filter.connect(gain);
gain.connect(panner);
panner.connect(eventBus);
if (delayInput && delaySend > 0) {
sendGain = context.createGain();
sendGain.gain.value = delaySend * 0.5;
panner.connect(sendGain);
sendGain.connect(delayInput);
}
oscillator.start(scheduledStart);
oscillator.stop(stopAt + this.engineConfig.piano.tailStopExtraSeconds);
oscillator.addEventListener(
'ended',
() => {
oscillator.disconnect();
filter.disconnect();
gain.disconnect();
panner.disconnect();
sendGain?.disconnect();
},
{ once: true }
);
}
}