109 lines
3.8 KiB
TypeScript
109 lines
3.8 KiB
TypeScript
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();
|
|
};
|
|
}
|