"""Glossolalia Dial — a single dial that grades a typed lyric into dreamy territory in two distinct phonotactic paths: Ghost mode: lyric is rewritten as a sequence of real English words (mondegreen substitution). Constrained by syllable count, primary-stress position, PanPhon feature-edit distance; reranked by DistilGPT-2 for semantic coherence. F5-TTS base reads it. Tongues mode: clean lyric goes into F5-TTS + a fine-tuned LoRA + a learned scalar conditioner (LevelEmbed at AdaLN side). The LoRA produces graded glossolalic audio in the user's chosen voice — invented pseudowords, sonorant-leaning palette. Both modes ride F5-TTS for voice cloning + audio synthesis. Off-the-Grid: no cloud APIs. v1 Gradio app (gr.Blocks). v2 (Off-Brand badge) is in app_server.py. """ from __future__ import annotations import os import tempfile from pathlib import Path import gradio as gr import numpy as np from config import ( CONTROL_STEM, HF_LORA_REPO, LEVEL_WORDS, RESEMBLYZER_MIN_COSINE, SAMPLE_RATE, VOICE_PRESETS, WHISPER_MODEL, ) from scripts.post_fx import PRESETS as POSTFX_PRESETS, apply_post_fx def _blend_with_music(vocal: np.ndarray, vocal_sr: int, music_path: str, vocal_gain_db: float = 0.0, music_gain_db: float = -8.0, tempo_lock: bool = True) -> tuple[np.ndarray, int]: """Mix the TTS vocal over an uploaded music track. - Detect music tempo via librosa.beat.beat_track - Detect music key (rough) via chroma + Krumhansl-Schmuckler profile - Optionally time-stretch the vocal so it lands in a tempo grid compatible with the music - Sum the two streams; trim to the longer of (music length, vocal length) - All local: librosa + numpy. Off-the-Grid stays clean. """ import librosa music, music_sr = librosa.load(music_path, sr=vocal_sr, mono=True) if tempo_lock and len(music) > vocal_sr * 2: try: mtempo, _ = librosa.beat.beat_track(y=music, sr=music_sr) # estimate vocal speech "tempo" by syllable-rate proxy (rms onset rate) vtempo, _ = librosa.beat.beat_track(y=vocal, sr=vocal_sr) if mtempo and vtempo and abs(np.log2(mtempo / vtempo)) < 1.5: # cap stretch at 25% in either direction to avoid robotic artifacts ratio = float(np.clip(vtempo / mtempo, 0.78, 1.28)) if abs(ratio - 1.0) > 0.04: vocal = librosa.effects.time_stretch(vocal, rate=ratio) except Exception as e: print(f"[blend] tempo lock failed: {e}; mixing without stretch") # Pad / truncate to the longer length n = max(len(music), len(vocal)) if len(vocal) < n: vocal = np.pad(vocal, (0, n - len(vocal))) if len(music) < n: music = np.pad(music, (0, n - len(music))) vocal_gain = 10.0 ** (vocal_gain_db / 20.0) music_gain = 10.0 ** (music_gain_db / 20.0) out = vocal_gain * vocal + music_gain * music peak = float(np.max(np.abs(out)) + 1e-9) if peak > 0.98: out = out * (0.98 / peak) return out.astype(np.float32), vocal_sr VOICE_IDS = list(VOICE_PRESETS.keys()) DEFAULT_VOICE = VOICE_IDS[0] if VOICE_IDS else "v1" DEFAULT_TEXT = "the river was wide and calm in the morning light" # default to the published LoRA repo so the Space loads it without needing env vars; # COHERENCE_DIAL_LORA env var overrides for local dev against a checkpoint dir. LORA_PATH = os.environ.get("COHERENCE_DIAL_LORA", HF_LORA_REPO) MODE_GHOST = "Ghost" MODE_TONGUES = "Tongues" MODES = (MODE_GHOST, MODE_TONGUES) # ----- inference engine (lazy-loaded; falls back to a silent stub if F5-TTS isn't installed) ----- class TTSEngine: """Dual-mode engine. One F5-TTS instance with the v8 LoRA loaded; mode is selected per inference call. For Ghost mode we set_dial(0) (LevelEmbed contributes ~zero) and feed mondegreen-substituted text. For Tongues mode we set_dial(level) and feed the clean lyric. The base LoRA attention adaptation is always on, but at dial=0 it produces audio indistinguishable from F5-TTS base (verified empirically by v5 sweep — lv0 sounded identical to base output).""" def __init__(self): self._tts = None self._asr = None self._enc = None self._mondegreen = None self._lm = None self._lora_loaded = False self.live = False def _ensure_mondegreen(self): """Lazy load the deterministic phonetic-ghost generator + DistilGPT-2 reranker.""" if self._mondegreen is not None: return try: from scripts.mondegreen import MondegreenIndex, LMReranker self._mondegreen = MondegreenIndex("data/cmudict.dict") print(f"[engine] Mondegreen index loaded ({self._mondegreen.size} words)") self._lm = LMReranker() print("[engine] DistilGPT-2 reranker loaded") except Exception as e: print(f"[engine] mondegreen load FAILED ({e}); Ghost mode falls back to clean text") self._mondegreen = False self._lm = False def ghost_text(self, sentence: str, level: int, seed: int = 42) -> str: """Deterministic Ghost mode substitution. Returns the source if mondegreen unavailable.""" self._ensure_mondegreen() if not self._mondegreen: return sentence return self._mondegreen.substitute(sentence, level, seed=seed, reranker=(self._lm or None)) def _ensure(self): if self._tts is not None: return try: import patches # noqa: F401 — installs F5TTS.load_lora before instantiation from f5_tts.api import F5TTS self._tts = F5TTS(model="F5TTS_v1_Base") self.live = True print(f"[engine] F5-TTS base loaded (model=F5TTS_v1_Base)") if LORA_PATH: try: self._tts.load_lora(LORA_PATH) self._lora_loaded = True print(f"[engine] LoRA loaded from {LORA_PATH}") except Exception as e: print(f"[engine] LoRA load FAILED ({e}); falling back to base model — Well-Tuned badge forfeit") else: print("[engine] no LoRA path configured; running base model only") except ImportError: print("[engine] f5-tts not installed; running with silent stub for layout testing") self.live = False def _ensure_asr(self): if self._asr is None: try: import whisper self._asr = whisper.load_model(WHISPER_MODEL) except Exception: self._asr = False return self._asr def _ensure_encoder(self): if self._enc is None: try: from resemblyzer import VoiceEncoder self._enc = VoiceEncoder() except Exception: self._enc = False return self._enc def generate(self, sentence: str, voice_id: str, level: int, seed: int = 42, mode: str = MODE_TONGUES, custom_voice_path: str | None = None, custom_voice_text: str = ""): """Returns (audio float32 mono, sample_rate, gen_text_used). mode=MODE_GHOST: substitute lyric via mondegreen at given level, set_dial(0), TTS reads it. mode=MODE_TONGUES: leave lyric clean, set_dial(level), LoRA conditions glossolalic audio. custom_voice_path: if a path is supplied, F5-TTS clones this clip instead of the preset voice. custom_voice_text is the transcript of that clip (improves clone quality; an empty string lets F5-TTS auto-transcribe via Whisper). """ self._ensure() if custom_voice_path: voice_wav = custom_voice_path voice_ref_text = (custom_voice_text or "").strip() else: voice = VOICE_PRESETS[voice_id] voice_wav = voice["wav"] ref_txt = Path(voice["ref_text"]) voice_ref_text = ref_txt.read_text(encoding="utf-8").strip() if ref_txt.exists() else "" if mode == MODE_GHOST: gen_text = self.ghost_text(sentence, level, seed=seed) tts_dial = 0 else: gen_text = sentence tts_dial = level if not self.live: return np.zeros(SAMPLE_RATE * 3, dtype=np.float32), SAMPLE_RATE, gen_text if hasattr(self._tts, "set_dial"): try: self._tts.set_dial(tts_dial) except Exception as e: print(f"[engine] set_dial({tts_dial}) FAILED ({e}); proceeding without conditioning") out = tempfile.NamedTemporaryFile(suffix=".wav", delete=False).name self._tts.infer(ref_file=voice_wav, ref_text=voice_ref_text, gen_text=gen_text, file_wave=out, seed=seed) import soundfile as sf y, sr = sf.read(out, always_2d=False) if y.ndim == 2: y = y.mean(axis=1) return y.astype(np.float32), sr, gen_text def transcribe_wer(self, y: np.ndarray, sr: int, ref_text: str) -> float | None: asr = self._ensure_asr() if not asr: return None import soundfile as sf, jiwer tmp = tempfile.NamedTemporaryFile(suffix=".wav", delete=False).name sf.write(tmp, y, sr) out = asr.transcribe(tmp, fp16=False, language="en", condition_on_previous_text=False, no_speech_threshold=0.8, logprob_threshold=-1.5) hyp = (out.get("text") or "").strip() if not hyp: return 1.0 return float(min(jiwer.wer(ref_text.lower(), hyp.lower()), 1.0)) def voice_cosine(self, y: np.ndarray, sr: int, ref_y: np.ndarray, ref_sr: int) -> float | None: enc = self._ensure_encoder() if not enc: return None from resemblyzer import preprocess_wav import soundfile as sf a = tempfile.NamedTemporaryFile(suffix=".wav", delete=False).name b = tempfile.NamedTemporaryFile(suffix=".wav", delete=False).name sf.write(a, y, sr); sf.write(b, ref_y, ref_sr) ea = enc.embed_utterance(preprocess_wav(a)) eb = enc.embed_utterance(preprocess_wav(b)) return float(np.dot(ea, eb) / ((np.linalg.norm(ea) * np.linalg.norm(eb)) + 1e-9)) ENGINE = TTSEngine() # ----- crossfading for Morph mode ----- def equal_power_concat(clips, sr, fade_ms=200): if not clips: return np.zeros(1, dtype=np.float32) fade_n = max(1, int(sr * fade_ms / 1000)) t = np.linspace(0, 1, fade_n, dtype=np.float32) fi = np.sin(t * np.pi / 2.0) fo = np.cos(t * np.pi / 2.0) out = clips[0].astype(np.float32).copy() for c in clips[1:]: c = c.astype(np.float32) if len(out) < fade_n or len(c) < fade_n: out = np.concatenate([out, c]); continue head = out[-fade_n:] * fo tail = c[:fade_n] * fi out = np.concatenate([out[:-fade_n], head + tail, c[fade_n:]]) return out def _wav_to_filepath(y: np.ndarray, sr: int) -> str: import soundfile as sf if y.ndim == 2: y = y.T # (channels, samples) -> (samples, channels) path = tempfile.NamedTemporaryFile(suffix=".wav", delete=False).name sf.write(path, y, sr) return path # ----- readout (live metrics strip) ----- def readout(level: int | None = None, wer: float | None = None, cosine: float | None = None, status: str = "") -> str: cells = [ ("DIAL", f"{level}" if level is not None else "—"), ("WER", f"{wer:.2f}" if wer is not None else "—"), ("VOICE-SIM", f"{cosine:.2f}" if cosine is not None else "—"), ("STATUS", status or ("live" if ENGINE.live else "stub")), ] return "
" + "".join( f"
{k}
{v}
" for k, v in cells ) + "
" # ----- speak + morph handlers ----- def _safe_int(v, lo: int = 0, hi: int = 4) -> int: try: return max(lo, min(hi, int(float(v)))) except (TypeError, ValueError): return lo def speak(sentence, voice_id, level, postfx_preset, mode, seed, custom_voice, custom_voice_text, music_path, music_gain_db): sentence = (sentence or "").strip() level = _safe_int(level) if not sentence: return None, readout(level, None, None, "type a sentence first"), "" y, sr, gen_text = ENGINE.generate( sentence, voice_id, level, seed=int(seed), mode=mode, custom_voice_path=custom_voice or None, custom_voice_text=custom_voice_text or "", ) if postfx_preset != "dry": y, _ = apply_post_fx(y, sr, preset=postfx_preset) if music_path: try: y, sr = _blend_with_music(y, sr, music_path, vocal_gain_db=0.0, music_gain_db=float(music_gain_db), tempo_lock=True) except Exception as e: print(f"[blend] failed: {e}; returning dry vocal") path = _wav_to_filepath(y, sr) readout_text = gen_text if (mode == MODE_GHOST and gen_text and gen_text != sentence) else "" return path, readout(level, None, None, f"{mode.lower()} · lv{level}"), readout_text def morph(sentence, voice_id, postfx_preset, mode, seed, custom_voice, custom_voice_text, music_path, music_gain_db, gap_ms: int = 250): sentence = (sentence or "").strip() if not sentence: return None, readout(None, None, None, "type a sentence first"), "" clips = [] for lv in range(5): y, sr, _ = ENGINE.generate( sentence, voice_id, lv, seed=int(seed), mode=mode, custom_voice_path=custom_voice or None, custom_voice_text=custom_voice_text or "", ) clips.append(y) morphed = equal_power_concat(clips, sr, fade_ms=gap_ms) if postfx_preset != "dry": morphed, _ = apply_post_fx(morphed, sr, preset=postfx_preset) if music_path: try: morphed, sr = _blend_with_music(morphed, sr, music_path, vocal_gain_db=0.0, music_gain_db=float(music_gain_db), tempo_lock=True) except Exception as e: print(f"[blend] failed: {e}; returning dry vocal") path = _wav_to_filepath(morphed, sr) return path, readout(None, None, None, f"{mode.lower()} · morphed 0->4"), "" # ----- CSS (dreamy pastel theme: half-remembered photograph of dusk) ----- CUSTOM_CSS = """ @import url('https://fonts.googleapis.com/css2?family=Pinyon+Script&family=Cormorant+Garamond:ital,wght@0,300;0,400;0,500;1,300;1,400&family=IBM+Plex+Mono:wght@300;400;500&display=swap'); /* HEAVEN OR LAS VEGAS — direct reference. Long-exposure Christmas lights, deep midnight violet background, hot red-orange sun in the lower-right hemisphere, gold light trails swooping diagonally. Title in hand-drawn flowing italic script (Pinyon Script ≈ the Vaughan Oliver "Heaven or Las Vegas" lettering). Photographic, luminous, analog. */ :root { --night: #1A0F2D; --night-deep: #0E0820; --violet: #3D1E54; --violet-glow: #5A2F75; --sun-core: #FF4D2C; --sun-mid: #FF7A3D; --sun-halo: rgba(255, 120, 60, 0.55); --gold: #F5C56B; --gold-bright: #FFDB8A; --gold-glow: rgba(245, 197, 107, 0.42); --cream: #F3E7C3; --cream-mute: #D6C998; --ink-light: #E8DBB3; --ink-mute: #A89A6A; --hairline: rgba(245, 197, 107, 0.22); } html, body, .gradio-container, .dark, .light, gradio-app { background: /* deep violet wash with night gradient */ radial-gradient(1400px 900px at 30% 18%, var(--violet-glow) 0%, var(--violet) 32%, var(--night) 72%, var(--night-deep) 100%) !important; background-attachment: fixed !important; color: var(--ink-light) !important; font-family: 'Cormorant Garamond', Georgia, serif !important; min-height: 100vh; } /* THE SUN — fixed, lower-right, the focal hot red-orange sphere from the cover */ .gradio-container::before { content: ''; position: fixed; z-index: 0; pointer-events: none; right: -120px; bottom: -120px; width: 720px; height: 720px; border-radius: 50%; background: radial-gradient(circle at 38% 32%, #FFE7B0 0%, #FFB070 12%, var(--sun-core) 28%, #C7311A 52%, #6B1808 78%, transparent 100%); filter: blur(8px); opacity: 0.92; box-shadow: 0 0 220px 60px rgba(255, 120, 60, 0.45), 0 0 460px 120px rgba(255, 80, 40, 0.22); animation: sunBreath 14s ease-in-out infinite alternate; } @keyframes sunBreath { 0% { transform: translate(0, 0) scale(1.00); opacity: 0.88; } 100% { transform: translate(-18px, -8px) scale(1.06); opacity: 0.96; } } /* Gold long-exposure light trails — diagonal swoops, painted with SVG */ body::after, gradio-app::after { content: ''; position: fixed; inset: 0; pointer-events: none; z-index: 0; background-image: url("data:image/svg+xml;utf8,"); background-size: cover; opacity: 0.55; mix-blend-mode: screen; } /* film grain — make it feel photographic, not digital */ body::before, gradio-app::before { content: ''; position: fixed; inset: 0; pointer-events: none; z-index: 0; background-image: url("data:image/svg+xml;utf8,"); opacity: 0.12; mix-blend-mode: overlay; } .gradio-container { max-width: 800px !important; margin: 0 auto !important; padding: 64px 32px 100px !important; position: relative; z-index: 1; } /* -------------------- HERO -------------------- */ #hero { text-align: center; margin-bottom: 72px; position: relative; padding-top: 24px; } #hero .wordmark-rule { display: inline-block; border: 1px solid var(--gold); padding: 4px 12px 5px; font-family: 'IBM Plex Mono', monospace; font-weight: 400; font-size: 10px; color: var(--gold); letter-spacing: 0.36em; text-transform: uppercase; margin-bottom: 38px; background: rgba(14, 8, 32, 0.55); } #hero h1 { font-family: 'Pinyon Script', cursive; font-weight: 400; font-size: 220px; letter-spacing: -0.012em; line-height: 0.74; margin: 0; color: var(--gold-bright); text-shadow: 0 0 22px rgba(245, 197, 107, 0.62), 0 0 70px rgba(245, 197, 107, 0.32), 0 0 130px rgba(245, 197, 107, 0.18), 0 3px 0 rgba(0, 0, 0, 0.45); text-transform: lowercase; transform: rotate(-3deg) translateX(-12px); display: inline-block; } #hero .tagline { font-family: 'Cormorant Garamond', serif; font-style: italic; font-weight: 300; font-size: 19px; color: var(--cream); margin-top: 28px; max-width: 480px; margin-left: auto; margin-right: auto; line-height: 1.65; text-shadow: 0 1px 8px rgba(0, 0, 0, 0.6); } /* -------------------- COMPONENTS -------------------- */ label, .gr-form > label, span[data-testid="block-info"], .label-wrap, label > span, [data-testid="block-label"] { color: var(--gold) !important; font-family: 'IBM Plex Mono', monospace !important; font-style: normal !important; font-weight: 400 !important; font-size: 10px !important; letter-spacing: 0.3em !important; text-transform: uppercase !important; opacity: 0.85 !important; } /* strip the default block chrome from EVERY gradio container so the page reads as light + type + air, not stacked UI cards. */ .block, .block-container, .form, .gradio-container > div > div, [data-testid="block"], .gr-form, .gr-box { background: transparent !important; border: none !important; box-shadow: none !important; border-radius: 0 !important; padding: 0 !important; } .svelte-vt1mxs, .svelte-1ipelgc, .gr-padded { background: transparent !important; } /* lyric textarea — handwriting on light, no card, just a hot gold underline */ textarea { background: transparent !important; border: none !important; border-bottom: 1px solid rgba(245, 197, 107, 0.32) !important; border-radius: 0 !important; color: var(--gold-bright) !important; font-family: 'Cormorant Garamond', serif !important; font-style: italic !important; font-weight: 300 !important; font-size: 32px !important; line-height: 1.32 !important; padding: 14px 4px 18px !important; box-shadow: none !important; resize: none !important; text-shadow: 0 0 14px rgba(245, 197, 107, 0.32); } textarea::placeholder { color: var(--cream-mute) !important; opacity: 0.45 !important; font-style: italic !important; } textarea:focus { border-bottom-color: var(--gold-bright) !important; outline: none !important; box-shadow: none !important; } /* dropdowns + number inputs — minimal, gold underline only. Need to nuke svelte's wrapper containers AND the inner input. DO NOT collapse min-height on the slider's .wrap — it would hide the track. */ input, select, .gr-input, .gr-dropdown, [role="listbox"], .secondary-wrap, .container, [data-testid="dropdown"], [data-testid="number"] > div { background: transparent !important; backdrop-filter: none !important; border: none !important; color: var(--cream) !important; border-radius: 0 !important; font-family: 'Cormorant Garamond', serif !important; font-size: 19px !important; box-shadow: none !important; } /* Slider's own .wrap needs to keep its layout intact */ #dial-slider .wrap.svelte-8epfm4 { background: transparent !important; } /* head row (label + number input + reset button) */ #dial-slider .head.svelte-8epfm4 { display: none !important; } /* min/max value labels flanking the track */ #dial-slider .min_value, #dial-slider .max_value { font-family: 'Cormorant Garamond', serif; font-style: italic; font-size: 18px; color: var(--cream-mute); padding: 0 14px; align-self: center; } #dial-slider .slider_input_container.svelte-8epfm4 { display: flex !important; align-items: center !important; gap: 0 !important; padding: 12px 0 !important; } input, select { border-bottom: 1px solid rgba(245, 197, 107, 0.32) !important; font-style: italic !important; color: var(--gold-bright) !important; } input[type="text"], input[type="number"], select { padding: 8px 4px !important; } input:focus, select:focus { border-bottom-color: var(--gold-bright) !important; outline: none !important; box-shadow: none !important; } /* dropdown trigger button (the visible selection) */ [data-testid="dropdown"] input, .gr-dropdown input { background: transparent !important; color: var(--gold-bright) !important; } /* dropdown popup options — dark velvet so they read on the violet bg */ [role="listbox"] [role="option"], .options ul li { background: rgba(14, 8, 32, 0.96) !important; color: var(--cream) !important; font-family: 'Cormorant Garamond', serif !important; font-style: italic !important; } [role="listbox"] [role="option"]:hover { background: rgba(245, 197, 107, 0.18) !important; color: var(--gold-bright) !important; } /* mode radio — luminous gold tabs. The radio group's outer wrap on its own variant of the svelte class */ .wrap.svelte-1mwvhlq, [role="radiogroup"] { display: inline-flex !important; gap: 0 !important; border: 1px solid var(--gold) !important; background: transparent !important; border-radius: 999px !important; padding: 3px !important; overflow: hidden; } /* kill the boxy gray container around the voice + post-fx dropdowns AND seed number */ .gradio-container [data-testid="dropdown"], .gradio-container [data-testid="number"], .gradio-container .form, [class^="block"], [class*=" block"] { background: transparent !important; border: none !important; box-shadow: none !important; } /* The dropdown internal button look — the gradio "selected value pill" */ .gradio-container .gr-dropdown, .gradio-container [data-testid="dropdown"] > div > div { background: transparent !important; border: none !important; border-bottom: 1px solid rgba(245, 197, 107, 0.32) !important; border-radius: 0 !important; padding: 6px 4px !important; } [role="radio"] label, .gr-input-label, input[type="radio"] + label, label[for*="radio"] { background: transparent !important; color: var(--cream-mute) !important; font-family: 'Cormorant Garamond', serif !important; font-style: italic !important; font-weight: 400 !important; font-size: 15px !important; padding: 8px 28px !important; cursor: pointer !important; border-radius: 999px !important; transition: all 0.22s !important; text-transform: none !important; letter-spacing: 0 !important; } [role="radio"][aria-checked="true"] label, input[type="radio"]:checked + label { background: linear-gradient(180deg, var(--sun-core), var(--sun-mid)) !important; color: var(--night-deep) !important; box-shadow: 0 0 22px var(--sun-halo), inset 0 1px 0 rgba(255, 220, 180, 0.5) !important; } /* buttons — glowing pill on dark, gold borders */ button.primary, button[variant="primary"], .primary > button, button.lg, .gr-button { background: rgba(14, 8, 32, 0.62) !important; color: var(--gold-bright) !important; border: 1px solid var(--gold) !important; padding: 13px 32px !important; font-family: 'Cormorant Garamond', serif !important; font-style: italic !important; font-weight: 400 !important; font-size: 17px !important; letter-spacing: 0.02em !important; border-radius: 999px !important; box-shadow: 0 0 24px rgba(245, 197, 107, 0.18), inset 0 0 12px rgba(245, 197, 107, 0.08) !important; transition: all 0.24s !important; text-shadow: 0 0 8px rgba(245, 197, 107, 0.4); } button.primary:hover, button[variant="primary"]:hover, .gr-button:hover { background: var(--gold-bright) !important; color: var(--night-deep) !important; text-shadow: none; box-shadow: 0 0 36px var(--gold-glow), inset 0 0 18px rgba(255, 219, 138, 0.4) !important; } /* second action — Morph — in the hot vermillion / red-orange sun palette */ .action-row button:nth-of-type(2) { border-color: var(--sun-mid) !important; color: var(--sun-mid) !important; text-shadow: 0 0 10px rgba(255, 122, 61, 0.4); box-shadow: 0 0 24px rgba(255, 122, 61, 0.22) !important; } .action-row button:nth-of-type(2):hover { background: linear-gradient(180deg, var(--sun-core), var(--sun-mid)) !important; color: var(--night-deep) !important; text-shadow: none; box-shadow: 0 0 40px var(--sun-halo) !important; } /* audio output card */ .gr-audio, audio { background: rgba(14, 8, 32, 0.62) !important; border: 1px solid var(--hairline) !important; border-radius: 6px !important; color: var(--cream) !important; } audio::-webkit-media-controls-panel { background: rgba(14, 8, 32, 0.85) !important; } /* THE DIAL — focal luminous filament with a sun-orb thumb. Slider IS the dial. */ #dial-frame { margin: 28px 0 8px; text-align: center; } .dial-tag { font-family: 'IBM Plex Mono', monospace; font-size: 10px; color: var(--gold); letter-spacing: 0.34em; text-transform: uppercase; opacity: 0.8; margin-bottom: 12px; } .dial-ticks { display: flex; justify-content: space-between; font-family: 'Cormorant Garamond', serif; font-style: italic; color: var(--cream-mute); font-size: 22px; margin: 24px 6px 4px; } .dial-ticks span { display: flex; flex-direction: column; align-items: center; line-height: 1; } .dial-ticks span small { font-family: 'IBM Plex Mono', monospace; font-style: normal; font-size: 9px; color: var(--cream-mute); opacity: 0.6; letter-spacing: 0.22em; text-transform: uppercase; margin-top: 8px; } #dial-slider, [elem_id="dial-slider"] { padding: 0 !important; margin: 0 !important; background: transparent !important; border: none !important; } #dial-slider > div, #dial-slider .wrap-inner { background: transparent !important; } /* The actual rail: a horizontal filament glowing in gold. */ input[type="range"] { -webkit-appearance: none; appearance: none; width: 100% !important; height: 6px !important; background: linear-gradient(90deg, rgba(245, 197, 107, 0.85), var(--gold-bright) 30%, var(--gold) 60%, rgba(245, 197, 107, 0.45)) !important; border-radius: 999px !important; outline: none !important; box-shadow: 0 0 14px var(--gold-glow), 0 0 36px rgba(245, 197, 107, 0.25); cursor: pointer; margin: 6px 0 !important; } /* The thumb is the red-orange sun — large, glowing, the focal object */ input[type="range"]::-webkit-slider-thumb { -webkit-appearance: none; appearance: none; width: 44px !important; height: 44px !important; border-radius: 50%; background: radial-gradient(circle at 32% 30%, #FFE0B0 0%, var(--sun-mid) 28%, var(--sun-core) 60%, #8B2710 100%); box-shadow: 0 0 18px rgba(255, 200, 120, 0.6), 0 0 38px var(--sun-halo), 0 0 80px rgba(255, 80, 40, 0.45), inset 0 -4px 8px rgba(120, 20, 0, 0.5); cursor: grab; border: 1px solid rgba(245, 197, 107, 0.7); margin-top: -19px; transition: box-shadow 0.18s, transform 0.18s; } input[type="range"]::-webkit-slider-thumb:hover { transform: scale(1.06); box-shadow: 0 0 24px rgba(255, 220, 160, 0.8), 0 0 56px var(--sun-halo), 0 0 120px rgba(255, 80, 40, 0.55), inset 0 -4px 8px rgba(120, 20, 0, 0.5); } input[type="range"]:active::-webkit-slider-thumb { cursor: grabbing; } input[type="range"]::-moz-range-thumb { width: 44px; height: 44px; border-radius: 50%; background: radial-gradient(circle at 32% 30%, #FFE0B0 0%, var(--sun-mid) 28%, var(--sun-core) 60%, #8B2710 100%); box-shadow: 0 0 18px rgba(255, 200, 120, 0.6), 0 0 38px var(--sun-halo), 0 0 80px rgba(255, 80, 40, 0.45), inset 0 -4px 8px rgba(120, 20, 0, 0.5); cursor: grab; border: 1px solid rgba(245, 197, 107, 0.7); } input[type="range"]::-moz-range-track { background: transparent; } /* readout — newspaper-strip-like, gold rule */ .readout { display: grid; grid-template-columns: repeat(4, 1fr); gap: 0; margin-top: 24px; border-top: 1px solid var(--hairline); border-bottom: 1px solid var(--hairline); padding: 16px 0; } .readout-cell { background: transparent; padding: 4px 14px; text-align: left; border-right: 1px solid var(--hairline); } .readout-cell:last-child { border-right: none; } .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; } .readout-val { font-family: 'Cormorant Garamond', serif; font-style: italic; font-size: 22px; font-weight: 400; color: var(--cream); letter-spacing: -0.01em; } /* ghost lyric — luminous pulled-quote in gold */ .ghost-lyric textarea, [data-testid="textbox"]:not(:first-of-type) textarea { font-family: 'Cormorant Garamond', serif !important; font-style: italic !important; font-size: 22px !important; color: var(--gold-bright) !important; border: none !important; border-left: 2px solid var(--gold) !important; background: rgba(245, 197, 107, 0.04) !important; padding: 14px 0 14px 22px !important; border-radius: 0 !important; text-shadow: 0 0 10px rgba(245, 197, 107, 0.3); } #footer { margin-top: 88px; padding-top: 28px; border-top: 1px solid var(--hairline); font-family: 'IBM Plex Mono', monospace; font-size: 10px; color: var(--cream-mute); letter-spacing: 0.26em; text-transform: uppercase; line-height: 1.9; display: grid; grid-template-columns: 1fr auto; gap: 24px; opacity: 0.7; } .dial-label { display: none; } /* hide the standard gradio header / footer chrome */ .show-api, footer.svelte-mpyp5e, gradio-app > footer { display: none !important; } @media (max-width: 720px) { #hero h1 { font-size: 88px !important; } .gradio-container { padding: 36px 18px 64px !important; } textarea { font-size: 20px !important; } } """ _THEME = gr.themes.Base(primary_hue=gr.themes.colors.pink, neutral_hue=gr.themes.colors.slate).set( body_background_fill="*neutral_950", block_background_fill="*neutral_900", ) # ----- circular knob widget (gr.HTML + html_template + js_on_load + trigger('change')) # Requires Gradio >= 6.9 for arbitrary event-name triggers (we pin >= 6.10 in requirements.txt). # Pattern is from gradio.app/guides/custom-HTML-components. KNOB_HTML = """
${value}
01234
turn · drag · scroll · arrow keys
""" KNOB_JS = """ (element, props) => { const host = element.querySelector('.knob-host'); if (!host) return; const lab = element.querySelector('.knob-label'); const ticks = Array.from(element.querySelectorAll('.knob-ticks span')); const LEVELS = 5; const ANGLE_MIN = -135, ANGLE_MAX = 135; let level = Math.max(0, Math.min(LEVELS - 1, Math.round(Number(props.value) || 0))); const render = (lv) => { const frac = lv / (LEVELS - 1); const ang = ANGLE_MIN + (ANGLE_MAX - ANGLE_MIN) * frac; const arc = (ANGLE_MAX - ANGLE_MIN) * frac; host.style.setProperty('--knob-angle', ang + 'deg'); host.style.setProperty('--arc-deg', arc + 'deg'); lab.textContent = lv; host.setAttribute('aria-valuenow', lv); ticks.forEach(t => t.classList.toggle('active', Number(t.dataset.lv) === lv)); }; const setLevel = (lv, fire) => { lv = Math.max(0, Math.min(LEVELS - 1, Math.round(lv))); if (lv === level && !fire) return; level = lv; render(level); if (fire) { props.value = level; trigger('change'); } }; render(level); let dragging = false, startY = 0, startLv = 0; host.addEventListener('pointerdown', (e) => { dragging = true; startY = e.clientY; startLv = level; host.setPointerCapture(e.pointerId); host.classList.add('dragging'); }); host.addEventListener('pointermove', (e) => { if (!dragging) return; const dy = startY - e.clientY; // drag up -> louder setLevel(startLv + dy / 40, true); }); const endDrag = (e) => { if (!dragging) return; dragging = false; try { host.releasePointerCapture(e.pointerId); } catch (_) {} host.classList.remove('dragging'); }; host.addEventListener('pointerup', endDrag); host.addEventListener('pointercancel', endDrag); host.addEventListener('wheel', (e) => { e.preventDefault(); setLevel(level + (e.deltaY < 0 ? 1 : -1), true); }, { passive: false }); host.addEventListener('keydown', (e) => { if (e.key === 'ArrowUp' || e.key === 'ArrowRight') { e.preventDefault(); setLevel(level + 1, true); } else if (e.key === 'ArrowDown' || e.key === 'ArrowLeft') { e.preventDefault(); setLevel(level - 1, true); } else if (e.key === 'Home') { e.preventDefault(); setLevel(0, true); } else if (e.key === 'End') { e.preventDefault(); setLevel(LEVELS - 1, true); } }); ticks.forEach(t => t.addEventListener('click', () => setLevel(Number(t.dataset.lv), true))); } """ STYLE_PRESETS = { "dreamy": "Goodman/Samarin glossolalia palette", "hopelandic": "glide-led, high-front vowel chains (Sigur Rós register)", "fraser": "m/n/l onsets, open back vowels (Cocteau Twins register)", } with gr.Blocks(title="Glossolalia Dial") as demo: gr.HTML( """
Volume I · Thousand Token Wood

glossolalia.

a single dial that grades your lyric from speech into wordless tongues, in your own voice. two phonotactic paths, one melody.

A field study
in dreamy dissolution
""" ) # lyric — magazine pull-quote spanning full width sentence = gr.Textbox(label="the lyric", value=DEFAULT_TEXT, lines=2, placeholder="anything; the dial will dissolve it", elem_id="lyric-input") # voice + mode in a quiet two-up with gr.Row(): voice = gr.Dropdown([(v["name"], k) for k, v in VOICE_PRESETS.items()], value=DEFAULT_VOICE, label="the voice", scale=1) mode = gr.Radio(choices=list(MODES), value=MODE_TONGUES, label="the path", scale=1) # custom voice — record or upload a 10-sec clip; F5-TTS clones it with gr.Accordion("Or clone your own voice (record / upload, 6-12 sec)", open=False, elem_id="custom-voice-accord"): with gr.Row(): custom_voice = gr.Audio( sources=["upload", "microphone"], type="filepath", label="reference clip — clean speech, no background music, 6-12 sec", scale=2, ) custom_voice_text = gr.Textbox( label="(optional) transcript of the clip", placeholder="leave empty to auto-transcribe (Whisper)", lines=2, scale=1, ) # background music — upload an instrumental; we tempo-lock + mix the vocal over it with gr.Accordion("Or drop in a backing track to mix the dial over", open=False, elem_id="music-accord"): with gr.Row(): music_input = gr.Audio( sources=["upload"], type="filepath", label="backing track (instrumental, any length, any tempo)", scale=2, ) music_gain = gr.Slider( -24, 6, value=-8, step=1, label="music level (dB)", scale=1, ) # the dial — focal luminous element (Gradio slider styled as glowing filament + sun orb) gr.HTML( """
the dial
0clean 1 2half-dissolved 3 4tongues
""" ) level = gr.Slider(0, 4, value=0, step=1, label="", elem_id="dial-slider", show_label=False, visible=True) # post-fx + seed as a quiet adjuster row with gr.Row(): postfx = gr.Dropdown(list(POSTFX_PRESETS.keys()), value="subtle", label="post · reverb / chorus / octave", scale=2) seed = gr.Number(value=42, precision=0, label="seed", scale=1) # two actions, side by side, the morph one in vermillion with gr.Row(elem_classes="action-row"): speak_btn = gr.Button("speak once", variant="primary", elem_classes="primary", scale=1) morph_btn = gr.Button("morph 0 → 4 in one breath", variant="primary", elem_classes="primary", scale=1) audio_out = gr.Audio(label="the take", type="filepath", autoplay=False) ghost_lyric = gr.Textbox(label="the ghost — the deterministic substitution at this dial", interactive=False, lines=2, elem_classes="ghost-lyric") metrics = gr.HTML(readout()) level.change(lambda lv: readout(level=_safe_int(lv)), inputs=level, outputs=metrics) speak_btn.click( speak, inputs=[sentence, voice, level, postfx, mode, seed, custom_voice, custom_voice_text, music_input, music_gain], outputs=[audio_out, metrics, ghost_lyric], ) morph_btn.click( morph, inputs=[sentence, voice, postfx, mode, seed, custom_voice, custom_voice_text, music_input, music_gain], outputs=[audio_out, metrics, ghost_lyric], ) gr.HTML( """ """ ) if __name__ == "__main__": demo.launch(theme=_THEME, css=CUSTOM_CSS)