Offer the easings of an animation key on Lerp and sound FX ramps
game-maker / gate (push) Successful in 3m15s
game-maker / suite (push) Successful in 15m36s

A timed Set sound FX parameters ran only linearly, and a Lerp had four
quadratic eases of its own. An animation key already had the full set.
Now all three offer the same choice: Linear, Step, the ten curves from
Sine to Bounce with In, Out or In-out, and a drawn Curve asset.

- easingParams in src/core/graph/nodes/easing.ts gives a node the
  fields Easing, Direction and Curve from the key's own lists, and
  easingOfParams reads them back as a key interpolation.
- There is no migration. A Lerp saved with the old in, out or inOut
  runs linear until its easing is picked again.
- Set sound FX parameters hands the easing to the player. Linear and
  Step stay ramps of the audio clock, every other easing goes to
  setValueCurveAtTime as 200 samples a second, in semitones and dB.
- Step now reaches its end at t 1. A key never asked for that, it
  reads the next key itself, so animations play as before.
- The curve-in-use count and the warning for a gone curve cover the
  sound node too.

Tests:
- node-catalog: Lerp with cubic in, quad out and in-out, a curve
  without a direction, step, an unknown easing as linear. The sound
  node hands on a curve, step and a gone curve as linear.
- sound-library: an eased change becomes a curve in semitones and
  decibels, a step a jump at the end.
- node-catalog and curve after dropping the old values: 399, green.
- e2e 6, 34, 53 on melektaus-atx: ALL PASSED, 198 checks. In Chrome a
  2 s Quad in from depth 7 to -5 read speed 0.864 early and 0.5 at the
  end, a 1 s Step held 1 and then jumped to 0.5.
- Counter-check: with curves sent as linear ramps, the curve test of
  the sound library fails.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-05 22:05:00 +02:00
1 parent 501a1524ed
commit e7a360db73
17 files changed
+347 -104

No files matched your search

