Skip to content

Commit fa9d88b

Browse files
committed
Take the beat the operator pointed at, not the one after it
The user, on the built app: "I click the down beat button at the very beginning of the bar/measure ... the dot is always one off ... it's always one too slow." It was. The snap set a flag that `advanceBar` spent on the *next* beat called, so a press landed a whole beat late for the only way the control is ever used — a person presses it *on* the downbeat they can hear, by which time the tracker has already called that beat, and the bar came out anchored to beat 2. HANDOFF §5.5 asks for a control that "snaps bar phase immediately", and a beat late is not immediately. The snap now takes the *nearest* beat: the one just called if it is less than half a beat period behind, and the one still to come otherwise. The split is symmetric and has no constant in it, deliberately — the pipeline's delay and a person's reaction time both move where inside that window a press falls, and neither has been measured. The period's own middle is the only place the line can go without inventing a number. That period is the filter's, never the published tempo, because ÷2 and ×2 move the second and not the first. Naming the beat just gone takes effect at once: `beatInBar` is 1 before `snapDownbeat()` returns, so the dots move under the operator's finger rather than a beat later. What cannot move is that beat's outgoing event, which has already been sent — so the correction reaches the transports on the next beat, which now carries `BeatEvent::snapped`. Link is unharmed by that: `forceBeat` is given the bar *position*, and forcing beat 2 of the bar moves a peer's phase exactly as far as forcing beat 1 would. This is the cause 8e31666 recorded as deliberate and left alone. That session was right that the button said nothing and fixed the silence; it took "lands on the next beat" as design rather than asking whether the next beat was the right one. Nothing on the tracking path changed, and that is measured rather than asserted: `--trace` over "01 - Pirates" and "1 - Jungle - GOOD TIMES" is byte-identical before and after, 19,698 frames of every published column. 219 local-core, 250 local, 219 Debug with the RT allocation guard live, clang-tidy and clang-format clean.
1 parent 10aa585 commit fa9d88b

11 files changed

Lines changed: 341 additions & 79 deletions

File tree

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,7 @@ there is an interface. None of them stops the tracker or reseeds the filter.
138138
| Key | |
139139
|---|---|
140140
| `space` | tap the tempo. Three taps are enough; the fold window moves onto what you tapped, so the readout and the setting agree on why the tempo is what it is. |
141-
| `d` | downbeat now — the next beat starts the bar, and goes on starting it. With `--link`, that beat is placed with `forceBeatAtTime`, so peers move too. |
141+
| `d` | downbeat now — the beat nearest the press starts the bar, and goes on starting it. Press it *on* the downbeat you can hear: the beat just gone is the one it takes. With `--link`, the new phase is placed with `forceBeatAtTime`, so peers move too. |
142142
| `h` / `x` | halve and double the published tempo, keeping the lock. |
143143
| `[` / `]` | move the latency offset by 5 ms, on the beat timestamps and the transports alike. |
144144
| `f` | turn the octave fold off and on. |

