praxelhq commited on
Commit
deafd92
·
verified ·
1 Parent(s): 56412a2

Build-Small submission: field-guide UI + Modal backend

Browse files
Files changed (5) hide show
  1. README.md +39 -5
  2. app.py +179 -0
  3. praxy_router.py +227 -0
  4. requirements.txt +5 -0
  5. theme.py +163 -0
README.md CHANGED
@@ -1,13 +1,47 @@
1
  ---
2
  title: Polyglot Me
3
- emoji: 🏆
4
- colorFrom: yellow
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
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ """