blackboxanalytics commited on
Commit
dd34fe0
·
1 Parent(s): c9f44d7

Fix ZeroGPU audio quality and stuck-processing UI

Browse files

Quality: stop pinning seed=7. That seed reproduced the verified lab take
on torch 2.7.1, but ZeroGPU runs torch 2.8-2.11, where the same seed is
just an arbitrary (bad) draw -- the 'sporadic loud random synth noise'
take. engine.continue_audio now draws best-of-N candidates and keeps the
cleanest by a blind artifact score that rejects both loud random bursts
and silence collapse. Early-accept + an 85s wall-clock budget keep it
inside the 120s GPU window. Stay at the distilled 8-step pingpong regime
(bumping steps adds artifacts, not quality).

UI: collapse the finish_btn .then chain into a single event so one
completion signal clears the spinner -- the chained second event could
hang the 'processing' state if the SSE stream blipped (ClientDisconnect).
Build the summary markdown visible (empty) instead of toggling it. Queue
one job at a time and launch with show_error so a real failure surfaces
in the UI instead of a silently stuck spinner.

Files changed (2) hide show
  1. app.py +21 -11
  2. engine.py +142 -48
app.py CHANGED
@@ -137,13 +137,15 @@ def _continue_on_gpu(listen_path, total_seconds, vibe):
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,
@@ -295,7 +297,11 @@ with gr.Blocks(title="CODA") as app:
295
  gr.Markdown("### Your finished song")
296
  output_audio = gr.Audio(label="", type="filepath",
297
  interactive=False)
298
- summary_md = gr.Markdown(visible=False)
 
 
 
 
299
 
300
  gr.Markdown(PUSHBACK_CREDIT, elem_id="coda-foot")
301
 
@@ -303,17 +309,21 @@ with gr.Blocks(title="CODA") as app:
303
  outputs=[info_md, finish_btn])
304
  demo_btn.click(fn=load_demo, inputs=[], outputs=[audio_input])
305
 
306
- def _show_summary():
307
- return gr.update(visible=True)
308
-
 
309
  finish_btn.click(
310
  fn=finish_song,
311
  inputs=[audio_input, total_slider, vibe, remaster],
312
- outputs=[output_audio, summary_md]).then(
313
- fn=_show_summary, inputs=[], outputs=[summary_md])
314
 
315
 
316
  if __name__ == "__main__":
317
  # Gradio 6 moved theme/css to launch(); pass them here (and they remain on
318
  # Blocks above) so the dark DAW theme applies however the Space serves it.
319
- app.queue().launch(theme=THEME, css=CSS)
 
 
 
 
 
137
  starts and finishes fast instead of sitting through a slow analysis preamble
138
  until the browser aborts the stream.
139
 
