testingfaces commited on
Commit
b721449
·
verified ·
1 Parent(s): 15f6f5f

Update denoiser.py

Browse files
Files changed (1) hide show
  1. denoiser.py +192 -525
denoiser.py CHANGED
@@ -1,28 +1,8 @@
1
  """
2
- Department 1 — Professional Audio Enhancer
3
- Matches CleanVoice feature-for-feature using FREE local models:
4
-
5
- ✅ Background noise removal → DeepFilterNet (SOTA free model) → noisereduce fallback
6
- ✅ Filler word removal → Word-level timestamps + room tone fill
7
- ✅ Stutter removal → Repeated-phrase detection + cut (fixed: catches triple+ repeats)
8
- ✅ Long silence removal → Energy-based VAD (keeps natural pauses)
9
- ✅ Breath sound reduction → Spectral gating (noisereduce non-stationary)
10
- ✅ Mouth sound reduction → Amplitude zscore transient suppression (tuned threshold)
11
- ✅ Room tone fill → Captures room noise, fills cuts naturally
12
- ✅ Audio normalization → pyloudnorm -18 LUFS
13
- ✅ CD quality output → 48000Hz PCM_24 (matches DeepFilterNet native SR)
14
-
15
- FIXES APPLIED:
16
- - TARGET_SR set to 48000 to match DeepFilterNet natively (no double resampling)
17
- - Mouth sound threshold raised 4.5→6.0 std (was removing real consonants p/b/t)
18
- - Duplicate _remove_background_noise fixed (was silently overwriting first def)
19
- - TARGET_SR set to 48000 — matches DeepFilterNet natively
20
- - Wiener filter added as Priority 2 fallback (artifact-free)
21
- - noisereduce kept as gentle last resort only
22
- - Room tone fallback: uses first 100ms if audio too short
23
- - Stutter detection fixed: now catches triple+ repeats (I I I was → I was)
24
- - Filler removal: also returns cleaned transcript text
25
- - Normalise RMS fallback formula corrected
26
  """
27
 
28
  import os
@@ -36,627 +16,314 @@ import logging
36
 
37
  logger = logging.getLogger(__name__)
38
 
39
- # TARGET_SR: 48000 matches DeepFilterNet's native sample rate (best quality).
40
- # On HuggingFace Spaces where DeepFilterNet is unavailable (no Rust compiler),
41
- # the Wiener/noisereduce fallbacks work fine at 48000 too — no need to change.
42
- # Override via environment variable if needed: TARGET_SR=44100 python app.py
43
- TARGET_SR = int(os.environ.get("TARGET_SR", 48000))
44
  TARGET_LOUDNESS = -18.0
45
 
46
- # Filler words (English + Telugu + Hindi)
47
  FILLER_WORDS = {
48
  "um", "umm", "ummm", "uh", "uhh", "uhhh",
49
- "hmm", "hm", "hmm", "hmmm",
50
- "er", "err", "errr",
51
- "eh", "ahh", "ah", "ohh", "oh",
52
- "like", "basically", "literally", "actually",
53
- "you know", "i mean", "so", "anyway", "anyways",
54
- "right", "okay", "ok", "alright",
55
- "well", "just", "sort of", "kind of",
56
- # Telugu
57
- "ante", "ane", "mane", "arey", "enti", "emo", "ani",
58
- # Hindi
59
- "matlab", "yani", "bas", "acha", "haan", "aur",
60
  }
61
 
62
-
63
  class Denoiser:
64
  def __init__(self):
65
- self._df_model = None
66
- self._df_state = None
67
- self._df_loaded = False
68
- self._room_tone = None # captured room noise sample
69
- print("[Denoiser] ✅ Professional Audio Enhancer ready")
70
 
71
- # ══════════════════════════════════════════════════════════════════
72
- # MAIN ENTRY POINT
73
- # ══════════════════════════════════════════════════════════════════
74
  def process(self, audio_path: str, out_dir: str,
75
- remove_fillers: bool = True,
76
- remove_silences: bool = True,
77
- remove_breaths: bool = True,
78
- remove_mouth_sounds: bool = True,
79
- remove_stutters: bool = True,
80
- word_segments: list = None) -> dict:
81
- """
82
- Full professional pipeline.
83
- word_segments: list of {'word': str, 'start': float, 'end': float}
84
- from Whisper word-level timestamps.
85
- Returns: {'audio_path': str, 'stats': dict}
86
- """
87
  t0 = time.time()
88
  stats = {}
89
- print("[Denoiser] ▶ Starting professional enhancement pipeline...")
90
 
91
- # ── 0. Convert to standard WAV ───────────────────────────────
92
  wav_in = os.path.join(out_dir, "stage0_input.wav")
93
  self._to_wav(audio_path, wav_in, TARGET_SR)
94
  audio, sr = sf.read(wav_in, always_2d=True)
95
  n_ch = audio.shape[1]
96
- duration = len(audio) / sr
97
- print(f"[Denoiser] Input: {sr}Hz, {n_ch}ch, {duration:.1f}s")
98
-
99
- # Work in mono float32
100
  mono = audio.mean(axis=1).astype(np.float32)
101
-
102
- # ── 1. Capture room tone BEFORE denoising ────────────────────
103
  self._room_tone = self._capture_room_tone(mono, sr)
104
 
105
- # ── 2. High-Pass Filter — removes low-freq hum/rumble (100-300Hz)
106
- # Applied BEFORE denoising for best results
107
- # Cuts electrical hum, fan/AC vibration, traffic rumble
108
- # Voice clarity is at 300-3400Hz — not affected at all
109
- mono = self._highpass_filter(mono, sr, cutoff_hz=100)
110
-
111
- # ── 2b. Clip protection — normalize if signal is clipping ──
112
- # Clipping causes distortion and ruins all downstream steps
113
- peak = np.max(np.abs(mono))
114
- if peak > 0.95:
115
- mono = mono * (0.95 / peak)
116
- print(f"[Denoiser] ⚠️ Clipping detected (peak={peak:.3f}) — normalized to 0.95")
117
-
118
- # ── 3. Background Noise Removal ──────────────────────────────
119
- mono, noise_method = self._remove_background_noise(mono, sr)
120
  stats['noise_method'] = noise_method
121
 
122
- # ── 3. Mouth Sound Reduction (clicks/pops) ───────────────────
123
  if remove_mouth_sounds:
124
- mono, n_clicks = self._reduce_mouth_sounds(mono, sr)
125
- stats['mouth_sounds_removed'] = n_clicks
126
-
127
- # ── 4. Breath Reduction ──────────────────────────────────────
128
  if remove_breaths:
129
  mono = self._reduce_breaths(mono, sr)
130
  stats['breaths_reduced'] = True
131
 
132
- # ── 5. Filler Word Removal (needs word-level timestamps) ─────
133
  stats['fillers_removed'] = 0
134
  if remove_fillers and word_segments:
135
- mono, n_fillers = self._remove_fillers(mono, sr, word_segments)
136
- stats['fillers_removed'] = n_fillers
137
 
138
- # ── 6. Stutter Removal (needs word-level timestamps) ─────────
139
  stats['stutters_removed'] = 0
140
  if remove_stutters and word_segments:
141
- mono, n_stutters = self._remove_stutters(mono, sr, word_segments)
142
- stats['stutters_removed'] = n_stutters
143
 
144
- # ── 7. Long Silence Removal ───────────────────────────────────
145
  stats['silences_removed_sec'] = 0.0
146
  if remove_silences:
147
- mono, sil_sec = self._remove_long_silences(mono, sr)
148
- stats['silences_removed_sec'] = round(sil_sec, 2)
149
 
150
- # ── 8. Voice warmth EQ ────────────────────────────────────────
151
- mono = self._voice_eq(mono, sr)
152
-
153
- # ── 9. Normalize Loudness ─────────────────────────────────────
154
  mono = self._normalise(mono, sr)
155
 
156
- # ── 10. Restore stereo / save ─────────────────────────────────
157
  out_audio = np.stack([mono, mono], axis=1) if n_ch == 2 else mono
158
  out_path = os.path.join(out_dir, "denoised.wav")
159
  sf.write(out_path, out_audio, sr, subtype="PCM_24")
160
 
161
  stats['processing_sec'] = round(time.time() - t0, 2)
162
- print(f"[Denoiser] ✅ Done in {stats['processing_sec']}s | {stats}")
163
  return {'audio_path': out_path, 'stats': stats}
164
 
165
  # ══════════════════════════════════════════════════════════════════
166
- # ROOM TONE CAPTURE
167
  # ══════════════════════════════════════════════════════════════════
168
- def _capture_room_tone(self, audio: np.ndarray, sr: int,
169
- sample_sec: float = 0.5) -> np.ndarray:
170
- """
171
- Find the quietest 0.5s section of audio = room tone.
172
- FIX: Falls back to first 100ms if audio is too short.
173
- """
174
- try:
175
- frame = int(sr * sample_sec)
176
-
177
- # FIX: Robust fallback for short audio
178
- if len(audio) < frame * 2:
179
- fallback_len = min(int(sr * 0.1), len(audio)) # first 100ms
180
- print("[Denoiser] Short audio — using first 100ms as room tone")
181
- return audio[:fallback_len].copy().astype(np.float32)
182
-
183
- best_rms = float('inf')
184
- best_start = 0
185
-
186
- step = sr
187
- for i in range(0, len(audio) - frame, step):
188
- chunk = audio[i:i + frame]
189
- rms = float(np.sqrt(np.mean(chunk ** 2)))
190
- if rms < best_rms:
191
- best_rms = rms
192
- best_start = i
193
-
194
- room = audio[best_start: best_start + frame].copy()
195
- print(f"[Denoiser] Room tone captured: RMS={best_rms:.5f}")
196
- return room
197
- except Exception as e:
198
- logger.warning(f"Room tone capture failed: {e}")
199
- return np.zeros(int(sr * sample_sec), dtype=np.float32)
200
-
201
- def _fill_with_room_tone(self, length: int) -> np.ndarray:
202
- """Tile room tone to fill a gap of `length` samples."""
203
- if self._room_tone is None or len(self._room_tone) == 0:
204
- return np.zeros(length, dtype=np.float32)
205
- reps = length // len(self._room_tone) + 1
206
- tiled = np.tile(self._room_tone, reps)[:length]
207
- # Fade in/out to avoid clicks
208
- fade = min(int(0.01 * len(tiled)), 64)
209
- if fade > 0:
210
- tiled[:fade] *= np.linspace(0, 1, fade)
211
- tiled[-fade:] *= np.linspace(1, 0, fade)
212
- return tiled.astype(np.float32)
213
-
214
- def _crossfade_fill(self, audio: np.ndarray, sr: int,
215
- start_sec: float, end_sec: float,
216
- fade_ms: float = 12.0) -> np.ndarray:
217
- """
218
- FIX BUG 1: Replace a cut region with room tone using crossfades.
219
- Hard splices cause audible clicks (1976 spikes measured vs 606 in CleanVoice).
220
- This crossfade brings spike count down to CleanVoice levels.
221
- fade_ms: length of the crossfade on each side of the cut.
222
- """
223
- fade = int(sr * fade_ms / 1000)
224
- s = int(start_sec * sr)
225
- e = int(end_sec * sr)
226
- gap = max(0, e - s)
227
- if gap == 0:
228
- return audio
229
-
230
- result = audio.copy()
231
- fill = self._fill_with_room_tone(gap + 2 * fade)
232
-
233
- # Crossfade out: speech → room tone at the cut start
234
- pre_s = max(0, s - fade)
235
- pre_len = s - pre_s
236
- if pre_len > 0:
237
- f_out = np.linspace(1.0, 0.0, pre_len)
238
- f_in = np.linspace(0.0, 1.0, pre_len)
239
- result[pre_s:s] = (audio[pre_s:s] * f_out
240
- + fill[:pre_len] * f_in)
241
-
242
- # Fill the gap with room tone
243
- result[s:e] = fill[fade:fade + gap]
244
-
245
- # Crossfade in: room tone → speech at the cut end
246
- post_e = min(len(audio), e + fade)
247
- post_len = post_e - e
248
- if post_len > 0:
249
- f_out = np.linspace(1.0, 0.0, post_len)
250
- f_in = np.linspace(0.0, 1.0, post_len)
251
- result[e:post_e] = (fill[fade + gap:fade + gap + post_len] * f_out
252
- + audio[e:post_e] * f_in)
253
-
254
- return result.astype(np.float32)
255
-
256
- # ══════════════════════════════════════════════════════════════════
257
- # BACKGROUND NOISE REMOVAL
258
- # ══════════════════════════════════════════════════════════════════
259
- # ══════════════════════════════════════════════════════════════════
260
- # HIGH-PASS FILTER — removes low-frequency hum/rumble
261
- # ══════════════════════════════════════════════════════════════════
262
- def _highpass_filter(self, audio: np.ndarray, sr: int,
263
- cutoff_hz: float = 100) -> np.ndarray:
264
- """
265
- High-pass filter to remove low-frequency noise:
266
- - Electrical hum (50/60 Hz and harmonics at 100-300 Hz)
267
- - Fan/AC motor vibration
268
- - Traffic/vehicle rumble
269
- - Phone body vibration
270
-
271
- Voice fundamentals: male=85-180Hz, female=165-255Hz
272
- Voice clarity : 300-3400Hz (completely unaffected)
273
-
274
- Using 4th order Butterworth — smooth rolloff, no ringing.
275
- cutoff_hz=100 removes hum without touching voice.
276
- """
277
- try:
278
- from scipy.signal import butter, filtfilt
279
- nyq = sr / 2.0
280
- normal = cutoff_hz / nyq
281
- # 4th order Butterworth high-pass
282
- b, a = butter(4, normal, btype='high', analog=False)
283
- filtered = filtfilt(b, a, audio).astype(np.float32)
284
- print(f"[Denoiser] ✅ High-pass filter applied ({cutoff_hz}Hz cutoff)")
285
- return filtered
286
- except Exception as e:
287
- logger.warning(f"High-pass filter failed: {e}")
288
- return audio
289
-
290
- def _remove_background_noise(self, audio, sr):
291
- # ── Priority 1: DeepFilterNet (SOTA — best quality) ─────────
292
  try:
