487 lines
23 KiB
Python
487 lines
23 KiB
Python
"""Offline checks for the audio preview — no server, no GPU, no model.
|
|
|
|
<ComfyUI>/venv/bin/python test_audio_preview.py [--write-clip]
|
|
|
|
Needs torch, because what it protects is tensor plumbing: which of the packed streams gets
|
|
picked up, the scale the sampler carries the audio at, how a window in seconds becomes latent
|
|
frames, and whether the bytes handed to the browser are a real audio file. The ComfyUI modules
|
|
the node imports are stubbed below — none of them are involved in the parts under test, and
|
|
stubbing them is what keeps this runnable without a server.
|
|
|
|
The fake VAEs below take their channel count, sample rate and samples-per-latent-frame as
|
|
constructor arguments, so the checks can prove those three come from the VAE and the decode
|
|
rather than from constants — a VAE variant at another rate is read correctly instead of quietly
|
|
decoding nonsense.
|
|
|
|
Two checks here exist because of bugs that shipped:
|
|
|
|
* **The stream index.** Video and audio differ by one index in a packed latent, and getting it
|
|
wrong does not raise — it decodes silence, or noise, and reads as a model problem. The stream
|
|
is now matched by the VAE's channel count, and a pack with audio first proves it.
|
|
* **The audio scale.** `ModelSamplingAV` carries the audio stream at `shift / audio_shift`, so
|
|
a decode that does not divide it out returns loud noise at every step while the video stream
|
|
looks perfect and points at nothing.
|
|
"""
|
|
import importlib.util
|
|
import io
|
|
import logging
|
|
import os
|
|
import sys
|
|
import types
|
|
import wave
|
|
|
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
|
|
try:
|
|
import torch
|
|
except ImportError:
|
|
print("SKIP test_audio_preview.py needs torch — run it with the python that runs "
|
|
"ComfyUI:\n <ComfyUI>/venv/bin/python test_audio_preview.py")
|
|
sys.exit(0)
|
|
|
|
|
|
# ---------------------------------------------------------------- stub ComfyUI
|
|
def _module(name, **attrs):
|
|
"""Register a stub module — and hang it off its parent.
|
|
|
|
`import comfy.utils` finds the entry in sys.modules, but `comfy.utils.unpack_latents` then
|
|
reads an *attribute* of `comfy`, which only the real import machinery sets.
|
|
"""
|
|
m = types.ModuleType(name)
|
|
for k, v in attrs.items():
|
|
setattr(m, k, v)
|
|
sys.modules[name] = m
|
|
if "." in name:
|
|
parent, _, leaf = name.rpartition(".")
|
|
setattr(sys.modules[parent], leaf, m)
|
|
return m
|
|
|
|
|
|
def _unpack_latents(packed, shapes):
|
|
"""Stand-in for comfy.utils.unpack_latents: split a flat pack back into its streams."""
|
|
out, offset = [], 0
|
|
for shape in shapes:
|
|
n = 1
|
|
for dim in shape:
|
|
n *= dim
|
|
out.append(packed[..., offset:offset + n].reshape((packed.shape[0],) + tuple(shape)))
|
|
offset += n
|
|
return out
|
|
|
|
|
|
def _vae_decode_audio(vae, samples):
|
|
"""Stand-in for comfy_extras.nodes_audio.vae_decode_audio, same contract."""
|
|
latent = samples["samples"]
|
|
if getattr(latent, "is_nested", False):
|
|
latent = latent.unbind()[-1]
|
|
return {"waveform": vae.decode(latent),
|
|
"sample_rate": getattr(vae, "audio_sample_rate", 32000)}
|
|
|
|
|
|
class _Routes:
|
|
"""Collects the routes the module registers, so the test can assert one was."""
|
|
|
|
def __init__(self):
|
|
self.registered = []
|
|
|
|
def get(self, path):
|
|
self.registered.append(path)
|
|
return lambda fn: fn
|
|
|
|
|
|
_ROUTES = _Routes()
|
|
_module("comfy")
|
|
_module("comfy.utils", unpack_latents=_unpack_latents)
|
|
_module("comfy.model_management",
|
|
OOM_EXCEPTION=getattr(torch.cuda, "OutOfMemoryError", MemoryError))
|
|
_module("comfy.patcher_extension",
|
|
WrappersMP=types.SimpleNamespace(OUTER_SAMPLE="outer_sample"),
|
|
add_wrapper_with_key=lambda *a, **k: None,
|
|
get_all_wrappers=lambda *a, **k: [])
|
|
_module("server", PromptServer=types.SimpleNamespace(
|
|
instance=types.SimpleNamespace(routes=_ROUTES, send_sync=lambda *a, **k: None)))
|
|
_module("aiohttp", web=types.SimpleNamespace(Response=lambda **kw: kw))
|
|
_module("comfy_extras")
|
|
_module("comfy_extras.nodes_audio", vae_decode_audio=_vae_decode_audio)
|
|
_module("comfy_api")
|
|
_io_stub = types.SimpleNamespace(
|
|
ComfyNode=type("ComfyNode", (), {}),
|
|
Schema=lambda **kw: kw, NodeOutput=lambda *a, **k: None,
|
|
Hidden=types.SimpleNamespace(unique_id="unique_id"))
|
|
for _name in ("Model", "Vae", "Int", "Float", "Boolean", "Combo", "Latent"):
|
|
setattr(_io_stub, _name, types.SimpleNamespace(Input=lambda *a, **k: None,
|
|
Output=lambda *a, **k: None))
|
|
_module("comfy_api.latest", io=_io_stub)
|
|
|
|
spec = importlib.util.spec_from_file_location(
|
|
"minimax_h3_audio_preview", os.path.join(HERE, "minimax_h3_audio_preview.py"))
|
|
node = importlib.util.module_from_spec(spec)
|
|
sys.modules["minimax_h3_audio_preview"] = node
|
|
spec.loader.exec_module(node)
|
|
|
|
_results = []
|
|
|
|
|
|
def check(name, got, want):
|
|
_results.append((got == want, name, got, want))
|
|
|
|
|
|
# H3's two streams at the shapes core builds them: video [B,24,T,h,w], audio [B,32,2,T].
|
|
# 32 is H3's audio channel count, and appears here as *the fake VAE's* rather than as something
|
|
# the node itself knows.
|
|
CHANNELS = 32
|
|
VIDEO_SHAPE = (24, 7, 6, 8)
|
|
AUDIO_SHAPE = (CHANNELS, 2, 40) # 40 latent frames = one second at 40 fps
|
|
VIDEO = torch.zeros((1,) + VIDEO_SHAPE)
|
|
AUDIO = torch.ones((1,) + AUDIO_SHAPE) # ones, so a mix-up shows up as silence
|
|
PACKED = torch.cat([VIDEO.reshape(1, -1), AUDIO.reshape(1, -1)], dim=-1)
|
|
SHAPES = [VIDEO_SHAPE, AUDIO_SHAPE]
|
|
|
|
# The same pair packed the other way round, to prove the stream is found by matching the VAE's
|
|
# channel count rather than by trusting audio to be last.
|
|
AUDIO_FIRST = torch.cat([AUDIO.reshape(1, -1), VIDEO.reshape(1, -1)], dim=-1)
|
|
AUDIO_FIRST_SHAPES = [AUDIO_SHAPE, VIDEO_SHAPE]
|
|
|
|
|
|
class _Nested:
|
|
"""What the sampler hands the callback when it has not been flattened."""
|
|
|
|
is_nested = True
|
|
|
|
def __init__(self, *streams):
|
|
self.tensors = list(streams)
|
|
|
|
|
|
class _UnbindOnly:
|
|
"""The same thing on a build that only exposes unbind()."""
|
|
|
|
is_nested = True
|
|
|
|
def __init__(self, *streams):
|
|
self._streams = list(streams)
|
|
|
|
def unbind(self):
|
|
return list(self._streams)
|
|
|
|
|
|
class _FakeAudioVAE:
|
|
"""Shaped like the real one: [B,C,2,T] -> [B,2,T*per_frame].
|
|
|
|
`per_frame` and `audio_sample_rate` are what set the latent frame rate the node has to
|
|
discover — 800 samples per frame at 32 kHz is H3's 40 fps. Records the latent it was handed,
|
|
so a check can confirm the sampler's audio_scale was divided out before the decode rather
|
|
than trusting the call to have happened.
|
|
"""
|
|
|
|
def __init__(self, channels=CHANNELS, sample_rate=32000, per_frame=800):
|
|
self.latent_channels = channels
|
|
self.audio_sample_rate = sample_rate
|
|
self.per_frame = per_frame
|
|
self.seen = None
|
|
|
|
def decode(self, z):
|
|
self.seen = z
|
|
return torch.zeros(z.shape[0], 2, z.shape[-1] * self.per_frame)
|
|
|
|
|
|
class _BrokenVAE:
|
|
latent_channels = CHANNELS
|
|
audio_sample_rate = 32000
|
|
|
|
def decode(self, z):
|
|
raise ValueError("decoder exploded")
|
|
|
|
|
|
# ---------------------------------------------------------------- stream picking
|
|
check("the channel count comes from the VAE",
|
|
node.vae_latent_channels(_FakeAudioVAE(channels=7)), 7)
|
|
check("a VAE that will not say gets no number",
|
|
node.vae_latent_channels(types.SimpleNamespace()), None)
|
|
check("nor does a nonsense one",
|
|
node.vae_latent_channels(types.SimpleNamespace(latent_channels=0)), None)
|
|
|
|
check("nested latent: the audio stream is the one with the VAE's channels",
|
|
tuple(node.audio_stream_of(_Nested(VIDEO, AUDIO), None, CHANNELS).shape),
|
|
tuple(AUDIO.shape))
|
|
check("nested latent without .tensors: unbind() is used instead",
|
|
tuple(node.audio_stream_of(_UnbindOnly(VIDEO, AUDIO), None, CHANNELS).shape),
|
|
tuple(AUDIO.shape))
|
|
check("flat pack: audio is unpacked and matched",
|
|
tuple(node.audio_stream_of(PACKED, SHAPES, CHANNELS).shape), tuple(AUDIO.shape))
|
|
check("flat pack: the audio found is the audio packed",
|
|
bool(torch.equal(node.audio_stream_of(PACKED, SHAPES, CHANNELS), AUDIO)), True)
|
|
|
|
# The point of matching by channel count rather than by index: a model that packs audio first
|
|
# is found anyway, where `[1]` or `[-1]` would hand back the video stream and decode nonsense.
|
|
check("audio packed FIRST is still found, by its channel count",
|
|
bool(torch.equal(node.audio_stream_of(AUDIO_FIRST, AUDIO_FIRST_SHAPES, CHANNELS), AUDIO)),
|
|
True)
|
|
check("audio nested first is still found",
|
|
bool(torch.equal(node.audio_stream_of(_Nested(AUDIO, VIDEO), None, CHANNELS), AUDIO)),
|
|
True)
|
|
# With nothing to match against, last-stream is the only assumption left — and the one every
|
|
# AV pack core builds satisfies.
|
|
check("with no channel count, the last stream is assumed",
|
|
bool(torch.equal(node.audio_stream_of(PACKED, SHAPES), AUDIO)), True)
|
|
|
|
check("a bare audio latent passes through",
|
|
tuple(node.audio_stream_of(AUDIO, None, CHANNELS).shape), tuple(AUDIO.shape))
|
|
check("a bare 3D audio latent passes through too",
|
|
tuple(node.audio_stream_of(torch.zeros(1, CHANNELS, 40), None, CHANNELS).shape),
|
|
(1, CHANNELS, 40))
|
|
check("a video-only 5D latent has no audio",
|
|
node.audio_stream_of(VIDEO, None, CHANNELS), None)
|
|
check("a 4D image latent is not mistaken for audio",
|
|
node.audio_stream_of(torch.zeros(1, 16, 64, 64), None, CHANNELS), None)
|
|
check("a bare latent whose channels are not the VAE's is refused",
|
|
node.audio_stream_of(torch.zeros(1, 8, 2, 40), None, CHANNELS), None)
|
|
check("no latent, no audio", node.audio_stream_of(None, None, CHANNELS), None)
|
|
check("a flat pack with no shapes cannot be unpacked",
|
|
node.audio_stream_of(PACKED, None, CHANNELS), None)
|
|
|
|
# ---------------------------------------------------------------- the audio scale
|
|
def guider(scale=None, says=None):
|
|
"""A stand-in guider. `scale` is what model_sampling carries, `says` what the model's
|
|
accessor reports — core's returns 1.0 whenever its latent_shapes is unset."""
|
|
sampling = types.SimpleNamespace(audio_scale=1.0 if scale is None else scale)
|
|
model = types.SimpleNamespace(model_sampling=sampling)
|
|
if says is not None:
|
|
model.audio_scale = lambda: says
|
|
return types.SimpleNamespace(model_patcher=types.SimpleNamespace(model=model))
|
|
|
|
|
|
check("the model's own accessor is the authority",
|
|
node.audio_scale_of(guider(4.0, says=12.0 / 3.0), 2), 4.0)
|
|
check("an accessor answering 1.0 on a two-stream pack does not end it",
|
|
node.audio_scale_of(guider(4.0, says=1.0), 2), 4.0)
|
|
check("model_sampling is read when there is no accessor",
|
|
node.audio_scale_of(guider(4.0), 2), 4.0)
|
|
check("no audio shift means no rescaling", node.audio_scale_of(guider(), 2), 1.0)
|
|
check("a single-stream latent is never rescaled", node.audio_scale_of(guider(4.0), 1), 1.0)
|
|
check("a model that knows nothing about it is left alone",
|
|
node.audio_scale_of(types.SimpleNamespace(), 2), 1.0)
|
|
check("a nonsense scale is ignored rather than dividing by zero",
|
|
node.audio_scale_of(guider(0.0, says=0.0), 2), 1.0)
|
|
|
|
vae = _FakeAudioVAE()
|
|
node.decode_audio(vae, AUDIO * 4.0, audio_scale=4.0)
|
|
check("the scale is divided out before the decode",
|
|
bool(torch.allclose(vae.seen, AUDIO)), True)
|
|
node.decode_audio(vae, AUDIO, audio_scale=1.0)
|
|
check("scale 1.0 leaves the latent untouched",
|
|
bool(torch.allclose(vae.seen, AUDIO)), True)
|
|
|
|
# ---------------------------------------------------------------- the latent frame rate
|
|
# Measured from the decode itself — frames in, samples out — so `window_seconds` means seconds
|
|
# on a model whose audio latent does not run at H3's 40 fps.
|
|
check("800 samples per frame at 32 kHz is 40 fps",
|
|
node.measured_latent_fps(120, 120 * 800, 32000), 40.0)
|
|
check("1600 samples per frame at 32 kHz is 20 fps",
|
|
node.measured_latent_fps(50, 50 * 1600, 32000), 20.0)
|
|
check("a different sample rate is accounted for",
|
|
node.measured_latent_fps(10, 10 * 960, 48000), 50.0)
|
|
check("no samples means nothing was learnt", node.measured_latent_fps(10, 0, 32000, 40.0), 40.0)
|
|
check("no frames either", node.measured_latent_fps(0, 8000, 32000, 40.0), 40.0)
|
|
check("an absurd answer is discarded rather than believed",
|
|
node.measured_latent_fps(1, 1, 32000, 40.0), 40.0)
|
|
check("the seed is only a seed", node.SEED_LATENT_FPS, 40.0)
|
|
|
|
# ---------------------------------------------------------------- window maths
|
|
check("a 0.5s window of a 1s latent is 20 frames at 40 fps",
|
|
node.audio_latent_window(AUDIO, 0.5, 40.0).shape[-1], 20)
|
|
check("the same 0.5s is 10 frames at 20 fps",
|
|
node.audio_latent_window(AUDIO, 0.5, 20.0).shape[-1], 10)
|
|
check("the window is taken from the head",
|
|
bool(torch.equal(node.audio_latent_window(AUDIO, 0.5, 40.0), AUDIO[..., :20])), True)
|
|
check("window 0 keeps the whole clip", node.audio_latent_window(AUDIO, 0, 40.0).shape[-1], 40)
|
|
check("a window longer than the clip keeps the whole clip",
|
|
node.audio_latent_window(AUDIO, 99, 40.0).shape[-1], 40)
|
|
check("a tiny window still yields one frame",
|
|
node.audio_latent_window(AUDIO, 0.001, 40.0).shape[-1], 1)
|
|
check("an unknown rate cannot cut a window, so nothing is cut",
|
|
node.audio_latent_window(AUDIO, 0.5, 0.0).shape[-1], 40)
|
|
|
|
# ---------------------------------------------------------------- clip + envelope
|
|
check("[B,C,L] loses its batch dim", tuple(node.to_clip(torch.zeros(1, 2, 800)).shape), (2, 800))
|
|
# Not upmixed: a one-channel WAV plays everywhere, and a mono model should not be made to lie
|
|
# about having two channels.
|
|
check("mono stays mono", tuple(node.to_clip(torch.zeros(1, 800)).shape), (1, 800))
|
|
check("more than two channels are cut to two",
|
|
tuple(node.to_clip(torch.zeros(1, 4, 800)).shape), (2, 800))
|
|
check("the level is left alone here, not clipped",
|
|
float(node.to_clip(torch.full((1, 2, 8), 4.0)).max()), 4.0)
|
|
|
|
# Clipping an over-range clip is what made noise sound like noise and drew the envelope as a
|
|
# solid rectangle; scaling keeps the shape so the waveform still says something.
|
|
check("an over-range clip is scaled to fit",
|
|
float(node.normalise(torch.full((2, 8), 4.0), 4.0).max()), 1.0)
|
|
check("scaling preserves the shape",
|
|
[round(float(v), 3) for v in node.normalise(torch.tensor([[2.0, -4.0]]), 4.0)[0]],
|
|
[0.5, -1.0])
|
|
check("a clip already in range is untouched",
|
|
float(node.normalise(torch.full((2, 8), 0.25), 0.25).max()), 0.25)
|
|
|
|
spiked = torch.zeros(2, 8000)
|
|
spiked[0, 100] = 1.0 # one spike, left channel, first bucket
|
|
env = node.envelope(spiked, buckets=8)
|
|
check("one row per channel", len(env), 2)
|
|
check("bucket count honoured", len(env[0]), 8)
|
|
check("the spike lands in the first bucket", env[0][0], 1.0)
|
|
check("silence reads as silence", max(env[1]), 0.0)
|
|
check("an empty clip has no envelope", node.envelope(torch.zeros(2, 0)), [])
|
|
check("fewer samples than buckets still works", len(node.envelope(torch.zeros(2, 3), 8)[0]), 3)
|
|
|
|
# ---------------------------------------------------------------- what the browser gets
|
|
# 0.5s of a 32 kHz stereo tone, loud enough that a silent output would show up as one.
|
|
tone = torch.sin(torch.arange(0, 16000, dtype=torch.float32) * 0.05).repeat(2, 1) * 0.5
|
|
raw = node.encode_wav(tone, 32000)
|
|
|
|
# One format, deterministically, so these checks can verify the actual bytes that reach the
|
|
# browser rather than trusting a codec to have produced something playable.
|
|
check("the clip is served from a registered route",
|
|
node.AUDIO_ROUTE in _ROUTES.registered, True)
|
|
check("WAV magic", raw[:4], b"RIFF")
|
|
check("the header declares WAVE", raw[8:12], b"WAVE")
|
|
with wave.open(io.BytesIO(raw)) as w:
|
|
check("WAV is stereo", w.getnchannels(), 2)
|
|
check("WAV is 16-bit", w.getsampwidth(), 2)
|
|
check("WAV keeps the sample rate", w.getframerate(), 32000)
|
|
check("WAV keeps every sample", w.getnframes(), 16000)
|
|
frames = w.readframes(w.getnframes())
|
|
check("every sample is present in the data chunk", len(frames), 16000 * 2 * 2)
|
|
check("the tone is not silence", max(frames) > 0, True)
|
|
|
|
# `--write-clip` drops the encoded clip next to this file: ground truth for "encoder or
|
|
# browser?" — if the file plays in a media player, the encoder is fine.
|
|
if "--write-clip" in sys.argv:
|
|
path = os.path.join(HERE, "audio_preview_check.wav")
|
|
with open(path, "wb") as f:
|
|
f.write(raw)
|
|
print("wrote %s — %d bytes, a 0.5s 32 kHz stereo tone.\n"
|
|
" If that plays in a media player, the encoder is fine.\n" % (path, len(raw)))
|
|
|
|
# ---------------------------------------------------------------- the gate
|
|
def wrapper(vae=None, **kw):
|
|
opts = dict(node_id="1", audio_vae=vae if vae is not None else _FakeAudioVAE(),
|
|
window_seconds=3.0, start_at_percent=50, every_n_steps=1, max_overhead=15)
|
|
opts.update(kw)
|
|
return node._AudioOuterSampleWrapper(**opts)
|
|
|
|
|
|
def state(**kw):
|
|
s = {"off": False, "warned": False, "logged": False, "sent": 0, "seq": 0,
|
|
"cost": 0.0, "finished": 0.0, "clip": 0.0, "steps": 0, "due": 0, "scale": None,
|
|
"fps": node.SEED_LATENT_FPS}
|
|
s.update(kw)
|
|
return s
|
|
|
|
|
|
on = wrapper()
|
|
check("no audio before the gate", on._due(4, 20, state()), False)
|
|
check("audio once the gate is passed", on._due(9, 20, state()), True)
|
|
check("audio on the last step", on._due(19, 20, state()), True)
|
|
check("a disabled run stays silent", on._due(19, 20, state(off=True)), False)
|
|
check("0% starts from the first step", wrapper(start_at_percent=0)._due(0, 20, state()), True)
|
|
check("100% waits for the last step",
|
|
wrapper(start_at_percent=100)._due(18, 20, state()), False)
|
|
check("every_n_steps skips the steps in between",
|
|
wrapper(every_n_steps=4)._due(11, 20, state()), False)
|
|
check("every_n_steps keeps the ones it lands on",
|
|
wrapper(every_n_steps=4)._due(12, 20, state()), True)
|
|
|
|
# ---------------------------------------------------------------- the throttle
|
|
check("nothing to wait for on the first update", on._skip(100.0, state()), False)
|
|
check("a clip still playing holds the next update",
|
|
on._skip(100.0, state(finished=99.0, clip=3.0)), True)
|
|
check("once it has played through, the next may go",
|
|
on._skip(103.5, state(finished=100.0, clip=3.0)), False)
|
|
check("an expensive decode is spaced by the overhead budget",
|
|
on._skip(105.0, state(finished=100.0, cost=1.0)), True) # 15% -> ~5.7s gap
|
|
check("overhead 0 means only the play-through wait",
|
|
wrapper(max_overhead=0)._skip(101.0, state(finished=100.0, cost=99.0)), False)
|
|
|
|
# ---------------------------------------------------------------- the payload
|
|
s = state()
|
|
payload = wrapper(window_seconds=0.5)._payload(PACKED, SHAPES, s)
|
|
check("a payload is produced", sorted(payload or {}),
|
|
["audio_mime", "envelope", "kb", "seconds", "url"])
|
|
check("the window decides the duration, not the latent", payload["seconds"], 0.5)
|
|
check("the envelope has both channels", len(payload["envelope"]), 2)
|
|
check("the url points at the route, for this node, with a sequence number",
|
|
payload["url"], node.AUDIO_ROUTE + "?node_id=1&seq=1")
|
|
check("the bytes are parked for the route to serve",
|
|
node._AUDIO_CLIPS["1"][1][:4], b"RIFF")
|
|
check("the parked clip is the whole clip",
|
|
len(node._AUDIO_CLIPS["1"][1]), 44 + 16000 * 2 * 2)
|
|
check("the payload's size matches what was parked",
|
|
payload["kb"], len(node._AUDIO_CLIPS["1"][1]) // 1024)
|
|
check("a second update gets a fresh url so nothing is answered from cache",
|
|
wrapper(window_seconds=0.5)._payload(PACKED, SHAPES, s)["url"],
|
|
node.AUDIO_ROUTE + "?node_id=1&seq=2")
|
|
check("window 0 decodes the whole second",
|
|
wrapper(window_seconds=0)._payload(PACKED, SHAPES, state())["seconds"], 1.0)
|
|
|
|
# ------------------------------------------- a VAE that is not H3's, end to end
|
|
# 1600 samples per frame at 32 kHz is a 20 fps audio latent, half H3's. The first window is cut
|
|
# at the seed rate and so comes out twice as long as asked; the decode measures the real rate,
|
|
# and every window after it is right. Nothing in the node was told the rate.
|
|
slow = _FakeAudioVAE(per_frame=1600)
|
|
slow_latent = torch.ones(1, CHANNELS, 2, 200) # 10s at 20 fps
|
|
w = wrapper(vae=slow, window_seconds=1.0)
|
|
s = state()
|
|
check("the first window is cut at the seed rate, so it runs long",
|
|
w._payload(slow_latent, None, s)["seconds"], 2.0)
|
|
check("and the decode measured the real frame rate", s["fps"], 20.0)
|
|
check("so the next window is the length actually asked for",
|
|
w._payload(slow_latent, None, s)["seconds"], 1.0)
|
|
|
|
# A 48 kHz mono decoder: rate, channel count and frame rate all differ from H3's, and all three
|
|
# come from the VAE and the decode rather than from a constant.
|
|
mono = _FakeAudioVAE(channels=8, sample_rate=48000, per_frame=960) # 50 fps
|
|
mono.decode = lambda z: torch.zeros(z.shape[0], 1, z.shape[-1] * 960)
|
|
s = state()
|
|
mono_payload = wrapper(vae=mono, window_seconds=0.5)._payload(
|
|
torch.ones(1, 8, 2, 400), None, s)
|
|
check("an 8-channel audio latent is decoded too", mono_payload is not None, True)
|
|
check("its measured frame rate is its own", s["fps"], 50.0)
|
|
check("one lane in the envelope for a mono clip", len(mono_payload["envelope"]), 1)
|
|
with wave.open(io.BytesIO(node._AUDIO_CLIPS["1"][1])) as _w:
|
|
check("and a mono WAV reaches the browser", _w.getnchannels(), 1)
|
|
check("at the VAE's own sample rate", _w.getframerate(), 48000)
|
|
|
|
# The wrong VAE is now caught by the mismatch rather than by a hard-coded channel count — and
|
|
# it says so at warning level, which a passing test should not print.
|
|
logging.disable(logging.CRITICAL)
|
|
try:
|
|
s = state()
|
|
check("a VAE whose channels match nothing in the latent decodes nothing",
|
|
wrapper(vae=_FakeAudioVAE(channels=13))._payload(PACKED, SHAPES, s), None)
|
|
check("and that is a wiring mistake, not a failure to switch off for", s["off"], False)
|
|
finally:
|
|
logging.disable(logging.NOTSET)
|
|
|
|
# ---------------------------------------------------------------- failures stay silent
|
|
# The next check makes the decoder throw on purpose, and the code under test logs that with
|
|
# exc_info — a real traceback in the output of a passing test reads as a failure, so mute it.
|
|
logging.disable(logging.CRITICAL)
|
|
try:
|
|
s = state()
|
|
check("a decode failure returns no payload rather than raising",
|
|
wrapper(vae=_BrokenVAE())._payload(PACKED, SHAPES, s), None)
|
|
check("and it turns audio off for the rest of the run", s["off"], True)
|
|
|
|
s = state()
|
|
check("a video-only latent yields no payload", on._payload(VIDEO, None, s), None)
|
|
check("and that is not treated as a failure", s["off"], False)
|
|
finally:
|
|
logging.disable(logging.NOTSET)
|
|
|
|
# ---------------------------------------------------------------- report
|
|
failed = [r for r in _results if not r[0]]
|
|
for ok, name, got, want in _results:
|
|
if not ok:
|
|
print("FAIL %s\n got: %r\n want: %r" % (name, got, want))
|
|
print("\n%d checks, %d passed, %d failed" %
|
|
(len(_results), len(_results) - len(failed), len(failed)))
|
|
sys.exit(1 if failed else 0)
|