Zoomed out, the wave left room above and below it, but the bright file, the picked clip and its handles still took the whole height. And a click in the wave beside the picked clip kept it picked, so only the strip could take it back to the sound. - fileReach in waveView.ts is how far up and down the file reaches: full scale and its inset, never past the area. At level 1 it is the whole height, zoomed in too. - The bright file, the shade outside the clip and the two handles are drawn within that reach. - A click over the wave beside the picked clip picks the sound. Inside the clip or on a handle nothing changes, and a drag still spans the clip anew. Tests: - Unit wave-view: 11 green (1 new), a counter-check red - e2e 34 (melektaus-atx): ALL PASSED, 91 checks (1 new) - Gate (melektaus-atx): CHECK EXIT 0, 271 files, 4195 tests Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
246 lines
9.2 KiB
TypeScript
246 lines
9.2 KiB
TypeScript
import { clamp } from '../../ui';
|
||
|
||
/**
|
||
* The time axis of the Sound tab (#343), apart from Pixi so a test can drive it: seconds run left to
|
||
* right, `x` is measured from the left edge of the area that shows the wave.
|
||
*/
|
||
export interface WaveView {
|
||
/** Pixels from the area's left edge to second 0. */
|
||
panX: number;
|
||
pxPerSecond: number;
|
||
}
|
||
|
||
/** From a few minutes across a screen to a sample every few pixels at 48 kHz. */
|
||
export const MIN_PX_PER_SECOND = 0.5;
|
||
export const MAX_PX_PER_SECOND = 200_000;
|
||
/** How far either end of the file can be panned in from its edge of the area, as a share of its width. */
|
||
const LEAD = 0.5;
|
||
/** Pixels either side of a handle that grab it. */
|
||
export const EDGE_REACH = 6;
|
||
|
||
export function secondsToX(view: WaveView, seconds: number): number {
|
||
return view.panX + seconds * view.pxPerSecond;
|
||
}
|
||
|
||
export function xToSeconds(view: WaveView, x: number): number {
|
||
return (x - view.panX) / view.pxPerSecond;
|
||
}
|
||
|
||
/** Zooms about `x`, so the second under the pointer stays under it. */
|
||
export function zoomWave(view: WaveView, factor: number, x: number): WaveView {
|
||
const pxPerSecond = clamp(view.pxPerSecond * factor, MIN_PX_PER_SECOND, MAX_PX_PER_SECOND);
|
||
return { panX: x - xToSeconds(view, x) * pxPerSecond, pxPerSecond };
|
||
}
|
||
|
||
/** The whole file across `width`, a little room either side. */
|
||
export function fitWave(duration: number, width: number, margin: number): WaveView {
|
||
const room = Math.max(1, width - margin * 2);
|
||
const pxPerSecond = clamp(room / Math.max(1e-6, duration), MIN_PX_PER_SECOND, MAX_PX_PER_SECOND);
|
||
return { panX: margin, pxPerSecond };
|
||
}
|
||
|
||
/**
|
||
* As the music tabs have it across and down: the start of the file no further in than half the
|
||
* width, its end no further in from the right – and a file that fits is not pushed off the left.
|
||
*/
|
||
export function clampWave(view: WaveView, duration: number, width: number): WaveView {
|
||
const lead = width * LEAD;
|
||
const least = Math.min(0, width - lead - duration * view.pxPerSecond);
|
||
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;
|
||
}
|
||
|
||
/**
|
||
* Pixels from the middle the file and its clip reach up and down: full scale and `inset` around it,
|
||
* zoomed out short of the area, zoomed in never past its `room` (#357).
|
||
*/
|
||
export function fileReach(level: number, half: number, inset: number, room: number): number {
|
||
return Math.min(room, levelOffset(1, level, half) + inset);
|
||
}
|
||
|
||
/**
|
||
* 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;
|
||
const power = 10 ** Math.floor(Math.log10(rough));
|
||
for (const step of [1, 2, 5, 10]) if (step * power >= rough) return step * power;
|
||
return 10 * power;
|
||
}
|
||
|
||
/** A second as the ruler writes it, with as many decimals as its step needs. */
|
||
export function rulerLabel(seconds: number, step: number): string {
|
||
const decimals = Math.max(0, Math.min(6, -Math.floor(Math.log10(step) + 1e-9)));
|
||
if (seconds >= 60) {
|
||
const minutes = Math.floor(seconds / 60);
|
||
const rest = (seconds - minutes * 60).toFixed(decimals);
|
||
return `${minutes}:${rest.padStart(decimals > 0 ? decimals + 3 : 2, '0')}`;
|
||
}
|
||
return `${seconds.toFixed(decimals)}s`;
|
||
}
|
||
|
||
/** The stretch a sound plays, in seconds of its file: `end` is never 0 here. */
|
||
export interface CutSpan {
|
||
start: number;
|
||
end: number;
|
||
}
|
||
|
||
/** The span an asset's start and end mean in a file of `duration`: an end of 0, or past it, is its end. */
|
||
export function spanOf(start: number, end: number, duration: number): CutSpan {
|
||
const to = end > 0 ? Math.min(end, duration) : duration;
|
||
const from = clamp(start, 0, Math.max(0, to));
|
||
return { start: from, end: Math.max(from, to) };
|
||
}
|
||
|
||
/** Which handle is at `x`, the nearer one where both are in reach. */
|
||
export function edgeAt(view: WaveView, span: CutSpan, x: number): 'start' | 'end' | null {
|
||
const toStart = Math.abs(secondsToX(view, span.start) - x);
|
||
const toEnd = Math.abs(secondsToX(view, span.end) - x);
|
||
if (Math.min(toStart, toEnd) > EDGE_REACH) return null;
|
||
return toEnd < toStart || (toEnd === toStart && x > secondsToX(view, span.end)) ? 'end' : 'start';
|
||
}
|
||
|
||
/**
|
||
* The span a drag leaves: one edge moved to `at`, or with no edge a new span from where it was
|
||
* pressed to `at`. Held inside the file and at least `least` seconds long; an edge dragged past the
|
||
* other stops there.
|
||
*/
|
||
export function draggedSpan(
|
||
span: CutSpan,
|
||
edge: 'start' | 'end' | null,
|
||
from: number,
|
||
at: number,
|
||
duration: number,
|
||
least: number,
|
||
): CutSpan {
|
||
const inFile = (s: number) => clamp(s, 0, duration);
|
||
const gap = Math.min(least, duration);
|
||
if (edge === 'start') return { start: Math.min(inFile(at), span.end - gap), end: span.end };
|
||
if (edge === 'end') return { start: span.start, end: Math.max(inFile(at), span.start + gap) };
|
||
const [a, b] = [inFile(from), inFile(at)].sort((p, q) => p - q) as [number, number];
|
||
return b - a >= gap ? { start: a, end: b } : { start: a, end: Math.min(duration, a + gap) };
|
||
}
|
||
|
||
/** A clip of the sound as its band in the strip (#357). */
|
||
export interface ClipBand {
|
||
id: string;
|
||
span: CutSpan;
|
||
}
|
||
|
||
/** The clip whose band is at `x`, the one drawn last where bands lie over each other; null for none. */
|
||
export function clipAt(view: WaveView, bands: readonly ClipBand[], x: number): string | null {
|
||
for (let i = bands.length - 1; i >= 0; i--) {
|
||
const { id, span } = bands[i]!;
|
||
if (x >= secondsToX(view, span.start) && x <= secondsToX(view, span.end)) return id;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/** What a clip stores for a span: seconds to the nearest sample, an end at the end of the file as 0. */
|
||
export function storedCut(span: CutSpan, duration: number, rate: number): { start: number; end: number } {
|
||
const sample = 1 / rate;
|
||
const start = span.start < sample / 2 ? 0 : roundTo(span.start, rate);
|
||
const end = duration - span.end < sample / 2 ? 0 : roundTo(span.end, rate);
|
||
return { start, end };
|
||
}
|
||
|
||
/** To the nearest sample, written short. */
|
||
function roundTo(seconds: number, rate: number): number {
|
||
return Number((Math.round(seconds * rate) / rate).toFixed(6));
|
||
}
|
||
|
||
/** Samples each block of `WavePeaks` covers. */
|
||
export const PEAK_BLOCK = 256;
|
||
|
||
/** The lowest and highest sample of each block of a sound, over all its channels. */
|
||
export interface WavePeaks {
|
||
min: Float32Array;
|
||
max: Float32Array;
|
||
}
|
||
|
||
export function wavePeaks(channels: readonly Float32Array[]): WavePeaks {
|
||
const length = channels[0]?.length ?? 0;
|
||
const blocks = Math.ceil(length / PEAK_BLOCK);
|
||
const min = new Float32Array(blocks).fill(Infinity);
|
||
const max = new Float32Array(blocks).fill(-Infinity);
|
||
for (const data of channels)
|
||
for (let i = 0; i < data.length; i++) {
|
||
const b = (i / PEAK_BLOCK) | 0;
|
||
const v = data[i]!;
|
||
if (v < min[b]!) min[b] = v;
|
||
if (v > max[b]!) max[b] = v;
|
||
}
|
||
return { min, max };
|
||
}
|
||
|
||
/**
|
||
* The lowest and highest sample from `from` up to `to` (sample indices, `to` after `from`): read
|
||
* from the blocks where the stretch spans whole ones, from the samples at its ends.
|
||
*/
|
||
export function spanPeak(
|
||
channels: readonly Float32Array[],
|
||
peaks: WavePeaks,
|
||
from: number,
|
||
to: number,
|
||
): { min: number; max: number } {
|
||
const length = channels[0]?.length ?? 0;
|
||
const a = Math.max(0, Math.floor(from));
|
||
const b = Math.min(length, Math.max(a + 1, Math.ceil(to)));
|
||
let min = Infinity;
|
||
let max = -Infinity;
|
||
const raw = (s: number, e: number) => {
|
||
for (const data of channels)
|
||
for (let i = s; i < e; i++) {
|
||
const v = data[i]!;
|
||
if (v < min) min = v;
|
||
if (v > max) max = v;
|
||
}
|
||
};
|
||
const firstBlock = Math.ceil(a / PEAK_BLOCK);
|
||
const lastBlock = Math.floor(b / PEAK_BLOCK);
|
||
if (lastBlock <= firstBlock) raw(a, b);
|
||
else {
|
||
raw(a, firstBlock * PEAK_BLOCK);
|
||
for (let k = firstBlock; k < lastBlock; k++) {
|
||
if (peaks.min[k]! < min) min = peaks.min[k]!;
|
||
if (peaks.max[k]! > max) max = peaks.max[k]!;
|
||
}
|
||
raw(lastBlock * PEAK_BLOCK, b);
|
||
}
|
||
return min <= max ? { min, max } : { min: 0, max: 0 };
|
||
}
|