Files
feuerwerk/tests/editor/wave-view.test.ts
T
adminandClaude Opus 5.5 cf96442835
game-maker / gate (push) Successful in 3m51s
game-maker / suite (push) Successful in 15m55s
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>
2026-10-09 06:19:05 +02:00

156 lines
6.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* The Sound tab's view (#343): seconds across, zoomed about the pointer, and the stretch of the
* file between two handles – how a drag moves it and what the asset then stores.
*/
import { describe, expect, it } from 'vitest';
import {
clampWave,
coversShown,
draggedSpan,
drawnSeconds,
edgeAt,
fileReach,
fitWave,
levelMarks,
MAX_LEVEL,
MIN_LEVEL,
rulerLabel,
rulerStep,
secondsToX,
shownSeconds,
spanOf,
spanPeak,
storedCut,
wavePeaks,
xToSeconds,
zoomLevel,
zoomWave,
} from '../../src/editor/sound/waveView';
describe('the height of the wave (#357)', () => {
it('zooms between a quarter and sixteen times full scale', () => {
expect(zoomLevel(1, 2)).toBe(2);
expect(zoomLevel(1, 0.5)).toBe(0.5);
expect(zoomLevel(0.3, 0.5)).toBe(MIN_LEVEL);
expect(zoomLevel(12, 2)).toBe(MAX_LEVEL);
});
it('marks every 6 dB that lies inside the area and not too close to the middle', () => {
// Level 1, full scale 100 px from the middle: 0 dB at 100, -6 at 50, -12 at 25, -18 at 12.6.
const marks = levelMarks(1, 100, 110, 14);
expect(marks.map((m) => m.db)).toEqual([0, -6, -12]);
expect(marks[1]!.offset).toBeCloseTo(50.1, 1);
// Zoomed out, -6 dB lies 12.5 px from the middle and is left out.
expect(levelMarks(0.25, 100, 110, 14).map((m) => m.db)).toEqual([0]);
// Zoomed in, 0 dB lies past the edge and the quiet ones show.
const loud = levelMarks(16, 100, 110, 14).map((m) => m.db);
expect(loud[0]).toBe(-24);
expect(loud).not.toContain(0);
});
it('lets the file and its clip reach as high as full scale zoomed out, never past the area', () => {
// Full scale 100 px from the middle at level 1, 6 px inset, 106 px of room.
expect(fileReach(1, 100, 6, 106)).toBe(106);
expect(fileReach(0.25, 100, 6, 106)).toBe(31);
expect(fileReach(4, 100, 6, 106)).toBe(106);
});
});
describe('the wave view', () => {
it('zooms about the pointer and fits the file with a margin either side', () => {
const view = { panX: 10, pxPerSecond: 100 };
const zoomed = zoomWave(view, 2, 210);
expect(xToSeconds(zoomed, 210)).toBeCloseTo(xToSeconds(view, 210));
expect(zoomed.pxPerSecond).toBe(200);
const fitted = fitWave(4, 832, 16);
expect(secondsToX(fitted, 0)).toBe(16);
expect(secondsToX(fitted, 4)).toBe(816);
});
it('keeps either end of the file from going further in than half the width', () => {
expect(clampWave({ panX: 900, pxPerSecond: 100 }, 20, 1000).panX).toBe(500);
expect(clampWave({ panX: -1600, pxPerSecond: 100 }, 20, 1000).panX).toBe(-1500);
expect(clampWave({ panX: -900, pxPerSecond: 100 }, 20, 1000).panX).toBe(-900);
// Two seconds fit: they stay on screen.
expect(clampWave({ panX: 20, pxPerSecond: 100 }, 2, 1000).panX).toBe(20);
expect(clampWave({ panX: -50, pxPerSecond: 100 }, 2, 1000).panX).toBe(0);
});
it('labels the ruler in steps of 1, 2 or 5, with the decimals the step needs', () => {
expect(rulerStep(100, 70)).toBe(1);
expect(rulerStep(40, 70)).toBe(2);
expect(rulerStep(1000, 70)).toBeCloseTo(0.1);
expect(rulerStep(10, 70)).toBe(10);
expect(rulerLabel(1.5, 0.5)).toBe('1.5s');
expect(rulerLabel(2, 1)).toBe('2s');
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', () => {
const view = { panX: 0, pxPerSecond: 100 };
it('reads an end of 0 or past the file as its end', () => {
expect(spanOf(0, 0, 3)).toEqual({ start: 0, end: 3 });
expect(spanOf(1, 9, 3)).toEqual({ start: 1, end: 3 });
expect(spanOf(2.5, 1, 3)).toEqual({ start: 1, end: 1 });
});
it('finds the handle in reach, the nearer one where both are', () => {
const span = { start: 1, end: 2 };
expect(edgeAt(view, span, 103)).toBe('start');
expect(edgeAt(view, span, 197)).toBe('end');
expect(edgeAt(view, span, 150)).toBeNull();
expect(edgeAt(view, { start: 1, end: 1.04 }, 103)).toBe('end');
});
it('moves one edge, never past the other, or spans a new stretch inside the file', () => {
const span = { start: 1, end: 2 };
expect(draggedSpan(span, 'start', 1, 0.5, 3, 0.001)).toEqual({ start: 0.5, end: 2 });
expect(draggedSpan(span, 'start', 1, 2.5, 3, 0.001)).toEqual({ start: 1.999, end: 2 });
expect(draggedSpan(span, 'end', 2, 9, 3, 0.001)).toEqual({ start: 1, end: 3 });
expect(draggedSpan(span, null, 2.5, 0.5, 3, 0.001)).toEqual({ start: 0.5, end: 2.5 });
expect(draggedSpan(span, null, -1, 1, 3, 0.001)).toEqual({ start: 0, end: 1 });
});
it('stores the ends of the file as 0, everything else to the sample', () => {
expect(storedCut({ start: 0, end: 3 }, 3, 48000)).toEqual({ start: 0, end: 0 });
expect(storedCut({ start: 0.000001, end: 2.999999 }, 3, 48000)).toEqual({ start: 0, end: 0 });
expect(storedCut({ start: 1.2345678, end: 2 }, 3, 48000)).toEqual({ start: 1.234563, end: 2 });
});
});
describe('the wave peaks', () => {
it('reads the lowest and highest sample of any stretch, over the blocks and the samples at its ends', () => {
const left = new Float32Array(1000);
const right = new Float32Array(1000);
left[10] = 0.5;
right[600] = -0.75;
left[999] = 0.9;
const peaks = wavePeaks([left, right]);
expect(peaks.max).toHaveLength(4);
expect(spanPeak([left, right], peaks, 0, 1000)).toEqual({ min: -0.75, max: Math.fround(0.9) });
expect(spanPeak([left, right], peaks, 5, 20)).toEqual({ min: 0, max: 0.5 });
expect(spanPeak([left, right], peaks, 11, 599)).toEqual({ min: 0, max: 0 });
expect(spanPeak([left, right], peaks, 300.2, 300.4)).toEqual({ min: 0, max: 0 });
});
});