UVR MDX-Net β†’ LiteRT (on-device vocal separation)

Two UVR MDX-Net vocal separators, converted to LiteRT (.tflite) as fp16 and made to delegate whole to a mobile GPU β€” the models that power on-device vocal/instrumental separation in the MusicStemSeparation Android app. This repo ships the models plus everything needed to run and reproduce them:

  • separate.py β€” a self-contained, runnable Python reference (numpy STFT + LiteRT). WAV in β†’ vocals + instrumental WAV out.
  • export/export_mdx_litert.py β€” the single, deterministic ONNXβ†’LiteRT-fp16 export script (it reproduces the shipped .tflite byte-for-byte).
  • A documented Android (Kotlin) integration recipe below β€” one CompiledModel.create() call that picks NPU β†’ GPU β†’ CPU by itself.

Not int8. base_model_relation: quantized is HF's closest label; these are fp16 precision + format (ONNX→LiteRT) conversions of the original UVR MDX-Net weights, not integer-quantized models.

Models

Model Params dim_f n_fft hop Size Notes
UVR_MDXNET_9482.fp16acc.tflite 7.4 M 2048 4096 1024 ~28 MB general, fast (default)
UVR-MDX-NET-Voc_FT.fp16acc.tflite 16.7 M 3072 6144 1024 ~64 MB vocal fine-tune, higher quality

Both run a native 256-frame segment (dim_t = 256, β‰ˆ 5.9 s @ 44.1 kHz). The model predicts the vocal spectrogram; the instrumental is the free residual mix βˆ’ vocals.

Quickstart

pip install -r requirements.txt          # numpy + ai-edge-litert (WAV IO is stdlib)

# input must be WAV β€” convert first if needed:
ffmpeg -i example.mp3 example.wav

python separate.py example.wav                    # 9482 (default)  β†’ example_vocals.wav + example_instrumental.wav
python separate.py example.wav --model voc_ft     # higher-quality model
python separate.py example.wav --model voc_ft --denoise --out-dir out/

separate.py runs the model on the CPU (via ai-edge-litert/XNNPACK) β€” a correct, portable reference. The fp16 speedup and the GPU/NPU path are Android-only (see below).

Model I/O contract (the one thing to get right)

input = OUTPUT = float32 tensor  [1, 4, dim_f, 256]   (NCHW)

The 4 channels are complex-as-channels, plane order [L_re, L_im, R_re, R_im] (real plane = 2Β·c, imag plane = 2Β·c + 1), flat row-major index ((planeΒ·dim_f) + bin)Β·256 + frame.

  • The STFT keeps bins [0, dim_f) of the n_fft/2 + 1 one-sided bins β€” i.e. the Nyquist bin is dropped (dim_f = n_fft/2); on the inverse it is zero-padded back.
  • Voc FT carries no embedded STFT metadata β€” derive n_fft = dim_f Β· 2.
  • A wrong packing (swapped re/im planes, a kept Nyquist bin, a stray 1/√n) produces plausible-but-wrong audio, not an error. If a stem sounds off, look here first.

How it works β€” STFT outside the graph

The .tflite is the learned core only β€” a convolutional U-Net that maps a spectrogram to the vocal spectrogram. The STFT, chunking, overlap-add and inverse STFT are ordinary CPU code (numpy in separate.py, Kotlin in the app). Per chunk:

STFT (host CPU) β†’ inference (LiteRT: CPU / GPU / NPU) β†’ iSTFT (host CPU) β†’ weighted overlap-add

This is a faithful reimplementation of UVR's mdx.py demix: periodic Hann window, center=True reflect padding, unnormalized (torch.stft(normalized=False)), a fixed 10% crossfade between chunks (set to 0 for the classic hard-tiled concat), and the free instrumental residual. Because the accelerator only ever sees the learned core, the model delegates cleanly and every stage is independently timed.

Android on-device inference

One call. LiteRT tries NPU, then GPU, then CPU, and keeps whatever works.

Gradle

dependencies {
    implementation("com.google.ai.edge.litert:litert:2.1.6")   // GPU accelerator .so ships inside this AAR
}
android {
    androidResources { noCompress += "tflite" }                // no inflate when copying the asset out
    // ABIs: arm64-v8a (+ x86_64 for the emulator)
}
import com.google.ai.edge.litert.Accelerator
import com.google.ai.edge.litert.BuiltinNpuAcceleratorProvider
import com.google.ai.edge.litert.CompiledModel
import com.google.ai.edge.litert.Environment

val env = Environment.create(BuiltinNpuAcceleratorProvider(context))
val model = CompiledModel.create(
    tflitePath,   // copy the asset to filesDir once, pass that path β€” see gotchas
    CompiledModel.Options(Accelerator.NPU, Accelerator.GPU, Accelerator.CPU).apply {
        gpuOptions = CompiledModel.GpuOptions(precision = CompiledModel.GpuOptions.Precision.FP16)
        cpuOptions = CompiledModel.CpuOptions(numThreads = bigCoreCount)   // defaults to 1 β€” always set it
    },
    env,
)
val inputs = model.createInputBuffers()
val outputs = model.createOutputBuffers()

// per chunk:
inputs[0].writeFloat(chunkFloats)   // 1*4*dim_f*256, [L_re, L_im, R_re, R_im]
model.run(inputs, outputs)
val vocals = outputs[0].readFloat()

Run one warm-up inference on a zeroed input, and retry with Options.CPU if it throws β€” once the model is compiled LiteRT won't fall back for you.

