akshan-main commited on
Commit
4416929
·
verified ·
1 Parent(s): 636267a

mirror app.py

Browse files
Files changed (1) hide show
  1. app.py +1818 -0
app.py ADDED
@@ -0,0 +1,1818 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Glossolalia Dial — a single dial that grades a typed lyric into dreamy territory in two
2
+ distinct 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-edit
6
+ 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 conditioner
8
+ (LevelEmbed at AdaLN side). The LoRA produces graded glossolalic audio in the
9
+ user's chosen voice — invented pseudowords, sonorant-leaning palette.
10
+
11
+ Both modes ride F5-TTS for voice cloning + audio synthesis. Off-the-Grid: no cloud APIs.
12
+
13
+ v1 Gradio app (gr.Blocks). v2 (Off-Brand badge) is in app_server.py.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import os
19
+ import re
20
+ import tempfile
21
+ from pathlib import Path
22
+
23
+ import gradio as gr
24
+ import numpy as np
25
+
26
+ from config import (
27
+ CONTROL_STEM, HF_LORA_REPO, LEVEL_WORDS, RESEMBLYZER_MIN_COSINE,
28
+ SAMPLE_RATE, VOICE_PRESETS, WHISPER_MODEL,
29
+ )
30
+ from scripts.post_fx import PRESETS as POSTFX_PRESETS, apply_post_fx
31
+
32
+
33
+ def _parse_per_word_overrides(text: str) -> dict[str, tuple[str, float]]:
34
+ """Parse a string like 'river=ree-vuh:1.3 calm=kawm light=:1.6' into a dict:
35
+ { 'river': ('ree-vuh', 1.3), 'calm': ('kawm', 1.0), 'light': ('light', 1.6) }
36
+ The 'word=replacement:stretch' format is intentionally simple:
37
+ - word: the source word in the input lyric (case-insensitive)
38
+ - replacement: the pronunciation guide we feed to F5-TTS (empty -> keep original)
39
+ - stretch: a speed multiplier for THAT word's audio chunk (1.0 = normal,
40
+ <1.0 = faster, >1.0 = slower / more sustained). Default 1.0 if omitted.
41
+ """
42
+ out: dict[str, tuple[str, float]] = {}
43
+ for tok in (text or "").split():
44
+ if "=" not in tok:
45
+ continue
46
+ word, rest = tok.split("=", 1)
47
+ word = word.strip().lower()
48
+ if ":" in rest:
49
+ repl, sval = rest.split(":", 1)
50
+ try:
51
+ stretch = float(sval)
52
+ except ValueError:
53
+ stretch = 1.0
54
+ else:
55
+ repl, stretch = rest, 1.0
56
+ stretch = max(0.5, min(2.5, stretch))
57
+ out[word] = (repl.strip() if repl.strip() else word, stretch)
58
+ return out
59
+
60
+
61
+ def _chunked_generate_with_overrides(
62
+ engine, sentence: str, voice_id: str, level: int, seed: int, mode: str,
63
+ custom_voice_path: str | None, custom_voice_text: str,
64
+ overrides: dict[str, tuple[str, float]]) -> tuple[np.ndarray, int, str]:
65
+ """Generate audio chunk-by-chunk so per-word stretch + pronunciation overrides apply.
66
+ Each chunk goes through F5-TTS at its own speed, then we equal-power concat.
67
+ Falls back to the single-shot path if no overrides are present."""
68
+ if not overrides:
69
+ return engine.generate(sentence, voice_id, level, seed=seed, mode=mode,
70
+ custom_voice_path=custom_voice_path,
71
+ custom_voice_text=custom_voice_text)
72
+ words = re.findall(r"[A-Za-z']+|[^A-Za-z']+", sentence)
73
+ chunks: list[tuple[str, float]] = []
74
+ for w in words:
75
+ key = w.lower().strip()
76
+ if key in overrides:
77
+ repl, stretch = overrides[key]
78
+ chunks.append((repl, stretch))
79
+ else:
80
+ chunks.append((w, 1.0))
81
+ # Merge adjacent chunks with stretch == 1.0 so we don't call F5-TTS 30 times for one sentence
82
+ merged: list[tuple[str, float]] = []
83
+ for text_part, st in chunks:
84
+ if merged and abs(merged[-1][1] - 1.0) < 1e-6 and abs(st - 1.0) < 1e-6:
85
+ merged[-1] = (merged[-1][0] + text_part, 1.0)
86
+ else:
87
+ merged.append((text_part, st))
88
+ # Generate each chunk
89
+ audios = []
90
+ sr_out = SAMPLE_RATE
91
+ last_gen_text_acc = ""
92
+ for text_part, stretch in merged:
93
+ if not text_part.strip():
94
+ continue
95
+ y, sr, gen_text = engine.generate(
96
+ text_part, voice_id, level, seed=seed, mode=mode,
97
+ custom_voice_path=custom_voice_path,
98
+ custom_voice_text=custom_voice_text,
99
+ )
100
+ sr_out = sr
101
+ if abs(stretch - 1.0) > 0.02:
102
+ try:
103
+ import librosa
104
+ y = librosa.effects.time_stretch(y.astype(np.float32), rate=1.0 / stretch)
105
+ except Exception as e:
106
+ print(f"[chunked] time_stretch failed for '{text_part}': {e}")
107
+ audios.append(y)
108
+ last_gen_text_acc += gen_text + " "
109
+ if not audios:
110
+ return engine.generate(sentence, voice_id, level, seed=seed, mode=mode,
111
+ custom_voice_path=custom_voice_path,
112
+ custom_voice_text=custom_voice_text)
113
+ final = equal_power_concat(audios, sr_out, fade_ms=80)
114
+ return final.astype(np.float32), sr_out, last_gen_text_acc.strip()
115
+
116
+
117
+ def _blend_with_music(vocal: np.ndarray, vocal_sr: int, music_path: str,
118
+ vocal_gain_db: float = 0.0, music_gain_db: float = -8.0,
119
+ tempo_lock: bool = True, autotune: bool = True
120
+ ) -> tuple[np.ndarray, int]:
121
+ """Mix the TTS vocal over an uploaded music track with TEMPO LOCK and AUTOTUNE.
122
+
123
+ Tempo lock:
124
+ - Detect music tempo via librosa.beat.beat_track
125
+ - Detect vocal "tempo" proxy from the speech onset rate
126
+ - Time-stretch the vocal to align (cap at +/-25% to keep formants).
127
+
128
+ Autotune (rough whole-clip pitch shift):
129
+ - Detect music's dominant pitch class via chroma_cqt average
130
+ - Detect vocal median f0 via librosa.yin
131
+ - Compute semitones from vocal_f0 to the nearest octave of the music's root note
132
+ - Pitch-shift the vocal by that many semitones (capped at +/-7 to avoid chipmunk)
133
+
134
+ All local: librosa + numpy. Off-the-Grid stays clean.
135
+ """
136
+ import librosa
137
+ import math
138
+
139
+ music, music_sr = librosa.load(music_path, sr=vocal_sr, mono=True)
140
+
141
+ # --- tempo lock ---
142
+ if tempo_lock and len(music) > vocal_sr * 2:
143
+ try:
144
+ mtempo, _ = librosa.beat.beat_track(y=music, sr=music_sr)
145
+ vtempo, _ = librosa.beat.beat_track(y=vocal, sr=vocal_sr)
146
+ if mtempo and vtempo and abs(np.log2(mtempo / vtempo)) < 1.5:
147
+ ratio = float(np.clip(vtempo / mtempo, 0.78, 1.28))
148
+ if abs(ratio - 1.0) > 0.04:
149
+ vocal = librosa.effects.time_stretch(vocal, rate=ratio)
150
+ except Exception as e:
151
+ print(f"[blend] tempo lock failed: {e}")
152
+
153
+ # --- autotune: detect music root + pitch-shift vocal toward it ---
154
+ if autotune:
155
+ try:
156
+ # 12-bin chroma -> dominant pitch class is the music's key center
157
+ chroma = librosa.feature.chroma_cqt(y=music, sr=music_sr, hop_length=2048)
158
+ key_idx = int(np.argmax(chroma.mean(axis=1))) # 0=C, 1=C#, ..., 11=B
159
+ key_names = ["C","C#","D","D#","E","F","F#","G","G#","A","A#","B"]
160
+ # vocal median f0 via YIN
161
+ f0 = librosa.yin(vocal.astype(np.float32),
162
+ fmin=70, fmax=500, sr=vocal_sr, frame_length=2048)
163
+ f0 = f0[~np.isnan(f0) & (f0 > 0)]
164
+ if len(f0) > 5:
165
+ vocal_f0 = float(np.median(f0))
166
+ # music root note across plausible vocal octaves: 65.4 Hz (C2) .. 523 Hz (C5)
167
+ root_freqs = [
168
+ 27.5 * (2 ** ((key_idx - 9 + 12 * o) / 12.0)) # A0=27.5; key_idx 9 == A
169
+ for o in range(2, 7)
170
+ ]
171
+ # pick the root octave closest in log-space to the vocal's median pitch
172
+ root = min(root_freqs, key=lambda r: abs(math.log2(r / vocal_f0)))
173
+ semitones = round(12 * math.log2(root / vocal_f0))
174
+ semitones = max(-7, min(7, semitones))
175
+ if semitones != 0:
176
+ vocal = librosa.effects.pitch_shift(
177
+ vocal.astype(np.float32), sr=vocal_sr, n_steps=semitones)
178
+ print(f"[blend] autotune: music key={key_names[key_idx]}, "
179
+ f"vocal_f0={vocal_f0:.1f}Hz -> root={root:.1f}Hz, "
180
+ f"shift {semitones} semitones")
181
+ except Exception as e:
182
+ print(f"[blend] autotune failed: {e}")
183
+
184
+ # --- mix ---
185
+ n = max(len(music), len(vocal))
186
+ if len(vocal) < n: vocal = np.pad(vocal, (0, n - len(vocal)))
187
+ if len(music) < n: music = np.pad(music, (0, n - len(music)))
188
+ vocal_gain = 10.0 ** (vocal_gain_db / 20.0)
189
+ music_gain = 10.0 ** (music_gain_db / 20.0)
190
+ out = vocal_gain * vocal + music_gain * music
191
+ peak = float(np.max(np.abs(out)) + 1e-9)
192
+ if peak > 0.98:
193
+ out = out * (0.98 / peak)
194
+ return out.astype(np.float32), vocal_sr
195
+
196
+ VOICE_IDS = list(VOICE_PRESETS.keys())
197
+ DEFAULT_VOICE = VOICE_IDS[0] if VOICE_IDS else "v1"
198
+ DEFAULT_TEXT = "she sells seashells by the seashore"
199
+ # default to the published LoRA repo so the Space loads it without needing env vars;
200
+ # COHERENCE_DIAL_LORA env var overrides for local dev against a checkpoint dir.
201
+ LORA_PATH = os.environ.get("COHERENCE_DIAL_LORA", HF_LORA_REPO)
202
+
203
+ MODE_GHOST = "Ghost"
204
+ MODE_TONGUES = "Tongues"
205
+ MODES = (MODE_GHOST, MODE_TONGUES)
206
+
207
+
208
+ # ----- inference engine (lazy-loaded; falls back to a silent stub if F5-TTS isn't installed) -----
209
+
210
+ class TTSEngine:
211
+ """Dual-mode engine. One F5-TTS instance with the v8 LoRA loaded; mode is selected per
212
+ inference call. For Ghost mode we set_dial(0) (LevelEmbed contributes ~zero) and feed
213
+ mondegreen-substituted text. For Tongues mode we set_dial(level) and feed the clean
214
+ lyric. The base LoRA attention adaptation is always on, but at dial=0 it produces audio
215
+ indistinguishable from F5-TTS base (verified empirically by v5 sweep — lv0 sounded
216
+ identical to base output)."""
217
+
218
+ def __init__(self):
219
+ self._tts = None
220
+ self._asr = None
221
+ self._enc = None
222
+ self._mondegreen = None
223
+ self._lm = None
224
+ self._lora_loaded = False
225
+ self.live = False
226
+
227
+ def _ensure_mondegreen(self):
228
+ """Lazy load the deterministic phonetic-ghost generator + DistilGPT-2 reranker."""
229
+ if self._mondegreen is not None:
230
+ return
231
+ try:
232
+ from scripts.mondegreen import MondegreenIndex, LMReranker
233
+ self._mondegreen = MondegreenIndex("data/cmudict.dict")
234
+ print(f"[engine] Mondegreen index loaded ({self._mondegreen.size} words)")
235
+ self._lm = LMReranker()
236
+ print("[engine] DistilGPT-2 reranker loaded")
237
+ except Exception as e:
238
+ print(f"[engine] mondegreen load FAILED ({e}); Ghost mode falls back to clean text")
239
+ self._mondegreen = False
240
+ self._lm = False
241
+
242
+ def ghost_text(self, sentence: str, level: int, seed: int = 42) -> str:
243
+ """Deterministic Ghost mode substitution. Returns the source if mondegreen unavailable."""
244
+ self._ensure_mondegreen()
245
+ if not self._mondegreen:
246
+ return sentence
247
+ return self._mondegreen.substitute(sentence, level, seed=seed,
248
+ reranker=(self._lm or None))
249
+
250
+ def _ensure(self):
251
+ if self._tts is not None:
252
+ return
253
+ try:
254
+ import patches # noqa: F401 — installs F5TTS.load_lora before instantiation
255
+ from f5_tts.api import F5TTS
256
+ self._tts = F5TTS(model="F5TTS_v1_Base")
257
+ self.live = True
258
+ print(f"[engine] F5-TTS base loaded (model=F5TTS_v1_Base)")
259
+ if LORA_PATH:
260
+ try:
261
+ self._tts.load_lora(LORA_PATH)
262
+ self._lora_loaded = True
263
+ print(f"[engine] LoRA loaded from {LORA_PATH}")
264
+ except Exception as e:
265
+ print(f"[engine] LoRA load FAILED ({e}); falling back to base model — Well-Tuned badge forfeit")
266
+ else:
267
+ print("[engine] no LoRA path configured; running base model only")
268
+ except ImportError:
269
+ print("[engine] f5-tts not installed; running with silent stub for layout testing")
270
+ self.live = False
271
+
272
+ def _ensure_asr(self):
273
+ if self._asr is None:
274
+ try:
275
+ import whisper
276
+ self._asr = whisper.load_model(WHISPER_MODEL)
277
+ except Exception:
278
+ self._asr = False
279
+ return self._asr
280
+
281
+ def _ensure_encoder(self):
282
+ if self._enc is None:
283
+ try:
284
+ from resemblyzer import VoiceEncoder
285
+ self._enc = VoiceEncoder()
286
+ except Exception:
287
+ self._enc = False
288
+ return self._enc
289
+
290
+ def generate(self, sentence: str, voice_id: str, level: int, seed: int = 42,
291
+ mode: str = MODE_TONGUES, custom_voice_path: str | None = None,
292
+ custom_voice_text: str = ""):
293
+ """Returns (audio float32 mono, sample_rate, gen_text_used).
294
+
295
+ mode=MODE_GHOST: substitute lyric via mondegreen at given level, set_dial(0), TTS reads it.
296
+ mode=MODE_TONGUES: leave lyric clean, set_dial(level), LoRA conditions glossolalic audio.
297
+
298
+ custom_voice_path: if a path is supplied, F5-TTS clones this clip instead of the
299
+ preset voice. custom_voice_text is the transcript of that clip (improves clone quality;
300
+ an empty string lets F5-TTS auto-transcribe via Whisper).
301
+ """
302
+ self._ensure()
303
+ if custom_voice_path:
304
+ voice_wav = custom_voice_path
305
+ voice_ref_text = (custom_voice_text or "").strip()
306
+ else:
307
+ voice = VOICE_PRESETS[voice_id]
308
+ voice_wav = voice["wav"]
309
+ ref_txt = Path(voice["ref_text"])
310
+ voice_ref_text = ref_txt.read_text(encoding="utf-8").strip() if ref_txt.exists() else ""
311
+ if mode == MODE_GHOST:
312
+ gen_text = self.ghost_text(sentence, level, seed=seed)
313
+ tts_dial = 0
314
+ else:
315
+ gen_text = sentence
316
+ tts_dial = level
317
+ if not self.live:
318
+ return np.zeros(SAMPLE_RATE * 3, dtype=np.float32), SAMPLE_RATE, gen_text
319
+ if hasattr(self._tts, "set_dial"):
320
+ try:
321
+ self._tts.set_dial(tts_dial)
322
+ except Exception as e:
323
+ print(f"[engine] set_dial({tts_dial}) FAILED ({e}); proceeding without conditioning")
324
+ out = tempfile.NamedTemporaryFile(suffix=".wav", delete=False).name
325
+ self._tts.infer(ref_file=voice_wav, ref_text=voice_ref_text,
326
+ gen_text=gen_text, file_wave=out, seed=seed)
327
+ import soundfile as sf
328
+ y, sr = sf.read(out, always_2d=False)
329
+ if y.ndim == 2:
330
+ y = y.mean(axis=1)
331
+ return y.astype(np.float32), sr, gen_text
332
+
333
+ def transcribe_wer(self, y: np.ndarray, sr: int, ref_text: str) -> float | None:
334
+ asr = self._ensure_asr()
335
+ if not asr:
336
+ return None
337
+ import soundfile as sf, jiwer
338
+ tmp = tempfile.NamedTemporaryFile(suffix=".wav", delete=False).name
339
+ sf.write(tmp, y, sr)
340
+ out = asr.transcribe(tmp, fp16=False, language="en", condition_on_previous_text=False,
341
+ no_speech_threshold=0.8, logprob_threshold=-1.5)
342
+ hyp = (out.get("text") or "").strip()
343
+ if not hyp:
344
+ return 1.0
345
+ return float(min(jiwer.wer(ref_text.lower(), hyp.lower()), 1.0))
346
+
347
+ def voice_cosine(self, y: np.ndarray, sr: int, ref_y: np.ndarray, ref_sr: int) -> float | None:
348
+ enc = self._ensure_encoder()
349
+ if not enc:
350
+ return None
351
+ from resemblyzer import preprocess_wav
352
+ import soundfile as sf
353
+ a = tempfile.NamedTemporaryFile(suffix=".wav", delete=False).name
354
+ b = tempfile.NamedTemporaryFile(suffix=".wav", delete=False).name
355
+ sf.write(a, y, sr); sf.write(b, ref_y, ref_sr)
356
+ ea = enc.embed_utterance(preprocess_wav(a))
357
+ eb = enc.embed_utterance(preprocess_wav(b))
358
+ return float(np.dot(ea, eb) / ((np.linalg.norm(ea) * np.linalg.norm(eb)) + 1e-9))
359
+
360
+
361
+ ENGINE = TTSEngine()
362
+
363
+
364
+ # ----- crossfading for Morph mode -----
365
+
366
+ def equal_power_concat(clips, sr, fade_ms=200):
367
+ if not clips:
368
+ return np.zeros(1, dtype=np.float32)
369
+ fade_n = max(1, int(sr * fade_ms / 1000))
370
+ t = np.linspace(0, 1, fade_n, dtype=np.float32)
371
+ fi = np.sin(t * np.pi / 2.0)
372
+ fo = np.cos(t * np.pi / 2.0)
373
+ out = clips[0].astype(np.float32).copy()
374
+ for c in clips[1:]:
375
+ c = c.astype(np.float32)
376
+ if len(out) < fade_n or len(c) < fade_n:
377
+ out = np.concatenate([out, c]); continue
378
+ head = out[-fade_n:] * fo
379
+ tail = c[:fade_n] * fi
380
+ out = np.concatenate([out[:-fade_n], head + tail, c[fade_n:]])
381
+ return out
382
+
383
+
384
+ def _wav_to_filepath(y: np.ndarray, sr: int) -> str:
385
+ import soundfile as sf
386
+ if y.ndim == 2:
387
+ y = y.T # (channels, samples) -> (samples, channels)
388
+ path = tempfile.NamedTemporaryFile(suffix=".wav", delete=False).name
389
+ sf.write(path, y, sr)
390
+ return path
391
+
392
+
393
+ # ----- readout (live metrics strip) -----
394
+
395
+ def readout(level: int | None = None, wer: float | None = None,
396
+ cosine: float | None = None, status: str = "") -> str:
397
+ cells = [
398
+ ("DIAL", f"{level}" if level is not None else "·"),
399
+ ("WER", f"{wer:.2f}" if wer is not None else "·"),
400
+ ("VOICE-SIM", f"{cosine:.2f}" if cosine is not None else "·"),
401
+ ("STATUS", status or ("live" if ENGINE.live else "stub")),
402
+ ]
403
+ return "<div class='readout'>" + "".join(
404
+ f"<div class='readout-cell'><div class='readout-label'>{k}</div><div class='readout-val'>{v}</div></div>"
405
+ for k, v in cells
406
+ ) + "</div>"
407
+
408
+
409
+ # ----- speak + morph handlers -----
410
+
411
+ def _safe_int(v, lo: int = 0, hi: int = 4) -> int:
412
+ try:
413
+ return max(lo, min(hi, int(float(v))))
414
+ except (TypeError, ValueError):
415
+ return lo
416
+
417
+
418
+ def _render_token_preview(lyric: str, overrides_text: str) -> str:
419
+ """Render the lyric as clickable gold token chips. Edited tokens show their override.
420
+ Python-driven: gets called on every lyric / overrides change so the markup stays in sync."""
421
+ overrides = _parse_per_word_overrides(overrides_text or "")
422
+ if not lyric or not lyric.strip():
423
+ return '<div class="token-row token-empty">type a lyric above; click any word here to hand-tune it</div>'
424
+ parts = re.findall(r"[A-Za-z']+|\s+|[^A-Za-z'\s]+", lyric)
425
+ pieces = []
426
+ for p in parts:
427
+ if not p:
428
+ continue
429
+ if not re.search(r"[A-Za-z]", p):
430
+ # whitespace / punctuation passes through, render \n as <br>
431
+ pieces.append(p.replace("\n", "<br>"))
432
+ continue
433
+ key = p.lower().strip("'")
434
+ if key in overrides:
435
+ repl, stretch = overrides[key]
436
+ label = f"{repl} ({stretch:.2f}x)"
437
+ pieces.append(f'<span class="token edited" data-word="{key}" data-pron="{repl}" '
438
+ f'data-stretch="{stretch:.2f}" title="{label}">{p}</span>')
439
+ else:
440
+ pieces.append(f'<span class="token" data-word="{key}" data-pron="{p}" '
441
+ f'data-stretch="1.00" title="click to hand-tune">{p}</span>')
442
+ return '<div class="token-row">' + "".join(pieces) + '</div>'
443
+
444
+
445
+ def speak(sentence, voice_id, level, postfx_preset, mode, seed,
446
+ custom_voice, custom_voice_text, music_path, music_gain_db, overrides_text):
447
+ sentence = (sentence or "").strip()
448
+ level = _safe_int(level)
449
+ if not sentence:
450
+ return None, readout(level, None, None, "type a sentence first"), ""
451
+ overrides = _parse_per_word_overrides(overrides_text or "")
452
+ y, sr, gen_text = _chunked_generate_with_overrides(
453
+ ENGINE, sentence, voice_id, level, int(seed), mode,
454
+ custom_voice or None, custom_voice_text or "", overrides,
455
+ )
456
+ if postfx_preset != "dry":
457
+ y, _ = apply_post_fx(y, sr, preset=postfx_preset)
458
+ if music_path:
459
+ try:
460
+ y, sr = _blend_with_music(y, sr, music_path,
461
+ vocal_gain_db=0.0,
462
+ music_gain_db=float(music_gain_db),
463
+ tempo_lock=True)
464
+ except Exception as e:
465
+ print(f"[blend] failed: {e}; returning dry vocal")
466
+ path = _wav_to_filepath(y, sr)
467
+ readout_text = gen_text if (mode == MODE_GHOST and gen_text and gen_text != sentence) else ""
468
+ return path, readout(level, None, None, f"{mode.lower()} · lv{level}"), readout_text
469
+
470
+
471
+ def morph(sentence, voice_id, postfx_preset, mode, seed,
472
+ custom_voice, custom_voice_text, music_path, music_gain_db,
473
+ overrides_text, gap_ms: int = 250):
474
+ sentence = (sentence or "").strip()
475
+ if not sentence:
476
+ return None, readout(None, None, None, "type a sentence first"), ""
477
+ overrides = _parse_per_word_overrides(overrides_text or "")
478
+ clips = []
479
+ sr_out = SAMPLE_RATE
480
+ for lv in range(5):
481
+ y, sr, _ = _chunked_generate_with_overrides(
482
+ ENGINE, sentence, voice_id, lv, int(seed), mode,
483
+ custom_voice or None, custom_voice_text or "", overrides,
484
+ )
485
+ sr_out = sr
486
+ clips.append(y)
487
+ morphed = equal_power_concat(clips, sr_out, fade_ms=gap_ms)
488
+ if postfx_preset != "dry":
489
+ morphed, _ = apply_post_fx(morphed, sr_out, preset=postfx_preset)
490
+ if music_path:
491
+ try:
492
+ morphed, sr_out = _blend_with_music(morphed, sr_out, music_path,
493
+ vocal_gain_db=0.0,
494
+ music_gain_db=float(music_gain_db),
495
+ tempo_lock=True)
496
+ except Exception as e:
497
+ print(f"[blend] failed: {e}; returning dry vocal")
498
+ path = _wav_to_filepath(morphed, sr_out)
499
+ return path, readout(None, None, None, f"{mode.lower()} · morphed 0->4"), ""
500
+
501
+
502
+ # ----- CSS (dreamy pastel theme: half-remembered photograph of dusk) -----
503
+
504
+ CUSTOM_CSS = """
505
+ /* HEAVEN OR LAS VEGAS — direct reference. Long-exposure Christmas lights, deep midnight
506
+ violet background, hot red-orange sun in the lower-right hemisphere, gold light trails
507
+ swooping diagonally. Title in hand-drawn flowing italic script (Pinyon Script ≈ the
508
+ Vaughan Oliver "Heaven or Las Vegas" lettering). Photographic, luminous, analog. */
509
+
510
+ :root {
511
+ --night: #1A0F2D;
512
+ --night-deep: #0E0820;
513
+ --violet: #3D1E54;
514
+ --violet-glow: #5A2F75;
515
+ --sun-core: #FF4D2C;
516
+ --sun-mid: #FF7A3D;
517
+ --sun-halo: rgba(255, 120, 60, 0.55);
518
+ --gold: #FFD66B; /* brighter, more saturated for higher contrast on violet AND on the sun side */
519
+ --gold-bright: #FFE9A3;
520
+ --gold-glow: rgba(255, 214, 107, 0.6);
521
+ --cream: #FFEFC9;
522
+ --cream-mute: #E8D9A0;
523
+ --ink-light: #FFE9B8;
524
+ --ink-mute: #BFAD78;
525
+ --hairline: rgba(255, 214, 107, 0.32);
526
+ }
527
+
528
+ /* BASE: NO opaque ancestor backgrounds (gradio-app + body default to opaque dark and
529
+ would paint over a background layer), and NO background-attachment:fixed (jank on
530
+ scroll, ignored on iOS Safari). The dark base + sun live on the injected #bg-sun
531
+ layer only. Verified: no ancestor sets transform/filter/will-change/contain, so a
532
+ position:fixed child of <body> anchors to the viewport and is never clipped. */
533
+ html, body, gradio-app, .gradio-container, .dark, .light {
534
+ background: transparent !important;
535
+ color: var(--ink-light) !important;
536
+ font-family: 'Cormorant Garamond', Georgia, serif !important;
537
+ min-height: 100vh;
538
+ }
539
+ /* Solid dark on <body> so first paint (before the JS mounts #bg-sun) shows no flash. */
540
+ body { background: var(--night) !important; }
541
+
542
+ /* THE FIXED SUN LAYER: a real <div id="bg-sun"> prepended to <body> by demo.load(js).
543
+ position:fixed + inset:0 => pinned to the viewport, never scrolls, never clipped.
544
+ The sun is a DEFINED ~560px circular ball tucked into the bottom-right corner
545
+ (a `circle 280px` radial-gradient = 560px diameter), centered ~150px past the corner
546
+ so most of the ball is visible like a little sun, fading cleanly to the dark base.
547
+ NO giant viewport-wide wash. translateZ(0) = own GPU layer, no repaint on scroll. */
548
+ #bg-sun {
549
+ position: fixed;
550
+ inset: 0;
551
+ z-index: 0;
552
+ pointer-events: none;
553
+ background:
554
+ radial-gradient(circle 420px at calc(100% - 160px) calc(100% - 160px),
555
+ #FFE7B0 0%,
556
+ #FFB070 14%,
557
+ #FF7A3D 30%,
558
+ var(--sun-core) 46%,
559
+ #C7311A 64%,
560
+ rgba(107, 24, 8, 0.45) 82%,
561
+ transparent 100%),
562
+ var(--night);
563
+ transform: translateZ(0);
564
+ }
565
+
566
+ /* CONTENT sits above the fixed sun layer. */
567
+ .gradio-container {
568
+ background: transparent !important;
569
+ position: relative;
570
+ z-index: 1;
571
+ }
572
+
573
+ /* SUN-OVERLAP READABILITY: any gold/cream text gets a dark halo so it pops against
574
+ the bright orange/red of the sun, and a subtle stroke for hard edges. Reads as
575
+ "lit from behind" on dark bg, as "outlined" on bright bg. Plus on the right-half
576
+ accordion labels (which directly overlap the sun corner), apply mix-blend-mode so
577
+ the text auto-inverts as it crosses the sun. */
578
+ .gradio-container button.lg,
579
+ .gradio-container button.primary,
580
+ .gradio-container [data-testid="block-info"],
581
+ .gradio-container input[role="listbox"],
582
+ .gradio-container label.svelte-19qdtil span.svelte-19qdtil,
583
+ .gradio-container .label-wrap,
584
+ .gradio-container .label-wrap span,
585
+ .gradio-container summary,
586
+ .gradio-container [class^="label"],
587
+ #token-preview .token,
588
+ .knob-ticks span {
589
+ text-shadow:
590
+ 0 0 1px rgba(0, 0, 0, 0.95),
591
+ 0 0 4px rgba(0, 0, 0, 0.85),
592
+ 0 0 14px rgba(0, 0, 0, 0.65) !important;
593
+ }
594
+ /* Sun-overlap masking restored: dark semi-opaque pills on labels so the bright sun
595
+ doesn't bleed through into text. Text stays gold; pill blocks the sun behind. */
596
+ .gradio-container .label-wrap,
597
+ .gradio-container summary,
598
+ .gradio-container [data-testid="block-info"] {
599
+ background: rgba(14, 8, 32, 0.78) !important;
600
+ backdrop-filter: blur(6px);
601
+ -webkit-backdrop-filter: blur(6px);
602
+ border: 1px solid rgba(255, 214, 107, 0.22) !important;
603
+ border-radius: 999px !important;
604
+ padding: 10px 22px !important;
605
+ margin: 4px 0 !important;
606
+ }
607
+ .gradio-container [data-testid="block-info"] {
608
+ display: inline-block !important;
609
+ padding: 3px 12px !important;
610
+ font-size: 12px !important;
611
+ margin-bottom: 6px !important;
612
+ }
613
+
614
+ /* (Light trails + film grain layers removed. Flat dark bg + viewport-fixed sun only.) */
615
+
616
+ .gradio-container { max-width: 1040px !important; margin: 0 auto !important; padding: 72px 48px 120px !important; position: relative; z-index: 1; }
617
+
618
+ /* -------------------- HERO -------------------- */
619
+
620
+ #hero { text-align: center; margin-bottom: 72px; position: relative; padding-top: 24px; }
621
+ #hero .wordmark-rule {
622
+ display: inline-block;
623
+ border: 1px solid var(--gold);
624
+ padding: 4px 12px 5px;
625
+ font-family: 'IBM Plex Mono', monospace; font-weight: 400; font-size: 10px;
626
+ color: var(--gold); letter-spacing: 0.36em; text-transform: uppercase;
627
+ margin-bottom: 38px;
628
+ background: rgba(14, 8, 32, 0.55);
629
+ }
630
+ #hero h1 {
631
+ font-family: 'Pinyon Script', cursive;
632
+ font-weight: 400; font-size: 220px;
633
+ letter-spacing: -0.012em; line-height: 0.74; margin: 0;
634
+ color: var(--gold-bright);
635
+ text-shadow:
636
+ 0 0 22px rgba(245, 197, 107, 0.62),
637
+ 0 0 70px rgba(245, 197, 107, 0.32),
638
+ 0 0 130px rgba(245, 197, 107, 0.18),
639
+ 0 3px 0 rgba(0, 0, 0, 0.45);
640
+ text-transform: lowercase;
641
+ transform: rotate(-3deg) translateX(-12px);
642
+ display: inline-block;
643
+ }
644
+ #hero .tagline {
645
+ font-family: 'Cormorant Garamond', serif; font-style: italic; font-weight: 300; font-size: 19px;
646
+ color: var(--cream); margin-top: 28px; max-width: 480px;
647
+ margin-left: auto; margin-right: auto;
648
+ line-height: 1.65;
649
+ text-shadow: 0 1px 8px rgba(0, 0, 0, 0.6);
650
+ }
651
+
652
+ /* -------------------- COMPONENTS -------------------- */
653
+
654
+ label, .gr-form > label, span[data-testid="block-info"], .label-wrap, label > span,
655
+ [data-testid="block-label"] {
656
+ color: var(--gold) !important;
657
+ font-family: 'IBM Plex Mono', monospace !important;
658
+ font-style: normal !important;
659
+ font-weight: 400 !important;
660
+ font-size: 10px !important;
661
+ letter-spacing: 0.3em !important;
662
+ text-transform: uppercase !important;
663
+ opacity: 0.85 !important;
664
+ }
665
+
666
+ /* strip the default block chrome from EVERY gradio container so the page reads
667
+ as light + type + air, not stacked UI cards.
668
+ Aggressive: nuke any "block" class background + border + radius. */
669
+ .block, .block-container, .form, .gradio-container > div > div,
670
+ [data-testid="block"], .gr-form, .gr-box,
671
+ .svelte-vt1mxs, .svelte-1ipelgc, .gr-padded,
672
+ [class*=" block"], [class^="block"],
673
+ .svelte-633qhp, .svelte-1plpy97, .svelte-1mwvhlq,
674
+ .gradio-container [class*="container"],
675
+ .gradio-container [class*="form"] {
676
+ background: transparent !important;
677
+ border: none !important;
678
+ box-shadow: none !important;
679
+ border-radius: 0 !important;
680
+ }
681
+ /* leave the slider container with vertical padding so the slider track is visible */
682
+ #dial-slider { padding: 18px 0 24px !important; }
683
+ /* keep accordion content padded so the audio drop boxes don't collapse */
684
+ [data-testid="accordion"] > div:nth-child(2),
685
+ .gradio-container [class*="accordion"] > div:not(:first-child) {
686
+ padding: 16px 0 8px !important;
687
+ }
688
+
689
+ /* Rows: consistent gaps, vertical alignment */
690
+ .gradio-container .gr-row, .gradio-container [class*="row"] {
691
+ gap: 28px !important;
692
+ align-items: end !important;
693
+ }
694
+
695
+ /* lyric textarea — handwriting on light, no card, just a hot gold underline */
696
+ textarea {
697
+ background: transparent !important;
698
+ border: none !important;
699
+ border-bottom: 1px solid rgba(245, 197, 107, 0.32) !important;
700
+ border-radius: 0 !important;
701
+ color: var(--gold-bright) !important;
702
+ font-family: 'Cormorant Garamond', serif !important;
703
+ font-style: italic !important;
704
+ font-weight: 300 !important;
705
+ font-size: 32px !important;
706
+ line-height: 1.32 !important;
707
+ padding: 14px 4px 18px !important;
708
+ box-shadow: none !important;
709
+ resize: none !important;
710
+ text-shadow: 0 0 14px rgba(245, 197, 107, 0.32);
711
+ }
712
+ textarea::placeholder { color: var(--cream-mute) !important; opacity: 0.45 !important; font-style: italic !important; }
713
+ textarea:focus { border-bottom-color: var(--gold-bright) !important; outline: none !important; box-shadow: none !important; }
714
+
715
+ /* dropdowns + number inputs — minimal, gold underline only.
716
+ Need to nuke svelte's wrapper containers AND the inner input.
717
+ DO NOT collapse min-height on the slider's .wrap — it would hide the track. */
718
+ input, select, .gr-input, .gr-dropdown, [role="listbox"],
719
+ .secondary-wrap, .container,
720
+ [data-testid="dropdown"], [data-testid="number"] > div {
721
+ background: transparent !important;
722
+ backdrop-filter: none !important;
723
+ border: none !important;
724
+ color: var(--cream) !important;
725
+ border-radius: 0 !important;
726
+ font-family: 'Cormorant Garamond', serif !important;
727
+ font-size: 19px !important;
728
+ box-shadow: none !important;
729
+ }
730
+ /* Slider's own .wrap needs to keep its layout intact */
731
+ #dial-slider .wrap.svelte-8epfm4 { background: transparent !important; }
732
+ /* head row (label + number input + reset button) */
733
+ #dial-slider .head.svelte-8epfm4 { display: none !important; }
734
+ /* min/max value labels flanking the track */
735
+ #dial-slider .min_value, #dial-slider .max_value {
736
+ font-family: 'Cormorant Garamond', serif; font-style: italic; font-size: 18px;
737
+ color: var(--cream-mute); padding: 0 14px; align-self: center;
738
+ }
739
+ #dial-slider .slider_input_container.svelte-8epfm4 {
740
+ display: flex !important; align-items: center !important; gap: 0 !important;
741
+ padding: 12px 0 !important;
742
+ }
743
+ input, select {
744
+ border-bottom: 1px solid rgba(245, 197, 107, 0.32) !important;
745
+ font-style: italic !important;
746
+ color: var(--gold-bright) !important;
747
+ }
748
+ input[type="text"], input[type="number"], select { padding: 8px 4px !important; }
749
+ input:focus, select:focus {
750
+ border-bottom-color: var(--gold-bright) !important; outline: none !important;
751
+ box-shadow: none !important;
752
+ }
753
+ /* dropdown trigger button (the visible selection) */
754
+ [data-testid="dropdown"] input, .gr-dropdown input {
755
+ background: transparent !important;
756
+ color: var(--gold-bright) !important;
757
+ }
758
+
759
+ /* Dropdown popup panel: dark solid background on the entire popup container plus
760
+ each option, so neither the wrapper gaps nor the options show the page through. */
761
+ ul[role="listbox"],
762
+ [role="listbox"]:not(input),
763
+ .options,
764
+ .options ul,
765
+ .choices,
766
+ ul.choices,
767
+ .svelte-1xfsv4t .options,
768
+ .svelte-1xfsv4t .options ul {
769
+ background: rgba(14, 8, 32, 0.98) !important;
770
+ border: 1px solid var(--gold) !important;
771
+ border-radius: 12px !important;
772
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6) !important;
773
+ padding: 6px 0 !important;
774
+ z-index: 100 !important;
775
+ }
776
+ ul[role="listbox"] [role="option"],
777
+ [role="option"],
778
+ .options ul li,
779
+ .choices li {
780
+ background: transparent !important; /* the parent's dark covers everything */
781
+ color: var(--cream) !important;
782
+ font-family: 'Cormorant Garamond', serif !important;
783
+ font-style: italic !important;
784
+ font-size: 16px !important;
785
+ padding: 8px 18px !important;
786
+ border-radius: 0 !important;
787
+ }
788
+ ul[role="listbox"] [role="option"]:hover,
789
+ [role="option"]:hover,
790
+ .options ul li:hover,
791
+ .choices li:hover {
792
+ background: rgba(245, 197, 107, 0.18) !important;
793
+ color: var(--gold-bright) !important;
794
+ }
795
+
796
+ /* mode radio — luminous gold tabs.
797
+ The radio group's outer wrap on its own variant of the svelte class */
798
+ .wrap.svelte-1mwvhlq, [role="radiogroup"] {
799
+ display: inline-flex !important; gap: 0 !important;
800
+ border: 1px solid var(--gold) !important;
801
+ background: transparent !important;
802
+ border-radius: 999px !important;
803
+ padding: 3px !important;
804
+ overflow: hidden;
805
+ }
806
+
807
+ /* kill the boxy gray container around the voice + post-fx dropdowns AND seed number */
808
+ .gradio-container [data-testid="dropdown"],
809
+ .gradio-container [data-testid="number"],
810
+ .gradio-container .form,
811
+ [class^="block"], [class*=" block"] {
812
+ background: transparent !important;
813
+ border: none !important;
814
+ box-shadow: none !important;
815
+ }
816
+ /* Gradio v6 dropdown internals: the .wrap holds the white default; override the whole
817
+ stack down to the bare <input role="listbox">. */
818
+ .gradio-container .wrap.svelte-1xfsv4t,
819
+ .gradio-container .wrap-inner.svelte-1xfsv4t,
820
+ .gradio-container .secondary-wrap.svelte-1xfsv4t {
821
+ background: rgba(14, 8, 32, 0.92) !important;
822
+ border: 1px solid var(--gold) !important;
823
+ border-radius: 999px !important;
824
+ box-shadow: 0 0 18px rgba(255, 214, 107, 0.12) !important;
825
+ }
826
+ .gradio-container input[role="listbox"],
827
+ .gradio-container input.border-none.svelte-1xfsv4t {
828
+ background: transparent !important;
829
+ color: var(--gold-bright) !important;
830
+ font-family: 'Cormorant Garamond', serif !important;
831
+ font-style: italic !important;
832
+ font-size: 17px !important;
833
+ padding: 8px 18px !important;
834
+ border: none !important;
835
+ }
836
+ .gradio-container input[role="listbox"]::placeholder { color: var(--cream-mute) !important; opacity: 1 !important; }
837
+ .gradio-container .dropdown-arrow.svelte-loyhyk { fill: var(--gold-bright) !important; }
838
+
839
+ /* Mode radio (Ghost / Tongues) — Gradio v6 svelte-19qdtil */
840
+ .gradio-container label.svelte-19qdtil {
841
+ background: rgba(14, 8, 32, 0.7) !important;
842
+ border: 1px solid var(--gold) !important;
843
+ border-radius: 999px !important;
844
+ padding: 8px 22px !important;
845
+ margin: 0 6px 0 0 !important;
846
+ cursor: pointer !important;
847
+ transition: all 0.22s !important;
848
+ }
849
+ .gradio-container label.svelte-19qdtil span.svelte-19qdtil {
850
+ color: var(--gold-bright) !important;
851
+ font-family: 'Cormorant Garamond', serif !important;
852
+ font-style: italic !important;
853
+ font-weight: 500 !important;
854
+ font-size: 16px !important;
855
+ }
856
+ .gradio-container label.svelte-19qdtil:has(input:checked) {
857
+ background: linear-gradient(180deg, var(--sun-core), var(--sun-mid)) !important;
858
+ border-color: var(--sun-core) !important;
859
+ box-shadow: 0 0 22px var(--sun-halo) !important;
860
+ }
861
+ .gradio-container label.svelte-19qdtil:has(input:checked) span.svelte-19qdtil {
862
+ color: var(--night-deep) !important;
863
+ }
864
+ /* The native radio dot — hide it; we use the chip-style instead */
865
+ .gradio-container input[type="radio"].svelte-19qdtil { display: none !important; }
866
+ [role="radio"][aria-checked="true"] label,
867
+ input[type="radio"]:checked + label {
868
+ background: linear-gradient(180deg, var(--sun-core), var(--sun-mid)) !important;
869
+ color: var(--night-deep) !important;
870
+ box-shadow: 0 0 22px var(--sun-halo), inset 0 1px 0 rgba(255, 220, 180, 0.5) !important;
871
+ }
872
+
873
+ /* buttons — glowing pill on dark, gold borders */
874
+ button.primary, button[variant="primary"], .primary > button, button.lg, .gr-button {
875
+ background: rgba(14, 8, 32, 0.92) !important; /* opaque enough to read over the sun */
876
+ color: var(--gold-bright) !important;
877
+ border: 2px solid var(--gold) !important; /* thicker for visibility */
878
+ padding: 14px 34px !important;
879
+ font-family: 'Cormorant Garamond', serif !important;
880
+ font-style: italic !important;
881
+ font-weight: 500 !important; /* heavier weight */
882
+ font-size: 18px !important;
883
+ letter-spacing: 0.02em !important;
884
+ border-radius: 999px !important;
885
+ box-shadow: 0 0 28px rgba(255, 214, 107, 0.32), inset 0 0 14px rgba(255, 214, 107, 0.12) !important;
886
+ transition: all 0.24s !important;
887
+ text-shadow: 0 0 6px rgba(255, 214, 107, 0.55);
888
+ }
889
+ button.primary:hover, button[variant="primary"]:hover, .gr-button:hover {
890
+ background: var(--gold-bright) !important;
891
+ color: var(--night-deep) !important;
892
+ text-shadow: none;
893
+ box-shadow: 0 0 36px var(--gold-glow), inset 0 0 18px rgba(255, 219, 138, 0.4) !important;
894
+ }
895
+ /* second action — Morph — in the hot vermillion / red-orange sun palette */
896
+ .action-row button:nth-of-type(2) {
897
+ border-color: var(--sun-mid) !important;
898
+ color: var(--sun-mid) !important;
899
+ text-shadow: 0 0 10px rgba(255, 122, 61, 0.4);
900
+ box-shadow: 0 0 24px rgba(255, 122, 61, 0.22) !important;
901
+ }
902
+ .action-row button:nth-of-type(2):hover {
903
+ background: linear-gradient(180deg, var(--sun-core), var(--sun-mid)) !important;
904
+ color: var(--night-deep) !important;
905
+ text-shadow: none;
906
+ box-shadow: 0 0 40px var(--sun-halo) !important;
907
+ }
908
+
909
+ /* ALL AUDIO WIDGETS (output "the take" + the voice-clone / backing-track upload
910
+ inputs). Gradio renders these white by default, which clashes with the dark theme
911
+ and reads as a "broken white player". Force the whole stack dark + gold. */
912
+ .gradio-container [data-testid="audio"],
913
+ #audio-out {
914
+ background: rgba(14, 8, 32, 0.88) !important;
915
+ border: 1px solid var(--gold) !important;
916
+ border-radius: 14px !important;
917
+ padding: 14px 16px !important;
918
+ box-shadow: 0 0 20px rgba(255, 214, 107, 0.14) !important;
919
+ margin: 10px 0 !important;
920
+ }
921
+ /* The white "Drop Audio Here / Click to Upload" drop zone inside audio inputs. */
922
+ .gradio-container [data-testid="audio"] .wrap,
923
+ .gradio-container [data-testid="audio"] .upload-container,
924
+ .gradio-container [data-testid="audio"] .file-upload,
925
+ .gradio-container [data-testid="audio"] [class*="upload"],
926
+ .gradio-container [data-testid="audio"] > div {
927
+ background: transparent !important;
928
+ color: var(--cream) !important;
929
+ border-color: var(--hairline) !important;
930
+ }
931
+ /* The white block-label tab (e.g. "the take") that floats top-left. */
932
+ .gradio-container [data-testid="audio"] [data-testid="block-label"],
933
+ .gradio-container [data-testid="block-label"],
934
+ .gradio-container label.svelte-19djge9 {
935
+ background: rgba(14, 8, 32, 0.92) !important;
936
+ color: var(--gold) !important;
937
+ border: 1px solid var(--hairline) !important;
938
+ border-radius: 8px !important;
939
+ }
940
+ .gradio-container [data-testid="block-label"] svg,
941
+ .gradio-container [data-testid="block-label"] span { color: var(--gold) !important; fill: var(--gold) !important; }
942
+ .gradio-container [data-testid="audio"] audio {
943
+ width: 100% !important; height: 46px !important; display: block !important;
944
+ }
945
+ /* The native HTML5 audio control bar: tint it to fit the dark theme. */
946
+ .gradio-container [data-testid="audio"] audio::-webkit-media-controls-panel {
947
+ background: rgba(20, 12, 40, 0.9) !important;
948
+ }
949
+
950
+ /* LIVE PREVIEW TEXTBOX (#ghost-lyric): big, gold-on-dark, italic — it's the
951
+ "see what the voice will say" surface and must be prominent. */
952
+ #ghost-lyric, #ghost-lyric > div, #ghost-lyric .wrap, #ghost-lyric > label > div {
953
+ background: rgba(14, 8, 32, 0.92) !important;
954
+ border: 1px solid var(--gold) !important;
955
+ border-radius: 14px !important;
956
+ }
957
+ #ghost-lyric textarea, #ghost-lyric input {
958
+ background: transparent !important;
959
+ color: var(--gold-bright) !important;
960
+ font-family: 'Cormorant Garamond', serif !important;
961
+ font-style: italic !important;
962
+ font-size: 22px !important;
963
+ line-height: 1.4 !important;
964
+ text-align: center !important;
965
+ border: none !important;
966
+ padding: 18px 22px !important;
967
+ min-height: 80px !important;
968
+ }
969
+
970
+ /* THE DIAL — brass knob with vermillion arc and a pointer needle.
971
+ Tick numbers sit in a semicircle above the knob: 0 on the left, 4 on the right. */
972
+ #dial-stack {
973
+ display: flex; flex-direction: column; align-items: center;
974
+ margin: 32px 0 8px;
975
+ }
976
+ .dial-tag {
977
+ font-family: 'IBM Plex Mono', monospace; font-size: 10px;
978
+ color: var(--gold); letter-spacing: 0.34em; text-transform: uppercase;
979
+ opacity: 0.8; margin-bottom: 12px; /* gap below the tag; ticks ride above the knob on their own */
980
+ }
981
+ .knob-stage {
982
+ position: relative;
983
+ width: 420px; height: 400px; /* big enough to hold ticks at radius 156 above and around the knob */
984
+ display: flex; align-items: flex-end; justify-content: center;
985
+ }
986
+ /* Tick semicircle hugging the brass ring of the knob.
987
+ Positioned with its origin AT the knob center, so the JS polar coords are direct. */
988
+ .knob-ticks {
989
+ position: absolute;
990
+ left: 50%;
991
+ bottom: 156px; /* knob-wrap margin-bottom (16) + knob radius (140) = knob center */
992
+ width: 0; height: 0; pointer-events: none;
993
+ }
994
+ .knob-ticks span {
995
+ position: absolute;
996
+ left: 0; top: 0;
997
+ transform: translate(calc(var(--tx) - 50%), calc(var(--ty) - 50%));
998
+ display: flex; flex-direction: column; align-items: center;
999
+ font-family: 'Cormorant Garamond', serif; font-style: italic;
1000
+ font-size: 24px; color: var(--cream-mute);
1001
+ cursor: pointer; pointer-events: auto;
1002
+ transition: color 0.22s, transform 0.22s;
1003
+ line-height: 1;
1004
+ }
1005
+ .knob-ticks span small {
1006
+ font-family: 'IBM Plex Mono', monospace; font-style: normal;
1007
+ font-size: 8px; color: var(--cream-mute); opacity: 0.55;
1008
+ letter-spacing: 0.22em; text-transform: uppercase;
1009
+ margin-top: 4px; max-width: 80px; text-align: center;
1010
+ }
1011
+ .knob-ticks span.active {
1012
+ color: var(--gold-bright); font-weight: 500; font-style: normal;
1013
+ text-shadow: 0 0 12px var(--gold-glow);
1014
+ transform: translate(calc(var(--tx) - 50%), calc(var(--ty) - 50%)) scale(1.22);
1015
+ }
1016
+ .knob-ticks span:hover { color: var(--gold-bright); }
1017
+
1018
+ /* Old-tech knob: knurled outer ring + flat dark face + thin cream indicator line.
1019
+ Inspired by 1970s hi-fi tuner knobs (Marantz, Moog, Bakelite). */
1020
+ .knob-wrap {
1021
+ position: relative;
1022
+ width: 280px; height: 280px;
1023
+ display: flex; align-items: center; justify-content: center;
1024
+ margin-bottom: 16px;
1025
+ filter: drop-shadow(0 14px 28px rgba(0,0,0,0.65));
1026
+ }
1027
+ .knob {
1028
+ position: relative; z-index: 2;
1029
+ width: 280px; height: 280px; border-radius: 50%;
1030
+ cursor: grab; outline: none;
1031
+ /* The flat face: warm walnut / dark Bakelite */
1032
+ background:
1033
+ radial-gradient(circle at 38% 32%,
1034
+ rgba(255, 220, 175, 0.18) 0%,
1035
+ rgba(255, 220, 175, 0.0) 28%),
1036
+ radial-gradient(circle at 50% 50%,
1037
+ #2C1B0E 0%, #1B0F08 70%, #100804 100%);
1038
+ box-shadow:
1039
+ inset 0 0 0 1px rgba(255, 220, 180, 0.1),
1040
+ inset 0 -6px 12px rgba(0,0,0,0.55),
1041
+ inset 0 4px 10px rgba(255, 220, 180, 0.08);
1042
+ touch-action: none; user-select: none;
1043
+ transition: filter 0.22s;
1044
+ }
1045
+ /* Knurled rim — fine repeating ridges in brass */
1046
+ .knob::before {
1047
+ content: ''; position: absolute; inset: 0; border-radius: 50%; pointer-events: none;
1048
+ background: repeating-conic-gradient(
1049
+ from 0deg,
1050
+ #B89968 0deg 2deg,
1051
+ #4A3618 2deg 4deg,
1052
+ #8B6E3A 4deg 6deg
1053
+ );
1054
+ -webkit-mask: radial-gradient(circle, transparent 102px, #000 103px, #000 136px, transparent 137px);
1055
+ mask: radial-gradient(circle, transparent 102px, #000 103px, #000 136px, transparent 137px);
1056
+ filter: brightness(0.92);
1057
+ }
1058
+ /* Subtle inner highlight ring between knurled rim and flat face */
1059
+ .knob::after {
1060
+ content: ''; position: absolute; inset: 12px; border-radius: 50%; pointer-events: none;
1061
+ box-shadow:
1062
+ inset 0 0 0 1px rgba(255, 220, 180, 0.18),
1063
+ inset 0 0 18px rgba(0,0,0,0.45);
1064
+ }
1065
+ .knob:focus-visible, .knob.dragging {
1066
+ filter: brightness(1.04);
1067
+ }
1068
+ .knob.dragging { cursor: grabbing; }
1069
+ /* Faint gold arc OUTSIDE the knurled rim showing dial position.
1070
+ Knob radius 140, rim outer ~136, so the arc sits at radius 144..150. The element
1071
+ extends to inset:-16px so the mask circle fits. */
1072
+ .knob-arc {
1073
+ position: absolute; z-index: 1; inset: -16px; border-radius: 50%; pointer-events: none;
1074
+ background: conic-gradient(from 270deg,
1075
+ var(--gold) 0deg,
1076
+ var(--gold-bright) var(--arc-deg, 0deg),
1077
+ transparent var(--arc-deg, 0deg) 180deg,
1078
+ transparent 360deg);
1079
+ -webkit-mask: radial-gradient(circle, transparent 144px, #000 145px, #000 152px, transparent 153px);
1080
+ mask: radial-gradient(circle, transparent 144px, #000 145px, #000 152px, transparent 153px);
1081
+ filter: drop-shadow(0 0 8px var(--gold-glow));
1082
+ opacity: 0.9;
1083
+ }
1084
+ /* THE INDICATOR LINE — a thin cream line painted on the knob face, from center
1085
+ to the edge of the knurled rim. Rotates with the knob value. */
1086
+ .knob-pointer {
1087
+ position: absolute; z-index: 4;
1088
+ left: 50%; top: 18px;
1089
+ width: 5px; height: 96px;
1090
+ margin-left: -2.5px;
1091
+ background: linear-gradient(180deg, #FFF6D8 0%, #EFDBA8 80%, rgba(239, 219, 168, 0.0) 100%);
1092
+ border-radius: 2px;
1093
+ box-shadow:
1094
+ 0 0 8px rgba(255, 240, 200, 0.7),
1095
+ 0 0 18px rgba(255, 240, 200, 0.35),
1096
+ inset 0 1px 0 rgba(255, 255, 240, 0.85);
1097
+ transform-origin: 50% 122px; /* pivot at the knob center (140 - 18) */
1098
+ transform: rotate(var(--knob-angle, -90deg));
1099
+ pointer-events: none;
1100
+ transition: transform 0.24s cubic-bezier(.34,1.36,.4,1);
1101
+ }
1102
+ .knob-pin { display: none; }
1103
+
1104
+ /* Hide the actual gradio slider — the knob drives it via JS */
1105
+ #dial-slider { display: none !important; }
1106
+ #dial-slider .head, #dial-slider .slider_input_container { display: none !important; }
1107
+
1108
+ /* readout — newspaper-strip-like, gold rule */
1109
+ .readout { display: grid; grid-template-columns: repeat(4, 1fr); gap: 0; margin-top: 24px;
1110
+ border-top: 1px solid var(--hairline); border-bottom: 1px solid var(--hairline); padding: 16px 0; }
1111
+ .readout-cell { background: transparent; padding: 4px 14px; text-align: left; border-right: 1px solid var(--hairline); }
1112
+ .readout-cell:last-child { border-right: none; }
1113
+ .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; }
1114
+ .readout-val { font-family: 'Cormorant Garamond', serif; font-style: italic; font-size: 22px; font-weight: 400; color: var(--cream); letter-spacing: -0.01em; }
1115
+
1116
+ /* ghost lyric — luminous pulled-quote in gold */
1117
+ .ghost-lyric textarea, [data-testid="textbox"]:not(:first-of-type) textarea {
1118
+ font-family: 'Cormorant Garamond', serif !important;
1119
+ font-style: italic !important;
1120
+ font-size: 22px !important;
1121
+ color: var(--gold-bright) !important;
1122
+ border: none !important;
1123
+ border-left: 2px solid var(--gold) !important;
1124
+ background: rgba(245, 197, 107, 0.04) !important;
1125
+ padding: 14px 0 14px 22px !important;
1126
+ border-radius: 0 !important;
1127
+ text-shadow: 0 0 10px rgba(245, 197, 107, 0.3);
1128
+ }
1129
+
1130
+ #footer {
1131
+ margin-top: 88px; padding-top: 28px;
1132
+ border-top: 1px solid var(--hairline);
1133
+ font-family: 'IBM Plex Mono', monospace;
1134
+ font-size: 10px; color: var(--cream-mute); letter-spacing: 0.26em;
1135
+ text-transform: uppercase;
1136
+ line-height: 1.9;
1137
+ display: grid; grid-template-columns: 1fr auto; gap: 24px;
1138
+ opacity: 0.7;
1139
+ }
1140
+
1141
+ /* per-word token chips + inline editor */
1142
+ #token-preview { margin-top: 6px; min-height: 40px; }
1143
+ .token-row {
1144
+ line-height: 2.2; font-family: 'Cormorant Garamond', serif; font-style: italic;
1145
+ font-size: 22px; color: var(--cream);
1146
+ }
1147
+ .token {
1148
+ cursor: pointer; padding: 2px 4px; border-radius: 4px;
1149
+ border-bottom: 1px dashed rgba(245, 197, 107, 0.32);
1150
+ transition: background 0.18s, color 0.18s;
1151
+ }
1152
+ .token:hover { background: rgba(245, 197, 107, 0.14); color: var(--gold-bright); }
1153
+ .token.edited {
1154
+ color: var(--gold-bright);
1155
+ background: rgba(255, 80, 40, 0.18);
1156
+ border-bottom: 1px solid var(--vermillion);
1157
+ text-shadow: 0 0 12px var(--gold-glow);
1158
+ }
1159
+ .token-editor {
1160
+ display: inline-block; vertical-align: middle;
1161
+ background: rgba(14, 8, 32, 0.95); border: 1px solid var(--gold);
1162
+ border-radius: 8px; padding: 12px 14px; margin: 6px 8px;
1163
+ box-shadow: 0 8px 28px rgba(0,0,0,0.5), 0 0 0 2px rgba(245, 197, 107, 0.12);
1164
+ color: var(--cream);
1165
+ font-family: 'Cormorant Garamond', serif; font-style: italic; font-size: 15px;
1166
+ z-index: 12; position: relative;
1167
+ }
1168
+ .token-editor .te-row { display: flex; align-items: center; gap: 10px; margin: 6px 0; }
1169
+ .token-editor .te-head { font-style: italic; color: var(--gold); border-bottom: 1px solid var(--hairline); padding-bottom: 6px; margin-bottom: 8px; }
1170
+ .token-editor .te-head b { color: var(--gold-bright); font-weight: 500; }
1171
+ .token-editor input.te-pron {
1172
+ background: transparent !important; border: none !important;
1173
+ border-bottom: 1px solid rgba(245, 197, 107, 0.4) !important;
1174
+ color: var(--gold-bright) !important; font-style: italic !important;
1175
+ font-size: 16px !important; padding: 4px 6px !important; min-width: 130px;
1176
+ }
1177
+ .token-editor input.te-stretch {
1178
+ -webkit-appearance: none; appearance: none;
1179
+ width: 150px; height: 3px; background: rgba(245, 197, 107, 0.32);
1180
+ border-radius: 999px; outline: none; cursor: pointer;
1181
+ box-shadow: 0 0 6px var(--gold-glow);
1182
+ }
1183
+ .token-editor input.te-stretch::-webkit-slider-thumb {
1184
+ -webkit-appearance: none; appearance: none;
1185
+ width: 14px; height: 14px; border-radius: 50%;
1186
+ background: radial-gradient(circle at 30% 30%, var(--sun-mid), var(--sun-core));
1187
+ box-shadow: 0 0 8px var(--sun-halo);
1188
+ cursor: grab;
1189
+ }
1190
+ .token-editor .te-val { color: var(--cream-mute); min-width: 50px; text-align: right; font-size: 13px; }
1191
+ .token-editor .te-buttons { justify-content: flex-end; }
1192
+ .token-editor button {
1193
+ background: transparent; border: 1px solid rgba(245, 197, 107, 0.42);
1194
+ color: var(--cream); padding: 4px 14px; border-radius: 999px;
1195
+ font-family: 'Cormorant Garamond', serif; font-style: italic; font-size: 14px;
1196
+ cursor: pointer; transition: all 0.18s;
1197
+ }
1198
+ .token-editor button:hover { background: rgba(245, 197, 107, 0.18); color: var(--gold-bright); }
1199
+ .token-editor button.te-apply { border-color: var(--vermillion); color: var(--vermillion); }
1200
+ .token-editor button.te-apply:hover { background: var(--vermillion); color: var(--paper); }
1201
+
1202
+ .dial-label { display: none; }
1203
+
1204
+ /* hide the standard gradio header / footer chrome */
1205
+ .show-api, footer.svelte-mpyp5e, gradio-app > footer { display: none !important; }
1206
+
1207
+ @media (max-width: 720px) {
1208
+ #hero h1 { font-size: 88px !important; }
1209
+ .gradio-container { padding: 36px 18px 64px !important; }
1210
+ textarea { font-size: 20px !important; }
1211
+ }
1212
+
1213
+ """
1214
+
1215
+
1216
+ _THEME = gr.themes.Base(primary_hue=gr.themes.colors.pink, neutral_hue=gr.themes.colors.slate).set(
1217
+ body_background_fill="*neutral_950", block_background_fill="*neutral_900",
1218
+ )
1219
+
1220
+
1221
+ # ----- circular knob widget (gr.HTML + html_template + js_on_load + trigger('change'))
1222
+ # Requires Gradio >= 6.9 for arbitrary event-name triggers (we pin >= 6.10 in requirements.txt).
1223
+ # Pattern is from gradio.app/guides/custom-HTML-components.
1224
+
1225
+ KNOB_HTML = """
1226
+ <style>
1227
+ #dial-knob { padding: 28px 0 12px 0; display: flex; flex-direction: column; align-items: center; }
1228
+ .knob-stage {
1229
+ position: relative; width: 280px; height: 280px;
1230
+ display: flex; align-items: center; justify-content: center;
1231
+ }
1232
+ /* outer brass ring */
1233
+ .knob-stage::before {
1234
+ content: ''; position: absolute; inset: 0; border-radius: 50%;
1235
+ background: conic-gradient(from 0deg,
1236
+ #B89968, #6B5328, #D6B888, #A8884D, #6B5328, #C6A468, #B89968);
1237
+ filter: blur(0.5px);
1238
+ }
1239
+ .knob-stage::after {
1240
+ content: ''; position: absolute; inset: 10px; border-radius: 50%;
1241
+ background: var(--paper);
1242
+ box-shadow:
1243
+ inset 0 0 0 1px rgba(42,31,45,0.18),
1244
+ 0 18px 32px -16px rgba(42,31,45,0.42),
1245
+ 0 2px 0 rgba(255,255,255,0.6) inset;
1246
+ }
1247
+ .knob-host {
1248
+ position: relative; z-index: 2; width: 192px; height: 192px;
1249
+ border-radius: 50%; cursor: grab; outline: none;
1250
+ background:
1251
+ radial-gradient(circle at 32% 28%, #FFF1D8 0%, #E8C7A0 30%, #B89968 64%, #6B5328 96%);
1252
+ box-shadow:
1253
+ inset 0 -8px 18px rgba(42,31,45,0.32),
1254
+ inset 0 2px 6px rgba(255,255,255,0.45),
1255
+ 0 6px 16px rgba(42,31,45,0.22);
1256
+ touch-action: none; user-select: none;
1257
+ transition: box-shadow 0.18s, filter 0.22s;
1258
+ }
1259
+ .knob-host:focus-visible, .knob-host.dragging {
1260
+ box-shadow:
1261
+ inset 0 -8px 18px rgba(42,31,45,0.32),
1262
+ inset 0 2px 6px rgba(255,255,255,0.55),
1263
+ 0 0 0 2px var(--vermillion),
1264
+ 0 8px 24px var(--vermillion-glow);
1265
+ filter: saturate(1.08);
1266
+ }
1267
+ .knob-host.dragging { cursor: grabbing; }
1268
+ /* vermillion arc */
1269
+ .knob-arc {
1270
+ position: absolute; z-index: 3; inset: -16px; border-radius: 50%; pointer-events: none;
1271
+ background: conic-gradient(
1272
+ from 225deg,
1273
+ var(--vermillion) 0deg,
1274
+ var(--vermillion) var(--arc-deg, 0deg),
1275
+ transparent var(--arc-deg, 0deg) 270deg,
1276
+ transparent 360deg
1277
+ );
1278
+ -webkit-mask: radial-gradient(circle, transparent 116px, #000 117px, #000 124px, transparent 125px);
1279
+ mask: radial-gradient(circle, transparent 116px, #000 117px, #000 124px, transparent 125px);
1280
+ filter: drop-shadow(0 0 8px var(--vermillion-glow));
1281
+ }
1282
+ /* indicator — a tiny vermillion bar with subtle glow */
1283
+ .knob-indicator {
1284
+ position: absolute; z-index: 4; left: 50%; top: 22px;
1285
+ width: 4px; height: 28px; margin-left: -2px;
1286
+ background: var(--vermillion); border-radius: 2px;
1287
+ transform-origin: 50% 74px;
1288
+ transform: rotate(var(--knob-angle, -135deg));
1289
+ box-shadow: 0 0 12px var(--vermillion-glow);
1290
+ pointer-events: none;
1291
+ transition: transform 0.16s cubic-bezier(.34,1.36,.4,1);
1292
+ }
1293
+ /* the center numeral — italic Fraunces in ink */
1294
+ .knob-label {
1295
+ position: absolute; z-index: 5; inset: 0;
1296
+ display: flex; align-items: center; justify-content: center;
1297
+ font-family: 'Fraunces', serif; font-style: italic; font-weight: 300;
1298
+ font-size: 92px; color: var(--ink);
1299
+ letter-spacing: -0.04em; pointer-events: none;
1300
+ text-shadow: 0 1px 0 rgba(255, 240, 220, 0.5), 0 -1px 0 rgba(42, 31, 45, 0.12);
1301
+ }
1302
+ /* ticks — italic editorial numerals around the knob */
1303
+ .knob-ticks {
1304
+ display: flex; justify-content: space-between; width: 320px; margin: 28px auto 0;
1305
+ font-family: 'Fraunces', serif; font-style: italic; font-weight: 300;
1306
+ font-size: 14px; color: var(--ink-quiet);
1307
+ padding: 0 18px;
1308
+ }
1309
+ .knob-ticks span {
1310
+ display: inline-flex; flex-direction: column; align-items: center; gap: 4px;
1311
+ cursor: pointer; transition: color 0.18s;
1312
+ }
1313
+ .knob-ticks span::before {
1314
+ content: ''; width: 1px; height: 8px; background: var(--ink-quiet);
1315
+ transition: background 0.18s;
1316
+ }
1317
+ .knob-ticks span.active { color: var(--vermillion); font-weight: 400; font-style: normal; }
1318
+ .knob-ticks span.active::before { background: var(--vermillion); height: 12px; }
1319
+ .knob-caption {
1320
+ font-family: 'IBM Plex Mono', monospace; font-size: 10px;
1321
+ letter-spacing: 0.34em; color: var(--ink-quiet);
1322
+ text-transform: uppercase; margin-top: 12px;
1323
+ }
1324
+ </style>
1325
+ <div id="dial-knob">
1326
+ <div class="knob-stage">
1327
+ <div class="knob-host" id="dial-knob-host" tabindex="0" role="slider"
1328
+ aria-valuemin="0" aria-valuemax="4" aria-valuenow="${value}" aria-label="dial">
1329
+ <div class="knob-arc"></div>
1330
+ <div class="knob-indicator"></div>
1331
+ <div class="knob-label">${value}</div>
1332
+ </div>
1333
+ </div>
1334
+ <div class="knob-ticks">
1335
+ <span data-lv="0">0</span><span data-lv="1">1</span><span data-lv="2">2</span><span data-lv="3">3</span><span data-lv="4">4</span>
1336
+ </div>
1337
+ <div class="knob-caption">turn · drag · scroll · arrow keys</div>
1338
+ </div>
1339
+ """
1340
+
1341
+ KNOB_JS = """
1342
+ (element, props) => {
1343
+ const host = element.querySelector('.knob-host');
1344
+ if (!host) return;
1345
+ const lab = element.querySelector('.knob-label');
1346
+ const ticks = Array.from(element.querySelectorAll('.knob-ticks span'));
1347
+ const LEVELS = 5;
1348
+ const ANGLE_MIN = -135, ANGLE_MAX = 135;
1349
+ let level = Math.max(0, Math.min(LEVELS - 1, Math.round(Number(props.value) || 0)));
1350
+
1351
+ const render = (lv) => {
1352
+ const frac = lv / (LEVELS - 1);
1353
+ const ang = ANGLE_MIN + (ANGLE_MAX - ANGLE_MIN) * frac;
1354
+ const arc = (ANGLE_MAX - ANGLE_MIN) * frac;
1355
+ host.style.setProperty('--knob-angle', ang + 'deg');
1356
+ host.style.setProperty('--arc-deg', arc + 'deg');
1357
+ lab.textContent = lv;
1358
+ host.setAttribute('aria-valuenow', lv);
1359
+ ticks.forEach(t => t.classList.toggle('active', Number(t.dataset.lv) === lv));
1360
+ };
1361
+
1362
+ const setLevel = (lv, fire) => {
1363
+ lv = Math.max(0, Math.min(LEVELS - 1, Math.round(lv)));
1364
+ if (lv === level && !fire) return;
1365
+ level = lv;
1366
+ render(level);
1367
+ if (fire) { props.value = level; trigger('change'); }
1368
+ };
1369
+ render(level);
1370
+
1371
+ let dragging = false, startY = 0, startLv = 0;
1372
+ host.addEventListener('pointerdown', (e) => {
1373
+ dragging = true; startY = e.clientY; startLv = level;
1374
+ host.setPointerCapture(e.pointerId); host.classList.add('dragging');
1375
+ });
1376
+ host.addEventListener('pointermove', (e) => {
1377
+ if (!dragging) return;
1378
+ const dy = startY - e.clientY; // drag up -> louder
1379
+ setLevel(startLv + dy / 40, true);
1380
+ });
1381
+ const endDrag = (e) => {
1382
+ if (!dragging) return;
1383
+ dragging = false;
1384
+ try { host.releasePointerCapture(e.pointerId); } catch (_) {}
1385
+ host.classList.remove('dragging');
1386
+ };
1387
+ host.addEventListener('pointerup', endDrag);
1388
+ host.addEventListener('pointercancel', endDrag);
1389
+ host.addEventListener('wheel', (e) => {
1390
+ e.preventDefault();
1391
+ setLevel(level + (e.deltaY < 0 ? 1 : -1), true);
1392
+ }, { passive: false });
1393
+ host.addEventListener('keydown', (e) => {
1394
+ if (e.key === 'ArrowUp' || e.key === 'ArrowRight') { e.preventDefault(); setLevel(level + 1, true); }
1395
+ else if (e.key === 'ArrowDown' || e.key === 'ArrowLeft') { e.preventDefault(); setLevel(level - 1, true); }
1396
+ else if (e.key === 'Home') { e.preventDefault(); setLevel(0, true); }
1397
+ else if (e.key === 'End') { e.preventDefault(); setLevel(LEVELS - 1, true); }
1398
+ });
1399
+ ticks.forEach(t => t.addEventListener('click', () => setLevel(Number(t.dataset.lv), true)));
1400
+ }
1401
+ """
1402
+
1403
+ STYLE_PRESETS = {
1404
+ "dreamy": "Goodman/Samarin glossolalia palette",
1405
+ "hopelandic": "glide-led, high-front vowel chains (Sigur Rós register)",
1406
+ "fraser": "m/n/l onsets, open back vowels (Cocteau Twins register)",
1407
+ }
1408
+
1409
+ with gr.Blocks(title="Glossolalia Dial") as demo:
1410
+ # Inject fonts via <link> — Gradio's CSS-in-JS strips @import, so the stylesheet
1411
+ # rule has to land in the document head as a tag, not in the CSS string.
1412
+ gr.HTML(
1413
+ """
1414
+ <link rel="preconnect" href="https://fonts.googleapis.com">
1415
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
1416
+ <link href="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" rel="stylesheet">
1417
+ <div id="hero">
1418
+ <h1>glossolalia</h1>
1419
+ <p class="tagline">type a lyric. pick a voice. turn the dial.<br>
1420
+ hear it dissolve into wordless tongues, in your own voice.</p>
1421
+ </div>
1422
+ """
1423
+ )
1424
+
1425
+ # lyric — magazine pull-quote spanning full width
1426
+ sentence = gr.Textbox(label="the lyric", value=DEFAULT_TEXT, lines=2,
1427
+ placeholder="anything; the dial will dissolve it",
1428
+ elem_id="lyric-input")
1429
+
1430
+ # voice + mode in a quiet two-up
1431
+ with gr.Row():
1432
+ voice = gr.Dropdown([(v["name"], k) for k, v in VOICE_PRESETS.items()],
1433
+ value=DEFAULT_VOICE, label="the voice", scale=1)
1434
+ mode = gr.Radio(choices=list(MODES), value=MODE_TONGUES,
1435
+ label="the path", scale=1)
1436
+
1437
+ # custom voice — record or upload a 10-sec clip; F5-TTS clones it
1438
+ with gr.Accordion("🎤 Clone your own voice (record or upload 6-12 sec)",
1439
+ open=False, elem_id="custom-voice-accord"):
1440
+ with gr.Row():
1441
+ custom_voice = gr.Audio(
1442
+ sources=["upload", "microphone"],
1443
+ type="filepath",
1444
+ label="reference clip (clean speech, no background music, 6-12 sec)",
1445
+ scale=2,
1446
+ )
1447
+ custom_voice_text = gr.Textbox(
1448
+ label="(optional) transcript of the clip",
1449
+ placeholder="leave empty to auto-transcribe (Whisper)",
1450
+ lines=2,
1451
+ scale=1,
1452
+ )
1453
+
1454
+ # background music — upload an instrumental; we tempo-lock + mix the vocal over it
1455
+ with gr.Accordion("🎵 Add background music (mix a backing track under the voice)",
1456
+ open=False, elem_id="music-accord"):
1457
+ with gr.Row():
1458
+ music_input = gr.Audio(
1459
+ sources=["upload"],
1460
+ type="filepath",
1461
+ label="backing track (instrumental, any length, any tempo)",
1462
+ scale=2,
1463
+ )
1464
+ music_gain = gr.Slider(
1465
+ -24, 6, value=-8, step=1,
1466
+ label="music level (dB)",
1467
+ scale=1,
1468
+ )
1469
+
1470
+ # per-word overrides — stretch + pronunciation per individual word.
1471
+ # Token preview HTML is Python-rendered on every lyric / state change so the markup
1472
+ # is always in sync. JS only handles the click-to-edit popover and writes the new
1473
+ # state back into the textbox below; Python then re-renders on that change.
1474
+ with gr.Accordion("✎ Stretch or re-spell individual words (click any word to edit)",
1475
+ open=False, elem_id="overrides-accord"):
1476
+ token_preview = gr.HTML(_render_token_preview(DEFAULT_TEXT, ""),
1477
+ elem_id="token-preview")
1478
+ overrides_text = gr.Textbox(
1479
+ label="override state (the click-to-edit writes here; you can also type by hand)",
1480
+ placeholder='advanced: word=pronunciation:stretch e.g. river=ree-vuh:1.4',
1481
+ lines=1,
1482
+ elem_id="overrides-state",
1483
+ value="",
1484
+ )
1485
+ # Click-to-edit on .token chips. Gradio v6 strips inline <script> tags inside
1486
+ # gr.HTML, so we register the delegated listener via demo.load(js=...) below.
1487
+
1488
+ # the dial — brass knob with vermillion arc and a Fraunces numeral center.
1489
+ # Tick numbers sit in a semicircle above the knob (0 on the left, 4 on the right).
1490
+ # The knob's indicator is a pointer needle that lines up with the active tick.
1491
+ gr.HTML(
1492
+ """
1493
+ <div id="dial-stack">
1494
+ <div class="dial-tag">the dial</div>
1495
+ <div class="knob-stage">
1496
+ <div class="knob-ticks" id="knob-ticks">
1497
+ <span data-lv="0">0</span>
1498
+ <span data-lv="1">1</span>
1499
+ <span data-lv="2">2</span>
1500
+ <span data-lv="3">3</span>
1501
+ <span data-lv="4">4</span>
1502
+ </div>
1503
+ <div class="knob-wrap">
1504
+ <div class="knob" id="knob" tabindex="0" role="slider"
1505
+ aria-valuemin="0" aria-valuemax="4" aria-valuenow="0">
1506
+ <div class="knob-arc"></div>
1507
+ <div class="knob-pointer"></div>
1508
+ <div class="knob-pin"></div>
1509
+ </div>
1510
+ </div>
1511
+ </div>
1512
+ </div>
1513
+ """
1514
+ )
1515
+ level = gr.Slider(0, 4, value=0, step=1, label="", elem_id="dial-slider",
1516
+ show_label=False, visible=True)
1517
+
1518
+ # Live preview of what the audio will say, updates as the dial / mode / lyric change.
1519
+ ghost_lyric = gr.Textbox(label="what the voice will say at this dial position",
1520
+ interactive=False, lines=2,
1521
+ value="",
1522
+ elem_classes="ghost-lyric",
1523
+ elem_id="ghost-lyric")
1524
+
1525
+ # post-fx as a quiet adjuster: how much room / reverb to wrap the voice in.
1526
+ postfx = gr.Dropdown(list(POSTFX_PRESETS.keys()), value="subtle",
1527
+ label="space (dry = bare voice, cathedral = drenched in reverb)")
1528
+ # seed is wired but hidden from the UI (it powers determinism but most users don't care).
1529
+ seed = gr.Number(value=42, precision=0, label="seed", visible=False)
1530
+
1531
+ # two actions: hear ONE dial position, or hear the WHOLE 0->4 dissolution in one take.
1532
+ # morph works in whichever mode is selected (Tongues or Ghost), so the label is neutral.
1533
+ with gr.Row(elem_classes="action-row"):
1534
+ speak_btn = gr.Button("play this dial", variant="primary",
1535
+ elem_classes="primary", scale=1)
1536
+ morph_btn = gr.Button("dissolve · sweep 0 → 4", variant="primary",
1537
+ elem_classes="primary", scale=1)
1538
+
1539
+ audio_out = gr.Audio(label="the take", type="filepath", autoplay=True,
1540
+ elem_id="audio-out")
1541
+ # Hidden — stub metrics block, only shown after we wire real Whisper-WER + Resemblyzer.
1542
+ metrics = gr.HTML(readout(), visible=False)
1543
+
1544
+ # Bind the brass knob to the hidden slider. Gradio strips inline <script> tags,
1545
+ # Inject the fixed background sun layer as a real <div> on <body>. A real node
1546
+ # (not a ::before pseudo) is immune to Gradio re-rendering its subtree and never
1547
+ # fights another element's pseudo slot. Idempotent so a reconnect can't duplicate it.
1548
+ demo.load(
1549
+ fn=None,
1550
+ js="""() => {
1551
+ if (!document.getElementById('bg-sun')) {
1552
+ const d = document.createElement('div');
1553
+ d.id = 'bg-sun';
1554
+ document.body.prepend(d);
1555
+ }
1556
+ return [];
1557
+ }""",
1558
+ )
1559
+
1560
+ # so we inject this via demo.load(js=...) which runs once on page mount.
1561
+ demo.load(
1562
+ fn=None,
1563
+ js="""() => {
1564
+ const LEVELS = 5;
1565
+ // 0 on the LEFT, 4 on the RIGHT, sweep over the TOP.
1566
+ const ANGLE_MIN = -90, ANGLE_MAX = 90;
1567
+ function getInp() { return document.querySelector('#dial-slider input[type="range"]'); }
1568
+ function levelToAngle(level) {
1569
+ const frac = level / (LEVELS - 1);
1570
+ return ANGLE_MIN + (ANGLE_MAX - ANGLE_MIN) * frac;
1571
+ }
1572
+ function positionTicks() {
1573
+ const ticks = document.querySelectorAll('.knob-ticks span');
1574
+ // .knob-ticks is positioned at the knob center, so polar coords directly:
1575
+ // tx = sin(angle)*R, ty = -cos(angle)*R. Knurled rim outer is at 88px, so
1576
+ // a radius of 102 sits the tick just outside the rim.
1577
+ const radius = 178;
1578
+ ticks.forEach(t => {
1579
+ const lv = Number(t.dataset.lv);
1580
+ const angle = levelToAngle(lv);
1581
+ const rad = angle * Math.PI / 180;
1582
+ const tx = Math.sin(rad) * radius;
1583
+ const ty = -Math.cos(rad) * radius;
1584
+ t.style.setProperty('--tx', tx + 'px');
1585
+ t.style.setProperty('--ty', ty + 'px');
1586
+ });
1587
+ }
1588
+ function render(level) {
1589
+ const knob = document.getElementById('knob'); if (!knob) return;
1590
+ const ticks = document.querySelectorAll('.knob-ticks span');
1591
+ const angle = levelToAngle(level);
1592
+ knob.style.setProperty('--knob-angle', angle + 'deg');
1593
+ knob.style.setProperty('--arc-deg', (angle - ANGLE_MIN) + 'deg');
1594
+ knob.setAttribute('aria-valuenow', level);
1595
+ ticks.forEach(t => t.classList.toggle('active', Number(t.dataset.lv) === level));
1596
+ }
1597
+ function setLevel(level, fire) {
1598
+ level = Math.max(0, Math.min(LEVELS - 1, Math.round(level)));
1599
+ render(level);
1600
+ if (fire) {
1601
+ const inp = getInp();
1602
+ if (inp) {
1603
+ inp.value = String(level);
1604
+ inp.dispatchEvent(new Event('input', {bubbles: true}));
1605
+ inp.dispatchEvent(new Event('change', {bubbles: true}));
1606
+ }
1607
+ }
1608
+ }
1609
+ function bind() {
1610
+ const knob = document.getElementById('knob');
1611
+ const inp = getInp();
1612
+ if (!knob || !inp || knob.dataset.bound === '1') return !!knob && !!inp;
1613
+ knob.dataset.bound = '1';
1614
+ positionTicks();
1615
+ render(parseInt(inp.value || '0', 10));
1616
+ inp.addEventListener('input', () => render(parseInt(inp.value || '0', 10)));
1617
+ // Rotational drag: convert pointer position to angle from knob center,
1618
+ // map that angle into 0..(LEVELS-1).
1619
+ let dragging = false;
1620
+ function angleFromEvent(e) {
1621
+ const r = knob.getBoundingClientRect();
1622
+ const cx = r.left + r.width / 2;
1623
+ const cy = r.top + r.height / 2;
1624
+ // 0 deg = pointing up; clockwise positive (standard CSS rotation).
1625
+ let a = Math.atan2(e.clientX - cx, cy - e.clientY) * 180 / Math.PI;
1626
+ return a; // range -180..+180
1627
+ }
1628
+ function angleToLevel(a) {
1629
+ // ANGLE_MIN = -135 -> level 0, ANGLE_MAX = +135 -> level 4
1630
+ let clamped = Math.max(ANGLE_MIN, Math.min(ANGLE_MAX, a));
1631
+ const frac = (clamped - ANGLE_MIN) / (ANGLE_MAX - ANGLE_MIN);
1632
+ return Math.round(frac * (LEVELS - 1));
1633
+ }
1634
+ knob.addEventListener('pointerdown', e => {
1635
+ dragging = true;
1636
+ knob.setPointerCapture(e.pointerId);
1637
+ knob.classList.add('dragging');
1638
+ setLevel(angleToLevel(angleFromEvent(e)), true);
1639
+ });
1640
+ knob.addEventListener('pointermove', e => {
1641
+ if (!dragging) return;
1642
+ setLevel(angleToLevel(angleFromEvent(e)), true);
1643
+ });
1644
+ const end = e => {
1645
+ if (!dragging) return;
1646
+ dragging = false;
1647
+ try { knob.releasePointerCapture(e.pointerId); } catch(_) {}
1648
+ knob.classList.remove('dragging');
1649
+ };
1650
+ knob.addEventListener('pointerup', end);
1651
+ knob.addEventListener('pointercancel', end);
1652
+ knob.addEventListener('wheel', e => {
1653
+ e.preventDefault();
1654
+ setLevel(parseInt(inp.value || '0', 10) + (e.deltaY < 0 ? 1 : -1), true);
1655
+ }, {passive: false});
1656
+ knob.addEventListener('keydown', e => {
1657
+ const cur = parseInt(inp.value || '0', 10);
1658
+ if (e.key === 'ArrowUp' || e.key === 'ArrowRight') { e.preventDefault(); setLevel(cur + 1, true); }
1659
+ else if (e.key === 'ArrowDown' || e.key === 'ArrowLeft') { e.preventDefault(); setLevel(cur - 1, true); }
1660
+ });
1661
+ document.querySelectorAll('.knob-ticks span').forEach(t => {
1662
+ t.addEventListener('click', () => setLevel(Number(t.dataset.lv), true));
1663
+ });
1664
+ return true;
1665
+ }
1666
+ // Gradio mounts components asynchronously; retry until both are present.
1667
+ let n = 0;
1668
+ const iv = setInterval(() => { if (bind() || ++n > 60) clearInterval(iv); }, 150);
1669
+ }""",
1670
+ )
1671
+
1672
+ # Click-to-edit on .token chips. Same demo.load(js=...) pattern as the knob —
1673
+ # inline <script> in gr.HTML is stripped by Gradio v6.
1674
+ demo.load(
1675
+ fn=None,
1676
+ js=r"""() => {
1677
+ function getStateTA() {
1678
+ const wrap = document.getElementById('overrides-state');
1679
+ return wrap ? wrap.querySelector('textarea, input') : null;
1680
+ }
1681
+ function parseState(s) {
1682
+ const out = {};
1683
+ (s || '').trim().split(/\s+/).forEach(t => {
1684
+ if (!t.includes('=')) return;
1685
+ const [w, rest] = t.split('=', 2);
1686
+ let repl = rest, stretch = '1.0';
1687
+ if (rest && rest.includes(':')) { [repl, stretch] = rest.split(':', 2); }
1688
+ out[w.toLowerCase()] = { repl: repl || w, stretch: parseFloat(stretch) || 1.0 };
1689
+ });
1690
+ return out;
1691
+ }
1692
+ function serializeState(state) {
1693
+ return Object.entries(state)
1694
+ .map(([w, v]) => `${w}=${v.repl}:${v.stretch.toFixed(2)}`)
1695
+ .join(' ');
1696
+ }
1697
+ function openEditor(anchor) {
1698
+ const stateTA = getStateTA();
1699
+ if (!stateTA) return;
1700
+ const word = anchor.dataset.word;
1701
+ const state = parseState(stateTA.value || '');
1702
+ const cur = state[word] || {
1703
+ repl: anchor.dataset.pron || word,
1704
+ stretch: parseFloat(anchor.dataset.stretch || '1.0'),
1705
+ };
1706
+ const old = document.querySelector('.token-editor');
1707
+ if (old) old.remove();
1708
+ const ed = document.createElement('div');
1709
+ ed.className = 'token-editor';
1710
+ ed.innerHTML = `
1711
+ <div class="te-row te-head">editing <b>${word}</b></div>
1712
+ <label class="te-row">pronunciation <input class="te-pron" type="text" value="${cur.repl}"></label>
1713
+ <label class="te-row">stretch <span class="te-val">${cur.stretch.toFixed(2)}x</span>
1714
+ <input class="te-stretch" type="range" min="0.5" max="2.5" step="0.05" value="${cur.stretch}"></label>
1715
+ <div class="te-row te-buttons">
1716
+ <button type="button" class="te-clear">clear</button>
1717
+ <button type="button" class="te-apply">apply</button>
1718
+ </div>`;
1719
+ anchor.insertAdjacentElement('afterend', ed);
1720
+ const pron = ed.querySelector('.te-pron');
1721
+ const sl = ed.querySelector('.te-stretch');
1722
+ const val = ed.querySelector('.te-val');
1723
+ sl.oninput = () => { val.textContent = parseFloat(sl.value).toFixed(2) + 'x'; };
1724
+ ed.querySelector('.te-apply').onclick = () => {
1725
+ const ns = parseState(stateTA.value || '');
1726
+ ns[word] = { repl: pron.value || word, stretch: parseFloat(sl.value) };
1727
+ stateTA.value = serializeState(ns);
1728
+ stateTA.dispatchEvent(new Event('input', { bubbles: true }));
1729
+ ed.remove();
1730
+ };
1731
+ ed.querySelector('.te-clear').onclick = () => {
1732
+ const ns = parseState(stateTA.value || '');
1733
+ delete ns[word];
1734
+ stateTA.value = serializeState(ns);
1735
+ stateTA.dispatchEvent(new Event('input', { bubbles: true }));
1736
+ ed.remove();
1737
+ };
1738
+ }
1739
+ // Delegate clicks: works even after Python re-renders the token preview.
1740
+ document.addEventListener('click', e => {
1741
+ const t = e.target.closest('#token-preview .token');
1742
+ if (t) openEditor(t);
1743
+ }, true);
1744
+ }""",
1745
+ )
1746
+
1747
+ level.change(lambda lv: readout(level=_safe_int(lv)),
1748
+ inputs=level, outputs=metrics)
1749
+ sentence.change(_render_token_preview,
1750
+ inputs=[sentence, overrides_text], outputs=token_preview)
1751
+ overrides_text.change(_render_token_preview,
1752
+ inputs=[sentence, overrides_text], outputs=token_preview)
1753
+
1754
+ # Live preview of "what the audio will say" as the dial / mode / lyric changes.
1755
+ # Ghost mode: mondegreen-substituted real English words.
1756
+ # Tongues mode: phoneme-corrupted pseudo-text.
1757
+ def _live_preview(text, lv, m, sd):
1758
+ try:
1759
+ lv = _safe_int(lv)
1760
+ sd = _safe_int(sd) or 42
1761
+ if not text or not text.strip():
1762
+ return ""
1763
+ # Dial=0 always returns the original lyric in either mode.
1764
+ if lv <= 0:
1765
+ return text.strip()
1766
+ if m == MODE_GHOST:
1767
+ # Ghost: real English words substituted via mondegreen + DistilGPT-2 rerank.
1768
+ return ENGINE.ghost_text(text, lv, seed=sd)
1769
+ else:
1770
+ # Tongues: pseudo-ASCII rendering of the phoneme corruption that the TTS
1771
+ # actually reads. Trimmed of inter-phoneme hyphens for readability.
1772
+ from scripts.corrupt_phonemes import load_lm, corrupt_sentence
1773
+ from pathlib import Path
1774
+ _lm = getattr(_live_preview, "_lm", None)
1775
+ if _lm is None:
1776
+ _lm = load_lm(Path("data/phoneme_lm.npz"))
1777
+ _live_preview._lm = _lm
1778
+ _, _, pseudo, _ = corrupt_sentence(text, lv, _lm, seed=sd)
1779
+ return pseudo
1780
+ except Exception as e:
1781
+ return f"(preview unavailable: {e})"
1782
+
1783
+ for trig in (level, sentence, mode, seed):
1784
+ trig.change(_live_preview,
1785
+ inputs=[sentence, level, mode, seed],
1786
+ outputs=ghost_lyric)
1787
+ # Also compute the preview once on initial page load so the textbox isn't empty.
1788
+ demo.load(_live_preview,
1789
+ inputs=[sentence, level, mode, seed],
1790
+ outputs=ghost_lyric)
1791
+ speak_btn.click(
1792
+ speak,
1793
+ inputs=[sentence, voice, level, postfx, mode, seed,
1794
+ custom_voice, custom_voice_text,
1795
+ music_input, music_gain, overrides_text],
1796
+ outputs=[audio_out, metrics, ghost_lyric],
1797
+ )
1798
+ morph_btn.click(
1799
+ morph,
1800
+ inputs=[sentence, voice, postfx, mode, seed,
1801
+ custom_voice, custom_voice_text,
1802
+ music_input, music_gain, overrides_text],
1803
+ outputs=[audio_out, metrics, ghost_lyric],
1804
+ )
1805
+
1806
+ gr.HTML(
1807
+ """
1808
+ <div id="footer">
1809
+ <div>Open weights · all inference local · the dial is a fine-tuned
1810
+ control surface, not a DSP effect.</div>
1811
+ <div>Build-Small · Thousand Token Wood</div>
1812
+ </div>
1813
+ """
1814
+ )
1815
+
1816
+
1817
+ if __name__ == "__main__":
1818
+ demo.launch(theme=_THEME, css=CUSTOM_CSS)