How it works

Unmix

A song is its parts added together, so pulling the singer out is solving g = Af for f. The catch is that the recording pins down almost nothing. It starts with how an MP3 stores sound and the whole problem written out as equations. Then come the ideas one at a time, each with the lines of code that carry it: six from the first round, two from a second round that treats the MP3 file itself as the measurement, and the browser version. Results, limits and how to reproduce them are at the end.

  • Inverse problems
  • Convex optimisation
  • Signal processing
  • Pitch tracking
  • Evaluation

From a stereo MP3 to a voice

  1. Trackfind the singer's pitch from the centre of the mix
  2. Combmark the time-frequency bins at that pitch's harmonics
  3. SolvePDHG splits the mix into voice, harmonic and percussive parts
  4. Refitre-split the recording with the parts as soft masks
  5. Scorecompare with the true parts of a synthetic song

The ideas

  1. AHow an MP3 stores sound
  2. BThe problem, in equations
  3. 01Mixing is a matrix with a huge null space
  4. 02Operators you can check
  5. 03Find the singer before separating
  6. 04A prior for each kind of part
  7. 05PDHG: one dual variable per term
  8. 06Keep the voice feasible, then refit
  9. 07The MP3 file is the measurement
  10. 08Smoothness that follows the pitch
  11. 09The same solver in your browser
  12. →Results
  13. →Limits and next steps
  14. →Prior work
  15. →Reproduce
  16. →Read further
A

How an MP3 stores sound

Background: what the file actually contains

An MP3 never stores the waveform. The encoder cuts each channel into granules of 576 samples (two per frame at 32–48 kHz, one per frame at 16–24 kHz, as in the test songs here). It turns each granule into 576 frequency coefficients, rounds those coefficients as coarsely as the ear allows, and stores the rounded integers. The decoder only undoes the transform, so everything it plays is a linear function of those integers.

1. The transform

A bank of 32 filters first splits the signal into 32 equal-width subbands (250 Hz each at 16 kHz). Each subband signal is then split again, into 18 frequencies, by a modified discrete cosine transform (MDCT) over 36 samples that overlap the previous granule by half:

Xk=∑n=02N−1wnxncos⁡[πN(n+12+N2)(k+12)],k=0,…,N−1,N=18.X_k \;=\; \sum_{n=0}^{2N-1} w_n\, x_n \cos\!\Big[\frac{\pi}{N}\Big(n + \frac12 + \frac N2\Big)\Big(k + \frac12\Big)\Big], \qquad k = 0,\dots,N-1,\quad N = 18.

That gives 32 × 18 = 576 coefficients per granule and channel, the same number as samples: the MDCT is critically sampled. Its outputs alias, but with the right window w the aliasing of neighbouring granules cancels on overlap-add (time-domain aliasing cancellation), so without rounding the decoder would rebuild the input exactly. Around sharp attacks the encoder switches to three short MDCTs with N = 6, so that rounding noise doesn't smear audibly before the attack (pre-echo).

2. The rounding

The coefficients are grouped into about 21 scalefactor bands, narrow at low frequencies and wide at high ones, like the ear's critical bands. A psychoacoustic model estimates how much noise each band can hide under the louder sounds around it, and picks a quantiser step Δb for the band to stay under that level. The file stores one integer qk per coefficient, and the decoder turns it back into

X^k=sign⁡(qk)|qk|4/3Δb,Δb=2(G−210)/42−m(sb+πb),\hat X_k \;=\; \operatorname{sign}(q_k)\,|q_k|^{4/3}\,\Delta_b, \qquad \Delta_b \;=\; 2^{(G-210)/4}\;2^{-m\,(s_b + \pi_b)},

where G is the granule's global gain, sb the band's scalefactor, πb a fixed pre-emphasis boost for high bands (when the granule turns it on), and m ∈ {½, 1} a per-granule scale. Short blocks add a gain 2−2gw per window. The 4/3 power makes the step grow with the amplitude, so loud coefficients are rounded more coarsely in absolute terms, which the ear also tolerates.

3. Packaging

The integers are Huffman-coded, which is lossless. Stereo is usually coded as mid and side, M = (L + R)/√2 and S = (L − R)/√2, switched per granule, because the side channel of most music is small and cheap. Frequencies above the encoder's lowpass are simply zero. All of the loss happens in step 2.

4. What this means for separation

Decoding is linear, x̂ = S X̂, with S the synthesis (IMDCT, window, overlap-add, polyphase filterbank). So the file doesn't pin down one waveform. For every coefficient it says the original coefficient lay in the interval of values that round to qk. LAME, the encoder used here, rounds to the nearest decoded level, so that interval runs between the midpoints to the neighbouring levels:

(Ax)k∈[ℓk,uk],ℓk=12((|qk|−1)4/3+|qk|4/3)Δb,uk=12(|qk|4/3+(|qk|+1)4/3)Δb(qk>0),\begin{gathered} \big(A x\big)_k \in [\ell_k,\, u_k], \\ \ell_k = \tfrac12\big((|q_k|-1)^{4/3} + |q_k|^{4/3}\big)\Delta_b, \qquad u_k = \tfrac12\big(|q_k|^{4/3} + (|q_k|+1)^{4/3}\big)\Delta_b \qquad (q_k > 0), \end{gathered}

mirrored for qk < 0, and |(Ax)k| ≤ Δb/2 for qk = 0. Here A is the encoder's analysis (filterbank then MDCT). Round 1 uses only the decoded waveform, which is the centre of every cell. Round 2 (section 07) uses the whole cell as the measurement. A coded zero above the lowpass, or in a band the encoder dropped entirely, carries no information, so those cells are left out.

B

The problem, in equations

Every model on this page in one place

The measurement

The recording has two channels gL, gR of T samples (16 kHz). It is modelled as a voice v, panned to the centre so the same signal appears in both channels, plus a harmonic accompaniment hc (chords, bass) and percussion pc in each channel:

gc=v+hc+pc,c∈{L,R}.g_c \;=\; v + h_c + p_c, \qquad c \in \{L, R\}.

That is 5T unknowns and 2T equations. Moving any signal δ from one part to another, (hc + δ, pc − δ), leaves g unchanged, so the null space has dimension 3T and the data alone decide nothing about the split. Everything else comes from the priors below.

The voice lives on a comb

F is a short-time Fourier transform (1024-sample window, hop R = 256) scaled to be a tight frame, FTF = I. Given the tracked pitch f0(n) in frame n (section 03), the comb M marks the bins near its harmonics, and the voice is built only from coefficients z on the comb (synthesis form, section 06):

v=FT(M⊙z),Mn,k=1 if |fk−jf0(n)|≤max⁡(1.5Δf,0.03jf0(n)) for some j≥1,else 0,\begin{gathered} v = F^{T}(M \odot z), \\ M_{n,k} = 1 \;\text{ if }\; |f_k - j f_0(n)| \le \max\big(1.5\,\Delta f,\; 0.03\, j f_0(n)\big) \;\text{ for some } j \ge 1, \quad \text{else } 0, \end{gathered}

counting harmonics j·f0(n) up to 5 kHz, and Mn,· = 0 in frames whose pitch is outside 75–1100 Hz, the range of human singing. Δf = 15.6 Hz is the bin spacing.

Round 1: the convex problem

