ibrhr commited on
Commit
2ea6124
Β·
verified Β·
1 Parent(s): 6bed623

Add MDX-Net LiteRT fp16 models, deterministic export script, and standalone inference guide

Browse files
.gitattributes CHANGED
@@ -33,3 +33,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ example.mp3 filter=lfs diff=lfs merge=lfs -text
LICENSE ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Gyoom
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
22
+
23
+ ---
24
+
25
+ The model weights are derived from the Ultimate Vocal Remover (UVR) MDX-Net
26
+ models by Anjok07 (UVR-MDXNET 9482) and Kimberley Jensen (UVR-MDX-NET Voc FT),
27
+ distributed under the MIT License via https://github.com/TRvlvr/model_repo and
28
+ mirrored at https://huggingface.co/Politrees/UVR_resources. This repository
29
+ redistributes format-converted (ONNX -> LiteRT fp16) copies under the same
30
+ MIT terms.
README.md CHANGED
@@ -1,3 +1,256 @@
1
  ---
2
  license: mit
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3
  ---
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
  license: mit
3
+ pipeline_tag: audio-to-audio
4
+ base_model:
5
+ - Politrees/UVR_resources
6
+ base_model_relation: quantized
7
+ tags:
8
+ - audio
9
+ - audio-to-audio
10
+ - music-source-separation
11
+ - stem-separation
12
+ - vocal-separation
13
+ - mdx-net
14
+ - uvr
15
+ - tflite
16
+ - litert
17
+ - on-device
18
+ - android
19
  ---
