File size: 8,096 Bytes
90bee07
 
f725827
bf0a6bc
 
 
 
 
90bee07
f725827
90bee07
 
dd40c18
90bee07
bf0a6bc
cf22e8a
bf0a6bc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f725827
 
 
 
c0f1647
bf0a6bc
 
 
 
 
 
 
 
 
 
90bee07
bf0a6bc
c0f1647
 
 
bf0a6bc
f725827
 
 
bf0a6bc
 
 
 
 
 
 
 
 
 
53dd54d
 
bf0a6bc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
90bee07
 
 
 
 
 
 
 
 
 
 
bf0a6bc
 
eedd337
 
 
 
 
 
 
 
90bee07
 
 
bf0a6bc
 
 
 
 
 
90bee07
bf0a6bc
 
 
f725827
 
bf0a6bc
86b1d3f
bf0a6bc
 
86b1d3f
 
90bee07
bf0a6bc
 
90bee07
 
 
 
 
 
 
 
 
86b1d3f
 
90bee07
bf0a6bc
90bee07
86b1d3f
 
 
 
 
 
 
bf0a6bc
 
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
"""
SonicMaster on HARP: text-controlled music restoration and mastering.

This Space is a frontend for the authors' own Space, amaai-lab/SonicMaster, which runs the model on
ZeroGPU. The model decodes through Stable Audio Open's VAE, whose weights are gated, so serving it
from here would hand it to callers who never accepted their terms (see "Calling Another Space" in
HARP's deployment guidelines). Each request is forwarded with the caller's x-ip-token instead,
which bills it to the caller's own ZeroGPU allowance, as a direct call to the authors' Space would.
"""

import tempfile
from pathlib import Path

import gradio as gr
import numpy as np
import soundfile as sf
import soxr
from gradio.context import LocalContext
from gradio_client import Client, handle_file
from gradio_client.exceptions import AppError
from pyharp import ModelCard, Subcategory, build_endpoint

BACKEND = "amaai-lab/SonicMaster"
# Connected to by URL. Given the repository id, gradio_client looks the host up through the Hub's
# API without a token, which the Hub rate-limits from the address a Space shares with others.
BACKEND_URL = "https://amaai-lab-sonicmaster.hf.space"
API_NAME = "/enhance_audio_ui"
# The parameters of API_NAME, in order, as the backend declared them when this frontend was written
BACKEND_PARAMETERS = ["audio_path", "prompt"]

# 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"}


def check_backend():
    """
    Stops the Space from starting if the backend's API no longer matches what process_fn sends.

    An upstream change then shows up as a Space that fails to start, rather than as a puzzle on
    every request. A backend that cannot be reached is only reported, since a sleeping or
    restarting Space is no reason to keep this one down.
    """
    try:
        api = Client(BACKEND_URL, verbose=False).view_api(return_format="dict", print_info=False)
    except Exception as e:
        print(f"SonicMaster: could not reach {BACKEND} to check its API: {e}", flush=True)
        return

    endpoint = api["named_endpoints"].get(API_NAME)
    names = [p.get("parameter_name") for p in endpoint["parameters"]] if endpoint else None
    if names != BACKEND_PARAMETERS:
        raise RuntimeError(f"{BACKEND}{API_NAME} now takes {names}, but this frontend sends "
                           f"{BACKEND_PARAMETERS}. Update process_fn to match.")


model_card = ModelCard(
    name="SonicMaster",
    description=(
        "Text-controlled all-in-one music restoration and mastering: one instruction in plain "
        "language fixes reverb, clipping, tonal balance, dynamics, or the stereo image. The "
        "model is a flow-matching transformer over Stable Audio's latent space, conditioned on "
        "a FLAN-T5 encoding of the instruction, so the wording of the prompt is what selects the "
        "treatment. It runs on the authors' Space (amaai-lab/SonicMaster), to which this Space "
        "forwards each request, so a request spends the caller's own ZeroGPU allowance and "
        "waits in that Space's queue. The model works at 44.1 kHz stereo, and the result is "
        "returned at the sample rate of the input. SonicMaster itself is Apache 2.0, but it "
        "decodes through Stable Audio Open's VAE, so results are also governed by the Stability "
        "AI Community License: "
        "https://huggingface.co/stabilityai/stable-audio-open-1.0/blob/main/LICENSE.md "
        "Repository: https://github.com/AMAAI-Lab/SonicMaster Paper: "
        "https://arxiv.org/abs/2508.03448"
    ),
    author="Jan Melechovsky, Ambuj Mehrish, Dorien Herremans",
    tags=[Subcategory.POST_PROCESSING, "text-guided", "zerogpu", "domain:music"],
)