140
+ Do NOT pin a magic seed. The lab's verified seed=7 take was made on torch
141
+ 2.7.1; ZeroGPU runs a different torch (2.8–2.11), so the same seed reproduces
142
+ a *different* draw on the deployed build, the bad "loud random synth noise"
143
+ one. Instead, leave the seed unset (engine draws several candidates and keeps
144
+ the cleanest by an artifact score), which is robust across torch builds.
145
  """
146
  return engine.continue_audio(
147
  listen_path, total_seconds=int(total_seconds),
148
+ prompt=(vibe or "").strip())
149
 
150
 
151
  def finish_song(audio_path, total_seconds, vibe, remaster,
 
297
  gr.Markdown("### Your finished song")
298
  output_audio = gr.Audio(label="", type="filepath",
299
  interactive=False)
300
+ # built visible with an empty value (an empty Markdown renders
301
+ # nothing). finish_song fills it in the SAME event that sets the
302
+ # audio, so there's no second `.then` to toggle visibility — one
303
+ # event = one completion signal, so the spinner always clears.
304
+ summary_md = gr.Markdown()
305
 
306
  gr.Markdown(PUSHBACK_CREDIT, elem_id="coda-foot")
307
 
 
309
  outputs=[info_md, finish_btn])
310
  demo_btn.click(fn=load_demo, inputs=[], outputs=[audio_input])
311
 
312
+ # Single event (no chained `.then`): finish_song returns BOTH the audio path
313
+ # and the summary markdown, so one completion message clears the spinner.
314
+ # A chained second event was a place the "stuck processing" state could hang
315
+ # if the SSE stream blipped (ClientDisconnect) between the two events.
316
  finish_btn.click(
317
  fn=finish_song,
318
  inputs=[audio_input, total_slider, vibe, remaster],
319
+ outputs=[output_audio, summary_md])
 
320
 
321
 
322
  if __name__ == "__main__":
323
  # Gradio 6 moved theme/css to launch(); pass them here (and they remain on
324
  # Blocks above) so the dark DAW theme applies however the Space serves it.
325
+ # queue: one heavy job at a time (single GPU), others wait rather than
326
+ # contend. show_error surfaces a real error in the UI instead of a silently
327
+ # stuck spinner — the failure mode we just chased on the deployed Space.
328
+ app.queue(default_concurrency_limit=1, max_size=10).launch(
329
+ theme=THEME, css=CSS, show_error=True)
engine.py CHANGED
@@ -5,8 +5,17 @@ into a finished-sounding track in the same key, tempo and feel. SA3 does that in
5
  a SINGLE call. Its `generate_diffusion_cond_inpaint` is a native audio-inpainting
6
  diffusion sampler: place the user's clip at the front of the buffer, mask the
7
  region after it, and the model fills the masked region conditioned on the kept
8
- 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 plus the source length in seconds; `stitch.py` joins that tail onto the
@@ -28,16 +37,30 @@ Mask convention (verified against the installed library 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
33
 
34
  MODEL_ID = "stabilityai/stable-audio-3-small-music"
35
 
36
  SR = 44100 # SA3 native sample rate (model_config: sample_rate)
37
- STEPS = 8 # SA3 Small is an 8-step adversarially-distilled model
 
 
 
 
38
  SAMPLER = "pingpong" # the sampler the distilled model was tuned for
39
  DEFAULT_CFG = 1.0 # distilled-model guidance; the prompt still conditions
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.
@@ -144,9 +167,47 @@ def plan_continuation(source_seconds, total_seconds):
144
  return lead, new_seconds, buffer_seconds
145
 
146
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
147
  def continue_audio(clip_path, total_seconds, prompt="", cfg_scale=DEFAULT_CFG,
148
- seed=-1, progress=None):
149
- """Continue `clip_path` up to `total_seconds` in one SA3 inpaint call.
 
 
 
 
 
150
 
151
  Returns (new_tail, source_seconds, SR) where:
152
  new_tail : (2, M) float32 @44.1k — ONLY the generated region
@@ -155,7 +216,7 @@ def continue_audio(clip_path, total_seconds, prompt="", cfg_scale=DEFAULT_CFG,
155
  SR : 44100
156
 
157
  `progress(stage_name)` is called (best-effort) at each stage so the UI can
158
- paint a live status. SA3 is one diffusion call, so progress is stage-based.
159
  """
160
  from einops import rearrange
161
  from stable_audio_tools.inference.generation import (
@@ -172,11 +233,21 @@ def continue_audio(clip_path, total_seconds, prompt="", cfg_scale=DEFAULT_CFG,
172
  model = _ensure_on_device()
173
  dev = _device()
174
 
175
- # The library does `np.random.randint(0, 2**32-1)` when seed == -1, which
176
- # overflows int32 on Windows/numpy<2. Draw a safe seed ourselves so the
177
- # default path works everywhere, not just on the Linux Space.
178
- if seed is None or seed < 0:
179
- seed = int(np.random.randint(0, 2 ** 31 - 1))
 
 
 
 
 
 
 
 
 
 
180
 
181
  _notify("reading")
182
  source = _load_source(clip_path)
@@ -202,43 +273,66 @@ def continue_audio(clip_path, total_seconds, prompt="", cfg_scale=DEFAULT_CFG,
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():
207
- output = generate_diffusion_cond_inpaint(
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,
218
- device=dev,
219
- )
 
 
 
 
 
 
 
 
 
 
220
 
221
  _notify("finalizing")
222
- # (b, d, n) -> (d, b*n); peak-normalize like Stability's reference Space
223
- # (unchanged from the verified-good local path).
224
- output = rearrange(output, "b d n -> d (b n)")
225
- audio = output.to(torch.float32).cpu().numpy()
226
- peak = float(np.abs(audio).max())
227
- if peak > 1e-9:
228
- audio = audio / peak
229
-
230
- if audio.shape[0] == 1: # safety: ensure stereo
231
- audio = np.repeat(audio, 2, axis=0)
232
-
233
- # the generated region is [lead, buffer]; lead ends at the clip's true end,
234
- # so this slice is the continuation that follows the source.
235
- start = int(round(lead * SR))
236
- end = min(int(round(buffer_seconds * SR)), audio.shape[-1])
237
- new_tail = audio[:, start:end]
238
- new_tail = np.ascontiguousarray(new_tail.astype(np.float32))
239
-
240
- print(f"[coda] generated tail: shape={new_tail.shape} "
241
- f"({new_tail.shape[-1] / SR:.1f}s), peak after norm "
242
- f"{float(np.abs(new_tail).max()):.3f}, "
243
- f"rms {float(np.sqrt(np.mean(new_tail ** 2))):.3f}", flush=True)
244
- return new_tail, source_seconds, SR
 
5
  a SINGLE call. Its `generate_diffusion_cond_inpaint` is a native audio-inpainting
6
  diffusion sampler: place the user's clip at the front of the buffer, mask the
7
  region after it, and the model fills the masked region conditioned on the kept
8
+ audio — true long-form continuation, 44.1 kHz stereo, no multi-pass chaining
9
+ and no energy guards.
10
+
11
+ Candidate selection (the deployed quality fix): the lab verified a take with a
12
+ pinned seed on torch 2.7.1, but ZeroGPU runs a different torch (2.8–2.11), so the
13
+ same seed no longer reproduces it — it just freezes one arbitrary draw, which on
14
+ the deployed build was the bad "sporadic loud synth noise" take. Instead of
15
+ trusting a magic seed, CODA draws a few candidates and keeps the cleanest by a
16
+ cheap artifact score (it rejects both failure modes: loud random bursts AND
17
+ silence collapse). A wall-clock budget + early-accept keep this inside the
18
+ ZeroGPU window, so it costs at most a couple of extra fast 8-step draws.
19
 
20
  This module is the whole generation core. It returns ONLY the newly generated
21
  tail plus the source length in seconds; `stitch.py` joins that tail onto the
 
37
  at the front and mask [lead, lead+new], so SA3 keeps the lead and generates a
38
  fresh `new`-second tail that continues from the clip's end.
39
  """
40
+ import time
41
+
42
  import numpy as np
43
  import torch
44
 
45
  MODEL_ID = "stabilityai/stable-audio-3-small-music"
46
 
47
  SR = 44100 # SA3 native sample rate (model_config: sample_rate)
48
+ STEPS = 8 # SA3 Small is an 8-step adversarially-distilled model.
49
+ # It was tuned for 8-step pingpong; pushing it to 16/25
50
+ # steps is OFF its distilled regime and tends to ADD
51
+ # artifacts, not remove them. We stay at 8 and fix
52
+ # quality by picking the best of a few draws instead.
53
  SAMPLER = "pingpong" # the sampler the distilled model was tuned for
54
  DEFAULT_CFG = 1.0 # distilled-model guidance; the prompt still conditions
55
  # at 1.0 (CFG amplification off, conditional path on)
56
+
57
+ # Best-of-N: with a random seed each draw differs, so we generate a few and keep
58
+ # the cleanest. Bounded so it never blows the ZeroGPU window.
59
+ DEFAULT_CANDIDATES = 3 # how many draws to consider when no seed is pinned
60
+ GPU_BUDGET_SECONDS = 85.0 # stop drawing once this much wall-clock is spent
61
+ # (the @spaces.GPU window is 120s; leave slack)
62
+ EARLY_ACCEPT_SCORE = 4.0 # a draw this clean is taken immediately, no re-draw
63
+ # (heuristic — see _tail_artifact_score; tune live)
64
  MAX_TOTAL_SECONDS = 120 # SA3 Small duration cap (sample_size / sample_rate)
65
  MIN_NEW_SECONDS = 5 # below this a "continuation" isn't worth a GPU call
66
  MAX_LEAD_SECONDS = 30 # how much of the clip's TAIL to feed SA3 as run-up.
 
167
  return lead, new_seconds, buffer_seconds
168
 
169
 
170
+ def _tail_artifact_score(tail, sr=SR):
171
+ """Lower is better. A blind, ear-free quality score for a generated tail,
172
+ used to pick the cleanest of several candidate draws.
173
+
174
+ It targets the two ways an SA3 draw goes bad:
175
+ * "sporadic loud random synth noises" — even a FEW short windows far louder
176
+ than the body push the loudest window way above the median. (After the
177
+ whole-buffer peak-normalize, a burst that set the peak crushes the body,
178
+ making the gap larger still.) Sustained dynamics rarely make any single
179
+ 50 ms window many times the median, so musical loudness doesn't trip it.
180
+ * silence collapse — a near-silent tail (the other known failure) is caught
181
+ by the explicit loudness floor below.
182
+
183
+ Score = max(window RMS) / median(window RMS) + silence penalty.
184
+ Computed on a mono mix over short (~50 ms) windows. A flat, steady signal
185
+ scores ~1; isolated loud bursts or a crushed body score high.
186
+ """
187
+ mono = tail.mean(axis=0) if tail.ndim == 2 else np.asarray(tail)
188
+ mono = np.asarray(mono, dtype=np.float64)
189
+ win = max(1, int(0.05 * sr))
190
+ if mono.size < win * 4:
191
+ return float("inf") # too short to judge — avoid it
192
+ n = mono.size // win
193
+ energies = np.sqrt(
194
+ np.mean(mono[:n * win].reshape(n, win) ** 2, axis=1) + 1e-12)
195
+ median = float(np.median(energies)) + 1e-9
196
+ loudest = float(np.max(energies))
197
+ spikiness = loudest / median
198
+ overall = float(np.sqrt(np.mean(mono ** 2)) + 1e-12)
199
+ silence_penalty = 0.0 if overall > 0.02 else (0.02 - overall) * 200.0
200
+ return spikiness + silence_penalty
201
+
202
+
203
  def continue_audio(clip_path, total_seconds, prompt="", cfg_scale=DEFAULT_CFG,
204
+ seed=-1, candidates=None, progress=None):
205
+ """Continue `clip_path` up to `total_seconds` with SA3 inpainting.
206
+
207
+ With the default `seed` (< 0) this draws up to `candidates` SA3 inpaint
208
+ takes and returns the cleanest by `_tail_artifact_score` (early-accepting a
209
+ clean draw and respecting a GPU wall-clock budget). Pin `seed` >= 0 for a
210
+ single deterministic draw (debug/repro).
211
 
212
  Returns (new_tail, source_seconds, SR) where:
213
  new_tail : (2, M) float32 @44.1k — ONLY the generated region
 
216
  SR : 44100
217
 
218
  `progress(stage_name)` is called (best-effort) at each stage so the UI can
219
+ paint a live status. Progress is stage-based (read / compose / finalize).
220
  """
221
  from einops import rearrange
222
  from stable_audio_tools.inference.generation import (
 
233
  model = _ensure_on_device()
234
  dev = _device()
235
 
236
+ # Seed policy. The library does `np.random.randint(0, 2**32-1)` when
237
+ # seed == -1, which overflows int32 on Windows/numpy<2, so we always draw
238
+ # our own safe seeds. A caller that pins a seed (>= 0) gets exactly one
239
+ # deterministic draw (reproducibility/debug paths). The default path
240
+ # (seed < 0) draws several candidates and keeps the cleanest — that's what
241
+ # the app uses, because a single pinned seed doesn't survive a torch change.
242
+ pinned = seed is not None and seed >= 0
243
+ n_candidates = 1 if pinned else max(1, int(candidates or DEFAULT_CANDIDATES))
244
+ if pinned:
245
+ seeds = [int(seed)]
246
+ else:
247
+ base = int(np.random.randint(0, 2 ** 31 - 1))
248
+ # spread the seeds far apart so the draws are genuinely different
249
+ seeds = [(base + i * 0x9E3779B1) % (2 ** 31 - 1)
250
+ for i in range(n_candidates)]
251
 
252
  _notify("reading")
253
  source = _load_source(clip_path)
 
273
  f"(+{new_seconds:.1f}s new), mask=[{mask_start:.1f}s, {mask_end:.1f}s], "
274
  f"steps={STEPS}, cfg={cfg_scale}, prompt={prompt!r}", flush=True)
275
 
276
+ def _draw(draw_seed):
277
+ """One full SA3 inpaint draw -> normalized generated tail (2, M)."""
278
+ with torch.no_grad():
279
+ output = generate_diffusion_cond_inpaint(
280
+ model,
281
+ steps=STEPS,
282
+ cfg_scale=cfg_scale,
283
+ conditioning=[{"prompt": prompt, "seconds_total": buffer_seconds}],
284
+ sample_size=_sample_size,
285
+ sampler_type=SAMPLER,
286
+ inpaint_audio=(SR, lead_audio),
287
+ inpaint_mask_start_seconds=mask_start,
288
+ inpaint_mask_end_seconds=mask_end,
289
+ seed=int(draw_seed),
290
+ device=dev,
291
+ )
292
+ # (b, d, n) -> (d, b*n); peak-normalize like Stability's reference Space
293
+ # (unchanged from the verified-good local path).
294
+ output = rearrange(output, "b d n -> d (b n)")
295
+ audio = output.to(torch.float32).cpu().numpy()
296
+ peak = float(np.abs(audio).max())
297
+ if peak > 1e-9:
298
+ audio = audio / peak
299
+ if audio.shape[0] == 1: # safety: ensure stereo
300
+ audio = np.repeat(audio, 2, axis=0)
301
+ # the generated region is [lead, buffer]; lead ends at the clip's true
302
+ # end, so this slice is the continuation that follows the source.
303
+ start = int(round(lead * SR))
304
+ end = min(int(round(buffer_seconds * SR)), audio.shape[-1])
305
+ return np.ascontiguousarray(audio[:, start:end].astype(np.float32))
306
+
307
  _notify("composing")
308
+ # Best-of-N: draw, score, keep the cleanest. Early-accept a clean draw and
309
+ # stop if the GPU wall-clock budget runs low, so this never blows the window.
310
+ best_tail, best_score, best_seed = None, float("inf"), None
311
+ t0 = time.time()
312
+ for i, draw_seed in enumerate(seeds):
313
+ tail = _draw(draw_seed)
314
+ score = _tail_artifact_score(tail, SR)
315
+ elapsed = time.time() - t0
316
+ print(f"[coda] candidate {i + 1}/{len(seeds)} seed={draw_seed} "
317
+ f"shape={tail.shape} ({tail.shape[-1] / SR:.1f}s) "
318
+ f"artifact_score={score:.2f} "
319
+ f"rms={float(np.sqrt(np.mean(tail ** 2))):.3f} "
320
+ f"elapsed={elapsed:.1f}s", flush=True)
321
+ if score < best_score:
322
+ best_tail, best_score, best_seed = tail, score, draw_seed
323
+ if best_score <= EARLY_ACCEPT_SCORE:
324
+ print(f"[coda] candidate {i + 1} clean enough "
325
+ f"(score {best_score:.2f} <= {EARLY_ACCEPT_SCORE}); accepting",
326
+ flush=True)
327
+ break
328
+ if elapsed > GPU_BUDGET_SECONDS and i + 1 < len(seeds):
329
+ print(f"[coda] GPU budget {GPU_BUDGET_SECONDS:.0f}s reached after "
330
+ f"{i + 1} draw(s); keeping best so far", flush=True)
331
+ break
332
 
333
  _notify("finalizing")
334
+ print(f"[coda] selected seed={best_seed} artifact_score={best_score:.2f} "
335
+ f"tail={best_tail.shape[-1] / SR:.1f}s peak after norm "
336
+ f"{float(np.abs(best_tail).max()):.3f} "
337
+ f"rms {float(np.sqrt(np.mean(best_tail ** 2))):.3f}", flush=True)
338
+ return best_tail, source_seconds, SR