Keep both sounds full at the middle of a Mix with a curve #331
game-maker / gate (push) Successful in 3m16s
game-maker / suite (push) Successful in 16m2s

A Mix crossfaded: at 0.5 each sound was at half. Layering two sounds
in an instrument now goes through a Mix (076c564), so the user asked
for a second curve where each side stays full up to the middle and
only fades on the other half. At 0.25 a is full and b half, at 0.5
both are full, at 0.75 a is half and b full.

- The Mix has a setting Curve with Crossfade (default) and Both at
  center. A plan and a chain only say `curve` where it is `both`, so
  old projects and their compiled plans stay as they were.
- A share is not a straight line of `mix` with that curve. Two ramps
  of their own would dip to half each when `mix` glides across the
  middle. So a ConstantSourceNode carries `mix` on the audio thread,
  and two WaveShaperNodes make the shares from it. The knob of such a
  Mix is that one source.
- The source is started with the voice and stopped when it is cut or
  ends by itself, so it does not run on after the voice.
- A sound FX node in an instrument keeps the curve of a Mix inside it.
- At 0.5 both sounds are added in full, up to +6 dB where both are
  loud. That is what several edges into the Output did before. The
  wiki says so.

Found on the way: the suite of 23bdc3f (task 529) failed the Step
check in e2e 34. It timed the jump from the start of the voice, but a
timed change runs from when it arrives. In CI that was 80 ms later,
and the pitch rightly held to 2.08 s.

- stats() says `tunedAt`, the second of the audio clock the last
  change moved a voice. A change that moves nothing, as On update
  sends every frame, leaves it.
- The Step check times its readings from there.

Tests:
- sound-library.test.ts: both shares shaped from one source, a glide
  moves only the source, the source stops with a cut and with an end
  by itself (50 tests, 2 new)
- soundfx.test.ts: curve only where set, one sound at 0 (41 tests)
- instrument.test.ts: the curve through a sound FX node (34 tests)
- Counter-checks: dropping the curve in throughFx and not stopping
  the source in done() each turn their new test red
- e2e 34 on melektaus-atx: ALL PASSED, 48 checks (+1). Rendered
  offline in Chrome, a and b come to 1/0, 1/0.5, 1/1, 0.5/1 and 0/1
  at mix 0 to 1, and a glide from 0 to 1 reads the same at 0.25, 0.5
  and 0.75 s. Step jumped 2.04 s after the change.
- Gate on melektaus-atx: CHECK EXIT 0, 244 test files, 3793 tests

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-06 13:12:56 +02:00
1 parent 076c564415
commit fb4d65a2ab
8 files changed
+261 -25

No files matched your search

+79 -5
View File
@@ -104,7 +104,16 @@ const disconnects: string[] = [];
* The silent sources the library times things on the audio clock with (#324): when each was told to
* stop, and whether it has. `clockTo` lets the clock run.
*/
const clocks: { stopAt: number; fired: boolean; onended: (() => void) | null }[] = [];
const clocks: {
offset: FakeParam;
stopAt: number;
fired: boolean;
started: boolean;
stopped: boolean;
onended: (() => void) | null;
}[] = [];
/** The curve of every WaveShaper made, in order. */
const shapers: { curve: Float32Array | null }[] = [];
let contextState = 'running';
/** Runs the audio clock up to `until`, ending each timer on the way in order, also ones set meanwhile. */
@@ -210,18 +219,28 @@ function fakeContext() {
createChannelSplitter: () => wired('splitter', {}),
createConstantSource: () => {
const clock = wired('clock', {
offset: { value: 1 },
offset: param(1),
stopAt: Infinity,
fired: false,
started: false,
stopped: false,
onended: null as (() => void) | null,
start: () => {},
stop: (at: number) => {
clock.stopAt = at;
start: () => {
clock.started = true;
},
stop: (at?: number) => {
clock.stopAt = at ?? Infinity;
clock.stopped = true;
},
});
clocks.push(clock);
return clock;
},
createWaveShaper: () => {
const shaper = wired('shaper', { curve: null as Float32Array | null });
shapers.push(shaper);
return shaper;
},
};
}
@@ -239,6 +258,7 @@ beforeEach(() => {
delays.length = 0;
disconnects.length = 0;
clocks.length = 0;
shapers.length = 0;
contextState = 'running';
decodes = [];
vi.stubGlobal(
@@ -533,6 +553,60 @@ describe('the sound library', () => {
expect(wires.filter((w) => w.startsWith('source'))).toEqual(['source > gain', 'source > gain']);
});
describe('a Mix with the curve "Both at center"', () => {
/** Two plain ways, the share open as `blend`. */
const both = (): FxVoice => ({
plan: {
mix: { value: 0.25, param: 'blend', min: 0, max: 1 },
curve: 'both',
a: { pitch: [], stages: [] },
b: { pitch: [], stages: [] },
stages: [],
},
params: {},
});
it('shapes both shares from one source carrying the mix, so a glide follows the curve', async () => {
const library = new SoundLibrary();
const voice = await play(library, FILE, {}, both());
// The voice, the Mix, share of a, share of b: the shares hold nothing of their own.
const [, , shareA, shareB] = gains;
expect([shareA!.value, shareB!.value]).toEqual([0, 0]);
expect(shapers.map((s) => [...s.curve!])).toEqual([
[1, 1, 1, 1, 0],
[0, 0, 0, 1, 1],
]);
expect(wires.filter((w) => w.startsWith('clock') || w.startsWith('shaper'))).toEqual([
'clock > shaper',
'shaper > ?',
'clock > shaper',
'shaper > ?',
]);
const [driver] = clocks;
expect(driver).toMatchObject({ started: true, stopped: false, offset: { value: 0.25 } });
expect(library.levels(FILE)).toEqual([[0.25]]);
expect(library.tune(voice, { blend: 0.75 })).toBe(true);
expect(driver!.offset.glides).toEqual([0.75]);
expect([shareA!.glides, shareB!.glides]).toEqual([[], []]);
});
it('stops that source with the voice, cut or ended by itself', async () => {
const library = new SoundLibrary();
await play(library, FILE, {}, both());
library.stop(FILE);
expect(clocks[0]!.stopped).toBe(false);
clockTo();
expect(clocks[0]!.stopped).toBe(true);
await play(library, FILE, {}, both());
const driver = clocks.find((c) => !c.stopped && c.started && c.offset.value === 0.25)!;
for (const source of sources.slice(-2)) source.onended?.();
expect(library.stats().playing).toBe(0);
expect(driver.stopped).toBe(true);
});
});
describe('a parameter changed while the voice plays (#315)', () => {
it('glides every knob it moves, a pitch in semitones, and leaves the rest alone', async () => {
const library = new SoundLibrary();