Files
wildminder-ComfyUI-VibeVoice/tests/test_realtime_node.py
T
WildAi 86be533986 fix: detect linked external_model by presence during validation (v2.2.2)
During prompt validation ComfyUI resolves linked inputs to None (no execution cache yet), so the previous 'external_model is not None' bypass never triggered and the node rejected the stale model_name widget. All three nodes now key the bypass on input presence in kwargs. Adds regression tests simulating the linked-input scenario.
2026-08-17 02:22:44 +03:00

368 lines
14 KiB
Python

"""Tests for nodes/realtime_node.py - VibeVoice Realtime (streaming TTS) node.
NTH-001: the streaming ``VibeVoice-Realtime-0.5B`` model is configured but was
never reachable through a node. This suite locks the schema (streaming-only
model combo), input validation (rejects non-streaming types), the extension
registration, and that ``execute`` routes through the shared patcher /
``generate_streaming_audio`` path.
"""
import pytest
from unittest.mock import patch, MagicMock
from ComfyUI_VibeVoice.nodes.realtime_node import VibeVoiceRealtimeNode
def _model_options(schema):
for inp in schema.inputs:
if inp.id == "model_name":
return list(inp.options)
raise AssertionError("model_name input not found in schema")
class TestRealtimeNodeSchema:
"""VibeVoiceRealtimeNode schema exposes streaming models only."""
def test_schema_node_id(self):
schema = VibeVoiceRealtimeNode.define_schema()
assert schema.node_id == "VibeVoiceRealtime"
def test_schema_display_name(self):
schema = VibeVoiceRealtimeNode.define_schema()
assert schema.display_name == "VibeVoice Realtime TTS"
def test_schema_category(self):
schema = VibeVoiceRealtimeNode.define_schema()
assert schema.category == "audio/tts"
def test_schema_uses_streaming_models_only(self):
with patch(
"ComfyUI_VibeVoice.modules.model_info.AVAILABLE_VIBEVOICE_MODELS",
{"VibeVoice-Realtime-0.5B": {"type": "official"}},
):
options = _model_options(VibeVoiceRealtimeNode.define_schema())
assert options == ["VibeVoice-Realtime-0.5B"]
def test_schema_excludes_asr_and_tts_models(self):
with patch(
"ComfyUI_VibeVoice.modules.model_info.AVAILABLE_VIBEVOICE_MODELS",
{
"VibeVoice-Realtime-0.5B": {"type": "official"},
"VibeVoice-ASR": {"type": "official"},
"VibeVoice-1.5B": {"type": "official"},
},
):
options = _model_options(VibeVoiceRealtimeNode.define_schema())
assert "VibeVoice-Realtime-0.5B" in options
assert "VibeVoice-ASR" not in options
assert "VibeVoice-1.5B" not in options
def test_schema_has_stream_toggle(self):
schema = VibeVoiceRealtimeNode.define_schema()
ids = [inp.id for inp in schema.inputs]
assert "stream" in ids
assert "force_offload" in ids
assert "speaker_1_voice" in ids
def test_schema_has_audio_output(self):
schema = VibeVoiceRealtimeNode.define_schema()
assert len(schema.outputs) == 1
class TestRealtimeNodeValidate:
"""VibeVoiceRealtimeNode.validate_inputs accepts only streaming_tts models."""
def test_validate_accepts_streaming_model(self):
with patch(
"ComfyUI_VibeVoice.nodes.realtime_node.AVAILABLE_VIBEVOICE_MODELS",
{"VibeVoice-Realtime-0.5B": {"type": "official"}},
):
result = VibeVoiceRealtimeNode.validate_inputs(model_name="VibeVoice-Realtime-0.5B")
assert result is True
def test_validate_rejects_asr_model(self):
with patch(
"ComfyUI_VibeVoice.nodes.realtime_node.AVAILABLE_VIBEVOICE_MODELS",
{"VibeVoice-Realtime-0.5B": {"type": "official"}, "VibeVoice-ASR": {"type": "official"}},
):
result = VibeVoiceRealtimeNode.validate_inputs(model_name="VibeVoice-ASR")
assert isinstance(result, str)
assert "streaming" in result.lower() or "streaming TTS" in result
def test_validate_rejects_tts_model(self):
with patch(
"ComfyUI_VibeVoice.nodes.realtime_node.AVAILABLE_VIBEVOICE_MODELS",
{"VibeVoice-Realtime-0.5B": {"type": "official"}, "VibeVoice-1.5B": {"type": "official"}},
):
result = VibeVoiceRealtimeNode.validate_inputs(model_name="VibeVoice-1.5B")
assert isinstance(result, str)
assert "streaming" in result.lower()
def test_validate_none_model(self):
assert VibeVoiceRealtimeNode.validate_inputs(model_name=None) is True
class TestRealtimeNodeExecute:
"""NTH-001: execute must route through the patcher + streaming generate path."""
def test_execute_calls_streaming_generate(self):
stub_patcher = MagicMock()
stub_patcher.is_loaded = True
model = MagicMock()
processor = MagicMock()
with patch(
"ComfyUI_VibeVoice.nodes.realtime_node.load_vibevoice_model",
return_value=(stub_patcher, model, processor),
) as mock_load, patch(
"ComfyUI_VibeVoice.nodes.realtime_node.generate_streaming_audio",
return_value=(MagicMock(), 24000),
) as mock_generate, patch(
"ComfyUI_VibeVoice.nodes.realtime_node.force_offload_model"
) as mock_offload, patch(
"ComfyUI_VibeVoice.nodes.realtime_node.ui"
) as mock_ui:
result = VibeVoiceRealtimeNode.execute(
model_name="VibeVoice-Realtime-0.5B",
text="[1] Hello world",
quantize_llm_4bit=False,
attention_mode="sdpa",
cfg_scale=1.3,
inference_steps=10,
seed=42,
do_sample=True,
temperature=0.95,
top_p=0.95,
top_k=0,
stream=False,
force_offload=False,
device="cpu",
dtype="auto",
)
mock_load.assert_called_once()
# The model/processor from the patcher loader must reach the streaming generator.
mock_generate.assert_called_once()
assert mock_generate.call_args.kwargs["model"] is model
assert mock_generate.call_args.kwargs["processor"] is processor
assert mock_generate.call_args.kwargs["text"] == "[1] Hello world"
# Two audio outputs: waveform + (optional) stream — single Audio.Output here.
assert result is not None
mock_offload.assert_not_called()
# ui.PreviewAudio was invoked for the preview.
mock_ui.PreviewAudio.assert_called_once()
def test_execute_offloads_when_requested(self):
stub_patcher = MagicMock()
stub_patcher.is_loaded = True
model = MagicMock()
processor = MagicMock()
with patch(
"ComfyUI_VibeVoice.nodes.realtime_node.load_vibevoice_model",
return_value=(stub_patcher, model, processor),
), patch(
"ComfyUI_VibeVoice.nodes.realtime_node.generate_streaming_audio",
return_value=(MagicMock(), 24000),
), patch(
"ComfyUI_VibeVoice.nodes.realtime_node.force_offload_model"
) as mock_offload, patch(
"ComfyUI_VibeVoice.nodes.realtime_node.ui"
):
VibeVoiceRealtimeNode.execute(
model_name="VibeVoice-Realtime-0.5B",
text="[1] Hello world",
quantize_llm_4bit=False,
attention_mode="sdpa",
cfg_scale=1.3,
inference_steps=10,
seed=42,
do_sample=True,
temperature=0.95,
top_p=0.95,
top_k=0,
stream=False,
force_offload=True,
device="cpu",
dtype="auto",
)
# force_offload=True must hand the patcher to the warm offload path.
mock_offload.assert_called_once_with(stub_patcher, "VibeVoice-Realtime-0.5B", warm=True)
class TestRealtimeNodeExternalModel:
"""Phase 3: Realtime node accepts an external VIBEVOICE_MODEL bundle."""
def _make_bundle(self, is_streaming=True):
return {
"model": MagicMock(),
"processor": MagicMock(),
"model_name": "ExtRealtime",
"source_path": "/fake/realtime.safetensors",
"is_streaming": is_streaming,
}
def test_realtime_schema_has_external_model_input(self):
schema = VibeVoiceRealtimeNode.define_schema()
ids = [inp.id for inp in schema.inputs]
assert "external_model" in ids
def test_realtime_external_model_input_is_optional(self):
schema = VibeVoiceRealtimeNode.define_schema()
ext_input = next(inp for inp in schema.inputs if inp.id == "external_model")
assert ext_input.optional is True
def test_realtime_execute_with_external_model_calls_load_from_external(self):
bundle = self._make_bundle(is_streaming=True)
stub_patcher = MagicMock()
with patch(
"ComfyUI_VibeVoice.nodes.realtime_node.load_vibevoice_from_external",
return_value=(stub_patcher, bundle["model"], bundle["processor"]),
) as mock_ext, patch(
"ComfyUI_VibeVoice.nodes.realtime_node.load_vibevoice_model"
) as mock_std, patch(
"ComfyUI_VibeVoice.nodes.realtime_node.generate_streaming_audio",
return_value=(MagicMock(), 24000),
), patch(
"ComfyUI_VibeVoice.nodes.realtime_node.ui"
):
VibeVoiceRealtimeNode.execute(
model_name="IgnoredModel",
text="[1] Hello world",
quantize_llm_4bit=False,
attention_mode="sdpa",
cfg_scale=1.3,
inference_steps=10,
seed=42,
do_sample=True,
temperature=0.95,
top_p=0.95,
top_k=0,
stream=False,
force_offload=False,
device="cpu",
dtype="auto",
external_model=bundle,
)
mock_ext.assert_called_once()
mock_std.assert_not_called()
def test_realtime_execute_without_external_model_calls_load_vibevoice_model(self):
stub_patcher = MagicMock()
with patch(
"ComfyUI_VibeVoice.nodes.realtime_node.load_vibevoice_model",
return_value=(stub_patcher, MagicMock(), MagicMock()),
) as mock_std, patch(
"ComfyUI_VibeVoice.nodes.realtime_node.load_vibevoice_from_external"
) as mock_ext, patch(
"ComfyUI_VibeVoice.nodes.realtime_node.generate_streaming_audio",
return_value=(MagicMock(), 24000),
), patch(
"ComfyUI_VibeVoice.nodes.realtime_node.ui"
):
VibeVoiceRealtimeNode.execute(
model_name="VibeVoice-Realtime-0.5B",
text="[1] Hello world",
quantize_llm_4bit=False,
attention_mode="sdpa",
cfg_scale=1.3,
inference_steps=10,
seed=42,
do_sample=True,
temperature=0.95,
top_p=0.95,
top_k=0,
stream=False,
force_offload=False,
device="cpu",
dtype="auto",
external_model=None,
)
mock_std.assert_called_once()
mock_ext.assert_not_called()
def test_realtime_execute_external_model_routes_to_streaming_generation(self):
"""External model path must call generate_streaming_audio (not generate_audio)."""
bundle = self._make_bundle(is_streaming=True)
stub_patcher = MagicMock()
with patch(
"ComfyUI_VibeVoice.nodes.realtime_node.load_vibevoice_from_external",
return_value=(stub_patcher, bundle["model"], bundle["processor"]),
), patch(
"ComfyUI_VibeVoice.nodes.realtime_node.generate_streaming_audio",
return_value=(MagicMock(), 24000),
) as mock_stream_gen, patch(
"ComfyUI_VibeVoice.nodes.realtime_node.ui"
):
VibeVoiceRealtimeNode.execute(
model_name="IgnoredModel",
text="[1] Hello world",
quantize_llm_4bit=False,
attention_mode="sdpa",
cfg_scale=1.3,
inference_steps=10,
seed=42,
do_sample=True,
temperature=0.95,
top_p=0.95,
top_k=0,
stream=False,
force_offload=False,
device="cpu",
dtype="auto",
external_model=bundle,
)
mock_stream_gen.assert_called_once()
assert mock_stream_gen.call_args.kwargs["model"] is bundle["model"]
def test_realtime_execute_rejects_non_streaming_external_model(self):
"""A non-streaming external model on the Realtime node raises ValueError."""
bundle = self._make_bundle(is_streaming=False)
with pytest.raises(ValueError, match="streaming"):
VibeVoiceRealtimeNode.execute(
model_name="VibeVoice-Realtime-0.5B",
text="[1] Hello world",
quantize_llm_4bit=False,
attention_mode="sdpa",
cfg_scale=1.3,
inference_steps=10,
seed=42,
do_sample=True,
temperature=0.95,
top_p=0.95,
top_k=0,
stream=False,
force_offload=False,
device="cpu",
dtype="auto",
external_model=bundle,
)
def test_realtime_validate_skips_model_name_when_external_model_provided(self):
bundle = self._make_bundle(is_streaming=True)
result = VibeVoiceRealtimeNode.validate_inputs(
external_model=bundle, model_name="nonexistent_model"
)
assert result is True
def test_realtime_validate_bypasses_when_external_model_linked_but_none(self):
"""REGRESSION: a *connected* external_model resolves to None during prompt
validation (ComfyUI has no execution cache yet). The bypass must trigger on
the input's *presence* in kwargs, not on a non-None value."""
with patch(
"ComfyUI_VibeVoice.nodes.realtime_node.is_model_type",
return_value=False, # model_name would be flagged as non-streaming
):
result = VibeVoiceRealtimeNode.validate_inputs(
external_model=None, model_name="VibeVoice-1.5B"
)
assert result is True