Gotchas

  • numThreads defaults to 1. The CPU rung is XNNPACK, fp16 flag and all, so it's a fine floor β€” but only if you set the thread count.
  • Pass a file path, not context.assets: the assets overload copies the whole model onto the heap (~128 MB peak for Voc FT).
  • Nothing tells you which accelerator you actually got. Grep logcat for Replacing N out of M node(s) with delegate.

NPU is work, not a free rung: only SM8550/8650/8750/8850, listed MediaTek SoCs and Tensor G3–G6 qualify, and you ship the Qualcomm/MediaTek libraries yourself (20–110 MB per device, install-time Play Feature Delivery). Untested here; leaving Accelerator.NPU in the set is harmless on everything else.

Why LiteRT β€” the acceleration story

MDX-Net delegates whole. It is a clean single-domain conv U-Net β€” no attention, no LSTM, no whole-tensor norm β€” which is exactly the op set XNNPACK, the GPU (ML Drift / OpenCL) and (on capable SoCs) the NPU map end-to-end. With the STFT pulled out of the graph, the accelerator sees only convs, transposed-convs and the TDF fully-connected blocks, so the whole graph delegates in one partition.

fp16 is the ~2Γ— lever and it's safe here. fp16 (not int8) is the quality-safe speedup, but only for a model with no whole-tensor reduction to overflow the fp16 ceiling (65504). MDX has only per-channel BatchNorm, which folds into the conv β€” nothing overflows:

Model peak activation fp16 ceiling headroom
9482 559 65504 117Γ—
Voc FT 1384 65504 47Γ—

fp16 is requested via the .tflite metadata flag reduced_precision_support = "fp16accfp16" (the fast accumulate value β€” measured quality-safe for MDX), which lets XNNPACK run the delegated ops in fp16 on ARMv8.2-FP16 cores.

The GPU fix β€” un-fuse TRANSPOSE_CONV's ReLU

The converter fuses each decoder ConvTranspose + BN + ReLU into one TRANSPOSE_CONV op with a fused ReLU β€” and a fused activation is precisely what tags the op v4. LiteRT 2.1.6's OpenCL delegate caps TRANSPOSE_CONV at v3, whose semantics have no fused activation. That lone v4 op therefore fails GPU-only compilation outright β€” and with a CPU rung in the set, silently strands all five decoder upsamplers on the CPU.

The export un-fuses it: each TRANSPOSE_CONV(+fused ReLU) becomes a legitimately-v3 bias-only TRANSPOSE_CONV followed by a standalone RELU op (which the GPU supports). The whole graph then delegates in one partition and computes the right function. (Simply force-lowering the v4 tag to v3 instead β€” the earlier attempt β€” made the GPU accept the graph but silently drop the ReLU on all five decoder upsamplers, producing an audible broadband buzz.) CPU numerics are unchanged.

Reproducing the export

The two .tflite were produced by export/export_mdx_litert.py, and the export is deterministic β€” re-running it from the source ONNX yields the shipped files byte-for-byte (verified via SHA-256).

# heavy converter env (Python 3.11):
pip install torch==2.9.1 --index-url https://download.pytorch.org/whl/cpu
pip install -r export/requirements.txt

python export/export_mdx_litert.py UVR_MDXNET_9482.onnx     UVR_MDXNET_9482.fp16acc.tflite
python export/export_mdx_litert.py UVR-MDX-NET-Voc_FT.onnx  UVR-MDX-NET-Voc_FT.fp16acc.tflite

Six gated steps: onnx2torch β†’ SNR-gate (>100 dB vs ONNX Runtime) β†’ litert_torch export (NCHW, static dim_t=256) β†’ +fp16 metadata β†’ un-fuse TRANSPOSE_CONV ReLU β†’ verify (CPU SNR, output shape, no op above the GPU v3 cap). Source ONNX: the UVR MDX-Net releases from TRvlvr/model_repo, also on the Hub at Politrees/UVR_resources.

onnx2tf does not work here — its per-op NCHW→NHWC guessing breaks the TDF MatMuls. The PyTorch → StableHLO → TFLite route (litert_torch) does no layout guessing and keeps NCHW I/O, so the app's complex-as-channels packing feeds the model directly.

Fidelity & performance

  • Fidelity: the fp16 .tflite is gated at > 100 dB CPU SNR vs the ONNX reference; measured β‰ˆ 109 dB (9482) and β‰ˆ 112 dB (Voc FT) after the un-fuse step. Validated by ear on-device.
  • Performance (one measured device β€” Xiaomi Redmi Note 10 Pro, Snapdragon 732G / Adreno 618, Android 13): XNNPACK-fp16 runs the graph at RTF β‰ˆ 1.0 (roughly real-time) as the universal CPU floor; the GPU path (whole-graph LITERT_CL partition) runs clean and faster than XNNPACK after the un-fuse fix. Numbers vary by SoC; treat these as one data point, not a benchmark.
  • NPU: the rung costs one word in the accelerator set (Accelerator.NPU), but it is hardware-gated (SM8550/8650/8750/8850, listed MediaTek SoCs, Tensor G3–G6) and needs ~20–110 MB of vendor libraries shipped in the APK. Deferred/untested β€” see above.

Attribution & license

Released under the MIT License, matching the upstream UVR MDX-Net models.

This repo's contribution: the ONNX β†’ LiteRT fp16 conversion, the whole-graph GPU fix (un-fusing TRANSPOSE_CONV's ReLU), the runnable separate.py reference, and the Android integration recipe. The weights are the original UVR models, format-converted.

Downloads last month
161
Inference Providers NEW
This model isn't deployed by any Inference Provider. πŸ™‹ Ask for provider support

Model tree for gyoom-sa/UVR-MDX-LiteRT

Quantized
(1)
this model