Files
Darksidewalker 3b94f5b785 feat(seamless-loop): sample-exact audio input/output and Beat-aware loop length (0.5.41)
Optional audio/frame_rate/audio_loop inputs and an AUDIO output: audio is cut and equal-power crossfaded with the same frame mapping as the video (round(f*sr/fps) from absolute frame indices). Beat-aware estimates the tempo (spectral flux, 60-200 BPM) and prefers a loop span of whole beats within 1.25x of the best visual cost; falls back to Crossfade without a clear tempo. Without audio the node is byte-identical to 0.5.40 (golden fixture).
2026-11-09 18:04:37 +01:00

305 lines
13 KiB
Python

"""Seamless Loop audio/beat contracts (CPU, synthetic, fake interpolation)."""
import hashlib
import importlib.util
import json
import logging
import math
from pathlib import Path
import sys
import pytest
import torch
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / 'nodes'))
GOLDEN = ROOT / 'tests/fixtures/seamless_loop_golden.json'
def load_loop():
spec = importlib.util.spec_from_file_location('nodes_seamless_loop', ROOT / 'nodes/nodes_seamless_loop.py')
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def lerp_loader(loop, monkeypatch):
monkeypatch.setattr(loop, '_load_interpolator', lambda *args: lambda a, b, t: torch.lerp(a, b, t))
def frames(n, period, h=9, w=11, phase=0.0):
"""Deterministic moving texture (no RNG, so golden hashes survive torch upgrades)."""
y, x = torch.meshgrid(torch.arange(h, dtype=torch.float32), torch.arange(w, dtype=torch.float32), indexing='ij')
out = []
for i in range(n):
p = 2 * math.pi * (i / period + phase)
out.append(torch.stack([.5 + .4 * torch.sin(x / 2 + p), .5 + .4 * torch.cos(y / 3 - p),
.5 + .3 * torch.sin((x + y) / 4 + 2 * p)], -1))
return torch.stack(out)
def image_cases():
"""name -> (images, exact_endpoint), covering cases (a)-(d)."""
closed = frames(17, 23.0)
closed[-1] = closed[0]
return {
'static': (torch.full((10, 9, 11, 3), .25), False),
'closed': (closed, False),
'closed_exact': (closed, True),
'short4': (frames(4, 7.0), False),
'short4_exact': (frames(4, 7.0), True),
'short2': (frames(2, 7.0), False),
'overlap40': (frames(40, 13.0), False),
'overlap40_exact': (frames(40, 13.0), True),
'overlap57': (frames(57, 29.5, phase=.1), False),
'linear30': (torch.linspace(0, 1, 30)[:, None, None, None].expand(30, 9, 11, 3).clone(), True),
}
def digest(tensor):
return hashlib.sha256(tensor.contiguous().numpy().tobytes()).hexdigest()
def fingerprint(loop, monkeypatch):
"""Image outputs and _analyze choices; used to prove the no-audio path is unchanged."""
lerp_loader(loop, monkeypatch)
result = {}
for name, (images, exact) in image_cases().items():
out = loop.DaSiWa_SeamlessLoop().execute(images, 'rife.safetensors', exact)
result[name] = {'shape': list(out[0].shape), 'sha256': digest(out[0]),
'analyze': list(loop._analyze(loop._preview(images))[:3])}
return result
def audio_for(n, fps, sr, signal='sine', channels=2, batch=1, extra=0):
s = round(n * sr / fps) + extra
t = torch.arange(s, dtype=torch.float64) / sr
if signal == 'sine':
mono = torch.sin(2 * math.pi * 220 * t)
else:
mono = signal(t)
wave = mono.float()[None, None].repeat(batch, channels, 1)
wave[:, 1:] *= .5 # distinct channels must stay distinct
return {'waveform': wave, 'sample_rate': sr}
def s_(f, sr, fps):
return round(f * sr / fps)
# --- backward compatibility -------------------------------------------------
def test_no_audio_path_is_byte_identical_to_0_5_40(monkeypatch):
loop = load_loop()
assert fingerprint(loop, monkeypatch) == json.loads(GOLDEN.read_text())
def test_no_audio_returns_none_second_output(monkeypatch):
loop = load_loop()
lerp_loader(loop, monkeypatch)
images, _ = image_cases()['overlap40']
out = loop.DaSiWa_SeamlessLoop().execute(images, 'rife.safetensors')
assert len(out) == 2 and out[1] is None
assert loop.DaSiWa_SeamlessLoop.RETURN_TYPES == ('IMAGE', 'AUDIO')
assert loop.DaSiWa_SeamlessLoop.RETURN_NAMES == ('images', 'audio')
def test_crossfade_audio_does_not_change_images(monkeypatch):
loop = load_loop()
lerp_loader(loop, monkeypatch)
golden = json.loads(GOLDEN.read_text())
for name, (images, exact) in image_cases().items():
out, _ = loop.DaSiWa_SeamlessLoop().execute(images, 'rife.safetensors', exact,
audio=audio_for(len(images), 24, 48000), frame_rate=24.0)
assert digest(out) == golden[name]['sha256'], name
def test_schema_new_inputs_are_optional_and_default_off(monkeypatch):
loop = load_loop()
import folder_paths
monkeypatch.setattr(folder_paths, 'get_filename_list', lambda category: ['rife.safetensors'])
schema = loop.DaSiWa_SeamlessLoop.INPUT_TYPES()
assert set(schema['required']) == {'images'}
keys = list(schema['optional'])
assert keys[-3:] == ['audio', 'frame_rate', 'audio_loop'] # appended, legacy widget order kept
assert schema['optional']['audio'][0] == 'AUDIO'
assert schema['optional']['frame_rate'][1]['default'] == 0.0
assert schema['optional']['audio_loop'][0] == ['Crossfade', 'Beat-aware']
assert schema['optional']['audio_loop'][1]['default'] == 'Crossfade'
# --- audio mapping -----------------------------------------------------------
def test_audio_without_frame_rate_raises():
loop = load_loop()
images = frames(12, 7.0)
with pytest.raises(ValueError, match='frame_rate'):
loop.DaSiWa_SeamlessLoop().execute(images, 'missing.safetensors', audio=audio_for(12, 24, 48000))
@pytest.mark.parametrize('fps,sr', [(24.0, 48000), (25.0, 44100), (30000 / 1001, 48000)])
@pytest.mark.parametrize('name', ['closed', 'closed_exact', 'short4', 'short4_exact', 'short2',
'overlap40', 'overlap40_exact', 'overlap57'])
def test_audio_length_is_sample_exact(monkeypatch, fps, sr, name):
loop = load_loop()
lerp_loader(loop, monkeypatch)
images, exact = image_cases()[name]
audio = audio_for(len(images), fps, sr, channels=2, batch=2)
out, aout = loop.DaSiWa_SeamlessLoop().execute(images, 'rife.safetensors', exact, audio=audio, frame_rate=fps)
wave = aout['waveform']
assert wave.shape == (2, 2, s_(len(out), sr, fps))
assert wave.dtype == audio['waveform'].dtype and aout['sample_rate'] == sr
assert aout is not audio and torch.isfinite(wave).all()
def test_static_audio_is_returned_unchanged(monkeypatch):
loop = load_loop()
images, _ = image_cases()['static']
audio = audio_for(10, 24, 48000, extra=123) # already mismatched: still unchanged
out, aout = loop.DaSiWa_SeamlessLoop().execute(images, 'missing.safetensors', audio=audio, frame_rate=24.0)
assert torch.equal(aout['waveform'], audio['waveform']) and aout is not audio
@pytest.mark.parametrize('exact', [False, True])
def test_closed_audio_drops_last_frame(exact):
loop = load_loop()
images, _ = image_cases()['closed']
audio = audio_for(len(images), 25, 44100)
out, aout = loop.DaSiWa_SeamlessLoop().execute(images, 'missing.safetensors', exact, audio=audio, frame_rate=25.0)
frames_out = len(images) if exact else len(images) - 1
assert torch.equal(aout['waveform'], audio['waveform'][..., :s_(frames_out, 44100, 25)])
@pytest.mark.parametrize('exact', [False, True])
def test_overlap_audio_mapping_and_wrap_continuity(monkeypatch, exact, caplog):
loop = load_loop()
lerp_loader(loop, monkeypatch)
fps, sr = 24.0, 48000
images, _ = image_cases()['overlap40']
audio = audio_for(len(images), fps, sr)
with caplog.at_level(logging.INFO):
out, aout = loop.DaSiWa_SeamlessLoop().execute(images, 'rife.safetensors', exact, audio=audio, frame_rate=fps)
start, end, k, _ = loop._analyze(loop._preview(images))
src, wave = audio['waveform'], aout['waveform']
head_end = s_(start + k, sr, fps)
middle = s_(end - k, sr, fps) - head_end
# Middle audio is the untouched source starting at frame start+k.
assert torch.equal(wave[..., :middle], src[..., head_end:head_end + middle])
count = end - start - k
# The crossfade ends exactly on the head, i.e. on the sample right before frame start+k.
assert torch.allclose(wave[..., s_(count, sr, fps) - 1], src[..., head_end - 1], atol=1e-6)
if exact:
tail = wave[..., s_(count, sr, fps):]
assert torch.equal(tail, src[..., head_end:head_end + tail.shape[-1]])
else:
# Repeating the output must be sample-continuous for a continuous signal.
mono = wave[0, 0].double()
joined = torch.cat([mono, mono])
steps = (joined[1:] - joined[:-1]).abs()
joint = steps[len(mono) - 1]
assert joint <= steps.max() * 1.0001
assert joint <= 2 * math.sin(math.pi * 220 / sr) + 1e-6 # no larger than a source sample step
assert any('audio' in r.message and 'samples' in r.message for r in caplog.records)
def test_short_audio_is_zero_padded_and_logged(monkeypatch, caplog):
loop = load_loop()
lerp_loader(loop, monkeypatch)
images, _ = image_cases()['overlap40']
audio = audio_for(len(images), 24, 48000, extra=-4800)
with caplog.at_level(logging.INFO):
out, aout = loop.DaSiWa_SeamlessLoop().execute(images, 'rife.safetensors', audio=audio, frame_rate=24.0)
assert aout['waveform'].shape[-1] == s_(len(out), 48000, 24)
assert any('shorter' in r.message for r in caplog.records)
def test_bridge_audio_appends_equal_power_crossfade(monkeypatch):
loop = load_loop()
lerp_loader(loop, monkeypatch)
fps, sr = 24.0, 48000
images, _ = image_cases()['short4']
audio = audio_for(4, fps, sr)
out, aout = loop.DaSiWa_SeamlessLoop().execute(images, 'rife.safetensors', audio=audio, frame_rate=fps)
src, wave = audio['waveform'], aout['waveform']
n = s_(4, sr, fps)
assert len(out) == 8 and torch.equal(wave[..., :n], src[..., :n])
x = wave.shape[-1] - n
assert torch.allclose(wave[..., n], src[..., n - x]) # starts as the tail
assert torch.allclose(wave[..., -1], src[..., x - 1], atol=1e-6) # ends as the head
# --- tempo / beat-aware ------------------------------------------------------
def clicks(bpm, seconds, sr=48000, offset=.05):
s = round(seconds * sr)
wave = torch.zeros(s)
beat = 60 / bpm
t = offset
while t < seconds:
i = round(t * sr)
n = min(240, s - i)
wave[i:i + n] += torch.hann_window(2 * n)[n:] * torch.sin(torch.arange(n) * .9)
t += beat
return wave
@pytest.mark.parametrize('bpm', [100, 120, 150])
def test_tempo_estimator(bpm):
loop = load_loop()
est, confidence = loop._estimate_tempo(clicks(bpm, 8.0)[None, None], 48000)
assert abs(est - bpm) / bpm < .02
assert confidence >= loop.BEAT_MIN_CONFIDENCE
def test_tempo_estimator_low_confidence_on_noise():
loop = load_loop()
g = torch.Generator().manual_seed(1)
_, confidence = loop._estimate_tempo(torch.randn(1, 2, 48000 * 6, generator=g) * .1, 48000)
assert confidence < loop.BEAT_MIN_CONFIDENCE
def beat_video():
# Visual period 21 frames plus drift: best crossfade loop is 62 frames (5.17 beats at 120 BPM / 24 fps),
# an 84-frame (7-beat) loop costs ~1.07x.
return (frames(96, 21.0) + .3 * torch.linspace(0, 1, 96)[:, None, None, None]).clamp(0, 1)
def test_beat_aware_picks_whole_beats_within_visual_tolerance(monkeypatch, caplog):
loop = load_loop()
lerp_loader(loop, monkeypatch)
fps, sr = 24.0, 48000
images = beat_video()
preview = loop._preview(images)
s0, e0, k0, _ = loop._analyze(preview)
period = .5
assert ((e0 - s0 - k0) / fps / period) % 1 > .1, 'fixture: visual best must not be a beat multiple'
wave = clicks(120, len(images) / fps + .1, sr)[:s_(len(images), sr, fps)]
audio = {'waveform': wave[None, None].repeat(1, 2, 1), 'sample_rate': sr}
with caplog.at_level(logging.INFO):
out, aout = loop.DaSiWa_SeamlessLoop().execute(images, 'rife.safetensors', audio=audio,
frame_rate=fps, audio_loop='Beat-aware')
loop_len = len(out)
beats = loop_len / fps / period
assert abs(beats - round(beats)) * period * fps <= .5 + 1e-9 # within frame quantization
assert aout['waveform'].shape[-1] == s_(loop_len, sr, fps)
costs = {(c[1], c[2], c[3]): c[0] for c in loop._candidates(preview, range(int(len(images) * .1) + 1))}
best = min(loop._candidates(preview))[0]
chosen = [key for key in costs if key[2] and key[1] - key[0] - key[2] == loop_len]
assert any(costs[key] <= best * loop.BEAT_VISUAL_TOLERANCE for key in chosen)
assert any('BPM' in r.message and 'beats' in r.message for r in caplog.records)
def test_beat_aware_low_confidence_falls_back_to_crossfade(monkeypatch, caplog):
loop = load_loop()
lerp_loader(loop, monkeypatch)
fps, sr = 24.0, 48000
images = beat_video()
g = torch.Generator().manual_seed(3)
audio = {'waveform': torch.randn(1, 2, s_(len(images), sr, fps), generator=g) * .1, 'sample_rate': sr}
cross = loop.DaSiWa_SeamlessLoop().execute(images, 'rife.safetensors', audio=audio, frame_rate=fps)
with caplog.at_level(logging.INFO):
beat = loop.DaSiWa_SeamlessLoop().execute(images, 'rife.safetensors', audio=audio, frame_rate=fps,
audio_loop='Beat-aware')
assert torch.equal(cross[0], beat[0])
assert torch.equal(cross[1]['waveform'], beat[1]['waveform'])
assert any('Crossfade' in r.message and 'confidence' in r.message for r in caplog.records)