File size: 9,167 Bytes
6e4a726
 
2e92c2b
6e4a726
2e92c2b
 
 
 
4061195
2e92c2b
da9e7a7
4eea459
2e92c2b
 
 
 
 
 
4061195
 
 
 
 
 
 
 
 
 
 
f3847d2
4061195
 
 
 
f99c093
2e92c2b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b4c5f79
 
2e92c2b
4061195
2e92c2b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4061195
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2e92c2b
4061195
2e92c2b
4061195
 
 
 
 
 
 
 
 
 
 
2e92c2b
4061195
2e92c2b
 
 
 
 
64b7761
2e92c2b
 
64b7761
2e92c2b
 
 
 
 
 
c63fab9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2e92c2b
6e4a726
 
 
 
 
2674bc9
6e4a726
 
2674bc9
 
6e4a726
4061195
 
 
 
 
 
 
 
2e92c2b
6e4a726
 
4061195
6e4a726
2e92c2b
 
 
 
 
 
 
 
 
 
 
64b7761
2e92c2b
 
 
 
 
 
 
 
 
 
 
1af61b0
2e92c2b
c63fab9
4061195
2e92c2b
 
 
 
f582d8c
 
2e92c2b
267b6f0
 
 
 
 
2e92c2b
f582d8c
 
 
 
 
2e92c2b
74a0d0b
2e92c2b
f582d8c
 
 
 
 
 
 
2e92c2b
06f6cf5
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
import spaces  # imported first, as ZeroGPU requires

import gradio as gr
import soundfile as sf
import torch
import torchaudio
import random
import tempfile
from pathlib import Path

from pyharp import ModelCard, build_endpoint, SampleRate, Channels
from pyharp.tags import Subcategory
from huggingface_hub import hf_hub_download

from remfx.models import RemFX, DemucsModel, DCUNetModel, FXClassifier
from remfx.classifier import Cnn14
from remfx.effects import Pedalboard_Effects

model_card = ModelCard(
    name="RemFX",
    description=(
        "Detects and removes audio effects from recordings. The model is "
        "fixed-rate and mono, so the result is resampled back to the input's "
        "rate and copied across its channels: the output has the format it came "
        "in with, but no stereo image. Repository: "
        "https://github.com/mhrice/RemFx Paper: "
        "https://arxiv.org/abs/2308.16177"
    ),
    author="Matthew Rice, Christian J. Steinmetz, George Fazekas, Joshua D. Reiss",
    tags=[Subcategory.EFFECT_REMOVAL, SampleRate(48000), Channels(1), 'domain:music'],
)

# A lossless input keeps its container. A lossy one comes back as WAV, since encoding the result
# lossily again would expose the artifacts of the first encode and add a second set.
LOSSLESS = {".wav", ".flac"}

# ---- config ----

SAMPLE_RATE = 48000 # from remfx_detect.yaml
NUM_BINS = 1025       # n_fft // 2 + 1, where stft n_fft=2048
HF_REPO = "vanshc/remfx-checkpoints"
DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")

# order in which effects are removed; shuffled at runtime (matches training config)
EFFECTS_ORDER = [
    "RandomPedalboardDistortion",
    "RandomPedalboardCompressor",
    "RandomPedalboardReverb",
    "RandomPedalboardChorus",
    "RandomPedalboardDelay",
]


# ---- model loading ----

def download_and_load(model, ckpt_filename):
    ckpt_path = hf_hub_download(repo_id=HF_REPO, filename=ckpt_filename,
                                revision="c860ca07d4e4fa8d88700c1942c450a98ffd8943")
    # checkpoints contain the full pytorch-lightning state dict
    state_dict = torch.load(ckpt_path, map_location="cpu")["state_dict"]
    model.load_state_dict(state_dict)
    return model.to(DEVICE).eval()


def build_effect_model(network, ckpt_filename):
    # RemFX is the lightning wrapper around each effect-removal network
    model = RemFX(
        lr=1e-4, lr_beta1=0.95, lr_beta2=0.999, lr_eps=1e-6,
        lr_weight_decay=1e-3, sample_rate=SAMPLE_RATE, network=network,
    )
    return download_and_load(model, ckpt_filename)


# demucs handles distortion + compressor (time-domain)
# dcunet handles reverb, chorus, delay (frequency-domain)
# params come from cfg/exp/remfx_detect.yaml and cfg/model/demucs.yaml + dcunet.yaml

demucs_kwargs = dict(sources=["mixture"], audio_channels=1, nfft=4096, channels=48) # from demucs.yaml
dcunet_kwargs = dict(architecture="Large-DCUNet-20", stft_kernel_size=512, fix_length_mode="pad") # from dcunet.yaml

# pyharp runs process_fn in a worker, which imports this file as __mp_main__ and starts with the
# server, so every model is loaded there alone
if __name__ != "__main__":
    print("Loading effect models...")

    effect_models = {
        "RandomPedalboardDistortion": build_effect_model(
            DemucsModel(SAMPLE_RATE, **demucs_kwargs), "demucs_distortion_aug.ckpt"
        ),
        "RandomPedalboardCompressor": build_effect_model(
            DemucsModel(SAMPLE_RATE, **demucs_kwargs), "demucs_compressor_aug.ckpt"
        ),
        "RandomPedalboardReverb": build_effect_model(
            DCUNetModel(SAMPLE_RATE, NUM_BINS, **dcunet_kwargs), "dcunet_reverb_aug.ckpt"
        ),
        "RandomPedalboardChorus": build_effect_model(
            DCUNetModel(SAMPLE_RATE, NUM_BINS, **dcunet_kwargs), "dcunet_chorus_aug.ckpt"
        ),
        "RandomPedalboardDelay": build_effect_model(
            DCUNetModel(SAMPLE_RATE, NUM_BINS, **dcunet_kwargs), "dcunet_delay_aug.ckpt"
        ),
    }

    print("Loading classifier...")

    cnn14 = Cnn14( # taken from remfx_detect.yaml
        num_classes=5,
        n_fft=2048, hop_length=512, n_mels=128,
        sample_rate=SAMPLE_RATE, model_sample_rate=SAMPLE_RATE,
        specaugment=True,
    )
    classifier = FXClassifier( # taken from remfx_detect.yaml
        lr=3e-4, lr_weight_decay=1e-3, sample_rate=SAMPLE_RATE,
        mixup=False, network=cnn14,
    )
    classifier = download_and_load(classifier, "classifier.ckpt")

    print("All models loaded.")


# ---- audio helpers ----

def load_and_prep(audio_path):
    audio, sr = torchaudio.load(audio_path)
    if sr != SAMPLE_RATE:
        audio = torchaudio.transforms.Resample(sr, SAMPLE_RATE)(audio)
    audio = audio.mean(dim=0, keepdim=True)  # stereo to mono: (1, T)
    audio = audio.unsqueeze(0)               # add batch dim for model: (1, 1, T)
    return audio.to(DEVICE)


# ---- inference ----

def match_input_format(output_path: str, input_path: str) -> str:
    """
    Rewrite an output file at the sample rate and channel count it came in with.

    The models here are fixed-rate and mono, and hand the writing to vendored
    upstream code, so the format is corrected afterwards rather than threaded
    through inference. Duplicated channels are copies, not a recovered stereo
    image; the model card says so.

    Args:
        output_path (str): File the model wrote. Overwritten in place.
        input_path (str): File the request supplied.

    Returns:
        output_path (str): The same path, for use in a return statement.
    """

    import librosa
    import numpy as np
    import soundfile

    want = soundfile.info(input_path)
    have = soundfile.info(output_path)

    if have.samplerate == want.samplerate and have.channels == want.channels:
        return output_path

    data, rate = soundfile.read(output_path, always_2d=True)

    if rate != want.samplerate:
        data = librosa.resample(
            data.T, orig_sr=rate, target_sr=want.samplerate
        ).T

    if data.shape[1] == 1 and want.channels > 1:
        data = np.tile(data, (1, want.channels))
    elif data.shape[1] > 1 and want.channels == 1:
        data = data.mean(axis=1, keepdims=True)

    soundfile.write(output_path, data, want.samplerate)

    return output_path


def process_fn(audio_path, threshold):
    # Checked before the GPU is reserved, so a request the model cannot take spends none of the
    # caller's ZeroGPU allowance
    if not audio_path:
        raise gr.Error("Provide the audio to clean.")
    try:
        info = sf.info(audio_path)
    except Exception as exc:
        raise gr.Error(f"Could not read the audio file: {exc}") from exc
    if info.frames == 0:
        raise gr.Error("The audio file is empty.")

    out_path = match_input_format(remove_effects(audio_path, threshold), audio_path)

    input_ext = Path(audio_path).suffix.lower()
    if input_ext in LOSSLESS and input_ext != ".wav":
        data, rate = sf.read(out_path, always_2d=True)
        out_path = str(Path(out_path).with_suffix(input_ext))
        sf.write(out_path, data, rate)
    return out_path


@spaces.GPU(duration=120)
@torch.inference_mode()
def remove_effects(audio_path, threshold):
    audio = load_and_prep(audio_path)

    outputs = classifier(audio)
    confidences = torch.hstack(outputs).squeeze(0)

    effects_to_remove = {
        Pedalboard_Effects[i].__name__
        for i, conf in enumerate(confidences)
        if conf.item() > threshold
    }

    # shuffle removal order (inference_effects_shuffle is set to True in training config)
    order = EFFECTS_ORDER.copy()
    random.shuffle(order)

    elem = audio
    for effect in order:
        if effect in effects_to_remove:
            # .model is the inner network (DemucsModel or DCUNetModel) inside RemFX
            elem = effect_models[effect].model.sample(elem)

    with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as f:
        out_path = f.name   

    torchaudio.save(out_path, elem.squeeze(0).cpu(), sample_rate=SAMPLE_RATE)

    return out_path


# ---- gradio ui ----

if __name__ == "__main__":
    with gr.Blocks() as demo:

        audio_input = gr.Audio(type="filepath", label="Input Audio").harp_required(True).set_info((
            "A recording with effects on it. The model detects which effects "
            "are present and removes them, so a dry recording has nothing to "
            "strip."
        ))

        threshold = gr.Slider(
            minimum=0.0, maximum=1.0, step=0.1, value=0.5,
            label="Detection Threshold",
            info="Effects with confidence above this value will be removed",
        )

        output_audio = gr.Audio(type="filepath", label="Effects Removed").set_info("Effect-removed audio.")

        # build_endpoint adds the process / cancel / view controls buttons for HARP
        build_endpoint(
            model_card=model_card,
            input_components=[audio_input, threshold],
            output_components=[output_audio],
            process_fn=process_fn,
        )

    demo.queue().launch(pwa=True, show_error=True, ssr_mode=False)