Download app.py from DibaAi/Decho: direct link, hf CLI and curl.
- Browser
- Download file 21.2 kB
-
https://huggingface.co/spaces/DibaAi/Decho/resolve/main/app.py
- Command line
-
hf download hf://spaces/DibaAi/Decho/app.py
-
curl -L -o app.py https://huggingface.co/spaces/DibaAi/Decho/resolve/main/app.py
21.2 kB
| """Decho — public demo Space of Dibachain's Persian text-to-speech (CPU). | |
| * Models (private repos, read with the Space secret HF_TOKEN; never exposed to visitors): | |
| rt DibaAi/Decho-v2-rt int8 - real-time distilled model (no guidance, 8 steps) | |
| int8 DibaAi/Decho-v2 int8 - standard | |
| fp32 DibaAi/Decho-v2 fp32 - studio (16 steps) | |
| * Sign in with Hugging Face (OAuth, scope inference-api): the language-model features (smart reading, podcast script | |
| writer, pronunciation suggestions) run on the signed-in user's own Hugging Face account / Inference Providers credit. | |
| Without sign-in: samples + short single-voice tries only. | |
| * No voice cloning in this public version (add_voice is not exposed; the speaker encoder is not even downloaded). | |
| """ | |
| from __future__ import annotations | |
| import base64 | |
| import hashlib | |
| import io | |
| import json | |
| import os | |
| import re | |
| import subprocess | |
| import sys | |
| import tempfile | |
| import threading | |
| import time | |
| import urllib.error | |
| import urllib.request | |
| import uuid | |
| from collections import OrderedDict, defaultdict, deque | |
| import numpy as np | |
| import soundfile as sf | |
| from fastapi import FastAPI, HTTPException, Request | |
| from fastapi.responses import FileResponse, JSONResponse, Response | |
| from fastapi.staticfiles import StaticFiles | |
| from huggingface_hub import attach_huggingface_oauth, parse_huggingface_oauth, snapshot_download | |
| from pydantic import BaseModel | |
| HERE = os.path.dirname(os.path.abspath(__file__)) | |
| TOKEN = os.environ.get("HF_TOKEN") # Space secret: read access to the private model repos | |
| THREADS = int(os.environ.get("THREADS", "2")) | |
| MAIN_REPO = os.environ.get("MAIN_REPO", "DibaAi/Decho-v2") | |
| RT_REPO = os.environ.get("RT_REPO", "DibaAi/Decho-v2-rt") | |
| MAIN_DIR = os.path.expanduser("~/model_main") | |
| RT_DIR = os.path.expanduser("~/model_rt") | |
| LLM_MODEL = os.environ.get("LLM_MODEL", "deepseek-ai/DeepSeek-V4.1-Flash") | |
| ROUTER = "https://router.huggingface.co/v1/chat/completions" | |
| BASE_FILES = ["config.json", "tokenizer.json", "decho/**", "g2p/**", "voices/**", "onnx/*.onnx", "onnx/*.npy"] | |
| # limits: anonymous visitors get a short try, signed-in users the full studio | |
| LIMITS = {"anon": {"chars": 220, "chars_per_hour": 2000, "streams": 1}, | |
| "user": {"chars": 4000, "chars_per_hour": 40000, "streams": 2}} | |
| MODELS = {"rt": {"steps": 8, "cfg": None}, "int8": {"steps": 8, "cfg": 3.0}, "fp32": {"steps": 16, "cfg": 3.0}} | |
| state = {"ready": False, "error": None, "models": {}, "loading": {}, "started": time.time()} | |
| _load_lock = threading.Lock() | |
| _synth_lock = threading.Lock() | |
| _waiting = [0] | |
| _waiting_lock = threading.Lock() | |
| app = FastAPI(title="Decho — Persian text-to-speech", docs_url=None, redoc_url=None) | |
| if os.environ.get("OAUTH_CLIENT_ID"): | |
| attach_huggingface_oauth(app) | |
| else: # local run without OAuth: the app still works, signed-out | |
| from starlette.middleware.sessions import SessionMiddleware | |
| app.add_middleware(SessionMiddleware, secret_key="local-dev") | |
| # ----------------------------------------------------------------------------------------------- models | |
| def _download(kind: str) -> str: | |
| # real files, not cache symlinks: onnxruntime refuses external data that resolves outside the model folder | |
| if kind == "rt": | |
| return snapshot_download(RT_REPO, token=TOKEN, allow_patterns=BASE_FILES + ["onnx/int8/*"], local_dir=RT_DIR) | |
| return snapshot_download(MAIN_REPO, token=TOKEN, allow_patterns=BASE_FILES + [f"onnx/{kind}/*"], local_dir=MAIN_DIR) | |
| def get_model(kind: str): | |
| if kind not in MODELS: | |
| raise HTTPException(400, "unknown model") | |
| if kind in state["models"]: | |
| return state["models"][kind] | |
| with _load_lock: | |
| if kind not in state["models"]: | |
| t0 = time.time() | |
| state["loading"][kind] = True | |
| try: | |
| d = _download(kind) | |
| if kind != "rt" and d not in sys.path: # one runtime package per process: the main repo's (it reads the | |
| sys.path.insert(0, d) # real-time model's config: film_layers, cfg_fixed, chunk sizes) | |
| from decho import Decho | |
| state["models"][kind] = Decho(d, precision="int8" if kind == "rt" else kind, threads=THREADS) | |
| state.setdefault("load_s", {})[kind] = round(time.time() - t0, 1) | |
| finally: | |
| state["loading"].pop(kind, None) | |
| return state["models"][kind] | |
| def _warm(): | |
| try: | |
| m = get_model("int8") # first: provides the runtime package | |
| m.synth("سلام.", steps=4, reader=False) | |
| r = get_model("rt") | |
| r.synth("سلام.", reader=False) | |
| state["ready"] = True | |
| get_model("fp32") # studio model in the background (large download) | |
| except Exception as e: # noqa: BLE001 - logged in full, a short notice in the UI instead of a dead page | |
| print("model load failed:", type(e).__name__, str(e)[:500], flush=True) | |
| state["error"] = "model_unavailable" | |
| threading.Thread(target=_warm, daemon=True).start() | |
| # ----------------------------------------------------------------------------------------------- identity, limits | |
| def who(request: Request) -> dict: | |
| """Signed-in Hugging Face user (OAuth) or an anonymous visitor (hashed client address).""" | |
| try: | |
| info = parse_huggingface_oauth(request) | |
| except Exception: # noqa: BLE001 | |
| info = None | |
| if info is not None and info.user_info is not None and info.access_token: | |
| u = info.user_info | |
| return {"kind": "user", "id": "u:" + (u.preferred_username or u.sub or "?"), "username": u.preferred_username, | |
| "name": u.name or u.preferred_username, "avatar": u.picture, "token": info.access_token, | |
| "pro": bool(u.is_pro)} | |
| ip = (request.headers.get("x-forwarded-for") or (request.client.host if request.client else "?")).split(",")[0].strip() | |
| return {"kind": "anon", "id": "a:" + hashlib.sha256(ip.encode()).hexdigest()[:16]} | |
| _usage: dict = defaultdict(deque) # id -> deque[(t, chars)] | |
| _usage_lock = threading.Lock() | |
| def charge(ident: dict, chars: int) -> None: | |
| lim = LIMITS[ident["kind"]] | |
| now = time.time() | |
| with _usage_lock: | |
| q = _usage[ident["id"]] | |
| while q and now - q[0][0] > 3600: | |
| q.popleft() | |
| used = sum(c for _, c in q) | |
| if used + chars > lim["chars_per_hour"]: | |
| if ident["kind"] == "anon": | |
| raise HTTPException(429, "limit_anon") | |
| raise HTTPException(429, "limit_user") | |
| q.append((now, chars)) | |
| _readers: "OrderedDict[str, object]" = OrderedDict() | |
| def user_reader(ident: dict): | |
| """LLM reading layer on the signed-in user's own token (their Inference Providers credit).""" | |
| if ident["kind"] != "user": | |
| return None | |
| from decho.llm_reader import HFRouterReader | |
| key = ident["id"] + ":" + hashlib.sha256(ident["token"].encode()).hexdigest()[:8] | |
| if key not in _readers: | |
| _readers[key] = HFRouterReader(token=ident["token"], timeout=60.0) | |
| while len(_readers) > 200: | |
| _readers.popitem(last=False) | |
| _readers.move_to_end(key) | |
| return _readers[key] | |
| def clean_lexicon(lex) -> dict: | |
| """Personal pronunciation dictionary sent by the browser: {word: Persian respelling | "r:<model text>"}.""" | |
| out = {} | |
| if not isinstance(lex, dict): | |
| return out | |
| for k, v in list(lex.items())[:300]: | |
| k, v = str(k).strip()[:40], str(v).strip()[:80] | |
| if not k or not v or not re.search(r"[-ۿ]", k): | |
| continue | |
| if v.startswith("r:") and not re.fullmatch(r"r:[A-Za-z' ]{1,78}", v): | |
| continue | |
| out[k] = v | |
| return out | |
| def wav_bytes(wav: np.ndarray, sr: int) -> bytes: | |
| buf = io.BytesIO() | |
| sf.write(buf, np.clip(np.asarray(wav, np.float32), -1, 1), sr, format="WAV", subtype="PCM_16") | |
| return buf.getvalue() | |
| # ----------------------------------------------------------------------------------------------- status / account | |
| def status(request: Request): | |
| ident = who(request) | |
| m = state["models"].get("int8") | |
| voices = [] | |
| if m is not None: | |
| meta = {} | |
| try: | |
| meta = json.load(open(os.path.join(MAIN_DIR, "voices", "voices.json"), encoding="utf-8")) | |
| except Exception: # noqa: BLE001 | |
| pass | |
| voices = [{"name": v, "gender": (meta.get(v) or {}).get("gender"), "fa": (meta.get(v) or {}).get("description", "")} | |
| for v in m.voices] | |
| with _waiting_lock: | |
| queue = _waiting[0] | |
| return JSONResponse({"ready": state["ready"], "error": state["error"], "models": sorted(state["models"]), | |
| "loading": sorted(state["loading"]), "voices": voices, "queue": queue, | |
| "limits": LIMITS[ident["kind"]], "signed_in": ident["kind"] == "user", | |
| "oauth": bool(os.environ.get("OAUTH_CLIENT_ID")), | |
| "space_host": os.environ.get("SPACE_HOST", "")}) | |
| def me(request: Request): | |
| ident = who(request) | |
| if ident["kind"] != "user": | |
| return {"signed_in": False} | |
| return {"signed_in": True, "username": ident["username"], "name": ident["name"], "avatar": ident["avatar"], | |
| "pro": ident["pro"]} | |
| # ----------------------------------------------------------------------------------------------- synthesis (streamed) | |
| class StreamReq(BaseModel): | |
| script: str | list | |
| model: str = "rt" # rt | int8 | fp32 | |
| voice: str | None = "default" | |
| speed: float = 1.0 | |
| seed: int | None = 0 | |
| smart: bool = False # LLM reading layer (signed-in users, their own credit) | |
| register: str = "formal" # formal | casual | |
| live: bool = False # short first chunk = earliest first audio | |
| pause_scale: float | None = None | |
| lexicon: dict | None = None | |
| _streams: dict = {} | |
| _streams_lock = threading.Lock() | |
| def stream_start(r: StreamReq, request: Request): | |
| ident = who(request) | |
| if not state["ready"]: | |
| raise HTTPException(503, state["error"] or "loading") | |
| lim = LIMITS[ident["kind"]] | |
| script = r.script | |
| if isinstance(script, list): | |
| script = [{"text": str(x.get("text", ""))[:lim["chars"]], "voice": x.get("voice")} for x in script[:200] | |
| if isinstance(x, dict)] | |
| n = sum(len(x["text"]) for x in script) | |
| else: | |
| script = str(script).strip() | |
| n = len(script) | |
| if n == 0: | |
| raise HTTPException(422, "empty") | |
| if n > lim["chars"]: | |
| raise HTTPException(413, "too_long_anon" if ident["kind"] == "anon" else "too_long") | |
| if ident["kind"] == "anon" and (isinstance(script, list) or r.model == "fp32" or r.smart): | |
| raise HTTPException(401, "sign_in") | |
| with _streams_lock: | |
| mine = [k for k, v in _streams.items() if v["owner"] == ident["id"] and not v["done"]] | |
| for k in mine[: max(0, len(mine) - lim["streams"] + 1)]: # a new request replaces the oldest open one | |
| _streams.pop(k, None) | |
| charge(ident, n) | |
| m = get_model(r.model) | |
| kw = dict(steps=MODELS[r.model]["steps"], speed=max(0.7, min(1.4, float(r.speed))), seed=r.seed, voice=r.voice or None, | |
| reader=False, register="casual" if r.register == "casual" else "formal", | |
| lexicon=clean_lexicon(r.lexicon) or None) | |
| if MODELS[r.model]["cfg"] is not None: | |
| kw["cfg"] = MODELS[r.model]["cfg"] | |
| if r.live: | |
| kw["first_chars"] = 40 | |
| if r.pause_scale: | |
| kw["pause_scale"] = max(0.5, min(2.0, float(r.pause_scale))) | |
| if r.smart: | |
| rd = user_reader(ident) | |
| if rd is not None: | |
| kw["reader"] = rd | |
| if kw["voice"] and kw["voice"] not in m.voices: | |
| raise HTTPException(422, "unknown voice") | |
| sid = uuid.uuid4().hex | |
| with _streams_lock: | |
| for k in [k for k, v in _streams.items() if time.time() - v["t"] > 900]: | |
| _streams.pop(k, None) | |
| _streams[sid] = {"gen": m.stream(script, **kw), "t": time.time(), "done": False, "owner": ident["id"]} | |
| return {"id": sid} | |
| def stream_next(sid: str, request: Request): | |
| st = _streams.get(sid) | |
| if not st or st["owner"] != who(request)["id"]: | |
| raise HTTPException(404, "unknown or expired stream") | |
| if st["done"]: | |
| return Response(status_code=204) | |
| with _waiting_lock: | |
| _waiting[0] += 1 | |
| with _synth_lock: # one synthesis at a time: the CPU is shared | |
| with _waiting_lock: | |
| _waiting[0] -= 1 | |
| t0 = time.time() | |
| try: | |
| chunk = next(st["gen"]) | |
| except StopIteration: | |
| st["done"] = True | |
| _streams.pop(sid, None) | |
| return Response(status_code=204) | |
| dt = time.time() - t0 | |
| st["t"] = time.time() | |
| wav, sr = np.asarray(chunk["audio"], np.float32), int(chunk["sr"]) | |
| dur = len(wav) / sr if sr else 0.0 | |
| meta = {k: chunk.get(k) for k in ("text", "voice", "model_text", "gap_after_s", "reader", "fixed_text", "uncertain")} | |
| meta["model_text"] = re.sub(r"^(?:<[^>]+>\s*)+", "", meta.get("model_text") or "") | |
| return Response(wav_bytes(wav, sr), media_type="audio/wav", headers={ | |
| "X-Synth-Seconds": f"{dt:.2f}", "X-Audio-Seconds": f"{dur:.2f}", | |
| "X-Meta": base64.b64encode(json.dumps(meta, ensure_ascii=False).encode()).decode(), | |
| "Access-Control-Expose-Headers": "X-Synth-Seconds, X-Audio-Seconds, X-Meta", "Cache-Control": "no-store"}) | |
| def stream_stop(sid: str, request: Request): | |
| st = _streams.get(sid) | |
| if st and st["owner"] == who(request)["id"]: | |
| _streams.pop(sid, None) | |
| return {"ok": True} | |
| # ----------------------------------------------------------------------------------------------- language-model features | |
| def _llm(ident: dict, messages: list, max_tokens: int, temperature: float) -> str: | |
| if ident["kind"] != "user": | |
| raise HTTPException(401, "sign_in") | |
| body = {"model": LLM_MODEL, "messages": messages, "max_tokens": max_tokens, "temperature": temperature, | |
| "chat_template_kwargs": {"thinking": False, "enable_thinking": False}} | |
| req = urllib.request.Request(ROUTER, data=json.dumps(body).encode(), method="POST", | |
| headers={"Authorization": f"Bearer {ident['token']}", "Content-Type": "application/json"}) | |
| try: | |
| with urllib.request.urlopen(req, timeout=120) as f: | |
| return json.loads(f.read())["choices"][0]["message"]["content"] or "" | |
| except urllib.error.HTTPError as e: | |
| if e.code in (401, 403): | |
| raise HTTPException(403, "hf_permission") | |
| if e.code == 402: | |
| raise HTTPException(402, "hf_credits") | |
| raise HTTPException(502, "llm_failed") | |
| except Exception: # noqa: BLE001 | |
| raise HTTPException(502, "llm_failed") | |
| POD_SYS = """تو نویسندهٔ حرفهای پادکست فارسی هستی. متنی مینویسی که برای خواندهشدن با صدای انسانگونه ساخته شده. | |
| قالب خروجی، بدون هیچ چیز دیگر: هر خط «نام گوینده: متن». نه عنوان، نه توضیح، نه markdown، نه شمارهگذاری. | |
| قواعد: | |
| - فقط از نامهای گویندهای که داده شده استفاده کن. | |
| - نوبتها کوتاه و طبیعی باشد (۱ تا ۳ جمله)، مثل گفتوگوی واقعی: واکنشهای کوتاه («آره دقیقاً»، «جالبه!»، «واقعاً؟»)، سؤال، شوخی ملایم. | |
| - گاهی خندهٔ کوتاه با «هاهاها» یا مکث فکر با «اممم…» بگذار؛ زیادهروی نکن. | |
| - بین بخشهای اصلی یک خط جدا فقط با [pause:1.0] بگذار. | |
| - شروع: خوشامد به شنوندهها و معرفی نام پادکست و موضوع. پایان: جمعبندی کوتاه و خداحافظی گرم. | |
| - اعداد و تاریخ را به رقم بنویس. ایموجی نگذار. جملهها را با نقطه، ویرگول، علامت سؤال و تعجب درست تمام کن. | |
| - محاوره را با املای استاندارد و رایج بنویس (مثلاً «میشه»، «اومدیم»، «نمیدونم») و نیمفاصله را رعایت کن. هیچ اِعرابی نگذار.""" | |
| class PodReq(BaseModel): | |
| source: str | |
| name: str = "پادکست" | |
| hosts: list | |
| tone: str = "casual" | |
| minutes: int = 2 | |
| def podcast_script(r: PodReq, request: Request): | |
| ident = who(request) | |
| hosts = [h for h in (r.hosts or []) if isinstance(h, dict) and str(h.get("name", "")).strip()][:4] | |
| if not hosts: | |
| raise HTTPException(422, "no_hosts") | |
| src = (r.source or "").strip()[:8000] | |
| if not src: | |
| raise HTTPException(422, "empty") | |
| minutes = max(1, min(5, int(r.minutes))) | |
| tone = "خودمانی و محاورهای (گفتار روزمرهٔ تهرانی، مؤدبانه)" if r.tone == "casual" else "رسمی، روان و حرفهای" | |
| who_ = "، ".join(f"{h['name']} ({h.get('role') or 'میزبان'})" for h in hosts) | |
| user = (f"نام پادکست: {r.name or 'پادکست'}\nگویندهها: {who_}\nلحن: {tone}\nطول تقریبی: {minutes} دقیقه " | |
| f"(حدود {minutes * 140} کلمه)\n" + ("متن منبع:" if len(src) > 300 else "موضوع:") + "\n" + src) | |
| text = _llm(ident, [{"role": "system", "content": POD_SYS}, {"role": "user", "content": user}], | |
| min(4000, 400 + minutes * 450), 0.8) | |
| names = {str(h["name"]).strip() for h in hosts} | |
| lines = [] | |
| for ln in text.splitlines(): | |
| ln = ln.strip().strip("*").strip() | |
| if ln.startswith("[pause"): | |
| lines.append(ln) | |
| elif ":" in ln or ":" in ln: | |
| ln = ln.replace(":", ":") | |
| if ln.split(":", 1)[0].strip() in names: | |
| lines.append(ln) | |
| return {"script": "\n".join(lines)} | |
| class SuggestReq(BaseModel): | |
| word: str | |
| sentence: str | None = None | |
| def lexicon_suggest(r: SuggestReq, request: Request): | |
| ident = who(request) | |
| if not state["ready"]: | |
| raise HTTPException(503, "loading") | |
| w = (r.word or "").strip()[:40] | |
| if not w: | |
| raise HTTPException(422, "empty") | |
| m = get_model("int8") | |
| rd = user_reader(ident) | |
| try: | |
| cands = m.suggest(w, sentence=(r.sentence or "")[:300] or None, reader=rd if rd is not None else False) | |
| except Exception: # noqa: BLE001 | |
| cands = [] | |
| return {"candidates": cands, "llm": rd is not None} | |
| class SayReq(BaseModel): | |
| reading: str | |
| word: str | None = None | |
| sentence: str | None = None | |
| voice: str | None = "default" | |
| def lexicon_say(r: SayReq, request: Request): | |
| ident = who(request) | |
| if ident["kind"] != "user": | |
| raise HTTPException(401, "sign_in") | |
| if not state["ready"]: | |
| raise HTTPException(503, "loading") | |
| reading = (r.reading or "").strip()[:80] | |
| if not reading or (not re.search(r"[-ۿ]", reading) and not re.fullmatch(r"[A-Za-z' ]+", reading)): | |
| raise HTTPException(422, "bad_reading") | |
| charge(ident, 60) | |
| m = get_model("int8") | |
| sent = (r.sentence or "")[:300] if (r.sentence and r.word and r.word in r.sentence) else None | |
| with _synth_lock: | |
| wav, sr = m.say_word(reading, voice=r.voice if r.voice in m.voices else "default", sentence=sent, word=r.word) | |
| return Response(wav_bytes(wav, sr), media_type="audio/wav") | |
| async def to_mp3(request: Request): | |
| """Browser-joined WAV -> MP3 (ffmpeg) for a small, universal download.""" | |
| if who(request)["kind"] != "user": | |
| raise HTTPException(401, "sign_in") | |
| data = await request.body() | |
| if not data or len(data) > 150 * 1024 * 1024: | |
| raise HTTPException(413, "too_large") | |
| with tempfile.TemporaryDirectory() as d: | |
| src, dst = os.path.join(d, "in.wav"), os.path.join(d, "out.mp3") | |
| open(src, "wb").write(data) | |
| p = subprocess.run(["ffmpeg", "-loglevel", "error", "-y", "-i", src, "-ac", "1", "-ar", "44100", "-b:a", "128k", dst]) | |
| if p.returncode or not os.path.exists(dst): | |
| raise HTTPException(500, "mp3_failed") | |
| return Response(open(dst, "rb").read(), media_type="audio/mpeg", | |
| headers={"Content-Disposition": "attachment; filename=decho.mp3"}) | |
| # ----------------------------------------------------------------------------------------------- pages | |
| app.mount("/static", StaticFiles(directory=os.path.join(HERE, "static")), name="static") | |
| def index(): | |
| return FileResponse(os.path.join(HERE, "static", "index.html"), headers={"Cache-Control": "no-cache"}) | |
| def robots(): | |
| return Response("User-agent: *\nAllow: /\n", media_type="text/plain") | |
| def healthz(): | |
| return {"ok": True, "ready": state["ready"]} | |