Keep both sounds full at the middle of a Mix with a curve #331
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 of23bdc3f(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:
1 parent
076c564415
commit
fb4d65a2ab
8 files changed
+261
-25
No files matched your search
@@ -403,6 +403,34 @@ describe('compiling a note', () => {
|
||||
expect(callProblem(through, lookup, f.instrument.id)).toBe('No sound FX picked');
|
||||
});
|
||||
|
||||
it('keeps the curve of a Mix inside a sound FX it sends a sound through', () => {
|
||||
const f = makeInstrument();
|
||||
nodeOf(f, 'InstrumentSample').params.sound = f.sound('Piano', KICK).id;
|
||||
// The FX: its Input on both sides of a Mix "Both at center", then the Output.
|
||||
const made = new CreateAssetCommand('soundfx', 'Wide');
|
||||
f.stack.execute(made);
|
||||
const fx = made.asset as SoundFxAsset;
|
||||
const fxEdit = graphEditor(f, fx as unknown as BehaviorAsset);
|
||||
const input = fxEdit.addNode('SoundFxInput');
|
||||
const mix = fxEdit.addNode('SoundFxMix', { curve: 'both' });
|
||||
const fxOut = fxEdit.addNode('SoundFxOutput');
|
||||
fxEdit.wire([
|
||||
[input, 'out', mix, 'a'],
|
||||
[input, 'out', mix, 'b'],
|
||||
[mix, 'out', fxOut, 'in'],
|
||||
]);
|
||||
const sample = nodeOf(f, 'InstrumentSample');
|
||||
const output = nodeOf(f, 'InstrumentOutput');
|
||||
f.instrument.graph.edges = f.instrument.graph.edges.filter((e) => e.to.node !== output.id);
|
||||
const through = f.edit.addNode('InstrumentSoundFx', { fx: fx.id });
|
||||
f.edit.wire([
|
||||
[sample, 'out', through, 'in'],
|
||||
[through, 'out', output, 'in'],
|
||||
]);
|
||||
const [layer] = compile(f, 60).layers;
|
||||
expect(layer).toMatchObject({ mix: { value: 0.5 }, curve: 'both', a: { source: { file: KICK } } });
|
||||
});
|
||||
|
||||
it('says what keeps it from compiling: a Sample nothing triggers, a sound that is gone', () => {
|
||||
const f = makeInstrument();
|
||||
const sample = nodeOf(f, 'InstrumentSample');
|
||||
|
||||
@@ -230,6 +230,15 @@ describe('a Mix in a sound FX graph (#316)', () => {
|
||||
expect(b).toMatchObject({ pitch: 0, stages: [{ kind: 'gain' }, { kind: 'filter' }] });
|
||||
});
|
||||
|
||||
it('says its curve only where it is "Both at center", and is still one sound at 0 or 1', () => {
|
||||
expect(fxChainOf(compileSoundFx(forked().fx.graph).compiled!)).not.toHaveProperty('curve');
|
||||
const f = forked();
|
||||
f.mix.params.curve = 'both';
|
||||
expect(fxChainOf(compileSoundFx(f.fx.graph).compiled!)).toMatchObject({ mix: 0.5, curve: 'both' });
|
||||
f.mix.params.mix = 0;
|
||||
expect(fxChainOf(compileSoundFx(f.fx.graph).compiled!)).toMatchObject({ pitch: 12 });
|
||||
});
|
||||
|
||||
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 }];
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user