This commit is contained in:
parent
2347ecd201
commit
10a81ba474
45 changed files with 1974 additions and 490 deletions
|
|
@ -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);
|
||||
|
|
|
|||
385
src/audio/garden-audio-gesture-state.ts
Normal file
385
src/audio/garden-audio-gesture-state.ts
Normal 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);
|
||||
};
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue