Spaces:
Running on Zero
Fix ZeroGPU continuation: bound the lead-in, normalize the tail, shrink the GPU window
Browse filesThree deployed bugs, all reproduced on the Blackwell GPU and fixed:
1. Near-silence / "only 5s of new audio". A long source (e.g. 100s) filled
SA3's buffer and left only a tiny masked window, which the 8-step distilled
sampler rendered as near-silence (rms 0.001). engine now conditions on at
most MAX_LEAD_SECONDS (30s) of the clip's TAIL and masks [lead, lead+new],
keeping the generated region substantial. The splice still rejoins the tail
onto the full pristine original, so the listener hears the whole clip.
Verified: 100s->120 now yields rms 0.15 (was 0.001).
2. Length/mask. With the whole source in the buffer, "total < source" floored
to source+5s. With a bounded lead, new = total - source actually extends to
the requested finished length (100s->120 generates +20s).
3. Stream abort (BodyStreamBuffer was aborted). enhance/fingerprint/decode ran
INSIDE @spaces.GPU, burning the ZeroGPU window before generation started.
Only the diffusion call is now wrapped by @spaces.GPU; all CPU prep and the
splice run outside it, so the GPU task starts and finishes fast (sub-second).
Also normalize the generated tail by its own peak (not the whole buffer) so a
loud lead transient can't divide the continuation down toward silence. Engine
unit tests updated to the bounded-lead contract.
- app.py +28 -25
- engine.py +74 -42
- test_engine_logic.py +60 -40
|
@@ -128,50 +128,53 @@ def analyze_on_upload(audio_path):
|
|
| 128 |
gr.update(interactive=False))
|
| 129 |
|
| 130 |
|
| 131 |
-
@spaces.GPU(duration=
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 132 |
def finish_song(audio_path, total_seconds, vibe, remaster,
|
| 133 |
progress=gr.Progress()):
|
| 134 |
-
"""
|
| 135 |
Returns (output_wav_path, summary_markdown)."""
|
| 136 |
if not audio_path:
|
| 137 |
raise gr.Error("Upload a clip (or load the PUSHBACK demo) first.")
|
| 138 |
|
| 139 |
total_seconds = int(total_seconds)
|
| 140 |
-
progress(0.05, desc="Listening to your clip…")
|
| 141 |
|
| 142 |
-
#
|
|
|
|
| 143 |
listen_path = enhance_to_tempfile(audio_path)
|
| 144 |
info = fingerprint(listen_path)
|
| 145 |
-
|
| 146 |
# the pristine original — what the listener hears for the first stretch
|
| 147 |
original, sr = librosa.load(audio_path, sr=None, mono=False)
|
| 148 |
if remaster:
|
| 149 |
-
progress(0.
|
| 150 |
original = enhance_audio(original, sr)
|
| 151 |
|
| 152 |
-
#
|
| 153 |
-
|
| 154 |
-
marks = {
|
| 155 |
-
"reading": (0.22, "Reading key, tempo & groove…"),
|
| 156 |
-
"composing": (0.45, "Composing the continuation…"),
|
| 157 |
-
"finalizing": (0.82, "Rendering 44.1 kHz stereo…"),
|
| 158 |
-
}
|
| 159 |
-
if stage in marks:
|
| 160 |
-
frac, desc = marks[stage]
|
| 161 |
-
progress(frac, desc=desc)
|
| 162 |
-
|
| 163 |
try:
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
# run. seed=7 is the draw that produced the verified lab_out/sa3 takes,
|
| 167 |
-
# so the app reproduces that result instead of re-rolling each time.
|
| 168 |
-
new_tail, source_seconds, SR = engine.continue_audio(
|
| 169 |
-
listen_path, total_seconds=total_seconds,
|
| 170 |
-
prompt=(vibe or "").strip(), seed=7, progress=_on_stage)
|
| 171 |
except ValueError as e:
|
| 172 |
-
# e.g. the clip is
|
| 173 |
raise gr.Error(str(e))
|
| 174 |
|
|
|
|
| 175 |
progress(0.9, desc="Splicing onto your original…")
|
| 176 |
out = stitch.stitch(original, sr, new_tail, source_seconds)
|
| 177 |
|
|
|
|
| 128 |
gr.update(interactive=False))
|
| 129 |
|
| 130 |
|
| 131 |
+
@spaces.GPU(duration=120)
|
| 132 |
+
def _continue_on_gpu(listen_path, total_seconds, vibe):
|
| 133 |
+
"""ONLY the SA3 diffusion call runs inside the GPU window. All CPU work —
|
| 134 |
+
enhancement, key/tempo analysis, decode, splice — happens OUTSIDE it (in
|
| 135 |
+
`finish_song`), so the scarce ZeroGPU allocation is spent generating instead
|
| 136 |
+
of decoding/analyzing audio. That's the stream-abort fix: the GPU task now
|
| 137 |
+
starts and finishes fast instead of sitting through a slow analysis preamble
|
| 138 |
+
until the browser aborts the stream.
|
| 139 |
+
|
| 140 |
+
Pin the seed to the lab's known-good draw. SA3 is generative: a random seed
|
| 141 |
+
gives a different (often weaker) continuation every run. seed=7 produced the
|
| 142 |
+
verified lab_out/sa3 takes, so the app reproduces that instead of re-rolling.
|
| 143 |
+
"""
|
| 144 |
+
return engine.continue_audio(
|
| 145 |
+
listen_path, total_seconds=int(total_seconds),
|
| 146 |
+
prompt=(vibe or "").strip(), seed=7)
|
| 147 |
+
|
| 148 |
+
|
| 149 |
def finish_song(audio_path, total_seconds, vibe, remaster,
|
| 150 |
progress=gr.Progress()):
|
| 151 |
+
"""Orchestrate the job: CPU prep -> GPU continuation -> CPU splice.
|
| 152 |
Returns (output_wav_path, summary_markdown)."""
|
| 153 |
if not audio_path:
|
| 154 |
raise gr.Error("Upload a clip (or load the PUSHBACK demo) first.")
|
| 155 |
|
| 156 |
total_seconds = int(total_seconds)
|
|
|
|
| 157 |
|
| 158 |
+
# --- CPU prep (outside the GPU window) ---
|
| 159 |
+
progress(0.05, desc="Listening to your clip…")
|
| 160 |
listen_path = enhance_to_tempfile(audio_path)
|
| 161 |
info = fingerprint(listen_path)
|
|
|
|
| 162 |
# the pristine original — what the listener hears for the first stretch
|
| 163 |
original, sr = librosa.load(audio_path, sr=None, mono=False)
|
| 164 |
if remaster:
|
| 165 |
+
progress(0.15, desc="Remastering your part…")
|
| 166 |
original = enhance_audio(original, sr)
|
| 167 |
|
| 168 |
+
# --- GPU continuation (the ONLY @spaces.GPU call) ---
|
| 169 |
+
progress(0.35, desc="Composing the continuation…")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 170 |
try:
|
| 171 |
+
new_tail, source_seconds, SR = _continue_on_gpu(
|
| 172 |
+
listen_path, total_seconds, vibe)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 173 |
except ValueError as e:
|
| 174 |
+
# e.g. the clip is a full-length track, not a clip to continue
|
| 175 |
raise gr.Error(str(e))
|
| 176 |
|
| 177 |
+
# --- CPU splice + write (outside the GPU window) ---
|
| 178 |
progress(0.9, desc="Splicing onto your original…")
|
| 179 |
out = stitch.stitch(original, sr, new_tail, source_seconds)
|
| 180 |
|
|
@@ -9,14 +9,24 @@ audio — true long-form continuation, 44.1 kHz stereo, no multi-pass chaining,
|
|
| 9 |
no energy guards, no re-roll logic.
|
| 10 |
|
| 11 |
This module is the whole generation core. It returns ONLY the newly generated
|
| 12 |
-
tail
|
| 13 |
-
|
| 14 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
|
| 16 |
Mask convention (verified against the installed library source):
|
| 17 |
inpaint_mask = ones(buffer); inpaint_mask[start:end] = 0
|
| 18 |
-
-> 1 = keep the input audio, 0 = generate.
|
| 19 |
-
the
|
|
|
|
| 20 |
"""
|
| 21 |
import numpy as np
|
| 22 |
import torch
|
|
@@ -30,6 +40,13 @@ DEFAULT_CFG = 1.0 # distilled-model guidance; the prompt still conditions
|
|
| 30 |
# at 1.0 (CFG amplification off, conditional path on)
|
| 31 |
MAX_TOTAL_SECONDS = 120 # SA3 Small duration cap (sample_size / sample_rate)
|
| 32 |
MIN_NEW_SECONDS = 5 # below this a "continuation" isn't worth a GPU call
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
|
| 34 |
_model = None
|
| 35 |
_model_config = None
|
|
@@ -95,31 +112,36 @@ MAX_SOURCE_SECONDS = MAX_TOTAL_SECONDS - MIN_NEW_SECONDS
|
|
| 95 |
|
| 96 |
|
| 97 |
def plan_continuation(source_seconds, total_seconds):
|
| 98 |
-
"""Pure helper (unit-testable, no model):
|
| 99 |
-
and return (
|
| 100 |
-
|
| 101 |
-
-
|
| 102 |
-
|
| 103 |
-
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 108 |
"""
|
| 109 |
source_seconds = float(source_seconds)
|
| 110 |
total_seconds = float(total_seconds)
|
| 111 |
if source_seconds > MAX_SOURCE_SECONDS:
|
| 112 |
raise ValueError(
|
| 113 |
-
f"clip is {source_seconds:.0f}s —
|
| 114 |
-
f"
|
| 115 |
-
f"{
|
| 116 |
-
f"{MAX_SOURCE_SECONDS:.0f}s.")
|
| 117 |
-
# source <= MAX_SOURCE_SECONDS, so source + MIN_NEW <= MAX_TOTAL: the floor
|
| 118 |
-
# never pushes total past the cap, and mask_end (total) > mask_start (source).
|
| 119 |
total_seconds = min(total_seconds, MAX_TOTAL_SECONDS)
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 123 |
|
| 124 |
|
| 125 |
def continue_audio(clip_path, total_seconds, prompt="", cfg_scale=DEFAULT_CFG,
|
|
@@ -159,19 +181,26 @@ def continue_audio(clip_path, total_seconds, prompt="", cfg_scale=DEFAULT_CFG,
|
|
| 159 |
_notify("reading")
|
| 160 |
source = _load_source(clip_path)
|
| 161 |
source_seconds = source.shape[-1] / SR
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 162 |
# the autoencoder runs in the model's dtype (fp16 on CUDA); the conditioning
|
| 163 |
# audio must match it or the encoder's conv1d rejects the input dtype.
|
| 164 |
model_dtype = next(model.model.parameters()).dtype
|
| 165 |
-
|
| 166 |
|
| 167 |
-
|
| 168 |
-
source_seconds, total_seconds)
|
| 169 |
prompt = (prompt or "").strip()
|
| 170 |
|
| 171 |
-
print(f"[coda] continuation: source={source_seconds:.1f}s
|
| 172 |
-
f"
|
| 173 |
-
f"mask=[{mask_start:.1f}s, {mask_end:.1f}s],
|
| 174 |
-
f"cfg={cfg_scale}, prompt={prompt!r}", flush=True)
|
| 175 |
|
| 176 |
_notify("composing")
|
| 177 |
with torch.no_grad():
|
|
@@ -179,10 +208,10 @@ def continue_audio(clip_path, total_seconds, prompt="", cfg_scale=DEFAULT_CFG,
|
|
| 179 |
model,
|
| 180 |
steps=STEPS,
|
| 181 |
cfg_scale=cfg_scale,
|
| 182 |
-
conditioning=[{"prompt": prompt, "seconds_total":
|
| 183 |
sample_size=_sample_size,
|
| 184 |
sampler_type=SAMPLER,
|
| 185 |
-
inpaint_audio=(SR,
|
| 186 |
inpaint_mask_start_seconds=mask_start,
|
| 187 |
inpaint_mask_end_seconds=mask_end,
|
| 188 |
seed=seed,
|
|
@@ -190,21 +219,24 @@ def continue_audio(clip_path, total_seconds, prompt="", cfg_scale=DEFAULT_CFG,
|
|
| 190 |
)
|
| 191 |
|
| 192 |
_notify("finalizing")
|
| 193 |
-
# (b, d, n) -> (d, b*n)
|
| 194 |
output = rearrange(output, "b d n -> d (b n)")
|
| 195 |
audio = output.to(torch.float32).cpu().numpy()
|
| 196 |
-
peak = float(np.abs(audio).max())
|
| 197 |
-
if peak > 1e-9:
|
| 198 |
-
audio = audio / peak
|
| 199 |
|
| 200 |
if audio.shape[0] == 1: # safety: ensure stereo
|
| 201 |
audio = np.repeat(audio, 2, axis=0)
|
| 202 |
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 208 |
|
| 209 |
print(f"[coda] generated tail: shape={new_tail.shape} "
|
| 210 |
f"({new_tail.shape[-1] / SR:.1f}s), peak after norm "
|
|
|
|
| 9 |
no energy guards, no re-roll logic.
|
| 10 |
|
| 11 |
This module is the whole generation core. It returns ONLY the newly generated
|
| 12 |
+
tail plus the source length in seconds; `stitch.py` joins that tail onto the
|
| 13 |
+
user's *pristine* original so the real recording (and any vocals) plays
|
| 14 |
+
untouched up to the seam.
|
| 15 |
+
|
| 16 |
+
Bounded lead-in (the deployed-bug fix): SA3 only needs a short run-up to know
|
| 17 |
+
where the song is going. We therefore condition on at most MAX_LEAD_SECONDS of
|
| 18 |
+
the clip's TAIL, not the whole clip. Feeding a long clip (e.g. 100 s) into the
|
| 19 |
+
buffer and masking only a few seconds makes the 8-step distilled sampler
|
| 20 |
+
collapse to near-silence in that tiny window — the bug that shipped. A bounded
|
| 21 |
+
lead keeps the masked (generated) region substantial and healthy, and because
|
| 22 |
+
stitch rejoins the tail onto the full pristine original, the listener still
|
| 23 |
+
hears their entire clip before the seam.
|
| 24 |
|
| 25 |
Mask convention (verified against the installed library source):
|
| 26 |
inpaint_mask = ones(buffer); inpaint_mask[start:end] = 0
|
| 27 |
+
-> 1 = keep the input audio, 0 = generate. We place `lead` seconds of source
|
| 28 |
+
at the front and mask [lead, lead+new], so SA3 keeps the lead and generates a
|
| 29 |
+
fresh `new`-second tail that continues from the clip's end.
|
| 30 |
"""
|
| 31 |
import numpy as np
|
| 32 |
import torch
|
|
|
|
| 40 |
# at 1.0 (CFG amplification off, conditional path on)
|
| 41 |
MAX_TOTAL_SECONDS = 120 # SA3 Small duration cap (sample_size / sample_rate)
|
| 42 |
MIN_NEW_SECONDS = 5 # below this a "continuation" isn't worth a GPU call
|
| 43 |
+
MAX_LEAD_SECONDS = 30 # how much of the clip's TAIL to feed SA3 as run-up.
|
| 44 |
+
# SA3 generates a healthy continuation from a bounded
|
| 45 |
+
# lead-in; keeping a very long source in the buffer and
|
| 46 |
+
# masking only a few seconds makes the distilled sampler
|
| 47 |
+
# produce near-silence. 30 s is inside the model's
|
| 48 |
+
# healthy range (the verified lab takes used ~30 s leads)
|
| 49 |
+
# and the splice restores the full clip anyway.
|
| 50 |
|
| 51 |
_model = None
|
| 52 |
_model_config = None
|
|
|
|
| 112 |
|
| 113 |
|
| 114 |
def plan_continuation(source_seconds, total_seconds):
|
| 115 |
+
"""Pure helper (unit-testable, no model): turn a (source, requested-total)
|
| 116 |
+
pair into the SA3 generation buffer and return (lead, new_seconds, buffer).
|
| 117 |
+
|
| 118 |
+
- `lead` : seconds of the clip's TAIL used as run-up context, capped at
|
| 119 |
+
MAX_LEAD_SECONDS so a long clip can't drown the masked region.
|
| 120 |
+
- `new` : seconds of fresh audio to generate. We extend to the requested
|
| 121 |
+
finished length (`total - source`), floored at MIN_NEW_SECONDS
|
| 122 |
+
so every call earns its GPU time, and bounded so the buffer
|
| 123 |
+
(lead + new) never exceeds SA3's MAX_TOTAL_SECONDS cap.
|
| 124 |
+
- `buffer` : lead + new, i.e. the full generation buffer. The mask runs
|
| 125 |
+
[lead, buffer]; buffer > lead always, so it never inverts.
|
| 126 |
+
|
| 127 |
+
Raises ValueError only for a clip longer than MAX_SOURCE_SECONDS — at that
|
| 128 |
+
point it's a full track, not an unfinished clip to continue.
|
| 129 |
"""
|
| 130 |
source_seconds = float(source_seconds)
|
| 131 |
total_seconds = float(total_seconds)
|
| 132 |
if source_seconds > MAX_SOURCE_SECONDS:
|
| 133 |
raise ValueError(
|
| 134 |
+
f"clip is {source_seconds:.0f}s — that's a finished-length track, "
|
| 135 |
+
f"not an unfinished clip. CODA continues clips up to "
|
| 136 |
+
f"{MAX_SOURCE_SECONDS:.0f}s; trim it shorter and re-upload.")
|
|
|
|
|
|
|
|
|
|
| 137 |
total_seconds = min(total_seconds, MAX_TOTAL_SECONDS)
|
| 138 |
+
lead = min(source_seconds, MAX_LEAD_SECONDS)
|
| 139 |
+
# extend to the requested finished length; floor at MIN_NEW, and never let
|
| 140 |
+
# lead + new exceed the buffer cap.
|
| 141 |
+
new_seconds = max(total_seconds - source_seconds, MIN_NEW_SECONDS)
|
| 142 |
+
new_seconds = min(new_seconds, MAX_TOTAL_SECONDS - lead)
|
| 143 |
+
buffer_seconds = lead + new_seconds
|
| 144 |
+
return lead, new_seconds, buffer_seconds
|
| 145 |
|
| 146 |
|
| 147 |
def continue_audio(clip_path, total_seconds, prompt="", cfg_scale=DEFAULT_CFG,
|
|
|
|
| 181 |
_notify("reading")
|
| 182 |
source = _load_source(clip_path)
|
| 183 |
source_seconds = source.shape[-1] / SR
|
| 184 |
+
|
| 185 |
+
lead, new_seconds, buffer_seconds = plan_continuation(
|
| 186 |
+
source_seconds, total_seconds)
|
| 187 |
+
|
| 188 |
+
# condition on only the TAIL `lead` seconds of the clip. This is the bug fix:
|
| 189 |
+
# a long source no longer fills the buffer and starves the masked region.
|
| 190 |
+
lead_samples = min(int(round(lead * SR)), source.shape[-1])
|
| 191 |
+
lead_audio = source[:, -lead_samples:]
|
| 192 |
# the autoencoder runs in the model's dtype (fp16 on CUDA); the conditioning
|
| 193 |
# audio must match it or the encoder's conv1d rejects the input dtype.
|
| 194 |
model_dtype = next(model.model.parameters()).dtype
|
| 195 |
+
lead_audio = lead_audio.to(model_dtype)
|
| 196 |
|
| 197 |
+
mask_start, mask_end = lead, buffer_seconds
|
|
|
|
| 198 |
prompt = (prompt or "").strip()
|
| 199 |
|
| 200 |
+
print(f"[coda] continuation: source={source_seconds:.1f}s, "
|
| 201 |
+
f"lead={lead:.1f}s -> buffer={buffer_seconds:.1f}s "
|
| 202 |
+
f"(+{new_seconds:.1f}s new), mask=[{mask_start:.1f}s, {mask_end:.1f}s], "
|
| 203 |
+
f"steps={STEPS}, cfg={cfg_scale}, prompt={prompt!r}", flush=True)
|
| 204 |
|
| 205 |
_notify("composing")
|
| 206 |
with torch.no_grad():
|
|
|
|
| 208 |
model,
|
| 209 |
steps=STEPS,
|
| 210 |
cfg_scale=cfg_scale,
|
| 211 |
+
conditioning=[{"prompt": prompt, "seconds_total": buffer_seconds}],
|
| 212 |
sample_size=_sample_size,
|
| 213 |
sampler_type=SAMPLER,
|
| 214 |
+
inpaint_audio=(SR, lead_audio),
|
| 215 |
inpaint_mask_start_seconds=mask_start,
|
| 216 |
inpaint_mask_end_seconds=mask_end,
|
| 217 |
seed=seed,
|
|
|
|
| 219 |
)
|
| 220 |
|
| 221 |
_notify("finalizing")
|
| 222 |
+
# (b, d, n) -> (d, b*n)
|
| 223 |
output = rearrange(output, "b d n -> d (b n)")
|
| 224 |
audio = output.to(torch.float32).cpu().numpy()
|
|
|
|
|
|
|
|
|
|
| 225 |
|
| 226 |
if audio.shape[0] == 1: # safety: ensure stereo
|
| 227 |
audio = np.repeat(audio, 2, axis=0)
|
| 228 |
|
| 229 |
+
# the generated region is [lead, buffer]; slice it out first, THEN normalize
|
| 230 |
+
# by the tail's OWN peak. Normalizing the whole buffer (as before) let a loud
|
| 231 |
+
# lead transient divide the tail down toward silence; per-tail normalization
|
| 232 |
+
# returns the continuation at a healthy standalone level and stitch re-levels
|
| 233 |
+
# it to the seam.
|
| 234 |
+
start = int(round(lead * SR))
|
| 235 |
+
end = min(int(round(buffer_seconds * SR)), audio.shape[-1])
|
| 236 |
+
new_tail = np.ascontiguousarray(audio[:, start:end].astype(np.float32))
|
| 237 |
+
peak = float(np.abs(new_tail).max())
|
| 238 |
+
if peak > 1e-9:
|
| 239 |
+
new_tail = new_tail / peak
|
| 240 |
|
| 241 |
print(f"[coda] generated tail: shape={new_tail.shape} "
|
| 242 |
f"({new_tail.shape[-1] / SR:.1f}s), peak after norm "
|
|
@@ -1,69 +1,89 @@
|
|
| 1 |
-
"""Unit tests for engine.py's pure continuation planning — the
|
| 2 |
-
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
import pytest
|
| 5 |
|
| 6 |
import engine
|
| 7 |
|
| 8 |
|
| 9 |
-
def
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
assert
|
| 13 |
-
|
| 14 |
-
assert
|
| 15 |
-
assert mend == 60
|
| 16 |
|
| 17 |
|
| 18 |
-
def
|
| 19 |
-
|
| 20 |
-
assert
|
| 21 |
-
assert
|
| 22 |
-
assert
|
| 23 |
|
| 24 |
|
| 25 |
def test_min_new_floor_enforced():
|
| 26 |
# asking for barely-longer-than-source still generates at least MIN_NEW
|
| 27 |
-
|
| 28 |
assert new == engine.MIN_NEW_SECONDS == 5
|
| 29 |
-
assert
|
| 30 |
-
assert mstart == 30 and mend == 35
|
| 31 |
|
| 32 |
|
| 33 |
-
def
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
|
|
|
|
|
|
| 40 |
|
| 41 |
|
| 42 |
-
def
|
| 43 |
-
#
|
| 44 |
-
|
| 45 |
-
|
|
|
|
| 46 |
assert new == engine.MIN_NEW_SECONDS == 5
|
| 47 |
-
assert
|
|
|
|
| 48 |
|
| 49 |
|
| 50 |
-
def
|
| 51 |
-
|
| 52 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
for src in (116, 120, 125, 200):
|
| 54 |
with pytest.raises(ValueError):
|
| 55 |
engine.plan_continuation(src, 60)
|
| 56 |
|
| 57 |
|
| 58 |
-
def test_no_plan_ever_inverts_the_mask():
|
| 59 |
-
for src in [1, 15, 29.5, 60, 90, 110, 115]:
|
| 60 |
-
total, new, mstart, mend = engine.plan_continuation(src, 60)
|
| 61 |
-
assert mend > mstart # mask_end strictly after mask_start
|
| 62 |
-
assert new >= engine.MIN_NEW_SECONDS - 1e-6
|
| 63 |
-
|
| 64 |
-
|
| 65 |
def test_constants_match_sa3_contract():
|
| 66 |
assert engine.SR == 44100
|
| 67 |
assert engine.STEPS == 8
|
| 68 |
assert engine.SAMPLER == "pingpong"
|
| 69 |
assert engine.MAX_TOTAL_SECONDS == 120
|
|
|
|
|
|
| 1 |
+
"""Unit tests for engine.py's pure continuation planning — the bounded-lead +
|
| 2 |
+
generation-buffer math. No model, no GPU: `plan_continuation` is a pure
|
| 3 |
+
function, so these run anywhere torch+numpy import.
|
| 4 |
+
|
| 5 |
+
Contract (post deployed-bug fix): plan_continuation(source, total) returns
|
| 6 |
+
(lead, new_seconds, buffer_seconds) where
|
| 7 |
+
- lead = min(source, MAX_LEAD_SECONDS) — tail run-up fed to SA3
|
| 8 |
+
- new = extend to `total`, floored at MIN_NEW, bounded so lead+new <= cap
|
| 9 |
+
- buffer = lead + new — the masked region is [lead, buffer]
|
| 10 |
+
"""
|
| 11 |
import pytest
|
| 12 |
|
| 13 |
import engine
|
| 14 |
|
| 15 |
|
| 16 |
+
def test_short_source_keeps_whole_clip_as_lead():
|
| 17 |
+
# source shorter than the lead cap: keep the whole clip, extend to total
|
| 18 |
+
lead, new, buf = engine.plan_continuation(30, 60)
|
| 19 |
+
assert lead == 30 # whole 30s clip is the lead-in
|
| 20 |
+
assert new == 30 # generate up to the 60s finished length
|
| 21 |
+
assert buf == 60 # buffer = lead + new
|
|
|
|
| 22 |
|
| 23 |
|
| 24 |
+
def test_buffer_capped_at_120():
|
| 25 |
+
lead, new, buf = engine.plan_continuation(30, 200)
|
| 26 |
+
assert lead == 30
|
| 27 |
+
assert buf == engine.MAX_TOTAL_SECONDS == 120
|
| 28 |
+
assert new == 90 # lead(30) + new(90) == 120 cap
|
| 29 |
|
| 30 |
|
| 31 |
def test_min_new_floor_enforced():
|
| 32 |
# asking for barely-longer-than-source still generates at least MIN_NEW
|
| 33 |
+
lead, new, buf = engine.plan_continuation(30, 31)
|
| 34 |
assert new == engine.MIN_NEW_SECONDS == 5
|
| 35 |
+
assert lead == 30 and buf == 35
|
|
|
|
| 36 |
|
| 37 |
|
| 38 |
+
def test_long_source_clamps_lead_and_still_extends():
|
| 39 |
+
# the deployed bug: a 100s source kept the whole clip and masked ~5s, which
|
| 40 |
+
# the sampler rendered as near-silence. now the lead is clamped and the
|
| 41 |
+
# request still extends toward the asked-for total.
|
| 42 |
+
lead, new, buf = engine.plan_continuation(100, 120)
|
| 43 |
+
assert lead == engine.MAX_LEAD_SECONDS == 30 # not 100s of kept context
|
| 44 |
+
assert new == 20 # 120 - 100 of fresh audio
|
| 45 |
+
assert buf == 50 # bounded, healthy buffer
|
| 46 |
+
assert buf - lead == new # masked span == new audio
|
| 47 |
|
| 48 |
|
| 49 |
+
def test_request_below_source_still_makes_real_audio():
|
| 50 |
+
# total < source is contradictory (can't shorten); we still generate a
|
| 51 |
+
# MIN_NEW tail rather than nothing, and the lead stays bounded.
|
| 52 |
+
lead, new, buf = engine.plan_continuation(100, 60)
|
| 53 |
+
assert lead == 30
|
| 54 |
assert new == engine.MIN_NEW_SECONDS == 5
|
| 55 |
+
assert buf == 35
|
| 56 |
+
assert buf > lead # mask never inverts
|
| 57 |
|
| 58 |
|
| 59 |
+
def test_mask_brackets_the_new_region_across_inputs():
|
| 60 |
+
for src, req in [(15, 40), (29.5, 60), (50, 90), (10, 120), (100, 110)]:
|
| 61 |
+
lead, new, buf = engine.plan_continuation(src, req)
|
| 62 |
+
assert lead == min(src, engine.MAX_LEAD_SECONDS)
|
| 63 |
+
assert abs((buf - lead) - new) < 1e-6 # masked span == new audio
|
| 64 |
+
assert buf > lead # never inverts
|
| 65 |
+
assert new >= engine.MIN_NEW_SECONDS - 1e-6
|
| 66 |
+
assert buf <= engine.MAX_TOTAL_SECONDS
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def test_normal_request_reaches_requested_total():
|
| 70 |
+
# for a legitimate request (total > source) the finished length is exactly
|
| 71 |
+
# source + new == total: it extends to what the user asked for.
|
| 72 |
+
for src, total in [(15, 40), (29.5, 60), (20, 90)]:
|
| 73 |
+
_, new, _ = engine.plan_continuation(src, total)
|
| 74 |
+
assert abs((src + new) - total) < 1e-6
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def test_overlong_source_raises():
|
| 78 |
+
# a clip longer than MAX_SOURCE_SECONDS is a finished track, not a clip
|
| 79 |
for src in (116, 120, 125, 200):
|
| 80 |
with pytest.raises(ValueError):
|
| 81 |
engine.plan_continuation(src, 60)
|
| 82 |
|
| 83 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 84 |
def test_constants_match_sa3_contract():
|
| 85 |
assert engine.SR == 44100
|
| 86 |
assert engine.STEPS == 8
|
| 87 |
assert engine.SAMPLER == "pingpong"
|
| 88 |
assert engine.MAX_TOTAL_SECONDS == 120
|
| 89 |
+
assert engine.MAX_LEAD_SECONDS == 30
|