Audio-to-Audio
LiteRT
LiteRT
audio
music-source-separation
stem-separation
vocal-separation
mdx-net
uvr
on-device
android
Instructions to use gyoom-sa/UVR-MDX-LiteRT with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- LiteRT
How to use gyoom-sa/UVR-MDX-LiteRT with LiteRT:
# No code snippets available yet for this library. # To use this model, check the repository files and the library's documentation. # Want to help? PRs adding snippets are welcome at: # https://github.com/huggingface/huggingface.js
- Notebooks
- Google Colab
- Kaggle
Add MDX-Net LiteRT fp16 models, deterministic export script, and standalone inference guide
Browse files- .gitattributes +1 -0
- LICENSE +30 -0
- README.md +253 -0
- UVR-MDX-NET-Voc_FT.fp16acc.tflite +3 -0
- UVR_MDXNET_9482.fp16acc.tflite +3 -0
- __pycache__/separate.cpython-313.pyc +0 -0
- example.mp3 +3 -0
- export/__pycache__/export_mdx_litert.cpython-313.pyc +0 -0
- export/export_mdx_litert.py +258 -0
- export/requirements.txt +15 -0
- requirements.txt +4 -0
- separate.py +327 -0
.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()
|