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 }; }