gradio server

#2
by akhaliq HF Staff - opened
Files changed (6) hide show
  1. .gitignore +1 -0
  2. README.md +22 -7
  3. app.py +104 -21
  4. create_env.py +59 -17
  5. index.html +1198 -0
  6. requirements.txt +84 -8
.gitignore CHANGED
@@ -20,3 +20,4 @@ venv*/
20
  .hf_home/
21
  flagged/
22
  gradio_cached_examples/
 
 
20
  .hf_home/
21
  flagged/
22
  gradio_cached_examples/
23
+ _out/
README.md CHANGED
@@ -4,11 +4,7 @@ emoji: 🐆
4
  colorFrom: yellow
5
  colorTo: red
6
  sdk: gradio
7
- # 5.49.x is the "bridge" release: it accepts huggingface-hub >=0.33.5,<2.0,
8
- # so it co-resolves with the NeMo stack (hub 0.x) at build time AND keeps
9
- # working after create_env.py re-pins transformers==5.3.0 (hub 1.x) at runtime.
10
- # gradio 6.x requires hub>=1.2 and cannot be co-installed with nemo-toolkit.
11
- sdk_version: 5.49.1
12
  python_version: '3.12'
13
  app_file: app.py
14
  pinned: false
@@ -33,6 +29,15 @@ heads, a Q-Former voice-cloning compressor, and DPO post-training).
33
  and voice adherence (defaults: `temperature=0.3`, `cfg_scale=3`).
34
  - All generation knobs are exposed under **Generation settings**.
35
 
 
 
 
 
 
 
 
 
 
36
  ## Configuration
37
 
38
  `config.yaml` selects the checkpoint, codec, preset speakers and default
@@ -43,5 +48,15 @@ generation parameters — re-point the Space without touching code.
43
  - The model repo is private: set the `HF_TOKEN` Space secret.
44
  - ZeroGPU: the model is loaded once at startup; each request only runs
45
  generation inside the GPU context.
46
- - `create_env.py` re-pins `transformers==5.3.0` at startup (NeMo downgrades
47
- it at build time) — it must run before any ML import in `app.py`.
 
 
 
 
 
 
 
 
 
 
 
4
  colorFrom: yellow
5
  colorTo: red
6
  sdk: gradio
7
+ sdk_version: 6.20.0
 
 
 
 
8
  python_version: '3.12'
9
  app_file: app.py
10
  pinned: false
 
29
  and voice adherence (defaults: `temperature=0.3`, `cfg_scale=3`).
30
  - All generation knobs are exposed under **Generation settings**.
31
 
32
+ ## Architecture
33
+
34
+ The Space runs on `gradio.Server` — a FastAPI app with Gradio's queueing
35
+ engine on top. The custom dark-themed UI in `index.html` is served at `/`
36
+ via `@app.get("/")`; the synthesis pipeline is exposed at
37
+ `@app.api("/synthesize")`, so requests flow through Gradio's queue
38
+ (concurrency control, ZeroGPU allocation, `gradio_client` compatibility)
39
+ while the frontend stays a self-contained HTML/CSS/JS bundle.
40
+
41
  ## Configuration
42
 
43
  `config.yaml` selects the checkpoint, codec, preset speakers and default
 
48
  - The model repo is private: set the `HF_TOKEN` Space secret.
49
  - ZeroGPU: the model is loaded once at startup; each request only runs
50
  generation inside the GPU context.
51
+ - `create_env.py` orchestrates a 4-step install at app startup it must
52
+ run before any ML import in `app.py`:
53
+ 1. Pin `huggingface-hub>=1.2,<2.0` (compatible with both gradio 6.20
54
+ and `nemo-toolkit`).
55
+ 2. `pip install --no-deps nemo-toolkit[tts]==2.4.0` (avoiding a downgrade
56
+ of hub by the resolver).
57
+ 3. Force-reinstall `transformers==5.3.0` (Qwen3.5 backbone that the
58
+ Gepard checkpoint was trained on).
59
+ 4. Cap `numpy<2.0` so the codec/NeMo stack stays on numpy 1.x.
60
+ - `nemo-toolkit` is installed at runtime (not in `requirements.txt`) to
61
+ keep gradio 6.20's `huggingface-hub>=1.2` constraint resolvable at
62
+ build time — NeMo's `transformers<=4.52` would otherwise pull hub<1.0.
app.py CHANGED
@@ -1,13 +1,19 @@
1
- """GEPARD — text-to-speech inference Space (ZeroGPU).
2
 
3
  Startup order is load-bearing:
4
- 1. ``create_env.setup_dependencies()`` re-pins transformers BEFORE any
5
- ML import — the Space image ships whatever NeMo pinned at build time,
6
- but the Gepard checkpoint requires transformers 5.3.0.
 
 
7
  2. ``spaces`` is imported before torch so ZeroGPU can patch CUDA calls.
8
  3. The engine (model + codec + speakers) is built ONCE at module level;
9
  ZeroGPU replays the recorded ``.to("cuda")`` moves when a GPU is
10
  attached, so no per-request reloading happens.
 
 
 
 
11
  """
12
 
13
  from create_env import setup_dependencies
@@ -28,19 +34,50 @@ except ImportError: # local run — no-op stand-in
28
 
29
  return _wrap
30
 
 
31
  from pathlib import Path # noqa: E402
32
 
33
- import gradio as gr # noqa: E402
 
 
 
 
 
 
34
 
35
  from gepard_inference.engine import AppConfig, GenerationParams, GepardEngine # noqa: E402
36
- from interface import MODE_PRESET, GepardInterface # noqa: E402
37
 
38
  CONFIG_PATH = Path(__file__).parent / "config.yaml"
39
 
40
  config = AppConfig.from_yaml(CONFIG_PATH)
41
  engine = GepardEngine(config).load()
42
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
43
 
 
 
 
 
44
  @_gpu_decorator(duration=config.gpu_duration)
45
  def synthesize(
46
  mode: str,
@@ -52,29 +89,31 @@ def synthesize(
52
  max_frames: float,
53
  repetition_penalty: float,
54
  repetition_window: float,
55
- ):
56
- """Gradio handler: resolve the reference voice, then generate speech.
57
 
58
  Runs inside the ZeroGPU context — both the reference encoding (clone
59
  mode) and the autoregressive generation share one GPU session.
 
 
 
60
  """
61
- if not (text or "").strip():
62
- raise gr.Error("Please enter some text to synthesize.")
 
63
 
64
  if mode == MODE_PRESET:
65
  if not speaker:
66
- raise gr.Error("Please choose a preset speaker.")
67
  ref_codes = engine.speakers.codes(speaker)
68
  else:
69
  if not ref_audio:
70
- raise gr.Error("Please record or upload a reference clip.")
71
  ref_codes = engine.encode_reference(ref_audio)
72
 
73
  params = GenerationParams(
74
  temperature=temperature,
75
  top_k=int(top_k),
76
- # CFG is not exposed in the UI: strength comes from config defaults and
77
- # the runner's length gate decides whether it applies at all.
78
  cfg_scale=config.defaults.cfg_scale,
79
  cfg_frames=config.defaults.cfg_frames,
80
  stop_threshold=config.defaults.stop_threshold,
@@ -82,14 +121,58 @@ def synthesize(
82
  repetition_penalty=repetition_penalty,
83
  repetition_window=int(repetition_window),
84
  )
85
- return engine.synthesize(text, ref_codes, params)
 
86
 
87
 
88
- demo = GepardInterface(
89
- config=config,
90
- speaker_names=engine.speakers.names,
91
- synthesize_fn=synthesize,
92
- ).build()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
93
 
94
  if __name__ == "__main__":
95
- demo.launch()
 
1
+ """GEPARD — text-to-speech inference Space (ZeroGPU), gradio.Server backend.
2
 
3
  Startup order is load-bearing:
4
+ 1. ``create_env.setup_dependencies()`` installs nemo-toolkit and re-pins
5
+ transformers BEFORE any ML import — the Space image ships gradio 6.20
6
+ which pins huggingface-hub>=1.2, and NeMo's transformers<=4.52 would
7
+ pull hub<1.0 at build time. create_env.py upgrades hub first, then
8
+ installs nemo with --no-deps, then re-pins transformers==5.3.0.
9
  2. ``spaces`` is imported before torch so ZeroGPU can patch CUDA calls.
10
  3. The engine (model + codec + speakers) is built ONCE at module level;
11
  ZeroGPU replays the recorded ``.to("cuda")`` moves when a GPU is
12
  attached, so no per-request reloading happens.
13
+
14
+ The UI is a custom HTML/CSS/JS frontend served at ``/``; ``@app.api()``
15
+ exposes the synthesis pipeline through Gradio's queue so the JS client
16
+ (and ``gradio_client``) can call it without colliding on the GPU.
17
  """
18
 
19
  from create_env import setup_dependencies
 
34
 
35
  return _wrap
36
 
37
+ import os # noqa: E402
38
  from pathlib import Path # noqa: E402
39
 
40
+ import numpy as np # noqa: E402
41
+ import scipy.io.wavfile as wavfile # noqa: E402
42
+
43
+ from fastapi.responses import HTMLResponse # noqa: E402
44
+
45
+ from gradio import Server # noqa: E402
46
+ from gradio.data_classes import FileData # noqa: E402
47
 
48
  from gepard_inference.engine import AppConfig, GenerationParams, GepardEngine # noqa: E402
49
+ from interface import MODE_PRESET # noqa: E402
50
 
51
  CONFIG_PATH = Path(__file__).parent / "config.yaml"
52
 
53
  config = AppConfig.from_yaml(CONFIG_PATH)
54
  engine = GepardEngine(config).load()
55
 
56
+ # Where synthesized WAVs land — same dir as the script so Spaces' persistent
57
+ # volume picks them up across restarts.
58
+ OUT_DIR = Path(__file__).parent / "_out"
59
+ OUT_DIR.mkdir(exist_ok=True)
60
+
61
+
62
+ def _save_wav(sample_rate: int, wave: np.ndarray) -> str:
63
+ """Write a float waveform to a 16-bit PCM WAV; return the absolute path."""
64
+ import uuid
65
+
66
+ # Clip & convert to int16 PCM (browser audio tag handles 16-bit PCM natively).
67
+ wave = np.asarray(wave, dtype=np.float32)
68
+ peak = float(np.max(np.abs(wave))) if wave.size else 0.0
69
+ if peak > 1.0:
70
+ wave = wave / peak
71
+ pcm = (wave * 32767.0).clip(-32768, 32767).astype(np.int16)
72
+ path = OUT_DIR / f"{uuid.uuid4().hex}.wav"
73
+ wavfile.write(path, sample_rate, pcm)
74
+ return str(path)
75
+
76
 
77
+ app = Server()
78
+
79
+
80
+ @app.api()
81
  @_gpu_decorator(duration=config.gpu_duration)
82
  def synthesize(
83
  mode: str,
 
89
  max_frames: float,
90
  repetition_penalty: float,
91
  repetition_window: float,
92
+ ) -> FileData:
93
+ """Generate speech from text + a preset speaker or a recorded reference.
94
 
95
  Runs inside the ZeroGPU context — both the reference encoding (clone
96
  mode) and the autoregressive generation share one GPU session.
97
+
98
+ Args match the current Gradio Blocks signature exactly so the rest of
99
+ the codebase (config defaults, examples) stays a drop-in.
100
  """
101
+ text = (text or "").strip()
102
+ if not text:
103
+ raise ValueError("Please enter some text to synthesize.")
104
 
105
  if mode == MODE_PRESET:
106
  if not speaker:
107
+ raise ValueError("Please choose a preset speaker.")
108
  ref_codes = engine.speakers.codes(speaker)
109
  else:
110
  if not ref_audio:
111
+ raise ValueError("Please record or upload a reference clip.")
112
  ref_codes = engine.encode_reference(ref_audio)
113
 
114
  params = GenerationParams(
115
  temperature=temperature,
116
  top_k=int(top_k),
 
 
117
  cfg_scale=config.defaults.cfg_scale,
118
  cfg_frames=config.defaults.cfg_frames,
119
  stop_threshold=config.defaults.stop_threshold,
 
121
  repetition_penalty=repetition_penalty,
122
  repetition_window=int(repetition_window),
123
  )
124
+ sample_rate, wave = engine.synthesize(text, ref_codes, params)
125
+ return FileData(path=_save_wav(sample_rate, wave))
126
 
127
 
128
+ @app.get("/")
129
+ async def homepage():
130
+ """Serve the custom HTML/CSS/JS frontend.
131
+
132
+ Server is a FastAPI app, so plain ``@app.get`` routes coexist naturally
133
+ with ``@app.api()`` routes. The frontend lives in ``index.html`` next
134
+ to this file. Returning an ``HTMLResponse`` (instead of a plain string)
135
+ sets ``Content-Type: text/html`` so the browser actually renders it.
136
+ """
137
+ html_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "index.html")
138
+ with open(html_path, "r", encoding="utf-8") as f:
139
+ return HTMLResponse(content=f.read())
140
+
141
+
142
+ @app.get("/config.json")
143
+ async def public_config():
144
+ """Static config exposed to the frontend (speaker list, defaults, examples)."""
145
+ return {
146
+ "speakers": [
147
+ {"key": k, "label": config.speaker_labels.get(k, k)}
148
+ for k in engine.speakers.names
149
+ ],
150
+ "defaults": {
151
+ "temperature": config.defaults.temperature,
152
+ "top_k": config.defaults.top_k,
153
+ "max_frames": config.defaults.max_frames,
154
+ "repetition_penalty": config.defaults.repetition_penalty,
155
+ "repetition_window": config.defaults.repetition_window,
156
+ },
157
+ "limits": {
158
+ "min_max_frames": 43,
159
+ "max_max_frames": 1075,
160
+ "min_repetition_window": 0,
161
+ "max_repetition_window": 128,
162
+ "min_repetition_penalty": 1.0,
163
+ "max_repetition_penalty": 1.5,
164
+ "min_temperature": 0.05,
165
+ "max_temperature": 1.0,
166
+ "min_top_k": 0,
167
+ "max_top_k": 200,
168
+ },
169
+ "examples": [
170
+ {"speaker": ex.speaker, "label": ex.label, "text": ex.text}
171
+ for ex in config.examples
172
+ ],
173
+ "modes": {"preset": MODE_PRESET, "clone": "Clone a voice"},
174
+ }
175
+
176
 
177
  if __name__ == "__main__":
178
+ app.launch(show_error=True)
create_env.py CHANGED
@@ -3,24 +3,66 @@ import subprocess
3
  import sys
4
 
5
  def setup_dependencies():
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6
  os.environ["OMP_NUM_THREADS"] = "4"
 
 
 
 
 
 
7
  try:
8
- if os.path.exists('/tmp/deps_installed'):
9
- return
10
-
11
- # Re-pin transformers on top of whatever NeMo downgraded at build time
12
- # (the Gepard checkpoint needs the Qwen3.5 backbone from 5.x). This
13
- # upgrades huggingface-hub to 1.x — gradio 5.49 accepts <2.0, so the
14
- # UI keeps working. numpy is capped so the force-reinstall does not
15
- # bump it to 2.x under the NeMo stack built against 1.26.
16
- print("Re-pinning transformers==5.3.0 ...")
17
- subprocess.check_call([
18
- sys.executable, "-m", "pip", "install", "--force-reinstall", "--no-cache-dir",
19
- "transformers==5.3.0", "numpy<2.0"
20
  ])
21
-
22
- with open('/tmp/deps_installed', 'w') as f:
23
- f.write('done')
24
-
 
 
 
 
 
 
 
 
 
 
 
25
  except Exception as e:
26
- print(f"Dependencies setup error: {e}")
 
 
3
  import sys
4
 
5
  def setup_dependencies():
6
+ """Install NeMo and pin transformers in the right order to satisfy
7
+ gradio 6.20's huggingface-hub>=1.2 requirement while still ending up
8
+ on transformers 5.3.0 (which the Gepard checkpoint needs).
9
+
10
+ Why not put nemo-toolkit in requirements.txt? NeMo's
11
+ ``nemo-toolkit[tts]==2.4.0`` pulls in ``transformers<=4.52`` (which
12
+ resolves to 4.51.x), and that requires ``huggingface-hub<1.0`` —
13
+ conflicting with gradio 6.20's ``huggingface-hub>=1.2`` at build time
14
+ so pip refuses to resolve. We install nemo-toolkit itself at runtime
15
+ with ``--no-deps`` instead.
16
+
17
+ All of NeMo's OTHER [tts] dependencies (librosa, soundfile, omegaconf,
18
+ matplotlib, seaborn, einops, kornia, lhotse, lightning, peft,
19
+ torchmetrics, datasets, …) are pre-installed at build time from
20
+ requirements.txt — the complete list minus the conflicting
21
+ transformers pin (source: pypi.org/pypi/nemo-toolkit/2.4.0/json).
22
+ So installing nemo with --no-deps doesn't miss anything import-time.
23
+
24
+ Order is load-bearing:
25
+ 1. transformers==5.3.0 with --no-deps — forces the runtime onto the
26
+ Qwen3.5 backbone version the Gepard checkpoint was trained on.
27
+ Combined with the pre-installed hub>=1.2, satisfies gradio 6.20.
28
+ 2. nemo-toolkit[tts]==2.4.0 with --no-deps — installed on top of
29
+ transformers 5.3.0 without re-resolving its declared (conflicting)
30
+ transformers<=4.52 constraint.
31
+ 3. numpy<2.0 — keep the NeMo/codec stack on numpy 1.x.
32
+
33
+ Idempotent: writes a sentinel file so the install only runs once per
34
+ container.
35
+ """
36
  os.environ["OMP_NUM_THREADS"] = "4"
37
+
38
+ sentinel = "/tmp/deps_installed"
39
+ if os.path.exists(sentinel):
40
+ return
41
+
42
+ pip = [sys.executable, "-m", "pip", "install", "--no-cache-dir"]
43
  try:
44
+ # 1. Pin the transformers + hub versions gradio 6.20 needs, before
45
+ # NeMo gets a chance to downgrade them. --no-deps because we
46
+ # already have these libraries installed at compatible versions.
47
+ print("Pinning transformers==5.3.0 + hub>=1.2 (no-deps) ...")
48
+ subprocess.check_call(pip + [
49
+ "--no-deps", "transformers==5.3.0", "huggingface-hub>=1.2,<2.0",
 
 
 
 
 
 
50
  ])
51
+
52
+ # 2. Install NeMo on top without re-resolving dependencies. Its
53
+ # declared transformers<=4.52 / hub<1.0 would conflict; with
54
+ # --no-deps the resolver doesn't see those constraints.
55
+ print("Installing nemo-toolkit[tts]==2.4.0 (no-deps) ...")
56
+ subprocess.check_call(pip + ["--no-deps", "nemo-toolkit[tts]==2.4.0"])
57
+
58
+ # 3. Cap numpy so a transitive bump doesn't break the NeMo/codec
59
+ # stack, which is built against numpy 1.x.
60
+ print("Capping numpy<2.0 ...")
61
+ subprocess.check_call(pip + ["numpy<2.0"])
62
+
63
+ with open(sentinel, "w") as f:
64
+ f.write("done")
65
+
66
  except Exception as e:
67
+ print(f"Dependencies setup error: {e}")
68
+ raise
index.html ADDED
@@ -0,0 +1,1198 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>GEPARD STUDIO</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
8
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
9
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
10
+ <style>
11
+ :root {
12
+ --bg: #fafafa;
13
+ --bg-elev: #ffffff;
14
+ --bg-elev-2: #f5f5f7;
15
+ --bg-input: #ffffff;
16
+ --bg-viewport: #ffffff;
17
+ --line: #e5e5ea;
18
+ --line-strong: #d1d1d6;
19
+ --line-hairline: #ececef;
20
+ --text: #1d1d1f;
21
+ --text-dim: #6e6e73;
22
+ --text-faint: #a1a1a6;
23
+ --text-label: #3a3a3c;
24
+ --accent: #f97316;
25
+ --accent-hover: #ea580c;
26
+ --accent-soft: rgba(249, 115, 22, 0.08);
27
+ --accent-text: #c2410c;
28
+ --red: #dc2626;
29
+ --green: #16a34e;
30
+ --hl-bg: rgba(249, 115, 22, 0.16);
31
+ --hl-text: #c2410c;
32
+ --radius-sm: 6px;
33
+ --radius: 10px;
34
+ --radius-lg: 16px;
35
+ --radius-pill: 999px;
36
+ --shadow-sm: 0 1px 2px rgba(0,0,0,0.04);
37
+ --shadow: 0 1px 3px rgba(0,0,0,0.06), 0 8px 24px rgba(0,0,0,0.04);
38
+ --shadow-lg: 0 4px 12px rgba(0,0,0,0.08), 0 24px 48px rgba(0,0,0,0.06);
39
+ }
40
+ * { box-sizing: border-box; }
41
+ html, body {
42
+ margin: 0; padding: 0;
43
+ background: var(--bg);
44
+ color: var(--text);
45
+ font-family: "Inter", -apple-system, BlinkMacSystemFont, "SF Pro Display", "SF Pro Text", ui-sans-serif, system-ui, sans-serif;
46
+ font-size: 14px;
47
+ line-height: 1.5;
48
+ min-height: 100vh;
49
+ -webkit-font-smoothing: antialiased;
50
+ -moz-osx-font-smoothing: grayscale;
51
+ letter-spacing: -0.005em;
52
+ }
53
+ body { display: flex; flex-direction: column; min-height: 100vh; }
54
+ .mono { font-family: "JetBrains Mono", ui-monospace, "SF Mono", Menlo, monospace; font-variant-numeric: tabular-nums; }
55
+
56
+ /* ===== Top bar ===== */
57
+ .topbar {
58
+ display: flex; align-items: center; justify-content: space-between;
59
+ height: 52px; padding: 0 20px;
60
+ background: rgba(255, 255, 255, 0.8);
61
+ backdrop-filter: saturate(180%) blur(20px);
62
+ -webkit-backdrop-filter: saturate(180%) blur(20px);
63
+ border-bottom: 1px solid var(--line-hairline);
64
+ position: sticky; top: 0; z-index: 10;
65
+ }
66
+ .brand { display: flex; align-items: center; gap: 10px; }
67
+ .brand .logo {
68
+ width: 26px; height: 26px;
69
+ background: linear-gradient(135deg, #fbbf24, #f97316, #dc2626);
70
+ border-radius: 7px;
71
+ display: inline-flex; align-items: center; justify-content: center;
72
+ font-size: 13px; color: #fff; font-weight: 700;
73
+ box-shadow: 0 1px 2px rgba(0,0,0,0.1);
74
+ }
75
+ .brand .name { font-size: 15px; font-weight: 600; letter-spacing: -0.01em; }
76
+ .brand .name .accent {
77
+ background: linear-gradient(90deg, #f97316, #dc2626);
78
+ -webkit-background-clip: text; background-clip: text; color: transparent;
79
+ }
80
+ .project-info {
81
+ display: flex; align-items: center; gap: 12px; margin-left: 18px;
82
+ padding-left: 18px; border-left: 1px solid var(--line-hairline);
83
+ }
84
+ .project-info .name { font-size: 13px; font-weight: 500; color: var(--text-dim); }
85
+ .stage-pill {
86
+ display: inline-flex; align-items: center; gap: 6px;
87
+ padding: 4px 10px;
88
+ background: var(--bg-elev-2);
89
+ border-radius: var(--radius-pill);
90
+ font-size: 11px; font-weight: 500;
91
+ color: var(--text-dim);
92
+ }
93
+ .stage-pill .dot {
94
+ width: 6px; height: 6px; border-radius: 50%;
95
+ background: var(--text-faint);
96
+ }
97
+ .stage-pill .dot.live { background: var(--accent); box-shadow: 0 0 6px rgba(249,115,22,0.5); }
98
+ .stage-pill .dot.playing { background: var(--green); box-shadow: 0 0 6px rgba(22,163,74,0.5); animation: pulse 1.4s ease-in-out infinite; }
99
+ @keyframes pulse { 0%,100%{opacity:1} 50%{opacity:0.4} }
100
+
101
+ /* ===== Viewport ===== */
102
+ .viewport {
103
+ margin: 16px 16px 0;
104
+ background: var(--bg-viewport);
105
+ border: 1px solid var(--line-hairline);
106
+ border-radius: var(--radius-lg);
107
+ box-shadow: var(--shadow);
108
+ overflow: hidden;
109
+ display: flex; flex-direction: column;
110
+ }
111
+ .viewport-head {
112
+ display: flex; align-items: center; justify-content: space-between;
113
+ padding: 12px 18px;
114
+ border-bottom: 1px solid var(--line-hairline);
115
+ }
116
+ .viewport-head .left, .viewport-head .right { display: flex; align-items: center; gap: 12px; }
117
+ .viewport-head .label {
118
+ font-size: 11px; font-weight: 600; letter-spacing: 0.06em; text-transform: uppercase; color: var(--text-dim);
119
+ }
120
+
121
+ /* Lyrics + input combined */
122
+ .stage {
123
+ min-height: 280px;
124
+ background: var(--bg-viewport);
125
+ position: relative;
126
+ }
127
+ .stage .progress {
128
+ position: absolute; top: 0; left: 0; right: 0; height: 2px;
129
+ background: var(--line-hairline);
130
+ z-index: 2;
131
+ }
132
+ .stage .progress .fill {
133
+ height: 100%; width: 0;
134
+ background: linear-gradient(90deg, #fbbf24, #f97316, #dc2626);
135
+ transition: width 0.05s linear;
136
+ }
137
+ .stage .text {
138
+ padding: 56px 40px 48px;
139
+ font-size: 28px;
140
+ font-weight: 400;
141
+ line-height: 1.55;
142
+ color: var(--text);
143
+ letter-spacing: -0.015em;
144
+ max-width: 880px;
145
+ margin: 0 auto;
146
+ text-align: center;
147
+ outline: none;
148
+ min-height: 280px;
149
+ display: flex; align-items: center; justify-content: center;
150
+ flex-wrap: wrap;
151
+ }
152
+ .stage .text:focus { outline: none; }
153
+ .stage .text[data-empty="true"]::before {
154
+ content: attr(data-placeholder);
155
+ color: var(--text-faint);
156
+ font-weight: 300;
157
+ pointer-events: none;
158
+ width: 100%;
159
+ }
160
+ .stage .text .word {
161
+ display: inline-block;
162
+ padding: 2px 4px;
163
+ margin: 0 1px;
164
+ border-radius: 6px;
165
+ color: rgba(29, 29, 31, 0.32);
166
+ transition: color 0.2s ease, background-color 0.2s ease, transform 0.2s ease;
167
+ }
168
+ .stage .text .word.past { color: rgba(29, 29, 31, 0.55); }
169
+ .stage .text .word.current {
170
+ color: var(--hl-text);
171
+ background: var(--hl-bg);
172
+ transform: scale(1.04);
173
+ }
174
+
175
+ /* Transport (compact) */
176
+ .transport {
177
+ display: flex; align-items: center; gap: 14px;
178
+ padding: 10px 18px;
179
+ border-top: 1px solid var(--line-hairline);
180
+ }
181
+ .transport-btns { display: flex; align-items: center; gap: 6px; }
182
+ .transport-btns button {
183
+ appearance: none; border: 0;
184
+ background: transparent; color: var(--text-dim);
185
+ cursor: pointer;
186
+ display: flex; align-items: center; justify-content: center;
187
+ transition: all 0.15s;
188
+ border-radius: var(--radius-sm);
189
+ }
190
+ .transport-btns button:hover:not(:disabled) { color: var(--text); background: var(--bg-elev-2); }
191
+ .transport-btns button:disabled { color: var(--text-faint); cursor: not-allowed; }
192
+ .transport-btns .skip { width: 30px; height: 30px; font-size: 11px; }
193
+ .transport-btns .play {
194
+ width: 36px; height: 36px; font-size: 13px;
195
+ background: var(--text); color: var(--bg-viewport);
196
+ box-shadow: 0 1px 2px rgba(0,0,0,0.1);
197
+ }
198
+ .transport-btns .play:hover:not(:disabled) { background: #000; }
199
+ .transport-btns .play:disabled { background: var(--line); color: var(--text-faint); box-shadow: none; }
200
+ .timecode {
201
+ font-family: "JetBrains Mono", ui-monospace, "SF Mono", Menlo, monospace;
202
+ font-variant-numeric: tabular-nums;
203
+ font-size: 13px; font-weight: 500;
204
+ color: var(--text);
205
+ letter-spacing: 0.02em;
206
+ }
207
+ .timecode .sep { color: var(--text-faint); margin: 0 2px; }
208
+ .timecode .dur { color: var(--text-dim); }
209
+ .transport .spacer { flex: 1; }
210
+ .transport .info {
211
+ font-size: 12px;
212
+ color: var(--text-dim);
213
+ }
214
+ .transport .info .k { color: var(--text-faint); margin-right: 4px; }
215
+ .transport .info .v { color: var(--text); font-weight: 500; }
216
+
217
+ /* ===== Console (single panel) ===== */
218
+ .console {
219
+ margin: 14px 16px 16px;
220
+ background: var(--bg-elev);
221
+ border: 1px solid var(--line-hairline);
222
+ border-radius: var(--radius-lg);
223
+ box-shadow: var(--shadow);
224
+ overflow: hidden;
225
+ }
226
+ .console-body {
227
+ padding: 16px 18px 18px;
228
+ display: flex; flex-direction: column; gap: 14px;
229
+ }
230
+
231
+ /* Row 1: Source + voice + advanced */
232
+ .row-source {
233
+ display: grid;
234
+ grid-template-columns: auto 1fr auto;
235
+ gap: 10px;
236
+ align-items: center;
237
+ }
238
+ .seg {
239
+ display: inline-flex;
240
+ background: var(--bg-elev-2);
241
+ border-radius: var(--radius-sm);
242
+ padding: 2px;
243
+ }
244
+ .seg button {
245
+ appearance: none; border: 0; background: transparent;
246
+ color: var(--text-dim);
247
+ padding: 6px 12px;
248
+ border-radius: 5px;
249
+ font: inherit; font-weight: 500; font-size: 13px;
250
+ cursor: pointer; transition: all 0.15s;
251
+ }
252
+ .seg button.active {
253
+ background: var(--bg-elev);
254
+ color: var(--text);
255
+ box-shadow: 0 1px 2px rgba(0,0,0,0.06), 0 0 0 1px var(--line);
256
+ }
257
+ .seg button:not(.active):hover { color: var(--text); }
258
+ select {
259
+ width: 100%;
260
+ background: var(--bg-elev-2);
261
+ border: 1px solid transparent;
262
+ border-radius: var(--radius-sm);
263
+ color: var(--text);
264
+ font: inherit; font-size: 13px;
265
+ padding: 7px 12px;
266
+ outline: none;
267
+ transition: all 0.15s;
268
+ appearance: none;
269
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%23a1a1a6' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E");
270
+ background-repeat: no-repeat;
271
+ background-position: right 10px center;
272
+ padding-right: 28px;
273
+ }
274
+ select:focus { border-color: var(--line-strong); background-color: var(--bg-elev); }
275
+ .advanced-toggle {
276
+ appearance: none; border: 0; background: transparent;
277
+ color: var(--text-dim); font: inherit; font-size: 12px;
278
+ font-weight: 500;
279
+ padding: 6px 10px;
280
+ cursor: pointer;
281
+ border-radius: var(--radius-sm);
282
+ transition: all 0.15s;
283
+ display: inline-flex; align-items: center; gap: 6px;
284
+ }
285
+ .advanced-toggle:hover { color: var(--text); background: var(--bg-elev-2); }
286
+ .advanced-toggle .chevron {
287
+ width: 10px; height: 10px;
288
+ transition: transform 0.2s;
289
+ }
290
+ .advanced-toggle.open .chevron { transform: rotate(180deg); }
291
+
292
+ /* Ref uploader (compact inline) */
293
+ .ref-inline {
294
+ margin-top: 10px;
295
+ display: grid;
296
+ grid-template-columns: 1fr auto;
297
+ gap: 8px;
298
+ align-items: center;
299
+ }
300
+ .file-drop {
301
+ border: 1px dashed var(--line-strong);
302
+ border-radius: var(--radius-sm);
303
+ padding: 8px 12px;
304
+ text-align: center;
305
+ color: var(--text-dim);
306
+ font-size: 12px;
307
+ cursor: pointer;
308
+ transition: all 0.15s;
309
+ }
310
+ .file-drop:hover, .file-drop.drag { border-color: var(--accent); color: var(--accent-text); background: var(--accent-soft); }
311
+ .file-name { font-size: 11px; color: var(--accent-text); margin-top: 4px; }
312
+
313
+ /* Reference uploader tabs */
314
+ .ref-tabs {
315
+ display: inline-flex;
316
+ background: var(--bg-elev-2);
317
+ border-radius: var(--radius-sm);
318
+ padding: 2px;
319
+ margin-bottom: 8px;
320
+ }
321
+ .ref-tab {
322
+ appearance: none; border: 0; background: transparent;
323
+ color: var(--text-dim);
324
+ padding: 5px 12px;
325
+ border-radius: 5px;
326
+ font: inherit; font-weight: 500; font-size: 12px;
327
+ cursor: pointer; transition: all 0.15s;
328
+ }
329
+ .ref-tab.active {
330
+ background: var(--bg-elev);
331
+ color: var(--text);
332
+ box-shadow: 0 1px 2px rgba(0,0,0,0.06), 0 0 0 1px var(--line);
333
+ }
334
+ .ref-tab:not(.active):hover { color: var(--text); }
335
+ .ref-panel.hidden { display: none; }
336
+
337
+ /* Mic UI */
338
+ .mic-ui {
339
+ display: flex; align-items: center; gap: 12px;
340
+ padding: 12px;
341
+ background: var(--bg-elev-2);
342
+ border-radius: var(--radius-sm);
343
+ }
344
+ .mic-btn {
345
+ appearance: none; border: 0;
346
+ width: 44px; height: 44px;
347
+ border-radius: 50%;
348
+ background: var(--bg-elev);
349
+ color: var(--text-dim);
350
+ cursor: pointer;
351
+ display: flex; align-items: center; justify-content: center;
352
+ border: 1px solid var(--line);
353
+ transition: all 0.15s;
354
+ flex-shrink: 0;
355
+ }
356
+ .mic-btn:hover { color: var(--text); border-color: var(--line-strong); }
357
+ .mic-btn.recording {
358
+ background: var(--red); color: white; border-color: transparent;
359
+ animation: micPulse 1.4s ease-in-out infinite;
360
+ }
361
+ .mic-btn.recording .mic-icon { color: white; }
362
+ .mic-icon { font-size: 14px; color: var(--text-dim); }
363
+ @keyframes micPulse {
364
+ 0%, 100% { box-shadow: 0 0 0 0 rgba(220,38,38,0.4); }
365
+ 50% { box-shadow: 0 0 0 8px rgba(220,38,38,0); }
366
+ }
367
+ .mic-info { flex: 1; min-width: 0; }
368
+ .mic-state { font-size: 12px; color: var(--text-dim); }
369
+ .mic-btn.recording + .mic-info .mic-state { color: var(--red); font-weight: 500; }
370
+ .mic-time { font-size: 14px; color: var(--text); font-weight: 500; margin-top: 2px; }
371
+
372
+ /* Advanced panel (collapsed by default) */
373
+ .advanced {
374
+ overflow: hidden;
375
+ max-height: 0;
376
+ transition: max-height 0.25s ease;
377
+ }
378
+ .advanced.open { max-height: 400px; }
379
+ .advanced-inner {
380
+ padding-top: 12px;
381
+ border-top: 1px solid var(--line-hairline);
382
+ margin-top: 4px;
383
+ }
384
+ .advanced-summary {
385
+ display: grid;
386
+ grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
387
+ gap: 8px 16px;
388
+ margin-bottom: 12px;
389
+ padding: 10px 14px;
390
+ background: var(--bg-elev-2);
391
+ border-radius: var(--radius-sm);
392
+ }
393
+ .advanced-summary .item {
394
+ display: flex; align-items: baseline; gap: 8px;
395
+ font-size: 12px;
396
+ }
397
+ .advanced-summary .item .k {
398
+ color: var(--text-faint);
399
+ text-transform: uppercase;
400
+ letter-spacing: 0.06em;
401
+ font-size: 10px;
402
+ font-weight: 600;
403
+ }
404
+ .advanced-summary .item .v {
405
+ color: var(--text);
406
+ font-family: "JetBrains Mono", ui-monospace, monospace;
407
+ font-size: 12px;
408
+ font-weight: 500;
409
+ }
410
+ .advanced-sliders {
411
+ display: grid;
412
+ grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
413
+ gap: 4px 20px;
414
+ }
415
+ .slider {
416
+ display: grid; grid-template-columns: 1fr auto; align-items: center; gap: 10px; padding: 4px 0;
417
+ }
418
+ .slider .name { font-size: 12px; color: var(--text-dim); }
419
+ .slider .val {
420
+ font-family: "JetBrains Mono", ui-monospace, monospace;
421
+ font-size: 12px; color: var(--text);
422
+ min-width: 48px; text-align: right;
423
+ font-variant-numeric: tabular-nums;
424
+ }
425
+ input[type="range"] {
426
+ -webkit-appearance: none; appearance: none;
427
+ width: 100%; height: 16px; background: transparent; cursor: pointer;
428
+ }
429
+ input[type="range"]::-webkit-slider-runnable-track {
430
+ height: 4px; background: var(--line); border-radius: 2px;
431
+ }
432
+ input[type="range"]::-webkit-slider-thumb {
433
+ -webkit-appearance: none; appearance: none;
434
+ width: 14px; height: 14px; border-radius: 50%;
435
+ background: var(--accent);
436
+ margin-top: -5px;
437
+ box-shadow: 0 0 0 3px var(--bg-elev), 0 1px 3px rgba(0,0,0,0.15);
438
+ cursor: pointer;
439
+ }
440
+ input[type="range"]::-moz-range-track { height: 4px; background: var(--line); border-radius: 2px; }
441
+ input[type="range"]::-moz-range-thumb {
442
+ width: 14px; height: 14px; border: 3px solid var(--bg-elev);
443
+ border-radius: 50%; background: var(--accent);
444
+ box-shadow: 0 1px 3px rgba(0,0,0,0.15);
445
+ cursor: pointer;
446
+ }
447
+
448
+ /* Row 2: Examples + Render (compact) */
449
+ .row-actions {
450
+ display: flex; align-items: center; gap: 14px;
451
+ flex-wrap: wrap;
452
+ }
453
+ .examples {
454
+ display: flex; flex-wrap: wrap; gap: 6px;
455
+ flex: 1; min-width: 0;
456
+ }
457
+ .example-chip {
458
+ appearance: none;
459
+ display: inline-flex; align-items: center; gap: 6px;
460
+ padding: 5px 10px;
461
+ background: var(--bg-elev-2);
462
+ border: 1px solid transparent;
463
+ border-radius: var(--radius-pill);
464
+ color: var(--text-dim);
465
+ font: inherit; font-size: 12px;
466
+ cursor: pointer;
467
+ transition: all 0.15s;
468
+ }
469
+ .example-chip:hover { border-color: var(--line); color: var(--text); background: var(--bg-elev); }
470
+ .example-chip .tag {
471
+ font-weight: 600;
472
+ color: var(--accent-text);
473
+ }
474
+ .example-chip .txt { max-width: 280px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
475
+ .example-chip .status { font-size: 10px; color: var(--text-faint); margin-left: 2px; }
476
+
477
+ /* Compact render button */
478
+ .render-btn {
479
+ appearance: none; border: 0;
480
+ display: inline-flex; align-items: center; gap: 8px;
481
+ padding: 8px 16px;
482
+ border-radius: var(--radius-sm);
483
+ background: var(--text);
484
+ color: var(--bg-elev);
485
+ font: inherit; font-size: 13px; font-weight: 600;
486
+ cursor: pointer;
487
+ box-shadow: 0 1px 2px rgba(0,0,0,0.1);
488
+ transition: all 0.15s;
489
+ flex-shrink: 0;
490
+ }
491
+ .render-btn:hover:not(:disabled) { background: #000; transform: translateY(-1px); box-shadow: 0 4px 12px rgba(0,0,0,0.15); }
492
+ .render-btn:active:not(:disabled) { transform: translateY(0); }
493
+ .render-btn:disabled { background: var(--line); color: var(--text-faint); cursor: not-allowed; box-shadow: none; }
494
+ .render-btn .icon {
495
+ width: 14px; height: 14px;
496
+ display: inline-flex; align-items: center; justify-content: center;
497
+ }
498
+ .render-btn .spinner {
499
+ width: 12px; height: 12px;
500
+ border: 2px solid rgba(255,255,255,0.3);
501
+ border-top-color: currentColor;
502
+ border-radius: 50%;
503
+ animation: spin 0.8s linear infinite;
504
+ }
505
+ @keyframes spin { to { transform: rotate(360deg); } }
506
+
507
+ /* Toast */
508
+ .toast {
509
+ padding: 8px 12px;
510
+ border-radius: var(--radius-sm);
511
+ font-size: 12px;
512
+ background: #fef2f2;
513
+ border: 1px solid #fecaca;
514
+ color: #b91c1c;
515
+ }
516
+ .toast.hidden { display: none; }
517
+
518
+ /* ===== Mobile / Tablet ===== */
519
+ @media (max-width: 900px) {
520
+ .viewport { margin: 12px 12px 0; border-radius: 14px; }
521
+ .viewport-head { padding: 10px 14px; }
522
+ .console { margin: 12px 12px 12px; border-radius: 14px; }
523
+ .advanced-summary { grid-template-columns: repeat(2, 1fr); }
524
+ }
525
+ @media (max-width: 600px) {
526
+ html, body { font-size: 15px; }
527
+ body { padding-bottom: env(safe-area-inset-bottom); }
528
+
529
+ /* Top bar — drop the project info, keep brand + stage pill */
530
+ .topbar { height: 48px; padding: 0 14px; }
531
+ .project-info { display: none; }
532
+
533
+ /* Viewport */
534
+ .viewport { margin: 8px 8px 0; border-radius: 12px; }
535
+ .viewport-head { padding: 10px 14px; }
536
+ .viewport-head .label { font-size: 10px; }
537
+ #voice-info { display: none; }
538
+
539
+ /* Stage — tighter padding, smaller type, more vertical room */
540
+ .stage { min-height: 240px; }
541
+ .stage .text {
542
+ padding: 40px 22px 36px;
543
+ font-size: 22px;
544
+ line-height: 1.5;
545
+ min-height: 240px;
546
+ }
547
+
548
+ /* Transport — full-width layout, info row hidden */
549
+ .transport { padding: 10px 14px; gap: 10px; flex-wrap: wrap; }
550
+ .transport-btns .skip { width: 36px; height: 36px; font-size: 12px; }
551
+ .transport-btns .play { width: 42px; height: 42px; font-size: 14px; }
552
+ .transport .spacer { flex: 1; }
553
+ .transport .info { display: none; }
554
+ .timecode { font-size: 14px; }
555
+ .timecode #dur-text { font-size: 12px; }
556
+
557
+ /* Console */
558
+ .console { margin: 10px 8px 12px; border-radius: 12px; }
559
+ .console-body { padding: 14px; gap: 12px; }
560
+
561
+ /* Source row — stack vertically */
562
+ .row-source {
563
+ grid-template-columns: 1fr;
564
+ gap: 8px;
565
+ }
566
+ .row-source .seg { width: 100%; }
567
+ .row-source .seg button { flex: 1; padding: 8px 12px; }
568
+ .row-source select { width: 100%; padding: 10px 14px; }
569
+ .row-source .advanced-toggle {
570
+ width: 100%;
571
+ justify-content: center;
572
+ padding: 10px 12px;
573
+ background: var(--bg-elev-2);
574
+ border-radius: var(--radius-sm);
575
+ }
576
+
577
+ /* Reference uploader */
578
+ .ref-inline {
579
+ grid-template-columns: 1fr;
580
+ gap: 6px;
581
+ }
582
+ .file-drop { padding: 12px; font-size: 13px; }
583
+
584
+ /* Advanced panel — single column */
585
+ .advanced-summary { grid-template-columns: repeat(2, 1fr); gap: 6px 10px; padding: 10px 12px; }
586
+ .advanced-summary .item { font-size: 11px; }
587
+ .advanced-sliders { grid-template-columns: 1fr; }
588
+
589
+ /* Actions row — examples wrap above, render full-width */
590
+ .row-actions {
591
+ flex-direction: column;
592
+ align-items: stretch;
593
+ gap: 10px;
594
+ }
595
+ .examples { order: 1; }
596
+ .example-chip { padding: 6px 11px; font-size: 12px; }
597
+ .render-btn {
598
+ order: 2;
599
+ width: 100%;
600
+ padding: 12px 16px;
601
+ font-size: 14px;
602
+ justify-content: center;
603
+ }
604
+ .toast { order: 3; }
605
+
606
+ /* Toast — slide up from bottom on mobile for visibility */
607
+ .toast {
608
+ position: fixed;
609
+ left: 12px; right: 12px; bottom: 12px;
610
+ margin: 0;
611
+ padding: 12px 16px;
612
+ border-radius: var(--radius);
613
+ box-shadow: var(--shadow-lg);
614
+ z-index: 20;
615
+ }
616
+ }
617
+ @media (max-width: 380px) {
618
+ .stage .text { font-size: 19px; padding: 32px 16px 28px; }
619
+ .advanced-summary { grid-template-columns: 1fr; }
620
+ }
621
+ </style>
622
+ </head>
623
+ <body>
624
+
625
+ <div class="topbar">
626
+ <div style="display: flex; align-items: center;">
627
+ <div class="brand">
628
+ <span class="logo">G</span>
629
+ <span class="name">GEPARD<span class="accent">.</span></span>
630
+ </div>
631
+ <div class="project-info">
632
+ <span class="name" id="project-name">Session</span>
633
+ </div>
634
+ </div>
635
+ <div class="stage-pill">
636
+ <span class="dot" id="stage-dot"></span>
637
+ <span id="stage-text">Ready</span>
638
+ </div>
639
+ </div>
640
+
641
+ <section class="viewport">
642
+ <div class="viewport-head">
643
+ <div class="left">
644
+ <span class="label">Script · Audio</span>
645
+ </div>
646
+ <div class="right">
647
+ <span class="mono" id="voice-info" style="font-size: 12px; color: var(--text-dim)">—</span>
648
+ </div>
649
+ </div>
650
+
651
+ <div class="stage">
652
+ <div class="progress"><div class="fill" id="progress-fill"></div></div>
653
+ <div class="text" id="text"
654
+ contenteditable="true"
655
+ spellcheck="false"
656
+ data-placeholder="Type or paste your script here, then press Render."
657
+ data-empty="true"></div>
658
+ </div>
659
+
660
+ <div class="transport">
661
+ <div class="transport-btns">
662
+ <button class="skip" id="stop-btn" type="button" title="Return to start" disabled>⏮</button>
663
+ <button class="play" id="play-btn" type="button" title="Play / Pause" disabled>▶</button>
664
+ <button class="skip" id="download-btn" type="button" title="Download WAV" disabled>↓</button>
665
+ </div>
666
+ <div class="timecode mono">
667
+ <span id="tc-s">00</span><span class="sep">:</span><span id="tc-f">00</span>
668
+ <span style="margin-left: 8px; font-size: 11px; color: var(--text-faint)" id="dur-text">0:00</span>
669
+ </div>
670
+ <div class="spacer"></div>
671
+ <div class="info">
672
+ <span class="k">Engine</span><span class="v">Gepard · 32 FSQ heads</span>
673
+ </div>
674
+ </div>
675
+ </section>
676
+
677
+ <section class="console">
678
+ <div class="console-body">
679
+
680
+ <div class="row-source">
681
+ <div class="seg" id="mode-seg">
682
+ <button id="mode-preset" class="active" type="button">Preset</button>
683
+ <button id="mode-clone" type="button">Clone</button>
684
+ </div>
685
+ <select id="speaker"></select>
686
+ <button class="advanced-toggle" id="advanced-toggle" type="button">
687
+ <span>Advanced</span>
688
+ <svg class="chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M6 9l6 6 6-6"/></svg>
689
+ </button>
690
+ </div>
691
+
692
+ <div class="ref-inline hidden" id="ref-section">
693
+ <div class="ref-tabs">
694
+ <button class="ref-tab active" data-tab="upload" type="button">Upload</button>
695
+ <button class="ref-tab" data-tab="mic" type="button">Record</button>
696
+ </div>
697
+ <div class="ref-panel" data-panel="upload">
698
+ <div class="file-drop" id="drop">Drop clip · or click to upload</div>
699
+ <input type="file" id="file-input" accept="audio/*" hidden />
700
+ </div>
701
+ <div class="ref-panel hidden" data-panel="mic">
702
+ <div class="mic-ui" id="mic-ui">
703
+ <button class="mic-btn" id="mic-btn" type="button">
704
+ <span class="mic-icon" id="mic-icon">●</span>
705
+ </button>
706
+ <div class="mic-info">
707
+ <div class="mic-state" id="mic-state">Tap to record · ≤ 60s</div>
708
+ <div class="mic-time mono" id="mic-time">0:00</div>
709
+ </div>
710
+ </div>
711
+ </div>
712
+ <div class="file-name" id="file-name"></div>
713
+ </div>
714
+
715
+ <div class="advanced" id="advanced">
716
+ <div class="advanced-inner">
717
+ <div class="advanced-summary" id="advanced-summary"></div>
718
+ <div class="advanced-sliders" id="sliders"></div>
719
+ </div>
720
+ </div>
721
+
722
+ <div class="row-actions">
723
+ <div class="examples" id="examples"></div>
724
+ <button class="render-btn" id="render-btn" type="button">
725
+ <span class="icon" id="render-icon">▶</span>
726
+ <span id="render-label">Render</span>
727
+ </button>
728
+ </div>
729
+
730
+ <div class="toast hidden" id="toast"></div>
731
+
732
+ </div>
733
+ </section>
734
+
735
+ <script type="module">
736
+ import { Client, handle_file } from "https://cdn.jsdelivr.net/npm/@gradio/client/dist/index.min.js";
737
+
738
+ const $ = (id) => document.getElementById(id);
739
+ const els = {
740
+ stageDot: $("stage-dot"), stageText: $("stage-text"),
741
+ text: $("text"), progressFill: $("progress-fill"),
742
+ voiceInfo: $("voice-info"),
743
+ playBtn: $("play-btn"), stopBtn: $("stop-btn"),
744
+ tcS: $("tc-s"), tcF: $("tc-f"), durText: $("dur-text"),
745
+ modePreset: $("mode-preset"), modeClone: $("mode-clone"),
746
+ speaker: $("speaker"),
747
+ advancedToggle: $("advanced-toggle"), advanced: $("advanced"),
748
+ advancedSummary: $("advanced-summary"),
749
+ refSection: $("ref-section"),
750
+ drop: $("drop"), fileInput: $("file-input"), fileName: $("file-name"),
751
+ micBtn: $("mic-btn"), micIcon: $("mic-icon"), micState: $("mic-state"), micTime: $("mic-time"),
752
+ sliders: $("sliders"),
753
+ examples: $("examples"),
754
+ renderBtn: $("render-btn"), renderIcon: $("render-icon"), renderLabel: $("render-label"),
755
+ toast: $("toast"),
756
+ downloadBtn: $("download-btn"),
757
+ };
758
+
759
+ let cfg = null;
760
+ let client = null;
761
+ let mode = "preset";
762
+ let refFile = null;
763
+ let audioEl = new Audio();
764
+ let currentSubmission = null;
765
+
766
+ let wordSpans = [];
767
+ let hlRAF = null;
768
+ let isPlaying = false;
769
+
770
+ const sliderSpec = [
771
+ { key: "temperature", label: "Temperature", fmt: v => Number(v).toFixed(2) },
772
+ { key: "top_k", label: "Top-K", fmt: v => String(Math.round(v)) },
773
+ { key: "max_frames", label: "Max Frames", fmt: v => String(Math.round(v)) },
774
+ { key: "repetition_penalty", label: "Rep. Penalty", fmt: v => Number(v).toFixed(2) },
775
+ { key: "repetition_window", label: "Rep. Window", fmt: v => String(Math.round(v)) },
776
+ ];
777
+
778
+ const pad = (n, w = 2) => String(Math.floor(n)).padStart(w, "0");
779
+ const fmtTime = (s) => { if (!isFinite(s)) return "0:00"; const m = Math.floor(s / 60); const r = Math.floor(s % 60); return `${m}:${pad(s%60)}`; };
780
+
781
+ const setStage = (state, text) => {
782
+ els.stageDot.classList.remove("live", "playing");
783
+ if (state) els.stageDot.classList.add(state);
784
+ els.stageText.textContent = text;
785
+ };
786
+ const setBusy = (busy) => {
787
+ els.renderBtn.disabled = busy;
788
+ if (busy) {
789
+ els.renderIcon.innerHTML = '<span class="spinner"></span>';
790
+ els.renderLabel.textContent = "Rendering";
791
+ } else {
792
+ els.renderIcon.textContent = "▶";
793
+ els.renderLabel.textContent = "Render";
794
+ }
795
+ };
796
+ const showError = (msg) => { els.toast.textContent = msg; els.toast.classList.remove("hidden"); };
797
+ const clearError = () => { els.toast.classList.add("hidden"); els.toast.textContent = ""; };
798
+
799
+ // ---------- Mode ----------
800
+ const setMode = (m) => {
801
+ mode = m;
802
+ els.modePreset.classList.toggle("active", m === "preset");
803
+ els.modeClone.classList.toggle("active", m !== "preset");
804
+ els.refSection.classList.toggle("hidden", m !== "clone");
805
+ };
806
+ els.modePreset.addEventListener("click", () => setMode("preset"));
807
+ els.modeClone.addEventListener("click", () => setMode("clone"));
808
+
809
+ // ---------- Reference: upload tabs ----------
810
+ // Tab switching
811
+ document.querySelectorAll(".ref-tab").forEach(tab => {
812
+ tab.addEventListener("click", () => {
813
+ document.querySelectorAll(".ref-tab").forEach(t => t.classList.remove("active"));
814
+ tab.classList.add("active");
815
+ const target = tab.dataset.tab;
816
+ document.querySelectorAll(".ref-panel").forEach(p => {
817
+ p.classList.toggle("hidden", p.dataset.panel !== target);
818
+ });
819
+ });
820
+ });
821
+
822
+ // File drop / upload
823
+ els.drop.addEventListener("click", () => els.fileInput.click());
824
+ els.fileInput.addEventListener("change", (e) => { const f = e.target.files[0]; if (f) setRefFile(f); });
825
+ ["dragenter", "dragover"].forEach(ev => els.drop.addEventListener(ev, (e) => { e.preventDefault(); els.drop.classList.add("drag"); }));
826
+ ["dragleave", "drop"].forEach(ev => els.drop.addEventListener(ev, (e) => { e.preventDefault(); els.drop.classList.remove("drag"); }));
827
+ els.drop.addEventListener("drop", (e) => { const f = e.dataTransfer.files[0]; if (f) setRefFile(f); });
828
+ const setRefFile = (f) => { refFile = f; els.fileName.textContent = `✓ ${f.name}`; };
829
+
830
+ // ---------- Microphone recording (MediaRecorder API) ----------
831
+ let mediaRecorder = null;
832
+ let micStream = null;
833
+ let micChunks = [];
834
+ let micStartedAt = 0;
835
+ let micTimer = null;
836
+
837
+ const stopMic = () => {
838
+ if (mediaRecorder && mediaRecorder.state === "recording") {
839
+ mediaRecorder.stop();
840
+ }
841
+ if (micStream) {
842
+ micStream.getTracks().forEach(t => t.stop());
843
+ micStream = null;
844
+ }
845
+ if (micTimer) { clearInterval(micTimer); micTimer = null; }
846
+ els.micBtn.classList.remove("recording");
847
+ };
848
+
849
+ const startMic = async () => {
850
+ try {
851
+ micStream = await navigator.mediaDevices.getUserMedia({ audio: true });
852
+ } catch (err) {
853
+ showError(`Microphone access denied: ${err.message}`);
854
+ return;
855
+ }
856
+ micChunks = [];
857
+ mediaRecorder = new MediaRecorder(micStream);
858
+ mediaRecorder.ondataavailable = (e) => { if (e.data.size) micChunks.push(e.data); };
859
+ mediaRecorder.onstop = () => {
860
+ const blob = new Blob(micChunks, { type: "audio/webm" });
861
+ const file = new File([blob], `recording-${Date.now()}.webm`, { type: "audio/webm" });
862
+ setRefFile(file);
863
+ els.micState.textContent = "Recorded · ready to render";
864
+ };
865
+ mediaRecorder.start();
866
+ micStartedAt = Date.now();
867
+ els.micBtn.classList.add("recording");
868
+ els.micState.textContent = "Recording · tap to stop";
869
+ els.micTime.textContent = "0:00";
870
+ micTimer = setInterval(() => {
871
+ const elapsed = (Date.now() - micStartedAt) / 1000;
872
+ const max = cfg?.max_ref_seconds || 60;
873
+ if (elapsed >= max) { stopMic(); return; }
874
+ const m = Math.floor(elapsed / 60);
875
+ const s = Math.floor(elapsed % 60);
876
+ els.micTime.textContent = `${m}:${String(s).padStart(2, "0")}`;
877
+ }, 200);
878
+ };
879
+
880
+ els.micBtn.addEventListener("click", () => {
881
+ if (els.micBtn.classList.contains("recording")) stopMic();
882
+ else startMic();
883
+ });
884
+
885
+ // ---------- Advanced toggle ----------
886
+ els.advancedToggle.addEventListener("click", () => {
887
+ els.advancedToggle.classList.toggle("open");
888
+ els.advanced.classList.toggle("open");
889
+ });
890
+
891
+ // ---------- Script editor (combined input + lyrics) ----------
892
+ // Two modes:
893
+ // - idle (no audio loaded): editable contenteditable, shows placeholder
894
+ // - playing: highlights current word; clicking back into edit clears highlights
895
+ const updatePlaceholder = () => {
896
+ const txt = els.text.innerText.trim();
897
+ els.text.setAttribute("data-empty", txt.length === 0 ? "true" : "false");
898
+ };
899
+ els.text.addEventListener("input", () => {
900
+ updatePlaceholder();
901
+ clearHighlight();
902
+ });
903
+ els.text.addEventListener("focus", () => {
904
+ clearHighlight();
905
+ isPlaying = false;
906
+ });
907
+ els.text.addEventListener("blur", () => {
908
+ updatePlaceholder();
909
+ });
910
+ const scriptText = () => {
911
+ const t = els.text.innerText.trim();
912
+ return t.length === 0 ? "" : t;
913
+ };
914
+
915
+ // Tokenize the current text into word spans for highlighting
916
+ const tokenize = (text) => {
917
+ els.text.innerHTML = "";
918
+ wordSpans = [];
919
+ const tokens = text.split(/(\s+)/);
920
+ for (const tok of tokens) {
921
+ if (/^\s+$/.test(tok)) {
922
+ els.text.appendChild(document.createTextNode(tok));
923
+ } else if (tok.length) {
924
+ const span = document.createElement("span");
925
+ span.className = "word";
926
+ span.textContent = tok;
927
+ els.text.appendChild(span);
928
+ wordSpans.push(span);
929
+ }
930
+ }
931
+ updatePlaceholder();
932
+ };
933
+ const clearHighlight = () => {
934
+ wordSpans.forEach(s => s.classList.remove("current", "past"));
935
+ };
936
+ const setCurrentWord = (idx) => {
937
+ for (let i = 0; i < wordSpans.length; i++) {
938
+ const s = wordSpans[i];
939
+ s.classList.remove("current");
940
+ if (i < idx) s.classList.add("past");
941
+ else s.classList.remove("past");
942
+ }
943
+ if (idx >= 0 && idx < wordSpans.length) {
944
+ const cur = wordSpans[idx];
945
+ cur.classList.add("current");
946
+ cur.classList.remove("past");
947
+ }
948
+ };
949
+
950
+ // ---------- Sliders + summary ----------
951
+ const buildSliders = () => {
952
+ els.sliders.innerHTML = "";
953
+ els.advancedSummary.innerHTML = "";
954
+ sliderSpec.forEach(spec => {
955
+ const min = cfg.limits[`min_${spec.key}`];
956
+ const max = cfg.limits[`max_${spec.key}`];
957
+ const def = cfg.defaults[spec.key];
958
+ const step = spec.key === "temperature" || spec.key === "repetition_penalty" ? 0.01 : 1;
959
+
960
+ // Slider row
961
+ const row = document.createElement("div"); row.className = "slider";
962
+ row.innerHTML = `<span class="name">${spec.label}</span><span class="val mono" id="sv-${spec.key}">${spec.fmt(def)}</span><input type="range" id="sl-${spec.key}" min="${min}" max="${max}" step="${step}" value="${def}" style="grid-column: 1 / -1" />`;
963
+ els.sliders.appendChild(row);
964
+ const input = row.querySelector("input"), out = row.querySelector(".val");
965
+ input.addEventListener("input", () => {
966
+ out.textContent = spec.fmt(input.value);
967
+ updateSummary();
968
+ });
969
+
970
+ // Summary chip
971
+ const item = document.createElement("div"); item.className = "item";
972
+ item.innerHTML = `<span class="k">${spec.label}</span><span class="v" id="sum-${spec.key}">${spec.fmt(def)}</span>`;
973
+ els.advancedSummary.appendChild(item);
974
+ });
975
+ };
976
+ const updateSummary = () => {
977
+ sliderSpec.forEach(spec => {
978
+ const out = $(`sum-${spec.key}`);
979
+ if (out) out.textContent = spec.fmt(sliderValue(spec.key));
980
+ });
981
+ };
982
+ const sliderValue = (key) => Number($(`sl-${key}`).value);
983
+
984
+ // ---------- Player ----------
985
+ const loadAudio = (url, script, filename = "gepard.wav") => {
986
+ audioEl = new Audio(url);
987
+ audioEl.preload = "auto";
988
+ audioEl._downloadName = filename;
989
+ els.downloadBtn.disabled = false;
990
+ els.downloadBtn.onclick = () => {
991
+ const a = document.createElement("a");
992
+ a.href = url;
993
+ a.download = filename;
994
+ a.click();
995
+ };
996
+ audioEl.addEventListener("loadedmetadata", () => {
997
+ els.durText.textContent = fmtTime(audioEl.duration);
998
+ els.playBtn.disabled = false;
999
+ els.stopBtn.disabled = false;
1000
+ setStage("", "Ready");
1001
+ tokenize(script);
1002
+ clearHighlight();
1003
+ const voiceLabel = els.speaker.options[els.speaker.selectedIndex]?.textContent || "Cloned";
1004
+ els.voiceInfo.textContent = voiceLabel;
1005
+ });
1006
+ audioEl.addEventListener("timeupdate", () => {
1007
+ const t = audioEl.currentTime;
1008
+ const d = audioEl.duration;
1009
+ const fps = 24;
1010
+ els.tcS.textContent = pad(t);
1011
+ els.tcF.textContent = pad((t - Math.floor(t)) * fps);
1012
+ const pct = d ? (t / d) * 100 : 0;
1013
+ els.progressFill.style.width = `${pct}%`;
1014
+ });
1015
+ audioEl.addEventListener("play", () => {
1016
+ els.playBtn.textContent = "❚❚";
1017
+ isPlaying = true;
1018
+ setStage("playing", "Playing");
1019
+ });
1020
+ audioEl.addEventListener("pause", () => {
1021
+ els.playBtn.textContent = "▶";
1022
+ isPlaying = false;
1023
+ setStage("", "Paused");
1024
+ });
1025
+ audioEl.addEventListener("ended", () => {
1026
+ els.playBtn.textContent = "▶";
1027
+ isPlaying = false;
1028
+ clearHighlight();
1029
+ setStage("", "Ready");
1030
+ });
1031
+ };
1032
+ els.playBtn.addEventListener("click", () => {
1033
+ if (!audioEl.src) return;
1034
+ if (audioEl.paused) audioEl.play();
1035
+ else audioEl.pause();
1036
+ });
1037
+ els.stopBtn.addEventListener("click", () => {
1038
+ if (!audioEl.src) return;
1039
+ audioEl.pause();
1040
+ audioEl.currentTime = 0;
1041
+ clearHighlight();
1042
+ });
1043
+ els.text.addEventListener("click", () => {
1044
+ // Allow normal editing on click
1045
+ });
1046
+
1047
+ // Highlight loop (runs always, no-ops without audio)
1048
+ const highlightLoop = () => {
1049
+ if (isPlaying && audioEl.duration && wordSpans.length) {
1050
+ const ratio = Math.min(1, audioEl.currentTime / audioEl.duration);
1051
+ const idx = Math.min(wordSpans.length - 1, Math.floor(ratio * wordSpans.length));
1052
+ setCurrentWord(idx);
1053
+ }
1054
+ hlRAF = requestAnimationFrame(highlightLoop);
1055
+ };
1056
+
1057
+ // ---------- Examples ----------
1058
+ // Cache: example_label -> { url, blob, script, speaker, filename }
1059
+ const exampleCache = new Map();
1060
+
1061
+ const renderExamples = (items) => {
1062
+ els.examples.innerHTML = "";
1063
+ items.forEach(ex => {
1064
+ const chip = document.createElement("button");
1065
+ chip.type = "button"; chip.className = "example-chip";
1066
+ chip.innerHTML = `<span class="tag">${ex.label}</span><span class="txt">${ex.text.slice(0,50)}${ex.text.length > 50 ? "…" : ""}</span><span class="status" data-status="${ex.label}"></span>`;
1067
+ chip.addEventListener("click", async () => {
1068
+ // Always pre-fill the script so the user can edit it
1069
+ tokenize(ex.text);
1070
+ els.text.focus();
1071
+ const range = document.createRange();
1072
+ range.selectNodeContents(els.text);
1073
+ range.collapse(false);
1074
+ const sel = window.getSelection();
1075
+ sel.removeAllRanges(); sel.addRange(range);
1076
+ setMode("preset");
1077
+ els.speaker.value = ex.speaker;
1078
+
1079
+ const cached = exampleCache.get(ex.label);
1080
+ if (cached) {
1081
+ // Play the cached audio
1082
+ loadAudio(cached.url, cached.script, cached.filename);
1083
+ audioEl.addEventListener("loadedmetadata", () => audioEl.play(), { once: true });
1084
+ } else {
1085
+ // Fallback: synthesize now (slow first time, then becomes cached)
1086
+ await synthesizeForExample(ex);
1087
+ }
1088
+ });
1089
+ els.examples.appendChild(chip);
1090
+ });
1091
+ };
1092
+
1093
+ const synthesizeForExample = async (ex) => {
1094
+ const args = {
1095
+ mode: cfg.modes.preset,
1096
+ speaker: ex.speaker,
1097
+ ref_audio: null,
1098
+ text: ex.text,
1099
+ temperature: sliderValue("temperature"),
1100
+ top_k: sliderValue("top_k"),
1101
+ max_frames: sliderValue("max_frames"),
1102
+ repetition_penalty: sliderValue("repetition_penalty"),
1103
+ repetition_window: sliderValue("repetition_window"),
1104
+ };
1105
+ const statusEl = document.querySelector(`[data-status="${ex.label}"]`);
1106
+ if (statusEl) statusEl.textContent = " · …";
1107
+ try {
1108
+ const sub = await client.submit("/synthesize", args);
1109
+ for await (const event of sub) {
1110
+ if (event.type === "data") {
1111
+ const data = event.data?.[0];
1112
+ if (data?.url) {
1113
+ const resp = await fetch(data.url); const blob = await resp.blob();
1114
+ const url = URL.createObjectURL(blob);
1115
+ const filename = `gepard-${ex.speaker}-${ex.label.toLowerCase().replace(/\s+/g, "-")}.wav`;
1116
+ exampleCache.set(ex.label, { url, blob, script: ex.text, speaker: ex.speaker, filename });
1117
+ if (statusEl) statusEl.textContent = " · ✓";
1118
+ loadAudio(url, ex.text, filename);
1119
+ audioEl.addEventListener("loadedmetadata", () => audioEl.play(), { once: true });
1120
+ }
1121
+ }
1122
+ }
1123
+ } catch (err) {
1124
+ if (statusEl) statusEl.textContent = " · !";
1125
+ console.error(err);
1126
+ }
1127
+ };
1128
+
1129
+ // ---------- Submit ----------
1130
+ els.renderBtn.addEventListener("click", async () => {
1131
+ clearError();
1132
+ const text = scriptText();
1133
+ if (!text) { showError("Add some text to your script first."); els.text.focus(); return; }
1134
+ if (mode === "preset" && !els.speaker.value) { showError("Choose a voice first."); return; }
1135
+ if (mode === "clone" && !refFile) { showError("Upload a reference clip first."); return; }
1136
+ const args = {
1137
+ mode: mode === "preset" ? cfg.modes.preset : cfg.modes.clone,
1138
+ speaker: mode === "preset" ? els.speaker.value : "",
1139
+ ref_audio: mode === "clone" ? handle_file(refFile) : null,
1140
+ text,
1141
+ temperature: sliderValue("temperature"),
1142
+ top_k: sliderValue("top_k"),
1143
+ max_frames: sliderValue("max_frames"),
1144
+ repetition_penalty: sliderValue("repetition_penalty"),
1145
+ repetition_window: sliderValue("repetition_window"),
1146
+ };
1147
+ setBusy(true);
1148
+ setStage("live", "Rendering");
1149
+ try {
1150
+ currentSubmission = await client.submit("/synthesize", args);
1151
+ for await (const event of currentSubmission) {
1152
+ if (event.type === "data") {
1153
+ const data = event.data?.[0];
1154
+ if (data?.url) {
1155
+ const resp = await fetch(data.url); const blob = await resp.blob();
1156
+ loadAudio(URL.createObjectURL(blob), text);
1157
+ // Auto-play once loaded
1158
+ audioEl.addEventListener("loadedmetadata", () => audioEl.play(), { once: true });
1159
+ }
1160
+ }
1161
+ }
1162
+ } catch (err) {
1163
+ console.error(err); showError(err?.message || String(err));
1164
+ setStage("", "Error");
1165
+ } finally {
1166
+ setBusy(false); currentSubmission = null;
1167
+ }
1168
+ });
1169
+
1170
+ // Enter to render when not editing text
1171
+ els.text.addEventListener("keydown", (e) => {
1172
+ if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
1173
+ e.preventDefault();
1174
+ els.renderBtn.click();
1175
+ }
1176
+ });
1177
+
1178
+ // ---------- Boot ----------
1179
+ (async () => {
1180
+ cfg = await fetch("/config.json").then(r => r.json());
1181
+ cfg.speakers.forEach(s => { const o = document.createElement("option"); o.value = s.key; o.textContent = s.label; els.speaker.appendChild(o); });
1182
+ buildSliders();
1183
+ renderExamples(cfg.examples);
1184
+ client = await Client.connect(window.location.origin);
1185
+ highlightLoop();
1186
+
1187
+ // Lazy cache: warm the first 3 examples in the background so first
1188
+ // click is instant. Sequential to avoid GPU contention; failures are
1189
+ // silent (next click will retry).
1190
+ (async () => {
1191
+ for (const ex of cfg.examples.slice(0, 3)) {
1192
+ try { await synthesizeForExample(ex); } catch (e) { /* skip */ }
1193
+ }
1194
+ })();
1195
+ })();
1196
+ </script>
1197
+ </body>
1198
+ </html>
requirements.txt CHANGED
@@ -1,14 +1,90 @@
1
  # GEPARD Space dependencies.
2
  #
3
  # torch is provided by the Space image — do NOT pin it here.
4
- # huggingface-hub is deliberately NOT listed: at build time the resolver picks
5
- # a 0.x hub (required by NeMo's transformers<=4.52 pin); at app startup
6
- # create_env.py force-reinstalls transformers==5.3.0, which upgrades hub to
7
- # 1.x still within gradio 5.49's accepted range (>=0.33.5,<2.0).
8
- nemo-toolkit[tts]==2.4.0
 
 
 
 
 
 
 
 
 
9
  safetensors
10
- librosa>=0.10.0
11
  soundfile
12
- omegaconf
13
  pyyaml
14
- numpy
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  # GEPARD Space dependencies.
2
  #
3
  # torch is provided by the Space image — do NOT pin it here.
4
+ # gradio is pinned via sdk_version in README.md (the Dockerfile installs it
5
+ # with the [oauth,mcp] extras). Don't re-pin it here.
6
+ #
7
+ # Why does nemo-toolkit[tts]==2.4.0 itself NOT appear here? Its
8
+ # `transformers<=4.52,>=4.51` constraint pulls in transformers 4.51.x which
9
+ # requires huggingface-hub<1.0 — conflicting with gradio 6.20's
10
+ # hub>=1.2 at build-time resolution. Install nemo itself in create_env.py
11
+ # with `--no-deps` at runtime, AFTER pinning transformers==5.3.0.
12
+ #
13
+ # Everything ELSE NeMo's [tts] extra needs IS listed below, pre-installed
14
+ # at build time — this is the full nemo-toolkit[tts]==2.4.0 dependency
15
+ # graph minus nemo-toolkit itself and the conflicting `transformers` pin.
16
+ # That avoids the ImportError whack-a-mole that `--no-deps` nemo caused.
17
+ # (Source: pypi.org/pypi/nemo-toolkit/2.4.0/json, `tts` extra.)
18
  safetensors
19
+ librosa>=0.10.1
20
  soundfile
 
21
  pyyaml
22
+ numpy<2.0
23
+ scipy>=0.14
24
+ ruamel.yaml
25
+ omegaconf<=2.3
26
+ webdataset>=0.2.86
27
+ pandas
28
+ tqdm>=4.41.0
29
+ wget
30
+ wrapt
31
+ numba
32
+ fsspec==2024.12.0
33
+ python-dateutil
34
+ text-unidecode
35
+ scikit-learn
36
+ tensorboard
37
+ onnx>=1.7.0
38
+ protobuf~=5.29.5
39
+ setuptools>=70.0.0
40
+ einops
41
+ kornia
42
+ matplotlib
43
+ seaborn
44
+ janome
45
+ jieba
46
+ pypinyin
47
+ pypinyin-dict
48
+ attrdict
49
+ cdifflib==1.2.6
50
+ nltk
51
+ braceexpand
52
+ editdistance
53
+ jiwer<4.0.0,>=3.1.0
54
+ kaldi-python-io
55
+ lhotse!=1.31.0
56
+ marshmallow
57
+ optuna
58
+ packaging
59
+ pyannote.core
60
+ pyannote.metrics
61
+ pydub
62
+ pyloudnorm
63
+ resampy
64
+ texterrors<1.0.0
65
+ whisper_normalizer
66
+ num2words
67
+ inflect
68
+ mediapy==1.1.6
69
+ sacremoses>=0.0.43
70
+ sentencepiece<1.0.0
71
+ cloudpickle
72
+ fiddle
73
+ hydra-core<=1.3.2,>1.3
74
+ lightning<=2.4.0,>2.2.1
75
+ peft
76
+ torchmetrics>=0.11.0
77
+ wandb
78
+ datasets
79
+ # Remaining [tts]-extra deps that are platform-conditional (linux x86_64 —
80
+ # which is what HF Spaces runs on).
81
+ bitsandbytes==0.45.5
82
+ sox<=1.5.0
83
+ # NOTE: nemo_text_processing is also part of the [tts] extra on linux x86_64,
84
+ # but it pulls pynini==2.1.6.post1 (a heavy C++ build). Not pre-installed —
85
+ # add it only if the runtime hits an ImportError naming it.
86
+ # transformers 5.x runtime imports (transformers itself is installed at
87
+ # runtime in create_env.py due to the NeMo conflict above; these are its
88
+ # runtime deps that --no-deps skips).
89
+ regex
90
+ tokenizers>=0.22.0,<=0.23.0 # pinned to transformers 5.3.0's accepted range