Spaces:
Sleeping
Sleeping
Build-Small submission: field-guide UI + Modal backend
Browse files- README.md +39 -5
- app.py +179 -0
- praxy_router.py +227 -0
- requirements.txt +5 -0
- theme.py +163 -0
README.md
CHANGED
|
@@ -1,13 +1,47 @@
|
|
| 1 |
---
|
| 2 |
title: Polyglot Me
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
colorTo: pink
|
| 6 |
sdk: gradio
|
| 7 |
-
sdk_version: 6.18.0
|
| 8 |
-
python_version: '3.13'
|
| 9 |
app_file: app.py
|
| 10 |
pinned: false
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
---
|
| 12 |
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
title: Polyglot Me
|
| 3 |
+
emoji: 🎙️
|
| 4 |
+
colorFrom: indigo
|
| 5 |
colorTo: pink
|
| 6 |
sdk: gradio
|
|
|
|
|
|
|
| 7 |
app_file: app.py
|
| 8 |
pinned: false
|
| 9 |
+
license: mit
|
| 10 |
+
tags:
|
| 11 |
+
- build-small-hackathon
|
| 12 |
+
- openbmb
|
| 13 |
+
- voice-cloning
|
| 14 |
+
- tts
|
| 15 |
+
- indic
|
| 16 |
+
- thousand-token-wood
|
| 17 |
+
- multilingual
|
| 18 |
---
|
| 19 |
|
| 20 |
+
# 🎙️ Polyglot Me
|
| 21 |
+
|
| 22 |
+
Record ~10 seconds of your voice, type a line, and hear **yourself** say it in
|
| 23 |
+
**English, Hindi, Telugu, and Tamil** — one cloned voice, every language.
|
| 24 |
+
|
| 25 |
+
## Build approach
|
| 26 |
+
|
| 27 |
+
Two models, zero configuration:
|
| 28 |
+
|
| 29 |
+
1. **Sarvam-Translate** turns your English sentence into Hindi, Telugu, and Tamil
|
| 30 |
+
in a single API call.
|
| 31 |
+
2. **VoxCPM2 (OpenBMB)** clones your voice from the 10-second reference clip and
|
| 32 |
+
generates each language independently. The voice is consistent across all four.
|
| 33 |
+
|
| 34 |
+
All GPU work runs on **Modal** (serverless); this Space is CPU-only and calls Modal via
|
| 35 |
+
`modal.Cls.from_name`. Output includes per-language audio players, a shareable waveform
|
| 36 |
+
card, and a ready-to-post social caption.
|
| 37 |
+
|
| 38 |
+
## Models (all ≤32B)
|
| 39 |
+
|
| 40 |
+
| Role | Model | Params |
|
| 41 |
+
|---|---|---|
|
| 42 |
+
| Voice | [VoxCPM2](https://huggingface.co/openbmb/VoxCPM2) (OpenBMB) | — |
|
| 43 |
+
| Praxy TTS base | [Praxel/praxy-voice-r6](https://huggingface.co/Praxel/praxy-voice-r6) | 544M |
|
| 44 |
+
|
| 45 |
+
> ⏳ First run takes ~2 min while models warm up on Modal. Subsequent runs are fast.
|
| 46 |
+
|
| 47 |
+
Built for the Hugging Face **Build Small** hackathon — *Thousand Token Wood* · OpenBMB.
|
app.py
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Polyglot Me — HF Space for App 2 (Thousand Token Wood).
|
| 2 |
+
|
| 3 |
+
Record ~10s of your voice, type a line, hear yourself across English / Hindi /
|
| 4 |
+
Telugu / Tamil. VoxCPM2 (OpenBMB) clones your voice for every language; Sarvam
|
| 5 |
+
translates. All on Modal; this Space is CPU-only.
|
| 6 |
+
|
| 7 |
+
Hackathon: Thousand Token Wood · OpenBMB · Modal.
|
| 8 |
+
"""
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
import io
|
| 11 |
+
import gradio as gr
|
| 12 |
+
import matplotlib
|
| 13 |
+
import modal
|
| 14 |
+
import numpy as np
|
| 15 |
+
import soundfile as sf
|
| 16 |
+
from theme import build_css
|
| 17 |
+
|
| 18 |
+
matplotlib.use("Agg")
|
| 19 |
+
import matplotlib.pyplot as plt # noqa: E402
|
| 20 |
+
|
| 21 |
+
MODAL_APP = "praxy-voice"
|
| 22 |
+
LANGS = ["en", "hi", "te", "ta"]
|
| 23 |
+
LANG_NAMES = {"en": "English", "hi": "Hindi", "te": "Telugu", "ta": "Tamil"}
|
| 24 |
+
LANG_SCRIPT = {"en": "Aa", "hi": "हि", "te": "తె", "ta": "த"}
|
| 25 |
+
LANG_LABEL = {"en": "LATIN", "hi": "DEVANAGARI", "te": "TELUGU", "ta": "TAMIL"}
|
| 26 |
+
# Header colours: WCAG-AA verified with white text (≥5:1). Jewel tones on dark.
|
| 27 |
+
LANG_HEADER = {"en": "#3B5998", "hi": "#C2410C", "te": "#0F766E", "ta": "#991B1B"}
|
| 28 |
+
# Brighter accents for the matplotlib share-card waveforms (graphics, not text).
|
| 29 |
+
LANG_WAVE = {"en": "#8B7FF9", "hi": "#FF7A7A", "te": "#4FC878", "ta": "#5BB8F2"}
|
| 30 |
+
|
| 31 |
+
_TRANSLATOR = modal.Cls.from_name(MODAL_APP, "SarvamTranslator")
|
| 32 |
+
_VOX = modal.Cls.from_name(MODAL_APP, "VoxCPM2Cloner")
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def _make_card(lines: dict, wav_paths: dict) -> str:
|
| 36 |
+
order = ["English", "Hindi", "Telugu", "Tamil"]
|
| 37 |
+
wave = {LANG_NAMES[l]: LANG_WAVE[l] for l in LANGS}
|
| 38 |
+
fig = plt.figure(figsize=(10, 6.5), facecolor="#0E1020")
|
| 39 |
+
fig.text(.5, .965, "Polyglot Me", ha="center", va="top", color="white",
|
| 40 |
+
fontsize=23, fontweight="bold")
|
| 41 |
+
fig.text(.5, .905, "one voice · four languages", ha="center", va="top",
|
| 42 |
+
color="#8E8FB4", fontsize=12)
|
| 43 |
+
for i, lang in enumerate(order):
|
| 44 |
+
ax = fig.add_subplot(4, 1, i + 1); ax.set_facecolor("#0E1020")
|
| 45 |
+
c = wave[lang]; path = wav_paths.get(lang)
|
| 46 |
+
if path:
|
| 47 |
+
arr, _ = sf.read(path, dtype="float32")
|
| 48 |
+
xs = np.linspace(0, 1, len(arr))
|
| 49 |
+
ax.fill_between(xs, arr, alpha=.4, color=c); ax.plot(xs, arr, color=c, lw=.6, alpha=.9)
|
| 50 |
+
ax.set_xlim(0, 1)
|
| 51 |
+
ax.text(-.01, .5, lang, transform=ax.transAxes, ha="right", va="center",
|
| 52 |
+
color=c, fontsize=11, fontweight="bold")
|
| 53 |
+
snip = lines.get(lang, "")
|
| 54 |
+
if len(snip) > 64: snip = snip[:61] + "…"
|
| 55 |
+
ax.text(.015, .5, snip, transform=ax.transAxes, ha="left", va="center",
|
| 56 |
+
color="white", fontsize=9.5, alpha=.88)
|
| 57 |
+
ax.set_xticks([]); ax.set_yticks([])
|
| 58 |
+
for sp in ax.spines.values(): sp.set_visible(False)
|
| 59 |
+
ax.axvline(0, color=c, lw=4, solid_capstyle="round")
|
| 60 |
+
fig.subplots_adjust(left=.13, right=.98, top=.87, bottom=.03, hspace=.1)
|
| 61 |
+
out = "/tmp/polyglot_card.png"
|
| 62 |
+
fig.savefig(out, dpi=160, bbox_inches="tight", facecolor="#0E1020"); plt.close(fig)
|
| 63 |
+
return out
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def _lang_header_html(lang: str) -> str:
|
| 67 |
+
return (
|
| 68 |
+
f'<div style="background:{LANG_HEADER[lang]};padding:16px 20px;display:flex;'
|
| 69 |
+
f'align-items:center;gap:14px;border-radius:18px 18px 0 0;">'
|
| 70 |
+
f'<div style="font-family:\'Fraunces\',serif;font-size:32px;font-weight:700;line-height:1;'
|
| 71 |
+
f'color:rgba(255,255,255,.85);min-width:42px;text-align:center;">{LANG_SCRIPT[lang]}</div>'
|
| 72 |
+
f'<div style="display:flex;flex-direction:column;gap:3px;">'
|
| 73 |
+
f'<div style="font-size:14px;font-weight:800;color:#fff;letter-spacing:.02em;">{LANG_NAMES[lang]}</div>'
|
| 74 |
+
f'<div style="font-size:10px;font-weight:700;color:rgba(255,255,255,.82);'
|
| 75 |
+
f'letter-spacing:.1em;">{LANG_LABEL[lang]} SCRIPT</div></div></div>')
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def generate(ref_audio_path, line):
|
| 79 |
+
empty = [None, None, None, None, None, "Record a clip and type a line.", ""]
|
| 80 |
+
if not ref_audio_path or not (line and line.strip()):
|
| 81 |
+
return empty
|
| 82 |
+
with open(ref_audio_path, "rb") as f: ref_bytes = f.read()
|
| 83 |
+
translated = _TRANSLATOR().translate.remote(line, "en", ["hi", "te", "ta"])
|
| 84 |
+
lines = {"en": line, **translated}
|
| 85 |
+
outs = []
|
| 86 |
+
for lang in LANGS:
|
| 87 |
+
wb, _ = _VOX().clone.remote(text=lines[lang], ref_audio_bytes=ref_bytes)
|
| 88 |
+
path = f"/tmp/polyglot_{lang}.wav"
|
| 89 |
+
with open(path, "wb") as f: f.write(wb)
|
| 90 |
+
outs.append(path)
|
| 91 |
+
card = _make_card({LANG_NAMES[l]: lines[l] for l in LANGS},
|
| 92 |
+
{LANG_NAMES[l]: outs[i] for i, l in enumerate(LANGS)})
|
| 93 |
+
transcript = "\n".join(f"{LANG_NAMES[l]}: {lines[l]}" for l in LANGS)
|
| 94 |
+
caption = ('I typed one line and heard myself say it in four languages 🎙️\n\n'
|
| 95 |
+
f'"{line}"\n\n' + transcript +
|
| 96 |
+
"\n\nBuilt with VoxCPM2 + Praxy for #BuildSmall #HuggingFace #PolyglotMe")
|
| 97 |
+
return outs + [card, transcript, caption]
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
HERO = """
|
| 101 |
+
<div style="border-radius:26px;padding:62px 28px 54px;text-align:center;position:relative;
|
| 102 |
+
overflow:hidden;min-height:300px;border:1px solid rgba(255,255,255,.08);
|
| 103 |
+
background:linear-gradient(135deg,#1a0050,#0d1a60,#003040,#0d1a00,#400010,#200040);
|
| 104 |
+
background-size:600% 600%;animation:gsh 12s ease infinite;">
|
| 105 |
+
<div style="position:absolute;top:-40px;left:-40px;width:210px;height:210px;border-radius:50%;
|
| 106 |
+
background:radial-gradient(circle,rgba(124,111,247,.4),transparent 70%);animation:orb 4s 0s ease-in-out infinite;"></div>
|
| 107 |
+
<div style="position:absolute;top:-20px;right:-20px;width:170px;height:170px;border-radius:50%;
|
| 108 |
+
background:radial-gradient(circle,rgba(247,111,111,.4),transparent 70%);animation:orb 4s .7s ease-in-out infinite;"></div>
|
| 109 |
+
<div style="position:absolute;bottom:-30px;left:30%;width:190px;height:190px;border-radius:50%;
|
| 110 |
+
background:radial-gradient(circle,rgba(79,200,120,.35),transparent 70%);animation:orb 4s 1.3s ease-in-out infinite;"></div>
|
| 111 |
+
<div style="position:absolute;bottom:-20px;right:10%;width:150px;height:150px;border-radius:50%;
|
| 112 |
+
background:radial-gradient(circle,rgba(79,184,247,.35),transparent 70%);animation:orb 4s 2s ease-in-out infinite;"></div>
|
| 113 |
+
<div style="position:relative;z-index:10;max-width:640px;margin:0 auto;">
|
| 114 |
+
<div style="font-size:60px;margin-bottom:10px;">🎙️</div>
|
| 115 |
+
<h1 style="font-family:'Fraunces',serif;font-size:clamp(2.6rem,7vw,4.4rem);font-weight:900;
|
| 116 |
+
color:#fff;margin:0 0 12px;letter-spacing:-.02em;line-height:1;
|
| 117 |
+
text-shadow:0 0 40px rgba(255,255,255,.3);">Polyglot Me</h1>
|
| 118 |
+
<p style="font-family:'Plus Jakarta Sans',sans-serif;font-size:clamp(1rem,2.5vw,1.2rem);
|
| 119 |
+
color:rgba(255,255,255,.82);margin:0 auto 18px;max-width:500px;line-height:1.6;">
|
| 120 |
+
Record ten seconds of your voice — hear <strong style="color:#fff;">yourself</strong>
|
| 121 |
+
speak English, Hindi, Telugu, and Tamil.</p>
|
| 122 |
+
<div style="display:flex;justify-content:center;gap:7px;flex-wrap:wrap;margin-bottom:12px;">
|
| 123 |
+
<span class="pf-pill" style="background:#3B5998;color:#fff;">ENGLISH</span>
|
| 124 |
+
<span class="pf-pill" style="background:#C2410C;color:#fff;">हिन्दी</span>
|
| 125 |
+
<span class="pf-pill" style="background:#0F766E;color:#fff;">తెలుగు</span>
|
| 126 |
+
<span class="pf-pill" style="background:#991B1B;color:#fff;">தமிழ்</span>
|
| 127 |
+
</div>
|
| 128 |
+
<div style="display:flex;justify-content:center;gap:8px;flex-wrap:wrap;">
|
| 129 |
+
<span class="pf-pill" style="background:rgba(255,255,255,.1);color:rgba(255,255,255,.8);border:1px solid rgba(255,255,255,.16);">VoxCPM2 · OPENBMB</span>
|
| 130 |
+
<span class="pf-pill" style="background:rgba(255,255,255,.1);color:rgba(255,255,255,.8);border:1px solid rgba(255,255,255,.16);">MODAL · SERVERLESS</span>
|
| 131 |
+
</div>
|
| 132 |
+
<p style="font-size:12.5px;color:rgba(255,255,255,.5);font-family:'Plus Jakarta Sans',sans-serif;margin:16px 0 0;">
|
| 133 |
+
⏳ First run warms the model on Modal (~2–4 min). After that it's quick.</p>
|
| 134 |
+
</div>
|
| 135 |
+
<div style="position:absolute;bottom:0;left:0;right:0;height:3px;
|
| 136 |
+
background:linear-gradient(90deg,#3B5998,#C2410C,#0F766E,#991B1B);"></div>
|
| 137 |
+
</div>
|
| 138 |
+
"""
|
| 139 |
+
|
| 140 |
+
EXTRA = """
|
| 141 |
+
@keyframes gsh {0%{background-position:0% 50%}50%{background-position:100% 50%}100%{background-position:0% 50%}}
|
| 142 |
+
@keyframes orb {0%,100%{transform:scale(1) translateY(0)}50%{transform:scale(1.06) translateY(-6px)}}
|
| 143 |
+
.lang-card{padding:0!important;overflow:hidden!important;}
|
| 144 |
+
#share-card img{border-radius:16px!important;border:1px solid rgba(255,255,255,.1)!important;}
|
| 145 |
+
#caption-out textarea{border-left:3px solid #5BB8F2!important;font-size:13px!important;line-height:1.8!important;}
|
| 146 |
+
#transcript-out textarea{font-size:13px!important;}
|
| 147 |
+
.rainbow-hr{height:2px;background:linear-gradient(90deg,#3B5998,#C2410C,#0F766E,#991B1B);border:none;margin:18px 0;opacity:.5;border-radius:2px;}
|
| 148 |
+
"""
|
| 149 |
+
|
| 150 |
+
with gr.Blocks(title="Polyglot Me") as demo:
|
| 151 |
+
gr.HTML(f"<style>{build_css('polyglot', EXTRA)}</style>")
|
| 152 |
+
gr.HTML(HERO)
|
| 153 |
+
|
| 154 |
+
with gr.Row():
|
| 155 |
+
ref_in = gr.Audio(sources=["microphone", "upload"], type="filepath",
|
| 156 |
+
label="Your voice (~10 seconds)", scale=1)
|
| 157 |
+
line_in = gr.Textbox(label="Say something (in English)", lines=3, scale=2,
|
| 158 |
+
placeholder="Good morning Amma, hope you slept well.")
|
| 159 |
+
speak_btn = gr.Button("🌍 Speak it in 4 languages", variant="primary", elem_id="cta")
|
| 160 |
+
gr.HTML('<hr class="rainbow-hr"/>')
|
| 161 |
+
|
| 162 |
+
audios = {}
|
| 163 |
+
with gr.Row():
|
| 164 |
+
for lang in LANGS:
|
| 165 |
+
with gr.Column(elem_classes=["lang-card"]):
|
| 166 |
+
gr.HTML(_lang_header_html(lang))
|
| 167 |
+
audios[lang] = gr.Audio(label="", type="filepath", show_label=False)
|
| 168 |
+
|
| 169 |
+
share_card = gr.Image(label="Share card", type="filepath", elem_id="share-card")
|
| 170 |
+
with gr.Row():
|
| 171 |
+
transcript_out = gr.Textbox(label="Translations", lines=4, elem_id="transcript-out", scale=1)
|
| 172 |
+
caption_out = gr.Textbox(label="Caption (copy for your post)", lines=6, elem_id="caption-out", scale=1)
|
| 173 |
+
|
| 174 |
+
speak_btn.click(generate, inputs=[ref_in, line_in],
|
| 175 |
+
outputs=[audios["en"], audios["hi"], audios["te"], audios["ta"],
|
| 176 |
+
share_card, transcript_out, caption_out])
|
| 177 |
+
|
| 178 |
+
if __name__ == "__main__":
|
| 179 |
+
demo.launch()
|
praxy_router.py
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Praxy production inference router (v2 — adds IndicF5 + codemix).
|
| 2 |
+
|
| 3 |
+
Single entry point that picks the right inference branch per language and
|
| 4 |
+
codemix-detection. See `memory/project_indicf5_unblock_recipe_2026-04-27.md`
|
| 5 |
+
and the v1-zero-shot scorecards in `evaluation/scorecards/indicf5_v1_*` for
|
| 6 |
+
the empirical data motivating the routes below.
|
| 7 |
+
|
| 8 |
+
Routing matrix (2026-04-27 v1):
|
| 9 |
+
|
| 10 |
+
| Language | Pure-script branch | Codemix branch |
|
| 11 |
+
|--------------|---------------------------------|---------------------------------|
|
| 12 |
+
| Telugu (te) | R6 LoRA (WER 0.034) | translit → IndicF5 (WER 0.14) |
|
| 13 |
+
| Hindi (hi) | vanilla Chatterbox + ref (0.025)| translit → IndicF5 (WER 0.20) |
|
| 14 |
+
| Tamil (ta) | R6 LoRA (WER 0.041) | translit → IndicF5 (WER 0.27) |
|
| 15 |
+
| English (en) | Chatterbox vanilla | n/a |
|
| 16 |
+
| Other Indic | IndicF5 best-effort | translit → IndicF5 best-effort |
|
| 17 |
+
|
| 18 |
+
`is_codemix(text)` returns True when ≥1 word is pure Latin (≥2 alphabetic
|
| 19 |
+
chars). Single Latin chars or digits are not enough to trigger.
|
| 20 |
+
|
| 21 |
+
The `translit → IndicF5` branch calls Haiku 4.5 to convert Latin English
|
| 22 |
+
words into native-script phonetic spelling matching how Bollywood/Sarvam
|
| 23 |
+
training data writes them ("WhatsApp" → "व्हाट्सऐप"), then sends the
|
| 24 |
+
all-native-script string to IndicF5. This single fix dropped Hi codemix
|
| 25 |
+
WER from 0.85 → 0.20 (76% relative drop) and Te codemix from 0.80 → 0.14
|
| 26 |
+
(82% relative drop). See `serving/codemix_to_native_script.py`.
|
| 27 |
+
"""
|
| 28 |
+
|
| 29 |
+
from __future__ import annotations
|
| 30 |
+
|
| 31 |
+
import re
|
| 32 |
+
from pathlib import Path
|
| 33 |
+
|
| 34 |
+
# Default reference audio per language. These are commercial-TTS-sourced
|
| 35 |
+
# clips reused for the v1 demo; Pushpak will swap in Praxel-owned voices
|
| 36 |
+
# (his + Ashwin's) before public launch.
|
| 37 |
+
_DEFAULT_REFS: dict[str, str] = {
|
| 38 |
+
"te": "data/references/sarvam_te_female_9s.wav",
|
| 39 |
+
"ta": "data/references/sarvam_ta_male_11s.wav",
|
| 40 |
+
"hi": "data/references/sarvam_hi_female_10s.wav",
|
| 41 |
+
"en": "data/references/ashwin_10s.wav", # Chatterbox base needs ref too
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
# Per-ref transcript cache (cleaned, no Whisper special tokens). IndicF5
|
| 45 |
+
# requires ref audio + EXACT matching ref text.
|
| 46 |
+
_DEFAULT_REF_TEXTS: dict[str, str] = {
|
| 47 |
+
"te": "మా తాతయ్య ప్రతి సాయంత్రం వరండాలో కూర్చుని తన చిన్నతనంలో జరిగిన కథలు చెబుతూ ఉంటారు మరియు మేము అందరూ కలిసి ఆసక్తిగా వినేవాళ్లం",
|
| 48 |
+
"ta": "எங்கள் தாத்தா தினமும் மாலையில் திண்ணையில் அமர்ந்து கொண்டு தன் சிறுவயதில் நடந்த கதைகளைச் சொல்லிக் கொண்டிருப்பார் நாங்கள் அனைவரும் சேர்ந்து ஆர்வமாகக் கேட்போம்",
|
| 49 |
+
"hi": "मेरे दादा जी हर शाम बरामदे में बैठकर अपने बचपन की कहानियां सुनाते हैं और हम सब मिलकर बडे चाव से उनकी बातें सुनते हैं",
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
# Per-language pure-script branch. "lora" = R6 LoRA (Chatterbox); "indicf5"
|
| 53 |
+
# = ai4bharat/IndicF5 zero-shot; "vanilla" = base Chatterbox no-LoRA.
|
| 54 |
+
_BRANCH: dict[str, str] = {
|
| 55 |
+
"te": "lora", # R6 wins pure-Te (WER 0.034 < 0.08 IndicF5)
|
| 56 |
+
"ta": "lora", # R6 wins pure-Ta (paper §V.1 0.041 LLM-WER)
|
| 57 |
+
"hi": "vanilla", # vanilla Chatterbox + Cart-Hi ref + Config B = 0.025
|
| 58 |
+
# WER (paper §V.1 ties Cartesia); IndicF5 zero-shot
|
| 59 |
+
# is 0.13 — vanilla Chatterbox recipe wins on Hi.
|
| 60 |
+
"en": "vanilla", # Chatterbox base is excellent at English
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
# Codemix always routes to IndicF5 with native-script preprocessing —
|
| 64 |
+
# IndicF5's char-level tokenizer + the translit fix gives the only
|
| 65 |
+
# working codemix recipe across our 8 architectural attempts.
|
| 66 |
+
_CODEMIX_BRANCH = "indicf5_native"
|
| 67 |
+
|
| 68 |
+
# Config B sampling overrides (Chatterbox-only — IndicF5 doesn't expose
|
| 69 |
+
# these knobs). From TTS paper §5.2 sweep.
|
| 70 |
+
CONFIG_B = dict(
|
| 71 |
+
exaggeration=0.7,
|
| 72 |
+
temperature=0.6,
|
| 73 |
+
min_p=0.1,
|
| 74 |
+
cfg_weight=0.5,
|
| 75 |
+
repetition_penalty=2.0,
|
| 76 |
+
top_p=1.0,
|
| 77 |
+
)
|
| 78 |
+
|
| 79 |
+
DEFAULT_R6_CKPT = "/cache/chatterbox_indic/round_6/step_8000.ckpt"
|
| 80 |
+
|
| 81 |
+
# A "codemix" word is a run of ≥2 Latin alphabetic chars. Single letters
|
| 82 |
+
# (acronym components) and digits don't trigger; numbers are normalised
|
| 83 |
+
# elsewhere via Indic number expansion.
|
| 84 |
+
_CODEMIX_WORD_RE = re.compile(r"[A-Za-z]{2,}")
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def is_codemix(text: str) -> bool:
|
| 88 |
+
"""Returns True iff `text` contains at least one Latin word ≥2 chars
|
| 89 |
+
that needs transliteration before IndicF5 synthesis."""
|
| 90 |
+
return bool(_CODEMIX_WORD_RE.search(text))
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def _resolve_ref_audio(ref_audio_path: str | None, lang: str) -> str:
|
| 94 |
+
if ref_audio_path:
|
| 95 |
+
return ref_audio_path
|
| 96 |
+
fallback = _DEFAULT_REFS.get(lang)
|
| 97 |
+
if not fallback:
|
| 98 |
+
raise ValueError(f"No default reference voice for lang={lang!r}; supply ref_audio_path.")
|
| 99 |
+
return fallback
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def _resolve_ref_text(ref_text: str | None, lang: str) -> str:
|
| 103 |
+
if ref_text:
|
| 104 |
+
return ref_text
|
| 105 |
+
fallback = _DEFAULT_REF_TEXTS.get(lang, "")
|
| 106 |
+
return fallback
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def route(text: str, lang: str, ref_audio_path: str | None = None,
|
| 110 |
+
ref_text: str | None = None) -> dict:
|
| 111 |
+
"""Return the inference-branch parameters for a given input.
|
| 112 |
+
|
| 113 |
+
Picks the route by combining (lang, codemix-detection). Returns a
|
| 114 |
+
dict with the branch identifier and the kwargs to forward to the
|
| 115 |
+
matching Modal entrypoint.
|
| 116 |
+
"""
|
| 117 |
+
lang = lang.lower()
|
| 118 |
+
# Codemix routing applies only to Indic targets; English target with
|
| 119 |
+
# English text is not codemix.
|
| 120 |
+
cm = is_codemix(text) and lang != "en"
|
| 121 |
+
pure_branch = _BRANCH.get(lang, "indicf5")
|
| 122 |
+
branch = _CODEMIX_BRANCH if cm else pure_branch
|
| 123 |
+
|
| 124 |
+
ref_audio = _resolve_ref_audio(ref_audio_path, lang)
|
| 125 |
+
ref_t = _resolve_ref_text(ref_text, lang)
|
| 126 |
+
|
| 127 |
+
if branch == "lora":
|
| 128 |
+
return {
|
| 129 |
+
"branch": "lora",
|
| 130 |
+
"model": "chatterbox_r6_lora",
|
| 131 |
+
"ckpt_path": DEFAULT_R6_CKPT,
|
| 132 |
+
"use_bups": True,
|
| 133 |
+
"no_lora": False,
|
| 134 |
+
"ref_audio_path": ref_audio,
|
| 135 |
+
"normalize_numbers": True,
|
| 136 |
+
"language_code": lang,
|
| 137 |
+
**CONFIG_B,
|
| 138 |
+
}
|
| 139 |
+
if branch == "vanilla":
|
| 140 |
+
return {
|
| 141 |
+
"branch": "vanilla",
|
| 142 |
+
"model": "chatterbox_base",
|
| 143 |
+
"ckpt_path": DEFAULT_R6_CKPT, # unused with no_lora
|
| 144 |
+
"use_bups": False,
|
| 145 |
+
"no_lora": True,
|
| 146 |
+
"ref_audio_path": ref_audio,
|
| 147 |
+
"normalize_numbers": True,
|
| 148 |
+
"language_code": lang,
|
| 149 |
+
**CONFIG_B,
|
| 150 |
+
}
|
| 151 |
+
if branch == "indicf5":
|
| 152 |
+
return {
|
| 153 |
+
"branch": "indicf5",
|
| 154 |
+
"model": "indicf5_zeroshot",
|
| 155 |
+
"ref_audio_path": ref_audio,
|
| 156 |
+
"ref_text": ref_t,
|
| 157 |
+
"language_code": lang,
|
| 158 |
+
}
|
| 159 |
+
if branch == "indicf5_native":
|
| 160 |
+
# Caller should run text through `serving.codemix_to_native_script
|
| 161 |
+
# .transliterate_codemix(text, lang)` before invoking the synth.
|
| 162 |
+
return {
|
| 163 |
+
"branch": "indicf5_native",
|
| 164 |
+
"model": "indicf5_native_codemix",
|
| 165 |
+
"ref_audio_path": ref_audio,
|
| 166 |
+
"ref_text": ref_t,
|
| 167 |
+
"language_code": lang,
|
| 168 |
+
"preprocess": "transliterate_codemix_to_native",
|
| 169 |
+
}
|
| 170 |
+
raise ValueError(f"Unknown branch {branch!r}")
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
def synthesize(text: str, lang: str, ref_audio_path: str | None = None,
|
| 174 |
+
ref_text: str | None = None) -> tuple[bytes, int]:
|
| 175 |
+
"""Production-grade single-utterance synthesis. Wraps `route()` and
|
| 176 |
+
runs the chosen Modal entrypoint. Returns (wav_bytes, sample_rate).
|
| 177 |
+
|
| 178 |
+
Note: this is a thin convenience wrapper. For batch eval/training
|
| 179 |
+
use the underlying Modal entrypoints directly to avoid per-call
|
| 180 |
+
Modal overhead.
|
| 181 |
+
"""
|
| 182 |
+
plan = route(text, lang, ref_audio_path=ref_audio_path, ref_text=ref_text)
|
| 183 |
+
branch = plan["branch"]
|
| 184 |
+
|
| 185 |
+
if branch == "indicf5_native":
|
| 186 |
+
from serving.codemix_to_native_script import transliterate_codemix
|
| 187 |
+
text = transliterate_codemix(text, lang)
|
| 188 |
+
|
| 189 |
+
if branch in ("indicf5", "indicf5_native"):
|
| 190 |
+
from serving.modal_app import IndicF5TTS
|
| 191 |
+
synth = IndicF5TTS()
|
| 192 |
+
from pathlib import Path as _P
|
| 193 |
+
ref_bytes = _P(plan["ref_audio_path"]).read_bytes()
|
| 194 |
+
return synth.synthesize.remote(
|
| 195 |
+
text=text,
|
| 196 |
+
ref_audio_bytes=ref_bytes,
|
| 197 |
+
ref_text=plan["ref_text"],
|
| 198 |
+
)
|
| 199 |
+
|
| 200 |
+
# lora / vanilla — Chatterbox path (existing implementation).
|
| 201 |
+
from serving.modal_app import PraxyChatterboxLoRA
|
| 202 |
+
import modal as _modal
|
| 203 |
+
env = {
|
| 204 |
+
"PRAXY_CKPT_PATH": plan["ckpt_path"],
|
| 205 |
+
"PRAXY_USE_BUPS": "1" if plan["use_bups"] else "0",
|
| 206 |
+
"PRAXY_NO_LORA": "1" if plan["no_lora"] else "0",
|
| 207 |
+
}
|
| 208 |
+
synth = PraxyChatterboxLoRA.with_options(secrets=[_modal.Secret.from_dict(env)])()
|
| 209 |
+
from pathlib import Path as _P
|
| 210 |
+
ref_bytes = _P(plan["ref_audio_path"]).read_bytes() if plan["ref_audio_path"] else None
|
| 211 |
+
if plan.get("normalize_numbers") and lang in {"te", "ta", "hi", "bn", "gu", "mr", "kn", "ml"}:
|
| 212 |
+
from praxy.linguistics.indic_numbers import normalize_indic_text
|
| 213 |
+
text = normalize_indic_text(text, lang)
|
| 214 |
+
return synth.synthesize.remote(
|
| 215 |
+
text=text,
|
| 216 |
+
language_code=lang,
|
| 217 |
+
ref_audio_bytes=ref_bytes,
|
| 218 |
+
exaggeration=CONFIG_B["exaggeration"],
|
| 219 |
+
cfg_weight=CONFIG_B["cfg_weight"],
|
| 220 |
+
temperature=CONFIG_B["temperature"],
|
| 221 |
+
repetition_penalty=CONFIG_B["repetition_penalty"],
|
| 222 |
+
min_p=CONFIG_B["min_p"],
|
| 223 |
+
top_p=CONFIG_B["top_p"],
|
| 224 |
+
)
|
| 225 |
+
|
| 226 |
+
|
| 227 |
+
__all__ = ["route", "synthesize", "is_codemix", "CONFIG_B", "DEFAULT_R6_CKPT"]
|
requirements.txt
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
gradio>=4.40
|
| 2 |
+
modal
|
| 3 |
+
numpy
|
| 4 |
+
soundfile
|
| 5 |
+
matplotlib
|
theme.py
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Praxy Field Guide — shared design system for the Build-Small hackathon apps.
|
| 2 |
+
|
| 3 |
+
One CSS framework, themed per app via tokens. Injected via gr.HTML('<style>…</style>')
|
| 4 |
+
because Gradio 6.0 dropped the css= argument from gr.Blocks(). All colour pairings
|
| 5 |
+
are chosen to pass WCAG AA (body text >=4.5:1, large text / UI >=3:1).
|
| 6 |
+
|
| 7 |
+
A copy of this file lives in each hf_space_* directory (the spaces upload
|
| 8 |
+
independently and cannot share a module). Keep them in sync.
|
| 9 |
+
"""
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
# Per-app design tokens. Each value is a CSS colour; contrast verified vs bg/accent.
|
| 13 |
+
THEMES = {
|
| 14 |
+
# Nidra — night sky. Dark is thematically right; text is solid (not low-alpha).
|
| 15 |
+
"nidra": {
|
| 16 |
+
"bg": "#0B1026", "bg2": "#070A1C", "surface": "#161C3F", "surface2": "#1E2550",
|
| 17 |
+
"ink": "#F4EFDE", "muted": "#AEB4D6", "faint": "#8086AE",
|
| 18 |
+
"accent": "#F5C842", "accent_ink": "#1A1304", "accent2": "#9E86FF",
|
| 19 |
+
"border": "rgba(245,200,66,.20)", "ring": "rgba(245,200,66,.45)",
|
| 20 |
+
"display": "Fraunces", "body": "Plus Jakarta Sans",
|
| 21 |
+
},
|
| 22 |
+
# Read-it-to-Amma — warm morning parchment. Accessibility app -> high contrast.
|
| 23 |
+
"readit": {
|
| 24 |
+
"bg": "#FBF3E3", "bg2": "#F6E9CE", "surface": "#FFFCF5", "surface2": "#F5EDE0",
|
| 25 |
+
"ink": "#2D1306", "muted": "#6B3D1E", "faint": "#9A7550",
|
| 26 |
+
"accent": "#C2410C", "accent_ink": "#FFFFFF", "accent2": "#7B1F2E",
|
| 27 |
+
"border": "rgba(120,60,0,.18)", "ring": "rgba(194,65,12,.35)",
|
| 28 |
+
"display": "Fraunces", "body": "Plus Jakarta Sans",
|
| 29 |
+
},
|
| 30 |
+
# Polyglot Me — festival; refined dark with four AA language accents.
|
| 31 |
+
"polyglot": {
|
| 32 |
+
"bg": "#0E1020", "bg2": "#080A18", "surface": "#181B33", "surface2": "#20243F",
|
| 33 |
+
"ink": "#F1EEF8", "muted": "#AFB0CC", "faint": "#7E80A2",
|
| 34 |
+
"accent": "#8B7FF9", "accent_ink": "#0B0B1A", "accent2": "#5BB8F2",
|
| 35 |
+
"border": "rgba(255,255,255,.10)", "ring": "rgba(139,127,249,.45)",
|
| 36 |
+
"display": "Fraunces", "body": "Plus Jakarta Sans",
|
| 37 |
+
},
|
| 38 |
+
# Parliament of Owls — naturalist field guide; parchment + forest + brass.
|
| 39 |
+
# Palette WCAG-verified: ink/bg 16.6:1, muted/bg 7.3:1, forest-accent/cream 7+:1.
|
| 40 |
+
"owls": {
|
| 41 |
+
"bg": "#F0E6CC", "bg2": "#E6D8B6", "surface": "#FAF5EB", "surface2": "#F2E9D2",
|
| 42 |
+
"ink": "#221E14", "muted": "#5C4A1E", "faint": "#8A7B5C",
|
| 43 |
+
"accent": "#2F5D44", "accent_ink": "#F7F1E1", "accent2": "#8B6914",
|
| 44 |
+
"border": "rgba(60,50,30,.22)", "ring": "rgba(47,93,68,.40)",
|
| 45 |
+
"display": "Fraunces", "body": "Plus Jakarta Sans",
|
| 46 |
+
},
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
_FONTS = ("https://fonts.googleapis.com/css2?"
|
| 50 |
+
"family=Fraunces:ital,opsz,wght@0,9..144,400;0,9..144,600;0,9..144,700;0,9..144,900;"
|
| 51 |
+
"1,9..144,400;1,9..144,600&"
|
| 52 |
+
"family=Plus+Jakarta+Sans:wght@400;500;600;700;800&"
|
| 53 |
+
"family=Noto+Sans:wght@400;500;600&display=swap")
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def build_css(theme: str, extra: str = "") -> str:
|
| 57 |
+
"""Return the full themed stylesheet for `theme` (a key in THEMES)."""
|
| 58 |
+
t = THEMES[theme]
|
| 59 |
+
return f"""
|
| 60 |
+
@import url('{_FONTS}');
|
| 61 |
+
|
| 62 |
+
:root {{
|
| 63 |
+
--bg:{t['bg']}; --bg2:{t['bg2']}; --surface:{t['surface']}; --surface2:{t['surface2']};
|
| 64 |
+
--ink:{t['ink']}; --muted:{t['muted']}; --faint:{t['faint']};
|
| 65 |
+
--accent:{t['accent']}; --accent-ink:{t['accent_ink']}; --accent2:{t['accent2']};
|
| 66 |
+
--border:{t['border']}; --ring:{t['ring']};
|
| 67 |
+
--display:'{t['display']}',Georgia,serif; --body:'{t['body']}','Noto Sans',sans-serif;
|
| 68 |
+
--radius:18px; --gap:18px;
|
| 69 |
+
}}
|
| 70 |
+
|
| 71 |
+
/* ---- fill the whole viewport, widen the column ---- */
|
| 72 |
+
html, body, gradio-app, .gradio-container, .app, #root {{
|
| 73 |
+
background: var(--bg) !important;
|
| 74 |
+
}}
|
| 75 |
+
body, .gradio-container {{ font-family: var(--body) !important; color: var(--ink) !important; }}
|
| 76 |
+
.gradio-container {{
|
| 77 |
+
max-width: 1180px !important; margin: 0 auto !important;
|
| 78 |
+
padding-left: 20px !important; padding-right: 20px !important;
|
| 79 |
+
}}
|
| 80 |
+
.gradio-container, .gradio-container * {{ box-sizing: border-box; }}
|
| 81 |
+
|
| 82 |
+
/* headings + prose */
|
| 83 |
+
h1,h2,h3 {{ font-family: var(--display) !important; color: var(--ink) !important; }}
|
| 84 |
+
|
| 85 |
+
/* ---- surfaces / cards ---- */
|
| 86 |
+
.gr-form, .gr-block, .gr-panel, .block, .contain, .gr-box, .form {{
|
| 87 |
+
background: var(--surface) !important;
|
| 88 |
+
border: 1px solid var(--border) !important;
|
| 89 |
+
border-radius: var(--radius) !important;
|
| 90 |
+
}}
|
| 91 |
+
.gap, .panel, .wrap {{ gap: var(--gap) !important; }}
|
| 92 |
+
|
| 93 |
+
/* ---- labels ---- */
|
| 94 |
+
label > span, .label-wrap > span, span[data-testid="block-info"] {{
|
| 95 |
+
color: var(--muted) !important; font-size: 11px !important;
|
| 96 |
+
font-weight: 700 !important; letter-spacing: .09em !important;
|
| 97 |
+
text-transform: uppercase !important; font-family: var(--body) !important;
|
| 98 |
+
}}
|
| 99 |
+
|
| 100 |
+
/* ---- inputs ---- */
|
| 101 |
+
input[type=text], input[type=number], textarea, select, .gr-text-input {{
|
| 102 |
+
background: var(--surface2) !important;
|
| 103 |
+
border: 1.5px solid var(--border) !important;
|
| 104 |
+
border-radius: 12px !important;
|
| 105 |
+
color: var(--ink) !important; font-size: 15px !important;
|
| 106 |
+
font-family: var(--body) !important;
|
| 107 |
+
}}
|
| 108 |
+
input::placeholder, textarea::placeholder {{ color: var(--faint) !important; opacity: 1; }}
|
| 109 |
+
input:focus, textarea:focus, select:focus {{
|
| 110 |
+
border-color: var(--accent) !important; outline: none !important;
|
| 111 |
+
box-shadow: 0 0 0 3px var(--ring) !important;
|
| 112 |
+
}}
|
| 113 |
+
input[type=range] {{ accent-color: var(--accent) !important; }}
|
| 114 |
+
input[type=checkbox] {{ accent-color: var(--accent) !important; width:18px; height:18px; }}
|
| 115 |
+
|
| 116 |
+
/* ---- accessible focus ring for keyboard users ---- */
|
| 117 |
+
:focus-visible {{ outline: 3px solid var(--ring) !important; outline-offset: 2px !important; }}
|
| 118 |
+
|
| 119 |
+
/* ---- primary CTA ---- */
|
| 120 |
+
button.primary, .gr-button-primary, #cta button {{
|
| 121 |
+
background: var(--accent) !important; color: var(--accent-ink) !important;
|
| 122 |
+
font-weight: 800 !important; font-size: 16px !important; letter-spacing: .02em !important;
|
| 123 |
+
font-family: var(--body) !important; border: none !important;
|
| 124 |
+
border-radius: 14px !important; padding: 16px 30px !important; width: 100% !important;
|
| 125 |
+
cursor: pointer !important;
|
| 126 |
+
box-shadow: 0 6px 22px rgba(0,0,0,.18) !important;
|
| 127 |
+
transition: transform .15s ease, box-shadow .25s ease, filter .2s ease !important;
|
| 128 |
+
}}
|
| 129 |
+
button.primary:hover, .gr-button-primary:hover, #cta button:hover {{
|
| 130 |
+
transform: translateY(-2px) !important; filter: brightness(1.05) !important;
|
| 131 |
+
box-shadow: 0 10px 34px rgba(0,0,0,.28) !important;
|
| 132 |
+
}}
|
| 133 |
+
#cta button:active {{ transform: translateY(0) !important; }}
|
| 134 |
+
|
| 135 |
+
/* secondary buttons */
|
| 136 |
+
.gr-button-secondary {{
|
| 137 |
+
background: var(--surface2) !important; color: var(--ink) !important;
|
| 138 |
+
border: 1.5px solid var(--border) !important; border-radius: 12px !important;
|
| 139 |
+
font-family: var(--body) !important; font-weight: 600 !important;
|
| 140 |
+
}}
|
| 141 |
+
|
| 142 |
+
/* audio + image blocks */
|
| 143 |
+
.waveform-container, .gr-audio, audio {{ border-radius: 12px !important; }}
|
| 144 |
+
|
| 145 |
+
/* tidy chrome */
|
| 146 |
+
footer {{ display: none !important; }}
|
| 147 |
+
.gradio-container .prose a {{ color: var(--accent2) !important; }}
|
| 148 |
+
::-webkit-scrollbar {{ width: 8px; height: 8px; }}
|
| 149 |
+
::-webkit-scrollbar-track {{ background: var(--bg2); }}
|
| 150 |
+
::-webkit-scrollbar-thumb {{ background: var(--accent); border-radius: 6px; opacity:.6; }}
|
| 151 |
+
|
| 152 |
+
/* shared pill / divider helpers used inside gr.HTML output */
|
| 153 |
+
.pf-pill {{
|
| 154 |
+
display:inline-block; padding:5px 14px; border-radius:20px; font-size:11px;
|
| 155 |
+
font-weight:700; letter-spacing:.06em; font-family:var(--body);
|
| 156 |
+
}}
|
| 157 |
+
.pf-divider {{ height:1px; background:linear-gradient(90deg,transparent,var(--border),transparent); margin:18px 0; border:none; }}
|
| 158 |
+
|
| 159 |
+
@media (max-width: 760px) {{
|
| 160 |
+
.gradio-container {{ padding-left: 12px !important; padding-right: 12px !important; }}
|
| 161 |
+
}}
|
| 162 |
+
{extra}
|
| 163 |
+
"""
|