Files
VoiceStudio/tests/test_watermark_chunking_1045.py
T
velixio 090cc37144 fix(memory): release the model before emptying the cache, not after
The shared voice model's unload emptied the allocator caches and *then*
dropped the reference. That frees nothing: the weights are still reachable
when gc.collect() runs, empty_cache() only returns blocks the allocator
already considered free, and the reference drops a moment later into a cache
nothing will flush again. The unload logs success, the engine leaves the
registry, and nvidia-smi does not move.

Six modules open-coded the same two lines. Exactly one had them inverted --
OmniVoiceBackend.unload, which is the path the engine-registry idle sweep
reaches, which is the sweep a headless worker node runs. So every unload a
user could trigger from the UI worked, and the one that runs unattended on a
machine lending its GPU held 3.6 GB indefinitely. Found on hardware: the
sweep fired on schedule, logged "Released 1 idle engine(s)", and VRAM stayed
flat at 3656 MiB for the next two minutes.

Replace all six with model_manager.unload_shared_model(), which clears the
reference, drops the clone-prompt side cache, then frees -- in that order,
in one place. Two callers gain the side-cache drop they were missing
(/system/flush-memory and the shutdown path), which is the same defect one
step down: an unload that kept the encoded reference tensors belonging to the
model it had just released.

A source guard asserts nothing outside model_manager assigns the shared
reference, so the next caller cannot reintroduce the ordering. It caught the
sixth site while being written.

Also give the AudioSeal watermark models the bargain every other model in the
app already makes: they loaded on the first embed and stayed resident for the
life of the process. CPU-resident, so this is system RAM rather than VRAM,
and the machines that notice are the ones running batches.

The error text on a failing unload changes with the ordering. "Could not be
unloaded, retry after the current generation finishes" was accurate when the
cache flush ran first and aborted before the release; now the release has
already happened and only the flush can fail, so it says that instead of
sending the user to repeat work that is done.
2026-08-12 02:33:04 +05:30

171 lines
6.1 KiB
Python

