github-actions[bot] commited on
Commit
f4ec4f5
·
1 Parent(s): 6b5a6dc

Deploy from Achraf-cyber/hackton-locallang@0c3f105334ac28b550aa393ba64fcd8c3167851d

Browse files
app/deps.py CHANGED
@@ -14,6 +14,14 @@ class Settings(BaseSettings):
14
  TTS_BACKEND_DYU: Literal["mms", "omnivoice"] = "mms"
15
  HF_TOKEN: str | None = None
16
 
 
 
 
 
 
 
 
 
17
 
18
  @lru_cache
19
  def get_settings() -> Settings:
 
14
  TTS_BACKEND_DYU: Literal["mms", "omnivoice"] = "mms"
15
  HF_TOKEN: str | None = None
16
 
17
+ # Stack de modeles : "old" (facebook/nllb + mms) ou "goaicorp" (modeles
18
+ # GO AI Corporation, licence CC-BY-NC 4.0, usage non-commercial). Defaut
19
+ # "old" = aucun changement en prod tant que la variable n'est pas positionnee
20
+ # explicitement a "goaicorp" dans les secrets du Space HF.
21
+ # NB : pour le TTS dioula, GO AI n'a PAS de modele ; les deux stacks
22
+ # utilisent donc facebook/mms-tts-dyu pour le dioula (voir tts.py).
23
+ MODEL_STACK: Literal["old", "goaicorp"] = "old"
24
+
25
 
26
  @lru_cache
27
  def get_settings() -> Settings:
app/services/asr.py CHANGED
@@ -1,7 +1,8 @@
1
  """Reconnaissance vocale (speech-to-text) pour le Dioula, le Moore et le francais
2
  via facebook/mms-1b-all.
3
 
4
- Trois backends, choisis par Settings.ASR_BACKEND :
 
5
  - "local" (defaut) : Wav2Vec2ForCTC + AutoProcessor charges en local.
6
  - "hf_api" : pont temporaire vers l'API d'inference Hugging Face, utile tant
7
  que le modele local (~3.86 Go) n'est pas entierement telecharge.
@@ -18,6 +19,14 @@ Trois backends, choisis par Settings.ASR_BACKEND :
18
  wheel Windows -- fonctionne uniquement sous Linux/WSL. L'import est fait en
19
  lazy pour ne pas casser les backends "local"/"hf_api" sur une machine
20
  Windows sans ce paquet.
 
 
 
 
 
 
 
 
21
  Le contrat de transcribe(audio_path, lang) est identique dans tous les cas.
22
  """
23
 
@@ -36,6 +45,15 @@ logger = logging.getLogger("model-service.asr")
36
  MODEL_NAME = "facebook/mms-1b-all"
37
  HF_API_MODEL_NAME = "openai/whisper-large-v3"
38
 
 
 
 
 
 
 
 
 
 
39
  MMS_LANG_CODES = {
40
  "dyu": "dyu",
41
  "mos": "mos",
@@ -62,6 +80,15 @@ class ASR:
62
  def __init__(self) -> None:
63
  settings = get_settings()
64
  self.backend = settings.ASR_BACKEND
 
 
 
 
 
 
 
 
 
65
 
66
  if self.backend == "hf_api":
67
  self._client = InferenceClient(model=HF_API_MODEL_NAME, token=settings.HF_TOKEN)
@@ -137,7 +164,38 @@ class ASR:
137
  )
138
  return result[0].strip()
139
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
140
  def transcribe(self, audio_path: str, lang: str) -> str:
 
 
 
141
  if self.backend == "hf_api":
142
  return self._transcribe_hf_api(audio_path, lang)
