prianshujha commited on
Commit
959b417
Β·
1 Parent(s): 83c1aed

changed to moonshine tiny

Browse files
config.py CHANGED
@@ -6,64 +6,60 @@ Edit the values in this file to match your local setup.
6
  import os
7
  from pathlib import Path
8
 
9
- # ── Project root ──────────────────────────────────────────────────────────────
10
- ROOT_DIR = Path(__file__).parent
11
- DATA_DIR = ROOT_DIR / "data"
12
  MODELS_DIR = ROOT_DIR / "models"
13
 
14
  DATA_DIR.mkdir(exist_ok=True)
15
  MODELS_DIR.mkdir(exist_ok=True)
16
 
17
- # ── Hugging Face ──────────────────────────────────────────────────────────────
18
- # Required: Cohere Transcribe is gated β€” you must accept the license at
19
- # https://huggingface.co/CohereLabs/cohere-transcribe-03-2026
20
- # then set your HF token here or in the environment variable HF_TOKEN.
21
  HF_TOKEN = os.environ.get("HF_TOKEN", "")
22
 
23
- # ── ASR: Cohere Transcribe ────────────────────────────────────────────────────
24
- TRANSCRIBE_MODEL_ID = "CohereLabs/cohere-transcribe-03-2026"
25
- TRANSCRIBE_LANGUAGE = "en" # ISO 639-1 β€” English only for now
26
- TRANSCRIBE_DEVICE = "cuda:0" # falls back to "cpu" automatically in code
27
 
28
- # ── Intent Parser: Qwen2.5-7B-Instruct (GGUF via llama-cpp-python) ────────────
29
  # Using q3_k_m quantization (3.55 GB, good quality/size tradeoff).
30
  # Already downloaded and available in ./models/
31
- QWEN_GGUF_PATH = MODELS_DIR / "qwen2.5-7b-instruct-q3_k_m.gguf"
32
  QWEN_N_GPU_LAYERS = 20 # offload 20 transformer layers to GPU (~0.8 GB VRAM)
33
- # remaining ~15 layers run on CPU RAM
34
- QWEN_N_CTX = 4096 # context window β€” sufficient for a call transcript
35
- QWEN_MAX_TOKENS = 512 # max tokens for the structured JSON response
36
- QWEN_TEMPERATURE = 0.1 # near-deterministic for structured output
37
 
38
- # ── Evaluator: MiniCPM3-4B (CPU, bitsandbytes 4-bit) ─────────────────────────
39
- MINICPM_MODEL_ID = "openbmb/MiniCPM3-4B"
40
- MINICPM_DEVICE = "cpu" # runs after Qwen is done β€” no VRAM conflict
41
  MINICPM_MAX_TOKENS = 256
42
 
43
- # ── VAD: Silero VAD (ONNX, CPU) ───────────────────────────────────────────────
44
- VAD_SAMPLE_RATE = 16000 # Hz β€” Silero and Cohere both want 16kHz
45
- VAD_CHUNK_MS = 250 # ms per audio chunk fed to VAD
46
- VAD_CHUNK_SAMPLES = int(VAD_SAMPLE_RATE * VAD_CHUNK_MS / 1000) # 4000
47
- VAD_SILENCE_THRESHOLD = 0.5 # Silero confidence below this β†’ silence
48
- VAD_SILENCE_DURATION_S = 0.8 # seconds of silence before utterance is flushed
49
- VAD_MIN_SPEECH_S = 0.5 # ignore utterances shorter than this (noise)
50
 
51
- # ── SQLite database ───────────────────────────────────────────────────────────
52
  DB_PATH = DATA_DIR / "calls.db"
53
 
54
- # ── Scheduling rules (evaluated by MiniCPM) ──────────────────────────────────
55
- # These are injected into MiniCPM's system prompt so it can reason about them.
56
  SCHEDULING_RULES = """
57
- 1. Meetings can only be booked Monday–Friday, 09:00–18:00.
58
  2. Minimum meeting duration is 15 minutes; maximum is 120 minutes.
59
- 3. Back-to-back meetings are not allowed β€” require a 15-minute gap between slots.
60
  4. If the caller does not provide a date or time, ask for one before confirming.
61
  5. If the requested slot is already booked, suggest the next available slot.
62
  6. Always confirm the caller's name before booking.
63
  """
64
 
65
- # ── Gradio UI ─────────────────────────────────────────────────────────────────
66
- APP_TITLE = "πŸ“ž AI Telecalling Agent"
67
  APP_DESCRIPTION = "Speak naturally β€” the agent will schedule your meeting automatically."
68
- SERVER_PORT = 7860
69
- SERVER_NAME = "0.0.0.0" # bind to all interfaces for HF Spaces
 
6
  import os
7
  from pathlib import Path
8
 
9
+ # Project root
10
+ ROOT_DIR = Path(__file__).parent
11
+ DATA_DIR = ROOT_DIR / "data"
12
  MODELS_DIR = ROOT_DIR / "models"
13
 
14
  DATA_DIR.mkdir(exist_ok=True)
15
  MODELS_DIR.mkdir(exist_ok=True)
16
 
17
+ # Hugging Face
18
+ # Optional: set HF_TOKEN for private models or authenticated downloads.
 
 
19
  HF_TOKEN = os.environ.get("HF_TOKEN", "")
20
 
21
+ # ASR: Hugging Face Moonshine
22
+ TRANSCRIBE_MODEL_ID = "UsefulSensors/moonshine-tiny"
23
+ TRANSCRIBE_LANGUAGE = "en" # Moonshine Tiny is English ASR
24
+ TRANSCRIBE_DEVICE = "cuda:0" # falls back to "cpu" automatically in code
25
 
26
+ # Intent Parser: Qwen2.5-7B-Instruct (GGUF via llama-cpp-python)
27
  # Using q3_k_m quantization (3.55 GB, good quality/size tradeoff).
28
  # Already downloaded and available in ./models/