minz,h,p⁡αV‖z‖1+αH∑c‖DtEFhc‖1+αP∑c∑n‖(Fpc)n,:‖2subject toFT(M⊙z)+hc+pc=gc,c∈{L,R}.\begin{aligned} \min_{z,\,h,\,p}\quad & \alpha_V \|z\|_1 \;+\; \alpha_H \sum_{c} \|D_t E F h_c\|_1 \;+\; \alpha_P \sum_{c}\sum_{n} \|(F p_c)_{n,:}\|_2 \\ \text{subject to}\quad & F^{T}(M \odot z) + h_c + p_c = g_c, \qquad c \in \{L, R\}. \end{aligned}
  • Voice, αV‖z‖1: few active coefficients on the comb.
  • Harmony, ‖DtEFhc‖1: sustained notes barely change from frame to frame. Dt is the forward difference over frames, (DtY)n,k = Yn+1,k − Yn,k. A steady tone's coefficient still rotates in phase by its frequency times the hop, so E = diag(e−iθn,k) first removes that rotation, using the mixture's instantaneous frequency (section 04):
θn,k=R∑m≤nω^m,k,ω^n,k=ωk+1Rwrap⁡(∠Gn,k−∠Gn−1,k−ωkR),G=F(gL+gR2),ωk=the bin’s centre frequency (rad/sample).\begin{gathered} \theta_{n,k} = R \sum_{m \le n} \hat\omega_{m,k}, \qquad \hat\omega_{n,k} = \omega_k + \frac{1}{R}\,\operatorname{wrap}\!\big(\angle G_{n,k} - \angle G_{n-1,k} - \omega_k R\big), \\ G = F\Big(\tfrac{g_L + g_R}{2}\Big), \qquad \omega_k = \text{the bin's centre frequency (rad/sample)}. \end{gathered}
  • Percussion, Σn‖(Fpc)n,:‖2: a group norm over each frame's whole spectrum, so only a few frames (the hits) are active, but broadband when they are.
  • The constraint: the three parts must add up to the recording exactly, in both channels. Round 1 also tried a tolerance per frequency band, ‖BbF(v + hc + pc − gc)‖2 ≤ εb; it made no difference (section 05).

Solving it: PDHG

Every term is convex but non-smooth. Write x = (z, h, p), and give each term i its linear operator Ki: KHx = DtEFhc, KPx = Fpc, KDx = F(FT(M ⊙ z) + hc + pc), with fD the indicator of {Fgc}. The problem becomes a saddle point,

minx⁡maxy⁡αV‖z‖1+∑i(⟨Kix,yi⟩−fi*(yi)),\min_{x}\;\max_{y}\;\; \alpha_V\|z\|_1 + \sum_i \Big( \langle K_i x,\, y_i\rangle - f_i^{*}(y_i) \Big),

and the primal–dual hybrid gradient method (Chambolle–Pock) alternates a step on each dual variable, a step on the primal, and an extrapolation:

yi←proxσifi*⁡(yi+σiKix‾)for each term i,x+←proxταV‖⋅‖1⁡(x−τ∑iKi*yi),x‾←2x+−x,x←x+.\begin{aligned} y_i &\leftarrow \operatorname{prox}_{\sigma_i f_i^{*}}\big(y_i + \sigma_i K_i \bar x\big) \quad\text{for each term } i,\\ x^{+} &\leftarrow \operatorname{prox}_{\tau \alpha_V \|\cdot\|_1}\Big(x - \tau \sum_i K_i^{*} y_i\Big),\\ \bar x &\leftarrow 2x^{+} - x, \qquad x \leftarrow x^{+}. \end{aligned}

Each prox is one line. The dual of an ℓ1 term is a projection onto a box, y ↦ y / max(1, |y|/α). The group norm projects each frame onto a ball, yn,: ↦ yn,: / max(1, ‖yn,:‖2/αP). By Moreau's identity the data constraint gives y ↦ y − σDFgc. The primal step soft-thresholds the voice and keeps it on the comb,

z←M⊙wmax⁡(0,1−ταV|w|),w=z−τM⊙F(∑cFTyD,c),z \leftarrow M \odot w \,\max\!\Big(0,\; 1 - \frac{\tau \alpha_V}{|w|}\Big), \qquad w = z - \tau\, M \odot F\Big(\sum_c F^{T} y_{D,c}\Big),

while h and p take plain gradient steps. The step sizes are σi = γi/Li² and τ = 0.99/Σγi, with Li ≥ ‖Ki‖ (‖DtEF‖ ≤ 2, ‖F‖ = 1). Then τ Σ σiLi² = 0.99 < 1, the condition under which PDHG converges.

The refit

The ℓ1 penalty shrinks the voice, so the parts are used only as soft masks to re-split the recording. With Sj ∈ {Fv, Fhc, Fpc},

s^j,c=FT(|Sj|2∑l|Sl|2⊙Fgc),v^=12(s^v,L+s^v,R).\hat s_{j,c} = F^{T}\!\left( \frac{|S_j|^2}{\sum_l |S_l|^2} \odot F g_c \right), \qquad \hat v = \tfrac12\big(\hat s_{v,L} + \hat s_{v,R}\big).

The masks sum to one in every bin, so the new parts still add up to the recording exactly.

Round 2: the MP3 cells as the data term

Write yc = v + hc + pc for the model's mixture. The equality yc = gc is replaced by the cells from part A. The encoder's analysis is A = D−1ST, with D = diag(STS), because S is orthogonal up to a scale per coefficient. Rescaled by Ψ = D−1/2ST (so ΨΨT = I), the constraint over both coded channels is a box:

D1/2ℓ≤Ψ(yLyR)≤D1/2uon trusted cells,yD↦w−σDclip⁡(wσD,D1/2ℓ,D1/2u),w=yD+σDΨy.\begin{gathered} D^{1/2}\ell \;\le\; \Psi \begin{pmatrix} y_L \\ y_R \end{pmatrix} \;\le\; D^{1/2} u \quad \text{on trusted cells}, \\ y_D \mapsto w - \sigma_D \operatorname{clip}\!\Big(\frac{w}{\sigma_D},\, D^{1/2}\ell,\, D^{1/2}u\Big), \qquad w = y_D + \sigma_D\, \Psi y. \end{gathered}

The second formula is its dual update, again by Moreau's identity. Only this one line of the solver changes.

Round 2: smoothness that follows the pitch

A constant-Q transform C (36 bins per octave) shifts every harmonic by the same number of bins when the pitch moves. The new voice penalty αT‖DθCv‖1 takes its differences along that shift:

(Dθc)k,t=ρk,t[(1−βk,t)cκk,t,t+1+βk,tcκk,t+1,t+1]−ck,t,k+dt=κk,t+βk,t,dt=36log2⁡f0(t+1)f0(t),\begin{gathered} (D_\theta c)_{k,t} = \rho_{k,t}\Big[(1-\beta_{k,t})\, c_{\kappa_{k,t},\,t+1} + \beta_{k,t}\, c_{\kappa_{k,t}+1,\,t+1}\Big] - c_{k,t}, \\ k + d_t = \kappa_{k,t} + \beta_{k,t}, \qquad d_t = 36\,\log_2\frac{f_0(t+1)}{f_0(t)}, \end{gathered}

with κ an integer, 0 ≤ β < 1, and |dt| ≤ 3 (dt = 0 in unvoiced frames). The unit-modulus factor ρk,t removes the expected phase advance, so a steady sung partial has zero difference. In the outer loop it comes from the first solve's voice estimate v̂: with ak,t = (Cv̂)k+dt,t+1 · conj((Cv̂)k,t), ρk,t = conj(ak,t)/|ak,t|. The full round-2 problem is

minz,h,p⁡αV‖z‖1+αT‖DθCv‖1+αH∑c‖DtEFhc‖1+αP∑c∑n‖(Fpc)n,:‖2subject tov=FT(M⊙z),D1/2ℓ≤Ψy≤D1/2u  on trusted cells.\begin{aligned} \min_{z,\,h,\,p}\quad & \alpha_V \|z\|_1 + \alpha_T \|D_\theta C v\|_1 + \alpha_H \sum_c \|D_t E F h_c\|_1 + \alpha_P \sum_c\sum_n \|(F p_c)_{n,:}\|_2 \\ \text{subject to}\quad & v = F^{T}(M\odot z), \qquad D^{1/2}\ell \le \Psi\, y \le D^{1/2}u \ \text{ on trusted cells}. \end{aligned}

The range gate, the third idea, acts before the solve: a pitch frame is kept only if it lies within the line's own register, P5/1.25 ≤ f0(t) ≤ 1.25·P95, where P5 and P95 are percentiles of the tracked pitch.

Why the cells didn't help

The box is only as wide as the codec error. At 128 kbps that is −26.7 dB, about 5% of the signal's amplitude, while separation errors are 4–10 dB down, 30–60% of the voice's amplitude. Relaxing the equality to the box moves the feasible set by much less than the null space the priors have to resolve, so the extra freedom mostly lets the ℓ1 terms shrink the parts a little more. See Results for the numbers.

01

Mixing is a matrix with a huge null space

The tomography analogy, and where it breaks

Each part of the song is placed in the stereo field with a left gain and a right gain, then everything is added. That is a linear map from the parts f to the recording g, the same shape as a tomography scanner turning a body into projections. Here there are only two “views”, left and right, for K parts.

g=Af,A=[a1⊗Ia2⊗I⋯aK⊗I]∈ℝ2T×KTg = A f, \qquad A = \begin{bmatrix} a_1 \otimes I & a_2 \otimes I & \cdots & a_K \otimes I \end{bmatrix} \in \mathbb{R}^{2T \times KT}
Limited-angle tomographySource separation
projection anglespan positions of the instruments
tens of viewstwo views: left and right
underdetermined XA is 2T × KT, far more so
directional TV, pixel sparsitya prior for each kind of source
PDHG with a data-discrepancy constraintthe same

One difference matters. In tomography the trouble is conditioning: some singular values are tiny. Here A is perfectly conditioned on its row space, but its null space has dimension (K − 2)T. So the measurements fix almost nothing, and the priors carry nearly all the information.

make_stems.pylines 47–49
def pan(x, p):
    """p in [-1, 1]: -1 hard left, 0 centre (equal gain in both channels), 1 hard right."""
    return np.stack([x * np.sqrt((1 - p) / 2) * np.sqrt(2), x * np.sqrt((1 + p) / 2) * np.sqrt(2)]) / np.sqrt(2)
  1. One column block of A: the part x is copied into both channels, scaled by its two pan gains. The test songs are built this way, so the forward model is exactly right.
unmix.pylines 5–11
Unknowns (time domain):  v      the lead voice, mono, constrained to the stereo centre
                         h_L,h_R  harmonic accompaniment (chords, bass), stereo
                         p_L,p_R  percussion, stereo
Measurement:             g_c = v + h_c + p_c      (c = L, R)   i.e.  g = A f

The mixing matrix is perfectly conditioned on its row space but has a (K-2)T-dimensional null
space, so everything comes from the priors:
  1. The solver uses three unknowns. The voice is one signal shared by both channels, which builds “the singer is panned centre” into the model itself.
  2. The measurement equation the solver must respect: in each channel, voice plus harmonic plus percussive parts equals the recording.
  3. The key difference from tomography. A is perfectly conditioned on its row space, but anything can be added to one part and subtracted from another without changing g. The data decide almost nothing; the priors decide the rest.
02

Operators you can check

A tight STFT frame and its exact adjoint

All the priors live in the time-frequency plane, so the solver keeps applying a short-time Fourier transform F and its adjoint FT. PDHG only converges if FT really is the adjoint and the operator norms are known. Building F as a tight (Parseval) frame makes both easy: ‖Fx‖ = ‖x‖, so its norm is exactly 1.

unmix.pylines 31–45
class Frame:
    """Circular sqrt-Hann STFT with ||F x|| = ||x|| exactly (Parseval). Full complex spectrum."""

    def __init__(self, T, N=1024, hop=256):
        assert T % hop == 0
        self.T, self.N, self.hop = T, N, hop
        self.nF = T // hop
        n = np.arange(N)
        self.w = np.sqrt(0.5 - 0.5 * np.cos(2 * np.pi * n / N)).astype(np.float32)
        self.c = float((self.w ** 2).sum() / hop)
        self.idx = (np.arange(self.nF)[:, None] * hop + n[None, :]) % T
        self.scale = 1.0 / np.sqrt(N * self.c)

    def fwd(self, x):
        return (np.fft.fft(x[self.idx] * self.w, axis=1) * self.scale).astype(np.complex64)
  1. A square-root Hann window. Its square is a Hann window, and Hann windows at hop N/4 add up to a constant, which is what makes the frame tight.
  2. Frame indices wrap around modulo T, so the transform is circular and every sample is covered the same number of times, edges included.
  3. The one constant that turns “sums to a constant” into ‖Fx‖ = ‖x‖ exactly.
unmix.pylines 55–68
def fast_adj(F: Frame, Y):
    """Overlap-add adjoint without np.add.at (much faster): sum the hop-shifted frames."""
    fr = (np.real(np.fft.ifft(Y, axis=1)) * F.N * F.w * F.scale).astype(np.float32)
    out = np.zeros(F.T + F.N, dtype=np.float32)
    R = F.N // F.hop
    for r in range(R):                                   # frames r, r+R, r+2R ... don't overlap
        blk = fr[r::R].reshape(-1)
        s = r * F.hop
        out[s:s + blk.size] += blk
    out[:F.N] += out[F.T:F.T + F.N]                      # circular wrap
    return out[:F.T]


Frame.adj = fast_adj   # overlap-add adjoint
  1. Inverse FFT of each frame, windowed again. ifft divides by N, so multiplying by F.N gives the true adjoint of fft.
  2. Overlap-add without a scatter: frames R apart never overlap, so each of the R groups is laid end to end and added in one slice. It runs every iteration, several times, so speed matters.
  3. Fold the overhang back to the start, matching the wrap-around in the forward transform.
Checked numerically

The frame is Parseval to 7·10−7, every adjoint matches its operator to float precision, and ‖DtEF‖ ≈ 1.78, under the bound of 2 used for the step sizes. The time difference Dt and its adjoint DtT are written by hand next to it.

03

Find the singer before separating

Centre, vibrato and register, with no training

The strongest prior is the voice's pitch: a sung note only has energy at its harmonics. But a pitch tracker run on the whole mix follows whatever is loudest, and on the evaluation song it found the singer in only 3% of frames. Three cues fix most of that. The first is that lead vocals sit dead centre, so bins where left and right agree are kept and the rest are suppressed.

voiceline.pylines 21–24
def centre_signal(g, F, power=80):
    L, R = F.fwd(g[0]), F.fwd(g[1])
    psi = np.abs(L + R) ** 2 / (2 * (np.abs(L) ** 2 + np.abs(R) ** 2) + 1e-12)
    return F.adj((psi ** power) * (L + R) / 2)
  1. ψ is 1 when L = R and falls towards 0 as the two channels differ in level or phase. It is a per-bin “how centred is this” score.
  2. Raising ψ to the 80th power turns it into a sharp gate: only bins that are very nearly centred survive into the signal the tracker hears.
voiceline.pylines 58–68
def track(g, F, centre_power=80, vibrato=3.0, voicing=0.03, fold=True):
    import librosa
    c = centre_signal(g, F, centre_power) if centre_power else g.mean(axis=0)
    y = librosa.resample(c.astype(np.float64), orig_sr=SR, target_sr=PZ.SR)
    t, midi, _ = PZ.melody_contour(y, vibrato=vibrato, voicing=voicing)
    hz = np.where(np.isfinite(midi), 440 * 2 ** ((midi - 69) / 12), 0.0)
    hz = hz[np.clip(np.searchsorted(t, F.frame_times()), 0, len(t) - 1)]
    hz = np.where((hz >= 75) & (hz <= 1100), hz, 0.0)  # outside any human singing range → not the voice
    if fold:
        hz = fold_to_register(hz)
    return hz
  1. Cue one: track the centre signal, not the plain mix.
  2. Cue two, from Pianoify's vibrato_score. Vibrato moves a pitch up and down at 4–8 Hz, so energy swings between neighbouring pitch bins in opposite phase; a restruck instrument note makes them rise and fall together. Pitches that show the anti-phase swing get a salience boost.
  3. Anything outside 75–1100 Hz is not a human singing pitch and is dropped.
  4. Cue three: a singer stays in one register, so frames nearly a full octave from the running median of the line are moved back by whole octaves.
Voice pitch accuracy as each cue is added, for the evaluation and tuning songs
Share of sung frames tracked within 50 cents of the true pitch, adding one cue at a time. On the evaluation song: 3% with the plain tracker, 66% with all cues.

The register also gives a voice-type estimate: it said “alto” and “tenor” for the two test voices, both right. (The median of the evaluation song's line sits near the alto/soprano border, so the browser port, whose track differs in under 1% of frames, says “soprano” there.) Building this also exposed a bug in Pianoify: its octave correction pulled high voices down an octave, because the even harmonics of a high note make the octave below look salient. It now moves down only when the lower pitch shows its own fundamental or third harmonic. On an isolated high voice that took pitch accuracy from 33% to 95%. Both the Python and browser versions of Pianoify are fixed.

The largest single effect

With Pianoify's plain tracker, the separated voice scores −4.8 dB SI-SDR. With the three cues it scores +4.4 dB. No other change on the page comes close to those 9 dB.

04

A prior for each kind of part

What makes harmony, drums and voice different

Since the data cannot tell the parts apart, each part gets a penalty that describes what it looks like. Sustained chords barely change over time. Drums are short and broadband, so only a few frames are active. The voice only exists on a comb M at the harmonics of the tracked pitch, and is sparse there.

minz,h,p⁡αH∑c‖DtEFhc‖1+αP∑c∑n‖(Fpc)n,:‖2+αV‖z‖1,v=FT(M⊙z)\min_{z,h,p}\; \alpha_H \sum_c \|D_t E F h_c\|_1 + \alpha_P \sum_c \sum_n \|(F p_c)_{n,:}\|_2 + \alpha_V \|z\|_1, \qquad v = F^{T}(M \odot z)

The harmonic penalty needs a trick. A steady tone's STFT coefficient still rotates in phase from frame to frame, so its time difference is not small. The operator E undoes that rotation first, as in Masuyama, Yatabe and Oikawa (2019).

unmix.pylines 72–82
def phase_correction(F: Frame, Gmix):
    """E = exp(-i Θ): removes each bin's accumulated phase advance (from the mixture's instantaneous frequency)."""
    N, hop = F.N, F.hop
    k = np.arange(N)
    omega = 2 * np.pi * np.where(k <= N // 2, k, k - N) / N              # rad / sample, signed
    ph = np.angle(Gmix)
    dphi = np.diff(ph, axis=0, prepend=ph[:1]) - omega * hop
    dphi = (dphi + np.pi) % (2 * np.pi) - np.pi
    inst = omega + dphi / hop
    theta = np.cumsum(inst * hop, axis=0)
    return np.exp(-1j * theta).astype(np.complex64)
  1. The phase change between frames, minus what the bin's centre frequency alone would predict, wrapped into (−π, π].
  2. That deviation gives each bin's instantaneous frequency, measured on the mixture.
  3. Accumulate the expected phase advance and multiply by its inverse rotation. After E, a held note is nearly constant across frames, and Dt of it is nearly zero.
unmix.pylines 93–103
def comb_support(F: Frame, f0_frames, fmax=5000.0, tol_bins=1.5, tol_rel=0.03, dilate=1):
    """Boolean mask (frames × bins): True near h·f0 for voiced frames whose f0 is a plausible human pitch."""
    hz = np.abs(F.bin_hz())
    mask = np.zeros((F.nF, F.N), dtype=bool)
    for n, f0 in enumerate(f0_frames):
        if not (75.0 <= f0 <= 1100.0):                   # range check: not a human singing pitch → no voice here
            continue
        H = int(fmax // f0)
        for h in range(1, H + 1):
            fc = h * f0
            mask[n] |= np.abs(hz - fc) <= max(tol_bins * SR / F.N, tol_rel * fc)
  1. Frames with no plausible sung pitch get an empty comb, so the voice is forced to be silent there.
  2. Mark every harmonic up to 5 kHz. The width grows with frequency (3% of the harmonic), because vibrato moves high harmonics further in Hz than low ones.
05

PDHG: one dual variable per term

Chambolle–Pock with per-block step sizes

The problem is convex but every term is non-smooth, so it is solved with the primal-dual hybrid gradient method (Chambolle–Pock). Each penalty and the data constraint gets its own dual variable, and each dual update is a cheap projection. The method converges when the step sizes satisfy τ Σ σi‖Ki‖² < 1, where Ki is the operator inside term i.

unmix.pylines 145–149
    # operator norms: ||D_t E F|| <= 2; ||F|| = 1; data block: stereo [[A,I,0,I,0],[A,0,I,0,I]] with ||A|| <= 1 → 2
    L = {'H': 2.0, 'P': 1.0, 'D': 2.0 if C == 2 else np.sqrt(3.0)}
    gamma = gamma or {'H': 1.0, 'P': 1.0, 'D': 1.0}
    sig = {k: gamma[k] / L[k] ** 2 for k in L}
    tau = 0.99 / sum(gamma.values())
  1. Norm bounds for the three blocks. They come straight from section 02: the frame has norm 1, a difference at most doubles it, and |E| = 1.
  2. Each dual step σi is scaled by 1/‖Ki‖², so τ Σ σi‖Ki‖² = 0.99 by construction, for any choice of the weights gamma.
unmix.pylines 158–174
    voice = lambda zz: F.adj(M * zz)
    for it in range(iters):
        vb = voice(zb)
        # ── dual updates (prox of conjugates) ──
        for c in range(C):
            w = yH[c] + sig['H'] * Dt(E * F.fwd(hb[c]))
            yH[c] = w / np.maximum(1.0, np.abs(w) / a_H)                     # ℓ1 → clip magnitudes
            w = yP[c] + sig['P'] * F.fwd(pb[c])
            yP[c] = w / np.maximum(1.0, np.linalg.norm(w, axis=1, keepdims=True) / a_P)   # ℓ2,1 over frames
            w = yD[c] + sig['D'] * F.fwd(vb + hb[c] + pb[c])
            u = w / sig['D']                                                 # prox of σ ι_C^* = w - σ P_C(w/σ)
            Pu = u.copy()
            for b in range(nb):
                r = u[:, sel[b]] - Gmix[c][:, sel[b]]
                nr = np.linalg.norm(r)
                Pu[:, sel[b]] = Gmix[c][:, sel[b]] + (r * (eps[c][b] / nr) if nr > eps[c][b] and nr > 0 else r)
            yD[c] = w - sig['D'] * Pu
  1. Harmonic term. The dual of an ℓ1 norm is a box, so its update just clips each complex coefficient to magnitude αH.
  2. Percussion term. The dual of a sum of per-frame ℓ2 norms projects each frame's whole spectrum onto a ball of radius αP.
  3. Data term. The prox of the constraint's conjugate comes from Moreau's identity: w minus σ times the projection of w/σ onto the allowed set.
  4. The allowed set is a ball of radius εb around the measured mixture in each frequency band. With ε = 0, the default, the recording is reproduced exactly.
A null result

Exact equality, one band, four bands and 2% tolerances all landed within 0.1 dB, and so did reweighting the dual steps. The MP3's codec noise is about 23 dB below the music while separation errors are about 4 dB, so slack in the data term is irrelevant at this error level. The bottleneck is the null space, not conditioning.

06

Keep the voice feasible, then refit

Synthesis form, soft-thresholding and a Wiener-style re-split

The first version said “the voice's STFT is zero off the comb”. That returned an almost silent voice: in a 4× redundant frame, onsets and window side-lobes always leak off the comb, so almost no real signal qualifies. The fix is to build the voice from coefficients that live on the comb, v = FT(M ⊙ z). Any z gives a valid voice.

unmix.pylines 176–186
        z_old, h_old, p_old = z, h, p
        aD = [F.adj(yD[c]) for c in range(C)]
        gz = M * F.fwd(sum(aD))                                              # adjoint of z ↦ F^T(M z)
        w = z - tau * gz
        mag = np.abs(w)
        z = (M * w * np.maximum(0.0, 1.0 - tau * a_V / np.maximum(mag, 1e-12))).astype(np.complex64)  # soft-threshold on the comb
        h = np.stack([h[c] - tau * (F.adj(np.conj(E) * DtT(yH[c])) + aD[c]) for c in range(C)])
        p = np.stack([p[c] - tau * (F.adj(yP[c]) + aD[c]) for c in range(C)])
        if rho != 1.0:
            z = z_old + rho * (z - z_old); h = h_old + rho * (h - h_old); p = p_old + rho * (p - p_old)
        zb, hb, pb = 2 * z - z_old, 2 * h - h_old, 2 * p - p_old
  1. The voice appears in both channels' data terms, so its gradient sums the two duals, then maps back through F and the comb.
  2. The prox of αV‖z‖1 for complex numbers: shrink each magnitude by ταV, keep the phase, and zero everything off the comb.
  3. The harmonic part moves along the adjoint chain FTE*DtT, the reverse of the forward chain in the dual update.
  4. The extrapolation step 2xnew − xold that makes this PDHG rather than plain alternating updates.

The ℓ1 penalty also shrinks the voice, which came out 3–4 dB too quiet. A final step uses the three recovered parts only as soft masks and re-splits the measured recording with them.

unmix.pylines 209–216
    for c in range(2):
        G = F.fwd(g[c])
        Hc, Pc = F.fwd(out["h"][c]), F.fwd(out["p"][c])
        a, b, d = np.abs(V) ** power, np.abs(Hc) ** power, np.abs(Pc) ** power
        tot = a + b + d + 1e-12
        v_new += 0.5 * F.adj(a / tot * G)
        h_new[c] = F.adj(b / tot * G)
        p_new[c] = F.adj(d / tot * G)
  1. Wiener-style masks |Sk|² / Σj|Sj|². They sum to 1 in every bin, so the three new parts still add up to the recording.
  2. The voice is the average of its estimates from the two channels, so it stays centred.
What each step was worth

Synthesis form took the voice from −13 dB to +4 dB SI-SDR. The ℓ1 penalty left the voice 3–4 dB too quiet; the refit adds 0.6 dB to the voice with the tracked pitch and 2.5 dB with the true pitch. Full numbers are in Results.

07

The MP3 file is the measurement

Round 2: quantiser cells instead of the decoded waveform

Round 1 treated the decoded audio as exact. But an MP3 doesn't store a waveform. It stores rounded MDCT coefficients, so each one really says “the original coefficient was somewhere in this interval”. That is a much more honest measurement, and closer to the tomography setting: g is no longer one point but a box. Reading it needs two things: the decoder's synthesis as a linear operator S, and the interval each stored integer stands for.

The decoder is minimp3 (public domain) with one hook added after its Huffman decoding. For every granule and channel the hook writes the integer quantised value q and the quantiser step of every coefficient, with the block type and the mid/side flag. That is everything the file says about the signal.

Layer III synthesis is linear: short-block reorder, anti-alias butterflies, a 36- or 12-point IMDCT with a window per block type and overlap-add, a sign flip on odd subbands, then the 32-band polyphase filterbank. The IMDCT/window/overlap stage and the polyphase stage are measured as impulse responses of minimp3 itself, one set per pair of consecutive block types. So S reproduces minimp3's output to 3·10−7, and its transpose is exact to 10−15. The encoder's analysis has to be S−1. S turns out to be orthogonal up to a per-coefficient scale (long and short blocks have different IMDCT gains, and mid/side frames carry a factor of 2), so the analysis is a weighted adjoint, A = W ST, with W read off the measured responses. It inverts S to 84 dB.

mp3/mp3model.pylines 230–238
    q, st = np.abs(bs["q"]).astype(np.float64), bs["step"]
    lv = lambda n: np.maximum(n, 0) ** (4 / 3)
    a = np.where(q > 0, (lv(q - 1) + lv(q)) / 2, 0.0) * st
    b = (lv(q) + lv(q + 1)) / 2 * st
    a, b = np.maximum(a - slack * st, 0), b + slack * st
    s = np.sign(bs["q"])
    lo = np.where(s > 0, a, np.where(s < 0, -b, -b))
    hi = np.where(s > 0, b, np.where(s < 0, -a, b))
    return lo, hi
  1. A stored integer q decodes to |q|4/3 times the step size, the MP3 power law.
  2. The encoder (LAME) rounds to the nearest decoded level, so the original lies between the midpoints to the neighbouring levels.
  3. Optional slack widens each cell by a fraction of a step, in case the encoder's rounding differs slightly.
  4. The sign of q puts the interval on the right side of zero. A zero gives a symmetric interval around zero.

The test that matters is whether the clean mix, before encoding, analysed with A, lands in the cells the file specifies. It exposed two facts about the encoder:

  • The encoder scaled its input by 0.950. Every decoded coefficient is 0.950 times the clean analysis, with correlation 1.0000 in every band. This is also why round 1 measured the codec as only 23.6 dB clean: most of that error was this gain, not quantisation.
  • LAME rounds to the nearest dequantised level. So the cell for q runs between the midpoints of neighbouring levels |q|4/3·step. At q = 0 and 1 this is exactly the ISO offset 0.4054 = 1 − 0.53/4. That is the code above.

Not every cell is information. A zero above the encoder's lowpass, or in a band the encoder dropped as a whole, means “not stored”, not “close to zero”. Those coefficients are left free.

mp3/mp3model.pylines 247–261
    q = bs["q"]
    nz = (q != 0).mean(axis=(0, 1))
    top = int(np.max(np.nonzero(nz > 0.01)[0])) if (nz > 0.01).any() else 575
    ok = (bs["step"] > 0) & (np.arange(576) < top - lowpass_margin)[None, None, :]
    G, C = q.shape[:2]
    for g in range(G):
        for c in range(C):
            start = 0
            for w in bs["sfb"][g, c].astype(int):
                if not w:
                    break
                if not q[g, c, start:start + w].any():
                    ok[g, c, start:start + w] = False
                start += w
    return ok
  1. Find the highest bin that is ever nonzero: that is the encoder's lowpass. Everything above it, minus a subband of margin, is untrusted.
  2. Only coefficients with a real step size can be trusted at all.
  3. A scalefactor band that came out entirely zero was probably dropped, not quantised, so its cells say nothing.

Leaving the uninformative cells out, the clean mix lands inside its cell for almost every coefficient:

BitrateCodec errorLowpassCells usedClean mix inside
exact½-step slack
128 kbps−26.7 dB7.2 kHz68%99.24%99.57%
64 kbps−23.3 dB7.2 kHz54%99.42%99.76%
32 kbps−18.8 dB5.5 kHz34%99.83%99.93%

In the solver this only changes the data term's prox. Equality with g becomes a box, Gram½·lo ≤ Ψ(v + hc + pc) ≤ Gram½·hi, with Ψ = Gram−½ST (so ΨΨT = I and ‖Ψ‖ = 1): the dual update clips the encoder-side analysis of the current mix into its cells.

unmix.pylines 312–318
        mixy = np.stack([vb + hb[0] + pb[0], vb + hb[1] + pb[1]]).astype(np.float64)
        if mp3 is None:                                                     # prox of sigma * iota_{=g}^*
            w = yD + sig["D"] * mixy
            yD = w - sig["D"] * g
        else:                                                               # prox of sigma * iota_box^*
            w = yD + sig["D"] * mp3.fwd(mixy)
            yD = w - sig["D"] * np.clip(w / sig["D"], mp3.lo, mp3.hi)
  1. The sum of the three parts in each channel, as in round 1.
  2. Round 1: the prox of the indicator of “equal to g”, via Moreau's identity.
  3. Round 2: map the mix into normalised MP3 coefficients and clip into [lo, hi]. Unreliable cells have infinite bounds, so the clip leaves them alone.
Histogram of where the clean mix falls inside its quantiser cell
Where each coefficient of the clean mix falls within its cell, for every |q| ≥ 2 at 128 kbps. 99.2% land inside without slack, 99.6% with half a step. It's denser near the lower edge because music spectra are peaky, so most values sit just above a quantisation level.
08

Smoothness that follows the pitch

Round 2: directional TV in a constant-Q frame, plus an outer loop

The voice's round-1 prior is only “sparse on the comb”. A sung note is also smooth over time, but not along a fixed frequency: vibrato and glides bend every harmonic. In a constant-Q frame C (a painless non-stationary Gabor frame, 36 bins per octave from 80 Hz to 4 kHz, a common 3 ms hop, Parseval on its band) a pitch change moves every harmonic by the same number of bins. So the total variation ‖DθCv‖1 can step along the pitch slope, dk(t) = 36·Δlog2f0, instead of straight across. Both operators check out numerically: the adjoints are exact to 10−15, and ‖DθC‖ = 1.93.

cqt.pylines 89–96
        f0 = np.interp(frame.t, f0_times, np.where(f0_hz > 0, f0_hz, 0.0))
        voiced = np.interp(frame.t, f0_times, (f0_hz > 0).astype(float)) > 0.99
        if follow:
            g = np.where(voiced, np.log2(np.maximum(f0, 1e-9)), np.nan)
            dk = frame.bpo * (g[1:] - g[:-1])
            dk = np.where(np.isfinite(dk) & (np.abs(dk) <= max_shift), dk, 0.0)
        else:
            dk = np.zeros(M - 1)
  1. Pitch frames are moved onto the constant-Q time grid; a frame counts as voiced only if it's voiced throughout.
  2. The step between frames, in bins: 36 × the change in log₂ pitch. Every harmonic moves by this much.
  3. Unvoiced frames and implausible jumps (more than 3 bins per hop) fall back to the fixed direction.
  4. With follow off, the direction is straight along time, the round-1 style smoothness.
cqt.pylines 124–127
    def __call__(self, C):
        nxt = C[:, 1:]
        interp = self.w0 * np.take_along_axis(nxt, self.k0, 0) + self.w1 * np.take_along_axis(nxt, self.k1, 0)
        return interp * self.rot - C[:, :-1]
  1. The next frame's coefficient is read at the shifted channel, interpolated between two neighbouring bins.
  2. The phase is rotated back by the predicted advance before differencing. Without this a steady tone would look rough, because its phase turns every frame.

That phase prediction is the hard part. On a synthetic vibrato tone over a steady chord, following the pitch with the phase predicted from the harmonics h·f0 makes the voice 3.8× cheaper than the chord. On the test songs it doesn't, and the reason is precision. Over a 3 ms hop the predicted phase is only right if the instantaneous frequency is right to a few cents. A pitch tracker gives 16 ms frames on a ⅓-semitone grid and smooths vibrato away, so the prior ends up rewarding the accompaniment, whose steady partials predict their own phase well. The fix is an outer loop: solve once without the TV, then use the recovered voice's own pitch and phase to define the TV, and solve again.

Total variation of the true voice and true accompaniment under five versions of the operator
Total variation per unit of signal for the true voice and the true accompaniment (tuning song). The number is how much costlier the accompaniment is, so above 1 means the prior prefers the voice. Predicting phase from the mix or from the pitch track favours the accompaniment. Predicting it from a voice estimate, the outer loop, tips it slightly towards the voice. The true voice's own phase would give 2.9×.
unmix.pylines 346–353
    out = separate2(g, f0_frames, a_T=0.0, voice_tv=None, **kw)
    f0 = f0_frames
    for _ in range(outer - 1):
        v = out["v"].astype(np.float64)
        if f0_from_voice:
            fv = VL.track(np.stack([v, v]), out["frame"], centre_power=0, vibrato=0.0, voicing=0.03, fold=True)
            f0 = np.where(fv > 0, fv, f0)
        out = separate2(g, f0_frames, a_T=a_T, voice_tv=voice_tv, f0_hz_cqt=f0, phase_from=v, **kw)
  1. First pass: plain round-1 priors.
  2. Re-track the pitch from the recovered voice alone (no centre or vibrato weighting needed), keeping the original track where this one finds nothing.
  3. Second pass: the directional TV now takes its direction and phase from the voice estimate.

The third idea, a range gate, is one function in run2.py: pitch frames outside the line's own register (5th–95th percentile, widened by a quarter) are dropped before building the comb. What all three were worth is under Results, round 2.

09

The same solver in your browser

A line-for-line JavaScript port of round 1, in a Web Worker

The tool on the project page runs the round-1 method: centre extraction, vibrato-weighted pitch tracking and register folding from voiceline.py, then separate and refit from unmix.py, with the same frame (1024-sample sqrt-Hann, hop 256), the same comb and the same tuned weights (αH = 3s, αP = 9s·N¼, αV = 0.3s, with s the median magnitude of the mixture's spectrum). It is plain JavaScript with no dependencies: web/unmix.js is the maths, web/worker.js runs it off the main thread, web/app.js is the page. Nothing is uploaded.

The browser decodes whatever file it can play, and its own resampler brings it to 16 kHz, the rate the method was tuned at.

web/app.jslines 44–56
  // resample to 16 kHz with the browser's own resampler
  const ch = Math.min(2, decoded.numberOfChannels);
  const n16 = Math.ceil(decoded.duration * SR);
  const off = new OfflineAudioContext(ch, n16, SR);
  const src = off.createBufferSource();
  src.buffer = decoded; src.connect(off.destination); src.start();
  const r = await off.startRendering();
  const L = r.getChannelData(0), R = ch > 1 ? r.getChannelData(1) : null;
  let mono = !R;
  if (R) {                                         // two identical channels count as mono
    let d = 0, s = 0;
    for (let i = 0; i < L.length; i += 7) { d += (L[i] - R[i]) ** 2; s += (L[i] + R[i]) ** 2; }
    mono = d <= 1e-8 * s;
  1. An OfflineAudioContext at 16 kHz renders the decoded song as fast as it can, which is a resampler for free. In Python this is ffmpeg.
  2. Up to two channels come back as Float32Arrays.
  3. A file whose two channels are identical counts as mono. Then the stereo-centre cue has nothing to work with, the centre signal is just the mix, and the solver uses the mono model (the pdhg-mono ablation, which cost 1.3 dB on the voice and 2 dB on the accompaniment). Unlike that ablation, the browser still runs the refit on mono files.

Speed comes from two changes that leave the maths exactly as it was. Every signal is real, so every spectrum the solver touches is Hermitian: only bins 0–512 are stored, and two real frames share one complex FFT (a radix-4 FFT on Float64Arrays, results kept in Float32Arrays like Python's complex64).

web/unmix.jslines 99–115
Frame.prototype.fwd = function (x, Xr, Xi) {
  const { N, T, hop, nF, K, ws, re, im, fft } = this;
  for (let n = 0; n < nF; n += 2) {
    const a = n * hop, b = (n + 1) * hop, two = n + 1 < nF;
    if (b + N <= T) {
      for (let m = 0; m < N; m++) { re[m] = x[a + m] * ws[m]; im[m] = x[b + m] * ws[m]; }
    } else {
      for (let m = 0; m < N; m++) { re[m] = x[(a + m) % T] * ws[m]; im[m] = two ? x[(b + m) % T] * ws[m] : 0; }
    }
    fft(re, im);
    const oa = n * K, ob = oa + K;
    for (let k = 0; k < K; k++) {
      const kk = (N - k) & (N - 1);
      const zr = re[k], zi = im[k], wr = re[kk], wi = im[kk];
      Xr[oa + k] = 0.5 * (zr + wr); Xi[oa + k] = 0.5 * (zi - wi);       // (Z[k] + conj Z[N-k]) / 2
      if (two) { Xr[ob + k] = 0.5 * (zi + wi); Xi[ob + k] = -0.5 * (zr - wr); }   // (Z[k] - conj Z[N-k]) / 2i
    }
  1. Frame n goes in the real part and frame n+1 in the imaginary part. Frames that wrap around the end take the slower modulo path below it.
  2. One FFT for two frames.
  3. The two spectra come back out from the symmetry of real signals: Xa = (Z[k] + Z̄[N−k])/2 and Xb = (Z[k] − Z̄[N−k])/2i. The adjoint does the reverse, and keeps only the real part of the DC and Nyquist bins, exactly what Python's np.real(np.fft.ifft(…)) does.

The second change: with exact data equality (ε = 0, the default), the data dual only ever accumulates F(v + hc + pc − gc), and FTF = I. So it can live in the time domain, which removes three transforms per iteration. Round 2's separate2 does the same.

web/unmix.jslines 275–281
      // data: equality with g
      const y = yD[c], gc = g[c], hbc = hb[c], pbc = pb[c];
      for (let t = 0; t < T; t++) y[t] += sigD * (vb[t] + hbc[t] + pbc[t] - gc[t]);
    }
    // ── primal updates ──
    if (C === 2) for (let t = 0; t < T; t++) tmp[t] = yD[0][t] + yD[1][t]; else tmp.set(yD[0]);
    F.fwd(tmp, Ar, Ai);
  1. The data dual is a running sum of residuals, one sample at a time.
  2. The voice's gradient sums the two channels' duals and maps them through F once, then the comb and the soft-threshold follow as in section 06.

The pitch tracker had to be rebuilt without librosa. Its constant-Q transform is emulated directly at 16 kHz: each of the 252 bins gets a Hann filter of length Q·sr/f (Q = 51.9, librosa's), applied as a sparse kernel on one 16384-point FFT per pair of columns, on librosa's 22050/512 time grid with its √length scaling. The 4–8 Hz band-pass for the vibrato score uses scipy's Butterworth coefficients, with the same odd padding and initial conditions as sosfiltfilt; the median and box filters use scipy's “reflect” edges; and the Viterbi search is the full search librosa does, ties included. The worker runs the three steps in order and posts progress, so the page can show it and stop it.

web/worker.jslines 34–43
    const tr = Unmix.track(L, stereo ? R : null, { progress: f => post('Finding the singer’s pitch', f) });
    const tTrack = (performance.now() - t0) / 1000;
    const g = stereo ? [L, R] : [L];
    const out = Unmix.separate(g, tr.f0, {
      iters,
      progress: (it, n, el) => post('Separating', it / n, { it, iters: n, elapsed: el }),
    });
    const tSolve = (performance.now() - t0) / 1000 - tTrack;
    post('Refitting', 1);
    const fin = Unmix.refit(g, out);
  1. Pitch first: about 2 s for 20 s of audio.
  2. Then PDHG, reporting every 5 iterations. The Stop button simply terminates the worker.
  3. The refit, then spectrogram images and the parts go back to the page as transferable buffers.

Checked against the Python

Both versions were run on the same 16 kHz excerpts of the two synthetic songs (20 s from 2.5 s, the MP3 decoded by ffmpeg and aligned as in run.py), scored against the true parts with the metrics in run.py. Given the same pitch track, the JavaScript solver and refit reproduce Python's output to 126 dB SDR, so the remaining differences come from the pitch tracker, whose track matches Python's in 99.3% (evaluation) and 99.4% (tuning) of frames.

After refit (dB)Voice SI-SDRAccomp. SDRPitch accuracy
PythonJSPythonJSPythonJS
Evaluation song, 300 iterations4.784.8612.4912.5566.3%66.6%
Evaluation song, 150 iterations4.654.7212.4512.51
Evaluation song, 100 iterations4.584.6612.4212.47
Tuning song, 300 iterations1.571.5811.1511.1662.3%62.6%
Tuning song, 100 iterations1.331.3411.2911.30
Speed, and the one shortcut

On this 2-core test machine, 300 iterations on 20 s took 256 s in numpy, 54–60 s in Node and 60 s in headless Chromium; 150 iterations took 31 s in the browser, plus about 2 s for the pitch. To keep a run near half a minute the page uses 150 iterations by default, which costs 0.13 dB of voice SI-SDR on the evaluation song (4.65 vs 4.78 dB in Python); 300 is one click away. Excerpts are capped at 30 s, since memory and time grow with length. Round 2 (MP3 cells, pitch-following TV) is not ported: it needs the patched minimp3 and helped little with a tracked pitch.

→

Results

A held-out song with known parts

3% → 66%voice pitch found in the mix, before and after the three cues
−0.9 → 4.8 dBvoice SI-SDR: standard REPET-SIM baseline vs. this method
10.5 dBthe same method given the true pitch: pitch tracking is now the bottleneck

Tests use synthetic songs whose parts are known exactly. The voice is formant-synthesised (glottal pulses through vowel resonances, with vibrato, glides, jitter and breath noise). The chords are panned across the stereo field, the bass is centred, and the drums are spread. The mix goes through a real 128 kbps MP3 encoder. Every setting was chosen on a tuning song: a fifth lower, tenor voice, different vowels, panning and random seed. The numbers below are from a separate evaluation song (alto voice), 20 seconds, 300 iterations. The demo song on the project page is that same 20-second excerpt.

Voice SI-SDR and accompaniment SDR for each method
Separation quality against the true parts (higher is better). Dark bars are given the true pitch, an upper bound for the pitch tracker.
MethodVoice SDRVoice SI-SDRAccomp. SDRVoice pitch*
Mixture, no separation−6.4−6.80.00%
REPET-SIM (librosa)2.6−0.99.535%
Comb mask, same tracker4.84.110.941%
PDHG5.44.412.257%
PDHG + refit6.04.812.557%
Comb mask, true pitch10.510.215.467%
PDHG + refit, true pitch9.910.516.392%

* Pitch accuracy of a tracker run on each recovered voice: how usable it is for Pianoify or Cover Me.

What helped, and what didn't

  • The voice cues matter most (section 03): −4.8 dB voice SI-SDR with Pianoify's plain tracker, +4.4 dB with the cues.
  • Stereo is worth about 1–2 dB. Treating the song as mono costs 1.3 dB on the voice and 2 dB on the accompaniment.
  • The reconstruction beats masking with the same pitch: +1.2 dB voice SDR and +1.6 dB accompaniment. It also splits the accompaniment into harmonic (7.4 dB) and drums (1.5 dB), and its voice is much cleaner to pitch-track (92% vs. 67% with true pitch).
  • The split data fidelity did nothing here (section 05): exact equality, bands and tolerances all landed within 0.1 dB.
  • Pitch tracking is the ceiling. With the true pitch, quality roughly doubles in dB. Around 5–6 s in the spectrograms below, the tracker locks onto chord tones and they leak into the voice.
Spectrograms of the mixture with true and tracked pitch, the true voice, and the recovered voice
Six seconds of the evaluation song. Top: the centre of the mix with the true pitch (white) and tracked pitch (dashed). Middle: the true voice. Bottom: the recovered voice. Vibrato and the upper harmonics survive. Where the tracked pitch goes wrong (5–6 s), chord tones come through instead.

Round 2: the MP3 as the measurement, and a prior that follows the pitch

The first round left three ideas open, each from the tomography analogy: the MP3's quantiser cells as the data term (section 07), directional TV that follows the pitch (section 08), and range as a constraint, dropping pitch frames outside the singer's own register from the comb. All three were tested on the same held-out song, with settings chosen on the tuning song, 20 s, 300 iterations.

Change in voice SI-SDR and accompaniment SDR for each extension, with tracked and true pitch
Each extension's gain over the matching baseline, the first prototype with refit: 4.3 dB voice SI-SDR with tracked pitch, 10.5 dB with true pitch. 128 kbps. The weights were chosen on the tuning song.
128 kbps, after refit (dB)Tracked pitchTrue pitch
VoiceAccomp.VoiceAccomp.
First prototype4.3212.3210.4616.75
+ voice TV, time direction4.4912.51––
+ pitch-following TV, harmonic phase3.9212.11––
+ pitch-following TV, outer loop4.4812.5111.5516.77
MP3 cells instead of equality4.1612.1910.2016.57
MP3 cells + outer-loop TV4.3812.4411.3516.65
Range gate on the pitch4.3612.36––
Ceiling: true voice's phase5.3813.1512.2417.14

The round-2 baseline (4.32 dB) differs slightly from round 1's 4.78 dB because round 2 works on minimp3's own timeline: the mixture is minimp3's decode, the excerpt is a whole number of MP3 granules, and the true parts are shifted by the codec delay and scaled by the encoder's input gain for scoring (run2.py).

Voice SI-SDR and accompaniment SDR against bitrate for equality and MP3-cell data terms
The data term at three bitrates. Dashed: equality with the decoded file. Solid: the file's quantiser cells. They track each other at every bitrate. The gap narrows as the codec gets coarser (with true pitch, −0.26 dB at 128 kbps, −0.09 dB at 32 kbps), but the cells never come out ahead in this range.
Idea 1: the model is right, but it doesn't help separation here.The MP3's cells describe the original signal almost perfectly (99.6–99.9% containment), yet using them costs 0.1–0.3 dB. The codec error is 19–27 dB down, while the separation error is 4–10 dB. So relaxing the data term to the true cell only gives the ℓ1 priors room to shrink the stems. Pinning the uninformative coefficients to their decoded values (a hybrid term) changed nothing (tuning song, 8.63 vs 8.63 dB). This is where the tomography analogy breaks: there the measurement is the information, here it leaves a (K−2)T-dimensional null space.
Idea 2: it works once the comb is in the right place.With the true pitch, the outer-loop pitch-following TV adds 1.1 dB of voice SI-SDR (10.5 → 11.6). With the tracked pitch it adds only 0.2 dB, the same as plain time-direction TV. Predicting phase from the pitch track alone costs 0.4 dB. The ceiling with the true voice's phase is +1.8 dB.
Idea 3: already done.The register folding from round 1 already does what a range gate would. The gate adds 0.04 dB.

The conclusion of round 1 stands, sharper: pitch tracking is the bottleneck. Every extension that helps does so mostly when the pitch is right.

→

Limits and next steps

What it can't do yet

  • Synthetic songs only. The next test is MUSDB18, 150 real songs with their parts, where neural models such as Demucs reach 7–9 dB on vocals. Expect this method to land well below that. Its value is being training-free and interpretable.
  • Real songs break the assumptions. The method expects one lead singer panned centre. Backing vocals, doubled or panned leads, centred instruments that sound like a voice, and heavy reverb all end up on the wrong side. On a mono file the centre cue is lost.
  • The MP3 data term is exact but not useful yet. It may pay off where codec error rivals separation error: very low bitrates, or once better priors (or a learned denoiser) bring the separation error down to the codec's level. Only MP3 is supported, through minimp3; AAC would need the same treatment.
  • Consonants are dropped. The comb only allows pitched sound, so “s”, “t” and breaths stay in the accompaniment.
  • Pitch tracking is the bottleneck. Better pitch, for example tracking inside the outer loop with more iterations or a learned pitch estimator, is worth more than any of the priors tried here. A learned denoiser plugged into PDHG as the regulariser is the other obvious next step.
  • The browser version is round 1 at 16 kHz, 30 s at most. Output is 16 kHz (nothing above 8 kHz), and 150 iterations by default instead of 300.
  • Into the apps. A recovered voice would give Cover Me the singer's real timing and phrasing, and give Pianoify a cleaner melody.
→

Reproduce

Every number on this page

Python with numpy, scipy and librosa, plus ffmpeg for the MP3. It uses ../pianoify/pianoify.py for the pitch salience, so keep the two folders side by side.

pip install -r requirements.txt
python make_stems.py data                       # evaluation song and its parts
python make_stems.py data_tune --transpose -7 --seed 7 --pans 0.6,-0.4,0.15 --vowels oo,eh,ah,ee,oh
python run.py data --iters 300                  # every method and ablation (~25 min on 2 cores)
python figures.py                               # out/*.png
# round 2 (needs gcc; minimp3 is in mp3/)
for b in 64 32; do ffmpeg -i data/mix.wav -b:a ${b}k data/mix_${b}k.mp3; done
python run2.py data --seconds 20 --iters 300 --aT 10 --out results/round2_128k.json \
  --methods eq+l1,eq+fixed,eq+follow,eq+loop,eq+bound,mp3+l1,mp3+loop,eq+l1+gate,eq+l1+oracle,eq+loop+oracle,eq+bound+oracle,mp3+l1+oracle,mp3+loop+oracle
python figures2.py diag && python figures2.py results
# the browser demo clip: the evaluation excerpt
ffmpeg -ss 2.5 -t 20 -i data/mix.wav -c:a libmp3lame -b:a 128k -ar 16000 web/demo.mp3

The browser code also runs in Node (require('./web/unmix.js') exposes track, separate and refit), which is how it was checked against the Python on the same excerpts.

→

Read further

Where everything lives

  • unmix.py tight STFT frame, operators, the PDHG solver, refit
  • voiceline.py centre extraction, vibrato-weighted tracking, register folding, voice type
  • ../pianoify/pianoify.py the pitch salience, Viterbi tracker and vibrato score it builds on
  • make_stems.py synthetic stereo songs with known parts
  • run.py baselines, ablations and metrics (SDR, SI-SDR, pitch accuracy)
  • figures.py the round 1 figures
  • probe.py helpers used for tuning on the tuning song
  • results/eval_song.json every round 1 number
  • mp3/mp3dump.c minimp3 with a hook: quantised values, steps and side info per granule; impulse responses of its synthesis
  • mp3/mp3model.py reads the MP3 bitstream; the decoder as an exact linear operator; the encoder's analysis; quantiser cells; which cells to trust
  • cqt.py tight constant-Q frame and the pitch-following directional TV
  • run2.py round 2 experiments: data terms, voice priors, outer loop, range gate, bitrates
  • figures2.py the round 2 figures
  • results/round2_*.json every round 2 number, plus the tuning runs
  • web/unmix.js the browser port: FFT, tight frame, pitch tracker, PDHG solver, refit (also runs in Node)
  • web/worker.js runs the port in a Web Worker and reports progress
  • web/app.js the project page: decoding, excerpt, players, downloads, spectrogram
  • web/demo.mp3 the demo clip: 20 s of the synthetic evaluation song