171 lines
6.9 KiB
JavaScript
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;
|
|
}
|