29
+ QWEN_GGUF_PATH = MODELS_DIR / "qwen2.5-7b-instruct-q3_k_m.gguf"
30
  QWEN_N_GPU_LAYERS = 20 # offload 20 transformer layers to GPU (~0.8 GB VRAM)
31
+ QWEN_N_CTX = 4096 # context window sufficient for a call transcript
32
+ QWEN_MAX_TOKENS = 512 # max tokens for the structured JSON response
33
+ QWEN_TEMPERATURE = 0.1 # near-deterministic for structured output
 
34
 
35
+ # Evaluator: MiniCPM3-4B (CPU, bitsandbytes 4-bit)
36
+ MINICPM_MODEL_ID = "openbmb/MiniCPM3-4B"
37
+ MINICPM_DEVICE = "cpu" # runs after Qwen is done; no VRAM conflict
38
  MINICPM_MAX_TOKENS = 256
39
 
40
+ # VAD: Silero VAD (ONNX, CPU)
41
+ VAD_SAMPLE_RATE = 16000 # Hz; Silero and Moonshine both use 16kHz
42
+ VAD_CHUNK_MS = 250 # ms per audio chunk fed to VAD
43
+ VAD_CHUNK_SAMPLES = int(VAD_SAMPLE_RATE * VAD_CHUNK_MS / 1000) # 4000
44
+ VAD_SILENCE_THRESHOLD = 0.5
45
+ VAD_SILENCE_DURATION_S = 0.8
46
+ VAD_MIN_SPEECH_S = 0.5
47
 
48
+ # SQLite database
49
  DB_PATH = DATA_DIR / "calls.db"
50
 
51
+ # Scheduling rules injected into MiniCPM's system prompt.
 
52
  SCHEDULING_RULES = """
53
+ 1. Meetings can only be booked Monday-Friday, 09:00-18:00.
54
  2. Minimum meeting duration is 15 minutes; maximum is 120 minutes.
55
+ 3. Back-to-back meetings are not allowed; require a 15-minute gap between slots.
56
  4. If the caller does not provide a date or time, ask for one before confirming.
57
  5. If the requested slot is already booked, suggest the next available slot.
58
  6. Always confirm the caller's name before booking.
59
  """
60
 
61
+ # Gradio UI
62
+ APP_TITLE = "πŸ“ž AI Telecalling Agent"
63
  APP_DESCRIPTION = "Speak naturally β€” the agent will schedule your meeting automatically."
64
+ SERVER_PORT = 7860
65
+ SERVER_NAME = "0.0.0.0" # bind to all interfaces for HF Spaces
data/calls.db CHANGED
Binary files a/data/calls.db and b/data/calls.db differ
 
pipeline/evaluater.py CHANGED
@@ -18,7 +18,7 @@ Memory strategy for RTX 2050
18
  ─────────────────────────────
19
  MiniCPM3-4B runs AFTER the transcriber finishes each utterance.
20
  They never overlap. MiniCPM3 loads in 4-bit (INT4) via bitsandbytes
21
- on CPU β€” no VRAM needed β€” freeing the full 4 GB for Cohere Transcribe.
22
 
23
  If bitsandbytes is unavailable (e.g. first-time setup) we fall back to
24
  plain float32 on CPU. Slower but always works.
@@ -469,4 +469,4 @@ def _smoke_test_offline():
469
 
470
 
471
  if __name__ == "__main__":
472
- _smoke_test_offline()
 
18
  ─────────────────────────────
19
  MiniCPM3-4B runs AFTER the transcriber finishes each utterance.
20
  They never overlap. MiniCPM3 loads in 4-bit (INT4) via bitsandbytes
21
+ on CPU, freeing the full 4 GB budget for Moonshine ASR.
22
 
23
  If bitsandbytes is unavailable (e.g. first-time setup) we fall back to
24
  plain float32 on CPU. Slower but always works.
 
469
 
470
 
471
  if __name__ == "__main__":
472
+ _smoke_test_offline()
pipeline/intent_parser.py CHANGED
@@ -5,7 +5,7 @@ Qwen2.5-7B-Instruct (Q4_K_M GGUF) intent & entity extractor.
5
 
6
  Responsibilities
7
  ────────────────
8
- Takes a raw transcript string from Cohere Transcribe and returns a
9
  validated SchedulingIntent object β€” structured data the evaluator and
10
  DB layer can act on directly.
11
 
@@ -198,7 +198,7 @@ class IntentParser:
198
  Parameters
199
  ----------
200
  transcript : str
201
- Raw text from Cohere Transcribe (one or more utterances joined).
202
 
203
  Returns
204
  -------
@@ -441,4 +441,4 @@ def _smoke_test_offline():
441
 
442
 
443
  if __name__ == "__main__":
444
- _smoke_test_offline()
 
5
 
6
  Responsibilities
7
  ────────────────
8
+ Takes a raw transcript string from Moonshine ASR and returns a
9
  validated SchedulingIntent object β€” structured data the evaluator and
10
  DB layer can act on directly.
11
 
 
198
  Parameters
199
  ----------
200
  transcript : str
201
+ Raw text from Moonshine ASR (one or more utterances joined).
202
 
203
  Returns
204
  -------
 
441
 
442
 
443
  if __name__ == "__main__":
444
+ _smoke_test_offline()
pipeline/orchestrator.py CHANGED
@@ -5,7 +5,7 @@ Orchestrator for the telecalling agent pipeline.
5
 
6
  Coordinates the end-to-end flow:
7
  1. VAD listener β†’ detects speech boundaries
8
- 2. Transcriber β†’ audio-to-text (Cohere)
9
  3. Intent parser β†’ structured intent extraction (Qwen2.5)
10
  4. Evaluator β†’ scheduling decision + spoken response (MiniCPM3)
11
  5. Database updates β†’ persist call state
 
5
 
6
  Coordinates the end-to-end flow:
7
  1. VAD listener β†’ detects speech boundaries
8
+ 2. Transcriber β†’ audio-to-text (Moonshine)
9
  3. Intent parser β†’ structured intent extraction (Qwen2.5)
10
  4. Evaluator β†’ scheduling decision + spoken response (MiniCPM3)
