| """Say It Back in Kalenjin β say a word in English, hear it in Kalenjin. |
| |
| English speech --(Whisper)--> English text |
| --(Kalenjin cascade, Modal)--> Kalenjin text |
| --(Cheps TTS, Modal)--> Kalenjin audio (auto-plays) |
| |
| Built for the HF Γ Gradio "Build Small" hackathon β Backyard AI track. |
| Custom-designed UI (off-white + terracotta, editorial serif) β not stock Gradio. |
| """ |
|
|
| from __future__ import annotations |
|
|
| import functools |
| import hashlib |
| import json |
| import os |
|
|
| import gradio as gr |
| import requests |
|
|
| HERE = os.path.dirname(os.path.abspath(__file__)) |
|
|
| TRANSLATE_URL = os.environ.get( |
| "MODAL_TRANSLATE_URL", |
| "https://tonykipkemboi--kalenjin-cascade-api-textcascade-translate.modal.run", |
| ) |
| TTS_URL = os.environ.get( |
| "MODAL_SYNTHESIZE_URL", |
| "https://tonykipkemboi--kalenjin-tts-serve-kalenjintts-fastapi-app.modal.run/synthesize", |
| ) |
| INFERENCE_TOKEN = os.environ.get("INFERENCE_TOKEN", "") |
|
|
| AUDIO_DIR = os.path.join(HERE, "audio") |
| os.makedirs(AUDIO_DIR, exist_ok=True) |
|
|
| try: |
| with open(os.path.join(HERE, "phrasebook.json"), encoding="utf-8") as f: |
| PHRASEBOOK = json.load(f) |
| except FileNotFoundError: |
| PHRASEBOOK = {} |
| FEATURED = list(PHRASEBOOK.keys()) or [ |
| "Thank you", "Good morning", "How are you?", "I love you", |
| "Water", "Milk", "My child", "Come and eat", |
| ] |
|
|
| |
| _whisper = None |
|
|
|
|
| def _get_whisper(): |
| global _whisper |
| if _whisper is None: |
| from faster_whisper import WhisperModel |
|
|
| _whisper = WhisperModel("base.en", device="cpu", compute_type="int8") |
| return _whisper |
|
|
|
|
| def transcribe_english(audio_path): |
| if not audio_path: |
| return "" |
| segments, _ = _get_whisper().transcribe(audio_path, language="en", beam_size=1) |
| return " ".join(s.text for s in segments).strip() |
|
|
|
|
| @functools.lru_cache(maxsize=512) |
| def translate_en_to_kal(text): |
| r = requests.post(TRANSLATE_URL, json={"text": text}, timeout=70) |
| r.raise_for_status() |
| d = r.json() |
| return (d.get("kalenjin", "").strip(), d.get("swahili", "").strip()) |
|
|
|
|
| @functools.lru_cache(maxsize=512) |
| def synthesize_kalenjin(text): |
| headers = {"Content-Type": "application/json"} |
| if INFERENCE_TOKEN: |
| headers["Authorization"] = f"Bearer {INFERENCE_TOKEN}" |
| r = requests.post(TTS_URL, json={"text": text}, headers=headers, timeout=150) |
| r.raise_for_status() |
| key = hashlib.sha256(text.encode()).hexdigest()[:16] |
| path = os.path.join(AUDIO_DIR, f"tts_{key}.wav") |
| with open(path, "wb") as f: |
| f.write(r.content) |
| return path |
|
|
|
|
| def _kal(text): |
| return f"<div class='kal-word'>{text}</div>" |
|
|
|
|
| def _meta(english, swahili=None): |
| bits = [] |
| if english: |
| bits.append(f"You said <b>β{english}β</b>") |
| if swahili: |
| bits.append(f"Swahili: <i>{swahili}</i>") |
| return "<div class='meta'>" + " Β· ".join(bits) + "</div>" if bits else "" |
|
|
|
|
| def say_it_back(typed_text, audio_path): |
| english = (typed_text or "").strip() |
| if not english and audio_path: |
| yield _kal("<span class='dim'>listeningβ¦</span>"), None, "" |
| english = transcribe_english(audio_path) |
| if not english: |
| yield _kal("<span class='dim'>Say or type something in English</span>"), None, "" |
| return |
|
|
| if english in PHRASEBOOK: |
| e = PHRASEBOOK[english] |
| yield _kal(e["kalenjin"]), os.path.join(HERE, e["wav"]), _meta(english, e["swahili"]) |
| return |
|
|
| yield _kal("<span class='dim'>translatingβ¦</span>"), None, _meta(english) |
| try: |
| kalenjin, swahili = translate_en_to_kal(english) |
| except Exception as e: |
| yield _kal("<span class='dim'>translation failed</span>"), None, _meta(english) |
| return |
| if not kalenjin: |
| yield _kal("<span class='dim'>no translation</span>"), None, _meta(english) |
| return |
|
|
| yield _kal(kalenjin), None, _meta(english, swahili) + "<div class='meta dim'>voicingβ¦</div>" |
| try: |
| wav = synthesize_kalenjin(kalenjin) |
| except Exception: |
| yield _kal(kalenjin), None, _meta(english, swahili) + "<div class='meta dim'>voice unavailable</div>" |
| return |
| yield _kal(kalenjin), wav, _meta(english, swahili) |
|
|
|
|
| def featured_click(phrase): |
| e = PHRASEBOOK.get(phrase) |
| if e: |
| return _kal(e["kalenjin"]), os.path.join(HERE, e["wav"]), _meta(phrase, e["swahili"]) |
| kalenjin, swahili = translate_en_to_kal(phrase) |
| return _kal(kalenjin), synthesize_kalenjin(kalenjin), _meta(phrase, swahili) |
|
|
|
|
| |
| HEAD = """ |
| <link rel="preconnect" href="https://fonts.googleapis.com"> |
| <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> |
| <link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,500;9..144,600&family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@500&display=swap" rel="stylesheet"> |
| """ |
|
|
| CSS = """ |
| :root { --ink:#2b2622; --cream:#fbf6ef; --terra:#cf5a26; --terra-d:#a8431a; --muted:#8a7d70; --line:#ece2d6; } |
| .gradio-container { max-width: 680px !important; margin: auto !important; background: transparent !important; } |
| body, .gradio-container { background: |
| radial-gradient(1200px 500px at 50% -200px, #f7e6d6 0%, rgba(247,230,214,0) 60%), var(--cream) !important; |
| font-family: 'Inter', system-ui, sans-serif; color: var(--ink); } |
| footer { display:none !important; } |
| |
| /* hero */ |
| .hero { text-align:center; padding: 28px 8px 6px; } |
| .hero .eyebrow { font-family:'JetBrains Mono',monospace; font-size:12px; letter-spacing:.18em; |
| color:var(--terra-d); text-transform:uppercase; margin-bottom:10px; } |
| .hero h1 { font-family:'Fraunces',serif; font-weight:600; font-size:46px; line-height:1.02; |
| margin:0 0 12px; color:var(--ink); } |
| .hero h1 em { font-style:italic; color:var(--terra); } |
| .hero .tag { font-size:17px; color:var(--ink); margin:0 0 6px; font-weight:500; } |
| .hero .why { font-size:15px; color:var(--muted); max-width:460px; margin:6px auto 0; line-height:1.5; } |
| |
| /* cards */ |
| #in-card, #out-card { background:#fff !important; border:1px solid var(--line) !important; |
| border-radius:20px !important; box-shadow: 0 8px 30px rgba(120,80,40,.06) !important; |
| padding:16px !important; margin-top:14px !important; } |
| |
| /* the big Kalenjin word */ |
| .kal-word { font-family:'Fraunces',serif; font-weight:600; font-size:44px; line-height:1.1; |
| color:var(--terra); text-align:center; padding:14px 6px 6px; word-break:break-word; } |
| .kal-word .dim { color:var(--muted); font-style:italic; font-size:24px; font-weight:500; } |
| .meta { text-align:center; color:var(--muted); font-size:14px; margin-top:2px; } |
| .meta.dim { opacity:.8; font-style:italic; } |
| |
| /* primary button */ |
| #go-btn { background:var(--terra) !important; color:#fff !important; border:none !important; |
| border-radius:14px !important; font-weight:600 !important; font-size:17px !important; } |
| #go-btn:hover { background:var(--terra-d) !important; } |
| |
| /* featured pills */ |
| .pills .gr-button, .pills button { background:#fff !important; border:1px solid var(--line) !important; |
| border-radius:999px !important; color:var(--ink) !important; font-weight:500 !important; } |
| .pills .gr-button:hover, .pills button:hover { border-color:var(--terra) !important; color:var(--terra-d) !important; } |
| |
| .section-label { font-family:'JetBrains Mono',monospace; font-size:12px; letter-spacing:.12em; |
| text-transform:uppercase; color:var(--muted); margin:18px 4px 2px; } |
| .footnote { text-align:center; color:var(--muted); font-size:12.5px; line-height:1.6; margin:22px 6px 8px; } |
| .footnote b { color:var(--ink); font-weight:600; } |
| """ |
|
|
| |
| |
| FORCE_LIGHT = """ |
| () => { |
| const u = new URL(window.location.href); |
| if (u.searchParams.get('__theme') !== 'light') { |
| u.searchParams.set('__theme', 'light'); |
| window.location.replace(u.href); |
| } |
| } |
| """ |
|
|
| with gr.Blocks(title="Say It Back in Kalenjin", theme=gr.themes.Soft(primary_hue="orange"), |
| css=CSS, head=HEAD, js=FORCE_LIGHT) as demo: |
| gr.HTML( |
| "<div class='hero'>" |
| "<div class='eyebrow'>[ English β Kalenjin Β· spoken ]</div>" |
| "<h1>Say It Back <em>in Kalenjin</em></h1>" |
| "<p class='tag'>Say a word in English. Hear it in Kalenjin.</p>" |
| "<p class='why'>Kalenjin is slipping away in my family. I built this so we can " |
| "learn it back β in a real Kalenjin voice (Cheps, a TTS I fine-tuned).</p>" |
| "</div>" |
| ) |
|
|
| with gr.Group(elem_id="in-card"): |
| mic = gr.Audio(sources=["microphone"], type="filepath", label="π€ Say it in English") |
| txt = gr.Textbox(label="β¦or type English", placeholder="e.g. good morning, how are you?") |
| go = gr.Button("Say it back β", variant="primary", elem_id="go-btn", size="lg") |
|
|
| with gr.Group(elem_id="out-card"): |
| kal_out = gr.HTML(_kal("<span class='dim'>your Kalenjin will appear here</span>")) |
| audio_out = gr.Audio(label="π Kalenjin voice (Cheps)", autoplay=True, interactive=False) |
| meta_out = gr.HTML("") |
|
|
| gr.HTML("<div class='section-label'>Tap a phrase to hear it</div>") |
| with gr.Row(elem_classes="pills"): |
| for _phrase in FEATURED: |
| gr.Button(_phrase, size="sm").click( |
| featured_click, inputs=gr.State(_phrase), outputs=[kal_out, audio_out, meta_out] |
| ) |
|
|
| gr.HTML( |
| "<div class='footnote'>Tiny by design β every model is small: " |
| "<b>Whisper 74M</b> (speech), <b>NLLB 600M</b> (translation), " |
| "<b>Cheps / Orpheus 3B</b> (voice). All under 4B.<br>" |
| "Models run on Modal Β· Built with Gradio for the Build Small hackathon π°πͺ</div>" |
| ) |
|
|
| go.click(say_it_back, inputs=[txt, mic], outputs=[kal_out, audio_out, meta_out]) |
| txt.submit(say_it_back, inputs=[txt, mic], outputs=[kal_out, audio_out, meta_out]) |
|
|
| if __name__ == "__main__": |
| demo.queue().launch() |
|
|