‎src/cli/main.cpp‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,7 @@ void printUsage(std::ostream& out) {
134134
<< " substring of its name, or its index)\n"
135135
<< " The outputs need a live input; over a file only the beats are printed.\n"
136136
<< " On a live input the manual controls are on the keyboard: space taps the\n"
137-
<< " tempo, d snaps the downbeat to the next beat, h and x halve and double\n"
137+
<< " tempo, d snaps the bar to the nearest beat, h and x halve and double\n"
138138
<< " it, l pins the lock so a breakdown cannot drop it, [ and ] move the\n"
139139
<< " latency offset, f turns the octave fold off and on, q stops. These need\n"
140140
<< " a console: MinTTY, which Git Bash uses, is not one, and the banner says\n"
@@ -1206,7 +1206,7 @@ int runTrackDevice(const TrackArgs& args, const takt4::model::ModelWeights& weig
12061206
}
12071207
case 'd':
12081208
(void)engine->post(Command::snapDownbeat());
1209-
std::cout << " downbeat: the next beat starts the bar\n";
1209+
std::cout << " downbeat: the nearest beat starts the bar\n";
12101210
break;
12111211
case 'h':
12121212
(void)engine->post(Command::halve());

‎src/core/engine/control.hpp‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,9 @@ struct Command {
2828
SetTempoOptions,
2929
Halve, ///< §5.5's ÷2.
3030
Redouble, ///< §5.5's ×2.
31-
/// §5.5's manual downbeat: the next beat called starts the bar. The one command
32-
/// the handoff calls non-negotiable, and the one an operator reaches for first.
31+
/// §5.5's manual downbeat: the beat nearest the press starts the bar. The one
32+
/// command the handoff calls non-negotiable, and the one an operator reaches for
33+
/// first.
3334
SnapDownbeat,
3435
/// §5.5's tap tempo, already measured: `tracking::TapTempo` counts the taps on
3536
/// whichever thread they arrive on, and only the tempo it worked out travels here.

‎src/core/tracking/tempo_tracker.cpp‎

Lines changed: 61 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -103,8 +103,12 @@ void TempoTracker::reset() noexcept {
103103
filterBeatInBar_ = 0;
104104
barOffset_ = 0;
105105
snapPending_ = false;
106+
snapUnsent_ = false;
106107
snapAwaitingFilter_ = false;
107108
sinceSnap_ = 0;
109+
framesSinceBeat_ = 0;
110+
beatsSeen_ = 0;
111+
filterIntervalFrames_ = 0;
108112
beatFrames_.clear();
109113
}
110114

@@ -295,8 +299,49 @@ void TempoTracker::seedTempo(double bpm) noexcept {
295299
}
296300
}
297301

302+
bool TempoTracker::lastBeatIsNearer() const noexcept {
303+
if (beatsSeen_ == 0 || filterIntervalFrames_ == 0) {
304+
return false; // no beat behind, or no idea when the next one is due
305+
}
306+
// Nearer than the next beat, whose best estimate is one period after the last one. The
307+
// comparison is doubled rather than halved so it stays in whole frames.
308+
return 2 * framesSinceBeat_ < filterIntervalFrames_;
309+
}
310+
298311
void TempoTracker::snapDownbeat() noexcept {
299-
snapPending_ = true;
312+
// Either way a correction is now owed to the outputs, and the next beat carries it.
313+
snapUnsent_ = true;
314+
if (!lastBeatIsNearer()) {
315+
snapPending_ = true; // the beat being pointed at has not happened yet
316+
return;
317+
}
318+
// The operator is pointing at the beat just called, so the bar starts there and this
319+
// beat's successor is beat 2 — not beat 1, which is what made every snap read a beat
320+
// late. Nothing is left pending: the rotation is known now and takes effect now.
321+
snapPending_ = false;
322+
const bool wasBarStart = state_.beatInBar == 1;
323+
startBarHere();
324+
state_.beatInBar = 1;
325+
if (!wasBarStart) {
326+
// A bar the operator has just declared, which the count above did not make. Had it
327+
// already been beat 1 the bar was counted when it was called, and counting it twice
328+
// would make a snap that changed nothing move the bar number.
329+
++state_.bars;
330+
}
331+
}
332+
333+
void TempoTracker::startBarHere() noexcept {
334+
const std::uint32_t meter = state_.beatsPerBar;
335+
snapAwaitingFilter_ = !(filterBeatInBar_ != 0 && meter > 0);
336+
if (snapAwaitingFilter_) {
337+
// Nothing to rotate yet. Count from here, and turn the count into an offset the
338+
// moment the filter first has an opinion.
339+
sinceSnap_ = 0;
340+
return;
341+
}
342+
// Rotate so this beat comes out as 1. Reducing the position first keeps this right
343+
// through a meter change that leaves the filter past the end of a bar.
344+
barOffset_ = (meter - (filterBeatInBar_ - 1) % meter) % meter;
300345
}
301346

302347
void TempoTracker::setLockPinned(bool pinned) noexcept {
@@ -344,17 +389,11 @@ void TempoTracker::advanceBar(bool filterCalledDownbeat) noexcept {
344389
const bool haveFilterBar = filterBeatInBar_ != 0;
345390

346391
if (snapPending_) {
392+
// A snap that named a beat still to come: this is it, so the bar starts here. The
393+
// one that named the beat just gone rotated in snapDownbeat() and left nothing
394+
// pending, which is why it falls through to the ordinary count below.
347395
snapPending_ = false;
348-
snapAwaitingFilter_ = !(haveFilterBar && meter > 0);
349-
if (snapAwaitingFilter_) {
350-
// Nothing to rotate yet. Count from here, and turn the count into an offset
351-
// the moment the filter first has an opinion.
352-
sinceSnap_ = 0;
353-
} else {
354-
// Rotate so this beat comes out as 1. Reducing the position first keeps this
355-
// right through a meter change that leaves the filter past the end of a bar.
356-
barOffset_ = (meter - (filterBeatInBar_ - 1) % meter) % meter;
357-
}
396+
startBarHere();
358397
} else if (snapAwaitingFilter_ && meter > 0) {
359398
sinceSnap_ = (sinceSnap_ + 1) % meter;
360399
if (haveFilterBar) {
@@ -479,6 +518,13 @@ std::optional<BeatEvent> TempoTracker::process(const TrackedFrame& frame) noexce
479518
state_.confidence = smoothedConfidence_;
480519
state_.rawBpm = frame.bpm;
481520
state_.beatsPerBar = frame.beatsPerBar;
521+
// Where in the beat we are, for a snap arriving between two frames to read. Kept even
522+
// when the interval is zero, because it is the last one known that says how long a beat
523+
// lasts, not this frame's absence of one.
524+
++framesSinceBeat_;
525+
if (frame.intervalFrames > 0) {
526+
filterIntervalFrames_ = frame.intervalFrames;
527+
}
482528

483529
// The lock is decided on the state space's own whole-frame interval, never on the
484530
// refined tempo. The refinement moves by tenths of a BPM from one frame to the next,
@@ -513,6 +559,8 @@ std::optional<BeatEvent> TempoTracker::process(const TrackedFrame& frame) noexce
513559
const bool emitted = frame.emitted != TrackedFrame::Emitted::None;
514560
if (emitted) {
515561
rememberBeat(frame.frameIndex);
562+
framesSinceBeat_ = 0;
563+
++beatsSeen_;
516564
}
517565

518566
// Holding below the confidence gate keeps whatever was last published (§5.5: "hold the
@@ -567,7 +615,8 @@ std::optional<BeatEvent> TempoTracker::process(const TrackedFrame& frame) noexce
567615
return std::nullopt;
568616
}
569617

570-
const bool snapped = snapPending_;
618+
const bool snapped = snapUnsent_;
619+
snapUnsent_ = false;
571620
advanceBar(frame.emitted == TrackedFrame::Emitted::Downbeat);
572621
++state_.beats;
573622

‎src/core/tracking/tempo_tracker.hpp‎

Lines changed: 55 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,9 +39,16 @@ struct BeatEvent {
3939
double bpm = 0.0;
4040
bool locked = false;
4141
double confidence = 0.0;
42-
/// True on the one beat an operator's downbeat snap landed on. §5.6 reserves Link's
42+
/// True on the first beat published under a snap's new bar phase. §5.6 reserves Link's
4343
/// `forceBeatAtTime` for exactly this beat — a `requestBeatAtTime` would be moved to
4444
/// where the session's phase already matches, which is the phase being corrected.
45+
///
46+
/// That is the beat the operator pointed at only when they pointed at one still to
47+
/// come. A snap naming the beat just gone (see `snapDownbeat`) cannot mark it, because
48+
/// it has already been sent, so the flag rides the next beat instead — which carries
49+
/// the identical correction, since `LinkSession::forceBeat` is given the *bar
50+
/// position*, and forcing beat 2 of the bar moves a peer's phase exactly as far as
51+
/// forcing beat 1 would.
4552
bool snapped = false;
4653
};
4754

@@ -286,8 +293,33 @@ class TempoTracker {
286293
/// window by hand — a tap confirming what is already tracked must not cost sync.
287294
void seedTempo(double bpm) noexcept;
288295

289-
/// §5.5's manual downbeat: the next beat called starts the bar, and every bar after
290-
/// it is counted from there.
296+
/// §5.5's manual downbeat: the beat the operator is pointing at starts the bar, and
297+
/// every bar after it is counted from there.
298+
///
299+
/// **Which beat that is, is the whole difficulty.** This used to be "the next beat
300+
/// called", and that is a beat late for the only way the control is ever used: a
301+
/// person presses the button *on* the downbeat they can hear, by which time the
302+
/// tracker has already called it, so the bar came out anchored to beat 2. The user's
303+
/// report, 2026-09-05: "if I press the downbeat, it should start at the very beginning
304+
/// of the four dots, but it's always one too slow." §5.5 wants a control that "snaps
305+
/// bar phase immediately", and a beat late is not immediately.
306+
///
307+
/// So it takes the **nearest** beat: the one just called if it is less than half a beat
308+
/// period behind, and the one still to come otherwise. Nothing is assumed about which
309+
/// side of the beat a press lands on, and nothing needs to be — the split is
310+
/// symmetric, so an operator anticipating the beat and an operator reacting to it both
311+
/// land on the beat they meant, and the pipeline's own delay only moves where inside
312+
/// that window the press falls. There is deliberately no constant to tune: the
313+
/// alternative is a fixed offset for a reaction time nobody has measured.
314+
///
315+
/// The half is measured against the *filter's* beat period, never the published tempo,
316+
/// because ÷2 and ×2 move the second and not the first — after a ÷2 the beats keep
317+
/// arriving at the rate they always did.
318+
///
319+
/// Naming the beat just gone takes effect **at once**: `beatInBar` is 1 before this
320+
/// returns, so the count moves under the operator's finger instead of a beat later.
321+
/// What cannot move is that beat's own outgoing event, which has been sent already —
322+
/// the correction reaches the transports on the next beat, marked `BeatEvent::snapped`.
291323
///
292324
/// The rotation this sets up is **kept** until the next snap or a reset, rather than
293325
/// being given back on the filter's next downbeat call. §5.5 asks for this because
@@ -365,6 +397,14 @@ class TempoTracker {
365397
/// Advances the filter's own bar position and turns it into the published one,
366398
/// applying whatever a snap has rotated the bar by. Called once per emitted beat.
367399
void advanceBar(bool filterCalledDownbeat) noexcept;
400+
/// Rotates the bar so that the beat `filterBeatInBar_` is currently on comes out as 1.
401+
/// A snap that names the beat just gone calls this before the position advances; one
402+
/// that names the beat still to come calls it after. Same arithmetic, one beat apart —
403+
/// which is the entire difference between the two.
404+
void startBarHere() noexcept;
405+
/// Whether the beat the filter last called is nearer than the one it will call next.
406+
/// False when neither is known yet, so a snap before the first beat waits for it.
407+
bool lastBeatIsNearer() const noexcept;
368408
/// The mean beat-to-beat gap in frames, over the gaps within `refineGapTolerance` of
369409
/// `cloudIntervalFrames`. Zero when there are not enough of them.
370410
double refinedIntervalFrames(double cloudIntervalFrames) const noexcept;
@@ -403,11 +443,23 @@ class TempoTracker {
403443
std::uint32_t filterBeatInBar_ = 0; ///< 1 on the filter's downbeat; 0 until it calls one
404444
std::uint32_t barOffset_ = 0; ///< beats added to the filter's position
405445
bool snapPending_ = false; ///< a snap waiting for the next beat to land on
446+
/// A snap whose new bar phase has not reached the outputs yet. Set by every snap and
447+
/// spent on the next beat, which is the earliest one that can carry it either way: a
448+
/// snap naming a beat still to come is carried by that beat, and one naming the beat
449+
/// just gone is carried by the beat after it. See `BeatEvent::snapped`.
450+
bool snapUnsent_ = false;
406451
/// A snap that arrived before the filter had ever called a downbeat has nothing to
407452
/// rotate yet, so the bar runs from the snap itself until the filter does — and
408453
/// `sinceSnap_` is what turns that into an offset when it finally happens.
409454
bool snapAwaitingFilter_ = false;
410455
std::uint32_t sinceSnap_ = 0;
456+
/// How long ago the last beat was and how far apart the beats are, both in frames, so
457+
/// that a snap arriving between two frames can tell which beat it means. The period is
458+
/// the filter's own, untouched by the fold or by ÷2 and ×2. Zero beats seen means the
459+
/// first is still to come.
460+
std::uint64_t framesSinceBeat_ = 0;
461+
std::uint64_t beatsSeen_ = 0;
462+
std::uint32_t filterIntervalFrames_ = 0;
411463

412464
/// The frames the last few beats were called on, oldest first. Bounded by
413465
/// `refineOverBeats + 1`, so this allocates once and never grows.

‎src/ui/main_window.slint‎

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -652,11 +652,12 @@ export component MainWindow inherits Window {
652652
// §5.5: "Big keyboard / MIDI / OSC downbeat now ... Non-negotiable — the best
653653
// online downbeat tracker in the world scores 56% F1, and being two beats out
654654
// is worse than one tap." So it is the widest thing in the row.
655-
// Lit from the press until the beat it lands on, because §5.5's snap takes
656-
// effect on the *next* beat the tracker calls — up to nine hundred milliseconds
657-
// at 70 BPM. A button that does nothing for most of a second, and then shifts a
658-
// bar phase that is hard to see moving, reads as a button that does nothing at
659-
// all. This is the difference between "it is coming" and "it is broken".
655+
// Lit from the press until the next beat, which is when the correction reaches
656+
// the outputs — up to nine hundred milliseconds at 70 BPM. A press on the beat
657+
// moves the bar dots immediately (the tracker takes the nearer beat, so it names
658+
// the one just heard), but a press between beats still has to wait for one, and a
659+
// button that does nothing for most of a second, then shifts a bar phase that is
660+
// hard to see moving, reads as a button that does nothing at all.
660661
Press {
661662
text: "DOWNBEAT";
662663
primary: root.running;

‎src/ui/window_controller.cpp‎

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -459,11 +459,12 @@ void WindowController::snapDownbeat() {
459459
if (!tracker_.engine().post(engine::Command::snapDownbeat())) {
460460
return; // queue full; nothing is coming, so do not light the button
461461
}
462-
// §5.5's snap deliberately takes effect on the *next* beat the tracker calls, which at
463-
// 70 BPM is the best part of a second away. Remember the beat count now so the redraw
464-
// can tell when it has landed, and light the button until then — the press is
465-
// otherwise entirely invisible, and a bar phase shifting is hard to see moving even
466-
// once it does.
462+
// The snap's correction reaches the outputs on the next beat the tracker calls, which
463+
// at 70 BPM is the best part of a second away — even when the bar dots move at once,
464+
// which they do whenever the press named the beat just gone. Remember the beat count
465+
// now so the redraw can tell when it has landed, and light the button until then: a
466+
// press between beats is otherwise entirely invisible, and a bar phase shifting is hard
467+
// to see moving even once it does.
467468
snapAwaitingBeat_ = tracker_.engine().state().beats;
468469
window_->set_snap_pending(true);
469470
}

‎tests/control/osc_control_test.cpp‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -94,8 +94,8 @@ TEST_CASE("the control addresses reach the tracker", "[control]") {
9494
(void)engine->step();
9595
CHECK_THAT(engine->state().bpm, WithinAbs(raw, 1e-6));
9696

97-
// A downbeat snap lands on the next beat the tracker calls; what that then means to
98-
// the bar is tests/engine/beat_engine_test.cpp's business.
97+
// A downbeat snap lands on the beat nearest the message; what that then means to the
98+
// bar is tests/engine/beat_engine_test.cpp's business.
9999
CHECK(control.dispatch("/takt4/ctl/downbeat", std::nullopt));
100100
(void)engine->step();
101101

0 commit comments

Comments
 (0)