Skip to content

Harmonic-plus-noise: additive filtered-noise branch (DDSP-style)#7

Draft
jmxpearson wants to merge 46 commits into
noise-injectionfrom
harmonic-plus-noise
Draft

Harmonic-plus-noise: additive filtered-noise branch (DDSP-style)#7
jmxpearson wants to merge 46 commits into
noise-injectionfrom
harmonic-plus-noise

Conversation

@jmxpearson

Copy link
Copy Markdown
Member

Harmonic-plus-noise: additive filtered-noise branch

Reworks the noise model from inside the ODE (the OU forcing on , which collapses the limit cycle — γ flips positive as the optimizer offloads generation onto the noise) to a parallel additive branch (DDSP-style), so the deterministic oscillator and the noise can't fight.

Architecture (only when --use-noise-branch):

oscillator (deterministic RK4) → envelope → tract ┐
                                                   ├─(+)→ output
white noise → sigma AM g(t) → learned per-frame filter ┘   (added OUTSIDE the tract)

The oscillator carries the tonal/harmonic content; the noise branch supplies the broadband/aperiodic floor. No ODE coupling ⇒ no collapse, no stochastic Heun, no NaN clamps needed for the source.

Why: an inference test (deterministic ckpt_4 + sigma-gated filtered noise) beat both the harmonic-only baseline and the noise-inside-ODE model on RMS-matched multi-res STFT: 2.35 vs 2.86 (harmonic only) / 2.44 (noise-in-ODE), with an oracle ceiling of 1.70 for a fully time-varying filter.

Changes

  • model.Ouroboros: use_noise_branch builds the sigma AM head (shared with the OU mode) + a new noisefilt Mamba head → per-timestep magnitude response over noise_bands. get_sigma keys off the head existing; new get_noise_filter.
  • spectral_rollout.filtered_noise_branch: white noise → STFT → per-frame filter (bands interpolated to rFFT bins, phase kept) → iSTFT → sigma AM; added to xg after the tract, gated by the same noise_gain ramp.
  • CLI / config / freeze-group / save-load persistence.

Verified: forward finite; gradients reach both the sigma and noise-filter heads; noise_gain=0 is deterministic (dW-independent); save/load round-trips the heads. Off by default; the OU path is untouched.

Not yet done: an actual training run (next step); the OU/enable_noise_forcing path is left in place for now rather than deleted.

🤖 Generated with Claude Code

claude added 30 commits July 1, 2026 18:57
Alternative to the in-ODE OU forcing: keep the oscillator PURELY deterministic (RK4) and add,
OUTSIDE the tract, a parallel filtered-noise source summed with the tract output:

  white noise -> sigma AM gate g(t) -> learned per-frame magnitude filter -> add

The oscillator carries the tonal/harmonic content, the noise branch the broadband/aperiodic
floor; the two can't fight (no ODE coupling), so no collapse and no stochastic Heun. Motivated
by an inference test where deterministic-harmonic + sigma-gated filtered noise beat both the
harmonic-only baseline and the noise-inside-ODE model on RMS-matched MRSTFT (2.35 vs 2.86/2.44).

- model.Ouroboros: use_noise_branch builds the sigma AM head (shared with the OU mode) plus a new
  noisefilt Mamba head emitting a per-timestep magnitude response over noise_bands bands;
  get_sigma now keys off the head existing; new get_noise_filter.
- spectral_rollout.filtered_noise_branch: white noise -> STFT -> per-frame filter (bands
  interpolated to rFFT bins, phase kept) -> iSTFT -> sigma AM; added to xg after the tract in
  spectral_rollout_step, gated by the same noise_gain ramp. Oscillator stays deterministic.
- config/CLI (--use-noise-branch / --noise-bands / --noise-nfft / --noise-hop), freeze group,
  save/load persistence.

Verified: forward finite, gradients reach both the sigma and noise-filter heads, noise_gain=0
is deterministic (dW-independent), save/load round-trips the heads. Off by default; the OU path
is untouched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017TbP4euX42eLH7CXGQbwaQ
…oly_autonomous)

So autonomy_score / the monitor render the FULL harmonic+noise output (noise added outside
the tract, gated by noise_gain), matching training -- not just the deterministic harmonic.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017TbP4euX42eLH7CXGQbwaQ
autonomy_score built drives_n by subscripting every drive value, but the drives dict carries
'sigma'=None when the model has a sigma head but enable_noise_forcing is off (harmonic-plus-
noise mode) -> TypeError. Populate g_gate whenever get_sigma returns non-None (head exists, not
just OU mode), and make the drives_n comprehension None-safe.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017TbP4euX42eLH7CXGQbwaQ
… of 65-band FIR

