File size: 10,891 Bytes
f626c4a
 
ed17409
64638ca
 
 
 
e89501c
f626c4a
3ce8224
3d8ce40
64638ca
e13f695
9a89e2a
 
 
997cf76
 
64638ca
 
 
 
997cf76
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f626c4a
53ce9f0
 
c31ecc7
 
 
 
 
 
 
 
 
 
 
e619a16
c31ecc7
 
 
f626c4a
 
 
 
 
 
 
 
 
64638ca
9a89e2a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
64638ca
ed17409
 
2e68f3b
 
 
 
f626c4a
 
 
ed17409
 
 
 
f626c4a
 
9a89e2a
f626c4a
 
 
 
 
 
 
 
4ceb571
 
f626c4a
 
 
 
 
 
e13f695
53ce9f0
e13f695
f626c4a
2e960ad
 
 
 
 
f626c4a
 
4c6f562
 
 
6e74851
2e960ad
 
 
 
 
 
e13f695
f626c4a
64638ca
9a89e2a
 
 
 
 
 
 
 
 
64638ca
9a89e2a
 
 
 
64638ca
10ef29b
 
 
 
 
 
4c6f562
64638ca
 
 
f626c4a
 
 
 
 
 
 
 
 
 
 
 
 
295b938
 
 
f4061db
 
 
 
295b938
 
 
 
 
 
 
 
 
 
 
 
 
 
ac5cb0b
295b938
 
 
 
 
 
 
 
 
306b9ee
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
import spaces  # imported before torch, as ZeroGPU requires

import math
import os
import tempfile
import soundfile as sf
import torch
from audiosr import build_model, super_resolution_long_audio
from huggingface_hub import hf_hub_download
from pyharp import ModelCard, build_endpoint, SampleRate, Channels
from pyharp.tags import Subcategory
import gradio as gr
import random
import numpy as np
import librosa
from scipy import signal
import audiosr.pipeline as _audiosr_pipeline
from audiosr.utils import _locate_cutoff_freq, lowpass as _audiosr_lowpass

os.environ["TOKENIZERS_PARALLELISM"] = "true"
torch.set_float32_matmul_precision("high")

def _patched_lowpass_filtering_prepare_inference(dl_output):
    """
    Patches a boundary bug in audiosr==0.0.7: cutoff_freq caps at exactly
    24000 Hz (both via normal detection saturating there, and via the
    "give up" fallback for quiet/narrowband audio). sampling_rate for this
    step is hardcoded to 48000 upstream, so nyquist == 24000 exactly,
    producing Wn == cutoff_freq / nyquist == 1.0 for a wide range of real
    inputs. scipy's iirfilter requires 0 < Wn < 1 strictly. Clamped with a
    small safety margin below Nyquist instead of patching the pip package.
    """
    waveform = dl_output["waveform"]
    sampling_rate = dl_output["sampling_rate"]

    cutoff_freq = (_locate_cutoff_freq(dl_output["stft"], percentile=0.985) / 1024) * 24000
    if cutoff_freq < 1000:
        cutoff_freq = 24000

    nyquist = 0.5 * sampling_rate
    if cutoff_freq >= nyquist:
        cutoff_freq = nyquist * 0.98  # safety margin

    order = 8
    ftype = np.random.choice(["butter", "cheby1", "ellip", "bessel"])
    filtered_audio = _audiosr_lowpass(
        waveform.numpy().squeeze(), highcut=cutoff_freq, fs=sampling_rate, order=order, _type=ftype,
    )
    filtered_audio = torch.FloatTensor(filtered_audio.copy()).unsqueeze(0)

    if waveform.size(-1) <= filtered_audio.size(-1):
        filtered_audio = filtered_audio[..., : waveform.size(-1)]
    else:
        filtered_audio = torch.nn.functional.pad(filtered_audio, (0, waveform.size(-1) - filtered_audio.size(-1)))

    return {"waveform_lowpass": filtered_audio}

_audiosr_pipeline.lowpass_filtering_prepare_inference = _patched_lowpass_filtering_prepare_inference

DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
model = None

model_card = ModelCard(
    name="AudioSR",
    description=(
        "Upsample any audio to 48kHz using audio super-resolution. Output is 48 "
        "kHz, which is the point of the model rather than a change of format. "
        "Super-resolution runs in mono, so a stereo input's result is copied "
        "across its channels and carries no stereo image. Repository: "
        "https://github.com/haoheliu/versatile_audio_super_resolution Paper: "
        "https://arxiv.org/abs/2309.07314"
    ),
    author="Haohe Liu, Ke Chen, Qiao Tian, Wenwu Wang, Mark D. Plumbley",
    tags=[Subcategory.BANDWIDTH_EXTENSION, SampleRate(48000), Channels(1), 'domain:music', 'domain:speech', 'domain:general-audio'],
)


# Built in the worker, which runs process_fn, on its main thread, where ZeroGPU's emulation records
# the CUDA placement, so that no request spends the caller's allowance on building it. The server
# never runs process_fn. audiosr downloads its checkpoint unpinned, and ignores a path it is given,
# so the pinned file, which the README preloads, is handed to it in place of its own download.
if __name__ != "__main__":
    _checkpoint = hf_hub_download("haoheliu/audiosr_basic", "pytorch_model.bin",
                                  revision="74a47f49061a1e788e968cc43ad45c0b6243f37d")
    _audiosr_pipeline.download_checkpoint = lambda model_name: _checkpoint
    model = build_model(model_name="basic", device=DEVICE)

def match_array_shapes(array_1: np.ndarray, array_2: np.ndarray):
    if (len(array_1.shape) == 1) & (len(array_2.shape) == 1):
        if array_1.shape[0] > array_2.shape[0]:
            array_1 = array_1[:array_2.shape[0]]
        elif array_1.shape[0] < array_2.shape[0]:
            array_1 = np.pad(array_1, ((array_2.shape[0] - array_1.shape[0], 0)), 'constant', constant_values=0)
    else:
        if array_1.shape[1] > array_2.shape[1]:
            array_1 = array_1[:,:array_2.shape[1]]
        elif array_1.shape[1] < array_2.shape[1]:
            padding = array_2.shape[1] - array_1.shape[1]
            array_1 = np.pad(array_1, ((0,0), (0,padding)), 'constant', constant_values=0)
    return array_1

def lr_filter(audio, cutoff, filter_type, order=12, sr=48000):
    audio = audio.T
    nyquist = 0.5 * sr
    normal_cutoff = cutoff / nyquist
    b, a = signal.butter(order//2, normal_cutoff, btype=filter_type, analog=False)
    sos = signal.tf2sos(b, a)
    filtered_audio = signal.sosfiltfilt(sos, audio)
    return filtered_audio.T


# The audio is upsampled in 15-second chunks that start 13 seconds apart, each at the number of
# DDIM steps asked for, so the cost grows with both
# PyHARP stops a job that runs past its timeout_s, 15 minutes by default, so a request whose
# reservation would exceed that is refused rather than started. A shorter one is left to
# ZeroGPU, which admits it when the caller's own allowance covers it.
MAX_GPU_SECONDS = 900


def gpu_seconds(input_audio_path: str, ddim_steps: int, guidance_scale: float, seed: int) -> float:
    # Measured at about 34 s for 8 s of audio (one chunk) and 47 s for 30 s (three) at 50 steps,
    # after about 15 s of setup, padded
    chunks = math.ceil(sf.info(input_audio_path).duration / 13)
    return 25 + 12 * chunks * int(ddim_steps) / 50


def process_fn(input_audio_path: str, ddim_steps: int, guidance_scale: float, seed: str, multiband_ensemble: bool, input_cutoff: int) -> str:
    # Checked before the GPU is reserved, so a request the model cannot take spends none of the
    # caller's ZeroGPU allowance
    if not input_audio_path:
        raise gr.Error("Provide the audio to upsample.")
    try:
        info = sf.info(input_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.")
    if gpu_seconds(input_audio_path, ddim_steps, guidance_scale, 0) > MAX_GPU_SECONDS:
        raise gr.Error(
            f"{int(ddim_steps)} DDIM steps on {info.duration:.0f} s of audio need more GPU time than "
            "a request can reserve. Lower the steps or trim the audio."
        )

    try:
        seed_val = int(seed) if seed and seed.strip() not in ("0", "") else random.randint(1, 2**32 - 1)
    except (TypeError, ValueError):
        seed_val = random.randint(1, 2**32 - 1)

    # AudioSR's internal cutoff-frequency detection (lowpass_filtering_prepare_inference)
    # hardcodes a 24kHz-Nyquist assumption regardless of actual input sample rate,
    # which can produce an invalid filter Wn and crash on input below 48kHz.
    # Resample up front so that assumption always holds.
    orig_sr = info.samplerate
    original_channels = info.channels
    # 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.
    input_ext = os.path.splitext(input_audio_path)[1].lower()
    output_ext = input_ext if input_ext in {".wav", ".flac"} else ".wav"
    if orig_sr < 48000:
        y, _ = librosa.load(input_audio_path, sr=None, mono=False)
        y = librosa.resample(y, orig_sr=orig_sr, target_sr=48000)
        resampled_path = tempfile.mktemp(suffix=".wav")
        sf.write(resampled_path, y.T if y.ndim > 1 else y, samplerate=48000)
        input_audio_path = resampled_path

    output = upsample(input_audio_path, int(ddim_steps), float(guidance_scale), seed_val)

    if multiband_ensemble:
        crossover_freq = int(input_cutoff) - 1000
        low, _ = librosa.load(input_audio_path, sr=48000, mono=True)
        out = output.squeeze() if output.ndim > 1 else output
        out = match_array_shapes(out, low)
        low = lr_filter(low, crossover_freq, 'lowpass', order=10)
        high = lr_filter(out, crossover_freq, 'highpass', order=10)
        high = lr_filter(high, 23000, 'lowpass', order=2)
        output = low + high
    else:
        if output.shape[0] == 1:
            output = output.squeeze(0)
        else:
            output = output.T

    # Super-resolution runs in mono, so a stereo input comes back as one
    # channel; it is copied across the input's channels rather than left mono,
    # which a session on a stereo track would otherwise misread
    if original_channels > 1 and output.ndim == 1:
        output = np.tile(output[:, None], (1, original_channels))

    output_path = tempfile.mktemp(suffix=output_ext)
    sf.write(output_path, output, samplerate=48000)
    return output_path


@spaces.GPU(duration=gpu_seconds)
@torch.inference_mode()
def upsample(input_audio_path: str, ddim_steps: int, guidance_scale: float, seed: int):
    waveform = super_resolution_long_audio(
        model,
        input_audio_path,
        seed=seed,
        guidance_scale=guidance_scale,
        ddim_steps=ddim_steps,
    )
    return waveform.cpu().numpy()

if __name__ == "__main__":
    with gr.Blocks() as demo:
        input_components = [
            gr.Audio(type="filepath", label="Input Audio").harp_required(True).set_info((
                "Low-bandwidth audio to upsample. The result is written at 48 kHz "
                "whatever the input rate was."
            )),
            gr.Slider(minimum=10, maximum=500, step=10, value=50,
                      label="DDIM Steps",
                      info="More steps = better quality but slower"),
            gr.Slider(minimum=1.0, maximum=20.0, step=0.5, value=3.5,
                      label="Guidance Scale",
                      info="Higher values follow the conditioning more closely"),
            gr.Textbox(value="0", label="Seed", info="0 = random seed"),
            gr.Checkbox(value=False, label="Multiband Ensemble",
                        info="Blend original low frequencies with upsampled highs"),
            gr.Slider(minimum=4000, maximum=20000, step=1000, value=12000,
                      label="Input Cutoff (Hz)",
                      info="Crossover frequency for multiband ensemble"),
        ]
        output_components = [
            gr.Audio(type="filepath", label="Upsampled Audio").set_info("Audio upsampled to 48kHz."),
        ]
        app = build_endpoint(
            model_card=model_card,
            input_components=input_components,
            output_components=output_components,
            process_fn=process_fn,
        )

    print("Launching Gradio...", flush=True)
    demo.queue().launch(server_name="0.0.0.0", server_port=7860, show_error=True, pwa=True, ssr_mode=False)