build-small-hackathon/glossolalia
3
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;