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
@@ -1,4 +1,4 @@
|
||||
import type { AnimationPlayer } from '../model/animation';
|
||||
import type { AnimationPlayer, EaseShare } from '../model/animation';
|
||||
import type { Mat } from '../model/matrix';
|
||||
import type { Position, PropertyBag } from '../model/Properties';
|
||||
import type { FxParams } from '../soundfx/nodes';
|
||||
@@ -255,9 +255,10 @@ export interface NodeContext {
|
||||
/**
|
||||
* Hands new values to the sound FX parameters of one voice, by the number `playSound` gave it
|
||||
* (#315), so a running sound follows the game. A voice that has ended is not reached. `time` is
|
||||
* how many seconds the values take to get there, on the audio clock; 0 is a quick glide.
|
||||
* how many seconds the values take to get there, on the audio clock and along `ease`; 0 is a
|
||||
* quick glide.
|
||||
*/
|
||||
tuneSound(voice: number, fxParams: FxParams, time: number): void;
|
||||
tuneSound(voice: number, fxParams: FxParams, time: number, ease: EaseShare): void;
|
||||
/**
|
||||
* Asks for another scene, by asset id. The swap happens once the frame is over – a graph must be
|
||||
* able to finish the flow it is in, and half a frame in the old world and half in the new one is
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
} from './Graph';
|
||||
import type { GraphLookup } from './lookup';
|
||||
import { nodeDef, type NodeDef, type PortDef } from './NodeRegistry';
|
||||
import { lerpProblem } from './nodes/data/Lerp';
|
||||
import { easingProblem } from './nodes/easing';
|
||||
import { withSplits } from './portSplits';
|
||||
import { agreedType, type SharedPortGroup } from './portTypes';
|
||||
|
||||
@@ -375,13 +375,14 @@ export function ghostProblem(node: GraphNode, graph: Graph, def: NodeDef): strin
|
||||
/**
|
||||
* Why this call cannot do anything, or null if it can. A call is the one node that depends on a
|
||||
* *different* graph, so it is the one node that can look perfectly healthy and still be dead. A
|
||||
* `Lerp` depends on another asset as well, the curve it eases along (#247), and says so here too.
|
||||
* `Lerp` or a `Set sound FX parameters` depends on another asset as well, the curve it eases along
|
||||
* (#247), and says so here too.
|
||||
*/
|
||||
export function callProblem(node: GraphNode, lookup: GraphLookup, ownerId = ''): string | null {
|
||||
if (node.type === ON_BEHAVIOR) return listenProblem(node, lookup, ownerId);
|
||||
if (node.type === SHADER_CALL) return functionCallProblem(node, lookup, ownerId);
|
||||
if (node.type === SOUND_FX_PARAMETERS) return soundFxParametersProblem(node, lookup);
|
||||
if (node.type !== RUN_BEHAVIOR) return lerpProblem(node, lookup);
|
||||
if (node.type !== RUN_BEHAVIOR) return easingProblem(node, lookup);
|
||||
const id = textOf(node.params[BEHAVIOR_PARAM] ?? '');
|
||||
if (!id) return 'No behavior picked';
|
||||
if (id === ownerId) return 'A behavior cannot run itself';
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import type { NodeDef } from '../../NodeRegistry';
|
||||
import { asNumber } from '../../values';
|
||||
import { curvesOf, easingOfParams, easingParams, hiddenEasing } from '../easing';
|
||||
import { exec, num } from '../shared';
|
||||
import { fxParamsOf } from './PlaySound';
|
||||
|
||||
import { easeShareOf } from '../../../model/animation';
|
||||
|
||||
/**
|
||||
* Changes the sound FX parameters of a sound that is already playing (#315). It reaches one voice,
|
||||
* by the number its `Play sound` handed out: every play is a sound of its own, and two objects with
|
||||
@@ -17,8 +20,8 @@ export const SetSoundFxParameters: NodeDef = {
|
||||
'glides to the values from a Sound FX parameters node. Keep the voice in a variable to reach it ' +
|
||||
'from another event. A parameter not given keeps its value, a voice that has ended is not ' +
|
||||
'reached. A pitch changes by speed here, so the sound gets shorter or longer while it is moved. ' +
|
||||
'time: seconds the values take to get there, evenly and on the clock of the sound itself, so ' +
|
||||
'a sweep sounds the same every time; 0 is a quick glide.',
|
||||
'time: seconds the values take to get there, along the chosen easing and on the clock of the ' +
|
||||
'sound itself, so a sweep sounds the same every time; 0 is a quick glide.',
|
||||
inputs: [
|
||||
exec(),
|
||||
{ ...num('voice'), inline: false },
|
||||
@@ -26,12 +29,18 @@ export const SetSoundFxParameters: NodeDef = {
|
||||
num('time'),
|
||||
],
|
||||
outputs: [exec()],
|
||||
params: [],
|
||||
run: (ctx, inputs) => {
|
||||
params: easingParams(),
|
||||
hiddenParams: hiddenEasing,
|
||||
run: (ctx, inputs, params, _def, _entry, lookup) => {
|
||||
const voice = asNumber(inputs.voice);
|
||||
const time = asNumber(inputs.time);
|
||||
if (voice > 0)
|
||||
ctx.tuneSound(voice, fxParamsOf(inputs.fxParams), Number.isFinite(time) && time > 0 ? time : 0);
|
||||
ctx.tuneSound(
|
||||
voice,
|
||||
fxParamsOf(inputs.fxParams),
|
||||
Number.isFinite(time) && time > 0 ? time : 0,
|
||||
easeShareOf(easingOfParams(params), curvesOf(lookup)),
|
||||
);
|
||||
return { next: 'exec' };
|
||||
},
|
||||
};
|
||||
@@ -1,60 +1,27 @@
|
||||
import type { GraphNode } from '../../Graph';
|
||||
import type { GraphLookup } from '../../lookup';
|
||||
import type { NodeDef } from '../../NodeRegistry';
|
||||
import { asNumber } from '../../values';
|
||||
import { curvesOf, easingOfParams, easingParams, hiddenEasing } from '../easing';
|
||||
import { num, withDefault } from '../shared';
|
||||
|
||||
import { curveValue, LERP, LERP_CURVE } from '../../../model/curve';
|
||||
import { textOf } from '../../../model/Properties';
|
||||
|
||||
/** Quadratic curves over t in 0…1, each running from 0 to 1. */
|
||||
const EASES: Record<string, (t: number) => number> = {
|
||||
linear: (t) => t,
|
||||
in: (t) => t * t,
|
||||
out: (t) => 1 - (1 - t) * (1 - t),
|
||||
inOut: (t) => (t < 0.5 ? 2 * t * t : 1 - 2 * (1 - t) * (1 - t)),
|
||||
};
|
||||
import { easedShare } from '../../../model/animation';
|
||||
import { LERP } from '../../../model/curve';
|
||||
|
||||
export const Lerp: NodeDef = {
|
||||
type: LERP,
|
||||
label: 'Lerp',
|
||||
category: 'data',
|
||||
description:
|
||||
'Blends from a to b as t runs from 0 to 1, along the chosen ease. t is held in 0…1. With Ease ' +
|
||||
'set to Curve it follows the Curve asset picked below, and may overshoot b.',
|
||||
'Blends from a to b as t runs from 0 to 1, along the chosen easing – the same ones an animation ' +
|
||||
'key offers. t is held in 0…1. Step stays at a until t reaches 1. Back, Elastic and a Curve ' +
|
||||
'asset may overshoot b.',
|
||||
inputs: [withDefault(num('a'), 0), withDefault(num('b'), 1), withDefault(num('t'), 0)],
|
||||
outputs: [num('result')],
|
||||
params: [
|
||||
{
|
||||
key: 'ease',
|
||||
label: 'Ease',
|
||||
kind: 'enum',
|
||||
default: 'linear',
|
||||
options: [
|
||||
{ value: 'linear', label: 'Linear' },
|
||||
{ value: 'in', label: 'In' },
|
||||
{ value: 'out', label: 'Out' },
|
||||
{ value: 'inOut', label: 'In-out' },
|
||||
{ value: 'curve', label: 'Curve' },
|
||||
],
|
||||
},
|
||||
{ key: LERP_CURVE, label: 'Curve', kind: 'asset', default: '', assetTypes: ['curve'] },
|
||||
],
|
||||
hiddenParams: (params) => (textOf(params.ease) === 'curve' ? [] : [LERP_CURVE]),
|
||||
params: easingParams(),
|
||||
hiddenParams: hiddenEasing,
|
||||
run: (_ctx, inputs, params, _def, _entry, lookup) => {
|
||||
const a = asNumber(inputs.a);
|
||||
const t = Math.min(Math.max(asNumber(inputs.t), 0), 1);
|
||||
const ease = textOf(params.ease);
|
||||
const points = ease === 'curve' ? lookup.curve(textOf(params[LERP_CURVE] ?? '')) : null;
|
||||
const share = points ? curveValue(points, t) : (EASES[ease] ?? EASES.linear!)(t);
|
||||
const share = easedShare(easingOfParams(params), t, curvesOf(lookup));
|
||||
return { outputs: { result: a + (asNumber(inputs.b) - a) * share } };
|
||||
},
|
||||
};
|
||||
|
||||
/** Why a Lerp set to Curve runs linear instead, or null when it eases as it says. */
|
||||
export function lerpProblem(node: GraphNode, lookup: GraphLookup): string | null {
|
||||
if (node.type !== LERP || textOf(node.params.ease) !== 'curve') return null;
|
||||
const id = textOf(node.params[LERP_CURVE] ?? '');
|
||||
if (!id) return 'No curve picked – runs linear';
|
||||
return lookup.curve(id) ? null : 'That curve is gone – runs linear';
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { GraphNode } from '../Graph';
|
||||
import type { GraphLookup } from '../lookup';
|
||||
|
||||
import type { KeyInterpolation } from '../../model/Asset';
|
||||
import { EASING_DIRECTIONS, EASING_SHAPES, easingOf, easingValue } from '../../model/animation';
|
||||
import { type CurveLookup, EASED_NODES, LERP_CURVE } from '../../model/curve';
|
||||
import { type PropertyBag, textOf } from '../../model/Properties';
|
||||
import type { PropertyDef } from '../../properties/PropertySchema';
|
||||
|
||||
const EASE = 'ease';
|
||||
const DIRECTION = 'direction';
|
||||
|
||||
/** The easings an animation key offers, as node settings: the same choice wherever a value runs somewhere. */
|
||||
export function easingParams(): PropertyDef[] {
|
||||
return [
|
||||
{
|
||||
key: EASE,
|
||||
label: 'Easing',
|
||||
kind: 'enum',
|
||||
default: 'linear',
|
||||
options: EASING_SHAPES.map((o) => ({ ...o })),
|
||||
},
|
||||
{
|
||||
key: DIRECTION,
|
||||
label: 'Direction',
|
||||
kind: 'enum',
|
||||
default: 'in-out',
|
||||
options: EASING_DIRECTIONS.map((o) => ({ ...o })),
|
||||
},
|
||||
{ key: LERP_CURVE, label: 'Curve', kind: 'asset', default: '', assetTypes: ['curve'] },
|
||||
];
|
||||
}
|
||||
|
||||
/** The settings read as an animation key's interpolation; unknown text is linear. */
|
||||
export function easingOfParams(params: PropertyBag): KeyInterpolation {
|
||||
const ease = textOf(params[EASE] ?? 'linear');
|
||||
const shape = EASING_SHAPES.find((o) => o.value === ease)?.value ?? 'linear';
|
||||
const direction = EASING_DIRECTIONS.find((o) => o.value === textOf(params[DIRECTION] ?? ''))?.value;
|
||||
return easingValue(shape, direction, textOf(params[LERP_CURVE] ?? ''));
|
||||
}
|
||||
|
||||
/** The settings that say nothing for this easing, for `hiddenParams`. */
|
||||
export function hiddenEasing(params: PropertyBag): string[] {
|
||||
const { shape, direction } = easingOf(easingOfParams(params));
|
||||
return [...(direction ? [] : [DIRECTION]), ...(shape === 'curve' ? [] : [LERP_CURVE])];
|
||||
}
|
||||
|
||||
export function curvesOf(lookup: GraphLookup): CurveLookup {
|
||||
return (id) => lookup.curve(id) ?? undefined;
|
||||
}
|
||||
|
||||
/** Why a node set to Curve runs linear instead, or null when it eases as it says. */
|
||||
export function easingProblem(node: GraphNode, lookup: GraphLookup): string | null {
|
||||
if (!EASED_NODES.includes(node.type)) return null;
|
||||
const { shape, curve } = easingOf(easingOfParams(node.params));
|
||||
if (shape !== 'curve') return null;
|
||||
if (!curve) return 'No curve picked – runs linear';
|
||||
return lookup.curve(curve) ? null : 'That curve is gone – runs linear';
|
||||
}
|
||||
@@ -233,7 +233,8 @@ export function easedShare(
|
||||
): number {
|
||||
const { shape, direction, curve } = easingOf(interpolation);
|
||||
if (shape === 'linear') return t;
|
||||
if (shape === 'step') return 0;
|
||||
// A key reads the next key's own value once it is there, so for it a step only ever holds.
|
||||
if (shape === 'step') return t >= 1 ? 1 : 0;
|
||||
if (shape === 'curve') {
|
||||
const points = curves(curve ?? '');
|
||||
return points ? curveValue(points, t) : t;
|
||||
@@ -249,6 +250,19 @@ export function easedShare(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* An easing as something that runs on its own clock, the audio one: the two shapes it has built in
|
||||
* stay names, everything else is the share at each `t`, a gone curve already read as linear.
|
||||
*/
|
||||
export type EaseShare = 'linear' | 'step' | ((t: number) => number);
|
||||
|
||||
export function easeShareOf(interpolation: KeyInterpolation, curves: CurveLookup = NO_CURVES): EaseShare {
|
||||
const { shape, curve } = easingOf(interpolation);
|
||||
if (shape === 'linear' || shape === 'step') return shape;
|
||||
if (shape === 'curve' && !curves(curve ?? '')) return 'linear';
|
||||
return (t) => easedShare(interpolation, t, curves);
|
||||
}
|
||||
|
||||
/** The value between two keys, `t` from 0 (at `a`) to 1 (at `b`). */
|
||||
export function blendValue(kind: string, a: PropertyValue, b: PropertyValue, t: number): PropertyValue {
|
||||
if (kind === 'number') {
|
||||
|
||||
@@ -15,8 +15,10 @@ import { cloneVectorPoint } from './vectorart';
|
||||
/** The node type that eases along a curve, and its setting naming the curve. */
|
||||
export const LERP = 'Lerp';
|
||||
export const LERP_CURVE = 'curve';
|
||||
/** Every node type with the easing settings of a `Lerp`, the curve among them. */
|
||||
export const EASED_NODES: readonly string[] = [LERP, 'SetSoundFxParameters'];
|
||||
|
||||
/** How many animation keys and `Lerp` nodes ease along a curve – what deleting it has to warn about. */
|
||||
/** How many animation keys and eased nodes run along a curve – what deleting it has to warn about. */
|
||||
export function curveUsages(project: Project, curveId: string): { keys: number; nodes: number } {
|
||||
let keys = 0;
|
||||
let nodes = 0;
|
||||
@@ -25,7 +27,9 @@ export function curveUsages(project: Project, curveId: string): { keys: number;
|
||||
for (const track of asset.tracks)
|
||||
keys += track.keys.filter((key) => key.interpolation === `curve:${curveId}`).length;
|
||||
if (isGraphAsset(asset))
|
||||
nodes += asset.graph.nodes.filter((n) => n.type === LERP && n.params[LERP_CURVE] === curveId).length;
|
||||
nodes += asset.graph.nodes.filter(
|
||||
(n) => EASED_NODES.includes(n.type) && n.params[LERP_CURVE] === curveId,
|
||||
).length;
|
||||
}
|
||||
return { keys, nodes };
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { EaseShare } from '../../core/model/animation';
|
||||
import type { FxParams, FxVoice } from '../../core/soundfx/nodes';
|
||||
|
||||
/**
|
||||
@@ -23,9 +24,10 @@ export interface AudioSink {
|
||||
* A name its FX does not have is left out, one not given keeps its value. A voice that has ended
|
||||
* is not there any more and nothing happens – a detached one is reached like any other. `time` is
|
||||
* how many seconds the values take to get there, counted on the audio clock from now – or from
|
||||
* the moment the voice really starts, where it still waits on its bytes. 0 is a quick glide.
|
||||
* the moment the voice really starts, where it still waits on its bytes, along `ease` (linear
|
||||
* where it is not given). 0 is a quick glide.
|
||||
*/
|
||||
tune(voice: number, params: FxParams, time?: number): void;
|
||||
tune(voice: number, params: FxParams, time?: number, ease?: EaseShare): void;
|
||||
/** Silences every voice of that file; `''` silences all of them. */
|
||||
stop(file: string): void;
|
||||
/** Silences every voice started by that owner – what destroying a behavior does. */
|
||||
|
||||
@@ -172,7 +172,7 @@ export function behaviorContext(
|
||||
const file = assetId === '' ? '' : soundFileOf(project, assetId);
|
||||
if (assetId === '' || file) run.audio.stop(file);
|
||||
},
|
||||
tuneSound: (voice, fxParams, time) => run.audio.tune(voice, fxParams, time),
|
||||
tuneSound: (voice, fxParams, time, ease) => run.audio.tune(voice, fxParams, time, ease),
|
||||
spawn: (assetId, position, parent) => world.spawn(assetId, position, resolve(parent) ?? null),
|
||||
addJoint: (assetId, owner, connected, at) => {
|
||||
const on = resolve(owner);
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -208,8 +208,15 @@ describe('a key and a Lerp that ease along a curve', () => {
|
||||
findAssetOf(f.project, behavior.asset.id, 'behavior')!.graph.nodes.push(
|
||||
{ id: 'l1', type: 'Lerp', x: 0, y: 0, params: { ease: 'curve', curve: curve.asset.id } },
|
||||
{ id: 'l2', type: 'Lerp', x: 0, y: 0, params: { ease: 'linear' } },
|
||||
{
|
||||
id: 's1',
|
||||
type: 'SetSoundFxParameters',
|
||||
x: 0,
|
||||
y: 0,
|
||||
params: { ease: 'curve', curve: curve.asset.id },
|
||||
},
|
||||
);
|
||||
expect(curveUsages(f.project, curve.asset.id)).toEqual({ keys: 2, nodes: 1 });
|
||||
expect(curveUsages(f.project, curve.asset.id)).toEqual({ keys: 2, nodes: 2 });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -834,25 +834,65 @@ const CASES: Record<string, Case[]> = {
|
||||
],
|
||||
Lerp: [
|
||||
{ name: 'linear by default', inputs: { a: 10, b: 20, t: 0.25 }, outputs: { result: 12.5 } },
|
||||
{ name: 'ease in', inputs: { a: 0, b: 8, t: 0.5 }, params: { ease: 'in' }, outputs: { result: 2 } },
|
||||
{ name: 'ease out', inputs: { a: 0, b: 8, t: 0.5 }, params: { ease: 'out' }, outputs: { result: 6 } },
|
||||
{
|
||||
name: 'ease in-out, slow at both ends',
|
||||
inputs: { a: 0, b: 8, t: 0.25 },
|
||||
params: { ease: 'inOut' },
|
||||
name: 'the easings of an animation key: cubic in',
|
||||
inputs: { a: 0, b: 8, t: 0.5 },
|
||||
params: { ease: 'cubic', direction: 'in' },
|
||||
outputs: { result: 1 },
|
||||
},
|
||||
{
|
||||
name: 'ease in-out, half way at the middle',
|
||||
name: 'quad out',
|
||||
inputs: { a: 0, b: 8, t: 0.5 },
|
||||
params: { ease: 'inOut' },
|
||||
params: { ease: 'quad', direction: 'out' },
|
||||
outputs: { result: 6 },
|
||||
},
|
||||
{
|
||||
name: 'a curve without a direction set eases both ways',
|
||||
inputs: { a: 0, b: 8, t: 0.25 },
|
||||
params: { ease: 'cubic' },
|
||||
outputs: { result: 0.5 },
|
||||
},
|
||||
{
|
||||
name: 'step stays at a until t reaches 1',
|
||||
inputs: { a: 2, b: 8, t: 0.99 },
|
||||
params: { ease: 'step' },
|
||||
outputs: { result: 2 },
|
||||
},
|
||||
{
|
||||
name: 'step at t 1 is b',
|
||||
inputs: { a: 2, b: 8, t: 1 },
|
||||
params: { ease: 'step' },
|
||||
outputs: { result: 8 },
|
||||
},
|
||||
{
|
||||
name: 'a direction says nothing for linear',
|
||||
inputs: { a: 0, b: 8, t: 0.5 },
|
||||
params: { ease: 'linear', direction: 'in' },
|
||||
outputs: { result: 4 },
|
||||
},
|
||||
{
|
||||
name: 'quad in-out, slow at both ends',
|
||||
inputs: { a: 0, b: 8, t: 0.25 },
|
||||
params: { ease: 'quad', direction: 'in-out' },
|
||||
outputs: { result: 1 },
|
||||
},
|
||||
{
|
||||
name: 'quad in-out, half way at the middle',
|
||||
inputs: { a: 0, b: 8, t: 0.5 },
|
||||
params: { ease: 'quad', direction: 'in-out' },
|
||||
outputs: { result: 4 },
|
||||
},
|
||||
{
|
||||
name: 'an unknown easing runs linear',
|
||||
inputs: { a: 0, b: 8, t: 0.25 },
|
||||
params: { ease: 'in' },
|
||||
outputs: { result: 2 },
|
||||
},
|
||||
{ name: 't past 1 stops at b', inputs: { a: 3, b: -5, t: 1.2 }, outputs: { result: -5 } },
|
||||
{
|
||||
name: 't below 0 stops at a',
|
||||
inputs: { a: 3, b: -5, t: -0.5 },
|
||||
params: { ease: 'out' },
|
||||
params: { ease: 'quad', direction: 'out' },
|
||||
outputs: { result: 3 },
|
||||
},
|
||||
{
|
||||
@@ -2032,19 +2072,58 @@ const CASES: Record<string, Case[]> = {
|
||||
name: 'hands the numbers of the bundle to the voice it is given',
|
||||
inputs: { voice: 4, fxParams: { depth: 3, label: 'x' } },
|
||||
next: 'exec',
|
||||
after: (ctx) => expect(ctx.tuned).toEqual([{ voice: 4, fxParams: { depth: 3 }, time: 0 }]),
|
||||
after: (ctx) =>
|
||||
expect(ctx.tuned).toEqual([{ voice: 4, fxParams: { depth: 3 }, time: 0, ease: 'linear' }]),
|
||||
},
|
||||
{
|
||||
name: 'hands on the time the values take to get there',
|
||||
inputs: { voice: 4, fxParams: { depth: -64 }, time: 1.28 },
|
||||
next: 'exec',
|
||||
after: (ctx) => expect(ctx.tuned).toEqual([{ voice: 4, fxParams: { depth: -64 }, time: 1.28 }]),
|
||||
after: (ctx) =>
|
||||
expect(ctx.tuned).toEqual([{ voice: 4, fxParams: { depth: -64 }, time: 1.28, ease: 'linear' }]),
|
||||
},
|
||||
{
|
||||
name: 'a time below 0 is a quick glide',
|
||||
inputs: { voice: 4, fxParams: { depth: 3 }, time: -2 },
|
||||
next: 'exec',
|
||||
after: (ctx) => expect(ctx.tuned).toEqual([{ voice: 4, fxParams: { depth: 3 }, time: 0 }]),
|
||||
after: (ctx) =>
|
||||
expect(ctx.tuned).toEqual([{ voice: 4, fxParams: { depth: 3 }, time: 0, ease: 'linear' }]),
|
||||
},
|
||||
{
|
||||
name: 'hands on the easing the values take: a curve with its direction as a share of the way',
|
||||
inputs: { voice: 4, fxParams: { depth: 3 }, time: 1 },
|
||||
params: { ease: 'cubic', direction: 'in' },
|
||||
next: 'exec',
|
||||
after: (ctx) => {
|
||||
const ease = ctx.tuned[0]!.ease;
|
||||
expect(typeof ease === 'function' && [ease(0), ease(0.5), ease(1)]).toEqual([0, 0.125, 1]);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'step is handed on by name, the clock jumps at the end',
|
||||
inputs: { voice: 4, fxParams: { depth: 3 }, time: 1 },
|
||||
params: { ease: 'step' },
|
||||
next: 'exec',
|
||||
after: (ctx) => expect(ctx.tuned[0]!.ease).toBe('step'),
|
||||
},
|
||||
{
|
||||
name: 'along a Curve asset, as a share that may overshoot',
|
||||
inputs: { voice: 4, fxParams: { depth: 3 }, time: 1 },
|
||||
params: { ease: 'curve', curve: 'c' },
|
||||
lookup: overshootingCurve,
|
||||
next: 'exec',
|
||||
after: (ctx) => {
|
||||
const ease = ctx.tuned[0]!.ease;
|
||||
expect(typeof ease === 'function' && ease(0.5)).toBeCloseTo(1.5, 6);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'a Curve asset that is gone is linear',
|
||||
inputs: { voice: 4, fxParams: { depth: 3 }, time: 1 },
|
||||
params: { ease: 'curve', curve: 'gone' },
|
||||
lookup: overshootingCurve,
|
||||
next: 'exec',
|
||||
after: (ctx) => expect(ctx.tuned[0]!.ease).toBe('linear'),
|
||||
},
|
||||
{
|
||||
name: 'voice 0 – nothing was played – reaches nothing, and the flow carries on',
|
||||
|
||||
+3
-2
@@ -23,6 +23,7 @@ import {
|
||||
VARIABLES_PARAM,
|
||||
} from '../src/core/graph';
|
||||
import type { GraphInterpreter } from '../src/core/graph/GraphInterpreter';
|
||||
import type { EaseShare } from '../src/core/model/animation';
|
||||
import type { BehaviorAsset, SceneAsset, VariablesAsset } from '../src/core/model/Asset';
|
||||
import { findObject } from '../src/core/model/hierarchy';
|
||||
import { worldPosition } from '../src/core/properties/transform';
|
||||
@@ -214,7 +215,7 @@ export type FakeContext = NodeContext & {
|
||||
fxParams: Readonly<Record<string, number>>;
|
||||
}[];
|
||||
silenced: string[];
|
||||
tuned: { voice: number; fxParams: Readonly<Record<string, number>>; time: number }[];
|
||||
tuned: { voice: number; fxParams: Readonly<Record<string, number>>; time: number; ease: EaseShare }[];
|
||||
/** The scenes the context was asked to load, in order. */
|
||||
loaded: string[];
|
||||
/** The time scale and the pause the context holds, as the game nodes left them. */
|
||||
@@ -271,7 +272,7 @@ export function fakeContext(overrides: Partial<NodeContext> = {}): FakeContext {
|
||||
ctx.played.push({ assetId, volume, loop, detached, fx, fxParams }),
|
||||
stopSound: (assetId) => ctx.silenced.push(assetId),
|
||||
tuned: [],
|
||||
tuneSound: (voice, fxParams, time) => ctx.tuned.push({ voice, fxParams, time }),
|
||||
tuneSound: (voice, fxParams, time, ease) => ctx.tuned.push({ voice, fxParams, time, ease }),
|
||||
loadScene: (sceneId) => ctx.loaded.push(sceneId),
|
||||
spawn: () => null,
|
||||
addJoint: () => null,
|
||||
|
||||
@@ -13,18 +13,20 @@ const FILE = 'a.wav';
|
||||
const OTHER = 'b.wav';
|
||||
|
||||
/**
|
||||
* An AudioParam that remembers where it was sent to glide, where it was sent to ramp – "linear" or
|
||||
* "exponential", to what, by when – and how often it was held. It has no cancelAndHoldAtTime, as in
|
||||
* Firefox, so the hold by hand is what runs here.
|
||||
* An AudioParam that remembers where it was sent to glide, where it was sent to ramp – "linear",
|
||||
* "exponential", "set" for a jump later on or "curve" with the samples' first, middle and last value,
|
||||
* to what, by when – and how often it was held. It has no cancelAndHoldAtTime, as in Firefox, so the
|
||||
* hold by hand (a set at the clock's 0) is what runs here.
|
||||
*/
|
||||
interface FakeParam {
|
||||
value: number;
|
||||
glides: number[];
|
||||
ramps: [string, number, number][];
|
||||
ramps: [string, number | number[], number][];
|
||||
holds: number;
|
||||
setTargetAtTime: (value: number) => void;
|
||||
cancelScheduledValues: () => void;
|
||||
setValueAtTime: () => void;
|
||||
setValueAtTime: (value: number, at: number) => void;
|
||||
setValueCurveAtTime: (values: Float32Array, at: number, time: number) => void;
|
||||
linearRampToValueAtTime: (value: number, at: number) => void;
|
||||
exponentialRampToValueAtTime: (value: number, at: number) => void;
|
||||
}
|
||||
@@ -37,7 +39,9 @@ function param(value: number): FakeParam {
|
||||
holds: 0,
|
||||
setTargetAtTime: (to) => made.glides.push(to),
|
||||
cancelScheduledValues: () => {},
|
||||
setValueAtTime: () => void (made.holds += 1),
|
||||
setValueAtTime: (to, at) => (at > 0 ? void made.ramps.push(['set', to, at]) : void (made.holds += 1)),
|
||||
setValueCurveAtTime: (values, at, time) =>
|
||||
made.ramps.push(['curve', [values[0]!, values[(values.length - 1) / 2]!, values.at(-1)!], at + time]),
|
||||
linearRampToValueAtTime: (to, at) => made.ramps.push(['linear', to, at]),
|
||||
exponentialRampToValueAtTime: (to, at) => made.ramps.push(['exponential', to, at]),
|
||||
};
|
||||
@@ -440,6 +444,29 @@ describe('the sound library', () => {
|
||||
expect(sources[0]!.playbackRate.glides).toEqual([1]);
|
||||
});
|
||||
|
||||
it('runs a timed change along an easing as a curve, each number in its own steps', async () => {
|
||||
const library = new SoundLibrary();
|
||||
const voice = await play(library, FILE, { owner: 'me' }, { plan: open, params: {} });
|
||||
const loud = gains[3]!;
|
||||
const from = loud.value;
|
||||
library.tune(voice, { depth: 12, loud: -6 }, 1.5, (t) => t * t);
|
||||
// Half the time is a quarter of the way: a quarter of the octave, a quarter of the decibels.
|
||||
expect(sources[0]!.playbackRate.ramps).toEqual([['curve', [1, expect.closeTo(2 ** 0.25), 2], 1.5]]);
|
||||
const quarter = from * (10 ** (-6 / 20) / from) ** 0.25;
|
||||
expect(loud.ramps).toEqual([
|
||||
['curve', [from, expect.closeTo(quarter), expect.closeTo(10 ** (-6 / 20))], 1.5],
|
||||
]);
|
||||
expect(sources[0]!.playbackRate.holds).toBe(1);
|
||||
});
|
||||
|
||||
it('a step holds the value for the time and then jumps', async () => {
|
||||
const library = new SoundLibrary();
|
||||
const voice = await play(library, FILE, { owner: 'me' }, { plan: open, params: {} });
|
||||
library.tune(voice, { depth: 12 }, 2, 'step');
|
||||
expect(sources[0]!.playbackRate.ramps).toEqual([['set', 2, 2]]);
|
||||
expect(sources[0]!.playbackRate.glides).toEqual([]);
|
||||
});
|
||||
|
||||
it('starts a timed change to a voice still waiting on its bytes when the voice starts', async () => {
|
||||
const library = new SoundLibrary();
|
||||
const voice = await play(library, FILE, { hold: true, owner: 'me' }, { plan: open, params: {} });
|
||||
|
||||
+15
-7
@@ -1150,8 +1150,9 @@ export async function graph(t) {
|
||||
|
||||
await showTab('scene');
|
||||
|
||||
// 6z-iii. Lerp (#246): typed into the palette it arrives linear with a 0…1 range, and the ease
|
||||
// picked in the inspector lands on the node and on its setting row.
|
||||
// 6z-iii. Lerp (#246): typed into the palette it arrives linear with a 0…1 range, and the easing
|
||||
// picked in the inspector – the choice an animation key has – lands on the node and on its
|
||||
// setting rows: a curve brings its direction along.
|
||||
async function lerpNode() {
|
||||
await mutate(`
|
||||
const p = __gm.state.project;
|
||||
@@ -1177,20 +1178,27 @@ export async function graph(t) {
|
||||
'typing "lerp" into the palette adds a linear Lerp from 0 to 1',
|
||||
lerp?.type === 'Lerp' &&
|
||||
JSON.stringify(inputs) === JSON.stringify(['a 0', 'b 1', 't 0']) &&
|
||||
JSON.stringify(settings) === JSON.stringify(['Ease: Linear']),
|
||||
JSON.stringify(settings) === JSON.stringify(['Easing: Linear']),
|
||||
JSON.stringify({ lerp, inputs, settings }),
|
||||
);
|
||||
const easeField = await json(`__gm.inspectorField('Ease')`);
|
||||
const easeField = await json(`__gm.inspectorField('Easing')`);
|
||||
if (easeField) {
|
||||
await click(easeField.x, easeField.y);
|
||||
await clickText('Cubic');
|
||||
}
|
||||
const directionField = await json(`__gm.inspectorField('Direction')`);
|
||||
if (directionField) {
|
||||
await click(directionField.x, directionField.y);
|
||||
await clickText('Out');
|
||||
}
|
||||
const picked = lerp ? (await asset('lerpy')).graph.nodes.find((n) => n.id === lerp.id) : null;
|
||||
const pickedRows = lerp ? await json(`__gm.graph.nodeSettings(${JSON.stringify(lerp.id)})`) : null;
|
||||
check(
|
||||
'picking "Out" in the inspector sets the node and its setting row',
|
||||
picked?.params.ease === 'out' && JSON.stringify(pickedRows) === JSON.stringify(['Ease: Out']),
|
||||
JSON.stringify({ easeField, params: picked?.params, pickedRows }),
|
||||
'picking "Cubic" and "Out" in the inspector sets the node and its setting rows',
|
||||
picked?.params.ease === 'cubic' &&
|
||||
picked.params.direction === 'out' &&
|
||||
JSON.stringify(pickedRows) === JSON.stringify(['Easing: Cubic', 'Direction: Out']),
|
||||
JSON.stringify({ easeField, directionField, params: picked?.params, pickedRows }),
|
||||
);
|
||||
await shot('22-lerp');
|
||||
await mutate(`
|
||||
|
||||
@@ -492,6 +492,38 @@ export async function sound(t) {
|
||||
JSON.stringify({ early, later, afterHalf }),
|
||||
);
|
||||
await stop();
|
||||
// The easings of an animation key: Quad in hands the clock a curve, which is still near the start
|
||||
// 0.7 s in and only then falls to the end (speed 0.5). Read a little later than that for the
|
||||
// round trips, it is above 0.8 until 1.1 s; linear is below 0.79 from 0.7 s on.
|
||||
await mutate(`
|
||||
const tune = __gm.state.project.assets.find((a) => a.id === 'sn-logic').graph.nodes.find((n) => n.id === 'sn-tune');
|
||||
Object.assign(tune.params, { ease: 'quad', direction: 'in' });`);
|
||||
await play();
|
||||
await waitFor(speed, (s) => typeof s === 'number');
|
||||
await new Promise((resolve) => setTimeout(resolve, 700));
|
||||
const easedEarly = await speed();
|
||||
const easedEnd = await waitFor(speed, (s) => typeof s === 'number' && s < 0.52);
|
||||
check(
|
||||
'Set sound FX parameters with Quad in ramps slowly first, then on to the end',
|
||||
easedEarly > 0.8 && easedEarly < 0.995 && easedEnd < 0.52,
|
||||
JSON.stringify({ easedEarly, easedEnd }),
|
||||
);
|
||||
await stop();
|
||||
// Step holds the pitch for the whole time and then jumps there.
|
||||
await mutate(`
|
||||
const tune = __gm.state.project.assets.find((a) => a.id === 'sn-logic').graph.nodes.find((n) => n.id === 'sn-tune');
|
||||
Object.assign(tune.params, { ease: 'step', time: 1 });`);
|
||||
await play();
|
||||
await waitFor(speed, (s) => typeof s === 'number');
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
const stepHeld = await speed();
|
||||
const stepEnd = await waitFor(speed, (s) => typeof s === 'number' && s < 0.9);
|
||||
check(
|
||||
'Set sound FX parameters with Step holds the pitch for the time, then jumps to the end',
|
||||
stepHeld === 1 && Math.abs(stepEnd - 0.5) < 0.001,
|
||||
JSON.stringify({ stepHeld, stepEnd }),
|
||||
);
|
||||
await stop();
|
||||
await mutate(`
|
||||
const logic = __gm.state.project.assets.find((a) => a.id === 'sn-logic');
|
||||
logic.variables = [];
|
||||
|
||||
@@ -235,7 +235,7 @@ export async function curves(t) {
|
||||
);
|
||||
await shot('53-curve-key');
|
||||
|
||||
// 53e. A Lerp picks it too – Ease: Curve, then the curve in its row – and the game logs its value.
|
||||
// 53e. A Lerp picks it too – Easing: Curve, then the curve in its row – and the game logs its value.
|
||||
await mutate(`
|
||||
const p = __gm.state.project;
|
||||
p.assets.push({ id: 'cv-lerpy', type: 'behavior', name: 'Curve lerp', props: { enabled: true, runInEditor: false },
|
||||
@@ -270,7 +270,7 @@ export async function curves(t) {
|
||||
await settle();
|
||||
return option;
|
||||
};
|
||||
await pick('Ease', 'Curve');
|
||||
await pick('Easing', 'Curve');
|
||||
const unpicked = await json("__gm.graph.nodeSettings('cv-lerp')");
|
||||
const name = await json(`__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(curveId)}).name`);
|
||||
const picked = await pick('Curve', `${name} (curve)`);
|
||||
@@ -279,7 +279,7 @@ export async function curves(t) {
|
||||
);
|
||||
const settings = await json("__gm.graph.nodeSettings('cv-lerp')");
|
||||
check(
|
||||
'Ease: Curve brings the Curve row onto the node, and picking the curve there sets it',
|
||||
'Easing: Curve brings the Curve row onto the node, and picking the curve there sets it',
|
||||
params?.ease === 'curve' &&
|
||||
params.curve === curveId &&
|
||||
Boolean(picked) &&
|
||||
|
||||
Reference in new issue
Block a user