143
 
 
1
  """Reconnaissance vocale (speech-to-text) pour le Dioula, le Moore et le francais
2
  via facebook/mms-1b-all.
3
 
4
+ Quatre backends, choisis par Settings.ASR_BACKEND, et une stack GO AI distincte
5
+ contrôlee par Settings.MODEL_STACK :
6
  - "local" (defaut) : Wav2Vec2ForCTC + AutoProcessor charges en local.
7
  - "hf_api" : pont temporaire vers l'API d'inference Hugging Face, utile tant
8
  que le modele local (~3.86 Go) n'est pas entierement telecharge.
 
19
  wheel Windows -- fonctionne uniquement sous Linux/WSL. L'import est fait en
20
  lazy pour ne pas casser les backends "local"/"hf_api" sur une machine
21
  Windows sans ce paquet.
22
+
23
+ Quand Settings.MODEL_STACK == "goaicorp", tous les backends ci-dessus sont
24
+ ignores au profit des modeles GO AI Corporation (licence CC-BY-NC 4.0) :
25
+ goaicorp/mos-asr (Mooré)
26
+ goaicorp/dyu-asr (Dioula)
27
+ Charges via transformers pipeline("automatic-speech-recognition"), qui est
28
+ agnostique de l'architecture (Whisper, Wav2Vec2, etc.) et s'adapte
29
+ automatiquement au modele telecharge.
30
  Le contrat de transcribe(audio_path, lang) est identique dans tous les cas.
31
  """
32
 
 
45
  MODEL_NAME = "facebook/mms-1b-all"
46
  HF_API_MODEL_NAME = "openai/whisper-large-v3"
47
 
48
+ # Stack GO AI (MODEL_STACK=goaicorp) — modeles specialises par langue
49
+ # Licence CC-BY-NC 4.0 (contact commercial : aristide@goaicorporation.org)
50
+ GOAICORP_ASR_MODEL_NAMES = {
51
+ "mos": "goaicorp/mos-asr",
52
+ "dyu": "goaicorp/dyu-asr",
53
+ # Pas de modele GO AI pour le français : fallback sur Whisper (hf_api)
54
+ "fra": "openai/whisper-large-v3",
55
+ }
56
+
57
  MMS_LANG_CODES = {
58
  "dyu": "dyu",
59
  "mos": "mos",
 
80
  def __init__(self) -> None:
81
  settings = get_settings()
82
  self.backend = settings.ASR_BACKEND
83
+ self._stack = settings.MODEL_STACK
84
+ self._hf_token = settings.HF_TOKEN
85
+
86
+ # La stack goaicorp remplace tous les backends ASR_BACKEND par les
87
+ # modeles GO AI, charges via pipeline() au premier appel (lazy).
88
+ if self._stack == "goaicorp":
89
+ self._goaicorp_pipelines: dict[str, object] = {}
90
+ logger.info("ASR: stack=goaicorp (CC-BY-NC 4.0, GO AI Corporation)")
91
+ return
92
 
93
  if self.backend == "hf_api":
94
  self._client = InferenceClient(model=HF_API_MODEL_NAME, token=settings.HF_TOKEN)
 
164
  )
165
  return result[0].strip()
166
 
167
+ def _get_goaicorp_pipeline(self, lang: str) -> object:
168
+ """Charge et met en cache le pipeline ASR GO AI pour la langue donnee.
169
+
170
+ pipeline("automatic-speech-recognition") est agnostique de
171
+ l'architecture : fonctionne pour Whisper, Wav2Vec2, Conformer, etc.
172
+ Le HF_TOKEN est necessaire pour les repos gated.
173
+ """
174
+ if lang not in self._goaicorp_pipelines:
175
+ repo_id = GOAICORP_ASR_MODEL_NAMES.get(lang)
176
+ if repo_id is None:
177
+ raise ValueError(f"Langue non supportee par la stack goaicorp ASR: {lang}")
178
+ from transformers import pipeline as hf_pipeline
179
+ logger.info("Chargement pipeline GO AI ASR %s (lang=%s)...", repo_id, lang)
180
+ device = 0 if torch.cuda.is_available() else -1
181
+ self._goaicorp_pipelines[lang] = hf_pipeline(
182
+ "automatic-speech-recognition",
183
+ model=repo_id,
184
+ token=self._hf_token,
185
+ device=device,
186
+ )
187
+ logger.info("Pipeline GO AI ASR %s charge.", repo_id)
188
+ return self._goaicorp_pipelines[lang]
189
+
190
+ def _transcribe_goaicorp(self, audio_path: str, lang: str) -> str:
191
+ pipe = self._get_goaicorp_pipeline(lang)
192
+ result = pipe(audio_path, return_timestamps=False)
193
+ return result["text"].strip() if isinstance(result, dict) else str(result).strip()
194
+
195
  def transcribe(self, audio_path: str, lang: str) -> str:
