blackboxanalytics commited on
Commit
e033515
·
1 Parent(s): 5067511

Fix ZeroGPU continuation: bound the lead-in, normalize the tail, shrink the GPU window

Browse files

Three 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.

Files changed (3) hide show
  1. app.py +28 -25
  2. engine.py +74 -42
  3. test_engine_logic.py +60 -40
app.py CHANGED
@@ -128,50 +128,53 @@ def analyze_on_upload(audio_path):
128
  gr.update(interactive=False))
129
 
130
 
131
- @spaces.GPU(duration=240)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
132
  def finish_song(audio_path, total_seconds, vibe, remaster,
133
  progress=gr.Progress()):
134
- """The whole job inside one GPU window: listen -> continue (SA3) -> splice.
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
- # analyze + build the conditioning feed from a cleaned copy
 
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.12, desc="Remastering your part…")
150
  original = enhance_audio(original, sr)
151
 
152
- # SA3 is a single call; map its stages onto the bar
153
- def _on_stage(stage):
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
- # Pin the seed to the lab's known-good draw. SA3 is generative: a
165
- # random seed gives a different (and often weaker) continuation every
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 too long to continue under the 120s cap
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
 
engine.py CHANGED
@@ -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 (the model's [source_end, total] region) plus the source length in seconds;
13
- `stitch.py` joins that tail onto the user's *pristine* original so the real
14
- recording (and any vocals) plays untouched up to the seam.
 
 
 
 
 
 
 
 
 
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. So masking [L_src, L_total] keeps
19
- the source in [0, L_src] and generates everything after it.
 
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): clamp the request to SA3's limits
99
- and return (total_seconds, new_seconds, mask_start, mask_end).
100
-
101
- - the mask runs from where the source ends to the total length: that masked
102
- region is what SA3 generates, so mask_end MUST exceed mask_start.
103
- - total is capped at MAX_TOTAL_SECONDS and floored so at least
104
- MIN_NEW_SECONDS of new audio is generated.
105
- - raises ValueError when the source is already so long there's no room to
106
- continue under the cap (otherwise the mask would invert and SA3 would
107
- silently generate nothing).
 
 
 
 
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 — too long to continue under SA3's "
114
- f"{MAX_TOTAL_SECONDS:.0f}s cap (need room for at least "
115
- f"{MIN_NEW_SECONDS:.0f}s of new audio); trim it under "
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
- total_seconds = max(total_seconds, source_seconds + MIN_NEW_SECONDS)
121
- new_seconds = total_seconds - source_seconds
122
- return total_seconds, new_seconds, source_seconds, total_seconds
 
 
 
 
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
- source = source.to(model_dtype)
166
 
167
- total_seconds, new_seconds, mask_start, mask_end = plan_continuation(
168
- source_seconds, total_seconds)
169
  prompt = (prompt or "").strip()
170
 
171
- print(f"[coda] continuation: source={source_seconds:.1f}s -> "
172
- f"total={total_seconds:.1f}s (+{new_seconds:.1f}s new), "
173
- f"mask=[{mask_start:.1f}s, {mask_end:.1f}s], steps={STEPS}, "
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": total_seconds}],
183
  sample_size=_sample_size,
184
  sampler_type=SAMPLER,
185
- inpaint_audio=(SR, source),
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); peak-normalize like Stability's reference Space
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
- boundary = int(round(source_seconds * SR))
204
- end = int(round(total_seconds * SR))
205
- end = min(end, audio.shape[-1])
206
- new_tail = audio[:, boundary:end]
207
- new_tail = np.ascontiguousarray(new_tail.astype(np.float32))
 
 
 
 
 
 
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 "
test_engine_logic.py CHANGED
@@ -1,69 +1,89 @@
1
- """Unit tests for engine.py's pure continuation planning — the source-length ->
2
- mask-bounds + total-length-cap math. No model, no GPU: `plan_continuation` is a
3
- pure function, so these run anywhere torch+numpy import."""
 
 
 
 
 
 
 
4
  import pytest
5
 
6
  import engine
7
 
8
 
9
- def test_normal_request_maps_to_tail_mask():
10
- total, new, mstart, mend = engine.plan_continuation(30, 60)
11
- assert total == 60
12
- assert new == 30
13
- # the mask runs from where the source ends to the total length
14
- assert mstart == 30
15
- assert mend == 60
16
 
17
 
18
- def test_total_capped_at_120():
19
- total, new, mstart, mend = engine.plan_continuation(30, 200)
20
- assert total == engine.MAX_TOTAL_SECONDS == 120
21
- assert new == 90
22
- assert mstart == 30 and mend == 120
23
 
24
 
25
  def test_min_new_floor_enforced():
26
  # asking for barely-longer-than-source still generates at least MIN_NEW
27
- total, new, mstart, mend = engine.plan_continuation(30, 31)
28
  assert new == engine.MIN_NEW_SECONDS == 5
29
- assert total == 35
30
- assert mstart == 30 and mend == 35
31
 
32
 
33
- def test_mask_always_brackets_the_new_region():
34
- for src, req in [(15, 40), (29.5, 60), (50, 90), (10, 120)]:
35
- total, new, mstart, mend = engine.plan_continuation(src, req)
36
- assert mstart == src # mask starts at the seam
37
- assert mend == total # …and runs to the end
38
- assert abs((mend - mstart) - new) < 1e-6 # masked span == new audio
39
- assert total <= engine.MAX_TOTAL_SECONDS
 
 
40
 
41
 
42
- def test_source_at_max_keeps_min_new():
43
- # the longest allowed clip still gets exactly MIN_NEW of continuation
44
- total, new, mstart, mend = engine.plan_continuation(engine.MAX_SOURCE_SECONDS, 130)
45
- assert total == engine.MAX_TOTAL_SECONDS == 120
 
46
  assert new == engine.MIN_NEW_SECONDS == 5
47
- assert mend > mstart # mask never inverts
 
48
 
49
 
50
- def test_overlong_source_raises_not_inverts():
51
- # the old bug: source >= cap produced an inverted mask and a silent
52
- # no-op continuation. now it must raise instead.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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