293
- result = self._deepfilter(audio, sr)
294
- print("[Denoiser] ✅ DeepFilterNet noise removal done")
295
- return result, "DeepFilterNet"
296
- except Exception as e:
297
- logger.warning(f"[Denoiser] DeepFilterNet unavailable ({e})")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
298
 
299
- # ── Try Wiener filter (scipy — artifact-free, no compilation needed) ─
300
- try:
301
- result = self._rnnoise(audio, sr)
302
- print("[Denoiser] ✅ Wiener filter noise removal done")
303
- return result, "Wiener filter"
304
  except Exception as e:
305
- logger.warning(f"[Denoiser] Wiener filter failed ({e})")
306
-
307
- # ── Fallback: noisereduce with mild settings ──────────────────
308
- # IMPORTANT: Keep prop_decrease LOW (0.50-0.60) to avoid musical
309
- # noise artifacts. Two aggressive passes make musical noise WORSE.
310
- try:
311
  import noisereduce as nr
312
-
313
- # Single gentle pass — avoids musical noise artifacts
314
- cleaned = nr.reduce_noise(
315
- y=audio, sr=sr,
316
- stationary=False, # non-stationary handles both types
317
- prop_decrease=0.55, # gentle — avoids buzzing artifacts
318
- freq_mask_smooth_hz=1000, # heavy smoothing = less musical noise
319
- time_mask_smooth_ms=100, # heavy smoothing = less musical noise
320
- n_std_thresh_stationary=2.0, # higher = less aggressive
321
- ).astype(np.float32)
322
- print("[Denoiser] ✅ noisereduce (gentle, artifact-free) done")
323
  return cleaned, "noisereduce"
324
- except Exception as e:
325
- logger.warning(f"noisereduce failed: {e}")
326
- return audio, "none"
327
-
328
- def _rnnoise(self, audio, sr):
329
- """
330
- Wiener filter via scipy — no compilation needed, works on HF Spaces.
331
- Much cleaner than noisereduce for voice — no musical artifacts.
332
- """
333
- from scipy.signal import wiener
334
- # Wiener filter works best on short frames
335
- frame_size = int(sr * 0.02) # 20ms frames
336
- result = np.zeros_like(audio)
337
- for i in range(0, len(audio) - frame_size, frame_size):
338
- frame = audio[i:i + frame_size]
339
- result[i:i + frame_size] = wiener(frame, mysize=7)
340
- # Handle last chunk
341
- remainder = len(audio) % frame_size
342
- if remainder:
343
- result[-remainder:] = wiener(audio[-remainder:], mysize=7)
344
- return result.astype(np.float32)
345
-
346
- def _deepfilter(self, audio, sr):
347
- if not self._df_loaded:
348
- from df.enhance import enhance, init_df
349
- self._df_model, self._df_state, _ = init_df()
350
- self._df_loaded = True
351
- from df.enhance import enhance
352
- import torch
353
- df_sr = self._df_state.sr()
354
- # FIX: TARGET_SR now matches DeepFilterNet's native SR (48kHz)
355
- # so resampling is skipped in most cases
356
- a = self._resample(audio, sr, df_sr) if sr != df_sr else audio
357
- t = torch.from_numpy(a).unsqueeze(0)
358
- out = enhance(self._df_model, self._df_state, t)
359
- res = out.squeeze().numpy().astype(np.float32)
360
- return self._resample(res, df_sr, sr) if df_sr != sr else res
361
 
362
  # ══════════════════════════════════════════════════════════════════
363
- # FILLER WORD REMOVAL + ROOM TONE FILL
364
  # ══════════════════════════════════════════════════════════════════
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
365
  def _remove_fillers(self, audio, sr, segments):
