github-actions[bot] commited on
Commit
fa2c906
·
1 Parent(s): 1b20879

Deploy from Achraf-cyber/hackton-locallang@e4d010ee5e0113719a38ea25d4ff0cc64b06b224

Browse files
Files changed (5) hide show
  1. Dockerfile +11 -4
  2. Dockerfile.asr +33 -0
  3. README.md +18 -5
  4. app/main_asr.py +123 -0
  5. 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
- # Space dedie a la traduction (facebook/nllb-200-3.3B, ~13 Go) : l'ASR/TTS
14
- # lourds (omnilingual-asr, omnivoice) sont geres dans un Space separe, voir
15
- # le prompt de migration. ASR_BACKEND/TTS_BACKEND_DYU restent sur leurs
16
- # defauts legers (Settings.local / mms).
 
 
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 est deploye comme un Space Docker (header YAML ci-dessus :
41
- `sdk: docker`, `app_port: 7860`), automatiquement a chaque push sur `main`
42
- via le workflow `.github/workflows/deploy-model-service.yml` (voir la racine
43
- du monorepo). Le Space lit `ALLOWED_ORIGINS` et `HF_TOKEN` depuis ses propres
44
- "Secrets" (Settings > Variables and secrets du Space), pas depuis ce repo.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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()