diff --git a/analysis/analysis.go b/analysis/analysis.go index 46166f1..62a37e2 100644 --- a/analysis/analysis.go +++ b/analysis/analysis.go @@ -30,7 +30,10 @@ const AnalysisRate = 44100 // phase, gated half-beat flip, codec-aware lossless latency): the values in a // v25 cache are still readable but its grids predate those fixes, so one // re-analysis pass upgrades every library instead of only newly-added tracks. -const cacheVersion = 26 +// Bumped to 27 for integer-snap BPM verification: ~1/3 of a real library +// detected a fraction off the true integer tempo (and gridded at the wrong +// period), so v26 BPMs and grids must re-analyze. +const cacheVersion = 27 // 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/bpm.go b/analysis/bpm.go index cf6ecf2..9af1c06 100644 --- a/analysis/bpm.go +++ b/analysis/bpm.go @@ -317,6 +317,22 @@ func DetectBeatsWithEncoderDelay(samples []float32, sampleRate int, encoderDelay bpm = math.Round(bpm*100) / 100 + // The phase onset envelope (multi-band when enabled) is shared by the + // snap verification here and the tempogram phase step below; computing it + // once keeps the snap step free on the tracks it doesn't change. + phaseOnset, phaseOnsetMs := onset, msPerFrame + if UseTempogramPhase && UseMultiBandOnset { + if mb, mbMs := multiBandOnset(samples, sampleRate); mb != nil { + phaseOnset, phaseOnsetMs = mb, mbMs + } + } + + // Integer-snap verification BEFORE any phase work, so the grid and + // downbeat are fit at the corrected tempo rather than patched after. + if SnapVerify && UseTempogramPhase { + bpm = snapVerifyBPM(phaseOnset, phaseOnsetMs, bpm) + } + // Now build a refined beat grid by snapping peaks to a regular grid. // Use the detected peaks to find the best phase alignment. msPerBeat := 60000.0 / bpm @@ -568,14 +584,9 @@ func DetectBeatsWithEncoderDelay(samples []float32, sampleRate int, encoderDelay // peak/DP phase, which only ever aligned to onset peaks in a fixed band // and matched rekordbox's grid ~30% of the time. if UseTempogramPhase { - phaseOnset, phaseOnsetMs := onset, msPerFrame - if UseMultiBandOnset { - if mb, mbMs := multiBandOnset(samples, sampleRate); mb != nil { - phaseOnset, phaseOnsetMs = mb, mbMs - } - } // Combine per-window phasors (WindowedPhase, the default) so the sharpest, // cleanest sections dominate; fall back to the whole-track tempogram sum. + // phaseOnset was computed above, before the snap verification. tp, ok := tempogramPhase(phaseOnset, phaseOnsetMs, msPerBeat) if WindowedPhase { tp, ok = windowedTempogramPhase(phaseOnset, phaseOnsetMs, msPerBeat, WindowSec, AmpWeight, ClarityWeight) @@ -601,11 +612,16 @@ func DetectBeatsWithEncoderDelay(samples []float32, sampleRate int, encoderDelay // BPM rounding: snap to the nearest "nice" BPM value if within 0.1 BPM. // This prevents cumulative drift from sub-BPM precision errors. // Applied AFTER phase detection to avoid changing the DP tracker's period. + // Skipped when snap verification ran: it already scored these candidates + // by tempogram coherence, and a fractional tempo it deliberately kept + // must not be blind-rounded here. roundedBPM := bpm - if r := math.Round(bpm); math.Abs(bpm-r) < 0.15 { - roundedBPM = r - } else if r := math.Round(bpm*2) / 2; math.Abs(bpm-r) < 0.05 { - roundedBPM = r + if !(SnapVerify && UseTempogramPhase) { + if r := math.Round(bpm); math.Abs(bpm-r) < 0.15 { + roundedBPM = r + } else if r := math.Round(bpm*2) / 2; math.Abs(bpm-r) < 0.05 { + roundedBPM = r + } } // If BPM was rounded, rebuild the grid with the rounded interval @@ -752,6 +768,48 @@ var ( WindowSec = 4.0 AmpWeight = 3.0 ClarityWeight = 1.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 + // BPM off the true tempo often enough that ~1/3 of a real library came + // out fractional (120.85 for a 121 track), which also computes the grid + // phase at the wrong period: ~0.2 BPM of error drifts the grid a full + // beat over a typical track. The detection is first refined by scanning + // tempogram coherence over ±SnapWindowBPM in SnapScanStep steps, then + // the nearest integer/half-integer to the refined tempo competes with a + // bias (SnapIntBias/SnapHalfBias). The bias is deliberately strong: + // tracks with a mid-track edit or phase jump split their coherence into + // side lobes either side of the true tempo (the integer sits in a local + // dip while a lobe 0.1 off wins the scan), so the integer must lose by + // more than the lobe effect before a fractional result is kept. A + // genuinely fractional tempo collapses the integer's coherence entirely + // (the grid drifts a beat or more across the track) and still wins. + SnapVerify = true + SnapWindowBPM = 0.55 + SnapScanStep = 0.03 + SnapIntBias = 1.5 + SnapHalfBias = 1.4 + + // SnapFracCoherence gates the two decision modes in snapVerifyBPM: at or + // above it the coherence comparison rules and a genuinely fractional + // tempo can keep its refined value; below it the integer prior rules. + // The gate is deliberately near-perfect: a real fractional track (one + // unbroken constant grid) measured 0.98, while phase-jump interference + // lobes reached 0.67-0.7 on tracks that are actually integer — so only + // coherence a lobe cannot fake overrules the prior. SnapNearInt is the + // prior-mode snap radius: within it the nearest integer wins without a + // vote (coherence votes on lobe-noise tracks flip ±1 BPM on a coin + // toss); outside it, in the ambiguous x.4-x.6 band, the flanking + // integers vote and a half-integer needs ≥ SnapWeakHalfMin absolute + // coherence plus double the best integer to take it instead. Before + // any of that, a flanking integer at ≥ SnapStrongInt coherence AND + // triple its rival's overrides proximity outright — the front-end can + // land a full BPM off a clean track's true tempo. + SnapFracCoherence = 0.9 + SnapNearInt = 0.35 + SnapWeakHalfMin = 0.5 + SnapStrongInt = 0.5 ) // rbBandEdgesHz is rekordbox's 25-band filterbank edge table (ascending Hz), @@ -903,6 +961,181 @@ func windowedTempogramPhase(onset []float64, msPerFrame, msPerBeat, winSec, ampW return n0 * msPerFrame, true } +// snapCoherence scores how well a candidate beat period fits the onset +// envelope across the whole track: per-window unit phasors at the period, +// combined with clarity-only weights (|z|/Σe, bounded [0,1]), returning the +// mean resultant length in [0,1]. windowedTempogramPhase's amplitude-cubed +// weighting is deliberately NOT used here: it lets a single loud window (or +// the band-norm EMA warmup spike in the first window) dominate the sum, which +// is what you want for picking the PHASE from the sharpest section but blinds +// the metric to cross-window rotation. With bounded weights the contrast is +// sharp: at the true period the unit phasors agree track-wide and R → 1; a +// 0.2 BPM error rotates them through most of a full turn over a typical +// track and R collapses. Peak width is roughly 60/durSec BPM. +func snapCoherence(onset []float64, msPerFrame, msPerBeat float64) (float64, bool) { + period := msPerBeat / msPerFrame + if period <= 1 || len(onset) < int(period*2) { + return 0, false + } + w := 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 Zr, Zi, wsum 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(w*float64(n)) + zi += e * math.Sin(w*float64(n)) + esum += e + } + mag := math.Hypot(zr, zi) + if mag < 1e-12 || esum < 1e-12 { + continue + } + clar := mag / esum + Zr += clar * zr / mag + Zi += clar * zi / mag + wsum += clar + } + if wsum < 1e-12 { + return 0, false + } + return math.Hypot(Zr, Zi) / wsum, true +} + +// snapVerifyBPM refines the detected tempo by tempogram coherence and snaps +// it to an integer (or half-integer) when that is what the audio supports. +// Dance music is produced at integer BPM almost without exception, and when +// the detector lands 0.1-0.5 off a whole number the wrong period also poisons +// the downstream grid: the tempogram phase is computed at a period that +// drifts against the track, so the beat grid and downbeat land wrong too. +// +// Step 1 refines: the autocorrelation lag grid is coarse (~0.8 BPM per lag at +// 125 BPM) and parabolic interpolation between lags lands up to ~0.5 BPM off, +// so the true tempo may not be near the detection at all. Coherence is sharp +// — the peak width is roughly 60/durSec BPM, ~0.16 for a six-minute track — +// so a fine scan over ±SnapWindowBPM localizes the true period far better +// than the interpolation did. +// +// Step 2 snaps. The coherence landscape of real tracks taught this function +// humility: a phase discontinuity (a breakdown whose beat re-enters off the +// extrapolated grid) makes the track's beat-coherent segments CANCEL exactly +// at the true tempo — a sharp null at the integer with symmetric side lobes — +// and a track with one long clean segment plus a short tail can lobe well +// above 0.5. Coherence comparisons between candidates are therefore noise on +// such tracks, and only near-perfect track-wide coherence (a single unbroken +// grid, ≥ SnapFracCoherence) is allowed to overrule the integer prior: +// +// - refCoh ≥ SnapFracCoherence: biased comparison. Every integer and +// half-integer in the scan range (plus the flanking integers) competes +// against the refined apex with SnapIntBias/SnapHalfBias; a genuinely +// fractional tempo (0.98 observed on a real one) keeps a decisive edge +// and survives with its refined value. +// - otherwise, the integer prior rules. Within SnapNearInt of an integer +// the detection snaps to it outright — no vote, because a coherence vote +// on a lobe-noise track flips ±1 BPM on a coin toss. Only in the +// genuinely ambiguous mid-band (a x.4-x.6 detection) do the flanking +// integers vote by coherence, and a half-integer may take it instead +// only on strong absolute evidence (≥ SnapWeakHalfMin and twice the +// best integer). +func snapVerifyBPM(onset []float64, msPerFrame, bpm float64) float64 { + coh := func(b float64) float64 { + if b <= 0 { + return -1 + } + c, ok := snapCoherence(onset, msPerFrame, 60000.0/b) + if !ok { + return -1 + } + return c + } + refined, refCoh := bpm, coh(bpm) + if refCoh < 0 { + return bpm + } + for d := -SnapWindowBPM; d <= SnapWindowBPM+1e-9; d += SnapScanStep { + if c := coh(bpm + d); c > refCoh { + refined, refCoh = bpm+d, c + } + } + // Parabolic interpolation of the scan apex: when a genuinely fractional + // tempo wins below, its value should be the peak's true position, not + // the nearest SnapScanStep grid point. + if cm, cp := coh(refined-SnapScanStep), coh(refined+SnapScanStep); cm >= 0 && cp >= 0 { + if denom := cm - 2*refCoh + cp; math.Abs(denom) > 1e-12 { + if d := 0.5 * (cm - cp) / denom; math.Abs(d) < 1 { + refined += d * SnapScanStep + } + } + } + if refCoh >= SnapFracCoherence { + // Near-perfect track-wide coherence: the apex is trustworthy, so the + // biased comparison decides. Every integer and half-integer in the + // scan range competes, plus the flanking integers even when the + // detection error pushes them outside the window — if the track + // really is on one of them, its coherence says so regardless of + // where the window sat. + lo, hi := bpm-SnapWindowBPM, bpm+SnapWindowBPM + best, bestScore := math.Round(refined*100)/100, refCoh + try := func(c, bias float64) { + if math.Abs(c-best) < 0.001 { + return + } + if s := coh(c) * bias; s > bestScore { + best, bestScore = c, s + } + } + for v := math.Ceil(lo); v <= hi+1e-9; v++ { + try(v, SnapIntBias) + } + try(math.Floor(bpm), SnapIntBias) + try(math.Ceil(bpm), SnapIntBias) + for v := math.Ceil(lo*2) / 2; v <= hi+1e-9; v += 0.5 { + if v != math.Trunc(v) { // integers already competed at the higher bias + try(v, SnapHalfBias) + } + } + return best + } + // Integer prior. The front-end can land up to a full BPM off the true + // tempo, so a flanking integer with decisive absolute coherence + // overrides plain proximity first (a clean true-132 track detected at + // 131.35 must not snap to 131). Lobe noise cannot fake this: observed + // integer coherence on phase-jump tracks tops out around 0.4. + fl, ce := math.Floor(bpm), math.Ceil(bpm) + cf, cc := coh(fl), coh(ce) + if cf >= SnapStrongInt && cf >= 3*cc { + return fl + } + if cc >= SnapStrongInt && cc >= 3*cf { + return ce + } + // Near an integer: snap to it, full stop. + if nearest := math.Round(bpm); math.Abs(bpm-nearest) <= SnapNearInt { + return nearest + } + // Ambiguous mid-band: the flanking integers vote by coherence. + bestInt, bestIntCoh := fl, cf + if cc > bestIntCoh { + bestInt, bestIntCoh = ce, cc + } + // A x.5 tempo is rare but real; overriding the prior needs strong + // absolute evidence, not a win over lobe noise. + if half := math.Floor(bpm) + 0.5; half > math.Floor(bpm) && half < math.Ceil(bpm) { + if c := coh(half); c >= SnapWeakHalfMin && c >= 2*bestIntCoh { + return half + } + } + return bestInt +} + // tempogramPhase returns the first-beat sub-beat offset (ms, in [0,msPerBeat)) // as the argument of the onset envelope's complex DFT bin at the beat period. // Modeling the envelope's beat component as A·cos(ω·n − ψ), the first beat sits diff --git a/analysis/bpm_snap_test.go b/analysis/bpm_snap_test.go new file mode 100644 index 0000000..f4ec1d2 --- /dev/null +++ b/analysis/bpm_snap_test.go @@ -0,0 +1,79 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package analysis + +import ( + "math" + "testing" +) + +// synthKickTrain renders a four-on-the-floor kick pattern at exactly the given +// BPM: a decaying 60Hz burst on every beat over durSec seconds. Deterministic +// (no RNG), long enough that a 0.2 BPM period error accumulates a visible +// phase drift for the tempogram coherence to catch. +func synthKickTrain(bpm, durSec float64) []float32 { + n := int(float64(AnalysisRate) * durSec) + out := make([]float32, n) + periodSamples := 60.0 / bpm * float64(AnalysisRate) + burstLen := int(0.08 * float64(AnalysisRate)) // 80ms kick + twoPiF := 2.0 * math.Pi * 60.0 / float64(AnalysisRate) + for beat := 0; ; beat++ { + start := int(float64(beat) * periodSamples) + if start >= n { + break + } + for i := 0; i < burstLen && start+i < n; i++ { + env := math.Exp(-float64(i) / (0.02 * float64(AnalysisRate))) // 20ms decay + out[start+i] += float32(0.8 * env * math.Sin(twoPiF*float64(i))) + } + } + return out +} + +// TestSnapVerify_IntegerTempo: a track at exactly 121 BPM must come out 121.00 +// even when the autocorrelation peak interpolation lands a fraction off — the +// coherence comparison against the integer candidate corrects it. +func TestSnapVerify_IntegerTempo(t *testing.T) { + for _, bpm := range []float64{121, 124, 140} { + samples := synthKickTrain(bpm, 150) + res := DetectBeatsWithEncoderDelay(samples, AnalysisRate, 0) + if res.BPM != bpm { + t.Errorf("synth %.0f BPM: detected %.2f, want %.2f exactly", bpm, res.BPM, bpm) + } + } +} + +// TestSnapVerify_FractionalTempoSurvives: a track genuinely at a fractional +// tempo inside the snap window must NOT be dragged to the integer — its true +// period has the decisive coherence advantage. +func TestSnapVerify_FractionalTempoSurvives(t *testing.T) { + const want = 122.30 // 0.30 from 122, inside SnapWindowBPM + samples := synthKickTrain(want, 150) + res := DetectBeatsWithEncoderDelay(samples, AnalysisRate, 0) + if math.Abs(res.BPM-want) > 0.1 { + t.Errorf("synth %.2f BPM: detected %.2f, want within 0.1 (not snapped to %g)", + want, res.BPM, math.Round(want)) + } + if res.BPM == math.Round(want) { + t.Errorf("genuinely fractional tempo was snapped to the integer %.0f", res.BPM) + } +} + +// TestSnapVerify_GridUsesSnappedPeriod: after snapping, the emitted beat grid +// must run at the snapped period (the phase pipeline consumed the corrected +// tempo), not the fractional one patched afterwards. +func TestSnapVerify_GridUsesSnappedPeriod(t *testing.T) { + samples := synthKickTrain(121, 150) + res := DetectBeatsWithEncoderDelay(samples, AnalysisRate, 0) + if res.BPM != 121 { + t.Fatalf("detected %.2f, want 121", res.BPM) + } + if len(res.Beats) < 10 { + t.Fatalf("too few beats: %d", len(res.Beats)) + } + wantInterval := 60000.0 / 121 + got := (res.Beats[len(res.Beats)-1] - res.Beats[0]) / float64(len(res.Beats)-1) + if math.Abs(got-wantInterval) > 0.05 { + t.Errorf("mean beat interval %.3fms, want %.3fms (grid not on snapped period)", got, wantInterval) + } +} diff --git a/analysis/snapdiag_test.go b/analysis/snapdiag_test.go new file mode 100644 index 0000000..c7d8783 --- /dev/null +++ b/analysis/snapdiag_test.go @@ -0,0 +1,76 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package analysis + +import ( + "encoding/json" + "fmt" + "math" + "os" + "strings" + "testing" +) + +// TestSnapDiag prints the snap-verification coherence profile around the +// stored tempo for chosen library tracks — the curve snapVerifyBPM decides +// on. Clean constant-tempo tracks show one sharp peak; phase-discontinuous +// tracks show a null at the true tempo with symmetric side lobes (the +// segments cancel exactly there), which is what SnapMinCoherence gates on. +// +// Env-gated diagnostic, not a correctness test: +// +// VYNULL_SNAP_DIAG_LIB=/path/library.json VYNULL_SNAP_DIAG_IDS="49,167" \ +// go test ./analysis/ -run SnapDiag -v +func TestSnapDiag(t *testing.T) { + lib := os.Getenv("VYNULL_SNAP_DIAG_LIB") + if lib == "" { + t.Skip("set VYNULL_SNAP_DIAG_LIB to a library.json to run") + } + var ids []int + if err := json.Unmarshal([]byte("["+os.Getenv("VYNULL_SNAP_DIAG_IDS")+"]"), &ids); err != nil || len(ids) == 0 { + t.Fatal("set VYNULL_SNAP_DIAG_IDS to a comma-separated track ID list") + } + raw, err := os.ReadFile(lib) + if err != nil { + t.Fatal(err) + } + var tracks []struct { + ID int `json:"id"` + Title string `json:"title"` + BPM float64 `json:"bpm"` + FilePath string `json:"file_path"` + } + if err := json.Unmarshal(raw, &tracks); err != nil { + t.Fatal(err) + } + want := make(map[int]bool, len(ids)) + for _, id := range ids { + want[id] = true + } + for _, tr := range tracks { + if !want[tr.ID] { + continue + } + samples, err := DecodePCM(tr.FilePath, AnalysisRate) + if err != nil { + t.Errorf("id %d: %v", tr.ID, err) + continue + } + mb, mbMs := multiBandOnset(samples, AnalysisRate) + if mb == nil { + t.Errorf("id %d: track too short for multiband onset", tr.ID) + continue + } + center := math.Round(tr.BPM) + fmt.Printf("\nid=%d %.35q stored=%.2f (curve around %.0f)\n", tr.ID, tr.Title, tr.BPM, center) + for d := -1.5; d <= 1.51; d += 0.1 { + b := center + d + c, ok := snapCoherence(mb, mbMs, 60000.0/b) + mark := " " + if math.Abs(d) < 0.001 { + mark = "*" + } + fmt.Printf(" %7.2f %s coh=%.3f ok=%v %s\n", b, mark, c, ok, strings.Repeat("#", int(c*60))) + } + } +} diff --git a/api/analysis_writeback_test.go b/api/analysis_writeback_test.go new file mode 100644 index 0000000..a8327e8 --- /dev/null +++ b/api/analysis_writeback_test.go @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package api + +import ( + "testing" + + "github.com/vynulldev/vynull/analysis" + "github.com/vynulldev/vynull/library" +) + +// Covers the BPM re-analysis writeback in applyAnalysisToTrack: a row whose +// BPM came from a previous analysis follows the new result (the served beat +// grid always comes from the analysis, so the row must not diverge), while a +// user override (marked by the DetectedBPM snapshot) is never clobbered. +func TestApplyAnalysisBPMWriteback(t *testing.T) { + lib := library.New() + lib.AddTrack(&library.Track{ID: 1, Title: "Stale", FilePath: "/m/a.mp3", BPM: 120.85}) + lib.AddTrack(&library.Track{ID: 2, Title: "Overridden", FilePath: "/m/b.mp3", BPM: 122, DetectedBPM: 121}) + lib.AddTrack(&library.Track{ID: 3, Title: "Empty", FilePath: "/m/c.mp3"}) + srv := &Server{Library: lib} + + // Re-analysis produced the snapped tempo: a non-overridden row follows it. + srv.applyAnalysisToTrack(1, &analysis.Result{BPM: 121}) + if got := lib.Track(1).BPM; got != 121 { + t.Errorf("stale row BPM = %v, want 121 (re-analysis writeback)", got) + } + + // A user override survives re-analysis untouched. + srv.applyAnalysisToTrack(2, &analysis.Result{BPM: 121}) + if got := lib.Track(2).BPM; got != 122 { + t.Errorf("overridden row BPM = %v, want 122 (user override kept)", got) + } + + // The original fill-when-empty behaviour still works. + srv.applyAnalysisToTrack(3, &analysis.Result{BPM: 124}) + if got := lib.Track(3).BPM; got != 124 { + t.Errorf("empty row BPM = %v, want 124 (fill)", got) + } + + // Identical result is a no-op (no spurious library save). + srv.applyAnalysisToTrack(1, &analysis.Result{BPM: 121}) + if got := lib.Track(1).BPM; got != 121 { + t.Errorf("row BPM = %v after identical result, want 121", got) + } +} diff --git a/api/api.go b/api/api.go index 49a0234..07f4aab 100644 --- a/api/api.go +++ b/api/api.go @@ -1996,10 +1996,14 @@ func (s *Server) analysisWorker() { } // applyAnalysisToTrack fills a library row's analysis-derived fields (BPM, -// duration, key, bitrate, artwork) from a result. Only empty fields are -// filled — user edits and existing values are never clobbered — and the -// library is saved only when something actually changed, so callers can -// invoke it on every analysis retrieval and it noops once the row is filled. +// duration, key, bitrate, artwork) from a result. Fields are filled when +// empty — user edits are never clobbered — and the library is saved only +// when something actually changed, so callers can invoke it on every +// analysis retrieval and it noops once the row is settled. BPM is the one +// field that also UPDATES on re-analysis (unless user-overridden): the beat +// grid served to decks always comes from the analysis result, so the row +// must follow it or the UI would display one tempo and the deck play +// another. // // This is the single writeback point for every api route that lands an // analysis result: the worker queue, the on-demand path, and a disk-cache @@ -2020,6 +2024,15 @@ func (s *Server) applyAnalysisToTrack(trackID uint32, r *analysis.Result) { if t.BPM == 0 && r.BPM > 0 { t.BPM = r.BPM changed = true + } else if t.DetectedBPM == 0 && r.BPM > 0 && t.BPM != r.BPM { + // Re-analysis produced a different tempo (e.g. a cacheVersion bump + // like the v27 integer-snap upgrade): adopt it unless the user + // overrode BPM (DetectedBPM != 0 marks an override snapshot). The + // played beat grid always comes from the analysis result, so + // leaving the row at the old value would display one BPM and play + // another. + t.BPM = r.BPM + changed = true } if t.Duration == 0 && r.Duration > 0 { t.Duration = library.DurationSec(time.Duration(r.Duration) * time.Second)