Ship file viewer, editor improvements, and private package releases
This commit is contained in:
1 parent
645914955c
commit
a4a31032b9
158 files changed
+10523
-137
No files matched your search
@@ -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();
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user