196
+ if self._stack == "goaicorp":
197
+ return self._transcribe_goaicorp(audio_path, lang)
198
+
199
  if self.backend == "hf_api":
200
  return self._transcribe_hf_api(audio_path, lang)
201
 
app/services/translator.py CHANGED
@@ -1,10 +1,24 @@
 
1
  import re
2
 
3
  import torch
4
  from transformers import AutoModelForSeq2SeqLM, AutoTokenizer
5
 
 
 
 
 
 
6
  MODEL_NAME = "facebook/nllb-200-3.3B"
7
 
 
 
 
 
 
 
 
 
8
  NLLB_LANG_CODES = {
9
  "fr": "fra_Latn",
10
  "dyu": "dyu_Latn",
@@ -18,10 +32,29 @@ class Translator:
18
  _instance = None
19
 
20
  def __init__(self) -> None:
 
21
  self.device = "cuda" if torch.cuda.is_available() else "cpu"
22
- self.nllb_tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME)
23
- self.nllb_model = AutoModelForSeq2SeqLM.from_pretrained(MODEL_NAME).to(self.device)
24
- self.nllb_model.eval()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
25
 
26
  @classmethod
27
  def get_instance(cls) -> "Translator":
@@ -29,29 +62,92 @@ class Translator:
29
  cls._instance = cls()
30
  return cls._instance
31
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
32
  def _split_sentences(self, text: str) -> list[str]:
33
  sentences = [s.strip() for s in _SENTENCE_SPLIT_RE.split(text.strip()) if s.strip()]
34
  return sentences or [text.strip()]
35
 
36
- def _translate_batch(self, sentences: list[str], src: str, tgt: str) -> list[str]:
37
- self.nllb_tokenizer.src_lang = NLLB_LANG_CODES[src]
38
- inputs = self.nllb_tokenizer(sentences, return_tensors="pt", padding=True).to(self.device)
39
- forced_bos_token_id = self.nllb_tokenizer.convert_tokens_to_ids(NLLB_LANG_CODES[tgt])
 
 
 
 
 
 
 
40
  with torch.no_grad():
41
- generated = self.nllb_model.generate(
42
  **inputs,
43
  forced_bos_token_id=forced_bos_token_id,
44
  num_beams=4,
45
  max_length=256,
46
  )
47
- return self.nllb_tokenizer.batch_decode(generated, skip_special_tokens=True)
48
 
49
  def translate(self, text: str, src: str, tgt: str) -> str:
50
  if src not in ["fr", "dyu", "mos"] or tgt not in ["fr", "dyu", "mos"]:
51
  raise ValueError(f"Langue non supportee: src={src}, tgt={tgt}")
52
 
53
  sentences = self._split_sentences(text)
54
- translated = self._translate_batch(sentences, src, tgt)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
55
  return " ".join(translated)
56
 
57
 
 
1
+ import logging
2
  import re
3
 
4
  import torch
5
  from transformers import AutoModelForSeq2SeqLM, AutoTokenizer
6
 
7
+ from app.deps import get_settings
8
+
9
+ logger = logging.getLogger("model-service.translator")
10
+
11
+ # Stack "old" : un seul modele NLLB-3.3B couvrant dyu ET mos.
12
  MODEL_NAME = "facebook/nllb-200-3.3B"
13
 
14
+ # Stack "goaicorp" : deux modeles specialises CC-BY-NC 4.0
15
+ # (Wendpanga Aristide Bandaogo, aristide@goaicorporation.org)
16
+ # Architecture M2M100ForConditionalGeneration = NLLB, memes codes de langue.
17
+ GOAICORP_MODEL_NAMES = {
18
+ "mos": "goaicorp/mos-translation",
19
+ "dyu": "goaicorp/dyu-translation",
20
+ }
21
+
22
  NLLB_LANG_CODES = {
23
  "fr": "fra_Latn",
24
  "dyu": "dyu_Latn",
 
32
  _instance = None
33
 
34
  def __init__(self) -> None:
35
+ settings = get_settings()
36
  self.device = "cuda" if torch.cuda.is_available() else "cpu"
37
+ self._stack = settings.MODEL_STACK
38
+ self._hf_token = settings.HF_TOKEN
39
+
40
+ if self._stack == "goaicorp":
41
+ # Chargement paresseux par langue (voir _get_goaicorp_model) :
42
+ # on n'alloue pas la RAM pour les deux modeles si une seule
43
+ # direction est utilisee, et on ne bloque pas le demarrage du
44
+ # Space sur le premier modele si le second est encore en cours
45
+ # de telechargement.
46
+ self._goaicorp_models: dict[str, AutoModelForSeq2SeqLM] = {}
47
+ self._goaicorp_tokenizers: dict[str, AutoTokenizer] = {}
48
+ logger.info("Translator: stack=goaicorp (CC-BY-NC 4.0, GO AI Corporation)")
49
+ else:
50
+ self.nllb_tokenizer = AutoTokenizer.from_pretrained(
51
+ MODEL_NAME, token=self._hf_token
52
+ )
53
+ self.nllb_model = AutoModelForSeq2SeqLM.from_pretrained(
54
+ MODEL_NAME, token=self._hf_token
55
+ ).to(self.device)
56
+ self.nllb_model.eval()
57
+ logger.info("Translator: stack=old (facebook/nllb-200-3.3B)")
58
 
59
  @classmethod
60
  def get_instance(cls) -> "Translator":
 
62
  cls._instance = cls()
63
  return cls._instance
64
 
65
+ # ------------------------------------------------------------------
66
+ # Interne : chargement paresseux des modeles GO AI
67
+ # ------------------------------------------------------------------
68
+
69
+ def _get_goaicorp_model(
70
+ self, lang: str
71
+ ) -> tuple[AutoModelForSeq2SeqLM, AutoTokenizer]:
72
+ """Charge et met en cache le modele GO AI pour la langue donnee.
73
+
74
+ Les deux modeles (mos / dyu) ont la meme architecture NLLB/M2M100.
75
+ Le token HF est necessaire pour les repos gated -- il doit etre
76
+ configure dans les secrets du Space HF (variable HF_TOKEN).
77
+ """
78
+ if lang not in GOAICORP_MODEL_NAMES:
79
+ raise ValueError(f"Langue non supportee par la stack goaicorp: {lang}")
80
+ if lang not in self._goaicorp_models:
81
+ repo_id = GOAICORP_MODEL_NAMES[lang]
82
+ logger.info("Chargement du modele GO AI %s...", repo_id)
83
+ tok = AutoTokenizer.from_pretrained(repo_id, token=self._hf_token)
84
+ model = AutoModelForSeq2SeqLM.from_pretrained(
85
+ repo_id, token=self._hf_token
86
+ ).to(self.device)
87
+ model.eval()
88
+ self._goaicorp_tokenizers[lang] = tok
89
+ self._goaicorp_models[lang] = model
90
+ logger.info("Modele GO AI %s charge.", repo_id)
91
+ return self._goaicorp_models[lang], self._goaicorp_tokenizers[lang]
92
+
93
+ # ------------------------------------------------------------------
94
+ # Traduction
95
+ # ------------------------------------------------------------------
96
+
97
  def _split_sentences(self, text: str) -> list[str]:
98
  sentences = [s.strip() for s in _SENTENCE_SPLIT_RE.split(text.strip()) if s.strip()]
99
  return sentences or [text.strip()]
100
 
101
+ def _translate_batch_nllb(
102
+ self,
103
+ sentences: list[str],
104
+ src: str,
105
+ tgt: str,
106
+ tokenizer: AutoTokenizer,
107
+ model: AutoModelForSeq2SeqLM,
108
+ ) -> list[str]:
109
+ tokenizer.src_lang = NLLB_LANG_CODES[src]
110
+ inputs = tokenizer(sentences, return_tensors="pt", padding=True).to(self.device)
111
+ forced_bos_token_id = tokenizer.convert_tokens_to_ids(NLLB_LANG_CODES[tgt])
112
  with torch.no_grad():
113
+ generated = model.generate(
114
  **inputs,
115
  forced_bos_token_id=forced_bos_token_id,
116
  num_beams=4,
117
  max_length=256,
118
  )
119
+ return tokenizer.batch_decode(generated, skip_special_tokens=True)
120
 
121
  def translate(self, text: str, src: str, tgt: str) -> str:
122
  if src not in ["fr", "dyu", "mos"] or tgt not in ["fr", "dyu", "mos"]:
123
  raise ValueError(f"Langue non supportee: src={src}, tgt={tgt}")
124
 
125
  sentences = self._split_sentences(text)
126
+
127
+ if self._stack == "goaicorp":
128
+ # Les modeles GO AI sont par paire (mos-translation, dyu-translation).
129
+ # Pour fr->mos on utilise le modele mos ; pour fr->dyu le modele dyu ;
130
+ # pour mos->dyu ou dyu->mos on passe par le français comme pivot :
131
+ # mos->fr avec mos-translation, puis fr->dyu avec dyu-translation.
132
+ if src == "fr":
133
+ # Traduction directe fr -> langue locale
134
+ model, tok = self._get_goaicorp_model(tgt)
135
+ translated = self._translate_batch_nllb(sentences, src, tgt, tok, model)
136
+ elif tgt == "fr":
137
+ # Traduction directe langue locale -> fr
138
+ model, tok = self._get_goaicorp_model(src)
139
+ translated = self._translate_batch_nllb(sentences, src, tgt, tok, model)
140
+ else:
141
+ # Pivot fr : src->fr puis fr->tgt
142
+ src_model, src_tok = self._get_goaicorp_model(src)
143
+ fr_sentences = self._translate_batch_nllb(sentences, src, "fr", src_tok, src_model)
144
+ tgt_model, tgt_tok = self._get_goaicorp_model(tgt)
145
+ translated = self._translate_batch_nllb(fr_sentences, "fr", tgt, tgt_tok, tgt_model)
146
+ else:
147
+ translated = self._translate_batch_nllb(
148
+ sentences, src, tgt, self.nllb_tokenizer, self.nllb_model
149
+ )
150
+
151
  return " ".join(translated)
152
 
153
 
app/services/tts.py CHANGED
@@ -1,5 +1,13 @@
1
  """Synthese vocale (text-to-speech) pour le Dioula et le Moore via les modeles
2
- VITS facebook/mms-tts-dyu et facebook/mms-tts-mos."""
 
 
 
 
 
 
 
 
3
 
4
  import io
5
  import logging
@@ -20,6 +28,13 @@ MMS_TTS_MODEL_NAMES = {
20
  "mos": "facebook/mms-tts-mos",
21
  }
22
 
 
 
 
 
 
 
 
23
  MAX_CHARS_BEFORE_SPLIT = 500
24
  SILENCE_SECONDS = 0.3
25
  MIN_SEGMENT_LETTERS = 4
@@ -65,6 +80,19 @@ class TTS:
65
  self._tokenizers: dict[str, VitsTokenizer] = {}
66
  self._allowed_chars: dict[str, set[str]] = {}
67
  self._omnivoice_model = None
 
 
 
 
 
 
 
 
 
 
 
 
 
68
 
69
  @classmethod
70
  def get_instance(cls) -> "TTS":
@@ -73,12 +101,16 @@ class TTS:
73
  return cls._instance
74
 
75
  def _get_model(self, lang: str) -> tuple[VitsModel, VitsTokenizer]:
76
- if lang not in MMS_TTS_MODEL_NAMES:
77
  raise ValueError(f"Langue non supportee: {lang}")
78
  if lang not in self._models:
79
- model_name = MMS_TTS_MODEL_NAMES[lang]
80
- self._tokenizers[lang] = VitsTokenizer.from_pretrained(model_name)
81
- model = VitsModel.from_pretrained(model_name).to(self.device)
 
 
 
 
82
  model.eval()
83
  self._models[lang] = model
84
  return self._models[lang], self._tokenizers[lang]
 
1
  """Synthese vocale (text-to-speech) pour le Dioula et le Moore via les modeles
2
+ VITS facebook/mms-tts-dyu et facebook/mms-tts-mos.
3
+
4
+ Quand Settings.MODEL_STACK == "goaicorp", le TTS Mooré utilise
5
+ goaicorp/mos-tts (CC-BY-NC 4.0, GO AI Corporation) à la place de
6
+ facebook/mms-tts-mos. L'architecture est identique (VITS/MMS-TTS) : le
7
+ code de synthese est donc entierement reutilise, seul le repo_id change.
8
+
9
+ LE TTS DIOULA NE CHANGE PAS : GO AI n'a pas de modele TTS pour le dioula.
10
+ facebook/mms-tts-dyu est utilise dans les deux stacks."""
11
 
12
  import io
13
  import logging
 
28
  "mos": "facebook/mms-tts-mos",
29
  }
30
 
31
+ # Stack GO AI pour le TTS Mooré uniquement (CC-BY-NC 4.0).
32
+ # Le dioula utilise TOUJOURS facebook/mms-tts-dyu (pas de modele GO AI dyu).
33
+ GOAICORP_TTS_MODEL_NAMES = {
34
+ "mos": "goaicorp/mos-tts",
35
+ "dyu": "facebook/mms-tts-dyu", # inchangé : GO AI n'a pas de modèle dyu TTS
36
+ }
37
+
38
  MAX_CHARS_BEFORE_SPLIT = 500
39
  SILENCE_SECONDS = 0.3
40
  MIN_SEGMENT_LETTERS = 4
 
80
  self._tokenizers: dict[str, VitsTokenizer] = {}
81
  self._allowed_chars: dict[str, set[str]] = {}
82
  self._omnivoice_model = None
83
+ # MODEL_STACK determine quel repo_id utiliser pour le TTS Mooré.
84
+ # Le TTS dioula est toujours sur facebook/mms-tts-dyu (GO AI n'a
85
+ # pas de modele dyu TTS).
86
+ from app.deps import get_settings as _gs
87
+ _s = _gs()
88
+ self._tts_model_names = (
89
+ GOAICORP_TTS_MODEL_NAMES if _s.MODEL_STACK == "goaicorp"
90
+ else MMS_TTS_MODEL_NAMES
91
+ )
92
+ if _s.MODEL_STACK == "goaicorp":
93
+ logger.info("TTS: stack=goaicorp pour Mooré (goaicorp/mos-tts, CC-BY-NC 4.0) ; "
94
+ "Dioula inchangé (facebook/mms-tts-dyu)")
95
+ self._hf_token = _s.HF_TOKEN
96
 
97
  @classmethod
98
  def get_instance(cls) -> "TTS":
 
101
  return cls._instance
102
 
103
  def _get_model(self, lang: str) -> tuple[VitsModel, VitsTokenizer]:
104
+ if lang not in self._tts_model_names:
105
  raise ValueError(f"Langue non supportee: {lang}")
106
  if lang not in self._models:
107
+ model_name = self._tts_model_names[lang]
108
+ self._tokenizers[lang] = VitsTokenizer.from_pretrained(
109
+ model_name, token=self._hf_token
110
+ )
111
+ model = VitsModel.from_pretrained(
112
+ model_name, token=self._hf_token
113
+ ).to(self.device)
114
  model.eval()
115
  self._models[lang] = model
116
  return self._models[lang], self._tokenizers[lang]