Files
feuerwerk/src/editor/sound/waveView.ts
T
adminandClaude Opus 5.5 852dce5142 Shrink a clip with the wave and drop it with a click beside (#357)
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>
2026-10-08 21:13:09 +02:00

246 lines
9.2 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.
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 };
}