Keep the Sound tab smooth while it pans (#360)
Panning the Sound tab drew the whole wave anew on every move, one filled rectangle per pixel column, and gave the ruler's labels new texts whenever a tick left the area. At 2560 px across that took the tab down to 16 fps. - The wave is drawn half the area's width past either side of it. A pan within that moves the drawing. Zoom, height, size or a pan past the margin draw it anew. - The bars of the wave share one fill instead of one fill each. - A ruler label keeps the second it already shows. Before, a pan shifted every label by one and each had to be rendered anew. - A clip's name is given its style only when the style changes. - The rule for what a drawing covers is pure in `waveView.ts` (`shownSeconds`, `drawnSeconds`, `coversShown`). Measured with a copy of the user's project, "Take 1" with 4 clips, 240 pan moves with the middle button, headless on stein2: - 2560x1440: main thread 712 -> 373 ms/s, texts rendered 694 -> 65 - 1280x800: main thread 429 -> 225 ms/s What is left in the profile is Pixi's render pass and the pointer events, which hovering pays as well. Tests: - Unit wave-view: 12 green (1 new), counter-probe red - e2e 34 (stein2, melektaus-atx was down): ALL PASSED, 92 checks (1 new). Counter-probe with the labels given out by position: red, 7 labels set anew instead of 1. - Gate (stein2): 272 files, 4210 tests green Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
490fcba88f
commit
cf96442835
5 files changed
+160
-15
No files matched your search
@@ -41,6 +41,8 @@ export function soundHooks({ state, editor }: HookContext) {
|
||||
stripAt: (seconds: number) => soundEditor.canvas.stripAt(seconds),
|
||||
/** The clips drawn faint over the wave, by name: all but the picked one. */
|
||||
faintClips: () => soundEditor.canvas.faintClips(),
|
||||
/** Wave draws, its shift from where it was drawn, and ruler labels given a text (#360). */
|
||||
drawInfo: () => soundEditor.canvas.drawInfo(),
|
||||
fadeInField: () => ({ ...centreOf(soundEditor.fadeInField), text: soundEditor.fadeInField.value }),
|
||||
fadeOutField: () => ({ ...centreOf(soundEditor.fadeOutField), text: soundEditor.fadeOutField.value }),
|
||||
/** The curve dropdowns show a picture of their curve; the tooltip names it. */
|
||||
|
||||
@@ -18,8 +18,10 @@ import {
|
||||
clampWave,
|
||||
type ClipBand,
|
||||
clipAt,
|
||||
coversShown,
|
||||
type CutSpan,
|
||||
draggedSpan,
|
||||
drawnSeconds,
|
||||
edgeAt,
|
||||
fileReach,
|
||||
fitWave,
|
||||
@@ -28,6 +30,7 @@ import {
|
||||
rulerLabel,
|
||||
rulerStep,
|
||||
secondsToX,
|
||||
shownSeconds,
|
||||
spanOf,
|
||||
spanPeak,
|
||||
storedCut,
|
||||
@@ -63,6 +66,8 @@ const LIT_LABEL = textStyle('small').clone();
|
||||
LIT_LABEL.fill = 0x30343c;
|
||||
/** Line pieces a fade's curve is drawn with at most. */
|
||||
const RAMP_STEPS = 32;
|
||||
/** The share of the area's width the wave is drawn past either side of it, for a pan to move (#360). */
|
||||
const DRAWN_MARGIN = 0.5;
|
||||
|
||||
/**
|
||||
* A drag that spans: an edge of the clip `clipId`, that clip anew, or with `clipId` null a new clip.
|
||||
@@ -116,6 +121,18 @@ export class WaveCanvas extends Widget {
|
||||
private readonly scaleLabels = new Container();
|
||||
private readonly scaleTexts: Text[] = [];
|
||||
private readonly waveG = new Graphics();
|
||||
/** What `waveG` was drawn for: a pan within `seconds` only moves it. */
|
||||
private drawn: {
|
||||
wave: Wave;
|
||||
panX: number;
|
||||
pxPerSecond: number;
|
||||
level: number;
|
||||
w: number;
|
||||
h: number;
|
||||
seconds: CutSpan | null;
|
||||
} | null = null;
|
||||
private waveDraws = 0;
|
||||
private rulerTexts = 0;
|
||||
private readonly overlay = new Graphics();
|
||||
private readonly labels = new Container();
|
||||
private readonly stripLabels = new Container();
|
||||
@@ -470,7 +487,6 @@ export class WaveCanvas extends Widget {
|
||||
this.bg.rect(SCALE_WIDTH - 1, RULER_HEIGHT, 1, STRIP_HEIGHT + wh).fill(c.border);
|
||||
this.clip.clear().rect(SCALE_WIDTH, 0, w, h).fill(0xffffff);
|
||||
this.scaleG.clear();
|
||||
this.waveG.clear();
|
||||
this.overlay.clear();
|
||||
const asset = this.asset();
|
||||
this.hint.visible = !wave;
|
||||
@@ -483,24 +499,21 @@ export class WaveCanvas extends Widget {
|
||||
: 'Loading…';
|
||||
this.hint.position.set(SCALE_WIDTH + Theme.padding * 2, WAVE_TOP + Theme.padding * 2);
|
||||
if (!wave || !asset) {
|
||||
this.waveG.clear();
|
||||
this.drawn = null;
|
||||
this.syncLabels([]);
|
||||
this.syncScale([]);
|
||||
this.syncStrip([]);
|
||||
return;
|
||||
}
|
||||
const x0 = secondsToX(this.view, 0);
|
||||
const x1 = secondsToX(this.view, wave.duration);
|
||||
const mid = WAVE_TOP + wh / 2;
|
||||
const half = this.level * this.fullHalf;
|
||||
const room = wh / 2;
|
||||
// The file itself a shade lighter than what lies around it, as high as the wave is zoomed out.
|
||||
const reach = fileReach(this.level, this.fullHalf, WAVE_INSET, room);
|
||||
const left = Math.max(0, x0);
|
||||
const right = Math.min(w, x1);
|
||||
if (right > left)
|
||||
this.waveG.rect(left, mid - reach, right - left, 2 * reach).fill({ color: c.panel, alpha: 0.5 });
|
||||
if (right > left) this.waveG.rect(left, mid, right - left, 1).fill({ color: c.border });
|
||||
this.drawWave(wave, left, right, mid, half, room);
|
||||
if (!this.waveStillDrawn(wave)) this.drawFile(wave, mid, half, room, reach);
|
||||
this.waveG.x = this.view.panX - this.drawn!.panX;
|
||||
const left = Math.max(0, secondsToX(this.view, 0));
|
||||
const right = Math.min(w, secondsToX(this.view, wave.duration));
|
||||
this.drawRuler(left, right);
|
||||
this.drawStrip(asset, wave);
|
||||
const span = this.shownClipSpan();
|
||||
@@ -513,6 +526,48 @@ export class WaveCanvas extends Widget {
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether the wave drawn last still serves: same file, zoom and size, and what is shown inside it. */
|
||||
private waveStillDrawn(wave: Wave): boolean {
|
||||
const d = this.drawn;
|
||||
return (
|
||||
d !== null &&
|
||||
d.wave === wave &&
|
||||
d.pxPerSecond === this.view.pxPerSecond &&
|
||||
d.level === this.level &&
|
||||
d.w === this.areaWidth &&
|
||||
d.h === this.h &&
|
||||
coversShown(d.seconds, shownSeconds(this.view, wave.duration, this.areaWidth))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The file a shade lighter than what lies around it, as high as the wave is zoomed out, its middle
|
||||
* line and the wave – past the area by a margin either side, so a pan only moves them (#360).
|
||||
*/
|
||||
private drawFile(wave: Wave, mid: number, half: number, room: number, reach: number): void {
|
||||
const c = Theme.colors;
|
||||
const w = this.areaWidth;
|
||||
const seconds = drawnSeconds(this.view, wave.duration, w, w * DRAWN_MARGIN);
|
||||
const { panX, pxPerSecond } = this.view;
|
||||
this.drawn = { wave, panX, pxPerSecond, level: this.level, w, h: this.h, seconds };
|
||||
this.waveDraws++;
|
||||
this.waveG.clear();
|
||||
if (!seconds) return;
|
||||
const left = secondsToX(this.view, seconds.start);
|
||||
const right = secondsToX(this.view, seconds.end);
|
||||
this.waveG.rect(left, mid - reach, right - left, 2 * reach).fill({ color: c.panel, alpha: 0.5 });
|
||||
this.waveG.rect(left, mid, right - left, 1).fill({ color: c.border });
|
||||
this.drawWave(wave, left, right, mid, half, room);
|
||||
}
|
||||
|
||||
/**
|
||||
* How often the wave has been drawn anew, how far it is moved from where it was drawn, and how often
|
||||
* a ruler label was given a new text (test hook).
|
||||
*/
|
||||
drawInfo(): { waveDraws: number; shift: number; rulerTexts: number } {
|
||||
return { waveDraws: this.waveDraws, shift: this.waveG.x, rulerTexts: this.rulerTexts };
|
||||
}
|
||||
|
||||
/**
|
||||
* A bar from the lowest to the highest sample per pixel, or the samples themselves zoomed far in.
|
||||
* `half` is full scale, what lies beyond `room` stops at the edge of the area.
|
||||
@@ -544,8 +599,10 @@ export class WaveCanvas extends Widget {
|
||||
const { min, max } = spanPeak(wave.channels, wave.peaks, from, to);
|
||||
const top = yOf(max);
|
||||
const bottom = yOf(min);
|
||||
this.waveG.rect(x, top, 1, Math.max(1, bottom - top)).fill(color);
|
||||
this.waveG.rect(x, top, 1, Math.max(1, bottom - top));
|
||||
}
|
||||
// One fill for all the bars: a fill each costs more than the bar itself.
|
||||
this.waveG.fill(color);
|
||||
}
|
||||
|
||||
private drawRuler(left: number, right: number): void {
|
||||
@@ -712,7 +769,8 @@ export class WaveCanvas extends Widget {
|
||||
this.stripTexts.push(text);
|
||||
this.stripLabels.addChild(text);
|
||||
}
|
||||
text.style = label.lit ? LIT_LABEL : textStyle('small');
|
||||
const style = label.lit ? LIT_LABEL : textStyle('small');
|
||||
if (text.style !== style) text.style = style;
|
||||
if (text.text !== label.text) text.text = label.text;
|
||||
for (let cut = label.text.length; text.width > label.room && cut > 1; cut--)
|
||||
text.text = `${label.text.slice(0, cut - 1)}…`;
|
||||
@@ -722,18 +780,28 @@ export class WaveCanvas extends Widget {
|
||||
for (let i = placed.length; i < this.stripTexts.length; i++) this.stripTexts[i]!.visible = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* The ruler's labels. A label already showing a second keeps it: a pan shifts which one is first,
|
||||
* and a text set anew is drawn anew (#360).
|
||||
*/
|
||||
private syncLabels(placed: readonly { text: string; x: number }[]): void {
|
||||
const shown = new Map(this.texts.filter((t) => t.visible).map((t) => [t.text, t]));
|
||||
const kept = placed.map((label) => shown.get(label.text));
|
||||
const spare = this.texts.filter((t) => !kept.includes(t));
|
||||
for (const text of spare) text.visible = false;
|
||||
placed.forEach((label, i) => {
|
||||
let text = this.texts[i];
|
||||
let text = kept[i] ?? spare.pop();
|
||||
if (!text) {
|
||||
text = crispText({ style: textStyle('small') });
|
||||
this.texts.push(text);
|
||||
this.labels.addChild(text);
|
||||
}
|
||||
text.visible = true;
|
||||
if (text.text !== label.text) text.text = label.text;
|
||||
if (text.text !== label.text) {
|
||||
text.text = label.text;
|
||||
this.rulerTexts++;
|
||||
}
|
||||
text.position.set(label.x, 6);
|
||||
});
|
||||
for (let i = placed.length; i < this.texts.length; i++) this.texts[i]!.visible = false;
|
||||
}
|
||||
}
|
||||
@@ -49,6 +49,32 @@ export function clampWave(view: WaveView, duration: number, width: number): Wave
|
||||
return { ...view, panX: clamp(view.panX, least, lead) };
|
||||
}
|
||||
|
||||
/** The seconds of the file the area shows, held inside the file; null where none of it is in view. */
|
||||
export function shownSeconds(view: WaveView, duration: number, width: number): CutSpan | null {
|
||||
const start = Math.max(0, xToSeconds(view, 0));
|
||||
const end = Math.min(duration, xToSeconds(view, width));
|
||||
return end > start ? { start, end } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The seconds a drawn wave covers: what the area shows and `margin` pixels either side, so a pan by
|
||||
* less moves the drawing instead of drawing it anew (#360).
|
||||
*/
|
||||
export function drawnSeconds(
|
||||
view: WaveView,
|
||||
duration: number,
|
||||
width: number,
|
||||
margin: number,
|
||||
): CutSpan | null {
|
||||
return shownSeconds({ ...view, panX: view.panX + margin }, duration, width + 2 * margin);
|
||||
}
|
||||
|
||||
/** Whether a wave drawn over `drawn` still covers what is `shown`. */
|
||||
export function coversShown(drawn: CutSpan | null, shown: CutSpan | null): boolean {
|
||||
if (!shown) return true;
|
||||
return drawn !== null && drawn.start <= shown.start && drawn.end >= shown.end;
|
||||
}
|
||||
|
||||
/** How far the wave's height zooms: out to a quarter, in to a sixteenth of full scale (#357). */
|
||||
export const MIN_LEVEL = 0.25;
|
||||
export const MAX_LEVEL = 16;
|
||||
|
||||
@@ -5,7 +5,9 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
clampWave,
|
||||
coversShown,
|
||||
draggedSpan,
|
||||
drawnSeconds,
|
||||
edgeAt,
|
||||
fileReach,
|
||||
fitWave,
|
||||
@@ -15,6 +17,7 @@ import {
|
||||
rulerLabel,
|
||||
rulerStep,
|
||||
secondsToX,
|
||||
shownSeconds,
|
||||
spanOf,
|
||||
spanPeak,
|
||||
storedCut,
|
||||
@@ -83,6 +86,23 @@ describe('the wave view', () => {
|
||||
expect(rulerLabel(0.25, 0.05)).toBe('0.25s');
|
||||
expect(rulerLabel(75, 5)).toBe('1:15');
|
||||
});
|
||||
|
||||
it('draws the wave past the area by a margin, and a pan within it keeps that drawing (#360)', () => {
|
||||
// 20 s at 100 px/s, 1000 px across: seconds 5 to 15 shown, 0 to 20 with 500 px either side.
|
||||
const view = { panX: -500, pxPerSecond: 100 };
|
||||
expect(shownSeconds(view, 20, 1000)).toEqual({ start: 5, end: 15 });
|
||||
const drawn = drawnSeconds(view, 20, 1000, 500);
|
||||
expect(drawn).toEqual({ start: 0, end: 20 });
|
||||
const near = drawnSeconds({ ...view, panX: -1000 }, 20, 1000, 300);
|
||||
expect(near).toEqual({ start: 7, end: 20 });
|
||||
// Panned 200 px it still covers what is shown, 400 px past it no longer.
|
||||
expect(coversShown(near, shownSeconds({ ...view, panX: -800 }, 20, 1000))).toBe(true);
|
||||
expect(coversShown(near, shownSeconds({ ...view, panX: -600 }, 20, 1000))).toBe(false);
|
||||
// Nothing of the file in view: nothing to cover, and nothing drawn.
|
||||
expect(shownSeconds({ panX: 1200, pxPerSecond: 100 }, 20, 1000)).toBeNull();
|
||||
expect(coversShown(null, null)).toBe(true);
|
||||
expect(coversShown(null, { start: 0, end: 1 })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the cut span', () => {
|
||||
|
||||
@@ -1980,6 +1980,35 @@ export async function sound(t) {
|
||||
Math.abs(refitted.top - fullTop) < 0.5,
|
||||
JSON.stringify({ levelScale, levelRect, fullTop, secondAt, zoomedOut, zoomedIn, refitted }),
|
||||
);
|
||||
// 34h-2b. A pan moves the wave drawn already and leaves each ruler label on its second (#360):
|
||||
// drawing both anew on every move took the frames down.
|
||||
const panFrom = await json('__gm.soundTab.at(0.375)');
|
||||
await t.wheel(panFrom.x, panFrom.y, -924);
|
||||
const panInfo = async () => ({
|
||||
...(await json('__gm.soundTab.drawInfo()')),
|
||||
at: (await json('__gm.soundTab.at(0.375)')).x,
|
||||
});
|
||||
const panBefore = await panInfo();
|
||||
await t.mouse('mouseMoved', panFrom.x, panFrom.y);
|
||||
await t.mouse('mousePressed', panFrom.x, panFrom.y, 'middle');
|
||||
for (let i = 1; i <= 8; i++) {
|
||||
await t.mouse('mouseMoved', panFrom.x - 30 * i, panFrom.y, 'middle', { buttons: 4 });
|
||||
await sleep(20);
|
||||
}
|
||||
await t.mouse('mouseReleased', panFrom.x - 240, panFrom.y, 'middle');
|
||||
await sleep(100);
|
||||
const panAfter = await panInfo();
|
||||
const panMoved = panAfter.at - panBefore.at;
|
||||
await click(levelFit.x, levelFit.y);
|
||||
await sleep(100);
|
||||
check(
|
||||
'a pan moves the wave drawn already, and the ruler labels keep their seconds',
|
||||
Math.abs(panMoved + 240) < 1 &&
|
||||
panAfter.waveDraws === panBefore.waveDraws &&
|
||||
Math.abs(panAfter.shift - panBefore.shift - panMoved) < 0.5 &&
|
||||
panAfter.rulerTexts - panBefore.rulerTexts <= 2,
|
||||
JSON.stringify({ panBefore, panAfter, panMoved }),
|
||||
);
|
||||
const clipProps = async () => (await clips())[0]?.props;
|
||||
let dragFrom = await json('__gm.soundTab.stripAt(0.1)');
|
||||
let dragTo = await json('__gm.soundTab.stripAt(0.3)');
|
||||
|
||||
Reference in new issue
Block a user