diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..d86a280 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,439 @@ +name: CI + +# Host-side Unity tests (pure logic, no hardware) + firmware compiles so a change +# that passes host tests but breaks a real build is caught before merge: +# - X32Link (ESP32-S3, arduino-cli): headless (Super Mini) + Waveshare touch variants +# - X32_emulator (ESP32-S3 XIAO, arduino-cli): on-device X32 OSC test double +# - KitchenSync (ESP32-P4, ESP-IDF): the hub-tier target (see ADR-0005) +on: + push: + branches: [master] + pull_request: + +jobs: + # Docs-only PRs (tickets, docs/, any .md) don't need a firmware build. + # + # NOT done with `paths-ignore`: the four jobs below are REQUIRED checks, and a + # workflow skipped by a path filter never reports them — they hang Pending and the + # PR is blocked forever (GitHub documents this; it is the same failure mode as a + # stale required-check name). So the jobs always RUN and report; every step inside + # them is gated on this output instead. A docs-only PR goes green in seconds. + changes: + name: Detect code changes + runs-on: ubuntu-latest + outputs: + code: ${{ steps.f.outputs.code }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - id: f + run: | + # Pushes to master always build — only PRs get the docs shortcut. + if [ "${{ github.event_name }}" != "pull_request" ]; then + echo "code=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + # Anything that is not a ticket, a doc, or a .md counts as code. + if git diff --name-only "${{ github.event.pull_request.base.sha }}...HEAD" \ + | grep -qvE '(^tasks/|^docs/|\.md$)'; then + echo "code=true" >> "$GITHUB_OUTPUT" + else + echo "code=false" >> "$GITHUB_OUTPUT" + fi + + host-tests: + name: Host logic tests (Unity) + runs-on: ubuntu-latest + needs: changes + steps: + - uses: actions/checkout@v4 + if: needs.changes.outputs.code == 'true' + - name: Build and run host tests + if: needs.changes.outputs.code == 'true' + run: make -C test # Unity is vendored in lib/unity — no submodule step + - name: Run X32 emulator seam tests + if: needs.changes.outputs.code == 'true' + run: make -C tests run # native gcc — x32_port IP/defaults/path seam + + # --------------------------------------------------------------------------- + # Six sketch/board variants used to compile SEQUENTIALLY in one job -- 3m45, and + # the critical path for the whole workflow (everything else finished in ~2 min). + # They are independent, so they fan out. Wall time drops to one compile + setup. + # + # The caches are shared by key, so the matrix legs all restore the same warm + # ~/.arduino15 + core.a instead of rebuilding the ESP32 core seven times. + # --------------------------------------------------------------------------- + firmware-compile: + name: Compile ${{ matrix.name }} + runs-on: ubuntu-latest + needs: changes + if: needs.changes.outputs.code == 'true' + strategy: + fail-fast: false # one broken board flag must not hide the others + matrix: + include: + - name: X32Link (headless) + sketch: X32Link + fqbn: esp32:esp32:esp32s3:USBMode=hwcdc,CDCOnBoot=cdc,FlashSize=16M,PSRAM=opi,PartitionScheme=min_spiffs + flags: "" + - name: X32Link (Waveshare touch) + sketch: X32Link + fqbn: esp32:esp32:esp32s3:USBMode=hwcdc,CDCOnBoot=cdc,FlashSize=16M,PSRAM=opi,PartitionScheme=min_spiffs + flags: "-DBOARD_WAVESHARE_S3_TOUCH_LCD_147" + # QT Py variant dropped (ESP-037): its 4/8 MB flash never fit the fleet's 16 MB + # partition story, and it was the odd board out. Super Mini + Waveshare only. + - name: X32 emulator (ESP-002) + sketch: X32_emulator + fqbn: esp32:esp32:XIAO_ESP32S3:PSRAM=opi,FlashSize=8M,PartitionScheme=default_8MB,USBMode=hwcdc + flags: "" + # PartitionScheme=custom is what makes the core USE the sketch-local + # partitions.csv (16MB dual-OTA). Without it the core silently falls back + # to the 4MB default table -- which is how the sketch quietly hit 87% full. + - name: KitchenSyncTouch (S3 touch board) + sketch: KitchenSyncTouch + fqbn: esp32:esp32:esp32s3:USBMode=hwcdc,CDCOnBoot=cdc,FlashSize=16M,PSRAM=opi,PartitionScheme=custom + flags: "" + env: + CORE_CACHE: ${{ github.workspace }}/.arduino-core-cache + steps: + - uses: actions/checkout@v4 + + - name: Cache arduino-cli data (ESP32 core + libraries) + uses: actions/cache@v4 + with: + path: | + ~/.arduino15 + ~/Arduino + key: arduino-esp32-${{ runner.os }}-x32link-v1 + restore-keys: | + arduino-esp32-${{ runner.os }}- + + - name: Cache arduino-cli binary + id: cache-arduino-cli-bin + uses: actions/cache@v4 + with: + path: bin + key: arduino-cli-bin-${{ runner.os }}-v1 + + - name: Cache arduino compiled core + uses: actions/cache@v4 + with: + path: ${{ env.CORE_CACHE }} + key: arduino-core-${{ runner.os }}-x32link-v1 + restore-keys: | + arduino-core-${{ runner.os }}- + + - name: Install arduino-cli + if: steps.cache-arduino-cli-bin.outputs.cache-hit != 'true' + run: curl -fsSL https://raw.githubusercontent.com/arduino/arduino-cli/master/install.sh | sh -s + + - name: Add arduino-cli to PATH + run: echo "$GITHUB_WORKSPACE/bin" >> "$GITHUB_PATH" + + - name: Install ESP32 core + LovyanGFX + run: | + arduino-cli config init --overwrite --additional-urls https://raw.githubusercontent.com/espressif/arduino-esp32/gh-pages/package_esp32_index.json + arduino-cli core update-index + arduino-cli core install esp32:esp32 + arduino-cli lib install LovyanGFX + + - name: Compile + run: | + # build_opt.h is one -D per line. Space-separated in the matrix so there is no + # YAML escape semantics to get subtly wrong. + : > "${{ matrix.sketch }}/build_opt.h" + for f in ${{ matrix.flags }}; do echo "$f" >> "${{ matrix.sketch }}/build_opt.h"; done + echo "build_opt.h:"; cat "${{ matrix.sketch }}/build_opt.h" + arduino-cli compile --fqbn "${{ matrix.fqbn }}" \ + --build-cache-path "$CORE_CACHE" "${{ matrix.sketch }}" + + # The ESP-025 bench rig: KitchenSyncTouch on a CLASSIC ESP32 (no USB peripheral, no + # display, 4MB flash). It was not compiled by CI at all -- the board profile could + # break and only the bench would find out. build-bench.sh is the ONLY supported way + # to build it: it swaps the board flag AND the partition table together, because the + # product's 16MB S3 table on a 4MB chip boot-loops with no app and no serial banner. + bench-rig-compile: + name: Compile KitchenSyncTouch (ESP-025 bench rig, classic ESP32) + runs-on: ubuntu-latest + needs: changes + if: needs.changes.outputs.code == 'true' + env: + CORE_CACHE: ${{ github.workspace }}/.arduino-core-cache + steps: + - uses: actions/checkout@v4 + - name: Cache arduino-cli data (ESP32 core + libraries) + uses: actions/cache@v4 + with: + path: | + ~/.arduino15 + ~/Arduino + key: arduino-esp32-${{ runner.os }}-x32link-v1 + restore-keys: | + arduino-esp32-${{ runner.os }}- + - name: Cache arduino-cli binary + id: cache-arduino-cli-bin + uses: actions/cache@v4 + with: + path: bin + key: arduino-cli-bin-${{ runner.os }}-v1 + - name: Cache arduino compiled core + uses: actions/cache@v4 + with: + path: ${{ env.CORE_CACHE }} + key: arduino-core-${{ runner.os }}-x32link-v1 + restore-keys: | + arduino-core-${{ runner.os }}- + - name: Install arduino-cli + if: steps.cache-arduino-cli-bin.outputs.cache-hit != 'true' + run: curl -fsSL https://raw.githubusercontent.com/arduino/arduino-cli/master/install.sh | sh -s + - name: Add arduino-cli to PATH + run: echo "$GITHUB_WORKSPACE/bin" >> "$GITHUB_PATH" + - name: Install ESP32 core + run: | + arduino-cli config init --overwrite --additional-urls https://raw.githubusercontent.com/espressif/arduino-esp32/gh-pages/package_esp32_index.json + arduino-cli core update-index + arduino-cli core install esp32:esp32 + - name: Compile via tools/build-bench.sh + run: tools/build-bench.sh + - name: HEAD must still be the product board + run: | + grep -q '^#define BOARD_WAVESHARE_S3_TOUCH_LCD_147' KitchenSyncTouch/config.h \ + || { echo "build-bench.sh left the board flag swapped"; exit 1; } + head -1 KitchenSyncTouch/partitions.csv | grep -q '16 MB' \ + || { echo "build-bench.sh left the DevKit partition table in place"; exit 1; } + # ESP-036: the headless Super Mini variant. Same job (the S3 core is already + # installed), so it costs one extra compile, not another runner. Keeps the third + # board from rotting -- a #ifdef nobody compiles is a #ifdef that silently breaks. + - name: Compile headless Super Mini via tools/build-supermini.sh + run: tools/build-supermini.sh + - name: HEAD must still be the product board (after Super Mini build) + run: | + grep -q '^#define BOARD_WAVESHARE_S3_TOUCH_LCD_147' KitchenSyncTouch/config.h \ + || { echo "build-supermini.sh left the board flag swapped"; exit 1; } + + loralink-compile: + name: Firmware compile (LoraLink) + runs-on: ubuntu-latest + needs: changes + env: + # 8MB flash, no PSRAM (Meshnology N30 — different profile from X32Link's + # QT Py/Super Mini). CDCOnBoot must stay 'default': this board has no + # native USB, only a CP2102 UART bridge, so 'cdc' silently blackholes + # Serial. Confirmed on-device: CDCOnBoot=cdc gave a silent serial + # console; FlashSize=16M gave a boot-time spi_flash size-mismatch + # assert crash-loop. + FQBN: esp32:esp32:esp32s3:CDCOnBoot=default,FlashSize=8M,PSRAM=disabled + # Persisted core.a build cache — keyed separately from firmware-compile + # because this board's FQBN (8M/no-PSRAM) yields a different compiled core. + CORE_CACHE: ${{ github.workspace }}/.arduino-core-cache + steps: + - uses: actions/checkout@v4 + if: needs.changes.outputs.code == 'true' + + # See firmware-compile's matching step for why the key is static + versioned. + # restore-keys falls back to firmware-compile's cache (same esp32:esp32 core) + # on a cold cache instead of re-downloading the core from scratch. + - name: Cache arduino-cli data (ESP32 core + libraries) + if: needs.changes.outputs.code == 'true' + uses: actions/cache@v4 + with: + path: | + ~/.arduino15 + ~/Arduino + key: arduino-esp32-${{ runner.os }}-lora-v1 + restore-keys: | + arduino-esp32-${{ runner.os }}- + + - name: Cache arduino-cli binary + if: needs.changes.outputs.code == 'true' + id: cache-arduino-cli-bin + uses: actions/cache@v4 + with: + path: bin + key: arduino-cli-bin-${{ runner.os }}-v1 + + - name: Cache arduino compiled core + if: needs.changes.outputs.code == 'true' + uses: actions/cache@v4 + with: + path: ${{ env.CORE_CACHE }} + key: arduino-core-${{ runner.os }}-lora-v1 + restore-keys: | + arduino-core-${{ runner.os }}- + + - name: Install arduino-cli + if: needs.changes.outputs.code == 'true' && steps.cache-arduino-cli-bin.outputs.cache-hit != 'true' + run: curl -fsSL https://raw.githubusercontent.com/arduino/arduino-cli/master/install.sh | sh -s + + - name: Add arduino-cli to PATH + if: needs.changes.outputs.code == 'true' + run: echo "$GITHUB_WORKSPACE/bin" >> "$GITHUB_PATH" + + - name: Install ESP32 core + LoRa/OLED libs + if: needs.changes.outputs.code == 'true' + run: | + arduino-cli config init --overwrite --additional-urls https://raw.githubusercontent.com/espressif/arduino-esp32/gh-pages/package_esp32_index.json + arduino-cli core update-index + arduino-cli core install esp32:esp32 + arduino-cli lib install RadioLib U8g2 + + - name: Provide placeholder WiFi secrets for CI compile + if: needs.changes.outputs.code == 'true' + run: cp LoraLink/lora_secrets.h.example LoraLink/lora_secrets.h + + - name: Compile LoraLink (sender role) + if: needs.changes.outputs.code == 'true' + run: arduino-cli compile --fqbn "$FQBN" --build-cache-path "$CORE_CACHE" LoraLink + + - name: Compile LoraLink (receiver role) + if: needs.changes.outputs.code == 'true' + run: | + echo '-DLORA_ROLE_OVERRIDE=LORA_ROLE_RECEIVER' > LoraLink/build_opt.h + arduino-cli compile --fqbn "$FQBN" --build-cache-path "$CORE_CACHE" LoraLink + git checkout -- LoraLink/build_opt.h + + kitchensync-compile: + name: Firmware compile (KitchenSync, ESP-IDF) + runs-on: ubuntu-latest + needs: changes + steps: + - uses: actions/checkout@v4 + if: needs.changes.outputs.code == 'true' + + # Component-manager downloads (esp_hosted / esp_wifi_remote etc.), keyed on + # the lock file. Exact-match only: a dependencies.lock change forces a clean + # re-resolve rather than restoring a stale component set. + - name: Cache ESP-IDF managed components + if: needs.changes.outputs.code == 'true' + uses: actions/cache@v4 + with: + path: KitchenSync/managed_components + key: idf-managed-${{ runner.os }}-${{ hashFiles('KitchenSync/dependencies.lock') }} + + # ccache dir persists compiled objects across runs. Rolling key (newest run + # wins); restore-keys pulls master's latest onto PRs. ccache is content-hashed + # so a stale entry is simply a miss, never wrong output. + - name: Cache ESP-IDF ccache + if: needs.changes.outputs.code == 'true' + uses: actions/cache@v4 + with: + path: KitchenSync/.ccache + key: idf-ccache-${{ runner.os }}-${{ github.sha }} + restore-keys: | + idf-ccache-${{ runner.os }}- + + # Pre-create so a cold miss still has a dir to mount + save (no Path + # Validation warning), and so ccache has somewhere to write on run one. + - name: Prepare ccache dir + if: needs.changes.outputs.code == 'true' + run: mkdir -p KitchenSync/.ccache + + # Builds KitchenSync for the ESP32-P4 via the official ESP-IDF Docker action. + # idf.py auto-downloads any managed components (esp_hosted / esp_wifi_remote + # once the P4-004/005 glue lands). release-v5.5 has the esp32p4 min-rev gate. + # + # The action is *composite* and only forwards IDF_TARGET into the container, + # so ccache env must be injected via extra_docker_args (-e). It mounts the + # workspace at /app//, so CCACHE_DIR points there — that maps + # back to KitchenSync/.ccache on the host (the dir cached above). + # IDF_CCACHE_ENABLE makes idf.py launch the compiler through ccache; + # CCACHE_BASEDIR + NOHASHDIR keep hits alive across the per-run build path. + - name: Compile KitchenSync for esp32p4 + if: needs.changes.outputs.code == 'true' + uses: espressif/esp-idf-ci-action@v1 + with: + esp_idf_version: release-v5.5 + target: esp32p4 + path: 'KitchenSync' + extra_docker_args: >- + -e IDF_CCACHE_ENABLE=1 + -e CCACHE_DIR=/app/${{ github.repository }}/KitchenSync/.ccache + -e CCACHE_BASEDIR=/app/${{ github.repository }} + -e CCACHE_NOHASHDIR=1 + + # ESP-026: the SAME project, on the other two chips. This is the whole premise of the + # migration -- one ESP-IDF app, three board targets -- and without CI it rots in a week. + # + # The P4-only hardware (ES8311 speaker, USB-MIDI host) is gated out by target in + # main/CMakeLists.txt, and its headers hand back no-op stubs, so the app core compiles + # unchanged on all three. A build that fails here means somebody reached for P4 hardware + # from shared code. + # idf.py writes `sdkconfig` pinned to the target it built, and KitchenSync/sdkconfig is + # TRACKED (the P4's). Reusing the same directory for the next target makes CMake read a + # config for the wrong chip and die with exit 2. Wipe it between targets; each one + # regenerates from sdkconfig.defaults. + # + # sudo, because esp-idf-ci-action runs in a container AS ROOT -- the build tree it leaves + # behind is root-owned and the runner user cannot delete it. (Not matrixing this job on + # purpose: it would rename the "Firmware compile (KitchenSync, ESP-IDF)" status context, + # which branch protection REQUIRES, and orphan it -- auto-merge would then block forever + # with every check green.) + - name: Reset sdkconfig between targets + if: needs.changes.outputs.code == 'true' + run: sudo rm -rf KitchenSync/build KitchenSync/sdkconfig + + - name: Compile KitchenSync for esp32s3 (ESP-026 multi-target) + if: needs.changes.outputs.code == 'true' + uses: espressif/esp-idf-ci-action@v1 + with: + esp_idf_version: release-v5.5 + target: esp32s3 + path: 'KitchenSync' + + # idf.py writes `sdkconfig` pinned to the target it built, and KitchenSync/sdkconfig is + # TRACKED (the P4's). Reusing the same directory for the next target makes CMake read a + # config for the wrong chip and die with exit 2. Wipe it between targets; each one + # regenerates from sdkconfig.defaults. + # + # sudo, because esp-idf-ci-action runs in a container AS ROOT -- the build tree it leaves + # behind is root-owned and the runner user cannot delete it. (Not matrixing this job on + # purpose: it would rename the "Firmware compile (KitchenSync, ESP-IDF)" status context, + # which branch protection REQUIRES, and orphan it -- auto-merge would then block forever + # with every check green.) + - name: Reset sdkconfig between targets + if: needs.changes.outputs.code == 'true' + run: sudo rm -rf KitchenSync/build KitchenSync/sdkconfig + + - name: Compile KitchenSync for esp32 (ESP-026, the bench-rig chip) + if: needs.changes.outputs.code == 'true' + uses: espressif/esp-idf-ci-action@v1 + with: + esp_idf_version: release-v5.5 + target: esp32 + path: 'KitchenSync' + + # --------------------------------------------------------------------------- + # GATE. This job compiles nothing. + # + # Branch protection on master requires the status context + # "Firmware compile (X32Link + KitchenSyncTouch + X32 emulator)". That used to be a + # real job. Fanning it out into a matrix renamed it, so the required context stopped + # ever reporting -- and auto-merge sat BLOCKED forever with every check green. (It did.) + # + # So this keeps that exact name and gates on every compile leg instead. Now the matrix + # can grow, shrink or get renamed without anyone editing repo settings, and a green + # tick here genuinely means every board built. + # + # if: always() + explicit result checks -- `needs` alone would SKIP this job when a + # dependency fails, and a skipped required check is not a failing one, which would let a + # broken build merge. + # --------------------------------------------------------------------------- + firmware-compile-gate: + name: Firmware compile (X32Link + KitchenSyncTouch + X32 emulator) + runs-on: ubuntu-latest + needs: [changes, firmware-compile, bench-rig-compile] + if: always() + steps: + - name: All board compiles must have passed + run: | + echo "changes: ${{ needs.changes.result }}" + echo "firmware-compile: ${{ needs.firmware-compile.result }}" + echo "bench-rig: ${{ needs.bench-rig-compile.result }}" + # A docs-only PR skips the compiles by design -- that is a pass, not a failure. + if [ "${{ needs.changes.outputs.code }}" != "true" ]; then + echo "no code changed; compiles skipped by design"; exit 0 + fi + [ "${{ needs.firmware-compile.result }}" = "success" ] || exit 1 + [ "${{ needs.bench-rig-compile.result }}" = "success" ] || exit 1 + echo "every board compiled" diff --git a/.github/workflows/release-pages.yml b/.github/workflows/release-pages.yml new file mode 100644 index 0000000..373e3fa --- /dev/null +++ b/.github/workflows/release-pages.yml @@ -0,0 +1,108 @@ +# GitHub Pages web flasher — build every board variant on a tag and publish a page that +# flashes them straight from Chrome/Edge over WebSerial. Design: +# docs/plans/2026-07-10-github-pages-web-flasher-design.md +# +# Why this exists at all: a partition table can ONLY be written over USB. It cannot be +# changed over OTA. So this is not merely a convenience over `arduino-cli` -- it is the +# only delivery path that can fix a device's partition layout, or recover one that will +# not boot. That is also why it must exist before hardware reaches a customer. +# +# Tag-triggered ONLY, deliberately: it can never fail or block the required checks on the +# protected master branch. +name: Release — web flasher (GitHub Pages) + +on: + push: + tags: ['v*'] + workflow_dispatch: # let a release be re-published without re-tagging + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: pages + cancel-in-progress: false + +jobs: + build: + name: Build firmware + assemble site + runs-on: ubuntu-latest + env: + # X32Link's 4 variants: same FQBN, differing only by build_opt.h -- exactly as + # ci.yml already compiles them. min_spiffs so every flashed image lands on an + # OTA-capable table and can use /update afterwards without a second USB trip. + X32_FQBN: esp32:esp32:esp32s3:USBMode=hwcdc,CDCOnBoot=cdc,FlashSize=16M,PSRAM=opi,PartitionScheme=min_spiffs + # KitchenSync Touch ships its own partitions.csv (16MB, dual-OTA, 4MB app slots). + # PartitionScheme=custom is what makes the core USE that file -- without it the core + # silently falls back to its 4MB default table and the app slot is 1.25MB. + TOUCH_FQBN: esp32:esp32:esp32s3:USBMode=hwcdc,CDCOnBoot=cdc,FlashSize=16M,PSRAM=opi,PartitionScheme=custom + CORE_CACHE: ${{ github.workspace }}/.arduino-core-cache + steps: + - uses: actions/checkout@v4 + + - name: Cache arduino-cli data (ESP32 core + libraries) + uses: actions/cache@v4 + with: + path: | + ~/.arduino15 + ~/Arduino + key: arduino-esp32-${{ runner.os }}-x32link-v1 + restore-keys: | + arduino-esp32-${{ runner.os }}- + + - name: Cache arduino compiled core + uses: actions/cache@v4 + with: + path: ${{ env.CORE_CACHE }} + key: arduino-core-${{ runner.os }}-x32link-v1 + restore-keys: | + arduino-core-${{ runner.os }}- + + - name: Install arduino-cli + run: curl -fsSL https://raw.githubusercontent.com/arduino/arduino-cli/master/install.sh | sh -s + + - name: Add arduino-cli to PATH + run: echo "$GITHUB_WORKSPACE/bin" >> "$GITHUB_PATH" + + - name: Install ESP32 core + LovyanGFX + run: | + arduino-cli config init --overwrite --additional-urls https://raw.githubusercontent.com/espressif/arduino-esp32/gh-pages/package_esp32_index.json + arduino-cli core update-index + arduino-cli core install esp32:esp32 + arduino-cli lib install LovyanGFX + + # The version a release publishes is the TAG. One source of truth -- no second + # place to forget to bump. + - name: Resolve version + id: ver + run: echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT" + + - name: Build every variant + env: + VERSION: ${{ steps.ver.outputs.version }} + run: | + set -euo pipefail + bash tools/build-flasher-site.sh + + # Catch a broken release BEFORE it is published, not after someone's device is + # halfway through a flash. + - name: Validate manifests + run: python3 tools/validate_manifests.py site/firmware + + - uses: actions/configure-pages@v5 + - uses: actions/upload-pages-artifact@v3 + with: + path: site + + deploy: + name: Deploy to GitHub Pages + needs: build + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - id: deployment + uses: actions/deploy-pages@v4 diff --git a/.gitignore b/.gitignore index ded1168..fecaca6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,28 @@ # Arduino build output **/build/ -# Host unit-test binaries -test/test_app_config -test/test_bpm_tracker -test/test_link_protocol -test/test_osc_out -test/test_midi_bpm_calc +# Host unit-test binaries (test/test_foo executables; keep test_foo.c sources) +test/test_* +!test/test_*.c +test/*.dSYM/ + +# macOS / editor cruft +.DS_Store + +# LoraLink WiFi credentials (copy lora_secrets.h.example -> lora_secrets.h) +LoraLink/lora_secrets.h + +# ESP-IDF (KitchenSync) +sdkconfig +sdkconfig.old +managed_components/ +dependencies.lock +.worktrees/ +.gstack/ +tests/test_x32_port +*.dSYM/ +KitchenSyncTouch/secrets.h + +# Web-flasher site: built by tools/build-flasher-site.sh, published by the release +# workflow. Firmware binaries are release artifacts, never repo contents. +site/ diff --git a/.ordna/config.yaml b/.ordna/config.yaml index 09075fe..d57373b 100644 --- a/.ordna/config.yaml +++ b/.ordna/config.yaml @@ -1,5 +1,5 @@ -# Task prefixes in use: ESP (emulator), LNK (X32Link), MCK (MidiClock), TSV (ToastSaver-HW), -# FDR (X32FaderDisp), MUT (X32SafeMutes), ITT (MidiOscIttt) +# Task prefixes in use: LNK (X32Link), MCK (MidiClock, legacy), ESP (emulator), +# ARC (firmware architecture), P4 (ESP32-P4 hub tier) tasksDir: tasks idPrefix: ESP zeroPaddedIds: 3 diff --git a/.out-of-scope/behringer-wing-support.md b/.out-of-scope/behringer-wing-support.md new file mode 100644 index 0000000..46caac1 --- /dev/null +++ b/.out-of-scope/behringer-wing-support.md @@ -0,0 +1,42 @@ +# Behringer Wing Support + +This project does not target the Behringer Wing as a mixer for tempo/FX +control. Supported mixer targets are the X32 family (X32, XR18/X-Air). + +## Why this is out of scope + +The Wing is not an X32-family device. It speaks its own control protocol — an +OSC-style layer plus a text/`$`-style node command channel — with a different +address tree, a name-addressed node/param model instead of numeric +`/fx/N/par/MM` indices, and different value encodings. None of the X32-dialect +OSC layer (`osc_out`/`osc_in`, the model table mapping `MODEL_X32`/`MODEL_XR18` +to ports 10023/10024 and slot counts) carries over; Wing support means either +parameterizing that layer or writing a second dialect module behind the +tempo-source→OSC seam. + +Two durable reasons to decline rather than defer: + +1. **The project's scope is gear on the bench.** Every mixer feature shipped so + far was validated against a real XR18 with live OSC dumps. Nobody involved + owns a Wing, so the core research deliverables — real protocol dumps, FX + topology confirmation, tempo-param encodings — cannot be produced, only + transcribed from third-party docs and shipped unverified. That conflicts + with the project's measure-before-building discipline. +2. **A second protocol stack is a real architectural cost.** Carrying a Wing + dialect module means a second address model, second introspection path, and + second unit-conversion table to keep correct, for hardware no user of this + project has asked about since filing. + +## What would change this + +A Wing on the bench (or a collaborator supplying real control-channel dumps). +If that happens: delete this file and re-file the research ticket. The X32/XR18 +FX research (LNK-029) is deliberately structured as the reusable template — +introspect FX type → map tempo param + encoding → fan out to N targets with +per-target note division — so the Wing track would re-answer those questions +rather than start from scratch. + +## Prior requests + +- LNK-030 — "Research: Behringer Wing support (different OSC protocol) for + tempo/FX control" (filed 2026-07-04, declined in triage 2026-07-11) diff --git a/AGENTS.md b/AGENTS.md index d283bf7..249f2b2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,47 @@ -# esp32 — Agent Guide - -## What this firmware is +# link-devices — Agent Guide + +## Which firmware am I touching? (read this first) + +**`KitchenSync` is the primary product of this repo.** The others are *related* products +that share its engine — not scratch work, but not the thing either. + +The section below describes **X32Link**, which is one of those related products, NOT the +primary one — and `X32Link/` is *also* the shared-pure-C library home. That is exactly how +a clock-box feature ends up in the mixer bridge by accident. It has happened. + +**Every board is an ESP32 variant.** There is no Arduino hardware anywhere in this repo. +"Arduino" below means only the **build framework** — .ino sketches built with +`arduino-cli` against the `esp32:esp32` core — as opposed to **ESP-IDF** (CMake + +`idf.py`). Both target ESP32s. + +| Directory | What it is | Chip | Framework | +|---|---|---|---| +| **`KitchenSync/`** | **THE PRIMARY PRODUCT** — the Link-native clock box: Link in → MIDI/analog clock + transport out. | ESP32-P4 | ESP-IDF | +| `KitchenSyncTouch/` | The same clock engine, related product. Two boards: the S3 touch-LCD unit, and the **ESP-025 bench rig** (classic ESP32 DevKit + two lit buttons). Pick the board in its `config.h`; build the rig with `tools/build-bench.sh`. | ESP32-S3 / ESP32 | Arduino | +| `X32Link/` | Related product: tempo → **XR18/X32 mixer** FX-delay sync over OSC. **Also the shared-pure-C home (ADR-0003/0007)** — every other sketch symlinks modules out of here. | ESP32-S3 | Arduino | +| `LoraLink/` | Related product: LoRa tempo bridge. | ESP32-S3 | Arduino | +| `X32_emulator/` | X32 console emulator, for on-device tests. | ESP32-S3 | Arduino | +| `LinkAudioPoC/` | Link Audio feasibility spike. Also vendors the **Ableton Link SDK**. | ESP32-P4 | ESP-IDF | +| `tools/linkcli/` | Host-side Ableton Link peer — **required** for any device tempo/transport test. | — | host | + +The framework split is why the shared logic is **pure C** (ADR-0003): it is the only thing +both an Arduino sketch and an ESP-IDF component can compile. Firmware-specific glue is not +portable between them; the pure modules are. + +**The rule:** a **clock-box** feature (clock out, transport, buttons, MIDI/analog outputs) +goes in **KitchenSync** — or KitchenSyncTouch when it's the Arduino/bench side of the same +engine. Touch `X32Link/` only for (a) a *mixer/OSC* feature, or (b) a **shared pure +module** — and a shared module lives in `X32Link/` purely because arduino-cli can't compile +outside a sketch root. That is a build constraint, not a statement about which product +matters. + +> **This file is the day-to-day map.** For the whole-platform architecture (identity, +> timing pipeline, control-plane boundary, roadmap) see +> [`docs/architecture/system-overview.md`](docs/architecture/system-overview.md); decisions +> are in [`docs/adr/`](docs/adr/) (0003–0014, continue the numbering); the firmware↔client +> API is [`docs/contracts/firmware-http-contract.md`](docs/contracts/firmware-http-contract.md). + +## What X32Link is **Tempo → XR18/X32 FX delay sync.** One ESP32 firmware that reads a musical tempo from one of two sources and writes the matching delay time to an FX slot @@ -23,19 +64,61 @@ bridge and emulator only. Don't edit `cli/` when working here. | Action | Command | |---|---| -| Compile | `arduino-cli compile --fqbn esp32:esp32:adafruit_qtpy_esp32s3_n4r2 X32Link` | -| Flash | `arduino-cli upload --fqbn esp32:esp32:adafruit_qtpy_esp32s3_n4r2 -p /dev/ttyACM0 X32Link` | +| Compile | `arduino-cli compile --fqbn esp32:esp32:adafruit_qtpy_esp32s3_n4r2:PartitionScheme=min_spiffs X32Link` | +| Flash | `arduino-cli upload --fqbn esp32:esp32:adafruit_qtpy_esp32s3_n4r2:PartitionScheme=min_spiffs -p /dev/ttyACM0 X32Link` | | Host tests | `cd test && make` (Unity; pure-logic suites, runs on the dev box) | +| Emulator seam tests | `cd tests && make run` (native gcc — `x32_port`) | +| **Bench rig** (ESP-025) | `tools/build-bench.sh [port]` — KitchenSyncTouch on the classic ESP32 DevKit. Swaps the board flag **and** the partition table together and restores both on exit. Never hand-swap: the product's 16 MB table on the DevKit's 4 MB chip boot-loops with no app and no banner, which looks exactly like a brick | +| Bench Link peer | `cmake -S tools/linkcli -B tools/linkcli/build && cmake --build tools/linkcli/build`, then `./tools/linkcli/build/linkcli` (see **Bench Link peer** below — required for any device tempo/transport test) | +| Emulator compile | `arduino-cli compile --fqbn esp32:esp32:XIAO_ESP32S3:PSRAM=opi,FlashSize=8M,PartitionScheme=default_8MB,USBMode=hwcdc X32_emulator` | Notes: -- **Board:** ESP32-S3 Super Mini (and XIAO). We compile against the - `adafruit_qtpy_esp32s3_n4r2` FQBN — it's a generic S3 profile that boots - these boards with USB-CDC serial. The beat **LED is a plain LED on GPIO48, - active-HIGH** on the Super Mini (`X32Link.ino`: `LED_PIN_NUM`; define - `LED_ACTIVE_LOW` for XIAO, `LED_RGB` for a WS2812 board). +- **Board:** ESP32-S3 Super Mini (and XIAO), or a genuine Adafruit QT Py + ESP32-S3. We compile against the `adafruit_qtpy_esp32s3_n4r2` FQBN either + way — for the Super Mini/XIAO it's just a generic S3 profile that boots + those boards with USB-CDC serial; for the QT Py it's the real board. The + beat **LED is a plain LED on GPIO48, active-HIGH** on the Super Mini + (`X32Link.ino`: `LED_PIN_NUM`; define `LED_ACTIVE_LOW` for XIAO, `LED_RGB` + for a generic WS2812 board). On the **QT Py ESP32-S3** the beat LED is the + onboard NeoPixel on **GPIO39**, whose level shifter needs **GPIO38** driven + HIGH before it lights — define `BOARD_QTPY_ESP32S3` (implies `LED_RGB`, + sets `LED_PIN_NUM`/`LED_PWR_PIN_NUM`). These are compile-time flags with no + runtime board detection — set the right one in `X32Link/build_opt.h` + (tracked but meant to stay empty at HEAD; edit it locally for the unit in + hand, don't commit board-specific flags there) before compiling. +- **Battery (QT Py only):** if the unit has Adafruit's "LiPo BFF" (MAX17048 + fuel gauge, STEMMA QT) stacked under it, also define `HAS_BATTERY_GAUGE` in + `build_opt.h` alongside `BOARD_QTPY_ESP32S3` — separate flag, since not + every QT Py has one attached. Reads over I2C on the STEMMA QT bus (SDA=7, + SCL=6 — the real board's default `Wire` pins), surfaced in `/status` as + `batt_v`/`batt_pct` and shown in the web UI panel footer. See + `battery_gauge.{h,c}` / `battery_gauge_io.{h,cpp}` / `battery_snapshot.{h,c}`. - Native-USB boards re-enumerate `/dev/ttyACM0` on reset; serial capture is flaky right after flashing. The web UI `/status` endpoint is the reliable way to read live BPM. +- **`PartitionScheme=min_spiffs` is required for OTA** (hardware-verified + 2026-07-09): the QT Py FQBN's default scheme is `tinyuf2_noota` — no second + app slot, so `/update` fails with `Partition Could Not be Found`. A USB + flash that changes the partition scheme also relocates NVS → config + WiFi + creds reset to first-boot defaults (AP fallback). Back up the config from + the device's `/` page before repartitioning; after it, one `/save` POST + restores it. +- **Versioning (LNK-038):** `X32Link/fw_version.h` is the one source of truth + for `FW_VERSION` (shared by every firmware in this repo — KitchenSync via + include path, X32_emulator via symlink; the emulator's + `XVERSION "4.06"` is the *emulated console's* version, not ours — never + merge the two). Devices report it on the serial boot + banner + periodic status line, the web UI header/footer, `/status` JSON + (`"fw"` field — use this to audit deployed units), and the `/update` OTA + page. Release = bump `FW_VERSION` + `git tag v` on that commit, + so any distributed .bin traces back to source. +- **OTA deployment (LNK-034 / P4-017):** once a unit is on WiFi, push firmware + **without USB** via `http:///update`. X32Link: + `curl -F 'update=@X32Link/build/.../X32Link.ino.bin' http:///update` + (needs `PartitionScheme=min_spiffs` on first USB flash). KitchenSync: + `curl --data-binary @KitchenSync/build/kitchensync.bin http:///update`. + Audit before/after with `/status` → `"fw"`. Full agent playbook: + [`docs/ota-deployment.md`](docs/ota-deployment.md). ## Mixer output @@ -44,43 +127,116 @@ Notes: - Normalization lives in `osc_out.c:bpm_to_normalized()` — delay ms = 60000/BPM, clamped to 3000 ms, scaled to 0.0–1.0. +## Bench Link peer (`tools/linkcli`) + +**The firmware cannot form a Link session on its own.** `X32Link/link_protocol.c` is a +*listener* — it parses the gossip timeline but never transmits. A device alone on the +network therefore sees `peers:0`, and `/transport` returns 200 and then silently drops +the intent. `tools/linkcli` is the Ableton stand-in that makes the session real, and it +is a prerequisite for **any** device tempo or transport test. + +It is a genuine Link peer built against the vendored SDK (`LinkAudioPoC/third_party/link` +— asio is bundled at `modules/asio-standalone`, nothing to fetch). It drives +`ableton::Link` directly rather than through the SDK's `linkhut` audio engine, so there is +no CoreAudio dependency and no click track. Source: `tools/linkcli/main.cpp`. + +Keys: `↑`/`↓` tempo ±1, `←`/`→` tempo ±0.1, `space` start/stop, `q`/`Q` quantum, +`p` force beat 0 onto *now* (a known downbeat to trigger the analyzer on), `s` start/stop +sync, `a` Link on/off, `x` quit. Flags: `--tempo N`, `--quantum N`, `--play`. + +Two behaviours that will otherwise read as firmware bugs: + +- **A peer that joins an already-playing session stays stopped.** This is Link's design, + not a bug: start/stop is last-writer-wins by timestamp (`Controller.hpp:89`), and a + booting peer initialises its start/stop state with `timestamp = hostTime` + (`Controller.hpp:67`) — so its fresh "stopped" is *newer* than an earlier "playing" and + wins. Tempo, by contrast, *is* adopted by late joiners, which makes the asymmetry + extra confusing. **Form the session first, then press play.** A device that reboots + mid-test comes back stopped even though the session is playing — re-press play rather + than hunting for a transport bug. +- **It is a live peer.** Pressing play starts every Link app on the LAN (Ableton Live, + Note on a phone). A peer count higher than expected is usually a real device, not a bug. + +Ableton Link is GPLv2. A dev tool that is never distributed is unencumbered; shipping any +of it is a licensing question, not an engineering one. + ## Modules (roles — the directory is the source of truth for the file list) | Module | Job | |---|---| | `X32Link.ino` | app core: setup/loop, WiFi + AP fallback, FreeRTOS bpm/led tasks, factory reset | | `tempo_source.{h,cpp}` | the input **seam** — one interface, dispatches to Link or MIDI by `input_source` | -| `link_listener.*` + `link_protocol.*` | Link adapter; `link_protocol.c` is our own ~100-line gossip parser (the vendored `lib/link/` SDK is **not** used at runtime) | -| `midi_clock.*` · `midi_bpm.*` · `midi_bpm_calc.*` | USB-MIDI adapter; `midi_bpm_calc` is the pure, host-tested BPM math (symlinked from `X32MidiClock/`) | +| `link_listener.*` + `link_protocol.*` | Link adapter; `link_protocol.c` is our own ~100-line gossip parser — **receive-only, it never transmits**, so the firmware joins a session but cannot create one (see **Bench Link peer**). The vendored Ableton SDK (`LinkAudioPoC/third_party/link`) is **not** used at runtime — only by `tools/linkcli` on the host | +| `link_measurement.{h,c}` + `link_measurement_session.{h,c}` + `link_measurement_io.cpp` | Link measurement (ping/pong) client. `link_measurement.c` = pure TLV build/parse + median/offset math; `link_measurement_session.c` = pure orchestrator (LNK-031: peer targeting, re-measure, epoch-reset, watchdog — the policy where LNK-026 lived, host-tested via an action list); `link_measurement_io.cpp` = thin WiFiUDP glue executing the session's actions — pinger-only, no PingResponder | +| `midi_clock.*` · `midi_bpm.*` · `midi_bpm_calc.*` | USB-MIDI adapter; `midi_bpm_calc` is the pure, host-tested BPM math. `midi_clock` also owns the shared USBMIDI endpoint + `midi_clock_send_f8()` for clock OUT | +| `clock_ticker.{h,c}` | LNK-027/028 shared pure tick engine: quantizes `tempo_source_beats_now()` to N PPQN pulses (phase-locked, re-primes on re-origin) + a `BarReset` tracker that fires once per bar boundary (analog reset pulse). Host-tested | +| `beat_synth.{h,c}` | LNK-033 pure free-run beat generator (`60000/bpm` interval + edge-detect); `tempo_source_beat()` delegates to it. Host-tested | +| `config_persist.{h,c}` | ARC-022 pure debounce policy deciding **when** a live config edit is written to NVS: `mark()` on edit, `due()` from a low-priority poll. Config is one nvs blob, so a slider drag must collapse to one write; a quiet window settles a burst and a max-deferral bound stops a continuous stream from starving the write. Time is an argument — host-tested | +| `clock_output.{h,c}` | ARC-019 **the** clock-out derivation, all three firmwares: `clock_output_due()` applies swing (P4-013) + phase nudge + division (P4-010) over `clock_ticker` (KitchenSync's `ks_tick` fan-out), and `ClockOutput`/`clock_output_step()` is the 1ms-writer layer — owns the reset-on-invalid-phase rule, the lifetime `dropped` banking, and the shared burst cap `CLOCK_OUTPUT_MAX_BURST` (4; ESP-018 bench value — KitchenSync's plan loop deliberately runs `KS_TICK_MAX_BURST` 96 instead, catch-up over drop). Host-tested | +| `midi_clock_out.{h,c}` + `midi_clock_out_io.{h,cpp}` | LNK-027 Link→USB-MIDI clock OUT; `midi_clock_out.{h,c}` is a thin 24-PPQN adapter over `clock_ticker` (host-test shim; the writer tasks now go through `clock_output_step`, ARC-019), `midi_clock_out_io.cpp` is the 1ms FreeRTOS task + TinyUSB/DIN writes. ARC-024: carries the tick-health probe (gap/work/overruns/bursts/dropped/core + per-stage split), published as plain scalars and **never logged** — an `ESP_LOGx` in a 1 ms RT task is a blocking UART write (P4-033). Read out via `/status` | | `bpm_tracker.*` | change/threshold detection | +| `bar.{h,c}` | ARC-003: a bar is `quantum` beats — `bar_beats()` / `bar_ms()`; one place owns beats-per-bar so non-4/4 `quantum` scales refresh/resend (was a hardcoded `4 *`) | | `osc_out.*` / `osc_sender.*` | OSC packet build / UDP send to the mixer | | `app_config.*` · `app_config_nvs.cpp` | config struct, validation, NVS persistence (incl. `input_source`) | -| `web_config.*` | rack-panel config web UI + captive portal + `/status` live-BPM endpoint | +| `tempo_snapshot.{h,c}` | ARC-001 seam: atomic `{bpm,phase,valid,quantum}` — one writer (`bpm_task`), many readers (web `/status`, UI, serial); replaces the old loose `g_current_*` globals + mutex | +| `ui_chrome.{h,c}` | ARC-017 shared web chrome for both config UIs: `ui_chrome_css()` / `ui_chrome_js()` (static, sent as chunks) + `ui_result_page()` / `ui_update_page()` (snprintf builders). Pure C, no Arduino/ESP-IDF. The *forms* stay per-firmware; only the look + client plumbing is shared. Host-tested. **Never edit this to fix one page** — anything a single page needs goes in that page's own ` +
+ +
KITCHEN·SYNCESP32-P4 · FW %FWVER%
+
+
Session TempoAbleton Link
+
188.8--.-BPM
+
+
+
0
+
Waiting
+
——.— BPM
+
0 pulses
+
off
+
Stoppedspace
+ +
+
+
+
WiFi Networks +%WIFI%
+
MIDI Clock Out +
+
%OUTPUTS%
+
Metronome (Speaker) +
+
+
Accent Bar 1 +
+
Metronome Sound +
+
VOL
+
VOICE
+
+
LED Strip · Visual Metronome +
+
%LEDCTL%
+
Follow Beat (Mic) +
+
+
+ +
+
Everything and the kitchen sync · FW %FWVER% · %FWBUILD% · Firmware Update
+
+)HTML"; + +// Replace every "%KEY%" in s with val. +static void subst(std::string &s, const char *key, const std::string &val) +{ + size_t p; + while ((p = s.find(key)) != std::string::npos) s.replace(p, strlen(key), val); +} + +// Escape a string for use inside a double-quoted HTML attribute. An SSID is +// user-controlled text: a bare '"' in it would otherwise close the value= and let +// the rest of the name inject markup into the page. +static std::string attr_esc(const char *s) +{ + std::string o; + for (const char *p = s; *p; ++p) { + switch (*p) { + case '&': o += "&"; break; + case '"': o += """; break; + case '<': o += "<"; break; + case '>': o += ">"; break; + default: o += *p; break; + } + } + return o; +} + +// ESP-013: one SSID/PASS pair per saved network. Slot 0 keeps the unsuffixed field +// names; the rest are wifi_ssid1.. / wifi_pass1.. Clearing an SSID forgets that slot. +static std::string build_wifi() +{ + if (!s_cfg) return ""; + std::string o; + for (int i = 0; i < KS_WIFI_SLOTS; i++) { + std::string sfx = (i == 0) ? "" : std::to_string(i); + o += "
SSIDwifi[i].ssid) + "\" autocomplete=\"off\" placeholder=\"" + + (i == 0 ? "required" : "optional") + "\">
" + "
PASSwifi[i].ssid[0] ? "keep current" : "") + "\">
"; + } + return o; +} + +// Build the 4 per-output clock config rows (enable / cable / rate / phase nudge), +// with the live config marked checked/selected (P4-010). +static std::string build_outputs() +{ + if (!s_cfg) return ""; + static const char* PORTS[4] = { "USB A", "USB B", "USB C", "USB D" }; + static const int PPQN[] = { 24, 48, 12, 6, 4, 2, 1 }; + static const char* PPQN_LBL[] = { "MIDI clock (24)", "×2 (48)", "÷2 (12)", + "÷4 (6)", "1/16 (4)", "1/8 (2)", "1/4 (1)" }; + std::string s; + for (int o = 0; o < KS_CLOCK_OUTPUTS; o++) { + const ClockOutputCfg* c = &s_cfg->clock[o]; + std::string N = std::to_string(o); + s += "
Clock Out " + std::to_string(o + 1) + ""; + s += ""; + // ESP-011: one Touch-style toggle per output (stopped/arming/playing), plus + // the per-output transport master. Follow Link on => this output tracks the + // session and its toggle greys out; off => the toggle is yours. + // ESP-019: the key that fires this toggle is printed on it. Clock Out 1..4 are + // data-out 0..3, so the badge is the 1-based label the user already reads. + s += std::string("
RUN") + + "" + + "" + std::to_string(o + 1) + "
"; + s += std::string("" + + "
follow Link transport
"; + s += "
CABLE
"; + s += "
RATE
"; + s += std::string("
NUDGE") + + "" + + "phase_mbeats) + "\" min=\"-250\" max=\"250\" step=\"5\">" + + "
"; + s += std::string("
SWING") + + "" + + "swing_mbeats) + "\" min=\"0\" max=\"250\" step=\"5\">" + + "
"; + s += "
"; + } + return s; +} + +// The metronome VOICE options, with the live one marked `selected` (P4-029). +// +// This used to be static HTML in PAGE with no `selected` at all, so the dropdown ALWAYS +// rendered "Tone" no matter what was saved -- and %MVOICE% was substituted into a token +// that appeared nowhere, silently doing nothing. +// +// It was not merely cosmetic: NVS held the right voice (and the metronome SOUNDED right), +// but the form showed Tone. So the next Save for any unrelated reason -- a WiFi edit -- +// POSTed metro_voice=0 and wiped the saved voice back to Tone. A read-back bug that eats +// data on the next write. +// +// Generated like every other select in this file (build_outputs, build_led) rather than +// hand-written, which is exactly why those never had the bug. +static std::string build_voice() +{ + static const char* VOICES[3] = { "Tone", "Click", "Wood" }; + std::string s; + for (int v = 0; v < 3; v++) + s += ""; + return s; +} + +// LED-strip customization controls (P4-019): brightness, pattern, fade, colours — +// all on the /live path so they apply instantly with no reboot. +static std::string build_led() +{ + if (!s_cfg) return ""; + char col[8]; + static const char* MODES[3] = { "Chase", "Flash", "Fill" }; + std::string s = "
Strip Look"; + s += "
"; + s += "
BRIGHTled_brightness) + "\" min=\"0\" max=\"100\" step=\"5\">
"; + s += "
FADEled_fade) + "\" min=\"0\" max=\"100\" step=\"5\">
"; + s += "
"; + s += "
MODE
"; + s += "
"; + snprintf(col, sizeof(col), "#%06X", s_cfg->led_beat_color & 0xFFFFFF); + s += std::string("
BEAT
"; + snprintf(col, sizeof(col), "#%06X", s_cfg->led_accent_color & 0xFFFFFF); + s += std::string("
ACCENT
"; + s += "
"; + s += "
"; + return s; +} + +// Only the slice between %CSS% and %JS% needs per-request values. The chrome on +// either side goes out straight from static storage, never copied. +static std::string build_body(const char* begin, const char* end) +{ + std::string h(begin, end); + subst(h, "%WIFI%", build_wifi()); + subst(h, "%MCKCHK%", (s_cfg && s_cfg->clock_out_enable) ? "checked" : ""); + subst(h, "%MTOCHK%", (s_cfg && s_cfg->metronome_enable) ? "checked" : ""); + subst(h, "%MTACHK%", (s_cfg && s_cfg->metronome_accent) ? "checked" : ""); + subst(h, "%LEDCHK%", (s_cfg && s_cfg->led_enable) ? "checked" : ""); + subst(h, "%LEDCTL%", build_led()); + subst(h, "%FBCHK%", (s_cfg && s_cfg->follow_beat_enable) ? "checked" : ""); + subst(h, "%CLKSECT%", (s_cfg && s_cfg->clock_out_enable) ? "" : "hide"); + subst(h, "%METSECT%", (s_cfg && s_cfg->metronome_enable) ? "" : "hide"); + subst(h, "%LEDSECT%", (s_cfg && s_cfg->led_enable) ? "" : "hide"); + subst(h, "%MVOL%", std::to_string(s_cfg ? s_cfg->metronome_volume : 80)); + subst(h, "%MVOICE%", build_voice()); + subst(h, "%OUTPUTS%", build_outputs()); + subst(h, "%FWVER%", FW_VERSION); // LNK-038 + subst(h, "%FWBUILD%", FW_BUILD); + return h; +} + +// ARC-017: assembled from parts and sent chunked. The shared CSS and JS are ~4KB +// of static rodata that used to be copied into the page string on every request. +static esp_err_t root_handler(httpd_req_t *req) +{ + static const char CSS_MARK[] = "%CSS%", JS_MARK[] = "%JS%"; + const char *css = strstr(PAGE, CSS_MARK); + const char *js = strstr(PAGE, JS_MARK); + if (!css || !js) return ESP_FAIL; // template lost its markers + + httpd_resp_set_type(req, "text/html"); + httpd_resp_send_chunk(req, PAGE, css - PAGE); + httpd_resp_send_chunk(req, ui_chrome_css(), HTTPD_RESP_USE_STRLEN); + + std::string body = build_body(css + strlen(CSS_MARK), js); + httpd_resp_send_chunk(req, body.c_str(), body.size()); + + httpd_resp_send_chunk(req, ui_chrome_js(), HTTPD_RESP_USE_STRLEN); + httpd_resp_send_chunk(req, js + strlen(JS_MARK), HTTPD_RESP_USE_STRLEN); + return httpd_resp_send_chunk(req, NULL, 0); // terminate +} + +static esp_err_t status_handler(httpd_req_t *req) +{ + // P4-038 grew this by the tick-health block (~90 bytes worst case); 448 keeps the + // same headroom test_ks_status.c asserts against. + char buf[576]; + /* P4-038: the origin-step gauge. `max_step_us` is how far a GhostXForm commit has thrown + * the beat origin -- the number that, unread, cost 138 seconds of silent DIN clock in + * ESP-027. It survives a peer churn now (ESP-028 split invalidate from reset), so this + * is a lifetime record and not a fresh page every time a laptop closes. */ + LinkPhaseHealth phase; + link_measurement_phase_health(&phase); + bool fb_enabled = s_cfg && s_cfg->follow_beat_enable; + FollowBeatOut fb = fb_enabled ? follow_beat_io_status() : FollowBeatOut{}; + int ls[KS_CLOCK_OUTPUTS]; + for (int i = 0; i < KS_CLOCK_OUTPUTS; i++) ls[i] = s_launch[i]; + WebTickHealth tick; // P4-038: the 1 ms clock task's health + bool have_tick = ks_tick_health(&tick); + /* ESP-029: ks_status_json is now the SHARED builder (X32Link/ks_status.c), emitted by + * this firmware AND by KitchenSyncTouch, so the two can no longer drift apart. + * + * KS_CLOCK_OUTPUTS is passed as the launch COUNT — the array length IS the device's + * output count. A Touch passes 1. Never padded. + * + * clk/pulses are nullptr: this firmware does not yet publish ESP-028's writer's-truth + * fields (the Touch does). NULL OMITS them rather than faking a `clk:"locked"` over a + * writer we have not measured — which is exactly the lie ESP-028 exists to prevent. + * Wiring the P4's writer state in is follow-up; this device's wire shape is unchanged. */ + ks_status_json(buf, sizeof(buf), + ks_clock_effective_bpm(), midi_clock_in_bpm(esp_timer_get_time()), /* ESP-037: effective, not link_proto_bpm (zeroes solo) */ + wifi_link_peers(), usb_midi_host_ready(), usb_midi_host_tx(), + FW_VERSION, fb_enabled, fb.bpm, fb.confidence, fb.valid, + ls, KS_CLOCK_OUTPUTS, + link_proto_playing(), link_proto_start_stop_seen(), + have_tick ? &tick : nullptr, &phase, + nullptr, nullptr, nullptr); + httpd_resp_set_type(req, "application/json"); + return httpd_resp_send(req, buf, HTTPD_RESP_USE_STRLEN); +} + +// P4-041: GET /config.json — the read-side counterpart to POST /save and POST +// /live, for a client (the iOS companion app) that needs the device's ACTUAL +// current settings instead of scraping the rendered HTML form. Snapshot-under- +// mutex-then-format-outside-it, the same principle +// ks_web_config_persist_tick() uses: the clock task takes s_cfg_mutex every +// tick, so holding it across the JSON formatting (slower, and followed by a +// network write) would stall the clock for no reason. Unlike that function, +// `snap` is a plain stack local, not a file static — this handler runs on the +// httpd task (~4 KB stack, same context status_handler's 576-byte local buffer +// already lives on above), not the tighter 3 KB status task, and a static here +// would race two concurrently-handled requests. +static esp_err_t config_json_handler(httpd_req_t *req) +{ + if (!s_cfg) return ESP_FAIL; + KsConfig snap; + if (s_cfg_mutex) xSemaphoreTake(s_cfg_mutex, portMAX_DELAY); + snap = *s_cfg; + if (s_cfg_mutex) xSemaphoreGive(s_cfg_mutex); + + // Worst case (every wifi SSID + every clock field maxed) measures 692 bytes + // (test_fits_in_a_generously_sized_stack_buffer); 768 keeps the same kind of + // headroom test_ks_status.c's buffer asserts against (P4-038's precedent). + char buf[768]; + /* ESP-030: the P4 is fully fitted — speaker (P4-006), LED strip (P4-018), mic + * (P4-020), four clock outputs. A board without them simply does not emit those + * keys; nothing here fakes hardware. */ + static const KsCaps caps = { .metronome = true, .led = true, .follow_beat = true, + .outputs = KS_CLOCK_OUTPUTS, + .wifi_slots = KS_WIFI_SLOTS, /* ESP-035 */ + .settable_tempo = true }; /* ESP-037: clock box */ + ks_config_json(buf, sizeof(buf), &snap, &caps); + httpd_resp_set_type(req, "application/json"); + return httpd_resp_send(req, buf, HTTPD_RESP_USE_STRLEN); +} + +// ARC-017: the page itself is ui_result_page() — pure, shared with X32Link, tested. +// reboot=true makes it poll until the device answers again and then return to '/', +// so Write & Reboot doesn't strand the browser on /save. +static esp_err_t send_result(httpd_req_t *req, const char *title, const char *msg, bool reboot) +{ + /* Heap, not stack: the httpd task has ~4KB and this page is over 1KB. */ + char *p = (char *)malloc(RESULT_PAGE_MAX); + if (!p) return ESP_ERR_NO_MEM; + int n = ui_result_page(p, RESULT_PAGE_MAX, title, msg, reboot); + if (n < 0 || n >= RESULT_PAGE_MAX) { free(p); return ESP_FAIL; } // truncated + httpd_resp_set_type(req, "text/html"); + esp_err_t e = httpd_resp_send(req, p, n); + free(p); + return e; +} + +static esp_err_t save_handler(httpd_req_t *req) +{ + /* ESP-013: 3 wifi slots (32-char ssid + 63-char pass, each up to 3x longer + * url-encoded) plus 4 outputs x 5 fields plus the led/metronome fields. The old + * 1024-byte buffer no longer covers the worst case. + * + * On the HEAP, not the stack: the httpd task gets ~4KB total, so a buffer this + * size as a local overflows it and panics with a stack-protection fault the + * instant anyone saves. */ + const size_t BODY_MAX = 3072; + /* A truncated body used to be parsed anyway: the tail fields simply vanished and + * a half-applied config was saved as if the user had asked for it. Refuse instead. */ + if (req->content_len >= BODY_MAX) + return send_result(req, "Config Too Large", "Too many characters in the form. Shorten a field and retry.", false); + + char *body = (char *)malloc(req->content_len + 1); + if (!body) return send_result(req, "Out of Memory", "Try again.", false); + + int len = (int)req->content_len; + int got = 0; + while (got < len) { + int r = httpd_req_recv(req, body + got, len - got); + if (r <= 0) { free(body); return ESP_FAIL; } + got += r; + } + body[got] = '\0'; + + // Decode + parse the POST body into a candidate config (pure, host-tested). + KsConfig cfg; + ks_form_resolve(body, s_cfg, &cfg); + free(body); + + if (!ks_config_valid(&cfg)) return send_result(req, "Invalid Config", "Check the values and go back.", false); + *s_cfg = cfg; + ks_config_save(s_cfg); + esp_err_t rc = send_result(req, "Saved — Restarting", "Reconnect to WiFi if credentials changed.", true); + vTaskDelay(pdMS_TO_TICKS(800)); + esp_restart(); + return rc; +} + +// POST /live (P4-015): apply the live-safe fields into the running config with no +// NVS write and no reboot, so timing (phase nudge, division, swing) is audible the +// moment a control moves. The body is a PARTIAL form, so it's applied as a patch +// (only present keys change). wifi + metronome enable/volume/voice are NOT touched +// here — they need a reconnect / codec re-init and only /save changes them. +// ESP-011: POST /transport?out=N&play=1|0 (out=all for every enabled output). +// A press, not a level -- the clock task consumes it exactly once, and the pure +// transport_launch decides WHEN it becomes MIDI Start (next bar line) vs Stop +// (immediately). +static esp_err_t transport_handler(httpd_req_t *req) +{ + char q[64]; + if (httpd_req_get_url_query_str(req, q, sizeof(q)) != ESP_OK) { + httpd_resp_set_status(req, "400 Bad Request"); + return httpd_resp_send(req, "need ?out=N|all&play=1|0", HTTPD_RESP_USE_STRLEN); + } + char out_s[8] = {0}, play_s[4] = {0}; + httpd_query_key_value(q, "out", out_s, sizeof(out_s)); + httpd_query_key_value(q, "play", play_s, sizeof(play_s)); + + int out = (strcmp(out_s, "all") == 0) ? -1 : atoi(out_s); + if (out >= KS_CLOCK_OUTPUTS) { + httpd_resp_set_status(req, "400 Bad Request"); + return httpd_resp_send(req, "bad out", HTTPD_RESP_USE_STRLEN); + } + transport_intent_post(out, play_s[0] == '1' ? TL_INTENT_PLAY : TL_INTENT_STOP); + + httpd_resp_set_type(req, "text/plain"); + return httpd_resp_send(req, "ok", HTTPD_RESP_USE_STRLEN); +} + +static esp_err_t live_handler(httpd_req_t *req) +{ + char body[1024]; + int len = req->content_len < (int)sizeof(body) - 1 ? req->content_len : (int)sizeof(body) - 1; + int got = 0; + while (got < len) { + int r = httpd_req_recv(req, body + got, len - got); + if (r <= 0) return ESP_FAIL; + got += r; + } + body[got] = '\0'; + + KsConfig cand; + ks_form_apply(body, s_cfg, &cand); // patch onto the current live config + if (!ks_config_valid(&cand)) { + httpd_resp_set_status(req, "400 Bad Request"); + httpd_resp_set_type(req, "text/plain"); + return httpd_resp_send(req, "invalid", HTTPD_RESP_USE_STRLEN); + } + + // ARC-016: patch the live-safe fields into the running config under the mutex so + // the clock task never reads a torn multi-field update (the clock[] array copy + // was the widest window). ks_config_live_safe_copy owns the field set; the + // generation bump makes the task re-prime its grids so a phase/rate change + // realigns cleanly instead of dumping a catch-up burst. + if (s_cfg_mutex) xSemaphoreTake(s_cfg_mutex, portMAX_DELAY); + ks_config_live_safe_copy(s_cfg, &cand); + if (s_gen) (*s_gen)++; + if (s_cfg_mutex) xSemaphoreGive(s_cfg_mutex); + + // P4-029: re-render the tone bursts + re-set codec volume now, no reboot (no-op + // if the metronome was off at boot — the codec isn't up until then). + metronome_audio_set(s_cfg->metronome_volume, s_cfg->metronome_voice); + + // ARC-022: mark it, don't write it. NVS is one blob and a slider drag lands + // dozens of these, so writing here would blob-write flash tens of times per + // gesture. The write happens once the edits settle, on the status task. + config_persist_mark(&s_persist, (uint32_t)(esp_timer_get_time() / 1000)); + + httpd_resp_set_type(req, "text/plain"); + return httpd_resp_send(req, "ok", HTTPD_RESP_USE_STRLEN); +} + +// ARC-022: poll from a LOW-PRIORITY task, never from the clock writer. A flash +// write suspends the cache and freezes both cores regardless of priority, so this +// cannot be made safe by ranking it -- it can only be kept off the RT path and made +// rare. Debounce is what makes it rare. +// +// The snapshot is a file static, not a local: KsConfig is a few hundred bytes and +// the status task's stack is 3 KB. +void ks_web_config_persist_tick(void) +{ + if (!s_cfg) return; + + /* ESP-037: while a Link session is DRIVING, remember its tempo as ours -- the same + * behaviour the Touch has. Link teaches the box the tempo; when Link goes away the + * box keeps playing it (the arbiter's free-run seed, applied by the clock task) AND + * persists it, so a power-cycle comes back at the last tempo it actually played. + * + * Only while peers>0: solo, tempo_mbpm is the user's own set value and the clock task + * applies it, so mirroring then would fight it. Written under the config mutex; the + * clock task picks up the change on its next tick without a re-prime (no g_cfg_gen + * bump -- a tempo change is not a grid realign). Marked (debounced), not written. */ + if (wifi_link_peers() > 0) { + int eff = (int)((float)link_proto_bpm() * 1000.0f + 0.5f); + if (eff >= 20000 && eff <= 300000 && eff != s_cfg->tempo_mbpm) { + if (s_cfg_mutex) xSemaphoreTake(s_cfg_mutex, portMAX_DELAY); + s_cfg->tempo_mbpm = eff; + if (s_cfg_mutex) xSemaphoreGive(s_cfg_mutex); + config_persist_mark(&s_persist, (uint32_t)(esp_timer_get_time() / 1000)); + } + } + + if (!config_persist_due(&s_persist, (uint32_t)(esp_timer_get_time() / 1000))) return; + + // Copy under the mutex, write outside it: the clock task takes this mutex every + // tick, and holding it across a flash write would stall the clock for the whole + // write on top of the unavoidable cache freeze. + static KsConfig snap; + if (s_cfg_mutex) xSemaphoreTake(s_cfg_mutex, portMAX_DELAY); + snap = *s_cfg; + if (s_cfg_mutex) xSemaphoreGive(s_cfg_mutex); + + esp_err_t e = ks_config_save(&snap); + if (e != ESP_OK) ESP_LOGW(TAG, "live config save failed: %s", esp_err_to_name(e)); +} + +// P4-017: web-based OTA. The partition table is CONFIG_PARTITION_TABLE_TWO_OTA +// (otadata + ota_0 + ota_1, no factory slot), so a push always targets "the +// other" slot and boots into it on success — the running image is never +// touched until esp_ota_set_boot_partition commits. +/* ARC-017: the /update page is ui_update_page() — shared with X32Link. + * multipart=false selects the fetch(body:file) uploader that esp_ota_ops wants. */ + +static esp_err_t update_page_handler(httpd_req_t *req) +{ + char *page = (char *)malloc(UPDATE_PAGE_MAX); /* not the 4KB httpd stack */ + if (!page) return ESP_ERR_NO_MEM; + /* LNK-038: show what's about to be overwritten. */ + int n = ui_update_page(page, UPDATE_PAGE_MAX, "KitchenSync", FW_VERSION, FW_BUILD, false); + if (n < 0 || n >= UPDATE_PAGE_MAX) { free(page); return ESP_FAIL; } + httpd_resp_set_type(req, "text/html"); + esp_err_t e = httpd_resp_send(req, page, n); + free(page); + return e; +} + +static esp_err_t send_plain(httpd_req_t *req, const char *status, const char *msg) +{ + httpd_resp_set_status(req, status); + httpd_resp_set_type(req, "text/plain"); + return httpd_resp_send(req, msg, HTTPD_RESP_USE_STRLEN); +} + +// POST /update — the request body IS the raw .bin (the page's JS does +// fetch('/update',{method:'POST',body:file}), no multipart), streamed straight +// into esp_ota_ops chunk by chunk so the whole image never sits in RAM at once. +static esp_err_t update_handler(httpd_req_t *req) +{ + const esp_partition_t *target = esp_ota_get_next_update_partition(NULL); + if (!target) return send_plain(req, "500 Internal Server Error", "no OTA partition available"); + + esp_ota_handle_t handle = 0; + esp_err_t err = esp_ota_begin(target, OTA_SIZE_UNKNOWN, &handle); + if (err != ESP_OK) { + ESP_LOGE(TAG, "ota begin failed: %s", esp_err_to_name(err)); + return send_plain(req, "500 Internal Server Error", "ota begin failed"); + } + + char buf[4096]; + int remaining = req->content_len; + while (remaining > 0) { + int r = httpd_req_recv(req, buf, remaining < (int)sizeof(buf) ? remaining : (int)sizeof(buf)); + if (r <= 0) { + if (r == HTTPD_SOCK_ERR_TIMEOUT) continue; + esp_ota_abort(handle); + return send_plain(req, "500 Internal Server Error", "upload interrupted"); + } + err = esp_ota_write(handle, buf, r); + if (err != ESP_OK) { + ESP_LOGE(TAG, "ota write failed: %s", esp_err_to_name(err)); + esp_ota_abort(handle); + return send_plain(req, "500 Internal Server Error", "flash write failed"); + } + remaining -= r; + } + + err = esp_ota_end(handle); + if (err != ESP_OK) { + ESP_LOGE(TAG, "ota end failed: %s", esp_err_to_name(err)); + return send_plain(req, "500 Internal Server Error", + err == ESP_ERR_OTA_VALIDATE_FAILED ? "image validation failed" : "ota end failed"); + } + + err = esp_ota_set_boot_partition(target); + if (err != ESP_OK) { + ESP_LOGE(TAG, "set boot partition failed: %s", esp_err_to_name(err)); + return send_plain(req, "500 Internal Server Error", "set boot partition failed"); + } + + ESP_LOGI(TAG, "OTA ok, booting %s next", target->label); + esp_err_t rc = send_plain(req, "200 OK", "OK - rebooting"); + vTaskDelay(pdMS_TO_TICKS(500)); + esp_restart(); + return rc; +} + +void ks_web_start(KsConfig* cfg, volatile uint32_t* gen, SemaphoreHandle_t cfg_mutex) +{ + s_cfg = cfg; + s_gen = gen; + s_cfg_mutex = cfg_mutex; + config_persist_reset(&s_persist); // ARC-022: clean at boot — never write on the way up + httpd_handle_t server = NULL; + httpd_config_t hcfg = HTTPD_DEFAULT_CONFIG(); + // P4-037: purge the least-recently-used connection when httpd's pool is full, rather + // than refusing the new one. Defence in depth beside the real fix (LWIP_MAX_SOCKETS=16 + // in sdkconfig.defaults): the pool is 7 with 3 reserved internally, so 4 client slots, + // and a desktop browser opens up to 6 keep-alive connections to one host. + hcfg.lru_purge_enable = true; + if (httpd_start(&server, &hcfg) != ESP_OK) { ESP_LOGE(TAG, "httpd start failed"); return; } + httpd_uri_t root = { .uri = "/", .method = HTTP_GET, .handler = root_handler, .user_ctx = NULL }; + httpd_uri_t status = { .uri = "/status", .method = HTTP_GET, .handler = status_handler, .user_ctx = NULL }; + httpd_uri_t config_json= { .uri = "/config.json", .method = HTTP_GET, .handler = config_json_handler, .user_ctx = NULL }; + httpd_uri_t save = { .uri = "/save", .method = HTTP_POST, .handler = save_handler, .user_ctx = NULL }; + httpd_uri_t live = { .uri = "/live", .method = HTTP_POST, .handler = live_handler, .user_ctx = NULL }; + httpd_uri_t transport = { .uri = "/transport", .method = HTTP_POST, .handler = transport_handler, .user_ctx = NULL }; + httpd_uri_t update_get = { .uri = "/update", .method = HTTP_GET, .handler = update_page_handler, .user_ctx = NULL }; + httpd_uri_t update_post= { .uri = "/update", .method = HTTP_POST, .handler = update_handler, .user_ctx = NULL }; + httpd_register_uri_handler(server, &transport); + httpd_register_uri_handler(server, &root); + httpd_register_uri_handler(server, &status); + httpd_register_uri_handler(server, &config_json); + httpd_register_uri_handler(server, &save); + httpd_register_uri_handler(server, &live); + httpd_register_uri_handler(server, &update_get); + httpd_register_uri_handler(server, &update_post); + ESP_LOGI(TAG, "web UI on :80"); +} diff --git a/KitchenSync/main/ks_web.h b/KitchenSync/main/ks_web.h new file mode 100644 index 0000000..23476fc --- /dev/null +++ b/KitchenSync/main/ks_web.h @@ -0,0 +1,32 @@ +#pragma once +// KitchenSync web UI (P4-007): rack-panel status + config page styled like X32Link, +// served over esp_http_server. Thin glue; the /status JSON is pure +// ks_status.c and the config model is pure ks_config.c. +#include +#include "ks_config.h" +#include "freertos/FreeRTOS.h" +#include "freertos/semphr.h" +#ifdef __cplusplus +extern "C" { +#endif + +#include "ks_config.h" +// ESP-011: the clock task publishes each output's launch state (0 stopped / +// 1 armed / 2 running) so /status can render it. +void ks_web_publish_launch(const int state[KS_CLOCK_OUTPUTS]); +// Start the web server. cfg is the live config: read to render the page, fully +// rewritten + saved + rebooted on POST /save, and patched in place (live-safe +// fields only, no reboot) on POST /live (P4-015). `gen` is a config-generation +// counter bumped on every /live change so the clock task re-primes its grids; +// pass the address of a counter the clock task watches. Both must outlive the +// server. cfg_mutex (ARC-016) guards the /live patch against the clock task's read so +// the task never sees a torn multi-field update; both hold it only for the copy. +void ks_web_start(KsConfig* cfg, volatile uint32_t* gen, SemaphoreHandle_t cfg_mutex); +// ARC-022: write the config blob to NVS if the /live edits have settled (debounced; +// the policy is the pure config_persist module). Call it from a LOW-PRIORITY task -- +// a flash write freezes both cores, so it must never sit on the clock writer. Cheap +// and non-blocking when nothing is owed, so it's safe to poll often. +void ks_web_config_persist_tick(void); +#ifdef __cplusplus +} +#endif diff --git a/KitchenSync/main/link_measure_io.c b/KitchenSync/main/link_measure_io.c new file mode 100644 index 0000000..f812957 --- /dev/null +++ b/KitchenSync/main/link_measure_io.c @@ -0,0 +1,102 @@ +/* + * KitchenSync glue: ESP-IDF/lwip port of X32Link/link_measurement_io.cpp (P4-009). + * + * Unicast ping/pong exchange against a Link peer's mep4 endpoint (discovered by + * link_protocol.c's gossip parse) to derive this device's GhostXForm. All + * decision policy — peer targeting, re-measure scheduling, the transport + * re-origin (epoch) response, the RX flush, the silence watchdog — lives in the + * pure, host-tested link_measurement_session.c (LNK-031); the wire format + + * sample/offset math is link_measurement.c. This file owns only the socket, + * timing, and translating the session's actions into lwip I/O — the ESP-IDF + * twin of the S3's WiFiUDP glue (ADR-0003/0005). + * + * Socket style mirrors wifi_link.c's multicast listener, but this is a separate + * *unicast* conversation on an ephemeral local port (the multicast discovery + * socket owns port 20808). recv is non-blocking so this poll can run inside + * clock_out_task's 1 ms loop without ever stalling the clock generator; the + * session's 50 ms watchdog, not a blocking recv, sets the retry cadence. + */ +#include +#include +#include + +#include "lwip/sockets.h" +#include "esp_log.h" +#include "esp_timer.h" + +#include "link_measure_io.h" +#include "link_measurement.h" +#include "link_measurement_session.h" +#include "link_measure_pump.h" // ARC-014: shared poll behind a socket vtable +#include "link_protocol.h" + +static const char *TAG = "link_measure"; + +static int s_sock = -1; +static LinkSession s_session; + +/* esp_timer_get_time(), not millis()/micros() — microsecond precision matters in + * this path (same clock the measurement math is expressed in; see LNK-019). */ +static int64_t now_us(void) { return esp_timer_get_time(); } + +/* Open the unicast measurement socket: ephemeral local port, non-blocking. + * Separate from wifi_link.c's multicast socket. Returns true once open. */ +static bool open_socket(void) +{ + int sock = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP); + if (sock < 0) { ESP_LOGE(TAG, "socket errno %d", errno); return false; } + + struct sockaddr_in bind_addr = { + .sin_family = AF_INET, + .sin_port = htons(0), /* OS-assigned ephemeral port */ + .sin_addr.s_addr = htonl(INADDR_ANY), + }; + if (bind(sock, (struct sockaddr *)&bind_addr, sizeof(bind_addr)) < 0) { + ESP_LOGE(TAG, "bind errno %d", errno); close(sock); return false; + } + + /* Non-blocking: this poll runs in the 1 ms clock loop and must never block + * on recv. The session watchdog drives the retry/timeout cadence instead. */ + int flags = fcntl(sock, F_GETFL, 0); + if (flags < 0) flags = 0; + fcntl(sock, F_SETFL, flags | O_NONBLOCK); + + s_sock = sock; + link_session_reset(&s_session); + link_measurement_reset(); + ESP_LOGI(TAG, "unicast measurement socket open (ephemeral port)"); + return true; +} + +/* ARC-014: the poll sequence + action dispatch live in the shared link_measure_pump; + * this glue supplies only the three lwip socket ops. */ + +/* ip/port from link_proto_peer_endpoint() are wire-order be32 / host-order uint16; + * htonl/htons put them into the network byte order lwip's sockaddr_in expects. */ +static void io_send_to(uint32_t ip, uint16_t port, const uint8_t *buf, int len) +{ + struct sockaddr_in dest = { + .sin_family = AF_INET, + .sin_port = htons(port), + .sin_addr.s_addr = htonl(ip), + }; + sendto(s_sock, buf, (size_t)len, 0, (struct sockaddr *)&dest, sizeof(dest)); +} + +/* Non-blocking read of one pending datagram; <=0 when nothing is queued. */ +static int io_recv_one(uint8_t *buf, int cap) +{ + return recvfrom(s_sock, buf, (size_t)cap, 0, NULL, NULL); +} + +static const LinkMeasureOps s_ops = { + .send_to = io_send_to, + .recv_one = io_recv_one, + .now_us = now_us, +}; + +void link_measure_io_poll(void) +{ + if (s_sock < 0 && !open_socket()) return; + link_measure_pump(&s_session, &s_ops); +} diff --git a/KitchenSync/main/link_measure_io.h b/KitchenSync/main/link_measure_io.h new file mode 100644 index 0000000..f2905c1 --- /dev/null +++ b/KitchenSync/main/link_measure_io.h @@ -0,0 +1,28 @@ +#pragma once +// KitchenSync glue: ESP-IDF/lwip port of X32Link/link_measurement_io.cpp — the +// unicast ping/pong measurement client that derives this device's GhostXForm +// (host->ghost clock offset) from a Link peer's mep4 endpoint. All protocol +// logic (TLV build/parse, sample math, GhostXForm derivation) and all decision +// policy (peer targeting, re-measure scheduling, epoch-reset response, RX flush, +// silence watchdog) live in the pure, host-tested link_measurement.{h,c} + +// link_measurement_session.{h,c}, reused UNCHANGED (ADR-0003/0005). This file is +// socket + timing glue only — not host-testable (needs real lwip sockets). +// +// The committed GhostXForm is read back via link_measurement_current_xform() +// from link_measurement.h (module-global, same as the S3). + +#ifdef __cplusplus +extern "C" { +#endif + +// Service the measurement client once: detect the reference-peer trigger via +// link_proto_peer_endpoint(), drive the in-flight ping/pong exchange, the 50 ms +// watchdog retry, the 250 ms failure path, and the transport-reorigin (epoch) +// reset. Opens its own unicast UDP socket lazily on the first call (non-blocking, +// so it is safe to call from the 1 ms clock loop). Call every clock-loop tick, +// mirroring the S3's link_measurement_io_poll(). +void link_measure_io_poll(void); + +#ifdef __cplusplus +} +#endif diff --git a/KitchenSync/main/metronome_audio.c b/KitchenSync/main/metronome_audio.c new file mode 100644 index 0000000..6fef667 --- /dev/null +++ b/KitchenSync/main/metronome_audio.c @@ -0,0 +1,175 @@ +/* + * KitchenSync metronome audio glue (P4-006) — ES8311 codec + I2S tone bursts out the + * ESP32-P4-NANO onboard speaker. The click/accent DECISION is pure and + * host-tested (X32Link/metronome.c); this file only makes sound. + * + * Hardware (Waveshare ESP32-P4-NANO): ES8311 codec (I2C addr 0x18) feeding an + * NS4150B power amp on GPIO53 (active-high enable), owned directly by this + * file. The I2C bus, I2S_NUM_0 channel, and codec bring-up (pin map, MCLK/ + * BCLK/WS/DOUT/DIN, boot-time pin logging) moved to i2s_audio_bus.c (P4-020) + * -- that module is the single owner, shared with follow_beat_io.c's mic + * capture, since the two can't each independently master I2S_NUM_0. This + * file only enables the TX handle i2s_audio_bus.c already brought up. + * + * A short pre-rendered tone burst (sine + attack/release envelope, no pop) is + * written from a dedicated player task on a queued click event, so the 1 ms + * clock_out_task never blocks on the ~50 ms I2S write. + */ +#include "metronome_audio.h" +#include "metronome_voice.h" /* click/accent tone presets (P4-012) */ +#include "i2s_audio_bus.h" /* shared I2S_NUM_0 + ES8311 codec owner (P4-020) */ + +#include +#include +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include "freertos/queue.h" +#include "driver/gpio.h" +#include "driver/i2s_std.h" +#include "esp_log.h" +#include "es8311.h" + +static const char *TAG = "metro_audio"; + +/* --- ESP32-P4-NANO pin map (see file header) --------------------------------- */ +#define PIN_PA_ENABLE GPIO_NUM_53 + +/* --- Audio format -------------------------------------------------------------- + * Tied to the shared bus's constant (both are 16000) so this file can never + * silently drift out of sync with the I2S/codec bring-up in i2s_audio_bus.c. */ +#define SAMPLE_RATE AUDIO_BUS_SAMPLE_RATE + +/* --- Tone bursts: pitch + length are runtime (voice preset, P4-012); the amp is + * fixed per role. Buffers are sized for the longest voice; s_*_frames holds the + * live length. --------------------------------------------------------------- */ +#define CLICK_AMP 0.28f +#define ACCENT_AMP 0.55f +#define MAX_MS 64 +#define MAX_FRAMES (SAMPLE_RATE * MAX_MS / 1000) + +static int16_t s_click[MAX_FRAMES * 2]; +static int16_t s_accent[MAX_FRAMES * 2]; +static int s_click_frames = 0; +static int s_accent_frames = 0; +static int s_volume = 80; /* 0..100, set at start from config (P4-012) */ + +static i2s_chan_handle_t s_tx = NULL; +static QueueHandle_t s_queue = NULL; +static volatile bool s_ready = false; + +/* Render a stereo tone burst with a short linear attack/release so the speaker + * never gets a step edge (which would pop). */ +static void render_burst(int16_t *buf, int frames, float freq, float amp) +{ + const int attack = SAMPLE_RATE * 4 / 1000; /* 4 ms */ + const int release = SAMPLE_RATE * 8 / 1000; /* 8 ms */ + for (int i = 0; i < frames; i++) { + float env = 1.0f; + if (i < attack) env = (float)i / (float)attack; + else if (i >= frames - release) env = (float)(frames - i) / (float)release; + float s = sinf(2.0f * (float)M_PI * freq * (float)i / (float)SAMPLE_RATE) * amp * env; + int16_t v = (int16_t)(s * 32767.0f); + buf[2 * i] = v; /* L */ + buf[2 * i + 1] = v; /* R */ + } +} + +static void player_task(void *arg) +{ + bool accent; + while (1) { + if (xQueueReceive(s_queue, &accent, portMAX_DELAY) != pdTRUE) continue; + int16_t *buf = accent ? s_accent : s_click; + size_t nbytes = (size_t)(accent ? s_accent_frames : s_click_frames) * 2 * sizeof(int16_t); + size_t written = 0; + esp_err_t e = i2s_channel_write(s_tx, buf, nbytes, &written, pdMS_TO_TICKS(200)); + if (e != ESP_OK) ESP_LOGW(TAG, "i2s write failed: %s", esp_err_to_name(e)); + } +} + +void metronome_audio_start(int volume, int voice) +{ + if (!audio_bus_ready()) { + ESP_LOGE(TAG, "audio bus not ready -- metronome muted (call audio_bus_init() first)"); + return; + } + s_volume = volume < 0 ? 0 : (volume > 100 ? 100 : volume); + + /* Voice preset -> click/accent pitch + length (P4-012). Lengths are clamped + * to the buffer; the amp stays fixed per role. */ + float click_hz, accent_hz; + int click_ms, accent_ms; + metronome_voice_params(voice, &click_hz, &click_ms, &accent_hz, &accent_ms); + s_click_frames = SAMPLE_RATE * click_ms / 1000; + s_accent_frames = SAMPLE_RATE * accent_ms / 1000; + if (s_click_frames > MAX_FRAMES) s_click_frames = MAX_FRAMES; + if (s_accent_frames > MAX_FRAMES) s_accent_frames = MAX_FRAMES; + + ESP_LOGI(TAG, "metronome audio: ES8311 codec + I2S out the onboard speaker vol=%d voice=%d", s_volume, voice); + + render_burst(s_click, s_click_frames, click_hz, CLICK_AMP); + render_burst(s_accent, s_accent_frames, accent_hz, ACCENT_AMP); + + /* NS4150B power amp enable (active-high). */ + gpio_config_t pa = { + .pin_bit_mask = 1ULL << PIN_PA_ENABLE, + .mode = GPIO_MODE_OUTPUT, + .pull_up_en = GPIO_PULLUP_DISABLE, + .pull_down_en = GPIO_PULLDOWN_DISABLE, + .intr_type = GPIO_INTR_DISABLE, + }; + gpio_config(&pa); + gpio_set_level(PIN_PA_ENABLE, 1); + + // TX is already enabled by audio_bus_init() -- it's the shared clock + // driver for the whole bus (see i2s_audio_bus.c), not something this + // feature owns anymore. Hardware validation (2026-07-09) found that + // gating TX-enable on metronome_enable left RX with no clock at all + // whenever the metronome was off. + s_tx = audio_bus_tx(); + esp_err_t e = es8311_voice_volume_set(audio_bus_codec(), s_volume, NULL); + if (e != ESP_OK) { ESP_LOGE(TAG, "codec volume set failed: %s -- metronome muted", esp_err_to_name(e)); return; } + + s_queue = xQueueCreate(4, sizeof(bool)); + if (!s_queue) { ESP_LOGE(TAG, "queue alloc failed -- metronome muted"); return; } + if (xTaskCreate(player_task, "metro_click", 4096, NULL, 5, NULL) != pdPASS) { + ESP_LOGE(TAG, "player task create failed -- metronome muted"); + return; + } + s_ready = true; + ESP_LOGI(TAG, "metronome audio ready"); +} + +bool metronome_audio_ready(void) { return s_ready; } + +/* P4-029: re-apply volume + voice at runtime (no reboot) so the web /live path can + * preview them like the LED colours. No-op until _ready() — the codec/I2S are only + * brought up at boot when the metronome was enabled, so enabling it still needs a + * reboot; only volume/voice of an already-running metronome are live. Re-renders the + * two short bursts and re-sets the codec volume; a click landing mid-re-render is at + * worst one slightly-off burst, which is fine for a user-driven config edit. */ +void metronome_audio_set(int volume, int voice) +{ + if (!s_ready) return; + s_volume = volume < 0 ? 0 : (volume > 100 ? 100 : volume); + es8311_voice_volume_set(audio_bus_codec(), s_volume, NULL); + + float click_hz, accent_hz; + int click_ms, accent_ms; + metronome_voice_params(voice, &click_hz, &click_ms, &accent_hz, &accent_ms); + int cf = SAMPLE_RATE * click_ms / 1000; + int af = SAMPLE_RATE * accent_ms / 1000; + if (cf > MAX_FRAMES) cf = MAX_FRAMES; + if (af > MAX_FRAMES) af = MAX_FRAMES; + render_burst(s_click, cf, click_hz, CLICK_AMP); + render_burst(s_accent, af, accent_hz, ACCENT_AMP); + s_click_frames = cf; /* publish lengths after the buffers are rendered */ + s_accent_frames = af; + ESP_LOGI(TAG, "metronome audio live: vol=%d voice=%d", s_volume, voice); +} + +void metronome_audio_click(bool accent) +{ + if (!s_ready) return; + (void)xQueueSend(s_queue, &accent, 0); /* non-blocking; drop if backed up */ +} diff --git a/KitchenSync/main/metronome_audio.h b/KitchenSync/main/metronome_audio.h new file mode 100644 index 0000000..0a8a3aa --- /dev/null +++ b/KitchenSync/main/metronome_audio.h @@ -0,0 +1,49 @@ +#pragma once + +/* ESP-026: the onboard speaker (P4-006) exists only on the P4-NANO. On the S3 / classic ESP32 the + * hardware is not fitted, so the header hands back no-op stubs and the APP CORE + * compiles unchanged -- no #ifdefs sprayed through ks_main.c / ks_web.cpp. Same shape + * as HAS_USB_MIDI / HAS_TOUCH_DISPLAY / LED_NONE elsewhere in this repo (ADR-0003: + * the glue is thin and per-target; the logic is pure and shared). */ +#include "sdkconfig.h" +#if CONFIG_IDF_TARGET_ESP32P4 +// KitchenSync audio glue (P4-006): the ES8311 codec + I2S tone-burst side of the +// metronome. Pure scheduling (when to click / accent) lives in the host-tested +// X32Link/metronome.c; this file is the platform-specific sound. Thin per +// ADR-0003. See metronome_audio.c for the ES8311/I2S pin assumptions (logged at +// boot) — verified against the Waveshare ESP32-P4-NANO 12_I2SCodec example. +#include +#ifdef __cplusplus +extern "C" { +#endif + +// Bring up the ES8311 codec + I2S TX and spawn the click-player task. Call once +// at boot (only when the metronome is enabled). On any codec/I2S failure it logs +// and leaves the feature disabled — the rest of the firmware runs unaffected. +void metronome_audio_start(int volume, int voice); + +// True once the codec + I2S came up successfully. +bool metronome_audio_ready(void); + +// P4-029: re-apply volume (0..100) + voice preset at runtime, no reboot (the web +// /live path). No-op until _ready() — enabling a metronome that was off at boot +// still needs a reboot to bring up the codec. +void metronome_audio_set(int volume, int voice); + +// Enqueue one click for the player task. Non-blocking (drops if the queue is +// full, which shouldn't happen at musical tempos); a no-op until _ready(). +// accent = the louder/higher bar-downbeat tone. +void metronome_audio_click(bool accent); + +#ifdef __cplusplus +} +#endif + +#else /* hardware not fitted on this board */ +#include +static inline void metronome_audio_start(int vol, int voice) { (void)vol; (void)voice; } +static inline bool metronome_audio_ready(void) { return false; } +static inline void metronome_audio_set(int vol, int voice) { (void)vol; (void)voice; } +static inline void metronome_audio_click(bool accent) { (void)accent; } + +#endif diff --git a/KitchenSync/main/midi_clock_in.c b/KitchenSync/main/midi_clock_in.c new file mode 100644 index 0000000..5764c46 --- /dev/null +++ b/KitchenSync/main/midi_clock_in.c @@ -0,0 +1,41 @@ +#include "midi_clock_in.h" +#include "midi_bpm_calc.h" + +// Ring of the last WINDOW pulse timestamps + a running total. The newest sits at +// (head-1); the oldest of the last WINDOW at head once the ring has filled. +static int64_t s_ts[MIDI_CLOCK_IN_WINDOW]; +static int s_head = 0; // next write slot +static uint32_t s_count = 0; // total pulses since reset +static int64_t s_last_us = 0; // timestamp of the newest pulse + +void midi_clock_in_reset(void) { + s_head = 0; + s_count = 0; + s_last_us = 0; +} + +void midi_clock_in_pulse(int64_t ts_us) { + s_ts[s_head] = ts_us; + s_head = (s_head + 1) % MIDI_CLOCK_IN_WINDOW; + s_last_us = ts_us; + s_count++; +} + +uint32_t midi_clock_in_pulse_count(void) { + return s_count; +} + +float midi_clock_in_bpm(int64_t now_us) { + if (s_count < (uint32_t)MIDI_CLOCK_IN_WINDOW) return 0.0f; + if (now_us - s_last_us > MIDI_CLOCK_IN_TIMEOUT_US) return 0.0f; // clock stopped + + // The WINDOW timestamps span (WINDOW-1) intervals. Newest is the slot just + // written (s_head-1); the oldest still in the window is s_head (about to be + // overwritten next, i.e. WINDOW-1 pulses back). + int newest = (s_head - 1 + MIDI_CLOCK_IN_WINDOW) % MIDI_CLOCK_IN_WINDOW; + int oldest = s_head; + int64_t span = s_ts[newest] - s_ts[oldest]; + if (span <= 0) return 0.0f; + + return midi_bpm_calc((uint32_t)span, MIDI_CLOCK_IN_WINDOW); // pure, host-tested +} diff --git a/KitchenSync/main/midi_clock_in.h b/KitchenSync/main/midi_clock_in.h new file mode 100644 index 0000000..29ad0a1 --- /dev/null +++ b/KitchenSync/main/midi_clock_in.h @@ -0,0 +1,40 @@ +#pragma once +// Pure MIDI-clock-IN tracker (P4-011): accumulates 24-PPQN timing pulses (0xF8) +// arriving on the USB-MIDI host IN endpoint and derives a stable BPM. The BPM +// math is the reused, host-tested midi_bpm_calc.c (24-PPQN pulse span -> BPM); +// this module owns the pulse ring + total count + stop-timeout. No ESP-IDF deps, +// so it is host-tested in test/test_midi_clock_in.c. The thin glue in +// usb_midi_host.c calls midi_clock_in_pulse() from its IN callback; the web +// status reads midi_clock_in_bpm(). (Publishing that tempo into Link is P4-011 +// stage 2.) +#include +#include +#ifdef __cplusplus +extern "C" { +#endif + +// Pulses averaged for a BPM reading: exactly one beat of 24 PPQN. midi_bpm_calc +// bakes in "window intervals == one beat", so this MUST be 24 for correct BPM (a +// wider window would scale the tempo). us-precise esp_timer timestamps on the P4 +// make one beat of averaging enough; the S3's jitter smoothing isn't needed. +#define MIDI_CLOCK_IN_WINDOW 24 +// No pulse for this long -> the clock is considered stopped (BPM 0). +#define MIDI_CLOCK_IN_TIMEOUT_US 2000000 + +void midi_clock_in_reset(void); + +// Record one 24-PPQN timing pulse observed at ts_us (a monotonic microsecond +// clock, e.g. esp_timer_get_time()). Cheap + lock-free: a single writer. +void midi_clock_in_pulse(int64_t ts_us); + +// Total pulses seen since reset (a running 24-PPQN count -> beat = count/24). +uint32_t midi_clock_in_pulse_count(void); + +// Current BPM, or 0.0f if fewer than MIDI_CLOCK_IN_WINDOW pulses have arrived or +// the last pulse is older than MIDI_CLOCK_IN_TIMEOUT_US (clock stopped). now_us +// is the same clock domain as midi_clock_in_pulse(). +float midi_clock_in_bpm(int64_t now_us); + +#ifdef __cplusplus +} +#endif diff --git a/KitchenSync/main/midi_uart_out.c b/KitchenSync/main/midi_uart_out.c new file mode 100644 index 0000000..eaf19eb --- /dev/null +++ b/KitchenSync/main/midi_uart_out.c @@ -0,0 +1,63 @@ +// Hardware MIDI OUT over UART1 + downbeat strobe — see midi_uart_out.h (ESP-015). +#include "midi_uart_out.h" +#include "driver/uart.h" +#include "driver/gpio.h" +#include "esp_log.h" + +static const char *TAG = "midi_uart_out"; + +// UART0 is the console; 1..4 are free. UART1 carries this TX now and can add RX +// (MIDI IN, ESP-016) on the same controller later. +#define MIDI_UART UART_NUM_1 +#define MIDI_BAUD 31250 +#define MIDI_TX_RING 256 // small: a mirror that falls seconds behind is useless + +static bool s_ready; +static int s_strobe = -1; + +void midi_uart_out_start(int tx_gpio, int strobe_gpio) +{ + const uart_config_t cfg = { + .baud_rate = MIDI_BAUD, + .data_bits = UART_DATA_8_BITS, + .parity = UART_PARITY_DISABLE, + .stop_bits = UART_STOP_BITS_1, + .flow_ctrl = UART_HW_FLOWCTRL_DISABLE, + .source_clk = UART_SCLK_DEFAULT, // 31250 divides cleanly -> no baud error + }; + esp_err_t e = uart_driver_install(MIDI_UART, MIDI_TX_RING, MIDI_TX_RING, 0, NULL, 0); + if (e == ESP_OK) e = uart_param_config(MIDI_UART, &cfg); + if (e == ESP_OK) e = uart_set_pin(MIDI_UART, tx_gpio, UART_PIN_NO_CHANGE, + UART_PIN_NO_CHANGE, UART_PIN_NO_CHANGE); + if (e != ESP_OK) { ESP_LOGE(TAG, "UART init failed: %s", esp_err_to_name(e)); return; } + + if (strobe_gpio >= 0) { + gpio_config_t gc = { + .pin_bit_mask = 1ULL << strobe_gpio, + .mode = GPIO_MODE_OUTPUT, + .pull_up_en = GPIO_PULLUP_DISABLE, + .pull_down_en = GPIO_PULLDOWN_DISABLE, + .intr_type = GPIO_INTR_DISABLE, + }; + gpio_config(&gc); + gpio_set_level(strobe_gpio, 0); + s_strobe = strobe_gpio; + } + s_ready = true; + ESP_LOGI(TAG, "MIDI OUT on GPIO%d @ %d baud, downbeat strobe GPIO%d", + tx_gpio, MIDI_BAUD, strobe_gpio); +} + +void midi_uart_out_byte(uint8_t status) +{ + if (!s_ready) return; + // One byte into the driver's TX ring; the peripheral shifts it out under + // interrupt. Never uart_wait_tx_done() here -- that would burn 320us of the + // 1 ms clock budget waiting for the shift register to drain. + uart_write_bytes(MIDI_UART, &status, 1); +} + +void midi_uart_out_strobe(bool level) +{ + if (s_strobe >= 0) gpio_set_level(s_strobe, level ? 1 : 0); +} diff --git a/KitchenSync/main/midi_uart_out.h b/KitchenSync/main/midi_uart_out.h new file mode 100644 index 0000000..3e95959 --- /dev/null +++ b/KitchenSync/main/midi_uart_out.h @@ -0,0 +1,39 @@ +#pragma once +// ESP-015: hardware MIDI OUT — a DIN-style byte stream on a GPIO, mirroring the +// USB-MIDI clock/transport the P4 already emits. MIDI on a wire is just UART at +// 31250 baud, 8N1, and a System Real-Time message (0xF8 clock / 0xFA start / +// 0xFB continue / 0xFC stop) is a single status byte -- no USB-MIDI 4-byte +// packing. This is thin glue over the UART peripheral; the bytes come from the +// pure clock engine (usb_midi_pack owns the USB side, this owns the wire). +// +// It also drives a "downbeat strobe" GPIO: one pulse per bar (from +// ks_tick's plan.downbeat), so a logic analyzer can trigger on the bar line and +// measure how far the emitted 0xFA sits from it (ESP-011, never measured). +// +// Impure (ESP-IDF UART + GPIO); not host-tested. The decisions it acts on +// (plan.downbeat, the byte values) are pure and tested in ks_tick / usb_midi_pack. +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +// Bring up UART1 at 31250/8N1 with TX on `tx_gpio`, and configure `strobe_gpio` +// as a plain output for the analyzer trigger. Pins route through the GPIO matrix, +// so any free pin works. Pass strobe_gpio < 0 to skip the strobe. +void midi_uart_out_start(int tx_gpio, int strobe_gpio); + +// Mirror one MIDI status byte onto the wire. Fire-and-forget: copies into the +// UART driver's TX ring and returns without waiting for the shift-out, so it +// never blocks the 1 ms clock task. A no-op until _start(). +void midi_uart_out_byte(uint8_t status); + +// Drive the downbeat strobe to `level`. The caller passes plan.downbeat every +// tick, so the line is high for the one ~1 ms tick that crossed the bar line and +// low otherwise -- a clean rising edge to trigger on. A no-op if no strobe pin. +void midi_uart_out_strobe(bool level); + +#ifdef __cplusplus +} +#endif 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/KitchenSync/main/transport_intent.c b/KitchenSync/main/transport_intent.c new file mode 100644 index 0000000..39938f7 --- /dev/null +++ b/KitchenSync/main/transport_intent.c @@ -0,0 +1,27 @@ +// See transport_intent.h (ESP-011). Thin glue: a tiny mailbox between the HTTP +// task and the clock task. +#include "transport_intent.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" + +static portMUX_TYPE s_lock = portMUX_INITIALIZER_UNLOCKED; +static TransportLaunchIntent s_pending[KS_CLOCK_OUTPUTS]; + +void transport_intent_post(int out, TransportLaunchIntent intent) { + portENTER_CRITICAL(&s_lock); + if (out < 0) { + for (int i = 0; i < KS_CLOCK_OUTPUTS; i++) s_pending[i] = intent; + } else if (out < KS_CLOCK_OUTPUTS) { + s_pending[out] = intent; + } + portEXIT_CRITICAL(&s_lock); +} + +void transport_intent_take(TransportLaunchIntent dst[KS_CLOCK_OUTPUTS]) { + portENTER_CRITICAL(&s_lock); + for (int i = 0; i < KS_CLOCK_OUTPUTS; i++) { + dst[i] = s_pending[i]; + s_pending[i] = TL_INTENT_NONE; // consume: one press, one action + } + portEXIT_CRITICAL(&s_lock); +} diff --git a/KitchenSync/main/transport_intent.h b/KitchenSync/main/transport_intent.h new file mode 100644 index 0000000..960d1e3 --- /dev/null +++ b/KitchenSync/main/transport_intent.h @@ -0,0 +1,20 @@ +#pragma once +// One-shot transport intents from the web UI to the clock task (ESP-011). +// The HTTP handler posts a button press; the 1 ms clock loop takes it exactly +// once and hands it to ks_tick_step. A press must never be applied twice (two +// MIDI Starts) nor dropped, so take() clears as it reads. +#include "ks_config.h" +#include "transport_launch.h" +#ifdef __cplusplus +extern "C" { +#endif + +// Post a press for output `out` (0..KS_CLOCK_OUTPUTS-1), or out < 0 for all. +void transport_intent_post(int out, TransportLaunchIntent intent); + +// Move the pending intents into `dst` and clear them. Called once per tick. +void transport_intent_take(TransportLaunchIntent dst[KS_CLOCK_OUTPUTS]); + +#ifdef __cplusplus +} +#endif diff --git a/KitchenSync/main/usb_midi_host.c b/KitchenSync/main/usb_midi_host.c new file mode 100644 index 0000000..cce2ff8 --- /dev/null +++ b/KitchenSync/main/usb_midi_host.c @@ -0,0 +1,170 @@ +/* + * KitchenSync glue: USB-MIDI host (P4-003/005). Enumerates a USB-MIDI device on the + * Type-A OTG port, claims its MIDIStreaming interface (Audio class / subclass + * 0x03), and sends 4-byte USB-MIDI event packets out the bulk OUT endpoint. + * Proven in the scratchpad p4_midi_clock spike. Packet *encoding* lives in the + * pure usb_midi_pack.c; this file is the USB I/O. + */ +#include +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include "esp_log.h" +#include "esp_timer.h" +#include "usb/usb_host.h" +#include "usb_midi_host.h" +#include "midi_clock_in.h" /* feed incoming 0xF8 timing to the BPM tracker (P4-011) */ + +static const char *TAG = "usb_midi_host"; + +static usb_host_client_handle_t s_client = NULL; +static usb_device_handle_t s_dev = NULL; +static volatile uint8_t s_new_addr = 0; +static volatile bool s_new_dev = false; +static volatile bool s_gone = false; +static volatile bool s_ready = false; + +static uint8_t s_iface = 0, s_ep_out = 0, s_ep_in = 0; +static uint16_t s_in_mps = 64; + +static usb_transfer_t *s_out = NULL, *s_in = NULL; +static volatile bool s_out_busy = false; +static volatile uint32_t s_tx = 0, s_out_err = 0, s_dropped = 0; +static volatile uint32_t s_rx_clocks = 0; /* 0xF8 seen on IN (loopback proof) */ + +/* ---- transfer callbacks (client-event context) --------------------------- */ +static void out_cb(usb_transfer_t *t) +{ + if (t->status == USB_TRANSFER_STATUS_COMPLETED) s_tx++; else s_out_err++; + s_out_busy = false; +} +static void in_cb(usb_transfer_t *t) +{ + if (t->status == USB_TRANSFER_STATUS_COMPLETED) { + int64_t now = esp_timer_get_time(); + for (int i = 0; i + 1 < t->actual_num_bytes; i += 4) + if (t->data_buffer[i + 1] == 0xF8) { + s_rx_clocks++; /* loopback proof counter */ + midi_clock_in_pulse(now); /* -> BPM tracker (P4-011) */ + } + } + if (s_ready) { t->num_bytes = s_in_mps; usb_host_transfer_submit(t); } +} + +static void client_cb(const usb_host_client_event_msg_t *msg, void *arg) +{ + if (msg->event == USB_HOST_CLIENT_EVENT_NEW_DEV) { + s_new_addr = msg->new_dev.address; s_new_dev = true; + } else if (msg->event == USB_HOST_CLIENT_EVENT_DEV_GONE) { + s_gone = true; + } +} + +/* Find the MIDIStreaming interface (Audio class 0x01 / subclass 0x03) + its bulk + * OUT and IN endpoints in the config descriptor. */ +static bool find_midi_endpoints(const usb_config_desc_t *cfg) +{ + const uint8_t *p = (const uint8_t *)cfg, *end = p + cfg->wTotalLength; + bool in_midi = false, got_out = false, got_in = false; + while (p + 2 <= end && p[0] >= 2) { + uint8_t bLen = p[0], bType = p[1]; + if (bType == 0x04 && bLen >= 9) { + in_midi = (p[5] == 0x01 && p[6] == 0x03); + if (in_midi) s_iface = p[2]; + } else if (in_midi && bType == 0x05 && bLen >= 7) { + uint8_t addr = p[2], attr = p[3]; + uint16_t mps = p[4] | (p[5] << 8); + if ((attr & 0x03) == 0x02) { + if (addr & 0x80) { s_ep_in = addr; s_in_mps = mps ? mps : 64; got_in = true; } + else { s_ep_out = addr; got_out = true; } + } + } + p += bLen; + } + return got_out && got_in; +} + +static void setup_device(void) +{ + ESP_ERROR_CHECK(usb_host_device_open(s_client, s_new_addr, &s_dev)); + const usb_config_desc_t *cfg; + ESP_ERROR_CHECK(usb_host_get_active_config_descriptor(s_dev, &cfg)); + if (!find_midi_endpoints(cfg)) { + ESP_LOGE(TAG, "no MIDIStreaming endpoints"); + usb_host_device_close(s_client, s_dev); s_dev = NULL; return; + } + ESP_ERROR_CHECK(usb_host_interface_claim(s_client, s_dev, s_iface, 0)); + ESP_ERROR_CHECK(usb_host_transfer_alloc(64, 0, &s_out)); + ESP_ERROR_CHECK(usb_host_transfer_alloc(64, 0, &s_in)); + s_out->device_handle = s_dev; s_out->bEndpointAddress = s_ep_out; s_out->callback = out_cb; + s_in->device_handle = s_dev; s_in->bEndpointAddress = s_ep_in; s_in->callback = in_cb; + s_ready = true; + s_in->num_bytes = s_in_mps; + usb_host_transfer_submit(s_in); + ESP_LOGI(TAG, "USB-MIDI ready: iface %u OUT 0x%02x IN 0x%02x", s_iface, s_ep_out, s_ep_in); +} + +static void teardown_device(void) +{ + s_ready = false; + if (s_out) { usb_host_transfer_free(s_out); s_out = NULL; } + if (s_in) { usb_host_transfer_free(s_in); s_in = NULL; } + if (s_dev) { + usb_host_interface_release(s_client, s_dev, s_iface); + usb_host_device_close(s_client, s_dev); + s_dev = NULL; + } + ESP_LOGW(TAG, "USB-MIDI device gone"); +} + +static void usb_lib_task(void *arg) +{ + while (1) { uint32_t f; usb_host_lib_handle_events(portMAX_DELAY, &f); } +} + +static void client_task(void *arg) +{ + const usb_host_client_config_t cc = { + .is_synchronous = false, .max_num_event_msg = 5, + .async = { .client_event_callback = client_cb, .callback_arg = NULL }, + }; + ESP_ERROR_CHECK(usb_host_client_register(&cc, &s_client)); + while (1) { + usb_host_client_handle_events(s_client, pdMS_TO_TICKS(50)); + if (s_new_dev) { s_new_dev = false; setup_device(); } + if (s_gone) { s_gone = false; teardown_device(); } + } +} + +/* ---- public API ---------------------------------------------------------- */ +void usb_midi_host_start(void) +{ + const usb_host_config_t hc = { .intr_flags = ESP_INTR_FLAG_LEVEL1 }; + ESP_ERROR_CHECK(usb_host_install(&hc)); + xTaskCreate(usb_lib_task, "usb_lib", 4096, NULL, 5, NULL); + xTaskCreate(client_task, "usb_cli", 4096, NULL, 5, NULL); +} + +bool usb_midi_host_ready(void) { return s_ready; } + +uint32_t usb_midi_host_rx_clocks(void) { return s_rx_clocks; } + +uint32_t usb_midi_host_tx(void) { return s_tx; } + +/* P4-034: returns false when the endpoint is BUSY so the caller can hold its batch and + * retry next tick instead of losing it. This used to be void and silently dropped -- + * with one in-flight transfer (~1 ms) and the fan-out calling it once per output + * back-to-back inside a 1 ms tick, ~3 of every 4 packets were discarded and only the + * first output ever reached USB gear. Callers must now batch a tick's events into ONE + * transfer (usb_midi_batch) and honour this return value. */ +bool usb_midi_host_send(const uint8_t* data, int len) +{ + if (!s_ready || len <= 0 || len > 64) return false; + if (s_out_busy) { s_dropped++; return false; } /* previous transfer still in flight */ + memcpy(s_out->data_buffer, data, len); + s_out->num_bytes = len; + s_out_busy = true; + if (usb_host_transfer_submit(s_out) != ESP_OK) { s_out_busy = false; s_out_err++; return false; } + return true; +} + +uint32_t usb_midi_host_dropped(void) { return s_dropped; } diff --git a/KitchenSync/main/usb_midi_host.h b/KitchenSync/main/usb_midi_host.h new file mode 100644 index 0000000..58475c1 --- /dev/null +++ b/KitchenSync/main/usb_midi_host.h @@ -0,0 +1,60 @@ +#pragma once + +/* ESP-026: the USB-MIDI host (P4-003/005) exists only on the P4-NANO. On the S3 / classic ESP32 the + * hardware is not fitted, so the header hands back no-op stubs and the APP CORE + * compiles unchanged -- no #ifdefs sprayed through ks_main.c / ks_web.cpp. Same shape + * as HAS_USB_MIDI / HAS_TOUCH_DISPLAY / LED_NONE elsewhere in this repo (ADR-0003: + * the glue is thin and per-target; the logic is pure and shared). */ +#include "sdkconfig.h" +#if CONFIG_IDF_TARGET_ESP32P4 +// KitchenSync glue: USB-MIDI host on the P4 Type-A OTG port. Enumerates a USB-MIDI +// device, claims its MIDIStreaming interface, and sends 4-byte USB-MIDI event +// packets out the bulk OUT endpoint. Proven in the scratchpad p4_midi_clock +// spike (P4-003). Packet encoding is the pure, host-tested usb_midi_pack.c. +#include +#include +#ifdef __cplusplus +extern "C" { +#endif + +// Install the USB host stack and start enumerating. Non-blocking. +void usb_midi_host_start(void); + +// True once a USB-MIDI device is enumerated and its interface claimed. +bool usb_midi_host_ready(void); + +// Submit a pre-encoded USB-MIDI event packet (typically 4 bytes) to the device. +// No-op if no device is ready. Non-blocking. +// Submit one bulk transfer (up to 64 bytes = 16 USB-MIDI events). Returns false when +// the endpoint is busy or not ready -- the caller keeps its batch and retries next tick +// (P4-034). Do NOT call this once per output: batch the tick's events (usb_midi_batch) +// and submit them together, or all but the first output get dropped. +bool usb_midi_host_send(const uint8_t* data, int len); + +// Packets refused because a transfer was already in flight. Surfaced in /status so a +// silent drop can never hide again (P4-034). +uint32_t usb_midi_host_dropped(void); + +// Count of 0xF8 timing-clock bytes seen on the device's IN endpoint — nonzero +// only if the device loops USB-in back to USB-out (a Midihub loopback preset), +// giving an end-to-end send+receive proof. +uint32_t usb_midi_host_rx_clocks(void); + +// Count of clock packets sent out to the device. +uint32_t usb_midi_host_tx(void); + +#ifdef __cplusplus +} +#endif + +#else /* hardware not fitted on this board */ +#include +#include +static inline void usb_midi_host_start(void) {} +static inline bool usb_midi_host_ready(void) { return false; } +static inline bool usb_midi_host_send(const uint8_t* d, int n) { (void)d; (void)n; return false; } +static inline uint32_t usb_midi_host_dropped(void) { return 0; } +static inline uint32_t usb_midi_host_rx_clocks(void) { return 0; } +static inline uint32_t usb_midi_host_tx(void) { return 0; } + +#endif diff --git a/KitchenSync/main/wifi_link.c b/KitchenSync/main/wifi_link.c new file mode 100644 index 0000000..8e9746c --- /dev/null +++ b/KitchenSync/main/wifi_link.c @@ -0,0 +1,283 @@ +/* + * KitchenSync glue: WiFi station (C6 hosted stack) + Ableton Link multicast listener. + * + * The gossip parsing + tempo/timeline math is the pure, host-tested + * link_protocol.c (reused unchanged). This file is thin I/O: associate to WiFi, + * join the Link group, and feed datagrams to the parser. Proven in the + * scratchpad p4_wifi_link spike (P4-004). + */ +#include +#include "lwip/sockets.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include "esp_event.h" +#include "esp_log.h" +#include "esp_timer.h" +#include "esp_wifi.h" +#include "esp_netif.h" +#include "esp_mac.h" +#include "mdns.h" +#include "fw_version.h" /* ESP-031: FW_VERSION in the mDNS TXT record */ +#include "wifi_link.h" +#include "wifi_conn_policy.h" // ARC-013: shared connection lifecycle +#include "ks_hostname.h" // ESP-012: pure per-unit name derivation + +static const char *TAG = "wifi_link"; + +#define LINK_MCAST_ADDR "224.76.78.75" /* Ableton Link session multicast group */ +#define LINK_PORT 20808 + +/* link_protocol.c calls this weak platform hook for a millisecond clock. */ +uint32_t link_proto_millis(void) { return (uint32_t)(esp_timer_get_time() / 1000); } + +/* ---- Link multicast listener --------------------------------------------- */ + +static int open_link_socket(void) +{ + int sock = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP); + if (sock < 0) { ESP_LOGE(TAG, "socket errno %d", errno); return -1; } + + int yes = 1; + setsockopt(sock, SOL_SOCKET, SO_REUSEADDR, &yes, sizeof(yes)); + + struct sockaddr_in bind_addr = { + .sin_family = AF_INET, + .sin_port = htons(LINK_PORT), + .sin_addr.s_addr = htonl(INADDR_ANY), + }; + if (bind(sock, (struct sockaddr *)&bind_addr, sizeof(bind_addr)) < 0) { + ESP_LOGE(TAG, "bind errno %d", errno); close(sock); return -1; + } + + struct ip_mreq mreq = {0}; + mreq.imr_multiaddr.s_addr = inet_addr(LINK_MCAST_ADDR); + mreq.imr_interface.s_addr = htonl(INADDR_ANY); + if (setsockopt(sock, IPPROTO_IP, IP_ADD_MEMBERSHIP, &mreq, sizeof(mreq)) < 0) { + ESP_LOGE(TAG, "join errno %d", errno); close(sock); return -1; + } + + struct timeval tv = { .tv_sec = 0, .tv_usec = 200000 }; + setsockopt(sock, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv)); + ESP_LOGI(TAG, "joined %s:%d", LINK_MCAST_ADDR, LINK_PORT); + return sock; +} + +static void link_task(void *arg) +{ + link_proto_reset(); + int sock = open_link_socket(); + if (sock < 0) { vTaskDelete(NULL); return; } + + uint8_t buf[512]; + int64_t last_tick = 0; + while (1) { + int n = recvfrom(sock, buf, sizeof(buf), 0, NULL, NULL); + if (n > 0) link_proto_parse(buf, n); + + int64_t now = esp_timer_get_time(); + if (now - last_tick >= 100000) { last_tick = now; link_proto_tick(); } + } +} + +/* ---- WiFi station -------------------------------------------------------- */ + +#define AP_SSID "KitchenSync-Setup" + +/* ARC-013: the whole connection lifecycle (give-up budget, retry-forever-after-IP, + * spawn-listener-once, AP-terminal) is the pure wifi_conn_policy; this glue just + * translates esp_wifi events and executes the returned action. */ +static WifiConnPolicy s_pol; + +/* ESP-013: the compacted saved networks, copied at start so the caller's KsConfig + * need not outlive us. s_pol.slot indexes this array. */ +static WifiCred s_creds[KS_WIFI_SLOTS]; +static int s_ncreds; + +/* ---- mDNS (ESP-012) ------------------------------------------------------- */ + +/* Advertise `kitchensync-XXXX.local` (per-unit, from the MAC, so two boards on one + * LAN never collide) plus the plain `kitchensync.local` as a delegated alias for + * the common single-board case. Called once we have an address -- either a DHCP + * lease or the SoftAP's own 192.168.4.1. */ +static char s_host[KS_HOSTNAME_MAX]; +static bool s_mdns_up; + +static void mdns_advertise(esp_ip4_addr_t ip) +{ + if (!s_mdns_up) { + uint8_t mac[6] = {0}; + /* On the P4 the WiFi MAC belongs to the onboard C6, not to this chip's + * efuse, so esp_read_mac(ESP_MAC_WIFI_STA) fails and every unit would + * name itself kitchensync-0000 -- exactly the collision the per-unit name + * exists to prevent. Ask the (remote) WiFi stack, which is started by the + * time we have an address. Fall back to the P4's own base MAC, still + * unique per board. */ + esp_err_t m = esp_wifi_get_mac(WIFI_IF_STA, mac); + if (m != ESP_OK) m = esp_wifi_get_mac(WIFI_IF_AP, mac); /* SoftAP fallback path */ + if (m != ESP_OK) m = esp_read_mac(mac, ESP_MAC_BASE); + if (m != ESP_OK) + ESP_LOGW(TAG, "no MAC available; hostname collides if two units share a LAN"); + ks_hostname(mac, s_host, sizeof(s_host)); + + esp_err_t e = mdns_init(); + if (e != ESP_OK) { ESP_LOGW(TAG, "mdns_init failed: %s", esp_err_to_name(e)); return; } + mdns_hostname_set(s_host); + mdns_instance_name_set("KitchenSync"); + + /* ESP-031: publish identity, don't make the client guess it from the name. + * + * A hostname is not an identity. The app matched `kitchensync-*`, so a real Touch + * (kstouch-dfd0) was invisible to it, and any stranger's box called + * kitchensync-anything got adopted into the fleet at a venue. Both directions fail. + * Clients match on `dev`. + * + * `target` is CONFIG_IDF_TARGET, so it is whatever this image was actually built for + * (this firmware builds for esp32/esp32p4/esp32s3) rather than a name someone typed. + * That is what lets a client REFUSE a cross-target OTA -- POST /update accepts any + * image today and can only ask the user to promise it matches. */ + mdns_txt_item_t txt[] = { + { "dev", "kitchensync" }, + { "model", "p4" }, + { "target", CONFIG_IDF_TARGET }, + { "fw", FW_VERSION }, + }; + mdns_service_add(NULL, "_http", "_tcp", 80, txt, sizeof(txt) / sizeof(txt[0])); + s_mdns_up = true; + } + + /* The friendly alias. Two units on one LAN both claim it; the -XXXX name is + * the unambiguous one, and that is the documented trade for having a name + * anyone can type. Re-registered on every new address, so a DHCP lease change + * or a fall to SoftAP does not leave a stale A record behind. */ + mdns_ip_addr_t addr = {0}; + addr.addr.type = ESP_IPADDR_TYPE_V4; + addr.addr.u_addr.ip4.addr = ip.addr; + mdns_delegate_hostname_remove("kitchensync"); /* no-op when absent */ + if (mdns_delegate_hostname_add("kitchensync", &addr) != ESP_OK) + ESP_LOGW(TAG, "could not claim kitchensync.local (another unit has it?)"); + + ESP_LOGI(TAG, "mDNS: http://%s.local (alias http://kitchensync.local)", s_host); +} + +/* Point the STA at slot `i` without restarting the WiFi stack. Called for the cold + * start and again on every WCA_TRY_SLOT. */ +static void apply_slot(int i) +{ + wifi_config_t wc = {0}; + strncpy((char *)wc.sta.ssid, s_creds[i].ssid, sizeof(wc.sta.ssid) - 1); + strncpy((char *)wc.sta.password, s_creds[i].pass, sizeof(wc.sta.password) - 1); + wc.sta.scan_method = WIFI_ALL_CHANNEL_SCAN; + wc.sta.sort_method = WIFI_CONNECT_AP_BY_SIGNAL; + ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_STA, &wc)); + ESP_LOGI(TAG, "connecting to SSID:%s (slot %d/%d)", s_creds[i].ssid, i + 1, s_ncreds); +} + +/* SoftAP config fallback — reached either from a first-boot empty ssid, or from + * give_up_and_start_ap() once the policy's budget trips. Does not re-init the WiFi + * stack or re-register events — wifi_link_start() did that once. */ +static void start_ap(void) +{ + s_pol.state = WCS_AP; // park the policy (covers first-boot AP + give-up) + esp_netif_create_default_wifi_ap(); + + wifi_config_t wc = {0}; + strcpy((char *)wc.ap.ssid, AP_SSID); + wc.ap.ssid_len = strlen(AP_SSID); + wc.ap.max_connection = 4; + wc.ap.authmode = WIFI_AUTH_OPEN; // open network for first-boot/fallback setup + ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_AP)); + ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_AP, &wc)); + ESP_ERROR_CHECK(esp_wifi_start()); + ESP_LOGW(TAG, "SoftAP '%s' at 192.168.4.1 for config", AP_SSID); + + /* ESP-012: name the config portal too, so first-boot setup does not require + * knowing 192.168.4.1. The SoftAP netif always holds that address. */ + esp_netif_ip_info_t ip = {0}; + esp_netif_t *ap = esp_netif_get_handle_from_ifkey("WIFI_AP_DEF"); + if (ap && esp_netif_get_ip_info(ap, &ip) == ESP_OK) mdns_advertise(ip.ip); +} + +static void give_up_and_start_ap(void) +{ + ESP_LOGW(TAG, "no connection after %llds — giving up on STA, falling back to AP", + (long long)(WIFI_CONN_TIMEOUT_US / 1000000)); + esp_wifi_disconnect(); + esp_wifi_stop(); + start_ap(); +} + +static void on_wifi_event(void *arg, esp_event_base_t base, int32_t id, void *data) +{ + /* Once parked in the config AP the policy is terminal; ignore stray STA events so + * a queued DISCONNECTED can't re-enter give_up_and_start_ap() and double-init the + * AP netif (ESP_ERROR_CHECK would abort). */ + if (s_pol.state == WCS_AP) return; + + if (base == WIFI_EVENT && id == WIFI_EVENT_STA_START) { + esp_wifi_connect(); + } else if (base == WIFI_EVENT && id == WIFI_EVENT_STA_DISCONNECTED) { + wifi_event_sta_disconnected_t *dc = (wifi_event_sta_disconnected_t *)data; + WifiConnAction a = wifi_conn_policy_step(&s_pol, WCE_DISCONNECTED, esp_timer_get_time()); + if (a == WCA_GIVE_UP_TO_AP) { + give_up_and_start_ap(); + } else { // WCA_CONNECT (same slot) or WCA_TRY_SLOT (next saved network) + ESP_LOGW(TAG, "disconnected (reason=%d), retrying", dc ? dc->reason : -1); + /* ESP-013: this slot's share of the budget ran out — re-point the STA at + * the next saved network before reconnecting. Safe while started; only + * the config changes, the stack keeps running. */ + if (a == WCA_TRY_SLOT) apply_slot(s_pol.slot); + /* Backoff before reconnecting: without it the C6 posts DISCONNECTED as + * fast as it fails (reason 201 while a hotspot wakes), spinning the hosted + * RPC. Runs in the event-loop task, not an ISR, so a short delay is safe. */ + vTaskDelay(pdMS_TO_TICKS(500)); + esp_wifi_connect(); + } + } else if (base == IP_EVENT && id == IP_EVENT_STA_GOT_IP) { + ip_event_got_ip_t *e = (ip_event_got_ip_t *)data; + /* Name the network, not just the address. A device that silently joined a + * different saved SSID than expected looks like a routing fault. */ + ESP_LOGI(TAG, "got ip " IPSTR " on SSID:%s (slot %d/%d)", IP2STR(&e->ip_info.ip), + s_pol.slot < s_ncreds ? s_creds[s_pol.slot].ssid : "?", + s_pol.slot + 1, s_ncreds); + /* Multicast must not be dropped by modem sleep (the P4 equivalent of the + * S3's WiFi.setSleep(false)). */ + esp_wifi_set_ps(WIFI_PS_NONE); + mdns_advertise(e->ip_info.ip); /* ESP-012: re-announce on every lease */ + if (wifi_conn_policy_step(&s_pol, WCE_GOT_IP, esp_timer_get_time()) == WCA_SPAWN_LISTENER) { + xTaskCreate(link_task, "link", 4096, NULL, 5, NULL); + } + } +} + +static void start_sta(void) +{ + esp_netif_create_default_wifi_sta(); + ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_STA)); + apply_slot(0); + wifi_conn_policy_reset(&s_pol, esp_timer_get_time(), s_ncreds); + ESP_ERROR_CHECK(esp_wifi_start()); +} + +void wifi_link_start(const WifiCred* creds, int n) +{ + if (n > KS_WIFI_SLOTS) n = KS_WIFI_SLOTS; + s_ncreds = (n < 0) ? 0 : n; + for (int i = 0; i < s_ncreds; i++) s_creds[i] = creds[i]; + + ESP_ERROR_CHECK(esp_netif_init()); + ESP_ERROR_CHECK(esp_event_loop_create_default()); + + /* Init the stack + register events once — start_sta() and the AP fallback + * both rely on this being done regardless of which path runs. */ + wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT(); + ESP_ERROR_CHECK(esp_wifi_init(&cfg)); + ESP_ERROR_CHECK(esp_event_handler_register(WIFI_EVENT, ESP_EVENT_ANY_ID, on_wifi_event, NULL)); + ESP_ERROR_CHECK(esp_event_handler_register(IP_EVENT, IP_EVENT_STA_GOT_IP, on_wifi_event, NULL)); + + if (s_ncreds > 0) start_sta(); + else start_ap(); +} + +bool wifi_link_ap_mode(void) { return s_pol.state == WCS_AP; } +bool wifi_link_timeline(LinkTimeline* out) { return link_proto_timeline(out); } +int wifi_link_peers(void) { return link_proto_peers(); } diff --git a/KitchenSync/main/wifi_link.h b/KitchenSync/main/wifi_link.h new file mode 100644 index 0000000..2f13416 --- /dev/null +++ b/KitchenSync/main/wifi_link.h @@ -0,0 +1,34 @@ +#pragma once +// KitchenSync glue: WiFi station (via the C6 hosted stack) + Ableton Link listener. +// Owns the network side; the gossip parsing is the pure, host-tested +// link_protocol.c. Exposes the current session timeline to the clock-out task. +#include +#include "link_protocol.h" // LinkTimeline +#include "ks_config.h" // WifiCred +#ifdef __cplusplus +extern "C" { +#endif + +// Bring up WiFi. With one or more saved networks: try each in turn (ESP-013) and, +// once connected, join the Link multicast group and feed the parser. With none +// (n == 0): start a SoftAP ("KitchenSync-Setup") so the web UI is reachable to +// enter credentials (first-boot config mode, no Link). Non-blocking beyond +// association. `creds` is copied, so the caller's buffer need not outlive the call. +// +// Pass the COMPACTED list from ks_config_wifi_slots(): empty slots are dropped +// there, so this never attempts a blank SSID. +void wifi_link_start(const WifiCred* creds, int n); + +// True when running in SoftAP config mode (no station credentials). +bool wifi_link_ap_mode(void); + +// Current Link session timeline; false if no valid session yet. Wraps the pure +// link_proto_timeline(). +bool wifi_link_timeline(LinkTimeline* out); + +// Number of Link peers currently seen. +int wifi_link_peers(void); + +#ifdef __cplusplus +} +#endif diff --git a/KitchenSync/partitions.csv b/KitchenSync/partitions.csv new file mode 100644 index 0000000..4d2e52d --- /dev/null +++ b/KitchenSync/partitions.csv @@ -0,0 +1,24 @@ +# KitchenSync P4 partition table — 16 MB flash, dual-OTA with 4 MB app slots. +# +# The P4-NANO's flash chip is 16 MB (confirmed by `esptool.py flash_id`: +# manufacturer c8, device 4018). The build previously assumed the ESP-IDF 4 MB +# default, which squeezed the built-in two_ota table's app slots to 1 MB each -- +# and the app had grown to 0xf5f40 (96% full), so the next feature would not fit. +# +# nvs / otadata / phy_init keep the EXACT offsets of the old built-in table +# (0x9000 / 0xd000 / 0xf000) so the saved config + WiFi credentials in NVS +# survive this repartition. Only the app region past 0x10000 changes. +# +# No factory slot: both app slots are equal-purpose OTA targets, which is what +# ks_web.cpp's POST /update assumes (esp_ota_get_next_update_partition always +# picks "the other" slot and boots into it on success). +# +# 0x10000 + 2 * 0x400000 = 0x810000 (8.06 MB) used; ~7.9 MB left unallocated for +# future use (SPIFFS/LittleFS). App slots must stay 64 KB-aligned. +# +# Name, Type, SubType, Offset, Size, Flags +nvs, data, nvs, 0x9000, 0x4000, +otadata, data, ota, 0xd000, 0x2000, +phy_init, data, phy, 0xf000, 0x1000, +ota_0, app, ota_0, 0x10000, 0x400000, +ota_1, app, ota_1, 0x410000, 0x400000, diff --git a/KitchenSync/sdkconfig.defaults b/KitchenSync/sdkconfig.defaults new file mode 100644 index 0000000..594117c --- /dev/null +++ b/KitchenSync/sdkconfig.defaults @@ -0,0 +1,46 @@ +# This P4-NANO is early silicon (chip rev v1.3). ESP-IDF v5.5 defaults its +# minimum P4 revision to v3.01 and refuses to boot; drop it to v1.0. Arduino +# tolerates v1.x natively, but the P4 hub features need ESP-IDF, so this gate is +# required here. See tasks/P4-004.md + the P4-NANO board notes. +CONFIG_ESP32P4_SELECTS_REV_LESS_V3=y +CONFIG_ESP32P4_REV_MIN_100=y + +# WiFi is provided by the onboard ESP32-C6 co-processor over ESP-Hosted. +CONFIG_SLAVE_IDF_TARGET_ESP32C6=y + +# Larger USB control transfers for MIDI-class config descriptors (P4-003). +CONFIG_USB_HOST_CONTROL_TRANSFER_MAX_SIZE=1024 + +# 1 kHz tick so the "1 ms" clock_out loop's vTaskDelay(pdMS_TO_TICKS(1)) is a real +# 1-tick yield. At the ESP-IDF default 100 Hz, pdMS_TO_TICKS(1) truncates to 0 → +# the priority-6 clock_out task busy-spins, starving IDLE0 (task-WDT) and adding +# up to 10 ms of MIDI-clock jitter. 1 kHz gives ≤1 ms clock granularity (P4-006). +CONFIG_FREERTOS_HZ=1000 + +# WiFi credentials (CONFIG_KS_WIFI_*) are intentionally NOT set here — set them +# locally (menuconfig or a gitignored sdkconfig); never commit credentials. + +# OTA firmware updates (P4-017): a custom two-slot table (partitions.csv) so +# `POST /update` can write the inactive slot and boot into it via +# esp_ota_set_boot_partition — no factory partition, both slots are equal-purpose +# OTA targets. Custom, not the built-in two_ota table, because that one sizes its +# app slots off the flash size: at 4MB it gave 1MB slots and the app hit 96% full. +CONFIG_PARTITION_TABLE_CUSTOM=y +CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions.csv" + +# Socket budget (P4-037). The ESP-IDF default of 10 cannot hold this device's +# tenants: the httpd wants 8 (1 listener + max_open_sockets 7), Link's multicast +# socket (wifi_link.c) 1, the Link measurement socket (link_measure_io.c) 1, and +# mDNS ~2 — about 12. LWIP ran dry, so `accept()` failed with ENFILE and the web +# UI wedged for good after ONE desktop-browser visit (a browser opens up to 6 +# keep-alive connections). httpd's lru_purge_enable alone does NOT save it: purge +# only fires when httpd's own pool is full, and LWIP was empty before that. +# Verified on hardware: 10 dies on one Chromium load, 16 survives. +CONFIG_LWIP_MAX_SOCKETS=16 + +# The P4-NANO's flash chip is 16MB — CONFIRMED on hardware with `esptool.py +# flash_id` (manufacturer c8, device 4018 = GD25Q128). The old 4MB value here was +# a guess ("the minimum step that fits the two-OTA table"), and it capped the app +# slots at 1MB. partitions.csv now gives each OTA slot 4MB. This value must never +# exceed the physical chip size. +CONFIG_ESPTOOLPY_FLASHSIZE_16MB=y diff --git a/KitchenSync/sdkconfig.defaults.esp32s3 b/KitchenSync/sdkconfig.defaults.esp32s3 new file mode 100644 index 0000000..bc3f17d --- /dev/null +++ b/KitchenSync/sdkconfig.defaults.esp32s3 @@ -0,0 +1,23 @@ +# ESP-026 spike — S3-only sdkconfig defaults. +# +# ESP-IDF auto-loads this file ON TOP OF sdkconfig.defaults when, and only when, the +# target is esp32s3 (i.e. after `idf.py set-target esp32s3`). It never affects the P4 +# build. The base sdkconfig.defaults is P4-tuned; its P4-only symbols (silicon rev, +# the ESP-Hosted C6 slave, USB-host transfer size) are unknown on the S3 and are +# ignored with harmless "unknown CONFIG_*" warnings. The 16MB flash + custom OTA +# partition table it sets DO apply here and match the Waveshare S3-Touch-LCD-1.47 +# board (16MB N16R8) — the same layout KitchenSyncTouch ships (partitions_devkit.csv +# is the separate 4MB DevKit table, not this board). + +# Bring the 1.47" touch LCD up by default on the S3 so a fresh `set-target esp32s3` +# comes up display-enabled and only WiFi credentials need entering in menuconfig. +# Spike convenience: productization will gate the display on a per-board Kconfig, not +# on "every S3" — a headless S3 Super Mini has no panel. See ADR-0009 / ESP-026. +CONFIG_KS_TOUCH_DISPLAY=y + +# Route the console to the S3's USB-Serial-JTAG (the same controller the Arduino +# build reached via "hardware CDC/JTAG"/hwcdc). ESP-IDF defaults the console to UART, +# which leaves the board's USB port dark once the app boots — so the cu.usbmodem port +# vanishes after boot and esptool can no longer auto-reset for the next flash. Driving +# USB-Serial-JTAG keeps the port alive (normal flash + monitor) and gives serial logs. +CONFIG_ESP_CONSOLE_USB_SERIAL_JTAG=y diff --git a/KitchenSyncTouch/KitchenSyncTouch.ino b/KitchenSyncTouch/KitchenSyncTouch.ino new file mode 100644 index 0000000..d04a171 --- /dev/null +++ b/KitchenSyncTouch/KitchenSyncTouch.ino @@ -0,0 +1,227 @@ +// KitchenSync Touch (ESP-016) — ESP32-S3-Touch-LCD-1.47. Grabs Ableton Link and +// drives a DIN synth (Dan's RC-505) with MIDI clock + transport, plus a full-screen +// touch transport toggle. NO OSC, NO X32 mixer. All config is on the web UI (touch +// is transport-only). Design: docs/plans/2026-07-10-kitchensynctouch-sketch-design.md. +#include +#include +#include "config.h" +#if __has_include("secrets.h") +#include "secrets.h" // first-boot WiFi seed if NVS is empty (gitignored) +#else +#define KSTOUCH_WIFI_SSID "" +#define KSTOUCH_WIFI_PASS "" +#endif +#include "fw_version.h" +#include "ks_config.h" // ESP-042: the SHARED fleet config (retired AppConfig) +#include "ktouch_config_nvs.h" // ESP-042: config_load/config_save on KsConfig +#include "tempo_source.h" +#ifdef HAS_TOUCH_DISPLAY +#include "ktouch_display.h" +#endif +#ifdef HAS_BUTTONS +#include "buttons.h" // ESP-025: pure debounce (shared, ADR-0007) +#include "transport_led.h" // ESP-025: lamp state for the two lit buttons +#include "ktouch_transport.h" // ESP-025: the same mailbox the touch UI posts to +#endif +#include "ktouch_web.h" + +KsConfig g_config; // ESP-042: the one config instance (fleet KsConfig) +char g_ks_host[32] = "kstouch"; // display name: mDNS name or AP address +bool g_ap_mode = false; // true when serving the setup SoftAP + +static void start_ap(void) { + g_ap_mode = true; + WiFi.mode(WIFI_AP); + // ESP-044: the setup SSID was invisible a few feet from a Super Mini even though + // softAP() returned true. Two causes, both fixed here: AP-mode modem sleep can gate + // the beacon, and the Super Mini's PCB antenna is weak, so the radio must run at full + // power. Harmless on the Touch product board (better beacon there too). + WiFi.setSleep(false); + bool ok = WiFi.softAP("KSTouch-Config"); + WiFi.setTxPower(WIFI_POWER_19_5dBm); // max + strlcpy(g_ks_host, "192.168.4.1", sizeof(g_ks_host)); // display shows this + if (!ok) Serial.println("[KSTouch] SoftAP start FAILED"); + Serial.println("[KSTouch] SoftAP 'KSTouch-Config' @ 192.168.4.1 for setup"); +} + + +#ifdef HAS_BUTTONS +// ESP-025 bench rig (classic ESP32 DevKit + screw-terminal breakout). Two illuminated +// buttons are this board's transport surface, in place of the Touch's LCD. They post +// the SAME TransportLaunchIntent into the SAME ktouch_transport mailbox the touch UI +// uses, so the 1 ms writer is identical on both boards -- what the bench measures is +// what the product runs. +// +// GPIO32/33 are the cleanest inputs on this board: no strapping role, real internal +// pull-ups. Deliberately NOT 34/35/VP/VN (input-only, and they have NO internal +// pull-up, so INPUT_PULLUP compiles and silently does nothing), and not 0/2/5/12/15 +// (strapping -- GPIO12 held high at boot sets the flash rail to 1.8 V and the board +// will not come up). Switch to GND + INPUT_PULLUP: pressed reads LOW. +// +// The lamps are load-bearing, not decoration: a press is QUANTIZED, so nothing happens +// for up to a bar. Without the blink that reads as a dead button and the user presses +// again. See transport_led.h. +#define BTN_TRANSPORT_PIN 32 +#define BTN_REALIGN_PIN 33 +#define LED_TRANSPORT_PIN 25 +#define LED_REALIGN_PIN 26 + +static Button s_btn_transport; +static Button s_btn_realign; +// ESP-025 bring-up: latch what the pin is doing, so a press can be checked from /status +// without having to catch it inside a serial window. +static volatile uint32_t s_btn_presses = 0; // debounced press edges, ever +static volatile uint32_t s_btn_lows = 0; // loop() iterations that saw the pin LOW +uint32_t ktouch_btn_presses(void) { return s_btn_presses; } +uint32_t ktouch_btn_lows(void) { return s_btn_lows; } +int ktouch_btn_level(void) { return digitalRead(BTN_TRANSPORT_PIN); } + +static void buttons_begin() { + pinMode(BTN_TRANSPORT_PIN, INPUT_PULLUP); + pinMode(BTN_REALIGN_PIN, INPUT_PULLUP); + pinMode(LED_TRANSPORT_PIN, OUTPUT); + pinMode(LED_REALIGN_PIN, OUTPUT); + digitalWrite(LED_TRANSPORT_PIN, LOW); + digitalWrite(LED_REALIGN_PIN, LOW); + button_reset(&s_btn_transport); + button_reset(&s_btn_realign); +} + +static const char* tl_name(int s) { + return s == TL_RUNNING ? "RUNNING" : s == TL_ARMED ? "ARMED" : "STOPPED"; +} + +static void buttons_tick() { + uint32_t now = millis(); + + + // Post intents; never touch the wire from loop(). A transport byte emitted here + // would land wherever loop() happened to be, not on the bar line -- the writer + // owns transport_launch and fires it on the grid. + if (digitalRead(BTN_TRANSPORT_PIN) == LOW) s_btn_lows++; + if (button_update(&s_btn_transport, digitalRead(BTN_TRANSPORT_PIN) == LOW, now)) { + s_btn_presses++; + bool running = ktouch_transport_state() != TL_STOPPED; + ktouch_transport_post(running ? TL_INTENT_STOP : TL_INTENT_PLAY); + Serial.printf("[btn] TRANSPORT (gpio%d) -> %s\n", BTN_TRANSPORT_PIN, + running ? "STOP (immediate)" : "PLAY (arms; fires on the bar line)"); + } + if (button_update(&s_btn_realign, digitalRead(BTN_REALIGN_PIN) == LOW, now)) { + ktouch_transport_post(TL_INTENT_REALIGN); + Serial.printf("[btn] REALIGN (gpio%d) -> arms; 0xFC+0xFA on the bar line\n", + BTN_REALIGN_PIN); + } + + // Serial IS the feedback surface until the lit buttons are wired. A quantized press + // does nothing for up to a whole bar, so without SOMETHING saying "armed" it reads as + // a dead button -- that is the entire reason transport_led exists. Log the edges only; + // logging every loop() would drown the console (and this is loop(), never the 1 ms + // writer, where an ESP_LOGx/Serial write is a blocking UART stall -- P4-033). + static int s_last_state = -1; + static bool s_last_realign = false; + int st = ktouch_transport_state(); + bool ra = ktouch_transport_realign_armed(); + if (st != s_last_state) { Serial.printf("[transport] %s\n", tl_name(st)); s_last_state = st; } + if (ra != s_last_realign) { Serial.printf("[realign] %s\n", ra ? "ARMED" : "fired/cleared"); s_last_realign = ra; } + + digitalWrite(LED_TRANSPORT_PIN, + transport_led_on((TransportLaunchState)ktouch_transport_state(), now) ? HIGH : LOW); + digitalWrite(LED_REALIGN_PIN, + realign_led_on(ktouch_transport_realign_armed(), now) ? HIGH : LOW); +} +#endif // HAS_BUTTONS + +void setup() { + Serial.begin(115200); + delay(200); + Serial.printf("\n[KSTouch] KitchenSync Touch fw:%s -- booting\n", FW_VERSION); + + config_load(&g_config); // NVS + // First boot with no saved SSID: seed from the compile-time secrets so a bench + // unit connects; a shipped unit is configured through the SoftAP + web form. + if (g_config.wifi[0].ssid[0] == '\0' && sizeof(KSTOUCH_WIFI_SSID) > 1) { + strlcpy(g_config.wifi[0].ssid, KSTOUCH_WIFI_SSID, sizeof(g_config.wifi[0].ssid)); + strlcpy(g_config.wifi[0].pass, KSTOUCH_WIFI_PASS, sizeof(g_config.wifi[0].pass)); + } + +#ifdef HAS_TOUCH_DISPLAY + ktouch_display_begin(); +#endif +#ifdef HAS_BUTTONS + buttons_begin(); +#endif + tempo_source_select(TEMPO_SRC_LINK); + tempo_source_pre_net(); + + if (g_config.wifi[0].ssid[0] == '\0') { + start_ap(); + } else { + WiFi.mode(WIFI_STA); + WiFi.setSleep(false); // modem sleep drops buffered multicast -> Link never rx + WiFi.begin(g_config.wifi[0].ssid, g_config.wifi[0].pass); + Serial.printf("[KSTouch] joining %s", g_config.wifi[0].ssid); + uint32_t t0 = millis(); + while (WiFi.status() != WL_CONNECTED && millis() - t0 < 20000) { delay(250); Serial.print("."); } + Serial.println(); + if (WiFi.status() == WL_CONNECTED) { + uint8_t mac[6]; WiFi.macAddress(mac); + snprintf(g_ks_host, sizeof(g_ks_host), "kstouch-%02x%02x", mac[4], mac[5]); + if (MDNS.begin(g_ks_host)) { + MDNS.addService("http", "tcp", 80); + /* ESP-031: this unit advertises as kstouch-XXXX, which does not match the + * app's `kitchensync-*` hostname filter -- so a real Touch is invisible to + * it. Publish identity in TXT and let the client match on `dev`. `target` + * is the build's, so a client can refuse a cross-target OTA. */ + MDNS.addServiceTxt("http", "tcp", "dev", "kitchensync"); + MDNS.addServiceTxt("http", "tcp", "model", "touch"); + MDNS.addServiceTxt("http", "tcp", "target", CONFIG_IDF_TARGET); + MDNS.addServiceTxt("http", "tcp", "fw", FW_VERSION); + } + Serial.printf("[KSTouch] ip %s http://%s.local\n", + WiFi.localIP().toString().c_str(), g_ks_host); + tempo_source_begin(); // Link + the DIN clock task + } else { + Serial.println("[KSTouch] WiFi failed -> setup AP"); + start_ap(); + } + } + + ktouch_web_begin(); // config form (on the STA IP or the AP) +} + +#ifdef HAS_STATUS_RGB +// ESP-043: drive the Super Mini's onboard WS2812 (GPIO48) as a status light. The +// state->colour decision is the pure, host-tested ktouch_status_rgb(); this seam just +// samples the live state and pushes the result to the LED. dark stopped, amber blink armed, +// green beat-flash running, cyan when Link-locked, brighter accent on the bar-1 downbeat. +#include "ktouch_status_rgb.h" +#include "ktouch_midi_out.h" // ktouch_midi_beats() / ktouch_midi_locked() +#include "ktouch_transport.h" // ktouch_transport_state() +static void status_rgb_tick() { + StatusRgb c = ktouch_status_rgb((TransportLaunchState)ktouch_transport_state(), + ktouch_midi_locked() != 0, + ktouch_midi_beats(), + g_config.quantum_beats); + // Only touch the RMT when the colour actually changes -- loop() runs every ~5 ms and + // rgbLedWrite reconfigures the peripheral on each call. + static StatusRgb last = { 1, 1, 1 }; // impossible first value forces the first write + if (c.r == last.r && c.g == last.g && c.b == last.b) return; + last = c; + rgbLedWrite(STATUS_RGB_GPIO, c.r, c.g, c.b); +} +#endif + +void loop() { + ktouch_web_tick(); + if (!g_ap_mode) tempo_source_poll(); +#ifdef HAS_STATUS_RGB + status_rgb_tick(); +#endif +#ifdef HAS_TOUCH_DISPLAY + ktouch_display_tick(); +#endif +#ifdef HAS_BUTTONS + buttons_tick(); +#endif + delay(5); +} diff --git a/KitchenSyncTouch/axs5106l.c b/KitchenSyncTouch/axs5106l.c new file mode 120000 index 0000000..a25ef07 --- /dev/null +++ b/KitchenSyncTouch/axs5106l.c @@ -0,0 +1 @@ +../X32Link/axs5106l.c \ No newline at end of file diff --git a/KitchenSyncTouch/axs5106l.h b/KitchenSyncTouch/axs5106l.h new file mode 120000 index 0000000..c5af236 --- /dev/null +++ b/KitchenSyncTouch/axs5106l.h @@ -0,0 +1 @@ +../X32Link/axs5106l.h \ No newline at end of file diff --git a/KitchenSyncTouch/beat_clock.c b/KitchenSyncTouch/beat_clock.c new file mode 120000 index 0000000..e6ed310 --- /dev/null +++ b/KitchenSyncTouch/beat_clock.c @@ -0,0 +1 @@ +../X32Link/beat_clock.c \ No newline at end of file diff --git a/KitchenSyncTouch/beat_clock.h b/KitchenSyncTouch/beat_clock.h new file mode 120000 index 0000000..b90c489 --- /dev/null +++ b/KitchenSyncTouch/beat_clock.h @@ -0,0 +1 @@ +../X32Link/beat_clock.h \ No newline at end of file diff --git a/KitchenSyncTouch/beat_source.c b/KitchenSyncTouch/beat_source.c new file mode 120000 index 0000000..84af150 --- /dev/null +++ b/KitchenSyncTouch/beat_source.c @@ -0,0 +1 @@ +../X32Link/beat_source.c \ No newline at end of file diff --git a/KitchenSyncTouch/beat_source.h b/KitchenSyncTouch/beat_source.h new file mode 120000 index 0000000..13c6afa --- /dev/null +++ b/KitchenSyncTouch/beat_source.h @@ -0,0 +1 @@ +../X32Link/beat_source.h \ No newline at end of file diff --git a/KitchenSyncTouch/buttons.c b/KitchenSyncTouch/buttons.c new file mode 120000 index 0000000..c602c78 --- /dev/null +++ b/KitchenSyncTouch/buttons.c @@ -0,0 +1 @@ +../X32Link/buttons.c \ No newline at end of file diff --git a/KitchenSyncTouch/buttons.h b/KitchenSyncTouch/buttons.h new file mode 120000 index 0000000..64142ae --- /dev/null +++ b/KitchenSyncTouch/buttons.h @@ -0,0 +1 @@ +../X32Link/buttons.h \ No newline at end of file diff --git a/KitchenSyncTouch/clock_output.c b/KitchenSyncTouch/clock_output.c new file mode 120000 index 0000000..c0908e3 --- /dev/null +++ b/KitchenSyncTouch/clock_output.c @@ -0,0 +1 @@ +../X32Link/clock_output.c \ No newline at end of file diff --git a/KitchenSyncTouch/clock_output.h b/KitchenSyncTouch/clock_output.h new file mode 120000 index 0000000..e3fd611 --- /dev/null +++ b/KitchenSyncTouch/clock_output.h @@ -0,0 +1 @@ +../X32Link/clock_output.h \ No newline at end of file diff --git a/KitchenSyncTouch/clock_ticker.c b/KitchenSyncTouch/clock_ticker.c new file mode 120000 index 0000000..fc283eb --- /dev/null +++ b/KitchenSyncTouch/clock_ticker.c @@ -0,0 +1 @@ +../X32Link/clock_ticker.c \ No newline at end of file diff --git a/KitchenSyncTouch/clock_ticker.h b/KitchenSyncTouch/clock_ticker.h new file mode 120000 index 0000000..e412109 --- /dev/null +++ b/KitchenSyncTouch/clock_ticker.h @@ -0,0 +1 @@ +../X32Link/clock_ticker.h \ No newline at end of file diff --git a/KitchenSyncTouch/config.h b/KitchenSyncTouch/config.h new file mode 100644 index 0000000..6bf5449 --- /dev/null +++ b/KitchenSyncTouch/config.h @@ -0,0 +1,97 @@ +#pragma once +// KitchenSync on Arduino — the CLOCK BOX (Link in -> DIN MIDI clock + transport out). +// NOT the X32Link mixer bridge; see the firmware map at the top of AGENTS.md. +// +// Three boards, one firmware: +// - Waveshare ESP32-S3-Touch-LCD-1.47 — the product. Transport via the touch UI. +// - Classic ESP32 DevKit (WROOM-32) — the ESP-025 bench rig. Transport via two +// illuminated buttons on a screw-terminal breakout. Same clock engine, same +// writer, same web UI: what you measure on the bench is what the product runs. +// - ESP32-S3 Super Mini (ESP-036) — HEADLESS. No screen, no buttons (yet): +// transport is driven entirely from the iOS app over /transport + /live. This is +// the small, cheap clock-box variant — a bare S3 + a DIN/TRS MIDI jack. The DIN +// MIDI TX lands on GPIO11 (din_midi_out.h), the same free header pin the Waveshare +// uses. Buttons are EXPECTED here eventually: the HAS_BUTTONS path already exists +// (the DevKit uses it), so adding them later means defining HAS_BUTTONS and giving +// the button/LED pins S3-safe GPIOs — the DevKit's 25/26 are classic-ESP32 pins and +// do NOT carry over (26-32 are the SPI flash bus on an S3 module). +// +// The board flag lives HERE, not in build_opt.h: a #define in a tracked source is +// part of the arduino-cli dependency graph, so it can't go stale in the build +// cache — the exact bug that blanked the X32Link touch build until a --clean. +// +// PICK EXACTLY ONE. Leave the Waveshare line active at HEAD (it is the product); +// comment-swap locally for the bench rig / Super Mini and don't commit the swap. +#define BOARD_WAVESHARE_S3_TOUCH_LCD_147 +// #define BOARD_ESP32_DEVKIT +// #define BOARD_S3_SUPERMINI + +// EXACTLY ONE board. `defined()` summed inside an #if is well-defined (it is NOT in a +// macro body), so this reads as "count the board flags; there must be one". +#if (defined(BOARD_WAVESHARE_S3_TOUCH_LCD_147) \ + + defined(BOARD_ESP32_DEVKIT) \ + + defined(BOARD_S3_SUPERMINI)) != 1 +#error "config.h: pick EXACTLY ONE board flag" +#endif + +// ---- ESP-030: what is ACTUALLY WIRED to this board ------------------------- +// +// These drive the KsCaps this device reports, and a device must not report hardware it +// does not have: emitting `led:false` with no strip attached is the same class of lie +// as ESP-028's `sync:1` over a wire that had been dead for 138 seconds. A client would +// dutifully draw an LED section for a board that cannot light anything. +// +// Capabilities belong to THIS BUILD, not to the product name. "The Touch has no LED" is +// wrong — the truth is "no strip is wired to this one YET". Solder one on, flip +// KSTOUCH_HAS_LED to 1, and /config.json starts emitting led_*, and the iOS app's +// settings screen grows an LED section — with NO app change at all. That is the whole +// point of declaring capability instead of hardcoding a product identity. +#define KSTOUCH_HAS_METRONOME 0 // no speaker fitted (the P4 has an ES8311; this doesn't) +#define KSTOUCH_HAS_LED 0 // no WS2812 strip wired — set to 1 when one is +#define KSTOUCH_HAS_FOLLOWBEAT 0 // no mic fitted +#define KSTOUCH_CLOCK_OUTPUTS 1 // one DIN jack. The clock array's LENGTH is this. + +// Link timing — the receive-only Link stack we reuse from X32Link expects these. +#define LINK_DEFAULT_BPM 120.0f +#define LINK_BPM_THRESHOLD 0.5f +#define LINK_POLL_MS 50 +#define LINK_SEND_INTERVAL_MS 500 +#define LINK_REFRESH_BARS 1 + +// MIDI clock: 24 PPQN protocol constant (drives the clock generator, Inc 1b). The +// rest are consumed by the shared midi_clock.cpp's MIDI-IN ring, which is dead code +// here (Link-only) but still compiled — keep the constants so it builds. +#define MCK_PPQN 24 +#define MCK_BPM_THRESHOLD 1.5f +#define MCK_POLL_MS 10 +#define MCK_CLOCK_WINDOW 48 +#define MCK_CLOCK_TIMEOUT_MS 2000 + +// KitchenSync Touch defaults — a MIDI product, NO mixer/OSC/model/fx anything. +#define DEFAULT_QUANTUM_BEATS 4 // 4 beats = 1 bar (4/4); web UI edits in bars +#define DEFAULT_CLOCK_ENABLE 1 // clock on by default (unlike X32Link's mixer product) +#define DEFAULT_TRANSPORT_ENABLE 1 +#define DEFAULT_PLAY_ON_RELEASE 0 // 0 = fire on touch, 1 = on release +#define DEFAULT_NUDGE_MBEATS 0 // DIN clock phase trim, millibeats +#define DEFAULT_BRIGHTNESS 80 // LCD backlight percent + +// The board flag implies the on-device touch UI (LNK-014/015). +// ESP-025: the DevKit has no display — its transport surface is two lit buttons. +// Both boards post the same TransportLaunchIntent into the same ktouch_transport +// mailbox, so the writer neither knows nor cares which one is fitted. +#if defined(BOARD_ESP32_DEVKIT) && !defined(HAS_BUTTONS) +#define HAS_BUTTONS +#endif + +#if defined(BOARD_WAVESHARE_S3_TOUCH_LCD_147) && !defined(HAS_TOUCH_DISPLAY) +#define HAS_TOUCH_DISPLAY +#endif + +// ESP-043: the S3 Super Mini has ONE onboard addressable RGB (WS2812 on GPIO48, per the +// Arduino esp32s3 variant's PIN_RGB_LED). A headless clock box has no screen, so drive it +// as a visual beat/heartbeat -- the only local feedback the board can give. NOT enabled on +// the Waveshare board (GPIO48 is spoken for by the panel there) or the DevKit. +#if defined(BOARD_S3_SUPERMINI) && !defined(HAS_STATUS_RGB) +#define HAS_STATUS_RGB +#define STATUS_RGB_GPIO 48 +#endif diff --git a/KitchenSyncTouch/config_persist.c b/KitchenSyncTouch/config_persist.c new file mode 120000 index 0000000..7449c45 --- /dev/null +++ b/KitchenSyncTouch/config_persist.c @@ -0,0 +1 @@ +../X32Link/config_persist.c \ No newline at end of file diff --git a/KitchenSyncTouch/config_persist.h b/KitchenSyncTouch/config_persist.h new file mode 120000 index 0000000..76b9b7b --- /dev/null +++ b/KitchenSyncTouch/config_persist.h @@ -0,0 +1 @@ +../X32Link/config_persist.h \ No newline at end of file diff --git a/KitchenSyncTouch/din_midi_out.cpp b/KitchenSyncTouch/din_midi_out.cpp new file mode 120000 index 0000000..a2856ce --- /dev/null +++ b/KitchenSyncTouch/din_midi_out.cpp @@ -0,0 +1 @@ +../X32Link/din_midi_out.cpp \ No newline at end of file diff --git a/KitchenSyncTouch/din_midi_out.h b/KitchenSyncTouch/din_midi_out.h new file mode 120000 index 0000000..f27ac0d --- /dev/null +++ b/KitchenSyncTouch/din_midi_out.h @@ -0,0 +1 @@ +../X32Link/din_midi_out.h \ No newline at end of file diff --git a/KitchenSyncTouch/fw_version.h b/KitchenSyncTouch/fw_version.h new file mode 120000 index 0000000..6a68aed --- /dev/null +++ b/KitchenSyncTouch/fw_version.h @@ -0,0 +1 @@ +../X32Link/fw_version.h \ No newline at end of file diff --git a/KitchenSyncTouch/ks_config.c b/KitchenSyncTouch/ks_config.c new file mode 120000 index 0000000..e9fa799 --- /dev/null +++ b/KitchenSyncTouch/ks_config.c @@ -0,0 +1 @@ +../X32Link/ks_config.c \ No newline at end of file diff --git a/KitchenSyncTouch/ks_config.h b/KitchenSyncTouch/ks_config.h new file mode 120000 index 0000000..a5ac548 --- /dev/null +++ b/KitchenSyncTouch/ks_config.h @@ -0,0 +1 @@ +../X32Link/ks_config.h \ No newline at end of file diff --git a/KitchenSyncTouch/ks_config_json.c b/KitchenSyncTouch/ks_config_json.c new file mode 120000 index 0000000..82b1690 --- /dev/null +++ b/KitchenSyncTouch/ks_config_json.c @@ -0,0 +1 @@ +../X32Link/ks_config_json.c \ No newline at end of file diff --git a/KitchenSyncTouch/ks_config_json.h b/KitchenSyncTouch/ks_config_json.h new file mode 120000 index 0000000..1af068f --- /dev/null +++ b/KitchenSyncTouch/ks_config_json.h @@ -0,0 +1 @@ +../X32Link/ks_config_json.h \ No newline at end of file diff --git a/KitchenSyncTouch/ks_status.c b/KitchenSyncTouch/ks_status.c new file mode 120000 index 0000000..36457b8 --- /dev/null +++ b/KitchenSyncTouch/ks_status.c @@ -0,0 +1 @@ +../X32Link/ks_status.c \ No newline at end of file diff --git a/KitchenSyncTouch/ks_status.h b/KitchenSyncTouch/ks_status.h new file mode 120000 index 0000000..b013411 --- /dev/null +++ b/KitchenSyncTouch/ks_status.h @@ -0,0 +1 @@ +../X32Link/ks_status.h \ No newline at end of file diff --git a/KitchenSyncTouch/ktouch_config_migrate.c b/KitchenSyncTouch/ktouch_config_migrate.c new file mode 100644 index 0000000..cdf11b1 --- /dev/null +++ b/KitchenSyncTouch/ktouch_config_migrate.c @@ -0,0 +1,100 @@ +#include "ktouch_config_migrate.h" +#include "ktouch_legacy_config.h" +#include + +// ESP-042: the four legacy Touch layouts share the same tail; only how far the tail +// reaches differs (v2 stopped before ppqn/swing, v3 before tempo). Rather than four +// near-identical copies, migrate onto a common shape and let the reader fill only what +// its version carried — the rest keeps the KsConfig default, exactly as the AppConfig +// vN->vN+1 chain did (ppqn 24, swing 0, tempo 120000). +// +// The Touch drives ONE clock output (DIN), so ppqn/nudge/swing land on clock[0]; the +// master clock gate becomes clock_out_enable. clock[0].enable stays 1 (a default), so a +// migrated box emits the same clock it did before. +static void apply_common(KsConfig* out, const WifiCred wifi[KS_WIFI_SLOTS], + int quantum_beats, int clock_enable, int transport_enable, + int play_on_release, int nudge_mbeats, int brightness) { + for (int i = 0; i < KS_WIFI_SLOTS; i++) out->wifi[i] = wifi[i]; + out->quantum_beats = quantum_beats; + out->clock_out_enable = clock_enable; + out->transport_enable = transport_enable; + out->play_on_release = play_on_release; + out->lcd_brightness = brightness; + out->clock[0].phase_mbeats = nudge_mbeats; +} + +bool ktouch_migrate_legacy_config(KsConfig* out, const void* blob, size_t blob_len, + uint32_t app_version) { + ks_config_defaults(out); // on any reject the caller loads these; also seeds the + // fields a shorter (older) blob never carried + if (!blob) return false; + + switch (app_version) { + case 1u: { + if (blob_len != sizeof(KTouchLegacyV1)) return false; + KTouchLegacyV1 v1; + memcpy(&v1, blob, sizeof(v1)); + // v1's single credential becomes slot 0; the newer slots stay EMPTY (from + // defaults), never filled with bytes that followed a shorter blob. v1's ssid + // field was 64 bytes; WifiCred::ssid is 33 — truncate and NUL-terminate (an + // over-32 SSID was never valid 802.11 anyway). + strncpy(out->wifi[0].ssid, v1.wifi_ssid, sizeof(out->wifi[0].ssid) - 1); + out->wifi[0].ssid[sizeof(out->wifi[0].ssid) - 1] = '\0'; + strncpy(out->wifi[0].pass, v1.wifi_pass, sizeof(out->wifi[0].pass) - 1); + out->wifi[0].pass[sizeof(out->wifi[0].pass) - 1] = '\0'; + out->quantum_beats = v1.quantum_beats; + out->clock_out_enable = v1.clock_enable; + out->transport_enable = v1.transport_enable; + out->play_on_release = v1.play_on_release; + out->lcd_brightness = v1.brightness; + out->clock[0].phase_mbeats = v1.nudge_mbeats; + // ppqn/swing/tempo keep the defaults (24, 0, 120000). + break; + } + case 2u: { + if (blob_len != sizeof(KTouchLegacyV2)) return false; + KTouchLegacyV2 v2; + memcpy(&v2, blob, sizeof(v2)); + apply_common(out, v2.wifi, v2.quantum_beats, v2.clock_enable, + v2.transport_enable, v2.play_on_release, v2.nudge_mbeats, + v2.brightness); + // ppqn/swing/tempo keep the defaults. + break; + } + case 3u: { + if (blob_len != sizeof(KTouchLegacyV3)) return false; + KTouchLegacyV3 v3; + memcpy(&v3, blob, sizeof(v3)); + apply_common(out, v3.wifi, v3.quantum_beats, v3.clock_enable, + v3.transport_enable, v3.play_on_release, v3.nudge_mbeats, + v3.brightness); + out->clock[0].ppqn = v3.ppqn; + out->clock[0].swing_mbeats = v3.swing_mbeats; + // tempo keeps the default (120000). + break; + } + case 4u: { + if (blob_len != sizeof(KTouchLegacyV4)) return false; + KTouchLegacyV4 v4; + memcpy(&v4, blob, sizeof(v4)); + apply_common(out, v4.wifi, v4.quantum_beats, v4.clock_enable, + v4.transport_enable, v4.play_on_release, v4.nudge_mbeats, + v4.brightness); + out->clock[0].ppqn = v4.ppqn; + out->clock[0].swing_mbeats = v4.swing_mbeats; + out->tempo_mbpm = v4.tempo_mbpm; + break; + } + default: + return false; // not a legacy Touch version + } + + // A migration is not a licence to skip the guard — bit-rot is bit-rot at any version. + // Same gate a verbatim KsConfig load runs through. On failure, restore clean defaults + // so the caller never sees a half-migrated struct. + if (!ks_config_valid(out)) { + ks_config_defaults(out); + return false; + } + return true; +} diff --git a/KitchenSyncTouch/ktouch_config_migrate.h b/KitchenSyncTouch/ktouch_config_migrate.h new file mode 100644 index 0000000..9f75698 --- /dev/null +++ b/KitchenSyncTouch/ktouch_config_migrate.h @@ -0,0 +1,32 @@ +#pragma once +// ESP-042: the ONE final migration that retires the Touch's AppConfig. It reads a legacy +// Touch config blob (versions 1..4, the frozen KTouchLegacy* layouts) and produces the +// equivalent shared KsConfig, so a Touch that upgrades to the converged firmware keeps its +// WiFi credentials and every setting instead of falling to the SoftAP portal. +// +// Pure and host-tested (test/test_ktouch_migrate.c). The NVS glue (app_config_nvs.cpp's +// successor) calls this when it finds an old `ver` in the "kstouch" namespace, then writes +// the result back as a KsConfig (KS_CONFIG_VERSION) so the migration runs exactly once. +// +// The Touch drives a SINGLE clock output on DIN, so its per-output fields (ppqn, nudge, +// swing) map onto KsConfig's clock[0]; clock[1..3] stay at defaults (KsCaps hides them on +// the wire). Everything else is a same-named field copy. +#include +#include +#include +#include "ks_config.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// Migrate a legacy Touch blob into `out`. Returns true on a clean, validated migration; +// false if `app_version` is not 1..4, `blob_len` does not match that version's frozen +// size, or the migrated result fails ks_config_valid (bit-rot). On false, `out` is left +// as clean defaults so the caller can load those instead. +bool ktouch_migrate_legacy_config(KsConfig* out, const void* blob, size_t blob_len, + uint32_t app_version); + +#ifdef __cplusplus +} +#endif diff --git a/KitchenSyncTouch/ktouch_config_nvs.cpp b/KitchenSyncTouch/ktouch_config_nvs.cpp new file mode 100644 index 0000000..40476f4 --- /dev/null +++ b/KitchenSyncTouch/ktouch_config_nvs.cpp @@ -0,0 +1,123 @@ +// ESP-042: NVS persistence for the Touch's config. The Touch converged onto the shared +// KsConfig (retiring AppConfig), so this stores a KsConfig blob under the "kstouch" +// namespace -- deliberately NOT "x32link": a board reflashed from X32Link must not read +// its mixer/model keys into this box. +// +// The config is ONE versioned blob. This file does no judging of its own: it hands +// whatever NVS held to the pure, host-tested deciders (ks_config_decode for a native +// KsConfig blob, ktouch_migrate_legacy_config for an old AppConfig blob) and stores +// whatever they return. Adding a config field touches the struct/defaults/validate/set +// and ZERO lines here. +#include "ktouch_config_nvs.h" +#include "ktouch_config_migrate.h" +#include "ks_config.h" +#include +#include + +static Preferences prefs; + +#define NS "kstouch" +#define KEY_CFG "cfg" +#define KEY_VER "ver" + +// The pre-ARC-020 field-keyed layout (never a blob, so the pure deciders can't see it). +// Read ONCE, on the first boot after this upgrade, then written back as a KsConfig blob so +// it never runs again. A field unit (Dan's Touch, ESP-021) that somehow still holds these +// keys would otherwise lose its WiFi on the way to the converged firmware. The old keys +// map straight onto KsConfig; anything the old build never wrote keeps the KsConfig +// default (which equals the old DEFAULT_* by construction). +static bool migrate_legacy_keys(KsConfig* cfg) { + if (!prefs.isKey("quantum") && !prefs.isKey("wifi_ssid") && + !prefs.isKey("bright") && !prefs.isKey("nudge_mb")) { + return false; // fresh flash, nothing to migrate + } + + ks_config_defaults(cfg); // any key the old build never wrote keeps its default + cfg->quantum_beats = prefs.getInt("quantum", cfg->quantum_beats); + cfg->clock_out_enable = prefs.getInt("clock_en", cfg->clock_out_enable); + cfg->transport_enable = prefs.getInt("tport_en", cfg->transport_enable); + cfg->play_on_release = prefs.getInt("play_rel", cfg->play_on_release); + cfg->clock[0].phase_mbeats = prefs.getInt("nudge_mb", cfg->clock[0].phase_mbeats); + cfg->lcd_brightness = prefs.getInt("bright", cfg->lcd_brightness); + // The legacy keys held ONE credential; it becomes slot 0. + prefs.getString("wifi_ssid", cfg->wifi[0].ssid, sizeof(cfg->wifi[0].ssid)); + prefs.getString("wifi_pass", cfg->wifi[0].pass, sizeof(cfg->wifi[0].pass)); + + // A migration is not a licence to skip the guard: garbage in the old keys is still + // garbage. Same gate the blob path runs through. + return ks_config_valid(cfg); +} + +extern "C" void config_load(KsConfig* cfg) { + uint8_t buf[sizeof(KsConfig)]; + size_t sz = 0; + uint32_t ver = 0; + bool have_ver = false; + bool have_blob = false; + + memset(buf, 0, sizeof(buf)); + + prefs.begin(NS, true); // read-only + have_ver = prefs.isKey(KEY_VER); + if (have_ver) ver = prefs.getUInt(KEY_VER, 0); + + // Read the stored length FIRST, then read anything that FITS the buffer -- a wrong + // size is rejected by the decider, never truncated into a plausible half-copy. An old + // AppConfig blob (152..328 bytes) is smaller than KsConfig, so it fits and is read; + // the decider judges it by (version, size). + size_t stored = prefs.getBytesLength(KEY_CFG); + if (stored > 0) { + have_blob = true; + sz = stored; + if (stored <= sizeof(buf)) prefs.getBytes(KEY_CFG, buf, stored); + // else: bigger than any layout we know -- leave buf zeroed; the deciders reject + // on size before reading a byte. + } + prefs.end(); + + const void* blob = have_blob ? (const void*)buf : NULL; + + // KS_CONFIG_VERSION (>= 5) is a NATIVE KsConfig blob -- the P4's own decode lineage, + // including its v4->v5 in-place migration. A version of 1..4 in THIS namespace is a + // legacy Touch AppConfig blob (the Touch never stored a KsConfig before), migrated + // once by ktouch_migrate_legacy_config and written back as a v5 KsConfig. + ks_decode_result r; + if (have_ver && ver >= KS_CONFIG_VERSION) { + r = ks_config_decode(cfg, blob, sz, have_ver, ver); + } else if (have_ver && ver >= 1u && ver <= 4u && + ktouch_migrate_legacy_config(cfg, blob, sz, ver)) { + r = KS_DECODE_MIGRATED; + } else { + ks_config_defaults(cfg); + r = KS_DECODE_DEFAULTED; + } + + if (r == KS_DECODE_OK) return; // common path: native blob accepted + + // An OLD blob (AppConfig, or an older KsConfig on a hypothetical future downgrade path) + // was upgraded. WRITE IT BACK NOW as a v5 KsConfig -- otherwise NVS keeps the old bytes + // and every future boot re-migrates. + if (r == KS_DECODE_MIGRATED) { + config_save(cfg); + return; + } + + // Defaults are already in *cfg. If there is no blob but the pre-ARC-020 field-keys are + // present, this is the first boot after that upgrade -- migrate them once and write the + // blob back so the next boot takes the fast path and the legacy keys are never read. + if (!have_blob) { + prefs.begin(NS, true); + bool migrated = migrate_legacy_keys(cfg); + prefs.end(); + if (migrated) config_save(cfg); + } +} + +extern "C" void config_save(const KsConfig* cfg) { + prefs.begin(NS, false); // read-write + // Blob and version are written together, so a power cut cannot leave a version stamp + // vouching for a blob that was never written (or vice versa). + prefs.putBytes(KEY_CFG, cfg, sizeof(*cfg)); + prefs.putUInt(KEY_VER, KS_CONFIG_VERSION); + prefs.end(); +} diff --git a/KitchenSyncTouch/ktouch_config_nvs.h b/KitchenSyncTouch/ktouch_config_nvs.h new file mode 100644 index 0000000..86e91f9 --- /dev/null +++ b/KitchenSyncTouch/ktouch_config_nvs.h @@ -0,0 +1,18 @@ +#pragma once +// ESP-042: NVS persistence for the Touch, now storing the SHARED KsConfig instead of the +// retired AppConfig. Namespace stays "kstouch" so a board reflashed from X32Link never +// reads its mixer keys into this box; the version key disambiguates a native KsConfig +// blob (KS_CONFIG_VERSION, >= 5) from a legacy AppConfig blob (1..4), which is migrated +// once via ktouch_migrate_legacy_config and written back. +#include "ks_config.h" + +#ifdef __cplusplus +extern "C" { +#endif + +void config_load(KsConfig* cfg); +void config_save(const KsConfig* cfg); + +#ifdef __cplusplus +} +#endif diff --git a/KitchenSyncTouch/ktouch_display.cpp b/KitchenSyncTouch/ktouch_display.cpp new file mode 100644 index 0000000..051ba55 --- /dev/null +++ b/KitchenSyncTouch/ktouch_display.cpp @@ -0,0 +1,268 @@ +#include "config.h" // board -> HAS_TOUCH_DISPLAY; must precede the guard +#include "ktouch_display.h" + +// Compiles only for the touch-LCD board (keeps LovyanGFX out of screenless builds). +#ifdef HAS_TOUCH_DISPLAY + +#include +#include +#include +#include +#define LGFX_USE_V1 +#include +#include "ks_config.h" +#include "tempo_source.h" +#include "axs5106l.h" +#include "ktouch_ui.h" +#include "ktouch_transport.h" +#include "transport_launch.h" + +extern KsConfig g_config; +extern char g_ks_host[]; +extern bool g_ap_mode; // true = SoftAP setup: show how to connect, not a name + +// Panel: JD9853 via Panel_ST7789 (validated on glass, LNK-014). Same config as +// X32Link/touch_display.cpp. 172x320 visible in 240x320 RAM. +class LGFX : public lgfx::LGFX_Device { + lgfx::Panel_ST7789 _panel; + lgfx::Bus_SPI _bus; +public: + LGFX() { + { auto c = _bus.config(); + c.spi_host = SPI2_HOST; c.spi_mode = 0; + c.freq_write = 40000000; c.freq_read = 16000000; + c.pin_sclk = 38; c.pin_mosi = 39; c.pin_miso = -1; c.pin_dc = 45; + c.dma_channel = SPI_DMA_CH_AUTO; + _bus.config(c); _panel.setBus(&_bus); } + { auto c = _panel.config(); + c.pin_cs = 21; c.pin_rst = 40; c.pin_busy = -1; + c.memory_width = 240; c.memory_height = 320; + c.panel_width = 172; c.panel_height = 320; + c.offset_x = 34; c.offset_y = 0; + c.invert = false; c.rgb_order = false; + _panel.config(c); } + setPanel(&_panel); + } +}; + +#define LCD_BL 46 +#define TP_SDA 42 +#define TP_SCL 41 +#define TP_RST 47 +#define SCR_W 320 +#define SCR_H 172 +#define WHEEL_CX 292 +#define WHEEL_CY 30 +#define WHEEL_R 24 +#define TOGGLE_Y 66 // full-width transport toggle fills y 66..171 + +static LGFX s_lcd; +static float s_shown_bpm = -1.0f; +static int s_shown_sync = -2; +static int s_shown_state = -1; +static int s_prev_mx = -1, s_prev_my = -1; +static bool s_touch_down = false; +static bool s_cueing = false; // turntable: held, armed-in-hand, no MIDI yet +static int s_touch_fails = 0; // consecutive failed I2C reads (debounce, see tick) +// Bench probe: the cue was leaving the screen mid-hold and /status could not see why. +// Counts the two ways a hold can be broken so the wire can tell us which it is. +static uint32_t s_fail_total = 0; // I2C reads that failed outright +static uint32_t s_zero_total = 0; // reads that SUCCEEDED but reported 0 touch points +static uint32_t s_cue_cancels = 0; // cues killed by the sustained-fault path +#define CUE_DISP 99 // synthetic shown-state for the cue panel +#define TOUCH_FAIL_LIMIT 10 // ~50ms of dead sensor before we call it a fault +#define RELEASE_CONFIRM 3 // immediate re-reads that must ALL agree a finger left + +// PWM the backlight (ledc via analogWrite) so it can be dimmed. Clamp to the +// config's floor is the caller's job; here we just refuse full-dark and full range. +static void backlight_apply(int pct) { + if (pct < 0) pct = 0; if (pct > 100) pct = 100; + analogWrite(LCD_BL, pct * 255 / 100); +} +void ktouch_display_set_brightness(int pct) { backlight_apply(pct); } + +// Bench probe accessors (see the counters above). +uint32_t ktouch_touch_fails(void) { return s_fail_total; } +uint32_t ktouch_touch_zeros(void) { return s_zero_total; } +uint32_t ktouch_cue_cancels(void) { return s_cue_cancels; } +int ktouch_cueing(void) { return s_cueing ? 1 : 0; } + +// I2C read + pure parse of the AXS5106L (0x63). axs5106l itself is pure (no Wire). +static bool read_touch(axs_touch_t *t) { + Wire.beginTransmission(AXS5106L_I2C_ADDR); + Wire.write(AXS5106L_TOUCH_REG); + if (Wire.endTransmission(true) != 0) return false; + uint8_t buf[AXS5106L_REPORT_LEN]; + if (Wire.requestFrom((int)AXS5106L_I2C_ADDR, (int)AXS5106L_REPORT_LEN) != AXS5106L_REPORT_LEN) + return false; + for (int i = 0; i < AXS5106L_REPORT_LEN; i++) buf[i] = Wire.read(); + return axs5106l_parse(buf, sizeof(buf), t) == 0; +} + +// The whole lower area is one transport toggle; colour + label follow the state. +static void draw_toggle(int state) { + uint32_t bg; const char* big; const char* hint; + if (state == TL_RUNNING) { bg = 0x1f7a0eu; big = "PLAYING"; hint = "tap to STOP"; } + else if (state == TL_ARMED) { bg = 0xb0701au; big = "ARMING"; hint = "starts next bar"; } + else { bg = 0x7a1810u; big = "STOPPED"; hint = "tap to PLAY"; } + s_lcd.fillRect(0, TOGGLE_Y, SCR_W, SCR_H - TOGGLE_Y, bg); + s_lcd.setTextColor(TFT_WHITE, bg); + s_lcd.setTextSize(5); s_lcd.setCursor(16, TOGGLE_Y + 14); s_lcd.print(big); + s_lcd.setTextSize(2); s_lcd.setCursor(16, TOGGLE_Y + 66); s_lcd.print(hint); +} + +// Turntable cue: held, armed-in-hand. Amber, distinct from ARMED; the drop +// happens on the "1" after release (transport_launch quantizes the PLAY). +static void draw_cue(void) { + uint32_t bg = 0x8a5a12u; + s_lcd.fillRect(0, TOGGLE_Y, SCR_W, SCR_H - TOGGLE_Y, bg); + s_lcd.setTextColor(TFT_WHITE, bg); + s_lcd.setTextSize(5); s_lcd.setCursor(16, TOGGLE_Y + 14); s_lcd.print("CUE"); + s_lcd.setTextSize(2); s_lcd.setCursor(16, TOGGLE_Y + 66); s_lcd.print("release on the 1"); +} + +// Top strip: header + wheel outline. Redrawn on a sync-state change. +static void draw_frame(void) { + s_lcd.fillScreen(TFT_BLACK); + s_lcd.setTextColor(0x838d95u, TFT_BLACK); s_lcd.setTextSize(1); + s_lcd.setCursor(8, 4); s_lcd.println("KITCHENSYNC TOUCH"); + s_lcd.drawCircle(WHEEL_CX, WHEEL_CY, WHEEL_R, TFT_DARKGREEN); + s_prev_mx = s_prev_my = -1; +} + +void ktouch_display_begin(void) { + s_lcd.init(); + s_lcd.setRotation(7); // landscape 320x172 + backlight_apply(g_config.lcd_brightness); + draw_frame(); + draw_toggle(TL_STOPPED); s_shown_state = TL_STOPPED; + pinMode(TP_RST, OUTPUT); + digitalWrite(TP_RST, LOW); delay(20); + digitalWrite(TP_RST, HIGH); delay(80); + Wire.begin(TP_SDA, TP_SCL); Wire.setClock(400000); +} + +void ktouch_display_tick(void) { + float bpm = tempo_source_bpm(); + int sync = tempo_source_phase_valid() ? 1 : (tempo_source_active() ? 0 : -1); + int state = ktouch_transport_state(); + uint32_t now = millis(); + + // ---- transport toggle: ANY touch flips it (no buttons -> no mis-hit) ---- + // The DECISION is pure and host-tested (ktouch_touch_step); this glue owns only + // the sensor read and the two carried bools. A failed read is reported as a fault + // rather than swallowed: debounce it (a transient I2C hiccup must not cancel a + // cue), but once the sensor is really gone, say so -- otherwise a stuck CUE panel + // has no way back. + axs_touch_t t; + bool ok = read_touch(&t); + bool touched = ok && t.points_len > 0; + if (!ok) s_fail_total++; + + // PHANTOM RELEASE (measured on the bench: 17 of these across a handful of holds). + // The AXS5106L intermittently returns a perfectly SUCCESSFUL read that reports zero + // touch points while a finger is still on the glass. Believing one is a release -- + // and in turntable mode a release IS the drop, so the sensor blinking fires the beat + // by itself, mid-hold. + // + // Confirm by re-reading IMMEDIATELY rather than by waiting ticks. The drop is timed + // to the "1": a debounce measured in milliseconds could push the release past the + // bar line and cost a whole bar. Three back-to-back reads on a 400kHz bus cost well + // under a millisecond, and any one of them seeing the finger proves it never left. + if (ok && !touched && s_touch_down) { + s_zero_total++; + for (int i = 0; i < RELEASE_CONFIRM && !touched; i++) { + axs_touch_t r; + if (read_touch(&r) && r.points_len > 0) touched = true; // still there: a blink + } + } + + if (ok) s_touch_fails = 0; + else if (s_touch_fails < TOUCH_FAIL_LIMIT) { s_touch_fails++; return; } // transient: hold state + if (!ok && s_cueing) s_cue_cancels++; + + KtouchTouchOut o = ktouch_touch_step(state, ok, touched, s_touch_down, + s_cueing, g_config.play_on_release != 0); + s_touch_down = touched; + s_cueing = o.cueing; + if (o.intent != TL_INTENT_NONE) ktouch_transport_post(o.intent); + + // ---- toggle zone repaint: cue panel while held, else follow launch state ---- + if (s_cueing) { + if (s_shown_state != CUE_DISP) { draw_cue(); s_shown_state = CUE_DISP; } + } else if (state != s_shown_state) { + draw_toggle(state); s_shown_state = state; + } + + // ---- status text (top strip): throttled, redraw on change ---- + static uint32_t s_last_text = 0; + if (now - s_last_text >= 200 && + (fabsf(bpm - s_shown_bpm) >= 0.05f || sync != s_shown_sync)) { + s_last_text = now; + bool sync_changed = sync != s_shown_sync; + s_shown_bpm = bpm; s_shown_sync = sync; + + // Tempo, with the tenth shown small -- the same shape as the web readout, and + // the only way the decimal fits: at size 4 a glyph is 24px, so a full "168.0" + // would run to x=128 and collide with the sync text at x=130. Big whole number, + // small tenth, unit after it. + // + // Link tempos are genuinely fractional (a session at 120.4 was displaying as + // "120"), and the redraw threshold below was already 0.05 -- the display could + // always SEE the tenth, it just never showed it. + // + // Round to tenths ONCE and split: computing the whole and the fraction + // separately would print 167.96 as "167.0" -- the whole truncates down while + // the tenth rounds up. + int tenths = (bpm > 0.0f) ? (int)(bpm * 10.0f + 0.5f) : 0; + + s_lcd.fillRect(0, 16, 128, 46, TFT_BLACK); + s_lcd.setTextColor(0xB6FF36u, TFT_BLACK); + if (bpm > 0.0f) { + s_lcd.setTextSize(4); s_lcd.setCursor(8, 18); + s_lcd.printf("%3d", tenths / 10); // 3 glyphs @24px -> x 8..80 + s_lcd.setTextSize(2); s_lcd.setCursor(80, 34); + s_lcd.printf(".%d", tenths % 10); // bottom-aligned with the big text + } else { + s_lcd.setTextSize(4); s_lcd.setCursor(8, 18); s_lcd.print("---"); + } + s_lcd.setTextColor(0x6f8a4du, TFT_BLACK); s_lcd.setTextSize(1); + s_lcd.setCursor(108, 44); s_lcd.println("BPM"); + + s_lcd.fillRect(120, 14, 150, 44, TFT_BLACK); + s_lcd.setTextColor(sync == 1 ? 0xB6FF36u : 0xFF9D3Bu, TFT_BLACK); s_lcd.setTextSize(1); + s_lcd.setCursor(130, 16); s_lcd.println(sync == 1 ? "SYNCED" : sync == 0 ? "linking" : "no link"); + s_lcd.setTextColor(TFT_WHITE, TFT_BLACK); s_lcd.setTextSize(1); + s_lcd.setCursor(130, 34); + if (g_ap_mode) { s_lcd.setTextColor(0xFF9D3Bu, TFT_BLACK); s_lcd.print("SETUP: join KSTouch-Config -> "); s_lcd.println(g_ks_host); } + else if (WiFi.status() == WL_CONNECTED) { s_lcd.print(g_ks_host); s_lcd.println(".local"); } + else s_lcd.println("no wifi"); + + if (sync_changed) s_prev_mx = -1; + } + + // ---- sync wheel marker (top-right) ---- + float phase = tempo_source_phase((float)g_config.quantum_beats); + if (phase >= 0.0f && g_config.quantum_beats > 0) { + float ang = fmodf(phase / (float)g_config.quantum_beats, 1.0f) * 360.0f; + float rad = (ang - 90.0f) * 0.01745329f; + int mx = WHEEL_CX + (int)(cosf(rad) * (WHEEL_R - 5)); + int my = WHEEL_CY + (int)(sinf(rad) * (WHEEL_R - 5)); + if (mx != s_prev_mx || my != s_prev_my) { + if (s_prev_mx >= 0) s_lcd.fillCircle(s_prev_mx, s_prev_my, 4, TFT_BLACK); + s_lcd.drawCircle(WHEEL_CX, WHEEL_CY, WHEEL_R, TFT_DARKGREEN); + s_lcd.fillCircle(mx, my, 3, TFT_GREEN); + s_prev_mx = mx; s_prev_my = my; + } + } +} + +#else // no touch display on this board -> no-ops +void ktouch_display_begin(void) {} +void ktouch_display_tick(void) {} +void ktouch_display_set_brightness(int pct) { (void)pct; } +uint32_t ktouch_touch_fails(void) { return 0; } +uint32_t ktouch_touch_zeros(void) { return 0; } +uint32_t ktouch_cue_cancels(void) { return 0; } +int ktouch_cueing(void) { return 0; } +#endif diff --git a/KitchenSyncTouch/ktouch_display.h b/KitchenSyncTouch/ktouch_display.h new file mode 100644 index 0000000..3748b31 --- /dev/null +++ b/KitchenSyncTouch/ktouch_display.h @@ -0,0 +1,13 @@ +#pragma once +// KitchenSync Touch on-device display (ESP-016). Status screen (Inc 1c): live BPM +// + sync state + IP. The transport PLAY/STOP screen + touch input land in Inc2. +void ktouch_display_begin(void); +void ktouch_display_tick(void); +void ktouch_display_set_brightness(int pct); // 10..100, live (PWM backlight) + +// Bench probe: why did a cue leave the screen mid-hold? /status surfaces these. +#include +uint32_t ktouch_touch_fails(void); // I2C reads that failed outright +uint32_t ktouch_touch_zeros(void); // reads that SUCCEEDED but said 0 points while held +uint32_t ktouch_cue_cancels(void); // cues killed by the sustained-fault path +int ktouch_cueing(void); // 1 = CUE panel showing right now diff --git a/KitchenSyncTouch/ktouch_legacy_config.h b/KitchenSyncTouch/ktouch_legacy_config.h new file mode 100644 index 0000000..3ef82c8 --- /dev/null +++ b/KitchenSyncTouch/ktouch_legacy_config.h @@ -0,0 +1,83 @@ +#pragma once +// ESP-042: the Touch's OLD config layouts, FROZEN, kept ONLY so the final +// AppConfig->KsConfig migration can read blobs already in the field. When the Touch +// stored its own `AppConfig` (app_config.h, now deleted) it wrote these bytes; a device +// that upgrades to the converged firmware holds one of them in NVS and must not lose its +// WiFi credentials or settings. This header is READ-ONLY history: nothing here may ever +// change, exactly like the frozen structs inside app_config.h were. +// +// Byte-for-byte copies of what shipped: +// v1 ESP-016 single wifi_ssid/wifi_pass, no ppqn/swing/tempo (152 bytes) +// v2 ESP-030 wifi[KS_WIFI_SLOTS] (316 bytes) +// v3 ESP-030 pt3 + ppqn + swing_mbeats (324 bytes) +// v4 ESP-037 + tempo_mbpm (the LAST AppConfig layout) (328 bytes) +#include +#include "ks_config.h" // the SHARED WifiCred / KS_WIFI_SLOTS + +#ifdef __cplusplus +extern "C" { +#endif + +#ifdef __cplusplus +# define KTOUCH_LEGACY_STATIC_ASSERT(cond, msg) static_assert(cond, msg) +#else +# define KTOUCH_LEGACY_STATIC_ASSERT(cond, msg) _Static_assert(cond, msg) +#endif + +typedef struct { + char wifi_ssid[64]; + char wifi_pass[64]; + int quantum_beats; + int clock_enable; + int transport_enable; + int play_on_release; + int nudge_mbeats; + int brightness; +} KTouchLegacyV1; +KTOUCH_LEGACY_STATIC_ASSERT(sizeof(KTouchLegacyV1) == 152, + "the v1 Touch layout is FROZEN — it describes bytes already in the field"); + +typedef struct { + WifiCred wifi[KS_WIFI_SLOTS]; + int quantum_beats; + int clock_enable; + int transport_enable; + int play_on_release; + int nudge_mbeats; + int brightness; +} KTouchLegacyV2; +KTOUCH_LEGACY_STATIC_ASSERT(sizeof(KTouchLegacyV2) == 316, + "the v2 Touch layout is FROZEN"); + +typedef struct { + WifiCred wifi[KS_WIFI_SLOTS]; + int quantum_beats; + int clock_enable; + int transport_enable; + int play_on_release; + int nudge_mbeats; + int brightness; + int ppqn; + int swing_mbeats; +} KTouchLegacyV3; +KTOUCH_LEGACY_STATIC_ASSERT(sizeof(KTouchLegacyV3) == 324, + "the v3 Touch layout is FROZEN"); + +typedef struct { + WifiCred wifi[KS_WIFI_SLOTS]; + int quantum_beats; + int clock_enable; + int transport_enable; + int play_on_release; + int nudge_mbeats; + int brightness; + int ppqn; + int swing_mbeats; + int tempo_mbpm; +} KTouchLegacyV4; +KTOUCH_LEGACY_STATIC_ASSERT(sizeof(KTouchLegacyV4) == 328, + "the v4 Touch layout is FROZEN — the LAST AppConfig, the bench unit holds one"); + +#ifdef __cplusplus +} +#endif diff --git a/KitchenSyncTouch/ktouch_midi_out.cpp b/KitchenSyncTouch/ktouch_midi_out.cpp new file mode 100644 index 0000000..99b33e2 --- /dev/null +++ b/KitchenSyncTouch/ktouch_midi_out.cpp @@ -0,0 +1,313 @@ +// KitchenSync Touch MIDI writer — DIN clock (ESP-016). +// +// DIN-ONLY, deliberately. Dan's RC-505 is driven over a 5-pin DIN jack, so this +// product has no reason to be a USB-MIDI device -- and being one actively hurts: +// USB.begin() with a MIDI interface turns the S3 into a composite CDC+MIDI device, +// and that re-enumeration knocks the native USB-CDC serial/upload port off the +// host (diagnosed on the bench -- the port only returns via the ROM download mode). +// Keeping USB plain CDC = stable serial. USB-MIDI can be an opt-in feature later. +// +// The tick TIMING is the pure, host-tested clock_output engine (over +// clock_ticker) — the same derivation KitchenSync's plan loop uses (ARC-019), +// so nudge/swing/division and the reset-on-invalid rule have one tested owner. +#include "ktouch_midi_out.h" +#include "clock_output.h" // ClockOutput: swing + nudge + ppqn -> pulses due +#include "din_midi_out.h" +#include "tempo_source.h" +#include "transport_launch.h" // bar-quantized PLAY/STOP (ESP-011 pure engine) +#include "ktouch_transport.h" // one-slot press mailbox +#include "beat_source.h" // ESP-027: free-run through a lost GhostXForm (ARC-007) +#include "master_clock.h" // ESP-027: originate a clock when solo (P4-040) +#include "link_protocol.h" // link_proto_timeline() +#include "link_measurement.h" // link_measurement_current_xform() +#include "esp_timer.h" // esp_timer_get_time() +#include "ks_config.h" // ESP-042: the shared fleet config +#include + +extern KsConfig g_config; + +// Burst cap + reset-on-invalid live in clock_output (CLOCK_OUTPUT_MAX_BURST), +// shared with X32Link's writer instead of copy-pasted into each task (ARC-019). +static ClockOutput s_out; +static TransportLaunch s_tl; +static BeatSource s_bs; // ESP-027 +static MasterClock s_mc; // ESP-027 + +// 1 ms writer task: quantize the current Link beat to 24 PPQN, emit due 0xF8 on +// the DIN wire. Resets when phase isn't valid so we never clock off a stale/absent +// timeline. Transport (0xFA/0xFC) joins this single writer in Inc2b. +/* ESP-018 tick-health probe. The analyzer caught the DIN clock stalling ~50 ms and then + * emitting a compressed catch-up burst -- and because the catch-up is capped by + * MAX_BURST, that means pulses are DROPPED, not merely delayed. On the RC-505 that is + * an audible stutter. + * + * Do not guess which of the two possible causes it is -- measure. `gap` (previous tick's + * end -> this tick's start) vs `work` (time spent inside the tick) separates them: + * + * gap >> 1ms -> the task was never scheduled. The culprit is OUTSIDE this loop: + * starvation by something else on core 1, or a flash-cache stall + * (which freezes BOTH cores regardless of priority). + * work >> 1ms -> a call in here blocked; the per-stage numbers say which. + * + * Worth knowing: this task is pinned to core 1, and Arduino's loopTask runs on core 1 + * too (CONFIG_ARDUINO_RUNNING_CORE=1). So the MIDI writer SHARES a core with the web + * server, the LovyanGFX SPI blits and the I2C touch reads. It out-prioritises them + * (6 vs 1), so plain preemption should cover it -- which is exactly why a large gap + * would be interesting: it would mean something preemption cannot fix. + * + * Published, never logged: an ESP_LOGx in a 1 ms real-time task is a blocking UART + * write, and that WAS the bug on the P4 (P4-033). The first version of that probe even + * logged on every overrun, so the log delayed the next tick, which logged again -- + * 3945 phantom overruns in 40s. It manufactured the fault it measured. */ +static volatile uint32_t s_max_gap_us = 0; +static volatile uint32_t s_max_work_us = 0; +static volatile uint32_t s_overruns = 0; +static volatile uint32_t s_bursts = 0; // ticks that emitted >1 clock = catch-up +static volatile int s_core = -1; // which core this task actually runs on +// Per-stage worst case, captured on the tick that set max_work. `work` exploded from +// 124us (idle) to 2431us the moment a Link session came up -- so a call in here starts +// BLOCKING once there is a real timeline. These say which one. +static volatile uint32_t w_beats = 0, w_clock = 0, w_tport = 0; +// ESP-027 diagnostic: what beat position is the writer ACTUALLY seeing, and from where. +static volatile float s_dbg_beats = 0.0f; +static volatile int s_dbg_locked = -1; +static volatile int s_dbg_active = -1; + +/* ESP-028: the only honest answer to "is the clock alive?" is BYTES ON THE WIRE. + * + * /status used to report `sync` = tempo_source_phase_valid() -- whether a phase ESTIMATE + * exists. That says nothing about whether pulses reach the jack, and during ESP-027 it read + * sync:1 peers:1 clock:1 drop:0 with ZERO bytes on the wire for 138 seconds. Every counter + * green. It did not merely fail to report the fault; it actively said the device was healthy, + * which is why finding it needed a logic analyzer instead of a glance at /status. + * + * The writer is the only thing that knows. It counts what it emits. */ +static volatile uint32_t s_pulses = 0; // lifetime 0xF8 written to the UART +static volatile uint32_t s_last_pulse_ms = 0; // when the last one went out + +/* ESP-028: the SAME lie, one field over. /status reported bpm from tempo_source_bpm() -> + * link_proto_bpm(), which zeroes the moment peers drop. So a device free-running perfectly + * at 120 BPM on the master_clock reported bpm 0.0 -- while emitting a 120 BPM clock. + * + * Take it from the timeline the WRITER is actually clocking off (the arbiter's, which + * survives peer loss), exactly as P4-039 did for the P4: "derive from the settled timeline, + * which survives peer loss -- not link_proto_bpm(), which a caller may zero independently." */ +static volatile float s_bpm = 0.0f; + +uint32_t ktouch_midi_max_gap(void) { return s_max_gap_us; } +uint32_t ktouch_midi_max_work(void) { return s_max_work_us; } +uint32_t ktouch_midi_overruns(void) { return s_overruns; } +uint32_t ktouch_midi_bursts(void) { return s_bursts; } +uint32_t ktouch_midi_w_beats(void) { return w_beats; } +uint32_t ktouch_midi_w_clock(void) { return w_clock; } +uint32_t ktouch_midi_w_tport(void) { return w_tport; } +int ktouch_midi_core(void) { return s_core; } +float ktouch_midi_beats(void) { return s_dbg_beats; } +int ktouch_midi_locked(void) { return s_dbg_locked; } +int ktouch_midi_bs_active(void){ return s_dbg_active; } + +// ESP-028. A monotonic pulse count makes "is the wire alive?" answerable by polling, with +// no analyzer. And the state answers it in one word. +uint32_t ktouch_midi_pulses(void) { return s_pulses; } +float ktouch_midi_bpm(void) { return s_bpm; } + +// At 120 BPM a pulse is due every ~20.8 ms; the slowest tempo we allow is far inside 500 ms. +// So half a second of silence is not slow -- it is STOPPED. +#define KTOUCH_SILENT_AFTER_MS 500 + +const char* ktouch_midi_clock_state(void) { + // Never emitted anything at all: not "locked", not "free". Silent. + if (s_pulses == 0) return "silent"; + if ((uint32_t)(millis() - s_last_pulse_ms) > KTOUCH_SILENT_AFTER_MS) return "silent"; + return s_dbg_locked == 1 ? "locked" : "free"; +} + +static void writer_task(void*) { + s_core = xPortGetCoreID(); // ESP-018: confirm, don't assume + clock_output_reset(&s_out); + transport_launch_reset(&s_tl); + beat_source_reset(&s_bs); // ESP-027 + master_clock_reset(&s_mc); // ESP-027 + uint32_t prev_end = 0; + /* vTaskDelayUntil, NOT vTaskDelay (ESP-018). + * + * vTaskDelay(1) sleeps one tick FROM NOW, so the period re-bases every wake: the + * loop body's time and any preemption latency are ADDED to each cycle instead of + * absorbed. Measured on the bench, that produced gaps of up to 5.7 ms even with the + * board completely idle and no HTTP traffic -- which is exactly the +-5 ms clock + * jitter the analyzer saw (533us stdev, vs the P4's 331us). + * + * vTaskDelayUntil holds an absolute 1 ms cadence: if a tick runs late it fires + * immediately rather than adding another whole tick on top. The clock grid stops + * inheriting the scheduler's slop. */ + TickType_t next_wake = xTaskGetTickCount(); + int s_applied_tempo_mbpm = -1; // ESP-037 + for (;;) { + uint32_t tk0 = micros(); + uint32_t gap = prev_end ? (tk0 - prev_end) : 0; + + /* ESP-037: apply the user's set tempo to the master clock. The WRITER owns s_mc, + * so it reads g_config.tempo_mbpm (which the web task writes) and pushes it in on + * CHANGE -- no cross-thread write to s_mc, same live-read pattern as nudge/ppqn. + * This covers both cold boot (starts -1, so the configured tempo seeds the very + * first tick -> a standalone box free-runs at YOUR tempo with no Link ever) and a + * live /live edit. A Link session still wins: the arbiter ignores s_mc when a peer + * is present. */ + if (g_config.tempo_mbpm != s_applied_tempo_mbpm) { + master_clock_set_bpm(&s_mc, (float)g_config.tempo_mbpm / 1000.0f); + s_applied_tempo_mbpm = g_config.tempo_mbpm; + } + + /* ESP-027: the beat basis comes from beat_source (ARC-007), NOT straight from the + * Link phase estimate. + * + * It used to call tempo_source_beats_now(), which returns -1 the moment the + * committed GhostXForm is invalid -- and clock_output's reset-on-invalid rule then + * emits NOTHING. So any loss of the xform silenced the DIN wire. Measured on the + * analyzer: drop the Link session and the clock stops DEAD and never returns, while + * /status still cheerfully reports sync:1 peers:1 clock:1. Zero bytes in 6 s. + * + * beat_source is what the P4 has always used and the Touch never got: while a + * session exists it prefers the phase-locked position, and when the xform goes away + * it falls back to a FREE-RUNNING accumulator at the last known tempo. The clock + * keeps playing. It signals `reprime` on a basis switch so the tick grid realigns + * instead of dumping a catch-up burst. + * + * have_session stays true here off the gossiped timeline alone -- that is the whole + * point: the timeline is still arriving, only the local host<->ghost mapping is + * momentarily gone, and a clock box must never go silent for that. */ + LinkTimeline link_tl; + bool link_have_session = link_proto_timeline(&link_tl) && link_tl.micros_per_beat > 0; + LinkGhostXForm link_xform = link_measurement_current_xform(); + + /* ESP-027 / P4-040: the arbiter, NOT the raw Link state. + * + * beat_source alone was not enough: it free-runs only while a session EXISTS but the + * xform is invalid. On TOTAL session loss (the last peer leaves) have_session goes + * false, beat_source stops, and the wire goes silent -- measured: kill the only Link + * peer and the DIN clock dies within one beat and NEVER returns, while /status still + * reports sync:1 peers:1 clock:1. + * + * For a CLOCK BOX that is indefensible. Close the laptop and the drum machine must + * keep playing. master_clock_arbiter defers to Link whenever a peer is present and, + * on the peers>0 -> 0 edge, seeds an internal tempo from whatever Link last showed -- + * so the output is continuous across the transition and needs no new UI. + * + * The P4 has had this since P4-040. The Touch never got it: master_clock.h said + * "KitchenSync (P4) only", written when the Touch was not yet the clock box. It is + * now the thing driving the RC-505, so it originates too. */ + MasterArbiterOut mc = master_clock_arbiter(&s_mc, link_proto_peers(), + link_have_session, link_tl, link_xform); + BeatSourceOut bs = beat_source_step(&s_bs, mc.have_session, mc.xform, mc.tl, + esp_timer_get_time()); + if (bs.reprime) clock_output_reset(&s_out); // realign, don't burst + s_dbg_beats = (float)bs.beats; s_dbg_locked = bs.locked; s_dbg_active = bs.active; + s_bpm = (bs.active && mc.tl.micros_per_beat > 0) // ESP-028 + ? (float)(60.0e6 / (double)mc.tl.micros_per_beat) : 0.0f; + double beats = bs.active ? bs.beats : -1.0; + uint32_t t_beats = micros(); + // One call owns the whole derivation (ARC-019): reset-on-invalid + + // dropped banking + burst cap inside clock_output_step; nudge trims + // only the CLOCK phase (+ve = ahead), tempo-relative so it holds + // across tempo changes — transport START stays on the true bar. + // ESP-030 pt3: rate + swing come from config now. The derivation always + // supported both (clock_output.c is the P4's, symlinked); the Touch simply + // had no fields to drive it, so it hardcoded 24/0. Defaults are 24/0, so a + // migrated device emits the exact clock it emitted yesterday. + int n = clock_output_step(&s_out, beats, g_config.clock[0].ppqn, + g_config.clock[0].phase_mbeats, g_config.clock[0].swing_mbeats); + if (n > 1) s_bursts++; // more than one pulse in a 1ms tick = catching up + + /* TRANSPORT FIRST, THEN CLOCK -- and the order is the whole point (ESP-023). + * + * The MIDI spec is explicit: a slave begins playing on the FIRST CLOCK IT + * RECEIVES AFTER a Start. So the Start must arrive BEFORE the clock byte that + * marks the downbeat. Emit the clock first and the slave discards it (it has + * not started yet) and begins on the NEXT one -- exactly one 24-PPQN tick late, + * on every single start, forever: 20.8 ms at 120 BPM. That is what "the drum + * machine falls behind when I start it from stopped" IS. + * + * ESP-023 measured this on the analyzer and fixed it on the P4 (ks_main.c). + * The Touch had the same bug and never got the fix -- it emitted clock at the + * top of the tick and transport at the bottom. Both bytes go out the same UART + * in the order written, so the order of these two blocks is the entire fix. */ + double q = (double)g_config.quantum_beats; + TransportLaunchOut o = transport_launch_step(&s_tl, ktouch_transport_take(), + beats, q, beats >= 0.0); + switch (o.action) { + case TL_START: din_midi_out_byte(0xFA); break; + case TL_STOP: din_midi_out_byte(0xFC); break; + case TL_RESTART: + // ESP-025 realign: Stop then Start, in that order, on the bar line. + // 0xFA is "play from the top", so the slave's pattern restarts at step 1 + // aligned to the bar -- without ever passing through a stopped state. + din_midi_out_byte(0xFC); + din_midi_out_byte(0xFA); + break; + case TL_NONE: + default: break; + } + ktouch_transport_publish_state(o.state); // stopped/armed/running -> display + ktouch_transport_publish_realign(o.realign_armed); // ESP-025 -> lit button + + uint32_t t_tport = micros(); + + // Clock, AFTER transport (see above). + for (int i = 0; i < n; i++) din_midi_out_byte(0xF8); + if (n > 0) { s_pulses += (uint32_t)n; s_last_pulse_ms = millis(); } // ESP-028 + + uint32_t tk1 = micros(); + uint32_t work = tk1 - tk0; + if (gap > s_max_gap_us) s_max_gap_us = gap; + if (work > s_max_work_us) { + s_max_work_us = work; + // Stage split re-labelled to match the new order -- the probe must keep + // measuring the stage it names, or ESP-018's next diagnosis reads a lie. + w_beats = t_beats - tk0; // tempo_source_beats_now() + w_tport = t_tport - t_beats; // clock_output_step + transport byte + publish + w_clock = tk1 - t_tport; // DIN clock bytes + } + if (gap > 5000 || work > 5000) s_overruns++; + prev_end = tk1; + + vTaskDelayUntil(&next_wake, 1); // absolute 1 ms cadence, no drift + } +} + +// Pulses the scheduler THREW AWAY (realign past the burst cap). The banking +// across resets lives in clock_output now (ARC-019); this stays the number +// that matters: a stall long enough to trip the realign leaves no burst and +// no gap on the wire, so before this counter existed it was undetectable. +uint32_t ktouch_midi_dropped(void) { return clock_output_dropped(&s_out); } + +void ktouch_midi_out_begin(int tx_gpio) { + din_midi_out_begin(tx_gpio); // UART1 TX @ 31250 8N1 on the DIN pin + /* ESP-018: priority 19, core 1. Both numbers are measured, not guessed. + * + * PRIORITY. The writer used to run at 6 -- BELOW lwIP's tcpip_task (18) and WiFi + * (23). Those legally preempt it, so under heavy web traffic the network stack + * descheduled the clock generator mid-tick. The stage probe caught it precisely: + * the writer's one call into the Link engine, tempo_source_beats_now(), measured + * 62us idle and 5038us under load -- an 80x blowup with no lock anywhere in that + * path. It was never blocking; it was being PREEMPTED. + * + * On the wire, at 118 BPM under heavy web load: + * priority 6: 1 stall (40ms), 1 burst <- pulses bunched + * priority 19: 0 stalls, 0 bursts <- clean + * and wbeats fell 5038us -> 87us. + * + * 19 sits ABOVE lwIP (18) and BELOW WiFi (23): the clock outranks the network + * stack, but never the radio itself. The task is ~200us of work per 1ms tick, so + * it cannot starve anything. + * + * This is why the P4 never had this bug: its WiFi lives on a separate C6 + * co-processor over SDIO, so its CPU never runs the network stack at all. The S3 + * runs WiFi on-die and has to out-rank it. (It is also most of why the P4 measures + * 331us of jitter and the S3 ~500us.) + * + * CORE. Core 1, deliberately -- alongside Arduino's loopTask, not away from it: + * core 1: gap 5749us, work 124us + * core 0: gap 8927us, work 5952us <- core 0 is where WiFi lives. Worse. + */ + xTaskCreatePinnedToCore(writer_task, "ks_midi_out", 2048, NULL, 19, NULL, 1); +} diff --git a/KitchenSyncTouch/ktouch_midi_out.h b/KitchenSyncTouch/ktouch_midi_out.h new file mode 100644 index 0000000..4de9a2f --- /dev/null +++ b/KitchenSyncTouch/ktouch_midi_out.h @@ -0,0 +1,42 @@ +#pragma once +// KitchenSync Touch MIDI writer (ESP-016). Emits 24-PPQN clock on the DIN wire. +// DIN-ONLY by design -- see ktouch_midi_out.cpp for why USB-MIDI is deliberately +// absent. Transport (0xFA/0xFC) lands here in Inc2b. +void ktouch_midi_out_begin(int tx_gpio); + +// ESP-018 tick health, surfaced in /status. The clock task never logs (an ESP_LOGx in a +// 1ms real-time task is a blocking UART write -- that WAS the bug on the P4, P4-033), so +// it publishes and the web layer reads. +// gap >> 1ms -> the task was not scheduled (starved, or a flash-cache stall, which +// freezes BOTH cores regardless of pinning or priority) +// work >> 1ms -> something inside the tick blocked +// dropped -> pulses the scheduler THREW AWAY on a realign past MAX_BURST. Until +// this counter existed, a stall long enough to trip it left no burst +// and no gap on the wire -- it was undetectable. +#include +uint32_t ktouch_midi_max_gap(void); +uint32_t ktouch_midi_max_work(void); +uint32_t ktouch_midi_overruns(void); +uint32_t ktouch_midi_bursts(void); +uint32_t ktouch_midi_dropped(void); +int ktouch_midi_core(void); // which core the writer really runs on +uint32_t ktouch_midi_w_beats(void); // worst-tick: time inside tempo_source_beats_now() +uint32_t ktouch_midi_w_clock(void); // worst-tick: clock scheduling + DIN writes +uint32_t ktouch_midi_w_tport(void); // worst-tick: transport step + +// ESP-027 diagnostic. +float ktouch_midi_beats(void); +int ktouch_midi_locked(void); +int ktouch_midi_bs_active(void); + +// ESP-028: the writer's own truth. `pulses` is a lifetime count of 0xF8 bytes actually +// written to the UART; `clock_state` is "locked" | "free" | "silent". A wire that has gone +// quiet must be visible in /status without a logic analyzer -- that is the entire lesson of +// ESP-027, where /status reported sync:1 through 138 seconds of silence. +uint32_t ktouch_midi_pulses(void); + +// ESP-028: the BPM the writer is actually clocking off -- from the arbiter's timeline, +// which survives peer loss. link_proto_bpm() zeroes when peers drop, so /status used to +// report 0.0 BPM while emitting a perfectly good 120 BPM clock. +float ktouch_midi_bpm(void); +const char* ktouch_midi_clock_state(void); diff --git a/KitchenSyncTouch/ktouch_status_rgb.c b/KitchenSyncTouch/ktouch_status_rgb.c new file mode 100644 index 0000000..37f60af --- /dev/null +++ b/KitchenSyncTouch/ktouch_status_rgb.c @@ -0,0 +1,42 @@ +#include "ktouch_status_rgb.h" +#include + +// The flash occupies the first slice of each beat: bright then dark, a hammer-fall rather +// than a throb. 0.14 of a beat is ~67 ms at 125 BPM -- readable, not a smear at fast tempi. +#define FLASH_FRACTION 0.14f + +StatusRgb ktouch_status_rgb(TransportLaunchState transport, bool link_locked, + float beats, int quantum) { + const StatusRgb dark = { 0, 0, 0 }; + + // Stopped says nothing, even while the clock free-runs -- the light reflects transport. + if (transport == TL_STOPPED) return dark; + + const int whole = (int)floorf(beats); + const float frac = beats - (float)whole; + if (frac >= FLASH_FRACTION) return dark; // between beats: dark + + if (quantum < 1) quantum = 1; + const bool downbeat = (whole % quantum) == 0; // bar-1 "one" + + if (transport == TL_ARMED) { + // Cocked hammer: amber blink at the beat rate, brighter on the downbeat. Redder + // than green so it reads amber (a warning-to-come), not the running green/cyan. + const StatusRgb amber = { 60, 20, 0 }; + const StatusRgb amber_down = { 110, 38, 0 }; + return downbeat ? amber_down : amber; + } + + // TL_RUNNING: green free-run, cyan when phase-locked to Link. The downbeat is a brighter + // accent of the SAME hue, so the "one" reads without masking the free/Link distinction. + if (link_locked) { + const StatusRgb cyan = { 0, 32, 38 }; + const StatusRgb cyan_down = { 0, 70, 82 }; + return downbeat ? cyan_down : cyan; + } + // Pure green -- NO blue, so it never reads as a dim cyan. The blue channel is what + // distinguishes "locked" at a glance, so free-run must keep it at zero. + const StatusRgb green = { 0, 40, 0 }; + const StatusRgb green_down = { 0, 90, 0 }; + return downbeat ? green_down : green; +} diff --git a/KitchenSyncTouch/ktouch_status_rgb.h b/KitchenSyncTouch/ktouch_status_rgb.h new file mode 100644 index 0000000..8987c27 --- /dev/null +++ b/KitchenSyncTouch/ktouch_status_rgb.h @@ -0,0 +1,34 @@ +#pragma once +// ESP-043: the headless Super Mini's onboard WS2812 (GPIO48) as a status light. A screenless +// clock box has no other local feedback, so the colour must read at a glance across a room. +// This is the STATE -> APPEARANCE decision as a PURE function, host-tested -- it does not live +// in loop() (the same rule that keeps transport/tempo mapping out of the views). +// +// stopped -> dark +// armed -> amber blink at beat rate (a cocked hammer: the tap registered, +// waiting for the bar line -- the DeviceDetail armed cadence) +// running, free-run -> green beat flash +// running, Link-locked -> cyan beat flash (phase-locked to a session) +// bar-1 downbeat -> a brighter accent of the running/armed colour +// +// The flash is HARD-EDGED (bright for the first slice of each beat, dark the rest), never a +// soft throb -- a throb reads as "loading", and a beat is a hammer-fall. +#include +#include +#include "transport_launch.h" // TransportLaunchState (pure, host-tested) + +#ifdef __cplusplus +extern "C" { +#endif + +typedef struct { uint8_t r, g, b; } StatusRgb; + +// Map the live state to the LED colour for THIS instant. `beats` is the writer's continuous +// beat counter (ktouch_midi_beats); `quantum` is beats per bar, for the downbeat accent. +// link_locked is true only when the clock is phase-locked to a Link session. +StatusRgb ktouch_status_rgb(TransportLaunchState transport, bool link_locked, + float beats, int quantum); + +#ifdef __cplusplus +} +#endif diff --git a/KitchenSyncTouch/ktouch_tempo.cpp b/KitchenSyncTouch/ktouch_tempo.cpp new file mode 100644 index 0000000..3a9bdc9 --- /dev/null +++ b/KitchenSyncTouch/ktouch_tempo.cpp @@ -0,0 +1,66 @@ +// KitchenSync Touch tempo seam — Link ONLY (ESP-016). Implements tempo_source.h +// so the reused clock task (midi_clock_out_io) and the display talk to one +// interface. The Link path is lifted verbatim from X32Link/tempo_source.cpp; the +// MIDI-in adapter and its deps (midi_bpm / beat_synth) and the OSC coupling are +// dropped. Do NOT symlink tempo_source.cpp too — this replaces it. +#include "tempo_source.h" +#include "config.h" +#include "ks_config.h" +#include "link_listener.h" +#include "link_measurement_io.h" +#include "link_protocol.h" +#include "link_measurement.h" +#include "link_phase.h" +#include "din_midi_out.h" // MIDI_TX_GPIO +#include "ktouch_midi_out.h" // DIN-only clock writer (no USB-MIDI composite) +#include +#include "esp_timer.h" + +extern KsConfig g_config; // owned by the .ino + +extern "C" void tempo_source_select(int /*kind*/) { /* Link-only product */ } + +// DIN-only product: no USB-MIDI, so nothing to enumerate before WiFi. Leaving USB +// as plain CDC keeps the serial/upload port stable (a composite CDC+MIDI drops it). +extern "C" void tempo_source_pre_net(void) { /* no USB-MIDI (see ktouch_midi_out) */ } + +// Link joins multicast after WiFi; the measurement client keeps a GhostXForm warm. +extern "C" void tempo_source_begin(void) { + link_listener_begin(); + link_measurement_io_begin(); + if (g_config.clock_out_enable) ktouch_midi_out_begin(MIDI_TX_GPIO); // DIN 0xF8 task +} + +extern "C" void tempo_source_poll(void) { + link_listener_poll(); + link_listener_tick(); + link_measurement_io_poll(); +} + +extern "C" float tempo_source_bpm(void) { return (float)link_listener_bpm(); } +extern "C" bool tempo_source_active(void) { return link_listener_peers() > 0; } +extern "C" bool tempo_source_beat(void) { return false; } // Inc1 display doesn't beat-flash + +extern "C" bool tempo_source_phase_valid(void) { + LinkTimeline t; + return link_measurement_have_phase_estimate() && link_proto_timeline(&t); +} + +// Monotonic non-wrapping beats — the clock generator's tick basis (LNK-027). +extern "C" double tempo_source_beats_now(void) { + if (!tempo_source_phase_valid()) return -1.0; + LinkTimeline timeline; + link_proto_timeline(&timeline); + LinkGhostXForm xform = link_measurement_current_xform(); + int64_t ghost_now_us = link_ghost_xform_host_to_ghost(xform, esp_timer_get_time()); + return link_phase_beats_now(timeline, ghost_now_us); +} + +extern "C" float tempo_source_phase(float quantum) { + double b = tempo_source_beats_now(); + if (b < 0.0) return -1.0f; + return (float)link_phase_from_beats(b, (double)quantum); +} + +extern "C" float tempo_source_threshold(void) { return LINK_BPM_THRESHOLD; } +extern "C" uint32_t tempo_source_poll_ms(void) { return LINK_POLL_MS; } diff --git a/KitchenSyncTouch/ktouch_transport.c b/KitchenSyncTouch/ktouch_transport.c new file mode 100644 index 0000000..64718f8 --- /dev/null +++ b/KitchenSyncTouch/ktouch_transport.c @@ -0,0 +1,31 @@ +// One-slot transport intent mailbox — see ktouch_transport.h (ESP-016). +#include "ktouch_transport.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" + +static portMUX_TYPE s_mux = portMUX_INITIALIZER_UNLOCKED; +static TransportLaunchIntent s_slot = TL_INTENT_NONE; +static volatile int s_state = TL_STOPPED; // published by the writer + +void ktouch_transport_post(TransportLaunchIntent intent) { + portENTER_CRITICAL(&s_mux); + s_slot = intent; // latest press wins (a fast STOP after PLAY overrides) + portEXIT_CRITICAL(&s_mux); +} + +TransportLaunchIntent ktouch_transport_take(void) { + portENTER_CRITICAL(&s_mux); + TransportLaunchIntent i = s_slot; + s_slot = TL_INTENT_NONE; + portEXIT_CRITICAL(&s_mux); + return i; +} + +void ktouch_transport_publish_state(int launch_state) { s_state = launch_state; } +int ktouch_transport_state(void) { return s_state; } + +// ESP-025. Plain scalar, one writer (the 1 ms MIDI task) and one reader (loop()) — +// same shape as the launch-state publish above, no lock needed. +static volatile bool s_realign_armed = false; +void ktouch_transport_publish_realign(bool armed) { s_realign_armed = armed; } +bool ktouch_transport_realign_armed(void) { return s_realign_armed; } diff --git a/KitchenSyncTouch/ktouch_transport.h b/KitchenSyncTouch/ktouch_transport.h new file mode 100644 index 0000000..5b4e759 --- /dev/null +++ b/KitchenSyncTouch/ktouch_transport.h @@ -0,0 +1,28 @@ +#pragma once +// KitchenSync Touch transport mailbox (ESP-016). One-slot, cross-task: the touch +// dispatch (loop) posts a press; the MIDI writer task consumes it exactly once. +// Single output (unlike the P4's per-cable array), so a trivial portMUX slot. +#include "transport_launch.h" // TransportLaunchIntent +#include + +#ifdef __cplusplus +extern "C" { +#endif + +void ktouch_transport_post(TransportLaunchIntent intent); +TransportLaunchIntent ktouch_transport_take(void); // consumes; NONE when empty + +// The writer publishes the current launch state (TL_STOPPED/ARMED/RUNNING) each +// tick; the display reads it to colour the toggle and pick the tap direction. +void ktouch_transport_publish_state(int launch_state); +int ktouch_transport_state(void); + +// ESP-025: realign is armed while RUNNING, so it cannot be expressed as a launch +// state without lying about whether the device is playing. The lit REALIGN button +// blinks on this; without it a quantized press looks dead for a whole bar. +void ktouch_transport_publish_realign(bool armed); +bool ktouch_transport_realign_armed(void); + +#ifdef __cplusplus +} +#endif diff --git a/KitchenSyncTouch/ktouch_ui.c b/KitchenSyncTouch/ktouch_ui.c new file mode 100644 index 0000000..44eddb4 --- /dev/null +++ b/KitchenSyncTouch/ktouch_ui.c @@ -0,0 +1,33 @@ +// Pure transport-toggle logic — see ktouch_ui.h (ESP-016). +#include "ktouch_ui.h" + +TransportLaunchIntent ktouch_toggle_intent(int launch_state) { + // Stopped -> start (transport_launch quantizes it to the next bar). Armed + // (waiting for the bar) or running -> stop: a second tap cancels an arm or + // stops playback. + return (launch_state == TL_STOPPED) ? TL_INTENT_PLAY : TL_INTENT_STOP; +} + +KtouchTouchOut ktouch_touch_step(int launch_state, bool touch_ok, bool touched, + bool was_touched, bool cueing, bool play_on_release) { + KtouchTouchOut o = { TL_INTENT_NONE, cueing }; + + // Sensor fault: cancel any cue and send nothing. A failed read is NOT a release + // (that would drop the beat on a dropped I2C transaction) and must not be + // ignored (that stranded the CUE panel on screen forever). + if (!touch_ok) { o.cueing = false; return o; } + + if (touched && !was_touched) { // ---- press ---- + if (!play_on_release) { + o.intent = ktouch_toggle_intent(launch_state); // digital: fire now + } else if (launch_state == TL_STOPPED) { + o.cueing = true; // turntable: cue in hand + } else { + o.intent = TL_INTENT_STOP; // running/armed: stop now + } + } else if (!touched && was_touched) { // ---- release ---- + if (cueing) { o.cueing = false; o.intent = TL_INTENT_PLAY; } // the drop + } + // held, or idle: nothing. A press must not repeat while the finger is down. + return o; +} diff --git a/KitchenSyncTouch/ktouch_ui.h b/KitchenSyncTouch/ktouch_ui.h new file mode 100644 index 0000000..828f50e --- /dev/null +++ b/KitchenSyncTouch/ktouch_ui.h @@ -0,0 +1,49 @@ +#pragma once +// Pure transport-toggle logic (ESP-016). Touch is transport-ONLY and the WHOLE +// screen is one toggle: no left/right buttons (so no mis-hit), no settings screen +// (so no stray-tap config change mid-performance). A tap sends the opposite of +// the current transport state. Host-tested in test/test_ktouch_ui.c. +#include +#include "transport_launch.h" // TransportLaunchIntent, TransportLaunchState + +#ifdef __cplusplus +extern "C" { +#endif + +// Given the current launch state (TL_STOPPED / TL_ARMED / TL_RUNNING), the intent +// a tap should post: stopped -> PLAY (arms, fires on the next bar); armed or +// running -> STOP (cancel the arm, or stop immediately). +TransportLaunchIntent ktouch_toggle_intent(int launch_state); + +// What one touch sample decides. `cueing` is the NEW cue state -- true means the +// screen shows the CUE panel (held, armed in hand, no MIDI sent yet). +typedef struct { + TransportLaunchIntent intent; + bool cueing; +} KtouchTouchOut; + +// The whole touch-edge decision, pure. The glue owns only the sensor read and the +// two carried bools (`was_touched`, `cueing`) -- this used to live inline in +// ktouch_display.cpp next to the Wire reads, where it could not be tested at all. +// +// Two trigger feels: +// digital DJ (play_on_release=0): a PRESS fires the toggle immediately. +// turntable DJ (play_on_release=1): a press on a stopped deck CUES it -- armed in +// hand, no MIDI yet -- and RELEASING drops it. transport_launch quantizes that +// PLAY to the next "1", so releasing just before the bar lands the beat on the +// downbeat. That release IS the feature. +// Either feel: pressing a running/armed deck stops it immediately. Stop is urgent +// and never waits for a release. +// +// `touch_ok` is false when the sensor read FAILED. A fault cancels a cue (cueing -> +// false, intent NONE). It must never be read as a release -- a dropped I2C +// transaction would otherwise fire the beat by itself. Nor can it be ignored: the +// old glue just skipped the tick, so a sensor that stopped answering stranded the +// CUE panel on screen forever. Debouncing a transient failure into a sustained one +// is the caller's job. +KtouchTouchOut ktouch_touch_step(int launch_state, bool touch_ok, bool touched, + bool was_touched, bool cueing, bool play_on_release); + +#ifdef __cplusplus +} +#endif diff --git a/KitchenSyncTouch/ktouch_web.cpp b/KitchenSyncTouch/ktouch_web.cpp new file mode 100644 index 0000000..d11f6ab --- /dev/null +++ b/KitchenSyncTouch/ktouch_web.cpp @@ -0,0 +1,683 @@ +// KitchenSync Touch web config server — see ktouch_web.h (ESP-016, Inc3). +#include "ktouch_web.h" +#include "ks_config.h" // ESP-042: the SHARED fleet config (retired AppConfig) +#include "ktouch_config_nvs.h" // ESP-042: config_load/config_save on KsConfig +#include "ui_chrome.h" +#include "fw_version.h" +#include "tempo_source.h" // live BPM / sync for /status +#include "link_protocol.h" // link_proto_peers() +#include "ktouch_transport.h" // transport state for /status +#include "ktouch_display.h" // live backlight brightness +#include "ktouch_midi_out.h" // ESP-018 tick health +#include "ks_status.h" // ESP-029: the SHARED /status builder — one shape across the fleet +#include "ks_config_json.h" // ESP-030: the SHARED /config.json builder + KsCaps +#include "config.h" // ESP-030: KSTOUCH_HAS_* — what is actually wired to this board +// ESP-025 bench-rig button telemetry. Only the DevKit has buttons, and these live in the +// .ino behind HAS_BUTTONS -- referencing them unconditionally breaks the S3 product link. +#ifdef HAS_BUTTONS +uint32_t ktouch_btn_presses(void); uint32_t ktouch_btn_lows(void); int ktouch_btn_level(void); +#else +static inline uint32_t ktouch_btn_presses(void) { return 0; } +static inline uint32_t ktouch_btn_lows(void) { return 0; } +static inline int ktouch_btn_level(void) { return -1; } // -1 = no buttons fitted +#endif +#include "config_persist.h" // ARC-022: debounced write-through for live edits +#include +#include +#include +#include // ESP-020: OTA into the inactive app slot + +#define UPDATE_PAGE_MAX 3072 // ui_update_page worst case + slack (ESP-020: the fetch + // variant renders 2386 bytes, so 2560 left only 174 spare) + +extern KsConfig g_config; + +static WebServer server(80); + +// ESP-042: the Touch's config edits now go through the SHARED grammar (ks_config_set) and, +// for live edits, the SHARED live-safe field set (ks_config_live_safe_copy) -- the exact +// path the P4 uses. This is the DRY win the whole ticket exists for: a live field can no +// longer be silently dropped (the ESP-037 bug), because ONE list owns "what is live". +// +// Small helper: apply a present form arg through the shared setter, returning whether the +// key was present AND accepted. The Touch page names two fields for humans (`nudge`, the +// BARS `quantum`) that differ from the wire keys; those are translated at the call site. +static bool set_if_present(KsConfig* c, const char* arg_key, const char* wire_key) { + if (!server.hasArg(arg_key)) return false; + return ks_config_set(c, wire_key, server.arg(arg_key).c_str()); +} + +// ARC-022: /nudge and /bright used to apply and return -- the edit was real until the +// next power cycle and then silently wasn't. They now mark the config dirty and +// ktouch_web_tick() writes the blob once the edits settle. Marked, not written, +// because config is ONE nvs blob: a slider drag is dozens of POSTs. +static ConfigPersist s_persist; + +// The config form body (between the shared +
+ +
KITCHEN·SYNC TOUCHFW %FWVER%
+
+
Session TempoAbleton Link
+
188.8--.-BPM
+
+
+
0
+
No Link
+
Off
+
Stopped
+
+
+ + +
+
WiFi Network
+
SSID
+
PASS
+
Transport + +
quantized MIDI Start / Stop over the DIN jack
+
+
Launch Quantize +
BARS
+
bars per phrase (4/4) — start fires on the next phrase line
+
Play On Release + +
turntable feel — transport fires when you lift off
+
+
MIDI Clock Out + +
24 PPQN clock to the DIN jack
+
+
Clock Nudge +
NUDGE
+
millibeats — slide the DIN clock into the pocket, live
+
+
Display
+
BRIGHT
+
LCD backlight (%) — applies live
+
+
+
+
KitchenSync Touch · FW %FWVER% · Firmware Update
+
+ +)HTML"; + +// The config model stores Link beats; the UI speaks bars (4/4). 4 beats = 1 bar. +static const int BEATS_PER_BAR = 4; + +static void handle_root() { + String h(FORM); + h.replace("%CSS%", ui_chrome_css()); + h.replace("%JS%", ui_chrome_js()); + h.replace("%SSID%", g_config.wifi[0].ssid); + int bars = g_config.quantum_beats / BEATS_PER_BAR; if (bars < 1) bars = 1; + h.replace("%Q%", String(bars)); + h.replace("%NUDGE%", String(g_config.clock[0].phase_mbeats)); + h.replace("%BRIGHT%", String(g_config.lcd_brightness)); + h.replace("%CLK%", g_config.clock_out_enable ? "checked" : ""); + h.replace("%TP%", g_config.transport_enable ? "checked" : ""); + h.replace("%REL%", g_config.play_on_release ? "checked" : ""); + h.replace("%FWVER%", FW_VERSION); + server.send(200, "text/html", h); +} + +static void handle_save() { + KsConfig c = g_config; + + /* ESP-030 pt3: EVERY WiFi slot, not just slot 0. + * + * This handler used to read `wifi_ssid`/`wifi_pass` and nothing else. So a client + * that POSTed `wifi_ssid1` got HTTP 200 and its network was SILENTLY DISCARDED — + * the user saved a second network, rebooted, and it was gone. A 200 that quietly + * drops the field is worse than a 404: the client believes it worked. + * + * Slot 0 is the unsuffixed key; slots 1..n take the index as a suffix — the same + * grammar ks_config_set() parses on the P4, so ONE client form fits the fleet. + * + * The two blank cases are NOT the same, and collapsing them loses data either way: + * + * blank PASSWORD = keep the current one. The form cannot show a password, so a + * user who edits only the SSID must not silently wipe it. + * blank SSID = FORGET this network — and that takes the password WITH it. + * Leaving the credential behind means a "deleted" network is + * still sitting in NVS, and /config.json goes on reporting + * pass_set:true for a slot that has no network. + * + * Both rules are ks_config_set()'s, host-tested in test_ks_config.c. */ + for (int i = 0; i < KS_WIFI_SLOTS; i++) { + char key[16]; + if (i == 0) snprintf(key, sizeof(key), "wifi_ssid"); + else snprintf(key, sizeof(key), "wifi_ssid%d", i); + bool forgotten = false; + if (server.hasArg(key)) { + /* HOLD THE String, never a char* into it. server.arg() returns a String BY + * VALUE; `const char* s = server.arg(k).c_str()` leaves s dangling into a + * temporary that died at the semicolon. That bug shipped for one flash and + * wrote FREED HEAP into the SSID -- the board came up trying to join a + * network nobody had ever configured, and fell back to SoftAP. */ + String ssid = server.arg(key); + if (ssid.length() == 0) { + memset(&c.wifi[i], 0, sizeof(c.wifi[i])); // network AND password + forgotten = true; + } else { + strlcpy(c.wifi[i].ssid, ssid.c_str(), sizeof(c.wifi[i].ssid)); + } + } + + if (i == 0) snprintf(key, sizeof(key), "wifi_pass"); + else snprintf(key, sizeof(key), "wifi_pass%d", i); + if (!forgotten && server.hasArg(key) && server.arg(key).length()) + strlcpy(c.wifi[i].pass, server.arg(key).c_str(), sizeof(c.wifi[i].pass)); + } + + /* CHECKBOXES, and the trap they set. + * + * An unchecked HTML checkbox sends NOTHING. So "absent = off" is the only way the + * device's own page can ever turn one OFF -- but it also means any client that + * POSTs a PARTIAL form silently switches off every box it didn't know to send. + * + * That is not hypothetical. It cost the user their transport twice over: + * - a bench POST that set only wifi/clk0_* turned clock_enable AND + * transport_enable to 0, killing play/stop on the touchscreen; + * - and the iOS app's saveFormFields does not emit `transport` or `play_rel` AT + * ALL -- they are Touch-only fields its KsConfig has never had. Saving a WiFi + * network from the app would have disabled the transport every time. + * + * So absent = off applies ONLY when the sender says "this is the whole form". The + * device's page posts the `full_form` sentinel (see FORM); a partial patch does + * not, and its silence about a checkbox means "leave it alone", not "turn it off". + * + * /live is the endpoint for partial edits. /save being partial-tolerant is a + * SAFETY net, not an invitation. */ + /* ESP-042: everything below goes through the SHARED ks_config_set grammar, the exact + * keys the P4 and the iOS app already use. `clock` (the page's master toggle) and a + * lone `clk0_en` from the app both mean "the clock" on this single-output box, so both + * map to `clock_out` (the master gate); clock[0].enable stays pinned on. */ + const bool full_form = server.hasArg("full_form"); + auto checkbox = [&](const char* name, const char* wire_key) { + if (full_form || server.hasArg(name)) + ks_config_set(&c, wire_key, server.hasArg(name) ? "1" : "0"); + }; + checkbox("clock", "clock_out"); + checkbox("transport", "transport"); + checkbox("play_rel", "play_rel"); + + // The BARS field is bars; the shared `quantum` key is BEATS (x4). NUDGE persists + // whatever /live set, onto the single DIN output's clk0_phase. + if (server.hasArg("quantum")) { + char beats[12]; + snprintf(beats, sizeof(beats), "%ld", server.arg("quantum").toInt() * BEATS_PER_BAR); + ks_config_set(&c, "quantum", beats); + } + set_if_present(&c, "nudge", "clk0_phase"); + set_if_present(&c, "bright", "bright"); + + /* The SHARED per-output grammar, so the iOS app's /save and the P4's /save are the + * same request. clk0_* are this device's single DIN output. */ + set_if_present(&c, "clock_out", "clock_out"); + set_if_present(&c, "clk0_en", "clock_out"); // single output: enabling it == the clock + set_if_present(&c, "clk0_phase","clk0_phase"); + set_if_present(&c, "clk0_swing","clk0_swing"); + set_if_present(&c, "clk0_ppqn", "clk0_ppqn"); + + if (!ks_config_valid(&c)) { + char p[1024]; ui_result_page(p, sizeof(p), "Invalid Config", "Check the values and go back.", false); + server.send(200, "text/html", p); return; + } + g_config = c; + config_save(&g_config); + char p[1024]; ui_result_page(p, sizeof(p), "Saved — Restarting", "Reconnect if WiFi changed.", true); + server.send(200, "text/html", p); + delay(800); + ESP.restart(); +} + +// Live status for the 1 Hz poll. +// +// ESP-028: `sync` used to be tempo_source_phase_valid() -- whether a phase ESTIMATE exists. +// That is not what anyone is asking. During ESP-027 it reported sync:1 peers:1 clock:1 drop:0 +// while the DIN wire was DEAD for 138 seconds. It did not merely fail to show the fault; it +// said the device was healthy, which is why the fault needed a logic analyzer to find. +// +// `sync` now reports whether PULSES ARE REACHING THE JACK, sourced from the writer, which is +// the only thing that knows: +// 1 locked -- phase-locked to a Link session, pulses flowing +// 0 free -- free-running (no xform, or solo via master_clock), pulses flowing +// -1 silent -- NOT EMITTING. The state that must never be invisible again. +// +// `clk` carries the same thing as a word, and `pulses` is a lifetime 0xF8 count so a poller +// can watch the wire advance without an analyzer. +static void handle_status() { + // ESP-028: from the WRITER, not link_proto_bpm() -- see ktouch_midi_out.h. A free-running + // clock has a tempo; reporting 0.0 while emitting 120 BPM is the same class of lie as sync. + float bpm = ktouch_midi_bpm(); + if (bpm <= 0.0f) bpm = tempo_source_bpm(); // pre-writer (clock disabled): fall back + const char* clk = ktouch_midi_clock_state(); // "locked" | "free" | "silent" + int sync = (strcmp(clk, "locked") == 0) ? 1 + : (strcmp(clk, "free") == 0) ? 0 + : -1; + + /* ESP-029: /status is now built by the SHARED ks_status_json (X32Link/ks_status.c), + * the same function KitchenSync uses. This device used to hand-roll its own snprintf + * here, and the two drifted -- the same nine tick-health fields, spelled `wbeats` here + * and `w_beats` there. Nobody chose that; it is just what a second implementation does. + * + * The clock ENGINE was already shared (46 symlinked modules from X32Link/). Only the + * web layer was not, which is exactly where the drift lived. Now it is shared too, so + * the fleet emits one shape by construction rather than by discipline (ARC-024). + * + * A client (the iOS app) could not decode this device AT ALL before this change: + * KsStatus threw on the first required key it was missing. */ + + /* The launch state IS the shared TL_STOPPED/TL_ARMED/TL_RUNNING enum + * (transport_launch.h) -- the same one the P4 puts in launch[]. Nothing to invent: + * this device already tracked it, it simply never published it. + * + * ONE output. The array length IS the output count -- never padded to four, or a + * client would render one real output card and three dead ones. */ + const int launch[1] = { ktouch_transport_state() }; + + WebTickHealth tick = { + .dropped = ktouch_midi_dropped(), + .bursts = ktouch_midi_bursts(), + .max_gap_us = ktouch_midi_max_gap(), + .max_work_us= ktouch_midi_max_work(), + .overruns = ktouch_midi_overruns(), + .w_beats = ktouch_midi_w_beats(), + .w_clock = ktouch_midi_w_clock(), + .core = ktouch_midi_core(), + .reprimes = 0, + }; + + const uint32_t pulses = ktouch_midi_pulses(); + + /* This device's OWN diagnostics, which its page reads. They ride in `extra` so the + * SHARED keys stay byte-identical with KitchenSync's while this device can still say + * more. `sync` is kept as a legacy alias of `clk` for the existing page JS. */ + char extra[220]; + snprintf(extra, sizeof(extra), + "\"sync\":%d,\"clock\":%d,\"transport\":%d,\"cue\":%d," + "\"tfail\":%lu,\"tzero\":%lu,\"ccancel\":%lu,\"wtport\":%lu," + "\"beats\":%.2f,\"locked\":%d,\"bsactive\":%d," + "\"btn\":%d,\"btnlows\":%lu,\"btnpress\":%lu", + sync, g_config.clock_out_enable ? 1 : 0, ktouch_transport_state(), ktouch_cueing(), + (unsigned long)ktouch_touch_fails(), (unsigned long)ktouch_touch_zeros(), + (unsigned long)ktouch_cue_cancels(), (unsigned long)ktouch_midi_w_tport(), + (double)ktouch_midi_beats(), ktouch_midi_locked(), ktouch_midi_bs_active(), + ktouch_btn_level(), (unsigned long)ktouch_btn_lows(), + (unsigned long)ktouch_btn_presses()); + + /* Honest zeroes, not flattering ones. This device has no MIDI-clock IN, no USB-MIDI + * host, and no mic follow-beat -- so those fields report the truth about hardware that + * is not fitted, rather than a plausible-looking default. Reporting `usb:true` on a + * device with no USB-MIDI is the same class of lie as ESP-028's `sync:1` over a dead + * wire, and this file of all files should not repeat it. */ + char buf[720]; + ks_status_json(buf, sizeof(buf), + bpm, + 0.0f, // min: no MIDI-clock IN on this hardware + link_proto_peers(), + false, // usb: DIN out only, no USB-MIDI host + pulses, // tx: the clock-pulse count == pulses here + FW_VERSION, + false, 0.0f, 0.0f, false, // follow_*: no mic follow-beat on this hardware + launch, 1, + ktouch_transport_state() == TL_RUNNING, + /* link_owns: FALSE, and it must stay false until this device actually + * defers to Link. + * + * ks_status.h defines link_owns as a LIVE claim: "when link_owns is + * true the manual PLAY/STOP buttons are ignored, so the UI greys + * them". A client is entitled to take that literally, and the iOS app + * does -- it stops SENDING the tap. + * + * This used to pass link_proto_start_stop_seen(), which is a different + * fact entirely: "has any peer EVER published a StartStopState", + * latched true until the last peer leaves. Run Ableton once and it is + * true for the rest of the session. + * + * Meanwhile ktouch_transport.c never consults Link start/stop at ALL. + * It obeys every intent it is given -- measured on the bench: stopped + * -> armed -> running with link_owns true throughout. So the device was + * announcing "I ignore your play button" while obeying it, and the app + * believed it and greyed the button out. Play and stop were dead in the + * app for exactly as long as a Link peer had ever pressed play. + * + * A HISTORICAL flag published as a LIVE state. T-018 was a lifetime + * dropped-tick counter rendered as a live fault; ESP-028 was sync:1 on + * a wire dead for 138 seconds. Same bug, third time. If the Touch ever + * grows real Link StartStop arbitration, this becomes the arbiter's + * answer -- never "we once saw one". */ + false, + &tick, + nullptr, // phase: no GhostXForm gauge published here yet + clk, &pulses, // ESP-028 writer's truth -- this device HAS it + extra); + server.send(200, "application/json", buf); +} + +/* ESP-030 pt3: POST /live — apply a partial patch IMMEDIATELY, no reboot. + * + * This device had no /live at all. It grew bespoke one-off endpoints (/nudge, /bright) + * while the P4 generalised to /live plus a form grammar. Same drift as /status: the + * clock ENGINE is shared (46 symlinked modules), the WEB layer never was. So every + * live edit the iOS app made — nudge, swing, rate, cable — POSTed to /live, got a 404, + * and was swallowed. The controls looked dead because the request landed nowhere. + * + * Speaks the SHARED form grammar (`clk0_phase`, `clk0_swing`, `clk0_ppqn`, ...), the + * same keys ks_config_set() parses on the P4, so a client sends ONE request shape to + * the whole fleet. + * + * ARC-022: A LIVE EDIT IS A REAL EDIT. config_persist_mark() marks the blob dirty and + * ktouch_web_tick() debounce-writes it once the edits settle — so a nudge STICKS across + * a reboot with no manual save, which is what a user expects and what /nudge already + * did. Marked, not written: a slider drag is dozens of POSTs, and config is one blob. + */ +static void handle_live() { + /* ESP-042: patch present keys onto a candidate through the SHARED grammar, then fold + * the LIVE-SAFE fields into the running config with ks_config_live_safe_copy -- the + * EXACT path the P4's /live uses. That single list is now the one owner of "what is + * live" across the fleet, so the ESP-037 class of bug (a live field parsed, validated, + * then silently dropped) cannot recur: a field is live iff it is in that copy. + * + * The candidate starts as the running config, so absent keys are unchanged (a partial + * patch). WiFi is deliberately NOT touched here -- it needs a reconnect, so it is a + * /save-and-reboot field, exactly as on the P4. */ + KsConfig cand = g_config; + bool touched = false; + + /* The shared per-output + fleet keys. clk0_en and clock_out both mean "the clock" on + * this single-output box (-> clock_out); tempo arrives as decimal BPM under `bpm`. */ + touched |= set_if_present(&cand, "clk0_phase", "clk0_phase"); + touched |= set_if_present(&cand, "clk0_swing", "clk0_swing"); + touched |= set_if_present(&cand, "clk0_ppqn", "clk0_ppqn"); + touched |= set_if_present(&cand, "clk0_en", "clock_out"); + touched |= set_if_present(&cand, "clock_out", "clock_out"); + touched |= set_if_present(&cand, "quantum", "quantum"); + touched |= set_if_present(&cand, "bright", "bright"); + touched |= set_if_present(&cand, "transport", "transport"); + touched |= set_if_present(&cand, "play_rel", "play_rel"); + touched |= set_if_present(&cand, "bpm", "bpm"); + + if (!ks_config_valid(&cand)) { server.send(400, "text/plain", "invalid"); return; } + + if (touched) { + ks_config_live_safe_copy(&g_config, &cand); // ONE owner of the live field set + config_persist_mark(&s_persist, millis()); /* ARC-022: survives a reboot */ + } + server.send(200, "text/plain", "ok"); +} + +/* ESP-030: GET /config.json — the device's ACTUAL settings, from the SHARED builder. + * + * Without this a client is read-only for configuration FOREVER: POST /save is a full + * form, so with no read there is no read-modify-write, and a client would have to POST + * fabricated defaults and clobber every setting it could not see. The iOS app correctly + * refuses to do that and disables its settings screen. This is what turns it back on. + * + * This device reports ONLY what is actually wired to it (config.h's KSTOUCH_HAS_*). + * Absent hardware is ABSENT from the document, never reported false: `led:false` with no + * strip attached is the same class of lie as ESP-028's `sync:1` over a dead wire, and a + * client would draw an LED section for a board that cannot light anything. + * + * Solder a strip on, flip KSTOUCH_HAS_LED, and the section appears here AND in the app, + * with no app change. Capability, not product identity. + */ +static void handle_config_json() { + static const KsCaps caps = { + .metronome = (bool)KSTOUCH_HAS_METRONOME, + .led = (bool)KSTOUCH_HAS_LED, + .follow_beat = (bool)KSTOUCH_HAS_FOLLOWBEAT, + .outputs = KSTOUCH_CLOCK_OUTPUTS, + .wifi_slots = KS_WIFI_SLOTS, /* ESP-035: the Touch really does hold three */ + .settable_tempo = true, /* ESP-037: a clock box; free-runs at a set BPM */ + }; + + /* ESP-042: g_config IS a KsConfig now, so /config.json emits it directly -- no + * hand-copied transient that could drift from storage. The only touch-up: mirror the + * single output's enable to the master clock gate so the app's per-output card reflects + * the one MIDI Clock toggle a user actually flips (clock[0].enable is otherwise pinned + * on). `caps` still hides the fields this hardware lacks (metronome, LED, USB cables, + * outputs 1..3) so absent hardware is ABSENT from the document, never reported false. */ + KsConfig c = g_config; + c.clock[0].enable = g_config.clock_out_enable; + + char buf[768]; + ks_config_json(buf, sizeof(buf), &c, &caps); + server.send(200, "application/json", buf); +} + +// Live clock nudge — the writer task reads g_config.nudge_mbeats every tick, so +// this takes effect immediately with no reboot. ARC-022: and it is now also KEPT. +// The old comment here said "persisted only on Save (no flash wear while the DJ +// dials it in)" -- the flash-wear worry was real, but the answer to it is a +// debounce, not throwing the edit away at the next power cycle. +static void handle_nudge() { + if (server.hasArg("mb") && ks_config_set(&g_config, "clk0_phase", server.arg("mb").c_str())) + config_persist_mark(&s_persist, millis()); + server.send(200, "text/plain", "ok"); +} + +// ESP-025 transport over HTTP. The Touch could only be driven from its LCD, which leaves +// a HEADLESS unit -- the bench rig -- with no way to start the clock at all. It also makes +// the ESP-023 analyzer check (is 0xFA emitted BEFORE the downbeat 0xF8?) scriptable instead +// of dependent on a human finger, which is what ESP-024's timing runs need. +// +// Posts an INTENT, exactly like a button or a touch. It does not touch the wire: the 1 ms +// writer owns transport_launch and fires START on the bar line (STOP is immediate). +// +// POST /transport?play=1 PLAY (arms; fires on the next bar line) +// POST /transport?play=0 STOP (immediate) +// POST /transport?realign=1 (arms; 0xFC then 0xFA on the next bar line) +static void handle_transport() { + if (server.hasArg("realign")) { + ktouch_transport_post(TL_INTENT_REALIGN); + server.send(200, "text/plain", "realign armed\n"); + return; + } + if (server.hasArg("play")) { + bool play = server.arg("play").toInt() != 0; + ktouch_transport_post(play ? TL_INTENT_PLAY : TL_INTENT_STOP); + server.send(200, "text/plain", play ? "play armed\n" : "stopped\n"); + return; + } + server.send(400, "text/plain", "want ?play=1|0 or ?realign=1\n"); +} + +// Live backlight brightness. Same pattern as /nudge: apply now, persist once settled. +static void handle_bright() { + if (server.hasArg("pct") && ks_config_set(&g_config, "bright", server.arg("pct").c_str())) { + ktouch_display_set_brightness(g_config.lcd_brightness); + config_persist_mark(&s_persist, millis()); + } + server.send(200, "text/plain", "ok"); +} + +// ESP-020: web OTA. The Touch was the ONE firmware in this repo with no OTA path at +// all -- and it is the firmware in Dan's unit. Without this, every future fix on a box +// that lives in someone else's rig needs physical access and a USB cable. +// +// This is wiring, not building. The page is ui_update_page() from ui_chrome, already +// shared with X32Link and KitchenSync (ARC-017) -- do not write a second one. The +// partition table already has the app0/app1 slots (4 MB each); they have just been +// sitting there unused. +// +// SAFETY: Update.h streams into the INACTIVE app slot. The running firmware is never +// touched, and the bootloader only switches slots after the image validates. So a +// truncated or aborted upload fails to validate and the device simply reboots into +// what it was already running. That is a property of the mechanism, not a hope -- but +// it is on the ticket to verify by deliberately killing an upload halfway. +static void handle_update_page() { + static char page[UPDATE_PAGE_MAX]; + // Show what is about to be overwritten. + int n = ui_update_page(page, sizeof(page), "KitchenSync Touch", FW_VERSION, FW_BUILD, true); + if (n < 0 || n >= (int)sizeof(page)) { server.send(500, "text/plain", "page too large"); return; } + server.send(200, "text/html", page); +} + +static void handle_update_upload() { + HTTPUpload& upload = server.upload(); + if (upload.status == UPLOAD_FILE_START) { + Serial.printf("[KSTouch] OTA: receiving %s\n", upload.filename.c_str()); + if (!Update.begin(UPDATE_SIZE_UNKNOWN)) Update.printError(Serial); + } else if (upload.status == UPLOAD_FILE_WRITE) { + if (Update.write(upload.buf, upload.currentSize) != upload.currentSize) Update.printError(Serial); + } else if (upload.status == UPLOAD_FILE_END) { + if (Update.end(true)) Serial.printf("[KSTouch] OTA: wrote %u bytes, restarting\n", upload.totalSize); + else Update.printError(Serial); + } else if (upload.status == UPLOAD_FILE_ABORTED) { + Update.end(); // release the slot; the running image is untouched + } +} + +static void handle_update_result() { + bool ok = !Update.hasError(); + char p[1024]; + ui_result_page(p, sizeof(p), + ok ? "Updated — Restarting" : "Update Failed", + ok ? "New firmware written. The device restarts now." + : "Upload was interrupted or the image was rejected. The device is still " + "running the firmware it booted with. Try again.", + ok); + server.send(ok ? 200 : 500, "text/html", p); + if (ok) { delay(1000); ESP.restart(); } +} + +void ktouch_web_begin(void) { + config_persist_reset(&s_persist); // ARC-022: clean at boot — never write on the way up + server.on("/", handle_root); + server.on("/status", handle_status); + server.on("/config.json", handle_config_json); // ESP-030 + server.on("/live", HTTP_POST, handle_live); // ESP-030 pt3 + server.on("/save", HTTP_POST, handle_save); + server.on("/nudge", HTTP_POST, handle_nudge); + server.on("/transport", HTTP_POST, handle_transport); // ESP-025: headless transport + server.on("/bright", HTTP_POST, handle_bright); + server.on("/update", HTTP_GET, handle_update_page); // ESP-020 + server.on("/update", HTTP_POST, handle_update_result, handle_update_upload); + server.begin(); +} + +void ktouch_web_tick(void) { + server.handleClient(); + + /* ESP-037: while a Link session is DRIVING, remember its tempo as ours. Link teaches + * the box the tempo; when Link goes away the box keeps playing it (the arbiter's + * free-run seed), and now it also PERSISTS, so a power-cycle comes back at the last + * tempo it actually played -- no jump, no surprise reset to an old manual value. + * + * Only while peers>0: solo, g_config.tempo_mbpm is the user's own set tempo and the + * writer applies it, so mirroring here would fight that. Marked (debounced), not + * written -- a stable Link tempo persists once; an Ableton tempo ramp coalesces. */ + if (link_proto_peers() > 0) { + float b = ktouch_midi_bpm(); + int eff = (int)(b * 1000.0f + 0.5f); + if (eff >= 20000 && eff <= 300000 && eff != g_config.tempo_mbpm) { + g_config.tempo_mbpm = eff; + config_persist_mark(&s_persist, millis()); + } + } + + // ARC-022: write the blob once the live edits have settled. loop() calls this + // every ~5 ms, and due() is a couple of unsigned compares when nothing is owed, + // so polling it here is free. The write itself is rare by construction -- once + // per settled burst -- which matters because a flash write freezes both cores. + if (config_persist_due(&s_persist, millis())) config_save(&g_config); +} diff --git a/KitchenSyncTouch/ktouch_web.h b/KitchenSyncTouch/ktouch_web.h new file mode 100644 index 0000000..1b30c04 --- /dev/null +++ b/KitchenSyncTouch/ktouch_web.h @@ -0,0 +1,6 @@ +#pragma once +// KitchenSync Touch web config (ESP-016, Inc3). ALL settings live here, not on the +// touch screen (touch is transport-only): WiFi, quantum, clock/transport enable, +// and the touch-vs-release trigger mode. Reuses the shared ui_chrome look. +void ktouch_web_begin(void); +void ktouch_web_tick(void); // pump the server (call from loop) diff --git a/KitchenSyncTouch/link_listener.cpp b/KitchenSyncTouch/link_listener.cpp new file mode 120000 index 0000000..af82b9d --- /dev/null +++ b/KitchenSyncTouch/link_listener.cpp @@ -0,0 +1 @@ +../X32Link/link_listener.cpp \ No newline at end of file diff --git a/KitchenSyncTouch/link_listener.h b/KitchenSyncTouch/link_listener.h new file mode 120000 index 0000000..c54fa1a --- /dev/null +++ b/KitchenSyncTouch/link_listener.h @@ -0,0 +1 @@ +../X32Link/link_listener.h \ No newline at end of file diff --git a/KitchenSyncTouch/link_measure_pump.c b/KitchenSyncTouch/link_measure_pump.c new file mode 120000 index 0000000..e60c76f --- /dev/null +++ b/KitchenSyncTouch/link_measure_pump.c @@ -0,0 +1 @@ +../X32Link/link_measure_pump.c \ No newline at end of file diff --git a/KitchenSyncTouch/link_measure_pump.h b/KitchenSyncTouch/link_measure_pump.h new file mode 120000 index 0000000..bcc5297 --- /dev/null +++ b/KitchenSyncTouch/link_measure_pump.h @@ -0,0 +1 @@ +../X32Link/link_measure_pump.h \ No newline at end of file diff --git a/KitchenSyncTouch/link_measurement.c b/KitchenSyncTouch/link_measurement.c new file mode 120000 index 0000000..9a094b8 --- /dev/null +++ b/KitchenSyncTouch/link_measurement.c @@ -0,0 +1 @@ +../X32Link/link_measurement.c \ No newline at end of file diff --git a/KitchenSyncTouch/link_measurement.h b/KitchenSyncTouch/link_measurement.h new file mode 120000 index 0000000..ea4c019 --- /dev/null +++ b/KitchenSyncTouch/link_measurement.h @@ -0,0 +1 @@ +../X32Link/link_measurement.h \ No newline at end of file diff --git a/KitchenSyncTouch/link_measurement_io.cpp b/KitchenSyncTouch/link_measurement_io.cpp new file mode 120000 index 0000000..4bbd7a4 --- /dev/null +++ b/KitchenSyncTouch/link_measurement_io.cpp @@ -0,0 +1 @@ +../X32Link/link_measurement_io.cpp \ No newline at end of file diff --git a/KitchenSyncTouch/link_measurement_io.h b/KitchenSyncTouch/link_measurement_io.h new file mode 120000 index 0000000..0945f93 --- /dev/null +++ b/KitchenSyncTouch/link_measurement_io.h @@ -0,0 +1 @@ +../X32Link/link_measurement_io.h \ No newline at end of file diff --git a/KitchenSyncTouch/link_measurement_session.c b/KitchenSyncTouch/link_measurement_session.c new file mode 120000 index 0000000..ab24b47 --- /dev/null +++ b/KitchenSyncTouch/link_measurement_session.c @@ -0,0 +1 @@ +../X32Link/link_measurement_session.c \ No newline at end of file diff --git a/KitchenSyncTouch/link_measurement_session.h b/KitchenSyncTouch/link_measurement_session.h new file mode 120000 index 0000000..5c3c9d1 --- /dev/null +++ b/KitchenSyncTouch/link_measurement_session.h @@ -0,0 +1 @@ +../X32Link/link_measurement_session.h \ No newline at end of file diff --git a/KitchenSyncTouch/link_phase.c b/KitchenSyncTouch/link_phase.c new file mode 120000 index 0000000..c64df84 --- /dev/null +++ b/KitchenSyncTouch/link_phase.c @@ -0,0 +1 @@ +../X32Link/link_phase.c \ No newline at end of file diff --git a/KitchenSyncTouch/link_phase.h b/KitchenSyncTouch/link_phase.h new file mode 120000 index 0000000..704f075 --- /dev/null +++ b/KitchenSyncTouch/link_phase.h @@ -0,0 +1 @@ +../X32Link/link_phase.h \ No newline at end of file diff --git a/KitchenSyncTouch/link_protocol.c b/KitchenSyncTouch/link_protocol.c new file mode 120000 index 0000000..8b9a377 --- /dev/null +++ b/KitchenSyncTouch/link_protocol.c @@ -0,0 +1 @@ +../X32Link/link_protocol.c \ No newline at end of file diff --git a/KitchenSyncTouch/link_protocol.h b/KitchenSyncTouch/link_protocol.h new file mode 120000 index 0000000..44b54b6 --- /dev/null +++ b/KitchenSyncTouch/link_protocol.h @@ -0,0 +1 @@ +../X32Link/link_protocol.h \ No newline at end of file diff --git a/KitchenSyncTouch/master_clock.c b/KitchenSyncTouch/master_clock.c new file mode 120000 index 0000000..cb791c3 --- /dev/null +++ b/KitchenSyncTouch/master_clock.c @@ -0,0 +1 @@ +../X32Link/master_clock.c \ No newline at end of file diff --git a/KitchenSyncTouch/master_clock.h b/KitchenSyncTouch/master_clock.h new file mode 120000 index 0000000..515cb58 --- /dev/null +++ b/KitchenSyncTouch/master_clock.h @@ -0,0 +1 @@ +../X32Link/master_clock.h \ No newline at end of file diff --git a/KitchenSyncTouch/midi_clock_out.c b/KitchenSyncTouch/midi_clock_out.c new file mode 120000 index 0000000..f0dd023 --- /dev/null +++ b/KitchenSyncTouch/midi_clock_out.c @@ -0,0 +1 @@ +../X32Link/midi_clock_out.c \ No newline at end of file diff --git a/KitchenSyncTouch/midi_clock_out.h b/KitchenSyncTouch/midi_clock_out.h new file mode 120000 index 0000000..699dea0 --- /dev/null +++ b/KitchenSyncTouch/midi_clock_out.h @@ -0,0 +1 @@ +../X32Link/midi_clock_out.h \ No newline at end of file diff --git a/KitchenSyncTouch/partitions.csv b/KitchenSyncTouch/partitions.csv new file mode 100644 index 0000000..dcecbab --- /dev/null +++ b/KitchenSyncTouch/partitions.csv @@ -0,0 +1,31 @@ +# KitchenSync Touch partition table — 16 MB flash, dual-OTA with 4 MB app slots. +# +# The Waveshare ESP32-S3-Touch-LCD-1.47 has a 16 MB chip (confirmed with +# `esptool.py flash_id` on the bench). CI already passes FlashSize=16M -- but with no +# PartitionScheme, the Arduino core fell back to its 4 MB `default` table: app slots of +# 0x140000 (1.25 MB) each, which is why the build reported "Maximum is 1310720 bytes". +# The sketch was at 87% of that, and ~12 MB of the chip sat unused. +# +# 87% left no room for the feature that needs it most: an HTTPS client (mbedtls + a CA +# bundle) costs 60-120 KB, so a device-side GitHub OTA physically would not fit. 4 MB +# slots take the same image from 87% to 27%. +# +# nvs and otadata keep the EXACT offsets of the Arduino default table (0x9000 / 0xe000) +# so an OTA update preserves saved WiFi + config. (A full flash via the web flasher +# erases NVS regardless -- that is the flasher's contract, not this table's.) +# +# WHY THIS MUST SHIP BEFORE ANY UNIT LEAVES THE BENCH: a partition table cannot be +# changed over OTA. It can only be written over USB. A device handed to a customer on +# the old 4 MB table can never move to this one remotely -- so the window to fix it +# closes the moment hardware leaves the building. +# +# 0x10000 + 2*0x400000 = 0x810000 used by code; the rest is spiffs + coredump, ending +# exactly at 16 MB. App slots stay 64 KB-aligned. +# +# Name, Type, SubType, Offset, Size, Flags +nvs, data, nvs, 0x9000, 0x5000, +otadata, data, ota, 0xe000, 0x2000, +app0, app, ota_0, 0x10000, 0x400000, +app1, app, ota_1, 0x410000, 0x400000, +spiffs, data, spiffs, 0x810000, 0x7E0000, +coredump, data, coredump, 0xFF0000, 0x10000, diff --git a/KitchenSyncTouch/partitions_devkit.csv b/KitchenSyncTouch/partitions_devkit.csv new file mode 100644 index 0000000..f5fdeb0 --- /dev/null +++ b/KitchenSyncTouch/partitions_devkit.csv @@ -0,0 +1,26 @@ +# ESP-025 bench rig partition table — classic ESP32 DevKit (WROOM-32), 4 MB flash. +# +# NOT the product table. partitions.csv is a 16 MB layout for the Waveshare S3 and is +# INVALID on this chip: its app0 alone is 0x400000 at offset 0x10000, which runs past +# the end of a 4 MB part. The bootloader rejects the table outright and the board +# boot-loops with "partition 2 invalid ... exceeds flash chip size" -- no app, no serial +# banner, nothing to debug against. Arduino gives a sketch exactly ONE partitions.csv, +# so the two boards cannot share it. +# +# Do not swap this in by hand -- use tools/build-bench.sh, which swaps the board flag +# AND this table together and restores both on exit. Committing a half-swap breaks the +# product build. +# +# Dual-OTA so the bench rig can take a web/OTA push like the product does. 0x1A0000 +# (1.625 MB) per slot vs a ~1.0 MB image = ~60% used, room to grow. nvs and otadata sit +# at the Arduino default offsets (0x9000 / 0xe000) so an OTA preserves saved WiFi/config. +# +# 0x10000 + 2*0x1A0000 = 0x350000, then spiffs + coredump end exactly at 4 MB. +# +# Name, Type, SubType, Offset, Size, Flags +nvs, data, nvs, 0x9000, 0x5000, +otadata, data, ota, 0xe000, 0x2000, +app0, app, ota_0, 0x10000, 0x1A0000, +app1, app, ota_1, 0x1B0000, 0x1A0000, +spiffs, data, spiffs, 0x350000, 0xA0000, +coredump, data, coredump, 0x3F0000, 0x10000, diff --git a/KitchenSyncTouch/partitions_supermini.csv b/KitchenSyncTouch/partitions_supermini.csv new file mode 100644 index 0000000..464eacd --- /dev/null +++ b/KitchenSyncTouch/partitions_supermini.csv @@ -0,0 +1,29 @@ +# ESP-036 headless Super Mini partition table — ESP32-S3, 4 MB flash. +# +# NOT the product table. partitions.csv is a 16 MB layout for the Waveshare S3, and +# Arduino gives a sketch exactly ONE partitions.csv that OVERRIDES the FQBN's +# PartitionScheme menu -- so a Super Mini build silently inherits the 16 MB table unless +# this one is swapped in. On a 4 MB chip that table is INVALID: app0 alone is 0x400000 +# at offset 0x10000, running past the end of the part. The bootloader rejects it and the +# board boot-loops with "partition 2 invalid ... exceeds flash chip size" -- no app, no +# banner. (Measured on the bench Super Mini, MAC 3c:0f:...:61:40, the first time this +# file did not exist and the 16 MB table shipped by mistake.) +# +# Do not swap this in by hand -- use tools/build-supermini.sh, which swaps the board +# flag AND this table together and restores both on exit. +# +# Dual-OTA so the headless box takes a web/OTA push like the product. 0x1A0000 (1.625 MB) +# per slot vs a ~1.0 MB image = ~62% used, room to grow. nvs and otadata sit at the +# Arduino default offsets (0x9000 / 0xe000) so an OTA preserves saved WiFi/config. +# +# 0x10000 + 2*0x1A0000 = 0x350000, then spiffs + coredump end exactly at 4 MB. Same +# geometry as the DevKit's 4 MB table -- both chips are 4 MB -- but kept separate so the +# board's intent is documented and a future 8/16 MB Super Mini can diverge here alone. +# +# Name, Type, SubType, Offset, Size, Flags +nvs, data, nvs, 0x9000, 0x5000, +otadata, data, ota, 0xe000, 0x2000, +app0, app, ota_0, 0x10000, 0x1A0000, +app1, app, ota_1, 0x1B0000, 0x1A0000, +spiffs, data, spiffs, 0x350000, 0xA0000, +coredump, data, coredump, 0x3F0000, 0x10000, diff --git a/KitchenSyncTouch/secrets.h.example b/KitchenSyncTouch/secrets.h.example new file mode 100644 index 0000000..4d82c07 --- /dev/null +++ b/KitchenSyncTouch/secrets.h.example @@ -0,0 +1,4 @@ +#pragma once +// Copy to secrets.h and fill in for Inc1 bring-up. secrets.h is gitignored. +#define KSTOUCH_WIFI_SSID "your-ssid" +#define KSTOUCH_WIFI_PASS "your-pass" diff --git a/KitchenSyncTouch/session_timeline.c b/KitchenSyncTouch/session_timeline.c new file mode 120000 index 0000000..57af8b1 --- /dev/null +++ b/KitchenSyncTouch/session_timeline.c @@ -0,0 +1 @@ +../X32Link/session_timeline.c \ No newline at end of file diff --git a/KitchenSyncTouch/session_timeline.h b/KitchenSyncTouch/session_timeline.h new file mode 120000 index 0000000..e6ddb90 --- /dev/null +++ b/KitchenSyncTouch/session_timeline.h @@ -0,0 +1 @@ +../X32Link/session_timeline.h \ No newline at end of file diff --git a/KitchenSyncTouch/swing.c b/KitchenSyncTouch/swing.c new file mode 120000 index 0000000..bcb6737 --- /dev/null +++ b/KitchenSyncTouch/swing.c @@ -0,0 +1 @@ +../X32Link/swing.c \ No newline at end of file diff --git a/KitchenSyncTouch/swing.h b/KitchenSyncTouch/swing.h new file mode 120000 index 0000000..bb05db3 --- /dev/null +++ b/KitchenSyncTouch/swing.h @@ -0,0 +1 @@ +../X32Link/swing.h \ No newline at end of file diff --git a/KitchenSyncTouch/tempo_source.h b/KitchenSyncTouch/tempo_source.h new file mode 120000 index 0000000..09481f4 --- /dev/null +++ b/KitchenSyncTouch/tempo_source.h @@ -0,0 +1 @@ +../X32Link/tempo_source.h \ No newline at end of file diff --git a/KitchenSyncTouch/transport_launch.c b/KitchenSyncTouch/transport_launch.c new file mode 120000 index 0000000..c59d047 --- /dev/null +++ b/KitchenSyncTouch/transport_launch.c @@ -0,0 +1 @@ +../X32Link/transport_launch.c \ No newline at end of file diff --git a/KitchenSyncTouch/transport_launch.h b/KitchenSyncTouch/transport_launch.h new file mode 120000 index 0000000..6fe3aea --- /dev/null +++ b/KitchenSyncTouch/transport_launch.h @@ -0,0 +1 @@ +../X32Link/transport_launch.h \ No newline at end of file diff --git a/KitchenSyncTouch/transport_led.c b/KitchenSyncTouch/transport_led.c new file mode 120000 index 0000000..579ba82 --- /dev/null +++ b/KitchenSyncTouch/transport_led.c @@ -0,0 +1 @@ +../X32Link/transport_led.c \ No newline at end of file diff --git a/KitchenSyncTouch/transport_led.h b/KitchenSyncTouch/transport_led.h new file mode 120000 index 0000000..2e2f47c --- /dev/null +++ b/KitchenSyncTouch/transport_led.h @@ -0,0 +1 @@ +../X32Link/transport_led.h \ No newline at end of file diff --git a/KitchenSyncTouch/ui_chrome.c b/KitchenSyncTouch/ui_chrome.c new file mode 120000 index 0000000..55f25c7 --- /dev/null +++ b/KitchenSyncTouch/ui_chrome.c @@ -0,0 +1 @@ +../X32Link/ui_chrome.c \ No newline at end of file diff --git a/KitchenSyncTouch/ui_chrome.h b/KitchenSyncTouch/ui_chrome.h new file mode 120000 index 0000000..f16e111 --- /dev/null +++ b/KitchenSyncTouch/ui_chrome.h @@ -0,0 +1 @@ +../X32Link/ui_chrome.h \ No newline at end of file diff --git a/KitchenSyncTouch/web_status_json.h b/KitchenSyncTouch/web_status_json.h new file mode 120000 index 0000000..a09fdb7 --- /dev/null +++ b/KitchenSyncTouch/web_status_json.h @@ -0,0 +1 @@ +../X32Link/web_status_json.h \ No newline at end of file diff --git a/LinkAudioPoC/.gitignore b/LinkAudioPoC/.gitignore new file mode 100644 index 0000000..d1bd0db --- /dev/null +++ b/LinkAudioPoC/.gitignore @@ -0,0 +1,8 @@ +# Vendored Ableton Link SDK clone (GPLv2). Not committed while this is a spike; +# vendoring shape (submodule vs subtree) decided if/when the PoC graduates. +third_party/link/ +build/ +sdkconfig +sdkconfig.old +managed_components/ +dependencies.lock diff --git a/LinkAudioPoC/CMakeLists.txt b/LinkAudioPoC/CMakeLists.txt new file mode 100644 index 0000000..7e3321f --- /dev/null +++ b/LinkAudioPoC/CMakeLists.txt @@ -0,0 +1,9 @@ +# P4-032 Link Audio PoC — Tier 1: official Ableton Link SDK as a live tempo +# peer on the ESP32-P4 (WiFi via the onboard C6 / ESP-Hosted, same recipe as +# KitchenSync). Standalone spike on purpose: the GPLv2 C++ SDK stays out of +# the KitchenSync production target until the PoC proves out (see +# docs/plans/2026-07-08-p4-link-audio-feasibility.md). +cmake_minimum_required(VERSION 3.16) + +include($ENV{IDF_PATH}/tools/cmake/project.cmake) +project(link_audio_poc) diff --git a/LinkAudioPoC/README.md b/LinkAudioPoC/README.md new file mode 100644 index 0000000..4d13606 --- /dev/null +++ b/LinkAudioPoC/README.md @@ -0,0 +1,79 @@ +# LinkAudioPoC — P4-032: onboard mic → Ableton Link Audio → Live + +**Status: PoC PROVEN, 2026-07-09.** All three tiers passed in one session on a +Waveshare ESP32-P4-NANO over WiFi (C6/ESP-Hosted) against Ableton Live 12.4: + +| Tier | Exit criterion | Result | +|---|---|---| +| 1 | Official Ableton Link SDK runs as a tempo peer on the P4 | ✓ first build — tempo/beats tracked a live session, no SDK code changes | +| 2 | `LinkAudio` sink's channel visible in Live | ✓ "P4 Mic" appeared; P4 also discovered Live's own five channels | +| 3 | Mic audio arrives in Live, beat-stamped | ✓ audible in Live; commit counter ran at block rate (source subscribed + consuming) | + +The feasibility doc (`docs/plans/2026-07-08-p4-link-audio-feasibility.md`) +expected Ethernet would be needed; WiFi via the C6 carried it fine at 16kHz +mono. Ethernet remains the right call for stage-grade jitter, not for the PoC. + +## Building + +``` +# Ableton Link SDK is a gitignored clone -- fetch + patch it once: +git clone --recurse-submodules https://github.com/Ableton/link third_party/link +git -C third_party/link apply ../../patches/ableton-link-esp32-fixes.patch + +idf.py set-target esp32p4 +# set CONFIG_POC_WIFI_SSID / CONFIG_POC_WIFI_PASSWORD locally (never commit) +idf.py build flash +``` + +GPLv2 note: the SDK stays out of the committed tree and out of the KitchenSync +production firmware. Shipping a closed product on this requires Ableton's +commercial Link license (link-devs@ableton.com) — email BEFORE productizing. + +## What the port actually required (all of it found the hard way) + +1. **SDK asio task stack, 8K → 16K** (`patches/ableton-link-esp32-fixes.patch`). + The SDK's esp32 `Context` spawns its io task — confusingly named "link" — + with a hardcoded 8192-byte stack sized for base Link. Every LinkAudio + processor (SinkProcessor/Resizer/PCMEncoder) executes on that task and + overflows it: hardware stack-protection fault with a constant ~2KB overshoot + no matter how big the *app's* task stack is. Also renamed it "link_io" so + panics distinguish it from app tasks. + +2. **Keep the P4's 8KB TCM block out of the heap** (`main/main.cpp`, + `SOC_RESERVE_MEMORY_REGION`). TCM's capability set includes + `MALLOC_CAP_INTERNAL`, so the best-fit allocator can place a small FreeRTOS + task stack there (observed: an ESP-Hosted 5120B stack). The first flash op + then asserts (`esp_task_stack_is_sane_cache_disabled`) — task stacks in TCM + aren't valid with the cache off. Which build loses this allocation lottery + is luck; reserving the region removes the dice. + +3. **Heap-allocate the `LinkAudio` object** — belt-and-braces after the stack + hunt; it's only ~1KB but there's no reason for it on a task stack. + +4. **`if_nametoindex`/`if_indextoname` stubs** — lwIP lacks them; upstream's + own example stubs them identically. + +## beat_stamper (the one pure module — host-tested) + +`main/beat_stamper.{h,c}`, tests in `test/test_beat_stamper.c`. Owns the novel +problem: `beatsAtBufferBegin` must be *continuous* in beat time (Live +reassembles the stream from these stamps; jitter = audible glitch), while the +measurement — session beat at DMA-read return — jitters with scheduling and +the I2S crystal drifts against the Link timeline. Policy: anchor first block +to measured, then advance by exactly `frames/rate × bps`, resync only when +prediction and measurement diverge past 0.25 beat. + +## Mic path + +Reuses `KitchenSync/main/i2s_audio_bus.c` by relative path — the +P4-020-hardware-validated ES8311 bring-up (24dB mic PGA, analog mic mode, +TX-enabled-as-clock-driver, mono ADC duplicated into both I2S slots). 16kHz +mono, 256-frame (~16ms) blocks, left slot. + +## Not yet done (before this graduates past PoC) + +- Sync accuracy is verified by ear only — no measured beat-alignment error. +- 16kHz mono PoC fidelity; 48kHz needs a codec reclock + bigger sink buffers. +- Ethernet transport untested (RJ45 present on the board). +- Ableton licensing conversation (ship-gate). +- KitchenSync integration deliberately out of scope (GPLv2 + RAM headroom). diff --git a/LinkAudioPoC/main/CMakeLists.txt b/LinkAudioPoC/main/CMakeLists.txt new file mode 100644 index 0000000..c72e367 --- /dev/null +++ b/LinkAudioPoC/main/CMakeLists.txt @@ -0,0 +1,24 @@ +set(CMAKE_CXX_STANDARD 17) + +# i2s_audio_bus.c reused from KitchenSync by relative path (ADR-0003 style, +# single source): the P4-020-validated ES8311 + full-duplex I2S bring-up, +# including the mic gain and TX-as-clock-driver fixes. +set(KS_DIR ${CMAKE_CURRENT_LIST_DIR}/../../KitchenSync/main) + +idf_component_register(SRCS main.cpp beat_stamper.c ${KS_DIR}/i2s_audio_bus.c + INCLUDE_DIRS . ${KS_DIR} + PRIV_REQUIRES driver esp_driver_i2s esp_timer esp_wifi esp_event esp_netif nvs_flash) + +# The SDK's Link engine spins timers/sockets on FreeRTOS tasks; unpinned is the +# upstream example default. +target_compile_definitions(${COMPONENT_LIB} PRIVATE LINK_ESP_TASK_CORE_ID=tskNO_AFFINITY) + +# asio (the SDK's IoContext backbone) throws; the SDK's esp32 example builds +# with exceptions on. CONFIG_COMPILER_CXX_EXCEPTIONS in sdkconfig.defaults is +# the project-wide half of this. +target_compile_options(${COMPONENT_LIB} PRIVATE -fexceptions) + +# Ableton::Link imported interface target -- include path + platform defines. +# ESP_PLATFORM (defined by IDF) makes platforms/Config.hpp pick the esp32 layer. +include(${CMAKE_CURRENT_LIST_DIR}/../third_party/link/AbletonLinkConfig.cmake) +target_link_libraries(${COMPONENT_LIB} PUBLIC Ableton::Link) diff --git a/LinkAudioPoC/main/Kconfig.projbuild b/LinkAudioPoC/main/Kconfig.projbuild new file mode 100644 index 0000000..2e1d07b --- /dev/null +++ b/LinkAudioPoC/main/Kconfig.projbuild @@ -0,0 +1,15 @@ +menu "Link Audio PoC" + +config POC_WIFI_SSID + string "WiFi SSID" + default "" + help + Station SSID. Set locally (menuconfig / sdkconfig); never commit. + +config POC_WIFI_PASSWORD + string "WiFi password" + default "" + help + Station password. Set locally; never commit. + +endmenu diff --git a/LinkAudioPoC/main/beat_stamper.c b/LinkAudioPoC/main/beat_stamper.c new file mode 100644 index 0000000..b844ee6 --- /dev/null +++ b/LinkAudioPoC/main/beat_stamper.c @@ -0,0 +1,33 @@ +#include "beat_stamper.h" +#include + +void beat_stamper_reset(BeatStamper* s) +{ + s->anchored = false; + s->next_begin = 0.0; +} + +double beat_stamper_stamp(BeatStamper* s, double measured_end_beats, + double bps, uint32_t frames, uint32_t rate) +{ + const double block_beats = (double)frames / (double)rate * bps; + const double measured_begin = measured_end_beats - block_beats; + + if (!s->anchored || fabs(measured_begin - s->next_begin) > BEAT_STAMPER_RESYNC_BEATS) { + s->next_begin = measured_begin; // anchor / resync to what the session says + s->anchored = true; + } else { + // Servo: the anchor came from ONE boot-time measurement, and whatever + // scheduling/network error that instant carried would otherwise be + // frozen in for the whole run (observed on hardware: +/-25ms run-to- + // run placement wander with +/-0.05ms stability WITHIN each run, + // 2026-07-10). Walking 1/128 of the remaining error out per block + // converges a 20ms anchor bias in seconds and tracks crystal drift, + // while staying ~two orders too slow to follow per-block jitter. + s->next_begin += (measured_begin - s->next_begin) * (1.0 / 128.0); + } + + const double begin = s->next_begin; + s->next_begin = begin + block_beats; // continuity: next block abuts exactly + return begin; +} diff --git a/LinkAudioPoC/main/beat_stamper.h b/LinkAudioPoC/main/beat_stamper.h new file mode 100644 index 0000000..d9ce322 --- /dev/null +++ b/LinkAudioPoC/main/beat_stamper.h @@ -0,0 +1,47 @@ +#pragma once +// Pure beat stamper (P4-032 Tier 3). Assigns each captured audio block the +// beatsAtBufferBegin that LinkAudioSink::BufferHandle::commit() needs. +// +// The problem it owns: Live reassembles the stream from these stamps, so they +// must be CONTINUOUS in beat time -- consecutive blocks must abut exactly, or +// the receiver hears a glitch. But the measurement we have (the session beat +// at the moment the I2S DMA read returns) jitters with task scheduling, and +// the I2S crystal drifts against the Link timeline. Policy: +// +// - first block: anchor so the block ENDS at the measured beat (the DMA +// read returns at buffer end), +// - after that: advance by exactly frames/rate * beats-per-second and +// ignore measured jitter, +// - unless |measured - predicted| exceeds the resync threshold (transport +// relocate, session takeover): snap back to measured -- one audible +// glitch beats a permanent offset. +// +// No SDK/ESP dependency -- host-tested in test/test_beat_stamper.c. +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +// Predicted-vs-measured divergence (in beats) that forces a resync. +// 0.25 beat = 125ms at 120 BPM: far beyond scheduling jitter, well under a +// musically meaningful relocate. +#define BEAT_STAMPER_RESYNC_BEATS 0.25 + +typedef struct { + bool anchored; + double next_begin; // where the NEXT block starts, in session beats +} BeatStamper; + +void beat_stamper_reset(BeatStamper* s); + +// Stamp one block. `measured_end_beats` = session beat observed when the +// block's DMA read returned (i.e. at the block's END); `bps` = tempo/60; +// `frames`/`rate` describe the block. Returns beatsAtBufferBegin. +double beat_stamper_stamp(BeatStamper* s, double measured_end_beats, + double bps, uint32_t frames, uint32_t rate); + +#ifdef __cplusplus +} +#endif diff --git a/LinkAudioPoC/main/idf_component.yml b/LinkAudioPoC/main/idf_component.yml new file mode 100644 index 0000000..c6a7072 --- /dev/null +++ b/LinkAudioPoC/main/idf_component.yml @@ -0,0 +1,12 @@ +dependencies: + # asio: the Link SDK's esp32 platform layer expects the IDF-managed asio + # component (same dep as the SDK's own examples/esp32). + espressif/asio: "*" + # WiFi on the P4 comes from the onboard ESP32-C6 over ESP-Hosted -- identical + # recipe to KitchenSync/main/idf_component.yml. + espressif/esp_hosted: "~2" + espressif/es8311: "^1.0.0" + espressif/esp_wifi_remote: + version: ">=0.10,<2.0" + idf: + version: ">=5.5.0" diff --git a/LinkAudioPoC/main/main.cpp b/LinkAudioPoC/main/main.cpp new file mode 100644 index 0000000..06ff847 --- /dev/null +++ b/LinkAudioPoC/main/main.cpp @@ -0,0 +1,287 @@ +/* + * P4-032 Tier 1: official Ableton Link SDK running on the ESP32-P4 as a live + * tempo peer, WiFi via the onboard C6 (ESP-Hosted), verified against Ableton + * Live. Success = Live's Link peer count includes this device and the logged + * tempo tracks Live's. + * + * Deliberately minimal: inline STA connect (no AP fallback, no web UI, no + * config) -- this spike answers "does the SDK's esp32 platform layer hold on + * IDF v5.5 / P4 RISC-V", nothing else. Tier 2 swaps ableton::Link for + * ableton::LinkAudio; Tier 3 feeds the mic. See + * docs/plans/2026-07-08-p4-link-audio-feasibility.md. + */ +#include + +#include +#include +#include +#include +#include +#include "beat_stamper.h" +#include "i2s_audio_bus.h" // KitchenSync's P4-020-validated ES8311/I2S bring-up +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include "esp_event.h" +#include "esp_log.h" +#include "esp_netif.h" +#include "esp_wifi.h" +#include "nvs_flash.h" +#include "sdkconfig.h" + +static const char* TAG = "link_poc"; + +// Keep the P4's 8KB TCM block OUT of the heap. Its capability set includes +// MALLOC_CAP_INTERNAL, and the best-fit allocator will happily place a small +// FreeRTOS task stack there (observed: an ESP-Hosted 5120B task stack) -- the +// first flash operation then dies on +// assert: spi_flash_disable_interrupts_caches_and_other_cpu +// (esp_task_stack_is_sane_cache_disabled) +// because task stacks in TCM aren't valid while the cache is off. Whether a +// stack lands there is allocation-order luck; this build lost the lottery +// (KitchenSync's image happens to win it). Reserving the region removes the +// dice entirely. +#include "heap_memory_layout.h" +// 0x30100044, not 0x30100000: the first 0x44 bytes are already a static +// reservation (boot log: "At 30100044 len 00001FBC (7 KiB): TCM"), and two +// reservations sharing a start address trip the sorted-regions assert in +// s_prepare_reserved_regions. +SOC_RESERVE_MEMORY_REGION(0x30100044, 0x30102000, poc_no_tcm_heap); + +// The SDK's esp32 example stubs these too: lwIP has no if_nametoindex, and +// Link's IpV4/V6 interface scanner links against them. +extern "C" unsigned int if_nametoindex(const char* /*ifName*/) +{ + return 0; +} +extern "C" char* if_indextoname(unsigned int /*ifIndex*/, char* /*ifName*/) +{ + return nullptr; +} + +// --- minimal STA connect (KitchenSync wifi_link.c, stripped of AP fallback) --- +static EventGroupHandle_t s_wifi_events; +static constexpr int kGotIp = BIT0; + +static void on_wifi_event(void*, esp_event_base_t base, int32_t id, void* data) +{ + if (base == WIFI_EVENT && id == WIFI_EVENT_STA_START) + { + esp_wifi_connect(); + } + else if (base == WIFI_EVENT && id == WIFI_EVENT_STA_DISCONNECTED) + { + ESP_LOGW(TAG, "disconnected, retrying"); + esp_wifi_connect(); + } + else if (base == IP_EVENT && id == IP_EVENT_STA_GOT_IP) + { + auto* e = static_cast(data); + ESP_LOGI(TAG, "got ip " IPSTR, IP2STR(&e->ip_info.ip)); + xEventGroupSetBits(s_wifi_events, kGotIp); + } +} + +static void wifi_connect_blocking() +{ + s_wifi_events = xEventGroupCreate(); + ESP_ERROR_CHECK(esp_netif_init()); + ESP_ERROR_CHECK(esp_event_loop_create_default()); + esp_netif_create_default_wifi_sta(); + + wifi_init_config_t init = WIFI_INIT_CONFIG_DEFAULT(); + ESP_ERROR_CHECK(esp_wifi_init(&init)); + ESP_ERROR_CHECK(esp_event_handler_register(WIFI_EVENT, ESP_EVENT_ANY_ID, on_wifi_event, nullptr)); + ESP_ERROR_CHECK(esp_event_handler_register(IP_EVENT, IP_EVENT_STA_GOT_IP, on_wifi_event, nullptr)); + + wifi_config_t wc = {}; + strncpy(reinterpret_cast(wc.sta.ssid), CONFIG_POC_WIFI_SSID, sizeof(wc.sta.ssid) - 1); + strncpy(reinterpret_cast(wc.sta.password), CONFIG_POC_WIFI_PASSWORD, sizeof(wc.sta.password) - 1); + ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_STA)); + ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_STA, &wc)); + ESP_LOGI(TAG, "connecting to SSID:%s", CONFIG_POC_WIFI_SSID); + ESP_ERROR_CHECK(esp_wifi_start()); + + xEventGroupWaitBits(s_wifi_events, kGotIp, pdFALSE, pdTRUE, portMAX_DELAY); +} + +// --- Tier 3: mic -> LinkAudio sink ------------------------------------------- +// Tier 1 (base Link tempo peer) passed 2026-07-09: first build, tempo/beats +// tracked a live session over C6/ESP-Hosted WiFi. Tier 2 passed same night: +// "P4 Mic" channel visible in Live 12.4, Live's own channels discovered here +// (after patching the SDK's 8K asio task stack, see Context.hpp). Tier 3: +// stream the onboard mic into the sink with beat_stamper-continuous +// beatsAtBufferBegin; success = Live plays the mic audio in sync. +// 44.1kHz stereo requirement (2026-07-10). 512 frames = ~11.6ms blocks. +// "Stereo" here is FORMAT, not capture: the ES8311's ADC is mono and the +// codec mirrors it into both I2S slots (P4-020 finding), so both channels +// carry the same mic. True 2-channel capture needs different input hardware +// (Tier 4 USB audio interface, or an external stereo ADC). +// Default until the session's rate is sniffed (below). Rate mismatch is +// audible: streaming 44.1k into a 48k Live session put the receiver resampler +// in the path, and clock-skewed stamps fought it -- "film projector" flutter, +// a spectral comb at block rate (86Hz = 44100/512) in the recording analysis +// (2026-07-10). Matching the session rate removes that resampler entirely. +static constexpr uint32_t kDefaultRate = 48000; +static constexpr uint32_t kBlockFrames = 512; +static constexpr uint32_t kChannels = 2; +static constexpr size_t kMaxSinkSamples = kBlockFrames * kChannels; + +// Session-rate sniffing: Live announces channels but the channel list carries +// no rate -- only received BUFFERS do (LinkAudioSource::BufferHandle::Info). +// So subscribe to the first remote channel, read one buffer's sampleRate, +// unsubscribe, reclock. 0 = not yet sniffed. +static std::atomic g_session_rate{0}; +static constexpr double kQuantum = 4.0; + +// Capture-chain latency, measured 2026-07-10 by recording Live's click track +// through this mic and measuring transient offsets from the grid: +21.4ms, +// constant to +/-0.05ms across clicks (and inter-click interval recovered the +// session tempo exactly, so the stamps are rate-perfect -- pure fixed lag). +// Composition: I2S DMA pipeline depth + ES8311 group delay + speaker-to-mic +// flight (~1-3ms of it is acoustic and rig-specific; re-measure per install). +// Calibrated 2026-07-10 by click-track, AFTER the beat_stamper servo landed. +// Pre-servo, placement wandered +/-25ms run to run (single-measurement anchor +// dice); post-servo two consecutive takes agreed within 0.04ms, making this a +// real constant. At 33800 the clicks sat -15.75ms early; 1:1 shift -> +// 18050us. Matches the physical estimate (I2S DMA pipeline + ES8311 group +// delay + ~1m speaker-to-mic flight). Includes the rig's acoustic path -- +// re-derive with one click-track take if the setup changes. +// Final 1ms trim off a verification take (user-measured, 2026-07-10). +static constexpr int64_t kCaptureLatencyUs = 17050; + +static void link_task(void*) +{ + auto link_ptr = std::make_unique(120.0, "KitchenSync P4"); + auto& link = *link_ptr; + link.enable(true); + link.enableLinkAudio(true); + + ableton::LinkAudioSink sink(link, "P4 Mic", kMaxSinkSamples); + + // Mic capture: shared-bus RX (stereo slots, ES8311's mono ADC duplicated in + // both -- P4-020 hardware finding; left slot used). TX is already enabled by + // audio_bus_init() as the shared clock driver. + if (i2s_channel_enable(audio_bus_rx()) != ESP_OK) + { + printf("[link_poc] RX enable failed -- no mic\n"); + vTaskDelete(nullptr); + } + + static int16_t stereo[kBlockFrames * kChannels]; + BeatStamper stamper; + beat_stamper_reset(&stamper); + + uint32_t committed = 0, skipped = 0, block = 0; + uint32_t rate = kDefaultRate; + std::optional sniffer; + + while (true) + { + size_t bytes = 0; + if (i2s_channel_read(audio_bus_rx(), stereo, sizeof(stereo), &bytes, portMAX_DELAY) != ESP_OK) + { + vTaskDelay(pdMS_TO_TICKS(50)); + continue; + } + const uint32_t frames = bytes / sizeof(int16_t) / kChannels; + + // --- session-rate adaptation ------------------------------------------ + const uint32_t sniffed = g_session_rate.load(std::memory_order_relaxed); + if (sniffed != 0 && sniffer) + { + printf("[link_poc] session rate sniffed: %u Hz\n", (unsigned)sniffed); + sniffer.reset(); // job done; destroy OFF the Link thread + } + if (sniffed != 0 && sniffed != rate) + { + printf("[link_poc] session rate %u != ours %u -- reclocking\n", + (unsigned)sniffed, (unsigned)rate); + if (audio_bus_reclock(sniffed) && i2s_channel_enable(audio_bus_rx()) == ESP_OK) + { + rate = sniffed; + beat_stamper_reset(&stamper); // old anchor is in the old rate's time + } + else + { + printf("[link_poc] reclock failed, staying at %u\n", (unsigned)rate); + g_session_rate.store(rate, std::memory_order_relaxed); // stop retrying + } + continue; // this block straddled the reclock; drop it + } + if (sniffed == 0 && !sniffer) + { + const auto chans = link.channels(); + if (!chans.empty()) + { + printf("[link_poc] sniffing session rate from '%s'\n", chans[0].name.c_str()); + sniffer.emplace(link, chans[0].id, + [](ableton::LinkAudioSource::BufferHandle bh) + { + uint32_t expect = 0; // only the first buffer matters + g_session_rate.compare_exchange_strong(expect, bh.info.sampleRate); + }); + } + } + // ----------------------------------------------------------------------- + + // Stamp at the block's END (the read just returned), then commit. + const auto state = link.captureAudioSessionState(); + // Ask Link where the session was when this block ACTUALLY ended at the + // ADC (now minus the measured capture latency) -- Link's own time->beat + // map does the conversion, so tempo changes stay correct. + const double endBeats = state.beatAtTime( + link.clock().micros() - std::chrono::microseconds(kCaptureLatencyUs), kQuantum); + const double bps = state.tempo() / 60.0; + const double beginBeats = + beat_stamper_stamp(&stamper, endBeats, bps, frames, rate); + + ableton::LinkAudioSink::BufferHandle h(sink); + if (h && h.maxNumSamples >= frames * kChannels) + { + memcpy(h.samples, stereo, frames * kChannels * sizeof(int16_t)); + if (h.commit(state, beginBeats, kQuantum, frames, kChannels, rate)) + ++committed; + else + ++skipped; + } + else + { + ++skipped; // no source subscribed / no buffer -- normal when idle + } + + if (++block % 64 == 0) // ~1s at 16ms blocks + { + printf("[link_poc] peers:%zu tempo:%.2f committed:%lu skipped:%lu channels:%zu\n", + link.numPeers(), state.tempo(), (unsigned long)committed, + (unsigned long)skipped, link.channels().size()); + } + } +} + +extern "C" void app_main() +{ + esp_err_t ret = nvs_flash_init(); + if (ret == ESP_ERR_NVS_NO_FREE_PAGES || ret == ESP_ERR_NVS_NEW_VERSION_FOUND) + { + ESP_ERROR_CHECK(nvs_flash_erase()); + ret = nvs_flash_init(); + } + ESP_ERROR_CHECK(ret); + + wifi_connect_blocking(); + + // ES8311 + full-duplex I2S (P4-020's shared bus: TX enabled as clock driver, + // mic PGA 24dB, analog mic mode). Must precede the capture loop's RX enable. + audio_bus_init(kDefaultRate); + if (!audio_bus_ready()) + { + printf("[link_poc] audio bus failed -- Tier 3 needs the mic\n"); + return; + } + + // 16K was always enough HERE -- the Tier 2 stack-protection faults were the + // SDK's own asio service task (8K, hardcoded, patched to 16K in the vendored + // Context.hpp), which shares the name "link" and is where LinkAudio's + // processors actually run. Named "poc" to keep the two apart in panics. + xTaskCreate(link_task, "poc", 16384, nullptr, 5, nullptr); +} diff --git a/LinkAudioPoC/patches/ableton-link-esp32-fixes.patch b/LinkAudioPoC/patches/ableton-link-esp32-fixes.patch new file mode 100644 index 0000000..2490ea5 --- /dev/null +++ b/LinkAudioPoC/patches/ableton-link-esp32-fixes.patch @@ -0,0 +1,22 @@ +diff --git a/include/ableton/platforms/esp32/Context.hpp b/include/ableton/platforms/esp32/Context.hpp +index 27fb8b5..8a6c88f 100644 +--- a/include/ableton/platforms/esp32/Context.hpp ++++ b/include/ableton/platforms/esp32/Context.hpp +@@ -63,9 +63,15 @@ class Context + : mpService(new ::asio::io_service()) + , mpWork(new ::asio::io_service::work(*mpService)) + { ++ // P4-032 PoC patch (2026-07-09): 8192 was sized for base Link and ++ // overflows the moment LinkAudio's SinkProcessor/Resizer machinery runs ++ // here (hardware stack-protection fault on ESP32-P4, constant ~2KB ++ // overshoot regardless of the app task's own stack -- this asio service ++ // task is where all Link/LinkAudio processing actually executes). ++ // Upstream this if the PoC graduates. + xTaskCreatePinnedToCore(run, +- "link", +- 8192, ++ "link_io", ++ 16384, + this, + 2 | portPRIVILEGE_BIT, + &mTaskHandle, diff --git a/LinkAudioPoC/sdkconfig.defaults b/LinkAudioPoC/sdkconfig.defaults new file mode 100644 index 0000000..959186a --- /dev/null +++ b/LinkAudioPoC/sdkconfig.defaults @@ -0,0 +1,16 @@ +# P4-032 PoC — P4-NANO board gates copied from KitchenSync/sdkconfig.defaults +# (early silicon rev + C6 hosted WiFi). See that file for the full rationale. +CONFIG_ESP32P4_SELECTS_REV_LESS_V3=y +CONFIG_ESP32P4_REV_MIN_100=y +CONFIG_SLAVE_IDF_TARGET_ESP32C6=y + +# asio throws; Link's esp32 example requires exceptions enabled. +CONFIG_COMPILER_CXX_EXCEPTIONS=y + +# Spike: single big app slot, no OTA. The SDK + asio produce a large image; +# this is the upstream example's choice too. +CONFIG_PARTITION_TABLE_SINGLE_APP_LARGE=y +CONFIG_ESPTOOLPY_FLASHSIZE_4MB=y + +# WiFi credentials (CONFIG_POC_WIFI_*) are set locally via menuconfig or a +# gitignored sdkconfig -- never committed. diff --git a/LoraLink/LoraLink.ino b/LoraLink/LoraLink.ino new file mode 100644 index 0000000..f2d11e8 --- /dev/null +++ b/LoraLink/LoraLink.ino @@ -0,0 +1,114 @@ +// LoraLink — relays the Ableton Link session BPM over LoRa for out-of-WiFi-range +// FX tempo control. No phase alignment — see docs/plans/2026-07-09-loralink-design.md. +#include +#include +#include // abs() + +#include "lora_config.h" +#include "lora_secrets.h" // WIFI_SSID / WIFI_PASSWORD — see lora_secrets.h.example +#include "lora_radio.h" +#include "lora_display.h" +#include "lora_bpm_packet.h" +#include "lora_freshness.h" + +// Reused unchanged from ../X32Link (ADR-0003: pure-C-logic + thin-glue split). +// link_listener.{h,cpp} below are symlinks to ../X32Link/link_listener.{h,cpp} +// — arduino-cli only compiles sources physically inside the sketch folder, +// so a relative "../X32Link/link_listener.h" include alone would leave the +// .cpp unbuilt (link error). The symlinks keep the source as the single +// unchanged file, just visible from both sketch folders. +#include "link_listener.h" + +#if LORA_ROLE == LORA_ROLE_SENDER + +static uint8_t s_seq = 0; + +static void wifi_connect_blocking() { + WiFi.mode(WIFI_STA); + WiFi.begin(WIFI_SSID, WIFI_PASSWORD); + Serial.print("[LoraLink] connecting to WiFi"); + while (WiFi.status() != WL_CONNECTED) { + delay(250); + Serial.print('.'); + } + Serial.printf("\n[LoraLink] WiFi up, ip=%s\n", WiFi.localIP().toString().c_str()); +} + +void setup() { + Serial.begin(115200); + lora_display_begin(); + lora_radio_begin(); + wifi_connect_blocking(); + link_listener_begin(); +} + +void loop() { + link_listener_poll(); + link_listener_tick(); + + int peers = link_listener_peers(); + bool active = peers > 0; + float bpm = active ? (float)link_listener_bpm() : 0.0f; + + static uint32_t s_last_send_ms = 0; + static uint16_t s_last_sent_bpm_x100 = 0xFFFF; // force an initial send + uint32_t now = millis(); + uint16_t bpm_x100 = (uint16_t)(bpm * 100.0f + 0.5f); + + bool changed = active && + (abs((int)bpm_x100 - (int)s_last_sent_bpm_x100) >= LORA_BPM_CHANGE_EPSILON_X100); + bool heartbeat_due = (uint32_t)(now - s_last_send_ms) >= LORA_HEARTBEAT_MS; + + if (changed || heartbeat_due) { + lora_bpm_packet_t pkt; + pkt.msg_type = active ? LORA_MSG_BPM : LORA_MSG_NO_SESSION; + pkt.seq = s_seq++; + pkt.bpm_x100 = active ? bpm_x100 : 0; + + uint8_t buf[LORA_BPM_PACKET_SIZE]; + lora_bpm_packet_encode(&pkt, buf); + lora_radio_send(buf, sizeof(buf)); + + s_last_send_ms = now; + s_last_sent_bpm_x100 = bpm_x100; + } + + lora_display_show_sender(peers, bpm, active); + delay(LORA_LINK_POLL_MS); +} + +#else // LORA_ROLE_RECEIVER + +static uint32_t s_last_seen_ms = 0; +static bool s_has_received = false; +static float s_last_bpm = 0.0f; +static bool s_rx_blink = false; + +void setup() { + Serial.begin(115200); + lora_display_begin(); + lora_radio_begin(); +} + +void loop() { + uint8_t buf[LORA_BPM_PACKET_SIZE]; + int len = 0; + if (lora_radio_try_receive(buf, sizeof(buf), &len)) { + lora_bpm_packet_t pkt; + if (lora_bpm_packet_decode(buf, len, &pkt)) { + s_rx_blink = !s_rx_blink; // any packet counts as reception activity + if (pkt.msg_type == LORA_MSG_BPM) { + s_last_bpm = pkt.bpm_x100 / 100.0f; + s_last_seen_ms = millis(); + s_has_received = true; + } + } + } + + bool stale = lora_freshness_is_stale(millis(), s_last_seen_ms, + LORA_STALE_THRESHOLD_MS, s_has_received); + lora_display_show_receiver(s_last_bpm, stale, s_rx_blink); + delay(50); +} + +#endif diff --git a/LoraLink/README.md b/LoraLink/README.md new file mode 100644 index 0000000..e04a9b1 --- /dev/null +++ b/LoraLink/README.md @@ -0,0 +1,59 @@ +# LoraLink — LoRa BPM relay + +Relays the current Ableton Link session tempo over LoRa, for FX tempo control +out of range of the venue WiFi network. No phase/beat alignment — BPM value +only. See [`docs/plans/2026-07-09-loralink-design.md`](../docs/plans/2026-07-09-loralink-design.md) +for the full design. + +## Hardware + +- 2x ESP32-S3 + SX1262 915MHz LoRa dev board w/ onboard SSD1306 OLED + ("N30" Meshtastic-compatible clone of the Heltec WiFi LoRa 32 V3 form factor). +- One board is flashed as **sender** (joins WiFi + the Link session, transmits + BPM), the other as **receiver** (listens, displays BPM). + +## Build / flash + +```sh +cp LoraLink/lora_secrets.h.example LoraLink/lora_secrets.h # fill in your WiFi creds +arduino-cli lib install RadioLib U8g2 +arduino-cli compile --fqbn esp32:esp32:esp32s3:CDCOnBoot=default,FlashSize=8M,PSRAM=disabled LoraLink +arduino-cli upload --fqbn esp32:esp32:esp32s3:CDCOnBoot=default,FlashSize=8M,PSRAM=disabled -p LoraLink +``` + +This board (Meshnology N30) has **8MB flash and no PSRAM** — different from +X32Link's QT Py/Super Mini profile (16MB, PSRAM). It also has no native USB, +only a CP2102 UART bridge, so `CDCOnBoot` must stay `default` (not `cdc`) or +`Serial` output is silently routed to a USB peripheral this board doesn't +wire up. Confirmed on-device: `CDCOnBoot=cdc` and `FlashSize=16M` together +produced total silence on the serial console and a boot-time +`spi_flash: Detected size(8192k) smaller than the size in the binary image +header(16384k)` assert crash-loop respectively. + +To flash the **receiver** board, override the role before compiling: + +```sh +echo '-DLORA_ROLE_OVERRIDE=LORA_ROLE_RECEIVER' > LoraLink/build_opt.h +arduino-cli compile ... LoraLink # then upload as above +git checkout -- LoraLink/build_opt.h # restore sender default for next time +``` + +## Architecture + +Pure-C wire format (`lora_bpm_packet.*`) and staleness check +(`lora_freshness.*`) are host-tested with Unity (`make -C test`). The sender +reuses `link_listener.*` from `../X32Link` unchanged to join the Link session +— no new Link-parsing code. Radio (`lora_radio.*`, RadioLib) and display +(`lora_display.*`, U8g2) are thin Arduino glue, verified on-device. + +`link_listener.*` and its transitive dependencies (`link_protocol.*`, +`session_timeline.*`, `link_phase.*`) appear in this folder as symlinks into +`../X32Link/` rather than copies — the Arduino/arduino-cli build only +compiles sources physically inside the sketch's own folder, so a relative +`#include "../X32Link/link_listener.h"` alone would leave the `.cpp` +unbuilt. The symlinks keep this a single unchanged source of truth. + +## Status + +Milestone 1: receiver only displays BPM. FX/MIDI/CV output from the received +BPM is a follow-on, not yet built. diff --git a/X32FaderDisp/build_opt.h b/LoraLink/build_opt.h similarity index 100% rename from X32FaderDisp/build_opt.h rename to LoraLink/build_opt.h diff --git a/LoraLink/link_listener.cpp b/LoraLink/link_listener.cpp new file mode 120000 index 0000000..af82b9d --- /dev/null +++ b/LoraLink/link_listener.cpp @@ -0,0 +1 @@ +../X32Link/link_listener.cpp \ No newline at end of file diff --git a/LoraLink/link_listener.h b/LoraLink/link_listener.h new file mode 120000 index 0000000..c54fa1a --- /dev/null +++ b/LoraLink/link_listener.h @@ -0,0 +1 @@ +../X32Link/link_listener.h \ No newline at end of file diff --git a/LoraLink/link_phase.c b/LoraLink/link_phase.c new file mode 120000 index 0000000..c64df84 --- /dev/null +++ b/LoraLink/link_phase.c @@ -0,0 +1 @@ +../X32Link/link_phase.c \ No newline at end of file diff --git a/LoraLink/link_phase.h b/LoraLink/link_phase.h new file mode 120000 index 0000000..704f075 --- /dev/null +++ b/LoraLink/link_phase.h @@ -0,0 +1 @@ +../X32Link/link_phase.h \ No newline at end of file diff --git a/LoraLink/link_protocol.c b/LoraLink/link_protocol.c new file mode 120000 index 0000000..8b9a377 --- /dev/null +++ b/LoraLink/link_protocol.c @@ -0,0 +1 @@ +../X32Link/link_protocol.c \ No newline at end of file diff --git a/LoraLink/link_protocol.h b/LoraLink/link_protocol.h new file mode 120000 index 0000000..44b54b6 --- /dev/null +++ b/LoraLink/link_protocol.h @@ -0,0 +1 @@ +../X32Link/link_protocol.h \ No newline at end of file diff --git a/LoraLink/lora_bpm_packet.c b/LoraLink/lora_bpm_packet.c new file mode 100644 index 0000000..2d41eee --- /dev/null +++ b/LoraLink/lora_bpm_packet.c @@ -0,0 +1,16 @@ +#include "lora_bpm_packet.h" + +void lora_bpm_packet_encode(const lora_bpm_packet_t *pkt, uint8_t *buf) { + buf[0] = pkt->msg_type; + buf[1] = pkt->seq; + buf[2] = (uint8_t)(pkt->bpm_x100 & 0xFF); + buf[3] = (uint8_t)((pkt->bpm_x100 >> 8) & 0xFF); +} + +bool lora_bpm_packet_decode(const uint8_t *buf, int len, lora_bpm_packet_t *out) { + if (len < LORA_BPM_PACKET_SIZE) return false; + out->msg_type = buf[0]; + out->seq = buf[1]; + out->bpm_x100 = (uint16_t)(buf[2] | (buf[3] << 8)); + return true; +} diff --git a/LoraLink/lora_bpm_packet.h b/LoraLink/lora_bpm_packet.h new file mode 100644 index 0000000..ab547d5 --- /dev/null +++ b/LoraLink/lora_bpm_packet.h @@ -0,0 +1,35 @@ +#pragma once +// Pure wire-format for the LoraLink BPM packet — encode/decode only, no +// radio/Arduino deps, host-testable. See +// docs/plans/2026-07-09-loralink-design.md. +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +#define LORA_BPM_PACKET_SIZE 4 + +typedef enum { + LORA_MSG_BPM = 1, // bpm_x100 holds the current session tempo + LORA_MSG_NO_SESSION = 2, // no Link session found; bpm_x100 is 0/unused +} lora_msg_type_t; + +typedef struct { + uint8_t msg_type; // lora_msg_type_t + uint8_t seq; // wraps 0-255; receiver-side gap display only, no retry + uint16_t bpm_x100; // BPM * 100, e.g. 12000 = 120.00 BPM +} lora_bpm_packet_t; + +// Encodes `pkt` into `buf[0..LORA_BPM_PACKET_SIZE)`; buf must have at least +// LORA_BPM_PACKET_SIZE bytes. bpm_x100 is packed little-endian. +void lora_bpm_packet_encode(const lora_bpm_packet_t *pkt, uint8_t *buf); + +// Decodes LORA_BPM_PACKET_SIZE bytes from `buf` into `out`. Returns false +// (leaving `out` untouched) if `len` is too short for a packet. +bool lora_bpm_packet_decode(const uint8_t *buf, int len, lora_bpm_packet_t *out); + +#ifdef __cplusplus +} +#endif diff --git a/LoraLink/lora_config.h b/LoraLink/lora_config.h new file mode 100644 index 0000000..72c0abf --- /dev/null +++ b/LoraLink/lora_config.h @@ -0,0 +1,49 @@ +#pragma once +// Board pins + role/timing constants for LoraLink. See +// docs/plans/2026-07-09-loralink-design.md. +// +// Pin mapping below is the STOCK Heltec WiFi LoRa 32 V3 pinout — the "N30" +// board is sold as a Meshtastic-compatible clone of that reference design. +// Verify against the actual board silkscreen/schematic; this is the only +// file that should need to change if the clone's pinout differs. + +// --- OLED (SSD1306, I2C) --- +#define OLED_SDA 17 +#define OLED_SCL 18 +#define OLED_RST 21 +#define VEXT_CTRL_PIN 36 // LOW = Vext (OLED/peripheral) power ON + +// --- LoRa (SX1262, SPI) --- +#define LORA_NSS 8 +#define LORA_SCK 9 +#define LORA_MOSI 10 +#define LORA_MISO 11 +#define LORA_RST 12 +#define LORA_BUSY 13 +#define LORA_DIO1 14 + +// --- Radio parameters (point-to-point, not LoRaWAN) --- +#define LORA_FREQ_MHZ 915.0 +#define LORA_BANDWIDTH_KHZ 125.0 +#define LORA_SPREADING_FACTOR 9 +#define LORA_CODING_RATE 5 +#define LORA_SYNC_WORD 0x12 // private-network sync word (not the + // 0x34 LoRaWAN public sync word) +#define LORA_TX_POWER_DBM 17 + +// --- Role selection (compile-time; each board is flashed once) --- +#define LORA_ROLE_SENDER 0 +#define LORA_ROLE_RECEIVER 1 + +#ifdef LORA_ROLE_OVERRIDE + #define LORA_ROLE LORA_ROLE_OVERRIDE +#else + #define LORA_ROLE LORA_ROLE_SENDER // flip via build_opt.h for the 2nd board +#endif + +// --- Timing --- +#define LORA_LINK_POLL_MS 20 // sender: Link listener poll interval +#define LORA_HEARTBEAT_MS 1500 // sender: resend at least this often +#define LORA_BPM_CHANGE_EPSILON_X100 5 // sender: resend immediately if BPM + // moves by >= 0.05 BPM +#define LORA_STALE_THRESHOLD_MS 5000 // receiver: "No signal" after this long diff --git a/LoraLink/lora_display.cpp b/LoraLink/lora_display.cpp new file mode 100644 index 0000000..b896998 --- /dev/null +++ b/LoraLink/lora_display.cpp @@ -0,0 +1,50 @@ +#include "lora_display.h" +#include "lora_config.h" +#include +#include + +static U8G2_SSD1306_128X64_NONAME_F_HW_I2C u8g2(U8G2_R0, OLED_RST, OLED_SCL, OLED_SDA); + +void lora_display_begin() { + pinMode(VEXT_CTRL_PIN, OUTPUT); + digitalWrite(VEXT_CTRL_PIN, LOW); // Vext ON (active-low on this board family) + delay(50); + u8g2.begin(); + u8g2.setFont(u8g2_font_6x10_tf); +} + +void lora_display_show_sender(int peers, float bpm, bool link_active) { + char line[32]; + u8g2.clearBuffer(); + u8g2.drawStr(0, 12, "LoraLink: SENDER"); + if (link_active) { + snprintf(line, sizeof(line), "Link: %d peers", peers); + u8g2.drawStr(0, 28, line); + snprintf(line, sizeof(line), "%.2f BPM -> TX", bpm); + u8g2.drawStr(0, 44, line); + } else { + u8g2.drawStr(0, 28, "No Link session"); + } + u8g2.sendBuffer(); +} + +void lora_display_show_receiver(float bpm, bool stale, bool rx_blink) { + char line[32]; + u8g2.clearBuffer(); + u8g2.drawStr(0, 12, "LoraLink: RECEIVER"); + if (stale) { + u8g2.drawStr(0, 28, "No signal"); + } else { + snprintf(line, sizeof(line), "%.2f BPM", bpm); + u8g2.drawStr(0, 28, line); + } + // Activity dot: toggles filled/hollow on every packet, independent of + // whether the BPM/status changed, so reception is visible even at a + // steady tempo. + if (rx_blink) { + u8g2.drawDisc(120, 8, 4); + } else { + u8g2.drawCircle(120, 8, 4); + } + u8g2.sendBuffer(); +} diff --git a/LoraLink/lora_display.h b/LoraLink/lora_display.h new file mode 100644 index 0000000..abb494e --- /dev/null +++ b/LoraLink/lora_display.h @@ -0,0 +1,7 @@ +#pragma once + +void lora_display_begin(); +void lora_display_show_sender(int peers, float bpm, bool link_active); +// rx_blink toggles on every packet received (BPM or NO_SESSION alike), so +// there's a visible heartbeat even while the BPM value itself isn't changing. +void lora_display_show_receiver(float bpm, bool stale, bool rx_blink); diff --git a/LoraLink/lora_freshness.c b/LoraLink/lora_freshness.c new file mode 100644 index 0000000..95add62 --- /dev/null +++ b/LoraLink/lora_freshness.c @@ -0,0 +1,7 @@ +#include "lora_freshness.h" + +bool lora_freshness_is_stale(uint32_t now_ms, uint32_t last_seen_ms, + uint32_t threshold_ms, bool has_received) { + if (!has_received) return true; + return (uint32_t)(now_ms - last_seen_ms) >= threshold_ms; +} diff --git a/LoraLink/lora_freshness.h b/LoraLink/lora_freshness.h new file mode 100644 index 0000000..bb53e2e --- /dev/null +++ b/LoraLink/lora_freshness.h @@ -0,0 +1,20 @@ +#pragma once +// Pure staleness check for the LoraLink receiver's last-seen BPM packet. +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +// True if the last received packet is older than `threshold_ms`, or if +// `has_received` is false (no packet seen yet since boot). `now_ms` / +// `last_seen_ms` are millis()-style timestamps; unsigned subtraction makes +// this wraparound-safe across the ~49.7-day millis() rollover, same trick +// wifi_down_blink_due() relies on (see X32Link/wifi_down_blink.c). +bool lora_freshness_is_stale(uint32_t now_ms, uint32_t last_seen_ms, + uint32_t threshold_ms, bool has_received); + +#ifdef __cplusplus +} +#endif diff --git a/LoraLink/lora_radio.cpp b/LoraLink/lora_radio.cpp new file mode 100644 index 0000000..48e66a1 --- /dev/null +++ b/LoraLink/lora_radio.cpp @@ -0,0 +1,56 @@ +#include "lora_radio.h" +#include "lora_config.h" +#include +#include +#include + +static SX1262 radio = new Module(LORA_NSS, LORA_DIO1, LORA_RST, LORA_BUSY); +static volatile bool s_rx_flag = false; + +// ISR set via radio.setDio1Action() below — mirrors RadioLib's +// SX126x_Receive_Interrupt example; DO NOT call radio.* methods from here. +static void onDio1Action() { + s_rx_flag = true; +} + +void lora_radio_begin() { + // RadioLib's Module only owns NSS/DIO1/RST/BUSY as plain GPIOs it drives + // directly - the SCK/MOSI/MISO lines belong to the global SPI peripheral, + // which defaults to this chip's default SPI pins unless told otherwise. + // Without this, radio.begin() silently talks to the wrong pins and hangs + // waiting for a response the SX1262 never sends. + SPI.begin(LORA_SCK, LORA_MISO, LORA_MOSI, LORA_NSS); + + int state = radio.begin(LORA_FREQ_MHZ, LORA_BANDWIDTH_KHZ, + LORA_SPREADING_FACTOR, LORA_CODING_RATE, + LORA_SYNC_WORD, LORA_TX_POWER_DBM); + if (state != RADIOLIB_ERR_NONE) { + Serial.printf("[LoraLink] radio.begin failed: %d\n", state); + } + radio.setDio2AsRfSwitch(true); // required on most SX1262 breakout boards + radio.setDio1Action(onDio1Action); + radio.startReceive(); +} + +bool lora_radio_send(const uint8_t *buf, int len) { + int state = radio.transmit((uint8_t *)buf, len); + radio.startReceive(); // re-arm RX after the blocking TX + return state == RADIOLIB_ERR_NONE; +} + +bool lora_radio_try_receive(uint8_t *buf, int buf_len, int *out_len) { + if (!s_rx_flag) return false; + s_rx_flag = false; + + size_t len = radio.getPacketLength(); + if (len == 0 || (int)len > buf_len) { + radio.startReceive(); + return false; + } + int state = radio.readData(buf, len); + radio.startReceive(); + if (state != RADIOLIB_ERR_NONE) return false; + + *out_len = (int)len; + return true; +} diff --git a/LoraLink/lora_radio.h b/LoraLink/lora_radio.h new file mode 100644 index 0000000..1297554 --- /dev/null +++ b/LoraLink/lora_radio.h @@ -0,0 +1,15 @@ +#pragma once +#include +#include + +// Configures and starts the SX1262 in receive mode (both roles listen by +// default; the sender simply also transmits between receives). +void lora_radio_begin(); + +// Blocking transmit of `len` bytes from `buf`. Returns true on success. +// Re-arms receive mode afterward. +bool lora_radio_send(const uint8_t *buf, int len); + +// Non-blocking poll: true if a packet arrived, with its length in *out_len +// (payload copied into `buf`, up to `buf_len` bytes). False if nothing new. +bool lora_radio_try_receive(uint8_t *buf, int buf_len, int *out_len); diff --git a/LoraLink/lora_secrets.h.example b/LoraLink/lora_secrets.h.example new file mode 100644 index 0000000..86b38be --- /dev/null +++ b/LoraLink/lora_secrets.h.example @@ -0,0 +1,6 @@ +#pragma once +// Copy this file to lora_secrets.h (gitignored) and fill in your real +// credentials — needed by the sender role only, to join the WiFi network +// the Link session is running on. +#define WIFI_SSID "your-ssid" +#define WIFI_PASSWORD "your-password" diff --git a/LoraLink/session_timeline.c b/LoraLink/session_timeline.c new file mode 120000 index 0000000..57af8b1 --- /dev/null +++ b/LoraLink/session_timeline.c @@ -0,0 +1 @@ +../X32Link/session_timeline.c \ No newline at end of file diff --git a/LoraLink/session_timeline.h b/LoraLink/session_timeline.h new file mode 120000 index 0000000..e6ddb90 --- /dev/null +++ b/LoraLink/session_timeline.h @@ -0,0 +1 @@ +../X32Link/session_timeline.h \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..179aab9 --- /dev/null +++ b/README.md @@ -0,0 +1,92 @@ +# link-devices + +Ableton Link → mixer / MIDI / analog **tempo-sync hardware devices**. ESP32 +firmware family. Carved from the `behringer` monorepo (2026-07-04, history +preserved; recovery tag `pre-split-2026-07-04` on the old repo). + +## Devices + +- **X32Link** (`X32Link/`) — the shipped device. ESP32-S3 firmware: reads tempo + from **Ableton Link** or **USB-MIDI clock** and writes the matching delay time + to a Behringer XR18/X32 FX slot over OSC. Also drives a 1.47" touch LCD + (Waveshare board), **USB-MIDI clock OUT** (LNK-027), a planned analog + Eurorack sync (LNK-028), and **web-based OTA updates** (LNK-034, upload a + .bin at `/update`). Runtime source is chosen from the web/touch config UI. +- **KitchenSync** (`KitchenSync/`) — the "pro" hub tier, in active development + (ESP32-P4-NANO, native ESP-IDF). Turns a Link session into phase-accurate MIDI + for real hardware: **USB-MIDI host** clock + transport out (drive gear like a + Blokas Midihub directly), **phase-locked downbeat** sync, four Multiclock-style + outputs with per-output division / phase nudge / **swing**, an audible + metronome, a WS2812 visual metronome, a rack-panel web UI with **live + (no-reboot) timing config**, **web-based OTA updates** (P4-017, dual-slot), + **MIDI clock IN** detection, and **Follow Beat** (P4-020) — mic-based tempo + detection from the onboard ES8311 codec, display-only for now. Reuses this + repo's pure modules unchanged. See [`KitchenSync/README.md`](KitchenSync/README.md). +- **X32_emulator** (`X32_emulator/`) — on-device X32 OSC emulator used for + integration tests (also consumed by the CLI tests in the `behringer` repo). +- **LoraLink** (`LoraLink/`) — a pair of ESP32-S3+SX1262 LoRa boards that relay + the Link session BPM out of WiFi range for loose (non-phase-accurate) FX + tempo control. See [`LoraLink/README.md`](LoraLink/README.md). +- **LinkAudioPoC** (`LinkAudioPoC/`) — a **proven spike, not a product** + (P4-032). Streams the P4's onboard mic into an Ableton Live 12.4 session as a + **Link Audio** channel, sample-rate-adaptive and calibrated to ~1 ms of Live's + grid. Deliberately separate from KitchenSync: it links the **GPLv2** Ableton + Link SDK, which cannot ship in closed hardware without a commercial licence + from Ableton — a business gate nobody has cleared yet. See + [`LinkAudioPoC/README.md`](LinkAudioPoC/README.md). + +## Architecture + +Pure-C logic (host-tested with Unity) + thin Arduino glue — see `AGENTS.md` and +`docs/adr/0003-firmware-pure-c-glue-split.md`. All the interesting logic (Link +gossip parse, ping/pong measurement + its orchestrator, bar/phase math, the tick +generator, config) is **Arduino-free and portable across hardware targets** — +which is exactly what makes the P4 tier cheap to spin up. `docs/adr/0004` records +the touch-vs-web config split. + +KitchenSync is an embedded **synchronization platform**; Ableton Link is one +supported input protocol, not the identity of the system. For the whole-platform +picture — product family, timing pipeline, control-plane boundary, and where this +is going — start here: + +- [`docs/architecture/system-overview.md`](docs/architecture/system-overview.md) — the source of truth for current state. +- [`docs/architecture/repository-roadmap.md`](docs/architecture/repository-roadmap.md) — the `ks-*` direction and its triggers. +- [`docs/architecture/alignment-report.md`](docs/architecture/alignment-report.md) — aligned / technical-debt / future-evolution snapshot. +- [`docs/contracts/firmware-http-contract.md`](docs/contracts/firmware-http-contract.md) — the firmware↔client HTTP + Bonjour API. +- [`docs/adr/`](docs/adr/) — the numbered decision log (0003–0014). + +## Build / test + +- **Host tests:** `make -C test` (Unity is vendored in `lib/unity/` — no submodule). + Every test also depends on every header, so a changed constant can never leave + a stale binary passing locally while CI fails. +- **Firmware (Arduino targets):** `arduino-cli compile --fqbn 'esp32:esp32:esp32s3:USBMode=hwcdc,CDCOnBoot=cdc,FlashSize=16M,PSRAM=opi' X32Link` (see `AGENTS.md`; board flag goes in `X32Link/build_opt.h`, kept empty at HEAD). +- **Firmware (ESP-IDF targets):** `idf.py build` in `KitchenSync/` or + `LinkAudioPoC/` (esp32p4). WiFi credentials live in a gitignored `sdkconfig`, + never committed. +- **OTA (no USB after first flash):** both X32Link and KitchenSync accept a `.bin` + at `/update` over WiFi. See [`docs/ota-deployment.md`](docs/ota-deployment.md). + +CI (`.github/workflows/ci.yml`) runs the host suite + emulator seam tests and +compiles X32Link (headless / touch / QT Py+battery), X32_emulator, LoraLink +(sender + receiver), and KitchenSync (ESP-IDF). `master` is protected: all four +checks must pass, on an up-to-date branch, before a PR can merge. + +## Tasks + +Own Ordna tracker in `tasks/` (prefixes: `LNK-` X32Link, `P4-` KitchenSync, `ARC-` +cross-cutting firmware architecture, `ESP-` emulator, `MCK-` the retired +standalone MIDI-clock sketch, historical; `.ordna/config.yaml`). The `behringer` monorepo keeps its `T-*` CLI tracker +separately. + +## Credits + +This firmware builds on third-party work — Unity, ESP-IDF + Espressif components, +Arduino-ESP32, RadioLib, U8g2, LovyanGFX, and the web-UI fonts. The shipping +firmware (X32Link, KitchenSync, LoraLink) speaks the Ableton Link protocol via a +**clean-room implementation** in `X32Link/link_protocol.c` — receive-only, no +Ableton code. The `LinkAudioPoC/` spike is the one exception: it links Ableton's +own **GPLv2** SDK (vendored, gitignored, patched — see +`LinkAudioPoC/patches/`), which is why it is quarantined outside the product +firmware. See [`THIRD_PARTY.md`](THIRD_PARTY.md) for authors, licenses, and +where each is used. diff --git a/THIRD_PARTY.md b/THIRD_PARTY.md new file mode 100644 index 0000000..97f10ba --- /dev/null +++ b/THIRD_PARTY.md @@ -0,0 +1,66 @@ +# Third-party credits + +This firmware family stands on other people's work. Thanks to the authors and +communities below. Each component remains under its own license, held by its +respective copyright holders. + +## Bundled in this repository + +| Component | Author(s) | License | Used by | Location | +|---|---|---|---|---| +| **Unity** (unit-test framework) | Mike Karlesky, Mark VanderVoord & Greg Williams — [ThrowTheSwitch.org](https://www.throwtheswitch.org/unity) | MIT | host test suites (`test/`) | vendored in [`lib/unity/`](lib/unity/) — full text in [`lib/unity/LICENSE.txt`](lib/unity/LICENSE.txt) | + +## Pulled at build time (not stored here) + +### ESP-IDF managed components — KitchenSync (ESP-IDF) + +Fetched by the IDF component manager per `KitchenSync/main/idf_component.yml`; each is +by **Espressif Systems**, **Apache-2.0**. + +| Component | Purpose | +|---|---| +| [`espressif/esp_hosted`](https://components.espressif.com/components/espressif/esp_hosted) | WiFi on the P4 via the onboard ESP32-C6 (ESP-Hosted transport) | +| [`espressif/esp_wifi_remote`](https://components.espressif.com/components/espressif/esp_wifi_remote) | WiFi API surface over the hosted link | +| [`espressif/es8311`](https://components.espressif.com/components/espressif/es8311) | ES8311 codec driver for the onboard-speaker metronome | + +### Frameworks & Arduino libraries — X32Link / X32_emulator (arduino-cli) + +| Component | Author(s) | License | +|---|---|---| +| **ESP-IDF** (KitchenSync) | Espressif Systems | Apache-2.0 | +| **Arduino-ESP32** core (X32Link, X32_emulator) | Espressif Systems | LGPL-2.1-or-later | +| **LovyanGFX** — touch-LCD graphics ([`X32Link/touch_display.*`](X32Link/)) | lovyan03 (Kenji Ono) | BSD-2-Clause (FreeBSD) | + +### Web fonts — loaded at runtime by the config UI (CDN, not redistributed here) + +The rack-panel web UI (`KitchenSync/main/ks_web.cpp`, `X32Link/web_config.cpp`) +references these over Google Fonts / jsDelivr. All **SIL Open Font License 1.1**. + +| Font | Author | Source | +|---|---|---| +| **Bricolage Grotesque** | Mathieu Triay | Google Fonts | +| **DM Mono** | Colophon Foundry (for Google) | Google Fonts | +| **DSEG** (7-seg display, v0.46.0) | Keshikan | [github.com/keshikan/DSEG](https://github.com/keshikan/DSEG) via jsDelivr | + +## Protocols (independent implementations — no third-party code) + +- **Ableton Link** — the Link session protocol is by **Ableton AG**. This project + speaks it via a **clean-room implementation** (gossip parse, ghost-xform + measurement, timeline/phase math in `link_protocol` / `link_measurement` / + `link_phase`) written from the observable wire protocol; no Ableton source is + used or included. "Ableton" and "Link" are trademarks of Ableton AG. +- **MIDI** is a specification of the MIDI Association; the clock/transport bytes + here are implemented directly. + +## Documentation & references + +- **X32 / M32 OSC protocol — Patrick Maillot.** The entire X32 / XR18 OSC side of + this project (writing FX delay times and reading mixer state over OSC) rests on + **Patrick Maillot's** meticulous *Unofficial X32/M32 OSC protocol* documentation: + . Behringer publishes no + official OSC spec; his reverse-engineered reference is what makes talking to the + console possible at all. This work would not exist without it — thank you. + +--- + +*Spot a missing or mis-stated attribution? It's a bug — please open an issue.* diff --git a/X32FaderDisp/X32FaderDisp.ino b/X32FaderDisp/X32FaderDisp.ino deleted file mode 100644 index d0b0d1d..0000000 --- a/X32FaderDisp/X32FaderDisp.ino +++ /dev/null @@ -1,95 +0,0 @@ -#include -#include "config.h" -#include "app_config.h" -#include "osc_listener.h" -#include "fader_disp.h" -#include "web_config.h" - -AppConfig g_config; - -// osc_listener callback — log every message; drive the scribble display when on. -static void on_osc(const osc_msg_t *m) { - Serial.printf("[FDR] %s ,%s (%d)\n", m->address, m->tags ? m->tags : "", m->n_args); - if (g_config.fdr_enable) fader_disp_handle(m); -} - -static bool wifi_try_connect() { - Serial.printf("[FDR] connecting to %s\n", g_config.wifi_ssid); - WiFi.mode(WIFI_STA); - WiFi.begin(g_config.wifi_ssid, g_config.wifi_pass); - unsigned long start = millis(); - while (WiFi.status() != WL_CONNECTED) { - if (millis() - start > 30000) { WiFi.disconnect(true); return false; } - delay(500); Serial.print("."); - } - WiFi.setSleep(false); - Serial.printf("\n[FDR] IP: %s\n", WiFi.localIP().toString().c_str()); - return true; -} - -static void wifi_connect() { - WiFi.begin(g_config.wifi_ssid, g_config.wifi_pass); - while (WiFi.status() != WL_CONNECTED) { delay(500); Serial.print("."); } - Serial.printf("\n[FDR] IP: %s\n", WiFi.localIP().toString().c_str()); -} - -static void start_config_ap() { - WiFi.mode(WIFI_AP); - WiFi.softAP("X32FaderDisp-Config"); - Serial.printf("[FDR] WiFi failed — AP at http://%s\n", WiFi.softAPIP().toString().c_str()); - web_config_ap_begin(); - for (;;) web_config_handle(); // save handler reboots -} - -static void check_factory_reset() { - pinMode(0, INPUT_PULLUP); - if (digitalRead(0) != LOW) return; - unsigned long t = millis(); - while (digitalRead(0) == LOW && millis() - t < 5000) delay(100); - if (millis() - t >= 5000) { config_clear(); ESP.restart(); } -} - -static void start_listening() { - int port = config_model_port(g_config.model); - osc_listener_begin(g_config.mixer_ip, port, on_osc); - fader_disp_begin(g_config.fdr_chan_count); -} - -void setup() { - Serial.begin(115200); - delay(2000); - Serial.println("[FDR] booting"); - - check_factory_reset(); - config_load(&g_config); - Serial.printf("[FDR] model=%s mixer=%s:%d\n", - g_config.model == MODEL_X32 ? "X32" : "XR18", - g_config.mixer_ip, config_model_port(g_config.model)); - - if (!wifi_try_connect()) start_config_ap(); // never returns on AP path - Serial.println("[FDR] connected"); - - start_listening(); - Serial.println("[FDR] handshake OK"); - web_config_begin(); -} - -void loop() { - if (WiFi.status() != WL_CONNECTED) { - Serial.println("[FDR] WiFi lost — reconnecting"); - wifi_connect(); - start_listening(); - } - osc_listener_poll(); - web_config_handle(); - - static uint32_t last_log = 0; - uint32_t now = millis(); - if (now - last_log >= 5000) { - last_log = now; - Serial.printf("[FDR] ip:%s heap:%lu\n", - WiFi.localIP().toString().c_str(), - (unsigned long)ESP.getFreeHeap()); - } - delay(5); -} diff --git a/X32FaderDisp/app_config.c b/X32FaderDisp/app_config.c deleted file mode 120000 index 68868ec..0000000 --- a/X32FaderDisp/app_config.c +++ /dev/null @@ -1 +0,0 @@ -../X32Link/app_config.c \ No newline at end of file diff --git a/X32FaderDisp/app_config.h b/X32FaderDisp/app_config.h deleted file mode 120000 index b804ca8..0000000 --- a/X32FaderDisp/app_config.h +++ /dev/null @@ -1 +0,0 @@ -../X32Link/app_config.h \ No newline at end of file diff --git a/X32FaderDisp/app_config_nvs.cpp b/X32FaderDisp/app_config_nvs.cpp deleted file mode 120000 index 4902cb1..0000000 --- a/X32FaderDisp/app_config_nvs.cpp +++ /dev/null @@ -1 +0,0 @@ -../X32Link/app_config_nvs.cpp \ No newline at end of file diff --git a/X32FaderDisp/config.h b/X32FaderDisp/config.h deleted file mode 100644 index 1403296..0000000 --- a/X32FaderDisp/config.h +++ /dev/null @@ -1,17 +0,0 @@ -#pragma once - -// X32FaderDisp — write live fader dB onto channel scribble strips (X32/M32). -#define FDR_NAME_REFRESH_MS 100 // min ms between scribble writes per channel -#define FDR_CHAN_COUNT 32 // X32 channels (16 for XR18) -#define FDR_RESTORE_ON_EXIT 1 // save/restore original names on disable/stop -#define FDR_XREMOTE_INTERVAL 9000 // ms between /xremote keepalives - -// First-boot defaults — consumed by the shared app_config.c -#define DEFAULT_WIFI_SSID "X32-Emulator" -#define DEFAULT_WIFI_PASS "" -#define DEFAULT_MIXER_IP "192.168.4.1" -#define DEFAULT_MODEL 2 // MODEL_X32 (FaderDisp is X32-only; port 10023) -#define DEFAULT_FX_SLOT 1 -#define DEFAULT_INPUT_SOURCE 0 -#define DEFAULT_FDR_ENABLE 1 -#define DEFAULT_FDR_CHAN_COUNT 32 // X32; set 16 for XR18 diff --git a/X32FaderDisp/fader_db.c b/X32FaderDisp/fader_db.c deleted file mode 100644 index 6a93e1c..0000000 --- a/X32FaderDisp/fader_db.c +++ /dev/null @@ -1,19 +0,0 @@ -#include "fader_db.h" -#include -#include - -float fader_to_db(float f) { - if (f >= 1.0f) return 10.0f; - if (f >= 0.5f) return 40.0f * f - 30.0f; - if (f >= 0.25f) return 80.0f * f - 50.0f; - if (f >= 0.0625f) return 160.0f * f - 70.0f; - if (f > 0.0f) return 480.0f * f - 90.0f; - return FADER_DB_MINUS_INF; /* f <= 0 → -oo */ -} - -void fader_db_str(float f, char *out, int n) { - float d = fader_to_db(f); - if (d <= FADER_DB_MINUS_INF + 0.05f) { snprintf(out, n, "-oo"); return; } - if (fabsf(d) < 0.05f) { snprintf(out, n, "0.0"); return; } - snprintf(out, n, "%+.1f", d); /* "+10.0", "-6.0" */ -} diff --git a/X32FaderDisp/fader_db.h b/X32FaderDisp/fader_db.h deleted file mode 100644 index 20d37bc..0000000 --- a/X32FaderDisp/fader_db.h +++ /dev/null @@ -1,19 +0,0 @@ -#pragma once - -#ifdef __cplusplus -extern "C" { -#endif - -#define FADER_DB_MINUS_INF (-90.0f) /* the "-oo" floor */ - -/* X32 fader law: OSC float f (0..1) → dB. Returns FADER_DB_MINUS_INF at f<=0. - * (Exact piecewise from docs/xr18-xair-osc-cheatsheet.md.) */ -float fader_to_db(float f); - -/* Format a fader float as a scribble-strip dB string ("+10.0", "0.0", "-6.0", - * "-oo"), ≤7 chars. Writes into out[n]. */ -void fader_db_str(float f, char *out, int n); - -#ifdef __cplusplus -} -#endif diff --git a/X32FaderDisp/fader_disp.cpp b/X32FaderDisp/fader_disp.cpp deleted file mode 100644 index e241ad0..0000000 --- a/X32FaderDisp/fader_disp.cpp +++ /dev/null @@ -1,81 +0,0 @@ -#include "fader_disp.h" -#include "fader_db.h" -#include "osc_listener.h" -#include "config.h" -#include -#include - -#define FDR_MAXCH 32 - -static int s_chan_count = FDR_CHAN_COUNT; -static char s_orig_name[FDR_MAXCH + 1][16]; // 1-indexed -static bool s_orig_cached[FDR_MAXCH + 1]; -static uint32_t s_last_write[FDR_MAXCH + 1]; - -// "/ch/NN/..." -> NN (1..chan_count), or -1. -static int parse_ch(const char *a) { - if (strncmp(a, "/ch/", 4) != 0) return -1; - if (a[4] < '0' || a[4] > '9' || a[5] < '0' || a[5] > '9') return -1; - int ch = (a[4] - '0') * 10 + (a[5] - '0'); - return (ch >= 1 && ch <= s_chan_count) ? ch : -1; -} - -static int osc_str(uint8_t *b, int o, const char *s) { - int n = (int)strlen(s) + 1; - memcpy(b + o, s, n); o += n; - while (o % 4) b[o++] = 0; - return o; -} - -static void set_name(int ch, const char *name) { - uint8_t b[48]; char addr[24]; - snprintf(addr, sizeof addr, "/ch/%02d/config/name", ch); - int o = osc_str(b, 0, addr); - o = osc_str(b, o, ",s"); - o = osc_str(b, o, name); - osc_listener_send(b, o); -} - -static void query_name(int ch) { - uint8_t b[32]; char addr[24]; - snprintf(addr, sizeof addr, "/ch/%02d/config/name", ch); - int o = osc_str(b, 0, addr); - osc_listener_send(b, o); // node query → mixer replies with current name -} - -void fader_disp_begin(int chan_count) { - s_chan_count = (chan_count > 0 && chan_count <= FDR_MAXCH) ? chan_count : FDR_CHAN_COUNT; - memset(s_orig_cached, 0, sizeof s_orig_cached); - memset(s_last_write, 0, sizeof s_last_write); - for (int ch = 1; ch <= s_chan_count; ch++) query_name(ch); -} - -void fader_disp_handle(const osc_msg_t *m) { - int ch = parse_ch(m->address); - if (ch < 0) return; - const char *suf = m->address + 6; // after "/ch/NN" - - if (strcmp(suf, "/mix/fader") == 0 && - m->n_args >= 1 && m->args[0].type == OSC_ARG_FLOAT) { - uint32_t now = (uint32_t)millis(); - if (now - s_last_write[ch] < FDR_NAME_REFRESH_MS) return; // throttle - s_last_write[ch] = now; - char db[8]; - fader_db_str(m->args[0].val.f, db, sizeof db); - set_name(ch, db); - } else if (strcmp(suf, "/config/name") == 0 && - m->n_args >= 1 && m->args[0].type == OSC_ARG_STRING) { - if (!s_orig_cached[ch]) { // first sight = original - strncpy(s_orig_name[ch], m->args[0].val.s, sizeof s_orig_name[ch] - 1); - s_orig_name[ch][sizeof s_orig_name[ch] - 1] = 0; - s_orig_cached[ch] = true; - } - } -} - -void fader_disp_restore(void) { -#if FDR_RESTORE_ON_EXIT - for (int ch = 1; ch <= s_chan_count; ch++) - if (s_orig_cached[ch]) set_name(ch, s_orig_name[ch]); -#endif -} diff --git a/X32FaderDisp/fader_disp.h b/X32FaderDisp/fader_disp.h deleted file mode 100644 index fc4a2ee..0000000 --- a/X32FaderDisp/fader_disp.h +++ /dev/null @@ -1,21 +0,0 @@ -#pragma once -#include "osc_in.h" - -#ifdef __cplusplus -extern "C" { -#endif - -/* Start: cache original channel names (queries /ch/NN/config/name) so they can - * be restored later. chan_count is 16 (XR18) or 32 (X32). */ -void fader_disp_begin(int chan_count); - -/* osc_listener callback: on /ch/NN/mix/fader, write the dB string to that - * channel's scribble name (throttled); on /ch/NN/config/name, cache originals. */ -void fader_disp_handle(const osc_msg_t *msg); - -/* Write the cached original names back (graceful stop / disable). */ -void fader_disp_restore(void); - -#ifdef __cplusplus -} -#endif diff --git a/X32FaderDisp/osc_in.c b/X32FaderDisp/osc_in.c deleted file mode 120000 index 15f78da..0000000 --- a/X32FaderDisp/osc_in.c +++ /dev/null @@ -1 +0,0 @@ -../X32Link/osc_in.c \ No newline at end of file diff --git a/X32FaderDisp/osc_in.h b/X32FaderDisp/osc_in.h deleted file mode 120000 index 7f7f107..0000000 --- a/X32FaderDisp/osc_in.h +++ /dev/null @@ -1 +0,0 @@ -../X32Link/osc_in.h \ No newline at end of file diff --git a/X32FaderDisp/osc_listener.cpp b/X32FaderDisp/osc_listener.cpp deleted file mode 120000 index 522768f..0000000 --- a/X32FaderDisp/osc_listener.cpp +++ /dev/null @@ -1 +0,0 @@ -../X32Link/osc_listener.cpp \ No newline at end of file diff --git a/X32FaderDisp/osc_listener.h b/X32FaderDisp/osc_listener.h deleted file mode 120000 index a63eded..0000000 --- a/X32FaderDisp/osc_listener.h +++ /dev/null @@ -1 +0,0 @@ -../X32Link/osc_listener.h \ No newline at end of file diff --git a/X32FaderDisp/osc_out.c b/X32FaderDisp/osc_out.c deleted file mode 120000 index afacf6a..0000000 --- a/X32FaderDisp/osc_out.c +++ /dev/null @@ -1 +0,0 @@ -../X32Link/osc_out.c \ No newline at end of file diff --git a/X32FaderDisp/osc_out.h b/X32FaderDisp/osc_out.h deleted file mode 120000 index f048502..0000000 --- a/X32FaderDisp/osc_out.h +++ /dev/null @@ -1 +0,0 @@ -../X32Link/osc_out.h \ No newline at end of file diff --git a/X32FaderDisp/osc_sender.cpp b/X32FaderDisp/osc_sender.cpp deleted file mode 120000 index 537e970..0000000 --- a/X32FaderDisp/osc_sender.cpp +++ /dev/null @@ -1 +0,0 @@ -../X32Link/osc_sender.cpp \ No newline at end of file diff --git a/X32FaderDisp/osc_sender.h b/X32FaderDisp/osc_sender.h deleted file mode 120000 index 839e044..0000000 --- a/X32FaderDisp/osc_sender.h +++ /dev/null @@ -1 +0,0 @@ -../X32Link/osc_sender.h \ No newline at end of file diff --git a/X32FaderDisp/web_config.cpp b/X32FaderDisp/web_config.cpp deleted file mode 100644 index 61ba3a1..0000000 --- a/X32FaderDisp/web_config.cpp +++ /dev/null @@ -1,202 +0,0 @@ -#include "web_config.h" -#include "app_config.h" -#include -#include -#include - -extern AppConfig g_config; -extern void config_save(const AppConfig*); - -static WebServer server(80); -static DNSServer s_dns; -static bool s_captive = false; -static IPAddress s_ap_ip; - -// Rack-panel config page for X32FaderDisp (same aesthetic as X32·SYNC). -static const char HTML_TMPL[] = R"HTML( - - -X32·FADER - - - -
- -
X32·FADERSCRIBBLE dB
-
- - - -
-
writes dB to scribble names
-
- -
-
-
-
XR18=16 · X32=32
-
- -
-
-
-
OSC·:10023
-
- -
-
-
-
on this network
-
HOST
-
-
-
2.4 GHz
-
SSID
-
PASS
-
- -
ESP32-S3 · X32/M32 scribble strips
-
-
-)HTML"; - -static String build_html() { - String h(HTML_TMPL); - h.replace("%EN%", String(g_config.fdr_enable)); - h.replace("%CH%", String(g_config.fdr_chan_count)); - h.replace("%MODEL%", String(g_config.model)); - h.replace("%IP%", g_config.mixer_ip); - h.replace("%SSID%", g_config.wifi_ssid); - return h; -} - -static void handle_root() { server.send(200, "text/html", build_html()); } - -static void send_result(int code, const char *title, const char *body) { - String p = "" - "" - "" - "

"; - p += title; p += "

"; p += body; p += "

"; - server.send(code, "text/html; charset=utf-8", p); -} - -static void handle_save() { - AppConfig cfg = g_config; - - int en = server.arg("fdr_enable").toInt(); - if (en == 0 || en == 1) cfg.fdr_enable = en; - - int ch = server.arg("fdr_chan").toInt(); - if (ch == 16 || ch == 32) cfg.fdr_chan_count = ch; - - int model = server.arg("model").toInt(); - if (model == MODEL_XR18 || model == MODEL_X32) cfg.model = model; - - String ip = server.arg("mixer_ip"); - if (ip.length() > 0 && ip.length() < (int)sizeof(cfg.mixer_ip)) - ip.toCharArray(cfg.mixer_ip, sizeof(cfg.mixer_ip)); - - String ssid = server.arg("wifi_ssid"); - if (ssid.length() > 0 && ssid.length() < (int)sizeof(cfg.wifi_ssid)) - ssid.toCharArray(cfg.wifi_ssid, sizeof(cfg.wifi_ssid)); - - String pass = server.arg("wifi_pass"); - if (pass.length() > 0 && pass.length() < (int)sizeof(cfg.wifi_pass)) - pass.toCharArray(cfg.wifi_pass, sizeof(cfg.wifi_pass)); - - if (config_validate(&cfg)) { - g_config = cfg; - config_save(&g_config); - send_result(200, "Saved — Restarting", "Reconnect to WiFi if credentials changed."); - delay(1000); - ESP.restart(); - } else { - send_result(400, "Invalid Config", "Check the mixer IP, then go back."); - } -} - -static void handle_captive_redirect() { - String url = "http://"; url += s_ap_ip.toString(); url += "/"; - server.sendHeader("Location", url, true); - server.send(302, "text/plain", ""); -} - -void web_config_begin() { - server.on("/", HTTP_GET, handle_root); - server.on("/save", HTTP_POST, handle_save); - server.begin(); -} - -void web_config_ap_begin() { - s_ap_ip = WiFi.softAPIP(); - s_captive = true; - s_dns.start(53, "*", s_ap_ip); - server.on("/", HTTP_GET, handle_root); - server.on("/save", HTTP_POST, handle_save); - server.on("/hotspot-detect.html", HTTP_GET, handle_captive_redirect); - server.on("/generate_204", HTTP_GET, handle_captive_redirect); - server.on("/ncsi.txt", HTTP_GET, handle_captive_redirect); - server.onNotFound(handle_captive_redirect); - server.begin(); -} - -void web_config_handle() { - if (s_captive) s_dns.processNextRequest(); - server.handleClient(); -} diff --git a/X32FaderDisp/web_config.h b/X32FaderDisp/web_config.h deleted file mode 120000 index 2e1811f..0000000 --- a/X32FaderDisp/web_config.h +++ /dev/null @@ -1 +0,0 @@ -../X32Link/web_config.h \ No newline at end of file diff --git a/X32Link/X32Link.ino b/X32Link/X32Link.ino index 4cf5a67..4e0a604 100644 --- a/X32Link/X32Link.ino +++ b/X32Link/X32Link.ino @@ -1,27 +1,123 @@ +// ============================================================================ +// X32Link — tempo -> XR18/X32 mixer FX-delay sync over OSC. +// +// THIS IS NOT THE CLOCK BOX. KitchenSync/ is the repo's PRIMARY product (Link in +// -> MIDI/analog clock + transport out); KitchenSyncTouch/ is that same engine built +// on the Arduino core instead of ESP-IDF. X32Link is a RELATED product, not the +// primary one. Clock-out, transport, buttons, MIDI and analog outputs belong THERE. +// +// X32Link/ is ALSO the shared-pure-C home (ADR-0003/0007): every other sketch +// symlinks modules out of this directory. That is an arduino-cli constraint -- +// it can only compile files inside a sketch root -- and NOT a statement that +// this firmware is the product. Adding a shared pure module here is right. +// Adding a clock-box FEATURE here is how it ends up in the wrong firmware. +// +// See the firmware map at the top of AGENTS.md. +// ============================================================================ #include +#include // ESP-035: this box was invisible to the fleet until now +#include #include "config.h" +#include "fw_version.h" // LNK-038: FW_VERSION / FW_BUILD identity #include "app_config.h" #include "tempo_source.h" +#include "led_phase.h" +#include "wifi_down_blink.h" #include "link_listener.h" // for the loop() status counters (rx/mcast) in Link mode #include "bpm_publisher.h" #include "osc_sender.h" #include "web_config.h" +#include "wifi_conn_policy.h" // ARC-013: shared WiFi connection lifecycle +#include "tempo_snapshot.h" // ARC-001: atomic {bpm,phase,valid,quantum} read path +#ifdef HAS_TOUCH_DISPLAY // LNK-025: implied by the board flag in config.h (included above) +#include "touch_display.h" // LNK-014: 1.47" JD9853 LCD + AXS5106L touch +#endif +#ifdef HAS_BATTERY_GAUGE // opt-in per unit — the QT Py's LiPo BFF fuel gauge +#include "battery_gauge_io.h" +#include "battery_snapshot.h" +#endif #define LED_FLASH_MS 30 +// WiFi-down status indicator (LNK-023): occasional triple-blink, distinct +// from the single per-beat flash, while WiFi.status() != WL_CONNECTED +// (covers both the initial connect attempt and parked-in-AP-fallback). +#define WIFI_DOWN_BLINK_ON_MS 60 +#define WIFI_DOWN_BLINK_GAP_MS 80 +#define WIFI_DOWN_BLINK_COUNT 3 +#define WIFI_DOWN_BLINK_INTERVAL_MS 4000 + +// MAX17048's own ADC/filtering cycle is ~1Hz internally — polling faster than +// that over I2C just re-reads the same conversion, so this is deliberately +// slow (also keeps it well off the touch board's separate I2C traffic, on +// units that somehow have both, though HAS_BATTERY_GAUGE + HAS_TOUCH_DISPLAY +// isn't a real combination today). +#define BATTERY_POLL_MS 3000 + AppConfig g_config; -static SemaphoreHandle_t g_bpm_mutex; -volatile float g_current_bpm = LINK_DEFAULT_BPM; +// Live tempo now lives behind the tempo_snapshot seam (ARC-001) — the old +// g_current_bpm / g_current_phase / g_phase_valid globals + g_bpm_mutex are +// gone; bpm_task publishes a coherent snapshot readers take atomically. + +// ARC-013: the WiFi connection lifecycle (initial-connect budget → AP fallback, +// retry-forever-after-IP) is the pure wifi_conn_policy, shared with the P4. AP is +// its terminal state (loop() checks it so the STA-reconnect block doesn't fight AP +// mode; terminal until handle_save() reboots — LNK-023). +static WifiConnPolicy g_wifi_pol; // Beat LED. ESP32-S3 Super Mini: a plain LED on GPIO48, active-HIGH (proven by // the original MIDI firmware). Define LED_ACTIVE_LOW for an active-low board -// (e.g. XIAO), or LED_RGB for a board with an addressable WS2812. -#define LED_PIN_NUM 48 +// (e.g. XIAO), or LED_RGB for a board with an addressable WS2812. Define +// BOARD_QTPY_ESP32S3 for the Adafruit QT Py ESP32-S3: its beat LED is an +// onboard NeoPixel on GPIO39 (implies LED_RGB) whose level shifter needs +// GPIO38 driven HIGH before it'll light — LED_PWR_PIN_NUM handles that. +// Define BOARD_WAVESHARE_S3_TOUCH_LCD_147 for the Waveshare ESP32-S3-Touch- +// LCD-1.47 (the Type-C touch board — NOT the non-touch USB-A dongle, which is +// a different PCB). This board has NO addressable WS2812: its only LEDs are a +// charger-driven charge-status LED and an always-on power LED, neither under +// MCU control (verified against the Waveshare schematic GPIO map). GPIO38 there +// is LCD_SCL, not an LED — driving it as a beat LED silently toggles the display +// clock, so don't. There is no discrete MCU beat LED (LED_NONE): the board flag +// implies HAS_TOUCH_DISPLAY (see above), so the beat shows on-screen (LNK-015 +// phase wheel) and via the web /status dot (LNK-022); GPIO46 is the LCD backlight +// owned by the display, not a strobe. +// ESP-037: the QT Py NeoPixel variant was dropped -- it was the only LED_RGB board, and +// its 4/8 MB flash never fit the fleet's 16 MB partition story. The LED_RGB machinery +// below stays (compiled out) in case an addressable-strip board returns. +#if defined(BOARD_WAVESHARE_S3_TOUCH_LCD_147) + #define LED_NONE // no MCU-controlled LED; beat shown via web /status +#else + #define LED_PIN_NUM 48 // Super Mini: a plain LED on GPIO48 +#endif -static void led_set(bool on) { +#define LED_BRIGHTNESS 40 // NeoPixel channel cap — matches the pre-LNK-039 hardcoded level +#define LED_STATUS_GREEN 0x00FF00 // WiFi-down blink stays this, never user-configurable + +#if defined(LED_RGB) +static uint32_t s_led_rgb = 0; // current flash colour, set per flash (LNK-039) +#endif + +// LNK-039: pick this flash's colour from the configured dot colours — +// bar-1 accent when bar phase is available, plain beat colour otherwise. +// Reads g_config at each flash, so /live colour edits apply immediately. +static void led_pick_color(bool bar_valid) { #if defined(LED_RGB) - rgbLedWrite(LED_PIN_NUM, 0, on ? 40 : 0, 0); // green beat + float bar_phase = bar_valid ? tempo_source_phase((float)g_config.quantum_beats) : 0.0f; + s_led_rgb = led_flash_rgb((uint32_t)g_config.dot_beat_color, + (uint32_t)g_config.dot_accent_color, + bar_phase, bar_valid, LED_BRIGHTNESS); +#else + (void)bar_valid; +#endif +} + +static void led_set(bool on) { +#if defined(LED_NONE) + (void)on; // headless: beat via web /status dot +#elif defined(LED_RGB) + uint32_t c = on ? s_led_rgb : 0; + rgbLedWrite(LED_PIN_NUM, (c >> 16) & 0xFF, (c >> 8) & 0xFF, c & 0xFF); #elif defined(LED_ACTIVE_LOW) digitalWrite(LED_PIN_NUM, on ? LOW : HIGH); #else @@ -30,96 +126,265 @@ static void led_set(bool on) { } static void led_setup() { -#if !defined(LED_RGB) +#if defined(LED_PWR_PIN_NUM) + pinMode(LED_PWR_PIN_NUM, OUTPUT); + digitalWrite(LED_PWR_PIN_NUM, HIGH); +#endif +#if !defined(LED_RGB) && !defined(LED_NONE) pinMode(LED_PIN_NUM, OUTPUT); #endif led_set(false); } +// Phase-locked beat LED (LNK-021). Source-agnostic: only calls the +// tempo_source_* seam, same as the rest of this file — Link vs MIDI is +// invisible here. Once tempo_source_phase_valid() is true, flashes on +// real phase-zero crossings (tempo_source_phase(1.0f), per-beat quantum — +// not the bar-level quantum the touch UI's phase wheel uses). Before +// that's available (sync gap: mid-measurement on Link, or +// tempo_source_phase_valid()'s underlying g_config.quantum_beats-sized +// bar not yet fully observed on MIDI — see tempo_source.cpp), falls back +// to tempo_source_beat()'s self-clearing per-beat flag so the LED never +// goes dark while waiting on phase. Polls every 5ms, so wrap detection has +// up to ~5ms of slop — within tolerance for a "looks synced" LED. static void led_task(void*) { led_setup(); - uint32_t last_flash_ms = 0; + float prev_phase = -1.0f; // -1.0f: no reading since (re)sync; seed-only + uint32_t last_wifi_blink_ms = 0; for (;;) { - xSemaphoreTake(g_bpm_mutex, portMAX_DELAY); - float bpm = g_current_bpm; - xSemaphoreGive(g_bpm_mutex); - - if (bpm > 0.0f && tempo_source_active()) { - uint32_t beat_ms = (uint32_t)(60000.0f / bpm); - uint32_t now = (uint32_t)millis(); - if (now - last_flash_ms >= beat_ms) { + if (tempo_source_active()) { + bool valid = tempo_source_phase_valid(); + if (valid) { + float phase = tempo_source_phase(1.0f); + if (led_phase_should_flash(prev_phase, phase, valid)) { + led_pick_color(true); // LNK-039: bar-1 accent vs beat colour + led_set(true); + vTaskDelay(pdMS_TO_TICKS(LED_FLASH_MS)); + led_set(false); + } + prev_phase = phase; + } else { + // Not synced yet — fall back to the per-beat flag so the + // LED doesn't go dark during the sync gap. Re-seed + // prev_phase so the invalid->valid transition's first + // phase reading is treated as a fresh seed, not compared + // against a stale value (no double-flash/stutter). + prev_phase = -1.0f; + if (tempo_source_beat()) { + led_pick_color(false); // no bar info in the sync gap: beat colour + led_set(true); + vTaskDelay(pdMS_TO_TICKS(LED_FLASH_MS)); + led_set(false); + } + } + } else { + prev_phase = -1.0f; // no source: re-seed for whenever it returns + } + + // WiFi-down triple-blink (LNK-023). Can visually collide with the + // beat-flash above (e.g. MIDI clock running while WiFi is down — + // the exact scenario found live); accepted rather than building + // priority/queueing — 1x30ms vs 3x60ms read as visually distinct. + // Blocking ~420ms here is fine: led_task is low-priority/dedicated. + uint32_t now_ms = (uint32_t)millis(); + bool wifi_connected = (WiFi.status() == WL_CONNECTED); + if (wifi_down_blink_due(now_ms, last_wifi_blink_ms, + WIFI_DOWN_BLINK_INTERVAL_MS, wifi_connected)) { + last_wifi_blink_ms = now_ms; + // Diagnostic blink keeps a fixed colour: a dark user beat colour + // (down to #000000) must never hide the "WiFi is down" signal. +#if defined(LED_RGB) + s_led_rgb = led_flash_rgb(LED_STATUS_GREEN, LED_STATUS_GREEN, 0.0f, false, LED_BRIGHTNESS); +#endif + for (int i = 0; i < WIFI_DOWN_BLINK_COUNT; i++) { led_set(true); - vTaskDelay(pdMS_TO_TICKS(LED_FLASH_MS)); + vTaskDelay(pdMS_TO_TICKS(WIFI_DOWN_BLINK_ON_MS)); led_set(false); - last_flash_ms = now; + vTaskDelay(pdMS_TO_TICKS(WIFI_DOWN_BLINK_GAP_MS)); } } + vTaskDelay(pdMS_TO_TICKS(5)); } } static void bpm_task(void*) { - bpm_publisher_init(tempo_source_threshold(), LINK_SEND_INTERVAL_MS, LINK_REFRESH_BARS); + bpm_publisher_init(tempo_source_threshold(), LINK_SEND_INTERVAL_MS, LINK_REFRESH_BARS, g_config.quantum_beats); + float pub_bpm = LINK_DEFAULT_BPM; // last published BPM (updates on d.send) for (;;) { tempo_source_poll(); + + // Phase moves continuously (unlike the threshold/refresh-gated bpm + // publish); read it every tick. + bool valid = tempo_source_phase_valid(); + float phase = valid ? tempo_source_phase((float)g_config.quantum_beats) : -1.0f; + PublishDecision d = bpm_publisher_step(tempo_source_bpm(), tempo_source_active(), (uint32_t)millis()); if (d.send) { osc_send_bpm(d.bpm); - xSemaphoreTake(g_bpm_mutex, portMAX_DELAY); - g_current_bpm = d.bpm; - xSemaphoreGive(g_bpm_mutex); + pub_bpm = d.bpm; Serial.printf("[X32Sync] BPM %.2f → OSC sent%s\n", d.bpm, d.refresh ? " (refresh)" : ""); } + + // One coherent snapshot per tick (ARC-001) — replaces the 3 globals + + // mutex; readers take an atomic, torn-free {bpm,phase,valid,quantum}. + tempo_snapshot_publish(pub_bpm, phase, valid, g_config.quantum_beats); + vTaskDelay(pdMS_TO_TICKS(tempo_source_poll_ms())); } } -// Returns true if connected within 30 s, false on timeout. -static bool wifi_try_connect() { - Serial.printf("[X32Link] connecting to %s\n", g_config.wifi_ssid); +#ifdef HAS_BATTERY_GAUGE +// LiPo BFF fuel gauge poll. Independent of WiFi/tempo — runs unconditionally +// once started, same "don't gate hardware readout on network state" reasoning +// as bpm_task/led_task above. battery_reading_t left un-published (present +// stays false) whenever the I2C read fails, e.g. no LiPo BFF stacked on this +// unit — handle_status() then omits batt_v/batt_pct from /status entirely. +static void battery_task(void*) { + battery_gauge_io_begin(); + for (;;) { + battery_reading_t r; + bool ok = battery_gauge_io_read(&r); + battery_snapshot_publish(ok ? r.volts : 0.0f, ok ? r.percent : 0.0f, ok); + vTaskDelay(pdMS_TO_TICKS(BATTERY_POLL_MS)); + } +} +#endif + +static int64_t now_us_ms() { return (int64_t)millis() * 1000; } + +// Last esp_wifi disconnect reason (201=NO_AP_FOUND, 15=4WAY_HANDSHAKE, 2=AUTH_EXPIRE…). +static int s_wifi_disc_reason = -1; + +static void on_wifi_event(WiFiEvent_t event, WiFiEventInfo_t info) { + if (event == ARDUINO_EVENT_WIFI_STA_START) { + esp_wifi_connect(); + } else if (event == ARDUINO_EVENT_WIFI_STA_DISCONNECTED) { + s_wifi_disc_reason = (int)info.wifi_sta_disconnected.reason; + Serial.printf("[X32Link] WiFi disconnect reason=%d\n", s_wifi_disc_reason); + if (g_wifi_pol.state == WCS_AP) return; + WifiConnAction a = wifi_conn_policy_step(&g_wifi_pol, WCE_DISCONNECTED, now_us_ms()); + if (a == WCA_CONNECT) { + delay(500); + esp_wifi_connect(); + } + } else if (event == ARDUINO_EVENT_WIFI_STA_GOT_IP) { + wifi_conn_policy_step(&g_wifi_pol, WCE_GOT_IP, now_us_ms()); + } +} + +// STA connect. Hidden SSIDs need WIFI_ALL_CHANNEL_SCAN on the wifi_config before +// connect; visible SSIDs work with WiFi.begin() after patching scan_method. +// ESP-035: mDNS name, same shape as the Touch's (-). +char g_ks_host[32] = "x32link"; + +static void wifi_sta_connect(const char* ssid, const char* pass) { + WiFi.persistent(false); // creds live in our NVS (x32link), not SDK wlan WiFi.mode(WIFI_STA); - WiFi.begin(g_config.wifi_ssid, g_config.wifi_pass); - unsigned long start = millis(); + WiFi.disconnect(true); + delay(100); + + wifi_config_t wc = {}; + strlcpy((char*)wc.sta.ssid, ssid, sizeof(wc.sta.ssid)); + if (pass) strlcpy((char*)wc.sta.password, pass, sizeof(wc.sta.password)); + wc.sta.scan_method = WIFI_ALL_CHANNEL_SCAN; + wc.sta.sort_method = WIFI_CONNECT_AP_BY_SIGNAL; + esp_wifi_set_config(WIFI_IF_STA, &wc); + WiFi.begin(ssid, pass); +} + +// Cold-start connect. Poll for WL_CONNECTED / timeout only — do NOT feed +// WCE_DISCONNECTED into the policy each poll (that spams esp_wifi_connect() and +// can make routers reject the STA). Mid-session drops are handled in on_wifi_event. +static bool wifi_try_connect() { + Serial.printf("[X32Link] connecting to '%s'\n", g_config.wifi_ssid); + if (g_config.wifi_pass[0] == '\0') + Serial.println("[X32Link] WARN: empty wifi_pass in NVS — re-enter PASS on save"); + + s_wifi_disc_reason = -1; + wifi_sta_connect(g_config.wifi_ssid, g_config.wifi_pass); + wifi_conn_policy_reset(&g_wifi_pol, now_us_ms(), 1); // S3 stores one network (ESP-013 is P4-only so far) while (WiFi.status() != WL_CONNECTED) { - if (millis() - start > 30000) { + // One slot, so its budget is the whole WIFI_CONN_TIMEOUT_US and slot_start_us + // is the cold-start instant -- same clock the old connect_start_us named. + if ((now_us_ms() - g_wifi_pol.slot_start_us) >= WIFI_CONN_TIMEOUT_US) { + g_wifi_pol.state = WCS_AP; // stop on_wifi_event re-connect attempts first + Serial.printf("[X32Link] WiFi timeout (last_reason=%d)\n", s_wifi_disc_reason); + WiFi.setAutoReconnect(false); // stop the core's own retry loop too WiFi.disconnect(true); return false; } delay(500); Serial.print("."); } + wifi_conn_policy_step(&g_wifi_pol, WCE_GOT_IP, now_us_ms()); WiFi.setSleep(false); // modem power-save drops buffered multicast — keep radio awake Serial.println(); Serial.printf("[X32Link] IP: %s\n", WiFi.localIP().toString().c_str()); - return true; -} -// Blocking reconnect used by loop() — no timeout (we have nowhere else to go). -static void wifi_connect() { - while (WiFi.status() != WL_CONNECTED) { - delay(500); - Serial.print("."); - } - // Already connected (called right after status check fails then recovers), - // or fall through from try_connect. Reinit if truly disconnected: - if (WiFi.status() != WL_CONNECTED) { - WiFi.begin(g_config.wifi_ssid, g_config.wifi_pass); - while (WiFi.status() != WL_CONNECTED) { delay(500); Serial.print("."); } + /* ESP-035: ANNOUNCE YOURSELF. + * + * This device had NO mDNS at all -- no .local name, no service record. It was the + * one member of the fleet that could not be FOUND: the companion app browses + * _http._tcp and this box was simply not there, so a unit that was powered, on the + * network and following the session looked exactly like no device at all. + * + * The Touch has done this since ESP-016 in one line. Same hostname shape + * (-), so one client rule covers the fleet (ARC-024). + * Passing no instance name makes the Bonjour instance default to the hostname, + * which is what the app's hostname fallback matches on. */ + uint8_t mac[6]; + WiFi.macAddress(mac); + snprintf(g_ks_host, sizeof(g_ks_host), "x32link-%02x%02x", mac[4], mac[5]); + if (MDNS.begin(g_ks_host)) { + MDNS.addService("http", "tcp", 80); + + /* ESP-031: advertise identity instead of letting a client guess it. + * + * The hostname is not an identity. The app matches `kitchensync-*`, so this box + * -- named x32link-XXXX -- is invisible to it even now that it answers mDNS, the + * same way a real Touch (kstouch-dfd0) is. And the mirror failure is worse: a + * stranger's box named kitchensync-anything gets adopted into the fleet at a venue. + * Match on `dev`, not the name. + * + * `target` comes from the build (CONFIG_IDF_TARGET), never a typed-in string, so a + * client can REFUSE a cross-target OTA rather than ask the user to promise the .bin + * is right -- which is all POST /update can do today. */ + // ESP-037: X32Link is its OWN product, not a KitchenSync. It publishes dev=x32link + // so the KitchenSync app (which matches dev=kitchensync) does not pick it up -- it + // has its own app. It shares the fleet PROTOCOL (Link, /status, /config.json), not + // the product identity. + MDNS.addServiceTxt("http", "tcp", "dev", "x32link"); + MDNS.addServiceTxt("http", "tcp", "model", "x32link"); + MDNS.addServiceTxt("http", "tcp", "target", CONFIG_IDF_TARGET); + MDNS.addServiceTxt("http", "tcp", "fw", FW_VERSION); + + Serial.printf("[X32Link] http://%s.local\n", g_ks_host); + } else { + Serial.println("[X32Link] WARN: mDNS failed — the app will not discover this unit"); } - Serial.println(); - Serial.printf("[X32Link] IP: %s\n", WiFi.localIP().toString().c_str()); + return true; } +// Non-blocking (LNK-023): configures AP + starts the captive-portal web +// server, then returns — does NOT loop. loop() already calls +// web_config_handle() every iteration, and that function is already +// AP/STA-agnostic (services the captive DNS only when s_captive, which +// web_config_ap_begin() sets), so no separate service loop is needed here. +// Sets s_ap_mode so loop()'s STA-reconnect block backs off. AP mode is +// terminal until the user reconfigures via the captive portal — handle_save() +// calls ESP.restart() — retrying STA without a reboot is explicitly out of +// scope (see ticket Notes). static void start_config_ap() { WiFi.mode(WIFI_AP); WiFi.softAP("X32Link-Config"); + g_wifi_pol.state = WCS_AP; // policy terminal (the failed try_connect already set it) Serial.printf("[X32Link] WiFi failed — AP started\n"); Serial.printf("[X32Link] join 'X32Link-Config' → http://%s\n", WiFi.softAPIP().toString().c_str()); web_config_ap_begin(); - for (;;) web_config_handle(); // save handler calls ESP.restart() } static void check_factory_reset() { @@ -138,7 +403,7 @@ static void check_factory_reset() { void setup() { Serial.begin(115200); delay(2000); - Serial.println("[X32Link] booting"); + Serial.println("[X32Link] booting fw:" FW_VERSION " built:" FW_BUILD); check_factory_reset(); config_load(&g_config); @@ -151,25 +416,56 @@ void setup() { tempo_source_select(g_config.input_source); tempo_source_pre_net(); // USB MIDI enumerates here (before WiFi); no-op for Link - if (!wifi_try_connect()) start_config_ap(); // never returns on AP path +#ifdef HAS_TOUCH_DISPLAY + touch_display_begin(); // splash up early, before the (up-to-30s) WiFi connect +#endif - tempo_source_begin(); // Link joins multicast here; no-op for MIDI - osc_sender_begin(); - g_bpm_mutex = xSemaphoreCreateMutex(); + // LNK-023: bpm_task/led_task created unconditionally, before WiFi is + // even attempted — tempo/LED processing (incl. USB MIDI clock, which + // needs no network at all) must not depend on WiFi succeeding. + // tempo_source_begin() (Link's multicast join) happens after, only on + // the STA-connected path — see the wifi_ok branch below. xTaskCreatePinnedToCore(bpm_task, "bpm", 4096, NULL, 1, NULL, 0); xTaskCreatePinnedToCore(led_task, "led", 2048, NULL, 1, NULL, 0); - web_config_begin(); +#ifdef HAS_BATTERY_GAUGE + xTaskCreatePinnedToCore(battery_task, "batt", 2048, NULL, 1, NULL, 0); +#endif - Serial.print("[X32Link] ready — config at http://"); - Serial.println(WiFi.localIP()); + WiFi.onEvent(on_wifi_event); + bool wifi_ok = wifi_try_connect(); + if (wifi_ok) { + tempo_source_begin(); // Link joins multicast here; no-op for MIDI + osc_sender_begin(); + web_config_begin(); + Serial.print("[X32Link] ready — config at http://"); + Serial.println(WiFi.localIP()); + } else { + start_config_ap(); // non-blocking — configures AP + web server, returns + } } void loop() { - if (WiFi.status() != WL_CONNECTED) { - Serial.println("[X32Sync] WiFi lost — reconnecting"); - wifi_connect(); +#ifdef HAS_TOUCH_DISPLAY + touch_display_tick(); // LNK-014: poll AXS5106L, echo coords to LCD + Serial + touch_display_update(); // LNK-015: refresh status screen live fields (BPM, phase wheel) +#endif + // Gated on !s_ap_mode (LNK-023): once parked in AP fallback, WiFi.status() + // is never WL_CONNECTED (we're in WIFI_AP mode, not WIFI_STA) — without + // this gate this block would call wifi_connect() (STA reconnect) every + // iteration and fight the AP radio config. AP mode is terminal until a + // reboot (handle_save()), so this intentionally never re-enters STA on + // its own — see start_config_ap()'s comment / ticket Notes. + // Mid-session reconnect is event-driven (on_wifi_event). After GOT_IP once, + // bring Link/OSC back when DHCP returns — don't block loop() on reconnect. + static bool s_need_link_restart = false; + if (WiFi.status() == WL_CONNECTED && s_need_link_restart) { + s_need_link_restart = false; tempo_source_begin(); osc_sender_begin(); + Serial.printf("[X32Link] WiFi back — IP: %s\n", WiFi.localIP().toString().c_str()); + } else if (g_wifi_pol.got_ip_once && g_wifi_pol.state != WCS_AP && + WiFi.status() != WL_CONNECTED) { + s_need_link_restart = true; } web_config_handle(); // service the web server every ~20 ms so /status polling is live @@ -177,12 +473,15 @@ void loop() { uint32_t now = millis(); if (now - last_log >= 5000) { last_log = now; - Serial.printf("[X32Link] ip:%s mcast:%d rx:%lu peers:%d bpm:%.2f heap:%lu\n", + TempoSnapshot ts; tempo_snapshot_read(&ts); + // fw as a %s arg, not pasted into the format — an injected FW_VERSION + // containing '%' must never become a conversion specifier. + Serial.printf("[X32Link] ip:%s mcast:%d rx:%lu peers:%d bpm:%.2f heap:%lu fw:%s\n", WiFi.localIP().toString().c_str(), link_listener_mcast_ok() ? 1 : 0, (unsigned long)link_listener_rx_count(), - link_listener_peers(), g_current_bpm, - (unsigned long)ESP.getFreeHeap()); + link_listener_peers(), ts.bpm, + (unsigned long)ESP.getFreeHeap(), FW_VERSION); } delay(20); } diff --git a/X32Link/app_config.c b/X32Link/app_config.c index 658b3d9..6e109fa 100644 --- a/X32Link/app_config.c +++ b/X32Link/app_config.c @@ -1,6 +1,7 @@ #include "app_config.h" #include "config.h" #include +#include void config_defaults(AppConfig* cfg) { strncpy(cfg->wifi_ssid, DEFAULT_WIFI_SSID, sizeof(cfg->wifi_ssid) - 1); @@ -14,6 +15,11 @@ void config_defaults(AppConfig* cfg) { cfg->input_source = DEFAULT_INPUT_SOURCE; cfg->fdr_enable = DEFAULT_FDR_ENABLE; cfg->fdr_chan_count = DEFAULT_FDR_CHAN_COUNT; + cfg->quantum_beats = DEFAULT_QUANTUM_BEATS; + cfg->midi_clock_out_enable = DEFAULT_MIDI_CLOCK_OUT_ENABLE; + cfg->phase_display_mode = DEFAULT_PHASE_DISPLAY_MODE; + cfg->dot_beat_color = DEFAULT_DOT_BEAT_COLOR; + cfg->dot_accent_color = DEFAULT_DOT_ACCENT_COLOR; } int config_model_port(int model) { @@ -24,6 +30,59 @@ int config_model_slot_max(int model) { return model == MODEL_X32 ? 8 : 4; } +void config_set_model(AppConfig* cfg, int model) { + if (model != MODEL_XR18 && model != MODEL_X32) return; + cfg->model = model; + int max = config_model_slot_max(model); + if (cfg->fx_slot > max) cfg->fx_slot = max; + if (cfg->fx_slot < 1) cfg->fx_slot = 1; +} + +// ARC-020: the single owner of "is this persisted blob safe to load?" — see +// app_config.h. Every gate is fail-closed: anything we cannot positively vouch +// for becomes defaults, because loading a stale layout puts garbage into fields +// the user never sees until the mixer gets OSC for the wrong FX slot. +cfg_decode_result config_decode(AppConfig* out, const void* blob, size_t blob_len, + bool version_present, uint32_t version) { + config_defaults(out); + + // Bytes with no version key predate versioning: they could be any old + // layout, so no size coincidence earns them a load. + if (!blob || !version_present) return CFG_DECODE_DEFAULTED; + + if (version != APP_CONFIG_VERSION || blob_len != sizeof(AppConfig)) + return CFG_DECODE_DEFAULTED; + + // Version and size both vouch for the layout; the bytes still have to be in + // range (bit-rot, or a layout shipped without a version bump). + AppConfig candidate; + memcpy(&candidate, blob, sizeof(candidate)); + if (!config_validate(&candidate)) return CFG_DECODE_DEFAULTED; + + *out = candidate; + return CFG_DECODE_OK; +} + +bool app_config_set(AppConfig* cfg, AppConfigField field, int value) { + AppConfig tmp = *cfg; // apply to a copy, keep iff it validates + switch (field) { + case ACF_INPUT_SOURCE: tmp.input_source = value; break; + case ACF_MODEL: config_set_model(&tmp, value); break; + case ACF_FX_SLOT: tmp.fx_slot = value; break; + case ACF_QUANTUM_BEATS: tmp.quantum_beats = value; break; + case ACF_MIDI_CLOCK_OUT: tmp.midi_clock_out_enable = value; break; + case ACF_FDR_ENABLE: tmp.fdr_enable = value; break; + case ACF_FDR_CHAN_COUNT: tmp.fdr_chan_count = value; break; + case ACF_PHASE_DISPLAY_MODE: tmp.phase_display_mode = value; break; + case ACF_DOT_BEAT_COLOR: tmp.dot_beat_color = value; break; + case ACF_DOT_ACCENT_COLOR: tmp.dot_accent_color = value; break; + default: return false; + } + if (!config_validate(&tmp)) return false; // config_validate is the one range owner + *cfg = tmp; + return true; +} + bool config_validate(const AppConfig* cfg) { if (cfg->mixer_ip[0] == '\0') return false; if (cfg->fx_slot < 1) return false; @@ -31,5 +90,91 @@ bool config_validate(const AppConfig* cfg) { if (cfg->input_source != 0 && cfg->input_source != 1) return false; if (cfg->fdr_enable != 0 && cfg->fdr_enable != 1) return false; if (cfg->fdr_chan_count != 16 && cfg->fdr_chan_count != 32) return false; + if (cfg->quantum_beats < 1 || cfg->quantum_beats > 16) return false; + if (cfg->midi_clock_out_enable != 0 && cfg->midi_clock_out_enable != 1) return false; + if (cfg->phase_display_mode != 0 && cfg->phase_display_mode != 1) return false; + if (cfg->dot_beat_color < 0 || cfg->dot_beat_color > 0xFFFFFF) return false; + if (cfg->dot_accent_color < 0 || cfg->dot_accent_color > 0xFFFFFF) return false; return true; } + +// ESP-040: an HTML posts "#rrggbb"; accept with or without the '#'. +// Rejects anything that isn't exactly six hex digits so a garbled colour never reaches +// the config as a partial parse. +static int parse_hex_color(const char* v, int* out) { + if (v[0] == '#') v++; + if (strlen(v) != 6) return 0; + char* end = NULL; + long x = strtol(v, &end, 16); + if (*end != '\0' || x < 0 || x > 0xFFFFFF) return 0; + *out = (int)x; + return 1; +} + +// ESP-040: bounded copy that treats an EMPTY value as "keep current" (returns true, no +// change) — a form cannot show a password, and X32Link stores exactly one credential, so +// a blank ssid/pass/ip must never wipe it. Rejects an over-long value rather than truncate. +static int keep_or_copy(char* dst, size_t dstsz, const char* v) { + if (v[0] == '\0') return 1; // keep current + if (strlen(v) >= dstsz) return 0; // too long — reject, don't truncate + strncpy(dst, v, dstsz - 1); + dst[dstsz - 1] = '\0'; + return 1; +} + +bool app_config_set_kv(AppConfig* cfg, const char* key, const char* val) { + // Strings first — they don't route through app_config_set (which is int-only). + if (strcmp(key, "wifi_ssid") == 0) return keep_or_copy(cfg->wifi_ssid, sizeof(cfg->wifi_ssid), val); + if (strcmp(key, "wifi_pass") == 0) return keep_or_copy(cfg->wifi_pass, sizeof(cfg->wifi_pass), val); + if (strcmp(key, "mixer_ip") == 0) return keep_or_copy(cfg->mixer_ip, sizeof(cfg->mixer_ip), val); + + // Colours: the SHARED key (led_beat/led_accent, what /config.json emits) and the + // device's own form key (dot_beat/dot_acc) are aliases for one field. + if (strcmp(key, "led_beat") == 0 || strcmp(key, "dot_beat") == 0) { + int c; if (!parse_hex_color(val, &c)) return false; + return app_config_set(cfg, ACF_DOT_BEAT_COLOR, c); + } + if (strcmp(key, "led_accent") == 0 || strcmp(key, "dot_acc") == 0) { + int c; if (!parse_hex_color(val, &c)) return false; + return app_config_set(cfg, ACF_DOT_ACCENT_COLOR, c); + } + + // Ints via app_config_set, so config_validate stays the one range owner. `clock_out` + // is the shared key /config.json publishes; `midi_clock_out` is the form's own name. + if (strcmp(key, "clock_out") == 0 || strcmp(key, "midi_clock_out") == 0) + return app_config_set(cfg, ACF_MIDI_CLOCK_OUT, atoi(val)); + // A checkbox posts value="1" when on; anything else (or absence) is off. + if (strcmp(key, "phase_flash") == 0) + return app_config_set(cfg, ACF_PHASE_DISPLAY_MODE, strcmp(val, "1") == 0 ? 1 : 0); + if (strcmp(key, "input_source") == 0) return app_config_set(cfg, ACF_INPUT_SOURCE, atoi(val)); + if (strcmp(key, "model") == 0) return app_config_set(cfg, ACF_MODEL, atoi(val)); + if (strcmp(key, "fx_slot") == 0) return app_config_set(cfg, ACF_FX_SLOT, atoi(val)); + if (strcmp(key, "quantum") == 0 || strcmp(key, "quantum_beats") == 0) + return app_config_set(cfg, ACF_QUANTUM_BEATS, atoi(val)); + if (strcmp(key, "fdr_enable") == 0) return app_config_set(cfg, ACF_FDR_ENABLE, atoi(val)); + if (strcmp(key, "fdr_chan_count") == 0) return app_config_set(cfg, ACF_FDR_CHAN_COUNT, atoi(val)); + + return false; // unknown key — a client that posts garbage changes nothing +} + +void x32_form_merge(AppConfig* out, const AppConfig* base, + const X32FormField* fields, int n, bool full_form) { + *out = *base; + if (full_form) { + // The device's own page posted the whole form, so an unchecked (absent) checkbox + // means "off". Pre-clear the two checkbox-backed booleans; a present key below + // flips its own back on. Value fields (input_source, colours, model, ...) are + // NEVER cleared by absence — only genuine checkboxes are, which is the whole point. + out->midi_clock_out_enable = 0; + out->phase_display_mode = 0; + } + // `fx_slot` validates against the model's slot max (config_validate), so `model` must + // land FIRST or a valid slot for the NEW model is rejected against the OLD one and + // silently dropped — independent of the order the fields arrived in the POST. Apply + // model up front; the main loop re-applies it harmlessly (idempotent). + for (int i = 0; i < n; i++) + if (strcmp(fields[i].key, "model") == 0) + app_config_set_kv(out, fields[i].key, fields[i].val); + for (int i = 0; i < n; i++) + app_config_set_kv(out, fields[i].key, fields[i].val); +} diff --git a/X32Link/app_config.h b/X32Link/app_config.h index b20c5c1..4a15bdf 100644 --- a/X32Link/app_config.h +++ b/X32Link/app_config.h @@ -1,5 +1,7 @@ #pragma once #include +#include +#include #ifdef __cplusplus extern "C" { @@ -8,6 +10,11 @@ extern "C" { #define MODEL_XR18 1 #define MODEL_X32 2 +// ARC-020: schema version for the persisted blob. Bump it whenever AppConfig's +// layout changes, or a stale blob gets read at the wrong offsets. The +// _Static_assert below turns "forgot to bump it" into a compile error. +#define APP_CONFIG_VERSION 1u + typedef struct { char wifi_ssid[64]; char wifi_pass[64]; @@ -17,13 +24,109 @@ typedef struct { int input_source; // 0 = Ableton Link, 1 = USB MIDI clock int fdr_enable; // X32FaderDisp: 0 = off, 1 = write dB to scribble names int fdr_chan_count;// X32FaderDisp: 16 (XR18) or 32 (X32) + int quantum_beats; // bar-quantized phase: beats per bar, 1-16 (LNK-019) + int midi_clock_out_enable; // LNK-027: 0/1 — emit 24-PPQN USB-MIDI clock while following Link + int phase_display_mode; // LNK-036: 0 = sweep wheel, 1 = beat-flash dot + int dot_beat_color; // LNK-036: 0xRRGGBB — beats 2..N (parity w/ P4 led_beat_color) + int dot_accent_color; // LNK-036: 0xRRGGBB — bar-1 downbeat (parity w/ P4 led_accent_color) } AppConfig; +// ARC-020: the size is not the safety mechanism — APP_CONFIG_VERSION is. This +// assert exists to make a layout change impossible to ship silently. +// When it fires: bump APP_CONFIG_VERSION, then update the size here. +// +// This header is compiled as C (host tests, pure modules) AND as C++ (the Arduino +// sketch + the web/touch glue), so the assert has to spell itself both ways: C11 +// says _Static_assert, C++ says static_assert, and g++ rejects the C spelling. +#ifdef __cplusplus +# define APP_CONFIG_STATIC_ASSERT(cond, msg) static_assert(cond, msg) +#else +# define APP_CONFIG_STATIC_ASSERT(cond, msg) _Static_assert(cond, msg) +#endif +APP_CONFIG_STATIC_ASSERT(sizeof(AppConfig) == 184, + "AppConfig layout changed: bump APP_CONFIG_VERSION, then fix this size (ARC-020)"); + void config_defaults(AppConfig* cfg); int config_model_port(int model); int config_model_slot_max(int model); bool config_validate(const AppConfig* cfg); +// ARC-012: one int-field setter for both editors (web + touch), so per-field ranges +// live only in config_validate. Tentatively applies `value` to `field`, keeps it iff +// the result validates (returns true); otherwise leaves cfg unchanged (returns false). +// model/fx_slot route through config_set_model so the model->slot clamp stays shared. +typedef enum { + ACF_INPUT_SOURCE = 1, ACF_MODEL, ACF_FX_SLOT, ACF_QUANTUM_BEATS, + ACF_MIDI_CLOCK_OUT, ACF_FDR_ENABLE, ACF_FDR_CHAN_COUNT, + ACF_PHASE_DISPLAY_MODE, ACF_DOT_BEAT_COLOR, ACF_DOT_ACCENT_COLOR, +} AppConfigField; + +bool app_config_set(AppConfig* cfg, AppConfigField field, int value); + +// ESP-040: string-keyed setter in the SHARED web vocabulary — the same keys the P4's +// ks_config_set parses and X32Link's own /config.json emits. This is what makes /save +// round-trip with /config.json: a client reads `clock_out`/`led_beat` and can POST them +// back under the same names. Accepts the shared keys AND X32Link's own form keys as +// aliases (clock_out|midi_clock_out, led_beat|dot_beat, led_accent|dot_acc, quantum| +// quantum_beats), plus the X32-only fields (model, mixer_ip, fx_slot, input_source, +// phase_flash). Int fields route through app_config_set (so config_validate stays the one +// range owner); strings are bounded-copied and a BLANK ssid/pass/ip is a no-op "keep +// current" (a form can't show a password). Returns true iff the key was recognised and +// the value applied. +bool app_config_set_kv(AppConfig* cfg, const char* key, const char* val); + +// ESP-040: one posted form field, already URL-decoded (Arduino WebServer hands these out +// via server.argName/arg). x32_form_merge consumes an array of them. +typedef struct { const char* key; const char* val; } X32FormField; + +// ESP-040: merge posted fields onto `base` into `out`, mirroring the P4's ks_form so the +// three firmwares share ONE partial-vs-full-form discipline instead of X32Link hand-rolling +// its own (which silently zeroed input_source / midi_clock_out / phase on a partial POST). +// +// full_form == false → PATCH: only keys present in `fields` change; everything else keeps +// its base value. This is what a partial client POST (or /live) needs. +// full_form == true → the device's OWN page posted the whole form, so an ABSENT +// checkbox-backed boolean (midi_clock_out_enable, phase_display_mode) +// means "off" and is pre-cleared before the present keys reapply. +// Value fields (input_source, colours, ...) are NEVER zeroed by +// absence — only genuine checkboxes are. +// +// `out` is not validated here; the caller runs config_validate() exactly as before. +void x32_form_merge(AppConfig* out, const AppConfig* base, + const X32FormField* fields, int n, bool full_form); + +// LNK-032: the model→fx_slot dependency, shared by the web and touch config +// editors so the "slot must be ≤ the model's max" rule lives in one host-tested +// place. Sets model (ignored if not a known model) and clamps fx_slot into +// [1, config_model_slot_max(model)]. +void config_set_model(AppConfig* cfg, int model); + +// ARC-020: did we load the persisted config, or fall back to defaults? +typedef enum { + CFG_DECODE_OK, // blob accepted verbatim + CFG_DECODE_DEFAULTED, // blob absent/unversioned/wrong-version/wrong-size/invalid +} cfg_decode_result; + +// The one owner of "is this persisted blob safe to load?". *out always ends up a +// valid config: either the blob (when it clears every gate) or clean defaults. +// +// Accepted verbatim ONLY when all of these hold: +// version_present — the NVS "ver" key existed +// version == APP_CONFIG_VERSION +// blob_len == sizeof(AppConfig) +// config_validate(blob) +// +// Otherwise *out is defaults. This is what stops a struct-layout change from +// loading bytes shifted into the wrong fields. Pure — app_config_nvs.cpp hands +// over whatever NVS held and does no judging of its own. Host-tested. +// +// NOTE: the pre-ARC-020 format was field-keyed Preferences, not a blob, so it +// cannot be decoded here (it never reaches this function as bytes). That +// one-time migration lives in app_config_nvs.cpp, the only place that can read +// the old keys. +cfg_decode_result config_decode(AppConfig* out, const void* blob, size_t blob_len, + bool version_present, uint32_t version); + // NVS-backed persistence (implemented in app_config_nvs.cpp) void config_load(AppConfig* cfg); void config_save(const AppConfig* cfg); diff --git a/X32Link/app_config_nvs.cpp b/X32Link/app_config_nvs.cpp index 7a5971b..0a03948 100644 --- a/X32Link/app_config_nvs.cpp +++ b/X32Link/app_config_nvs.cpp @@ -1,37 +1,113 @@ +// NVS persistence for the X32Link config. Namespace "x32link" -- deliberately NOT +// "kstouch": a board reflashed the other way must not read a trimmed MIDI struct +// into this one. +// +// ARC-020: the config is ONE versioned blob, not a field-by-field key list. This +// file does no judging of its own -- it hands whatever NVS held to the pure +// config_decode() (app_config.c, host-tested) and stores whatever it is given. +// Adding a config field now touches struct/defaults/validate/set and ZERO lines +// here, which is the entire point: the old code listed every field twice, and a +// missed put/get pair failed to persist silently. #include "app_config.h" #include +#include static Preferences prefs; +#define NS "x32link" +#define KEY_CFG "cfg" +#define KEY_VER "ver" + +// The pre-ARC-020 field-keyed layout. Read ONCE, on the first boot after the +// upgrade, then written back as a blob so this never runs again. +// +// This cannot live in the pure config_decode(): the old format is not a blob, it +// is a set of Preferences keys, so only this file can see it. Deployed units OTA +// across this change -- stranding a user's mixer IP and WiFi creds would be a +// silent reset that looks exactly like a broken update. +static bool migrate_legacy(AppConfig* cfg) { + if (!prefs.isKey("model") && !prefs.isKey("mixer_ip") && + !prefs.isKey("wifi_ssid") && !prefs.isKey("fx_slot")) { + return false; // fresh flash, nothing to migrate + } + + AppConfig legacy; + config_defaults(&legacy); // any key the old build never wrote keeps its default + legacy.model = prefs.getInt("model", legacy.model); + legacy.fx_slot = prefs.getInt("fx_slot", legacy.fx_slot); + legacy.input_source = prefs.getInt("input_src", legacy.input_source); + legacy.fdr_enable = prefs.getInt("fdr_en", legacy.fdr_enable); + legacy.fdr_chan_count = prefs.getInt("fdr_ch", legacy.fdr_chan_count); + legacy.quantum_beats = prefs.getInt("quantum_beats", legacy.quantum_beats); + legacy.midi_clock_out_enable = prefs.getInt("mck_out", legacy.midi_clock_out_enable); + legacy.phase_display_mode = prefs.getInt("ph_mode", legacy.phase_display_mode); + legacy.dot_beat_color = prefs.getInt("dot_beat", legacy.dot_beat_color); + legacy.dot_accent_color = prefs.getInt("dot_acc", legacy.dot_accent_color); + prefs.getString("mixer_ip", legacy.mixer_ip, sizeof(legacy.mixer_ip)); + prefs.getString("wifi_ssid", legacy.wifi_ssid, sizeof(legacy.wifi_ssid)); + prefs.getString("wifi_pass", legacy.wifi_pass, sizeof(legacy.wifi_pass)); + + // A migration is not a licence to skip the guard: garbage in the old keys is + // still garbage. Same gate the blob path runs through. + if (!config_validate(&legacy)) return false; + + *cfg = legacy; + return true; +} + extern "C" void config_load(AppConfig* cfg) { - config_defaults(cfg); - prefs.begin("x32link", true); // read-only - cfg->model = prefs.getInt("model", cfg->model); - cfg->fx_slot = prefs.getInt("fx_slot", cfg->fx_slot); - cfg->input_source = prefs.getInt("input_src", cfg->input_source); - cfg->fdr_enable = prefs.getInt("fdr_en", cfg->fdr_enable); - cfg->fdr_chan_count = prefs.getInt("fdr_ch", cfg->fdr_chan_count); - prefs.getString("mixer_ip", cfg->mixer_ip, sizeof(cfg->mixer_ip)); - prefs.getString("wifi_ssid", cfg->wifi_ssid, sizeof(cfg->wifi_ssid)); - prefs.getString("wifi_pass", cfg->wifi_pass, sizeof(cfg->wifi_pass)); + uint8_t buf[sizeof(AppConfig)]; + size_t sz = 0; + uint32_t ver = 0; + bool have_ver = false; + bool have_blob = false; + + memset(buf, 0, sizeof(buf)); + + prefs.begin(NS, true); // read-only + have_ver = prefs.isKey(KEY_VER); + if (have_ver) ver = prefs.getUInt(KEY_VER, 0); + + // Read the stored length FIRST. getBytes into a smaller buffer would silently + // truncate; handing config_decode() the real length instead lets it reject a + // wrong-size blob rather than load a half-copy. + size_t stored = prefs.getBytesLength(KEY_CFG); + if (stored > 0) { + have_blob = true; + sz = stored; + if (stored == sizeof(buf)) prefs.getBytes(KEY_CFG, buf, sizeof(buf)); + // else: leave buf zeroed -- decode rejects on size before reading it. + } prefs.end(); + + if (config_decode(cfg, have_blob ? (const void*)buf : NULL, sz, have_ver, ver) + == CFG_DECODE_OK) { + return; // the common path: blob accepted + } + + // Defaults are already in *cfg (config_decode guarantees it). If there is no + // blob but the old field-keys are there, this is the first boot after the + // upgrade -- migrate them once and write the blob back, so the next boot takes + // the fast path above and the legacy keys are never read again. + if (!have_blob) { + prefs.begin(NS, true); + bool migrated = migrate_legacy(cfg); + prefs.end(); + if (migrated) config_save(cfg); + } } extern "C" void config_save(const AppConfig* cfg) { - prefs.begin("x32link", false); // read-write - prefs.putInt("model", cfg->model); - prefs.putInt("fx_slot", cfg->fx_slot); - prefs.putInt("input_src", cfg->input_source); - prefs.putInt("fdr_en", cfg->fdr_enable); - prefs.putInt("fdr_ch", cfg->fdr_chan_count); - prefs.putString("mixer_ip", cfg->mixer_ip); - prefs.putString("wifi_ssid", cfg->wifi_ssid); - prefs.putString("wifi_pass", cfg->wifi_pass); + prefs.begin(NS, false); // read-write + // Blob and version are written together, so a power cut cannot leave a version + // stamp vouching for a blob that was never written (or vice versa). + prefs.putBytes(KEY_CFG, cfg, sizeof(*cfg)); + prefs.putUInt(KEY_VER, APP_CONFIG_VERSION); prefs.end(); } extern "C" void config_clear(void) { - prefs.begin("x32link", false); - prefs.clear(); + prefs.begin(NS, false); + prefs.clear(); // drops the blob, the version, and any legacy keys prefs.end(); } diff --git a/X32Link/axs5106l.c b/X32Link/axs5106l.c new file mode 100644 index 0000000..40aa3b8 --- /dev/null +++ b/X32Link/axs5106l.c @@ -0,0 +1,18 @@ +#include "axs5106l.h" + +int axs5106l_parse(const uint8_t *buf, size_t len, axs_touch_t *out) { + if (!buf || !out || len < AXS5106L_REPORT_LEN) return -1; + + uint8_t count = buf[1]; + out->count = count; + + uint8_t n = count < AXS5106L_MAX_POINTS ? count : AXS5106L_MAX_POINTS; + out->points_len = n; + + for (uint8_t i = 0; i < n; i++) { + size_t b = 2 + (size_t)i * 6; // [status, x_hi, x_lo, y_hi, y_lo, pressure] + out->points[i].x = (uint16_t)(((buf[b] & 0x0F) << 8) | buf[b + 1]); + out->points[i].y = (uint16_t)(((buf[b + 2] & 0x0F) << 8) | buf[b + 3]); + } + return 0; +} diff --git a/X32Link/axs5106l.h b/X32Link/axs5106l.h new file mode 100644 index 0000000..34a16ec --- /dev/null +++ b/X32Link/axs5106l.h @@ -0,0 +1,42 @@ +#ifndef AXS5106L_H +#define AXS5106L_H + +// Pure parse for the AXS5106L capacitive touch controller (Waveshare +// ESP32-S3-Touch-LCD-1.47, I2C addr 0x63). Host-testable: no I2C/Wire here — +// the caller reads AXS5106L_REPORT_LEN bytes from register 0x01 and hands the +// buffer to axs5106l_parse(). Report layout (from the ChipSourceTek part / +// reference drivers): data[1] = finger count; each point is 6 bytes at +// 2 + i*6 as [status, x_hi, x_lo, y_hi, y_lo, pressure], X/Y are 12-bit with +// the coordinate high nibble in the low 4 bits of x_hi/y_hi (status flags in +// the high nibble, masked off). + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +#define AXS5106L_I2C_ADDR 0x63 +#define AXS5106L_TOUCH_REG 0x01 +#define AXS5106L_ID_REG 0x08 +#define AXS5106L_REPORT_LEN 14 +#define AXS5106L_MAX_POINTS 2 + +typedef struct { uint16_t x, y; } axs_point_t; + +typedef struct { + uint8_t count; // fingers the controller reports (data[1]) + uint8_t points_len; // points actually decoded (<= AXS5106L_MAX_POINTS) + axs_point_t points[AXS5106L_MAX_POINTS]; +} axs_touch_t; + +// Parse a 14-byte AXS5106L touch report (register 0x01 read) into *out. +// Returns 0 on success; -1 if buf==NULL, out==NULL, or len < AXS5106L_REPORT_LEN. +int axs5106l_parse(const uint8_t *buf, size_t len, axs_touch_t *out); + +#ifdef __cplusplus +} +#endif + +#endif // AXS5106L_H diff --git a/X32Link/bar.c b/X32Link/bar.c new file mode 100644 index 0000000..7f7346a --- /dev/null +++ b/X32Link/bar.c @@ -0,0 +1,11 @@ +#include "bar.h" + +int bar_beats(int quantum, int bars) { + if (quantum < 1 || bars < 1) return 0; + return quantum * bars; +} + +double bar_ms(float bpm, int quantum, int bars) { + if (bpm <= 0.0f || quantum < 1 || bars < 1) return 0.0; + return (double)bars * (double)quantum * 60000.0 / (double)bpm; +} diff --git a/X32Link/bar.h b/X32Link/bar.h new file mode 100644 index 0000000..3e664f7 --- /dev/null +++ b/X32Link/bar.h @@ -0,0 +1,23 @@ +#pragma once +#include + +#ifdef __cplusplus +extern "C" { +#endif + +// A bar is `quantum` beats — ARC-003. One place owns that assumption so non-4/4 +// quanta (g_config.quantum_beats is 1..16) get the right refresh/resend cadence, +// instead of the hardcoded `4 *` that used to be scattered across the call +// sites. (MIDI is 24 PPQN, so a bar is 24*quantum pulses — but the +// two call sites here work in beats and milliseconds, not pulses.) + +// Total beats in `bars` bars. Returns 0 for quantum < 1 or bars < 1. +int bar_beats(int quantum, int bars); + +// Milliseconds for `bars` bars at `bpm`. Returns 0.0 for bpm <= 0, quantum < 1, +// or bars < 1. +double bar_ms(float bpm, int quantum, int bars); + +#ifdef __cplusplus +} +#endif diff --git a/X32Link/battery_gauge.c b/X32Link/battery_gauge.c new file mode 100644 index 0000000..8a4065f --- /dev/null +++ b/X32Link/battery_gauge.c @@ -0,0 +1,19 @@ +// See battery_gauge.h. +#include "battery_gauge.h" + +float battery_gauge_volts(uint8_t hi, uint8_t lo) { + uint16_t raw = (uint16_t)((hi << 8) | lo); + return (float)raw * 78.125e-6f; +} + +float battery_gauge_percent(uint8_t hi, uint8_t lo) { + return (float)hi + (float)lo / 256.0f; +} + +bool battery_gauge_parse(uint8_t vcell_hi, uint8_t vcell_lo, uint8_t soc_hi, uint8_t soc_lo, + battery_reading_t *out) { + if (!out) return false; + out->volts = battery_gauge_volts(vcell_hi, vcell_lo); + out->percent = battery_gauge_percent(soc_hi, soc_lo); + return true; +} diff --git a/X32Link/battery_gauge.h b/X32Link/battery_gauge.h new file mode 100644 index 0000000..60dd934 --- /dev/null +++ b/X32Link/battery_gauge.h @@ -0,0 +1,39 @@ +// Pure parse for the MAX17048 LiPo fuel gauge (Adafruit "LiPo BFF", STEMMA QT, +// I2C addr 0x36 — the board stacked under the QT Py ESP32-S3's battery +// connector). Host-testable: no I2C/Wire here — the caller reads the two +// 16-bit big-endian registers below and hands the raw bytes to +// battery_gauge_parse(). Register layout is the MAX17048/49 datasheet's +// ModelGauge output pair, not anything board-specific: +// VCELL (reg 0x02): 16-bit, 78.125uV/LSB cell voltage. +// SOC (reg 0x04): 16-bit, MSB = whole percent, LSB = percent/256. +#pragma once +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +#define BATTERY_GAUGE_I2C_ADDR 0x36 +#define BATTERY_GAUGE_VCELL_REG 0x02 +#define BATTERY_GAUGE_SOC_REG 0x04 + +typedef struct { + float volts; // cell voltage, e.g. 3.85f + float percent; // 0..100 (MAX17048 can report slightly over 100) +} battery_reading_t; + +// Decode one big-endian VCELL register pair into volts. +float battery_gauge_volts(uint8_t hi, uint8_t lo); + +// Decode one big-endian SOC register pair into a 0..100 percent estimate. +float battery_gauge_percent(uint8_t hi, uint8_t lo); + +// Convenience: decode both registers into *out. Returns false (leaves *out +// untouched) if out == NULL. +bool battery_gauge_parse(uint8_t vcell_hi, uint8_t vcell_lo, uint8_t soc_hi, uint8_t soc_lo, + battery_reading_t *out); + +#ifdef __cplusplus +} +#endif diff --git a/X32Link/battery_gauge_io.cpp b/X32Link/battery_gauge_io.cpp new file mode 100644 index 0000000..7cec71a --- /dev/null +++ b/X32Link/battery_gauge_io.cpp @@ -0,0 +1,33 @@ +// See battery_gauge_io.h. +#include "battery_gauge_io.h" +#include + +// QT Py ESP32-S3's STEMMA QT connector — the genuine adafruit_qtpy_esp32s3 +// board variant's default Wire pins (pins_arduino.h: SDA=7, SCL=6). Explicit +// here rather than a bare Wire.begin() so this stays correct even compiled +// against a generic esp32s3 profile (same reasoning as touch_display.cpp's +// TP_SDA/TP_SCL). +#define BATTERY_SDA_PIN 7 +#define BATTERY_SCL_PIN 6 + +void battery_gauge_io_begin(void) { + Wire.begin(BATTERY_SDA_PIN, BATTERY_SCL_PIN); +} + +static bool read_reg16(uint8_t reg, uint8_t *hi, uint8_t *lo) { + Wire.beginTransmission(BATTERY_GAUGE_I2C_ADDR); + Wire.write(reg); + if (Wire.endTransmission(false) != 0) return false; // repeated START, no STOP + + if (Wire.requestFrom((int)BATTERY_GAUGE_I2C_ADDR, 2) != 2) return false; + *hi = Wire.read(); + *lo = Wire.read(); + return true; +} + +bool battery_gauge_io_read(battery_reading_t *out) { + uint8_t vcell_hi, vcell_lo, soc_hi, soc_lo; + if (!read_reg16(BATTERY_GAUGE_VCELL_REG, &vcell_hi, &vcell_lo)) return false; + if (!read_reg16(BATTERY_GAUGE_SOC_REG, &soc_hi, &soc_lo)) return false; + return battery_gauge_parse(vcell_hi, vcell_lo, soc_hi, soc_lo, out); +} diff --git a/X32Link/battery_gauge_io.h b/X32Link/battery_gauge_io.h new file mode 100644 index 0000000..8df068f --- /dev/null +++ b/X32Link/battery_gauge_io.h @@ -0,0 +1,20 @@ +// Thin I2C glue for the MAX17048 fuel gauge on the QT Py's STEMMA QT bus — +// see battery_gauge.h for the pure register decode. HAS_BATTERY_GAUGE-gated +// (opt-in per unit: not every QT Py has the LiPo BFF attached). +#pragma once +#include "battery_gauge.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// Wire.begin() on the QT Py ESP32-S3's STEMMA QT pins (SDA=7, SCL=6). +void battery_gauge_io_begin(void); + +// Reads VCELL + SOC over I2C and parses them into *out. Returns false (leaves +// *out untouched) on any I2C error, e.g. the LiPo BFF isn't attached. +bool battery_gauge_io_read(battery_reading_t *out); + +#ifdef __cplusplus +} +#endif diff --git a/X32Link/battery_snapshot.c b/X32Link/battery_snapshot.c new file mode 100644 index 0000000..96c3178 --- /dev/null +++ b/X32Link/battery_snapshot.c @@ -0,0 +1,28 @@ +#include "battery_snapshot.h" + +#if defined(ARDUINO) || defined(ESP_PLATFORM) + #include "freertos/FreeRTOS.h" + static portMUX_TYPE s_mux = portMUX_INITIALIZER_UNLOCKED; + #define BS_ENTER() portENTER_CRITICAL(&s_mux) + #define BS_EXIT() portEXIT_CRITICAL(&s_mux) +#else + #define BS_ENTER() ((void)0) + #define BS_EXIT() ((void)0) +#endif + +static BatterySnapshot s_snap = { 0.0f, 0.0f, false }; + +void battery_snapshot_publish(float volts, float percent, bool present) { + BS_ENTER(); + s_snap.volts = volts; + s_snap.percent = percent; + s_snap.present = present; + BS_EXIT(); +} + +void battery_snapshot_read(BatterySnapshot* out) { + if (!out) return; + BS_ENTER(); + *out = s_snap; + BS_EXIT(); +} diff --git a/X32Link/battery_snapshot.h b/X32Link/battery_snapshot.h new file mode 100644 index 0000000..80e63b4 --- /dev/null +++ b/X32Link/battery_snapshot.h @@ -0,0 +1,21 @@ +#pragma once +#include + +#ifdef __cplusplus +extern "C" { +#endif + +// One coherent battery reading (same ARC-001 shape as tempo_snapshot.h). +// Published by the battery task, read by the web /status endpoint. +typedef struct { + float volts; + float percent; + bool present; // false = no LiPo BFF found on this poll (or never polled) +} BatterySnapshot; + +void battery_snapshot_publish(float volts, float percent, bool present); +void battery_snapshot_read(BatterySnapshot* out); + +#ifdef __cplusplus +} +#endif diff --git a/X32Link/beat_clock.c b/X32Link/beat_clock.c new file mode 100644 index 0000000..cedb697 --- /dev/null +++ b/X32Link/beat_clock.c @@ -0,0 +1,21 @@ +#include "beat_clock.h" + +void beat_clock_reset(BeatClock* s) { + s->beats = 0.0; + s->last_us = 0; + s->primed = false; +} + +double beat_clock_advance(BeatClock* s, int64_t now_us, int64_t micros_per_beat) { + if (micros_per_beat <= 0) return s->beats; // no valid tempo yet — hold + if (!s->primed) { // anchor on the first advance + s->primed = true; + s->last_us = now_us; + return s->beats; + } + if (now_us > s->last_us) { // integrate forward at the current rate + s->beats += (double)(now_us - s->last_us) / (double)micros_per_beat; + s->last_us = now_us; + } + return s->beats; +} diff --git a/X32Link/beat_clock.h b/X32Link/beat_clock.h new file mode 100644 index 0000000..bfe5206 --- /dev/null +++ b/X32Link/beat_clock.h @@ -0,0 +1,34 @@ +#pragma once +// Pure free-running beat-position accumulator (P4-005). Integrates a possibly- +// changing tempo (micros_per_beat, from the Link timeline) over a monotonic +// microsecond clock into the continuous beat position clock_ticker consumes. +// +// Unlike link_phase_beats_now(), this is anchored to the LOCAL clock: it starts +// at beat 0 on the first advance, so it needs no session/ghost time alignment +// and never goes negative. It therefore conveys the correct *tempo* (clock rate) +// but not the session *phase* (downbeat) — right for a free-running 24-PPQN MIDI +// clock stream; phase-locked Start/downbeat would need the ghost-xform pipeline. +// Tempo changes are integrated at the current rate, so the position is smooth +// (no jump). Host-tested in test/test_beat_clock.c. No Arduino/ESP-IDF dep. +#include +#include +#ifdef __cplusplus +extern "C" { +#endif + +typedef struct { + double beats; // accumulated beat position + int64_t last_us; // monotonic time of the last advance + bool primed; // false until the first advance anchors the clock +} BeatClock; + +void beat_clock_reset(BeatClock* s); + +// Advance to now_us at the current micros_per_beat and return the beat position. +// First call after a reset primes (returns 0, no jump). A non-forward now_us or +// micros_per_beat <= 0 returns the current position without advancing. +double beat_clock_advance(BeatClock* s, int64_t now_us, int64_t micros_per_beat); + +#ifdef __cplusplus +} +#endif diff --git a/X32Link/beat_source.c b/X32Link/beat_source.c new file mode 100644 index 0000000..b030b05 --- /dev/null +++ b/X32Link/beat_source.c @@ -0,0 +1,60 @@ +#include "beat_source.h" + +void beat_source_reset(BeatSource* s) { + beat_clock_reset(&s->bc); + s->was_locked = false; + s->running = false; + s->last_beats = 0.0; + s->have_last = false; +} + +BeatSourceOut beat_source_step(BeatSource* s, bool have_session, + LinkGhostXForm xform, LinkTimeline tl, int64_t now_us) { + BeatSourceOut o = { .active = false, .beats = 0.0, .locked = false, .reprime = false }; + + if (!have_session) { + // Session gone: re-prime once on the falling edge (the caller resets its + // grids + transport there) and restart the free accumulator so the next + // acquire is clean. Stay quiet on later idle steps. + if (s->running) { + beat_clock_reset(&s->bc); + s->was_locked = false; + s->running = false; + s->have_last = false; + o.reprime = true; + } + return o; + } + + // Basis: a committed GhostXForm reads the true session phase; otherwise the + // free-running local accumulator. A change of basis re-primes the grids + // (mirrors the old `locked != phase_locked` edge; false in idle/free too, so + // a first acquire straight into lock re-primes but into free does not). + bool locked = xform.valid; + bool reprime = (locked != s->was_locked); + + double beats; + if (locked) { + int64_t ghost_now = link_ghost_xform_host_to_ghost(xform, now_us); + beats = link_phase_beats_now(tl, ghost_now); + } else { + if (reprime) beat_clock_reset(&s->bc); // entering free from lock: restart at 0 + beats = beat_clock_advance(&s->bc, now_us, tl.micros_per_beat); + } + + // ESP-027: a backwards step means the session re-originated. The basis did not switch + // (locked is unchanged), so the rule above sees nothing -- and clock_ticker then waits + // for beats to climb back to its primed grid, emitting silence for as long as that takes. + if (s->have_last && beats < s->last_beats - BEAT_SOURCE_BACKSTEP_BEATS) reprime = true; + s->last_beats = beats; + s->have_last = true; + + s->was_locked = locked; + s->running = true; + + o.active = true; + o.beats = beats; + o.locked = locked; + o.reprime = reprime; + return o; +} diff --git a/X32Link/beat_source.h b/X32Link/beat_source.h new file mode 100644 index 0000000..a928a58 --- /dev/null +++ b/X32Link/beat_source.h @@ -0,0 +1,61 @@ +#pragma once +// Pure beat-source selector (ARC-007). Owns the policy that used to sit inline in +// KitchenSync's clock_out_task: given the current Link session state, decide whether the +// beat position comes from the phase-locked session (a committed GhostXForm -> +// link_phase_beats_now, true downbeat) or the free-running local accumulator +// (beat_clock, correct rate / arbitrary phase), and tell the caller when a basis +// switch or session loss means it must re-prime its tick/click grids so the +// boundary realigns instead of dumping a catch-up burst. +// +// The free-run accumulator is owned here, so this is the single place that knows +// how the beat is sourced. No I/O — the caller passes in the live session state +// (have_session, current xform, timeline, now) each step. Host-tested in +// test/test_beat_source.c. No Arduino/ESP-IDF dependency. +#include +#include +#include "beat_clock.h" +#include "link_phase.h" // LinkTimeline, link_phase_beats_now +#include "link_measurement.h" // LinkGhostXForm, link_ghost_xform_host_to_ghost + +#ifdef __cplusplus +extern "C" { +#endif + +// ESP-027: a BACKWARDS beat step is not physics -- time does not run backwards. It means +// the session re-originated: a fresh GhostXForm commit maps "now" to a different beat. +// +// This matters because clock_ticker primes its grid on the beat position. If beats then +// jump backwards it waits for them to climb back, emitting NOTHING the whole time. Measured +// on the analyzer: kill a Link peer and let it rejoin, and the beat position steps back 276 +// beats with `locked` unchanged -- so no basis switch, so no re-prime -- and the DIN clock +// went silent for over TWO MINUTES while /status cheerfully reported sync:1 peers:1. +// +// 0.25 beat = 6 pulses at 24 PPQN. Smaller backsteps (an ordinary xform refinement, sub-ms) +// cost a few pulses of quantisation and are absorbed; anything past this is a re-origin. +#define BEAT_SOURCE_BACKSTEP_BEATS 0.25 + +typedef struct { + BeatClock bc; // free-run accumulator (owned) + bool was_locked; // did the last active step read session phase? + bool running; // did the last step have a session? + double last_beats; // ESP-027: previous step's beat position + bool have_last; // is last_beats meaningful? +} BeatSource; + +typedef struct { + bool active; // a session is present (a beat exists this step) + double beats; // current beat position (valid when active) + bool locked; // true = session phase, false = free-run (valid when active) + bool reprime; // caller must reset its tick/click grids this step +} BeatSourceOut; + +void beat_source_reset(BeatSource* s); + +// Advance one step. `xform.valid` selects the basis; `now_us` is the monotonic +// local clock. Returns the beat position + whether the caller must re-prime. +BeatSourceOut beat_source_step(BeatSource* s, bool have_session, + LinkGhostXForm xform, LinkTimeline tl, int64_t now_us); + +#ifdef __cplusplus +} +#endif diff --git a/X32Link/beat_synth.c b/X32Link/beat_synth.c new file mode 100644 index 0000000..7407af4 --- /dev/null +++ b/X32Link/beat_synth.c @@ -0,0 +1,16 @@ +// Pure free-running beat generator — see beat_synth.h. +#include "beat_synth.h" + +void beat_synth_reset(BeatSynth* s) { + s->last_ms = 0; +} + +bool beat_synth_step(BeatSynth* s, uint32_t now_ms, float bpm) { + if (bpm <= 0.0f) return false; + uint32_t interval = (uint32_t)(60000.0f / bpm); + if ((uint32_t)(now_ms - s->last_ms) >= interval) { + s->last_ms = now_ms; + return true; + } + return false; +} diff --git a/X32Link/beat_synth.h b/X32Link/beat_synth.h new file mode 100644 index 0000000..78244fd --- /dev/null +++ b/X32Link/beat_synth.h @@ -0,0 +1,31 @@ +#pragma once +// Pure free-running beat generator (LNK-033). Given a wall-clock millisecond +// reading and a BPM, fire true once per 60000/bpm interval — the "synthesised +// beat" the LED/UI fall back to before a real Link phase measurement is valid. +// Previously inlined in tempo_source_beat() against millis(); factored out so the +// interval + edge-detect + self-clear is host-tested (ADR-0003). No Arduino +// dependency. See test/test_beat_synth.c. +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +typedef struct { + uint32_t last_ms; // wall-clock ms of the last emitted beat +} BeatSynth; + +// Clear state (last_ms = 0). Behaviour then matches a fresh static counter: +// the first step() with now_ms past one interval fires immediately. +void beat_synth_reset(BeatSynth* s); + +// Advance to now_ms and return true if a beat is due at `bpm`. Fires when +// (now_ms - last_ms) has reached the 60000/bpm interval (unsigned/wrap-safe), +// then latches last_ms = now_ms. bpm <= 0 always returns false and leaves state +// untouched (so a resumed BPM fires promptly, matching the old inline behaviour). +bool beat_synth_step(BeatSynth* s, uint32_t now_ms, float bpm); + +#ifdef __cplusplus +} +#endif diff --git a/X32Link/bpm_publisher.c b/X32Link/bpm_publisher.c index 086f6ec..8b85b95 100644 --- a/X32Link/bpm_publisher.c +++ b/X32Link/bpm_publisher.c @@ -4,20 +4,23 @@ // host-testable. No Arduino deps — the caller passes the clock in. #include "bpm_publisher.h" +#include "bar.h" #include static float s_tracked; static float s_threshold; static uint32_t s_send_interval; static int s_refresh_bars; +static int s_quantum; static uint32_t s_last_send; static uint32_t s_last_bar; -void bpm_publisher_init(float threshold, uint32_t send_interval_ms, int refresh_bars) { +void bpm_publisher_init(float threshold, uint32_t send_interval_ms, int refresh_bars, int quantum) { s_tracked = 0.0f; s_threshold = threshold; s_send_interval = send_interval_ms; s_refresh_bars = refresh_bars; + s_quantum = quantum; s_last_send = 0; s_last_bar = 0; } @@ -30,7 +33,7 @@ PublishDecision bpm_publisher_step(float bpm, bool active, uint32_t now_ms) { bool refresh = bpm > 0.0f && active && (uint32_t)(now_ms - s_last_bar) >= - (uint32_t)(4 * s_refresh_bars * 60000.0f / bpm); + (uint32_t)bar_ms(bpm, s_quantum, s_refresh_bars); if ((changed || refresh) && (uint32_t)(now_ms - s_last_send) >= s_send_interval) { d.send = true; diff --git a/X32Link/bpm_publisher.h b/X32Link/bpm_publisher.h index 92af772..edf53dd 100644 --- a/X32Link/bpm_publisher.h +++ b/X32Link/bpm_publisher.h @@ -15,7 +15,7 @@ typedef struct { /* Configure the policy. threshold = min BPM change to trigger a send; * send_interval_ms = floor between sends; refresh_bars = resend every N bars. */ -void bpm_publisher_init(float threshold, uint32_t send_interval_ms, int refresh_bars); +void bpm_publisher_init(float threshold, uint32_t send_interval_ms, int refresh_bars, int quantum); /* Feed the latest reading each tick. bpm<=0 means "no signal" (never sends); * active gates the periodic refresh (peers present / pulses flowing). */ diff --git a/X32Link/buttons.c b/X32Link/buttons.c new file mode 100644 index 0000000..b963e9c --- /dev/null +++ b/X32Link/buttons.c @@ -0,0 +1,37 @@ +#include "buttons.h" + +void button_reset(Button* b) { + b->stable = false; + b->raw = false; + b->raw_since_ms = 0; + b->primed = false; +} + +bool button_update(Button* b, bool raw_pressed, uint32_t now_ms) { + // First call seeds the state. A button already held down at boot must NOT + // read as a press edge — that would fire an action on power-up. + if (!b->primed) { + b->primed = true; + b->raw = raw_pressed; + b->stable = raw_pressed; + b->raw_since_ms = now_ms; + return false; + } + + if (raw_pressed != b->raw) { + b->raw = raw_pressed; + b->raw_since_ms = now_ms; // restart the settle window on every bounce + return false; + } + + // Level has been steady since raw_since_ms. Commit it once it outlasts a bounce. + if (b->raw == b->stable) return false; + if ((uint32_t)(now_ms - b->raw_since_ms) < BUTTON_DEBOUNCE_MS) return false; + + b->stable = b->raw; + return b->stable; // edge only on release -> press +} + +bool button_is_pressed(const Button* b) { + return b->stable; +} diff --git a/X32Link/buttons.h b/X32Link/buttons.h new file mode 100644 index 0000000..23bcc7b --- /dev/null +++ b/X32Link/buttons.h @@ -0,0 +1,38 @@ +#pragma once +// ESP-025: pure debounce for physical buttons on the bench rig (ESP32 DevKit + +// screw-terminal breakout). No Arduino, no GPIO — the caller reads the pin and +// passes the raw level in, so the whole thing is host-testable. +// +// Wiring assumed: switch to GND with INPUT_PULLUP, so the pin reads LOW when +// pressed. The caller converts that to `raw_pressed` (i.e. !digitalRead(pin)) — +// this module never sees a pin polarity. +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +// A bounce settles well inside 25 ms on a normal tactile switch; anything longer +// starts eating fast double-taps. +#define BUTTON_DEBOUNCE_MS 25 + +typedef struct { + bool stable; // debounced level: true = pressed + bool raw; // last raw level seen + uint32_t raw_since_ms; // when `raw` last changed + bool primed; // false until the first update seeds the state +} Button; + +void button_reset(Button* b); + +// Feed the raw pin level. Returns true exactly once per debounced press +// (release -> press edge); never on release, never twice for one press. +bool button_update(Button* b, bool raw_pressed, uint32_t now_ms); + +// Debounced level, for callers that want hold rather than edge. +bool button_is_pressed(const Button* b); + +#ifdef __cplusplus +} +#endif diff --git a/X32Link/clock_output.c b/X32Link/clock_output.c new file mode 100644 index 0000000..57224fc --- /dev/null +++ b/X32Link/clock_output.c @@ -0,0 +1,33 @@ +#include "clock_output.h" +#include "swing.h" + +int clock_output_due(ClockTicker* t, double beats_now, int ppqn, + int phase_mbeats, int swing_mbeats, int max_burst) { + // Swing the musical time first (delays each off-eighth), then apply the + // constant phase nudge (positive fires earlier), then quantize to ppqn. + double swung = swing_warp(beats_now, swing_mbeats); + double shifted = swung + (double)phase_mbeats / 1000.0; + return clock_ticker_ticks_due(t, shifted, ppqn, max_burst); +} + +void clock_output_reset(ClockOutput* o) { + clock_ticker_reset(&o->t); + o->dropped = 0; +} + +int clock_output_step(ClockOutput* o, double beats_now, int ppqn, + int phase_mbeats, int swing_mbeats) { + if (beats_now < 0.0) { + // Phase not valid: bank the discarded-pulse count before reset zeroes + // it, re-prime so the next valid beat starts a clean grid, stay quiet. + o->dropped += o->t.dropped; + clock_ticker_reset(&o->t); + return 0; + } + return clock_output_due(&o->t, beats_now, ppqn, phase_mbeats, swing_mbeats, + CLOCK_OUTPUT_MAX_BURST); +} + +uint32_t clock_output_dropped(const ClockOutput* o) { + return o->dropped + o->t.dropped; +} diff --git a/X32Link/clock_output.h b/X32Link/clock_output.h new file mode 100644 index 0000000..feaa9f7 --- /dev/null +++ b/X32Link/clock_output.h @@ -0,0 +1,56 @@ +#pragma once +// Pure per-output clock scheduler (P4-010): applies an output's swing (P4-013), +// phase nudge (signed milli-beats) and division (ppqn) to the shared beat +// position, then returns the pulses due via the tested clock_ticker. Swing is the +// non-linear time-warp (see swing.h); phase is a constant offset. Host-tested in +// test/test_clock_output.c. No Arduino/ESP-IDF dependency. +#include "clock_ticker.h" +#ifdef __cplusplus +extern "C" { +#endif + +// Advance output `t` to beats_now and return pulses due at `ppqn` pulses/beat. +// The beat is swung by `swing_mbeats` (0 = straight; delays each off-eighth, +// P4-013) and then shifted by `phase_mbeats`/1000 of a beat (positive = fires +// earlier). Both are applied before quantizing. +int clock_output_due(ClockTicker* t, double beats_now, int ppqn, + int phase_mbeats, int swing_mbeats, int max_burst); + +// --- ARC-019: the 1ms-writer derivation, one owner --------------------------- + +// Burst cap for the 1ms writer tasks (Touch DIN, X32Link USB+DIN): a forward +// jump past this many pulses realigns (discarding the backlog into `dropped`) +// instead of spraying a catch-up burst — ESP-018 chose 4 on the bench; a burst +// is audible on downstream gear. KitchenSync's plan loop deliberately runs a +// different policy (KS_TICK_MAX_BURST 96: prefer catch-up over dropping) — +// a per-product choice, not a second copy of this constant. +#define CLOCK_OUTPUT_MAX_BURST 4 + +// Per-writer clock-out state: the tick grid plus the lifetime count of pulses +// discarded by realign/reset. The ticker's own `dropped` is zeroed by every +// re-prime (a pure struct can't tell first-init from re-prime), so the +// lifetime total is banked here, not in the ticker. +typedef struct { + ClockTicker t; + uint32_t dropped; // banked across resets; live total via _dropped() +} ClockOutput; + +// Full clear: re-prime the grid AND zero the lifetime dropped count. +void clock_output_reset(ClockOutput* o); + +// One 1ms-writer step. beats_now < 0 means phase is not valid (pre-sync, peer +// loss, mid re-measure): bank the ticker's dropped count, re-prime, emit +// nothing — the reset-on-invalid rule lives HERE, not in each writer task. +// Otherwise apply swing + phase nudge + division via clock_output_due() with +// the shared burst cap and return the pulses due now. +int clock_output_step(ClockOutput* o, double beats_now, int ppqn, + int phase_mbeats, int swing_mbeats); + +// Lifetime pulses discarded (banked + whatever the live ticker holds). A stall +// long enough to trip the realign leaves no burst and no gap on the wire — +// this counter is the only trace (ESP-018). +uint32_t clock_output_dropped(const ClockOutput* o); + +#ifdef __cplusplus +} +#endif diff --git a/X32Link/clock_ticker.c b/X32Link/clock_ticker.c new file mode 100644 index 0000000..e33b410 --- /dev/null +++ b/X32Link/clock_ticker.c @@ -0,0 +1,72 @@ +// Shared pure clock-tick scheduler — see clock_ticker.h. +#include "clock_ticker.h" +#include + +void clock_ticker_reset(ClockTicker* s) { + s->last_tick = 0; + s->primed = false; + /* Zeroed here so the counter is always INITIALISED -- callers do + * `ClockTicker t; clock_ticker_reset(&t);`, so leaving it alone means it starts as + * stack garbage and can never be trusted. reset() only fires at startup and on Link + * phase-invalid (no clock to drop anyway), so nothing real is lost. Accumulating a + * LIFETIME total across resets is the glue's job: a pure struct cannot tell "first + * init" from "re-prime", and that distinction is what the decision needs. */ + s->dropped = 0; +} + +int clock_ticker_ticks_due(ClockTicker* s, double beats_now, int ppqn, int max_burst) { + if (ppqn <= 0) return 0; + if (beats_now < 0.0) beats_now = 0.0; + int64_t tick = (int64_t)floor(beats_now * (double)ppqn); + + if (!s->primed) { // first observation: align the grid, no backlog + s->primed = true; + s->last_tick = tick; + return 0; + } + + int64_t due = tick - s->last_tick; + if (due <= 0) return 0; // backward or same 1/ppqn-beat slot + + if (max_burst > 0 && due > (int64_t)max_burst) { + // Big forward jump (tempo re-origin / long stall): realign to the new + // position instead of flooding the output with a catch-up burst. + // + // ESP-018: this THROWS AWAY every pending pulse. Still the right musical call + // -- a 20-pulse burst is worse than a gap -- but it used to happen silently, + // so a stall long enough to trip it left no evidence anywhere. Count it. + s->dropped += (uint32_t)due; + s->last_tick = tick; + return 0; + } + + s->last_tick = tick; + return (int)due; +} + +void bar_reset_reset(BarReset* s) { + s->last_bar = 0; + s->primed = false; +} + +bool bar_reset_due(BarReset* s, double beats_now, double quantum) { + if (quantum <= 0.0) return false; + if (beats_now < 0.0) beats_now = 0.0; + int64_t bar = (int64_t)floor(beats_now / quantum); + + if (!s->primed) { + s->primed = true; + s->last_bar = bar; + return false; + } + + int64_t delta = bar - s->last_bar; + if (delta == 1) { // clean crossing into the next bar + s->last_bar = bar; + return true; + } + if (delta != 0) { // backward or multi-bar jump (re-origin): realign, + s->last_bar = bar; // don't fire a false downbeat + } + return false; +} diff --git a/X32Link/clock_ticker.h b/X32Link/clock_ticker.h new file mode 100644 index 0000000..560c591 --- /dev/null +++ b/X32Link/clock_ticker.h @@ -0,0 +1,62 @@ +#pragma once +// Shared, pure clock-tick scheduler for tempo fan-out (LNK-027 USB-MIDI clock +// out at 24 PPQN; LNK-028 analog Eurorack sync at a configurable PPQN). It +// quantizes a *monotonic beat position* (from the Link phase pipeline) to N +// pulses per beat and reports how many pulses are due since the last call — +// phase-locked (first pulse on phase-zero), re-priming on a large jump +// (re-origin / stall) instead of flooding. A companion BarReset tracker fires +// once per bar boundary, for the analog "reset" trigger. No Arduino/hardware +// dependency — host-tested in test/test_clock_ticker.c. Glue owns the timer/RMT +// and the actual edge emission. +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +typedef struct { + int64_t last_tick; // last emitted 1/ppqn-beat grid index + bool primed; // false until the first beats_now aligns the grid + // ESP-018: pulses the realign path DISCARDED. On a forward jump bigger than + // max_burst the ticker realigns rather than flooding the wire with a catch-up + // burst -- the right musical call, but `return 0` throws every pending pulse + // away, and until now it did so with NO trace: no burst on the analyzer, no + // counter, no log. A stall long enough to trip it was simply invisible. + // reset() ZEROES this, so it is always initialised (callers declare the struct on + // the stack and call reset on it). Keeping a LIFETIME total across resets is the + // caller's job -- a pure struct cannot distinguish "first init" from "re-prime". + uint32_t dropped; +} ClockTicker; + +// Clear state: the next ticks_due() call re-primes the grid (emits nothing). +void clock_ticker_reset(ClockTicker* s); + +// Advance to beats_now and return how many pulses to emit now, at `ppqn` pulses +// per beat: +// - first call after a reset primes the grid and returns 0 (no backlog dump); +// - a backward or same-slot move returns 0; +// - a forward move of N pulses returns N, UNLESS N exceeds max_burst (a tempo +// re-origin or a long stall), in which case it re-primes and returns 0 — the +// caller keeps phase alignment rather than flooding the output. +// beats_now < 0 is treated as 0 (a "not valid yet" sentinel from the glue). +// ppqn <= 0 is a no-op that returns 0 without touching state. +int clock_ticker_ticks_due(ClockTicker* s, double beats_now, int ppqn, int max_burst); + +typedef struct { + int64_t last_bar; // last observed bar index = floor(beats / quantum) + bool primed; +} BarReset; + +void bar_reset_reset(BarReset* s); + +// True exactly once when beats_now crosses forward into the next bar (bar = +// floor(beats / quantum)). First call after a reset primes and returns false. A +// same-bar read returns false. A backward move or a forward jump of more than one +// bar (a re-origin) re-primes and returns false — better to miss a reset than to +// fire a false downbeat. quantum <= 0 returns false. +bool bar_reset_due(BarReset* s, double beats_now, double quantum); + +#ifdef __cplusplus +} +#endif diff --git a/X32Link/config.h b/X32Link/config.h index 3df06e5..d6e038d 100644 --- a/X32Link/config.h +++ b/X32Link/config.h @@ -10,7 +10,8 @@ // USB MIDI clock input — per-input knobs (used when input_source = MIDI) #define MCK_BPM_THRESHOLD 1.5f // MIDI jitter is higher than Link #define MCK_POLL_MS 10 -#define MCK_CLOCK_WINDOW 24 // pulses to average (1 beat = 24 PPQN) +#define MCK_PPQN 24 // MIDI clock pulses per beat (protocol constant; drives the per-beat flag) +#define MCK_CLOCK_WINDOW 48 // pulses to average for BPM (2 beats) — smooths 1ms-poll timestamp jitter #define MCK_CLOCK_TIMEOUT_MS 2000 // no pulse for this long → clock stopped // First-boot defaults — overridden after first web config save @@ -22,3 +23,19 @@ #define DEFAULT_INPUT_SOURCE 0 // TEMPO_SRC_LINK #define DEFAULT_FDR_ENABLE 0 // FaderDisp off on non-FDR firmware #define DEFAULT_FDR_CHAN_COUNT 32 +#define DEFAULT_QUANTUM_BEATS 4 // bar-quantized phase: beats per bar +#define DEFAULT_MIDI_CLOCK_OUT_ENABLE 0 // LNK-027: MIDI clock OUT off by default +#define DEFAULT_PHASE_DISPLAY_MODE 0 // LNK-036: 0 = sweep wheel, 1 = beat-flash dot +#define DEFAULT_DOT_BEAT_COLOR 0xB6FF36 // beats 2..N (P4 led_beat_color parity) +#define DEFAULT_DOT_ACCENT_COLOR 0xFF9D3B // bar-1 downbeat (P4 led_accent_color parity) + +// Board profile (LNK-025) — the Waveshare ESP32-S3-Touch-LCD-1.47 carries the +// 1.47" LCD, so its single board flag implies the on-device display UI: one +// -DBOARD_WAVESHARE_S3_TOUCH_LCD_147 yields the full LNK-014/015 touch build +// instead of two flags (BOARD_* + HAS_TOUCH_DISPLAY) hand-kept in sync. This +// MUST live in a shared header every relevant translation unit includes — +// deriving it inside X32Link.ino alone leaves touch_display.cpp (a separate TU) +// unaware, compiling its body out and undefined-ref'ing touch_display_begin(). +#if defined(BOARD_WAVESHARE_S3_TOUCH_LCD_147) && !defined(HAS_TOUCH_DISPLAY) +#define HAS_TOUCH_DISPLAY +#endif diff --git a/X32Link/config_persist.c b/X32Link/config_persist.c new file mode 100644 index 0000000..cc50c6f --- /dev/null +++ b/X32Link/config_persist.c @@ -0,0 +1,32 @@ +#include "config_persist.h" + +void config_persist_reset(ConfigPersist* p) { + if (!p) return; + p->first_edit_ms = 0; + p->last_edit_ms = 0; + p->dirty = false; +} + +void config_persist_mark(ConfigPersist* p, uint32_t now_ms) { + if (!p) return; + if (!p->dirty) { // first edit of a new burst starts the deferral clock + p->first_edit_ms = now_ms; + p->dirty = true; + } + p->last_edit_ms = now_ms; +} + +bool config_persist_due(ConfigPersist* p, uint32_t now_ms) { + if (!p || !p->dirty) return false; + + // Unsigned subtraction, deliberately: it stays correct across the millis() + // rollover at 2^32 ms (~49 days). Comparing timestamps directly would not. + uint32_t quiet = now_ms - p->last_edit_ms; // idle since the last edit + uint32_t held = now_ms - p->first_edit_ms; // dirty since the burst began + + if (quiet >= CONFIG_PERSIST_QUIET_MS || held >= CONFIG_PERSIST_MAX_DEFER_MS) { + p->dirty = false; + return true; + } + return false; +} diff --git a/X32Link/config_persist.h b/X32Link/config_persist.h new file mode 100644 index 0000000..f42a7a2 --- /dev/null +++ b/X32Link/config_persist.h @@ -0,0 +1,55 @@ +#pragma once +// ARC-022: when does a live config edit get written to flash? +// +// A live edit is a real edit -- it must survive a power cycle. But config is +// stored as ONE nvs blob (nvs_set_blob(h, KEY, cfg, sizeof(*cfg))); there is no +// per-field write. A slider drag emits dozens of POSTs, so writing on every edit +// would blob-write flash tens of times per gesture and wear NVS for no benefit. +// +// So edits mark the config dirty and the blob is written once they settle. This +// module owns that decision and nothing else: no flash, no timer, no Arduino, no +// wall clock. Time is an argument, so the policy is host-testable and the glue is +// left with nothing to get wrong but mark() on edit and due() on a poll. +// +// The write must never land on the 1 ms MIDI writer task: a flash write suspends +// the cache and freezes BOTH cores regardless of priority. Poll due() from a +// low-priority task (the P4's status_task at prio 2, the Arduino loop()). +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +// Quiet window: a burst is settled once this long passes with no new edit. Long +// enough that a slider drag is one write; short enough that a user who changes a +// setting and immediately yanks power almost certainly still keeps it. +#define CONFIG_PERSIST_QUIET_MS 1500u + +// Maximum deferral: a continuous stream of edits that never leaves a quiet gap is +// written this long after the FIRST edit of the burst. Debounce defers a write; +// it must not starve one. Without this bound, a control the browser polls (or a +// user who never stops dragging) would keep resetting the quiet window forever. +#define CONFIG_PERSIST_MAX_DEFER_MS 10000u + +typedef struct { + uint32_t first_edit_ms; // when the current dirty burst began + uint32_t last_edit_ms; // most recent edit in it + bool dirty; // an edit is pending a write +} ConfigPersist; + +// Clear state: not dirty, nothing owed. Callers stack-allocate, so this must zero +// every field -- see clock_ticker.dropped, which read stack garbage until it did. +void config_persist_reset(ConfigPersist* p); + +// An edit arrived (call from the /live handler, after the config is updated). +void config_persist_mark(ConfigPersist* p, uint32_t now_ms); + +// Should the blob be written NOW? True exactly once per settled burst -- it +// clears the dirty flag, so a poll loop cannot re-write the same blob every pass. +// Returns false on a clean tracker at any time. +bool config_persist_due(ConfigPersist* p, uint32_t now_ms); + +#ifdef __cplusplus +} +#endif diff --git a/X32Link/din_midi_out.cpp b/X32Link/din_midi_out.cpp new file mode 100644 index 0000000..2e59f7c --- /dev/null +++ b/X32Link/din_midi_out.cpp @@ -0,0 +1,21 @@ +// DIN MIDI OUT on the S3 (KitchenSync Touch) -- see din_midi_out.h (ESP-016). +#include "din_midi_out.h" +#include + +// UART0 is the console (GPIO43/44); UART1 is free. TX routes to any GPIO through +// the matrix, RX unused (-1) -- this is clock/transport OUT only. +static HardwareSerial s_midi(1); +static bool s_ready = false; + +void din_midi_out_begin(int tx_gpio) { + // 31250 divides cleanly off the UART clock, so the byte rate is exact and the + // analyzer decodes it at 31250 with no baud error (same as the P4, ESP-015). + s_midi.begin(31250, SERIAL_8N1, -1 /*rx*/, tx_gpio /*tx*/); + s_ready = true; +} + +void din_midi_out_byte(uint8_t status) { + // write() copies into the driver's TX ring and returns; the peripheral shifts + // it out under interrupt, so this never blocks the 1 ms clock task. + if (s_ready) s_midi.write(status); +} diff --git a/X32Link/din_midi_out.h b/X32Link/din_midi_out.h new file mode 100644 index 0000000..b0cc147 --- /dev/null +++ b/X32Link/din_midi_out.h @@ -0,0 +1,39 @@ +#pragma once +// ESP-016: hardware DIN MIDI OUT for KitchenSync Touch (ESP32-S3). A UART TX at +// 31250 8N1 on a GPIO -> 5-pin DIN jack, mirroring the USB-MIDI clock X32Link +// already emits -- so the S3 can drive DIN gear (Dan's RC-505) with no host. +// +// Thin Arduino glue over HardwareSerial; the byte TIMING is the pure, host-tested +// midi_clock_out engine (the same one that feeds the USB path). Same shape as the +// P4's midi_uart_out (ESP-015). Keep this header free of Arduino includes so the +// host test build can glob it without pulling in the core. +#include + +// MIDI TX pin on the Waveshare ESP32-S3-Touch-LCD-1.47: GPIO11 is free and broken +// out on the right header next to 3V3 + GND (see the s3-touch pinout / ESP-016). +// Any free header GPIO works -- UART routes to any pin via the matrix. +#if defined(BOARD_ESP32_DEVKIT) +// ESP-025 bench rig (classic ESP32 DevKit): GPIO17 = UART2 TX, broken out on the +// screw terminals. Do NOT use the terminals marked TX/RX (GPIO1/3) -- on this board +// those ARE the CP2102 console, so MIDI would spray into the serial log and the log +// would spray into the MIDI wire. +#define MIDI_TX_GPIO 17 +#else +#define MIDI_TX_GPIO 11 +#endif + +#ifdef __cplusplus +extern "C" { +#endif + +// Bring up UART1 (UART0 is the console) at 31250 8N1, TX on tx_gpio, no RX. +void din_midi_out_begin(int tx_gpio); + +// Write one raw MIDI status byte to the wire. On a DIN wire a System Real-Time +// message (0xF8 clock / 0xFA start / 0xFC stop) is a single byte -- no USB-MIDI +// packing. Fire-and-forget; a no-op until _begin(). +void din_midi_out_byte(uint8_t status); + +#ifdef __cplusplus +} +#endif diff --git a/X32Link/fw_version.h b/X32Link/fw_version.h new file mode 100644 index 0000000..8673644 --- /dev/null +++ b/X32Link/fw_version.h @@ -0,0 +1,28 @@ +// LNK-038: firmware version identity — the one source of truth for what a +// device reports on serial, the web UI, /status JSON, and the /update page. +// KitchenSync compiles with X32Link/ on its include path (ADR-0003) and +// X32_emulator symlinks this file, so every firmware reports the same version. +// +// Release process: bump FW_VERSION, tag the commit (git tag v), +// then any distributed .bin traces back to source. Guarded so CI can inject +// a richer string via -DFW_VERSION="\"$(git describe --tags)\"" later. +#pragma once + +#ifndef FW_VERSION +#define FW_VERSION "2.2.0" +#endif + +// Invariants an injected FW_VERSION must keep: charset [A-Za-z0-9.-] only — +// it is interpolated unescaped into /status JSON and web-UI JS strings — and +// short enough that the /status snprintf buffers never truncate (asserted +// below; both firmwares' status handlers rely on it). +#ifdef __cplusplus +static_assert(sizeof(FW_VERSION) <= 24, "FW_VERSION too long for /status buffers"); +#else +_Static_assert(sizeof(FW_VERSION) <= 24, "FW_VERSION too long for /status buffers"); +#endif + +// Compile stamp — tells dev builds apart between version bumps. Note: expands +// per translation unit, so serial vs web stamps can differ on an incremental +// rebuild; clean release builds agree. +#define FW_BUILD __DATE__ " " __TIME__ diff --git a/X32Link/ks_config.c b/X32Link/ks_config.c new file mode 100644 index 0000000..8019e4b --- /dev/null +++ b/X32Link/ks_config.c @@ -0,0 +1,396 @@ +#include "ks_config.h" +#include +#include + +void ks_config_defaults(KsConfig* c) { + memset(c, 0, sizeof(*c)); + c->clock_out_enable = 1; + c->metronome_enable = 0; // audible: default off so a fresh board is silent + c->metronome_accent = 1; // when enabled, accent the bar-1 downbeat + c->metronome_volume = 80; // ES8311 codec volume + c->metronome_voice = 0; // Tone (default) + c->led_enable = 0; // visual metronome on the strip: default off + c->follow_beat_enable = 0; // mic capture: default off (P4-020) + c->led_brightness = 60; // % (P4-019) + c->led_mode = 0; // chase + c->led_fade = 55; // pulse dim across a beat + c->led_beat_color = 0x00B400; // green + c->led_accent_color = 0xDC6E00; // amber + // P4-010: output 0 is the default 24-PPQN MIDI clock on cable 0; rest off. + for (int i = 0; i < KS_CLOCK_OUTPUTS; i++) { + c->clock[i].enable = (i == 0) ? 1 : 0; + c->clock[i].cable = i; + c->clock[i].ppqn = 24; + c->clock[i].phase_mbeats = 0; + c->clock[i].swing_mbeats = 0; // straight by default (P4-013) + c->clock[i].follow_link = 1; // Link owns transport by default (ESP-011) + } + c->tempo_mbpm = 120000; // ESP-037: 120.000 BPM (Link default) when solo + // ESP-042: the defaults the Touch's AppConfig used to carry. + c->quantum_beats = 4; // 1 bar (4/4) + c->transport_enable = 1; // UI transport allowed + c->play_on_release = 0; // fire on touch, not release + c->lcd_brightness = 80; // LCD backlight % +} + +bool ks_config_valid(const KsConfig* c) { + if (c->clock_out_enable != 0 && c->clock_out_enable != 1) return false; + if (c->metronome_enable != 0 && c->metronome_enable != 1) return false; + if (c->metronome_accent != 0 && c->metronome_accent != 1) return false; + if (c->metronome_volume < 0 || c->metronome_volume > 100) return false; + if (c->metronome_voice < 0 || c->metronome_voice > 2) return false; + if (c->led_enable != 0 && c->led_enable != 1) return false; + if (c->follow_beat_enable != 0 && c->follow_beat_enable != 1) return false; + if (c->led_brightness < 0 || c->led_brightness > 100) return false; + if (c->led_mode < 0 || c->led_mode > 2) return false; + if (c->led_fade < 0 || c->led_fade > 100) return false; + if (c->led_beat_color < 0 || c->led_beat_color > 0xFFFFFF) return false; + if (c->led_accent_color < 0 || c->led_accent_color > 0xFFFFFF) return false; + for (int i = 0; i < KS_CLOCK_OUTPUTS; i++) { + const ClockOutputCfg* o = &c->clock[i]; + if (o->enable != 0 && o->enable != 1) return false; + if (o->cable < 0 || o->cable > 3) return false; + if (o->ppqn < 1 || o->ppqn > 48) return false; + if (o->phase_mbeats < -250 || o->phase_mbeats > 250) return false; + if (o->swing_mbeats < 0 || o->swing_mbeats > 250) return false; + if (o->follow_link != 0 && o->follow_link != 1) return false; + } + /* ESP-037: milli-BPM in the musical band (MASTER_CLOCK_BPM_MIN..MAX * 1000). + * Literals, not the clock header -- this pure config must not pull in Link types. */ + if (c->tempo_mbpm < 20000 || c->tempo_mbpm > 300000) return false; + /* ESP-042: the Touch's fields. Same ranges AppConfig validated. */ + if (c->quantum_beats < 1 || c->quantum_beats > 64) return false; + if (c->transport_enable != 0 && c->transport_enable != 1) return false; + if (c->play_on_release != 0 && c->play_on_release != 1) return false; + if (c->lcd_brightness < 10 || c->lcd_brightness > 100) return false; + return true; +} + +// ESP-013: drop the empty slots so the connection policy can stay SSID-blind. +int ks_config_wifi_slots(const KsConfig* c, WifiCred out[KS_WIFI_SLOTS]) { + int n = 0; + for (int i = 0; i < KS_WIFI_SLOTS; i++) { + if (c->wifi[i].ssid[0] != '\0') out[n++] = c->wifi[i]; + } + return n; +} + +// Copy a bounded string; returns false if it wouldn't fit (config unchanged). +static bool copy_field(char* dst, size_t cap, const char* src) { + if (strlen(src) >= cap) return false; + strcpy(dst, src); + return true; +} + +// ESP-013 form keys: `base` alone means slot 0 (the pre-multi-network name, kept +// so the existing form and its tests never had to move), `base` means slot N. +// Returns -1 for "not this family, or an out-of-range slot". +static int wifi_slot_index(const char* key, const char* base) { + size_t n = strlen(base); + if (strncmp(key, base, n) != 0) return -1; + const char* suffix = key + n; + if (suffix[0] == '\0') return 0; + if (suffix[1] != '\0' || suffix[0] < '0' || suffix[0] > '9') return -1; + int i = suffix[0] - '0'; + return (i < KS_WIFI_SLOTS) ? i : -1; +} + +bool ks_config_set(KsConfig* c, const char* key, const char* value) { + int slot = wifi_slot_index(key, "wifi_ssid"); + if (slot >= 0) { + // Empty ssid = forget this network. Clearing the password with it means a + // forgotten slot can never be retried against a same-named network later. + if (value[0] == '\0') { memset(&c->wifi[slot], 0, sizeof(c->wifi[slot])); return true; } + return copy_field(c->wifi[slot].ssid, sizeof(c->wifi[slot].ssid), value); + } + slot = wifi_slot_index(key, "wifi_pass"); + if (slot >= 0) { + if (value[0] == '\0') return true; // blank = keep current + return copy_field(c->wifi[slot].pass, sizeof(c->wifi[slot].pass), value); + } + if (strcmp(key, "clock_out") == 0) { + int v = atoi(value); + if (v != 0 && v != 1) return false; + c->clock_out_enable = v; + return true; + } + if (strcmp(key, "bpm") == 0) { + // ESP-037: decimal BPM -> milli-BPM, range-checked. A bad number is rejected + // (config stays valid) rather than clocking gear at a nonsense rate. + int mbpm = (int)(atof(value) * 1000.0 + 0.5); + if (mbpm < 20000 || mbpm > 300000) return false; + c->tempo_mbpm = mbpm; + return true; + } + // ESP-042: the fleet fields. `quantum` is in BEATS (the Touch's web page converts its + // BARS control to beats before sending); `bright` is the LCD backlight, distinct from + // `led_bright` (the WS2812 strip). + if (strcmp(key, "quantum") == 0) { + int v = atoi(value); + if (v < 1 || v > 64) return false; + c->quantum_beats = v; + return true; + } + if (strcmp(key, "transport") == 0) { + int v = atoi(value); + if (v != 0 && v != 1) return false; + c->transport_enable = v; + return true; + } + if (strcmp(key, "play_rel") == 0) { + int v = atoi(value); + if (v != 0 && v != 1) return false; + c->play_on_release = v; + return true; + } + if (strcmp(key, "bright") == 0) { + int v = atoi(value); + if (v < 10 || v > 100) return false; + c->lcd_brightness = v; + return true; + } + if (strcmp(key, "metronome") == 0) { + int v = atoi(value); + if (v != 0 && v != 1) return false; + c->metronome_enable = v; + return true; + } + if (strcmp(key, "metro_accent") == 0) { + int v = atoi(value); + if (v != 0 && v != 1) return false; + c->metronome_accent = v; + return true; + } + if (strcmp(key, "metro_vol") == 0) { + int v = atoi(value); + if (v < 0 || v > 100) return false; + c->metronome_volume = v; + return true; + } + if (strcmp(key, "metro_voice") == 0) { + int v = atoi(value); + if (v < 0 || v > 2) return false; + c->metronome_voice = v; + return true; + } + if (strcmp(key, "led") == 0) { + int v = atoi(value); + if (v != 0 && v != 1) return false; + c->led_enable = v; + return true; + } + if (strcmp(key, "follow_beat") == 0) { + int v = atoi(value); + if (v != 0 && v != 1) return false; + c->follow_beat_enable = v; + return true; + } + if (strcmp(key, "led_bright") == 0) { + int v = atoi(value); + if (v < 0 || v > 100) return false; + c->led_brightness = v; + return true; + } + if (strcmp(key, "led_mode") == 0) { + int v = atoi(value); + if (v < 0 || v > 2) return false; + c->led_mode = v; + return true; + } + if (strcmp(key, "led_fade") == 0) { + int v = atoi(value); + if (v < 0 || v > 100) return false; + c->led_fade = v; + return true; + } + // Colours arrive from an HTML as "#rrggbb" (the '#' is + // url-decoded before we see it); accept with or without the leading '#'. + if (strcmp(key, "led_beat") == 0 || strcmp(key, "led_accent") == 0) { + const char* hex = (value[0] == '#') ? value + 1 : value; + char* end = NULL; + long v = strtol(hex, &end, 16); + if (end == hex || *end != '\0' || v < 0 || v > 0xFFFFFF) return false; + if (key[4] == 'b') c->led_beat_color = (int)v; // "led_beat" + else c->led_accent_color = (int)v; // "led_accent" + return true; + } + // P4-010 per-output fields: "clk_en|cable|ppqn|phase|swing|follow" (N = 0..3). + if (strncmp(key, "clk", 3) == 0 && key[3] >= '0' && key[3] <= '9' && key[4] == '_') { + int idx = key[3] - '0'; + if (idx < 0 || idx >= KS_CLOCK_OUTPUTS) return false; + const char* f = key + 5; + int v = atoi(value); + ClockOutputCfg* o = &c->clock[idx]; + if (strcmp(f, "en") == 0) { if (v != 0 && v != 1) return false; o->enable = v; return true; } + if (strcmp(f, "cable") == 0) { if (v < 0 || v > 3) return false; o->cable = v; return true; } + if (strcmp(f, "ppqn") == 0) { if (v < 1 || v > 48) return false; o->ppqn = v; return true; } + if (strcmp(f, "phase") == 0) { if (v < -250 || v > 250) return false; o->phase_mbeats = v; return true; } + if (strcmp(f, "swing") == 0) { if (v < 0 || v > 250) return false; o->swing_mbeats = v; return true; } + if (strcmp(f, "follow") == 0){ if (v != 0 && v != 1) return false; o->follow_link = v; return true; } + return false; + } + return false; +} + +// ARC-016: the single owner of the live-safe field set — see ks_config.h. +void ks_config_live_safe_copy(KsConfig* dst, const KsConfig* src) { + dst->clock_out_enable = src->clock_out_enable; + dst->metronome_accent = src->metronome_accent; + dst->metronome_volume = src->metronome_volume; + dst->metronome_voice = src->metronome_voice; + dst->led_enable = src->led_enable; + dst->led_brightness = src->led_brightness; + dst->led_mode = src->led_mode; + dst->led_fade = src->led_fade; + dst->led_beat_color = src->led_beat_color; + dst->led_accent_color = src->led_accent_color; + for (int o = 0; o < KS_CLOCK_OUTPUTS; o++) dst->clock[o] = src->clock[o]; + dst->tempo_mbpm = src->tempo_mbpm; /* ESP-037: a set tempo is a LIVE edit */ + /* ESP-042: the fleet fields are all live -- no reboot to change quantize, the + * transport gate, the touch behaviour, or the backlight. */ + dst->quantum_beats = src->quantum_beats; + dst->transport_enable = src->transport_enable; + dst->play_on_release = src->play_on_release; + dst->lcd_brightness = src->lcd_brightness; +} + +/* ESP-013: the v1 layout, FROZEN. This is a copy of what shipped, not an alias of + * the current structs — if ClockOutputCfg or KsConfig ever change again, this must + * not follow them, or the migration would read v1 bytes at v3 offsets. Nothing here + * may reference the live types. Its own _Static_assert nails the size. */ +typedef struct { int enable, cable, ppqn, phase_mbeats, swing_mbeats; } ClockOutputCfgV1; +typedef struct { + char wifi_ssid[33]; + char wifi_pass[64]; + int clock_out_enable, metronome_enable, metronome_accent, metronome_volume, metronome_voice; + ClockOutputCfgV1 clock[4]; + int led_enable, led_brightness, led_mode, led_fade, led_beat_color, led_accent_color; + int follow_beat_enable; +} KsConfigV1; +_Static_assert(sizeof(KsConfigV1) == 228, "frozen v1 layout must not change (ESP-013 migration)"); + +// v1 -> v2: the single network becomes slot 0; the new slots start empty. +static void migrate_v1(KsConfig* out, const KsConfigV1* v1) { + ks_config_defaults(out); // new fields (wifi[1..]) get their defaults + memcpy(out->wifi[0].ssid, v1->wifi_ssid, sizeof(out->wifi[0].ssid)); + memcpy(out->wifi[0].pass, v1->wifi_pass, sizeof(out->wifi[0].pass)); + out->wifi[0].ssid[sizeof(out->wifi[0].ssid) - 1] = '\0'; // a corrupt blob need not be NUL-terminated + out->wifi[0].pass[sizeof(out->wifi[0].pass) - 1] = '\0'; + out->clock_out_enable = v1->clock_out_enable; + out->metronome_enable = v1->metronome_enable; + out->metronome_accent = v1->metronome_accent; + out->metronome_volume = v1->metronome_volume; + out->metronome_voice = v1->metronome_voice; + for (int i = 0; i < KS_CLOCK_OUTPUTS; i++) { + out->clock[i].enable = v1->clock[i].enable; + out->clock[i].cable = v1->clock[i].cable; + out->clock[i].ppqn = v1->clock[i].ppqn; + out->clock[i].phase_mbeats = v1->clock[i].phase_mbeats; + out->clock[i].swing_mbeats = v1->clock[i].swing_mbeats; + } + out->led_enable = v1->led_enable; + out->led_brightness = v1->led_brightness; + out->led_mode = v1->led_mode; + out->led_fade = v1->led_fade; + out->led_beat_color = v1->led_beat_color; + out->led_accent_color = v1->led_accent_color; + out->follow_beat_enable = v1->follow_beat_enable; +} + +// ESP-037: the v3 layout, FROZEN -- the KsConfig that shipped right before tempo. It is +// byte-identical to the first (sizeof(KsConfig)-4) bytes of the live v4 struct, since +// tempo_mbpm was appended at the end, so the migration is a copy plus the one default. +// The size assert is the tripwire: if the live ClockOutputCfg/WifiCred ever change, this +// stops being 436 and the build breaks here instead of silently reading a P4's bytes wrong. +typedef struct { + WifiCred wifi[KS_WIFI_SLOTS]; + int clock_out_enable, metronome_enable, metronome_accent, metronome_volume, metronome_voice; + ClockOutputCfg clock[KS_CLOCK_OUTPUTS]; + int led_enable, led_brightness, led_mode, led_fade, led_beat_color, led_accent_color; + int follow_beat_enable; +} KsConfigV3; +_Static_assert(sizeof(KsConfigV3) == 436, "frozen v3 layout must not change (ESP-037 migration)"); + +// v3 -> v4: every field carries across; the new tempo comes up at its default (120 BPM), +// so a migrated P4 free-runs at 120 exactly as it did before tempo was settable. +static void migrate_v3(KsConfig* out, const KsConfigV3* v3) { + ks_config_defaults(out); // tempo_mbpm -> 120000, then the v3 bytes overwrite the rest + memcpy(out, v3, sizeof(*v3)); // v3 layout == first 436 bytes of KsConfig + out->tempo_mbpm = 120000; // the one field v3 never had +} + +// ESP-042: the v4 layout, FROZEN -- KsConfig right before the fleet fields. It is +// V3 + tempo_mbpm = the first (sizeof(KsConfig)-16) bytes of the live v5 struct, since +// the four ESP-042 fields were appended at the end. +typedef struct { + WifiCred wifi[KS_WIFI_SLOTS]; + int clock_out_enable, metronome_enable, metronome_accent, metronome_volume, metronome_voice; + ClockOutputCfg clock[KS_CLOCK_OUTPUTS]; + int led_enable, led_brightness, led_mode, led_fade, led_beat_color, led_accent_color; + int follow_beat_enable; + int tempo_mbpm; +} KsConfigV4; +_Static_assert(sizeof(KsConfigV4) == 440, "frozen v4 layout must not change (ESP-042 migration)"); + +// v4 -> v5: every field carries; the four new fleet fields come up at their defaults +// (quantum 4, transport on, fire-on-touch, LCD 80) -- what the Touch's AppConfig used, +// and harmless on a P4 that ignores them. +static void migrate_v4(KsConfig* out, const KsConfigV4* v4) { + ks_config_defaults(out); // the four ESP-042 fields get their defaults first + memcpy(out, v4, sizeof(*v4)); // v4 layout == first 440 bytes of KsConfig +} + +// P4-014: the single owner of "is this persisted blob safe to load?" — see ks_config.h. +// Every gate is fail-closed: anything we can't positively vouch for becomes defaults, +// because loading a stale layout puts garbage in fields the user never sees until a +// clock output misfires. +ks_decode_result ks_config_decode(KsConfig* out, const void* blob, size_t blob_len, + bool version_present, uint32_t version) { + ks_config_defaults(out); + + // A legacy blob predates the version key: its bytes could be any old layout, + // so no size coincidence earns it a migration. + if (!blob || !version_present) return KS_DECODE_DEFAULTED; + + if (version == 1u && blob_len == sizeof(KsConfigV1)) { + KsConfigV1 v1; + memcpy(&v1, blob, sizeof(v1)); + KsConfig candidate; + migrate_v1(&candidate, &v1); + // A migration is not a licence to skip the guard. + if (!ks_config_valid(&candidate)) return KS_DECODE_DEFAULTED; + *out = candidate; + return KS_DECODE_MIGRATED; + } + + if (version == 3u && blob_len == sizeof(KsConfigV3)) { // ESP-037 + KsConfigV3 v3; + memcpy(&v3, blob, sizeof(v3)); + KsConfig candidate; + migrate_v3(&candidate, &v3); + if (!ks_config_valid(&candidate)) return KS_DECODE_DEFAULTED; + *out = candidate; + return KS_DECODE_MIGRATED; + } + + if (version == 4u && blob_len == sizeof(KsConfigV4)) { // ESP-042 + KsConfigV4 v4; + memcpy(&v4, blob, sizeof(v4)); + KsConfig candidate; + migrate_v4(&candidate, &v4); + if (!ks_config_valid(&candidate)) return KS_DECODE_DEFAULTED; + *out = candidate; + return KS_DECODE_MIGRATED; + } + + if (version != KS_CONFIG_VERSION || blob_len != sizeof(KsConfig)) return KS_DECODE_DEFAULTED; + + // Version and size both vouch for the layout; the bytes still have to be in + // range (bit-rot, or a layout change someone shipped without bumping). + KsConfig candidate; + memcpy(&candidate, blob, sizeof(candidate)); + if (!ks_config_valid(&candidate)) return KS_DECODE_DEFAULTED; + + *out = candidate; + return KS_DECODE_OK; +} diff --git a/X32Link/ks_config.h b/X32Link/ks_config.h new file mode 100644 index 0000000..58bee24 --- /dev/null +++ b/X32Link/ks_config.h @@ -0,0 +1,183 @@ +#pragma once +// Pure KitchenSync runtime config (P4-007): the settings the web UI edits and NVS +// persists. No ESP-IDF dependency — struct + defaults + per-field apply + +// validation, host-tested in test/test_ks_config.c. NVS load/save is the thin +// glue in ks_config_nvs.c. +#include +#include +#include +#ifdef __cplusplus +extern "C" { +#endif + +#define KS_CLOCK_OUTPUTS 4 // Multiclock-style per-output clocks (P4-010) + +// ESP-013: saved WiFi networks. A device that moves between a studio and a home +// network should join whichever is present, not fall to the SoftAP portal at the +// second site. Slot 0 keeps the old single-network semantics (an empty ssid there +// on a fresh board selects config mode), so nothing downstream had to change. +// +// Must not exceed WIFI_CONN_MAX_SLOTS: the connection policy splits one fixed +// association budget across the slots, and too many makes each share too short to +// associate. +#define KS_WIFI_SLOTS 3 + +typedef struct { + char ssid[33]; // empty => slot unused (skipped, never attempted) + char pass[64]; +} WifiCred; + +// P4-014: schema version of the PERSISTED config. Bump on ANY change to +// KsConfig's layout — added, removed, reordered, or resized field. The +// _Static_assert below turns "forgot to bump it" into a compile error. +// +// Deliberately NOT a field inside KsConfig: on the first upgrade from an +// unversioned blob, an in-struct version would read the old blob's first four +// bytes (wifi[0].ssid[0..3] — arbitrary ASCII) and could alias a plausible +// version. Stored under its own NVS key instead, where "absent" is an +// unambiguous "legacy, don't trust these bytes". See ks_config_nvs.c. +// +// v2 (ESP-013): single wifi_ssid/wifi_pass became wifi[KS_WIFI_SLOTS]. +// A v1 blob is MIGRATED, not discarded — a version bump that silently forgot +// the user's network would inflict exactly the SoftAP re-setup this feature +// exists to abolish. See ks_config_decode(). +// v4 (ESP-037): a settable free-run tempo (tempo_mbpm). Drives the clock when SOLO; a +// Link session still wins. A v3 blob is MIGRATED, never discarded. +// v5 (ESP-042): KsConfig becomes the FLEET config -- the Touch retires its own AppConfig +// and stores this. It brings four fields that were AppConfig-only: quantum_beats, +// transport_enable, play_on_release, lcd_brightness. The P4 defaults them (it hardcodes +// its quantum and has no LCD); a v4 blob is MIGRATED, never discarded. +#define KS_CONFIG_VERSION 5u + +// One configurable clock output (routed to a USB-MIDI cable). Division, phase and +// swing are the E-RM-Multiclock-style controls. +typedef struct { + int enable; // 0/1 — emit this output + int cable; // 0..3 — USB-MIDI virtual cable (Midihub USB A..D) + int ppqn; // pulses per beat: 1=1/4, 2=1/8, 4=1/16, 24=MIDI clock, 48=×2 (1..48) + int phase_mbeats; // phase nudge, signed milli-beats (±250 = ±1/4 beat) for latency comp + int swing_mbeats; // swing/shuffle, 0..250 milli-beats of off-eighth delay (0 = straight, P4-013) + int follow_link; // 0/1 — who owns THIS output's transport. 1 (default): the Link + // session drives it once the session publishes StartStopState. + // 0: it's manual — the web toggle starts/stops it and the + // session's transport never touches it. The arbitration's + // invariant is exactly ONE master per output: two masters is + // how "why did my gear stop" happens (a manual START stomped + // by the session's stopped state), so this picks the master + // rather than letting both write. +} ClockOutputCfg; + +typedef struct { + WifiCred wifi[KS_WIFI_SLOTS]; // ESP-013; wifi[0].ssid empty => SoftAP config mode + int clock_out_enable; // 0/1 — master gate for all clock outputs + int metronome_enable; // 0/1 — click the onboard speaker on each beat (P4-006) + int metronome_accent; // 0/1 — accent the bar-1 downbeat (louder/higher) + int metronome_volume; // 0..100 — ES8311 codec volume (P4-012) + int metronome_voice; // 0..2 — click voice preset Tone/Click/Wood (P4-012) + ClockOutputCfg clock[KS_CLOCK_OUTPUTS]; // P4-010 per-output clocks + int led_enable; // 0/1 — visual metronome on the WS2812 strip (P4-018); + // independent of metronome_enable + int led_brightness; // 0..100 — master strip brightness % (P4-019) + int led_mode; // 0..2 — pattern: 0 chase, 1 flash, 2 fill + int led_fade; // 0..100 — dim across a beat % (0 steady .. 100 dark by next) + int led_beat_color; // 0xRRGGBB — colour for beats 2..N + int led_accent_color; // 0xRRGGBB — colour for the bar-1 downbeat + int follow_beat_enable; // 0/1 -- mic-based tempo detection (P4-020, display only) + + // ESP-037: settable free-run tempo, milli-BPM (128000 = 128.000 BPM). Drives the + // clock when SOLO -- a Link session still wins (master_clock_arbiter). Milli so tap + // tempo's fractional BPM survives. Range MASTER_CLOCK_BPM_MIN..MAX * 1000. + int tempo_mbpm; + + // ESP-042: fields the Touch's AppConfig owned before the fleet converged on ONE + // config. The P4 defaults them today (it hardcodes its quantum, drives transport per + // output via follow_link, and has no touch screen or LCD) -- they are here so a + // single struct, migration, and live-safe list serve both products. + int quantum_beats; // launch/phase quantize in Link beats (1..64). Web UI edits + // BARS (x4). The Touch's transport arms to this; the P4 + // currently hardcodes 4 (KS_TICK_METRO_QUANTUM). + int transport_enable; // 0/1 -- allow PLAY/STOP from the device's own UI (Touch). + int play_on_release; // 0 = fire on touch, 1 = on release (Touch turntable feel). + int lcd_brightness; // 10..100 -- LCD backlight %, distinct from led_brightness + // (the WS2812 strip). The Touch has a screen; the P4 does not. +} KsConfig; + +// P4-014 tripwire. sizeof alone can't detect a same-size field reorder, so it is +// not the safety mechanism — KS_CONFIG_VERSION is. This assert exists to make +// editing the struct impossible without noticing the version constant above. +// When it fires: bump KS_CONFIG_VERSION, then update the size here. +// +// ESP-030: this header is now SHARED, so it compiles as C (ESP-IDF, the pure modules, +// the host tests) AND as C++ (the Arduino sketch and its web glue). The assert has to +// spell itself both ways: C11 says _Static_assert, C++ says static_assert, and g++ +// rejects the C spelling outright. Same dodge app_config.h already uses. +#ifdef __cplusplus +# define KS_CONFIG_STATIC_ASSERT(cond, msg) static_assert(cond, msg) +#else +# define KS_CONFIG_STATIC_ASSERT(cond, msg) _Static_assert(cond, msg) +#endif +KS_CONFIG_STATIC_ASSERT(sizeof(KsConfig) == 456, + "KsConfig layout changed: bump KS_CONFIG_VERSION, then fix this size (P4-014)"); + +void ks_config_defaults(KsConfig* c); + +// True if every field is in range. An empty ssid is valid (it selects SoftAP +// config mode), so callers check ssid separately to decide station vs AP. +bool ks_config_valid(const KsConfig* c); + +// ESP-013: compact the saved networks into the order they should be tried, +// dropping empty slots. Returns the count written to `out` (0..KS_WIFI_SLOTS). +// +// This is where "skip an empty slot" lives, so wifi_conn_policy never needs to +// know an SSID exists: the glue hands it this count and walks the compacted list. +int ks_config_wifi_slots(const KsConfig* c, WifiCred out[KS_WIFI_SLOTS]); + +// Apply one web form field (key,value). Returns true if the key is known and the +// value passed validation (config updated); false for an unknown key or an +// out-of-range value (config left unchanged). An empty wifi_pass is a no-op +// "keep current" (returns true), so a blank password field never wipes the +// saved one. +// +// ESP-013 keys: "wifi_ssid"/"wifi_pass" address slot 0 (unchanged), and +// "wifi_ssid"/"wifi_pass" address slot N for N in 1..KS_WIFI_SLOTS-1. +// Setting a slot's ssid to "" FORGETS that network: the password is cleared with +// it, so a forgotten slot can never be retried with a stale password. +bool ks_config_set(KsConfig* c, const char* key, const char* value); + +// ARC-016: the one owner of "which fields apply live" (no reboot). Copies exactly the +// live-safe fields from a validated candidate into the running config; the /live +// handler calls this instead of an inline field list. WiFi creds and any reboot-only +// field are deliberately excluded (those go through Save + reboot). +void ks_config_live_safe_copy(KsConfig* dst, const KsConfig* src); + +// P4-014: did we load the persisted config, or fall back to defaults? +typedef enum { + KS_DECODE_OK, // blob accepted verbatim + KS_DECODE_DEFAULTED, // blob absent/legacy/wrong-version/wrong-size/invalid + KS_DECODE_MIGRATED, // ESP-013: an older version was upgraded in place +} ks_decode_result; + +// The one owner of "is this persisted blob safe to load?". *out always ends up +// a valid config: either the blob (when it clears every gate) or clean defaults. +// +// The blob is accepted verbatim ONLY when all of these hold: +// version_present — the NVS "ver" key existed (a legacy blob predates it) +// version == KS_CONFIG_VERSION +// blob_len == sizeof(KsConfig) +// ks_config_valid(blob) +// +// A KNOWN older version whose size matches that version's frozen layout is +// migrated instead of discarded (KS_DECODE_MIGRATED). Only v1 -> v2 exists so +// far. A migration must never widen what is accepted: the migrated result runs +// through ks_config_valid() exactly like a verbatim one, and a v1 blob of the +// wrong size is still defaults. +// +// Otherwise *out is defaults. This is what stops a struct-layout change from +// loading bytes shifted into the wrong fields. Pure — ks_config_nvs.c hands over +// whatever NVS held and does no judging of its own. Host-tested. +ks_decode_result ks_config_decode(KsConfig* out, const void* blob, size_t blob_len, + bool version_present, uint32_t version); + +#ifdef __cplusplus +} +#endif diff --git a/X32Link/ks_config_json.c b/X32Link/ks_config_json.c new file mode 100644 index 0000000..5af8144 --- /dev/null +++ b/X32Link/ks_config_json.c @@ -0,0 +1,89 @@ +#include "ks_config_json.h" +#include +#include + +// Appends one snprintf's worth of text at n, tracking truncation the same way +// ks_status_json()'s chain does: once a step is truncated (or a prior step +// already was), stop calling snprintf again -- its return value on a truncated +// buffer is "what would have fit", not "how much I wrote", so writing further +// from a wrong offset would corrupt what's already in buf. +#define APPEND(...) \ + do { \ + if (truncated) break; \ + int _m = snprintf(buf + n, (n < len) ? (len - n) : 0, __VA_ARGS__); \ + if (_m < 0) { truncated = true; break; } \ + n += (size_t)_m; \ + if (n >= len) truncated = true; \ + } while (0) + +int ks_config_json(char* buf, size_t len, const KsConfig* c, const KsCaps* caps) { + size_t n = 0; + bool truncated = false; + + APPEND("{\"clock_out\":%s", c->clock_out_enable ? "true" : "false"); + + /* ESP-030: hardware that is not fitted is ABSENT from the document, never reported + * false. `led:false` on a board with no strip is the same class of lie as ESP-028's + * `sync:1` over a dead wire -- and a client would draw an LED section for a device + * that cannot light anything. Attach a strip, flip the board flag, and the section + * appears here and in the client with no client change at all. */ + if (caps->metronome) { + APPEND(",\"metronome\":%s,\"metro_accent\":%s,\"metro_vol\":%d,\"metro_voice\":%d", + c->metronome_enable ? "true" : "false", c->metronome_accent ? "true" : "false", + c->metronome_volume, c->metronome_voice); + } + if (caps->led) { + APPEND(",\"led\":%s,\"led_bright\":%d,\"led_mode\":%d,\"led_fade\":%d," + "\"led_beat\":\"#%06X\",\"led_accent\":\"#%06X\"", + c->led_enable ? "true" : "false", c->led_brightness, c->led_mode, c->led_fade, + (unsigned)(c->led_beat_color & 0xFFFFFF), + (unsigned)(c->led_accent_color & 0xFFFFFF)); + } + if (caps->follow_beat) { + APPEND(",\"follow_beat\":%s", c->follow_beat_enable ? "true" : "false"); + } + /* ESP-037: the STORED tempo, decimal BPM. Distinct from /status.bpm (the EFFECTIVE + * tempo -- Link's, when a session is driving). A listener-only box (settable_tempo + * false) says nothing: a tempo it cannot set is a lie like unfitted hardware. */ + if (caps->settable_tempo) { + APPEND(",\"bpm\":%d.%03d", c->tempo_mbpm / 1000, c->tempo_mbpm % 1000); + } + + /* The array LENGTH is the number of credentials this build can actually STORE -- + * never padded (ESP-035). Advertising three slots on a board that holds one makes a + * client offer a second network the device will silently discard, which is exactly + * how the Touch lost the user's second SSID (ESP-030 pt3). Clamped to what the + * struct actually holds. */ + int slots = caps->wifi_slots; + if (slots > KS_WIFI_SLOTS) slots = KS_WIFI_SLOTS; + if (slots < 0) slots = 0; + + APPEND(",\"wifi\":["); + for (int i = 0; i < slots && !truncated; i++) { + APPEND("%s{\"ssid\":\"%s\",\"pass_set\":%s}", i ? "," : "", c->wifi[i].ssid, + c->wifi[i].pass[0] ? "true" : "false"); + } + + /* The array LENGTH is the FITTED output count -- never padded. A client renders a + * card per element, so padding a one-output board to four would draw three dead + * outputs. Clamped to what the struct actually holds. */ + int outputs = caps->outputs; + if (outputs > KS_CLOCK_OUTPUTS) outputs = KS_CLOCK_OUTPUTS; + if (outputs < 0) outputs = 0; + + APPEND("],\"clock\":["); + for (int i = 0; i < outputs && !truncated; i++) { + const ClockOutputCfg* o = &c->clock[i]; + APPEND("%s{\"en\":%s,\"cable\":%d,\"ppqn\":%d,\"phase\":%d,\"swing\":%d,\"follow\":%s}", + i ? "," : "", o->enable ? "true" : "false", o->cable, o->ppqn, o->phase_mbeats, + o->swing_mbeats, o->follow_link ? "true" : "false"); + } + APPEND("]}"); + + // snprintf-style: the accumulated length even when truncated (ks_status_json's + // same convention), so a caller can compare it against `len` to detect + // truncation and, if it cares, retry with a bigger buffer. + return (int)n; +} + +#undef APPEND diff --git a/X32Link/ks_config_json.h b/X32Link/ks_config_json.h new file mode 100644 index 0000000..329aa65 --- /dev/null +++ b/X32Link/ks_config_json.h @@ -0,0 +1,88 @@ +#pragma once +// Pure GET /config.json builder (P4-041) -- the read-side counterpart to +// ks_form_resolve()/ks_form_apply() (ks_form.c, the write side). Field names match +// the POST /save and /live form keys 1:1 (see ks_config_set() in ks_config.c), +// so a client never has to maintain a second name mapping for reading vs. writing. +// +// Exists because /status (ks_status.c) is telemetry only -- bpm, peers, launch +// state -- and never carried the device's actual settings. The only place a +// client could previously read "is output 2 enabled, what's its division" was by +// scraping the rendered HTML of GET / (ks_web.cpp's root_handler), which ties a +// client to the web UI's markup instead of a contract. No existing consumer +// (X32Link, the P4 web UI itself) parses this; it's additive, written for the +// iOS companion app (docs/plans/2026-07-14-ios-kitchensync-app-plan.md). +// +// SECURITY: WiFi passwords are NEVER included -- same rule ks_web.cpp's +// build_wifi() already follows for the HTML form (a saved password is never +// echoed back to the browser, only a "keep current" placeholder). Here that's +// `wifi_pass_set`: true if the slot has a saved password, never the password +// itself, so a client can show "configured" without the secret ever crossing +// LAN HTTP in the read direction either. +#include +#include "ks_config.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// ESP-030: what this board actually HAS. +// +// A device must not report hardware it does not have. Emitting `led:false` on a +// board with no strip is the same class of lie as ESP-028's `sync:1` over a wire +// that had been dead for 138 seconds -- and a client would dutifully draw an LED +// section for a device that cannot light anything. Absent hardware is ABSENT from +// the document, not reported false. +// +// Capabilities are a property of the BUILD, not of the product name. "The Touch has +// no LED" is wrong -- the truth is "this Touch has no strip WIRED UP YET". Attach +// one, flip the board flag, and /config.json starts emitting led_*, and a client's +// settings screen grows an LED section with no client change at all. So this comes +// from the board config, never from a hardcoded product identity. +typedef struct { + bool metronome; // a speaker is fitted (ES8311 / buzzer) + bool led; // a WS2812 strip is wired + bool follow_beat; // a mic is fitted + int outputs; // clock outputs FITTED -- the `clock` array's length. Never padded. + + /* ESP-035: how many wifi credentials this build can actually STORE. + * + * `wifi` used to be hardcoded to KS_WIFI_SLOTS, which is a lie on any board that + * holds fewer. The X32Link holds ONE. Advertise three and a client renders three: + * the user saves a second network, the device's /save reads only `wifi_ssid` and + * throws it away, and the client cheerfully reports success. + * + * Not hypothetical -- that is the bug that cost the user their second network on the + * Touch (ESP-030 pt3), here reachable from the READ side. The array's LENGTH is the + * truth, exactly as it already is for `clock`. */ + int wifi_slots; + + /* ESP-037: can a user SET this box's tempo? True on the clock boxes (P4, Touch), + * which free-run at a settable BPM when solo. False on X32Link -- it is listener- + * only, syncs a mixer to Link, and originates nothing. When false, /config.json + * emits no `bpm` at all: reporting a tempo a device cannot set is the same lie as + * reporting hardware it does not have. */ + bool settable_tempo; +} KsCaps; + +// Formats KsConfig as JSON, emitting ONLY what `caps` says the board has: +// {"clock_out":bool, +// ["metronome":bool,"metro_accent":bool,"metro_vol":N,"metro_voice":N,] // iff caps.metronome +// ["led":bool,"led_bright":N,"led_mode":N,"led_fade":N, +// "led_beat":"#rrggbb","led_accent":"#rrggbb",] // iff caps.led +// ["follow_beat":bool,] // iff caps.follow_beat +// "wifi":[{"ssid":"S","pass_set":bool}, ...], +// "clock":[{"en":bool,"cable":N,"ppqn":N,"phase":N,"swing":N,"follow":bool}, ...]} +// +// `wifi` has caps.wifi_slots entries and `clock` has caps.outputs entries -- the FITTED +// counts, never padded, because a client renders a row/card per element and would +// otherwise draw slots and outputs the device cannot honour. Same order as the form's +// slot/output numbering (slot 0 is the unsuffixed "wifi_ssid"/"clk0_*" keys). +// +// Returns snprintf()'s return value (same truncation-detection convention as +// ks_status_json()) -- a caller comparing it against `len` can tell a truncated +// buffer from a short one. +int ks_config_json(char* buf, size_t len, const KsConfig* c, const KsCaps* caps); + +#ifdef __cplusplus +} +#endif diff --git a/X32Link/ks_status.c b/X32Link/ks_status.c new file mode 100644 index 0000000..667bf65 --- /dev/null +++ b/X32Link/ks_status.c @@ -0,0 +1,101 @@ +#include "ks_status.h" +#include + +/* Appends with snprintf semantics: `*n` accumulates what WOULD have been written, so a + * caller can always detect truncation (ks_web serving half a JSON object silently is + * how a UI just stops updating with no error anywhere). Returns false on encoding error. */ +static bool append(char* buf, size_t len, int* n, const char* fmt, ...) + __attribute__((format(printf, 4, 5))); + +#include +static bool append(char* buf, size_t len, int* n, const char* fmt, ...) { + va_list ap; + va_start(ap, fmt); + int m; + if ((size_t)*n < len) { + m = vsnprintf(buf + *n, len - (size_t)*n, fmt, ap); + } else { + /* Already full: measure only, never write past the end. */ + char scratch[1]; + m = vsnprintf(scratch, 0, fmt, ap); + } + va_end(ap); + if (m < 0) return false; + *n += m; + return true; +} + +int ks_status_json(char* buf, size_t len, float bpm, float midi_bpm, int peers, bool usb, uint32_t tx, + const char* fw, bool follow_enabled, float follow_bpm, float follow_confidence, + bool follow_valid, const int* launch, int launch_count, bool playing, bool link_owns, + const WebTickHealth* tick, const LinkPhaseHealth* phase, + const char* clk, const uint32_t* pulses, const char* extra) { + int n = 0; + if (!append(buf, len,& n, + "{\"bpm\":%.1f,\"min\":%.1f,\"peers\":%d,\"usb\":%s,\"tx\":%lu,\"fw\":\"%s\"," + "\"follow_enabled\":%s,\"follow_bpm\":%.1f,\"follow_confidence\":%.1f,\"follow_valid\":%s," + "\"launch\":[", + bpm, midi_bpm, peers, usb ? "true" : "false", (unsigned long)tx, fw, + follow_enabled ? "true" : "false", follow_bpm, follow_confidence, + follow_valid ? "true" : "false")) return -1; + + /* ESP-029: the array LENGTH is the device's REAL output count -- 4 on KitchenSync, + * 1 on the Touch. Never padded. A client renders a card per element, so padding a + * one-output device out to four would draw three dead outputs. */ + for (int i = 0; i < launch_count; i++) { + if (!append(buf, len, &n, "%s%d", i ? "," : "", launch[i])) return -1; + } + + if (!append(buf, len, &n, "],\"playing\":%s,\"link_owns\":%s", + playing ? "true" : "false", link_owns ? "true" : "false")) return -1; + + /* P4-038: the clock task's own health, in X32Link's exact key names (ARC-024) -- + * one shape across the fleet, so one script audits every device. */ + if (tick) { + if (!append(buf, len, &n, + ",\"drop\":%lu,\"burst\":%lu,\"gap\":%lu,\"work\":%lu,\"over\":%lu,\"core\":%d," + "\"w_beats\":%lu,\"w_clock\":%lu,\"reprime\":%lu", + (unsigned long)tick->dropped, (unsigned long)tick->bursts, + (unsigned long)tick->max_gap_us, (unsigned long)tick->max_work_us, + (unsigned long)tick->overruns, tick->core, + (unsigned long)tick->w_beats, (unsigned long)tick->w_clock, + (unsigned long)tick->reprimes)) return -1; + } + + /* P4-038: phase health. `xf_step` is the number that moves the BAR LINE -- the + * GhostXForm is only an origin, and a commit steps it with no slew. */ + if (phase) { + if (!append(buf, len, &n, + ",\"xf\":%lu,\"xf_step\":%lu,\"xf_max\":%lu,\"rtt_min\":%lu,\"rtt_max\":%lu", + (unsigned long)phase->commits, (unsigned long)phase->last_step_us, + (unsigned long)phase->max_step_us, (unsigned long)phase->rtt_min_us, + (unsigned long)phase->rtt_max_us)) return -1; + } + + /* ESP-028's writer's truth. `clk` is what the WRITER is doing -- locked / free / + * SILENT -- and `pulses` is the lifetime 0xF8 count, so "is the wire actually + * alive?" is answerable by polling instead of by logic analyzer. That is the single + * highest-value diagnostic in the fleet, and the Touch had it while the P4 did not. + * + * NULL means ABSENT, never a default: a device that doesn't measure its writer must + * not claim `clk:"locked"`. That is exactly the lie ESP-028 exists to prevent -- a + * device reporting `sync:1` over a wire that had been dead for 138 seconds. */ + if (clk) { + if (!append(buf, len, &n, ",\"clk\":\"%s\"", clk)) return -1; + } + if (pulses) { + if (!append(buf, len, &n, ",\"pulses\":%lu", (unsigned long)*pulses)) return -1; + } + + /* Device-specific diagnostics, appended INSIDE the object. The Touch's own page reads + * `cue`, `btn`, `beats` and friends, and those must survive the move to this shared + * builder. The SHARED keys stay byte-identical across the fleet (ARC-024) while each + * device can still say more; a client ignores keys it doesn't know, which costs + * nothing. Caller supplies a bare fragment with no braces and no leading comma. */ + if (extra && extra[0]) { + if (!append(buf, len, &n, ",%s", extra)) return -1; + } + + if (!append(buf, len, &n, "}")) return -1; + return n; +} diff --git a/X32Link/ks_status.h b/X32Link/ks_status.h new file mode 100644 index 0000000..0753ca1 --- /dev/null +++ b/X32Link/ks_status.h @@ -0,0 +1,83 @@ +#pragma once +// Pure /status JSON builder for the KitchenSync web UI (P4-007). No ESP-IDF/network +// dependency — just snprintf, host-tested in test/test_ks_status.c. Same +// "pull the formatting out of the server glue" pattern as X32Link's +// web_status_json.c. +#include +#include +#include +#include "web_status_json.h" +#include "link_measurement.h" /* P4-038: LinkPhaseHealth — the origin-step gauge */ /* P4-038: WebTickHealth — the SAME struct and the same + * JSON keys X32Link publishes (ARC-024). One shape across + * the fleet, so one script audits every device. */ +#ifdef __cplusplus +extern "C" { +#endif + +// Formats {"bpm":F,"min":F,"peers":N,"usb":bool,"tx":N,"fw":"S","follow_enabled":bool, +// "follow_bpm":F,"follow_confidence":F,"follow_valid":bool} into buf. `bpm` is the +// live Link session tempo, `min` the detected MIDI-clock-IN tempo (0 when no clock +// in, P4-011), `peers` the Link peer count, `usb` whether a USB-MIDI device is +// ready, `tx` the running clock-pulse count, `fw` the firmware version string +// (LNK-038). `follow_enabled` is the mic tempo-follow feature's config toggle +// (P4-020) -- kept separate from `follow_valid` because both are false when the +// feature is off AND when it's on-but-not-yet-confident; the web UI needs to tell +// those two states apart ("off" vs "listening..."), so it must branch on +// `follow_enabled` first, not infer it from `follow_valid`. `follow_bpm`/ +// `follow_confidence`/`follow_valid` are 0/0/false whenever `follow_enabled` is +// false. Returns snprintf()'s return value so the caller can detect truncation. +// `launch` is the per-output quantized-launch state (ESP-011): 0 stopped, +// 1 armed (waiting for the bar line), 2 running. Emitted as "launch":[a,b,c,d] +// so the UI can render "starting on next bar..." rather than a dead button. +// `playing` is the session's real transport state and `link_owns` is true once a +// Link peer has published StartStopState (ks_tick arbitration): when link_owns is +// true the manual PLAY/STOP buttons are ignored, so the UI greys them and shows +// the session's `playing` state instead of the (frozen) manual launch state. +// `tick` is the 1 ms clock task's health (P4-038), emitted as the same +// "drop"/"burst"/"gap"/"work"/"over"/"core"/"w_beats"/"w_clock" block X32Link +// publishes (ARC-024), so one fleet script reads every device. Pass NULL to omit the +// block entirely -- never publish a row of zeroes, which would read as "measured, all +// clean" when the truth is "not measured". +// +// The numbers are LIFETIME, not windowed. That is the whole point: KitchenSync already +// had this probe and threw it away into a once-a-second log, so a 766 ms clock stall +// (P4-038) left no trace anywhere unless someone happened to have serial attached at +// that moment. A worst-case that scrolled past is not a measurement. +// +// ESP-029: this builder is SHARED. It lives in X32Link/ (the shared-module home) and is +// symlinked into KitchenSyncTouch/, exactly like the 46 other modules the two products +// already share. Both firmwares emit /status from THIS function, so the wire shape is +// identical BY CONSTRUCTION rather than by discipline. +// +// That is the whole point. Before this, ktouch_web.cpp hand-rolled its own snprintf and +// ks_status.c had another, and they drifted: the same nine tick-health fields, spelled +// `wbeats`/`wclock` on one device and `w_beats`/`w_clock` on the other. Nobody chooses +// that. Worse, the same "/status lies about the clock" bug had to be fixed TWICE -- +// P4-039 did it for the P4 and, per ESP-028, "the Touch never got it", which is what +// cost 138 seconds of silent DIN clock. One implementation, one host-test suite, no +// drift. +// +// `launch` / `launch_count`: the array LENGTH is the device's REAL output count -- 4 on +// KitchenSync, 1 on the Touch. NEVER pad. A client renders a card per element, so +// padding a one-output device out to four would draw three dead outputs. +// +// `clk` / `pulses` (ESP-028; optional -- pass NULL to omit): what the WRITER is doing +// ("locked" / "free" / "silent") and the lifetime 0xF8 count. Together they make "is the +// wire actually alive?" answerable by polling instead of with a logic analyzer. NULL +// means ABSENT, never a default -- a device that does not measure its writer must not +// claim `clk:"locked"`, which is precisely the lie ESP-028 exists to prevent. +// +// `extra` (optional -- NULL to omit): device-specific diagnostics, appended INSIDE the +// object. A bare fragment, no braces and no leading comma. The Touch's own page reads +// `cue`, `btn`, `beats` and friends; they ride here so the SHARED keys stay byte-identical +// across the fleet while each device can still say more. A client ignores keys it does not +// know, which costs nothing. +int ks_status_json(char* buf, size_t len, float bpm, float midi_bpm, int peers, bool usb, uint32_t tx, + const char* fw, bool follow_enabled, float follow_bpm, float follow_confidence, + bool follow_valid, const int* launch, int launch_count, bool playing, bool link_owns, + const WebTickHealth* tick, const LinkPhaseHealth* phase, + const char* clk, const uint32_t* pulses, const char* extra); + +#ifdef __cplusplus +} +#endif diff --git a/X32Link/led_output.c b/X32Link/led_output.c new file mode 100644 index 0000000..c1afd44 --- /dev/null +++ b/X32Link/led_output.c @@ -0,0 +1,35 @@ +#include "led_output.h" + +static uint8_t scale8(uint8_t v, double s) { + double x = v * s; + if (x < 0) x = 0; + if (x > 255) x = 255; + return (uint8_t)(x + 0.5); +} + +void led_output_render(const LedSnapshot* snap, const LedCfg* cfg, int npix, RGB out[]) { + // Blank first: covers enable==0 and the STATUS/EFFECT stubs (ESP-048/050), + // so an unimplemented style never silently renders the metronome pattern. + for (int i = 0; i < npix; i++) { out[i].r = out[i].g = out[i].b = 0; } + if (!cfg->enable) return; + + switch (cfg->style) { + case LED_STYLE_METRONOME: { + MetroStripCfg mcfg = { + cfg->beat_color, cfg->accent_color, 100, (uint8_t)cfg->mode, (uint8_t)cfg->fade + }; + metro_strip_render(snap->beats, snap->quantum, npix, &mcfg, out); + double s = cfg->bright / 100.0; + for (int i = 0; i < npix; i++) { + out[i].r = scale8(out[i].r, s); + out[i].g = scale8(out[i].g, s); + out[i].b = scale8(out[i].b, s); + } + break; + } + case LED_STYLE_STATUS: // ESP-048 + case LED_STYLE_EFFECT: // ESP-050 + default: + break; // stub: stays blank until its ticket lands + } +} diff --git a/X32Link/led_output.h b/X32Link/led_output.h new file mode 100644 index 0000000..28b8505 --- /dev/null +++ b/X32Link/led_output.h @@ -0,0 +1,39 @@ +#pragma once +// Pure RGB LED output dispatcher (ESP-046). Given a phase-accurate snapshot and +// a per-output config, it fills an RGB array for one strip. It is the single +// owner of brightness: the METRONOME style renders via metro_strip_render at +// full scale, then bright is applied once over the whole buffer. STATUS and +// EFFECT are stubs for later tickets (ESP-048/050). No I/O, no Arduino/ESP-IDF +// dependency. Host-tested in test/test_led_output.c. +#include "metro_strip.h" // RGB, MetroStripCfg, metro_strip_render + +#ifdef __cplusplus +extern "C" { +#endif + +// Phase-accurate inputs. Increment 1 consumes only beats/quantum (METRONOME); +// transport/link_locked are carried for the STATUS style added later. +typedef struct { + double beats; + int quantum; + int transport; + int link_locked; +} LedSnapshot; + +enum { LED_STYLE_STATUS, LED_STYLE_METRONOME, LED_STYLE_EFFECT }; + +typedef struct { + int enable; + int style; + int bright; // master brightness, 0..100 % + RGB beat_color, accent_color; + int mode; // METRO_STRIP_CHASE / FLASH / FILL + int fade; +} LedCfg; + +// Render `cfg`'s style for `snap` into out[0..npix). enable==0 blanks the strip. +void led_output_render(const LedSnapshot* snap, const LedCfg* cfg, int npix, RGB out[]); + +#ifdef __cplusplus +} +#endif diff --git a/X32Link/led_phase.c b/X32Link/led_phase.c new file mode 100644 index 0000000..3157ebc --- /dev/null +++ b/X32Link/led_phase.c @@ -0,0 +1,19 @@ +// Pure phase-wrap-detection decision for the beat LED — see led_phase.h. +#include "led_phase.h" + +bool led_phase_should_flash(float prev_phase, float phase, bool valid) { + if (!valid) return false; + if (prev_phase < 0.0f) return false; // first reading since (re)sync: seed only + return phase < prev_phase; // wrapped: near-1 -> near-0 +} + +uint32_t led_flash_rgb(uint32_t beat_rgb, uint32_t accent_rgb, float bar_phase, + bool bar_valid, uint8_t bright) { + // >= 0 guard: (int) truncation would map the -1.0f "no reading" sentinel + // family (-0.x) to beat 0 and falsely accent it. + uint32_t c = (bar_valid && bar_phase >= 0.0f && (int)bar_phase == 0) ? accent_rgb : beat_rgb; + uint32_t r = ((c >> 16) & 0xFF) * bright / 255; + uint32_t g = ((c >> 8) & 0xFF) * bright / 255; + uint32_t b = ( c & 0xFF) * bright / 255; + return (r << 16) | (g << 8) | b; +} diff --git a/X32Link/led_phase.h b/X32Link/led_phase.h new file mode 100644 index 0000000..497c2ab --- /dev/null +++ b/X32Link/led_phase.h @@ -0,0 +1,37 @@ +// Pure phase-wrap-detection decision for the beat LED (LNK-021). +// +// Source-agnostic: led_task() in X32Link.ino polls tempo_source_phase(1.0f) +// every 5ms and needs to know whether that poll just crossed a beat +// boundary (phase wrapped from near-1 back to near-0) — this is that +// decision, pulled out so it's host-testable without dragging in +// FreeRTOS/Arduino. No relation to link_phase.c, which is Link-adapter-only +// beats/timeline math; this file knows nothing about Link or MIDI. +#pragma once +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +// Should the LED flash on this poll? `prev_phase` is the phase reading +// from the previous poll (-1.0f means "no prior reading since (re)sync" — +// the caller must seed it that way on the invalid->valid transition so the +// first valid reading never falsely flashes). `phase` is this poll's +// reading. `valid` mirrors tempo_source_phase_valid() — when false the +// answer is always false (the caller should be running the fallback blink +// instead, not calling this at all, but the gate is here too so the +// decision is fully described by its inputs). +bool led_phase_should_flash(float prev_phase, float phase, bool valid); + +// LNK-039: which colour should this flash be, at what level? Picks the +// accent for the bar downbeat (int part of `bar_phase` == 0, same rule as +// the touch dot) when `bar_valid`, else the beat colour; then scales each +// channel by bright/255 (40 reproduces the old hardcoded NeoPixel level). +// Colours are 0xRRGGBB, matching dot_beat_color/dot_accent_color. +uint32_t led_flash_rgb(uint32_t beat_rgb, uint32_t accent_rgb, float bar_phase, + bool bar_valid, uint8_t bright); + +#ifdef __cplusplus +} +#endif diff --git a/X32Link/link_measure_pump.c b/X32Link/link_measure_pump.c new file mode 100644 index 0000000..c00e505 --- /dev/null +++ b/X32Link/link_measure_pump.c @@ -0,0 +1,87 @@ +// Pure-ish Link measurement poll — see link_measure_pump.h (ARC-014). No sockets of +// its own; all I/O goes through the LinkMeasureOps vtable, so the poll sequence is +// host-testable with a fake ops. +#include "link_measure_pump.h" +#include "link_measurement.h" +#include "link_protocol.h" + +static void run_action(const LinkMeasureOps* ops, const LinkSessionAct* a) { + switch (a->type) { + case LS_FLUSH_RX: { + uint8_t junk[128]; + while (ops->recv_one(junk, sizeof(junk)) > 0) { /* discard stale pongs */ } + break; + } + case LS_START_ATTEMPT: link_measurement_attempt_begin(); break; + case LS_SEND_PING: { + uint8_t buf[LINK_MEASUREMENT_PING_MAX_LEN]; + int n = link_measurement_build_ping(buf, sizeof(buf), a->send_time_us, + a->has_prev_ghost, a->prev_ghost_us); + if (n > 0) ops->send_to(a->ip, a->port, buf, n); + break; + } + case LS_END_OK: link_measurement_attempt_end(true); break; + case LS_END_FAIL: link_measurement_attempt_end(false); break; + /* ESP-028: invalidate, do NOT full-reset. The full reset also blanks the P4-038 + * phase-health gauge, and a peer churn fires this -- so routing it through + * link_measurement_reset() would wipe the lifetime origin-step history on every + * peer bounce, i.e. exactly when it is worth reading. */ + case LS_RESET_XFORM: link_measurement_invalidate_xform(); break; + } +} + +static void run_all(const LinkMeasureOps* ops, const LinkSessionAct* acts, int n) { + for (int i = 0; i < n; i++) run_action(ops, &acts[i]); +} + +void link_measure_pump(LinkSession* session, const LinkMeasureOps* ops) { + // ESP-028: sized from the session's own bound, not a literal. on_trigger now emits up to + // 4 actions (RESET_XFORM + FLUSH_RX + START_ATTEMPT + SEND_PING) and the emitters drop + // the TAIL when the buffer is short — an under-sized array here would silently swallow + // the ping, i.e. the attempt would never leave the box. + LinkSessionAct acts[LINK_SESSION_MAX_ACTIONS]; + const int MAXA = LINK_SESSION_MAX_ACTIONS; + int n; + + // 1. Epoch reset (ARC-011): settled-timeline debounce confirmed a re-origin. + if (link_proto_epoch_reset_pending()) { + n = link_session_on_epoch_reset(session, acts, MAXA); + run_all(ops, acts, n); + } + + // 2. Trigger: first peer with an advertised mep4 endpoint. + uint32_t ip = 0; uint16_t port = 0; bool found = false; + int peer_count = link_proto_peers(); + for (int i = 0; i < peer_count && !found; i++) { + if (link_proto_peer_endpoint(i, &ip, &port)) found = true; + } + n = link_session_on_trigger(session, found, ip, port, ops->now_us(), + link_measurement_active(), acts, MAXA); + run_all(ops, acts, n); + + if (!link_measurement_active()) return; + + // 3. Drain pongs; the session decides commit-vs-reping per pong. + uint8_t buf[128]; + int len; + while ((len = ops->recv_one(buf, sizeof(buf))) > 0) { + int64_t h_recv = ops->now_us(); + + LinkPongFields fields; + if (!link_measurement_parse_pong(buf, len, &fields)) continue; + + link_measurement_add_pong_samples(h_recv, &fields); + n = link_session_on_pong(session, h_recv, fields.has_ghost_time, fields.ghost_time_us, + link_measurement_samples_count(), acts, MAXA); + bool ended = false; + for (int i = 0; i < n; i++) { + run_action(ops, &acts[i]); + if (acts[i].type == LS_END_OK) ended = true; + } + if (ended) return; // attempt committed this poll; nothing left to drive + } + + // 4. Silence watchdog. + n = link_session_on_watchdog(session, ops->now_us(), acts, MAXA); + run_all(ops, acts, n); +} diff --git a/X32Link/link_measure_pump.h b/X32Link/link_measure_pump.h new file mode 100644 index 0000000..9f8f58f --- /dev/null +++ b/X32Link/link_measure_pump.h @@ -0,0 +1,32 @@ +#pragma once +// ARC-014: the Link measurement poll, once. The two boards' glue +// (KitchenSync/main/link_measure_io.c, X32Link/link_measurement_io.cpp) were ~90% +// identical — the same run_action dispatch and the same 4-step poll (epoch reset, +// trigger, drain pongs, watchdog), copied. The only genuine per-target difference is +// three socket primitives. This module owns the poll; each board supplies a +// LinkMeasureOps vtable. The sequence now exists in one host-testable place instead of +// drifting between copies. +#include +#include "link_measurement_session.h" // LinkSession + +#ifdef __cplusplus +extern "C" { +#endif + +typedef struct { + // Send a prebuilt ping datagram to the peer's advertised endpoint (ip is wire-order + // be32, port host-order — as from link_proto_peer_endpoint()). + void (*send_to)(uint32_t ip, uint16_t port, const uint8_t* buf, int len); + // Read one pending datagram into buf (<= cap); returns <= 0 when nothing queued. + int (*recv_one)(uint8_t* buf, int cap); + // Monotonic microsecond clock (esp_timer_get_time() on both boards). + int64_t (*now_us)(void); +} LinkMeasureOps; + +// Drive one measurement poll against `ops`. Owns the action dispatch + the 4-step +// sequence; `session` is the per-board LinkSession. Assumes the socket is already open. +void link_measure_pump(LinkSession* session, const LinkMeasureOps* ops); + +#ifdef __cplusplus +} +#endif diff --git a/X32Link/link_measurement.c b/X32Link/link_measurement.c new file mode 100644 index 0000000..435f820 --- /dev/null +++ b/X32Link/link_measurement.c @@ -0,0 +1,328 @@ +// Pure logic for the Ableton Link measurement (ping/pong) protocol. +// No WiFi/Arduino dependency — testable on host. See link_measurement.h for +// the wire format and algorithm references. + +#include "link_measurement.h" +#include +#include +#include + +static const uint8_t MAGIC[8] = {'_','l','i','n','k','_','v', 1}; + +static const uint8_t MSG_PING = 1; +static const uint8_t MSG_PONG = 2; + +static const uint32_t HOST_TIME_KEY = 0x5f5f6874u; // '__ht' +static const uint32_t GHOST_TIME_KEY = 0x5f5f6774u; // '__gt' +static const uint32_t PREV_GHOST_KEY = 0x5f706774u; // '_pgt' +// SessionMembership ('sess') is present in real pongs but ignored here — +// we don't track session identity changes in this path. + +/* ---------------------------------------------------------------------- */ +/* Byte helpers (BE), same conventions as link_protocol.c */ +/* ---------------------------------------------------------------------- */ + +static int64_t be64(const uint8_t* p) { + int64_t v = 0; + for (int i = 0; i < 8; i++) v = (v << 8) | p[i]; + return v; +} + +static void put_be32(uint8_t* p, uint32_t v) { + p[0] = (uint8_t)(v >> 24); p[1] = (uint8_t)(v >> 16); + p[2] = (uint8_t)(v >> 8); p[3] = (uint8_t)v; +} + +static uint32_t be32(const uint8_t* p) { + return ((uint32_t)p[0] << 24) | ((uint32_t)p[1] << 16) | + ((uint32_t)p[2] << 8) | (uint32_t)p[3]; +} + +static void put_be64(uint8_t* p, int64_t v) { + for (int i = 7; i >= 0; i--) { p[i] = (uint8_t)v; v >>= 8; } +} + +// Append one TLV (key + 8-byte int64 value) at buf[i], return new offset. +static int put_tlv_i64(uint8_t* buf, int i, uint32_t key, int64_t value) { + put_be32(buf + i, key); i += 4; + put_be32(buf + i, 8); i += 4; + put_be64(buf + i, value); i += 8; + return i; +} + +/* ---------------------------------------------------------------------- */ +/* GhostXForm */ +/* ---------------------------------------------------------------------- */ + +int64_t link_ghost_xform_host_to_ghost(LinkGhostXForm xform, int64_t host_us) { + return host_us + xform.intercept_us; +} + +/* ---------------------------------------------------------------------- */ +/* Ping build/parse */ +/* ---------------------------------------------------------------------- */ + +int link_measurement_build_ping(uint8_t* buf, int buf_cap, + int64_t host_time_us, + bool has_prev_ghost, int64_t prev_ghost_us) { + if (!buf) return 0; + int need = 8 + 1 + 16 + (has_prev_ghost ? 16 : 0); + if (buf_cap < need) return 0; + + int i = 0; + memcpy(buf, MAGIC, 8); i += 8; + buf[i++] = MSG_PING; + i = put_tlv_i64(buf, i, HOST_TIME_KEY, host_time_us); + if (has_prev_ghost) i = put_tlv_i64(buf, i, PREV_GHOST_KEY, prev_ghost_us); + return i; +} + +bool link_measurement_parse_ping(const uint8_t* buf, int len, LinkPingFields* out) { + if (!buf || len < 9) return false; + if (memcmp(buf, MAGIC, 8) != 0) return false; + if (buf[8] != MSG_PING) return false; + if (out) memset(out, 0, sizeof(*out)); + + const uint8_t* p = buf + 9; + const uint8_t* end = buf + len; + while (p + 8 <= end) { + uint32_t key = be32(p); p += 4; + uint32_t size = be32(p); p += 4; + if (p + size > end) break; + if (key == HOST_TIME_KEY && size >= 8 && out) { + out->has_host_time = true; + out->host_time_us = be64(p); + } else if (key == PREV_GHOST_KEY && size >= 8 && out) { + out->has_prev_ghost_time = true; + out->prev_ghost_time_us = be64(p); + } + p += size; + } + return true; +} + +/* ---------------------------------------------------------------------- */ +/* Pong parse */ +/* ---------------------------------------------------------------------- */ + +bool link_measurement_parse_pong(const uint8_t* buf, int len, LinkPongFields* out) { + if (!buf || len < 9) return false; + if (memcmp(buf, MAGIC, 8) != 0) return false; + if (buf[8] != MSG_PONG) return false; + if (out) memset(out, 0, sizeof(*out)); + + const uint8_t* p = buf + 9; + const uint8_t* end = buf + len; + while (p + 8 <= end) { + uint32_t key = be32(p); p += 4; + uint32_t size = be32(p); p += 4; + if (p + size > end) break; + if (key == GHOST_TIME_KEY && size >= 8 && out) { + out->has_ghost_time = true; + out->ghost_time_us = be64(p); + } else if (key == HOST_TIME_KEY && size >= 8 && out) { + out->has_host_time = true; + out->host_time_us = be64(p); + } else if (key == PREV_GHOST_KEY && size >= 8 && out) { + out->has_prev_ghost_time = true; + out->prev_ghost_time_us = be64(p); + } + // unknown keys (e.g. SessionMembership 'sess') skipped via p += size below + p += size; + } + return true; +} + +/* ---------------------------------------------------------------------- */ +/* Sample buffer / median */ +/* ---------------------------------------------------------------------- */ + +static double s_samples[LINK_MEASUREMENT_MAX_SAMPLES]; +static int s_sample_count = 0; +static int s_sample_next = 0; // ring write cursor + +void link_measurement_samples_reset(void) { + s_sample_count = 0; + s_sample_next = 0; +} + +int link_measurement_samples_count(void) { return s_sample_count; } + +static void push_sample(double v) { + s_samples[s_sample_next] = v; + s_sample_next = (s_sample_next + 1) % LINK_MEASUREMENT_MAX_SAMPLES; + if (s_sample_count < LINK_MEASUREMENT_MAX_SAMPLES) s_sample_count++; +} + +/* P4-038 phase-health counters. Lifetime; reset only by link_measurement_reset(). */ +static uint32_t s_ph_commits; +static uint32_t s_ph_last_step_us; +static uint32_t s_ph_max_step_us; +static uint32_t s_ph_rtt_min_us = UINT32_MAX; +static uint32_t s_ph_rtt_max_us; + +void link_measurement_phase_health(LinkPhaseHealth* out) { + if (!out) return; + out->commits = s_ph_commits; + out->last_step_us = s_ph_last_step_us; + out->max_step_us = s_ph_max_step_us; + out->rtt_min_us = (s_ph_rtt_min_us == UINT32_MAX) ? 0 : s_ph_rtt_min_us; + out->rtt_max_us = s_ph_rtt_max_us; +} + +/* P4-038: the RTT that produced each sample, so the commit can throw out the queued ones. */ +static uint32_t s_sample_rtt[LINK_MEASUREMENT_MAX_SAMPLES]; + +int link_measurement_add_pong_samples(int64_t h_recv, const LinkPongFields* pong) { + if (!pong || !pong->has_ghost_time || !pong->has_host_time) return 0; + + // LNK-026 bug 2: drop a pong whose round trip is negative or implausibly + // large — it's a stale/mismatched reply (e.g. left in the RX buffer across + // the idle gap between attempts), and its ~2s-old echoed host_time would + // poison the median by ~1s (~2 beats). See LINK_MEASUREMENT_MAX_RTT_US. + int64_t rtt = h_recv - pong->host_time_us; + if (rtt < 0 || rtt > LINK_MEASUREMENT_MAX_RTT_US) return 0; + + /* P4-038: the RTT of an ACCEPTED sample is the direct predictor of how far this + * commit can throw the origin -- the midpoint estimate below assumes a symmetric + * delay, so it carries ~rtt/2 of error when the delay is not. */ + if ((uint32_t)rtt < s_ph_rtt_min_us) s_ph_rtt_min_us = (uint32_t)rtt; + if ((uint32_t)rtt > s_ph_rtt_max_us) s_ph_rtt_max_us = (uint32_t)rtt; + + int added = 0; + double g = (double)pong->ghost_time_us; + double h_sent = (double)pong->host_time_us; + double h_r = (double)h_recv; + + double sample_a = g - (h_r + h_sent) / 2.0; + if (s_sample_count < LINK_MEASUREMENT_MAX_SAMPLES) s_sample_rtt[s_sample_count] = (uint32_t)rtt; + push_sample(sample_a); + added++; + + if (pong->has_prev_ghost_time) { + double prev_g = (double)pong->prev_ghost_time_us; + double sample_b = (g + prev_g) / 2.0 - h_sent; + if (s_sample_count < LINK_MEASUREMENT_MAX_SAMPLES) s_sample_rtt[s_sample_count] = (uint32_t)rtt; + push_sample(sample_b); + added++; + } + + return added; +} + +static int cmp_double(const void* a, const void* b) { + double da = *(const double*)a, db = *(const double*)b; + return (da > db) - (da < db); +} + +bool link_measurement_median(double* out_median) { + if (s_sample_count == 0) return false; + + /* P4-038: only the low-RTT samples vote. A sample delayed well past the best one in + * this attempt was queued, so its midpoint estimate is asymmetric, so it is biased by + * ~rtt/2 -- and that bias is what threw the origin 500 ms on the bench. Every sample + * at the same RTT (the host tests, and an idle radio) survives the filter unchanged. */ + uint32_t best = UINT32_MAX; + for (int i = 0; i < s_sample_count; i++) + if (s_sample_rtt[i] < best) best = s_sample_rtt[i]; + + double tmp[LINK_MEASUREMENT_MAX_SAMPLES]; + int n = 0; + for (int i = 0; i < s_sample_count; i++) + if (s_sample_rtt[i] <= best + LINK_MEASUREMENT_RTT_SLACK_US) tmp[n++] = s_samples[i]; + if (n == 0) return false; /* cannot happen: the best sample always survives */ + + qsort(tmp, (size_t)n, sizeof(double), cmp_double); + double m = (n % 2 == 1) ? tmp[n / 2] + : (tmp[n / 2 - 1] + tmp[n / 2]) / 2.0; + if (out_median) *out_median = m; + return true; +} + +/* ---------------------------------------------------------------------- */ +/* Attempt lifecycle */ +/* ---------------------------------------------------------------------- */ + +static bool s_active = false; +static LinkGhostXForm s_xform = {0, false}; + +void link_measurement_reset(void) { + link_measurement_invalidate_xform(); + /* P4-038: the gauge is module state too. A reset that leaves it behind reports a step + * count from a previous life -- ks_tick_reset forgot exactly this and the host test + * caught it as a phantom +1. This is the FULL reset (boot); see + * link_measurement_invalidate_xform() for why a peer churn must NOT come through here. */ + s_ph_commits = 0; + s_ph_last_step_us = 0; + s_ph_max_step_us = 0; + s_ph_rtt_min_us = UINT32_MAX; + s_ph_rtt_max_us = 0; +} + +/* ESP-028: drop the MAPPING without wiping the GAUGE. + * + * A peer churn must invalidate the committed xform -- serving a dead session's origin as a + * valid phase estimate is what silenced the DIN wire for 138 seconds (ESP-027). But it must + * NOT zero the P4-038 phase-health counters, which are documented as lifetime: a churn is + * precisely the moment you want the history of how far commits have been throwing the origin. + * Routing LS_RESET_XFORM through the full reset would blank the gauge on every peer bounce. + * + * Safe to keep the gauge across this: `valid` is now false, so the next commit computes no + * step against the dead origin (see attempt_end) -- there is no "previous life" contamination + * to inherit, which is the only thing the full reset was protecting against. */ +void link_measurement_invalidate_xform(void) { + link_measurement_samples_reset(); + s_active = false; + s_xform.intercept_us = 0; + s_xform.valid = false; +} + +void link_measurement_attempt_begin(void) { + link_measurement_samples_reset(); + s_active = true; +} + +void link_measurement_attempt_end(bool success) { + if (success) { + double m; + if (link_measurement_median(&m)) { + int64_t next = (int64_t)llround(m); + + if (s_xform.valid) { + int64_t d = next - s_xform.intercept_us; + int64_t mag = d < 0 ? -d : d; + + /* P4-038: gauge the step the MEASUREMENT ASKED FOR, before the clamp trims + * it. That is the diagnostic: it says how badly the estimate is behaving + * even when the slew is successfully protecting the bar line. Gauging the + * APPLIED move instead would just report the clamp back to us, and the + * moment the fix worked we would go blind to the thing it is fixing. */ + s_ph_last_step_us = (uint32_t)mag; + if ((uint32_t)mag > s_ph_max_step_us) s_ph_max_step_us = (uint32_t)mag; + + /* SLEW: bound how far one commit may move an ESTABLISHED origin. A genuine + * re-origin (>= 1 s; link_phase.c has seen ~510 s) is adopted whole -- the + * clamp rejects measurement noise, it does not fight the session. */ + if (mag < LINK_MEASUREMENT_REORIGIN_US && mag > LINK_MEASUREMENT_MAX_SLEW_US) { + next = s_xform.intercept_us + + (d > 0 ? LINK_MEASUREMENT_MAX_SLEW_US : -LINK_MEASUREMENT_MAX_SLEW_US); + } + } + s_ph_commits++; + s_xform.intercept_us = next; + s_xform.valid = true; + } + // empty sample buffer despite a "success" call: leave existing + // xform (if any) untouched rather than committing garbage. + } + s_active = false; +} + +LinkGhostXForm link_measurement_current_xform(void) { return s_xform; } +bool link_measurement_active(void) { return s_active; } + +// A committed xform is our phase estimate. Deliberately independent of +// s_active: an attempt in flight never makes phase valid, and a committed +// estimate stays trustworthy across the next attempt (which leaves it +// untouched on failure). See ARC-002. +bool link_measurement_have_phase_estimate(void) { return s_xform.valid; } diff --git a/X32Link/link_measurement.h b/X32Link/link_measurement.h new file mode 100644 index 0000000..2cf939f --- /dev/null +++ b/X32Link/link_measurement.h @@ -0,0 +1,229 @@ +// Ableton Link measurement protocol — pure logic (LNK-018). +// +// Pinger-only client: ping a peer's `mep4` unicast endpoint (discovered by +// LNK-017's link_proto_peer_endpoint()), exchange a few ping/pong round +// trips, and derive this device's clock offset relative to the session's +// shared virtual clock (GHostTime). We never implement the PingResponder +// (reply) side — this firmware never broadcasts Alive/Response packets, so +// no peer will ever ping us back. +// +// Wire format (big-endian), distinct from link_protocol.c's discovery +// header: +// magic = "_link_v\x01" (8 bytes) +// messageType = uint8 kPing=1, kPong=2 +// ... TLV payload: key(4 BE) + size(4 BE) + value(size), same envelope +// convention as link_protocol.c's discovery TLVs ... +// +// No WiFi/Arduino dependency here — testable on host. See +// link_measurement_io.cpp for the thin WiFiUDP unicast glue that drives +// this module on real hardware. +#pragma once +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +// Cap of accumulated offset samples before we start evicting the oldest +// (ring buffer). Ticket calls 32 "enough for our purposes" — official Link +// SDK accumulates up to 100 but that's conservative, not a protocol +// minimum. +#define LINK_MEASUREMENT_MAX_SAMPLES 32 + +// Sample count at which an in-flight attempt is considered "good enough" to +// commit a GhostXForm from (io.cpp decides when to stop pinging once it +// reaches this many accumulated samples). Not specified numerically by the +// ticket ("see Acceptance Criteria for the exact target" — AC doesn't +// actually give one); picked as a judgment call: by round 4 a pinger with +// PrevGHostTime echoing has produced ~7-8 samples (2 per round after round +// 1), which is enough for a stable median while still converging in well +// under the 250ms failure window at LAN RTT speed. +#define LINK_MEASUREMENT_READY_SAMPLES 8 + +// Worst-case encoded length of a Ping message (magic + type + HostTime TLV +// + PrevGHostTime TLV). +#define LINK_MEASUREMENT_PING_MAX_LEN 41 + +// LNK-026 (bug 2): reject pong samples whose round trip (h_recv - echoed +// host_time) is negative or exceeds this bound. A stale pong left in the RX +// buffer across the idle gap between attempts echoes a host_time ~one +// remeasure interval (2 s) old; committing it underestimates the GhostXForm by +// ~rtt/2 (~1 s ~= 2 beats), skewing phase. The watchdog abandons an attempt +// after 5*50ms = 250ms of silence, so any pong older than that is by +// definition not part of a live exchange — 250ms is the principled cutoff. +#define LINK_MEASUREMENT_MAX_RTT_US 250000 + +/* P4-038. The offset estimate is `ghost - (h_recv + h_sent)/2` -- the MIDPOINT, which + * assumes the delay is symmetric. It is not, once HTTP traffic shares the radio: each + * sample then carries roughly rtt/2 of error, and the 250 ms gate above (chosen in LNK-026 + * to reject STALE pongs, not to bound ACCURACY) happily admits ~115 ms of it. + * + * Measured on the bench with the analyzer + the xf gauge: under web load a commit threw the + * beat origin by 500 ms, the bar strobe wobbled 185 ms (idle: 4.9 ms), and the device + * emitted 20% MORE clock pulses than real time allows -- because the GhostXForm is nothing + * but an origin, and committing one STEPS it. + * + * Two rules, and they are complementary: + * + * SLACK -- only the low-RTT samples of an attempt may vote. A sample delayed far beyond + * the best one is queued, therefore asymmetric, therefore biased. Median over the + * survivors keeps the robustness against a single liar while removing the systematic + * skew. (When every sample has the same RTT this changes nothing, which is why the + * pre-existing median tests still pass untouched.) + * + * SLEW -- bound how far ONE commit may move an ESTABLISHED origin. Arriving a little late + * is survivable; the downbeat teleporting mid-bar is not. */ +#define LINK_MEASUREMENT_RTT_SLACK_US 15000 /* a sample may exceed the attempt's best RTT + * by this much and still vote */ +#define LINK_MEASUREMENT_MAX_SLEW_US 20000 /* max origin move per commit (20 ms) */ + +/* ...but a genuine session re-origin is not noise: link_phase.c documents real ones jumping + * by many SECONDS (~510 s seen on hardware). Slewing to that 20 ms at a time would take + * hours, so a move this big is adopted whole. The clamp exists to reject measurement noise, + * never to fight the session. */ +#define LINK_MEASUREMENT_REORIGIN_US 1000000 /* >= 1 s: a real re-origin, adopt immediately */ + +typedef struct { + int64_t intercept_us; // signed microsecond offset, host -> ghost + bool valid; +} LinkGhostXForm; + +/* P4-038 phase health. The GhostXForm is nothing but an ORIGIN, and attempt_end() STEPS + * it -- no slew, no clamp. So a commit that lands badly moves the beat origin, and the bar + * line moves with it. On the bench the bar strobe wobbled 185 ms under web load while the + * clock task was provably healthy (gap 1.5 ms, 0 overruns, 0 drops, 0 re-primes) and the + * device emitted 137 pulses MORE than real time allows. A stall cannot manufacture pulses; + * a moving origin can. + * + * Why the RTT matters: add_pong_samples() estimates the offset as + * `ghost - (h_recv + h_sent)/2` -- the midpoint, which ASSUMES a symmetric delay. Queue the + * network in one direction (HTTP traffic sharing the radio) and the estimate is biased by + * roughly half the asymmetry. The LNK-026 gate admits any RTT up to 250 ms, so it will + * happily accept a sample carrying ~125 ms of error. + * + * max_step_us >> a few ms -> commits are THROWING the origin. That is the bar wobble. + * rtt_max_us near the gate -> the samples feeding those commits are load-inflated. */ +typedef struct { + uint32_t commits; // GhostXForm commits (lifetime) + uint32_t last_step_us; // |origin move| at the most recent commit + uint32_t max_step_us; // worst |origin move| ever committed + uint32_t rtt_min_us; // best / worst RTT among ACCEPTED samples + uint32_t rtt_max_us; +} LinkPhaseHealth; + +// Fills `out` with the lifetime phase-health counters. Never resets. +void link_measurement_phase_health(LinkPhaseHealth* out); + +// slope is hardcoded 1.0 in the real protocol too — Link does not model +// clock drift in this path. +int64_t link_ghost_xform_host_to_ghost(LinkGhostXForm xform, int64_t host_us); + +/* ---------------------------------------------------------------------- */ +/* Wire format: TLV build/parse for ping/pong messages */ +/* ---------------------------------------------------------------------- */ + +// Build a Ping message (what we send) into buf. +// Round 1: HostTime{host_time_us} only (has_prev_ghost=false) +// Round N>1: HostTime{host_time_us} + PrevGHostTime{prev_ghost_us} +// Returns the number of bytes written, or 0 if buf_cap is too small. +int link_measurement_build_ping(uint8_t* buf, int buf_cap, + int64_t host_time_us, + bool has_prev_ghost, int64_t prev_ghost_us); + +typedef struct { + bool has_host_time; + int64_t host_time_us; + bool has_prev_ghost_time; + int64_t prev_ghost_time_us; +} LinkPingFields; + +// Parse a Ping message body back out. Mainly exists for host-test +// round-tripping of link_measurement_build_ping() — production code (the +// pinger) never needs to parse its own pings. +bool link_measurement_parse_ping(const uint8_t* buf, int len, LinkPingFields* out); + +typedef struct { + bool has_ghost_time; + int64_t ghost_time_us; + bool has_host_time; // our own echoed HostTime from the ping this pong replies to + int64_t host_time_us; + bool has_prev_ghost_time; // our own echoed PrevGHostTime (round 2+) + int64_t prev_ghost_time_us; +} LinkPongFields; + +// Parse a Pong message (what we receive). TLV order is not guaranteed; +// looked up by key. SessionMembership ('sess') is present but ignored — we +// don't track session identity changes here. Returns false if the magic or +// message type don't match (not a Pong message at all); a structurally +// valid Pong with missing fields still returns true with the corresponding +// has_* flags left false. +bool link_measurement_parse_pong(const uint8_t* buf, int len, LinkPongFields* out); + +/* ---------------------------------------------------------------------- */ +/* Sample buffer / median / offset math */ +/* ---------------------------------------------------------------------- */ + +void link_measurement_samples_reset(void); +int link_measurement_samples_count(void); + +// Given one received pong and our local clock reading at the moment it +// arrived (h_recv), compute sample_a (always, if HostTime+GHostTime +// present) and sample_b (only if PrevGHostTime also present) per the +// closed-form offset formulas, and push whichever apply into the capped +// ring buffer (oldest evicted on overflow). Returns the number of samples +// pushed (0, 1, or 2). +int link_measurement_add_pong_samples(int64_t h_recv, const LinkPongFields* pong); + +// Median of the currently accumulated samples. Returns false if the buffer +// is empty. +bool link_measurement_median(double* out_median); + +/* ---------------------------------------------------------------------- */ +/* Attempt lifecycle — state owned here, driven by named events from the */ +/* Arduino glue (link_measurement_io.cpp), which is the only piece that */ +/* knows about real sockets/timing. */ +/* ---------------------------------------------------------------------- */ + +// Resets all module state: sample buffer, committed GhostXForm, active flag. +void link_measurement_reset(void); + +// Starts a fresh measurement attempt: clears the sample buffer, marks +// active=true. Does not touch any previously committed GhostXForm. +void link_measurement_attempt_begin(void); + +// Ends the in-flight attempt. +// success=true: derive a GhostXForm from the current sample buffer's +// median and commit it (valid=true) — unless the buffer +// is empty, in which case this is a no-op on the xform. +// success=false: leave the existing committed GhostXForm (if any) +// untouched rather than overwriting it with garbage, per +// the ticket's failure-handling rule. +// Either way, active becomes false. +void link_measurement_attempt_end(bool success); + +LinkGhostXForm link_measurement_current_xform(void); + +// The one honest question for phase validity: does a committed, trustworthy +// GhostXForm exist? Gate phase output on THIS, never on active() (ARC-002). +// Gating on active() was the "flashing dot" bug: active is true only while an +// attempt is in flight and drops the instant it commits, so phase flickered +// valid-then-invalid at exactly the moment the estimate became good. +bool link_measurement_have_phase_estimate(void); + +/* ESP-028: invalidate the committed mapping WITHOUT zeroing the P4-038 phase-health gauge. + * This is what a peer churn wants: the old session's origin must stop being served (ESP-027), + * but the lifetime record of how badly commits have been throwing the origin must survive -- + * a churn is exactly when that history is worth having. link_measurement_reset() is the full + * boot reset and blanks the gauge too. */ +void link_measurement_invalidate_xform(void); + +// Lifecycle only — true while a measurement attempt is in flight. For the +// measurement pump's attempt orchestration; NOT a phase-validity signal. +// Callers deciding whether phase can be trusted must use +// link_measurement_have_phase_estimate() instead. +bool link_measurement_active(void); + +#ifdef __cplusplus +} +#endif diff --git a/X32Link/link_measurement_io.cpp b/X32Link/link_measurement_io.cpp new file mode 100644 index 0000000..59cc8dd --- /dev/null +++ b/X32Link/link_measurement_io.cpp @@ -0,0 +1,66 @@ +// Thin Arduino glue: WiFiUDP unicast ping/pong exchange against a Link peer's +// mep4 endpoint (discovered by LNK-017's link_protocol.c). All decision policy — +// peer targeting, re-measure scheduling, the transport re-origin response, the +// RX flush, the silence watchdog — lives in the pure, host-tested +// link_measurement_session.c (LNK-031); the wire format + sample/offset math is +// in link_measurement.c. This file owns only sockets, timing, and translating +// the session's actions into I/O. +#include "link_measurement_io.h" +#include "link_measurement.h" +#include "link_measurement_session.h" +#include "link_measure_pump.h" // ARC-014: shared poll behind a socket vtable +#include "link_protocol.h" +#include +#include "esp_timer.h" + +// Independent socket from link_listener.cpp's multicast discovery conversation +// (port 20808) — a separate unicast conversation. Port 0 = OS-assigned ephemeral +// local port; we only ever talk to the peer's advertised mep4 endpoint. +static WiFiUDP s_udp; +static bool s_socket_open = false; +static LinkSession s_session; + +// esp_timer_get_time(), not millis()/micros() — see LNK-019 for why microsecond +// precision matters specifically in this path. +static int64_t now_us() { return esp_timer_get_time(); } + +// Build an IPAddress from our wire-format uint32_t (big-endian octets). The +// 4-octet ctor is used deliberately — IPAddress(uint32_t) assigns the raw word +// into a little-endian layout with no byteswap, which would mis-order the octets. +static IPAddress ip_from_be32(uint32_t ip) { + return IPAddress((ip >> 24) & 0xFF, (ip >> 16) & 0xFF, + (ip >> 8) & 0xFF, ip & 0xFF); +} + +// ARC-014: the poll sequence + action dispatch live in the shared link_measure_pump; +// this glue supplies only the three WiFiUDP socket ops. +static void io_send_to(uint32_t ip, uint16_t port, const uint8_t* buf, int len) { + s_udp.beginPacket(ip_from_be32(ip), port); + s_udp.write(buf, len); + s_udp.endPacket(); +} + +static int io_recv_one(uint8_t* buf, int cap) { + if (s_udp.parsePacket() <= 0) return 0; // nothing queued + return s_udp.read(buf, cap); +} + +static int64_t io_now_us() { return now_us(); } + +static const LinkMeasureOps s_ops = { + io_send_to, + io_recv_one, + io_now_us, +}; + +void link_measurement_io_begin() { + s_udp.stop(); + s_socket_open = s_udp.begin((uint16_t)0) != 0; + link_session_reset(&s_session); + link_measurement_reset(); +} + +void link_measurement_io_poll() { + if (!s_socket_open) return; + link_measure_pump(&s_session, &s_ops); +} diff --git a/X32Link/link_measurement_io.h b/X32Link/link_measurement_io.h new file mode 100644 index 0000000..abe3cdd --- /dev/null +++ b/X32Link/link_measurement_io.h @@ -0,0 +1,18 @@ +#pragma once +// Thin Arduino/WiFiUDP glue for the Link measurement (ping/pong) client. +// All protocol logic (TLV build/parse, sample math, GhostXForm derivation) +// lives in the pure, host-tested link_measurement.{h,c} — this file is +// socket + timing glue only. Not host-testable (needs real WiFiUDP). + +// Opens this module's own unicast UDP socket (separate from +// link_listener.cpp's multicast discovery socket / port 20808) and resets +// measurement state. Call once, after WiFi is up — mirrors +// link_listener_begin(). +void link_measurement_io_begin(); + +// Service the measurement client: detects the 0->1 reference-peer trigger +// (or a replaced reference peer) via link_proto_peer_endpoint(), drives the +// in-flight ping/pong exchange, the 50ms watchdog retry, and the +// 5-consecutive-timeout (250ms) failure path. Call every loop tick, +// alongside link_listener_poll()/tick(). +void link_measurement_io_poll(); diff --git a/X32Link/link_measurement_session.c b/X32Link/link_measurement_session.c new file mode 100644 index 0000000..ad95296 --- /dev/null +++ b/X32Link/link_measurement_session.c @@ -0,0 +1,154 @@ +// Pure Link measurement-session orchestrator — see link_measurement_session.h. +#include "link_measurement_session.h" +#include "link_measurement.h" // LINK_MEASUREMENT_READY_SAMPLES +#include + +void link_session_reset(LinkSession* s) { + memset(s, 0, sizeof(*s)); +} + +int link_session_on_epoch_reset(LinkSession* s, LinkSessionAct* out, int max) { + // ARC-011: the settled-timeline owner (session_timeline via link_protocol) has + // already debounced and confirmed a genuine transport re-origin (LNK-026 bug 1 / + // P4-028). Drop the committed xform (measured against the old ghost epoch), forget + // the reference peer, and arm an immediate re-measure against the new epoch. + int n = 0; + if (n < max) out[n++].type = LS_RESET_XFORM; + s->have_ref = false; + s->next_measure_us = 0; + return n; +} + +int link_session_on_trigger(LinkSession* s, bool peer_found, uint32_t ip, uint16_t port, + int64_t now_us, bool active, LinkSessionAct* out, int max) { + int n = 0; + + if (!peer_found) { + // No advertised endpoint — the ref vanished (ByeBye, or PEER_TTL_MS=15 s of + // silence in link_protocol.c) or hasn't published mep4 yet. Forget it so a + // reappearing/new peer re-triggers a fresh attempt. + // + // ESP-028: forgetting the ref was ALL LNK-031 did here, and that is the defect. + // The committed GhostXForm was measured against THAT peer's ghost epoch, and + // link_measurement_have_phase_estimate() reports nothing but s_xform.valid — which + // nobody was clearing. So the dead session's mapping kept being served as a live + // phase estimate. + // + // Measured on the analyzer (ESP-027): kill the only Link peer, let it rejoin, and + // `beats` stepped BACKWARDS 276 beats while /status still said sync:1. clock_ticker + // had primed its grid at beat 290 on the old session; the new session read 14 + // through the old origin; the ticker waited for beats to climb back — 276 beats = + // 138 SECONDS of zero bytes on the wire, with every counter green. + // + // No estimate is honest: beat_source free-runs through it (ESP-027) and + // master_clock originates. A stale estimate lies, and the wire goes quiet. + if (s->have_ref) { + if (n < max) out[n++].type = LS_RESET_XFORM; + s->have_ref = false; + s->next_measure_us = 0; // measure the instant a peer reappears + } + return n; + } + + // ESP-028: {ip,port} is the only session identity visible from here — link_protocol + // exposes no session id, and a Link peer that restarts comes back on a NEW ephemeral + // mep4 port. So a moved endpoint means the committed xform maps to a ghost epoch we + // can no longer reach: it is a re-target, not a re-measure. + bool moved = s->have_ref && (ip != s->ref_ip || port != s->ref_port); + bool different = !s->have_ref || moved; + bool due = now_us >= s->next_measure_us; + if (!((different || due) && !active)) return 0; + + // ESP-028: drop the dead mapping BEFORE the fresh attempt, never after it commits. + // Two failure modes ride on this ordering: + // 1. The attempt needs ~4 round trips and may fail outright — LS_END_FAIL leaves the + // committed xform untouched by design (ARC-002). Without this, the stale mapping + // is served for the whole attempt, and FOREVER if the attempt fails. + // 2. link_measurement_attempt_end() slews a commit that lands within 1 s of an + // ESTABLISHED origin (LINK_MEASUREMENT_MAX_SLEW_US = 20 ms/commit, P4-038's noise + // clamp). Against a DEAD origin that clamp fights the new session instead of + // protecting the bar line: a 500 ms epoch difference would need ~25 re-measures + // (~50 s at LINK_SESSION_REMEASURE_US) to walk off. Clearing `valid` first makes + // the first commit adopt the new session's origin whole — which is exactly what + // the clamp's re-origin escape hatch was written to allow. + // + // Cost when the endpoint moves but the SESSION does not (peer A leaves a 3-peer session + // and the pump re-targets to B — link_protocol's swap-remove reshuffles peer[0]): one + // wasted invalidation. The re-measure lands within milliseconds of the old intercept, + // far under beat_source's 0.25-beat re-prime threshold, and the gap free-runs. A + // needless re-measure costs a free-run window in milliseconds. A stale mapping cost 138 + // seconds of silence. The asymmetry decides it. + if (moved) { + if (n < max) out[n++].type = LS_RESET_XFORM; + } + + // LNK-026 bug 2: flush stale pongs before this attempt's own pings. + if (n < max) out[n++].type = LS_FLUSH_RX; + + s->ref_ip = ip; + s->ref_port = port; + s->have_ref = true; + s->have_prev_ghost = false; + s->prev_ghost_us = 0; + s->consecutive_timeouts = 0; + s->next_measure_us = now_us + LINK_SESSION_REMEASURE_US; + + if (n < max) out[n++].type = LS_START_ATTEMPT; + + if (n < max) { + LinkSessionAct* a = &out[n++]; + a->type = LS_SEND_PING; + a->ip = ip; a->port = port; + a->has_prev_ghost = false; a->prev_ghost_us = 0; + a->send_time_us = now_us; + } + s->last_send_us = now_us; + return n; +} + +int link_session_on_pong(LinkSession* s, int64_t h_recv, bool has_ghost, int64_t ghost_us, + int samples_count, LinkSessionAct* out, int max) { + s->consecutive_timeouts = 0; // a pong resets the silence watchdog + + int n = 0; + if (samples_count >= LINK_MEASUREMENT_READY_SAMPLES) { + if (n < max) out[n++].type = LS_END_OK; + return n; + } + + // Not enough samples yet — re-ping immediately, carrying this pong's + // GHostTime as the next ping's PrevGHostTime (round 2+). + s->have_prev_ghost = has_ghost; + s->prev_ghost_us = ghost_us; + s->last_send_us = h_recv; + + if (n < max) { + LinkSessionAct* a = &out[n++]; + a->type = LS_SEND_PING; + a->ip = s->ref_ip; a->port = s->ref_port; + a->has_prev_ghost = has_ghost; a->prev_ghost_us = ghost_us; + a->send_time_us = h_recv; + } + return n; +} + +int link_session_on_watchdog(LinkSession* s, int64_t now_us, LinkSessionAct* out, int max) { + if (now_us - s->last_send_us < LINK_SESSION_WATCHDOG_US) return 0; + + int n = 0; + s->consecutive_timeouts++; + if (s->consecutive_timeouts >= LINK_SESSION_MAX_TIMEOUTS) { + if (n < max) out[n++].type = LS_END_FAIL; // abandon; committed xform untouched + return n; + } + + s->last_send_us = now_us; + if (n < max) { + LinkSessionAct* a = &out[n++]; + a->type = LS_SEND_PING; + a->ip = s->ref_ip; a->port = s->ref_port; + a->has_prev_ghost = s->have_prev_ghost; a->prev_ghost_us = s->prev_ghost_us; + a->send_time_us = now_us; + } + return n; +} diff --git a/X32Link/link_measurement_session.h b/X32Link/link_measurement_session.h new file mode 100644 index 0000000..cdeefcf --- /dev/null +++ b/X32Link/link_measurement_session.h @@ -0,0 +1,101 @@ +#pragma once +// LNK-031: the Link measurement-session orchestrator, pulled out of the thin +// WiFiUDP glue (link_measurement_io.cpp) into a pure, host-tested state machine. +// This owns the *policy* — peer targeting, re-measure scheduling, the transport +// re-origin (epoch) response, the RX-flush on a fresh attempt, and the silence +// watchdog / timeout retry — all of which used to live untestable in glue and +// was where both LNK-026 bugs hid. +// +// Shape: the glue gathers facts (peer endpoint, gossiped timeline, socket +// events, clock) and calls the on_* event functions; each returns an ordered +// list of ACTIONS for the glue to execute (flush the socket, begin/commit an +// attempt, send a ping, reset the committed xform). No side effects here — every +// decision is a pure, replayable step. No Arduino headers. See +// test/test_link_measurement_session.c. +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +// Policy constants (moved here from the glue — they are decisions, not I/O). +#define LINK_SESSION_WATCHDOG_US 50000 // 50 ms silence watchdog +#define LINK_SESSION_MAX_TIMEOUTS 5 // 5 * 50 ms = 250 ms -> abandon attempt +#define LINK_SESSION_REMEASURE_US 2000000 // 2 s periodic re-measure + +// Most actions any single on_* call can emit. on_trigger is the worst case, and since +// ESP-028 it is exactly this: RESET_XFORM + FLUSH_RX + START_ATTEMPT + SEND_PING. Callers +// MUST size their action buffer with this — the emitters are all `if (n < max)`, so an +// under-sized buffer does not error, it silently drops the TAIL, and the tail is the ping. +#define LINK_SESSION_MAX_ACTIONS 4 + +typedef enum { + LS_FLUSH_RX = 1, // discard any buffered pongs before a fresh attempt + LS_START_ATTEMPT, // begin sample collection (link_measurement_attempt_begin) + LS_SEND_PING, // send a ping to {ip,port} (has_prev_ghost/prev_ghost_us) at send_time_us + LS_END_OK, // commit the attempt (link_measurement_attempt_end(true)) + LS_END_FAIL, // abandon the attempt (link_measurement_attempt_end(false)) + LS_RESET_XFORM, // invalidate the committed GhostXForm (link_measurement_reset) +} LinkSessionActType; + +typedef struct { + LinkSessionActType type; + uint32_t ip; // LS_SEND_PING target (wire-order be32) + uint16_t port; // LS_SEND_PING target + bool has_prev_ghost; // LS_SEND_PING: include a PrevGHostTime TLV? + int64_t prev_ghost_us; // LS_SEND_PING PrevGHostTime value + int64_t send_time_us; // LS_SEND_PING HostTime value +} LinkSessionAct; + +typedef struct { + bool have_ref; // do we currently target a reference peer? + uint32_t ref_ip; + uint16_t ref_port; + bool have_prev_ghost; // carry PrevGHostTime into the next ping (round 2+) + int64_t prev_ghost_us; + int64_t last_send_us; // last ping send time (drives the watchdog) + int consecutive_timeouts; // silence watchdog fires this many times -> abandon + int64_t next_measure_us; // when the periodic re-measure is due +} LinkSession; + +// Reset all session state (call from the glue's begin()). +void link_session_reset(LinkSession* s); + +// ARC-011: a genuine transport re-origin has been confirmed upstream (the settled- +// timeline debounce in session_timeline, driven by link_protocol). Emit LS_RESET_XFORM +// and arm an immediate re-measure (forget the ref, zero next_measure). The epoch +// detection + debounce no longer live here — link_proto_epoch_reset_pending() gates +// this call. Returns action count. +int link_session_on_epoch_reset(LinkSession* s, LinkSessionAct* out, int max); + +// Trigger check each poll. peer_found + {ip,port} come from +// link_proto_peer_endpoint(); active mirrors link_measurement_active(). Starts a +// fresh attempt (FLUSH_RX, START_ATTEMPT, first SEND_PING) when a different peer +// appeared or the re-measure timer is due and no attempt is in flight. +// +// ESP-028: the reference ENDPOINT is the session identity this module can see. When it +// disappears or moves (a restarted Link peer returns on a new ephemeral mep4 port), the +// committed GhostXForm maps to a ghost epoch that no longer exists, so this emits +// LS_RESET_XFORM *first* — before the flush and the fresh attempt. Serving a stale mapping +// as a valid phase estimate is what silenced the DIN clock for 138 s (ESP-027). +// Invariant: a committed xform is never served for an endpoint we are no longer targeting. +// Returns action count. +int link_session_on_trigger(LinkSession* s, bool peer_found, uint32_t ip, uint16_t port, + int64_t now_us, bool active, LinkSessionAct* out, int max); + +// A pong arrived; the glue has already added its samples, so samples_count is the +// post-add total. Resets the timeout counter; emits LS_END_OK once +// LINK_MEASUREMENT_READY_SAMPLES is reached, else carries this pong's GHostTime +// into the next ping and emits SEND_PING. h_recv is the local receive time. +int link_session_on_pong(LinkSession* s, int64_t h_recv, bool has_ghost, int64_t ghost_us, + int samples_count, LinkSessionAct* out, int max); + +// Silence watchdog — call once per poll while an attempt is active. After +// WATCHDOG_US of silence it re-pings; after MAX_TIMEOUTS it emits LS_END_FAIL +// (leaving any committed xform untouched). Returns action count. +int link_session_on_watchdog(LinkSession* s, int64_t now_us, LinkSessionAct* out, int max); + +#ifdef __cplusplus +} +#endif diff --git a/X32Link/link_phase.c b/X32Link/link_phase.c new file mode 100644 index 0000000..d99331a --- /dev/null +++ b/X32Link/link_phase.c @@ -0,0 +1,24 @@ +// Pure bar-quantized phase math for the Link adapter — see link_phase.h. +#include "link_phase.h" +#include + +double link_phase_beats_now(LinkTimeline timeline, int64_t ghost_now_us) { + return ((double)timeline.beat_origin_micro / 1e6) + + (double)(ghost_now_us - timeline.time_origin_us) + / (double)timeline.micros_per_beat; +} + +double link_phase_from_beats(double beats_now, double quantum) { + double phase_now = fmod(beats_now, quantum); + if (phase_now < 0) phase_now += quantum; + return phase_now; +} + +bool link_phase_timeline_epoch_reset(int64_t prev_time_origin_us, int64_t new_time_origin_us) { + // A genuine re-origin resets the ghost clock, so time_origin jumps backward by + // far more than gossip jitter / UDP reordering (sub-second). Threshold at 1s: + // real re-origins jump by many seconds (~510s observed on hardware), while an + // out-of-order gossip is never close. + const int64_t RESET_THRESHOLD_US = 1000000; // 1 second + return (prev_time_origin_us - new_time_origin_us) > RESET_THRESHOLD_US; +} diff --git a/X32Link/link_phase.h b/X32Link/link_phase.h new file mode 100644 index 0000000..290745a --- /dev/null +++ b/X32Link/link_phase.h @@ -0,0 +1,39 @@ +// Pure bar-quantized phase math for the Link adapter (LNK-019). +// +// Glues LNK-017's parsed LinkTimeline and LNK-018's GhostXForm together: +// the Arduino-side caller (tempo_source.cpp) maps its local esp_timer clock +// into ghost time via link_ghost_xform_host_to_ghost() and passes the +// result in here. Everything in this file is pure arithmetic — no +// WiFi/Arduino dependency — and is host-tested in test/test_link_phase.c. +#pragma once +#include +#include +#include "link_protocol.h" // LinkTimeline + +#ifdef __cplusplus +extern "C" { +#endif + +// Beats elapsed at ghost_now_us, per Timeline.toBeats() (Link SDK +// terminology): beat_origin_micro is the timeline's beat origin expressed +// in fixed-point micro-beats (beats * 1e6), time_origin_us/micros_per_beat +// anchor the linear beats-vs-ghost-time relationship. +double link_phase_beats_now(LinkTimeline timeline, int64_t ghost_now_us); + +// Phase within [0, quantum) given beats_now. fmod() can return a negative +// result when beats_now < 0 (e.g. a ghost_now_us before the timeline's +// origin) — shifted back into [0, quantum) here. +double link_phase_from_beats(double beats_now, double quantum); + +// LNK-026: a Link host restarting transport can re-origin the session — it +// republishes the timeline with a fresh ghost-time epoch, so time_origin_us +// jumps backward far beyond gossip jitter or UDP reordering. Detect that so the +// caller can invalidate a committed GhostXForm bound to the OLD epoch; without +// this, phase is computed from the new origin + the stale clock offset and reads +// as garbage until the ~2s periodic re-measurement catches up. Returns true only +// for a large backward step (not a small out-of-order blip). +bool link_phase_timeline_epoch_reset(int64_t prev_time_origin_us, int64_t new_time_origin_us); + +#ifdef __cplusplus +} +#endif diff --git a/X32Link/link_protocol.c b/X32Link/link_protocol.c index 871d736..93674b6 100644 --- a/X32Link/link_protocol.c +++ b/X32Link/link_protocol.c @@ -12,21 +12,36 @@ // BPM = 60e6 / microsPerBeat #include "link_protocol.h" +#include "session_timeline.h" // ARC-011: settled-timeline owner + epoch debounce #include static const uint8_t MAGIC[8] = {'_','a','s','d','p','_','v', 1}; static const uint32_t TMLN_KEY = 0x746d6c6eu; +static const uint32_t MEP4_KEY = 0x6d657034u; +static const uint32_t STST_KEY = 0x73747374u; // Link StartStopState ('stst') static const uint32_t PEER_TTL_MS = 15000; static const uint8_t MSG_ALIVE = 1; static const uint8_t MSG_RESPONSE = 2; static const uint8_t MSG_BYEBYE = 3; -typedef struct { uint8_t id[8]; uint32_t expires_ms; } Peer; - -static double s_bpm = 0.0; -static Peer s_peers[8]; -static int s_peer_count = 0; +typedef struct { + uint8_t id[8]; + uint32_t expires_ms; + uint32_t mep4_ip; + uint16_t mep4_port; + bool has_mep4; +} Peer; + +static double s_bpm = 0.0; +static Peer s_peers[8]; +static int s_peer_count = 0; +static LinkTimeline s_timeline; // raw gossip (last-writer-wins) +static bool s_timeline_seen = false; +static SessionTimeline s_stl; // ARC-011: settled view served to consumers +static volatile bool s_epoch_reset_pending = false; // latched on a confirmed re-origin +static bool s_playing = false; // Link StartStopState isPlaying +static bool s_stst_seen = false; // have we parsed a StartStopState yet // millis() shim — overridden in tests via weak symbol uint32_t __attribute__((weak)) link_proto_millis(void) { return 0; } @@ -41,6 +56,10 @@ static int64_t be64(const uint8_t* p) { return v; } +static uint16_t be16(const uint8_t* p) { + return (uint16_t)(((uint16_t)p[0]<<8)|(uint16_t)p[1]); +} + static int find_peer(const uint8_t* id) { for (int i = 0; i < s_peer_count; i++) if (memcmp(s_peers[i].id, id, 8) == 0) return i; @@ -53,6 +72,9 @@ static void upsert_peer(const uint8_t* id) { if (s_peer_count < 8) { memcpy(s_peers[s_peer_count].id, id, 8); s_peers[s_peer_count].expires_ms = link_proto_millis() + PEER_TTL_MS; + s_peers[s_peer_count].mep4_ip = 0; + s_peers[s_peer_count].mep4_port = 0; + s_peers[s_peer_count].has_mep4 = false; s_peer_count++; } } @@ -62,11 +84,18 @@ static void remove_peer(const uint8_t* id) { if (idx < 0) return; s_peers[idx] = s_peers[--s_peer_count]; if (s_peer_count == 0) s_bpm = 0.0; // no session → no tempo + if (s_peer_count == 0) { s_playing = false; s_stst_seen = false; } } void link_proto_reset(void) { s_bpm = 0.0; s_peer_count = 0; + s_timeline_seen = false; + s_playing = false; + s_stst_seen = false; + memset(&s_timeline, 0, sizeof(s_timeline)); + session_timeline_reset(&s_stl); + s_epoch_reset_pending = false; } // Drop peers whose TTL elapsed without a refreshing Alive/Response. @@ -80,6 +109,7 @@ void link_proto_tick(void) { i++; } if (s_peer_count == 0) s_bpm = 0.0; // no session → no tempo + if (s_peer_count == 0) { s_playing = false; s_stst_seen = false; } } bool link_proto_parse(const uint8_t* buf, int len) { @@ -103,6 +133,31 @@ bool link_proto_parse(const uint8_t* buf, int len) { if (key == TMLN_KEY && size >= 8) { int64_t us_per_beat = be64(p); if (us_per_beat > 0) s_bpm = 60.0e6 / (double)us_per_beat; + s_timeline.micros_per_beat = us_per_beat; + if (size >= 24) { + s_timeline.beat_origin_micro = be64(p + 8); + s_timeline.time_origin_us = be64(p + 16); + } + s_timeline_seen = true; + // ARC-011: debounce the epoch here so BOTH the beat math (settled read) + // and the measurement glue (the pending latch) see one settled timeline. + if (session_timeline_observe(&s_stl, true, s_timeline, + (int64_t)link_proto_millis() * 1000)) + s_epoch_reset_pending = true; + } + if (key == MEP4_KEY && size >= 6) { + int idx = find_peer(nodeId); + if (idx >= 0) { + s_peers[idx].mep4_ip = be32(p); + s_peers[idx].mep4_port = be16(p + 4); + s_peers[idx].has_mep4 = true; + } + } + // StartStopState: isPlaying(1) + beats(int64 BE) + timestamp(int64 BE). + // Only isPlaying is needed to drive MIDI transport (P4-008). + if (key == STST_KEY && size >= 1) { + s_playing = (p[0] != 0); + s_stst_seen = true; } p += size; } @@ -110,4 +165,29 @@ bool link_proto_parse(const uint8_t* buf, int len) { } double link_proto_bpm(void) { return s_bpm; } + +bool link_proto_playing(void) { return s_playing; } +bool link_proto_start_stop_seen(void) { return s_stst_seen; } int link_proto_peers(void) { return s_peer_count; } + +bool link_proto_timeline(LinkTimeline* out) { + // ARC-011: serve the settled (held-last-good, epoch-debounced) timeline, not the + // raw store — so a joining peer's un-synced gossip never reaches the beat math. + return session_timeline_settled(&s_stl, out); +} + +// ARC-011: true once (then clears) when the settled-timeline debounce confirms a +// genuine re-origin; the measurement glue polls this to reset the committed xform. +bool link_proto_epoch_reset_pending(void) { + bool p = s_epoch_reset_pending; + s_epoch_reset_pending = false; + return p; +} + +bool link_proto_peer_endpoint(int index, uint32_t* ip, uint16_t* port) { + if (index < 0 || index >= s_peer_count) return false; + if (!s_peers[index].has_mep4) return false; + if (ip) *ip = s_peers[index].mep4_ip; + if (port) *port = s_peers[index].mep4_port; + return true; +} diff --git a/X32Link/link_protocol.h b/X32Link/link_protocol.h index 8e314a8..994b73d 100644 --- a/X32Link/link_protocol.h +++ b/X32Link/link_protocol.h @@ -6,11 +6,29 @@ extern "C" { #endif +typedef struct { + int64_t micros_per_beat; + int64_t beat_origin_micro; + int64_t time_origin_us; +} LinkTimeline; + void link_proto_reset(void); void link_proto_tick(void); bool link_proto_parse(const uint8_t* buf, int len); double link_proto_bpm(void); int link_proto_peers(void); +bool link_proto_timeline(LinkTimeline* out); // settled (ARC-011): epoch-debounced +// ARC-011: true once (then clears) when the settled-timeline debounce confirms a +// genuine transport re-origin — the measurement glue resets the committed xform. +bool link_proto_epoch_reset_pending(void); +bool link_proto_peer_endpoint(int index, uint32_t* ip, uint16_t* port); + +// Link StartStopState (transport). link_proto_playing() is the session's +// isPlaying flag; link_proto_start_stop_seen() is false until a StartStopState +// has actually been parsed (and resets when the session empties), so a caller +// can prime on the first real observation rather than on the default. +bool link_proto_playing(void); +bool link_proto_start_stop_seen(void); #ifdef __cplusplus } diff --git a/X32Link/master_clock.c b/X32Link/master_clock.c new file mode 100644 index 0000000..e53a36e --- /dev/null +++ b/X32Link/master_clock.c @@ -0,0 +1,69 @@ +// P4-040: internal/tap tempo source + Link/internal arbiter — see master_clock.h. +#include "master_clock.h" +#include +#include + +void master_clock_reset(MasterClock* mc) { + memset(mc, 0, sizeof(*mc)); +} + +void master_clock_set_bpm(MasterClock* mc, float bpm) { + // ESP-037: reject anything outside the musical band, keeping the current tempo. The + // old `bpm <= 0` guard stopped a divide but let 5000 BPM through as a nonsense rate. + // (>= / <= so the MIN/MAX edges are IN.) NaN fails both compares and is rejected too. + if (!(bpm >= (float)MASTER_CLOCK_BPM_MIN && bpm <= (float)MASTER_CLOCK_BPM_MAX)) return; + mc->micros_per_beat = (int64_t)llround(60.0e6 / (double)bpm); + mc->has_tempo = true; +} + +void master_clock_tap(MasterClock* mc, int64_t now_us) { + if (mc->has_last_tap) { + int64_t interval = now_us - mc->last_tap_us; + if (interval > 0 && interval <= MASTER_CLOCK_TAP_TIMEOUT_US) { + mc->micros_per_beat = interval; + mc->has_tempo = true; + } + } + mc->last_tap_us = now_us; + mc->has_last_tap = true; +} + +MasterArbiterOut master_clock_arbiter(MasterClock* mc, int link_peers, + bool link_have_session, LinkTimeline link_tl, + LinkGhostXForm link_xform) { + bool solo = (link_peers == 0); + + // Rising edge into solo only (guarded by was_solo, so a live tap taken + // during an already-solo run is never clobbered on a later tick): seed + // from whatever Link is currently showing, overwriting any stale tempo + // held from a previous solo period, so output is continuous. If Link has + // nothing to seed from, leave whatever tempo the user last set alone. + if (solo && !mc->was_solo && link_have_session && link_tl.micros_per_beat > 0) { + mc->micros_per_beat = link_tl.micros_per_beat; + mc->has_tempo = true; + } + mc->was_solo = solo; + + MasterArbiterOut out; + memset(&out, 0, sizeof(out)); + + if (!solo) { + out.have_session = link_have_session; + out.tl = link_tl; + out.xform = link_xform; + out.on_internal = false; + return out; + } + + if (mc->has_tempo) { + out.have_session = true; + out.tl.micros_per_beat = mc->micros_per_beat; + out.xform.valid = false; + out.on_internal = true; + return out; + } + + out.have_session = false; + out.on_internal = false; + return out; +} diff --git a/X32Link/master_clock.h b/X32Link/master_clock.h new file mode 100644 index 0000000..f9cb17b --- /dev/null +++ b/X32Link/master_clock.h @@ -0,0 +1,64 @@ +#pragma once +// P4-040: internal/tap tempo source + Link/internal arbiter. Pure, host-tested, +// same shape as beat_source.h (ARC-007). KitchenSync (P4) only -- X32Link and +// the LED-only variant stay follow-only; this module is not compiled into them. +// +// The arbiter needs no phase-lock machinery of its own: when solo, it feeds +// ks_tick_step a free-running (xform.valid=false) LinkTimeline sourced from +// this module's micros_per_beat, reusing beat_source_step's existing +// free-run path unchanged. +#include +#include +#include "link_phase.h" // LinkTimeline +#include "link_measurement.h" // LinkGhostXForm + +#ifdef __cplusplus +extern "C" { +#endif + +typedef struct { + bool has_tempo; // user tapped/set a BPM, or we auto-seeded one + int64_t micros_per_beat; // valid iff has_tempo + + int64_t last_tap_us; + bool has_last_tap; + + bool was_solo; // edge-detects link_peers>0 -> 0, for seeding +} MasterClock; + +void master_clock_reset(MasterClock* mc); + +// A tap more than this long after the previous one starts a fresh interval +// instead of computing a tempo from it (stale double-tap guard). +#define MASTER_CLOCK_TAP_TIMEOUT_US 2000000 + +// ESP-037: the musical band a user-set tempo may land in. A settable tempo means a +// client (or a fat-fingered tap) can hand this any float, and set_bpm feeds the DIN +// clock writer directly -- so anything outside the band is REJECTED, not clocked. 20 +// covers the slowest ballad; 300 covers fast DnB/footwork. Widen here if a genre needs +// it -- both the firmware and the app clamp to the same pair. +#define MASTER_CLOCK_BPM_MIN 20 +#define MASTER_CLOCK_BPM_MAX 300 + +void master_clock_tap(MasterClock* mc, int64_t now_us); +void master_clock_set_bpm(MasterClock* mc, float bpm); + +typedef struct { + bool have_session; // feed straight into KsTickInputs.have_session + LinkTimeline tl; + LinkGhostXForm xform; + bool on_internal; // status/UI: are we currently master-clock-driven? +} MasterArbiterOut; + +// Call once per tick, before building KsTickInputs. peers>0 always defers to +// Link (rejoin always wins -- this device never broadcasts, so there is no +// competing session to arbitrate). On the peers>0 -> 0 edge, seeds the +// internal tempo from whatever Link was last showing, if the user hasn't +// already set one -- output is continuous across the transition. +MasterArbiterOut master_clock_arbiter(MasterClock* mc, int link_peers, + bool link_have_session, LinkTimeline link_tl, + LinkGhostXForm link_xform); + +#ifdef __cplusplus +} +#endif diff --git a/X32Link/metro_strip.c b/X32Link/metro_strip.c new file mode 100644 index 0000000..a239608 --- /dev/null +++ b/X32Link/metro_strip.c @@ -0,0 +1,66 @@ +#include "metro_strip.h" +#include + +static uint8_t scale8(uint8_t v, double s) { + double x = v * s; + if (x < 0) x = 0; + if (x > 255) x = 255; + return (uint8_t)(x + 0.5); +} + +static void set_px(RGB* p, RGB c, double s) { + p->r = scale8(c.r, s); + p->g = scale8(c.g, s); + p->b = scale8(c.b, s); +} + +void metro_strip_render(double beats, int quantum, int npix, + const MetroStripCfg* cfg, RGB out[]) { + for (int i = 0; i < npix; i++) { out[i].r = out[i].g = out[i].b = 0; } + if (!cfg || quantum < 1 || npix < 1) return; + + double n = floor(beats); + int beat_in_bar = (((int)n % quantum) + quantum) % quantum; // positive modulo + double phase = beats - n; // 0..1 within the beat + + RGB color = (beat_in_bar == 0) ? cfg->accent : cfg->beat; + double master = cfg->bright / 100.0; + double faded = master * (1.0 - (cfg->fade / 100.0) * phase); // chase/flash pulse + + if (cfg->mode == METRO_STRIP_FLASH) { + for (int i = 0; i < npix; i++) set_px(&out[i], color, faded); + return; + } + if (cfg->mode == METRO_STRIP_FILL) { + double progress = (beat_in_bar + phase) / (double)quantum; // 0..1 across the bar + int litn = (int)(progress * npix + 0.5); + if (litn > npix) litn = npix; + for (int i = 0; i < litn; i++) set_px(&out[i], color, master); // steady fill + return; + } + + // CHASE (default): one block per beat walks across the strip. + int ppb = npix / quantum; + if (ppb < 1) ppb = 1; + int start = beat_in_bar * ppb; + for (int i = start; i < start + ppb && i < npix; i++) set_px(&out[i], color, faded); +} + +// ESP-009. Sine breath between a dim floor and a modest ceiling -- deliberately +// below the beat chase's brightness so standby can never be mistaken for +// playback. Floor is non-zero: fully dark reads as "dead", which is the whole +// bug this fixes. +void metro_strip_standby(double phase, int npix, const MetroStripCfg* cfg, RGB out[]) { + static const double kFloor = 0.06; // never fully dark + static const double kPeak = 0.30; // well under a lit beat pixel + + double ph = phase - floor(phase); // wrap to 0..1 + double breath = (1.0 - cos(2.0 * M_PI * ph)) * 0.5; // 0 at ph=0, 1 at ph=0.5 + double level = (kFloor + (kPeak - kFloor) * breath) * (cfg->bright / 100.0); + + for (int i = 0; i < npix; i++) { + out[i].r = scale8(cfg->beat.r, level); + out[i].g = scale8(cfg->beat.g, level); + out[i].b = scale8(cfg->beat.b, level); + } +} diff --git a/X32Link/metro_strip.h b/X32Link/metro_strip.h new file mode 100644 index 0000000..8d78817 --- /dev/null +++ b/X32Link/metro_strip.h @@ -0,0 +1,49 @@ +#pragma once +// Pure WS2812 visual-metronome renderer (P4-018, customizable P4-019). Given the +// continuous beat position and a config (colors, brightness, pattern, fade), it +// fills an RGB array; the led_strip glue pushes it to the strip. Three patterns: +// CHASE — one block per beat walks across the strip (npix/quantum px per beat) +// FLASH — every pixel pulses together on each beat +// FILL — pixels fill across the bar like a progress bar, resetting each downbeat +// The bar-1 downbeat uses the accent colour, other beats the beat colour. No I/O. +// Host-tested in test/test_metro_strip.c. No Arduino/ESP-IDF dependency. +#include + +#define METRO_STRIP_PIXELS 8 // the attached strip length + +// Pattern modes (config led_mode). +#define METRO_STRIP_CHASE 0 +#define METRO_STRIP_FLASH 1 +#define METRO_STRIP_FILL 2 + +#ifdef __cplusplus +extern "C" { +#endif + +typedef struct { uint8_t r, g, b; } RGB; + +typedef struct { + RGB beat; // colour for beats 2..N + RGB accent; // colour for the bar-1 downbeat + uint8_t bright; // master brightness, 0..100 % + uint8_t mode; // METRO_STRIP_CHASE / FLASH / FILL + uint8_t fade; // dim across a beat, 0..100 % (0 = steady, 100 = dark by next beat) +} MetroStripCfg; + +// Render the pattern for beat position `beats` into out[0..npix). `quantum` is +// beats per bar (e.g. 4). Safe for negative beats and npix not a multiple of +// quantum. Pixels not lit are set to {0,0,0}. +void metro_strip_render(double beats, int quantum, int npix, + const MetroStripCfg* cfg, RGB out[]); + +// Standby breath (ESP-009): session joined, transport stopped. `phase` is a +// free-running 0..1 ramp (one full breath per period; the caller picks the +// rate). Every pixel pulses together in the beat colour, dim and never fully +// dark -- a strip that reaches zero looks like a board that crashed between +// blinks, which is the exact confusion this exists to prevent. Carries no beat +// information: in standby there is no beat. +void metro_strip_standby(double phase, int npix, const MetroStripCfg* cfg, RGB out[]); + +#ifdef __cplusplus +} +#endif diff --git a/X32Link/metronome.c b/X32Link/metronome.c new file mode 100644 index 0000000..f08bef4 --- /dev/null +++ b/X32Link/metronome.c @@ -0,0 +1,18 @@ +// Pure metronome scheduler — see metronome.h. +#include "metronome.h" + +void metronome_reset(Metronome* m) { + clock_ticker_reset(&m->beat); + bar_reset_reset(&m->bar); +} + +MetroClick metronome_update(Metronome* m, double beats_now, double quantum, int max_burst) { + // Both engines observe the SAME beats value every call, so the downbeat + // detection lands on the exact update that also crosses a beat (a bar + // boundary is an integer beat). Call both unconditionally to keep BarReset + // advancing in lockstep with the beat grid. + int due = clock_ticker_ticks_due(&m->beat, beats_now, 1, max_burst); + bool downbeat = bar_reset_due(&m->bar, beats_now, quantum); + if (due <= 0) return METRO_NONE; // mid-beat / backward / re-primed jump + return downbeat ? METRO_ACCENT : METRO_CLICK; +} diff --git a/X32Link/metronome.h b/X32Link/metronome.h new file mode 100644 index 0000000..af624fe --- /dev/null +++ b/X32Link/metronome.h @@ -0,0 +1,44 @@ +#pragma once +// Pure metronome scheduler (P4-006). From a monotonic beat position it decides +// WHEN to click and WHETHER the click is a bar-downbeat accent — nothing about +// sound. It composes the shared, host-tested engines UNCHANGED (ADR-0003): +// - clock_ticker at ppqn=1 -> one pulse per whole beat (the click grid) +// - BarReset at `quantum` -> fires once per bar boundary (the accent) +// The two are driven off the SAME beats value each call, so they stay in +// lockstep (a bar boundary is always an integer beat). A large forward jump +// (tempo re-origin / stall) re-primes both instead of flooding, and a backward +// move is silent. The audio tone burst (ES8311/I2S) is the platform glue in +// KitchenSync/main/metronome_audio.c. No Arduino/ESP-IDF dependency — +// host-tested in test/test_metronome.c. +#include "clock_ticker.h" +#include +#ifdef __cplusplus +extern "C" { +#endif + +typedef enum { + METRO_NONE = 0, // no beat boundary crossed this update + METRO_CLICK = 1, // plain beat click + METRO_ACCENT = 2, // bar-downbeat accent (bar 1 of `quantum`) +} MetroClick; + +typedef struct { + ClockTicker beat; // ppqn=1: one tick per beat + BarReset bar; // bar-boundary (downbeat) detector +} Metronome; + +// Clear state: the next metronome_update() re-primes the grid (emits NONE). +void metronome_reset(Metronome* m); + +// Advance to beats_now and report the click for this instant: +// - the first call after a reset primes and returns METRO_NONE (no click); +// - a whole-beat crossing returns METRO_ACCENT on a bar downbeat, else +// METRO_CLICK; +// - no crossing (mid-beat), a backward move, or a jump larger than max_burst +// returns METRO_NONE (the jump re-primes so alignment is kept, not flooded). +// quantum = beats per bar (accent cadence, 1..N). max_burst caps beat catch-up. +MetroClick metronome_update(Metronome* m, double beats_now, double quantum, int max_burst); + +#ifdef __cplusplus +} +#endif diff --git a/X32Link/metronome_voice.c b/X32Link/metronome_voice.c new file mode 100644 index 0000000..1317f26 --- /dev/null +++ b/X32Link/metronome_voice.c @@ -0,0 +1,17 @@ +#include "metronome_voice.h" + +void metronome_voice_params(int voice, float* click_hz, int* click_ms, + float* accent_hz, int* accent_ms) { + switch (voice) { + case METRO_VOICE_CLICK: // tight, high, short + *click_hz = 2000.0f; *click_ms = 22; *accent_hz = 3000.0f; *accent_ms = 26; + break; + case METRO_VOICE_WOOD: // woody, lower + *click_hz = 1200.0f; *click_ms = 32; *accent_hz = 1500.0f; *accent_ms = 36; + break; + case METRO_VOICE_TONE: // soft sine (default) + default: + *click_hz = 1000.0f; *click_ms = 45; *accent_hz = 1760.0f; *accent_ms = 55; + break; + } +} diff --git a/X32Link/metronome_voice.h b/X32Link/metronome_voice.h new file mode 100644 index 0000000..6600e10 --- /dev/null +++ b/X32Link/metronome_voice.h @@ -0,0 +1,24 @@ +#pragma once +// Pure metronome click-voice presets (P4-012): map a voice id to the tone +// parameters (pitch + length) for the plain click and the bar-1 accent. The +// audio glue renders bursts from these; keeping the mapping pure makes it +// host-testable and float-free at the config layer. No Arduino/ESP-IDF dep. +#ifdef __cplusplus +extern "C" { +#endif + +enum { + METRO_VOICE_TONE = 0, // soft sine (default) + METRO_VOICE_CLICK, // tight, high, short + METRO_VOICE_WOOD, // woody, lower + METRO_VOICE_COUNT, +}; + +// Fill the click/accent pitch (Hz) and length (ms) for `voice`. Out-of-range +// falls back to METRO_VOICE_TONE. +void metronome_voice_params(int voice, float* click_hz, int* click_ms, + float* accent_hz, int* accent_ms); + +#ifdef __cplusplus +} +#endif diff --git a/X32Link/midi_bpm.cpp b/X32Link/midi_bpm.cpp deleted file mode 120000 index 31ec4ff..0000000 --- a/X32Link/midi_bpm.cpp +++ /dev/null @@ -1 +0,0 @@ -../X32MidiClock/midi_bpm.cpp \ No newline at end of file diff --git a/X32Link/midi_bpm.cpp b/X32Link/midi_bpm.cpp new file mode 100644 index 0000000..e05c9c6 --- /dev/null +++ b/X32Link/midi_bpm.cpp @@ -0,0 +1,27 @@ +#include "midi_bpm.h" +#include "midi_clock.h" +#include "midi_bpm_calc.h" +#include "config.h" +#include + +void midi_bpm_init() { + // nothing to initialise — state lives in midi_clock module +} + +float midi_bpm_update() { + uint32_t count = midi_clock_pulse_count(); + if (count < (uint32_t)MCK_CLOCK_WINDOW) return 0.0f; + + // Timeout: no pulse for MCK_CLOCK_TIMEOUT_MS → clock stopped + uint32_t last_us = midi_clock_last_pulse_us(); + if ((uint32_t)(micros() - last_us) > (uint32_t)(MCK_CLOCK_TIMEOUT_MS * 1000UL)) + return 0.0f; + + // MCK_CLOCK_WINDOW timestamps span (MCK_CLOCK_WINDOW-1) intervals. + // Scale up to a full beat (MCK_CLOCK_WINDOW intervals = 24 PPQN). + uint32_t newest = midi_clock_get_timestamp(MCK_CLOCK_WINDOW - 1); + uint32_t oldest = midi_clock_get_timestamp(0); + uint32_t span_us = newest - oldest; // uint32_t wraps correctly + + return midi_bpm_calc(span_us, MCK_CLOCK_WINDOW); // pure, host-tested +} diff --git a/X32Link/midi_bpm.h b/X32Link/midi_bpm.h deleted file mode 120000 index 34d2719..0000000 --- a/X32Link/midi_bpm.h +++ /dev/null @@ -1 +0,0 @@ -../X32MidiClock/midi_bpm.h \ No newline at end of file diff --git a/X32Link/midi_bpm.h b/X32Link/midi_bpm.h new file mode 100644 index 0000000..830cc0c --- /dev/null +++ b/X32Link/midi_bpm.h @@ -0,0 +1,4 @@ +#pragma once + +void midi_bpm_init(void); +float midi_bpm_update(void); // returns 0.0 if no clock or timed out diff --git a/X32Link/midi_bpm_calc.c b/X32Link/midi_bpm_calc.c deleted file mode 120000 index f271cf6..0000000 --- a/X32Link/midi_bpm_calc.c +++ /dev/null @@ -1 +0,0 @@ -../X32MidiClock/midi_bpm_calc.c \ No newline at end of file diff --git a/X32Link/midi_bpm_calc.c b/X32Link/midi_bpm_calc.c new file mode 100644 index 0000000..4aa038d --- /dev/null +++ b/X32Link/midi_bpm_calc.c @@ -0,0 +1,18 @@ +#include "midi_bpm_calc.h" + +float midi_bpm_calc(uint32_t span_us, int window) { + if (span_us == 0 || window < 2) return 0.0f; + // span covers (window-1) intervals; a beat is `window` intervals. + return 60000000.0f * (float)(window - 1) / ((float)span_us * (float)window); +} + +float midi_phase_calc(uint32_t pulse_count, int quantum) { + if (quantum < 1) return 0.0f; + uint32_t pulses_per_bar = 24u * (uint32_t)quantum; + return (float)(pulse_count % pulses_per_bar) / 24.0f; +} + +bool midi_phase_valid(uint32_t pulse_count, int quantum) { + if (quantum < 1) return false; + return pulse_count >= 24u * (uint32_t)quantum; +} diff --git a/X32Link/midi_bpm_calc.h b/X32Link/midi_bpm_calc.h deleted file mode 120000 index aeb3c4f..0000000 --- a/X32Link/midi_bpm_calc.h +++ /dev/null @@ -1 +0,0 @@ -../X32MidiClock/midi_bpm_calc.h \ No newline at end of file diff --git a/X32Link/midi_bpm_calc.h b/X32Link/midi_bpm_calc.h new file mode 100644 index 0000000..97dc865 --- /dev/null +++ b/X32Link/midi_bpm_calc.h @@ -0,0 +1,28 @@ +#pragma once +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +// Pure BPM-from-MIDI-clock math, no Arduino deps — host-testable. +// MIDI clock is 24 PPQN. A window of `window` timestamps spans (window-1) +// intervals; scale that up to a full beat. Returns 0.0f for a zero/invalid span. +float midi_bpm_calc(uint32_t span_us, int window); + +// Phase within the current bar [0, quantum) from a raw, directly-observed +// 24-PPQN pulse count (LNK-019). Unlike Link's phase — which is estimated +// from a clock-sync offset and carries clock-sync jitter — MIDI clock +// pulses are locally counted exactly, so this reading is *more* precise, +// not less, despite the simpler math. Returns 0.0f for quantum < 1. +float midi_phase_calc(uint32_t pulse_count, int quantum); + +// True once at least one full bar's worth of pulses (24 * quantum) has been +// observed since clock start — avoids a misleading phase reading from a +// partial bar at stream start. Returns false for quantum < 1. +bool midi_phase_valid(uint32_t pulse_count, int quantum); + +#ifdef __cplusplus +} +#endif diff --git a/X32Link/midi_clock.cpp b/X32Link/midi_clock.cpp deleted file mode 120000 index e1c0c75..0000000 --- a/X32Link/midi_clock.cpp +++ /dev/null @@ -1 +0,0 @@ -../X32MidiClock/midi_clock.cpp \ No newline at end of file diff --git a/X32Link/midi_clock.cpp b/X32Link/midi_clock.cpp new file mode 100644 index 0000000..943c887 --- /dev/null +++ b/X32Link/midi_clock.cpp @@ -0,0 +1,89 @@ +#include "midi_clock.h" +#include "config.h" +#include +#include + +#define RING_SIZE (MCK_CLOCK_WINDOW * 2) + +// USB product string a DAW lists this device under. Renamed from "X32MidiClock" +// when that sketch was retired (LNK-024); safe to change only because no units +// are in users' hands — a saved MIDI port would otherwise re-appear as new. +USBMIDI MidiUSB("X32Link"); + +// LNK-027: track whether the USBMIDI endpoint is already up, so the OUT path +// (Link mode) and the IN path (MIDI mode) can each call begin() without a +// double-begin. +static bool s_usb_begun = false; + +static volatile uint32_t s_ring[RING_SIZE]; +static volatile uint32_t s_head = 0; +static volatile uint32_t s_pulse_count = 0; +static volatile bool s_beat_flag = false; +static portMUX_TYPE s_mux = portMUX_INITIALIZER_UNLOCKED; + +// High-priority poll task — calls readPacket() at 1 ms intervals. +// MIDI Clock is a single-byte real-time message (0xF8), CIN=0x0F. +static void midi_poll_task(void*) { + midiEventPacket_t pkt; + for (;;) { + while (MidiUSB.readPacket(&pkt)) { + if (pkt.byte1 == 0xF8) { + uint32_t t = micros(); + portENTER_CRITICAL(&s_mux); + s_ring[s_head % RING_SIZE] = t; + s_head++; + s_pulse_count++; + if (s_pulse_count % MCK_PPQN == 0) s_beat_flag = true; // per beat, not per BPM window + portEXIT_CRITICAL(&s_mux); + } + } + vTaskDelay(1); // 1 ms — fine at 240 BPM (pulse every ~10 ms) + } +} + +void midi_clock_init() { + midi_clock_usb_begin(); + xTaskCreatePinnedToCore(midi_poll_task, "midi_poll", 2048, NULL, 5, NULL, 1); +} + +// LNK-027 MIDI clock OUT helpers (shared USBMIDI endpoint). +void midi_clock_usb_begin() { + if (s_usb_begun) return; + MidiUSB.begin(); + s_usb_begun = true; +} + +void midi_clock_send_f8() { + // System real-time Timing Clock (0xF8): USB-MIDI CIN 0x0F, cable 0. + midiEventPacket_t pkt = {0x0F, 0xF8, 0x00, 0x00}; + MidiUSB.writePacket(&pkt); +} + +uint32_t midi_clock_pulse_count() { + return s_pulse_count; +} + +uint32_t midi_clock_last_pulse_us() { + if (s_pulse_count == 0) return 0; + portENTER_CRITICAL(&s_mux); + uint32_t t = s_ring[(s_head - 1) % RING_SIZE]; + portEXIT_CRITICAL(&s_mux); + return t; +} + +bool midi_clock_beat_flag() { + if (!s_beat_flag) return false; + portENTER_CRITICAL(&s_mux); + s_beat_flag = false; + portEXIT_CRITICAL(&s_mux); + return true; +} + +// index 0 = oldest pulse in the current MCK_CLOCK_WINDOW window +uint32_t midi_clock_get_timestamp(uint32_t index) { + portENTER_CRITICAL(&s_mux); + uint32_t slot = (s_head - MCK_CLOCK_WINDOW + index) % RING_SIZE; + uint32_t t = s_ring[slot]; + portEXIT_CRITICAL(&s_mux); + return t; +} diff --git a/X32Link/midi_clock.h b/X32Link/midi_clock.h deleted file mode 120000 index b1418d9..0000000 --- a/X32Link/midi_clock.h +++ /dev/null @@ -1 +0,0 @@ -../X32MidiClock/midi_clock.h \ No newline at end of file diff --git a/X32Link/midi_clock.h b/X32Link/midi_clock.h new file mode 100644 index 0000000..d06c836 --- /dev/null +++ b/X32Link/midi_clock.h @@ -0,0 +1,15 @@ +#pragma once +#include + +void midi_clock_init(void); +uint32_t midi_clock_pulse_count(void); +uint32_t midi_clock_last_pulse_us(void); +uint32_t midi_clock_get_timestamp(uint32_t index); // index 0 = oldest in window +bool midi_clock_beat_flag(void); // returns true once per beat, then clears + +// LNK-027 MIDI clock OUT: bring up the shared USBMIDI endpoint (idempotent — +// safe alongside midi_clock_init()) and send one 0xF8 real-time clock tick. +// The IN path (midi_clock_init/poll) and the OUT path are mutually exclusive by +// input_source, but both share this one USBMIDI device. +void midi_clock_usb_begin(void); +void midi_clock_send_f8(void); diff --git a/X32Link/midi_clock_out.c b/X32Link/midi_clock_out.c new file mode 100644 index 0000000..b1fe09a --- /dev/null +++ b/X32Link/midi_clock_out.c @@ -0,0 +1,11 @@ +// 24-PPQN MIDI clock OUT — a thin adapter over the shared clock_ticker engine. +// See midi_clock_out.h. +#include "midi_clock_out.h" + +void midi_clock_out_reset(MidiClockOut* s) { + clock_ticker_reset(s); +} + +int midi_clock_out_ticks_due(MidiClockOut* s, double beats_now, int max_burst) { + return clock_ticker_ticks_due(s, beats_now, MIDI_CLOCK_OUT_PPQN, max_burst); +} diff --git a/X32Link/midi_clock_out.h b/X32Link/midi_clock_out.h new file mode 100644 index 0000000..33bcc20 --- /dev/null +++ b/X32Link/midi_clock_out.h @@ -0,0 +1,30 @@ +#pragma once +// 24-PPQN MIDI clock OUT scheduler (LNK-027) — a thin adapter over the shared, +// pure clock_ticker engine (LNK-028 uses the same engine at a configurable +// PPQN). No Arduino/USB dependency — host-tested in test/test_midi_clock_out.c. +// The thin glue (midi_clock_out_io.cpp) owns the timer task and the TinyUSB +// writes. Ticks are phase-locked: the first tick lands on phase-zero and the +// clock stays bar-aligned, not just tempo-matched. +#include "clock_ticker.h" + +#ifdef __cplusplus +extern "C" { +#endif + +#define MIDI_CLOCK_OUT_PPQN 24 + +// MIDI clock out is a ClockTicker fixed at 24 PPQN. +typedef ClockTicker MidiClockOut; + +// Clear state: the next ticks_due() call re-primes the grid (emits nothing). +void midi_clock_out_reset(MidiClockOut* s); + +// Advance the scheduler to the current monotonic beat position and return how +// many 0xF8 ticks to emit now (24 PPQN). Same semantics as +// clock_ticker_ticks_due() with ppqn=24 — prime-on-first, no backward/backlog, +// re-prime (return 0) on a forward jump larger than max_burst. +int midi_clock_out_ticks_due(MidiClockOut* s, double beats_now, int max_burst); + +#ifdef __cplusplus +} +#endif diff --git a/X32Link/midi_clock_out_io.cpp b/X32Link/midi_clock_out_io.cpp new file mode 100644 index 0000000..67794a0 --- /dev/null +++ b/X32Link/midi_clock_out_io.cpp @@ -0,0 +1,150 @@ +// LNK-027 glue: drive USB-MIDI clock OUT from the Link phase pipeline. See +// midi_clock_out_io.h. Timing math is in the pure, host-tested clock_output.c +// (ARC-019 — the same derivation KitchenSync and the Touch use); this file +// only owns the FreeRTOS task and the TinyUSB writes. +#include "midi_clock_out_io.h" +#include "clock_output.h" +#include "midi_clock.h" // midi_clock_send_f8() +#include "din_midi_out.h" // ESP-016: mirror 0xF8 onto the DIN wire (S3 -> RC-505) +#include "tempo_source.h" // tempo_source_beats_now() +#include + +// Burst cap + reset-on-invalid live in clock_output (CLOCK_OUTPUT_MAX_BURST), +// shared with the Touch's writer instead of copy-pasted into each task +// (ARC-019). At the 1ms cadence a normal step is 0-1 ticks even at high tempo, +// so 4 is generous slack while still catching a real discontinuity. +static ClockOutput s_out; + +/* ARC-024 tick-health probe. This firmware has never been measured. The Touch's + * identical writer, on identical silicon, turned out to be losing MIDI pulses under web + * load (ESP-018) — and nothing about that was visible on serial, on the wire, or in any + * counter. It took a probe to see it, and X32Link has not had one. + * + * Two candidate causes, and they are told apart by which number moves: + * + * gap >> 1ms -> the task was never SCHEDULED. The cause is OUTSIDE this loop: + * preemption by something higher (lwIP's tcpip_task sits at 18, this + * task at 6), or a flash-cache stall, which freezes BOTH cores + * regardless of priority or pinning. + * work >> 1ms -> a call INSIDE the tick blocked. w_beats / w_clock say which. + * + * On the Touch the answer was gap, and the smoking gun was the stage split: + * tempo_source_beats_now() measured 62us idle and 5038us under load — an 80x blowup + * with no lock anywhere in that path. It was never blocking. It was being preempted. + * + * Published, never logged. See the header for why that is not a stylistic preference. */ +static volatile uint32_t s_max_gap_us = 0; +static volatile uint32_t s_max_work_us = 0; +static volatile uint32_t s_overruns = 0; +static volatile uint32_t s_bursts = 0; // ticks that emitted >1 pulse = catch-up +/* P4-038: re-prime episodes. clock_output_step() re-primes the grid whenever phase is + * invalid (pre-sync, peer loss, mid re-measure). That emits nothing and drops nothing, so + * it is invisible in every other counter -- yet the wire goes silent while it lasts. On + * the P4 the probe showed a perfectly-scheduled clock task (gap 1.5ms, zero overruns) whose + * wire still stalled 150 ms: a task that is never late cannot explain a wire that stops, + * unless the grid moved. Count the EPISODE (the valid->invalid edge), not the ticks. */ +static volatile uint32_t s_reprimes = 0; +static bool s_phase_was_valid = true; +static volatile int s_core = -1; // which core this task ACTUALLY runs on +static volatile uint32_t s_w_beats = 0, s_w_clock = 0; // worst-tick stage split + +static volatile bool s_running = false; // the writer task was actually created + +bool midi_clock_out_io_health(WebTickHealth* out) { + if (!out || !s_running) return false; + out->max_gap_us = s_max_gap_us; + out->max_work_us = s_max_work_us; + out->overruns = s_overruns; + out->bursts = s_bursts; + out->core = s_core; + out->w_beats = s_w_beats; + out->w_clock = s_w_clock; + // Lifetime total, banked across resets inside clock_output (ARC-019). This + // is the number that matters most: a stall long enough to trip the realign + // leaves no burst and no gap on the wire — this counter is the only trace. + out->dropped = clock_output_dropped(&s_out); + // P4-038: re-prime episodes. A stall that moves the GRID drops nothing and bursts + // nothing -- it is invisible in every counter above, yet the wire goes quiet. + out->reprimes = s_reprimes; + return true; +} + +// 1ms high-priority task: quantize the current Link beat position to 24 PPQN and +// emit any due 0xF8 ticks. Resets the scheduler whenever phase isn't valid +// (pre-sync, peer loss, mid re-measure) so we never spray ticks off a stale or +// absent timeline, and re-prime cleanly when it comes back. +static void midi_clock_out_task(void*) { + s_core = xPortGetCoreID(); // ARC-024: confirm, don't assume. ESP-018's ticket was + // WRONG about core assignment; only measuring caught it. + clock_output_reset(&s_out); + uint32_t prev_end = 0; + /* vTaskDelayUntil, NOT vTaskDelay (ESP-018, ported here by ARC-024). + * + * vTaskDelay(1) sleeps one tick FROM NOW, so the period re-bases on every wake: the + * loop body's own runtime and any preemption latency are ADDED to each cycle rather + * than absorbed by it. Measured on the Touch, that alone produced gaps of up to + * 5.7 ms with the board completely idle and no HTTP traffic. + * + * vTaskDelayUntil holds an ABSOLUTE 1 ms cadence: a tick that runs late fires + * immediately instead of adding another whole tick on top, so the clock grid stops + * inheriting the scheduler's slop. */ + TickType_t next_wake = xTaskGetTickCount(); + for (;;) { + uint32_t tk0 = micros(); + uint32_t gap = prev_end ? (tk0 - prev_end) : 0; + + double beats = tempo_source_beats_now(); // <0 when phase not valid + if (beats < 0.0) { + if (s_phase_was_valid) { s_reprimes++; s_phase_was_valid = false; } // P4-038 + } else { + s_phase_was_valid = true; + } + uint32_t t_beats = micros(); + // One call owns the whole derivation (ARC-019): reset-on-invalid + + // dropped banking + burst cap inside clock_output_step. ppqn 24, + // nudge 0, swing 0 until X32Link grows the config fields (the Touch + // already feeds its nudge here; the derivation supports all three). + int n = clock_output_step(&s_out, beats, 24, 0, 0); + if (n > 1) s_bursts++; // more than one pulse in a 1 ms tick = catching up + // Same code point for both wires: the DIN byte and the USB packet leave + // microseconds apart, so DIN gear (ESP-016) is clocked in lockstep with + // the USB path -- and DIN clocks even with no USB host attached. + for (int i = 0; i < n; i++) { din_midi_out_byte(0xF8); midi_clock_send_f8(); } + + uint32_t tk1 = micros(); + uint32_t work = tk1 - tk0; + if (gap > s_max_gap_us) s_max_gap_us = gap; + if (work > s_max_work_us) { + s_max_work_us = work; + s_w_beats = t_beats - tk0; // tempo_source_beats_now() + s_w_clock = tk1 - t_beats; // scheduling + DIN byte + USB packet + } + if (gap > 5000 || work > 5000) s_overruns++; + prev_end = tk1; + + vTaskDelayUntil(&next_wake, 1); // absolute 1 ms cadence, no drift + } +} + +void midi_clock_out_io_begin(void) { + midi_clock_usb_begin(); // idempotent; USB.begin() is done in tempo_source_pre_net + din_midi_out_begin(MIDI_TX_GPIO); // ESP-016: DIN MIDI out on the S3 header + /* ARC-024: priority 19, core 1 — the ESP-018 fix, ported to the firmware that never + * got it. This task ran at 6, which is BELOW lwIP's tcpip_task (18): the network + * stack could legally preempt the clock generator, and on the Touch, on identical + * silicon, it did — costing real MIDI pulses under web load. + * + * 19 is the only defensible number, and it is a sandwich: + * - ABOVE lwIP (18), so serving the web UI cannot deschedule the clock. + * - BELOW WiFi (23), because starving the radio to feed the clock loses the Link + * session, and a perfectly-timed clock with no tempo is worth nothing. + * + * Core 1 is unchanged, and deliberately: Arduino's loopTask is on core 1 too + * (CONFIG_ARDUINO_RUNNING_CORE=1), so the writer shares a core with the web server. + * It now out-prioritises everything there. What priority CANNOT fix is a flash-cache + * stall, which freezes both cores regardless — which is exactly why the config write + * in ARC-022 is debounced and kept on a low-priority task. */ + xTaskCreatePinnedToCore(midi_clock_out_task, "midi_clk_out", 2048, NULL, 19, NULL, 1); + s_running = true; // ARC-024: /status omits tick health entirely until this is set, + // so a row of zeroes can never be mistaken for "measured, clean". +} diff --git a/X32Link/midi_clock_out_io.h b/X32Link/midi_clock_out_io.h new file mode 100644 index 0000000..473a11a --- /dev/null +++ b/X32Link/midi_clock_out_io.h @@ -0,0 +1,29 @@ +#pragma once +// LNK-027 glue for Link → USB-MIDI clock OUT. Owns the high-priority timer task +// that reads the monotonic beat position from the tempo_source seam, runs the +// pure midi_clock_out.c scheduler, and writes 0xF8 ticks over USB-MIDI. Not +// host-testable (needs FreeRTOS + TinyUSB) — all timing math lives in the pure +// module. Call once, after USB + the phase pipeline are up, only when +// input_source == Link and midi_clock_out_enable. + +#include +#include +#include "web_status_json.h" // WebTickHealth + +void midi_clock_out_io_begin(void); + +// ARC-024 tick health, surfaced in /status. X32Link is the firmware that has been +// running the LONGEST and was measured the LEAST — it carried no probe at all, on the +// same S3 silicon, the same on-die WiFi and the same lwIP priority that produced +// ESP-018 on the Touch. Every structural precondition for that bug is present here. +// +// The writer NEVER logs: an ESP_LOGx in a 1 ms real-time task is a blocking UART write +// (~10 ms at 115200), and that WAS the bug on the P4 (P4-033). Worse, the first version +// of this probe on the Touch logged on every overrun — the log delayed the next tick, +// which overran, which logged — manufacturing 3945 phantom overruns in 40 s. So the +// writer only ever PUBLISHES plain scalars and the web layer reads them. +// +// Returns false when the writer task was never started (clock-out disabled, or the +// device is on MIDI input rather than Link) — /status then omits the block entirely +// rather than publishing a row of zeroes that would read as "measured, all clean". +bool midi_clock_out_io_health(WebTickHealth* out); diff --git a/X32Link/session_timeline.c b/X32Link/session_timeline.c new file mode 100644 index 0000000..2f1e175 --- /dev/null +++ b/X32Link/session_timeline.c @@ -0,0 +1,51 @@ +// Pure settled-timeline owner + epoch debounce — see session_timeline.h (ARC-011). +#include "session_timeline.h" +#include "link_phase.h" // link_phase_timeline_epoch_reset() +#include + +void session_timeline_reset(SessionTimeline* st) { + memset(st, 0, sizeof(*st)); +} + +bool session_timeline_observe(SessionTimeline* st, bool tl_valid, + LinkTimeline raw, int64_t now_us) { + if (!tl_valid) return false; // no timeline this poll — hold settled + + if (!st->have_settled) { // first timeline: adopt, no reset + st->settled = raw; + st->have_settled = true; + return false; + } + + bool backward = link_phase_timeline_epoch_reset(st->settled.time_origin_us, + raw.time_origin_us); + if (backward) { + // A backward time_origin jump is either a genuine transport re-origin or a + // joining peer briefly gossiping its own un-synced timeline (P4-028), which + // last-writer-wins makes indistinguishable here. Debounce: only confirm once + // the jump PERSISTS past the settle window; a corrective gossip (origin back to + // the session's) clears the candidate below, so a transient join never resets. + if (!st->epoch_pending) { + st->epoch_pending = true; + st->epoch_pending_since_us = now_us; + } + if (now_us - st->epoch_pending_since_us >= SESSION_TIMELINE_SETTLE_US) { + st->settled = raw; // adopt the confirmed epoch + st->epoch_pending = false; + return true; // caller resets xform + re-measures + } + return false; // pending — keep serving the last good timeline + } + + // Not a backward jump: a normal step, or a correction that undid a transient junk + // timeline. Clear any pending candidate and track the fresh timeline. + st->epoch_pending = false; + st->settled = raw; + return false; +} + +bool session_timeline_settled(const SessionTimeline* st, LinkTimeline* out) { + if (!st->have_settled) return false; + if (out) *out = st->settled; + return true; +} diff --git a/X32Link/session_timeline.h b/X32Link/session_timeline.h new file mode 100644 index 0000000..e70d093 --- /dev/null +++ b/X32Link/session_timeline.h @@ -0,0 +1,52 @@ +#pragma once +// ARC-011: the single owner of "what Link timeline is safe to play right now". +// +// The gossiped timeline (link_protocol.c's last-writer-wins store) is observed here. +// A backward time_origin jump is DEBOUNCED (P4-028): a joining peer briefly gossips +// its own un-synced timeline, which looks like a transport re-origin. We hold the +// last-good timeline across a settle window; a corrective gossip clears the candidate, +// a genuine re-origin persists and confirms. +// +// link_protocol owns an instance: link_proto_timeline() returns settled(), so BOTH +// consumers — the epoch/xform logic (via the confirm signal) and the beat/phase math +// (via the returned timeline) — see the same held-last-good timeline with no code +// change. Before ARC-011 the held-good origin was trapped inside LinkSession while the +// beat math read the raw store, so the junk window still glitched the beat. Pure, no +// I/O; see test/test_session_timeline.c. +#include +#include +#include "link_protocol.h" // LinkTimeline + +#ifdef __cplusplus +extern "C" { +#endif + +// A backward time_origin jump must persist this long to count as a genuine re-origin +// (not a joining peer's transient un-synced gossip). Tuned to the observed ~500 ms +// peer-join transient; validate/tune on hardware. +#define SESSION_TIMELINE_SETTLE_US 500000 // 500 ms + +typedef struct { + bool have_settled; // adopted a first timeline yet? + LinkTimeline settled; // last-good timeline, held across a debounce + bool epoch_pending; // a backward jump is being debounced + int64_t epoch_pending_since_us;// when the pending jump was first seen +} SessionTimeline; + +// Reset all state. +void session_timeline_reset(SessionTimeline* st); + +// Observe the raw gossiped timeline. tl_valid=false is a no-op. Returns true iff a +// genuine re-origin just CONFIRMED — the caller must then reset the committed +// GhostXForm and re-measure. Updates the held-last-good `settled`: adopts `raw` on a +// confirmed re-origin, keeps the prior good timeline while a backward jump is pending, +// tracks `raw` on any non-backward step. +bool session_timeline_observe(SessionTimeline* st, bool tl_valid, + LinkTimeline raw, int64_t now_us); + +// Read the settled (held-last-good) timeline. Returns false until the first observe. +bool session_timeline_settled(const SessionTimeline* st, LinkTimeline* out); + +#ifdef __cplusplus +} +#endif diff --git a/X32Link/swing.c b/X32Link/swing.c new file mode 100644 index 0000000..ac4eafd --- /dev/null +++ b/X32Link/swing.c @@ -0,0 +1,19 @@ +#include "swing.h" +#include + +double swing_warp(double beats, int swing_mbeats) { + if (swing_mbeats <= 0) return beats; // straight + + double ratio = 0.5 + (double)swing_mbeats / 1000.0; // (0.5, 0.75] + if (ratio > 0.75) ratio = 0.75; // config caps at 250, but be safe + + double n = floor(beats); + double f = beats - n; // fraction within the beat, [0, 1) + + // Piecewise-linear map: the first eighth [0, ratio) real -> [0, 0.5) grid + // (stretched), the second eighth [ratio, 1) real -> [0.5, 1) grid (compressed). + // Continuous at f == ratio (both give 0.5) and f == 0/1 (fixed points). + double w = (f < ratio) ? f * 0.5 / ratio + : 0.5 + (f - ratio) * 0.5 / (1.0 - ratio); + return n + w; +} diff --git a/X32Link/swing.h b/X32Link/swing.h new file mode 100644 index 0000000..f594c5c --- /dev/null +++ b/X32Link/swing.h @@ -0,0 +1,24 @@ +#pragma once +// Pure swing/shuffle time-warp (P4-013). Delays the off-eighth of each beat so a +// straight clock grooves. Within beat n, the eighth-note boundary that sits at +// n+0.5 in a straight feel is pushed to n + ratio, where +// ratio = 0.5 + swing_mbeats/1000 (clamped to 0.75). +// swing_mbeats is milli-beats of delay applied to that boundary — same unit as the +// per-output phase nudge — so 0 = straight, ~167 = triplet (2:1) feel, 250 = max. +// +// The warp is continuous, monotonic, and maps every whole beat onto itself, so no +// pulses are gained or lost across a beat — they are just redistributed (long +// first eighth, short second). Feed the warped position to clock_ticker / +// clock_output before quantizing. Host-tested in test/test_swing.c. No +// Arduino/ESP-IDF dependency. +#ifdef __cplusplus +extern "C" { +#endif + +// Map a linear beat position to its swung position for `swing_mbeats` of swing. +// swing_mbeats <= 0 returns beats unchanged (straight). +double swing_warp(double beats, int swing_mbeats); + +#ifdef __cplusplus +} +#endif diff --git a/X32Link/tempo_snapshot.c b/X32Link/tempo_snapshot.c new file mode 100644 index 0000000..8d4ff7e --- /dev/null +++ b/X32Link/tempo_snapshot.c @@ -0,0 +1,35 @@ +#include "tempo_snapshot.h" + +// Lock: on ESP32/Arduino a portMUX spinlock — brief, safe from any task, and +// what protects the publish/read from tearing across a context switch. On the +// host test build there is no concurrency, so it compiles to nothing. Same +// pure-core / thin-glue split the rest of the firmware uses (see AGENTS.md), +// which keeps this module host-testable. +#if defined(ARDUINO) || defined(ESP_PLATFORM) + #include "freertos/FreeRTOS.h" + static portMUX_TYPE s_mux = portMUX_INITIALIZER_UNLOCKED; + #define TS_ENTER() portENTER_CRITICAL(&s_mux) + #define TS_EXIT() portEXIT_CRITICAL(&s_mux) +#else + #define TS_ENTER() ((void)0) + #define TS_EXIT() ((void)0) +#endif + +static TempoSnapshot s_snap = { 0.0f, -1.0f, false, 4 }; + +void tempo_snapshot_publish(float bpm, float phase, bool valid, int quantum) { + bool coherent = valid && phase >= 0.0f; // never valid with a sentinel phase + TS_ENTER(); + s_snap.bpm = bpm; + s_snap.phase = coherent ? phase : -1.0f; + s_snap.valid = coherent; + s_snap.quantum = quantum; + TS_EXIT(); +} + +void tempo_snapshot_read(TempoSnapshot* out) { + if (!out) return; + TS_ENTER(); + *out = s_snap; + TS_EXIT(); +} diff --git a/X32Link/tempo_snapshot.h b/X32Link/tempo_snapshot.h new file mode 100644 index 0000000..cbfcb54 --- /dev/null +++ b/X32Link/tempo_snapshot.h @@ -0,0 +1,30 @@ +#pragma once +#include + +#ifdef __cplusplus +extern "C" { +#endif + +// One coherent reading of the live tempo (ARC-001). Published by the bpm task, +// read by the web /status endpoint, the on-device UI, and serial logging. The +// whole struct is published/read under a lock so a reader never observes a torn +// pair — e.g. the old three-loose-globals path could emit valid=true alongside +// a stale phase=-1. Here that combination is impossible by construction. +typedef struct { + float bpm; // last published BPM + float phase; // [0, quantum) when valid; < 0 (sentinel) when not + bool valid; // phase is a real, trustworthy reading + int quantum; // beats per bar the phase is measured against +} TempoSnapshot; + +// Publish a coherent snapshot. One writer (the bpm task). Enforces the +// invariant that `valid` implies a non-negative phase: a would-be contradictory +// (valid && phase < 0) publish is stored as not-valid. +void tempo_snapshot_publish(float bpm, float phase, bool valid, int quantum); + +// Read the current snapshot atomically into *out. Many readers. NULL-safe. +void tempo_snapshot_read(TempoSnapshot* out); + +#ifdef __cplusplus +} +#endif diff --git a/X32Link/tempo_source.cpp b/X32Link/tempo_source.cpp index 3c5be53..ad4d741 100644 --- a/X32Link/tempo_source.cpp +++ b/X32Link/tempo_source.cpp @@ -4,34 +4,66 @@ #include "tempo_source.h" #include "config.h" #include "link_listener.h" +#include "link_measurement_io.h" +#include "link_protocol.h" +#include "link_measurement.h" +#include "link_phase.h" #include "midi_clock.h" #include "midi_bpm.h" +#include "midi_bpm_calc.h" +#include "midi_clock_out_io.h" // LNK-027: Link -> USB-MIDI clock OUT +#include "beat_synth.h" // LNK-033: pure free-run beat generator +#include "app_config.h" #include #include +#include "esp_timer.h" // esp_timer_get_time() — same clock as LNK-018's + // measurement path; see link_measurement_io.cpp static int s_kind = TEMPO_SRC_LINK; +// quantum_beats lives on the shared AppConfig (X32Link.ino owns the +// instance) — read directly here rather than threading it through every +// call, same pattern as osc_sender.cpp/web_config.cpp. +extern AppConfig g_config; + extern "C" void tempo_source_select(int kind) { s_kind = (kind == TEMPO_SRC_MIDI) ? TEMPO_SRC_MIDI : TEMPO_SRC_LINK; } -// USB MIDI must enumerate BEFORE WiFi. Link has nothing to do here. +// USB MIDI must enumerate BEFORE WiFi. In Link mode we normally have nothing to +// do here — but if MIDI clock OUT is enabled (LNK-027) we still need the USB MIDI +// device enumerated pre-WiFi so the host sees it, same ordering as the MIDI-in path. extern "C" void tempo_source_pre_net(void) { if (s_kind == TEMPO_SRC_MIDI) { USB.manufacturerName("X32Sync"); midi_clock_init(); // MidiUSB.begin() + spawns the poll task USB.begin(); midi_bpm_init(); + } else if (s_kind == TEMPO_SRC_LINK && g_config.midi_clock_out_enable) { + USB.manufacturerName("X32Sync"); + midi_clock_usb_begin(); // MidiUSB.begin() only — no IN poll task in Link mode + USB.begin(); } } -// Link joins multicast AFTER WiFi is up. +// Link joins multicast AFTER WiFi is up. The measurement client (LNK-018) +// rides along on its own unicast socket — it doesn't expose phase yet +// (that's LNK-019), it just keeps a GhostXForm warm in the background once +// a peer is discovered. extern "C" void tempo_source_begin(void) { - if (s_kind == TEMPO_SRC_LINK) link_listener_begin(); + if (s_kind == TEMPO_SRC_LINK) { + link_listener_begin(); + link_measurement_io_begin(); + if (g_config.midi_clock_out_enable) midi_clock_out_io_begin(); // LNK-027 + } } extern "C" void tempo_source_poll(void) { - if (s_kind == TEMPO_SRC_LINK) { link_listener_poll(); link_listener_tick(); } + if (s_kind == TEMPO_SRC_LINK) { + link_listener_poll(); + link_listener_tick(); + link_measurement_io_poll(); + } // MIDI is interrupt/task-driven (midi_poll_task) — nothing to pump here. } @@ -48,12 +80,59 @@ extern "C" bool tempo_source_active(void) { // LED beat: MIDI forwards the clock's beat flag; Link synthesises from BPM. extern "C" bool tempo_source_beat(void) { if (s_kind == TEMPO_SRC_MIDI) return midi_clock_beat_flag(); - float bpm = (float)link_listener_bpm(); - if (bpm <= 0.0f || link_listener_peers() == 0) return false; - static uint32_t last = 0; - uint32_t now = millis(); - if (now - last >= (uint32_t)(60000.0f / bpm)) { last = now; return true; } - return false; + if (link_listener_peers() == 0) return false; + static BeatSynth s_beat = {0}; // LNK-033 + return beat_synth_step(&s_beat, millis(), (float)link_listener_bpm()); +} + +// Phase within the current quantum (bar) — LNK-019. Link: maps our local +// esp_timer clock into the session's shared GHostTime domain via LNK-018's +// committed GhostXForm, reads LNK-017's parsed timeline, and runs the pure +// beats/phase math in link_phase.c. MIDI: pulse-count modulo pulses-per-bar +// via midi_bpm_calc.c's midi_phase_calc() — exact, not estimated, since +// MIDI clock pulses are directly observed. +extern "C" float tempo_source_phase(float quantum) { + if (!tempo_source_phase_valid()) return -1.0f; + + if (s_kind == TEMPO_SRC_LINK) { + LinkTimeline timeline; + link_proto_timeline(&timeline); // valid() already confirmed true + LinkGhostXForm xform = link_measurement_current_xform(); + int64_t ghost_now_us = link_ghost_xform_host_to_ghost(xform, esp_timer_get_time()); + double beats_now = link_phase_beats_now(timeline, ghost_now_us); + return (float)link_phase_from_beats(beats_now, (double)quantum); + } + + return midi_phase_calc(midi_clock_pulse_count(), (int)quantum); +} + +// Monotonic absolute beats (non-wrapping) — the clock generator's tick basis. +// Same math as tempo_source_phase() but without the fmod(quantum). See header. +extern "C" double tempo_source_beats_now(void) { + if (!tempo_source_phase_valid()) return -1.0; + + if (s_kind == TEMPO_SRC_LINK) { + LinkTimeline timeline; + link_proto_timeline(&timeline); // valid() already confirmed true + LinkGhostXForm xform = link_measurement_current_xform(); + int64_t ghost_now_us = link_ghost_xform_host_to_ghost(xform, esp_timer_get_time()); + return link_phase_beats_now(timeline, ghost_now_us); + } + + // MIDI: 24 PPQN pulse count → beats. Monotonic while the clock runs. + return (double)midi_clock_pulse_count() / (double)MCK_PPQN; +} + +extern "C" bool tempo_source_phase_valid(void) { + if (s_kind == TEMPO_SRC_LINK) { + LinkTimeline timeline; + // Valid once we hold a phase estimate (a committed GhostXForm) and the + // timeline is parsed. The seam only exposes the honest question now — + // active() isn't reachable here, so the old "flashing dot" bug (gating + // on an in-flight attempt) is impossible by construction. See ARC-002. + return link_measurement_have_phase_estimate() && link_proto_timeline(&timeline); + } + return midi_phase_valid(midi_clock_pulse_count(), g_config.quantum_beats); } extern "C" float tempo_source_threshold(void) { diff --git a/X32Link/tempo_source.h b/X32Link/tempo_source.h index a0fbd04..5f5fd98 100644 --- a/X32Link/tempo_source.h +++ b/X32Link/tempo_source.h @@ -17,8 +17,34 @@ void tempo_source_begin(void); // post-WiFi init (Link joins multicast h void tempo_source_poll(void); // service the input every bpm_task tick float tempo_source_bpm(void); // current BPM; 0.0 = no live signal bool tempo_source_active(void); // peers>0 / pulses within timeout + +// Per-beat blink (LNK-008): MIDI forwards the real clock's beat flag; Link +// synthesizes one locally from BPM via a millis()-timed interval. Cheap and +// always available — doesn't require a completed Link measurement. This is +// coarser than tempo_source_phase() crossing zero: once +// tempo_source_phase_valid() is true, watching phase() cross zero is a +// *more accurate* beat-crossing event (Link: derived from the session's +// actual timeline, not re-synthesized; MIDI: pulse-exact, not just +// BPM-interval timed). The two are not redundant — beat() stays useful as +// a fallback before phase() has enough data to be valid. See LNK-019. bool tempo_source_beat(void); // true once per beat, self-clears (LED) +// Phase within the current quantum (bar), range [0, quantum). Returns +// -1.0f if no valid phase reading is available yet (no completed +// GhostXForm for Link; less than one full bar of pulses for MIDI) — check +// tempo_source_phase_valid() first. See LNK-019. +float tempo_source_phase(float quantum); + +// True once tempo_source_phase() can return a real value. +bool tempo_source_phase_valid(void); + +// Monotonic absolute beat position of the active source, or -1.0 when phase +// isn't valid yet. Unlike tempo_source_phase() this does NOT wrap at the +// quantum — it grows continuously with the timeline, which is what a clock +// generator needs to quantize to a 1/24-beat tick grid (LNK-027). Link: the +// session's beats-now; MIDI: pulse_count / 24. +double tempo_source_beats_now(void); + float tempo_source_threshold(void); // per-input change threshold uint32_t tempo_source_poll_ms(void); // per-input poll interval diff --git a/X32Link/touch_display.cpp b/X32Link/touch_display.cpp new file mode 100644 index 0000000..8d2501c --- /dev/null +++ b/X32Link/touch_display.cpp @@ -0,0 +1,528 @@ +#include "config.h" // LNK-025: board->HAS_TOUCH_DISPLAY derivation; must precede the guard +#include "touch_display.h" + +// Whole module compiles only for the touch-LCD board — keeps LovyanGFX out of +// screenless builds (Super Mini / XIAO / QT Py). X32Link.ino calls these under +// the same guard, so no undefined-reference when it's off. +#ifdef HAS_TOUCH_DISPLAY + +#include +#include +#include // WiFi.localIP() for the on-screen config address +#define LGFX_USE_V1 +#include +#include "axs5106l.h" +#include "touch_ui.h" // LNK-015: pure UI logic (hit-test, formatting, taps) +#include "tempo_source.h" // tempo_source_active() (side-effect-free read) +#include "tempo_snapshot.h" // ARC-001: atomic {bpm,phase,valid,quantum} + +extern AppConfig g_config; +// Live tempo via tempo_snapshot_read() — one coherent read, no torn fields. +// (Do NOT call tempo_source_beat() here — it self-clears and led_task owns it.) + +// ---- Panel (validated on glass, LNK-014 Progress) -------------------------- +// JD9853 via LovyanGFX Panel_ST7789. Pins: SCK38 MOSI39 DC45 CS21 RST40, BL46. +// 172x320 visible in 240x320 controller RAM -> offset_x=34. invert=false, +// setRotation(6) after LNK-035. Backlight driven manually (GPIO46 HIGH), not Light_PWM. +class LGFX : public lgfx::LGFX_Device { + lgfx::Panel_ST7789 _panel; + lgfx::Bus_SPI _bus; +public: + LGFX() { + { auto c = _bus.config(); + c.spi_host = SPI2_HOST; c.spi_mode = 0; + c.freq_write = 40000000; c.freq_read = 16000000; + c.pin_sclk = 38; c.pin_mosi = 39; c.pin_miso = -1; c.pin_dc = 45; + c.dma_channel = SPI_DMA_CH_AUTO; + _bus.config(c); _panel.setBus(&_bus); } + { auto c = _panel.config(); + c.pin_cs = 21; c.pin_rst = 40; c.pin_busy = -1; + c.memory_width = 240; c.memory_height = 320; + c.panel_width = 172; c.panel_height = 320; + c.offset_x = 34; c.offset_y = 0; + c.invert = false; c.rgb_order = false; + _panel.config(c); } + setPanel(&_panel); + } +}; + +#define TP_SDA 42 +#define TP_SCL 41 +#define TP_RST 47 +#define LCD_BL 46 + +static LGFX s_lcd; +static uint32_t s_last_print_ms = 0; + +static void backlight_on(void) { + pinMode(LCD_BL, OUTPUT); + digitalWrite(LCD_BL, HIGH); +} + +static void draw_splash(void) { + s_lcd.fillScreen(TFT_BLACK); + s_lcd.setTextColor(TFT_WHITE, TFT_BLACK); + s_lcd.setTextSize(3); + s_lcd.setCursor(8, 40); s_lcd.println("X32"); + s_lcd.setCursor(8, 80); s_lcd.println("SYNC"); + s_lcd.setTextSize(1); + s_lcd.setCursor(8, 130); s_lcd.println("booting..."); +} + +// LNK-015 screen state (settings/keyboard screens land in later tasks). +enum ui_screen_t { SCREEN_STATUS, SCREEN_SETTINGS, SCREEN_KEYBOARD }; +static ui_screen_t s_screen = SCREEN_STATUS; + +// Status-screen phase wheel geometry (portrait 172x320, setRotation(6) after LNK-035). +#define WHEEL_CX 86 +#define WHEEL_CY 252 +#define WHEEL_R 44 + +// touch_display_update() repaint caches (reset by draw_status()). +static char s_bpm_shown[8] = ""; +static char s_ip_shown[16] = ""; +static int s_prev_mx = -1, s_prev_my = -1; +static int s_prev_beat = -1; // LNK-036: last beat index shown (flash mode) +static bool s_flash_on = false; // LNK-036: dot currently lit (flash mode) +static uint32_t s_flash_off_at_ms = 0; // LNK-036: when to blank the flash +static int s_prev_mode = -1; // LNK-037: last phase_display_mode drawn +static bool s_wheel_valid_shown = false; +static uint32_t s_last_update_ms = 0; + +// Tap targets (screen coords after the rotation-6 map (LNK-035)). Task 9: nav only. +static const ui_rect_t STATUS_SET_RECT = {116, 4, 52, 28}; // "SET" on status +static const ui_rect_t SETTINGS_BACK_RECT = {8, 4, 40, 28}; // "<" top-left +static const ui_rect_t SETTINGS_WRITE_RECT = {8, 272, 156, 40}; // Write & Reboot + +#define ACT_BACK 100 // tap-table action ids (outside the UI_F_* range) +#define ACT_WRITE 101 +#define ACT_IP_EDIT 102 // settings: open the IP keypad +#define ACT_KBD_BS 110 // keypad: backspace / OK / cancel +#define ACT_KBD_OK 111 +#define ACT_KBD_CANCEL 112 +// (digit/'.' keys carry their own ASCII char as the field id, all < 100) + +// Settings edit state (Task 10): a working copy of g_config that taps mutate, +// plus the current screen's interactive rect table (rebuilt each draw). Task 12 +// adds s_kbd_buf, the IP being typed on the keypad screen. +static AppConfig s_working; +static char s_kbd_buf[16]; +static ui_rect_t s_set_rects[24]; +static int s_set_fields[24]; +static int s_set_n = 0; + +// LNK-015 Task 7/8: status screen. draw_status() paints the static frame; +// touch_display_update() (Task 8) refreshes the live BPM number + phase wheel. +static void draw_status(void) { + s_lcd.fillScreen(TFT_BLACK); + + s_lcd.setTextColor(TFT_DARKGREEN, TFT_BLACK); + s_lcd.setTextSize(1); + s_lcd.setCursor(8, 84); + s_lcd.print("BPM"); + + s_lcd.setTextColor(TFT_WHITE, TFT_BLACK); + s_lcd.setTextSize(2); + s_lcd.setCursor(8, 128); + s_lcd.print(g_config.input_source == 0 ? "Ableton Link" : "USB MIDI"); + + // OSC target (the mixer we send to). Device's own IP is drawn live by + // touch_display_update() (below) since WiFi isn't up yet at begin() time. + s_lcd.setTextColor(TFT_GREEN, TFT_BLACK); + s_lcd.setTextSize(1); + s_lcd.setCursor(8, 178); + s_lcd.printf("osc %s /fx%d", g_config.mixer_ip, g_config.fx_slot); + + s_lcd.drawCircle(WHEEL_CX, WHEEL_CY, WHEEL_R, TFT_DARKGREEN); + + // "SET" button (top-right) opens the settings screen (Task 9 nav). + s_lcd.drawRoundRect(STATUS_SET_RECT.x, STATUS_SET_RECT.y, + STATUS_SET_RECT.w, STATUS_SET_RECT.h, 4, TFT_DARKGREY); + s_lcd.setTextColor(TFT_WHITE, TFT_BLACK); + s_lcd.setTextSize(1); + s_lcd.setCursor(STATUS_SET_RECT.x + 14, STATUS_SET_RECT.y + 10); + s_lcd.print("SET"); + + // force touch_display_update() to repaint the dynamic parts next tick + s_bpm_shown[0] = '\0'; + s_ip_shown[0] = '\0'; + s_prev_mx = -1; + s_prev_beat = -1; + s_wheel_valid_shown = false; +} + +static ui_rect_t rect(int x, int y, int w, int h) { + ui_rect_t r; r.x = (int16_t)x; r.y = (int16_t)y; r.w = (int16_t)w; r.h = (int16_t)h; + return r; +} + +static void set_add(ui_rect_t r, int field) { + if (s_set_n < (int)(sizeof s_set_rects / sizeof s_set_rects[0])) { + s_set_rects[s_set_n] = r; + s_set_fields[s_set_n] = field; + s_set_n++; + } +} + +// A labeled pill: filled when selected, outlined otherwise. Registers its tap rect. +static void set_seg(ui_rect_t r, const char *label, bool sel, int field) { + if (sel) { + s_lcd.fillRoundRect(r.x, r.y, r.w, r.h, 4, TFT_DARKGREEN); + s_lcd.setTextColor(TFT_BLACK, TFT_DARKGREEN); + } else { + s_lcd.drawRoundRect(r.x, r.y, r.w, r.h, 4, TFT_DARKGREY); + s_lcd.setTextColor(TFT_WHITE, TFT_BLACK); + } + s_lcd.setTextSize(2); + int tw = (int)strlen(label) * 12; // ~12 px/char at size 2 + s_lcd.setCursor(r.x + (r.w - tw) / 2, r.y + (r.h - 16) / 2); + s_lcd.print(label); + set_add(r, field); +} + +static void set_label(int y, const char *s) { + s_lcd.setTextColor(TFT_DARKGREEN, TFT_BLACK); + s_lcd.setTextSize(1); + s_lcd.setCursor(8, y); + s_lcd.print(s); +} + +// Task 10: full settings screen, drawn from s_working. Taps mutate s_working via +// ui_apply_settings_tap() + redraw. Persistence is Task 11 (Write & Reboot). +static void draw_settings(void) { + s_lcd.fillScreen(TFT_BLACK); + s_set_n = 0; + + // back "<" (top-left) + title + s_lcd.drawRoundRect(SETTINGS_BACK_RECT.x, SETTINGS_BACK_RECT.y, + SETTINGS_BACK_RECT.w, SETTINGS_BACK_RECT.h, 4, TFT_GREEN); + s_lcd.setTextColor(TFT_GREEN, TFT_BLACK); + s_lcd.setTextSize(2); + s_lcd.setCursor(SETTINGS_BACK_RECT.x + 13, SETTINGS_BACK_RECT.y + 7); + s_lcd.print("<"); + set_add(SETTINGS_BACK_RECT, ACT_BACK); + + s_lcd.setTextColor(TFT_WHITE, TFT_BLACK); + s_lcd.setTextSize(2); + s_lcd.setCursor(58, 8); + s_lcd.print("SETTINGS"); + + set_label(32, "SOURCE"); + set_seg(rect(8, 42, 76, 26), "LINK", s_working.input_source == 0, UI_F_SRC_LINK); + set_seg(rect(88, 42, 76, 26), "MIDI", s_working.input_source == 1, UI_F_SRC_MIDI); + + set_label(72, "MODEL"); + set_seg(rect(8, 82, 76, 26), "XR", s_working.model == MODEL_XR18, UI_F_MODEL_XR); + set_seg(rect(88, 82, 76, 26), "X32", s_working.model == MODEL_X32, UI_F_MODEL_X32); + + set_label(110, "FX SLOT"); + int smax = config_model_slot_max(s_working.model); + for (int s = 1; s <= smax; s++) { + int col = (s - 1) % 4, row = (s - 1) / 4; + char num[3]; snprintf(num, sizeof num, "%d", s); + set_seg(rect(8 + col * 40, 120 + row * 30, 34, 26), + num, s_working.fx_slot == s, UI_F_SLOT_1 + (s - 1)); + } + + set_label(182, "QUANTUM (beats/bar)"); + set_seg(rect(8, 192, 34, 28), "-", false, UI_F_QUANTUM_DEC); + s_lcd.setTextColor(TFT_WHITE, TFT_BLACK); + s_lcd.setTextSize(3); + s_lcd.setCursor(66, 194); + s_lcd.printf("%d", s_working.quantum_beats); + set_seg(rect(124, 192, 34, 28), "+", false, UI_F_QUANTUM_INC); + + set_label(226, "MIXER IP (tap to edit)"); + s_lcd.drawRoundRect(8, 236, 156, 28, 4, TFT_DARKGREY); + s_lcd.setTextColor(TFT_CYAN, TFT_BLACK); + s_lcd.setTextSize(2); + s_lcd.setCursor(14, 241); + s_lcd.print(s_working.mixer_ip); + set_add(rect(8, 236, 156, 28), ACT_IP_EDIT); + + // Write & Reboot (Task 11): persist s_working + restart, like handle_save(). + s_lcd.fillRoundRect(SETTINGS_WRITE_RECT.x, SETTINGS_WRITE_RECT.y, + SETTINGS_WRITE_RECT.w, SETTINGS_WRITE_RECT.h, 6, TFT_DARKGREEN); + s_lcd.setTextColor(TFT_BLACK, TFT_DARKGREEN); + s_lcd.setTextSize(2); + s_lcd.setCursor(SETTINGS_WRITE_RECT.x + 12, SETTINGS_WRITE_RECT.y + 12); + s_lcd.print("SAVE+REBOOT"); + set_add(SETTINGS_WRITE_RECT, ACT_WRITE); +} + +static void enter_settings(void) { + s_working = g_config; // edit a copy; commit is Task 11 (Write & Reboot) + s_screen = SCREEN_SETTINGS; + draw_settings(); +} + +// Task 12: numeric keypad for the mixer IP. Alpha entry (SSID/password) stays on +// the web captive portal — see LNK-015 Notes. Digits/'.' carry their ASCII char +// as the tap field id; backspace/OK/cancel use ACT_KBD_*. +static void redraw_kbd_buf(void) { + s_lcd.fillRect(8, 40, 156, 18, TFT_BLACK); + s_lcd.setTextColor(TFT_CYAN, TFT_BLACK); + s_lcd.setTextSize(2); + s_lcd.setCursor(8, 40); + s_lcd.print(s_kbd_buf[0] ? s_kbd_buf : "_"); +} + +static void draw_keyboard(void) { + s_lcd.fillScreen(TFT_BLACK); + s_set_n = 0; + + s_lcd.setTextColor(TFT_WHITE, TFT_BLACK); + s_lcd.setTextSize(2); + s_lcd.setCursor(8, 8); + s_lcd.print("MIXER IP"); + redraw_kbd_buf(); + + // 4x3 phone pad: 1-9, then '.', '0', backspace + static const char pad[11] = {'1','2','3','4','5','6','7','8','9','.','0'}; + for (int idx = 0; idx < 12; idx++) { + int col = idx % 3, row = idx / 3; + ui_rect_t r = rect(8 + col * 54, 80 + row * 46, 50, 40); + char lbl[2]; int field; + if (idx < 11) { lbl[0] = pad[idx]; lbl[1] = 0; field = pad[idx]; } + else { lbl[0] = '<'; lbl[1] = 0; field = ACT_KBD_BS; } + s_lcd.drawRoundRect(r.x, r.y, r.w, r.h, 4, TFT_DARKGREY); + s_lcd.setTextColor(TFT_WHITE, TFT_BLACK); + s_lcd.setTextSize(3); + s_lcd.setCursor(r.x + 16, r.y + 9); + s_lcd.print(lbl); + set_add(r, field); + } + + s_lcd.fillRoundRect(8, 268, 76, 40, 6, TFT_DARKGREEN); + s_lcd.setTextColor(TFT_BLACK, TFT_DARKGREEN); + s_lcd.setTextSize(2); + s_lcd.setCursor(34, 280); + s_lcd.print("OK"); + set_add(rect(8, 268, 76, 40), ACT_KBD_OK); + + s_lcd.drawRoundRect(88, 268, 76, 40, 6, TFT_DARKGREY); + s_lcd.setTextColor(TFT_WHITE, TFT_BLACK); + s_lcd.setTextSize(2); + s_lcd.setCursor(102, 280); + s_lcd.print("Cx"); + set_add(rect(88, 268, 76, 40), ACT_KBD_CANCEL); +} + +static void enter_keyboard(void) { + strncpy(s_kbd_buf, s_working.mixer_ip, sizeof s_kbd_buf - 1); + s_kbd_buf[sizeof s_kbd_buf - 1] = '\0'; + s_screen = SCREEN_KEYBOARD; + draw_keyboard(); +} + +// LNK-036: paint the phase indicator. Called every loop (not the 10 Hz text throttle) +// so the beat flash lands on the beat instead of up to a throttle-length behind it. +// Mode 0 = sweeping marker; mode 1 = beat-flash dot: light on each beat (accent colour +// on the bar-1 downbeat, beat colour otherwise) then blank to black between beats so +// each beat reads as a distinct flash. +#define DOT_FLASH_ON_MS 70 +static void render_phase_indicator(const TempoSnapshot &ts, uint32_t now) { + bool valid = ts.valid; // snapshot: valid ⇒ phase >= 0 + if (g_config.phase_display_mode != s_prev_mode) { // LNK-037: /live mode switch — clear + s_lcd.fillCircle(WHEEL_CX, WHEEL_CY, WHEEL_R - 1, TFT_BLACK); + s_lcd.drawCircle(WHEEL_CX, WHEEL_CY, WHEEL_R, TFT_DARKGREEN); + s_prev_mx = -1; s_prev_beat = -1; s_flash_on = false; + s_prev_mode = g_config.phase_display_mode; + } + if (valid != s_wheel_valid_shown) { // state change: clear interior once + s_lcd.fillCircle(WHEEL_CX, WHEEL_CY, WHEEL_R - 1, TFT_BLACK); + s_lcd.drawCircle(WHEEL_CX, WHEEL_CY, WHEEL_R, TFT_DARKGREEN); + s_prev_mx = -1; s_prev_beat = -1; s_flash_on = false; + if (!valid) { // "syncing" once on entering invalid + s_lcd.setTextColor(TFT_ORANGE, TFT_BLACK); + s_lcd.setTextSize(1); + s_lcd.setCursor(WHEEL_CX - 21, WHEEL_CY - 3); + s_lcd.print("syncing"); + } + s_wheel_valid_shown = valid; + } + if (!valid) return; + + if (g_config.phase_display_mode == 1) { + int beat = (int)ts.phase; // 0..quantum-1; 0 = downbeat + if (beat != s_prev_beat) { // beat onset: light the dot + uint32_t c = (beat == 0) ? (uint32_t)g_config.dot_accent_color + : (uint32_t)g_config.dot_beat_color; + uint16_t c565 = s_lcd.color565((c >> 16) & 0xFF, (c >> 8) & 0xFF, c & 0xFF); + s_lcd.fillCircle(WHEEL_CX, WHEEL_CY, WHEEL_R - 4, c565); + s_lcd.drawCircle(WHEEL_CX, WHEEL_CY, WHEEL_R, TFT_DARKGREEN); // repair outline + s_prev_beat = beat; + s_flash_on = true; + s_flash_off_at_ms = now + DOT_FLASH_ON_MS; + } else if (s_flash_on && (int32_t)(now - s_flash_off_at_ms) >= 0) { + s_lcd.fillCircle(WHEEL_CX, WHEEL_CY, WHEEL_R - 1, TFT_BLACK); // blank between beats + s_lcd.drawCircle(WHEEL_CX, WHEEL_CY, WHEEL_R, TFT_DARKGREEN); + s_flash_on = false; + } + } else { + float ang = ui_phase_angle(ts.phase, (float)ts.quantum); + float rad = (ang - 90.0f) * 0.01745329f; // deg->rad; 0deg at top + int mx = WHEEL_CX + (int)(cosf(rad) * (WHEEL_R - 8)); + int my = WHEEL_CY + (int)(sinf(rad) * (WHEEL_R - 8)); + if (mx != s_prev_mx || my != s_prev_my) { + if (s_prev_mx >= 0) s_lcd.fillCircle(s_prev_mx, s_prev_my, 7, TFT_BLACK); + s_lcd.drawCircle(WHEEL_CX, WHEEL_CY, WHEEL_R, TFT_DARKGREEN); // repair outline + s_lcd.fillCircle(mx, my, 6, TFT_GREEN); + s_prev_mx = mx; s_prev_my = my; + } + } +} + +void touch_display_update(void) { + if (s_screen != SCREEN_STATUS) return; + uint32_t now = millis(); + + bool active = tempo_source_active(); + TempoSnapshot ts; tempo_snapshot_read(&ts); + + // Phase indicator every call (LNK-036) so the beat flash tracks the beat; the + // text below stays at ~10 Hz. + render_phase_indicator(ts, now); + + if (now - s_last_update_ms < 100) return; // ~10 Hz is plenty for the text + s_last_update_ms = now; + + // BPM number — repaint only when the shown text changes (no flicker). + char bpm[8]; + if (active) ui_bpm_str(bpm, sizeof bpm, ts.bpm); + else snprintf(bpm, sizeof bpm, "--.-"); // no live signal + if (strcmp(bpm, s_bpm_shown) != 0) { + s_lcd.fillRect(8, 44, 160, 34, TFT_BLACK); + s_lcd.setTextColor(active ? TFT_GREEN : TFT_DARKGREY, TFT_BLACK); + s_lcd.setTextSize(4); + s_lcd.setCursor(8, 44); + s_lcd.print(bpm); + strncpy(s_bpm_shown, bpm, sizeof s_bpm_shown - 1); + s_bpm_shown[sizeof s_bpm_shown - 1] = '\0'; + } + + // Device IP (where to browse for config) — reads 0.0.0.0 until WiFi is up. + IPAddress ip = WiFi.localIP(); + char ipstr[16]; + snprintf(ipstr, sizeof ipstr, "%u.%u.%u.%u", ip[0], ip[1], ip[2], ip[3]); + if (strcmp(ipstr, s_ip_shown) != 0) { + s_lcd.fillRect(8, 158, 160, 12, TFT_BLACK); + s_lcd.setTextColor(TFT_CYAN, TFT_BLACK); + s_lcd.setTextSize(1); + s_lcd.setCursor(8, 158); + s_lcd.printf("web %s", ipstr); + strncpy(s_ip_shown, ipstr, sizeof s_ip_shown - 1); + s_ip_shown[sizeof s_ip_shown - 1] = '\0'; + } +} + +static bool axs_read(axs_touch_t *t) { + Wire.beginTransmission(AXS5106L_I2C_ADDR); + Wire.write(AXS5106L_TOUCH_REG); + if (Wire.endTransmission(true) != 0) return false; // STOP, then a fresh read + uint8_t buf[AXS5106L_REPORT_LEN]; + int n = Wire.requestFrom((int)AXS5106L_I2C_ADDR, (int)AXS5106L_REPORT_LEN); + if (n < AXS5106L_REPORT_LEN) return false; + for (int i = 0; i < AXS5106L_REPORT_LEN; i++) buf[i] = Wire.read(); + return axs5106l_parse(buf, sizeof(buf), t) == 0; +} + +void touch_display_begin(void) { + s_lcd.init(); + s_lcd.setRotation(6); // LNK-035: 180deg from rotation 4 so the USB cable + // exits downward when the unit is hand-held. Drawing + // is in screen space (LovyanGFX remaps); only the + // touch transform below flips to match. + backlight_on(); // manual GPIO46 HIGH — LovyanGFX Light_PWM/LEDC + // failed to drive the backlight in the full build + draw_splash(); + + pinMode(TP_RST, OUTPUT); + digitalWrite(TP_RST, LOW); delay(20); + digitalWrite(TP_RST, HIGH); delay(80); + Wire.begin(TP_SDA, TP_SCL); + Wire.setClock(400000); + Serial.printf("[X32Link] touch_display up (%dx%d)\n", (int)s_lcd.width(), (int)s_lcd.height()); + + draw_status(); // LNK-015 Task 7: show the status screen (BPM --.- until tempo) +} + +void touch_display_tick(void) { + // Poll the AXS5106L and dispatch taps. Coordinates are mapped to screen space + // with the rotation-6 transform (LNK-035, 180deg from LNK-014's rotation 4): both + // axes flip vs rotation 4, so X is inverted and Y maps direct. One tap per press: + // act on the touch-down edge, rearm on release. + static bool s_touch_down = false; + + axs_touch_t t; + if (!axs_read(&t)) return; // I2C gap: keep prior state + bool touched = t.points_len > 0; + + if (touched && !s_touch_down) { + s_touch_down = true; + int x = 171 - t.points[0].x; // rotation-6: X inverted (panel w=172), + int y = t.points[0].y; // Y maps direct + if (s_screen == SCREEN_STATUS) { + if (ui_hit(&STATUS_SET_RECT, 1, x, y) >= 0) enter_settings(); + } else if (s_screen == SCREEN_SETTINGS) { + int i = ui_hit(s_set_rects, s_set_n, x, y); + if (i >= 0) { + int f = s_set_fields[i]; + if (f == ACT_BACK) { + s_screen = SCREEN_STATUS; draw_status(); + } else if (f == ACT_IP_EDIT) { + enter_keyboard(); + } else if (f == ACT_WRITE) { + // Persist exactly like web_config.cpp's handle_save(). + if (config_validate(&s_working)) { + g_config = s_working; + config_save(&g_config); + s_lcd.fillScreen(TFT_BLACK); + s_lcd.setTextColor(TFT_GREEN, TFT_BLACK); + s_lcd.setTextSize(2); + s_lcd.setCursor(8, 140); + s_lcd.print("Saved."); + s_lcd.setCursor(8, 170); + s_lcd.print("Rebooting"); + delay(700); + ESP.restart(); + } else { // invalid: show error, don't save + s_lcd.fillScreen(TFT_BLACK); + s_lcd.setTextColor(TFT_RED, TFT_BLACK); + s_lcd.setTextSize(2); + s_lcd.setCursor(8, 130); + s_lcd.print("Invalid config"); + s_lcd.setTextSize(1); + s_lcd.setCursor(8, 158); + s_lcd.print("check the mixer IP"); + delay(1300); + draw_settings(); + } + } else { + ui_apply_settings_tap(&s_working, f); + draw_settings(); + } + } + } else if (s_screen == SCREEN_KEYBOARD) { + int i = ui_hit(s_set_rects, s_set_n, x, y); + if (i >= 0) { + int f = s_set_fields[i]; + if (f == ACT_KBD_OK) { + strncpy(s_working.mixer_ip, s_kbd_buf, sizeof s_working.mixer_ip - 1); + s_working.mixer_ip[sizeof s_working.mixer_ip - 1] = '\0'; + s_screen = SCREEN_SETTINGS; draw_settings(); + } else if (f == ACT_KBD_CANCEL) { + s_screen = SCREEN_SETTINGS; draw_settings(); + } else { // digit / '.' / backspace + ui_ip_apply(s_kbd_buf, sizeof s_kbd_buf, + f == ACT_KBD_BS ? '\b' : (char)f); + redraw_kbd_buf(); + } + } + } + } else if (!touched) { + s_touch_down = false; + } +} + +#endif // HAS_TOUCH_DISPLAY diff --git a/X32Link/touch_display.h b/X32Link/touch_display.h new file mode 100644 index 0000000..d5f84a9 --- /dev/null +++ b/X32Link/touch_display.h @@ -0,0 +1,20 @@ +#ifndef TOUCH_DISPLAY_H +#define TOUCH_DISPLAY_H + +// On-device 1.47" LCD + touch for the Waveshare ESP32-S3-Touch-LCD-1.47. +// Raw LovyanGFX (no LVGL) driving the JD9853 panel (ST7789 command set), plus +// the AXS5106L capacitive touch controller (pure parse in axs5106l.c). Gated by +// HAS_TOUCH_DISPLAY so screenless boards are unaffected. LNK-014. + +// Panel + touch + LVGL-free splash init. Call once in setup(), early (shows the +// splash while WiFi connects). +void touch_display_begin(void); + +// Poll touch, echo raw coordinates to Serial + on-screen. Call from loop(). +void touch_display_tick(void); + +// LNK-015: refresh the status screen's live fields (BPM, phase wheel). Cheap, +// ~10 Hz internally; call once per loop() iteration. Call from loop(). +void touch_display_update(void); + +#endif // TOUCH_DISPLAY_H diff --git a/X32Link/touch_ui.c b/X32Link/touch_ui.c new file mode 100644 index 0000000..fd6b2b9 --- /dev/null +++ b/X32Link/touch_ui.c @@ -0,0 +1,78 @@ +#include "touch_ui.h" +#include +#include +#include + +int ui_hit(const ui_rect_t *r, int n, int x, int y) { + if (!r) return -1; + for (int i = 0; i < n; i++) { + if (x >= r[i].x && x < r[i].x + r[i].w && + y >= r[i].y && y < r[i].y + r[i].h) { + return i; + } + } + return -1; +} + +void ui_bpm_str(char *out, size_t n, float bpm) { + if (!out || n == 0) return; + if (bpm <= 0.0f) { + snprintf(out, n, "--.-"); + } else { + snprintf(out, n, "%.1f", bpm); + } +} + +float ui_phase_angle(float phase, float quantum) { + if (quantum <= 0.0f) return 0.0f; + float frac = fmodf(phase, quantum); + if (frac < 0.0f) frac += quantum; + return frac / quantum * 360.0f; +} + +// Shared buffer edit: '\b' backspaces (no-op if empty); otherwise append when +// the key passes `accept` and there is room (len < cap-1). Always NUL-terminated. +static void ui_buf_apply(char *buf, size_t cap, char key, int (*accept)(char)) { + if (!buf || cap == 0) return; + size_t len = strlen(buf); + if (key == '\b') { + if (len > 0) buf[len - 1] = '\0'; + return; + } + if (accept(key) && len < cap - 1) { + buf[len] = key; + buf[len + 1] = '\0'; + } +} + +static int accept_printable(char c) { return c >= 0x20 && c < 0x7f; } + +static int accept_ip_char(char c) { return (c >= '0' && c <= '9') || c == '.'; } + +void ui_kbd_apply(char *buf, size_t cap, char key) { + ui_buf_apply(buf, cap, key, accept_printable); +} + +void ui_ip_apply(char *buf, size_t cap, char key) { + ui_buf_apply(buf, cap, key, accept_ip_char); +} + +void ui_apply_settings_tap(AppConfig *cfg, int field_id) { + if (!cfg) return; + // ARC-012: every field goes through app_config_set — the ranges (quantum 1-16, + // slot <= model max via config_set_model, input_source 0/1) live only in + // config_validate now, not inline here. Out-of-range taps (inc past 16, an + // unavailable slot) are simply rejected and leave cfg unchanged. + switch (field_id) { + case UI_F_SRC_LINK: app_config_set(cfg, ACF_INPUT_SOURCE, 0); break; + case UI_F_SRC_MIDI: app_config_set(cfg, ACF_INPUT_SOURCE, 1); break; + case UI_F_MODEL_XR: app_config_set(cfg, ACF_MODEL, MODEL_XR18); break; + case UI_F_MODEL_X32: app_config_set(cfg, ACF_MODEL, MODEL_X32); break; + case UI_F_QUANTUM_INC: app_config_set(cfg, ACF_QUANTUM_BEATS, cfg->quantum_beats + 1); break; + case UI_F_QUANTUM_DEC: app_config_set(cfg, ACF_QUANTUM_BEATS, cfg->quantum_beats - 1); break; + default: + if (field_id >= UI_F_SLOT_1 && field_id <= UI_F_SLOT_8) + app_config_set(cfg, ACF_FX_SLOT, field_id - UI_F_SLOT_1 + 1); + break; + } +} diff --git a/X32Link/touch_ui.h b/X32Link/touch_ui.h new file mode 100644 index 0000000..aa89fa1 --- /dev/null +++ b/X32Link/touch_ui.h @@ -0,0 +1,58 @@ +#ifndef TOUCH_UI_H +#define TOUCH_UI_H + +#include +#include +#include "app_config.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// Axis-aligned rectangle for hit-testing. Top-left inclusive, bottom-right +// exclusive (see ui_hit). +typedef struct { int16_t x, y, w, h; } ui_rect_t; + +// Settings-screen field ids. Slots are contiguous so UI_F_SLOT_1 + n addresses +// slot n+1. +typedef enum { + UI_F_SRC_LINK = 0, + UI_F_SRC_MIDI, + UI_F_MODEL_XR, + UI_F_MODEL_X32, + UI_F_SLOT_1, + UI_F_SLOT_2, + UI_F_SLOT_3, + UI_F_SLOT_4, + UI_F_SLOT_5, + UI_F_SLOT_6, + UI_F_SLOT_7, + UI_F_SLOT_8, + UI_F_QUANTUM_INC, + UI_F_QUANTUM_DEC +} ui_field_t; + +// Return index of first rect in r[0..n) containing (x,y), else -1. +int ui_hit(const ui_rect_t *r, int n, int x, int y); + +// Format bpm as "%.1f" into out, or "--.-" when bpm <= 0. +void ui_bpm_str(char *out, size_t n, float bpm); + +// Map phase 0..quantum to 0..360 degrees (wrapping); 0 if quantum <= 0. +float ui_phase_angle(float phase, float quantum); + +// Mutate cfg per a tapped settings field. +void ui_apply_settings_tap(AppConfig *cfg, int field_id); + +// Append a printable key to buf (NUL-terminated), '\b' backspaces. No-op when +// full or backspacing an empty buffer. +void ui_kbd_apply(char *buf, size_t cap, char key); + +// Like ui_kbd_apply but only accepts [0-9.] and '\b'. +void ui_ip_apply(char *buf, size_t cap, char key); + +#ifdef __cplusplus +} +#endif + +#endif // TOUCH_UI_H diff --git a/X32Link/transport.c b/X32Link/transport.c new file mode 100644 index 0000000..208162c --- /dev/null +++ b/X32Link/transport.c @@ -0,0 +1,18 @@ +#include "transport.h" + +void transport_reset(Transport* t) { + t->primed = false; + t->playing = false; +} + +TransportAction transport_update(Transport* t, bool valid, bool playing) { + if (!valid) return TRANSPORT_NONE; // no real reading yet — hold + if (!t->primed) { // first observation primes silently + t->primed = true; + t->playing = playing; + return TRANSPORT_NONE; + } + if (playing == t->playing) return TRANSPORT_NONE; + t->playing = playing; + return playing ? TRANSPORT_START : TRANSPORT_STOP; +} diff --git a/X32Link/transport.h b/X32Link/transport.h new file mode 100644 index 0000000..b1fac90 --- /dev/null +++ b/X32Link/transport.h @@ -0,0 +1,34 @@ +#pragma once +// Pure MIDI-transport state machine (P4-008): turns the Link session's isPlaying +// flag into MIDI Start (0xFA) / Stop (0xFC) edges, emitting exactly once per +// transition. No Arduino/ESP-IDF dependency — host-tested in test/test_transport.c. +#include +#ifdef __cplusplus +extern "C" { +#endif + +typedef enum { + TRANSPORT_NONE = 0, + TRANSPORT_START, // Link went stopped -> playing (emit 0xFA) + TRANSPORT_STOP, // Link went playing -> stopped (emit 0xFC) + TRANSPORT_RESTART, // realign on the bar line: stop THEN start (emit 0xFC then 0xFA) +} TransportAction; + +typedef struct { + bool primed; + bool playing; +} Transport; + +void transport_reset(Transport* t); + +// Feed the current (valid, playing) state and get the edge action. `valid=false` +// is a no-op (returns NONE, state held) — pass link_proto_start_stop_seen() so we +// prime on the first REAL observation, not on the default. The first valid call +// primes (records `playing`, returns NONE) so joining mid-play does NOT emit a +// spurious Start (which would jump downstream gear to bar 1). Subsequent valid +// calls emit START / STOP on a change, NONE otherwise. +TransportAction transport_update(Transport* t, bool valid, bool playing); + +#ifdef __cplusplus +} +#endif diff --git a/X32Link/transport_launch.c b/X32Link/transport_launch.c new file mode 100644 index 0000000..e3e305a --- /dev/null +++ b/X32Link/transport_launch.c @@ -0,0 +1,86 @@ +// Pure quantized-launch state machine — see transport_launch.h (ESP-011, ESP-025). +#include "transport_launch.h" +#include + +void transport_launch_reset(TransportLaunch* t) { + t->state = TL_STOPPED; + t->have_last = false; + t->last_beats = 0.0; + t->realign_armed = false; +} + +// Did `beats` reach or pass a multiple of `quantum` since `last`? Also true when +// `beats` lands exactly on one, so pressing play on the downbeat fires now +// rather than waiting a whole bar. +static bool crossed_boundary(double last, double beats, double quantum) { + if (quantum <= 0.0) return true; + return floor(beats / quantum) > floor(last / quantum) || fmod(beats, quantum) == 0.0; +} + +TransportLaunchOut transport_launch_step(TransportLaunch* t, TransportLaunchIntent intent, + double beats, double quantum, bool have_beat) { + TransportLaunchOut o = { TL_NONE, t->state, t->realign_armed }; + + switch (intent) { + case TL_INTENT_STOP: + // Immediate, never quantized. Stopping while merely ARMED disarms + // silently: we never started, so there is nothing to stop. + if (t->state == TL_RUNNING) o.action = TL_STOP; + t->state = TL_STOPPED; + t->realign_armed = false; // ESP-025: a pending realign dies with the transport + break; + + case TL_INTENT_PLAY: + if (t->state == TL_RUNNING) break; // no double Start + if (!have_beat) { // nothing to quantize to + t->state = TL_RUNNING; + o.action = TL_START; + } else if (fmod(beats, quantum) == 0.0) { // already on the grid + t->state = TL_RUNNING; + o.action = TL_START; + } else { + t->state = TL_ARMED; + } + break; + + case TL_INTENT_REALIGN: + // ESP-025. Only meaningful while actually playing — a stopped device has + // no pattern position to re-anchor, and Play already starts on the grid. + if (t->state != TL_RUNNING) break; + if (!have_beat) break; // no grid to land on; realigning to nothing is a no-op + if (fmod(beats, quantum) == 0.0) { + o.action = TL_RESTART; // already on the bar line — fire now + } else { + t->realign_armed = true; // ...otherwise wait for it + } + break; + + case TL_INTENT_NONE: + if (t->state == TL_ARMED) { + // Beat grid vanished under us: start rather than hang armed. + if (!have_beat) { + t->state = TL_RUNNING; + o.action = TL_START; + } else if (t->have_last && crossed_boundary(t->last_beats, beats, quantum)) { + t->state = TL_RUNNING; + o.action = TL_START; + } + } else if (t->realign_armed && t->state == TL_RUNNING) { + // ESP-025. Unlike ARMED above, a vanished grid does NOT fire this: a + // realign exists only to land on a bar line, so with no grid there is + // nothing to land on. Stay armed and fire when the grid returns. + if (have_beat && t->have_last + && crossed_boundary(t->last_beats, beats, quantum)) { + t->realign_armed = false; + o.action = TL_RESTART; + } + } + break; + } + + t->last_beats = beats; + t->have_last = true; + o.state = t->state; + o.realign_armed = t->realign_armed; + return o; +} diff --git a/X32Link/transport_launch.h b/X32Link/transport_launch.h new file mode 100644 index 0000000..39f2835 --- /dev/null +++ b/X32Link/transport_launch.h @@ -0,0 +1,82 @@ +#pragma once +// Pure quantized-launch state machine (ESP-011). Owns "when does Play actually +// become MIDI Start?" -- press play mid-bar and the rig must not lurch in +// immediately; it ARMS, waits for the next quantum boundary, and fires on the +// grid, so gear started from the web UI lands in time with the Link session. +// +// Start is quantized, Stop is immediate. That asymmetry is deliberate: it is +// Ableton's own launch behaviour and what muscle memory expects. +// +// One instance per clock output (P4-010 gives each output its own division, +// phase and swing; ESP-011 gives each its own transport, so the drum machine +// can start this bar and the synth two bars later). +// +// The emitted action still goes through the existing `transport` edge module +// before hitting the wire -- this decides WHEN, that one guarantees exactly +// one 0xFA/0xFC per transition. Host-tested in test/test_transport_launch.c. +// No Arduino/ESP-IDF dependency. +#include + +#ifdef __cplusplus +extern "C" { +#endif + +// What the user asked for this tick (a button press, not a level). +typedef enum { + TL_INTENT_NONE = 0, + TL_INTENT_PLAY, + TL_INTENT_STOP, + // ESP-025 REALIGN: re-anchor a RUNNING slave's pattern to the bar line without + // stopping it. NOT a tempo fix -- Link owns tempo, so nothing is ever out of + // "sync"; what drifts is the slave's pattern POSITION (it started on the wrong + // beat, or free-ran and came back offset). Arms like Play and fires on the next + // boundary. A no-op when stopped: press Play, that is what Play is for. + TL_INTENT_REALIGN, +} TransportLaunchIntent; + +typedef enum { + TL_STOPPED = 0, + TL_ARMED, // play pressed, waiting for the next quantum boundary + TL_RUNNING, +} TransportLaunchState; + +typedef enum { + TL_NONE = 0, + TL_START, // emit MIDI Start (0xFA) now + TL_STOP, // emit MIDI Stop (0xFC) now + // ESP-025: emit Stop (0xFC) then Start (0xFA) back-to-back, IN THAT ORDER. + // 0xFA means "play from the top", so the slave's pattern restarts at step 1 -- + // landing on the bar line, which is the whole point. State stays TL_RUNNING: + // the device never stopped, so the UI must not flicker through "stopped". + TL_RESTART, +} TransportLaunchAction; + +typedef struct { + TransportLaunchState state; + bool have_last; // is last_beats meaningful? + double last_beats; // previous tick's beat position (boundary-crossing test) + bool realign_armed; // ESP-025: realign pressed, waiting for the boundary +} TransportLaunch; + +typedef struct { + TransportLaunchAction action; + TransportLaunchState state; // for the UI: stopped / armed / running + // ESP-025: realign is armed while RUNNING, so it cannot be expressed as a state + // without lying about whether the device is playing. The lit button blinks on + // this; without it a quantized press looks like it did nothing for a whole bar + // and the user presses again. + bool realign_armed; +} TransportLaunchOut; + +void transport_launch_reset(TransportLaunch* t); + +// Advance one tick. `beats` is the continuous session beat position, `quantum` +// beats per bar, `have_beat` whether a beat grid exists at all. With no grid +// there is nothing to quantize to, so Play starts immediately rather than +// hanging armed forever waiting for a boundary that will never arrive. +TransportLaunchOut transport_launch_step(TransportLaunch* t, TransportLaunchIntent intent, + double beats, double quantum, bool have_beat); + +#ifdef __cplusplus +} +#endif diff --git a/X32Link/transport_led.c b/X32Link/transport_led.c new file mode 100644 index 0000000..95b94d9 --- /dev/null +++ b/X32Link/transport_led.c @@ -0,0 +1,19 @@ +// ESP-025 — see transport_led.h. +#include "transport_led.h" + +static bool blink_phase(uint32_t now_ms) { + return ((now_ms / TRANSPORT_LED_BLINK_MS) % 2) == 0; +} + +bool transport_led_on(TransportLaunchState state, uint32_t now_ms) { + switch (state) { + case TL_RUNNING: return true; // solid + case TL_ARMED: return blink_phase(now_ms);// "heard you, waiting for the bar line" + case TL_STOPPED: + default: return false; + } +} + +bool realign_led_on(bool realign_armed, uint32_t now_ms) { + return realign_armed && blink_phase(now_ms); +} diff --git a/X32Link/transport_led.h b/X32Link/transport_led.h new file mode 100644 index 0000000..f7fc887 --- /dev/null +++ b/X32Link/transport_led.h @@ -0,0 +1,29 @@ +#pragma once +// ESP-025: lamp state for the two illuminated transport buttons. Pure — takes the +// launch state and a clock, returns whether the lamp is lit. No Arduino, no GPIO. +// +// This exists because a quantized button is INVISIBLE without it. Press Play mid-bar +// and nothing happens for up to a whole bar; every user's instinct is to press again. +// The blink is what says "heard you, waiting for the bar line". +#include "transport_launch.h" +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +// Fast enough to read as "pending" rather than as a slow status heartbeat, and far +// off the beat LED's per-beat flash so the two can't be confused on the bench. +#define TRANSPORT_LED_BLINK_MS 120 + +// Transport button: dark when stopped, blinking while armed, solid while running. +bool transport_led_on(TransportLaunchState state, uint32_t now_ms); + +// Realign button: blinking while a realign is armed, dark otherwise. It is never +// solid — realign is a momentary action, not a mode you sit in. +bool realign_led_on(bool realign_armed, uint32_t now_ms); + +#ifdef __cplusplus +} +#endif diff --git a/X32Link/ui_chrome.c b/X32Link/ui_chrome.c new file mode 100644 index 0000000..ed78eaf --- /dev/null +++ b/X32Link/ui_chrome.c @@ -0,0 +1,191 @@ +// Shared web chrome for both firmwares' config UIs — see ui_chrome.h (ARC-017). +#include "ui_chrome.h" +#include + +/* The rack-panel look. Every rule here was byte-identical in + * X32Link/web_config.cpp and KitchenSync/main/ks_web.cpp, except five that had + * drifted and are resolved below. Nothing framework-specific. + * + * Drift resolved (nobody had decided either way; both were accidents): + * --mut #717a82 (S3) vs #838d95 (P4) -> the P4's. It is + * the later value and lighter; the S3's fails a + * contrast check against --panel-2. + * .sw .swlbl::after "Off" vs " Off" -> no leading space. The gap is + * already supplied by .sw{gap:13px}; the space was + * a stray keystroke that shifted the P4's label. + * .sw .track/.knob transition explicit props (S3) vs blanket `.2s` (P4) -> the + * S3's. `transition:.2s` animates every property, + * including the knob's `left` AND its background, + * which is what we want, but also any future one. + * .sw input:checked + .track the S3's glow (box-shadow) -> kept. The P4 had + * silently lost it. + * .unit the S3's `animation:rise` is an entry animation + * it alone has -> pushed back to its own block. + */ +static const char CSS[] = +":root{--bg:#070809;--panel-2:#0f1216;--ink:#e9ece6;--mut:#838d95;--line:#262b31;" +"--led:#b6ff36;--led-dim:#36431a;--amber:#ff9d3b;" +"--mono:'DM Mono',ui-monospace,Menlo,monospace;--disp:'Bricolage Grotesque','Arial Narrow',sans-serif;--seg:'DSEG7 Classic','DM Mono',monospace;}" +"*{box-sizing:border-box}html,body{margin:0}" +"body{min-height:100vh;background:var(--bg);color:var(--ink);font-family:var(--mono);font-size:14px;" +"display:flex;align-items:flex-start;justify-content:center;padding:34px 16px 80px;" +"background-image:radial-gradient(900px 500px at 50% -10%,#14181d 0%,transparent 60%),radial-gradient(700px 500px at 90% 110%,#0e1318 0%,transparent 55%);}" +".unit{width:100%;max-width:430px;border:1px solid var(--line);border-radius:16px;position:relative;overflow:hidden;" +"background:repeating-linear-gradient(180deg,rgba(255,255,255,.012) 0 2px,transparent 2px 4px),linear-gradient(180deg,#191d22,#101317);" +"box-shadow:0 1px 0 rgba(255,255,255,.04),0 22px 60px -20px #000;}" +".unit::before{content:\"\";position:absolute;inset:0 0 auto 0;height:120px;background:linear-gradient(180deg,rgba(255,255,255,.05),transparent);pointer-events:none}" +".screw{position:absolute;width:11px;height:11px;border-radius:50%;background:radial-gradient(circle at 35% 30%,#3a4047,#0d0f12 70%);box-shadow:inset 0 1px 1px rgba(255,255,255,.18)}" +".screw::after{content:\"\";position:absolute;inset:3px;border-top:1px solid #05070a;transform:rotate(28deg)}" +".tl{top:12px;left:12px}.tr{top:12px;right:12px}.bl{bottom:12px;left:12px}.br{bottom:12px;right:12px}" +".brand{display:flex;align-items:center;gap:11px;padding:18px 26px 14px;border-bottom:1px solid var(--line)}" +".pwr{width:9px;height:9px;border-radius:50%;background:var(--led);box-shadow:0 0 10px 1px var(--led);animation:breathe 3.4s ease-in-out infinite}" +".wordmark{font-family:var(--disp);font-weight:800;font-size:20px;letter-spacing:.06em;text-transform:uppercase}" +".wordmark b{color:var(--led)}" +".rev{margin-left:auto;font-size:10.5px;color:var(--mut);letter-spacing:.18em;text-transform:uppercase}" +".scr{margin:18px;padding:18px 20px 16px;border-radius:11px;position:relative;border:1px solid #1f261b;" +"background:radial-gradient(120% 120% at 50% -20%,rgba(182,255,54,.08),transparent 60%),linear-gradient(180deg,#0a0d0a,#070907);" +"box-shadow:inset 0 0 32px rgba(0,0,0,.9)}" +".scr-top{display:flex;align-items:center;gap:8px;margin-bottom:6px}" +".beat{width:8px;height:8px;border-radius:2px;background:var(--led-dim)}" +".beat.on{background:var(--led);box-shadow:0 0 9px 1px var(--led)}" +".scr-lbl{font-size:10.5px;letter-spacing:.22em;color:#6f8a4d;text-transform:uppercase}" +".scr-src{margin-left:auto;font-size:10.5px;letter-spacing:.2em;color:var(--amber);text-transform:uppercase}" +".readout{position:relative;font-family:var(--seg);line-height:1;padding:4px 0 2px}" +".readout .ghost{position:absolute;inset:4px 0 2px;color:#1a2113}" +".readout .live{position:relative;color:var(--led);text-shadow:0 0 14px rgba(182,255,54,.45)}" +".bignum{font-size:58px}" +".unit-bpm{font-family:var(--mono);font-size:13px;color:#6f8a4d;letter-spacing:.2em;margin-left:6px}" +".fld{display:flex;align-items:center;background:var(--panel-2);border:1px solid var(--line);border-radius:9px;padding:0 12px}" +".fld:focus-within{border-color:#4a5a2c;box-shadow:0 0 0 3px rgba(182,255,54,.08)}" +".fld .pre{color:#4b535b;font-size:12px;letter-spacing:.1em;padding-right:9px;border-right:1px solid var(--line);margin-right:11px}" +".sw{display:flex;align-items:center;gap:13px;cursor:pointer;user-select:none}" +".sw input{position:absolute;opacity:0;width:0;height:0}" +".sw .track{position:relative;flex:none;width:52px;height:28px;border-radius:999px;background:var(--panel-2);border:1px solid var(--line);transition:background .2s,border-color .2s,box-shadow .2s}" +".sw .knob{position:absolute;top:3px;left:3px;width:20px;height:20px;border-radius:50%;background:#5b636b;box-shadow:0 1px 2px rgba(0,0,0,.5);transition:left .2s,background .2s}" +".sw input:checked + .track{background:linear-gradient(180deg,#caff5a,#9be32a);border-color:#7fbf1f;box-shadow:0 0 14px -3px var(--led)}" +".sw input:checked + .track .knob{left:28px;background:#0a0d07}" +".sw .swlbl{font-family:var(--mono);font-size:12.5px;letter-spacing:.14em;text-transform:uppercase;color:var(--mut)}" +".sw .swlbl::after{content:\"Off\"}" +".sw input:checked ~ .swlbl{color:var(--led)}" +".sw input:checked ~ .swlbl::after{content:\"On\"}" +".write{width:100%;border:0;cursor:pointer;border-radius:11px;font-family:var(--disp);font-weight:800;font-size:16px;letter-spacing:.14em;text-transform:uppercase;color:#0a0d07;padding:17px;" +"background:linear-gradient(180deg,#d2ff63,#9be32a);box-shadow:0 6px 0 #5e8a16,0 16px 30px -12px rgba(182,255,54,.5)}" +".foot{text-align:center;color:#3c444c;font-size:10.5px;letter-spacing:.18em;text-transform:uppercase}" +"@keyframes breathe{0%,100%{opacity:1}50%{opacity:.45}}" +"@media (prefers-reduced-motion:reduce){*{animation:none!important;transition:none!important}}"; + +const char* ui_chrome_css(void) { return CSS; } + +/* The client-side plumbing both pages share. The divergence — X32Link phase-locks + * the beat dot off {phase,valid,quantum} and interpolates with rAF (LNK-022), + * KitchenSync free-runs off bpm and also paints peers/usb/min/tx — lives in each + * page's own onStatus(d). Hence a hook, not a `product` switch. */ +static const char JS[] = +"var bpmEl=document.getElementById('bpm'),beatEl=document.getElementById('beat');" +"var beatTimer=null,shownBpm=-1,chromePhaseLocked=false;" +"function flashBeat(){beatEl.classList.add('on');setTimeout(function(){beatEl.classList.remove('on')},90)}" +/* Free-running fallback blink. A page that drives the dot itself sets + * chromePhaseLocked and this stays parked. */ +"function setBeat(bpm){if(beatTimer){clearInterval(beatTimer);beatTimer=null}" +"if(bpm>0){beatTimer=setInterval(flashBeat,60000/bpm)}}" +"function showBpm(bpm){if(Math.abs(bpm-shownBpm)<0.05)return;shownBpm=bpm;" +"bpmEl.textContent=bpm>0?bpm.toFixed(1):'--.-';if(!chromePhaseLocked)setBeat(bpm)}" +/* Live preview: POST one changed field to /live, no reboot (LNK-037 / P4-015). */ +"function postLive(el){var n=el.name;if(!n)return;" +"var v=el.type==='checkbox'?(el.checked?'1':'0'):el.value;" +"fetch('/live',{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded'}," +"body:encodeURIComponent(n)+'='+encodeURIComponent(v)}).catch(function(){})}" +/* /status is 1Hz on purpose — too coarse to drive a smooth dot, fine to correct it. */ +"function poll(){fetch('/status',{cache:'no-store'}).then(function(r){return r.json()})" +".then(function(d){if(typeof d.bpm==='number')showBpm(d.bpm);" +"if(typeof onStatus==='function')onStatus(d)}).catch(function(){})}"; + +const char* ui_chrome_js(void) { return JS; } + +int ui_result_page(char* buf, size_t len, + const char* title, const char* body, bool reboot) +{ + /* The reboot poller: wait for the device to answer again, then go home, so + * Write & Reboot / OTA do not strand the browser on this page. */ + static const char REBOOT_JS[] = + "

returning to config…

" + ""; + + return snprintf(buf, len, + "" + "" + "" + "
" + "
" + "

%s

" + "

%s

" + "%s" + "
", + title, body, reboot ? REBOOT_JS : ""); +} + +int ui_update_page(char* buf, size_t len, const char* product, + const char* fw, const char* build, bool multipart) +{ + /* Arduino's Update.h consumes a multipart form; esp_ota_ops streams the raw + * body, so the P4 uploads with fetch() and reports progress in #st. */ + static const char MULTIPART_FORM[] = + "
" + "" + "" + "
"; + static const char FETCH_FORM[] = + "" + "" + "
" + ""; + + return snprintf(buf, len, + "" + "" + "%s · Firmware Update" + "" + "
" + "

Firmware Update

" + "

Running FW %s · built %s

" + /* ESP-020: say it plainly. Writing the image suspends the flash cache, which + * freezes BOTH cores regardless of task priority -- so the 1 ms MIDI writer + * cannot run while this happens. This is not a subtle timing effect: it is + * seconds against a 1 ms tick. The clock WILL stop. Better the user reads that + * here than discovers it in front of an audience. */ + "

Updating stops the clock. Writing firmware freezes both " + "CPU cores for several seconds, so MIDI clock and transport halt until the device " + "reboots. Do not update mid-set.

" + "

Select a compiled .bin. The device flashes it and reboots automatically. " + "A failed or interrupted upload leaves the running firmware untouched.

" + "%s" + "

← Back

" + "
", + product, fw, build, multipart ? MULTIPART_FORM : FETCH_FORM); +} diff --git a/X32Link/ui_chrome.h b/X32Link/ui_chrome.h new file mode 100644 index 0000000..23c4cbd --- /dev/null +++ b/X32Link/ui_chrome.h @@ -0,0 +1,64 @@ +#pragma once +// ARC-017: the web chrome both firmwares' config UIs share — the rack-panel look +// and the client-side plumbing. Pure C: no Arduino, no ESP-IDF, just snprintf. +// Compiled into KitchenSync by relative path (X32LINK_DIR in its CMakeLists), +// same as clock_ticker.c and the rest of the shared pure C. Host-tested in +// test/test_ui_chrome.c. +// +// The *forms* stay per-firmware: X32Link's is tempo source / mixer model / FX +// slot, KitchenSync's is 4x clock output + metronome + LED strip. There is no +// shared config page to extract, and pretending otherwise would produce a +// parameterized monster. This owns only what was genuinely duplicated. +// +// Cascade order matters: emit ui_chrome_css() FIRST, then the page's own
-
X32·SYNCFW 2.0
+
X32·SYNCFW %FWVER%
Session TempoAbleton Link
188.8%BPM%BPM
-
SOURCE·LINKOUT·/fx/·/par/01
+
SOURCE·LINKOUT·/fx/·/par/01
+ + @@ -119,6 +120,20 @@ form .row:nth-child(2){animation-delay:.10s}form .row:nth-child(3){animation-del
+
beats per bar · 1–16
+
BEATS
+
+
+
Link→USB 24PPQN 0xF8 · restart to apply
+ +
+
+
beat-flash dot vs sweep wheel · colours also set the beat LED
+ +
BEAT
+
BAR1
+
+
on this network
HOST
@@ -128,10 +143,12 @@ form .row:nth-child(2){animation-delay:.10s}form .row:nth-child(3){animation-del
PASS
-
ESP32-S3 · Ableton Link ↔ OSC
+
ESP32-S3 · Ableton Link ↔ OSC · FW %FWVER% · %FWBUILD% · Firmware Update
-)HTML"; -static String build_html() { - String h(HTML_TMPL); +// Only the slice between %CSS% and %JS% needs per-request values; the chrome on +// either side is streamed straight from flash. +static String build_body(const char* begin, const char* end) { + String h; + h.concat(begin, end - begin); + TempoSnapshot ts; tempo_snapshot_read(&ts); char bpm[8]; - snprintf(bpm, sizeof(bpm), "%.1f", g_current_bpm); + snprintf(bpm, sizeof(bpm), "%.1f", ts.bpm); h.replace("%MODEL%", String(g_config.model)); h.replace("%IP%", g_config.mixer_ip); h.replace("%SLOT%", String(g_config.fx_slot)); + h.replace("%QUANTUM%", String(g_config.quantum_beats)); + h.replace("%MCKCHK%", g_config.midi_clock_out_enable ? "checked" : ""); + h.replace("%PHFLASHCHK%", g_config.phase_display_mode == 1 ? "checked" : ""); + char dotbuf[8]; + snprintf(dotbuf, sizeof(dotbuf), "#%06X", g_config.dot_beat_color & 0xFFFFFF); + h.replace("%DOTBEAT%", dotbuf); + snprintf(dotbuf, sizeof(dotbuf), "#%06X", g_config.dot_accent_color & 0xFFFFFF); + h.replace("%DOTACC%", dotbuf); h.replace("%SRC%", String(g_config.input_source)); h.replace("%SSID%", g_config.wifi_ssid); h.replace("%BPM%", bpm); + h.replace("%FWVER%", FW_VERSION); // LNK-038 — replaces the old hardcoded "FW 2.0" + h.replace("%FWBUILD%", FW_BUILD); return h; } +// ARC-017: sent chunked from parts. The shared CSS + JS are ~4KB of flash that +// used to be copied into an Arduino String -- and then realloc'd by every +// .replace() pass over it. Now only the middle slice is ever copied. static void handle_root() { - server.send(200, "text/html", build_html()); + static const char CSS_MARK[] = "%CSS%", JS_MARK[] = "%JS%"; + const char *css = strstr(HTML_TMPL, CSS_MARK); + const char *js = strstr(HTML_TMPL, JS_MARK); + if (!css || !js) { server.send(500, "text/plain", "template"); return; } + + server.setContentLength(CONTENT_LENGTH_UNKNOWN); + server.send(200, "text/html", ""); + server.sendContent(HTML_TMPL, css - HTML_TMPL); + server.sendContent(ui_chrome_css()); + server.sendContent(build_body(css + strlen(CSS_MARK), js)); + server.sendContent(ui_chrome_js()); + // The page's own JS still carries %FWVER% (the source-badge text), so the tail + // gets a substitution pass too -- sending it raw would print the marker. + String tail(js + strlen(JS_MARK)); + tail.replace("%FWVER%", FW_VERSION); + server.sendContent(tail); + server.sendContent(""); // terminate the chunked response } -// Live tempo for the panel's 7-seg readout. Shared-safe: only g_current_bpm -// (both firmwares define it) — no Link-only symbols, so X32MidiClock compiles. -static void handle_status() { - char buf[28]; - snprintf(buf, sizeof(buf), "{\"bpm\":%.1f}", g_current_bpm); - server.send(200, "application/json", buf); +// LNK-034: web-based OTA. Plain multipart form (no JS needed) posts a compiled +// .bin to /update; Update.h streams it straight into the inactive OTA app slot +// (the Arduino esp32 core's default partition scheme reserves app0/app1) and +// the result page reboots into it on success, same shape as handle_save(). +/* ARC-017: the /update page is ui_update_page() -- shared with KitchenSync. + * multipart=true selects the plain form Arduino's Update.h consumes. */ + +static void handle_update_page() { + static char page[UPDATE_PAGE_MAX]; + // LNK-038: show what's about to be overwritten. + int n = ui_update_page(page, sizeof(page), "X32·SYNC", FW_VERSION, FW_BUILD, true); + if (n < 0 || n >= (int)sizeof(page)) { server.send(500, "text/plain", "page too large"); return; } + server.send(200, "text/html", page); } -// Minimal dark result page matching the panel aesthetic. -static void send_result(int code, const char* title, const char* body) { - String p = - "" - "" - "" - "
" - "
" - "

"; - p += title; - p += "

"; - p += body; - p += "

"; - server.send(code, "text/html; charset=utf-8", p); +static void handle_update_upload() { + HTTPUpload& upload = server.upload(); + if (upload.status == UPLOAD_FILE_START) { + Serial.printf("OTA: receiving %s\n", upload.filename.c_str()); + if (!Update.begin(UPDATE_SIZE_UNKNOWN)) Update.printError(Serial); + } else if (upload.status == UPLOAD_FILE_WRITE) { + if (Update.write(upload.buf, upload.currentSize) != upload.currentSize) Update.printError(Serial); + } else if (upload.status == UPLOAD_FILE_END) { + if (Update.end(true)) Serial.printf("OTA: wrote %u bytes, restarting\n", upload.totalSize); + else Update.printError(Serial); + } else if (upload.status == UPLOAD_FILE_ABORTED) { + Update.end(); + } } -static void handle_save() { - AppConfig cfg = g_config; +// Live tempo for the panel's 7-seg readout, plus phase/valid/quantum for +// the web beat dot (LNK-022) — same data the LED (LNK-021) and touch wheel +// (LNK-015) already drive off, just surfaced over HTTP so the JS can +// poll-correct + client-side-interpolate instead of free-running off bpm +// alone. quantum is g_config.quantum_beats (bar-quantized, matching the +// touch UI's phase wheel, not the LED's per-beat 1.0f quantum). One atomic +// tempo_snapshot_read() (ARC-001 seam) — never tempo_source_* directly. +/* ESP-035: /status is now built by the SHARED ks_status_json -- the same function the + * P4 and the Touch use (ESP-029, ARC-024: "one shape across the fleet, so one script + * audits every device"). + * + * This box was the last hold-out. It hand-rolled its own /status, and the companion app + * could not decode it AT ALL -- KsStatus threw on the first required key it was missing. + * A device that was powered, on the network and following the session was invisible. + * + * Its OWN page still needs `phase`/`valid`/`quantum`/battery, so those ride in `extra` + * rather than being dropped -- exactly what the Touch did with its legacy `sync` alias. + * The tick-health block was ALREADY spelled identically (ARC-024 got there first), so + * it needs no translation at all. */ +static void handle_status() { + TempoSnapshot ts; tempo_snapshot_read(&ts); + /* STATIC, not stack -- the rule this file already sets at the top: WebServer + * dispatches one request at a time from loopTask, and a multi-KB local is how the + * P4's httpd task earned a stack-protection panic (ESP-013). The shared document is + * three times the size of the one this used to build, so the old 256-byte local + * would have been a stack landmine. */ + static char buf[768]; + static char extra[160]; + + // ARC-024: NULL unless the MIDI writer task is actually running, so the block is + // omitted rather than reported as a row of zeroes. + WebTickHealth th; + const WebTickHealth* tick = midi_clock_out_io_health(&th) ? &th : NULL; - int model = server.arg("model").toInt(); - if (model == MODEL_XR18 || model == MODEL_X32) cfg.model = model; + int n = snprintf(extra, sizeof(extra), "\"phase\":%.4f,\"valid\":%s,\"quantum\":%d", + (double)ts.phase, ts.valid ? "true" : "false", ts.quantum); +#ifdef HAS_BATTERY_GAUGE + BatterySnapshot bs; battery_snapshot_read(&bs); + if (bs.present && n > 0 && n < (int)sizeof(extra)) + snprintf(extra + n, sizeof(extra) - (size_t)n, ",\"batt_v\":%.2f,\"batt_pct\":%.1f", + (double)bs.volts, (double)bs.percent); +#endif - String ip = server.arg("mixer_ip"); - if (ip.length() > 0 && ip.length() < (int)sizeof(cfg.mixer_ip)) - ip.toCharArray(cfg.mixer_ip, sizeof(cfg.mixer_ip)); + /* HONEST ZEROES, not flattering ones. This box has no MIDI-clock IN, no USB-MIDI + * host, no mic follow-beat, and NO QUANTIZED TRANSPORT -- it follows Link and drives + * a mixer's FX delay. So `launch` is EMPTY (launch_count 0), not a fabricated + * [0]: the array's length is the output count, and inventing one would make a client + * draw a transport button for a device that has no transport to run. + * + * `clk`/`pulses` are NULL for the same reason -- this device does not publish + * ESP-028's writer's truth yet, and a plausible-looking "locked" would be precisely + * the lie ESP-028 exists to prevent. */ + ks_status_json(buf, sizeof(buf), + ts.bpm, + 0.0f, // min: no MIDI-clock IN on this hardware + link_proto_peers(), + false, // usb: no USB-MIDI host + 0, // tx: this box keeps NO lifetime pulse counter. + // 0 = not measured, and the honest way to say + // "is the wire alive" is ESP-028's clk/pulses, + // which is passed NULL below rather than faked. + FW_VERSION, + false, 0.0f, 0.0f, false, // follow_*: no mic on this hardware + NULL, 0, // launch: NO transport on this product + false, // playing + false, // link_owns: it never defers, so never claim it + tick, + NULL, // phase health: not published here + NULL, NULL, // clk/pulses: ESP-028 not implemented here + extra); + server.send(200, "application/json", buf); +} - int slot = server.arg("fx_slot").toInt(); - if (slot >= 1 && slot <= config_model_slot_max(cfg.model)) cfg.fx_slot = slot; +/* ESP-035: GET /config.json, via the SHARED builder + KsCaps. + * + * X32Link is a DIFFERENT PRODUCT on the same fleet protocol -- it syncs an X32/XR18's + * FX tap-delay to Link. So its capabilities are genuinely different, and it must say so + * rather than pretend to be a clock box: + * + * outputs = 0 Its USB-MIDI clock is a fixed on/off (midi_clock_out_enable), NOT a + * configurable output with a cable, division, phase and swing. Claim + * one and a client draws NUDGE and SWING steppers that can never work + * -- the exact bug the Touch shipped. `clock_out` still carries the + * on/off, because that part is real. + * wifi_slots = 1 It stores ONE credential. Claim three and the user saves a second + * network, /save silently discards it, and the client reports success + * -- the exact bug that cost the user their second SSID on the Touch. + * led It really does have one (a NeoPixel beat LED). */ +static void handle_config_json() { + /* KsCaps.led means an ADDRESSABLE strip -- brightness, mode, fade, beat/accent + * colour. That is only true on the LED_RGB builds (the QT Py's NeoPixel). The + * headless board's LED is a plain on/off pin, and the Waveshare touch board has no + * MCU-controlled LED at all (LED_NONE). + * + * So this is #if'd, not hardcoded true. Claiming `led` on a board with a bare + * indicator would make a client draw colour pickers and a brightness slider for + * hardware that cannot honour any of them -- which is the ENTIRE point of caps, and + * the same lie as reporting a metronome on a board with no speaker. + * + * Capabilities are a property of the BUILD. Solder a WS2812 on and rebuild with + * LED_RGB, and the LED section appears with no client change at all. */ + /* ESP-037: the QT Py NeoPixel was the only addressable-strip X32Link and it's gone, + * so no X32Link build has a controllable colour strip: the Super Mini's LED is a + * bare on/off pin, the Waveshare has none. led = false. */ + static const KsCaps caps = { + .metronome = false, // no speaker + .led = false, // bare on/off pin (Super Mini) or none (Waveshare) + .follow_beat = false, // no mic + .outputs = 0, // no CONFIGURABLE clock outputs -- see above + .wifi_slots = 1, // this build stores exactly one credential + .settable_tempo = false, // ESP-037: listener-only. It follows Link; it never + // originates a tempo, so /config.json emits no bpm. + }; - String ssid = server.arg("wifi_ssid"); - if (ssid.length() > 0 && ssid.length() < (int)sizeof(cfg.wifi_ssid)) - ssid.toCharArray(cfg.wifi_ssid, sizeof(cfg.wifi_ssid)); + /* This device's AppConfig, expressed in the shared KsConfig shape. Only what it + * really has is populated; `caps` then stops the rest from being emitted at all. */ + KsConfig c; + ks_config_defaults(&c); + c.clock_out_enable = g_config.midi_clock_out_enable; + strlcpy(c.wifi[0].ssid, g_config.wifi_ssid, sizeof(c.wifi[0].ssid)); + strlcpy(c.wifi[0].pass, g_config.wifi_pass, sizeof(c.wifi[0].pass)); + c.led_beat_color = (int)g_config.dot_beat_color; + c.led_accent_color = (int)g_config.dot_accent_color; - String pass = server.arg("wifi_pass"); - if (pass.length() > 0 && pass.length() < (int)sizeof(cfg.wifi_pass)) - pass.toCharArray(cfg.wifi_pass, sizeof(cfg.wifi_pass)); + static char buf[768]; // static for the same reason as handle_status (ESP-013) + ks_config_json(buf, sizeof(buf), &c, &caps); + server.send(200, "application/json", buf); +} - int src = server.arg("input_source").toInt(); - if (src == 0 || src == 1) cfg.input_source = src; +// Minimal dark result page matching the panel aesthetic. +// reboot=true adds a script that waits for the device to come back after the restart +// and then loads the config homepage — so Write & Reboot / OTA return to '/' on their +// own instead of leaving the browser stranded on the result page. +static void send_result(int code, const char* title, const char* body, bool reboot) { + // ARC-017: the page is ui_result_page() -- pure, shared with KitchenSync, tested. + static char p[RESULT_PAGE_MAX]; + int n = ui_result_page(p, sizeof(p), title, body, reboot); + if (n < 0 || n >= (int)sizeof(p)) { server.send(500, "text/plain", "page too large"); return; } + server.send(code, "text/html; charset=utf-8", p); +} + +static void handle_update_result() { + bool ok = !Update.hasError(); + send_result(ok ? 200 : 500, + ok ? "Updated — Restarting" : "Update Failed", + ok ? "New firmware written. The device restarts now." + : "Upload was interrupted or the image was rejected. Try again.", ok); + if (ok) { + delay(1000); + ESP.restart(); + } +} + +// ESP-040: snapshot the POST's args (already URL-decoded by WebServer) and hand them to +// the pure, host-tested x32_form_merge. Holding the Strings keeps their c_str() valid for +// the duration of the merge. Runs in loop() context (ample stack), not a tiny httpd task. +static void merge_post_into(AppConfig* out, const AppConfig* base, bool full_form) { + const int n = server.args(); + static const int MAXF = 40; // the form has ~11 fields; 40 is headroom + String keys[MAXF], vals[MAXF]; + X32FormField fields[MAXF]; + int cnt = 0; + for (int i = 0; i < n && cnt < MAXF; i++) { + keys[cnt] = server.argName(i); + vals[cnt] = server.arg(i); + fields[cnt].key = keys[cnt].c_str(); + fields[cnt].val = vals[cnt].c_str(); + cnt++; + } + x32_form_merge(out, base, fields, cnt, full_form); +} + +static void handle_save() { + /* ESP-040: the whole save now goes through the shared, host-tested merge instead of + * reading each field unconditionally. The old code read input_source / midi_clock_out / + * phase_flash straight off server.arg(), and an absent key decodes to 0 — a LEGAL value + * for all three — so a partial POST (the iOS app saving a WiFi network) silently reset + * the tempo source, disabled the MIDI clock, and flipped the phase display. The device's + * own page posts a `full_form` sentinel, so "absent checkbox = off" applies ONLY to it; + * a partial patch leaves what it doesn't mention alone. Same discipline as the P4's + * ks_form_resolve and the Touch's full_form guard — now all three share it. */ + AppConfig cfg; + merge_post_into(&cfg, &g_config, server.hasArg("full_form")); if (config_validate(&cfg)) { g_config = cfg; config_save(&g_config); - send_result(200, "Saved — Restarting", "Reconnect to WiFi if credentials changed."); + send_result(200, "Saved — Restarting", "Reconnect to WiFi if credentials changed.", true); delay(1000); ESP.restart(); } else { - send_result(400, "Invalid Config", "Check the mixer IP and FX slot, then go back."); + send_result(400, "Invalid Config", "Check the mixer IP and FX slot, then go back.", false); + } +} + +// LNK-037: apply live-safe fields to the running config with no reboot, mirroring the +// P4's /live. touch_display reads g_config each render, so the phase dot mode/colours +// update instantly. +// +// ARC-022: these edits are now KEPT. This handler had the same bug as the P4's and the +// Touch's -- it applied the value and returned, so a colour or quantum set here looked +// right until the next power cycle and then quietly wasn't. It marks the config dirty +// now; web_config_handle() writes the blob once the edits settle. +static void handle_live() { + // ARC-012: same validated setter as save; ranges live in config_validate only. + // Only a value the setter ACCEPTED dirties the config -- a rejected one changed nothing. + bool edited = false; + if (server.hasArg("phase_flash")) + edited |= app_config_set(&g_config, ACF_PHASE_DISPLAY_MODE, server.arg("phase_flash").toInt() == 1 ? 1 : 0); + if (server.hasArg("dot_beat")) { + String cb = server.arg("dot_beat"); + if (cb.length() == 7 && cb[0] == '#') + edited |= app_config_set(&g_config, ACF_DOT_BEAT_COLOR, (int)strtol(cb.c_str() + 1, NULL, 16)); + } + if (server.hasArg("dot_acc")) { + String ca = server.arg("dot_acc"); + if (ca.length() == 7 && ca[0] == '#') + edited |= app_config_set(&g_config, ACF_DOT_ACCENT_COLOR, (int)strtol(ca.c_str() + 1, NULL, 16)); } + if (server.hasArg("quantum_beats")) + edited |= app_config_set(&g_config, ACF_QUANTUM_BEATS, server.arg("quantum_beats").toInt()); + if (edited) config_persist_mark(&s_persist, millis()); + server.send(200, "text/plain", "ok"); } static void handle_captive_redirect() { @@ -251,13 +522,19 @@ static void handle_captive_redirect() { } void web_config_begin() { + config_persist_reset(&s_persist); // ARC-022: clean at boot — never write on the way up server.on("/", HTTP_GET, handle_root); server.on("/status", HTTP_GET, handle_status); + server.on("/config.json", HTTP_GET, handle_config_json); // ESP-035 server.on("/save", HTTP_POST, handle_save); + server.on("/live", HTTP_POST, handle_live); // LNK-037: no-reboot preview + server.on("/update", HTTP_GET, handle_update_page); + server.on("/update", HTTP_POST, handle_update_result, handle_update_upload); server.begin(); } void web_config_ap_begin() { + config_persist_reset(&s_persist); // ARC-022: this path serves /live too s_ap_ip = WiFi.softAPIP(); s_captive = true; @@ -267,6 +544,7 @@ void web_config_ap_begin() { server.on("/", HTTP_GET, handle_root); server.on("/status", HTTP_GET, handle_status); server.on("/save", HTTP_POST, handle_save); + server.on("/live", HTTP_POST, handle_live); // LNK-037: no-reboot preview // OS captive-portal detection endpoints → redirect to config page. server.on("/hotspot-detect.html", HTTP_GET, handle_captive_redirect); // iOS/macOS @@ -282,4 +560,9 @@ void web_config_ap_begin() { void web_config_handle() { if (s_captive) s_dns.processNextRequest(); server.handleClient(); + // ARC-022: write the blob once the live edits have settled. due() is a couple of + // unsigned compares when nothing is owed, so polling it from loop() is free; the + // write itself is rare by construction, which matters because a flash write + // suspends the cache and freezes both cores. + if (config_persist_due(&s_persist, millis())) config_save(&g_config); } diff --git a/X32Link/web_status_json.c b/X32Link/web_status_json.c new file mode 100644 index 0000000..3014b91 --- /dev/null +++ b/X32Link/web_status_json.c @@ -0,0 +1,54 @@ +// See web_status_json.h. +#include "web_status_json.h" +#include +#include + +// Appended as a cursor rather than one snprintf per combination: there are now two +// independent optional blocks (battery, ARC-024 tick health), and a branch per +// combination is how you end up with a well-formed object in three cases and a +// malformed one in the fourth. Each append is snprintf-bounded and the running total +// is snprintf-style, so truncation is still reported exactly as before. +static void app(char** p, size_t* left, int* total, const char* fmt, ...) + __attribute__((format(printf, 4, 5))); + +static void app(char** p, size_t* left, int* total, const char* fmt, ...) { + va_list ap; + va_start(ap, fmt); + int n = vsnprintf(*p, *left, fmt, ap); + va_end(ap); + if (n < 0) return; + *total += n; + // On truncation snprintf returns what it WOULD have written; advance only by what + // it actually did, and never past the end of the buffer. + size_t adv = ((size_t)n < *left) ? (size_t)n : (*left ? *left - 1 : 0); + *p += adv; + *left -= adv; +} + +int web_status_json(char* buf, size_t buf_len, float bpm, float phase, bool valid, int quantum, + const char* fw, bool has_batt, float batt_v, float batt_pct, + const WebTickHealth* tick) { + char* p = buf; + size_t left = buf_len; + int total = 0; + + app(&p, &left, &total, "{\"bpm\":%.1f,\"phase\":%.4f,\"valid\":%s,\"quantum\":%d,\"fw\":\"%s\"", + (double)bpm, (double)phase, valid ? "true" : "false", quantum, fw); + + if (has_batt) + app(&p, &left, &total, ",\"batt_v\":%.2f,\"batt_pct\":%.1f", + (double)batt_v, (double)batt_pct); + + if (tick) + app(&p, &left, &total, + ",\"drop\":%lu,\"burst\":%lu,\"gap\":%lu,\"work\":%lu,\"over\":%lu,\"core\":%d," + "\"w_beats\":%lu,\"w_clock\":%lu,\"reprime\":%lu", + (unsigned long)tick->dropped, (unsigned long)tick->bursts, + (unsigned long)tick->max_gap_us, (unsigned long)tick->max_work_us, + (unsigned long)tick->overruns, tick->core, + (unsigned long)tick->w_beats, (unsigned long)tick->w_clock, + (unsigned long)tick->reprimes); + + app(&p, &left, &total, "}"); + return total; +} diff --git a/X32Link/web_status_json.h b/X32Link/web_status_json.h new file mode 100644 index 0000000..7e39c6d --- /dev/null +++ b/X32Link/web_status_json.h @@ -0,0 +1,71 @@ +// Pure builder for the /status JSON payload (LNK-022) — see web_config.cpp. +// Host-testable: no Arduino/WebServer dependency, just snprintf. Same +// "pull the formatting logic out of the .ino-adjacent file" pattern as +// led_phase.h/.c (LNK-021). +#pragma once +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +#include + +// ARC-024: tick health for the 1 ms MIDI writer. X32Link shipped with NO probe at +// all -- the firmware running the longest was the one measured the least, on the +// same S3 silicon and the same lwIP priority that produced ESP-018 on the Touch. +// +// max_gap_us >> 1000 -> the writer task was never SCHEDULED (preempted/starved, +// or a flash-cache stall, which freezes both cores). +// max_work_us >> 1000 -> something INSIDE the tick blocked; w_* says which stage. +// dropped -> pulses the ticker THREW AWAY on a realign past MAX_BURST. +// These leave no burst and no gap on the wire, so without +// this counter a stall long enough to trip it is invisible. +// core -> which core the writer REALLY landed on. ESP-018's original +// premise about core assignment was wrong, and only the +// measurement caught it. Report it; never assume it. +typedef struct { + uint32_t dropped; // lifetime pulses discarded by the realign + uint32_t bursts; // ticks that emitted >1 pulse (catch-up) + uint32_t max_gap_us; // worst inter-tick gap + uint32_t max_work_us; // worst time spent inside a tick + uint32_t overruns; // ticks where gap or work blew past the threshold + uint32_t w_beats; // worst tick, stage: tempo_source_beats_now() + uint32_t w_clock; // worst tick, stage: scheduling + DIN/USB writes + int core; // core the writer task actually runs on + /* P4-038: times the beat grid was RE-PRIMED (phase went invalid, basis switched, + * a GhostXForm re-commit moved the origin). This is the counter that tells a + * SCHEDULING stall apart from a PHASE stall, and nothing else can. + * + * The probe proved KitchenSync's clock task was scheduled perfectly (gap 1.5 ms, + * work 1.7 ms, zero overruns) while the analyzer watched its wire go silent for + * 150 ms and then burst. A task that is never late cannot explain a wire that + * stops -- unless the grid underneath it moved. A re-prime emits nothing and + * counts no drops, so it is invisible in every other number here. */ + uint32_t reprimes; +} WebTickHealth; + +// Formats {"bpm":F,"phase":F,"valid":bool,"quantum":N,"fw":"S"} into buf. +// `phase` and `quantum` are passed through as-is (caller decides what to do +// with tempo_source_phase()'s -1.0f "no reading yet" sentinel — this function +// doesn't special-case it, it just prints whatever float it's given; +// `valid` is the field JS actually gates on). `fw` is the firmware version +// string (LNK-038) — caller-supplied (glue passes FW_VERSION) so this builder +// stays pure and version-agnostic. `has_batt` gates two extra fields, +// `"batt_v":F,"batt_pct":F` — omitted entirely when false, so boards with no +// fuel gauge (i.e. most of them) get the same schema as before this field +// existed; the web JS feature-detects on `typeof d.batt_pct`. Returns the +// snprintf() return value (bytes that would have been written, excluding the +// terminator), so the caller can detect truncation the same way any snprintf +// caller would. `tick` (ARC-024) gates a further block of MIDI-writer tick-health +// fields, `"drop":N,"burst":N,"gap":N,"work":N,"over":N,"core":N,"w_beats":N, +// "w_clock":N` -- omitted entirely when NULL, so a build with no probe keeps the +// old schema byte for byte. Same opt-in shape as has_batt, and independent of it. +int web_status_json(char* buf, size_t buf_len, float bpm, float phase, bool valid, int quantum, + const char* fw, bool has_batt, float batt_v, float batt_pct, + const WebTickHealth* tick); + +#ifdef __cplusplus +} +#endif diff --git a/X32Link/wifi_conn_policy.c b/X32Link/wifi_conn_policy.c new file mode 100644 index 0000000..87a4335 --- /dev/null +++ b/X32Link/wifi_conn_policy.c @@ -0,0 +1,53 @@ +// Pure WiFi connection lifecycle — see wifi_conn_policy.h (ARC-013, ESP-013). +#include "wifi_conn_policy.h" + +void wifi_conn_policy_reset(WifiConnPolicy* p, int64_t now_us, int nslots) { + if (nslots < 1) nslots = 1; + if (nslots > WIFI_CONN_MAX_SLOTS) nslots = WIFI_CONN_MAX_SLOTS; + + p->state = WCS_CONNECTING; + p->got_ip_once = false; + p->listener_started = false; + p->slot = 0; + p->nslots = nslots; + p->slot_start_us = now_us; +} + +// Each saved network gets an equal share of the one total budget, so adding +// networks costs the user nothing in time-to-config-AP. +static int64_t slot_budget_us(const WifiConnPolicy* p) { + return WIFI_CONN_TIMEOUT_US / p->nslots; +} + +WifiConnAction wifi_conn_policy_step(WifiConnPolicy* p, WifiConnEvent e, int64_t now_us) { + if (p->state == WCS_AP) return WCA_NONE; // terminal until reboot + + switch (e) { + case WCE_GOT_IP: + p->state = WCS_GOT_IP; + p->got_ip_once = true; // also freezes the slot walk + if (!p->listener_started) { // bring up the listener exactly once + p->listener_started = true; + return WCA_SPAWN_LISTENER; + } + return WCA_NONE; + + case WCE_DISCONNECTED: + // The budget only governs INITIAL acquisition. Once we've ever had an IP + // the creds are known-good, so retry this slot forever instead of walking + // on, or falling to AP, on a transient mid-session drop. + if (!p->got_ip_once && (now_us - p->slot_start_us) >= slot_budget_us(p)) { + if (p->slot + 1 < p->nslots) { // another saved network to try + p->slot++; + p->slot_start_us = now_us; // fresh budget for the new slot + p->state = WCS_CONNECTING; + return WCA_TRY_SLOT; + } + p->state = WCS_AP; // out of networks + return WCA_GIVE_UP_TO_AP; + } + p->state = WCS_CONNECTING; + return WCA_CONNECT; + } + return WCA_NONE; +} diff --git a/X32Link/wifi_conn_policy.h b/X32Link/wifi_conn_policy.h new file mode 100644 index 0000000..4f79714 --- /dev/null +++ b/X32Link/wifi_conn_policy.h @@ -0,0 +1,81 @@ +#pragma once +// ARC-013: the WiFi connection lifecycle as a pure state machine, shared by both +// boards. Replaces the scattered module-static flags (P4 wifi_link.c) and the +// hand-rolled blocking loop with its inlined `> 30000` literal (S3 X32Link.ino), so +// the give-up budget + the "retry forever once we've had an IP" rule live in ONE +// host-tested place (subsumes the old wifi_fallback). +// +// The glue translates its platform events (esp_wifi events / WiFi.status() polls) +// into WifiConnEvent, calls step(), and executes the returned action. No I/O here. +// See test/test_wifi_conn_policy.c. +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +// STA association budget: give the configured networks this long, IN TOTAL, to +// connect on a cold start before falling back to the config AP. Hidden SSIDs + +// Wi-Fi 6 routers can take longer than a visible beacon scan. +// +// ESP-013: with N saved networks each slot gets TIMEOUT/N. Splitting the existing +// budget rather than adding a per-slot one keeps a 3-network device reaching the +// config AP in the same 45s a 1-network device always did — otherwise the user +// waits over two minutes to see the setup portal. +#define WIFI_CONN_TIMEOUT_US (45LL * 1000000LL) + +// Cap on saved networks. Bounds the per-slot budget (45s/4 ≈ 11s) so the walk +// never degrades into a scan too short to associate. +#define WIFI_CONN_MAX_SLOTS 4 + +typedef enum { + WCS_CONNECTING = 0, // trying to associate (initial or retry) + WCS_GOT_IP, // associated + DHCP at least once — creds proven good + WCS_AP, // gave up on STA, parked in the config AP (terminal) +} WifiConnState; + +typedef enum { + WCE_DISCONNECTED = 1, // STA disconnected / not-yet-connected poll + WCE_GOT_IP, // got a DHCP lease +} WifiConnEvent; + +typedef enum { + WCA_NONE = 0, + WCA_CONNECT, // (re)issue an STA connect on the CURRENT slot (glue adds backoff) + WCA_TRY_SLOT, // ESP-013: reconfigure STA for slot `p->slot`, then connect + WCA_GIVE_UP_TO_AP, // stop STA, bring up the config AP (terminal until reboot) + WCA_SPAWN_LISTENER, // start the Link listener — emitted exactly once, on first IP +} WifiConnAction; + +typedef struct { + WifiConnState state; + bool got_ip_once; // ever had an IP → retry forever, never fall to AP + bool listener_started; // spawn the Link listener exactly once + int slot; // ESP-013: which saved network we are trying (0..nslots-1) + int nslots; // how many usable networks the glue handed us (>= 1) + int64_t slot_start_us; // when the CURRENT slot's attempt began (budget base) +} WifiConnPolicy; + +// Start a fresh cold-start attempt at slot 0: CONNECTING, budget clock from now_us. +// +// `nslots` is the count of USABLE saved networks. The policy is deliberately blind +// to SSIDs: the glue compacts empty slots away and passes the count, so "skip an +// empty slot" never becomes a state in here. Values < 1 are clamped to 1 (a device +// with zero usable networks goes to AP by the normal budget path, not by dividing +// by zero); values above WIFI_CONN_MAX_SLOTS are clamped to the cap. +void wifi_conn_policy_reset(WifiConnPolicy* p, int64_t now_us, int nslots); + +// Advance the machine on one event; returns the action for the glue to execute. +// - GOT_IP: → GOT_IP; first time also emits WCA_SPAWN_LISTENER. Freezes the walk: +// this slot's creds are proven, so we never advance off it again. +// - DISCONNECTED, still acquiring: retry the current slot (WCA_CONNECT) until its +// TIMEOUT/nslots share elapses, then advance to the next slot (WCA_TRY_SLOT), or +// give up to AP (WCA_GIVE_UP_TO_AP) if that was the last one. +// - DISCONNECTED, after an IP: WCA_CONNECT forever on the proven slot; never AP. +// - Once in AP: every event is WCA_NONE (terminal until reboot). +WifiConnAction wifi_conn_policy_step(WifiConnPolicy* p, WifiConnEvent e, int64_t now_us); + +#ifdef __cplusplus +} +#endif diff --git a/X32Link/wifi_down_blink.c b/X32Link/wifi_down_blink.c new file mode 100644 index 0000000..b0aebf8 --- /dev/null +++ b/X32Link/wifi_down_blink.c @@ -0,0 +1,12 @@ +// Pure trigger-decision for the WiFi-down status LED — see wifi_down_blink.h. +#include "wifi_down_blink.h" + +bool wifi_down_blink_due(uint32_t now_ms, uint32_t last_blink_ms, + uint32_t interval_ms, bool wifi_connected) { + if (wifi_connected) return false; + // Unsigned subtraction wraps correctly across millis() rollover (~49.7 + // days), same trick every millis()-timed caller in this codebase relies + // on (see wifi_try_connect()'s `millis() - start` above it in + // X32Link.ino). + return (uint32_t)(now_ms - last_blink_ms) >= interval_ms; +} diff --git a/X32Link/wifi_down_blink.h b/X32Link/wifi_down_blink.h new file mode 100644 index 0000000..a30c040 --- /dev/null +++ b/X32Link/wifi_down_blink.h @@ -0,0 +1,29 @@ +// Pure trigger-decision for the WiFi-down status LED (LNK-023). +// +// led_task() in X32Link.ino polls this every 5ms alongside the existing +// phase-wrap beat flash (led_phase.c) — when WiFi isn't connected (initial +// connect attempt or parked in AP fallback), the LED should fire an +// occasional triple-blink distinct from the per-beat flash. This is just +// the "is it time yet" decision, pulled out so it's host-testable without +// FreeRTOS/Arduino/WiFi.h. Mirrors led_phase.c's pattern: pure fn, actual +// GPIO sequencing stays in led_task(). +#pragma once +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +// Should the triple-blink fire on this poll? `now_ms`/`last_blink_ms` are +// both millis()-style timestamps (wraparound-safe via unsigned subtraction +// — same trick millis() callers always rely on). `wifi_connected` mirrors +// `WiFi.status() == WL_CONNECTED`; when true, always false regardless of +// timing (the gate is here too so the decision is fully described by its +// inputs, same rationale as led_phase_should_flash()'s `valid` param). +bool wifi_down_blink_due(uint32_t now_ms, uint32_t last_blink_ms, + uint32_t interval_ms, bool wifi_connected); + +#ifdef __cplusplus +} +#endif diff --git a/X32MidiClock/X32MidiClock.ino b/X32MidiClock/X32MidiClock.ino deleted file mode 100644 index 59c8954..0000000 --- a/X32MidiClock/X32MidiClock.ino +++ /dev/null @@ -1,131 +0,0 @@ -#include -#include -#include -#include "config.h" -#include "app_config.h" -#include "midi_clock.h" -#include "midi_bpm.h" -#include "bpm_tracker.h" -#include "osc_sender.h" -#include "web_config.h" - -#define LED_PIN 48 -#define LED_FLASH_MS 30 - -AppConfig g_config; -volatile float g_current_bpm = MCK_DEFAULT_BPM; - -static void bpm_task(void*) { - uint32_t last_send_ms = 0; - int beat_count = 0; - for (;;) { - bool bar_tick = false; - if (midi_clock_beat_flag()) { - digitalWrite(LED_PIN, HIGH); - vTaskDelay(pdMS_TO_TICKS(LED_FLASH_MS)); - digitalWrite(LED_PIN, LOW); - if (++beat_count >= 4 * MCK_REFRESH_BARS) { - beat_count = 0; - bar_tick = true; - } - } - - float bpm = midi_bpm_update(); - uint32_t now = (uint32_t)millis(); - bool changed = bpm > 0.0f && bpm_tracker_update(bpm); - bool refresh = bpm > 0.0f && bar_tick; - if ((changed || refresh) && (now - last_send_ms) >= MCK_SEND_INTERVAL_MS) { - osc_send_bpm(bpm); - last_send_ms = now; - g_current_bpm = bpm; - Serial0.printf("[X32MidiClock] BPM %.2f → OSC sent%s\n", bpm, changed ? "" : " (refresh)"); - } - vTaskDelay(pdMS_TO_TICKS(MCK_POLL_MS)); - } -} - -static bool wifi_try_connect() { - Serial0.printf("[X32MidiClock] connecting to %s\n", g_config.wifi_ssid); - WiFi.mode(WIFI_STA); - WiFi.begin(g_config.wifi_ssid, g_config.wifi_pass); - unsigned long start = millis(); - while (WiFi.status() != WL_CONNECTED) { - if (millis() - start > 30000) { WiFi.disconnect(true); return false; } - delay(500); - Serial0.print("."); - } - Serial0.println(); - Serial0.printf("[X32MidiClock] IP: %s\n", WiFi.localIP().toString().c_str()); - return true; -} - -static void start_config_ap() { - WiFi.mode(WIFI_AP); - WiFi.softAP("X32MidiClock-Config"); - Serial0.printf("[X32MidiClock] AP started — join 'X32MidiClock-Config' → http://%s\n", - WiFi.softAPIP().toString().c_str()); - web_config_ap_begin(); - for (;;) web_config_handle(); // save handler calls ESP.restart() -} - -static void check_factory_reset() { - pinMode(0, INPUT_PULLUP); - if (digitalRead(0) != LOW) return; - Serial0.println("[X32MidiClock] BOOT held — factory reset in 5s, release to cancel"); - unsigned long t = millis(); - while (digitalRead(0) == LOW && millis() - t < 5000) delay(100); - if (millis() - t >= 5000) { - config_clear(); - Serial0.println("[X32MidiClock] NVS cleared — restarting"); - ESP.restart(); - } -} - -void setup() { - Serial0.begin(115200); - delay(500); - Serial0.println("[X32MidiClock] booting"); - - pinMode(LED_PIN, OUTPUT); - digitalWrite(LED_PIN, LOW); - - check_factory_reset(); - config_load(&g_config); - Serial0.printf("[X32MidiClock] model=%s slot=%d mixer=%s:%d\n", - g_config.model == MODEL_X32 ? "X32" : "XR18", - g_config.fx_slot, g_config.mixer_ip, - config_model_port(g_config.model)); - - // USB MIDI must be initialised before WiFi to ensure USB enumeration is fast - // Device name is set in midi_clock.cpp via USBMIDI constructor - USB.manufacturerName("X32Link"); - midi_clock_init(); // calls MidiUSB.begin() + spawns poll task - USB.begin(); - - if (!wifi_try_connect()) start_config_ap(); // never returns on AP path - - osc_sender_begin(); - midi_bpm_init(); - bpm_tracker_init(0.0f, MCK_BPM_THRESHOLD); - xTaskCreatePinnedToCore(bpm_task, "bpm", 4096, NULL, 1, NULL, 0); - web_config_begin(); - - Serial0.print("[X32MidiClock] ready — config at http://"); - Serial0.println(WiFi.localIP()); -} - -void loop() { - if (WiFi.status() != WL_CONNECTED) { - Serial0.println("[X32MidiClock] WiFi lost — reconnecting"); - WiFi.begin(g_config.wifi_ssid, g_config.wifi_pass); - while (WiFi.status() != WL_CONNECTED) { delay(500); Serial0.print("."); } - Serial0.println(); - osc_sender_begin(); - } - web_config_handle(); - Serial0.printf("[X32MidiClock] bpm:%.2f pulses:%lu heap:%lu\n", - g_current_bpm, - (unsigned long)midi_clock_pulse_count(), - (unsigned long)ESP.getFreeHeap()); - delay(5000); -} diff --git a/X32MidiClock/app_config.c b/X32MidiClock/app_config.c deleted file mode 120000 index 68868ec..0000000 --- a/X32MidiClock/app_config.c +++ /dev/null @@ -1 +0,0 @@ -../X32Link/app_config.c \ No newline at end of file diff --git a/X32MidiClock/app_config.h b/X32MidiClock/app_config.h deleted file mode 120000 index b804ca8..0000000 --- a/X32MidiClock/app_config.h +++ /dev/null @@ -1 +0,0 @@ -../X32Link/app_config.h \ No newline at end of file diff --git a/X32MidiClock/app_config_nvs.cpp b/X32MidiClock/app_config_nvs.cpp deleted file mode 120000 index 4902cb1..0000000 --- a/X32MidiClock/app_config_nvs.cpp +++ /dev/null @@ -1 +0,0 @@ -../X32Link/app_config_nvs.cpp \ No newline at end of file diff --git a/X32MidiClock/bpm_tracker.c b/X32MidiClock/bpm_tracker.c deleted file mode 120000 index b69a836..0000000 --- a/X32MidiClock/bpm_tracker.c +++ /dev/null @@ -1 +0,0 @@ -../X32Link/bpm_tracker.c \ No newline at end of file diff --git a/X32MidiClock/bpm_tracker.h b/X32MidiClock/bpm_tracker.h deleted file mode 120000 index 79a0a4d..0000000 --- a/X32MidiClock/bpm_tracker.h +++ /dev/null @@ -1 +0,0 @@ -../X32Link/bpm_tracker.h \ No newline at end of file diff --git a/X32MidiClock/build_opt.h b/X32MidiClock/build_opt.h deleted file mode 100644 index 8b13789..0000000 --- a/X32MidiClock/build_opt.h +++ /dev/null @@ -1 +0,0 @@ - diff --git a/X32MidiClock/config.h b/X32MidiClock/config.h deleted file mode 100644 index 32b1215..0000000 --- a/X32MidiClock/config.h +++ /dev/null @@ -1,19 +0,0 @@ -#pragma once - -#define MCK_DEFAULT_BPM 120.0f -#define MCK_BPM_THRESHOLD 1.5f // ignore jitter smaller than this -#define MCK_SEND_INTERVAL_MS 500 // minimum ms between OSC sends -#define MCK_REFRESH_BARS 1 // resend every N bars (4 beats) -#define MCK_CLOCK_WINDOW 24 // pulses to average (1 beat = 24 PPQN) -#define MCK_CLOCK_TIMEOUT_MS 2000 // stop sending OSC if no pulse for this long -#define MCK_POLL_MS 10 // BPM task poll interval - -// First-boot defaults — overridden after first web config save -#define DEFAULT_WIFI_SSID "X32-Emulator" -#define DEFAULT_WIFI_PASS "" -#define DEFAULT_MIXER_IP "192.168.4.1" -#define DEFAULT_MODEL 1 // MODEL_XR18 -#define DEFAULT_FX_SLOT 1 -#define DEFAULT_INPUT_SOURCE 1 // TEMPO_SRC_MIDI (this firmware is MIDI-only) -#define DEFAULT_FDR_ENABLE 0 // FaderDisp off on non-FDR firmware -#define DEFAULT_FDR_CHAN_COUNT 32 diff --git a/X32MidiClock/midi_bpm.cpp b/X32MidiClock/midi_bpm.cpp deleted file mode 100644 index e05c9c6..0000000 --- a/X32MidiClock/midi_bpm.cpp +++ /dev/null @@ -1,27 +0,0 @@ -#include "midi_bpm.h" -#include "midi_clock.h" -#include "midi_bpm_calc.h" -#include "config.h" -#include - -void midi_bpm_init() { - // nothing to initialise — state lives in midi_clock module -} - -float midi_bpm_update() { - uint32_t count = midi_clock_pulse_count(); - if (count < (uint32_t)MCK_CLOCK_WINDOW) return 0.0f; - - // Timeout: no pulse for MCK_CLOCK_TIMEOUT_MS → clock stopped - uint32_t last_us = midi_clock_last_pulse_us(); - if ((uint32_t)(micros() - last_us) > (uint32_t)(MCK_CLOCK_TIMEOUT_MS * 1000UL)) - return 0.0f; - - // MCK_CLOCK_WINDOW timestamps span (MCK_CLOCK_WINDOW-1) intervals. - // Scale up to a full beat (MCK_CLOCK_WINDOW intervals = 24 PPQN). - uint32_t newest = midi_clock_get_timestamp(MCK_CLOCK_WINDOW - 1); - uint32_t oldest = midi_clock_get_timestamp(0); - uint32_t span_us = newest - oldest; // uint32_t wraps correctly - - return midi_bpm_calc(span_us, MCK_CLOCK_WINDOW); // pure, host-tested -} diff --git a/X32MidiClock/midi_bpm.h b/X32MidiClock/midi_bpm.h deleted file mode 100644 index 830cc0c..0000000 --- a/X32MidiClock/midi_bpm.h +++ /dev/null @@ -1,4 +0,0 @@ -#pragma once - -void midi_bpm_init(void); -float midi_bpm_update(void); // returns 0.0 if no clock or timed out diff --git a/X32MidiClock/midi_bpm_calc.c b/X32MidiClock/midi_bpm_calc.c deleted file mode 100644 index a7f184b..0000000 --- a/X32MidiClock/midi_bpm_calc.c +++ /dev/null @@ -1,7 +0,0 @@ -#include "midi_bpm_calc.h" - -float midi_bpm_calc(uint32_t span_us, int window) { - if (span_us == 0 || window < 2) return 0.0f; - // span covers (window-1) intervals; a beat is `window` intervals. - return 60000000.0f * (float)(window - 1) / ((float)span_us * (float)window); -} diff --git a/X32MidiClock/midi_bpm_calc.h b/X32MidiClock/midi_bpm_calc.h deleted file mode 100644 index c08554c..0000000 --- a/X32MidiClock/midi_bpm_calc.h +++ /dev/null @@ -1,15 +0,0 @@ -#pragma once -#include - -#ifdef __cplusplus -extern "C" { -#endif - -// Pure BPM-from-MIDI-clock math, no Arduino deps — host-testable. -// MIDI clock is 24 PPQN. A window of `window` timestamps spans (window-1) -// intervals; scale that up to a full beat. Returns 0.0f for a zero/invalid span. -float midi_bpm_calc(uint32_t span_us, int window); - -#ifdef __cplusplus -} -#endif diff --git a/X32MidiClock/midi_clock.cpp b/X32MidiClock/midi_clock.cpp deleted file mode 100644 index 185e208..0000000 --- a/X32MidiClock/midi_clock.cpp +++ /dev/null @@ -1,68 +0,0 @@ -#include "midi_clock.h" -#include "config.h" -#include -#include - -#define RING_SIZE (MCK_CLOCK_WINDOW * 2) - -USBMIDI MidiUSB("X32MidiClock"); - -static volatile uint32_t s_ring[RING_SIZE]; -static volatile uint32_t s_head = 0; -static volatile uint32_t s_pulse_count = 0; -static volatile bool s_beat_flag = false; -static portMUX_TYPE s_mux = portMUX_INITIALIZER_UNLOCKED; - -// High-priority poll task — calls readPacket() at 1 ms intervals. -// MIDI Clock is a single-byte real-time message (0xF8), CIN=0x0F. -static void midi_poll_task(void*) { - midiEventPacket_t pkt; - for (;;) { - while (MidiUSB.readPacket(&pkt)) { - if (pkt.byte1 == 0xF8) { - uint32_t t = micros(); - portENTER_CRITICAL(&s_mux); - s_ring[s_head % RING_SIZE] = t; - s_head++; - s_pulse_count++; - if (s_pulse_count % MCK_CLOCK_WINDOW == 0) s_beat_flag = true; - portEXIT_CRITICAL(&s_mux); - } - } - vTaskDelay(1); // 1 ms — fine at 240 BPM (pulse every ~10 ms) - } -} - -void midi_clock_init() { - MidiUSB.begin(); - xTaskCreatePinnedToCore(midi_poll_task, "midi_poll", 2048, NULL, 5, NULL, 1); -} - -uint32_t midi_clock_pulse_count() { - return s_pulse_count; -} - -uint32_t midi_clock_last_pulse_us() { - if (s_pulse_count == 0) return 0; - portENTER_CRITICAL(&s_mux); - uint32_t t = s_ring[(s_head - 1) % RING_SIZE]; - portEXIT_CRITICAL(&s_mux); - return t; -} - -bool midi_clock_beat_flag() { - if (!s_beat_flag) return false; - portENTER_CRITICAL(&s_mux); - s_beat_flag = false; - portEXIT_CRITICAL(&s_mux); - return true; -} - -// index 0 = oldest pulse in the current MCK_CLOCK_WINDOW window -uint32_t midi_clock_get_timestamp(uint32_t index) { - portENTER_CRITICAL(&s_mux); - uint32_t slot = (s_head - MCK_CLOCK_WINDOW + index) % RING_SIZE; - uint32_t t = s_ring[slot]; - portEXIT_CRITICAL(&s_mux); - return t; -} diff --git a/X32MidiClock/midi_clock.h b/X32MidiClock/midi_clock.h deleted file mode 100644 index 20747d4..0000000 --- a/X32MidiClock/midi_clock.h +++ /dev/null @@ -1,8 +0,0 @@ -#pragma once -#include - -void midi_clock_init(void); -uint32_t midi_clock_pulse_count(void); -uint32_t midi_clock_last_pulse_us(void); -uint32_t midi_clock_get_timestamp(uint32_t index); // index 0 = oldest in window -bool midi_clock_beat_flag(void); // returns true once per beat, then clears diff --git a/X32MidiClock/osc_out.c b/X32MidiClock/osc_out.c deleted file mode 120000 index afacf6a..0000000 --- a/X32MidiClock/osc_out.c +++ /dev/null @@ -1 +0,0 @@ -../X32Link/osc_out.c \ No newline at end of file diff --git a/X32MidiClock/osc_out.h b/X32MidiClock/osc_out.h deleted file mode 120000 index f048502..0000000 --- a/X32MidiClock/osc_out.h +++ /dev/null @@ -1 +0,0 @@ -../X32Link/osc_out.h \ No newline at end of file diff --git a/X32MidiClock/osc_sender.cpp b/X32MidiClock/osc_sender.cpp deleted file mode 120000 index 537e970..0000000 --- a/X32MidiClock/osc_sender.cpp +++ /dev/null @@ -1 +0,0 @@ -../X32Link/osc_sender.cpp \ No newline at end of file diff --git a/X32MidiClock/osc_sender.h b/X32MidiClock/osc_sender.h deleted file mode 120000 index 839e044..0000000 --- a/X32MidiClock/osc_sender.h +++ /dev/null @@ -1 +0,0 @@ -../X32Link/osc_sender.h \ No newline at end of file diff --git a/X32MidiClock/web_config.cpp b/X32MidiClock/web_config.cpp deleted file mode 120000 index 8a31f85..0000000 --- a/X32MidiClock/web_config.cpp +++ /dev/null @@ -1 +0,0 @@ -../X32Link/web_config.cpp \ No newline at end of file diff --git a/X32MidiClock/web_config.h b/X32MidiClock/web_config.h deleted file mode 120000 index 2e1811f..0000000 --- a/X32MidiClock/web_config.h +++ /dev/null @@ -1 +0,0 @@ -../X32Link/web_config.h \ No newline at end of file diff --git a/X32_emulator/X32_emulator.ino b/X32_emulator/X32_emulator.ino index f93ca3d..a20dc4c 100644 --- a/X32_emulator/X32_emulator.ino +++ b/X32_emulator/X32_emulator.ino @@ -2,6 +2,7 @@ #include #include #include +#include "fw_version.h" // LNK-038: our build identity (NOT XVERSION — that's the emulated console's) #define WIFI_SSID_HOME "Skeyelab" #define WIFI_PASS_HOME "diamond2" @@ -75,7 +76,7 @@ void setup() { Serial.println("OTA ready (x32-emulator.local)"); x32_init(ip_str); - Serial.println("X32 emulator ready on UDP port 10023"); + Serial.println("X32 emulator fw:" FW_VERSION " built:" FW_BUILD " — ready on UDP port 10023"); } void loop() { diff --git a/X32_emulator/fw_version.h b/X32_emulator/fw_version.h new file mode 120000 index 0000000..6a68aed --- /dev/null +++ b/X32_emulator/fw_version.h @@ -0,0 +1 @@ +../X32Link/fw_version.h \ No newline at end of file diff --git a/docs/adr/0003-firmware-pure-c-glue-split.md b/docs/adr/0003-firmware-pure-c-glue-split.md new file mode 100644 index 0000000..b130b12 --- /dev/null +++ b/docs/adr/0003-firmware-pure-c-glue-split.md @@ -0,0 +1,57 @@ +# 3. ESP32 firmware splits pure-C logic from thin Arduino glue + +Date: 2026-07-03 +Status: accepted (sharing mechanism amended by [ADR-0007](0007-shared-pure-c-compiled-by-path.md)) + +> **Amended 2026-07-09.** The pure-C / thin-glue split below stands unchanged. +> Only the paragraph describing *how* shared files reach each firmware is stale: +> `X32MidiClock/` was retired in LNK-024, so nothing is symlinked into it and the +> `midi_*` files are now real files in `X32Link/`. Shared pure C is compiled by +> path. The symlink-mistaken-for-a-copy risk listed in Consequences is likewise +> retired. See ADR-0007. + +## Context + +The ESP32-S3 firmware (`esp32/X32Link`, `esp32/X32MidiClock`) has to run real +hardware — WiFi/UDP, USB-MIDI, NVS, a LovyanGFX touch LCD — but the interesting +behavior is logic: Ableton Link gossip parsing, ping/pong clock-offset math, BPM +change/threshold detection, bar/phase math, config validation, touch hit-testing. +Arduino code can only be compiled for the board and is painful to test on the dev +box, so any logic trapped inside `.cpp` glue is effectively untested until it's +flashed. Several real bugs this cycle (torn tempo reads, Link phase never holding +valid, MIDI BPM waver) were logic bugs that a host test would have caught. + +## Decision + +Every non-trivial module is split in two: + +- `foo.c` — **pure logic**, no Arduino headers. Compiled BOTH into the sketch and + into a Unity `test_foo.c` suite under `esp32/test/` (run on the dev box via + `make`). On-device-only concerns (e.g. portMUX) are `#if defined(ARDUINO) || + defined(ESP_PLATFORM)` guarded, no-op on host. +- `foo_io.cpp` / `foo.cpp` — **thin glue**: WiFiUDP, GPIO, NVS, LovyanGFX. As + close to no branching logic as possible. + +Examples: `link_measurement.c` (TLV build/parse + median/offset) vs +`link_measurement_io.cpp` (unicast WiFiUDP); `touch_ui.c` (hit-testing / keypad +buffer) vs `touch_display.cpp` (LovyanGFX); `app_config.c` vs `app_config_nvs.cpp`; +`tempo_snapshot.c` (atomic tempo hand-off) and `bar.c` (beats-per-bar) as pure +seams with their own suites. + +Shared C files are **real in `X32Link/` and symlinked into `X32MidiClock/`** +(Arduino requires flat sketch dirs); the `midi_*` files are the reverse. Edit the +real file. `check_docs.sh` (in the test target) asserts every source named in +`esp32/AGENTS.md` exists, so the module map can't rot. + +## Consequences + +- New logic lands test-first: a `.c` + a wired-in `test_*.c` before it's called + from a sketch. Behavior is reproducible on the dev box before a board is plugged + in — which matters because native-USB-S3 serial is flaky after flashing (the web + `/status` endpoint, not serial, is the reliable live readout). +- The symlink sharing keeps one source of truth but is invisible in a flat Arduino + dir — a reader can mistake a symlink for a copy and edit the wrong end. +- A machine-readable version of this (plus STACK/PATTERNS/TRADEOFFS/PHILOSOPHY for + the whole monorepo) is stored in the codebase-memory graph ADR. That copy lives + in the graph DB and is rebuilt/wiped on re-index; this file is the durable, + git-tracked source of record. diff --git a/docs/adr/0004-touch-realtime-web-config.md b/docs/adr/0004-touch-realtime-web-config.md new file mode 100644 index 0000000..045d2b4 --- /dev/null +++ b/docs/adr/0004-touch-realtime-web-config.md @@ -0,0 +1,45 @@ +# 4. Touch screen carries real-time controls; static config lives in the web UI + +Date: 2026-07-04 +Status: accepted + +## Context + +X32Link has two config surfaces: the **web config UI** (rack-panel page served +over WiFi) and the on-device **1.47" touch LCD settings screen** (LNK-014/015). +During the LNK-032 config-edit review a gap surfaced: `midi_clock_out_enable` +(LNK-027) can be set from the web UI but has no control on the touch settings +screen — and that screen is already full (source / model / FX slot / quantum / +mixer IP / Write & Reboot). + +The question: make room on the touch screen for every config field so the two +editors stay at parity, or accept that the touch screen intentionally shows a +subset? + +## Decision + +The **web UI is the complete config surface**. The **touch screen carries only +controls a user would plausibly change in real time, at the rack** — plus live +status (the phase wheel, BPM) — not static setup that requires a reboot to take +effect. + +- Reboot-gated setup — WiFi credentials, mixer IP/model, FX slot, and + `midi_clock_out_enable` — is a "configure once, Write & Reboot" concern and + belongs on the web page. +- The touch screen is for on-the-fly changes (e.g. bar quantum) and live readout. + +So `midi_clock_out_enable` having no touch control is **intentional, not a bug**: +it's a reboot-gated config flag, not a real-time control. + +## Consequences + +- The touch settings screen stays uncluttered and focused; not every web field + needs a touch twin — the two editors are deliberately **not** at full parity. +- The LNK-032 shared config-edit helpers (`config_set_model`, and future + `config_set_*`) still let both editors share clamp/dependency rules for the + fields they *do* both expose. +- Future architecture reviews should **not** re-flag "the touch UI can't set + field X" as a defect for reboot-gated config fields — that is this decision, + not drift. (This closes the LNK-032 "touch can't set midi_clock_out" item as + won't-do rather than deferred.) +- If a field becomes real-time-relevant later, it can earn a touch control then. diff --git a/docs/adr/0005-per-target-firmware-framework.md b/docs/adr/0005-per-target-firmware-framework.md new file mode 100644 index 0000000..8c36b99 --- /dev/null +++ b/docs/adr/0005-per-target-firmware-framework.md @@ -0,0 +1,63 @@ +# 5. Per-target firmware framework: Arduino for the S3, ESP-IDF for the P4 + +Date: 2026-07-05 +Status: amended by [ADR-0009](0009-converge-the-clock-box-on-esp-idf.md) + +> **Amended 2026-07-12.** The revisit condition below — *"if maintaining two glue layers +> becomes a real burden (not expected)"* — **fired**. The 1 ms clock writer exists twice +> (`ks_main.c` and `ktouch_midi_out.cpp`), and ESP-023 was fixed in the P4's copy while the +> Touch silently kept the bug. ADR-0009 converges the CLOCK BOX on ESP-IDF. The rest of this +> ADR stands: X32Link, LoraLink and X32_emulator are *related* products, do not duplicate the +> clock engine, and stay on Arduino. + +## Context + +The product line now spans two SoCs. The shipped **ESP32-S3 `X32Link`** is an +Arduino sketch (arduino-cli, `adafruit_qtpy_esp32s3` FQBN) — WiFiUDP for Link, a +LovyanGFX touch LCD, a web config server, TinyUSB-device MIDI. The new **ESP32-P4 +`KitchenSync`** hub tier (P4-001..006) grows I/O the S3 physically can't: USB-MIDI +**host**, WiFi via the onboard **ESP32-C6** co-processor, later audio. + +The 2026-07-04 hardware spikes established a hard constraint: the P4 hub features +require ESP-IDF components that the Arduino-ESP32 core does not cleanly expose — +`usb_host` (USB-MIDI host, P4-003/005) and `esp_hosted` / `esp_wifi_remote` +(hosted WiFi through the C6, P4-004). All three P4 spikes (USB host, WiFi, Link +listener) were ESP-IDF and worked; the equivalent under Arduino would fight the +framework. + +This raised the question: unify on one framework — in particular, rewrite the +working S3 `X32Link` in ESP-IDF to match? + +## Decision + +**Choose the firmware framework per target, and keep the pure logic shared:** + +- **S3 `X32Link` stays Arduino.** It is shipped and hardware-validated; its glue + leans on Arduino-native libraries (LovyanGFX, the web server, TinyUSB-device). + There is no user-facing gain in rewriting it, and its limits (no USB host, no + second radio) are *hardware* — ESP-IDF would not unlock them. +- **P4 `KitchenSync` is a native ESP-IDF app.** Its headline features need ESP-IDF + components, so this is not a preference but a requirement. +- **The pure, host-tested C modules are shared unchanged across both**, compiled + into each app by relative-path `SRCS` (single source, no copies/forks) and + still covered by their Unity suites in `test/`. KitchenSync's scaffold already + compiles `X32Link/clock_ticker.c` as-is. + +This does **not** weaken [ADR-0003](0003-firmware-pure-c-glue-split.md): that ADR +already says the interesting logic is pure C and only the glue is platform +specific. Two glue frameworks is exactly the shape ADR-0003 anticipates — the +seam that matters (the pure modules) is unchanged. + +## Consequences + +- Two build systems (arduino-cli for S3, `idf.py` for P4) and two CI compile + jobs. This is a low, one-time tax because the shared surface is the pure C, not + the glue. +- New *logic* must stay in the pure `.c` layer so both targets keep reusing it; + resist the temptation to write feature logic inside ESP-IDF or Arduino glue. +- If a pure module ever needs an on-device guard, keep the existing + `#if defined(ARDUINO) || defined(ESP_PLATFORM)` pattern so it stays host-testable + and compiles under both frameworks. +- Revisit only if a future S3 feature genuinely needs an ESP-IDF-only capability + that its hardware can actually support, or if maintaining two glue layers + becomes a real burden (not expected — glue is thin by ADR-0003). diff --git a/docs/adr/0006-shared-pure-c-lives-in-arduino-sketch-root.md b/docs/adr/0006-shared-pure-c-lives-in-arduino-sketch-root.md new file mode 100644 index 0000000..8e03cbc --- /dev/null +++ b/docs/adr/0006-shared-pure-c-lives-in-arduino-sketch-root.md @@ -0,0 +1,72 @@ +# 6. Shared pure C lives in the X32Link Arduino sketch root + +Date: 2026-07-05 +Status: superseded by [ADR-0007](0007-shared-pure-c-compiled-by-path.md) + +> **Superseded 2026-07-09.** The conclusion below still holds — shared pure C +> lives in the `X32Link/` sketch root, and ARC-008/ARC-009 stay declined — but +> the reasoning does not. This ADR rests its case on "the symlink farm the +> flat-sketch rule forces"; LNK-024 retired `X32MidiClock/` and with it the farm. +> The real and sufficient blocker is that arduino-cli compiles only the sketch +> root. See ADR-0007. + +## Context + +ADR-0003 splits the firmware into pure, host-tested C logic + thin per-target +glue, and ADR-0005 has each target use its own framework: **X32Link** (ESP32-S3) +builds with **arduino-cli**, **KitchenSync** (ESP32-P4) with **ESP-IDF**. The pure C is +meant to be shared across both targets, unchanged. + +A recurring instinct — including from the automated architecture review that +produced ARC-006..010 — is to *co-locate* the shared pure modules into tidy +subdirectories: a `link/` package for the four Link modules (ARC-008), or a +neutral `shared/` root for all of them (ARC-009). The directory name would then +tell the truth about ownership instead of burying shared code inside one target's +folder. + +The blocker is the **arduino-cli sketch model**. arduino-cli compiles only the +files in the **sketch root directory** (`X32Link/`), plus a `src/` subfolder with +its own include-path rules. It does **not** recurse into arbitrary subdirectories. +So a file moved to `X32Link/link/` or a top-level `shared/` simply stops being +compiled into the S3 firmware — the one we flash to hardware — producing link +errors, not a warning. + +The existing codebase already encodes the workaround: **X32MidiClock** (a second +Arduino sketch) consumes the shared modules by **symlinking each file back into +its own sketch root** (`X32MidiClock/bpm_tracker.c -> ../X32Link/bpm_tracker.c`, +~16 of them). KitchenSync, being ESP-IDF, has no such constraint — it references the +files by path from its `CMakeLists.txt`. + +So any "clean move" of a shared module is really: move the real file, then +symlink it back into every Arduino sketch root that needs it, and repoint the +ESP-IDF `CMakeLists.txt` and the host-test `Makefile`. The physical grouping is +undone by the symlink farm the flat-sketch rule forces. + +## Decision + +Shared pure C modules **physically live in the `X32Link/` sketch root**. Arduino +sketches other than X32Link consume them by **symlinking the file into their own +root**; ESP-IDF targets reference them by relative path from `CMakeLists.txt`; +host tests compile them via `-I../X32Link` and explicit paths in `test/Makefile`. + +We do **not** relocate shared modules into per-concept subdirectories (`link/`) +or a neutral `shared/` root. The navigability gain does not justify a symlink +farm in front of the firmware we flash. Navigability is instead served by +documentation (module tables in `AGENTS.md`, header-comment pipelines) and by the +deep-module refactors that *do* pay off — the pure, host-tested seams from +ARC-006 (`ks_form`) and ARC-007 (`beat_source`). + +## Consequences + +- `X32Link/` contains both S3-specific glue and target-neutral shared logic; the + directory name under-sells what lives there. `AGENTS.md` documents which + modules are shared vs S3-only. +- Adding a shared module used by another Arduino sketch means adding a symlink in + that sketch's root (mirror the X32MidiClock pattern). +- ARC-008 (co-locate `link/`) and ARC-009 (hoist `shared/`) are **declined** on + this basis; ARC-010 (prune shallow modules) is deferred to the P4-013 swing + outcome, independent of layout. Future architecture reviews should not + re-propose relocating shared modules out of the sketch root. +- If the S3 firmware ever migrates off arduino-cli (e.g. to ESP-IDF or a + PlatformIO library layout), this constraint lifts and the co-location refactors + become worth revisiting. diff --git a/docs/adr/0007-shared-pure-c-compiled-by-path.md b/docs/adr/0007-shared-pure-c-compiled-by-path.md new file mode 100644 index 0000000..e0dfbd6 --- /dev/null +++ b/docs/adr/0007-shared-pure-c-compiled-by-path.md @@ -0,0 +1,91 @@ +# 7. Shared pure C is compiled by path, not symlinked + +Date: 2026-07-09 +Status: superseded by [ADR-0009](0009-converge-the-clock-box-on-esp-idf.md) + +> **Superseded 2026-07-12.** Two claims below did not survive contact with the repo. +> (1) "Symlinking is no longer a sharing mechanism … `X32_emulator/fw_version.h` is the +> only instance" is **factually false**: `KitchenSyncTouch/` symlinks 41 files out of +> `X32Link/` and `LoraLink/` symlinks 8. Symlinks into a flat sketch root are, and always +> were, the sharing mechanism for arduino-cli consumers. (2) Keeping the shared engine in +> `X32Link/` is not merely untidy — it caused a real defect (the ESP-025 bench rig was +> built into the mixer firmware and had to be backed out). ADR-0009 moves the engine to a +> home owned by the primary product, bundled with the KitchenSyncTouch → ESP-IDF migration +> so the move is symlink-neutral. + +Supersedes [ADR-0006](0006-shared-pure-c-lives-in-arduino-sketch-root.md). +Amends the sharing-mechanism paragraph of [ADR-0003](0003-firmware-pure-c-glue-split.md). + +## Context + +ADR-0003 established the pure-C / thin-glue split and described how the shared +modules reach each firmware: + +> Shared C files are **real in `X32Link/` and symlinked into `X32MidiClock/`** +> (Arduino requires flat sketch dirs); the `midi_*` files are the reverse. + +ADR-0006 then declined the "co-locate shared modules into `link/` or `shared/`" +refactors (ARC-008, ARC-009), reasoning that any clean move would be undone by +"the symlink farm the flat-sketch rule forces." + +Both statements described a repo with **two** Arduino sketches. LNK-024 +(2026-07-08) retired `X32MidiClock/` — X32Link had supported MIDI clock since +LNK-010, so the second sketch was a duplicate product. Its `midi_*` files became +real files in `X32Link/`, and its 18 inbound symlinks went with it. + +What remains: + +- **X32Link** (ESP32-S3, arduino-cli) — owns the shared pure C as real files in + its sketch root. +- **KitchenSync** (ESP32-P4, ESP-IDF) — compiles those same files by relative + path via `SRCS` + `X32LINK_DIR` in `main/CMakeLists.txt`. +- **Host tests** — compile them via `-I../X32Link` and explicit paths in + `test/Makefile`. +- **X32_emulator** — one vestigial symlink, `X32_emulator/fw_version.h`. + +The symlink farm is gone. Nothing was decided about it; it evaporated as a +side effect of deleting a duplicate sketch. This ADR records what the sharing +mechanism actually is now, because two accepted ADRs describe one that isn't. + +## Decision + +**Shared pure C is compiled into each target by path.** ESP-IDF targets list it +in `SRCS`; host tests list it in `test/Makefile`. Symlinking is no longer a +sharing mechanism — it is a workaround reserved for the case that forces it: a +*second* arduino-cli sketch that needs a shared file in its own flat root. +`X32_emulator/fw_version.h` is the only instance. Do not add more without that +constraint actually applying. + +**Shared pure C still physically lives in the `X32Link/` sketch root**, and +ARC-008 / ARC-009 stay declined — but on the narrower, more durable grounds that +ADR-0006 buried under the symlink argument: **arduino-cli compiles only the +sketch root directory** (plus `src/` with its own include rules). It does not +recurse. A file moved to `X32Link/link/` or a top-level `shared/` stops being +compiled into the firmware we flash, and the failure is a link error, not a +warning. That constraint is untouched by LNK-024 and is the whole reason. The +symlink farm was a *consequence* of the constraint, never the reason itself, and +ADR-0006 mistook one for the other. + +ADR-0003's pure-C / thin-glue split is unaffected and, with two glue frameworks +under ADR-0005, more load-bearing than when it was written. Only its +sharing-mechanism sentence is amended. + +## Consequences + +- `X32Link/` still contains both S3-specific glue and target-neutral shared + logic; its name still under-sells what lives there. `AGENTS.md` remains the + place that says which modules are shared vs S3-only. +- Adding a shared module now means one edit — a line in + `KitchenSync/main/CMakeLists.txt` and one in `test/Makefile`. No symlink. +- **The silent-fork failure mode is gone.** A symlink can be replaced by a copy + (tarball export, `cp -r`, a Windows checkout without `core.symlinks`) and drift + from its target with nothing complaining; ADR-0003 named this risk in its own + Consequences. A CMake `SRCS` path cannot silently fork — a missing file is a + build error. This closes the risk rather than guarding it. +- The reason ARC-008/009 are declined is now a single, checkable fact about + arduino-cli. If the S3 ever migrates off arduino-cli, the constraint lifts and + co-location becomes worth revisiting — same escape hatch ADR-0006 left, now + resting on the real blocker. +- `X32_emulator/fw_version.h` is the last symlink in the repo. If the emulator is + ever folded in or retired, no symlink mechanism remains and this ADR's second + paragraph can be dropped entirely. diff --git a/docs/adr/0008-p4-web-ui-is-the-brand-reference.md b/docs/adr/0008-p4-web-ui-is-the-brand-reference.md new file mode 100644 index 0000000..a915909 --- /dev/null +++ b/docs/adr/0008-p4-web-ui-is-the-brand-reference.md @@ -0,0 +1,69 @@ +# 8. The KitchenSync P4 web page is the brand reference; per-firmware pages mirror it + +Date: 2026-07-10 +Status: accepted + +## Context + +Three firmwares serve a rack-panel web config page and share one design system: +`ui_chrome.{h,c}` (ARC-017) owns the *look* (colour tokens, panel, screws, tempo +glass, switches, Save button) and the client plumbing (`poll()`, `showBpm`, +`postLive`). Each firmware keeps its **own form** — the fields and the +page-specific CSS/markup that extend the chrome — because the feature sets differ +(P4: multi-output clock + metronome + LED strip; Touch: DIN clock + transport; +X32Link: mixer OSC). + +"The forms stay per-firmware" left a hole: *how much finish* each form should +carry was never written down. During ESP-016 (Inc3b) the KitchenSync Touch page +had drifted well below the others — flat stacked toggles, no section grouping, and +help text built by misusing the `.cap` heading class with inline +`style="…color:var(--mut)"` overrides (a design-system bypass). It read as a +different, lesser product than the P4 page even though it used the same chrome. + +Two failure modes were possible when closing that gap: +1. Fix one page by editing the *shared* `ui_chrome.c` — which silently changes the + other two firmwares (and X32Link ships to different hardware). +2. Let each page invent its own structure ad hoc, so "on brand" means nothing and + the pages diverge again on the next increment. + +## Decision + +**`KitchenSync/main/ks_web.cpp` (the P4 page) is the canonical brand/UX +reference.** It is the most complete, most polished config surface, and its +page-specific patterns are the house style every firmware's page should mirror, +scaled to its own feature set: + +- **`.frow.head`** header rows pair a section title (with the LED-dim status tick) + with that feature's master toggle. +- **`.grp` groups + `.sect` accent rails**: a feature's settings live behind its + master toggle and reveal when it flips on (`syncSect`). +- **Structured `.fld` fields** with `.pre` prefix labels — never inline `style=`. +- Help text is a **token-driven class** (`.hint` / equivalent), never a + repurposed `.cap` with inline colour/spacing. +- A **responsive desktop treatment** (two-column form, 4-across status "meter + bridge") when the form is long enough to benefit. + +**The shared chrome is never edited to fix a single page.** Anything one page +needs that isn't already in `ui_chrome.c` is added as page-specific CSS in that +page's own `