Spaces:
Sleeping
Sleeping
github-actions[bot] commited on
Commit ·
fa2c906
1
Parent(s): 1b20879
Deploy from Achraf-cyber/hackton-locallang@e4d010ee5e0113719a38ea25d4ff0cc64b06b224
Browse files- Dockerfile +11 -4
- Dockerfile.asr +33 -0
- README.md +18 -5
- app/main_asr.py +123 -0
- app/services/tts.py +14 -0
Dockerfile
CHANGED
|
@@ -8,12 +8,19 @@ WORKDIR /app
|
|
| 8 |
COPY requirements.txt .
|
| 9 |
RUN pip install --no-cache-dir -r requirements.txt
|
| 10 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
COPY . .
|
| 12 |
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
#
|
| 16 |
-
#
|
|
|
|
|
|
|
| 17 |
|
| 18 |
EXPOSE 7860
|
| 19 |
|
|
|
|
| 8 |
COPY requirements.txt .
|
| 9 |
RUN pip install --no-cache-dir -r requirements.txt
|
| 10 |
|
| 11 |
+
# TTS_BACKEND_DYU=omnivoice : test de k2-fsa/OmniVoice pour le dioula (Qwen3
|
| 12 |
+
# backbone + audio_tokenizer separe, pas de dependance fairseq2/omnilingual-asr
|
| 13 |
+
# contrairement a l'ASR -- safe a installer meme dans ce Space translation-only.
|
| 14 |
+
RUN pip install --no-cache-dir retrying xxhash omnivoice
|
| 15 |
+
|
| 16 |
COPY . .
|
| 17 |
|
| 18 |
+
ENV TTS_BACKEND_DYU=omnivoice
|
| 19 |
+
|
| 20 |
+
# Space dedie a la traduction (facebook/nllb-200-3.3B, ~13 Go) + test TTS
|
| 21 |
+
# OmniVoice dyu. L'ASR lourd (omnilingual-asr/fairseq2) est gere dans un
|
| 22 |
+
# Space separe, voir le prompt de migration. ASR_BACKEND reste sur son
|
| 23 |
+
# defaut leger (Settings.local).
|
| 24 |
|
| 25 |
EXPOSE 7860
|
| 26 |
|
Dockerfile.asr
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
+
|
| 3 |
+
RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg \
|
| 4 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 5 |
+
|
| 6 |
+
WORKDIR /app
|
| 7 |
+
|
| 8 |
+
COPY requirements.txt .
|
| 9 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 10 |
+
|
| 11 |
+
# ASR_BACKEND=omnilingual_llm (omniASR-LLM-7B, ~29 Go) : Linux uniquement
|
| 12 |
+
# (fairseq2n n'a aucun wheel Windows, d'ou l'absence de ces paquets dans
|
| 13 |
+
# requirements.txt) -- safe a installer ici car ce Space (dedie a l'ASR)
|
| 14 |
+
# tourne toujours sous Linux, et n'a plus a partager sa RAM avec NLLB/TTS
|
| 15 |
+
# (voir model-service/Dockerfile, le Space traduction separe).
|
| 16 |
+
# torch/torchaudio doivent matcher EXACTEMENT le wheel fairseq2 prebuild
|
| 17 |
+
# (voir requirements-omnilingual.txt) ; --no-deps evite que omnilingual-asr
|
| 18 |
+
# ne re-resolve torch en variante CUDA par dessus.
|
| 19 |
+
RUN pip install --no-cache-dir "torch==2.9.1" "torchaudio==2.9.1" \
|
| 20 |
+
--index-url https://download.pytorch.org/whl/cpu \
|
| 21 |
+
&& pip install --no-cache-dir "fairseq2" \
|
| 22 |
+
--extra-index-url https://fair.pkg.atmeta.com/fairseq2/whl/pt2.9.1/cpu \
|
| 23 |
+
--trusted-host fair.pkg.atmeta.com \
|
| 24 |
+
&& pip install --no-cache-dir omnilingual-asr --no-deps \
|
| 25 |
+
&& pip install --no-cache-dir retrying xxhash
|
| 26 |
+
|
| 27 |
+
COPY . .
|
| 28 |
+
|
| 29 |
+
ENV ASR_BACKEND=omnilingual_llm
|
| 30 |
+
|
| 31 |
+
EXPOSE 7860
|
| 32 |
+
|
| 33 |
+
CMD ["uvicorn", "app.main_asr:app", "--host", "0.0.0.0", "--port", "7860"]
|
README.md
CHANGED
|
@@ -37,8 +37,21 @@ uvicorn app.main:app --reload --port 8000
|
|
| 37 |
|
| 38 |
## Deploiement sur Hugging Face Spaces
|
| 39 |
|
| 40 |
-
Ce dossier
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
|
| 38 |
## Deploiement sur Hugging Face Spaces
|
| 39 |
|
| 40 |
+
Ce dossier alimente DEUX Spaces Docker separes (16 Go de RAM chacun),
|
| 41 |
+
synchronises automatiquement a chaque push sur `main` :
|
| 42 |
+
|
| 43 |
+
- **Traduction + TTS** (`AchrafCyber/model-service`, header YAML ci-dessus :
|
| 44 |
+
`sdk: docker`, `app_port: 7860`, `Dockerfile`) : NLLB-200-3.3B + TTS
|
| 45 |
+
dyu/mos. Workflow : `.github/workflows/deploy-model-service.yml`.
|
| 46 |
+
- **ASR** (`AchrafCyber/asr-service`, `Dockerfile.asr`, point d'entree
|
| 47 |
+
`app/main_asr.py`) : omniASR-LLM-7B (Meta Omnilingual ASR). Workflow :
|
| 48 |
+
`.github/workflows/deploy-asr-service.yml`, qui renomme `Dockerfile.asr`
|
| 49 |
+
en `Dockerfile` dans le Space cible (HF Spaces exige ce nom exact).
|
| 50 |
+
|
| 51 |
+
Les deux workflows partagent le meme dossier source (`model-service/`) mais
|
| 52 |
+
poussent vers deux repos Space distincts avec un `Dockerfile` different.
|
| 53 |
+
Chaque Space lit ses propres "Secrets" (Settings > Variables and secrets du
|
| 54 |
+
Space : `ALLOWED_ORIGINS`, `HF_TOKEN`), pas ce repo. Cote backend, l'URL du
|
| 55 |
+
Space ASR se configure via `ASR_SERVICE_URL` (voir `backend/lib/env.ts`) ;
|
| 56 |
+
si absente, `/transcribe` retombe sur `MODEL_SERVICE_URL` (mode "un seul
|
| 57 |
+
Space", utile en local).
|
app/main_asr.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""model-service-asr - FastAPI app dediee a l'ASR (Space separe du service de
|
| 2 |
+
traduction/TTS, voir model-service/Dockerfile.asr).
|
| 3 |
+
|
| 4 |
+
Lancement local :
|
| 5 |
+
uvicorn app.main_asr:app --reload --port 8001
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import logging
|
| 9 |
+
import time
|
| 10 |
+
import uuid
|
| 11 |
+
from pathlib import Path
|
| 12 |
+
from typing import Literal
|
| 13 |
+
|
| 14 |
+
from fastapi import FastAPI, File, Form, UploadFile
|
| 15 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 16 |
+
from fastapi.responses import HTMLResponse
|
| 17 |
+
from pydantic import BaseModel
|
| 18 |
+
|
| 19 |
+
from app.deps import get_settings
|
| 20 |
+
from app.services.asr import (
|
| 21 |
+
HF_API_MODEL_NAME,
|
| 22 |
+
MODEL_NAME as ASR_LOCAL_MODEL_NAME,
|
| 23 |
+
OMNILINGUAL_CTC_MODEL_CARD,
|
| 24 |
+
OMNILINGUAL_LLM_MODEL_CARD,
|
| 25 |
+
OMNILINGUAL_MODEL_CARD,
|
| 26 |
+
ASR,
|
| 27 |
+
)
|
| 28 |
+
|
| 29 |
+
logging.basicConfig(level=logging.INFO)
|
| 30 |
+
logger = logging.getLogger("model-service-asr")
|
| 31 |
+
|
| 32 |
+
settings = get_settings()
|
| 33 |
+
|
| 34 |
+
MEDIA_DIR = Path(__file__).resolve().parent.parent / "media"
|
| 35 |
+
MEDIA_DIR.mkdir(parents=True, exist_ok=True)
|
| 36 |
+
|
| 37 |
+
app = FastAPI(
|
| 38 |
+
title="model-service-asr",
|
| 39 |
+
description="Expose l'ASR (dyu/mos/fra) - Space separe de la traduction/TTS.",
|
| 40 |
+
)
|
| 41 |
+
|
| 42 |
+
app.add_middleware(
|
| 43 |
+
CORSMiddleware,
|
| 44 |
+
allow_origins=settings.ALLOWED_ORIGINS,
|
| 45 |
+
allow_credentials=True,
|
| 46 |
+
allow_methods=["*"],
|
| 47 |
+
allow_headers=["*"],
|
| 48 |
+
)
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
class TranscribeResponse(BaseModel):
|
| 52 |
+
text: str
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
_ASR_MODEL_NAMES = {
|
| 56 |
+
"local": ASR_LOCAL_MODEL_NAME,
|
| 57 |
+
"hf_api": HF_API_MODEL_NAME,
|
| 58 |
+
"omnilingual": f"facebook/{OMNILINGUAL_MODEL_CARD}",
|
| 59 |
+
"omnilingual_ctc": f"facebook/{OMNILINGUAL_CTC_MODEL_CARD}",
|
| 60 |
+
"omnilingual_llm": f"facebook/{OMNILINGUAL_LLM_MODEL_CARD}",
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
@app.get("/", response_class=HTMLResponse)
|
| 65 |
+
def dashboard() -> str:
|
| 66 |
+
asr_loaded = "chargé" if ASR._instance is not None else "pas encore chargé (lazy)"
|
| 67 |
+
model_name = _ASR_MODEL_NAMES.get(settings.ASR_BACKEND, settings.ASR_BACKEND)
|
| 68 |
+
return f"""<!DOCTYPE html>
|
| 69 |
+
<html lang="fr">
|
| 70 |
+
<head>
|
| 71 |
+
<meta charset="utf-8">
|
| 72 |
+
<title>model-service-asr — état</title>
|
| 73 |
+
<style>
|
| 74 |
+
body {{ font-family: system-ui, sans-serif; background: #0f172a; color: #e2e8f0; padding: 2rem; }}
|
| 75 |
+
h1 {{ font-size: 1.25rem; }}
|
| 76 |
+
table {{ border-collapse: collapse; width: 100%; max-width: 720px; margin-top: 1rem; }}
|
| 77 |
+
td {{ padding: 0.5rem 0.75rem; border-bottom: 1px solid #334155; }}
|
| 78 |
+
td:first-child {{ color: #94a3b8; white-space: nowrap; }}
|
| 79 |
+
code {{ color: #7dd3fc; }}
|
| 80 |
+
.ok {{ color: #4ade80; }}
|
| 81 |
+
</style>
|
| 82 |
+
</head>
|
| 83 |
+
<body>
|
| 84 |
+
<h1>🩺 model-service-asr — <span class="ok">en ligne</span></h1>
|
| 85 |
+
<table>
|
| 86 |
+
<tr><td>Composant</td><td>Modèle actif</td><td>État</td></tr>
|
| 87 |
+
<tr><td>ASR (dyu/mos/fra)</td><td><code>{model_name}</code></td><td>{asr_loaded}</td></tr>
|
| 88 |
+
</table>
|
| 89 |
+
<p style="color:#64748b; margin-top:1.5rem;">
|
| 90 |
+
Config via la variable d'env ASR_BACKEND. Le modèle est chargé au premier appel
|
| 91 |
+
(singleton paresseux), donc "pas encore chargé" juste après un redémarrage est normal.
|
| 92 |
+
</p>
|
| 93 |
+
</body>
|
| 94 |
+
</html>"""
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
@app.get("/health")
|
| 98 |
+
def health() -> dict[str, str]:
|
| 99 |
+
return {"status": "ok"}
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
@app.post("/transcribe", response_model=TranscribeResponse)
|
| 103 |
+
async def transcribe(
|
| 104 |
+
file: UploadFile = File(...),
|
| 105 |
+
lang: Literal["dyu", "mos", "fra"] = Form(...),
|
| 106 |
+
) -> TranscribeResponse:
|
| 107 |
+
start = time.perf_counter()
|
| 108 |
+
|
| 109 |
+
suffix = Path(file.filename or "audio").suffix or ".wav"
|
| 110 |
+
tmp_path = MEDIA_DIR / f"{uuid.uuid4()}{suffix}"
|
| 111 |
+
contents = await file.read()
|
| 112 |
+
tmp_path.write_bytes(contents)
|
| 113 |
+
|
| 114 |
+
try:
|
| 115 |
+
asr = ASR.get_instance()
|
| 116 |
+
text = asr.transcribe(str(tmp_path), lang)
|
| 117 |
+
finally:
|
| 118 |
+
tmp_path.unlink(missing_ok=True)
|
| 119 |
+
|
| 120 |
+
elapsed = time.perf_counter() - start
|
| 121 |
+
logger.info("POST /transcribe lang=%s duration=%.3fs", lang, elapsed)
|
| 122 |
+
|
| 123 |
+
return TranscribeResponse(text=text)
|
app/services/tts.py
CHANGED
|
@@ -40,6 +40,19 @@ _ACCENT_TRANSLATION = str.maketrans(
|
|
| 40 |
_CONSONANT_FALLBACK = {"c": "k", "h": "", "j": "z", "q": "k", "x": "ks"}
|
| 41 |
_CH_DIGRAPH_RE = re.compile(r"ch", re.IGNORECASE)
|
| 42 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
|
| 44 |
class TTS:
|
| 45 |
_instance = None
|
|
@@ -189,6 +202,7 @@ class TTS:
|
|
| 189 |
|
| 190 |
def speak(self, text: str, lang: str, output_path: str) -> str:
|
| 191 |
settings = get_settings()
|
|
|
|
| 192 |
if lang == "dyu" and settings.TTS_BACKEND_DYU == "omnivoice":
|
| 193 |
try:
|
| 194 |
model = self._get_omnivoice_model()
|
|
|
|
| 40 |
_CONSONANT_FALLBACK = {"c": "k", "h": "", "j": "z", "q": "k", "x": "ks"}
|
| 41 |
_CH_DIGRAPH_RE = re.compile(r"ch", re.IGNORECASE)
|
| 42 |
|
| 43 |
+
# Convention administrative francophone : NOM en majuscules, Prenom en casse
|
| 44 |
+
# normale (ex. "SIMBRE Achraf"). VITS (mms-tts) traite un mot tout en
|
| 45 |
+
# majuscules comme une unite a part (sigle/acronyme) et marque une coupure
|
| 46 |
+
# audible avant le mot suivant -- un nom de famille se retrouve alors detache
|
| 47 |
+
# du prenom au lieu d'etre prononce comme un seul groupe. Les vrais sigles
|
| 48 |
+
# sont deja proscrits en amont (MT_FRIENDLY_RULE, backend/lib/llm.ts) donc on
|
| 49 |
+
# neutralise sans risque la casse de tout mot entierement en majuscules.
|
| 50 |
+
_ALLCAPS_WORD_RE = re.compile(r"\b[A-ZÀ-Ö]{2,}\b")
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def _normalize_allcaps_names(text: str) -> str:
|
| 54 |
+
return _ALLCAPS_WORD_RE.sub(lambda m: m.group(0).capitalize(), text)
|
| 55 |
+
|
| 56 |
|
| 57 |
class TTS:
|
| 58 |
_instance = None
|
|
|
|
| 202 |
|
| 203 |
def speak(self, text: str, lang: str, output_path: str) -> str:
|
| 204 |
settings = get_settings()
|
| 205 |
+
text = _normalize_allcaps_names(text)
|
| 206 |
if lang == "dyu" and settings.TTS_BACKEND_DYU == "omnivoice":
|
| 207 |
try:
|
| 208 |
model = self._get_omnivoice_model()
|