CoolFace
Apppublic

build-small-hackathon/glossolalia

sourceHugging Faceapache-2.0updated 3mo agoView on Hugging Face
3likes
app.py1891 linesDownload Raw Back to root
1"""Glossolalia Dial — a single dial that grades a typed lyric into dreamy territory in two2distinct phonotactic paths:3 4  Ghost mode: lyric is rewritten as a sequence of real English words (mondegreen substitution).5              Constrained by syllable count, primary-stress position, PanPhon feature-edit6              distance; reranked by DistilGPT-2 for semantic coherence. F5-TTS base reads it.7  Tongues mode: clean lyric goes into F5-TTS + a fine-tuned LoRA + a learned scalar conditioner8                (LevelEmbed at AdaLN side). The LoRA produces graded glossolalic audio in the9                user's chosen voice — invented pseudowords, sonorant-leaning palette.10 11Both modes ride F5-TTS for voice cloning + audio synthesis. Off-the-Grid: no cloud APIs.12 13v1 Gradio app (gr.Blocks). v2 (Off-Brand badge) is in app_server.py.14"""15 16from __future__ import annotations17 18import os19import re20import tempfile21from pathlib import Path22 23import gradio as gr24import numpy as np25 26# ZeroGPU support (org Space). `spaces` is only installed on the ZeroGPU deployment;27# where it's absent (the T4 Space, local dev) gpu_task is a no-op pass-through, so the28# SAME app.py runs on both. On ZeroGPU it allocates a transient GPU for each call.29try:30    import spaces31 32    _ON_ZEROGPU = True33 34    def gpu_task(fn):35        return spaces.GPU(duration=120)(fn)36except Exception:37    _ON_ZEROGPU = False38 39    def gpu_task(fn):40        return fn41 42from config import (43    CONTROL_STEM, HF_LORA_REPO, LEVEL_WORDS, RESEMBLYZER_MIN_COSINE,44    SAMPLE_RATE, VOICE_PRESETS, WHISPER_MODEL,45)46from scripts.post_fx import PRESETS as POSTFX_PRESETS, apply_post_fx47 48 49def _parse_per_word_overrides(text: str) -> dict[str, tuple[str, float]]:50    """Parse a string like 'river=ree-vuh:1.3 calm=kawm light=:1.6' into a dict:51        { 'river': ('ree-vuh', 1.3), 'calm': ('kawm', 1.0), 'light': ('light', 1.6) }52    The 'word=replacement:stretch' format is intentionally simple:53      - word: the source word in the input lyric (case-insensitive)54      - replacement: the pronunciation guide we feed to F5-TTS (empty -> keep original)55      - stretch: a speed multiplier for THAT word's audio chunk (1.0 = normal,56        <1.0 = faster, >1.0 = slower / more sustained). Default 1.0 if omitted.57    """58    out: dict[str, tuple[str, float]] = {}59    for tok in (text or "").split():60        if "=" not in tok:61            continue62        word, rest = tok.split("=", 1)63        word = word.strip().lower()64        if ":" in rest:65            repl, sval = rest.split(":", 1)66            try:67                stretch = float(sval)68            except ValueError:69                stretch = 1.070        else:71            repl, stretch = rest, 1.072        stretch = max(0.5, min(2.5, stretch))73        out[word] = (repl.strip() if repl.strip() else word, stretch)74    return out75 76 77def _chunked_generate_with_overrides(78        engine, sentence: str, voice_id: str, level: int, seed: int, mode: str,79        custom_voice_path: str | None, custom_voice_text: str,80        overrides: dict[str, tuple[str, float]]) -> tuple[np.ndarray, int, str]:81    """Generate audio chunk-by-chunk so per-word stretch + pronunciation overrides apply.82    Each chunk goes through F5-TTS at its own speed, then we equal-power concat.83    Falls back to the single-shot path if no overrides are present."""84    if not overrides:85        return engine.generate(sentence, voice_id, level, seed=seed, mode=mode,86                                custom_voice_path=custom_voice_path,87                                custom_voice_text=custom_voice_text)88    words = re.findall(r"[A-Za-z']+|[^A-Za-z']+", sentence)89    chunks: list[tuple[str, float]] = []90    for w in words:91        key = w.lower().strip()92        if key in overrides:93            repl, stretch = overrides[key]94            chunks.append((repl, stretch))95        else:96            chunks.append((w, 1.0))97    # Merge adjacent chunks with stretch == 1.0 so we don't call F5-TTS 30 times for one sentence98    merged: list[tuple[str, float]] = []99    for text_part, st in chunks:100        if merged and abs(merged[-1][1] - 1.0) < 1e-6 and abs(st - 1.0) < 1e-6:101            merged[-1] = (merged[-1][0] + text_part, 1.0)102        else:103            merged.append((text_part, st))104    # Generate each chunk105    audios = []106    sr_out = SAMPLE_RATE107    last_gen_text_acc = ""108    for text_part, stretch in merged:109        if not text_part.strip():110            continue111        y, sr, gen_text = engine.generate(112            text_part, voice_id, level, seed=seed, mode=mode,113            custom_voice_path=custom_voice_path,114            custom_voice_text=custom_voice_text,115        )116        sr_out = sr117        if abs(stretch - 1.0) > 0.02:118            try:119                import librosa120                y = librosa.effects.time_stretch(y.astype(np.float32), rate=1.0 / stretch)121            except Exception as e:122                print(f"[chunked] time_stretch failed for '{text_part}': {e}")123        audios.append(y)124        last_gen_text_acc += gen_text + " "125    if not audios:126        return engine.generate(sentence, voice_id, level, seed=seed, mode=mode,127                                custom_voice_path=custom_voice_path,128                                custom_voice_text=custom_voice_text)129    final = equal_power_concat(audios, sr_out, fade_ms=80)130    return final.astype(np.float32), sr_out, last_gen_text_acc.strip()131 132 133def _blend_with_music(vocal: np.ndarray, vocal_sr: int, music_path: str,134                       vocal_gain_db: float = 0.0, music_gain_db: float = -8.0,135                       tempo_lock: bool = True, autotune: bool = True136                       ) -> tuple[np.ndarray, int]:137    """Mix the TTS vocal over an uploaded music track with TEMPO LOCK and AUTOTUNE.138 139    Tempo lock:140      - Detect music tempo via librosa.beat.beat_track141      - Detect vocal "tempo" proxy from the speech onset rate142      - Time-stretch the vocal to align (cap at +/-25% to keep formants).143 144    Autotune (rough whole-clip pitch shift):145      - Detect music's dominant pitch class via chroma_cqt average146      - Detect vocal median f0 via librosa.yin147      - Compute semitones from vocal_f0 to the nearest octave of the music's root note148      - Pitch-shift the vocal by that many semitones (capped at +/-7 to avoid chipmunk)149 150    All local: librosa + numpy. Off-the-Grid stays clean.151    """152    import librosa153    import math154 155    music, music_sr = librosa.load(music_path, sr=vocal_sr, mono=True)156 157    # --- tempo lock ---158    if tempo_lock and len(music) > vocal_sr * 2:159        try:160            mtempo, _ = librosa.beat.beat_track(y=music, sr=music_sr)161            vtempo, _ = librosa.beat.beat_track(y=vocal, sr=vocal_sr)162            if mtempo and vtempo and abs(np.log2(mtempo / vtempo)) < 1.5:163                ratio = float(np.clip(vtempo / mtempo, 0.78, 1.28))164                if abs(ratio - 1.0) > 0.04:165                    vocal = librosa.effects.time_stretch(vocal, rate=ratio)166        except Exception as e:167            print(f"[blend] tempo lock failed: {e}")168 169    # --- autotune: detect music root + pitch-shift vocal toward it ---170    if autotune:171        try:172            # 12-bin chroma -> dominant pitch class is the music's key center173            chroma = librosa.feature.chroma_cqt(y=music, sr=music_sr, hop_length=2048)174            key_idx = int(np.argmax(chroma.mean(axis=1)))  # 0=C, 1=C#, ..., 11=B175            key_names = ["C","C#","D","D#","E","F","F#","G","G#","A","A#","B"]176            # vocal median f0 via YIN177            f0 = librosa.yin(vocal.astype(np.float32),178                             fmin=70, fmax=500, sr=vocal_sr, frame_length=2048)179            f0 = f0[~np.isnan(f0) & (f0 > 0)]180            if len(f0) > 5:181                vocal_f0 = float(np.median(f0))182                # music root note across plausible vocal octaves: 65.4 Hz (C2) .. 523 Hz (C5)183                root_freqs = [184                    27.5 * (2 ** ((key_idx - 9 + 12 * o) / 12.0))  # A0=27.5; key_idx 9 == A185                    for o in range(2, 7)186                ]187                # pick the root octave closest in log-space to the vocal's median pitch188                root = min(root_freqs, key=lambda r: abs(math.log2(r / vocal_f0)))189                semitones = round(12 * math.log2(root / vocal_f0))190                semitones = max(-7, min(7, semitones))191                if semitones != 0:192                    vocal = librosa.effects.pitch_shift(193                        vocal.astype(np.float32), sr=vocal_sr, n_steps=semitones)194                print(f"[blend] autotune: music key={key_names[key_idx]}, "195                      f"vocal_f0={vocal_f0:.1f}Hz -> root={root:.1f}Hz, "196                      f"shift {semitones} semitones")197        except Exception as e:198            print(f"[blend] autotune failed: {e}")199 200    # --- mix ---201    n = max(len(music), len(vocal))202    if len(vocal) < n: vocal = np.pad(vocal, (0, n - len(vocal)))203    if len(music) < n: music = np.pad(music, (0, n - len(music)))204    vocal_gain = 10.0 ** (vocal_gain_db / 20.0)205    music_gain = 10.0 ** (music_gain_db / 20.0)206    out = vocal_gain * vocal + music_gain * music207    peak = float(np.max(np.abs(out)) + 1e-9)208    if peak > 0.98:209        out = out * (0.98 / peak)210    return out.astype(np.float32), vocal_sr211 212VOICE_IDS = list(VOICE_PRESETS.keys())213DEFAULT_VOICE = VOICE_IDS[0] if VOICE_IDS else "v1"214DEFAULT_TEXT = "she sells seashells by the seashore"215# default to the published LoRA repo so the Space loads it without needing env vars;216# COHERENCE_DIAL_LORA env var overrides for local dev against a checkpoint dir.217LORA_PATH = os.environ.get("COHERENCE_DIAL_LORA", HF_LORA_REPO)218 219MODE_GHOST = "Ghost"220MODE_TONGUES = "Tongues"221MODES = (MODE_GHOST, MODE_TONGUES)222 223 224# ----- inference engine (lazy-loaded; falls back to a silent stub if F5-TTS isn't installed) -----225 226class TTSEngine:227    """Dual-mode engine. One F5-TTS instance with the v8 LoRA loaded; mode is selected per228    inference call. For Ghost mode we set_dial(0) (LevelEmbed contributes ~zero) and feed229    mondegreen-substituted text. For Tongues mode we set_dial(level) and feed the clean230    lyric. The base LoRA attention adaptation is always on, but at dial=0 it produces audio231    indistinguishable from F5-TTS base (verified empirically by v5 sweep — lv0 sounded232    identical to base output)."""233 234    def __init__(self):235        self._tts = None236        self._asr = None237        self._enc = None238        self._mondegreen = None239        self._lm = None240        self._lora_loaded = False241        self.live = False242 243    def _ensure_mondegreen(self):244        """Lazy load the deterministic phonetic-ghost generator + DistilGPT-2 reranker."""245        if self._mondegreen is not None:246            return247        try:248            from scripts.mondegreen import MondegreenIndex, LMReranker249            self._mondegreen = MondegreenIndex("data/cmudict.dict")250            print(f"[engine] Mondegreen index loaded ({self._mondegreen.size} words)")251            self._lm = LMReranker()252            print("[engine] DistilGPT-2 reranker loaded")253        except Exception as e:254            print(f"[engine] mondegreen load FAILED ({e}); Ghost mode falls back to clean text")255            self._mondegreen = False256            self._lm = False257 258    def ghost_text(self, sentence: str, level: int, seed: int = 42) -> str:259        """Deterministic Ghost mode substitution. Returns the source if mondegreen unavailable."""260        self._ensure_mondegreen()261        if not self._mondegreen:262            return sentence263        return self._mondegreen.substitute(sentence, level, seed=seed,264                                            reranker=(self._lm or None))265 266    def _ensure(self):267        if self._tts is not None:268            return269        try:270            import patches  # noqa: F401 — installs F5TTS.load_lora before instantiation271            from f5_tts.api import F5TTS272            # Lazy-loaded inside generate(), which on ZeroGPU runs under @spaces.GPU, so273            # F5-TTS's auto device detection picks the allocated GPU. (On the T4 Space it274            # loads to cuda directly; locally it falls back to CPU.)275            # Auto device detection: cuda on the T4 Space, the allocated GPU inside a ZeroGPU276            # worker (generate runs under @spaces.GPU), cpu locally.277            self._tts = F5TTS(model="F5TTS_v1_Base")278            self.live = True279            print(f"[engine] F5-TTS base loaded (device={self._tts.device})")280            if LORA_PATH:281                try:282                    self._tts.load_lora(LORA_PATH)283                    self._lora_loaded = True284                    print(f"[engine] LoRA loaded from {LORA_PATH}")285                except Exception as e:286                    print(f"[engine] LoRA load FAILED ({e}); falling back to base model — Well-Tuned badge forfeit")287            else:288                print("[engine] no LoRA path configured; running base model only")289        except ImportError:290            print("[engine] f5-tts not installed; running with silent stub for layout testing")291            self.live = False292 293    def _ensure_asr(self):294        if self._asr is None:295            try:296                import whisper297                self._asr = whisper.load_model(WHISPER_MODEL)298            except Exception:299                self._asr = False300        return self._asr301 302    def _ensure_encoder(self):303        if self._enc is None:304            try:305                from resemblyzer import VoiceEncoder306                self._enc = VoiceEncoder()307            except Exception:308                self._enc = False309        return self._enc310 311    def generate(self, sentence: str, voice_id: str, level: int, seed: int = 42,312                  mode: str = MODE_TONGUES, custom_voice_path: str | None = None,313                  custom_voice_text: str = ""):314        """Returns (audio float32 mono, sample_rate, gen_text_used).315 316        mode=MODE_GHOST: substitute lyric via mondegreen at given level, set_dial(0), TTS reads it.317        mode=MODE_TONGUES: leave lyric clean, set_dial(level), LoRA conditions glossolalic audio.318 319        custom_voice_path: if a path is supplied, F5-TTS clones this clip instead of the320        preset voice. custom_voice_text is the transcript of that clip (improves clone quality;321        an empty string lets F5-TTS auto-transcribe via Whisper).322        """323        self._ensure()324        if custom_voice_path:325            # The uploaded/recorded clip may be mp3/m4a/etc. Our soundfile reader (and a326            # clean F5-TTS clone) want a 24kHz mono WAV, so transcode it first via librosa327            # (handles many formats through ffmpeg). Trim to <=15s to keep cloning fast.328            try:329                import librosa, soundfile as sf330                ref, _ = librosa.load(custom_voice_path, sr=24000, mono=True)331                ref = ref[: 24000 * 15]332                tmp_ref = tempfile.NamedTemporaryFile(suffix=".wav", delete=False).name333                sf.write(tmp_ref, ref, 24000, subtype="PCM_16")334                voice_wav = tmp_ref335            except Exception as e:336                print(f"[engine] custom voice transcode failed ({e}); using raw path")337                voice_wav = custom_voice_path338            voice_ref_text = (custom_voice_text or "").strip()339            # F5-TTS needs a transcript of the reference. If the user left it blank,340            # transcribe the clip ourselves so cloning is reliable instead of depending341            # on F5-TTS's internal auto-transcribe path.342            if not voice_ref_text:343                asr = self._ensure_asr()344                if asr:345                    try:346                        voice_ref_text = asr.transcribe(voice_wav)["text"].strip()347                        print(f"[engine] auto-transcribed clone ref: {voice_ref_text[:60]!r}")348                    except Exception as e:349                        print(f"[engine] clone ref transcribe failed: {e}")350        else:351            voice = VOICE_PRESETS[voice_id]352            voice_wav = voice["wav"]353            ref_txt = Path(voice["ref_text"])354            voice_ref_text = ref_txt.read_text(encoding="utf-8").strip() if ref_txt.exists() else ""355        if mode == MODE_GHOST:356            gen_text = self.ghost_text(sentence, level, seed=seed)357            tts_dial = 0358        else:359            gen_text = sentence360            tts_dial = level361        if not self.live:362            return np.zeros(SAMPLE_RATE * 3, dtype=np.float32), SAMPLE_RATE, gen_text363        if hasattr(self._tts, "set_dial"):364            try:365                self._tts.set_dial(tts_dial)366            except Exception as e:367                print(f"[engine] set_dial({tts_dial}) FAILED ({e}); proceeding without conditioning")368        out = tempfile.NamedTemporaryFile(suffix=".wav", delete=False).name369        self._tts.infer(ref_file=voice_wav, ref_text=voice_ref_text,370                        gen_text=gen_text, file_wave=out, seed=seed)371        import soundfile as sf372        y, sr = sf.read(out, always_2d=False)373        if y.ndim == 2:374            y = y.mean(axis=1)375        return y.astype(np.float32), sr, gen_text376 377    def transcribe_wer(self, y: np.ndarray, sr: int, ref_text: str) -> float | None:378        asr = self._ensure_asr()379        if not asr:380            return None381        import soundfile as sf, jiwer382        tmp = tempfile.NamedTemporaryFile(suffix=".wav", delete=False).name383        sf.write(tmp, y, sr)384        out = asr.transcribe(tmp, fp16=False, language="en", condition_on_previous_text=False,385                             no_speech_threshold=0.8, logprob_threshold=-1.5)386        hyp = (out.get("text") or "").strip()387        if not hyp:388            return 1.0389        return float(min(jiwer.wer(ref_text.lower(), hyp.lower()), 1.0))390 391    def voice_cosine(self, y: np.ndarray, sr: int, ref_y: np.ndarray, ref_sr: int) -> float | None:392        enc = self._ensure_encoder()393        if not enc:394            return None395        from resemblyzer import preprocess_wav396        import soundfile as sf397        a = tempfile.NamedTemporaryFile(suffix=".wav", delete=False).name398        b = tempfile.NamedTemporaryFile(suffix=".wav", delete=False).name399        sf.write(a, y, sr); sf.write(b, ref_y, ref_sr)400        ea = enc.embed_utterance(preprocess_wav(a))401        eb = enc.embed_utterance(preprocess_wav(b))402        return float(np.dot(ea, eb) / ((np.linalg.norm(ea) * np.linalg.norm(eb)) + 1e-9))403 404 405ENGINE = TTSEngine()406 407 408# ----- crossfading for Morph mode -----409 410def equal_power_concat(clips, sr, fade_ms=200):411    if not clips:412        return np.zeros(1, dtype=np.float32)413    fade_n = max(1, int(sr * fade_ms / 1000))414    t = np.linspace(0, 1, fade_n, dtype=np.float32)415    fi = np.sin(t * np.pi / 2.0)416    fo = np.cos(t * np.pi / 2.0)417    out = clips[0].astype(np.float32).copy()418    for c in clips[1:]:419        c = c.astype(np.float32)420        if len(out) < fade_n or len(c) < fade_n:421            out = np.concatenate([out, c]); continue422        head = out[-fade_n:] * fo423        tail = c[:fade_n] * fi424        out = np.concatenate([out[:-fade_n], head + tail, c[fade_n:]])425    return out426 427 428def _wav_to_filepath(y: np.ndarray, sr: int) -> str:429    import soundfile as sf430    if y.ndim == 2:431        y = y.T  # (channels, samples) -> (samples, channels)432    path = tempfile.NamedTemporaryFile(suffix=".wav", delete=False).name433    sf.write(path, y, sr)434    return path435 436 437# ----- readout (live metrics strip) -----438 439def readout(level: int | None = None, wer: float | None = None,440            cosine: float | None = None, status: str = "") -> str:441    cells = [442        ("DIAL", f"{level}" if level is not None else "·"),443        ("WER", f"{wer:.2f}" if wer is not None else "·"),444        ("VOICE-SIM", f"{cosine:.2f}" if cosine is not None else "·"),445        ("STATUS", status or ("live" if ENGINE.live else "stub")),446    ]447    return "<div class='readout'>" + "".join(448        f"<div class='readout-cell'><div class='readout-label'>{k}</div><div class='readout-val'>{v}</div></div>"449        for k, v in cells450    ) + "</div>"451 452 453# ----- speak + morph handlers -----454 455def _safe_int(v, lo: int = 0, hi: int = 4) -> int:456    try:457        return max(lo, min(hi, int(float(v))))458    except (TypeError, ValueError):459        return lo460 461 462def _render_token_preview(lyric: str, overrides_text: str) -> str:463    """Render the lyric as clickable gold token chips. Edited tokens show their override.464    Python-driven: gets called on every lyric / overrides change so the markup stays in sync."""465    overrides = _parse_per_word_overrides(overrides_text or "")466    if not lyric or not lyric.strip():467        return '<div class="token-row token-empty">type a lyric above; click any word here to hand-tune it</div>'468    parts = re.findall(r"[A-Za-z']+|\s+|[^A-Za-z'\s]+", lyric)469    pieces = []470    for p in parts:471        if not p:472            continue473        if not re.search(r"[A-Za-z]", p):474            # whitespace / punctuation passes through, render \n as <br>475            pieces.append(p.replace("\n", "<br>"))476            continue477        key = p.lower().strip("'")478        if key in overrides:479            repl, stretch = overrides[key]480            label = f"{repl} ({stretch:.2f}x)"481            pieces.append(f'<span class="token edited" data-word="{key}" data-pron="{repl}" '482                          f'data-stretch="{stretch:.2f}" title="{label}">{p}</span>')483        else:484            pieces.append(f'<span class="token" data-word="{key}" data-pron="{p}" '485                          f'data-stretch="1.00" title="click to hand-tune">{p}</span>')486    return '<div class="token-row">' + "".join(pieces) + '</div>'487 488 489def _apply_output_fx(y_dry, sr, postfx_preset, music_path, music_gain_db):490    """Cheap post-generation DSP: post-fx bus + optional music blend (CPU only, NOT491    @gpu_task). Kept separate from F5-TTS generation so changing post-fx / music492    re-renders instantly from the cached dry voice instead of re-running the neural net."""493    y = y_dry494    if postfx_preset and postfx_preset != "dry":495        y, _ = apply_post_fx(y, sr, preset=postfx_preset)496    if music_path:497        try:498            y, sr = _blend_with_music(y, sr, music_path,499                                       vocal_gain_db=0.0,500                                       music_gain_db=float(music_gain_db),501                                       tempo_lock=True)502        except Exception as e:503            print(f"[blend] failed: {e}; returning dry vocal")504    return _wav_to_filepath(y, sr)505 506 507@gpu_task508def speak(sentence, voice_id, level, postfx_preset, mode, seed,509          custom_voice, custom_voice_text, music_path, music_gain_db, overrides_text):510    sentence = (sentence or "").strip()511    level = _safe_int(level)512    if not sentence:513        return None, readout(level, None, None, "type a sentence first"), "", None514    overrides = _parse_per_word_overrides(overrides_text or "")515    y, sr, gen_text = _chunked_generate_with_overrides(516        ENGINE, sentence, voice_id, level, int(seed), mode,517        custom_voice or None, custom_voice_text or "", overrides,518    )519    # Save the DRY voice to its own file and cache the PATH (a cheap string), so post-fx /520    # music changes re-render from it without re-inference. We cache a path rather than the521    # raw array because speak runs in ZeroGPU's forked GPU worker and its return values are522    # serialized back across the process boundary; a path is trivial to pass, an array isn't.523    dry_path = _wav_to_filepath(y, sr)524    path = _apply_output_fx(y, sr, postfx_preset, music_path, music_gain_db)525    readout_text = gen_text if (mode == MODE_GHOST and gen_text and gen_text != sentence) else ""526    return path, readout(level, None, None, f"{mode.lower()} · lv{level}"), readout_text, dry_path527 528 529@gpu_task530def morph(sentence, voice_id, postfx_preset, mode, seed,531          custom_voice, custom_voice_text, music_path, music_gain_db,532          overrides_text, gap_ms: int = 250):533    sentence = (sentence or "").strip()534    if not sentence:535        return None, readout(None, None, None, "type a sentence first"), "", None536    overrides = _parse_per_word_overrides(overrides_text or "")537    clips = []538    sr_out = SAMPLE_RATE539    for lv in range(5):540        y, sr, _ = _chunked_generate_with_overrides(541            ENGINE, sentence, voice_id, lv, int(seed), mode,542            custom_voice or None, custom_voice_text or "", overrides,543        )544        sr_out = sr545        clips.append(y)546    morphed = equal_power_concat(clips, sr_out, fade_ms=gap_ms)547    dry_path = _wav_to_filepath(morphed, sr_out)548    path = _apply_output_fx(morphed, sr_out, postfx_preset, music_path, music_gain_db)549    return path, readout(None, None, None, f"{mode.lower()} · morphed 0->4"), "", dry_path550 551 552def reapply_fx(dry_path, postfx_preset, music_path, music_gain_db):553    """Re-render the output from the cached DRY voice file when post-fx / music changes,554    so the user hears the effect immediately without re-running F5-TTS. Nothing generated555    yet (no cached path) -> leave the player as-is. Pure CPU DSP, NOT @gpu_task."""556    if not dry_path:557        return gr.update()558    try:559        import soundfile as sf560        y, sr = sf.read(str(dry_path), dtype="float32")561    except Exception:562        return gr.update()563    return _apply_output_fx(y, sr, postfx_preset, music_path, music_gain_db)564 565 566# ----- CSS (dreamy pastel theme: half-remembered photograph of dusk) -----567 568CUSTOM_CSS = """569/* HEAVEN OR LAS VEGAS — direct reference. Long-exposure Christmas lights, deep midnight570   violet background, hot red-orange sun in the lower-right hemisphere, gold light trails571   swooping diagonally. Title in hand-drawn flowing italic script (Pinyon Script ≈ the572   Vaughan Oliver "Heaven or Las Vegas" lettering). Photographic, luminous, analog. */573 574:root {575    --night: #1A0F2D;576    --night-deep: #0E0820;577    --violet: #3D1E54;578    --violet-glow: #5A2F75;579    --sun-core: #FF4D2C;580    --sun-mid: #FF7A3D;581    --sun-halo: rgba(255, 120, 60, 0.55);582    --gold: #FFD66B;            /* brighter, more saturated for higher contrast on violet AND on the sun side */583    --gold-bright: #FFE9A3;584    --gold-glow: rgba(255, 214, 107, 0.6);585    --cream: #FFEFC9;586    --cream-mute: #E8D9A0;587    --ink-light: #FFE9B8;588    --ink-mute: #BFAD78;589    --hairline: rgba(255, 214, 107, 0.32);590}591 592/* BASE: NO opaque ancestor backgrounds (gradio-app + body default to opaque dark and593   would paint over a background layer), and NO background-attachment:fixed (jank on594   scroll, ignored on iOS Safari). The dark base + sun live on the injected #bg-sun595   layer only. Verified: no ancestor sets transform/filter/will-change/contain, so a596   position:fixed child of <body> anchors to the viewport and is never clipped. */597html, body, gradio-app, .gradio-container, .dark, .light {598    background: transparent !important;599    color: var(--ink-light) !important;600    font-family: 'Cormorant Garamond', Georgia, serif !important;601    min-height: 100vh;602}603/* Solid dark on <body> so first paint (before the JS mounts #bg-sun) shows no flash. */604body { background: var(--night) !important; }605 606/* THE FIXED SUN LAYER: a real <div id="bg-sun"> prepended to <body> by demo.load(js).607   position:fixed + inset:0 => pinned to the viewport, never scrolls, never clipped.608   The sun is a DEFINED ~560px circular ball tucked into the bottom-right corner609   (a `circle 280px` radial-gradient = 560px diameter), centered ~150px past the corner610   so most of the ball is visible like a little sun, fading cleanly to the dark base.611   NO giant viewport-wide wash. translateZ(0) = own GPU layer, no repaint on scroll. */612#bg-sun {613    position: fixed;614    inset: 0;615    z-index: 0;616    pointer-events: none;617    background:618      radial-gradient(circle 420px at calc(100% - 160px) calc(100% - 160px),619        #FFE7B0 0%,620        #FFB070 14%,621        #FF7A3D 30%,622        var(--sun-core) 46%,623        #C7311A 64%,624        rgba(107, 24, 8, 0.45) 82%,625        transparent 100%),626      var(--night);627    transform: translateZ(0);628}629 630/* CONTENT sits above the fixed sun layer. */631.gradio-container {632    background: transparent !important;633    position: relative;634    z-index: 1;635}636 637/* SUN-OVERLAP READABILITY: any gold/cream text gets a dark halo so it pops against638   the bright orange/red of the sun, and a subtle stroke for hard edges. Reads as639   "lit from behind" on dark bg, as "outlined" on bright bg. Plus on the right-half640   accordion labels (which directly overlap the sun corner), apply mix-blend-mode so641   the text auto-inverts as it crosses the sun. */642.gradio-container button.lg,643.gradio-container button.primary,644.gradio-container [data-testid="block-info"],645.gradio-container input[role="listbox"],646.gradio-container label.svelte-19qdtil span.svelte-19qdtil,647.gradio-container .label-wrap,648.gradio-container .label-wrap span,649.gradio-container summary,650.gradio-container [class^="label"],651#token-preview .token,652.knob-ticks span {653    text-shadow:654      0 0 1px rgba(0, 0, 0, 0.95),655      0 0 4px rgba(0, 0, 0, 0.85),656      0 0 14px rgba(0, 0, 0, 0.65) !important;657}658/* Sun-overlap masking restored: dark semi-opaque pills on labels so the bright sun659   doesn't bleed through into text. Text stays gold; pill blocks the sun behind. */660.gradio-container .label-wrap,661.gradio-container summary,662.gradio-container [data-testid="block-info"] {663    background: rgba(14, 8, 32, 0.78) !important;664    backdrop-filter: blur(6px);665    -webkit-backdrop-filter: blur(6px);666    border: 1px solid rgba(255, 214, 107, 0.22) !important;667    border-radius: 999px !important;668    padding: 10px 22px !important;669    margin: 4px 0 !important;670}671.gradio-container [data-testid="block-info"] {672    display: inline-block !important;673    padding: 3px 12px !important;674    font-size: 12px !important;675    margin-bottom: 6px !important;676}677 678/* (Light trails + film grain layers removed. Flat dark bg + viewport-fixed sun only.) */679 680.gradio-container { max-width: 1040px !important; margin: 0 auto !important; padding: 72px 48px 120px !important; position: relative; z-index: 1; }681 682/* -------------------- HERO -------------------- */683 684#hero { text-align: center; margin-bottom: 72px; position: relative; padding-top: 24px; }685#hero .wordmark-rule {686    display: inline-block;687    border: 1px solid var(--gold);688    padding: 4px 12px 5px;689    font-family: 'IBM Plex Mono', monospace; font-weight: 400; font-size: 10px;690    color: var(--gold); letter-spacing: 0.36em; text-transform: uppercase;691    margin-bottom: 38px;692    background: rgba(14, 8, 32, 0.55);693}694#hero h1 {695    font-family: 'Pinyon Script', cursive;696    font-weight: 400; font-size: 220px;697    letter-spacing: -0.012em; line-height: 0.74; margin: 0;698    color: var(--gold-bright);699    text-shadow:700      0 0 22px rgba(245, 197, 107, 0.62),701      0 0 70px rgba(245, 197, 107, 0.32),702      0 0 130px rgba(245, 197, 107, 0.18),703      0 3px 0 rgba(0, 0, 0, 0.45);704    text-transform: lowercase;705    transform: rotate(-3deg) translateX(-12px);706    display: inline-block;707}708#hero .tagline {709    font-family: 'Cormorant Garamond', serif; font-style: italic; font-weight: 300; font-size: 19px;710    color: var(--cream); margin-top: 28px; max-width: 480px;711    margin-left: auto; margin-right: auto;712    line-height: 1.65;713    text-shadow: 0 1px 8px rgba(0, 0, 0, 0.6);714}715 716/* -------------------- COMPONENTS -------------------- */717 718label, .gr-form > label, span[data-testid="block-info"], .label-wrap, label > span,719[data-testid="block-label"] {720    color: var(--gold) !important;721    font-family: 'IBM Plex Mono', monospace !important;722    font-style: normal !important;723    font-weight: 400 !important;724    font-size: 10px !important;725    letter-spacing: 0.3em !important;726    text-transform: uppercase !important;727    opacity: 0.85 !important;728}729 730/* strip the default block chrome from EVERY gradio container so the page reads731   as light + type + air, not stacked UI cards.732   Aggressive: nuke any "block" class background + border + radius. */733.block, .block-container, .form, .gradio-container > div > div,734[data-testid="block"], .gr-form, .gr-box,735.svelte-vt1mxs, .svelte-1ipelgc, .gr-padded,736[class*=" block"], [class^="block"],737.svelte-633qhp, .svelte-1plpy97, .svelte-1mwvhlq,738.gradio-container [class*="container"],739.gradio-container [class*="form"] {740    background: transparent !important;741    border: none !important;742    box-shadow: none !important;743    border-radius: 0 !important;744}745/* leave the slider container with vertical padding so the slider track is visible */746#dial-slider { padding: 18px 0 24px !important; }747/* keep accordion content padded so the audio drop boxes don't collapse */748[data-testid="accordion"] > div:nth-child(2),749.gradio-container [class*="accordion"] > div:not(:first-child) {750    padding: 16px 0 8px !important;751}752 753/* Rows: consistent gaps, vertical alignment */754.gradio-container .gr-row, .gradio-container [class*="row"] {755    gap: 28px !important;756    align-items: end !important;757}758 759/* lyric textarea — handwriting on light, no card, just a hot gold underline */760textarea {761    background: transparent !important;762    border: none !important;763    border-bottom: 1px solid rgba(245, 197, 107, 0.32) !important;764    border-radius: 0 !important;765    color: var(--gold-bright) !important;766    font-family: 'Cormorant Garamond', serif !important;767    font-style: italic !important;768    font-weight: 300 !important;769    font-size: 32px !important;770    line-height: 1.32 !important;771    padding: 14px 4px 18px !important;772    box-shadow: none !important;773    resize: none !important;774    text-shadow: 0 0 14px rgba(245, 197, 107, 0.32);775}776textarea::placeholder { color: var(--cream-mute) !important; opacity: 0.45 !important; font-style: italic !important; }777textarea:focus { border-bottom-color: var(--gold-bright) !important; outline: none !important; box-shadow: none !important; }778 779/* dropdowns + number inputs — minimal, gold underline only.780   Need to nuke svelte's wrapper containers AND the inner input.781   DO NOT collapse min-height on the slider's .wrap — it would hide the track. */782input, select, .gr-input, .gr-dropdown, [role="listbox"],783.secondary-wrap, .container,784[data-testid="dropdown"], [data-testid="number"] > div {785    background: transparent !important;786    backdrop-filter: none !important;787    border: none !important;788    color: var(--cream) !important;789    border-radius: 0 !important;790    font-family: 'Cormorant Garamond', serif !important;791    font-size: 19px !important;792    box-shadow: none !important;793}794/* Slider's own .wrap needs to keep its layout intact */795#dial-slider .wrap.svelte-8epfm4 { background: transparent !important; }796/* head row (label + number input + reset button) */797#dial-slider .head.svelte-8epfm4 { display: none !important; }798/* min/max value labels flanking the track */799#dial-slider .min_value, #dial-slider .max_value {800    font-family: 'Cormorant Garamond', serif; font-style: italic; font-size: 18px;801    color: var(--cream-mute); padding: 0 14px; align-self: center;802}803#dial-slider .slider_input_container.svelte-8epfm4 {804    display: flex !important; align-items: center !important; gap: 0 !important;805    padding: 12px 0 !important;806}807input, select {808    border-bottom: 1px solid rgba(245, 197, 107, 0.32) !important;809    font-style: italic !important;810    color: var(--gold-bright) !important;811}812input[type="text"], input[type="number"], select { padding: 8px 4px !important; }813input:focus, select:focus {814    border-bottom-color: var(--gold-bright) !important; outline: none !important;815    box-shadow: none !important;816}817/* dropdown trigger button (the visible selection) */818[data-testid="dropdown"] input, .gr-dropdown input {819    background: transparent !important;820    color: var(--gold-bright) !important;821}822 823/* Dropdown popup panel: dark solid background on the entire popup container plus824   each option, so neither the wrapper gaps nor the options show the page through. */825ul[role="listbox"],826[role="listbox"]:not(input),827.options,828.options ul,829.choices,830ul.choices,831.svelte-1xfsv4t .options,832.svelte-1xfsv4t .options ul {833    background: rgba(14, 8, 32, 0.98) !important;834    border: 1px solid var(--gold) !important;835    border-radius: 12px !important;836    box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6) !important;837    padding: 6px 0 !important;838    z-index: 100 !important;839}840ul[role="listbox"] [role="option"],841[role="option"],842.options ul li,843.choices li {844    background: transparent !important;        /* the parent's dark covers everything */845    color: var(--cream) !important;846    font-family: 'Cormorant Garamond', serif !important;847    font-style: italic !important;848    font-size: 16px !important;849    padding: 8px 18px !important;850    border-radius: 0 !important;851}852ul[role="listbox"] [role="option"]:hover,853[role="option"]:hover,854.options ul li:hover,855.choices li:hover {856    background: rgba(245, 197, 107, 0.18) !important;857    color: var(--gold-bright) !important;858}859 860/* mode radio — luminous gold tabs.861   The radio group's outer wrap on its own variant of the svelte class */862.wrap.svelte-1mwvhlq, [role="radiogroup"] {863    display: inline-flex !important; gap: 0 !important;864    border: 1px solid var(--gold) !important;865    background: transparent !important;866    border-radius: 999px !important;867    padding: 3px !important;868    overflow: hidden;869}870 871/* kill the boxy gray container around the voice + post-fx dropdowns AND seed number */872.gradio-container [data-testid="dropdown"],873.gradio-container [data-testid="number"],874.gradio-container .form,875[class^="block"], [class*=" block"] {876    background: transparent !important;877    border: none !important;878    box-shadow: none !important;879}880/* Gradio v6 dropdown internals: the .wrap holds the white default; override the whole881   stack down to the bare <input role="listbox">. */882.gradio-container .wrap.svelte-1xfsv4t,883.gradio-container .wrap-inner.svelte-1xfsv4t,884.gradio-container .secondary-wrap.svelte-1xfsv4t {885    background: rgba(14, 8, 32, 0.92) !important;886    border: 1px solid var(--gold) !important;887    border-radius: 999px !important;888    box-shadow: 0 0 18px rgba(255, 214, 107, 0.12) !important;889}890.gradio-container input[role="listbox"],891.gradio-container input.border-none.svelte-1xfsv4t {892    background: transparent !important;893    color: var(--gold-bright) !important;894    font-family: 'Cormorant Garamond', serif !important;895    font-style: italic !important;896    font-size: 17px !important;897    padding: 8px 18px !important;898    border: none !important;899}900.gradio-container input[role="listbox"]::placeholder { color: var(--cream-mute) !important; opacity: 1 !important; }901.gradio-container .dropdown-arrow.svelte-loyhyk { fill: var(--gold-bright) !important; }902 903/* Mode radio (Ghost / Tongues) — Gradio v6 svelte-19qdtil */904.gradio-container label.svelte-19qdtil {905    background: rgba(14, 8, 32, 0.7) !important;906    border: 1px solid var(--gold) !important;907    border-radius: 999px !important;908    padding: 8px 22px !important;909    margin: 0 6px 0 0 !important;910    cursor: pointer !important;911    transition: all 0.22s !important;912}913.gradio-container label.svelte-19qdtil span.svelte-19qdtil {914    color: var(--gold-bright) !important;915    font-family: 'Cormorant Garamond', serif !important;916    font-style: italic !important;917    font-weight: 500 !important;918    font-size: 16px !important;919}920.gradio-container label.svelte-19qdtil:has(input:checked) {921    background: linear-gradient(180deg, var(--sun-core), var(--sun-mid)) !important;922    border-color: var(--sun-core) !important;923    box-shadow: 0 0 22px var(--sun-halo) !important;924}925.gradio-container label.svelte-19qdtil:has(input:checked) span.svelte-19qdtil {926    color: var(--night-deep) !important;927}928/* The native radio dot — hide it; we use the chip-style instead */929.gradio-container input[type="radio"].svelte-19qdtil { display: none !important; }930[role="radio"][aria-checked="true"] label,931input[type="radio"]:checked + label {932    background: linear-gradient(180deg, var(--sun-core), var(--sun-mid)) !important;933    color: var(--night-deep) !important;934    box-shadow: 0 0 22px var(--sun-halo), inset 0 1px 0 rgba(255, 220, 180, 0.5) !important;935}936 937/* buttons — glowing pill on dark, gold borders */938button.primary, button[variant="primary"], .primary > button, button.lg, .gr-button {939    background: rgba(14, 8, 32, 0.92) !important;       /* opaque enough to read over the sun */940    color: var(--gold-bright) !important;941    border: 2px solid var(--gold) !important;            /* thicker for visibility */942    padding: 14px 34px !important;943    font-family: 'Cormorant Garamond', serif !important;944    font-style: italic !important;945    font-weight: 500 !important;                          /* heavier weight */946    font-size: 18px !important;947    letter-spacing: 0.02em !important;948    border-radius: 999px !important;949    box-shadow: 0 0 28px rgba(255, 214, 107, 0.32), inset 0 0 14px rgba(255, 214, 107, 0.12) !important;950    transition: all 0.24s !important;951    text-shadow: 0 0 6px rgba(255, 214, 107, 0.55);952}953button.primary:hover, button[variant="primary"]:hover, .gr-button:hover {954    background: var(--gold-bright) !important;955    color: var(--night-deep) !important;956    text-shadow: none;957    box-shadow: 0 0 36px var(--gold-glow), inset 0 0 18px rgba(255, 219, 138, 0.4) !important;958}959/* second action — Morph — in the hot vermillion / red-orange sun palette */960.action-row button:nth-of-type(2) {961    border-color: var(--sun-mid) !important;962    color: var(--sun-mid) !important;963    text-shadow: 0 0 10px rgba(255, 122, 61, 0.4);964    box-shadow: 0 0 24px rgba(255, 122, 61, 0.22) !important;965}966.action-row button:nth-of-type(2):hover {967    background: linear-gradient(180deg, var(--sun-core), var(--sun-mid)) !important;968    color: var(--night-deep) !important;969    text-shadow: none;970    box-shadow: 0 0 40px var(--sun-halo) !important;971}972 973/* ALL AUDIO WIDGETS (output "the take" + the voice-clone / backing-track upload974   inputs). Gradio renders these white by default, which clashes with the dark theme975   and reads as a "broken white player". Force the whole stack dark + gold. */976.gradio-container [data-testid="audio"],977#audio-out {978    background: rgba(14, 8, 32, 0.88) !important;979    border: 1px solid var(--gold) !important;980    border-radius: 14px !important;981    padding: 14px 16px !important;982    box-shadow: 0 0 20px rgba(255, 214, 107, 0.14) !important;983    margin: 10px 0 !important;984}985/* The white "Drop Audio Here / Click to Upload" drop zone inside audio inputs. */986.gradio-container [data-testid="audio"] .wrap,987.gradio-container [data-testid="audio"] .upload-container,988.gradio-container [data-testid="audio"] .file-upload,989.gradio-container [data-testid="audio"] [class*="upload"],990.gradio-container [data-testid="audio"] > div {991    background: transparent !important;992    color: var(--cream) !important;993    border-color: var(--hairline) !important;994}995/* The white block-label tab (e.g. "the take") that floats top-left. */996.gradio-container [data-testid="audio"] [data-testid="block-label"],997.gradio-container [data-testid="block-label"],998.gradio-container label.svelte-19djge9 {999    background: rgba(14, 8, 32, 0.92) !important;1000    color: var(--gold) !important;1001    border: 1px solid var(--hairline) !important;1002    border-radius: 8px !important;1003}1004.gradio-container [data-testid="block-label"] svg,1005.gradio-container [data-testid="block-label"] span { color: var(--gold) !important; fill: var(--gold) !important; }1006.gradio-container [data-testid="audio"] audio {1007    width: 100% !important; height: 46px !important; display: block !important;1008}1009/* The native HTML5 audio control bar: tint it to fit the dark theme. */1010.gradio-container [data-testid="audio"] audio::-webkit-media-controls-panel {1011    background: rgba(20, 12, 40, 0.9) !important;1012}1013 1014/* LIVE PREVIEW TEXTBOX (#ghost-lyric): big, gold-on-dark, italic — it's the1015   "see what the voice will say" surface and must be prominent. */1016#ghost-lyric, #ghost-lyric > div, #ghost-lyric .wrap, #ghost-lyric > label > div {1017    background: rgba(14, 8, 32, 0.92) !important;1018    border: 1px solid var(--gold) !important;1019    border-radius: 14px !important;1020}1021#ghost-lyric textarea, #ghost-lyric input {1022    background: transparent !important;1023    color: var(--gold-bright) !important;1024    font-family: 'Cormorant Garamond', serif !important;1025    font-style: italic !important;1026    font-size: 22px !important;1027    line-height: 1.4 !important;1028    text-align: center !important;1029    border: none !important;1030    padding: 18px 22px !important;1031    min-height: 80px !important;1032}1033 1034/* THE DIAL — brass knob with vermillion arc and a pointer needle.1035   Tick numbers sit in a semicircle above the knob: 0 on the left, 4 on the right. */1036#dial-stack {1037    display: flex; flex-direction: column; align-items: center;1038    margin: 32px 0 8px;1039}1040.dial-tag {1041    font-family: 'IBM Plex Mono', monospace; font-size: 10px;1042    color: var(--gold); letter-spacing: 0.34em; text-transform: uppercase;1043    opacity: 0.8; margin-bottom: 12px;        /* gap below the tag; ticks ride above the knob on their own */1044}1045.knob-stage {1046    position: relative;1047    width: 420px; height: 400px;          /* big enough to hold ticks at radius 156 above and around the knob */1048    display: flex; align-items: flex-end; justify-content: center;1049}1050/* Tick semicircle hugging the brass ring of the knob.1051   Positioned with its origin AT the knob center, so the JS polar coords are direct. */1052.knob-ticks {1053    position: absolute;1054    left: 50%;1055    bottom: 156px;   /* knob-wrap margin-bottom (16) + knob radius (140) = knob center */1056    width: 0; height: 0; pointer-events: none;1057}1058.knob-ticks span {1059    position: absolute;1060    left: 0; top: 0;1061    transform: translate(calc(var(--tx) - 50%), calc(var(--ty) - 50%));1062    display: flex; flex-direction: column; align-items: center;1063    font-family: 'Cormorant Garamond', serif; font-style: italic;1064    font-size: 24px; color: var(--cream-mute);1065    cursor: pointer; pointer-events: auto;1066    transition: color 0.22s, transform 0.22s;1067    line-height: 1;1068}1069.knob-ticks span small {1070    font-family: 'IBM Plex Mono', monospace; font-style: normal;1071    font-size: 8px; color: var(--cream-mute); opacity: 0.55;1072    letter-spacing: 0.22em; text-transform: uppercase;1073    margin-top: 4px; max-width: 80px; text-align: center;1074}1075.knob-ticks span.active {1076    color: var(--gold-bright); font-weight: 500; font-style: normal;1077    text-shadow: 0 0 12px var(--gold-glow);1078    transform: translate(calc(var(--tx) - 50%), calc(var(--ty) - 50%)) scale(1.22);1079}1080.knob-ticks span:hover { color: var(--gold-bright); }1081 1082/* Old-tech knob: knurled outer ring + flat dark face + thin cream indicator line.1083   Inspired by 1970s hi-fi tuner knobs (Marantz, Moog, Bakelite). */1084.knob-wrap {1085    position: relative;1086    width: 280px; height: 280px;1087    display: flex; align-items: center; justify-content: center;1088    margin-bottom: 16px;1089    filter: drop-shadow(0 14px 28px rgba(0,0,0,0.65));1090}1091.knob {1092    position: relative; z-index: 2;1093    width: 280px; height: 280px; border-radius: 50%;1094    cursor: grab; outline: none;1095    /* The flat face: warm walnut / dark Bakelite */1096    background:1097      radial-gradient(circle at 38% 32%,1098        rgba(255, 220, 175, 0.18) 0%,1099        rgba(255, 220, 175, 0.0) 28%),1100      radial-gradient(circle at 50% 50%,1101        #2C1B0E 0%, #1B0F08 70%, #100804 100%);1102    box-shadow:1103      inset 0 0 0 1px rgba(255, 220, 180, 0.1),1104      inset 0 -6px 12px rgba(0,0,0,0.55),1105      inset 0 4px 10px rgba(255, 220, 180, 0.08);1106    touch-action: none; user-select: none;1107    transition: filter 0.22s;1108}1109/* Knurled rim — fine repeating ridges in brass */1110.knob::before {1111    content: ''; position: absolute; inset: 0; border-radius: 50%; pointer-events: none;1112    background: repeating-conic-gradient(1113      from 0deg,1114      #B89968 0deg 2deg,1115      #4A3618 2deg 4deg,1116      #8B6E3A 4deg 6deg1117    );1118    -webkit-mask: radial-gradient(circle, transparent 102px, #000 103px, #000 136px, transparent 137px);1119            mask: radial-gradient(circle, transparent 102px, #000 103px, #000 136px, transparent 137px);1120    filter: brightness(0.92);1121}1122/* Subtle inner highlight ring between knurled rim and flat face */1123.knob::after {1124    content: ''; position: absolute; inset: 12px; border-radius: 50%; pointer-events: none;1125    box-shadow:1126      inset 0 0 0 1px rgba(255, 220, 180, 0.18),1127      inset 0 0 18px rgba(0,0,0,0.45);1128}1129.knob:focus-visible, .knob.dragging {1130    filter: brightness(1.04);1131}1132.knob.dragging { cursor: grabbing; }1133/* Faint gold arc OUTSIDE the knurled rim showing dial position.1134   Knob radius 140, rim outer ~136, so the arc sits at radius 144..150. The element1135   extends to inset:-16px so the mask circle fits. */1136.knob-arc {1137    position: absolute; z-index: 1; inset: -16px; border-radius: 50%; pointer-events: none;1138    background: conic-gradient(from 270deg,1139      var(--gold) 0deg,1140      var(--gold-bright) var(--arc-deg, 0deg),1141      transparent var(--arc-deg, 0deg) 180deg,1142      transparent 360deg);1143    -webkit-mask: radial-gradient(circle, transparent 144px, #000 145px, #000 152px, transparent 153px);1144            mask: radial-gradient(circle, transparent 144px, #000 145px, #000 152px, transparent 153px);1145    filter: drop-shadow(0 0 8px var(--gold-glow));1146    opacity: 0.9;1147}1148/* THE INDICATOR LINE — a thin cream line painted on the knob face, from center1149   to the edge of the knurled rim. Rotates with the knob value. */1150.knob-pointer {1151    position: absolute; z-index: 4;1152    left: 50%; top: 18px;1153    width: 5px; height: 96px;1154    margin-left: -2.5px;1155    background: linear-gradient(180deg, #FFF6D8 0%, #EFDBA8 80%, rgba(239, 219, 168, 0.0) 100%);1156    border-radius: 2px;1157    box-shadow:1158      0 0 8px rgba(255, 240, 200, 0.7),1159      0 0 18px rgba(255, 240, 200, 0.35),1160      inset 0 1px 0 rgba(255, 255, 240, 0.85);1161    transform-origin: 50% 122px;  /* pivot at the knob center (140 - 18) */1162    transform: rotate(var(--knob-angle, -90deg));1163    pointer-events: none;1164    transition: transform 0.24s cubic-bezier(.34,1.36,.4,1);1165}1166.knob-pin { display: none; }1167 1168/* Hide the actual gradio slider — the knob drives it via JS */1169#dial-slider { display: none !important; }1170#dial-slider .head, #dial-slider .slider_input_container { display: none !important; }1171 1172/* readout — newspaper-strip-like, gold rule */1173.readout { display: grid; grid-template-columns: repeat(4, 1fr); gap: 0; margin-top: 24px;1174           border-top: 1px solid var(--hairline); border-bottom: 1px solid var(--hairline); padding: 16px 0; }1175.readout-cell { background: transparent; padding: 4px 14px; text-align: left; border-right: 1px solid var(--hairline); }1176.readout-cell:last-child { border-right: none; }1177.readout-label { font-family: 'IBM Plex Mono', monospace; font-size: 9px; letter-spacing: 0.32em; color: var(--gold); margin-bottom: 6px; text-transform: uppercase; opacity: 0.7; }1178.readout-val { font-family: 'Cormorant Garamond', serif; font-style: italic; font-size: 22px; font-weight: 400; color: var(--cream); letter-spacing: -0.01em; }1179 1180/* ghost lyric — luminous pulled-quote in gold */1181.ghost-lyric textarea, [data-testid="textbox"]:not(:first-of-type) textarea {1182    font-family: 'Cormorant Garamond', serif !important;1183    font-style: italic !important;1184    font-size: 22px !important;1185    color: var(--gold-bright) !important;1186    border: none !important;1187    border-left: 2px solid var(--gold) !important;1188    background: rgba(245, 197, 107, 0.04) !important;1189    padding: 14px 0 14px 22px !important;1190    border-radius: 0 !important;1191    text-shadow: 0 0 10px rgba(245, 197, 107, 0.3);1192}1193 1194#footer {1195    margin-top: 88px; padding-top: 28px;1196    border-top: 1px solid var(--hairline);1197    font-family: 'IBM Plex Mono', monospace;1198    font-size: 10px; color: var(--cream-mute); letter-spacing: 0.26em;1199    text-transform: uppercase;1200    line-height: 1.9;

Showing the first 1,200 of 1891 lines. Download the file for the rest.