342 lines
13 KiB
JavaScript
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');
|
|
});
|