| """ |
| 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 |
|
|
|
|
| |
| |
| |
|
|
| 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. |
| """ |
| |
| 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 |
| |
| |
| |
| cutoff = 0.5 + (1 - strength) * 4.0 |
| 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) |
|
|
| |
| 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) |
| |
| 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 |
| 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])] |
| |
| 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 |
|
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
|
|
| |
| |
| |
|
|
| 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) |
| 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) |
| 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) |
|
|
| 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, |
| True, |
| 0.25, |
| 0.1, |
| 0.6, |
| 0.5, |
| -1.0, |
| ) |
|
|
|
|
| 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, |
| True, |
| 0.3, |
| 0.35, |
| 0.65, |
| 0.6, |
| -1.0, |
| ) |
|
|
|
|
| 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, |
| True, |
| 0.3, |
| 0.55, |
| 0.7, |
| 0.75, |
| -1.0, |
| ) |
|
|
|
|
| |
| |
| |
|
|
| 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() |
|
|