Skip to content

Instantly share code, notes, and snippets.

@chigkim
Created June 18, 2026 00:23
Show Gist options
  • Select an option

  • Save chigkim/bebc04d75e1ec6ea49f3be3d0f2ab6c3 to your computer and use it in GitHub Desktop.

Select an option

Save chigkim/bebc04d75e1ec6ea49f3be3d0f2ab6c3 to your computer and use it in GitHub Desktop.
Whisperize
#!/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