82 lines
3.1 KiB
Python
82 lines
3.1 KiB
Python
"""
|
|||
|
|
Mastering chain for generated TTS/voice clips: fixes the "tinny" spectral
|
||
|
|
imbalance common to neural vocoder output (excess 3-6kHz presence energy,
|
||
|
|
starved low end relative to a naturally-recorded voice - see the project's
|
||
|
|
spectral comparison: raw recordings skew toward 200-1000Hz, generated clips
|
||
|
|
dump ~35% of their energy into 3-6kHz) and adds the density/space that
|
||
|
|
reads as "produced" rather than dry/synthetic.
|
||
|
|
|
||
|
|
Chain order matters: EQ before compression (so the compressor reacts to
|
||
|
|
the corrected tonal balance, not the raw tinny one), reverb after
|
||
|
|
compression (so the room tail doesn't get squashed), limiter last.
|
||
|
|
"""
|
||
|
|
|
||
|
|
import argparse
|
||
|
|
|
||
|
|
import numpy as np
|
||
|
|
import soundfile as sf
|
||
|
|
from pedalboard import (
|
||
|
|
Compressor,
|
||
|
|
HighpassFilter,
|
||
|
|
HighShelfFilter,
|
||
|
|
Limiter,
|
||
|
|
LowShelfFilter,
|
||
|
|
PeakFilter,
|
||
|
|
Pedalboard,
|
||
|
|
Reverb,
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def build_chain(
|
||
|
|
warmth_db: float,
|
||
|
|
presence_cut_db: float,
|
||
|
|
air_db: float,
|
||
|
|
comp_ratio: float,
|
||
|
|
reverb_wet: float,
|
||
|
|
) -> Pedalboard:
|
||
|
|
return Pedalboard([
|
||
|
|
HighpassFilter(cutoff_frequency_hz=60),
|
||
|
|
LowShelfFilter(cutoff_frequency_hz=150, gain_db=warmth_db, q=0.7),
|
||
|
|
PeakFilter(cutoff_frequency_hz=3800, gain_db=presence_cut_db, q=1.0),
|
||
|
|
HighShelfFilter(cutoff_frequency_hz=6500, gain_db=air_db, q=0.7),
|
||
|
|
Compressor(threshold_db=-20, ratio=comp_ratio, attack_ms=12, release_ms=120),
|
||
|
|
Reverb(room_size=0.08, damping=0.85, wet_level=reverb_wet, dry_level=1.0 - reverb_wet),
|
||
|
|
Limiter(threshold_db=-1.0, release_ms=100),
|
||
|
|
])
|
||
|
|
|
||
|
|
|
||
|
|
def main() -> None:
|
||
|
|
parser = argparse.ArgumentParser(description="Apply a studio mastering chain to a voice clip")
|
||
|
|
parser.add_argument("--input", required=True)
|
||
|
|
parser.add_argument("--output", required=True)
|
||
|
|
parser.add_argument("--warmth-db", type=float, default=5.5, help="Low-shelf boost @150Hz")
|
||
|
|
parser.add_argument("--presence-cut-db", type=float, default=-5.0, help="Peak cut @3.8kHz (the tinny band)")
|
||
|
|
parser.add_argument("--air-db", type=float, default=-4.5, help="High-shelf @6.5kHz, for softness")
|
||
|
|
parser.add_argument("--comp-ratio", type=float, default=2.5)
|
||
|
|
parser.add_argument("--reverb-wet", type=float, default=0.025)
|
||
|
|
args = parser.parse_args()
|
||
|
|
|
||
|
|
audio, sr = sf.read(args.input, always_2d=True)
|
||
|
|
audio = audio.T.astype(np.float32) # pedalboard wants (channels, samples)
|
||
|
|
|
||
|
|
board = build_chain(
|
||
|
|
args.warmth_db, args.presence_cut_db, args.air_db, args.comp_ratio, args.reverb_wet
|
||
|
|
)
|
||
|
|
processed = board(audio, sr)
|
||
|
|
|
||
|
|
# pedalboard's Limiter is attack/release-based, not a true brick-wall
|
||
|
|
# limiter - fast transients can still punch through its threshold (seen
|
||
|
|
# in practice: in-memory peaks hit 0dBFS despite threshold_db=-1.0,
|
||
|
|
# producing audible clipping distortion on playback). Hard safety
|
||
|
|
# ceiling here guarantees the written file never clips regardless.
|
||
|
|
target_peak = 10 ** (-1.0 / 20)
|
||
|
|
peak = np.max(np.abs(processed))
|
||
|
|
if peak > target_peak:
|
||
|
|
processed = processed * (target_peak / peak)
|
||
|
|
|
||
|
|
sf.write(args.output, processed.T, sr)
|
||
|
|
print(f"[INFO] Saved {args.output}")
|
||
|
|
|
||
|
|
|
||
|
|
if __name__ == "__main__":
|
||
|
|
main()
|