WIP
This commit is contained in:
parent
34ac200437
commit
39b0160064
136 changed files with 7129 additions and 1950 deletions
|
|
@ -1,3 +1,4 @@
|
|||
import { appConfig } from '../config';
|
||||
import { clamp, clamp01 } from '../utils/clamp';
|
||||
import { VibePreset } from '../vibes';
|
||||
import { GardenAudioConfig } from './garden-audio-config';
|
||||
|
|
@ -5,13 +6,13 @@ import { GardenAudioEnergy } from './garden-audio-energy';
|
|||
import { GardenAudioGraph } from './garden-audio-graph';
|
||||
import { getStrokeMetrics } from './garden-audio-input';
|
||||
import { getVibeProfile, normalizeColorIndex } from './garden-audio-music';
|
||||
import { GardenAudioScore } from './garden-audio-score';
|
||||
import type {
|
||||
GardenAudioColorIndex,
|
||||
GardenAudioSnapshot,
|
||||
GardenAudioStartOptions,
|
||||
GardenAudioStroke,
|
||||
} from './garden-audio-types';
|
||||
import { GenerativePianoEngine } from './generative-piano';
|
||||
import { NoiseBurstPlayer } from './noise-burst-player';
|
||||
import { PianoSampler } from './piano-sampler';
|
||||
|
||||
|
|
@ -26,18 +27,14 @@ export class GardenAudio {
|
|||
private readonly piano: PianoSampler;
|
||||
private readonly noise: NoiseBurstPlayer;
|
||||
private readonly energy: GardenAudioEnergy;
|
||||
private readonly score: GardenAudioScore;
|
||||
private readonly pianoEngine: GenerativePianoEngine;
|
||||
|
||||
private currentVibeId: string | null = null;
|
||||
private hasStarted = false;
|
||||
private isDestroyed = false;
|
||||
private isMuted = false;
|
||||
private isGestureActive = false;
|
||||
private selectedColorIndex: GardenAudioColorIndex = 0;
|
||||
private rhythmAnchorTime: number | null = null;
|
||||
private startedAt: number | null = null;
|
||||
private nextStepAt = 0;
|
||||
private stepIndex = 0;
|
||||
private lastTapAt = Number.NEGATIVE_INFINITY;
|
||||
private lastEraserAt = Number.NEGATIVE_INFINITY;
|
||||
private lastVibeStingerAt = Number.NEGATIVE_INFINITY;
|
||||
|
||||
|
|
@ -45,12 +42,12 @@ export class GardenAudio {
|
|||
this.graph = new GardenAudioGraph(config);
|
||||
this.piano = new PianoSampler(config, this.graph);
|
||||
this.noise = new NoiseBurstPlayer(this.graph);
|
||||
this.energy = new GardenAudioEnergy(config.rhythm);
|
||||
this.score = new GardenAudioScore(config, (note) => this.piano.play(note));
|
||||
this.energy = new GardenAudioEnergy();
|
||||
this.pianoEngine = new GenerativePianoEngine(config, (note) => this.piano.play(note));
|
||||
}
|
||||
|
||||
public start(vibe: VibePreset, options: GardenAudioStartOptions = {}): void {
|
||||
if (!this.config.enabled || this.isDestroyed || this.isMuted) {
|
||||
if (this.isDestroyed || this.isMuted) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -59,6 +56,10 @@ export class GardenAudio {
|
|||
return;
|
||||
}
|
||||
|
||||
if (options.userGesture === true) {
|
||||
this.graph.unlock();
|
||||
}
|
||||
|
||||
if (context.state === 'suspended') {
|
||||
if (options.userGesture !== true) {
|
||||
return;
|
||||
|
|
@ -67,17 +68,11 @@ export class GardenAudio {
|
|||
}
|
||||
|
||||
this.hasStarted = true;
|
||||
this.rhythmAnchorTime ??= context.currentTime;
|
||||
this.startedAt ??= context.currentTime;
|
||||
this.applyVibe(vibe);
|
||||
if (this.nextStepAt <= 0) {
|
||||
this.nextStepAt = context.currentTime + 0.02;
|
||||
}
|
||||
this.pianoEngine.prime(context.currentTime);
|
||||
this.graph.setMasterGain(this.config.masterVolume, this.config.fadeInSeconds);
|
||||
|
||||
if (this.config.piano.preloadOnStart) {
|
||||
void this.piano.load(context);
|
||||
}
|
||||
void this.piano.load(context);
|
||||
}
|
||||
|
||||
public changeVibe(vibe: VibePreset, options: GardenAudioStartOptions = {}): void {
|
||||
|
|
@ -100,8 +95,8 @@ export class GardenAudio {
|
|||
public setMuted(isMuted: boolean): void {
|
||||
this.isMuted = isMuted;
|
||||
this.graph.setMasterGain(
|
||||
isMuted ? 0.0001 : this.config.masterVolume,
|
||||
isMuted ? 0.02 : this.config.fadeInSeconds
|
||||
isMuted ? appConfig.audioEngine.muteGain : this.config.masterVolume,
|
||||
isMuted ? appConfig.audioEngine.muteRampSeconds : this.config.fadeInSeconds
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -111,20 +106,21 @@ export class GardenAudio {
|
|||
return;
|
||||
}
|
||||
|
||||
this.isGestureActive = true;
|
||||
this.energy.beginGesture(context.currentTime);
|
||||
this.pianoEngine.beginGesture(context.currentTime);
|
||||
}
|
||||
|
||||
public endGesture(): void {
|
||||
const context = this.graph.context;
|
||||
this.isGestureActive = false;
|
||||
this.energy.endGesture();
|
||||
this.pianoEngine.endGesture();
|
||||
if (!context) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.energy.endGesture(context.currentTime);
|
||||
}
|
||||
|
||||
public rememberColor(colorIndex: number): void {
|
||||
this.selectedColorIndex = normalizeColorIndex(colorIndex);
|
||||
this.piano.fadeActive(context.currentTime, appConfig.audioEngine.gestureFadeSeconds);
|
||||
}
|
||||
|
||||
public update(snapshot: GardenAudioSnapshot): void {
|
||||
|
|
@ -139,17 +135,27 @@ export class GardenAudio {
|
|||
|
||||
if (snapshot.isErasing) {
|
||||
this.energy.silence();
|
||||
this.piano.fadeActive(context.currentTime);
|
||||
this.piano.fadeActive(
|
||||
context.currentTime,
|
||||
appConfig.audioEngine.gestureFadeSeconds
|
||||
);
|
||||
this.updateDelay(snapshot);
|
||||
return;
|
||||
}
|
||||
|
||||
this.scheduleRhythm(snapshot.vibe);
|
||||
if (this.isGestureActive) {
|
||||
this.pianoEngine.renderLookahead({
|
||||
vibe: snapshot.vibe,
|
||||
now: context.currentTime,
|
||||
activity: this.energy.getActivity(),
|
||||
selectedColorIndex: this.selectedColorIndex,
|
||||
});
|
||||
}
|
||||
this.updateDelay(snapshot);
|
||||
}
|
||||
|
||||
public stroke(stroke: GardenAudioStroke): void {
|
||||
if (!this.config.enabled || this.isDestroyed || this.isMuted) {
|
||||
if (this.isDestroyed || this.isMuted) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -158,6 +164,9 @@ export class GardenAudio {
|
|||
if (!context) {
|
||||
return;
|
||||
}
|
||||
if (!this.isGestureActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
const metrics = getStrokeMetrics(
|
||||
stroke,
|
||||
|
|
@ -169,32 +178,14 @@ export class GardenAudio {
|
|||
this.selectedColorIndex = normalizeColorIndex(stroke.colorIndex);
|
||||
|
||||
if (stroke.isErasing) {
|
||||
this.energy.recordEraserStroke(now);
|
||||
this.energy.recordEraserStroke();
|
||||
this.playEraser(stroke, metrics.speedAmount, metrics.pressure, now);
|
||||
return;
|
||||
}
|
||||
|
||||
const strokeEnergy = metrics.effectiveEnergy * this.getStartupEnergyScale(now);
|
||||
this.energy.recordStroke(metrics.distancePixels, strokeEnergy, now);
|
||||
if (metrics.distancePixels >= 2.5) {
|
||||
this.energy.raiseTarget(strokeEnergy);
|
||||
}
|
||||
|
||||
if (
|
||||
metrics.distancePixels >= 2.5 &&
|
||||
now - this.lastTapAt >= this.getTapIntervalSeconds(now)
|
||||
) {
|
||||
this.lastTapAt = now;
|
||||
this.score.playStrokeTap({
|
||||
stroke,
|
||||
energy: strokeEnergy,
|
||||
now,
|
||||
selectedColorIndex: this.selectedColorIndex,
|
||||
stepIndex: this.stepIndex,
|
||||
rhythmAnchorTime: this.getRhythmAnchorTime(now),
|
||||
stepDurationSeconds: this.getStepDurationSeconds(now),
|
||||
});
|
||||
}
|
||||
const strokeEnergy = metrics.effectiveEnergy;
|
||||
this.energy.recordStroke(strokeEnergy, now);
|
||||
this.pianoEngine.wake(now);
|
||||
}
|
||||
|
||||
public async destroy(): Promise<void> {
|
||||
|
|
@ -203,50 +194,15 @@ export class GardenAudio {
|
|||
|
||||
this.piano.reset();
|
||||
this.energy.reset();
|
||||
this.pianoEngine.reset();
|
||||
this.currentVibeId = null;
|
||||
this.hasStarted = false;
|
||||
this.isGestureActive = false;
|
||||
this.selectedColorIndex = 0;
|
||||
this.rhythmAnchorTime = null;
|
||||
this.startedAt = null;
|
||||
this.nextStepAt = 0;
|
||||
this.stepIndex = 0;
|
||||
this.lastTapAt = Number.NEGATIVE_INFINITY;
|
||||
this.lastEraserAt = Number.NEGATIVE_INFINITY;
|
||||
this.lastVibeStingerAt = Number.NEGATIVE_INFINITY;
|
||||
}
|
||||
|
||||
private scheduleRhythm(vibe: VibePreset): void {
|
||||
const context = this.graph.context;
|
||||
if (!context || !this.graph.eventBus) {
|
||||
return;
|
||||
}
|
||||
|
||||
const now = context.currentTime;
|
||||
const stepSeconds = this.getStepDurationSeconds(now);
|
||||
if (this.nextStepAt <= 0 || this.nextStepAt < now - stepSeconds) {
|
||||
this.nextStepAt = now + 0.02;
|
||||
}
|
||||
|
||||
const lookaheadEnd = now + this.config.rhythm.lookaheadSeconds;
|
||||
while (this.nextStepAt <= lookaheadEnd) {
|
||||
const swingOffset =
|
||||
this.stepIndex % 2 === 1 ? stepSeconds * this.config.rhythm.swing : 0;
|
||||
const startTime = this.nextStepAt + swingOffset;
|
||||
this.score.playRhythmStep({
|
||||
vibe,
|
||||
stepIndex: this.stepIndex,
|
||||
startTime,
|
||||
activity: this.getSettledActivity(
|
||||
this.energy.getActivityAt(startTime),
|
||||
startTime
|
||||
),
|
||||
selectedColorIndex: this.selectedColorIndex,
|
||||
});
|
||||
this.nextStepAt += stepSeconds;
|
||||
this.stepIndex += 1;
|
||||
}
|
||||
}
|
||||
|
||||
private playVibeChangeStinger(vibe: VibePreset): void {
|
||||
const context = this.graph.context;
|
||||
if (!context) {
|
||||
|
|
@ -254,12 +210,15 @@ export class GardenAudio {
|
|||
}
|
||||
|
||||
const now = context.currentTime;
|
||||
if (now - this.lastVibeStingerAt < 0.45) {
|
||||
if (
|
||||
now - this.lastVibeStingerAt <
|
||||
appConfig.audioEngine.vibeChangeStingerMinIntervalSeconds
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.lastVibeStingerAt = now;
|
||||
this.score.playVibeChangeStinger(vibe, now);
|
||||
this.pianoEngine.playVibeChangeStinger(vibe, now);
|
||||
}
|
||||
|
||||
private playEraser(
|
||||
|
|
@ -268,27 +227,38 @@ export class GardenAudio {
|
|||
pressure: number,
|
||||
now: number
|
||||
): void {
|
||||
if (!this.config.eraser.enabled || !this.graph.context) {
|
||||
if (!this.graph.context) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sizeAmount = clamp01(
|
||||
(stroke.eraserSizePixels ?? 96) / Math.max(1, stroke.canvasSize[0] * 0.18)
|
||||
(stroke.eraserSizePixels ?? appConfig.audioEngine.eraser.defaultSizePixels) /
|
||||
Math.max(
|
||||
1,
|
||||
stroke.canvasSize[0] * appConfig.audioEngine.eraser.canvasWidthRatioForFullSize
|
||||
)
|
||||
);
|
||||
const x = clamp01(stroke.to[0] / Math.max(1, stroke.canvasSize[0]));
|
||||
const filterHz =
|
||||
this.config.eraser.filterMinHz +
|
||||
(this.config.eraser.filterMaxHz - this.config.eraser.filterMinHz) *
|
||||
clamp01(speedAmount * 0.58 + pressure * 0.26 + sizeAmount * 0.16);
|
||||
clamp01(
|
||||
speedAmount * appConfig.audioEngine.eraser.filterSpeedWeight +
|
||||
pressure * appConfig.audioEngine.eraser.filterPressureWeight +
|
||||
sizeAmount * appConfig.audioEngine.eraser.filterSizeWeight
|
||||
);
|
||||
|
||||
if (now - this.lastEraserAt >= this.config.eraser.minIntervalSeconds) {
|
||||
this.lastEraserAt = now;
|
||||
this.noise.play({
|
||||
startTime: now,
|
||||
durationSeconds: 0.08,
|
||||
durationSeconds: appConfig.audioEngine.eraser.durationSeconds,
|
||||
gain:
|
||||
this.config.eraser.noiseGain *
|
||||
(0.45 + speedAmount * 0.38 + pressure * 0.24 + sizeAmount * 0.18),
|
||||
(appConfig.audioEngine.eraser.gainBase +
|
||||
speedAmount * appConfig.audioEngine.eraser.gainSpeedWeight +
|
||||
pressure * appConfig.audioEngine.eraser.gainPressureWeight +
|
||||
sizeAmount * appConfig.audioEngine.eraser.gainSizeWeight),
|
||||
filterHz,
|
||||
pan: clamp(x * 2 - 1, -1, 1),
|
||||
});
|
||||
|
|
@ -303,11 +273,8 @@ export class GardenAudio {
|
|||
|
||||
const profile = getVibeProfile(this.config, snapshot.vibe);
|
||||
const activity = snapshot.isErasing
|
||||
? 0.12
|
||||
: this.getSettledActivity(
|
||||
this.energy.getActivityAt(context.currentTime),
|
||||
context.currentTime
|
||||
);
|
||||
? appConfig.audioEngine.delay.erasingActivity
|
||||
: this.energy.getLevel();
|
||||
this.graph.updateDelay(profile, activity);
|
||||
}
|
||||
|
||||
|
|
@ -319,68 +286,4 @@ export class GardenAudio {
|
|||
this.currentVibeId = vibe.id;
|
||||
this.graph.applyDelayProfile(getVibeProfile(this.config, vibe));
|
||||
}
|
||||
|
||||
private getStepDurationSeconds(time: number): number {
|
||||
const baseStepSeconds = 60 / this.config.rhythm.bpm / this.config.rhythm.stepsPerBeat;
|
||||
return baseStepSeconds * this.getStartupTempoMultiplier(time);
|
||||
}
|
||||
|
||||
private getRhythmAnchorTime(now: number): number {
|
||||
this.rhythmAnchorTime ??= now;
|
||||
return this.rhythmAnchorTime;
|
||||
}
|
||||
|
||||
private getTapIntervalSeconds(time: number): number {
|
||||
return (
|
||||
this.config.rhythm.minTapIntervalSeconds *
|
||||
this.getStartupTapIntervalMultiplier(time)
|
||||
);
|
||||
}
|
||||
|
||||
private getSettledActivity(activity: number, time: number): number {
|
||||
return Math.min(activity, this.getStartupActivityCeiling(time));
|
||||
}
|
||||
|
||||
private getStartupEnergyScale(time: number): number {
|
||||
return this.interpolateStartupValue(
|
||||
this.config.startup.initialEnergyMultiplier,
|
||||
1,
|
||||
time
|
||||
);
|
||||
}
|
||||
|
||||
private getStartupActivityCeiling(time: number): number {
|
||||
return this.interpolateStartupValue(
|
||||
this.config.startup.initialActivityCeiling,
|
||||
1,
|
||||
time
|
||||
);
|
||||
}
|
||||
|
||||
private getStartupTempoMultiplier(time: number): number {
|
||||
return this.interpolateStartupValue(
|
||||
this.config.startup.initialTempoMultiplier,
|
||||
1,
|
||||
time
|
||||
);
|
||||
}
|
||||
|
||||
private getStartupTapIntervalMultiplier(time: number): number {
|
||||
return this.interpolateStartupValue(
|
||||
this.config.startup.initialTapIntervalMultiplier,
|
||||
1,
|
||||
time
|
||||
);
|
||||
}
|
||||
|
||||
private interpolateStartupValue(from: number, to: number, time: number): number {
|
||||
const durationSeconds = this.config.startup.calmDurationSeconds;
|
||||
if (this.startedAt === null || durationSeconds <= 0) {
|
||||
return to;
|
||||
}
|
||||
|
||||
const progress = clamp01((time - this.startedAt) / durationSeconds);
|
||||
const easedProgress = progress * progress * (3 - 2 * progress);
|
||||
return from + (to - from) * easedProgress;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue