Spaces:
Running on Zero
Running on Zero
| """CODA β finish the song you quit on. | |
| Upload a short, unfinished music clip. CODA listens to it (key, tempo, meter), | |
| then continues it into a longer, finished-sounding track in the same feel using | |
| Stable Audio 3 Small Music β a single native audio-continuation call, 44.1 kHz | |
| stereo β and splices the new part seamlessly onto your pristine original with a | |
| level-matched crossfade and a clean closing fade. | |
| Deliberately one job, done well. No lyrics generator, no cover-art printer β just | |
| a real, listenable continuation of your clip. | |
| """ | |
| # stable-audio-tools hard-pins torch==2.7.1 in its pyproject.toml; ZeroGPU only | |
| # accepts torch 2.8.0/2.9.1/2.10.0/2.11.0. We install the package without its | |
| # dependency tree so the ZeroGPU-managed torch in the env is used instead. | |
| import subprocess as _sp, sys as _sys | |
| try: | |
| import stable_audio_tools as _ # noqa: F401 | |
| del _ | |
| except ImportError: | |
| _sp.check_call([_sys.executable, "-m", "pip", "install", | |
| "--no-cache-dir", "--no-deps", "-q", | |
| "git+https://github.com/Stability-AI/stable-audio-tools.git"]) | |
| del _sp, _sys | |
| import os | |
| import tempfile | |
| import gradio as gr | |
| import librosa | |
| import numpy as np | |
| import soundfile as sf | |
| import engine | |
| import stitch | |
| from analyze import fingerprint | |
| from enhance import enhance_audio, enhance_to_tempfile, input_quality | |
| # ZeroGPU shim: on a Space `spaces` exists and `@spaces.GPU` attaches a GPU for | |
| # the duration of the call. Locally we no-op so the app still runs. | |
| try: | |
| import spaces | |
| except ImportError: | |
| class _FakeSpaces: | |
| def GPU(self, fn=None, **kw): | |
| return fn if fn else (lambda f: f) | |
| spaces = _FakeSpaces() | |
| # bundled demo: "Track0000" β Tony Winslow's own song, recorded in 2016 and | |
| # never finished. The emotional core of CODA: the exact kind of clip this tool | |
| # exists to finish. lo-fi in, finished-sounding out. | |
| DEMO_CLIP = os.path.join(os.path.dirname(__file__), | |
| "examples", "track0000_tony_winslow.mp3") | |
| # total finished length. SA3 Small generates up to 120 s in one call, so this is | |
| # a *total length* control (clip + continuation), not a "seconds to add" knob. | |
| MIN_TOTAL, MAX_TOTAL, DEFAULT_TOTAL = 30, 120, 60 | |
| # SA3's weights are gated under the Stability Community License, so the download | |
| # 401s unless the request is authenticated. Log in with the HF_TOKEN secret | |
| # (which must come from an account that accepted the licence on the model page) | |
| # BEFORE preload() pulls the weights. No-op when unset, so local runs that have | |
| # already done `huggingface-cli login` still work. | |
| _HF_TOKEN = os.environ.get("HF_TOKEN") | |
| if _HF_TOKEN: | |
| try: | |
| from huggingface_hub import login as _hf_login | |
| _hf_login(token=_HF_TOKEN) | |
| print("[coda] authenticated to the HF Hub via HF_TOKEN", flush=True) | |
| except Exception as _e: | |
| print(f"[coda] HF login failed ({_e}); gated model download may 401", | |
| flush=True) | |
| elif os.environ.get("SPACE_ID"): | |
| print("[coda] WARNING: no HF_TOKEN secret set β the gated SA3 weights will " | |
| "401. Add HF_TOKEN in the Space settings (Settings -> Variables and " | |
| "secrets) using a token from an account that accepted the licence.", | |
| flush=True) | |
| # On a Space, pull weights into CPU RAM at boot so the GPU window is spent | |
| # generating, not reading 3 GB off disk. `spaces` defers CUDA placement until | |
| # the first @spaces.GPU call. | |
| if os.environ.get("SPACE_ID"): | |
| try: | |
| engine.preload() | |
| except Exception as _e: | |
| print(f"[coda] preload failed ({_e}); will lazy-load", flush=True) | |
| def _readout_html(info, quality): | |
| """The 'CODA heard' fingerprint as a premium audio-tool readout (a HUD of | |
| labelled value cells), not a text dump. Rendered into a gr.HTML panel.""" | |
| cells = [ | |
| ("KEY", str(info["key"]), ""), | |
| ("TEMPO", str(info["bpm"]), "BPM"), | |
| ("METER", str(info["time_signature"]), ""), | |
| ("LENGTH", f"{info['duration']}", "S"), | |
| ] | |
| cell_html = "".join( | |
| f"<div class='coda-hud-cell'>" | |
| f"<div class='coda-hud-k'>{k}</div>" | |
| f"<div class='coda-hud-v'>{v}<span class='coda-hud-u'>{u}</span></div>" | |
| f"</div>" | |
| for (k, v, u) in cells) | |
| note = "" | |
| if quality and quality.get("lofi"): | |
| note = (f"<div class='coda-hud-note'>" | |
| f"<span class='coda-hud-tag'>LO-FI ~{quality['bandwidth_hz']/1000:.0f}KHZ</span>" | |
| f"CODA cleans a copy before it listens, so it follows the " | |
| f"<em>song</em>, not the hiss</div>") | |
| return ( | |
| "<div class='coda-hud'>" | |
| "<div class='coda-hud-head'><span class='coda-hud-dot'></span>" | |
| "CODA HEARD<span class='coda-hud-live'>ANALYZED</span></div>" | |
| f"<div class='coda-hud-grid'>{cell_html}</div>" | |
| f"{note}</div>") | |
| def _warn_html(title, body): | |
| """A clip-rejected / read-error message styled to match the readout HUD.""" | |
| return ("<div class='coda-hud coda-hud-warn'>" | |
| f"<div class='coda-hud-head'><span class='coda-hud-dot'></span>{title}</div>" | |
| f"<div class='coda-hud-note'>{body}</div></div>") | |
| def _listening_html(): | |
| """Instant 'CODA is reading your clip' state for the readout, shown the moment | |
| a clip lands so the panel lights up immediately instead of sitting greyed-out | |
| until the (CPU-bound) key/tempo analysis finishes. Replaced in-place by the | |
| real fingerprint readout a beat later.""" | |
| return ("<div class='coda-hud coda-hud-listening'>" | |
| "<div class='coda-hud-head'><span class='coda-hud-dot'></span>" | |
| "CODA HEARD<span class='coda-hud-live'>LISTENINGβ¦</span></div>" | |
| "<div class='coda-hud-scan'><span></span></div>" | |
| "<div class='coda-hud-note'>Reading key, tempo and groove from your " | |
| "clipβ¦</div></div>") | |
| # the live "what is CODA doing right now" overlay shown over the result panel | |
| # during generation. finish_song yields these at each real pipeline milestone. | |
| _STAGE_STEPS = ["listening", "composing", "splicing"] | |
| def _stage_html(phase, label, sub=""): | |
| bars = "".join("<span></span>" for _ in range(9)) | |
| steps = "".join( | |
| f"<span class='coda-step{' on' if _STAGE_STEPS.index(phase) >= i else ''}" | |
| f"{' now' if _STAGE_STEPS.index(phase) == i else ''}'></span>" | |
| if phase in _STAGE_STEPS else "<span class='coda-step'></span>" | |
| for i in range(len(_STAGE_STEPS))) | |
| return ( | |
| f"<div class='coda-stage' data-phase='{phase}'>" | |
| "<div class='coda-stage-core'>" | |
| "<div class='coda-stage-ring'></div>" | |
| "<div class='coda-stage-ring r2'></div>" | |
| f"<div class='coda-stage-bars'>{bars}</div>" | |
| "</div>" | |
| f"<div class='coda-stage-label'>{label}<span class='coda-stage-dots'></span></div>" | |
| f"<div class='coda-stage-sub'>{sub}</div>" | |
| f"<div class='coda-stage-steps'>{steps}</div>" | |
| "</div>") | |
| def _mmss(seconds): | |
| s = int(round(max(float(seconds), 0.0))) | |
| return f"{s // 60}:{s % 60:02d}" | |
| def _seam_html(source_seconds, total): | |
| """A self-contained before/after 'composition ribbon' for the finished track: | |
| one bar split at the EXACT seam β YOUR PART on the left, CODA's continuation | |
| on the right β with the join marked and labelled. | |
| Rendered SERVER-SIDE from the precise numbers finish_song already computed, so | |
| the marker is always exactly where CODA begins. The old approach measured the | |
| WaveSurfer waveform in the browser and drifted/stuck (the 'needle in the wrong | |
| place' bug); this can't, because the split is baked into the HTML. Purely | |
| cosmetic β does NOT touch enhance -> fingerprint -> continue_audio -> stitch.""" | |
| src = max(float(source_seconds), 0.0) | |
| tot = max(float(total), src + 0.001) | |
| pct = max(0.0, min(src / tot, 1.0)) * 100.0 | |
| added = max(tot - src, 0.0) | |
| return ( | |
| "<div class='coda-seam-ribbon'>" | |
| "<div class='coda-seam-cap'>" | |
| "<span>The finished track</span>" | |
| f"<span class='coda-seam-cap-tot'>{_mmss(tot)} total</span>" | |
| "</div>" | |
| "<div class='coda-seam-track'>" | |
| f"<div class='coda-seam-seg coda-seam-seg-yours' style='width:{pct:.3f}%'>" | |
| "<span class='coda-seam-seg-lab'>YOUR PART</span></div>" | |
| f"<div class='coda-seam-seg coda-seam-seg-coda' style='width:{100.0 - pct:.3f}%'>" | |
| "<span class='coda-seam-seg-lab'>CODA</span></div>" | |
| f"<div class='coda-seam-join' style='left:{pct:.3f}%'></div>" | |
| "</div>" | |
| "<div class='coda-seam-foot'>" | |
| f"<span class='coda-seam-foot-y'>Your original Β· {_mmss(src)}</span>" | |
| f"<span class='coda-seam-foot-c'>CODA added Β· +{_mmss(added)}</span>" | |
| "</div>" | |
| "</div>") | |
| def analyze_on_upload(audio_path): | |
| """Fast CPU-only pass the instant a clip loads, so the user sees what CODA | |
| heard immediately instead of a dead screen. | |
| A GENERATOR so the readout lights up the MOMENT a clip lands: it yields a | |
| 'listeningβ¦' state first, then replaces it with the real key/tempo readout | |
| once the (CPU-bound) analysis completes. That kills the 'CODA heard stays | |
| greyed until you generate' feel on slower hosts. Does NOT touch the | |
| generation pipeline β it drives the readout panel + the Finish button's armed | |
| state, and RESETS the result panel (player / summary / seam / stage) so a new | |
| clip can't show the previous run's output.""" | |
| # A freshly loaded clip makes the PREVIOUS finished result stale. Wipe the | |
| # whole result panel β player + summary + seam ribbon + any leftover stage | |
| # overlay β on the first beat, so (a) the old output can never be mistaken for | |
| # the new one, and (b) the next run's player starts from None and is forced to | |
| # reload its waveform cleanly. These are the trailing 4 outputs added to this | |
| # handler; `_clear` clears them once on the first yield, `_keep` leaves them | |
| # untouched on later yields so we don't re-send the clear every beat. | |
| _clear = (gr.update(value=None), gr.update(value=""), | |
| gr.update(value=""), gr.update(value="")) | |
| _keep = (gr.update(), gr.update(), gr.update(), gr.update()) | |
| if not audio_path: | |
| yield (gr.update(value="", visible=False), | |
| gr.update(interactive=False), *_clear) | |
| return | |
| # instant feedback β panel lights up before the analysis even starts | |
| yield (gr.update(value=_listening_html(), visible=True), | |
| gr.update(interactive=False), *_clear) | |
| try: | |
| listen_path = enhance_to_tempfile(audio_path) | |
| info = fingerprint(listen_path) | |
| quality = input_quality(audio_path) | |
| # SA3 continues up to a 120s total, so a clip needs headroom for at | |
| # least MIN_NEW seconds of new audio. Block over-long clips here, with a | |
| # clear message, instead of failing at generation time. | |
| if info["duration"] > engine.MAX_SOURCE_SECONDS: | |
| msg = _warn_html( | |
| "Clip too long", | |
| f"That clip is {info['duration']:.0f}s. CODA continues clips up to " | |
| f"{engine.MAX_SOURCE_SECONDS:.0f}s (Stable Audio 3's " | |
| f"{engine.MAX_TOTAL_SECONDS:.0f}s total cap). Trim it shorter and " | |
| f"re-upload.") | |
| yield (gr.update(value=msg, visible=True), | |
| gr.update(interactive=False), *_keep) | |
| return | |
| html = _readout_html(info, quality) | |
| yield (gr.update(value=html, visible=True), | |
| gr.update(interactive=True), *_keep) | |
| except Exception as e: | |
| print(f"[coda] analysis failed ({e})", flush=True) | |
| yield (gr.update(value=_warn_html("Couldn't read that file", str(e)), | |
| visible=True), | |
| gr.update(interactive=False), *_keep) | |
| def _continue_on_gpu(listen_path, total_seconds, vibe): | |
| """ONLY the SA3 diffusion call runs inside the GPU window. All CPU work β | |
| enhancement, key/tempo analysis, decode, splice β happens OUTSIDE it (in | |
| `finish_song`), so the scarce ZeroGPU allocation is spent generating instead | |
| of decoding/analyzing audio. That's the stream-abort fix: the GPU task now | |
| starts and finishes fast instead of sitting through a slow analysis preamble | |
| until the browser aborts the stream. | |
| Do NOT pin a magic seed. The lab's verified seed=7 take was made on torch | |
| 2.7.1; ZeroGPU runs a different torch (2.8β2.11), so the same seed reproduces | |
| a *different* draw β on the deployed build, the bad "loud random synth noise" | |
| one. Instead, leave the seed unset (engine draws several candidates and keeps | |
| the cleanest by an artifact score), which is robust across torch builds. | |
| """ | |
| return engine.continue_audio( | |
| listen_path, total_seconds=int(total_seconds), | |
| prompt=(vibe or "").strip()) | |
| def finish_song(audio_path, total_seconds, vibe, remaster, | |
| progress=gr.Progress()): | |
| """Orchestrate the job: CPU prep -> GPU continuation -> CPU splice. | |
| A GENERATOR so the UI gets a real, dramatic, server-synced wait state: it | |
| yields a live 'stage' overlay (gr.HTML) at each genuine pipeline milestone β | |
| listening / composing / splicing β and finally yields the finished audio + | |
| summary while clearing the overlay. This is still ONE event (the spinner | |
| clears on StopIteration), so it keeps the single-completion property; the | |
| earlier 'stuck spinner' risk was a CHAINED second `.then` event, which this | |
| is not. The exact pipeline contract and call order are unchanged: | |
| enhance_to_tempfile -> fingerprint -> engine.continue_audio -> stitch.stitch. | |
| Yields/returns 3-tuples for (output_audio, summary_md, stage_html).""" | |
| if not audio_path: | |
| raise gr.Error("Upload a clip (or load the Track0000 demo) first.") | |
| total_seconds = int(total_seconds) | |
| try: | |
| # --- BEAT: listening (CPU prep, outside the GPU window) --- | |
| # Clear ANY previous result the moment the run starts: blank the player | |
| # (value=None), the summary and the seam ribbon. This is what makes a | |
| # resubmit show fresh output on the FIRST click β the player transitions | |
| # None -> new file at the reveal, which forces WaveSurfer to tear down and | |
| # reload instead of (sometimes) keeping the prior take's waveform mounted. | |
| progress(0.05, desc="Listening to your clipβ¦") | |
| yield gr.update(value=None), gr.update(value=""), _stage_html( | |
| "listening", "Listening to your clip", | |
| "reading key Β· tempo Β· groove"), gr.update(value="") # +seam_data | |
| listen_path = enhance_to_tempfile(audio_path) | |
| info = fingerprint(listen_path) | |
| # the pristine original β what the listener hears for the first stretch | |
| original, sr = librosa.load(audio_path, sr=None, mono=False) | |
| if remaster: | |
| progress(0.15, desc="Remastering your partβ¦") | |
| original = enhance_audio(original, sr) | |
| # --- BEAT: composing (holds during the ONLY @spaces.GPU call) --- | |
| progress(0.35, desc="Composing the continuationβ¦") | |
| yield gr.update(), gr.update(), _stage_html( | |
| "composing", "Composing the continuation", | |
| f"Stable Audio 3 Β· extending in {info['key']} at {info['bpm']} BPM" | |
| ), gr.update() # 4-tuple: +seam_data | |
| try: | |
| new_tail, source_seconds, SR = _continue_on_gpu( | |
| listen_path, total_seconds, vibe) | |
| except ValueError as e: | |
| # e.g. the clip is a full-length track, not a clip to continue | |
| raise gr.Error(str(e)) | |
| # --- BEAT: splicing (CPU splice + write, outside the GPU window) --- | |
| progress(0.9, desc="Splicing onto your originalβ¦") | |
| yield gr.update(), gr.update(), _stage_html( | |
| "splicing", "Splicing onto your original", | |
| "level-matched crossfade Β· clean closing fade"), gr.update() # +seam_data | |
| out = stitch.stitch(original, sr, new_tail, source_seconds) | |
| out_path = os.path.join(tempfile.mkdtemp(), "coda_finished.wav") | |
| sf.write(out_path, out.T, SR, subtype="PCM_16") | |
| progress(1.0, desc="Done.") | |
| total = out.shape[-1] / SR | |
| added = total - source_seconds | |
| vibe_note = f" guided by *β{vibe.strip()}β*" if (vibe or "").strip() else "" | |
| summary = ( | |
| f"### Finished β {total:.0f}s\n" | |
| f"Your **{source_seconds:.0f}s** clip in **{info['key']}** at " | |
| f"**{info['bpm']} BPM** continued for **~{added:.0f}s** more{vibe_note}, " | |
| f"then crossfaded onto your original and faded to a clean close.\n\n" | |
| f"*Stable Audio 3 generated the continuation as 44.1 kHz stereo in a " | |
| f"single pass; your original recording plays untouched up to the seam.*" | |
| ) | |
| # --- BEAT: reveal (set audio + summary, clear overlay, surface seam) --- | |
| yield out_path, summary, "", _seam_html(source_seconds, total) | |
| except gr.Error: | |
| # clear the overlay + any stale seam so a failure leaves a clean panel | |
| yield gr.update(), gr.update(), "", "" | |
| raise | |
| def load_demo(): | |
| """Load the Track0000 demo clip into the uploader. | |
| Copy the bundled clip to a fresh temp file and hand THAT to the uploader. | |
| Gradio 6 refuses to move a non-user-uploaded bundled file into its cache | |
| ("β¦was not uploaded by a user"), which broke the demo on the Space; a file | |
| in a per-request temp dir is one Gradio will cache and serve normally.""" | |
| import shutil | |
| dst = os.path.join(tempfile.mkdtemp(), os.path.basename(DEMO_CLIP)) | |
| shutil.copyfile(DEMO_CLIP, dst) | |
| return dst | |
| # --- ethereal "dark studio" theme -------------------------------------------- | |
| THEME = gr.themes.Base( | |
| primary_hue=gr.themes.colors.cyan, | |
| secondary_hue=gr.themes.colors.purple, | |
| neutral_hue=gr.themes.colors.slate, | |
| font=[gr.themes.GoogleFont("Space Grotesk"), | |
| gr.themes.GoogleFont("Inter"), "ui-sans-serif", "system-ui"], | |
| font_mono=[gr.themes.GoogleFont("JetBrains Mono"), "ui-monospace", "monospace"], | |
| ).set( | |
| body_background_fill="#070710", | |
| body_background_fill_dark="#070710", | |
| background_fill_primary="rgba(255,255,255,0)", | |
| background_fill_secondary="rgba(255,255,255,0)", | |
| block_background_fill="rgba(255,255,255,0.025)", | |
| block_border_color="rgba(150,160,220,0.10)", | |
| block_border_width="1px", | |
| block_radius="16px", | |
| block_label_background_fill="rgba(0,0,0,0)", | |
| block_label_text_color="#9aa3bd", | |
| block_title_text_color="#9aa3bd", | |
| body_text_color="#e9ecf8", | |
| body_text_color_subdued="#9aa3bd", | |
| button_primary_background_fill="linear-gradient(100deg,#6fe0f5,#a98bff)", | |
| button_primary_background_fill_hover="linear-gradient(100deg,#7be8ff,#b89bff)", | |
| button_primary_text_color="#0a0a16", | |
| color_accent_soft="rgba(111,224,245,0.10)", | |
| border_color_accent="#6fe0f5", | |
| input_background_fill="rgba(10,11,22,0.55)", | |
| input_border_color="rgba(150,160,220,0.12)", | |
| slider_color="#6fe0f5", | |
| ) | |
| CSS = """ | |
| /* CODA β premium dark-studio design system */ | |
| @import url('https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=Inter:wght@400;500;600&display=swap'); | |
| :root{ | |
| --cyan:#6fe0f5; --violet:#a98bff; --gold:#f6d68a; | |
| --text:#e9ecf8; --sub:#9aa3bd; | |
| --glass:rgba(18,20,36,0.55); | |
| --glass-brd:rgba(150,160,220,0.14); | |
| --mx:50%; --my:28%; | |
| /* milled-metal light model (cheap, no textures) */ | |
| --bevel-hi:rgba(255,255,255,.06); | |
| --bevel-lo:rgba(0,0,0,.55); | |
| --well:linear-gradient(180deg,rgba(0,0,0,.34),rgba(0,0,0,.16)); | |
| --ring:rgba(150,160,220,.14); | |
| --ring-hot:rgba(111,224,245,.45); | |
| --ease-expo:cubic-bezier(.16,1,.3,1); | |
| --ink:#0a0a16; | |
| } | |
| /* ---------- ambient background layers (UNCHANGED β already GOOD) ---------- */ | |
| .coda-aurora,.coda-glow,.coda-particles{ position:fixed; inset:0; pointer-events:none; overflow:hidden; } | |
| .coda-aurora{ | |
| z-index:-3; | |
| background: | |
| radial-gradient(38% 48% at 18% 22%, rgba(124,92,255,.20), transparent 62%), | |
| radial-gradient(44% 52% at 84% 28%, rgba(64,200,235,.16), transparent 62%), | |
| radial-gradient(52% 46% at 50% 96%, rgba(246,214,138,.10), transparent 60%), | |
| radial-gradient(40% 42% at 72% 78%, rgba(169,139,255,.14), transparent 62%); | |
| filter:saturate(125%); | |
| animation:codaAurora 26s ease-in-out infinite alternate; | |
| } | |
| @keyframes codaAurora{ | |
| 0%{transform:translate3d(0,0,0) scale(1)} | |
| 50%{transform:translate3d(2.5%,-1.8%,0) scale(1.07)} | |
| 100%{transform:translate3d(-1.8%,1.4%,0) scale(1.03)} | |
| } | |
| .coda-glow{ | |
| z-index:-2; | |
| background:radial-gradient(420px 420px at var(--mx) var(--my), | |
| rgba(111,224,245,.08), transparent 68%); | |
| transition:background .25s ease; | |
| } | |
| .coda-particles{ z-index:-1; } | |
| .coda-particles span{ | |
| position:absolute; bottom:-12px; border-radius:50%; | |
| background:radial-gradient(circle, rgba(190,205,255,.95), rgba(190,205,255,0) 70%); | |
| opacity:0; animation-name:codaFloat; animation-timing-function:linear; | |
| animation-iteration-count:infinite; | |
| } | |
| @keyframes codaFloat{ | |
| 0%{transform:translateY(0) scale(.5); opacity:0} | |
| 12%{opacity:.7} 88%{opacity:.45} | |
| 100%{transform:translateY(-108vh) scale(1.05); opacity:0} | |
| } | |
| /* ---------- shell ---------- */ | |
| .gradio-container{ max-width:1080px !important; margin:0 auto !important; } | |
| .gradio-container, .gradio-container *{ font-family:'Inter',ui-sans-serif,system-ui; } | |
| /* ---------- hero (wordmark + spectrum power-on) ---------- */ | |
| #coda-head{ text-align:center; padding:40px 0 4px; position:relative; } | |
| #coda-title{ | |
| font-family:'Space Grotesk',sans-serif; font-weight:700; | |
| font-size:clamp(3rem,9vw,4.6rem); letter-spacing:.20em; margin:0 0 .2rem; | |
| background:linear-gradient(100deg,#6fe0f5 0%,#a98bff 46%,#f6d68a 100%); | |
| background-size:220% auto; -webkit-background-clip:text; background-clip:text; | |
| -webkit-text-fill-color:transparent; color:transparent; | |
| filter:drop-shadow(0 0 38px rgba(150,140,255,.30)); | |
| animation:codaShine 9s linear infinite; | |
| } | |
| @keyframes codaShine{ to{ background-position:220% center } } | |
| #coda-tagline{ | |
| color:var(--sub); text-transform:uppercase; letter-spacing:.34em; | |
| font-size:.78rem; margin:.2rem 0 0; font-weight:500; | |
| } | |
| #coda-eq{ | |
| display:flex; gap:3px; align-items:center; justify-content:center; | |
| height:46px; max-width:440px; margin:22px auto 4px; opacity:.92; | |
| } | |
| #coda-eq span{ | |
| flex:1 1 0; max-width:5px; height:100%; border-radius:3px; | |
| transform-origin:center; transform:scaleY(.2); | |
| background:linear-gradient(180deg,#7be8ff,#a98bff); | |
| box-shadow:0 0 9px rgba(111,224,245,.45); | |
| /* one-shot power-on, THEN the infinite breathing bar (additive list) */ | |
| animation:codaPowerOn .7s var(--ease-expo) both, codaBar 1.2s ease-in-out infinite .7s; | |
| animation-name:codaPowerOn, codaBar; | |
| } | |
| @keyframes codaPowerOn{ from{transform:scaleY(.04); opacity:.25} to{opacity:.92} } | |
| @keyframes codaBar{ 0%,100%{transform:scaleY(.16)} 50%{transform:scaleY(1)} } | |
| #coda-rule{ | |
| height:1px; border:0; max-width:260px; margin:18px auto 6px; | |
| background:linear-gradient(90deg,transparent,var(--cyan),var(--violet),transparent); | |
| opacity:.6; | |
| } | |
| #coda-intro{ text-align:center; color:var(--sub); max-width:680px; | |
| margin:0 auto 6px; font-size:1rem; line-height:1.6; } | |
| #coda-intro strong{ color:#dfe4ff; font-weight:600; } | |
| /* ---------- glass panels -> milled faceplates ---------- */ | |
| .coda-glass, .coda-card{ | |
| background:var(--glass) !important; | |
| border:1px solid var(--glass-brd) !important; | |
| border-radius:20px !important; | |
| backdrop-filter:blur(16px) saturate(135%); | |
| -webkit-backdrop-filter:blur(16px) saturate(135%); | |
| /* light model: top-bevel lip + recessed body + outer contact drop */ | |
| box-shadow: | |
| inset 0 1px 0 var(--bevel-hi), | |
| inset 0 -1px 0 var(--bevel-lo), | |
| 0 12px 40px rgba(0,0,0,.5) !important; | |
| padding:22px !important; position:relative; | |
| } | |
| .coda-glass{ animation:codaRise .85s var(--ease-expo) .55s both; } | |
| .coda-card{ animation:codaRise .85s var(--ease-expo) .63s both; } | |
| @keyframes codaRise{ from{opacity:0; transform:translateY(16px) scale(.99)} to{opacity:1; transform:none} } | |
| /* section micro-labels (engraved legend + live status lamp) */ | |
| .coda-label{ | |
| color:var(--cyan); text-transform:uppercase; letter-spacing:.18em; | |
| font-size:.72rem; font-weight:600; margin:0 0 10px; display:flex; | |
| align-items:center; gap:.5rem; | |
| } | |
| .coda-label::before{ | |
| content:''; width:7px; height:7px; border-radius:50%; | |
| background:var(--cyan); box-shadow:0 0 10px var(--cyan); | |
| animation:codaPulse 2.4s ease-in-out infinite; | |
| } | |
| @keyframes codaPulse{ 0%,100%{opacity:.5; transform:scale(.85)} 50%{opacity:1; transform:scale(1.15)} } | |
| /* blend inner gradio blocks into the glass; mute redundant helper text */ | |
| .coda-glass .block, .coda-card .block{ box-shadow:none !important; background:transparent !important; } | |
| .coda-glass .info, .coda-card .info{ color:var(--sub) !important; font-size:.78rem !important; opacity:.85; } | |
| .coda-card h3{ | |
| color:var(--cyan); text-transform:uppercase; letter-spacing:.16em; | |
| font-size:.74rem; font-weight:600; margin:.1rem 0 .7rem; | |
| } | |
| /* ---------- primary FINISH key: disarmed -> armed -> held ---------- */ | |
| .coda-go, .coda-go button{ | |
| background:linear-gradient(100deg,#6fe0f5,#a98bff 62%,#cf9bff) !important; | |
| background-size:180% auto !important; | |
| color:var(--ink) !important; font-weight:600 !important; letter-spacing:.03em; | |
| border:0 !important; border-radius:14px !important; min-height:48px; | |
| box-shadow: | |
| inset 0 1px 0 rgba(255,255,255,.42), | |
| inset 0 -2px 4px rgba(0,0,0,.25), | |
| 0 0 0 1px rgba(255,255,255,.08), | |
| 0 8px 26px rgba(124,100,255,.34) !important; | |
| transition:transform .25s var(--ease-expo), box-shadow .25s, | |
| filter .25s, background-position .6s !important; | |
| } | |
| /* IGNITE on arm (the real interactive flip removes [disabled]) */ | |
| .coda-go:not([disabled]){ animation:codaCtaSheen .85s ease-out 1; } | |
| @keyframes codaCtaSheen{ 0%{background-position:120% center} 100%{background-position:0% center} } | |
| .coda-go:hover:not([disabled]){ | |
| transform:translateY(-2px); filter:brightness(1.06); | |
| background-position:right center !important; | |
| box-shadow: | |
| inset 0 1px 0 rgba(255,255,255,.5), | |
| 0 0 0 1px rgba(255,255,255,.16), | |
| 0 14px 40px rgba(124,100,255,.5), | |
| 0 0 34px rgba(111,224,245,.38) !important; | |
| } | |
| .coda-go:active:not([disabled]){ | |
| transform:translateY(0) scale(.99); | |
| box-shadow: | |
| inset 0 2px 6px rgba(0,0,0,.4), | |
| 0 0 0 1px rgba(255,255,255,.12), | |
| 0 0 30px rgba(124,100,255,.45) !important; | |
| } | |
| .coda-go[disabled]{ | |
| filter:grayscale(.6) brightness(.6); opacity:.5; | |
| box-shadow:inset 0 1px 2px rgba(0,0,0,.5) !important; | |
| } | |
| /* held/lit while the engine runs β keyed off the live stage overlay via :has(), | |
| so it needs no JS to set/clear and is correct for the whole run + after. */ | |
| .gradio-container:has(.coda-stage) .coda-go, | |
| .gradio-container:has(.coda-stage) .coda-go button{ | |
| filter:brightness(.94); | |
| box-shadow:inset 0 2px 8px rgba(0,0,0,.45),0 0 26px rgba(169,139,255,.45) !important; | |
| } | |
| /* ---------- demo button (quiet utility key) ---------- */ | |
| .coda-demo{ | |
| background:linear-gradient(180deg,rgba(255,255,255,.045),rgba(255,255,255,.02)) !important; | |
| color:var(--sub) !important; | |
| border:1px solid var(--glass-brd) !important; border-radius:11px !important; | |
| font-weight:500 !important; letter-spacing:.02em; | |
| box-shadow:inset 0 1px 0 rgba(255,255,255,.05), inset 0 -1px 2px rgba(0,0,0,.4) !important; | |
| transition:all .25s ease !important; | |
| } | |
| .coda-demo:hover{ | |
| color:#eaf6ff !important; border-color:var(--ring-hot) !important; | |
| background:linear-gradient(180deg,rgba(111,224,245,.10),rgba(111,224,245,.04)) !important; | |
| box-shadow:inset 0 1px 0 rgba(255,255,255,.06),0 0 22px rgba(111,224,245,.20) !important; | |
| } | |
| /* ---------- vibe textbox (carved + arming focus) ---------- */ | |
| .coda-vibe textarea, .coda-vibe input[type=text]{ | |
| background:var(--well) !important; | |
| border:1px solid var(--ring) !important; border-radius:10px !important; | |
| color:var(--text) !important; caret-color:var(--cyan); | |
| box-shadow:inset 0 2px 6px rgba(0,0,0,.5), inset 0 1px 0 rgba(255,255,255,.04) !important; | |
| transition:box-shadow .2s, border-color .2s !important; | |
| } | |
| .coda-vibe textarea::placeholder, .coda-vibe input[type=text]::placeholder{ | |
| color:#6b7388 !important; letter-spacing:.02em; opacity:.9; | |
| } | |
| .coda-vibe textarea:focus, .coda-vibe input[type=text]:focus{ | |
| border-color:var(--cyan) !important; | |
| box-shadow: | |
| inset 0 0 0 1px rgba(111,224,245,.35), | |
| inset 0 2px 6px rgba(0,0,0,.45), | |
| 0 0 22px rgba(111,224,245,.18) !important; | |
| } | |
| /* ---------- length slider (milled fader, webkit + moz) ---------- */ | |
| .coda-slider input[type=range]{ | |
| -webkit-appearance:none; appearance:none; height:6px; border-radius:4px; | |
| background:rgba(10,11,22,.7); | |
| box-shadow:inset 0 1px 3px rgba(0,0,0,.7), inset 0 -1px 0 rgba(255,255,255,.04); | |
| accent-color:var(--cyan); /* native fill is always correct β no JS --val */ | |
| } | |
| .coda-slider input[type=range]::-webkit-slider-thumb{ | |
| -webkit-appearance:none; width:20px; height:20px; border-radius:50%; | |
| background:radial-gradient(circle at 50% 35%,#eafcff,#6fe0f5 58%,#3f8fa8); | |
| border:1px solid rgba(255,255,255,.16); | |
| box-shadow:0 2px 4px rgba(0,0,0,.6), inset 0 1px 0 rgba(255,255,255,.22), | |
| inset 0 -1px 2px rgba(0,0,0,.55), 0 0 0 0 rgba(111,224,245,0); | |
| transition:transform .14s, box-shadow .14s; cursor:grab; | |
| } | |
| .coda-slider input[type=range]:active::-webkit-slider-thumb{ | |
| transform:scale(1.1); cursor:grabbing; | |
| box-shadow:0 2px 6px rgba(0,0,0,.7), inset 0 1px 0 rgba(255,255,255,.28), | |
| 0 0 0 4px rgba(111,224,245,.18), 0 0 16px rgba(111,224,245,.55); | |
| } | |
| .coda-slider input[type=range]::-moz-range-thumb{ | |
| width:20px; height:20px; border-radius:50%; border:1px solid rgba(255,255,255,.16); | |
| background:radial-gradient(circle at 50% 35%,#eafcff,#6fe0f5 58%,#3f8fa8); | |
| box-shadow:0 2px 4px rgba(0,0,0,.6), inset 0 1px 0 rgba(255,255,255,.22); | |
| transition:transform .14s, box-shadow .14s; | |
| } | |
| .coda-slider input[type=range]:active::-moz-range-thumb{ | |
| transform:scale(1.1); | |
| box-shadow:0 0 0 4px rgba(111,224,245,.18), 0 0 16px rgba(111,224,245,.55); | |
| } | |
| .coda-slider input[type=range]::-moz-range-track{ height:6px; border-radius:4px; background:rgba(10,11,22,.7); } | |
| /* the companion number box = honest mono value chip */ | |
| .coda-slider input[type=number]{ | |
| background:rgba(10,11,22,.6) !important; border:1px solid var(--ring) !important; | |
| box-shadow:inset 0 1px 3px rgba(0,0,0,.5) !important; | |
| color:#dfe4ff !important; border-radius:8px !important; | |
| font-family:'JetBrains Mono',ui-monospace,monospace !important; | |
| font-variant-numeric:tabular-nums; | |
| } | |
| /* ---------- remaster checkbox (backlit toggle) ---------- */ | |
| .coda-check input[type=checkbox]{ | |
| -webkit-appearance:none; appearance:none; width:18px; height:18px; | |
| border-radius:5px; position:relative; cursor:pointer; vertical-align:middle; | |
| background:linear-gradient(180deg,rgba(0,0,0,.42),rgba(0,0,0,.22)); | |
| border:1px solid rgba(150,160,220,.2); | |
| box-shadow:inset 0 1px 2px rgba(0,0,0,.6), inset 0 -1px 0 rgba(255,255,255,.04); | |
| transition:background .2s, box-shadow .2s, border-color .2s; | |
| } | |
| .coda-check input[type=checkbox]:hover{ border-color:var(--ring-hot); } | |
| .coda-check input[type=checkbox]:checked{ | |
| background:linear-gradient(135deg,#7be8ff,#a98bff); | |
| border-color:transparent; | |
| box-shadow:inset 0 1px 0 rgba(255,255,255,.3), 0 0 14px rgba(111,224,245,.45); | |
| } | |
| .coda-check input[type=checkbox]:checked::after{ | |
| content:''; position:absolute; left:5px; top:1px; width:5px; height:10px; | |
| border:solid var(--ink); border-width:0 2px 2px 0; | |
| transform:rotate(42deg) scale(0); | |
| animation:codaCheck .18s cubic-bezier(.34,1.56,.64,1) forwards; | |
| } | |
| @keyframes codaCheck{ to{ transform:rotate(42deg) scale(1) } } | |
| /* generic range accent (covers any non-scoped range, e.g. player seek) */ | |
| input[type=range]{ accent-color:var(--cyan); } | |
| /* ---------- upload dropzone -> recessed INPUT channel ---------- */ | |
| .coda-drop{ | |
| border-radius:16px !important; | |
| background:radial-gradient(120% 120% at 50% 0%, rgba(111,224,245,.05), rgba(10,11,22,.5)) !important; | |
| box-shadow:inset 0 2px 10px rgba(0,0,0,.5), inset 0 1px 0 rgba(255,255,255,.05) !important; | |
| transition:box-shadow .3s, transform .3s var(--ease-expo); | |
| } | |
| /* Gradio's Upload inner empty-state = .wrap (verified). Tame the stark frame. */ | |
| .coda-drop .wrap{ | |
| border:1.5px dashed rgba(111,224,245,.22) !important; border-radius:13px !important; | |
| background:transparent !important; transition:border-color .3s, background .3s; | |
| } | |
| .coda-drop .wrap:hover{ border-color:var(--ring-hot) !important; background:rgba(111,224,245,.04) !important; } | |
| .coda-drop:hover{ box-shadow:inset 0 2px 10px rgba(0,0,0,.5), 0 0 30px rgba(124,100,255,.18) !important; } | |
| .coda-drop .wrap *{ color:var(--sub) !important; letter-spacing:.03em; } | |
| .coda-drop svg{ filter:drop-shadow(0 0 8px rgba(111,224,245,.3)); opacity:.8; } | |
| /* loaded/hot channel */ | |
| .coda-has-clip .coda-drop, .coda-drop:has(audio){ | |
| box-shadow:inset 0 1px 0 rgba(255,255,255,.06), 0 0 22px rgba(111,224,245,.14) !important; | |
| } | |
| .coda-has-clip .coda-drop .wrap, .coda-drop:has(audio) .wrap{ | |
| border-style:solid !important; border-color:rgba(111,224,245,.3) !important; | |
| } | |
| /* ---------- output player -> integrated deck (REAL StaticAudio classes) ---------- */ | |
| .coda-player{ | |
| border:1px solid rgba(150,160,220,0.16) !important; border-radius:14px !important; | |
| background:linear-gradient(180deg, rgba(111,224,245,.04), rgba(169,139,255,.04)) !important; | |
| box-shadow:inset 0 1px 0 rgba(255,255,255,.05), inset 0 -1px 0 rgba(0,0,0,.5), | |
| inset 0 0 30px rgba(0,0,0,.3) !important; | |
| } | |
| .coda-player .standard-player, .coda-player .component-wrapper{ padding:14px !important; } | |
| /* recess the waveform + scrubber into a well (canvas color is locked β frame, don't recolor) */ | |
| .coda-player .waveform-container{ | |
| background:rgba(10,11,22,.4) !important; border-radius:10px !important; | |
| box-shadow:inset 0 1px 4px rgba(0,0,0,.5); | |
| } | |
| .coda-player .timestamps{ | |
| font-family:'JetBrains Mono',ui-monospace,monospace !important; | |
| font-variant-numeric:tabular-nums; color:var(--sub) !important; letter-spacing:.02em; | |
| } | |
| .coda-player .play-pause-button{ color:var(--cyan) !important; fill:var(--cyan) !important; | |
| transition:filter .2s, transform .12s; } | |
| .coda-player .play-pause-button:hover{ filter:drop-shadow(0 0 8px rgba(111,224,245,.5)); transform:scale(1.06); } | |
| /* ---------- finished-song card: living glow (UNCHANGED base) ---------- */ | |
| #coda-result{ position:relative; transition:min-height .5s var(--ease-expo); } | |
| /* while the processing 'stage' overlay is up, give the card real vertical room so | |
| the spinner, label, sub-line and step rail never crush together (the overlay is | |
| position:absolute, so it can't size the card itself). Released the instant the | |
| result lands and .coda-stage is gone β the transition above smooths the change. */ | |
| #coda-result:has(.coda-stage){ min-height:380px; } | |
| #coda-result::after{ | |
| content:''; position:absolute; inset:-1px; border-radius:20px; pointer-events:none; | |
| box-shadow:0 0 0 1px rgba(111,224,245,.10), 0 0 40px rgba(111,224,245,.06) inset; | |
| animation:codaBreath 4.5s ease-in-out infinite; | |
| } | |
| @keyframes codaBreath{ 0%,100%{opacity:.4} 50%{opacity:.9} } | |
| /* ---------- REVEAL: gold MASTER escalation (only on a real finished result) ---------- */ | |
| /* keyed off the output <audio> appearing β fully CSS, no JS reveal event needed. | |
| the one-shot flourish plays as the rule first matches (when the track lands). */ | |
| #coda-result:has(.coda-player audio){ | |
| --color-accent:var(--gold); /* play icon + seek progress -> gold */ | |
| animation:codaReveal 1.2s var(--ease-expo) both; | |
| } | |
| #coda-result:has(.coda-player audio) .coda-player{ | |
| border-top-color:rgba(246,214,138,.5) !important; | |
| box-shadow:inset 0 1px 0 rgba(246,214,138,.28), inset 0 -1px 0 rgba(0,0,0,.5), | |
| 0 0 30px rgba(246,214,138,.10) !important; | |
| } | |
| #coda-result:has(.coda-player audio) .play-pause-button{ color:var(--gold) !important; fill:var(--gold) !important; | |
| filter:drop-shadow(0 0 10px rgba(246,214,138,.5)); } | |
| #coda-result:has(.coda-player audio) .coda-result-head{ color:var(--gold); } | |
| #coda-result:has(.coda-player audio) .coda-result-dot{ background:var(--gold); box-shadow:0 0 10px var(--gold); } | |
| #coda-result:has(.coda-player audio)::after{ | |
| box-shadow:0 0 0 1px rgba(246,214,138,.16), 0 0 46px rgba(246,214,138,.09) inset; | |
| animation:codaBreath 5.5s ease-in-out infinite; /* warmer, slower */ | |
| } | |
| /* one-shot reveal flourish (warm). Played via the :has() rule above when the | |
| finished <audio> first appears; this class is kept as a reduced-motion hook. */ | |
| .coda-reveal{ animation:codaReveal 1.1s var(--ease-expo) both; } | |
| @keyframes codaReveal{ | |
| 0%{ transform:scale(.985); } | |
| 35%{ transform:scale(1.012); box-shadow:0 0 0 1px rgba(246,214,138,.5), 0 0 60px rgba(246,214,138,.4); } | |
| 100%{ transform:scale(1); } | |
| } | |
| /* ---------- footer ---------- */ | |
| #coda-foot{ text-align:center; color:#6b7388; font-size:.85rem; margin-top:18px; line-height:1.7; } | |
| #coda-foot strong{ color:var(--violet); } | |
| .coda-badge{ | |
| display:inline-block; margin-top:8px; padding:5px 14px; border-radius:999px; | |
| font-size:.72rem; letter-spacing:.14em; text-transform:uppercase; color:var(--sub); | |
| border:1px solid var(--glass-brd); background:rgba(255,255,255,.03); | |
| } | |
| /* ---------- kill default-Gradio tells ---------- */ | |
| footer{ display:none !important; } | |
| .coda-readout .progress-text, #coda-result .progress-text, | |
| .coda-readout .eta-bar, #coda-result .eta-bar{ display:none !important; } | |
| /* ---------- 'CODA heard' readout -> backlit LCD HUD (REAL .coda-hud* classes) ---------- */ | |
| .coda-readout{ position:relative; } | |
| .coda-hud{ | |
| font-family:'Space Grotesk',sans-serif; position:relative; overflow:hidden; | |
| padding:16px 18px; border-radius:14px; | |
| background:linear-gradient(180deg,#0b1119,#070b12); | |
| border:1px solid rgba(111,224,245,.14); | |
| box-shadow:inset 0 1px 0 rgba(255,255,255,.04), inset 0 0 0 1px rgba(0,0,0,.6), | |
| inset 0 0 40px rgba(0,0,0,.5), 0 0 26px rgba(111,224,245,.05); | |
| animation:codaLcdOn .42s ease-out both; | |
| } | |
| /* faint scanline overlay (cheap) */ | |
| .coda-hud::after{ | |
| content:''; position:absolute; inset:0; pointer-events:none; opacity:.45; | |
| background:repeating-linear-gradient(0deg, rgba(0,0,0,.16) 0, rgba(0,0,0,.16) 1px, transparent 1px, transparent 3px); | |
| } | |
| /* one-shot scan-sweep on power-on */ | |
| .coda-hud::before{ | |
| content:''; position:absolute; top:0; left:-40%; width:40%; height:100%; pointer-events:none; | |
| background:linear-gradient(90deg, transparent, rgba(111,224,245,.18), transparent); | |
| animation:codaScan .8s ease-out 1 both; | |
| } | |
| @keyframes codaLcdOn{ from{opacity:0; filter:brightness(.4)} to{opacity:1; filter:brightness(1)} } | |
| @keyframes codaScan{ from{left:-40%} to{left:120%} } | |
| .coda-hud-head{ | |
| display:flex; align-items:center; gap:.5rem; color:var(--cyan); | |
| text-transform:uppercase; letter-spacing:.2em; font-size:.72rem; font-weight:600; | |
| margin-bottom:14px; position:relative; | |
| } | |
| .coda-hud-dot{ | |
| width:7px; height:7px; border-radius:50%; background:var(--cyan); | |
| box-shadow:0 0 10px var(--cyan); animation:codaPulse 2.4s ease-in-out infinite; | |
| } | |
| .coda-hud-live{ | |
| margin-left:auto; font-size:.6rem; letter-spacing:.18em; color:#7be8ff; | |
| padding:3px 8px; border-radius:999px; border:1px solid rgba(111,224,245,.25); | |
| background:rgba(111,224,245,.06); | |
| } | |
| .coda-hud-grid{ display:grid; grid-template-columns:1fr 1fr; gap:10px; position:relative; } | |
| .coda-hud-cell{ | |
| position:relative; padding:12px 14px; border-radius:12px; | |
| background:linear-gradient(180deg, rgba(0,0,0,.4), rgba(0,0,0,.2)); | |
| border:1px solid rgba(150,160,220,.12); | |
| box-shadow:inset 0 1px 0 rgba(255,255,255,.05), inset 0 0 24px rgba(0,0,0,.4); | |
| /* per-cell settle stagger (meter locking) */ | |
| animation:codaCellSettle .26s var(--ease-expo) both; | |
| } | |
| .coda-hud-cell:nth-child(2){ animation-delay:.06s } | |
| .coda-hud-cell:nth-child(3){ animation-delay:.12s } | |
| .coda-hud-cell:nth-child(4){ animation-delay:.18s } | |
| @keyframes codaCellSettle{ from{opacity:0; transform:translateY(5px)} to{opacity:1; transform:none} } | |
| .coda-hud-k{ | |
| color:var(--sub); text-transform:uppercase; letter-spacing:.16em; | |
| font-size:.62rem; font-weight:600; margin-bottom:4px; | |
| } | |
| .coda-hud-v{ | |
| font-family:'JetBrains Mono','ui-monospace',monospace; color:#eaf0ff; | |
| font-size:1.5rem; font-weight:500; line-height:1; display:flex; | |
| align-items:baseline; gap:.3rem; font-variant-numeric:tabular-nums; | |
| text-shadow:0 0 10px rgba(111,224,245,.25); | |
| } | |
| .coda-hud-u{ font-size:.7rem; color:var(--sub); letter-spacing:.1em; font-weight:600; } | |
| .coda-hud-note{ | |
| margin-top:12px; color:var(--sub); font-size:.82rem; line-height:1.55; font-family:'Inter',sans-serif; | |
| position:relative; | |
| } | |
| .coda-hud-note em{ color:#cfd6f5; font-style:italic; } | |
| .coda-hud-tag{ | |
| display:inline-block; margin-right:.5rem; font-size:.62rem; letter-spacing:.12em; | |
| color:var(--gold); padding:2px 8px; border-radius:6px; vertical-align:middle; | |
| border:1px solid rgba(246,214,138,.25); background:rgba(246,214,138,.06); | |
| } | |
| .coda-hud-warn{ animation:none; } | |
| .coda-hud-warn .coda-hud-head{ color:var(--gold); } | |
| .coda-hud-warn .coda-hud-dot{ background:var(--gold); box-shadow:0 0 10px var(--gold); } | |
| /* ---------- result panel head ---------- */ | |
| .coda-result-head{ | |
| display:flex; align-items:center; gap:.5rem; color:var(--cyan); | |
| text-transform:uppercase; letter-spacing:.2em; font-size:.72rem; font-weight:600; margin-bottom:4px; | |
| } | |
| .coda-result-dot{ | |
| width:7px; height:7px; border-radius:50%; background:var(--violet); | |
| box-shadow:0 0 10px var(--violet); animation:codaPulse 2.6s ease-in-out infinite; | |
| } | |
| /* ---------- summary -> mono gold spec plate (styles <strong>, NOT <code>) ---------- */ | |
| .coda-summary{ font-family:'Inter',sans-serif; } | |
| .coda-summary h3{ color:var(--text); font-family:'Space Grotesk',sans-serif; letter-spacing:.01em; } | |
| .coda-summary strong{ | |
| font-family:'JetBrains Mono',ui-monospace,monospace; color:var(--gold); | |
| font-weight:500; font-variant-numeric:tabular-nums; | |
| background:rgba(246,214,138,.06); border:1px solid rgba(246,214,138,.18); | |
| border-radius:6px; padding:1px 7px; | |
| } | |
| /* ---------- live processing 'stage' overlay (REAL .coda-stage* classes) ---------- */ | |
| #coda-stage:empty{ display:none; } | |
| .coda-stage{ | |
| position:absolute; inset:0; z-index:6; border-radius:18px; | |
| display:flex; flex-direction:column; align-items:center; justify-content:center; | |
| /* gap:0 β the inter-element rhythm is set by explicit margins below so the | |
| label+sub read as one pair while the spinner and step rail get real air */ | |
| gap:0; text-align:center; padding:42px 34px; | |
| background:radial-gradient(120% 120% at 50% 30%, rgba(20,16,40,.82), rgba(8,9,18,.92)); | |
| backdrop-filter:blur(10px); -webkit-backdrop-filter:blur(10px); | |
| box-shadow:inset 0 1px 0 rgba(255,255,255,.05), inset 0 0 40px rgba(0,0,0,.5); | |
| animation:codaFade .5s ease both; | |
| } | |
| @keyframes codaFade{ from{opacity:0} to{opacity:1} } | |
| .coda-stage-core{ position:relative; width:96px; height:96px; flex:0 0 96px; display:flex; align-items:center; justify-content:center; margin-bottom:30px; } | |
| .coda-stage-ring{ | |
| position:absolute; inset:0; border-radius:50%; border:2px solid transparent; | |
| border-top-color:var(--cyan); border-right-color:rgba(111,224,245,.35); | |
| box-shadow:0 0 28px rgba(111,224,245,.35); animation:codaSpin 1.5s linear infinite; | |
| } | |
| .coda-stage-ring.r2{ | |
| inset:14px; border-top-color:var(--violet); border-right-color:rgba(169,139,255,.3); | |
| animation:codaSpin 2.1s linear infinite reverse; | |
| } | |
| @keyframes codaSpin{ to{ transform:rotate(360deg) } } | |
| .coda-stage-bars{ display:flex; gap:3px; align-items:center; height:30px; } | |
| .coda-stage-bars span{ | |
| width:3px; height:100%; border-radius:2px; transform:scaleY(.3); transform-origin:center; | |
| background:linear-gradient(180deg,#7be8ff,#a98bff); animation:codaBar 1s ease-in-out infinite; | |
| } | |
| .coda-stage-bars span:nth-child(2){animation-delay:.1s} | |
| .coda-stage-bars span:nth-child(3){animation-delay:.2s} | |
| .coda-stage-bars span:nth-child(4){animation-delay:.3s} | |
| .coda-stage-bars span:nth-child(5){animation-delay:.15s} | |
| .coda-stage-bars span:nth-child(6){animation-delay:.25s} | |
| .coda-stage-bars span:nth-child(7){animation-delay:.35s} | |
| .coda-stage-bars span:nth-child(8){animation-delay:.2s} | |
| .coda-stage-bars span:nth-child(9){animation-delay:.05s} | |
| .coda-stage-label{ | |
| font-family:'Space Grotesk',sans-serif; font-size:1.08rem; font-weight:600; | |
| color:#eef1ff; letter-spacing:.01em; line-height:1.3; | |
| } | |
| .coda-stage-dots::after{ | |
| content:'β¦'; animation:codaDots 1.4s steps(4,end) infinite; | |
| display:inline-block; width:1.2em; text-align:left; overflow:hidden; vertical-align:bottom; | |
| } | |
| @keyframes codaDots{ 0%{clip-path:inset(0 100% 0 0)} 100%{clip-path:inset(0 0 0 0)} } | |
| .coda-stage-sub{ | |
| color:var(--sub); font-size:.82rem; letter-spacing:.02em; max-width:320px; | |
| margin-top:11px; line-height:1.5; /* hugs the label as a pair */ | |
| font-family:'JetBrains Mono',ui-monospace,monospace; font-variant-numeric:tabular-nums; | |
| } | |
| .coda-stage-sub:empty{ display:none; } /* no phantom gap when a beat has no sub */ | |
| .coda-stage-steps{ display:flex; gap:10px; margin-top:32px; } | |
| .coda-step{ | |
| width:34px; height:3px; border-radius:3px; background:rgba(255,255,255,.12); | |
| transition:background .3s, box-shadow .3s; | |
| } | |
| .coda-step.on{ background:var(--cyan); } /* latched done -> cyan */ | |
| .coda-step.now{ background:var(--violet); box-shadow:0 0 12px var(--violet); /* active engine step -> violet */ | |
| animation:codaPulse 1.1s ease-in-out infinite; } | |
| /* ---------- 'has-clip' ambient reaction (UNCHANGED) ---------- */ | |
| .gradio-container:has(.coda-drop audio) .coda-aurora{ filter:saturate(150%); animation-duration:18s; } | |
| .gradio-container:has(.coda-drop audio) #coda-eq span{ animation-duration:.9s; } | |
| /* ---------- reduced-motion: kill EVERY animation (existing + new) ---------- */ | |
| @media (prefers-reduced-motion: reduce){ | |
| .coda-aurora, #coda-eq span, .coda-particles span, .coda-label::before, | |
| #coda-result::after, #coda-title, .coda-glass, .coda-card, | |
| .coda-hud, .coda-hud::before, .coda-hud-cell, .coda-hud-dot, | |
| .coda-go:not([disabled]), .coda-reveal, .coda-stage, .coda-stage-ring, | |
| .coda-stage-bars span, .coda-stage-dots::after, .coda-step.now, | |
| .coda-result-dot, #coda-eq span, .coda-drop .icon-wrap, | |
| #coda-result:has(.coda-player audio){ | |
| animation:none !important; | |
| } | |
| .coda-hud::before{ display:none !important; } | |
| } | |
| /* ===================================================================== */ | |
| /* CODA EXTRAS β premium touches kept from the prior pass (additive) */ | |
| /* ===================================================================== */ | |
| /* uppercase engraved legends on every control label (Gradio block-info) */ | |
| .gradio-container [data-testid="block-info"]{ | |
| color:#aeb6cf !important; text-transform:uppercase; letter-spacing:.15em; | |
| font-size:.7rem !important; font-weight:600; font-family:'Space Grotesk',sans-serif; | |
| } | |
| .gradio-container .info-text{ | |
| color:#828aa6 !important; font-size:.78rem !important; line-height:1.5 !important; | |
| } | |
| /* the audio module's floating label -> engraved chip */ | |
| .coda-drop label.float, .coda-player label.float{ | |
| color:#aeb6cf !important; text-transform:uppercase; letter-spacing:.15em; | |
| font-size:.66rem !important; font-weight:600; | |
| background:rgba(10,12,22,.7) !important; border-radius:8px !important; | |
| border:1px solid rgba(150,160,220,.12) !important; -webkit-backdrop-filter:blur(6px); backdrop-filter:blur(6px); | |
| } | |
| /* never show Gradio's default status text inside our panels */ | |
| #coda-result [data-testid="status-tracker"], | |
| .coda-readout [data-testid="status-tracker"]{ display:none !important; } | |
| /* dropzone empty-state: tall inviting channel + glowing floating icon */ | |
| .coda-drop .audio-container{ | |
| min-height:200px; display:flex; flex-direction:column; | |
| align-items:center; justify-content:center; | |
| } | |
| .coda-drop .icon-wrap{ | |
| width:54px !important; height:54px !important; margin-bottom:6px; | |
| display:flex; align-items:center; justify-content:center; border-radius:50%; | |
| background:radial-gradient(circle at 40% 35%, rgba(111,224,245,.18), rgba(111,224,245,0) 70%); | |
| animation:codaFloatY 3.4s ease-in-out infinite; | |
| } | |
| .coda-drop .icon-wrap svg{ | |
| width:26px !important; height:26px !important; opacity:1 !important; | |
| color:var(--cyan) !important; stroke:var(--cyan) !important; | |
| filter:drop-shadow(0 0 10px rgba(111,224,245,.6)); | |
| } | |
| @keyframes codaFloatY{ 0%,100%{transform:translateY(0)} 50%{transform:translateY(-6px)} } | |
| .coda-drop .or{ color:#5f6886 !important; font-size:.72rem; letter-spacing:.1em; } | |
| /* 'Click to Upload' affordance as a refined pill */ | |
| .coda-drop button.center{ | |
| border:1px solid rgba(111,224,245,.3) !important; border-radius:10px !important; | |
| padding:7px 16px !important; color:#dff6ff !important; | |
| background:rgba(111,224,245,.06) !important; font-weight:500 !important; | |
| } | |
| .coda-drop button.center:hover{ | |
| background:rgba(111,224,245,.12) !important; | |
| box-shadow:0 0 20px rgba(111,224,245,.2) !important; | |
| } | |
| .coda-drop button:not(.coda-demo):not(.center):hover, .coda-player button:hover{ | |
| color:var(--cyan) !important; | |
| } | |
| @media (prefers-reduced-motion: reduce){ .coda-drop .icon-wrap{ animation:none !important; } } | |
| /* ===== FEATURE A β PLAY-ENERGIZED HERO ===== */ | |
| /* When the player reports it's playing, the JS adds .coda-eq-playing and the | |
| spectrum comes alive: each bar beats faster, taller and brighter (keeping its | |
| per-bar inline animation-delay so the field stays lively, not in lockstep). | |
| Pure CSS, so it renders even in a backgrounded tab and is cheap. */ | |
| #coda-eq.coda-eq-playing{ | |
| filter: drop-shadow(0 0 14px rgba(124,100,255,.28)); | |
| transition: filter .25s ease; | |
| } | |
| #coda-eq.coda-eq-playing span{ | |
| animation-name: codaBarHot !important; | |
| animation-duration: .5s !important; | |
| box-shadow: 0 0 16px rgba(111,224,245,.85), 0 0 5px rgba(169,139,255,.6); | |
| } | |
| @keyframes codaBarHot{ 0%,100%{ transform:scaleY(.14) } 50%{ transform:scaleY(1.08) } } | |
| @media (prefers-reduced-motion: reduce){ | |
| #coda-eq.coda-eq-playing span{ animation:none !important; transform:none !important; } | |
| } | |
| /* ===== FEATURE A (LIVE) β real audio-reactive bars ===== | |
| When a Web Audio AnalyserNode is driving the field, the JS adds .coda-eq-live | |
| and writes each bar's scaleY inline from getByteFrequencyData(). A *running* | |
| CSS animation would override those inline transforms, so we kill it here. This | |
| rule sits AFTER .coda-eq-playing so its equal-specificity !important wins by | |
| source order. On stop the JS removes the class and the CSS breathing resumes. */ | |
| #coda-eq.coda-eq-live span{ | |
| animation: none !important; | |
| transition: transform .06s linear, box-shadow .2s ease; | |
| box-shadow: 0 0 16px rgba(111,224,245,.7), 0 0 6px rgba(169,139,255,.55); | |
| } | |
| @media (prefers-reduced-motion: reduce){ | |
| #coda-eq.coda-eq-live span{ transition:none !important; } | |
| } | |
| /* ===== FEATURE B β SEAM REVEAL (gold-accented, part of the MASTER reveal) ===== */ | |
| #coda-seam-overlay{ | |
| position:absolute; inset:0; pointer-events:none; z-index:4; display:none; | |
| } | |
| .coda-seam-line{ | |
| position:absolute; top:6%; bottom:6%; left:var(--seam, 50%); | |
| width:2px; transform:translateX(-1px); | |
| background:linear-gradient(180deg, | |
| rgba(246,214,138,0) 0%, rgba(246,214,138,.95) 18%, | |
| rgba(246,214,138,.95) 82%, rgba(246,214,138,0) 100%); | |
| box-shadow:0 0 10px rgba(246,214,138,.65), 0 0 2px rgba(246,214,138,.9); | |
| border-radius:2px; | |
| } | |
| .coda-seam-line::before{ | |
| content:''; position:absolute; top:-3px; left:50%; | |
| width:7px; height:7px; transform:translateX(-50%) rotate(45deg); | |
| background:var(--gold); border-radius:1px; | |
| box-shadow:0 0 8px rgba(246,214,138,.8); | |
| } | |
| .coda-seam-tag{ | |
| position:absolute; top:2px; font-family:'Space Grotesk',sans-serif; | |
| font-size:.56rem; letter-spacing:.18em; font-weight:600; text-transform:uppercase; | |
| padding:2px 7px; border-radius:6px; white-space:nowrap; | |
| -webkit-backdrop-filter:blur(4px); backdrop-filter:blur(4px); | |
| } | |
| .coda-seam-yours{ | |
| right:calc(100% - var(--seam, 50%) + 8px); | |
| color:#9aa3bd; border:1px solid rgba(150,160,220,.22); background:rgba(10,12,22,.55); | |
| } | |
| .coda-seam-coda{ | |
| left:calc(var(--seam, 50%) + 8px); | |
| color:var(--gold); border:1px solid rgba(246,214,138,.35); | |
| background:rgba(246,214,138,.08); text-shadow:0 0 8px rgba(246,214,138,.4); | |
| } | |
| #coda-seam-overlay.coda-seam-near-end .coda-seam-coda{ left:auto; right:6px; } | |
| #coda-seam-overlay.coda-seam-near-start .coda-seam-yours{ right:auto; left:6px; } | |
| #coda-seam-overlay.coda-seam-in .coda-seam-line{ animation:codaSeamWipe .7s var(--ease-expo) both; } | |
| #coda-seam-overlay.coda-seam-in .coda-seam-tag{ animation:codaSeamTag .5s var(--ease-expo) .25s both; } | |
| @keyframes codaSeamWipe{ 0%{ transform:translateX(-1px) scaleY(0); opacity:0; } 100%{ transform:translateX(-1px) scaleY(1); opacity:1; } } | |
| @keyframes codaSeamTag{ from{ opacity:0; transform:translateY(-3px); } to{ opacity:1; transform:none; } } | |
| /* "Play from the seam" key β gold utility control in the result head */ | |
| .coda-seam-btn{ | |
| display:none; margin-left:auto; align-items:center; gap:.4rem; | |
| font-family:'Space Grotesk',sans-serif; font-size:.62rem; letter-spacing:.12em; | |
| text-transform:uppercase; font-weight:600; cursor:pointer; | |
| color:var(--gold); padding:5px 12px; border-radius:999px; | |
| border:1px solid rgba(246,214,138,.3); | |
| background:linear-gradient(180deg, rgba(246,214,138,.10), rgba(246,214,138,.04)); | |
| box-shadow:inset 0 1px 0 rgba(255,255,255,.06), 0 0 0 0 rgba(246,214,138,0); | |
| transition:transform .2s var(--ease-expo), box-shadow .25s, background .25s, color .2s; | |
| } | |
| /* the existing .coda-result-head is already display:flex (app.py line 775); the | |
| button uses margin-left:auto to push to the right edge β no head override needed */ | |
| .coda-seam-btn:hover{ | |
| transform:translateY(-1px); color:#fff3d6; | |
| background:linear-gradient(180deg, rgba(246,214,138,.18), rgba(246,214,138,.07)); | |
| box-shadow:inset 0 1px 0 rgba(255,255,255,.1), 0 0 18px rgba(246,214,138,.4); | |
| } | |
| .coda-seam-btn:active{ transform:translateY(0) scale(.98); } | |
| .coda-seam-btn .coda-seam-ico{ font-size:.6rem; line-height:1; filter:drop-shadow(0 0 6px rgba(246,214,138,.6)); } | |
| @media (prefers-reduced-motion: reduce){ | |
| #coda-seam-overlay.coda-seam-in .coda-seam-line, | |
| #coda-seam-overlay.coda-seam-in .coda-seam-tag{ animation:none !important; } | |
| .coda-seam-btn{ transition:none !important; } | |
| } | |
| /* ===== FEATURE C β CINEMATIC ONBOARDING ===== */ | |
| #coda-intro-overlay{ | |
| position:fixed; inset:0; z-index:9999; opacity:0; pointer-events:none; | |
| display:flex; align-items:center; justify-content:center; padding:24px; | |
| background: | |
| radial-gradient(60% 60% at 50% 38%, rgba(20,18,44,.72), rgba(6,7,16,.96)), | |
| #06070f; | |
| -webkit-backdrop-filter:blur(6px); backdrop-filter:blur(6px); | |
| transition:opacity .6s cubic-bezier(.16,1,.3,1); | |
| } | |
| #coda-intro-overlay.coda-intro-on{ opacity:1; pointer-events:auto; } | |
| #coda-intro-overlay.coda-intro-out{ opacity:0; pointer-events:none; } | |
| #coda-intro-overlay.coda-intro-out #coda-intro-card{ | |
| transform:translateY(-8px) scale(.985); | |
| transition:transform .6s cubic-bezier(.16,1,.3,1); | |
| } | |
| body.coda-intro-lock{ overflow:hidden !important; } | |
| #coda-intro-overlay::after{ | |
| content:''; position:absolute; inset:0; pointer-events:none; opacity:.5; | |
| background:repeating-linear-gradient(0deg, | |
| rgba(0,0,0,.10) 0, rgba(0,0,0,.10) 1px, transparent 1px, transparent 3px); | |
| mix-blend-mode:overlay; | |
| } | |
| #coda-intro-card{ | |
| position:relative; width:min(640px,92vw); text-align:center; | |
| padding:8px 18px 26px; color:#e9ecf8; font-family:'Space Grotesk',sans-serif; | |
| } | |
| #coda-intro-skip{ | |
| position:absolute; top:18px; right:20px; z-index:2; | |
| font-family:'Inter',sans-serif; font-size:.72rem; letter-spacing:.18em; | |
| text-transform:uppercase; color:#6b7388; cursor:pointer; user-select:none; | |
| padding:6px 10px; border-radius:8px; border:1px solid transparent; | |
| transition:color .2s, border-color .2s, background .2s; | |
| } | |
| #coda-intro-skip:hover, #coda-intro-skip:focus-visible{ | |
| color:#cfd6f5; border-color:rgba(150,160,220,.2); | |
| background:rgba(255,255,255,.03); outline:none; | |
| } | |
| .coda-intro-beat{ opacity:0; transform:translateY(10px); will-change:opacity,transform; } | |
| .coda-intro-on .coda-intro-beat{ animation:codaIntroIn .9s cubic-bezier(.16,1,.3,1) both; } | |
| .coda-intro-on .coda-intro-beat.b1{ animation-delay:.25s; } | |
| .coda-intro-on .coda-intro-beat.b2{ animation-delay:1.5s; } | |
| .coda-intro-on .coda-intro-beat.b3{ animation-delay:2.7s; } | |
| .coda-intro-on .coda-intro-beat.b4{ animation-delay:4.2s; } | |
| @keyframes codaIntroIn{ | |
| from{ opacity:0; transform:translateY(10px); filter:blur(4px); } | |
| to { opacity:1; transform:none; filter:blur(0); } | |
| } | |
| .coda-intro-year{ | |
| font-family:'JetBrains Mono',ui-monospace,monospace; | |
| font-size:.92rem; letter-spacing:.5em; color:#7be8ff; text-indent:.5em; | |
| text-transform:uppercase; margin:0 0 6px; text-shadow:0 0 14px rgba(111,224,245,.4); | |
| } | |
| .coda-intro-line{ | |
| font-family:'Inter',sans-serif; font-weight:400; | |
| font-size:clamp(1rem,3.4vw,1.32rem); line-height:1.5; color:#cfd6f5; | |
| margin:0 auto 4px; max-width:30ch; | |
| } | |
| .coda-intro-line em{ color:#f6d68a; font-style:italic; } | |
| .coda-intro-mark{ | |
| font-family:'Space Grotesk',sans-serif; font-weight:700; | |
| font-size:clamp(2.4rem,11vw,4.2rem); letter-spacing:.2em; margin:14px 0 2px; | |
| text-indent:.2em; | |
| background:linear-gradient(100deg,#6fe0f5 0%,#a98bff 46%,#f6d68a 100%); | |
| background-size:220% auto; -webkit-background-clip:text; background-clip:text; | |
| -webkit-text-fill-color:transparent; color:transparent; | |
| filter:drop-shadow(0 0 30px rgba(150,140,255,.3)); | |
| } | |
| .coda-intro-on .coda-intro-mark{ animation:codaIntroIn .9s cubic-bezier(.16,1,.3,1) 2.7s both, codaShine 9s linear 3.6s infinite; } | |
| #coda-intro-eq{ | |
| display:flex; gap:3px; align-items:center; justify-content:center; | |
| height:20px; max-width:180px; margin:6px auto 2px; opacity:0; | |
| } | |
| .coda-intro-on #coda-intro-eq{ animation:codaIntroIn .8s ease-out 3.1s both; } | |
| #coda-intro-eq span{ | |
| flex:1 1 0; max-width:4px; height:100%; border-radius:3px; transform:scaleY(.2); | |
| transform-origin:center; background:linear-gradient(180deg,#7be8ff,#a98bff); | |
| box-shadow:0 0 8px rgba(111,224,245,.4); animation:codaBar 1.2s ease-in-out infinite; | |
| } | |
| #coda-intro-eq span:nth-child(2){animation-delay:.1s} | |
| #coda-intro-eq span:nth-child(3){animation-delay:.2s} | |
| #coda-intro-eq span:nth-child(4){animation-delay:.3s} | |
| #coda-intro-eq span:nth-child(5){animation-delay:.15s} | |
| #coda-intro-eq span:nth-child(6){animation-delay:.25s} | |
| #coda-intro-eq span:nth-child(7){animation-delay:.35s} | |
| #coda-intro-eq span:nth-child(8){animation-delay:.2s} | |
| #coda-intro-eq span:nth-child(9){animation-delay:.3s} | |
| #coda-intro-eq span:nth-child(10){animation-delay:.1s} | |
| #coda-intro-eq span:nth-child(11){animation-delay:.22s} | |
| #coda-intro-eq span:nth-child(12){animation-delay:.32s} | |
| .coda-intro-fin{ | |
| font-family:'Space Grotesk',sans-serif; font-weight:600; | |
| font-size:clamp(1.05rem,3.6vw,1.4rem); letter-spacing:.04em; color:#f6d68a; | |
| margin:10px 0 22px; text-shadow:0 0 18px rgba(246,214,138,.3); | |
| } | |
| #coda-intro-enter{ | |
| font-family:'Space Grotesk',sans-serif; font-weight:600; letter-spacing:.04em; | |
| font-size:.92rem; color:#0a0a16; cursor:pointer; user-select:none; | |
| padding:13px 30px; border-radius:14px; border:0; | |
| background:linear-gradient(100deg,#6fe0f5,#a98bff 60%,#f6d68a); | |
| background-size:180% auto; | |
| box-shadow:inset 0 1px 0 rgba(255,255,255,.45), | |
| 0 8px 30px rgba(124,100,255,.4), 0 0 0 1px rgba(255,255,255,.08); | |
| transition:transform .25s cubic-bezier(.16,1,.3,1), | |
| box-shadow .25s, background-position .6s, filter .2s; | |
| } | |
| #coda-intro-enter:hover, #coda-intro-enter:focus-visible{ | |
| transform:translateY(-2px); filter:brightness(1.05); | |
| background-position:right center; outline:none; | |
| box-shadow:inset 0 1px 0 rgba(255,255,255,.5), | |
| 0 14px 40px rgba(124,100,255,.5), 0 0 34px rgba(246,214,138,.4); | |
| } | |
| #coda-intro-enter:active{ transform:translateY(0) scale(.99); } | |
| #coda-intro-overlay.coda-intro-ready #coda-intro-enter{ animation:codaIntroPulse 2s ease-in-out infinite; } | |
| @keyframes codaIntroPulse{ | |
| 0%,100%{ box-shadow:inset 0 1px 0 rgba(255,255,255,.45), 0 8px 30px rgba(124,100,255,.4), 0 0 0 1px rgba(255,255,255,.08); } | |
| 50% { box-shadow:inset 0 1px 0 rgba(255,255,255,.5), 0 10px 34px rgba(124,100,255,.55), 0 0 30px rgba(246,214,138,.45); } | |
| } | |
| #coda-intro-replay{ | |
| display:inline-block; margin-top:6px; cursor:pointer; color:#6b7388; | |
| font-size:.74rem; letter-spacing:.14em; text-transform:uppercase; | |
| border-bottom:1px dotted rgba(150,160,220,.3); transition:color .2s; | |
| } | |
| #coda-intro-replay:hover, #coda-intro-replay:focus-visible{ color:var(--cyan); outline:none; } | |
| #coda-intro-overlay.coda-intro-reduced .coda-intro-beat, | |
| #coda-intro-overlay.coda-intro-reduced #coda-intro-eq{ | |
| opacity:1 !important; transform:none !important; filter:none !important; animation:none !important; | |
| } | |
| #coda-intro-overlay.coda-intro-reduced #coda-intro-eq span{ transform:scaleY(.6) !important; animation:none !important; } | |
| @media (prefers-reduced-motion: reduce){ | |
| #coda-intro-overlay, #coda-intro-card, .coda-intro-beat, .coda-intro-mark, | |
| #coda-intro-eq, #coda-intro-eq span, #coda-intro-overlay.coda-intro-ready #coda-intro-enter{ | |
| animation:none !important; | |
| } | |
| .coda-intro-beat, #coda-intro-eq{ opacity:1 !important; transform:none !important; filter:none !important; } | |
| #coda-intro-overlay{ transition:opacity .2s linear; } | |
| } | |
| /* ===================================================================== */ | |
| /* CODA UI FIXES (additive) β server-rendered before/after ribbon, full */ | |
| /* waveform, instant 'listening' readout, and elegance polish. */ | |
| /* ===================================================================== */ | |
| /* ---------- before/after COMPOSITION RIBBON (server-rendered, exact) ---------- | |
| replaces the old client-side seam overlay that drifted/stuck on the waveform. | |
| The split is baked in as inline width/left %, so the join is ALWAYS exactly | |
| where CODA begins. */ | |
| .coda-seam-ribbon{ | |
| margin:16px 2px 2px; font-family:'Space Grotesk',sans-serif; | |
| animation:codaSeamRibbonIn .7s var(--ease-expo) both; | |
| } | |
| @keyframes codaSeamRibbonIn{ from{opacity:0; transform:translateY(7px)} to{opacity:1; transform:none} } | |
| .coda-seam-cap{ | |
| display:flex; justify-content:space-between; align-items:baseline; | |
| font-size:.6rem; letter-spacing:.18em; text-transform:uppercase; | |
| color:var(--sub); margin:0 2px 8px; font-weight:600; | |
| } | |
| .coda-seam-cap-tot{ | |
| font-family:'JetBrains Mono',ui-monospace,monospace; color:#cfd6f5; | |
| letter-spacing:.05em; font-variant-numeric:tabular-nums; | |
| } | |
| .coda-seam-track{ | |
| position:relative; display:flex; height:38px; border-radius:11px; overflow:hidden; | |
| border:1px solid rgba(150,160,220,.16); | |
| box-shadow:inset 0 1px 4px rgba(0,0,0,.55), inset 0 -1px 0 rgba(255,255,255,.04); | |
| } | |
| .coda-seam-seg{ | |
| display:flex; align-items:center; min-width:0; padding:0 13px; overflow:hidden; | |
| position:relative; transition:width .5s var(--ease-expo); | |
| } | |
| .coda-seam-seg-yours{ | |
| justify-content:flex-start; | |
| background:linear-gradient(180deg, rgba(111,224,245,.13), rgba(111,224,245,.04)); | |
| } | |
| .coda-seam-seg-coda{ | |
| justify-content:flex-end; | |
| background:linear-gradient(180deg, rgba(246,214,138,.18), rgba(246,214,138,.06)); | |
| } | |
| .coda-seam-seg-lab{ font-size:.62rem; font-weight:600; letter-spacing:.16em; white-space:nowrap; } | |
| .coda-seam-seg-yours .coda-seam-seg-lab{ color:var(--cyan); text-shadow:0 0 10px rgba(111,224,245,.3); } | |
| .coda-seam-seg-coda .coda-seam-seg-lab{ color:var(--gold); text-shadow:0 0 10px rgba(246,214,138,.35); } | |
| .coda-seam-join{ | |
| position:absolute; top:0; bottom:0; width:2px; transform:translateX(-1px); | |
| background:linear-gradient(180deg, rgba(246,214,138,.25), var(--gold) 22%, var(--gold) 78%, rgba(246,214,138,.25)); | |
| box-shadow:0 0 10px rgba(246,214,138,.7), 0 0 2px rgba(246,214,138,.9); | |
| transform-origin:center; animation:codaSeamWipe .7s var(--ease-expo) .25s both; | |
| } | |
| .coda-seam-join::before, .coda-seam-join::after{ | |
| content:''; position:absolute; left:50%; width:7px; height:7px; | |
| transform:translateX(-50%) rotate(45deg); background:var(--gold); border-radius:1px; | |
| box-shadow:0 0 8px rgba(246,214,138,.8); | |
| } | |
| .coda-seam-join::before{ top:-3px; } | |
| .coda-seam-join::after{ bottom:-3px; } | |
| .coda-seam-foot{ | |
| display:flex; justify-content:space-between; margin:9px 2px 0; | |
| font-family:'JetBrains Mono',ui-monospace,monospace; font-size:.66rem; | |
| font-variant-numeric:tabular-nums; letter-spacing:.02em; | |
| } | |
| .coda-seam-foot-y{ color:var(--cyan); } | |
| .coda-seam-foot-c{ color:var(--gold); } | |
| /* ---------- OUTPUT WAVEFORM: show it in full (was clipped ~15px) ---------- | |
| WaveSurfer draws taller than Gradio's default container, so the lower part of | |
| the waveform was bleeding into the timestamps and looking 'cut off'. Give it | |
| real vertical room and let it breathe. */ | |
| .coda-player .component-wrapper{ overflow:visible; } | |
| .coda-player .waveform-container{ | |
| min-height:90px !important; align-items:center !important; overflow:visible !important; | |
| } | |
| /* ---------- 'CODA HEARD' instant LISTENING state ---------- | |
| the readout lights up the moment a clip lands, before key/tempo analysis is | |
| done β so it never sits greyed-out waiting on generation. */ | |
| .coda-hud-listening .coda-hud-live{ | |
| color:var(--gold); border-color:rgba(246,214,138,.3); background:rgba(246,214,138,.06); | |
| } | |
| .coda-hud-scan{ | |
| height:6px; margin:2px 0 12px; border-radius:3px; overflow:hidden; | |
| background:rgba(0,0,0,.42); box-shadow:inset 0 1px 2px rgba(0,0,0,.6); | |
| } | |
| .coda-hud-scan span{ | |
| display:block; height:100%; width:36%; border-radius:3px; | |
| background:linear-gradient(90deg, transparent, var(--cyan), transparent); | |
| animation:codaHudScan 1.15s var(--ease-expo) infinite; | |
| } | |
| @keyframes codaHudScan{ 0%{transform:translateX(-120%)} 100%{transform:translateX(380%)} } | |
| /* ---------- ELEGANCE POLISH ---------- */ | |
| .gradio-container *{ -webkit-tap-highlight-color:transparent; } | |
| /* one consistent, soft focus ring instead of the browser default */ | |
| .gradio-container button:focus-visible, | |
| .gradio-container [tabindex]:focus-visible, | |
| .coda-vibe textarea:focus-visible{ | |
| outline:none !important; | |
| box-shadow:0 0 0 2px rgba(111,224,245,.5), 0 0 18px rgba(111,224,245,.25) !important; | |
| } | |
| /* quiet utility key easing (was a generic 'all .25s ease') */ | |
| .coda-demo{ transition:color .25s var(--ease-expo), border-color .25s var(--ease-expo), | |
| background .25s var(--ease-expo), box-shadow .25s var(--ease-expo) !important; } | |
| /* the finished summary settles in gently with the reveal rather than popping */ | |
| .coda-summary h3, .coda-summary p{ animation:codaSeamRibbonIn .6s var(--ease-expo) both; } | |
| @media (prefers-reduced-motion: reduce){ | |
| .coda-seam-ribbon, .coda-seam-join, .coda-seam-seg, .coda-hud-scan span, | |
| .coda-summary h3, .coda-summary p{ | |
| animation:none !important; transition:none !important; | |
| } | |
| .coda-hud-scan{ display:none; } | |
| } | |
| """ | |
| # ethereal hero spectrum: a frequency-spectrum bar field with a centered | |
| # envelope; each bar drifts on its own phase for a living, breathing look. | |
| _N = 48 | |
| _bars = [] | |
| for _i in range(_N): | |
| _t = (_i - (_N - 1) / 2) / ((_N - 1) / 2) | |
| _env = 1.0 - 0.72 * _t * _t # ~0.28 at the edges, 1.0 in the middle | |
| _delay = (_i * 0.06) % 1.6 | |
| _dur = 1.15 + (_i % 6) * 0.11 | |
| _bars.append( | |
| f"<span style='height:{_env*100:.0f}%;" | |
| f"animation-delay:-{_delay:.2f}s;animation-duration:{_dur:.2f}s'></span>") | |
| SPECTRUM = "<div id='coda-eq'>" + "".join(_bars) + "</div>" | |
| # ambient drifting particles β pure-CSS motes that float up behind the glass. | |
| # (left%, size px, duration s, delay s) hand-tuned for an even, calm spread. | |
| _seeds = [(6, 2.4, 16, 0), (13, 1.6, 21, 3), (21, 3.0, 18, 6), (29, 2.0, 24, 1), | |
| (37, 2.6, 19, 8), (45, 1.8, 22, 4), (53, 3.2, 17, 7), (61, 2.2, 25, 2), | |
| (69, 1.6, 20, 9), (77, 2.8, 23, 5), (84, 2.0, 18, 1), (91, 3.0, 21, 6), | |
| (9, 2.2, 26, 10), (34, 1.8, 19, 12), (66, 2.6, 22, 7), (88, 2.4, 24, 3)] | |
| _parts = "".join( | |
| f"<span style='left:{_l}%;width:{_s}px;height:{_s}px;" | |
| f"animation-duration:{_d}s;animation-delay:-{_dl}s'></span>" | |
| for (_l, _s, _d, _dl) in _seeds) | |
| AMBIENT = ("<div class='coda-aurora'></div>" | |
| "<div class='coda-glow'></div>" | |
| f"<div class='coda-particles'>{_parts}</div>") | |
| # Feature C β cinematic onboarding overlay. The 2016 story plays on | |
| # EVERY page load. It is rendered with `coda-intro-on` BAKED IN, so the CSS beats | |
| # start immediately on load WITHOUT waiting on (or even needing) JS β bulletproof | |
| # against the head-injection issue that made it vanish on the Space. The | |
| # CODA_INIT_JS controller (when present) relocates it to <body>, locks scroll, | |
| # wires Esc / auto-advance / replay, and hard-removes it on dismiss. Skip / Enter | |
| # carry an INLINE onclick fallback so the overlay is always dismissable even if | |
| # the JS module never loads (no lock-out). | |
| _INTRO_CLOSE = ( | |
| "(window.__codaCloseIntro||function(){" | |
| "var o=document.getElementById('coda-intro-overlay');" | |
| "if(o){o.style.opacity=0;o.style.pointerEvents='none';" | |
| "setTimeout(function(){if(o)o.remove();},620);}" | |
| "document.body.classList.remove('coda-intro-lock');})()") | |
| INTRO_OVERLAY = ( | |
| "<div id='coda-intro-overlay' class='coda-intro-on' role='dialog' " | |
| "aria-modal='true' aria-label='CODA β the story'>" | |
| f'<div id="coda-intro-skip" role="button" tabindex="0" onclick="{_INTRO_CLOSE}">Skip</div>' | |
| "<div id='coda-intro-card'>" | |
| "<div class='coda-intro-beat b1'><p class='coda-intro-year'>2016</p></div>" | |
| "<div class='coda-intro-beat b2'><p class='coda-intro-line'>A song, recorded " | |
| "one night β then <em>quit at the bridge</em>.</p></div>" | |
| "<div class='coda-intro-beat b3'>" | |
| "<h1 class='coda-intro-mark'>CODA</h1>" | |
| "<div id='coda-intro-eq'>" + ("<span></span>" * 12) + "</div></div>" | |
| "<div class='coda-intro-beat b4'>" | |
| "<p class='coda-intro-fin'>Ten years later, it gets finished.</p>" | |
| f'<button id="coda-intro-enter" type="button" onclick="{_INTRO_CLOSE}">Enter CODA</button>' | |
| "</div></div></div>") | |
| # the only JS, and purely cosmetic: a soft light that follows the cursor. Runs | |
| # once on load and just writes --mx/--my; all the rendering is CSS (.coda-glow). | |
| POINTER_JS = """ | |
| () => { | |
| if (window.__codaPointer) return; | |
| window.__codaPointer = true; | |
| const set = (x, y) => { | |
| const r = document.documentElement.style; | |
| r.setProperty('--mx', x + 'px'); | |
| r.setProperty('--my', y + 'px'); | |
| }; | |
| set(window.innerWidth * 0.5, window.innerHeight * 0.28); | |
| window.addEventListener('pointermove', | |
| (e) => set(e.clientX, e.clientY), { passive: true }); | |
| } | |
| """ | |
| # --- unified front-end init: Features A (audio-reactive hero), B (seam | |
| # reveal), C (cinematic onboarding). One idempotent vanilla-JS module under | |
| # window.__coda; composes with POINTER_JS. Wired via a second app.load(js=...). | |
| CODA_INIT_JS = """ | |
| () => { | |
| /* ============================================================================ | |
| * CODA β unified front-end init (Features A + B + C). ONE idempotent vanilla-JS | |
| * module under window.__coda. Composes with POINTER_JS (window.__codaPointer) β | |
| * never clobbers it. No CDN, no libs. Wired via a SECOND app.load(js=...). | |
| * Each sub-feature guards its own init flag, so re-firing app.load on an SSE | |
| * reconnect never double-installs observers/listeners. | |
| * ========================================================================== */ | |
| try { | |
| var W = (window.__coda = window.__coda || {}); | |
| var doc = document; | |
| var REDUCED = false; | |
| try { REDUCED = !!(window.matchMedia && | |
| window.matchMedia('(prefers-reduced-motion: reduce)').matches); } catch (e) {} | |
| function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); } | |
| function fmtTime(s) { | |
| if (!isFinite(s) || s < 0) s = 0; | |
| var m = Math.floor(s / 60), r = Math.round(s % 60); | |
| if (r === 60) { m += 1; r = 0; } | |
| return m + ':' + (r < 10 ? '0' : '') + r; | |
| } | |
| function isCodaAudio(el) { | |
| return !!(el && el.tagName === 'AUDIO' && | |
| (el.closest('.coda-drop') || el.closest('.coda-player'))); | |
| } | |
| /* ===== SHARED AUDIO LAYER (AnalyserNode context) ===== */ | |
| var AUDIO = (W.audio = W.audio || { | |
| AC: (window.AudioContext || window.webkitAudioContext || null), | |
| ctx: null, | |
| ensureCtx: function () { | |
| if (!this.AC) return null; | |
| if (!this.ctx) { try { this.ctx = new this.AC(); } catch (e) { return null; } } | |
| if (this.ctx.state === 'suspended') { try { this.ctx.resume(); } catch (e) {} } | |
| return this.ctx; | |
| } | |
| }); | |
| /* ===== FEATURE A β AUDIO-REACTIVE HERO (real FFT via a shadow <audio>) ===== | |
| * The hero bars dance with the finished track, driven by real FFT data. | |
| * | |
| * Verified live: Gradio's WaveSurfer plays from a DECODED BUFFER through its | |
| * own Web Audio graph β its <audio> element never loads (readyState 0, no | |
| * src, currentTime stays 0 while it plays). So there is no media element of | |
| * WaveSurfer's to tap, and wrapping HTMLMediaElement.play() never fires. | |
| * | |
| * Instead we drive the 48 bars from a SHADOW <audio> of the SAME file (its | |
| * download URL), wired MediaElementSource -> AnalyserNode and deliberately NOT | |
| * connected to destination: it yields real getByteFrequencyData() while | |
| * staying SILENT, so WaveSurfer remains the only audible source (no echo). We | |
| * start/stop the shadow in lockstep with WaveSurfer (play state = its Pause | |
| * button; position = its timestamp, resynced so it can't drift), and paint the | |
| * bars every frame. Bars are mirrored center=lows / edges=highs to keep the | |
| * hero's centered silhouette, with snappy attack + soft release. If Web Audio | |
| * is unavailable, autoplay is blocked, or the file is cross-origin-tainted, we | |
| * never confirm live data and the CSS-energized `.coda-eq-playing` look stays | |
| * as a graceful fallback β nothing regresses. On stop the bars glide to idle | |
| * and the CSS breathing animation resumes. */ | |
| if (!W.heroFx) { | |
| W.heroFx = true; | |
| var EQ = (W.eq = W.eq || {}); | |
| EQ.idle = 0.16; | |
| EQ.shadow = null; EQ.url = null; EQ.analyser = null; EQ.data = null; | |
| EQ.srcNode = null; /* live MediaElementSource, torn down per track */ | |
| EQ.raf = 0; EQ.settleRaf = 0; EQ.syncRaf = 0; EQ.confirmed = false; EQ.tries = 0; | |
| EQ.bars = function () { | |
| var eq = doc.getElementById('coda-eq'); | |
| return eq ? eq.querySelectorAll('span') : []; | |
| }; | |
| /* bar i (0..n-1) -> a frequency bin. Distance from center picks the band | |
| * (center=low, edge=high), perceptually spread so the lows aren't crammed. */ | |
| EQ.band = function (data, i, n, bins) { | |
| var half = n / 2; | |
| var d = Math.abs(i + 0.5 - half) / half; /* 0 center .. ~1 edge */ | |
| var t = Math.pow(d, 1.35); | |
| var top = Math.max(4, Math.floor(bins * 0.80)); /* top bins are ~empty */ | |
| var idx = 1 + Math.round(t * (top - 2)); | |
| var a = data[idx]; | |
| var b = data[idx + 1 < top ? idx + 1 : idx]; | |
| return (a + b) / 510; /* /255/2 -> 0..1 */ | |
| }; | |
| EQ.paint = function (data, bins) { | |
| var spans = EQ.bars(), n = spans.length; | |
| if (!n) return; | |
| if (!EQ.state || EQ.state.length !== n) { | |
| EQ.state = new Float32Array(n); EQ.state.fill(EQ.idle); | |
| } | |
| for (var i = 0; i < n; i++) { | |
| var amp = EQ.band(data, i, n, bins); | |
| var target = clamp(0.12 + amp * 1.18, 0.04, 1.14); | |
| var prev = EQ.state[i]; | |
| var k = target > prev ? 0.55 : 0.20; /* snappy attack, soft release */ | |
| var v = prev + (target - prev) * k; | |
| EQ.state[i] = v; | |
| spans[i].style.transform = 'scaleY(' + v.toFixed(3) + ')'; | |
| } | |
| }; | |
| /* the CODA player currently showing a Pause button (i.e. actually playing) */ | |
| EQ.activePlayer = function () { | |
| var players = doc.querySelectorAll('.coda-player, .coda-drop'); | |
| for (var i = 0; i < players.length; i++) { | |
| var bs = players[i].querySelectorAll('button'); | |
| for (var j = 0; j < bs.length; j++) { | |
| var l = (bs[j].getAttribute('aria-label') || bs[j].title || | |
| bs[j].textContent || '').toLowerCase(); | |
| if (l.indexOf('pause') !== -1) return players[i]; | |
| } | |
| } | |
| return null; | |
| }; | |
| EQ.playerURL = function (p) { | |
| if (!p) return null; | |
| var a = p.querySelector('a.download-link, a[download], a[href*="file="]'); | |
| if (a && a.getAttribute('href')) return a.href; | |
| var au = p.querySelector('audio'); | |
| if (au && (au.currentSrc || au.src)) return au.currentSrc || au.src; | |
| return null; | |
| }; | |
| EQ.playerPos = function (p) { | |
| try { | |
| var t = p.querySelector('.timestamps time'); | |
| if (!t) return null; | |
| var s = (t.textContent || '').trim().split(':'); | |
| if (s.length < 2) return null; | |
| var v = parseInt(s[0], 10) * 60 + parseInt(s[1], 10); | |
| return isFinite(v) ? v : null; | |
| } catch (e) { return null; } | |
| }; | |
| /* (re)build the silent shadow element + analyser for `url`. Returns false if | |
| Web Audio is unavailable or the graph can't be built (-> CSS fallback). */ | |
| EQ.ensureShadow = function (url) { | |
| if (!AUDIO.AC) return false; | |
| if (EQ.url !== url) { /* new track */ | |
| if (EQ.shadow) { try { EQ.shadow.pause(); EQ.shadow.src = ''; } catch (e) {} } | |
| /* tear down the PREVIOUS Web Audio graph so old MediaElementSource / | |
| analyser nodes don't pile up across resubmits β each finished take is | |
| a new URL, i.e. a fresh graph. (A MediaElementSource can't be re-made | |
| for the same element anyway, so the shadow is always a new element.) */ | |
| if (EQ.srcNode) { try { EQ.srcNode.disconnect(); } catch (e) {} EQ.srcNode = null; } | |
| if (EQ.analyser) { try { EQ.analyser.disconnect(); } catch (e) {} } | |
| EQ.shadow = new Audio(); | |
| EQ.shadow.crossOrigin = 'anonymous'; /* clean FFT if ever cross-origin */ | |
| EQ.shadow.preload = 'auto'; | |
| EQ.shadow.src = url; | |
| EQ.url = url; EQ.analyser = null; EQ.confirmed = false; | |
| } | |
| var ctx = AUDIO.ensureCtx(); | |
| if (!ctx) return false; | |
| if (!EQ.analyser) { | |
| try { | |
| var node = ctx.createMediaElementSource(EQ.shadow); | |
| var an = ctx.createAnalyser(); | |
| an.fftSize = 256; an.smoothingTimeConstant = 0.78; | |
| node.connect(an); /* NOT ->destination: stays silent */ | |
| EQ.analyser = an; EQ.srcNode = node; EQ.data = new Uint8Array(an.frequencyBinCount); | |
| } catch (e) { return false; } | |
| } | |
| return true; | |
| }; | |
| EQ.loop = function () { | |
| EQ.raf = requestAnimationFrame(EQ.loop); | |
| if (!EQ.analyser) return; | |
| EQ.analyser.getByteFrequencyData(EQ.data); | |
| if (!EQ.confirmed) { | |
| /* Hold on the CSS-energized look until the analyser delivers a real | |
| signal β covers the brief window where the AudioContext is still | |
| resuming after the click, or the track opens quietly. We keep | |
| looping (cheap) rather than giving up, so it self-heals the instant | |
| audio energy arrives; EQ.stop() ends the loop on pause/stop. */ | |
| var mx = 0, i; | |
| for (i = 0; i < EQ.data.length; i++) if (EQ.data[i] > mx) mx = EQ.data[i]; | |
| if (mx === 0) return; | |
| EQ.confirmed = true; | |
| } | |
| /* JS owns the bars on every confirmed frame. Re-assert .coda-eq-live each | |
| frame (idempotent) so a PRIOR stop()'s class removal can't survive into a | |
| pauseβplay cycle: without the class the .coda-eq-playing keyframe overrides | |
| our inline scaleY and the bars freeze. This is what made reactivity die on | |
| the 2nd play. Cheap: classList.add is a no-op once present. */ | |
| var eq = doc.getElementById('coda-eq'); | |
| if (eq && !eq.classList.contains('coda-eq-live')) eq.classList.add('coda-eq-live'); | |
| EQ.paint(EQ.data, EQ.analyser.frequencyBinCount); | |
| }; | |
| EQ.start = function (p) { | |
| if (REDUCED) return; | |
| var url = EQ.playerURL(p); | |
| if (!url || !EQ.ensureShadow(url)) return; /* -> CSS fallback */ | |
| EQ.tries = 0; | |
| /* Re-arm the confirm gate for THIS play session. On a pauseβplay the gate | |
| was left latched true, so the loop skipped re-adding .coda-eq-live and the | |
| bars went dead. Resetting it makes every play (1st, 2nd, Nth) re-detect | |
| energy and re-take ownership of the bars, holding the CSS pulse only for | |
| the few frames while the AudioContext resumes. */ | |
| EQ.confirmed = false; | |
| /* ensureShadow already resumed the AudioContext (it may have auto-suspended | |
| while paused); make sure the silent shadow is actually rolling again. */ | |
| var pos = EQ.playerPos(p); | |
| if (pos != null) { try { EQ.shadow.currentTime = pos; } catch (e) {} } | |
| try { var pr = EQ.shadow.play(); if (pr && pr.catch) pr.catch(function () {}); } catch (e) {} | |
| if (EQ.settleRaf) { cancelAnimationFrame(EQ.settleRaf); EQ.settleRaf = 0; } | |
| if (!EQ.raf) EQ.loop(); | |
| }; | |
| EQ.stop = function () { | |
| if (EQ.raf) { cancelAnimationFrame(EQ.raf); EQ.raf = 0; } | |
| if (EQ.shadow) { try { EQ.shadow.pause(); } catch (e) {} } | |
| var spans = EQ.bars(), n = spans.length; | |
| var eq = doc.getElementById('coda-eq'); | |
| if (!n) { if (eq) eq.classList.remove('coda-eq-live'); return; } | |
| if (!EQ.state || EQ.state.length !== n) { | |
| EQ.state = new Float32Array(n); EQ.state.fill(EQ.idle); | |
| } | |
| /* glide the bars down to idle, THEN hand back to the CSS breathing anim */ | |
| function decay() { | |
| var moving = false; | |
| for (var i = 0; i < n; i++) { | |
| EQ.state[i] += (EQ.idle - EQ.state[i]) * 0.22; | |
| if (Math.abs(EQ.state[i] - EQ.idle) > 0.012) moving = true; | |
| spans[i].style.transform = 'scaleY(' + EQ.state[i].toFixed(3) + ')'; | |
| } | |
| if (moving) { | |
| EQ.settleRaf = requestAnimationFrame(decay); | |
| } else { | |
| EQ.settleRaf = 0; | |
| if (eq) eq.classList.remove('coda-eq-live'); | |
| for (var j = 0; j < n; j++) spans[j].style.transform = ''; /* CSS resumes */ | |
| } | |
| } | |
| decay(); | |
| }; | |
| EQ.sync = function () { | |
| var eq = doc.getElementById('coda-eq'); if (!eq) return; | |
| var p = REDUCED ? null : EQ.activePlayer(); | |
| eq.classList.toggle('coda-eq-playing', !!p); /* CSS floor while playing */ | |
| if (p) { | |
| if (!EQ.raf) { | |
| EQ.start(p); | |
| } else if (EQ.shadow) { /* keep the shadow locked to the player */ | |
| /* If the silent shadow stalled while the main player is still playing | |
| β ended, or the browser paused it after a scrub past its buffered | |
| edge β wake it back up so the analyser keeps feeding the bars. */ | |
| if (EQ.shadow.paused || EQ.shadow.ended) { | |
| try { var rp = EQ.shadow.play(); if (rp && rp.catch) rp.catch(function () {}); } catch (e) {} | |
| } | |
| /* bound drift to the timestamp (covers scrubbing/seeking) */ | |
| var pos = EQ.playerPos(p); | |
| if (pos != null && isFinite(EQ.shadow.duration) && | |
| Math.abs(EQ.shadow.currentTime - pos) > 0.7) { | |
| try { EQ.shadow.currentTime = pos; } catch (e) {} | |
| } | |
| } | |
| } else if (EQ.raf || EQ.settleRaf || (EQ.shadow && !EQ.shadow.paused)) { | |
| EQ.stop(); | |
| } | |
| }; | |
| /* Coalesce mutation storms. WaveSurfer repaints + the stage overlay's | |
| animations mutate the DOM many times per frame; the old direct callback | |
| ran EQ.sync on EVERY one (hundreds/sec during playback) β a real source of | |
| the resubmit jank. Collapse to at most one sync per animation frame; the | |
| 250ms interval below still independently drives play/stop + drift resync. */ | |
| EQ.syncSoon = function () { | |
| if (EQ.syncRaf) return; | |
| EQ.syncRaf = requestAnimationFrame(function () { EQ.syncRaf = 0; EQ.sync(); }); | |
| }; | |
| try { | |
| var mo = new MutationObserver(EQ.syncSoon); | |
| mo.observe(doc.body, { subtree: true, childList: true, attributes: true, | |
| attributeFilter: ['aria-label', 'title', 'class'] }); | |
| } catch (e) {} | |
| W.playPoll = setInterval(EQ.sync, 250); /* drives play/stop + drift resync */ | |
| EQ.sync(); | |
| W.heroSync = EQ.sync; | |
| } | |
| /* ===== FEATURE B β BEFORE/AFTER SEAM REVEAL ===== | |
| * The before/after split (YOUR PART | CODA + the join marker) is now rendered | |
| * SERVER-SIDE as a self-contained ribbon (see _seam_html in app.py) with the | |
| * exact proportions baked into inline styles. That removes the old, fragile | |
| * client-side path that measured WaveSurfer's waveform and could leave the | |
| * marker stuck in the wrong place. No JS needed here anymore. */ | |
| /* ===== FEATURE C β CINEMATIC ONBOARDING ===== */ | |
| (function featureC() { | |
| if (W.intro) return; | |
| W.intro = true; | |
| var KEY = 'coda_intro_seen'; | |
| var hasLS = (function () { | |
| try { var k = '__coda_t'; localStorage.setItem(k, '1'); localStorage.removeItem(k); return true; } | |
| catch (e) { return false; } | |
| })(); | |
| (function cacheTemplate() { | |
| var h = doc.getElementById('coda-intro-overlay'); | |
| if (h && !W.introHTML) { try { W.introHTML = h.outerHTML; } catch (e) {} } | |
| })(); | |
| function hardRemove(host) { | |
| try { | |
| host.style.display = 'none'; host.style.pointerEvents = 'none'; | |
| host.setAttribute('aria-hidden', 'true'); host.remove(); | |
| } catch (e) {} | |
| } | |
| /* Design choice: the 2016 intro replays on EVERY page load, not just the | |
| * first. So there is NO localStorage "seen" gate β we always boot it. | |
| * Skip / Esc / click-outside / auto-advance still dismiss it per load. */ | |
| var tries = 0; | |
| function boot() { | |
| var host = doc.getElementById('coda-intro-overlay'); | |
| if (!host) { if (tries++ < 60) requestAnimationFrame(boot); return; } | |
| init(host); | |
| } | |
| function init(host) { | |
| /* no seen-gate: the intro plays every load (see featureC note above) */ | |
| try { if (host.parentNode !== doc.body) doc.body.appendChild(host); } catch (e) {} | |
| try { doc.body.classList.add('coda-intro-lock'); } catch (e) {} | |
| host.classList.add('coda-intro-on'); | |
| if (REDUCED) host.classList.add('coda-intro-reduced'); | |
| var enter = doc.getElementById('coda-intro-enter'); | |
| var skip = doc.getElementById('coda-intro-skip'); | |
| try { if (enter) enter.focus({ preventScroll: true }); } catch (e) {} | |
| var dismissed = false, walkAway = null, readyT = null; | |
| function dismiss() { | |
| if (dismissed) return; dismissed = true; | |
| try { if (walkAway) clearTimeout(walkAway); } catch (e) {} | |
| try { if (readyT) clearTimeout(readyT); } catch (e) {} | |
| try { doc.removeEventListener('keydown', onKey, true); } catch (e) {} | |
| host.classList.add('coda-intro-out'); | |
| var done = false; | |
| function finish() { | |
| if (done) return; done = true; | |
| hardRemove(host); | |
| try { doc.body.classList.remove('coda-intro-lock'); } catch (e) {} | |
| /* intentionally do NOT persist a 'seen' flag β replay every load */ | |
| } | |
| host.addEventListener('transitionend', finish, { once: true }); | |
| setTimeout(finish, 750); | |
| } | |
| /* expose the graceful close so the overlay's inline onclick fallback | |
| routes through it when the JS module is present (else it self-closes) */ | |
| try { window.__codaCloseIntro = dismiss; } catch (e) {} | |
| if (enter) enter.addEventListener('click', dismiss); | |
| if (skip) skip.addEventListener('click', dismiss); | |
| host.addEventListener('click', function (ev) { if (ev.target === host) dismiss(); }); | |
| function onKey(ev) { | |
| if (ev.key === 'Escape') { dismiss(); } | |
| else if (ev.key === 'Enter' || ev.key === ' ' || ev.key === 'Spacebar') { | |
| if (host.contains(doc.activeElement) || doc.activeElement === doc.body) { ev.preventDefault(); dismiss(); } | |
| } | |
| } | |
| doc.addEventListener('keydown', onKey, true); | |
| var ARM = REDUCED ? 100 : 5200; | |
| readyT = setTimeout(function () { host.classList.add('coda-intro-ready'); }, ARM); | |
| walkAway = setTimeout(function () { if (!dismissed) dismiss(); }, REDUCED ? 1600 : 11000); | |
| ['click', 'keydown', 'pointerdown'].forEach(function (t) { | |
| host.addEventListener(t, function () { try { clearTimeout(walkAway); } catch (e) {} }, | |
| { once: true, passive: true }); | |
| }); | |
| wireReplay(); | |
| } | |
| function wireReplay() { | |
| var foot = doc.getElementById('coda-foot'); | |
| if (!foot || doc.getElementById('coda-intro-replay')) return; | |
| var link = doc.createElement('span'); | |
| link.id = 'coda-intro-replay'; link.textContent = 'replay intro'; | |
| link.setAttribute('role', 'button'); link.setAttribute('tabindex', '0'); | |
| function replay() { | |
| try { localStorage.removeItem(KEY); } catch (e) {} | |
| var tmpl = W.introHTML; if (!tmpl) return; | |
| var old = doc.getElementById('coda-intro-overlay'); if (old) old.remove(); | |
| var wrap = doc.createElement('div'); wrap.innerHTML = tmpl; | |
| var fresh = wrap.firstElementChild; if (!fresh) return; | |
| doc.body.appendChild(fresh); init(fresh); | |
| } | |
| link.addEventListener('click', replay); | |
| link.addEventListener('keydown', function (e) { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); replay(); } }); | |
| foot.appendChild(doc.createElement('br')); | |
| foot.appendChild(link); | |
| } | |
| boot(); | |
| })(); | |
| } catch (e) { | |
| try { | |
| var h = document.getElementById('coda-intro-overlay'); | |
| if (h) { h.style.display = 'none'; h.remove(); } | |
| document.body.classList.remove('coda-intro-lock'); | |
| } catch (_) {} | |
| } | |
| } | |
| """ | |
| # On-load JS hook. In this Gradio build, js-only app.load() handlers are dropped | |
| # from the served config AND gr.Blocks(js=...) is populated but never auto-run by | |
| # the frontend (both verified live). The reliable path is a <script> injected via | |
| # gr.Blocks(head=...): the browser always executes it. It polls until the Gradio | |
| # app has mounted, then runs the cursor glow + Features A/B/C; every sub-feature | |
| # guards its own flag, so the repeated poll-driven calls are idempotent. | |
| # The IIFE that polls until the Gradio app has mounted, then runs the cursor glow | |
| # + Features A/B/C. Defined once and delivered through TWO channels (below), since | |
| # head= injection is unreliable on the deployed Space β see BOOTSTRAP_HTML. | |
| _BOOT_JS = ( | |
| "(function () {\n" | |
| " function boot() {\n" | |
| " try { (" + POINTER_JS.strip() + ")(); } catch (e) {}\n" | |
| " try { (" + CODA_INIT_JS.strip() + ")(); } catch (e) {}\n" | |
| " }\n" | |
| " function ready() {\n" | |
| " /* wait for the real CONTENT elements, not just .gradio-container (which\n" | |
| " exists before its children render) β else Feature C inits with no\n" | |
| " overlay in the DOM and gives up. */\n" | |
| " return !!(document.getElementById('coda-eq') &&\n" | |
| " document.getElementById('coda-intro-overlay'));\n" | |
| " }\n" | |
| " function poll(n) { if (ready()) { boot(); } else if (n > 0) {\n" | |
| " setTimeout(function () { poll(n - 1); }, 120); } }\n" | |
| " if (document.readyState === 'loading') {\n" | |
| " document.addEventListener('DOMContentLoaded', function () { poll(80); });\n" | |
| " } else { poll(80); }\n" | |
| " window.addEventListener('load', function () { poll(30); });\n" | |
| "})();\n" | |
| ) | |
| # Channel 1 β head injection. Works locally (Gradio 6.16.0 honors head= on | |
| # launch(), NOT on gr.Blocks()), and is the clean path wherever it lands. | |
| HEAD_SCRIPT = "<script>\n" + _BOOT_JS + "</script>" | |
| # Channel 2 β IN-BODY bootstrap (the reliable one on the Space). On the deployed | |
| # Space, head= is dropped entirely (verified live: none of the module's symbols | |
| # reach the page), so the whole front-end module β cursor glow + Feature A | |
| # audio-reactive hero + Feature C intro controller β never ran. This channel does | |
| # NOT depend on head= at all: a gr.HTML carries the boot code in an inert | |
| # <script type="text/plain"> plus a 0Γ0 <img src="x"> whose onerror fires the | |
| # instant Gradio sets the component's innerHTML (a broken <img> always errors, | |
| # and innerHTML-inserted handlers DO run β unlike innerHTML-inserted <script>). | |
| # The handler copies the boot text into a real <script> element and appends it, | |
| # which executes it. Idempotent via window.__codaBoot, and _BOOT_JS itself guards | |
| # every sub-feature, so co-firing with Channel 1 (where present) is harmless. | |
| BOOTSTRAP_HTML = ( | |
| "<div style=\"position:absolute;width:0;height:0;overflow:hidden\" aria-hidden=\"true\">" | |
| "<script type=\"text/plain\" id=\"coda-boot-src\">" + _BOOT_JS + "</script>" | |
| "<img src=\"x\" alt=\"\" " | |
| "onerror=\"(function(){try{if(window.__codaBoot)return;window.__codaBoot=1;" | |
| "var t=document.getElementById('coda-boot-src');if(!t)return;" | |
| "var s=document.createElement('script');s.textContent=t.textContent;" | |
| "(document.head||document.documentElement).appendChild(s);}catch(e){}})()\">" | |
| "</div>" | |
| ) | |
| with gr.Blocks(title="CODA") as app: | |
| # in-body front-end bootstrap (Channel 2). First child so its <img> mounts and | |
| # fires as early as possible; 0Γ0 and aria-hidden, so it never affects layout. | |
| gr.HTML(BOOTSTRAP_HTML) | |
| # cinematic onboarding overlay (position:fixed; the JS relocates it to <body> | |
| # and never lets it block the tool). First child so it can never affect layout. | |
| gr.HTML(INTRO_OVERLAY) | |
| # ambient layers (aurora + cursor glow + drifting particles) live behind | |
| # everything via position:fixed / negative z-index, so they never affect layout. | |
| gr.HTML(AMBIENT) | |
| with gr.Column(elem_id="coda-head"): | |
| gr.HTML("<h1 id='coda-title'>CODA</h1>" | |
| "<p id='coda-tagline'>the songs you quit on, finished</p>" | |
| + SPECTRUM) | |
| gr.HTML("<hr id='coda-rule'/>") | |
| gr.HTML( | |
| "<p id='coda-intro'>Upload a short, unfinished music clip. CODA reads its " | |
| "<strong>key</strong>, <strong>tempo</strong> and <strong>groove</strong>, " | |
| "then <strong>Stable Audio 3</strong> continues it into a finished-sounding " | |
| "track in the same feel β 44.1 kHz stereo, spliced seamlessly onto your " | |
| "original.</p>") | |
| with gr.Row(equal_height=False): | |
| with gr.Column(scale=1, elem_classes="coda-glass"): | |
| gr.HTML("<div class='coda-label'>Your clip</div>") | |
| audio_input = gr.Audio( | |
| label="Your unfinished clip", type="filepath", | |
| sources=["upload"], elem_classes="coda-drop", | |
| waveform_options=gr.WaveformOptions( | |
| waveform_color="#33405e", | |
| waveform_progress_color="#6fe0f5", | |
| trim_region_color="#a98bff")) | |
| demo_btn = gr.Button("π§ Hear Track0000 β recorded 2016, never finished", | |
| size="sm", elem_classes="coda-demo") | |
| total_slider = gr.Slider( | |
| MIN_TOTAL, MAX_TOTAL, value=DEFAULT_TOTAL, step=1, | |
| label="Finished length (seconds)", | |
| info="Total length of the finished track. Longer = a bit slower.", | |
| elem_classes="coda-slider") | |
| # Hidden mirror of the slider, and the field finish_song actually | |
| # reads. Two reasons it's separate from the slider: (1) some Gradio | |
| # frontends snap a slider back to its minimum on the 2nd+ submit, | |
| # which would make the next run generate a tiny tail that "sounds like | |
| # the original" β this hidden field is written only from the slider's | |
| # change/release, so it isn't subject to that reset; (2) unlike a | |
| # gr.State it stays settable through the API. Mirrored just below. | |
| length_input = gr.Number(value=DEFAULT_TOTAL, visible=False) | |
| vibe = gr.Textbox( | |
| label="Describe the vibe (optional)", lines=1, | |
| placeholder="e.g. warm lo-fi, vinyl crackle, mellow piano", | |
| info="Leave empty for pure audio-led continuation.", | |
| elem_classes="coda-vibe") | |
| remaster = gr.Checkbox( | |
| value=False, label="Remaster my part too", | |
| info="Apply the same lo-fi cleanup to your original section so " | |
| "the whole track sits at one level.", | |
| elem_classes="coda-check") | |
| finish_btn = gr.Button("β¨ Finish this song", variant="primary", | |
| interactive=False, elem_classes="coda-go") | |
| with gr.Column(scale=1): | |
| # the 'CODA heard' readout β a gr.HTML panel (not Markdown) so the | |
| # fingerprint renders as a custom audio-tool HUD with real classes. | |
| info_md = gr.HTML(visible=False, elem_classes="coda-card coda-readout") | |
| # built visible inside the result group: Gradio drops visible=False | |
| # audio players at build time, so we never toggle player visibility. | |
| with gr.Group(elem_classes="coda-card", elem_id="coda-result"): | |
| gr.HTML("<div class='coda-result-head'>" | |
| "<span class='coda-result-dot'></span>YOUR FINISHED SONG" | |
| "</div>") | |
| # live processing overlay (absolutely positioned over this card). | |
| # finish_song yields stage HTML here at each milestone; empty = gone. | |
| stage = gr.HTML("", elem_id="coda-stage") | |
| # No autoplay: an autoplay output <audio> element is reused across | |
| # runs and can fail to reload its src on the 2nd+ generation β the | |
| # result then looks like it "didn't play" / "didn't update". A | |
| # plain player reliably loads each new result; the user presses | |
| # play, which also avoids browsers blocking autoplay-with-sound. | |
| output_audio = gr.Audio(label="", type="filepath", | |
| interactive=False, | |
| elem_classes="coda-player", | |
| waveform_options=gr.WaveformOptions( | |
| waveform_color="#3a3060", | |
| waveform_progress_color="#a98bff")) | |
| # before/after composition ribbon, sat right under the player. | |
| # finish_song fills it on reveal with a fully self-contained, | |
| # server-computed YOUR PART | CODA split (see _seam_html) β the | |
| # marker is always exactly where CODA begins, no client-side | |
| # waveform measuring. Empty until a result lands. | |
| seam_data = gr.HTML("", elem_id="coda-seam-host") | |
| # built visible with an empty value (an empty Markdown renders | |
| # nothing). finish_song fills it in the SAME event that sets the | |
| # audio, so there's no second `.then` to toggle visibility β one | |
| # event = one completion signal, so the spinner always clears. | |
| summary_md = gr.Markdown(elem_classes="coda-summary") | |
| gr.HTML( | |
| "<div id='coda-foot'>Demo clip: <strong>Track0000</strong> β recorded " | |
| "2016, never finished. Tony Winslow's own unfinished song, the kind CODA " | |
| "exists to finish. Bring your own clip to finish yours." | |
| "<br/><span class='coda-badge'>Stable Audio 3 Β· ZeroGPU Β· 44.1 kHz " | |
| "stereo</span></div>") | |
| # On a new clip, analyze_on_upload also RESETS the result panel (player, | |
| # summary, seam ribbon, any leftover stage overlay) β its trailing 4 outputs β | |
| # so a fresh upload after a finished run starts from a clean slate. | |
| audio_input.change(fn=analyze_on_upload, inputs=[audio_input], | |
| outputs=[info_md, finish_btn, | |
| output_audio, summary_md, stage, seam_data]) | |
| demo_btn.click(fn=load_demo, inputs=[], outputs=[audio_input]) | |
| # Mirror the slider into the hidden length field on change AND release, so the | |
| # chosen length is captured whether the user drags the handle or clicks the | |
| # track β and then survives a frontend slider reset on the next submit. | |
| total_slider.change(lambda v: v, inputs=[total_slider], outputs=[length_input]) | |
| total_slider.release(lambda v: v, inputs=[total_slider], outputs=[length_input]) | |
| # Single event (no chained `.then`): finish_song returns BOTH the audio path | |
| # and the summary markdown, so one completion message clears the spinner. | |
| # A chained second event was a place the "stuck processing" state could hang | |
| # if the SSE stream blipped (ClientDisconnect) between the two events. | |
| # Read the length from length_input (not the slider directly) β see above. | |
| # Single completion event (unchanged contract). The journey's beat states β | |
| # the held button during the run, the gold MASTER escalation on reveal, and | |
| # the ambient 'has-clip' reaction β are ALL driven by CSS :has() off real DOM | |
| # state (the stage overlay, the output <audio>, the input <audio>), so they | |
| # need no JS event to fire. This is far more reliable than js-only .change/ | |
| # .then callbacks, which Gradio does not consistently run on backend updates. | |
| # seam_data is the 4th output (still ONE event, no chained .then): finish_song | |
| # yields a 4-tuple ending in the hidden seam-data div the Feature-B JS reads. | |
| finish_btn.click( | |
| fn=finish_song, | |
| inputs=[audio_input, length_input, vibe, remaster], | |
| outputs=[output_audio, summary_md, stage, seam_data]) | |
| # NOTE: on-load JS (cursor glow + Features A/B/C) runs via the combined | |
| # gr.Blocks(js=ALL_LOAD_JS) above β js-only app.load() handlers are dropped | |
| # from the served config in this Gradio build, so they must not be used here. | |
| if __name__ == "__main__": | |
| # Gradio 6 moved theme/css/head to launch(). On the deployed Space (Gradio | |
| # 6.16.0) head= on gr.Blocks() is SILENTLY IGNORED, so the whole front-end | |
| # JS module (cursor glow + Feature A audio-reactive hero + Feature C intro) | |
| # never got injected β which is why the 2016 intro "disappeared" on the | |
| # Space. Passing head=HEAD_SCRIPT to launch() is the supported path and | |
| # injects it on 6.16.0+. (HEAD_SCRIPT itself is idempotent, so even if a | |
| # newer Gradio also honored Blocks(head=) the double-run would be harmless.) | |
| # queue: one heavy job at a time (single GPU), others wait rather than | |
| # contend. show_error surfaces a real error in the UI instead of a silently | |
| # stuck spinner. allowed_paths: on HF Spaces, Gradio 6 refuses to serve the | |
| # bundled demo clip (gradio_api/file=β¦/examples/β¦ β 403) unless its | |
| # directory is explicitly allowed, which broke "Try the demo" on the Space. | |
| app.queue(default_concurrency_limit=1, max_size=10).launch( | |
| theme=THEME, css=CSS, head=HEAD_SCRIPT, show_error=True, | |
| allowed_paths=[os.path.join(os.path.dirname(__file__), "examples")]) | |