20
+
21
+ # UVR MDX-Net β†’ LiteRT (on-device vocal separation)
22
+
23
+ Two **UVR MDX-Net** vocal separators, converted to **LiteRT** (`.tflite`) as **fp16** and made to
24
+ delegate *whole* to a mobile **GPU** β€” the models that power on-device vocal/instrumental separation in
25
+ the MusicStemSeparation Android app. This repo ships the models plus everything needed to *run* and
26
+ *reproduce* them:
27
+
28
+ - **`separate.py`** β€” a self-contained, runnable Python reference (numpy STFT + LiteRT). WAV in β†’
29
+ `vocals` + `instrumental` WAV out.
30
+ - **`export/export_mdx_litert.py`** — the single, deterministic ONNX→LiteRT-fp16 export script (it
31
+ reproduces the shipped `.tflite` **byte-for-byte**).
32
+ - A documented **Android (Kotlin) integration** recipe below — the real CPU→GPU accelerator ladder.
33
+
34
+ > **Not int8.** `base_model_relation: quantized` is HF's closest label; these are **fp16** precision +
35
+ > format (ONNX→LiteRT) conversions of the original UVR MDX-Net weights, not integer-quantized models.
36
+
37
+ ## Models
38
+
39
+ | Model | Params | `dim_f` | `n_fft` | `hop` | Size | Notes |
40
+ |---|---|---|---|---|---|---|
41
+ | **`UVR_MDXNET_9482.fp16acc.tflite`** | 7.4 M | 2048 | 4096 | 1024 | ~28 MB | general, fast (default) |
42
+ | **`UVR-MDX-NET-Voc_FT.fp16acc.tflite`** | 16.7 M | 3072 | 6144 | 1024 | ~64 MB | vocal fine-tune, higher quality |
43
+
44
+ Both run a native **256-frame** segment (`dim_t = 256`, β‰ˆ 5.9 s @ 44.1 kHz). The model predicts the
45
+ **vocal** spectrogram; the **instrumental is the free residual** `mix βˆ’ vocals`.
46
+
47
+ ## Quickstart
48
+
49
+ ```bash
50
+ pip install -r requirements.txt # numpy + ai-edge-litert (WAV IO is stdlib)
51
+
52
+ # input must be WAV β€” convert first if needed:
53
+ ffmpeg -i example.mp3 example.wav
54
+
55
+ python separate.py example.wav # 9482 (default) β†’ example_vocals.wav + example_instrumental.wav
56
+ python separate.py example.wav --model voc_ft # higher-quality model
57
+ python separate.py example.wav --model voc_ft --denoise --out-dir out/
58
+ ```
59
+
60
+ `separate.py` runs the model on the **CPU** (via `ai-edge-litert`/XNNPACK) β€” a correct, portable
61
+ reference. The fp16 speedup and the GPU/NPU path are Android-only (see below).
62
+
63
+ ## Model I/O contract (the one thing to get right)
64
+
65
+ ```
66
+ input = OUTPUT = float32 tensor [1, 4, dim_f, 256] (NCHW)
67
+ ```
68
+ The 4 channels are **complex-as-channels**, plane order **`[L_re, L_im, R_re, R_im]`**
69
+ (real plane = `2Β·c`, imag plane = `2Β·c + 1`), flat row-major index `((planeΒ·dim_f) + bin)Β·256 + frame`.
70
+
71
+ - The STFT keeps bins `[0, dim_f)` of the `n_fft/2 + 1` one-sided bins β€” i.e. **the Nyquist bin is
72
+ dropped** (`dim_f = n_fft/2`); on the inverse it is zero-padded back.
73
+ - Voc FT carries **no embedded STFT metadata** β€” derive `n_fft = dim_f Β· 2`.
74
+ - A wrong packing (swapped re/im planes, a kept Nyquist bin, a stray `1/√n`) produces
75
+ *plausible-but-wrong* audio, not an error. If a stem sounds off, look here first.
76
+
77
+ ## How it works β€” STFT *outside* the graph
78
+
79
+ The `.tflite` is the **learned core only** β€” a convolutional U-Net that maps a spectrogram to the vocal
80
+ spectrogram. The STFT, chunking, overlap-add and inverse STFT are ordinary CPU code (numpy in
81
+ `separate.py`, Kotlin in the app). Per chunk:
82
+
83
+ ```
84
+ STFT (host CPU) β†’ inference (LiteRT: CPU / GPU / NPU) β†’ iSTFT (host CPU) β†’ weighted overlap-add
85
+ ```
86
+
87
+ This is a faithful reimplementation of UVR's `mdx.py` demix: periodic Hann window, `center=True` reflect
88
+ padding, **unnormalized** (`torch.stft(normalized=False)`), a fixed **10%** crossfade between chunks
89
+ (set to 0 for the classic hard-tiled concat), and the free instrumental residual. Because the accelerator
90
+ only ever sees the learned core, the model delegates cleanly and every stage is independently timed.
91
+
92
+ ## Android on-device inference (XNNPACK ↔ GPU)
93
+
94
+ The app picks the accelerator **by availability**, not by assumption. LiteRT exposes two APIs, and each
95
+ rung uses the right one:
96
+
97
+ - **XNNPACK (fp16) / built-in CPU** β†’ the classic `org.tensorflow.lite.Interpreter`.
98
+ - **GPU** (and NPU) β†’ the newer `com.google.ai.edge.litert.CompiledModel` + `Accelerator.GPU`.
99
+
100
+ `CompiledModel` is **all-or-nothing** β€” one unsupported op fails the *whole* compile (that is exactly why
101
+ the export un-fuses `TRANSPOSE_CONV`, see below). And whether the GPU delegate accepts a model can't be
102
+ known without trying (*coverage-gated, not accelerator-exists*). So: **build each rung, run a warm-up
103
+ inference, and keep the first that survives**, falling back `GPU β†’ XNNPACK β†’ CPU`.
104
+
105
+ **Gradle**
106
+ ```kotlin
107
+ dependencies {
108
+ implementation("com.google.ai.edge.litert:litert:2.1.6") // GPU accelerator .so ships inside this AAR
109
+ }
110
+ android {
111
+ androidResources { noCompress += "tflite" } // let LiteRT memory-map the model
112
+ // ABIs: arm64-v8a (+ x86_64 for the emulator)
113
+ }
114
+ ```
115
+
116
+ **Snippet A β€” XNNPACK / CPU via `Interpreter`** (fp16 engages automatically on ARMv8.2-FP16 cores)
117
+ ```kotlin
118
+ import org.tensorflow.lite.Interpreter
119
+ import java.io.RandomAccessFile
120
+ import java.nio.channels.FileChannel
121
+
122
+ val mapped = RandomAccessFile(tflitePath, "r").use { raf ->
123
+ raf.channel.map(FileChannel.MapMode.READ_ONLY, 0, raf.length()) // mmap the .tflite
124
+ }
125
+ val interpreter = Interpreter(mapped, Interpreter.Options().apply {
126
+ setNumThreads(bigCoreCount) // e.g. the device's performance-core count
127
+ setUseXNNPACK(true) // fp16 kicks in via the model's reduced_precision_support flag
128
+ })
129
+ // per chunk: input/output are direct float ByteBuffers of 1*4*dim_f*256 floats
130
+ interpreter.run(inputBuffer, outputBuffer)
131
+ ```
132
+
133
+ **Snippet B β€” GPU via `CompiledModel`** (fp16 is the GPU's native, ~2Γ— precision; MDX is fp16-safe)
134
+ ```kotlin
135
+ import com.google.ai.edge.litert.Accelerator
136
+ import com.google.ai.edge.litert.CompiledModel
137
+ import com.google.ai.edge.litert.Environment
138
+
139
+ val env = Environment.create()
140
+ val model = CompiledModel.create(
141
+ tflitePath,
142
+ CompiledModel.Options(Accelerator.GPU).apply {
143
+ gpuOptions = CompiledModel.GpuOptions(precision = CompiledModel.GpuOptions.Precision.FP16)
144
+ },
145
+ env,
146
+ )
147
+ val inputs = model.createInputBuffers()
148
+ val outputs = model.createOutputBuffers()
149
+ // per chunk:
150
+ inputs[0].writeFloat(chunkFloats) // 1*4*dim_f*256, [L_re, L_im, R_re, R_im]
151
+ model.run(inputs, outputs)
152
+ val vocals = outputs[0].readFloat()
153
+ // NPU: swap Accelerator.NPU + QualcommOptions(HtpPerformanceMode.SUSTAINED_HIGH_PERFORMANCE);
154
+ // needs a vendor runtime (hardware-gated β€” see "NPU" below).
155
+ ```
156
+
157
+ **Snippet C β€” the fallback ladder** (build + warm-up-run each rung; keep the first that survives)
158
+ ```kotlin
159
+ fun createEngine(tflitePath: String): Engine {
160
+ for (backend in listOf(Backend.GPU, Backend.XNNPACK, Backend.CPU)) {
161
+ runCatching {
162
+ val engine = build(backend, tflitePath) // Snippet A or B
163
+ engine.warmUp() // one inference on a zeroed input
164
+ engine
165
+ }.onSuccess { return it }
166
+ .onFailure { Log.w(TAG, "backend $backend failed; falling back", it) }
167
+ }
168
+ error("no backend available") // the built-in CPU floor is always last and always works
169
+ }
170
+ ```
171
+
172
+ The full production version (fallback-reason reporting, the NPU rung, warm-up gating, the `MdxEngine`
173
+ seam) lives in the app's `tools/mdxlitert/pipeline/MdxLiteRtEngine.kt`.
174
+
175
+ ## Why LiteRT β€” the acceleration story
176
+
177
+ **MDX-Net delegates whole.** It is a clean single-domain conv U-Net β€” no attention, no LSTM, no
178
+ whole-tensor norm β€” which is exactly the op set XNNPACK, the GPU (ML Drift / OpenCL) and (on capable
179
+ SoCs) the NPU map end-to-end. With the STFT pulled out of the graph, the accelerator sees only convs,
180
+ transposed-convs and the TDF fully-connected blocks, so the whole graph delegates in one partition.
181
+
182
+ **fp16 is the ~2Γ— lever and it's safe here.** fp16 (not int8) is the quality-safe speedup, but only for a
183
+ model with no whole-tensor reduction to overflow the fp16 ceiling (65504). MDX has only per-channel
184
+ BatchNorm, which folds into the conv β€” nothing overflows:
185
+
186
+ | Model | peak activation | fp16 ceiling | headroom |
187
+ |---|---|---|---|
188
+ | 9482 | 559 | 65504 | **117Γ—** |
189
+ | Voc FT | 1384 | 65504 | **47Γ—** |
190
+
191
+ fp16 is requested via the `.tflite` metadata flag `reduced_precision_support = "fp16accfp16"` (the fast
192
+ accumulate value β€” measured quality-safe for MDX), which lets XNNPACK run the delegated ops in fp16 on
193
+ ARMv8.2-FP16 cores.
194
+
195
+ ### The GPU fix β€” un-fuse `TRANSPOSE_CONV`'s ReLU
196
+
197
+ The converter fuses each decoder `ConvTranspose + BN + ReLU` into one `TRANSPOSE_CONV` op with a *fused
198
+ ReLU* β€” and a fused activation is precisely what tags the op **v4**. LiteRT 2.1.6's OpenCL delegate caps
199
+ `TRANSPOSE_CONV` at **v3**, whose semantics have *no* fused activation. Because `CompiledModel` is
200
+ all-or-nothing, that lone v4 op fails the whole GPU compile.
201
+
202
+ The export **un-fuses** it: each `TRANSPOSE_CONV(+fused ReLU)` becomes a legitimately-v3 bias-only
203
+ `TRANSPOSE_CONV` followed by a standalone `RELU` op (which the GPU supports). The whole graph then
204
+ delegates in one partition **and computes the right function**. (Simply force-lowering the v4 tag to v3
205
+ instead β€” the earlier attempt β€” made the GPU accept the graph but silently drop the ReLU on all five
206
+ decoder upsamplers, producing an audible broadband buzz.) CPU numerics are unchanged.
207
+
208
+ ## Reproducing the export
209
+
210
+ The two `.tflite` were produced by `export/export_mdx_litert.py`, and the export is **deterministic** β€”
211
+ re-running it from the source ONNX yields the shipped files **byte-for-byte** (verified via SHA-256).
212
+
213
+ ```bash
214
+ # heavy converter env (Python 3.11):
215
+ pip install torch==2.9.1 --index-url https://download.pytorch.org/whl/cpu
216
+ pip install -r export/requirements.txt
217
+
218
+ python export/export_mdx_litert.py UVR_MDXNET_9482.onnx UVR_MDXNET_9482.fp16acc.tflite
219
+ python export/export_mdx_litert.py UVR-MDX-NET-Voc_FT.onnx UVR-MDX-NET-Voc_FT.fp16acc.tflite
220
+ ```
221
+
222
+ Six gated steps: `onnx2torch β†’ SNR-gate (>100 dB vs ONNX Runtime) β†’ litert_torch export (NCHW, static
223
+ dim_t=256) β†’ +fp16 metadata β†’ un-fuse TRANSPOSE_CONV ReLU β†’ verify (CPU SNR, output shape, no op above
224
+ the GPU v3 cap)`. Source ONNX: the UVR MDX-Net releases from
225
+ [TRvlvr/model_repo](https://github.com/TRvlvr/model_repo), also on the Hub at
226
+ [Politrees/UVR_resources](https://huggingface.co/Politrees/UVR_resources).
227
+
228
+ > `onnx2tf` does **not** work here — its per-op NCHW→NHWC guessing breaks the TDF MatMuls. The
229
+ > PyTorch β†’ StableHLO β†’ TFLite route (`litert_torch`) does no layout guessing and keeps NCHW I/O, so the
230
+ > app's complex-as-channels packing feeds the model directly.
231
+
232
+ ## Fidelity & performance
233
+
234
+ - **Fidelity:** the fp16 `.tflite` is gated at **> 100 dB** CPU SNR vs the ONNX reference; measured
235
+ **β‰ˆ 109 dB (9482)** and **β‰ˆ 112 dB (Voc FT)** after the un-fuse step. Validated by ear on-device.
236
+ - **Performance (one measured device β€” Xiaomi Redmi Note 10 Pro, Snapdragon 732G / Adreno 618, Android
237
+ 13):** XNNPACK-fp16 runs the graph at **RTF β‰ˆ 1.0** (roughly real-time) as the universal CPU floor;
238
+ the GPU path (whole-graph `LITERT_CL` partition) runs **clean and faster than XNNPACK** after the
239
+ un-fuse fix. Numbers vary by SoC; treat these as one data point, not a benchmark.
240
+ - **NPU:** the rung is wired (`CompiledModel` + `Accelerator.NPU`, QNN / NeuroPilot) but is
241
+ hardware-gated β€” LiteRT admits only SM8550+ Snapdragons and listed MediaTek Dimensity SoCs β€” and is
242
+ currently deferred/untested.
243
+
244
+ ## Attribution & license
245
+
246
+ Released under the **MIT License**, matching the upstream UVR MDX-Net models.
247
+
248
+ - **UVR MDX-Net** and the **9482** model β€” the [Ultimate Vocal Remover](https://github.com/Anjok07/ultimatevocalremovergui)
249
+ project by **Anjok07** and contributors.
250
+ - **UVR-MDX-NET Voc FT** β€” vocal fine-tune by **Kimberley Jensen**.
251
+ - Source ONNX via [TRvlvr/model_repo](https://github.com/TRvlvr/model_repo) (Hub mirror:
252
+ [Politrees/UVR_resources](https://huggingface.co/Politrees/UVR_resources)).
253
+
254
+ **This repo's contribution:** the ONNX β†’ LiteRT **fp16** conversion, the whole-graph GPU fix (un-fusing
255
+ `TRANSPOSE_CONV`'s ReLU), the runnable `separate.py` reference, and the Android integration recipe. The
256
+ weights are the original UVR models, format-converted.
UVR-MDX-NET-Voc_FT.fp16acc.tflite ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:5ef47e3b3bafa14357532c0a3f6c5f18444d94b6efe3fd62b3d13f80051f1e58
3
+ size 66848828
UVR_MDXNET_9482.fp16acc.tflite ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:2a07e11db13a11ca4900a54b4a316ef67931e993a6a3d19444bccbbeb9b445ee
3
+ size 29802684
__pycache__/separate.cpython-313.pyc ADDED
Binary file (22.7 kB). View file
 
example.mp3 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:1b3783ea92eafc1741e6730bc0dc22b047c56dfcb0defc8af085ec9b5e937f56
3
+ size 2402037
export/__pycache__/export_mdx_litert.cpython-313.pyc ADDED
Binary file (17.7 kB). View file
 
export/export_mdx_litert.py ADDED
@@ -0,0 +1,258 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """One-shot: a UVR MDX-Net .onnx -> the GPU-ready fp16 LiteRT .tflite the app ships.
3
+
4
+ Pipeline (each step gated so a bad export fails loudly, not silently):
5
+ 1. onnx2torch(onnx) -> nn.Module
6
+ 2. SNR-gate the torch module vs ONNX Runtime (must be > --min-snr, default 100 dB)
7
+ 3. litert_torch export -> .tflite (keeps NCHW I/O; static dim_t=256)
8
+ 4. add `reduced_precision_support = fp16accfp16` metadata (the XNNPACK-fp16 signal; measured
9
+ quality-safe for MDX: peak activation << the 65504 fp16 ceiling, no whole-tensor reduction)
10
+ 5. un-fuse the RELU from every TRANSPOSE_CONV (fused activation is exactly what tags the op v4 β€”
11
+ "TransposeConvOp has fused activation function from version 4", op_version.cc β€” and LiteRT's GPU
12
+ accelerator caps TRANSPOSE_CONV at v3, whose semantics have NO fused activation. Force-lowering
13
+ the tag instead (the old approach) made the GPU accept the graph but its kernel then SILENTLY
14
+ DROPPED the ReLU on all 5 decoder upsamplers β€” the "GPU buzz". A standalone RELU op after a
15
+ legitimately-v3 bias-only TRANSPOSE_CONV delegates whole-graph AND computes the right function.
16
+ See UVR_MDX_LITERT.md Β§5.)
17
+ 6. verify: CPU-inference SNR vs ONNX, output shape, and no op left above the GPU version cap.
18
+
19
+ `onnx2tf` is NOT usable here (it guesses NCHW->NHWC per op and breaks the 22 TDF MatMuls + a
20
+ ConvTranspose dummy-inference); the PyTorch -> StableHLO -> TFLite route below does no layout guessing.
21
+
22
+ Env (pinned): onnx2torch 1.5.15, litert_torch 0.8.0 (ai_edge_torch 0.7.2), torch 2.9.1+cpu,
23
+ ai-edge-litert 2.1.2, onnxruntime, onnx, tensorflow, flatbuffers, Python 3.11.
24
+
25
+ Usage:
26
+ python export_mdx_litert.py UVR_MDXNET_9482.onnx UVR_MDXNET_9482.fp16acc.tflite
27
+ python export_mdx_litert.py UVR-MDX-NET-Voc_FT.onnx UVR-MDX-NET-Voc_FT.fp16acc.tflite
28
+ """
29
+ import argparse
30
+ import os
31
+ import sys
32
+ import types
33
+ import warnings
34
+
35
+ warnings.filterwarnings("ignore")
36
+ os.environ.setdefault("GRPC_VERBOSITY", "NONE")
37
+
38
+ # onnx2torch eagerly imports torchvision; MDX uses no vision ops, so stub it out when the real package
39
+ # is missing or ABI-broken in the conversion env.
40
+ try:
41
+ import torchvision # noqa: F401
42
+ except Exception:
43
+ _tv = types.ModuleType("torchvision")
44
+ _tv.__version__ = "0.0.0"
45
+ _ops = types.ModuleType("torchvision.ops")
46
+
47
+ def _stub(*a, **k):
48
+ raise NotImplementedError("torchvision op unavailable (stub)")
49
+
50
+ for _n in ("nms", "batched_nms", "roi_align", "RoIAlign", "DeformConv2d", "deform_conv2d"):
51
+ setattr(_ops, _n, _stub)
52
+ _tv.ops = _ops
53
+ sys.modules["torchvision"] = _tv
54
+ sys.modules["torchvision.ops"] = _ops
55
+
56
+ import flatbuffers
57
+ import numpy as np
58
+ import onnx
59
+ import onnx2torch
60
+ import onnxruntime as ort
61
+ import torch
62
+ import litert_torch
63
+ from ai_edge_litert.interpreter import Interpreter
64
+ from tensorflow.lite.python import schema_py_generated as schema
65
+
66
+ DIM_T = 256 # native trained segment (~5.92 s @ hop 1024); static in the shipped tflite
67
+ FP16_FLAG = b"fp16accfp16" # reduced_precision_support: fast fp16 accumulate, quality-safe for MDX
68
+ GPU_TRANSPOSE_CONV_CAP = 3
69
+
70
+
71
+ def onnx_dim_f(onnx_path):
72
+ """MDX input is [batch, 4, dim_f, dim_t]; return dim_f (2048 for 9482, 3072 for Voc FT)."""
73
+ dims = onnx.load(onnx_path).graph.input[0].type.tensor_type.shape.dim
74
+ return int(dims[2].dim_value)
75
+
76
+
77
+ def sample(dim_f):
78
+ return np.random.randn(1, 4, dim_f, DIM_T).astype(np.float32)
79
+
80
+
81
+ def snr(ref, got):
82
+ den = np.sqrt((ref ** 2).mean())
83
+ num = np.sqrt(((ref - got) ** 2).mean())
84
+ return 20.0 * np.log10(den / (num + 1e-12))
85
+
86
+
87
+ def module_snr(mod, onnx_path, dim_f, trials=2):
88
+ sess = ort.InferenceSession(onnx_path, providers=["CPUExecutionProvider"])
89
+ iname = sess.get_inputs()[0].name
90
+ worst = 1e9
91
+ for _ in range(trials):
92
+ x = sample(dim_f)
93
+ ref = sess.run(None, {iname: x})[0]
94
+ with torch.no_grad():
95
+ got = mod(torch.from_numpy(x)).numpy()
96
+ worst = min(worst, snr(ref, got))
97
+ return float(worst)
98
+
99
+
100
+ def add_fp16_metadata(path):
101
+ model = schema.ModelT.InitFromObj(schema.Model.GetRootAsModel(open(path, "rb").read(), 0))
102
+ for md in (model.metadata or []):
103
+ name = md.name.decode() if isinstance(md.name, (bytes, bytearray)) else md.name
104
+ if name == "reduced_precision_support":
105
+ return
106
+ if model.metadata is None:
107
+ model.metadata = []
108
+ buf = schema.BufferT()
109
+ buf.data = list(FP16_FLAG)
110
+ model.buffers.append(buf)
111
+ md = schema.MetadataT()
112
+ md.name = "reduced_precision_support"
113
+ md.buffer = len(model.buffers) - 1
114
+ model.metadata.append(md)
115
+ builder = flatbuffers.Builder(1024)
116
+ builder.Finish(model.Pack(builder), b"TFL3")
117
+ open(path, "wb").write(bytes(builder.Output()))
118
+
119
+
120
+ def unfuse_transpose_conv_relu(path):
121
+ """Split `TRANSPOSE_CONV(+fused RELU)` into `TRANSPOSE_CONV (v3, bias-only) -> RELU`.
122
+
123
+ The GPU accelerator's TRANSPOSE_CONV kernel implements v3 semantics (no fused activation) and
124
+ silently drops a fused ReLU, so the activation must live in its own (GPU-supported) RELU op.
125
+ CPU numerics are unchanged. Same surgery as the standalone unfuse_tconv_relu.py.
126
+ """
127
+ model = schema.ModelT.InitFromObj(schema.Model.GetRootAsModel(open(path, "rb").read(), 0))
128
+ tconv_opcodes = {
129
+ i for i, oc in enumerate(model.operatorCodes)
130
+ if max(oc.builtinCode, oc.deprecatedBuiltinCode) == schema.BuiltinOperator.TRANSPOSE_CONV
131
+ }
132
+ relu_opcode = next(
133
+ (i for i, oc in enumerate(model.operatorCodes)
134
+ if max(oc.builtinCode, oc.deprecatedBuiltinCode) == schema.BuiltinOperator.RELU), None)
135
+ if relu_opcode is None:
136
+ oc = schema.OperatorCodeT()
137
+ oc.builtinCode = schema.BuiltinOperator.RELU
138
+ oc.deprecatedBuiltinCode = schema.BuiltinOperator.RELU
139
+ oc.version = 1
140
+ model.operatorCodes.append(oc)
141
+ relu_opcode = len(model.operatorCodes) - 1
142
+
143
+ unfused = 0
144
+ for sg in model.subgraphs:
145
+ new_ops = []
146
+ for op in sg.operators:
147
+ new_ops.append(op)
148
+ opts = op.builtinOptions
149
+ if (op.opcodeIndex not in tconv_opcodes or opts is None
150
+ or getattr(opts, "fusedActivationFunction", 0) == 0):
151
+ continue
152
+ assert opts.fusedActivationFunction == schema.ActivationFunctionType.RELU
153
+ old_out = op.outputs[0]
154
+ old_t = sg.tensors[old_out]
155
+
156
+ pre = schema.TensorT()
157
+ pre.shape = list(old_t.shape) if old_t.shape is not None else None
158
+ pre.type = old_t.type
159
+ base = old_t.name.decode() if isinstance(old_t.name, (bytes, bytearray)) else old_t.name
160
+ pre.name = (base + "_prerelu").encode()
161
+ buf = schema.BufferT()
162
+ model.buffers.append(buf)
163
+ pre.buffer = len(model.buffers) - 1
164
+ sg.tensors.append(pre)
165
+ pre_idx = len(sg.tensors) - 1
166
+
167
+ opts.fusedActivationFunction = schema.ActivationFunctionType.NONE
168
+ outs = list(op.outputs) # the packed array is read-only; swap in a mutable list
169
+ outs[0] = pre_idx
170
+ op.outputs = outs
171
+
172
+ relu = schema.OperatorT()
173
+ relu.opcodeIndex = relu_opcode
174
+ relu.inputs = [pre_idx]
175
+ relu.outputs = [old_out]
176
+ new_ops.append(relu)
177
+ unfused += 1
178
+ sg.operators = new_ops
179
+
180
+ # With the activation un-fused, bias-only TRANSPOSE_CONV is legitimately v3 β€” no forged tag.
181
+ for oc in model.operatorCodes:
182
+ code = max(oc.builtinCode, oc.deprecatedBuiltinCode)
183
+ if code == schema.BuiltinOperator.TRANSPOSE_CONV and oc.version > GPU_TRANSPOSE_CONV_CAP:
184
+ oc.version = GPU_TRANSPOSE_CONV_CAP
185
+ builder = flatbuffers.Builder(1024)
186
+ builder.Finish(model.Pack(builder), b"TFL3")
187
+ open(path, "wb").write(bytes(builder.Output()))
188
+ return unfused
189
+
190
+
191
+ def tflite_cpu_snr(path, onnx_path, dim_f, trials=2):
192
+ sess = ort.InferenceSession(onnx_path, providers=["CPUExecutionProvider"])
193
+ iname = sess.get_inputs()[0].name
194
+ it = Interpreter(model_path=path)
195
+ it.allocate_tensors()
196
+ inp = it.get_input_details()[0]
197
+ outp = it.get_output_details()[0]
198
+ worst, oshape = 1e9, None
199
+ for _ in range(trials):
200
+ x = sample(dim_f)
201
+ ref = sess.run(None, {iname: x})[0]
202
+ it.set_tensor(inp["index"], x)
203
+ it.invoke()
204
+ got = it.get_tensor(outp["index"])
205
+ oshape = got.shape
206
+ worst = min(worst, snr(ref, got))
207
+ return float(worst), oshape
208
+
209
+
210
+ def over_cap_ops(path):
211
+ model = schema.Model.GetRootAsModel(open(path, "rb").read(), 0)
212
+ bad = []
213
+ for i in range(model.OperatorCodesLength()):
214
+ oc = model.OperatorCodes(i)
215
+ code = max(oc.BuiltinCode(), oc.DeprecatedBuiltinCode())
216
+ if code == schema.BuiltinOperator.TRANSPOSE_CONV and oc.Version() > GPU_TRANSPOSE_CONV_CAP:
217
+ bad.append(("TRANSPOSE_CONV", oc.Version()))
218
+ return bad
219
+
220
+
221
+ def main():
222
+ ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
223
+ ap.add_argument("onnx", help="source UVR MDX-Net .onnx")
224
+ ap.add_argument("out", help="output .fp16acc.tflite")
225
+ ap.add_argument("--min-snr", type=float, default=100.0)
226
+ args = ap.parse_args()
227
+
228
+ dim_f = onnx_dim_f(args.onnx)
229
+ print(f"[1/6] onnx2torch: {args.onnx} (dim_f={dim_f}, dim_t={DIM_T})", flush=True)
230
+ mod = onnx2torch.convert(args.onnx).eval()
231
+
232
+ msnr = module_snr(mod, args.onnx, dim_f)
233
+ print(f"[2/6] torch-module SNR vs ONNX Runtime = {msnr:.1f} dB", flush=True)
234
+ if msnr < args.min_snr:
235
+ raise SystemExit(f"FAIL: onnx2torch fidelity {msnr:.1f} dB < {args.min_snr} dB (do not ship)")
236
+
237
+ print(f"[3/6] litert_torch export -> {args.out}", flush=True)
238
+ litert_torch.convert(mod, (torch.from_numpy(sample(dim_f)),)).export(args.out)
239
+
240
+ print("[4/6] add fp16 metadata (reduced_precision_support=fp16accfp16)", flush=True)
241
+ add_fp16_metadata(args.out)
242
+
243
+ n = unfuse_transpose_conv_relu(args.out)
244
+ print(f"[5/6] un-fused {n} TRANSPOSE_CONV ReLU(s) -> standalone RELU ops (GPU kernel drops fused "
245
+ f"activations; op now legitimately v{GPU_TRANSPOSE_CONV_CAP})", flush=True)
246
+
247
+ tsnr, oshape = tflite_cpu_snr(args.out, args.onnx, dim_f)
248
+ bad = over_cap_ops(args.out)
249
+ size_mb = os.path.getsize(args.out) / 1e6
250
+ ok = tsnr >= args.min_snr and oshape == (1, 4, dim_f, DIM_T) and not bad
251
+ print(f"[6/6] verify: tflite CPU SNR vs ONNX = {tsnr:.1f} dB | out = {oshape} | "
252
+ f"over-cap ops = {bad or 'none'} | size = {size_mb:.1f} MB", flush=True)
253
+ print(" " + ("PASS β€” GPU-ready fp16 tflite" if ok else "FAIL β€” do not ship"), flush=True)
254
+ raise SystemExit(0 if ok else 1)
255
+
256
+
257
+ if __name__ == "__main__":
258
+ main()
export/requirements.txt ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Toolchain to reproduce the ONNX -> LiteRT fp16 export (export_mdx_litert.py). Python 3.11.
2
+ # Install torch from the CPU wheel index first:
3
+ # pip install torch==2.9.1 --index-url https://download.pytorch.org/whl/cpu
4
+ # pip install -r requirements.txt
5
+ # Only onnx2torch and litert-torch are hard-pinned (the conversion route is sensitive to their
6
+ # versions); the rest float. This is the heavy converter env β€” to only RUN a shipped .tflite you just
7
+ # need `numpy` + `ai-edge-litert` (see ../requirements.txt).
8
+ onnx2torch==1.5.15
9
+ litert-torch==0.8.0
10
+ ai-edge-litert
11
+ tensorflow
12
+ onnx
13
+ onnxruntime
14
+ flatbuffers
15
+ numpy
requirements.txt ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ # Runtime deps for separate.py (the standalone inference guide).
2
+ # WAV IO uses the Python stdlib `wave` module, so there are no audio libraries to install.
3
+ numpy>=1.24
4
+ ai-edge-litert>=1.1
separate.py ADDED
@@ -0,0 +1,327 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Standalone vocal / instrumental separation with UVR MDX-Net on LiteRT.
3
+
4
+ This is a self-contained, runnable reference for the "STFT outside the graph" design used by the
5
+ MusicStemSeparation Android app. The `.tflite` graph is the **learned core only** β€” a convolutional
6
+ U-Net that maps a spectrogram to the vocal spectrogram. The STFT, chunking, overlap-add and inverse
7
+ STFT are ordinary CPU code here (numpy), exactly as they are ordinary Kotlin code in the app. Per chunk:
8
+
9
+ STFT (numpy) -> inference (LiteRT) -> iSTFT (numpy) -> weighted overlap-add
10
+
11
+ Everything below mirrors the app's DSP line-for-line so the output matches; the Kotlin sources are cited
12
+ inline (`CacDsp.kt`, `MdxSeparationPipeline.kt`, `MdxLiteRtEngine.kt`, `MdxTypes.kt`, audio/*). The whole
13
+ pipeline is a faithful reimplementation of UVR's `mdx.py` demix (with a 10% crossfade instead of hard
14
+ tiling β€” set OVERLAP = 0 to get the classic hard concat).
15
+
16
+ THE MODEL I/O CONTRACT (the one thing to get right):
17
+ input = OUTPUT = float32 tensor [1, 4, dim_f, 256] (NCHW)
18
+ the 4 channels are complex-as-channels, plane order [L_re, L_im, R_re, R_im]
19
+ flat row-major index = ((plane * dim_f) + bin) * 256 + frame
20
+ 9482 : dim_f 2048, n_fft 4096 Voc FT : dim_f 3072, n_fft 6144 both hop 1024
21
+ A wrong packing (swapped re/im planes, kept Nyquist bin, a stray 1/sqrt(n)) yields plausible-but-wrong
22
+ audio, not an error β€” so this is where to look first if a stem sounds off.
23
+
24
+ NOTE ON PRECISION / SPEED: this runs the model on the CPU via ai-edge-litert (XNNPACK). The `.tflite`
25
+ carries `reduced_precision_support=fp16accfp16`, which only engages fp16 kernels on ARMv8.2-FP16 cores;
26
+ on a desktop it runs fp32 (correct, just not the ~2x mobile speed). The real CPU->GPU->NPU accelerator
27
+ ladder is Android-only β€” see the README's "Android on-device inference" section and the app's
28
+ `MdxLiteRtEngine.kt`.
29
+
30
+ Usage:
31
+ python separate.py song.wav # 9482 model, writes song_vocals.wav + song_instrumental.wav
32
+ python separate.py song.wav --model voc_ft # higher-quality (larger) model
33
+ python separate.py song.wav --out-dir out/ --denoise
34
+ Input must be a WAV (convert other formats first, e.g. `ffmpeg -i in.mp3 in.wav`). Feed 44.1 kHz for
35
+ exact UVR parity; other rates are linearly resampled (matching the app, not librosa/soxr).
36
+ """
37
+ import argparse
38
+ import os
39
+ import sys
40
+ import wave
41
+
42
+ import numpy as np
43
+ from ai_edge_litert.interpreter import Interpreter
44
+
45
+ # ── Fixed constants (MdxTypes.kt) ────────────────────────────────────────────────────────────────
46
+ SAMPLE_RATE = 44100 # MDX_SAMPLE_RATE β€” MDX-Net operates at 44.1 kHz
47
+ DIM_T = 256 # native trained segment (~5.92 s @ hop 1024); static in the shipped .tflite
48
+ HOP = 1024 # STFT hop, both models
49
+ OVERLAP = 0.10 # MDX_OVERLAP β€” fixed 10% chunk crossfade (0 -> classic hard-tiled concat)
50
+
51
+ # The .tflite assets live beside this script. Each entry carries its STFT framing (MdxModel enum).
52
+ HERE = os.path.dirname(os.path.abspath(__file__))
53
+ MODELS = {
54
+ "9482": dict(tflite="UVR_MDXNET_9482.fp16acc.tflite", n_fft=4096, dim_f=2048, compensation=1.0),
55
+ "voc_ft": dict(tflite="UVR-MDX-NET-Voc_FT.fp16acc.tflite", n_fft=6144, dim_f=3072, compensation=1.0),
56
+ }
57
+
58
+
59
+ # ══════════════════════════════════════════════════════════════════════════════════════════════════
60
+ # Host DSP β€” a numpy port of CacDsp.kt (CacStft / CacIstft / reflect pad / crossfade window).
61
+ # Mirrors UVR's STFT/ISTFT: periodic Hann, center=True reflect pad, normalized=False (no 1/sqrt(n)),
62
+ # Nyquist bin dropped (dim_f = n_fft/2).
63
+ # ══════════════════════════════════════════════════════════════════════════════════════════════════
64
+
65
+ def hann_periodic(n):
66
+ """torch.hann_window(n, periodic=True) = 0.5*(1 - cos(2*pi*k/n)), k=0..n-1 (divisor n)."""
67
+ k = np.arange(n, dtype=np.float64)
68
+ return (0.5 * (1.0 - np.cos(2.0 * np.pi * k / n))).astype(np.float32)
69
+
70
+
71
+ class Stft:
72
+ """Forward STFT: stereo chunk (chunk_size samples/channel) -> CaC tensor [1, 4, dim_f, 256].
73
+
74
+ Port of CacStft.transform. For each channel: reflect-pad by n_fft/2 (center=True), slide a
75
+ hop-spaced periodic-Hann window over DIM_T frames, rfft each frame, keep bins [0, dim_f) (dropping
76
+ the Nyquist bin since dim_f = n_fft/2), and store real/imag into planes [L_re, L_im, R_re, R_im].
77
+ """
78
+
79
+ def __init__(self, n_fft, dim_f):
80
+ self.n = n_fft
81
+ self.dim_f = dim_f
82
+ self.pad = n_fft // 2 # centerPad
83
+ self.chunk_size = HOP * (DIM_T - 1)
84
+ self.hann = hann_periodic(n_fft)
85
+
86
+ def _channel_spec(self, samples):
87
+ # reflectPad(x, left=pad, right=pad): mirror without repeating the edge == np.pad(mode="reflect").
88
+ padded = np.pad(samples, (self.pad, self.pad), mode="reflect")
89
+ # DIM_T frames starting at f*hop, each n_fft long (padded length = chunk_size + n_fft).
90
+ frames = np.lib.stride_tricks.sliding_window_view(padded, self.n)[::HOP]
91
+ assert frames.shape[0] == DIM_T, (frames.shape, DIM_T)
92
+ frames = frames * self.hann # analysis window
93
+ spec = np.fft.rfft(frames, axis=1) # [DIM_T, n/2+1], unnormalized (normalized=False)
94
+ spec = spec[:, : self.dim_f] # crop to dim_f -> drops the Nyquist bin
95
+ # -> [dim_f, DIM_T] planes; rfft already gives DC imag == 0.
96
+ return spec.real.T.astype(np.float32), spec.imag.T.astype(np.float32)
97
+
98
+ def __call__(self, left, right):
99
+ l_re, l_im = self._channel_spec(left)
100
+ r_re, r_im = self._channel_spec(right)
101
+ out = np.empty((1, 4, self.dim_f, DIM_T), dtype=np.float32)
102
+ out[0, 0], out[0, 1], out[0, 2], out[0, 3] = l_re, l_im, r_re, r_im # [L_re, L_im, R_re, R_im]
103
+ return out
104
+
105
+
106
+ class Istft:
107
+ """Inverse STFT: CaC tensor [1, 4, dim_f, 256] -> stereo chunk (chunk_size samples/channel).
108
+
109
+ Port of CacIstft.inverse. Rebuilds each frame's one-sided spectrum (Nyquist bin zero-padded back),
110
+ irfft (includes 1/n), applies the synthesis Hann window, overlap-adds, divides by the running
111
+ window-sum-of-squares envelope (+1e-8), and trims the n_fft/2 center pad each side.
112
+ """
113
+
114
+ def __init__(self, n_fft, dim_f):
115
+ self.n = n_fft
116
+ self.dim_f = dim_f
117
+ self.pad = n_fft // 2
118
+ self.chunk_size = HOP * (DIM_T - 1)
119
+ self.hann = hann_periodic(n_fft)
120
+ self.ola_len = (DIM_T - 1) * HOP + n_fft
121
+ self.hann_sq = (self.hann * self.hann).astype(np.float32)
122
+
123
+ def _channel_wave(self, re, im):
124
+ # Full one-sided spectrum [DIM_T, n/2+1]; bins >= dim_f (i.e. the Nyquist bin) stay zero.
125
+ full = np.zeros((DIM_T, self.n // 2 + 1), dtype=np.complex128)
126
+ full[:, : self.dim_f] = re.T + 1j * im.T
127
+ frames = np.fft.irfft(full, n=self.n, axis=1).astype(np.float32) # [DIM_T, n], includes 1/n
128
+ frames = frames * self.hann # synthesis window
129
+ ola = np.zeros(self.ola_len, dtype=np.float32)
130
+ env = np.zeros(self.ola_len, dtype=np.float32)
131
+ for t in range(DIM_T):
132
+ s = t * HOP
133
+ ola[s : s + self.n] += frames[t]
134
+ env[s : s + self.n] += self.hann_sq
135
+ seg = slice(self.pad, self.pad + self.chunk_size)
136
+ return ola[seg] / (env[seg] + 1e-8)
137
+
138
+ def __call__(self, spec):
139
+ left = self._channel_wave(spec[0, 0], spec[0, 1])
140
+ right = self._channel_wave(spec[0, 2], spec[0, 3])
141
+ return left, right
142
+
143
+
144
+ def build_crossfade_window(gen_size, overlap_len):
145
+ """Trapezoid: ramp up over first overlap_len, flat 1, ramp down over last overlap_len. Strictly
146
+ positive so the envelope divide is well-defined at the un-overlapped first/last edges. Port of
147
+ CacDsp.buildCrossfadeWindow (overlap_len == 0 -> all ones = hard tile)."""
148
+ if overlap_len <= 0:
149
+ return np.ones(gen_size, dtype=np.float32)
150
+ denom = float(overlap_len + 1)
151
+ k = np.arange(gen_size, dtype=np.float32)
152
+ up = (k + 1.0) / denom
153
+ down = (gen_size - k) / denom
154
+ return np.minimum(1.0, np.minimum(up, down)).astype(np.float32)
155
+
156
+
157
+ # ══════════════════════════════════════════════════════════════════════════════════════════════════
158
+ # Inference seam β€” a numpy port of the MdxEngine seam (MdxLiteRtEngine.kt). One run() method:
159
+ # CaC spectrogram in -> vocal CaC spectrogram out. This is the whole "how to call the model" story.
160
+ # ══════════════════════════════════════════════════════════════════════════════════════════════════
161
+
162
+ class MdxLiteRtEngine:
163
+ """LiteRT inference stage. On the CPU this is the classic Interpreter path (== InterpreterRunner in
164
+ the app; the GPU/NPU rungs use the CompiledModel API and are Android-only)."""
165
+
166
+ def __init__(self, tflite_path):
167
+ self.it = Interpreter(model_path=tflite_path) # LiteRT memory-maps the .tflite
168
+ self.it.allocate_tensors()
169
+ self._in = self.it.get_input_details()[0]
170
+ self._out = self.it.get_output_details()[0]
171
+
172
+ def _infer(self, x):
173
+ self.it.set_tensor(self._in["index"], np.ascontiguousarray(x, dtype=np.float32))
174
+ self.it.invoke()
175
+ return self.it.get_tensor(self._out["index"]).copy() # copy: the tensor is reused across calls
176
+
177
+ def run(self, spec, denoise=False):
178
+ out = self._infer(spec)
179
+ if denoise:
180
+ # UVR shift trick: spec_pred = 0.5*model(spec) - 0.5*model(-spec). 2x inference cost.
181
+ out = 0.5 * out - 0.5 * self._infer(-spec)
182
+ return out
183
+
184
+
185
+ # ══════════════════════════════════════════════════════════════════════════════════════════════════
186
+ # Pipeline β€” a numpy port of MdxSeparationPipeline.separate: chunk, STFT -> infer -> iSTFT, weighted
187
+ # overlap-add, envelope normalize, volume compensation, and the free instrumental residual.
188
+ # ══════════════════════════════════════════════════════════════════════════════════════════════════
189
+
190
+ def separate(engine, cfg, left, right, denoise=False):
191
+ n_fft, dim_f, comp = cfg["n_fft"], cfg["dim_f"], cfg["compensation"]
192
+ trim = n_fft // 2 # STFT center trim / per-side overlap
193
+ chunk_size = HOP * (DIM_T - 1)
194
+ gen_size = chunk_size - 2 * trim # useful samples a chunk contributes
195
+ n_sample = min(len(left), len(right))
196
+ if n_sample <= 0:
197
+ raise ValueError("empty audio")
198
+
199
+ # Overlap geometry (MdxSeparationPipeline.kt).
200
+ stride = int(round(gen_size * (1.0 - OVERLAP)))
201
+ stride = max(1, min(gen_size, stride))
202
+ overlap_len = gen_size - stride
203
+ window = build_crossfade_window(gen_size, overlap_len)
204
+ n_chunks = 1 if n_sample <= gen_size else (n_sample - gen_size + stride - 1) // stride + 1
205
+ out_len = (n_chunks - 1) * stride + gen_size
206
+
207
+ # mixture = [trim zeros | audio | right-pad zeros | trim zeros] so every window is in-bounds.
208
+ mix_len = trim + out_len + trim
209
+ mix_l = np.zeros(mix_len, dtype=np.float32); mix_l[trim : trim + n_sample] = left[:n_sample]
210
+ mix_r = np.zeros(mix_len, dtype=np.float32); mix_r[trim : trim + n_sample] = right[:n_sample]
211
+
212
+ acc_l = np.zeros(out_len, dtype=np.float32)
213
+ acc_r = np.zeros(out_len, dtype=np.float32)
214
+ env = np.zeros(out_len, dtype=np.float32)
215
+
216
+ stft = Stft(n_fft, dim_f)
217
+ istft = Istft(n_fft, dim_f)
218
+ for i in range(n_chunks):
219
+ s = i * stride
220
+ spec = stft(mix_l[s : s + chunk_size], mix_r[s : s + chunk_size]) # STFT
221
+ voc = engine.run(spec, denoise) # inference
222
+ w_l, w_r = istft(voc) # iSTFT -> chunk_size samples
223
+ # Window the central gen_size samples (drop trim each side) and overlap-add.
224
+ acc_l[s : s + gen_size] += window * w_l[trim : trim + gen_size]
225
+ acc_r[s : s + gen_size] += window * w_r[trim : trim + gen_size]
226
+ env[s : s + gen_size] += window
227
+ print(f" chunk {i + 1}/{n_chunks}", end="\r", flush=True)
228
+ print()
229
+
230
+ # Normalize by the crossfade envelope, crop to length, apply volume compensation.
231
+ voc_l = (acc_l[:n_sample] / (env[:n_sample] + 1e-8) * comp).astype(np.float32)
232
+ voc_r = (acc_r[:n_sample] / (env[:n_sample] + 1e-8) * comp).astype(np.float32)
233
+ # Instrumental is the free residual: original mix - vocals.
234
+ ins_l = (left[:n_sample] - voc_l).astype(np.float32)
235
+ ins_r = (right[:n_sample] - voc_r).astype(np.float32)
236
+ return voc_l, voc_r, ins_l, ins_r
237
+
238
+
239
+ # ══════════════════════════════════════════════════════════════════════════════════════════════════
240
+ # Audio IO β€” stdlib `wave`, matching audio/AudioDecoder, AudioResampler, WavWriter (16-bit, /32768 in,
241
+ # x32767 out). No external audio deps; WAV in / WAV out.
242
+ # ══════════════════════════════════════════════════════════════════════════════════════════════════
243
+
244
+ def read_wav_stereo_44100(path):
245
+ with wave.open(path, "rb") as w:
246
+ ch, width, sr, nframes = w.getnchannels(), w.getsampwidth(), w.getframerate(), w.getnframes()
247
+ raw = w.readframes(nframes)
248
+ if width == 2:
249
+ data = np.frombuffer(raw, dtype="<i2").astype(np.float32) / 32768.0
250
+ elif width == 1: # WAV 8-bit is unsigned
251
+ data = (np.frombuffer(raw, dtype=np.uint8).astype(np.float32) - 128.0) / 128.0
252
+ elif width == 4: # assume 32-bit signed PCM
253
+ data = np.frombuffer(raw, dtype="<i4").astype(np.float32) / 2147483648.0
254
+ else:
255
+ raise ValueError(f"unsupported WAV sample width {width} bytes (use 16-bit PCM WAV)")
256
+ data = data.reshape(-1, ch)
257
+ if ch == 1:
258
+ left = right = data[:, 0] # mono -> duplicate (AudioDecoder)
259
+ else:
260
+ left, right = data[:, 0], data[:, 1] # keep first two channels
261
+ if sr != SAMPLE_RATE: # resample only if needed (AudioResampler.resampleLinear)
262
+ left = resample_linear(left, sr, SAMPLE_RATE)
263
+ right = resample_linear(right, sr, SAMPLE_RATE)
264
+ return np.ascontiguousarray(left), np.ascontiguousarray(right)
265
+
266
+
267
+ def resample_linear(src, src_sr, dst_sr):
268
+ """Naive linear-interpolation resampler β€” a port of AudioResampler.resampleLinear (NOT librosa/soxr,
269
+ so it matches the app, not UVR's reference; feed 44.1 kHz for exact parity)."""
270
+ if src_sr == dst_sr or src.size < 2:
271
+ return src.astype(np.float32)
272
+ dst_len = max(1, int(src.size * dst_sr // src_sr))
273
+ pos = np.arange(dst_len, dtype=np.float64) * (src_sr / dst_sr)
274
+ i0 = np.floor(pos).astype(np.int64)
275
+ frac = (pos - i0).astype(np.float32)
276
+ i0 = np.clip(i0, 0, src.size - 1)
277
+ i1 = np.clip(i0 + 1, 0, src.size - 1)
278
+ return (src[i0] + (src[i1] - src[i0]) * frac).astype(np.float32)
279
+
280
+
281
+ def write_wav_stereo_16(path, left, right, sr=SAMPLE_RATE):
282
+ n = min(len(left), len(right))
283
+ l = np.clip(left[:n], -1.0, 1.0); r = np.clip(right[:n], -1.0, 1.0)
284
+ inter = np.empty(n * 2, dtype="<i2")
285
+ inter[0::2] = (l * 32767.0).astype(np.int16) # x32767 truncate, matching WavWriter.toPcm16
286
+ inter[1::2] = (r * 32767.0).astype(np.int16)
287
+ os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True)
288
+ with wave.open(path, "wb") as w:
289
+ w.setnchannels(2); w.setsampwidth(2); w.setframerate(sr)
290
+ w.writeframes(inter.tobytes())
291
+
292
+
293
+ # ══════════════════════════════════════════════════════════════════════════════════════════════════
294
+
295
+ def main():
296
+ ap = argparse.ArgumentParser(description="UVR MDX-Net vocal separation on LiteRT (STFT outside the graph).")
297
+ ap.add_argument("input", help="input WAV (any sample rate; 44.1 kHz for exact parity)")
298
+ ap.add_argument("--model", choices=list(MODELS), default="9482", help="which MDX-Net model (default: 9482)")
299
+ ap.add_argument("--out-dir", default=".", help="directory for the output stems (default: .)")
300
+ ap.add_argument("--denoise", action="store_true", help="UVR shift trick (2x inference, slightly cleaner)")
301
+ args = ap.parse_args()
302
+
303
+ cfg = MODELS[args.model]
304
+ tflite = os.path.join(HERE, cfg["tflite"])
305
+ if not os.path.exists(tflite):
306
+ sys.exit(f"model not found: {tflite}\nDownload it from the repo (it is tracked with Git LFS).")
307
+
308
+ print(f"model : {args.model} ({cfg['tflite']}, dim_f={cfg['dim_f']}, n_fft={cfg['n_fft']})")
309
+ print(f"loading : {args.input}")
310
+ left, right = read_wav_stereo_44100(args.input)
311
+ print(f"samples : {len(left)} per channel ({len(left)/SAMPLE_RATE:.1f} s @ {SAMPLE_RATE} Hz)")
312
+
313
+ engine = MdxLiteRtEngine(tflite)
314
+ print("separating :")
315
+ voc_l, voc_r, ins_l, ins_r = separate(engine, cfg, left, right, denoise=args.denoise)
316
+
317
+ stem = os.path.splitext(os.path.basename(args.input))[0]
318
+ voc_path = os.path.join(args.out_dir, f"{stem}_vocals.wav")
319
+ ins_path = os.path.join(args.out_dir, f"{stem}_instrumental.wav")
320
+ write_wav_stereo_16(voc_path, voc_l, voc_r)
321
+ write_wav_stereo_16(ins_path, ins_l, ins_r)
322
+ print(f"wrote : {voc_path}")
323
+ print(f" : {ins_path}")
324
+
325
+
326
+ if __name__ == "__main__":
327
+ main()