From 6aa275b93d0f690d1f107e6d9e869724967e7ed2 Mon Sep 17 00:00:00 2001 From: Vynull App Date: Sun, 6 Sep 2026 18:22:17 +0000 Subject: [PATCH 1/2] analysis: re-trim tempogram latency to center the grid-phase peak The windowed phase estimator turned out to be start-anchored: the band-norm EMA warms up from zero, which inflates the first second of flux frames ~100x, and with the amplitude-cubed window weighting the phase then comes almost entirely from the track's first strong onsets. An A/B against a library of imported rekordbox grids shows this is load-bearing, not a bug: seeding the EMA honestly drops <50ms grid agreement by 12 points and grows the half-beat tail, and an explicit exponential early-window decay cannot reproduce the win either, so the anchor lives in the inflated first onset frames, not in any window preference. Both experiments stay behind new default-off knobs (BandNormWarmupSec, PhaseEarlyTauSec) whose comments warn the warmup must not be "fixed", and a new env-gated TestWarmupDiag measures the window-0 dominance directly. Rekordbox itself normalizes its onset novelty with the same kind of one-pole EMA, so its phase is plausibly start-anchored by the very same accident. The calibration that WAS wrong: TempogramLatencyMs was centered on the mean error, but the well-aligned population sits ~9ms early while a long late tail drags the mean positive. Re-trimming 30 -> 34ms centers the error distribution's peak instead, taking <10ms grid agreement from 23.9% to 34.3% with <20ms, <50ms and the half-beat tail unchanged (tuned and held-out validated on disjoint slices; latency shifts can now be swept offline from a VYNULL_BEAT_REF_DUMP CSV). cacheVersion bumps to 28 so existing libraries re-analyze onto the corrected phase. --- analysis/analysis.go | 5 +- analysis/beatgrid_accuracy_test.go | 4 +- analysis/bpm.go | 54 +++++++- analysis/warmupdiag_test.go | 215 +++++++++++++++++++++++++++++ 4 files changed, 275 insertions(+), 3 deletions(-) create mode 100644 analysis/warmupdiag_test.go diff --git a/analysis/analysis.go b/analysis/analysis.go index 0d99a8d..d4e5ad6 100644 --- a/analysis/analysis.go +++ b/analysis/analysis.go @@ -36,7 +36,10 @@ const AnalysisRate = 44100 // Bumped to 28 for the PWV4 7-bit band clamp: cached colour previews may // carry >127 in d2-d5, which real rekordbox never emits and which restarts // an XDJ-AZ on load, so v27 waveforms must re-encode. -const cacheVersion = 28 +// Bumped to 29 for the TempogramLatencyMs re-trim (30 -> 34ms): every grid +// shifts +4ms, substantially raising sub-10ms agreement with rekordbox's +// grids, so v28 grids re-analyze to pick up the corrected phase. +const cacheVersion = 29 // PWV4Override and PWV5Override, when non-nil, replace every track's color // preview / detail waveform at serve time. Set via the --pwv4-override / diff --git a/analysis/beatgrid_accuracy_test.go b/analysis/beatgrid_accuracy_test.go index ea632a8..fa56dbe 100644 --- a/analysis/beatgrid_accuracy_test.go +++ b/analysis/beatgrid_accuracy_test.go @@ -76,7 +76,9 @@ func TestBeatGridAccuracy(t *testing.T) { envF("VYNULL_BEAT_REF_CLARW", &ClarityWeight) envF("VYNULL_BEAT_REF_GATE", &HalfBeatGate) envF("VYNULL_BEAT_REF_LATENCY", &TempogramLatencyMs) - t.Logf("windowed=%v winSec=%.1f ampW=%.1f clarW=%.1f gate=%.1f", WindowedPhase, WindowSec, AmpWeight, ClarityWeight, HalfBeatGate) + envF("VYNULL_BEAT_REF_WARMUP", &BandNormWarmupSec) + envF("VYNULL_BEAT_REF_EARLYTAU", &PhaseEarlyTauSec) + t.Logf("windowed=%v winSec=%.1f ampW=%.1f clarW=%.1f gate=%.1f warmup=%.1f earlyTau=%.1f", WindowedPhase, WindowSec, AmpWeight, ClarityWeight, HalfBeatGate, BandNormWarmupSec, PhaseEarlyTauSec) type result struct { name string diff --git a/analysis/bpm.go b/analysis/bpm.go index 9af1c06..b346326 100644 --- a/analysis/bpm.go +++ b/analysis/bpm.go @@ -732,6 +732,15 @@ var ( BandNormAlpha = 0.99 MultiBandMaxHz = 4096.0 + // BandNormWarmupSec seeds each band's normalization EMA with its average + // magnitude over this many leading seconds, instead of letting it warm up + // from zero. From zero, the first ~1.2s of flux frames are divided by a + // near-zero running level and come out inflated ~100x; window 0's phasor + // magnitude then dominates windowedTempogramPhase's amplitude-cubed combine + // on many tracks, effectively reducing "windowed" to "window 0 only". + // 0 keeps the legacy from-zero warmup. + BandNormWarmupSec = 0.0 + // TempogramLatencyMs is the pipeline group delay (STFT framing + flux + EMA), // which is codec-independent. LossyEncoderDelayMs is added on top for lossy // codecs: rekordbox grids on the raw decoded stream INCLUDING the ~1105-sample @@ -741,7 +750,12 @@ var ( // terms were separated by calibrating the pipeline delay on lossless (the clean // case) and confirming lossy = pipeline + encoder delay reproduces the old // single 55ms constant. See EncoderDelayMs and DetectBeatsWithEncoderDelay. - TempogramLatencyMs = 30.0 + // 34 = the earlier 30ms calibration re-trimmed +4ms against a library of + // imported rekordbox grids: the well-aligned population sat ~9ms early + // while a long late tail held the MEAN bias positive — centering the error + // distribution's PEAK, not its mean, took <10ms alignment 23.9%->34.3% + // with <20/<50ms and the half-beat tail unchanged (held-out validated). + TempogramLatencyMs = 34.0 LossyEncoderDelayMs = 25.0 // HalfBeatGate, when > 0, flips the grid by half a beat if the half-beat @@ -769,6 +783,16 @@ var ( AmpWeight = 3.0 ClarityWeight = 1.0 + // PhaseEarlyTauSec, when > 0, multiplies each window's combine weight by + // exp(-startSec/tau): an explicit start-anchor. Rekordbox's grid phase is + // anchored near the track start — discovered via the EMA warmup spike, + // which accidentally gave window 0 a ~10^5x weight and OUTSCORED the honest + // windowed combine by 12pts (<50ms 73.9% vs 61.3% with the warmup seeded). + // The decay makes that anchoring deliberate: early clean windows dominate, + // but a silent or fade-in intro still falls through to later windows. + // 0 disables (pure clarity-weighted combine). + PhaseEarlyTauSec = 0.0 + // SnapVerify enables coherence refinement + integer snap of the detected // BPM (see snapVerifyBPM). The autocorrelation lag grid is coarse // (~2.9ms at 44.1k/128) and parabolic peak interpolation lands 0.1-0.5 @@ -868,6 +892,31 @@ func multiBandOnset(samples []float32, sampleRate int) ([]float64, float64) { prevMag := make([]float64, nb) bandMag := make([]float64, nb) mean := make([]float64, nb) // per-band running level for adaptive normalization + if UseBandNorm && BandNormWarmupSec > 0 { + // Seed the EMA at each band's average level over the leading + // BandNormWarmupSec so the first flux frames are divided by a real + // level, not a near-zero warming-up one (see BandNormWarmupSec). + warm := int(BandNormWarmupSec * float64(sampleRate) / float64(hop)) + if warm > n { + warm = n + } + for k := 0; k < warm && k < len(re); k++ { + lo := 1 + for b := 0; b < nb; b++ { + var s float64 + for bin := lo; bin < hiBin[b]; bin++ { + s += math.Sqrt(re[k][bin]*re[k][bin] + im[k][bin]*im[k][bin]) + } + mean[b] += s + lo = hiBin[b] + } + } + if warm > 0 { + for b := range mean { + mean[b] /= float64(warm) + } + } + } for k := 0; k < n && k < len(re); k++ { lo := 1 for b := 0; b < nb; b++ { @@ -948,6 +997,9 @@ func windowedTempogramPhase(onset []float64, msPerFrame, msPerBeat, winSec, ampW continue } weight := math.Pow(mag, ampW) * math.Pow(mag/esum, clarW) + if PhaseEarlyTauSec > 0 { + weight *= math.Exp(-float64(start) * msPerFrame / 1000 / PhaseEarlyTauSec) + } Zr += weight * zr / mag Zi += weight * zi / mag } diff --git a/analysis/warmupdiag_test.go b/analysis/warmupdiag_test.go new file mode 100644 index 0000000..30fa2ac --- /dev/null +++ b/analysis/warmupdiag_test.go @@ -0,0 +1,215 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package analysis + +import ( + "encoding/json" + "math" + "os" + "runtime" + "sort" + "strconv" + "sync" + "testing" +) + +// TestWarmupDiag quantifies the multiBandOnset EMA warmup spike's effect on +// windowedTempogramPhase, on real tracks. The band-norm EMA (mean[b] starts at +// 0, alpha 0.99) makes the first ~1.2s of flux frames hugely inflated; window +// 0's phasor magnitude is therefore inflated, and with AmpWeight=3 its combine +// weight may dominate the whole track. For each sampled manifest track this +// prints window 0's share of the total combine weight and how far the phase +// moves when the warmup-contaminated windows are excluded. +// +// VYNULL_WARMUP_DIAG=/path/manifest.json go test ./analysis/ -run WarmupDiag -v +// +// VYNULL_WARMUP_DIAG_N caps the sample (default 60, stride-sampled across the +// manifest for genre spread). +func TestWarmupDiag(t *testing.T) { + path := os.Getenv("VYNULL_WARMUP_DIAG") + if path == "" { + t.Skip("set VYNULL_WARMUP_DIAG to a reference manifest to run") + } + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read manifest: %v", err) + } + var gt []struct { + File string `json:"file"` + BPM float64 `json:"bpm"` + FirstBeatMs float64 `json:"first_beat_ms"` + Title string `json:"title"` + } + if err := json.Unmarshal(data, >); err != nil { + t.Fatalf("parse manifest: %v", err) + } + nSample := 60 + if v, _ := strconv.Atoi(os.Getenv("VYNULL_WARMUP_DIAG_N")); v > 0 { + nSample = v + } + stride := len(gt) / nSample + if stride < 1 { + stride = 1 + } + var sample []int + for i := 0; i < len(gt) && len(sample) < nSample; i += stride { + sample = append(sample, i) + } + + const warmupMs = 1500.0 // exclude windows starting inside the EMA warmup + + type res struct { + w0Share float64 // window 0's fraction of total combine weight + magRatio float64 // window 0 phasor magnitude / median other-window magnitude + deltaMs float64 // |phase(all) - phase(excluding warmup windows)| folded + nWin int + ok bool + } + results := make([]res, len(sample)) + + var wg sync.WaitGroup + jobs := make(chan int) + for w := 0; w < runtime.NumCPU()/2; w++ { + wg.Add(1) + go func() { + defer wg.Done() + for si := range jobs { + g := gt[sample[si]] + if g.BPM <= 0 { + continue + } + samples, err := DecodePCM(g.File, AnalysisRate) + if err != nil || len(samples) == 0 { + continue + } + onset, msPerFrame := multiBandOnset(samples, AnalysisRate) + if onset == nil { + continue + } + msPerBeat := 60000.0 / g.BPM + period := msPerBeat / msPerFrame + if period <= 1 || len(onset) < int(period*2) { + continue + } + w2pi := 2 * math.Pi / period + winFrames := int(WindowSec * 1000 / msPerFrame) + if min := int(period * 2); winFrames < min { + winFrames = min + } + hop := winFrames / 2 + if hop < 1 { + hop = 1 + } + var weights, mags []float64 + var ZrAll, ZiAll, ZrLate, ZiLate float64 + for start := 0; start+winFrames <= len(onset); start += hop { + var zr, zi, esum float64 + for n := start; n < start+winFrames; n++ { + e := onset[n] + zr += e * math.Cos(w2pi*float64(n)) + zi += e * math.Sin(w2pi*float64(n)) + esum += e + } + mag := math.Hypot(zr, zi) + if mag < 1e-12 || esum < 1e-12 { + weights = append(weights, 0) + mags = append(mags, 0) + continue + } + weight := math.Pow(mag, AmpWeight) * math.Pow(mag/esum, ClarityWeight) + weights = append(weights, weight) + mags = append(mags, mag) + ZrAll += weight * zr / mag + ZiAll += weight * zi / mag + if float64(start)*msPerFrame >= warmupMs { + ZrLate += weight * zr / mag + ZiLate += weight * zi / mag + } + } + if len(weights) < 3 { + continue + } + var wsum float64 + for _, x := range weights { + wsum += x + } + others := append([]float64(nil), mags[1:]...) + sort.Float64s(others) + medOther := others[len(others)/2] + phase := func(zr, zi float64) (float64, bool) { + if zr == 0 && zi == 0 { + return 0, false + } + n0 := math.Mod(math.Atan2(zi, zr)/w2pi, period) + if n0 < 0 { + n0 += period + } + return n0 * msPerFrame, true + } + pAll, ok1 := phase(ZrAll, ZiAll) + pLate, ok2 := phase(ZrLate, ZiLate) + if !ok1 || !ok2 { + continue + } + d := math.Mod(pAll-pLate, msPerBeat) + if d < -msPerBeat/2 { + d += msPerBeat + } else if d >= msPerBeat/2 { + d -= msPerBeat + } + r := res{ + w0Share: weights[0] / (wsum + 1e-30), + deltaMs: math.Abs(d), + nWin: len(weights), + ok: true, + } + if medOther > 0 { + r.magRatio = mags[0] / medOther + } + results[si] = r + } + }() + } + for i := range sample { + jobs <- i + } + close(jobs) + wg.Wait() + + var shares, ratios, deltas []float64 + dom50, dom90, moved20, moved50 := 0, 0, 0, 0 + for _, r := range results { + if !r.ok { + continue + } + shares = append(shares, r.w0Share) + ratios = append(ratios, r.magRatio) + deltas = append(deltas, r.deltaMs) + if r.w0Share > 0.5 { + dom50++ + } + if r.w0Share > 0.9 { + dom90++ + } + if r.deltaMs > 20 { + moved20++ + } + if r.deltaMs > 50 { + moved50++ + } + } + n := len(shares) + if n == 0 { + t.Fatal("no tracks scored") + } + sort.Float64s(shares) + sort.Float64s(ratios) + sort.Float64s(deltas) + pct := func(c int) float64 { return 100 * float64(c) / float64(n) } + t.Logf("EMA warmup diagnostic — %d tracks", n) + t.Logf(" window-0 weight share: median %.3f p90 %.3f max %.3f", shares[n/2], shares[n*9/10], shares[n-1]) + t.Logf(" window-0 dominates (>50%% weight): %.1f%% (>90%%): %.1f%%", pct(dom50), pct(dom90)) + t.Logf(" window-0 mag / median other mag: median %.1fx p90 %.1fx", ratios[n/2], ratios[n*9/10]) + t.Logf(" phase shift when excluding warmup windows: median %.1f ms p90 %.1f ms", deltas[n/2], deltas[n*9/10]) + t.Logf(" tracks where phase moves >20ms: %.1f%% >50ms: %.1f%%", pct(moved20), pct(moved50)) +} From 88b2c753615f38ff84295d194f6111ee12e0a97d Mon Sep 17 00:00:00 2001 From: Vynull App Date: Thu, 1 Oct 2026 21:03:20 +0000 Subject: [PATCH 2/2] analysis: user-edited beat grids survive cacheVersion bumps loadFromDisk discarded (and deleted) any stale-version cache entry unconditionally, GridEdited or not - every cacheVersion bump so far has silently wiped all hand-fixed beat grids and re-analyzed those tracks back to the detector's wrong phase. Found while preparing the v29 bump, which would have destroyed a grid fixed minutes earlier. A stale entry carrying GridEdited now stashes its beats/BPM/downbeat (and keeps the file on disk, so the edit also survives a restart that happens before re-analysis); Set re-applies the stash onto the fresh result - new waveforms, the user's grid - and regenerates the grid blobs from the carried beats. TestGridEditSurvivesCacheBump pins the miss-but-keep-file behavior, the re-application on Set, and persistence across a store restart. --- analysis/analysis.go | 44 ++++++++++++++++++++++++-- analysis/gridcarry_test.go | 64 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 106 insertions(+), 2 deletions(-) create mode 100644 analysis/gridcarry_test.go diff --git a/analysis/analysis.go b/analysis/analysis.go index d4e5ad6..ed0249f 100644 --- a/analysis/analysis.go +++ b/analysis/analysis.go @@ -95,6 +95,14 @@ type Result struct { GridEdited bool // user manually adjusted the beat grid — serve our blobs, not the on-disk ANLZ } +// gridCarry is a user-edited beat grid rescued from a stale-version cache +// entry, awaiting re-application after re-analysis (see loadFromDisk / Set). +type gridCarry struct { + beats []float64 + bpm float64 + downbeat int +} + // Store holds analysis results keyed by track ID. // When cacheDir is set, results are persisted to disk using gob encoding, // keyed by a hash of the track's file path for stable cache identity. @@ -118,6 +126,11 @@ type Store struct { // which only covers analyzed/cache-loaded tracks). Set once at startup. TotalTracksFn func() int + // gridCarry holds user-edited beat grids rescued from stale-version + // cache entries (keyed by file path), re-applied by Set after the + // track re-analyzes. Guarded by mu. + gridCarry map[string]gridCarry + // Importer, when set, is tried before running our own DSP on a track: // it returns pre-existing analysis (e.g. parsed from a served rekordbox // USB's ANLZ files) or nil to fall through to AnalyzeTrack. An imported @@ -171,11 +184,24 @@ func (s *Store) Get(trackID uint32) *Result { return nil } -// Set stores an analysis result for a track. +// Set stores an analysis result for a track. If a user-edited beat grid was +// carried over from a stale-version cache entry for this file (see +// loadFromDisk), it is re-applied here: a cacheVersion bump refreshes the +// waveforms but must never revert a grid the user fixed by hand. func (s *Store) Set(trackID uint32, r *Result) { s.mu.Lock() - s.results[trackID] = r filePath := s.pathMap[trackID] + if c, ok := s.gridCarry[filePath]; ok && r != nil && !r.GridEdited { + r.Beats = c.beats + r.BPM = c.bpm + r.DownbeatIndex = c.downbeat + r.GridEdited = true + r.BeatGrid = nil // regenerate blobs from the carried beats + r.BeatGridPQT2 = nil + delete(s.gridCarry, filePath) + log.Printf("analysis-cache: re-applied user-edited beat grid for track %d after re-analysis", trackID) + } + s.results[trackID] = r s.mu.Unlock() // Persist to disk. @@ -420,6 +446,20 @@ func (s *Store) loadFromDisk(filePath string) *Result { return nil } if r.CacheVersion != cacheVersion { + // A user-edited beat grid must survive the bump: stash it for Set() + // to re-apply after re-analysis, and keep the file on disk so the + // edit also survives a restart that happens before the track is + // re-analyzed (the stash is in-memory only). + if r.GridEdited { + s.mu.Lock() + if s.gridCarry == nil { + s.gridCarry = make(map[string]gridCarry) + } + s.gridCarry[filePath] = gridCarry{beats: r.Beats, bpm: r.BPM, downbeat: r.DownbeatIndex} + s.mu.Unlock() + log.Printf("analysis-cache: stale version %d (want %d) but grid is user-edited — re-analyzing %s, keeping the edited grid", r.CacheVersion, cacheVersion, filepath.Base(path)) + return nil + } log.Printf("analysis-cache: stale version %d (want %d), discarding %s", r.CacheVersion, cacheVersion, filepath.Base(path)) os.Remove(path) return nil diff --git a/analysis/gridcarry_test.go b/analysis/gridcarry_test.go new file mode 100644 index 0000000..5d2a168 --- /dev/null +++ b/analysis/gridcarry_test.go @@ -0,0 +1,64 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package analysis + +import ( + "encoding/gob" + "os" + "testing" +) + +// TestGridEditSurvivesCacheBump pins the contract that a user-edited beat +// grid survives a cacheVersion bump: the stale entry is re-analyzed for +// fresh waveforms, but the hand-fixed grid is carried over instead of +// being reverted to the detector's (wrong) phase. Before this, every bump +// silently deleted all grid edits. +func TestGridEditSurvivesCacheBump(t *testing.T) { + dir := t.TempDir() + s := NewStoreWithCache(dir) + const fp = "/music/some-track.flac" + s.SetPath(7, fp) + + // A previous-version cache entry carrying a user-edited grid. + stale := &Result{ + CacheVersion: cacheVersion - 1, + GridEdited: true, + BPM: 122, + Beats: []float64{0, 491.8, 983.6}, + DownbeatIndex: 0, + } + f, err := os.Create(s.cacheFile(fp)) + if err != nil { + t.Fatal(err) + } + if err := gob.NewEncoder(f).Encode(stale); err != nil { + t.Fatal(err) + } + f.Close() + + // Get must treat it as a miss (forcing re-analysis), but keep the file + // so the edit also survives a restart before re-analysis happens. + if r := s.Get(7); r != nil { + t.Fatalf("stale entry served: %+v", r) + } + if _, err := os.Stat(s.cacheFile(fp)); err != nil { + t.Fatal("stale grid-edited cache file was deleted — edit would not survive a restart") + } + + // Fresh analysis arrives with the detector's (wrong) grid: the edit + // must win. + fresh := &Result{CacheVersion: cacheVersion, BPM: 122, Beats: []float64{302.4, 794.2}, DownbeatIndex: 1} + s.Set(7, fresh) + got := s.Get(7) + if got == nil || !got.GridEdited || got.Beats[0] != 0 || got.DownbeatIndex != 0 { + t.Fatalf("edited grid not re-applied: %+v", got) + } + + // And the merged result must be what persisted: a second store sees it. + s2 := NewStoreWithCache(dir) + s2.SetPath(7, fp) + got2 := s2.Get(7) + if got2 == nil || !got2.GridEdited || got2.Beats[0] != 0 { + t.Fatalf("persisted result lost the edit: %+v", got2) + } +}