Replace the DDSP-style 65-band per-frame magnitude filter with the noise branch's own low-order
rational filter -- a Tract instance (noise_tract_n_sec 2nd-order pole/zero sections, same
parameterization as the vocal tract), applied to sigma-gated white noise. The low filter order
can shape a broadband spectral envelope but CANNOT synthesize sharp harmonic peaks, so it can't
model the tonal structure outright -- forcing the oscillator to carry it. Motivated by the
freeze-noise test where the 65-band filter modelled the org545 syllables so completely that the
oscillator was abandoned (alpha -> 0, source dead).

Drops noise_bands/noise_nfft/noise_hop + the noisefilt Mamba head and get_noise_filter; adds
noise_tract_n_sec (default 3) + --noise-tract-n-sec. Verified: forward finite, gradients reach
the sigma head AND the noise_tract pole/zero params, noise_gain=0 deterministic, save/load
round-trips, eval path finite.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017TbP4euX42eLH7CXGQbwaQ
…stead of duplicate drives

The drives were plotted in BOTH lower panels (left+right). Replace the LEFT one with the additive
filtered-noise term -- the noise_gain*filtered_noise the model adds to the tract output to make the
final waveform -- y-locked to the target scale like the other left-column waveforms, so its
amplitude is directly comparable to target/reconstruction/source. Drives stay on the right panel;
non-noise models fall back to drives on the left (legacy view).

integrate_poly_autonomous now captures the added noise term and returns it via the drives dict
('noise' key); autonomy_score threads it through the trajectories. Verified: noise term present,
correct length, monitor renders NEW_CKPT with no error.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017TbP4euX42eLH7CXGQbwaQ
The monitor SIGSTOPs the GPU trainer during each score so the autonomy subprocess isn't starved
-- necessary for GPU scoring, but in CPU mode (MONITOR_DEVICE=cpu) scoring never touches the GPU,
so freezing GPU training is pure waste (~80s/checkpoint, and it pauses through the whole backlog
when the sidecar falls behind). Guard: skip the SIGSTOP/SIGCONT entirely when MONITOR_DEVICE=cpu,
so the CPU sidecar runs fully decoupled and training is never interrupted. Picked up on the running
sidecar's next poll (it re-invokes the monitor per poll).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017TbP4euX42eLH7CXGQbwaQ
A comb (1 - r*e^{-jw*tau}) imposes periodic spectral teeth -- exactly the harmonic-like structure
the low-order noise filter must NOT be able to synthesize. Leaving it in gave the noise a way to
fake periodic peaks and partly defeat the handicap. Add a use_comb switch to Tract (default True,
so the vocal tract is unchanged); build the noise branch's Tract with use_comb=False, so it's a
pure broadband pole/zero envelope with no periodic structure. r_raw/tau_raw aren't even created
when disabled. Verified: noise_tract has no comb params, vocal tract keeps its comb, gradients
reach the noise pole/zero params, save/load round-trips.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017TbP4euX42eLH7CXGQbwaQ
…default off)

Sigma was the only head not low-passed. Add sigma_lowpass_ms (default 0.0 = off, unchanged sharp
gate); when >0, get_sigma smooths g(t) with the same zero-phase Gaussian as the drives, so the
noise amplitude envelope can't snap abruptly (a candidate cause of the occasional nan_skips, and
sensible since the noise isn't meant to model syllable onsets). The Gaussian's positive unit-sum
weights keep the relu'd gate nonnegative. Threaded through model_cv, save/load, and the entry CLI
(--sigma-lowpass-ms). Verified: off by default leaves g unchanged; >0 smooths it (roughness
0.085->~0) while staying nonneg; gradients flow through the low-pass; sigma_lowpass_ms round-trips.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017TbP4euX42eLH7CXGQbwaQ
Sigma/g is ~1000x smaller than gamma (e.g. sigma ~1e-3 vs gamma ~1.0), so sharing gamma/alpha's
y-axis squashed it to a flat line -- looked like a dead/constant gate when it's just quiet. Give
sigma its own twin axis (offset spine, clears omega^2's twin) so its structure is visible.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017TbP4euX42eLH7CXGQbwaQ
…king legacy sibling path)

