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 <noreply@anthropic.com>
This commit is contained in:
1 parent
297fca3e94
commit
804a97b0a9
10 files changed
+53
-11
No files matched your search
@@ -250,6 +250,11 @@ export interface NodeContext {
|
|||||||
fx: string,
|
fx: string,
|
||||||
fxParams: FxParams,
|
fxParams: FxParams,
|
||||||
): number;
|
): 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. */
|
/** Silences every voice of that sound; `''` silences everything this run started. */
|
||||||
stopSound(assetId: string): void;
|
stopSound(assetId: string): void;
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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. ' +
|
'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 ' +
|
'"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 ' +
|
'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: [
|
inputs: [
|
||||||
exec(),
|
exec(),
|
||||||
soundPort(),
|
soundPort(),
|
||||||
@@ -71,7 +73,7 @@ export const PlaySound: NodeDef = {
|
|||||||
soundFxPort(),
|
soundFxPort(),
|
||||||
{ key: 'fxParams', label: 'fx params', type: 'fxparams' },
|
{ key: 'fxParams', label: 'fx params', type: 'fxparams' },
|
||||||
],
|
],
|
||||||
outputs: [exec(), num('voice')],
|
outputs: [exec(), num('voice'), num('length')],
|
||||||
params: [],
|
params: [],
|
||||||
run: (ctx, inputs) => {
|
run: (ctx, inputs) => {
|
||||||
const sound = asString(inputs.sound);
|
const sound = asString(inputs.sound);
|
||||||
@@ -85,7 +87,8 @@ export const PlaySound: NodeDef = {
|
|||||||
fxParamsOf(inputs.fxParams),
|
fxParamsOf(inputs.fxParams),
|
||||||
)
|
)
|
||||||
: 0;
|
: 0;
|
||||||
return { next: 'exec', outputs: { voice } };
|
const length = voice ? ctx.soundLength(sound) : 0;
|
||||||
|
return { next: 'exec', outputs: { voice, length } };
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,11 @@ export interface AudioSink {
|
|||||||
* where it is not given). 0 is a quick glide.
|
* where it is not given). 0 is a quick glide.
|
||||||
*/
|
*/
|
||||||
tune(voice: number, params: FxParams, time?: number, ease?: EaseShare): void;
|
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. */
|
/** Silences every voice of that file; `''` silences all of them. */
|
||||||
stop(file: string): void;
|
stop(file: string): void;
|
||||||
/** Silences every voice started by that owner – what destroying a behavior does. */
|
/** Silences every voice started by that owner – what destroying a behavior does. */
|
||||||
@@ -37,6 +42,7 @@ export interface AudioSink {
|
|||||||
export const SILENT_AUDIO: AudioSink = {
|
export const SILENT_AUDIO: AudioSink = {
|
||||||
play: () => 0,
|
play: () => 0,
|
||||||
tune: () => {},
|
tune: () => {},
|
||||||
|
length: () => 0,
|
||||||
stop: () => {},
|
stop: () => {},
|
||||||
stopOwner: () => {},
|
stopOwner: () => {},
|
||||||
};
|
};
|
||||||
@@ -166,6 +166,10 @@ export function behaviorContext(
|
|||||||
},
|
},
|
||||||
playSound: (assetId, volume, loop, detached, fx, fxParams) =>
|
playSound: (assetId, volume, loop, detached, fx, fxParams) =>
|
||||||
playSound(run, assetId, volume, loop, detached ? '' : entity.id, 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;
|
// 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.
|
// only the empty id means "everything", which is what the node promises.
|
||||||
stopSound: (assetId) => {
|
stopSound: (assetId) => {
|
||||||
|
|||||||
@@ -227,6 +227,10 @@ export class SoundLibrary implements AudioSink {
|
|||||||
return this.buffers.get(file)?.duration ?? null;
|
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
|
* 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.
|
* start was not heard, and a graph tuning it in the meantime had moved on by the time it started.
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ function start(f: Fixture, audio?: AudioSink) {
|
|||||||
const sink: AudioSink = audio ?? {
|
const sink: AudioSink = audio ?? {
|
||||||
play: () => 0,
|
play: () => 0,
|
||||||
tune: () => {},
|
tune: () => {},
|
||||||
|
length: () => 0,
|
||||||
stop: () => {},
|
stop: () => {},
|
||||||
stopOwner: (owner: string) => stoppedOwners.push(owner),
|
stopOwner: (owner: string) => stoppedOwners.push(owner),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1980,14 +1980,14 @@ const CASES: Record<string, Case[]> = {
|
|||||||
name: 'no sound is a no-op that still continues the flow',
|
name: 'no sound is a no-op that still continues the flow',
|
||||||
inputs: { sound: '', volume: 1, loop: '' },
|
inputs: { sound: '', volume: 1, loop: '' },
|
||||||
next: 'exec',
|
next: 'exec',
|
||||||
outputs: { voice: 0 },
|
outputs: { voice: 0, length: 0 },
|
||||||
after: (ctx) => expect(ctx.played).toEqual([]),
|
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' },
|
inputs: { sound: 'snd-1', volume: 0.5, loop: 'true' },
|
||||||
next: 'exec',
|
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
|
// 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
|
// flag – is the BehaviorSystem's to resolve, since only it holds the project (see
|
||||||
// `sounds.test.ts`).
|
// `sounds.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
|
* Records what it was asked to play, with the line of effects its values make, and every tune with
|
||||||
* voice is numbered by how many have been played, so the first is 1.
|
* 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 & {
|
function fakeAudio(): AudioSink & {
|
||||||
played: { file: string; owner: string; fx: FxChain | undefined }[];
|
played: { file: string; owner: string; fx: FxChain | undefined }[];
|
||||||
tuned: { voice: number; params: Readonly<Record<string, number>> }[];
|
tuned: { voice: number; params: Readonly<Record<string, number>> }[];
|
||||||
|
times: (number | undefined)[];
|
||||||
} {
|
} {
|
||||||
const sink = {
|
const sink = {
|
||||||
played: [] as { file: string; owner: string; fx: FxChain | undefined }[],
|
played: [] as { file: string; owner: string; fx: FxChain | undefined }[],
|
||||||
tuned: [] as { voice: number; params: Readonly<Record<string, number>> }[],
|
tuned: [] as { voice: number; params: Readonly<Record<string, number>> }[],
|
||||||
|
times: [] as (number | undefined)[],
|
||||||
play: (file: string, _volume: number, _loop: boolean, owner: string, fx?: FxVoice) =>
|
play: (file: string, _volume: number, _loop: boolean, owner: string, fx?: FxVoice) =>
|
||||||
sink.played.push({ file, owner, fx: fx && fxChainOf(fx.plan, fx.params) }),
|
sink.played.push({ file, owner, fx: fx && fxChainOf(fx.plan, fx.params) }),
|
||||||
tune: (voice: number, params: Readonly<Record<string, number>>) => sink.tuned.push({ voice, params }),
|
tune: (voice: number, params: Readonly<Record<string, number>>, time?: number) => {
|
||||||
|
sink.tuned.push({ voice, params });
|
||||||
|
sink.times.push(time);
|
||||||
|
},
|
||||||
|
length: () => 0.25,
|
||||||
stop: () => {},
|
stop: () => {},
|
||||||
stopOwner: () => {},
|
stopOwner: () => {},
|
||||||
};
|
};
|
||||||
@@ -503,9 +510,9 @@ describe('Sound FX parameters and Play sound', () => {
|
|||||||
describe('Set sound FX parameters (#315)', () => {
|
describe('Set sound FX parameters (#315)', () => {
|
||||||
/**
|
/**
|
||||||
* Pressing x plays Jump through an FX twice, the first `sound` – then hands `depth` to the voice
|
* 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 f = makeSpriteScene('Box');
|
||||||
const { fx } = makeFx(f);
|
const { fx } = makeFx(f);
|
||||||
const created = new CreateSoundCommand('Jump', FILE);
|
const created = new CreateSoundCommand('Jump', FILE);
|
||||||
@@ -525,6 +532,7 @@ describe('Set sound FX parameters (#315)', () => {
|
|||||||
[first, 'voice', set, 'voice'],
|
[first, 'voice', set, 'voice'],
|
||||||
[params, 'params', set, 'fxParams'],
|
[params, 'params', set, 'fxParams'],
|
||||||
]);
|
]);
|
||||||
|
if (spanned) edit.wire([[first, 'length', set, 'time']]);
|
||||||
return f;
|
return f;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -545,4 +553,12 @@ describe('Set sound FX parameters (#315)', () => {
|
|||||||
expect(audio.played).toHaveLength(1);
|
expect(audio.played).toHaveLength(1);
|
||||||
expect(audio.tuned).toEqual([]);
|
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]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -43,6 +43,7 @@ function fakeAudio(): AudioSink & {
|
|||||||
play: (file: string, volume: number, loop: boolean, owner: string) =>
|
play: (file: string, volume: number, loop: boolean, owner: string) =>
|
||||||
sink.played.push({ file, volume, loop, owner }),
|
sink.played.push({ file, volume, loop, owner }),
|
||||||
tune: () => {},
|
tune: () => {},
|
||||||
|
length: () => 0,
|
||||||
stop: (file: string) => sink.stopped.push(file),
|
stop: (file: string) => sink.stopped.push(file),
|
||||||
stopOwner: (owner: string) => sink.stoppedOwners.push(owner),
|
stopOwner: (owner: string) => sink.stoppedOwners.push(owner),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -270,6 +270,8 @@ export function fakeContext(overrides: Partial<NodeContext> = {}): FakeContext {
|
|||||||
// The number of a voice is how many have been played: unique, and never 0.
|
// The number of a voice is how many have been played: unique, and never 0.
|
||||||
playSound: (assetId, volume, loop, detached, fx, fxParams) =>
|
playSound: (assetId, volume, loop, detached, fx, fxParams) =>
|
||||||
ctx.played.push({ 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),
|
stopSound: (assetId) => ctx.silenced.push(assetId),
|
||||||
tuned: [],
|
tuned: [],
|
||||||
tuneSound: (voice, fxParams, time, ease) => ctx.tuned.push({ voice, fxParams, time, ease }),
|
tuneSound: (voice, fxParams, time, ease) => ctx.tuned.push({ voice, fxParams, time, ease }),
|
||||||
|
|||||||
Reference in new issue
Block a user