Zoom the height of a sound's wave over its level scale (#357)

The Sound tab always drew the wave at the full height of the tab. Quiet
stretches were hard to see, and there was no room above a loud wave.

- A level scale in dB stands left of the wave, a mark every 6 dB down
  from 0, as long as a mark lies inside the area and at least 14 px
  from the middle.
- The wheel over the scale zooms the height, from 0.25x to 16x. Over
  the wave it zooms the time as before.
- What is louder than the area stops at its edge. The fade ramps grow
  with the wave.
- Fit puts the height back to 1x. The tab keeps the height with its
  view when another tab is shown.
- The rules live in waveView.ts (zoomLevel, levelMarks), the hooks
  give the height, the scale and the top of a full-scale wave.

Tests:
- Unit wave-view: 10 green (2 new), 2 counter-checks each red
- e2e 34 (melektaus-atx): ALL PASSED, 87 checks (1 new)
- Gate (melektaus-atx): CHECK EXIT 0, 270 files, 4183 tests

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-08 19:21:02 +02:00
1 parent 25c1d02b06
commit c7fa1d15b0
5 files changed
+245 -35

No files matched your search

+5
View File
@@ -16,6 +16,11 @@ export function soundHooks({ state, editor }: HookContext) {
/** The stretch between the handles, in seconds; null while the file is not there. */
span: () => soundEditor.canvas.shownSpan(),
duration: () => soundEditor.canvas.fileDuration(),
/** The height zoom (#357): the factor, the middle of the level scale, and the top of a full-scale wave. */
level: () => soundEditor.canvas.levelZoom,
scale: () => soundEditor.canvas.scaleCentre(),
fullScaleTop: () => soundEditor.canvas.fullScaleTop(),
fitButton: () => centreOf(soundEditor.fitButton),
cursor: () => soundEditor.canvas.cursor,
playhead: () => soundEditor.canvas.playhead,
playing: () => soundEditor.isPlaying,
+141 -35
View File
@@ -22,6 +22,8 @@ import {
draggedSpan,
edgeAt,
fitWave,
levelMarks,
levelOffset,
rulerLabel,
rulerStep,
secondsToX,
@@ -32,10 +34,17 @@ import {
wavePeaks,
type WaveView,
xToSeconds,
zoomLevel,
zoomWave,
} from './waveView';
const RULER_HEIGHT = 24;
/** The level scale left of the wave, in decibels (#357). */
const SCALE_WIDTH = 36;
/** Pixels apart the level scale's marks are at least. */
const SCALE_SPACING = 14;
/** Room above and below a full-scale wave at level 1. */
const WAVE_INSET = 6;
/** Room either side of the file when it is fitted. */
const FIT_MARGIN = 16;
/** Pixels apart the ruler's labels are at least. */
@@ -81,10 +90,14 @@ function waveOf(buffer: AudioBuffer): Wave {
* The Sound tab's canvas (#343): the whole file as one waveform, the loudest of its channels, under
* a ruler in seconds. The stretch the asset is cut to stands between two handles, what lies outside
* it darker; dragging a handle moves that edge, dragging beside them spans a new stretch. One drag
* is one undo step. The wheel zooms, the middle button pans, as in the music tabs.
* is one undo step. The wheel zooms, the middle button pans, as in the music tabs; over the level
* scale on the left the wheel zooms the wave's height (#357).
*/
export class WaveCanvas extends Widget {
private readonly bg = new Graphics();
private readonly scaleG = new Graphics();
private readonly scaleLabels = new Container();
private readonly scaleTexts: Text[] = [];
private readonly waveG = new Graphics();
private readonly overlay = new Graphics();
private readonly labels = new Container();
@@ -94,6 +107,8 @@ export class WaveCanvas extends Widget {
private readonly texts: Text[] = [];
private readonly hint = crispText({ text: '', style: textStyle('muted') });
private view: WaveView = { panX: FIT_MARGIN, pxPerSecond: 100 };
/** How many times its full height the wave is drawn. */
private level = 1;
private needsFit = true;
private assetId = '';
private gesture: Gesture | null = null;
@@ -109,10 +124,12 @@ export class WaveCanvas extends Widget {
super();
this.focusable = true;
this.labels.eventMode = 'none';
this.scaleLabels.eventMode = 'none';
this.hint.eventMode = 'none';
this.content.x = SCALE_WIDTH;
this.content.addChild(this.waveG, this.overlay, this.labels);
this.content.mask = this.clip;
this.addChild(this.bg, this.content, this.clip, this.hint);
this.addChild(this.bg, this.scaleG, this.scaleLabels, this.content, this.clip, this.hint);
this.on('pointerdown', (e: FederatedPointerEvent) => this.onPointerDown(e));
this.on('globalpointermove', (e: FederatedPointerEvent) => this.onPointerMove(e));
this.onPointerRelease(() => this.onPointerUp());
@@ -136,30 +153,53 @@ export class WaveCanvas extends Widget {
this.redraw();
}
saveView(): WaveView {
return { ...this.view };
saveView(): WaveView & { level: number } {
return { ...this.view, level: this.level };
}
restoreView(view: unknown): void {
const held = view as Partial<WaveView>;
const held = view as Partial<WaveView & { level: number }>;
if (typeof held.panX !== 'number' || typeof held.pxPerSecond !== 'number') return;
this.view = { panX: held.panX, pxPerSecond: held.pxPerSecond };
this.level = typeof held.level === 'number' ? zoomLevel(held.level, 1) : 1;
this.needsFit = false;
this.redraw();
}
/** The whole file across. */
/** The whole file across, at its full height. */
fit(): void {
const wave = this.wave();
if (!wave || this.w <= 0) {
if (!wave || this.areaWidth <= 0) {
this.needsFit = true;
return;
}
this.view = fitWave(wave.duration, this.w, FIT_MARGIN);
this.view = fitWave(wave.duration, this.areaWidth, FIT_MARGIN);
this.level = 1;
this.needsFit = false;
this.redraw();
}
/** How many times its full height the wave is drawn (test hook). */
get levelZoom(): number {
return this.level;
}
/** The middle of the level scale on screen (test hook). */
scaleCentre(): Vec2 {
const origin = this.getGlobalPosition();
return { x: origin.x + SCALE_WIDTH / 2, y: origin.y + RULER_HEIGHT + this.waveHeight / 2 };
}
/** Where the top of a full-scale wave is drawn on screen, before it is held inside the tab (test hook). */
fullScaleTop(): number {
return (
this.getGlobalPosition().y +
RULER_HEIGHT +
this.waveHeight / 2 -
levelOffset(1, this.level, this.fullHalf)
);
}
/** The stretch the asset is cut to, as shown – while a drag runs, as it would leave it. */
shownSpan(): CutSpan | null {
const asset = this.asset();
@@ -178,7 +218,10 @@ export class WaveCanvas extends Widget {
/** Where a second of the file is on screen, at the middle of the wave (test hook). */
secondsToScreen(seconds: number): Vec2 {
const origin = this.getGlobalPosition();
return { x: origin.x + secondsToX(this.view, seconds), y: origin.y + RULER_HEIGHT + this.waveHeight / 2 };
return {
x: origin.x + SCALE_WIDTH + secondsToX(this.view, seconds),
y: origin.y + RULER_HEIGHT + this.waveHeight / 2,
};
}
/** Puts the whole file back, in one undo step. */
@@ -212,6 +255,22 @@ export class WaveCanvas extends Widget {
return Math.max(0, this.h - RULER_HEIGHT);
}
/** Pixels from the middle a full-scale sample stands at level 1. */
private get fullHalf(): number {
return Math.max(1, this.waveHeight / 2 - WAVE_INSET);
}
/** The width the wave has, right of the level scale. */
private get areaWidth(): number {
return Math.max(0, this.w - SCALE_WIDTH);
}
/** A point in the wave's area: x from its left edge, right of the level scale. */
private areaPoint(e: FederatedPointerEvent | FederatedWheelEvent): Vec2 {
const p = this.toLocal(e.global);
return { x: p.x - SCALE_WIDTH, y: p.y };
}
override onKeyDown(event: KeyboardEvent): boolean {
if (event.code !== 'Space' || event.ctrlKey || event.metaKey || !this.asset()) return false;
this.onTogglePlay();
@@ -222,14 +281,14 @@ export class WaveCanvas extends Widget {
const wave = this.wave();
const span = this.shownSpan();
if (!wave || !span) return;
const p = this.toLocal(e.global);
const p = this.areaPoint(e);
e.stopPropagation();
ui().focus.focus(this);
if (e.button === 1) {
this.gesture = { kind: 'pan', pointerX: p.x, panX: this.view.panX };
return;
}
if (e.button !== 0) return;
if (e.button !== 0 || p.x < 0) return;
const from = Math.max(0, Math.min(wave.duration, xToSeconds(this.view, p.x)));
this.gesture = {
kind: 'span',
@@ -242,7 +301,7 @@ export class WaveCanvas extends Widget {
}
private onPointerMove(e: FederatedPointerEvent): void {
const p = this.toLocal(e.global);
const p = this.areaPoint(e);
const g = this.gesture;
const wave = this.wave();
if (!g || !wave) {
@@ -250,7 +309,7 @@ export class WaveCanvas extends Widget {
return;
}
if (g.kind === 'pan') {
this.view = clampWave({ ...this.view, panX: g.panX + p.x - g.pointerX }, wave.duration, this.w);
this.view = clampWave({ ...this.view, panX: g.panX + p.x - g.pointerX }, wave.duration, this.areaWidth);
this.needsFit = false;
this.redraw();
return;
@@ -294,8 +353,9 @@ export class WaveCanvas extends Widget {
private onWheel(e: FederatedWheelEvent): void {
const wave = this.wave();
if (!wave) return;
const p = this.toLocal(e.global);
this.view = clampWave(zoomWave(this.view, zoomFactor(e.deltaY), p.x), wave.duration, this.w);
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();
e.preventDefault();
@@ -303,7 +363,7 @@ export class WaveCanvas extends Widget {
private updateCursor(x: number, y: number): void {
const span = this.shownSpan();
const inside = x >= 0 && x <= this.w && y >= 0 && y <= this.h;
const inside = x >= 0 && x <= this.areaWidth && y >= 0 && y <= this.h;
const cursor = inside && span && edgeAt(this.view, span, x) ? 'ew-resize' : 'default';
if (this.cursor !== cursor) this.cursor = cursor;
}
@@ -314,14 +374,18 @@ export class WaveCanvas extends Widget {
override redraw(): void {
const wave = this.wave();
if (this.needsFit && wave && this.w > 0) return this.fit();
if (this.needsFit && wave && this.areaWidth > 0) return this.fit();
const c = Theme.colors;
const { w, h } = this;
const { h } = this;
const w = this.areaWidth;
const wh = this.waveHeight;
this.bg.clear().rect(0, 0, w, h).fill(c.viewport);
this.bg.rect(0, 0, w, RULER_HEIGHT).fill(c.panelHeader);
this.bg.rect(0, RULER_HEIGHT - 1, w, 1).fill(c.border);
this.clip.clear().rect(0, 0, w, h).fill(0xffffff);
this.bg.clear().rect(0, 0, this.w, h).fill(c.viewport);
this.bg.rect(0, 0, this.w, RULER_HEIGHT).fill(c.panelHeader);
this.bg.rect(0, RULER_HEIGHT - 1, this.w, 1).fill(c.border);
this.bg.rect(0, RULER_HEIGHT, SCALE_WIDTH, wh).fill(c.panelHeader);
this.bg.rect(SCALE_WIDTH - 1, RULER_HEIGHT, 1, 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();
@@ -334,32 +398,40 @@ export class WaveCanvas extends Widget {
: soundLibrary.failedToLoad(asset.file)
? 'Could not be decoded.'
: 'Loading…';
this.hint.position.set(Theme.padding * 2, RULER_HEIGHT + Theme.padding * 2);
this.hint.position.set(SCALE_WIDTH + Theme.padding * 2, RULER_HEIGHT + Theme.padding * 2);
if (!wave || !span || !asset) {
this.syncLabels([]);
this.syncScale([]);
return;
}
const x0 = secondsToX(this.view, 0);
const x1 = secondsToX(this.view, wave.duration);
const mid = RULER_HEIGHT + wh / 2;
const half = Math.max(1, wh / 2 - 6);
const half = this.level * this.fullHalf;
const room = wh / 2;
// The file itself a shade lighter than what lies before and after it.
const left = Math.max(0, x0);
const right = Math.min(w, x1);
if (right > left) this.bg.rect(left, RULER_HEIGHT, right - left, wh).fill({ color: c.panel, alpha: 0.5 });
if (right > left)
this.waveG.rect(left, RULER_HEIGHT, right - left, wh).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);
this.drawWave(wave, left, right, mid, half, room);
this.drawRuler(left, right);
this.drawCut(asset, wave, span, mid, half);
this.drawCut(asset, wave, span, mid, half, room);
this.drawScale(mid, room);
if (this.playhead !== null) {
const x = secondsToX(this.view, this.playhead);
if (x >= 0 && x <= w) this.overlay.rect(x - 0.5, 0, 1, h).fill(c.hot);
}
}
/** A bar from the lowest to the highest sample per pixel, or the samples themselves zoomed far in. */
private drawWave(wave: Wave, left: number, right: number, mid: number, half: number): void {
/**
* 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.
*/
private drawWave(wave: Wave, left: number, right: number, mid: number, half: number, room: number): void {
const color = { color: 0xffffff, alpha: 0.75 };
const yOf = (v: number) => mid - Math.max(-room, Math.min(room, v * half));
const perPx = wave.rate / this.view.pxPerSecond;
if (perPx < 1) {
const first = Math.max(0, Math.floor(xToSeconds(this.view, left) * wave.rate));
@@ -373,7 +445,7 @@ export class WaveCanvas extends Widget {
(most, data) => (Math.abs(data[i]!) > Math.abs(most) ? data[i]! : most),
0,
);
points.push(secondsToX(this.view, i / wave.rate), mid - Math.max(-1, Math.min(1, v)) * half);
points.push(secondsToX(this.view, i / wave.rate), yOf(v));
}
if (points.length >= 4) this.waveG.poly(points, false).stroke({ width: 1, ...color });
return;
@@ -382,8 +454,8 @@ export class WaveCanvas extends Widget {
const from = xToSeconds(this.view, x) * wave.rate;
const to = xToSeconds(this.view, x + 1) * wave.rate;
const { min, max } = spanPeak(wave.channels, wave.peaks, from, to);
const top = mid - Math.min(1, max) * half;
const bottom = mid - Math.max(-1, min) * half;
const top = yOf(max);
const bottom = yOf(min);
this.waveG.rect(x, top, 1, Math.max(1, bottom - top)).fill(color);
}
}
@@ -404,14 +476,22 @@ export class WaveCanvas extends Widget {
}
/** Dark outside the stretch, a handle at each edge, and the ramp at each edge inside the file. */
private drawCut(asset: SoundAsset, wave: Wave, span: CutSpan, mid: number, half: number): void {
private drawCut(
asset: SoundAsset,
wave: Wave,
span: CutSpan,
mid: number,
half: number,
room: number,
): void {
const c = Theme.colors;
const { h } = this;
const w = this.areaWidth;
const a = secondsToX(this.view, span.start);
const b = secondsToX(this.view, span.end);
const shade = { color: 0x000000, alpha: 0.55 };
if (a > 0) this.overlay.rect(0, RULER_HEIGHT, a, h - RULER_HEIGHT).fill(shade);
if (b < this.w) this.overlay.rect(b, RULER_HEIGHT, this.w - b, h - RULER_HEIGHT).fill(shade);
if (b < w) this.overlay.rect(b, RULER_HEIGHT, w - b, h - RULER_HEIGHT).fill(shade);
this.overlay.rect(a, 0, Math.max(1, b - a), 4).fill({ color: c.accent, alpha: 0.6 });
const cut = cutOf(resolveAssetProps(this.state.project, asset));
const range = cutRange(wave.channels[0]!.length, wave.rate, { ...cut, ...span });
@@ -423,7 +503,7 @@ export class WaveCanvas extends Widget {
const steps = Math.max(1, Math.min(RAMP_STEPS, Math.round(xb - xa)));
for (let i = 0; i <= steps; i++) {
const t = i / steps;
const y = mid + half - 2 * half * fadeGain(curve, rising ? t : 1 - t);
const y = mid - Math.max(-room, Math.min(room, half * (2 * fadeGain(curve, rising ? t : 1 - t) - 1)));
if (i === 0) this.overlay.moveTo(xa, y);
else this.overlay.lineTo(xa + t * (xb - xa), y);
}
@@ -452,6 +532,32 @@ export class WaveCanvas extends Widget {
}
}
/** A tick either side of the middle for every mark, its decibels beside the upper one. */
private drawScale(mid: number, room: number): void {
const c = Theme.colors;
// A label half its height in from the edge, so the top one does not run into the ruler.
const marks = levelMarks(this.level, this.fullHalf, room - SCALE_SPACING / 2, SCALE_SPACING);
for (const { offset } of marks)
for (const y of [mid - offset, mid + offset])
this.scaleG.rect(SCALE_WIDTH - 6, Math.round(y), 5, 1).fill({ color: c.textMuted, alpha: 0.8 });
this.syncScale(marks.map(({ db, offset }) => ({ text: String(db), y: mid - offset })));
}
private syncScale(placed: readonly { text: string; y: number }[]): void {
placed.forEach((label, i) => {
let text = this.scaleTexts[i];
if (!text) {
text = crispText({ style: textStyle('small') });
this.scaleTexts.push(text);
this.scaleLabels.addChild(text);
}
text.visible = true;
if (text.text !== label.text) text.text = label.text;
text.position.set(Math.round(SCALE_WIDTH - 8 - text.width), Math.round(label.y - text.height / 2));
});
for (let i = placed.length; i < this.scaleTexts.length; i++) this.scaleTexts[i]!.visible = false;
}
private syncLabels(placed: readonly { text: string; x: number }[]): void {
placed.forEach((label, i) => {
let text = this.texts[i];
+37
View File
@@ -49,6 +49,43 @@ export function clampWave(view: WaveView, duration: number, width: number): Wave
return { ...view, panX: clamp(view.panX, least, lead) };
}
/** 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;
/** Decibels apart the level scale's marks are. */
const LEVEL_STEP_DB = 6;
/** The quietest mark the scale goes down to. */
const QUIETEST_DB = -96;
export function zoomLevel(level: number, factor: number): number {
return clamp(level * factor, MIN_LEVEL, MAX_LEVEL);
}
/** Pixels from the middle of the wave an amplitude is drawn at, `half` being full scale at level 1. */
export function levelOffset(amplitude: number, level: number, half: number): number {
return amplitude * level * half;
}
/**
* The marks of the level scale: every 6 dB down from 0, as far from the middle as they are drawn.
* Only those within `room` of the middle and at least `minPx` from it – 6 dB halve the distance,
* so that keeps them as far apart from each other too.
*/
export function levelMarks(
level: number,
half: number,
room: number,
minPx: number,
): { db: number; offset: number }[] {
const marks: { db: number; offset: number }[] = [];
for (let db = 0; db >= QUIETEST_DB; db -= LEVEL_STEP_DB) {
const offset = levelOffset(10 ** (db / 20), level, half);
if (offset < minPx) break;
if (offset <= room) marks.push({ db, offset });
}
return marks;
}
/** Seconds between the ruler's labels: 1, 2 or 5 times a power of ten, at least `minPx` apart. */
export function rulerStep(pxPerSecond: number, minPx: number): number {
const rough = minPx / pxPerSecond;
+26
View File
@@ -8,6 +8,9 @@ import {
draggedSpan,
edgeAt,
fitWave,
levelMarks,
MAX_LEVEL,
MIN_LEVEL,
rulerLabel,
rulerStep,
secondsToX,
@@ -16,9 +19,32 @@ import {
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);
});
});
describe('the wave view', () => {
it('zooms about the pointer and fits the file with a margin either side', () => {
const view = { panX: 10, pxPerSecond: 100 };
+36
View File
@@ -1933,6 +1933,42 @@ export async function sound(t) {
tabOpened.info === '0.75 s',
JSON.stringify(tabOpened),
);
// 34h-2a. The wheel over the level scale left of the wave zooms its height (#357): out leaves room
// above a full-scale wave, in takes it past the top of the tab, the seconds stay where they
// are, and Fit brings back the full height.
const levelScale = await json('__gm.soundTab.scale()');
const levelRect = await json('__gm.soundTab.rect()');
const fullTop = await json('__gm.soundTab.fullScaleTop()');
const secondAt = (await json('__gm.soundTab.at(0.5)')).x;
await t.wheel(levelScale.x, levelScale.y, 924);
const zoomedOut = {
level: await json('__gm.soundTab.level()'),
top: await json('__gm.soundTab.fullScaleTop()'),
secondAt: (await json('__gm.soundTab.at(0.5)')).x,
};
await t.wheel(levelScale.x, levelScale.y, -1848);
const zoomedIn = {
level: await json('__gm.soundTab.level()'),
top: await json('__gm.soundTab.fullScaleTop()'),
};
const levelFit = await json('__gm.soundTab.fitButton()');
await click(levelFit.x, levelFit.y);
await sleep(100);
const refitted = {
level: await json('__gm.soundTab.level()'),
top: await json('__gm.soundTab.fullScaleTop()'),
};
check(
'the wheel over the level scale zooms the height alone, and Fit puts it back',
Math.abs(zoomedOut.level - 0.25) < 0.01 &&
Math.abs(zoomedOut.top - levelScale.y - (fullTop - levelScale.y) * 0.25) < 1 &&
zoomedOut.secondAt === secondAt &&
Math.abs(zoomedIn.level - 4) < 0.05 &&
zoomedIn.top < levelRect.y &&
refitted.level === 1 &&
Math.abs(refitted.top - fullTop) < 0.5,
JSON.stringify({ levelScale, levelRect, fullTop, secondAt, zoomedOut, zoomedIn, refitted }),
);
const droneProps = async () => (await assetsOfType('sound')).find((a) => a.id === droneId)?.props;
let dragFrom = await json('__gm.soundTab.at(0.75)');
let dragTo = await json('__gm.soundTab.at(0.5)');