Files
SoulStream/src-tauri/src/vocal_sep.rs
T

500 lines
20 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Learned vocal separation for the lyric aligner — the "honest fix" the
//! vocals.rs reducer comment promises: a real separator lifting the voice
//! out of the band before CTC alignment, instead of band-pass pre-emphasis.
//!
//! Model: UVR-MDX-NET Voc_FT (KUIELab MDX-Net / TFC-TDF v2, the Ultimate
//! Vocal Remover project's vocal fine-tune; MIT with credit). Chosen
//! 2026-08-19 from the verified table in
//! research/lyrics-vocal-separation/candidates.md: 63.7 MiB, ONNX opset 13 —
//! under the inbox-WinML ceiling this machine PROVED by running the MMS
//! aligner (opset 14) — static `input`/`output` [1,4,3072,256] f32, vocals
//! SDR 9.64 on the MVSEP multisong benchmark (+1.5 dB over htdemucs at a
//! fifth of the size; the RoFormer/Demucs ONNX exports are opset 17–23 and
//! cannot load here as published).
//!
//! Posture is lyric_align.rs's exactly: WinML (no onnxruntime, no child
//! processes), download only from the user's explicit Settings click, the
//! session cached in a static — and NOTHING depends on this module. When
//! the model is absent or anything fails, the aligner keeps its band-passed
//! mixture input (the ctc-v4 behavior).
//!
//! The inference recipe is UVR's own non-overlap framing, kept bit-faithful
//! so the model sees what it was trained on: stereo 44.1 kHz → per-chunk
//! centered STFT (n_fft 7680, hop 1024, periodic Hann, reflect pad) → crop
//! to 3072 bins → model (channels [L_re, L_im, R_re, R_im]) → vocal
//! spectrogram → zero-pad the cropped bins back → WOLA iSTFT → trim
//! n_fft/2 from each end → butt-join. Each chunk contributes 253,440 fresh
//! samples (~5.75 s), so a four-minute track is ~42 model evaluations.
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use std::time::Instant;
use realfft::num_complex::Complex;
use realfft::{ComplexToReal, RealFftPlanner, RealToComplex};
use tauri::Emitter;
use windows::{
core::{Interface, HSTRING},
AI::MachineLearning::{
LearningModel, LearningModelBinding, LearningModelDevice, LearningModelDeviceKind,
LearningModelSession, TensorFloat,
},
Win32::System::WinRT::{RoInitialize, RO_INIT_MULTITHREADED},
};
const MODEL_URL: &str =
"https://github.com/TRvlvr/model_repo/releases/download/all_public_uvr_models/UVR-MDX-NET-Voc_FT.onnx";
/// Exact pinned size, shown in Settings and enforced after download — if the
/// asset is ever republished with different bytes, refuse rather than trust.
/// (Verified against the served bytes AND the file's own protobuf tail:
/// opset 13, `input`/`output` [batch,4,3072,256]. Kim_Vocal_2 is 66,759,214
/// — a first pin mistakenly used that sibling's size, and this guard caught
/// it exactly as designed.)
const MODEL_BYTES: u64 = 66_762_490;
// UVR MDX family constants (their model_data.json for this checkpoint:
// dim_f 3072, dim_t 2^8, n_fft 7680; hop is the architecture's fixed 1024).
const N_FFT: usize = 7_680;
const HOP: usize = 1_024;
const BINS: usize = N_FFT / 2 + 1; // 3841
const DIM_F: usize = 3_072;
const DIM_T: usize = 256;
/// Samples one model window covers: 256 centered STFT frames at hop 1024.
const CHUNK: usize = HOP * (DIM_T - 1); // 261,120
/// STFT edge effects live inside half a window; UVR trims this from each
/// chunk end and butt-joins the rest — no cross-fade, nothing to tune, and
/// the kept region sits in full window-overlap coverage by construction.
const TRIM: usize = N_FFT / 2; // 3840
/// Fresh samples each chunk contributes (~5.75 s at 44.1 kHz).
const GEN: usize = CHUNK - 2 * TRIM; // 253,440
/// Flat f32 count of the model's input and output: [1, 4, 3072, 256].
const TENSOR_LEN: usize = 4 * DIM_F * DIM_T;
struct Engine {
session: LearningModelSession,
/// Which device kind actually carried the smoke eval — for the log line.
device: &'static str,
}
static ENGINE: Mutex<Option<Engine>> = Mutex::new(None);
fn model_file(app: &tauri::AppHandle) -> Result<PathBuf, String> {
Ok(crate::lyric_align::models_dir(app)?.join("uvr-mdx-voc-ft.onnx"))
}
pub fn model_present(app: &tauri::AppHandle) -> bool {
matches!(model_file(app), Ok(m) if m.is_file())
}
#[tauri::command]
pub fn vocal_sep_status(app: tauri::AppHandle) -> serde_json::Value {
let size = model_file(&app)
.ok()
.and_then(|p| std::fs::metadata(p).ok())
.map(|m| m.len())
.unwrap_or(0);
serde_json::json!({
"present": model_present(&app),
"bytes": size,
"downloadBytes": MODEL_BYTES,
})
}
/// Download the separation model. Runs ONLY from the user's explicit
/// Settings click — the consent-before-download charter rule. Streams to a
/// temp file, verifies the exact pinned byte count, renames atomically,
/// emits `lyrics:sep-model-progress` along the way.
#[tauri::command]
pub async fn vocal_sep_download(app: tauri::AppHandle) -> Result<(), String> {
let client = reqwest::Client::builder()
.user_agent("SoulStream/0.1 (vocal separation)")
.build()
.map_err(|e| e.to_string())?;
let dest = model_file(&app)?;
let tmp = dest.with_extension("onnx.downloading");
let mut response = client
.get(MODEL_URL)
.send()
.await
.map_err(|e| e.to_string())?
.error_for_status()
.map_err(|e| e.to_string())?;
let total = response.content_length().unwrap_or(MODEL_BYTES);
let mut file = std::fs::File::create(&tmp).map_err(|e| e.to_string())?;
let mut received: u64 = 0;
let mut last_emit: u64 = 0;
use std::io::Write;
while let Some(chunk) = response.chunk().await.map_err(|e| e.to_string())? {
file.write_all(&chunk).map_err(|e| e.to_string())?;
received += chunk.len() as u64;
if received - last_emit > 2 * 1024 * 1024 {
last_emit = received;
let _ = app.emit(
"lyrics:sep-model-progress",
serde_json::json!({ "received": received, "total": total }),
);
}
}
drop(file);
if received != MODEL_BYTES {
let _ = std::fs::remove_file(&tmp);
return Err(format!(
"download size mismatch: {received} bytes, expected {MODEL_BYTES}"
));
}
std::fs::rename(&tmp, &dest).map_err(|e| e.to_string())?;
let _ = app.emit(
"lyrics:sep-model-progress",
serde_json::json!({ "received": received, "total": total, "done": true }),
);
Ok(())
}
fn ensure_engine(app: &tauri::AppHandle) -> Result<(), String> {
let mut guard = ENGINE.lock().unwrap();
if guard.is_some() {
return Ok(());
}
let model_path = model_file(app)?;
if !model_path.is_file() {
return Err("separation model not downloaded".into());
}
// Same WinRT posture as the aligner: initialize, tolerate an existing
// apartment, never uninitialize someone else's.
match unsafe { RoInitialize(RO_INIT_MULTITHREADED) } {
Ok(()) => {}
Err(e) if e.code().0 as u32 == 0x8001_0106 => {}
Err(e) => return Err(format!("WinRT init failed: {e}")),
}
let model =
LearningModel::LoadFromFilePath(&HSTRING::from(model_path.to_string_lossy().as_ref()))
.map_err(|e| format!("could not load separation model: {e}"))?;
// DirectX first (DirectML — the discrete GPU when there is one): a track
// is ~42 conv-net evaluations and the GPU turns minutes into seconds.
// The zeros smoke eval proves the device can actually RUN the graph —
// "loads but cannot execute" is a real DML failure mode — then Cpu is
// the quiet fallback, so this works everywhere the aligner does.
for (kind, name) in [
(LearningModelDeviceKind::DirectX, "DirectX"),
(LearningModelDeviceKind::Cpu, "Cpu"),
] {
let Ok(device) = LearningModelDevice::Create(kind) else {
continue;
};
let Ok(session) = LearningModelSession::CreateFromModelOnDevice(&model, &device) else {
continue;
};
let zeros = vec![0.0f32; TENSOR_LEN];
if eval_chunk(&session, &zeros).is_ok() {
*guard = Some(Engine {
session,
device: name,
});
return Ok(());
}
}
Err("no WinML device could run the separation model".into())
}
fn eval_chunk(session: &LearningModelSession, flat: &[f32]) -> Result<Vec<f32>, String> {
let input = TensorFloat::CreateFromShapeArrayAndDataArray(
&[1, 4, DIM_F as i64, DIM_T as i64],
flat,
)
.map_err(|e| format!("could not create input tensor: {e}"))?;
let binding = LearningModelBinding::CreateFromSession(session)
.map_err(|e| format!("could not create binding: {e}"))?;
binding
.Bind(&HSTRING::from("input"), &input)
.map_err(|e| format!("could not bind input: {e}"))?;
let result = session
.Evaluate(&binding, &HSTRING::from("vocal-sep"))
.map_err(|e| format!("inference failed: {e}"))?;
if !result.Succeeded().unwrap_or(false) {
return Err("inference did not succeed".into());
}
let out: TensorFloat = result
.Outputs()
.and_then(|o| o.Lookup(&HSTRING::from("output")))
.map_err(|e| format!("output missing: {e}"))?
.cast()
.map_err(|e| format!("output wrong type: {e}"))?;
let values: Vec<f32> = out
.GetAsVectorView()
.map_err(|e| format!("could not read output: {e}"))?
.into_iter()
.collect();
if values.len() != TENSOR_LEN {
return Err(format!("unexpected output length {}", values.len()));
}
Ok(values)
}
// ---- STFT plumbing (pure DSP — no WinML below this line) -------------------
struct Stft {
r2c: Arc<dyn RealToComplex<f32>>,
c2r: Arc<dyn ComplexToReal<f32>>,
/// Periodic Hann — torch.hann_window's default, what the model trained on.
window: Vec<f32>,
/// WOLA denominator Σ w² over the padded chunk layout. The 256 frames
/// tile the padded buffer exactly, so it is identical for every chunk.
envelope: Vec<f32>,
}
impl Stft {
fn new() -> Self {
let mut planner = RealFftPlanner::<f32>::new();
let r2c = planner.plan_fft_forward(N_FFT);
let c2r = planner.plan_fft_inverse(N_FFT);
let window: Vec<f32> = (0..N_FFT)
.map(|n| 0.5 - 0.5 * (2.0 * std::f32::consts::PI * n as f32 / N_FFT as f32).cos())
.collect();
let mut envelope = vec![1e-8f32; CHUNK + 2 * TRIM];
for t in 0..DIM_T {
for (n, w) in window.iter().enumerate() {
envelope[t * HOP + n] += w * w;
}
}
Stft {
r2c,
c2r,
window,
envelope,
}
}
/// Centered STFT of one CHUNK-sized segment: reflect-pad n_fft/2 each
/// side (torch.stft center=True), frames at the hop — frame-major
/// [t * BINS + f].
fn forward(&self, chunk: &[f32]) -> Result<Vec<Complex<f32>>, String> {
debug_assert_eq!(chunk.len(), CHUNK);
let mut padded = vec![0.0f32; CHUNK + 2 * TRIM];
padded[TRIM..TRIM + CHUNK].copy_from_slice(chunk);
for i in 1..=TRIM {
padded[TRIM - i] = chunk[i];
padded[TRIM + CHUNK - 1 + i] = chunk[CHUNK - 1 - i];
}
let mut spec = vec![Complex::default(); DIM_T * BINS];
let mut frame = vec![0.0f32; N_FFT];
for t in 0..DIM_T {
for (n, w) in self.window.iter().enumerate() {
frame[n] = padded[t * HOP + n] * w;
}
self.r2c
.process(&mut frame, &mut spec[t * BINS..(t + 1) * BINS])
.map_err(|e| format!("stft failed: {e}"))?;
}
Ok(spec)
}
/// WOLA inverse of a frame-major spectrogram back to CHUNK samples with
/// the centered pad trimmed off — torch.istft's window-envelope
/// normalization. Consumes the spectrogram (the FFT scratches it).
fn inverse(&self, spec: &mut [Complex<f32>]) -> Result<Vec<f32>, String> {
let mut acc = vec![0.0f32; CHUNK + 2 * TRIM];
let mut frame = vec![0.0f32; N_FFT];
let scale = 1.0 / N_FFT as f32; // realfft's c2r is unnormalized
for t in 0..DIM_T {
let row = &mut spec[t * BINS..(t + 1) * BINS];
// The model emits free re/im everywhere; a real inverse needs
// pure-real DC and Nyquist. Nyquist sits in the cropped-zero
// region already, DC gets clamped here.
row[0].im = 0.0;
row[BINS - 1].im = 0.0;
self.c2r
.process(row, &mut frame)
.map_err(|e| format!("istft failed: {e}"))?;
for (n, w) in self.window.iter().enumerate() {
acc[t * HOP + n] += frame[n] * scale * w;
}
}
Ok((TRIM..TRIM + CHUNK)
.map(|i| acc[i] / self.envelope[i])
.collect())
}
}
/// Pack two frame-major spectrograms into the model's flat input — channel
/// order [L_re, L_im, R_re, R_im] (UVR's reshape/permute order), frequency
/// cropped to DIM_F.
fn pack_input(spec_l: &[Complex<f32>], spec_r: &[Complex<f32>]) -> Vec<f32> {
let mut flat = vec![0.0f32; TENSOR_LEN];
for (c, spec) in [spec_l, spec_r].into_iter().enumerate() {
for f in 0..DIM_F {
for t in 0..DIM_T {
let z = spec[t * BINS + f];
flat[((2 * c) * DIM_F + f) * DIM_T + t] = z.re;
flat[((2 * c + 1) * DIM_F + f) * DIM_T + t] = z.im;
}
}
}
flat
}
/// Unpack the model's flat output back into two frame-major spectrograms.
/// Bins at and above DIM_F stay zero — the crop restored as silence, which
/// for a vocal stem is honest (nothing sung lives above ~17.6 kHz).
fn unpack_output(flat: &[f32]) -> (Vec<Complex<f32>>, Vec<Complex<f32>>) {
let mut left = vec![Complex::default(); DIM_T * BINS];
let mut right = vec![Complex::default(); DIM_T * BINS];
for (c, spec) in [&mut left, &mut right].into_iter().enumerate() {
for f in 0..DIM_F {
for t in 0..DIM_T {
spec[t * BINS + f] = Complex::new(
flat[((2 * c) * DIM_F + f) * DIM_T + t],
flat[((2 * c + 1) * DIM_F + f) * DIM_T + t],
);
}
}
}
(left, right)
}
/// Chunk a stereo 44.1 kHz track through `run` (the [1,4,3072,256] →
/// same-shape spectrogram transform) and reassemble the returned stem.
/// UVR's non-overlap framing: TRIM zeros lead the track so chunk c's kept
/// middle lands exactly at c·GEN, edge effects fall in the trimmed halves,
/// and segments butt-join.
fn process_chunks(
stft: &Stft,
left: &[f32],
right: &[f32],
mut run: impl FnMut(&[f32]) -> Result<Vec<f32>, String>,
) -> Result<(Vec<f32>, Vec<f32>), String> {
let len = left.len().min(right.len());
if len < HOP {
return Err("audio too short to separate".into());
}
let chunks = len.div_ceil(GEN);
let padded_len = TRIM + chunks * GEN + TRIM;
let mut mix_l = vec![0.0f32; padded_len];
let mut mix_r = vec![0.0f32; padded_len];
mix_l[TRIM..TRIM + len].copy_from_slice(&left[..len]);
mix_r[TRIM..TRIM + len].copy_from_slice(&right[..len]);
let mut stem_l = vec![0.0f32; chunks * GEN];
let mut stem_r = vec![0.0f32; chunks * GEN];
for c in 0..chunks {
let s = c * GEN;
let spec_l = stft.forward(&mix_l[s..s + CHUNK])?;
let spec_r = stft.forward(&mix_r[s..s + CHUNK])?;
let out = run(&pack_input(&spec_l, &spec_r))?;
if out.len() != TENSOR_LEN {
return Err(format!("unexpected chunk output length {}", out.len()));
}
let (mut vl, mut vr) = unpack_output(&out);
let wl = stft.inverse(&mut vl)?;
let wr = stft.inverse(&mut vr)?;
stem_l[s..s + GEN].copy_from_slice(&wl[TRIM..TRIM + GEN]);
stem_r[s..s + GEN].copy_from_slice(&wr[TRIM..TRIM + GEN]);
// Politeness between chunks — this thread must never contend with
// the audio path for sustained stretches (the passage loop's rule).
std::thread::sleep(std::time::Duration::from_millis(3));
}
stem_l.truncate(len);
stem_r.truncate(len);
Ok((stem_l, stem_r))
}
/// Whole-file vocal stem as 16 kHz mono for the CTC aligner: decode →
/// chunked WinML separation → mid fold + anti-alias resample (player crate).
/// Fails soft by design — the caller falls back to the band-passed mixture.
pub fn separate_vocals_16k(
app: &tauri::AppHandle,
path: &std::path::Path,
) -> Result<Vec<f32>, String> {
if !model_present(app) {
return Err("separation model not downloaded".into());
}
let [left, right] = player::vocals::separation_input_44k1(path)?;
ensure_engine(app)?;
let guard = ENGINE.lock().unwrap();
let engine = guard.as_ref().ok_or("engine unavailable")?;
let stft = Stft::new();
let started = Instant::now();
let (stem_l, stem_r) = process_chunks(&stft, &left, &right, |flat| {
eval_chunk(&engine.session, flat)
})?;
log::info!(
"vocal_sep: {:.0}s of audio separated in {:.1}s on {}",
stem_l.len() as f32 / 44_100.0,
started.elapsed().as_secs_f32(),
engine.device
);
Ok(player::vocals::stem_pcm_16k(&stem_l, &stem_r))
}
#[cfg(test)]
mod tests {
use super::*;
/// The WOLA plumbing alone — forward STFT straight back through the
/// inverse, no crop — must be per-sample exact: the numerator and the
/// envelope share the same window algebra, so any real error here is a
/// tiling or normalization bug, not numerics.
#[test]
fn stft_wola_roundtrip_is_exact() {
let chunk: Vec<f32> = (0..CHUNK)
.map(|i| (2.0 * std::f32::consts::PI * 440.0 * i as f32 / 44_100.0).sin() * 0.5)
.collect();
let stft = Stft::new();
let mut spec = stft.forward(&chunk).unwrap();
let back = stft.inverse(&mut spec).unwrap();
let worst = chunk
.iter()
.zip(&back)
.map(|(a, b)| (a - b).abs())
.fold(0.0f32, f32::max);
assert!(worst < 1e-4, "un-cropped WOLA must be exact, worst {worst}");
}
/// An identity model (output spectrogram = input spectrogram) must hand
/// the track back through the whole STFT → crop → pack → unpack →
/// zero-pad → iSTFT → trim → butt-join pipeline: proves the frame
/// tiling, the chunk joins, and the layout round-trip all at once.
/// Tolerance: the reflect pad puts a derivative kink at each chunk
/// edge whose broadband energy reaches above bin 3072, and the model
/// crop discards it — torch.stft + UVR's own framing lose exactly the
/// same (~1% here); the model trained on that world.
#[test]
fn identity_model_reconstructs_across_chunk_joins() {
let len = GEN + GEN / 2; // two chunks, one join, one truncation
let track: Vec<f32> = (0..len)
.map(|i| (2.0 * std::f32::consts::PI * 440.0 * i as f32 / 44_100.0).sin() * 0.5)
.collect();
let stft = Stft::new();
let (out_l, out_r) =
process_chunks(&stft, &track, &track, |flat| Ok(flat.to_vec())).unwrap();
assert_eq!(out_l.len(), len);
assert_eq!(out_r.len(), len);
let mut worst = 0.0f32;
for (a, b) in track.iter().zip(&out_l) {
worst = worst.max((a - b).abs());
}
assert!(
worst < 2e-2,
"identity round-trip must reconstruct the tone, worst err {worst}"
);
// The join at GEN must not be a seam beyond that same crop error.
let seam = (GEN - 50..GEN + 50)
.map(|i| (track[i] - out_l[i]).abs())
.fold(0.0f32, f32::max);
assert!(seam < 2e-2, "butt-join must be seamless, err {seam}");
}
/// The kept region of every chunk sits in full window-overlap coverage:
/// the WOLA envelope there is flat and far from zero.
#[test]
fn envelope_is_stable_where_samples_are_kept() {
let stft = Stft::new();
let kept = &stft.envelope[2 * TRIM..2 * TRIM + GEN];
let min = kept.iter().cloned().fold(f32::INFINITY, f32::min);
let max = kept.iter().cloned().fold(0.0f32, f32::max);
assert!(min > 2.0, "envelope must be well-conditioned, min {min}");
assert!(
max / min < 1.5,
"envelope must be near-flat in the kept region, {min}..{max}"
);
}
}