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

342 lines
13 KiB
JavaScript

import test from 'node:test';
import assert from 'node:assert/strict';
import {
alignPeaksToTimeline,
computePeaks,
drawWaveform,
loadAudioReference,
mixToMono,
sliceSamples,
timelineSeconds,
AUTO_AUDIO_BYTES,
formatBytes,
WAVEFORM_BUCKETS,
} from '../../web/ui/waveform.js';
function sine(length, cycles = 4) {
return Float32Array.from({ length }, (_, index) => Math.sin((index / length) * cycles * 2 * Math.PI));
}
function buffer(channels, length, duration, sampleRate = length / duration) {
return {
numberOfChannels: channels.length,
length,
duration,
sampleRate,
getChannelData: (index) => channels[index],
};
}
test('peaks reduce any sample count to the requested bucket count', () => {
const peaks = computePeaks(sine(44100), 100);
assert.equal(peaks.length, 100);
for (const peak of peaks) {
assert.ok(peak.min >= -1 && peak.min <= 1);
assert.ok(peak.max >= -1 && peak.max <= 1);
assert.ok(peak.max >= peak.min);
}
assert.ok(Math.max(...peaks.map((p) => p.max)) > 0.9, 'a full-scale sine reaches the top');
assert.ok(Math.min(...peaks.map((p) => p.min)) < -0.9);
});
test('silence and empty input never produce a phantom waveform', () => {
assert.deepEqual(computePeaks([], 50), []);
assert.deepEqual(computePeaks(null), []);
const silent = computePeaks(new Float32Array(1000), 10);
assert.equal(silent.length, 10);
assert.ok(silent.every((peak) => peak.min === 0 && peak.max === 0));
});
test('fewer samples than buckets still yields one bucket per slot', () => {
const peaks = computePeaks(Float32Array.from([1, -1, 0.5]), 8);
assert.equal(peaks.length, 8);
});
test('channels are averaged into mono', () => {
const left = Float32Array.from([1, 1, 0]);
const right = Float32Array.from([-1, 0, 0]);
const mono = mixToMono(buffer([left, right], 3, 1));
assert.ok(Math.abs(mono[0] - 0) < 1e-6);
assert.ok(Math.abs(mono[1] - 0.5) < 1e-6);
assert.equal(mixToMono(buffer([left], 3, 1)), left, 'a mono buffer is passed through');
});
test('shorter audio occupies only its share of the timeline width', () => {
const peaks = computePeaks(sine(1000), 100);
// 124 frames at 24 fps ≈ 5.17s of timeline; a 2s clip covers ~39% of it.
const aligned = alignPeaksToTimeline(peaks, 2, timelineSeconds(124));
assert.equal(aligned.peaks.length, 100);
assert.ok(Math.abs(aligned.widthFraction - 2 / (124 / 24)) < 1e-6);
});
test('longer audio is cropped to the timeline instead of being squeezed', () => {
const peaks = computePeaks(sine(1000), 100);
const aligned = alignPeaksToTimeline(peaks, 20, timelineSeconds(124));
assert.equal(aligned.widthFraction, 1);
assert.equal(aligned.peaks.length, Math.round((100 * (124 / 24)) / 20));
assert.ok(aligned.peaks.length < 100);
});
test('no audio means no drawing at all', () => {
assert.deepEqual(alignPeaksToTimeline([], 5, 5), { peaks: [], widthFraction: 0 });
assert.deepEqual(alignPeaksToTimeline([{ min: 0, max: 1 }], 0, 5), { peaks: [], widthFraction: 0 });
});
test('drawing fills the requested fraction of the canvas and nothing beyond', () => {
const calls = [];
const canvas = {
width: 900,
height: 60,
getContext: () => ({
clearRect: () => {},
fillRect: (x, y, w, h) => calls.push({ x, y, w, h }),
set fillStyle(value) { this._fill = value; },
}),
};
const peaks = computePeaks(sine(4000), 200);
assert.equal(drawWaveform(canvas, peaks, { widthFraction: 0.5 }), true);
assert.equal(calls.length, 200);
assert.ok(calls.every((call) => call.x + call.w <= 451), 'nothing is drawn past the half mark');
assert.ok(calls.every((call) => call.h >= 1), 'silent columns keep a visible baseline');
assert.equal(drawWaveform(canvas, [], {}), false);
});
test('the connected audio file is fetched and decoded without executing the graph', async () => {
const source = { id: 2, comfyClass: 'LoadAudio', widgets: [{ name: 'audio', value: 'voice.wav [input]' }] };
const node = { id: 1, inputs: [{ name: 'audio_reference', link: 4 }] };
node.graph = { links: { 4: { origin_id: 2 } }, getNodeById: (id) => (id === 2 ? source : null) };
const fetched = [];
const loaded = await loadAudioReference(node, 'audio_reference', {
toUrl: (path, type) => `/api/view?filename=${path}&type=${type}`,
buckets: 32,
fetchAudio: async (url) => { fetched.push(url); return new ArrayBuffer(8); },
decodeAudio: async () => buffer([sine(2000)], 2000, 3.5),
});
assert.deepEqual(fetched, ['/api/view?filename=voice.wav&type=input'], 'the [input] suffix is stripped');
assert.equal(loaded.duration, 3.5);
assert.equal(loaded.peaks.length, 32);
assert.equal(loaded.label, 'voice.wav');
});
test('VHS audio widget names resolve too, and a bare socket reports why', async () => {
const source = { id: 2, comfyClass: 'VHS_LoadAudioUpload', widgets: [{ name: 'audio', value: 'track.mp3' }, { name: 'start_time', value: 2 }] };
const node = { id: 1, inputs: [{ name: 'audio_reference', link: 4 }] };
node.graph = { links: { 4: { origin_id: 2 } }, getNodeById: (id) => (id === 2 ? source : null) };
const loaded = await loadAudioReference(node, 'audio_reference', {
toUrl: (path) => `/api/view?filename=${path}`,
buckets: 8,
fetchAudio: async () => new ArrayBuffer(8),
decodeAudio: async () => buffer([sine(5000)], 5000, 5, 1000),
});
assert.equal(loaded.label, 'track.mp3');
assert.ok(Math.abs(loaded.duration - 3) < 1e-6, 'start_time 2 crops a 5s file to its last 3s');
const empty = { id: 1, inputs: [{ name: 'audio_reference', link: null }], graph: { links: {} } };
await assert.rejects(
() => loadAudioReference(empty, 'audio_reference', { toUrl: (p) => p }),
/No readable audio to preview/,
);
});
test('the default bucket count is a sane waveform resolution', () => {
assert.equal(WAVEFORM_BUCKETS, 900);
});
// --- Audio coming from a video loader, verified against the live /object_info ---
// VHS_LoadVideo(video, force_rate, frame_load_cap, skip_first_frames) -> AUDIO
// LoadVideo(file) -> VIDEO -> GetVideoComponents(video) -> AUDIO
function wire(source, extra = []) {
const node = { id: 1, inputs: [{ name: 'audio_reference', link: 4 }] };
const byId = new Map([[source.id, source], ...extra.map((item) => [item.id, item])]);
const graph = { links: { 4: { origin_id: source.id }, 9: { origin_id: 3 } }, getNodeById: (id) => byId.get(id) || null };
for (const item of [node, source, ...extra]) item.graph = graph;
return node;
}
async function load(node, options = {}) {
return loadAudioReference(node, 'audio_reference', {
toUrl: (path, type) => `/api/view?filename=${path}&type=${type}`,
buckets: 16,
fetchAudio: async () => new ArrayBuffer(8),
decodeAudio: async () => buffer([sine(48000 * 10)], 48000 * 10, 10, 48000),
...options,
});
}
test('samples are cropped to the requested window', () => {
const samples = Float32Array.from({ length: 1000 }, (_, index) => index / 1000);
const window = sliceSamples(samples, 100, 2, 3);
assert.equal(window.samples.length, 300);
assert.equal(window.duration, 3);
assert.ok(Math.abs(window.samples[0] - 0.2) < 1e-6, 'it starts at 2s');
});
test('a zero duration means everything from the start offset onwards', () => {
const samples = Float32Array.from({ length: 1000 }, (_, index) => index);
const window = sliceSamples(samples, 100, 4, 0);
assert.equal(window.samples.length, 600);
assert.equal(window.duration, 6);
});
test('a window past the end of the file yields nothing, not a misleading sliver', () => {
const window = sliceSamples(Float32Array.from([1, 2, 3]), 10, 99, 5);
assert.equal(window.samples.length, 0);
assert.equal(window.duration, 0);
});
test('a trim that starts past the end of the audio is reported, not drawn', async () => {
const source = {
id: 2,
comfyClass: 'VHS_LoadVideo',
widgets: [
{ name: 'video', value: 'shot.mp4' },
{ name: 'force_rate', value: 24 },
{ name: 'frame_load_cap', value: 24 },
{ name: 'skip_first_frames', value: 100000 },
{ name: 'select_every_nth', value: 1 },
],
};
await assert.rejects(() => load(wire(source)), /trim starts past the end/);
});
test('VHS_LoadVideo audio resolves to the video file and honours its frame trim', async () => {
const source = {
id: 2,
comfyClass: 'VHS_LoadVideo',
widgets: [
{ name: 'video', value: 'shot.mp4' },
{ name: 'force_rate', value: 24 },
{ name: 'frame_load_cap', value: 48 },
{ name: 'skip_first_frames', value: 120 },
{ name: 'select_every_nth', value: 1 },
],
};
const fetched = [];
const loaded = await load(wire(source), { fetchAudio: async (url) => { fetched.push(url); return new ArrayBuffer(8); } });
assert.deepEqual(fetched, ['/api/view?filename=shot.mp4&type=input']);
// skip 120 frames at 24 fps = 5s in, 48 frames = 2s long
assert.ok(Math.abs(loaded.duration - 2) < 1e-6);
assert.equal(loaded.peaks.length, 16);
});
test('select_every_nth stretches the source-time window the audio spans', async () => {
const source = {
id: 2,
comfyClass: 'VHS_LoadVideo',
widgets: [
{ name: 'video', value: 'shot.mp4' },
{ name: 'force_rate', value: 24 },
{ name: 'frame_load_cap', value: 24 },
{ name: 'skip_first_frames', value: 0 },
{ name: 'select_every_nth', value: 3 },
],
};
const loaded = await load(wire(source));
assert.ok(Math.abs(loaded.duration - 3) < 1e-6, '24 output frames every 3rd source frame = 72 frames = 3s');
});
test('an untrimmed VHS_LoadVideo shows the whole track', async () => {
const source = {
id: 2,
comfyClass: 'VHS_LoadVideo',
widgets: [
{ name: 'video', value: 'shot.mp4' },
{ name: 'force_rate', value: 0 },
{ name: 'frame_load_cap', value: 0 },
{ name: 'skip_first_frames', value: 0 },
{ name: 'select_every_nth', value: 1 },
],
};
const loaded = await load(wire(source));
assert.ok(Math.abs(loaded.duration - 10) < 1e-6);
});
test('LoadVideo through GetVideoComponents reaches the video file untrimmed', async () => {
const loader = { id: 3, comfyClass: 'LoadVideo', widgets: [{ name: 'file', value: 'clip.mp4' }] };
const components = { id: 2, comfyClass: 'GetVideoComponents', inputs: [{ name: 'video', link: 9 }], widgets: [] };
const fetched = [];
const loaded = await load(wire(components, [loader]), {
fetchAudio: async (url) => { fetched.push(url); return new ArrayBuffer(8); },
});
assert.deepEqual(fetched, ['/api/view?filename=clip.mp4&type=input']);
assert.ok(Math.abs(loaded.duration - 10) < 1e-6);
assert.equal(loaded.label, 'clip.mp4');
});
test('a video loader with no file selected says so', async () => {
const source = { id: 2, comfyClass: 'VHS_LoadVideo', widgets: [{ name: 'video', value: '' }] };
await assert.rejects(() => load(wire(source)), /the loader has no file selected/);
});
// --- Automatic pass vs. forced reload ---
test('the automatic pass refuses an oversized file before downloading it', async () => {
const source = { id: 2, comfyClass: 'LoadAudio', widgets: [{ name: 'audio', value: 'huge.wav' }] };
let buffered = false;
const response = {
ok: true,
headers: { get: (name) => (name === 'content-length' ? String(200 * 1024 * 1024) : null) },
arrayBuffer: async () => { buffered = true; return new ArrayBuffer(8); },
};
const previousFetch = globalThis.fetch;
globalThis.fetch = async () => response;
try {
await assert.rejects(
() => loadAudioReference(wire(source), 'audio_reference', {
toUrl: (path) => `/api/view?filename=${path}`,
maxBytes: AUTO_AUDIO_BYTES,
decodeAudio: async () => buffer([sine(100)], 100, 1, 100),
}),
/200\.0 MB, over the 64\.0 MB automatic limit/,
);
assert.equal(buffered, false, 'the body is never pulled');
} finally {
globalThis.fetch = previousFetch;
}
});
test('a forced reload has no size limit', async () => {
const source = { id: 2, comfyClass: 'LoadAudio', widgets: [{ name: 'audio', value: 'huge.wav' }] };
const previousFetch = globalThis.fetch;
globalThis.fetch = async () => ({
ok: true,
headers: { get: () => String(200 * 1024 * 1024) },
arrayBuffer: async () => new ArrayBuffer(8),
});
try {
const loaded = await loadAudioReference(wire(source), 'audio_reference', {
toUrl: (path) => `/api/view?filename=${path}`,
maxBytes: 0,
buckets: 8,
decodeAudio: async () => buffer([sine(1000)], 1000, 2, 500),
});
assert.equal(loaded.peaks.length, 8);
assert.equal(loaded.duration, 2);
} finally {
globalThis.fetch = previousFetch;
}
});
test('a failed request is reported with its status', async () => {
const source = { id: 2, comfyClass: 'LoadAudio', widgets: [{ name: 'audio', value: 'gone.wav' }] };
const previousFetch = globalThis.fetch;
globalThis.fetch = async () => ({ ok: false, status: 404, headers: { get: () => null } });
try {
await assert.rejects(
() => loadAudioReference(wire(source), 'audio_reference', { toUrl: (path) => path }),
/audio request failed \(404\)/,
);
} finally {
globalThis.fetch = previousFetch;
}
});
test('sizes are reported in units a human reads', () => {
assert.equal(formatBytes(64 * 1024 * 1024), '64.0 MB');
assert.equal(formatBytes(269114), '263 kB');
assert.equal(formatBytes(0), '0 kB');
assert.equal(formatBytes(null), '0 kB');
});