Change the sound FX parameters of a sound while it plays #315
Until now a value handed to a sound FX counted from the start of the voice only. An engine whose pitch follows the speed, or a loop that gets quieter, could not be built. Every play of Play sound now gives out the number of its voice, and a new node lets the knobs of that one voice glide to new values while it plays. - Play sound has a new output voice: the number of this one play, or 0 where nothing was played. Kept in a Number variable it reaches the voice from another event. Two plays of one sound are two voices. - New action node Set sound FX parameters (exec, voice, fx params). It reaches exactly the voice it is given, a detached one too. A voice that has ended, or voice 0, is not reached. - AudioSink.play returns the voice number and takes the plan and the values (FxVoice) instead of a finished chain. AudioSink.tune takes a voice number and new values. A name the voice's FX does not have is left out, one not given keeps its value. - SoundLibrary hands out the number at once, before the bytes are there, and never twice. A voice still waiting for its bytes starts with the values handed in meanwhile. - Every knob of a voice moves with setTargetAtTime (30 ms time constant). A knob whose value did not change is not sent again, so a tune every frame costs next to nothing. - A pitch on a playing voice glides by playbackRate. A new WSOLA copy in the middle of a sound is not worked out, so the voice gets shorter or longer while it is moved. On a loop that is not heard. - fxChainOf now gives a chain whose shape depends on the plan alone. A stage fed by a parameter stays at 0 dB, and a Mix fed by a parameter keeps both ways. A typed-in 0 or Mix at 0/1 is still left out by the compiler. - Every way of a voice has a source of its own, also at the same pitch, so each can glide on its own. The pitched copy is shared. - The tab keeps the numbers of what it plays, so moving a parameter field is heard at once. - Test hook __gm.sound.levels(file) reads the knobs of every voice of a file, oldest first, and stats() counts the voices tuned. Tests: - soundfx.test.ts: 29 tests (+2 for the node in a running world: the voice of the first of two plays of one sound, and a play that played nothing) - sound-library.test.ts: 15 tests (+3: glide, reach by number, pending voice) - node-catalog.test.ts: 370 tests (+2, Play sound checks its voice) - sounds + keep-on-scene-load: 31 tests, unchanged - Counter-test: a tune that reaches every voice of the same file makes the reach test fail. - e2e 34 (melektaus-atx): ALL PASSED - 30 checks (+1). In Chrome two looping voices of one sound start at depth 7. The one whose number sits in a variable glides to 0.7938 (2^(-4/12) = 0.7937), the other stays at 1. Both gains stay at 0.5012. - The gate ran before voices got a number (CHECK EXIT 0, 241 files, 3658 tests), not after. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
072537b2e8
commit
c7e9769f90
18 files changed
+563
-106
No files matched your search
@@ -54,7 +54,8 @@ function keep(f: Fixture, sceneId: string, objectId: string): void {
|
||||
function start(f: Fixture, audio?: AudioSink) {
|
||||
const stoppedOwners: string[] = [];
|
||||
const sink: AudioSink = audio ?? {
|
||||
play: () => {},
|
||||
play: () => 0,
|
||||
tune: () => {},
|
||||
stop: () => {},
|
||||
stopOwner: (owner: string) => stoppedOwners.push(owner),
|
||||
};
|
||||
|
||||
@@ -1940,12 +1940,14 @@ const CASES: Record<string, Case[]> = {
|
||||
name: 'no sound is a no-op that still continues the flow',
|
||||
inputs: { sound: '', volume: 1, loop: '' },
|
||||
next: 'exec',
|
||||
outputs: { voice: 0 },
|
||||
after: (ctx) => expect(ctx.played).toEqual([]),
|
||||
},
|
||||
{
|
||||
name: 'hands the id on with the volume and loop it was given',
|
||||
name: 'hands the id on with the volume and loop it was given, and gives out the voice (#315)',
|
||||
inputs: { sound: 'snd-1', volume: 0.5, loop: 'true' },
|
||||
next: 'exec',
|
||||
outputs: { voice: 1 },
|
||||
// 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`).
|
||||
@@ -2025,6 +2027,20 @@ const CASES: Record<string, Case[]> = {
|
||||
after: (ctx) => expect(ctx.silenced).toEqual(['']),
|
||||
},
|
||||
],
|
||||
SetSoundFxParameters: [
|
||||
{
|
||||
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 } }]),
|
||||
},
|
||||
{
|
||||
name: 'voice 0 – nothing was played – reaches nothing, and the flow carries on',
|
||||
inputs: { voice: 0, fxParams: { depth: 3 } },
|
||||
next: 'exec',
|
||||
after: (ctx) => expect(ctx.tuned).toEqual([]),
|
||||
},
|
||||
],
|
||||
Log: [
|
||||
{
|
||||
name: 'logs its message and continues',
|
||||
|
||||
+81
-16
@@ -12,7 +12,14 @@ import type { BehaviorAsset, SoundFxAsset } from '../../src/core/model/Asset';
|
||||
import { isPlaceableType } from '../../src/core/model/AssetTypes';
|
||||
import { assetRefChoices } from '../../src/core/properties/assetRef';
|
||||
import { compileSoundFx, soundFxBuildFor } from '../../src/core/soundfx/compile';
|
||||
import { DRY, EMPTY_PLAN, type FxChain, fxChainOf, type FxPlan } from '../../src/core/soundfx/nodes';
|
||||
import {
|
||||
DRY,
|
||||
EMPTY_PLAN,
|
||||
type FxChain,
|
||||
fxChainOf,
|
||||
type FxPlan,
|
||||
type FxVoice,
|
||||
} from '../../src/core/soundfx/nodes';
|
||||
import { deserializeProject } from '../../src/core/store/serialize';
|
||||
import type { AudioSink } from '../../src/runtime/systems/Audio';
|
||||
import { paletteEntries } from '../../src/editor/graph/paletteEntries';
|
||||
@@ -214,15 +221,15 @@ describe('a Mix in a sound FX graph (#316)', () => {
|
||||
expect(b).toMatchObject({ pitch: 0, stages: [{ kind: 'gain' }, { kind: 'filter' }] });
|
||||
});
|
||||
|
||||
it('takes its share from a parameter, held between 0 and 1', () => {
|
||||
it('takes its share from a parameter, held between 0 and 1, and keeps both ways for it (#315)', () => {
|
||||
const f = forked();
|
||||
f.input.ports = [{ key: 'blend', kind: 'number', default: 0.25 }];
|
||||
f.edit.connect(f.input, 'blend', f.mix, 'mix');
|
||||
const plan = compileSoundFx(f.fx.graph).compiled!;
|
||||
expect(fxChainOf(plan)).toMatchObject({ mix: 0.25 });
|
||||
expect(fxChainOf(plan, { blend: 0.8 })).toMatchObject({ mix: 0.8 });
|
||||
expect(fxChainOf(plan, { blend: -3 })).toMatchObject({ pitch: 12 });
|
||||
expect(fxChainOf(plan, { blend: 3 })).toMatchObject({ pitch: 0 });
|
||||
expect(fxChainOf(plan, { blend: -3 })).toMatchObject({ mix: 0, a: { pitch: 12 }, b: { pitch: 0 } });
|
||||
expect(fxChainOf(plan, { blend: 3 })).toMatchObject({ mix: 1, a: { pitch: 12 }, b: { pitch: 0 } });
|
||||
});
|
||||
|
||||
it('hands a Pitch behind it to both of its sounds', () => {
|
||||
@@ -249,12 +256,20 @@ describe('a Mix in a sound FX graph (#316)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
/** Records what it was asked to play, with the line of effects. */
|
||||
function fakeAudio(): AudioSink & { played: { file: string; fx: FxChain | undefined }[] } {
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
function fakeAudio(): AudioSink & {
|
||||
played: { file: string; owner: string; fx: FxChain | undefined }[];
|
||||
tuned: { voice: number; params: Readonly<Record<string, number>> }[];
|
||||
} {
|
||||
const sink = {
|
||||
played: [] as { file: string; fx: FxChain | undefined }[],
|
||||
play: (file: string, _volume: number, _loop: boolean, _owner: string, fx?: FxChain) =>
|
||||
sink.played.push({ file, fx }),
|
||||
played: [] as { file: string; owner: string; fx: FxChain | undefined }[],
|
||||
tuned: [] as { voice: number; params: Readonly<Record<string, number>> }[],
|
||||
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<Record<string, number>>) => sink.tuned.push({ voice, params }),
|
||||
stop: () => {},
|
||||
stopOwner: () => {},
|
||||
};
|
||||
@@ -286,7 +301,7 @@ describe('Play sound with fx', () => {
|
||||
const f = playingScene((s) => s.fx.id);
|
||||
const audio = fakeAudio();
|
||||
startWorld(f, { behaviors: true, audio }).frame();
|
||||
expect(audio.played).toEqual([{ file: FILE, fx: { pitch: -12, stages: [] } }]);
|
||||
expect(audio.played).toMatchObject([{ file: FILE, fx: { pitch: -12, stages: [] } }]);
|
||||
});
|
||||
|
||||
it('plays it dry without an fx, with one that is gone, and with one that does not compile', () => {
|
||||
@@ -305,7 +320,8 @@ describe('Play sound with fx', () => {
|
||||
expect(soundFxBuildFor(broken.fx).compiled).toBeNull();
|
||||
const brokenAudio = fakeAudio();
|
||||
startWorld(broken, { behaviors: true, audio: brokenAudio }).frame();
|
||||
for (const audio of [none, gone, brokenAudio]) expect(audio.played).toEqual([{ file: FILE, fx: DRY }]);
|
||||
for (const audio of [none, gone, brokenAudio])
|
||||
expect(audio.played).toMatchObject([{ file: FILE, fx: DRY }]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -329,11 +345,11 @@ describe('the parameters of a sound FX', () => {
|
||||
expect(pitchOf(fxChainOf(plan, { other: 3 }))).toBe(-12);
|
||||
});
|
||||
|
||||
it('keep a stage that is 0 by default, so a value handed in can open it', () => {
|
||||
it('keep a stage that is 0 by default, so a value handed in can open it, even on a playing voice', () => {
|
||||
const f = withParam('SoundFxGain', 'gain', [{ key: 'loud', kind: 'number' }]);
|
||||
const plan = soundFxBuildFor(f.fx).compiled!;
|
||||
expect(plan.stages).toHaveLength(1);
|
||||
expect(fxChainOf(plan)).toEqual(DRY);
|
||||
expect(fxChainOf(plan)).toEqual({ pitch: 0, stages: [{ kind: 'gain', gain: 1 }] });
|
||||
expect(fxChainOf(plan, { loud: 6 }).stages[0]).toMatchObject({ kind: 'gain' });
|
||||
expect((fxChainOf(plan, { loud: 6 }).stages[0] as { gain: number }).gain).toBeCloseTo(10 ** (6 / 20));
|
||||
});
|
||||
@@ -437,7 +453,9 @@ describe('Sound FX parameters and Play sound', () => {
|
||||
const f = scene({ pick: ['depth'], values: { depth: 7 } });
|
||||
const audio = fakeAudio();
|
||||
startWorld(f, { behaviors: true, audio }).frame();
|
||||
expect(audio.played).toEqual([{ file: FILE, fx: { pitch: 7, stages: [] } }]);
|
||||
expect(audio.played).toMatchObject([
|
||||
{ file: FILE, fx: { pitch: 7, stages: [{ kind: 'gain', gain: 1 }] } },
|
||||
]);
|
||||
});
|
||||
|
||||
it('starts a picked parameter at the default of the FX', () => {
|
||||
@@ -449,7 +467,7 @@ describe('Sound FX parameters and Play sound', () => {
|
||||
]);
|
||||
const audio = fakeAudio();
|
||||
startWorld(f, { behaviors: true, audio }).frame();
|
||||
expect(audio.played[0]!.fx).toEqual({ pitch: -12, stages: [] });
|
||||
expect(audio.played[0]!.fx).toEqual({ pitch: -12, stages: [{ kind: 'gain', gain: 1 }] });
|
||||
});
|
||||
|
||||
it('says so at the node when no FX is picked or it is gone', () => {
|
||||
@@ -475,9 +493,56 @@ describe('Sound FX parameters and Play sound', () => {
|
||||
expect(f.params.params.drop).toBe(7);
|
||||
const audio = fakeAudio();
|
||||
startWorld(f, { behaviors: true, audio }).frame();
|
||||
expect(audio.played[0]!.fx).toEqual({ pitch: 7, stages: [] });
|
||||
expect(audio.played[0]!.fx).toEqual({ pitch: 7, stages: [{ kind: 'gain', gain: 1 }] });
|
||||
f.stack.undo();
|
||||
expect(f.params.ports).toEqual([{ key: 'depth', kind: 'number' }]);
|
||||
expect(f.params.params.depth).toBe(7);
|
||||
});
|
||||
});
|
||||
|
||||
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.
|
||||
*/
|
||||
function tuning(sound?: string) {
|
||||
const f = makeSpriteScene('Box');
|
||||
const { fx } = makeFx(f);
|
||||
const created = new CreateSoundCommand('Jump', FILE);
|
||||
f.stack.execute(created);
|
||||
const behavior = makeBehavior(f, 'Logic');
|
||||
place(f, behavior.id, { x: 0, y: 0 }, place(f, f.spriteId, { x: 0, y: 0 }));
|
||||
const edit = graphEditor(f, behavior);
|
||||
const first = edit.addNode('PlaySound', { sound: sound ?? created.asset.id, fx: fx.id });
|
||||
const second = edit.addNode('PlaySound', { sound: created.asset.id, fx: fx.id });
|
||||
const set = edit.addNode('SetSoundFxParameters');
|
||||
const params = edit.addNode('SoundFxParameters', { soundFxAsset: fx.id, depth: 3 });
|
||||
params.ports = [{ key: 'depth', kind: 'number' }];
|
||||
edit.wire([
|
||||
[edit.addNode('OnKeyDown', { key: 'x' }), 'exec', first, 'exec'],
|
||||
[first, 'exec', second, 'exec'],
|
||||
[second, 'exec', set, 'exec'],
|
||||
[first, 'voice', set, 'voice'],
|
||||
[params, 'params', set, 'fxParams'],
|
||||
]);
|
||||
return f;
|
||||
}
|
||||
|
||||
it('hands the values to the one voice its Play sound gave out, not to another of the same sound', () => {
|
||||
const audio = fakeAudio();
|
||||
const { input, frame } = startWorld(tuning(), { behaviors: true, audio });
|
||||
input.press('x');
|
||||
frame();
|
||||
expect(audio.played).toHaveLength(2);
|
||||
expect(audio.tuned).toEqual([{ voice: 1, params: { depth: 3 } }]);
|
||||
});
|
||||
|
||||
it('reaches nothing where its Play sound played nothing', () => {
|
||||
const audio = fakeAudio();
|
||||
const { input, frame } = startWorld(tuning('no-such-sound'), { behaviors: true, audio });
|
||||
input.press('x');
|
||||
frame();
|
||||
expect(audio.played).toHaveLength(1);
|
||||
expect(audio.tuned).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -42,6 +42,7 @@ function fakeAudio(): AudioSink & {
|
||||
stoppedOwners: [] as string[],
|
||||
play: (file: string, volume: number, loop: boolean, owner: string) =>
|
||||
sink.played.push({ file, volume, loop, owner }),
|
||||
tune: () => {},
|
||||
stop: (file: string) => sink.stopped.push(file),
|
||||
stopOwner: (owner: string) => sink.stoppedOwners.push(owner),
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user