Files
VoiceStudio/tests/test_clone_prompt_cache.py
T
58c6f37252 perf(dub): single-use per-segment refs no longer evict the prompts a dub reuses; add docs/performance.md (#1132)
* perf(dub): single-use per-segment refs no longer evict the prompts a dub reuses; add docs/performance.md

The scan-resistance fix:

A dub cuts a distinct reference clip per segment (Wave 3.2 / #486 — each line
clones its own source delivery) and falls back to the per-speaker clone for
segments under 3 s. Both paths flow through the voice-clone prompt cache — an
LRU of 8. Streaming hundreds of one-shot per-segment clips through that LRU
evicts the per-speaker and locked-profile prompts that every fallback segment
reuses, so the speaker ref was re-encoded (~0.4 s each, measured with
scripts/bench_pipeline.py) again and again across the render.

Note what this deliberately does NOT do: the bench's "166 misses vs 2 speakers"
framing suggested keying refs per speaker — but per-segment refs are the
intentional prosody-matching feature, and the re-transcription behind them is
the #1004 correctness fix. Their encode cost is the price of the feature, not
waste. The waste was only the eviction side-effect, and that's what this
removes: _get_clone_prompt(store=False) still reads the cache (a hit is free)
but never inserts, and the dub loop marks exactly the segment-scoped refs
(auto-seg: bindings and auto: bindings resolved to a segment clip) as
single-use. Per-speaker, locked-profile, and preview refs cache as before.

cache_ref is popped in generate_with_cached_ref before the model call — the
model's generate() has an explicit signature and would TypeError — and unknown
engines ignore it (**kw adapters).

The doc:

docs/performance.md is the first performance documentation in the repo — none
of the ~15 perf env vars appeared anywhere in docs/, the Performance panel's
only control is Windows-only, and slowness reports (#1032) arrived as mysteries
instead of settings checks. Covers the three classic causes of "it got slow",
where generation/dub time goes, every knob with defaults and warnings (raising
OMNIVOICE_GPU_WORKERS on a small GPU is the #567 crash, not a speedup), platform
notes, and how to run the bench so reports carry numbers. Linked from README's
install section.

Tests: store=False semantics (encodes, never inserts, still reads), the flood
scenario end to end (a speaker prompt stays warm through 3x the cache cap of
one-shots), and the pop contract (cache_ref never reaches the model). Full
suite: 2974 passed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs,dub: review round — qualify the per-file cache claim; note the OOM-retry tradeoff

- CodeRabbit: docs/performance.md's "the reference encode is cached per file"
  now carves out the dub's per-line clips (single-use by design — nothing for
  a cache to save).
- Greptile P2 (OOM retry re-encodes a single-use ref): acknowledged in a code
  comment as deliberate — caching the retry's ref would reintroduce the
  eviction this flag prevents, to optimize a path that only runs after an OOM
  already cost seconds.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(performance): probe-based torch.compile wording; honest accelerator + cache claims (review)

Greptile's repeated OOM-retry finding is deliberately skipped: retaining the
prompt across the retry would require passing prompt objects through the
adapter protocol (backend.generate takes paths), to save 0.4s on a path that
only runs after an OOM already cost seconds — the tradeoff is documented at
the call site.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: mergetest <nizam4103@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 14:36:06 +05:30

155 lines
5.5 KiB
Python

"""Voice-clone prompt cache (#427) — the bounded-LRU reference-encode cache.
Pure cache logic: the model is a stub whose create_voice_clone_prompt counts
calls, so we assert the reference is encoded ONCE per (path, mtime, ref_text)
and that misses/errors fall back cleanly. (No real model / torch math here.)
"""
from __future__ import annotations
import pytest
from services import tts_backend as tb
class _StubModel:
def __init__(self, *, fail=False):
self.calls = 0
self.fail = fail
def create_voice_clone_prompt(self, ref_audio, ref_text=None, preprocess_prompt=True):
self.calls += 1
if self.fail:
raise RuntimeError("encode boom")
return f"PROMPT::{ref_audio}::{ref_text}::{preprocess_prompt}"
@pytest.fixture(autouse=True)
def _clear_cache():
tb.clear_clone_prompt_cache()
yield
tb.clear_clone_prompt_cache()
def _wav(tmp_path, name="ref.wav", data=b"\x00" * 100):
p = tmp_path / name
p.write_bytes(data)
return str(p)
def test_encodes_once_then_hits_cache(tmp_path):
m = _StubModel()
ref = _wav(tmp_path)
first = tb._get_clone_prompt(m, ref, "hello")
second = tb._get_clone_prompt(m, ref, "hello")
assert first == second
assert m.calls == 1 # second call was a cache hit — no re-encode
def test_different_ref_text_re_encodes(tmp_path):
m = _StubModel()
ref = _wav(tmp_path)
tb._get_clone_prompt(m, ref, "hello")
tb._get_clone_prompt(m, ref, "different")
assert m.calls == 2
def test_mtime_change_invalidates(tmp_path):
m = _StubModel()
ref = _wav(tmp_path)
tb._get_clone_prompt(m, ref, "hi")
# Rewrite with a different mtime → key changes → re-encode.
import os
os.utime(ref, (1, 1))
tb._get_clone_prompt(m, ref, "hi")
assert m.calls == 2
def test_lru_eviction_bounds_cache(tmp_path):
m = _StubModel()
# Fill past the cap with distinct refs.
for i in range(tb._PROMPT_CACHE_MAX + 3):
tb._get_clone_prompt(m, _wav(tmp_path, f"r{i}.wav"), "t")
assert len(tb._prompt_cache) == tb._PROMPT_CACHE_MAX
assert m.calls == tb._PROMPT_CACHE_MAX + 3
def test_encode_failure_returns_none_and_does_not_cache(tmp_path):
m = _StubModel(fail=True)
ref = _wav(tmp_path)
assert tb._get_clone_prompt(m, ref, "x") is None # caller falls back to inline ref
assert len(tb._prompt_cache) == 0
def test_preprocess_prompt_is_part_of_the_key(tmp_path):
"""preprocess_prompt changes the encoded prompt (silence trim + ref-text
punctuation), so it must key the cache. It didn't — and /v1/audio/speech
exposes the flag, so a preprocess_prompt=False request could be served a
True-encoded prompt (and poison the entry for everyone else)."""
m = _StubModel()
ref = _wav(tmp_path)
a = tb._get_clone_prompt(m, ref, "hi", True)
b = tb._get_clone_prompt(m, ref, "hi", False)
assert m.calls == 2, "preprocess_prompt=False was served the True-encoded prompt"
assert a != b
# And each variant is independently cached.
tb._get_clone_prompt(m, ref, "hi", True)
tb._get_clone_prompt(m, ref, "hi", False)
assert m.calls == 2
def test_preprocess_prompt_reaches_the_encoder(tmp_path):
"""It was accepted by the API and dropped on the floor before reaching here."""
m = _StubModel()
prompt = tb._get_clone_prompt(m, _wav(tmp_path), "hi", False)
assert prompt.endswith("::False")
def test_clear_empties_cache(tmp_path):
m = _StubModel()
tb._get_clone_prompt(m, _wav(tmp_path), "x")
assert len(tb._prompt_cache) == 1
tb.clear_clone_prompt_cache()
assert len(tb._prompt_cache) == 0
# ── Single-use references (store=False): dub per-segment clips ───────────────
#
# A dub cuts a distinct reference clip per segment (Wave 3.2 prosody matching),
# each used exactly once. Inserting a stream of hundreds of those into an LRU
# of 8 evicts the per-speaker / locked-profile prompts every OTHER segment
# reuses — so each short segment falling back to its speaker ref re-encoded it
# (~0.4 s each, measured on an M2). store=False is the scan-resistance: encode,
# use, don't displace anything.
def test_store_false_encodes_but_never_inserts(tmp_path):
m = _StubModel()
ref = _wav(tmp_path)
p = tb._get_clone_prompt(m, ref, "one-shot", store=False)
assert p is not None and m.calls == 1
assert len(tb._prompt_cache) == 0, "single-use prompt was inserted into the LRU"
def test_store_false_still_reads_the_cache(tmp_path):
"""A hit is free — store=False only skips the insert, not the lookup."""
m = _StubModel()
ref = _wav(tmp_path)
tb._get_clone_prompt(m, ref, "hi") # cached normally
tb._get_clone_prompt(m, ref, "hi", store=False) # must hit, not re-encode
assert m.calls == 1
def test_single_use_flood_does_not_evict_reused_prompts(tmp_path):
"""The dub scenario end to end: a per-speaker ref stays warm through a
flood of per-segment one-shots far larger than the cache cap."""
m = _StubModel()
speaker_ref = _wav(tmp_path, "speaker.wav")
tb._get_clone_prompt(m, speaker_ref, "speaker") # encode #1, cached
for i in range(tb._PROMPT_CACHE_MAX * 3): # the flood
tb._get_clone_prompt(m, _wav(tmp_path, f"seg{i}.wav"), "seg", store=False)
before = m.calls
tb._get_clone_prompt(m, speaker_ref, "speaker") # short-segment fallback
assert m.calls == before, (
"the speaker prompt was evicted by single-use segment refs and re-encoded"
)