say-it-back / app.py
Tonykip's picture
Upload app.py with huggingface_hub
aeab81a verified
Raw
History Blame Contribute Delete
10.4 kB
"""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", "") # Space secret; never committed
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",
]
# ── models ───────────────────────────────────────────────────────────────────
_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'>" + " &nbsp;Β·&nbsp; ".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: # noqa: BLE001
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: # noqa: BLE001
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)
# ── custom design ────────────────────────────────────────────────────────────
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 mode regardless of the device's system theme (the custom design is
# built for light; dark mode made it look broken). Reload once into ?__theme=light.
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()