Skip to content

Commit 095f870

Browse files
committed
Take a lost lock back on the beats' own evidence; clocks and rule lengths follow the beats going out
A lock taken at a wrong tempo (double time, or 176 over a record at 130) and then lost left the number frozen there while every beat went out at the record's real tempo — for minutes, because replacing a long-held lock needs up to 300 frames of unbroken agreement and the decoder's tempo dips past the lock's band as each beat arrives. Over the 109-g playlist played straight through, steady beats went out under a number more than 6 % off them for 31.8 of 715 live minutes. Tracker: - Unlocked after a lock, once the lock has been gone 3 s, the last eight published beats are steady (interquartile range under 5 % of the median gap) and the decoder's tempo over the last beat agrees with them, the lock moves to the rate the beats are going out at. Replacements only: a first lock, and a lock coming back to the tempo published, are earned as before. - The octave the filter's beats sit at against its cloud is kept through a lock move, and the first lock after no signal, only if the beats do not cleanly show another; it used to be kept through everything but a reset. - TempoState::gridBpm: the rate the published beats are going out at — the number on the beats' octave while it agrees with them, their measured rate when it does not. clockOctaves: the operator's x2, which doubles the number and cannot double the beats. Outputs: - The MIDI clock and Link run at gridBpm, times two under a x2; the clock's receiver and Link's session count that many of their beats to each beat going out, so a x2 or a number left behind no longer stops and restarts a receiver every bar, and a receiver's bar 1 is the tracker's downbeat. - The beat scheduler predicts at gridBpm, and a rule's waits and follow-ups in beats or bars, and TEST's hold, are counted in it (Context::beatSeconds). Window: the line under the tempo says what the beats are going out at whenever the outputs are live, locked or not ("number held from last lock" unlocked, "press ÷2 to match" locked). Measured over the same playlist played straight through: steady beats under a number more than 6 % off them for 9.3 minutes rather than 31.8, the longest stretch 9 s rather than 145; tracked a record at a time, 6.1 minutes rather than 22.7. The refeval gate passes (tempo accuracy up, beat F unchanged), Ballroom is bit-identical, and on the agreed fifteen the number is off the reference for 331 s rather than 405 s while the outputs are live (258 rather than 273 with the 70-140 window). takt4-cli --trace writes beats_bpm and grid_bpm.
1 parent 46e439d commit 095f870

19 files changed

Lines changed: 947 additions & 70 deletions

‎src/cli/main.cpp‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1068,7 +1068,8 @@ class FrameTracer {
10681068
// the first of them a trace cannot tell that story at all.
10691069
out_ << "frame\ttime\tbeat_act\tdown_act\tgathering\tinterval\trefined_interval"
10701070
"\tcloud_bpm\tagreement\tbpm\tconfidence\tlocked\tholding\tmeter\tbeat_in_bar"
1071-
"\temitted\tpublished\tflux\tintensity\tonset\tinterp\tacquired\tno_signal\n"
1071+
"\temitted\tpublished\tflux\tintensity\tonset\tinterp\tacquired\tno_signal"
1072+
"\tbeats_bpm\tgrid_bpm\n"
10721073
<< std::fixed << std::setprecision(6);
10731074
}
10741075

@@ -1088,7 +1089,8 @@ class FrameTracer {
10881089
<< static_cast<int>(tracked.emitted) << '\t' << (frame.beat ? 1 : 0) << '\t'
10891090
<< frame.activation.flux << '\t' << static_cast<int>(frame.activation.intensity)
10901091
<< '\t' << (frame.activation.onset ? 1 : 0) << '\t' << (frame.interpolated ? 1 : 0)
1091-
<< '\t' << (state.acquired ? 1 : 0) << '\t' << (state.noSignal ? 1 : 0) << '\n';
1092+
<< '\t' << (state.acquired ? 1 : 0) << '\t' << (state.noSignal ? 1 : 0) << '\t'
1093+
<< state.beatsBpm << '\t' << state.gridBpm << '\n';
10921094
}
10931095

10941096
private:

‎src/core/output/beat_scheduler.cpp‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,11 @@
66
namespace takt4::output {
77

88
double beatSeconds(const tracking::TempoState& state, double bpm) noexcept {
9+
// The tracker's own answer, where it has one: the beats' measured rate whenever the number
10+
// has been left behind by them, which no octave of the number can say (`TempoState::gridBpm`).
11+
if (state.gridBpm > 0.0) {
12+
return 60.0 / state.gridBpm;
13+
}
914
if (!(bpm > 0.0)) {
1015
return 0.0;
1116
}

‎src/core/output/beat_scheduler.hpp‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -131,8 +131,11 @@ class BeatScheduler {
131131
/// published tempo **only when the beats are on the published grid**. A ×2 doubles the number
132132
/// and cannot double the beats, and a fold the music has not yet backed halves the number and
133133
/// leaves the beats alone (`TempoState::beatDivisor`); in both the beats are an octave from the
134-
/// tempo. Worked out from the rate the filter calls beats at and the divisor, snapped to the
135-
/// octave of the published tempo so the refined tempo's precision is kept. Zero with no tempo.
134+
/// tempo. `TempoState::gridBpm` is the tracker's answer, and it is used whenever it is set: it
135+
/// also covers a number left behind by the beats at some other ratio — a lock lost at 176 over a
136+
/// record at 130 — which no octave of the number reaches. Without one (a state built by hand),
137+
/// worked out from the rate the filter calls beats at and the divisor, snapped to the octave of
138+
/// the published tempo so the refined tempo's precision is kept. Zero with no tempo.
136139
double beatSeconds(const tracking::TempoState& state, double bpm) noexcept;
137140

138141
} // namespace takt4::output

‎src/core/output/output_runner.cpp‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1107,6 +1107,7 @@ trigger::Context OutputRunner::contextAt(double now) const {
11071107
const engine::EngineIntensity intensity = engine_.intensity();
11081108
trigger::Context context;
11091109
context.bpm = state.bpm;
1110+
context.beatSeconds = state.gridBpm > 0.0 ? 60.0 / state.gridBpm : 0.0;
11101111
context.confidence = state.confidence;
11111112
context.locked = state.locked;
11121113
context.meter = state.beatsPerBar;
@@ -1126,6 +1127,7 @@ void OutputRunner::fireBeat(const ScheduledBeat& beat, double now) {
11261127
// then held to — see `trigger::Message::moment`.
11271128
trigger::Context context = contextAt(now);
11281129
context.bpm = beat.event.bpm;
1130+
context.beatSeconds = beat.event.gridBpm > 0.0 ? 60.0 / beat.event.gridBpm : 0.0;
11291131
context.confidence = beat.event.confidence;
11301132
context.locked = beat.event.locked;
11311133
context.meter = beat.event.beatsPerBar;

‎src/core/output/transports.cpp‎

Lines changed: 47 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,19 @@ std::int64_t toMicros(double seconds) noexcept {
3636
return static_cast<std::int64_t>(seconds * 1e6);
3737
}
3838

39+
/// How many of the clocks' beats go by in one published beat: one, or two or four under the
40+
/// operator's ×2 — which doubles the tempo the clocks carry and cannot double the beats
41+
/// (`TempoState::clockOctaves`).
42+
std::uint32_t clockBeatsPerBeat(const tracking::BeatEvent& event) noexcept {
43+
return std::uint32_t{1} << std::clamp(event.clockOctaves, 0, 2);
44+
}
45+
46+
/// The rate the published beats are going out at (`TempoState::gridBpm`), or the published tempo
47+
/// for a beat that does not say — one built by hand.
48+
double gridBpmOf(const tracking::BeatEvent& event) noexcept {
49+
return event.gridBpm > 0.0 ? event.gridBpm : event.bpm;
50+
}
51+
3952
/// Why `device` would not open, in the words its row shows — from inside the `catch` that
4053
/// caught what opening it threw. Not on the machine and held by another program are told apart:
4154
/// they have different fixes, and the first used to be said of both.
@@ -605,9 +618,16 @@ double Transports::tailSeconds() const noexcept {
605618
void Transports::publishClocks(const tracking::BeatEvent& event, std::int64_t hostMicros,
606619
double beatTime) {
607620
const std::int64_t latencyMicros = latencyMicros_.load(std::memory_order_relaxed);
621+
// **The beats going out, never the number blind** — `TempoState::gridBpm`. A number frozen at
622+
// 188 where a lost lock left it, over beats at 94, ticked a receiver at twice the music and
623+
// restarted it every bar, since its quarter notes and the tracker's beats then disagreed two
624+
// to one. The operator's ×2 alone runs the clock above the beats, which is what it is for.
625+
const double grid = gridBpmOf(event);
626+
const std::uint32_t perBeat = clockBeatsPerBeat(event);
627+
const double tempo = grid * static_cast<double>(perBeat);
608628
for (Clock& clock : clocks_) {
609629
MidiClock& midi = *clock.clock;
610-
midi.setTempo(event.bpm);
630+
midi.setTempo(tempo);
611631
// The beat's own time, not the round that drained it: the audio arrived a pipeline's
612632
// worth of time before the beat was called, and the clock used to be synced to when it
613633
// was *drained* — late by that pipeline, plus the offset (§5.5). The clock steers
@@ -623,18 +643,24 @@ void Transports::publishClocks(const tracking::BeatEvent& event, std::int64_t ho
623643
// 2026-09-25, M6). The tracker publishes `beatInBar` 0 before it has found a bar, and
624644
// that was read as beat 1 — so a receiver's bar 1 could land on beat 2, 3 or 4 and stay
625645
// there for the run. Link's path already waited for this (`phased`, below).
626-
if (event.locked && event.bpm > 0.0 && event.beatInBar > 0 && event.beatsPerBar > 0) {
627-
const double beat = 60.0 / event.bpm;
646+
if (event.locked && grid > 0.0 && event.beatInBar > 0 && event.beatsPerBar > 0) {
647+
const double beat = 60.0 / grid;
628648
const std::uint32_t meter = event.beatsPerBar;
629649
const std::uint32_t inBar = std::clamp<std::uint32_t>(event.beatInBar, 1, meter);
650+
// The receiver counts its own quarter notes, `perBeat` of them to a published beat, and
651+
// its bars in those: the tracker's downbeat starts one of them, and this beat is the
652+
// start of the receiver's quarter note `(inBar - 1) * perBeat` from there.
653+
const std::uint32_t receiverBeat = ((inBar - 1) * perBeat) % meter + 1;
630654
if (midi.waitingToStart()) {
655+
// On the tracker's own downbeats, so the receiver's bar 1 is the music's even
656+
// where its bar is a part of the tracker's.
631657
midi.startOnDownbeat(heardAt - static_cast<double>(inBar - 1) * beat,
632658
static_cast<double>(meter) * beat);
633659
} else {
634660
// And once it has started, its bars follow the tracker's: a DOWNBEAT press, or a
635661
// bar found again elsewhere after a break, restarts the receiver on the new
636662
// downbeat — Song Position cannot move it while it plays (M19's other half).
637-
midi.followBar(heardAt, inBar, meter);
663+
midi.followBar(heardAt, receiverBeat, meter);
638664
}
639665
}
640666
}
@@ -682,7 +708,10 @@ void Transports::publishToLink(const tracking::BeatEvent& event, std::int64_t ho
682708
// the tempo is the hunt's, octave flips and all, and every Start used to overwrite
683709
// Resolume's tempo with it for a few seconds (the audit's H15). And the next lock snaps
684710
// again: whatever the session did in between, it is put back under the music then.
685-
if (!event.locked || !(event.bpm > 0.0)) {
711+
// The beats' rate, and the operator's ×2 over it: see `publishClocks`.
712+
const std::uint32_t perBeat = clockBeatsPerBeat(event);
713+
const double tempo = gridBpmOf(event) * static_cast<double>(perBeat);
714+
if (!event.locked || !(tempo > 0.0)) {
686715
linkSnapped_ = false;
687716
return;
688717
}
@@ -699,15 +728,19 @@ void Transports::publishToLink(const tracking::BeatEvent& event, std::int64_t ho
699728
// peer's tempo up to 2.6 BPM off meanwhile — slow, wobbling feedback for an operator
700729
// dragging the delay to line Resolume up. The delay is a place to put the beat, not drift.
701730
if (linkSnapped_ && std::abs(static_cast<double>(latencyMicros - linkSnappedOffset_)) / 1e6 *
702-
event.bpm / 60.0 >
731+
tempo / 60.0 >
703732
kLinkPhaseDeadband) {
704733
linkSnapped_ = false;
705734
}
706735

707736
const std::chrono::microseconds at{hostMicros + latencyMicros};
708737
// Bar phase needs a bar: before the first downbeat only the tempo goes.
709738
const bool phased = event.beatInBar > 0 && event.beatsPerBar > 0;
710-
const double beat = phased ? static_cast<double>(event.beatInBar - 1) : 0.0;
739+
// The session's own beat this one falls on: `perBeat` of Link's beats to each published one,
740+
// its bar the tracker's meter of them — so under a ×2 the tracker's downbeat starts one of
741+
// Link's bars, and the beat after it is Link's beat 3.
742+
const double beat =
743+
phased ? static_cast<double>(((event.beatInBar - 1) * perBeat) % event.beatsPerBar) : 0.0;
711744
const double quantum = phased ? static_cast<double>(event.beatsPerBar) : 1.0;
712745
const auto sendTempo = [&](double bpm) {
713746
if (std::abs(bpm - lastLinkBpm_) > kLinkTempoEpsilon) {
@@ -726,15 +759,15 @@ void Transports::publishToLink(const tracking::BeatEvent& event, std::int64_t ho
726759
// *forces* the phase, and so does a DOWNBEAT (§5.6 reserves force for that, and Link's
727760
// header names bridging an external clock as its legitimate use).
728761
if (phased && (!linkSnapped_ || event.snapped)) {
729-
link_->snap(event.bpm, beat, at, quantum);
730-
lastLinkBpm_ = event.bpm;
762+
link_->snap(tempo, beat, at, quantum);
763+
lastLinkBpm_ = tempo;
731764
linkSnapped_ = true;
732765
linkSnappedOffset_ = latencyMicros;
733766
barsApart_ = 0;
734767
return;
735768
}
736769
if (!phased) {
737-
sendTempo(event.bpm);
770+
sendTempo(tempo);
738771
return;
739772
}
740773

@@ -751,8 +784,8 @@ void Transports::publishToLink(const tracking::BeatEvent& event, std::int64_t ho
751784
// not drift. A tempo nudge would take a hundred beats over a whole beat, so a bar's
752785
// worth of disagreement in a row is answered the way a DOWNBEAT is.
753786
if (++barsApart_ >= event.beatsPerBar) {
754-
link_->snap(event.bpm, beat, at, quantum);
755-
lastLinkBpm_ = event.bpm;
787+
link_->snap(tempo, beat, at, quantum);
788+
lastLinkBpm_ = tempo;
756789
linkSnappedOffset_ = latencyMicros;
757790
barsApart_ = 0;
758791
return;
@@ -761,7 +794,7 @@ void Transports::publishToLink(const tracking::BeatEvent& event, std::int64_t ho
761794
barsApart_ = 0;
762795
}
763796
if (std::abs(error) < kLinkPhaseDeadband) {
764-
sendTempo(event.bpm);
797+
sendTempo(tempo);
765798
return;
766799
}
767800
// A session ahead is slowed and one behind is hurried, by the share of the error a beat
@@ -770,7 +803,7 @@ void Transports::publishToLink(const tracking::BeatEvent& event, std::int64_t ho
770803
// share of however long ago `at` was — (now − at) × Δbpm / 60 beats, thousandths of a beat
771804
// when `at` is a beat's stamp plus a latency that has already gone by.
772805
const double nudge = std::clamp(error / kLinkNudgeBeats, -kLinkMaxNudge, kLinkMaxNudge);
773-
sendTempo(event.bpm * (1.0 - nudge));
806+
sendTempo(tempo * (1.0 - nudge));
774807
}
775808

776809
} // namespace takt4::output

0 commit comments

Comments
 (0)