Broulaye Doumbia commited on
Commit
34edd5f
·
1 Parent(s): da3a060

step 2: add app_minimal.py — zero-shot baseline for rebuild.

Browse files
app_minimal.py ADDED
@@ -0,0 +1,372 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Minimal baseline Gradio entry point for the Month 1-3 rebuild.
2
+
3
+ Wires the simplest possible slice: Whisper (zero-shot) -> Qwen -> MMS-TTS.
4
+ No LoRA adapters, no memory loop, no speaker ID, no voice cloning, no IoT,
5
+ no phrase matcher. Used for field testing and building a real-user eval set.
6
+
7
+ See docs/baseline_rebuild.md for the plan this fits into.
8
+
9
+ Run locally:
10
+ HF_TOKEN=hf_xxx python app_minimal.py
11
+
12
+ Environment variables (all optional except HF_TOKEN, which is needed for the
13
+ Qwen HF Serverless call):
14
+ HF_TOKEN — HuggingFace token with read access
15
+ LLM_MODEL_ID — default "Qwen/Qwen2.5-7B-Instruct"
16
+ DEVICE — "cuda" or "cpu" (auto if unset)
17
+ LOG_LEVEL — default "INFO"
18
+ """
19
+ from __future__ import annotations
20
+
21
+ import logging
22
+ import os
23
+ from typing import Optional, Tuple
24
+
25
+ import numpy as np
26
+
27
+ # Local imports — the four modules the baseline-rebuild plan authorizes.
28
+ # Everything else in src/ is intentionally unused here.
29
+ from src.data.bam_normalize import normalize as bam_normalize
30
+ from src.engine.whisper_base import WhisperBackbone
31
+ from src.llm.gemma_client import GemmaClient
32
+ from src.tts.mms_tts import MMSTTSEngine
33
+
34
+ logging.basicConfig(
35
+ level=os.getenv("LOG_LEVEL", "INFO"),
36
+ format="%(asctime)s %(name)-30s %(levelname)-7s %(message)s",
37
+ )
38
+ logger = logging.getLogger(__name__)
39
+
40
+
41
+ # ── Environment ──────────────────────────────────────────────────────────────
42
+ HF_TOKEN = os.environ.get("HF_TOKEN")
43
+ LLM_MODEL_ID = os.environ.get("LLM_MODEL_ID", "Qwen/Qwen2.5-7B-Instruct")
44
+ _REQUESTED_DEVICE = os.environ.get("DEVICE") # optional override
45
+
46
+ LANG_CHOICES = [("Bambara", "bam"), ("Fula", "ful"), ("French", "fr"), ("English", "en")]
47
+ LANG_NAMES = {"bam": "Bambara", "ful": "Fula", "fr": "French", "en": "English"}
48
+ LANG_TO_WHISPER_HINT = {
49
+ # Whisper large-v3-turbo does not know Bambara/Fula as first-class
50
+ # languages. We leave `language` unset for those so Whisper auto-detects;
51
+ # fr/en are explicit hints for clean decoding.
52
+ "bam": None,
53
+ "ful": None,
54
+ "fr": "french",
55
+ "en": "english",
56
+ }
57
+
58
+
59
+ def _with_reply_language_directive(user_text: str, output_lang: str) -> str:
60
+ """Append an explicit reply-language directive to the user message.
61
+
62
+ The LLM's system prompt (in GemmaClient) does not know which language we
63
+ want the reply in — it picks based on vibes, which can drift (e.g. to
64
+ Wolof). We keep GemmaClient untouched and steer from the user turn.
65
+ """
66
+ name = LANG_NAMES.get(output_lang, "English")
67
+ return f"{user_text}\n\n(Please reply in {name} only.)"
68
+
69
+
70
+ # ── Service singletons (lazy-loaded) ────────────────────────────────────────
71
+ _backbone: Optional[WhisperBackbone] = None
72
+ _llm: Optional[GemmaClient] = None
73
+ _tts: Optional[MMSTTSEngine] = None
74
+
75
+
76
+ def _resolve_device() -> str:
77
+ """Pick 'cuda' if torch sees a GPU, else 'cpu'. DEVICE env overrides."""
78
+ import torch # lazy
79
+ if _REQUESTED_DEVICE:
80
+ return _REQUESTED_DEVICE
81
+ return "cuda" if torch.cuda.is_available() else "cpu"
82
+
83
+
84
+ def get_backbone() -> WhisperBackbone:
85
+ """Load the Whisper backbone once and cache. Zero-shot — no adapters."""
86
+ global _backbone
87
+ if _backbone is None:
88
+ _backbone = WhisperBackbone(config_path="configs/base_config.yaml")
89
+ _backbone.load(device=_resolve_device(), hf_token=HF_TOKEN)
90
+ logger.info("Whisper backbone ready: %s on %s",
91
+ _backbone.model_id, _backbone.device)
92
+ return _backbone
93
+
94
+
95
+ def get_llm() -> GemmaClient:
96
+ global _llm
97
+ if _llm is None:
98
+ _llm = GemmaClient(model_id=LLM_MODEL_ID, hf_token=HF_TOKEN)
99
+ logger.info("LLM client configured: %s", LLM_MODEL_ID)
100
+ return _llm
101
+
102
+
103
+ def get_tts() -> MMSTTSEngine:
104
+ global _tts
105
+ if _tts is None:
106
+ _tts = MMSTTSEngine()
107
+ logger.info("MMS-TTS engine ready (lazy per-language load)")
108
+ return _tts
109
+
110
+
111
+ # ── Core pipeline ────────────────────────────────────────────────────────────
112
+ def transcribe(audio_np: np.ndarray, sample_rate: int, input_lang: str) -> str:
113
+ """Run zero-shot Whisper on a numpy audio array. Returns the raw transcript.
114
+
115
+ `input_lang` drives two things only: the Whisper language hint (for fr/en)
116
+ and whether bam_normalize is applied. It has no effect on the TTS voice or
117
+ on the LLM reply language — those are driven by the separate output-language
118
+ dropdown in the UI.
119
+ """
120
+ import torch # lazy
121
+ import librosa # lazy — resample if the mic gave us something non-16k
122
+
123
+ backbone = get_backbone()
124
+ target_sr = 16_000
125
+
126
+ # Ensure mono float32
127
+ if audio_np.ndim == 2:
128
+ audio_np = audio_np.mean(axis=1)
129
+ audio_np = audio_np.astype(np.float32)
130
+
131
+ # Gradio's gr.Audio often returns int16-scaled floats or ints — normalize.
132
+ peak = np.max(np.abs(audio_np)) if audio_np.size else 0.0
133
+ if peak > 1.5: # looks like raw int16 cast to float
134
+ audio_np = audio_np / 32768.0
135
+
136
+ if sample_rate != target_sr:
137
+ audio_np = librosa.resample(audio_np, orig_sr=sample_rate, target_sr=target_sr)
138
+
139
+ inputs = backbone.processor(
140
+ audio_np, sampling_rate=target_sr, return_tensors="pt"
141
+ )
142
+ input_features = inputs.input_features.to(backbone.device)
143
+ if backbone.device == "cuda":
144
+ input_features = input_features.half()
145
+
146
+ gen_kwargs: dict = {"max_new_tokens": 128}
147
+ hint = LANG_TO_WHISPER_HINT.get(input_lang)
148
+ if hint:
149
+ gen_kwargs["language"] = hint
150
+ gen_kwargs["task"] = "transcribe"
151
+
152
+ with torch.no_grad():
153
+ output_ids = backbone.model.generate(input_features, **gen_kwargs)
154
+
155
+ transcript = backbone.processor.batch_decode(
156
+ output_ids, skip_special_tokens=True
157
+ )[0].strip()
158
+
159
+ if input_lang == "bam" and transcript:
160
+ transcript = bam_normalize(transcript)
161
+
162
+ return transcript
163
+
164
+
165
+ def run_pipeline(
166
+ audio: Optional[Tuple[int, np.ndarray]],
167
+ input_lang: str,
168
+ output_lang: str,
169
+ ) -> Tuple[str, str, Optional[Tuple[int, np.ndarray]]]:
170
+ """Gradio handler for the Voice tab.
171
+
172
+ Args:
173
+ audio: (sample_rate, audio_np) from gr.Audio.
174
+ input_lang: language of the spoken input (drives Whisper hint + bam_normalize).
175
+ output_lang: language the LLM should reply in and the TTS should speak.
176
+
177
+ Returns (transcript, reply_text, reply_audio). Graceful degradation: any
178
+ stage failure yields a readable string and None audio instead of raising.
179
+ """
180
+ if audio is None:
181
+ return "", "(no audio received)", None
182
+
183
+ sample_rate, audio_np = audio
184
+ if audio_np.size == 0:
185
+ return "", "(empty audio)", None
186
+
187
+ try:
188
+ transcript = transcribe(audio_np, sample_rate, input_lang)
189
+ except Exception as exc: # pragma: no cover — field-safety
190
+ logger.exception("Transcription failed")
191
+ return "", f"(STT error: {exc})", None
192
+
193
+ if not transcript:
194
+ return "", "(no speech detected)", None
195
+
196
+ try:
197
+ # No memory loop in minimal — always pass empty vocabulary context.
198
+ reply = get_llm().chat(
199
+ _with_reply_language_directive(transcript, output_lang),
200
+ vocabulary_context="",
201
+ )
202
+ except Exception as exc: # pragma: no cover
203
+ logger.exception("LLM call failed")
204
+ return transcript, f"(LLM error: {exc})", None
205
+
206
+ reply_text: str = reply.get("response", "") or "(empty reply)"
207
+
208
+ try:
209
+ wav, sr = get_tts().synthesize(
210
+ reply_text, language=output_lang, device=_resolve_device()
211
+ )
212
+ except Exception as exc:
213
+ logger.exception("TTS failed")
214
+ return transcript, reply_text, None
215
+
216
+ return transcript, reply_text, (sr, wav)
217
+
218
+
219
+ def run_text_pipeline(
220
+ text: str,
221
+ output_lang: str,
222
+ ) -> Tuple[str, Optional[Tuple[int, np.ndarray]]]:
223
+ """Gradio handler for the Text tab.
224
+
225
+ Args:
226
+ text: typed user input.
227
+ output_lang: language the LLM should reply in and the TTS should speak.
228
+
229
+ No input-language param — typed input is whatever the user types; the LLM
230
+ reads it as-is and replies in `output_lang`. Skips Whisper entirely; this
231
+ is the fast dev-loop path.
232
+ """
233
+ text = (text or "").strip()
234
+ if not text:
235
+ return "(no text entered)", None
236
+
237
+ try:
238
+ reply = get_llm().chat(
239
+ _with_reply_language_directive(text, output_lang),
240
+ vocabulary_context="",
241
+ )
242
+ except Exception as exc: # pragma: no cover
243
+ logger.exception("LLM call failed")
244
+ return f"(LLM error: {exc})", None
245
+
246
+ reply_text: str = reply.get("response", "") or "(empty reply)"
247
+
248
+ try:
249
+ wav, sr = get_tts().synthesize(
250
+ reply_text, language=output_lang, device=_resolve_device()
251
+ )
252
+ except Exception as exc:
253
+ logger.exception("TTS failed")
254
+ return reply_text, None
255
+
256
+ return reply_text, (sr, wav)
257
+
258
+
259
+ # ── Gradio UI ────────────────────────────────────────────────────────────────
260
+ def build_ui():
261
+ """Construct and return the Gradio Blocks app."""
262
+ import gradio as gr # lazy — keeps module importable without gradio installed
263
+
264
+ with gr.Blocks(title="Sahel-Voice — Minimal Baseline") as demo:
265
+ gr.Markdown(
266
+ "# 🌾 Sahel-Voice — Minimal Baseline\n"
267
+ "Zero-shot Whisper → Qwen → MMS-TTS. No adapters, no memory, no polish. "
268
+ "This is the field-test baseline — see `docs/baseline_rebuild.md`."
269
+ )
270
+
271
+ # Shared across tabs. Split into two so input and output language
272
+ # are never conflated — the Voice tab cares about both; the Text tab
273
+ # only uses output_lang (it doesn't feed Whisper).
274
+ with gr.Row():
275
+ input_lang = gr.Dropdown(
276
+ choices=LANG_CHOICES, value="bam", label="Input language",
277
+ info="Language you're speaking/typing. Drives Whisper hint "
278
+ "(fr/en only) and bam_normalize (bam only).",
279
+ )
280
+ output_lang = gr.Dropdown(
281
+ choices=LANG_CHOICES, value="bam", label="Output language",
282
+ info="Language the LLM should reply in. Also picks the TTS voice.",
283
+ )
284
+
285
+ with gr.Tabs():
286
+ # ── Voice tab — the actual baseline the field test measures ─────
287
+ with gr.Tab("🎤 Voice (full STT → LLM → TTS)"):
288
+ with gr.Row():
289
+ with gr.Column():
290
+ audio_in = gr.Audio(
291
+ sources=["microphone", "upload"],
292
+ type="numpy",
293
+ label="Speak (or upload a .wav)",
294
+ )
295
+ voice_submit = gr.Button(
296
+ "Transcribe + Reply", variant="primary"
297
+ )
298
+ with gr.Column():
299
+ transcript_out = gr.Textbox(
300
+ label="Transcript (zero-shot Whisper)",
301
+ lines=2, interactive=False,
302
+ )
303
+ voice_reply_out = gr.Textbox(
304
+ label="LLM reply", lines=4, interactive=False,
305
+ )
306
+ voice_audio_out = gr.Audio(
307
+ label="Reply audio", type="numpy", autoplay=False,
308
+ )
309
+
310
+ voice_submit.click(
311
+ fn=run_pipeline,
312
+ inputs=[audio_in, input_lang, output_lang],
313
+ outputs=[transcript_out, voice_reply_out, voice_audio_out],
314
+ )
315
+
316
+ # ── Text tab — dev loop, skips Whisper ──────────────────────────
317
+ with gr.Tab("⌨️ Text (LLM → TTS, dev loop)"):
318
+ with gr.Row():
319
+ with gr.Column():
320
+ text_in = gr.Textbox(
321
+ label="Type your message",
322
+ lines=3,
323
+ placeholder="e.g. I ni ce — how do I say hello in Bambara?",
324
+ )
325
+ text_submit = gr.Button("Send", variant="primary")
326
+ with gr.Column():
327
+ text_reply_out = gr.Textbox(
328
+ label="LLM reply", lines=4, interactive=False,
329
+ )
330
+ text_audio_out = gr.Audio(
331
+ label="Reply audio", type="numpy", autoplay=False,
332
+ )
333
+
334
+ # Text tab only uses output_lang — input_lang is a no-op here.
335
+ text_submit.click(
336
+ fn=run_text_pipeline,
337
+ inputs=[text_in, output_lang],
338
+ outputs=[text_reply_out, text_audio_out],
339
+ )
340
+ # Pressing Enter in the textbox also submits.
341
+ text_in.submit(
342
+ fn=run_text_pipeline,
343
+ inputs=[text_in, output_lang],
344
+ outputs=[text_reply_out, text_audio_out],
345
+ )
346
+
347
+ gr.Markdown(
348
+ "---\n"
349
+ "**What's intentionally missing:** LoRA adapters, memory/vocabulary "
350
+ "persistence, speaker ID, Waxal/F5 TTS, IoT sensor integration, "
351
+ "phrase-matcher shortcuts. All of those live in `app.py` — this is the "
352
+ "stripped-down baseline used to measure what Whisper zero-shot does on "
353
+ "real Bambara/Fula recordings and to collect a real-user eval set.\n\n"
354
+ "The **Text** tab skips Whisper — it's for fast iteration on the "
355
+ "LLM + TTS path, not for field-test measurement."
356
+ )
357
+
358
+ return demo
359
+
360
+
361
+ def main() -> None:
362
+ if not HF_TOKEN:
363
+ logger.warning(
364
+ "HF_TOKEN is not set — the LLM call will fail. "
365
+ "Export HF_TOKEN before launching for the pipeline to work end-to-end."
366
+ )
367
+ demo = build_ui()
368
+ demo.queue().launch()
369
+
370
+
371
+ if __name__ == "__main__":
372
+ main()
notebooks/kaggle_master_trainer/kaggle_master_trainer.ipynb CHANGED
@@ -30,7 +30,19 @@
30
  "metadata": {},
31
  "outputs": [],
32
  "source": [
33
- "print(\"test\")"
 
 
 
 
 
 
 
 
 
 
 
 
34
  ]
35
  },
36
  {
 
30
  "metadata": {},
31
  "outputs": [],
32
  "source": [
33
+ "# ── Cell 0: RUN_TRAINING gate ────────────────────────────────────────────────\n",
34
+ "# Kaggle runs the kernel on every `kaggle kernels push`. When you're just\n",
35
+ "# syncing code and don't want a real training run, leave this env var unset\n",
36
+ "# and the notebook exits in ~2s without burning GPU quota.\n",
37
+ "#\n",
38
+ "# To actually train: Kaggle web UI → Settings → Environment Variables →\n",
39
+ "# add RUN_TRAINING = 1, then push.\n",
40
+ "import os, sys\n",
41
+ "if os.environ.get('RUN_TRAINING', '0') != '1':\n",
42
+ " print('RUN_TRAINING != 1 — code-only push, skipping execution.')\n",
43
+ " print('Set RUN_TRAINING=1 in the Kaggle kernel env vars to run for real.')\n",
44
+ " sys.exit(0)\n",
45
+ "print('RUN_TRAINING=1 — proceeding with full training run.')\n"
46
  ]
47
  },
48
  {