Ship file viewer, editor improvements, and private package releases

This commit is contained in:
Clintchiz committed 2026-09-09 11:20:11 +05:30
1 parent 645914955c
commit a4a31032b9
158 files changed
+10523 -137

No files matched your search

+108
View File
@@ -0,0 +1,108 @@
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();
};
}