Spaces:
Paused
Paused
Update denoiser.py
Browse files- denoiser.py +192 -525
denoiser.py
CHANGED
|
@@ -1,28 +1,8 @@
|
|
| 1 |
"""
|
| 2 |
-
Department 1 — Professional Audio Enhancer
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 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 |
-
|
| 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 |
-
"
|
| 51 |
-
"
|
| 52 |
-
"
|
| 53 |
-
"
|
| 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.
|
| 66 |
-
|
| 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
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 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
|
| 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 |
-
|
| 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 |
-
# ──
|
| 106 |
-
|
| 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,
|
| 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,
|
| 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,
|
| 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,
|
| 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
|
| 163 |
return {'audio_path': out_path, 'stats': stats}
|
| 164 |
|
| 165 |
# ══════════════════════════════════════════════════════════════════
|
| 166 |
-
#
|
| 167 |
# ══════════════════════════════════════════════════════════════════
|
| 168 |
-
def
|
| 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 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 298 |
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
except Exception as e:
|
| 305 |
-
logger.warning(f"
|
| 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 |
-
#
|
| 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 |
-
|
| 382 |
-
|
| 383 |
for start, end, word in sorted(cuts, key=lambda x: x[0]):
|
| 384 |
-
|
| 385 |
-
|
| 386 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 452 |
-
|
| 453 |
-
else:
|
| 454 |
-
break # no more repeats — stop
|
| 455 |
-
|
| 456 |
i += 1
|
| 457 |
|
| 458 |
-
if not cuts:
|
| 459 |
-
return audio, 0
|
| 460 |
|
| 461 |
-
|
| 462 |
-
|
| 463 |
for start, end in sorted(cuts, key=lambda x: x[0]):
|
| 464 |
-
|
| 465 |
-
|
| 466 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
#
|
| 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 |
-
|
| 483 |
-
|
| 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
|
| 507 |
-
|
| 508 |
-
|
| 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
|
| 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
|
| 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
|
| 551 |
-
max_sil_frames = int(max_silence_sec / 0.02)
|
| 552 |
-
|
| 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 |
-
|
| 563 |
-
|
| 564 |
-
if rms < threshold:
|
| 565 |
silence_count += 1
|
| 566 |
-
if silence_count <= max_sil_frames:
|
| 567 |
-
|
| 568 |
-
else:
|
| 569 |
-
total_removed += frame_len
|
| 570 |
-
in_long_sil = True
|
| 571 |
else:
|
| 572 |
if in_long_sil:
|
| 573 |
-
|
| 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 |
-
|
| 620 |
-
loudness = meter.integrated_loudness(audio)
|
| 621 |
if np.isfinite(loudness) and loudness < 0:
|
| 622 |
audio = pyln.normalize.loudness(audio, loudness, TARGET_LOUDNESS)
|
| 623 |
-
|
| 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 |
-
|
| 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")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|