11
  5. Database updates β†’ persist call state
pipeline/transcriber.py CHANGED
@@ -1,53 +1,28 @@
1
  """
2
  pipeline/transcriber.py
3
 
4
- Cohere Transcribe wrapper for the telecalling agent.
5
-
6
- Model
7
- ─────
8
- CohereLabs/cohere-transcribe-03-2026
9
- - 2B parameter Conformer encoder + lightweight Transformer decoder
10
- - Audio-in, text-out ASR, Apache 2.0
11
- - Gated: requires HF_TOKEN with accepted licence at
12
- https://huggingface.co/CohereLabs/cohere-transcribe-03-2026
13
-
14
- Memory budget on RTX 2050 (4 GB VRAM)
15
- ──────────────────────────────────────
16
- Full fp32 β†’ ~8 GB βœ—
17
- float16 β†’ ~4 GB βœ— (tight, unstable with other allocations)
18
- torch_dtype=torch.float16 + device_map keeps ~2.2 GB β†’ βœ“
19
-
20
- Design decisions
21
- ────────────────
22
- - Lazy-loaded: model is not pulled from HF until the first call arrives.
23
- This keeps Gradio startup fast and avoids OOM if the user never speaks.
24
- - GPU-first with automatic CPU fallback: if CUDA is unavailable or VRAM
25
- is insufficient the model moves to CPU transparently.
26
- - Uses model.transcribe(audio_arrays=...) to avoid disk I/O entirely.
27
- - compile=False by default: torch.compile causes a 30-60 s warmup on
28
- first call which would break the live-call UX. Set compile=True only
29
- in batch / offline mode.
30
- - Thread-safe: model loading is protected by a threading.Lock so
31
- concurrent Gradio sessions don't race to download.
32
-
33
- Usage
34
- ─────
35
- transcriber = Transcriber() # cheap β€” model not loaded yet
36
- text = transcriber.transcribe(sr, audio_np) # loads model on first call
37
- transcriber.unload() # free VRAM between sessions
38
  """
39
 
40
  import logging
41
  import threading
42
  import time
43
- from typing import Optional
44
 
45
  import numpy as np
46
  import torch
47
 
48
  from config import (
49
  TRANSCRIBE_MODEL_ID,
50
- TRANSCRIBE_LANGUAGE,
51
  TRANSCRIBE_DEVICE,
52
  HF_TOKEN,
53
  )
@@ -57,19 +32,23 @@ logger = logging.getLogger(__name__)
57
 
58
  class Transcriber:
59
  """
60
- Lazy-loading wrapper around CohereLabs/cohere-transcribe-03-2026.
61
 
62
  Thread-safe: a single instance can be shared across the whole app.
63
  """
64
 
65
- def __init__(self):
66
- self._model = None
67
- self._processor = None
68
- self._device = None
69
- self._lock = threading.Lock()
70
- self._loaded = False
71
 
72
- # ── Public API ────────────────────────────────────────────────────────────
 
 
 
 
 
 
 
 
73
 
74
  def transcribe(self, sample_rate: int, audio: np.ndarray) -> str:
75
  """
@@ -78,7 +57,7 @@ class Transcriber:
78
  Parameters
79
  ----------
80
  sample_rate : int
81
- Sample rate of `audio` (typically 16000 from VAD).
82
  audio : np.ndarray
83
  Mono float32 PCM in [-1.0, 1.0].
84
 
@@ -99,26 +78,14 @@ class Transcriber:
99
 
100
  try:
101
  t0 = time.perf_counter()
102
-
103
- results = self._model.transcribe(
104
- processor = self._processor,
105
- audio_arrays = [audio], # list of np.ndarray
106
- sample_rates = [sample_rate], # matching list of ints
107
- language = TRANSCRIBE_LANGUAGE,
108
- punctuation = True,
109
- batch_size = 1, # one utterance at a time (live)
110
- compile = False, # no warmup delay in live mode
111
- pipeline_detokenization = False, # not needed for batch_size=1
112
- )
113
-
114
  elapsed = time.perf_counter() - t0
115
- text = results[0].strip() if results else ""
116
 
117
  duration = len(audio) / sample_rate
118
- rtfx = duration / elapsed if elapsed > 0 else 0
119
  logger.info(
120
  f"Transcribed {duration:.2f}s audio in {elapsed:.2f}s "
121
- f"(RTFx {rtfx:.1f}x): '{text[:80]}{'…' if len(text)>80 else ''}'"
122
  )
123
  return text
124
 
@@ -130,8 +97,7 @@ class Transcriber:
130
  self, utterances: list[tuple[int, np.ndarray]]
131
  ) -> list[str]:
132
  """
133
- Transcribe multiple utterances in one forward pass.
134
- More efficient for post-call batch processing.
135
 
136
  Parameters
137
  ----------
@@ -139,54 +105,47 @@ class Transcriber:
139
 
140
  Returns
141
  -------
142
- list of str β€” one entry per input, "" on individual errors
143
  """
144
  if not utterances:
145
  return []
146
 
147
  self._ensure_loaded()
148
 
149
- sample_rates = [sr for sr, _ in utterances]
150
- audio_arrays = [self._validate_audio(a) for _, a in utterances]
151
 
152
- # Replace any None (invalid) arrays with silent arrays
153
  audio_arrays = [
154
  a if a is not None else np.zeros(sample_rates[i], dtype=np.float32)
155
  for i, a in enumerate(audio_arrays)
156
  ]
157
 
158
  try:
159
- results = self._model.transcribe(
160
- processor = self._processor,
161
- audio_arrays = audio_arrays,
162
- sample_rates = sample_rates,
163
- language = TRANSCRIBE_LANGUAGE,
164
- punctuation = True,
165
- batch_size = len(utterances),
166
- compile = False,
167
- pipeline_detokenization = True, # helps with larger batches
168
- )
169
- return [r.strip() for r in results]
170
-
171
  except Exception as exc:
172
  logger.error(f"Batch transcription failed: {exc}", exc_info=True)
173
  return [""] * len(utterances)
174
 
175
  def unload(self):
176
  """
