From d16535875e7b2da43994d8beb0f418347ca4838a Mon Sep 17 00:00:00 2001 From: Eric Dahl Date: Fri, 17 Jul 2026 01:06:09 -0400 Subject: [PATCH] =?UTF-8?q?feat(transport):=20pure=20single-button=20gestu?= =?UTF-8?q?re=20decoder=20=E2=80=94=20TAP=20vs=20HOLD=20(ESP-045)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Mini's one live button needs two gestures: short press = play/stop, hold = realign. transport_button_update() splits a debounced press into TAP (on release) or HOLD (fired once at a 600ms threshold, while still down; the release then stays silent). Pure timing over buttons.c debounce — the glue owns only the pin read and mapping TAP->toggle / HOLD->realign, so the short-vs-long decision is host-tested. Lives in KitchenSync/main/ (transport is a KitchenSync concept, not X32Link). TDD: test_transport_button.c — tap, hold-once-then-silent-release, and back-to-back gesture reset. All host suites green. Claude-Session: https://claude.ai/code/session_01F7YsQgso3KNpeXbP5vNVj6 --- KitchenSync/main/transport_button.c | 30 +++++++++++++++ KitchenSync/main/transport_button.h | 42 ++++++++++++++++++++ test/Makefile | 9 ++++- test/test_transport_button.c | 60 +++++++++++++++++++++++++++++ 4 files changed, 139 insertions(+), 2 deletions(-) create mode 100644 KitchenSync/main/transport_button.c create mode 100644 KitchenSync/main/transport_button.h create mode 100644 test/test_transport_button.c diff --git a/KitchenSync/main/transport_button.c b/KitchenSync/main/transport_button.c new file mode 100644 index 0000000..8d9e52d --- /dev/null +++ b/KitchenSync/main/transport_button.c @@ -0,0 +1,30 @@ +// ESP-045: single-button transport gesture — see transport_button.h. Pure. +#include "transport_button.h" + +void transport_button_reset(TransportButton* tb) { + button_reset(&tb->btn); + tb->down = false; + tb->press_ms = 0; + tb->hold_fired = false; +} + +ButtonGesture transport_button_update(TransportButton* tb, bool raw_pressed, uint32_t now_ms) { + if (button_update(&tb->btn, raw_pressed, now_ms)) { // debounced press edge + tb->down = true; + tb->press_ms = now_ms; + tb->hold_fired = false; + return BTN_GESTURE_NONE; + } + if (tb->down && !button_is_pressed(&tb->btn)) { // released + tb->down = false; + return tb->hold_fired ? BTN_GESTURE_NONE : BTN_GESTURE_TAP; + } + // held past the threshold, still down: realign — fire once, and the later release + // stays silent (hold_fired short-circuits the TAP above). + if (tb->down && !tb->hold_fired && button_is_pressed(&tb->btn) + && (now_ms - tb->press_ms) >= TRANSPORT_BUTTON_HOLD_MS) { + tb->hold_fired = true; + return BTN_GESTURE_HOLD; + } + return BTN_GESTURE_NONE; +} diff --git a/KitchenSync/main/transport_button.h b/KitchenSync/main/transport_button.h new file mode 100644 index 0000000..1431063 --- /dev/null +++ b/KitchenSync/main/transport_button.h @@ -0,0 +1,42 @@ +#pragma once +// ESP-045: one physical button, two gestures — the Mini's live transport surface. +// A short press TAPS (play/stop toggle); holding past a threshold is a HOLD (realign). +// Pure timing + debounce over buttons.h, so the whole gesture split is host-testable; +// the glue owns only the pin read and mapping the gesture to a TransportLaunchIntent +// (TAP -> play/stop per launch state, HOLD -> realign). No intent/launch-state +// dependency here on purpose — this module decides ONLY short-vs-long. +#include +#include +#include "buttons.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// Held this long (from the debounced press edge) turns a press into a HOLD. Short +// enough to feel deliberate, long enough that a normal tap never trips it. +#define TRANSPORT_BUTTON_HOLD_MS 600 + +typedef enum { + BTN_GESTURE_NONE = 0, + BTN_GESTURE_TAP, // short press, reported on release -> play/stop toggle + BTN_GESTURE_HOLD, // held past the threshold, reported once while still down -> realign +} ButtonGesture; + +typedef struct { + Button btn; // debounce (edge + level) + bool down; // a debounced press is in progress, not yet resolved + uint32_t press_ms; // when the debounced press edge landed + bool hold_fired; // HOLD already emitted for this press (so release is silent) +} TransportButton; + +void transport_button_reset(TransportButton* tb); + +// Feed the raw pin level (true = pressed) and the current time. Returns a gesture at +// most once per press: HOLD the moment the press crosses TRANSPORT_BUTTON_HOLD_MS while +// still down; otherwise TAP when the press is released. NONE on every other tick. +ButtonGesture transport_button_update(TransportButton* tb, bool raw_pressed, uint32_t now_ms); + +#ifdef __cplusplus +} +#endif diff --git a/test/Makefile b/test/Makefile index af2c577..f178442 100644 --- a/test/Makefile +++ b/test/Makefile @@ -21,6 +21,7 @@ TOUCHUI = ../X32Link/touch_ui.c SNAP = ../X32Link/tempo_snapshot.c BAR = ../X32Link/bar.c BUTTONS = ../X32Link/buttons.c +TBTN = ../KitchenSync/main/transport_button.c TLED = ../X32Link/transport_led.c TLAUNCH = ../X32Link/transport_launch.c MIDIOUT = ../X32Link/midi_clock_out.c @@ -74,7 +75,7 @@ HEADERS = $(wildcard ../X32Link/*.h ../KitchenSync/main/*.h ../LoraLink/*.h \ .PHONY: all clean -all: test_bpm_tracker test_link_protocol test_link_measurement test_link_phase test_led_phase test_wifi_down_blink test_app_config test_osc_out test_midi_bpm_calc test_bpm_publisher test_osc_in test_web_status_json test_touch_ui test_tempo_snapshot test_bar test_midi_clock_out test_clock_ticker test_beat_synth test_link_measurement_session test_session_timeline test_link_measure_pump test_ks_tick test_follow_beat test_usb_midi_pack test_beat_clock test_ks_status test_ks_config test_ks_config_json test_ks_form test_metronome test_transport test_clock_output test_metronome_voice test_beat_source test_swing test_midi_clock_in test_metro_strip test_wifi_conn_policy test_lora_bpm_packet test_lora_freshness test_battery_gauge test_beat_stamper test_transport_launch test_ks_hostname test_ui_chrome test_ktouch_ui test_ktouch_migrate test_status_rgb test_usb_midi_batch test_config_persist test_master_clock test_buttons test_transport_led +all: test_bpm_tracker test_link_protocol test_link_measurement test_link_phase test_led_phase test_wifi_down_blink test_app_config test_osc_out test_midi_bpm_calc test_bpm_publisher test_osc_in test_web_status_json test_touch_ui test_tempo_snapshot test_bar test_midi_clock_out test_clock_ticker test_beat_synth test_link_measurement_session test_session_timeline test_link_measure_pump test_ks_tick test_follow_beat test_usb_midi_pack test_beat_clock test_ks_status test_ks_config test_ks_config_json test_ks_form test_metronome test_transport test_clock_output test_metronome_voice test_beat_source test_swing test_midi_clock_in test_metro_strip test_wifi_conn_policy test_lora_bpm_packet test_lora_freshness test_battery_gauge test_beat_stamper test_transport_launch test_ks_hostname test_ui_chrome test_ktouch_ui test_ktouch_migrate test_status_rgb test_usb_midi_batch test_config_persist test_master_clock test_buttons test_transport_led test_transport_button ./test_bpm_tracker ./test_link_protocol ./test_link_measurement @@ -91,6 +92,7 @@ all: test_bpm_tracker test_link_protocol test_link_measurement test_link_phase t ./test_tempo_snapshot ./test_bar ./test_buttons + ./test_transport_button ./test_transport_led ./test_midi_clock_out ./test_clock_ticker @@ -181,6 +183,9 @@ test_bar: test_bar.c $(BAR) $(UNITY) $(HEADERS) test_buttons: test_buttons.c $(BUTTONS) $(UNITY) $(HEADERS) $(CC) $(CFLAGS) $(filter %.c,$^) -o $@ +test_transport_button: test_transport_button.c $(TBTN) $(BUTTONS) $(UNITY) $(HEADERS) + $(CC) $(CFLAGS) -I../KitchenSync/main $(filter %.c,$^) -o $@ + test_transport_led: test_transport_led.c $(TLED) $(TLAUNCH) $(UNITY) $(HEADERS) $(CC) $(CFLAGS) $(filter %.c,$^) -o $@ -lm @@ -233,7 +238,7 @@ test_battery_gauge: test_battery_gauge.c $(BATTGAUGE) $(UNITY) $(HEADERS) $(CC) $(CFLAGS) $(filter %.c,$^) -o $@ -lm clean: - rm -f test_buttons test_transport_led test_bpm_tracker test_link_protocol test_link_measurement test_link_phase test_led_phase test_wifi_down_blink test_app_config test_osc_out test_midi_bpm_calc test_bpm_publisher test_osc_in test_web_status_json test_axs5106l test_touch_ui test_tempo_snapshot test_bar + rm -f test_transport_button test_buttons test_transport_led test_bpm_tracker test_link_protocol test_link_measurement test_link_phase test_led_phase test_wifi_down_blink test_app_config test_osc_out test_midi_bpm_calc test_bpm_publisher test_osc_in test_web_status_json test_axs5106l test_touch_ui test_tempo_snapshot test_bar test_config_persist: test_config_persist.c $(CFGPERSIST) $(UNITY) $(HEADERS) $(CC) $(CFLAGS) $(filter %.c,$^) -o $@ diff --git a/test/test_transport_button.c b/test/test_transport_button.c new file mode 100644 index 0000000..1370380 --- /dev/null +++ b/test/test_transport_button.c @@ -0,0 +1,60 @@ +#include "unity.h" +#include "transport_button.h" + +void setUp(void) {} +void tearDown(void) {} + +// A quick press-and-release (held well under the hold threshold) is a TAP — the +// play/stop toggle. Reported on RELEASE, so a press can still become a HOLD. +static void test_quick_press_release_is_a_tap(void) { + TransportButton tb; transport_button_reset(&tb); + transport_button_update(&tb, false, 0); // seed: released + transport_button_update(&tb, true, 100); // press (bouncing) + transport_button_update(&tb, true, 100 + BUTTON_DEBOUNCE_MS); // debounced press edge + transport_button_update(&tb, true, 200); // held briefly (< HOLD_MS) + transport_button_update(&tb, false, 300); // release (bouncing) + ButtonGesture g = transport_button_update(&tb, false, 300 + BUTTON_DEBOUNCE_MS); // settled + TEST_ASSERT_EQUAL_INT(BTN_GESTURE_TAP, g); +} + +// Holding past the threshold is a HOLD (realign) — fired ONCE the moment it crosses, +// while still down. The release afterward is silent, not a second TAP. +static void test_long_hold_fires_once_then_silent_release(void) { + TransportButton tb; transport_button_reset(&tb); + transport_button_update(&tb, false, 0); // seed: released + transport_button_update(&tb, true, 100); // press (bouncing) + transport_button_update(&tb, true, 100 + BUTTON_DEBOUNCE_MS); // debounced press edge (t=125) + uint32_t edge = 100 + BUTTON_DEBOUNCE_MS; + TEST_ASSERT_EQUAL_INT(BTN_GESTURE_NONE, transport_button_update(&tb, true, edge + 300)); // < HOLD + TEST_ASSERT_EQUAL_INT(BTN_GESTURE_HOLD, transport_button_update(&tb, true, edge + TRANSPORT_BUTTON_HOLD_MS)); + TEST_ASSERT_EQUAL_INT(BTN_GESTURE_NONE, transport_button_update(&tb, true, edge + TRANSPORT_BUTTON_HOLD_MS + 200)); // not again + transport_button_update(&tb, false, edge + 2000); // release (bouncing) + TEST_ASSERT_EQUAL_INT(BTN_GESTURE_NONE, transport_button_update(&tb, false, edge + 2000 + BUTTON_DEBOUNCE_MS)); +} + +// Back-to-back gestures: a HOLD must not poison the next press. After a hold+release, +// a fresh short press is a clean TAP again (the press edge re-arms the state). +static void test_hold_then_next_press_is_a_clean_tap(void) { + TransportButton tb; transport_button_reset(&tb); + transport_button_update(&tb, false, 0); + transport_button_update(&tb, true, 100); + uint32_t e1 = 100 + BUTTON_DEBOUNCE_MS; + transport_button_update(&tb, true, e1); // press edge + TEST_ASSERT_EQUAL_INT(BTN_GESTURE_HOLD, transport_button_update(&tb, true, e1 + TRANSPORT_BUTTON_HOLD_MS)); + transport_button_update(&tb, false, e1 + 1000); // release (bouncing) + transport_button_update(&tb, false, e1 + 1000 + BUTTON_DEBOUNCE_MS); // settled (silent) + uint32_t t2 = e1 + 2000; // second press: a tap + transport_button_update(&tb, true, t2); + uint32_t e2 = t2 + BUTTON_DEBOUNCE_MS; + transport_button_update(&tb, true, e2); // press edge + transport_button_update(&tb, false, e2 + 100); // release (bouncing) + TEST_ASSERT_EQUAL_INT(BTN_GESTURE_TAP, transport_button_update(&tb, false, e2 + 100 + BUTTON_DEBOUNCE_MS)); +} + +int main(void) { + UNITY_BEGIN(); + RUN_TEST(test_quick_press_release_is_a_tap); + RUN_TEST(test_long_hold_fires_once_then_silent_release); + RUN_TEST(test_hold_then_next_press_is_a_clean_tap); + return UNITY_END(); +}