From c7fa1d15b0a342bb49ea4165be1f66f9f1c88d43 Mon Sep 17 00:00:00 2001 From: MelekTaus Date: Thu, 8 Oct 2026 19:21:02 +0200 Subject: [PATCH] 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 --- src/editor/debug/soundHooks.ts | 5 + src/editor/sound/WaveCanvas.ts | 176 ++++++++++++++++++++++++++------- src/editor/sound/waveView.ts | 37 +++++++ tests/editor/wave-view.test.ts | 26 +++++ tools/e2e/34-sound.mjs | 36 +++++++ 5 files changed, 245 insertions(+), 35 deletions(-) diff --git a/src/editor/debug/soundHooks.ts b/src/editor/debug/soundHooks.ts index 4ab3e960..74744a40 100644 --- a/src/editor/debug/soundHooks.ts +++ b/src/editor/debug/soundHooks.ts @@ -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, diff --git a/src/editor/sound/WaveCanvas.ts b/src/editor/sound/WaveCanvas.ts index 34799dac..ec3f6c54 100644 --- a/src/editor/sound/WaveCanvas.ts +++ b/src/editor/sound/WaveCanvas.ts @@ -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; + const held = view as Partial; 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]; diff --git a/src/editor/sound/waveView.ts b/src/editor/sound/waveView.ts index 51fd7a0c..c76b7298 100644 --- a/src/editor/sound/waveView.ts +++ b/src/editor/sound/waveView.ts @@ -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; diff --git a/tests/editor/wave-view.test.ts b/tests/editor/wave-view.test.ts index 85bff32f..e25caf29 100644 --- a/tests/editor/wave-view.test.ts +++ b/tests/editor/wave-view.test.ts @@ -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 }; diff --git a/tools/e2e/34-sound.mjs b/tools/e2e/34-sound.mjs index 7dba9e6b..201134b1 100644 --- a/tools/e2e/34-sound.mjs +++ b/tools/e2e/34-sound.mjs @@ -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)');