detect_batches_per_epoch only looked at <parent>/<runname>_train.log, but the launch script writes
<run_dir>/train.log -- so the log was never found and it fell back to 750 (the old batch-16 default).
That mis-stamped the monitor's val_* scalars at epoch*750 while the trainer stamps at epoch*188
(batch 64), stretching the val x-axis ~4x. Check <run_dir>/train.log first, then the legacy sibling.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017TbP4euX42eLH7CXGQbwaQ
…), not N*bpe

checkpoint_N is saved at the END of epoch N, i.e. trainer global step (N+1)*bpe-1, but the monitor
stamped its Val/* scalars + spectrograms at N*bpe -- one epoch (bpe steps) too early, so every point
sat one epoch left of the train-loss curve for the same model. Use (N+1)*bpe-1 (exact for
from-scratch runs). Was previously documented as a 'good enough' shortcut.
…the sidecar uses)

Previous commit fixed only the fallback branch, but run_autonomy_on_checkpoint always sets
MONITOR_STEP_OVERRIDE=ckpt_epoch*bpe, so the TB step took the override branch and stayed one epoch
early. The noise-gain calc already adds +bpe to reach end-of-epoch; do the same for the TB step:
step_override + bpe - 1 = (ckpt_epoch+1)*bpe-1. Now Val/spectrogram points land on the trainer's
end-of-epoch scalar step.
…lpha-lowpass-ms

alpha (kernel weight [0,0] = pressure-analog DC drive) is normally smoothed at drive_lowpass_ms
(1ms) with the other drives. Add alpha_lowpass_ms (default 0 = off): when >0, alpha gets an extra
low-pass at that (longer) timescale in _lowpass_weights, so the driving pressure varies slowly
(syllable-scale) -- matching the physical syringeal pressure wave. Only the [0,0] term is affected;
all other kernel weights unchanged. Verified: alpha roughness collapses at 15ms while other weights
are identical; off by default; save/load round-trips.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017TbP4euX42eLH7CXGQbwaQ
…-> NaN)

The deterministic RK4 path evaluated the v^P polynomial on UNCLAMPED substep state and relied only
on the end-of-step state clamp -- fine for gamma>0 (bounded amplitude) but a strongly anti-damped
(gamma<0) oscillator's intermediate substep velocity overflowed fp32 mid-step -> NaN -> every batch
nan-skipped (a no-noise slow-alpha run diverged and stalled at 100% nan-skip). Add the same
kernel-input soft-tanh clamp the Heun core already has (xx_c=BX*tanh(xx/BX), vv_c=BXP*tanh(vv/BXP)
before the polynomial; linear terms keep the raw state) to both the eager _rk4_core_factory and the
graphstep _GraphedRK4Step. Verified: the diverged checkpoint now yields a finite loss+grads on both
backends (was NaN), eager==graphstep. Small backward-memory cost (a couple (B,) tanh tensors/substep).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017TbP4euX42eLH7CXGQbwaQ
…d of duplicate drives)

The lower-left panel is reserved for the filtered-noise waveform; when there's no noise branch it
was falling back to re-plotting the drives (duplicating the right panel). For noise-off models leave
it blank with a 'no noise branch' annotation instead.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017TbP4euX42eLH7CXGQbwaQ
…near data-init

Adds a gauge anchor on the tract GAIN K=softplus(tract.K_raw): (K/K0 - 1)^2 toward its data-init
value K0, mirroring the envelope anchor. Closes the (K, source) -> (c*K, source/c) gauge that the
envelope anchor leaves open -- pins the absolute output gain WITHOUT flattening the envelope (which
mean((e-1)^2) does). K is a scalar so the term is tiny with a bounded gradient
(dL/dK_raw = 2(K/K0-1)/K0 * sigmoid(K_raw)).

- model.py: register persistent buffer Tract.K_anchor_target = softplus(K_raw) at init.
- model_cv.py: after the data-driven K_raw init, update K_anchor_target so the anchor pins K near
  the data-matched start, not softplus(0).
- spectral_rollout.py: compute L_k_anchor from model.tract, add lam_tract_k_anchor*L_k_anchor to
  total, return 'k_anchor'.
- train.py: thread param, log Loss/k_anchor + LossW/k_anchor, print K_anchor_target; load_state_dict
  strict=False guarded to tolerate the new buffer's absence in pre-existing checkpoints (any OTHER
  key mismatch still raises).
- entry script: --lam-tract-k-anchor flag + manifest.

Verified: old checkpoints (no buffer) still load; penalty is 0 at K=K0, >0 when perturbed with finite
grad to K_raw, 0 when lam=0. Not yet used by any run (default 0).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017TbP4euX42eLH7CXGQbwaQ
Adds mean((max_t e - 1)^2): a quadratic penalty on the departure of the envelope's PEAK from 1.
Unlike --lam-env-anchor (pins e(t) to a per-sample target -> penalizes the whole shape) and unlike
a K-only anchor (leaves the envelope free to roam its level up, observed diverging to ~14x), this
fixes ONLY the absolute scale of e(t) while leaving its time-variance (syllable shaping) unpenalized.
Gradient flows through each sample's argmax timestep (max-pool subgradient).

Threaded through spectral_rollout_step (returns 'env_max'), train.py (param + Loss/env_max + LossW
logging + config print), model_cv.py, and the entry script (--lam-env-max-anchor + manifest).
Verified: 0 at peak=1 / lam=0, >0 with finite grad when the peak departs from 1. Default 0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017TbP4euX42eLH7CXGQbwaQ
Replace the linear-frequency STFT spectrograms in the diagnostic panels with mel-spaced ones
(librosa mel filterbank, already a codebase dep via visualization/model_vis.py; n_mels=128,
fmax=16kHz, n_fft=1024/hop=256). n_fft=1024 gives enough low-frequency resolution that the bottom
mel bands aren't empty (an earlier n_fft=512 manual filterbank striped). Keeps the existing shared
normalization: dB relative to the TARGET's peak MEL POWER so all rows stay directly comparable in
amplitude. Removes the now-unused linear _stft_mag helper. Y-axis relabeled 'mel (Hz)' with
mel-spaced Hz ticks; title notes 'mel'. Verified render on a live checkpoint: no striping, low/mid
vocal structure resolved.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017TbP4euX42eLH7CXGQbwaQ
…ls the LF rumble

Adds a deterministic low-frequency 'rumble' source: a parallel Mamba head (rumble_mamba + zero-init
rumble_net) that DIRECTLY emits a waveform (it does NOT drive an oscillator/ODE), band-limited to
< --rumble-lowpass-hz via a fixed soft-mask rFFT low-pass, and ADDED to the tract+noise output
OUTSIDE the tract. Gives the model a cheap dedicated channel for the sub-cutoff recording floor so
the oscillator isn't forced to spend capacity on it -- but the oscillator is left FULL-RANGE (not
high-passed), so it can still produce LF when a vocalization has genuine low-frequency content.

- model.py: Ouroboros(use_rumble_branch, rumble_lowpass_hz=250); get_rumble() + _lowpass_fft()
  (raised-cosine rolloff to 1.5x cutoff, differentiable). Zero-init head -> starts silent.
- spectral_rollout.py: rumble_branch(); added to the forward alongside the noise branch.
- eval.py: rumble added to the autonomous reconstruction + captured (_rumble_cap), threaded into
  all 3 return_drives dicts as 'rumble'.
- train.py/model_cv.py/entry: persistence + config threading + --use-rumble-branch/--rumble-lowpass-hz.
- monitor: per-epoch mel-PSD panel (psd/mel) -- target / tract out (=auto-noise-rumble) / noise /
  rumble for the 3 TB vocs.

Verified: low-pass band-limits (energy ratio above 1.5x cutoff ~1e-8); forward+backward finite with
grad reaching the zero-init head; end-to-end monitor render produces spectrograms + psd/mel with
drives['rumble'] populated; save/load round-trips the config.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017TbP4euX42eLH7CXGQbwaQ
Add the rumble branch (drives['rumble']) as its own spec row in the diagnostic panels, between
source and the drives/noise row: target / auto / source / rumble / (noise+drives). Same mel-spec +
target-referenced dB treatment as the other rows; band-limiting is visible as energy confined to the
bottom (< cutoff) mel bands. Only shown when the rumble branch is present.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017TbP4euX42eLH7CXGQbwaQ
(1) --mel-spec: compute the MRSTFT magnitude loss on MEL-warped spectra (librosa mel filterbank
applied to |STFT| before the SC + log-mag terms; --mel-n-mels, fmax=Nyquist). Emphasizes low/mid
vocal structure the way a mel spectrogram does.

(2) --osc-warmup-epochs N: gate the deterministic (oscillator->env->tract) output by osc_gain, held
at 0 for the first N epochs then ramped 0->1 over the next epoch. So the rumble+noise branches fit
the SPECTRAL loss FIRST and the oscillator is brought in afterward (for the harmonics), avoiding the
collapse where an easy branch grabs the loss early and knocks the oscillator out of the pitch basin.
At osc_gain=0 the spectral loss sends no gradient to the oscillator/tract/envelope (the TF anchor
still idles the drives in a learnable basin). Threaded through the training forward, train.py ramp,
eval autonomous reconstruction, autonomy_score, and the monitor (per-epoch osc_gain from the ramp
schedule, like noise_gain; title shows osc_gain).

Verified: mel loss finite + differs from linear + grads flow; osc_gain=1 gives full oscillator
gradient (~89), osc_gain=0 drops the spectral-path gradient to 0 (residual is the TF anchor);
end-to-end monitor render + score with the osc path works.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017TbP4euX42eLH7CXGQbwaQ
During oscillator warmup (osc_gain=0) the source row now reads ~0 (flat waveform, empty spectrogram),
reflecting that the oscillator isn't contributing to the output yet -- consistent with the auto row
(= rumble+noise only) and the osc_gain title tag.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017TbP4euX42eLH7CXGQbwaQ
The rumble low-pass passes 0 Hz and the MRSTFT loss ignores the 0-freq bin, so the rumble accumulated
an un-penalized DC offset (a pure additive offset on the output). Subtract its per-sample mean in
rumble_branch (both training + eval use it), matching the oscillator source's DC-drop. NOTE: this
zeroes the 0-freq term but does NOT remove the STFT edge frames' broadband spike -- that comes from
the nonzero signal value at the window boundary, not the DC, and is a cosmetic STFT boundary effect.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017TbP4euX42eLH7CXGQbwaQ
…ss (raw target)

autonomy_score + integrate_poly_autonomous used correct() (data - butter_lowpass, a high-pass) on
BOTH the target and the reconstruction, while the training loss fits the RAW target (only the 0-freq
STFT bin dropped) with a mean-subtracted source. Align them: target = raw seg; source detrend =
mean removal (matching xg - xg.mean()); ref_rms from raw audio. Now the metric + spectrograms +
PSD reflect exactly what the loss optimizes, with the LF modeled by the rumble branch rather than
filtered out.

NOTE (surfaced by this change): on the RAW org545 target the val metric jumps (autonomy -1.7 -> +0.9
at warmup, osc off) because the raw target is dominated by a broadband noise floor that the noise
branch matches ~perfectly + an LF lobe the rumble matches -- i.e. the metric now rewards matching the
recording noise, not the (buried) vocal harmonics. Was the point of the high-pass/denoising.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017TbP4euX42eLH7CXGQbwaQ
The time-varying noise gate g(t) was tracking syllabic content (sigma peaked on loud syllables;
temporal CV up to 0.8), so it soaks up vocalization energy -- fatal for a 'subtract the noise floor'
scheme. --sigma-constant makes the sigma Mamba head mean-pool over time and predict ONE number per
segment, forcing g to model a stationary floor level. Threaded through the constructor, save/load,
model_cv, and the CLI + manifest. Verified: g is exactly time-invariant (std 0) when set.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017TbP4euX42eLH7CXGQbwaQ
The constant sigma under the mean/log-mel loss parked ~20-50x above the true floor (loudness-weighted
average, not floor). --noise-floor-fit replaces the BROADBAND (>= --floor-cutoff-hz) spectral target
with a per-freq FLOOR = the --floor-pctile (default 15th) percentile of |STFT(target)| pooled over
batch AND time. Batch pooling is key -- a 46ms rollout may be all-syllable, but across 64 segments the
quiet inter-syllable frames reveal the floor (minimum statistics). LF bins keep the real time-resolved
target so the rumble fits the LF signal. Threaded through mrstft_loss -> spectral_rollout_step ->
train -> model_cv -> CLI + manifest. Verified: floor loss finite, grads flow, target switches to floor.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017TbP4euX42eLH7CXGQbwaQ
…ed cutoff)

The broadband noise carried LF energy, so the single shared sigma gate was propped up by the LF fit
(rumble band uses the real loud target) -- preventing sigma from converging to the true broadband
floor. Now, when the rumble branch is on, the noise is high-passed ABOVE rumble_lowpass_hz (high-pass
= x - lowpass(x) at the SAME cutoff), so rumble (lowpass) and noise (highpass) cross over cleanly and
ONE parameter (--rumble-lowpass-hz) controls both. LF is owned entirely by the rumble Mamba head;
sigma only ever scales the broadband floor. Verified: noise LF/total 0.0116 -> 0.0000 with rumble on.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017TbP4euX42eLH7CXGQbwaQ
…le-lowpass-hz

--floor-cutoff-hz now defaults to -1 = auto (1.5*rumble_lowpass_hz, the rumble's effective upper
edge), so ONE user parameter (--rumble-lowpass-hz) controls the whole LF/noise split: rumble low-pass,
noise high-pass, and floor-fit boundary. Explicit --floor-cutoff-hz still overrides.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017TbP4euX42eLH7CXGQbwaQ
…d percentile)

The batch-pooled per-freq percentile floor converged the noise to a SHARED target -- right for vocs
whose floor matched the batch average, off-by-a-constant for others (a target problem, per-voc floors
vary). New target, per sample: select the QUIET TIME FRAMES (broadband power -- NOT total power, which
the LF contaminates -- in the [0.4x,1x]*floor_pctile percentile band) and average their spectra -> a
coherent per-sample floor SPECTRUM. Unlike a per-frequency percentile (which stitches a different time
frame per bin and sits ~4-6 dB below the mean), the quiet-frame mean is only ~1 dB biased; a
floor_correction factor (~1.2, cf MAD->sigma 1.4826) de-biases the residual minimum-statistics pull.
The shared noise_tract learns the shape. Broadband bins get this floor; LF keeps the real target.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017TbP4euX42eLH7CXGQbwaQ
Add docs/noise_floor_fit.md documenting the noise-floor-fit choices (per-sample quiet-frame floor
target, single LF/noise crossover, why quiet-frame-spectrum over per-freq-percentile, the total-power
vs per-bin de-bias equivalence) and the principled Gamma-K derivation of --floor-correction with its
<1% validation vs simulation. Cross-referenced from the floor block in mrstft_loss.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017TbP4euX42eLH7CXGQbwaQ
claude added 16 commits July 7, 2026 14:51
…-pool

The mean-pool reduction for --sigma-constant is loudness-weighted, so it can't estimate a per-voc
floor from a loud-syllable voc -- and with per-voc floor targets it collapsed the shared sigma to 0
(noise branch died) by ep2 trying to satisfy the low-floor vocs. The sigma_mamba already reads the
full waveform + its reversal (x_in=[flip(z),z], bidirectional); the fix is the REDUCTION: take the
15th percentile over time of the per-time gate. The floor IS the quiet-moment level, so this lets the
SSM park syllable loudness in the high percentiles and read the floor off the low ones, and lets
different vocs land on different floors. See docs/noise_floor_fit.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017TbP4euX42eLH7CXGQbwaQ
…ercentile)

The hard 15th-percentile readout gave gradient to a single time sample -> sparse updates that
overshot and oscillated (syllD 109->52->0.95->13.8, syllE 46->16->0->6.9) and snapped to 0 when the
head zeroed part of the gate. Replace with the mean of the lowest ~20% of the per-time gate: dense
gradient over the whole quiet tail, smooth, and it can't collapse to 0 unless the entire tail is 0.
Still reads the floor (quiet-moment level), still per-voc. See docs/noise_floor_fit.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017TbP4euX42eLH7CXGQbwaQ
… reduction in gradient)

Every scalar reduction of the gate in the forward pass failed: mean-pool collapsed sigma to 0,
hard-percentile oscillated (109->0.95->13.8), soft-low-k still fragile -- because the reduction sat
in the gradient path and the frames were selected by g(t)'s own moving values. New scheme: keep g(t)
TIME-VARYING and mask the BROADBAND spectral loss to the target's QUIET frames (broadband power in
[0.4p,p] percentile). There g(t)*noise_tract is regressed onto the real quiet-frame spectra (the
floor); loud frames get no broadband gradient so syllables can't drag g up; LF keeps the loss at all
frames (rumble). Frames are selected by the TARGET (stable per-voc), not g(t). The constant per-voc
readout g0 = mean(g(t)) over the quiet frames x floor_correction is taken POST-HOC, out of the
gradient. get_sigma no longer reduces under --sigma-constant. Next: a learned g0 head. docs updated
separately.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017TbP4euX42eLH7CXGQbwaQ
…s) + masked loss

Per user: strip the noise-floor readout to its simplest form. Mamba sees the whole waveform, mean-pools
to a single scalar gain on the filtered noise; the spectral loss is masked to the quiet frames, so the
gain is trained directly to the floor. No time-varying g(t), no post-hoc readout, no second head -- the
scalar IS the readout. Softplus (not relu) so it can approach 0 smoothly for clean vocs rather than
dead-relu collapsing (the failure mode of the earlier mean-pool+relu under the unmasked floor-replace
loss). The masked-loss regression is what makes this a fair test of whether the Mamba can just learn
the quiet-part gain. Time-varying version preserved at 9b58d79 as fallback.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017TbP4euX42eLH7CXGQbwaQ
…td (time-domain rumble MSE)

Two fixes for the noise-floor-fit stage:

(1) --noise-fit-only: the oscillator is frozen+off during this stage, so skip the drives / TF anchor /
RK4 rollout entirely and run only the feedforward rumble + filtered noise. No O(H) rollout cost, so the
window can be LONG (use --context-len ~0.3s): a 46ms window has no quiet frames for a dense vocalizer
(syllD's 46ms quiet-frame floor is 67x its true full-voc floor), so g was fitting an elevated target;
a long window contains quiet gaps and the per-segment quiet-frame floor becomes the true floor.

(2) --lam-rumble-td: time-domain MSE of the rumble vs the raw LF waveform (lowpass at rumble_lowpass_hz,
DC-subtracted). The rumble is deterministic so it CAN phase-align; the magnitude loss can't (phase
random -> raw-rumble ADDS power, 4-5x, instead of cancelling). Rumble-only; drop once rumble frozen ->
then raw-rumble subtracts cleanly. Verified: noise-fit-only 13k-sample window in 0.43s, no oscillator
grad; rumble MSE fires on LF signals with grad to rumble_net.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017TbP4euX42eLH7CXGQbwaQ
Fold in everything the runs established: the masked-regression floor loss (scalar softplus gain trained
by masking the broadband loss to target-selected quiet frames -- after mean-pool collapse / percentile
oscillation showed no in-forward reduction survives the gradient); the long-window --noise-fit-only
stage (skip oscillator -> 300ms window so dense vocs' segments contain quiet gaps -> true floor, vs 67x
elevated at 46ms); the rumble time-domain MSE (deterministic rumble CAN phase-align; enables clean
time-domain subtraction, magnitude-only would ADD LF 4-5x); the residual recipe (rumble time-domain +
noise power-domain); [5,15] beats [10,25] as the floor estimator; universal floor shape -> one shared
filter; and the K-on-quiet-frames caveat for the self-calibrating correction. Replaces the now-resolved
mean-pool 'open issue'.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017TbP4euX42eLH7CXGQbwaQ
…d (was unapplied + hard-coded)

Two bugs the user caught: (1) when the floor loss switched from replacing the target to MASKING it, the
floor_correction went dead -- the gain was trained to the raw quiet-frame low-tail with no de-bias, so
sparse vocs undershot below their floor. (2) it was a hard-coded scalar (1.2/1.3). Fix: in the floor
block, lift the broadband quiet-frame TARGET by C(K, band), where C is the closed-form minimum-statistics
correction _floor_C(K, p_lo, p_hi) computed from the [0.4p,p] band and K = mean^2/var of the broadband
level over its lower half (~noise; the loud syllables that collapse K are excluded), estimated per batch.
--floor-correction default -1 = auto; >0 overrides. Verified C tracks the band (1.21/1.29/1.30 for
[10,25]/[6,15]/[5,15] at K=30) and grads flow.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017TbP4euX42eLH7CXGQbwaQ
…t -> 2.3x over-correction)

The auto C(K,band) estimated K by pooling the broadband level bbp across the whole batch, so the
cross-voc LEVEL differences (recordings vary in floor level) dominated the variance and collapsed K
far below its true value -> a huge cmag and a 2.3x over-correction at ep0. Estimate K PER SAMPLE
instead (mean^2/var of each voc's own below-median bbp), then per-sample C(K,band). Now K~92 ->
cmag~1.15 (near the true magnitude-sum pure-noise DOF ~150-300 -> ~1.10), a sane modest de-bias.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017TbP4euX42eLH7CXGQbwaQ
…mble low-pass

The single shared crossover (noise HP = rumble LP = 250) leaves a residual bump in 250-375Hz: the
rumble rolls off from 250 while the raw still has LF signal there. Add --noise-highpass-hz (default 0 =
use rumble_lowpass_hz, unchanged): set noise-HP 250 < rumble-LP 300 to DECOUPLE, giving a 250-300
OVERLAP where the rumble carries the deterministic (phase-matched) LF signal and the noise carries the
floor. Threaded through the model, filtered_noise_branch, save/load, model_cv, CLI + manifest. Verified
the noise gains power in 250-300 when decoupled. To use next run after corr2 finishes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017TbP4euX42eLH7CXGQbwaQ
Two materially-stale sections fixed: (1) the bias correction is no longer 'not yet wired'/hard-coded --
it is computed live as C(K,band) from the band and a PER-SAMPLE K (mean^2/var of each voc's own
below-median broadband level; batch-pooling collapses K), applied by lifting the quiet-frame target
before both loss terms; document the two bugs this fixed (correction went dead on the mask switch;
hard-coded 1.2/1.3) and the magnitude-sum DOF caveat. (2) new --noise-highpass-hz decouples the noise
HP from the rumble LP (rumble-LP 300 / noise-HP 250) to fill the 250-375 crossover residual bump
(measured 0.51 -> 0.05 at 300-375, floors unchanged). Update flags + branches + validation notes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017TbP4euX42eLH7CXGQbwaQ
…round freeze)

Stage 2 of the sequential separation trains the oscillator on the residual by freezing the whole learned
BACKGROUND (noise gate + noise_tract + rumble head) and resuming a noise-fit checkpoint. freeze_noise_epochs
previously covered only sigma+noise_tract; add rumble_mamba+rumble_net so one flag freezes both branches.
Set freeze_noise_epochs >= n_epochs to keep the background frozen for the whole oscillator run.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017TbP4euX42eLH7CXGQbwaQ
…C) rollout

The diagnostic panels reused the cold-start autonomy_score trajectories -- silence-lead-in vocs whose
IC (audio[0]) is silence -- so the 'source' row was a non-ignited cold-start rollout, not comparable to
the target panel and not what training optimizes. Add a second rollout for the PANELS on the same vocs
with the ~45ms/2000-sample silence lead-in stripped (audio[0] = onset = the segment data IC training
supplies), cold_start=False; keep the cold-start call only for the Val/autonomy deployment METRIC. Drives
stay open-loop from the target and the state autonomous -- identical dynamics to teacher_forced_rollout.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017TbP4euX42eLH7CXGQbwaQ
…ng like a kernel weight)

Stage-2 diagnosis: the oscillator's gamma drive runs away negative (bias 0->-0.07, weight collapsing),
so the anti-damped state diverges to the +-3 soft clamp -- there's nothing anchoring gamma. Add a penalty
lam_gamma * lam**1 * sum_t gamma(t)^2: gamma is the degree-1 linear-damping coefficient (the x'^1 term),
so this is the SAME degree-graded-L2 form the kernel weights get, pulling it toward neutral damping so it
can't drift into the diverging regime. On its OWN scale (not lam_reg): gamma needs a far larger coefficient
to bite than the kernel tolerates without losing its amplitude-bounding nonlinearity. Omega is deliberately
NOT regularized (that would drop the natural frequency out of the harmonic band). Threaded through
spectral_rollout_step/train/model_cv/CLI + Loss/gamma_reg TB scalar. Default 0 = off (no behavior change).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017TbP4euX42eLH7CXGQbwaQ
…h), drop separate lam_gamma

Per design call: regularize gamma EXACTLY as a kernel term rather than on a separate scale. gamma is the
degree-1 linear-damping coefficient (x'^1), so add lam**1 * sum_t gamma^2 directly into L_reg -> scaled by
the same lam_reg and graded identically to the polynomial coefficients. Removes the lam_gamma param/CLI/
threading added in the prior commit. Omega is NOT regularized (would pull the natural frequency down).
gamma_reg still logged (Loss/gamma_reg) as the gamma-only slice of L_reg for monitoring. Raising lam_reg
now tightens kernel + gamma together (toward the linear, neutral-damping, on-frequency regime).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017TbP4euX42eLH7CXGQbwaQ
…oll CPU cost)

The prior data-IC-panels patch kept the cold-start autonomy_score (for the Val/autonomy metric) AND added
a second data-IC autonomy_score (for the panels) -> two full-voc integrations per poll, ~2x slower on CPU.
Collapse to ONE data-IC call (silence lead-in stripped, cold_start=False) feeding both the Val/* scalars
and the panels. Trades away the separate cold-start (ignition) autonomy number; Val/autonomy is now the
data-IC autonomy, which matches what the panels show and what training optimizes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017TbP4euX42eLH7CXGQbwaQ
… raw

The oscillator is meant to reconstruct the residual after the frozen rumble+noise; the TF acceleration
anchor was still teacher-forcing to the RAW audio, so it fought the rumble's LF content. Subtract the
(frozen, detached) rumble waveform and its 1st/2nd derivatives -- same 9-pt finite-diff stencil and
tau/dt rescaling as the data derivatives -- from the teacher-forced state (x, z2), the target accel
(d2x), and recompute the polynomial kernel at the residual state. Gated on use_rumble_branch; no-op
otherwise. Verified finite L_tf + drive grads.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017TbP4euX42eLH7CXGQbwaQ
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants