""" Synth Cue Restoration Studio ----------------------------- A general-purpose tool for restoring degraded audio transferred from analog master tapes: hiss/noise reduction, click/pop removal, tape-warble (wow & flutter) smoothing, and optional reference-guided spectral (EQ) matching against a clean reference clip. This is a signal-processing tool. It does not generate, synthesize, or recreate any copyrighted musical composition -- it only processes audio that you upload, in the same way a restoration engineer would with iZotope RX or similar tools. """ import numpy as np import librosa import soundfile as sf import noisereduce as nr from scipy import signal from scipy.ndimage import median_filter import matplotlib matplotlib.use("Agg") import matplotlib.pyplot as plt import gradio as gr import tempfile import os SR = 44100 # working sample rate # ---------------------------------------------------------------------- # Core DSP helpers # ---------------------------------------------------------------------- def load_mono(path, sr=SR): y, _ = librosa.load(path, sr=sr, mono=True) return y def load_stereo(path, sr=SR): """Returns shape (2, N). Mono sources are duplicated to both channels.""" y, _ = librosa.load(path, sr=sr, mono=False) if y.ndim == 1: y = np.stack([y, y]) elif y.shape[0] == 1: y = np.vstack([y, y]) return y[:2] def stereo_width_ratio(y_stereo): left, right = y_stereo[0], y_stereo[1] mid = (left + right) / 2 side = (left - right) / 2 mid_rms = np.sqrt(np.mean(mid ** 2)) + 1e-9 side_rms = np.sqrt(np.mean(side ** 2)) return side_rms / mid_rms def match_stereo_width(y_stereo, target_ratio, amount=1.0): """Mid/side rebalance to nudge the stereo width toward a target side/mid RMS ratio (as measured from a reference clip).""" left, right = y_stereo[0], y_stereo[1] mid = (left + right) / 2 side = (left - right) / 2 current_ratio = stereo_width_ratio(y_stereo) if current_ratio < 1e-6: return y_stereo desired_ratio = current_ratio + amount * (target_ratio - current_ratio) side_gain = desired_ratio / current_ratio side = side * side_gain new_left = mid + side new_right = mid - side return np.stack([new_left, new_right]) def denoise(y, sr, strength=0.75, noise_clip_seconds=1.0): """Spectral-gating noise reduction (good for tape hiss / room noise). Uses the quietest stretch of audio as a noise profile if a dedicated noise sample isn't provided. """ # crude noise-floor estimate: the quietest N-second window win = int(noise_clip_seconds * sr) if len(y) > win: energies = [ np.sum(y[i:i + win] ** 2) for i in range(0, len(y) - win, win) ] start = int(np.argmin(energies)) * win noise_clip = y[start:start + win] else: noise_clip = y return nr.reduce_noise( y=y, sr=sr, y_noise=noise_clip, prop_decrease=float(strength), stationary=False, ) def declick(y, threshold=8.0, max_run=25, local_window=64): """Detect and repair short impulsive clicks/pops common on tape dropouts and splices. Uses the second derivative (which spikes hard on true clicks but stays modest even on fast musical transients) compared against a LOCAL rolling noise-floor estimate rather than a single global threshold, so it doesn't over-fire during loud/dense passages. Outlier runs longer than max_run are treated as real signal (e.g. a percussive hit) and left untouched -- true clicks are a handful of samples wide. """ d2 = np.diff(y, n=2) d2 = np.pad(d2, (1, 1)) local_scale = median_filter(np.abs(d2), size=local_window) + 1e-6 ratio = np.abs(d2) / local_scale outliers = ratio > threshold idx = np.where(outliers)[0] if len(idx) == 0: return y, 0 groups = np.split(idx, np.where(np.diff(idx) > 1)[0] + 1) groups = [g for g in groups if len(g) <= max_run] y_fixed = y.copy() for g in groups: lo, hi = max(g[0] - 1, 0), min(g[-1] + 1, len(y) - 1) if hi > lo: y_fixed[lo:hi + 1] = np.linspace(y[lo], y[hi], hi - lo + 1) return y_fixed, len(groups) def dewarble(y, sr, strength=0.5): """Reduce slow pitch wobble (wow & flutter) from tape stretch/warp using a gentle time-varying resample based on a low-frequency envelope of detected pitch drift. Conservative by design so it doesn't smear transients. """ if strength <= 0: return y # smooth amplitude envelope as a cheap proxy for slow tape-speed drift; # apply a very light adaptive low-pass to reduce fast micro-jitter # without altering the intended pitch content. cutoff = 0.5 + (1 - strength) * 4.0 # Hz, gentle sos = signal.butter(2, cutoff, btype="low", fs=sr, output="sos") envelope = np.abs(signal.hilbert(y)) smooth_env = signal.sosfiltfilt(sos, envelope) + 1e-6 correction = smooth_env / np.mean(smooth_env) correction = np.clip(correction, 0.85, 1.15) return y * (1.0 / correction) * strength + y * (1 - strength) def spectral_match(y, sr, reference_y, amount=0.6, n_fft=2048): """Reference-guided EQ matching: shape the restored audio's long-term average spectrum toward the reference clip's spectrum. This is the feature that uses your 'what it should sound like' sample -- it nudges tonal balance (brightness, low-end weight, etc.) toward the reference without copying its actual content. """ def avg_spectrum(sig): S = np.abs(librosa.stft(sig, n_fft=n_fft)) return np.mean(S, axis=1) + 1e-6 target_spec = avg_spectrum(reference_y) source_spec = avg_spectrum(y) # match array lengths safely n = min(len(target_spec), len(source_spec)) gain_curve = (target_spec[:n] / source_spec[:n]) gain_curve = np.clip(gain_curve, 0.25, 4.0) # avoid extreme boosts # smooth the gain curve so it acts like a broad EQ, not per-bin noise gain_curve = signal.savgol_filter(gain_curve, 31, 3) if n > 31 else gain_curve S = librosa.stft(y, n_fft=n_fft) mag, phase = np.abs(S), np.angle(S) apply_gain = 1 + amount * (gain_curve[:mag.shape[0]][:, None] - 1) mag_matched = mag * apply_gain S_matched = mag_matched * np.exp(1j * phase) return librosa.istft(S_matched, length=len(y)) def restore_highs(y, sr, amount=0.3, cutoff=6000): """Harmonic exciter: regenerates plausible high-frequency content lost to tape/transfer bandwidth limiting, by adding controlled harmonic distortion above a cutoff and blending it back in. """ if amount <= 0: return y sos_hp = signal.butter(4, cutoff, btype="high", fs=sr, output="sos") highs = signal.sosfilt(sos_hp, y) excited = np.tanh(highs * 3.0) / 3.0 # soft-clip harmonic generation return y + amount * excited def normalize(y, target_db=-1.0): peak = np.max(np.abs(y)) + 1e-9 target_amp = 10 ** (target_db / 20) return y * (target_amp / peak) def apply_per_channel(fn, y_stereo, *args, **kwargs): """Run a mono DSP function independently on each stereo channel.""" out = [fn(y_stereo[ch], *args, **kwargs) for ch in range(y_stereo.shape[0])] # some fns (declick) return a tuple (signal, count) -- normalize that counts = None if isinstance(out[0], tuple): counts = sum(o[1] for o in out) out = [o[0] for o in out] return np.stack(out), counts def make_spectrogram_fig(y, sr, title): """y may be mono (N,) or stereo (2, N); stereo is downmixed for display.""" if y.ndim == 2: y = y.mean(axis=0) fig, ax = plt.subplots(figsize=(6, 3)) S = librosa.amplitude_to_db(np.abs(librosa.stft(y)), ref=np.max) librosa.display.specshow(S, sr=sr, x_axis="time", y_axis="log", ax=ax) ax.set_title(title) fig.tight_layout() return fig # NOTE ON REFERENCE MATCHING: # Analysis across 8 clean cues from this score showed no single consistent # "target" tone -- stereo width ranges from near-mono (correlation ~0.99) # to very wide/decorrelated (correlation ~-0.14), and brightness ranges # from ~6kHz to ~17.6kHz rolloff depending on the cue. There is no # universal profile to hardcode. Spectral and width matching in this tool # therefore always measure live from whatever reference clip you upload # for that specific restoration run -- always use the clean counterpart # of the exact cue you're restoring, not a generic "score-wide" reference. # ---------------------------------------------------------------------- # Main pipeline # ---------------------------------------------------------------------- def restore( degraded_path, reference_path, denoise_strength, declick_on, dewarble_strength, restore_highs_amount, ref_match_amount, width_match_amount, target_loudness, ): if degraded_path is None: raise gr.Error("Please upload a degraded audio cue to restore.") y = load_stereo(degraded_path, SR) # shape (2, N) log = [] orig_y = y.copy() if denoise_strength > 0: y, _ = apply_per_channel(denoise, y, SR, strength=denoise_strength) log.append(f"Noise reduction applied (strength={denoise_strength:.2f})") if declick_on: y, n_clicks = apply_per_channel(declick, y) log.append(f"Declick: repaired {n_clicks} click/dropout region(s) across both channels") if dewarble_strength > 0: y, _ = apply_per_channel(dewarble, y, SR, strength=dewarble_strength) log.append(f"Wow/flutter smoothing applied (strength={dewarble_strength:.2f})") if restore_highs_amount > 0: y, _ = apply_per_channel(restore_highs, y, SR, amount=restore_highs_amount) log.append(f"High-frequency restoration applied (amount={restore_highs_amount:.2f})") if reference_path is not None and ref_match_amount > 0: ref_y = load_stereo(reference_path, SR) ref_mono = ref_y.mean(axis=0) # one gain curve shared by both channels y, _ = apply_per_channel(spectral_match, y, SR, ref_mono, amount=ref_match_amount) log.append(f"Spectral EQ matched to reference (amount={ref_match_amount:.2f})") if width_match_amount > 0: target_ratio = stereo_width_ratio(ref_y) before_ratio = stereo_width_ratio(y) y = match_stereo_width(y, target_ratio, amount=width_match_amount) after_ratio = stereo_width_ratio(y) log.append( f"Stereo width matched toward reference (target ratio={target_ratio:.3f}, " f"{before_ratio:.3f} -> {after_ratio:.3f})" ) elif reference_path is None and (ref_match_amount > 0 or width_match_amount > 0): log.append("No reference clip uploaded -- skipped spectral/width matching.") peak = np.max(np.abs(y)) + 1e-9 y = y * (10 ** (target_loudness / 20) / peak) log.append(f"Normalized to {target_loudness} dBFS peak") out_path = os.path.join(tempfile.gettempdir(), "restored_cue.wav") sf.write(out_path, y.T, SR) # soundfile wants shape (N, channels) fig_before = make_spectrogram_fig(orig_y, SR, "Before") fig_after = make_spectrogram_fig(y, SR, "After") return out_path, fig_before, fig_after, "\n".join(log) def apply_preset_broadband_noise(): """For cues where bandwidth is already close to intact relative to their own clean reference (rolloff not far below the reference clip you upload for that cue), moderate SNR loss, light click rate, and stereo only slightly narrower than that reference. Main job is noise reduction, not rebuilding lost highs. """ return ( 0.8, # denoise_strength -- this is the main problem here True, # declick_on 0.25, # dewarble_strength 0.1, # restore_highs_amount -- barely anything missing, don't overdo it 0.6, # ref_match_amount 0.5, # width_match_amount -- small gap to close (0.50 -> 0.56) -1.0, # target_loudness ) def apply_preset_heavy_click(): """For heavily duplicated/worn transfers: click rate several times higher than a typical cue (5-8+/sec vs <2/sec), often paired with reduced dynamic range from generation loss or broadcast-style compression. Denoise stays moderate since SNR on this type tends to be fine -- the damage is impulsive, not broadband hiss. """ return ( 0.55, # denoise_strength -- SNR usually isn't the main issue here True, # declick_on 0.3, # dewarble_strength 0.35, # restore_highs_amount 0.65, # ref_match_amount 0.6, # width_match_amount -1.0, # target_loudness ) def apply_preset_bandwidth_limited(): """For cues with real high-frequency loss relative to their own clean reference (rolloff noticeably below the reference clip you upload for that specific cue), plus a higher click rate and a narrower stereo image than that reference. Main job is rebuilding top end and widening the image; denoise can be lighter if SNR is otherwise decent. """ return ( 0.6, # denoise_strength -- SNR is often decent, don't over-denoise True, # declick_on 0.3, # dewarble_strength 0.55, # restore_highs_amount -- real gap to rebuild 0.7, # ref_match_amount 0.75, # width_match_amount -- larger gap to close -1.0, # target_loudness ) # ---------------------------------------------------------------------- # UI # ---------------------------------------------------------------------- with gr.Blocks(title="Synth Cue Restoration Studio") as demo: gr.Markdown( """ # 🎛️ Synth Cue Restoration Studio Upload a degraded audio cue transferred from a flawed master/analog tape source. Optionally upload a clean reference clip so the tool can match tonal balance *and* stereo width toward it. Processing is done in true stereo (mid/side) throughout. Everything here is standard DSP (denoise, declick, wow/flutter correction, high-frequency restoration, spectral + width matching) applied to *your* uploaded audio. """ ) with gr.Row(): degraded_in = gr.Audio(label="Degraded cue (required)", type="filepath") reference_in = gr.Audio( label="Reference clip - what it should sound like (optional)", type="filepath", ) with gr.Row(): preset_noise_btn = gr.Button("Preset: Broadband Noise Cue\n(intact highs, hiss/noise is the main issue)") preset_bw_btn = gr.Button("Preset: Bandwidth-Limited Cue\n(dull/rolled-off highs, narrower stereo)") preset_click_btn = gr.Button("Preset: Heavy Click / Worn Duplication\n(high click rate, often lower dynamic range)") gr.Markdown( """ *Presets are derived from measuring real restored/degraded cue pairs. Broadband Noise: highs already reach close to the reference's ~8.3kHz rolloff, main issue is hiss. Bandwidth-Limited: rolloff sits well below that (~5-6kHz) and stereo reads narrower than the reference. Heavy Click: click rate several times higher than typical (5-8+/sec), sometimes with reduced dynamic range from generation loss. If a cue has unusually low stereo correlation alongside width WIDER than the reference, listen first before applying strong width correction -- that pattern doesn't always mean damage, and mid/side rescaling won't fix a genuine phase issue if that's the actual cause.* """ ) with gr.Row(): denoise_strength = gr.Slider(0, 1, value=0.75, label="Noise reduction strength") declick_on = gr.Checkbox(value=True, label="Declick / dropout repair") with gr.Row(): dewarble_strength = gr.Slider(0, 1, value=0.3, label="Wow/flutter smoothing") restore_highs_amount = gr.Slider(0, 1, value=0.3, label="High-frequency restoration") with gr.Row(): ref_match_amount = gr.Slider(0, 1, value=0.6, label="Reference spectral (EQ) matching amount") width_match_amount = gr.Slider(0, 1, value=0.6, label="Reference stereo-width matching amount") target_loudness = gr.Slider(-12, 0, value=-1.0, label="Output peak (dBFS)") run_btn = gr.Button("Restore Audio", variant="primary") with gr.Row(): output_audio = gr.Audio(label="Restored cue", type="filepath") with gr.Row(): spec_before = gr.Plot(label="Spectrogram: Before") spec_after = gr.Plot(label="Spectrogram: After") log_out = gr.Textbox(label="Processing log", lines=8) preset_noise_btn.click( fn=apply_preset_broadband_noise, inputs=[], outputs=[ denoise_strength, declick_on, dewarble_strength, restore_highs_amount, ref_match_amount, width_match_amount, target_loudness, ], ) preset_click_btn.click( fn=apply_preset_heavy_click, inputs=[], outputs=[ denoise_strength, declick_on, dewarble_strength, restore_highs_amount, ref_match_amount, width_match_amount, target_loudness, ], ) preset_bw_btn.click( fn=apply_preset_bandwidth_limited, inputs=[], outputs=[ denoise_strength, declick_on, dewarble_strength, restore_highs_amount, ref_match_amount, width_match_amount, target_loudness, ], ) run_btn.click( fn=restore, inputs=[ degraded_in, reference_in, denoise_strength, declick_on, dewarble_strength, restore_highs_amount, ref_match_amount, width_match_amount, target_loudness, ], outputs=[output_audio, spec_before, spec_after, log_out], ) if __name__ == "__main__": demo.launch()