177
- Release GPU memory. Call at end of a call session if memory is tight.
178
  Model will be reloaded lazily on the next call.
179
  """
180
  with self._lock:
181
  if self._loaded:
182
  del self._model
183
- del self._processor
184
- self._model = None
185
- self._processor = None
186
- self._loaded = False
 
 
 
 
 
187
  if torch.cuda.is_available():
188
  torch.cuda.empty_cache()
189
- logger.info("Transcriber unloaded β€” VRAM freed.")
190
 
191
  @property
192
  def is_loaded(self) -> bool:
@@ -196,117 +155,215 @@ class Transcriber:
196
  def device(self) -> Optional[str]:
197
  return str(self._device) if self._device else None
198
 
199
- # ── Internal ──────────────────────────────────────────────────────────────
200
-
201
  def _ensure_loaded(self):
202
  """Load model + processor exactly once, thread-safely."""
203
  if self._loaded:
204
  return
205
 
206
  with self._lock:
207
- if self._loaded: # double-checked locking
208
  return
209
  self._load()
210
 
211
  def _load(self):
212
- """
213
- Pull model from HuggingFace and move to best available device.
214
-
215
- Device selection for RTX 2050 (4 GB):
216
- - float16 on CUDA: ~2.2 GB VRAM ← preferred
217
- - float32 on CPU: ~8.0 GB RAM ← fallback
218
- """
219
  import os
220
- from transformers import AutoProcessor, AutoModelForSpeechSeq2Seq
221
 
222
- logger.info(f"Loading Cohere Transcribe ({TRANSCRIBE_MODEL_ID})…")
 
 
 
 
 
223
  t0 = time.perf_counter()
224
 
225
  token_kwargs = {"token": HF_TOKEN} if HF_TOKEN else {}
226
-
227
- # After first download, use local cache only to avoid repeated HF hub calls
228
  local_only = os.path.exists(
229
- os.path.expanduser(f"~/.cache/huggingface/hub/models--{TRANSCRIBE_MODEL_ID.replace('/', '--')}")
 
 
230
  )
231
 
232
- # Processor (tokenizer + feature extractor) β€” tiny, load first
233
- self._processor = AutoProcessor.from_pretrained(
 
 
 
 
234
  TRANSCRIBE_MODEL_ID,
235
- trust_remote_code=True,
236
  local_files_only=local_only,
237
  **token_kwargs,
238
  )
 
239
 
240
- # Determine device & dtype
241
  if torch.cuda.is_available():
242
  try:
243
  self._device = torch.device(TRANSCRIBE_DEVICE)
244
- dtype = torch.float16
245
- logger.info(f"CUDA available β€” loading in float16 on {self._device}")
246
  except Exception:
247
  self._device = torch.device("cpu")
248
- dtype = torch.float32
249
- logger.warning("CUDA device init failed β€” falling back to CPU float32")
250
  else:
251
  self._device = torch.device("cpu")
252
- dtype = torch.float32
253
- logger.info("No CUDA β€” loading on CPU in float32 (will be slower)")
254
 
255
- # Model weights
256
- self._model = AutoModelForSpeechSeq2Seq.from_pretrained(
257
  TRANSCRIBE_MODEL_ID,
258
- trust_remote_code = True,
259
- torch_dtype = dtype,
260
- low_cpu_mem_usage = True, # stream weights instead of double-buffering
261
- local_files_only = local_only,
262
  **token_kwargs,
263
  ).to(self._device)
264
-
265
  self._model.eval()
266
 
267
  elapsed = time.perf_counter() - t0
268
  logger.info(
269
- f"Cohere Transcribe ready on {self._device} "
270
- f"(dtype={dtype}, loaded in {elapsed:.1f}s)"
271
  )
272
 
273
- if torch.cuda.is_available():
274
  allocated = torch.cuda.memory_allocated(self._device) / 1024**3
275
- reserved = torch.cuda.memory_reserved(self._device) / 1024**3
276
  logger.info(
277
- f"VRAM after load β€” allocated: {allocated:.2f} GB, "
278
- f"reserved: {reserved:.2f} GB"
279
  )
280
 
281
  self._loaded = True
282
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
283
  @staticmethod
284
- def _validate_audio(audio: np.ndarray) -> Optional[np.ndarray]:
285
  """
286
- Ensure audio is mono float32. Returns None if unusable.
 
 
 
 
287
  """
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
288
  if audio is None or len(audio) == 0:
289
  return None
290
 
291
  audio = np.array(audio, dtype=np.float32)
292
 
293
- # Stereo β†’ mono
294
  if audio.ndim == 2:
295
  audio = audio.mean(axis=1)
296
  elif audio.ndim != 1:
297
- logger.warning(f"Unexpected audio shape {audio.shape} β€” skipping")
298
  return None
299
 
300
- # Normalise int16-range inputs
301
  if audio.max() > 1.0 or audio.min() < -1.0:
302
  audio = audio / 32768.0
303
 
304
- audio = np.clip(audio, -1.0, 1.0)
305
- return audio
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
306
 
307
 
308
- # ── Module-level singleton ─────────────────────────────────────────────────────
309
- # Shared across the whole app β€” model loaded once, reused every utterance.
310
  _transcriber: Optional[Transcriber] = None
311
 
312
 
@@ -318,60 +375,46 @@ def get_transcriber() -> Transcriber:
318
  return _transcriber
319
 
320
 
321
- # ── Offline smoke test (no HF token needed) ───────────────────────────────────
322
-
323
  def _smoke_test_offline():
324
- """
325
- Validates the audio pre-processing path without loading the model.
326
- Full model load requires a HF token + accepted Cohere licence.
327
- """
328
  import math
 
329
  logging.basicConfig(level=logging.INFO)
330
- logger.info("Running offline smoke test (pre-processing only)…")
331
 
332
- SR = 16000
333
 
334
- # 1. Stereo int16 β†’ mono float32
335
- stereo_int16 = (np.random.randn(SR, 2) * 32767).astype(np.int16)
336
  result = Transcriber._validate_audio(stereo_int16)
337
  assert result is not None
338
- assert result.ndim == 1, f"Expected mono, got shape {result.shape}"
339
- assert result.dtype == np.float32
340
- assert result.max() <= 1.0
341
- assert result.min() >= -1.0
342
- logger.info(" βœ“ Stereo int16 β†’ mono float32 normalisation")
343
-
344
- # 2. Already mono float32 β€” passthrough
345
- mono_float = np.sin(2 * math.pi * 440 * np.linspace(0, 1, SR)).astype(np.float32)
346
  result = Transcriber._validate_audio(mono_float)
347
- assert result is not None and result.shape == (SR,)
348
- logger.info(" βœ“ Mono float32 passthrough")
349
 
350
- # 3. Empty input β†’ None
351
  result = Transcriber._validate_audio(np.array([]))
352
  assert result is None
353
- logger.info(" βœ“ Empty input β†’ None")
354
 
355
- # 4. Singleton pattern
356
  t1 = get_transcriber()
357
  t2 = get_transcriber()
358
  assert t1 is t2
359
- logger.info(" βœ“ Module singleton")
360
 
361
- logger.info("\nOffline smoke test PASSED βœ“")
362
  logger.info(
363
- "\nTo run the full model test (requires HF_TOKEN + accepted licence):\n"
364
- " export HF_TOKEN=hf_...\n"
365
- " PYTHONPATH=. python3 -c \"\n"
366
- " from pipeline.transcriber import get_transcriber\n"
367
- " import numpy as np\n"
368
- " t = get_transcriber()\n"
369
- " # 2s of 440 Hz tone β€” expect near-empty transcription\n"
370
- " audio = np.sin(2*3.14*440*np.linspace(0,2,32000)).astype('float32')\n"
371
- " print(repr(t.transcribe(16000, audio)))\n"
372
- " \""
373
  )
374
 
375
 
376
  if __name__ == "__main__":
377
- _smoke_test_offline()
 
1
  """
2
  pipeline/transcriber.py
3
 
4
+ Hugging Face Moonshine ASR wrapper for the telecalling agent.
5
+
6
+ The public API intentionally matches the previous ASR wrapper:
7
+
8
+ transcriber = Transcriber()
9
+ text = transcriber.transcribe(sample_rate, audio_np)
10
+
11
+ Internally this uses UsefulSensors/moonshine-tiny through Transformers:
12
+ AutoFeatureExtractor prepares audio features, AutoTokenizer decodes generated
13
+ tokens, and MoonshineForConditionalGeneration generates transcripts in memory.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14
  """
15
 
16
  import logging
17
  import threading
18
  import time
19
+ from typing import Optional, Sequence
20
 
21
  import numpy as np
22
  import torch
23
 
24
  from config import (
25
  TRANSCRIBE_MODEL_ID,
 
26
  TRANSCRIBE_DEVICE,
27
  HF_TOKEN,
28
  )
 
32
 
33
  class Transcriber:
34
  """
35
+ Lazy-loading wrapper around UsefulSensors/moonshine-tiny.
36
 
37
  Thread-safe: a single instance can be shared across the whole app.
38
  """
39
 
40
+ # Recommended by the Moonshine model card to reduce hallucination loops.
41
+ _MAX_TOKENS_PER_SECOND = 6.5
 
 
 
 
42
 
43
+ def __init__(self):
44
+ self._model = None
45
+ self._feature_extractor = None
46
+ self._tokenizer = None
47
+ self._device = None
48
+ self._dtype = None
49
+ self._sample_rate = None
50
+ self._lock = threading.Lock()
51
+ self._loaded = False
52
 
53
  def transcribe(self, sample_rate: int, audio: np.ndarray) -> str:
54
  """
 
57
  Parameters
58
  ----------
59
  sample_rate : int
60
+ Sample rate of `audio`.
61
  audio : np.ndarray
62
  Mono float32 PCM in [-1.0, 1.0].
63
 
 
78
 
79
  try:
80
  t0 = time.perf_counter()
81
+ text = self._generate_text([audio], [sample_rate])[0]
 
 
 
 
 
 
 
 
 
 
 
82
  elapsed = time.perf_counter() - t0
 
83
 
84
  duration = len(audio) / sample_rate
85
+ rtfx = duration / elapsed if elapsed > 0 else 0
86
  logger.info(
87
  f"Transcribed {duration:.2f}s audio in {elapsed:.2f}s "
88
+ f"(RTFx {rtfx:.1f}x): '{text[:80]}{'...' if len(text) > 80 else ''}'"
89
  )
90
  return text
91
 
 
97
  self, utterances: list[tuple[int, np.ndarray]]
98
  ) -> list[str]:
99
  """
100
+ Transcribe multiple utterances in one generation call.
 
101
 
102
  Parameters
103
  ----------
 
105
 
106
  Returns
107
  -------
108
+ list of str, one entry per input, "" on individual errors
109
  """
110
  if not utterances:
111
  return []
112
 
113
  self._ensure_loaded()
114
 
115
+ sample_rates = [sr for sr, _ in utterances]
116
+ audio_arrays = [self._validate_audio(a) for _, a in utterances]
117
 
 
118
  audio_arrays = [
119
  a if a is not None else np.zeros(sample_rates[i], dtype=np.float32)
120
  for i, a in enumerate(audio_arrays)
121
  ]
122
 
123
  try:
124
+ return self._generate_text(audio_arrays, sample_rates)
 
 
 
 
 
 
 
 
 
 
 
125
  except Exception as exc:
126
  logger.error(f"Batch transcription failed: {exc}", exc_info=True)
127
  return [""] * len(utterances)
128
 
129
  def unload(self):
130
  """
131
+ Release GPU memory. Call at end of a call session if memory is tight.
132
  Model will be reloaded lazily on the next call.
133
  """
134
  with self._lock:
135
  if self._loaded:
136
  del self._model
