Offer the easings of an animation key on Lerp and sound FX ramps
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:
1 parent
501a1524ed
commit
e7a360db73
17 files changed
+347
-104
No files matched your search
@@ -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. */
|
||||
|
||||
Reference in new issue
Block a user