export const DEFAULT_PEAK_BUCKETS = 8192; export function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); } export function encodeWav(samples, sampleRate) { const buf = new ArrayBuffer(44 + samples.length * 2); const view = new DataView(buf); const writeStr = (off, s) => { for (let i = 0; i < s.length; i++) view.setUint8(off + i, s.charCodeAt(i)); }; writeStr(0, 'RIFF'); view.setUint32(4, 36 + samples.length * 2, true); writeStr(8, 'WAVE'); writeStr(12, 'fmt '); view.setUint32(16, 16, true); view.setUint16(20, 1, true); view.setUint16(22, 1, true); view.setUint32(24, sampleRate, true); view.setUint32(28, sampleRate * 2, true); view.setUint16(32, 2, true); view.setUint16(34, 16, true); writeStr(36, 'data'); view.setUint32(40, samples.length * 2, true); for (let i = 0; i < samples.length; i++) { const s = clamp(samples[i], -1, 1); view.setInt16(44 + i * 2, s < 0 ? s * 0x8000 : s * 0x7fff, true); } return buf; } export function computePeaksFromChannel(channel, buckets = DEFAULT_PEAK_BUCKETS) { const n = channel.length; const eff = Math.max(1, Math.min(buckets, n)); const peaks = new Float32Array(eff * 2); const step = n / eff; for (let b = 0; b < eff; b++) { const s = Math.floor(b * step); const e = Math.min(n, Math.floor((b + 1) * step)); let mn = 1, mx = -1; for (let i = s; i < e; i++) { const v = channel[i]; if (v < mn) mn = v; if (v > mx) mx = v; } if (mx < mn) { mn = 0; mx = 0; } peaks[b * 2] = mn; peaks[b * 2 + 1] = mx; } return peaks; } export function pickTickInterval(viewDur) { const candidates = [0.01, 0.05, 0.1, 0.25, 0.5, 1, 2, 5, 10, 30, 60, 120, 300, 600]; const target = viewDur / 8; for (const c of candidates) if (c >= target) return c; return 1200; } export function xToTime(x, width, viewStart, viewEnd) { const clampedX = clamp(x, 0, width); return viewStart + (clampedX / Math.max(1, width)) * (viewEnd - viewStart); } export function pickHandle(clientX, canvasLeft, canvasWidth, state, edgeGrabPx = 10) { const x = clientX - canvasLeft; const { viewStart, viewEnd, start, end } = state; const viewDur = viewEnd - viewStart; if (viewDur <= 0) return null; const sx = ((start - viewStart) / viewDur) * canvasWidth; const ex = ((end - viewStart) / viewDur) * canvasWidth; if (Math.abs(x - sx) < edgeGrabPx) return 'start'; if (Math.abs(x - ex) < edgeGrabPx) return 'end'; if (x > sx && x < ex) return 'region'; return null; } export function applyDrag(state, pointerClientX, canvasLeft, canvasWidth, drag, minGap = 0.02) { const { start, end, viewStart, viewEnd, duration } = state; const t = xToTime(pointerClientX - canvasLeft, canvasWidth, viewStart, viewEnd); const out = { start, end, viewStart, viewEnd }; if (drag.mode === 'start') { out.start = clamp(t, 0, Math.max(0, end - minGap)); } else if (drag.mode === 'end') { out.end = clamp(t, start + minGap, duration); } else if (drag.mode === 'region') { const len = drag.regionLen; const anchor = clamp(t - drag.offset, 0, Math.max(0, duration - len)); out.start = anchor; out.end = anchor + len; } else if (drag.mode === 'new') { const a = clamp(drag.anchorT, 0, duration); const p = clamp(t, 0, duration); if (p >= a) { out.start = a; out.end = Math.max(p, a + minGap); } else { out.end = a; out.start = Math.min(p, a - minGap); } out.start = Math.max(0, out.start); out.end = Math.min(duration, out.end); } else if (drag.mode === 'pan') { const delta = pointerClientX - drag.startClientX; const viewDur = drag.viewDur; const timeDelta = -(delta / Math.max(1, canvasWidth)) * viewDur; const newVs = clamp(drag.viewStart + timeDelta, 0, Math.max(0, duration - viewDur)); out.viewStart = newVs; out.viewEnd = newVs + viewDur; } return out; } export function zoomCenter(viewStart, viewEnd, duration, factor) { const viewDur = viewEnd - viewStart; const center = (viewStart + viewEnd) / 2; const newDur = clamp(viewDur * factor, 0.01, duration); const newVs = clamp(center - newDur / 2, 0, Math.max(0, duration - newDur)); return { viewStart: newVs, viewEnd: newVs + newDur }; } export function zoomAtCursor(viewStart, viewEnd, duration, factor, xFrac, minDur = 0.01) { const viewDur = viewEnd - viewStart; const anchor = viewStart + xFrac * viewDur; const newDur = clamp(viewDur * factor, minDur, duration); let newVs = anchor - xFrac * newDur; newVs = clamp(newVs, 0, Math.max(0, duration - newDur)); return { viewStart: newVs, viewEnd: newVs + newDur }; } export async function computePeaksAsync(channel, buckets = DEFAULT_PEAK_BUCKETS, onProgress) { const n = channel.length; const eff = Math.max(1, Math.min(buckets, n)); const peaks = new Float32Array(eff * 2); const step = n / eff; const YIELD_EVERY = 64; // buckets per chunk for (let b = 0; b < eff; b++) { const s = Math.floor(b * step); const e = Math.min(n, Math.floor((b + 1) * step)); let mn = 1, mx = -1; for (let i = s; i < e; i++) { const v = channel[i]; if (v < mn) mn = v; if (v > mx) mx = v; } if (mx < mn) { mn = 0; mx = 0; } peaks[b * 2] = mn; peaks[b * 2 + 1] = mx; if ((b & (YIELD_EVERY - 1)) === 0) { if (onProgress) onProgress(b / eff); // Yield to event loop so UI stays responsive. await new Promise((r) => setTimeout(r, 0)); } } if (onProgress) onProgress(1); return peaks; } export async function probeDuration(file) { return new Promise((resolve, reject) => { const url = URL.createObjectURL(file); const a = new Audio(); a.preload = 'metadata'; const cleanup = () => { try { URL.revokeObjectURL(url); } catch {} }; a.addEventListener('loadedmetadata', () => { const d = a.duration; cleanup(); resolve(isFinite(d) ? d : 0); }, { once: true }); a.addEventListener('error', () => { cleanup(); reject(new Error('metadata failed')); }, { once: true }); a.src = url; }); } export async function decodeToMonoLowRate(file, targetSR = 22050) { const duration = await probeDuration(file); const arr = await file.arrayBuffer(); const len = Math.max(1, Math.ceil(Math.max(0.001, duration) * targetSR)); const Offline = window.OfflineAudioContext || window.webkitOfflineAudioContext; const offline = new Offline(1, len, targetSR); const buf = await offline.decodeAudioData(arr); return buf; } export function sliceToMono(buffer, startSec, endSec) { const sr = buffer.sampleRate; const s0 = Math.floor(startSec * sr); const s1 = Math.floor(endSec * sr); const len = Math.max(0, s1 - s0); const chCount = buffer.numberOfChannels; const out = new Float32Array(len); for (let c = 0; c < chCount; c++) { const d = buffer.getChannelData(c); for (let i = 0; i < len; i++) out[i] += d[s0 + i] / chCount; } return out; }