diff --git a/src/core/graph/NodeContext.ts b/src/core/graph/NodeContext.ts index c6b9927d..8304ef41 100644 --- a/src/core/graph/NodeContext.ts +++ b/src/core/graph/NodeContext.ts @@ -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 diff --git a/src/core/graph/nested.ts b/src/core/graph/nested.ts index 77407b7c..3f54ac6a 100644 --- a/src/core/graph/nested.ts +++ b/src/core/graph/nested.ts @@ -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'; diff --git a/src/core/graph/nodes/action/SetSoundFxParameters.ts b/src/core/graph/nodes/action/SetSoundFxParameters.ts index bf265efe..61f2d36e 100644 --- a/src/core/graph/nodes/action/SetSoundFxParameters.ts +++ b/src/core/graph/nodes/action/SetSoundFxParameters.ts @@ -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' }; }, }; diff --git a/src/core/graph/nodes/data/Lerp.ts b/src/core/graph/nodes/data/Lerp.ts index 6afd05a0..e950c298 100644 --- a/src/core/graph/nodes/data/Lerp.ts +++ b/src/core/graph/nodes/data/Lerp.ts @@ -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 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'; -} diff --git a/src/core/graph/nodes/easing.ts b/src/core/graph/nodes/easing.ts new file mode 100644 index 00000000..4054a421 --- /dev/null +++ b/src/core/graph/nodes/easing.ts @@ -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'; +} diff --git a/src/core/model/animation.ts b/src/core/model/animation.ts index 3c7e3f77..8f92e377 100644 --- a/src/core/model/animation.ts +++ b/src/core/model/animation.ts @@ -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') { diff --git a/src/core/model/curve.ts b/src/core/model/curve.ts index 461c3c65..84d96e60 100644 --- a/src/core/model/curve.ts +++ b/src/core/model/curve.ts @@ -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 }; } diff --git a/src/runtime/systems/Audio.ts b/src/runtime/systems/Audio.ts index ff8d0ce9..2de66169 100644 --- a/src/runtime/systems/Audio.ts +++ b/src/runtime/systems/Audio.ts @@ -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. */ diff --git a/src/runtime/systems/behaviorContext.ts b/src/runtime/systems/behaviorContext.ts index 46e4d259..05db358b 100644 --- a/src/runtime/systems/behaviorContext.ts +++ b/src/runtime/systems/behaviorContext.ts @@ -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); diff --git a/src/viewport/SoundLibrary.ts b/src/viewport/SoundLibrary.ts index 1e33c08c..47a78679 100644 --- a/src/viewport/SoundLibrary.ts +++ b/src/viewport/SoundLibrary.ts @@ -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 & Partial>; 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. */ diff --git a/tests/core/curve.test.ts b/tests/core/curve.test.ts index 7070ae90..7fa29027 100644 --- a/tests/core/curve.test.ts +++ b/tests/core/curve.test.ts @@ -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 }); }); }); diff --git a/tests/core/node-catalog.test.ts b/tests/core/node-catalog.test.ts index e2917367..46a7f5eb 100644 --- a/tests/core/node-catalog.test.ts +++ b/tests/core/node-catalog.test.ts @@ -834,25 +834,65 @@ const CASES: Record = { ], 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 = { 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', diff --git a/tests/helpers.ts b/tests/helpers.ts index 082ffe11..6d1e663a 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -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>; }[]; silenced: string[]; - tuned: { voice: number; fxParams: Readonly>; time: number }[]; + tuned: { voice: number; fxParams: Readonly>; 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 = {}): 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, diff --git a/tests/viewport/sound-library.test.ts b/tests/viewport/sound-library.test.ts index 565cb66c..970af6f8 100644 --- a/tests/viewport/sound-library.test.ts +++ b/tests/viewport/sound-library.test.ts @@ -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: {} }); diff --git a/tools/e2e/06-graph.mjs b/tools/e2e/06-graph.mjs index a13100bf..34e4799b 100644 --- a/tools/e2e/06-graph.mjs +++ b/tools/e2e/06-graph.mjs @@ -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(` diff --git a/tools/e2e/34-sound.mjs b/tools/e2e/34-sound.mjs index 0455df14..609ee505 100644 --- a/tools/e2e/34-sound.mjs +++ b/tools/e2e/34-sound.mjs @@ -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 = []; diff --git a/tools/e2e/53-curves.mjs b/tools/e2e/53-curves.mjs index 575e0429..ed915956 100644 --- a/tools/e2e/53-curves.mjs +++ b/tools/e2e/53-curves.mjs @@ -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) &&