Vansh Chugh commited on
Commit
e3cf774
·
1 Parent(s): ccc8f4d

initial deploy

Browse files
Files changed (7) hide show
  1. .gitignore +5 -0
  2. README.md +13 -5
  3. SOURCES.md +5 -0
  4. app.py +229 -0
  5. model.json +7 -0
  6. packages.txt +1 -0
  7. requirements.txt +3 -0
.gitignore ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ __pycache__/
2
+ *.pyc
3
+ .DS_Store
4
+ .venv/
5
+ StemFX-repo/
README.md CHANGED
@@ -1,13 +1,21 @@
1
  ---
2
  title: StemFX
3
- emoji: 💻
4
- colorFrom: green
5
  colorTo: purple
6
  sdk: gradio
7
- sdk_version: 6.20.0
8
- python_version: '3.13'
9
  app_file: app.py
10
  pinned: false
 
11
  ---
12
 
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
1
  ---
2
  title: StemFX
3
+ emoji: 🎚️
4
+ colorFrom: blue
5
  colorTo: purple
6
  sdk: gradio
7
+ sdk_version: 5.28.0
8
+ python_version: '3.11'
9
  app_file: app.py
10
  pinned: false
11
+ license: mit
12
  ---
13
 
14
+ # StemFX
15
+
16
+ Mixing style transfer: predicts a per-stem effects chain that makes one
17
+ mix sound like a reference mix, then renders the result.
18
+
19
+ Paper: [StemFX: Learning Mixing Style Representations via Autoregressive
20
+ FX Chain Prediction on Source-Separated Stems](https://arxiv.org/abs/2607.15634)
21
+ (ISMIR 2026). Source: [barry-mir/stemfx](https://github.com/barry-mir/stemfx).
SOURCES.md ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ # Sources — StemFX
2
+
3
+ - Source repo: https://github.com/barry-mir/stemfx.git
4
+ - Paper: https://arxiv.org/html/2607.15634v1
5
+ - Hardware: undetermined, started on cpu-basic — run `probe_space.py --resize` once app.py is deployed to decide gpu vs cpu
app.py ADDED
@@ -0,0 +1,229 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import sys
2
+ sys.stdout.reconfigure(line_buffering=True)
3
+
4
+ try:
5
+ import spaces
6
+ except ImportError:
7
+ # keep @spaces.GPU usable as a no-op; ZeroGPU requires this exact name.
8
+ class spaces:
9
+ class GPU:
10
+ def __init__(self, func=None, duration=60):
11
+ self.func = func
12
+
13
+ def __call__(self, *args, **kwargs):
14
+ if self.func is not None:
15
+ return self.func(*args, **kwargs)
16
+ func = args[0]
17
+ return func
18
+
19
+ import os
20
+ import tempfile
21
+
22
+ import numpy as np
23
+ import pyloudnorm as pyln
24
+ import soundfile as sf
25
+ import torch
26
+ import torchaudio
27
+ import gradio as gr
28
+ from pyharp import ModelCard, build_endpoint
29
+
30
+ import stemfx
31
+ from stemfx.separator import SCNetSeparator
32
+ from multiafx import FXChain
33
+
34
+
35
+ SAMPLE_RATE = 44100
36
+ SEGMENT_SAMPLES = SAMPLE_RATE * 10 # StemFX's encoder is trained on fixed 10s clips (stemfx.api.SEGMENT_SECONDS)
37
+ STEM_NAMES = ("vocals", "bass", "drums", "other")
38
+
39
+ DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
40
+
41
+ model_card = ModelCard(
42
+ name="StemFX",
43
+ description=(
44
+ "Predicts a per-stem effects chain that makes one mix sound like a "
45
+ "reference mix, then applies it to the full track. The chain itself "
46
+ "is chosen by listening to only the first 10 seconds of each input "
47
+ "(a limit of the underlying model, trained on 10-second clips) and "
48
+ "then applied uniformly across the whole song -- it won't adapt if "
49
+ "the song's character changes partway through."
50
+ ),
51
+ author="Yuan-Chiao Cheng, Jui-Te Wu, Brian Chen, Yen-Tung Yeh, Yu-Hua Chen, Yi-Hsuan Yang",
52
+ tags=["audio-effects", "mixing", "style-transfer"],
53
+ )
54
+
55
+ _model = None
56
+ _separator = None
57
+
58
+
59
+ def _get_model():
60
+ """Load StemFX on first use, so the CUDA touch (if any) happens inside
61
+ the GPU-attached call, not at import time."""
62
+ global _model
63
+ if _model is None:
64
+ _model = stemfx.load(device=DEVICE)
65
+ return _model
66
+
67
+
68
+ def _get_separator():
69
+ """Load the SCNet stem separator on first use -- same GPU-safety reasoning as _get_model."""
70
+ global _separator
71
+ if _separator is None:
72
+ _separator = SCNetSeparator(device=DEVICE)
73
+ return _separator
74
+
75
+
76
+ def _load_wav(path: str) -> torch.Tensor:
77
+ """Load a wav as a (2, T) float32 tensor at 44.1kHz.
78
+
79
+ Adapted from stemfx.api._load_wav: uses soundfile rather than
80
+ torchaudio.load(), which would pull in torchcodec.
81
+ """
82
+ data, sr = sf.read(path, dtype="float32", always_2d=True)
83
+ audio = torch.from_numpy(data.T.copy())
84
+ if sr != SAMPLE_RATE:
85
+ audio = torchaudio.functional.resample(audio, sr, SAMPLE_RATE)
86
+ if audio.shape[0] == 1:
87
+ audio = audio.repeat(2, 1)
88
+ elif audio.shape[0] > 2:
89
+ audio = audio[:2]
90
+ return audio.float()
91
+
92
+
93
+ def _loudness_normalize(audio: torch.Tensor, target_lufs: float) -> torch.Tensor:
94
+ """Normalize integrated loudness to a target LUFS -- same approach stemfx.api uses internally."""
95
+ meter = pyln.Meter(SAMPLE_RATE)
96
+ audio_np = audio.cpu().numpy().astype(np.float32)
97
+ integrated = meter.integrated_loudness(audio_np.T)
98
+ if not np.isfinite(integrated) or integrated < -70:
99
+ return audio
100
+ out = pyln.normalize.loudness(audio_np.T, integrated, target_lufs).T
101
+ return torch.from_numpy(out.astype(np.float32))
102
+
103
+
104
+ def _pretty_chain(chain: dict) -> str:
105
+ """Render a predicted FX chain as one readable line per stem.
106
+
107
+ Same format as stemfx.api.TransferResult.pretty(), reimplemented here
108
+ because we call model.transfer() directly (chain only, no audio) rather
109
+ than transfer_audio() -- see process_fn's docstring for why.
110
+ """
111
+ def fmt(v):
112
+ return f"{v:.3g}" if isinstance(v, float) else str(v)
113
+
114
+ lines = []
115
+ for stem in STEM_NAMES:
116
+ steps = chain.get(stem, [])
117
+ if not steps:
118
+ lines.append(f" {stem}: (no FX)")
119
+ continue
120
+ chunks = []
121
+ for step in steps:
122
+ eff = step["effect"]
123
+ params = ", ".join(f"{k}={fmt(v)}" for k, v in step.get("params", {}).items())
124
+ chunks.append(f"{eff}({params})" if params else eff)
125
+ lines.append(f" {stem}: " + " -> ".join(chunks))
126
+ return "\n".join(lines)
127
+
128
+
129
+ @spaces.GPU
130
+ @torch.inference_mode()
131
+ def process_fn(
132
+ original_path: str,
133
+ reference_path: str,
134
+ normalize_loudness: bool,
135
+ target_lufs: float,
136
+ ) -> tuple[str, str]:
137
+ """Restyle the full original mix to sound like the reference mix.
138
+
139
+ stemfx's own transfer_audio() separates the full track (same cost as here)
140
+ but then crops rendered output down to 10s. StemFX's encoder was trained on
141
+ fixed 10s clips, but the predicted FX chain is static params that can be applied to any length.
142
+ So here we separate once, embed from a cropped copy, and render on the
143
+ full-length stems; same separation cost and process as trasnfer_audio, but full-length output.
144
+ """
145
+ model = _get_model()
146
+ separator = _get_separator()
147
+
148
+ orig_audio = _load_wav(original_path)
149
+ orig_stems = separator.separate(orig_audio) # full length; embed() below crops its own copy internally
150
+
151
+ ref_audio = _load_wav(reference_path)[:, :SEGMENT_SAMPLES] # only the first 10s of the reference is ever used
152
+ ref_stems = separator.separate(ref_audio)
153
+
154
+ emb_orig = model.embed(orig_stems)
155
+ emb_target = model.embed(ref_stems)
156
+ chain = model.transfer(emb_orig, emb_target)
157
+
158
+ processed = {}
159
+ for stem in STEM_NAMES:
160
+ audio_np = orig_stems[stem].cpu().numpy().astype(np.float32)
161
+ steps = chain.get(stem, [])
162
+ if steps:
163
+ audio_np = FXChain(steps)(audio_np, SAMPLE_RATE)
164
+ processed[stem] = torch.from_numpy(audio_np)
165
+
166
+ if normalize_loudness:
167
+ processed = {k: _loudness_normalize(v, target_lufs) for k, v in processed.items()}
168
+
169
+ mix = sum(processed.values())
170
+ peak = mix.abs().max()
171
+ if peak > 0.95:
172
+ mix = mix * (0.95 / peak)
173
+ if normalize_loudness:
174
+ mix = _loudness_normalize(mix, target_lufs)
175
+
176
+ audio_path = tempfile.NamedTemporaryFile(suffix=".wav", delete=False).name
177
+ sf.write(audio_path, np.ascontiguousarray(mix.cpu().numpy().T), SAMPLE_RATE)
178
+
179
+ chain_path = tempfile.NamedTemporaryFile(suffix=".txt", delete=False).name
180
+ with open(chain_path, "w") as f:
181
+ f.write(
182
+ f"{os.path.basename(original_path)}\n\n"
183
+ "Predicted FX Chain (chosen from first 10s, applied to full track, styled after reference mix)\n"
184
+ f"{_pretty_chain(chain)}\n"
185
+ )
186
+
187
+ return audio_path, chain_path
188
+
189
+
190
+ with gr.Blocks() as demo:
191
+ input_components = [
192
+ gr.Audio(type="filepath", label="Original Mix")
193
+ .harp_required(True)
194
+ .set_info("The mix to restyle. Effects are chosen using its first 10 seconds, then applied to the whole track."),
195
+ gr.Audio(type="filepath", label="Reference Mix")
196
+ .harp_required(True)
197
+ .set_info("The mix whose sound/style to copy. Only its first 10 seconds are used."),
198
+ gr.Checkbox(
199
+ value=True,
200
+ label="Normalize Output Loudness",
201
+ info="Normalize output to a target loudness (default: True, per repo config)",
202
+ ),
203
+ gr.Slider(
204
+ minimum=-36,
205
+ maximum=-9,
206
+ step=0.5,
207
+ value=-23.0,
208
+ label="Target Loudness (LUFS)",
209
+ info="Loudness target used when normalization is enabled (default: -23.0, per repo config)",
210
+ ),
211
+ ]
212
+ output_components = [
213
+ gr.Audio(type="filepath", label="Processed Mix").set_info(
214
+ "Full original mix, re-rendered with the predicted FX chain in the reference's style."
215
+ ),
216
+ gr.File(type="filepath", file_types=[".txt"], label="FX Chain").set_info(
217
+ "Human-readable per-stem effects chain predicted by the model."
218
+ ),
219
+ ]
220
+
221
+ build_endpoint(
222
+ model_card=model_card,
223
+ input_components=input_components,
224
+ output_components=output_components,
225
+ process_fn=process_fn,
226
+ )
227
+
228
+ if __name__ == "__main__":
229
+ demo.queue().launch(pwa=True)
model.json ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "StemFX",
3
+ "package_dir": ".venv/lib/python3.11/site-packages/stemfx",
4
+ "entry_point": "stemfx.load",
5
+ "checkpoint": {"repo": "barry-mir/stemfx-bsfilm", "filename": "best_checkpoint.pt", "size_mb": 109},
6
+ "note": "stemfx is a published, checksummed PyPI package (pip install stemfx, v0.2.0, matches this exact source checkout) -- depended on directly rather than vendored. package_dir points at the installed copy for find_dead_files.py's missing-requirements check, not a Space-repo copy (Step 3's file-copy step was skipped)."
7
+ }
packages.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ sox
requirements.txt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ git+https://github.com/TEAMuP-dev/pyharp.git@develop
2
+ # model-specific deps below:
3
+ stemfx