From 804a97b0a9f867816c409ce8ba037e93fd78c195 Mon Sep 17 00:00:00 2001 From: MelekTaus Date: Mon, 5 Oct 2026 22:27:44 +0200 Subject: [PATCH] Hand out the length of a played sound from Play sound Wired into the time of Set sound FX parameters, a change then spans exactly one run of the clip, so a fade or a filter sweep ends with its last sample. - Play sound has a second output, length, in seconds. It is 0 where nothing was played or the sound has not loaded yet. - The AudioSink gives the length of a file, the SoundLibrary takes it from the decoded buffer. A pitch the voice starts on keeps it (#312). - A pitch changed while the voice plays still changes its length, so a pitch sweep over length does not end with the sound. A pitch that keeps the length in real time is #317. Tests: - soundfx, sounds, keep-on-scene-load, node-catalog, sound-library: 462 tests green. - Counter-check: with length always 0, the catalog case and the new soundfx case fail. Co-Authored-By: Claude Opus 5.5 --- src/core/graph/NodeContext.ts | 5 +++++ src/core/graph/nodes/action/PlaySound.ts | 9 +++++--- src/runtime/systems/Audio.ts | 6 ++++++ src/runtime/systems/behaviorContext.ts | 4 ++++ src/viewport/SoundLibrary.ts | 4 ++++ tests/core/keep-on-scene-load.test.ts | 1 + tests/core/node-catalog.test.ts | 6 +++--- tests/core/soundfx.test.ts | 26 +++++++++++++++++++----- tests/core/sounds.test.ts | 1 + tests/helpers.ts | 2 ++ 10 files changed, 53 insertions(+), 11 deletions(-) diff --git a/src/core/graph/NodeContext.ts b/src/core/graph/NodeContext.ts index 8304ef41..4e0bf165 100644 --- a/src/core/graph/NodeContext.ts +++ b/src/core/graph/NodeContext.ts @@ -250,6 +250,11 @@ export interface NodeContext { fx: string, fxParams: FxParams, ): number; + /** + * How many seconds one run of that sound asset lasts, so a change can span it; 0 where it does not + * exist or has not been loaded yet. + */ + soundLength(assetId: string): number; /** Silences every voice of that sound; `''` silences everything this run started. */ stopSound(assetId: string): void; /** diff --git a/src/core/graph/nodes/action/PlaySound.ts b/src/core/graph/nodes/action/PlaySound.ts index 34bc626e..faf48242 100644 --- a/src/core/graph/nodes/action/PlaySound.ts +++ b/src/core/graph/nodes/action/PlaySound.ts @@ -61,7 +61,9 @@ export const PlaySound: NodeDef = { 'unless "detached" is on. "fx" sends it through a sound FX asset; left empty, it plays as it is. ' + '"fx params" takes values for its parameters from a Sound FX parameters node. "voice" is the ' + 'number of this one play, for Set sound FX parameters to change it while it plays; 0 where ' + - 'nothing was played.', + 'nothing was played. "length" is how many seconds one run of the sound lasts, to hand to the ' + + '"time" of Set sound FX parameters so a change spans the sound; 0 where nothing was played or ' + + 'the sound has not been loaded yet. A pitch changed while it plays makes it longer or shorter.', inputs: [ exec(), soundPort(), @@ -71,7 +73,7 @@ export const PlaySound: NodeDef = { soundFxPort(), { key: 'fxParams', label: 'fx params', type: 'fxparams' }, ], - outputs: [exec(), num('voice')], + outputs: [exec(), num('voice'), num('length')], params: [], run: (ctx, inputs) => { const sound = asString(inputs.sound); @@ -85,7 +87,8 @@ export const PlaySound: NodeDef = { fxParamsOf(inputs.fxParams), ) : 0; - return { next: 'exec', outputs: { voice } }; + const length = voice ? ctx.soundLength(sound) : 0; + return { next: 'exec', outputs: { voice, length } }; }, }; diff --git a/src/runtime/systems/Audio.ts b/src/runtime/systems/Audio.ts index 2de66169..6e28786e 100644 --- a/src/runtime/systems/Audio.ts +++ b/src/runtime/systems/Audio.ts @@ -28,6 +28,11 @@ export interface AudioSink { * where it is not given). 0 is a quick glide. */ tune(voice: number, params: FxParams, time?: number, ease?: EaseShare): void; + /** + * How many seconds one run of that file lasts, the same with a pitch it starts on (#312); 0 while + * its bytes have not arrived. + */ + length(file: string): number; /** 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. */ @@ -37,6 +42,7 @@ export interface AudioSink { export const SILENT_AUDIO: AudioSink = { play: () => 0, tune: () => {}, + length: () => 0, stop: () => {}, stopOwner: () => {}, }; diff --git a/src/runtime/systems/behaviorContext.ts b/src/runtime/systems/behaviorContext.ts index 05db358b..6e4c8464 100644 --- a/src/runtime/systems/behaviorContext.ts +++ b/src/runtime/systems/behaviorContext.ts @@ -166,6 +166,10 @@ export function behaviorContext( }, playSound: (assetId, volume, loop, detached, fx, fxParams) => playSound(run, assetId, volume, loop, detached ? '' : entity.id, fx, fxParams), + soundLength: (assetId) => { + const file = soundFileOf(project, assetId); + return file ? run.audio.length(file) : 0; + }, // An id nothing resolves stops nothing, rather than silencing the whole run by accident; // only the empty id means "everything", which is what the node promises. stopSound: (assetId) => { diff --git a/src/viewport/SoundLibrary.ts b/src/viewport/SoundLibrary.ts index 47a78679..19344dfd 100644 --- a/src/viewport/SoundLibrary.ts +++ b/src/viewport/SoundLibrary.ts @@ -227,6 +227,10 @@ export class SoundLibrary implements AudioSink { return this.buffers.get(file)?.duration ?? null; } + length(file: string): number { + return this.duration(file) ?? 0; + } + /** * Starts loading every file not held yet. Waiting for the first play left that play late: its * start was not heard, and a graph tuning it in the meantime had moved on by the time it started. diff --git a/tests/core/keep-on-scene-load.test.ts b/tests/core/keep-on-scene-load.test.ts index 818e9466..c5fbca43 100644 --- a/tests/core/keep-on-scene-load.test.ts +++ b/tests/core/keep-on-scene-load.test.ts @@ -56,6 +56,7 @@ function start(f: Fixture, audio?: AudioSink) { const sink: AudioSink = audio ?? { play: () => 0, tune: () => {}, + length: () => 0, stop: () => {}, stopOwner: (owner: string) => stoppedOwners.push(owner), }; diff --git a/tests/core/node-catalog.test.ts b/tests/core/node-catalog.test.ts index 46a7f5eb..a1231e34 100644 --- a/tests/core/node-catalog.test.ts +++ b/tests/core/node-catalog.test.ts @@ -1980,14 +1980,14 @@ const CASES: Record = { name: 'no sound is a no-op that still continues the flow', inputs: { sound: '', volume: 1, loop: '' }, next: 'exec', - outputs: { voice: 0 }, + outputs: { voice: 0, length: 0 }, after: (ctx) => expect(ctx.played).toEqual([]), }, { - name: 'hands the id on with the volume and loop it was given, and gives out the voice (#315)', + name: 'hands the id on with the volume and loop it was given, and gives out the voice (#315) and its length', inputs: { sound: 'snd-1', volume: 0.5, loop: 'true' }, next: 'exec', - outputs: { voice: 1 }, + outputs: { voice: 1, length: 0.25 }, // What the two mean – a factor on the asset's volume, an answer that overrules its loop // flag – is the BehaviorSystem's to resolve, since only it holds the project (see // `sounds.test.ts`). diff --git a/tests/core/soundfx.test.ts b/tests/core/soundfx.test.ts index 8cf4383c..daacf501 100644 --- a/tests/core/soundfx.test.ts +++ b/tests/core/soundfx.test.ts @@ -257,19 +257,26 @@ describe('a Mix in a sound FX graph (#316)', () => { }); /** - * Records what it was asked to play, with the line of effects its values make, and every tune. A - * voice is numbered by how many have been played, so the first is 1. + * Records what it was asked to play, with the line of effects its values make, and every tune with + * its time on the side. A voice is numbered by how many have been played, so the first is 1. Every + * file lasts a quarter of a second. */ function fakeAudio(): AudioSink & { played: { file: string; owner: string; fx: FxChain | undefined }[]; tuned: { voice: number; params: Readonly> }[]; + times: (number | undefined)[]; } { const sink = { played: [] as { file: string; owner: string; fx: FxChain | undefined }[], tuned: [] as { voice: number; params: Readonly> }[], + times: [] as (number | undefined)[], play: (file: string, _volume: number, _loop: boolean, owner: string, fx?: FxVoice) => sink.played.push({ file, owner, fx: fx && fxChainOf(fx.plan, fx.params) }), - tune: (voice: number, params: Readonly>) => sink.tuned.push({ voice, params }), + tune: (voice: number, params: Readonly>, time?: number) => { + sink.tuned.push({ voice, params }); + sink.times.push(time); + }, + length: () => 0.25, stop: () => {}, stopOwner: () => {}, }; @@ -503,9 +510,9 @@ describe('Sound FX parameters and Play sound', () => { describe('Set sound FX parameters (#315)', () => { /** * Pressing x plays Jump through an FX twice, the first `sound` – then hands `depth` to the voice - * the first play gave out. + * the first play gave out, over the length of that play where `spanned`. */ - function tuning(sound?: string) { + function tuning(sound?: string, spanned = false) { const f = makeSpriteScene('Box'); const { fx } = makeFx(f); const created = new CreateSoundCommand('Jump', FILE); @@ -525,6 +532,7 @@ describe('Set sound FX parameters (#315)', () => { [first, 'voice', set, 'voice'], [params, 'params', set, 'fxParams'], ]); + if (spanned) edit.wire([[first, 'length', set, 'time']]); return f; } @@ -545,4 +553,12 @@ describe('Set sound FX parameters (#315)', () => { expect(audio.played).toHaveLength(1); expect(audio.tuned).toEqual([]); }); + + it('spans a change over the length its Play sound hands out', () => { + const audio = fakeAudio(); + const { input, frame } = startWorld(tuning(undefined, true), { behaviors: true, audio }); + input.press('x'); + frame(); + expect(audio.times).toEqual([0.25]); + }); }); diff --git a/tests/core/sounds.test.ts b/tests/core/sounds.test.ts index ae463302..5e79551d 100644 --- a/tests/core/sounds.test.ts +++ b/tests/core/sounds.test.ts @@ -43,6 +43,7 @@ function fakeAudio(): AudioSink & { play: (file: string, volume: number, loop: boolean, owner: string) => sink.played.push({ file, volume, loop, owner }), tune: () => {}, + length: () => 0, stop: (file: string) => sink.stopped.push(file), stopOwner: (owner: string) => sink.stoppedOwners.push(owner), }; diff --git a/tests/helpers.ts b/tests/helpers.ts index 6d1e663a..4ad23cc9 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -270,6 +270,8 @@ export function fakeContext(overrides: Partial = {}): FakeContext { // The number of a voice is how many have been played: unique, and never 0. playSound: (assetId, volume, loop, detached, fx, fxParams) => ctx.played.push({ assetId, volume, loop, detached, fx, fxParams }), + // Every sound lasts a quarter of a second, as if it had been loaded. + soundLength: () => 0.25, stopSound: (assetId) => ctx.silenced.push(assetId), tuned: [], tuneSound: (voice, fxParams, time, ease) => ctx.tuned.push({ voice, fxParams, time, ease }),