137
+ del self._feature_extractor
138
+ del self._tokenizer
139
+ self._model = None
140
+ self._feature_extractor = None
141
+ self._tokenizer = None
142
+ self._device = None
143
+ self._dtype = None
144
+ self._sample_rate = None
145
+ self._loaded = False
146
  if torch.cuda.is_available():
147
  torch.cuda.empty_cache()
148
+ logger.info("Moonshine transcriber unloaded; VRAM freed.")
149
 
150
  @property
151
  def is_loaded(self) -> bool:
 
155
  def device(self) -> Optional[str]:
156
  return str(self._device) if self._device else None
157
 
 
 
158
  def _ensure_loaded(self):
159
  """Load model + processor exactly once, thread-safely."""
160
  if self._loaded:
161
  return
162
 
163
  with self._lock:
164
+ if self._loaded:
165
  return
166
  self._load()
167
 
168
  def _load(self):
169
+ """Pull Moonshine from Hugging Face and move it to the best device."""
 
 
 
 
 
 
170
  import os
 
171
 
172
+ from transformers import AutoFeatureExtractor, AutoTokenizer
173
+
174
+ self._install_torchvision_import_stub_if_needed()
175
+ from transformers import MoonshineForConditionalGeneration
176
+
177
+ logger.info(f"Loading Moonshine ASR ({TRANSCRIBE_MODEL_ID})...")
178
  t0 = time.perf_counter()
179
 
180
  token_kwargs = {"token": HF_TOKEN} if HF_TOKEN else {}
 
 
181
  local_only = os.path.exists(
182
+ os.path.expanduser(
183
+ f"~/.cache/huggingface/hub/models--{TRANSCRIBE_MODEL_ID.replace('/', '--')}"
184
+ )
185
  )
186
 
187
+ self._feature_extractor = AutoFeatureExtractor.from_pretrained(
188
+ TRANSCRIBE_MODEL_ID,
189
+ local_files_only=local_only,
190
+ **token_kwargs,
191
+ )
192
+ self._tokenizer = AutoTokenizer.from_pretrained(
193
  TRANSCRIBE_MODEL_ID,
 
194
  local_files_only=local_only,
195
  **token_kwargs,
196
  )
197
+ self._sample_rate = int(self._feature_extractor.sampling_rate)
198
 
 
199
  if torch.cuda.is_available():
200
  try:
201
  self._device = torch.device(TRANSCRIBE_DEVICE)
202
+ self._dtype = torch.float16
203
+ logger.info(f"CUDA available; loading Moonshine in float16 on {self._device}")
204
  except Exception:
205
  self._device = torch.device("cpu")
206
+ self._dtype = torch.float32
207
+ logger.warning("CUDA device init failed; falling back to CPU float32")
208
  else:
209
  self._device = torch.device("cpu")
210
+ self._dtype = torch.float32
211
+ logger.info("No CUDA; loading Moonshine on CPU in float32")
212
 
213
+ self._model = MoonshineForConditionalGeneration.from_pretrained(
 
214
  TRANSCRIBE_MODEL_ID,
215
+ torch_dtype=self._dtype,
216
+ low_cpu_mem_usage=True,
217
+ local_files_only=local_only,
 
218
  **token_kwargs,
219
  ).to(self._device)
 
220
  self._model.eval()
221
 
222
  elapsed = time.perf_counter() - t0
223
  logger.info(
224
+ f"Moonshine ASR ready on {self._device} "
225
+ f"(dtype={self._dtype}, sample_rate={self._sample_rate}, loaded in {elapsed:.1f}s)"
226
  )
227
 
228
+ if torch.cuda.is_available() and self._device.type == "cuda":
229
  allocated = torch.cuda.memory_allocated(self._device) / 1024**3
230
+ reserved = torch.cuda.memory_reserved(self._device) / 1024**3
231
  logger.info(
232
+ f"VRAM after load: allocated={allocated:.2f} GB, reserved={reserved:.2f} GB"
 
233
  )
234
 
235
  self._loaded = True
236
 
237
+ def _generate_text(
238
+ self,
239
+ audio_arrays: Sequence[np.ndarray],
240
+ sample_rates: Sequence[int],
241
+ ) -> list[str]:
242
+ """Run Moonshine generation and decode transcripts."""
243
+ prepared = [
244
+ self._resample(audio, sr, self._sample_rate)
245
+ for audio, sr in zip(audio_arrays, sample_rates)
246
+ ]
247
+
248
+ inputs = self._feature_extractor(
249
+ prepared,
250
+ return_tensors="pt",
251
+ sampling_rate=self._sample_rate,
252
+ padding=True,
253
+ )
254
+ inputs = inputs.to(self._device, self._dtype)
255
+
256
+ with torch.inference_mode():
257
+ seq_lens = inputs.attention_mask.sum(dim=-1)
258
+ token_limit_factor = self._MAX_TOKENS_PER_SECOND / self._sample_rate
259
+ max_length = max(1, int((seq_lens * token_limit_factor).max().item()))
260
+ generated_ids = self._model.generate(**inputs, max_length=max_length)
261
+
262
+ return [
263
+ self._tokenizer.decode(ids, skip_special_tokens=True).strip()
264
+ for ids in generated_ids
265
+ ]
266
+
267
  @staticmethod
268
+ def _install_torchvision_import_stub_if_needed() -> None:
269
  """
270
+ Keep audio-only Moonshine usable when torchvision is installed but broken.
271
+
272
+ Transformers 5.x imports generic image/video utilities while importing
273
+ modeling classes. A mismatched torchvision wheel can raise before any ASR
274
+ code runs, even though Moonshine does not use torchvision at inference.
275
  """
