notUbaid commited on
Commit
477898b
·
verified ·
1 Parent(s): f7d5650

Upload ml/model/engine.py with huggingface_hub

Browse files
Files changed (1) hide show
  1. ml/model/engine.py +76 -103
ml/model/engine.py CHANGED
@@ -1,72 +1,62 @@
1
  """
2
- ml/model/engine.py - Production-Grade Unified Speech Diagnostics Engine
3
- ========================================================================
4
- Integrates and optimizes all speech pathology subsystems into a fast,
5
- stable, singleton pipeline:
6
- 1. Neural LoRA Stutter & Disfluency Classifier (wav2vec2-base)
7
- 2. Neural ASR & GOP Phonetic Alignment (wav2vec2-base-960h CTC)
8
- 3. High-Precision Praat Phonation & Articulation Acoustics
9
- 4. Explicit Sound Disorder Rules ('R' Rhotacism, 'S' Sigmatism / Lisping)
10
- 5. Multi-Modal Decision Fusion with Self-Calibration & Silence Guard
11
  """
12
  from __future__ import annotations
13
- import difflib
14
  import gc
15
  import json
16
  import os
17
- import string
18
  import time
19
  from pathlib import Path
20
- from typing import Optional, Dict, Any, List, Tuple, Generator
21
 
22
  import numpy as np
23
  import torch
24
  from peft import PeftModel
25
  from transformers import (
26
  Wav2Vec2FeatureExtractor,
27
- Wav2Vec2ForSequenceClassification,
28
  Wav2Vec2ForCTC,
 
29
  Wav2Vec2Processor,
30
  )
31
 
32
- from ml.model import pron_eval, fusion
33
- from ml.model.stutter_trainer import SR, MAX_SECONDS, MODEL_BASE, ID2LABEL, BIN_ID2LABEL
34
 
 
 
35
  CKPT_PATH = "ml/models/stutter/stutter_lora"
 
 
36
  CTC_MODEL_NAME = "facebook/wav2vec2-base-960h"
37
 
38
- # Constrain PyTorch thread overhead for low-RAM CPU environments (Render / Cloud)
39
- if not torch.cuda.is_available():
40
- torch.set_num_threads(min(2, os.cpu_count() or 1))
41
- torch.set_num_interop_threads(1)
42
-
43
 
44
  class SpeechDiagnosticEngine:
45
- """Singleton, thread-safe, high-speed speech diagnostic pipeline."""
46
-
47
  _instance: Optional[SpeechDiagnosticEngine] = None
48
 
49
  def __init__(self, ckpt_dir: str = CKPT_PATH, device: Optional[str] = None):
50
  self.device = device or ("cuda" if torch.cuda.is_available() else "cpu")
51
  print(f"[SpeechDiagnosticEngine] Initializing on device: {self.device}")
52
 
53
- # 1. Load Neural Stutter Model
54
- ckpt = Path(ckpt_dir)
55
  self.stutter_model = None
56
  self.stutter_feat = None
57
- self.id2label = BIN_ID2LABEL
58
-
 
59
  if ckpt.exists():
60
  try:
61
- cm_path = ckpt.parent / "class_map.json"
62
- binary = True
63
- if cm_path.exists():
64
- try:
65
- binary = bool(json.loads(cm_path.read_text(encoding="utf-8")).get("binary", True))
66
- except Exception:
67
- binary = True
68
- self.id2label = BIN_ID2LABEL if binary else ID2LABEL
69
-
70
  base = Wav2Vec2ForSequenceClassification.from_pretrained(
71
  MODEL_BASE, num_labels=len(self.id2label), ignore_mismatched_sizes=True
72
  )
@@ -85,12 +75,11 @@ class SpeechDiagnosticEngine:
85
  self.asr_model.to(self.device)
86
  self.asr_model.eval()
87
 
88
- # Clean memory allocation
89
  gc.collect()
90
  if torch.cuda.is_available():
91
  torch.cuda.empty_cache()
92
 
93
- print("[SpeechDiagnosticEngine] Engine ready.")
94
 
95
  @classmethod
96
  def get_instance(cls, ckpt_dir: str = CKPT_PATH) -> SpeechDiagnosticEngine:
@@ -101,9 +90,9 @@ class SpeechDiagnosticEngine:
101
 
