Spaces:
Build error
Build error
Download app.py from radhapreetam/vani-speech-api: direct link, hf CLI and curl.
- Browser
- Download file 21.3 kB
-
https://huggingface.co/spaces/radhapreetam/vani-speech-api/resolve/main/app.py
- Command line
-
hf download hf://spaces/radhapreetam/vani-speech-api/app.py
-
curl -L -o app.py https://huggingface.co/spaces/radhapreetam/vani-speech-api/resolve/main/app.py
21.3 kB
| #!/usr/bin/env python3 | |
| """ | |
| VANI Speech API — hosted speech-to-text and text-to-speech Space. | |
| PURPOSE: Take the two real capabilities already proven locally in | |
| `dashboard_demo/vani_demo_service.py` (speech-to-text, text-to-speech) and make | |
| them reachable over HTTPS from the public website, so the website page stops | |
| depending on a service running on RP's own Mac at http://127.0.0.1:8901. | |
| WHEN TO RUN: as a Hugging Face Space (the platform runs `python app.py` and | |
| proxies port 7860), or locally for testing with the same command. | |
| WHEN NOT TO RUN: do not run this as the local development service — that role | |
| belongs to `dashboard_demo/vani_demo_service.py`, which keeps the local-only | |
| extras (macOS `say`, the LM Studio correction lane, the personal dictionary) | |
| that deliberately do NOT exist here because they are not portable. | |
| PORTABILITY, STATED PLAINLY: | |
| * Speech-to-text is faster-whisper (CTranslate2), not the MLX Whisper build | |
| used locally. MLX runs on Apple silicon only and would fail on this Linux | |
| host. Same model weights family, different runtime. | |
| * Text-to-speech is Kokoro (pure PyTorch, CPU), not macOS `say`. `say` is an | |
| operating-system binary that exists only on macOS. | |
| * Nothing in this file or in requirements.txt needs Apple hardware. | |
| """ | |
| import io | |
| import os | |
| import time | |
| import tempfile | |
| import threading | |
| import uuid | |
| import numpy as np | |
| import soundfile as sf | |
| from fastapi import FastAPI, File, HTTPException, UploadFile | |
| from fastapi.middleware.cors import CORSMiddleware | |
| from fastapi.responses import HTMLResponse, Response | |
| from pydantic import BaseModel | |
| # -------------------------------------------------------------------------- | |
| # Configuration | |
| # -------------------------------------------------------------------------- | |
| STT_MODEL_SIZE = os.environ.get("VANI_STT_MODEL", "small") | |
| TTS_VOICE_DEFAULT = os.environ.get("VANI_TTS_VOICE", "af_heart") | |
| TTS_SAMPLE_RATE = 24000 # Kokoro's native output rate | |
| PORT = int(os.environ.get("PORT", "7860")) | |
| # Which browser origins may call this API. The website is the real consumer; | |
| # the localhost entries let the site be previewed locally against the hosted | |
| # API without a second deployment. | |
| ALLOWED_ORIGINS = [ | |
| "https://radhapreetam.com", | |
| "https://www.radhapreetam.com", | |
| "http://localhost:4321", | |
| "http://127.0.0.1:4321", | |
| "http://localhost:3000", | |
| "http://127.0.0.1:3000", | |
| ] | |
| _TMP_DIR = tempfile.mkdtemp(prefix="vani_api_") | |
| app = FastAPI(title="VANI Speech API", version="1.0.0") | |
| app.add_middleware( | |
| CORSMiddleware, | |
| allow_origins=ALLOWED_ORIGINS, | |
| allow_methods=["GET", "POST", "OPTIONS"], | |
| allow_headers=["*"], | |
| ) | |
| # -------------------------------------------------------------------------- | |
| # Lazily-loaded engines. | |
| # | |
| # Both are loaded on first use, not at import, so the Space finishes booting | |
| # and starts answering /health immediately. That matters: a Space that spends | |
| # two minutes loading models before it binds its port looks dead to every | |
| # health check and to any first-time visitor. Load state is reported honestly | |
| # through /health so the page can show a real loading indicator instead of | |
| # silence. | |
| # -------------------------------------------------------------------------- | |
| _lock = threading.Lock() | |
| _stt = None | |
| _tts = None | |
| _stt_error = None | |
| _tts_error = None | |
| def get_stt(): | |
| global _stt, _stt_error | |
| if _stt is not None or _stt_error is not None: | |
| return _stt | |
| with _lock: | |
| if _stt is None and _stt_error is None: | |
| try: | |
| from faster_whisper import WhisperModel | |
| t0 = time.time() | |
| _stt = WhisperModel(STT_MODEL_SIZE, device="cpu", compute_type="int8") | |
| print(f"[stt] faster-whisper '{STT_MODEL_SIZE}' loaded in " | |
| f"{time.time() - t0:.1f}s (cpu/int8)", flush=True) | |
| except Exception as e: # noqa: BLE001 - reported, never swallowed | |
| _stt_error = f"{type(e).__name__}: {e}" | |
| print(f"[stt] LOAD FAILED: {_stt_error}", flush=True) | |
| return _stt | |
| def get_tts(): | |
| global _tts, _tts_error | |
| if _tts is not None or _tts_error is not None: | |
| return _tts | |
| with _lock: | |
| if _tts is None and _tts_error is None: | |
| try: | |
| from kokoro import KPipeline | |
| t0 = time.time() | |
| _tts = KPipeline(lang_code="a") # 'a' = American English | |
| print(f"[tts] kokoro pipeline loaded in {time.time() - t0:.1f}s (cpu)", | |
| flush=True) | |
| except Exception as e: # noqa: BLE001 | |
| _tts_error = f"{type(e).__name__}: {e}" | |
| print(f"[tts] LOAD FAILED: {_tts_error}", flush=True) | |
| return _tts | |
| def _engine_state(obj, err): | |
| if err: | |
| return "error" | |
| return "ready" if obj is not None else "not_loaded" | |
| # -------------------------------------------------------------------------- | |
| # API | |
| # -------------------------------------------------------------------------- | |
| def health(): | |
| """Cheap, never loads a model. Answers immediately, even during cold start.""" | |
| return { | |
| "status": "ok", | |
| "service": "vani-speech-api", | |
| "stt_backend": f"faster-whisper ({STT_MODEL_SIZE}, cpu/int8)", | |
| "tts_backend": "kokoro (cpu)", | |
| "stt_state": _engine_state(_stt, _stt_error), | |
| "tts_state": _engine_state(_tts, _tts_error), | |
| "stt_error": _stt_error, | |
| "tts_error": _tts_error, | |
| "apple_silicon_required": False, | |
| } | |
| def warmup(): | |
| """Force both engines to load. Lets a caller pay the cold-start cost up | |
| front rather than inside a user's first real request.""" | |
| t0 = time.time() | |
| get_stt() | |
| stt_done = time.time() | |
| get_tts() | |
| return { | |
| "stt_state": _engine_state(_stt, _stt_error), | |
| "tts_state": _engine_state(_tts, _tts_error), | |
| "stt_load_seconds": round(stt_done - t0, 1), | |
| "tts_load_seconds": round(time.time() - stt_done, 1), | |
| "stt_error": _stt_error, | |
| "tts_error": _tts_error, | |
| } | |
| async def speech_to_text(audio: UploadFile = File(...)): | |
| raw = await audio.read() | |
| if not raw: | |
| raise HTTPException(400, "Empty audio upload — no bytes received.") | |
| model = get_stt() | |
| if model is None: | |
| raise HTTPException(503, f"Speech-to-text engine unavailable: {_stt_error}") | |
| # faster-whisper decodes via PyAV, which reads the browser's webm/opus and | |
| # mp4/aac MediaRecorder output directly. The local service needed an | |
| # explicit ffmpeg conversion step because it fed bytes to Python's stdlib | |
| # `wave` module, which only understands RIFF/PCM. Passing a path here | |
| # avoids that whole class of bug. | |
| suffix = os.path.splitext(audio.filename or "recording.webm")[1] or ".webm" | |
| path = os.path.join(_TMP_DIR, f"in_{uuid.uuid4().hex}{suffix}") | |
| with open(path, "wb") as f: | |
| f.write(raw) | |
| try: | |
| t0 = time.time() | |
| segments, info = model.transcribe(path, beam_size=1, vad_filter=True) | |
| text = "".join(seg.text for seg in segments).strip() | |
| elapsed = time.time() - t0 | |
| except Exception as e: # noqa: BLE001 | |
| # The exception text from the decoder embeds the server-side temp file | |
| # path. That is useless to the user and needlessly exposes the server's | |
| # filesystem layout to the browser, so it is logged here and kept out of | |
| # the response body. | |
| print(f"[stt] decode/transcribe failed for {path}: {type(e).__name__}: {e}", | |
| flush=True) | |
| raise HTTPException( | |
| 422, | |
| "That audio could not be read. Re-record and try again — hold the " | |
| "button down while you speak. If it keeps happening, your browser is " | |
| "producing a recording format this server cannot decode.", | |
| ) | |
| finally: | |
| try: | |
| os.remove(path) | |
| except OSError: | |
| pass | |
| print(f"[stt] {len(raw)} bytes ({audio.content_type}) -> {text!r} in {elapsed:.2f}s", | |
| flush=True) | |
| return { | |
| "text": text, | |
| "language": getattr(info, "language", None), | |
| "confidence": round(float(getattr(info, "language_probability", 0.0)), 4), | |
| "seconds": round(elapsed, 2), | |
| "audio_seconds": round(float(getattr(info, "duration", 0.0)), 2), | |
| "note": None if text else "No speech detected in that recording.", | |
| } | |
| class TTSRequest(BaseModel): | |
| text: str | |
| voice: str | None = None | |
| def text_to_speech(req: TTSRequest): | |
| text = (req.text or "").strip() | |
| if not text: | |
| raise HTTPException(400, "text must not be empty") | |
| if len(text) > 1000: | |
| raise HTTPException(400, "text is limited to 1000 characters per request") | |
| pipeline = get_tts() | |
| if pipeline is None: | |
| raise HTTPException(503, f"Text-to-speech engine unavailable: {_tts_error}") | |
| voice = req.voice or TTS_VOICE_DEFAULT | |
| try: | |
| t0 = time.time() | |
| chunks = [] | |
| for _gs, _ps, audio_chunk in pipeline(text, voice=voice): | |
| chunks.append(np.asarray(audio_chunk, dtype=np.float32)) | |
| if not chunks: | |
| raise HTTPException(500, "Text-to-speech produced no audio for that text.") | |
| samples = np.concatenate(chunks) | |
| elapsed = time.time() - t0 | |
| except HTTPException: | |
| raise | |
| except Exception as e: # noqa: BLE001 | |
| raise HTTPException(500, f"Text-to-speech failed ({type(e).__name__}: {e}). " | |
| f"Try a shorter, plainer sentence.") | |
| buf = io.BytesIO() | |
| sf.write(buf, samples, TTS_SAMPLE_RATE, format="WAV", subtype="PCM_16") | |
| wav = buf.getvalue() | |
| print(f"[tts] {len(text)} chars -> {len(wav)} bytes wav " | |
| f"({len(samples) / TTS_SAMPLE_RATE:.1f}s audio) in {elapsed:.2f}s", flush=True) | |
| return Response( | |
| content=wav, | |
| media_type="audio/wav", | |
| headers={ | |
| "Content-Disposition": 'inline; filename="vani.wav"', | |
| "X-Vani-Seconds": str(round(elapsed, 2)), | |
| }, | |
| ) | |
| # -------------------------------------------------------------------------- | |
| # Demo page. | |
| # | |
| # Interaction model follows Wispr Flow: one large press-to-talk control, no | |
| # surrounding chrome competing for attention, and the transcript appearing in | |
| # place. Deliberate UX decisions, each one a failure mode seen before: | |
| # * The microphone permission is EXPLAINED before it is requested. An | |
| # unexplained browser permission prompt is the single most common | |
| # first-use dead end. | |
| # * A real loading state covers cold start. Hosted CPU inference takes over | |
| # a minute on first call and silence reads as broken. | |
| # * The text-to-speech box ships with a worked example already in it rather | |
| # than a blank field. | |
| # * Every error says what to do next, not just that something failed. | |
| # -------------------------------------------------------------------------- | |
| PAGE = """<!doctype html> | |
| <html lang="en"><head> | |
| <meta charset="utf-8"/> | |
| <meta name="viewport" content="width=device-width, initial-scale=1"/> | |
| <title>VANI — Speech Demo</title> | |
| <style> | |
| :root{--ink:#37434F;--mint:#C7E9D9;--lav:#D9CCEF;--peach:#F8D9B0;--rose:#F6C9C4;--blue:#C4DBF0;} | |
| *{box-sizing:border-box} | |
| body{margin:0;background:#F7F8FA;color:var(--ink); | |
| font:16px/1.55 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif} | |
| .wrap{max-width:620px;margin:0 auto;padding:32px 20px 64px} | |
| h1{font-size:24px;margin:0 0 4px} | |
| .sub{color:#63707C;margin:0 0 24px;font-size:14px} | |
| .card{background:#fff;border:1px solid #E3E7EB;border-radius:14px;padding:20px;margin-bottom:18px} | |
| .card h2{font-size:15px;margin:0 0 12px;letter-spacing:.02em;text-transform:uppercase;color:#63707C} | |
| button{font:inherit;cursor:pointer;border:0;border-radius:10px;padding:12px 18px;color:var(--ink)} | |
| button:disabled{opacity:.5;cursor:not-allowed} | |
| #talk{width:100%;padding:26px;font-size:18px;font-weight:600;background:var(--blue)} | |
| #talk.rec{background:var(--rose)} | |
| #speak{background:var(--mint)} | |
| textarea{width:100%;font:inherit;padding:12px;border:1px solid #D7DDE3;border-radius:10px; | |
| resize:vertical;color:var(--ink);background:#fff} | |
| .out{margin-top:14px;padding:14px;border-radius:10px;background:var(--lav);min-height:52px; | |
| white-space:pre-wrap;color:var(--ink)} | |
| .note{font-size:13px;color:#63707C;margin-top:8px} | |
| .warn{background:var(--peach);padding:12px;border-radius:10px;font-size:14px;margin-bottom:14px;color:var(--ink)} | |
| .err{background:var(--rose);padding:12px;border-radius:10px;font-size:14px;margin-top:10px;color:var(--ink)} | |
| .pill{display:inline-block;background:var(--mint);padding:3px 10px;border-radius:99px; | |
| font-size:12px;color:var(--ink)} | |
| .pill.cold{background:var(--peach)}.pill.bad{background:var(--rose)} | |
| </style></head><body><div class="wrap"> | |
| <h1>VANI — Speech Demo</h1> | |
| <p class="sub">Speech to text, and text to speech. Running on hosted CPU — no software to install.</p> | |
| <p><span id="status" class="pill cold">checking the server…</span></p> | |
| <div class="card"> | |
| <h2>Speak, and see the words</h2> | |
| <div class="warn" id="micnote"> | |
| <b>Before you press the button:</b> your browser will ask for permission to use | |
| the microphone. Nothing is recorded until you press and hold. The audio is sent | |
| to this server, turned into text, and discarded — it is not stored. | |
| </div> | |
| <button id="talk">Hold to talk</button> | |
| <div class="note">Press and hold, say a sentence, then let go. Try: | |
| <i>“Book the badminton court for Sunday morning.”</i></div> | |
| <div class="out" id="sttout">Your words will appear here.</div> | |
| <div id="stterr"></div> | |
| </div> | |
| <div class="card"> | |
| <h2>Type, and hear it spoken</h2> | |
| <textarea id="tts" rows="3">Good evening. This is VANI, reading your words aloud.</textarea> | |
| <div class="note">An example is filled in already — press play, or replace it with your own.</div> | |
| <p><button id="speak">Play this aloud</button></p> | |
| <audio id="player" controls style="width:100%;display:none"></audio> | |
| <div id="ttserr"></div> | |
| </div> | |
| <script> | |
| var API = location.origin; | |
| var st = document.getElementById('status'); | |
| function setPill(t, cls){ st.textContent = t; st.className = 'pill' + (cls ? ' ' + cls : ''); } | |
| function err(el, msg){ document.getElementById(el).innerHTML = '<div class="err">' + msg + '</div>'; } | |
| function clr(el){ document.getElementById(el).innerHTML = ''; } | |
| // Cold start is real and slow. Poll health so the page can say so honestly | |
| // instead of looking frozen. | |
| async function poll(){ | |
| try{ | |
| var r = await fetch(API + '/health'); var j = await r.json(); | |
| if(j.stt_state === 'error' || j.tts_state === 'error'){ | |
| setPill('server problem — see the error below', 'bad'); return; | |
| } | |
| if(j.stt_state === 'ready' && j.tts_state === 'ready'){ setPill('ready'); return; } | |
| setPill('waking up — first use takes up to 2 minutes', 'cold'); | |
| }catch(e){ setPill('cannot reach the server', 'bad'); } | |
| setTimeout(poll, 4000); | |
| } | |
| poll(); | |
| fetch(API + '/warmup', {method:'POST'}).catch(function(){}); | |
| // ---- speech to text ------------------------------------------------------- | |
| var rec = null, chunks = [], talk = document.getElementById('talk'); | |
| async function start(){ | |
| clr('stterr'); | |
| if(!navigator.mediaDevices || !window.MediaRecorder){ | |
| err('stterr','This browser cannot record audio. Use Chrome, Edge or Safari — and note that recording needs an https page.'); return; | |
| } | |
| var stream; | |
| try{ stream = await navigator.mediaDevices.getUserMedia({audio:true}); } | |
| catch(e){ | |
| err('stterr','Microphone permission was not granted. Click the padlock or camera icon in the address bar, set Microphone to Allow, then reload this page.'); | |
| return; | |
| } | |
| chunks = []; rec = new MediaRecorder(stream); | |
| rec.ondataavailable = function(e){ if(e.data.size) chunks.push(e.data); }; | |
| rec.onstop = function(){ stream.getTracks().forEach(function(t){t.stop();}); send(); }; | |
| rec.start(); talk.classList.add('rec'); talk.textContent = 'Listening — let go when done'; | |
| } | |
| function stop(){ if(rec && rec.state !== 'inactive') rec.stop(); | |
| talk.classList.remove('rec'); talk.textContent = 'Hold to talk'; } | |
| async function send(){ | |
| var blob = new Blob(chunks, {type: rec.mimeType || 'audio/webm'}); | |
| if(blob.size < 1200){ err('stterr','That recording was too short to contain speech. Hold the button down while you speak, for at least a second.'); return; } | |
| document.getElementById('sttout').textContent = 'Transcribing…'; | |
| var fd = new FormData(); fd.append('audio', blob, 'recording.webm'); | |
| try{ | |
| var r = await fetch(API + '/speech-to-text', {method:'POST', body: fd}); | |
| var j = await r.json(); | |
| if(!r.ok){ err('stterr', (j.detail || 'The server could not transcribe that.')); | |
| document.getElementById('sttout').textContent = 'Your words will appear here.'; return; } | |
| document.getElementById('sttout').textContent = j.text || j.note || '(nothing heard)'; | |
| setPill('ready'); | |
| }catch(e){ err('stterr','Could not reach the server. Check your connection and try again.'); | |
| document.getElementById('sttout').textContent = 'Your words will appear here.'; } | |
| } | |
| talk.addEventListener('mousedown', start); talk.addEventListener('mouseup', stop); | |
| talk.addEventListener('mouseleave', function(){ if(rec && rec.state==='recording') stop(); }); | |
| talk.addEventListener('touchstart', function(e){ e.preventDefault(); start(); }); | |
| talk.addEventListener('touchend', function(e){ e.preventDefault(); stop(); }); | |
| // ---- text to speech ------------------------------------------------------- | |
| document.getElementById('speak').addEventListener('click', async function(){ | |
| clr('ttserr'); | |
| var b = this, txt = document.getElementById('tts').value.trim(); | |
| if(!txt){ err('ttserr','Type or paste a sentence first, then press play.'); return; } | |
| b.disabled = true; b.textContent = 'Generating…'; | |
| try{ | |
| var r = await fetch(API + '/text-to-speech', {method:'POST', | |
| headers:{'Content-Type':'application/json'}, body: JSON.stringify({text: txt})}); | |
| if(!r.ok){ var j = await r.json().catch(function(){return {};}); | |
| err('ttserr', j.detail || 'The server could not generate audio for that text.'); return; } | |
| var p = document.getElementById('player'); | |
| p.src = URL.createObjectURL(await r.blob()); p.style.display = 'block'; p.play(); | |
| setPill('ready'); | |
| }catch(e){ err('ttserr','Could not reach the server. Check your connection and try again.'); } | |
| finally{ b.disabled = false; b.textContent = 'Play this aloud'; } | |
| }); | |
| </script> | |
| </div></body></html>""" | |
| def index(): | |
| return HTMLResponse(PAGE) | |
| # -------------------------------------------------------------------------- | |
| # Gradio surface at /ui. | |
| # | |
| # Mounted so the Space behaves like a normal Gradio Space (the platform's own | |
| # expectation for sdk: gradio), and so there is a second, dependency-free way | |
| # to exercise the same two engines if the custom page ever misbehaves. | |
| # -------------------------------------------------------------------------- | |
| def _build_gradio(): | |
| import gradio as gr | |
| def _stt_fn(filepath): | |
| if not filepath: | |
| return "Record or upload some audio first." | |
| model = get_stt() | |
| if model is None: | |
| return f"Speech-to-text engine unavailable: {_stt_error}" | |
| segments, info = model.transcribe(filepath, beam_size=1, vad_filter=True) | |
| return "".join(s.text for s in segments).strip() or "(no speech detected)" | |
| def _tts_fn(text): | |
| pipeline = get_tts() | |
| if pipeline is None: | |
| raise gr.Error(f"Text-to-speech engine unavailable: {_tts_error}") | |
| chunks = [np.asarray(a, dtype=np.float32) | |
| for _g, _p, a in pipeline(text or " ", voice=TTS_VOICE_DEFAULT)] | |
| return (TTS_SAMPLE_RATE, np.concatenate(chunks)) | |
| with gr.Blocks(title="VANI Speech") as demo: | |
| gr.Markdown("### VANI Speech — engine check\nThe main demo is at `/`.") | |
| with gr.Tab("Speech to text"): | |
| a_in = gr.Audio(sources=["microphone", "upload"], type="filepath", | |
| label="Record or upload") | |
| t_out = gr.Textbox(label="Transcript") | |
| gr.Button("Transcribe").click(_stt_fn, a_in, t_out) | |
| with gr.Tab("Text to speech"): | |
| t_in = gr.Textbox(label="Text", | |
| value="Good evening. This is VANI, reading your words aloud.") | |
| a_out = gr.Audio(label="Audio") | |
| gr.Button("Speak").click(_tts_fn, t_in, a_out) | |
| return demo | |
| try: | |
| import gradio as gr # noqa: E402 | |
| app = gr.mount_gradio_app(app, _build_gradio(), path="/ui") | |
| print("[boot] gradio surface mounted at /ui", flush=True) | |
| except Exception as _e: # noqa: BLE001 | |
| # A missing or incompatible gradio must not take the HTTP API down — the | |
| # API is the deliverable, /ui is the convenience. | |
| print(f"[boot] gradio surface unavailable ({type(_e).__name__}: {_e}); " | |
| f"HTTP API still served", flush=True) | |
| if __name__ == "__main__": | |
| import uvicorn | |
| print(f"[boot] VANI Speech API listening on 0.0.0.0:{PORT}", flush=True) | |
| uvicorn.run(app, host="0.0.0.0", port=PORT) | |