The four effects that swing were still drawn as rows of numbers. Like
the Delay, the Saturation and the Limiter, each node now shows a device
of a fixed size. Its screen draws what it does over the first seconds
of a sound, and a light plays that in real time, over and over.
- One device for all four (ModulationPanel, ModulationFace and
modulationView), as KnobPanel is for Gain, Pitch, Mix and Pan. A short
description gives each its title, plate, buttons and knobs.
- Tremolo, 640x280 in cream: a wave its volume shapes, the volume as
its outline. It says DOWN TO 50%, EVERY 200 ms and DIPS -6 dB.
- Vibrato, 640x280 in plum: the pitch in semitones, cool below and warm
above. Under a wet below 1 the dry pitch stands beside it. It says
how late the voice is (LATE 0.93 ms).
- Phaser, 760x280 in mustard, and Flanger, 680x280 in steel blue: the
response from 20 Hz to 20 kHz, and under it a lane of the turn or the
delay over time. The response is drawn where the light is, so the
notches swirl and the comb jets. A comb finer than a column fills it.
- The light loops over 1, 2, 4 or 8 s, two LFO swings at least, for a
Flanger as long as its sweep takes to reach 20 ms. The face only
keeps the editor drawing while something swings and is heard.
- A Rate lamp beside each name goes from dark to lit with the swing.
- Nothing is listened to. The pictures use the worklets' own numbers:
lfoAt, vibratoSpeedUp (now exported from lfo.ts), the allpass chain
with its feedback, and the comb, reckoned at 48 kHz.
- Phaser and Flanger take 4 preset columns. Phased Pad was looked at
in a picture, nothing overlaps.
- Exported from nodes.ts: LFO_SHAPE, lfoShapeOf, TREMOLO_ROWS,
VIBRATO_ROWS, PHASER_STAGE_ROW and phaserStagesOf. FLANGER_MAX_MS is
exported from phaser.ts.
Decided here, as the request left the look open:
- A notch counts where the response falls below -3 dB, not some dB
under the loudest point. Feedback rings peaks far above 0 dB, and the
first rule counted 7 notches on a Phaser of 12 stages.
- Rate and Depth of Tremolo and Vibrato are big knobs, Wet is small.
Tests:
Unit modulation-view (stein2): 22 tests, new. Phaser and Flanger
give the dB of their worklets on a sine within 0.005 dB, the
Tremolo its samples, the Vibrato the pitch it reads at.
Counter-check: with the sign of the Phaser's feedback flipped in one
place, exactly the worklet test fails.
e2e 34 (atx): ALL PASSED, 142 checks, 4 new. Each device has its
size, its ports outside, a light that runs by itself, and a button
and a knob as one undo step each. Picking 8 stages on the Phaser
draws 4 notches.
Gate (atx): CHECK EXIT 0, 291 files, 4467 tests.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
3929 lines
189 KiB
JavaScript
3929 lines
189 KiB
JavaScript
import { execSync } from 'node:child_process';
|
||
import { sineWav } from '../lib/audio.mjs';
|
||
import { edge, node, port, variable, written } from '../lib/builders.mjs';
|
||
import { uploadSound } from '../lib/projects.mjs';
|
||
import { LANES, MAKE_OBJECT, pickAssetType } from './lib/scaffold.mjs';
|
||
import { sampleZoom } from './lib/zoomTransition.mjs';
|
||
|
||
/**
|
||
* Sound assets (#45): uploaded like a typeface – picked from what the server holds through
|
||
* "+ → Sound", or dropped onto the editor – and played by a graph through `Play sound`. Nothing
|
||
* wears a sound and nothing places it; the asset says how loud it is and whether it loops, and the
|
||
* node scales the one and can overrule the other in either direction.
|
||
*
|
||
* Nothing is ever *heard* here: CI runs the browser muted (see tools/lib/browser.mjs). The witness
|
||
* is `__gm.sound.stats()` – voices started, stopped and in flight – the way `renderStats` is the
|
||
* witness for a repaint that did not happen. No pixel check could see any of this.
|
||
*
|
||
* The seed puts two generated WAVs on the server without making assets of them: a 0.25 s blip and
|
||
* a 0.75 s drone, told apart by their exact size rather than by their name.
|
||
*/
|
||
const LANE = LANES.sound;
|
||
const BLIP_BYTES = 4044;
|
||
const DRONE_BYTES = 12044;
|
||
|
||
export async function sound(t) {
|
||
const { check, sleep, json, evaluate, mutate, click, clickText, press, play, stop, shot, showTab } = t;
|
||
const { select, drag } = t;
|
||
const assetsOfType = (type) =>
|
||
json(`__gm.state.project.assets.filter((a) => a.type === ${JSON.stringify(type)})
|
||
.map((a) => ({ id: a.id, name: a.name, file: a.file, props: a.props }))`);
|
||
const stats = () => json('__gm.sound.stats()');
|
||
/** A voice starts only once its bytes have been decoded, so what it changes is awaited. */
|
||
const waitFor = async (read, done, tries = 25) => {
|
||
let value = await read();
|
||
for (let i = 0; i < tries && !done(value); i++) {
|
||
await sleep(150);
|
||
value = await read();
|
||
}
|
||
return value;
|
||
};
|
||
// Every reading up to the one `done` takes: a check holds each against its own time on the audio
|
||
// clock, however late the first one comes in.
|
||
const trace = async (read, done, tries = 40) => {
|
||
const seen = [await read()];
|
||
for (let i = 0; i < tries && !done(seen.at(-1)); i++) {
|
||
await sleep(150);
|
||
seen.push(await read());
|
||
}
|
||
return seen;
|
||
};
|
||
|
||
await press('Escape');
|
||
await evaluate('document.activeElement instanceof HTMLElement && document.activeElement.blur()');
|
||
await evaluate('__gm.state.select(null)');
|
||
await showTab('scene');
|
||
|
||
// 34a. What the server holds, straight from the API: the two files the seed put there.
|
||
const stored = await evaluate(`fetch('/game-maker/api/index.php?r=sounds').then((r) => r.json())`);
|
||
const blip = stored.find((item) => item.bytes === BLIP_BYTES);
|
||
const drone = stored.find((item) => item.bytes === DRONE_BYTES);
|
||
check(
|
||
'the server holds the two seeded sounds, named by their content',
|
||
Boolean(blip && drone) && /^[a-f0-9]{12}\/[a-f0-9]{64}\.wav$/.test(blip.file),
|
||
JSON.stringify({ blip: blip?.file, drone: drone?.file, count: stored.length }),
|
||
);
|
||
if (!blip || !drone) return;
|
||
check(
|
||
'the listing carries the name each file was uploaded under',
|
||
blip.name === 'blip.wav' && drone.name === 'drone.wav',
|
||
JSON.stringify({ blip: blip.name, drone: drone.name }),
|
||
);
|
||
// A file of this run's own that no project uses, for the picker to delete (#344). Random bytes
|
||
// behind a WAV header: the store reads the signature only, and no earlier run has the same.
|
||
const spare = await evaluate(`(async () => {
|
||
const bytes = crypto.getRandomValues(new Uint8Array(48));
|
||
bytes.set([...'RIFF'].map((c) => c.charCodeAt(0)), 0);
|
||
bytes.set([...'WAVE'].map((c) => c.charCodeAt(0)), 8);
|
||
const r = await fetch('/game-maker/api/index.php?r=sounds&name=spare.wav', {
|
||
method: 'POST', headers: { 'Content-Type': 'audio/wav' }, body: bytes,
|
||
});
|
||
return (await r.json()).file;
|
||
})()`);
|
||
|
||
// 34b. "+ → Sound" offers them; a sound is picked rather than created empty, like a typeface.
|
||
await clickText('+');
|
||
await pickAssetType(t, 'Sound');
|
||
await sleep(500);
|
||
const picker = await json('__gm.soundPicker()');
|
||
check(
|
||
'the + menu opens the sound picker on what the server holds, none of it in the project yet',
|
||
Boolean(picker) && picker.files.includes(blip.file) && picker.used.length === 0,
|
||
JSON.stringify(picker),
|
||
);
|
||
check(
|
||
'a row nobody uses yet is captioned with the name the file was uploaded under',
|
||
picker.captions[picker.files.indexOf(blip.file)] === 'blip.wav',
|
||
JSON.stringify(picker.captions),
|
||
);
|
||
await shot('34-sound-picker');
|
||
|
||
// 34b'. A file of one's own that no project uses goes from the picker (#344): the first press of
|
||
// Delete only asks, the second deletes it on the server.
|
||
const spareRow = await json(`__gm.soundPickerRow(${JSON.stringify(spare)})`);
|
||
await click(spareRow.x, spareRow.y);
|
||
const marked = await json('__gm.soundPicker()');
|
||
check(
|
||
'a file of one’s own says whose it is and may be deleted',
|
||
marked.selected === spare && marked.detail.includes('owner e2e') && marked.deleteButton.enabled,
|
||
JSON.stringify({ selected: marked.selected, spare, detail: marked.detail, button: marked.deleteButton }),
|
||
);
|
||
await click(marked.deleteButton.x, marked.deleteButton.y);
|
||
const armed = await json('__gm.soundPicker()');
|
||
await click(armed.deleteButton.x, armed.deleteButton.y);
|
||
const emptied = await waitFor(
|
||
() => json('__gm.soundPicker()'),
|
||
(p) => !p.files.includes(spare),
|
||
);
|
||
const served = await evaluate(`fetch('/game-maker/data/sounds/${spare}').then((r) => r.status)`);
|
||
check(
|
||
'the first press asks, the second deletes the file on the server',
|
||
armed.deleteButton.shown === 'Delete for good' &&
|
||
armed.files.includes(spare) &&
|
||
!emptied.files.includes(spare) &&
|
||
emptied.files.includes(blip.file) &&
|
||
emptied.detail === 'Deleted spare.wav.' &&
|
||
served === 404,
|
||
JSON.stringify({ armed: armed.deleteButton, left: emptied.files.length, detail: emptied.detail, served }),
|
||
);
|
||
|
||
// 34c. The ▶ at a row's left is a preview: it plays that file without choosing it.
|
||
const playAt = await json(`__gm.soundPickerPlay(${JSON.stringify(drone.file)})`);
|
||
await click(playAt.x, playAt.y);
|
||
const previewed = await waitFor(stats, (s) => s.started > 0);
|
||
const afterPreview = await json('__gm.soundPicker()');
|
||
check(
|
||
'pressing a row’s ▶ plays that file and picks nothing',
|
||
previewed.started === 1 &&
|
||
previewed.last?.file === drone.file &&
|
||
afterPreview.played === drone.file &&
|
||
afterPreview.selected === null,
|
||
JSON.stringify({ previewed, selected: afterPreview.selected, played: afterPreview.played }),
|
||
);
|
||
|
||
const row = await json(`__gm.soundPickerRow(${JSON.stringify(blip.file)})`);
|
||
await click(row.x, row.y);
|
||
await clickText('Use sound');
|
||
const sounds = await assetsOfType('sound');
|
||
check(
|
||
'picking a row makes a sound asset of that file, named after the file rather than "Sound"',
|
||
sounds.length === 1 && sounds[0].file === blip.file && sounds[0].name === 'blip',
|
||
JSON.stringify(sounds),
|
||
);
|
||
if (sounds.length !== 1) return;
|
||
const soundId = sounds[0].id;
|
||
check(
|
||
'a fresh sound asset is full volume and one-shot',
|
||
sounds[0].props.volume === 1 && sounds[0].props.loop === false,
|
||
JSON.stringify(sounds[0].props),
|
||
);
|
||
// Closing the picker silences its preview; nothing it started may outlive the dialog.
|
||
check(
|
||
'closing the picker leaves nothing playing',
|
||
(await stats()).playing === 0,
|
||
JSON.stringify(await stats()),
|
||
);
|
||
|
||
// 34d. Its inspector: the two rows, and the preview that is the only way to tell which sound it is.
|
||
await select({ kind: 'asset', assetId: soundId });
|
||
const volumeRow = await json("__gm.inspectorField('Volume', true)");
|
||
const loopRow = await json("__gm.inspectorField('Loop', true)");
|
||
check(
|
||
'a sound asset has a Volume row and a Loop row',
|
||
Boolean(volumeRow) && Boolean(loopRow),
|
||
JSON.stringify({ volumeRow, loopRow }),
|
||
);
|
||
await shot('34-sound-asset');
|
||
|
||
// 34e. A graph plays it. The behavior sits in its own lane; the node's volume is a factor on the
|
||
// asset's, so 0.5 × 0.4 has to reach the library as 0.2 rather than as either of the two.
|
||
await mutate(`
|
||
const p = __gm.state.project;
|
||
const scene = __gm.state.activeScene;
|
||
p.assets.find((a) => a.id === ${JSON.stringify(soundId)}).props.volume = 0.4;
|
||
const asset = structuredClone(p.assets.find((a) => a.id === 'mover'));
|
||
Object.assign(asset, {
|
||
id: 'sn-logic',
|
||
name: 'Noisy',
|
||
props: { enabled: true, runInEditor: false },
|
||
variables: [],
|
||
graph: ${JSON.stringify({
|
||
nodes: [
|
||
node('sn-start', 'OnStart', 0, 0),
|
||
node('sn-play', 'PlaySound', 260, 0, { sound: soundId, volume: 0.5, loop: '' }),
|
||
],
|
||
edges: [edge('sn-e1', 'sn-start', 'exec', 'sn-play', 'exec')],
|
||
})},
|
||
});
|
||
p.assets.unshift(asset);
|
||
${MAKE_OBJECT}
|
||
scene.objects.push(
|
||
make('sn-box', 'crate', '', ${LANE}, 'Noisy box'),
|
||
make('sn-brain', 'sn-logic', 'sn-box', 0, 'Noisy'),
|
||
);
|
||
__gm.lookAt(0, ${LANE});`);
|
||
|
||
await evaluate('__gm.sound.stopAll()');
|
||
const before = await stats();
|
||
await play();
|
||
const played = await waitFor(stats, (s) => s.started > before.started);
|
||
check(
|
||
'a graph playing a sound reaches the library, at the asset volume scaled by the node’s',
|
||
played.started === before.started + 1 &&
|
||
played.last?.file === blip.file &&
|
||
Math.abs(played.last.volume - 0.2) < 0.001 &&
|
||
played.last.loop === false,
|
||
JSON.stringify(played),
|
||
);
|
||
await stop();
|
||
|
||
// 34f. A looping sound is music: it plays on, and ending the run ends it – nothing may outlive
|
||
// the world that started it.
|
||
await mutate(`
|
||
const p = __gm.state.project;
|
||
p.assets.find((a) => a.id === ${JSON.stringify(soundId)}).props.loop = true;`);
|
||
await play();
|
||
const looping = await waitFor(stats, (s) => s.playing > 0 && s.last?.loop === true);
|
||
check(
|
||
'the asset’s own loop flag reaches the library even though the node never asked for one',
|
||
looping.playing >= 1 && looping.last?.loop === true,
|
||
JSON.stringify(looping),
|
||
);
|
||
// Well past the blip's own 0.25 s, so a one-shot would have ended by itself here.
|
||
await sleep(700);
|
||
const stillLooping = await stats();
|
||
await stop();
|
||
const afterStop = await stats();
|
||
check(
|
||
'a loop keeps playing while the run is on and ends with it',
|
||
stillLooping.playing >= 1 && afterStop.playing === 0,
|
||
JSON.stringify({ stillLooping, afterStop }),
|
||
);
|
||
|
||
// 34f-2. The node overrules the asset in both directions – the reason `loop` is a choice of three
|
||
// and not a checkbox: music has to be playable as a one-shot.
|
||
const setNodeLoop = (value) =>
|
||
mutate(`
|
||
const p = __gm.state.project;
|
||
const graph = p.assets.find((a) => a.id === 'sn-logic').graph;
|
||
graph.nodes.find((n) => n.id === 'sn-play').params.loop = ${JSON.stringify(value)};`);
|
||
await setNodeLoop('false');
|
||
const beforeOnce = await stats();
|
||
await play();
|
||
const once = await waitFor(stats, (s) => s.started > beforeOnce.started);
|
||
check('a node saying "off" plays a looping asset once', once.last?.loop === false, JSON.stringify(once));
|
||
await stop();
|
||
await setNodeLoop('');
|
||
|
||
// 34g. `Stop sound` silences it from inside the graph, without ending the run.
|
||
await mutate(`
|
||
const p = __gm.state.project;
|
||
const graph = p.assets.find((a) => a.id === 'sn-logic').graph;
|
||
graph.nodes.push(${JSON.stringify(node('sn-stop', 'StopSound', 520, 0, { sound: soundId }))});
|
||
graph.nodes.push(${JSON.stringify(node('sn-key', 'OnKeyDown', 260, 220, { key: 's' }))});
|
||
graph.edges.push(${JSON.stringify(edge('sn-e2', 'sn-key', 'exec', 'sn-stop', 'exec'))});`);
|
||
await play();
|
||
await waitFor(stats, (s) => s.playing > 0);
|
||
await press('s');
|
||
const silenced = await waitFor(stats, (s) => s.playing === 0);
|
||
check(
|
||
'Stop sound silences a looping sound while the run carries on',
|
||
silenced.playing === 0 && (await json('__gm.playInfo()')).playing === true,
|
||
JSON.stringify(silenced),
|
||
);
|
||
await stop();
|
||
|
||
// 34g-2. A voice belongs to the behavior playing it: destroy the object and its sounds go with it,
|
||
// without any `Stop sound` in the graph. `detached` is the one way out, for the death
|
||
// sound of an object that destroys itself.
|
||
await mutate(`
|
||
const p = __gm.state.project;
|
||
const graph = p.assets.find((a) => a.id === 'sn-logic').graph;
|
||
graph.nodes.push(
|
||
${JSON.stringify(node('sn-key2', 'OnKeyDown', 260, 440, { key: 'd' }))},
|
||
${JSON.stringify(node('sn-owner', 'Owner', 260, 620))},
|
||
${JSON.stringify(node('sn-destroy', 'Destroy', 520, 440))},
|
||
);
|
||
graph.edges.push(
|
||
${JSON.stringify(edge('sn-e3', 'sn-key2', 'exec', 'sn-destroy', 'exec'))},
|
||
${JSON.stringify(edge('sn-e4', 'sn-owner', 'object', 'sn-destroy', 'object'))},
|
||
);`);
|
||
await play();
|
||
await waitFor(stats, (s) => s.playing > 0);
|
||
await press('d');
|
||
const destroyed = await waitFor(stats, (s) => s.playing === 0);
|
||
check(
|
||
'destroying the object silences the looping sound its behavior was playing',
|
||
destroyed.playing === 0 && (await json('__gm.playInfo()')).playing === true,
|
||
JSON.stringify(destroyed),
|
||
);
|
||
await stop();
|
||
|
||
const setDetached = (value) =>
|
||
mutate(`
|
||
const p = __gm.state.project;
|
||
const graph = p.assets.find((a) => a.id === 'sn-logic').graph;
|
||
graph.nodes.find((n) => n.id === 'sn-play').params.detached = ${JSON.stringify(value)};`);
|
||
await setDetached(true);
|
||
await play();
|
||
await waitFor(stats, (s) => s.playing > 0);
|
||
await press('d');
|
||
// Long enough that the reaping frame has been and gone – a voice still here now is here on purpose.
|
||
await sleep(600);
|
||
const detached = await stats();
|
||
check(
|
||
'a detached voice is nobody’s and plays on after the object that started it is gone',
|
||
detached.playing >= 1,
|
||
JSON.stringify(detached),
|
||
);
|
||
await stop();
|
||
check(
|
||
'ending the run silences a detached voice too',
|
||
(await stats()).playing === 0,
|
||
JSON.stringify(await stats()),
|
||
);
|
||
await setDetached(false);
|
||
|
||
// 34g-3. A sound FX asset (#311): "+ → Sound FX" opens its tab, which says what keeps the graph
|
||
// from compiling; built into Input → Pitch → Gain → Output, the header plays the sound
|
||
// through it, and a `Play sound` naming it sends the game's voice the same way. The pitch
|
||
// comes from a parameter `depth` of the Input: the header has a field for it, and a
|
||
// `Sound FX parameters` node hands the game's value in.
|
||
await showTab('scene');
|
||
await clickText('+');
|
||
await pickAssetType(t, 'Sound FX');
|
||
const fxCreated = await json(`{
|
||
tab: __gm.tab(),
|
||
current: __gm.soundfx.current(),
|
||
info: __gm.soundfx.info(),
|
||
preview: __gm.soundfx.preview(),
|
||
assets: __gm.state.project.assets.filter((a) => a.type === 'soundfx').map((a) => a.id),
|
||
}`);
|
||
check(
|
||
'the + menu creates a sound FX asset and opens its tab, with no Output to compile and no Play',
|
||
fxCreated.tab === 'soundfx' &&
|
||
fxCreated.assets.length === 1 &&
|
||
fxCreated.current === fxCreated.assets[0] &&
|
||
fxCreated.info === 'No Output node' &&
|
||
fxCreated.preview.sound === soundId &&
|
||
fxCreated.preview.play === false,
|
||
JSON.stringify(fxCreated),
|
||
);
|
||
const fxId = fxCreated.assets[0];
|
||
const fxCanvas = await json('__gm.soundfx.canvasRect()');
|
||
await click(fxCanvas.x + 80, fxCanvas.y + 60, 'right');
|
||
const fxPalette = await json('__gm.overlayTexts()');
|
||
check(
|
||
'its palette offers the sound effects and no behavior node',
|
||
['Input', 'Output', 'Gain', 'Pitch', 'Parametric EQ', 'Mix', 'Delay'].every((label) =>
|
||
fxPalette.includes(label),
|
||
) && !fxPalette.includes('Branch'),
|
||
JSON.stringify(fxPalette.slice(0, 12)),
|
||
);
|
||
await press('Escape');
|
||
await mutate(`
|
||
const fx = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)});
|
||
fx.name = 'Low';
|
||
fx.graph = ${JSON.stringify({
|
||
nodes: [
|
||
node('fx-in', 'SoundFxInput', 0, 0, {}, [{ key: 'depth', kind: 'number', default: -12 }]),
|
||
node('fx-pitch', 'SoundFxPitch', 220, 0),
|
||
node('fx-gain', 'SoundFxGain', 440, 0, { gain: -6 }),
|
||
node('fx-out', 'SoundFxOutput', 660, 0),
|
||
],
|
||
edges: [
|
||
edge('fx-e1', 'fx-in', 'out', 'fx-pitch', 'in'),
|
||
edge('fx-e2', 'fx-pitch', 'out', 'fx-gain', 'in'),
|
||
edge('fx-e3', 'fx-gain', 'out', 'fx-out', 'in'),
|
||
edge('fx-e4', 'fx-in', 'depth', 'fx-pitch', 'semitones'),
|
||
],
|
||
})};`);
|
||
const fxBuilt = await json(
|
||
'{ info: __gm.soundfx.info(), preview: __gm.soundfx.preview(), params: __gm.soundfx.params() }',
|
||
);
|
||
check(
|
||
'Input → Pitch → Gain → Output compiles to two effects, Play is offered, depth starts at its default',
|
||
fxBuilt.info === '2 effects' && fxBuilt.preview.play === true && fxBuilt.params.depth === -12,
|
||
JSON.stringify(fxBuilt),
|
||
);
|
||
await evaluate('__gm.sound.stopAll()');
|
||
const beforeFx = await stats();
|
||
const fxPlayAt = await json('__gm.soundfx.playAt()');
|
||
await click(fxPlayAt.x, fxPlayAt.y);
|
||
const heardFx = await waitFor(stats, (s) => s.started > beforeFx.started);
|
||
const blipSeconds = await json(`__gm.sound.duration(${JSON.stringify(blip.file)})`);
|
||
check(
|
||
'the tab’s Play sends the sound through the graph: an octave down, as long as before, one gain stage',
|
||
heardFx.last?.file === blip.file &&
|
||
heardFx.last.pitches.join() === '-12' &&
|
||
heardFx.last.duration === blipSeconds &&
|
||
heardFx.last.stages === 1,
|
||
JSON.stringify({ ...heardFx, blipSeconds }),
|
||
);
|
||
await evaluate('__gm.sound.stopAll()');
|
||
await evaluate('__gm.soundfx.setParam("depth", 5)');
|
||
const beforeParam = await stats();
|
||
await click(fxPlayAt.x, fxPlayAt.y);
|
||
const heardParam = await waitFor(stats, (s) => s.started > beforeParam.started);
|
||
check(
|
||
'the header plays with the value its depth field is set to',
|
||
heardParam.last?.pitches.join() === '5' && heardParam.last.stages === 1,
|
||
JSON.stringify(heardParam),
|
||
);
|
||
await evaluate('__gm.sound.stopAll()');
|
||
// #316: the Pitch → Gain way and the dry Input meet in a Mix – two sources, one per pitch.
|
||
await mutate(`
|
||
const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph;
|
||
graph.nodes.push(${JSON.stringify(node('fx-mix', 'SoundFxMix', 550, 0, { mix: 0.5 }))});
|
||
graph.edges = graph.edges.filter((e) => e.id !== 'fx-e3');
|
||
graph.edges.push(
|
||
${JSON.stringify(edge('fx-e5', 'fx-gain', 'out', 'fx-mix', 'a'))},
|
||
${JSON.stringify(edge('fx-e6', 'fx-in', 'out', 'fx-mix', 'b'))},
|
||
${JSON.stringify(edge('fx-e7', 'fx-mix', 'out', 'fx-out', 'in'))},
|
||
);`);
|
||
const mixInfo = await json('__gm.soundfx.info()');
|
||
const beforeMix = await stats();
|
||
await click(fxPlayAt.x, fxPlayAt.y);
|
||
const heardMix = await waitFor(stats, (s) => s.started > beforeMix.started);
|
||
check(
|
||
'a Mix of the pitched way and the dry Input plays one voice from two sources of the same length',
|
||
mixInfo === '3 effects' &&
|
||
heardMix.last?.pitches.join() === '5,0' &&
|
||
heardMix.last.stages === 1 &&
|
||
heardMix.last.duration === blipSeconds,
|
||
JSON.stringify({ mixInfo, ...heardMix }),
|
||
);
|
||
await evaluate('__gm.sound.stopAll()');
|
||
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-mix');
|
||
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: 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(
|
||
node('fx-delay', 'SoundFxDelay', 550, 0, { time: 0.3, feedback: 0.5, mix: 0.5, pingPong: true }),
|
||
)});
|
||
graph.edges = graph.edges.filter((e) => e.id !== 'fx-e3');
|
||
graph.edges.push(
|
||
${JSON.stringify(edge('fx-e8', 'fx-gain', 'out', 'fx-delay', 'in'))},
|
||
${JSON.stringify(edge('fx-e9', 'fx-delay', 'out', 'fx-out', 'in'))},
|
||
);`);
|
||
const delayInfo = await json('__gm.soundfx.info()');
|
||
await evaluate('__gm.sound.stopAll()');
|
||
const beforeEcho = await stats();
|
||
await click(fxPlayAt.x, fxPlayAt.y);
|
||
const echoStarted = await waitFor(stats, (s) => s.started > beforeEcho.started);
|
||
// Timed on the audio clock: the page's can fall behind it (task 523 saw a 2.95 s tail end 1 s in).
|
||
const audioNow = () => json('({ playing: __gm.sound.stats().playing, clock: __gm.sound.clock() })');
|
||
const startedAt = (await audioNow()).clock;
|
||
const echoLevels = await json(`__gm.sound.levels(${JSON.stringify(blip.file)})`);
|
||
const ringing = await waitFor(audioNow, (s) => s.clock - startedAt >= 1);
|
||
const quiet = await waitFor(audioNow, (s) => s.playing === 0, 40);
|
||
const rangFor = quiet.clock - startedAt;
|
||
const echoKnobs = echoLevels?.[0]?.slice(2) ?? [];
|
||
check(
|
||
'a Delay keeps the voice after its sound has ended, until its echoes have faded',
|
||
delayInfo === '3 effects' &&
|
||
echoStarted.last?.stages === 2 &&
|
||
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] - 1) < 1e-6 &&
|
||
Math.abs(echoKnobs[4] - 1) < 1e-6 &&
|
||
ringing.playing === 1 &&
|
||
ringing.clock - startedAt < 2.4 &&
|
||
quiet.playing === 0 &&
|
||
rangFor > 2.4 &&
|
||
rangFor < 4.5,
|
||
JSON.stringify({ delayInfo, last: echoStarted.last, echoLevels, ringing, startedAt, rangFor }),
|
||
);
|
||
await click(fxPlayAt.x, fxPlayAt.y);
|
||
await waitFor(stats, (s) => s.playing === 1);
|
||
const beforeLetRing = await stats();
|
||
await evaluate(`__gm.sound.stop(${JSON.stringify(blip.file)}, true)`);
|
||
await sleep(500);
|
||
const letRing = await stats();
|
||
await evaluate(`__gm.sound.stop(${JSON.stringify(blip.file)})`);
|
||
const cut = await stats();
|
||
check(
|
||
'Let ring stops the sound and keeps the voice for its echoes; a stop after that cuts it',
|
||
letRing.playing === 1 &&
|
||
letRing.stopped === beforeLetRing.stopped + 1 &&
|
||
cut.playing === 0 &&
|
||
cut.stopped === beforeLetRing.stopped + 2,
|
||
JSON.stringify({ before: beforeLetRing.stopped, letRing, cut }),
|
||
);
|
||
// #354: the Delay bypassed is drawn faded, and the voice plays as if it were not there. Since
|
||
// #393 the Delay is a device, which writes no setting rows, so "Bypass: true" is not read.
|
||
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, and the voice passes only the gain',
|
||
bypassed.alpha < 0.5 && bypassed.gain === 1 && 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');
|
||
graph.edges = graph.edges.filter((e) => !['fx-e8', 'fx-e9'].includes(e.id));
|
||
graph.edges.push(${JSON.stringify(edge('fx-e3', 'fx-gain', 'out', 'fx-out', 'in'))});`);
|
||
// #367: a Parametric EQ behind the gain. A double-click on the node opens its editor, one on the
|
||
// curve adds a peak there, a drag moves it as one undo step, the wheel narrows it, and the
|
||
// voice passes one filter for it.
|
||
// #370: the editor is a tab of its own, and goes with its node.
|
||
await mutate(`
|
||
const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph;
|
||
graph.nodes.push(${JSON.stringify(node('fx-eq', 'SoundFxParametricEq', -240, 0))});
|
||
graph.edges = graph.edges.filter((e) => e.id !== 'fx-e3');
|
||
graph.edges.push(
|
||
${JSON.stringify(edge('fx-e10', 'fx-gain', 'out', 'fx-eq', 'in'))},
|
||
${JSON.stringify(edge('fx-e11', 'fx-eq', 'out', 'fx-out', 'in'))},
|
||
);`);
|
||
const eqNode = await json("__gm.soundfx.nodeAt('fx-eq')");
|
||
await t.doubleClick(eqNode.x, eqNode.y);
|
||
const eqShown = await json('__gm.eq.shown()');
|
||
const eqDocs = await json('__gm.documents()');
|
||
const eqPeakAt = await json('__gm.eq.pointAt(1000, 6)');
|
||
await t.doubleClick(eqPeakAt.x, eqPeakAt.y);
|
||
const eqAdded = await json('{ bands: __gm.eq.bands(), fields: __gm.eq.fields() }');
|
||
const bandNear = (band, hz, db) =>
|
||
band && Math.abs(Math.log2(band.frequency / hz)) < 0.05 && Math.abs(band.gain - db) < 0.5;
|
||
check(
|
||
'a double-click opens the Parametric EQ in a tab, and one on its curve adds a peak there',
|
||
eqShown?.nodeId === 'fx-eq' &&
|
||
eqDocs.active === `eq:${fxId}:fx-eq` &&
|
||
eqDocs.docs.includes(`asset:${fxId}`) &&
|
||
eqAdded.bands.length === 1 &&
|
||
eqAdded.bands[0].type === 'peaking' &&
|
||
bandNear(eqAdded.bands[0], 1000, 6) &&
|
||
eqAdded.fields.type === 'Peaking' &&
|
||
eqAdded.fields.slope === null,
|
||
JSON.stringify({ eqShown, eqDocs, eqAdded }),
|
||
);
|
||
const eqHandle = await json('__gm.eq.handleAt(0)');
|
||
const eqTo = await json('__gm.eq.pointAt(4000, -6)');
|
||
await drag(eqHandle.x, eqHandle.y, eqTo.x, eqTo.y);
|
||
const eqDragged = (await json('__gm.eq.bands()'))[0];
|
||
await press('z', { ctrl: true });
|
||
const eqUndone = (await json('__gm.eq.bands()'))[0];
|
||
await press('z', { ctrl: true, shift: true });
|
||
const eqHandleBack = await json('__gm.eq.handleAt(0)');
|
||
await t.wheel(eqHandleBack.x, eqHandleBack.y, -100);
|
||
const eqWheeled = (await json('__gm.eq.bands()'))[0];
|
||
check(
|
||
'dragging the band moves it as one undo step, and the wheel narrows it',
|
||
bandNear(eqDragged, 4000, -6) &&
|
||
bandNear(eqUndone, 1000, 6) &&
|
||
bandNear(eqWheeled, 4000, -6) &&
|
||
eqWheeled.q > 1,
|
||
JSON.stringify({ eqDragged, eqUndone, eqWheeled }),
|
||
);
|
||
// #376: the wheel on free space zooms the curve to ±12 dB, so the band's handle moves out with its
|
||
// gain. The zoom is saved on the node, the Q stays, and Ctrl+Z undoes the Q, not the zoom.
|
||
const eqFree = await json('__gm.eq.pointAt(50, 12)');
|
||
await t.wheel(eqFree.x, eqFree.y, -100);
|
||
const eqZoomed = await json(`({
|
||
range: __gm.eq.range(),
|
||
saved: __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph.nodes
|
||
.find((n) => n.id === 'fx-eq').params.range,
|
||
q: __gm.eq.bands()[0].q,
|
||
handle: __gm.eq.handleAt(0),
|
||
at: __gm.eq.pointAt(4000, -6),
|
||
})`);
|
||
await press('z', { ctrl: true });
|
||
const eqZoomUndone = await json('({ range: __gm.eq.range(), q: __gm.eq.bands()[0].q })');
|
||
await press('z', { ctrl: true, shift: true });
|
||
check(
|
||
'the wheel on free space zooms the curve to ±12 dB, saved on the node, and Ctrl+Z leaves the zoom',
|
||
eqZoomed.range === 12 &&
|
||
eqZoomed.saved === 12 &&
|
||
eqZoomed.q === eqWheeled.q &&
|
||
eqZoomed.handle.y > eqHandleBack.y + 10 &&
|
||
Math.abs(eqZoomed.handle.y - eqZoomed.at.y) < 1 &&
|
||
eqZoomUndone.range === 12 &&
|
||
eqZoomUndone.q < eqWheeled.q,
|
||
JSON.stringify({ eqHandleBack, eqZoomed, eqZoomUndone }),
|
||
);
|
||
// #377: at 1000 ms a frame after the wheel the dB are on their way from ±12 to ±6, and the node
|
||
// holds the step at once.
|
||
const eqZoom = await sampleZoom(
|
||
t,
|
||
eqFree,
|
||
`({ scale: __gm.eq.zoomShown().range, moving: __gm.eq.zoomShown().moving, saved: __gm.eq.range() })`,
|
||
);
|
||
check(
|
||
'a zoom of the curve is shown on its way and lands where the jump does',
|
||
eqZoom.ok && eqZoom.samples.start.scale === 12 && eqZoom.samples.midway.saved === 6,
|
||
eqZoom.detail,
|
||
);
|
||
await shot('34-parametric-eq');
|
||
// #374: floated beside the Sound FX tab, the EQ's tab draws the spectrum of what passes the node:
|
||
// the blip's 880 Hz is the loudest bin in front of the EQ and behind it. Once the tab is not
|
||
// seen, no tap feeds an analyser any more, though the voice still plays.
|
||
await evaluate(`__gm.floatDocument(${JSON.stringify(`eq:${fxId}:fx-eq`)})`);
|
||
await evaluate('__gm.soundfx.setParam("depth", 0)');
|
||
await evaluate('__gm.sound.stopAll()');
|
||
const framesBefore = (await json('__gm.eq.spectrum()')).frames;
|
||
await evaluate('__gm.soundfx.play()');
|
||
const spectrum = await waitFor(
|
||
() => json('__gm.eq.spectrum()'),
|
||
(s) => s.frames >= framesBefore + 3,
|
||
);
|
||
await shot('34-eq-spectrum');
|
||
const nearBlip = (hz) => Math.abs(hz - 880) <= spectrum.binHz;
|
||
check(
|
||
'floated beside the Sound FX tab, the EQ draws the blip’s 880 Hz as the loudest bin on both sides',
|
||
spectrum.taps.listened === `${fxId}:fx-eq` &&
|
||
spectrum.frames >= framesBefore + 3 &&
|
||
nearBlip(spectrum.peakIn) &&
|
||
nearBlip(spectrum.peakOut) &&
|
||
spectrum.levelOut > -60,
|
||
JSON.stringify(spectrum),
|
||
);
|
||
const eqWindow = (await json('__gm.floatWindows()')).find((w) => w.kind === 'eq');
|
||
await click(eqWindow.dock.x, eqWindow.dock.y);
|
||
const unseen = await evaluate(`(async () => {
|
||
const frame = () => new Promise((done) => requestAnimationFrame(() => done()));
|
||
__gm.sound.stopAll();
|
||
const before = __gm.sound.stats().started;
|
||
__gm.soundfx.play();
|
||
for (let i = 0; i < 120 && __gm.sound.stats().started === before; i++) await frame();
|
||
await frame();
|
||
const seen = __gm.eq.spectrum().taps;
|
||
// Not the sound FX graph: the node there listens to the same tap (#381).
|
||
__gm.showTab('scene');
|
||
await frame();
|
||
await frame();
|
||
return { seen, unseen: __gm.eq.spectrum().taps, playing: __gm.sound.stats().playing };
|
||
})()`);
|
||
check(
|
||
'once the EQ’s tab is not seen, no tap feeds an analyser, though the voice still plays',
|
||
unseen.seen.held >= 2 &&
|
||
unseen.seen.feeding === unseen.seen.held &&
|
||
unseen.unseen.listened === '' &&
|
||
unseen.unseen.held >= 2 &&
|
||
unseen.unseen.feeding === 0 &&
|
||
unseen.playing === 1,
|
||
JSON.stringify(unseen),
|
||
);
|
||
await evaluate(`__gm.openDocument(${JSON.stringify(`asset:${fxId}`)})`);
|
||
// #381: the EQ node shows the panel of its tab at full size, its inputs on the left and its output
|
||
// on the right. A double-click on its curve adds a band without opening the tab, a drag is one
|
||
// undo step, the wheel over a band zooms the graph and leaves its Q and the dB alone (#383),
|
||
// and a right-click takes the band. Played, the node
|
||
// draws the spectrum, its tab open in the row.
|
||
await evaluate('__gm.soundfx.fit()');
|
||
await sleep(100);
|
||
const eqPanel = () => json("__gm.eq.panel('fx-eq')");
|
||
const eqFace = await eqPanel();
|
||
const eqRect = eqFace.rect;
|
||
const besideEq = (p) => p.y > eqRect.y && p.y < eqRect.y + eqRect.h;
|
||
check(
|
||
'a Parametric EQ node shows the panel of its tab at full size, its inputs on the left of it and its output on the right',
|
||
eqFace.shown &&
|
||
eqFace.size.w === 720 &&
|
||
eqFace.size.h === 360 &&
|
||
Object.values(eqFace.inputs).length >= 1 &&
|
||
Object.values(eqFace.inputs).every((p) => p.x < eqRect.x && besideEq(p)) &&
|
||
Object.values(eqFace.outputs).length === 1 &&
|
||
Object.values(eqFace.outputs).every((p) => p.x > eqRect.x + eqRect.w && besideEq(p)) &&
|
||
eqFace.curve.h > eqRect.h / 2 &&
|
||
eqFace.bands.length === 1,
|
||
JSON.stringify(eqFace),
|
||
);
|
||
const eqNodeAt = (hz, db) => json(`__gm.eq.panelPointAt('fx-eq', ${hz}, ${db})`);
|
||
const eqAddAt = await eqNodeAt(250, 6);
|
||
await t.doubleClick(eqAddAt.x, eqAddAt.y);
|
||
const eqNodeAdded = { panel: await eqPanel(), docs: await json('__gm.documents()') };
|
||
const eqNodeTo = await eqNodeAt(500, -3);
|
||
const eqNodeHandle = eqNodeAdded.panel.handles[1];
|
||
await drag(eqNodeHandle.x, eqNodeHandle.y, eqNodeTo.x, eqNodeTo.y);
|
||
const eqNodeDragged = (await eqPanel()).bands[1];
|
||
await press('z', { ctrl: true });
|
||
const eqNodeUndone = (await eqPanel()).bands[1];
|
||
const eqRangeBefore = await json('__gm.eq.range()');
|
||
await t.wheel(eqNodeHandle.x, eqNodeHandle.y, -100);
|
||
const eqNodeWheeled = { panel: await eqPanel(), range: await json('__gm.eq.range()') };
|
||
await evaluate('__gm.soundfx.fit()');
|
||
await sleep(100);
|
||
const eqNodeLast = (await eqPanel()).handles[1];
|
||
await click(eqNodeLast.x, eqNodeLast.y, 'right');
|
||
const eqNodeRemoved = await eqPanel();
|
||
check(
|
||
'in the node, a double-click on the curve adds a band without opening the tab, a drag moves it as one undo step, the wheel over it zooms the graph and leaves its Q, and a right-click takes it',
|
||
eqNodeAdded.panel.bands.length === 2 &&
|
||
bandNear(eqNodeAdded.panel.bands[1], 250, 6) &&
|
||
eqNodeAdded.panel.picked === 1 &&
|
||
eqNodeAdded.docs.active === `asset:${fxId}` &&
|
||
bandNear(eqNodeDragged, 500, -3) &&
|
||
bandNear(eqNodeUndone, 250, 6) &&
|
||
eqNodeWheeled.panel.bands[1].q === eqNodeUndone.q &&
|
||
eqNodeWheeled.panel.rect.w > eqRect.w + 10 &&
|
||
eqNodeWheeled.range === eqRangeBefore &&
|
||
eqNodeRemoved.bands.length === 1 &&
|
||
bandNear(eqNodeRemoved.bands[0], 4000, -6),
|
||
JSON.stringify({ eqNodeAdded, eqNodeDragged, eqNodeUndone, eqNodeWheeled, eqNodeRemoved }),
|
||
);
|
||
await evaluate('__gm.soundfx.fit()');
|
||
await evaluate('__gm.sound.stopAll()');
|
||
const eqNodeFrames = (await eqPanel()).spectrum.frames;
|
||
await evaluate('__gm.soundfx.play()');
|
||
const eqNodeHeard = await waitFor(eqPanel, (p) => p.spectrum.frames >= eqNodeFrames + 3);
|
||
await evaluate('__gm.sound.stopAll()');
|
||
check(
|
||
'played, the EQ node draws the blip’s 880 Hz behind its curve, its tab open in the row',
|
||
eqNodeHeard.spectrum.frames >= eqNodeFrames + 3 &&
|
||
nearBlip(eqNodeHeard.spectrum.peakOut) &&
|
||
(await json('__gm.documents()')).docs.includes(`eq:${fxId}:fx-eq`),
|
||
JSON.stringify(eqNodeHeard.spectrum),
|
||
);
|
||
await shot('34-eq-node');
|
||
// #385: the EQ node is resized by its lower right corner, one undo step: small, the row under its
|
||
// curve goes, and a little larger it comes back with what fits of it, from the left.
|
||
const eqCorner = (await eqPanel()).corner;
|
||
await drag(eqCorner.x, eqCorner.y, eqCorner.x - 600, eqCorner.y - 400);
|
||
const eqLeast = {
|
||
panel: await eqPanel(),
|
||
size: await json(
|
||
`__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph.nodes.find((n) => n.id === 'fx-eq').size`,
|
||
),
|
||
};
|
||
await press('z', { ctrl: true });
|
||
const eqFull = await eqPanel();
|
||
const zoom = eqFull.rect.w / eqFull.size.w;
|
||
await drag(eqFull.corner.x, eqFull.corner.y, eqFull.corner.x - 300 * zoom, eqFull.corner.y - 160 * zoom);
|
||
const eqMid = await eqPanel();
|
||
const eqRow = ['Band', 'Type', 'Hz', 'dB', 'Q', 'Slope', 'End'];
|
||
const leftPart = (row) => row.every((name, i) => name === eqRow[i]);
|
||
check(
|
||
'an EQ node is resized by its corner as one undo step: small, its row goes; larger, the groups that fit come back from the left',
|
||
eqLeast.size?.w === 240 &&
|
||
eqLeast.size.h === 120 &&
|
||
eqLeast.panel.size.w === 240 &&
|
||
eqLeast.panel.rows.row.length === 0 &&
|
||
eqFull.size.w === 720 &&
|
||
eqFull.rows.row.join() === eqRow.join() &&
|
||
eqFull.rows.help === false &&
|
||
eqMid.size.w < 720 &&
|
||
eqMid.rows.row.length >= 1 &&
|
||
eqMid.rows.row.length < eqRow.length &&
|
||
leftPart(eqMid.rows.row),
|
||
JSON.stringify({
|
||
eqLeast,
|
||
eqFull: { size: eqFull.size, rows: eqFull.rows },
|
||
eqMid: { size: eqMid.size, rows: eqMid.rows },
|
||
}),
|
||
);
|
||
await press('z', { ctrl: true });
|
||
// #385: a Filter node is edited in its node as one band of the curve: its handle is dragged for the
|
||
// frequency and the gain as one undo step. A double-click on its title opens the same in a tab,
|
||
// Delete there leaves the node, and a small window draws what fits of the row and no help.
|
||
await mutate(`
|
||
const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph;
|
||
graph.nodes.push(${JSON.stringify(node('fx-filter', 'SoundFxFilter', -240, 600, { filter: 'peaking', cutoff: 600, gain: -8, q: 2 }))});
|
||
graph.edges = graph.edges.filter((e) => e.id !== 'fx-e11');
|
||
graph.edges.push(
|
||
${JSON.stringify(edge('fx-e12', 'fx-eq', 'out', 'fx-filter', 'in'))},
|
||
${JSON.stringify(edge('fx-e13', 'fx-filter', 'out', 'fx-out', 'in'))},
|
||
);`);
|
||
await evaluate('__gm.soundfx.fit()');
|
||
await sleep(100);
|
||
const filterPanel = () => json("__gm.eq.panel('fx-filter')");
|
||
const filterFace = await filterPanel();
|
||
const filterRect = filterFace.rect;
|
||
const filterTo = await json("__gm.eq.panelPointAt('fx-filter', 2000, 6)");
|
||
await drag(filterFace.handles[0].x, filterFace.handles[0].y, filterTo.x, filterTo.y);
|
||
const filterDragged = await filterPanel();
|
||
await press('z', { ctrl: true });
|
||
const filterUndone = await filterPanel();
|
||
check(
|
||
'a Filter node shows its curve with one band between its ports, dragged for frequency and gain as one undo step',
|
||
filterFace.shown &&
|
||
filterFace.size.w === 560 &&
|
||
filterFace.size.h === 280 &&
|
||
Object.values(filterFace.inputs).every((p) => p.x < filterRect.x) &&
|
||
Object.values(filterFace.outputs).every((p) => p.x > filterRect.x + filterRect.w) &&
|
||
filterFace.rows.row.join() === 'Type,Hz,dB,Q,Slope' &&
|
||
filterFace.fields.type === 'Peaking' &&
|
||
bandNear(filterFace.bands[0], 600, -8) &&
|
||
bandNear(filterDragged.bands[0], 2000, 6) &&
|
||
bandNear(filterUndone.bands[0], 600, -8) &&
|
||
filterDragged.bands[0].q === 2,
|
||
JSON.stringify({ filterFace, filterDragged: filterDragged.bands, filterUndone: filterUndone.bands }),
|
||
);
|
||
const filterTitle = await json("__gm.soundfx.nodeAt('fx-filter')");
|
||
await t.doubleClick(filterTitle.x, filterTitle.y);
|
||
const filterTab = {
|
||
shown: await json('__gm.filter.shown()'),
|
||
rows: await json('__gm.filter.rows()'),
|
||
docs: await json('__gm.documents()'),
|
||
};
|
||
await press('Delete');
|
||
const filterKept = await json(
|
||
`__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph.nodes.some((n) => n.id === 'fx-filter')`,
|
||
);
|
||
await evaluate(`__gm.floatDocument(${JSON.stringify(`filter:${fxId}:fx-filter`)})`);
|
||
const filterWindow = (await json('__gm.floatWindows()')).find((w) => w.kind === 'filter');
|
||
await drag(filterWindow.grip.x, filterWindow.grip.y, filterWindow.box.x + 470, filterWindow.box.y + 250);
|
||
const filterSmall = { rows: await json('__gm.filter.rows()'), labels: await json('__gm.filter.labels()') };
|
||
await shot('34-filter-window');
|
||
const filterRow = ['Type', 'Hz', 'dB', 'Q', 'Slope'];
|
||
check(
|
||
'a double-click opens the Filter in a tab with its help, Delete there leaves the node, and a small window draws what fits of the row and no help',
|
||
filterTab.shown?.nodeId === 'fx-filter' &&
|
||
filterTab.docs.active === `filter:${fxId}:fx-filter` &&
|
||
filterTab.rows.row.join() === filterRow.join() &&
|
||
filterTab.rows.help === true &&
|
||
filterKept &&
|
||
filterSmall.rows.help === false &&
|
||
filterSmall.rows.row.length >= 1 &&
|
||
filterSmall.rows.row.length < filterRow.length &&
|
||
filterSmall.rows.row.every((name, i) => name === filterRow[i]),
|
||
JSON.stringify({ filterTab, filterKept, filterSmall }),
|
||
);
|
||
// #385: played, the Filter node and its window both draw what passes it, as the EQ does.
|
||
await evaluate('__gm.sound.stopAll()');
|
||
const filterSpectra = async () => ({
|
||
node: (await filterPanel()).spectrum,
|
||
tab: await json('__gm.filter.spectrum()'),
|
||
});
|
||
const filterFrames = await filterSpectra();
|
||
await evaluate('__gm.soundfx.play()');
|
||
const filterHeard = await waitFor(
|
||
filterSpectra,
|
||
(s) => s.node.frames >= filterFrames.node.frames + 3 && s.tab.frames >= filterFrames.tab.frames + 3,
|
||
);
|
||
await evaluate('__gm.sound.stopAll()');
|
||
check(
|
||
'played, the Filter node and its window draw the blip’s 880 Hz behind the curve',
|
||
filterHeard.node.frames >= filterFrames.node.frames + 3 &&
|
||
filterHeard.tab.frames >= filterFrames.tab.frames + 3 &&
|
||
nearBlip(filterHeard.node.peakIn) &&
|
||
nearBlip(filterHeard.tab.peakIn) &&
|
||
filterHeard.tab.taps.listened.split(' ').includes(`${fxId}:fx-filter`),
|
||
JSON.stringify({ filterFrames, filterHeard }),
|
||
);
|
||
const filterClose = (await json('__gm.floatWindows()')).find((w) => w.kind === 'filter');
|
||
await click(filterClose.close.x, filterClose.close.y);
|
||
await evaluate(`__gm.openDocument(${JSON.stringify(`asset:${fxId}`)})`);
|
||
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-filter');
|
||
graph.edges = graph.edges.filter((e) => !['fx-e12', 'fx-e13'].includes(e.id));
|
||
graph.edges.push(${JSON.stringify(edge('fx-e11', 'fx-eq', 'out', 'fx-out', 'in'))});`);
|
||
await evaluate('__gm.sound.stopAll()');
|
||
const eqBefore = await stats();
|
||
await click(fxPlayAt.x, fxPlayAt.y);
|
||
const eqHeard = await waitFor(stats, (s) => s.started > eqBefore.started);
|
||
await evaluate('__gm.sound.stopAll()');
|
||
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-eq');
|
||
graph.edges = graph.edges.filter((e) => !['fx-e10', 'fx-e11'].includes(e.id));
|
||
graph.edges.push(${JSON.stringify(edge('fx-e3', 'fx-gain', 'out', 'fx-out', 'in'))});`);
|
||
const eqDocsAfter = await json('__gm.documents()');
|
||
check(
|
||
'the voice passes the gain and the band, and the EQ’s tab goes with its node',
|
||
eqHeard.last?.stages === 2 && !eqDocsAfter.docs.some((doc) => doc.startsWith('eq:')),
|
||
JSON.stringify({ last: eqHeard.last, eqDocsAfter }),
|
||
);
|
||
// #378: a Compressor behind the gain opens a tab of its own as well. Its knobs turn with a drag up
|
||
// (one undo step), a notch of the wheel and a double-click back to the default, and take a
|
||
// typed number. The end of its curve is dragged for the ratio. Played, the level going out
|
||
// stays under the one coming in, and a knob whose input is linked is greyed.
|
||
await mutate(`
|
||
const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph;
|
||
graph.nodes.push(${JSON.stringify(
|
||
node('fx-comp', 'SoundFxCompressor', -240, 0, {
|
||
threshold: -30,
|
||
ratio: 4,
|
||
knee: 0,
|
||
}),
|
||
)});
|
||
graph.edges = graph.edges.filter((e) => e.id !== 'fx-e3');
|
||
graph.edges.push(
|
||
${JSON.stringify(edge('fx-e12', 'fx-gain', 'out', 'fx-comp', 'in'))},
|
||
${JSON.stringify(edge('fx-e13', 'fx-comp', 'out', 'fx-out', 'in'))},
|
||
);`);
|
||
const compNode = await json("__gm.soundfx.nodeAt('fx-comp')");
|
||
await t.doubleClick(compNode.x, compNode.y);
|
||
const compShown = await json(
|
||
'({ shown: __gm.compressor.shown(), docs: __gm.documents(), knobs: __gm.compressor.knobs() })',
|
||
);
|
||
check(
|
||
'a double-click opens the Compressor in a tab, a knob for each of its numbers',
|
||
compShown.shown?.nodeId === 'fx-comp' &&
|
||
compShown.docs.active === `compressor:${fxId}:fx-comp` &&
|
||
compShown.knobs.threshold.value === -30 &&
|
||
compShown.knobs.ratio.value === 4 &&
|
||
compShown.knobs.knee.value === 0 &&
|
||
compShown.knobs.attack.value === 0.003 &&
|
||
Object.keys(compShown.knobs).length === 6,
|
||
JSON.stringify(compShown),
|
||
);
|
||
const thresholdDial = await json("__gm.compressor.dialAt('threshold')");
|
||
await drag(thresholdDial.x, thresholdDial.y, thresholdDial.x, thresholdDial.y - 20);
|
||
const compDragged = await json('__gm.compressor.numbers()');
|
||
await press('z', { ctrl: true });
|
||
const compUndone = await json('__gm.compressor.numbers()');
|
||
await press('z', { ctrl: true, shift: true });
|
||
const ratioDial = await json("__gm.compressor.dialAt('ratio')");
|
||
await t.wheel(ratioDial.x, ratioDial.y, -100);
|
||
const kneeDial = await json("__gm.compressor.dialAt('knee')");
|
||
await t.doubleClick(kneeDial.x, kneeDial.y);
|
||
const makeupField = await json("__gm.compressor.fieldAt('makeup')");
|
||
await click(makeupField.x, makeupField.y);
|
||
await t.typeText('3');
|
||
await press('Enter');
|
||
await sleep(150);
|
||
const compTurned = await json('({ numbers: __gm.compressor.numbers(), knobs: __gm.compressor.knobs() })');
|
||
check(
|
||
'a knob turns up with a drag up as one undo step, a notch of the wheel, a double-click and a typed number',
|
||
compDragged.threshold === -20 &&
|
||
compUndone.threshold === -30 &&
|
||
compTurned.numbers.threshold === -20 &&
|
||
compTurned.numbers.ratio > 4 &&
|
||
compTurned.numbers.ratio < 5 &&
|
||
compTurned.numbers.knee === 6 &&
|
||
compTurned.numbers.makeup === 3 &&
|
||
compTurned.knobs.makeup.value === 3,
|
||
JSON.stringify({ compDragged, compUndone, compTurned }),
|
||
);
|
||
// Auto-gain is a switch (#378): a click turns it on as one undo step, and it says what it gives.
|
||
const autoOff = await json('__gm.compressor.autoGain()');
|
||
await click(autoOff.at.x, autoOff.at.y);
|
||
await sleep(100);
|
||
const autoOn = await json('({ ...__gm.compressor.autoGain(), numbers: __gm.compressor.numbers() })');
|
||
await press('z', { ctrl: true });
|
||
await sleep(100);
|
||
const autoUndone = await json('({ ...__gm.compressor.autoGain(), numbers: __gm.compressor.numbers() })');
|
||
check(
|
||
'the auto-gain switch turns on with a click as one undo step and says the dB it gives',
|
||
!autoOff.on &&
|
||
autoOff.says === 'off' &&
|
||
autoOn.on &&
|
||
autoOn.numbers.autoGain === true &&
|
||
/^\+\d+\.\d dB$/.test(autoOn.says) &&
|
||
!autoUndone.on &&
|
||
autoUndone.numbers.autoGain === false &&
|
||
autoUndone.says === 'off',
|
||
JSON.stringify({ autoOff, autoOn, autoUndone }),
|
||
);
|
||
const ratioHandle = await json("__gm.compressor.handleAt('ratio')");
|
||
const ratioTo = await json('__gm.compressor.pointAt(0, -10)');
|
||
await drag(ratioHandle.x, ratioHandle.y, ratioTo.x, ratioTo.y);
|
||
const compCurve = await json(
|
||
"({ numbers: __gm.compressor.numbers(), handle: __gm.compressor.handleAt('ratio'), at: __gm.compressor.pointAt(0, -10) })",
|
||
);
|
||
await press('z', { ctrl: true });
|
||
const compCurveUndone = await json('__gm.compressor.numbers()');
|
||
check(
|
||
'dragging the end of the curve sets the ratio that ends there, as one undo step',
|
||
compCurve.numbers.ratio < compTurned.numbers.ratio &&
|
||
Math.abs(compCurve.handle.y - compCurve.at.y) < 2 &&
|
||
compCurveUndone.ratio === compTurned.numbers.ratio,
|
||
JSON.stringify({ compCurve, compCurveUndone }),
|
||
);
|
||
await mutate(`
|
||
const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph;
|
||
Object.assign(graph.nodes.find((n) => n.id === 'fx-comp').params, { threshold: -50, ratio: 8, knee: 0, makeup: 0 });`);
|
||
await evaluate('__gm.sound.stopAll()');
|
||
const levelsBefore = (await json('__gm.compressor.levels()')).frames;
|
||
await evaluate('__gm.soundfx.play()');
|
||
const compLevels = await waitFor(
|
||
() => json('__gm.compressor.levels()'),
|
||
(l) => l.frames >= levelsBefore + 3 && l.reduction > 3,
|
||
);
|
||
await shot('34-compressor');
|
||
check(
|
||
'played, the tab reads the level on both sides of the node, the one going out squeezed under the one coming in',
|
||
compLevels.taps.listened === `${fxId}:fx-comp` &&
|
||
compLevels.in > -50 &&
|
||
compLevels.out < compLevels.in &&
|
||
compLevels.reduction > 3,
|
||
JSON.stringify(compLevels),
|
||
);
|
||
await evaluate('__gm.sound.stopAll()');
|
||
// Floated and sized down as far as it goes, the window keeps every knob; sized up, the knobs stay on
|
||
// the bottom line and the curve in the middle of the height to its right.
|
||
await evaluate(`__gm.floatDocument(${JSON.stringify(`compressor:${fxId}:fx-comp`)})`);
|
||
const compWindow = async () => (await json('__gm.floatWindows()')).find((w) => w.kind === 'compressor');
|
||
const compLayout = async () => {
|
||
const win = await compWindow();
|
||
const at = await json(
|
||
"({ first: __gm.compressor.dialAt('threshold'), last: __gm.compressor.autoGain().at, ...__gm.compressor.pictures() })",
|
||
);
|
||
const inside = (p) => p.x > win.box.x && p.x < win.box.x + win.box.w && p.y < win.box.y + win.box.h - 20;
|
||
return { box: win.box, ...at, inside: inside(at.first) && inside(at.last) };
|
||
};
|
||
let floatedComp = await compWindow();
|
||
await t.drag(floatedComp.grip.x, floatedComp.grip.y, floatedComp.grip.x - 2000, floatedComp.grip.y - 2000);
|
||
const compSmall = await compLayout();
|
||
floatedComp = await compWindow();
|
||
await t.drag(floatedComp.grip.x, floatedComp.grip.y, floatedComp.grip.x + 2000, floatedComp.grip.y + 2000);
|
||
const compLarge = await compLayout();
|
||
const { levels: bigLevels, curve: bigCurve } = compLarge;
|
||
check(
|
||
'a floated Compressor holds every knob at its least size; sized up, the knobs stay at the bottom, the levels fill the height and the curve sits in its middle',
|
||
compSmall.box.w > 320 &&
|
||
compSmall.box.h > 200 &&
|
||
compSmall.inside &&
|
||
compLarge.box.h > compSmall.box.h + 100 &&
|
||
compLarge.inside &&
|
||
compLarge.box.y + compLarge.box.h - compLarge.last.y < 100 &&
|
||
bigLevels.h > compSmall.levels.h + 100 &&
|
||
bigCurve.h === bigCurve.w &&
|
||
Math.abs(bigCurve.y + bigCurve.h / 2 - (bigLevels.y + bigLevels.h / 2)) <= 1,
|
||
JSON.stringify({ compSmall, compLarge }),
|
||
);
|
||
// Read again: Dock moved right with the window's edge.
|
||
floatedComp = await compWindow();
|
||
await click(floatedComp.dock.x, floatedComp.dock.y);
|
||
await mutate(`
|
||
const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph;
|
||
graph.edges.push(${JSON.stringify(edge('fx-e14', 'fx-in', 'depth', 'fx-comp', 'ratio'))});`);
|
||
const compLinked = await json('__gm.compressor.knobs()');
|
||
// Not the sound FX graph: the node there listens to the same taps (#380).
|
||
await showTab('scene');
|
||
await sleep(100);
|
||
const compUnseen = await json('__gm.compressor.levels()');
|
||
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-comp');
|
||
graph.edges = graph.edges.filter((e) => !['fx-e12', 'fx-e13', 'fx-e14'].includes(e.id));
|
||
graph.edges.push(${JSON.stringify(edge('fx-e3', 'fx-gain', 'out', 'fx-out', 'in'))});`);
|
||
const compDocsAfter = await json('__gm.documents()');
|
||
check(
|
||
'a linked input greys its knob, an unseen tab feeds no analyser, and the tab goes with its node',
|
||
!compLinked.ratio.enabled &&
|
||
compLinked.threshold.enabled &&
|
||
compUnseen.taps.listened === '' &&
|
||
!compDocsAfter.docs.some((doc) => doc.startsWith('compressor:')),
|
||
JSON.stringify({ compLinked, unseen: compUnseen.taps, compDocsAfter }),
|
||
);
|
||
// #379, #380: a Compressor node is drawn as its device – its inputs on the left, its output on the
|
||
// right, the panel between them. Its knobs and its lever work right in the graph, and
|
||
// played, the meter's needle swings and the LEDs light. A double-click opens the tab of #378.
|
||
await evaluate(`__gm.openDocument(${JSON.stringify(`asset:${fxId}`)})`);
|
||
await mutate(`
|
||
const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph;
|
||
graph.nodes.push(${JSON.stringify(node('fx-hw', 'SoundFxCompressor', -240, 0, { threshold: -50, ratio: 8, knee: 0, makeup: 20 }))});
|
||
graph.edges = graph.edges.filter((e) => e.id !== 'fx-e3');
|
||
graph.edges.push(
|
||
${JSON.stringify(edge('fx-e15', 'fx-gain', 'out', 'fx-hw', 'in'))},
|
||
${JSON.stringify(edge('fx-e16', 'fx-hw', 'out', 'fx-out', 'in'))},
|
||
);`);
|
||
await evaluate('__gm.soundfx.fit()');
|
||
await sleep(100);
|
||
const hwPanel = () => json("__gm.compressor.panel('fx-hw')");
|
||
const hwShown = await hwPanel();
|
||
const hwRect = hwShown.rect;
|
||
const hwInputs = Object.values(hwShown.inputs);
|
||
const hwOutputs = Object.values(hwShown.outputs);
|
||
const besideThePanel = (p) => p.y > hwRect.y && p.y < hwRect.y + hwRect.h;
|
||
check(
|
||
'a Compressor node shows its panel at full size, its inputs on the left of it and its output on the right, its numbers as plain labels, the ratio as 8:1',
|
||
hwShown.shown &&
|
||
hwShown.numbers.ratio === '8:1' &&
|
||
hwShown.numbers.threshold === '-50' &&
|
||
hwShown.size.w === 820 &&
|
||
hwShown.size.h === 280 &&
|
||
hwInputs.length === 8 &&
|
||
hwInputs.every((p) => p.x < hwRect.x && besideThePanel(p)) &&
|
||
hwOutputs.length === 1 &&
|
||
hwOutputs.every((p) => p.x > hwRect.x + hwRect.w && besideThePanel(p)) &&
|
||
hwShown.node.w > hwRect.w &&
|
||
hwShown.node.h > hwRect.h,
|
||
JSON.stringify(hwShown),
|
||
);
|
||
const hwThreshold = hwShown.dials.threshold;
|
||
await drag(hwThreshold.x, hwThreshold.y, hwThreshold.x, hwThreshold.y - 20);
|
||
const hwNumbers = () =>
|
||
json(
|
||
`__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph.nodes.find((n) => n.id === 'fx-hw').params`,
|
||
);
|
||
const hwDragged = await hwNumbers();
|
||
await press('z', { ctrl: true });
|
||
const hwUndone = await hwNumbers();
|
||
const hwRatio = hwShown.dials.ratio;
|
||
await t.wheel(hwRatio.x, hwRatio.y, -100);
|
||
const hwWheeled = { numbers: await hwNumbers(), panel: await hwPanel() };
|
||
await evaluate('__gm.soundfx.fit()');
|
||
await sleep(100);
|
||
const hwRefit = (await hwPanel()).dials.ratio;
|
||
await t.doubleClick(hwRefit.x, hwRefit.y);
|
||
const hwReset = { numbers: await hwNumbers(), docs: await json('__gm.documents()') };
|
||
await click(hwShown.autoGain.at.x, hwShown.autoGain.at.y);
|
||
await sleep(100);
|
||
const hwLever = { numbers: await hwNumbers(), panel: await hwPanel() };
|
||
check(
|
||
'in the node, a knob turns with a drag up as one undo step, the wheel over it zooms the graph and leaves it, a double-click resets it without opening the tab, and the lever switches auto-gain',
|
||
hwDragged.threshold > -50 &&
|
||
hwUndone.threshold === -50 &&
|
||
hwWheeled.numbers.ratio === 8 &&
|
||
hwWheeled.panel.rect.w > hwRect.w + 10 &&
|
||
hwReset.numbers.ratio === 4 &&
|
||
hwReset.docs.active === `asset:${fxId}` &&
|
||
hwLever.numbers.autoGain === true &&
|
||
hwLever.panel.autoGain.on &&
|
||
/^\+\d+\.\d dB$/.test(hwLever.panel.autoGain.says),
|
||
JSON.stringify({ hwDragged, hwUndone, hwWheeled, hwReset, hwLever }),
|
||
);
|
||
await press('z', { ctrl: true });
|
||
await evaluate('__gm.sound.stopAll()');
|
||
await evaluate('__gm.soundfx.play()');
|
||
const hwPlayed = await waitFor(hwPanel, (p) => p.seen.needle > 3 && p.seen.leds > 0);
|
||
await shot('34-compressor-node');
|
||
check(
|
||
'played, the needle in the node swings towards what is taken away and the LEDs light with the level going out',
|
||
hwPlayed.seen.needle > 3 && hwPlayed.seen.leds > 0 && /GR +\d+\.\d dB/.test(hwPlayed.lcd),
|
||
JSON.stringify(hwPlayed),
|
||
);
|
||
await evaluate('__gm.sound.stopAll()');
|
||
const hwTitle = await json("__gm.soundfx.nodeAt('fx-hw')");
|
||
await t.doubleClick(hwTitle.x, hwTitle.y);
|
||
await sleep(100);
|
||
const hwTab = {
|
||
docs: await json('__gm.documents()'),
|
||
windows: await json('__gm.floatWindows()'),
|
||
shown: await json('__gm.compressor.shown()'),
|
||
};
|
||
// The tab and the node listen to the same taps: the tab, open but not seen, must leave them be.
|
||
await evaluate(`__gm.openDocument(${JSON.stringify(`asset:${fxId}`)})`);
|
||
await sleep(100);
|
||
await evaluate('__gm.soundfx.play()');
|
||
const hwBesideTab = await waitFor(hwPanel, (p) => p.seen.needle > 3);
|
||
await evaluate('__gm.sound.stopAll()');
|
||
check(
|
||
'a double-click on the node opens its tab in the row, and with the tab open the node’s needle still swings',
|
||
hwTab.docs.active === `compressor:${fxId}:fx-hw` &&
|
||
hwTab.windows.length === 0 &&
|
||
hwTab.shown?.nodeId === 'fx-hw' &&
|
||
hwBesideTab.seen.needle > 3,
|
||
JSON.stringify({ hwTab, seen: hwBesideTab.seen }),
|
||
);
|
||
// #384: a Gain node is drawn as an orange device with its one knob, worked as the Compressor's.
|
||
// The Compressor's node is wide enough to lie over the Gain's: the Gain goes below it here.
|
||
const gainUnder = await json("__gm.compressor.panel('fx-hw').node");
|
||
await mutate(`
|
||
const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph;
|
||
graph.nodes.find((n) => n.id === 'fx-gain').y = 400;`);
|
||
await evaluate('__gm.soundfx.fit()');
|
||
await sleep(100);
|
||
const gainPanel = () => json("__gm.gain.panel('fx-gain')");
|
||
const gainShown = await gainPanel();
|
||
const gainRect = gainShown.rect;
|
||
const gainBeside = (p) => p.y > gainRect.y && p.y < gainRect.y + gainRect.h;
|
||
const gainDb = () =>
|
||
json(
|
||
`__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph.nodes.find((n) => n.id === 'fx-gain').params.gain`,
|
||
);
|
||
const gainDial = gainShown.knob.dial;
|
||
await drag(gainDial.x, gainDial.y, gainDial.x, gainDial.y - 20);
|
||
const gainDragged = await gainDb();
|
||
await press('z', { ctrl: true });
|
||
const gainUndone = await gainDb();
|
||
await t.wheel(gainDial.x, gainDial.y, -100);
|
||
const gainWheeled = { db: await gainDb(), panel: await gainPanel() };
|
||
await evaluate('__gm.soundfx.fit()');
|
||
await sleep(100);
|
||
const gainRefit = (await gainPanel()).knob.dial;
|
||
await t.doubleClick(gainRefit.x, gainRefit.y);
|
||
const gainReset = { db: await gainDb(), docs: await json('__gm.documents()') };
|
||
await press('z', { ctrl: true });
|
||
check(
|
||
'a Gain node shows its panel with one knob between its ports; a drag turns it as one undo step, the wheel zooms the graph, a double-click resets it without a tab',
|
||
gainShown.shown &&
|
||
gainShown.size.w === 160 &&
|
||
gainShown.size.h === 160 &&
|
||
gainShown.knob.number === '-6' &&
|
||
gainShown.knob.enabled &&
|
||
Object.values(gainShown.inputs).length === 3 &&
|
||
Object.values(gainShown.inputs).every((p) => p.x < gainRect.x && gainBeside(p)) &&
|
||
Object.values(gainShown.outputs).every((p) => p.x > gainRect.x + gainRect.w && gainBeside(p)) &&
|
||
gainDragged > -6 &&
|
||
gainUndone === -6 &&
|
||
gainWheeled.db === -6 &&
|
||
gainWheeled.panel.rect.w > gainRect.w + 5 &&
|
||
gainReset.db === 0 &&
|
||
gainReset.docs.active === `asset:${fxId}`,
|
||
JSON.stringify({ gainUnder, gainShown, gainDragged, gainUndone, gainWheeled, gainReset }),
|
||
);
|
||
await mutate(`
|
||
const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph;
|
||
graph.nodes.find((n) => n.id === 'fx-gain').y = 0;`);
|
||
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-hw');
|
||
graph.edges = graph.edges.filter((e) => !['fx-e15', 'fx-e16'].includes(e.id));
|
||
graph.edges.push(${JSON.stringify(edge('fx-e3', 'fx-gain', 'out', 'fx-out', 'in'))});`);
|
||
// #389: a Reverb node is drawn as a device with a round screen of its tail. A knob its type does
|
||
// not take goes over to Custom with the type's numbers, as one undo step; a sound picked on
|
||
// it makes the type Sound and the screen draws that sound's tail. It is wired to nothing.
|
||
await mutate(`
|
||
const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph;
|
||
graph.nodes.push(${JSON.stringify(node('fx-rv', 'SoundFxReverb', 0, 400, { room: 'hall' }))});`);
|
||
await evaluate('__gm.soundfx.fit()');
|
||
await sleep(100);
|
||
const reverbPanel = () => json("__gm.reverb.panel('fx-rv')");
|
||
const reverbParams = () =>
|
||
json(
|
||
`__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph.nodes.find((n) => n.id === 'fx-rv').params`,
|
||
);
|
||
const rvShown = await reverbPanel();
|
||
const rvRect = rvShown.rect;
|
||
const rvBeside = (p) => p.y > rvRect.y && p.y < rvRect.y + rvRect.h;
|
||
const rvDial = rvShown.knobs.decay.dial;
|
||
await drag(rvDial.x, rvDial.y, rvDial.x, rvDial.y - 20);
|
||
const rvTurned = { params: await reverbParams(), panel: await reverbPanel() };
|
||
await press('z', { ctrl: true });
|
||
const rvUndone = await reverbParams();
|
||
await click(rvShown.rooms.cave.x, rvShown.rooms.cave.y);
|
||
const rvCave = { params: await reverbParams(), panel: await reverbPanel() };
|
||
await click(rvCave.panel.sound.at.x, rvCave.panel.sound.at.y);
|
||
const rvOffered = (await json('__gm.menuRows()')) ?? [];
|
||
const rvPick = rvOffered.find((row) => row);
|
||
const rvPickAt = await t.locate(rvPick, true);
|
||
await click(rvPickAt.x, rvPickAt.y);
|
||
const rvSound = {
|
||
params: await reverbParams(),
|
||
panel: await waitFor(reverbPanel, (p) => p.scope.kind === 'sound'),
|
||
};
|
||
await press('z', { ctrl: true });
|
||
const rvSoundUndone = await reverbParams();
|
||
check(
|
||
'a Reverb node shows its device between its ports; a knob of a room goes to Custom with its numbers as one undo step, a type button switches, a picked sound makes it Sound and draws that sound',
|
||
rvShown.shown &&
|
||
rvShown.size.w === 640 &&
|
||
rvShown.size.h === 280 &&
|
||
rvShown.room === 'hall' &&
|
||
['decay', 'damping', 'predelay', 'impulse'].every((key) => rvShown.idle.includes(key)) &&
|
||
Object.keys(rvShown.inputs).join() === 'in,mix' &&
|
||
Object.values(rvShown.inputs).every((p) => p.x < rvRect.x && rvBeside(p)) &&
|
||
Object.values(rvShown.outputs).every((p) => p.x > rvRect.x + rvRect.w && rvBeside(p)) &&
|
||
rvShown.scope.kind === 'made' &&
|
||
rvShown.scope.reach > 0.48 &&
|
||
rvShown.scope.reach < 0.5 &&
|
||
rvShown.scope.says.ends === 'ENDS 2.52 s' &&
|
||
rvShown.highs === 'HIGHS 5.5 kHz' &&
|
||
rvTurned.params.room === 'custom' &&
|
||
rvTurned.params.decay > 2.5 &&
|
||
rvTurned.params.damping === 0.35 &&
|
||
rvTurned.params.predelay === 0.02 &&
|
||
Object.keys(rvTurned.panel.inputs).length === 5 &&
|
||
rvTurned.panel.idle.join() === 'impulse' &&
|
||
rvTurned.panel.scope.reach > rvShown.scope.reach &&
|
||
rvUndone.room === 'hall' &&
|
||
rvUndone.decay === undefined &&
|
||
rvCave.params.room === 'cave' &&
|
||
rvCave.panel.scope.reach > 0.68 &&
|
||
Boolean(rvPick) &&
|
||
rvSound.params.room === 'sound' &&
|
||
Boolean(rvSound.params.impulse) &&
|
||
rvSound.panel.sound.text === rvPick &&
|
||
rvSound.panel.scope.kind === 'sound' &&
|
||
rvSound.panel.scope.rings > 0 &&
|
||
rvSoundUndone.room === 'cave' &&
|
||
rvSoundUndone.impulse === undefined,
|
||
JSON.stringify({ rvShown, rvTurned, rvUndone, rvCave, rvOffered, rvSound, rvSoundUndone }),
|
||
);
|
||
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-rv');`);
|
||
// #390: a Pitch is drawn as the Gain's device in green, a Mix in blue with a lever for its curve.
|
||
// The Pitch in the chain has its semitones wired, so its knob is locked; a free one turns.
|
||
await mutate(`
|
||
const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph;
|
||
graph.nodes.push(
|
||
${JSON.stringify(node('fx-pt', 'SoundFxPitch', 0, 400, { semitones: -12 }))},
|
||
${JSON.stringify(node('fx-mx', 'SoundFxMix', 400, 400, { mix: 0.3 }))},
|
||
);`);
|
||
await evaluate('__gm.soundfx.fit()');
|
||
await sleep(100);
|
||
const knobParams = (id) =>
|
||
json(
|
||
`__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph.nodes.find((n) => n.id === '${id}').params`,
|
||
);
|
||
const ptWired = await json("__gm.pitch.panel('fx-pitch')");
|
||
const ptShown = await json("__gm.pitch.panel('fx-pt')");
|
||
await drag(ptShown.knob.dial.x, ptShown.knob.dial.y, ptShown.knob.dial.x, ptShown.knob.dial.y - 20);
|
||
const ptTurned = (await knobParams('fx-pt')).semitones;
|
||
await press('z', { ctrl: true });
|
||
const ptUndone = (await knobParams('fx-pt')).semitones;
|
||
const mxShown = await json("__gm.mix.panel('fx-mx')");
|
||
const mxRect = mxShown.rect;
|
||
await click(mxShown.lever.at.x, mxShown.lever.at.y);
|
||
const mxBoth = { params: await knobParams('fx-mx'), panel: await json("__gm.mix.panel('fx-mx')") };
|
||
await press('z', { ctrl: true });
|
||
const mxUndone = { params: await knobParams('fx-mx'), panel: await json("__gm.mix.panel('fx-mx')") };
|
||
await drag(mxShown.knob.dial.x, mxShown.knob.dial.y, mxShown.knob.dial.x, mxShown.knob.dial.y - 20);
|
||
const mxTurned = (await knobParams('fx-mx')).mix;
|
||
await press('z', { ctrl: true });
|
||
const mxTurnUndone = (await knobParams('fx-mx')).mix;
|
||
check(
|
||
'a Pitch node is a green one-knob device, locked while its semitones are wired; a Mix node is a blue one whose lever switches the curve, each change one undo step',
|
||
ptWired.size.w === 160 &&
|
||
!ptWired.knob.enabled &&
|
||
ptWired.lever === null &&
|
||
ptShown.knob.enabled &&
|
||
ptShown.knob.number === '-12' &&
|
||
ptTurned > -12 &&
|
||
ptUndone === -12 &&
|
||
mxShown.size.w === 180 &&
|
||
mxShown.size.h === 200 &&
|
||
mxShown.knob.number === '0.3' &&
|
||
// The lever stands above the knob, both in the middle of the plate.
|
||
mxShown.lever.at.y < mxShown.knob.dial.y &&
|
||
Math.abs(mxShown.lever.at.x - mxShown.knob.dial.x) < 1 &&
|
||
Object.values(mxShown.inputs).length === 3 &&
|
||
Object.values(mxShown.inputs).every((p) => p.x < mxRect.x) &&
|
||
Object.values(mxShown.outputs).every((p) => p.x > mxRect.x + mxRect.w) &&
|
||
mxShown.lever.on === false &&
|
||
mxShown.lever.says === 'crossfade' &&
|
||
mxBoth.params.curve === 'both' &&
|
||
mxBoth.panel.lever.on &&
|
||
mxBoth.panel.lever.says === 'both at center' &&
|
||
mxUndone.params.curve !== 'both' &&
|
||
mxUndone.panel.lever.says === 'crossfade' &&
|
||
mxTurned > 0.3 &&
|
||
mxTurnUndone === 0.3,
|
||
JSON.stringify({
|
||
ptWired,
|
||
ptShown,
|
||
ptTurned,
|
||
ptUndone,
|
||
mxShown,
|
||
mxBoth,
|
||
mxUndone,
|
||
mxTurned,
|
||
mxTurnUndone,
|
||
}),
|
||
);
|
||
await mutate(`
|
||
const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph;
|
||
graph.nodes = graph.nodes.filter((n) => !['fx-pt', 'fx-mx'].includes(n.id));`);
|
||
// #392: a Pan is the same device in cyan, its 0 straight up, L and R at the ends of the turn.
|
||
await mutate(`
|
||
const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph;
|
||
graph.nodes.push(${JSON.stringify(node('fx-pn', 'SoundFxPan', 0, 400, { pan: -0.35 }))});`);
|
||
await evaluate('__gm.soundfx.fit()');
|
||
await sleep(100);
|
||
const pnShown = await json("__gm.pan.panel('fx-pn')");
|
||
await drag(pnShown.knob.dial.x, pnShown.knob.dial.y, pnShown.knob.dial.x, pnShown.knob.dial.y - 20);
|
||
const pnTurned = (await knobParams('fx-pn')).pan;
|
||
await press('z', { ctrl: true });
|
||
const pnUndone = (await knobParams('fx-pn')).pan;
|
||
check(
|
||
'a Pan node is a cyan one-knob device whose knob turns the pan, one undo step',
|
||
pnShown.size.w === 160 &&
|
||
pnShown.size.h === 160 &&
|
||
pnShown.lever === null &&
|
||
pnShown.knob.enabled &&
|
||
pnShown.knob.number === '-0.35' &&
|
||
Object.values(pnShown.inputs).every((p) => p.x < pnShown.rect.x) &&
|
||
Object.values(pnShown.outputs).every((p) => p.x > pnShown.rect.x + pnShown.rect.w) &&
|
||
pnTurned > -0.35 &&
|
||
pnTurned <= 1 &&
|
||
pnUndone === -0.35,
|
||
JSON.stringify({ pnShown, pnTurned, pnUndone }),
|
||
);
|
||
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-pn');`);
|
||
// #393: a Delay is a device of its own size with its echoes on a screen and a light running over
|
||
// them at their beat. Ping-pong puts them left and right by turns, Sync names the time as a
|
||
// note, the time knob turns it; each is one undo step.
|
||
await mutate(`
|
||
const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph;
|
||
graph.nodes.push(${JSON.stringify(node('fx-dl', 'SoundFxDelay', 0, 400, { time: 0.5, feedback: 0.5, mix: 0.5 }))});`);
|
||
await evaluate('__gm.soundfx.fit()');
|
||
await sleep(100);
|
||
const dlShown = await json("__gm.delay.panel('fx-dl')");
|
||
await sleep(150);
|
||
const dlLater = (await json("__gm.delay.panel('fx-dl')")).scope.at;
|
||
await click(dlShown.pingPong.at.x, dlShown.pingPong.at.y);
|
||
const dlPingPong = { params: await knobParams('fx-dl'), panel: await json("__gm.delay.panel('fx-dl')") };
|
||
await click(dlShown.sync.at.x, dlShown.sync.at.y);
|
||
const dlBeats = { params: await knobParams('fx-dl'), panel: await json("__gm.delay.panel('fx-dl')") };
|
||
const dlTime = dlShown.knobs.time.dial;
|
||
await drag(dlTime.x, dlTime.y, dlTime.x, dlTime.y - 20);
|
||
const dlTurned = (await knobParams('fx-dl')).time;
|
||
for (let i = 0; i < 3; i++) await press('z', { ctrl: true });
|
||
const dlUndone = { params: await knobParams('fx-dl'), panel: await json("__gm.delay.panel('fx-dl')") };
|
||
const dlSides = (panel) =>
|
||
panel.scope.echoes
|
||
.slice(0, 3)
|
||
.map((e) => e.side)
|
||
.join(' ');
|
||
check(
|
||
'a Delay node is a device with its echoes on a screen and a running light; ping-pong, sync and the time knob are one undo step each',
|
||
dlShown.size.w === 680 &&
|
||
dlShown.size.h === 280 &&
|
||
Object.values(dlShown.inputs).length === 5 &&
|
||
Object.values(dlShown.inputs).every((p) => p.x < dlShown.rect.x) &&
|
||
Object.values(dlShown.outputs).every((p) => p.x > dlShown.rect.x + dlShown.rect.w) &&
|
||
dlShown.knobs.time.number === '500 ms' &&
|
||
dlShown.scope.heard === 10 &&
|
||
dlShown.scope.echoes.length === 10 &&
|
||
dlShown.scope.says.every === 'EVERY 500 ms' &&
|
||
dlShown.scope.says.ends === 'ENDS 5 s' &&
|
||
dlSides(dlShown) === 'both both both' &&
|
||
// The light runs on by itself.
|
||
dlLater !== dlShown.scope.at &&
|
||
dlPingPong.params.pingPong === true &&
|
||
dlPingPong.panel.pingPong.lit &&
|
||
dlSides(dlPingPong.panel) === 'left right left' &&
|
||
dlBeats.params.sync === 'beats' &&
|
||
dlBeats.panel.sync.says === 'beats' &&
|
||
dlBeats.panel.knobs.time.number === '1/8' &&
|
||
dlBeats.panel.scope.says.every === 'EVERY 1/8 · 250 ms AT 120 BPM' &&
|
||
dlTurned > 0.5 &&
|
||
dlUndone.params.time === 0.5 &&
|
||
dlUndone.params.sync !== 'beats' &&
|
||
dlUndone.params.pingPong !== true &&
|
||
dlUndone.panel.knobs.time.number === '500 ms' &&
|
||
dlSides(dlUndone.panel) === 'both both both',
|
||
JSON.stringify({ dlShown, dlLater, dlPingPong, dlBeats, dlTurned, dlUndone }),
|
||
);
|
||
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-dl');`);
|
||
// #396: a Saturation is a device of its own size with the wave a sine comes out as on a screen,
|
||
// glowing hotter the more it is driven. A type button, the auto-gain lever and the drive
|
||
// knob are one undo step each.
|
||
await mutate(`
|
||
const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph;
|
||
graph.nodes.push(${JSON.stringify(node('fx-st', 'SoundFxSaturation', 0, 400, { drive: 12 }))});`);
|
||
await evaluate('__gm.soundfx.fit()');
|
||
await sleep(100);
|
||
const stShown = await json("__gm.saturation.panel('fx-st')");
|
||
await sleep(150);
|
||
const stLater = (await json("__gm.saturation.panel('fx-st')")).scope.flare;
|
||
await click(stShown.types.hard.x, stShown.types.hard.y);
|
||
const stHard = { params: await knobParams('fx-st'), panel: await json("__gm.saturation.panel('fx-st')") };
|
||
await click(stShown.autoGain.at.x, stShown.autoGain.at.y);
|
||
const stOff = { params: await knobParams('fx-st'), panel: await json("__gm.saturation.panel('fx-st')") };
|
||
const stDrive = stShown.knobs.drive.dial;
|
||
await drag(stDrive.x, stDrive.y, stDrive.x, stDrive.y - 40);
|
||
const stHot = { params: await knobParams('fx-st'), panel: await json("__gm.saturation.panel('fx-st')") };
|
||
for (let i = 0; i < 3; i++) await press('z', { ctrl: true });
|
||
const stUndone = { params: await knobParams('fx-st'), panel: await json("__gm.saturation.panel('fx-st')") };
|
||
check(
|
||
'a Saturation node is a device whose screen glows hotter with the drive; a type, auto-gain and the drive knob are one undo step each',
|
||
stShown.size.w === 720 &&
|
||
stShown.size.h === 280 &&
|
||
Object.values(stShown.inputs).length === 5 &&
|
||
Object.values(stShown.inputs).every((p) => p.x < stShown.rect.x) &&
|
||
Object.values(stShown.outputs).every((p) => p.x > stShown.rect.x + stShown.rect.w) &&
|
||
stShown.type === 'soft' &&
|
||
stShown.knobs.drive.number === '12 dB' &&
|
||
stShown.heat.word === 'WARM' &&
|
||
stShown.heat.burning &&
|
||
stShown.autoGain.lit &&
|
||
stShown.scope.says.auto.startsWith('AUTO-GAIN ') &&
|
||
// The glow flickers by itself.
|
||
stLater !== stShown.scope.flare &&
|
||
stHard.params.curve === 'hard' &&
|
||
stHard.panel.type === 'hard' &&
|
||
stOff.params.autoGain === false &&
|
||
stOff.panel.scope.says.auto === 'AUTO-GAIN OFF' &&
|
||
stHot.params.drive > 12 &&
|
||
stHot.panel.heat.share > stShown.heat.share &&
|
||
stHot.panel.scope.embers > 0 &&
|
||
stUndone.params.drive === 12 &&
|
||
stUndone.params.curve !== 'hard' &&
|
||
stUndone.params.autoGain !== false &&
|
||
stUndone.panel.type === 'soft' &&
|
||
stUndone.panel.heat.word === 'WARM',
|
||
JSON.stringify({ stShown, stLater, stHard, stOff, stHot, stUndone }),
|
||
);
|
||
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-st');`);
|
||
// #397: a Limiter is a device of its own size whose screen catches what it takes away from the
|
||
// moment a sound starts and holds it once the sound ends. Wired in before the output, the
|
||
// most it caught is the loudest that came in over its limit. The lever and the threshold
|
||
// knob are one undo step each and leave the capture held.
|
||
await mutate(`
|
||
const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph;
|
||
graph.nodes.push(${JSON.stringify(node('fx-lm', 'SoundFxLimiter', 0, 400, { threshold: -20 }))});
|
||
const last = graph.edges.find((e) => e.to.node === 'fx-out' && e.to.port === 'in');
|
||
graph.edges.push({ id: 'fx-e-lm', from: { node: 'fx-lm', port: 'out' }, to: { node: 'fx-out', port: 'in' } });
|
||
last.to = { node: 'fx-lm', port: 'in' };`);
|
||
await evaluate('__gm.soundfx.fit()');
|
||
await sleep(100);
|
||
const lmPanel = () => json("__gm.limiter.panel('fx-lm')");
|
||
const lmShown = await lmPanel();
|
||
await evaluate('__gm.sound.stopAll()');
|
||
await evaluate('__gm.soundfx.play()');
|
||
const lmHeld = await waitFor(lmPanel, (p) => p.scope.state === 'held', 40);
|
||
await click(lmShown.autoGain.at.x, lmShown.autoGain.at.y);
|
||
const lmOff = { params: await knobParams('fx-lm'), panel: await lmPanel() };
|
||
const lmDial = lmShown.knobs.threshold.dial;
|
||
await drag(lmDial.x, lmDial.y, lmDial.x, lmDial.y - 20);
|
||
const lmTurned = (await knobParams('fx-lm')).threshold;
|
||
for (let i = 0; i < 2; i++) await press('z', { ctrl: true });
|
||
const lmUndone = { params: await knobParams('fx-lm'), panel: await lmPanel() };
|
||
check(
|
||
'a Limiter node holds what it took away once the sound ends, the most of it the loudest over its limit; its lever and threshold are one undo step each',
|
||
lmShown.size.w === 720 &&
|
||
lmShown.size.h === 280 &&
|
||
Object.values(lmShown.inputs).length === 5 &&
|
||
Object.values(lmShown.inputs).every((p) => p.x < lmShown.rect.x) &&
|
||
Object.values(lmShown.outputs).every((p) => p.x > lmShown.rect.x + lmShown.rect.w) &&
|
||
lmShown.knobs.threshold.number === '-20 dB' &&
|
||
lmShown.autoGain.lit &&
|
||
lmShown.autoGain.says === '+19 dB' &&
|
||
lmShown.status === 'WAITING FOR SOUND' &&
|
||
lmHeld.status === 'HELD' &&
|
||
lmHeld.scope.limited > 0 &&
|
||
lmHeld.scope.most !== null &&
|
||
lmHeld.scope.loudest > -20 &&
|
||
Math.abs(lmHeld.scope.most.db - (lmHeld.scope.loudest + 20)) < 1 &&
|
||
/^MOST -\d+(\.\d)? dB AT \d+ ms$/.test(lmHeld.scope.says.most) &&
|
||
lmHeld.scope.says.side.startsWith('LASTED ') &&
|
||
!lmHeld.lamp &&
|
||
lmOff.params.autoGain === false &&
|
||
lmOff.panel.autoGain.says === 'off' &&
|
||
lmOff.panel.scope.state === 'held' &&
|
||
lmTurned > -20 &&
|
||
lmUndone.params.threshold === -20 &&
|
||
lmUndone.params.autoGain !== false &&
|
||
lmUndone.panel.scope.state === 'held' &&
|
||
lmUndone.panel.scope.most?.db === lmHeld.scope.most.db,
|
||
JSON.stringify({ lmShown, lmHeld, lmOff, lmTurned, lmUndone }),
|
||
);
|
||
await mutate(`
|
||
const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph;
|
||
const into = graph.edges.find((e) => e.to.node === 'fx-lm' && e.to.port === 'in');
|
||
into.to = { node: 'fx-out', port: 'in' };
|
||
graph.edges = graph.edges.filter((e) => e.id !== 'fx-e-lm');
|
||
graph.nodes = graph.nodes.filter((n) => n.id !== 'fx-lm');`);
|
||
// #398: a Tremolo, a Vibrato, a Phaser and a Flanger are devices of their own size whose screens
|
||
// draw what they do over the first seconds of a sound, a light playing it by itself. A
|
||
// button and a knob are one undo step each, and the screen follows them.
|
||
const swinging = [
|
||
{
|
||
kind: 'tremolo',
|
||
type: 'SoundFxTremolo',
|
||
size: [640, 280],
|
||
inputs: 4,
|
||
pick: 'square',
|
||
knob: 'depth',
|
||
shown: (p) => p.knobs.rate.number === '5 Hz' && p.side === 'DOWN TO 50%' && p.picked === 'sine',
|
||
turned: (p) => p.side !== 'DOWN TO 50%' && p.picked === 'square',
|
||
},
|
||
{
|
||
kind: 'vibrato',
|
||
type: 'SoundFxVibrato',
|
||
size: [640, 280],
|
||
inputs: 4,
|
||
pick: 'triangle',
|
||
knob: 'depth',
|
||
shown: (p) => p.side === 'UP 0.5 st DOWN 0.51 st' && p.scope.says.right === 'LATE 0.93 ms',
|
||
turned: (p) => p.scope.highest > 0.5 && p.picked === 'triangle',
|
||
},
|
||
{
|
||
kind: 'phaser',
|
||
type: 'SoundFxPhaser',
|
||
size: [760, 280],
|
||
inputs: 6,
|
||
pick: '8',
|
||
knob: 'frequency',
|
||
shown: (p) => p.side === '2 NOTCHES' && p.scope.notches.length === 2,
|
||
turned: (p) => p.side === '4 NOTCHES' && p.scope.notches.length === 4 && p.scope.highest > 1600,
|
||
},
|
||
{
|
||
kind: 'flanger',
|
||
type: 'SoundFxFlanger',
|
||
size: [680, 280],
|
||
inputs: 7,
|
||
pick: null,
|
||
knob: 'sweep',
|
||
shown: (p) => p.side === 'DELAY 1 ms TO 3 ms' && p.scope.says.right === 'NO SWEEP',
|
||
turned: (p) => p.scope.says.right.startsWith('SWEEP +'),
|
||
},
|
||
];
|
||
for (const device of swinging) {
|
||
const id = `fx-${device.kind}`;
|
||
const panel = () => json(`__gm.${device.kind}.panel('${id}')`);
|
||
await mutate(`
|
||
const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph;
|
||
graph.nodes.push(${JSON.stringify(node(id, device.type, 0, 400, {}))});`);
|
||
await evaluate('__gm.soundfx.fit()');
|
||
await sleep(100);
|
||
const shown = await panel();
|
||
await sleep(150);
|
||
const later = (await panel()).scope.at;
|
||
if (device.pick) await click(shown.picks[device.pick].x, shown.picks[device.pick].y);
|
||
const dial = shown.knobs[device.knob].dial;
|
||
await drag(dial.x, dial.y, dial.x, dial.y - 20);
|
||
const turned = { params: await knobParams(id), panel: await panel() };
|
||
for (let i = 0; i < (device.pick ? 2 : 1); i++) await press('z', { ctrl: true });
|
||
const undone = { params: await knobParams(id), panel: await panel() };
|
||
check(
|
||
`a ${device.kind} node is a device whose screen plays what it does; a button and a knob are one undo step each`,
|
||
shown.size.w === device.size[0] &&
|
||
shown.size.h === device.size[1] &&
|
||
Object.values(shown.inputs).length === device.inputs &&
|
||
Object.values(shown.inputs).every((p) => p.x < shown.rect.x) &&
|
||
Object.values(shown.outputs).every((p) => p.x > shown.rect.x + shown.rect.w) &&
|
||
device.shown(shown) &&
|
||
shown.scope.moving &&
|
||
// The light runs on by itself.
|
||
later !== shown.scope.at &&
|
||
turned.params[device.knob] !== undefined &&
|
||
device.turned(turned.panel) &&
|
||
Object.keys(undone.params).length === 0 &&
|
||
device.shown(undone.panel),
|
||
JSON.stringify({ shown, later, turned, undone }),
|
||
);
|
||
await mutate(`
|
||
const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph;
|
||
graph.nodes = graph.nodes.filter((n) => n.id !== ${JSON.stringify(id)});`);
|
||
}
|
||
await showTab('scene');
|
||
await mutate(`
|
||
const graph = __gm.state.project.assets.find((a) => a.id === 'sn-logic').graph;
|
||
Object.assign(graph.nodes.find((n) => n.id === 'sn-play').params, { loop: 'false', fx: ${JSON.stringify(fxId)} });`);
|
||
const beforeGameFx = await stats();
|
||
await play();
|
||
const gameFx = await waitFor(stats, (s) => s.started > beforeGameFx.started);
|
||
check(
|
||
'a Play sound naming the asset plays the game’s voice through it',
|
||
gameFx.last?.pitches.join() === '-12' && gameFx.last.stages === 1,
|
||
JSON.stringify(gameFx),
|
||
);
|
||
await stop();
|
||
await mutate(`
|
||
const graph = __gm.state.project.assets.find((a) => a.id === 'sn-logic').graph;
|
||
graph.nodes.push(${JSON.stringify(
|
||
node('sn-fxp', 'SoundFxParameters', 0, 120, { soundFxAsset: fxId, depth: 7 }, [
|
||
{ key: 'depth', kind: 'number' },
|
||
]),
|
||
)});
|
||
graph.edges.push(${JSON.stringify(edge('sn-e-fxp', 'sn-fxp', 'params', 'sn-play', 'fxParams'))});`);
|
||
const beforeGameParam = await stats();
|
||
await play();
|
||
const gameParam = await waitFor(stats, (s) => s.started > beforeGameParam.started);
|
||
check(
|
||
'a Sound FX parameters node hands depth 7 to the game’s voice',
|
||
gameParam.last?.pitches.join() === '7' && gameParam.last.stages === 1,
|
||
JSON.stringify(gameParam),
|
||
);
|
||
await stop();
|
||
// #315: two looping voices of the same sound start at depth 7, the first keeps its number in a
|
||
// variable, and `Set sound FX parameters` hands that one voice depth 3 every frame. In a
|
||
// real AudioContext the first one's pitch – the AudioParam of its worklet (#317), in
|
||
// semitones – glides there, the second one's stays put.
|
||
const tuneIds = ['sn-keep', 'sn-play2', 'sn-tick', 'sn-read', 'sn-tune', 'sn-fxp2'];
|
||
const tuneEdges = [
|
||
'sn-e-keep',
|
||
'sn-e-voice',
|
||
'sn-e-play2',
|
||
'sn-e-fxp3',
|
||
'sn-e-tick',
|
||
'sn-e-read',
|
||
'sn-e-fxp2',
|
||
];
|
||
await mutate(`
|
||
const logic = __gm.state.project.assets.find((a) => a.id === 'sn-logic');
|
||
logic.variables = [${JSON.stringify(variable('voice', 'number', 0))}];
|
||
const graph = logic.graph;
|
||
const play = graph.nodes.find((n) => n.id === 'sn-play');
|
||
play.params.loop = 'true';
|
||
graph.nodes.push(
|
||
${JSON.stringify(node('sn-keep', 'Variables', 520, 0, {}, [written('voice', 'number')]))},
|
||
{ ...structuredClone(play), id: 'sn-play2', x: 780 },
|
||
${JSON.stringify(node('sn-tick', 'OnUpdate', 0, 300))},
|
||
${JSON.stringify(node('sn-read', 'Variables', 0, 420, {}, [port('voice', 'number')]))},
|
||
${JSON.stringify(node('sn-tune', 'SetSoundFxParameters', 260, 300))},
|
||
${JSON.stringify(
|
||
node('sn-fxp2', 'SoundFxParameters', 0, 540, { soundFxAsset: fxId, depth: 3 }, [
|
||
{ key: 'depth', kind: 'number' },
|
||
]),
|
||
)},
|
||
);
|
||
graph.edges.push(
|
||
${JSON.stringify(edge('sn-e-keep', 'sn-play', 'exec', 'sn-keep', 'exec'))},
|
||
${JSON.stringify(edge('sn-e-voice', 'sn-play', 'voice', 'sn-keep', 'voice'))},
|
||
${JSON.stringify(edge('sn-e-play2', 'sn-keep', 'exec', 'sn-play2', 'exec'))},
|
||
${JSON.stringify(edge('sn-e-fxp3', 'sn-fxp', 'params', 'sn-play2', 'fxParams'))},
|
||
${JSON.stringify(edge('sn-e-tick', 'sn-tick', 'exec', 'sn-tune', 'exec'))},
|
||
${JSON.stringify(edge('sn-e-read', 'sn-read', 'voice', 'sn-tune', 'voice'))},
|
||
${JSON.stringify(edge('sn-e-fxp2', 'sn-fxp2', 'params', 'sn-tune', 'fxParams'))},
|
||
);`);
|
||
const beforeTune = await stats();
|
||
await play();
|
||
const tunedStats = await waitFor(
|
||
stats,
|
||
(s) => s.tuned > beforeTune.tuned && s.started >= beforeTune.started + 2,
|
||
);
|
||
const glided = (levels) =>
|
||
Array.isArray(levels) && levels.length === 2 && Math.abs(levels[0][0] - 3) < 0.05;
|
||
const levels = await waitFor(() => json(`__gm.sound.levels(${JSON.stringify(blip.file)})`), glided);
|
||
check(
|
||
'Set sound FX parameters moves the one voice whose number it holds from depth 7 to 3, not the other',
|
||
tunedStats.last?.pitches.join() === '7' &&
|
||
tunedStats.last.loop === true &&
|
||
tunedStats.worklet === true &&
|
||
glided(levels) &&
|
||
Math.abs(levels[0][1] - 10 ** (-6 / 20)) < 0.001 &&
|
||
levels[1][0] === 7,
|
||
JSON.stringify({ last: tunedStats.last, tuned: tunedStats.tuned, worklet: tunedStats.worklet, levels }),
|
||
);
|
||
await stop();
|
||
// A `time` of 2 s takes the same voice from depth 7 to -5 evenly on the audio clock, 6 semitones
|
||
// a second: half a second in it is still well above the end, where a quick glide would be there.
|
||
await mutate(`
|
||
const graph = __gm.state.project.assets.find((a) => a.id === 'sn-logic').graph;
|
||
graph.nodes.find((n) => n.id === 'sn-tune').params.time = 2;
|
||
graph.nodes.find((n) => n.id === 'sn-fxp2').params.depth = -5;`);
|
||
await play();
|
||
const pitch = async () => (await json(`__gm.sound.levels(${JSON.stringify(blip.file)})`))?.[0]?.[0];
|
||
const early = await waitFor(pitch, (s) => typeof s === 'number' && s < 6.9);
|
||
const later = await waitFor(pitch, (s) => typeof s === 'number' && s < early - 0.6);
|
||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||
const afterHalf = await pitch();
|
||
check(
|
||
'Set sound FX parameters with a time ramps the pitch over that time instead of jumping',
|
||
early > -1.8 && later > -3.3 && afterHalf > -4.3 && afterHalf < later,
|
||
JSON.stringify({ early, later, afterHalf }),
|
||
);
|
||
await stop();
|
||
// The easings of an animation key: Quad in hands the clock a curve, 7 - 3 * t^2 towards -5 in 2 s,
|
||
// where linear is 7 - 6 * t. Each reading is timed on the audio clock from the voice's start, so a
|
||
// first one that comes in late is held against where the curve is then. Between 0.3 and 1.7 s the
|
||
// curve is more than a semitone above linear, and it never falls below it.
|
||
await mutate(`
|
||
const tune = __gm.state.project.assets.find((a) => a.id === 'sn-logic').graph.nodes.find((n) => n.id === 'sn-tune');
|
||
Object.assign(tune.params, { ease: 'quad', direction: 'in' });`);
|
||
await play();
|
||
const pitchNow = () =>
|
||
json(`({ pitch: __gm.sound.levels(${JSON.stringify(blip.file)})?.[0]?.[0], clock: __gm.sound.clock() })`);
|
||
const sinceStart = async (seen, since = 'start') => {
|
||
const now = await stats();
|
||
const from = since === 'tune' ? now.tunedAt : now.last.at;
|
||
return seen
|
||
.filter((s) => typeof s.pitch === 'number')
|
||
.map((s) => ({ t: +(s.clock - from).toFixed(3), pitch: +s.pitch.toFixed(3) }));
|
||
};
|
||
const sweep = await sinceStart(await trace(pitchNow, (s) => typeof s.pitch === 'number' && s.pitch < -4.3));
|
||
const midway = sweep.filter(({ t }) => t > 0.3 && t < 1.7);
|
||
check(
|
||
'Set sound FX parameters with Quad in ramps slowly first, then on to the end',
|
||
midway.length > 0 &&
|
||
midway.every(({ t, pitch }) => pitch > 7 - 6 * t + 1) &&
|
||
sweep.every(({ t, pitch }) => pitch > 7 - 6 * t - 0.1) &&
|
||
sweep.at(-1).pitch < -4.3,
|
||
JSON.stringify(sweep),
|
||
);
|
||
await stop();
|
||
// Step holds the pitch for the whole time and then jumps there: every reading before 2 s on the
|
||
// audio clock is still 7, the first one past it is the end. Timed from the change, not from the
|
||
// start of the voice: task 529 saw it arrive 80 ms later and the 7 held, rightly, to 2.08 s.
|
||
await mutate(`
|
||
const tune = __gm.state.project.assets.find((a) => a.id === 'sn-logic').graph.nodes.find((n) => n.id === 'sn-tune');
|
||
Object.assign(tune.params, { ease: 'step', time: 2 });`);
|
||
await play();
|
||
const steps = await sinceStart(
|
||
await trace(pitchNow, (s) => typeof s.pitch === 'number' && s.pitch < 6),
|
||
'tune',
|
||
);
|
||
const held = steps.filter(({ pitch }) => pitch === 7);
|
||
const jumped = steps.find(({ pitch }) => pitch < 6);
|
||
check(
|
||
'Set sound FX parameters with Step holds the pitch for the time, then jumps to the end',
|
||
held.length > 0 &&
|
||
held.every(({ t }) => t < 2.05) &&
|
||
jumped !== undefined &&
|
||
jumped.t > 1.95 &&
|
||
Math.abs(jumped.pitch + 5) < 0.001,
|
||
JSON.stringify(steps),
|
||
);
|
||
await stop();
|
||
// #317: the worklet itself, rendered in an offline context of this browser – what a muted check
|
||
// can hear. A second of 440 Hz an octave up is 880 Hz and still a second long; a sweep from
|
||
// 0 to -12 over it keeps that second too. Then 16 stereo voices at +5 for 2 s, timed, for
|
||
// how many this machine plays at once.
|
||
const offline = await evaluate(`
|
||
(async () => {
|
||
const RATE = 48000;
|
||
const render = async (voices, seconds, channels, shape) => {
|
||
const context = new OfflineAudioContext(channels, RATE * seconds, RATE);
|
||
await context.audioWorklet.addModule(__gm.sound.fxWorkletUrl());
|
||
const tone = new Float32Array(RATE * seconds);
|
||
for (let i = 0; i < tone.length; i++) tone[i] = Math.sin((2 * Math.PI * 440 * i) / RATE) * 0.5;
|
||
for (let v = 0; v < voices; v++) {
|
||
const node = new AudioWorkletNode(context, 'pitch-player', {
|
||
numberOfInputs: 0,
|
||
outputChannelCount: [channels],
|
||
parameterData: { pitch: shape.from },
|
||
processorOptions: { file: 'probe', channels: v === 0 ? Array(channels).fill(tone) : undefined, step: 1, loop: false },
|
||
});
|
||
if (shape.to !== shape.from)
|
||
node.parameters.get('pitch').setValueAtTime(shape.from, 0).linearRampToValueAtTime(shape.to, seconds);
|
||
node.connect(context.destination);
|
||
}
|
||
const started = performance.now();
|
||
const out = (await context.startRendering()).getChannelData(0);
|
||
return { out, ms: performance.now() - started };
|
||
};
|
||
const rises = (out, from, to) => {
|
||
let count = 0;
|
||
for (let i = from + 1; i < to; i++) if (out[i - 1] < 0 && out[i] >= 0) count++;
|
||
return count / ((to - from) / RATE);
|
||
};
|
||
const heard = (out) => {
|
||
let last = out.length;
|
||
while (last > 0 && Math.abs(out[last - 1]) <= 1e-4) last--;
|
||
return last;
|
||
};
|
||
const up = await render(1, 1, 1, { from: 12, to: 12 });
|
||
const sweep = await render(1, 1, 1, { from: 0, to: -12 });
|
||
const load = await render(16, 2, 2, { from: 5, to: 5 });
|
||
return {
|
||
upHz: rises(up.out, 12000, 36000),
|
||
upLength: heard(up.out),
|
||
sweepEarlyHz: rises(sweep.out, 6000, 12000),
|
||
sweepLateHz: rises(sweep.out, 36000, 42000),
|
||
sweepLength: heard(sweep.out),
|
||
voices: 16,
|
||
renderMs: Math.round(load.ms),
|
||
realTimeVoices: Math.round((16 * 2000) / load.ms),
|
||
};
|
||
})()`);
|
||
check(
|
||
'the pitch worklet plays an octave up at the length of the sound, and a sweep down keeps it too',
|
||
Math.abs(offline.upHz / 880 - 1) < 0.02 &&
|
||
offline.upLength > 48000 - 100 &&
|
||
offline.sweepEarlyHz > offline.sweepLateHz * 1.3 &&
|
||
offline.sweepLength > 48000 - 100,
|
||
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.25) 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.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);
|
||
burst.getChannelData(0).fill(0.5);
|
||
for (let v = 0; v < voices; v++) {
|
||
const source = context.createBufferSource();
|
||
source.buffer = burst;
|
||
const nodes = __gm.sound.stageNodes(context, stage);
|
||
source.connect(nodes.input);
|
||
nodes.output.connect(context.destination);
|
||
source.start();
|
||
}
|
||
const started = performance.now();
|
||
const out = await context.startRendering();
|
||
return { left: out.getChannelData(0), right: out.getChannelData(1), ms: performance.now() - started };
|
||
};
|
||
// The middle of the burst: its edges ring a little through the damping.
|
||
const peak = (data, at) => {
|
||
let sum = 0;
|
||
const from = Math.round((at + 0.003) * RATE);
|
||
const to = Math.round((at + 0.007) * RATE);
|
||
for (let i = from; i < to; i++) sum += Math.abs(data[i]);
|
||
return Math.round((sum / (to - from)) * 1000) / 1000;
|
||
};
|
||
// Samples from where each echo is due to where it is heard, in both kinds of echo.
|
||
const late = ({ left, right }) => {
|
||
const found = [];
|
||
for (let k = 1; k <= 4; k++) {
|
||
const from = Math.round((k * 0.3 - 0.05) * RATE);
|
||
let at = from;
|
||
while (at < from + RATE * 0.1 && Math.abs(left[at]) <= 0.002 && Math.abs(right[at]) <= 0.002) at++;
|
||
found.push(at - Math.round(k * 0.3 * RATE));
|
||
}
|
||
return found;
|
||
};
|
||
const one = await render(1, 1.3);
|
||
const plain = await render(1, 1.3, echoOf(false));
|
||
const load = await render(64, 2);
|
||
const lateBy = late(one);
|
||
const at = (k) => k * 0.3 + lateBy[k - 1] / RATE;
|
||
return {
|
||
late: lateBy,
|
||
latePlain: late(plain),
|
||
dry: [peak(one.left, 0), peak(one.right, 0)],
|
||
first: [peak(one.left, at(1)), peak(one.right, at(1))],
|
||
second: [peak(one.left, at(2)), peak(one.right, at(2))],
|
||
third: [peak(one.left, at(3)), peak(one.right, at(3))],
|
||
between: [peak(one.left, 0.45), peak(one.right, 0.45)],
|
||
voices: 64,
|
||
renderMs: Math.round(load.ms),
|
||
realTimeVoices: Math.round((64 * 2000) / load.ms),
|
||
};
|
||
})()`);
|
||
const near = (value, to) => Math.abs(value - to) < 0.02;
|
||
check(
|
||
'the player’s ping-pong echo comes back left, then right, then left, each round half as loud',
|
||
near(echo.dry[0], 0.5) &&
|
||
near(echo.dry[1], 0.5) &&
|
||
near(echo.first[0], 0.25) &&
|
||
echo.first[1] < 0.005 &&
|
||
echo.second[0] < 0.005 &&
|
||
near(echo.second[1], 0.125) &&
|
||
near(echo.third[0], 0.0625) &&
|
||
echo.third[1] < 0.005 &&
|
||
echo.between.every((v) => v < 0.005) &&
|
||
// Chrome takes a render quantum more for every round of a feedback loop.
|
||
[echo.late, echo.latePlain].every((late) => late.every((n, k) => n === 0 || n === k * 128)),
|
||
JSON.stringify(echo),
|
||
);
|
||
// #322: the Bitcrusher on the worklet, rendered offline. A 440 Hz sine at 0.8 through 2 bits at
|
||
// 4 kHz holds every value for 12 samples and leaves only steps of 0.5. 16 bits at 48 kHz
|
||
// leave the sound as it is, a mix of 0.5 is half of each. Then 64 stereo voices for 2 s,
|
||
// timed, for how many this machine crushes at once.
|
||
const crushed = await evaluate(`
|
||
(async () => {
|
||
const RATE = 48000;
|
||
const render = async (voices, seconds, channels, stage) => {
|
||
const context = new OfflineAudioContext(channels, RATE * seconds, RATE);
|
||
await context.audioWorklet.addModule(__gm.sound.fxWorkletUrl());
|
||
const tone = context.createBuffer(channels, RATE * seconds, RATE);
|
||
for (let c = 0; c < channels; c++) {
|
||
const data = tone.getChannelData(c);
|
||
for (let i = 0; i < data.length; i++) data[i] = Math.sin((2 * Math.PI * 440 * i) / RATE) * 0.8;
|
||
}
|
||
for (let v = 0; v < voices; v++) {
|
||
const source = context.createBufferSource();
|
||
source.buffer = tone;
|
||
const nodes = __gm.sound.stageNodes(context, { kind: 'crush', ...stage });
|
||
source.connect(nodes.input);
|
||
nodes.output.connect(context.destination);
|
||
source.start();
|
||
}
|
||
const started = performance.now();
|
||
const out = (await context.startRendering()).getChannelData(0);
|
||
return { out, tone: tone.getChannelData(0), ms: performance.now() - started };
|
||
};
|
||
const crush = await render(1, 0.5, 1, { bits: 2, rate: 4000, mix: 1 });
|
||
let offStep = 0;
|
||
let unheld = 0;
|
||
for (let i = 0; i < crush.out.length; i++) {
|
||
offStep = Math.max(offStep, Math.abs(crush.out[i] * 2 - Math.round(crush.out[i] * 2)));
|
||
if (crush.out[i] !== crush.out[i - (i % 12)]) unheld++;
|
||
}
|
||
const levels = [...new Set(crush.out)].sort((a, b) => a - b);
|
||
const clean = await render(1, 0.5, 1, { bits: 16, rate: 48000, mix: 1 });
|
||
const half = await render(1, 0.5, 1, { bits: 1, rate: 48000, mix: 0.5 });
|
||
let cleanOff = 0;
|
||
let halfOff = 0;
|
||
for (let i = 0; i < clean.out.length; i++) {
|
||
cleanOff = Math.max(cleanOff, Math.abs(clean.out[i] - clean.tone[i]));
|
||
halfOff = Math.max(halfOff, Math.abs(half.out[i] - (half.tone[i] + Math.round(half.tone[i])) / 2));
|
||
}
|
||
const load = await render(64, 2, 2, { bits: 6, rate: 11025, mix: 0.8 });
|
||
return {
|
||
levels,
|
||
offStep,
|
||
unheld,
|
||
cleanOff,
|
||
halfOff,
|
||
voices: 64,
|
||
renderMs: Math.round(load.ms),
|
||
realTimeVoices: Math.round((64 * 2000) / load.ms),
|
||
};
|
||
})()`);
|
||
check(
|
||
'the Bitcrusher holds the sound at its rate and rounds it to its bits, and 16 bits at 48 kHz leave it as it is',
|
||
JSON.stringify(crushed.levels) === JSON.stringify([-1, -0.5, 0, 0.5, 1]) &&
|
||
crushed.offStep < 1e-6 &&
|
||
crushed.unheld === 0 &&
|
||
crushed.cleanOff < 2 ** -15 &&
|
||
crushed.halfOff < 1e-6,
|
||
JSON.stringify(crushed),
|
||
);
|
||
// #341: Phaser and Flanger on the worklet, rendered offline. A 1 kHz sine at 0.8 through a Phaser
|
||
// at 1 kHz: two stages cut it out, four leave it whole. Swept 4 octaves wide once a second,
|
||
// the notch passes it and is two octaves off between, where cos(28°) of it is left. A
|
||
// Flanger of a voice starting at 0.25 s: an impulse 1 ms later comes back after 48 samples
|
||
// at half. Sweeping 100 ms/s from that start, an impulse 0.1 s in comes back 533 samples
|
||
// later. Then 64 stereo voices of each for 2 s, timed.
|
||
const swirled = await evaluate(`
|
||
(async () => {
|
||
const RATE = 48000;
|
||
const render = async (voices, seconds, channels, stage, fill, at = 0) => {
|
||
const context = new OfflineAudioContext(channels, RATE * seconds, RATE);
|
||
await context.audioWorklet.addModule(__gm.sound.fxWorkletUrl());
|
||
const sound = context.createBuffer(channels, RATE * seconds, RATE);
|
||
for (let c = 0; c < channels; c++) fill(sound.getChannelData(c));
|
||
for (let v = 0; v < voices; v++) {
|
||
const source = context.createBufferSource();
|
||
source.buffer = sound;
|
||
const nodes = __gm.sound.stageNodes(context, stage, at);
|
||
source.connect(nodes.input);
|
||
nodes.output.connect(context.destination);
|
||
source.start();
|
||
}
|
||
const started = performance.now();
|
||
const out = (await context.startRendering()).getChannelData(0);
|
||
return { out, ms: performance.now() - started };
|
||
};
|
||
const sine = (data) => {
|
||
for (let i = 0; i < data.length; i++) data[i] = Math.sin((2 * Math.PI * 1000 * i) / RATE) * 0.8;
|
||
};
|
||
const rms = (out, from, to) => {
|
||
let sum = 0;
|
||
for (let i = from; i < to; i++) sum += out[i] ** 2;
|
||
return Math.sqrt(sum / (to - from));
|
||
};
|
||
const phaser = (stages, rate, depth) =>
|
||
({ kind: 'phaser', stages, frequency: 1000, rate, depth, feedback: 0, mix: 1 });
|
||
const notch = (await render(1, 0.3, 1, phaser(2, 0, 0), sine)).out;
|
||
const peak = (await render(1, 0.3, 1, phaser(4, 0, 0), sine)).out;
|
||
const swept = (await render(1, 1, 1, phaser(2, 1, 4), sine)).out;
|
||
const windows = Array.from({ length: 40 }, (_, w) => rms(swept, w * 1200, (w + 1) * 1200));
|
||
const flanger = (offset, sweep) =>
|
||
({ kind: 'flanger', offset, sweep, rate: 0, depth: 0, feedback: 0, mix: 1 });
|
||
const impulse = (at) => (data) => { data[Math.round(at * RATE)] = 1; };
|
||
const heard = (out, from) => {
|
||
const found = [];
|
||
for (let i = from; i < from + 2000; i++) if (Math.abs(out[i]) > 1e-4) found.push([i - from, +out[i].toFixed(3)]);
|
||
return found;
|
||
};
|
||
const still = heard((await render(1, 0.5, 1, flanger(1, 0), impulse(0.251), 0.25)).out, Math.round(0.251 * RATE));
|
||
const sweeping = heard((await render(1, 0.5, 1, flanger(0, 100), impulse(0.35), 0.25)).out, Math.round(0.35 * RATE));
|
||
const noise = (data) => { for (let i = 0; i < data.length; i++) data[i] = Math.sin(i * 12.9898) % 1 * 0.5; };
|
||
const phaserLoad = await render(64, 2, 2, { ...phaser(12, 0.5, 2), feedback: 0.6 }, noise);
|
||
const flangerLoad = await render(64, 2, 2, { kind: 'flanger', offset: 1, sweep: 2, rate: 0.3, depth: 3, feedback: 0.5, mix: 1 }, noise);
|
||
return {
|
||
notch: +rms(notch, 4800, notch.length).toFixed(4),
|
||
peak: +rms(peak, 4800, peak.length).toFixed(4),
|
||
sweptLow: +Math.min(...windows).toFixed(3),
|
||
sweptHigh: +Math.max(...windows).toFixed(3),
|
||
still,
|
||
sweeping,
|
||
phaserVoices: Math.round((64 * 2000) / phaserLoad.ms),
|
||
flangerVoices: Math.round((64 * 2000) / flangerLoad.ms),
|
||
};
|
||
})()`);
|
||
check(
|
||
'the Phaser cuts a notch at its frequency and sweeps it, the Flanger comes back offset ms late and sweeps from its voice',
|
||
swirled.notch < 0.01 &&
|
||
Math.abs(swirled.peak - 0.8 * Math.SQRT1_2) < 0.01 &&
|
||
swirled.sweptLow < 0.1 &&
|
||
swirled.sweptHigh > 0.46 &&
|
||
swirled.sweptHigh < 0.51 &&
|
||
JSON.stringify(swirled.still) ===
|
||
JSON.stringify([
|
||
[0, 0.5],
|
||
[48, 0.5],
|
||
]) &&
|
||
swirled.sweeping[0]?.[1] === 0.5 &&
|
||
JSON.stringify(swirled.sweeping.slice(1).map(([i]) => i)) === JSON.stringify([533, 534]),
|
||
JSON.stringify(swirled),
|
||
);
|
||
// #318: the Filter rendered offline. A 2 kHz sine at 0.5 through a low-pass at 1 kHz with no bump
|
||
// falls by 12.3 dB at 12 dB/oct and by four times that at 48 dB/oct. A peak of +6 dB at
|
||
// 1 kHz raises a 1 kHz sine by 6 dB, a notch there takes it out.
|
||
const filtered = await evaluate(`
|
||
(async () => {
|
||
const RATE = 48000;
|
||
const level = async (hz, stage) => {
|
||
const context = new OfflineAudioContext(1, RATE / 2, RATE);
|
||
const tone = context.createOscillator();
|
||
tone.frequency.value = hz;
|
||
const half = context.createGain();
|
||
half.gain.value = 0.5;
|
||
const nodes = __gm.sound.stageNodes(context, { kind: 'pass', stages: 1, cutoff: 1000, q: 0.7071, gain: 0, ...stage });
|
||
tone.connect(half).connect(nodes.input);
|
||
nodes.output.connect(context.destination);
|
||
tone.start();
|
||
const out = (await context.startRendering()).getChannelData(0).subarray(RATE / 4);
|
||
let sum = 0;
|
||
for (const v of out) sum += v * v;
|
||
return +(20 * Math.log10(Math.sqrt(sum / out.length) / (0.5 * Math.SQRT1_2))).toFixed(2);
|
||
};
|
||
return {
|
||
low12: await level(2000, { filter: 'lowpass', q: -3.0103 }),
|
||
low48: await level(2000, { filter: 'lowpass', q: -3.0103, stages: 4 }),
|
||
peak: await level(1000, { filter: 'peaking', q: 1, gain: 6 }),
|
||
notch: await level(1000, { filter: 'notch', q: 1 }),
|
||
};
|
||
})()`);
|
||
check(
|
||
'a Filter at 48 dB/oct falls four times as steeply as at 12, a peak raises its frequency by its gain, a notch takes it out',
|
||
Math.abs(filtered.low12 + 12.3) < 0.2 &&
|
||
Math.abs(filtered.low48 + 49.2) < 0.8 &&
|
||
Math.abs(filtered.peak - 6) < 0.1 &&
|
||
filtered.notch < -40,
|
||
JSON.stringify(filtered),
|
||
);
|
||
// #319: Pan rendered offline. A mono sine at 0.5 all to the left leaves the right silent, in the
|
||
// middle each side gets 0.707 of it. A stereo sound panned half right keeps its right side
|
||
// and moves part of its left into it, as Web Audio pans stereo: left 0.707, right 1 + 0.707.
|
||
const panned = await evaluate(`
|
||
(async () => {
|
||
const RATE = 48000;
|
||
const peaks = async (pan, channels) => {
|
||
const context = new OfflineAudioContext(2, RATE / 10, RATE);
|
||
const sound = context.createBuffer(channels, RATE / 10, RATE);
|
||
for (let c = 0; c < channels; c++) {
|
||
const data = sound.getChannelData(c);
|
||
for (let i = 0; i < data.length; i++) data[i] = Math.sin((2 * Math.PI * 1000 * i) / RATE) * 0.5;
|
||
}
|
||
const source = context.createBufferSource();
|
||
source.buffer = sound;
|
||
const nodes = __gm.sound.stageNodes(context, { kind: 'pan', pan });
|
||
source.connect(nodes.input);
|
||
nodes.output.connect(context.destination);
|
||
source.start();
|
||
const out = await context.startRendering();
|
||
return [0, 1].map((c) => +Math.max(...out.getChannelData(c).map(Math.abs)).toFixed(3));
|
||
};
|
||
return { left: await peaks(-1, 1), middle: await peaks(0, 1), balance: await peaks(0.5, 2) };
|
||
})()`);
|
||
check(
|
||
'Pan moves a mono sound between the sides, and a stereo one towards the side it goes to',
|
||
JSON.stringify(panned) ===
|
||
JSON.stringify({ left: [0.5, 0], middle: [0.354, 0.354], balance: [0.354, 0.854] }),
|
||
JSON.stringify(panned),
|
||
);
|
||
// #326: Compressor and Limiter rendered offline. An impulse comes out of the Compressor 288
|
||
// samples late (the spec's 6 ms) and out of the Limiter 240 (5 ms). A 1 kHz sine at full
|
||
// scale through a hard-knee Compressor at -12 dB, 4:1 with full auto-gain comes back near
|
||
// full scale; with no auto-gain and no makeup it is 9 dB down. A sine at 2 through a
|
||
// Limiter at -6 dB never goes past it, with auto-gain 1 it sits at the ceiling of -1 dB.
|
||
const dynamics = await evaluate(`
|
||
(async () => {
|
||
const RATE = 48000;
|
||
const render = async (stage, fill) => {
|
||
const context = new OfflineAudioContext(1, RATE / 2, RATE);
|
||
await context.audioWorklet.addModule(__gm.sound.fxWorkletUrl());
|
||
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 impulse = (data) => { data[480] = 0.01; };
|
||
const sine = (amplitude) => (data) => {
|
||
for (let i = 0; i < data.length; i++) data[i] = Math.sin((2 * Math.PI * 1000 * i) / RATE) * amplitude;
|
||
};
|
||
const loudest = (out, from = 0) => { let at = 0; for (let i = from; i < out.length; i++) if (Math.abs(out[i]) > Math.abs(out[at])) at = i; return at; };
|
||
const db = (out) => +(20 * Math.log10(Math.abs(out[loudest(out, RATE / 4)]))).toFixed(2);
|
||
const compress = (gain) => ({ kind: 'compress', threshold: -24, knee: 0, ratio: 4, attack: 0.003, release: 0.25, gain });
|
||
const makeup = (autoGain) => __gm.sound.compressorMakeup({ threshold: -24, knee: 0, ratio: 4, makeup: 0, autoGain });
|
||
const limit = (autoGain) => ({ kind: 'limit', threshold: -6, ceiling: -1, release: 0.05, autoGain });
|
||
const limited = await render(limit(0), sine(2));
|
||
// A sine at the -18 dB auto-gain keeps as loud as it came in (#378).
|
||
const reference = sine(10 ** (-18 / 20));
|
||
return {
|
||
compressorLate: loudest(await render(compress(1), impulse)) - 480,
|
||
limiterLate: loudest(await render(limit(0), impulse)) - 480,
|
||
autoGain: db(await render(compress(makeup(true)), reference)),
|
||
noMakeup: db(await render(compress(makeup(false)), reference)),
|
||
limitedPeak: +(20 * Math.log10(Math.max(...limited.map(Math.abs)))).toFixed(3),
|
||
ceiling: db(await render(limit(1), sine(2))),
|
||
};
|
||
})()`);
|
||
check(
|
||
'a Compressor is 6 ms late and auto-gain keeps -18 dB as loud, a Limiter is 5 ms late and never passes its limit',
|
||
dynamics.compressorLate === 288 &&
|
||
dynamics.limiterLate === 240 &&
|
||
Math.abs(dynamics.autoGain + 18) < 0.5 &&
|
||
Math.abs(dynamics.noMakeup + 22.5) < 0.5 &&
|
||
dynamics.limitedPeak <= -6 + 1e-3 &&
|
||
dynamics.ceiling <= -1 + 1e-3 &&
|
||
dynamics.ceiling > -1.2,
|
||
JSON.stringify(dynamics),
|
||
);
|
||
// #323: Saturation rendered offline. An impulse through it comes out 192 samples late at 48 and
|
||
// at 44.1 kHz, the oversampling's delay. Half bent and half dry at 1125 Hz – where 192
|
||
// samples off would cancel – the two meet in step and the sine stays as loud. A sine at
|
||
// -12 dBFS driven 24 dB into tanh comes back as loud with full auto-gain.
|
||
const saturated = await evaluate(`
|
||
(async () => {
|
||
const render = async (rate, stage, fill) => {
|
||
const context = new OfflineAudioContext(1, rate / 2, rate);
|
||
const sound = context.createBuffer(1, rate / 2, rate);
|
||
fill(sound.getChannelData(0), rate);
|
||
const source = context.createBufferSource();
|
||
source.buffer = sound;
|
||
const nodes = __gm.sound.stageNodes(context, { kind: 'saturate', curve: 'hard', drive: 0, tone: 20000, level: 1, mix: 1, ...stage });
|
||
source.connect(nodes.input);
|
||
nodes.output.connect(context.destination);
|
||
source.start();
|
||
return (await context.startRendering()).getChannelData(0);
|
||
};
|
||
const impulse = (data) => { data[480] = 0.5; };
|
||
const sine = (hz, amplitude) => (data, rate) => {
|
||
for (let i = 0; i < data.length; i++) data[i] = Math.sin((2 * Math.PI * hz * i) / rate) * amplitude;
|
||
};
|
||
const loudest = (out) => out.reduce((at, v, i) => (Math.abs(v) > Math.abs(out[at]) ? i : at), 0);
|
||
const db = (out, amplitude) => {
|
||
let sum = 0;
|
||
const from = out.length / 2;
|
||
for (let i = from; i < out.length; i++) sum += out[i] ** 2;
|
||
return +(20 * Math.log10(Math.sqrt(sum / (out.length - from)) / (amplitude * Math.SQRT1_2))).toFixed(2);
|
||
};
|
||
return {
|
||
late48: loudest(await render(48000, {}, impulse)) - 480,
|
||
late44: loudest(await render(44100, {}, impulse)) - 480,
|
||
halfDry: db(await render(48000, { mix: 0.5 }, sine(1125, 0.5)), 0.5),
|
||
autoGain: db(await render(48000, { curve: 'soft', drive: 24, level: 10 ** (__gm.sound.saturationAutoGain('soft', 24) / 20) }, sine(1000, 0.25)), 0.25),
|
||
};
|
||
})()`);
|
||
check(
|
||
'a Saturation is 192 samples late at either rate, its dry half meets the bent one in step, auto-gain keeps it as loud',
|
||
saturated.late48 === 192 &&
|
||
saturated.late44 === 192 &&
|
||
Math.abs(saturated.halfDry) < 0.1 &&
|
||
Math.abs(saturated.autoGain) < 0.5,
|
||
JSON.stringify(saturated),
|
||
);
|
||
// #320: Tremolo and Vibrato on the worklet, rendered offline. A Tremolo square at 10 Hz, depth 1,
|
||
// on a steady 0.5 for a voice starting at 0.1 s: silence before it, then 0.5 for 50 ms and
|
||
// 0 for 50 ms. A Vibrato square at 5 Hz, one semitone deep, on a 1 kHz sine: the first
|
||
// half of a period at 1059.5 Hz, the second at 940.5 Hz.
|
||
const swung = await evaluate(`
|
||
(async () => {
|
||
const RATE = 48000;
|
||
const render = async (stage, fill, at = 0) => {
|
||
const context = new OfflineAudioContext(1, RATE, RATE);
|
||
await context.audioWorklet.addModule(__gm.sound.fxWorkletUrl());
|
||
const sound = context.createBuffer(1, RATE, RATE);
|
||
fill(sound.getChannelData(0));
|
||
const source = context.createBufferSource();
|
||
source.buffer = sound;
|
||
const nodes = __gm.sound.stageNodes(context, stage, at);
|
||
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 * 1000 * i) / RATE); };
|
||
const levels = (out, from, to) => [...new Set(out.subarray(from, to))].map((v) => +v.toFixed(3));
|
||
const tremolo = await render({ kind: 'tremolo', shape: 'square', rate: 10, depth: 1 }, steady, 0.1);
|
||
const hz = (out, from, to) => {
|
||
const at = [];
|
||
for (let i = Math.round(from * RATE) + 1; i < to * RATE; i++)
|
||
if (out[i - 1] < 0 && out[i] >= 0) at.push(i - out[i] / (out[i] - out[i - 1]));
|
||
return +(((at.length - 1) * RATE) / (at.at(-1) - at[0])).toFixed(1);
|
||
};
|
||
const vibrato = await render({ kind: 'vibrato', shape: 'square', rate: 5, depth: 1 }, sine);
|
||
return {
|
||
before: levels(tremolo, 0, 4800),
|
||
on: levels(tremolo, 4810, 7190),
|
||
off: levels(tremolo, 7210, 9590),
|
||
up: hz(vibrato, 0.41, 0.49),
|
||
down: hz(vibrato, 0.51, 0.59),
|
||
};
|
||
})()`);
|
||
check(
|
||
'a Tremolo starts its swing with its voice, a Vibrato jumps a semitone up and as far down',
|
||
JSON.stringify([swung.before, swung.on, swung.off]) === JSON.stringify([[0], [0.5], [0]]) &&
|
||
Math.abs(swung.up - 1059.5) < 1 &&
|
||
Math.abs(swung.down - 940.5) < 1,
|
||
JSON.stringify(swung),
|
||
);
|
||
// #328: Reverb rendered offline. An impulse at 0.05 s through a Custom reverb of 1 s with a
|
||
// pre-delay of 50 ms: nothing of it before 0.1 s, then it falls by 60 dB a second, 36 dB
|
||
// from 0.2-0.3 s to 0.8-0.9 s after it. At mix 0.5 the impulse itself is there at half.
|
||
// With a sound as its impulse response – one sample at 0.2 s – the impulse comes back
|
||
// 0.2 s and the pre-delay later, and nowhere else.
|
||
const reverbed = await evaluate(`
|
||
(async () => {
|
||
const RATE = 48000;
|
||
const render = async (stage, impulse) => {
|
||
const context = new OfflineAudioContext(1, RATE * 1.5, RATE);
|
||
const sound = context.createBuffer(1, RATE * 1.5, RATE);
|
||
sound.getChannelData(0)[RATE * 0.05] = 0.5;
|
||
let ir;
|
||
if (impulse) {
|
||
ir = context.createBuffer(2, RATE / 2, RATE);
|
||
for (let c = 0; c < 2; c++) ir.getChannelData(c)[RATE * impulse] = 1;
|
||
}
|
||
const source = context.createBufferSource();
|
||
source.buffer = sound;
|
||
const nodes = __gm.sound.stageNodes(context, { kind: 'reverb', decay: 1, damping: 0, predelay: 0.05, mix: 1, ...stage }, 0, ir);
|
||
source.connect(nodes.input);
|
||
nodes.output.connect(context.destination);
|
||
source.start();
|
||
return (await context.startRendering()).getChannelData(0);
|
||
};
|
||
const db = (out, from, to) => {
|
||
let sum = 0;
|
||
for (let i = from * RATE; i < to * RATE; i++) sum += out[i] ** 2;
|
||
return 10 * Math.log10(sum / ((to - from) * RATE));
|
||
};
|
||
const peak = (out, from, to) => Math.max(...out.subarray(from * RATE, to * RATE).map(Math.abs));
|
||
const wet = await render({});
|
||
const half = await render({ mix: 0.5 });
|
||
const sounded = await render({ impulse: 'room.wav' }, 0.2);
|
||
const heard = [];
|
||
sounded.forEach((v, i) => { if (Math.abs(v) > 1e-6) heard.push(i); });
|
||
return {
|
||
before: peak(wet, 0, 0.0999),
|
||
fall: +(db(wet, 0.25, 0.35) - db(wet, 0.85, 0.95)).toFixed(1),
|
||
dry: +half[RATE * 0.05].toFixed(4),
|
||
sound: [heard[0], heard.at(-1)],
|
||
};
|
||
})()`);
|
||
check(
|
||
'a Reverb waits its pre-delay, falls 60 dB in its decay, and takes a sound as its impulse response',
|
||
reverbed.before === 0 &&
|
||
Math.abs(reverbed.fall - 36) < 3 &&
|
||
reverbed.dry === 0.25 &&
|
||
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
|
||
// 110 Hz, it ends at 1 s and tells the page. Then 64 voices for 2 s, timed.
|
||
const chipped = await evaluate(`
|
||
(async () => {
|
||
const RATE = 48000;
|
||
const shape = { wave: 'square', dutySweep: 0, slide: 0, deltaSlide: 0, minFrequency: 0, vibratoDepth: 0,
|
||
vibratoSpeed: 6, arpeggio: 0, arpeggioTime: 0.1, repeat: 0, startAt: 0 };
|
||
const render = async (voices, seconds, params, options) => {
|
||
const context = new OfflineAudioContext(1, RATE * seconds, RATE);
|
||
await context.audioWorklet.addModule(__gm.sound.fxWorkletUrl());
|
||
const said = [];
|
||
for (let v = 0; v < voices; v++) {
|
||
const node = new AudioWorkletNode(context, 'chip-player', {
|
||
numberOfInputs: 0,
|
||
outputChannelCount: [1],
|
||
parameterData: params,
|
||
processorOptions: { ...shape, ...options },
|
||
});
|
||
node.port.onmessage = (event) => said.push(event.data);
|
||
node.connect(context.destination);
|
||
}
|
||
const started = performance.now();
|
||
const out = (await context.startRendering()).getChannelData(0);
|
||
const ms = performance.now() - started;
|
||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||
return { out, ms, said };
|
||
};
|
||
const cycles = (out, from, to) => {
|
||
let n = 0;
|
||
for (let i = Math.round(from * RATE) + 1; i < Math.round(to * RATE); i++) if (out[i - 1] <= 0 && out[i] > 0) n++;
|
||
return n;
|
||
};
|
||
const tone = await render(1, 1, { frequency: 220, duty: 0.25, pitch: 12 }, { startAt: 0.25 });
|
||
const heard = tone.out.subarray(0.25 * RATE);
|
||
const firstHeard = tone.out.findIndex((v) => v !== 0);
|
||
const slid = await render(1, 1.5, { frequency: 440, duty: 0.5, pitch: 0 }, { slide: -24, minFrequency: 110 });
|
||
const lastHeard = slid.out.findLastIndex((v) => v !== 0);
|
||
const load = await render(64, 2, { frequency: 330, duty: 0.5, pitch: 0 }, { wave: 'noise', slide: -6, vibratoDepth: 0.5 });
|
||
return {
|
||
firstHeard,
|
||
periods: cycles(tone.out, 0.25, 1),
|
||
up: Math.round((heard.filter((v) => v > 0).length / heard.length) * 1000) / 1000,
|
||
peak: heard.reduce((top, v) => Math.max(top, Math.abs(v)), 0),
|
||
lastHeard: lastHeard / RATE,
|
||
said: slid.said,
|
||
voices: 64,
|
||
renderMs: Math.round(load.ms),
|
||
realTimeVoices: Math.round((64 * 2000) / load.ms),
|
||
};
|
||
})()`);
|
||
check(
|
||
'the Chip plays its wave at its frequency and pitch from its start, and ends by itself below its lowest frequency',
|
||
chipped.firstHeard === 0.25 * 48000 &&
|
||
Math.abs(chipped.periods - 330) <= 1 &&
|
||
Math.abs(chipped.up - 0.25) < 0.01 &&
|
||
Math.abs(chipped.peak - 0.5) < 0.01 &&
|
||
Math.abs(chipped.lastHeard - 1) < 0.01 &&
|
||
chipped.said.join() === 'ended',
|
||
JSON.stringify(chipped),
|
||
);
|
||
// A Mix "Both at center", rendered offline: a steady 1 on a and 0.5 on b in two channels, so each
|
||
// share reads off as it is. Then mix glides 0 → 1 over a second: at its middle both stay full.
|
||
const atCenter = await evaluate(`
|
||
(async () => {
|
||
const RATE = 48000;
|
||
const render = async (mix, glide) => {
|
||
const context = new OfflineAudioContext(2, RATE, RATE);
|
||
const merger = context.createChannelMerger(2);
|
||
merger.connect(context.destination);
|
||
const shareA = context.createGain();
|
||
const shareB = context.createGain();
|
||
shareA.gain.value = 0;
|
||
shareB.gain.value = 0;
|
||
const driver = __gm.sound.bothAtCenter(context, mix, shareA, shareB);
|
||
for (const [share, level, side] of [[shareA, 1, 0], [shareB, 0.5, 1]]) {
|
||
const steady = context.createConstantSource();
|
||
steady.offset.value = level;
|
||
steady.connect(share).connect(merger, 0, side);
|
||
steady.start();
|
||
}
|
||
if (glide) driver.offset.setValueAtTime(0, 0).linearRampToValueAtTime(1, 1);
|
||
driver.start();
|
||
const out = await context.startRendering();
|
||
const at = (s) => [out.getChannelData(0)[Math.round(s * RATE)], out.getChannelData(1)[Math.round(s * RATE)] * 2];
|
||
return glide ? [0.25, 0.5, 0.75].map(at) : at(0.5);
|
||
};
|
||
const round = (pair) => pair.map((v) => Math.round(v * 1000) / 1000);
|
||
const fixed = {};
|
||
for (const mix of [0, 0.25, 0.5, 0.75, 1]) fixed[mix] = round(await render(mix, false));
|
||
return { fixed, glide: (await render(0, true)).map(round) };
|
||
})()`);
|
||
const pairNear = (pair, a, b) => near(pair[0], a) && near(pair[1], b);
|
||
check(
|
||
'a Mix "Both at center" keeps a full up to the middle and b full from it, also while mix glides',
|
||
pairNear(atCenter.fixed[0], 1, 0) &&
|
||
pairNear(atCenter.fixed[0.25], 1, 0.5) &&
|
||
pairNear(atCenter.fixed[0.5], 1, 1) &&
|
||
pairNear(atCenter.fixed[0.75], 0.5, 1) &&
|
||
pairNear(atCenter.fixed[1], 0, 1) &&
|
||
pairNear(atCenter.glide[0], 1, 0.5) &&
|
||
pairNear(atCenter.glide[1], 1, 1) &&
|
||
pairNear(atCenter.glide[2], 0.5, 1),
|
||
JSON.stringify(atCenter),
|
||
);
|
||
await mutate(`
|
||
const logic = __gm.state.project.assets.find((a) => a.id === 'sn-logic');
|
||
logic.variables = [];
|
||
const graph = logic.graph;
|
||
graph.nodes = graph.nodes.filter((n) => !${JSON.stringify(tuneIds)}.includes(n.id));
|
||
graph.edges = graph.edges.filter((e) => !${JSON.stringify(tuneEdges)}.includes(e.id));`);
|
||
await mutate(`
|
||
const graph = __gm.state.project.assets.find((a) => a.id === 'sn-logic').graph;
|
||
graph.nodes = graph.nodes.filter((n) => n.id !== 'sn-fxp');
|
||
graph.edges = graph.edges.filter((e) => e.id !== 'sn-e-fxp');
|
||
Object.assign(graph.nodes.find((n) => n.id === 'sn-play').params, { loop: '', fx: '' });
|
||
const project = __gm.state.project;
|
||
project.assets = project.assets.filter((a) => a.id !== ${JSON.stringify(fxId)});`);
|
||
|
||
// 34g-5. An instrument (#331): "+ → Instrument" opens its tab on Notes → Sample → Output, which
|
||
// plays nothing until the Sample has a sound. With the blip, looped, every key of C4–B4
|
||
// sounds; a key held by the pointer plays until it is let go, the computer keyboard plays
|
||
// as a tracker does, and a game's `Play note` releases its note after its duration.
|
||
await showTab('scene');
|
||
await clickText('+');
|
||
await pickAssetType(t, 'Instrument');
|
||
const insCreated = await json(`{
|
||
tab: __gm.tab(),
|
||
current: __gm.instrument.current(),
|
||
info: __gm.instrument.info(),
|
||
keys: __gm.instrument.keys(),
|
||
assets: __gm.state.project.assets.filter((a) => a.type === 'instrument')
|
||
.map((a) => ({ id: a.id, nodes: a.graph.nodes.map((n) => n.type) })),
|
||
}`);
|
||
check(
|
||
'the + menu creates an instrument, Notes → Sample → Output, and opens its tab; no key sounds yet',
|
||
insCreated.tab === 'instrument' &&
|
||
insCreated.assets.length === 1 &&
|
||
insCreated.current === insCreated.assets[0].id &&
|
||
insCreated.assets[0].nodes.join() === 'InstrumentNotes,InstrumentSample,InstrumentOutput' &&
|
||
insCreated.info === 'No sound picked' &&
|
||
insCreated.keys.sounding.length === 0,
|
||
JSON.stringify(insCreated),
|
||
);
|
||
const insId = insCreated.assets[0]?.id;
|
||
// Bottom left: the fitted graph is one row of three nodes, its links among them.
|
||
const insCanvas = await json('__gm.instrument.canvasRect()');
|
||
await click(insCanvas.x + 30, insCanvas.y + insCanvas.h - 30, 'right');
|
||
const insPalette = await json('__gm.overlayTexts()');
|
||
check(
|
||
'its palette offers its own nodes and the effects, but no Input and no behavior node',
|
||
['Notes', 'Sample', 'Parameters', 'Sound FX', 'Envelope', 'Gain', 'Filter', 'Delay'].every((label) =>
|
||
insPalette.includes(label),
|
||
) &&
|
||
!insPalette.includes('Input') &&
|
||
!insPalette.includes('Branch'),
|
||
JSON.stringify(insPalette.slice(0, 14)),
|
||
);
|
||
await press('Escape');
|
||
await mutate(`
|
||
const ins = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(insId)});
|
||
Object.assign(ins.graph.nodes.find((n) => n.type === 'InstrumentSample').params, {
|
||
sound: ${JSON.stringify(soundId)},
|
||
play: 'loop',
|
||
});`);
|
||
const insReady = await json('{ info: __gm.instrument.info(), keys: __gm.instrument.keys() }');
|
||
check(
|
||
'with a sound picked the twelve keys from C4 to B4 sound, the others stay grey',
|
||
insReady.info === '12 notes' &&
|
||
insReady.keys.sounding.join() === [60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71].join(),
|
||
JSON.stringify(insReady),
|
||
);
|
||
await shot('34-instrument');
|
||
|
||
await evaluate('__gm.sound.stopAll()');
|
||
const beforeKey = await stats();
|
||
const e4 = await json('__gm.instrument.keyAt(64)');
|
||
await t.mouse('mouseMoved', e4.x, e4.y);
|
||
await t.mouse('mousePressed', e4.x, e4.y);
|
||
const heldKey = await waitFor(stats, (s) => s.started > beforeKey.started);
|
||
// Longer than the blip: a looped note that ended with its sound would be gone by now.
|
||
await sleep(600);
|
||
const stillHeld = await json('{ stats: __gm.sound.stats(), keys: __gm.instrument.keys() }');
|
||
check(
|
||
'holding E4 plays the blip four semitones up, looped, as one layer of the instrument, and lights the key',
|
||
heldKey.last?.file === `instrument:${insId}` &&
|
||
heldKey.last.pitches.join() === '4' &&
|
||
heldKey.last.layers === 1 &&
|
||
stillHeld.stats.playing === 1 &&
|
||
stillHeld.keys.held.join() === '64',
|
||
JSON.stringify({ heldKey, stillHeld }),
|
||
);
|
||
await t.mouse('mouseReleased', e4.x, e4.y);
|
||
const letGo = await waitFor(stats, (s) => s.playing === 0);
|
||
check(
|
||
'letting go of the key ends the note: its sample fades out and the voice goes',
|
||
letGo.playing === 0 && (await json('__gm.instrument.keys()')).held.length === 0,
|
||
JSON.stringify(letGo),
|
||
);
|
||
|
||
// "Loop, then release": held, it loops 0.05 to 0.1 s; let go, it plays the 0.15 s after the loop.
|
||
const setPlay = (fields) =>
|
||
mutate(`
|
||
const ins = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(insId)});
|
||
Object.assign(ins.graph.nodes.find((n) => n.type === 'InstrumentSample').params, ${JSON.stringify(fields)});`);
|
||
await setPlay({ play: 'release', loopStart: 0.05, loopEnd: 0.1 });
|
||
const beforeRelease = await stats();
|
||
await t.mouse('mousePressed', e4.x, e4.y);
|
||
await waitFor(stats, (s) => s.started > beforeRelease.started);
|
||
await sleep(600);
|
||
const heldLoop = await stats();
|
||
await t.mouse('mouseReleased', e4.x, e4.y);
|
||
const leftAt = await audioNow();
|
||
const ringingKeys = await json('__gm.instrument.keys()');
|
||
const playedOut = await waitFor(audioNow, (s) => s.playing === 0);
|
||
const afterKeys = await waitFor(
|
||
() => json('__gm.instrument.keys()'),
|
||
(keys) => keys.ringing.length === 0,
|
||
);
|
||
// 0.15 s after the loop, up to two rounds more (the one it is in, the one the worklet read into)
|
||
// and the 150 ms waitFor polls at: a fade would be gone in 0.01 s.
|
||
const outFor = playedOut.clock - leftAt.clock;
|
||
check(
|
||
'"Loop, then release" loops while held, and let go plays on past the loop with its key half lit',
|
||
heldLoop.playing === 1 &&
|
||
ringingKeys.held.length === 0 &&
|
||
ringingKeys.ringing.join() === '64' &&
|
||
outFor > 0.12 &&
|
||
outFor < 0.5 &&
|
||
afterKeys.ringing.length === 0,
|
||
JSON.stringify({ heldLoop: heldLoop.playing, ringingKeys, outFor, afterKeys }),
|
||
);
|
||
await setPlay({ play: 'loop', loopStart: 0, loopEnd: 0 });
|
||
|
||
// The click left the keys focused. Z is C3 there – no sound in this instrument –, one octave up C4.
|
||
const beforeZ = await stats();
|
||
await press('z');
|
||
await sleep(200);
|
||
const lowZ = await stats();
|
||
await press('ArrowRight');
|
||
await t.keyDown('z');
|
||
const typed = await waitFor(stats, (s) => s.started > lowZ.started);
|
||
const typedKeys = await json('__gm.instrument.keys()');
|
||
await t.keyUp('z');
|
||
const typedOff = await waitFor(stats, (s) => s.playing === 0);
|
||
check(
|
||
'the computer keyboard plays as a tracker: Z is C3 and silent here, one octave up it is C4 until let go',
|
||
lowZ.started === beforeZ.started &&
|
||
typedKeys.octave === 4 &&
|
||
typedKeys.held.join() === '60' &&
|
||
typed.last?.pitches.join() === '0' &&
|
||
typedOff.playing === 0,
|
||
JSON.stringify({ beforeZ: beforeZ.started, lowZ: lowZ.started, typed, typedKeys, typedOff }),
|
||
);
|
||
await press('ArrowLeft');
|
||
|
||
// Twice the eleven keys off the root, all held at once: each plays through the pitch worklet, and
|
||
// the ten oldest are cut, so no more than twelve phase vocoders run and the audio thread keeps up.
|
||
await evaluate('__gm.sound.stopAll()');
|
||
const crowd = await json(`(() => {
|
||
const voices = [...Array(22)].map((_, i) => __gm.instrument.play(61 + (i % 11)));
|
||
return { started: voices.filter((v) => v > 0).length, playing: voices.map((v) => __gm.sound.playing(v)), stats: __gm.sound.stats() };
|
||
})()`);
|
||
check(
|
||
'held at once, 22 notes off the root keep only the 12 newest on the pitch worklet',
|
||
crowd.started === 22 &&
|
||
crowd.playing.join() === [...Array(22)].map((_, i) => i >= 10).join() &&
|
||
crowd.stats.vocoders === 12 &&
|
||
crowd.stats.playing === 12,
|
||
JSON.stringify({ started: crowd.started, playing: crowd.playing, vocoders: crowd.stats.vocoders }),
|
||
);
|
||
|
||
// Closing the tab ends what it plays: E4, looped and held by no key, falls silent with it.
|
||
await evaluate('__gm.sound.stopAll()');
|
||
await json('__gm.instrument.play(64)');
|
||
const heldBeforeClose = await stats();
|
||
await evaluate(`__gm.closeDocument('asset:${insId}')`);
|
||
await sleep(300);
|
||
const afterClose = await stats();
|
||
await evaluate(`__gm.openDocument('asset:${insId}')`);
|
||
check(
|
||
'closing the Instrument tab ends a note it still plays',
|
||
heldBeforeClose.playing === 1 && afterClose.playing === 0,
|
||
JSON.stringify({ held: heldBeforeClose.playing, closed: afterClose.playing }),
|
||
);
|
||
|
||
// 34g-6. Presets (#329): Preset → Laser makes the instrument one Chip through Envelope, Filter and
|
||
// Bitcrusher and plays nothing. Randomize and Mutate play the note last played; a change
|
||
// only plays with Audition on. Three undos bring the blip back.
|
||
const insGraph = () =>
|
||
json(`(() => {
|
||
const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(insId)}).graph;
|
||
const of = (type) => graph.nodes.find((n) => n.type === type)?.params ?? null;
|
||
return { types: graph.nodes.map((n) => n.type), chip: of('InstrumentChip'), envelope: of('InstrumentEnvelope') };
|
||
})()`);
|
||
const clickHook = async (hook) => {
|
||
const at = await json(`__gm.instrument.${hook}()`);
|
||
await click(at.x, at.y);
|
||
};
|
||
await evaluate('__gm.sound.stopAll()');
|
||
const beforePreset = await stats();
|
||
await clickHook('presetAt');
|
||
const presetMenu = await json('__gm.overlayTexts()');
|
||
await clickText('Laser');
|
||
await sleep(400);
|
||
const preset = await stats();
|
||
const laser = await insGraph();
|
||
const presetInfo = await json('__gm.instrument.info()');
|
||
check(
|
||
'Preset → Laser makes Notes → Chip → Envelope → Filter → Bitcrusher → Output and plays nothing',
|
||
['Pickup', 'Laser', 'Explosion', 'Powerup', 'Hit', 'Jump', 'Blip'].every((label) =>
|
||
presetMenu.includes(label),
|
||
) &&
|
||
laser.types.join() ===
|
||
'InstrumentNotes,InstrumentChip,InstrumentEnvelope,SoundFxFilter,SoundFxBitcrusher,InstrumentOutput' &&
|
||
laser.chip?.followNote === false &&
|
||
laser.chip.slide < 0 &&
|
||
laser.envelope?.sustain === 0 &&
|
||
preset.started === beforePreset.started &&
|
||
presetInfo === '128 notes',
|
||
JSON.stringify({ presetMenu, laser, started: [beforePreset.started, preset.started], presetInfo }),
|
||
);
|
||
await shot('34-instrument-preset');
|
||
|
||
// #386: the Laser's Chip is drawn as a device between its ports: a button per wave, the "follow
|
||
// note" lever and a knob per number, each change one undo step, and its screens drawn from
|
||
// it. A Laser falls: its pitch ends lower than it starts. Three undos bring the Laser back.
|
||
await clickText('Fit');
|
||
await sleep(300);
|
||
const chipId = (
|
||
await json(
|
||
`__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(insId)}).graph.nodes.find((n) => n.type === 'InstrumentChip')`,
|
||
)
|
||
).id;
|
||
const chipPanel = () => json(`__gm.chip.panel('${chipId}')`);
|
||
const chipShown = await chipPanel();
|
||
const chipRect = chipShown.rect;
|
||
const chipBeside = (p) => p.y > chipRect.y && p.y < chipRect.y + chipRect.h;
|
||
await click(chipShown.waves.triangle.x, chipShown.waves.triangle.y);
|
||
const chipPicked = { params: (await insGraph()).chip, panel: await chipPanel() };
|
||
await click(chipShown.follow.at.x, chipShown.follow.at.y);
|
||
const chipFollowed = { params: (await insGraph()).chip, panel: await chipPanel() };
|
||
const slideDial = chipShown.knobs.slide.dial;
|
||
await drag(slideDial.x, slideDial.y, slideDial.x, slideDial.y - 20);
|
||
const chipSlid = (await insGraph()).chip;
|
||
for (let i = 0; i < 3; i++) await press('z', { ctrl: true });
|
||
const chipUndone = (await insGraph()).chip;
|
||
check(
|
||
'a Chip node shows its device between its ports; a wave button, the lever and a knob each change it as one undo step, and its screens follow',
|
||
chipShown.shown &&
|
||
chipShown.size.w === 840 &&
|
||
chipShown.size.h === 280 &&
|
||
Object.values(chipShown.inputs).length === 12 &&
|
||
Object.values(chipShown.inputs).every((p) => p.x < chipRect.x && chipBeside(p)) &&
|
||
Object.values(chipShown.outputs).every((p) => p.x > chipRect.x + chipRect.w && chipBeside(p)) &&
|
||
chipShown.wave === laser.chip.wave &&
|
||
chipShown.follow.on === false &&
|
||
chipShown.knobs.slide.value === laser.chip.slide &&
|
||
chipShown.pitch.from.startsWith(chipShown.knobs.frequency.number) &&
|
||
chipShown.pitch.last < 0 &&
|
||
chipPicked.params.wave === 'triangle' &&
|
||
chipPicked.panel.waveScreen === 'TRIANGLE' &&
|
||
chipPicked.panel.idle.includes('duty') &&
|
||
chipFollowed.params.followNote === true &&
|
||
chipFollowed.panel.follow.on &&
|
||
chipFollowed.panel.idle.includes('frequency') &&
|
||
// A Laser's "min frequency" may lie above A4: then it says the note ends at once.
|
||
chipFollowed.panel.pitch.from.startsWith('A4 440 Hz') &&
|
||
chipSlid.slide > laser.chip.slide &&
|
||
JSON.stringify(chipUndone) === JSON.stringify(laser.chip),
|
||
JSON.stringify({
|
||
chipShown,
|
||
chipPicked,
|
||
chipFollowed,
|
||
slide: [laser.chip.slide, chipSlid.slide],
|
||
chipUndone,
|
||
}),
|
||
);
|
||
|
||
// #388: the Laser's Envelope is drawn as a device between its ports, its curve on a screen. Its
|
||
// sustain is 0, so the curve ends with the decay; nothing takes its value, so "from" and
|
||
// "to" are grey. A knob and a scrubbed field each change it as one undo step.
|
||
const envelopeId = (
|
||
await json(
|
||
`__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(insId)}).graph.nodes.find((n) => n.type === 'InstrumentEnvelope')`,
|
||
)
|
||
).id;
|
||
const envelopePanel = () => json(`__gm.envelope.panel('${envelopeId}')`);
|
||
const envShown = await envelopePanel();
|
||
const envRect = envShown.rect;
|
||
const envBeside = (p) => p.y > envRect.y && p.y < envRect.y + envRect.h;
|
||
const decayDial = envShown.knobs.decay.dial;
|
||
await drag(decayDial.x, decayDial.y, decayDial.x, decayDial.y - 20);
|
||
const envTurned = { params: (await insGraph()).envelope, panel: await envelopePanel() };
|
||
const toField = envShown.fields.to.at;
|
||
await drag(toField.x, toField.y, toField.x + 30, toField.y);
|
||
const envScrubbed = { params: (await insGraph()).envelope, panel: await envelopePanel() };
|
||
for (let i = 0; i < 2; i++) await press('z', { ctrl: true });
|
||
const envUndone = (await insGraph()).envelope;
|
||
check(
|
||
'an Envelope node shows its device between its ports with its curve; a knob and a field each change it as one undo step',
|
||
envShown.shown &&
|
||
envShown.size.w === 520 &&
|
||
envShown.size.h === 280 &&
|
||
Object.values(envShown.inputs).length === 7 &&
|
||
Object.values(envShown.inputs).every((p) => p.x < envRect.x && envBeside(p)) &&
|
||
Object.values(envShown.outputs).every((p) => p.x > envRect.x + envRect.w && envBeside(p)) &&
|
||
envShown.trace.ends &&
|
||
envShown.trace.stages.map((s) => s.key).join() === 'attack,decay' &&
|
||
envShown.screen.ends === 'NOTE ENDS' &&
|
||
envShown.range === '' &&
|
||
envShown.idle.includes('from') &&
|
||
envShown.idle.includes('to') &&
|
||
envShown.knobs.decay.value === laser.envelope.decay &&
|
||
envTurned.params.decay > laser.envelope.decay &&
|
||
envTurned.panel.knobs.decay.value === envTurned.params.decay &&
|
||
envTurned.panel.trace.stages[1].seconds === envTurned.params.decay &&
|
||
envScrubbed.params.to > 1 &&
|
||
envScrubbed.panel.fields.to.text === String(envScrubbed.params.to) &&
|
||
JSON.stringify(envUndone) === JSON.stringify(laser.envelope),
|
||
JSON.stringify({ envShown, envTurned, envScrubbed, envUndone, laser: laser.envelope }),
|
||
);
|
||
|
||
const beforeRandom = await stats();
|
||
await clickHook('randomizeAt');
|
||
const randomized = await waitFor(stats, (s) => s.started > beforeRandom.started);
|
||
const drawn = await insGraph();
|
||
await clickHook('mutateAt');
|
||
const mutated = await waitFor(stats, (s) => s.started > randomized.started);
|
||
const moved = await insGraph();
|
||
check(
|
||
'Randomize draws the numbers anew and keeps the wave, Mutate moves some; both play at once',
|
||
randomized.started > beforeRandom.started &&
|
||
drawn.chip?.wave === laser.chip.wave &&
|
||
drawn.chip.frequency !== laser.chip.frequency &&
|
||
mutated.started > randomized.started &&
|
||
JSON.stringify([moved.chip, moved.envelope]) !== JSON.stringify([drawn.chip, drawn.envelope]),
|
||
JSON.stringify({ laser: laser.chip, drawn: drawn.chip, moved: moved.chip }),
|
||
);
|
||
|
||
const setFrequency = (hz) =>
|
||
mutate(`__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(insId)})
|
||
.graph.nodes.find((n) => n.type === 'InstrumentChip').params.frequency = ${hz}`);
|
||
await sleep(300);
|
||
const silent = await stats();
|
||
await setFrequency(300);
|
||
await sleep(500);
|
||
const unheard = await stats();
|
||
await clickHook('auditionAt');
|
||
const auditionOn = (await json('__gm.instrument.auditionAt()')).toggled;
|
||
await setFrequency(400);
|
||
const auditioned = await waitFor(stats, (s) => s.started > unheard.started);
|
||
await clickHook('auditionAt');
|
||
check(
|
||
'a change to the instrument is heard only with Audition on',
|
||
unheard.started === silent.started && auditionOn && auditioned.started > unheard.started,
|
||
JSON.stringify({
|
||
silent: silent.started,
|
||
unheard: unheard.started,
|
||
auditionOn,
|
||
auditioned: auditioned.started,
|
||
}),
|
||
);
|
||
|
||
for (let i = 0; i < 3; i++) await t.clickToolbar('undo');
|
||
const restored = await insGraph();
|
||
check(
|
||
'three undos take back Mutate, Randomize and the preset: Notes → Sample → Output again',
|
||
restored.types.join() === 'InstrumentNotes,InstrumentSample,InstrumentOutput',
|
||
JSON.stringify(restored.types),
|
||
);
|
||
|
||
// 34g-7. An instrument to play tunes on: Preset → Square Lead follows the note and holds it, and
|
||
// plays nothing. One undo takes it back.
|
||
await evaluate('__gm.sound.stopAll()');
|
||
const beforeLead = await stats();
|
||
await clickHook('presetAt');
|
||
await clickText('Square Lead');
|
||
await sleep(400);
|
||
const lead = await stats();
|
||
const leadGraph = await insGraph();
|
||
const leadInfo = await json('__gm.instrument.info()');
|
||
await t.clickToolbar('undo');
|
||
const unled = await insGraph();
|
||
check(
|
||
'Preset → Square Lead plays every note at its own pitch, holds it and plays nothing; one undo takes it back',
|
||
['Square Lead', 'Phased Pad', 'Organ', 'Bell', 'Drum Kit'].every((label) => presetMenu.includes(label)) &&
|
||
leadGraph.chip?.followNote === true &&
|
||
leadGraph.envelope?.sustain > 0 &&
|
||
lead.started === beforeLead.started &&
|
||
leadInfo === '128 notes' &&
|
||
unled.types.join() === 'InstrumentNotes,InstrumentSample,InstrumentOutput',
|
||
JSON.stringify({
|
||
chip: leadGraph.chip,
|
||
envelope: leadGraph.envelope,
|
||
started: [beforeLead.started, lead.started],
|
||
leadInfo,
|
||
unled,
|
||
}),
|
||
);
|
||
await evaluate('__gm.sound.stopAll()');
|
||
|
||
await mutate(`
|
||
const graph = __gm.state.project.assets.find((a) => a.id === 'sn-logic').graph;
|
||
graph.nodes.push(${JSON.stringify(
|
||
node('sn-note', 'PlayNote', 260, 220, { instrument: insId, note: 67, duration: 1.5 }),
|
||
)});
|
||
graph.edges.find((e) => e.id === 'sn-e1').to = { node: 'sn-note', port: 'exec' };`);
|
||
// A key still held in the Instrument tab is not part of the game: Play ends it.
|
||
const preview = await json('__gm.instrument.play(64)');
|
||
const previewHeld = await json(`__gm.sound.playing(${preview})`);
|
||
await showTab('scene');
|
||
const beforeNote = await stats();
|
||
await play();
|
||
const previewAfterPlay = await json(`__gm.sound.playing(${preview})`);
|
||
check(
|
||
'Play ends what the tabs play: a note held in the Instrument tab is gone',
|
||
preview > 0 && previewHeld && !previewAfterPlay,
|
||
JSON.stringify({ preview, previewHeld, previewAfterPlay }),
|
||
);
|
||
// The sample loops, so only the release after 1.5 s on the audio clock ends the note: it is heard
|
||
// well past the 0.25 s of the blip, never past its duration, and is gone soon after.
|
||
const noteNow = () => json('({ stats: __gm.sound.stats(), clock: __gm.sound.clock() })');
|
||
const noted = await waitFor(noteNow, (s) => s.stats.started > beforeNote.started);
|
||
const heard = await trace(noteNow, (s) => s.stats.playing === 0);
|
||
const noteAt = noted.stats.last?.at ?? 0;
|
||
const heardAt = heard.filter((s) => s.stats.playing === 1).map((s) => +(s.clock - noteAt).toFixed(3));
|
||
const goneAt = +(heard.at(-1).clock - noteAt).toFixed(3);
|
||
check(
|
||
'a game’s Play note plays G4 – the blip seven semitones up – and lets it go after its duration',
|
||
noted.stats.last?.file === `instrument:${insId}` &&
|
||
noted.stats.last.pitches.join() === '7' &&
|
||
heardAt.some((t) => t > 0.5) &&
|
||
heardAt.every((t) => t < 1.6) &&
|
||
heard.at(-1).stats.playing === 0 &&
|
||
goneAt < 3,
|
||
JSON.stringify({ last: noted.stats.last, heardAt, goneAt }),
|
||
);
|
||
await stop();
|
||
|
||
// #332: the scheduler, driven by the hook – nothing in the editor plays a sequence yet. A bar of
|
||
// quarters at 150 BPM in a loop: every note is planned before its second comes, each 0.4 s
|
||
// after the one before, also across the seam into the next round. Where the music stands
|
||
// is the audio clock less the output latency, counted from the first note.
|
||
// #366: the audio clock moves on by a render block even between two reads in one expression, so
|
||
// it is read before and after the position, and the position lies between the two.
|
||
const bar = [60, 62, 64, 65].map((pitch, i) => ({
|
||
tick: i * 480,
|
||
length: 240,
|
||
note: pitch,
|
||
velocity: 1,
|
||
instrument: insId,
|
||
}));
|
||
await evaluate(
|
||
`__gm.music.play(${JSON.stringify({ notes: bar })}, { tempo: 150, loop: { start: 0, end: 1920 } })`,
|
||
);
|
||
const planned = await waitFor(
|
||
() => json('__gm.music.planned()'),
|
||
(p) => p.length >= 9,
|
||
40,
|
||
);
|
||
const where = await json(
|
||
`({
|
||
before: __gm.sound.clock(),
|
||
position: __gm.music.position(),
|
||
after: __gm.sound.clock(),
|
||
latency: __gm.music.latency(),
|
||
})`,
|
||
);
|
||
await evaluate('__gm.music.stop()');
|
||
const musicOff = await waitFor(stats, (s) => s.playing === 0);
|
||
const apart = planned.slice(1, 9).map((p, i) => +(p.at - planned[i].at).toFixed(6));
|
||
const ahead = planned.map((p) => +(p.at - p.planned).toFixed(3));
|
||
const ticksHeardAt = (clock) => ((clock - where.latency - planned[0].at) * 150 * 480) / 60;
|
||
const heardTicks = [ticksHeardAt(where.before), ticksHeardAt(where.after)];
|
||
// How far the position lies past the first read, around the loop of 1920 ticks.
|
||
const pastBefore = (((where.position.ticks - heardTicks[0]) % 1920) + 1920) % 1920;
|
||
check(
|
||
'the scheduler plans a looped bar on the audio clock: every note ahead of its second, 0.4 s apart across the seam',
|
||
planned.length >= 9 &&
|
||
planned
|
||
.slice(0, 9)
|
||
.map((p) => p.note)
|
||
.join() === '60,62,64,65,60,62,64,65,60' &&
|
||
planned.every((p) => p.voice > 0 && Math.abs(p.duration - 0.2) < 1e-9 && p.tempo === 150) &&
|
||
apart.every((gap) => Math.abs(gap - 0.4) < 1e-6) &&
|
||
ahead.every((s) => s > 0 && s <= 0.16) &&
|
||
(pastBefore <= heardTicks[1] - heardTicks[0] + 1 || pastBefore > 1919) &&
|
||
where.position.bar === 1 &&
|
||
musicOff.playing === 0 &&
|
||
(await json('__gm.music.playing()')) === false,
|
||
JSON.stringify({ apart, ahead, where, heardTicks, pastBefore, playing: musicOff.playing }),
|
||
);
|
||
|
||
// A note planned ahead through the pitch worklet starts on its sample, also inside a render block:
|
||
// rendered offline at pitch 0, which plays the sound as it is, so the first sample heard is it.
|
||
const onsets = await evaluate(`
|
||
(async () => {
|
||
const RATE = 48000;
|
||
const onset = async (startAt) => {
|
||
const context = new OfflineAudioContext(1, RATE / 2, RATE);
|
||
await context.audioWorklet.addModule(__gm.sound.fxWorkletUrl());
|
||
const node = new AudioWorkletNode(context, 'pitch-player', {
|
||
numberOfInputs: 0,
|
||
outputChannelCount: [1],
|
||
parameterData: { pitch: 0 },
|
||
processorOptions: { file: 'onset', channels: [new Float32Array(RATE).fill(0.5)], step: 1, loop: false, startAt },
|
||
});
|
||
node.connect(context.destination);
|
||
const out = (await context.startRendering()).getChannelData(0);
|
||
return out.findIndex((v) => Math.abs(v) > 1e-6);
|
||
};
|
||
const at = [0.0123, 0.25, 0.05 + 0.4 * 2 / 3];
|
||
return { expected: at.map((s) => Math.round(s * RATE)), heard: await Promise.all(at.map(onset)) };
|
||
})()`);
|
||
check(
|
||
'a pitched note planned ahead starts on its very sample, inside a render block too',
|
||
onsets.heard.join() === onsets.expected.join() && onsets.expected.some((i) => i % 128 !== 0),
|
||
JSON.stringify(onsets),
|
||
);
|
||
|
||
// #335: an Envelope's curve as Chrome renders it – up in the attack, easing towards the sustain,
|
||
// and from wherever it is when the note ends down to silence in the release, mid-attack too –
|
||
// and a Gain it moves through a WaveShaper, from a quarter to full in equal ratios.
|
||
const curves = await evaluate(`
|
||
(async () => {
|
||
const RATE = 48000;
|
||
const envelope = { id: 'e', attack: 0.1, decay: 0.2, sustain: 0.5, release: 0.3 };
|
||
const render = async (releaseAt, moved) => {
|
||
const context = new OfflineAudioContext(1, RATE, RATE);
|
||
const driver = __gm.sound.envelopeDriver(context, envelope, 0);
|
||
if (releaseAt !== null) __gm.sound.releaseEnvelope(driver.offset, envelope, 0, releaseAt);
|
||
if (moved) {
|
||
const steady = context.createConstantSource();
|
||
const stage = { kind: 'gain', gain: 1, mod: { envelope, from: 0.25, to: 1 } };
|
||
const made = __gm.sound.stageWith(context, stage, driver);
|
||
steady.connect(made.input);
|
||
made.output.connect(context.destination);
|
||
steady.start();
|
||
} else driver.connect(context.destination);
|
||
driver.start();
|
||
const out = (await context.startRendering()).getChannelData(0);
|
||
return (s) => Math.round(out[Math.round(s * RATE)] * 10000) / 10000;
|
||
};
|
||
const held = await render(0.5, false);
|
||
const early = await render(0.05, false);
|
||
const gain = await render(null, true);
|
||
return {
|
||
held: [0.05, 0.1, 0.3, 0.5, 0.65, 0.8].map(held),
|
||
early: [0.05, 0.15, 0.35].map(early),
|
||
gain: [0.05, 0.1, 0.9].map(gain),
|
||
};
|
||
})()`);
|
||
const closeTo = (values, expected) => values.every((v, i) => Math.abs(v - expected[i]) < 0.002);
|
||
check(
|
||
'an Envelope rises, eases to its sustain and releases from where it is, and moves a Gain with that curve',
|
||
closeTo(curves.held, [0.5, 1, 0.5005, 0.5, 0.0158, 0.0005]) &&
|
||
closeTo(curves.early, [0.5, 0.05, 0.0005]) &&
|
||
closeTo(curves.gain, [0.5, 1, 0.5]),
|
||
JSON.stringify(curves),
|
||
);
|
||
|
||
// A note of 0.2 s through an Envelope with a release of 1 s: the looped blip plays on through the
|
||
// release, and the voice goes with it – without one it would end 10 ms after the note.
|
||
await mutate(`
|
||
const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(insId)}).graph;
|
||
const into = graph.edges.find((e) => e.to.node === graph.nodes.find((n) => n.type === 'InstrumentOutput').id);
|
||
graph.nodes.push(${JSON.stringify(node('ins-env', 'InstrumentEnvelope', 300, 120, { release: 1 }))});
|
||
graph.edges.push({ id: 'ins-e-env', from: { node: 'ins-env', port: 'out' }, to: { ...into.to } });
|
||
into.to = { node: 'ins-env', port: 'in' };`);
|
||
await evaluate('__gm.sound.stopAll()');
|
||
const beforeShaped = await stats();
|
||
await evaluate(
|
||
`__gm.music.play(${JSON.stringify({ notes: [{ tick: 0, length: 240, note: 60, velocity: 1, instrument: insId }] })}, { tempo: 150 })`,
|
||
);
|
||
const shaped = await waitFor(noteNow, (s) => s.stats.started > beforeShaped.started);
|
||
const ringOut = await trace(noteNow, (s) => s.stats.playing === 0);
|
||
await evaluate('__gm.music.stop()');
|
||
const shapedAt = shaped.stats.last?.at ?? 0;
|
||
const shapedGone = +(ringOut.at(-1).clock - shapedAt).toFixed(3);
|
||
// 0.2 s of note and 1 s of release; up to the 150 ms waitFor polls at on top.
|
||
check(
|
||
'a note through an Envelope plays on through its release and goes with it',
|
||
shaped.stats.last?.envelopes === 1 &&
|
||
ringOut.some((s) => s.stats.playing === 1 && s.clock - shapedAt > 0.8) &&
|
||
ringOut.at(-1).stats.playing === 0 &&
|
||
shapedGone > 1.15 &&
|
||
shapedGone < 1.6,
|
||
JSON.stringify({ last: shaped.stats.last, shapedGone }),
|
||
);
|
||
|
||
// #329: the Sample swapped for a Chip, the Envelope's sustain at 0 and its decay 0.1 s. The note of
|
||
// 0.2 s is over once the decay is, on its own – held to its end and through the release of
|
||
// 1 s it would take 1.2 s.
|
||
await mutate(`
|
||
const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(insId)}).graph;
|
||
const notes = graph.nodes.find((n) => n.type === 'InstrumentNotes');
|
||
graph.nodes.push(${JSON.stringify(node('ins-chip', 'InstrumentChip', 120, 200, { wave: 'saw', slide: -12 }))});
|
||
graph.edges.find((e) => e.to.node === 'ins-env' && e.to.port === 'in').from = { node: 'ins-chip', port: 'out' };
|
||
graph.edges.push({ id: 'ins-e-chip', from: { node: notes.id, port: 'any' }, to: { node: 'ins-chip', port: 'trigger' } });
|
||
Object.assign(graph.nodes.find((n) => n.id === 'ins-env').params, { attack: 0.005, decay: 0.1, sustain: 0 });`);
|
||
const beforeChip = await stats();
|
||
await evaluate(
|
||
`__gm.music.play(${JSON.stringify({ notes: [{ tick: 0, length: 240, note: 60, velocity: 1, instrument: insId }] })}, { tempo: 150 })`,
|
||
);
|
||
const chipNote = await waitFor(noteNow, (s) => s.stats.started > beforeChip.started);
|
||
const chipOut = await trace(noteNow, (s) => s.stats.playing === 0);
|
||
await evaluate('__gm.music.stop()');
|
||
const chipGone = +(chipOut.at(-1).clock - (chipNote.stats.last?.at ?? 0)).toFixed(3);
|
||
// 0.105 s of attack and decay; up to the 150 ms the polls take on top.
|
||
check(
|
||
'a Chip plays a note without a sound file, and an Envelope with sustain 0 ends it after its decay',
|
||
chipNote.stats.worklet === true &&
|
||
chipNote.stats.last?.file === `instrument:${insId}` &&
|
||
chipNote.stats.last?.envelopes === 1 &&
|
||
chipNote.stats.last?.duration === 0 &&
|
||
chipOut.at(-1).stats.playing === 0 &&
|
||
chipGone < 0.5,
|
||
JSON.stringify({ last: chipNote.stats.last, chipGone }),
|
||
);
|
||
|
||
await mutate(`
|
||
const graph = __gm.state.project.assets.find((a) => a.id === 'sn-logic').graph;
|
||
graph.nodes = graph.nodes.filter((n) => n.id !== 'sn-note');
|
||
graph.edges.find((e) => e.id === 'sn-e1').to = { node: 'sn-play', port: 'exec' };
|
||
const project = __gm.state.project;
|
||
project.assets = project.assets.filter((a) => a.id !== ${JSON.stringify(insId)});`);
|
||
|
||
// 34h. Dropping a sound file onto the editor uploads it and makes an asset named after the file.
|
||
const dropped = await evaluate(`
|
||
(async () => {
|
||
const bytes = await fetch('/game-maker/data/sounds/${drone.file}').then((r) => r.arrayBuffer());
|
||
const file = new File([bytes], 'drone.wav', { type: 'audio/wav' });
|
||
const data = new DataTransfer();
|
||
data.items.add(file);
|
||
const canvas = document.querySelector('canvas');
|
||
canvas.dispatchEvent(new DragEvent('drop', { dataTransfer: data, bubbles: true, cancelable: true }));
|
||
return file.size;
|
||
})()`);
|
||
await waitFor(
|
||
() => assetsOfType('sound'),
|
||
(list) => list.length === 2,
|
||
);
|
||
const both = await assetsOfType('sound');
|
||
check(
|
||
'a dropped sound is uploaded and becomes an asset named after the file',
|
||
dropped === DRONE_BYTES &&
|
||
both.length === 2 &&
|
||
both.some((a) => a.file === drone.file && a.name === 'drone'),
|
||
JSON.stringify({ dropped, both, notice: await json('__gm.state.notice') }),
|
||
);
|
||
|
||
// 34h-2. The Sound tab (#343): the drone's 0.75 s as a wave, the sound itself the whole file. Its
|
||
// clips (#357) stand as bands in a strip under the ruler: a drag there spans a new one, a
|
||
// click picks one, and the picked clip stands between two handles. A clip is played by a
|
||
// name of its own, its samples cut to the sample and ramped from silence at an edge inside
|
||
// the file. One drag is one undo step.
|
||
const droneId = both.find((a) => a.file === drone.file)?.id;
|
||
await evaluate(`__gm.state.openAsset(${JSON.stringify(droneId)})`);
|
||
const tab = () => json('__gm.soundTab.span()');
|
||
const clips = () => json('__gm.soundTab.clips()');
|
||
const picked = () => json('__gm.soundTab.pickedClip()');
|
||
await waitFor(tab, (span) => span !== null);
|
||
const tabOpened = {
|
||
current: await json('__gm.soundTab.current()'),
|
||
span: await tab(),
|
||
info: await json('__gm.soundTab.info()'),
|
||
clips: await clips(),
|
||
picked: await picked(),
|
||
reverse: await json('__gm.soundTab.reverseButton()'),
|
||
played: await json(`__gm.soundTab.played(${JSON.stringify(droneId)})`),
|
||
};
|
||
check(
|
||
'opening a sound shows its tab: the whole file, no clip, nothing to turn or fade',
|
||
tabOpened.current === droneId &&
|
||
tabOpened.span?.start === 0 &&
|
||
Math.abs(tabOpened.span?.end - 0.75) < 0.001 &&
|
||
tabOpened.info === '0.75 s' &&
|
||
tabOpened.clips.length === 0 &&
|
||
tabOpened.picked === null &&
|
||
tabOpened.reverse.enabled === false &&
|
||
tabOpened.played === drone.file,
|
||
JSON.stringify(tabOpened),
|
||
);
|
||
// 34h-2a. The wheel over the level scale left of the wave zooms its height (#357): out leaves room
|
||
// above a full-scale wave, in takes it past the top of the tab, the seconds stay where they
|
||
// are, and Fit brings back the full height.
|
||
const levelScale = await json('__gm.soundTab.scale()');
|
||
const levelRect = await json('__gm.soundTab.rect()');
|
||
const fullTop = await json('__gm.soundTab.fullScaleTop()');
|
||
const secondAt = (await json('__gm.soundTab.at(0.5)')).x;
|
||
await t.wheel(levelScale.x, levelScale.y, 924);
|
||
const zoomedOut = {
|
||
level: await json('__gm.soundTab.level()'),
|
||
top: await json('__gm.soundTab.fullScaleTop()'),
|
||
secondAt: (await json('__gm.soundTab.at(0.5)')).x,
|
||
};
|
||
await t.wheel(levelScale.x, levelScale.y, -1848);
|
||
const zoomedIn = {
|
||
level: await json('__gm.soundTab.level()'),
|
||
top: await json('__gm.soundTab.fullScaleTop()'),
|
||
};
|
||
const levelFit = await json('__gm.soundTab.fitButton()');
|
||
await click(levelFit.x, levelFit.y);
|
||
await sleep(100);
|
||
const refitted = {
|
||
level: await json('__gm.soundTab.level()'),
|
||
top: await json('__gm.soundTab.fullScaleTop()'),
|
||
};
|
||
check(
|
||
'the wheel over the level scale zooms the height alone, and Fit puts it back',
|
||
Math.abs(zoomedOut.level - 0.25) < 0.01 &&
|
||
Math.abs(zoomedOut.top - levelScale.y - (fullTop - levelScale.y) * 0.25) < 1 &&
|
||
zoomedOut.secondAt === secondAt &&
|
||
Math.abs(zoomedIn.level - 4) < 0.05 &&
|
||
zoomedIn.top < levelRect.y &&
|
||
refitted.level === 1 &&
|
||
Math.abs(refitted.top - fullTop) < 0.5,
|
||
JSON.stringify({ levelScale, levelRect, fullTop, secondAt, zoomedOut, zoomedIn, refitted }),
|
||
);
|
||
// #377: at 1000 ms a frame after the wheel the zoom is on its way, on the level scale and on the wave alike.
|
||
const waveZoom = (key) => `({ scale: __gm.soundTab.zoom().${key}, moving: __gm.soundTab.zoom().moving })`;
|
||
const waveLevel = await sampleZoom(t, levelScale, waveZoom('level'));
|
||
const waveTime = await sampleZoom(t, await json('__gm.soundTab.at(0.5)'), waveZoom('pxPerSecond'));
|
||
check(
|
||
'a zoom of the height and of the time is shown on its way and lands where the jump does',
|
||
waveLevel.ok && waveTime.ok,
|
||
`${waveLevel.detail} ${waveTime.detail}`,
|
||
);
|
||
// 34h-2b. A pan moves the wave drawn already and leaves each ruler label on its second (#360):
|
||
// drawing both anew on every move took the frames down.
|
||
const panFrom = await json('__gm.soundTab.at(0.375)');
|
||
await t.wheel(panFrom.x, panFrom.y, -924);
|
||
const panInfo = async () => ({
|
||
...(await json('__gm.soundTab.drawInfo()')),
|
||
at: (await json('__gm.soundTab.at(0.375)')).x,
|
||
});
|
||
const panBefore = await panInfo();
|
||
await t.mouse('mouseMoved', panFrom.x, panFrom.y);
|
||
await t.mouse('mousePressed', panFrom.x, panFrom.y, 'middle');
|
||
for (let i = 1; i <= 8; i++) {
|
||
await t.mouse('mouseMoved', panFrom.x - 30 * i, panFrom.y, 'middle', { buttons: 4 });
|
||
await sleep(20);
|
||
}
|
||
await t.mouse('mouseReleased', panFrom.x - 240, panFrom.y, 'middle');
|
||
await sleep(100);
|
||
const panAfter = await panInfo();
|
||
const panMoved = panAfter.at - panBefore.at;
|
||
await click(levelFit.x, levelFit.y);
|
||
await sleep(100);
|
||
check(
|
||
'a pan moves the wave drawn already, and the ruler labels keep their seconds',
|
||
Math.abs(panMoved + 240) < 1 &&
|
||
panAfter.waveDraws === panBefore.waveDraws &&
|
||
Math.abs(panAfter.shift - panBefore.shift - panMoved) < 0.5 &&
|
||
panAfter.rulerTexts - panBefore.rulerTexts <= 2,
|
||
JSON.stringify({ panBefore, panAfter, panMoved }),
|
||
);
|
||
const clipProps = async () => (await clips())[0]?.props;
|
||
let dragFrom = await json('__gm.soundTab.stripAt(0.1)');
|
||
let dragTo = await json('__gm.soundTab.stripAt(0.3)');
|
||
await t.drag(dragFrom.x, dragFrom.y, dragTo.x, dragTo.y);
|
||
const spanned = { clips: await clips(), picked: await picked(), info: await json('__gm.soundTab.info()') };
|
||
const clipId = spanned.clips[0]?.id;
|
||
check(
|
||
'a drag in the strip spans a new clip, "Clip 1", picked, and the sound stays the whole file',
|
||
spanned.clips.length === 1 &&
|
||
spanned.clips[0].name === 'Clip 1' &&
|
||
Math.abs(spanned.clips[0].props.start - 0.1) < 0.01 &&
|
||
Math.abs(spanned.clips[0].props.end - 0.3) < 0.01 &&
|
||
spanned.picked === clipId &&
|
||
spanned.info ===
|
||
`${(spanned.clips[0].props.end - spanned.clips[0].props.start).toFixed(2)} s of 0.75 s` &&
|
||
(await json(`__gm.soundTab.played(${JSON.stringify(droneId)})`)) === drone.file,
|
||
JSON.stringify(spanned),
|
||
);
|
||
await press('z', { ctrl: true });
|
||
const undoneClip = { clips: await clips(), picked: await picked() };
|
||
await press('z', { ctrl: true, shift: true });
|
||
const redoneClip = await clips();
|
||
const beside = await json('__gm.soundTab.stripAt(0.5)');
|
||
await click(beside.x, beside.y);
|
||
const pickedBeside = await picked();
|
||
const faintBeside = await json('__gm.soundTab.faintClips()');
|
||
await shot('34-sound-faint-clip');
|
||
const onBand = await json('__gm.soundTab.stripAt(0.2)');
|
||
await click(onBand.x, onBand.y);
|
||
const faintPicked = await json('__gm.soundTab.faintClips()');
|
||
check(
|
||
'one undo takes the clip away, a redo brings it back; a click in the strip picks it, beside it the sound, the clip then faint over the wave',
|
||
undoneClip.clips.length === 0 &&
|
||
undoneClip.picked === null &&
|
||
redoneClip.map((c) => c.id).join() === clipId &&
|
||
pickedBeside === null &&
|
||
faintBeside.join() === 'Clip 1' &&
|
||
faintPicked.length === 0 &&
|
||
(await picked()) === clipId,
|
||
JSON.stringify({
|
||
undoneClip,
|
||
redoneClip,
|
||
pickedBeside,
|
||
faintBeside,
|
||
faintPicked,
|
||
picked: await picked(),
|
||
}),
|
||
);
|
||
const inClip = await json('__gm.soundTab.at(0.2)');
|
||
await click(inClip.x, inClip.y);
|
||
const pickedInClip = await picked();
|
||
const besideClip = await json('__gm.soundTab.at(0.6)');
|
||
await click(besideClip.x, besideClip.y);
|
||
const pickedBesideClip = await picked();
|
||
await click(onBand.x, onBand.y);
|
||
check(
|
||
'a click over the wave inside the picked clip keeps it, beside it picks the sound',
|
||
pickedInClip === clipId && pickedBesideClip === null && (await picked()) === clipId,
|
||
JSON.stringify({ pickedInClip, pickedBesideClip, picked: await picked() }),
|
||
);
|
||
dragFrom = await json('__gm.soundTab.at(0.3)');
|
||
dragTo = await json('__gm.soundTab.at(0.5)');
|
||
await t.mouse('mouseMoved', dragFrom.x - 1, dragFrom.y);
|
||
await t.mouse('mouseMoved', dragFrom.x, dragFrom.y);
|
||
const onHandle = await json('__gm.soundTab.cursor()');
|
||
await t.drag(dragFrom.x, dragFrom.y, dragTo.x, dragTo.y);
|
||
const lengthened = await clipProps();
|
||
await press('z', { ctrl: true });
|
||
check(
|
||
'dragging the end handle moves that edge of the picked clip, the handle showing it can be dragged',
|
||
onHandle === 'ew-resize' &&
|
||
Math.abs(lengthened?.start - 0.1) < 0.01 &&
|
||
Math.abs(lengthened?.end - 0.5) < 0.01 &&
|
||
Math.abs((await clipProps())?.end - 0.3) < 0.01,
|
||
JSON.stringify({ onHandle, lengthened, undone: await clipProps() }),
|
||
);
|
||
const clipCut = await clipProps();
|
||
const cutPlayed = await json(`__gm.soundTab.played(${JSON.stringify(clipId)})`);
|
||
const cutSamples = await evaluate(`__gm.soundTab.samples(${JSON.stringify(cutPlayed)})`);
|
||
const keptSamples = clipCut ? Math.round((clipCut.end - clipCut.start) * (cutSamples?.rate ?? 0)) : -1;
|
||
check(
|
||
'the clip is played by a name of its own, cut to the sample',
|
||
cutPlayed === `${drone.file}#${clipCut?.start}~${clipCut?.end}~0.005~0.005~linear~linear` &&
|
||
cutSamples?.length === keptSamples,
|
||
JSON.stringify({ clipCut, cutPlayed, cutSamples, keptSamples }),
|
||
);
|
||
check(
|
||
'the cut rises from silence and falls back to it, the default 5 ms at each edge inside the file',
|
||
cutSamples?.head[0] === 0 && Math.abs(cutSamples?.tail.at(-1)) < 0.01,
|
||
JSON.stringify(cutSamples),
|
||
);
|
||
// 34h-ii. Each edge of the picked clip has a fade of its own, each shaped by its own curve (#345).
|
||
const tabFadeOut = await json('__gm.soundTab.fadeOutField()');
|
||
await click(tabFadeOut.x, tabFadeOut.y);
|
||
await t.typeText('40');
|
||
await press('Enter');
|
||
await sleep(150);
|
||
const curveOutButton = await json('__gm.soundTab.curveOutButton()');
|
||
await click(curveOutButton.x, curveOutButton.y);
|
||
await clickText('Exponential');
|
||
await sleep(150);
|
||
const curved = await json(`__gm.soundTab.played(${JSON.stringify(clipId)})`);
|
||
const curvedSamples = await evaluate(`__gm.soundTab.samples(${JSON.stringify(curved)})`);
|
||
check(
|
||
'the header sets the fade out and its curve on their own, the fade in staying Linear',
|
||
(await clipProps())?.fadeOut === 40 &&
|
||
(await clipProps())?.fadeOutCurve === 'exponential' &&
|
||
((await clipProps())?.fadeInCurve ?? 'linear') === 'linear' &&
|
||
(await json('__gm.soundTab.curveOutButton()')).tooltip === 'Fade out curve: Exponential' &&
|
||
(await json('__gm.soundTab.curveInButton()')).tooltip === 'Fade in curve: Linear' &&
|
||
curved.endsWith('~0.005~0.04~linear~exponential'),
|
||
JSON.stringify({ props: await clipProps(), curved }),
|
||
);
|
||
await press('z', { ctrl: true });
|
||
await press('z', { ctrl: true });
|
||
const tabFadeIn = await json('__gm.soundTab.fadeInField()');
|
||
await click(tabFadeIn.x, tabFadeIn.y);
|
||
await t.typeText('0');
|
||
await press('Enter');
|
||
const fadeOutField = await json('__gm.soundTab.fadeOutField()');
|
||
await click(fadeOutField.x, fadeOutField.y);
|
||
await t.typeText('0');
|
||
await press('Enter');
|
||
await sleep(150);
|
||
const unfaded = await json(`__gm.soundTab.played(${JSON.stringify(clipId)})`);
|
||
check(
|
||
'after two undos the curve is Linear again, and both fades go to 0 in the header',
|
||
(await clipProps())?.fadeIn === 0 &&
|
||
(await clipProps())?.fadeOut === 0 &&
|
||
((await clipProps())?.fadeOutCurve ?? 'linear') === 'linear' &&
|
||
unfaded.endsWith('~0~0~linear~linear'),
|
||
JSON.stringify({ props: await clipProps(), unfaded }),
|
||
);
|
||
// The same stretch unfaded is the drone itself: against it, the i-th sample before the end of the
|
||
// exponential fall is (i / ramp)² of it, where a straight line would leave i / ramp.
|
||
const rawSamples = await evaluate(`__gm.soundTab.samples(${JSON.stringify(unfaded)})`);
|
||
const outRamp = Math.round(0.04 * (rawSamples?.rate ?? 0));
|
||
const fall = [1, 2, 3]
|
||
.map((i) => ({ i, raw: rawSamples?.tail.at(-1 - i), curved: curvedSamples?.tail.at(-1 - i) }))
|
||
.filter((s) => Math.abs(s.raw) > 1e-3);
|
||
check(
|
||
'the end falls along the curve: (i / ramp)² of the drone on an exponential one',
|
||
curvedSamples?.tail.at(-1) === 0 &&
|
||
fall.length > 0 &&
|
||
fall.every((s) => Math.abs(s.curved / s.raw / (s.i / outRamp) ** 2 - 1) < 1e-3),
|
||
JSON.stringify({ fall, outRamp }),
|
||
);
|
||
await evaluate('__gm.sound.stopAll()');
|
||
const tabPlay = await json('__gm.soundTab.playButton()');
|
||
await click(tabPlay.x, tabPlay.y);
|
||
const tabHeard = await waitFor(stats, (s) => s.last?.file === unfaded);
|
||
const tabPlayhead = await waitFor(
|
||
() => json('__gm.soundTab.playhead()'),
|
||
(seconds) => seconds !== null && seconds > 0.1,
|
||
10,
|
||
);
|
||
const tabEnded = await waitFor(
|
||
() => json('__gm.soundTab.playing()'),
|
||
(on) => on === false,
|
||
);
|
||
check(
|
||
'Play plays only the picked clip, its playhead running inside it, and stops when it ends',
|
||
tabHeard.last?.file === unfaded &&
|
||
Math.abs(tabHeard.last?.duration - 0.2) < 0.01 &&
|
||
tabPlayhead !== null &&
|
||
tabPlayhead >= 0.1 &&
|
||
tabPlayhead <= 0.3 &&
|
||
tabEnded === false &&
|
||
(await json('__gm.soundTab.playhead()')) === null,
|
||
JSON.stringify({ last: tabHeard.last, tabPlayhead, tabEnded }),
|
||
);
|
||
// 34h-iii. Reverse plays the picked clip backwards, the playhead running from its end (#321).
|
||
const reverseButton = await json('__gm.soundTab.reverseButton()');
|
||
await click(reverseButton.x, reverseButton.y);
|
||
await sleep(150);
|
||
const reversed = await json(`__gm.soundTab.played(${JSON.stringify(clipId)})`);
|
||
const reversedSamples = await evaluate(`__gm.soundTab.samples(${JSON.stringify(reversed)})`);
|
||
check(
|
||
'Reverse turns the clip around: its first samples are the last of the clip played forwards',
|
||
(await clipProps())?.reverse === true &&
|
||
(await json('__gm.soundTab.reverseButton()')).on === true &&
|
||
reversed === `${unfaded}~reverse` &&
|
||
reversedSamples?.length === rawSamples?.length &&
|
||
reversedSamples?.head.every((v, i) => v === rawSamples?.tail.at(-1 - i)),
|
||
JSON.stringify({ reversed, head: reversedSamples?.head, tail: rawSamples?.tail }),
|
||
);
|
||
// Over the whole file (0.75 s) the playhead has time for several readings: the handles are dragged
|
||
// past both ends of it.
|
||
for (const [from, to] of [
|
||
[0.1, -0.1],
|
||
[0.3, 0.9],
|
||
]) {
|
||
const a = await json(`__gm.soundTab.at(${from})`);
|
||
const b = await json(`__gm.soundTab.at(${to})`);
|
||
await t.drag(a.x, a.y, b.x, b.y);
|
||
}
|
||
const whole = await clipProps();
|
||
await click(tabPlay.x, tabPlay.y);
|
||
await waitFor(
|
||
() => json('__gm.soundTab.playhead()'),
|
||
(seconds) => seconds !== null,
|
||
10,
|
||
);
|
||
const backwards = (
|
||
await trace(
|
||
() => json('__gm.soundTab.playhead()'),
|
||
(seconds) => seconds === null,
|
||
)
|
||
).filter((seconds) => seconds !== null);
|
||
check(
|
||
'played backwards, the playhead runs from the end of the clip towards its start',
|
||
whole?.reverse === true &&
|
||
whole?.start === 0 &&
|
||
whole?.end === 0 &&
|
||
backwards.length >= 2 &&
|
||
backwards.every((seconds) => seconds >= 0 && seconds <= 0.75) &&
|
||
backwards.every((seconds, i) => i === 0 || seconds <= backwards[i - 1]) &&
|
||
backwards.at(-1) < backwards[0],
|
||
JSON.stringify({ whole, backwards }),
|
||
);
|
||
await shot('34-sound-tab');
|
||
// 34h-iv. A sound field offers the clip after its sound, as "drone / Clip 1".
|
||
await select({ kind: 'node', assetId: 'sn-logic', nodeId: 'sn-play' });
|
||
// The row is named after the port, as the node body names it.
|
||
const soundRow = await json("__gm.inspectorField('sound', true)");
|
||
await click(soundRow.x, soundRow.y);
|
||
const soundOffered = await json('__gm.overlayTexts()');
|
||
await clickText('drone / Clip 1');
|
||
await sleep(150);
|
||
const playNode = async () =>
|
||
(await json("__gm.state.project.assets.find((a) => a.id === 'sn-logic').graph")).nodes.find(
|
||
(n) => n.id === 'sn-play',
|
||
);
|
||
check(
|
||
'a sound field offers each clip after its sound, and picking one names the clip',
|
||
soundOffered.indexOf('drone / Clip 1') === soundOffered.indexOf('drone') + 1 &&
|
||
(await playNode())?.params.sound === clipId,
|
||
JSON.stringify({ soundOffered, node: await playNode() }),
|
||
);
|
||
// 34h-v. Delete takes the picked clip away, and the sound stays.
|
||
await evaluate(
|
||
`__gm.state.select({ kind: 'clip', assetId: ${JSON.stringify(droneId)}, clipId: ${JSON.stringify(clipId)} })`,
|
||
);
|
||
await waitFor(picked, (id) => id === clipId);
|
||
await press('Delete');
|
||
check(
|
||
'Delete takes the picked clip away, the sound staying on screen',
|
||
(await clips()).length === 0 &&
|
||
(await picked()) === null &&
|
||
(await json('__gm.soundTab.current()')) === droneId,
|
||
JSON.stringify({ clips: await clips(), current: await json('__gm.soundTab.current()') }),
|
||
);
|
||
|
||
// 34h-3. Buses and the master (#325), routed in the Master routing tab (#351). "+ → Bus" makes
|
||
// one; the tab has a node for it and for the sound, each linked to the master. Links drawn
|
||
// there and one deleted send the sound into a bus and that bus into the other; the Sound
|
||
// tab still plays it on the master (#368). A click on a node shows its asset, on the
|
||
// master the master's volume.
|
||
for (let i = 0; i < 2; i++) {
|
||
await clickText('+');
|
||
await pickAssetType(t, 'Bus');
|
||
await sleep(150);
|
||
}
|
||
const [busA, busB] = await assetsOfType('bus');
|
||
await t.clickToolbar('menu');
|
||
await clickText('Master routing');
|
||
await sleep(300);
|
||
const routing = () => json('__gm.routing.graph()');
|
||
const links = (graph) => graph.edges.map((e) => `${e.from.node}>${e.to.node}`);
|
||
const opened = { shown: await json('__gm.routing.shown()'), graph: await routing() };
|
||
const labels = await json(
|
||
`[${JSON.stringify(droneId)}, ${JSON.stringify(busA.id)}, '#master'].map((id) => __gm.routing.label(id))`,
|
||
);
|
||
await select({ kind: 'asset', assetId: droneId });
|
||
const busRow = await json("__gm.inspectorField('Bus', true)");
|
||
await select({ kind: 'asset', assetId: busA.id });
|
||
const intoRow = await json("__gm.inspectorField('Into', true)");
|
||
check(
|
||
'Menu → Master routing has a node per sound and bus and the master, each linked to the master; no Bus or Into rows',
|
||
opened.shown &&
|
||
[droneId, busA.id, busB.id, '#master'].every((id) => opened.graph.nodes.some((n) => n.id === id)) &&
|
||
[droneId, busA.id, busB.id].every((id) => links(opened.graph).includes(`${id}>#master`)) &&
|
||
JSON.stringify(labels) === JSON.stringify(['drone', busA.name, 'Master']) &&
|
||
[busA, busB].every((bus) => bus.props.volume === 1 && bus.props.fx === '' && !('into' in bus.props)) &&
|
||
busRow === null &&
|
||
intoRow === null,
|
||
JSON.stringify({ opened, labels, busRow, intoRow, busA, busB }),
|
||
);
|
||
|
||
const portOf = (nodeId, key, isOutput) =>
|
||
json(`__gm.routing.portAt(${JSON.stringify(nodeId)}, ${JSON.stringify(key)}, ${isOutput})`);
|
||
const link = async (from, to) => {
|
||
const a = await portOf(from, 'out', true);
|
||
const b = await portOf(to, 'in', false);
|
||
await drag(a.x, a.y, b.x, b.y);
|
||
await sleep(200);
|
||
};
|
||
// The link is picked by the model: its line may run behind a node, as lines do.
|
||
const unlink = async (from, to) => {
|
||
const edge = (await routing()).edges.find((e) => e.from.node === from && e.to.node === to);
|
||
await select({ kind: 'edge', assetId: '#routing', edgeId: edge.id, edgeIds: [edge.id] });
|
||
await press('Delete');
|
||
await sleep(150);
|
||
};
|
||
await link(droneId, busB.id);
|
||
await link(busB.id, busA.id);
|
||
await unlink(droneId, '#master');
|
||
await unlink(busB.id, '#master');
|
||
await shot('h3-routing');
|
||
await mutate(`
|
||
__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(busA.id)}).props.volume = 0.5;`);
|
||
await evaluate(`__gm.state.openAsset(${JSON.stringify(droneId)})`);
|
||
// The preview of a tab leaves the routing out (#368): heard on the master, the buses stay silent.
|
||
const tapped = JSON.stringify(['mix', `mix:${busB.id}`]);
|
||
await json(`__gm.routing.peaks(${tapped})`);
|
||
const startedBefore = (await stats()).started;
|
||
await click(tabPlay.x, tabPlay.y);
|
||
await waitFor(stats, (s) => s.started > startedBefore, 10);
|
||
await sleep(300);
|
||
const buses = await json('__gm.sound.buses()');
|
||
const heardOn = (await stats()).last?.bus;
|
||
const peak = await waitFor(
|
||
() => json(`__gm.routing.peaks(${tapped})`),
|
||
(peaks) => Math.max(...(peaks.mix ?? [0])) > 0.01,
|
||
);
|
||
await click(tabPlay.x, tabPlay.y);
|
||
const levelOf = (name) => buses.find((bus) => bus.name === name);
|
||
check(
|
||
'linked in the routing, the mix sends the sound’s bus into the other; the Sound tab plays it on the master, past both',
|
||
links(await routing())
|
||
.filter((l) => l.startsWith(droneId) || l.startsWith(busB.id))
|
||
.join() === `${droneId}>${busB.id},${busB.id}>${busA.id}` &&
|
||
heardOn === 'mix' &&
|
||
levelOf(`mix:${busB.id}`)?.into === `mix:${busA.id}` &&
|
||
levelOf(`mix:${busA.id}`)?.into === 'mix' &&
|
||
levelOf(`mix:${busA.id}`)?.gain === 0.5 &&
|
||
levelOf('mix')?.into === null &&
|
||
Math.max(...(peak?.mix ?? [0])) > 0.01 &&
|
||
Math.max(...(peak?.[`mix:${busB.id}`] ?? [0])) < 0.001,
|
||
JSON.stringify({ links: links(await routing()), heardOn, buses, peak }),
|
||
);
|
||
|
||
await t.clickToolbar('menu');
|
||
await clickText('Master routing');
|
||
await sleep(200);
|
||
let at = await json(`__gm.routing.nodeAt(${JSON.stringify(busA.id)})`);
|
||
await click(at.x, at.y);
|
||
await sleep(200);
|
||
const busShown = await json("__gm.inspectorField('Volume', true)");
|
||
at = await json("__gm.routing.nodeAt('#master')");
|
||
await click(at.x, at.y);
|
||
await sleep(200);
|
||
await shot('h3-master');
|
||
const masterField = await json("__gm.inspectorField('Volume', true)");
|
||
await click(masterField.x, masterField.y);
|
||
await press('a', { ctrl: true });
|
||
await t.typeText('0.6');
|
||
await press('Enter');
|
||
await sleep(150);
|
||
const masterSet = await json('__gm.state.project.masterVolume');
|
||
await t.clickToolbar('undo');
|
||
await sleep(150);
|
||
const masterShown = { set: masterSet, undone: await json('__gm.state.project.masterVolume') };
|
||
at = await json(`__gm.routing.nodeAt(${JSON.stringify(droneId)})`);
|
||
await click(at.x, at.y);
|
||
await click(at.x, at.y);
|
||
await sleep(300);
|
||
check(
|
||
'a click on a node shows its asset in the inspector, on the master the master volume, a double click opens the sound',
|
||
busShown !== null &&
|
||
masterShown.set === 0.6 &&
|
||
masterShown.undone === 1 &&
|
||
(await json('__gm.soundTab.current()')) === droneId &&
|
||
(await json('__gm.routing.shown()')) === false,
|
||
JSON.stringify({ busShown, masterShown, current: await json('__gm.soundTab.current()') }),
|
||
);
|
||
|
||
await t.clickToolbar('menu');
|
||
await clickText('Project settings…');
|
||
const masterAt = await json('__gm.projectSettingsMasterAt()');
|
||
await click(masterAt.volume.x, masterAt.volume.y);
|
||
await press('a', { ctrl: true });
|
||
await t.typeText('0.6');
|
||
await press('Enter');
|
||
await clickText('Apply');
|
||
await sleep(200);
|
||
const mastered = {
|
||
project: await json('__gm.state.project.masterVolume'),
|
||
heard: await json("__gm.sound.mixVolume('mix')"),
|
||
};
|
||
await t.clickToolbar('undo');
|
||
await sleep(200);
|
||
const undone = {
|
||
project: await json('__gm.state.project.masterVolume'),
|
||
heard: await json("__gm.sound.mixVolume('mix')"),
|
||
};
|
||
check(
|
||
'the master volume is a project setting the mix follows, and undo gives the old one back',
|
||
mastered.project === 0.6 && mastered.heard === 0.6 && undone.project === 1 && undone.heard === 1,
|
||
JSON.stringify({ masterAt, mastered, undone }),
|
||
);
|
||
// The routing starts again from the assets with the next edit: everything on the master.
|
||
await mutate(`
|
||
const p = __gm.state.project;
|
||
p.assets = p.assets.filter((a) => a.type !== 'bus');
|
||
p.routing = { nodes: [], edges: [] };`);
|
||
|
||
// 34h-4. A long sound streamed (#349): five minutes of mono, decoded whole some 55 MB. With Stream
|
||
// on, the editor makes a copy of it as Opus and a game plays from that, through its bus and
|
||
// the master; the renderer grows by a few megabytes while it plays, not by the decoded
|
||
// file. The counter-proof right after turns Stream off: decoded, it grows by about that much.
|
||
const long = await uploadSound(t.baseUrl, sineWav(300, 330), 'long.wav');
|
||
// The tab's renderer is by far the largest. Summed, a renderer Chrome starts meanwhile counted too.
|
||
const renderers = () =>
|
||
execSync('ps -eo rss,args')
|
||
.toString()
|
||
.split('\n')
|
||
.filter((line) => line.includes('--type=renderer'))
|
||
.map((line) => Number(line.trim().split(/\s+/)[0]) / 1024);
|
||
const rendererMb = () => Math.max(...renderers());
|
||
const restingMb = async () => {
|
||
await t.cdp.send('HeapProfiler.collectGarbage');
|
||
await sleep(1500);
|
||
return rendererMb();
|
||
};
|
||
// What the game runs: On start plays the long sound, or a song.
|
||
const startWith = (played) =>
|
||
mutate(`
|
||
__gm.state.project.assets.find((a) => a.id === 'sn-logic').graph = ${JSON.stringify({
|
||
nodes: [node('sn-start', 'OnStart', 0, 0), played],
|
||
edges: [edge('sn-e1', 'sn-start', 'exec', played.id, 'exec')],
|
||
})};`);
|
||
const playsLong = node('sn-play', 'PlaySound', 260, 0, { sound: 'sn-long', volume: 1, loop: '' });
|
||
await evaluate('__gm.sound.stopAll()');
|
||
await mutate(`
|
||
const p = __gm.state.project;
|
||
p.assets.push(
|
||
{ id: 'sn-long', type: 'sound', name: 'long', props: { volume: 1, loop: true, fx: '', stream: true }, file: ${JSON.stringify(long.file)} },
|
||
{ id: 'sn-bus', type: 'bus', name: 'Music', props: { volume: 1, fx: '' } },
|
||
);
|
||
p.routing = {
|
||
nodes: [
|
||
{ id: 'sn-long', type: 'RoutedAsset', x: 0, y: 0, params: { name: 'long', kind: 'sound' } },
|
||
{ id: 'sn-bus', type: 'RoutedAsset', x: 320, y: 0, params: { name: 'Music', kind: 'bus' } },
|
||
{ id: '#master', type: 'RoutedMaster', x: 640, y: -80, params: {} },
|
||
],
|
||
edges: [
|
||
{ id: 'sn-r1', from: { node: 'sn-long', port: 'out' }, to: { node: 'sn-bus', port: 'in' } },
|
||
{ id: 'sn-r2', from: { node: 'sn-bus', port: 'out' }, to: { node: '#master', port: 'in' } },
|
||
],
|
||
};`);
|
||
await startWith(playsLong);
|
||
await evaluate('__gm.streams.idle()');
|
||
const copies = await json("__gm.streams.copies('sn-long')");
|
||
const streamed = (await json('__gm.streams.table()')).find((entry) => entry.name === long.file);
|
||
check(
|
||
'with Stream on the editor makes a copy of the long sound as Opus, five minutes to the sample, and a game streams it from there',
|
||
copies?.sound?.from === long.file &&
|
||
copies.sound.frames === 300 * 48000 &&
|
||
copies.sound.bytes > 0 &&
|
||
streamed?.file === copies.sound.file &&
|
||
streamed.frames === 300 * 48000,
|
||
JSON.stringify({ long, copies, streamed }),
|
||
);
|
||
|
||
const statsBefore = await stats();
|
||
const decodedBefore = statsBefore.decoded;
|
||
const restStreamed = await restingMb();
|
||
const renderersBefore = renderers();
|
||
await play();
|
||
const tapsLong = JSON.stringify(['mix', 'mix:sn-bus']);
|
||
await json(`__gm.routing.peaks(${tapsLong})`);
|
||
const streaming = await waitFor(stats, (s) => s.streaming.playing > 0, 40);
|
||
const heardLong = await waitFor(
|
||
() => json(`__gm.routing.peaks(${tapsLong})`),
|
||
(peaks) => Math.min(Math.max(...(peaks.mix ?? [0])), Math.max(...(peaks['mix:sn-bus'] ?? [0]))) > 0.01,
|
||
);
|
||
await sleep(1000);
|
||
// After a garbage collection as at rest: play mode makes garbage of its own (#378, CI task 614).
|
||
const playingStreamed = await restingMb();
|
||
const renderersPlaying = renderers();
|
||
const heldStreamed = await stats();
|
||
await t.clickToolbar('pause');
|
||
await sleep(500);
|
||
const paused = { clock: await json('__gm.sound.clock()'), state: await json('__gm.sound.clockState()') };
|
||
await sleep(500);
|
||
const pausedLater = await json('__gm.sound.clock()');
|
||
await t.clickToolbar('pause');
|
||
await sleep(1000);
|
||
const resumedLong = await stats();
|
||
await stop();
|
||
const stoppedLong = await stats();
|
||
check(
|
||
'played, the streamed sound is heard on its bus and the master, none of the file decoded',
|
||
streaming?.last?.file === long.file &&
|
||
streaming.last.bus === 'mix:sn-bus' &&
|
||
streaming.decoded === decodedBefore &&
|
||
Math.max(...(heardLong?.mix ?? [0])) > 0.01,
|
||
JSON.stringify({ streaming, heardLong }),
|
||
);
|
||
check(
|
||
'the pause holds the stream where it is, it plays on after it without a sample lost, and stopping ends it',
|
||
paused.state === 'suspended' &&
|
||
pausedLater === paused.clock &&
|
||
resumedLong.streaming.playing === 1 &&
|
||
resumedLong.streaming.skipped === 0 &&
|
||
stoppedLong.streaming.playing === 0 &&
|
||
stoppedLong.playing === 0,
|
||
JSON.stringify({ paused, pausedLater, resumedLong: resumedLong.streaming, stoppedLong }),
|
||
);
|
||
|
||
// The counter-proof: Stream off, the game plays it decoded, and the renderer holds all of it.
|
||
// Measured from before it is switched off, as from then on the editor decodes it ahead, and before
|
||
// anything below has had large buffers made: Chrome does not hand all of that back at once.
|
||
await evaluate('__gm.sound.stopAll()');
|
||
const restDecoded = await restingMb();
|
||
await mutate(`__gm.state.project.assets.find((a) => a.id === 'sn-long').props.stream = false;`);
|
||
await play();
|
||
const decodedNow = await waitFor(stats, (s) => s.playing > 0 && s.last?.file === long.file, 60);
|
||
await sleep(1000);
|
||
const playingDecoded = await restingMb();
|
||
await stop();
|
||
await evaluate('__gm.streams.idle()');
|
||
check(
|
||
'with Stream off the same sound is decoded whole, and the renderer grows by more than 40 MB',
|
||
decodedNow?.streaming.playing === 0 &&
|
||
decodedNow.decoded > decodedBefore &&
|
||
playingDecoded - restDecoded > 40 &&
|
||
(await json("__gm.streams.copies('sn-long')"))?.sound === null,
|
||
JSON.stringify({ restDecoded, playingDecoded, decodedNow }),
|
||
);
|
||
// What the sound library holds of it, counted by the library: streamed at most 4 s sent ahead on top
|
||
// of what it held before (its head among the heads of every copy), decoded all five minutes: 50 MB
|
||
// at 44.1 kHz.
|
||
// Not the growth of the renderer: after the modules before this one it grew by 25 to 66 MB while
|
||
// streaming, alone by 1 to 8 (#378, CI tasks 611 to 617). That growth stays in the log.
|
||
const mb = (bytes) => bytes / 2 ** 20;
|
||
const kept = {
|
||
before: mb(statsBefore.streaming.held),
|
||
streamed: mb(heldStreamed.streaming.held - statsBefore.streaming.held),
|
||
streamedWhole: mb(heldStreamed.decodedBytes - statsBefore.decodedBytes),
|
||
decodedWhole: mb((decodedNow?.decodedBytes ?? 0) - statsBefore.decodedBytes),
|
||
};
|
||
check(
|
||
'streamed, the page holds a few seconds of its samples and none decoded whole, decoded all five minutes',
|
||
kept.streamed > 0 && kept.streamed < 4 && kept.streamedWhole === 0 && kept.decodedWhole > 40,
|
||
JSON.stringify({
|
||
kept,
|
||
grownStreamed: playingStreamed - restStreamed,
|
||
grownDecoded: playingDecoded - restDecoded,
|
||
renderersBefore,
|
||
renderersPlaying,
|
||
}),
|
||
);
|
||
|
||
// The Sound tab points a long sound at the switch, and says once it streams.
|
||
await evaluate("__gm.state.openAsset('sn-long')");
|
||
await sleep(300);
|
||
const longInfo = await json('__gm.soundTab.info()');
|
||
await mutate(`__gm.state.project.assets.find((a) => a.id === 'sn-long').props.stream = true;`);
|
||
await evaluate('__gm.streams.idle()');
|
||
await sleep(200);
|
||
const streamedInfo = await json('__gm.soundTab.info()');
|
||
check(
|
||
'the Sound tab suggests Stream for a sound of a minute or more, and says when it is streamed',
|
||
longInfo === '300.00 s · long, try Stream' && streamedInfo === '300.00 s · streamed',
|
||
JSON.stringify({ longInfo, streamedInfo }),
|
||
);
|
||
// On a track of a song it starts on its beat, a clip of its own streamed from a copy of its cut.
|
||
await mutate(`
|
||
const sound = __gm.state.project.assets.find((a) => a.id === 'sn-long');
|
||
sound.clips = [{ id: 'sn-long-clip', name: 'Middle', props: { start: 10, end: 20, fadeIn: 5, fadeOut: 5, fadeInCurve: 'linear', fadeOutCurve: 'linear', reverse: true } }];`);
|
||
await clickText('+');
|
||
await pickAssetType(t, 'Song');
|
||
await sleep(200);
|
||
const [longSong] = await assetsOfType('song');
|
||
await mutate(`
|
||
const song = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(longSong.id)});
|
||
song.items = [{ id: 'sn-i1', track: song.tracks[0].id, kind: 'sound', asset: 'sn-long-clip', tick: 0, length: 3840, offset: 0, volume: 0, transpose: 0, instrument: '', fx: '' }];`);
|
||
await evaluate('__gm.streams.idle()');
|
||
await startWith(
|
||
node('sn-song', 'PlaySong', 260, 0, { song: longSong.id, volume: 1, fadeIn: 0, loop: false }),
|
||
);
|
||
await showTab('scene');
|
||
await play();
|
||
const songRun = await waitFor(
|
||
async () => ({ runs: await json('__gm.sound.playbacks()'), stats: await stats() }),
|
||
(got) => got.runs[0]?.clips.length > 0 && got.stats.streaming.playing > 0,
|
||
40,
|
||
);
|
||
await stop();
|
||
const clipCopy = (await json("__gm.streams.copies('sn-long')"))?.clips[0]?.stream;
|
||
const clipName = await json("__gm.soundTab.played('sn-long-clip')");
|
||
const songClip = songRun?.runs[0]?.clips[0];
|
||
check(
|
||
'a clip of the streamed sound gets a copy of its cut, and a song streams it on its track, started on its beat',
|
||
clipCopy?.from === clipName &&
|
||
clipCopy.frames === 10 * 48000 &&
|
||
songClip?.sound === 'sn-long-clip' &&
|
||
songRun.stats.last?.file === clipName &&
|
||
Math.abs(songRun.stats.last.at - songClip.at) < 1e-6,
|
||
JSON.stringify({ clipCopy, clipName, songClip, last: songRun?.stats.last }),
|
||
);
|
||
|
||
// Offline, to the sample: from 0.25 s on, two seconds into the copy; round the seam of its loop;
|
||
// and the reversed clip from its start – each against the copy decoded whole.
|
||
const exactly = async (...args) =>
|
||
JSON.parse(await evaluate(`__gm.streams.exact(...${JSON.stringify(args)}).then(JSON.stringify)`));
|
||
const exact = {
|
||
start: await exactly(long.file, 2, false, 0.25, 2),
|
||
seam: await exactly(long.file, 299, true, 0, 2.5),
|
||
clip: await exactly(clipName, 9, true, 0.1, 2),
|
||
};
|
||
check(
|
||
'streamed, it starts on its sample, runs on past where it jumped in and round its loop as the copy decoded whole',
|
||
Object.values(exact).every(
|
||
(run) =>
|
||
run !== null &&
|
||
run.own < 1e-6 &&
|
||
run.browser < 0.01 &&
|
||
run.skipped === 0 &&
|
||
run.decoded === run.frames,
|
||
),
|
||
JSON.stringify(exact),
|
||
);
|
||
|
||
// 34h-5. With Stream off the clip keeps its copy, and a game only playing the sound through it
|
||
// decodes the copy, not the five minutes. The counter-proof plays the sound itself: then the
|
||
// whole file is decoded ahead again.
|
||
await mutate(`__gm.state.project.assets.find((a) => a.id === 'sn-long').props.stream = false;`);
|
||
await evaluate('__gm.streams.idle()');
|
||
const keptCopies = await json("__gm.streams.copies('sn-long')");
|
||
const freshly = async () =>
|
||
JSON.parse(await evaluate(`__gm.streams.fresh(${JSON.stringify(clipName)}).then(JSON.stringify)`));
|
||
const fresh = await freshly();
|
||
check(
|
||
'with Stream off the clip keeps a copy of its cut, and a game decodes that rather than the whole file, as the cut sounds',
|
||
keptCopies?.sound === null &&
|
||
keptCopies.clips[0]?.stream?.from === clipName &&
|
||
fresh?.ahead.includes(clipName) &&
|
||
!fresh.ahead.includes(long.file) &&
|
||
fresh.whole === false &&
|
||
fresh.decoded === fresh.ahead.length &&
|
||
fresh.played?.length === 10 * 48000 &&
|
||
fresh.played.wanted === fresh.played.length &&
|
||
fresh.played.level < 0.5 &&
|
||
fresh.played.worst < 0.1,
|
||
JSON.stringify({ keptCopies, fresh }),
|
||
);
|
||
await startWith(playsLong);
|
||
const freshWhole = await freshly();
|
||
check(
|
||
'once the game plays the sound itself, its whole file is decoded ahead again',
|
||
freshWhole?.ahead.includes(long.file) &&
|
||
!freshWhole.ahead.includes(clipName) &&
|
||
freshWhole.whole === true,
|
||
JSON.stringify({ ahead: freshWhole?.ahead, whole: freshWhole?.whole }),
|
||
);
|
||
|
||
await mutate(`
|
||
const p = __gm.state.project;
|
||
p.assets = p.assets.filter((a) => !['sn-long', 'sn-bus', ${JSON.stringify(longSong.id)}].includes(a.id));
|
||
p.routing = { nodes: [], edges: [] };`);
|
||
|
||
// 34i. The step takes back everything it made, so the steps after it see the fixture again.
|
||
await mutate(`
|
||
const gone = new Set(['sn-logic', ...${JSON.stringify(both.map((a) => a.id))}]);
|
||
const project = __gm.state.project;
|
||
project.assets = project.assets.filter((a) => !gone.has(a.id));
|
||
__gm.state.activeScene.objects = __gm.state.activeScene.objects.filter(
|
||
(o) => !['sn-box', 'sn-brain'].includes(o.id));
|
||
__gm.state.select(null);
|
||
__gm.lookAt(0, 0);`);
|
||
check(
|
||
'the step leaves nothing of its own behind',
|
||
(await assetsOfType('sound')).length === 0 && (await stats()).playing === 0,
|
||
JSON.stringify(await assetsOfType('sound')),
|
||
);
|
||
}
|