366
- """
367
- Cut filler words using word-level timestamps.
368
- Fills gaps with room tone for natural sound.
369
- """
370
  try:
371
  cuts = []
372
  for seg in segments:
373
- word = seg.get('word', '').strip().lower()
374
- word = re.sub(r'[^a-z\s]', '', word).strip()
375
  if word in FILLER_WORDS:
376
  cuts.append((seg['start'], seg['end'], word))
377
 
378
- if not cuts:
379
- return audio, 0
380
 
381
- # FIX BUG 1: apply crossfade on each cut in-place (no hard splices)
382
- out = audio.copy()
383
  for start, end, word in sorted(cuts, key=lambda x: x[0]):
384
- out = self._crossfade_fill(out, sr, start, end)
385
-
386
- print(f"[Denoiser] ✅ Removed {len(cuts)} filler words: {[c[2] for c in cuts[:5]]}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
387
  return out.astype(np.float32), len(cuts)
388
  except Exception as e:
389
  logger.warning(f"Filler removal failed: {e}")
390
  return audio, 0
391
 
392
- def clean_transcript_fillers(self, transcript: str) -> str:
393
- """
394
- FIX (NEW): Also remove filler words from the transcript TEXT,
395
- so the displayed text matches the cleaned audio.
396
- """
397
- words = transcript.split()
398
- result = []
399
- i = 0
400
- while i < len(words):
401
- word = re.sub(r'[^a-z\s]', '', words[i].lower()).strip()
402
- # Check two-word fillers first ("you know", "i mean")
403
- if i + 1 < len(words):
404
- two = word + " " + re.sub(r'[^a-z\s]', '', words[i+1].lower()).strip()
405
- if two in FILLER_WORDS:
406
- i += 2
407
- continue
408
- if word in FILLER_WORDS:
409
- i += 1
410
- continue
411
- result.append(words[i])
412
- i += 1
413
- return " ".join(result)
414
-
415
- # ══════════════════════════════════════════════════════════════════
416
- # STUTTER REMOVAL — FIXED
417
- # ══════════════════════════════════════════════════════════════════
418
  def _remove_stutters(self, audio, sr, segments):
419
- """
420
- FIX: Now correctly catches triple+ repeats (I I I was → I was).
421
- Old code broke after finding one repeat and missed subsequent ones.
422
-
423
- Strategy:
424
- - Scan forward from each word
425
- - While next word == current word, mark all but last as cuts
426
- - Skip past all repeats in one go
427
- """
428
  try:
429
- if len(segments) < 2:
430
- return audio, 0
431
-
432
- cuts = []
433
- stutters_found = 0
434
- i = 0
435
 
 
436
  while i < len(segments):
437
  word = re.sub(r'[^a-z]', '', segments[i].get('word', '').strip().lower())
438
-
439
  if not word:
440
- i += 1
441
- continue
442
-
443
- # FIX: Look ahead for ALL consecutive repeats, not just one
444
  j = i + 1
445
  while j < len(segments):
446
  next_word = re.sub(r'[^a-z]', '', segments[j].get('word', '').strip().lower())
447
  if next_word == word:
448
- # Mark earlier copy as cut, keep advancing
449
  cuts.append((segments[i]['start'], segments[i]['end']))
450
  stutters_found += 1
451
- i = j # slide i forward to current repeat
452
- j += 1
453
- else:
454
- break # no more repeats — stop
455
-
456
  i += 1
457
 
458
- if not cuts:
459
- return audio, 0
460
 
461
- # FIX BUG 1: apply crossfade on each stutter cut in-place
462
- out = audio.copy()
463
  for start, end in sorted(cuts, key=lambda x: x[0]):
464
- out = self._crossfade_fill(out, sr, start, end)
465
-
466
- print(f"[Denoiser] ✅ Removed {stutters_found} stutters")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
467
  return out.astype(np.float32), stutters_found
468
  except Exception as e:
469
  logger.warning(f"Stutter removal failed: {e}")
470
  return audio, 0
471
 
 
 
 
 
 
 
 
 
 
 
 
 
 
472
  # ══════════════════════════════════════════════════════════════════
473
- # BREATH REDUCTION
474
  # ══════════════════════════════════════════════════════════════════
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
475
  def _reduce_breaths(self, audio, sr):
476
- """
477
- Breaths = short broadband bursts between speech.
478
- Non-stationary spectral gating catches them well.
479
- """
480
  try:
481
  import noisereduce as nr
482
- cleaned = nr.reduce_noise(
483
- y=audio, sr=sr,
484
- stationary=False,
485
- prop_decrease=0.90, # increased from 0.60
486
- freq_mask_smooth_hz=400,
487
- time_mask_smooth_ms=40,
488
- n_std_thresh_stationary=1.0,
489
- ).astype(np.float32)
490
- print("[Denoiser] ✅ Breath reduction done")
491
- return cleaned
492
- except Exception as e:
493
- logger.warning(f"Breath reduction failed: {e}")
494
- return audio
495
 
496
- # ══════════════════════════════════════════════════════════════════
497
- # MOUTH SOUND REDUCTION — FIXED THRESHOLD
498
- # ══════════════════════════════════════════════════════════════════
499
  def _reduce_mouth_sounds(self, audio, sr):
500
- """
501
- Mouth clicks/pops = very short, very high amplitude transients.
502
- FIX: Threshold raised from 4.5→6.0 std to avoid removing
503
- real consonants like p, b, t which have similar transient energy.
504
- """
505
  try:
506
- result = audio.copy()
507
- win = int(sr * 0.003) # 3ms window
508
- hop = win // 2
509
- rms_arr = []
510
-
511
- for i in range(0, len(audio) - win, hop):
512
- rms_arr.append(float(np.sqrt(np.mean(audio[i:i+win]**2))))
513
-
514
- if not rms_arr:
515
- return audio, 0
516
-
517
- rms_arr = np.array(rms_arr)
518
- mean_rms = float(np.mean(rms_arr))
519
- std_rms = float(np.std(rms_arr))
520
- # FIX: was 4.5 — too sensitive, removed real speech consonants
521
- threshold = mean_rms + 6.0 * std_rms
522
- n_removed = 0
523
-
524
  for idx, rms in enumerate(rms_arr):
525
  if rms > threshold:
526
- start = idx * hop
527
- end = min(start + win, len(result))
528
- fade = np.linspace(1, 0, end - start)
529
- result[start:end] *= fade
530
  n_removed += 1
531
-
532
- if n_removed:
533
- print(f"[Denoiser] ✅ Suppressed {n_removed} mouth sound transients")
534
  return result.astype(np.float32), n_removed
535
- except Exception as e:
536
- logger.warning(f"Mouth sound reduction failed: {e}")
537
- return audio, 0
538
 
539
- # ══════════════════════════════════════════════════════════════════
540
- # LONG SILENCE REMOVAL
541
- # ══════════════════════════════════════════════════════════════════
542
- def _remove_long_silences(self, audio, sr,
543
- max_silence_sec=0.8,
544
- keep_pause_sec=0.25):
545
- """
546
- Shorten silences longer than max_silence_sec.
547
- Keeps keep_pause_sec worth of silence for natural pacing.
548
- """
549
  try:
550
- frame_len = int(sr * 0.02)
551
- max_sil_frames = int(max_silence_sec / 0.02)
552
- keep_frames = int(keep_pause_sec / 0.02)
553
- threshold = 0.008
554
-
555
- kept = []
556
- silence_count = 0
557
- total_removed = 0
558
- in_long_sil = False
559
-
560
  for i in range(0, len(audio) - frame_len, frame_len):
561
  frame = audio[i:i + frame_len]
562
- rms = float(np.sqrt(np.mean(frame**2)))
563
-
564
- if rms < threshold:
565
  silence_count += 1
566
- if silence_count <= max_sil_frames:
567
- kept.append(frame)
568
- else:
569
- total_removed += frame_len
570
- in_long_sil = True
571
  else:
572
  if in_long_sil:
573
- pad = self._fill_with_room_tone(keep_frames * frame_len)
574
- kept.append(pad)
575
  in_long_sil = False
576
  silence_count = 0
577
  kept.append(frame)
 
 
578
 
579
- result = np.concatenate(kept) if kept else audio
580
- removed_sec = total_removed / sr
581
- if removed_sec > 0:
582
- print(f"[Denoiser] ✅ Removed {removed_sec:.1f}s of long silences")
583
- return result.astype(np.float32), removed_sec
584
- except Exception as e:
585
- logger.warning(f"Silence removal failed: {e}")
586
- return audio, 0.0
587
-
588
- def _voice_eq(self, audio: np.ndarray, sr: int) -> np.ndarray:
589
- """
590
- FIX BUG 2: Voice warmth EQ to match CleanVoice output profile.
591
- Measured gap: CleanVoice bass=0.1835, ClearWave bass=0.0634 (2.9x less).
592
- - Bass shelf +3dB at 200Hz — adds warmth stripped by highpass
593
- - High shelf -4dB at 7kHz — removes harshness
594
- """
595
- try:
596
- from scipy.signal import butter, sosfilt
597
- # Bass warmth boost: low shelf ~200Hz
598
- sos_bass = butter(2, 200.0 / (sr / 2.0), btype='low', output='sos')
599
- bass = sosfilt(sos_bass, audio)
600
- audio = (audio + bass * 0.40).astype(np.float32) # +~3dB at bass
601
-
602
- # High shelf cut: reduce harshness above 7kHz
603
- sos_hf = butter(2, 7000.0 / (sr / 2.0), btype='high', output='sos')
604
- hf = sosfilt(sos_hf, audio)
605
- audio = (audio - hf * 0.60).astype(np.float32) # -~4dB above 7kHz
606
-
607
- print("[Denoiser] ✅ Voice EQ applied (bass warmth + HF softening)")
608
- return audio
609
- except Exception as e:
610
- logger.warning(f"Voice EQ failed: {e}")
611
- return audio
612
-
613
- # ══════════════════════════════════════════════════════════════════
614
- # NORMALIZATION — FIXED RMS FALLBACK + PEAK LIMITER (BUG 4)
615
- # ══════════════════════════════════════════════════════════════════
616
  def _normalise(self, audio, sr):
617
  try:
618
  import pyloudnorm as pyln
619
- meter = pyln.Meter(sr)
620
- loudness = meter.integrated_loudness(audio)
621
  if np.isfinite(loudness) and loudness < 0:
622
  audio = pyln.normalize.loudness(audio, loudness, TARGET_LOUDNESS)
623
- print(f"[Denoiser] ✅ Normalized: {loudness:.1f} → {TARGET_LOUDNESS} LUFS")
624
- except Exception:
625
- # FIX: Corrected RMS fallback formula
626
  rms = np.sqrt(np.mean(audio**2))
627
- if rms > 1e-9:
628
- target_rms = 10 ** (TARGET_LOUDNESS / 20.0)
629
- audio = audio * (target_rms / rms)
630
-
631
- # FIX BUG 4: Hard peak limiter at -1.5 dBFS (CleanVoice target: -2.1 dBFS)
632
- # Measured ClearWave peak: -0.1 dBFS (dangerously hot, near-clipping)
633
- PEAK_CEILING = 0.84 # ≈ -1.5 dBFS
634
- peak = np.max(np.abs(audio))
635
- if peak > PEAK_CEILING:
636
- audio = audio * (PEAK_CEILING / peak)
637
- print(f"[Denoiser] ✅ Peak limited: {20*np.log10(peak):.1f} → {20*np.log10(PEAK_CEILING):.1f} dBFS")
638
-
639
  return np.clip(audio, -0.99, 0.99).astype(np.float32)
640
 
641
- # ══════════════════════════════════════════════════════════════════
642
- # HELPERS
643
- # ══════════════════════════════════════════════════════════════════
644
  def _to_wav(self, src, dst, target_sr):
645
- result = subprocess.run([
646
- "ffmpeg", "-y", "-i", src,
647
- "-acodec", "pcm_s24le", "-ar", str(target_sr), dst
648
- ], capture_output=True)
649
- if result.returncode != 0:
650
  data, sr = sf.read(src, always_2d=True)
651
- sf.write(dst, data, sr, subtype="PCM_24")
652
-
653
- def _resample(self, audio, orig_sr, target_sr):
654
- try:
655
- import librosa
656
- return librosa.resample(audio, orig_sr=orig_sr, target_sr=target_sr)
657
- except Exception:
658
- length = int(len(audio) * target_sr / orig_sr)
659
- return np.interp(
660
- np.linspace(0, len(audio), length),
661
- np.arange(len(audio)), audio
662
- ).astype(np.float32)
 
1
  """
2
+ Department 1 — Professional Audio Enhancer (Commercial Grade)
3
+ UPGRADES APPLIED:
4
+ 1. True Voice Separation: HTDemucs v4 extracts pure vocals, deleting background noise.
5
+ 2. Invisible Edits: Zero-crossing detection + 10ms crossfades prevent digital clicks.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6
  """
7
 
8
  import os
 
16
 
17
  logger = logging.getLogger(__name__)
18
 
19
+ TARGET_SR = 48000
 
 
 
 
20
  TARGET_LOUDNESS = -18.0
21
 
 
22
  FILLER_WORDS = {
23
  "um", "umm", "ummm", "uh", "uhh", "uhhh",
24
+ "hmm", "hm", "hmm", "hmmm", "er", "err", "errr",
25
+ "eh", "ahh", "ah", "like", "basically", "literally",
26
+ "you know", "i mean", "so", "right", "okay", "ok",
27
+ "ante", "ane", "mane", "arey", "enti",
28
+ "matlab", "yani", "bas", "acha",
 
 
 
 
 
 
29
  }
30
 
 
31
  class Denoiser:
32
  def __init__(self):
33
+ self._room_tone = None
34
+ print("[Denoiser] ✅ Commercial Audio Enhancer ready")
 
 
 
35
 
 
 
 
36
  def process(self, audio_path: str, out_dir: str,
37
+ remove_fillers: bool = True, remove_silences: bool = True,
38
+ remove_breaths: bool = True, remove_mouth_sounds: bool = True,
39
+ remove_stutters: bool = True, word_segments: list = None) -> dict:
40
+
 
 
 
 
 
 
 
 
41
  t0 = time.time()
42
  stats = {}
43
+ print("[Denoiser] ▶ Starting commercial enhancement pipeline...")
44
 
 
45
  wav_in = os.path.join(out_dir, "stage0_input.wav")
46
  self._to_wav(audio_path, wav_in, TARGET_SR)
47
  audio, sr = sf.read(wav_in, always_2d=True)
48
  n_ch = audio.shape[1]
49
+
 
 
 
50
  mono = audio.mean(axis=1).astype(np.float32)
 
 
51
  self._room_tone = self._capture_room_tone(mono, sr)
52
 
53
+ # ── UPGRADE 1: True Source Separation (HTDemucs) ──
54
+ mono, noise_method = self._demucs_separation(mono, sr)
 
 
 
 
 
 
 
 
 
 
 
 
 
55
  stats['noise_method'] = noise_method
56
 
 
57
  if remove_mouth_sounds:
58
+ mono, stats['mouth_sounds_removed'] = self._reduce_mouth_sounds(mono, sr)
 
 
 
59
  if remove_breaths:
60
  mono = self._reduce_breaths(mono, sr)
61
  stats['breaths_reduced'] = True
62
 
 
63
  stats['fillers_removed'] = 0
64
  if remove_fillers and word_segments:
65
+ mono, stats['fillers_removed'] = self._remove_fillers(mono, sr, word_segments)
 
66
 
 
67
  stats['stutters_removed'] = 0
68
  if remove_stutters and word_segments:
69
+ mono, stats['stutters_removed'] = self._remove_stutters(mono, sr, word_segments)
 
70
 
 
71
  stats['silences_removed_sec'] = 0.0
72
  if remove_silences:
73
+ mono, stats['silences_removed_sec'] = self._remove_long_silences(mono, sr)
 
74
 
 
 
 
 
75
  mono = self._normalise(mono, sr)
76
 
 
77
  out_audio = np.stack([mono, mono], axis=1) if n_ch == 2 else mono
78
  out_path = os.path.join(out_dir, "denoised.wav")
79
  sf.write(out_path, out_audio, sr, subtype="PCM_24")
80
 
81
  stats['processing_sec'] = round(time.time() - t0, 2)
82
+ print(f"[Denoiser] ✅ Done in {stats['processing_sec']}s")
83
  return {'audio_path': out_path, 'stats': stats}
84
 
85
  # ══════════════════════════════════════════════════════════════════
86
+ # UPGRADE 1: TRUE SOURCE SEPARATION (HTDEMUCS)
87
  # ══════════════════════════════════════════════════════════════════
88
+ def _demucs_separation(self, audio, sr):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
89
  try:
90
+ import torch
91
+ from demucs.pretrained import get_model
92
+ from demucs.apply import apply_model
93
+ from demucs.audio import convert_audio
94
+
95
+ if not hasattr(self, '_demucs_model'):
96
+ print("[Denoiser] Downloading/Loading HTDemucs v4 (this takes a moment on first run)...")
97
+ self._demucs_model = get_model('htdemucs')
98
+ self._demucs_model.eval()
99
+
100
+ wav = torch.from_numpy(audio).float()
101
+ if wav.ndim == 1:
102
+ wav = wav.unsqueeze(0).repeat(2, 1)
103
+ wav = wav.unsqueeze(0)
104
+
105
+ model_sr = self._demucs_model.samplerate
106
+ wav = convert_audio(wav, sr, model_sr, self._demucs_model.audio_channels)
107
+
108
+ with torch.no_grad():
109
+ # device='cpu' ensures stability without triggering out-of-memory errors
110
+ sources = apply_model(self._demucs_model, wav, device='cpu', shifts=1, split=True, overlap=0.25)
111
+
112
+ # HTDemucs sources: ['drums', 'bass', 'other', 'vocals']. Index 3 is pure vocals.
113
+ vocals = sources[0, 3].mean(dim=0).numpy()
114
 
115
+ import librosa
116
+ final_audio = librosa.resample(vocals, orig_sr=model_sr, target_sr=sr).astype(np.float32)
117
+ print("[Denoiser] ✅ HTDemucs Voice Isolation complete")
118
+ return final_audio, "HTDemucs v4 (Voice Isolation)"
119
+
120
  except Exception as e:
121
+ logger.warning(f"HTDemucs failed ({e}), falling back to noisereduce")
 
 
 
 
 
122
  import noisereduce as nr
123
+ cleaned = nr.reduce_noise(y=audio, sr=sr, stationary=False, prop_decrease=0.98).astype(np.float32)
 
 
 
 
 
 
 
 
 
 
124
  return cleaned, "noisereduce"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
125
 
126
  # ══════════════════════════════════════════════════════════════════
127
+ # UPGRADE 3: INVISIBLE EDITS (ZERO-CROSSING & CROSSFADING)
128
  # ══════════════════════════════════════════════════════════════════
129
+ def _get_zero_crossing(self, audio, idx, window=200):
130
+ """Finds the nearest point where the waveform hits zero amplitude."""
131
+ idx = max(1, min(len(audio)-1, int(idx)))
132
+ for i in range(window):
133
+ if idx+i < len(audio) and audio[idx+i] * audio[idx+i-1] <= 0: return idx+i
134
+ if idx-i > 0 and audio[idx-i] * audio[idx-i-1] <= 0: return idx-i
135
+ return idx
136
+
137
+ def _crossfade(self, a1, a2, sr, fade_ms=10):
138
+ """Seamlessly blends two audio arrays together to prevent digital clicks."""
139
+ fade_len = int(sr * (fade_ms / 1000.0))
140
+ if len(a1) < fade_len or len(a2) < fade_len:
141
+ return np.concatenate([a1, a2])
142
+
143
+ out = np.zeros(len(a1) + len(a2) - fade_len, dtype=np.float32)
144
+ out[:len(a1)-fade_len] = a1[:-fade_len]
145
+
146
+ fade_out = a1[-fade_len:] * np.linspace(1, 0, fade_len)
147
+ fade_in = a2[:fade_len] * np.linspace(0, 1, fade_len)
148
+ out[len(a1)-fade_len:len(a1)] = fade_out + fade_in
149
+ out[len(a1):] = a2[fade_len:]
150
+ return out
151
+
152
  def _remove_fillers(self, audio, sr, segments):
 
 
 
 
153
  try:
154
  cuts = []
155
  for seg in segments:
156
+ word = re.sub(r'[^a-z\s]', '', seg.get('word', '').strip().lower())
 
157
  if word in FILLER_WORDS:
158
  cuts.append((seg['start'], seg['end'], word))
159
 
160
+ if not cuts: return audio, 0
 
161
 
162
+ result = []
163
+ prev = 0.0
164
  for start, end, word in sorted(cuts, key=lambda x: x[0]):
165
+ keep_sta = self._get_zero_crossing(audio, int(prev * sr))
166
+ keep_end = self._get_zero_crossing(audio, int(start * sr))
167
+
168
+ if keep_sta < keep_end:
169
+ result.append(audio[keep_sta:keep_end])
170
+
171
+ gap_len = int((end - start) * sr)
172
+ if gap_len > 0:
173
+ result.append(self._fill_with_room_tone(gap_len))
174
+ prev = end
175
+
176
+ remain_start = self._get_zero_crossing(audio, int(prev * sr))
177
+ if remain_start < len(audio):
178
+ result.append(audio[remain_start:])
179
+
180
+ # Apply crossfading to all segments instead of blunt concatenation
181
+ out = result[0]
182
+ for i in range(1, len(result)):
183
+ out = self._crossfade(out, result[i], sr)
184
+
185
+ print(f"[Denoiser] ✅ Removed {len(cuts)} fillers with invisible edits")
186
  return out.astype(np.float32), len(cuts)
187
  except Exception as e:
188
  logger.warning(f"Filler removal failed: {e}")
189
  return audio, 0
190
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
191
  def _remove_stutters(self, audio, sr, segments):
 
 
 
 
 
 
 
 
 
192
  try:
193
+ if len(segments) < 2: return audio, 0
 
 
 
 
 
194
 
195
+ cuts, stutters_found, i = [], 0, 0
196
  while i < len(segments):
197
  word = re.sub(r'[^a-z]', '', segments[i].get('word', '').strip().lower())
 
198
  if not word:
199
+ i += 1; continue
 
 
 
200
  j = i + 1
201
  while j < len(segments):
202
  next_word = re.sub(r'[^a-z]', '', segments[j].get('word', '').strip().lower())
203
  if next_word == word:
 
204
  cuts.append((segments[i]['start'], segments[i]['end']))
205
  stutters_found += 1
206
+ i, j = j, j + 1
207
+ else: break
 
 
 
208
  i += 1
209
 
210
+ if not cuts: return audio, 0
 
211
 
212
+ result = []
213
+ prev = 0.0
214
  for start, end in sorted(cuts, key=lambda x: x[0]):
215
+ keep_sta = self._get_zero_crossing(audio, int(prev * sr))
216
+ keep_end = self._get_zero_crossing(audio, int(start * sr))
217
+
218
+ if keep_sta < keep_end:
219
+ result.append(audio[keep_sta:keep_end])
220
+
221
+ gap_len = int((end - start) * sr)
222
+ if gap_len > 0:
223
+ result.append(self._fill_with_room_tone(gap_len))
224
+ prev = end
225
+
226
+ remain = self._get_zero_crossing(audio, int(prev * sr))
227
+ if remain < len(audio):
228
+ result.append(audio[remain:])
229
+
230
+ out = result[0]
231
+ for i in range(1, len(result)):
232
+ out = self._crossfade(out, result[i], sr)
233
+
234
+ print(f"[Denoiser] ✅ Removed {stutters_found} stutters with invisible edits")
235
  return out.astype(np.float32), stutters_found
236
  except Exception as e:
237
  logger.warning(f"Stutter removal failed: {e}")
238
  return audio, 0
239
 
240
+ def clean_transcript_fillers(self, transcript: str) -> str:
241
+ words, result, i = transcript.split(), [], 0
242
+ while i < len(words):
243
+ word = re.sub(r'[^a-z\s]', '', words[i].lower()).strip()
244
+ if i + 1 < len(words):
245
+ if word + " " + re.sub(r'[^a-z\s]', '', words[i+1].lower()).strip() in FILLER_WORDS:
246
+ i += 2; continue
247
+ if word in FILLER_WORDS:
248
+ i += 1; continue
249
+ result.append(words[i])
250
+ i += 1
251
+ return " ".join(result)
252
+
253
  # ══════════════════════════════════════════════════════════════════
254
+ # UTILITIES (Room Tone, Breaths, Clicks, Loudness)
255
  # ══════════════════════════════════════════════════════════════════
256
+ def _capture_room_tone(self, audio: np.ndarray, sr: int, sample_sec: float = 0.5) -> np.ndarray:
257
+ frame = int(sr * sample_sec)
258
+ if len(audio) < frame * 2: return audio[:min(int(sr * 0.1), len(audio))].copy()
259
+ best_rms, best_start = float('inf'), 0
260
+ for i in range(0, len(audio) - frame, sr):
261
+ rms = float(np.sqrt(np.mean(audio[i:i + frame] ** 2)))
262
+ if rms < best_rms: best_rms, best_start = rms, i
263
+ return audio[best_start: best_start + frame].copy()
264
+
265
+ def _fill_with_room_tone(self, length: int) -> np.ndarray:
266
+ if self._room_tone is None or len(self._room_tone) == 0: return np.zeros(length, dtype=np.float32)
267
+ tiled = np.tile(self._room_tone, length // len(self._room_tone) + 1)[:length]
268
+ fade = min(int(0.01 * len(tiled)), 64)
269
+ if fade > 0:
270
+ tiled[:fade] *= np.linspace(0, 1, fade)
271
+ tiled[-fade:] *= np.linspace(1, 0, fade)
272
+ return tiled.astype(np.float32)
273
+
274
  def _reduce_breaths(self, audio, sr):
 
 
 
 
275
  try:
276
  import noisereduce as nr
277
+ return nr.reduce_noise(y=audio, sr=sr, stationary=False, prop_decrease=0.90, n_std_thresh_stationary=1.0).astype(np.float32)
278
+ except: return audio
 
 
 
 
 
 
 
 
 
 
 
279
 
 
 
 
280
  def _reduce_mouth_sounds(self, audio, sr):
 
 
 
 
 
281
  try:
282
+ result, win, n_removed = audio.copy(), int(sr * 0.003), 0
283
+ hop = win // 2
284
+ rms_arr = [float(np.sqrt(np.mean(audio[i:i+win]**2))) for i in range(0, len(audio) - win, hop)]
285
+ if not rms_arr: return audio, 0
286
+ threshold = float(np.mean(rms_arr)) + 6.0 * float(np.std(rms_arr))
 
 
 
 
 
 
 
 
 
 
 
 
 
287
  for idx, rms in enumerate(rms_arr):
288
  if rms > threshold:
289
+ start, end = idx * hop, min((idx * hop) + win, len(result))
290
+ result[start:end] *= np.linspace(1, 0, end - start)
 
 
291
  n_removed += 1
 
 
 
292
  return result.astype(np.float32), n_removed
293
+ except: return audio, 0
 
 
294
 
295
+ def _remove_long_silences(self, audio, sr, max_silence_sec=1.5, keep_pause_sec=0.4):
 
 
 
 
 
 
 
 
 
296
  try:
297
+ frame_len, kept, silence_count, total_removed, in_long_sil = int(sr * 0.02), [], 0, 0, False
298
+ max_sil_frames, keep_frames = int(max_silence_sec / 0.02), int(keep_pause_sec / 0.02)
299
+
 
 
 
 
 
 
 
300
  for i in range(0, len(audio) - frame_len, frame_len):
301
  frame = audio[i:i + frame_len]
302
+ if float(np.sqrt(np.mean(frame**2))) < 0.008:
 
 
303
  silence_count += 1
304
+ if silence_count <= max_sil_frames: kept.append(frame)
305
+ else: total_removed += frame_len; in_long_sil = True
 
 
 
306
  else:
307
  if in_long_sil:
308
+ kept.append(self._fill_with_room_tone(keep_frames * frame_len))
 
309
  in_long_sil = False
310
  silence_count = 0
311
  kept.append(frame)
312
+ return (np.concatenate(kept) if kept else audio).astype(np.float32), total_removed / sr
313
+ except: return audio, 0.0
314
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
315
  def _normalise(self, audio, sr):
316
  try:
317
  import pyloudnorm as pyln
318
+ loudness = pyln.Meter(sr).integrated_loudness(audio)
 
319
  if np.isfinite(loudness) and loudness < 0:
320
  audio = pyln.normalize.loudness(audio, loudness, TARGET_LOUDNESS)
321
+ except:
 
 
322
  rms = np.sqrt(np.mean(audio**2))
323
+ if rms > 1e-9: audio = audio * (10 ** (TARGET_LOUDNESS / 20.0) / rms)
 
 
 
 
 
 
 
 
 
 
 
324
  return np.clip(audio, -0.99, 0.99).astype(np.float32)
325
 
 
 
 
326
  def _to_wav(self, src, dst, target_sr):
327
+ if subprocess.run(["ffmpeg", "-y", "-i", src, "-acodec", "pcm_s24le", "-ar", str(target_sr), dst], capture_output=True).returncode != 0:
 
 
 
 
328
  data, sr = sf.read(src, always_2d=True)
329
+ sf.write(dst, data, sr, subtype="PCM_24")