102
  @torch.inference_mode()
103
  def transcribe_and_align(self, audio_input: Any, reference: str) -> dict:
104
- """Perform neural ASR decoding and word alignment with human tolerance."""
105
- arr = pron_eval._load_wave(audio_input)
106
- if pron_eval.is_silent_or_empty(arr, SR):
107
  ref_norm = pron_eval._norm(reference)
108
  return {
109
  "asr_hypothesis": "",
@@ -118,7 +107,9 @@ class SpeechDiagnosticEngine:
118
  "length_warning": None,
119
  }
120
 
121
- inp = self.asr_processor(arr, sampling_rate=SR, return_tensors="pt")
 
 
122
  inp = {k: v.to(self.device) for k, v in inp.items()}
123
  logits = self.asr_model(**inp).logits
124
  pred_ids = torch.argmax(logits, dim=-1)
@@ -126,42 +117,20 @@ class SpeechDiagnosticEngine:
126
 
127
  ref = pron_eval._norm(reference)
128
  alignment = pron_eval.align_words(ref, hypothesis)
129
-
130
- ref_words = ref.split()
131
- hyp_words = hypothesis.split()
132
- n_ref = max(len(ref_words), 1)
133
- n_hyp = max(len(hyp_words), 1)
134
 
135
  length_warning = None
136
- if len(hyp_words) == 1 and len(ref_words) >= 4:
137
- length_warning = f"You spoke 1 word ('{hypothesis}'), but the target sentence has {len(ref_words)} words."
138
-
139
- correct_count = sum(1 for a in alignment if a["status"] == "correct")
140
-
141
- if len(hyp_words) < len(ref_words) and len(hyp_words) > 0:
142
- spoken_precision = correct_count / n_hyp
143
- matched_targets = " ".join([a["expected"] for a in alignment if a["spoken"] != "—"])
144
- char_sim = difflib.SequenceMatcher(None, hypothesis, matched_targets).ratio()
145
- pron_score = 0.70 * spoken_precision + 0.30 * char_sim
146
- else:
147
- word_acc = correct_count / n_ref
148
- char_acc = difflib.SequenceMatcher(None, ref, hypothesis).ratio()
149
- pron_score = 0.75 * word_acc + 0.25 * char_acc
150
-
151
- if correct_count == n_ref or (len(hyp_words) == 1 and len(ref_words) == 1 and hyp_words[0] == ref_words[0]):
152
- pron_score = 1.0
153
-
154
- errors = sum(1 for a in alignment if a["status"] != "correct")
155
- wer = min(1.0, errors / n_ref)
156
 
