Every entry point that conditions on a reference clip now takes the waveform in memory: the vendored loaders gained load_audio_at (path, ndarray or tensor -> float32 mono at the target rate, soundfile + torchaudio, no librosa), and the resamples ride the new dsp module. The node hands over preprocess_comfy_audio output at the right rate per site (S3Gen 24k for conditioning, the tokenizer's 16k for the VC source), so temp_wav_path and its temp-file machinery are gone -- which also removes the 16-bit PCM quantization the file round-trip imposed. Resampling is the one deliberate numerical difference: torchaudio's sinc kernel instead of soxr, touching only clips supplied at call time. The built-in-voice shared-assets E2E stays byte-identical (md5 370acf3f9469d55759c3d6096b2628ff); the reference-clip E2E changes measurably in the last digits, as expected and reported.
436 lines
17 KiB
Python
436 lines
17 KiB
Python
"""S8.6 -- iterative and chunked voice conversion on ``ChatterboxVC``.
|
|
|
|
Three new widgets, each with a failure mode that does not raise:
|
|
|
|
* ``refinement_passes`` feeds each iteration's output back in. If the passes are
|
|
not deterministic, the cache is a correctness bug rather than a speed-up --
|
|
so determinism is asserted by running the same job twice and comparing
|
|
samples, not by counting calls.
|
|
* The cache is what makes the widget usable at all: six passes after five are
|
|
already computed must cost one conversion, and three passes again must cost
|
|
nothing. Both are asserted by counting model calls, which is the only way to
|
|
tell a cache hit from a fast stub.
|
|
* ``max_chunk_duration=0`` disables chunking. A default of 30 s silently applied
|
|
to a 4-second clip would be indistinguishable from working, so the assertion
|
|
is that a clip under the ceiling comes back as **one** piece.
|
|
|
|
``smart`` cutting lands in silence, ``fixed`` does not, and that difference is
|
|
the reason both modes exist -- so it is asserted on the audio rather than on the
|
|
cut positions.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
import torch
|
|
|
|
from ComfyUI_Chatterbox.modules.audio_utils import S3GEN_SAMPLE_RATE
|
|
from ComfyUI_Chatterbox.modules.generation import (
|
|
REFINEMENT_CACHE_DEPTH,
|
|
RefinementCache,
|
|
combine_audio_chunks,
|
|
iterative_convert_voice,
|
|
split_audio_for_conversion,
|
|
)
|
|
from ComfyUI_Chatterbox.nodes.vc_node import (
|
|
CHUNK_METHOD_SMART,
|
|
ChatterboxVCNode,
|
|
)
|
|
|
|
|
|
class _CountingVC:
|
|
"""A conversion stand-in that records what it was handed.
|
|
|
|
Deterministic *and* order-dependent: each call returns a distinct constant,
|
|
so the final result tells you how many passes ran, and the waveform the
|
|
previous pass handed to ``generate`` tells you what it produced. That is
|
|
what makes "each pass is fed the previous output" an assertion rather than
|
|
a claim.
|
|
|
|
``convert_voice_audio`` hands the vendored models an in-memory waveform
|
|
(P-043): a mono float32 array at the tokenizer's 16 kHz, so the input is
|
|
inspected in-process rather than read back from a temporary file.
|
|
"""
|
|
|
|
def __init__(self, base: float = 0.5) -> None:
|
|
self.base = base
|
|
self.calls: list[dict] = []
|
|
|
|
def generate(self, source, target_voice_path=None, **kwargs):
|
|
import numpy as np
|
|
|
|
samples = np.asarray(source, dtype=np.float32).reshape(-1)
|
|
self.calls.append(
|
|
{
|
|
"input_samples": int(samples.size),
|
|
"input_mean": float(np.mean(samples)) if samples.size else 0.0,
|
|
**kwargs,
|
|
}
|
|
)
|
|
value = self.base * len(self.calls)
|
|
return torch.full((1, 240), value, dtype=torch.float32)
|
|
|
|
|
|
@pytest.fixture
|
|
def source_audio() -> dict:
|
|
return {
|
|
"waveform": torch.ones((1, 1, S3GEN_SAMPLE_RATE), dtype=torch.float32) * 0.25,
|
|
"sample_rate": S3GEN_SAMPLE_RATE,
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Schema
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestSchema:
|
|
def test_the_three_widgets_are_appended_after_the_frozen_block(self) -> None:
|
|
from .conftest_helpers import load_frozen_snapshot, schema_signatures
|
|
|
|
snapshot = load_frozen_snapshot()
|
|
frozen = snapshot["nodes"]["ChatterboxVC"]["required"]
|
|
actual = schema_signatures(ChatterboxVCNode, "required")
|
|
|
|
assert tuple(entry[0] for entry in actual[: len(frozen)]) == tuple(
|
|
entry["name"] for entry in frozen
|
|
), "the frozen block moved"
|
|
assert tuple(entry[0] for entry in actual[len(frozen) :]) == (
|
|
"device",
|
|
"dtype",
|
|
"attention_mode",
|
|
"refinement_passes",
|
|
"max_chunk_duration",
|
|
"chunk_method",
|
|
)
|
|
|
|
def test_the_documented_ranges_and_defaults(self) -> None:
|
|
required = ChatterboxVCNode.GET_NODE_INFO_V1()["input"]["required"]
|
|
|
|
refinement = required["refinement_passes"]
|
|
assert refinement[0] == "INT"
|
|
assert (refinement[1]["min"], refinement[1]["max"], refinement[1]["default"]) == (
|
|
1,
|
|
30,
|
|
1,
|
|
)
|
|
assert "5" in refinement[1]["tooltip"], "the tooltip states the recommended max"
|
|
|
|
chunk = required["max_chunk_duration"]
|
|
assert chunk[0] == "FLOAT"
|
|
assert (chunk[1]["min"], chunk[1]["max"], chunk[1]["default"]) == (0.0, 300.0, 30.0)
|
|
|
|
method = required["chunk_method"]
|
|
assert method[0] == "COMBO"
|
|
assert method[1]["options"] == ["smart", "fixed"]
|
|
assert method[1]["default"] == CHUNK_METHOD_SMART == "smart"
|
|
|
|
def test_the_widget_defaults_match_the_execute_signature(self) -> None:
|
|
import inspect
|
|
|
|
defaults = {
|
|
name: parameter.default
|
|
for name, parameter in inspect.signature(
|
|
ChatterboxVCNode.execute
|
|
).parameters.items()
|
|
}
|
|
assert defaults["refinement_passes"] == 1
|
|
assert defaults["max_chunk_duration"] == 0.0
|
|
assert defaults["chunk_method"] == "smart"
|
|
|
|
def test_the_cache_is_capped_at_five_iterations(self) -> None:
|
|
assert REFINEMENT_CACHE_DEPTH == 5
|
|
cache = RefinementCache()
|
|
assert cache.depth == 5
|
|
cache.put("job", [{"audio": i} for i in range(9)])
|
|
assert len(cache.get("job")) == 5
|
|
assert [entry["audio"] for entry in cache.get("job")] == [4, 5, 6, 7, 8]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Refinement
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestRefinementPasses:
|
|
def test_one_pass_is_one_conversion(self, source_audio) -> None:
|
|
model = _CountingVC()
|
|
iterative_convert_voice(model, source_audio)
|
|
assert len(model.calls) == 1
|
|
|
|
@pytest.mark.parametrize("passes", [1, 2, 3, 5])
|
|
def test_n_passes_are_n_conversions(self, source_audio, passes: int) -> None:
|
|
model = _CountingVC()
|
|
iterative_convert_voice(model, source_audio, refinement_passes=passes)
|
|
assert len(model.calls) == passes
|
|
|
|
def test_the_pass_number_is_clamped_up_to_one(self, source_audio) -> None:
|
|
model = _CountingVC()
|
|
iterative_convert_voice(model, source_audio, refinement_passes=0)
|
|
assert len(model.calls) == 1
|
|
|
|
def test_each_pass_is_fed_the_previous_output(self, source_audio) -> None:
|
|
"""That is what refinement *is*: a fold, not a loop over the source."""
|
|
model = _CountingVC()
|
|
iterative_convert_voice(model, source_audio, refinement_passes=3)
|
|
|
|
# The source crosses at the tokenizer's 16 kHz (P-043): a second at
|
|
# 24 kHz resamples to 16 000 samples.
|
|
assert model.calls[0]["input_samples"] == 16000
|
|
|
|
# Pass 1 sees the source clip (mean 0.25); passes 2 and 3 see the
|
|
# previous pass's output (0.5 then 1.0), not the source again.
|
|
# Tolerance 1e-3 rather than 1e-4: the sinc resampler of P-044 leaves
|
|
# a ~1e-4 ripple on a constant clip's edges, and the fold compounds
|
|
# it -- 0.2501 / 0.4999 / 0.9998 rather than exact constants. The
|
|
# property under test is the fold, and the inequality below pins it.
|
|
means = [call["input_mean"] for call in model.calls]
|
|
assert means[0] == pytest.approx(0.25, abs=1e-3), means
|
|
assert means[1] == pytest.approx(0.5, abs=1e-3), means
|
|
assert means[2] == pytest.approx(1.0, abs=1e-3), means
|
|
assert means[1:] != [means[0]] * 2, "the source was re-converted every pass"
|
|
|
|
def test_n_passes_are_deterministic(self, source_audio) -> None:
|
|
"""The property the cache rests on.
|
|
|
|
Run twice, from scratch, and compare samples. A cache that returned a
|
|
different iteration than recomputing *would have been* a defect, and
|
|
counting calls would not detect it.
|
|
"""
|
|
first_model = _CountingVC()
|
|
first, first_sr = iterative_convert_voice(
|
|
first_model, source_audio, refinement_passes=4
|
|
)
|
|
second_model = _CountingVC()
|
|
second, second_sr = iterative_convert_voice(
|
|
second_model, source_audio, refinement_passes=4
|
|
)
|
|
assert torch.equal(first, second)
|
|
assert first_sr == second_sr == S3GEN_SAMPLE_RATE
|
|
assert len(first_model.calls) == len(second_model.calls) == 4
|
|
|
|
def test_the_result_is_the_nth_pass(self, source_audio) -> None:
|
|
"""And the number of passes is visible in the result."""
|
|
model = _CountingVC()
|
|
waveform, _ = iterative_convert_voice(model, source_audio, refinement_passes=3)
|
|
assert float(waveform[0, 0]) == pytest.approx(1.5), (
|
|
"three passes of base 0.5 must end at 1.5"
|
|
)
|
|
|
|
def test_a_different_number_of_passes_gives_a_different_result(self, source_audio) -> None:
|
|
"""Otherwise "n passes" would be a knob with no effect."""
|
|
one, _ = iterative_convert_voice(_CountingVC(), source_audio)
|
|
three, _ = iterative_convert_voice(_CountingVC(), source_audio, refinement_passes=3)
|
|
assert not torch.equal(one, three)
|
|
|
|
|
|
class TestRefinementCache:
|
|
def test_a_repeat_request_returns_the_cached_iteration(self, source_audio) -> None:
|
|
cache = RefinementCache()
|
|
model = _CountingVC()
|
|
|
|
iterative_convert_voice(
|
|
model,
|
|
source_audio,
|
|
refinement_passes=3,
|
|
cache=cache,
|
|
cache_key="job",
|
|
)
|
|
assert len(model.calls) == 3
|
|
|
|
first, _ = iterative_convert_voice(
|
|
model, source_audio, refinement_passes=3, cache=cache, cache_key="job"
|
|
)
|
|
assert len(model.calls) == 3, "a cached pass still converted"
|
|
|
|
second, _ = iterative_convert_voice(
|
|
model, source_audio, refinement_passes=3, cache=cache, cache_key="job"
|
|
)
|
|
assert len(model.calls) == 3
|
|
assert torch.equal(first, second)
|
|
|
|
def test_asking_for_more_costs_only_the_difference(self, source_audio) -> None:
|
|
cache = RefinementCache()
|
|
model = _CountingVC()
|
|
iterative_convert_voice(
|
|
model, source_audio, refinement_passes=3, cache=cache, cache_key="job"
|
|
)
|
|
iterative_convert_voice(
|
|
model, source_audio, refinement_passes=5, cache=cache, cache_key="job"
|
|
)
|
|
assert len(model.calls) == 5, "two more passes should cost two conversions"
|
|
|
|
def test_different_keys_do_not_share_iterations(self, source_audio) -> None:
|
|
cache = RefinementCache()
|
|
model = _CountingVC()
|
|
iterative_convert_voice(
|
|
model, source_audio, refinement_passes=2, cache=cache, cache_key="a"
|
|
)
|
|
iterative_convert_voice(
|
|
model, source_audio, refinement_passes=2, cache=cache, cache_key="b"
|
|
)
|
|
assert len(model.calls) == 4
|
|
|
|
def test_different_settings_do_not_share_iterations(self, source_audio) -> None:
|
|
cache = RefinementCache()
|
|
model = _CountingVC()
|
|
iterative_convert_voice(
|
|
model, source_audio, n_timesteps=10, cache=cache, cache_key="job"
|
|
)
|
|
iterative_convert_voice(
|
|
model, source_audio, n_timesteps=20, cache=cache, cache_key="job"
|
|
)
|
|
assert len(model.calls) == 2
|
|
|
|
def test_the_cache_is_capped(self, source_audio) -> None:
|
|
"""Nine passes retain five iterations -- the documented cap, not memory."""
|
|
cache = RefinementCache()
|
|
model = _CountingVC()
|
|
iterative_convert_voice(
|
|
model, source_audio, refinement_passes=9, cache=cache, cache_key="job"
|
|
)
|
|
assert len(model.calls) == 9
|
|
assert len(cache) == 1
|
|
assert list(cache.iteration_counts().values()) == [5]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Chunking
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _speech_like(seconds: float, period: float = 0.02) -> torch.Tensor:
|
|
"""A clip of tone bursts separated by digital silence.
|
|
|
|
Silence is what ``smart`` is looking for, so the fixture has to actually
|
|
contain some -- and the silence has to sit *near* the ideal cut rather than
|
|
exactly on it, or the test would pass on any implementation.
|
|
"""
|
|
sample_rate = S3GEN_SAMPLE_RATE
|
|
total = int(seconds * sample_rate)
|
|
samples = torch.zeros(total, dtype=torch.float32)
|
|
burst = int(period * sample_rate)
|
|
for start in range(0, total, int(0.1 * sample_rate)):
|
|
end = min(start + burst, total)
|
|
samples[start:end] = 0.5
|
|
return samples
|
|
|
|
|
|
class TestChunking:
|
|
def test_zero_disables_chunking(self) -> None:
|
|
clip = _speech_like(60.0)
|
|
chunks = split_audio_for_conversion(clip, S3GEN_SAMPLE_RATE, 0)
|
|
assert len(chunks) == 1
|
|
assert chunks[0].numel() == clip.numel()
|
|
|
|
def test_a_clip_under_the_ceiling_is_not_split(self) -> None:
|
|
clip = _speech_like(4.0)
|
|
chunks = split_audio_for_conversion(clip, S3GEN_SAMPLE_RATE, 30)
|
|
assert len(chunks) == 1
|
|
|
|
def test_a_long_clip_is_split_into_bounded_pieces(self) -> None:
|
|
clip = _speech_like(120.0)
|
|
limit = int(30.0 * S3GEN_SAMPLE_RATE)
|
|
for method in ("smart", "fixed"):
|
|
chunks = split_audio_for_conversion(clip, S3GEN_SAMPLE_RATE, 30, method)
|
|
# ``fixed`` lands exactly four 30-second chunks. ``smart`` may cut
|
|
# *earlier* than the ideal boundary to land in a pause, which leaves a
|
|
# longer remainder, so its count is bounded rather than exact.
|
|
assert len(chunks) >= 4, method
|
|
assert sum(c.numel() for c in chunks) == clip.numel(), (
|
|
"chunking must not drop or duplicate samples"
|
|
)
|
|
|
|
assert len(split_audio_for_conversion(clip, S3GEN_SAMPLE_RATE, 30, "fixed")) == 4
|
|
assert all(
|
|
chunk.numel() <= limit * 1.3
|
|
for chunk in split_audio_for_conversion(clip, S3GEN_SAMPLE_RATE, 30, "smart")
|
|
), "a smart cut looks 25% either side of the ideal boundary, not further"
|
|
|
|
def test_smart_cuts_land_in_silence_and_fixed_does_not(self) -> None:
|
|
"""The reason both modes exist."""
|
|
clip = _speech_like(60.0)
|
|
window = int(0.01 * S3GEN_SAMPLE_RATE)
|
|
|
|
smart = split_audio_for_conversion(clip, S3GEN_SAMPLE_RATE, 30, "smart")
|
|
fixed = split_audio_for_conversion(clip, S3GEN_SAMPLE_RATE, 30, "fixed")
|
|
|
|
def peak_at(chunks, edge: int) -> float:
|
|
position = 0
|
|
for index, chunk in enumerate(chunks):
|
|
if index == edge:
|
|
return float(chunk[:window].abs().max())
|
|
position += chunk.numel()
|
|
return 1.0
|
|
|
|
smart_edges = [peak_at(smart, i) for i in range(1, len(smart))]
|
|
fixed_edges = [peak_at(fixed, i) for i in range(1, len(fixed))]
|
|
|
|
assert max(smart_edges) < 0.1, (
|
|
f"a smart cut landed on signal at amplitude {max(smart_edges)}"
|
|
)
|
|
assert max(fixed_edges) > 0.1, (
|
|
"fixed cuts were expected to land on signal; the fixture no longer "
|
|
"has enough speech for the comparison to mean anything"
|
|
)
|
|
|
|
def test_fixed_cuts_are_at_exact_intervals(self) -> None:
|
|
clip = _speech_like(120.0)
|
|
chunks = split_audio_for_conversion(clip, S3GEN_SAMPLE_RATE, 30, "fixed")
|
|
exact = int(30.0 * S3GEN_SAMPLE_RATE)
|
|
assert len(chunks) == 4
|
|
assert all(chunk.numel() == exact for chunk in chunks)
|
|
|
|
def test_a_clip_with_no_silence_still_chunks(self) -> None:
|
|
"""``smart`` must not fail on continuous speech."""
|
|
clip = torch.full((S3GEN_SAMPLE_RATE * 60,), 0.5, dtype=torch.float32)
|
|
chunks = split_audio_for_conversion(clip, S3GEN_SAMPLE_RATE, 30, "smart")
|
|
assert len(chunks) >= 2
|
|
assert sum(c.numel() for c in chunks) == clip.numel()
|
|
|
|
def test_an_empty_clip_is_refused_by_the_conversion_not_the_splitter(self, source_audio) -> None:
|
|
model = _CountingVC()
|
|
with pytest.raises(ValueError):
|
|
iterative_convert_voice(model, {"waveform": torch.zeros(0), "sample_rate": 24000})
|
|
|
|
|
|
class TestChunkJoining:
|
|
def _pieces(self):
|
|
return [
|
|
torch.full((1, 100), 0.5),
|
|
torch.full((1, 100), -0.5),
|
|
torch.full((1, 100), 0.25),
|
|
]
|
|
|
|
def test_concatenate_is_a_hard_join(self) -> None:
|
|
joined = combine_audio_chunks(self._pieces(), S3GEN_SAMPLE_RATE, "concatenate")
|
|
assert joined.shape[-1] == 300
|
|
assert float(joined[0, 100]) == -0.5
|
|
|
|
def test_silence_padding_inserts_the_configured_gap(self) -> None:
|
|
joined = combine_audio_chunks(
|
|
self._pieces(), S3GEN_SAMPLE_RATE, "silence_padding", 100
|
|
)
|
|
gap = int(S3GEN_SAMPLE_RATE * 100 / 1000)
|
|
assert joined.shape[-1] == 300 + 2 * gap
|
|
assert float(joined[0, 150]) == 0.0
|
|
|
|
def test_zero_padding_is_legal(self) -> None:
|
|
joined = combine_audio_chunks(
|
|
self._pieces(), S3GEN_SAMPLE_RATE, "silence_padding", 0
|
|
)
|
|
assert joined.shape[-1] == 300
|
|
|
|
def test_crossfade_overlaps_and_stays_finite(self) -> None:
|
|
joined = combine_audio_chunks(self._pieces(), S3GEN_SAMPLE_RATE, "crossfade", 10)
|
|
assert joined.shape[-1] < 300, "a crossfade is shorter than a concatenation"
|
|
assert torch.isfinite(joined).all()
|
|
|
|
def test_one_piece_comes_back_untouched(self) -> None:
|
|
only = torch.ones((1, 100))
|
|
assert combine_audio_chunks([only], S3GEN_SAMPLE_RATE, "crossfade") is only
|
|
|
|
def test_no_pieces_is_an_error(self) -> None:
|
|
with pytest.raises(ValueError):
|
|
combine_audio_chunks([], S3GEN_SAMPLE_RATE, "concatenate")
|