import { setButtonLabel } from "./dom.ts"; import { readSource } from "../source.ts"; import type { ViewerSource } from "../types.ts"; /** Peak envelope in bounded bins, including every channel. */ export function waveformPeaks(channels: Float32Array[], bins = 160): number[] { const length = Math.max(0, ...channels.map((channel) => channel.length)); return Array.from({ length: bins }, (_, i) => { let peak = 0; for (const channel of channels) for ( let n = Math.floor((i * length) / bins); n < Math.min(channel.length, Math.ceil(((i + 1) * length) / bins)); n++ ) peak = Math.max(peak, Math.abs(channel[n] ?? 0)); return Math.min(1, peak); }); } /** User-triggered decoding preserves streaming playback until a waveform is requested. */ export function mountWaveform( parent: HTMLElement, audio: HTMLMediaElement, source: ViewerSource, signal: AbortSignal, ): () => void { const doc = parent.ownerDocument; const view = doc.defaultView; const button = doc.createElement("button"); button.type = "button"; button.className = "wrn-viewer-action"; setButtonLabel(button, "Show waveform"); const note = doc.createElement("p"); note.setAttribute("role", "status"); const canvas = doc.createElement("canvas"); canvas.width = 640; canvas.height = 96; canvas.className = "wrn-viewer-waveform"; canvas.setAttribute("role", "img"); canvas.setAttribute("aria-label", "Audio waveform"); const seek = doc.createElement("input"); seek.type = "range"; seek.min = "0"; seek.max = "1000"; seek.value = "0"; seek.setAttribute("aria-label", "Seek audio"); let context: AudioContext | undefined; let disposed = false; const update = () => { if (Number.isFinite(audio.duration) && audio.duration > 0) seek.value = String(Math.round((audio.currentTime / audio.duration) * 1000)); }; seek.oninput = () => { if (Number.isFinite(audio.duration)) audio.currentTime = (Number(seek.value) / 1000) * audio.duration; }; audio.addEventListener("timeupdate", update); button.onclick = async () => { button.disabled = true; note.textContent = "Building waveform…"; parent.append(note); try { if (!view?.AudioContext) throw new Error("No decoder"); // 8 MiB encoded and 10 minutes of audio bound this optional analysis. if (!Number.isFinite(audio.duration) || audio.duration > 600) throw new Error("Duration unavailable or too long"); const file = await readSource(source, 8 * 1024 * 1024, { signal }); if (disposed) return; context = new view.AudioContext(); const decoded = await context.decodeAudioData(await file.arrayBuffer()); if (disposed) return; const peaks = waveformPeaks( Array.from({ length: decoded.numberOfChannels }, (_, i) => decoded.getChannelData(i)), ); const draw = canvas.getContext("2d"); if (!draw) throw new Error("No canvas"); draw.fillStyle = view.getComputedStyle(parent).getPropertyValue("--wrn-color-primary") || view.getComputedStyle(parent).color; peaks.forEach((peak, i) => { const h = Math.max(1, peak * 90); draw.fillRect(i * 4, (96 - h) / 2, 3, h); }); note.textContent = ""; button.remove(); parent.append(canvas, seek); } catch { if (!disposed) { note.textContent = "Waveform unavailable for this file. Audio controls remain available."; button.disabled = false; } } finally { await context?.close().catch(() => {}); context = undefined; } }; parent.append(button); return () => { disposed = true; audio.removeEventListener("timeupdate", update); void context?.close().catch(() => {}); button.remove(); note.remove(); canvas.remove(); seek.remove(); }; }