276
+ try:
277
+ import torchvision # noqa: F401
278
+ return
279
+ except Exception:
280
+ pass
281
+
282
+ import importlib.machinery
283
+ import sys
284
+ import types
285
+
286
+ for name in (
287
+ "torchvision",
288
+ "torchvision.transforms",
289
+ "torchvision.transforms.v2",
290
+ "torchvision.transforms.v2.functional",
291
+ "torchvision.io",
292
+ ):
293
+ sys.modules.pop(name, None)
294
+
295
+ torchvision = types.ModuleType("torchvision")
296
+ torchvision.__spec__ = importlib.machinery.ModuleSpec("torchvision", None)
297
+ transforms = types.ModuleType("torchvision.transforms")
298
+ transforms.__spec__ = importlib.machinery.ModuleSpec("torchvision.transforms", None)
299
+ transforms_v2 = types.ModuleType("torchvision.transforms.v2")
300
+ transforms_v2.__spec__ = importlib.machinery.ModuleSpec(
301
+ "torchvision.transforms.v2", None
302
+ )
303
+ transforms_v2_functional = types.ModuleType("torchvision.transforms.v2.functional")
304
+ transforms_v2_functional.__spec__ = importlib.machinery.ModuleSpec(
305
+ "torchvision.transforms.v2.functional", None
306
+ )
307
+ torchvision_io = types.ModuleType("torchvision.io")
308
+ torchvision_io.__spec__ = importlib.machinery.ModuleSpec("torchvision.io", None)
309
+
310
+ class InterpolationMode:
311
+ NEAREST = 0
312
+ NEAREST_EXACT = 0
313
+ BILINEAR = 2
314
+ BICUBIC = 3
315
+ BOX = 4
316
+ HAMMING = 5
317
+ LANCZOS = 1
318
+
319
+ transforms.InterpolationMode = InterpolationMode
320
+ transforms.v2 = transforms_v2
321
+ transforms_v2.functional = transforms_v2_functional
322
+ torchvision.transforms = transforms
323
+ torchvision.io = torchvision_io
324
+
325
+ sys.modules["torchvision"] = torchvision
326
+ sys.modules["torchvision.transforms"] = transforms
327
+ sys.modules["torchvision.transforms.v2"] = transforms_v2
328
+ sys.modules["torchvision.transforms.v2.functional"] = transforms_v2_functional
329
+ sys.modules["torchvision.io"] = torchvision_io
330
+
331
+ @staticmethod
332
+ def _validate_audio(audio: np.ndarray) -> Optional[np.ndarray]:
333
+ """Ensure audio is mono float32 in [-1.0, 1.0]."""
334
  if audio is None or len(audio) == 0:
335
  return None
336
 
337
  audio = np.array(audio, dtype=np.float32)
338
 
 
339
  if audio.ndim == 2:
340
  audio = audio.mean(axis=1)
341
  elif audio.ndim != 1:
342
+ logger.warning(f"Unexpected audio shape {audio.shape}; skipping")
343
  return None
344
 
 
345
  if audio.max() > 1.0 or audio.min() < -1.0:
346
  audio = audio / 32768.0
347
 
348
+ return np.clip(audio, -1.0, 1.0)
349
+
350
+ @staticmethod
351
+ def _resample(audio: np.ndarray, orig_sr: int, target_sr: int) -> np.ndarray:
352
+ """Simple linear interpolation resample for occasional sample-rate mismatch."""
353
+ if orig_sr == target_sr:
354
+ return audio.astype(np.float32, copy=False)
355
+ if len(audio) == 0:
356
+ return audio.astype(np.float32)
357
+
358
+ ratio = target_sr / orig_sr
359
+ new_length = max(1, int(len(audio) * ratio))
360
+ return np.interp(
361
+ np.linspace(0, len(audio) - 1, new_length),
362
+ np.arange(len(audio)),
363
+ audio,
364
+ ).astype(np.float32)
365
 
366
 
 
 
367
  _transcriber: Optional[Transcriber] = None
368
 
369
 
 
375
  return _transcriber
376
 
377
 
 
 
378
  def _smoke_test_offline():
379
+ """Validate audio preprocessing and singleton behavior without loading the model."""
 
 
 
380
  import math
381
+
382
  logging.basicConfig(level=logging.INFO)
383
+ logger.info("Running offline smoke test (pre-processing only)...")
384
 
385
+ sr = 16000
386
 
387
+ stereo_int16 = (np.random.randn(sr, 2) * 32767).astype(np.int16)
 
388
  result = Transcriber._validate_audio(stereo_int16)
389
  assert result is not None
390
+ assert result.ndim == 1, f"Expected mono, got shape {result.shape}"
391
+ assert result.dtype == np.float32
392
+ assert result.max() <= 1.0
393
+ assert result.min() >= -1.0
394
+ logger.info("Stereo int16 to mono float32 normalization")
395
+
396
+ mono_float = np.sin(2 * math.pi * 440 * np.linspace(0, 1, sr)).astype(np.float32)
 
397
  result = Transcriber._validate_audio(mono_float)
398
+ assert result is not None and result.shape == (sr,)
399
+ logger.info("Mono float32 passthrough")
400
 
 
401
  result = Transcriber._validate_audio(np.array([]))
402
  assert result is None
403
+ logger.info("Empty input returns None")
404
 
 
405
  t1 = get_transcriber()
406
  t2 = get_transcriber()
407
  assert t1 is t2
408
+ logger.info("Module singleton")
409
 
410
+ logger.info("Offline smoke test PASSED")
411
  logger.info(
412
+ "\nTo run the full model test:\n"
413
+ " python -c \"from pipeline.transcriber import get_transcriber; "
414
+ "import numpy as np; t=get_transcriber(); "
415
+ "audio=np.zeros(16000,dtype='float32'); print(repr(t.transcribe(16000,audio)))\""
 
 
 
 
 
 
416
  )
417
 
418
 
419
  if __name__ == "__main__":
420
+ _smoke_test_offline()
pipeline/vad_listener.py CHANGED
@@ -13,7 +13,7 @@ chunks. This module wraps that stream in a stateful VADListener that:
13
  3. On silence-end event, flushes the complete utterance into a
14
  thread-safe queue as a (sample_rate, np.ndarray) tuple.
15
  4. The orchestrator reads from that queue and passes each utterance
16
- to Cohere Transcribe.
17
 
18
  Because Gradio's streaming callback fires on the main thread, all VAD
