Zoom with the wheel and set the loop on its own strip in music tabs
The ruler of the Pattern and Song tabs did two things with one drag,
and the wheel did three things with Alt and Shift. Both now do one
thing each, and the Song tab gets a few edits it was missing.
- The ruler has a loop strip along its top. A drag there sets the
loop, a press or drag below it moves only where playing starts.
- The wheel only zooms: the time over the grid, lanes and ruler, the
rows over the keys, the tracks over their heads. Alt and Shift no
longer change it. A middle-drag still pans.
- Tracks can be zoomed from 28 to 160 px. Below 46 px a head shows
only its name, M and S. An old saved view gets the old height.
- A click on nothing in the lanes picks the song itself.
- A right-click on an item opens a menu with Delete instead of
deleting it. The inspector has no delete buttons any more.
- A sound item goes up to +12 dB, a track up to +12 dB. A pattern
item stays at 0 dB, since a note is held to unity.
- An end of an item under the pointer lights up and shows a resize
cursor. A long item marks every point where it starts over.
- The Pattern tab has an Audition switch in its header, off at first.
Only with it on does a note sound when drawn, grabbed or moved.
The keys down the left sound either way.
- A looped sound item draws every round alike. Each round is drawn
from its own start, and each column shows the loudest part of the
sound it covers instead of one sampled bucket.
- A song is at least one bar long instead of four. An empty song
fits that bar across, and Play song ends after the last item.
- The ghost notes held back in #334 got their own issue, #339.
Finding: suite 542 for f97961c failed 5 game checks in e2e 64 and 65
while my runs on the same Windows machine overlapped it. Modules
60-65 alone without load passed, so this push's suite has to show it.
Tests:
- Unit: song-edit 11, piano-roll 17, song 14, sound-library 67.
Four of them are new, one in song changed.
- e2e 64+65 on melektaus-atx: ALL PASSED - 46 checks (+3).
- e2e 60-65 on melektaus-atx: ALL PASSED - 86 checks.
- Gate on melektaus-atx: 251 files, 3907 tests, CHECK EXIT 0.
- Audition, after the gate: e2e 64 alone ALL PASSED - 25 checks
(+1), transitions and piano-roll 30 tests, typecheck and ESLint
clean.
- Waveform and one bar, after the gate: song, song-edit, song-play
and the node catalog 438 tests. e2e 65 alone ALL PASSED - 25
checks (+1). A probe shot of a looped pop sound: all six rounds
pixel for pixel alike.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f97961c8ff
commit
aa097bd076
19 files changed
+627
-202
No files matched your search
+58
-12
@@ -6,7 +6,8 @@ import { LANES, pickAssetType, scaffoldBehavior } from './lib/scaffold.mjs';
|
||||
* keys sound where the instrument plays. A click draws a note on the grid, its right end lengthens
|
||||
* it, its body moves it – with Alt off the grid –, a band picks notes, Ctrl+D puts a copy behind
|
||||
* them, the arrows transpose, Delete and a right-click take notes away, Ctrl+C/V copies them to the
|
||||
* start set on the ruler, the lane sets a velocity, a drag on the ruler sets the loop. Space plays the
|
||||
* start set on the ruler, the lane sets a velocity, a drag on the ruler moves the start and one on
|
||||
* the strip above it sets the loop, the wheel zooms the time or the rows. Space plays the
|
||||
* pattern round its loop through the scheduler, and a note drawn meanwhile is planned without a
|
||||
* restart. A game plays it through Play pattern, looped or once. Nothing is heard in CI (muted browser): the witnesses are the model, the planned notes and
|
||||
* the sound counters, as in e2e 34.
|
||||
@@ -92,7 +93,8 @@ export async function pattern(t) {
|
||||
JSON.stringify(keyed),
|
||||
);
|
||||
|
||||
// 64b. Zoomed in on the ruler, a click draws a 1/16 on the grid line before it, and sounds.
|
||||
// 64b. Zoomed in on the ruler, a click draws a 1/16 on the grid line before it, and with Audition
|
||||
// switched on it sounds.
|
||||
const before = await json('__gm.pattern.view()');
|
||||
const r0 = await json('__gm.pattern.rulerAt(0)');
|
||||
await wheel(r0.x, r0.y, -600);
|
||||
@@ -102,6 +104,14 @@ export async function pattern(t) {
|
||||
zoomed.pxPerTick > before.pxPerTick * 2 && Math.abs(zoomed.panX) < 1.5,
|
||||
JSON.stringify({ before, zoomed }),
|
||||
);
|
||||
const auditionOff = await json('__gm.pattern.auditionAt()');
|
||||
await click(auditionOff.x, auditionOff.y);
|
||||
const auditionOn = await json('__gm.pattern.auditionAt()');
|
||||
check(
|
||||
'Audition in the header is off at first and a click switches it on',
|
||||
auditionOff.toggled === false && auditionOn.toggled === true,
|
||||
JSON.stringify({ auditionOff, auditionOn }),
|
||||
);
|
||||
const heard = await stats();
|
||||
let p = await json('__gm.pattern.gridAt(250, 62)');
|
||||
await click(p.x, p.y);
|
||||
@@ -138,16 +148,22 @@ export async function pattern(t) {
|
||||
);
|
||||
const second = lengths.find((n) => n.id !== first)?.id;
|
||||
|
||||
// 64d. Its body moves it onto the grid and to another key; with Alt it lands on the tick.
|
||||
// 64d. Its body moves it onto the grid and to another key, silent with Audition off; with Alt it
|
||||
// lands on the tick.
|
||||
await sleep(500);
|
||||
const audition = await json('__gm.pattern.auditionAt()');
|
||||
await click(audition.x, audition.y);
|
||||
const quiet = await stats();
|
||||
p = await json('__gm.pattern.gridAt(300, 62)');
|
||||
let to = await json('__gm.pattern.gridAt(780, 60)');
|
||||
await drag(p.x, p.y, to.x, to.y);
|
||||
await sleep(300);
|
||||
const moved = (await notes()).find((n) => n.id === first);
|
||||
const movedStats = await stats();
|
||||
check(
|
||||
'dragging a note moves it to the grid line and the key under the pointer',
|
||||
moved?.tick === 720 && moved?.note === 60,
|
||||
JSON.stringify(moved),
|
||||
'dragging a note moves it to the grid line and the key under the pointer, silent with Audition off',
|
||||
moved?.tick === 720 && moved?.note === 60 && movedStats.started === quiet.started,
|
||||
JSON.stringify({ moved, quiet, movedStats }),
|
||||
);
|
||||
await sleep(500);
|
||||
p = await json('__gm.pattern.gridAt(800, 60)');
|
||||
@@ -237,9 +253,38 @@ export async function pattern(t) {
|
||||
JSON.stringify({ count: erased.length, velocities: soft.map((n) => n.velocity) }),
|
||||
);
|
||||
|
||||
// 64i. A drag on the ruler sets the loop; its menu takes it back to the whole pattern.
|
||||
p = await json('__gm.pattern.rulerAt(1920)');
|
||||
to = await json('__gm.pattern.rulerAt(2880)');
|
||||
// 64i. A drag on the ruler moves only where playing starts; one on the strip above sets the loop,
|
||||
// its menu takes it back to the whole pattern. The wheel zooms the time on the grid, the rows on the keys.
|
||||
p = await json('__gm.pattern.rulerAt(960)');
|
||||
to = await json('__gm.pattern.rulerAt(2400)');
|
||||
await drag(p.x + 2, p.y, to.x + 2, to.y);
|
||||
const scrubbed = await json(
|
||||
`{ start: __gm.pattern.view().start, loop: __gm.pattern.loop(${JSON.stringify(id)}) }`,
|
||||
);
|
||||
const wheelFrom = await json('__gm.pattern.view()');
|
||||
p = await json('__gm.pattern.gridAt(960, 62)');
|
||||
await wheel(p.x, p.y, -200);
|
||||
const gridWheel = await json('__gm.pattern.view()');
|
||||
p = await json('__gm.pattern.keyAt(62)');
|
||||
await wheel(p.x, p.y, -200);
|
||||
const keysWheel = await json('__gm.pattern.view()');
|
||||
await wheel(p.x, p.y, 200);
|
||||
p = await json('__gm.pattern.gridAt(960, 62)');
|
||||
await wheel(p.x, p.y, 200);
|
||||
check(
|
||||
'a drag on the ruler moves where playing starts and leaves the loop; the wheel zooms the time on the grid, the rows on the keys',
|
||||
scrubbed.start === 2400 &&
|
||||
scrubbed.loop === null &&
|
||||
gridWheel.pxPerTick > wheelFrom.pxPerTick &&
|
||||
gridWheel.rowHeight === wheelFrom.rowHeight &&
|
||||
Math.abs(gridWheel.panY - wheelFrom.panY) < 1e-6 &&
|
||||
keysWheel.rowHeight > wheelFrom.rowHeight &&
|
||||
keysWheel.pxPerTick === gridWheel.pxPerTick &&
|
||||
keysWheel.panX === gridWheel.panX,
|
||||
JSON.stringify({ scrubbed, wheelFrom, gridWheel, keysWheel }),
|
||||
);
|
||||
p = await json('__gm.pattern.loopAt(1920)');
|
||||
to = await json('__gm.pattern.loopAt(2880)');
|
||||
await drag(p.x, p.y, to.x, to.y);
|
||||
const looped = await json(`__gm.pattern.loop(${JSON.stringify(id)})`);
|
||||
await click(p.x + 20, p.y, 'right');
|
||||
@@ -259,11 +304,12 @@ export async function pattern(t) {
|
||||
|
||||
// 64j. Space plays bar 1 round its loop from the start, each note at its tick on the audio clock;
|
||||
// a note drawn while it plays is planned in the next round, without starting over.
|
||||
p = await json('__gm.pattern.rulerAt(1920)');
|
||||
to = await json('__gm.pattern.rulerAt(0)');
|
||||
p = await json('__gm.pattern.loopAt(1920)');
|
||||
to = await json('__gm.pattern.loopAt(0)');
|
||||
await drag(p.x, p.y, to.x, to.y);
|
||||
const bar1 = await json(`__gm.pattern.loop(${JSON.stringify(id)})`);
|
||||
// At the loop's start, but a click: it sets where playing starts and leaves the loop as it is.
|
||||
// At the loop's start, but on the ruler below it: it sets where playing starts and leaves the loop as it is.
|
||||
to = await json('__gm.pattern.rulerAt(0)');
|
||||
await click(to.x + 2, to.y);
|
||||
await press('Space');
|
||||
const planned = await waitFor(
|
||||
|
||||
+100
-6
@@ -5,8 +5,10 @@ import { LANES, pickAssetType, scaffoldBehavior } from './lib/scaffold.mjs';
|
||||
* Songs and their tracks (#334). "+ → Song" opens the tab on four empty tracks. A pattern and a
|
||||
* sound dragged in from the Assets panel land as items where the preview showed them; an item's
|
||||
* right end lengthens it, its body moves it across tracks, its left end cuts it back, Ctrl+D puts a
|
||||
* copy behind it, Ctrl+E splits it where playing starts, Delete takes it away. A head mutes, solos
|
||||
* and sets the volume of its track, "+ Track" adds one. Space plays the song through the scheduler:
|
||||
* copy behind it, Ctrl+E splits it where playing starts, Delete or its menu takes it away. A click
|
||||
* on nothing picks the song, an end under the pointer lights up, a long item marks its rounds, the
|
||||
* wheel zooms tracks or time, the strip above the ruler sets the loop. A head mutes, solos and sets
|
||||
* the volume of its track, "+ Track" adds one. Space plays the song through the scheduler:
|
||||
* the pattern round and round at the song's tempo, the sound as a clip, each track on its own bus.
|
||||
* A pattern started meanwhile takes over, and a double-click opens a pattern item in its tab.
|
||||
* Nothing is heard in CI (muted browser): the witnesses are the model, the planned notes and clips
|
||||
@@ -129,7 +131,22 @@ export async function song(t) {
|
||||
const model = () => json(`__gm.song.model(${JSON.stringify(id)})`);
|
||||
const tracks = (await model()).tracks.map((track) => track.id);
|
||||
|
||||
// 65b. A pattern dragged onto the second track lands on the bar before the pointer, a bar long.
|
||||
// 65b. An empty song is a bar long and fits it across; the wheel takes the view out to eight bars.
|
||||
// A pattern dragged onto the second track lands on the bar before the pointer, a bar long.
|
||||
const fitted = await json('__gm.song.view()');
|
||||
const area = await json('__gm.song.rect()');
|
||||
for (let i = 0; i < 30; i++) {
|
||||
const far = await json(`__gm.song.laneAt(${8 * BAR}, 0)`);
|
||||
if (far.x < area.x + area.w) break;
|
||||
const near = await json(`__gm.song.laneAt(0, 0)`);
|
||||
await t.wheel(near.x + 2, near.y, 200);
|
||||
}
|
||||
const zoomedOut = await json('__gm.song.view()');
|
||||
check(
|
||||
'an empty song fits a single bar across, and the wheel over the lanes takes the view out',
|
||||
fitted.pxPerTick > zoomedOut.pxPerTick * 4,
|
||||
JSON.stringify({ fitted, zoomedOut }),
|
||||
);
|
||||
const preview = await dragAsset(PATTERN, await json(`__gm.song.laneAt(${BAR + 400}, 1)`));
|
||||
let items = (await model()).items;
|
||||
const riff = items[0];
|
||||
@@ -240,11 +257,88 @@ export async function song(t) {
|
||||
p = await json(`__gm.song.itemAt(${JSON.stringify(parts[0].id)}, 'body')`);
|
||||
await click(p.x, p.y, 'right');
|
||||
await sleep(150);
|
||||
const menuDelete = await json("__gm.locate('Delete', true)");
|
||||
const beforeMenu = (await model()).items.length;
|
||||
if (menuDelete) await click(menuDelete.x, menuDelete.y);
|
||||
await sleep(150);
|
||||
items = (await model()).items;
|
||||
check(
|
||||
'Delete and a right-click take items away',
|
||||
items.length === 2 && items.every((item) => item.id === riff.id || item.id === clip.id),
|
||||
JSON.stringify(items),
|
||||
'Delete takes an item away, and a right-click opens a menu whose "Delete" does',
|
||||
beforeMenu === 3 &&
|
||||
Boolean(menuDelete) &&
|
||||
items.length === 2 &&
|
||||
items.every((item) => item.id === riff.id || item.id === clip.id),
|
||||
JSON.stringify({ beforeMenu, menuDelete, items }),
|
||||
);
|
||||
|
||||
// 65f-ii. A click on nothing picks the song; a sound item turns up to +12 dB in the inspector, which
|
||||
// has no delete button any more. An end of an item under the pointer shows it can be dragged,
|
||||
// and an item longer than what it plays marks where it starts over.
|
||||
p = await json('__gm.song.laneAt(300, 3)');
|
||||
await click(p.x, p.y);
|
||||
const emptyPick = await json('__gm.state.selection');
|
||||
p = await json(`__gm.song.itemAt(${JSON.stringify(clip.id)}, 'body')`);
|
||||
await click(p.x, p.y);
|
||||
await sleep(150);
|
||||
const volumeField = await json("__gm.inspectorField('Volume (dB)')");
|
||||
await click(volumeField.x, volumeField.y);
|
||||
await t.typeText('30');
|
||||
await press('Enter');
|
||||
await sleep(150);
|
||||
const loud = (await model()).items.find((item) => item.id === clip.id);
|
||||
const deleteButtons = await json("[__gm.locate('Delete item'), __gm.locate('Delete items')]");
|
||||
p = await json(`__gm.song.itemAt(${JSON.stringify(clip.id)}, 'end')`);
|
||||
await mouse('mouseMoved', p.x - 1, p.y);
|
||||
await mouse('mouseMoved', p.x, p.y);
|
||||
const onEnd = await json('__gm.song.view()');
|
||||
p = await json(`__gm.song.itemAt(${JSON.stringify(clip.id)}, 'body')`);
|
||||
await mouse('mouseMoved', p.x, p.y);
|
||||
const onBody = await json('__gm.song.view()');
|
||||
const seams = (await json('__gm.song.items()')).find((item) => item.id === riff.id)?.seams;
|
||||
check(
|
||||
'a click on nothing picks the song, a sound item goes to +12 dB, an end lights up under the pointer, and a long item marks its rounds',
|
||||
emptyPick?.kind === 'asset' &&
|
||||
emptyPick.assetId === id &&
|
||||
loud?.volume === 12 &&
|
||||
deleteButtons.every((b) => b === null) &&
|
||||
onEnd.hover?.id === clip.id &&
|
||||
onEnd.hover.part === 'end' &&
|
||||
onEnd.cursor === 'ew-resize' &&
|
||||
onBody.hover?.part === 'body' &&
|
||||
onBody.cursor === 'default' &&
|
||||
seams?.join() === [3 * BAR, 4 * BAR].join(),
|
||||
JSON.stringify({ emptyPick, loud, deleteButtons, onEnd, onBody, seams }),
|
||||
);
|
||||
await shot('01b-hover');
|
||||
|
||||
// 65f-iii. The wheel zooms the tracks over their heads and the time over the lanes; a drag on the
|
||||
// strip above the ruler sets the loop, Ctrl+Z takes it back.
|
||||
const unzoomed = await json('__gm.song.view()');
|
||||
p = await json("__gm.song.headAt(0, 'name')");
|
||||
await t.wheel(p.x, p.y, -200);
|
||||
const taller = await json('__gm.song.view()');
|
||||
p = await json(`__gm.song.laneAt(${2 * BAR}, 0)`);
|
||||
await t.wheel(p.x, p.y, -200);
|
||||
const wider = await json('__gm.song.view()');
|
||||
p = await json("__gm.song.headAt(0, 'name')");
|
||||
await t.wheel(p.x, p.y, 200);
|
||||
p = await json('__gm.song.fitAt()');
|
||||
await click(p.x, p.y);
|
||||
p = await json(`__gm.song.loopAt(${BAR})`);
|
||||
to = await json(`__gm.song.loopAt(${3 * BAR})`);
|
||||
await drag(p.x, p.y, to.x, to.y);
|
||||
const songLoop = (await model()).loop;
|
||||
await press('z', { ctrl: true });
|
||||
check(
|
||||
'the wheel zooms the tracks on their heads and the time on the lanes, and the strip above the ruler sets the loop',
|
||||
taller.trackHeight > unzoomed.trackHeight &&
|
||||
taller.pxPerTick === unzoomed.pxPerTick &&
|
||||
wider.pxPerTick > taller.pxPerTick &&
|
||||
wider.trackHeight === taller.trackHeight &&
|
||||
songLoop?.start === BAR &&
|
||||
songLoop.end === 3 * BAR &&
|
||||
(await model()).loop === null,
|
||||
JSON.stringify({ unzoomed, taller, wider, songLoop }),
|
||||
);
|
||||
|
||||
// 65g. The heads: mute and solo toggle, the volume slider sets decibels, "+ Track" adds a track.
|
||||
|
||||
Reference in new issue
Block a user