+47 -15
View File
@@ -6,6 +6,7 @@ import {
type FxStage,
type FxVoice,
} from '../core/soundfx/nodes';
import type { EaseShare } from '../core/model/animation';
import { pitchShift } from '../core/soundfx/pitchShift';
import type { AudioSink } from '../runtime/systems/Audio';
@@ -62,7 +63,7 @@ interface Wiring {
interface Pending {
id: number;
fx: FxVoice;
timed: { params: FxParams; time: number }[];
timed: { params: FxParams; time: number; ease: EaseShare }[];
}
/**
@@ -136,25 +137,25 @@ export class SoundLibrary implements AudioSink {
// bytes were in flight has to win, or a sound the run already ended would start afterwards.
if (!loaded || this.cancelsFor(file, owner) !== askedAt) return;
this.start(id, file, loaded, volume, loop, owner, waiting.fx);
for (const { params, time } of waiting.timed) this.tune(id, params, time);
for (const { params, time, ease } of waiting.timed) this.tune(id, params, time, ease);
});
return id;
}
/** Says whether the voice was still there to be reached – playing, or waiting on its bytes. */
tune(id: number, params: FxParams, time = 0): boolean {
tune(id: number, params: FxParams, time = 0, ease: EaseShare = 'linear'): boolean {
const waiting = this.pending.find((other) => other.id === id);
if (waiting) {
// A quick change is where the voice starts; a timed one – and anything after it, so the order
// holds – runs from the moment it is heard.
if (time > 0 || waiting.timed.length > 0) waiting.timed.push({ params, time });
if (time > 0 || waiting.timed.length > 0) waiting.timed.push({ params, time, ease });
else waiting.fx = { plan: waiting.fx.plan, params: { ...waiting.fx.params, ...params } };
return true;
}
const voice = this.voices.find((other) => other.id === id);
if (!voice) return false;
voice.fx = { plan: voice.fx.plan, params: { ...voice.fx.params, ...params } };
this.glide(voice, time);
this.glide(voice, time, ease);
this.tuned += 1;
return true;
}
@@ -350,10 +351,10 @@ export class SoundLibrary implements AudioSink {
/**
* Sends every knob of a voice towards what its values say now, in `time` seconds of the audio
* clock or else in a quick glide. A pitch cannot get a new copy in the middle of a sound, so it
* clock along `ease`, or else in a quick glide. A pitch cannot get a new copy in the middle of a sound, so it
* glides by speed: the voice gets that much shorter or longer.
*/
private glide(voice: Voice, time: number): void {
private glide(voice: Voice, time: number, ease: EaseShare): void {
const levels = levelsOf(fxChainOf(voice.fx.plan, voice.fx.params));
const now = this.audio().currentTime;
voice.knobs.forEach((knob, i) => {
@@ -361,8 +362,8 @@ export class SoundLibrary implements AudioSink {
if (level === undefined || level === knob.target) return;
knob.target = level;
if (knob.kind === 'pitch')
move(knob.source.playbackRate, 2 ** ((level - knob.base) / 12), now, time, true);
else move(knob.param, level, now, time, knob.even === 'ratio');
move(knob.source.playbackRate, 2 ** ((level - knob.base) / 12), now, time, true, ease);
else move(knob.param, level, now, time, knob.even === 'ratio', ease);
});
}
@@ -445,21 +446,52 @@ function fxNode(context: AudioContext, stage: FxStage): [AudioNode, AudioParam]
/**
* Sends one knob from where it is right now to `to`. Whatever was still planned for it is dropped
* first, so a change never queues behind an older one. A timed change runs evenly – a `ratio` one
* evenly in its logarithm, which is what decibels and semitones are.
* first, so a change never queues behind an older one. A timed change runs along `ease` – a `ratio`
* one in its logarithm, which is what decibels and semitones are. Linear and step are the clock's
* own ramps, every other easing is handed over as a curve of samples.
*/
function move(param: AudioParam, to: number, now: number, time: number, ratio: boolean): void {
function move(
param: AudioParam,
to: number,
now: number,
time: number,
ratio: boolean,
ease: EaseShare = 'linear',
): void {
const from = param.value;
// Firefox has no cancelAndHoldAtTime: there the value it reads out now is held by hand.
const held = param as Omit<AudioParam, 'cancelAndHoldAtTime'> &
Partial<Pick<AudioParam, 'cancelAndHoldAtTime'>>;
if (held.cancelAndHoldAtTime) held.cancelAndHoldAtTime(now);
else {
param.cancelScheduledValues(now);
param.setValueAtTime(param.value, now);
param.setValueAtTime(from, now);
}
const logarithmic = ratio && from > 0 && to > 0;
if (time <= 0) param.setTargetAtTime(to, now, GLIDE);
else if (ratio && to > 0) param.exponentialRampToValueAtTime(to, now + time);
else param.linearRampToValueAtTime(to, now + time);
else if (ease === 'step') param.setValueAtTime(to, now + time);
else if (ease === 'linear' && logarithmic) param.exponentialRampToValueAtTime(to, now + time);
else if (ease === 'linear') param.linearRampToValueAtTime(to, now + time);
else param.setValueCurveAtTime(easedCurve(from, to, time, logarithmic, ease), now, time);
}
/** Samples per second of an eased curve: a bounce keeps its corners, a long sweep stays small. */
const CURVE_RATE = 200;
function easedCurve(
from: number,
to: number,
time: number,
logarithmic: boolean,
share: (t: number) => number,
): Float32Array {
const count = Math.min(Math.max(Math.ceil(time * CURVE_RATE) + 1, 16), 4096);
const curve = new Float32Array(count);
for (let i = 0; i < count; i++) {
const s = share(i / (count - 1));
curve[i] = logarithmic ? from * (to / from) ** s : from + (to - from) * s;
}
return curve;
}
/** The numbers of a chain in the order `wire` makes their knobs: a stage's `gain` is what its node holds. */