157
  return {
158
  "asr_hypothesis": hypothesis,
159
  "reference_normalized": ref,
160
- "word_error": errors,
161
- "n_reference_words": n_ref,
162
- "wer": round(wer, 4),
163
- "goodness": round(pron_score, 4),
164
- "pron_score": round(pron_score, 4),
165
  "alignment": alignment,
166
  "is_silent": False,
167
  "length_warning": length_warning,
@@ -173,11 +142,12 @@ class SpeechDiagnosticEngine:
173
  if self.stutter_model is None:
174
  return None
175
 
176
- arr = pron_eval._load_wave(audio_input)
177
- if pron_eval.is_silent_or_empty(arr, SR):
178
  return None
179
 
180
- arr_clipped = arr[: int(SR * MAX_SECONDS)]
 
181
  inp = self.stutter_feat(arr_clipped, sampling_rate=SR, return_tensors="pt", padding=True)
182
  inp = {k: v.to(self.device) for k, v in inp.items()}
183
  logits = self.stutter_model(**inp).logits
@@ -192,23 +162,23 @@ class SpeechDiagnosticEngine:
192
  """Complete, unified diagnostic pipeline with latency timing."""
193
  t0 = time.perf_counter()
194
 
195
- # 1. Load and condition audio
196
- arr = pron_eval._load_wave(audio_input)
197
- is_silent = pron_eval.is_silent_or_empty(arr, SR)
198
 
199
- # 2. Articulation Acoustics (Praat)
200
- artic = pron_eval.praat_metrics_arr(arr, SR)
201
 
202
- # 3. Neural ASR Pronunciation & Word Alignment
203
- pron = self.transcribe_and_align(arr, target_phrase)
204
 
205
- # 4. Neural Stutter Classification
206
- stut_probs = self.predict_stutter_probs(arr) if not is_silent else None
207
  p_stut = float(stut_probs[1]) if (stut_probs and len(stut_probs) > 1) else (
208
  float(np.sum(stut_probs[1:])) if stut_probs else 0.0
209
  )
210
 
211
- # 5. Sound Disorder Analysis ('R', 'S', Substitutions, Phonation)
212
  flaws = pron_eval.analyze_speech_flaws(
213
  reference=target_phrase,
214
  hypothesis=pron.get("asr_hypothesis", ""),
@@ -218,7 +188,7 @@ class SpeechDiagnosticEngine:
218
  is_silent=is_silent,
219
  )
220
 
221
- # 6. Self-Calibration (if provided)
222
  cal = None
223
  if normal_calibration_audio is not None and self.stutter_model is not None:
224
  norm_probs = self.predict_stutter_probs(normal_calibration_audio)
@@ -227,7 +197,6 @@ class SpeechDiagnosticEngine:
227
 
228
  # 7. Multi-Modal Decision Fusion
229
  decision = fusion.diag_statistics(stut_probs, pron, artic, cal)
230
-
231
  latency_ms = round((time.perf_counter() - t0) * 1000, 1)
232
 
233
  return {
@@ -238,7 +207,8 @@ class SpeechDiagnosticEngine:
238
  "articulation": artic,
239
  "stutter_probs": stut_probs,
240
  "latency_ms": latency_ms,
241
- "duration_s": round(len(arr) / SR, 2),
 
242
  }
243
 
244
  def diagnose_audio_stream(
@@ -250,49 +220,51 @@ class SpeechDiagnosticEngine:
250
  """Streaming generator yielding step-by-step progress and telemetry."""
251
  t0 = time.perf_counter()
252
 
253
- # Step 1: Conditioning
254
  t_step = time.perf_counter()
255
- arr = pron_eval._load_wave(audio_input)
256
- is_silent = pron_eval.is_silent_or_empty(arr, SR)
257
  t_s1 = round((time.perf_counter() - t_step) * 1000, 1)
258
  yield {
259
  "step": 1,
260
  "total": 5,
261
  "label": "Acoustic Signal Preconditioning",
262
- "detail": f"16kHz PCM Resampling, 60Hz Butterworth High-Pass, Silence Check ({t_s1} ms)",
263
  "progress": 0.20,
264
  "elapsed_ms": round((time.perf_counter() - t0) * 1000, 1),
265
  }
266
 
267
- # Step 2: Praat Phonation
268
  t_step = time.perf_counter()
269
- artic = pron_eval.praat_metrics_arr(arr, SR)
270
  t_s2 = round((time.perf_counter() - t_step) * 1000, 1)
 
 
271
  yield {
272
  "step": 2,
273
  "total": 5,
274
- "label": "Biomechanical Phonation Tracking",
275
- "detail": f"Praat PointProcess Pitch F0={artic.get('f0_median_hz',0):.1f}Hz, Jitter={artic.get('jitter',0)*100:.2f}%, HNR={artic.get('hnr_db',0):.1f}dB ({t_s2} ms)",
276
  "progress": 0.40,
277
  "elapsed_ms": round((time.perf_counter() - t0) * 1000, 1),
278
  }
279
 
280
- # Step 3: Neural CTC ASR Alignment
281
  t_step = time.perf_counter()
282
- pron = self.transcribe_and_align(arr, target_phrase)
283
  t_s3 = round((time.perf_counter() - t_step) * 1000, 1)
284
  yield {
285
  "step": 3,
286
  "total": 5,
287
- "label": "Neural ASR & Dynamic Alignment",
288
- "detail": f"wav2vec2-CTC Transcribed: \"{pron.get('asr_hypothesis','')}\" | WER: {pron.get('wer',0)*100:.1f}% ({t_s3} ms)",
289
  "progress": 0.60,
290
  "elapsed_ms": round((time.perf_counter() - t0) * 1000, 1),
291
  }
292
 
293
- # Step 4: Neural Disfluency Classification
294
  t_step = time.perf_counter()
295
- stut_probs = self.predict_stutter_probs(arr) if not is_silent else None
296
  p_stut = float(stut_probs[1]) if (stut_probs and len(stut_probs) > 1) else (
297
  float(np.sum(stut_probs[1:])) if stut_probs else 0.0
298
  )
@@ -335,7 +307,8 @@ class SpeechDiagnosticEngine:
335
  "articulation": artic,
336
  "stutter_probs": stut_probs,
337
  "latency_ms": latency_ms,
338
- "duration_s": round(len(arr) / SR, 2),
 
339
  "step_timings_ms": {
340
  "preconditioning": t_s1,
341
  "phonation_praat": t_s2,
@@ -349,7 +322,7 @@ class SpeechDiagnosticEngine:
349
  "step": 5,
350
  "total": 5,
351
  "label": "Multi-Modal Decision Fusion & Clinical Report",
352
- "detail": f"Fluency Index: {decision.get('fluency_100', 0)}/100 | Severity: {decision['buckets']['overall'].upper()} ({t_s5} ms)",
353
  "progress": 1.0,
354
  "elapsed_ms": latency_ms,
355
  "final_result": final_res,
 
1
  """
2
+ ml/model/engine.py - Complete Multi-Modal Speech Diagnostic Engine
3
+ ===================================================================
4
+ Orchestrates:
5
+ 1. Signal conditioning & raw-signal Multi-Factor VAD
6
+ 2. Acoustic & Voice Phonation Analysis (Praat PointProcess)
7
+ 3. Neural ASR & DP Phonetic Alignment (wav2vec2-CTC)
8
+ 4. Neural Disfluency Detection (wav2vec2 + LoRA)
9
+ 5. Multi-Modal Decision Fusion, Concern Bands & Confidence Margins
 
10
  """
11
  from __future__ import annotations
 
12
  import gc
13
  import json
14
  import os
 
15
  import time
16
  from pathlib import Path
17
+ from typing import Any, Dict, Generator, List, Optional
18
 
19
  import numpy as np
20
  import torch
21
  from peft import PeftModel
22
  from transformers import (
23
  Wav2Vec2FeatureExtractor,
 
24
  Wav2Vec2ForCTC,
25
+ Wav2Vec2ForSequenceClassification,
26
  Wav2Vec2Processor,
27
  )
28
 
29
+ from ml.model import fusion, pron_eval
 
30
 
31
+ SR = 16000
32
+ MAX_SECONDS = 6.0
33
  CKPT_PATH = "ml/models/stutter/stutter_lora"
34
+ CLASS_MAP_PATH = "ml/models/stutter/class_map.json"
35
+ MODEL_BASE = "facebook/wav2vec2-base"
36
  CTC_MODEL_NAME = "facebook/wav2vec2-base-960h"
37
 
 
 
 
 
 
38
 
39
  class SpeechDiagnosticEngine:
 
 
40
  _instance: Optional[SpeechDiagnosticEngine] = None
41
 
42
  def __init__(self, ckpt_dir: str = CKPT_PATH, device: Optional[str] = None):
43
  self.device = device or ("cuda" if torch.cuda.is_available() else "cpu")
44
  print(f"[SpeechDiagnosticEngine] Initializing on device: {self.device}")
45
 
46
+ # 1. Load LoRA Stutter Classifier
 
47
  self.stutter_model = None
48
  self.stutter_feat = None
49
+ self.id2label = {0: "fluent", 1: "stutter"}
50
+
51
+ ckpt = Path(ckpt_dir)
52
  if ckpt.exists():
53
  try:
54
+ cm_file = ckpt.parent / "class_map.json"
55
+ if cm_file.exists():
56
+ with open(cm_file, "r", encoding="utf-8") as f:
57
+ data = json.load(f)
58
+ self.id2label = {int(k): v for k, v in data.get("id2label", {}).items()}
59
+
 
 
 
60
  base = Wav2Vec2ForSequenceClassification.from_pretrained(
61
  MODEL_BASE, num_labels=len(self.id2label), ignore_mismatched_sizes=True
62
  )
 
75
  self.asr_model.to(self.device)
76
  self.asr_model.eval()
77
 
 
78
  gc.collect()
79
  if torch.cuda.is_available():
80
  torch.cuda.empty_cache()
81
 
82
+ print("[SpeechDiagnosticEngine] Engine initialized and ready.")
83
 
84
  @classmethod
85
  def get_instance(cls, ckpt_dir: str = CKPT_PATH) -> SpeechDiagnosticEngine:
 
90
 
91
  @torch.inference_mode()
92
  def transcribe_and_align(self, audio_input: Any, reference: str) -> dict:
93
+ """Perform neural ASR decoding and DP phonetic word alignment."""
94
+ raw_arr = pron_eval._load_wave(audio_input)
95
+ if pron_eval.is_silent_or_empty(raw_arr, SR):
96
  ref_norm = pron_eval._norm(reference)
97
  return {
98
  "asr_hypothesis": "",
 
107
  "length_warning": None,
108
  }
109
 
110
+ # Normalize audio strictly for neural model forward pass
111
+ norm_arr = pron_eval.normalize_for_neural_inference(raw_arr)
112
+ inp = self.asr_processor(norm_arr, sampling_rate=SR, return_tensors="pt")
113
  inp = {k: v.to(self.device) for k, v in inp.items()}
114
  logits = self.asr_model(**inp).logits
115
  pred_ids = torch.argmax(logits, dim=-1)
 
117
 
118
  ref = pron_eval._norm(reference)
119
  alignment = pron_eval.align_words(ref, hypothesis)
120
+ wer, pron_score, counts = pron_eval.compute_standard_wer_and_pron_score(ref, hypothesis, alignment)
 
 
 
 
121
 
122
  length_warning = None
123
+ if len(hypothesis.split()) == 1 and len(ref.split()) >= 4:
124
+ length_warning = f"Only 1 word ('{hypothesis}') was recognized out of {len(ref.split())} target words."
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
125
 
126
  return {
127
  "asr_hypothesis": hypothesis,
128
  "reference_normalized": ref,
129
+ "word_error": counts["substitutions"] + counts["deletions"] + counts["insertions"],
130
+ "n_reference_words": counts["n_ref"],
131
+ "wer": wer,
132
+ "goodness": pron_score,
133
+ "pron_score": pron_score,
134
  "alignment": alignment,
135
  "is_silent": False,
136
  "length_warning": length_warning,
 
142
  if self.stutter_model is None:
143
  return None
144
 
145
+ raw_arr = pron_eval._load_wave(audio_input)
146
+ if pron_eval.is_silent_or_empty(raw_arr, SR):
147
  return None
148
 
149
+ norm_arr = pron_eval.normalize_for_neural_inference(raw_arr)
150
+ arr_clipped = norm_arr[: int(SR * MAX_SECONDS)]
151
  inp = self.stutter_feat(arr_clipped, sampling_rate=SR, return_tensors="pt", padding=True)
152
  inp = {k: v.to(self.device) for k, v in inp.items()}
153
  logits = self.stutter_model(**inp).logits
 
162
  """Complete, unified diagnostic pipeline with latency timing."""
163
  t0 = time.perf_counter()
164
 
165
+ # 1. Load raw audio and check VAD
166
+ raw_arr = pron_eval._load_wave(audio_input)
167
+ is_silent = pron_eval.is_silent_or_empty(raw_arr, SR)
168
 
169
+ # 2. Voice Acoustics & Phonation (Praat on raw waveform)
170
+ artic = pron_eval.praat_metrics_arr(raw_arr, SR)
171
 
172
+ # 3. Neural ASR & DP Alignment
173
+ pron = self.transcribe_and_align(raw_arr, target_phrase)
174
 
175
+ # 4. Neural Disfluency Detection
176
+ stut_probs = self.predict_stutter_probs(raw_arr) if not is_silent else None
177
  p_stut = float(stut_probs[1]) if (stut_probs and len(stut_probs) > 1) else (
178
  float(np.sum(stut_probs[1:])) if stut_probs else 0.0
179
  )
180
 
181
+ # 5. Acoustic-Phonetic Flaw Rules
182
  flaws = pron_eval.analyze_speech_flaws(
183
  reference=target_phrase,
184
  hypothesis=pron.get("asr_hypothesis", ""),
 
188
  is_silent=is_silent,
189
  )
190
 
191
+ # 6. Baseline Normalization ("My Normal")
192
  cal = None
193
  if normal_calibration_audio is not None and self.stutter_model is not None:
194
  norm_probs = self.predict_stutter_probs(normal_calibration_audio)
 
197
 
198
  # 7. Multi-Modal Decision Fusion
199
  decision = fusion.diag_statistics(stut_probs, pron, artic, cal)
 
200
  latency_ms = round((time.perf_counter() - t0) * 1000, 1)
201
 
202
  return {
 
207
  "articulation": artic,
208
  "stutter_probs": stut_probs,
209
  "latency_ms": latency_ms,
210
+ "duration_s": round(len(raw_arr) / SR, 2),
211
+ "device": self.device,
212
  }
213
 
214
  def diagnose_audio_stream(
 
220
  """Streaming generator yielding step-by-step progress and telemetry."""
221
  t0 = time.perf_counter()
222
 
223
+ # Step 1: Conditioning & Raw-Signal VAD
224
  t_step = time.perf_counter()
225
+ raw_arr = pron_eval._load_wave(audio_input)
226
+ is_silent = pron_eval.is_silent_or_empty(raw_arr, SR)
227
  t_s1 = round((time.perf_counter() - t_step) * 1000, 1)
228
  yield {
229
  "step": 1,
230
  "total": 5,
231
  "label": "Acoustic Signal Preconditioning",
232
+ "detail": f"16kHz PCM Resampling, 60Hz High-Pass, Multi-Factor VAD ({t_s1} ms)",
233
  "progress": 0.20,
234
  "elapsed_ms": round((time.perf_counter() - t0) * 1000, 1),
235
  }
236
 
237
+ # Step 2: Phonation & Voice Analysis (Praat)
238
  t_step = time.perf_counter()
239
+ artic = pron_eval.praat_metrics_arr(raw_arr, SR)
240
  t_s2 = round((time.perf_counter() - t_step) * 1000, 1)
241
+ f0_val = f"{artic.get('f0_median_hz', 0):.1f}Hz" if artic.get('f0_median_hz') is not None else "N/A"
242
+ hnr_val = f"{artic.get('hnr_db', 0):.1f}dB" if artic.get('hnr_db') is not None else "N/A"
243
  yield {
244
  "step": 2,
245
  "total": 5,
246
+ "label": "Acoustic & Voice Phonation Analysis",
247
+ "detail": f"Praat PointProcess Pitch F0={f0_val}, HNR={hnr_val} ({t_s2} ms)",
248
  "progress": 0.40,
249
  "elapsed_ms": round((time.perf_counter() - t0) * 1000, 1),
250
  }
251
 
252
+ # Step 3: Neural ASR & DP Phonetic Alignment
253
  t_step = time.perf_counter()
254
+ pron = self.transcribe_and_align(raw_arr, target_phrase)
255
  t_s3 = round((time.perf_counter() - t_step) * 1000, 1)
256
  yield {
257
  "step": 3,
258
  "total": 5,
259
+ "label": "Neural ASR & Phonetic Alignment",
260
+ "detail": f"wav2vec2-CTC: \"{pron.get('asr_hypothesis','')}\" | WER: {pron.get('wer',0)*100:.1f}% ({t_s3} ms)",
261
  "progress": 0.60,
262
  "elapsed_ms": round((time.perf_counter() - t0) * 1000, 1),
263
  }
264
 
265
+ # Step 4: Neural Disfluency Detection
266
  t_step = time.perf_counter()
267
+ stut_probs = self.predict_stutter_probs(raw_arr) if not is_silent else None
268
  p_stut = float(stut_probs[1]) if (stut_probs and len(stut_probs) > 1) else (
269
  float(np.sum(stut_probs[1:])) if stut_probs else 0.0
270
  )
 
307
  "articulation": artic,
308
  "stutter_probs": stut_probs,
309
  "latency_ms": latency_ms,
310
+ "duration_s": round(len(raw_arr) / SR, 2),
311
+ "device": self.device,
312
  "step_timings_ms": {
313
  "preconditioning": t_s1,
314
  "phonation_praat": t_s2,
 
322
  "step": 5,
323
  "total": 5,
324
  "label": "Multi-Modal Decision Fusion & Clinical Report",
325
+ "detail": f"Screening Index: {decision.get('fluency_100', 0)}/100 | Concern Band: {decision['buckets']['overall'].upper()} ({t_s5} ms)",
326
  "progress": 1.0,
327
  "elapsed_ms": latency_ms,
328
  "final_result": final_res,