diff --git a/README.md b/README.md index f5c16e1c..9af2720d 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh The browser suite and the three example games need a machine with a GPU: ```bash -node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1937 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1941 checks (GM_STEPS=20 for one step) # every tool signs in first – see the Accounts page node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs diff --git a/src/editor/debug/graphHooks.ts b/src/editor/debug/graphHooks.ts index 46e587a1..039ff4d6 100644 --- a/src/editor/debug/graphHooks.ts +++ b/src/editor/debug/graphHooks.ts @@ -126,6 +126,8 @@ export function graphHooks({ state, editor }: HookContext) { picked: () => editor.eqEditor.picked, /** The dB the curve is zoomed to, above and below 0 (#376). */ range: () => editor.eqEditor.range(), + /** The dB the curve draws right now, and whether a zoom is on its way (#377). */ + zoomShown: () => editor.eqEditor.curve.zoomShown, handleAt: (index: number) => editor.eqEditor.curve.handleGlobal(index), pointAt: (hz: number, db: number) => editor.eqEditor.curve.pointGlobal(hz, db), fields: () => { diff --git a/src/editor/debug/soundHooks.ts b/src/editor/debug/soundHooks.ts index c4db500f..e00e18f9 100644 --- a/src/editor/debug/soundHooks.ts +++ b/src/editor/debug/soundHooks.ts @@ -24,6 +24,8 @@ export function soundHooks({ state, editor }: HookContext) { level: () => soundEditor.canvas.levelZoom, scale: () => soundEditor.canvas.scaleCentre(), fullScaleTop: () => soundEditor.canvas.fullScaleTop(), + /** The time zoom, and whether a zoom is on its way (#377). */ + zoom: () => soundEditor.canvas.zoomInfo(), fitButton: () => centreOf(soundEditor.fitButton), cursor: () => soundEditor.canvas.cursor, playhead: () => soundEditor.canvas.playhead, diff --git a/src/editor/pattern/PianoRoll.ts b/src/editor/pattern/PianoRoll.ts index 086107f2..0819fcba 100644 --- a/src/editor/pattern/PianoRoll.ts +++ b/src/editor/pattern/PianoRoll.ts @@ -47,6 +47,7 @@ import { zoomTime, } from '../music/timeView'; import type { EditorState } from '../state'; +import { axisTween } from '../viewTween'; import { clampShift, draggedLength, @@ -176,6 +177,17 @@ export class PianoRoll extends Widget { private readonly hint = crispText({ text: '', style: textStyle('muted') }); private readonly laneLabel = crispText({ text: 'Velocity', style: textStyle('small') }); private view: RollView = { panX: 0, pxPerTick: 0.1, panY: 0, rowHeight: DEFAULT_ROW_HEIGHT }; + /** A zoom or a Fit shown as a transition (#377), the time and the rows each on their own. */ + private readonly timeTween = axisTween( + () => ({ scale: this.view.pxPerTick, offset: this.view.panX }), + ({ scale, offset }) => (this.view = { ...this.view, pxPerTick: scale, panX: offset }), + () => this.redraw(), + ); + private readonly rowTween = axisTween( + () => ({ scale: this.view.rowHeight, offset: this.view.panY }), + ({ scale, offset }) => (this.view = { ...this.view, rowHeight: scale, panY: offset }), + () => this.redraw(), + ); private needsFit = true; private assetId = ''; private gesture: Gesture | null = null; @@ -268,6 +280,8 @@ export class PianoRoll extends Widget { } saveView(): RollView { + this.timeTween.land(); + this.rowTween.land(); return { ...this.view }; } @@ -277,8 +291,13 @@ export class PianoRoll extends Widget { this.redraw(); } - /** The whole pattern across, and its notes – or what the instrument plays – down the middle. */ + /** Fit by hand, shown as a transition. */ fit(): void { + this.timeTween.change(() => this.rowTween.change(() => this.frame())); + } + + /** The whole pattern across, and its notes – or what the instrument plays – down the middle. */ + private frame(): void { const asset = this.asset(); const width = this.gridWidth; const height = this.gridHeight; @@ -484,10 +503,12 @@ export class PianoRoll extends Widget { keys: number | null; hover: NoteHit | null; cursor: string; + moving: boolean; } { const g = this.gesture; return { ...this.view, + moving: this.timeTween.moving || this.rowTween.moving, start: this.startTick, playhead: this.playhead, loop: this.shownLoop(), @@ -545,6 +566,8 @@ export class PianoRoll extends Widget { e.stopPropagation(); ui().focus.focus(this); if (e.button === 1) { + this.timeTween.finish(); + this.rowTween.finish(); this.gesture = { kind: 'pan', pointer: p, panX: this.view.panX, panY: this.view.panY }; return; } @@ -900,14 +923,16 @@ export class PianoRoll extends Widget { const area = this.areaAt({ x: local.x, y: local.y }); const g = this.inGrid({ x: local.x, y: local.y }); const factor = zoomFactor(e.deltaY); - let view: RollView; - if (area === 'keys') view = zoomRows(this.view, factor, g.y); - else if (area === 'ruler' || area === 'grid' || area === 'lane') - view = { ...this.view, ...zoomTime(this.view, factor, g.x) }; - else return; - this.view = clampRows(clampTimePan(view, this.gridWidth, this.earliest), this.gridHeight); - this.needsFit = false; - this.redraw(); + const rows = area === 'keys'; + if (!rows && area !== 'ruler' && area !== 'grid' && area !== 'lane') return; + (rows ? this.rowTween : this.timeTween).change(() => { + const view = rows + ? zoomRows(this.view, factor, g.y) + : { ...this.view, ...zoomTime(this.view, factor, g.x) }; + this.view = clampRows(clampTimePan(view, this.gridWidth, this.earliest), this.gridHeight); + this.needsFit = false; + this.redraw(); + }); e.preventDefault(); } @@ -951,12 +976,12 @@ export class PianoRoll extends Widget { } override layout(): void { - if (this.needsFit) this.fit(); + if (this.needsFit) this.frame(); } override redraw(): void { if (this.needsFit && this.gridWidth > 0 && this.gridHeight > 0 && this.asset()) { - this.fit(); + this.frame(); return; } const c = Theme.colors; diff --git a/src/editor/song/SongTimeline.ts b/src/editor/song/SongTimeline.ts index 9ed7f55a..26d6073d 100644 --- a/src/editor/song/SongTimeline.ts +++ b/src/editor/song/SongTimeline.ts @@ -73,6 +73,7 @@ import { } from '../music/timeView'; import { snapDown, snapNear } from '../pattern/rollEdit'; import type { EditorState } from '../state'; +import { axisTween } from '../viewTween'; import { drawMeter } from './meterDraw'; import { gainToDb, @@ -240,6 +241,17 @@ export class SongTimeline extends Widget { private readonly clips = { lanes: new Graphics(), heads: new Graphics(), ruler: new Graphics() }; private readonly hint = crispText({ text: '', style: textStyle('muted') }); private view: SongView = { panX: 0, pxPerTick: 0.02, panY: 0, trackHeight: TRACK_HEIGHT }; + /** A zoom or a Fit shown as a transition (#377), the time and the tracks each on their own. */ + private readonly timeTween = axisTween( + () => ({ scale: this.view.pxPerTick, offset: this.view.panX }), + ({ scale, offset }) => (this.view = { ...this.view, pxPerTick: scale, panX: offset }), + () => this.redraw(), + ); + private readonly trackTween = axisTween( + () => ({ scale: this.view.trackHeight, offset: this.view.panY }), + ({ scale, offset }) => (this.view = { ...this.view, trackHeight: scale, panY: offset }), + () => this.redraw(), + ); private needsFit = true; private assetId = ''; private gesture: Gesture | null = null; @@ -340,6 +352,8 @@ export class SongTimeline extends Widget { } saveView(): SongView { + this.timeTween.land(); + this.trackTween.land(); return { ...this.view }; } @@ -349,8 +363,13 @@ export class SongTimeline extends Widget { this.redraw(); } - /** The whole song across and all its tracks down, with some room around. */ + /** Fit by hand, shown as a transition. */ fit(): void { + this.timeTween.change(() => this.trackTween.change(() => this.frame())); + } + + /** The whole song across and all its tracks down, with some room around. */ + private frame(): void { const asset = this.asset(); if (!asset || this.lanesWidth <= 0 || this.lanesHeight <= 0) { this.needsFit = true; @@ -660,9 +679,11 @@ export class SongTimeline extends Widget { hover: { id: string; part: ItemPart } | null; cursor: string; take: (Omit & { peaks: number }) | null; + moving: boolean; } { return { ...this.view, + moving: this.timeTween.moving || this.trackTween.moving, start: this.startTick, playhead: this.playhead, loop: this.shownLoop(), @@ -735,6 +756,8 @@ export class SongTimeline extends Widget { e.stopPropagation(); ui().focus.focus(this); if (e.button === 1) { + this.timeTween.finish(); + this.trackTween.finish(); this.gesture = { kind: 'pan', pointer: p, panX: this.view.panX, panY: this.view.panY }; return; } @@ -1263,14 +1286,16 @@ export class SongTimeline extends Widget { const area = this.areaAt({ x: local.x, y: local.y }); const g = this.inLanes({ x: local.x, y: local.y }); const factor = zoomFactor(e.deltaY); - let view: SongView; - if (area === 'heads') view = zoomTracks(this.view, factor, g.y); - else if (area === 'ruler' || area === 'lanes') - view = { ...this.view, ...zoomTime(this.view, factor, g.x, SONG_MAX_PX_PER_TICK) }; - else return; - this.view = clampTracks(clampTimePan(view, this.lanesWidth), asset.tracks.length, this.lanesHeight); - this.needsFit = false; - this.redraw(); + const tracks = area === 'heads'; + if (!tracks && area !== 'ruler' && area !== 'lanes') return; + (tracks ? this.trackTween : this.timeTween).change(() => { + const view = tracks + ? zoomTracks(this.view, factor, g.y) + : { ...this.view, ...zoomTime(this.view, factor, g.x, SONG_MAX_PX_PER_TICK) }; + this.view = clampTracks(clampTimePan(view, this.lanesWidth), asset.tracks.length, this.lanesHeight); + this.needsFit = false; + this.redraw(); + }); e.preventDefault(); } @@ -1374,12 +1399,12 @@ export class SongTimeline extends Widget { } override layout(): void { - if (this.needsFit) this.fit(); + if (this.needsFit) this.frame(); } override redraw(): void { if (this.needsFit && this.lanesWidth > 0 && this.lanesHeight > 0 && this.asset()) { - this.fit(); + this.frame(); return; } const c = Theme.colors; diff --git a/src/editor/sound/WaveCanvas.ts b/src/editor/sound/WaveCanvas.ts index 03718913..abdc156d 100644 --- a/src/editor/sound/WaveCanvas.ts +++ b/src/editor/sound/WaveCanvas.ts @@ -14,6 +14,7 @@ import { cutOf, END_KEY, START_KEY } from '../../core/properties/sounds'; import { crispText, type Size, textStyle, Theme, ui, Widget, zoomFactor } from '../../ui'; import { soundLibrary } from '../../viewport/SoundLibrary'; import type { EditorState } from '../state'; +import { axisTween } from '../viewTween'; import { clampWave, type ClipBand, @@ -145,6 +146,17 @@ export class WaveCanvas extends Widget { private view: WaveView = { panX: FIT_MARGIN, pxPerSecond: 100 }; /** How many times its full height the wave is drawn. */ private level = 1; + /** A zoom or a Fit shown as a transition (#377), the time and the height each on their own. */ + private readonly timeTween = axisTween( + () => ({ scale: this.view.pxPerSecond, offset: this.view.panX }), + ({ scale, offset }) => (this.view = { pxPerSecond: scale, panX: offset }), + () => this.redraw(), + ); + private readonly levelTween = axisTween( + () => ({ scale: this.level, offset: 0 }), + ({ scale }) => (this.level = scale), + () => this.redraw(), + ); private needsFit = true; private assetId = ''; private gesture: Gesture | null = null; @@ -192,6 +204,8 @@ export class WaveCanvas extends Widget { } saveView(): WaveView & { level: number } { + this.timeTween.land(); + this.levelTween.land(); return { ...this.view, level: this.level }; } @@ -204,8 +218,13 @@ export class WaveCanvas extends Widget { this.redraw(); } - /** The whole file across, at its full height. */ + /** Fit by hand, shown as a transition. */ fit(): void { + this.timeTween.change(() => this.levelTween.change(() => this.frame())); + } + + /** The whole file across, at its full height. */ + private frame(): void { const wave = this.wave(); if (!wave || this.areaWidth <= 0) { this.needsFit = true; @@ -222,6 +241,11 @@ export class WaveCanvas extends Widget { return this.level; } + /** The zoom across and down, and whether a transition is on its way (test hook). */ + zoomInfo(): WaveView & { level: number; moving: boolean } { + return { ...this.view, level: this.level, moving: this.timeTween.moving || this.levelTween.moving }; + } + /** The middle of the level scale on screen (test hook). */ scaleCentre(): Vec2 { const origin = this.getGlobalPosition(); @@ -353,6 +377,7 @@ export class WaveCanvas extends Widget { e.stopPropagation(); ui().focus.focus(this); if (e.button === 1) { + this.timeTween.finish(); this.gesture = { kind: 'pan', pointerX: p.x, panX: this.view.panX }; return; } @@ -453,10 +478,14 @@ export class WaveCanvas extends Widget { const wave = this.wave(); if (!wave) return; const p = this.areaPoint(e); - if (p.x < 0) this.level = zoomLevel(this.level, zoomFactor(e.deltaY)); - else this.view = clampWave(zoomWave(this.view, zoomFactor(e.deltaY), p.x), wave.duration, this.areaWidth); - this.needsFit = false; - this.redraw(); + const level = p.x < 0; + (level ? this.levelTween : this.timeTween).change(() => { + if (level) this.level = zoomLevel(this.level, zoomFactor(e.deltaY)); + else + this.view = clampWave(zoomWave(this.view, zoomFactor(e.deltaY), p.x), wave.duration, this.areaWidth); + this.needsFit = false; + this.redraw(); + }); e.preventDefault(); } @@ -468,12 +497,12 @@ export class WaveCanvas extends Widget { } override layout(): void { - if (this.needsFit) this.fit(); + if (this.needsFit) this.frame(); } override redraw(): void { const wave = this.wave(); - if (this.needsFit && wave && this.areaWidth > 0) return this.fit(); + if (this.needsFit && wave && this.areaWidth > 0) return this.frame(); const c = Theme.colors; const { h } = this; const w = this.areaWidth; diff --git a/src/editor/soundfx/EqEditor.ts b/src/editor/soundfx/EqEditor.ts index 09dab8a7..7f25b615 100644 --- a/src/editor/soundfx/EqEditor.ts +++ b/src/editor/soundfx/EqEditor.ts @@ -57,6 +57,7 @@ import { import { FramePreview } from '../framePreview'; import type { EditorState } from '../state'; import { TabHeader } from '../TabHeader'; +import { ViewTween } from '../viewTween'; import { bandAt, EQ_GRID_FREQUENCIES, @@ -110,6 +111,16 @@ class EqCurve extends Widget { private drag: { index: number; startX: number; startY: number; moved: boolean } | null = null; /** The spectrum drawn now; null while nothing has passed the node or it has fallen away. */ private heard: Spectrum | null = null; + /** The dB drawn while a zoom is on its way (#377); null draws the node's. */ + private shownRange: number | null = null; + private readonly tween = new ViewTween({ + read: () => ({ scale: 1 / this.range, x: 0, y: 0 }), + write: (view) => (this.shownRange = 1 / view.scale), + draw: () => { + if (!this.tween.moving) this.shownRange = null; + this.redraw(); + }, + }); /** How many spectra were drawn, and the loudest bin on either side of the last one (checks). */ seen = { frames: 0, peakIn: 0, peakOut: 0, levelOut: -Infinity, binHz: 0 }; @@ -139,7 +150,22 @@ class EqCurve extends Widget { } get area(): EqArea { - return { w: this.w, h: this.h, range: this.host.range() }; + return { w: this.w, h: this.h, range: this.range }; + } + + private get range(): number { + return this.shownRange ?? this.host.range(); + } + + /** The dB drawn and whether a zoom is on its way – the check scripts sample it. */ + get zoomShown(): { range: number; moving: boolean } { + return { range: this.range, moving: this.tween.moving }; + } + + /** Another node: a zoom on its way to the one before ends. */ + settle(): void { + this.tween.land(); + this.shownRange = null; } /** Where a band's handle is on screen – the check scripts aim with it. */ @@ -224,7 +250,11 @@ class EqCurve extends Widget { const index = bandAt(bands, at.x, at.y, this.area, GRAB_RADIUS); const band = bands[index]; if (!band) { - this.host.zoom(wheeledRange(this.host.range(), e.deltaY)); + const range = wheeledRange(this.host.range(), e.deltaY); + this.tween.change(() => { + this.shownRange = null; + this.host.zoom(range); + }); return; } if (!hasQ(band.type)) return; @@ -245,12 +275,13 @@ class EqCurve extends Widget { const text = this.labels[label++]!; text.position.set(Math.min(this.w - text.width - 2, x + 3), this.h - text.height - 2); } - const dbs = eqGridDb(area.range); + // The lines of the range a zoom is heading to, so they slide to their place with whole numbers. + const dbs = eqGridDb(this.host.range()); this.dbLabels.forEach((text, i) => { const db = dbs[i]; - text.visible = db !== undefined; - if (db === undefined) return; - const y = Math.round(dbToY(db, area)) + 0.5; + const y = db === undefined ? -1 : Math.round(dbToY(db, area)) + 0.5; + text.visible = y > 0 && y < this.h; + if (!text.visible || db === undefined) return; this.grid.moveTo(0, y).lineTo(this.w, y); text.text = db > 0 ? `+${db}` : `${db}`; text.position.set(3, y - text.height - 1); @@ -429,6 +460,7 @@ export class EqEditor extends VStack implements EqHost { /** Points the tab at a node; the band picked stays when it is the same node again. */ show(assetId: string, nodeId: string): void { if (assetId !== this.assetId || nodeId !== this.nodeId) { + this.curve.settle(); this.perFrame.cancel(); this.curve.showSpectrum(null); this.before = null; diff --git a/src/editor/viewTween.ts b/src/editor/viewTween.ts index 547931c6..15c480e0 100644 --- a/src/editor/viewTween.ts +++ b/src/editor/viewTween.ts @@ -154,3 +154,31 @@ export class ViewTween { this.clock.remove(this.tick); } } + +/** One axis of a view whose axes zoom apart (#377): `scale` pixels per unit, unit 0 at `offset`. */ +export interface AxisMap { + scale: number; + offset: number; +} + +/** A tween along one axis; the music tabs keep one per axis, so either zooms on its own. */ +export function axisTween( + read: () => AxisMap, + write: (axis: AxisMap) => void, + draw: () => void, + duration?: () => number, + clock?: TweenClock, +): ViewTween { + return new ViewTween( + { + read: () => { + const { scale, offset } = read(); + return { scale, x: offset, y: 0 }; + }, + write: (view) => write({ scale: view.scale, offset: view.x }), + draw, + }, + duration, + clock, + ); +} diff --git a/tests/editor/view-tween.test.ts b/tests/editor/view-tween.test.ts index e2612f57..b4652c88 100644 --- a/tests/editor/view-tween.test.ts +++ b/tests/editor/view-tween.test.ts @@ -9,7 +9,7 @@ import { } from '../../src/editor/generalSettings'; import { zoomedAbout } from '../../src/editor/graph/graphView'; import { localToImage, zoomAbout } from '../../src/editor/texture/sheetView'; -import { easeOut, tweenView, type ViewMap, ViewTween } from '../../src/editor/viewTween'; +import { axisTween, easeOut, tweenView, type ViewMap, ViewTween } from '../../src/editor/viewTween'; function fakeClock() { let time = 0; @@ -188,6 +188,59 @@ describe('the transition of a view', () => { }); }); +describe('a view whose axes zoom apart (#377)', () => { + function song(clock: ReturnType) { + const s = { view: { panX: 40, pxPerTick: 0.02, panY: 10, trackHeight: 52 } }; + const time = axisTween( + () => ({ scale: s.view.pxPerTick, offset: s.view.panX }), + ({ scale, offset }) => (s.view = { ...s.view, pxPerTick: scale, panX: offset }), + () => {}, + () => 200, + clock, + ); + const tracks = axisTween( + () => ({ scale: s.view.trackHeight, offset: s.view.panY }), + ({ scale, offset }) => (s.view = { ...s.view, trackHeight: scale, panY: offset }), + () => {}, + () => 200, + clock, + ); + return { s, time, tracks }; + } + + it('zooms the time about the pointer and leaves the tracks alone', () => { + const clock = fakeClock(); + const { s, time, tracks } = song(clock); + const tickAt = (x: number) => (x - s.view.panX) / s.view.pxPerTick; + const held = tickAt(300); + time.change(() => (s.view = { ...s.view, pxPerTick: 0.08, panX: 300 - held * 0.08 })); + clock.advance(80); + expect(s.view.pxPerTick).toBeGreaterThan(0.02); + expect(s.view.pxPerTick).toBeLessThan(0.08); + expect(tickAt(300)).toBeCloseTo(held, 9); + expect(s.view.trackHeight).toBe(52); + expect(s.view.panY).toBe(10); + expect([time.moving, tracks.moving]).toEqual([true, false]); + clock.advance(200); + expect(s.view.pxPerTick).toBe(0.08); + expect(time.moving).toBe(false); + }); + + it('moves both axes on a Fit, each landing where the jump would', () => { + const clock = fakeClock(); + const { s, time, tracks } = song(clock); + const fitted = { panX: 30, pxPerTick: 0.005, panY: 20, trackHeight: 90 }; + time.change(() => tracks.change(() => (s.view = { ...fitted }))); + clock.advance(100); + expect(s.view.pxPerTick).toBeLessThan(0.02); + expect(s.view.trackHeight).toBeGreaterThan(52); + expect([time.moving, tracks.moving]).toEqual([true, true]); + clock.advance(200); + expect(s.view).toEqual(fitted); + expect(clock.attached).toBe(0); + }); +}); + describe('the general settings', () => { function memoryStore() { const items = new Map(); diff --git a/tools/e2e/34-sound.mjs b/tools/e2e/34-sound.mjs index 8724167c..12d51d89 100644 --- a/tools/e2e/34-sound.mjs +++ b/tools/e2e/34-sound.mjs @@ -3,6 +3,7 @@ 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 @@ -600,6 +601,18 @@ export async function sound(t) { 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 @@ -2137,6 +2150,15 @@ export async function sound(t) { 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)'); @@ -2578,12 +2600,14 @@ export async function sound(t) { // 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'); - const rendererMb = () => + // 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')) - .reduce((sum, line) => sum + Number(line.trim().split(/\s+/)[0]) / 1024, 0); + .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); @@ -2631,6 +2655,7 @@ export async function sound(t) { const decodedBefore = (await stats()).decoded; const restStreamed = await restingMb(); + const renderersBefore = renderers(); await play(); const tapsLong = JSON.stringify(['mix', 'mix:sn-bus']); await json(`__gm.routing.peaks(${tapsLong})`); @@ -2641,6 +2666,7 @@ export async function sound(t) { ); await sleep(2500); const playingStreamed = rendererMb(); + const renderersPlaying = renderers(); await t.clickToolbar('pause'); await sleep(500); const paused = { clock: await json('__gm.sound.clock()'), state: await json('__gm.sound.clockState()') }; @@ -2658,7 +2684,14 @@ export async function sound(t) { streaming.decoded === decodedBefore && playingStreamed - restStreamed < 20 && Math.max(...(heardLong?.mix ?? [0])) > 0.01, - JSON.stringify({ restStreamed, playingStreamed, streaming, heardLong }), + JSON.stringify({ + restStreamed, + playingStreamed, + renderersBefore, + renderersPlaying, + streaming, + heardLong, + }), ); check( 'the pause holds the stream where it is, it plays on after it without a sample lost, and stopping ends it', diff --git a/tools/e2e/64-pattern.mjs b/tools/e2e/64-pattern.mjs index 7c721888..30c5152c 100644 --- a/tools/e2e/64-pattern.mjs +++ b/tools/e2e/64-pattern.mjs @@ -1,5 +1,6 @@ import { edge, node } from '../lib/builders.mjs'; import { LANES, pickAssetType, scaffoldBehavior } from './lib/scaffold.mjs'; +import { sampleZoom } from './lib/zoomTransition.mjs'; /** * Patterns and their piano roll (#333). "+ → Pattern" opens the tab; with an instrument picked its @@ -342,6 +343,15 @@ export async function pattern(t) { keysWheel.panX === gridWheel.panX, JSON.stringify({ scrubbed, wheelFrom, gridWheel, keysWheel }), ); + // #377: at 1000 ms a frame after the wheel the zoom is on its way, on the grid and on the keys alike. + const rollZoom = (key) => `({ scale: __gm.pattern.view().${key}, moving: __gm.pattern.view().moving })`; + const rollTime = await sampleZoom(t, await json('__gm.pattern.gridAt(960, 62)'), rollZoom('pxPerTick')); + const rollRows = await sampleZoom(t, await json('__gm.pattern.keyAt(62)'), rollZoom('rowHeight')); + check( + 'a zoom of the time and of the rows is shown on its way and lands where the jump does', + rollTime.ok && rollRows.ok, + `${rollTime.detail} ${rollRows.detail}`, + ); // A middle-drag far to the right pans tick 0 in from the left edge, but only half the grid in; a // click left of it draws nothing. Fit takes the view back. const area = await json('__gm.pattern.rect()'); diff --git a/tools/e2e/65-song.mjs b/tools/e2e/65-song.mjs index f7539b0e..ecf74687 100644 --- a/tools/e2e/65-song.mjs +++ b/tools/e2e/65-song.mjs @@ -1,5 +1,6 @@ import { edge, node } from '../lib/builders.mjs'; import { LANES, pickAssetType, scaffoldBehavior } from './lib/scaffold.mjs'; +import { sampleZoom } from './lib/zoomTransition.mjs'; /** * Songs and their tracks (#334). "+ → Song" opens the tab on one empty track, a head's menu adds @@ -439,6 +440,15 @@ export async function song(t) { (await model()).loop === null, JSON.stringify({ unzoomed, taller, wider, songLoop }), ); + // #377: at 1000 ms a frame after the wheel the zoom is on its way, on the heads and on the lanes alike. + const songZoom = (key) => `({ scale: __gm.song.view().${key}, moving: __gm.song.view().moving })`; + const songTracks = await sampleZoom(t, await json("__gm.song.headAt(0, 'name')"), songZoom('trackHeight')); + const songTime = await sampleZoom(t, await json(`__gm.song.laneAt(${2 * BAR}, 0)`), songZoom('pxPerTick')); + check( + 'a zoom of the tracks and of the time is shown on its way and lands where the jump does', + songTracks.ok && songTime.ok, + `${songTracks.detail} ${songTime.detail}`, + ); // 65g. The heads: mute and solo toggle, the volume slider sets decibels, the menu adds a track. await click(...Object.values(await json("__gm.song.headAt(0, 'mute')"))); diff --git a/tools/e2e/lib/zoomTransition.mjs b/tools/e2e/lib/zoomTransition.mjs new file mode 100644 index 00000000..94ef6399 --- /dev/null +++ b/tools/e2e/lib/zoomTransition.mjs @@ -0,0 +1,34 @@ +/** + * A wheel step on one axis of a music tab, once as a jump and once as a transition of 1000 ms (#377). + * `read` returns `{ scale, moving }` – the zoom of that axis and whether a transition is on its way. + * The step is taken back each time with the opposite wheel, so the view ends where it started. + */ +export async function sampleZoom(t, at, read, dy = -100) { + const { evaluate, json, mouse, settle, wheel, waitUntil } = t; + const now = () => json(read); + await evaluate('__gm.zoomTransition(0)'); + const start = await now(); + await wheel(at.x, at.y, dy); + const jumped = await now(); + await wheel(at.x, at.y, -dy); + const back = await now(); + await evaluate('__gm.zoomTransition(1000)'); + await mouse('mouseMoved', at.x, at.y); + await mouse('mouseWheel', at.x, at.y, 'none', { deltaX: 0, deltaY: dy }); + await settle(2); + const midway = await now(); + await waitUntil(`!(${read}).moving`, { label: 'the zoom transition ended' }); + const landed = await now(); + await evaluate('__gm.zoomTransition(0)'); + await wheel(at.x, at.y, -dy); + const between = (v, a, b) => v > Math.min(a, b) && v < Math.max(a, b); + const ok = + jumped.scale !== start.scale && + Math.abs(back.scale - start.scale) < 1e-9 && + midway.moving === true && + between(midway.scale, start.scale, jumped.scale) && + landed.moving === false && + Math.abs(landed.scale - jumped.scale) < 1e-9; + const samples = { start, jumped, back, midway, landed }; + return { ok, samples, detail: JSON.stringify(samples) }; +}