Files
2026-08-28 01:01:12 +02:00

171 lines
6.9 KiB
JavaScript

import { H3_FPS } from './state.js';
import { resolveInputAudio } from './preview_sources.js';
export const WAVEFORM_BUCKETS = 900;
// decodeAudioData expands everything to Float32 in memory (an hour at 48 kHz is ~700 MB),
// and it cannot decode a fragment. The automatic pass therefore refuses oversized files
// and defers to an explicit rebuild instead of risking the tab.
export const AUTO_AUDIO_BYTES = 64 * 1024 * 1024;
export function formatBytes(bytes) {
const value = Math.max(0, Number(bytes) || 0);
if (value >= 1024 * 1024) return `${(value / (1024 * 1024)).toFixed(1)} MB`;
return `${Math.round(value / 1024)} kB`;
}
/**
* Reduces samples to per-bucket min/max pairs, the classic waveform envelope.
* Pure: takes a plain array/typed array, returns `{ min, max }` in [-1, 1].
*/
export function computePeaks(samples, buckets = WAVEFORM_BUCKETS) {
const length = samples?.length || 0;
const count = Math.max(1, Math.round(Number(buckets) || WAVEFORM_BUCKETS));
if (!length) return [];
const peaks = [];
for (let index = 0; index < count; index += 1) {
const start = Math.floor((index * length) / count);
const end = Math.max(start + 1, Math.floor(((index + 1) * length) / count));
let min = 1;
let max = -1;
for (let cursor = start; cursor < end && cursor < length; cursor += 1) {
const value = samples[cursor];
if (value < min) min = value;
if (value > max) max = value;
}
peaks.push({ min: Math.max(-1, min), max: Math.min(1, max) });
}
return peaks;
}
/** Averages every channel of a decoded buffer into one mono track. */
export function mixToMono(buffer) {
const channels = Math.max(1, Number(buffer?.numberOfChannels) || 1);
const length = Math.max(0, Number(buffer?.length) || 0);
if (!length) return new Float32Array(0);
if (channels === 1) return buffer.getChannelData(0);
const mono = new Float32Array(length);
for (let channel = 0; channel < channels; channel += 1) {
const data = buffer.getChannelData(channel);
for (let index = 0; index < length; index += 1) mono[index] += data[index] / channels;
}
return mono;
}
/**
* Crops mono samples to the window a loader actually outputs.
* A zero duration means "everything from startSeconds to the end". A window that starts
* past the end of the file yields nothing rather than a misleading sliver of the tail.
*/
export function sliceSamples(samples, sampleRate, startSeconds = 0, durationSeconds = 0) {
const length = samples?.length || 0;
const rate = Math.max(1, Number(sampleRate) || 0);
if (!length) return { samples: samples || [], duration: 0 };
const start = Math.min(length, Math.max(0, Math.round((Number(startSeconds) || 0) * rate)));
const requested = Math.max(0, Math.round((Number(durationSeconds) || 0) * rate));
const end = requested > 0 ? Math.min(length, start + requested) : length;
if (end <= start) return { samples: [], duration: 0 };
return { samples: samples.slice(start, end), duration: (end - start) / rate };
}
/**
* Maps an audio envelope onto the timeline's frame axis.
* Shorter audio keeps its own share of the width; longer audio is cropped to the timeline.
*/
export function alignPeaksToTimeline(peaks, audioSeconds, timelineSeconds) {
const list = peaks || [];
const audio = Math.max(0, Number(audioSeconds) || 0);
const timeline = Math.max(0.0001, Number(timelineSeconds) || 0);
if (!list.length || !audio) return { peaks: [], widthFraction: 0 };
if (audio <= timeline) return { peaks: list, widthFraction: Math.min(1, audio / timeline) };
const visible = Math.max(1, Math.round((list.length * timeline) / audio));
return { peaks: list.slice(0, visible), widthFraction: 1 };
}
export function timelineSeconds(frameCount, fps = H3_FPS) {
return Math.max(1, Math.round(Number(frameCount) || 1)) / Math.max(0.001, Number(fps) || H3_FPS);
}
/**
* Loads and decodes the audio connected to `inputName`. No graph execution: the file the
* loader already points at is fetched from /view and decoded by the browser.
*/
export async function loadAudioReference(node, inputName, {
toUrl,
buckets = WAVEFORM_BUCKETS,
maxBytes = 0,
fetchAudio = defaultFetch,
decodeAudio,
} = {}) {
const resolved = resolveInputAudio(node, inputName, toUrl);
if (resolved.status !== 'ready') {
throw new Error(`No readable audio to preview: ${resolved.reason}.`);
}
const decode = decodeAudio || defaultDecoder();
const buffer = await decode(await fetchAudio(resolved.url, { maxBytes }));
// The waveform must show what the loader outputs, not the whole file behind it.
const window = sliceSamples(
mixToMono(buffer),
buffer?.sampleRate,
resolved.startSeconds,
resolved.durationSeconds,
);
if (!window.samples.length) {
throw new Error("the loader's trim starts past the end of this audio");
}
return {
url: resolved.url,
label: resolved.label,
duration: window.duration || Math.max(0, Number(buffer?.duration) || 0),
peaks: computePeaks(window.samples, buckets),
};
}
async function defaultFetch(url, { maxBytes = 0 } = {}) {
const response = await fetch(url);
if (!response.ok) throw new Error(`audio request failed (${response.status})`);
const size = Number(response.headers?.get?.('content-length')) || 0;
if (maxBytes > 0 && size > maxBytes) {
// Reported before buffering: the point is not to pull the file at all.
throw new Error(`file is ${formatBytes(size)}, over the ${formatBytes(maxBytes)} automatic limit`);
}
return response.arrayBuffer();
}
function defaultDecoder() {
return async (arrayBuffer) => {
const Context = globalThis.AudioContext || globalThis.webkitAudioContext;
if (!Context) throw new Error('this browser exposes no AudioContext');
const context = new Context();
try {
return await context.decodeAudioData(arrayBuffer);
} finally {
context.close?.();
}
};
}
/**
* Draws the envelope at a fixed internal resolution and lets CSS stretch it, so the
* canvas never depends on being laid out before the timeline renders.
*/
export function drawWaveform(canvas, peaks, { widthFraction = 1, color = '#6f8ea4' } = {}) {
const context = canvas?.getContext?.('2d');
if (!context) return false;
const width = canvas.width;
const height = canvas.height;
context.clearRect(0, 0, width, height);
const list = peaks || [];
if (!list.length) return false;
const usable = Math.max(1, Math.round(width * Math.min(1, Math.max(0, widthFraction))));
const middle = height / 2;
context.fillStyle = color;
for (let index = 0; index < list.length; index += 1) {
const x = Math.round((index * usable) / list.length);
const next = Math.max(x + 1, Math.round(((index + 1) * usable) / list.length));
const top = middle - (list[index].max * middle);
const bottom = middle - (list[index].min * middle);
context.fillRect(x, Math.min(top, bottom), next - x, Math.max(1, Math.abs(bottom - top)));
}
return true;
}