This commit is contained in:
parent
39b0160064
commit
2347ecd201
71 changed files with 3779 additions and 1586 deletions
|
|
@ -1,13 +1,13 @@
|
|||
import { appConfig } from '../config';
|
||||
|
||||
export type GardenAudioChordQuality = 'major' | 'minor';
|
||||
type GardenAudioChordQuality = 'major' | 'minor';
|
||||
|
||||
export interface GardenAudioChord {
|
||||
rootOffset: number;
|
||||
quality: GardenAudioChordQuality;
|
||||
}
|
||||
|
||||
export interface GardenAudioColorVoice {
|
||||
interface GardenAudioColorVoice {
|
||||
scaleDegreeOffset: number;
|
||||
velocityMultiplier: number;
|
||||
panOffset: number;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { appConfig } from '../config';
|
||||
import type { GardenAudioEngineConfig } from '../config';
|
||||
import { clamp01 } from '../utils/clamp';
|
||||
|
||||
const STROKE_IMMEDIATE_ACTIVITY_SCALE = 0.85;
|
||||
|
|
@ -9,6 +9,8 @@ export class GardenAudioEnergy {
|
|||
private targetEnergy = 0;
|
||||
private lastEnergyUpdateAt = 0;
|
||||
|
||||
public constructor(private readonly engineConfig: GardenAudioEngineConfig) {}
|
||||
|
||||
public beginGesture(now: number): void {
|
||||
this.isGestureActive = true;
|
||||
this.lastEnergyUpdateAt = now;
|
||||
|
|
@ -46,15 +48,15 @@ export class GardenAudioEnergy {
|
|||
const elapsedSeconds = Math.max(0, now - this.lastEnergyUpdateAt);
|
||||
this.lastEnergyUpdateAt = now;
|
||||
this.targetEnergy *= Math.exp(
|
||||
-elapsedSeconds / appConfig.audioEngine.energy.strokeDecaySeconds
|
||||
-elapsedSeconds / this.engineConfig.energy.strokeDecaySeconds
|
||||
);
|
||||
|
||||
const target = this.isGestureActive ? this.targetEnergy : 0;
|
||||
let timeConstant = appConfig.audioEngine.energy.decaySeconds;
|
||||
let timeConstant = this.engineConfig.energy.decaySeconds;
|
||||
if (!this.isGestureActive) {
|
||||
timeConstant = appConfig.audioEngine.energy.releaseSeconds;
|
||||
timeConstant = this.engineConfig.energy.releaseSeconds;
|
||||
} else if (target > this.energy) {
|
||||
timeConstant = appConfig.audioEngine.energy.attackSeconds;
|
||||
timeConstant = this.engineConfig.energy.attackSeconds;
|
||||
}
|
||||
const amount = 1 - Math.exp(-elapsedSeconds / timeConstant);
|
||||
this.energy += (target - this.energy) * amount;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { appConfig } from '../config';
|
||||
import type { GardenAudioEngineConfig } from '../config';
|
||||
import { clamp } from '../utils/clamp';
|
||||
import { GardenAudioConfig, GardenAudioVibeProfile } from './garden-audio-config';
|
||||
|
||||
|
|
@ -14,7 +14,10 @@ export class GardenAudioGraph {
|
|||
private delayOutput: GainNode | null = null;
|
||||
private hasUnlocked = false;
|
||||
|
||||
public constructor(private readonly config: GardenAudioConfig) {}
|
||||
public constructor(
|
||||
private readonly config: GardenAudioConfig,
|
||||
private readonly engineConfig: GardenAudioEngineConfig
|
||||
) {}
|
||||
|
||||
public ensureContext(canCreate: boolean): AudioContext | null {
|
||||
if (this.context) {
|
||||
|
|
@ -62,8 +65,8 @@ export class GardenAudioGraph {
|
|||
|
||||
const buffer = this.context.createBuffer(
|
||||
1,
|
||||
appConfig.audioEngine.graph.unlockBufferLength,
|
||||
appConfig.audioEngine.graph.unlockSampleRate
|
||||
this.engineConfig.graph.unlockBufferLength,
|
||||
this.engineConfig.graph.unlockSampleRate
|
||||
);
|
||||
const source = this.context.createBufferSource();
|
||||
source.buffer = buffer;
|
||||
|
|
@ -92,7 +95,7 @@ export class GardenAudioGraph {
|
|||
this.delayNode.delayTime.setTargetAtTime(
|
||||
this.config.delay.timeSeconds * profile.delayTimeMultiplier,
|
||||
this.context.currentTime,
|
||||
appConfig.audioEngine.graph.delayTimeRampSeconds
|
||||
this.engineConfig.graph.delayTimeRampSeconds
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -105,22 +108,22 @@ export class GardenAudioGraph {
|
|||
this.delayNode.delayTime.setTargetAtTime(
|
||||
this.config.delay.timeSeconds * profile.delayTimeMultiplier,
|
||||
now,
|
||||
appConfig.audioEngine.graph.delayTimeRampSeconds
|
||||
this.engineConfig.graph.delayTimeRampSeconds
|
||||
);
|
||||
this.delayFeedback.gain.setTargetAtTime(
|
||||
clamp(
|
||||
this.config.delay.feedback +
|
||||
activity * appConfig.audioEngine.graph.delayActivityFeedbackWeight,
|
||||
appConfig.audioEngine.graph.delayFeedbackMin,
|
||||
appConfig.audioEngine.graph.delayFeedbackMax
|
||||
activity * this.engineConfig.graph.delayActivityFeedbackWeight,
|
||||
this.engineConfig.graph.delayFeedbackMin,
|
||||
this.engineConfig.graph.delayFeedbackMax
|
||||
),
|
||||
now,
|
||||
this.config.updateRampSeconds
|
||||
);
|
||||
this.delayOutput.gain.setTargetAtTime(
|
||||
this.config.delay.wetGain *
|
||||
(appConfig.audioEngine.graph.delayOutputBase +
|
||||
activity * appConfig.audioEngine.graph.delayOutputActivityWeight),
|
||||
(this.engineConfig.graph.delayOutputBase +
|
||||
activity * this.engineConfig.graph.delayOutputActivityWeight),
|
||||
now,
|
||||
this.config.updateRampSeconds
|
||||
);
|
||||
|
|
@ -134,9 +137,9 @@ export class GardenAudioGraph {
|
|||
|
||||
if (this.masterGain && context.state !== 'closed') {
|
||||
this.masterGain.gain.setTargetAtTime(
|
||||
appConfig.audioEngine.graph.closeGain,
|
||||
this.engineConfig.graph.closeGain,
|
||||
context.currentTime,
|
||||
appConfig.audioEngine.graph.closeRampSeconds
|
||||
this.engineConfig.graph.closeRampSeconds
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -171,7 +174,7 @@ export class GardenAudioGraph {
|
|||
|
||||
private createBuses(context: AudioContext, masterGain: GainNode): void {
|
||||
this.eventBus = context.createGain();
|
||||
this.eventBus.gain.value = appConfig.audioEngine.graph.eventBusGain;
|
||||
this.eventBus.gain.value = this.engineConfig.graph.eventBusGain;
|
||||
this.eventBus.connect(masterGain);
|
||||
}
|
||||
|
||||
|
|
@ -181,9 +184,9 @@ export class GardenAudioGraph {
|
|||
|
||||
for (let index = 0; index < data.length; index++) {
|
||||
data[index] =
|
||||
appConfig.audioEngine.graph.noiseMin +
|
||||
this.engineConfig.graph.noiseMin +
|
||||
Math.random() *
|
||||
(appConfig.audioEngine.graph.noiseMax - appConfig.audioEngine.graph.noiseMin);
|
||||
(this.engineConfig.graph.noiseMax - this.engineConfig.graph.noiseMin);
|
||||
}
|
||||
|
||||
return buffer;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { appConfig } from '../config';
|
||||
import type { GardenAudioEngineConfig } from '../config';
|
||||
import { clamp01 } from '../utils/clamp';
|
||||
import { GardenAudioStroke } from './garden-audio-types';
|
||||
|
||||
|
|
@ -12,24 +12,25 @@ export interface GardenAudioStrokeMetrics {
|
|||
export const getStrokeMetrics = (
|
||||
stroke: GardenAudioStroke,
|
||||
speedForFullEnergyPixelsPerSecond: number,
|
||||
fallbackPressure: number
|
||||
fallbackPressure: number,
|
||||
inputConfig: GardenAudioEngineConfig['input']
|
||||
): GardenAudioStrokeMetrics => {
|
||||
const dx = stroke.to[0] - stroke.from[0];
|
||||
const dy = stroke.to[1] - stroke.from[1];
|
||||
const distancePixels = Math.hypot(dx, dy);
|
||||
const speedPixelsPerSecond = getStrokeVelocity(stroke, distancePixels);
|
||||
const pressure = getPressureAmount(stroke, fallbackPressure);
|
||||
const speedPixelsPerSecond = getStrokeVelocity(stroke, distancePixels, inputConfig);
|
||||
const pressure = getPressureAmount(stroke, fallbackPressure, inputConfig);
|
||||
const speedAmount = clamp01(speedPixelsPerSecond / speedForFullEnergyPixelsPerSecond);
|
||||
const strokeEnergy = clamp01(
|
||||
appConfig.audioEngine.input.strokeEnergyBase +
|
||||
speedAmount * appConfig.audioEngine.input.strokeEnergySpeedWeight +
|
||||
pressure * appConfig.audioEngine.input.strokeEnergyPressureWeight
|
||||
inputConfig.strokeEnergyBase +
|
||||
speedAmount * inputConfig.strokeEnergySpeedWeight +
|
||||
pressure * inputConfig.strokeEnergyPressureWeight
|
||||
);
|
||||
const effectiveEnergy =
|
||||
strokeEnergy *
|
||||
(appConfig.audioEngine.input.distanceEnergyBase +
|
||||
clamp01(distancePixels / appConfig.audioEngine.input.distanceForFullEnergyPixels) *
|
||||
appConfig.audioEngine.input.distanceEnergyScale);
|
||||
(inputConfig.distanceEnergyBase +
|
||||
clamp01(distancePixels / inputConfig.distanceForFullEnergyPixels) *
|
||||
inputConfig.distanceEnergyScale);
|
||||
|
||||
return {
|
||||
distancePixels,
|
||||
|
|
@ -39,7 +40,11 @@ export const getStrokeMetrics = (
|
|||
};
|
||||
};
|
||||
|
||||
const getStrokeVelocity = (stroke: GardenAudioStroke, distancePixels: number): number => {
|
||||
const getStrokeVelocity = (
|
||||
stroke: GardenAudioStroke,
|
||||
distancePixels: number,
|
||||
inputConfig: GardenAudioEngineConfig['input']
|
||||
): number => {
|
||||
if (
|
||||
stroke.velocityPixelsPerSecond !== undefined &&
|
||||
Number.isFinite(stroke.velocityPixelsPerSecond) &&
|
||||
|
|
@ -48,12 +53,13 @@ const getStrokeVelocity = (stroke: GardenAudioStroke, distancePixels: number): n
|
|||
return stroke.velocityPixelsPerSecond;
|
||||
}
|
||||
|
||||
return distancePixels / appConfig.audioEngine.input.fallbackFrameSeconds;
|
||||
return distancePixels / inputConfig.fallbackFrameSeconds;
|
||||
};
|
||||
|
||||
const getPressureAmount = (
|
||||
stroke: GardenAudioStroke,
|
||||
fallbackPressure: number
|
||||
fallbackPressure: number,
|
||||
inputConfig: GardenAudioEngineConfig['input']
|
||||
): number => {
|
||||
if (
|
||||
stroke.pressure !== undefined &&
|
||||
|
|
@ -64,6 +70,6 @@ const getPressureAmount = (
|
|||
}
|
||||
|
||||
return stroke.pointerType === 'pen'
|
||||
? Math.max(appConfig.audioEngine.input.penMinPressure, clamp01(fallbackPressure))
|
||||
? Math.max(inputConfig.penMinPressure, clamp01(fallbackPressure))
|
||||
: clamp01(fallbackPressure);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import { clamp } from '../utils/clamp';
|
||||
import { VibePreset } from '../vibes';
|
||||
import {
|
||||
GardenAudioChord,
|
||||
|
|
@ -10,9 +9,6 @@ import { GardenAudioColorIndex } from './garden-audio-types';
|
|||
export const normalizeColorIndex = (index: number): GardenAudioColorIndex =>
|
||||
Math.max(0, Math.min(2, Math.round(index))) as GardenAudioColorIndex;
|
||||
|
||||
export const clampMidi = (midi: number, min: number, max: number): number =>
|
||||
Math.round(clamp(midi, min, max));
|
||||
|
||||
export const getVibeProfile = (
|
||||
config: GardenAudioConfig,
|
||||
vibe: VibePreset
|
||||
|
|
@ -21,15 +17,6 @@ export const getVibeProfile = (
|
|||
config.vibes[config.fallbackVibeId] ??
|
||||
Object.values(config.vibes)[0];
|
||||
|
||||
export const getChordAtStep = (
|
||||
config: GardenAudioConfig,
|
||||
profile: GardenAudioVibeProfile,
|
||||
stepIndex: number
|
||||
): GardenAudioChord => {
|
||||
const barIndex = Math.floor(stepIndex / config.rhythm.stepsPerBar);
|
||||
return profile.progression[barIndex % profile.progression.length];
|
||||
};
|
||||
|
||||
export const getChordIntervals = (
|
||||
chord: GardenAudioChord,
|
||||
openVoicing: boolean
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ export interface GardenAudioSnapshot {
|
|||
vibe: VibePreset;
|
||||
selectedColorIndex: number;
|
||||
isErasing: boolean;
|
||||
mirrorSegmentCount?: number;
|
||||
}
|
||||
|
||||
export interface GardenAudioStroke {
|
||||
|
|
@ -18,6 +19,15 @@ export interface GardenAudioStroke {
|
|||
pressure?: number;
|
||||
velocityPixelsPerSecond?: number;
|
||||
eraserSizePixels?: number;
|
||||
mirrorSegmentCount?: number;
|
||||
pointerType?: string;
|
||||
}
|
||||
|
||||
export interface GardenAudioTouchDown {
|
||||
vibe: VibePreset;
|
||||
colorIndex: number;
|
||||
mirrorSegmentCount?: number;
|
||||
pressure?: number;
|
||||
pointerType?: string;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,13 +4,14 @@ import { VibePreset } from '../vibes';
|
|||
import { GardenAudioConfig } from './garden-audio-config';
|
||||
import { GardenAudioEnergy } from './garden-audio-energy';
|
||||
import { GardenAudioGraph } from './garden-audio-graph';
|
||||
import { getStrokeMetrics } from './garden-audio-input';
|
||||
import { GardenAudioStrokeMetrics, getStrokeMetrics } from './garden-audio-input';
|
||||
import { getVibeProfile, normalizeColorIndex } from './garden-audio-music';
|
||||
import type {
|
||||
GardenAudioColorIndex,
|
||||
GardenAudioSnapshot,
|
||||
GardenAudioStartOptions,
|
||||
GardenAudioStroke,
|
||||
GardenAudioTouchDown,
|
||||
} from './garden-audio-types';
|
||||
import { GenerativePianoEngine } from './generative-piano';
|
||||
import { NoiseBurstPlayer } from './noise-burst-player';
|
||||
|
|
@ -20,6 +21,7 @@ export type {
|
|||
GardenAudioSnapshot,
|
||||
GardenAudioStartOptions,
|
||||
GardenAudioStroke,
|
||||
GardenAudioTouchDown,
|
||||
} from './garden-audio-types';
|
||||
|
||||
export class GardenAudio {
|
||||
|
|
@ -35,6 +37,7 @@ export class GardenAudio {
|
|||
private isMuted = false;
|
||||
private isGestureActive = false;
|
||||
private selectedColorIndex: GardenAudioColorIndex = 0;
|
||||
private hasQueuedPianoLoad = false;
|
||||
private lastEraserAt = Number.NEGATIVE_INFINITY;
|
||||
private lastVibeStingerAt = Number.NEGATIVE_INFINITY;
|
||||
|
||||
|
|
@ -70,9 +73,21 @@ export class GardenAudio {
|
|||
this.hasStarted = true;
|
||||
this.applyVibe(vibe);
|
||||
this.pianoEngine.prime(context.currentTime);
|
||||
this.graph.setMasterGain(this.config.masterVolume, this.config.fadeInSeconds);
|
||||
this.graph.setMasterGain(
|
||||
this.config.masterVolume,
|
||||
options.userGesture === true
|
||||
? appConfig.audioEngine.muteRampSeconds
|
||||
: this.config.fadeInSeconds
|
||||
);
|
||||
|
||||
void this.piano.load(context);
|
||||
if (!this.hasQueuedPianoLoad) {
|
||||
this.hasQueuedPianoLoad = true;
|
||||
void this.piano.load(context).then(() => {
|
||||
if (this.graph.context === context && !this.isDestroyed) {
|
||||
this.pianoEngine.cue(context.currentTime);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public changeVibe(vibe: VibePreset, options: GardenAudioStartOptions = {}): void {
|
||||
|
|
@ -108,19 +123,38 @@ export class GardenAudio {
|
|||
|
||||
this.isGestureActive = true;
|
||||
this.energy.beginGesture(context.currentTime);
|
||||
this.pianoEngine.beginGesture(context.currentTime);
|
||||
this.pianoEngine.beginGesture();
|
||||
}
|
||||
|
||||
public endGesture(): void {
|
||||
const context = this.graph.context;
|
||||
this.isGestureActive = false;
|
||||
this.energy.endGesture();
|
||||
this.pianoEngine.endGesture();
|
||||
if (!context) {
|
||||
}
|
||||
|
||||
public touchDown(touch: GardenAudioTouchDown): void {
|
||||
if (this.isDestroyed || this.isMuted) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.piano.fadeActive(context.currentTime, appConfig.audioEngine.gestureFadeSeconds);
|
||||
const context = this.graph.context;
|
||||
if (!context || !this.isGestureActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.selectedColorIndex = normalizeColorIndex(touch.colorIndex);
|
||||
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);
|
||||
|
||||
this.energy.recordStroke(strength, context.currentTime);
|
||||
this.pianoEngine.recordTouchDown({
|
||||
vibe: touch.vibe,
|
||||
now: context.currentTime,
|
||||
strength,
|
||||
selectedColorIndex: this.selectedColorIndex,
|
||||
mirrorAmount,
|
||||
});
|
||||
}
|
||||
|
||||
public update(snapshot: GardenAudioSnapshot): void {
|
||||
|
|
@ -135,22 +169,14 @@ export class GardenAudio {
|
|||
|
||||
if (snapshot.isErasing) {
|
||||
this.energy.silence();
|
||||
this.piano.fadeActive(
|
||||
context.currentTime,
|
||||
appConfig.audioEngine.gestureFadeSeconds
|
||||
);
|
||||
this.updateDelay(snapshot);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.isGestureActive) {
|
||||
this.pianoEngine.renderLookahead({
|
||||
vibe: snapshot.vibe,
|
||||
now: context.currentTime,
|
||||
activity: this.energy.getActivity(),
|
||||
selectedColorIndex: this.selectedColorIndex,
|
||||
});
|
||||
}
|
||||
this.pianoEngine.renderLookahead({
|
||||
vibe: snapshot.vibe,
|
||||
now: context.currentTime,
|
||||
activity: snapshot.isErasing ? 0 : this.energy.getLevel(),
|
||||
selectedColorIndex: this.selectedColorIndex,
|
||||
});
|
||||
this.updateDelay(snapshot);
|
||||
}
|
||||
|
||||
|
|
@ -183,9 +209,16 @@ export class GardenAudio {
|
|||
return;
|
||||
}
|
||||
|
||||
const strokeEnergy = metrics.effectiveEnergy;
|
||||
const mirrorAmount = this.getMirrorAmount(stroke.mirrorSegmentCount ?? 1);
|
||||
const strokeEnergy = this.getStrokeMusicActivity(stroke, metrics, mirrorAmount);
|
||||
this.energy.recordStroke(strokeEnergy, now);
|
||||
this.pianoEngine.wake(now);
|
||||
this.pianoEngine.recordStroke({
|
||||
vibe: stroke.vibe,
|
||||
now,
|
||||
activity: strokeEnergy,
|
||||
selectedColorIndex: this.selectedColorIndex,
|
||||
mirrorAmount,
|
||||
});
|
||||
}
|
||||
|
||||
public async destroy(): Promise<void> {
|
||||
|
|
@ -199,6 +232,7 @@ export class GardenAudio {
|
|||
this.hasStarted = false;
|
||||
this.isGestureActive = false;
|
||||
this.selectedColorIndex = 0;
|
||||
this.hasQueuedPianoLoad = false;
|
||||
this.lastEraserAt = Number.NEGATIVE_INFINITY;
|
||||
this.lastVibeStingerAt = Number.NEGATIVE_INFINITY;
|
||||
}
|
||||
|
|
@ -285,5 +319,62 @@ export class GardenAudio {
|
|||
|
||||
this.currentVibeId = vibe.id;
|
||||
this.graph.applyDelayProfile(getVibeProfile(this.config, vibe));
|
||||
this.pianoEngine.cue(this.graph.context.currentTime);
|
||||
}
|
||||
|
||||
private getMirrorAmount(mirrorSegmentCount: number): number {
|
||||
const maxMirrorSegmentCount = Math.max(1, appConfig.simulation.maxMirrorSegmentCount);
|
||||
const segmentCount = clamp(
|
||||
Number.isFinite(mirrorSegmentCount) ? mirrorSegmentCount : 1,
|
||||
1,
|
||||
maxMirrorSegmentCount
|
||||
);
|
||||
|
||||
if (maxMirrorSegmentCount <= 1) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
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)
|
||||
: 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);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -14,104 +14,220 @@ const makeEngine = () => {
|
|||
return { engine, notes };
|
||||
};
|
||||
|
||||
const getBeatSeconds = (): number => 60 / gardenAudioConfig.rhythm.bpm;
|
||||
|
||||
const getBeatsPerBar = (): number =>
|
||||
Math.round(gardenAudioConfig.rhythm.stepsPerBar / gardenAudioConfig.rhythm.stepsPerBeat);
|
||||
|
||||
const renderBars = (
|
||||
engine: GenerativePianoEngine,
|
||||
activity: number,
|
||||
selectedColorIndex = 0,
|
||||
bars = 4
|
||||
bars = 8
|
||||
) => {
|
||||
engine.renderLookahead({
|
||||
vibe: VIBE_PRESETS[0],
|
||||
now: 0,
|
||||
activity,
|
||||
selectedColorIndex: selectedColorIndex as 0 | 1 | 2,
|
||||
lookaheadSeconds:
|
||||
(60 / gardenAudioConfig.rhythm.bpm) *
|
||||
Math.round(
|
||||
gardenAudioConfig.rhythm.stepsPerBar / gardenAudioConfig.rhythm.stepsPerBeat
|
||||
) *
|
||||
bars,
|
||||
lookaheadSeconds: getBeatSeconds() * getBeatsPerBar() * bars,
|
||||
});
|
||||
};
|
||||
|
||||
const average = (values: Array<number>): number =>
|
||||
values.reduce((sum, value) => sum + value, 0) / values.length;
|
||||
|
||||
const uniqueStartTimes = (notes: Array<PianoNote>): Array<string> =>
|
||||
Array.from(new Set(notes.map((note) => note.startTime.toFixed(3))));
|
||||
|
||||
const countNotesBetween = (
|
||||
notes: Array<PianoNote>,
|
||||
startSeconds: number,
|
||||
endSeconds: number
|
||||
): number =>
|
||||
notes.filter(
|
||||
(note) => note.startTime >= startSeconds && note.startTime < endSeconds
|
||||
).length;
|
||||
|
||||
describe('GenerativePianoEngine', () => {
|
||||
it('does not emit notes below the sparse activity threshold', () => {
|
||||
it('plays quiet background music even when the garden is idle', () => {
|
||||
const { engine, notes } = makeEngine();
|
||||
|
||||
renderBars(engine, gardenAudioConfig.rhythm.sparseActivity - 0.01);
|
||||
|
||||
expect(notes).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('keeps drawing notes on beat starts', () => {
|
||||
const { engine, notes } = makeEngine();
|
||||
const beatSeconds = 60 / gardenAudioConfig.rhythm.bpm;
|
||||
const startDelaySeconds = 0.02;
|
||||
|
||||
renderBars(engine, 1, 1);
|
||||
renderBars(engine, 0);
|
||||
|
||||
expect(notes.length).toBeGreaterThan(0);
|
||||
notes.forEach((note) => {
|
||||
const beatsFromStart = (note.startTime - startDelaySeconds) / beatSeconds;
|
||||
expect(notes.some((note) => note.durationSeconds > getBeatSeconds() * 12)).toBe(
|
||||
true
|
||||
);
|
||||
expect(Math.max(...notes.map((note) => note.velocity))).toBeLessThan(0.16);
|
||||
});
|
||||
|
||||
it('keeps the background sparse instead of filling every beat', () => {
|
||||
const { engine, notes } = makeEngine();
|
||||
|
||||
renderBars(engine, 0, 1, 4);
|
||||
|
||||
expect(uniqueStartTimes(notes).length).toBeLessThan(8);
|
||||
});
|
||||
|
||||
it('lets activity add density without changing the beat grid', () => {
|
||||
const idle = makeEngine();
|
||||
const active = makeEngine();
|
||||
const startDelaySeconds = 0.02;
|
||||
|
||||
renderBars(idle.engine, 0, 1, 8);
|
||||
renderBars(active.engine, 1, 1, 8);
|
||||
|
||||
expect(active.notes.length).toBeGreaterThan(idle.notes.length);
|
||||
active.notes.forEach((note) => {
|
||||
const beatsFromStart = (note.startTime - startDelaySeconds) / getBeatSeconds();
|
||||
expect(Math.abs(beatsFromStart - Math.round(beatsFromStart))).toBeLessThan(0.001);
|
||||
});
|
||||
});
|
||||
|
||||
it('varies density with activity without exceeding one note per beat', () => {
|
||||
const low = makeEngine();
|
||||
const high = makeEngine();
|
||||
|
||||
renderBars(low.engine, gardenAudioConfig.rhythm.sparseActivity + 0.03, 1);
|
||||
renderBars(high.engine, 1, 1);
|
||||
|
||||
expect(high.notes.length).toBeGreaterThan(low.notes.length);
|
||||
expect(high.notes.length).toBeLessThanOrEqual(16);
|
||||
});
|
||||
|
||||
it('wakes every color with a prompt first note at low activity', () => {
|
||||
it('uses color pools with multiple notes instead of one key per color', () => {
|
||||
([0, 1, 2] as const).forEach((selectedColorIndex) => {
|
||||
const { engine, notes } = makeEngine();
|
||||
const now = 4;
|
||||
|
||||
engine.beginGesture(1);
|
||||
engine.wake(now);
|
||||
engine.renderLookahead({
|
||||
vibe: VIBE_PRESETS[0],
|
||||
now,
|
||||
activity: gardenAudioConfig.rhythm.sparseActivity + 0.01,
|
||||
selectedColorIndex,
|
||||
lookaheadSeconds: 0.08,
|
||||
});
|
||||
renderBars(engine, 1, selectedColorIndex, 16);
|
||||
|
||||
expect(notes).toHaveLength(1);
|
||||
expect(notes[0].startTime).toBeCloseTo(now + 0.02);
|
||||
expect(new Set(notes.map((note) => note.midi)).size).toBeGreaterThan(3);
|
||||
});
|
||||
});
|
||||
|
||||
it('uses different color roles for register and pan', () => {
|
||||
const anchor = makeEngine();
|
||||
const spark = makeEngine();
|
||||
it('keeps the upper color higher and wider than the lower color', () => {
|
||||
const lower = makeEngine();
|
||||
const upper = makeEngine();
|
||||
|
||||
renderBars(anchor.engine, 1, 0);
|
||||
renderBars(spark.engine, 1, 2);
|
||||
renderBars(lower.engine, 1, 0, 16);
|
||||
renderBars(upper.engine, 1, 2, 16);
|
||||
|
||||
expect(average(spark.notes.map((note) => note.midi))).toBeGreaterThan(
|
||||
average(anchor.notes.map((note) => note.midi))
|
||||
expect(average(upper.notes.map((note) => note.midi))).toBeGreaterThan(
|
||||
average(lower.notes.map((note) => note.midi))
|
||||
);
|
||||
expect(average(spark.notes.map((note) => note.pan))).toBeGreaterThan(
|
||||
average(anchor.notes.map((note) => note.pan))
|
||||
expect(average(upper.notes.map((note) => note.pan))).toBeGreaterThan(
|
||||
average(lower.notes.map((note) => note.pan))
|
||||
);
|
||||
});
|
||||
|
||||
it('is deterministic for the same phrase inputs', () => {
|
||||
it('starts a fading brush phrase layer with each new brush gesture', () => {
|
||||
const baseline = makeEngine();
|
||||
const layered = makeEngine();
|
||||
const now = 4;
|
||||
|
||||
baseline.engine.renderLookahead({
|
||||
vibe: VIBE_PRESETS[0],
|
||||
now,
|
||||
activity: 0.35,
|
||||
selectedColorIndex: 1,
|
||||
lookaheadSeconds: 12,
|
||||
});
|
||||
|
||||
layered.engine.beginGesture();
|
||||
layered.engine.recordStroke({
|
||||
vibe: VIBE_PRESETS[0],
|
||||
now,
|
||||
activity: 0.85,
|
||||
selectedColorIndex: 1,
|
||||
mirrorAmount: 0.45,
|
||||
});
|
||||
layered.engine.renderLookahead({
|
||||
vibe: VIBE_PRESETS[0],
|
||||
now,
|
||||
activity: 0.35,
|
||||
selectedColorIndex: 1,
|
||||
lookaheadSeconds: 12,
|
||||
});
|
||||
|
||||
const earlyExtra =
|
||||
countNotesBetween(layered.notes, now + 1, now + 5) -
|
||||
countNotesBetween(baseline.notes, now + 1, now + 5);
|
||||
const lateExtra =
|
||||
countNotesBetween(layered.notes, now + 10.5, now + 12) -
|
||||
countNotesBetween(baseline.notes, now + 10.5, now + 12);
|
||||
|
||||
expect(earlyExtra).toBeGreaterThan(2);
|
||||
expect(lateExtra).toBe(0);
|
||||
});
|
||||
|
||||
it('makes brush phrase layers denser at higher mirror amounts', () => {
|
||||
const lowMirror = makeEngine();
|
||||
const highMirror = makeEngine();
|
||||
const now = 4;
|
||||
|
||||
lowMirror.engine.beginGesture();
|
||||
lowMirror.engine.recordStroke({
|
||||
vibe: VIBE_PRESETS[0],
|
||||
now,
|
||||
activity: 0.85,
|
||||
selectedColorIndex: 2,
|
||||
mirrorAmount: 0,
|
||||
});
|
||||
lowMirror.engine.renderLookahead({
|
||||
vibe: VIBE_PRESETS[0],
|
||||
now,
|
||||
activity: 0.35,
|
||||
selectedColorIndex: 2,
|
||||
lookaheadSeconds: 9,
|
||||
});
|
||||
|
||||
highMirror.engine.beginGesture();
|
||||
highMirror.engine.recordStroke({
|
||||
vibe: VIBE_PRESETS[0],
|
||||
now,
|
||||
activity: 0.85,
|
||||
selectedColorIndex: 2,
|
||||
mirrorAmount: 1,
|
||||
});
|
||||
highMirror.engine.renderLookahead({
|
||||
vibe: VIBE_PRESETS[0],
|
||||
now,
|
||||
activity: 0.35,
|
||||
selectedColorIndex: 2,
|
||||
lookaheadSeconds: 9,
|
||||
});
|
||||
|
||||
expect(highMirror.notes.length).toBeGreaterThan(lowMirror.notes.length);
|
||||
});
|
||||
|
||||
it('plays one immediate touch note and throttles later stroke accents', () => {
|
||||
const { engine, notes } = makeEngine();
|
||||
const now = 4;
|
||||
|
||||
engine.beginGesture();
|
||||
engine.recordStroke({
|
||||
vibe: VIBE_PRESETS[0],
|
||||
now,
|
||||
activity: 0.9,
|
||||
selectedColorIndex: 1,
|
||||
});
|
||||
engine.recordStroke({
|
||||
vibe: VIBE_PRESETS[0],
|
||||
now: now + 1,
|
||||
activity: 0.95,
|
||||
selectedColorIndex: 1,
|
||||
});
|
||||
|
||||
expect(notes).toHaveLength(1);
|
||||
expect(notes[0].startTime).toBe(now);
|
||||
|
||||
engine.recordStroke({
|
||||
vibe: VIBE_PRESETS[0],
|
||||
now: now + 6,
|
||||
activity: 0.95,
|
||||
selectedColorIndex: 1,
|
||||
});
|
||||
|
||||
expect(notes).toHaveLength(2);
|
||||
expect(new Set(notes.map((note) => note.midi)).size).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it('is deterministic for the same musical inputs', () => {
|
||||
const first = makeEngine();
|
||||
const second = makeEngine();
|
||||
|
||||
renderBars(first.engine, 0.78, 2);
|
||||
renderBars(second.engine, 0.78, 2);
|
||||
renderBars(first.engine, 0.78, 2, 16);
|
||||
renderBars(second.engine, 0.78, 2, 16);
|
||||
|
||||
expect(second.notes).toEqual(first.notes);
|
||||
});
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,9 +1,12 @@
|
|||
import { appConfig } from '../config';
|
||||
import type { GardenAudioEngineConfig } from '../config';
|
||||
import { GardenAudioGraph } from './garden-audio-graph';
|
||||
import { NoiseBurst } from './garden-audio-types';
|
||||
|
||||
export class NoiseBurstPlayer {
|
||||
public constructor(private readonly graph: GardenAudioGraph) {}
|
||||
public constructor(
|
||||
private readonly engineConfig: GardenAudioEngineConfig,
|
||||
private readonly graph: GardenAudioGraph
|
||||
) {}
|
||||
|
||||
public play({ startTime, durationSeconds, gain, filterHz, pan }: NoiseBurst): void {
|
||||
const { context, eventBus, noiseBuffer } = this.graph;
|
||||
|
|
@ -12,7 +15,7 @@ export class NoiseBurstPlayer {
|
|||
}
|
||||
|
||||
const scheduledStart = Math.max(
|
||||
context.currentTime + appConfig.audioEngine.noiseBurst.scheduleAheadSeconds,
|
||||
context.currentTime + this.engineConfig.noiseBurst.scheduleAheadSeconds,
|
||||
startTime
|
||||
);
|
||||
const source = context.createBufferSource();
|
||||
|
|
@ -24,17 +27,17 @@ export class NoiseBurstPlayer {
|
|||
source.buffer = noiseBuffer;
|
||||
filter.type = 'bandpass';
|
||||
filter.frequency.setValueAtTime(filterHz, scheduledStart);
|
||||
filter.Q.value = appConfig.audioEngine.noiseBurst.filterQ;
|
||||
filter.Q.value = this.engineConfig.noiseBurst.filterQ;
|
||||
envelope.gain.setValueAtTime(
|
||||
appConfig.audioEngine.noiseBurst.silentGain,
|
||||
this.engineConfig.noiseBurst.silentGain,
|
||||
scheduledStart
|
||||
);
|
||||
envelope.gain.exponentialRampToValueAtTime(
|
||||
Math.max(appConfig.audioEngine.noiseBurst.silentGain, gain),
|
||||
scheduledStart + appConfig.audioEngine.noiseBurst.attackSeconds
|
||||
Math.max(this.engineConfig.noiseBurst.silentGain, gain),
|
||||
scheduledStart + this.engineConfig.noiseBurst.attackSeconds
|
||||
);
|
||||
envelope.gain.exponentialRampToValueAtTime(
|
||||
appConfig.audioEngine.noiseBurst.silentGain,
|
||||
this.engineConfig.noiseBurst.silentGain,
|
||||
stopAt
|
||||
);
|
||||
panner.pan.setValueAtTime(pan, scheduledStart);
|
||||
|
|
@ -45,7 +48,7 @@ export class NoiseBurstPlayer {
|
|||
panner.connect(eventBus);
|
||||
source.start(
|
||||
scheduledStart,
|
||||
Math.random() * appConfig.audioEngine.noiseBurst.offsetRandomSeconds
|
||||
Math.random() * this.engineConfig.noiseBurst.offsetRandomSeconds
|
||||
);
|
||||
source.stop(stopAt);
|
||||
source.addEventListener(
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { appConfig } from '../config';
|
||||
import type { GardenAudioEngineConfig } from '../config';
|
||||
import { clamp, clamp01 } from '../utils/clamp';
|
||||
import { GardenAudioConfig } from './garden-audio-config';
|
||||
import { GardenAudioGraph } from './garden-audio-graph';
|
||||
|
|
@ -12,6 +12,7 @@ export class PianoSampler {
|
|||
|
||||
public constructor(
|
||||
private readonly config: GardenAudioConfig,
|
||||
private readonly engineConfig: GardenAudioEngineConfig,
|
||||
private readonly graph: GardenAudioGraph
|
||||
) {}
|
||||
|
||||
|
|
@ -51,31 +52,39 @@ export class PianoSampler {
|
|||
lowpassHz = this.config.piano.lowpassHz,
|
||||
}: PianoNote): void {
|
||||
const { context, eventBus, delayInput } = this.graph;
|
||||
if (!context || !eventBus || this.samples.length === 0) {
|
||||
if (!context || !eventBus) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sample = this.findNearestSample(midi);
|
||||
if (!sample) {
|
||||
this.playFallbackPluck({
|
||||
midi,
|
||||
velocity,
|
||||
startTime,
|
||||
durationSeconds,
|
||||
pan,
|
||||
delaySend,
|
||||
lowpassHz,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const scheduledStart = Math.max(
|
||||
context.currentTime + appConfig.audioEngine.piano.scheduleAheadSeconds,
|
||||
context.currentTime + this.engineConfig.piano.scheduleAheadSeconds,
|
||||
startTime
|
||||
);
|
||||
const noteVelocity = clamp01(velocity);
|
||||
const noteGainValue = Math.max(
|
||||
appConfig.audioEngine.piano.minGain,
|
||||
this.engineConfig.piano.minGain,
|
||||
this.config.piano.gain * noteVelocity
|
||||
);
|
||||
const sustainSeconds =
|
||||
this.config.piano.sustainSeconds *
|
||||
(appConfig.audioEngine.piano.sustainBase +
|
||||
noteVelocity * appConfig.audioEngine.piano.sustainVelocityRange);
|
||||
(this.engineConfig.piano.sustainBase +
|
||||
noteVelocity * this.engineConfig.piano.sustainVelocityRange);
|
||||
const sustainAt =
|
||||
scheduledStart +
|
||||
Math.max(appConfig.audioEngine.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;
|
||||
|
|
@ -90,20 +99,18 @@ export class PianoSampler {
|
|||
const oldest = this.activeVoices.shift();
|
||||
oldest?.gain.gain.cancelScheduledValues(scheduledStart);
|
||||
oldest?.gain.gain.setTargetAtTime(
|
||||
appConfig.audioEngine.piano.minGain,
|
||||
this.engineConfig.piano.minGain,
|
||||
scheduledStart,
|
||||
appConfig.audioEngine.piano.voiceStealFadeSeconds
|
||||
);
|
||||
oldest?.source.stop(
|
||||
scheduledStart + appConfig.audioEngine.piano.voiceStealStopSeconds
|
||||
this.engineConfig.piano.voiceStealFadeSeconds
|
||||
);
|
||||
oldest?.source.stop(scheduledStart + this.engineConfig.piano.voiceStealStopSeconds);
|
||||
}
|
||||
|
||||
source.buffer = sample.buffer;
|
||||
source.playbackRate.setValueAtTime(
|
||||
Math.pow(
|
||||
2,
|
||||
(midi - sample.midi) / appConfig.audioEngine.piano.pitchSemitonesPerOctave
|
||||
(midi - sample.midi) / this.engineConfig.piano.pitchSemitonesPerOctave
|
||||
),
|
||||
scheduledStart
|
||||
);
|
||||
|
|
@ -111,30 +118,30 @@ export class PianoSampler {
|
|||
filter.frequency.setValueAtTime(
|
||||
clamp(
|
||||
lowpassHz,
|
||||
appConfig.audioEngine.piano.lowpassMinHz,
|
||||
appConfig.audioEngine.piano.lowpassMaxHz
|
||||
this.engineConfig.piano.lowpassMinHz,
|
||||
this.engineConfig.piano.lowpassMaxHz
|
||||
),
|
||||
scheduledStart
|
||||
);
|
||||
filter.Q.value = appConfig.audioEngine.piano.filterQ;
|
||||
gain.gain.setValueAtTime(appConfig.audioEngine.piano.minGain, scheduledStart);
|
||||
filter.Q.value = this.engineConfig.piano.filterQ;
|
||||
gain.gain.setValueAtTime(this.engineConfig.piano.minGain, scheduledStart);
|
||||
gain.gain.exponentialRampToValueAtTime(
|
||||
noteGainValue,
|
||||
scheduledStart + appConfig.audioEngine.piano.gainAttackSeconds
|
||||
scheduledStart + this.engineConfig.piano.gainAttackSeconds
|
||||
);
|
||||
gain.gain.setTargetAtTime(
|
||||
Math.max(
|
||||
appConfig.audioEngine.piano.minGain,
|
||||
this.engineConfig.piano.minGain,
|
||||
noteGainValue * this.config.piano.sustainLevel
|
||||
),
|
||||
sustainAt,
|
||||
Math.max(
|
||||
appConfig.audioEngine.piano.minFadeSeconds,
|
||||
sustainSeconds * appConfig.audioEngine.piano.sustainBase
|
||||
this.engineConfig.piano.minFadeSeconds,
|
||||
sustainSeconds * this.engineConfig.piano.sustainBase
|
||||
)
|
||||
);
|
||||
gain.gain.setTargetAtTime(
|
||||
appConfig.audioEngine.piano.minGain,
|
||||
this.engineConfig.piano.minGain,
|
||||
releaseAt,
|
||||
releaseSeconds
|
||||
);
|
||||
|
|
@ -153,7 +160,7 @@ export class PianoSampler {
|
|||
}
|
||||
|
||||
source.start(scheduledStart);
|
||||
source.stop(stopAt + appConfig.audioEngine.piano.tailStopExtraSeconds);
|
||||
source.stop(stopAt + this.engineConfig.piano.tailStopExtraSeconds);
|
||||
this.activeVoices.push({ gain, source, startAt: scheduledStart, stopAt });
|
||||
|
||||
source.addEventListener(
|
||||
|
|
@ -170,41 +177,6 @@ export class PianoSampler {
|
|||
);
|
||||
}
|
||||
|
||||
public fadeActive(
|
||||
now: number,
|
||||
fadeSeconds = appConfig.audioEngine.piano.defaultFadeSeconds
|
||||
): void {
|
||||
this.activeVoices.forEach((voice) => {
|
||||
voice.gain.gain.cancelScheduledValues(now);
|
||||
if (voice.startAt > now) {
|
||||
voice.gain.gain.setValueAtTime(appConfig.audioEngine.piano.minGain, now);
|
||||
voice.stopAt = now;
|
||||
try {
|
||||
voice.source.stop(now);
|
||||
} catch {
|
||||
// The source may already have a stop time scheduled.
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const stopAt = Math.min(voice.stopAt, now + fadeSeconds);
|
||||
voice.gain.gain.setTargetAtTime(
|
||||
appConfig.audioEngine.piano.minGain,
|
||||
now,
|
||||
Math.max(
|
||||
appConfig.audioEngine.piano.minFadeSeconds,
|
||||
fadeSeconds * appConfig.audioEngine.piano.fadeTimeConstantRatio
|
||||
)
|
||||
);
|
||||
voice.stopAt = stopAt;
|
||||
try {
|
||||
voice.source.stop(stopAt);
|
||||
} catch {
|
||||
// The source may already have a stop time scheduled.
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public reset(): void {
|
||||
this.sampleLoadPromise = null;
|
||||
this.samples = [];
|
||||
|
|
@ -224,4 +196,90 @@ 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
export interface PianoSampleDefinition {
|
||||
interface PianoSampleDefinition {
|
||||
midi: number;
|
||||
url: string;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue