blackboxanalytics commited on
Commit
f66f37b
Β·
1 Parent(s): 9c6b4bb

Make CODA a premium audio instrument with a 4-beat journey

Browse files

Turn the UI into a milled-metal studio instrument with a cyan->violet->gold
narrative arc across four emotional beats:

- OPEN: faceplates rise on expo-out easing as milled panels (bevel lip +
recessed body + contact drop); the spectrum powers on from flat; the
Finish key sits visibly disarmed.
- UPLOAD: the fingerprint readout renders as a backlit-LCD HUD that powers
on (scanline sweep + per-cell settle) and the Finish key ignites.
- PROCESS: a generator yields a live stage overlay at the real pipeline
milestones (listening/composing/splicing) with counter-rotating rings,
dancing bars, and a violet active-step stepper that holds the GPU wait.
- REVEAL: the result deck escalates to a gold MASTER (gold bevel, head,
play control, and mono spec-plate summary chips).

Every module is custom-styled away from default Gradio: recessed upload
channel, milled fader with a lit knurled thumb, carved vibe field, backlit
remaster toggle, illuminated key states, integrated player decks. Beat
states are driven by CSS :has() off real DOM (stage overlay / output audio)
so they need no fragile JS events. Gradio footer hidden.

Engine pipeline, hidden length mirror, and no-autoplay output are unchanged.

Files changed (1) hide show
  1. app.py +620 -117
app.py CHANGED
@@ -86,20 +86,65 @@ if os.environ.get("SPACE_ID"):
86
  print(f"[coda] preload failed ({_e}); will lazy-load", flush=True)
87
 
88
 
89
- def _fmt_info(info, quality):
90
- """human-readable summary of what CODA heard."""
91
- lines = [
92
- f"**KEY**  `{info['key']}`",
93
- f"**TEMPO**  `{info['bpm']} BPM`",
94
- f"**METER**  `{info['time_signature']}`",
95
- f"**CLIP**  `{info['duration']}s`",
 
96
  ]
 
 
 
 
 
 
 
97
  if quality and quality.get("lofi"):
98
- lines.append(
99
- f"**SOURCE**  `lo-fi ~{quality['bandwidth_hz']/1000:.0f}kHz` "
100
- f"β€” CODA cleans a copy before it listens, so it follows the *song*, "
101
- f"not the hiss")
102
- return " \n".join(lines)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
103
 
104
 
105
  def analyze_on_upload(audio_path):
@@ -115,16 +160,19 @@ def analyze_on_upload(audio_path):
115
  # least MIN_NEW seconds of new audio. Block over-long clips here, with a
116
  # clear message, instead of failing at generation time.
117
  if info["duration"] > engine.MAX_SOURCE_SECONDS:
118
- msg = (f"### Clip too long\nThat clip is **{info['duration']:.0f}s**. "
119
- f"CODA continues clips up to **{engine.MAX_SOURCE_SECONDS:.0f}s** "
120
- f"(Stable Audio 3's {engine.MAX_TOTAL_SECONDS:.0f}s total cap). "
121
- f"Trim it shorter and re-upload.")
 
 
122
  return gr.update(value=msg, visible=True), gr.update(interactive=False)
123
- md = "### CODA heard\n" + _fmt_info(info, quality)
124
- return gr.update(value=md, visible=True), gr.update(interactive=True)
125
  except Exception as e:
126
  print(f"[coda] analysis failed ({e})", flush=True)
127
- return (gr.update(value=f"Couldn't read that file: {e}", visible=True),
 
128
  gr.update(interactive=False))
129
 
130
 
@@ -151,51 +199,75 @@ def _continue_on_gpu(listen_path, total_seconds, vibe):
151
  def finish_song(audio_path, total_seconds, vibe, remaster,
152
  progress=gr.Progress()):
153
  """Orchestrate the job: CPU prep -> GPU continuation -> CPU splice.
154
- Returns (output_wav_path, summary_markdown)."""
 
 
 
 
 
 
 
 
 
 
155
  if not audio_path:
156
  raise gr.Error("Upload a clip (or load the PUSHBACK demo) first.")
157
 
158
  total_seconds = int(total_seconds)
159
-
160
- # --- CPU prep (outside the GPU window) ---
161
- progress(0.05, desc="Listening to your clip…")
162
- listen_path = enhance_to_tempfile(audio_path)
163
- info = fingerprint(listen_path)
164
- # the pristine original β€” what the listener hears for the first stretch
165
- original, sr = librosa.load(audio_path, sr=None, mono=False)
166
- if remaster:
167
- progress(0.15, desc="Remastering your part…")
168
- original = enhance_audio(original, sr)
169
-
170
- # --- GPU continuation (the ONLY @spaces.GPU call) ---
171
- progress(0.35, desc="Composing the continuation…")
172
  try:
173
- new_tail, source_seconds, SR = _continue_on_gpu(
174
- listen_path, total_seconds, vibe)
175
- except ValueError as e:
176
- # e.g. the clip is a full-length track, not a clip to continue
177
- raise gr.Error(str(e))
178
-
179
- # --- CPU splice + write (outside the GPU window) ---
180
- progress(0.9, desc="Splicing onto your original…")
181
- out = stitch.stitch(original, sr, new_tail, source_seconds)
182
-
183
- out_path = os.path.join(tempfile.mkdtemp(), "coda_finished.wav")
184
- sf.write(out_path, out.T, SR, subtype="PCM_16")
185
-
186
- progress(1.0, desc="Done.")
187
- total = out.shape[-1] / SR
188
- added = total - source_seconds
189
- vibe_note = f" guided by *β€œ{vibe.strip()}”*" if (vibe or "").strip() else ""
190
- summary = (
191
- f"### Finished β€” {total:.0f}s\n"
192
- f"Your **{source_seconds:.0f}s** clip in **{info['key']}** at "
193
- f"**{info['bpm']} BPM** continued for **~{added:.0f}s** more{vibe_note}, "
194
- f"then crossfaded onto your original and faded to a clean close.\n\n"
195
- f"*Stable Audio 3 generated the continuation as 44.1 kHz stereo in a "
196
- f"single pass; your original recording plays untouched up to the seam.*"
197
- )
198
- return out_path, summary
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
199
 
200
 
201
  def load_demo():
@@ -236,6 +308,7 @@ THEME = gr.themes.Base(
236
  )
237
 
238
  CSS = """
 
239
  @import url('https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=Inter:wght@400;500;600&display=swap');
240
 
241
  :root{
@@ -244,12 +317,18 @@ CSS = """
244
  --glass:rgba(18,20,36,0.55);
245
  --glass-brd:rgba(150,160,220,0.14);
246
  --mx:50%; --my:28%;
 
 
 
 
 
 
 
 
247
  }
248
 
249
- /* ---------- ambient background layers ---------- */
250
- .coda-aurora,.coda-glow,.coda-particles{
251
- position:fixed; inset:0; pointer-events:none; overflow:hidden;
252
- }
253
  .coda-aurora{
254
  z-index:-3;
255
  background:
@@ -288,7 +367,7 @@ CSS = """
288
  .gradio-container{ max-width:1080px !important; margin:0 auto !important; }
289
  .gradio-container, .gradio-container *{ font-family:'Inter',ui-sans-serif,system-ui; }
290
 
291
- /* ---------- hero ---------- */
292
  #coda-head{ text-align:center; padding:40px 0 4px; position:relative; }
293
  #coda-title{
294
  font-family:'Space Grotesk',sans-serif; font-weight:700;
@@ -313,9 +392,11 @@ CSS = """
313
  transform-origin:center; transform:scaleY(.2);
314
  background:linear-gradient(180deg,#7be8ff,#a98bff);
315
  box-shadow:0 0 9px rgba(111,224,245,.45);
316
- animation-name:codaBar; animation-timing-function:ease-in-out;
317
- animation-iteration-count:infinite;
 
318
  }
 
319
  @keyframes codaBar{ 0%,100%{transform:scaleY(.16)} 50%{transform:scaleY(1)} }
320
  #coda-rule{
321
  height:1px; border:0; max-width:260px; margin:18px auto 6px;
@@ -326,24 +407,28 @@ CSS = """
326
  margin:0 auto 6px; font-size:1rem; line-height:1.6; }
327
  #coda-intro strong{ color:#dfe4ff; font-weight:600; }
328
 
329
- /* ---------- glass panels ---------- */
330
  .coda-glass, .coda-card{
331
  background:var(--glass) !important;
332
  border:1px solid var(--glass-brd) !important;
333
  border-radius:20px !important;
334
  backdrop-filter:blur(16px) saturate(135%);
335
  -webkit-backdrop-filter:blur(16px) saturate(135%);
336
- box-shadow:0 10px 38px rgba(0,0,0,.45), inset 0 1px 0 rgba(255,255,255,.06);
337
- padding:22px !important;
 
 
 
 
338
  }
339
- .coda-glass{ animation:codaRise .8s cubic-bezier(.2,.8,.2,1) both; }
340
- .coda-card{ animation:codaRise .8s cubic-bezier(.2,.8,.2,1) .08s both; }
341
- @keyframes codaRise{ from{opacity:0; transform:translateY(14px)} to{opacity:1; transform:none} }
342
 
343
- /* section labels */
344
  .coda-label{
345
  color:var(--cyan); text-transform:uppercase; letter-spacing:.18em;
346
- font-size:.72rem; font-weight:600; margin:0 0 2px; display:flex;
347
  align-items:center; gap:.5rem;
348
  }
349
  .coda-label::before{
@@ -353,66 +438,205 @@ CSS = """
353
  }
354
  @keyframes codaPulse{ 0%,100%{opacity:.5; transform:scale(.85)} 50%{opacity:1; transform:scale(1.15)} }
355
 
356
- /* make inner gradio blocks blend into the glass */
357
- .coda-glass .block, .coda-card .block{ box-shadow:none !important; }
 
358
  .coda-card h3{
359
  color:var(--cyan); text-transform:uppercase; letter-spacing:.16em;
360
  font-size:.74rem; font-weight:600; margin:.1rem 0 .7rem;
361
  }
362
 
363
- /* ---------- buttons ---------- */
364
  .coda-go, .coda-go button{
365
  background:linear-gradient(100deg,#6fe0f5,#a98bff 62%,#cf9bff) !important;
366
  background-size:180% auto !important;
367
- color:#0a0a16 !important; font-weight:600 !important; letter-spacing:.03em;
368
- border:0 !important; border-radius:14px !important; min-height:46px;
369
- box-shadow:0 0 0 1px rgba(255,255,255,.08),0 8px 26px rgba(124,100,255,.34) !important;
370
- transition:transform .25s cubic-bezier(.2,.8,.2,1), box-shadow .25s,
 
 
 
 
371
  filter .25s, background-position .6s !important;
372
  }
 
 
 
373
  .coda-go:hover:not([disabled]){
374
  transform:translateY(-2px); filter:brightness(1.06);
375
  background-position:right center !important;
376
- box-shadow:0 0 0 1px rgba(255,255,255,.14),0 14px 40px rgba(124,100,255,.5),
377
- 0 0 34px rgba(111,224,245,.38) !important;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
378
  }
379
- .coda-go[disabled]{ filter:grayscale(.55) brightness(.7); opacity:.5;
380
- box-shadow:none !important; }
381
 
 
382
  .coda-demo{
383
- background:rgba(255,255,255,.04) !important; color:var(--sub) !important;
384
- border:1px solid var(--glass-brd) !important; border-radius:12px !important;
385
- font-weight:500 !important; transition:all .25s ease !important;
 
 
 
386
  }
387
  .coda-demo:hover{
388
- color:#eaf6ff !important; border-color:var(--cyan) !important;
389
- background:rgba(111,224,245,.08) !important;
390
- box-shadow:0 0 22px rgba(111,224,245,.22) !important;
391
  }
392
 
393
- /* ---------- inputs ---------- */
394
- .coda-glass input[type=text], .coda-glass textarea{
 
 
 
 
395
  transition:box-shadow .2s, border-color .2s !important;
396
  }
397
- .coda-glass input[type=text]:focus, .coda-glass textarea:focus{
 
 
 
398
  border-color:var(--cyan) !important;
399
- box-shadow:0 0 0 1px var(--cyan),0 0 22px rgba(111,224,245,.22) !important;
 
 
 
400
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
401
  input[type=range]{ accent-color:var(--cyan); }
402
 
403
- /* upload dropzone β€” inviting, glowing */
404
- .coda-drop{ border-radius:16px !important; transition:box-shadow .3s, transform .3s; }
405
- .coda-drop:hover{ box-shadow:0 0 30px rgba(124,100,255,.18) !important; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
406
 
407
- /* the output player: replace Gradio's stark 3px white empty-state frame with a
408
- soft, on-brand panel so the result area looks intentional before a song loads */
409
  .coda-player{
410
- border:1px solid rgba(150,160,220,0.16) !important; border-width:1px !important;
411
- border-radius:14px !important;
412
  background:linear-gradient(180deg, rgba(111,224,245,.04), rgba(169,139,255,.04)) !important;
 
 
 
 
 
 
 
 
413
  }
 
 
 
 
 
 
 
414
 
415
- /* finished-song card gets a gentle living glow */
416
  #coda-result{ position:relative; }
417
  #coda-result::after{
418
  content:''; position:absolute; inset:-1px; border-radius:20px; pointer-events:none;
@@ -421,9 +645,37 @@ input[type=range]{ accent-color:var(--cyan); }
421
  }
422
  @keyframes codaBreath{ 0%,100%{opacity:.4} 50%{opacity:.9} }
423
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
424
  /* ---------- footer ---------- */
425
- #coda-foot{ text-align:center; color:#6b7388; font-size:.85rem; margin-top:18px;
426
- line-height:1.7; }
427
  #coda-foot strong{ color:var(--violet); }
428
  .coda-badge{
429
  display:inline-block; margin-top:8px; padding:5px 14px; border-radius:999px;
@@ -431,11 +683,238 @@ input[type=range]{ accent-color:var(--cyan); }
431
  border:1px solid var(--glass-brd); background:rgba(255,255,255,.03);
432
  }
433
 
434
- /* respect reduced-motion preferences */
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
435
  @media (prefers-reduced-motion: reduce){
436
- .coda-aurora,#coda-eq span,.coda-particles span,.coda-label::before,
437
- #coda-result::after,#coda-title{ animation:none !important; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
438
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
439
  """
440
 
441
  # ethereal hero spectrum: a frequency-spectrum bar field with a centered
@@ -483,6 +962,7 @@ POINTER_JS = """
483
  }
484
  """
485
 
 
486
  with gr.Blocks(title="CODA") as app:
487
  # ambient layers (aurora + cursor glow + drifting particles) live behind
488
  # everything via position:fixed / negative z-index, so they never affect layout.
@@ -505,13 +985,18 @@ with gr.Blocks(title="CODA") as app:
505
  gr.HTML("<div class='coda-label'>Your clip</div>")
506
  audio_input = gr.Audio(
507
  label="Your unfinished clip", type="filepath",
508
- sources=["upload"], elem_classes="coda-drop")
 
 
 
 
509
  demo_btn = gr.Button("🎧 Try the demo β€” PUSHBACK (via TikTok)",
510
  size="sm", elem_classes="coda-demo")
511
  total_slider = gr.Slider(
512
  MIN_TOTAL, MAX_TOTAL, value=DEFAULT_TOTAL, step=1,
513
  label="Finished length (seconds)",
514
- info="Total length of the finished track. Longer = a bit slower.")
 
515
  # Hidden mirror of the slider, and the field finish_song actually
516
  # reads. Two reasons it's separate from the slider: (1) some Gradio
517
  # frontends snap a slider back to its minimum on the 2nd+ submit,
@@ -523,20 +1008,29 @@ with gr.Blocks(title="CODA") as app:
523
  vibe = gr.Textbox(
524
  label="Describe the vibe (optional)", lines=1,
525
  placeholder="e.g. warm lo-fi, vinyl crackle, mellow piano",
526
- info="Leave empty for pure audio-led continuation.")
 
527
  remaster = gr.Checkbox(
528
  value=False, label="Remaster my part too",
529
  info="Apply the same lo-fi cleanup to your original section so "
530
- "the whole track sits at one level.")
 
531
  finish_btn = gr.Button("✨ Finish this song", variant="primary",
532
  interactive=False, elem_classes="coda-go")
533
 
534
  with gr.Column(scale=1):
535
- info_md = gr.Markdown(visible=False, elem_classes="coda-card")
 
 
536
  # built visible inside the result group: Gradio drops visible=False
537
  # audio players at build time, so we never toggle player visibility.
538
  with gr.Group(elem_classes="coda-card", elem_id="coda-result"):
539
- gr.Markdown("### Your finished song")
 
 
 
 
 
540
  # No autoplay: an autoplay output <audio> element is reused across
541
  # runs and can fail to reload its src on the 2nd+ generation β€” the
542
  # result then looks like it "didn't play" / "didn't update". A
@@ -544,12 +1038,15 @@ with gr.Blocks(title="CODA") as app:
544
  # play, which also avoids browsers blocking autoplay-with-sound.
545
  output_audio = gr.Audio(label="", type="filepath",
546
  interactive=False,
547
- elem_classes="coda-player")
 
 
 
548
  # built visible with an empty value (an empty Markdown renders
549
  # nothing). finish_song fills it in the SAME event that sets the
550
  # audio, so there's no second `.then` to toggle visibility β€” one
551
  # event = one completion signal, so the spinner always clears.
552
- summary_md = gr.Markdown()
553
 
554
  gr.HTML(
555
  "<div id='coda-foot'>Demo clip: <strong>PUSHBACK</strong> (via TikTok), "
@@ -572,10 +1069,16 @@ with gr.Blocks(title="CODA") as app:
572
  # A chained second event was a place the "stuck processing" state could hang
573
  # if the SSE stream blipped (ClientDisconnect) between the two events.
574
  # Read the length from length_input (not the slider directly) β€” see above.
 
 
 
 
 
 
575
  finish_btn.click(
576
  fn=finish_song,
577
  inputs=[audio_input, length_input, vibe, remaster],
578
- outputs=[output_audio, summary_md])
579
 
580
  # cosmetic only: start the cursor-following glow once the page is ready.
581
  app.load(js=POINTER_JS)
 
86
  print(f"[coda] preload failed ({_e}); will lazy-load", flush=True)
87
 
88
 
89
+ def _readout_html(info, quality):
90
+ """The 'CODA heard' fingerprint as a premium audio-tool readout (a HUD of
91
+ labelled value cells), not a text dump. Rendered into a gr.HTML panel."""
92
+ cells = [
93
+ ("KEY", str(info["key"]), ""),
94
+ ("TEMPO", str(info["bpm"]), "BPM"),
95
+ ("METER", str(info["time_signature"]), ""),
96
+ ("LENGTH", f"{info['duration']}", "S"),
97
  ]
98
+ cell_html = "".join(
99
+ f"<div class='coda-hud-cell'>"
100
+ f"<div class='coda-hud-k'>{k}</div>"
101
+ f"<div class='coda-hud-v'>{v}<span class='coda-hud-u'>{u}</span></div>"
102
+ f"</div>"
103
+ for (k, v, u) in cells)
104
+ note = ""
105
  if quality and quality.get("lofi"):
106
+ note = (f"<div class='coda-hud-note'>"
107
+ f"<span class='coda-hud-tag'>LO-FI ~{quality['bandwidth_hz']/1000:.0f}KHZ</span>"
108
+ f"CODA cleans a copy before it listens, so it follows the "
109
+ f"<em>song</em>, not the hiss</div>")
110
+ return (
111
+ "<div class='coda-hud'>"
112
+ "<div class='coda-hud-head'><span class='coda-hud-dot'></span>"
113
+ "CODA&nbsp;HEARD<span class='coda-hud-live'>ANALYZED</span></div>"
114
+ f"<div class='coda-hud-grid'>{cell_html}</div>"
115
+ f"{note}</div>")
116
+
117
+
118
+ def _warn_html(title, body):
119
+ """A clip-rejected / read-error message styled to match the readout HUD."""
120
+ return ("<div class='coda-hud coda-hud-warn'>"
121
+ f"<div class='coda-hud-head'><span class='coda-hud-dot'></span>{title}</div>"
122
+ f"<div class='coda-hud-note'>{body}</div></div>")
123
+
124
+
125
+ # the live "what is CODA doing right now" overlay shown over the result panel
126
+ # during generation. finish_song yields these at each real pipeline milestone.
127
+ _STAGE_STEPS = ["listening", "composing", "splicing"]
128
+
129
+
130
+ def _stage_html(phase, label, sub=""):
131
+ bars = "".join("<span></span>" for _ in range(9))
132
+ steps = "".join(
133
+ f"<span class='coda-step{' on' if _STAGE_STEPS.index(phase) >= i else ''}"
134
+ f"{' now' if _STAGE_STEPS.index(phase) == i else ''}'></span>"
135
+ if phase in _STAGE_STEPS else "<span class='coda-step'></span>"
136
+ for i in range(len(_STAGE_STEPS)))
137
+ return (
138
+ f"<div class='coda-stage' data-phase='{phase}'>"
139
+ "<div class='coda-stage-core'>"
140
+ "<div class='coda-stage-ring'></div>"
141
+ "<div class='coda-stage-ring r2'></div>"
142
+ f"<div class='coda-stage-bars'>{bars}</div>"
143
+ "</div>"
144
+ f"<div class='coda-stage-label'>{label}<span class='coda-stage-dots'></span></div>"
145
+ f"<div class='coda-stage-sub'>{sub}</div>"
146
+ f"<div class='coda-stage-steps'>{steps}</div>"
147
+ "</div>")
148
 
149
 
150
  def analyze_on_upload(audio_path):
 
160
  # least MIN_NEW seconds of new audio. Block over-long clips here, with a
161
  # clear message, instead of failing at generation time.
162
  if info["duration"] > engine.MAX_SOURCE_SECONDS:
163
+ msg = _warn_html(
164
+ "Clip too long",
165
+ f"That clip is {info['duration']:.0f}s. CODA continues clips up to "
166
+ f"{engine.MAX_SOURCE_SECONDS:.0f}s (Stable Audio 3's "
167
+ f"{engine.MAX_TOTAL_SECONDS:.0f}s total cap). Trim it shorter and "
168
+ f"re-upload.")
169
  return gr.update(value=msg, visible=True), gr.update(interactive=False)
170
+ html = _readout_html(info, quality)
171
+ return gr.update(value=html, visible=True), gr.update(interactive=True)
172
  except Exception as e:
173
  print(f"[coda] analysis failed ({e})", flush=True)
174
+ return (gr.update(value=_warn_html("Couldn't read that file", str(e)),
175
+ visible=True),
176
  gr.update(interactive=False))
177
 
178
 
 
199
  def finish_song(audio_path, total_seconds, vibe, remaster,
200
  progress=gr.Progress()):
201
  """Orchestrate the job: CPU prep -> GPU continuation -> CPU splice.
202
+
203
+ A GENERATOR so the UI gets a real, dramatic, server-synced wait state: it
204
+ yields a live 'stage' overlay (gr.HTML) at each genuine pipeline milestone β€”
205
+ listening / composing / splicing β€” and finally yields the finished audio +
206
+ summary while clearing the overlay. This is still ONE event (the spinner
207
+ clears on StopIteration), so it keeps the single-completion property; the
208
+ earlier 'stuck spinner' risk was a CHAINED second `.then` event, which this
209
+ is not. The exact pipeline contract and call order are unchanged:
210
+ enhance_to_tempfile -> fingerprint -> engine.continue_audio -> stitch.stitch.
211
+
212
+ Yields/returns 3-tuples for (output_audio, summary_md, stage_html)."""
213
  if not audio_path:
214
  raise gr.Error("Upload a clip (or load the PUSHBACK demo) first.")
215
 
216
  total_seconds = int(total_seconds)
 
 
 
 
 
 
 
 
 
 
 
 
 
217
  try:
218
+ # --- BEAT: listening (CPU prep, outside the GPU window) ---
219
+ progress(0.05, desc="Listening to your clip…")
220
+ yield gr.update(), gr.update(), _stage_html(
221
+ "listening", "Listening to your clip",
222
+ "reading key Β· tempo Β· groove")
223
+ listen_path = enhance_to_tempfile(audio_path)
224
+ info = fingerprint(listen_path)
225
+ # the pristine original β€” what the listener hears for the first stretch
226
+ original, sr = librosa.load(audio_path, sr=None, mono=False)
227
+ if remaster:
228
+ progress(0.15, desc="Remastering your part…")
229
+ original = enhance_audio(original, sr)
230
+
231
+ # --- BEAT: composing (holds during the ONLY @spaces.GPU call) ---
232
+ progress(0.35, desc="Composing the continuation…")
233
+ yield gr.update(), gr.update(), _stage_html(
234
+ "composing", "Composing the continuation",
235
+ f"Stable Audio 3 Β· extending in {info['key']} at {info['bpm']} BPM")
236
+ try:
237
+ new_tail, source_seconds, SR = _continue_on_gpu(
238
+ listen_path, total_seconds, vibe)
239
+ except ValueError as e:
240
+ # e.g. the clip is a full-length track, not a clip to continue
241
+ raise gr.Error(str(e))
242
+
243
+ # --- BEAT: splicing (CPU splice + write, outside the GPU window) ---
244
+ progress(0.9, desc="Splicing onto your original…")
245
+ yield gr.update(), gr.update(), _stage_html(
246
+ "splicing", "Splicing onto your original",
247
+ "level-matched crossfade Β· clean closing fade")
248
+ out = stitch.stitch(original, sr, new_tail, source_seconds)
249
+
250
+ out_path = os.path.join(tempfile.mkdtemp(), "coda_finished.wav")
251
+ sf.write(out_path, out.T, SR, subtype="PCM_16")
252
+
253
+ progress(1.0, desc="Done.")
254
+ total = out.shape[-1] / SR
255
+ added = total - source_seconds
256
+ vibe_note = f" guided by *β€œ{vibe.strip()}”*" if (vibe or "").strip() else ""
257
+ summary = (
258
+ f"### Finished β€” {total:.0f}s\n"
259
+ f"Your **{source_seconds:.0f}s** clip in **{info['key']}** at "
260
+ f"**{info['bpm']} BPM** continued for **~{added:.0f}s** more{vibe_note}, "
261
+ f"then crossfaded onto your original and faded to a clean close.\n\n"
262
+ f"*Stable Audio 3 generated the continuation as 44.1 kHz stereo in a "
263
+ f"single pass; your original recording plays untouched up to the seam.*"
264
+ )
265
+ # --- BEAT: reveal (set audio + summary, clear the overlay) ---
266
+ yield out_path, summary, ""
267
+ except gr.Error:
268
+ # clear the overlay so a failure never leaves a stuck 'composing' animation
269
+ yield gr.update(), gr.update(), ""
270
+ raise
271
 
272
 
273
  def load_demo():
 
308
  )
309
 
310
  CSS = """
311
+ /* ===================================================================== CODA β€” premium audio-instrument treatment. This is a COMPLETE replacement for the CSS string in app.py. It keeps every existing ambient layer / hero / HUD / stage rule that already works, retargets only REAL class names (verified against the app.py source + the Gradio 6.17 StaticAudio/Upload compiled assets), and adds the milled-metal light model, the LCD power-on, the milled controls, the illuminated key states, and the gold MASTER reveal. No new font import (theme already loads Space Grotesk + Inter + JetBrains Mono). ===================================================================== */
312
  @import url('https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=Inter:wght@400;500;600&display=swap');
313
 
314
  :root{
 
317
  --glass:rgba(18,20,36,0.55);
318
  --glass-brd:rgba(150,160,220,0.14);
319
  --mx:50%; --my:28%;
320
+ /* milled-metal light model (cheap, no textures) */
321
+ --bevel-hi:rgba(255,255,255,.06);
322
+ --bevel-lo:rgba(0,0,0,.55);
323
+ --well:linear-gradient(180deg,rgba(0,0,0,.34),rgba(0,0,0,.16));
324
+ --ring:rgba(150,160,220,.14);
325
+ --ring-hot:rgba(111,224,245,.45);
326
+ --ease-expo:cubic-bezier(.16,1,.3,1);
327
+ --ink:#0a0a16;
328
  }
329
 
330
+ /* ---------- ambient background layers (UNCHANGED β€” already GOOD) ---------- */
331
+ .coda-aurora,.coda-glow,.coda-particles{ position:fixed; inset:0; pointer-events:none; overflow:hidden; }
 
 
332
  .coda-aurora{
333
  z-index:-3;
334
  background:
 
367
  .gradio-container{ max-width:1080px !important; margin:0 auto !important; }
368
  .gradio-container, .gradio-container *{ font-family:'Inter',ui-sans-serif,system-ui; }
369
 
370
+ /* ---------- hero (wordmark + spectrum power-on) ---------- */
371
  #coda-head{ text-align:center; padding:40px 0 4px; position:relative; }
372
  #coda-title{
373
  font-family:'Space Grotesk',sans-serif; font-weight:700;
 
392
  transform-origin:center; transform:scaleY(.2);
393
  background:linear-gradient(180deg,#7be8ff,#a98bff);
394
  box-shadow:0 0 9px rgba(111,224,245,.45);
395
+ /* one-shot power-on, THEN the infinite breathing bar (additive list) */
396
+ animation:codaPowerOn .7s var(--ease-expo) both, codaBar 1.2s ease-in-out infinite .7s;
397
+ animation-name:codaPowerOn, codaBar;
398
  }
399
+ @keyframes codaPowerOn{ from{transform:scaleY(.04); opacity:.25} to{opacity:.92} }
400
  @keyframes codaBar{ 0%,100%{transform:scaleY(.16)} 50%{transform:scaleY(1)} }
401
  #coda-rule{
402
  height:1px; border:0; max-width:260px; margin:18px auto 6px;
 
407
  margin:0 auto 6px; font-size:1rem; line-height:1.6; }
408
  #coda-intro strong{ color:#dfe4ff; font-weight:600; }
409
 
410
+ /* ---------- glass panels -> milled faceplates ---------- */
411
  .coda-glass, .coda-card{
412
  background:var(--glass) !important;
413
  border:1px solid var(--glass-brd) !important;
414
  border-radius:20px !important;
415
  backdrop-filter:blur(16px) saturate(135%);
416
  -webkit-backdrop-filter:blur(16px) saturate(135%);
417
+ /* light model: top-bevel lip + recessed body + outer contact drop */
418
+ box-shadow:
419
+ inset 0 1px 0 var(--bevel-hi),
420
+ inset 0 -1px 0 var(--bevel-lo),
421
+ 0 12px 40px rgba(0,0,0,.5) !important;
422
+ padding:22px !important; position:relative;
423
  }
424
+ .coda-glass{ animation:codaRise .85s var(--ease-expo) .55s both; }
425
+ .coda-card{ animation:codaRise .85s var(--ease-expo) .63s both; }
426
+ @keyframes codaRise{ from{opacity:0; transform:translateY(16px) scale(.99)} to{opacity:1; transform:none} }
427
 
428
+ /* section micro-labels (engraved legend + live status lamp) */
429
  .coda-label{
430
  color:var(--cyan); text-transform:uppercase; letter-spacing:.18em;
431
+ font-size:.72rem; font-weight:600; margin:0 0 10px; display:flex;
432
  align-items:center; gap:.5rem;
433
  }
434
  .coda-label::before{
 
438
  }
439
  @keyframes codaPulse{ 0%,100%{opacity:.5; transform:scale(.85)} 50%{opacity:1; transform:scale(1.15)} }
440
 
441
+ /* blend inner gradio blocks into the glass; mute redundant helper text */
442
+ .coda-glass .block, .coda-card .block{ box-shadow:none !important; background:transparent !important; }
443
+ .coda-glass .info, .coda-card .info{ color:var(--sub) !important; font-size:.78rem !important; opacity:.85; }
444
  .coda-card h3{
445
  color:var(--cyan); text-transform:uppercase; letter-spacing:.16em;
446
  font-size:.74rem; font-weight:600; margin:.1rem 0 .7rem;
447
  }
448
 
449
+ /* ---------- primary FINISH key: disarmed -> armed -> held ---------- */
450
  .coda-go, .coda-go button{
451
  background:linear-gradient(100deg,#6fe0f5,#a98bff 62%,#cf9bff) !important;
452
  background-size:180% auto !important;
453
+ color:var(--ink) !important; font-weight:600 !important; letter-spacing:.03em;
454
+ border:0 !important; border-radius:14px !important; min-height:48px;
455
+ box-shadow:
456
+ inset 0 1px 0 rgba(255,255,255,.42),
457
+ inset 0 -2px 4px rgba(0,0,0,.25),
458
+ 0 0 0 1px rgba(255,255,255,.08),
459
+ 0 8px 26px rgba(124,100,255,.34) !important;
460
+ transition:transform .25s var(--ease-expo), box-shadow .25s,
461
  filter .25s, background-position .6s !important;
462
  }
463
+ /* IGNITE on arm (the real interactive flip removes [disabled]) */
464
+ .coda-go:not([disabled]){ animation:codaCtaSheen .85s ease-out 1; }
465
+ @keyframes codaCtaSheen{ 0%{background-position:120% center} 100%{background-position:0% center} }
466
  .coda-go:hover:not([disabled]){
467
  transform:translateY(-2px); filter:brightness(1.06);
468
  background-position:right center !important;
469
+ box-shadow:
470
+ inset 0 1px 0 rgba(255,255,255,.5),
471
+ 0 0 0 1px rgba(255,255,255,.16),
472
+ 0 14px 40px rgba(124,100,255,.5),
473
+ 0 0 34px rgba(111,224,245,.38) !important;
474
+ }
475
+ .coda-go:active:not([disabled]){
476
+ transform:translateY(0) scale(.99);
477
+ box-shadow:
478
+ inset 0 2px 6px rgba(0,0,0,.4),
479
+ 0 0 0 1px rgba(255,255,255,.12),
480
+ 0 0 30px rgba(124,100,255,.45) !important;
481
+ }
482
+ .coda-go[disabled]{
483
+ filter:grayscale(.6) brightness(.6); opacity:.5;
484
+ box-shadow:inset 0 1px 2px rgba(0,0,0,.5) !important;
485
+ }
486
+ /* held/lit while the engine runs β€” keyed off the live stage overlay via :has(),
487
+ so it needs no JS to set/clear and is correct for the whole run + after. */
488
+ .gradio-container:has(.coda-stage) .coda-go,
489
+ .gradio-container:has(.coda-stage) .coda-go button{
490
+ filter:brightness(.94);
491
+ box-shadow:inset 0 2px 8px rgba(0,0,0,.45),0 0 26px rgba(169,139,255,.45) !important;
492
  }
 
 
493
 
494
+ /* ---------- demo button (quiet utility key) ---------- */
495
  .coda-demo{
496
+ background:linear-gradient(180deg,rgba(255,255,255,.045),rgba(255,255,255,.02)) !important;
497
+ color:var(--sub) !important;
498
+ border:1px solid var(--glass-brd) !important; border-radius:11px !important;
499
+ font-weight:500 !important; letter-spacing:.02em;
500
+ box-shadow:inset 0 1px 0 rgba(255,255,255,.05), inset 0 -1px 2px rgba(0,0,0,.4) !important;
501
+ transition:all .25s ease !important;
502
  }
503
  .coda-demo:hover{
504
+ color:#eaf6ff !important; border-color:var(--ring-hot) !important;
505
+ background:linear-gradient(180deg,rgba(111,224,245,.10),rgba(111,224,245,.04)) !important;
506
+ box-shadow:inset 0 1px 0 rgba(255,255,255,.06),0 0 22px rgba(111,224,245,.20) !important;
507
  }
508
 
509
+ /* ---------- vibe textbox (carved + arming focus) ---------- */
510
+ .coda-vibe textarea, .coda-vibe input[type=text]{
511
+ background:var(--well) !important;
512
+ border:1px solid var(--ring) !important; border-radius:10px !important;
513
+ color:var(--text) !important; caret-color:var(--cyan);
514
+ box-shadow:inset 0 2px 6px rgba(0,0,0,.5), inset 0 1px 0 rgba(255,255,255,.04) !important;
515
  transition:box-shadow .2s, border-color .2s !important;
516
  }
517
+ .coda-vibe textarea::placeholder, .coda-vibe input[type=text]::placeholder{
518
+ color:#6b7388 !important; letter-spacing:.02em; opacity:.9;
519
+ }
520
+ .coda-vibe textarea:focus, .coda-vibe input[type=text]:focus{
521
  border-color:var(--cyan) !important;
522
+ box-shadow:
523
+ inset 0 0 0 1px rgba(111,224,245,.35),
524
+ inset 0 2px 6px rgba(0,0,0,.45),
525
+ 0 0 22px rgba(111,224,245,.18) !important;
526
  }
527
+
528
+ /* ---------- length slider (milled fader, webkit + moz) ---------- */
529
+ .coda-slider input[type=range]{
530
+ -webkit-appearance:none; appearance:none; height:6px; border-radius:4px;
531
+ background:rgba(10,11,22,.7);
532
+ box-shadow:inset 0 1px 3px rgba(0,0,0,.7), inset 0 -1px 0 rgba(255,255,255,.04);
533
+ accent-color:var(--cyan); /* native fill is always correct β€” no JS --val */
534
+ }
535
+ .coda-slider input[type=range]::-webkit-slider-thumb{
536
+ -webkit-appearance:none; width:20px; height:20px; border-radius:50%;
537
+ background:radial-gradient(circle at 50% 35%,#eafcff,#6fe0f5 58%,#3f8fa8);
538
+ border:1px solid rgba(255,255,255,.16);
539
+ box-shadow:0 2px 4px rgba(0,0,0,.6), inset 0 1px 0 rgba(255,255,255,.22),
540
+ inset 0 -1px 2px rgba(0,0,0,.55), 0 0 0 0 rgba(111,224,245,0);
541
+ transition:transform .14s, box-shadow .14s; cursor:grab;
542
+ }
543
+ .coda-slider input[type=range]:active::-webkit-slider-thumb{
544
+ transform:scale(1.1); cursor:grabbing;
545
+ box-shadow:0 2px 6px rgba(0,0,0,.7), inset 0 1px 0 rgba(255,255,255,.28),
546
+ 0 0 0 4px rgba(111,224,245,.18), 0 0 16px rgba(111,224,245,.55);
547
+ }
548
+ .coda-slider input[type=range]::-moz-range-thumb{
549
+ width:20px; height:20px; border-radius:50%; border:1px solid rgba(255,255,255,.16);
550
+ background:radial-gradient(circle at 50% 35%,#eafcff,#6fe0f5 58%,#3f8fa8);
551
+ box-shadow:0 2px 4px rgba(0,0,0,.6), inset 0 1px 0 rgba(255,255,255,.22);
552
+ transition:transform .14s, box-shadow .14s;
553
+ }
554
+ .coda-slider input[type=range]:active::-moz-range-thumb{
555
+ transform:scale(1.1);
556
+ box-shadow:0 0 0 4px rgba(111,224,245,.18), 0 0 16px rgba(111,224,245,.55);
557
+ }
558
+ .coda-slider input[type=range]::-moz-range-track{ height:6px; border-radius:4px; background:rgba(10,11,22,.7); }
559
+ /* the companion number box = honest mono value chip */
560
+ .coda-slider input[type=number]{
561
+ background:rgba(10,11,22,.6) !important; border:1px solid var(--ring) !important;
562
+ box-shadow:inset 0 1px 3px rgba(0,0,0,.5) !important;
563
+ color:#dfe4ff !important; border-radius:8px !important;
564
+ font-family:'JetBrains Mono',ui-monospace,monospace !important;
565
+ font-variant-numeric:tabular-nums;
566
+ }
567
+
568
+ /* ---------- remaster checkbox (backlit toggle) ---------- */
569
+ .coda-check input[type=checkbox]{
570
+ -webkit-appearance:none; appearance:none; width:18px; height:18px;
571
+ border-radius:5px; position:relative; cursor:pointer; vertical-align:middle;
572
+ background:linear-gradient(180deg,rgba(0,0,0,.42),rgba(0,0,0,.22));
573
+ border:1px solid rgba(150,160,220,.2);
574
+ box-shadow:inset 0 1px 2px rgba(0,0,0,.6), inset 0 -1px 0 rgba(255,255,255,.04);
575
+ transition:background .2s, box-shadow .2s, border-color .2s;
576
+ }
577
+ .coda-check input[type=checkbox]:hover{ border-color:var(--ring-hot); }
578
+ .coda-check input[type=checkbox]:checked{
579
+ background:linear-gradient(135deg,#7be8ff,#a98bff);
580
+ border-color:transparent;
581
+ box-shadow:inset 0 1px 0 rgba(255,255,255,.3), 0 0 14px rgba(111,224,245,.45);
582
+ }
583
+ .coda-check input[type=checkbox]:checked::after{
584
+ content:''; position:absolute; left:5px; top:1px; width:5px; height:10px;
585
+ border:solid var(--ink); border-width:0 2px 2px 0;
586
+ transform:rotate(42deg) scale(0);
587
+ animation:codaCheck .18s cubic-bezier(.34,1.56,.64,1) forwards;
588
+ }
589
+ @keyframes codaCheck{ to{ transform:rotate(42deg) scale(1) } }
590
+
591
+ /* generic range accent (covers any non-scoped range, e.g. player seek) */
592
  input[type=range]{ accent-color:var(--cyan); }
593
 
594
+ /* ---------- upload dropzone -> recessed INPUT channel ---------- */
595
+ .coda-drop{
596
+ border-radius:16px !important;
597
+ background:radial-gradient(120% 120% at 50% 0%, rgba(111,224,245,.05), rgba(10,11,22,.5)) !important;
598
+ box-shadow:inset 0 2px 10px rgba(0,0,0,.5), inset 0 1px 0 rgba(255,255,255,.05) !important;
599
+ transition:box-shadow .3s, transform .3s var(--ease-expo);
600
+ }
601
+ /* Gradio's Upload inner empty-state = .wrap (verified). Tame the stark frame. */
602
+ .coda-drop .wrap{
603
+ border:1.5px dashed rgba(111,224,245,.22) !important; border-radius:13px !important;
604
+ background:transparent !important; transition:border-color .3s, background .3s;
605
+ }
606
+ .coda-drop .wrap:hover{ border-color:var(--ring-hot) !important; background:rgba(111,224,245,.04) !important; }
607
+ .coda-drop:hover{ box-shadow:inset 0 2px 10px rgba(0,0,0,.5), 0 0 30px rgba(124,100,255,.18) !important; }
608
+ .coda-drop .wrap *{ color:var(--sub) !important; letter-spacing:.03em; }
609
+ .coda-drop svg{ filter:drop-shadow(0 0 8px rgba(111,224,245,.3)); opacity:.8; }
610
+ /* loaded/hot channel */
611
+ .coda-has-clip .coda-drop, .coda-drop:has(audio){
612
+ box-shadow:inset 0 1px 0 rgba(255,255,255,.06), 0 0 22px rgba(111,224,245,.14) !important;
613
+ }
614
+ .coda-has-clip .coda-drop .wrap, .coda-drop:has(audio) .wrap{
615
+ border-style:solid !important; border-color:rgba(111,224,245,.3) !important;
616
+ }
617
 
618
+ /* ---------- output player -> integrated deck (REAL StaticAudio classes) ---------- */
 
619
  .coda-player{
620
+ border:1px solid rgba(150,160,220,0.16) !important; border-radius:14px !important;
 
621
  background:linear-gradient(180deg, rgba(111,224,245,.04), rgba(169,139,255,.04)) !important;
622
+ box-shadow:inset 0 1px 0 rgba(255,255,255,.05), inset 0 -1px 0 rgba(0,0,0,.5),
623
+ inset 0 0 30px rgba(0,0,0,.3) !important;
624
+ }
625
+ .coda-player .standard-player, .coda-player .component-wrapper{ padding:14px !important; }
626
+ /* recess the waveform + scrubber into a well (canvas color is locked β€” frame, don't recolor) */
627
+ .coda-player .waveform-container{
628
+ background:rgba(10,11,22,.4) !important; border-radius:10px !important;
629
+ box-shadow:inset 0 1px 4px rgba(0,0,0,.5);
630
  }
631
+ .coda-player .timestamps{
632
+ font-family:'JetBrains Mono',ui-monospace,monospace !important;
633
+ font-variant-numeric:tabular-nums; color:var(--sub) !important; letter-spacing:.02em;
634
+ }
635
+ .coda-player .play-pause-button{ color:var(--cyan) !important; fill:var(--cyan) !important;
636
+ transition:filter .2s, transform .12s; }
637
+ .coda-player .play-pause-button:hover{ filter:drop-shadow(0 0 8px rgba(111,224,245,.5)); transform:scale(1.06); }
638
 
639
+ /* ---------- finished-song card: living glow (UNCHANGED base) ---------- */
640
  #coda-result{ position:relative; }
641
  #coda-result::after{
642
  content:''; position:absolute; inset:-1px; border-radius:20px; pointer-events:none;
 
645
  }
646
  @keyframes codaBreath{ 0%,100%{opacity:.4} 50%{opacity:.9} }
647
 
648
+ /* ---------- REVEAL: gold MASTER escalation (only on a real finished result) ---------- */
649
+ /* keyed off the output <audio> appearing β€” fully CSS, no JS reveal event needed.
650
+ the one-shot flourish plays as the rule first matches (when the track lands). */
651
+ #coda-result:has(.coda-player audio){
652
+ --color-accent:var(--gold); /* play icon + seek progress -> gold */
653
+ animation:codaReveal 1.2s var(--ease-expo) both;
654
+ }
655
+ #coda-result:has(.coda-player audio) .coda-player{
656
+ border-top-color:rgba(246,214,138,.5) !important;
657
+ box-shadow:inset 0 1px 0 rgba(246,214,138,.28), inset 0 -1px 0 rgba(0,0,0,.5),
658
+ 0 0 30px rgba(246,214,138,.10) !important;
659
+ }
660
+ #coda-result:has(.coda-player audio) .play-pause-button{ color:var(--gold) !important; fill:var(--gold) !important;
661
+ filter:drop-shadow(0 0 10px rgba(246,214,138,.5)); }
662
+ #coda-result:has(.coda-player audio) .coda-result-head{ color:var(--gold); }
663
+ #coda-result:has(.coda-player audio) .coda-result-dot{ background:var(--gold); box-shadow:0 0 10px var(--gold); }
664
+ #coda-result:has(.coda-player audio)::after{
665
+ box-shadow:0 0 0 1px rgba(246,214,138,.16), 0 0 46px rgba(246,214,138,.09) inset;
666
+ animation:codaBreath 5.5s ease-in-out infinite; /* warmer, slower */
667
+ }
668
+ /* one-shot reveal flourish (warm). Played via the :has() rule above when the
669
+ finished <audio> first appears; this class is kept as a reduced-motion hook. */
670
+ .coda-reveal{ animation:codaReveal 1.1s var(--ease-expo) both; }
671
+ @keyframes codaReveal{
672
+ 0%{ transform:scale(.985); }
673
+ 35%{ transform:scale(1.012); box-shadow:0 0 0 1px rgba(246,214,138,.5), 0 0 60px rgba(246,214,138,.4); }
674
+ 100%{ transform:scale(1); }
675
+ }
676
+
677
  /* ---------- footer ---------- */
678
+ #coda-foot{ text-align:center; color:#6b7388; font-size:.85rem; margin-top:18px; line-height:1.7; }
 
679
  #coda-foot strong{ color:var(--violet); }
680
  .coda-badge{
681
  display:inline-block; margin-top:8px; padding:5px 14px; border-radius:999px;
 
683
  border:1px solid var(--glass-brd); background:rgba(255,255,255,.03);
684
  }
685
 
686
+ /* ---------- kill default-Gradio tells ---------- */
687
+ footer{ display:none !important; }
688
+ .coda-readout .progress-text, #coda-result .progress-text,
689
+ .coda-readout .eta-bar, #coda-result .eta-bar{ display:none !important; }
690
+
691
+ /* ---------- 'CODA heard' readout -> backlit LCD HUD (REAL .coda-hud* classes) ---------- */
692
+ .coda-readout{ position:relative; }
693
+ .coda-hud{
694
+ font-family:'Space Grotesk',sans-serif; position:relative; overflow:hidden;
695
+ padding:16px 18px; border-radius:14px;
696
+ background:linear-gradient(180deg,#0b1119,#070b12);
697
+ border:1px solid rgba(111,224,245,.14);
698
+ box-shadow:inset 0 1px 0 rgba(255,255,255,.04), inset 0 0 0 1px rgba(0,0,0,.6),
699
+ inset 0 0 40px rgba(0,0,0,.5), 0 0 26px rgba(111,224,245,.05);
700
+ animation:codaLcdOn .42s ease-out both;
701
+ }
702
+ /* faint scanline overlay (cheap) */
703
+ .coda-hud::after{
704
+ content:''; position:absolute; inset:0; pointer-events:none; opacity:.45;
705
+ background:repeating-linear-gradient(0deg, rgba(0,0,0,.16) 0, rgba(0,0,0,.16) 1px, transparent 1px, transparent 3px);
706
+ }
707
+ /* one-shot scan-sweep on power-on */
708
+ .coda-hud::before{
709
+ content:''; position:absolute; top:0; left:-40%; width:40%; height:100%; pointer-events:none;
710
+ background:linear-gradient(90deg, transparent, rgba(111,224,245,.18), transparent);
711
+ animation:codaScan .8s ease-out 1 both;
712
+ }
713
+ @keyframes codaLcdOn{ from{opacity:0; filter:brightness(.4)} to{opacity:1; filter:brightness(1)} }
714
+ @keyframes codaScan{ from{left:-40%} to{left:120%} }
715
+ .coda-hud-head{
716
+ display:flex; align-items:center; gap:.5rem; color:var(--cyan);
717
+ text-transform:uppercase; letter-spacing:.2em; font-size:.72rem; font-weight:600;
718
+ margin-bottom:14px; position:relative;
719
+ }
720
+ .coda-hud-dot{
721
+ width:7px; height:7px; border-radius:50%; background:var(--cyan);
722
+ box-shadow:0 0 10px var(--cyan); animation:codaPulse 2.4s ease-in-out infinite;
723
+ }
724
+ .coda-hud-live{
725
+ margin-left:auto; font-size:.6rem; letter-spacing:.18em; color:#7be8ff;
726
+ padding:3px 8px; border-radius:999px; border:1px solid rgba(111,224,245,.25);
727
+ background:rgba(111,224,245,.06);
728
+ }
729
+ .coda-hud-grid{ display:grid; grid-template-columns:1fr 1fr; gap:10px; position:relative; }
730
+ .coda-hud-cell{
731
+ position:relative; padding:12px 14px; border-radius:12px;
732
+ background:linear-gradient(180deg, rgba(0,0,0,.4), rgba(0,0,0,.2));
733
+ border:1px solid rgba(150,160,220,.12);
734
+ box-shadow:inset 0 1px 0 rgba(255,255,255,.05), inset 0 0 24px rgba(0,0,0,.4);
735
+ /* per-cell settle stagger (meter locking) */
736
+ animation:codaCellSettle .26s var(--ease-expo) both;
737
+ }
738
+ .coda-hud-cell:nth-child(2){ animation-delay:.06s }
739
+ .coda-hud-cell:nth-child(3){ animation-delay:.12s }
740
+ .coda-hud-cell:nth-child(4){ animation-delay:.18s }
741
+ @keyframes codaCellSettle{ from{opacity:0; transform:translateY(5px)} to{opacity:1; transform:none} }
742
+ .coda-hud-k{
743
+ color:var(--sub); text-transform:uppercase; letter-spacing:.16em;
744
+ font-size:.62rem; font-weight:600; margin-bottom:4px;
745
+ }
746
+ .coda-hud-v{
747
+ font-family:'JetBrains Mono','ui-monospace',monospace; color:#eaf0ff;
748
+ font-size:1.5rem; font-weight:500; line-height:1; display:flex;
749
+ align-items:baseline; gap:.3rem; font-variant-numeric:tabular-nums;
750
+ text-shadow:0 0 10px rgba(111,224,245,.25);
751
+ }
752
+ .coda-hud-u{ font-size:.7rem; color:var(--sub); letter-spacing:.1em; font-weight:600; }
753
+ .coda-hud-note{
754
+ margin-top:12px; color:var(--sub); font-size:.82rem; line-height:1.55; font-family:'Inter',sans-serif;
755
+ position:relative;
756
+ }
757
+ .coda-hud-note em{ color:#cfd6f5; font-style:italic; }
758
+ .coda-hud-tag{
759
+ display:inline-block; margin-right:.5rem; font-size:.62rem; letter-spacing:.12em;
760
+ color:var(--gold); padding:2px 8px; border-radius:6px; vertical-align:middle;
761
+ border:1px solid rgba(246,214,138,.25); background:rgba(246,214,138,.06);
762
+ }
763
+ .coda-hud-warn{ animation:none; }
764
+ .coda-hud-warn .coda-hud-head{ color:var(--gold); }
765
+ .coda-hud-warn .coda-hud-dot{ background:var(--gold); box-shadow:0 0 10px var(--gold); }
766
+
767
+ /* ---------- result panel head ---------- */
768
+ .coda-result-head{
769
+ display:flex; align-items:center; gap:.5rem; color:var(--cyan);
770
+ text-transform:uppercase; letter-spacing:.2em; font-size:.72rem; font-weight:600; margin-bottom:4px;
771
+ }
772
+ .coda-result-dot{
773
+ width:7px; height:7px; border-radius:50%; background:var(--violet);
774
+ box-shadow:0 0 10px var(--violet); animation:codaPulse 2.6s ease-in-out infinite;
775
+ }
776
+
777
+ /* ---------- summary -> mono gold spec plate (styles <strong>, NOT <code>) ---------- */
778
+ .coda-summary{ font-family:'Inter',sans-serif; }
779
+ .coda-summary h3{ color:var(--text); font-family:'Space Grotesk',sans-serif; letter-spacing:.01em; }
780
+ .coda-summary strong{
781
+ font-family:'JetBrains Mono',ui-monospace,monospace; color:var(--gold);
782
+ font-weight:500; font-variant-numeric:tabular-nums;
783
+ background:rgba(246,214,138,.06); border:1px solid rgba(246,214,138,.18);
784
+ border-radius:6px; padding:1px 7px;
785
+ }
786
+
787
+ /* ---------- live processing 'stage' overlay (REAL .coda-stage* classes) ---------- */
788
+ #coda-stage:empty{ display:none; }
789
+ .coda-stage{
790
+ position:absolute; inset:0; z-index:6; border-radius:18px;
791
+ display:flex; flex-direction:column; align-items:center; justify-content:center;
792
+ gap:14px; text-align:center; padding:20px;
793
+ background:radial-gradient(120% 120% at 50% 30%, rgba(20,16,40,.82), rgba(8,9,18,.92));
794
+ backdrop-filter:blur(10px); -webkit-backdrop-filter:blur(10px);
795
+ box-shadow:inset 0 1px 0 rgba(255,255,255,.05), inset 0 0 40px rgba(0,0,0,.5);
796
+ animation:codaFade .5s ease both;
797
+ }
798
+ @keyframes codaFade{ from{opacity:0} to{opacity:1} }
799
+ .coda-stage-core{ position:relative; width:96px; height:96px; display:flex; align-items:center; justify-content:center; }
800
+ .coda-stage-ring{
801
+ position:absolute; inset:0; border-radius:50%; border:2px solid transparent;
802
+ border-top-color:var(--cyan); border-right-color:rgba(111,224,245,.35);
803
+ box-shadow:0 0 28px rgba(111,224,245,.35); animation:codaSpin 1.5s linear infinite;
804
+ }
805
+ .coda-stage-ring.r2{
806
+ inset:14px; border-top-color:var(--violet); border-right-color:rgba(169,139,255,.3);
807
+ animation:codaSpin 2.1s linear infinite reverse;
808
+ }
809
+ @keyframes codaSpin{ to{ transform:rotate(360deg) } }
810
+ .coda-stage-bars{ display:flex; gap:3px; align-items:center; height:30px; }
811
+ .coda-stage-bars span{
812
+ width:3px; height:100%; border-radius:2px; transform:scaleY(.3); transform-origin:center;
813
+ background:linear-gradient(180deg,#7be8ff,#a98bff); animation:codaBar 1s ease-in-out infinite;
814
+ }
815
+ .coda-stage-bars span:nth-child(2){animation-delay:.1s}
816
+ .coda-stage-bars span:nth-child(3){animation-delay:.2s}
817
+ .coda-stage-bars span:nth-child(4){animation-delay:.3s}
818
+ .coda-stage-bars span:nth-child(5){animation-delay:.15s}
819
+ .coda-stage-bars span:nth-child(6){animation-delay:.25s}
820
+ .coda-stage-bars span:nth-child(7){animation-delay:.35s}
821
+ .coda-stage-bars span:nth-child(8){animation-delay:.2s}
822
+ .coda-stage-bars span:nth-child(9){animation-delay:.05s}
823
+ .coda-stage-label{
824
+ font-family:'Space Grotesk',sans-serif; font-size:1.05rem; font-weight:600;
825
+ color:#eef1ff; letter-spacing:.01em;
826
+ }
827
+ .coda-stage-dots::after{
828
+ content:'…'; animation:codaDots 1.4s steps(4,end) infinite;
829
+ display:inline-block; width:1.2em; text-align:left; overflow:hidden; vertical-align:bottom;
830
+ }
831
+ @keyframes codaDots{ 0%{clip-path:inset(0 100% 0 0)} 100%{clip-path:inset(0 0 0 0)} }
832
+ .coda-stage-sub{
833
+ color:var(--sub); font-size:.82rem; letter-spacing:.02em; max-width:300px;
834
+ font-family:'JetBrains Mono',ui-monospace,monospace; font-variant-numeric:tabular-nums;
835
+ }
836
+ .coda-stage-steps{ display:flex; gap:7px; margin-top:4px; }
837
+ .coda-step{
838
+ width:30px; height:3px; border-radius:3px; background:rgba(255,255,255,.12);
839
+ transition:background .3s, box-shadow .3s;
840
+ }
841
+ .coda-step.on{ background:var(--cyan); } /* latched done -> cyan */
842
+ .coda-step.now{ background:var(--violet); box-shadow:0 0 12px var(--violet); /* active engine step -> violet */
843
+ animation:codaPulse 1.1s ease-in-out infinite; }
844
+
845
+ /* ---------- 'has-clip' ambient reaction (UNCHANGED) ---------- */
846
+ .gradio-container:has(.coda-drop audio) .coda-aurora{ filter:saturate(150%); animation-duration:18s; }
847
+ .gradio-container:has(.coda-drop audio) #coda-eq span{ animation-duration:.9s; }
848
+
849
+ /* ---------- reduced-motion: kill EVERY animation (existing + new) ---------- */
850
  @media (prefers-reduced-motion: reduce){
851
+ .coda-aurora, #coda-eq span, .coda-particles span, .coda-label::before,
852
+ #coda-result::after, #coda-title, .coda-glass, .coda-card,
853
+ .coda-hud, .coda-hud::before, .coda-hud-cell, .coda-hud-dot,
854
+ .coda-go:not([disabled]), .coda-reveal, .coda-stage, .coda-stage-ring,
855
+ .coda-stage-bars span, .coda-stage-dots::after, .coda-step.now,
856
+ .coda-result-dot, #coda-eq span, .coda-drop .icon-wrap,
857
+ #coda-result:has(.coda-player audio){
858
+ animation:none !important;
859
+ }
860
+ .coda-hud::before{ display:none !important; }
861
+ }
862
+
863
+
864
+ /* ===================================================================== */
865
+ /* CODA EXTRAS β€” premium touches kept from the prior pass (additive) */
866
+ /* ===================================================================== */
867
+ /* uppercase engraved legends on every control label (Gradio block-info) */
868
+ .gradio-container [data-testid="block-info"]{
869
+ color:#aeb6cf !important; text-transform:uppercase; letter-spacing:.15em;
870
+ font-size:.7rem !important; font-weight:600; font-family:'Space Grotesk',sans-serif;
871
  }
872
+ .gradio-container .info-text{
873
+ color:#828aa6 !important; font-size:.78rem !important; line-height:1.5 !important;
874
+ }
875
+ /* the audio module's floating label -> engraved chip */
876
+ .coda-drop label.float, .coda-player label.float{
877
+ color:#aeb6cf !important; text-transform:uppercase; letter-spacing:.15em;
878
+ font-size:.66rem !important; font-weight:600;
879
+ background:rgba(10,12,22,.7) !important; border-radius:8px !important;
880
+ border:1px solid rgba(150,160,220,.12) !important; -webkit-backdrop-filter:blur(6px); backdrop-filter:blur(6px);
881
+ }
882
+ /* never show Gradio's default status text inside our panels */
883
+ #coda-result [data-testid="status-tracker"],
884
+ .coda-readout [data-testid="status-tracker"]{ display:none !important; }
885
+ /* dropzone empty-state: tall inviting channel + glowing floating icon */
886
+ .coda-drop .audio-container{
887
+ min-height:200px; display:flex; flex-direction:column;
888
+ align-items:center; justify-content:center;
889
+ }
890
+ .coda-drop .icon-wrap{
891
+ width:54px !important; height:54px !important; margin-bottom:6px;
892
+ display:flex; align-items:center; justify-content:center; border-radius:50%;
893
+ background:radial-gradient(circle at 40% 35%, rgba(111,224,245,.18), rgba(111,224,245,0) 70%);
894
+ animation:codaFloatY 3.4s ease-in-out infinite;
895
+ }
896
+ .coda-drop .icon-wrap svg{
897
+ width:26px !important; height:26px !important; opacity:1 !important;
898
+ color:var(--cyan) !important; stroke:var(--cyan) !important;
899
+ filter:drop-shadow(0 0 10px rgba(111,224,245,.6));
900
+ }
901
+ @keyframes codaFloatY{ 0%,100%{transform:translateY(0)} 50%{transform:translateY(-6px)} }
902
+ .coda-drop .or{ color:#5f6886 !important; font-size:.72rem; letter-spacing:.1em; }
903
+ /* 'Click to Upload' affordance as a refined pill */
904
+ .coda-drop button.center{
905
+ border:1px solid rgba(111,224,245,.3) !important; border-radius:10px !important;
906
+ padding:7px 16px !important; color:#dff6ff !important;
907
+ background:rgba(111,224,245,.06) !important; font-weight:500 !important;
908
+ }
909
+ .coda-drop button.center:hover{
910
+ background:rgba(111,224,245,.12) !important;
911
+ box-shadow:0 0 20px rgba(111,224,245,.2) !important;
912
+ }
913
+ .coda-drop button:not(.coda-demo):not(.center):hover, .coda-player button:hover{
914
+ color:var(--cyan) !important;
915
+ }
916
+ @media (prefers-reduced-motion: reduce){ .coda-drop .icon-wrap{ animation:none !important; } }
917
+
918
  """
919
 
920
  # ethereal hero spectrum: a frequency-spectrum bar field with a centered
 
962
  }
963
  """
964
 
965
+
966
  with gr.Blocks(title="CODA") as app:
967
  # ambient layers (aurora + cursor glow + drifting particles) live behind
968
  # everything via position:fixed / negative z-index, so they never affect layout.
 
985
  gr.HTML("<div class='coda-label'>Your clip</div>")
986
  audio_input = gr.Audio(
987
  label="Your unfinished clip", type="filepath",
988
+ sources=["upload"], elem_classes="coda-drop",
989
+ waveform_options=gr.WaveformOptions(
990
+ waveform_color="#33405e",
991
+ waveform_progress_color="#6fe0f5",
992
+ trim_region_color="#a98bff"))
993
  demo_btn = gr.Button("🎧 Try the demo β€” PUSHBACK (via TikTok)",
994
  size="sm", elem_classes="coda-demo")
995
  total_slider = gr.Slider(
996
  MIN_TOTAL, MAX_TOTAL, value=DEFAULT_TOTAL, step=1,
997
  label="Finished length (seconds)",
998
+ info="Total length of the finished track. Longer = a bit slower.",
999
+ elem_classes="coda-slider")
1000
  # Hidden mirror of the slider, and the field finish_song actually
1001
  # reads. Two reasons it's separate from the slider: (1) some Gradio
1002
  # frontends snap a slider back to its minimum on the 2nd+ submit,
 
1008
  vibe = gr.Textbox(
1009
  label="Describe the vibe (optional)", lines=1,
1010
  placeholder="e.g. warm lo-fi, vinyl crackle, mellow piano",
1011
+ info="Leave empty for pure audio-led continuation.",
1012
+ elem_classes="coda-vibe")
1013
  remaster = gr.Checkbox(
1014
  value=False, label="Remaster my part too",
1015
  info="Apply the same lo-fi cleanup to your original section so "
1016
+ "the whole track sits at one level.",
1017
+ elem_classes="coda-check")
1018
  finish_btn = gr.Button("✨ Finish this song", variant="primary",
1019
  interactive=False, elem_classes="coda-go")
1020
 
1021
  with gr.Column(scale=1):
1022
+ # the 'CODA heard' readout β€” a gr.HTML panel (not Markdown) so the
1023
+ # fingerprint renders as a custom audio-tool HUD with real classes.
1024
+ info_md = gr.HTML(visible=False, elem_classes="coda-card coda-readout")
1025
  # built visible inside the result group: Gradio drops visible=False
1026
  # audio players at build time, so we never toggle player visibility.
1027
  with gr.Group(elem_classes="coda-card", elem_id="coda-result"):
1028
+ gr.HTML("<div class='coda-result-head'>"
1029
+ "<span class='coda-result-dot'></span>YOUR FINISHED SONG"
1030
+ "</div>")
1031
+ # live processing overlay (absolutely positioned over this card).
1032
+ # finish_song yields stage HTML here at each milestone; empty = gone.
1033
+ stage = gr.HTML("", elem_id="coda-stage")
1034
  # No autoplay: an autoplay output <audio> element is reused across
1035
  # runs and can fail to reload its src on the 2nd+ generation β€” the
1036
  # result then looks like it "didn't play" / "didn't update". A
 
1038
  # play, which also avoids browsers blocking autoplay-with-sound.
1039
  output_audio = gr.Audio(label="", type="filepath",
1040
  interactive=False,
1041
+ elem_classes="coda-player",
1042
+ waveform_options=gr.WaveformOptions(
1043
+ waveform_color="#3a3060",
1044
+ waveform_progress_color="#a98bff"))
1045
  # built visible with an empty value (an empty Markdown renders
1046
  # nothing). finish_song fills it in the SAME event that sets the
1047
  # audio, so there's no second `.then` to toggle visibility β€” one
1048
  # event = one completion signal, so the spinner always clears.
1049
+ summary_md = gr.Markdown(elem_classes="coda-summary")
1050
 
1051
  gr.HTML(
1052
  "<div id='coda-foot'>Demo clip: <strong>PUSHBACK</strong> (via TikTok), "
 
1069
  # A chained second event was a place the "stuck processing" state could hang
1070
  # if the SSE stream blipped (ClientDisconnect) between the two events.
1071
  # Read the length from length_input (not the slider directly) β€” see above.
1072
+ # Single completion event (unchanged contract). The journey's beat states β€”
1073
+ # the held button during the run, the gold MASTER escalation on reveal, and
1074
+ # the ambient 'has-clip' reaction β€” are ALL driven by CSS :has() off real DOM
1075
+ # state (the stage overlay, the output <audio>, the input <audio>), so they
1076
+ # need no JS event to fire. This is far more reliable than js-only .change/
1077
+ # .then callbacks, which Gradio does not consistently run on backend updates.
1078
  finish_btn.click(
1079
  fn=finish_song,
1080
  inputs=[audio_input, length_input, vibe, remaster],
1081
+ outputs=[output_audio, summary_md, stage])
1082
 
1083
  # cosmetic only: start the cursor-following glow once the page is ready.
1084
  app.load(js=POINTER_JS)