"""Watermark ops must run in bounded chunks (#1045).
AudioSeal's activation memory grows linearly with input length: embedding a
single multi-minute waveform in one call demanded a >2 GB CPU buffer, which
OOM'd a reporter's 16 GB Windows machine mid-generate ("DefaultCPUAllocator:
not enough memory: you tried to allocate 2202777600 bytes"). embed_watermark
and detect_watermark now slice the audio into ≤ ~30 s chunks so peak memory
is bounded regardless of generation length. Fail-before/pass-after: on the
pre-fix code the fakes below observe one call spanning the whole waveform.
"""
from __future__ import annotations
import pytest
import torch
from services import watermark
from services.watermark import (
_CHUNK_SECONDS,
_iter_chunks,
detect_watermark,
embed_watermark,
)
SR = 24000
class FakeGenerator:
"""Stands in for the AudioSeal generator; records chunk lengths."""
def __init__(self):
self.seen_lengths: list[int] = []
def __call__(self, audio, sample_rate, message=None):
self.seen_lengths.append(audio.shape[-1])
return audio * 2.0 # position-independent transform → order is checkable
class FakeDetector:
"""Stands in for the AudioSeal detector; confidence peaks on the chunk
holding the sentinel spike so best-chunk aggregation is observable."""
def __init__(self):
self.seen_lengths: list[int] = []
def detect_watermark(self, audio, sample_rate, message_threshold=0.5):
self.seen_lengths.append(audio.shape[-1])
conf = 0.9 if float(audio.abs().max()) > 100.0 else 0.1
msg = torch.tensor(watermark.OMNI_MESSAGE) if conf > 0.5 else torch.zeros(16)
return (conf, msg)
@pytest.fixture
def fake_audioseal(monkeypatch):
gen, det = FakeGenerator(), FakeDetector()
monkeypatch.setattr(watermark, "_generator", gen)
monkeypatch.setattr(watermark, "_detector", det)
monkeypatch.setattr(watermark, "_audioseal_available", True)
return gen, det
def test_embed_long_audio_is_chunk_bounded(fake_audioseal):
gen, _ = fake_audioseal
seconds = 95 # → 30 + 30 + 30 + 5
wave = torch.arange(SR * seconds, dtype=torch.float32).unsqueeze(0)
out = embed_watermark(wave, SR, force=True)
max_chunk = _CHUNK_SECONDS * SR
assert len(gen.seen_lengths) == 4
assert all(n <= max_chunk for n in gen.seen_lengths), gen.seen_lengths
# Every sample processed, in order, shape preserved
assert out.shape == wave.shape
assert torch.equal(out, wave * 2.0)
def test_embed_short_audio_single_call(fake_audioseal):
gen, _ = fake_audioseal
wave = torch.randn(1, SR * 3)
out = embed_watermark(wave, SR, force=True)
assert gen.seen_lengths == [SR * 3]
assert torch.equal(out, wave * 2.0)
def test_embed_subsecond_tail_folds_into_previous_chunk(fake_audioseal):
gen, _ = fake_audioseal
# 30.5 s → a lone 0.5 s tail would embed poorly; folded into chunk 1
wave = torch.randn(1, int(SR * 30.5))
embed_watermark(wave, SR, force=True)
assert gen.seen_lengths == [int(SR * 30.5)]
assert max(gen.seen_lengths) <= (_CHUNK_SECONDS + 1) * SR
def test_embed_1d_shape_restored(fake_audioseal):
wave = torch.randn(SR * 65)
out = embed_watermark(wave, SR, force=True)
assert out.shape == wave.shape
def test_detect_long_audio_is_chunk_bounded_and_keeps_best_chunk(fake_audioseal):
_, det = fake_audioseal
# Spike (→ high confidence) only in the LAST chunk: a whole-file pass or
# first-chunk-only shortcut would miss it.
wave = torch.randn(1, SR * 95) * 0.01
wave[0, -SR:] = 500.0
result = detect_watermark(wave, SR)
max_chunk = _CHUNK_SECONDS * SR
assert len(det.seen_lengths) == 4
assert all(n <= max_chunk for n in det.seen_lengths), det.seen_lengths
assert result["is_watermarked"] is True
assert result["confidence"] == 0.9
assert result["is_omnivoice"] is True
def test_iter_chunks_covers_everything_exactly_once():
audio = torch.arange(SR * 95, dtype=torch.float32).reshape(1, 1, -1)
rejoined = torch.cat(list(_iter_chunks(audio, SR)), dim=-1)
assert torch.equal(rejoined, audio)
def test_iter_chunks_empty_audio_yields_nothing():
assert list(_iter_chunks(torch.zeros(1, 1, 0), SR)) == []
# ── Idle release of the AudioSeal models (#1495) ───────────────────────────
#
# They loaded on the first embed and then stayed resident for the life of the
# process — the one model in the app still making that bargain after the TTS
# model and the capture ASR both stopped. CPU-resident, so this is system RAM,
# and the machines that notice are the ones running batches.
def test_idle_release_drops_both_models(monkeypatch):
monkeypatch.setattr(watermark, "_generator", object())
monkeypatch.setattr(watermark, "_detector", object())
monkeypatch.setattr(watermark, "_last_used", 100.0)
assert watermark.release_idle_models(60.0, now=200.0) is True
assert watermark._generator is None
assert watermark._detector is None
def test_idle_release_keeps_a_recently_used_model(monkeypatch):
generator = object()
monkeypatch.setattr(watermark, "_generator", generator)
monkeypatch.setattr(watermark, "_detector", None)
monkeypatch.setattr(watermark, "_last_used", 100.0)
assert watermark.release_idle_models(60.0, now=130.0) is False
assert watermark._generator is generator
def test_idle_release_is_a_noop_when_nothing_loaded(monkeypatch):
monkeypatch.setattr(watermark, "_generator", None)
monkeypatch.setattr(watermark, "_detector", None)
monkeypatch.setattr(watermark, "_last_used", 0.0)
assert watermark.release_idle_models(0.0) is False
def test_embedding_restarts_the_idle_clock(monkeypatch):
"""Without this the models are released mid-batch: `_last_used` would sit
at whatever the first embed set it to while the batch kept watermarking."""
monkeypatch.setattr(watermark, "_generator", object())
monkeypatch.setattr(watermark, "_last_used", 0.0)
monkeypatch.setattr(watermark.time, "monotonic", lambda: 500.0)
watermark._get_generator()
assert watermark._last_used == 500.0