Created
June 18, 2026 00:23
-
-
Save chigkim/bebc04d75e1ec6ea49f3be3d0f2ab6c3 to your computer and use it in GitHub Desktop.
Whisperize
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| # | |
| # /// script | |
| # requires-python = ">=3.13" | |
| # dependencies = [ | |
| # "librosa>=0.11.0", | |
| # "numpy>=2.4.6", | |
| # "scipy>=1.17.1", | |
| # "soundfile>=0.14.0", | |
| # ] | |
| # /// | |
| """Create a synthetic whisper-like version of speech using LPC/noise excitation. | |
| This is DSP augmentation, not a neural voice conversion model. It removes the | |
| periodic voiced excitation and re-synthesizes each frame's spectral envelope with | |
| noise, then high-passes the result to reduce pitch/rumble. | |
| """ | |
| import argparse | |
| import os | |
| import librosa | |
| import numpy as np | |
| import soundfile as sf | |
| from scipy.linalg import solve_toeplitz | |
| from scipy.signal import butter, lfilter, welch | |
| def highpass(x, sr, cutoff): | |
| if cutoff <= 0: | |
| return x | |
| b, a = butter(2, cutoff / (sr * 0.5), btype="highpass") | |
| return lfilter(b, a, x).astype(np.float32) | |
| def lowpass(x, sr, cutoff): | |
| if cutoff <= 0 or cutoff >= sr * 0.5: | |
| return x | |
| b, a = butter(2, cutoff / (sr * 0.5), btype="lowpass") | |
| return lfilter(b, a, x).astype(np.float32) | |
| def bandpass(x, sr, low, high): | |
| nyq = sr * 0.5 | |
| low = max(20.0, min(float(low), nyq - 10.0)) | |
| high = max(low + 10.0, min(float(high), nyq - 1.0)) | |
| b, a = butter(2, [low / nyq, high / nyq], btype="bandpass") | |
| return lfilter(b, a, x).astype(np.float32) | |
| def match_excitation_spectrum(exc, target_mag, amount=1.0): | |
| """Blend excitation magnitude spectrum toward a target average magnitude.""" | |
| amount = float(np.clip(amount, 0.0, 1.0)) | |
| x = np.asarray(exc, dtype=np.float32) | |
| if amount <= 0: | |
| return x | |
| spec = np.fft.rfft(x) | |
| phase = np.exp(1j * np.angle(spec)) | |
| src_mag = np.maximum(np.abs(spec).astype(np.float32), 1e-8) | |
| target = np.asarray(target_mag, dtype=np.float32) | |
| if len(target) != len(spec): | |
| src = np.linspace(0.0, 1.0, len(target)) | |
| dst = np.linspace(0.0, 1.0, len(spec)) | |
| target = np.interp(dst, src, target).astype(np.float32) | |
| target = np.maximum(target, 1e-8) | |
| # Geometric blend keeps the base noise color when amount < 1, and exact | |
| # speech-spectrum matching when amount = 1. | |
| mag = np.exp((1.0 - amount) * np.log(src_mag) + amount * np.log(target)) | |
| y = np.fft.irfft(mag * phase, n=len(x)).astype(np.float32) | |
| y -= np.mean(y) | |
| y /= np.sqrt(np.mean(y ** 2) + 1e-10) | |
| return y.astype(np.float32) | |
| def color_excitation(exc, sr, color="white", lp_hz=0.0): | |
| """Color the LPC noise excitation before it is filtered by the vocal tract.""" | |
| color = (color or "white").lower() | |
| x = np.asarray(exc, dtype=np.float32) | |
| if color in {"pink", "warm"}: | |
| spec = np.fft.rfft(x) | |
| freqs = np.fft.rfftfreq(len(x), d=1.0 / sr) | |
| weights = np.ones_like(freqs) | |
| weights[1:] = 1.0 / np.sqrt(freqs[1:]) | |
| spec *= weights | |
| x = np.fft.irfft(spec, n=len(x)).astype(np.float32) | |
| elif color in {"brown", "dark"}: | |
| spec = np.fft.rfft(x) | |
| freqs = np.fft.rfftfreq(len(x), d=1.0 / sr) | |
| weights = np.ones_like(freqs) | |
| weights[1:] = 1.0 / np.maximum(freqs[1:], 1.0) | |
| spec *= weights | |
| x = np.fft.irfft(spec, n=len(x)).astype(np.float32) | |
| elif color == "soft": | |
| # Gentle broad tilt, not a hard low-pass. Explicit --noise-lp is still | |
| # required if the user wants an actual low-pass cutoff. | |
| spec = np.fft.rfft(x) | |
| freqs = np.fft.rfftfreq(len(x), d=1.0 / sr) | |
| weights = 1.0 / np.sqrt(1.0 + (freqs / 8000.0) ** 2) | |
| spec *= weights | |
| x = np.fft.irfft(spec, n=len(x)).astype(np.float32) | |
| elif color == "air": | |
| # Slightly emphasize upper breath but keep it less white-noise flat. | |
| spec = np.fft.rfft(x) | |
| freqs = np.fft.rfftfreq(len(x), d=1.0 / sr) | |
| weights = 0.7 + 0.3 * np.sqrt(np.maximum(freqs, 1.0) / max(sr * 0.5, 1.0)) | |
| spec *= weights | |
| x = np.fft.irfft(spec, n=len(x)).astype(np.float32) | |
| if lp_hz > 0: | |
| x = lowpass(x, sr, lp_hz) | |
| x -= np.mean(x) | |
| x /= np.sqrt(np.mean(x ** 2) + 1e-10) | |
| return x.astype(np.float32) | |
| def octave_to_q(octaves): | |
| """Convert bandwidth in octaves to RBJ peaking-EQ Q.""" | |
| octaves = max(float(octaves), 1e-3) | |
| return 1.0 / (2.0 * np.sinh(np.log(2.0) * octaves / 2.0)) | |
| def peaking_eq(x, sr, freq, gain_db=-4.0, q=2.0): | |
| """RBJ peaking EQ. Negative gain_db makes a narrow dip.""" | |
| if freq <= 0 or freq >= sr * 0.5: | |
| return x.astype(np.float32) | |
| a = 10.0 ** (gain_db / 40.0) | |
| w0 = 2.0 * np.pi * freq / sr | |
| alpha = np.sin(w0) / (2.0 * q) | |
| cos_w0 = np.cos(w0) | |
| b0 = 1.0 + alpha * a | |
| b1 = -2.0 * cos_w0 | |
| b2 = 1.0 - alpha * a | |
| a0 = 1.0 + alpha / a | |
| a1 = -2.0 * cos_w0 | |
| a2 = 1.0 - alpha / a | |
| b = np.array([b0, b1, b2], dtype=np.float64) / a0 | |
| aa = np.array([1.0, a1 / a0, a2 / a0], dtype=np.float64) | |
| return lfilter(b, aa, x).astype(np.float32) | |
| def detect_harsh_frequency(x, sr): | |
| """Automatically find the harshest narrow spectral excess. | |
| This does not use a user-specified de-essing range. It scans most of the | |
| audible speech band above 1 kHz, compares each bin to its local spectral | |
| neighborhood, and weights regions the ear tends to perceive as harsh | |
| without forcing the result into a fixed sibilance band. | |
| """ | |
| nyq = sr * 0.5 | |
| low = 1000.0 | |
| high = min(12000.0, nyq - 1.0) | |
| if high <= low: | |
| return min(3000.0, nyq * 0.8) | |
| nperseg = min(8192, max(1024, len(x) // 4)) | |
| freqs, power = welch(x, fs=sr, nperseg=nperseg) | |
| mask = (freqs >= low) & (freqs <= high) | |
| if not np.any(mask): | |
| return min(3000.0, nyq * 0.8) | |
| f = freqs[mask] | |
| p_db = 10.0 * np.log10(power[mask] + 1e-20) | |
| # Local average with reflected padding avoids falsely picking range edges. | |
| kernel = max(9, int(round(len(p_db) * 0.08)) | 1) | |
| pad = kernel // 2 | |
| padded = np.pad(p_db, pad, mode="reflect") | |
| local_db = np.convolve(padded, np.ones(kernel) / kernel, mode="valid") | |
| excess_db = p_db - local_db | |
| # Psychoacoustic harshness preference: broad, not a hard range. This favors | |
| # upper mids / lower treble but can still choose elsewhere if the spike is | |
| # truly dominant. | |
| harsh_weight = ( | |
| 0.35 | |
| + 0.45 * np.exp(-0.5 * ((f - 3500.0) / 1800.0) ** 2) | |
| + 0.35 * np.exp(-0.5 * ((f - 7000.0) / 3000.0) ** 2) | |
| ) | |
| # Ignore broad tilt; choose narrow peaks that stick out locally. | |
| score = np.maximum(excess_db, 0.0) * harsh_weight | |
| if np.max(score) <= 0: | |
| return float(f[int(np.argmax(p_db * harsh_weight))]) | |
| return float(f[int(np.argmax(score))]) | |
| def smooth_control(control, sr, attack_ms=3.0, release_ms=80.0): | |
| """Attack/release smoothing for dynamic EQ gain control.""" | |
| attack = np.exp(-1.0 / max(1.0, sr * attack_ms / 1000.0)) | |
| release = np.exp(-1.0 / max(1.0, sr * release_ms / 1000.0)) | |
| out = np.zeros_like(control, dtype=np.float32) | |
| y = 0.0 | |
| for i, x in enumerate(control): | |
| coeff = attack if x > y else release | |
| y = coeff * y + (1.0 - coeff) * x | |
| out[i] = y | |
| return out | |
| def dynamic_eq_band( | |
| y, | |
| sr, | |
| band_low, | |
| band_high, | |
| amount, | |
| dynamic_depth_db, | |
| threshold_percentile, | |
| attack_ms=3.0, | |
| release_ms=80.0, | |
| ): | |
| """Dynamically attenuate one band when its own envelope flares up.""" | |
| band = bandpass(y, sr, band_low, band_high) | |
| env = lowpass(np.abs(band), sr, 45.0) | |
| threshold = np.percentile(env, threshold_percentile) | |
| # Soft-knee-ish detector: 0 below threshold, approaches 1 as band dominates. | |
| over = np.maximum(env - threshold, 0.0) / (threshold + 1e-8) | |
| control = np.clip(over / (1.0 + over), 0.0, 1.0).astype(np.float32) | |
| control = smooth_control(control, sr, attack_ms=attack_ms, release_ms=release_ms) | |
| attenuation_db = dynamic_depth_db * amount * control | |
| keep = 10.0 ** (-attenuation_db / 20.0) | |
| reduction = 1.0 - keep | |
| return reduction.astype(np.float32) * band | |
| def deess( | |
| x, | |
| sr, | |
| amount=0.0, | |
| freq=0.0, | |
| q=2.5, | |
| static_dip_db=0.0, | |
| dynamic_depth_db=12.0, | |
| threshold_percentile=70.0, | |
| bands=10, | |
| low=1500.0, | |
| high=6500.0, | |
| attack_ms=3.0, | |
| release_ms=80.0, | |
| max_reduction_rms=0.45, | |
| ): | |
| """Dynamic EQ de-esser. | |
| By default, this is a multi-band dynamic EQ: it splits the region between | |
| low/high into bands and each band ducks independently when that band's | |
| envelope flares up. If freq > 0, it falls back to a single dynamic band | |
| centered on that frequency. | |
| """ | |
| amount = float(np.clip(amount, 0.0, 1.0)) | |
| if amount <= 0: | |
| return x.astype(np.float32), 0.0 | |
| y = x.astype(np.float32) | |
| if freq > 0: | |
| freq = float(freq) | |
| if static_dip_db > 0: | |
| y = peaking_eq(y, sr, freq, gain_db=-abs(static_dip_db) * amount, q=q) | |
| width = max(700.0, freq / max(q, 0.1)) | |
| reduction = dynamic_eq_band( | |
| y, | |
| sr, | |
| freq - width * 0.5, | |
| freq + width * 0.5, | |
| amount, | |
| dynamic_depth_db, | |
| threshold_percentile, | |
| attack_ms=attack_ms, | |
| release_ms=release_ms, | |
| ) | |
| return (y - reduction).astype(np.float32), freq | |
| nyq = sr * 0.5 | |
| low = max(80.0, min(float(low), nyq - 20.0)) | |
| high = max(low + 100.0, min(float(high), nyq - 1.0)) | |
| bands = max(1, int(bands)) | |
| # Log spacing better matches perception and gives useful resolution near HP. | |
| edges = np.geomspace(low, high, bands + 1) | |
| total_reduction = np.zeros_like(y, dtype=np.float32) | |
| centers = [] | |
| for lo, hi in zip(edges[:-1], edges[1:]): | |
| reduction = dynamic_eq_band( | |
| y, | |
| sr, | |
| float(lo), | |
| float(hi), | |
| amount, | |
| dynamic_depth_db, | |
| threshold_percentile, | |
| attack_ms=attack_ms, | |
| release_ms=release_ms, | |
| ) | |
| total_reduction += reduction | |
| centers.append(np.sqrt(lo * hi)) | |
| # Smooth safety scale prevents overlapping bands from over-subtracting. | |
| # Do not clip sample-by-sample here: that creates gritty distortion. | |
| y_rms = np.sqrt(np.mean(y ** 2) + 1e-10) | |
| reduction_rms = np.sqrt(np.mean(total_reduction ** 2) + 1e-10) | |
| max_reduction_rms = float(np.clip(max_reduction_rms, 0.05, 1.0)) * y_rms | |
| if reduction_rms > max_reduction_rms: | |
| total_reduction *= max_reduction_rms / reduction_rms | |
| return (y - total_reduction).astype(np.float32), float(np.mean(centers)) | |
| def deess_stft( | |
| x, | |
| sr, | |
| amount=0.0, | |
| freq=0.0, | |
| dynamic_depth_db=24.0, | |
| threshold_percentile=50.0, | |
| bands=10, | |
| low=1500.0, | |
| high=6500.0, | |
| attack_ms=30.0, | |
| release_ms=300.0, | |
| n_fft=2048, | |
| hop_length=256, | |
| freq_smooth_bins=15, | |
| gain_floor_db=-18.0, | |
| ): | |
| """STFT-domain multiband dynamic EQ. | |
| This attenuates FFT magnitudes directly, avoiding the IIR ringing/phase | |
| artifacts caused by bandpass-subtraction dynamic EQ. | |
| """ | |
| amount = float(np.clip(amount, 0.0, 1.0)) | |
| if amount <= 0: | |
| return x.astype(np.float32), 0.0 | |
| y = x.astype(np.float32) | |
| n_fft = int(n_fft) | |
| hop_length = int(hop_length) | |
| stft = librosa.stft(y, n_fft=n_fft, hop_length=hop_length, win_length=n_fft, window="hann") | |
| mag = np.abs(stft).astype(np.float32) | |
| phase = np.exp(1j * np.angle(stft)) | |
| freqs = librosa.fft_frequencies(sr=sr, n_fft=n_fft) | |
| nyq = sr * 0.5 | |
| low = max(80.0, min(float(low), nyq - 20.0)) | |
| high = max(low + 100.0, min(float(high), nyq - 1.0)) | |
| if freq > 0: | |
| center = float(freq) | |
| width = max(700.0, center / 2.5) | |
| edges = np.array([max(20.0, center - width * 0.5), min(nyq - 1.0, center + width * 0.5)]) | |
| centers = [center] | |
| else: | |
| bands = max(1, int(bands)) | |
| edges = np.geomspace(low, high, bands + 1) | |
| centers = [float(np.sqrt(lo * hi)) for lo, hi in zip(edges[:-1], edges[1:])] | |
| frame_rate = sr / hop_length | |
| total_gain_db = np.zeros_like(mag, dtype=np.float32) | |
| for i, (lo, hi) in enumerate(zip(edges[:-1], edges[1:])): | |
| mask = (freqs >= lo) & (freqs < hi) | |
| if not np.any(mask): | |
| continue | |
| # Band energy over time, in dB, then percentile threshold per band. | |
| band_mag = mag[mask, :] | |
| env_db = 20.0 * np.log10(np.sqrt(np.mean(band_mag ** 2, axis=0)) + 1e-8) | |
| threshold = np.percentile(env_db, threshold_percentile) | |
| # Soft knee in dB. 0 below threshold, approaches 1 as excess grows. | |
| over_db = np.maximum(env_db - threshold, 0.0) | |
| control = over_db / (over_db + 12.0) | |
| control = smooth_control( | |
| control.astype(np.float32), | |
| frame_rate, | |
| attack_ms=attack_ms, | |
| release_ms=release_ms, | |
| ) | |
| attenuation_db = dynamic_depth_db * amount * control | |
| total_gain_db[mask, :] -= attenuation_db[np.newaxis, :] | |
| # Smooth the gain curve across frequency so band edges do not become | |
| # audible spectral holes/ringing tones. | |
| freq_smooth_bins = max(1, int(freq_smooth_bins)) | |
| if freq_smooth_bins > 1: | |
| if freq_smooth_bins % 2 == 0: | |
| freq_smooth_bins += 1 | |
| half = freq_smooth_bins // 2 | |
| tri = np.r_[np.arange(1, half + 2), np.arange(half, 0, -1)].astype(np.float32) | |
| tri /= np.sum(tri) | |
| padded = np.pad(total_gain_db, ((half, half), (0, 0)), mode="edge") | |
| smoothed = np.empty_like(total_gain_db) | |
| for t in range(total_gain_db.shape[1]): | |
| smoothed[:, t] = np.convolve(padded[:, t], tri, mode="valid") | |
| total_gain_db = smoothed | |
| # Apply attenuation in magnitude domain with a floor to avoid unnatural | |
| # over-carving. Very deep holes are what sounded like focused ringing. | |
| gain = 10.0 ** (np.maximum(total_gain_db, gain_floor_db) / 20.0) | |
| out_stft = (mag * gain) * phase | |
| out = librosa.istft(out_stft, hop_length=hop_length, win_length=n_fft, window="hann", length=len(y)) | |
| return out.astype(np.float32), float(np.mean(centers)) | |
| def lpc_coefficients(frame, order): | |
| """Autocorrelation LPC. Returns denominator A=[1,a1,...].""" | |
| frame = frame.astype(np.float64) | |
| if np.max(np.abs(frame)) < 1e-8: | |
| return np.r_[1.0, np.zeros(order)] | |
| # Autocorrelation lags 0..order | |
| r = np.correlate(frame, frame, mode="full") | |
| mid = len(frame) - 1 | |
| r = r[mid:mid + order + 1] | |
| r[0] += 1e-6 * (r[0] + 1e-9) # stabilize | |
| try: | |
| a_rest = solve_toeplitz((r[:-1], r[:-1]), -r[1:]) | |
| except Exception: | |
| a_rest = np.zeros(order) | |
| return np.r_[1.0, a_rest] | |
| def whisperize( | |
| y, | |
| sr, | |
| frame_ms=32.0, | |
| hop_ms=8.0, | |
| lpc_order=50, | |
| lpc_boost=0.0, | |
| highpass_hz=1500.0, | |
| lowpass_hz=6500.0, | |
| output_level=1.0, | |
| noise_tilt=0.0, | |
| breath=0.0, | |
| final_eq_freq=600.0, | |
| final_eq_oct=1.0, | |
| final_eq_gain=-30.0, | |
| deharsh=0.0, | |
| exciter_color="pink", | |
| noise_match=0.0, | |
| exciter_hp=0.0, | |
| exciter_lp=0.0, | |
| deess_amount=0.0, | |
| deess_freq=0.0, | |
| deess_q=2.5, | |
| deess_dip_db=0.0, | |
| deess_depth_db=12.0, | |
| deess_threshold=70.0, | |
| deess_bands=10, | |
| deess_attack=3.0, | |
| deess_release=80.0, | |
| deess_max_rms=0.45, | |
| deess_mode="stft", | |
| deess_fft=2048, | |
| deess_hop=256, | |
| deess_freq_smooth=15, | |
| deess_floor=-18.0, | |
| ): | |
| """Noise-excite LPC spectral envelope to create whisper-like speech.""" | |
| y = np.asarray(y, dtype=np.float32) | |
| if y.ndim > 1: | |
| y = y.mean(axis=1) | |
| # Remove DC and sub-rumble before analysis. | |
| y = y - np.mean(y) | |
| y = highpass(y, sr, 40.0) | |
| frame_len = int(round(sr * frame_ms / 1000.0)) | |
| hop = int(round(sr * hop_ms / 1000.0)) | |
| frame_len = max(frame_len, lpc_order * 3) | |
| hop = max(1, hop) | |
| win = np.hanning(frame_len).astype(np.float32) | |
| noise_match = float(np.clip(noise_match, 0.0, 1.0)) | |
| input_target_mag = None | |
| if noise_match > 0: | |
| analysis = librosa.stft( | |
| y, | |
| n_fft=frame_len, | |
| hop_length=hop, | |
| win_length=frame_len, | |
| window="hann", | |
| center=True, | |
| ) | |
| input_target_mag = np.mean(np.abs(analysis), axis=1).astype(np.float32) | |
| input_target_mag = np.maximum(input_target_mag, np.percentile(input_target_mag, 5) * 0.25 + 1e-8) | |
| pad = frame_len | |
| yp = np.pad(y, (pad, pad)) | |
| out = np.zeros_like(yp, dtype=np.float32) | |
| norm = np.zeros_like(yp, dtype=np.float32) | |
| rng = np.random.default_rng(1234) | |
| zi = np.zeros(lpc_order) | |
| for start in range(0, len(yp) - frame_len, hop): | |
| frame = yp[start:start + frame_len] | |
| windowed = frame * win | |
| rms = float(np.sqrt(np.mean(windowed ** 2) + 1e-10)) | |
| if rms < 1e-5: | |
| continue | |
| a = lpc_coefficients(windowed, lpc_order) | |
| # White noise excitation, gently tilted brighter for whisper frication. | |
| exc = rng.standard_normal(frame_len).astype(np.float32) | |
| exc = color_excitation(exc, sr, color=exciter_color, lp_hz=0.0) | |
| if input_target_mag is not None: | |
| exc = match_excitation_spectrum(exc, input_target_mag, amount=noise_match) | |
| # Explicit main-noise filters. These are skipped when set to 0. | |
| if exciter_lp > 0: | |
| exc = lowpass(exc, sr, float(exciter_lp)) | |
| if exciter_hp > 0: | |
| exc = highpass(exc, sr, float(exciter_hp)) | |
| exc -= np.mean(exc) | |
| exc /= np.sqrt(np.mean(exc ** 2) + 1e-10) | |
| bright = exc - noise_tilt * np.r_[0.0, exc[:-1]] | |
| bright /= np.sqrt(np.mean(bright ** 2) + 1e-10) | |
| synth, zi = lfilter([1.0], a, bright, zi=zi) | |
| synth = synth.astype(np.float32) | |
| # Optional LPC/formant contrast. A second gentle LPC pass emphasizes | |
| # the speech-shaped resonances relative to the broadband noisy floor. | |
| # Keep this modest; high values can ring or sound hollow/vocoder-ish. | |
| boost = float(np.clip(lpc_boost, 0.0, 1.0)) | |
| if boost > 0: | |
| boosted = lfilter([1.0], a, synth).astype(np.float32) | |
| boosted = np.nan_to_num(boosted) | |
| boosted /= np.sqrt(np.mean(boosted ** 2) + 1e-10) | |
| synth_norm = synth / np.sqrt(np.mean(synth ** 2) + 1e-10) | |
| synth = (1.0 - boost) * synth_norm + boost * boosted | |
| synth /= np.sqrt(np.mean(synth ** 2) + 1e-10) | |
| synth *= rms | |
| out[start:start + frame_len] += synth * win | |
| norm[start:start + frame_len] += win ** 2 | |
| out = out / np.maximum(norm, 1e-6) | |
| out = out[pad:pad + len(y)] | |
| # Add a small breath/noise layer following the amplitude envelope. | |
| if breath > 0: | |
| env = librosa.feature.rms(y=y, frame_length=frame_len, hop_length=hop)[0] | |
| env_t = librosa.frames_to_time(np.arange(len(env)), sr=sr, hop_length=hop) | |
| t = np.arange(len(y)) / sr | |
| env = np.interp(t, env_t, env, left=0.0, right=0.0).astype(np.float32) | |
| noise = rng.standard_normal(len(y)).astype(np.float32) | |
| noise = highpass(noise, sr, highpass_hz) | |
| noise /= np.sqrt(np.mean(noise ** 2) + 1e-10) | |
| out = (1.0 - breath) * out + breath * noise * env | |
| # Optional dynamic EQ de-esser before broad de-harshing. By default, this | |
| # uses multiple independent bands between the output HP and LP cutoffs. | |
| if deess_amount > 0: | |
| if deess_mode == "iir": | |
| out, detected_freq = deess( | |
| out, | |
| sr, | |
| amount=deess_amount, | |
| freq=deess_freq, | |
| q=deess_q, | |
| static_dip_db=deess_dip_db, | |
| dynamic_depth_db=deess_depth_db, | |
| threshold_percentile=deess_threshold, | |
| bands=deess_bands, | |
| low=highpass_hz, | |
| high=lowpass_hz, | |
| attack_ms=deess_attack, | |
| release_ms=deess_release, | |
| max_reduction_rms=deess_max_rms, | |
| ) | |
| else: | |
| out, detected_freq = deess_stft( | |
| out, | |
| sr, | |
| amount=deess_amount, | |
| freq=deess_freq, | |
| dynamic_depth_db=deess_depth_db, | |
| threshold_percentile=deess_threshold, | |
| bands=deess_bands, | |
| low=highpass_hz, | |
| high=lowpass_hz, | |
| attack_ms=deess_attack, | |
| release_ms=deess_release, | |
| n_fft=deess_fft, | |
| hop_length=deess_hop, | |
| freq_smooth_bins=deess_freq_smooth, | |
| gain_floor_db=deess_floor, | |
| ) | |
| whisperize.last_deess_freq = detected_freq | |
| else: | |
| whisperize.last_deess_freq = 0.0 | |
| # Optional gentle broad de-harshing: blend in a slightly lower low-pass | |
| # version. Use after de-essing only if the whole signal is still raspy. | |
| if deharsh > 0: | |
| deharsh = float(np.clip(deharsh, 0.0, 1.0)) | |
| smooth_cutoff = min(lowpass_hz, 5200.0) | |
| smooth = lowpass(out, sr, smooth_cutoff) | |
| out = (1.0 - deharsh) * out + deharsh * smooth | |
| # Optional final broad/narrow peaking EQ after dynamics/smoothing, before | |
| # final HP/LP cleanup. | |
| if final_eq_freq > 0 and abs(final_eq_gain) > 1e-6: | |
| out = peaking_eq(out, sr, final_eq_freq, gain_db=final_eq_gain, q=octave_to_q(final_eq_oct)) | |
| out = highpass(out, sr, highpass_hz) | |
| out = lowpass(out, sr, lowpass_hz) | |
| # Final peak normalization only. --level is the target absolute peak: | |
| # 1.0 => [-1, 1], 0.9 => [-0.9, 0.9]. No RMS normalization. | |
| out = np.nan_to_num(out) | |
| target_peak = max(0.0, float(output_level)) | |
| peak = float(np.max(np.abs(out))) | |
| if target_peak > 0 and peak > 1e-10: | |
| out = out * (target_peak / peak) | |
| return out.astype(np.float32) | |
| def main(): | |
| parser = argparse.ArgumentParser(description="Create whisper-like augmentation from speech") | |
| parser.add_argument("input", help="Input audio file or folder") | |
| parser.add_argument("output", nargs="?", default=None, help="Output wav file or folder (optional)") | |
| parser.add_argument("--hp", type=float, default=1500.0, help="Output high-pass cutoff Hz") | |
| parser.add_argument("--lp", type=float, default=6500.0, help="Output low-pass cutoff Hz; lowers hiss") | |
| parser.add_argument("--level", type=float, default=1.0, help="Final peak normalization target; 1.0 peaks at +/-1, 0.9 at +/-0.9") | |
| parser.add_argument("--order", type=int, default=50, help="LPC order") | |
| parser.add_argument("--lpc-boost", type=float, default=0.0, help="Boost LPC/formant contrast vs broadband noise, 0..1; high values may ring") | |
| parser.add_argument("--tilt", type=float, default=0.0, help="Noise brightness; lower is smoother, higher is hissier") | |
| parser.add_argument("--breath", type=float, default=0.0, help="Extra breath noise amount") | |
| parser.add_argument("--eq-freq", type=float, default=600.0, help="Final peaking EQ frequency Hz before output HP/LP; 0=off") | |
| parser.add_argument("--eq-oct", type=float, default=1.0, help="Final peaking EQ bandwidth in octaves") | |
| parser.add_argument("--eq-gain", type=float, default=-30.0, help="Final peaking EQ gain in dB") | |
| parser.add_argument("--deharsh", type=float, default=0.0, help="Blend in gentler low-pass output; try 0.15-0.35") | |
| parser.add_argument("--noise-color", dest="exciter_color", choices=["white", "soft", "pink", "warm", "dark", "brown", "air"], default="pink", help="Base noise color before LPC synthesis") | |
| parser.add_argument("--noise-match", type=float, default=0.0, help="Blend base noise spectrum toward input speech spectrum, 0..1; 1=exact match") | |
| parser.add_argument("--noise-hp", dest="exciter_hp", type=float, default=0.0, help="Optional high-pass for main random noise before LPC; 0=off") | |
| parser.add_argument("--noise-lp", dest="exciter_lp", type=float, default=0.0, help="Optional low-pass for main random noise before LPC; 0=off") | |
| parser.add_argument("--deess", type=float, default=0.0, help="Targeted de-esser amount 0..1; try 0.25-0.6") | |
| parser.add_argument("--deess-freq", type=float, default=0.0, help="De-esser center frequency Hz; 0=auto-detect harshest") | |
| parser.add_argument("--deess-q", type=float, default=2.5, help="De-esser band Q; higher=narrower") | |
| parser.add_argument("--deess-dip", type=float, default=0.0, help="Optional fixed/static dip in dB; 0 means dynamic-only") | |
| parser.add_argument("--deess-depth", type=float, default=12.0, help="Max dynamic EQ reduction in dB") | |
| parser.add_argument("--deess-threshold", type=float, default=70.0, help="Dynamic EQ threshold percentile; lower=more often") | |
| parser.add_argument("--deess-bands", type=int, default=10, help="Number of dynamic EQ bands between --hp and --lp when --deess-freq is 0") | |
| parser.add_argument("--deess-attack", type=float, default=3.0, help="Dynamic EQ attack time in ms") | |
| parser.add_argument("--deess-release", type=float, default=80.0, help="Dynamic EQ release time in ms") | |
| parser.add_argument("--deess-max-rms", type=float, default=0.45, help="IIR mode only: smooth safety limit for total dynamic reduction RMS") | |
| parser.add_argument("--deess-mode", choices=["stft", "iir"], default="stft", help="Dynamic EQ engine; stft avoids IIR ringing") | |
| parser.add_argument("--deess-fft", type=int, default=2048, help="STFT mode FFT/window size") | |
| parser.add_argument("--deess-hop", type=int, default=256, help="STFT mode hop size") | |
| parser.add_argument("--deess-freq-smooth", type=int, default=15, help="STFT mode: smooth gain over this many FFT bins") | |
| parser.add_argument("--deess-floor", type=float, default=-18.0, help="STFT mode: deepest allowed gain in dB; prevents spectral holes") | |
| args = parser.parse_args() | |
| if not os.path.exists(args.input): | |
| print(f"Error: Input path '{args.input}' does not exist.") | |
| return | |
| if os.path.isdir(args.input): | |
| print(f"Scanning directory {args.input} for audio files...") | |
| if args.output is not None: | |
| os.makedirs(args.output, exist_ok=True) | |
| for root, dirs, files in os.walk(args.input): | |
| for file in files: | |
| file_path = os.path.join(root, file) | |
| name, ext = os.path.splitext(file) | |
| if name.endswith("_whisper"): | |
| continue | |
| # Quick filter of common non-audio extensions to speed up directory walking | |
| if ext.lower() in {'.txt', '.py', '.json', '.md', '.git', '.sh', '.csv', '.tsv', '.png', '.jpg', '.jpeg', '.gif', '.pdf', '.zip', '.tar', '.gz', '.db', '.DS_Store'}: | |
| continue | |
| try: | |
| y, sr = librosa.load(file_path, sr=None, mono=True) | |
| except Exception: | |
| continue | |
| if args.output is None: | |
| out_path = os.path.join(root, f"{name}_whisper{ext}") | |
| else: | |
| rel_path = os.path.relpath(file_path, args.input) | |
| out_path = os.path.join(args.output, rel_path) | |
| base, ext = os.path.splitext(out_path) | |
| out_path = f"{base}_whisper{ext}" | |
| out = whisperize( | |
| y, | |
| sr, | |
| lpc_order=args.order, | |
| lpc_boost=args.lpc_boost, | |
| highpass_hz=args.hp, | |
| lowpass_hz=args.lp, | |
| output_level=args.level, | |
| noise_tilt=args.tilt, | |
| breath=args.breath, | |
| final_eq_freq=args.eq_freq, | |
| final_eq_oct=args.eq_oct, | |
| final_eq_gain=args.eq_gain, | |
| deharsh=args.deharsh, | |
| exciter_color=args.exciter_color, | |
| noise_match=args.noise_match, | |
| exciter_hp=args.exciter_hp, | |
| exciter_lp=args.exciter_lp, | |
| deess_amount=args.deess, | |
| deess_freq=args.deess_freq, | |
| deess_q=args.deess_q, | |
| deess_dip_db=args.deess_dip, | |
| deess_depth_db=args.deess_depth, | |
| deess_threshold=args.deess_threshold, | |
| deess_bands=args.deess_bands, | |
| deess_attack=args.deess_attack, | |
| deess_release=args.deess_release, | |
| deess_max_rms=args.deess_max_rms, | |
| deess_mode=args.deess_mode, | |
| deess_fft=args.deess_fft, | |
| deess_hop=args.deess_hop, | |
| deess_freq_smooth=args.deess_freq_smooth, | |
| deess_floor=args.deess_floor, | |
| ) | |
| os.makedirs(os.path.dirname(out_path) or ".", exist_ok=True) | |
| sf.write(out_path, out, sr) | |
| msg = f"Wrote {out_path} ({len(out) / sr:.2f}s, sr={sr})" | |
| if getattr(whisperize, "last_deess_freq", 0.0): | |
| if args.deess_freq > 0: | |
| msg += f", deess={whisperize.last_deess_freq:.0f}Hz" | |
| else: | |
| msg += f", deess={args.deess_mode}/{args.deess_bands} bands {args.hp:.0f}-{args.lp:.0f}Hz" | |
| print(msg) | |
| else: | |
| if args.output is None: | |
| base, ext = os.path.splitext(args.input) | |
| out_path = f"{base}_whisper{ext}" | |
| elif os.path.isdir(args.output) or args.output.endswith('/') or args.output.endswith('\\'): | |
| in_name = os.path.basename(args.input) | |
| base, ext = os.path.splitext(in_name) | |
| out_path = os.path.join(args.output, f"{base}_whisper{ext}") | |
| else: | |
| out_path = args.output | |
| try: | |
| y, sr = librosa.load(args.input, sr=None, mono=True) | |
| except Exception as e: | |
| print(f"Error loading {args.input}: {e}") | |
| return | |
| out = whisperize( | |
| y, | |
| sr, | |
| lpc_order=args.order, | |
| lpc_boost=args.lpc_boost, | |
| highpass_hz=args.hp, | |
| lowpass_hz=args.lp, | |
| output_level=args.level, | |
| noise_tilt=args.tilt, | |
| breath=args.breath, | |
| final_eq_freq=args.eq_freq, | |
| final_eq_oct=args.eq_oct, | |
| final_eq_gain=args.eq_gain, | |
| deharsh=args.deharsh, | |
| exciter_color=args.exciter_color, | |
| noise_match=args.noise_match, | |
| exciter_hp=args.exciter_hp, | |
| exciter_lp=args.exciter_lp, | |
| deess_amount=args.deess, | |
| deess_freq=args.deess_freq, | |
| deess_q=args.deess_q, | |
| deess_dip_db=args.deess_dip, | |
| deess_depth_db=args.deess_depth, | |
| deess_threshold=args.deess_threshold, | |
| deess_bands=args.deess_bands, | |
| deess_attack=args.deess_attack, | |
| deess_release=args.deess_release, | |
| deess_max_rms=args.deess_max_rms, | |
| deess_mode=args.deess_mode, | |
| deess_fft=args.deess_fft, | |
| deess_hop=args.deess_hop, | |
| deess_freq_smooth=args.deess_freq_smooth, | |
| deess_floor=args.deess_floor, | |
| ) | |
| os.makedirs(os.path.dirname(out_path) or ".", exist_ok=True) | |
| sf.write(out_path, out, sr) | |
| msg = f"Wrote {out_path} ({len(out) / sr:.2f}s, sr={sr})" | |
| if getattr(whisperize, "last_deess_freq", 0.0): | |
| if args.deess_freq > 0: | |
| msg += f", deess={whisperize.last_deess_freq:.0f}Hz" | |
| else: | |
| msg += f", deess={args.deess_mode}/{args.deess_bands} bands {args.hp:.0f}-{args.lp:.0f}Hz" | |
| print(msg) | |
| if __name__ == "__main__": | |
| main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment