From ffd2da3166cab147b11833a85228125d9bfe9973 Mon Sep 17 00:00:00 2001 From: Cam Gorrie Date: Fri, 3 Jul 2026 20:31:57 -0400 Subject: [PATCH 1/5] Add beat sync for loopjefe-lv2 --- src/effects.c | 106 +++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 93 insertions(+), 13 deletions(-) diff --git a/src/effects.c b/src/effects.c index 332e1eb..5e4480d 100644 --- a/src/effects.c +++ b/src/effects.c @@ -35,6 +35,7 @@ #include #include #include +#include #ifdef _WIN32 #include @@ -281,6 +282,7 @@ enum PostPonedEventType { POSTPONED_MIDI_PROGRAM_CHANGE, POSTPONED_MIDI_MAP, POSTPONED_TRANSPORT, + POSTPONED_BEAT_SYNC, POSTPONED_JACK_MIDI_CONNECT, POSTPONED_LOG_TRACE, // stack allocated, rt-safe POSTPONED_LOG_MESSAGE, // heap allocated @@ -604,6 +606,13 @@ typedef struct POSTPONED_TRANSPORT_EVENT_T { float bpm; } postponed_transport_event_t; +typedef struct POSTPONED_BEAT_SYNC_EVENT_T { + int32_t bar; // 0-indexed, matches the time:bar convention used elsewhere in this file + uint64_t t_us; // CLOCK_MONOTONIC microseconds at which this downbeat occurred + float bpm; + float bpb; +} postponed_beat_sync_event_t; + typedef struct POSTPONED_JACK_MIDI_CONNECT_EVENT_T { jack_port_id_t port; } postponed_jack_midi_connect_event_t; @@ -628,6 +637,7 @@ typedef struct POSTPONED_EVENT_T { postponed_midi_program_change_event_t program_change; postponed_midi_map_event_t midi_map; postponed_transport_event_t transport; + postponed_beat_sync_event_t beat_sync; postponed_jack_midi_connect_event_t jack_midi_connect; postponed_log_trace_event_t log_trace; postponed_log_message_event_t log_message; @@ -725,6 +735,7 @@ static jack_port_t *g_audio_out2_port; static jack_port_t *g_midi_in_port; static jack_position_t g_jack_pos; static bool g_jack_rolling; +static int32_t g_last_beat_sync_bar = -1; static volatile double g_transport_bpb; static volatile double g_transport_bpm; static volatile bool g_transport_reset; @@ -1336,6 +1347,16 @@ static void RunPostPonedEvents(int ignored_effect_id) got_transport = true; break; + case POSTPONED_BEAT_SYNC: + // one event per downbeat crossing, all delivered (not a state to dedup) + snprintf(buf, FEEDBACK_BUF_SIZE, "beat_sync %i %llu %f %f", + eventptr->event.beat_sync.bar, + (unsigned long long)eventptr->event.beat_sync.t_us, + eventptr->event.beat_sync.bpm, + eventptr->event.beat_sync.bpb); + socket_send_feedback_debug(buf); + break; + case POSTPONED_JACK_MIDI_CONNECT: if (g_jack_global_client != NULL) { const jack_port_id_t port_id = eventptr->event.jack_midi_connect.port; @@ -2472,32 +2493,91 @@ static bool UpdateGlobalJackPosition(enum UpdatePositionFlag flag, bool do_post) g_jack_pos.beats_per_minute = g_transport_bpm; } + // Detect downbeat (bar boundary) crossings and post an absolute-timestamped + // beat_sync event so external clients (pi-Stomp) can drive a metronome/count-in + // LED without running their own JACK client. One event per newly-seen bar; + // t_us is back-dated to the actual downbeat frame (not "now"), so consumers can + // extrapolate a drift-free grid regardless of feedback/relay latency. + bool posted = false; + + if (g_jack_rolling && (g_jack_pos.valid & JackPositionBBT) != 0x0 && g_jack_pos.beats_per_minute > 0.0) + { + const int32_t bar = (int32_t)g_jack_pos.bar - 1; + + if (bar != g_last_beat_sync_bar) + { + g_last_beat_sync_bar = bar; + + const double tick = (g_jack_pos.valid & JackTickDouble) ? g_jack_pos.tick_double : g_jack_pos.tick; + const double bar_beat = (g_jack_pos.beat - 1) + (tick / g_jack_pos.ticks_per_beat); + const double beat_length_frames = 60.0 * g_jack_pos.frame_rate / g_jack_pos.beats_per_minute; + const double frames_since_downbeat = bar_beat * beat_length_frames; + + struct timespec now; + clock_gettime(CLOCK_MONOTONIC, &now); + const uint64_t now_us = (uint64_t)now.tv_sec * 1000000ULL + (uint64_t)now.tv_nsec / 1000ULL; + const uint64_t t_us = now_us - (uint64_t)llround(frames_since_downbeat * 1000000.0 / g_jack_pos.frame_rate); + + postponed_event_list_data* const posteventptr = rtsafe_memory_pool_allocate_atomic(g_rtsafe_mem_pool); + + if (posteventptr) + { + posteventptr->event.type = POSTPONED_BEAT_SYNC; + posteventptr->event.beat_sync.bar = bar; + posteventptr->event.beat_sync.t_us = t_us; + posteventptr->event.beat_sync.bpm = g_jack_pos.beats_per_minute; + posteventptr->event.beat_sync.bpb = g_jack_pos.beats_per_bar; + + pthread_mutex_lock(&g_rtsafe_mutex); + list_add_tail(&posteventptr->siblings, &g_rtsafe_list); + pthread_mutex_unlock(&g_rtsafe_mutex); + + posted = true; + } + } + } + else + { + // stopped or no valid BBT: forget the last bar so a fresh roll re-anchors immediately + g_last_beat_sync_bar = -1; + } + if (flag == UPDATE_POSITION_SKIP) - return false; + { + if (posted && do_post) + sem_post(&g_postevents_semaphore); + return posted; + } if (flag == UPDATE_POSITION_IF_CHANGED && old_rolling == g_jack_rolling && !doubles_differ_enough(old_bpb, g_transport_bpb) && !doubles_differ_enough(old_bpm, g_transport_bpm)) - return false; + { + if (posted && do_post) + sem_post(&g_postevents_semaphore); + return posted; + } postponed_event_list_data* const posteventptr = rtsafe_memory_pool_allocate_atomic(g_rtsafe_mem_pool); - if (!posteventptr) - return false; + if (posteventptr) + { + posteventptr->event.type = POSTPONED_TRANSPORT; + posteventptr->event.transport.rolling = g_jack_rolling; + posteventptr->event.transport.bpb = g_transport_bpb; + posteventptr->event.transport.bpm = g_transport_bpm; - posteventptr->event.type = POSTPONED_TRANSPORT; - posteventptr->event.transport.rolling = g_jack_rolling; - posteventptr->event.transport.bpb = g_transport_bpb; - posteventptr->event.transport.bpm = g_transport_bpm; + pthread_mutex_lock(&g_rtsafe_mutex); + list_add_tail(&posteventptr->siblings, &g_rtsafe_list); + pthread_mutex_unlock(&g_rtsafe_mutex); - pthread_mutex_lock(&g_rtsafe_mutex); - list_add_tail(&posteventptr->siblings, &g_rtsafe_list); - pthread_mutex_unlock(&g_rtsafe_mutex); + posted = true; + } - if (do_post) + if (posted && do_post) sem_post(&g_postevents_semaphore); - return true; + return posted; } static int ProcessGlobalClient(jack_nframes_t nframes, void *arg) From 5f439afeb2c4b119086ac2ea217087c223b8047c Mon Sep 17 00:00:00 2001 From: Cam Gorrie Date: Mon, 6 Jul 2026 22:46:12 -0400 Subject: [PATCH 2/5] Swap bar for beat_in_bar --- src/effects.c | 47 +++++++++++++++++++++++++++++------------------ 1 file changed, 29 insertions(+), 18 deletions(-) diff --git a/src/effects.c b/src/effects.c index 5e4480d..f463a4f 100644 --- a/src/effects.c +++ b/src/effects.c @@ -607,10 +607,10 @@ typedef struct POSTPONED_TRANSPORT_EVENT_T { } postponed_transport_event_t; typedef struct POSTPONED_BEAT_SYNC_EVENT_T { - int32_t bar; // 0-indexed, matches the time:bar convention used elsewhere in this file - uint64_t t_us; // CLOCK_MONOTONIC microseconds at which this downbeat occurred + uint64_t t_us; // CLOCK_MONOTONIC microseconds at which this sample was taken ("now", not back-dated) float bpm; float bpb; + double beat_in_bar; // fractional beat position within the current bar at `t_us`, from JACK BBT } postponed_beat_sync_event_t; typedef struct POSTPONED_JACK_MIDI_CONNECT_EVENT_T { @@ -1348,12 +1348,16 @@ static void RunPostPonedEvents(int ignored_effect_id) break; case POSTPONED_BEAT_SYNC: - // one event per downbeat crossing, all delivered (not a state to dedup) - snprintf(buf, FEEDBACK_BUF_SIZE, "beat_sync %i %llu %f %f", - eventptr->event.beat_sync.bar, + // A clock-sample: (t_us=now, bpm, bpb, beat_in_bar). Consumers + // forward-extrapolate pos(t) = beat_in_bar + (t - t_us) * bpm / 60 + // from this; every sample fully replaces prior anchor state (not a + // delta), so a dropped/late one just means more extrapolation, + // never a wrong lock. All delivered (not a state to dedup). + snprintf(buf, FEEDBACK_BUF_SIZE, "beat_sync %llu %f %f %f", (unsigned long long)eventptr->event.beat_sync.t_us, eventptr->event.beat_sync.bpm, - eventptr->event.beat_sync.bpb); + eventptr->event.beat_sync.bpb, + eventptr->event.beat_sync.beat_in_bar); socket_send_feedback_debug(buf); break; @@ -2493,40 +2497,47 @@ static bool UpdateGlobalJackPosition(enum UpdatePositionFlag flag, bool do_post) g_jack_pos.beats_per_minute = g_transport_bpm; } - // Detect downbeat (bar boundary) crossings and post an absolute-timestamped - // beat_sync event so external clients (pi-Stomp) can drive a metronome/count-in - // LED without running their own JACK client. One event per newly-seen bar; - // t_us is back-dated to the actual downbeat frame (not "now"), so consumers can - // extrapolate a drift-free grid regardless of feedback/relay latency. + // Post a beat_sync clock-sample so external clients (pi-Stomp) can drive a + // metronome/count-in LED without running their own JACK client. This is a + // *sample of the transport clock* (t_us=now, bpm, bpb, beat_in_bar), not a + // back-dated "downbeat event" — the consumer forward-extrapolates + // pos(t) = beat_in_bar + (t - t_us) * bpm/60, so correctness never depends + // on cadence (the absolute bar count is DAW-context, not needed here). + // Emitted on two triggers: + // - a new bar (heartbeat; also what re-anchors after a stop/start) + // - a discrete bpm/bpb change while rolling (so a tap-tempo/CC-driven + // change re-anchors the pi the same process cycle, not ~1 bar later) + // A dropped/late sample just means more extrapolation, never a wrong lock. bool posted = false; if (g_jack_rolling && (g_jack_pos.valid & JackPositionBBT) != 0x0 && g_jack_pos.beats_per_minute > 0.0) { const int32_t bar = (int32_t)g_jack_pos.bar - 1; + const bool new_bar = (bar != g_last_beat_sync_bar); + const bool bpm_or_bpb_changed = (flag != UPDATE_POSITION_SKIP) && + (doubles_differ_enough(old_bpb, g_transport_bpb) || + doubles_differ_enough(old_bpm, g_transport_bpm)); - if (bar != g_last_beat_sync_bar) + if (new_bar || bpm_or_bpb_changed) { g_last_beat_sync_bar = bar; const double tick = (g_jack_pos.valid & JackTickDouble) ? g_jack_pos.tick_double : g_jack_pos.tick; - const double bar_beat = (g_jack_pos.beat - 1) + (tick / g_jack_pos.ticks_per_beat); - const double beat_length_frames = 60.0 * g_jack_pos.frame_rate / g_jack_pos.beats_per_minute; - const double frames_since_downbeat = bar_beat * beat_length_frames; + const double beat_in_bar = (g_jack_pos.beat - 1) + (tick / g_jack_pos.ticks_per_beat); struct timespec now; clock_gettime(CLOCK_MONOTONIC, &now); const uint64_t now_us = (uint64_t)now.tv_sec * 1000000ULL + (uint64_t)now.tv_nsec / 1000ULL; - const uint64_t t_us = now_us - (uint64_t)llround(frames_since_downbeat * 1000000.0 / g_jack_pos.frame_rate); postponed_event_list_data* const posteventptr = rtsafe_memory_pool_allocate_atomic(g_rtsafe_mem_pool); if (posteventptr) { posteventptr->event.type = POSTPONED_BEAT_SYNC; - posteventptr->event.beat_sync.bar = bar; - posteventptr->event.beat_sync.t_us = t_us; + posteventptr->event.beat_sync.t_us = now_us; posteventptr->event.beat_sync.bpm = g_jack_pos.beats_per_minute; posteventptr->event.beat_sync.bpb = g_jack_pos.beats_per_bar; + posteventptr->event.beat_sync.beat_in_bar = beat_in_bar; pthread_mutex_lock(&g_rtsafe_mutex); list_add_tail(&posteventptr->siblings, &g_rtsafe_list); From abfeede23af8db448897956e3eec517a0423d9bd Mon Sep 17 00:00:00 2001 From: Cam Gorrie Date: Sun, 23 Aug 2026 14:11:18 -0400 Subject: [PATCH 3/5] UpdateGlobalJackPosition never emitted a beat_sync on a tempo change -- this fixes that --- src/effects.c | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/effects.c b/src/effects.c index f463a4f..03097ee 100644 --- a/src/effects.c +++ b/src/effects.c @@ -2514,9 +2514,13 @@ static bool UpdateGlobalJackPosition(enum UpdatePositionFlag flag, bool do_post) { const int32_t bar = (int32_t)g_jack_pos.bar - 1; const bool new_bar = (bar != g_last_beat_sync_bar); - const bool bpm_or_bpb_changed = (flag != UPDATE_POSITION_SKIP) && + // UPDATE_POSITION_FORCED means that a caller has just set bpm or bpb. + // Those callers write the global before they call, thus old_bpm and + // old_bpb already hold the new value and can not show the change. + const bool bpm_or_bpb_changed = (flag == UPDATE_POSITION_FORCED) || + ((flag == UPDATE_POSITION_IF_CHANGED) && (doubles_differ_enough(old_bpb, g_transport_bpb) || - doubles_differ_enough(old_bpm, g_transport_bpm)); + doubles_differ_enough(old_bpm, g_transport_bpm))); if (new_bar || bpm_or_bpb_changed) { @@ -6979,7 +6983,7 @@ int effects_set_beats_per_minute(double bpm) g_transport_bpm = bpm; g_transport_reset = true; TriggerJackTimebase(false); - UpdateGlobalJackPosition(UPDATE_POSITION_FORCED, false); + UpdateGlobalJackPosition(UPDATE_POSITION_FORCED, true); } else { result = ERR_JACK_VALUE_OUT_OF_RANGE; } From 30e4f5764eefc8cab068788bebb5a0e323c473a0 Mon Sep 17 00:00:00 2001 From: Cam Gorrie Date: Sun, 23 Aug 2026 23:17:13 -0400 Subject: [PATCH 4/5] Fix beat sync: send the *new* tempo, not the old one --- src/effects.c | 32 +++++++++++++++++++++++--------- 1 file changed, 23 insertions(+), 9 deletions(-) diff --git a/src/effects.c b/src/effects.c index 03097ee..5ff68b1 100644 --- a/src/effects.c +++ b/src/effects.c @@ -198,6 +198,13 @@ typedef struct { // transport defaults #define TRANSPORT_TICKS_PER_BEAT 1920.0 +// beat_sync flags. A bar heartbeat gives a beat_in_bar that a client can use +// for phase. A tempo change or a meter change does not. g_transport_reset +// makes the next JackTimebase calculate the timeline again from the absolute +// frame at the new tempo. The bar and the beat then change to new values. +#define BEAT_SYNC_FLAG_NEW_BAR 0x1 +#define BEAT_SYNC_FLAG_TEMPO_CHANGED 0x2 + /* ************************************************************************************************************************ @@ -611,6 +618,7 @@ typedef struct POSTPONED_BEAT_SYNC_EVENT_T { float bpm; float bpb; double beat_in_bar; // fractional beat position within the current bar at `t_us`, from JACK BBT + int32_t flags; // BEAT_SYNC_FLAG_*: the cause of this sample. A client does not calculate it. } postponed_beat_sync_event_t; typedef struct POSTPONED_JACK_MIDI_CONNECT_EVENT_T { @@ -1348,16 +1356,20 @@ static void RunPostPonedEvents(int ignored_effect_id) break; case POSTPONED_BEAT_SYNC: - // A clock-sample: (t_us=now, bpm, bpb, beat_in_bar). Consumers - // forward-extrapolate pos(t) = beat_in_bar + (t - t_us) * bpm / 60 - // from this; every sample fully replaces prior anchor state (not a - // delta), so a dropped/late one just means more extrapolation, - // never a wrong lock. All delivered (not a state to dedup). - snprintf(buf, FEEDBACK_BUF_SIZE, "beat_sync %llu %f %f %f", + // A clock-sample: (t_us=now, bpm, bpb, beat_in_bar, flags). + // Consumers forward-extrapolate + // pos(t) = beat_in_bar + (t - t_us) * bpm / 60 from this; every + // sample fully replaces prior anchor state (not a delta), so a + // dropped/late one just means more extrapolation, never a wrong + // lock. All delivered (not a state to dedup). The phase is + // correct only if NEW_BAR is set. Refer to the BEAT_SYNC_FLAG_* + // notes. + snprintf(buf, FEEDBACK_BUF_SIZE, "beat_sync %llu %f %f %f %d", (unsigned long long)eventptr->event.beat_sync.t_us, eventptr->event.beat_sync.bpm, eventptr->event.beat_sync.bpb, - eventptr->event.beat_sync.beat_in_bar); + eventptr->event.beat_sync.beat_in_bar, + eventptr->event.beat_sync.flags); socket_send_feedback_debug(buf); break; @@ -2539,9 +2551,11 @@ static bool UpdateGlobalJackPosition(enum UpdatePositionFlag flag, bool do_post) { posteventptr->event.type = POSTPONED_BEAT_SYNC; posteventptr->event.beat_sync.t_us = now_us; - posteventptr->event.beat_sync.bpm = g_jack_pos.beats_per_minute; - posteventptr->event.beat_sync.bpb = g_jack_pos.beats_per_bar; + posteventptr->event.beat_sync.bpm = g_transport_bpm; + posteventptr->event.beat_sync.bpb = g_transport_bpb; posteventptr->event.beat_sync.beat_in_bar = beat_in_bar; + posteventptr->event.beat_sync.flags = (new_bar ? BEAT_SYNC_FLAG_NEW_BAR : 0) | + (bpm_or_bpb_changed ? BEAT_SYNC_FLAG_TEMPO_CHANGED : 0); pthread_mutex_lock(&g_rtsafe_mutex); list_add_tail(&posteventptr->siblings, &g_rtsafe_list); From 6b12e945c9a02dbc7afca02c59f95ceb5a80ff0f Mon Sep 17 00:00:00 2001 From: Cam Gorrie Date: Sun, 23 Aug 2026 23:31:10 -0400 Subject: [PATCH 5/5] Add a deploy script --- deploy.sh | 68 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100755 deploy.sh diff --git a/deploy.sh b/deploy.sh new file mode 100755 index 0000000..7441caf --- /dev/null +++ b/deploy.sh @@ -0,0 +1,68 @@ +#!/usr/bin/env bash +# Build and deploy mod-host to the device. +# Syncs source, compiles on-device (aarch64), installs over the +# mod-host-pistomp deb's files, then restarts mod-host. +# +# This is a *development* shortcut: it overwrites files owned by the +# mod-host-pistomp package, so apt will consider them modified and the next +# `apt install --reinstall mod-host-pistomp` (or a version bump) reverts them. +# For a real release, land on master and bump the version in pi-gen-pistomp. +# +# Usage: +# ./deploy.sh +set -euo pipefail + +HOST="${PISTOMP_HOST:-pistomp.local}" +USER="${PISTOMP_USER:-pistomp}" +TARGET="${USER}@${HOST}" +PREFIX="/usr" +REMOTE_SRC="/tmp/mod-host" + +echo "==> Syncing source to ${TARGET}:${REMOTE_SRC}" +ssh "${TARGET}" "mkdir -p ${REMOTE_SRC}" +rsync -az --delete \ + --exclude='.git' --exclude='*.o' --exclude='*.so' --exclude='*.dylib' \ + --exclude='*.dSYM' --exclude='mod-host' --exclude='src/info.h' \ + ./ "${TARGET}:${REMOTE_SRC}/" + +# Build deps. The device is a runtime image, so the -dev packages the deb's +# chroot has are not necessarily here. Missing libreadline-dev is a hard +# compile error; missing libfftw3-dev silently drops -DHAVE_FFTW335 and gives +# you a binary that differs from the shipped deb, which is worse. +echo "==> Checking build deps" +ssh "${TARGET}" "set -e; missing=; \ + [ -e /usr/include/readline/readline.h ] || missing=\"\$missing libreadline-dev\"; \ + pkg-config --atleast-version=3.3.5 fftw3 fftw3f 2>/dev/null || missing=\"\$missing libfftw3-dev\"; \ + pkg-config --exists jack || missing=\"\$missing libjack-jackd2-dev\"; \ + if [ -n \"\$missing\" ]; then \ + echo \" installing:\$missing\"; \ + sudo apt-get update -qq && sudo apt-get install -y -qq \$missing; \ + else echo ' ok'; fi" + +echo "==> Building on device" +ssh "${TARGET}" "set -e; \ + pkg-config --atleast-version=1.9.0 jack || { \ + echo 'ERROR: pkg-config cannot resolve jack >= 1.9.0 on device' >&2; \ + echo \" Found: \$(pkg-config --modversion jack 2>&1)\" >&2; \ + exit 1; }; \ + make -C ${REMOTE_SRC} -j\$(nproc)" + +ssh "${TARGET}" "nm -D --undefined-only ${REMOTE_SRC}/mod-host | grep -q jack_internal_client_load" \ + || { echo "ERROR: built binary lacks jack_internal_client_load (HAVE_JACK2 off)" >&2; exit 1; } + +# Stop first: overwriting the running executable in place gives ETXTBSY. +# mod-ui has Requires=mod-host, so stopping mod-host stops mod-ui too (and +# mod-ala-pi-stomp, which mod-ui Wants) — and systemd does NOT bring dependents +# back when mod-host starts again. Starting mod-ui is what restores the whole +# stack: it pulls mod-host via Requires and pi-stomp via Wants. +echo "==> Stopping mod-host" +ssh "${TARGET}" "sudo systemctl stop mod-host" + +echo "==> Installing to ${PREFIX}" +ssh "${TARGET}" "sudo make -C ${REMOTE_SRC} install PREFIX=${PREFIX}" + +echo "==> Starting mod-host + mod-ui" +ssh "${TARGET}" "sudo systemctl start mod-ui" +ssh "${TARGET}" "systemctl is-active mod-host mod-ui mod-ala-pi-stomp || true" + +echo "==> Done"