19
  processing is synchronous and cheap (< 1 ms per 250 ms chunk on CPU).
@@ -112,7 +112,7 @@ class VADListener:
112
  Yields
113
  ------
114
  (VAD_SAMPLE_RATE, np.ndarray[float32])
115
- Complete utterance, ready to be passed to Cohere Transcribe.
116
  """
117
  if audio is None or len(audio) == 0:
118
  return
@@ -299,4 +299,4 @@ def _smoke_test():
299
 
300
 
301
  if __name__ == "__main__":
302
- _smoke_test()
 
13
  3. On silence-end event, flushes the complete utterance into a
14
  thread-safe queue as a (sample_rate, np.ndarray) tuple.
15
  4. The orchestrator reads from that queue and passes each utterance
16
+ to Moonshine ASR.
17
 
18
  Because Gradio's streaming callback fires on the main thread, all VAD
19
  processing is synchronous and cheap (< 1 ms per 250 ms chunk on CPU).
 
112
  Yields
113
  ------
114
  (VAD_SAMPLE_RATE, np.ndarray[float32])
115
+ Complete utterance, ready to be passed to Moonshine ASR.
116
  """
117
  if audio is None or len(audio) == 0:
118
  return
 
299
 
300
 
301
  if __name__ == "__main__":
302
+ _smoke_test()
readme.md CHANGED
@@ -5,7 +5,7 @@ TeeleAgentHF is an AI-powered telecalling agent built for a Hugging Face competi
5
  ## Key Features
6
 
7
  - Real-time microphone capture with Gradio UI
8
- - ASR: Cohere Transcribe (streaming)
9
  - Intent parsing: Qwen2.5-7B-Instruct (GGUF via llama-cpp-python)
10
  - Evaluation: MiniCPM3-4B (int4 quantized evaluator)
11
  - VAD: Silero VAD (ONNX)
 
5
  ## Key Features
6
 
7
  - Real-time microphone capture with Gradio UI
8
+ - ASR: Hugging Face Moonshine (streaming)
9
  - Intent parsing: Qwen2.5-7B-Instruct (GGUF via llama-cpp-python)
10
  - Evaluation: MiniCPM3-4B (int4 quantized evaluator)
11
  - VAD: Silero VAD (ONNX)
requirements.txt CHANGED
@@ -1,34 +1,34 @@
1
- # ── Core ML / inference ───────────────────────────────────────────────────────
2
  torch>=2.4.0
3
  torchaudio>=2.4.0
4
- transformers>=4.56,<5.3,!=5.0.*,!=5.1.* # Cohere Transcribe requirement (exact)
5
  accelerate>=0.30.0
6
  bitsandbytes>=0.43.0 # 4-bit quant for MiniCPM on CPU
7
  sentencepiece
8
  protobuf
9
 
10
- # ── Cohere Transcribe ASR ─────────────────────────────────────────────────────
11
  soundfile
12
  librosa
13
  huggingface_hub
14
 
15
- # ── Qwen2.5 intent parser (llama-cpp-python with CUDA offload) ────────────────
16
- # Install with CUDA support β€” do NOT use the plain pip version.
17
  # Run this separately after pip install -r requirements.txt:
18
  # CMAKE_ARGS="-DGGML_CUDA=on" pip install llama-cpp-python --no-cache-dir
19
  # Windows (MSVC):
20
  # set CMAKE_ARGS=-DGGML_CUDA=on && pip install llama-cpp-python --no-cache-dir
21
- llama-cpp-python>=0.2.85 # plain CPU fallback β€” override with CUDA build above
22
 
23
- # ── VAD ───────────────────────────────────────────────────────────────────────
24
  numpy>=1.24.0 # Install early to avoid dependency conflicts
25
  silero-vad # pulls torch; also needs torchaudio for audio I/O
26
  onnxruntime>=1.16.0 # ONNX runtime for Silero VAD
27
  pyaudio # microphone access (Linux: apt install python3-pyaudio)
28
 
29
- # ── Gradio UI ─────────────────────────────────────────────────────────────────
30
  gradio>=4.40.0
31
 
32
- # ── Utilities ─────────────────────────────────────────────────────────────────
33
  python-dotenv # optional: load HF_TOKEN from .env file
34
- pydantic>=2.0 # JSON schema validation for Qwen output
 
1
+ # Core ML / inference
2
  torch>=2.4.0
3
  torchaudio>=2.4.0
4
+ transformers>=4.56,<5.3,!=5.0.*,!=5.1.* # MoonshineForConditionalGeneration support
5
  accelerate>=0.30.0
6
  bitsandbytes>=0.43.0 # 4-bit quant for MiniCPM on CPU
7
  sentencepiece
8
  protobuf
9
 
10
+ # ASR audio helpers
11
  soundfile
12
  librosa
13
  huggingface_hub
14
 
15
+ # Qwen2.5 intent parser (llama-cpp-python with CUDA offload)
16
+ # Install with CUDA support; do not use the plain pip version.
17
  # Run this separately after pip install -r requirements.txt:
18
  # CMAKE_ARGS="-DGGML_CUDA=on" pip install llama-cpp-python --no-cache-dir
19
  # Windows (MSVC):
20
  # set CMAKE_ARGS=-DGGML_CUDA=on && pip install llama-cpp-python --no-cache-dir
21
+ llama-cpp-python>=0.2.85 # plain CPU fallback; override with CUDA build above
22
 
23
+ # VAD
24
  numpy>=1.24.0 # Install early to avoid dependency conflicts
25
  silero-vad # pulls torch; also needs torchaudio for audio I/O
26
  onnxruntime>=1.16.0 # ONNX runtime for Silero VAD
27
  pyaudio # microphone access (Linux: apt install python3-pyaudio)
28
 
29
+ # Gradio UI
30
  gradio>=4.40.0
31
 
32
+ # Utilities
33
  python-dotenv # optional: load HF_TOKEN from .env file
34
+ pydantic>=2.0 # JSON schema validation for Qwen output
test_intent_parser.py ADDED
File without changes