Show the zoom of the music tabs and the EQ as a transition (#377)
The zoom transition of #309 left out the Pattern, Song and Sound tabs
and the Parametric EQ's curve. There a wheel step or Fit still jumped,
whatever "Zoom (ms)" in the general settings said.
- axisTween wraps ViewTween for a single axis. The music tabs zoom
their time across and their rows, tracks or height down apart, so
each keeps one tween per axis and either goes its own way.
- Fit by its button is shown as a transition too, as in the other
tabs. The fit on opening a document still jumps.
- A middle-button pan first lands a transition under way, and saving
a document's view lands it on the step it was heading for.
- The EQ's range is saved on the node at once. While the zoom is on
its way the curve draws a range of its own and writes nothing.
Its dB lines are those of the new range, sliding to their place,
so no line shows a crooked number.
Found on the way, not part of #377:
- CI task 611 for 82e96e6 failed the stream check of e2e 34. The
renderer had grown by 60.1 MB with nothing decoded. The check
summed the RSS of every Chrome renderer, and the test Chrome runs
four of them. It now measures the largest one, the tab's own, and
writes all of them into the check's detail.
Tests:
- view-tween.test.ts: 18 tests, 2 new. They fail when axisTween
writes the wrong offset.
- e2e 34, 64, 65 on melektaus-atx: ALL PASSED, 202 checks, 4 new.
At 1000 ms each zoom is between its two steps a frame after the
wheel and lands on the step a jump reaches.
- Stream check: the tab's renderer grew by 8.6 MB streamed and by
66.9 MB decoded.
- Gate on melektaus-atx: CHECK EXIT 0, 280 files, 4321 tests.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
82e96e680f
commit
23922ee4a2
13 files changed
+315
-32
No files matched your search
@@ -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:
|
The browser suite and the three example games need a machine with a GPU:
|
||||||
|
|
||||||
```bash
|
```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
|
# every tool signs in first – see the Accounts page
|
||||||
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
|
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
|
||||||
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
|
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
|
||||||
|
|||||||
@@ -126,6 +126,8 @@ export function graphHooks({ state, editor }: HookContext) {
|
|||||||
picked: () => editor.eqEditor.picked,
|
picked: () => editor.eqEditor.picked,
|
||||||
/** The dB the curve is zoomed to, above and below 0 (#376). */
|
/** The dB the curve is zoomed to, above and below 0 (#376). */
|
||||||
range: () => editor.eqEditor.range(),
|
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),
|
handleAt: (index: number) => editor.eqEditor.curve.handleGlobal(index),
|
||||||
pointAt: (hz: number, db: number) => editor.eqEditor.curve.pointGlobal(hz, db),
|
pointAt: (hz: number, db: number) => editor.eqEditor.curve.pointGlobal(hz, db),
|
||||||
fields: () => {
|
fields: () => {
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ export function soundHooks({ state, editor }: HookContext) {
|
|||||||
level: () => soundEditor.canvas.levelZoom,
|
level: () => soundEditor.canvas.levelZoom,
|
||||||
scale: () => soundEditor.canvas.scaleCentre(),
|
scale: () => soundEditor.canvas.scaleCentre(),
|
||||||
fullScaleTop: () => soundEditor.canvas.fullScaleTop(),
|
fullScaleTop: () => soundEditor.canvas.fullScaleTop(),
|
||||||
|
/** The time zoom, and whether a zoom is on its way (#377). */
|
||||||
|
zoom: () => soundEditor.canvas.zoomInfo(),
|
||||||
fitButton: () => centreOf(soundEditor.fitButton),
|
fitButton: () => centreOf(soundEditor.fitButton),
|
||||||
cursor: () => soundEditor.canvas.cursor,
|
cursor: () => soundEditor.canvas.cursor,
|
||||||
playhead: () => soundEditor.canvas.playhead,
|
playhead: () => soundEditor.canvas.playhead,
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ import {
|
|||||||
zoomTime,
|
zoomTime,
|
||||||
} from '../music/timeView';
|
} from '../music/timeView';
|
||||||
import type { EditorState } from '../state';
|
import type { EditorState } from '../state';
|
||||||
|
import { axisTween } from '../viewTween';
|
||||||
import {
|
import {
|
||||||
clampShift,
|
clampShift,
|
||||||
draggedLength,
|
draggedLength,
|
||||||
@@ -176,6 +177,17 @@ export class PianoRoll extends Widget {
|
|||||||
private readonly hint = crispText({ text: '', style: textStyle('muted') });
|
private readonly hint = crispText({ text: '', style: textStyle('muted') });
|
||||||
private readonly laneLabel = crispText({ text: 'Velocity', style: textStyle('small') });
|
private readonly laneLabel = crispText({ text: 'Velocity', style: textStyle('small') });
|
||||||
private view: RollView = { panX: 0, pxPerTick: 0.1, panY: 0, rowHeight: DEFAULT_ROW_HEIGHT };
|
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 needsFit = true;
|
||||||
private assetId = '';
|
private assetId = '';
|
||||||
private gesture: Gesture | null = null;
|
private gesture: Gesture | null = null;
|
||||||
@@ -268,6 +280,8 @@ export class PianoRoll extends Widget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
saveView(): RollView {
|
saveView(): RollView {
|
||||||
|
this.timeTween.land();
|
||||||
|
this.rowTween.land();
|
||||||
return { ...this.view };
|
return { ...this.view };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -277,8 +291,13 @@ export class PianoRoll extends Widget {
|
|||||||
this.redraw();
|
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 {
|
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 asset = this.asset();
|
||||||
const width = this.gridWidth;
|
const width = this.gridWidth;
|
||||||
const height = this.gridHeight;
|
const height = this.gridHeight;
|
||||||
@@ -484,10 +503,12 @@ export class PianoRoll extends Widget {
|
|||||||
keys: number | null;
|
keys: number | null;
|
||||||
hover: NoteHit | null;
|
hover: NoteHit | null;
|
||||||
cursor: string;
|
cursor: string;
|
||||||
|
moving: boolean;
|
||||||
} {
|
} {
|
||||||
const g = this.gesture;
|
const g = this.gesture;
|
||||||
return {
|
return {
|
||||||
...this.view,
|
...this.view,
|
||||||
|
moving: this.timeTween.moving || this.rowTween.moving,
|
||||||
start: this.startTick,
|
start: this.startTick,
|
||||||
playhead: this.playhead,
|
playhead: this.playhead,
|
||||||
loop: this.shownLoop(),
|
loop: this.shownLoop(),
|
||||||
@@ -545,6 +566,8 @@ export class PianoRoll extends Widget {
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
ui().focus.focus(this);
|
ui().focus.focus(this);
|
||||||
if (e.button === 1) {
|
if (e.button === 1) {
|
||||||
|
this.timeTween.finish();
|
||||||
|
this.rowTween.finish();
|
||||||
this.gesture = { kind: 'pan', pointer: p, panX: this.view.panX, panY: this.view.panY };
|
this.gesture = { kind: 'pan', pointer: p, panX: this.view.panX, panY: this.view.panY };
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -900,14 +923,16 @@ export class PianoRoll extends Widget {
|
|||||||
const area = this.areaAt({ x: local.x, y: local.y });
|
const area = this.areaAt({ x: local.x, y: local.y });
|
||||||
const g = this.inGrid({ x: local.x, y: local.y });
|
const g = this.inGrid({ x: local.x, y: local.y });
|
||||||
const factor = zoomFactor(e.deltaY);
|
const factor = zoomFactor(e.deltaY);
|
||||||
let view: RollView;
|
const rows = area === 'keys';
|
||||||
if (area === 'keys') view = zoomRows(this.view, factor, g.y);
|
if (!rows && area !== 'ruler' && area !== 'grid' && area !== 'lane') return;
|
||||||
else if (area === 'ruler' || area === 'grid' || area === 'lane')
|
(rows ? this.rowTween : this.timeTween).change(() => {
|
||||||
view = { ...this.view, ...zoomTime(this.view, factor, g.x) };
|
const view = rows
|
||||||
else return;
|
? 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.view = clampRows(clampTimePan(view, this.gridWidth, this.earliest), this.gridHeight);
|
||||||
this.needsFit = false;
|
this.needsFit = false;
|
||||||
this.redraw();
|
this.redraw();
|
||||||
|
});
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -951,12 +976,12 @@ export class PianoRoll extends Widget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override layout(): void {
|
override layout(): void {
|
||||||
if (this.needsFit) this.fit();
|
if (this.needsFit) this.frame();
|
||||||
}
|
}
|
||||||
|
|
||||||
override redraw(): void {
|
override redraw(): void {
|
||||||
if (this.needsFit && this.gridWidth > 0 && this.gridHeight > 0 && this.asset()) {
|
if (this.needsFit && this.gridWidth > 0 && this.gridHeight > 0 && this.asset()) {
|
||||||
this.fit();
|
this.frame();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const c = Theme.colors;
|
const c = Theme.colors;
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ import {
|
|||||||
} from '../music/timeView';
|
} from '../music/timeView';
|
||||||
import { snapDown, snapNear } from '../pattern/rollEdit';
|
import { snapDown, snapNear } from '../pattern/rollEdit';
|
||||||
import type { EditorState } from '../state';
|
import type { EditorState } from '../state';
|
||||||
|
import { axisTween } from '../viewTween';
|
||||||
import { drawMeter } from './meterDraw';
|
import { drawMeter } from './meterDraw';
|
||||||
import {
|
import {
|
||||||
gainToDb,
|
gainToDb,
|
||||||
@@ -240,6 +241,17 @@ export class SongTimeline extends Widget {
|
|||||||
private readonly clips = { lanes: new Graphics(), heads: new Graphics(), ruler: new Graphics() };
|
private readonly clips = { lanes: new Graphics(), heads: new Graphics(), ruler: new Graphics() };
|
||||||
private readonly hint = crispText({ text: '', style: textStyle('muted') });
|
private readonly hint = crispText({ text: '', style: textStyle('muted') });
|
||||||
private view: SongView = { panX: 0, pxPerTick: 0.02, panY: 0, trackHeight: TRACK_HEIGHT };
|
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 needsFit = true;
|
||||||
private assetId = '';
|
private assetId = '';
|
||||||
private gesture: Gesture | null = null;
|
private gesture: Gesture | null = null;
|
||||||
@@ -340,6 +352,8 @@ export class SongTimeline extends Widget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
saveView(): SongView {
|
saveView(): SongView {
|
||||||
|
this.timeTween.land();
|
||||||
|
this.trackTween.land();
|
||||||
return { ...this.view };
|
return { ...this.view };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -349,8 +363,13 @@ export class SongTimeline extends Widget {
|
|||||||
this.redraw();
|
this.redraw();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The whole song across and all its tracks down, with some room around. */
|
/** Fit by hand, shown as a transition. */
|
||||||
fit(): void {
|
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();
|
const asset = this.asset();
|
||||||
if (!asset || this.lanesWidth <= 0 || this.lanesHeight <= 0) {
|
if (!asset || this.lanesWidth <= 0 || this.lanesHeight <= 0) {
|
||||||
this.needsFit = true;
|
this.needsFit = true;
|
||||||
@@ -660,9 +679,11 @@ export class SongTimeline extends Widget {
|
|||||||
hover: { id: string; part: ItemPart } | null;
|
hover: { id: string; part: ItemPart } | null;
|
||||||
cursor: string;
|
cursor: string;
|
||||||
take: (Omit<TakeView, 'peaks'> & { peaks: number }) | null;
|
take: (Omit<TakeView, 'peaks'> & { peaks: number }) | null;
|
||||||
|
moving: boolean;
|
||||||
} {
|
} {
|
||||||
return {
|
return {
|
||||||
...this.view,
|
...this.view,
|
||||||
|
moving: this.timeTween.moving || this.trackTween.moving,
|
||||||
start: this.startTick,
|
start: this.startTick,
|
||||||
playhead: this.playhead,
|
playhead: this.playhead,
|
||||||
loop: this.shownLoop(),
|
loop: this.shownLoop(),
|
||||||
@@ -735,6 +756,8 @@ export class SongTimeline extends Widget {
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
ui().focus.focus(this);
|
ui().focus.focus(this);
|
||||||
if (e.button === 1) {
|
if (e.button === 1) {
|
||||||
|
this.timeTween.finish();
|
||||||
|
this.trackTween.finish();
|
||||||
this.gesture = { kind: 'pan', pointer: p, panX: this.view.panX, panY: this.view.panY };
|
this.gesture = { kind: 'pan', pointer: p, panX: this.view.panX, panY: this.view.panY };
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1263,14 +1286,16 @@ export class SongTimeline extends Widget {
|
|||||||
const area = this.areaAt({ x: local.x, y: local.y });
|
const area = this.areaAt({ x: local.x, y: local.y });
|
||||||
const g = this.inLanes({ x: local.x, y: local.y });
|
const g = this.inLanes({ x: local.x, y: local.y });
|
||||||
const factor = zoomFactor(e.deltaY);
|
const factor = zoomFactor(e.deltaY);
|
||||||
let view: SongView;
|
const tracks = area === 'heads';
|
||||||
if (area === 'heads') view = zoomTracks(this.view, factor, g.y);
|
if (!tracks && area !== 'ruler' && area !== 'lanes') return;
|
||||||
else if (area === 'ruler' || area === 'lanes')
|
(tracks ? this.trackTween : this.timeTween).change(() => {
|
||||||
view = { ...this.view, ...zoomTime(this.view, factor, g.x, SONG_MAX_PX_PER_TICK) };
|
const view = tracks
|
||||||
else return;
|
? 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.view = clampTracks(clampTimePan(view, this.lanesWidth), asset.tracks.length, this.lanesHeight);
|
||||||
this.needsFit = false;
|
this.needsFit = false;
|
||||||
this.redraw();
|
this.redraw();
|
||||||
|
});
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1374,12 +1399,12 @@ export class SongTimeline extends Widget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override layout(): void {
|
override layout(): void {
|
||||||
if (this.needsFit) this.fit();
|
if (this.needsFit) this.frame();
|
||||||
}
|
}
|
||||||
|
|
||||||
override redraw(): void {
|
override redraw(): void {
|
||||||
if (this.needsFit && this.lanesWidth > 0 && this.lanesHeight > 0 && this.asset()) {
|
if (this.needsFit && this.lanesWidth > 0 && this.lanesHeight > 0 && this.asset()) {
|
||||||
this.fit();
|
this.frame();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const c = Theme.colors;
|
const c = Theme.colors;
|
||||||
|
|||||||
@@ -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 { crispText, type Size, textStyle, Theme, ui, Widget, zoomFactor } from '../../ui';
|
||||||
import { soundLibrary } from '../../viewport/SoundLibrary';
|
import { soundLibrary } from '../../viewport/SoundLibrary';
|
||||||
import type { EditorState } from '../state';
|
import type { EditorState } from '../state';
|
||||||
|
import { axisTween } from '../viewTween';
|
||||||
import {
|
import {
|
||||||
clampWave,
|
clampWave,
|
||||||
type ClipBand,
|
type ClipBand,
|
||||||
@@ -145,6 +146,17 @@ export class WaveCanvas extends Widget {
|
|||||||
private view: WaveView = { panX: FIT_MARGIN, pxPerSecond: 100 };
|
private view: WaveView = { panX: FIT_MARGIN, pxPerSecond: 100 };
|
||||||
/** How many times its full height the wave is drawn. */
|
/** How many times its full height the wave is drawn. */
|
||||||
private level = 1;
|
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 needsFit = true;
|
||||||
private assetId = '';
|
private assetId = '';
|
||||||
private gesture: Gesture | null = null;
|
private gesture: Gesture | null = null;
|
||||||
@@ -192,6 +204,8 @@ export class WaveCanvas extends Widget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
saveView(): WaveView & { level: number } {
|
saveView(): WaveView & { level: number } {
|
||||||
|
this.timeTween.land();
|
||||||
|
this.levelTween.land();
|
||||||
return { ...this.view, level: this.level };
|
return { ...this.view, level: this.level };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -204,8 +218,13 @@ export class WaveCanvas extends Widget {
|
|||||||
this.redraw();
|
this.redraw();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The whole file across, at its full height. */
|
/** Fit by hand, shown as a transition. */
|
||||||
fit(): void {
|
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();
|
const wave = this.wave();
|
||||||
if (!wave || this.areaWidth <= 0) {
|
if (!wave || this.areaWidth <= 0) {
|
||||||
this.needsFit = true;
|
this.needsFit = true;
|
||||||
@@ -222,6 +241,11 @@ export class WaveCanvas extends Widget {
|
|||||||
return this.level;
|
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). */
|
/** The middle of the level scale on screen (test hook). */
|
||||||
scaleCentre(): Vec2 {
|
scaleCentre(): Vec2 {
|
||||||
const origin = this.getGlobalPosition();
|
const origin = this.getGlobalPosition();
|
||||||
@@ -353,6 +377,7 @@ export class WaveCanvas extends Widget {
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
ui().focus.focus(this);
|
ui().focus.focus(this);
|
||||||
if (e.button === 1) {
|
if (e.button === 1) {
|
||||||
|
this.timeTween.finish();
|
||||||
this.gesture = { kind: 'pan', pointerX: p.x, panX: this.view.panX };
|
this.gesture = { kind: 'pan', pointerX: p.x, panX: this.view.panX };
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -453,10 +478,14 @@ export class WaveCanvas extends Widget {
|
|||||||
const wave = this.wave();
|
const wave = this.wave();
|
||||||
if (!wave) return;
|
if (!wave) return;
|
||||||
const p = this.areaPoint(e);
|
const p = this.areaPoint(e);
|
||||||
if (p.x < 0) this.level = zoomLevel(this.level, zoomFactor(e.deltaY));
|
const level = p.x < 0;
|
||||||
else this.view = clampWave(zoomWave(this.view, zoomFactor(e.deltaY), p.x), wave.duration, this.areaWidth);
|
(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.needsFit = false;
|
||||||
this.redraw();
|
this.redraw();
|
||||||
|
});
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -468,12 +497,12 @@ export class WaveCanvas extends Widget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override layout(): void {
|
override layout(): void {
|
||||||
if (this.needsFit) this.fit();
|
if (this.needsFit) this.frame();
|
||||||
}
|
}
|
||||||
|
|
||||||
override redraw(): void {
|
override redraw(): void {
|
||||||
const wave = this.wave();
|
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 c = Theme.colors;
|
||||||
const { h } = this;
|
const { h } = this;
|
||||||
const w = this.areaWidth;
|
const w = this.areaWidth;
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ import {
|
|||||||
import { FramePreview } from '../framePreview';
|
import { FramePreview } from '../framePreview';
|
||||||
import type { EditorState } from '../state';
|
import type { EditorState } from '../state';
|
||||||
import { TabHeader } from '../TabHeader';
|
import { TabHeader } from '../TabHeader';
|
||||||
|
import { ViewTween } from '../viewTween';
|
||||||
import {
|
import {
|
||||||
bandAt,
|
bandAt,
|
||||||
EQ_GRID_FREQUENCIES,
|
EQ_GRID_FREQUENCIES,
|
||||||
@@ -110,6 +111,16 @@ class EqCurve extends Widget {
|
|||||||
private drag: { index: number; startX: number; startY: number; moved: boolean } | null = null;
|
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. */
|
/** The spectrum drawn now; null while nothing has passed the node or it has fallen away. */
|
||||||
private heard: Spectrum | null = null;
|
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). */
|
/** 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 };
|
seen = { frames: 0, peakIn: 0, peakOut: 0, levelOut: -Infinity, binHz: 0 };
|
||||||
|
|
||||||
@@ -139,7 +150,22 @@ class EqCurve extends Widget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
get area(): EqArea {
|
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. */
|
/** 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 index = bandAt(bands, at.x, at.y, this.area, GRAB_RADIUS);
|
||||||
const band = bands[index];
|
const band = bands[index];
|
||||||
if (!band) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
if (!hasQ(band.type)) return;
|
if (!hasQ(band.type)) return;
|
||||||
@@ -245,12 +275,13 @@ class EqCurve extends Widget {
|
|||||||
const text = this.labels[label++]!;
|
const text = this.labels[label++]!;
|
||||||
text.position.set(Math.min(this.w - text.width - 2, x + 3), this.h - text.height - 2);
|
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) => {
|
this.dbLabels.forEach((text, i) => {
|
||||||
const db = dbs[i];
|
const db = dbs[i];
|
||||||
text.visible = db !== undefined;
|
const y = db === undefined ? -1 : Math.round(dbToY(db, area)) + 0.5;
|
||||||
if (db === undefined) return;
|
text.visible = y > 0 && y < this.h;
|
||||||
const y = Math.round(dbToY(db, area)) + 0.5;
|
if (!text.visible || db === undefined) return;
|
||||||
this.grid.moveTo(0, y).lineTo(this.w, y);
|
this.grid.moveTo(0, y).lineTo(this.w, y);
|
||||||
text.text = db > 0 ? `+${db}` : `${db}`;
|
text.text = db > 0 ? `+${db}` : `${db}`;
|
||||||
text.position.set(3, y - text.height - 1);
|
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. */
|
/** Points the tab at a node; the band picked stays when it is the same node again. */
|
||||||
show(assetId: string, nodeId: string): void {
|
show(assetId: string, nodeId: string): void {
|
||||||
if (assetId !== this.assetId || nodeId !== this.nodeId) {
|
if (assetId !== this.assetId || nodeId !== this.nodeId) {
|
||||||
|
this.curve.settle();
|
||||||
this.perFrame.cancel();
|
this.perFrame.cancel();
|
||||||
this.curve.showSpectrum(null);
|
this.curve.showSpectrum(null);
|
||||||
this.before = null;
|
this.before = null;
|
||||||
|
|||||||
@@ -154,3 +154,31 @@ export class ViewTween {
|
|||||||
this.clock.remove(this.tick);
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
} from '../../src/editor/generalSettings';
|
} from '../../src/editor/generalSettings';
|
||||||
import { zoomedAbout } from '../../src/editor/graph/graphView';
|
import { zoomedAbout } from '../../src/editor/graph/graphView';
|
||||||
import { localToImage, zoomAbout } from '../../src/editor/texture/sheetView';
|
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() {
|
function fakeClock() {
|
||||||
let time = 0;
|
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<typeof fakeClock>) {
|
||||||
|
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', () => {
|
describe('the general settings', () => {
|
||||||
function memoryStore() {
|
function memoryStore() {
|
||||||
const items = new Map<string, string>();
|
const items = new Map<string, string>();
|
||||||
|
|||||||
+36
-3
@@ -3,6 +3,7 @@ import { sineWav } from '../lib/audio.mjs';
|
|||||||
import { edge, node, port, variable, written } from '../lib/builders.mjs';
|
import { edge, node, port, variable, written } from '../lib/builders.mjs';
|
||||||
import { uploadSound } from '../lib/projects.mjs';
|
import { uploadSound } from '../lib/projects.mjs';
|
||||||
import { LANES, MAKE_OBJECT, pickAssetType } from './lib/scaffold.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 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,
|
eqZoomUndone.q < eqWheeled.q,
|
||||||
JSON.stringify({ eqHandleBack, eqZoomed, eqZoomUndone }),
|
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');
|
await shot('34-parametric-eq');
|
||||||
// #374: floated beside the Sound FX tab, the EQ's tab draws the spectrum of what passes the node:
|
// #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
|
// 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,
|
Math.abs(refitted.top - fullTop) < 0.5,
|
||||||
JSON.stringify({ levelScale, levelRect, fullTop, secondAt, zoomedOut, zoomedIn, refitted }),
|
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):
|
// 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.
|
// drawing both anew on every move took the frames down.
|
||||||
const panFrom = await json('__gm.soundTab.at(0.375)');
|
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
|
// 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.
|
// 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 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')
|
execSync('ps -eo rss,args')
|
||||||
.toString()
|
.toString()
|
||||||
.split('\n')
|
.split('\n')
|
||||||
.filter((line) => line.includes('--type=renderer'))
|
.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 () => {
|
const restingMb = async () => {
|
||||||
await t.cdp.send('HeapProfiler.collectGarbage');
|
await t.cdp.send('HeapProfiler.collectGarbage');
|
||||||
await sleep(1500);
|
await sleep(1500);
|
||||||
@@ -2631,6 +2655,7 @@ export async function sound(t) {
|
|||||||
|
|
||||||
const decodedBefore = (await stats()).decoded;
|
const decodedBefore = (await stats()).decoded;
|
||||||
const restStreamed = await restingMb();
|
const restStreamed = await restingMb();
|
||||||
|
const renderersBefore = renderers();
|
||||||
await play();
|
await play();
|
||||||
const tapsLong = JSON.stringify(['mix', 'mix:sn-bus']);
|
const tapsLong = JSON.stringify(['mix', 'mix:sn-bus']);
|
||||||
await json(`__gm.routing.peaks(${tapsLong})`);
|
await json(`__gm.routing.peaks(${tapsLong})`);
|
||||||
@@ -2641,6 +2666,7 @@ export async function sound(t) {
|
|||||||
);
|
);
|
||||||
await sleep(2500);
|
await sleep(2500);
|
||||||
const playingStreamed = rendererMb();
|
const playingStreamed = rendererMb();
|
||||||
|
const renderersPlaying = renderers();
|
||||||
await t.clickToolbar('pause');
|
await t.clickToolbar('pause');
|
||||||
await sleep(500);
|
await sleep(500);
|
||||||
const paused = { clock: await json('__gm.sound.clock()'), state: await json('__gm.sound.clockState()') };
|
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 &&
|
streaming.decoded === decodedBefore &&
|
||||||
playingStreamed - restStreamed < 20 &&
|
playingStreamed - restStreamed < 20 &&
|
||||||
Math.max(...(heardLong?.mix ?? [0])) > 0.01,
|
Math.max(...(heardLong?.mix ?? [0])) > 0.01,
|
||||||
JSON.stringify({ restStreamed, playingStreamed, streaming, heardLong }),
|
JSON.stringify({
|
||||||
|
restStreamed,
|
||||||
|
playingStreamed,
|
||||||
|
renderersBefore,
|
||||||
|
renderersPlaying,
|
||||||
|
streaming,
|
||||||
|
heardLong,
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
check(
|
check(
|
||||||
'the pause holds the stream where it is, it plays on after it without a sample lost, and stopping ends it',
|
'the pause holds the stream where it is, it plays on after it without a sample lost, and stopping ends it',
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { edge, node } from '../lib/builders.mjs';
|
import { edge, node } from '../lib/builders.mjs';
|
||||||
import { LANES, pickAssetType, scaffoldBehavior } from './lib/scaffold.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
|
* 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,
|
keysWheel.panX === gridWheel.panX,
|
||||||
JSON.stringify({ scrubbed, wheelFrom, gridWheel, keysWheel }),
|
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
|
// 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.
|
// click left of it draws nothing. Fit takes the view back.
|
||||||
const area = await json('__gm.pattern.rect()');
|
const area = await json('__gm.pattern.rect()');
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { edge, node } from '../lib/builders.mjs';
|
import { edge, node } from '../lib/builders.mjs';
|
||||||
import { LANES, pickAssetType, scaffoldBehavior } from './lib/scaffold.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
|
* 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,
|
(await model()).loop === null,
|
||||||
JSON.stringify({ unzoomed, taller, wider, songLoop }),
|
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.
|
// 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')")));
|
await click(...Object.values(await json("__gm.song.headAt(0, 'mute')")));
|
||||||
|
|||||||
@@ -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) };
|
||||||
|
}
|
||||||
Reference in new issue
Block a user