Give every sound FX effect a Bypass and a Wet (#354)
A node could only be taken out of a sound FX by unplugging it, and only six of them could be blended with the sound they get. Every effect now has both, in the sound FX graph and in the instrument graph alike. The Mix, the Input and the Output have neither. - Bypass is a checkbox setting. A bypassed node is left out when the graph is compiled, so it costs nothing and holds nothing back. The graph draws it faded, and its body says "Bypass: true". - Wet is a number input like the others. It can be a parameter, be moved by Set sound FX parameters and, where the node allows it for its other numbers, follow an Envelope. - A blend is a stage of its own (`wet`) around what the node adds. The sound as it came in is held back as far as the node is late, so a Compressor, a Limiter or a Vibrato do not comb it out. - The six nodes that had a Mix keep their key `mix` and only call it Wet. Saved values, parameters and edges stay as they were. - The Delay crossfades now: the sound alone at 0, the sound and its echoes in full at 0.5, the echoes alone at 1. Before, the sound was always in full and `mix` was the echoes. - The Pitch takes its semitones by Wet instead of blending, through a share `times` on the amount that an instrument binds as well. Findings: - A Delay saved before this sounds different. It sounds the same at half its old value. The two chip presets with a Delay (Echo Lead, Bell) are halved to keep their sound. - Project cbbf160bb7e8 has two Delays (mix 1 and 0.5). They are left as they are. Tests: - Unit: soundfx 65 (8 new), sound-library 107 (3 new), instrument 35 (1 new), envelope 13. Two delay tests and the filter ports test follow the new numbers. - Counter-checks: 8, each turns its test red. - e2e 34 (melektaus-atx): ALL PASSED, 83 checks (2 new). The ping-pong check at wet 0.25 measures what it did at mix 0.5. - Gate (melektaus-atx): CHECK EXIT 0, 268 files, 4154 tests. - After the gate the derived ports of a blend are asked for once instead of twice: typecheck, lint and the three unit files green again (113 tests). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
c95fbc167e
commit
7a17030472
13 files changed
+978
-385
No files matched your search
+79
-7
@@ -436,9 +436,10 @@ export async function sound(t) {
|
||||
graph.edges = graph.edges.filter((e) => !['fx-e5', 'fx-e6', 'fx-e7'].includes(e.id));
|
||||
graph.edges.push(${JSON.stringify(edge('fx-e3', 'fx-gain', 'out', 'fx-out', 'in'))});`);
|
||||
// #324, #327: a ping-pong Delay behind the gain. The blip is a quarter of a second, its echoes come
|
||||
// 0.3 s apart, each half as loud as the one before: nine are heard, 2.7 s – the voice
|
||||
// stays that long after its sound and goes then. Asked to let ring, only the sound
|
||||
// stops and the voice stays; a stop after that cuts it.
|
||||
// 0.3 s apart, each half as loud as the one before: at wet 0.5 the first is in full
|
||||
// (#354) and ten are heard, 3 s – the voice stays that long after its sound and goes
|
||||
// then. Asked to let ring, only the sound stops and the voice stays; a stop after that
|
||||
// cuts it.
|
||||
await mutate(`
|
||||
const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph;
|
||||
graph.nodes.push(${JSON.stringify(
|
||||
@@ -466,11 +467,12 @@ export async function sound(t) {
|
||||
'a Delay keeps the voice after its sound has ended, until its echoes have faded',
|
||||
delayInfo === '3 effects' &&
|
||||
echoStarted.last?.stages === 2 &&
|
||||
echoKnobs.length === 4 &&
|
||||
echoKnobs.length === 5 &&
|
||||
Math.abs(echoKnobs[0] - 0.3) < 1e-6 &&
|
||||
Math.abs(echoKnobs[1] - 0.5) < 1e-6 &&
|
||||
Math.abs(echoKnobs[2] / (20000 * 0.025 ** 0.3) - 1) < 1e-3 &&
|
||||
Math.abs(echoKnobs[3] - 0.5) < 1e-6 &&
|
||||
Math.abs(echoKnobs[3] - 1) < 1e-6 &&
|
||||
Math.abs(echoKnobs[4] - 1) < 1e-6 &&
|
||||
ringing.playing === 1 &&
|
||||
ringing.clock - startedAt < 2.4 &&
|
||||
quiet.playing === 0 &&
|
||||
@@ -494,6 +496,25 @@ export async function sound(t) {
|
||||
cut.stopped === beforeLetRing.stopped + 2,
|
||||
JSON.stringify({ before: beforeLetRing.stopped, letRing, cut }),
|
||||
);
|
||||
// #354: the Delay bypassed is drawn faded and says so, and the voice plays as if it were not there.
|
||||
await mutate(`
|
||||
const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph;
|
||||
graph.nodes.find((n) => n.id === 'fx-delay').params.bypass = true;`);
|
||||
const bypassed = await json(
|
||||
"({ alpha: __gm.soundfx.nodeAlpha('fx-delay'), settings: __gm.soundfx.nodeSettings('fx-delay'), gain: __gm.soundfx.nodeAlpha('fx-gain') })",
|
||||
);
|
||||
const beforeBypass = await stats();
|
||||
await click(fxPlayAt.x, fxPlayAt.y);
|
||||
const heardBypass = await waitFor(stats, (s) => s.started > beforeBypass.started);
|
||||
await evaluate('__gm.sound.stopAll()');
|
||||
check(
|
||||
'a bypassed Delay is drawn faded with "Bypass: true", and the voice passes only the gain',
|
||||
bypassed.alpha < 0.5 &&
|
||||
bypassed.gain === 1 &&
|
||||
bypassed.settings?.includes('Bypass: true') &&
|
||||
heardBypass.last?.stages === 1,
|
||||
JSON.stringify({ bypassed, last: heardBypass.last }),
|
||||
);
|
||||
await mutate(`
|
||||
const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph;
|
||||
graph.nodes = graph.nodes.filter((n) => n.id !== 'fx-delay');
|
||||
@@ -719,13 +740,13 @@ export async function sound(t) {
|
||||
JSON.stringify(offline),
|
||||
);
|
||||
// #327: the player's own echo, rendered offline. A 10 ms burst into a ping-pong Delay (0.3 s,
|
||||
// feedback 0.5, mix 0.5) comes back left at 0.3 s at half its level, right at 0.6 s at
|
||||
// feedback 0.5, wet 0.25 – the mix 0.5 of before #354) comes back left at 0.3 s at half its level, right at 0.6 s at
|
||||
// half of that, left again at 0.9 s – in Chrome each round 128 samples later than that. Then
|
||||
// 64 stereo voices of it for 2 s, timed, for how many this machine plays at once (#324).
|
||||
const echo = await evaluate(`
|
||||
(async () => {
|
||||
const RATE = 48000;
|
||||
const echoOf = (pingPong) => ({ kind: 'delay', time: 0.3, feedback: 0.5, damping: 20000, mix: 0.5, pingPong });
|
||||
const echoOf = (pingPong) => ({ kind: 'delay', time: 0.3, feedback: 0.5, damping: 20000, mix: 0.25, pingPong });
|
||||
const render = async (voices, seconds, stage = echoOf(true)) => {
|
||||
const context = new OfflineAudioContext(2, RATE * seconds, RATE);
|
||||
const burst = context.createBuffer(1, RATE * 0.01, RATE);
|
||||
@@ -1196,6 +1217,57 @@ export async function sound(t) {
|
||||
JSON.stringify(reverbed.sound) === JSON.stringify([0.3 * 48000, 0.3 * 48000]),
|
||||
JSON.stringify(reverbed),
|
||||
);
|
||||
// #354: Wet rendered offline. A steady 0.5 through a Gain of 0 at wet 0.25 is 0.375. A Delay of
|
||||
// 0.1 s without feedback on it: at wet 0.25 the sound in full and the echo at half, at 0.75
|
||||
// the sound at half and the echo in full, at 1 the echo alone. A Compressor at 1:1 blended
|
||||
// half and half at 1083 Hz – where its 6 ms would cancel – stays as loud: the sound as it
|
||||
// came in is held back to meet it.
|
||||
const wetted = await evaluate(`
|
||||
(async () => {
|
||||
const RATE = 48000;
|
||||
const render = async (stage, fill) => {
|
||||
const context = new OfflineAudioContext(1, RATE / 2, RATE);
|
||||
const sound = context.createBuffer(1, RATE / 2, RATE);
|
||||
fill(sound.getChannelData(0));
|
||||
const source = context.createBufferSource();
|
||||
source.buffer = sound;
|
||||
const nodes = __gm.sound.stageNodes(context, stage);
|
||||
source.connect(nodes.input);
|
||||
nodes.output.connect(context.destination);
|
||||
source.start();
|
||||
return (await context.startRendering()).getChannelData(0);
|
||||
};
|
||||
const steady = (data) => data.fill(0.5);
|
||||
const sine = (data) => { for (let i = 0; i < data.length; i++) data[i] = Math.sin((2 * Math.PI * 1083.333 * i) / RATE) * 0.5; };
|
||||
const at = (out, seconds) => +out[Math.round(seconds * RATE)].toFixed(3);
|
||||
const delay = async (mix) => {
|
||||
const out = await render({ kind: 'delay', time: 0.1, feedback: 0, damping: 20000, mix, pingPong: false }, steady);
|
||||
return [at(out, 0.05), at(out, 0.2)];
|
||||
};
|
||||
const compress = { kind: 'compress', threshold: 0, knee: 0, ratio: 1, attack: 0.003, release: 0.25, gain: 1 };
|
||||
const blended = await render({ kind: 'wet', wet: 0.5, stages: [compress] }, sine);
|
||||
let sum = 0;
|
||||
for (let i = RATE / 4; i < RATE / 2; i++) sum += blended[i] ** 2;
|
||||
return {
|
||||
gain: at(await render({ kind: 'wet', wet: 0.25, stages: [{ kind: 'gain', gain: 0 }] }, steady), 0.1),
|
||||
quarter: await delay(0.25),
|
||||
threeQuarters: await delay(0.75),
|
||||
echoOnly: await delay(1),
|
||||
compressed: +(20 * Math.log10(Math.sqrt(sum / (RATE / 4)) / (0.5 * Math.SQRT1_2))).toFixed(2),
|
||||
};
|
||||
})()`);
|
||||
check(
|
||||
'Wet blends a node with the sound as it came in, a Delay crossfades its echo, a late node meets it in step',
|
||||
wetted.gain === 0.375 &&
|
||||
JSON.stringify([wetted.quarter, wetted.threeQuarters, wetted.echoOnly]) ===
|
||||
JSON.stringify([
|
||||
[0.5, 0.75],
|
||||
[0.25, 0.75],
|
||||
[0, 0.5],
|
||||
]) &&
|
||||
Math.abs(wetted.compressed) < 0.1,
|
||||
JSON.stringify(wetted),
|
||||
);
|
||||
// #329: the Chip on the worklet, rendered offline. A square at 220 Hz with a pitch of 12 and a duty
|
||||
// of 0.25, starting at 0.25 s: silence until then, 440 periods a second after it, up a
|
||||
// quarter of each. Sliding two octaves a second down from 440 Hz with a lowest frequency of
|
||||
|
||||
Reference in new issue
Block a user