def _caller_client():
    """A client of the backend carrying the caller's x-ip-token, so that ZeroGPU bills the caller."""
    # The Hub adds x-ip-token to every request a Space receives, naming the caller. PyHARP
    # restores the request inside its worker, so it is read here.
    request = LocalContext.request.get(None)
    ip_token = request.headers.get("x-ip-token") if request is not None else None
    print(f"SonicMaster: forwarding to {BACKEND} "
          f"{'with' if ip_token else 'without'} the caller's x-ip-token", flush=True)
    return Client(BACKEND_URL, headers={"x-ip-token": ip_token} if ip_token else None,
                  verbose=False)


def _in_the_format_of(output_path, input_path, seconds):
    """
    The backend's audio at the rate and in the container of the input, cut to `seconds`, as
    16-bit within full scale. The channels stay as the model made them.
    """
    audio, rate = sf.read(output_path, dtype="float32", always_2d=True)
    input_rate = sf.info(input_path).samplerate
    if rate != input_rate:
        audio = soxr.resample(audio, rate, input_rate)
        rate = input_rate
    audio = audio[: int(round(seconds * rate))]
    peak = float(np.abs(audio).max()) if audio.size else 0.0
    if peak > 1.0:
        audio = audio / peak
    ext = Path(input_path).suffix.lower()
    path = Path(tempfile.mkdtemp()) / f"sonicmaster{ext if ext in LOSSLESS else '.wav'}"
    sf.write(path, audio, rate, subtype="PCM_16")
    return str(path)


def process_fn(input_audio_path, prompt):
    """
    Apply the instruction to the recording.

    Args:
        input_audio_path (str): The recording to edit.
        prompt (str): What to change about it.

    Returns:
        output_path (str): The edited audio.
    """

    # Checked before forwarding, so a request the model cannot take waits in no queue and spends
    # none of the caller's allowance
    if not input_audio_path:
        raise gr.Error("Provide the recording to treat.")
    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 not (prompt or "").strip():
        raise gr.Error("Describe what to change about the audio.")

    try:
        output, status = _caller_client().predict(handle_file(input_audio_path), prompt.strip(),
                                                  api_name=API_NAME)
    except AppError as e:
        # The backend's own message, a quota error included, so the caller sees why
        raise gr.Error(str(e))

    if not output:
        raise gr.Error(status or "SonicMaster returned no audio.")
    return _in_the_format_of(output, input_audio_path, float(info.duration))


# The processing worker imports this file, so the app must not be built there
if __name__ == "__main__":
    check_backend()

    with gr.Blocks() as demo:
        input_components = [
            gr.Audio(type="filepath", label="Input audio").harp_required(True).set_info((
                "The recording to treat. The model works at 44.1 kHz stereo, and a mono input "
                "is heard on both channels."
            )),
            gr.Textbox(label="Instruction", value="Remove the reverb from this recording.",
                       info=(
                           "What to change, in plain language, for example "
                           "\"reduce the reverb\", \"brighten the high end\", "
                           "\"even out the loudness\", or \"widen the stereo "
                           "image\". One instruction at a time works better "
                           "than several combined."
                       )),
        ]
        output_components = [
            gr.Audio(type="filepath", label="Treated audio").set_info((
                "The result, at 16-bit, at the sample rate and in the container of the input."
            )),
        ]
        build_endpoint(
            model_card=model_card,
            input_components=input_components,
            output_components=output_components,
            process_fn=process_fn,
        )

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