Skip to content

Commit 2ff8922

Browse files
committed
Match librosa beat tracking semantics
1 parent e58d447 commit 2ff8922

3 files changed

Lines changed: 133 additions & 55 deletions

File tree

src/beat.cpp

Lines changed: 104 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -268,20 +268,27 @@ namespace {
268268

269269
// Normalize onsets by standard deviation
270270
ArrayXr normalize_onsets(const ArrayXr& onsets) {
271-
Real mean = onsets.mean();
272-
Real std = std::sqrt((onsets - mean).square().mean());
273-
if (std < util::tiny<Real>()) {
274-
return onsets;
271+
if (onsets.size() <= 1) {
272+
return onsets / util::tiny<Real>();
275273
}
276-
return onsets / std;
274+
275+
Real mean = onsets.mean();
276+
Real variance = (onsets - mean).square().sum() /
277+
static_cast<Real>(onsets.size() - 1);
278+
Real std = std::sqrt(variance);
279+
return onsets / (std + util::tiny<Real>());
280+
}
281+
282+
int round_to_nearest_even(Real value) {
283+
return static_cast<int>(std::nearbyint(value));
277284
}
278285

279286
// Compute local score with Gaussian weighting
280287
ArrayXr beat_local_score(const ArrayXr& onset_envelope, Real frames_per_beat) {
281288
Eigen::Index N = onset_envelope.size();
282289
ArrayXr localscore(N);
283290

284-
int fpb = static_cast<int>(std::round(frames_per_beat));
291+
int fpb = round_to_nearest_even(frames_per_beat);
285292
int window_size = 2 * fpb + 1;
286293

287294
// Create Gaussian window
@@ -294,11 +301,15 @@ ArrayXr beat_local_score(const ArrayXr& onset_envelope, Real frames_per_beat) {
294301
// Same-mode convolution
295302
for (Eigen::Index i = 0; i < N; ++i) {
296303
Real sum = 0.0;
297-
for (int k = 0; k < window_size; ++k) {
304+
Eigen::Index half_window = window_size / 2;
305+
Eigen::Index k_start = std::max<Eigen::Index>(
306+
0, i + half_window - N + 1);
307+
Eigen::Index k_stop = std::min<Eigen::Index>(
308+
i + half_window + 1, window_size);
309+
310+
for (Eigen::Index k = k_start; k < k_stop; ++k) {
298311
Eigen::Index j = i + window_size / 2 - k;
299-
if (j >= 0 && j < N) {
300-
sum += window(k) * onset_envelope(j);
301-
}
312+
sum += window(k) * onset_envelope(j);
302313
}
303314
localscore(i) = sum;
304315
}
@@ -320,19 +331,21 @@ std::pair<std::vector<int>, ArrayXr> beat_track_dp(
320331
Real score_thresh = 0.01 * localscore.maxCoeff();
321332
bool first_beat = true;
322333

334+
backlink[0] = -1;
323335
cumscore(0) = localscore(0);
324336

325-
int fpb = static_cast<int>(std::round(frames_per_beat));
337+
int fpb = round_to_nearest_even(frames_per_beat);
338+
int first_lag = round_to_nearest_even(static_cast<Real>(fpb) / 2.0);
326339

327-
for (Eigen::Index i = 1; i < N; ++i) {
340+
for (Eigen::Index i = 0; i < N; ++i) {
328341
Real best_score = -std::numeric_limits<Real>::infinity();
329342
int beat_location = -1;
330343

331344
// Search over possible predecessors
332-
Eigen::Index search_start = std::max(Eigen::Index(0), i - 2 * fpb);
333-
Eigen::Index search_end = std::max(Eigen::Index(0), i - fpb / 2);
334-
335-
for (Eigen::Index loc = search_start; loc < search_end; ++loc) {
345+
for (Eigen::Index loc = i - first_lag; loc >= i - 2 * fpb; --loc) {
346+
if (loc < 0) {
347+
break;
348+
}
336349
Real penalty = std::log(static_cast<Real>(i - loc)) - std::log(frames_per_beat);
337350
Real score = cumscore(loc) - tightness * penalty * penalty;
338351
if (score > best_score) {
@@ -358,25 +371,52 @@ std::pair<std::vector<int>, ArrayXr> beat_track_dp(
358371
return {backlink, cumscore};
359372
}
360373

361-
// Backtrack from the best ending point
362-
std::vector<bool> dp_backtrack(const std::vector<int>& backlink, const ArrayXr& cumscore) {
374+
Real median(std::vector<Real> values) {
375+
if (values.empty()) {
376+
return 0.0;
377+
}
378+
379+
std::sort(values.begin(), values.end());
380+
size_t mid = values.size() / 2;
381+
if (values.size() % 2 == 1) {
382+
return values[mid];
383+
}
384+
385+
return 0.5 * (values[mid - 1] + values[mid]);
386+
}
387+
388+
Eigen::Index last_beat(const ArrayXr& cumscore) {
363389
Eigen::Index N = cumscore.size();
364-
std::vector<bool> beats(N, false);
390+
auto localmax = util::localmax(cumscore);
391+
392+
std::vector<Real> local_scores;
393+
local_scores.reserve(static_cast<size_t>(N));
394+
for (Eigen::Index i = 0; i < N; ++i) {
395+
if (localmax(i)) {
396+
local_scores.push_back(cumscore(i));
397+
}
398+
}
365399

366-
// Find the last beat (max cumscore in the last portion)
367-
Eigen::Index search_start = std::max(Eigen::Index(0), N - N / 4);
368-
Eigen::Index tail = search_start;
369-
Real max_score = cumscore(search_start);
400+
Real threshold = 0.5 * median(local_scores);
370401

371-
for (Eigen::Index i = search_start + 1; i < N; ++i) {
372-
if (cumscore(i) > max_score) {
373-
max_score = cumscore(i);
402+
Eigen::Index tail = N - 1;
403+
for (Eigen::Index i = N - 1; i >= 0; --i) {
404+
if (localmax(i) && cumscore(i) >= threshold) {
374405
tail = i;
406+
break;
375407
}
376408
}
377409

410+
return tail;
411+
}
412+
413+
// Backtrack from the best ending point
414+
std::vector<bool> dp_backtrack(const std::vector<int>& backlink, const ArrayXr& cumscore) {
415+
Eigen::Index N = cumscore.size();
416+
std::vector<bool> beats(N, false);
417+
378418
// Backtrack
379-
Eigen::Index idx = tail;
419+
Eigen::Index idx = last_beat(cumscore);
380420
while (idx >= 0) {
381421
beats[idx] = true;
382422
idx = backlink[idx];
@@ -393,7 +433,7 @@ std::vector<bool> trim_beats(const ArrayXr& localscore, const std::vector<bool>&
393433
return trimmed;
394434
}
395435

396-
// Compute threshold based on beat onsets
436+
// Compute the smoothed beat-onset envelope threshold.
397437
std::vector<Real> beat_scores;
398438
for (size_t i = 0; i < beats.size(); ++i) {
399439
if (beats[i]) {
@@ -405,34 +445,43 @@ std::vector<bool> trim_beats(const ArrayXr& localscore, const std::vector<bool>&
405445
return trimmed;
406446
}
407447

408-
// RMS of beat scores
409-
Real rms = 0;
410-
for (Real s : beat_scores) {
411-
rms += s * s;
448+
std::vector<Real> window = {0.0, 0.5, 1.0, 0.5, 0.0};
449+
std::vector<Real> smooth_boe(beat_scores.size() + window.size() - 1, 0.0);
450+
for (size_t i = 0; i < beat_scores.size(); ++i) {
451+
for (size_t j = 0; j < window.size(); ++j) {
452+
smooth_boe[i + j] += beat_scores[i] * window[j];
453+
}
412454
}
413-
rms = std::sqrt(rms / beat_scores.size());
414-
Real threshold = 0.5 * rms;
415455

416-
// Trim leading weak beats
417-
for (size_t i = 0; i < trimmed.size(); ++i) {
418-
if (trimmed[i]) {
419-
if (localscore(i) <= threshold) {
420-
trimmed[i] = false;
421-
} else {
422-
break;
423-
}
424-
}
456+
size_t start = window.size() / 2;
457+
size_t stop = std::min(
458+
smooth_boe.size(),
459+
static_cast<size_t>(localscore.size()) + window.size() / 2);
460+
461+
Real mean_square = 0.0;
462+
size_t smooth_count = 0;
463+
for (size_t i = start; i < stop; ++i) {
464+
mean_square += smooth_boe[i] * smooth_boe[i];
465+
++smooth_count;
425466
}
426467

427-
// Trim trailing weak beats
428-
for (int i = static_cast<int>(trimmed.size()) - 1; i >= 0; --i) {
429-
if (trimmed[i]) {
430-
if (localscore(i) <= threshold) {
431-
trimmed[i] = false;
432-
} else {
433-
break;
434-
}
435-
}
468+
Real threshold = 0.0;
469+
if (trim && smooth_count > 0) {
470+
threshold = 0.5 * std::sqrt(mean_square / static_cast<Real>(smooth_count));
471+
}
472+
473+
// Match librosa.beat.__trim_beats: the threshold is computed from selected
474+
// beat scores, but edge suppression scans frame-local scores.
475+
Eigen::Index n = 0;
476+
while (n < localscore.size() && localscore(n) <= threshold) {
477+
trimmed[static_cast<size_t>(n)] = false;
478+
++n;
479+
}
480+
481+
n = localscore.size() - 1;
482+
while (n >= 0 && localscore(n) <= threshold) {
483+
trimmed[static_cast<size_t>(n)] = false;
484+
--n;
436485
}
437486

438487
return trimmed;
@@ -469,7 +518,7 @@ std::pair<Real, std::vector<Eigen::Index>> beat_track(
469518

470519
// Convert BPM to frames per beat
471520
Real frame_rate = sr / hop_length;
472-
Real frames_per_beat = std::round(frame_rate * 60.0 / bpm_val);
521+
Real frames_per_beat = std::nearbyint(frame_rate * 60.0 / bpm_val);
473522

474523
// Normalize onsets
475524
ArrayXr normalized = normalize_onsets(onset_envelope);
@@ -519,7 +568,8 @@ std::pair<Real, std::vector<Eigen::Index>> beat_track_audio(
519568
BeatUnits units) {
520569

521570
// Compute onset envelope
522-
ArrayXr envelope = onset::onset_strength(y, sr, 2048, hop_length);
571+
ArrayXr envelope = onset::onset_strength(y, sr, 2048, hop_length, 1, 1,
572+
false, true, AggregateFunc::Median);
523573

524574
return beat_track(envelope, sr, hop_length, start_bpm, tightness, trim, bpm_opt, units);
525575
}

tests/crossval/test_crossval.cpp

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -277,6 +277,18 @@ class CrossValidationTest : public ::testing::Test {
277277
EXPECT_GT(match_ratio, 0.5) << msg << " too few events matched";
278278
}
279279

280+
void expectEventsEqual(const std::vector<Eigen::Index>& actual,
281+
const ArrayXr& expected,
282+
const std::string& msg = "") {
283+
ASSERT_EQ(actual.size(), static_cast<size_t>(expected.size()))
284+
<< msg << " event count mismatch";
285+
for (Eigen::Index i = 0; i < expected.size(); ++i) {
286+
EXPECT_EQ(actual[static_cast<size_t>(i)],
287+
static_cast<Eigen::Index>(std::llround(expected(i))))
288+
<< msg << " at event " << i;
289+
}
290+
}
291+
280292
// Check reconstruction quality: ||W*H - S|| / ||S|| < tol
281293
void expectReconstructionQuality(const ArrayXXr& W, const ArrayXXr& H,
282294
const ArrayXXr& S, double tol = 0.1,
@@ -936,7 +948,7 @@ TEST_F(CrossValidationTest, BeatTrackFrames) {
936948
auto [bpm, beats_cpp] = beat::beat_track(onset_env, 22050);
937949
ArrayXr beats_py = beats_ref.toArrayXr();
938950

939-
expectEventsNear(beats_cpp, beats_py, 3, 0.5, "beat_track");
951+
expectEventsEqual(beats_cpp, beats_py, "beat_track");
940952
}
941953

942954
TEST_F(CrossValidationTest, BeatPLP) {

tests/test_beat.cpp

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,22 @@ TEST(BeatTrackTest, WithFixedTempo) {
175175
EXPECT_GT(beats.size(), 0);
176176
}
177177

178+
TEST(BeatTrackTest, IncludesFirstFrameConvolutionTap) {
179+
int n_frames = 50;
180+
ArrayXr onset_envelope = ArrayXr::Zero(n_frames);
181+
182+
for (int i = 0; i < n_frames; i += 8) {
183+
onset_envelope(i) = 1.0;
184+
}
185+
186+
auto [bpm, beats] = beat_track(onset_envelope, 24000, 500,
187+
120.0, 100.0, true, 120.0);
188+
189+
EXPECT_EQ(bpm, 120.0);
190+
std::vector<Eigen::Index> expected = {0, 24, 48};
191+
EXPECT_EQ(beats, expected);
192+
}
193+
178194
TEST(BeatTrackTest, EmptySignal) {
179195
ArrayXr onset_envelope = ArrayXr::Zero(100);
180196

0 commit comments

Comments
 (0)