From 57ae9deb3031beab14f8473a59115e6916dc0fe7 Mon Sep 17 00:00:00 2001 From: Steve Calcott Date: Fri, 16 Oct 2026 11:08:53 +0000 Subject: [PATCH] Add a Mastering tab and auto-apply toggle for generated voice output Pedalboard-based chain (warmth EQ, presence dip, compression, light reverb, hard-ceiling limiter) fixes the spectral imbalance behind generated TTS sounding tinny - see mastering/master.py docstring and commit history for the before/after spectral analysis and the A/B tuning pass against real character clips. Pipeline Studios Generate step can auto-apply it; the standalone tab works on any clip. Co-Authored-By: Claude Sonnet 5 --- mastering/master.py | 82 ++++++++++++++++++++++++++++++++++++ webui/app.py | 100 ++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 178 insertions(+), 4 deletions(-) create mode 100644 mastering/master.py diff --git a/mastering/master.py b/mastering/master.py new file mode 100644 index 0000000..8707786 --- /dev/null +++ b/mastering/master.py @@ -0,0 +1,82 @@ +""" +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() diff --git a/webui/app.py b/webui/app.py index 1f4b43e..28ce5c2 100644 --- a/webui/app.py +++ b/webui/app.py @@ -62,6 +62,21 @@ SFX_SA3_GENERATE = SFX_SA3_DIR / "generate.py" SFX_MODEL_SA3 = "Stable Audio 3 (Small-SFX)" SFX_MODEL_MOSS = "MOSS-SoundEffect" +# Mastering - own isolated venv (pedalboard), fixes the "tinny" spectral +# imbalance generated TTS output has (see mastering/master.py's docstring +# for the measured before/after). Defaults below match what Steve approved +# after an A/B listen on real character clips. +MASTERING_DIR = REPO_ROOT / "mastering" +MASTERING_PYTHON = MASTERING_DIR / ".venv" / "bin" / "python3" +MASTERING_SCRIPT = MASTERING_DIR / "master.py" +MASTERING_DEFAULTS = { + "warmth_db": 5.5, + "presence_cut_db": -5.0, + "air_db": -4.5, + "comp_ratio": 2.5, + "reverb_wet": 0.025, +} + MODEL_BASE = "Base (full quality)" MODEL_FLASH = "Flash (fast draft, 4-step)" @@ -406,7 +421,38 @@ def reset_to_original(history: list): # --------------------------------------------------------------------------- -def generate_speech(reference: str, text: str, language: str): +def apply_mastering( + audio_path: str, + warmth_db: float = MASTERING_DEFAULTS["warmth_db"], + presence_cut_db: float = MASTERING_DEFAULTS["presence_cut_db"], + air_db: float = MASTERING_DEFAULTS["air_db"], + comp_ratio: float = MASTERING_DEFAULTS["comp_ratio"], + reverb_wet: float = MASTERING_DEFAULTS["reverb_wet"], +) -> str: + """Run mastering/master.py's pedalboard chain (warmth EQ, presence dip, + compression, a touch of reverb, hard-ceiling limiter) on an existing + clip. Shared by the standalone Mastering tab and Pipeline Studio's + optional auto-apply-after-generation toggle.""" + if not audio_path: + raise gr.Error("No audio to master yet.") + out_path = WORKDIR / f"mastered_{int(time.time())}.wav" + _run( + [ + str(MASTERING_PYTHON), str(MASTERING_SCRIPT), + "--input", audio_path, + "--output", str(out_path), + "--warmth-db", str(warmth_db), + "--presence-cut-db", str(presence_cut_db), + "--air-db", str(air_db), + "--comp-ratio", str(comp_ratio), + "--reverb-wet", str(reverb_wet), + ], + cwd=MASTERING_DIR, + ) + return str(out_path) + + +def generate_speech(reference: str, text: str, language: str, master_after: bool): if not reference: raise gr.Error("Set a voice reference first (upload, strip, and/or edit it above).") if not text or not text.strip(): @@ -430,8 +476,9 @@ def generate_speech(reference: str, text: str, language: str): cwd=TTS_DIR, env=env, ) - transcript = _whisper_transcribe(str(out_path)) - return str(out_path), transcript + final_path = apply_mastering(str(out_path)) if master_after else str(out_path) + transcript = _whisper_transcribe(final_path) + return final_path, transcript def generate_sound_effect(description: str, model_choice: str, duration: float): @@ -838,6 +885,10 @@ with gr.Blocks(title="Audio-demo pipeline studio") as demo: gr.Markdown("## 3. Generate (MOSS-TTS)") gen_text = gr.Textbox(label="Text to speak", lines=6) gen_language = gr.Dropdown(LANGUAGES, value="English", label="Language") + gen_master_cb = gr.Checkbox( + label="Apply mastering after generation (warmth/de-tin/light reverb - see Mastering tab)", + value=True, + ) generate_btn = gr.Button("Generate speech", variant="primary") generated_audio = gr.Audio(label="Generated speech", type="filepath") gen_transcript_box = gr.Textbox(label="Whisper transcript of generated speech") @@ -878,7 +929,7 @@ with gr.Blocks(title="Audio-demo pipeline studio") as demo: outputs=[current_ref_audio, history_state, history_label, ref_transcript_box], ) generate_btn.click( - generate_speech, inputs=[current_ref_audio, gen_text, gen_language], + generate_speech, inputs=[current_ref_audio, gen_text, gen_language, gen_master_cb], outputs=[generated_audio, gen_transcript_box], ) use_ref_transcript_btn.click(lambda t: t, inputs=[ref_transcript_box], outputs=[gen_text]) @@ -927,6 +978,47 @@ with gr.Blocks(title="Audio-demo pipeline studio") as demo: outputs=[sfx_save_status], ) + with gr.Tab("Mastering"): + gr.Markdown( + "# Mastering\n" + "Fixes the \"tinny\" spectral imbalance common to generated " + "TTS output (too much 3-6kHz presence, not enough low end) " + "and adds a bit of warmth/density/space. Works on any clip - " + "Pipeline Studio's Generate step can also apply this " + "automatically (see its checkbox)." + ) + with gr.Row(): + with gr.Column(): + master_input = gr.Audio(label="Input audio", type="filepath") + master_warmth = gr.Slider( + minimum=0, maximum=10, step=0.5, value=MASTERING_DEFAULTS["warmth_db"], + label="Warmth (low-shelf boost @150Hz, dB)", + ) + master_presence = gr.Slider( + minimum=-10, maximum=0, step=0.5, value=MASTERING_DEFAULTS["presence_cut_db"], + label="De-tin (presence cut @3.8kHz, dB)", + ) + master_air = gr.Slider( + minimum=-10, maximum=2, step=0.5, value=MASTERING_DEFAULTS["air_db"], + label="Softness (high-shelf @6.5kHz, dB)", + ) + master_comp = gr.Slider( + minimum=1, maximum=6, step=0.5, value=MASTERING_DEFAULTS["comp_ratio"], + label="Compression ratio", + ) + master_reverb = gr.Slider( + minimum=0, maximum=0.2, step=0.005, value=MASTERING_DEFAULTS["reverb_wet"], + label="Reverb wet level", + ) + master_btn = gr.Button("Apply mastering", variant="primary") + with gr.Column(): + master_output = gr.Audio(label="Mastered output", type="filepath") + master_btn.click( + apply_mastering, + inputs=[master_input, master_warmth, master_presence, master_air, master_comp, master_reverb], + outputs=[master_output], + ) + with gr.Tab("Characters"): gr.Markdown( f"# The Service Station cast\n"