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:
1 parent
25c1d02b06
commit
c7fa1d15b0
5 files changed
+245
-35
No files matched your search
@@ -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
@@ -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];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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)');
|
||||
|
||||
Reference in new issue
Block a user