From a5341b7169d384a7fee98c0f12d74a7c5bbed4d0 Mon Sep 17 00:00:00 2001 From: Ken Van Hoeylandt Date: Fri, 23 Oct 2026 19:38:55 +0200 Subject: [PATCH 1/2] Updates for new platform naming, add macos support --- Apps/Brainfuck/manifest.properties | 4 +- Apps/Breakout/manifest.properties | 4 +- Apps/BusyBoxVi/manifest.properties | 6 +- Apps/Calculator/manifest.properties | 4 +- Apps/Diceware/manifest.properties | 4 +- Apps/Doom/manifest.properties | 4 +- Apps/EpubReader/manifest.properties | 4 +- Apps/EspNowBridge/manifest.properties | 4 +- Apps/GPIO/manifest.properties | 4 +- Apps/GraphicsDemo/manifest.properties | 4 +- Apps/HelloWorld/manifest.properties | 6 +- Apps/M5UnitTest/manifest.properties | 4 +- Apps/Magic8Ball/manifest.properties | 4 +- Apps/MediaKeys/manifest.properties | 4 +- Apps/MidiController/CMakeLists.txt | 13 + ...ntrol-Change-Messages_Another-Producer.pdf | Bin 0 -> 182253 bytes Apps/MidiController/docs/README.md | 25 + Apps/MidiController/docs/architecture.md | 144 +++ Apps/MidiController/docs/encoder.md | 158 +++ Apps/MidiController/docs/hardware.md | 125 +++ Apps/MidiController/docs/lcd.md | 162 +++ Apps/MidiController/docs/modes.md | 300 ++++++ Apps/MidiController/docs/scales.md | 73 ++ Apps/MidiController/docs/units.md | 317 ++++++ Apps/MidiController/main/CMakeLists.txt | 5 + .../main/Source/EncoderInput.cpp | 375 +++++++ .../main/Source/GroveLookup.cpp | 45 + Apps/MidiController/main/Source/GroveLookup.h | 12 + .../MidiController/main/Source/LcdDisplay.cpp | 803 +++++++++++++++ Apps/MidiController/main/Source/LcdDisplay.h | 49 + .../main/Source/MidiController.cpp | 965 ++++++++++++++++++ .../main/Source/MidiController.h | 567 ++++++++++ Apps/MidiController/main/Source/MidiScales.h | 76 ++ Apps/MidiController/main/Source/Sequencer.cpp | 107 ++ Apps/MidiController/main/Source/Sequencer.h | 58 ++ Apps/MidiController/main/Source/UiKit.h | 159 +++ Apps/MidiController/main/Source/ViewArp.cpp | 541 ++++++++++ Apps/MidiController/main/Source/ViewBaby8.cpp | 417 ++++++++ Apps/MidiController/main/Source/ViewBeats.cpp | 347 +++++++ Apps/MidiController/main/Source/ViewChord.cpp | 298 ++++++ .../main/Source/ViewDimensions.cpp | 575 +++++++++++ Apps/MidiController/main/Source/ViewDrop.cpp | 505 +++++++++ .../MidiController/main/Source/ViewEuclid.cpp | 662 ++++++++++++ Apps/MidiController/main/Source/ViewGrid.cpp | 289 ++++++ Apps/MidiController/main/Source/ViewKeys.cpp | 427 ++++++++ Apps/MidiController/main/Source/ViewLfo.cpp | 449 ++++++++ Apps/MidiController/main/Source/ViewRaga.cpp | 554 ++++++++++ Apps/MidiController/main/Source/ViewRng.cpp | 352 +++++++ Apps/MidiController/main/Source/ViewTb3po.cpp | 606 +++++++++++ Apps/MidiController/main/Source/ViewXYPad.cpp | 310 ++++++ Apps/MidiController/main/Source/ViewZen.cpp | 501 +++++++++ Apps/MidiController/main/Source/main.cpp | 62 ++ Apps/MidiController/manifest.properties | 11 + Apps/MystifyDemo/manifest.properties | 4 +- Apps/Neatvi/manifest.properties | 6 +- Apps/Rcc700/manifest.properties | 6 +- Apps/SatelliteTracker/manifest.properties | 6 +- Apps/SerialConsole/manifest.properties | 4 +- Apps/Snake/manifest.properties | 4 +- Apps/TamaTac/manifest.properties | 4 +- Apps/TodoList/manifest.properties | 4 +- Apps/Tone/manifest.properties | 6 +- Apps/TwoEleven/manifest.properties | 4 +- Apps/WebServer/manifest.properties | 4 +- Apps/Xcc700/manifest.properties | 6 +- Scripts/release.py | 3 + 66 files changed, 11506 insertions(+), 59 deletions(-) create mode 100644 Apps/MidiController/CMakeLists.txt create mode 100644 Apps/MidiController/docs/MIDI-Control-Change-Messages_Another-Producer.pdf create mode 100644 Apps/MidiController/docs/README.md create mode 100644 Apps/MidiController/docs/architecture.md create mode 100644 Apps/MidiController/docs/encoder.md create mode 100644 Apps/MidiController/docs/hardware.md create mode 100644 Apps/MidiController/docs/lcd.md create mode 100644 Apps/MidiController/docs/modes.md create mode 100644 Apps/MidiController/docs/scales.md create mode 100644 Apps/MidiController/docs/units.md create mode 100644 Apps/MidiController/main/CMakeLists.txt create mode 100644 Apps/MidiController/main/Source/EncoderInput.cpp create mode 100644 Apps/MidiController/main/Source/GroveLookup.cpp create mode 100644 Apps/MidiController/main/Source/GroveLookup.h create mode 100644 Apps/MidiController/main/Source/LcdDisplay.cpp create mode 100644 Apps/MidiController/main/Source/LcdDisplay.h create mode 100644 Apps/MidiController/main/Source/MidiController.cpp create mode 100644 Apps/MidiController/main/Source/MidiController.h create mode 100644 Apps/MidiController/main/Source/MidiScales.h create mode 100644 Apps/MidiController/main/Source/Sequencer.cpp create mode 100644 Apps/MidiController/main/Source/Sequencer.h create mode 100644 Apps/MidiController/main/Source/UiKit.h create mode 100644 Apps/MidiController/main/Source/ViewArp.cpp create mode 100644 Apps/MidiController/main/Source/ViewBaby8.cpp create mode 100644 Apps/MidiController/main/Source/ViewBeats.cpp create mode 100644 Apps/MidiController/main/Source/ViewChord.cpp create mode 100644 Apps/MidiController/main/Source/ViewDimensions.cpp create mode 100644 Apps/MidiController/main/Source/ViewDrop.cpp create mode 100644 Apps/MidiController/main/Source/ViewEuclid.cpp create mode 100644 Apps/MidiController/main/Source/ViewGrid.cpp create mode 100644 Apps/MidiController/main/Source/ViewKeys.cpp create mode 100644 Apps/MidiController/main/Source/ViewLfo.cpp create mode 100644 Apps/MidiController/main/Source/ViewRaga.cpp create mode 100644 Apps/MidiController/main/Source/ViewRng.cpp create mode 100644 Apps/MidiController/main/Source/ViewTb3po.cpp create mode 100644 Apps/MidiController/main/Source/ViewXYPad.cpp create mode 100644 Apps/MidiController/main/Source/ViewZen.cpp create mode 100644 Apps/MidiController/main/Source/main.cpp create mode 100644 Apps/MidiController/manifest.properties diff --git a/Apps/Brainfuck/manifest.properties b/Apps/Brainfuck/manifest.properties index 46cf5ac..11e9925 100644 --- a/Apps/Brainfuck/manifest.properties +++ b/Apps/Brainfuck/manifest.properties @@ -2,8 +2,8 @@ manifest.version=0.3 target.sdk=0.8.0-dev target.platforms=esp32,esp32s3,esp32c6,esp32p4 id=tactility.brainfuck -version.name=0.16.0 -version.code=16 +version.name=0.17.0 +version.code=17 name=Brainfuck description=Brainfuck esoteric language interpreter app.0.id=tactility.brainfuck diff --git a/Apps/Breakout/manifest.properties b/Apps/Breakout/manifest.properties index 239ec50..9f510fd 100644 --- a/Apps/Breakout/manifest.properties +++ b/Apps/Breakout/manifest.properties @@ -2,8 +2,8 @@ manifest.version=0.3 target.sdk=0.8.0-dev target.platforms=esp32,esp32s3,esp32c6,esp32p4 id=tactility.breakout -version.name=0.20.0 -version.code=20 +version.name=0.21.0 +version.code=21 name=Breakout description=Classic brick-breaking arcade game app.0.id=tactility.breakout diff --git a/Apps/BusyBoxVi/manifest.properties b/Apps/BusyBoxVi/manifest.properties index 954b77a..190a9f2 100644 --- a/Apps/BusyBoxVi/manifest.properties +++ b/Apps/BusyBoxVi/manifest.properties @@ -1,9 +1,9 @@ manifest.version=0.3 target.sdk=0.8.0-dev -target.platforms=esp32,esp32s3,esp32c6,esp32p4,posix-x86_64 +target.platforms=all id=tactility.busyboxvi -version.name=0.3.0 -version.code=3 +version.name=0.4.0 +version.code=4 name=BusyBox vi description=BusyBox vi text editor app.0.id=tactility.busyboxvi diff --git a/Apps/Calculator/manifest.properties b/Apps/Calculator/manifest.properties index 67c5bd9..04c097f 100644 --- a/Apps/Calculator/manifest.properties +++ b/Apps/Calculator/manifest.properties @@ -2,8 +2,8 @@ manifest.version=0.3 target.sdk=0.8.0-dev target.platforms=esp32,esp32s3,esp32c6,esp32p4 id=tactility.calculator -version.name=0.17.0 -version.code=17 +version.name=0.18.0 +version.code=18 name=Calculator app.0.id=tactility.calculator app.0.name=Calculator diff --git a/Apps/Diceware/manifest.properties b/Apps/Diceware/manifest.properties index 4cbf3ef..2ad0638 100644 --- a/Apps/Diceware/manifest.properties +++ b/Apps/Diceware/manifest.properties @@ -2,8 +2,8 @@ manifest.version=0.3 target.sdk=0.8.0-dev target.platforms=esp32,esp32s3,esp32c6,esp32p4 id=tactility.diceware -version.name=0.19.0 -version.code=19 +version.name=0.20.0 +version.code=20 name=Diceware description=Password generator app.0.id=tactility.diceware diff --git a/Apps/Doom/manifest.properties b/Apps/Doom/manifest.properties index e15169f..b91afab 100644 --- a/Apps/Doom/manifest.properties +++ b/Apps/Doom/manifest.properties @@ -2,8 +2,8 @@ manifest.version=0.3 target.sdk=0.8.0-dev target.platforms=esp32p4 id=tactility.doom -version.name=0.10.0 -version.code=10 +version.name=0.11.0 +version.code=11 name=Doom description=Classic DOOM. Tab5 only, keyboard required to play. app.0.id=tactility.doom diff --git a/Apps/EpubReader/manifest.properties b/Apps/EpubReader/manifest.properties index b9d44f3..76d54e0 100644 --- a/Apps/EpubReader/manifest.properties +++ b/Apps/EpubReader/manifest.properties @@ -2,8 +2,8 @@ manifest.version=0.3 target.sdk=0.8.0-dev target.platforms=esp32s3,esp32p4 id=tactility.epubreader -version.name=0.16.0 -version.code=16 +version.name=0.17.0 +version.code=17 name=ePub Reader description=ePub and text file reader. Requires PSRAM! app.0.id=tactility.epubreader diff --git a/Apps/EspNowBridge/manifest.properties b/Apps/EspNowBridge/manifest.properties index a4fd13e..d731aed 100644 --- a/Apps/EspNowBridge/manifest.properties +++ b/Apps/EspNowBridge/manifest.properties @@ -2,8 +2,8 @@ manifest.version=0.3 target.sdk=0.8.0-dev target.platforms=esp32p4 id=tactility.espnowbridge -version.name=0.14.0 -version.code=14 +version.name=0.15.0 +version.code=15 name=ESP-NOW Bridge description=Companion app for updating P4 device C6 co-processor firmware to enable ESP-NOW bridge support. app.0.id=tactility.espnowbridge diff --git a/Apps/GPIO/manifest.properties b/Apps/GPIO/manifest.properties index 61e2212..2c95c13 100644 --- a/Apps/GPIO/manifest.properties +++ b/Apps/GPIO/manifest.properties @@ -2,8 +2,8 @@ manifest.version=0.3 target.sdk=0.8.0-dev target.platforms=esp32,esp32s3,esp32c6,esp32p4 id=tactility.gpio -version.name=0.20.0 -version.code=20 +version.name=0.21.0 +version.code=21 name=GPIO description=Shows the live state of all GPIO pins app.0.id=tactility.gpio diff --git a/Apps/GraphicsDemo/manifest.properties b/Apps/GraphicsDemo/manifest.properties index c951512..7bb38f9 100644 --- a/Apps/GraphicsDemo/manifest.properties +++ b/Apps/GraphicsDemo/manifest.properties @@ -2,8 +2,8 @@ manifest.version=0.3 target.sdk=0.8.0-dev target.platforms=esp32,esp32s3,esp32c6,esp32p4 id=tactility.graphicsdemo -version.name=0.17.0 -version.code=17 +version.name=0.18.0 +version.code=18 name=Graphics Demo app.0.id=tactility.graphicsdemo app.0.name=Graphics Demo diff --git a/Apps/HelloWorld/manifest.properties b/Apps/HelloWorld/manifest.properties index 993c05d..549584f 100644 --- a/Apps/HelloWorld/manifest.properties +++ b/Apps/HelloWorld/manifest.properties @@ -1,9 +1,9 @@ manifest.version=0.3 target.sdk=0.8.0-dev -target.platforms=esp32,esp32s3,esp32c6,esp32p4,posix-x86_64 +target.platforms=all id=tactility.helloworld -version.name=0.17.0 -version.code=17 +version.name=0.18.0 +version.code=18 name=Hello World app.0.id=tactility.helloworld app.0.name=Hello World diff --git a/Apps/M5UnitTest/manifest.properties b/Apps/M5UnitTest/manifest.properties index 58e9f5f..bbe7d4b 100644 --- a/Apps/M5UnitTest/manifest.properties +++ b/Apps/M5UnitTest/manifest.properties @@ -2,8 +2,8 @@ manifest.version=0.3 target.sdk=0.8.0-dev target.platforms=esp32s3,esp32p4 id=tactility.m5unittest -version.name=0.15.0 -version.code=15 +version.name=0.16.0 +version.code=16 name=M5 Unit Test app.0.id=tactility.m5unittest app.0.name=M5 Unit Test diff --git a/Apps/Magic8Ball/manifest.properties b/Apps/Magic8Ball/manifest.properties index 71187d0..3d66ec3 100644 --- a/Apps/Magic8Ball/manifest.properties +++ b/Apps/Magic8Ball/manifest.properties @@ -2,8 +2,8 @@ manifest.version=0.3 target.sdk=0.8.0-dev target.platforms=esp32,esp32s3,esp32c6,esp32p4 id=tactility.magic8ball -version.name=0.16.0 -version.code=16 +version.name=0.17.0 +version.code=17 name=Magic 8-Ball app.0.id=tactility.magic8ball app.0.name=Magic 8-Ball diff --git a/Apps/MediaKeys/manifest.properties b/Apps/MediaKeys/manifest.properties index 5797ec8..3030e08 100644 --- a/Apps/MediaKeys/manifest.properties +++ b/Apps/MediaKeys/manifest.properties @@ -2,8 +2,8 @@ manifest.version=0.3 target.sdk=0.8.0-dev target.platforms=esp32s3,esp32p4 id=tactility.mediakeys -version.name=0.19.0 -version.code=19 +version.name=0.20.0 +version.code=20 name=Media Keys description=Bluetooth media keys. Touch or Physical Keyboard control\nB - previous, P - play/pause, N - next, M - mute, D - volume down, U - volume up.\nQ or ESC to exit focus. app.0.id=tactility.mediakeys diff --git a/Apps/MidiController/CMakeLists.txt b/Apps/MidiController/CMakeLists.txt new file mode 100644 index 0000000..c90c5ec --- /dev/null +++ b/Apps/MidiController/CMakeLists.txt @@ -0,0 +1,13 @@ +# tactility-cmakelists-version: 1 +cmake_minimum_required(VERSION 3.20) + +if (NOT DEFINED ENV{TACTILITY_SDK_PATH}) + message(FATAL_ERROR "TACTILITY_SDK_PATH environment variable is not set") +endif() + +get_filename_component(TACTILITY_SDK_PATH "$ENV{TACTILITY_SDK_PATH}" ABSOLUTE) +include("${TACTILITY_SDK_PATH}/TactilitySDK.cmake") + +tactility_project_pre(midicontroller) +project(midicontroller) +tactility_project_post(midicontroller) diff --git a/Apps/MidiController/docs/MIDI-Control-Change-Messages_Another-Producer.pdf b/Apps/MidiController/docs/MIDI-Control-Change-Messages_Another-Producer.pdf new file mode 100644 index 0000000000000000000000000000000000000000..d113dfce7fc35abccf86f5f94cb716e3c799a0af GIT binary patch literal 182253 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

$vs?R^n;q6p&zT1zzWd(QwfOZ-PJaI8 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

#x-VVTV=yLy1*!7VdO8mxu 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 Ab5q*KXfl@NRQWtCHRECi9R}X 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�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 — create new LVGL tree + 11. lv_indev_wait_release() — discard any touch still in-flight + +teardown() (after the window is removed) + └─ encoderStop() — stop poll timer, LEDs off, lcdStop() + └─ delete uiTimer; stopAllModeTimers() + └─ stop/reset the Sequencer + └─ stop BLE/USB, unsubscribe BT events, release device references + └─ resetAllState() — reset all state structs and UI pointers +``` + +### Why `lv_indev_wait_release` is called twice + +A tap on a home tile fires `switchToMode`. The finger-up event from that same tap can land on a button in the freshly built view if both share the same screen coordinates. The first `wait_release` runs before view construction but LVGL may still queue the event. A second call after construction ensures it is discarded before any button in the new view can process it. + +--- + +## State management + +Each mode has its own state struct nested inside `MidiController` (e.g. `RagaState raga_`). Data fields (bpm, notes, parameters) **persist** across mode switches; LVGL widget pointers are **nulled** before the old tree is destroyed and **re-populated** when the new view is built. + +`resetAllState()` (called from `teardown()`) resets every per-mode state struct and its UI pointers to defaults. + +--- + +## MIDI transport + +MIDI is sent over either Bluetooth LE or USB. On launch the app shows a transport chooser (unless BLE MIDI is already running). The toolbar `USB`/`BLE` button reopens the chooser while sending is stopped. The choice is stored in `activeTransport_` for the session. + +The toolbar switch calls `handleTransportToggle(true/false)`, which starts or stops the selected transport. + +**BLE:** uses Tactility's `bluetooth_midi` driver. +1. If the BT radio is off, `bluetooth_set_radio_enabled(true)` is called and `startBleMidi()` runs from the `BT_RADIO_STATE_ON` event callback. +2. `bluetooth_midi_start()` enables the MIDI profile; the device advertises as **TT MIDI**. +3. `sendMidi(cmd, d1, d2)` calls `bluetooth_midi_send()`. + +**USB:** `startUsbMidi()` calls `usb_midi_device_start()` and `sendMidi()` calls `usb_midi_device_send()`. + +On stop or hide, `CC 123` (All Notes Off) is sent before the transport is stopped. Every `*_get()` helper returns a referenced `Device`, so each caller pairs it with `device_put()`. + +--- + +## LVGL patterns used + +### Canvas-style drawing +ZEN, DROP, LFO, and XY draw into a plain container via `LV_EVENT_DRAW_MAIN`; TB3PO and EUCLID build child widgets (labels, lines, rings) inside their container and rebuild them when the pattern changes. No `lv_canvas` buffer is allocated — the container is freed with the view tree. + +### Touch handling +KEYS uses `LV_EVENT_PRESSING` on the piano row plus per-key `PRESSED`/`RELEASED` handlers so a finger can slide across keys. XY uses `LV_EVENT_PRESSING` on the pad and derives CC values from finger X/Y. GRID uses per-cell `PRESSED`/`RELEASED` handlers with one held note per pointer slot. + +### Encoder poll timer +`EncoderInput.cpp` runs a single 100 ms LVGL timer. On each tick: +1. Check `btnSeedNext` — if set, read buttons silently to seed `btnPrev` and return. +2. Route to the per-mode dispatch function based on `currentMode_`. +3. Dispatch reads deltas + buttons, updates state, refreshes LVGL labels, flushes LEDs, calls `lcdUpdate`. + +### LED dirty-caching +`Unit8Encoder::flushLeds` maintains a 9-entry colour cache (8 encoder LEDs + 1 switch LED). It sends the 24-byte encoder-LED burst only if one of those eight changed, and writes the switch LED separately only if that entry changed. + +--- + +## MIDI messages sent + +| Command | Hex | Used for | +|---------|-----|----------| +| Note On | `0x90` | All melodic/drum note triggers | +| Note Off | `0x80` | All note releases | +| Control Change | `0xB0` | XY pad (CC 74/71 default), LFO modulation, All Notes Off (CC 123) | +| Pitch Bend | `0xE0` | LFO in pitch-bend mode | + +All messages are sent on channel 1 (command bytes `0x90`/`0x80`/`0xB0`/`0xE0` without channel offset). diff --git a/Apps/MidiController/docs/encoder.md b/Apps/MidiController/docs/encoder.md new file mode 100644 index 0000000..894bfb5 --- /dev/null +++ b/Apps/MidiController/docs/encoder.md @@ -0,0 +1,158 @@ +# 8Encoder + +The M5Stack 8Encoder V2 unit normally connects via **PaHub CH4**, though the app probes every PaHub channel and accepts it on any port. It provides 8 rotary encoders with push-buttons, individually addressable RGB LEDs, and one toggle switch with its own LED. + +The encoder is completely optional. If the PaHub or the encoder unit is not detected, the app operates in touch-only mode with no degradation. + +--- + +## Per-mode mappings + +Only five modes use the encoder. In all other modes the encoder is idle (LEDs off). + +### RAGA + +| Control | Action | +|---------|--------| +| Knob 0 | Raga selection (0–7, wraps) | +| Knob 1 | Tala selection (0–2, wraps) | +| Knob 2 | Root note (MIDI 24–96) | +| Knob 3 | BPM (40–240) | +| Knobs 4–7 | Unused | +| Button 0 | Play / Stop toggle | +| Button 1 | Drone On / Off toggle | +| Buttons 2–7 | Unused | + +**LED colours:** + +| Knob | Colour | Meaning | +|------|--------|---------| +| 0 | Orange `0xFF8800` | Raga | +| 1 | Deep orange `0xFF6600` | Tala | +| 2 | Cyan `0x00FFCC` | Root | +| 3 | Yellow `0xFFE000` | BPM | +| 4–7 | Off | — | + +--- + +### DIMENSIONS + +| Control | Action | +|---------|--------| +| Knob 0 | Parameter A (0–100) | +| Knob 1 | Parameter B (0–100) | +| Knob 2 | Parameter C (0–100) | +| Knob 3 | Parameter D (0–100) | +| Knob 4 | Equation (1–20) | +| Knob 5 | BPM (40–280) | +| Knob 6 | Octave offset (−4 to +4) | +| Knob 7 | Random amount (0–64) | +| Button 0 | Play / Stop toggle | +| Button 1 | Reset (A/B/C/D → 50, t → 0) | +| Buttons 2–7 | Unused | + +**LED colours** (match the UI label colours): + +| Knob | Colour | Parameter | +|------|--------|-----------| +| 0 | `0x88DDFF` (light blue) | A | +| 1 | `0x88FFCC` (mint) | B | +| 2 | `0xFFCC88` (peach) | C | +| 3 | `0xFF88CC` (pink) | D | +| 4 | `0xFF8844` (orange) | Equation | +| 5 | `0xFFE000` (yellow) | BPM | +| 6 | `0xAAFF88` (lime) | Octave | +| 7 | `0xFFAA44` (amber) | Random | + +--- + +### TB3PO + +| Control | Action | +|---------|--------| +| Knob 0 | Density (0–16) | +| Knob 1 | BPM (40–280) | +| Knob 2 | Key root (0–11, wraps) | +| Knob 3 | Octave (1–6) | +| Knob 4 | Scale selection (6 options, wraps) | +| Knob 5 | Regen (any delta draws a new random seed) | +| Knobs 6–7 | Unused | +| Button 0 | Play / Stop toggle | +| Button 1 | Regen (new random seed and pattern) | +| Buttons 2–7 | Unused | + +**LED colours:** While playing, animate with the current 32-step playback position. The steps are grouped into eight 4-step ranges: knob 0 = steps 1–4, knob 1 = steps 5–8, … knob 7 = steps 29–32. The knob for the active range is lit orange `0xFF6600`; the other seven are dark `0x221100`. All eight are dark while stopped. + +--- + +### BABY8 + +| Control | Action | +|---------|--------| +| Knob 0 | Step 1 note (0–127) | +| Knob 1 | Step 2 note | +| Knob 2 | Step 3 note | +| Knob 3 | Step 4 note | +| Knob 4 | Step 5 note | +| Knob 5 | Step 6 note | +| Knob 6 | Step 7 note | +| Knob 7 | Step 8 note | +| Button 0 | Toggle gate for step 1 | +| Button 1 | Toggle gate for step 2 | +| … | … | +| Button 7 | Toggle gate for step 8 | + +**LED colours:** +- Current playback step (while playing): white `0xFFFFFF` +- Gate on (not current): dark green `0x004400` +- Gate off: dark red `0x440000` + +--- + +### EUCLID + +| Control | Action | +|---------|--------| +| Knob 0 | Selected voice — Steps (1–32) | +| Knob 1 | Selected voice — Events (0–steps) | +| Knob 2 | Selected voice — Rotation (0–steps-1, wraps) | +| Knob 3 | Selected voice — Note (0–127) | +| Knob 4 | BPM (40–280) | +| Knob 5 | Voice 6 — Steps | +| Knob 6 | Voice 7 — Steps | +| Knob 7 | Voice 8 — Steps | +| Button 0 | Select voice 1 | +| Button 1 | Select voice 2 | +| … | … | +| Button 7 | Select voice 8 | + +**LED colours** (per-voice accent colours): + +| Knob/Button | Colour | Voice | +|-------------|--------|-------| +| 0 | `0xFF4040` Red | Voice 1 | +| 1 | `0xFF9900` Orange | Voice 2 | +| 2 | `0x44DD44` Green | Voice 3 | +| 3 | `0x44BBFF` Blue | Voice 4 | +| 4 | `0xFF44FF` Magenta | Voice 5 | +| 5 | `0xFFDD00` Yellow | Voice 6 | +| 6 | `0x00FFCC` Cyan | Voice 7 | +| 7 | `0xFF8888` Pink | Voice 8 | + +Selected voice LED is at full brightness; all others are dimmed to ¼ intensity (`>> 2` each channel). + +--- + +## Button edge detection and mode-switch safety + +Buttons use a rising-edge detector: `if (btn && !btnPrev[i])`. This means holding a button across a mode switch will not register as a press in the new mode. + +On each mode switch `btnSeedNext` is set. The very next encoder poll reads the live button state silently into `btnPrev` without firing any actions, then returns. This seeds the edge detector correctly regardless of which buttons are physically held. + +--- + +## Poll rate + +The encoder poll timer fires every **100 ms** (10 Hz). This is a balance between responsiveness and I2C bus traffic: each poll requires up to 16 individual I2C reads (8 deltas + 8 buttons) across the PaHub multiplexer. + +Delta values accumulate in the encoder hardware between polls, so fast knob turns are not lost — the delta register counts all movement since the last read. diff --git a/Apps/MidiController/docs/hardware.md b/Apps/MidiController/docs/hardware.md new file mode 100644 index 0000000..25fa0701 --- /dev/null +++ b/Apps/MidiController/docs/hardware.md @@ -0,0 +1,125 @@ +# Hardware + +## Target device — M5Stack Tab5 + +| Property | Value | +|----------|-------| +| SoC | ESP32-P4 | +| Display | 1280×720 MIPI DSI (built-in) | +| OS | Tactility RTOS / FreeRTOS | +| Port A | Grove port in I2C mode — SDA=53, SCL=54 | + +All external hardware hangs off **Port A** via the PaHub multiplexer. + +--- + +## Wiring + +``` +Tab5 Port A + └─ PaHub (TCA9548A, 0x70) + ├─ CH4 ── 8Encoder Unit (0x41) + └─ CH5 ── LCD Unit (0x3E) +``` + +The app probes all six PaHub channels and uses the first matching unit it finds, so CH4/CH5 are the usual wiring rather than a hard requirement. + +Both units are **optional**. If the PaHub is not detected the app operates in touch-only mode. +If the PaHub is present but a unit is missing, that unit is silently disabled. + +--- + +## PaHub channel map + +| Channel | Unit | I2C address | +|---------|------|-------------| +| CH4 | 8Encoder V2 (typical) | 0x41 | +| CH5 | LCD Unit (135×240, typical) | 0x3E | +| CH0–3 | unused / available | — | + +The app scans CH0–CH5 at startup, so either unit can be moved to another free channel. + +--- + +## I2C unit summary + +### 8Encoder V2 (0x41) +8 rotary encoders with push-buttons, individually addressable RGB LEDs (3-byte RGB), and one toggle switch with its own LED. + +Key registers: + +| Register | Description | +|----------|-------------| +| `0x20 + i*4` | Counter delta since last read (int32_t, 4 pulses/detent) | +| `0x50 + i` | Button state (0 = released, non-zero = pressed) | +| `0x60` | Toggle-switch state (0 = off, 1 = on) | +| `0x70 + i*3` | Encoder LED RGB (3-byte stride, `i` = 0–7) | +| `0x88` | Toggle-switch LED RGB (3 bytes) | + +### PaHub v2 (0x70 — TCA9548A) +TCA9548A-based I2C multiplexer with **6 exposed channels (CH0–CH5)**; the chip itself supports 8, but only six ports are wired on the unit. Write a single bitmask byte to select a channel; write `0x00` to deselect all. + +``` +select(ch) → write(1 << ch) +deselect() → write(0x00) +``` + +### LCD Unit (0x3E — ESP32-PICO inside) +135×240 IPS display (portrait default). The internal ESP32 bridges I2C commands directly to an ST7789V2. +Unlike the STM32-based units it uses a **command-buffer protocol** — no STOP+delay pattern needed. + +Key commands: + +| Command | Description | +|---------|-------------| +| `0x22 [brightness]` | Set backlight (0=off, 255=max) | +| `0x36 [rotation]` | Set rotation (0–3 = 0°/90°/180°/270°) | +| `0x6A [x0][y0][x1][y1][hi][lo]` | Fill rectangle with inline RGB565 colour | +| `0x62 [x][y][hi][lo]` | Draw single pixel RGB565 | +| `0x2A [x0][x1]` / `0x2B [y0][y1]` | Set active window (column / row range) | +| `0x42 [pixels…]` | Write raw RGB565 stream into active window | +| `0x09` | Read buffer-remaining count (1-byte response) | + +Used in portrait orientation (rotation 0): **135 px wide × 240 px tall**. + +### ByteButton (0x47) — available, not wired +8 push-buttons with 4-byte LE RGB888 LEDs per button. + +| Register | Description | +|----------|-------------| +| `0x00` | Button bitmask (bit i = button i) | +| `0x60 + i` | Individual button state | +| `0x20 + i*4` | LED colour [B, G, R, 0x00] | +| `0x19 = 0x00` | Show-mode register (must be written on init) | + +### Joystick2 (0x63) — available, not wired +Dual-axis thumbstick with button and RGB LED. + +| Register | Description | +|----------|-------------| +| `0x50` | XY 12-bit offset-corrected (2×int16) | +| `0x20` | Button (0 = pressed) | +| `0x30` | LED LE uint32 RGB | + +### Scroll (0x40) — available, not wired +Single rotary encoder with button and RGB LED. + +| Register | Description | +|----------|-------------| +| `0x50` | Delta since last read (int16_t, resets on read) | +| `0x20` | Button (0 = pressed) | +| `0x30` | LED [0x00, R, G, B] | +| `0x40` | Reset encoder counter (write 1) | + +--- + +## I2C protocol notes + +**STM32-based units** (ByteButton, Joystick2, Scroll, 8Encoder): +- Use a **write-then-read** pattern with a mandatory 2 ms delay between the address write (STOP) and the data read. +- A repeated-START without the STOP causes the STM32 HAL to return stale data. +- Helper: `unitReadReg()` in `UnitCommon.h` handles this automatically. + +**ESP32-based units** (LCD Unit): +- Standard I2C — no delay needed. Each write is a self-contained command. +- Pixel streaming ends at the I2C STOP condition. diff --git a/Apps/MidiController/docs/lcd.md b/Apps/MidiController/docs/lcd.md new file mode 100644 index 0000000..c0a4ae1 --- /dev/null +++ b/Apps/MidiController/docs/lcd.md @@ -0,0 +1,162 @@ +# LCD Unit Display + +The M5Stack LCD Unit (135×240 IPS, portrait) normally connects via **PaHub CH5**, though the app probes all six PaHub channels and accepts it on any port. It shows a live secondary display that mirrors and extends the current mode's state. + +The LCD is completely optional. If the PaHub or the LCD unit is not detected, `lcdUpdate` is a no-op throughout. + +--- + +## General layout + +The display is **135 px wide × 240 px tall** in portrait orientation (rotation 0). + +Every layout starts with a **16 px coloured header bar** (plus a 1 px separator) at the top. For the five encoder modes it shows the mode name in 2× scale text, using that mode's accent colour; other modes show a generic `MIDI` header. + +A **5×7 bitmap font** (printable ASCII 32–126) is used for all text rendering. Two scale factors are used: +- **1× scale** — 5×7 px glyphs (very compact, for labels and values) +- **2× scale** — 10×14 px glyphs (for names and primary values) + +Colours are RGB565. The background is always black (`0x0000`). + +--- + +## Per-mode displays + +### HOME / Other non-encoder modes + +``` +┌─────────────────┐ +│ MIDI │ ← 2× text on grey background (16 px) +│ │ +│ │ +│ (blank) │ +└─────────────────┘ +``` + +Shows the generic `MIDI` header. The content area is blank (these modes don't use the encoder or LCD detail view). + +--- + +### RAGA + +``` +┌─────────────────┐ y=0 +│ RAGA │ header (16 px, orange) +│ │ +│ Bhairavi │ ← raga name, 2× orange (y=22) +│ │ +│ TALA Teental │ y=68 +│ ROOT C3 120 │ y=82 (root + BPM on same row) +├─────────────────┤ y=98 +│ PLAYING │ ← red/dark 28 px bar (y=100) +│ DRONE ON │ ← blue/dark 20 px bar (y=132) +│ │ +│ K1 RAGA │ ← knob legend (1× text, y=170) +│ K2 TALA │ +│ K3 ROOT │ +│ K4 BPM │ +│ B1 PLAY │ +│ B2 DRON │ +└─────────────────┘ +``` + +**Dirty fields:** Raga name, tala, root note, BPM, play state, drone state — each redraws only when its value changes. + +--- + +### DIMENSIONS + +``` +┌─────────────────┐ +│ DIM │ header (cyan) +│ A 50 B 50 │ ← labels + values, y=20 +│ [████] [████] │ ← filled colour bars, y=30–38 +│ C 50 D 50 │ ← y=50 +│ [████] [████] │ ← bars, y=60–68 +│ EQ 3 BPM 120│ ← y=82 +│ OCT +0 RND 0 │ ← y=96 +├─────────────────┤ y=110 +│ STOPPED │ ← green=playing, dark=stopped (y=112) +└─────────────────┘ +``` + +A/B share the top row (x=2 and x=67); C/D share the next row. Each has a colour bar showing the 0–100 value as a filled rectangle. + +**Bar colours** match the encoder LED and UI label colours: A=`#88DDFF`, B=`#88FFCC`, C=`#FFCC88`, D=`#FF88CC`. + +--- + +### TB3PO + +``` +┌─────────────────┐ +│ TB-3PO │ header (green) +│ ┌──┬──┬──┬──┐ │ ← 32-step piano roll (2 rows of 16) +│ │ │██│ │ │… │ each step: 6×12 px cell +│ └──┴──┴──┴──┘ │ white=current (while playing), green=gate on, dark=no gate +│ │ yellow accent dot = accent step +│ KEY C SCALE Major│ +│ DEN 8 BPM 120 │ +├─────────────────┤ +│ STOPPED │ +└─────────────────┘ +``` + +The piano roll starts at y=20, with two rows on a 15 px pitch (cells are 6×12 px). Row 0 = steps 0–15, row 1 = steps 16–31. Each cell has a 2 px gap between neighbours. Accent steps show a small yellow dot at the top-left corner. + +--- + +### BABY8 + +``` +┌─────────────────┐ +│ BABY8 │ header (magenta) +├─────────────────┤ +│ 1 C4 [■] │ ← step row 27 px tall; note name 2×, gate dot +│ 2 D4 [■] │ white bg = current step (gate on) +│ 3 E4 [ ] │ white bg + dark dot = current step (gate off) +│ 4 F4 [■] │ +│ 5 G4 [■] │ +│ 6 A4 [■] │ +│ 7 B4 [■] │ +│ 8 C5 [■] │ +└─────────────────┘ +``` + +Each step gets 25 px of height (27 px total with a 2 px separator line). The step number is on the left, note name (2×) in the centre-left, and a filled gate dot on the right. While playing, the current step has a white background with black text; when its gate is off the gate dot is dark instead of green. No step is highlighted while stopped. + +--- + +### EUCLID + +``` +┌─────────────────┐ +│ EUCLID │ header (red) +│ [1][2][3][4][5][6][7][8] ← voice tabs y=18–33, 15×15 px +│ VOICE 3 │ ← selected voice name, 2× in voice colour (y=38) +│ │ +│ STEPS 8 │ ← y=80 +│ EVENTS 5 │ ← y=94 +│ ROT 2 │ ← y=108 +│ NOTE C2 │ ← y=122 +│ BPM 120 │ ← y=140 +├─────────────────┤ y=154 +│ STOPPED │ ← y=156–174 +│ K1 STEPS │ ← knob legend y=176, 10 px rows +│ K2 EVENTS │ +│ K3 ROT │ +│ K4 NOTE │ +│ K5 BPM │ +│ B VOICE │ +└─────────────────┘ +``` + +Voice tabs start at y=18 with a 17 px pitch. The selected voice tab is drawn at full colour; others are dark grey. When the voice changes, the name, all parameter values, and the knob legend are all redrawn. + +--- + +## Dirty-caching + +`lcdUpdate` is called from the 100 ms encoder poll for the five encoder modes, and immediately on mode switch. Each mode maintains a cache of the last-drawn values. Only fields that have changed are redrawn, keeping bus traffic low. + +On a mode switch `lcdSetMode` clears the per-mode cache structs, seeds their change sentinels, and sets `s_dirty = true`, forcing a full screen redraw on the next `lcdUpdate` call. diff --git a/Apps/MidiController/docs/modes.md b/Apps/MidiController/docs/modes.md new file mode 100644 index 0000000..30e936d --- /dev/null +++ b/Apps/MidiController/docs/modes.md @@ -0,0 +1,300 @@ +# Modes + +The home screen shows a 3×5 grid of tiles. Tapping any tile switches to that mode. +Tap the **←** back button (top-left of most views) to return home. + +--- + +## KEYS — Chromatic Piano + +Single octave of piano keys. Drag your finger across keys for continuous glide. Multiple fingers play independently; each finger tracks its own note. + +**Parameters:** +- Octave (1–8) — base octave of the keyboard +- Scale / key root — shown in the status and control rows only; the keyboard stays fully chromatic and is not transposed + +**Controls:** OCT− / OCT+ buttons; SCALE cycle; KEY− / KEY+ + +**MIDI:** Note On on finger contact, Note Off on release. Each touch slot holds and slides its own note; lifting one finger does not release the others. + +**Scale/key controls** use the same scale table as RNG, ZEN, and DROP, but KEYS keeps its own selection and does not apply it to the keyboard. CHORD uses the same scale table with its key fixed at C — see [scales.md](scales.md). + +--- + +## BEATS — 4-Track Drum Sequencer + +Classic 4×16 step sequencer. Steps loop continuously while playing. + +**Tracks:** +| Track | Default MIDI note | Colour | +|-------|------------------|--------| +| KICK | 36 (C2) | Red | +| SNARE | 38 (D2) | Yellow | +| HIHAT | 42 (F#2) | Cyan | +| OPEN | 46 (A#2) | Purple | + +**Controls:** Tap any step cell to toggle it. PLAY/STOP, CLEAR (stops playback and turns all steps off), BPM− / BPM+. + +**Note duration:** Each track has a fixed note-off delay (KICK 200 ms, SNARE 150 ms, HIHAT 50 ms, OPEN 300 ms). + +--- + +## ZEN — Ambient Physics + +Up to 4 balls bounce around a rectangular arena. Each wall segment triggers a scale note on collision. + +**Wall layout:** 8 top segments, 8 bottom segments, 4 left segments, 4 right segments (24 total). Each segment maps to a scale degree; walls flash white briefly on hit. + +**Controls:** ADD (spawn ball), RESET (remove all but one), SCALE cycle, KEY± , OCT. + +**Parameters:** Scale, key, octave (2–6), ball count (1–4). + +**MIDI:** Random velocity (70–109). Note Off follows 150 ms after each Note On; walls flash for 200 ms on impact. + +--- + +## DROP — Gravity Platform Sequencer + +Balls roll and fall under gravity, triggering notes when they land on platforms. + +Two modes — **EDIT** (tap to place horizontal platforms) and **DROP** (tap to spawn balls). + +**Controls:** Toggle EDIT/DROP, CLEAR (removes balls and platforms), scale/key/octave. + +**Parameters:** Scale, key, octave (2–7), up to 6 platform positions. Platforms are always horizontal and receive a note automatically from the selected scale by platform index (degrees 0, 2, 4, 6, 1, 3). + +--- + +## RNG — Random Melody Generator + +Generates random melodies constrained to a scale, key, octave range, and probability. + +**Parameters:** +| Parameter | Range | Default | +|-----------|-------|---------| +| BPM | 40–300 | 120 | +| Subdivision | ¼ / ⅛ / 1/16 | ¼ | +| Min octave | 1–8 (≤ max) | 3 | +| Max octave | 1–8 (≥ min) | 6 | +| Probability | 0–100 % | 50 % | +| Scale | 6 options | Major | +| Key | C–B | C | + +**Controls:** PLAY/STOP, BPM± (step 5), KEY±, SCALE cycle, PROB± (step 5), MinOct±, MaxOct±, Subdivision cycle. + +On each tick, a note is chosen randomly from `(scale, key, octave range)` with the given probability. It plays for 75 % of the step duration, then Note Off. + +--- + +## XY — 2D CC Pad + +Full-screen touch pad. Finger X maps to one CC, finger Y maps to another. + +**Parameters:** +| Axis | Default CC | Suggested use | +|------|-----------|---------------| +| X | 74 | Filter cutoff | +| Y | 71 | Resonance | + +**Controls:** X CC± / Y CC± buttons to change target CC numbers (0–127). + +MIDI CC is sent continuously while the finger is held. Values range 0–127 across the full pad width/height. + +--- + +## ARP — Chord Arpeggiator + +Touch a note on the mini piano to latch an arpeggio of the selected chord type at the selected pattern and speed. + +**Chord types:** Major, Minor, Dominant 7th. + +**Patterns:** UP, DOWN, UP-DOWN, RANDOM, CHANCE (probabilistic skips). + +**Speed:** Quarter / Eighth / Sixteenth / 32nd note. + +**Parameters:** BPM, arpeggio pattern, chord type, octave range (1–4 octaves), piano octave (1–7). + +**Controls:** Tap piano key (bottom row) to start arp. Pattern/Chord/OctRange/Speed/BPM controls at top. + +--- + +## GRID — Isomorphic Grid + +8 columns × 5 rows. Each row is offset by a perfect 4th (5 semitones) from the row below — the same layout as a Linnstrument. Every cell adds one semitone across the row. + +**Note layout:** +``` +Row 4 (top): root + 20 … 27 semitones +Row 3: root + 15 … 22 +Row 2: root + 10 … 17 +Row 1: root + 5 … 12 +Row 0 (bot): root + 0 … 7 (chromatic: C, C#, D, D#, E, F, F#, G at octave 3) +``` + +**Cell colours:** C notes bright cyan; black-key notes dark blue; white-key notes medium blue; pressed cells bright cyan. + +**Controls:** OCT− / OCT+ (octave 1–6). + +Cells show the note name. Touch for Note On, release for Note Off. Multi-touch is supported: each pointer slot tracks its own held cell, so lifting one finger does not release notes held by the others. + +--- + +## CHORD — Diatonic Chord Player + +8 large buttons play chords built on the selected scale, with the root fixed at C. Chord VIII is chord I one octave up. + +**Chord voicings:** MAJ (root+M3+P5), MIN (root+m3+P5), DIM (root+m3+dim5). The chord qualities follow the major-scale degree pattern: I/IV/V major, ii/iii/vi minor, vii diminished, I+ major. The selected scale supplies the chord roots. + +**Controls:** Press a chord button to play; release to stop. OCT± / SCALE cycle / CLEAR. + +**Parameters:** Octave (1–7), scale. The key is fixed at C. + +--- + +## LFO — Low-Frequency Oscillator + +Continuously modulates a MIDI CC (or pitch bend) with a selectable waveform. + +**Waveforms:** Sine, Triangle, Square, Sawtooth. + +**Parameters:** +| Parameter | Range | Default | +|-----------|-------|---------| +| Rate | 0.1–10 Hz | 1 Hz | +| Amount | 0–127 | 64 | +| CC target | 0–127 | 1 (modwheel) | +| Mode | CC / Pitch | CC | + +**Controls:** START/STOP, Rate±, WAVE cycle, Amount±, CC±, PITCH mode toggle. + +In **Pitch** mode, a 14-bit pitch-bend message centred at 8192 is used instead of a CC. The actual semitone range depends on the receiver's pitch-bend setting (commonly ±2 semitones). The waveform is drawn live on the canvas. + +--- + +## RAGA — Indian Classical Generator + +Generates melodic phrases based on 8 classical Indian ragas with tala (rhythmic cycle) structure and an optional drone note. + +**Ragas:** +| # | Name | Character | +|---|------|-----------| +| 0 | Bhairavi | Morning, devotional | +| 1 | Lalit | Pre-dawn | +| 2 | Bhupali | Evening, peaceful | +| 3 | Todi | Morning, intense | +| 4 | Madhuvanti | Afternoon | +| 5 | Meghmalhar | Monsoon | +| 6 | Yaman | Evening, romantic | +| 7 | Malkauns | Night, serious | + +**Talas:** +| # | Name | Beats | +|---|------|-------| +| 0 | Teental | 16 | +| 1 | Rupak | 7 | +| 2 | Jhaptal | 10 | + +**Parameters:** Raga (0–7), tala (0–2), root note (MIDI 24–96), BPM (40–240). + +**Controls:** RAGA±, TALA±, ROOT±, BPM±, PLAY/STOP, DRONE toggle. + +**How it works:** Bhairavi phrases use a 2nd-order Markov chain; the other seven ragas use a degree random walk. A new pitch is emitted every 16th note (BPM/4), while the selected tala supplies the beat-accent cycle. The drone note (root) sustains independently of the melody. + +**Encoder:** Knob 0=raga, 1=tala, 2=root, 3=BPM; Button 0=play/stop, Button 1=drone. + +--- + +## DIMENSIONS — Parametric Equation Sequencer + +20 parametric equations map 4 parameters (A, B, C, D) and a running time variable (t) to a 3D position (px, py, pz). The position is quantised to a MIDI note each tick. + +**Parameters:** +| Param | Range | Description | +|-------|-------|-------------| +| Equation | 1–20 | Selects the formula | +| A, B, C, D | 0–100 | Equation coefficients | +| Octave | −4 to +4 | Note range offset | +| Random | 0–64 | Adds stochastic perturbation | +| BPM | 40–280 | Tick rate | + +**Controls:** Equation±, PLAY/STOP, RESET (resets A/B/C/D to 50 and t to 0), BPM±, OCT±, Rnd±. + +**Equation variety:** Includes Lissajous figures, chaotic attractors, Fourier synthesis, and other 3D parametric forms. Each produces a different melodic contour as t advances. + +**Encoder:** Knobs 0–3 = A/B/C/D; Knob 4 = equation; Knob 5 = BPM; Knob 6 = octave; Knob 7 = random; Button 0 = play/stop; Button 1 = reset. + +--- + +## BABY8 — 8-Step Melodic Sequencer + +Classic Baby-8 / analogue-sequencer style. 8 steps, each with a note, velocity, and gate. Up to 8 patterns. + +**Step display:** +| State | Colour | +|-------|--------| +| Current playing step, gate on | White | +| Current playing step, gate off | Blue | +| Selected (not playing), gate on | Orange | +| Selected (not playing), gate off | Brown | +| Other step, gate on | Dark green | +| Other step, gate off | Dark grey | + +**Parameters per step:** Note (0–127), velocity (0–127), gate (on/off). + +**Global parameters:** BPM (40–280), swing (50–80 %), active pattern (1–8, stored as index 0–7). + +**Controls:** PLAY/STOP, BPM±, SWING±, tap step to select, NOTE±, VEL±, GATE toggle, PATTERN±. + +**Swing:** Lengthens even-numbered steps and shortens odd-numbered steps by the same amount, so the pair still fills two 16th notes and the tempo stays constant. At 50 % there is no swing; at 80 % the groove is heavy. + +**Encoder:** Knobs 0–7 = step note (by index); Buttons 0–7 = toggle step gate. + +--- + +## TB3PO — 303-Style Acid Bass + +Procedurally generated 32-step bass patterns in the spirit of the Roland TB-303. + +**Step properties:** Note degree, octave shift (up/down), accent (higher velocity), slide (legato transition into the next gated step), gate (active/silent). + +**Parameters:** +| Parameter | Range | Default | +|-----------|-------|---------| +| Density | 0–16 | 8 | +| BPM | 40–280 | 120 | +| Key | C–B | C | +| Octave | 1–6 | 3 | +| Scale | 6 options | Major | +| Seed | uint32 (randomized on REGEN) | 12345 | + +**Controls:** PLAY/STOP, REGEN (new random seed and pattern), Density±, BPM±, KEY±, SCALE cycle. Octave has no touch control; use encoder knob 3. + +**Pattern generation (REGEN):** An LCG PRNG seeded by the current seed value generates note degrees, gate probability (higher density = more gates), accents, and slides. REGEN and density changes draw a new random seed; key and scale changes reuse the current seed. The same seed and parameters always produce the same pattern. + +**Slide:** A slide flag holds the step's note past its nominal 90%-gate Note Off. If the next step is gated, that step's Note On is sent before the old note's Note Off, so a mono synth hears a legato transition. The old note is then released so a poly synth does not leave it hanging. If the next step is silent, the held note ends at the step boundary. + +**Encoder:** Knob 0=density, 1=BPM, 2=key, 3=octave, 4=scale, 5=regen (any delta triggers regen); Button 0=play/stop, Button 1=regen. + +--- + +## EUCLID — 8-Voice Euclidean Sequencer + +8 independent voices, each with a Euclidean rhythm pattern. All voices share a global BPM and step clock. The canvas shows 8 concentric rings (one per voice) with a rotating sweep line indicating the current position. + +**Euclidean patterns:** A bucket (Bresenham-style) accumulator distributes `events` triggers as evenly as possible across `steps` total slots. Before rotation the first trigger lands on step 0; rotation shifts the whole pattern. For example, E(3,8) with rotation 0 produces `[1,0,0,1,0,0,1,0]` (triggers on steps 0, 3, and 6). + +**Per-voice parameters:** +| Parameter | Range | Description | +|-----------|-------|-------------| +| Steps | 1–32 | Total pattern length | +| Events | 0–steps | Number of triggers | +| Rotation | 0–steps-1 | Pattern phase offset | +| Note | 0–127 | MIDI note triggered | + +**Global parameters:** BPM (40–280), triplet mode (2/3 timing). + +**Voice colours:** Each voice has a distinct colour both on the canvas ring and its tab button. + +**Controls:** PLAY/STOP, TRIPLET toggle, BPM±; per-voice: tap voice tab to select, Steps±, Events±, Rotation±, Note±. + +**Encoder:** Knob 0=sel.steps, 1=sel.events, 2=sel.rotation, 3=sel.note, 4=BPM; Knobs 5–7=steps for voices 6–8; Buttons 0–7 = select voice. diff --git a/Apps/MidiController/docs/scales.md b/Apps/MidiController/docs/scales.md new file mode 100644 index 0000000..854f650 --- /dev/null +++ b/Apps/MidiController/docs/scales.md @@ -0,0 +1,73 @@ +# Scales and Keys + +Defined in `MidiScales.h`. The shared scale/key helpers are used for note generation by RNG, ZEN, DROP, and CHORD (CHORD always uses a C root). KEYS keeps a scale/key selection for its status row but remains fully chromatic. TB3PO has its own internal six-scale table; RAGA uses raga-specific pitch sets. + +--- + +## Scales + +| Index | Name | Intervals (semitones from root) | Notes | +|-------|------|---------------------------------|-------| +| 0 | Major | 0 2 4 5 7 9 11 | 7 | +| 1 | Minor (natural) | 0 2 3 5 7 8 10 | 7 | +| 2 | Dorian | 0 2 3 5 7 9 10 | 7 | +| 3 | Pentatonic | 0 2 4 7 9 | 5 | +| 4 | Blues | 0 3 5 6 7 10 | 6 | +| 5 | Chromatic | 0 1 2 3 4 5 6 7 8 9 10 11 | 12 | + +--- + +## Keys + +12 root notes, index 0–11: + +`C C# D D# E F F# G G# A A# B` + +The key is a transpose offset applied on top of the scale intervals. Key 0 = C, Key 2 = D, etc. + +--- + +## Helper functions + +### `getNoteInScale(scaleIdx, degree, octave, keyTranspose)` +Returns the MIDI note number for a given scale degree, octave, and key. `degree` wraps around the scale length automatically. + +``` +getNoteInScale(0, 0, 4, 0) → 60 (C4, Major scale, degree 0) +getNoteInScale(0, 1, 4, 0) → 62 (D4, Major scale, degree 1) +getNoteInScale(3, 0, 4, 2) → 62 (D4, Pentatonic scale, root=D) +``` + +### `isNoteInScale(scaleIdx, keyTranspose, midiNote)` +Returns `true` if the note belongs to the given scale/key. Available for scale-aware highlighting; the current KEYS view is fully chromatic. + +### `isRootNote(keyTranspose, midiNote)` +Returns `true` if the note is the root (tonic) of the key. GRID is chromatic and highlights C notes directly. + +### `midiNoteName(note, buf, len)` +Formats a MIDI note as `"C#4"`, `"A#3"`, etc. All accidentals use sharps: C#, D#, F#, G#, A#. MIDI 60 is C4 (scientific pitch notation). + +### `midiNoteClass(note, buf, len)` +Returns only the pitch class without octave: `"C#"`, `"G"`, etc. + +--- + +## Scale usage by mode + +| Mode | Uses scale | Notes | +|------|-----------|-------| +| KEYS | Label only | Fully chromatic; scale/key shown in the status row but not applied to key colours or pitches | +| BEATS | No | Fixed drum notes | +| ZEN | Yes | Wall segments map to scale degrees | +| DROP | Yes | Platform note derived from scale | +| RNG | Yes | Random note drawn from scale | +| XY | No | Raw CC values | +| ARP | No | Chromatic piano plus fixed major/minor/7th chord intervals | +| GRID | No | Chromatic isomorphic layout; C notes highlighted | +| CHORD | Yes | Roots from scale with fixed C key; chord qualities follow the major-scale degree pattern | +| LFO | No | CC/pitch bend only | +| RAGA | Custom | Raga-specific pitch sets (not using scale index) | +| DIMENSIONS | No | Equation output mapped to MIDI range | +| BABY8 | No | Notes set freely per step | +| TB3PO | Yes | Own six-scale table; generated steps quantised to scale (its Chrome option is the full 12-note chromatic scale) | +| EUCLID | No | Notes set freely per voice | diff --git a/Apps/MidiController/docs/units.md b/Apps/MidiController/docs/units.md new file mode 100644 index 0000000..591632a --- /dev/null +++ b/Apps/MidiController/docs/units.md @@ -0,0 +1,317 @@ +# I2C Unit Drivers + +The unit drivers live in `Libraries/M5UnitModules/`: + +- `Include/UnitXxx.h` +- `Source/UnitXxx.cpp` + +The app compiles them through `Apps/MidiController/main/CMakeLists.txt`, which globs both the app's `Source/*.c*` files and `../../../Libraries/M5UnitModules/Source/*.c*`. + +Each I2C driver follows the same pattern: + +- `begin(Device* dev, uint8_t addr = DEFAULT_ADDR)` — probes the device; returns `false` if not found +- Methods are no-ops (or return `false`) if `begin()` was not called or returned `false` +- No exceptions, no dynamic allocation + +`UnitMidi` is the exception: it drives a SAM2695 over a UART at 31250 bps, so its `begin()` takes a UART controller instead of an I2C address. + +--- + +## UnitCommon.h + +Shared helpers for **STM32-based** M5Stack units (8Encoder, ByteButton, Joystick2, Scroll). + +STM32 units use a register-pointer protocol: write the register address (STOP), wait 2 ms, then read the data. A repeated-START without the STOP will return stale or zero data because the STM32 HAL fires its receive callback on the STOP condition. + +```cpp +// Write data to a register. len is limited to UNIT_MAX_WRITE_PAYLOAD (32 bytes). +bool unitWriteReg(Device* dev, uint8_t addr, + uint8_t reg, const uint8_t* buf, uint16_t len); + +// Write just the register pointer (no data) — prepares for a subsequent read +bool unitWriteRegPtr(Device* dev, uint8_t addr, uint8_t reg); + +// Full read sequence: writeRegPtr → vTaskDelay(2ms) → read +bool unitReadReg(Device* dev, uint8_t addr, + uint8_t reg, uint8_t* buf, uint16_t len); + +// Check that a device ACKs at the given address (zero-length write) +bool unitProbe(Device* dev, uint8_t addr); +``` + +`KNOWN_UNIT_ADDRS` lists the addresses the app probes when scanning PaHub channels: `0x28` (Rfid2), `0x3E` (LCD), `0x40` (Scroll), `0x41` (8Encoder), `0x47` (ByteButton), `0x5F` (CardKB2), and `0x63` (Joystick2). + +--- + +## Unit8Encoder + +**I2C address:** `0x41` (default, configurable) +**Protocol:** STM32-based (STOP+delay pattern) + +### Register map + +| Register | Description | +|----------|-------------| +| `0x00 + i*4` | Absolute counter for knob `i` (int32_t, LE), `i` = 0–7 | +| `0x20 + i*4` | Delta since last read for knob `i` (int32_t, LE), resets on read | +| `0x40 + i` | Write 1 to reset counter `i` | +| `0x50 + i` | Button `i` state (0 = released, 1 = pressed) | +| `0x60` | Toggle-switch state (0 = off, 1 = on) | +| `0x70 + i*3` | Encoder LED `i` RGB (3 bytes: R, G, B), `i` = 0–7 | +| `0x88` | Toggle-switch LED RGB (3 bytes) | +| `0xF0` | Firmware version | +| `0xFF` | I2C address (R/W) | + +### API + +```cpp +bool begin(Device* dev, uint8_t addr = 0x41); +bool isPresent() const; + +// Read all 8 deltas and button states in one call. +// Deltas are returned in detents (hardware counts ÷4). +// Performs 16 individual register reads. +bool readAll(int32_t deltas[8], uint8_t buttons[8]); + +// Read the toggle switch. Returns false on I2C error (state is then unchanged). +bool readSwitch(bool& state); + +// Set one LED immediately. rgb = 0x00RRGGBB. +// idx 0-7 = encoder LEDs, idx 8 = switch LED. +void setLed(uint8_t idx, uint32_t rgb); +void setSwitchLed(uint32_t rgb); + +// Set all 9 LEDs (8 encoders + switch) to the same colour. +void setAllLeds(uint32_t rgb); + +// Compare pending[9] against cached state and write only the changed LEDs. +// pending[0-7] = encoder LEDs, pending[8] = switch LED. +void flushLeds(const uint32_t pending[LED_COUNT]); + +// Cached colours, index 0-7 = encoders, 8 = switch. +const uint32_t* ledColors() const; + +static constexpr uint8_t LED_COUNT = 9; +static constexpr uint8_t ENCODER_LED_COUNT = 8; +``` + +### Notes + +- Delta registers accumulate movement between reads — no encoder ticks are lost even at a 100 ms poll rate. +- `flushLeds` sends a 24-byte payload (25-byte transaction) for the eight encoder LEDs and a separate 3-byte payload (4-byte transaction) for the switch LED, but only when the corresponding cached colours changed. + +--- + +## UnitPaHub + +**I2C address:** `0x70` (TCA9548A-based, fixed) +**Protocol:** Direct I2C write (no register pointer) + +The module exposes **6 channels (CH0–CH5)**. The TCA9548A chip itself supports 8 channels, but only six are wired to Grove ports on this unit. + +A single-byte bitmask write selects which downstream channel is active. Only one channel should be active at a time. + +```cpp +bool begin(Device* dev, uint8_t addr = 0x70); +bool isPresent() const; + +// Activate one channel (0–5). Writes (1 << channel) to the device. +bool select(uint8_t channel); + +// Deactivate all channels. Writes 0x00. +bool deselect(); + +uint8_t currentChannel() const; + +static constexpr uint8_t NUM_CHANNELS = 6; +static constexpr uint8_t NO_CHANNEL = 0xFF; +``` + +### Channel assignments in this app + +The app probes **every** channel (0–5) for the 8Encoder and LCD Unit and uses the first device that responds. The usual wiring is: + +| Channel | Connected unit | +|---------|---------------| +| CH4 | 8Encoder V2 (0x41) | +| CH5 | LCD Unit (0x3E) | + +Always call `select(channel)` before any transaction with a unit on that channel. + +--- + +## UnitLcd + +**I2C address:** `0x3E` +**Protocol:** ESP32-internal command buffer (no STOP+delay needed) + +See [hardware.md](hardware.md) for the command reference. + +```cpp +bool begin(Device* dev, uint8_t addr = 0x3E); +bool isPresent() const; + +uint16_t width() const; // current rotation's logical width +uint16_t height() const; // current rotation's logical height +uint8_t rotation() const; + +void setBrightness(uint8_t brightness); // 0=off, 255=max +void setRotation(uint8_t rotation); // 0=portrait, 1=landscape, 2/3=flipped + +void fillScreen(uint16_t rgb565); +void fillRect(uint8_t x0, uint8_t y0, uint8_t x1, uint8_t y1, uint16_t rgb565); +void drawPixel(uint8_t x, uint8_t y, uint16_t rgb565); + +// Set active drawing window (CASET + RASET commands). +bool setWindow(uint8_t x0, uint8_t y0, uint8_t x1, uint8_t y1); + +// Write raw RGB565 pixels into the active window. +// Sent in chunks of 126 pixels (253 bytes including the command byte). +void writePixels(const uint16_t* pixels, uint32_t len); + +void drawHLine(uint8_t x, uint8_t y, uint8_t len, uint16_t rgb565); +void drawVLine(uint8_t x, uint8_t y, uint8_t len, uint16_t rgb565); +void drawLine(int16_t x0, int16_t y0, int16_t x1, int16_t y1, uint16_t rgb565); +void drawRect(uint8_t x, uint8_t y, uint8_t w, uint8_t h, uint16_t rgb565); + +void fillCircle(int16_t cx, int16_t cy, int16_t r, uint16_t rgb565); +void drawCircle(int16_t cx, int16_t cy, int16_t r, uint16_t rgb565); +void fillRoundRect(int16_t x, int16_t y, int16_t w, int16_t h, int16_t r, uint16_t rgb565); +void drawRoundRect(int16_t x, int16_t y, int16_t w, int16_t h, int16_t r, uint16_t rgb565); +void fillTriangle(int16_t x0, int16_t y0, int16_t x1, int16_t y1, + int16_t x2, int16_t y2, uint16_t rgb565); +void drawTriangle(int16_t x0, int16_t y0, int16_t x1, int16_t y1, + int16_t x2, int16_t y2, uint16_t rgb565); + +// Angles in degrees (0=right, 90=down, 180=left, 270=up). +void fillArc(int16_t cx, int16_t cy, int16_t r0, int16_t r1, + float startDeg, float endDeg, uint16_t rgb565); +void drawArc(int16_t cx, int16_t cy, int16_t r0, int16_t r1, + float startDeg, float endDeg, uint16_t rgb565); + +void drawChar(uint8_t x, uint8_t y, char c, uint16_t fg, uint16_t bg, uint8_t scale = 1); +void drawText(uint8_t x, uint8_t y, const char* str, uint16_t fg, uint16_t bg, uint8_t scale = 1); + +// Query remaining command buffer space (0–255). +// Returns UINT8_MAX on I2C error (treat as "plenty of space" / safe fallback). +uint8_t bufferRemaining(); + +// Convert 0x00RRGGBB → RGB565. +static uint16_t rgb888to565(uint32_t rgb888); +static uint16_t color565(uint8_t r, uint8_t g, uint8_t b); +``` + +### Pixel chunking + +The ESP32 I2C driver has a 256-byte buffer. `writePixels` splits the stream into chunks of **126 pixels** (252 bytes plus the `0x42` command byte = 253 bytes). Each chunk is a separate I2C transaction. + +--- + +## UnitByteButton + +**I2C address:** `0x47` +**Protocol:** STM32-based (STOP+delay pattern) + +8 push-buttons with per-button RGB LEDs. + +```cpp +bool begin(Device* dev, uint8_t addr = 0x47); +bool isPresent() const; + +// Read all 8 buttons as a bitmask (bit i = button i, 1=pressed). +// Sets *ok=false on I2C error (ok may be nullptr). +uint8_t readButtons(bool* ok = nullptr); + +// Read a single button (true = pressed). +bool readButton(uint8_t idx); + +// Set one LED (0x00RRGGBB, idx 0-7) and send it immediately. +void setLed(uint8_t idx, uint32_t rgb); + +// Flush pending[8] to hardware in one burst if anything changed. +void flushLeds(const uint32_t pending[8]); + +// Set all 8 LEDs to one colour immediately. +void setAllLeds(uint32_t rgb); + +const uint32_t* ledColors() const; +``` + +**Init note:** The show-mode register `0x19` is written `0x00` during `begin()`; otherwise LEDs will not update. + +**Wire format:** each LED is sent as a little-endian `uint32_t` `[B, G, R, 0x00]`. + +--- + +## UnitJoystick2 + +**I2C address:** `0x63` +**Protocol:** STM32-based (STOP+delay pattern) + +Dual-axis thumbstick with push-button and RGB LED. + +```cpp +bool begin(Device* dev, uint8_t addr = 0x63); +bool isPresent() const; + +// Read offset-corrected 12-bit X/Y. Centre = 0, range ~±2048. +bool readXY12(int16_t* x, int16_t* y); + +// Read raw 12-bit X/Y (0-4095, centre ~2048). +bool readXY12Raw(uint16_t* x, uint16_t* y); + +// Read offset-corrected 8-bit X/Y. Centre = 0, range ~±128. +bool readXY8(int8_t* x, int8_t* y); + +// True when the button is pressed (inverts the hardware's 0=pressed convention). +bool isPressed() const; + +// Set LED colour (0x00RRGGBB). Returns false on I2C error. +bool setLed(uint32_t rgb); +``` + +--- + +## UnitScroll + +**I2C address:** `0x40` +**Protocol:** STM32-based (STOP+delay pattern) + +Single rotary encoder with push-button and RGB LED. + +```cpp +bool begin(Device* dev, uint8_t addr = 0x40); +bool isPresent() const; + +// Read accumulated delta since last call (int16_t, resets on read). +int16_t readDelta(); + +// Read the absolute encoder value. +int16_t readAbsolute() const; + +// True when the button is pressed. +bool isPressed() const; + +// Set LED colour (0x00RRGGBB). +// Wire format: [0x00, R, G, B] at register 0x30. +void setLed(uint32_t rgb); + +// Reset the internal counter to zero. +void resetEncoder(); +``` + +--- + +## Other drivers in the library + +`Libraries/M5UnitModules/` also contains drivers for `UnitCardKB2`, `UnitDualButton`, `UnitMidi`, and `UnitRfid2`. They are not used by this app, but are compiled by the app's CMake glob. `UnitMidi` uses UART rather than I2C; the others follow the I2C driver pattern. + +--- + +## Adding a new unit + +1. Create `Libraries/M5UnitModules/Include/UnitXxx.h` and `Libraries/M5UnitModules/Source/UnitXxx.cpp`. +2. Include `UnitCommon.h` for the shared I2C helpers. +3. Implement `begin(Device*, uint8_t addr)` returning `false` if `unitProbe` fails. +4. Guard every method with `if (!dev_) return;` (or return `false`). +5. The file is picked up automatically by the `GLOB_RECURSE` rules in `Apps/MidiController/main/CMakeLists.txt`. diff --git a/Apps/MidiController/main/CMakeLists.txt b/Apps/MidiController/main/CMakeLists.txt new file mode 100644 index 0000000..866a653 --- /dev/null +++ b/Apps/MidiController/main/CMakeLists.txt @@ -0,0 +1,5 @@ +include("$ENV{TACTILITY_SDK_PATH}/TactilitySDK.cmake") + +file(GLOB_RECURSE SOURCE_FILES Source/*.c*) +file(GLOB_RECURSE UNIT_MODULE_FILES ../../../Libraries/M5UnitModules/Source/*.c*) +tactility_component_register(SRCS ${SOURCE_FILES} ${UNIT_MODULE_FILES} INCLUDE_DIRS ../../../Libraries/M5UnitModules/Include) diff --git a/Apps/MidiController/main/Source/EncoderInput.cpp b/Apps/MidiController/main/Source/EncoderInput.cpp new file mode 100644 index 0000000..d6fe3f8 --- /dev/null +++ b/Apps/MidiController/main/Source/EncoderInput.cpp @@ -0,0 +1,375 @@ +#include "MidiController.h" +#include "LcdDisplay.h" +#include "GroveLookup.h" +#include +#include + +#include +#include +#include +#include + +// --------------------------------------------------------------------------- +// M5Stack 8Encoder (V2, addr 0x41) + LCD Unit (addr 0x3E) +// Both are on PaHub (addr 0x70) on the grove port in I2C mode (Tab5 Port A, SDA=53 SCL=54). +// Looked up via findGroveI2cDevice() (GroveLookup.h) rather than a hardcoded device name: the +// grove port device itself ("grove0") is a mode-switchable I2C/UART router (GROVE_TYPE), not an +// I2C controller - passing it directly into i2c_controller_*() calls crashes (wrong driver data +// layout). The actual I2C-master child device it spawns in I2C mode is named "grove0_i2c", but +// even that name isn't hardcoded here since grove port naming isn't guaranteed. +// +// Usually 8Encoder on PaHub CH4 and LCD on CH5, but encoderInit() probes +// every channel (0-5) and accepts either unit on any free port. +// +// Per-mode encoder → parameter mapping: +// EUCLID : knob 0 → sel.steps; knob 1 → sel.events; knob 2 → sel.rot; knob 3 → sel.note +// knob 4 → BPM; knobs 5-7 → steps for voices 6-8; button i → select voice i +// BABY8 : knob i (0-7) → note for step i; button i → toggle gate i +// DIMENSIONS : knob 0-3 → A/B/C/D; knob 4 → equation; knob 5 → BPM; knob 6 → oct; knob 7 → rnd +// button 0 → play/stop; button 1 → reset +// TB3PO : knob 0 → density; knob 1 → BPM; knob 2 → key; knob 3 → octave +// knob 4 → scale; knob 5 → regen-on-turn; button 0 → play/stop; button 1 → regen +// RAGA : knob 0 → raga; knob 1 → tala; knob 2 → root; knob 3 → BPM +// button 0 → play/stop; button 1 → drone toggle +// --------------------------------------------------------------------------- + +static constexpr auto* TAG = "EncoderInput"; +static constexpr int POLL_MS = 100; + +static UnitPaHub s_hub; +static Unit8Encoder s_enc; +static Device* s_i2cDev = nullptr; +// Discovered in encoderInit() by probing every PaHub channel (0-5), the 8Encoder can be plugged into any port on the hub. +static uint8_t s_encChannel = UnitPaHub::NO_CHANNEL; + +// --------------------------------------------------------------------------- +// LED colour tables +// --------------------------------------------------------------------------- + +// flushLeds() expects LED_COUNT (9) entries: 8 encoder LEDs + the switch LED. +// The app leaves the switch LED off, so the ninth entry is always 0. +static constexpr uint32_t EUCLID_COLORS[Unit8Encoder::LED_COUNT] = { + 0xFF4040, 0xFF9900, 0x44DD44, 0x44BBFF, + 0xFF44FF, 0xFFDD00, 0x00FFCC, 0xFF8888, + 0x000000 +}; +static constexpr uint32_t RAGA_COLORS[Unit8Encoder::LED_COUNT] = { + 0xFF8800, 0xFF6600, 0x00FFCC, 0xFFE000, + 0x000000, 0x000000, 0x000000, 0x000000, + 0x000000 +}; +static constexpr uint32_t DIM_COLORS[Unit8Encoder::LED_COUNT] = { + 0x88DDFF, 0x88FFCC, 0xFFCC88, 0xFF88CC, + 0xFF8844, 0xFFE000, + 0xAAFF88, 0xFFAA44, + 0x000000 +}; + +// --------------------------------------------------------------------------- +// PaHub channel helpers +// --------------------------------------------------------------------------- + +static bool selectEncoder() { + return s_encChannel != UnitPaHub::NO_CHANNEL && s_hub.select(s_encChannel); +} + +// --------------------------------------------------------------------------- +// Per-mode dispatch (encoder side only - LED flush goes through Unit8Encoder) +// --------------------------------------------------------------------------- + +static void dispatchEuclid(MidiController* mc) { + auto& es = mc->euclid_; + int32_t deltas[8] = {}; + uint8_t buttons[8] = {}; + if (!selectEncoder()) return; + if (!s_enc.readAll(deltas, buttons)) return; + + bool changed = false; + auto& sv = es.voice[es.selectedVoice]; + + if (deltas[0]) { int s = sv.steps + deltas[0]; s = std::max(1,std::min(32,s)); sv.steps = s; if (sv.events > sv.steps) sv.events = sv.steps; changed = true; } + if (deltas[1]) { int e = sv.events + deltas[1]; e = std::max(0,std::min(sv.steps,e)); sv.events = e; changed = true; } + if (deltas[2]) { int safeSteps = std::max(1, sv.steps); int r = sv.rotation + deltas[2]; r = ((r % safeSteps) + safeSteps) % safeSteps; sv.rotation = r; changed = true; } + if (deltas[3]) { int n = sv.note + deltas[3]; n = std::max(0,std::min(127,n)); sv.note = n; changed = true; } + if (deltas[4]) { int b = es.bpm + deltas[4]; b = std::max(40,std::min(280,b)); es.bpm = b; if (es.bpmLbl) { char buf[8]; snprintf(buf,sizeof(buf),"%d",b); lv_label_set_text(es.bpmLbl,buf); } } + for (int i = 5; i <= 7; i++) { + if (deltas[i]) { auto& v = es.voice[i]; int s = v.steps + deltas[i]; s = std::max(1,std::min(32,s)); v.steps = s; if (v.events > v.steps) v.events = v.steps; changed = true; } + } + for (int i = 0; i < 8; i++) { + bool btn = (buttons[i] != 0); + if (btn && !mc->encoder_.btnPrev[i]) { es.selectedVoice = i; changed = true; } + mc->encoder_.btnPrev[i] = btn; + } + if (changed) euclidOnEncoderUpdate(mc); + + uint32_t pending[Unit8Encoder::LED_COUNT] = {}; + for (int i = 0; i < 8; i++) { + uint32_t c = EUCLID_COLORS[i]; + pending[i] = (i == es.selectedVoice) ? c : (c >> 2) & 0x3F3F3F; + } + selectEncoder(); + s_enc.flushLeds(pending); + lcdUpdate(mc); +} + +static void dispatchBaby8(MidiController* mc) { + auto& bs = mc->baby8_; + int32_t deltas[8] = {}; + uint8_t buttons[8] = {}; + if (!selectEncoder()) return; + if (!s_enc.readAll(deltas, buttons)) return; + + static const char* NOTE_NAMES[] = {"C","C#","D","D#","E","F","F#","G","G#","A","A#","B"}; + bool stepChanged = false; + for (int i = 0; i < 8; i++) { + if (deltas[i]) { + int n = bs.steps[i].note + deltas[i]; + n = std::max(0, std::min(127, n)); + bs.steps[i].note = n; + if (bs.noteLbl[i]) { + char buf[8]; snprintf(buf,sizeof(buf),"%s%d",NOTE_NAMES[n%12],n/12-1); + lv_label_set_text(bs.noteLbl[i], buf); + } + stepChanged = true; + } + bool btn = (buttons[i] != 0); + if (btn && !mc->encoder_.btnPrev[i]) { + bs.steps[i].gate = !bs.steps[i].gate; + stepChanged = true; + } + mc->encoder_.btnPrev[i] = btn; + } + // Encoder edits must repaint the step buttons immediately; the timer only + // refreshes them while the sequencer is running. + if (stepChanged) baby8UpdateStepColors(mc); + + uint32_t pending[Unit8Encoder::LED_COUNT] = {}; + for (int i = 0; i < 8; i++) + pending[i] = (bs.playing && i == bs.currentStep) ? 0xFFFFFF + : (bs.steps[i].gate ? 0x004400 : 0x440000); + selectEncoder(); + s_enc.flushLeds(pending); + lcdUpdate(mc); +} + +static void dispatchDimensions(MidiController* mc) { + auto& ds = mc->dim_; + int32_t deltas[8] = {}; + uint8_t buttons[8] = {}; + if (!selectEncoder()) return; + if (!s_enc.readAll(deltas, buttons)) return; + + float* params[4] = {&ds.a,&ds.b,&ds.c,&ds.d}; + lv_obj_t** lbls[4] = {&ds.aLbl,&ds.bLbl,&ds.cLbl,&ds.dLbl}; + static const char* NAMES[4] = {"A","B","C","D"}; + for (int i = 0; i < 4; i++) { + if (deltas[i]) { + float v = *params[i] + deltas[i]; + v = std::max(0.0f, std::min(100.0f, v)); + *params[i] = v; + if (*lbls[i]) { char b[12]; snprintf(b,sizeof(b),"%s:%.0f",NAMES[i],v); lv_label_set_text(*lbls[i],b); } + } + } + if (deltas[4]) { int eq = ds.equation + deltas[4]; eq = std::max(1,std::min(20,eq)); ds.equation = eq; if (ds.eqLbl) { char b[12]; snprintf(b,sizeof(b),"Eq:%d",eq); lv_label_set_text(ds.eqLbl,b); } } + if (deltas[5]) { int bpm = ds.bpm + deltas[5]; bpm = std::max(40,std::min(280,bpm)); ds.bpm = bpm; if (ds.bpmLbl) { char b[8]; snprintf(b,sizeof(b),"%d",bpm); lv_label_set_text(ds.bpmLbl,b); } } + if (deltas[6]) dimChangeOctave(mc, deltas[6]); + if (deltas[7]) dimChangeRandom(mc, deltas[7]); + + for (int i = 0; i < 2; i++) { + bool btn = (buttons[i] != 0); + if (btn && !mc->encoder_.btnPrev[i]) { if (i==0) dimTogglePlay(mc); else dimReset(mc); } + mc->encoder_.btnPrev[i] = btn; + } + selectEncoder(); + s_enc.flushLeds(DIM_COLORS); + lcdUpdate(mc); +} + +static void dispatchTb3po(MidiController* mc) { + auto& ts = mc->tb3po_; + int32_t deltas[8] = {}; + uint8_t buttons[8] = {}; + if (!selectEncoder()) return; + if (!s_enc.readAll(deltas, buttons)) return; + + if (deltas[0]) { + int d = ts.density + deltas[0]; + d = std::max(0, std::min(16, d)); + if (d != ts.density) { + ts.density = d; + if (ts.densityLbl) { char b[8]; snprintf(b, sizeof(b), "D:%d", d); lv_label_set_text(ts.densityLbl, b); } + // Match the touch UI: a density change also draws a fresh seed and pattern. + tb3Regen(mc); + } + } + if (deltas[1]) { int bpm = ts.bpm + deltas[1]; bpm = std::max(40,std::min(280,bpm)); ts.bpm = bpm; if (ts.bpmLbl) { char b[8]; snprintf(b,sizeof(b),"%d",bpm); lv_label_set_text(ts.bpmLbl,b); } } + bool keyOrOctaveChanged = false; + if (deltas[2]) { ts.key = ((ts.key + deltas[2]) % 12 + 12) % 12; static const char* KS[] = {"C","C#","D","D#","E","F","F#","G","G#","A","A#","B"}; if (ts.keyLbl) lv_label_set_text(ts.keyLbl, KS[ts.key]); keyOrOctaveChanged = true; } + if (deltas[3]) { int oct = ts.octave + deltas[3]; oct = std::max(1,std::min(6,oct)); ts.octave = oct; keyOrOctaveChanged = true; } + if (deltas[4]) tb3ChangeScale(mc, deltas[4]); + if (deltas[5]) tb3Regen(mc); + // The piano roll shows resolved note names; key/octave changes need a repaint + // even when the sequencer is stopped. + if (keyOrOctaveChanged) tb3Redraw(mc); + for (int i = 0; i < 2; i++) { + bool btn = (buttons[i] != 0); + if (btn && !mc->encoder_.btnPrev[i]) { if (i==0) tb3TogglePlay(mc); else tb3Regen(mc); } + mc->encoder_.btnPrev[i] = btn; + } + + uint32_t pending[Unit8Encoder::LED_COUNT] = {}; + for (int i = 0; i < 8; i++) + pending[i] = (ts.playing && i == (ts.currentStep / 4)) ? 0xFF6600 : 0x221100; + selectEncoder(); + s_enc.flushLeds(pending); + lcdUpdate(mc); +} + +static void dispatchRaga(MidiController* mc) { + auto& rs = mc->raga_; + int32_t deltas[8] = {}; + uint8_t buttons[8] = {}; + if (!selectEncoder()) return; + if (!s_enc.readAll(deltas, buttons)) return; + + static const char* RAGA_NAMES[] = {"Bhairavi","Lalit","Bhupali","Todi","Madhuvanti","Meghmalhar","Yaman","Malkauns"}; + static const char* TALA_NAMES[] = {"Teental","Rupak","Jhaptal"}; + + if (deltas[0]) { rs.raga = ((rs.raga + deltas[0]) % 8 + 8) % 8; if (rs.ragaLbl) lv_label_set_text(rs.ragaLbl, RAGA_NAMES[rs.raga]); if (rs.ragaBigLbl) lv_label_set_text(rs.ragaBigLbl, RAGA_NAMES[rs.raga]); } + if (deltas[1]) { rs.tala = ((rs.tala + deltas[1]) % 3 + 3) % 3; rs.talaBeat = 0; if (rs.talaLbl) lv_label_set_text(rs.talaLbl, TALA_NAMES[rs.tala]); if (rs.talaBigLbl) lv_label_set_text(rs.talaBigLbl, TALA_NAMES[rs.tala]); } + if (deltas[2]) { int r = rs.rootNote + deltas[2]; r = std::max(24,std::min(96,r)); rs.rootNote = r; if (rs.rootLbl) { static const char* N[] = {"C","C#","D","D#","E","F","F#","G","G#","A","A#","B"}; char b[8]; snprintf(b,sizeof(b),"%s%d",N[r%12],r/12-1); lv_label_set_text(rs.rootLbl,b); } } + if (deltas[3]) { int bpm = rs.bpm + deltas[3]; bpm = std::max(40,std::min(240,bpm)); rs.bpm = bpm; if (rs.bpmLbl) { char b[8]; snprintf(b,sizeof(b),"%d",bpm); lv_label_set_text(rs.bpmLbl,b); } } + for (int i = 0; i < 2; i++) { + bool btn = (buttons[i] != 0); + if (btn && !mc->encoder_.btnPrev[i]) { if (i==0) ragaTogglePlay(mc); else ragaToggleDrone(mc); } + mc->encoder_.btnPrev[i] = btn; + } + selectEncoder(); + s_enc.flushLeds(RAGA_COLORS); + lcdUpdate(mc); +} + +// --------------------------------------------------------------------------- +// Poll timer +// --------------------------------------------------------------------------- + +static void cbEncoderPoll(lv_timer_t* t) { + auto* mc = static_cast(lv_timer_get_user_data(t)); + if (!mc) return; + // With no encoder, this timer still refreshes a detected LCD unit. + if (!mc->encoder_.present) { + lcdUpdate(mc); + return; + } + if (!s_hub.isPresent()) return; + + // After a mode switch, do one silent read to seed btnPrev with the live + // button state - prevents a physically-held button from registering as a + // fresh press in the new mode's dispatch. + if (mc->encoder_.btnSeedNext) { + mc->encoder_.btnSeedNext = false; + int32_t deltas[8] = {}; + uint8_t buttons[8] = {}; + if (selectEncoder()) { + s_enc.readAll(deltas, buttons); + for (int i = 0; i < 8; i++) + mc->encoder_.btnPrev[i] = (buttons[i] != 0); + } + return; + } + + switch (mc->currentMode_) { + case MidiMode::EUCLID: dispatchEuclid (mc); break; + case MidiMode::BABY8: dispatchBaby8 (mc); break; + case MidiMode::DIMENSIONS: dispatchDimensions(mc); break; + case MidiMode::TB3PO: dispatchTb3po (mc); break; + case MidiMode::RAGA: dispatchRaga (mc); break; + default: break; + } +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +void encoderSetLed(MidiController* mc, uint8_t idx, uint32_t rgb) { + if (!mc->encoder_.present || idx >= 8) return; + if (!s_hub.isPresent()) return; + selectEncoder(); + s_enc.setLed(idx, rgb); + // sync cache + mc->encoder_.ledColor[idx] = rgb; +} + +void encoderSetAllLeds(MidiController* mc, uint32_t rgb) { + if (!mc->encoder_.present) return; + if (!s_hub.isPresent()) return; + selectEncoder(); + s_enc.setAllLeds(rgb); + for (int i = 0; i < 8; i++) mc->encoder_.ledColor[i] = rgb; +} + +void encoderInit(MidiController* mc) { + if (mc->encoderTimer_) return; + + Device* dev = findGroveI2cDevice(); + if (!dev) { + ESP_LOGE(TAG, "grove0_i2c not ready"); + mc->encoder_.present = false; + return; + } + s_i2cDev = dev; + + // Init PaHub + if (!s_hub.begin(dev)) { + ESP_LOGW(TAG, "PaHub not found - no encoder or LCD"); + mc->encoder_.present = false; + s_i2cDev = nullptr; + return; + } + + // Find the 8Encoder by probing every PaHub channel - Unit8Encoder::begin() + // itself probes the I2C address and returns false if nothing answers, so trying each + // channel in turn and keeping the first hit is safe/cheap (6 channels, worst case). + s_encChannel = UnitPaHub::NO_CHANNEL; + for (uint8_t ch = 0; ch < UnitPaHub::NUM_CHANNELS; ch++) { + if (!s_hub.select(ch)) continue; + if (s_enc.begin(dev)) { + s_encChannel = ch; + break; + } + } + if (s_encChannel == UnitPaHub::NO_CHANNEL) { + ESP_LOGW(TAG, "8Encoder not found on any PaHub channel"); + mc->encoder_.present = false; + // Still try LCD even without encoder + } else { + mc->encoder_.present = true; + memset(mc->encoder_.ledColor, 0, sizeof(mc->encoder_.ledColor)); + ESP_LOGI(TAG, "8Encoder ready on PaHub CH%d", (int)s_encChannel); + } + + // Find the LCD the same way (LcdDisplay.cpp's own channel scan). + lcdInit(&s_hub); + lcdSetMode(mc->currentMode_); + + if (mc->encoder_.present || lcdIsPresent()) { + mc->encoderTimer_ = lv_timer_create(cbEncoderPoll, POLL_MS, mc); + } +} + +void encoderStop(MidiController* mc) { + if (mc->encoderTimer_) { + lv_timer_delete(mc->encoderTimer_); + mc->encoderTimer_ = nullptr; + } + if (mc->encoder_.present && s_hub.isPresent()) { + selectEncoder(); + s_enc.setAllLeds(0x000000); + } + lcdStop(); + memset(mc->encoder_.ledColor, 0, sizeof(mc->encoder_.ledColor)); + mc->encoder_.present = false; + s_i2cDev = nullptr; + s_encChannel = UnitPaHub::NO_CHANNEL; +} diff --git a/Apps/MidiController/main/Source/GroveLookup.cpp b/Apps/MidiController/main/Source/GroveLookup.cpp new file mode 100644 index 0000000..795da04 --- /dev/null +++ b/Apps/MidiController/main/Source/GroveLookup.cpp @@ -0,0 +1,45 @@ +#include "GroveLookup.h" +#include +#include + +namespace { + +struct GroveChildSearch { + const DeviceType* childType; + Device* result; +}; + +bool onGroveDevice(Device* groveDevice, void* context) { + auto* search = static_cast(context); + + GroveMode mode; + if (grove_get_mode(groveDevice, &mode) != ERROR_NONE) return true; + if (mode != (search->childType == &I2C_CONTROLLER_TYPE ? GROVE_MODE_I2C : GROVE_MODE_UART)) return true; + + device_for_each_child(groveDevice, search, [](Device* child, void* ctx) -> bool { + auto* s = static_cast(ctx); + if (device_get_type(child) == s->childType && device_is_ready(child)) { + s->result = child; + return false; // stop - found it + } + return true; + }); + + return search->result == nullptr; // stop outer iteration once found +} + +Device* findGroveChild(const DeviceType* childType) { + GroveChildSearch search{childType, nullptr}; + device_for_each_of_type(&GROVE_TYPE, &search, onGroveDevice); + return search.result; +} + +} // namespace + +Device* findGroveI2cDevice() { + return findGroveChild(&I2C_CONTROLLER_TYPE); +} + +Device* findGroveUartDevice() { + return findGroveChild(&UART_CONTROLLER_TYPE); +} diff --git a/Apps/MidiController/main/Source/GroveLookup.h b/Apps/MidiController/main/Source/GroveLookup.h new file mode 100644 index 0000000..9711e5a --- /dev/null +++ b/Apps/MidiController/main/Source/GroveLookup.h @@ -0,0 +1,12 @@ +#pragma once + +#include +#include + +// Finds the I2C controller device exposed by the first grove port currently +// in GROVE_MODE_I2C, by checking each grove port's child device type +// (not by name - grove port names are not guaranteed, e.g. "port_a"). +Device* findGroveI2cDevice(); + +// Same as findGroveI2cDevice() but for UART mode. +Device* findGroveUartDevice(); diff --git a/Apps/MidiController/main/Source/LcdDisplay.cpp b/Apps/MidiController/main/Source/LcdDisplay.cpp new file mode 100644 index 0000000..6166f52 --- /dev/null +++ b/Apps/MidiController/main/Source/LcdDisplay.cpp @@ -0,0 +1,803 @@ +#include "LcdDisplay.h" +#include "GroveLookup.h" + +#include +#include +#include +#include + +static constexpr auto* TAG = "LcdDisplay"; + +// --------------------------------------------------------------------------- +// Hardware +// --------------------------------------------------------------------------- + +static UnitPaHub* s_hub = nullptr; +static UnitLcd s_lcd; +static Device* s_i2cDev = nullptr; +// Discovered in lcdInit() by probing every PaHub channel (0-5), the LCD unit can be plugged into any port on the hub. +static uint8_t s_lcdChannel = UnitPaHub::NO_CHANNEL; + +// --------------------------------------------------------------------------- +// State cache - only redraw when something actually changed +// --------------------------------------------------------------------------- + +static MidiMode s_mode = MidiMode::HOME; +static bool s_dirty = true; // force full redraw on first update + +// Per-mode cached values (simple structs with sentinels so first draw always fires) +struct RagaCache { int raga=-1, tala=-1, root=-1, bpm=-1; bool playing=false, drone=false; }; +struct DimCache { int a=-1, b=-1, c=-1, d=-1, eq=-1, bpm=-1, octave=INT_MIN, rnd_cached=INT_MIN; bool playing=false; }; +struct Tb3Cache { int step=-1, key=-1, scale=-1, bpm=-1, density=-1; int patScale=-1, patDensity=-1; uint32_t seed=0; bool playing=false; }; +struct Baby8Cache { int note[8]={-1,-1,-1,-1,-1,-1,-1,-1}; bool gate[8]={}; int step=-1; bool playing=false; }; +struct EuclidCache{ int voice=-1, steps=-1, events=-1, rot=-1, note=-1, bpm=-1; bool playing=false; }; + +static RagaCache s_raga; +static DimCache s_dim; +static Tb3Cache s_tb3; +static Baby8Cache s_b8; +static EuclidCache s_euc; + +// --------------------------------------------------------------------------- +// Colours (RGB565) +// --------------------------------------------------------------------------- + +static constexpr uint16_t C_BG = 0x0000; // black +static constexpr uint16_t C_WHITE = 0xFFFF; +static constexpr uint16_t C_GRAY = 0x4A69; +static constexpr uint16_t C_DGRAY = 0x2104; +static constexpr uint16_t C_RED = 0xF800; +static constexpr uint16_t C_GREEN = 0x07E0; +static constexpr uint16_t C_BLUE = 0x001F; +static constexpr uint16_t C_ORANGE = 0xFD20; +static constexpr uint16_t C_YELLOW = 0xFFE0; +static constexpr uint16_t C_CYAN = 0x07FF; +static constexpr uint16_t C_MAGENTA = 0xF81F; +static constexpr uint16_t C_PURPLE = 0x801F; + +// Voice colours matching EUCLID_COLORS in EncoderInput +static constexpr uint16_t VOICE_COL[8] = { + 0xF800, 0xFD20, 0x47E0, 0x45FF, + 0xF81F, 0xFFE0, 0x07FE, 0xFC11 +}; + +// --------------------------------------------------------------------------- +// Drawing primitives +// --------------------------------------------------------------------------- + +static bool selectLcd() { + return s_hub && s_lcdChannel != UnitPaHub::NO_CHANNEL && s_hub->select(s_lcdChannel); +} + +static void fillRect(uint8_t x, uint8_t y, uint8_t w, uint8_t h, uint16_t col) { + if (w == 0 || h == 0) return; + s_lcd.fillRect(x, y, (uint8_t)(x + w - 1), (uint8_t)(y + h - 1), col); +} + +static void hline(uint8_t x, uint8_t y, uint8_t w, uint16_t col) { + fillRect(x, y, w, 1, col); +} + +// --------------------------------------------------------------------------- +// Minimal 5×7 bitmap font (printable ASCII 0x20-0x7E) +// Each character is 5 columns × 7 rows, packed into 5 bytes (LSB = top row). +// --------------------------------------------------------------------------- + +static const uint8_t FONT5x7[][5] = { + {0x00,0x00,0x00,0x00,0x00}, // ' ' + {0x00,0x00,0x5F,0x00,0x00}, // '!' + {0x00,0x07,0x00,0x07,0x00}, // '"' + {0x14,0x7F,0x14,0x7F,0x14}, // '#' + {0x24,0x2A,0x7F,0x2A,0x12}, // '$' + {0x23,0x13,0x08,0x64,0x62}, // '%' + {0x36,0x49,0x55,0x22,0x50}, // '&' + {0x00,0x05,0x03,0x00,0x00}, // ''' + {0x00,0x1C,0x22,0x41,0x00}, // '(' + {0x00,0x41,0x22,0x1C,0x00}, // ')' + {0x08,0x2A,0x1C,0x2A,0x08}, // '*' + {0x08,0x08,0x3E,0x08,0x08}, // '+' + {0x00,0x50,0x30,0x00,0x00}, // ',' + {0x08,0x08,0x08,0x08,0x08}, // '-' + {0x00,0x60,0x60,0x00,0x00}, // '.' + {0x20,0x10,0x08,0x04,0x02}, // '/' + {0x3E,0x51,0x49,0x45,0x3E}, // '0' + {0x00,0x42,0x7F,0x40,0x00}, // '1' + {0x42,0x61,0x51,0x49,0x46}, // '2' + {0x21,0x41,0x45,0x4B,0x31}, // '3' + {0x18,0x14,0x12,0x7F,0x10}, // '4' + {0x27,0x45,0x45,0x45,0x39}, // '5' + {0x3C,0x4A,0x49,0x49,0x30}, // '6' + {0x01,0x71,0x09,0x05,0x03}, // '7' + {0x36,0x49,0x49,0x49,0x36}, // '8' + {0x06,0x49,0x49,0x29,0x1E}, // '9' + {0x00,0x36,0x36,0x00,0x00}, // ':' + {0x00,0x56,0x36,0x00,0x00}, // ';' + {0x00,0x08,0x14,0x22,0x41}, // '<' + {0x14,0x14,0x14,0x14,0x14}, // '=' + {0x41,0x22,0x14,0x08,0x00}, // '>' + {0x02,0x01,0x51,0x09,0x06}, // '?' + {0x32,0x49,0x79,0x41,0x3E}, // '@' + {0x7E,0x11,0x11,0x11,0x7E}, // 'A' + {0x7F,0x49,0x49,0x49,0x36}, // 'B' + {0x3E,0x41,0x41,0x41,0x22}, // 'C' + {0x7F,0x41,0x41,0x22,0x1C}, // 'D' + {0x7F,0x49,0x49,0x49,0x41}, // 'E' + {0x7F,0x09,0x09,0x09,0x01}, // 'F' + {0x3E,0x41,0x49,0x49,0x7A}, // 'G' + {0x7F,0x08,0x08,0x08,0x7F}, // 'H' + {0x00,0x41,0x7F,0x41,0x00}, // 'I' + {0x20,0x40,0x41,0x3F,0x01}, // 'J' + {0x7F,0x08,0x14,0x22,0x41}, // 'K' + {0x7F,0x40,0x40,0x40,0x40}, // 'L' + {0x7F,0x02,0x04,0x02,0x7F}, // 'M' + {0x7F,0x04,0x08,0x10,0x7F}, // 'N' + {0x3E,0x41,0x41,0x41,0x3E}, // 'O' + {0x7F,0x09,0x09,0x09,0x06}, // 'P' + {0x3E,0x41,0x51,0x21,0x5E}, // 'Q' + {0x7F,0x09,0x19,0x29,0x46}, // 'R' + {0x46,0x49,0x49,0x49,0x31}, // 'S' + {0x01,0x01,0x7F,0x01,0x01}, // 'T' + {0x3F,0x40,0x40,0x40,0x3F}, // 'U' + {0x1F,0x20,0x40,0x20,0x1F}, // 'V' + {0x3F,0x40,0x38,0x40,0x3F}, // 'W' + {0x63,0x14,0x08,0x14,0x63}, // 'X' + {0x07,0x08,0x70,0x08,0x07}, // 'Y' + {0x61,0x51,0x49,0x45,0x43}, // 'Z' + {0x00,0x7F,0x41,0x41,0x00}, // '[' + {0x02,0x04,0x08,0x10,0x20}, // '\' + {0x00,0x41,0x41,0x7F,0x00}, // ']' + {0x04,0x02,0x01,0x02,0x04}, // '^' + {0x40,0x40,0x40,0x40,0x40}, // '_' + {0x00,0x01,0x02,0x04,0x00}, // '`' + {0x20,0x54,0x54,0x54,0x78}, // 'a' + {0x7F,0x48,0x44,0x44,0x38}, // 'b' + {0x38,0x44,0x44,0x44,0x20}, // 'c' + {0x38,0x44,0x44,0x48,0x7F}, // 'd' + {0x38,0x54,0x54,0x54,0x18}, // 'e' + {0x08,0x7E,0x09,0x01,0x02}, // 'f' + {0x08,0x14,0x54,0x54,0x3C}, // 'g' + {0x7F,0x08,0x04,0x04,0x78}, // 'h' + {0x00,0x44,0x7D,0x40,0x00}, // 'i' + {0x20,0x40,0x44,0x3D,0x00}, // 'j' + {0x7F,0x10,0x28,0x44,0x00}, // 'k' + {0x00,0x41,0x7F,0x40,0x00}, // 'l' + {0x7C,0x04,0x18,0x04,0x78}, // 'm' + {0x7C,0x08,0x04,0x04,0x78}, // 'n' + {0x38,0x44,0x44,0x44,0x38}, // 'o' + {0x7C,0x14,0x14,0x14,0x08}, // 'p' + {0x08,0x14,0x14,0x18,0x7C}, // 'q' + {0x7C,0x08,0x04,0x04,0x08}, // 'r' + {0x48,0x54,0x54,0x54,0x20}, // 's' + {0x04,0x3F,0x44,0x40,0x20}, // 't' + {0x3C,0x40,0x40,0x40,0x7C}, // 'u' + {0x1C,0x20,0x40,0x20,0x1C}, // 'v' + {0x3C,0x40,0x30,0x40,0x3C}, // 'w' + {0x44,0x28,0x10,0x28,0x44}, // 'x' + {0x0C,0x50,0x50,0x50,0x3C}, // 'y' + {0x44,0x64,0x54,0x4C,0x44}, // 'z' + {0x00,0x08,0x36,0x41,0x00}, // '{' + {0x00,0x00,0x7F,0x00,0x00}, // '|' + {0x00,0x41,0x36,0x08,0x00}, // '}' + {0x08,0x04,0x08,0x10,0x08}, // '~' +}; + +// Draw one character at pixel position (x,y), scale 1 or 2, colour/bg. +static void drawChar(uint8_t x, uint8_t y, char c, uint16_t col, uint16_t bg, uint8_t scale = 1) { + if (c < 0x20 || c > 0x7E) c = '?'; + const uint8_t* glyph = FONT5x7[c - 0x20]; + for (int col_i = 0; col_i < 5; col_i++) { + uint8_t col_data = glyph[col_i]; + for (int row_i = 0; row_i < 7; row_i++) { + uint16_t px_col = (col_data & (1 << row_i)) ? col : bg; + uint8_t px = (uint8_t)(x + col_i * scale); + uint8_t py = (uint8_t)(y + row_i * scale); + if (scale == 1) + s_lcd.drawPixel(px, py, px_col); + else + fillRect(px, py, scale, scale, px_col); + } + } +} + +// Draw a string. Returns x after last character. +static uint8_t drawStr(uint8_t x, uint8_t y, const char* str, uint16_t col, + uint16_t bg = C_BG, uint8_t scale = 1) { + uint8_t cw = (uint8_t)(6 * scale); // 5px + 1px gap + while (*str) { + drawChar(x, y, *str++, col, bg, scale); + x = (uint8_t)(x + cw); + } + return x; +} + +// Draw a string centred in a width-w region starting at x. +static void drawStrCentred(uint8_t x, uint8_t y, uint8_t w, const char* str, + uint16_t col, uint16_t bg = C_BG, uint8_t scale = 1) { + int len = (int)strlen(str); + int sw = len * 6 * scale - scale; // total pixel width (no trailing gap) + int offset = ((int)w - sw) / 2; + if (offset < 0) offset = 0; + drawStr((uint8_t)(x + offset), y, str, col, bg, scale); +} + +// Erase a text row (fill with background). +static void clearRow(uint8_t y, uint8_t h) { + fillRect(0, y, 135, h, C_BG); +} + +// --------------------------------------------------------------------------- +// Mode header (top 18px) +// --------------------------------------------------------------------------- + +static const char* modeName(MidiMode m) { + switch (m) { + case MidiMode::RAGA: return "RAGA"; + case MidiMode::DIMENSIONS: return "DIM"; + case MidiMode::TB3PO: return "TB-3PO"; + case MidiMode::BABY8: return "BABY8"; + case MidiMode::EUCLID: return "EUCLID"; + default: return "MIDI"; + } +} + +static uint16_t modeAccent(MidiMode m) { + switch (m) { + case MidiMode::RAGA: return C_ORANGE; + case MidiMode::DIMENSIONS: return C_CYAN; + case MidiMode::TB3PO: return C_GREEN; + case MidiMode::BABY8: return C_MAGENTA; + case MidiMode::EUCLID: return C_RED; + default: return C_GRAY; + } +} + +static void drawHeader(MidiMode m) { + uint16_t acc = modeAccent(m); + fillRect(0, 0, 135, 16, acc); + drawStrCentred(0, 4, 135, modeName(m), C_BG, acc, 2); + hline(0, 16, 135, C_DGRAY); +} + +// --------------------------------------------------------------------------- +// Note name helper +// --------------------------------------------------------------------------- + +static const char* NOTE_NAMES[] = { + "C","C#","D","D#","E","F","F#","G","G#","A","A#","B" +}; + +static void noteName(int midi, char* buf, int bufsz) { + int n = midi % 12; + int oct = midi / 12 - 1; + snprintf(buf, (size_t)bufsz, "%s%d", NOTE_NAMES[n], oct); +} + +// --------------------------------------------------------------------------- +// Per-mode draw functions +// --------------------------------------------------------------------------- + +// -- RAGA --------------------------------------------------------------- +// Layout (y=0..239, portrait): +// 0-16 header +// 18-65 raga name (large, 2×scale) +// 68-79 tala +// 82-93 root + BPM on same row +// 96- play/drone status bars + +static void drawRaga(const MidiController::RagaState& rs) { + static const char* RAGA_NAMES[] = { + "Bhairavi","Lalit","Bhupali","Todi", + "Madhuvanti","Meghmalhar","Yaman","Malkauns" + }; + static const char* TALA_NAMES[] = {"Teental","Rupak","Jhaptal"}; + + auto& c = s_raga; + bool fullRedraw = s_dirty; + + if (fullRedraw) { + fillRect(0, 0, 135, 240, C_BG); + drawHeader(MidiMode::RAGA); + // Static labels + drawStr(2, 70, "TALA", C_GRAY); + drawStr(2, 84, "ROOT", C_GRAY); + drawStr(70, 84, "BPM", C_GRAY); + hline(0, 98, 135, C_DGRAY); + } + + static constexpr int RAGA_NAMES_COUNT = (int)(sizeof(RAGA_NAMES) / sizeof(RAGA_NAMES[0])); + static constexpr int TALA_NAMES_COUNT = (int)(sizeof(TALA_NAMES) / sizeof(TALA_NAMES[0])); + + // Raga name (big, 2× scale) + if (fullRedraw || c.raga != rs.raga) { + clearRow(20, 46); + if (rs.raga >= 0 && rs.raga < RAGA_NAMES_COUNT) { + drawStrCentred(0, 22, 135, RAGA_NAMES[rs.raga], C_ORANGE, C_BG, 2); + } else { + drawStrCentred(0, 22, 135, "--", C_ORANGE, C_BG, 2); + } + c.raga = rs.raga; + } + + // Tala + if (fullRedraw || c.tala != rs.tala) { + fillRect(36, 68, 99, 10, C_BG); + if (rs.tala >= 0 && rs.tala < TALA_NAMES_COUNT) { + drawStr(36, 68, TALA_NAMES[rs.tala], C_YELLOW); + } else { + drawStr(36, 68, "--", C_YELLOW); + } + c.tala = rs.tala; + } + + // Root + if (fullRedraw || c.root != rs.rootNote) { + fillRect(36, 82, 32, 10, C_BG); + char buf[8]; noteName(rs.rootNote, buf, sizeof(buf)); + drawStr(36, 82, buf, C_CYAN); + c.root = rs.rootNote; + } + + // BPM + if (fullRedraw || c.bpm != rs.bpm) { + fillRect(96, 82, 39, 10, C_BG); + char buf[6]; snprintf(buf, sizeof(buf), "%d", rs.bpm); + drawStr(96, 82, buf, C_WHITE); + c.bpm = rs.bpm; + } + + // Play status bar + if (fullRedraw || c.playing != rs.playing) { + uint16_t col = rs.playing ? C_RED : C_DGRAY; + fillRect(0, 100, 135, 28, col); + drawStrCentred(0, 107, 135, rs.playing ? "PLAYING" : "STOPPED", C_WHITE, col, 2); + c.playing = rs.playing; + } + + // Drone bar + if (fullRedraw || c.drone != rs.droneOn) { + uint16_t col = rs.droneOn ? C_BLUE : C_DGRAY; + fillRect(0, 132, 135, 20, col); + drawStrCentred(0, 137, 135, rs.droneOn ? "DRONE ON" : "DRONE OFF", C_WHITE, col); + c.drone = rs.droneOn; + } + + // Knob legend at bottom + if (fullRedraw) { + uint8_t ly = 170; + struct { const char* k; const char* v; uint16_t col; } rows[] = { + {"K1","RAGA", C_ORANGE}, + {"K2","TALA", C_YELLOW}, + {"K3","ROOT", C_CYAN}, + {"K4","BPM", C_WHITE}, + {"B1","PLAY", C_RED}, + {"B2","DRON", C_BLUE}, + }; + for (auto& r : rows) { + fillRect(0, ly, 135, 10, C_BG); + drawStr(2, ly, r.k, C_GRAY); + drawStr(22, ly, r.v, r.col); + ly = (uint8_t)(ly + 12); + } + } +} + +// -- DIMENSIONS --------------------------------------------------------------- +// Layout: +// 0-16 header +// 18-77 A/B top row, C/D bottom row - coloured value bars +// 80-91 Eq + BPM +// 94-105 Oct + Rnd +// 108- play status + knob legend + +static void drawBar(uint8_t x, uint8_t y, uint8_t w, uint8_t h, + float val, float maxVal, uint16_t col) { + if (maxVal <= 0.0f) { fillRect(x, y, w, h, C_DGRAY); return; } + uint8_t filled = (uint8_t)((val / maxVal) * w); + if (filled > w) filled = w; + fillRect(x, y, filled, h, col); + if (filled < w) fillRect((uint8_t)(x + filled), y, (uint8_t)(w - filled), h, C_DGRAY); +} + +static void drawDimensions(const MidiController::DimensionsState& ds) { + auto& c = s_dim; + bool fullRedraw = s_dirty; + + if (fullRedraw) { + fillRect(0, 0, 135, 240, C_BG); + drawHeader(MidiMode::DIMENSIONS); + // Static labels + drawStr(2, 20, "A", UnitLcd::rgb888to565(0x88DDFF)); + drawStr(69, 20, "B", UnitLcd::rgb888to565(0x88FFCC)); + drawStr(2, 50, "C", UnitLcd::rgb888to565(0xFFCC88)); + drawStr(69, 50, "D", UnitLcd::rgb888to565(0xFF88CC)); + drawStr(2, 82, "EQ", C_ORANGE); + drawStr(70, 82, "BPM", C_YELLOW); + drawStr(2, 96, "OCT", UnitLcd::rgb888to565(0xAAFF88)); + drawStr(70, 96, "RND", UnitLcd::rgb888to565(0xFFAA44)); + hline(0, 110, 135, C_DGRAY); + } + + static const uint16_t BC[4] = { + UnitLcd::rgb888to565(0x88DDFF), UnitLcd::rgb888to565(0x88FFCC), + UnitLcd::rgb888to565(0xFFCC88), UnitLcd::rgb888to565(0xFF88CC) + }; + float vals[4] = { ds.a, ds.b, ds.c, ds.d }; + int cvals[4]= { (int)ds.a, (int)ds.b, (int)ds.c, (int)ds.d }; + int* cprev[4]= { &c.a, &c.b, &c.c, &c.d }; + + for (int i = 0; i < 4; i++) { + if (fullRedraw || *cprev[i] != cvals[i]) { + uint8_t bx = (i & 1) ? 67 : 2; + uint8_t by = (i < 2) ? 30 : 60; + // value text + fillRect(bx, by - 10, 60, 10, C_BG); + char buf[8]; snprintf(buf, sizeof(buf), "%.0f", vals[i]); + drawStr((uint8_t)(bx + 12), (uint8_t)(by - 10), buf, BC[i]); + // bar + drawBar(bx, by, 60, 8, vals[i], 100.0f, BC[i]); + *cprev[i] = cvals[i]; + } + } + + if (fullRedraw || c.eq != ds.equation) { + fillRect(20, 82, 46, 10, C_BG); + char buf[8]; snprintf(buf, sizeof(buf), "%d", ds.equation); + drawStr(20, 82, buf, C_ORANGE); + c.eq = ds.equation; + } + if (fullRedraw || c.bpm != ds.bpm) { + fillRect(90, 82, 45, 10, C_BG); + char buf[6]; snprintf(buf, sizeof(buf), "%d", ds.bpm); + drawStr(90, 82, buf, C_YELLOW); + c.bpm = ds.bpm; + } + if (fullRedraw || c.octave != ds.octave) { + fillRect(22, 96, 46, 10, C_BG); + char buf[8]; snprintf(buf, sizeof(buf), "%+d", ds.octave); + drawStr(22, 96, buf, UnitLcd::rgb888to565(0xAAFF88)); + c.octave = ds.octave; + } + int rndRounded = (int)ds.rnd; + if (fullRedraw || c.rnd_cached != rndRounded) { + fillRect(90, 96, 45, 10, C_BG); + char buf[8]; snprintf(buf, sizeof(buf), "%.0f", ds.rnd); + drawStr(90, 96, buf, UnitLcd::rgb888to565(0xFFAA44)); + c.rnd_cached = rndRounded; + } + + if (fullRedraw || c.playing != ds.playing) { + uint16_t col = ds.playing ? C_GREEN : C_DGRAY; + fillRect(0, 112, 135, 20, col); + drawStrCentred(0, 117, 135, ds.playing ? "PLAYING" : "STOPPED", C_WHITE, col); + c.playing = ds.playing; + } +} + +// -- TB3PO --------------------------------------------------------------- +// Layout: +// 0-16 header +// 18-49 32-step mini piano roll (6×12 px cells at an 8 px pitch, 2 rows of 16) +// 52-63 key + scale +// 66-77 density + BPM +// 80-91 play status + +static constexpr const char* TB3_SCALE_NAMES[] = { + "Major","Minor","Dorian","Penta","Blues","Chrome" +}; +static constexpr int TB3_SCALE_NAMES_COUNT = (int)(sizeof(TB3_SCALE_NAMES) / sizeof(TB3_SCALE_NAMES[0])); +static void drawTb3po(const MidiController::Tb3poState& ts) { + auto& c = s_tb3; + bool fullRedraw = s_dirty; + + if (fullRedraw) { + fillRect(0, 0, 135, 240, C_BG); + drawHeader(MidiMode::TB3PO); + drawStr(2, 54, "KEY", C_GREEN); + drawStr(70, 54, "SCALE", C_CYAN); + drawStr(2, 68, "DEN", C_YELLOW); + drawStr(70, 68, "BPM", C_WHITE); + hline(0, 82, 135, C_DGRAY); + } + + // 32-step piano roll - two rows of 16 cells (6×12 px, 2 px gaps) + // Row 0: steps 0-15, Row 1: steps 16-31 + // The pattern itself changes on regen, density, or scale edits even when the + // playback position does not, so those also invalidate the step cells. + if (fullRedraw || c.step != ts.currentStep || c.seed != ts.seed || + c.playing != ts.playing || c.patDensity != ts.density || c.patScale != ts.scale) { + for (int i = 0; i < 32; i++) { + int row = i / 16; + int col_i = i % 16; + uint8_t x = (uint8_t)(2 + col_i * 8); + uint8_t y = (uint8_t)(20 + row * 15); + bool active = ts.playing && (i == ts.currentStep); + bool hasNote = ts.steps[i].gate; + uint16_t col; + if (active) col = C_WHITE; + else if (hasNote) col = C_GREEN; + else col = C_DGRAY; + fillRect(x, y, 6, 12, col); + // Accent dot + if (ts.steps[i].accent && !active) + fillRect((uint8_t)(x+2), (uint8_t)(y+1), 2, 2, C_YELLOW); + } + c.step = ts.currentStep; + c.seed = ts.seed; + c.patDensity = ts.density; + c.patScale = ts.scale; + } + + if (fullRedraw || c.key != ts.key) { + fillRect(22, 54, 46, 10, C_BG); + int safeKey = ((ts.key % 12) + 12) % 12; + drawStr(22, 54, KEY_NAMES[safeKey], C_GREEN); + c.key = ts.key; + } + if (fullRedraw || c.scale != ts.scale) { + fillRect(96, 54, 39, 10, C_BG); + drawStr(96, 54, TB3_SCALE_NAMES[ts.scale % TB3_SCALE_NAMES_COUNT], C_CYAN); + c.scale = ts.scale; + } + if (fullRedraw || c.density != ts.density) { + fillRect(22, 68, 46, 10, C_BG); + char buf[4]; snprintf(buf, sizeof(buf), "%d", ts.density); + drawStr(22, 68, buf, C_YELLOW); + c.density = ts.density; + } + if (fullRedraw || c.bpm != ts.bpm) { + fillRect(96, 68, 39, 10, C_BG); + char buf[6]; snprintf(buf, sizeof(buf), "%d", ts.bpm); + drawStr(96, 68, buf, C_WHITE); + c.bpm = ts.bpm; + } + if (fullRedraw || c.playing != ts.playing) { + uint16_t col = ts.playing ? C_GREEN : C_DGRAY; + fillRect(0, 84, 135, 20, col); + drawStrCentred(0, 89, 135, ts.playing ? "PLAYING" : "STOPPED", C_WHITE, col); + c.playing = ts.playing; + } +} + +// -- BABY8 --------------------------------------------------------------- +// Layout: +// 0-16 header +// 18- 8 step cells, each 28px tall: +// note name, gate indicator, current step highlight + +static void drawBaby8(const MidiController::Baby8State& bs) { + auto& c = s_b8; + bool fullRedraw = s_dirty; + + if (fullRedraw) { + fillRect(0, 0, 135, 240, C_BG); + drawHeader(MidiMode::BABY8); + } + + for (int i = 0; i < 8; i++) { + bool stepChanged = fullRedraw || c.step != bs.currentStep || c.playing != bs.playing; + bool noteChanged = fullRedraw || c.note[i] != bs.steps[i].note; + bool gateChanged = fullRedraw || c.gate[i] != bs.steps[i].gate; + + if (!stepChanged && !noteChanged && !gateChanged) continue; + + uint8_t y = (uint8_t)(18 + i * 27); + bool isCurrent = bs.playing && (i == bs.currentStep); + bool gateOn = bs.steps[i].gate; + + uint16_t bgcol = isCurrent ? C_WHITE : (gateOn ? C_DGRAY : C_BG); + uint16_t fgcol = isCurrent ? C_BG : (gateOn ? C_GREEN : C_GRAY); + + fillRect(0, y, 135, 25, bgcol); + + // Step number + char sbuf[4]; snprintf(sbuf, sizeof(sbuf), "%d", i + 1); + drawStr(4, (uint8_t)(y + 8), sbuf, isCurrent ? C_BG : C_GRAY, bgcol); + + // Note name + char nbuf[8]; noteName(bs.steps[i].note, nbuf, sizeof(nbuf)); + drawStr(24, (uint8_t)(y + 4), nbuf, fgcol, bgcol, 2); + + // Gate dot + uint16_t dotcol = gateOn ? (isCurrent ? C_BG : C_GREEN) : C_DGRAY; + fillRect(115, (uint8_t)(y + 8), 12, 8, dotcol); + + hline(0, (uint8_t)(y + 25), 135, C_DGRAY); + + c.note[i] = bs.steps[i].note; + c.gate[i] = bs.steps[i].gate; + } + c.step = bs.currentStep; + c.playing = bs.playing; +} + +// -- EUCLID --------------------------------------------------------------- +// Layout: +// 0-16 header +// 18-33 voice tabs (8 mini boxes) +// 38-53 selected voice name (2x scale) +// 80-150 steps / events / rotation / note / BPM +// 156-174 play status, 176-236 knob legend + +static void drawEuclid(const MidiController::EuclidState& es) { + auto& c = s_euc; + bool fullRedraw = s_dirty; + + if (fullRedraw) { + fillRect(0, 0, 135, 240, C_BG); + drawHeader(MidiMode::EUCLID); + drawStr(2, 80, "STEPS", C_GRAY); + drawStr(2, 94, "EVENTS", C_GRAY); + drawStr(2, 108, "ROT", C_GRAY); + drawStr(2, 122, "NOTE", C_GRAY); + drawStr(2, 140, "BPM", C_GRAY); + hline(0, 154, 135, C_DGRAY); + } + + // Capture voice-changed flag before updating the cache + bool voiceChanged = (c.voice != es.selectedVoice); + + // Selected voice index + int idx = es.selectedVoice & 7; + + // Voice tabs + if (fullRedraw || voiceChanged) { + for (int i = 0; i < 8; i++) { + uint8_t tx = (uint8_t)(i * 17); + uint16_t col = VOICE_COL[i]; + uint16_t bg = (i == idx) ? col : C_DGRAY; + fillRect(tx, 18, 15, 15, bg); + char vb[2] = { (char)('1' + i), 0 }; + drawChar((uint8_t)(tx + 4), 22, vb[0], + (i == idx) ? C_BG : C_GRAY, bg); + } + c.voice = es.selectedVoice; + } + + // Selected voice name + if (fullRedraw || voiceChanged) { + clearRow(36, 18); + char vbuf[16]; snprintf(vbuf, sizeof(vbuf), "VOICE %d", idx + 1); + drawStrCentred(0, 38, 135, vbuf, VOICE_COL[idx], C_BG, 2); + } + + const auto& sv = es.voice[idx]; + + auto drawVal = [&](uint8_t y, int val, int& cached, uint16_t col) { + if (fullRedraw || cached != val || voiceChanged) { + fillRect(54, y, 81, 10, C_BG); + char b[8]; snprintf(b, sizeof(b), "%d", val); + drawStr(54, y, b, col); + cached = val; + } + }; + + drawVal(80, sv.steps, c.steps, C_YELLOW); + drawVal(94, sv.events, c.events, C_CYAN); + drawVal(108, sv.rotation, c.rot, UnitLcd::rgb888to565(0xAAFF88)); + + if (fullRedraw || c.note != sv.note || voiceChanged) { + fillRect(54, 122, 81, 10, C_BG); + char nb[8]; noteName(sv.note, nb, sizeof(nb)); + drawStr(54, 122, nb, UnitLcd::rgb888to565(0xFF88CC)); + c.note = sv.note; + } + + if (fullRedraw || c.bpm != es.bpm) { + fillRect(54, 140, 81, 10, C_BG); + char b[8]; snprintf(b, sizeof(b), "%d", es.bpm); + drawStr(54, 140, b, C_WHITE); + c.bpm = es.bpm; + } + + if (fullRedraw || c.playing != es.playing) { + uint16_t col = es.playing ? C_RED : C_DGRAY; + fillRect(0, 156, 135, 18, col); + drawStrCentred(0, 160, 135, es.playing ? "PLAYING" : "STOPPED", C_WHITE, col); + c.playing = es.playing; + } + + // Knob legend + if (fullRedraw || voiceChanged) { + uint8_t ly = 176; + struct { const char* k; const char* v; uint16_t col; } rows[] = { + {"K1","STEPS", C_YELLOW}, + {"K2","EVENTS", C_CYAN}, + {"K3","ROT", UnitLcd::rgb888to565(0xAAFF88)}, + {"K4","NOTE", UnitLcd::rgb888to565(0xFF88CC)}, + {"K5","BPM", C_WHITE}, + {"B", "VOICE", VOICE_COL[idx]}, + }; + for (auto& r : rows) { + fillRect(0, ly, 135, 10, C_BG); + drawStr(2, ly, r.k, C_GRAY); + drawStr(22, ly, r.v, r.col); + ly = (uint8_t)(ly + 10); + } + } +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +bool lcdInit(UnitPaHub* hub) { + s_hub = hub; + if (!s_hub) return false; + + // findGroveI2cDevice() (GroveLookup.h), not a hardcoded "i2c1" name - the grove port device + // is a mode-switchable I2C/UART router, not an I2C controller itself, so device_get_by_name + // on the router's own name crashes (wrong driver data layout for i2c_controller_*() calls). + Device* i2cDev = findGroveI2cDevice(); + if (!i2cDev) { + ESP_LOGE(TAG, "grove0_i2c not found"); + return false; + } + s_i2cDev = i2cDev; + + // Find the LCD by probing every PaHub channel - UnitLcd::begin() + // itself probes the I2C address and returns false if nothing answers, so trying each channel + // in turn and keeping the first hit is safe/cheap (6 channels, worst case). + s_lcdChannel = UnitPaHub::NO_CHANNEL; + for (uint8_t ch = 0; ch < UnitPaHub::NUM_CHANNELS; ch++) { + if (!s_hub->select(ch)) continue; + if (s_lcd.begin(i2cDev, UnitLcd::DEFAULT_ADDR)) { + s_lcdChannel = ch; + break; + } + } + if (s_lcdChannel == UnitPaHub::NO_CHANNEL) { + ESP_LOGW(TAG, "LCD unit not found on any PaHub channel"); + s_i2cDev = nullptr; + return false; + } + s_lcd.setRotation(0); // portrait 135×240 + s_lcd.setBrightness(180); + s_lcd.fillScreen(C_BG); + ESP_LOGI(TAG, "LCD ready on PaHub CH%d", (int)s_lcdChannel); + return true; +} + +void lcdStop() { + if (s_lcd.isPresent() && selectLcd()) { + s_lcd.setBrightness(0); + s_lcd.fillScreen(C_BG); + } + s_i2cDev = nullptr; + s_lcdChannel = UnitPaHub::NO_CHANNEL; +} + +bool lcdIsPresent() { + return s_lcd.isPresent(); +} + +void lcdSetMode(MidiMode mode) { + s_mode = mode; + s_dirty = true; + // Reset all caches so next lcdUpdate does a full redraw + s_raga = {}; s_raga.raga = -1; + s_dim = {}; s_dim.a = -1; + s_tb3 = {}; s_tb3.step = -1; + s_b8 = {}; for (auto& n : s_b8.note) n = -1; + s_euc = {}; s_euc.voice = -1; +} + +void lcdUpdate(MidiController* mc) { + if (!s_lcd.isPresent() || !s_hub) return; + if (!selectLcd()) return; + + switch (s_mode) { + case MidiMode::RAGA: drawRaga (mc->raga_); break; + case MidiMode::DIMENSIONS: drawDimensions(mc->dim_); break; + case MidiMode::TB3PO: drawTb3po (mc->tb3po_); break; + case MidiMode::BABY8: drawBaby8 (mc->baby8_); break; + case MidiMode::EUCLID: drawEuclid (mc->euclid_);break; + default: { + if (s_dirty) { + fillRect(0, 0, 135, 240, C_BG); + drawHeader(s_mode); + } + break; + } + } + s_dirty = false; +} diff --git a/Apps/MidiController/main/Source/LcdDisplay.h b/Apps/MidiController/main/Source/LcdDisplay.h new file mode 100644 index 0000000..5881271 --- /dev/null +++ b/Apps/MidiController/main/Source/LcdDisplay.h @@ -0,0 +1,49 @@ +/* + * LcdDisplay.h - per-mode LCD content for the M5Stack LCD Unit (135×240) + * + * The LCD unit usually sits on PaHub CH5, but lcdInit() probes every channel. + * All public functions select the discovered channel, draw, then leave it + * selected. EncoderInput re-selects the encoder channel before it reads, so + * there is no conflict as long as they don't run concurrently - both are + * called from the same LVGL timer thread. + * + * Layout per mode (portrait 135×240, white text on dark background): + * + * HOME / idle: + * Full-screen mode name + "MIDI CTRL" branding + * + * RAGA: + * Large raga name, tala, root note, BPM, play/drone status + * + * DIMENSIONS: + * A B C D values as a 2×2 grid with colour bars, equation, BPM + * + * TB3PO: + * 32-step piano-roll mini view (current step highlighted), key, scale, BPM + * + * BABY8: + * 8-step note grid - note name per step, current step highlighted, gates + * + * EUCLID: + * Selected voice name + params (steps/events/rotation/note), BPM + */ +#pragma once + +#include +#include +#include "MidiController.h" + +// Initialise LCD subsystem. Pass the already-initialised PaHub. +// Returns false if the LCD unit is not found on any PaHub channel. +bool lcdInit(UnitPaHub* hub); + +// Release resources (called from encoderStop). +void lcdStop(); + +bool lcdIsPresent(); + +// Called from switchToMode - clears screen and draws static mode chrome. +void lcdSetMode(MidiMode mode); + +// Called from each dispatch function after state changes. +void lcdUpdate(MidiController* mc); diff --git a/Apps/MidiController/main/Source/MidiController.cpp b/Apps/MidiController/main/Source/MidiController.cpp new file mode 100644 index 0000000..fe22ebe --- /dev/null +++ b/Apps/MidiController/main/Source/MidiController.cpp @@ -0,0 +1,965 @@ +#include "MidiController.h" +#include "LcdDisplay.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +static constexpr auto* TAG = "MidiController"; + +MidiController* MidiController::s_instance = nullptr; + +// --------------------------------------------------------------------------- +// Theme colors +// --------------------------------------------------------------------------- + +static lv_color_t MC_WARNING() { return lv_color_hex(0xFFE000); } +static lv_color_t MC_ERROR() { return lv_color_hex(0xFF2020); } + +// MIDI note numbers for the four drum tracks (KICK, SNARE, HIHAT, OPEN) +static constexpr uint8_t DRUM_NOTES[] = { 36, 38, 42, 46 }; + +// Per-track colors for BEATS mode (KICK, SNARE, HIHAT, OPEN) +static lv_color_t TRACK_COLOR(int t) { + switch (t) { + case 0: return MC_ERROR(); + case 1: return MC_WARNING(); + case 2: return MC_PRIMARY(); + case 3: return lv_color_hex(0xCC44FF); + default: return MC_SURFACE(); + } +} + +// --------------------------------------------------------------------------- +// Scaling helper - always based on display width, never parent +// --------------------------------------------------------------------------- + + +static inline lv_coord_t dispW() { + return (lv_coord_t)lv_display_get_horizontal_resolution(nullptr); +} + +static inline lv_coord_t dispH() { + return (lv_coord_t)lv_display_get_vertical_resolution(nullptr); +} + +// --------------------------------------------------------------------------- +// Style helpers +// --------------------------------------------------------------------------- + +static void styleContainer(lv_obj_t* obj, lv_color_t bg = {}, bool transparent = true) { + if (transparent) { + lv_obj_set_style_bg_opa(obj, LV_OPA_TRANSP, 0); + } else { + lv_obj_set_style_bg_color(obj, bg, 0); + lv_obj_set_style_bg_opa(obj, LV_OPA_COVER, 0); + } + lv_obj_set_style_border_width(obj, 0, 0); + lv_obj_set_style_pad_all(obj, 0, 0); + lv_obj_set_scrollable(obj, false); +} + +// --------------------------------------------------------------------------- +// MIDI transport - BLE (MediaKeys-style radio management) or USB, chosen from the transport +// selector overlay before the toolbar switch is turned on. sendMidi() dispatches to whichever +// one is currently running (only one of bleRunning_/usbRunning_ is ever true). +// --------------------------------------------------------------------------- + +// Only reached via handleTransportToggle() (radio already on) or handleBtEvent() (radio just +// turned on after handleTransportToggle() requested it) - btDevice_ is always the ref'd pointer +// device_get_first_by_type() gave handleTransportToggle() in both cases, so every path that +// clears it here must device_put() first. +void MidiController::startBleMidi() { + radioEnabling_ = false; + struct Device* midiDev = bluetooth_midi_get(); + if (!midiDev) { + ESP_LOGE(TAG, "No BLE MIDI device after radio on"); + if (btDevice_) { + unsubscribeBtEvents(); + device_put(btDevice_); + } + btDevice_ = nullptr; + bleRunning_ = false; + failTransportStart(); + return; + } + error_t startErr = bluetooth_midi_start(midiDev); + device_put(midiDev); // bluetooth_midi_get() returns a referenced device + if (startErr != ERROR_NONE) { + ESP_LOGW(TAG, "bluetooth_midi_start failed"); + if (btDevice_) { + unsubscribeBtEvents(); + device_put(btDevice_); + } + btDevice_ = nullptr; + bleRunning_ = false; + failTransportStart(); + return; + } + bleRunning_ = true; + ESP_LOGI(TAG, "BLE MIDI started"); +} + +void MidiController::stopBleMidi() { + if (bleRunning_) { + sendMidi(0xB0, 123, 0); // CC 123 = All Notes Off + struct Device* midiDev = bluetooth_midi_get(); + if (midiDev) { + bluetooth_midi_stop(midiDev); + device_put(midiDev); // bluetooth_midi_get() returns a referenced device + } + bleRunning_ = false; + } +} + +void MidiController::startUsbMidi() { + struct Device* dev = usb_midi_device_get(); + if (!dev) { + ESP_LOGE(TAG, "No USB MIDI device available"); + failTransportStart(); + return; + } + usb_midi_device_set_name(dev, "TT MIDI"); + error_t err = usb_midi_device_start(dev); + device_put(dev); + if (err != ERROR_NONE) { + ESP_LOGW(TAG, "usb_midi_device_start failed: %d (slot busy?)", (int)err); + failTransportStart(); + return; + } + usbRunning_ = true; + ESP_LOGI(TAG, "USB MIDI started"); +} + +void MidiController::stopUsbMidi() { + if (usbRunning_) { + sendMidi(0xB0, 123, 0); // CC 123 = All Notes Off + struct Device* dev = usb_midi_device_get(); + if (dev) { + usb_midi_device_stop(dev); + device_put(dev); + } + usbRunning_ = false; + } +} + +void MidiController::handleBtEvent(const struct BtEvent& event) { + if (event.type == BT_EVENT_RADIO_STATE_CHANGED) { + ESP_LOGI(TAG, "BT radio state: %d", (int)event.radio_state); + if (event.radio_state == BT_RADIO_STATE_ON && radioEnabling_) { + startBleMidi(); + } else if (event.radio_state == BT_RADIO_STATE_OFF && bleRunning_) { + ESP_LOGI(TAG, "BT radio turned off externally, stopping MIDI"); + sendMidi(0xB0, 123, 0); // best-effort All Notes Off; the radio may already be down + bleRunning_ = false; + failTransportStart(); + } + } +} + +void MidiController::pollBtEvents() { + if (!btEventSubscribed_) return; + struct BtEvent event {}; + while (bluetooth_event_poll(&btEventSub_, &event) == ERROR_NONE) { + handleBtEvent(event); + } + // If the radio dropped externally, the event handler only updated the UI. + // Do not unsubscribe from inside the poll callback (that would destroy the + // subscription while the loop is still using it); clean up once the queue + // is drained and no enable request is in flight. + if (!bleRunning_ && !radioEnabling_ && btEventSubscribed_) { + unsubscribeBtEvents(); + if (btDevice_) { + device_put(btDevice_); + btDevice_ = nullptr; + } + radioWasOff_ = false; + } +} + +void MidiController::subscribeBtEvents() { + if (btEventSubscribed_ || btDevice_ == nullptr) return; + btEventSub_ = {}; + bluetooth_event_subscribe(btDevice_, &btEventSub_, eventGroup_); + btEventSubscribed_ = true; +} + +void MidiController::unsubscribeBtEvents() { + if (!btEventSubscribed_) return; + if (btDevice_ != nullptr) { + bluetooth_event_unsubscribe(btDevice_, &btEventSub_); + } + btEventSubscribed_ = false; +} + +void MidiController::failTransportStart() { + lvgl_lock(); + if (switchWidget_) lv_obj_remove_state(switchWidget_, LV_STATE_CHECKED); + if (transportBtn_) lv_obj_remove_state(transportBtn_, LV_STATE_DISABLED); + lvgl_unlock(); +} + +void MidiController::enableTransportButton() { + lvgl_lock(); + if (transportBtn_) lv_obj_remove_state(transportBtn_, LV_STATE_DISABLED); + lvgl_unlock(); +} + +// Runs on the LVGL task: only the UI changes here. Starting a transport can write to flash or NVS, +// which isn't possible on the LVGL task, so the app's task does that in applyTransport(). +void MidiController::handleTransportToggle(bool enabled) { + ESP_LOGI(TAG, "Transport toggle: %s", enabled ? "ON" : "OFF"); + if (enabled) { + // Close the transport chooser if it is still open + if (transportOverlay_) { + lv_obj_del(transportOverlay_); + transportOverlay_ = nullptr; + // First launch: the chooser was the only content. Open HOME before starting, + // otherwise the switch would leave the content area empty. + if (currentViewRoot_ == nullptr) { + currentMode_ = MidiMode::HOME; + buildHomeView(contentArea_); + } + } + if (transportBtn_) lv_obj_add_state(transportBtn_, LV_STATE_DISABLED); + } + transportRequest_.store(enabled ? 1 : 0); + task_event_group_signal(eventGroup_, transportRequestBit_); +} + +// Runs on the app's task +void MidiController::applyTransport(bool enabled) { + radioEnabling_ = false; + if (enabled) { + if (activeTransport_ == MidiTransport::USB) { + startUsbMidi(); + return; + } + + // bluetooth_find_first_ready_device() only finds an already-started device, so it can't + // be used here: on a cold boot the BT device may not be started yet so this toggle would silently do nothing. + // device_get_first_by_type() finds the device regardless of ready state; start it on demand. + if (device_get_first_by_type(&BLUETOOTH_TYPE, &btDevice_) != ERROR_NONE) { + ESP_LOGE(TAG, "No Bluetooth device found"); + failTransportStart(); + return; + } + if (!device_is_ready(btDevice_) && device_start(btDevice_) != ERROR_NONE) { + ESP_LOGE(TAG, "Failed to start Bluetooth device"); + device_put(btDevice_); + btDevice_ = nullptr; + failTransportStart(); + return; + } + bluetooth_set_device_name(btDevice_, "TT MIDI"); + subscribeBtEvents(); + + enum BtRadioState radioState; + bluetooth_get_radio_state(btDevice_, &radioState); + if (radioState == BT_RADIO_STATE_ON) { + radioWasOff_ = false; + startBleMidi(); + } else { + ESP_LOGI(TAG, "BT radio not on (state=%d), enabling...", (int)radioState); + radioWasOff_ = true; + radioEnabling_ = true; + bluetooth_set_radio_enabled(btDevice_, true); + } + } else { + if (activeTransport_ == MidiTransport::USB) { + stopUsbMidi(); + } else { + stopBleMidi(); + if (btDevice_) { + unsubscribeBtEvents(); + if (radioWasOff_) bluetooth_set_radio_enabled(btDevice_, false); + device_put(btDevice_); + btDevice_ = nullptr; + } + radioWasOff_ = false; + } + enableTransportButton(); + } +} + +void MidiController::attachEventGroup(struct TaskEventGroup* group) { + eventGroup_ = group; + task_event_group_claim_bit(group, &transportRequestBit_); +} + +void MidiController::processEvents() { + const int request = transportRequest_.exchange(-1); + if (request >= 0) { + applyTransport(request == 1); + } + pollBtEvents(); +} + +void MidiController::sendMidi(uint8_t cmd, uint8_t d1, uint8_t d2) { + uint8_t msg[3] = { cmd, d1, d2 }; + if (bleRunning_) { + struct Device* dev = bluetooth_midi_get(); + if (dev) { + bluetooth_midi_send(dev, msg, 3); + device_put(dev); // bluetooth_midi_get() returns a referenced device + } + } else if (usbRunning_) { + struct Device* dev = usb_midi_device_get(); + if (dev) { + usb_midi_device_send(dev, msg, 3); + device_put(dev); + } + } +} + +void MidiController::stopAllNotes() { + for (int i = 0; i < 128; i++) { + sendMidi(0x80, (uint8_t)i, 0); + } +} + +// --------------------------------------------------------------------------- +// Transport selector +// --------------------------------------------------------------------------- + +void MidiController::updateTransportButton() { + if (!transportBtn_) return; + lv_obj_t* lbl = lv_obj_get_child(transportBtn_, 0); + if (lbl) lv_label_set_text(lbl, activeTransport_ == MidiTransport::USB ? "USB" : "BLE"); +} + +void MidiController::showTransportSelect() { + if (transportOverlay_ || bleRunning_ || usbRunning_) return; + transportOverlay_ = buildTransportSelectOverlay(contentArea_); +} + +lv_obj_t* MidiController::buildTransportSelectOverlay(lv_obj_t* parent) { + const lv_font_t* fontSmall = lvgl_get_text_font(FONT_SIZE_SMALL); + const lv_font_t* fontDefault = lvgl_get_text_font(FONT_SIZE_DEFAULT); + const lv_font_t* fontLarge = lvgl_get_text_font(FONT_SIZE_LARGE); + + lv_obj_t* root = mcContainer(parent); + lv_obj_set_size(root, LV_PCT(100), LV_PCT(100)); + lv_obj_set_flex_flow(root, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(root, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_set_style_bg_color(root, MC_BG(), 0); + lv_obj_set_style_bg_opa(root, LV_OPA_COVER, 0); + lv_obj_set_style_pad_all(root, 32, 0); + lv_obj_set_style_pad_gap(root, 18, 0); + lv_obj_set_scrollable(root, false); + lv_obj_set_clickable(root, true); // swallow taps on empty space + lv_obj_move_foreground(root); + + mcLabel(root, "SELECT MIDI TRANSPORT", fontLarge, MC_PRIMARY()); + mcLabel(root, "Choose how this app sends MIDI.", fontDefault, MC_LABEL()); + + lv_obj_t* row = mcContainer(root); + lv_obj_set_size(row, LV_PCT(100), 280); + lv_obj_set_flex_flow(row, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(row, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_set_style_pad_gap(row, 24, 0); + + struct CardSpec { + const char* title; + const char* subtitle; + MidiTransport transport; + }; + static const CardSpec CARDS[] = { + { "BLUETOOTH LE", "Wireless MIDI", MidiTransport::BLE }, + { "USB", "Wired MIDI device", MidiTransport::USB }, + }; + + for (const CardSpec& spec : CARDS) { + bool selected = (activeTransport_ == spec.transport); + + lv_obj_t* card = mcButton(row); + lv_obj_set_size(card, 420, 260); + lv_obj_set_style_bg_color(card, selected ? MC_SURFACE() : MC_PANEL(), 0); + lv_obj_set_style_bg_opa(card, LV_OPA_COVER, 0); + lv_obj_set_style_radius(card, 20, 0); + lv_obj_set_style_border_width(card, selected ? 3 : 1, 0); + lv_obj_set_style_border_color(card, selected ? MC_PRIMARY() : lv_color_hex(0x223344), 0); + lv_obj_set_style_pad_all(card, 20, 0); + lv_obj_set_style_pad_gap(card, 10, 0); + lv_obj_set_flex_flow(card, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(card, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_set_user_data(card, (void*)(intptr_t)(spec.transport == MidiTransport::USB ? 1 : 0)); + lv_obj_add_event_cb(card, cbTransportCard, LV_EVENT_CLICKED, this); + + mcLabel(card, spec.title, fontLarge, selected ? MC_PRIMARY() : MC_TEXT()); + mcLabel(card, spec.subtitle, fontSmall, MC_LABEL()); + if (selected) mcLabel(card, "CURRENT", fontSmall, MC_PRIMARY()); + } + + if (currentViewRoot_ != nullptr) { + mcLabel(root, "The toolbar switch starts and stops sending.", fontSmall, MC_LABEL()); + mcBtn(root, "CANCEL", 160, MC_BTN_H, cbTransportCancel, this); + } else { + mcLabel(root, "Choose a transport to open the controller.", fontSmall, MC_LABEL()); + } + + return root; +} + +void MidiController::cbTransportSelect(lv_event_t* e) { + auto* mc = static_cast(lv_event_get_user_data(e)); + if (mc) mc->showTransportSelect(); +} + +void MidiController::cbTransportCard(lv_event_t* e) { + auto* mc = static_cast(lv_event_get_user_data(e)); + lv_obj_t* card = lv_event_get_target_obj(e); + if (!mc || !card) return; + + intptr_t id = (intptr_t)lv_obj_get_user_data(card); + mc->activeTransport_ = (id == 1) ? MidiTransport::USB : MidiTransport::BLE; + mc->updateTransportButton(); + + if (mc->transportOverlay_) { + lv_obj_del(mc->transportOverlay_); + mc->transportOverlay_ = nullptr; + } + + // First launch: there is no view behind the overlay yet - open the home grid. + if (mc->currentViewRoot_ == nullptr) { + mc->currentMode_ = MidiMode::HOME; + mc->buildHomeView(mc->contentArea_); + } +} + +void MidiController::cbTransportCancel(lv_event_t* e) { + auto* mc = static_cast(lv_event_get_user_data(e)); + if (!mc || !mc->transportOverlay_) return; + lv_obj_del(mc->transportOverlay_); + mc->transportOverlay_ = nullptr; +} + +// --------------------------------------------------------------------------- +// Mode timer management +// --------------------------------------------------------------------------- + +void MidiController::stopAllModeTimers() { + if (arpTimer_) { lv_timer_delete(arpTimer_); arpTimer_ = nullptr; } + if (lfoTimer_) { lv_timer_delete(lfoTimer_); lfoTimer_ = nullptr; } + if (zenTimer_) { lv_timer_delete(zenTimer_); zenTimer_ = nullptr; } + if (dropTimer_) { lv_timer_delete(dropTimer_); dropTimer_ = nullptr; } + if (rngTimer_) { lv_timer_delete(rngTimer_); rngTimer_ = nullptr; } + if (ragaTimer_) { lv_timer_delete(ragaTimer_); ragaTimer_ = nullptr; } + if (dimTimer_) { lv_timer_delete(dimTimer_); dimTimer_ = nullptr; } + if (baby8Timer_) { lv_timer_delete(baby8Timer_); baby8Timer_ = nullptr; } + if (tb3poTimer_) { lv_timer_delete(tb3poTimer_); tb3poTimer_ = nullptr; } + if (euclidTimer_){ lv_timer_delete(euclidTimer_);euclidTimer_= nullptr; } +} + +// --------------------------------------------------------------------------- +// Navigation +// --------------------------------------------------------------------------- + +void MidiController::switchToMode(MidiMode mode) { + // Close the transport chooser if it is still open + if (transportOverlay_) { + lv_obj_del(transportOverlay_); + transportOverlay_ = nullptr; + } + + // Stop active mode timers + stopAllModeTimers(); + + // Stop sequencer if leaving BEATS + if (currentMode_ == MidiMode::BEATS && seq_ && seq_->isPlaying()) { + seq_->stop(); + beats_.playing = false; + } + + // Send All-Notes-Off (CC 123) - single packet instead of 128 note-offs + sendMidi(0xB0, 123, 0); + + // Null all LVGL widget pointers BEFORE deleting the tree, so any callbacks + // fired during deletion (LV_EVENT_DELETE, pending timers) see null pointers. + // Data fields (octave, bpm, scale, etc.) are preserved across mode switches. + zen_.canvas = nullptr; zen_.statusLbl = nullptr; zen_.ballCntLbl = nullptr; zen_.octLbl = nullptr; + for (auto& b : zen_.ballObj) b = nullptr; + + drop_.canvas = nullptr; drop_.statusLbl = nullptr; + drop_.modeLbl = nullptr; drop_.octLbl = nullptr; drop_.modeBtn = nullptr; + for (auto& b : drop_.ballObj) b = nullptr; + for (auto& l : drop_.platLbl) l = nullptr; + + xy_.canvas = nullptr; xy_.indicator = nullptr; + xy_.xCCLbl = nullptr; xy_.yCCLbl = nullptr; xy_.valLbl = nullptr; + + for (auto& k : keys_.whiteKey) k = nullptr; + for (auto& k : keys_.blackKey) k = nullptr; + keys_.pianoArea = nullptr; + keys_.octLbl = nullptr; keys_.keyLbl = nullptr; + keys_.scaleLbl = nullptr; keys_.statusLbl = nullptr; + + beats_.playBtn = nullptr; beats_.playLbl = nullptr; beats_.bpmLbl = nullptr; + for (int t = 0; t < Sequencer::NUM_TRACKS; t++) + for (int s = 0; s < Sequencer::NUM_STEPS; s++) + beats_.stepBtn[t][s] = nullptr; + + for (auto& k : arp_.pianoKey) k = nullptr; + arp_.patternLbl = nullptr; arp_.chordLbl = nullptr; + arp_.speedLbl = nullptr; arp_.bpmLbl = nullptr; + arp_.octLbl = nullptr; arp_.pianoOctLbl = nullptr; + arp_.statusLbl = nullptr; arp_.noteLbl = nullptr; + + for (auto& b : chord_.chordBtn) b = nullptr; + chord_.octLbl = nullptr; chord_.scaleLbl = nullptr; + + for (int r = 0; r < 5; r++) + for (int c = 0; c < 8; c++) + grid_.cell[r][c] = nullptr; + grid_.octLbl = nullptr; grid_.noteLbl = nullptr; + + lfo_.canvas = nullptr; lfo_.startBtn = nullptr; lfo_.startLbl = nullptr; + lfo_.statusLbl = nullptr; lfo_.rateLbl = nullptr; lfo_.waveLbl = nullptr; + lfo_.amountLbl = nullptr; lfo_.ccLbl = nullptr; lfo_.valueLbl = nullptr; + + rng_.playBtn = nullptr; rng_.playLbl = nullptr; rng_.keyLbl = nullptr; + rng_.scaleLbl = nullptr; rng_.minOctLbl = nullptr; rng_.maxOctLbl = nullptr; + rng_.probLbl = nullptr; rng_.bpmLbl = nullptr; rng_.subdivLbl = nullptr; + rng_.noteLbl = nullptr; + rng_.playing = false; rng_.currentNote = -1; + + raga_.ragaLbl = nullptr; raga_.talaLbl = nullptr; raga_.rootLbl = nullptr; + raga_.bpmLbl = nullptr; raga_.statusLbl = nullptr; raga_.playBtn = nullptr; + raga_.playLbl = nullptr; raga_.droneBtn = nullptr; raga_.droneLbl = nullptr; + raga_.ragaBigLbl= nullptr; raga_.talaBigLbl = nullptr; + raga_.playing = false; raga_.droneOn = false; + if (raga_.droneActive) { sendMidi(0x80, raga_.droneNote, 0); raga_.droneActive = false; } + if (raga_.noteActive) { sendMidi(0x80, raga_.currentNote, 0); raga_.noteActive = false; } + + dim_.eqLbl = nullptr; dim_.aLbl = nullptr; dim_.bLbl = nullptr; + dim_.cLbl = nullptr; dim_.dLbl = nullptr; dim_.outLbl = nullptr; + dim_.octLbl = nullptr; dim_.rndLbl = nullptr; dim_.bpmLbl = nullptr; dim_.playLbl = nullptr; + dim_.playing = false; + if (dim_.lastNote >= 0) { sendMidi(0x80, (uint8_t)dim_.lastNote, 0); dim_.lastNote = -1; } + + for (int i = 0; i < 8; i++) { baby8_.stepBtn[i] = nullptr; baby8_.noteLbl[i] = nullptr; } + baby8_.bpmLbl = nullptr; baby8_.swingLbl = nullptr; baby8_.patLbl = nullptr; + baby8_.editNoteLbl = nullptr; baby8_.editVelLbl = nullptr; baby8_.playLbl = nullptr; + baby8_.playing = false; + if (baby8_.lastNote >= 0) { sendMidi(0x80, (uint8_t)baby8_.lastNote, 0); baby8_.lastNote = -1; } + + tb3po_.canvas = nullptr; tb3po_.densityLbl = nullptr; tb3po_.seedLbl = nullptr; + tb3po_.bpmLbl = nullptr; tb3po_.scaleLbl = nullptr; tb3po_.keyLbl = nullptr; + tb3po_.playLbl = nullptr; + tb3po_.playing = false; + if (tb3po_.lastNote >= 0) { sendMidi(0x80, (uint8_t)tb3po_.lastNote, 0); tb3po_.lastNote = -1; } + tb3po_.lastNoteStep = -1; + + euclid_.canvas = nullptr; euclid_.bpmLbl = nullptr; euclid_.stepsLbl = nullptr; + euclid_.eventsLbl= nullptr; euclid_.rotLbl = nullptr; euclid_.noteLbl = nullptr; + euclid_.voiceLbl = nullptr; euclid_.playLbl = nullptr; euclid_.tripLbl = nullptr; + euclid_.voiceTabRow = nullptr; + euclid_.playing = false; + + // Turn off encoder LEDs before building the new view + // (each view's dispatch sets its own colors on first poll) + encoderSetAllLeds(this, 0x000000); + + // On the next encoder poll, seed btnPrev from the live button state + // without firing any actions - prevents a held button from registering + // as a fresh press in the new view. + encoder_.btnSeedNext = true; + + // Discard any touch still in progress so the tap that triggered this mode + // switch doesn't land as a click on a button in the newly built view. + lv_indev_t* indev = lv_indev_get_next(nullptr); + while (indev) { + lv_indev_wait_release(indev); + indev = lv_indev_get_next(indev); + } + + // Destroy current view tree + if (currentViewRoot_) { + lv_obj_del(currentViewRoot_); + currentViewRoot_ = nullptr; + } + + currentMode_ = mode; + lcdSetMode(mode); // marks LCD dirty + lcdUpdate(this); // paint immediately (no-op if LCD not present) + + if (!contentArea_) return; + + switch (mode) { + case MidiMode::HOME: buildHomeView(contentArea_); break; + case MidiMode::KEYS: buildKeysView (this, contentArea_); break; + case MidiMode::BEATS: buildBeatsView(this, contentArea_); break; + case MidiMode::ZEN: buildZenView (this, contentArea_); break; + case MidiMode::DROP: buildDropView (this, contentArea_); break; + case MidiMode::RNG: buildRngView (this, contentArea_); break; + case MidiMode::XY: buildXYPadView(this, contentArea_); break; + case MidiMode::ARP: buildArpView (this, contentArea_); break; + case MidiMode::GRID: buildGridView (this, contentArea_); break; + case MidiMode::CHORD: buildChordView (this, contentArea_); break; + case MidiMode::LFO: buildLfoView (this, contentArea_); break; + case MidiMode::RAGA: buildRagaView (this, contentArea_); break; + case MidiMode::DIMENSIONS: buildDimensionsView(this, contentArea_); break; + case MidiMode::BABY8: buildBaby8View (this, contentArea_); break; + case MidiMode::TB3PO: buildTb3poView (this, contentArea_); break; + case MidiMode::EUCLID: buildEuclidView (this, contentArea_); break; + default: buildHomeView(contentArea_); break; + } + + // Re-arm wait_release after view construction so any touch that was in + // flight when we entered this function cannot land on buttons in the new view. + { + lv_indev_t* indev = lv_indev_get_next(nullptr); + while (indev) { + lv_indev_wait_release(indev); + indev = lv_indev_get_next(indev); + } + } +} + +// --------------------------------------------------------------------------- +// HOME view - 2×5 mode selection grid +// --------------------------------------------------------------------------- + +static const char* MODE_LABELS[] = { + "KEYS", "BEATS", "ZEN", "DROP", "RNG", + "XY", "ARP", "GRID", "CHORD", "LFO", + "RAGA", "DIM", "BABY8", "TB3PO", "EUCLID" +}; + +static lv_color_t MODE_COLOR(int idx) { + static const uint32_t COLORS[] = { + 0xFF3333, 0xFF8800, 0xFFFF00, 0x33CC33, 0x3399FF, + 0xAA33FF, 0xFF33CC, 0x00FFFF, 0xFF9944, 0x99FF44, + 0xFF8844, 0x44BBFF, 0xFF88FF, 0x88FF44, 0xFF4488 + }; + if (idx >= 0 && idx < 15) return lv_color_hex(COLORS[idx]); + return lv_color_hex(0x888888); +} + +void MidiController::cbModeBtn(lv_event_t* e) { + if (!s_instance) return; + int idx = (int)(intptr_t)lv_event_get_user_data(e); + if (idx >= 0 && idx < (int)MidiMode::COUNT) { + s_instance->switchToMode((MidiMode)idx); + } +} + +void MidiController::buildHomeView(lv_obj_t* parent) { + lv_coord_t W = dispW(); + lv_coord_t H = dispH(); + + lv_obj_t* root = mcContainer(parent); + lv_obj_set_size(root, LV_PCT(100), LV_PCT(100)); + styleContainer(root); + lv_obj_set_flex_flow(root, LV_FLEX_FLOW_COLUMN); + lv_obj_set_style_bg_color(root, MC_BG(), 0); + lv_obj_set_style_bg_opa(root, LV_OPA_COVER, 0); + lv_obj_set_style_pad_all(root, 16, 0); + lv_obj_set_style_pad_gap(root, 16, 0); + currentViewRoot_ = root; + + // Button grid - 3 rows of 5, each row flex_grow=1 so buttons always fill height + lv_coord_t gap = 8; + // Root has pad_all 16 on each side; 5 buttons share the rest minus 4 gaps. + lv_coord_t btnW = (W - 16 * 2 - gap * 4) / 5; + const lv_font_t* btnFont = W < 400 ? lvgl_get_text_font(FONT_SIZE_SMALL) + : lvgl_get_text_font(FONT_SIZE_DEFAULT); + + for (int row = 0; row < 3; row++) { + lv_obj_t* rowObj = mcContainer(root); + lv_obj_set_width(rowObj, LV_PCT(100)); + lv_obj_set_flex_grow(rowObj, 1); + lv_obj_set_flex_flow(rowObj, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(rowObj, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + styleContainer(rowObj); + lv_obj_set_style_bg_color(rowObj, MC_BG(), 0); + lv_obj_set_style_bg_opa(rowObj, LV_OPA_COVER, 0); + lv_obj_set_style_pad_gap(rowObj, gap, 0); + lv_obj_set_style_pad_all(rowObj, 0, 0); + + for (int col = 0; col < 5; col++) { + int i = row * 5 + col; + lv_obj_t* btn = mcButton(rowObj); + lv_obj_set_size(btn, btnW, LV_PCT(100)); + lv_obj_set_style_bg_color(btn, MC_SURFACE(), 0); + lv_obj_set_style_bg_opa(btn, LV_OPA_COVER, 0); + lv_obj_set_style_border_color(btn, MODE_COLOR(i), 0); + lv_obj_set_style_border_width(btn, 8, 0); + lv_obj_set_style_radius(btn, 24, 0); + lv_obj_set_style_pad_all(btn, 8, 0); + lv_obj_add_event_cb(btn, cbModeBtn, LV_EVENT_CLICKED, + (void*)(intptr_t)i); + + // Color accent bar at top of button + lv_obj_t* bar = mcContainer(btn); + lv_obj_set_size(bar, LV_PCT(100), 16); + lv_obj_set_style_bg_color(bar, MODE_COLOR(i), 0); + lv_obj_set_style_bg_opa(bar, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(bar, 0, 0); + lv_obj_set_style_radius(bar, 0, 0); + lv_obj_set_clickable(bar, false); + lv_obj_set_align(bar, LV_ALIGN_TOP_MID); + lv_obj_set_scrollable(bar, false); + + lv_obj_t* lbl = lv_label_create(btn); + lv_label_set_text(lbl, MODE_LABELS[i]); + lv_obj_set_style_text_color(lbl, MC_TEXT(), 0); + lv_obj_set_style_text_font(lbl, btnFont, 0); + lv_obj_center(lbl); + lv_obj_set_clickable(lbl, false); + } + } + (void)H; +} + +// --------------------------------------------------------------------------- +// UI timer - BEATS step indicator +// --------------------------------------------------------------------------- + +void MidiController::cbTransportSwitch(lv_event_t* e) { + if (lv_event_get_code(e) != LV_EVENT_VALUE_CHANGED) return; + auto* mc = static_cast(lv_event_get_user_data(e)); + if (!mc) return; + bool on = lv_obj_has_state(lv_event_get_target_obj(e), LV_STATE_CHECKED); + mc->handleTransportToggle(on); +} + +void MidiController::cbUiTimer(lv_timer_t* t) { + auto* mc = static_cast(lv_timer_get_user_data(t)); + if (!mc) return; + + // BEATS: update step highlight + if (mc->currentMode_ == MidiMode::BEATS && mc->seq_) { + int step = mc->seqPlayStep.load(); + int lastStep = mc->seqLastStep.load(); + if (step != lastStep) { + int prev = lastStep; + mc->seqLastStep.store(step); + + // De-highlight previous step across all tracks + if (prev >= 0 && prev < Sequencer::NUM_STEPS) { + for (int t = 0; t < Sequencer::NUM_TRACKS; t++) { + lv_obj_t* btn = mc->beats_.stepBtn[t][prev]; + if (btn) { + bool active = mc->seq_->getStep(t, prev); + lv_obj_set_style_bg_color(btn, + active ? TRACK_COLOR(t) : MC_SURFACE(), LV_PART_MAIN); + lv_obj_set_style_border_width(btn, 0, LV_PART_MAIN); + } + } + } + // Highlight current step + if (step >= 0 && step < Sequencer::NUM_STEPS) { + for (int t = 0; t < Sequencer::NUM_TRACKS; t++) { + lv_obj_t* btn = mc->beats_.stepBtn[t][step]; + if (btn) { + lv_obj_set_style_bg_color(btn, lv_color_hex(0xFFFFFF), LV_PART_MAIN); + lv_obj_set_style_border_width(btn, 4, LV_PART_MAIN); + lv_obj_set_style_border_color(btn, MC_PRIMARY(), LV_PART_MAIN); + } + } + } + } + + // Check note-offs + uint64_t now = (uint64_t)esp_timer_get_time(); + for (int t = 0; t < Sequencer::NUM_TRACKS; t++) { + if (mc->beats_.noteOffAtUs[t] > 0 && now >= mc->beats_.noteOffAtUs[t]) { + mc->sendMidi(0x80, DRUM_NOTES[t], 0); + mc->beats_.noteOffAtUs[t] = 0; + } + } + } +} + +// --------------------------------------------------------------------------- +// App lifecycle +// --------------------------------------------------------------------------- + +void MidiController::detectBleState() { + // Check if BLE MIDI is already active (e.g. system BT was on before app launched). + // If so, mark bleRunning_ so the app is ready to send. No USB + // equivalent - unlike BLE's system-wide radio, USB device mode has no "already running from + // some other app" case worth auto-detecting (only one class can hold the slot at a time, and + // if MIDI already holds it, that's almost certainly this same app from a previous session). + // + // Uses the same ref-counted device_get_first_by_type() lookup handleTransportToggle() uses + // (rather than bluetooth_find_first_ready_device(), which would also work here since this + // check only fires if the radio's already on) so btDevice_ is always ref'd whenever non-null, + // uniformly - every btDevice_ = nullptr elsewhere in this file can then unconditionally + // device_put() first without needing to track which code path originally acquired it. + if (device_get_first_by_type(&BLUETOOTH_TYPE, &btDevice_) == ERROR_NONE) { + enum BtRadioState radioState; + bluetooth_get_radio_state(btDevice_, &radioState); + if (radioState == BT_RADIO_STATE_ON) { + // Radio is already on - register callback and start MIDI immediately. + radioWasOff_ = false; + bluetooth_set_device_name(btDevice_, "TT MIDI"); + subscribeBtEvents(); + struct Device* midiDev = bluetooth_midi_get(); + bool midiStarted = false; + if (midiDev) { + midiStarted = (bluetooth_midi_start(midiDev) == ERROR_NONE); + device_put(midiDev); // bluetooth_midi_get() returns a referenced device + } + if (midiStarted) { + bleRunning_ = true; + activeTransport_ = MidiTransport::BLE; + } else { + // MIDI start failed - clean up subscription + unsubscribeBtEvents(); + device_put(btDevice_); + btDevice_ = nullptr; + } + } else { + device_put(btDevice_); + btDevice_ = nullptr; + } + } +} + +void MidiController::createWidgets(lv_obj_t* parent, void* userData) { + auto* mc = static_cast(userData); + s_instance = mc; + mc->createWidgetsImpl(parent); +} + +void MidiController::createWidgetsImpl(lv_obj_t* parent) { + lv_obj_set_scrollable(parent, false); + lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN); + lv_obj_set_style_bg_color(parent, MC_BG(), 0); + lv_obj_set_style_bg_opa(parent, LV_OPA_COVER, 0); + + // Toolbar + toolbar_ = lvgl_toolbar_create(parent, "Midi Controller"); + lv_obj_align(toolbar_, LV_ALIGN_TOP_MID, 0, 0); + + // Transport selector button (BLE/USB), added before the switch so it sits to its left within + // the toolbar's action area. Opens the full-screen chooser; disabled while the switch is on so + // the transport can't change mid-stream. + transportBtn_ = lvgl_toolbar_add_text_button_action(toolbar_, "BLE", cbTransportSelect, this); + + switchWidget_ = lvgl_toolbar_add_switch_action(toolbar_); + lv_obj_add_event_cb(switchWidget_, cbTransportSwitch, LV_EVENT_VALUE_CHANGED, this); + + // Main wrapper + mainWrapper_ = mcContainer(parent); + lv_obj_set_width(mainWrapper_, LV_PCT(100)); + lv_obj_set_flex_grow(mainWrapper_, 1); + styleContainer(mainWrapper_); + lv_obj_set_style_bg_color(mainWrapper_, MC_BG(), 0); + lv_obj_set_style_bg_opa(mainWrapper_, LV_OPA_COVER, 0); + + // Content area (fills mainWrapper) + contentArea_ = mcContainer(mainWrapper_); + lv_obj_set_size(contentArea_, LV_PCT(100), LV_PCT(100)); + styleContainer(contentArea_); + lv_obj_set_style_bg_color(contentArea_, MC_BG(), 0); + lv_obj_set_style_bg_opa(contentArea_, LV_OPA_COVER, 0); + + // Create Sequencer + seq_ = std::make_unique(); + seq_->onStep = [](int /*step*/, int track) { + if (!s_instance) return; + static const uint32_t NOTE_OFF_US[] = { 200000, 150000, 50000, 300000 }; + s_instance->sendMidi(0x90, DRUM_NOTES[track], 100); + s_instance->beats_.noteOffAtUs[track] = + (uint64_t)esp_timer_get_time() + NOTE_OFF_US[track]; + }; + seq_->onTick = [](int step) { + if (s_instance) s_instance->seqPlayStep.store(step); + }; + + // detectBleState() found a BLE MIDI session that is already running (e.g. system BT was on + // before the app launched): the switch shows it + if (bleRunning_) { + lv_obj_add_state(switchWidget_, LV_STATE_CHECKED); + lv_obj_add_state(transportBtn_, LV_STATE_DISABLED); + } + + // Start HOME view, or show the transport chooser first (unless BLE was already running). + currentMode_ = MidiMode::HOME; + if (bleRunning_) { + buildHomeView(contentArea_); + } else { + showTransportSelect(); + } + + // UI timer + uiTimer_ = lv_timer_create(cbUiTimer, 20, this); + + // 8Encoder (optional hardware - graceful no-op if not present) + encoderInit(this); +} + +void MidiController::teardown() { + // Stop 8Encoder poll + turn off LEDs + encoderStop(this); + + // Stop UI timer first + if (uiTimer_) { lv_timer_delete(uiTimer_); uiTimer_ = nullptr; } + + // Stop mode timers + stopAllModeTimers(); + + // Stop sequencer + if (seq_) { + seq_->stop(); + seq_.reset(); + } + + // MIDI transport + BLE radio cleanup (MediaKeys pattern). + radioEnabling_ = false; + stopBleMidi(); + stopUsbMidi(); + if (btDevice_) { + unsubscribeBtEvents(); + if (radioWasOff_) bluetooth_set_radio_enabled(btDevice_, false); + device_put(btDevice_); + btDevice_ = nullptr; + } + radioWasOff_ = false; + + resetAllState(); + s_instance = nullptr; +} + +void MidiController::resetAllState() { + toolbar_ = nullptr; + transportBtn_ = nullptr; + transportOverlay_ = nullptr; + mainWrapper_ = nullptr; + switchWidget_ = nullptr; + contentArea_ = nullptr; + currentViewRoot_ = nullptr; + + keys_ = {}; keys_.octave = 4; + for (auto& n : keys_.lastNote) n = -1; + beats_ = {}; beats_.bpm = 120; + xy_ = {}; xy_.xCC = 74; xy_.yCC = 71; xy_.xVal = 64; xy_.yVal = 64; + arp_ = {}; arp_.bpm = 120; arp_.speedIdx = 1; arp_.pianoOctave = 4; + arp_.triggeredNote = -1; arp_.lastNote = -1; + grid_ = {}; for (auto& n : grid_.pressedNote) n = -1; + chord_ = {}; chord_.octave = 4; + lfo_ = {}; lfo_.rate = 1.0f; lfo_.amount = 64; lfo_.ccTarget = 1; lfo_.lastValue = 64; + zen_ = {}; zen_.octave = 4; + drop_ = {}; drop_.octave = 4; + rng_ = {}; rng_.bpm = 120; rng_.subdivision = 0; + rng_.minOctave = 3; rng_.maxOctave = 6; rng_.probability = 50; + raga_ = {}; raga_.rootNote = 48; raga_.bpm = 120; + dim_ = {}; dim_.equation = 1; dim_.a = 50; dim_.b = 50; dim_.c = 50; dim_.d = 50; + dim_.bpm = 120; dim_.t2 = 100; dim_.gran = 1.0f; + dim_.xOff = 64; dim_.yOff = 64; dim_.lastNote = -1; + baby8_ = {}; baby8_.bpm = 120; baby8_.swing = 50; baby8_.lastNote = -1; + for (int i = 0; i < 8; i++) { baby8_.steps[i].note = 60 + i; baby8_.steps[i].velocity = 100; baby8_.steps[i].gate = true; } + for (int p = 0; p < 8; p++) for (int s = 0; s < 8; s++) { baby8_.patterns[p][s].note = 60 + s; baby8_.patterns[p][s].velocity = 100; baby8_.patterns[p][s].gate = true; } + tb3po_ = {}; tb3po_.density = 8; tb3po_.bpm = 120; tb3po_.octave = 3; tb3po_.lastNote = -1; + euclid_= {}; euclid_.bpm = 120; +} diff --git a/Apps/MidiController/main/Source/MidiController.h b/Apps/MidiController/main/Source/MidiController.h new file mode 100644 index 0000000..86e20c4 --- /dev/null +++ b/Apps/MidiController/main/Source/MidiController.h @@ -0,0 +1,567 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "MidiScales.h" +#include "Sequencer.h" +#include "UiKit.h" + +// --------------------------------------------------------------------------- +// Transport selection - which MIDI output carries sendMidi()'s bytes +// --------------------------------------------------------------------------- + +enum class MidiTransport { + BLE = 0, + USB = 1, +}; + +// --------------------------------------------------------------------------- +// Mode enum +// --------------------------------------------------------------------------- + +enum class MidiMode { + HOME = -1, + KEYS = 0, + BEATS = 1, + ZEN = 2, + DROP = 3, + RNG = 4, + XY = 5, + ARP = 6, + GRID = 7, + CHORD = 8, + LFO = 9, + RAGA = 10, + DIMENSIONS = 11, + BABY8 = 12, + TB3PO = 13, + EUCLID = 14, + COUNT = 15, +}; + +// --------------------------------------------------------------------------- +// Forward declarations for per-view build functions (each in ViewXxx.cpp) +// --------------------------------------------------------------------------- + +class MidiController; +void buildKeysView (MidiController* mc, lv_obj_t* parent); +void buildBeatsView (MidiController* mc, lv_obj_t* parent); +void buildXYPadView (MidiController* mc, lv_obj_t* parent); +void buildArpView (MidiController* mc, lv_obj_t* parent); +void buildGridView (MidiController* mc, lv_obj_t* parent); +void buildChordView (MidiController* mc, lv_obj_t* parent); +void buildLfoView (MidiController* mc, lv_obj_t* parent); +void buildZenView (MidiController* mc, lv_obj_t* parent); +void buildDropView (MidiController* mc, lv_obj_t* parent); +void buildRngView (MidiController* mc, lv_obj_t* parent); +void buildRagaView (MidiController* mc, lv_obj_t* parent); +void buildDimensionsView(MidiController* mc, lv_obj_t* parent); +void buildBaby8View (MidiController* mc, lv_obj_t* parent); +void buildTb3poView (MidiController* mc, lv_obj_t* parent); +void buildEuclidView (MidiController* mc, lv_obj_t* parent); +void euclidOnEncoderUpdate (MidiController* mc); // regen patterns + refresh UI after knob change + +// Encoder action entry points - implemented in each View*.cpp +void ragaTogglePlay (MidiController* mc); +void ragaToggleDrone (MidiController* mc); +void dimTogglePlay (MidiController* mc); +void dimReset (MidiController* mc); +void dimChangeOctave (MidiController* mc, int delta); +void dimChangeRandom (MidiController* mc, int delta); +void tb3TogglePlay (MidiController* mc); +void tb3ChangeScale (MidiController* mc, int delta); +void tb3Regen (MidiController* mc); +void tb3Redraw (MidiController* mc); // repaint piano roll after key/octave changes +void baby8UpdateStepColors(MidiController* mc); // refresh step colours after an encoder gate change + +// 8Encoder hardware support (EncoderInput.cpp) +void encoderInit (MidiController* mc); // detect hardware, start poll timer +void encoderStop (MidiController* mc); // stop poll timer, turn LEDs off +void encoderSetLed(MidiController* mc, uint8_t idx, uint32_t rgb); // set one LED +void encoderSetAllLeds(MidiController* mc, uint32_t rgb); // set all LEDs same color + +// --------------------------------------------------------------------------- +// MidiController +// --------------------------------------------------------------------------- + +class MidiController final { +public: + // window_manager_create()'s WindowCreateWidgetsFn - userData is the MidiController* for this instance. + static void createWidgets(lv_obj_t* parent, void* userData); + + // Call once, after the window has been torn down. + void teardown(); + + // Send a 3-byte MIDI message via BLE MIDI (no-op if not connected) + void sendMidi(uint8_t cmd, uint8_t d1, uint8_t d2); + + // Send Note Off for all 128 notes on channel 1 + void stopAllNotes(); + + // Switch to a mode (or HOME). Stops timers, sends All-Notes-Off, swaps view. + void switchToMode(MidiMode mode); + + // BLE state. Written by the app's own task, read by sendMidi() on any task. + std::atomic bleRunning_ {false}; + + // Called by main() on the app's own task. BLE and USB MIDI start and stop there, as they do + // flash and NVS work, which the LVGL task can't do: its stack is in external RAM. + // attachEventGroup() claims the bit that the LVGL task signals transport requests with. + void attachEventGroup(struct TaskEventGroup* group); + // Picks up a BLE MIDI session that is already running, before the window is created + void detectBleState(); + // Handles the pending transport request and Bluetooth events. Call when the event group wakes. + void processEvents(); + + // Active mode timers - each view creates its own, stored here so + // switchToMode() can delete them before swapping the view tree. + lv_timer_t* arpTimer_ = nullptr; + lv_timer_t* lfoTimer_ = nullptr; + lv_timer_t* zenTimer_ = nullptr; + lv_timer_t* dropTimer_ = nullptr; + lv_timer_t* rngTimer_ = nullptr; + lv_timer_t* ragaTimer_ = nullptr; + lv_timer_t* dimTimer_ = nullptr; + lv_timer_t* baby8Timer_ = nullptr; + lv_timer_t* tb3poTimer_ = nullptr; + lv_timer_t* euclidTimer_ = nullptr; + lv_timer_t* encoderTimer_ = nullptr; // 8Encoder/LCD poll (100 ms) + + // M5Stack 8Encoder state (I2C address 0x41, optional hardware) + struct EncoderState { + bool present = false; // true if detected at 0x41 on startup + int32_t counter[8] = {}; // last read absolute counter per knob + bool btnPrev[8] = {}; // previous button state for edge detection + bool btnSeedNext = false; // if true, next poll seeds btnPrev without firing actions + // LED colors per knob (0xRRGGBB, 0 = off) + uint32_t ledColor[8] = {}; + } encoder_; + + // Drum sequencer (owned here, accessed by ViewBeats) + std::unique_ptr seq_; + std::atomic seqPlayStep{-1}; + std::atomic seqLastStep{-1}; // previous step for highlight refresh + + // Singleton for static callbacks + static MidiController* s_instance; + + // Current view root - set by each buildXxxView, deleted in switchToMode + lv_obj_t* currentViewRoot_ = nullptr; + + // ----------------------------------------------------------------------- + // Per-mode state (inner structs - widget pointers nulled by switchToMode/teardown) + // ----------------------------------------------------------------------- + + struct KeysState { + int octave = 4, scale = 0, key = 0; + // One "currently sounding note" per touch slot (see lvgl_pointer_get_slot_index() in + // ViewKeys.cpp's cbPianoPressing/cbPianoReleased) rather than a single lastNote - lets + // multiple fingers each independently slide/hold their own key. -1 = no note held in + // that slot. Explicitly initialized (not just `= {}`, which would zero-init to note 0 - + // a real playable note, not "empty") both here and again in resetAllState(). The literal + // count below must track LVGL_POINTER_MAX_SLOTS - static_assert catches drift. + int lastNote[LVGL_POINTER_MAX_SLOTS] = { -1, -1, -1, -1, -1 }; + static_assert(LVGL_POINTER_MAX_SLOTS == 5, "update the lastNote initializer above to match"); + // Single octave visible at a time (OCT-/OCT+ shifts it), like SoundBench. + lv_obj_t* pianoArea = nullptr; + lv_obj_t* whiteKey[7] = {}; + lv_obj_t* blackKey[5] = {}; + lv_obj_t* statusLbl = nullptr; + lv_obj_t* scaleLbl = nullptr; + lv_obj_t* keyLbl = nullptr; + lv_obj_t* octLbl = nullptr; + } keys_; + + struct BeatsState { + int bpm = 120; + bool playing = false; + lv_obj_t* stepBtn[Sequencer::NUM_TRACKS][Sequencer::NUM_STEPS] = {}; + lv_obj_t* playBtn = nullptr; + lv_obj_t* playLbl = nullptr; + lv_obj_t* bpmLbl = nullptr; + // per-track note-off scheduling (filled in onStep callback) + uint64_t noteOffAtUs[Sequencer::NUM_TRACKS] = {}; + } beats_; + + struct XYState { + int xCC = 74, yCC = 71; + int xVal = 64, yVal = 64; + lv_obj_t* canvas = nullptr; + lv_obj_t* indicator = nullptr; // circle widget tracking touch position + lv_obj_t* xCCLbl = nullptr; + lv_obj_t* yCCLbl = nullptr; + lv_obj_t* valLbl = nullptr; + // canvas pixel dimensions (set after layout) + int canvasW = 0, canvasH = 0; + } xy_; + + struct ArpState { + int bpm = 120; + int speedIdx = 1; // 0=quarter 1=eighth 2=sixteenth 3=32nd + int pattern = 0; // 0=UP 1=DOWN 2=UPDN 3=RAND 4=CHANCE + int chordType = 0; // 0=MAJ 1=MIN 2=7TH + int octaveRange = 2; + int pianoOctave = 4; + int triggeredNote = -1; // root note being arped (-1=stopped) + bool isPlaying = false; + int arpStep = 0; + int arpDir = 1; // +1 or -1 for UPDN + lv_obj_t* pianoKey[12] = {}; + lv_obj_t* statusLbl = nullptr; + lv_obj_t* noteLbl = nullptr; + lv_obj_t* patternLbl = nullptr; + lv_obj_t* chordLbl = nullptr; + lv_obj_t* speedLbl = nullptr; + lv_obj_t* bpmLbl = nullptr; + lv_obj_t* octLbl = nullptr; + lv_obj_t* pianoOctLbl = nullptr; + int lastNote = -1; // for note-off + } arp_; + + struct GridState { + int octave = 3; + // One held note per touch slot, same pattern as KeysState::lastNote - lets multiple + // fingers each hold their own grid cell instead of the last touch stealing the others'. + // -1 = no note held in that slot. + int pressedNote[LVGL_POINTER_MAX_SLOTS] = { -1, -1, -1, -1, -1 }; + static_assert(LVGL_POINTER_MAX_SLOTS == 5, "update the pressedNote initializer above to match"); + lv_obj_t* cell[5][8] = {}; + lv_obj_t* octLbl = nullptr; + lv_obj_t* noteLbl = nullptr; + } grid_; + + struct ChordState { + int octave = 4; + int scale = 0; + int key = 0; + bool chordPlaying[8] = {}; + // up to 4 notes per chord + int chordNotes[8][4] = {}; + lv_obj_t* chordBtn[8] = {}; + lv_obj_t* octLbl = nullptr; + lv_obj_t* scaleLbl = nullptr; + } chord_; + + struct LfoState { + float rate = 1.0f; // Hz + int amount = 64; // 0-127 scale factor + int ccTarget = 1; // CC number (0-127) when not in pitch mode + bool pitchMode = false; + bool running = false; + int waveform = 0; // 0=SINE 1=TRI 2=SQR 3=SAW + float phase = 0.0f; // 0.0 - 1.0 + int lastValue = 64; + uint64_t lastUpdateUs = 0; + int updateCounter = 0; // throttle label updates + lv_obj_t* canvas = nullptr; + lv_obj_t* statusLbl = nullptr; + lv_obj_t* rateLbl = nullptr; + lv_obj_t* amountLbl = nullptr; + lv_obj_t* ccLbl = nullptr; + lv_obj_t* waveLbl = nullptr; + lv_obj_t* valueLbl = nullptr; + lv_obj_t* startBtn = nullptr; + lv_obj_t* startLbl = nullptr; + int canvasW = 0, canvasH = 0; + } lfo_; + + struct ZenState { + struct Ball { + float x, y, vx, vy; + int radius; + uint32_t color; + bool active = false; + }; + static constexpr int MAX_BALLS = 4; + Ball balls[MAX_BALLS] = {}; + lv_obj_t* ballObj[MAX_BALLS] = {}; // LVGL circle widgets for each ball + int numBalls = 0; + int scale = 0, key = 0, octave = 4; + // Wall flash timers (ms, 0 = no flash) + uint32_t wallFlashUntilMs[24] = {}; // TOP_SEGS(8)+BOTTOM_SEGS(8)+SIDE_SEGS*2(8) = 24 + // Pending note-offs: note value + time to send (0 = empty slot) + static constexpr int MAX_PENDING_OFFS = 8; + uint8_t pendingOffNote[MAX_PENDING_OFFS] = {}; + uint32_t pendingOffMs [MAX_PENDING_OFFS] = {}; + lv_obj_t* canvas = nullptr; + int canvasW = 0, canvasH = 0; + lv_obj_t* statusLbl = nullptr; + lv_obj_t* ballCntLbl = nullptr; + lv_obj_t* octLbl = nullptr; + } zen_; + + struct DropState { + struct DropBall { + float x, y, vx, vy; + bool active = false; + uint32_t spawnedMs = 0; + }; + struct Platform { + float x, y, w; + float angle; // radians, tilt + int note; + }; + static constexpr int MAX_BALLS = 8; + static constexpr int MAX_PLATFORMS = 6; + DropBall balls[MAX_BALLS] = {}; + lv_obj_t* ballObj[MAX_BALLS] = {}; // LVGL circle widgets + lv_obj_t* platLbl[MAX_PLATFORMS] = {}; // LVGL label widgets on canvas + Platform platforms[MAX_PLATFORMS] = {}; + int numPlatforms = 0; + int scale = 0, key = 0, octave = 4; + bool editMode = false; + lv_obj_t* canvas = nullptr; + int canvasW = 0, canvasH = 0; + lv_obj_t* statusLbl = nullptr; + lv_obj_t* modeLbl = nullptr; // shows "DROP" or "EDIT" + lv_obj_t* octLbl = nullptr; + lv_obj_t* modeBtn = nullptr; + } drop_; + + struct RngState { + int bpm = 120; + int subdivision = 0; // index into SUBDIV_NAMES: 0=1/4 1=1/8 2=1/16 + int minOctave = 3; + int maxOctave = 6; + int probability = 50; // 0-100% + int scaleIdx = 0; + int rootKey = 0; // 0-11 + bool playing = false; + int currentNote = -1; + uint64_t nextNoteUs = 0; + uint64_t noteOffUs = 0; + lv_obj_t* noteLbl = nullptr; + lv_obj_t* playBtn = nullptr; + lv_obj_t* playLbl = nullptr; + lv_obj_t* bpmLbl = nullptr; + lv_obj_t* probLbl = nullptr; + lv_obj_t* scaleLbl = nullptr; + lv_obj_t* keyLbl = nullptr; + lv_obj_t* minOctLbl = nullptr; + lv_obj_t* maxOctLbl = nullptr; + lv_obj_t* subdivLbl = nullptr; + } rng_; + + // ----------------------------------------------------------------------- + // New mode states + // ----------------------------------------------------------------------- + + struct RagaState { + int raga = 0; // 0-7 + int tala = 0; // 0-2 + int rootNote = 48; + int bpm = 120; + bool playing = false; + bool droneOn = false; + // Markov / phrase state + int8_t phrase[128] = {}; + int phraseLen = 0, phraseIdx = 0; + int8_t history[2] = {0, 0}; // 2nd-order Markov history + uint32_t nextNoteMs = 0; + uint32_t noteOffMs = 0; + bool noteActive = false; + uint8_t currentNote = 0; + bool droneActive = false; + uint8_t droneNote = 0; + int talaBeat = 0; + uint32_t noteInterval = 150; + uint32_t noteDuration = 120; + // UI + lv_obj_t* ragaLbl = nullptr; + lv_obj_t* talaLbl = nullptr; + lv_obj_t* rootLbl = nullptr; + lv_obj_t* bpmLbl = nullptr; + lv_obj_t* statusLbl = nullptr; + lv_obj_t* playBtn = nullptr; + lv_obj_t* playLbl = nullptr; + lv_obj_t* droneBtn = nullptr; + lv_obj_t* droneLbl = nullptr; + lv_obj_t* ragaBigLbl = nullptr; // right-side display + lv_obj_t* talaBigLbl = nullptr; + } raga_; + + struct DimensionsState { + int equation = 1; // 1-20 + float a = 50, b = 50, c = 50, d = 50; + float t = 0.0f; + float t2 = 100.0f; + float gran = 1.0f; // t step per tick + float rnd = 0.0f; + float xOff = 64.0f, yOff = 64.0f; + float px = 0, py = 0, pz = 0; + int octave = 0; + int bpm = 120; + bool playing = false; + int lastNote = -1; + uint32_t tickMs = 0; // next tick timestamp + uint32_t noteOffMs = 0; + // UI + lv_obj_t* eqLbl = nullptr; + lv_obj_t* aLbl = nullptr; + lv_obj_t* bLbl = nullptr; + lv_obj_t* cLbl = nullptr; + lv_obj_t* dLbl = nullptr; + lv_obj_t* outLbl = nullptr; // "px:N py:N pz:N t:N" + lv_obj_t* octLbl = nullptr; + lv_obj_t* rndLbl = nullptr; + lv_obj_t* bpmLbl = nullptr; + lv_obj_t* playLbl = nullptr; + } dim_; + + struct Baby8State { + struct Step { int note = 60; int velocity = 100; bool gate = true; }; + Step steps[8] = {}; + Step patterns[8][8] = {}; + int currentStep = 0; + int selectedStep = 0; + bool playing = false; + int bpm = 120; + int swing = 50; // 50-80 + int patternIdx = 0; + int lastNote = -1; + uint32_t nextStepMs = 0; + // UI + lv_obj_t* stepBtn[8] = {}; + lv_obj_t* noteLbl[8] = {}; + lv_obj_t* bpmLbl = nullptr; + lv_obj_t* swingLbl = nullptr; + lv_obj_t* patLbl = nullptr; + lv_obj_t* editNoteLbl = nullptr; + lv_obj_t* editVelLbl = nullptr; + lv_obj_t* playLbl = nullptr; + } baby8_; + + struct Tb3poState { + struct Step { + int note = 0; + bool oct_up = false, oct_down = false; + bool accent = false, slide = false, gate = false; + }; + Step steps[32] = {}; + uint32_t seed = 12345; + int density = 8; // 0-16 + int scale = 0; + int key = 0; + int octave = 3; + int currentStep = 0; + bool playing = false; + int bpm = 120; + int lastNote = -1; + int lastNoteStep = -1; // step that owns lastNote (slide bookkeeping) + uint32_t nextStepMs = 0; + uint32_t noteOffMs = 0; + // UI + lv_obj_t* canvas = nullptr; + lv_obj_t* densityLbl = nullptr; + lv_obj_t* seedLbl = nullptr; + lv_obj_t* bpmLbl = nullptr; + lv_obj_t* scaleLbl = nullptr; + lv_obj_t* keyLbl = nullptr; + lv_obj_t* playLbl = nullptr; + int canvasW = 0, canvasH = 0; + } tb3po_; + + struct EuclidState { + struct Voice { + int steps = 16; + int events = 4; + int rotation = 0; + int note = 36; + bool pattern[64] = {}; + }; + Voice voice[8] = {}; + uint32_t currentStep = 0; // free-running master clock (each voice uses modulo its own steps) + bool playing = false; + int bpm = 120; + bool triplet = false; + int selectedVoice = 0; + uint32_t nextStepMs = 0; + // UI + lv_obj_t* canvas = nullptr; + lv_obj_t* bpmLbl = nullptr; + lv_obj_t* stepsLbl = nullptr; + lv_obj_t* eventsLbl = nullptr; + lv_obj_t* rotLbl = nullptr; + lv_obj_t* noteLbl = nullptr; + lv_obj_t* voiceLbl = nullptr; + lv_obj_t* playLbl = nullptr; + lv_obj_t* tripLbl = nullptr; // TRIP ON / TRIP OFF + lv_obj_t* voiceTabRow = nullptr; // tab button row - for encoder voice-select highlight + int canvasW = 0, canvasH = 0; + } euclid_; + + // Current mode - read by EncoderInput.cpp for per-mode dispatch + MidiMode currentMode_ = MidiMode::HOME; + +public: + AppInstanceId appInstanceId = 0; + WindowId window = 0; + +private: + lv_obj_t* toolbar_ = nullptr; + lv_obj_t* transportBtn_ = nullptr; // toolbar button: current transport - opens the selector + lv_obj_t* transportOverlay_ = nullptr; // full-content transport chooser (null when closed) + lv_obj_t* mainWrapper_ = nullptr; + lv_obj_t* contentArea_ = nullptr; + lv_obj_t* switchWidget_ = nullptr; + lv_timer_t* uiTimer_ = nullptr; // 20ms general-purpose UI refresh + + // Which transport sendMidi() targets. Chosen in the transport selector before the switch is + // turned on; only one of bleRunning_/usbRunning_ is ever true. + MidiTransport activeTransport_ = MidiTransport::BLE; + + // BLE radio management (MediaKeys pattern) + struct Device* btDevice_ = nullptr; + bool radioWasOff_ = false; // true if we turned the radio on (must turn it back off) + bool radioEnabling_ = false; // true while waiting for BT_RADIO_STATE_ON + + // The app's event group, owned by main(). Bluetooth events and transport requests wake its loop. + struct TaskEventGroup* eventGroup_ = nullptr; + uint32_t transportRequestBit_ = 0; + // -1: none, 0: turn the transport off, 1: turn it on. Set by the LVGL task, taken by the app's task. + std::atomic transportRequest_ {-1}; + struct BtEventSubscription btEventSub_ {}; + bool btEventSubscribed_ = false; + + // USB MIDI state - no radio-enable dance needed, claim/release is synchronous. + std::atomic usbRunning_ {false}; + + void handleTransportToggle(bool enabled); // LVGL task: updates the UI and requests the transport change + void applyTransport(bool enabled); // App task: starts or stops the transport + void failTransportStart(); // Switches the UI back to off + void enableTransportButton(); + void startBleMidi(); // called once radio is confirmed ON + void stopBleMidi(); // sends all-notes-off and stops BLE MIDI + void startUsbMidi(); + void stopUsbMidi(); // sends all-notes-off and stops USB MIDI + + void showTransportSelect(); // opens the BLE/USB chooser over the current view + void updateTransportButton(); // refresh the toolbar button label + lv_obj_t* buildTransportSelectOverlay(lv_obj_t* parent); + + void createWidgetsImpl(lv_obj_t* parent); + void buildHomeView(lv_obj_t* parent); + void stopAllModeTimers(); + void resetAllState(); + + void handleBtEvent(const struct BtEvent& event); + void pollBtEvents(); + void subscribeBtEvents(); + void unsubscribeBtEvents(); + + static void cbUiTimer(lv_timer_t* t); + static void cbModeBtn(lv_event_t* e); + static void cbTransportSwitch(lv_event_t* e); + static void cbTransportSelect(lv_event_t* e); // toolbar transport button + static void cbTransportCard(lv_event_t* e); // card inside the transport chooser + static void cbTransportCancel(lv_event_t* e); +}; diff --git a/Apps/MidiController/main/Source/MidiScales.h b/Apps/MidiController/main/Source/MidiScales.h new file mode 100644 index 0000000..c5cbb5c --- /dev/null +++ b/Apps/MidiController/main/Source/MidiScales.h @@ -0,0 +1,76 @@ +#pragma once + +#include +#include + +// --------------------------------------------------------------------------- +// Scale definitions +// --------------------------------------------------------------------------- + +struct MidiScale { + const char* name; + uint8_t intervals[12]; + int numNotes; +}; + +static constexpr MidiScale MIDI_SCALES[] = { + { "Major", { 0, 2, 4, 5, 7, 9, 11 }, 7 }, + { "Minor", { 0, 2, 3, 5, 7, 8, 10 }, 7 }, + { "Dorian", { 0, 2, 3, 5, 7, 9, 10 }, 7 }, + { "Penta", { 0, 2, 4, 7, 9 }, 5 }, + { "Blues", { 0, 3, 5, 6, 7, 10 }, 6 }, + { "Chrome", { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11 }, 12 }, +}; +static constexpr int NUM_MIDI_SCALES = sizeof(MIDI_SCALES) / sizeof(MIDI_SCALES[0]); + +// Returns the MIDI note number for a given scale degree, octave, and key transpose. +// degree can exceed numNotes - it wraps and increments the octave. +inline int getNoteInScale(int scaleIdx, int degree, int octave, int keyTranspose = 0) { + scaleIdx = ((scaleIdx % NUM_MIDI_SCALES) + NUM_MIDI_SCALES) % NUM_MIDI_SCALES; + const MidiScale& s = MIDI_SCALES[scaleIdx]; + int actualDegree = ((degree % s.numNotes) + s.numNotes) % s.numNotes; + int octaveOffset = (degree >= 0) ? (degree / s.numNotes) : ((degree - s.numNotes + 1) / s.numNotes); + int note = 60 + s.intervals[actualDegree] + ((octave - 4 + octaveOffset) * 12) + keyTranspose; + // Clamp to valid MIDI range + if (note < 0) note = 0; + if (note > 127) note = 127; + return note; +} + +// Returns true if a given MIDI pitch class (0-11) is in the specified scale + key. +inline bool isNoteInScale(int scaleIdx, int keyTranspose, int midiNote) { + scaleIdx = ((scaleIdx % NUM_MIDI_SCALES) + NUM_MIDI_SCALES) % NUM_MIDI_SCALES; + const MidiScale& s = MIDI_SCALES[scaleIdx]; + int pitchClass = ((midiNote - keyTranspose) % 12 + 12) % 12; + for (int i = 0; i < s.numNotes; i++) { + if (s.intervals[i] == pitchClass) return true; + } + return false; +} + +// Returns true if the pitch class is the root note. +inline bool isRootNote(int keyTranspose, int midiNote) { + return (((midiNote - keyTranspose) % 12 + 12) % 12) == 0; +} + +// Fills buf with a sharp-based note name like "C4", "C#4", "A#3", etc. +inline const char* midiNoteName(int note, char* buf, int bufLen) { + static const char* NAMES[] = { "C","C#","D","D#","E","F","F#","G","G#","A","A#","B" }; + if (note < 0) note = 0; + int octave = (note / 12) - 1; // MIDI 60 = C4 (scientific pitch notation) + snprintf(buf, bufLen, "%s%d", NAMES[note % 12], octave); + return buf; +} + +// Returns short note name without octave (e.g. "C#") +inline const char* midiNoteClass(int note, char* buf, int bufLen) { + static const char* NAMES[] = { "C","C#","D","D#","E","F","F#","G","G#","A","A#","B" }; + if (note < 0) note = 0; + snprintf(buf, bufLen, "%s", NAMES[note % 12]); + return buf; +} + +// Key names for root note display (C, C#, D, ...) +static constexpr const char* KEY_NAMES[] = { + "C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B" +}; diff --git a/Apps/MidiController/main/Source/Sequencer.cpp b/Apps/MidiController/main/Source/Sequencer.cpp new file mode 100644 index 0000000..07e1088 --- /dev/null +++ b/Apps/MidiController/main/Source/Sequencer.cpp @@ -0,0 +1,107 @@ +#include "Sequencer.h" + +#include +#include +#include + +static constexpr auto* TAG = "MidiSeq"; + +Sequencer::Sequencer() { + memset(steps_, 0, sizeof(steps_)); + + esp_timer_create_args_t args = { + .callback = timerCb, + .arg = this, + .dispatch_method = ESP_TIMER_TASK, + .name = "midi_seq_tick", + .skip_unhandled_events = true, + }; + if (esp_timer_create(&args, &timer_) != ESP_OK) { + ESP_LOGE(TAG, "Failed to create timer"); + timer_ = nullptr; + } +} + +Sequencer::~Sequencer() { + stop(); + if (timer_) { + esp_timer_delete(timer_); + timer_ = nullptr; + } +} + +void Sequencer::start() { + if (playing_.load()) return; + currentStep.store(0); + playing_.store(true); + restartTimer(); +} + +void Sequencer::stop() { + if (!playing_.load()) return; + playing_.store(false); + if (timer_) esp_timer_stop(timer_); + currentStep.store(-1); +} + +void Sequencer::setBpm(float bpm) { + if (bpm < 40.0f) bpm = 40.0f; + if (bpm > 300.0f) bpm = 300.0f; + bpm_.store(bpm); + if (playing_.load()) restartTimer(); +} + +void Sequencer::toggleStep(int track, int step) { + if (track >= 0 && track < NUM_TRACKS && step >= 0 && step < NUM_STEPS) { + std::lock_guard lock(mutex_); + steps_[track][step] = !steps_[track][step]; + } +} + +bool Sequencer::getStep(int track, int step) const { + if (track >= 0 && track < NUM_TRACKS && step >= 0 && step < NUM_STEPS) { + std::lock_guard lock(const_cast(mutex_)); + return steps_[track][step]; + } + return false; +} + +void Sequencer::clearAll() { + // Stop playback first so the timer callback cannot observe a partially-cleared array + stop(); + std::lock_guard lock(mutex_); + memset(steps_, 0, sizeof(steps_)); +} + +void Sequencer::restartTimer() { + if (!timer_) return; + esp_timer_stop(timer_); + // 16th-note interval: 60s / BPM / 4 * 1e6 us = 15,000,000 / BPM + uint64_t intervalUs = (uint64_t)(15000000.0f / bpm_.load()); + if (esp_timer_start_periodic(timer_, intervalUs) != ESP_OK) { + ESP_LOGE(TAG, "Failed to start sequencer timer"); + } +} + +void Sequencer::timerCb(void* arg) { + static_cast(arg)->onTimerFired(); +} + +void Sequencer::onTimerFired() { + if (!playing_.load()) return; + + int step = currentStep.load(); + + if (onStep) { + std::lock_guard lock(mutex_); + for (int t = 0; t < NUM_TRACKS; t++) { + if (steps_[t][step]) { + onStep(step, t); + } + } + } + + currentStep.store((step + 1) % NUM_STEPS); + + if (onTick) onTick(step); +} diff --git a/Apps/MidiController/main/Source/Sequencer.h b/Apps/MidiController/main/Source/Sequencer.h new file mode 100644 index 0000000..61f68c7 --- /dev/null +++ b/Apps/MidiController/main/Source/Sequencer.h @@ -0,0 +1,58 @@ +#pragma once + +#include +#include +#include +#include +#include + +// 4-track 16-step drum sequencer using esp_timer for BPM-accurate step timing. + +class Sequencer { +public: + static constexpr int NUM_TRACKS = 4; + static constexpr int NUM_STEPS = 16; + + // Called from esp_timer task when a step fires for an active track. + // onStep(step, track) - only fires for tracks that have that step enabled. + using StepCallback = std::function; + + // Called each step tick regardless of active steps - for UI playhead advance. + // Runs in esp_timer task - must be fast (store to atomic, no LVGL calls). + using TickCallback = std::function; + + Sequencer(); + ~Sequencer(); + + void start(); + void stop(); + bool isPlaying() const { return playing_.load(); } + + void setBpm(float bpm); + float getBpm() const { return bpm_.load(); } + + void toggleStep(int track, int step); + bool getStep(int track, int step) const; + void clearAll(); + + // Currently playing step (-1 if stopped). Written from esp_timer task, + // read from LVGL task - atomic for safe cross-task access. + std::atomic currentStep{-1}; + + // Callbacks - must be set before start() is called; not safe to modify + // while the sequencer is running. + StepCallback onStep; + TickCallback onTick; + +private: + bool steps_[NUM_TRACKS][NUM_STEPS] = {}; + std::atomic bpm_{120.0f}; + std::atomic playing_{false}; + std::mutex mutex_; // guards steps_ + + esp_timer_handle_t timer_ = nullptr; + + static void timerCb(void* arg); + void onTimerFired(); + void restartTimer(); +}; diff --git a/Apps/MidiController/main/Source/UiKit.h b/Apps/MidiController/main/Source/UiKit.h new file mode 100644 index 0000000..e433811 --- /dev/null +++ b/Apps/MidiController/main/Source/UiKit.h @@ -0,0 +1,159 @@ +#pragma once + +#include +#include + +// --------------------------------------------------------------------------- +// MidiController UI kit +// +// Fixed-pixel instrument UI. The global LVGL theme (Material) styles every +// button/container with focus-ring margins, large paddings, pill radii and its +// own colours, so all widgets are created through these helpers, which reset +// those theme defaults back to the stock values the layouts are written for. +// +// Layout metrics are deliberately shared: every view uses the same row height, +// padding, gap and control widths so the transport/control rows look identical +// from one mode to the next. +// --------------------------------------------------------------------------- + +// --- Palette --------------------------------------------------------------- + +static inline lv_color_t MC_BG() { return lv_color_hex(0x080C12); } // view background +static inline lv_color_t MC_PANEL() { return lv_color_hex(0x0A0C14); } // row / control strip +static inline lv_color_t MC_SURFACE() { return lv_color_hex(0x1A2A40); } // raised cells / keys +static inline lv_color_t MC_BTN() { return lv_color_hex(0x2F3237); } // neutral button +static inline lv_color_t MC_BTN_ALT() { return lv_color_hex(0x222233); } // back / secondary button +static inline lv_color_t MC_LABEL() { return lv_color_hex(0x666688); } // captions +static inline lv_color_t MC_VALUE() { return lv_color_hex(0x00DFFF); } // readouts +static inline lv_color_t MC_PRIMARY() { return lv_color_hex(0x00DFFF); } // accent / highlight +static inline lv_color_t MC_OK() { return lv_color_hex(0x00AA44); } // play / start +static inline lv_color_t MC_STOP() { return lv_color_hex(0xFF2020); } // stop / recording +static inline lv_color_t MC_TEXT() { return lv_color_white(); } + +// --- Metrics --------------------------------------------------------------- + +constexpr int MC_ROW_H = 88; // transport/control row height +constexpr int MC_ROW_PAD = 12; // row inner padding +constexpr int MC_ROW_GAP = 12; // gap between row children +constexpr int MC_BTN_H = MC_ROW_H - 2 * MC_ROW_PAD; // 64 +constexpr int MC_HDR_H = 88; // header row +constexpr int MC_STATUS_H = 56; // bottom status strip + +// Control widths (shared tiers - pick the smallest that fits the row). +constexpr int MC_W_XS = 80; +constexpr int MC_W_S = 88; +constexpr int MC_W_M = 112; +constexpr int MC_W_L = 144; +constexpr int MC_W_XL = 176; +constexpr int MC_W_XXL = 224; + +// Readout-label widths. +constexpr int MC_LBL_S = 96; +constexpr int MC_LBL_M = 144; +constexpr int MC_LBL_L = 176; +constexpr int MC_LBL_XL = 224; + +// --- Widget primitives ----------------------------------------------------- + +static inline lv_obj_t* mcContainer(lv_obj_t* parent) { + lv_obj_t* obj = lv_obj_create(parent); + lv_obj_set_style_bg_opa(obj, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(obj, 0, 0); + lv_obj_set_style_radius(obj, 0, 0); + lv_obj_set_style_pad_all(obj, 0, 0); + lv_obj_set_style_pad_gap(obj, 0, 0); + return obj; +} + +static inline lv_obj_t* mcButton(lv_obj_t* parent) { + lv_obj_t* btn = lv_button_create(parent); + // Stock dark-theme button defaults: no focus-ring margin, rounded rect, grey fill. + lv_obj_set_style_margin_all(btn, 0, 0); + lv_obj_set_style_radius(btn, 16, 0); + lv_obj_set_style_bg_color(btn, MC_BTN(), 0); + lv_obj_set_style_pad_hor(btn, 14, 0); + lv_obj_set_style_pad_ver(btn, 8, 0); + return btn; +} + +static inline lv_obj_t* mcLabel(lv_obj_t* parent, const char* txt, + const lv_font_t* font, lv_color_t color, + int width = 0) { + lv_obj_t* lbl = lv_label_create(parent); + lv_label_set_text(lbl, txt); + lv_obj_set_style_text_font(lbl, font, 0); + lv_obj_set_style_text_color(lbl, color, 0); + if (width > 0) { + lv_obj_set_width(lbl, width); + lv_obj_set_style_text_align(lbl, LV_TEXT_ALIGN_CENTER, 0); + } + return lbl; +} + +// --- Row / control builders ------------------------------------------------ + +// Panel-styled flex row with the shared height/padding/gap. +static inline lv_obj_t* mcRow(lv_obj_t* parent, int height = MC_ROW_H) { + lv_obj_t* row = mcContainer(parent); + lv_obj_set_size(row, LV_PCT(100), height); + lv_obj_set_flex_flow(row, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(row, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_set_style_bg_color(row, MC_PANEL(), 0); + lv_obj_set_style_bg_opa(row, LV_OPA_COVER, 0); + lv_obj_set_style_pad_all(row, MC_ROW_PAD, 0); + lv_obj_set_style_pad_gap(row, MC_ROW_GAP, 0); + lv_obj_set_scrollable(row, false); + return row; +} + +// Small text button on a neutral fill. +static inline lv_obj_t* mcBtn(lv_obj_t* parent, const char* txt, int w, int h, + lv_event_cb_t cb, void* user) { + lv_obj_t* btn = mcButton(parent); + lv_obj_set_size(btn, w, h); + if (cb != nullptr) { + lv_obj_add_event_cb(btn, cb, LV_EVENT_CLICKED, user); + } + lv_obj_t* lbl = mcLabel(btn, txt, lvgl_get_text_font(FONT_SIZE_SMALL), MC_TEXT()); + lv_obj_center(lbl); + return btn; +} + +static inline lv_obj_t* mcSmallBtn(lv_obj_t* parent, const char* txt, int w, + lv_event_cb_t cb, void* user) { + return mcBtn(parent, txt, w, MC_BTN_H, cb, user); +} + +// Primary transport button ("PLAY"/"START"). +static inline lv_obj_t* mcPrimaryBtn(lv_obj_t* parent, const char* txt, int w, + lv_event_cb_t cb, void* user) { + lv_obj_t* btn = mcSmallBtn(parent, txt, w, cb, user); + lv_obj_set_style_bg_color(btn, MC_OK(), 0); + return btn; +} + +// Back / secondary navigation button. +static inline lv_obj_t* mcBackBtn(lv_obj_t* parent, lv_event_cb_t cb, void* user, + int w = MC_W_M) { + lv_obj_t* btn = mcSmallBtn(parent, "<", w, cb, user); + lv_obj_set_style_bg_color(btn, MC_BTN_ALT(), 0); + return btn; +} + +// Centered readout value. +static inline lv_obj_t* mcValue(lv_obj_t* parent, const char* txt, int w, + lv_color_t color = MC_VALUE()) { + return mcLabel(parent, txt, lvgl_get_text_font(FONT_SIZE_SMALL), color, w); +} + +// Small caption ("Rate:", "Key:", ...). +static inline lv_obj_t* mcHead(lv_obj_t* parent, const char* txt) { + return mcLabel(parent, txt, lvgl_get_text_font(FONT_SIZE_SMALL), MC_LABEL()); +} + +// Per-mode title: normal font, accent colour, grows to fill the row. +static inline lv_obj_t* mcTitle(lv_obj_t* parent, const char* txt, lv_color_t accent) { + lv_obj_t* lbl = mcLabel(parent, txt, lvgl_get_text_font(FONT_SIZE_DEFAULT), accent); + lv_obj_set_flex_grow(lbl, 1); + return lbl; +} diff --git a/Apps/MidiController/main/Source/ViewArp.cpp b/Apps/MidiController/main/Source/ViewArp.cpp new file mode 100644 index 0000000..d5b2dba --- /dev/null +++ b/Apps/MidiController/main/Source/ViewArp.cpp @@ -0,0 +1,541 @@ +#include "MidiController.h" + +#include +#include +#include +#include +#include + +// --------------------------------------------------------------------------- +// ARP view - Piano-triggered chord arpeggiator +// +// Layout (flex column): +// [header: < | ARP] +// [controls: Pattern | Chord | Octaves | Speed | BPM] +// [status label] +// [12-key chromatic piano row] +// --------------------------------------------------------------------------- + +static const char* ARP_PATTERNS[] = { "UP", "DOWN", "UP/DN", "RAND", "CHANCE" }; +static const char* CHORD_NAMES[] = { "MAJOR", "MINOR", "7TH" }; +// Chord intervals relative to root (in semitones) +static const int CHORD_INTERVALS[3][4] = { + { 0, 4, 7, -1 }, // MAJ + { 0, 3, 7, -1 }, // MIN + { 0, 4, 7, 10 }, // 7TH +}; +static const char* SPEED_NAMES[] = { "1/4", "1/8", "1/16", "1/32" }; +static const int SPEED_DIVS[] = { 1, 2, 4, 8 }; // subdivisions of quarter note +static const bool IS_SHARP[12] = { false,true,false,true,false,false,true,false,true,false,true,false }; + +static inline lv_coord_t dispW() { + return (lv_coord_t)lv_display_get_horizontal_resolution(nullptr); +} + +// --------------------------------------------------------------------------- +// Arpeggio timer +// --------------------------------------------------------------------------- + +static int getArpNoteCount(MidiController* mc) { + auto& ap = mc->arp_; + const int* iv = CHORD_INTERVALS[ap.chordType]; + int count = 0; + while (count < 4 && iv[count] >= 0) count++; + return count * ap.octaveRange; +} + +static int getArpNote(MidiController* mc, int step) { + auto& ap = mc->arp_; + if (ap.triggeredNote < 0) return -1; + + const int* iv = CHORD_INTERVALS[ap.chordType]; + int numIntervals = 0; + while (numIntervals < 4 && iv[numIntervals] >= 0) numIntervals++; + int total = numIntervals * ap.octaveRange; + if (total <= 0) return ap.triggeredNote; + + step = step % total; + int octOffset = step / numIntervals; + int intOffset = step % numIntervals; + int note = ap.triggeredNote + iv[intOffset] + octOffset * 12; + return std::max(0, std::min(127, note)); +} + +static void cbArpTimer(lv_timer_t* t) { + auto* mc = static_cast(lv_timer_get_user_data(t)); + if (!mc || !mc->arp_.isPlaying) return; + auto& ap = mc->arp_; + + if (ap.triggeredNote < 0) return; + + int total = getArpNoteCount(mc); + if (total <= 0) return; + + // Note off for previous note + if (ap.lastNote >= 0) { + mc->sendMidi(0x80, (uint8_t)ap.lastNote, 0); + ap.lastNote = -1; + } + + int note = -1; + switch (ap.pattern) { + case 0: // UP + note = getArpNote(mc, ap.arpStep); + ap.arpStep = (ap.arpStep + 1) % total; + break; + case 1: // DOWN + note = getArpNote(mc, total - 1 - ap.arpStep); + ap.arpStep = (ap.arpStep + 1) % total; + break; + case 2: // UP/DN + note = getArpNote(mc, ap.arpStep); + ap.arpStep += ap.arpDir; + if (ap.arpStep >= total) { ap.arpStep = std::max(0, total - 2); ap.arpDir = -1; } + if (ap.arpStep < 0) { ap.arpStep = std::min(1, total - 1); ap.arpDir = 1; } + break; + case 3: // RAND + note = getArpNote(mc, (int)(esp_random() % (uint32_t)total)); + break; + case 4: // CHANCE - UP with 30% skip + if ((esp_random() % 100) >= 30) { + note = getArpNote(mc, ap.arpStep); + } + ap.arpStep = (ap.arpStep + 1) % total; + break; + } + + if (note >= 0) { + mc->sendMidi(0x90, (uint8_t)note, 100); + ap.lastNote = note; + } + + // Update note display + if (ap.noteLbl && note >= 0) { + char buf[8]; + midiNoteName(note, buf, sizeof(buf)); + lv_label_set_text(ap.noteLbl, buf); + } +} + +static void updateArpTimer(MidiController* mc) { + auto& ap = mc->arp_; + if (mc->arpTimer_) { + // Interval: quarter note at bpm / subdivision factor + uint32_t intervalMs = (uint32_t)(60000.0f / ap.bpm / SPEED_DIVS[ap.speedIdx]); + if (intervalMs < 20) intervalMs = 20; + lv_timer_set_period(mc->arpTimer_, intervalMs); + } +} + +static void startArp(MidiController* mc, int rootNote) { + auto& ap = mc->arp_; + ap.triggeredNote = rootNote; + ap.arpStep = 0; + ap.arpDir = 1; + ap.isPlaying = true; + + uint32_t intervalMs = (uint32_t)(60000.0f / ap.bpm / SPEED_DIVS[ap.speedIdx]); + if (intervalMs < 20) intervalMs = 20; + + if (!mc->arpTimer_) { + mc->arpTimer_ = lv_timer_create(cbArpTimer, intervalMs, mc); + } else { + lv_timer_set_period(mc->arpTimer_, intervalMs); + lv_timer_reset(mc->arpTimer_); + } + + if (ap.statusLbl) { + char buf[24]; + char noteName[8]; + midiNoteName(rootNote, noteName, sizeof(noteName)); + snprintf(buf, sizeof(buf), "Arping: %s %s", noteName, CHORD_NAMES[ap.chordType]); + lv_label_set_text(ap.statusLbl, buf); + } +} + +static void stopArp(MidiController* mc) { + auto& ap = mc->arp_; + ap.isPlaying = false; + ap.triggeredNote = -1; + if (ap.lastNote >= 0) { + mc->sendMidi(0x80, (uint8_t)ap.lastNote, 0); + ap.lastNote = -1; + } + if (ap.noteLbl) lv_label_set_text(ap.noteLbl, ""); + if (ap.statusLbl) lv_label_set_text(ap.statusLbl, "Tap a key to start"); +} + +// --------------------------------------------------------------------------- +// Control callbacks +// --------------------------------------------------------------------------- + +static void cbBack(lv_event_t* e) { + auto* mc = static_cast(lv_event_get_user_data(e)); + if (mc) mc->switchToMode(MidiMode::HOME); +} + +static void cbPatternPrev(lv_event_t* e) { + auto* mc = static_cast(lv_event_get_user_data(e)); + if (!mc) return; + mc->arp_.pattern = (mc->arp_.pattern + 4) % 5; + if (mc->arp_.patternLbl) lv_label_set_text(mc->arp_.patternLbl, ARP_PATTERNS[mc->arp_.pattern]); +} +static void cbPatternNext(lv_event_t* e) { + auto* mc = static_cast(lv_event_get_user_data(e)); + if (!mc) return; + mc->arp_.pattern = (mc->arp_.pattern + 1) % 5; + if (mc->arp_.patternLbl) lv_label_set_text(mc->arp_.patternLbl, ARP_PATTERNS[mc->arp_.pattern]); +} + +static void cbChordCycle(lv_event_t* e) { + auto* mc = static_cast(lv_event_get_user_data(e)); + if (!mc) return; + mc->arp_.chordType = (mc->arp_.chordType + 1) % 3; + if (mc->arp_.chordLbl) lv_label_set_text(mc->arp_.chordLbl, CHORD_NAMES[mc->arp_.chordType]); +} + +static void cbOctDown(lv_event_t* e) { + auto* mc = static_cast(lv_event_get_user_data(e)); + if (!mc) return; + if (mc->arp_.octaveRange > 1) mc->arp_.octaveRange--; + if (mc->arp_.octLbl) { + char buf[4]; snprintf(buf, sizeof(buf), "%d", mc->arp_.octaveRange); + lv_label_set_text(mc->arp_.octLbl, buf); + } +} +static void cbOctUp(lv_event_t* e) { + auto* mc = static_cast(lv_event_get_user_data(e)); + if (!mc) return; + if (mc->arp_.octaveRange < 4) mc->arp_.octaveRange++; + if (mc->arp_.octLbl) { + char buf[4]; snprintf(buf, sizeof(buf), "%d", mc->arp_.octaveRange); + lv_label_set_text(mc->arp_.octLbl, buf); + } +} + +static void cbSpeedDown(lv_event_t* e) { + auto* mc = static_cast(lv_event_get_user_data(e)); + if (!mc) return; + if (mc->arp_.speedIdx > 0) mc->arp_.speedIdx--; + if (mc->arp_.speedLbl) lv_label_set_text(mc->arp_.speedLbl, SPEED_NAMES[mc->arp_.speedIdx]); + updateArpTimer(mc); +} +static void cbSpeedUp(lv_event_t* e) { + auto* mc = static_cast(lv_event_get_user_data(e)); + if (!mc) return; + if (mc->arp_.speedIdx < 3) mc->arp_.speedIdx++; + if (mc->arp_.speedLbl) lv_label_set_text(mc->arp_.speedLbl, SPEED_NAMES[mc->arp_.speedIdx]); + updateArpTimer(mc); +} + +static void cbBpmDown(lv_event_t* e) { + auto* mc = static_cast(lv_event_get_user_data(e)); + if (!mc) return; + mc->arp_.bpm -= 5; if (mc->arp_.bpm < 40) mc->arp_.bpm = 40; + if (mc->arp_.bpmLbl) { + char buf[8]; snprintf(buf, sizeof(buf), "%d", mc->arp_.bpm); + lv_label_set_text(mc->arp_.bpmLbl, buf); + } + updateArpTimer(mc); +} +static void cbBpmUp(lv_event_t* e) { + auto* mc = static_cast(lv_event_get_user_data(e)); + if (!mc) return; + mc->arp_.bpm += 5; if (mc->arp_.bpm > 300) mc->arp_.bpm = 300; + if (mc->arp_.bpmLbl) { + char buf[8]; snprintf(buf, sizeof(buf), "%d", mc->arp_.bpm); + lv_label_set_text(mc->arp_.bpmLbl, buf); + } + updateArpTimer(mc); +} + +static void updatePianoOctLabel(MidiController* mc) { + if (mc->arp_.pianoOctLbl) { + char buf[4]; snprintf(buf, sizeof(buf), "%d", mc->arp_.pianoOctave); + lv_label_set_text(mc->arp_.pianoOctLbl, buf); + } +} + +static void rebuildPianoKeyNotes(MidiController* mc) { + auto& ap = mc->arp_; + // Reset all key highlights before re-assigning notes (octave change invalidates old highlight) + for (int k = 0; k < 12; k++) { + if (!ap.pianoKey[k]) continue; + bool isSharp = IS_SHARP[k]; + lv_obj_set_style_bg_color(ap.pianoKey[k], + isSharp ? lv_color_hex(0xDDDDCC) : lv_color_hex(0x111111), + LV_PART_MAIN); + } + ap.triggeredNote = -1; // old note no longer valid in new octave + stopArp(mc); + + for (int k = 0; k < 12; k++) { + if (!ap.pianoKey[k]) continue; + int note = std::max(0, std::min(127, (ap.pianoOctave + 1) * 12 + k)); + lv_obj_set_user_data(ap.pianoKey[k], (void*)(intptr_t)note); + // Update label (child 0) + lv_obj_t* lbl = lv_obj_get_child(ap.pianoKey[k], 0); + if (lbl) { + char noteName[8]; midiNoteName(note, noteName, sizeof(noteName)); + lv_label_set_text(lbl, noteName); + } + } + updatePianoOctLabel(mc); +} + +static void cbPianoOctDown(lv_event_t* e) { + auto* mc = static_cast(lv_event_get_user_data(e)); + if (!mc) return; + if (mc->arp_.pianoOctave > 1) mc->arp_.pianoOctave--; + rebuildPianoKeyNotes(mc); +} +static void cbPianoOctUp(lv_event_t* e) { + auto* mc = static_cast(lv_event_get_user_data(e)); + if (!mc) return; + if (mc->arp_.pianoOctave < 7) mc->arp_.pianoOctave++; + rebuildPianoKeyNotes(mc); +} + +static void cbPianoKey(lv_event_t* e) { + auto* mc = static_cast(lv_event_get_user_data(e)); + if (!mc) return; + auto& ap = mc->arp_; + + lv_obj_t* btn = lv_event_get_target_obj(e); + int note = (int)(intptr_t)lv_obj_get_user_data(btn); + + // isSharp: sharps (C#,D#,F#,G#,A#) are light keys + auto keyColor = [](int semitone) -> uint32_t { + return IS_SHARP[semitone] ? 0xDDDDCC : 0x111111; + }; + + if (ap.triggeredNote == note && ap.isPlaying) { + // Tap same key to stop + stopArp(mc); + lv_obj_set_style_bg_color(btn, lv_color_hex(keyColor(note % 12)), LV_PART_MAIN); + } else { + // Reset previously highlighted key + if (ap.triggeredNote >= 0) { + int prevKey = ap.triggeredNote % 12; + if (ap.pianoKey[prevKey]) { + lv_obj_set_style_bg_color(ap.pianoKey[prevKey], + lv_color_hex(keyColor(prevKey)), + LV_PART_MAIN); + } + } + startArp(mc, note); + lv_obj_set_style_bg_color(btn, lv_color_hex(0x00DFFF), LV_PART_MAIN); + } +} + +// --------------------------------------------------------------------------- +// Teardown +// --------------------------------------------------------------------------- + +static void cbArpRootDelete(lv_event_t* e) { + auto* mc = static_cast(lv_event_get_user_data(e)); + if (!mc) return; + auto& ap = mc->arp_; + + // Stop and delete the arp timer so it cannot fire after the widgets are gone + if (mc->arpTimer_) { + lv_timer_delete(mc->arpTimer_); + mc->arpTimer_ = nullptr; + } + stopArp(mc); + + // Null all stored LVGL pointers - objects are being destroyed + ap.noteLbl = nullptr; + ap.statusLbl = nullptr; + ap.pianoOctLbl = nullptr; + for (int k = 0; k < 12; k++) ap.pianoKey[k] = nullptr; +} + +// --------------------------------------------------------------------------- +// Build function +// --------------------------------------------------------------------------- + +void buildArpView(MidiController* mc, lv_obj_t* parent) { + auto& ap = mc->arp_; + + lv_coord_t W = dispW(); + const lv_font_t* fontSmall = lvgl_get_text_font(FONT_SIZE_SMALL); + const lv_font_t* fontNormal = lvgl_get_text_font(FONT_SIZE_DEFAULT); + + int hdrH = MC_HDR_H; + int ctrlH = 88; + int statusH = 56; + + lv_obj_t* root = mcContainer(parent); + lv_obj_add_event_cb(root, cbArpRootDelete, LV_EVENT_DELETE, mc); + lv_obj_set_size(root, LV_PCT(100), LV_PCT(100)); + lv_obj_set_flex_flow(root, LV_FLEX_FLOW_COLUMN); + lv_obj_set_style_bg_color(root, lv_color_hex(0x080C12), 0); + lv_obj_set_style_bg_opa(root, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(root, 0, 0); + lv_obj_set_style_pad_all(root, 0, 0); + lv_obj_set_style_pad_gap(root, 1, 0); + lv_obj_set_scrollable(root, false); + mc->currentViewRoot_ = root; + + // Helper lambda to create a control row + auto makeRow = [&](int h) -> lv_obj_t* { return mcRow(root, h); }; + + auto makeSmallBtn = [&](lv_obj_t* parent_, const char* txt, lv_event_cb_t cb) -> lv_obj_t* { + return mcSmallBtn(parent_, txt, MC_W_S, cb, mc); + }; + + auto makeLbl = [&](lv_obj_t* parent_, const char* txt, int w, lv_color_t col) -> lv_obj_t* { + return mcValue(parent_, txt, w, col); + }; + + // -- Header row ----------------------------------------------------------- + lv_obj_t* hdr = makeRow(hdrH); + mcBackBtn(hdr, cbBack, mc, MC_W_M); + + lv_obj_t* titleLbl = lv_label_create(hdr); + lv_label_set_text(titleLbl, "ARPEGGIATOR"); + lv_obj_set_style_text_font(titleLbl, fontNormal, 0); + lv_obj_set_style_text_color(titleLbl, lv_color_hex(0xFF8000), 0); + lv_obj_set_flex_grow(titleLbl, 1); + + // -- Pattern row --------------------------------------------------------- + lv_obj_t* patRow = makeRow(ctrlH); + lv_obj_t* patLblHead = lv_label_create(patRow); + lv_label_set_text(patLblHead, "PTRN:"); + lv_obj_set_style_text_font(patLblHead, fontSmall, 0); + lv_obj_set_style_text_color(patLblHead, lv_color_hex(0x666688), 0); + makeSmallBtn(patRow, "<", cbPatternPrev); + ap.patternLbl = makeLbl(patRow, ARP_PATTERNS[ap.pattern], 144, lv_color_hex(0x00DFFF)); + makeSmallBtn(patRow, ">", cbPatternNext); + + lv_obj_t* sep1 = lv_label_create(patRow); + lv_label_set_text(sep1, " CHORD:"); + lv_obj_set_style_text_font(sep1, fontSmall, 0); + lv_obj_set_style_text_color(sep1, lv_color_hex(0x666688), 0); + lv_obj_t* chordBtn = mcButton(patRow); + lv_obj_set_size(chordBtn, 144, MC_BTN_H); + lv_obj_add_event_cb(chordBtn, cbChordCycle, LV_EVENT_CLICKED, mc); + ap.chordLbl = lv_label_create(chordBtn); + lv_label_set_text(ap.chordLbl, CHORD_NAMES[ap.chordType]); + lv_obj_set_style_text_font(ap.chordLbl, fontSmall, 0); + lv_obj_center(ap.chordLbl); + + // -- Octave / Speed / BPM row --------------------------------------------- + lv_obj_t* paramRow = makeRow(ctrlH); + lv_obj_t* octHead = lv_label_create(paramRow); + lv_label_set_text(octHead, "OCT:"); + lv_obj_set_style_text_font(octHead, fontSmall, 0); + lv_obj_set_style_text_color(octHead, lv_color_hex(0x666688), 0); + makeSmallBtn(paramRow, "-", cbOctDown); + char octBuf[4]; snprintf(octBuf, sizeof(octBuf), "%d", ap.octaveRange); + ap.octLbl = makeLbl(paramRow, octBuf, 80, lv_color_hex(0xFFFFFF)); + makeSmallBtn(paramRow, "+", cbOctUp); + + lv_obj_t* spdHead = lv_label_create(paramRow); + lv_label_set_text(spdHead, " SPD:"); + lv_obj_set_style_text_font(spdHead, fontSmall, 0); + lv_obj_set_style_text_color(spdHead, lv_color_hex(0x666688), 0); + makeSmallBtn(paramRow, "<", cbSpeedDown); + ap.speedLbl = makeLbl(paramRow, SPEED_NAMES[ap.speedIdx], 144, lv_color_hex(0x00DFFF)); + makeSmallBtn(paramRow, ">", cbSpeedUp); + + lv_obj_t* bpmHead = lv_label_create(paramRow); + lv_label_set_text(bpmHead, " BPM:"); + lv_obj_set_style_text_font(bpmHead, fontSmall, 0); + lv_obj_set_style_text_color(bpmHead, lv_color_hex(0x666688), 0); + makeSmallBtn(paramRow, "-", cbBpmDown); + char bpmBuf[8]; snprintf(bpmBuf, sizeof(bpmBuf), "%d", ap.bpm); + ap.bpmLbl = makeLbl(paramRow, bpmBuf, 160, lv_color_hex(0xFFE000)); + makeSmallBtn(paramRow, "+", cbBpmUp); + + // -- Status + note label row ----------------------------------------------- + lv_obj_t* statusRow = mcContainer(root); + lv_obj_set_size(statusRow, LV_PCT(100), statusH); + lv_obj_set_flex_flow(statusRow, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(statusRow, LV_FLEX_ALIGN_SPACE_BETWEEN, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_set_style_bg_color(statusRow, lv_color_hex(0x0A0C14), 0); + lv_obj_set_style_bg_opa(statusRow, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(statusRow, 0, 0); + lv_obj_set_style_pad_all(statusRow, 8, 0); + lv_obj_set_scrollable(statusRow, false); + + ap.statusLbl = lv_label_create(statusRow); + lv_label_set_text(ap.statusLbl, "Tap a key to start"); + lv_obj_set_style_text_font(ap.statusLbl, fontSmall, 0); + lv_obj_set_style_text_color(ap.statusLbl, lv_color_hex(0x666688), 0); + + ap.noteLbl = lv_label_create(statusRow); + lv_label_set_text(ap.noteLbl, ""); + lv_obj_set_style_text_font(ap.noteLbl, fontSmall, 0); + lv_obj_set_style_text_color(ap.noteLbl, lv_color_hex(0x00DFFF), 0); + + // -- Piano octave controls + piano row -------------------------------------- + lv_obj_t* pianoHdrRow = makeRow(72); + lv_obj_t* pOctHead = lv_label_create(pianoHdrRow); + lv_label_set_text(pOctHead, "Piano Oct:"); + lv_obj_set_style_text_font(pOctHead, fontSmall, 0); + lv_obj_set_style_text_color(pOctHead, lv_color_hex(0x666688), 0); + lv_obj_t* pOctDown = mcButton(pianoHdrRow); + lv_obj_set_size(pOctDown, 88, 56); + lv_obj_add_event_cb(pOctDown, cbPianoOctDown, LV_EVENT_CLICKED, mc); + lv_obj_t* podl = lv_label_create(pOctDown); lv_label_set_text(podl, "-"); + lv_obj_set_style_text_font(podl, fontSmall, 0); lv_obj_center(podl); + char pianoOctBuf[4]; snprintf(pianoOctBuf, sizeof(pianoOctBuf), "%d", ap.pianoOctave); + ap.pianoOctLbl = lv_label_create(pianoHdrRow); + lv_label_set_text(ap.pianoOctLbl, pianoOctBuf); + lv_obj_set_style_text_font(ap.pianoOctLbl, fontSmall, 0); + lv_obj_set_style_text_color(ap.pianoOctLbl, lv_color_hex(0xFFFFFF), 0); + lv_obj_set_width(ap.pianoOctLbl, 72); + lv_obj_set_style_text_align(ap.pianoOctLbl, LV_TEXT_ALIGN_CENTER, 0); + lv_obj_t* pOctUp = mcButton(pianoHdrRow); + lv_obj_set_size(pOctUp, 88, 56); + lv_obj_add_event_cb(pOctUp, cbPianoOctUp, LV_EVENT_CLICKED, mc); + lv_obj_t* poul = lv_label_create(pOctUp); lv_label_set_text(poul, "+"); + lv_obj_set_style_text_font(poul, fontSmall, 0); lv_obj_center(poul); + + // Piano row - flex_grow fills the rest + lv_obj_t* pianoArea = mcContainer(root); + lv_obj_set_width(pianoArea, LV_PCT(100)); + lv_obj_set_flex_grow(pianoArea, 1); + lv_obj_set_style_bg_color(pianoArea, lv_color_hex(0x050508), 0); + lv_obj_set_style_bg_opa(pianoArea, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(pianoArea, 0, 0); + lv_obj_set_style_pad_all(pianoArea, 0, 0); + lv_obj_set_scrollable(pianoArea, false); + + // 12 chromatic keys - all same height, equal width. + // Original style: naturals (C,D,E,F,G,A,B) = dark keys, sharps = light keys with labels. + int keyW = (int)W / 12; + + for (int k = 0; k < 12; k++) { + int note = (ap.pianoOctave + 1) * 12 + k; + note = std::max(0, std::min(127, note)); + + lv_obj_t* btn = mcButton(pianoArea); + lv_obj_set_pos(btn, k * keyW, 0); + bool isSharp = IS_SHARP[k]; + lv_obj_set_size(btn, keyW - 1, LV_PCT(100)); + // Sharps = light (cream), naturals = dark + lv_obj_set_style_bg_color(btn, isSharp ? lv_color_hex(0xDDDDCC) : lv_color_hex(0x111111), 0); + lv_obj_set_style_bg_opa(btn, LV_OPA_COVER, 0); + lv_obj_set_style_border_color(btn, lv_color_hex(0x444444), 0); + lv_obj_set_style_border_width(btn, 1, 0); + lv_obj_set_style_radius(btn, 2, 0); + lv_obj_set_style_pad_all(btn, 0, 0); + lv_obj_set_user_data(btn, (void*)(intptr_t)note); + lv_obj_add_event_cb(btn, cbPianoKey, LV_EVENT_CLICKED, mc); + + // Note name label on all keys + char noteName[8]; + midiNoteName(note, noteName, sizeof(noteName)); + lv_obj_t* lbl = lv_label_create(btn); + lv_label_set_text(lbl, noteName); + lv_obj_set_style_text_font(lbl, fontSmall, 0); + lv_obj_set_style_text_color(lbl, isSharp ? lv_color_hex(0x333333) : lv_color_hex(0x888888), 0); + lv_obj_align(lbl, LV_ALIGN_BOTTOM_MID, 0, -2); + lv_obj_set_clickable(lbl, false); + + ap.pianoKey[k] = btn; + } +} diff --git a/Apps/MidiController/main/Source/ViewBaby8.cpp b/Apps/MidiController/main/Source/ViewBaby8.cpp new file mode 100644 index 0000000..d2e0a29 --- /dev/null +++ b/Apps/MidiController/main/Source/ViewBaby8.cpp @@ -0,0 +1,417 @@ +#include "MidiController.h" + +#include +#include +#include +#include +#include + +// --------------------------------------------------------------------------- +// BABY8 view - 8-step melodic sequencer with swing +// +// Layout (flex column): +// [ctrl row: < | PLAY | BPM- | N | BPM+ | SWING- | N% | SWING+] +// [step grid row (flex_grow): 8 step cells] +// [edit row: NOTE- | noteName | NOTE+ VEL- | vel | VEL+ GATE PAT- | P:N | PAT+] +// --------------------------------------------------------------------------- + + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +void baby8UpdateStepColors(MidiController* mc) { + auto& bs = mc->baby8_; + for (int i = 0; i < 8; i++) { + if (!bs.stepBtn[i]) continue; + const auto& s = bs.steps[i]; + uint32_t col; + if (i == bs.currentStep && bs.playing) { + col = s.gate ? 0xFFFFFF : 0x4488FF; + } else if (i == bs.selectedStep) { + col = s.gate ? 0xFF8800 : 0x886633; + } else { + col = s.gate ? 0x006622 : 0x222222; + } + lv_obj_set_style_bg_color(bs.stepBtn[i], lv_color_hex(col), 0); + // Update note label + if (bs.noteLbl[i] && s.gate) { + char buf[8]; midiNoteName(s.note, buf, sizeof(buf)); + lv_label_set_text(bs.noteLbl[i], buf); + } else if (bs.noteLbl[i]) { + lv_label_set_text(bs.noteLbl[i], "--"); + } + } +} + +static void baby8UpdateEditRow(MidiController* mc) { + auto& bs = mc->baby8_; + const auto& s = bs.steps[bs.selectedStep]; + if (bs.editNoteLbl) { + char buf[8]; midiNoteName(s.note, buf, sizeof(buf)); + lv_label_set_text(bs.editNoteLbl, buf); + } + if (bs.editVelLbl) { + char buf[6]; snprintf(buf, sizeof(buf), "%d", s.velocity); + lv_label_set_text(bs.editVelLbl, buf); + } +} + +// --------------------------------------------------------------------------- +// Timer callback +// --------------------------------------------------------------------------- + +static void cbBaby8Timer(lv_timer_t* t) { + auto* mc = static_cast(lv_timer_get_user_data(t)); + if (!mc) return; + auto& bs = mc->baby8_; + if (!bs.playing) return; + + uint32_t now = lv_tick_get(); + + // Note-off + if (bs.lastNote >= 0 && bs.nextStepMs >= 10 && now >= bs.nextStepMs - 10) { + mc->sendMidi(0x80, (uint8_t)bs.lastNote, 0); + bs.lastNote = -1; + } + + if (now < bs.nextStepMs) return; + + // Advance step + bs.currentStep = (bs.currentStep + 1) % 8; + bool even = (bs.currentStep % 2 == 0); + + // BPM → 16th note duration + uint32_t beatMs = 60000u / (uint32_t)bs.bpm; + uint32_t sixteenth = beatMs / 4; + + // Swing: lengthen even steps and shorten odd steps by the same amount so a + // pair still fills two 16th notes and the overall tempo does not drift. + // swing=50 → no swing (both equal); swing>50 → even step longer. + uint32_t stepDur = sixteenth; + if (bs.swing > 50) { + if (even) stepDur = (uint32_t)(sixteenth * bs.swing / 50u); + else stepDur = (uint32_t)(sixteenth * (100u - bs.swing) / 50u); + if (stepDur < 20) stepDur = 20; + } + + bs.nextStepMs = now + stepDur; + + // Play current step + const auto& step = bs.steps[bs.currentStep]; + if (step.gate) { + mc->sendMidi(0x90, (uint8_t)step.note, (uint8_t)step.velocity); + bs.lastNote = step.note; + } + + // Update step highlight + baby8UpdateStepColors(mc); +} + +// --------------------------------------------------------------------------- +// Callbacks +// --------------------------------------------------------------------------- + +static void cbBaby8Back(lv_event_t* e) { + auto* mc = static_cast(lv_event_get_user_data(e)); + if (mc) mc->switchToMode(MidiMode::HOME); +} + +static void cbBaby8Play(lv_event_t* e) { + auto* mc = static_cast(lv_event_get_user_data(e)); + if (!mc) return; + auto& bs = mc->baby8_; + bs.playing = !bs.playing; + if (bs.playing) { + bs.currentStep = 7; // will advance to 0 on first tick + uint32_t beatMs = 60000u / (uint32_t)bs.bpm; + bs.nextStepMs = lv_tick_get() + beatMs / 4; + } else { + if (bs.lastNote >= 0) { + mc->sendMidi(0x80, (uint8_t)bs.lastNote, 0); + bs.lastNote = -1; + } + baby8UpdateStepColors(mc); + } + if (bs.playLbl) lv_label_set_text(bs.playLbl, bs.playing ? "STOP" : "PLAY"); +} + +static void cbBaby8BpmDown(lv_event_t* e) { + auto* mc = static_cast(lv_event_get_user_data(e)); + if (!mc) return; + auto& bs = mc->baby8_; + if (bs.bpm > 40) bs.bpm -= 5; + if (bs.bpmLbl) { + char buf[8]; snprintf(buf, sizeof(buf), "%d", bs.bpm); + lv_label_set_text(bs.bpmLbl, buf); + } +} +static void cbBaby8BpmUp(lv_event_t* e) { + auto* mc = static_cast(lv_event_get_user_data(e)); + if (!mc) return; + auto& bs = mc->baby8_; + if (bs.bpm < 280) bs.bpm += 5; + if (bs.bpmLbl) { + char buf[8]; snprintf(buf, sizeof(buf), "%d", bs.bpm); + lv_label_set_text(bs.bpmLbl, buf); + } +} + +static void cbBaby8SwingDown(lv_event_t* e) { + auto* mc = static_cast(lv_event_get_user_data(e)); + if (!mc) return; + auto& bs = mc->baby8_; + if (bs.swing > 50) bs.swing -= 5; + if (bs.swingLbl) { + char buf[8]; snprintf(buf, sizeof(buf), "%d%%", bs.swing); + lv_label_set_text(bs.swingLbl, buf); + } +} +static void cbBaby8SwingUp(lv_event_t* e) { + auto* mc = static_cast(lv_event_get_user_data(e)); + if (!mc) return; + auto& bs = mc->baby8_; + if (bs.swing < 80) bs.swing += 5; + if (bs.swingLbl) { + char buf[8]; snprintf(buf, sizeof(buf), "%d%%", bs.swing); + lv_label_set_text(bs.swingLbl, buf); + } +} + +static void cbBaby8StepTap(lv_event_t* e) { + auto* mc = static_cast(lv_event_get_user_data(e)); + if (!mc) return; + auto& bs = mc->baby8_; + // Find which step button was tapped by matching lv_obj + lv_obj_t* obj = lv_event_get_target_obj(e); + for (int i = 0; i < 8; i++) { + if (bs.stepBtn[i] == obj) { + bs.selectedStep = i; + break; + } + } + baby8UpdateStepColors(mc); + baby8UpdateEditRow(mc); +} + +static void cbBaby8NoteDown(lv_event_t* e) { + auto* mc = static_cast(lv_event_get_user_data(e)); + if (!mc) return; + auto& bs = mc->baby8_; + auto& s = bs.steps[bs.selectedStep]; + if (s.note > 0) s.note--; + baby8UpdateEditRow(mc); + baby8UpdateStepColors(mc); +} +static void cbBaby8NoteUp(lv_event_t* e) { + auto* mc = static_cast(lv_event_get_user_data(e)); + if (!mc) return; + auto& bs = mc->baby8_; + auto& s = bs.steps[bs.selectedStep]; + if (s.note < 127) s.note++; + baby8UpdateEditRow(mc); + baby8UpdateStepColors(mc); +} + +static void cbBaby8VelDown(lv_event_t* e) { + auto* mc = static_cast(lv_event_get_user_data(e)); + if (!mc) return; + auto& bs = mc->baby8_; + auto& s = bs.steps[bs.selectedStep]; + if (s.velocity > 10) s.velocity -= 10; + else s.velocity = 1; + baby8UpdateEditRow(mc); +} +static void cbBaby8VelUp(lv_event_t* e) { + auto* mc = static_cast(lv_event_get_user_data(e)); + if (!mc) return; + auto& bs = mc->baby8_; + auto& s = bs.steps[bs.selectedStep]; + if (s.velocity <= 117) s.velocity += 10; + else s.velocity = 127; + baby8UpdateEditRow(mc); +} + +static void cbBaby8Gate(lv_event_t* e) { + auto* mc = static_cast(lv_event_get_user_data(e)); + if (!mc) return; + auto& bs = mc->baby8_; + bs.steps[bs.selectedStep].gate = !bs.steps[bs.selectedStep].gate; + baby8UpdateEditRow(mc); + baby8UpdateStepColors(mc); +} + +static void cbBaby8PatDown(lv_event_t* e) { + auto* mc = static_cast(lv_event_get_user_data(e)); + if (!mc) return; + auto& bs = mc->baby8_; + // Save current pattern before switching + memcpy(bs.patterns[bs.patternIdx], bs.steps, sizeof(bs.steps)); + bs.patternIdx = (bs.patternIdx + 7) % 8; + memcpy(bs.steps, bs.patterns[bs.patternIdx], sizeof(bs.steps)); + if (bs.patLbl) { + char buf[6]; snprintf(buf, sizeof(buf), "P:%d", bs.patternIdx + 1); + lv_label_set_text(bs.patLbl, buf); + } + baby8UpdateStepColors(mc); + baby8UpdateEditRow(mc); +} +static void cbBaby8PatUp(lv_event_t* e) { + auto* mc = static_cast(lv_event_get_user_data(e)); + if (!mc) return; + auto& bs = mc->baby8_; + // Save current pattern before switching + memcpy(bs.patterns[bs.patternIdx], bs.steps, sizeof(bs.steps)); + bs.patternIdx = (bs.patternIdx + 1) % 8; + memcpy(bs.steps, bs.patterns[bs.patternIdx], sizeof(bs.steps)); + if (bs.patLbl) { + char buf[6]; snprintf(buf, sizeof(buf), "P:%d", bs.patternIdx + 1); + lv_label_set_text(bs.patLbl, buf); + } + baby8UpdateStepColors(mc); + baby8UpdateEditRow(mc); +} + +// --------------------------------------------------------------------------- +// Build +// --------------------------------------------------------------------------- + +void buildBaby8View(MidiController* mc, lv_obj_t* parent) { + auto& bs = mc->baby8_; + + const lv_font_t* fontSmall = lvgl_get_text_font(FONT_SIZE_SMALL); + const lv_font_t* fontNormal = lvgl_get_text_font(FONT_SIZE_DEFAULT); + + int sBtnW = 88; + + lv_obj_t* root = mcContainer(parent); + lv_obj_set_size(root, LV_PCT(100), LV_PCT(100)); + lv_obj_set_flex_flow(root, LV_FLEX_FLOW_COLUMN); + lv_obj_set_style_bg_color(root, lv_color_hex(0x070A10), 0); + lv_obj_set_style_bg_opa(root, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(root, 0, 0); + lv_obj_set_style_pad_all(root, 0, 0); + lv_obj_set_style_pad_gap(root, 8, 0); + lv_obj_set_scrollable(root, false); + mc->currentViewRoot_ = root; + + auto makeRow = [&]() -> lv_obj_t* { return mcRow(root); }; + + auto makeBtn = [&](lv_obj_t* par, const char* txt, int w, lv_event_cb_t cb, void* ud) -> lv_obj_t* { + return mcSmallBtn(par, txt, w, cb, ud); + }; + + auto makeLbl = [&](lv_obj_t* par, const char* txt, int w, lv_color_t col) -> lv_obj_t* { + return mcValue(par, txt, w, col); + }; + + auto makeSep = [&](lv_obj_t* par) { + lv_obj_t* sep = lv_label_create(par); + lv_label_set_text(sep, "|"); + lv_obj_set_style_text_font(sep, fontSmall, 0); + lv_obj_set_style_text_color(sep, lv_color_hex(0x334455), 0); + }; + + // -- Control row ----------------------------------------------------------- + { + lv_obj_t* row = makeRow(); + mcBackBtn(row, cbBaby8Back, mc, MC_W_S); + lv_obj_t* titleLbl = lv_label_create(row); + lv_label_set_text(titleLbl, "BABY-8"); + lv_obj_set_style_text_font(titleLbl, fontNormal, 0); + lv_obj_set_style_text_color(titleLbl, lv_color_hex(0x44BBFF), 0); + lv_obj_set_flex_grow(titleLbl, 1); + lv_obj_t* playBtn = makeBtn(row, bs.playing ? "STOP" : "PLAY", 176, cbBaby8Play, mc); + bs.playLbl = lv_obj_get_child(playBtn, 0); + makeSep(row); + makeBtn(row, "<", sBtnW, cbBaby8BpmDown, mc); + char bufBpm[8]; snprintf(bufBpm, sizeof(bufBpm), "%d", bs.bpm); + bs.bpmLbl = makeLbl(row, bufBpm, 136, lv_color_hex(0xFFE000)); + makeBtn(row, ">", sBtnW, cbBaby8BpmUp, mc); + makeSep(row); + makeBtn(row, "<", sBtnW, cbBaby8SwingDown, mc); + char bufSw[8]; snprintf(bufSw, sizeof(bufSw), "%d%%", bs.swing); + bs.swingLbl = makeLbl(row, bufSw, 128, lv_color_hex(0xAADDFF)); + makeBtn(row, ">", sBtnW, cbBaby8SwingUp, mc); + } + + // -- Step grid (flex_grow) ----------------------------------------------------------- + { + lv_obj_t* gridArea = mcContainer(root); + lv_obj_set_width(gridArea, LV_PCT(100)); + lv_obj_set_flex_grow(gridArea, 1); + lv_obj_set_flex_flow(gridArea, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(gridArea, LV_FLEX_ALIGN_SPACE_EVENLY, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_set_style_bg_color(gridArea, lv_color_hex(0x050810), 0); + lv_obj_set_style_bg_opa(gridArea, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(gridArea, 0, 0); + lv_obj_set_style_pad_all(gridArea, 16, 0); + lv_obj_set_style_pad_gap(gridArea, 12, 0); + lv_obj_set_scrollable(gridArea, false); + + // Compute cell size from available space + int cellW = (lv_display_get_horizontal_resolution(nullptr) - 32 - 7 * 12) / 8; + + for (int i = 0; i < 8; i++) { + lv_obj_t* cell = mcContainer(gridArea); + lv_obj_set_size(cell, cellW, LV_PCT(100)); + lv_obj_set_flex_flow(cell, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(cell, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_set_style_radius(cell, 16, 0); + lv_obj_set_style_border_width(cell, 1, 0); + lv_obj_set_style_border_color(cell, lv_color_hex(0x334455), 0); + lv_obj_set_style_bg_color(cell, lv_color_hex(bs.steps[i].gate ? 0x006622u : 0x222222u), 0); + lv_obj_set_style_bg_opa(cell, LV_OPA_COVER, 0); + lv_obj_set_style_pad_all(cell, 8, 0); + lv_obj_set_style_pad_gap(cell, 8, 0); + lv_obj_set_scrollable(cell, false); + lv_obj_set_clickable(cell, true); + lv_obj_add_event_cb(cell, cbBaby8StepTap, LV_EVENT_CLICKED, mc); + bs.stepBtn[i] = cell; + + // Step number + lv_obj_t* numLbl = lv_label_create(cell); + char nbuf[4]; snprintf(nbuf, sizeof(nbuf), "%d", i + 1); + lv_label_set_text(numLbl, nbuf); + lv_obj_set_style_text_font(numLbl, fontSmall, 0); + lv_obj_set_style_text_color(numLbl, lv_color_hex(0x888888), 0); + + // Note name + lv_obj_t* noteLbl = lv_label_create(cell); + char noteBuf[8]; midiNoteName(bs.steps[i].note, noteBuf, sizeof(noteBuf)); + lv_label_set_text(noteLbl, bs.steps[i].gate ? noteBuf : "--"); + lv_obj_set_style_text_font(noteLbl, fontSmall, 0); + lv_obj_set_style_text_color(noteLbl, lv_color_hex(0xFFFFFF), 0); + bs.noteLbl[i] = noteLbl; + } + } + + // -- Edit row ----------------------------------------------------------- + { + lv_obj_t* row = makeRow(); + // Note + makeBtn(row, "<", sBtnW, cbBaby8NoteDown, mc); + char noteBuf[8]; midiNoteName(bs.steps[bs.selectedStep].note, noteBuf, sizeof(noteBuf)); + bs.editNoteLbl = makeLbl(row, noteBuf, 128, lv_color_hex(0x88DDFF)); + makeBtn(row, ">", sBtnW, cbBaby8NoteUp, mc); + // Velocity + makeBtn(row, "<", sBtnW, cbBaby8VelDown, mc); + char velBuf[6]; snprintf(velBuf, sizeof(velBuf), "%d", bs.steps[bs.selectedStep].velocity); + bs.editVelLbl = makeLbl(row, velBuf, 112, lv_color_hex(0xFFCC88)); + makeBtn(row, ">", sBtnW, cbBaby8VelUp, mc); + // Gate + makeBtn(row, "GATE", 160, cbBaby8Gate, mc); + // Pattern + makeBtn(row, "<", sBtnW, cbBaby8PatDown, mc); + char patBuf[16]; snprintf(patBuf, sizeof(patBuf), "P:%d", bs.patternIdx + 1); + bs.patLbl = makeLbl(row, patBuf, 112, lv_color_hex(0xAA88FF)); + makeBtn(row, ">", sBtnW, cbBaby8PatUp, mc); + } + + // -- Start timer ----------------------------------------------------------- + bs.nextStepMs = lv_tick_get() + 60000u / (uint32_t)bs.bpm / 4u; + if (mc->baby8Timer_) { + lv_timer_delete(mc->baby8Timer_); + } + mc->baby8Timer_ = lv_timer_create(cbBaby8Timer, 10, mc); +} diff --git a/Apps/MidiController/main/Source/ViewBeats.cpp b/Apps/MidiController/main/Source/ViewBeats.cpp new file mode 100644 index 0000000..8d758b2 --- /dev/null +++ b/Apps/MidiController/main/Source/ViewBeats.cpp @@ -0,0 +1,347 @@ +#include "MidiController.h" + +#include +#include +#include + +// --------------------------------------------------------------------------- +// BEATS view - 4-track 16-step drum sequencer +// +// Layout (flex column): +// [transport row: BACK | PLAY/STOP | CLR | BPM- | BPM | BPM+ | "BPM"] +// [step-marker header row (beat numbers 1/5/9/13)] +// [4 × 16 step button grid - flex_grow, each row = track label + 16 buttons] +// --------------------------------------------------------------------------- + +// Drum track names and colors +static const char* TRACK_NAMES[] = { "KICK", "SNRE", "HHAT", "OPEN" }; + +static lv_color_t trackColor(int t) { + switch (t) { + case 0: return lv_color_hex(0xFF2020); // red + case 1: return lv_color_hex(0xFFE000); // yellow + case 2: return lv_color_hex(0x00DFFF); // cyan + case 3: return lv_color_hex(0xCC44FF); // purple + default: return lv_color_hex(0x888888); + } +} + +static inline lv_coord_t dispW() { + return (lv_coord_t)lv_display_get_horizontal_resolution(nullptr); +} + +// Beat group separator: every 4 steps gets a slightly different shade +static bool isBeatBoundary(int step) { return (step % 4) == 0; } + +static void refreshStepBtn(MidiController* mc, int track, int step) { + lv_obj_t* btn = mc->beats_.stepBtn[track][step]; + if (!btn) return; + bool active = mc->seq_ && mc->seq_->getStep(track, step); + lv_obj_set_style_bg_color(btn, + active ? trackColor(track) : lv_color_hex(0x1A2A40), + LV_PART_MAIN); + // Restore beat-boundary left border (overwritten by playback cursor highlight) + if (isBeatBoundary(step)) { + lv_obj_set_style_border_width(btn, 1, LV_PART_MAIN); + lv_obj_set_style_border_side(btn, LV_BORDER_SIDE_LEFT, LV_PART_MAIN); + lv_obj_set_style_border_color(btn, lv_color_hex(0x334455), LV_PART_MAIN); + } else { + lv_obj_set_style_border_width(btn, 0, LV_PART_MAIN); + } +} + +static void refreshAllStepBtns(MidiController* mc) { + for (int t = 0; t < Sequencer::NUM_TRACKS; t++) { + for (int s = 0; s < Sequencer::NUM_STEPS; s++) { + refreshStepBtn(mc, t, s); + } + } +} + +static void refreshPlayBtn(MidiController* mc) { + if (!mc->beats_.playLbl) return; + lv_label_set_text(mc->beats_.playLbl, + mc->beats_.playing ? "STOP" : "PLAY"); + lv_obj_set_style_bg_color(mc->beats_.playBtn, + mc->beats_.playing ? lv_color_hex(0xFF2020) : lv_color_hex(0x00AA44), + LV_PART_MAIN); +} + +static void refreshBpmLbl(MidiController* mc) { + if (!mc->beats_.bpmLbl) return; + char buf[8]; snprintf(buf, sizeof(buf), "%d", mc->beats_.bpm); + lv_label_set_text(mc->beats_.bpmLbl, buf); +} + +// --------------------------------------------------------------------------- +// Callbacks +// --------------------------------------------------------------------------- + +static void cbBack(lv_event_t* e) { + auto* mc = static_cast(lv_event_get_user_data(e)); + if (mc) mc->switchToMode(MidiMode::HOME); +} + +static void cbStepBtn(lv_event_t* e) { + auto* mc = static_cast(lv_event_get_user_data(e)); + if (!mc || !mc->seq_) return; + lv_obj_t* btn = lv_event_get_target_obj(e); + int packed = (int)(intptr_t)lv_obj_get_user_data(btn); + int track = packed >> 8; + int step = packed & 0xFF; + mc->seq_->toggleStep(track, step); + refreshStepBtn(mc, track, step); +} + +static void cbPlayStop(lv_event_t* e) { + auto* mc = static_cast(lv_event_get_user_data(e)); + if (!mc || !mc->seq_) return; + if (mc->beats_.playing) { + mc->seq_->stop(); + mc->beats_.playing = false; + mc->stopAllNotes(); + mc->seqPlayStep.store(-1); + mc->seqLastStep = -1; + refreshAllStepBtns(mc); // self-heals borders and colors + } else { + mc->seqLastStep = -1; + mc->seq_->start(); + mc->beats_.playing = true; + } + refreshPlayBtn(mc); +} + +static void cbClear(lv_event_t* e) { + auto* mc = static_cast(lv_event_get_user_data(e)); + if (!mc || !mc->seq_) return; + mc->seq_->clearAll(); + // clearAll() also stops the sequencer, so bring the transport UI back in sync. + mc->beats_.playing = false; + mc->seqPlayStep.store(-1); + mc->seqLastStep = -1; + mc->stopAllNotes(); + refreshPlayBtn(mc); + refreshAllStepBtns(mc); +} + +static void cbBpmDown(lv_event_t* e) { + auto* mc = static_cast(lv_event_get_user_data(e)); + if (!mc) return; + mc->beats_.bpm -= 5; + if (mc->beats_.bpm < 40) mc->beats_.bpm = 40; + if (mc->seq_) mc->seq_->setBpm((float)mc->beats_.bpm); + refreshBpmLbl(mc); +} + +static void cbBpmUp(lv_event_t* e) { + auto* mc = static_cast(lv_event_get_user_data(e)); + if (!mc) return; + mc->beats_.bpm += 5; + if (mc->beats_.bpm > 300) mc->beats_.bpm = 300; + if (mc->seq_) mc->seq_->setBpm((float)mc->beats_.bpm); + refreshBpmLbl(mc); +} + +// --------------------------------------------------------------------------- +// Build function +// --------------------------------------------------------------------------- + +void buildBeatsView(MidiController* mc, lv_obj_t* parent) { + auto& bs = mc->beats_; + if (mc->seq_) mc->seq_->setBpm((float)bs.bpm); + + lv_coord_t W = dispW(); + const lv_font_t* fontSmall = lvgl_get_text_font(FONT_SIZE_SMALL); + const lv_font_t* fontNormal = lvgl_get_text_font(FONT_SIZE_DEFAULT); + + int labelW = 152; // track label column width + int ctrlH = 88; // transport row height (matches KEYS) + int btnH = MC_BTN_H; + + // Available width for step grid (excluding track label and row padding) + lv_coord_t gridW = W - labelW - 8 * 2; // row has pad_all=4 each side + // Step button width: account for 15 gaps between 16 steps (pad_gap=4) + int stepW = (gridW - 4 * 15) / Sequencer::NUM_STEPS; + + // Root + lv_obj_t* root = mcContainer(parent); + lv_obj_set_size(root, LV_PCT(100), LV_PCT(100)); + lv_obj_set_flex_flow(root, LV_FLEX_FLOW_COLUMN); + lv_obj_set_style_bg_color(root, lv_color_hex(0x080C12), 0); + lv_obj_set_style_bg_opa(root, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(root, 0, 0); + lv_obj_set_style_pad_all(root, 0, 0); + lv_obj_set_style_pad_gap(root, 0, 0); + lv_obj_set_scrollable(root, false); + mc->currentViewRoot_ = root; + + // -- Transport row (TOP) -------------------------------- + lv_obj_t* transport = mcRow(root, ctrlH); + + // Back button + mcBackBtn(transport, cbBack, mc, MC_W_S); + + lv_obj_t* titleLbl = lv_label_create(transport); + lv_label_set_text(titleLbl, "BEATS"); + lv_obj_set_style_text_font(titleLbl, fontNormal, 0); + lv_obj_set_style_text_color(titleLbl, lv_color_hex(0x44BBFF), 0); + lv_obj_set_flex_grow(titleLbl, 1); + + // PLAY/STOP + lv_obj_t* playBtn = mcButton(transport); + lv_obj_set_size(playBtn, 176, btnH); + lv_obj_set_style_bg_color(playBtn, lv_color_hex(0x00AA44), 0); + lv_obj_add_event_cb(playBtn, cbPlayStop, LV_EVENT_CLICKED, mc); + bs.playBtn = playBtn; + lv_obj_t* playLbl = lv_label_create(playBtn); + lv_label_set_text(playLbl, "PLAY"); + lv_obj_set_style_text_font(playLbl, fontSmall, 0); + lv_obj_center(playLbl); + bs.playLbl = playLbl; + + // CLEAR + lv_obj_t* clearBtn = mcButton(transport); + lv_obj_set_size(clearBtn, 144, btnH); + lv_obj_set_style_bg_color(clearBtn, lv_color_hex(0x331A00), 0); + lv_obj_add_event_cb(clearBtn, cbClear, LV_EVENT_CLICKED, mc); + lv_obj_t* clearLbl = lv_label_create(clearBtn); + lv_label_set_text(clearLbl, "CLEAR"); + lv_obj_set_style_text_font(clearLbl, fontSmall, 0); + lv_obj_center(clearLbl); + + // BPM - + lv_obj_t* bpmDown = mcButton(transport); + lv_obj_set_size(bpmDown, 80, btnH); + lv_obj_add_event_cb(bpmDown, cbBpmDown, LV_EVENT_CLICKED, mc); + lv_obj_t* bdLbl = lv_label_create(bpmDown); + lv_label_set_text(bdLbl, "-"); + lv_obj_set_style_text_font(bdLbl, fontSmall, 0); + lv_obj_center(bdLbl); + + // BPM label + bs.bpmLbl = lv_label_create(transport); + char bpmBuf[8]; snprintf(bpmBuf, sizeof(bpmBuf), "%d", bs.bpm); + lv_label_set_text(bs.bpmLbl, bpmBuf); + lv_obj_set_style_text_font(bs.bpmLbl, fontSmall, 0); + lv_obj_set_style_text_color(bs.bpmLbl, lv_color_hex(0xFFE000), 0); + lv_obj_set_width(bs.bpmLbl, 128); + lv_obj_set_style_text_align(bs.bpmLbl, LV_TEXT_ALIGN_CENTER, 0); + + // BPM + + lv_obj_t* bpmUp = mcButton(transport); + lv_obj_set_size(bpmUp, 80, btnH); + lv_obj_add_event_cb(bpmUp, cbBpmUp, LV_EVENT_CLICKED, mc); + lv_obj_t* buLbl = lv_label_create(bpmUp); + lv_label_set_text(buLbl, "+"); + lv_obj_set_style_text_font(buLbl, fontSmall, 0); + lv_obj_center(buLbl); + + // BPM unit label + lv_obj_t* bpmUnit = lv_label_create(transport); + lv_label_set_text(bpmUnit, "BPM"); + lv_obj_set_style_text_font(bpmUnit, fontSmall, 0); + lv_obj_set_style_text_color(bpmUnit, lv_color_hex(0x666688), 0); + + // -- Track header row (step markers only) -------------------------------- + lv_obj_t* hdrRow = mcContainer(root); + lv_obj_set_size(hdrRow, LV_PCT(100), 64); + lv_obj_set_flex_flow(hdrRow, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(hdrRow, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_set_style_bg_color(hdrRow, lv_color_hex(0x0A0C14), 0); + lv_obj_set_style_bg_opa(hdrRow, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(hdrRow, 0, 0); + lv_obj_set_style_pad_all(hdrRow, 4, 0); + lv_obj_set_style_pad_gap(hdrRow, 0, 0); + lv_obj_set_scrollable(hdrRow, false); + + // Spacer to align step markers with step buttons below + lv_obj_t* hdrSpacer = mcContainer(hdrRow); + lv_obj_set_size(hdrSpacer, labelW + 4, 1); + lv_obj_set_style_bg_opa(hdrSpacer, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(hdrSpacer, 0, 0); + + // Step index labels (beat markers: 1, 5, 9, 13) + for (int s = 0; s < Sequencer::NUM_STEPS; s++) { + if (s % 4 == 0) { + lv_obj_t* marker = lv_label_create(hdrRow); + char buf[4]; snprintf(buf, sizeof(buf), "%d", s + 1); + lv_label_set_text(marker, buf); + lv_obj_set_width(marker, stepW * 4 + 4 * 3); + lv_obj_set_style_text_font(marker, fontSmall, 0); + lv_obj_set_style_text_color(marker, lv_color_hex(0x666688), 0); + lv_obj_set_style_text_align(marker, LV_TEXT_ALIGN_LEFT, 0); + } + } + + // -- Step grid (4 tracks × 16 steps) -------------------------------- + lv_obj_t* gridArea = mcContainer(root); + lv_obj_set_width(gridArea, LV_PCT(100)); + lv_obj_set_flex_grow(gridArea, 1); + lv_obj_set_style_bg_color(gridArea, lv_color_hex(0x080C12), 0); + lv_obj_set_style_bg_opa(gridArea, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(gridArea, 0, 0); + lv_obj_set_style_pad_all(gridArea, 0, 0); + lv_obj_set_style_pad_gap(gridArea, 0, 0); + lv_obj_set_scrollable(gridArea, false); + lv_obj_set_flex_flow(gridArea, LV_FLEX_FLOW_COLUMN); + + for (int t = 0; t < Sequencer::NUM_TRACKS; t++) { + // Track row + lv_obj_t* trackRow = mcContainer(gridArea); + lv_obj_set_width(trackRow, LV_PCT(100)); + lv_obj_set_flex_grow(trackRow, 1); + lv_obj_set_flex_flow(trackRow, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(trackRow, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_set_style_bg_color(trackRow, lv_color_hex(0x080C12), 0); + lv_obj_set_style_bg_opa(trackRow, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(trackRow, 0, 0); + lv_obj_set_style_pad_all(trackRow, 4, 0); + lv_obj_set_style_pad_gap(trackRow, 4, 0); + lv_obj_set_scrollable(trackRow, false); + + // Track label + lv_obj_t* trackLbl = mcContainer(trackRow); + lv_obj_set_size(trackLbl, labelW, LV_PCT(100)); + lv_obj_set_style_bg_color(trackLbl, lv_color_hex(0x0A0C14), 0); + lv_obj_set_style_bg_opa(trackLbl, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(trackLbl, 2, 0); + lv_obj_set_style_border_side(trackLbl, LV_BORDER_SIDE_RIGHT, 0); + lv_obj_set_style_border_color(trackLbl, trackColor(t), 0); + lv_obj_set_style_pad_all(trackLbl, 8, 0); + lv_obj_set_scrollable(trackLbl, false); + + lv_obj_t* tNameLbl = lv_label_create(trackLbl); + lv_label_set_text(tNameLbl, TRACK_NAMES[t]); + lv_obj_set_style_text_font(tNameLbl, fontSmall, 0); + lv_obj_set_style_text_color(tNameLbl, trackColor(t), 0); + lv_obj_center(tNameLbl); + + // 16 step buttons + for (int s = 0; s < Sequencer::NUM_STEPS; s++) { + lv_obj_t* btn = mcButton(trackRow); + lv_obj_set_size(btn, stepW, LV_PCT(90)); + lv_obj_set_style_bg_color(btn, lv_color_hex(0x1A2A40), 0); + lv_obj_set_style_bg_opa(btn, LV_OPA_COVER, 0); + lv_obj_set_style_radius(btn, 2, 0); + lv_obj_set_style_pad_all(btn, 0, 0); + lv_obj_set_style_border_width(btn, 0, 0); + + // Beat boundary: slight highlight on every 4th step column + if (isBeatBoundary(s)) { + lv_obj_set_style_border_width(btn, 1, LV_PART_MAIN); + lv_obj_set_style_border_side(btn, LV_BORDER_SIDE_LEFT, LV_PART_MAIN); + lv_obj_set_style_border_color(btn, lv_color_hex(0x334455), LV_PART_MAIN); + } + + // Pack track+step into user_data + lv_obj_set_user_data(btn, (void*)(intptr_t)((t << 8) | s)); + lv_obj_add_event_cb(btn, cbStepBtn, LV_EVENT_CLICKED, mc); + + bs.stepBtn[t][s] = btn; + } + } + + // Sync UI state with current playback status + refreshPlayBtn(mc); + refreshAllStepBtns(mc); +} diff --git a/Apps/MidiController/main/Source/ViewChord.cpp b/Apps/MidiController/main/Source/ViewChord.cpp new file mode 100644 index 0000000..83cf5a1 --- /dev/null +++ b/Apps/MidiController/main/Source/ViewChord.cpp @@ -0,0 +1,298 @@ +#include "MidiController.h" + +#include +#include +#include +#include + +// --------------------------------------------------------------------------- +// CHORD view - Diatonic chord player +// +// 8 chord buttons (I-VII + I octave up) filling the main area. +// Control row at bottom: OCT- | OCT+ | SCALE | CLEAR +// --------------------------------------------------------------------------- + +// Diatonic chord Roman numeral labels +static const char* CHORD_LABELS[] = { "I", "ii", "iii", "IV", "V", "vi", "vii\xc2\xb0", "I+" }; +// Chord type per diatonic degree: 0=MAJ 1=MIN 2=DIM +static const int CHORD_TYPE[8] = { 0, 1, 1, 0, 0, 1, 2, 0 }; +// Diatonic scale degree index for getNoteInScale (degree 0-6, degree 7 = degree 0 +1 octave) +static const int DEGREE_IDX[8] = { 0, 1, 2, 3, 4, 5, 6, 0 }; +// Octave offset per degree (0=same octave, 1=octave up for I+) +static const int DEGREE_OCT[8] = { 0, 0, 0, 0, 0, 0, 0, 1 }; +// Per-degree color palette +static const uint32_t DEGREE_COLORS[8] = { + 0xFF3333, 0xFF8800, 0xFFFF00, 0x33CC33, + 0x3399FF, 0xAA33FF, 0xFF33CC, 0xFF3333 +}; + +// Chord voicing intervals (up to 4 notes, -1 = unused) +static const int CHORD_VOICING[3][4] = { + { 0, 4, 7, -1 }, // MAJ + { 0, 3, 7, -1 }, // MIN + { 0, 3, 6, -1 }, // DIM +}; + + +static void playChord(MidiController* mc, int degree) { + auto& ch = mc->chord_; + const int* voicing = CHORD_VOICING[CHORD_TYPE[degree]]; + int rootNote = getNoteInScale(ch.scale, DEGREE_IDX[degree], ch.octave + DEGREE_OCT[degree], ch.key); + rootNote = std::max(0, std::min(127, rootNote)); + + for (int n = 0; n < 4; n++) { + if (voicing[n] < 0) { + ch.chordNotes[degree][n] = -1; + continue; + } + int note = std::max(0, std::min(127, rootNote + voicing[n])); + ch.chordNotes[degree][n] = note; + mc->sendMidi(0x90, (uint8_t)note, 100); + } +} + +static void refreshChordBtn(MidiController* mc, int degree) { + lv_obj_t* btn = mc->chord_.chordBtn[degree]; + if (!btn) return; + bool pressed = mc->chord_.chordPlaying[degree]; + lv_color_t col = lv_color_hex(DEGREE_COLORS[degree]); + if (pressed) { + lv_obj_set_style_bg_color(btn, col, LV_PART_MAIN); + lv_obj_set_style_bg_opa(btn, LV_OPA_COVER, LV_PART_MAIN); + // Dark text on bright bg + lv_obj_t* lbl = lv_obj_get_child(btn, 0); + if (lbl) lv_obj_set_style_text_color(lbl, lv_color_hex(0x080C12), LV_PART_MAIN); + } else { + lv_obj_set_style_bg_color(btn, lv_color_hex(0x1A2A40), LV_PART_MAIN); + lv_obj_set_style_bg_opa(btn, LV_OPA_COVER, LV_PART_MAIN); + lv_obj_t* lbl = lv_obj_get_child(btn, 0); + if (lbl) lv_obj_set_style_text_color(lbl, col, LV_PART_MAIN); + } +} + +static void clearAllChordState(MidiController* mc) { + for (int i = 0; i < 8; i++) { + mc->chord_.chordPlaying[i] = false; + refreshChordBtn(mc, i); + } +} + +static void rebuildChordBtnLabels(MidiController* mc) { + auto& ch = mc->chord_; + for (int i = 0; i < 8; i++) { + if (!ch.chordBtn[i]) continue; + lv_obj_t* lblObj = lv_obj_get_child(ch.chordBtn[i], 0); + if (lblObj) { + int rootNote = std::max(0, std::min(127, getNoteInScale(ch.scale, DEGREE_IDX[i], ch.octave + DEGREE_OCT[i], ch.key))); + char noteName[8]; midiNoteName(rootNote, noteName, sizeof(noteName)); + char buf[16]; snprintf(buf, sizeof(buf), "%s\n%s", CHORD_LABELS[i], noteName); + lv_label_set_text(lblObj, buf); + } + } +} + +// --------------------------------------------------------------------------- +// Callbacks +// --------------------------------------------------------------------------- + +static void cbBack(lv_event_t* e) { + auto* mc = static_cast(lv_event_get_user_data(e)); + if (mc) mc->switchToMode(MidiMode::HOME); +} + +static void cbChordPressed(lv_event_t* e) { + auto* mc = static_cast(lv_event_get_user_data(e)); + if (!mc) return; + lv_obj_t* btn = lv_event_get_target_obj(e); + int degree = (int)(intptr_t)lv_obj_get_user_data(btn); + mc->chord_.chordPlaying[degree] = true; + playChord(mc, degree); + refreshChordBtn(mc, degree); +} + +static void cbChordReleased(lv_event_t* e) { + auto* mc = static_cast(lv_event_get_user_data(e)); + if (!mc) return; + lv_obj_t* btn = lv_event_get_target_obj(e); + int degree = (int)(intptr_t)lv_obj_get_user_data(btn); + mc->chord_.chordPlaying[degree] = false; + // Use stored notes from press to avoid note-off mismatch if octave/scale changed + for (int n = 0; n < 4; n++) { + int note = mc->chord_.chordNotes[degree][n]; + if (note >= 0) mc->sendMidi(0x80, (uint8_t)note, 0); + } + refreshChordBtn(mc, degree); +} + +static void cbOctDown(lv_event_t* e) { + auto* mc = static_cast(lv_event_get_user_data(e)); + if (!mc) return; + mc->stopAllNotes(); + clearAllChordState(mc); + if (mc->chord_.octave > 1) mc->chord_.octave--; + rebuildChordBtnLabels(mc); + if (mc->chord_.octLbl) { + char buf[8]; snprintf(buf, sizeof(buf), "Oct %d", mc->chord_.octave); + lv_label_set_text(mc->chord_.octLbl, buf); + } +} +static void cbOctUp(lv_event_t* e) { + auto* mc = static_cast(lv_event_get_user_data(e)); + if (!mc) return; + mc->stopAllNotes(); + clearAllChordState(mc); + if (mc->chord_.octave < 7) mc->chord_.octave++; + rebuildChordBtnLabels(mc); + if (mc->chord_.octLbl) { + char buf[8]; snprintf(buf, sizeof(buf), "Oct %d", mc->chord_.octave); + lv_label_set_text(mc->chord_.octLbl, buf); + } +} + +static void cbScaleCycle(lv_event_t* e) { + auto* mc = static_cast(lv_event_get_user_data(e)); + if (!mc) return; + mc->stopAllNotes(); + clearAllChordState(mc); + mc->chord_.scale = (mc->chord_.scale + 1) % NUM_MIDI_SCALES; + rebuildChordBtnLabels(mc); + if (mc->chord_.scaleLbl) { + lv_label_set_text(mc->chord_.scaleLbl, MIDI_SCALES[mc->chord_.scale].name); + } +} + +static void cbClear(lv_event_t* e) { + auto* mc = static_cast(lv_event_get_user_data(e)); + if (!mc) return; + mc->stopAllNotes(); + for (int i = 0; i < 8; i++) { + mc->chord_.chordPlaying[i] = false; + refreshChordBtn(mc, i); + } +} + +// --------------------------------------------------------------------------- +// Build function +// --------------------------------------------------------------------------- + +void buildChordView(MidiController* mc, lv_obj_t* parent) { + auto& ch = mc->chord_; + // Mode switching sends All Notes Off, so any held-chord flags from the + // previous visit are stale; rebuild the buttons in the released state. + for (auto& playing : ch.chordPlaying) playing = false; + + const lv_font_t* fontSmall = lvgl_get_text_font(FONT_SIZE_SMALL); + const lv_font_t* fontNormal = lvgl_get_text_font(FONT_SIZE_DEFAULT); + int ctrlH = 88; + int btnH = MC_BTN_H; + + lv_obj_t* root = mcContainer(parent); + lv_obj_set_size(root, LV_PCT(100), LV_PCT(100)); + lv_obj_set_flex_flow(root, LV_FLEX_FLOW_COLUMN); + lv_obj_set_style_bg_color(root, lv_color_hex(0x080C12), 0); + lv_obj_set_style_bg_opa(root, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(root, 0, 0); + lv_obj_set_style_pad_all(root, 0, 0); + lv_obj_set_style_pad_gap(root, 0, 0); + lv_obj_set_scrollable(root, false); + mc->currentViewRoot_ = root; + + // -- Control row (TOP) ---------------------------------------------------- + lv_obj_t* ctrl = mcRow(root, ctrlH); + + // Back + mcBackBtn(ctrl, cbBack, mc, 96); + + lv_obj_t* titleLbl = lv_label_create(ctrl); + lv_label_set_text(titleLbl, "CHORD"); + lv_obj_set_style_text_font(titleLbl, fontNormal, 0); + lv_obj_set_style_text_color(titleLbl, lv_color_hex(0x44BBFF), 0); + lv_obj_set_flex_grow(titleLbl, 1); + + // OCT - + lv_obj_t* octDown = mcButton(ctrl); + lv_obj_set_size(octDown, 128, btnH); + lv_obj_add_event_cb(octDown, cbOctDown, LV_EVENT_CLICKED, mc); + lv_obj_t* odl = lv_label_create(octDown); + lv_label_set_text(odl, "OCT-"); + lv_obj_set_style_text_font(odl, fontSmall, 0); + lv_obj_center(odl); + + // OCT label + ch.octLbl = lv_label_create(ctrl); + char obuf[8]; snprintf(obuf, sizeof(obuf), "Oct %d", ch.octave); + lv_label_set_text(ch.octLbl, obuf); + lv_obj_set_style_text_font(ch.octLbl, fontSmall, 0); + lv_obj_set_style_text_color(ch.octLbl, lv_color_hex(0x666688), 0); + lv_obj_set_width(ch.octLbl, 144); + lv_obj_set_style_text_align(ch.octLbl, LV_TEXT_ALIGN_CENTER, 0); + + // OCT + + lv_obj_t* octUp = mcButton(ctrl); + lv_obj_set_size(octUp, 128, btnH); + lv_obj_add_event_cb(octUp, cbOctUp, LV_EVENT_CLICKED, mc); + lv_obj_t* oul = lv_label_create(octUp); lv_label_set_text(oul, "OCT+"); + lv_obj_set_style_text_font(oul, fontSmall, 0); lv_obj_center(oul); + + // SCALE + lv_obj_t* scaleBtn = mcButton(ctrl); + lv_obj_set_size(scaleBtn, 224, btnH); + lv_obj_add_event_cb(scaleBtn, cbScaleCycle, LV_EVENT_CLICKED, mc); + ch.scaleLbl = lv_label_create(scaleBtn); + lv_label_set_text(ch.scaleLbl, MIDI_SCALES[ch.scale].name); + lv_obj_set_style_text_font(ch.scaleLbl, fontSmall, 0); + lv_obj_center(ch.scaleLbl); + + // CLEAR + lv_obj_t* clearBtn = mcButton(ctrl); + lv_obj_set_size(clearBtn, 176, btnH); + lv_obj_set_style_bg_color(clearBtn, lv_color_hex(0x331A00), 0); + lv_obj_add_event_cb(clearBtn, cbClear, LV_EVENT_CLICKED, mc); + lv_obj_t* clearLbl = lv_label_create(clearBtn); + lv_label_set_text(clearLbl, "CLEAR"); + lv_obj_set_style_text_font(clearLbl, fontSmall, 0); + lv_obj_center(clearLbl); + + // -- Chord button row (fills remaining space) ----------------------------- + lv_obj_t* chordRow = mcContainer(root); + lv_obj_set_width(chordRow, LV_PCT(100)); + lv_obj_set_flex_grow(chordRow, 1); + lv_obj_set_flex_flow(chordRow, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(chordRow, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_set_style_bg_color(chordRow, lv_color_hex(0x080C12), 0); + lv_obj_set_style_bg_opa(chordRow, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(chordRow, 0, 0); + lv_obj_set_style_pad_all(chordRow, 8, 0); + lv_obj_set_style_pad_gap(chordRow, 8, 0); + lv_obj_set_scrollable(chordRow, false); + + for (int i = 0; i < 8; i++) { + lv_obj_t* btn = mcButton(chordRow); + lv_obj_set_flex_grow(btn, 1); + lv_obj_set_height(btn, LV_PCT(100)); + lv_obj_set_style_bg_color(btn, lv_color_hex(0x1A2A40), 0); + lv_obj_set_style_bg_opa(btn, LV_OPA_COVER, 0); + lv_obj_set_style_border_color(btn, lv_color_hex(DEGREE_COLORS[i]), 0); + lv_obj_set_style_border_width(btn, 8, 0); + lv_obj_set_style_radius(btn, 16, 0); + lv_obj_set_style_pad_all(btn, 8, 0); + lv_obj_set_user_data(btn, (void*)(intptr_t)i); + lv_obj_add_event_cb(btn, cbChordPressed, LV_EVENT_PRESSED, mc); + lv_obj_add_event_cb(btn, cbChordReleased, LV_EVENT_RELEASED, mc); + + // Label: Roman numeral + root note name with octave (two lines) + int rootNote = std::max(0, std::min(127, getNoteInScale(ch.scale, DEGREE_IDX[i], ch.octave + DEGREE_OCT[i], ch.key))); + char rootName[8]; midiNoteName(rootNote, rootName, sizeof(rootName)); + char lbuf[16]; snprintf(lbuf, sizeof(lbuf), "%s\n%s", CHORD_LABELS[i], rootName); + lv_obj_t* lbl = lv_label_create(btn); + lv_label_set_text(lbl, lbuf); + lv_obj_set_style_text_font(lbl, fontSmall, 0); + lv_obj_set_style_text_color(lbl, lv_color_hex(DEGREE_COLORS[i]), 0); + lv_obj_set_style_text_align(lbl, LV_TEXT_ALIGN_CENTER, 0); + lv_obj_center(lbl); + lv_obj_set_clickable(lbl, false); + + ch.chordBtn[i] = btn; + } + +} diff --git a/Apps/MidiController/main/Source/ViewDimensions.cpp b/Apps/MidiController/main/Source/ViewDimensions.cpp new file mode 100644 index 0000000..1227f1e --- /dev/null +++ b/Apps/MidiController/main/Source/ViewDimensions.cpp @@ -0,0 +1,575 @@ +#include "MidiController.h" + +#include +#include +#include +#include +#include +#include +#include + +// --------------------------------------------------------------------------- +// DIMENSIONS view - parametric equation sequencer (20 equations) +// +// Layout (flex column): +// [row1: < | EQ- | Eq:N | EQ+ | PLAY | RESET] +// [row2: A- | A:N | A+ B- | B:N | B+] +// [row3: C- | C:N | C+ D- | D:N | D+] +// [outArea flex_grow: px:N py:N pz:N t:N] +// [row5: OCT- | Oct N | OCT+ RND- | Rnd:N | RND+ BPM- | N | BPM+] +// --------------------------------------------------------------------------- + + +// --------------------------------------------------------------------------- +// Parametric equations +// --------------------------------------------------------------------------- + +static inline float sq(float x) { return x * x; } + +// Arduino random() compat - uniform int in [lo, hi) +static inline int rng_range(int lo, int hi) { + if (hi <= lo) return lo; + return lo + (int)((uint32_t)(esp_random()) % (uint32_t)(hi - lo)); +} + +static void dimsEval(int eq, float t, float a, float b, float c, float d, + float rnd, float x, float y, + float& px, float& py, float& pz) { + if (a == 0) a = 0.001f; + if (b == 0) b = 0.001f; + if (c == 0) c = 0.001f; + if (d == 0) d = 0.001f; + + constexpr float PI = 3.14159265f; + float px_f, py_f, pz_f; + + switch (eq) { + case 1: + px_f = x + t * sinf(a * t + PI/2.0f); + py_f = y + t * sinf(b * t); + pz_f = t * cosf(a * t / PI * c); + break; + case 2: { + float denom2 = (a - b); + if (fabsf(denom2) < 0.01f) denom2 = (denom2 >= 0.0f) ? 0.01f : -0.01f; + px_f = x + t * sinf(a * t + PI/2.0f) + cosf(sq(a+b)/PI) * tanf(a); + py_f = y + t * sinf(b * t) * (tanf((t * a)/denom2) / (b * 4.0f)); + pz_f = t * cosf(a * t / PI); + } + break; + case 3: + px_f = x + t * sinf(a * t + PI/(c + 1.0f)) + cosf(sq(a+b)/(c + 0.5f)); + py_f = y + t * sinf(c * t) * (tanf((t * a)/(c-b + 0.12f)) / ((b * 4.0f) + 0.5f)); + pz_f = t * sinf(a * t / PI); + break; + case 4: + px_f = x + t * sinf(a * t + PI/c) + cosf(sq(PI/2.0f)/c); + py_f = y + t * sinf(b * t/2.0f) * cosf(t/PI * a); + pz_f = t * sinf(c * t / PI); + break; + case 5: + px_f = x + t * sinf(a * t/2.0f) + (rnd * (PI / (float)rng_range(2,30) * 1.75f)); + py_f = y + t * sinf(b * t) + (rnd * (PI / (float)rng_range(5,25) * c)); + pz_f = t * cosf(a * t / PI * c); + break; + case 6: + px_f = x + (float)rng_range(-64,64); + py_f = y + (float)rng_range(-64,64); + pz_f = t * (float)rng_range(0,127); + break; + case 7: + px_f = x + t * sinf(a * t + PI/3.0f) * (PI / powf(b, 2.0f)); + py_f = y + t * sinf(b * t); + pz_f = t * cosf(a * t / PI * c * t); + break; + case 8: + px_f = x + t * sinf(a * t + PI/2.0f) - (t * cosf(a * t + PI/4.0f)); + py_f = y + t * sinf(b * t) + (y - t * cosf(0.25f * b * t)); + pz_f = t * cosf(a * t / PI * c); + break; + case 9: + px_f = x + t * sinf(a * t + PI/25.0f) * (d * 0.5f); + py_f = y + t * cosf(-b * t + PI/130.0f) * (d * 0.5f) + t * sinf(a * t + PI/25.0f); + pz_f = t * sinf(t / PI * c); + break; + case 10: + px_f = x + t * sinf(a * t + PI/2.0f) * logf(16.0f)/b * 2.0f; + py_f = y + t * sinf(b * t) + sinf(d * t * 100.0f) + tanf(PI * t); + pz_f = t * 0.5f * sinf(t / PI * c + 0.25f); + break; + case 11: + px_f = x + t * sinf(a * t + PI/3.0f * 0.025f); + py_f = y + t * sinf(b * 0.05f * t * cosf(b * t * 0.05f)) + rnd; + pz_f = t * tanf(a * t / PI * c * 0.025f) + rnd * 1.0f + d; + break; + case 12: + px_f = x + t * sinf(a * t + PI/2.0f); + py_f = y + (float)rng_range(-64,64) * rnd * 1.0f/64.0f; + pz_f = t * cosf(d * t / PI * c) * 1.0f + b * sinf(t * 2.25f); + break; + case 13: { + float bmod = (b == 0) ? 1.0f : b; + px_f = x + t * sinf(fmodf(a, bmod) * t) + sinf(PI/3.0f * c); + py_f = y + t * sinf(t * c) + cosf(fmodf(a, bmod)) * 20.25f; + pz_f = t * cosf(a * t / PI); + break; + } + case 14: + px_f = x + (float)rng_range(-64,64) * rnd * 1.0f/64.0f; + py_f = y + t * sinf(a * t + PI/2.0f); + pz_f = t * sinf(b * t / PI) * 2.0f + b * cosf(t * 20.0f); + break; + case 15: + px_f = x + t * tanf(a * t + PI * sinf(b * t)) * 0.75f; + py_f = y + t * tanf(b * t) - cosf(a * t * 0.5f) * 0.75f; + pz_f = t * cosf(2.0f * a * t / PI * c); + break; + case 16: + px_f = x + t * sinf(a * t + PI/3.0f) - (1.75f * t) * sinf(1.5f * a * t + PI/3.0f) * 1.0f/c * 0.5f; + py_f = y + t * sinf(b * t + PI/3.0f) - (1.75f * t) * sinf(1.5f * b * t + PI/3.0f) * 1.0f/c * 0.5f; + pz_f = y + t * sinf(d * t + PI/3.0f) - (1.75f * t) * cosf(1.5f * d * t + PI/2.0f) * 1.0f/c * 0.5f; + break; + case 17: + px_f = x + t * sinf(a * t + PI); + py_f = y + t * tanf(b * t - PI); + pz_f = t * cosf(a * t / PI * c); + break; + case 18: { + int r = (int)fabsf(rnd); + if (r == 0) r = 1; + px_f = x + (float)rng_range(-r, r); + py_f = y + (float)rng_range(-r, r); + pz_f = t * (float)rng_range(0, r); + break; + } + case 19: + px_f = x + t * sinf(a * t + PI/2.0f) + cosf(sq(a+b)/PI) * tanf(a) + rnd; + py_f = y + t * sinf(b/c * t) * (tanf((t * a)/(a+b)) / (b * 4.0f)) + d/2.25f; + pz_f = t * tanf(c * t / PI); + break; + case 20: + px_f = x + t * sinf(a * t + (PI / powf(c, 3.0f))); + py_f = y + (t * sinf(b * t/2.0f) * (tanf(t/PI * a) * 0.25f)) - d; + pz_f = t/2.25f * tanf(c * t/4.0f) * rnd; + break; + default: + px_f = x; py_f = y; pz_f = 64.0f; + break; + } + + // Clamp 0-127; guard NaN/Inf + auto guard = [](float v) -> float { + if (!std::isfinite(v)) return 64.0f; + return std::max(0.0f, std::min(127.0f, v)); + }; + px = guard(px_f); + py = guard(py_f); + pz = guard(pz_f); +} + +// px (0-127) → BPM-derived ms per step (10 clock-division tiers) +static uint32_t pxToMs(float px_val, int bpm) { + // Tiers range from a whole note (16 sixteenths) to a 32nd note (0.5). + static const float DIVIDERS[10] = { + 16.0f, 12.0f, 8.0f, 6.0f, 4.0f, 3.0f, 2.0f, 4.0f/3.0f, 1.0f, 0.5f + }; + int idx = (int)(px_val / 127.0f * 9.0f + 0.5f); + if (idx < 0) idx = 0; + if (idx > 9) idx = 9; + float beatMs = 60000.0f / (float)bpm; + uint32_t ms = (uint32_t)(beatMs * DIVIDERS[idx] / 4.0f); + if (ms < 20) ms = 20; + return ms; +} + +// --------------------------------------------------------------------------- +// Timer callback +// --------------------------------------------------------------------------- + +static void cbDimTimer(lv_timer_t* t) { + auto* mc = static_cast(lv_timer_get_user_data(t)); + if (!mc) return; + auto& ds = mc->dim_; + if (!ds.playing) return; + + uint32_t now = lv_tick_get(); + + // Note-off + if (ds.lastNote >= 0 && now >= ds.noteOffMs) { + mc->sendMidi(0x80, (uint8_t)ds.lastNote, 0); + ds.lastNote = -1; + } + + if (now < ds.tickMs) return; + + // Evaluate equation + dimsEval(ds.equation, ds.t, ds.a, ds.b, ds.c, ds.d, ds.rnd, + ds.xOff, ds.yOff, ds.px, ds.py, ds.pz); + + // Advance t (wraps at t2) + ds.t += ds.gran; + if (ds.t > ds.t2) ds.t = 0.0f; + + // py → note (0-127 mapped to C2-C7, chromatic, before the octave offset) + int note = (int)(ds.py / 127.0f * 60.0f) + 36; // MIDI 36-96 + note += ds.octave * 12; + if (note < 0) note = 0; + if (note > 127) note = 127; + + // pz → velocity + int vel = std::max(1, std::min(127, (int)ds.pz)); + + // Trigger if py above rest threshold (low gate: ~4%) + if (ds.py >= 5.0f) { + if (ds.lastNote >= 0) { + mc->sendMidi(0x80, (uint8_t)ds.lastNote, 0); + } + mc->sendMidi(0x90, (uint8_t)note, (uint8_t)vel); + ds.lastNote = note; + ds.noteOffMs = now + 80; // 80ms gate + } + + // Schedule next tick based on px + uint32_t stepMs = pxToMs(ds.px, ds.bpm); + ds.tickMs = now + stepMs; + + // Update output label + if (ds.outLbl) { + char buf[48]; + snprintf(buf, sizeof(buf), "px:%-3d py:%-3d pz:%-3d t:%-5.1f", + (int)ds.px, (int)ds.py, (int)ds.pz, ds.t); + lv_label_set_text(ds.outLbl, buf); + } +} + +// Forward declarations for public encoder entry points (defined at file end) +void dimTogglePlay(MidiController* mc); +void dimReset (MidiController* mc); + +// --------------------------------------------------------------------------- +// Callbacks +// --------------------------------------------------------------------------- + +static void cbDimBack(lv_event_t* e) { + auto* mc = static_cast(lv_event_get_user_data(e)); + if (mc) mc->switchToMode(MidiMode::HOME); +} + +static void cbDimPlay(lv_event_t* e) { + auto* mc = static_cast(lv_event_get_user_data(e)); + if (mc) dimTogglePlay(mc); +} + +static void cbDimReset(lv_event_t* e) { + auto* mc = static_cast(lv_event_get_user_data(e)); + if (!mc) return; + dimReset(mc); +} + +static void cbDimEqDown(lv_event_t* e) { + auto* mc = static_cast(lv_event_get_user_data(e)); + if (!mc) return; + auto& ds = mc->dim_; + ds.equation = (ds.equation > 1) ? ds.equation - 1 : 20; + if (ds.eqLbl) { + char buf[16]; snprintf(buf, sizeof(buf), "Eq:%d", ds.equation); + lv_label_set_text(ds.eqLbl, buf); + } +} +static void cbDimEqUp(lv_event_t* e) { + auto* mc = static_cast(lv_event_get_user_data(e)); + if (!mc) return; + auto& ds = mc->dim_; + ds.equation = (ds.equation < 20) ? ds.equation + 1 : 1; + if (ds.eqLbl) { + char buf[16]; snprintf(buf, sizeof(buf), "Eq:%d", ds.equation); + lv_label_set_text(ds.eqLbl, buf); + } +} + +// Generic param adjust helpers - user_data encodes which param + direction +// We use a small enum packing: low bit = direction (0=down 1=up), rest = param idx (0=a,1=b,2=c,3=d) +struct DimParamEvt { MidiController* mc; int param; int dir; }; +static DimParamEvt s_paramEvt[8]; // 4 params × 2 directions + +static void cbDimParam(lv_event_t* e) { + auto* pe = static_cast(lv_event_get_user_data(e)); + if (!pe || !pe->mc) return; + auto& ds = pe->mc->dim_; + float* p[4] = {&ds.a, &ds.b, &ds.c, &ds.d}; + lv_obj_t** lbls[4] = {&ds.aLbl, &ds.bLbl, &ds.cLbl, &ds.dLbl}; + static const char* names[4] = {"A", "B", "C", "D"}; + float& v = *p[pe->param]; + v += (pe->dir > 0) ? 1.0f : -1.0f; + if (v < 0) v = 0; + if (v > 100) v = 100; + if (*lbls[pe->param]) { + char buf[12]; snprintf(buf, sizeof(buf), "%s:%.0f", names[pe->param], v); + lv_label_set_text(*lbls[pe->param], buf); + } +} + +static void cbDimOctDown(lv_event_t* e) { + auto* mc = static_cast(lv_event_get_user_data(e)); + if (!mc) return; + auto& ds = mc->dim_; + if (ds.octave > -4) ds.octave--; + if (ds.octLbl) { + char buf[10]; snprintf(buf, sizeof(buf), "Oct%+d", ds.octave); + lv_label_set_text(ds.octLbl, buf); + } +} +static void cbDimOctUp(lv_event_t* e) { + auto* mc = static_cast(lv_event_get_user_data(e)); + if (!mc) return; + auto& ds = mc->dim_; + if (ds.octave < 4) ds.octave++; + if (ds.octLbl) { + char buf[10]; snprintf(buf, sizeof(buf), "Oct%+d", ds.octave); + lv_label_set_text(ds.octLbl, buf); + } +} + +static void cbDimRndDown(lv_event_t* e) { + auto* mc = static_cast(lv_event_get_user_data(e)); + if (!mc) return; + auto& ds = mc->dim_; + if (ds.rnd > 0) ds.rnd -= 5.0f; + if (ds.rnd < 0) ds.rnd = 0; + if (ds.rndLbl) { + char buf[10]; snprintf(buf, sizeof(buf), "R:%.0f", ds.rnd); + lv_label_set_text(ds.rndLbl, buf); + } +} +static void cbDimRndUp(lv_event_t* e) { + auto* mc = static_cast(lv_event_get_user_data(e)); + if (!mc) return; + auto& ds = mc->dim_; + if (ds.rnd < 64) ds.rnd += 5.0f; + if (ds.rndLbl) { + char buf[10]; snprintf(buf, sizeof(buf), "R:%.0f", ds.rnd); + lv_label_set_text(ds.rndLbl, buf); + } +} + +static void cbDimBpmDown(lv_event_t* e) { + auto* mc = static_cast(lv_event_get_user_data(e)); + if (!mc) return; + auto& ds = mc->dim_; + if (ds.bpm > 40) ds.bpm -= 5; + if (ds.bpmLbl) { + char buf[8]; snprintf(buf, sizeof(buf), "%d", ds.bpm); + lv_label_set_text(ds.bpmLbl, buf); + } +} +static void cbDimBpmUp(lv_event_t* e) { + auto* mc = static_cast(lv_event_get_user_data(e)); + if (!mc) return; + auto& ds = mc->dim_; + if (ds.bpm < 280) ds.bpm += 5; + if (ds.bpmLbl) { + char buf[8]; snprintf(buf, sizeof(buf), "%d", ds.bpm); + lv_label_set_text(ds.bpmLbl, buf); + } +} + +// --------------------------------------------------------------------------- +// Build +// --------------------------------------------------------------------------- + +void buildDimensionsView(MidiController* mc, lv_obj_t* parent) { + auto& ds = mc->dim_; + + const lv_font_t* fontSmall = lvgl_get_text_font(FONT_SIZE_SMALL); + const lv_font_t* fontNormal = lvgl_get_text_font(FONT_SIZE_DEFAULT); + + int smallBtnW = 88; + int valW = 152; + + lv_obj_t* root = mcContainer(parent); + lv_obj_set_size(root, LV_PCT(100), LV_PCT(100)); + lv_obj_set_flex_flow(root, LV_FLEX_FLOW_COLUMN); + lv_obj_set_style_bg_color(root, lv_color_hex(0x080C14), 0); + lv_obj_set_style_bg_opa(root, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(root, 0, 0); + lv_obj_set_style_pad_all(root, 0, 0); + lv_obj_set_style_pad_gap(root, 8, 0); + lv_obj_set_scrollable(root, false); + mc->currentViewRoot_ = root; + + auto makeRow = [&]() -> lv_obj_t* { return mcRow(root); }; + + auto makeBtn = [&](lv_obj_t* par, const char* txt, int w, lv_event_cb_t cb, void* ud) -> lv_obj_t* { + return mcSmallBtn(par, txt, w, cb, ud); + }; + + auto makeLbl = [&](lv_obj_t* par, const char* txt, int w, lv_color_t col) -> lv_obj_t* { + return mcValue(par, txt, w, col); + }; + + auto makeSepLbl = [&](lv_obj_t* par) { + lv_obj_t* sep = lv_label_create(par); + lv_label_set_text(sep, "|"); + lv_obj_set_style_text_font(sep, fontSmall, 0); + lv_obj_set_style_text_color(sep, lv_color_hex(0x334455), 0); + }; + + // -- Row 1: [<] [EQ-] [Eq:N] [EQ+] | [PLAY] [RESET] ----------------------- + { + lv_obj_t* row = makeRow(); + mcBackBtn(row, cbDimBack, mc, MC_W_S); + lv_obj_t* titleLbl = lv_label_create(row); + lv_label_set_text(titleLbl, "DIMENSIONS"); + lv_obj_set_style_text_font(titleLbl, fontNormal, 0); + lv_obj_set_style_text_color(titleLbl, lv_color_hex(0x44BBFF), 0); + lv_obj_set_flex_grow(titleLbl, 1); + makeBtn(row, "<", smallBtnW, cbDimEqDown, mc); + char buf[16]; snprintf(buf, sizeof(buf), "Eq:%d", ds.equation); + ds.eqLbl = makeLbl(row, buf, 144, lv_color_hex(0xFF8844)); + makeBtn(row, ">", smallBtnW, cbDimEqUp, mc); + makeSepLbl(row); + lv_obj_t* playBtn = makeBtn(row, ds.playing ? "STOP" : "PLAY", 176, cbDimPlay, mc); + ds.playLbl = lv_obj_get_child(playBtn, 0); + makeBtn(row, "RESET", 192, cbDimReset, mc); + } + + // -- Row 2: A and B params -------------------------------------------------- + { + lv_obj_t* row = makeRow(); + // A + s_paramEvt[0] = {mc, 0, -1}; + s_paramEvt[1] = {mc, 0, +1}; + makeBtn(row, "A-", smallBtnW, cbDimParam, &s_paramEvt[0]); + char bufA[12]; snprintf(bufA, sizeof(bufA), "A:%.0f", ds.a); + ds.aLbl = makeLbl(row, bufA, valW, lv_color_hex(0x88DDFF)); + makeBtn(row, "A+", smallBtnW, cbDimParam, &s_paramEvt[1]); + makeSepLbl(row); + // B + s_paramEvt[2] = {mc, 1, -1}; + s_paramEvt[3] = {mc, 1, +1}; + makeBtn(row, "B-", smallBtnW, cbDimParam, &s_paramEvt[2]); + char bufB[12]; snprintf(bufB, sizeof(bufB), "B:%.0f", ds.b); + ds.bLbl = makeLbl(row, bufB, valW, lv_color_hex(0x88FFCC)); + makeBtn(row, "B+", smallBtnW, cbDimParam, &s_paramEvt[3]); + } + + // -- Row 3: C and D params -------------------------------------------------- + { + lv_obj_t* row = makeRow(); + // C + s_paramEvt[4] = {mc, 2, -1}; + s_paramEvt[5] = {mc, 2, +1}; + makeBtn(row, "C-", smallBtnW, cbDimParam, &s_paramEvt[4]); + char bufC[12]; snprintf(bufC, sizeof(bufC), "C:%.0f", ds.c); + ds.cLbl = makeLbl(row, bufC, valW, lv_color_hex(0xFFCC88)); + makeBtn(row, "C+", smallBtnW, cbDimParam, &s_paramEvt[5]); + makeSepLbl(row); + // D + s_paramEvt[6] = {mc, 3, -1}; + s_paramEvt[7] = {mc, 3, +1}; + makeBtn(row, "D-", smallBtnW, cbDimParam, &s_paramEvt[6]); + char bufD[12]; snprintf(bufD, sizeof(bufD), "D:%.0f", ds.d); + ds.dLbl = makeLbl(row, bufD, valW, lv_color_hex(0xFF88CC)); + makeBtn(row, "D+", smallBtnW, cbDimParam, &s_paramEvt[7]); + } + + // -- Output area (flex_grow) ------------------------------------------------ + { + lv_obj_t* outArea = mcContainer(root); + lv_obj_set_width(outArea, LV_PCT(100)); + lv_obj_set_flex_grow(outArea, 1); + lv_obj_set_style_bg_color(outArea, lv_color_hex(0x05080F), 0); + lv_obj_set_style_bg_opa(outArea, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(outArea, 0, 0); + lv_obj_set_style_pad_all(outArea, 24, 0); + lv_obj_set_scrollable(outArea, false); + lv_obj_set_flex_flow(outArea, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(outArea, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + + ds.outLbl = lv_label_create(outArea); + lv_label_set_text(ds.outLbl, "px: -- py: -- pz: -- t: --"); + lv_obj_set_style_text_font(ds.outLbl, fontNormal, 0); + lv_obj_set_style_text_color(ds.outLbl, lv_color_hex(0x44BBEE), 0); + lv_obj_set_width(ds.outLbl, LV_PCT(100)); + lv_obj_set_style_text_align(ds.outLbl, LV_TEXT_ALIGN_CENTER, 0); + } + + // -- Row 5: OCT RND BPM -------------------------------------------------- + { + lv_obj_t* row = makeRow(); + // Octave + makeBtn(row, "<", smallBtnW, cbDimOctDown, mc); + char bufOct[10]; snprintf(bufOct, sizeof(bufOct), "Oct%+d", ds.octave); + ds.octLbl = makeLbl(row, bufOct, 168, lv_color_hex(0xAAFF88)); + makeBtn(row, ">", smallBtnW, cbDimOctUp, mc); + makeSepLbl(row); + // Rnd + makeBtn(row, "<", smallBtnW, cbDimRndDown, mc); + char bufRnd[10]; snprintf(bufRnd, sizeof(bufRnd), "R:%.0f", ds.rnd); + ds.rndLbl = makeLbl(row, bufRnd, 144, lv_color_hex(0xFFAA44)); + makeBtn(row, ">", smallBtnW, cbDimRndUp, mc); + makeSepLbl(row); + // BPM + makeBtn(row, "<", smallBtnW, cbDimBpmDown, mc); + char bufBpm[8]; snprintf(bufBpm, sizeof(bufBpm), "%d", ds.bpm); + ds.bpmLbl = makeLbl(row, bufBpm, 144, lv_color_hex(0xFFE000)); + makeBtn(row, ">", smallBtnW, cbDimBpmUp, mc); + } + + // -- Start timer (20ms poll) ------------------------------------------------ + mc->dimTimer_ = lv_timer_create(cbDimTimer, 20, mc); + ds.tickMs = lv_tick_get(); +} + +// --------------------------------------------------------------------------- +// Public encoder entry points (called from EncoderInput.cpp) +// --------------------------------------------------------------------------- + +void dimTogglePlay(MidiController* mc) { + auto& ds = mc->dim_; + ds.playing = !ds.playing; + if (!ds.playing && ds.lastNote >= 0) { + mc->sendMidi(0x80, (uint8_t)ds.lastNote, 0); + ds.lastNote = -1; + } + if (ds.playing) ds.tickMs = lv_tick_get(); + if (ds.playLbl) lv_label_set_text(ds.playLbl, ds.playing ? "STOP" : "PLAY"); +} + +void dimReset(MidiController* mc) { + auto& ds = mc->dim_; + ds.t = 0.0f; + ds.a = 50; ds.b = 50; ds.c = 50; ds.d = 50; + if (ds.aLbl) lv_label_set_text(ds.aLbl, "A:50"); + if (ds.bLbl) lv_label_set_text(ds.bLbl, "B:50"); + if (ds.cLbl) lv_label_set_text(ds.cLbl, "C:50"); + if (ds.dLbl) lv_label_set_text(ds.dLbl, "D:50"); + if (ds.lastNote >= 0) { + mc->sendMidi(0x80, (uint8_t)ds.lastNote, 0); + ds.lastNote = -1; + } +} + +void dimChangeOctave(MidiController* mc, int delta) { + auto& ds = mc->dim_; + int oct = ds.octave + delta; + if (oct < -4) oct = -4; + if (oct > 4) oct = 4; + ds.octave = oct; + if (ds.octLbl) { + char buf[10]; snprintf(buf, sizeof(buf), "Oct%+d", oct); + lv_label_set_text(ds.octLbl, buf); + } +} + +void dimChangeRandom(MidiController* mc, int delta) { + auto& ds = mc->dim_; + float r = ds.rnd + (float)delta; + if (r < 0) r = 0; + if (r > 64) r = 64; + ds.rnd = r; + if (ds.rndLbl) { + char buf[8]; snprintf(buf, sizeof(buf), "R:%.0f", r); + lv_label_set_text(ds.rndLbl, buf); + } +} diff --git a/Apps/MidiController/main/Source/ViewDrop.cpp b/Apps/MidiController/main/Source/ViewDrop.cpp new file mode 100644 index 0000000..8ff754c --- /dev/null +++ b/Apps/MidiController/main/Source/ViewDrop.cpp @@ -0,0 +1,505 @@ +#include "MidiController.h" + +#include +#include +#include +#include +#include +#include + +// --------------------------------------------------------------------------- +// DROP view - Gravity-drop physics + platform note triggering +// +// Layout (flex column): +// [physics area - flex_grow, touch to drop/place] +// [control row: < | MODE | OCT- | Oct N | OCT+ | CLEAR | SCALE | KEY- | KEY+ | status] +// +// Two modes: +// DROP (default): touch spawns a ball at touch point +// EDIT: touch places a horizontal platform (up to MAX_PLATFORMS); CLEAR removes all +// --------------------------------------------------------------------------- + + +static const uint32_t DROP_BALL_COLORS[] = { + 0xFF8800, 0xFF4444, 0x44FFAA, 0xAA44FF, + 0xFFFF44, 0x44AAFF, 0xFF44AA, 0x44FF44 +}; + +static constexpr float GRAVITY_BASE = 0.16f; +static constexpr float BOUNCE_COEFF = 0.55f; +static constexpr float FRICTION = 0.98f; +static constexpr float BALL_LIFE_MS = 8000.0f; + +// --------------------------------------------------------------------------- +// Physics helpers +// --------------------------------------------------------------------------- + +struct Vec2 { float x, y; }; +static float dot(Vec2 a, Vec2 b) { return a.x*b.x + a.y*b.y; } + +static void updatePlatformNote(MidiController* mc, int p) { + auto& dr = mc->drop_; + int degree = (p * 2) % 7; + dr.platforms[p].note = getNoteInScale(dr.scale, degree, dr.octave, dr.key); +} + +static void refreshPlatformLabel(MidiController* mc, int p) { + auto& dr = mc->drop_; + if (!dr.canvas || p < 0 || p >= dr.numPlatforms) return; + auto& plt = dr.platforms[p]; + + // Create label widget if it doesn't exist yet + if (!dr.platLbl[p]) { + lv_obj_t* lbl = lv_label_create(dr.canvas); + lv_obj_set_style_text_color(lbl, lv_color_hex(0xFFFFFF), 0); + lv_obj_set_style_text_font(lbl, lvgl_get_text_font(FONT_SIZE_SMALL), 0); + lv_obj_set_style_bg_color(lbl, lv_color_hex(0x000000), 0); + lv_obj_set_style_bg_opa(lbl, LV_OPA_70, 0); + lv_obj_set_style_border_width(lbl, 0, 0); + lv_obj_set_style_pad_all(lbl, 4, 0); + lv_obj_set_style_radius(lbl, 8, 0); + lv_obj_set_scrollable(lbl, false); + lv_obj_set_clickable(lbl, false); + dr.platLbl[p] = lbl; + } + + char noteName[8]; midiNoteName(plt.note, noteName, sizeof(noteName)); + lv_label_set_text(dr.platLbl[p], noteName); + // Size to content so dark bg pill fits text exactly + lv_obj_set_size(dr.platLbl[p], LV_SIZE_CONTENT, LV_SIZE_CONTENT); + // Let LVGL compute content size first, then center on platform + lv_obj_update_layout(dr.platLbl[p]); + lv_coord_t lblW = lv_obj_get_width(dr.platLbl[p]); + lv_coord_t lblH = lv_obj_get_height(dr.platLbl[p]); + lv_obj_set_pos(dr.platLbl[p], + (lv_coord_t)(plt.x - lblW / 2), + (lv_coord_t)(plt.y - lblH / 2)); +} + +static void rebuildPlatformNotes(MidiController* mc) { + auto& dr = mc->drop_; + for (int p = 0; p < dr.numPlatforms; p++) { + updatePlatformNote(mc, p); + refreshPlatformLabel(mc, p); + } +} + +// --------------------------------------------------------------------------- +// Draw event +// --------------------------------------------------------------------------- + +static void cbDropDraw(lv_event_t* e) { + auto* mc = static_cast(lv_event_get_user_data(e)); + if (!mc) return; + auto& dr = mc->drop_; + + lv_obj_t* obj = lv_event_get_target_obj(e); + lv_layer_t* layer = lv_event_get_layer(e); + + lv_area_t area; + lv_obj_get_coords(obj, &area); + int32_t ox = area.x1, oy = area.y1; + + // Background + lv_draw_fill_dsc_t bg; + lv_draw_fill_dsc_init(&bg); + bg.color = lv_color_hex(0x080C12); + bg.opa = LV_OPA_COVER; + lv_draw_fill(layer, &bg, &area); + + if (dr.numPlatforms == 0) return; + + // Note-based platform colors (hue cycle per semitone) + static const uint32_t NOTE_COLORS[12] = { + 0xFF3333, 0xFF6600, 0xFFAA00, 0xFFFF00, + 0x88FF00, 0x00FF88, 0x00FFFF, 0x0088FF, + 0x0033FF, 0x8800FF, 0xFF00FF, 0xFF0088 + }; + + // Draw platforms - line width matches physics platH (half-thickness × 2) + float speedScale = (dr.canvasW > 0) ? (float)dr.canvasW / 220.0f : 1.0f; + lv_draw_line_dsc_t line; + lv_draw_line_dsc_init(&line); + line.width = std::max(2, (int)(6.0f * speedScale * 2.0f)); + line.opa = LV_OPA_COVER; + + for (int p = 0; p < dr.numPlatforms; p++) { + auto& plt = dr.platforms[p]; + float cosA = cosf(plt.angle); + float sinA = sinf(plt.angle); + float hw = plt.w * 0.5f; + line.color = lv_color_hex(NOTE_COLORS[plt.note % 12]); + line.p1.x = (lv_value_precise_t)(ox + plt.x - hw * cosA); + line.p1.y = (lv_value_precise_t)(oy + plt.y - hw * sinA); + line.p2.x = (lv_value_precise_t)(ox + plt.x + hw * cosA); + line.p2.y = (lv_value_precise_t)(oy + plt.y + hw * sinA); + lv_draw_line(layer, &line); + } +} + +// --------------------------------------------------------------------------- +// Physics timer +// --------------------------------------------------------------------------- + +static void cbDropTimer(lv_timer_t* t) { + auto* mc = static_cast(lv_timer_get_user_data(t)); + if (!mc) return; + auto& dr = mc->drop_; + if (!dr.canvas || dr.canvasW <= 0 || dr.canvasH <= 0) return; + + float cW = (float)dr.canvasW; + float cH = (float)dr.canvasH; + float speedScale = cW / 220.0f; + float gravity = GRAVITY_BASE * speedScale; + // Platform half-thickness scales with display so balls hit reliably + float platH = 6.0f * speedScale; + int ballR = 16; + uint32_t nowMs = lv_tick_get(); + + for (int b = 0; b < MidiController::DropState::MAX_BALLS; b++) { + auto& ball = dr.balls[b]; + if (!ball.active) continue; + + if ((nowMs - ball.spawnedMs) > (uint32_t)BALL_LIFE_MS) { + ball.active = false; + continue; + } + + ball.vy += gravity; + ball.vx *= FRICTION; + ball.x += ball.vx; + ball.y += ball.vy; + + // Floor bounce + if (ball.y >= cH - ballR) { + ball.y = cH - ballR; + ball.vy = -ball.vy * BOUNCE_COEFF; + if (fabsf(ball.vy) < 0.5f * speedScale) ball.vy = 0; + } + // Wall clamp + if (ball.x < ballR) ball.x = ballR; + if (ball.x > cW - ballR) ball.x = cW - ballR; + + // Platform collisions + for (int p = 0; p < dr.numPlatforms; p++) { + auto& plt = dr.platforms[p]; + float cosA = cosf(plt.angle); + float sinA = sinf(plt.angle); + float dx = ball.x - plt.x; + float dy = ball.y - plt.y; + float localX = dx * cosA + dy * sinA; + float localY = -dx * sinA + dy * cosA; + float halfW = plt.w * 0.5f; + + if (fabsf(localX) < halfW && localY >= -platH && localY <= platH) { + // Normal perpendicular to platform, pointing toward the ball + Vec2 rawNormal = {-sinA, cosA}; + Vec2 vel = {ball.vx, ball.vy}; + float rawVn = dot(vel, rawNormal); + // Pick the normal direction that opposes ball's velocity + Vec2 normal = (rawVn < 0) ? rawNormal : Vec2{sinA, -cosA}; + float vn = dot(vel, normal); + if (vn < 0) { + ball.vx -= 2.0f * vn * normal.x; + ball.vy -= 2.0f * vn * normal.y; + ball.vx *= BOUNCE_COEFF; + ball.vy *= BOUNCE_COEFF; + // Push ball out along the normal so it clears the platform surface + ball.x = plt.x + (localX) * cosA + (platH + 1.0f) * normal.x; + ball.y = plt.y + (localX) * sinA + (platH + 1.0f) * normal.y; + + if (fabsf(vn) > 1.0f * speedScale) { + int vel_midi = std::min(127, 70 + (int)(fabsf(vn) / speedScale * 5)); + mc->sendMidi(0x90, (uint8_t)plt.note, vel_midi); + mc->sendMidi(0x80, (uint8_t)plt.note, 0); + } + } + } + } + } + + // Update ball widget positions + for (int b = 0; b < MidiController::DropState::MAX_BALLS; b++) { + if (dr.ballObj[b]) { + if (dr.balls[b].active) { + lv_obj_set_pos(dr.ballObj[b], + (lv_coord_t)(dr.balls[b].x - ballR), + (lv_coord_t)(dr.balls[b].y - ballR)); + lv_obj_set_hidden(dr.ballObj[b], false); + } else { + lv_obj_set_hidden(dr.ballObj[b], true); + } + } + } + + lv_obj_invalidate(dr.canvas); +} + +// --------------------------------------------------------------------------- +// Canvas size event - just update dimensions, no auto-platform creation +// --------------------------------------------------------------------------- + +static void cbCanvasSize(lv_event_t* e) { + auto* mc = static_cast(lv_event_get_user_data(e)); + if (!mc) return; + auto& dr = mc->drop_; + lv_obj_t* obj = lv_event_get_target_obj(e); + int w = (int)lv_obj_get_content_width(obj); + int h = (int)lv_obj_get_content_height(obj); + if (w <= 0 || h <= 0) return; + + // If canvas size changed, scale existing platform positions + if (dr.canvasW > 0 && dr.canvasH > 0 && (w != dr.canvasW || h != dr.canvasH)) { + float sx = (float)w / dr.canvasW; + float sy = (float)h / dr.canvasH; + for (int p = 0; p < dr.numPlatforms; p++) { + dr.platforms[p].x *= sx; + dr.platforms[p].y *= sy; + dr.platforms[p].w *= sx; + refreshPlatformLabel(mc, p); + } + } + dr.canvasW = w; + dr.canvasH = h; +} + +// --------------------------------------------------------------------------- +// Touch callback - spawns ball (DROP mode) or places platform (EDIT mode) +// --------------------------------------------------------------------------- + +static void cbCanvasTouch(lv_event_t* e) { + auto* mc = static_cast(lv_event_get_user_data(e)); + if (!mc) return; + auto& dr = mc->drop_; + if (dr.canvasW <= 0 || dr.canvasH <= 0) return; + + lv_indev_t* indev = lv_event_get_indev(e); + if (!indev) return; + lv_point_t pt; + lv_indev_get_point(indev, &pt); + + lv_area_t area; + lv_obj_get_coords(dr.canvas, &area); + float lx = (float)((int)pt.x - (int)area.x1); + float ly = (float)((int)pt.y - (int)area.y1); + if (lx < 0 || ly < 0 || lx > dr.canvasW || ly > dr.canvasH) return; + + if (dr.editMode) { + // Place a new horizontal platform at touch point (up to MAX_PLATFORMS) + if (dr.numPlatforms >= MidiController::DropState::MAX_PLATFORMS) return; + int p = dr.numPlatforms; + dr.platforms[p].x = lx; + dr.platforms[p].y = ly; + dr.platforms[p].w = (float)dr.canvasW * 0.22f; + dr.platforms[p].angle = 0.0f; // always horizontal + updatePlatformNote(mc, p); + dr.numPlatforms++; + refreshPlatformLabel(mc, p); + lv_obj_invalidate(dr.canvas); + } else { + // Spawn a ball at touch point + int ballR = 16; + for (int b = 0; b < MidiController::DropState::MAX_BALLS; b++) { + if (!dr.balls[b].active) { + dr.balls[b].x = lx; + dr.balls[b].y = ly; + float vx = ((float)(esp_random() % 201) - 100.0f) * 0.012f * ((float)dr.canvasW / 220.0f); + dr.balls[b].vx = vx; + dr.balls[b].vy = 0.0f; + dr.balls[b].active = true; + dr.balls[b].spawnedMs = lv_tick_get(); + if (!dr.ballObj[b]) { + lv_obj_t* w = mcContainer(dr.canvas); + lv_obj_set_size(w, ballR * 2, ballR * 2); + lv_obj_set_style_radius(w, LV_RADIUS_CIRCLE, 0); + lv_obj_set_style_bg_color(w, lv_color_hex(DROP_BALL_COLORS[b % 8]), 0); + lv_obj_set_style_bg_opa(w, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(w, 0, 0); + lv_obj_set_style_pad_all(w, 0, 0); + lv_obj_set_scrollable(w, false); + lv_obj_set_clickable(w, false); + dr.ballObj[b] = w; + } + lv_obj_set_pos(dr.ballObj[b], (lv_coord_t)(lx - ballR), (lv_coord_t)(ly - ballR)); + lv_obj_set_hidden(dr.ballObj[b], false); + break; + } + } + } +} + +// --------------------------------------------------------------------------- +// Control callbacks +// --------------------------------------------------------------------------- + +static void cbBack(lv_event_t* e) { + auto* mc = static_cast(lv_event_get_user_data(e)); + if (mc) mc->switchToMode(MidiMode::HOME); +} + +static void cbModeToggle(lv_event_t* e) { + auto* mc = static_cast(lv_event_get_user_data(e)); + if (!mc) return; + auto& dr = mc->drop_; + dr.editMode = !dr.editMode; + // Button label shows what pressing again WILL do (toggle label) + if (dr.modeLbl) lv_label_set_text(dr.modeLbl, dr.editMode ? "DROP" : "EDIT"); + if (dr.modeBtn) { + lv_obj_set_style_bg_color(dr.modeBtn, + dr.editMode ? lv_color_hex(0x008833) : lv_color_hex(0x003388), 0); + } + if (dr.canvas) lv_obj_invalidate(dr.canvas); +} + +static void cbClear(lv_event_t* e) { + auto* mc = static_cast(lv_event_get_user_data(e)); + if (!mc) return; + auto& dr = mc->drop_; + // Clear balls + for (int b = 0; b < MidiController::DropState::MAX_BALLS; b++) + dr.balls[b].active = false; + // Clear platform labels + for (int p = 0; p < MidiController::DropState::MAX_PLATFORMS; p++) { + if (dr.platLbl[p]) { + lv_obj_delete(dr.platLbl[p]); + dr.platLbl[p] = nullptr; + } + } + dr.numPlatforms = 0; + mc->stopAllNotes(); + if (dr.canvas) lv_obj_invalidate(dr.canvas); +} + +static void cbScaleCycle(lv_event_t* e) { + auto* mc = static_cast(lv_event_get_user_data(e)); + if (!mc) return; + mc->drop_.scale = (mc->drop_.scale + 1) % NUM_MIDI_SCALES; + rebuildPlatformNotes(mc); + if (mc->drop_.statusLbl) lv_label_set_text(mc->drop_.statusLbl, MIDI_SCALES[mc->drop_.scale].name); +} + +static void cbKeyDown(lv_event_t* e) { + auto* mc = static_cast(lv_event_get_user_data(e)); + if (!mc) return; + mc->drop_.key = (mc->drop_.key + 11) % 12; + rebuildPlatformNotes(mc); +} +static void cbKeyUp(lv_event_t* e) { + auto* mc = static_cast(lv_event_get_user_data(e)); + if (!mc) return; + mc->drop_.key = (mc->drop_.key + 1) % 12; + rebuildPlatformNotes(mc); +} + +static void cbOctDown(lv_event_t* e) { + auto* mc = static_cast(lv_event_get_user_data(e)); + if (!mc) return; + if (mc->drop_.octave > 2) mc->drop_.octave--; + rebuildPlatformNotes(mc); + if (mc->drop_.octLbl) { + char buf[8]; snprintf(buf, sizeof(buf), "Oct %d", mc->drop_.octave); + lv_label_set_text(mc->drop_.octLbl, buf); + } +} +static void cbOctUp(lv_event_t* e) { + auto* mc = static_cast(lv_event_get_user_data(e)); + if (!mc) return; + if (mc->drop_.octave < 7) mc->drop_.octave++; + rebuildPlatformNotes(mc); + if (mc->drop_.octLbl) { + char buf[8]; snprintf(buf, sizeof(buf), "Oct %d", mc->drop_.octave); + lv_label_set_text(mc->drop_.octLbl, buf); + } +} + +// --------------------------------------------------------------------------- +// Build function +// --------------------------------------------------------------------------- + +void buildDropView(MidiController* mc, lv_obj_t* parent) { + auto& dr = mc->drop_; + + // Reset volatile state on view entry. ballObj[]/platLbl[] were nulled by + // switchToMode (previous root destroyed), null again here to be safe. + dr.numPlatforms = 0; + dr.editMode = false; + for (auto& ball : dr.balls) ball.active = false; + for (auto& obj : dr.ballObj) obj = nullptr; + for (auto& lbl : dr.platLbl) lbl = nullptr; + + // Clamp octave to valid range [2,7] + dr.octave = std::clamp(dr.octave, 2, 7); + + const lv_font_t* fontSmall = lvgl_get_text_font(FONT_SIZE_SMALL); + int ctrlH = 88; + + lv_obj_t* root = mcContainer(parent); + lv_obj_set_size(root, LV_PCT(100), LV_PCT(100)); + lv_obj_set_flex_flow(root, LV_FLEX_FLOW_COLUMN); + lv_obj_set_style_bg_color(root, lv_color_hex(0x080C12), 0); + lv_obj_set_style_bg_opa(root, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(root, 0, 0); + lv_obj_set_style_pad_all(root, 0, 0); + lv_obj_set_style_pad_gap(root, 0, 0); + lv_obj_set_scrollable(root, false); + mc->currentViewRoot_ = root; + + // -- Control row (TOP) ---------------------------------------------------- + lv_obj_t* ctrl = mcRow(root, ctrlH); + + auto makeBtn = [&](const char* lbl, int w, lv_event_cb_t cb) -> lv_obj_t* { + return mcSmallBtn(ctrl, lbl, w, cb, mc); + }; + + // < (back) + mcBackBtn(ctrl, cbBack, mc, 96); + + // MODE toggle: DROP / EDIT + dr.modeBtn = makeBtn("EDIT", 176, cbModeToggle); + lv_obj_set_style_bg_color(dr.modeBtn, lv_color_hex(0x003388), 0); + dr.modeLbl = lv_obj_get_child(dr.modeBtn, 0); + + // OCT- label OCT+ + makeBtn("OCT-", 96, cbOctDown); + char octBuf[8]; snprintf(octBuf, sizeof(octBuf), "Oct %d", dr.octave); + dr.octLbl = lv_label_create(ctrl); + lv_label_set_text(dr.octLbl, octBuf); + lv_obj_set_style_text_font(dr.octLbl, fontSmall, 0); + lv_obj_set_style_text_color(dr.octLbl, lv_color_hex(0xFFFFFF), 0); + lv_obj_set_width(dr.octLbl, 64); + lv_obj_set_style_text_align(dr.octLbl, LV_TEXT_ALIGN_CENTER, 0); + makeBtn("OCT+", 96, cbOctUp); + + // CLR (clears balls and platforms) + makeBtn("CLEAR", 128, cbClear); + + // SCALE | KEY- | KEY+ + lv_obj_t* scaleBtn = mcButton(ctrl); + lv_obj_set_size(scaleBtn, 208, MC_BTN_H); + lv_obj_add_event_cb(scaleBtn, cbScaleCycle, LV_EVENT_CLICKED, mc); + dr.statusLbl = lv_label_create(scaleBtn); + lv_label_set_text(dr.statusLbl, MIDI_SCALES[dr.scale].name); + lv_obj_set_style_text_font(dr.statusLbl, fontSmall, 0); + lv_obj_center(dr.statusLbl); + + makeBtn("KEY-", 96, cbKeyDown); + makeBtn("KEY+", 96, cbKeyUp); + + // -- Physics area (fills remaining space below controls) ------------------- + lv_obj_t* physArea = mcContainer(root); + lv_obj_set_width(physArea, LV_PCT(100)); + lv_obj_set_flex_grow(physArea, 1); + lv_obj_set_style_bg_color(physArea, lv_color_hex(0x080C12), 0); + lv_obj_set_style_bg_opa(physArea, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(physArea, 0, 0); + lv_obj_set_style_pad_all(physArea, 0, 0); + lv_obj_set_scrollable(physArea, false); + lv_obj_set_clickable(physArea, true); + lv_obj_add_event_cb(physArea, cbDropDraw, LV_EVENT_DRAW_MAIN, mc); + lv_obj_add_event_cb(physArea, cbCanvasSize, LV_EVENT_SIZE_CHANGED, mc); + lv_obj_add_event_cb(physArea, cbCanvasTouch, LV_EVENT_CLICKED, mc); + dr.canvas = physArea; + + // Start timer + mc->dropTimer_ = lv_timer_create(cbDropTimer, 16, mc); +} diff --git a/Apps/MidiController/main/Source/ViewEuclid.cpp b/Apps/MidiController/main/Source/ViewEuclid.cpp new file mode 100644 index 0000000..3c9e9b5 --- /dev/null +++ b/Apps/MidiController/main/Source/ViewEuclid.cpp @@ -0,0 +1,662 @@ +#include "MidiController.h" +#include "LcdDisplay.h" + +#include +#include +#include +#include +#include +#include + +// --------------------------------------------------------------------------- +// EUCLID view - 8-voice Euclidean rhythm sequencer +// +// (toolbar ~40px, statusbar ~20px → content ~1280×660) +// +// Layout (flex column, fills content area): +// TOP ROW (88px, full width): < | PLAY | BPM- N BPM+ | TRIP ON/OFF +// BODY (flex_grow, flex row): +// LEFT (flex_grow): canvas - 8 concentric rings + sweep line +// RIGHT (500px): controls stacked in flex column +// ├ voice tab row: 8 equal tabs +// ├ voice name label (large, colored) +// └ param rows (flex_grow each): Steps / Events / Rotation / Note +// --------------------------------------------------------------------------- + +// Fixed pixel constants +static constexpr int ROW_H = 88; // matches ViewKeys rowH +static constexpr int COL_W = 500; +static constexpr int CHROME_H = 60; // toolbar (~40px) + statusbar (~20px) +static constexpr int BTN_H = 64; // matches ViewKeys btnH (rowH-24) +static constexpr int BTN_W_SM = 88; // small button (< >) +static constexpr int BTN_W_MD = 144; // medium button (PLAY) +static constexpr int BTN_W_LG = 88; // back button +static constexpr int LBL_W = 100; // value label width +static constexpr int PAD = 12; // row padding / gap - matches ViewKeys + +// Per-voice accent colors (8 voices) +static constexpr uint32_t VOICE_COLORS[8] = { + 0xFF4040, 0xFF9900, 0x44DD44, 0x44BBFF, + 0xFF44FF, 0xFFDD00, 0x00FFCC, 0xFF8888 +}; + +// --------------------------------------------------------------------------- +// Bucket (Bresenham-style) Euclidean pattern generator +// --------------------------------------------------------------------------- + +static void euclidGenPattern(MidiController::EuclidState::Voice& v) { + memset(v.pattern, 0, sizeof(v.pattern)); + if (v.events == 0 || v.steps == 0) return; + int events = std::min(v.events, v.steps); + // Pre-fill the accumulator so the first event lands on step 0. Starting at + // zero instead delays the first event and produces uneven gaps for some + // pairs (for example E(3,8) became 00100110 rather than 10010010). + int bucket = v.steps - events; + for (int i = 0; i < v.steps; i++) { + bucket += events; + if (bucket >= v.steps) { + bucket -= v.steps; + int pos = (i + v.rotation) % v.steps; + if (pos < 0) pos += v.steps; + v.pattern[pos] = true; + } + } +} + +// --------------------------------------------------------------------------- +// Canvas draw +// --------------------------------------------------------------------------- + +static void euclidDrawCanvas(MidiController::EuclidState& es) { + if (!es.canvas) return; + lv_obj_clean(es.canvas); + + int W = lv_obj_get_width(es.canvas); + int H = lv_obj_get_height(es.canvas); + if (W <= 4 || H <= 4) { + // Layout hasn't settled yet - use stored estimates + W = es.canvasW; + H = es.canvasH; + } + if (W <= 0 || H <= 0) return; + + int cx = W / 2; + int cy = H / 2; + int maxR = std::min(cx, cy) - 8; + if (maxR < 20) return; + int ringSpacing = maxR / 9; // 8 rings + small gap from center + + for (int vi = 0; vi < 8; vi++) { + const auto& v = es.voice[vi]; + if (v.steps == 0) continue; + int r = maxR - vi * ringSpacing; + if (r < 8) continue; + + // Selected voice: draw a subtle ring outline behind the dots + if (vi == es.selectedVoice) { + lv_obj_t* ring = mcContainer(es.canvas); + lv_obj_set_size(ring, r * 2 + 8, r * 2 + 8); + lv_obj_set_pos(ring, cx - r - 4, cy - r - 4); + lv_obj_set_ignore_layout(ring, true); + lv_obj_set_style_radius(ring, LV_RADIUS_CIRCLE, 0); + lv_obj_set_style_bg_opa(ring, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(ring, 2, 0); + lv_obj_set_style_border_color(ring, lv_color_hex(VOICE_COLORS[vi]), 0); + lv_obj_set_style_border_opa(ring, LV_OPA_50, 0); + lv_obj_set_scrollable(ring, false); + lv_obj_set_clickable(ring, false); + } + + for (int step = 0; step < v.steps; step++) { + float angle = (float)(2.0 * M_PI * step) / v.steps - (float)(M_PI / 2.0); + int px = cx + (int)(cosf(angle) * r); + int py = cy + (int)(sinf(angle) * r); + + bool active = v.pattern[step]; + bool isCurrent = (es.playing && step == (es.currentStep % v.steps)); + + uint32_t col; + if (isCurrent && active) col = 0xFFFFFF; + else if (isCurrent) col = 0x44BBFF; + else if (active) col = VOICE_COLORS[vi]; + else col = 0x1A2030; + + int dotR = active ? 7 : 4; + lv_obj_t* dot = mcContainer(es.canvas); + lv_obj_set_size(dot, dotR * 2, dotR * 2); + lv_obj_set_pos(dot, px - dotR, py - dotR); + lv_obj_set_ignore_layout(dot, true); + lv_obj_set_style_radius(dot, LV_RADIUS_CIRCLE, 0); + lv_obj_set_style_bg_color(dot, lv_color_hex(col), 0); + lv_obj_set_style_bg_opa(dot, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(dot, 0, 0); + lv_obj_set_scrollable(dot, false); + lv_obj_set_clickable(dot, false); + } + } + + // Sweep line - radial from center to edge + if (es.playing && es.voice[0].steps > 0) { + int steps = es.voice[0].steps; + float angle = (float)(2.0 * M_PI * (es.currentStep % steps)) / steps - (float)(M_PI / 2.0); + int ex = cx + (int)(cosf(angle) * maxR); + int ey = cy + (int)(sinf(angle) * maxR); + static lv_point_precise_t pts[2]; + pts[0] = {(lv_value_precise_t)cx, (lv_value_precise_t)cy}; + pts[1] = {(lv_value_precise_t)ex, (lv_value_precise_t)ey}; + lv_obj_t* ln = lv_line_create(es.canvas); + lv_line_set_points(ln, pts, 2); + lv_obj_set_ignore_layout(ln, true); + lv_obj_set_style_line_color(ln, lv_color_hex(0x2244AA), 0); + lv_obj_set_style_line_opa(ln, LV_OPA_60, 0); + lv_obj_set_style_line_width(ln, 2, 0); + lv_obj_set_clickable(ln, false); + } +} + +// --------------------------------------------------------------------------- +// Update param labels for selected voice +// --------------------------------------------------------------------------- + +static void euclidUpdateParamLabels(MidiController::EuclidState& es) { + const auto& v = es.voice[es.selectedVoice]; + if (es.stepsLbl) { + char buf[8]; snprintf(buf, sizeof(buf), "S:%d", v.steps); + lv_label_set_text(es.stepsLbl, buf); + } + if (es.eventsLbl) { + char buf[8]; snprintf(buf, sizeof(buf), "E:%d", v.events); + lv_label_set_text(es.eventsLbl, buf); + } + if (es.rotLbl) { + char buf[8]; snprintf(buf, sizeof(buf), "R:%d", v.rotation); + lv_label_set_text(es.rotLbl, buf); + } + if (es.noteLbl) { + char buf[8]; midiNoteName(v.note, buf, sizeof(buf)); + lv_label_set_text(es.noteLbl, buf); + } + if (es.voiceLbl) { + char buf[24]; snprintf(buf, sizeof(buf), "Voice %d", es.selectedVoice + 1); + lv_label_set_text(es.voiceLbl, buf); + lv_obj_set_style_text_color(es.voiceLbl, lv_color_hex(VOICE_COLORS[es.selectedVoice]), 0); + } +} + +// --------------------------------------------------------------------------- +// Called by EncoderInput after modifying voice params via knob/button +// --------------------------------------------------------------------------- + +void euclidOnEncoderUpdate(MidiController* mc) { + auto& es = mc->euclid_; + // Regenerate pattern and refresh UI for all 8 voices (knobs may have + // changed any of them, and the canvas shows all voices simultaneously) + for (int i = 0; i < 8; i++) { + euclidGenPattern(es.voice[i]); + } + euclidUpdateParamLabels(es); + euclidDrawCanvas(es); + // Update voice tab button opacities (selected = full, others = dim) + if (es.voiceTabRow) { + uint32_t childCnt = lv_obj_get_child_count(es.voiceTabRow); + for (uint32_t i = 0; i < childCnt && i < 8; i++) { + lv_obj_t* t = lv_obj_get_child(es.voiceTabRow, i); + lv_obj_set_style_bg_opa(t, (i == (uint32_t)es.selectedVoice) ? LV_OPA_COVER : LV_OPA_30, 0); + } + } +} + +// --------------------------------------------------------------------------- +// Timer callback +// --------------------------------------------------------------------------- + +static void cbEuclidTimer(lv_timer_t* t) { + auto* mc = static_cast(lv_timer_get_user_data(t)); + if (!mc) return; + auto& es = mc->euclid_; + if (!es.playing) return; + + uint32_t now = lv_tick_get(); + if (now < es.nextStepMs) return; + + mc->sendMidi(0xB0, 123, 0); // all-notes-off + + for (int vi = 0; vi < 8; vi++) { + auto& v = es.voice[vi]; + if (v.steps == 0) continue; + int stepIdx = es.currentStep % v.steps; + if (v.pattern[stepIdx]) { + mc->sendMidi(0x90, (uint8_t)v.note, 110); + } + } + + // Free-running clock: each voice takes currentStep modulo its own length. + // Wrapping at 64 would restart voices whose step count does not divide 64. + es.currentStep++; + + uint32_t stepMs = 60000u / (uint32_t)es.bpm / 4u; + if (es.triplet) stepMs = stepMs * 2 / 3; + if (stepMs < 20) stepMs = 20; + es.nextStepMs = now + stepMs; + + if (es.bpmLbl) { + char buf[8]; snprintf(buf, sizeof(buf), "%d", es.bpm); + lv_label_set_text(es.bpmLbl, buf); + } + + euclidDrawCanvas(es); +} + +// --------------------------------------------------------------------------- +// Callbacks +// --------------------------------------------------------------------------- + +static void cbEuclidBack(lv_event_t* e) { + auto* mc = static_cast(lv_event_get_user_data(e)); + if (mc) mc->switchToMode(MidiMode::HOME); +} + +static void cbEuclidPlay(lv_event_t* e) { + auto* mc = static_cast(lv_event_get_user_data(e)); + if (!mc) return; + auto& es = mc->euclid_; + es.playing = !es.playing; + if (!es.playing) { + mc->sendMidi(0xB0, 123, 0); + es.currentStep = 0; + } else { + es.nextStepMs = lv_tick_get(); + } + if (es.playLbl) lv_label_set_text(es.playLbl, es.playing ? "STOP" : "PLAY"); + // Stopping resets the playhead; repaint the rings so it is not left on the + // previous step until the next timer tick (which no longer runs when stopped). + euclidDrawCanvas(es); + lcdUpdate(mc); +} + +static void cbEuclidTrip(lv_event_t* e) { + auto* mc = static_cast(lv_event_get_user_data(e)); + if (!mc) return; + auto& es = mc->euclid_; + es.triplet = !es.triplet; + if (es.tripLbl) lv_label_set_text(es.tripLbl, es.triplet ? "TRIP ON" : "TRIP OFF"); +} + +static void cbEuclidBpmDown(lv_event_t* e) { + auto* mc = static_cast(lv_event_get_user_data(e)); + if (!mc) return; + auto& es = mc->euclid_; + if (es.bpm > 40) es.bpm -= 5; + if (es.bpmLbl) { + char buf[8]; snprintf(buf, sizeof(buf), "%d", es.bpm); + lv_label_set_text(es.bpmLbl, buf); + } +} +static void cbEuclidBpmUp(lv_event_t* e) { + auto* mc = static_cast(lv_event_get_user_data(e)); + if (!mc) return; + auto& es = mc->euclid_; + if (es.bpm < 280) es.bpm += 5; + if (es.bpmLbl) { + char buf[8]; snprintf(buf, sizeof(buf), "%d", es.bpm); + lv_label_set_text(es.bpmLbl, buf); + } +} + +// Voice tab - mc is stored as object user_data, voice index as event user_data +static void cbEuclidVoiceTab(lv_event_t* e) { + lv_obj_t* tab = lv_event_get_target_obj(e); + auto* mc = static_cast(lv_obj_get_user_data(tab)); + if (!mc) return; + auto& es = mc->euclid_; + intptr_t idx = (intptr_t)lv_event_get_user_data(e); + es.selectedVoice = (int)idx; + + // Update tab button opacities: selected = full, others = dim + lv_obj_t* tabRow = lv_obj_get_parent(tab); + if (tabRow) { + uint32_t childCnt = lv_obj_get_child_count(tabRow); + for (uint32_t i = 0; i < childCnt && i < 8; i++) { + lv_obj_t* t = lv_obj_get_child(tabRow, i); + lv_obj_set_style_bg_opa(t, (i == (uint32_t)idx) ? LV_OPA_COVER : LV_OPA_30, 0); + } + } + + euclidUpdateParamLabels(es); + euclidDrawCanvas(es); + lcdUpdate(mc); +} + +static void cbEuclidStepsDown(lv_event_t* e) { + auto* mc = static_cast(lv_event_get_user_data(e)); + if (!mc) return; + auto& v = mc->euclid_.voice[mc->euclid_.selectedVoice]; + if (v.steps > 1) v.steps--; + if (v.events > v.steps) v.events = v.steps; + euclidGenPattern(v); + euclidUpdateParamLabels(mc->euclid_); + euclidDrawCanvas(mc->euclid_); +} +static void cbEuclidStepsUp(lv_event_t* e) { + auto* mc = static_cast(lv_event_get_user_data(e)); + if (!mc) return; + auto& v = mc->euclid_.voice[mc->euclid_.selectedVoice]; + if (v.steps < 32) v.steps++; + euclidGenPattern(v); + euclidUpdateParamLabels(mc->euclid_); + euclidDrawCanvas(mc->euclid_); +} + +static void cbEuclidEvtsDown(lv_event_t* e) { + auto* mc = static_cast(lv_event_get_user_data(e)); + if (!mc) return; + auto& v = mc->euclid_.voice[mc->euclid_.selectedVoice]; + if (v.events > 0) v.events--; + euclidGenPattern(v); + euclidUpdateParamLabels(mc->euclid_); + euclidDrawCanvas(mc->euclid_); +} +static void cbEuclidEvtsUp(lv_event_t* e) { + auto* mc = static_cast(lv_event_get_user_data(e)); + if (!mc) return; + auto& v = mc->euclid_.voice[mc->euclid_.selectedVoice]; + if (v.events < v.steps) v.events++; + euclidGenPattern(v); + euclidUpdateParamLabels(mc->euclid_); + euclidDrawCanvas(mc->euclid_); +} + +static void cbEuclidRotDown(lv_event_t* e) { + auto* mc = static_cast(lv_event_get_user_data(e)); + if (!mc) return; + auto& v = mc->euclid_.voice[mc->euclid_.selectedVoice]; + v.rotation = (v.rotation - 1 + v.steps) % std::max(1, v.steps); + euclidGenPattern(v); + euclidUpdateParamLabels(mc->euclid_); + euclidDrawCanvas(mc->euclid_); +} +static void cbEuclidRotUp(lv_event_t* e) { + auto* mc = static_cast(lv_event_get_user_data(e)); + if (!mc) return; + auto& v = mc->euclid_.voice[mc->euclid_.selectedVoice]; + v.rotation = (v.rotation + 1) % std::max(1, v.steps); + euclidGenPattern(v); + euclidUpdateParamLabels(mc->euclid_); + euclidDrawCanvas(mc->euclid_); +} + +static void cbEuclidNoteDown(lv_event_t* e) { + auto* mc = static_cast(lv_event_get_user_data(e)); + if (!mc) return; + auto& v = mc->euclid_.voice[mc->euclid_.selectedVoice]; + if (v.note > 0) v.note--; + euclidUpdateParamLabels(mc->euclid_); +} +static void cbEuclidNoteUp(lv_event_t* e) { + auto* mc = static_cast(lv_event_get_user_data(e)); + if (!mc) return; + auto& v = mc->euclid_.voice[mc->euclid_.selectedVoice]; + if (v.note < 127) v.note++; + euclidUpdateParamLabels(mc->euclid_); +} + +// --------------------------------------------------------------------------- +// Build +// --------------------------------------------------------------------------- + +void buildEuclidView(MidiController* mc, lv_obj_t* parent) { + auto& es = mc->euclid_; + + const lv_font_t* fontSmall = lvgl_get_text_font(FONT_SIZE_SMALL); + const lv_font_t* fontNormal = lvgl_get_text_font(FONT_SIZE_DEFAULT); + const lv_font_t* fontLarge = lvgl_get_text_font(FONT_SIZE_LARGE); + + // Default voice params if not yet set + static const int DEFAULT_STEPS[8] = {16, 16, 16, 16, 16, 16, 16, 16}; + static const int DEFAULT_EVENTS[8] = {4, 4, 8, 5, 3, 6, 7, 2}; + static const int DEFAULT_ROT[8] = {0, 2, 0, 1, 0, 1, 3, 2}; + static const int DEFAULT_NOTE[8] = {36, 38, 42, 39, 45, 47, 49, 51}; + for (int i = 0; i < 8; i++) { + auto& v = es.voice[i]; + if (v.steps == 0) { + v.steps = DEFAULT_STEPS[i]; + v.events = DEFAULT_EVENTS[i]; + v.rotation = DEFAULT_ROT[i]; + v.note = DEFAULT_NOTE[i]; + } + euclidGenPattern(v); + } + + // -- Root: flex COLUMN - full content area -------------------------------- + lv_obj_t* root = mcContainer(parent); + lv_obj_set_size(root, LV_PCT(100), LV_PCT(100)); + lv_obj_set_flex_flow(root, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(root, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START); + lv_obj_set_style_bg_color(root, MC_BG(), 0); + lv_obj_set_style_bg_opa(root, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(root, 0, 0); + lv_obj_set_style_pad_all(root, 0, 0); + lv_obj_set_style_pad_gap(root, 0, 0); + lv_obj_set_scrollable(root, false); + mc->currentViewRoot_ = root; + + // Shared helpers (used in both transport row and right column) + auto makeBtn = [&](lv_obj_t* par, const char* txt, int w, + lv_event_cb_t cb, void* ud) -> lv_obj_t* { + return mcSmallBtn(par, txt, w, cb, ud); + }; + + auto makeLbl = [&](lv_obj_t* par, const char* txt, int w, + lv_color_t col, const lv_font_t* font = nullptr) -> lv_obj_t* { + lv_obj_t* lbl = lv_label_create(par); + lv_label_set_text(lbl, txt); + lv_obj_set_style_text_font(lbl, font ? font : fontSmall, 0); + lv_obj_set_style_text_color(lbl, col, 0); + lv_obj_set_width(lbl, w); + lv_obj_set_style_text_align(lbl, LV_TEXT_ALIGN_CENTER, 0); + return lbl; + }; + + // -- Row 1: Full-width transport (ROW_H) ---------------------------------- + // < | PLAY | BPM- N BPM+ | TRIP ON/OFF + { + lv_obj_t* topRow = mcRow(root); + + mcBackBtn(topRow, cbEuclidBack, mc, BTN_W_LG); + + lv_obj_t* title = lv_label_create(topRow); + lv_label_set_text(title, "EUCLID"); + lv_obj_set_style_text_font(title, fontNormal, 0); + lv_obj_set_style_text_color(title, lv_color_hex(0x44BBFF), 0); + lv_obj_set_flex_grow(title, 1); + + lv_obj_t* playBtn = makeBtn(topRow, es.playing ? "STOP" : "PLAY", + BTN_W_MD, cbEuclidPlay, mc); + es.playLbl = lv_obj_get_child(playBtn, 0); + + lv_obj_t* sep = lv_label_create(topRow); + lv_label_set_text(sep, "|"); + lv_obj_set_style_text_font(sep, fontSmall, 0); + lv_obj_set_style_text_color(sep, lv_color_hex(0x334455), 0); + + makeBtn(topRow, "<", BTN_W_SM, cbEuclidBpmDown, mc); + char bpmBuf[8]; snprintf(bpmBuf, sizeof(bpmBuf), "%d", es.bpm); + es.bpmLbl = makeLbl(topRow, bpmBuf, LBL_W, lv_color_hex(0xFFE000)); + makeBtn(topRow, ">", BTN_W_SM, cbEuclidBpmUp, mc); + + sep = lv_label_create(topRow); + lv_label_set_text(sep, "|"); + lv_obj_set_style_text_font(sep, fontSmall, 0); + lv_obj_set_style_text_color(sep, lv_color_hex(0x334455), 0); + + lv_obj_t* tripBtn = makeBtn(topRow, es.triplet ? "TRIP ON" : "TRIP OFF", + 120, cbEuclidTrip, mc); + es.tripLbl = lv_obj_get_child(tripBtn, 0); + } + + // -- Body: flex ROW (flex_grow) - canvas LEFT + controls RIGHT ------------ + { + lv_obj_t* body = mcContainer(root); + lv_obj_set_size(body, LV_PCT(100), 0); + lv_obj_set_flex_grow(body, 1); + lv_obj_set_flex_flow(body, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(body, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START); + lv_obj_set_style_bg_opa(body, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(body, 0, 0); + lv_obj_set_style_pad_all(body, 0, 0); + lv_obj_set_style_pad_gap(body, 0, 0); + lv_obj_set_scrollable(body, false); + + // -- LEFT: canvas area (flex_grow, LV_PCT(100) height fills body) ---- + { + lv_obj_t* canvasArea = mcContainer(body); + lv_obj_set_flex_grow(canvasArea, 1); + lv_obj_set_height(canvasArea, LV_PCT(100)); + lv_obj_set_style_bg_color(canvasArea, lv_color_hex(0x040610), 0); + lv_obj_set_style_bg_opa(canvasArea, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(canvasArea, 0, 0); + lv_obj_set_style_pad_all(canvasArea, 0, 0); + lv_obj_set_scrollable(canvasArea, false); + lv_obj_set_layout(canvasArea, LV_LAYOUT_NONE); // children use absolute lv_obj_set_pos() + es.canvas = canvasArea; + // Estimates for first draw before layout settles + es.canvasW = lv_display_get_horizontal_resolution(nullptr) - COL_W; + es.canvasH = lv_display_get_vertical_resolution(nullptr) - CHROME_H - ROW_H; + } + + // -- RIGHT: controls column (500px wide) ------------------------------ + { + + lv_obj_t* rightCol = mcContainer(body); + lv_obj_set_size(rightCol, COL_W, LV_PCT(100)); + lv_obj_set_flex_flow(rightCol, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(rightCol, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START); + lv_obj_set_style_bg_color(rightCol, MC_PANEL(), 0); + lv_obj_set_style_bg_opa(rightCol, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(rightCol, 0, 0); + lv_obj_set_style_pad_all(rightCol, PAD, 0); + lv_obj_set_style_pad_gap(rightCol, PAD, 0); + lv_obj_set_scrollable(rightCol, false); + + auto makeRow = [&](int h) -> lv_obj_t* { + lv_obj_t* row = mcContainer(rightCol); + lv_obj_set_size(row, LV_PCT(100), h); + lv_obj_set_flex_flow(row, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(row, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_set_style_bg_opa(row, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(row, 0, 0); + lv_obj_set_style_pad_all(row, 0, 0); + lv_obj_set_style_pad_gap(row, PAD, 0); + lv_obj_set_scrollable(row, false); + return row; + }; + + // -- Voice tabs (8 equal, full width) ---------------------------- + { + lv_obj_t* row = makeRow(ROW_H); + es.voiceTabRow = row; + int tabW = (COL_W - PAD * 2 - PAD * 7) / 8; + for (int i = 0; i < 8; i++) { + lv_obj_t* tab = mcButton(row); + lv_obj_set_size(tab, tabW, BTN_H); + lv_obj_set_user_data(tab, mc); + lv_obj_add_event_cb(tab, cbEuclidVoiceTab, LV_EVENT_CLICKED, (void*)(intptr_t)i); + lv_obj_set_style_bg_color(tab, lv_color_hex(VOICE_COLORS[i]), 0); + lv_obj_set_style_bg_opa(tab, (i == es.selectedVoice) ? LV_OPA_COVER : LV_OPA_30, 0); + lv_obj_t* lbl = lv_label_create(tab); + char vbuf[4]; snprintf(vbuf, sizeof(vbuf), "%d", i + 1); + lv_label_set_text(lbl, vbuf); + lv_obj_set_style_text_font(lbl, fontSmall, 0); + lv_obj_center(lbl); + } + } + + // -- Voice name (large, colored) ---------------------------------- + { + lv_obj_t* row = makeRow(ROW_H); + char vbuf[24]; snprintf(vbuf, sizeof(vbuf), "Voice %d", es.selectedVoice + 1); + es.voiceLbl = makeLbl(row, vbuf, COL_W - PAD * 2, + lv_color_hex(VOICE_COLORS[es.selectedVoice]), fontLarge); + } + + // Helper: param row with colored header + < value > buttons + // flex_grow=1 so all 4 rows share remaining space equally + auto makeParamRow = [&](const char* name, lv_color_t col) -> lv_obj_t* { + lv_obj_t* row = mcContainer(rightCol); + lv_obj_set_width(row, LV_PCT(100)); + lv_obj_set_flex_grow(row, 1); + lv_obj_set_flex_flow(row, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(row, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_set_style_bg_color(row, lv_color_hex(0x0C0E1A), 0); + lv_obj_set_style_bg_opa(row, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(row, 1, 0); + lv_obj_set_style_border_color(row, lv_color_hex(0x1A2030), 0); + lv_obj_set_style_pad_all(row, PAD, 0); + lv_obj_set_style_pad_gap(row, PAD, 0); + lv_obj_set_scrollable(row, false); + lv_obj_t* hdr = lv_label_create(row); + lv_label_set_text(hdr, name); + lv_obj_set_style_text_font(hdr, fontSmall, 0); + lv_obj_set_style_text_color(hdr, col, 0); + lv_obj_set_width(hdr, 120); + return row; + }; + + // -- Steps -------------------------------------------------------- + { + lv_obj_t* row = makeParamRow("Steps", lv_color_hex(0xFFCC88)); + makeBtn(row, "<", BTN_W_SM, cbEuclidStepsDown, mc); + char sBuf[8]; snprintf(sBuf, sizeof(sBuf), "S:%d", es.voice[es.selectedVoice].steps); + es.stepsLbl = makeLbl(row, sBuf, LBL_W, lv_color_hex(0xFFCC88)); + makeBtn(row, ">", BTN_W_SM, cbEuclidStepsUp, mc); + } + + // -- Events ------------------------------------------------------- + { + lv_obj_t* row = makeParamRow("Events", lv_color_hex(0x88DDFF)); + makeBtn(row, "<", BTN_W_SM, cbEuclidEvtsDown, mc); + char eBuf[8]; snprintf(eBuf, sizeof(eBuf), "E:%d", es.voice[es.selectedVoice].events); + es.eventsLbl = makeLbl(row, eBuf, LBL_W, lv_color_hex(0x88DDFF)); + makeBtn(row, ">", BTN_W_SM, cbEuclidEvtsUp, mc); + } + + // -- Rotation ----------------------------------------------------- + { + lv_obj_t* row = makeParamRow("Rotation", lv_color_hex(0xAAFF88)); + makeBtn(row, "<", BTN_W_SM, cbEuclidRotDown, mc); + char rBuf[8]; snprintf(rBuf, sizeof(rBuf), "R:%d", es.voice[es.selectedVoice].rotation); + es.rotLbl = makeLbl(row, rBuf, LBL_W, lv_color_hex(0xAAFF88)); + makeBtn(row, ">", BTN_W_SM, cbEuclidRotUp, mc); + } + + // -- Note --------------------------------------------------------- + { + lv_obj_t* row = makeParamRow("Note", lv_color_hex(0xFF88CC)); + makeBtn(row, "<", BTN_W_SM, cbEuclidNoteDown, mc); + char nBuf[8]; midiNoteName(es.voice[es.selectedVoice].note, nBuf, sizeof(nBuf)); + es.noteLbl = makeLbl(row, nBuf, LBL_W, lv_color_hex(0xFF88CC)); + makeBtn(row, ">", BTN_W_SM, cbEuclidNoteUp, mc); + } + } + } + + // -- Redraw when canvas gets its real size (first layout pass) ------------ + // At build time LV_PCT(100) hasn't resolved yet, so estimates are used. + // LV_EVENT_SIZE_CHANGED fires once the layout pass completes. + lv_obj_add_event_cb(es.canvas, [](lv_event_t* ev) { + auto* mc2 = static_cast(lv_event_get_user_data(ev)); + if (!mc2) return; + auto& es2 = mc2->euclid_; + int w = lv_obj_get_width(es2.canvas); + int h = lv_obj_get_height(es2.canvas); + if (w > 4 && h > 4) { + es2.canvasW = w; + es2.canvasH = h; + euclidDrawCanvas(es2); + } + }, LV_EVENT_SIZE_CHANGED, mc); + + // -- Start timer ---------------------------------------------------------- + mc->euclidTimer_ = lv_timer_create(cbEuclidTimer, 10, mc); + es.nextStepMs = lv_tick_get(); +} diff --git a/Apps/MidiController/main/Source/ViewGrid.cpp b/Apps/MidiController/main/Source/ViewGrid.cpp new file mode 100644 index 0000000..219aa63 --- /dev/null +++ b/Apps/MidiController/main/Source/ViewGrid.cpp @@ -0,0 +1,289 @@ +#include "MidiController.h" + +#include +#include +#include +#include +#include + +// --------------------------------------------------------------------------- +// GRID view - Isomorphic 8×5 Linnstrument-style grid +// +// Layout: perfect-4ths tuning - each row is 5 semitones higher, each column +1. +// Base note = (octave + 1) * 12 - at oct=3: baseNote=48 (C3). +// --------------------------------------------------------------------------- + +static inline lv_coord_t dispW() { + return (lv_coord_t)lv_display_get_horizontal_resolution(nullptr); +} +// Whether a semitone class is a "black" key +static bool isBlackKey(int semitone) { + semitone = ((semitone % 12) + 12) % 12; + return (semitone == 1 || semitone == 3 || semitone == 6 || semitone == 8 || semitone == 10); +} + +static lv_color_t cellTextColor(int note) { + int sc12 = note % 12; + if (isBlackKey(sc12)) return lv_color_hex(0x808080); // dark cell - light text + return lv_color_hex(0x000000); // C notes and white keys - dark text +} + +static int gridNote(int row, int col, int baseNote) { + // Row 0 = lowest, row 4 = highest (display top = row 4) + int note = baseNote + (row * 5) + col; + return std::max(0, std::min(127, note)); +} + +static bool isNotePressed(const MidiController::GridState& gs, int note) { + for (int slot = 0; slot < LVGL_POINTER_MAX_SLOTS; slot++) { + if (gs.pressedNote[slot] == note) return true; + } + return false; +} + +static void refreshGridColors(MidiController* mc) { + if (!mc) return; + auto& gs = mc->grid_; + int baseNote = (gs.octave + 1) * 12; + for (int r = 0; r < 5; r++) { + for (int c = 0; c < 8; c++) { + if (!gs.cell[r][c]) continue; + int note = gridNote(r, c, baseNote); + int sc12 = note % 12; + lv_color_t cellColor; + if (isNotePressed(gs, note)) { + cellColor = lv_color_hex(0x00DFFF); // pressed + } else if (sc12 == 0) { + cellColor = lv_color_hex(0x00FFCC); // C notes + } else if (isBlackKey(sc12)) { + cellColor = lv_color_hex(0x1A2A40); // black key + } else { + cellColor = lv_color_hex(0x2A3A50); // white key + } + lv_obj_set_style_bg_color(gs.cell[r][c], cellColor, LV_PART_MAIN); + lv_obj_set_user_data(gs.cell[r][c], (void*)(intptr_t)note); + // Update note name label (child 0) + lv_obj_t* lbl = lv_obj_get_child(gs.cell[r][c], 0); + if (lbl) { + char buf[8]; midiNoteName(note, buf, sizeof(buf)); + lv_label_set_text(lbl, buf); + lv_obj_set_style_text_color(lbl, cellTextColor(note), 0); + } + } + } +} + +static void refreshOctLabel(MidiController* mc) { + if (!mc->grid_.octLbl) return; + char buf[10]; snprintf(buf, sizeof(buf), "Oct %d", mc->grid_.octave); + lv_label_set_text(mc->grid_.octLbl, buf); +} + +// --------------------------------------------------------------------------- +// Callbacks +// --------------------------------------------------------------------------- + +static void cbBack(lv_event_t* e) { + auto* mc = static_cast(lv_event_get_user_data(e)); + if (mc) mc->switchToMode(MidiMode::HOME); +} + +static void cbCellPressed(lv_event_t* e) { + auto* mc = static_cast(lv_event_get_user_data(e)); + if (!mc) return; + auto& gs = mc->grid_; + + lv_indev_t* indev = lv_event_get_indev(e); + if (!indev) return; + int8_t slotIdx = lvgl_pointer_get_slot_index(indev); + if (slotIdx < 0) return; + + lv_obj_t* btn = lv_event_get_target_obj(e); + int note = (int)(intptr_t)lv_obj_get_user_data(btn); + if (gs.pressedNote[slotIdx] >= 0) mc->sendMidi(0x80, (uint8_t)gs.pressedNote[slotIdx], 0); + gs.pressedNote[slotIdx] = note; + mc->sendMidi(0x90, (uint8_t)note, 100); + refreshGridColors(mc); + if (mc->grid_.noteLbl) { + char buf[8]; midiNoteName(note, buf, sizeof(buf)); + lv_label_set_text(mc->grid_.noteLbl, buf); + } +} + +static void cbCellReleased(lv_event_t* e) { + auto* mc = static_cast(lv_event_get_user_data(e)); + if (!mc) return; + auto& gs = mc->grid_; + + lv_indev_t* indev = lv_event_get_indev(e); + if (!indev) return; + int8_t slotIdx = lvgl_pointer_get_slot_index(indev); + if (slotIdx < 0) return; + + lv_obj_t* btn = lv_event_get_target_obj(e); + int note = (int)(intptr_t)lv_obj_get_user_data(btn); + if (gs.pressedNote[slotIdx] == note) { + mc->sendMidi(0x80, (uint8_t)note, 0); + gs.pressedNote[slotIdx] = -1; + refreshGridColors(mc); + if (mc->grid_.noteLbl) lv_label_set_text(mc->grid_.noteLbl, ""); + } +} + +static void releaseAllGridNotes(MidiController* mc) { + auto& gs = mc->grid_; + for (int slot = 0; slot < LVGL_POINTER_MAX_SLOTS; slot++) { + if (gs.pressedNote[slot] >= 0) { + mc->sendMidi(0x80, (uint8_t)gs.pressedNote[slot], 0); + gs.pressedNote[slot] = -1; + } + } + if (mc->grid_.noteLbl) lv_label_set_text(mc->grid_.noteLbl, ""); +} + +static void cbOctDown(lv_event_t* e) { + auto* mc = static_cast(lv_event_get_user_data(e)); + if (!mc) return; + if (mc->grid_.octave > 1) { + releaseAllGridNotes(mc); + mc->grid_.octave--; + refreshOctLabel(mc); + refreshGridColors(mc); + } +} + +static void cbOctUp(lv_event_t* e) { + auto* mc = static_cast(lv_event_get_user_data(e)); + if (!mc) return; + if (mc->grid_.octave < 6) { + releaseAllGridNotes(mc); + mc->grid_.octave++; + refreshOctLabel(mc); + refreshGridColors(mc); + } +} + +// --------------------------------------------------------------------------- +// Build function +// --------------------------------------------------------------------------- + +void buildGridView(MidiController* mc, lv_obj_t* parent) { + if (!mc) return; + auto& gs = mc->grid_; + for (auto& n : gs.pressedNote) n = -1; // Ensure clean state when entering grid view + + lv_coord_t W = dispW(); + const lv_font_t* fontSmall = lvgl_get_text_font(FONT_SIZE_SMALL); + const lv_font_t* fontNormal = lvgl_get_text_font(FONT_SIZE_DEFAULT); + + int ctrlH = 88; + int btnH = MC_BTN_H; + + lv_obj_t* root = mcContainer(parent); + lv_obj_set_size(root, LV_PCT(100), LV_PCT(100)); + lv_obj_set_flex_flow(root, LV_FLEX_FLOW_COLUMN); + lv_obj_set_style_bg_color(root, lv_color_hex(0x080C12), 0); + lv_obj_set_style_bg_opa(root, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(root, 0, 0); + lv_obj_set_style_pad_all(root, 0, 0); + lv_obj_set_style_pad_gap(root, 0, 0); + lv_obj_set_scrollable(root, false); + mc->currentViewRoot_ = root; + + // -- Control row (TOP) ---------------------------------------------------- + lv_obj_t* ctrl = mcRow(root, ctrlH); + + mcBackBtn(ctrl, cbBack, mc, MC_W_S); + lv_obj_t* titleLbl = lv_label_create(ctrl); + lv_label_set_text(titleLbl, "GRID KEYS"); + lv_obj_set_style_text_font(titleLbl, fontNormal, 0); + lv_obj_set_style_text_color(titleLbl, lv_color_hex(0x44BBFF), 0); + lv_obj_set_flex_grow(titleLbl, 1); + + lv_obj_t* octDown = mcButton(ctrl); + lv_obj_set_size(octDown, 112, btnH); + lv_obj_add_event_cb(octDown, cbOctDown, LV_EVENT_CLICKED, mc); + lv_obj_t* odl = lv_label_create(octDown); lv_label_set_text(odl, "OCT-"); + lv_obj_set_style_text_font(odl, fontSmall, 0); lv_obj_center(odl); + + gs.octLbl = lv_label_create(ctrl); + refreshOctLabel(mc); + lv_obj_set_style_text_font(gs.octLbl, fontSmall, 0); + lv_obj_set_style_text_color(gs.octLbl, lv_color_hex(0x00DFFF), 0); + lv_obj_set_width(gs.octLbl, 176); + lv_obj_set_style_text_align(gs.octLbl, LV_TEXT_ALIGN_CENTER, 0); + + lv_obj_t* octUp = mcButton(ctrl); + lv_obj_set_size(octUp, 112, btnH); + lv_obj_add_event_cb(octUp, cbOctUp, LV_EVENT_CLICKED, mc); + lv_obj_t* oul = lv_label_create(octUp); lv_label_set_text(oul, "OCT+"); + lv_obj_set_style_text_font(oul, fontSmall, 0); lv_obj_center(oul); + + gs.noteLbl = lv_label_create(ctrl); + lv_label_set_text(gs.noteLbl, ""); + lv_obj_set_style_text_font(gs.noteLbl, fontSmall, 0); + lv_obj_set_style_text_color(gs.noteLbl, lv_color_hex(0x00DFFF), 0); + lv_obj_set_flex_grow(gs.noteLbl, 1); + lv_obj_set_style_text_align(gs.noteLbl, LV_TEXT_ALIGN_RIGHT, 0); + + // -- Grid (5 rows × 8 cols, fills available space) ----------------------- + lv_obj_t* gridArea = mcContainer(root); + lv_obj_set_width(gridArea, LV_PCT(100)); + lv_obj_set_flex_grow(gridArea, 1); + lv_obj_set_style_bg_color(gridArea, lv_color_hex(0x050508), 0); + lv_obj_set_style_bg_opa(gridArea, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(gridArea, 0, 0); + lv_obj_set_style_pad_top(gridArea, 8, 0); + lv_obj_set_style_pad_bottom(gridArea, 16, 0); + lv_obj_set_style_pad_left(gridArea, 16, 0); + lv_obj_set_style_pad_right(gridArea, 16, 0); + lv_obj_set_style_pad_gap(gridArea, 4, 0); + lv_obj_set_scrollable(gridArea, false); + lv_obj_set_flex_flow(gridArea, LV_FLEX_FLOW_COLUMN); + + int cellW = ((int)W - 16 * 2) / 8; + + // Build rows top to bottom: row 4 (highest) at top, row 0 at bottom + for (int r = 4; r >= 0; r--) { + lv_obj_t* rowObj = mcContainer(gridArea); + lv_obj_set_width(rowObj, LV_PCT(100)); + lv_obj_set_flex_grow(rowObj, 1); + lv_obj_set_flex_flow(rowObj, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(rowObj, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_set_style_bg_opa(rowObj, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(rowObj, 0, 0); + lv_obj_set_style_pad_all(rowObj, 0, 0); + lv_obj_set_style_pad_gap(rowObj, 4, 0); + lv_obj_set_scrollable(rowObj, false); + + for (int c = 0; c < 8; c++) { + lv_obj_t* btn = mcButton(rowObj); + lv_obj_set_size(btn, cellW - 4, LV_PCT(100)); + lv_obj_set_style_bg_color(btn, lv_color_hex(0x2A3A50), 0); + lv_obj_set_style_bg_opa(btn, LV_OPA_COVER, 0); + lv_obj_set_style_radius(btn, 8, 0); + lv_obj_set_style_pad_all(btn, 0, 0); + lv_obj_set_style_border_width(btn, 0, 0); + lv_obj_add_event_cb(btn, cbCellPressed, LV_EVENT_PRESSED, mc); + lv_obj_add_event_cb(btn, cbCellReleased, LV_EVENT_RELEASED, mc); + + // Note label (small, aligned center) + int note = gridNote(r, c, (gs.octave + 1) * 12); + lv_obj_set_user_data(btn, (void*)(intptr_t)note); + { + char buf[8]; midiNoteName(note, buf, sizeof(buf)); + lv_obj_t* lbl = lv_label_create(btn); + lv_label_set_text(lbl, buf); + lv_obj_set_style_text_font(lbl, fontSmall, 0); + lv_obj_set_style_text_color(lbl, cellTextColor(note), 0); + lv_obj_align(lbl, LV_ALIGN_CENTER, 0, 0); + lv_obj_set_clickable(lbl, false); + } + + gs.cell[r][c] = btn; + } + } + + // Apply initial colors + refreshGridColors(mc); +} diff --git a/Apps/MidiController/main/Source/ViewKeys.cpp b/Apps/MidiController/main/Source/ViewKeys.cpp new file mode 100644 index 0000000..1846c8e --- /dev/null +++ b/Apps/MidiController/main/Source/ViewKeys.cpp @@ -0,0 +1,427 @@ +#include "MidiController.h" + +#include +#include +#include +#include +#include + +// --------------------------------------------------------------------------- +// KEYS view - single-octave chromatic keyboard +// +// Layout (flex column): +// [control row: < | OCT- | octave | OCT+ | SCALE | KEY- | key | KEY+] +// [piano area: 7 white keys with 5 black keys overlaid] +// [status label] +// --------------------------------------------------------------------------- + +static constexpr int WHITE_OFF[7] = { 0, 2, 4, 5, 7, 9, 11 }; +static constexpr int BLACK_OFF[5] = { 1, 3, 6, 8, 10 }; +// Center X of each black key as fraction of white-key-width × 100 +static constexpr int BLACK_FRAC[5] = { 65, 165, 365, 465, 565 }; + +static const char* WHITE_NAME[7] = { "C","D","E","F","G","A","B" }; + +// Key colors by role +static lv_color_t keyColorWhiteDefault() { return lv_color_hex(0xE8E8D4); } +static lv_color_t keyColorBlackDefault() { return lv_color_hex(0x111111); } + +static inline lv_coord_t dispW() { + return (lv_coord_t)lv_display_get_horizontal_resolution(nullptr); +} + +static void resetKeyColors(MidiController* mc) { + auto& ks = mc->keys_; + + for (int i = 0; i < 7; i++) { + if (!ks.whiteKey[i]) continue; + lv_obj_set_style_bg_color(ks.whiteKey[i], keyColorWhiteDefault(), LV_PART_MAIN); + } + + for (int i = 0; i < 5; i++) { + if (!ks.blackKey[i]) continue; + lv_obj_set_style_bg_color(ks.blackKey[i], keyColorBlackDefault(), LV_PART_MAIN); + } +} + +// Rebuild note assignments after an octave change, and refresh the scale/key labels +static void rebuildKeyNotes(MidiController* mc) { + auto& ks = mc->keys_; + + // White keys + for (int k = 0; k < 7; k++) { + if (!ks.whiteKey[k]) continue; + int note = std::max(0, std::min(127, (ks.octave + 1) * 12 + WHITE_OFF[k])); + lv_obj_set_user_data(ks.whiteKey[k], (void*)(intptr_t)note); + + // Update C note label + if (k == 0) { + lv_obj_t* lbl = lv_obj_get_child(ks.whiteKey[k], 0); + if (lbl) { + char buf[16]; snprintf(buf, sizeof(buf), "C%d", ks.octave); + lv_label_set_text(lbl, buf); + } + } + } + + // Black keys + for (int k = 0; k < 5; k++) { + if (!ks.blackKey[k]) continue; + int note = std::max(0, std::min(127, (ks.octave + 1) * 12 + BLACK_OFF[k])); + lv_obj_set_user_data(ks.blackKey[k], (void*)(intptr_t)note); + } + + // Update status label + if (ks.statusLbl) { + char buf[48]; + snprintf(buf, sizeof(buf), "Oct %d | %s | Key %s", + ks.octave, + MIDI_SCALES[ks.scale % NUM_MIDI_SCALES].name, + KEY_NAMES[ks.key % 12]); + lv_label_set_text(ks.statusLbl, buf); + } + if (ks.octLbl) { + char buf[16]; snprintf(buf, sizeof(buf), "Oct %d", ks.octave); + lv_label_set_text(ks.octLbl, buf); + } + if (ks.scaleLbl) { + lv_label_set_text(ks.scaleLbl, MIDI_SCALES[ks.scale % NUM_MIDI_SCALES].name); + } + if (ks.keyLbl) { + lv_label_set_text(ks.keyLbl, KEY_NAMES[ks.key % 12]); + } + + resetKeyColors(mc); +} + +// --------------------------------------------------------------------------- +// Touch handling - drag across keys (pressing on pianoArea) +// --------------------------------------------------------------------------- + +static void cbPianoPressing(lv_event_t* e) { + auto* mc = static_cast(lv_event_get_user_data(e)); + if (!mc) return; + auto& ks = mc->keys_; + + // lv_event_get_indev() (the indev that actually fired THIS event), not lv_indev_active() - + // the latter is a process-global "whichever indev is being processed right now" and would + // make every finger's hit-test collapse onto whichever touch happened to be read last, once + // multiple simultaneous touch slots exist (see lvgl-module's pointer pool). slotIdx indexes + // ks.lastNote so each finger tracks its own held note independently. + lv_indev_t* indev = lv_event_get_indev(e); + if (!indev) return; + int8_t slotIdx = lvgl_pointer_get_slot_index(indev); + if (slotIdx < 0) return; + lv_point_t pt; + lv_indev_get_point(indev, &pt); + + if (!ks.pianoArea) return; + lv_area_t rowArea; + lv_obj_get_coords(ks.pianoArea, &rowArea); + if (pt.y < rowArea.y1 || pt.y > rowArea.y2) return; + + // Black keys take priority - check them first + int newNote = -1; + for (int i = 0; i < 5; i++) { + if (!ks.blackKey[i]) continue; + lv_area_t area; + lv_obj_get_coords(ks.blackKey[i], &area); + if (pt.x >= area.x1 && pt.x <= area.x2 && pt.y >= area.y1 && pt.y <= area.y2) { + newNote = (int)(intptr_t)lv_obj_get_user_data(ks.blackKey[i]); + break; + } + } + + // Then white keys + if (newNote < 0) { + for (int i = 0; i < 7; i++) { + if (!ks.whiteKey[i]) continue; + lv_area_t area; + lv_obj_get_coords(ks.whiteKey[i], &area); + if (pt.x >= area.x1 && pt.x <= area.x2) { + newNote = (int)(intptr_t)lv_obj_get_user_data(ks.whiteKey[i]); + break; + } + } + } + + if (newNote < 0) return; + if (newNote == ks.lastNote[slotIdx]) return; + + // Slide: off old note, on new note (only this finger's note - other slots' held notes are + // untouched) + if (ks.lastNote[slotIdx] >= 0) { + mc->sendMidi(0x80, (uint8_t)ks.lastNote[slotIdx], 0); + } + mc->sendMidi(0x90, (uint8_t)newNote, 100); + ks.lastNote[slotIdx] = newNote; +} + +static void cbPianoReleased(lv_event_t* e) { + auto* mc = static_cast(lv_event_get_user_data(e)); + if (!mc) return; + auto& ks = mc->keys_; + + lv_indev_t* indev = lv_event_get_indev(e); + if (!indev) return; + int8_t slotIdx = lvgl_pointer_get_slot_index(indev); + if (slotIdx < 0) return; + + if (ks.lastNote[slotIdx] >= 0) { + mc->sendMidi(0x80, (uint8_t)ks.lastNote[slotIdx], 0); + ks.lastNote[slotIdx] = -1; + } +} + +// --------------------------------------------------------------------------- +// Control callbacks +// --------------------------------------------------------------------------- + +static void cbOctDown(lv_event_t* e) { + auto* mc = static_cast(lv_event_get_user_data(e)); + if (!mc) return; + if (mc->keys_.octave > 1) { + mc->keys_.octave--; + rebuildKeyNotes(mc); + } +} + +static void cbOctUp(lv_event_t* e) { + auto* mc = static_cast(lv_event_get_user_data(e)); + if (!mc) return; + if (mc->keys_.octave < 8) { + mc->keys_.octave++; + rebuildKeyNotes(mc); + } +} + +static void cbScaleCycle(lv_event_t* e) { + auto* mc = static_cast(lv_event_get_user_data(e)); + if (!mc) return; + mc->keys_.scale = (mc->keys_.scale + 1) % NUM_MIDI_SCALES; + rebuildKeyNotes(mc); +} + +static void cbKeyDown(lv_event_t* e) { + auto* mc = static_cast(lv_event_get_user_data(e)); + if (!mc) return; + mc->keys_.key = (mc->keys_.key + 11) % 12; + rebuildKeyNotes(mc); +} + +static void cbKeyUp(lv_event_t* e) { + auto* mc = static_cast(lv_event_get_user_data(e)); + if (!mc) return; + mc->keys_.key = (mc->keys_.key + 1) % 12; + rebuildKeyNotes(mc); +} + +static void cbBack(lv_event_t* e) { + auto* mc = static_cast(lv_event_get_user_data(e)); + if (mc) mc->switchToMode(MidiMode::HOME); +} + +// --------------------------------------------------------------------------- +// Build function +// --------------------------------------------------------------------------- + +void buildKeysView(MidiController* mc, lv_obj_t* parent) { + auto& ks = mc->keys_; + // A note held when the view was left is already silenced by the mode-switch + // All Notes Off; clear the per-slot state so the next tap always sends Note On. + for (auto& n : ks.lastNote) n = -1; + + lv_coord_t W = dispW(); + const lv_font_t* fontSmall = lvgl_get_text_font(FONT_SIZE_SMALL); + const lv_font_t* fontNormal = lvgl_get_text_font(FONT_SIZE_DEFAULT); + + int rowH = 88; // single combined control row height + int statusH = 56; + + // Root container + lv_obj_t* root = mcContainer(parent); + lv_obj_set_size(root, LV_PCT(100), LV_PCT(100)); + lv_obj_set_flex_flow(root, LV_FLEX_FLOW_COLUMN); + lv_obj_set_style_bg_color(root, lv_color_hex(0x080C12), 0); + lv_obj_set_style_bg_opa(root, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(root, 0, 0); + lv_obj_set_style_pad_all(root, 0, 0); + lv_obj_set_style_pad_gap(root, 0, 0); + lv_obj_set_scrollable(root, false); + mc->currentViewRoot_ = root; + + // -- Single control row: [<] [OCT-] [OCT] [OCT+] | [SCALE] [KEY-] [KEY] [KEY+] -- + lv_obj_t* ctrl = mcRow(root, rowH); + + int btnH = MC_BTN_H; + + // Back button + mcBackBtn(ctrl, cbBack, mc, MC_W_S); + + // OCT - + lv_obj_t* octDownBtn = mcButton(ctrl); + lv_obj_set_size(octDownBtn, 144, btnH); + lv_obj_add_event_cb(octDownBtn, cbOctDown, LV_EVENT_CLICKED, mc); + lv_obj_t* odLbl = lv_label_create(octDownBtn); + lv_label_set_text(odLbl, "OCT-"); + lv_obj_set_style_text_font(odLbl, fontSmall, 0); + lv_obj_center(odLbl); + + // OCT label + ks.octLbl = lv_label_create(ctrl); + char obuf[16]; snprintf(obuf, sizeof(obuf), "Oct %d", ks.octave); + lv_label_set_text(ks.octLbl, obuf); + lv_obj_set_style_text_font(ks.octLbl, fontSmall, 0); + lv_obj_set_style_text_color(ks.octLbl, lv_color_hex(0x00DFFF), 0); + lv_obj_set_width(ks.octLbl, 144); + lv_obj_set_style_text_align(ks.octLbl, LV_TEXT_ALIGN_CENTER, 0); + + // OCT + + lv_obj_t* octUpBtn = mcButton(ctrl); + lv_obj_set_size(octUpBtn, 144, btnH); + lv_obj_add_event_cb(octUpBtn, cbOctUp, LV_EVENT_CLICKED, mc); + lv_obj_t* ouLbl = lv_label_create(octUpBtn); + lv_label_set_text(ouLbl, "OCT+"); + lv_obj_set_style_text_font(ouLbl, fontSmall, 0); + lv_obj_center(ouLbl); + + // Divider label + lv_obj_t* div = lv_label_create(ctrl); + lv_label_set_text(div, "|"); + lv_obj_set_style_text_color(div, lv_color_hex(0x334455), 0); + lv_obj_set_style_text_font(div, fontSmall, 0); + + // Scale cycle button + lv_obj_t* scaleBtn = mcButton(ctrl); + lv_obj_set_size(scaleBtn, 224, btnH); + lv_obj_add_event_cb(scaleBtn, cbScaleCycle, LV_EVENT_CLICKED, mc); + ks.scaleLbl = lv_label_create(scaleBtn); + lv_label_set_text(ks.scaleLbl, MIDI_SCALES[ks.scale % NUM_MIDI_SCALES].name); + lv_obj_set_style_text_font(ks.scaleLbl, fontSmall, 0); + lv_obj_center(ks.scaleLbl); + + // KEY - + lv_obj_t* keyDownBtn = mcButton(ctrl); + lv_obj_set_size(keyDownBtn, 112, btnH); + lv_obj_add_event_cb(keyDownBtn, cbKeyDown, LV_EVENT_CLICKED, mc); + lv_obj_t* kdLbl = lv_label_create(keyDownBtn); + lv_label_set_text(kdLbl, "KEY-"); + lv_obj_set_style_text_font(kdLbl, fontSmall, 0); + lv_obj_center(kdLbl); + + // Key label + ks.keyLbl = lv_label_create(ctrl); + lv_label_set_text(ks.keyLbl, KEY_NAMES[ks.key % 12]); + lv_obj_set_style_text_font(ks.keyLbl, fontNormal, 0); + lv_obj_set_style_text_color(ks.keyLbl, MC_VALUE(), 0); + lv_obj_set_width(ks.keyLbl, 96); + lv_obj_set_style_text_align(ks.keyLbl, LV_TEXT_ALIGN_CENTER, 0); + + // KEY + + lv_obj_t* keyUpBtn = mcButton(ctrl); + lv_obj_set_size(keyUpBtn, 112, btnH); + lv_obj_add_event_cb(keyUpBtn, cbKeyUp, LV_EVENT_CLICKED, mc); + lv_obj_t* kuLbl = lv_label_create(keyUpBtn); + lv_label_set_text(kuLbl, "KEY+"); + lv_obj_set_style_text_font(kuLbl, fontSmall, 0); + lv_obj_center(kuLbl); + + // -- Piano area (single row/octave, flex_grow fills remaining height) ----- + lv_obj_t* pianoArea = mcContainer(root); + lv_obj_set_width(pianoArea, LV_PCT(100)); + lv_obj_set_flex_grow(pianoArea, 1); + lv_obj_set_style_bg_color(pianoArea, lv_color_hex(0x050508), 0); + lv_obj_set_style_bg_opa(pianoArea, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(pianoArea, 0, 0); + lv_obj_set_style_radius(pianoArea, 0, 0); + lv_obj_set_style_pad_all(pianoArea, 0, 0); + lv_obj_set_scrollable(pianoArea, false); + + lv_obj_add_event_cb(pianoArea, cbPianoPressing, LV_EVENT_PRESSING, mc); + lv_obj_add_event_cb(pianoArea, cbPianoReleased, LV_EVENT_RELEASED, mc); + + ks.pianoArea = pianoArea; + + int wkGap = 2; // seam between white keys (dark bg shows through) + int wkw = W / 7; // white key cell width + int bkw = (wkw * 3) / 5; // black key width (~60%) + + // White keys (7), full height, thin dark seam between each - no border stroke + for (int k = 0; k < 7; k++) { + int midiNote = std::max(0, std::min(127, (ks.octave + 1) * 12 + WHITE_OFF[k])); + + lv_obj_t* btn = mcButton(pianoArea); + lv_obj_set_pos(btn, k * wkw, 0); + lv_obj_set_size(btn, wkw - wkGap, LV_PCT(100)); + lv_obj_set_style_bg_color(btn, keyColorWhiteDefault(), 0); + lv_obj_set_style_bg_opa(btn, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(btn, 0, 0); + lv_obj_set_style_radius(btn, 0, 0); + lv_obj_set_style_pad_all(btn, 0, 0); + lv_obj_set_style_shadow_width(btn, 0, 0); + // Without this, LVGL locks the press onto the first key touched and never + // re-detects the object under the finger while sliding (see lv_indev.c + // indev_proc_press: press-locked objects skip re-search) - breaks piano slide. + lv_obj_set_press_lock(btn, false); + lv_obj_set_user_data(btn, (void*)(intptr_t)midiNote); + // Note On/Off via the row container's PRESSING/RELEASED handlers + // but also handle individual taps via PRESSED/RELEASED on each key + lv_obj_add_event_cb(btn, cbPianoPressing, LV_EVENT_PRESSED, mc); + lv_obj_add_event_cb(btn, cbPianoReleased, LV_EVENT_RELEASED, mc); + + // Note label (C note shows octave, others show letter) + lv_obj_t* lbl = lv_label_create(btn); + if (k == 0) { + char buf[16]; snprintf(buf, sizeof(buf), "C%d", ks.octave); + lv_label_set_text(lbl, buf); + } else { + lv_label_set_text(lbl, WHITE_NAME[k]); + } + lv_obj_set_style_text_font(lbl, fontSmall, 0); + lv_obj_set_style_text_color(lbl, lv_color_hex(0x555566), 0); + lv_obj_align(lbl, LV_ALIGN_BOTTOM_MID, 0, -6); + + ks.whiteKey[k] = btn; + } + + // Black keys (5), shorter, straddling the seam between white keys, on top + for (int k = 0; k < 5; k++) { + int midiNote = std::max(0, std::min(127, (ks.octave + 1) * 12 + BLACK_OFF[k])); + int centerX = (BLACK_FRAC[k] * wkw) / 100; + int leftX = centerX - bkw / 2; + + lv_obj_t* btn = mcButton(pianoArea); + lv_obj_set_pos(btn, leftX, 0); + lv_obj_set_size(btn, bkw, LV_PCT(60)); + lv_obj_set_style_bg_color(btn, keyColorBlackDefault(), 0); + lv_obj_set_style_bg_opa(btn, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(btn, 0, 0); + lv_obj_set_style_radius(btn, 0, 0); + lv_obj_set_style_pad_all(btn, 0, 0); + lv_obj_set_style_shadow_width(btn, 0, 0); + lv_obj_set_press_lock(btn, false); + lv_obj_set_user_data(btn, (void*)(intptr_t)midiNote); + lv_obj_add_event_cb(btn, cbPianoPressing, LV_EVENT_PRESSED, mc); + lv_obj_add_event_cb(btn, cbPianoReleased, LV_EVENT_RELEASED, mc); + lv_obj_move_foreground(btn); // render on top of white keys + + ks.blackKey[k] = btn; + } + + // -- Status bar ------------------------------------------------------------ + lv_obj_t* statusBar = mcContainer(root); + lv_obj_set_size(statusBar, LV_PCT(100), statusH); + lv_obj_set_style_bg_color(statusBar, lv_color_hex(0x0A0C14), 0); + lv_obj_set_style_bg_opa(statusBar, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(statusBar, 0, 0); + lv_obj_set_style_pad_all(statusBar, 8, 0); + lv_obj_set_scrollable(statusBar, false); + + ks.statusLbl = lv_label_create(statusBar); + lv_obj_set_style_text_font(ks.statusLbl, fontSmall, 0); + lv_obj_set_style_text_color(ks.statusLbl, lv_color_hex(0x666688), 0); + lv_obj_center(ks.statusLbl); + + // Apply initial labels and default key colours + rebuildKeyNotes(mc); +} diff --git a/Apps/MidiController/main/Source/ViewLfo.cpp b/Apps/MidiController/main/Source/ViewLfo.cpp new file mode 100644 index 0000000..28893b8 --- /dev/null +++ b/Apps/MidiController/main/Source/ViewLfo.cpp @@ -0,0 +1,449 @@ +#include "MidiController.h" + +#include +#include +#include +#include +#include + +// --------------------------------------------------------------------------- +// LFO view - Low-frequency oscillator for CC/Pitch modulation +// +// Layout (flex column): +// [row 1: < | LFO MOD | status dot] +// [row 2: START/STOP | Rate -/val/Hz/+ | WAVE] +// [row 3: Amt -/val/+ | Tgt CC -/val/+ | PITCH] +// [waveform area (LV_EVENT_DRAW_MAIN)] +// [value readout (flex_grow)] +// --------------------------------------------------------------------------- + +static const char* WAVE_NAMES[] = { "SINE", "TRI", "SQR", "SAW" }; + + +// --------------------------------------------------------------------------- +// Waveform computation +// --------------------------------------------------------------------------- + +static float computeWave(int waveform, float phase) { + switch (waveform) { + case 0: return sinf(phase * (float)(2.0 * M_PI)); + case 1: return (phase < 0.5f) ? (phase * 4.0f - 1.0f) : (3.0f - phase * 4.0f); + case 2: return (phase < 0.5f) ? 1.0f : -1.0f; + case 3: return (2.0f * phase) - 1.0f; + default: return 0.0f; + } +} + +static int waveToCCValue(float wave, int amount, int center = 64) { + int val = center + (int)(wave * (float)amount * 0.5f); + return val < 0 ? 0 : (val > 127 ? 127 : val); +} + +// --------------------------------------------------------------------------- +// Waveform draw event +// --------------------------------------------------------------------------- + +static void cbWaveDraw(lv_event_t* e) { + auto* mc = static_cast(lv_event_get_user_data(e)); + if (!mc) return; + auto& lf = mc->lfo_; + + lv_obj_t* obj = lv_event_get_target_obj(e); + lv_layer_t* layer = lv_event_get_layer(e); + + lv_area_t area; + lv_obj_get_coords(obj, &area); + int32_t ox = area.x1, oy = area.y1; + int32_t cW = lv_area_get_width(&area); + int32_t cH = lv_area_get_height(&area); + + lf.canvasW = (int)cW; + lf.canvasH = (int)cH; + + // Background + lv_draw_fill_dsc_t bg; + lv_draw_fill_dsc_init(&bg); + bg.color = lv_color_hex(0x080C12); + bg.opa = LV_OPA_COVER; + lv_draw_fill(layer, &bg, &area); + + if (cW <= 1 || cH <= 1) return; + + lv_draw_line_dsc_t line; + lv_draw_line_dsc_init(&line); + line.opa = LV_OPA_COVER; + + // Center line + line.color = lv_color_hex(0x1A2A40); + line.width = 1; + line.p1.x = (lv_value_precise_t)ox; + line.p1.y = (lv_value_precise_t)(oy + cH / 2); + line.p2.x = (lv_value_precise_t)(ox + cW - 1); + line.p2.y = (lv_value_precise_t)(oy + cH / 2); + lv_draw_line(layer, &line); + + // Waveform - initialize prevY from waveform value at phase 0 (not hardcoded cH/2) + line.color = lv_color_hex(0x00DFFF); + line.width = 1; + float v0 = computeWave(lf.waveform, 0.0f); + int prevY = (int)((1.0f - (v0 + 1.0f) * 0.5f) * (float)(cH - 1)); + prevY = prevY < 0 ? 0 : (prevY >= cH ? cH - 1 : prevY); + for (int x = 1; x < cW; x++) { + float ph = (float)x / (float)cW; + float val = computeWave(lf.waveform, ph); + int y = (int)((1.0f - (val + 1.0f) * 0.5f) * (float)(cH - 1)); + y = y < 0 ? 0 : (y >= cH ? cH - 1 : y); + line.p1.x = (lv_value_precise_t)(ox + x - 1); + line.p1.y = (lv_value_precise_t)(oy + prevY); + line.p2.x = (lv_value_precise_t)(ox + x); + line.p2.y = (lv_value_precise_t)(oy + y); + lv_draw_line(layer, &line); + prevY = y; + } +} + +// --------------------------------------------------------------------------- +// LFO timer +// --------------------------------------------------------------------------- + +static void cbLfoTimer(lv_timer_t* t) { + auto* mc = static_cast(lv_timer_get_user_data(t)); + if (!mc || !mc->lfo_.running) return; + auto& lf = mc->lfo_; + + uint64_t nowUs = (uint64_t)esp_timer_get_time(); + uint64_t deltaUs = nowUs - lf.lastUpdateUs; + lf.lastUpdateUs = nowUs; + + float phaseInc = lf.rate * (float)deltaUs / 1000000.0f; + lf.phase = fmodf(lf.phase + phaseInc, 1.0f); + + float wave = computeWave(lf.waveform, lf.phase); + + if (lf.pitchMode) { + int bend = 8192 + (int)(wave * (float)lf.amount * 64.0f); + bend = bend < 0 ? 0 : (bend > 16383 ? 16383 : bend); + uint8_t lsb = bend & 0x7F; + uint8_t msb = (bend >> 7) & 0x7F; + if (lf.lastValue != bend / 128) { + mc->sendMidi(0xE0, lsb, msb); + lf.lastValue = bend / 128; + } + } else { + int ccVal = waveToCCValue(wave, lf.amount); + if (ccVal != lf.lastValue) { + mc->sendMidi(0xB0, (uint8_t)lf.ccTarget, (uint8_t)ccVal); + lf.lastValue = ccVal; + } + } + + lf.updateCounter++; + if (lf.updateCounter >= 5) { + lf.updateCounter = 0; + if (lf.valueLbl) { + char buf[12]; snprintf(buf, sizeof(buf), "Val: %d", lf.lastValue); + lv_label_set_text(lf.valueLbl, buf); + } + if (lf.statusLbl) { + lv_label_set_text(lf.statusLbl, + lf.pitchMode ? "RUN PITCH" : "RUN CC"); + } + } +} + +// --------------------------------------------------------------------------- +// Control callbacks +// --------------------------------------------------------------------------- + +static void cbBack(lv_event_t* e) { + auto* mc = static_cast(lv_event_get_user_data(e)); + if (mc) mc->switchToMode(MidiMode::HOME); +} + +static void cbStartStop(lv_event_t* e) { + auto* mc = static_cast(lv_event_get_user_data(e)); + if (!mc) return; + auto& lf = mc->lfo_; + lf.running = !lf.running; + lf.lastUpdateUs = (uint64_t)esp_timer_get_time(); + if (lf.running) { + if (!mc->lfoTimer_) { + mc->lfoTimer_ = lv_timer_create(cbLfoTimer, 20, mc); + } + } else { + if (mc->lfoTimer_) { lv_timer_delete(mc->lfoTimer_); mc->lfoTimer_ = nullptr; } + if (lf.pitchMode) mc->sendMidi(0xE0, 0x00, 0x40); + } + if (lf.startBtn) { + lv_obj_set_style_bg_color(lf.startBtn, + lf.running ? lv_color_hex(0xFF2020) : lv_color_hex(0x00AA44), 0); + } + if (lf.startLbl) lv_label_set_text(lf.startLbl, lf.running ? "STOP" : "START"); + if (lf.statusLbl) lv_label_set_text(lf.statusLbl, lf.running ? "RUN" : "IDLE"); +} + +static void cbRateDown(lv_event_t* e) { + auto* mc = static_cast(lv_event_get_user_data(e)); + if (!mc) return; + mc->lfo_.rate -= 0.1f; + if (mc->lfo_.rate < 0.1f) mc->lfo_.rate = 0.1f; + if (mc->lfo_.rateLbl) { + char buf[8]; snprintf(buf, sizeof(buf), "%.1f", mc->lfo_.rate); + lv_label_set_text(mc->lfo_.rateLbl, buf); + } +} +static void cbRateUp(lv_event_t* e) { + auto* mc = static_cast(lv_event_get_user_data(e)); + if (!mc) return; + mc->lfo_.rate += 0.1f; + if (mc->lfo_.rate > 10.0f) mc->lfo_.rate = 10.0f; + if (mc->lfo_.rateLbl) { + char buf[8]; snprintf(buf, sizeof(buf), "%.1f", mc->lfo_.rate); + lv_label_set_text(mc->lfo_.rateLbl, buf); + } +} + +static void cbWaveCycle(lv_event_t* e) { + auto* mc = static_cast(lv_event_get_user_data(e)); + if (!mc) return; + mc->lfo_.waveform = (mc->lfo_.waveform + 1) % 4; + if (mc->lfo_.waveLbl) lv_label_set_text(mc->lfo_.waveLbl, WAVE_NAMES[mc->lfo_.waveform]); + // Invalidate canvas to trigger redraw + if (mc->lfo_.canvas) lv_obj_invalidate(mc->lfo_.canvas); +} + +static void cbAmountDown(lv_event_t* e) { + auto* mc = static_cast(lv_event_get_user_data(e)); + if (!mc) return; + mc->lfo_.amount -= 5; if (mc->lfo_.amount < 0) mc->lfo_.amount = 0; + if (mc->lfo_.amountLbl) { + char buf[10]; snprintf(buf, sizeof(buf), "Amt:%d", mc->lfo_.amount); + lv_label_set_text(mc->lfo_.amountLbl, buf); + } +} +static void cbAmountUp(lv_event_t* e) { + auto* mc = static_cast(lv_event_get_user_data(e)); + if (!mc) return; + mc->lfo_.amount += 5; if (mc->lfo_.amount > 127) mc->lfo_.amount = 127; + if (mc->lfo_.amountLbl) { + char buf[10]; snprintf(buf, sizeof(buf), "Amt:%d", mc->lfo_.amount); + lv_label_set_text(mc->lfo_.amountLbl, buf); + } +} + +static void cbCCDown(lv_event_t* e) { + auto* mc = static_cast(lv_event_get_user_data(e)); + if (!mc || mc->lfo_.pitchMode) return; + mc->lfo_.ccTarget = (mc->lfo_.ccTarget - 1 + 128) % 128; + if (mc->lfo_.ccLbl) { + char buf[8]; snprintf(buf, sizeof(buf), "CC %d", mc->lfo_.ccTarget); + lv_label_set_text(mc->lfo_.ccLbl, buf); + } +} +static void cbCCUp(lv_event_t* e) { + auto* mc = static_cast(lv_event_get_user_data(e)); + if (!mc || mc->lfo_.pitchMode) return; + mc->lfo_.ccTarget = (mc->lfo_.ccTarget + 1) % 128; + if (mc->lfo_.ccLbl) { + char buf[8]; snprintf(buf, sizeof(buf), "CC %d", mc->lfo_.ccTarget); + lv_label_set_text(mc->lfo_.ccLbl, buf); + } +} + +static void cbPitchToggle(lv_event_t* e) { + auto* mc = static_cast(lv_event_get_user_data(e)); + if (!mc) return; + auto& lf = mc->lfo_; + bool wasPitch = lf.pitchMode; + lf.pitchMode = !lf.pitchMode; + // Leaving pitch mode must release the bend, otherwise the receiver stays + // detuned. Force the next CC update as well so the CC target is refreshed. + if (wasPitch) mc->sendMidi(0xE0, 0x00, 0x40); + lf.lastValue = -1; + if (mc->lfo_.ccLbl) { + if (mc->lfo_.pitchMode) { + lv_label_set_text(mc->lfo_.ccLbl, "PITCH"); + lv_obj_set_style_text_color(mc->lfo_.ccLbl, lv_color_hex(0xFF8000), 0); + } else { + char buf[8]; snprintf(buf, sizeof(buf), "CC %d", mc->lfo_.ccTarget); + lv_label_set_text(mc->lfo_.ccLbl, buf); + lv_obj_set_style_text_color(mc->lfo_.ccLbl, lv_color_hex(0x00DFFF), 0); + } + } +} + +// --------------------------------------------------------------------------- +// Teardown +// --------------------------------------------------------------------------- + +static void cbLfoRootDelete(lv_event_t* e) { + auto* mc = static_cast(lv_event_get_user_data(e)); + if (!mc) return; + auto& lf = mc->lfo_; + + // Stop and delete the LFO timer so it cannot fire after widgets are gone + if (mc->lfoTimer_) { + lv_timer_delete(mc->lfoTimer_); + mc->lfoTimer_ = nullptr; + } + // Do not leave a pitch-bend receiver detuned when leaving the view. + if (lf.pitchMode) mc->sendMidi(0xE0, 0x00, 0x40); + lf.running = false; + + // Null all stored LVGL pointers + lf.canvas = nullptr; + lf.statusLbl = nullptr; + lf.rateLbl = nullptr; + lf.amountLbl = nullptr; + lf.ccLbl = nullptr; + lf.waveLbl = nullptr; + lf.valueLbl = nullptr; + lf.startBtn = nullptr; + lf.startLbl = nullptr; +} + +// --------------------------------------------------------------------------- +// Build function +// --------------------------------------------------------------------------- + +void buildLfoView(MidiController* mc, lv_obj_t* parent) { + auto& lf = mc->lfo_; + + const lv_font_t* fontSmall = lvgl_get_text_font(FONT_SIZE_SMALL); + const lv_font_t* fontNormal = lvgl_get_text_font(FONT_SIZE_DEFAULT); + + int rowH = 88; + int waveH = 160; + + lv_obj_t* root = mcContainer(parent); + lv_obj_add_event_cb(root, cbLfoRootDelete, LV_EVENT_DELETE, mc); + lv_obj_set_size(root, LV_PCT(100), LV_PCT(100)); + lv_obj_set_flex_flow(root, LV_FLEX_FLOW_COLUMN); + lv_obj_set_style_bg_color(root, lv_color_hex(0x080C12), 0); + lv_obj_set_style_bg_opa(root, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(root, 0, 0); + lv_obj_set_style_pad_all(root, 0, 0); + lv_obj_set_style_pad_gap(root, 1, 0); + lv_obj_set_scrollable(root, false); + mc->currentViewRoot_ = root; + + auto makeRow = [&]() -> lv_obj_t* { return mcRow(root); }; + + auto makeBtn = [&](lv_obj_t* par, const char* txt, int w, lv_event_cb_t cb) -> lv_obj_t* { + return mcSmallBtn(par, txt, w, cb, mc); + }; + + auto makeLbl = [&](lv_obj_t* par, const char* txt, int w, lv_color_t col) -> lv_obj_t* { + return mcValue(par, txt, w, col); + }; + + // -- Row 1: [<] [LFO MOD] [status] ---------------------------------------- + lv_obj_t* row1 = makeRow(); + mcBackBtn(row1, cbBack, mc, MC_W_M); + lv_obj_t* title = lv_label_create(row1); + lv_label_set_text(title, "LFO MOD"); + lv_obj_set_style_text_font(title, fontNormal, 0); + lv_obj_set_style_text_color(title, lv_color_hex(0xCC44FF), 0); + lv_obj_set_flex_grow(title, 1); + lf.statusLbl = lv_label_create(row1); + lv_label_set_text(lf.statusLbl, "IDLE"); + lv_obj_set_style_text_font(lf.statusLbl, fontSmall, 0); + lv_obj_set_style_text_color(lf.statusLbl, MC_LABEL(), 0); + + // -- Row 2: START/STOP | Rate -/val/Hz/+ | WAVE --------------------------- + lv_obj_t* row2 = makeRow(); + lf.startBtn = mcButton(row2); + lv_obj_set_size(lf.startBtn, 224, MC_BTN_H); + lv_obj_set_style_bg_color(lf.startBtn, lv_color_hex(0x00AA44), 0); + lv_obj_add_event_cb(lf.startBtn, cbStartStop, LV_EVENT_CLICKED, mc); + lf.startLbl = lv_label_create(lf.startBtn); + lv_label_set_text(lf.startLbl, "START"); + lv_obj_set_style_text_font(lf.startLbl, fontSmall, 0); + lv_obj_center(lf.startLbl); + + lv_obj_t* rHdr = lv_label_create(row2); + lv_label_set_text(rHdr, " Rate:"); + lv_obj_set_style_text_font(rHdr, fontSmall, 0); + lv_obj_set_style_text_color(rHdr, lv_color_hex(0x666688), 0); + makeBtn(row2, "-", 96, cbRateDown); + char rbuf[8]; snprintf(rbuf, sizeof(rbuf), "%.1f", lf.rate); + lf.rateLbl = makeLbl(row2, rbuf, 144, lv_color_hex(0x00DFFF)); + lv_obj_t* rHz = lv_label_create(row2); + lv_label_set_text(rHz, "Hz"); + lv_obj_set_style_text_font(rHz, fontSmall, 0); + lv_obj_set_style_text_color(rHz, lv_color_hex(0x666688), 0); + makeBtn(row2, "+", 96, cbRateUp); + + lv_obj_t* waveBtn = mcButton(row2); + lv_obj_set_size(waveBtn, 208, MC_BTN_H); + lv_obj_add_event_cb(waveBtn, cbWaveCycle, LV_EVENT_CLICKED, mc); + lf.waveLbl = lv_label_create(waveBtn); + lv_label_set_text(lf.waveLbl, WAVE_NAMES[lf.waveform]); + lv_obj_set_style_text_font(lf.waveLbl, fontSmall, 0); + lv_obj_center(lf.waveLbl); + + // -- Row 3: Amount -/val/+ | CC -/val/+ | PITCH --------------------------- + lv_obj_t* row3 = makeRow(); + + lv_obj_t* aHdr = lv_label_create(row3); + lv_label_set_text(aHdr, "Amt:"); + lv_obj_set_style_text_font(aHdr, fontSmall, 0); + lv_obj_set_style_text_color(aHdr, lv_color_hex(0x666688), 0); + makeBtn(row3, "-", 96, cbAmountDown); + char abuf[10]; snprintf(abuf, sizeof(abuf), "Amt:%d", lf.amount); + lf.amountLbl = makeLbl(row3, abuf, 208, lv_color_hex(0xFFE000)); + makeBtn(row3, "+", 96, cbAmountUp); + + lv_obj_t* cHdr = lv_label_create(row3); + lv_label_set_text(cHdr, " Tgt:"); + lv_obj_set_style_text_font(cHdr, fontSmall, 0); + lv_obj_set_style_text_color(cHdr, lv_color_hex(0x666688), 0); + makeBtn(row3, "-", 96, cbCCDown); + char ccbuf[10]; snprintf(ccbuf, sizeof(ccbuf), "CC %d", lf.ccTarget); + lf.ccLbl = makeLbl(row3, ccbuf, 192, lv_color_hex(0x00DFFF)); + makeBtn(row3, "+", 96, cbCCUp); + + lv_obj_t* pitchBtn = mcButton(row3); + lv_obj_set_size(pitchBtn, 192, MC_BTN_H); + lv_obj_set_style_bg_color(pitchBtn, lv_color_hex(0x220033), 0); + lv_obj_add_event_cb(pitchBtn, cbPitchToggle, LV_EVENT_CLICKED, mc); + lv_obj_t* pitchLbl = lv_label_create(pitchBtn); + lv_label_set_text(pitchLbl, "PITCH"); + lv_obj_set_style_text_font(pitchLbl, fontSmall, 0); + lv_obj_center(pitchLbl); + + // -- Spacer row (pushes waveform area down) -------------------------------- + lv_obj_t* spacer = mcContainer(root); + lv_obj_set_size(spacer, LV_PCT(100), rowH); + lv_obj_set_style_bg_opa(spacer, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(spacer, 0, 0); + lv_obj_set_style_pad_all(spacer, 0, 0); + lv_obj_set_scrollable(spacer, false); + + // -- Waveform display (draw event) ----------------------------------------- + lv_obj_t* waveArea = mcContainer(root); + lv_obj_set_size(waveArea, LV_PCT(100), waveH); + lv_obj_set_style_bg_color(waveArea, lv_color_hex(0x080C12), 0); + lv_obj_set_style_bg_opa(waveArea, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(waveArea, 0, 0); + lv_obj_set_style_pad_all(waveArea, 0, 0); + lv_obj_set_scrollable(waveArea, false); + lv_obj_add_event_cb(waveArea, cbWaveDraw, LV_EVENT_DRAW_MAIN, mc); + lf.canvas = waveArea; + + // -- Value readout --------------------------------------------------------- + lv_obj_t* footer = mcContainer(root); + lv_obj_set_width(footer, LV_PCT(100)); + lv_obj_set_flex_grow(footer, 1); + lv_obj_set_style_bg_color(footer, lv_color_hex(0x080C12), 0); + lv_obj_set_style_bg_opa(footer, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(footer, 0, 0); + lv_obj_set_style_pad_all(footer, 16, 0); + lv_obj_set_scrollable(footer, false); + + lf.valueLbl = lv_label_create(footer); + lv_label_set_text(lf.valueLbl, "Val: 64"); + lv_obj_set_style_text_font(lf.valueLbl, fontNormal, 0); + lv_obj_set_style_text_color(lf.valueLbl, lv_color_hex(0x00DFFF), 0); + lv_obj_center(lf.valueLbl); +} diff --git a/Apps/MidiController/main/Source/ViewRaga.cpp b/Apps/MidiController/main/Source/ViewRaga.cpp new file mode 100644 index 0000000..7d2eba7 --- /dev/null +++ b/Apps/MidiController/main/Source/ViewRaga.cpp @@ -0,0 +1,554 @@ +#include "MidiController.h" + +#include +#include +#include +#include +#include +#include + +// --------------------------------------------------------------------------- +// RAGA view - Indian classical raga Markov-chain phrase generator +// +// Layout (flex column): +// [ctrl row: < | RAGA | status] +// [raga row: Raga- | name | Raga+] +// [tala row: Tala- | name | Tala+] +// [root row: Root- | C3 | Root+] +// [bpm row: BPM- | 120 | BPM+ ] +// [spacer flex_grow] +// [play row: PLAY/STOP | DRONE] +// --------------------------------------------------------------------------- + +// --------------------------------------------------------------------------- +// Raga data +// --------------------------------------------------------------------------- + +static constexpr int RAGA_COUNT = 8; +static constexpr int TALA_COUNT = 3; + +static const char* RAGA_NAMES[RAGA_COUNT] = { + "Bhairavi", "Lalit", "Bhupali", "Todi", + "Madhuvanti", "Meghmalhar", "Yaman", "Malkauns" +}; +static const char* TALA_NAMES[TALA_COUNT] = { + "Teental", "Rupak", "Jhaptal" +}; + +static const uint8_t RAGA_INTERVALS[RAGA_COUNT][7] = { + {0, 1, 3, 5, 7, 8, 10}, // Bhairavi + {0, 1, 4, 5, 6, 9, 11}, // Lalit + {0, 2, 4, 7, 9, 0, 0}, // Bhupali (5 notes) + {0, 1, 3, 6, 7, 8, 11}, // Todi + {0, 2, 3, 6, 7, 9, 11}, // Madhuvanti + {0, 2, 5, 7, 10, 0, 0}, // Meghmalhar (5 notes) + {0, 2, 4, 6, 7, 9, 11}, // Yaman + {0, 3, 5, 8, 10, 0, 0}, // Malkauns (5 notes) +}; +static const uint8_t RAGA_LENGTHS[RAGA_COUNT] = {7, 7, 5, 7, 7, 5, 7, 5}; + +// Tala accent patterns (2=sam/strong, 1=khali/medium, 0=weak) +static const uint8_t TEENTAL_ACCENTS[16] = {2,0,0,0, 1,0,0,0, 1,0,0,0, 1,0,0,0}; +static const uint8_t RUPAK_ACCENTS[7] = {2,0,0, 1,0,0,0}; +static const uint8_t JHAPTAL_ACCENTS[10] = {2,0,0, 1,0,0, 0,1,0,0}; + +static const int TALA_BEATS[TALA_COUNT] = {16, 7, 10}; +static const uint8_t* TALA_ACCENTS[TALA_COUNT] = { + TEENTAL_ACCENTS, RUPAK_ACCENTS, JHAPTAL_ACCENTS +}; + +// 2nd-order Markov weights for Bhairavi [prev_prev][prev][next] +static const uint8_t BHAIRAVI_WEIGHTS[7][7][7] = { + {{40,30,15,25,10,5,5},{20,20,40,35,10,5,5},{15,25,20,50,10,5,5}, + {30,20,30,30,15,10,5},{25,15,20,40,20,15,10},{20,15,10,25,15,20,30},{50,20,10,15,10,10,15}}, + {{40,30,20,20,10,5,5},{25,35,30,20,10,5,5},{15,20,25,45,15,5,5}, + {25,20,35,30,15,10,5},{20,25,20,35,20,10,5},{15,20,15,25,15,25,20},{45,25,15,15,10,10,10}}, + {{35,30,20,25,10,5,5},{35,25,25,25,10,5,5},{20,25,25,35,15,10,5}, + {20,15,20,40,20,20,10},{15,20,25,40,20,15,10},{10,15,15,30,15,25,25},{40,25,15,15,10,10,15}}, + {{45,30,20,20,10,5,5},{30,25,30,25,10,5,5},{25,35,25,25,10,5,5}, + {25,20,25,40,20,15,10},{20,15,20,40,25,15,10},{10,10,15,30,15,25,35},{50,20,15,20,10,10,10}}, + {{40,25,20,25,15,5,5},{25,25,25,30,15,10,5},{20,25,25,40,15,10,5}, + {20,20,25,40,20,15,10},{25,15,20,40,25,20,10},{15,15,15,30,15,30,25},{45,20,15,20,15,10,10}}, + {{40,25,20,20,15,10,10},{25,30,25,25,15,15,10},{20,25,25,35,15,15,10}, + {20,20,25,35,20,20,15},{20,20,20,35,25,20,15},{15,15,15,25,15,30,30},{45,20,15,20,15,15,15}}, + {{50,25,15,20,10,10,10},{30,30,25,25,15,10,5},{25,25,25,30,15,10,10}, + {25,20,25,35,15,15,10},{25,20,20,35,20,15,10},{20,20,15,25,15,25,25},{45,25,15,20,10,10,15}}, +}; + +// --------------------------------------------------------------------------- +// Algorithm helpers (file-static, operate on RagaState&) +// --------------------------------------------------------------------------- + +static int ragaSelectNextBhairavi(const int8_t hist[2]) { + int h0 = ((int)hist[0] % 7 + 7) % 7; + int h1 = ((int)hist[1] % 7 + 7) % 7; + const uint8_t* w = BHAIRAVI_WEIGHTS[h0][h1]; + int total = 0; + for (int i = 0; i < 7; i++) total += w[i]; + if (total <= 0) return 0; + int sel = (int)((uint32_t)esp_random() % (uint32_t)total); + int cum = 0; + for (int i = 0; i < 7; i++) { + cum += w[i]; + if (sel < cum) return i; + } + return 0; +} + +static void ragaGeneratePhrase(MidiController::RagaState& rs) { + int scaleLen = RAGA_LENGTHS[rs.raga]; + const uint8_t* scale = RAGA_INTERVALS[rs.raga]; + int total = 32; + rs.phraseLen = total; + + if (rs.raga == 0) { + // Bhairavi: 2nd-order Markov + int8_t hist[2] = {rs.history[0], rs.history[1]}; + int octave = 0; + for (int i = 0; i < total; i++) { + int degree = ragaSelectNextBhairavi(hist); + if ((uint32_t)(esp_random() % 100) < 8) { + int shift = (int)((uint32_t)(esp_random() % 3)) - 1; + octave = std::max(-1, std::min(1, octave + shift)); + } + rs.phrase[i] = (int8_t)(scale[degree] + octave * 12); + hist[0] = hist[1]; + hist[1] = (int8_t)degree; + } + rs.history[0] = hist[0]; + rs.history[1] = hist[1]; + } else { + // Other ragas: random walk + int degree = (int)((uint32_t)(esp_random() % (uint32_t)scaleLen)); + int octave = 0; + for (int i = 0; i < total; i++) { + int step = (int)((uint32_t)(esp_random() % 3)) - 1; + degree = std::max(0, std::min(scaleLen - 1, degree + step)); + if ((uint32_t)(esp_random() % 100) < 10) { + int shift = (int)((uint32_t)(esp_random() % 3)) - 1; + octave = std::max(-1, std::min(1, octave + shift)); + } + rs.phrase[i] = (int8_t)(scale[degree] + octave * 12); + } + } + rs.phraseIdx = 0; +} + +static void ragaUpdateTempo(MidiController::RagaState& rs) { + if (rs.bpm < 1) rs.bpm = 120; // Safe default + uint32_t interval = 60000u / (uint32_t)rs.bpm; + rs.noteInterval = std::max((uint32_t)40, interval / 4); + rs.noteDuration = std::max((uint32_t)30, rs.noteInterval * 8 / 10); +} + +static void ragaStopNote(MidiController* mc) { + auto& rs = mc->raga_; + if (rs.noteActive) { + mc->sendMidi(0x80, rs.currentNote, 0); + rs.noteActive = false; + } +} + +static void ragaUpdateDrone(MidiController* mc) { + auto& rs = mc->raga_; + if (rs.droneOn) { + if (!rs.droneActive || rs.droneNote != (uint8_t)rs.rootNote) { + if (rs.droneActive) mc->sendMidi(0x80, rs.droneNote, 0); + rs.droneNote = (uint8_t)rs.rootNote; + rs.droneActive = true; + mc->sendMidi(0x90, rs.droneNote, 80); + } + } else if (rs.droneActive) { + mc->sendMidi(0x80, rs.droneNote, 0); + rs.droneActive = false; + } +} + +// --------------------------------------------------------------------------- +// Timer callback +// --------------------------------------------------------------------------- + +static void cbRagaTimer(lv_timer_t* t) { + auto* mc = static_cast(lv_timer_get_user_data(t)); + if (!mc) return; + auto& rs = mc->raga_; + + ragaUpdateDrone(mc); + if (!rs.playing) return; + + uint32_t now = lv_tick_get(); + + // Note-off check + if (rs.noteActive && now >= rs.noteOffMs) { + ragaStopNote(mc); + } + + // Schedule next note + if (now >= rs.nextNoteMs) { + if (rs.phraseLen == 0 || rs.phraseIdx >= rs.phraseLen) + ragaGeneratePhrase(rs); + + ragaStopNote(mc); + uint8_t note = (uint8_t)std::max(0, std::min(127, rs.rootNote + rs.phrase[rs.phraseIdx++])); + // Ensure talaBeat is within bounds for current tala + if (rs.talaBeat >= TALA_BEATS[rs.tala]) rs.talaBeat = 0; + uint8_t accent = TALA_ACCENTS[rs.tala][rs.talaBeat]; + uint8_t vel = (uint8_t)std::min(127, 90 + (int)accent * 10); + mc->sendMidi(0x90, note, vel); + rs.currentNote = note; + rs.noteActive = true; + rs.noteOffMs = now + rs.noteDuration; + + // Update status label + if (rs.statusLbl) { + char buf[32]; + char noteName[8]; midiNoteName(note, noteName, sizeof(noteName)); + snprintf(buf, sizeof(buf), "%s | beat %d/%d", + noteName, rs.talaBeat + 1, TALA_BEATS[rs.tala]); + lv_label_set_text(rs.statusLbl, buf); + } + rs.talaBeat = (rs.talaBeat + 1) % TALA_BEATS[rs.tala]; + rs.nextNoteMs = now + rs.noteInterval; + } +} + +// Forward declarations for public encoder entry points (defined at file end) +void ragaTogglePlay (MidiController* mc); +void ragaToggleDrone(MidiController* mc); + +// --------------------------------------------------------------------------- +// Callbacks +// --------------------------------------------------------------------------- + +static void cbBack(lv_event_t* e) { + auto* mc = static_cast(lv_event_get_user_data(e)); + if (mc) mc->switchToMode(MidiMode::HOME); +} + +static void cbPlayStop(lv_event_t* e) { + auto* mc = static_cast(lv_event_get_user_data(e)); + if (mc) ragaTogglePlay(mc); +} + +static void cbDrone(lv_event_t* e) { + auto* mc = static_cast(lv_event_get_user_data(e)); + if (mc) ragaToggleDrone(mc); +} + +static void cbRagaDown(lv_event_t* e) { + auto* mc = static_cast(lv_event_get_user_data(e)); + if (!mc) return; + auto& rs = mc->raga_; + rs.raga = (rs.raga + RAGA_COUNT - 1) % RAGA_COUNT; + ragaGeneratePhrase(rs); + if (rs.ragaLbl) lv_label_set_text(rs.ragaLbl, RAGA_NAMES[rs.raga]); + if (rs.ragaBigLbl) lv_label_set_text(rs.ragaBigLbl, RAGA_NAMES[rs.raga]); +} +static void cbRagaUp(lv_event_t* e) { + auto* mc = static_cast(lv_event_get_user_data(e)); + if (!mc) return; + auto& rs = mc->raga_; + rs.raga = (rs.raga + 1) % RAGA_COUNT; + ragaGeneratePhrase(rs); + if (rs.ragaLbl) lv_label_set_text(rs.ragaLbl, RAGA_NAMES[rs.raga]); + if (rs.ragaBigLbl) lv_label_set_text(rs.ragaBigLbl, RAGA_NAMES[rs.raga]); +} + +static void cbTalaDown(lv_event_t* e) { + auto* mc = static_cast(lv_event_get_user_data(e)); + if (!mc) return; + auto& rs = mc->raga_; + rs.tala = (rs.tala + TALA_COUNT - 1) % TALA_COUNT; + rs.talaBeat = 0; + if (rs.talaLbl) lv_label_set_text(rs.talaLbl, TALA_NAMES[rs.tala]); + if (rs.talaBigLbl) lv_label_set_text(rs.talaBigLbl, TALA_NAMES[rs.tala]); +} +static void cbTalaUp(lv_event_t* e) { + auto* mc = static_cast(lv_event_get_user_data(e)); + if (!mc) return; + auto& rs = mc->raga_; + rs.tala = (rs.tala + 1) % TALA_COUNT; + rs.talaBeat = 0; + if (rs.talaLbl) lv_label_set_text(rs.talaLbl, TALA_NAMES[rs.tala]); + if (rs.talaBigLbl) lv_label_set_text(rs.talaBigLbl, TALA_NAMES[rs.tala]); +} + +static void cbRootDown(lv_event_t* e) { + auto* mc = static_cast(lv_event_get_user_data(e)); + if (!mc) return; + auto& rs = mc->raga_; + if (rs.rootNote > 24) rs.rootNote--; + if (rs.rootLbl) { + char buf[8]; midiNoteName(rs.rootNote, buf, sizeof(buf)); + lv_label_set_text(rs.rootLbl, buf); + } +} +static void cbRootUp(lv_event_t* e) { + auto* mc = static_cast(lv_event_get_user_data(e)); + if (!mc) return; + auto& rs = mc->raga_; + if (rs.rootNote < 96) rs.rootNote++; + if (rs.rootLbl) { + char buf[8]; midiNoteName(rs.rootNote, buf, sizeof(buf)); + lv_label_set_text(rs.rootLbl, buf); + } +} + +static void cbBpmDown(lv_event_t* e) { + auto* mc = static_cast(lv_event_get_user_data(e)); + if (!mc) return; + auto& rs = mc->raga_; + if (rs.bpm > 40) rs.bpm -= 5; + ragaUpdateTempo(rs); + if (rs.bpmLbl) { + char buf[8]; snprintf(buf, sizeof(buf), "%d", rs.bpm); + lv_label_set_text(rs.bpmLbl, buf); + } +} +static void cbBpmUp(lv_event_t* e) { + auto* mc = static_cast(lv_event_get_user_data(e)); + if (!mc) return; + auto& rs = mc->raga_; + if (rs.bpm < 240) rs.bpm += 5; + ragaUpdateTempo(rs); + if (rs.bpmLbl) { + char buf[8]; snprintf(buf, sizeof(buf), "%d", rs.bpm); + lv_label_set_text(rs.bpmLbl, buf); + } +} + +// --------------------------------------------------------------------------- +// Build +// --------------------------------------------------------------------------- + +void buildRagaView(MidiController* mc, lv_obj_t* parent) { + auto& rs = mc->raga_; + + const lv_font_t* fontSmall = lvgl_get_text_font(FONT_SIZE_SMALL); + const lv_font_t* fontNormal = lvgl_get_text_font(FONT_SIZE_DEFAULT); + const lv_font_t* fontLarge = lvgl_get_text_font(FONT_SIZE_LARGE); + + static constexpr int rowH = 88; + static constexpr int btnH = 64; + static constexpr int pad = 12; + + ragaUpdateTempo(rs); + + // -- Root: flex column ----------------------------------------------------- + lv_obj_t* root = mcContainer(parent); + lv_obj_set_size(root, LV_PCT(100), LV_PCT(100)); + lv_obj_set_flex_flow(root, LV_FLEX_FLOW_COLUMN); + lv_obj_set_style_bg_color(root, lv_color_hex(0x080C12), 0); + lv_obj_set_style_bg_opa(root, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(root, 0, 0); + lv_obj_set_style_pad_all(root, 0, 0); + lv_obj_set_style_pad_gap(root, 0, 0); + lv_obj_set_scrollable(root, false); + mc->currentViewRoot_ = root; + + // -- Top control row ------------------------------------------------------ + { + lv_obj_t* ctrl = mcRow(root, rowH); + + // Back + mcBackBtn(ctrl, cbBack, mc, MC_W_S); + + // Title + lv_obj_t* title = lv_label_create(ctrl); + lv_label_set_text(title, "RAGA"); + lv_obj_set_style_text_font(title, fontNormal, 0); + lv_obj_set_style_text_color(title, lv_color_hex(0xFF8844), 0); + lv_obj_set_flex_grow(title, 1); + + // Play / Stop button in the top bar - sync to current state + rs.playBtn = mcButton(ctrl); + lv_obj_set_size(rs.playBtn, 160, btnH); + lv_obj_set_style_bg_color(rs.playBtn, + rs.playing ? lv_color_hex(0xFF2020) : lv_color_hex(0x00AA44), 0); + lv_obj_add_event_cb(rs.playBtn, cbPlayStop, LV_EVENT_CLICKED, mc); + rs.playLbl = lv_label_create(rs.playBtn); + lv_label_set_text(rs.playLbl, rs.playing ? "STOP" : "PLAY"); + lv_obj_set_style_text_font(rs.playLbl, fontSmall, 0); + lv_obj_center(rs.playLbl); + + // Drone button - sync to current state + rs.droneBtn = mcButton(ctrl); + lv_obj_set_size(rs.droneBtn, 200, btnH); + lv_obj_set_style_bg_color(rs.droneBtn, + rs.droneOn ? lv_color_hex(0x00AA44) : lv_color_hex(0x1A2A40), LV_PART_MAIN); + lv_obj_add_event_cb(rs.droneBtn, cbDrone, LV_EVENT_CLICKED, mc); + rs.droneLbl = lv_label_create(rs.droneBtn); + lv_label_set_text(rs.droneLbl, rs.droneOn ? "DRONE ON" : "DRONE OFF"); + lv_obj_set_style_text_font(rs.droneLbl, fontSmall, 0); + lv_obj_center(rs.droneLbl); + + // Status + rs.statusLbl = lv_label_create(ctrl); + lv_label_set_text(rs.statusLbl, "IDLE"); + lv_obj_set_style_text_font(rs.statusLbl, fontSmall, 0); + lv_obj_set_style_text_color(rs.statusLbl, lv_color_hex(0x666688), 0); + lv_obj_set_width(rs.statusLbl, 260); + lv_obj_set_style_text_align(rs.statusLbl, LV_TEXT_ALIGN_RIGHT, 0); + } + + // -- Body: flex row - left params | right display -------------------------- + lv_obj_t* body = mcContainer(root); + lv_obj_set_width(body, LV_PCT(100)); + lv_obj_set_flex_grow(body, 1); + lv_obj_set_flex_flow(body, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(body, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START); + lv_obj_set_style_bg_opa(body, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(body, 0, 0); + lv_obj_set_style_pad_all(body, 0, 0); + lv_obj_set_style_pad_gap(body, 0, 0); + lv_obj_set_scrollable(body, false); + + // -- LEFT: parameter column (flex column, 600px) --------------------------- + { + lv_obj_t* col = mcContainer(body); + lv_obj_set_size(col, 600, LV_PCT(100)); + lv_obj_set_flex_flow(col, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(col, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START); + lv_obj_set_style_bg_opa(col, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(col, 0, 0); + lv_obj_set_style_pad_all(col, 0, 0); + lv_obj_set_style_pad_gap(col, 0, 0); + lv_obj_set_scrollable(col, false); + + // Each param gets flex_grow=1 so all 4 fill the column equally + auto makeParamRow = [&](const char* name, lv_color_t nameCol) -> lv_obj_t* { + lv_obj_t* row = mcContainer(col); + lv_obj_set_width(row, LV_PCT(100)); + lv_obj_set_flex_grow(row, 1); + lv_obj_set_flex_flow(row, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(row, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_set_style_bg_color(row, lv_color_hex(0x0A0C14), 0); + lv_obj_set_style_bg_opa(row, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(row, 0, 0); + lv_obj_set_style_border_side(row, LV_BORDER_SIDE_BOTTOM, 0); + lv_obj_set_style_border_color(row, lv_color_hex(0x1A2030), 0); + lv_obj_set_style_pad_all(row, pad, 0); + lv_obj_set_style_pad_gap(row, pad, 0); + lv_obj_set_scrollable(row, false); + lv_obj_t* hdr = lv_label_create(row); + lv_label_set_text(hdr, name); + lv_obj_set_style_text_font(hdr, fontSmall, 0); + lv_obj_set_style_text_color(hdr, nameCol, 0); + lv_obj_set_width(hdr, 120); + return row; + }; + + auto makeBtn = [&](lv_obj_t* par, const char* txt, int w, lv_event_cb_t cb) -> lv_obj_t* { + return mcSmallBtn(par, txt, w, cb, mc); + }; + + // Value label uses flex_grow=1 so it takes remaining width - prevents > button overflow + auto makeValLbl = [&](lv_obj_t* par, const char* txt, lv_color_t col) -> lv_obj_t* { + lv_obj_t* lbl = lv_label_create(par); + lv_label_set_text(lbl, txt); + lv_obj_set_style_text_font(lbl, fontNormal, 0); + lv_obj_set_style_text_color(lbl, col, 0); + lv_obj_set_flex_grow(lbl, 1); + lv_obj_set_style_text_align(lbl, LV_TEXT_ALIGN_CENTER, 0); + return lbl; + }; + + // Raga + { + lv_obj_t* row = makeParamRow("Raga", lv_color_hex(0x888888)); + makeBtn(row, "<", 88, cbRagaDown); + rs.ragaLbl = makeValLbl(row, RAGA_NAMES[rs.raga], lv_color_hex(0xFF8844)); + makeBtn(row, ">", 88, cbRagaUp); + } + // Tala + { + lv_obj_t* row = makeParamRow("Tala", lv_color_hex(0x888888)); + makeBtn(row, "<", 88, cbTalaDown); + rs.talaLbl = makeValLbl(row, TALA_NAMES[rs.tala], lv_color_hex(0xAADDFF)); + makeBtn(row, ">", 88, cbTalaUp); + } + // Root + { + lv_obj_t* row = makeParamRow("Root", lv_color_hex(0x888888)); + makeBtn(row, "<", 88, cbRootDown); + char rootBuf[8]; midiNoteName(rs.rootNote, rootBuf, sizeof(rootBuf)); + rs.rootLbl = makeValLbl(row, rootBuf, MC_VALUE()); + makeBtn(row, ">", 88, cbRootUp); + } + // BPM + { + lv_obj_t* row = makeParamRow("BPM", lv_color_hex(0x888888)); + makeBtn(row, "<", 88, cbBpmDown); + char bpmBuf[8]; snprintf(bpmBuf, sizeof(bpmBuf), "%d", rs.bpm); + rs.bpmLbl = makeValLbl(row, bpmBuf, lv_color_hex(0xFFE000)); + makeBtn(row, ">", 88, cbBpmUp); + } + } + + // -- RIGHT: status display (flex_grow) ------------------------------------- + { + lv_obj_t* display = mcContainer(body); + lv_obj_set_height(display, LV_PCT(100)); + lv_obj_set_flex_grow(display, 1); + lv_obj_set_flex_flow(display, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(display, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_set_style_bg_color(display, lv_color_hex(0x05080F), 0); + lv_obj_set_style_bg_opa(display, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(display, 0, 0); + lv_obj_set_style_pad_all(display, pad * 2, 0); + lv_obj_set_style_pad_gap(display, pad, 0); + lv_obj_set_scrollable(display, false); + + // Large raga name (updates when raga changes) + rs.ragaBigLbl = lv_label_create(display); + lv_label_set_text(rs.ragaBigLbl, RAGA_NAMES[rs.raga]); + lv_obj_set_style_text_font(rs.ragaBigLbl, fontLarge, 0); + lv_obj_set_style_text_color(rs.ragaBigLbl, lv_color_hex(0xFF8844), 0); + lv_obj_set_style_text_align(rs.ragaBigLbl, LV_TEXT_ALIGN_CENTER, 0); + lv_obj_set_width(rs.ragaBigLbl, LV_PCT(100)); + + // Tala info (updates when tala changes) + rs.talaBigLbl = lv_label_create(display); + lv_label_set_text(rs.talaBigLbl, TALA_NAMES[rs.tala]); + lv_obj_set_style_text_font(rs.talaBigLbl, fontNormal, 0); + lv_obj_set_style_text_color(rs.talaBigLbl, lv_color_hex(0xAADDFF), 0); + lv_obj_set_style_text_align(rs.talaBigLbl, LV_TEXT_ALIGN_CENTER, 0); + lv_obj_set_width(rs.talaBigLbl, LV_PCT(100)); + } + + // -- Start timer ---------------------------------------------------------- + mc->ragaTimer_ = lv_timer_create(cbRagaTimer, 20, mc); +} + +// --------------------------------------------------------------------------- +// Public encoder entry points (called from EncoderInput.cpp) +// --------------------------------------------------------------------------- + +void ragaTogglePlay(MidiController* mc) { + auto& rs = mc->raga_; + rs.playing = !rs.playing; + if (rs.playing) { + ragaUpdateTempo(rs); + ragaGeneratePhrase(rs); + rs.talaBeat = 0; + rs.nextNoteMs = lv_tick_get(); + } else { + ragaStopNote(mc); + if (rs.statusLbl) lv_label_set_text(rs.statusLbl, "IDLE"); + } + if (rs.playLbl) lv_label_set_text(rs.playLbl, rs.playing ? "STOP" : "PLAY"); + if (rs.playBtn) + lv_obj_set_style_bg_color(rs.playBtn, + rs.playing ? lv_color_hex(0xFF2020) : lv_color_hex(0x00AA44), 0); +} + +void ragaToggleDrone(MidiController* mc) { + auto& rs = mc->raga_; + rs.droneOn = !rs.droneOn; + if (rs.droneLbl) lv_label_set_text(rs.droneLbl, rs.droneOn ? "DRONE ON" : "DRONE OFF"); + if (rs.droneBtn) { + lv_obj_set_style_bg_color(rs.droneBtn, + rs.droneOn ? lv_color_hex(0x00AA44) : lv_color_hex(0x1A2A40), + LV_PART_MAIN); + } +} diff --git a/Apps/MidiController/main/Source/ViewRng.cpp b/Apps/MidiController/main/Source/ViewRng.cpp new file mode 100644 index 0000000..db972ec --- /dev/null +++ b/Apps/MidiController/main/Source/ViewRng.cpp @@ -0,0 +1,352 @@ +#include "MidiController.h" + +#include +#include +#include +#include +#include +#include + +// --------------------------------------------------------------------------- +// RNG view - Random melody generator +// +// All controls, no canvas. +// Layout (flex column): +// [header row] +// [play controls: PLAY/STOP | Key -/+/label | Scale] +// [octave range: MinOct -/label/+ | MaxOct -/label/+] +// [probability: Prob -/bar/+] +// [BPM + subdivision] +// [current note display - flex_grow] +// --------------------------------------------------------------------------- + +static const char* SUBDIV_NAMES[] = { "1/4", "1/8", "1/16" }; +static const int SUBDIV_DIVS[] = { 1, 2, 4 }; + + +static uint64_t rngNoteIntervalUs(MidiController* mc) { + auto& rn = mc->rng_; + int idx = rn.subdivision % (sizeof(SUBDIV_DIVS) / sizeof(SUBDIV_DIVS[0])); + float quarterUs = 60000000.0f / (float)rn.bpm; + return (uint64_t)(quarterUs / (float)SUBDIV_DIVS[idx]); +} + +static void cbRngTimer(lv_timer_t* t) { + auto* mc = static_cast(lv_timer_get_user_data(t)); + if (!mc || !mc->rng_.playing) return; + auto& rn = mc->rng_; + + uint64_t nowUs = (uint64_t)esp_timer_get_time(); + + // Handle note-off + if (rn.noteOffUs > 0 && nowUs >= rn.noteOffUs) { + if (rn.currentNote >= 0) { + mc->sendMidi(0x80, (uint8_t)rn.currentNote, 0); + rn.currentNote = -1; + } + rn.noteOffUs = 0; + } + + // Trigger next note + if (nowUs >= rn.nextNoteUs) { + uint64_t interval = rngNoteIntervalUs(mc); + rn.nextNoteUs = nowUs + interval; + + if ((int)(esp_random() % 100) < rn.probability) { + // Pick random degree within scale + int numDegrees = MIDI_SCALES[rn.scaleIdx % NUM_MIDI_SCALES].numNotes; + int degree = (int)(esp_random() % (uint32_t)numDegrees); + int octRange = rn.maxOctave - rn.minOctave + 1; + if (octRange < 1) octRange = 1; + int octave = rn.minOctave + (int)(esp_random() % (uint32_t)octRange); + int note = getNoteInScale(rn.scaleIdx, degree, octave, rn.rootKey); + + // Note off for previous if still playing + if (rn.currentNote >= 0) { + mc->sendMidi(0x80, (uint8_t)rn.currentNote, 0); + } + + mc->sendMidi(0x90, (uint8_t)note, 80 + (int)(esp_random() % 40)); + rn.currentNote = note; + rn.noteOffUs = nowUs + interval * 3 / 4; // note length = 75% of interval + + if (rn.noteLbl) { + char buf[16]; + char noteName[8]; + midiNoteName(note, noteName, sizeof(noteName)); + snprintf(buf, sizeof(buf), "%s", noteName); + lv_label_set_text(rn.noteLbl, buf); + } + } else { + // Rest - no note + if (rn.noteLbl) lv_label_set_text(rn.noteLbl, "( rest )"); + } + } +} + +// --------------------------------------------------------------------------- +// Callbacks +// --------------------------------------------------------------------------- + +static void cbBack(lv_event_t* e) { + auto* mc = static_cast(lv_event_get_user_data(e)); + if (mc) mc->switchToMode(MidiMode::HOME); +} + +static void cbPlayStop(lv_event_t* e) { + auto* mc = static_cast(lv_event_get_user_data(e)); + if (!mc) return; + auto& rn = mc->rng_; + rn.playing = !rn.playing; + if (rn.playing) { + rn.nextNoteUs = (uint64_t)esp_timer_get_time(); + rn.noteOffUs = 0; + rn.currentNote = -1; + if (!mc->rngTimer_) { + mc->rngTimer_ = lv_timer_create(cbRngTimer, 10, mc); + } + } else { + if (rn.currentNote >= 0) { + mc->sendMidi(0x80, (uint8_t)rn.currentNote, 0); + rn.currentNote = -1; + } + if (mc->rngTimer_) { lv_timer_delete(mc->rngTimer_); mc->rngTimer_ = nullptr; } + if (rn.noteLbl) lv_label_set_text(rn.noteLbl, ""); + } + if (rn.playLbl) lv_label_set_text(rn.playLbl, rn.playing ? "STOP" : "PLAY"); + if (rn.playBtn) { + lv_obj_set_style_bg_color(rn.playBtn, + rn.playing ? lv_color_hex(0xFF2020) : lv_color_hex(0x00AA44), 0); + } +} + +static void cbKeyDown(lv_event_t* e) { + auto* mc = static_cast(lv_event_get_user_data(e)); + if (!mc) return; + mc->rng_.rootKey = (mc->rng_.rootKey + 11) % 12; + if (mc->rng_.keyLbl) lv_label_set_text(mc->rng_.keyLbl, KEY_NAMES[mc->rng_.rootKey]); +} +static void cbKeyUp(lv_event_t* e) { + auto* mc = static_cast(lv_event_get_user_data(e)); + if (!mc) return; + mc->rng_.rootKey = (mc->rng_.rootKey + 1) % 12; + if (mc->rng_.keyLbl) lv_label_set_text(mc->rng_.keyLbl, KEY_NAMES[mc->rng_.rootKey]); +} + +static void cbScaleCycle(lv_event_t* e) { + auto* mc = static_cast(lv_event_get_user_data(e)); + if (!mc) return; + mc->rng_.scaleIdx = (mc->rng_.scaleIdx + 1) % NUM_MIDI_SCALES; + if (mc->rng_.scaleLbl) lv_label_set_text(mc->rng_.scaleLbl, MIDI_SCALES[mc->rng_.scaleIdx].name); +} + +static void cbMinOctDown(lv_event_t* e) { + auto* mc = static_cast(lv_event_get_user_data(e)); + if (!mc) return; + if (mc->rng_.minOctave > 1) mc->rng_.minOctave--; + if (mc->rng_.minOctLbl) { + char buf[4]; snprintf(buf, sizeof(buf), "%d", mc->rng_.minOctave); + lv_label_set_text(mc->rng_.minOctLbl, buf); + } +} +static void cbMinOctUp(lv_event_t* e) { + auto* mc = static_cast(lv_event_get_user_data(e)); + if (!mc) return; + if (mc->rng_.minOctave < mc->rng_.maxOctave) mc->rng_.minOctave++; + if (mc->rng_.minOctLbl) { + char buf[4]; snprintf(buf, sizeof(buf), "%d", mc->rng_.minOctave); + lv_label_set_text(mc->rng_.minOctLbl, buf); + } +} +static void cbMaxOctDown(lv_event_t* e) { + auto* mc = static_cast(lv_event_get_user_data(e)); + if (!mc) return; + if (mc->rng_.maxOctave > mc->rng_.minOctave) mc->rng_.maxOctave--; + if (mc->rng_.maxOctLbl) { + char buf[4]; snprintf(buf, sizeof(buf), "%d", mc->rng_.maxOctave); + lv_label_set_text(mc->rng_.maxOctLbl, buf); + } +} +static void cbMaxOctUp(lv_event_t* e) { + auto* mc = static_cast(lv_event_get_user_data(e)); + if (!mc) return; + if (mc->rng_.maxOctave < 8) mc->rng_.maxOctave++; + if (mc->rng_.maxOctLbl) { + char buf[4]; snprintf(buf, sizeof(buf), "%d", mc->rng_.maxOctave); + lv_label_set_text(mc->rng_.maxOctLbl, buf); + } +} + +static void cbProbDown(lv_event_t* e) { + auto* mc = static_cast(lv_event_get_user_data(e)); + if (!mc) return; + mc->rng_.probability -= 5; if (mc->rng_.probability < 0) mc->rng_.probability = 0; + if (mc->rng_.probLbl) { + char buf[8]; snprintf(buf, sizeof(buf), "%d%%", mc->rng_.probability); + lv_label_set_text(mc->rng_.probLbl, buf); + } +} +static void cbProbUp(lv_event_t* e) { + auto* mc = static_cast(lv_event_get_user_data(e)); + if (!mc) return; + mc->rng_.probability += 5; if (mc->rng_.probability > 100) mc->rng_.probability = 100; + if (mc->rng_.probLbl) { + char buf[8]; snprintf(buf, sizeof(buf), "%d%%", mc->rng_.probability); + lv_label_set_text(mc->rng_.probLbl, buf); + } +} + +static void cbBpmDown(lv_event_t* e) { + auto* mc = static_cast(lv_event_get_user_data(e)); + if (!mc) return; + mc->rng_.bpm -= 5; if (mc->rng_.bpm < 40) mc->rng_.bpm = 40; + if (mc->rng_.bpmLbl) { + char buf[8]; snprintf(buf, sizeof(buf), "%d", mc->rng_.bpm); + lv_label_set_text(mc->rng_.bpmLbl, buf); + } +} +static void cbBpmUp(lv_event_t* e) { + auto* mc = static_cast(lv_event_get_user_data(e)); + if (!mc) return; + mc->rng_.bpm += 5; if (mc->rng_.bpm > 300) mc->rng_.bpm = 300; + if (mc->rng_.bpmLbl) { + char buf[8]; snprintf(buf, sizeof(buf), "%d", mc->rng_.bpm); + lv_label_set_text(mc->rng_.bpmLbl, buf); + } +} + +static void cbSubdivDown(lv_event_t* e) { + auto* mc = static_cast(lv_event_get_user_data(e)); + if (!mc) return; + if (mc->rng_.subdivision > 0) mc->rng_.subdivision--; + if (mc->rng_.subdivLbl) lv_label_set_text(mc->rng_.subdivLbl, SUBDIV_NAMES[mc->rng_.subdivision]); +} +static void cbSubdivUp(lv_event_t* e) { + auto* mc = static_cast(lv_event_get_user_data(e)); + if (!mc) return; + if (mc->rng_.subdivision < 2) mc->rng_.subdivision++; + if (mc->rng_.subdivLbl) lv_label_set_text(mc->rng_.subdivLbl, SUBDIV_NAMES[mc->rng_.subdivision]); +} + +// --------------------------------------------------------------------------- +// Build function +// --------------------------------------------------------------------------- + +void buildRngView(MidiController* mc, lv_obj_t* parent) { + auto& rn = mc->rng_; + + const lv_font_t* fontSmall = lvgl_get_text_font(FONT_SIZE_SMALL); + const lv_font_t* fontNormal = lvgl_get_text_font(FONT_SIZE_DEFAULT); + const lv_font_t* fontLarge = lvgl_get_text_font(FONT_SIZE_LARGE); + + lv_obj_t* root = mcContainer(parent); + lv_obj_set_size(root, LV_PCT(100), LV_PCT(100)); + lv_obj_set_flex_flow(root, LV_FLEX_FLOW_COLUMN); + lv_obj_set_style_bg_color(root, lv_color_hex(0x080C12), 0); + lv_obj_set_style_bg_opa(root, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(root, 0, 0); + lv_obj_set_style_pad_all(root, 0, 0); + lv_obj_set_style_pad_gap(root, 1, 0); + lv_obj_set_scrollable(root, false); + mc->currentViewRoot_ = root; + + // Helper to make a row + auto makeRow = [&]() -> lv_obj_t* { return mcRow(root); }; + + auto makeBtn = [&](lv_obj_t* parent_, const char* txt, int w, lv_event_cb_t cb) { + mcSmallBtn(parent_, txt, w, cb, mc); + }; + + auto makeLbl = [&](lv_obj_t* parent_, const char* txt, int w, lv_color_t col) -> lv_obj_t* { + return mcValue(parent_, txt, w, col); + }; + + auto makeHdr = [&](lv_obj_t* parent_, const char* txt) { + mcHead(parent_, txt); + }; + + // -- Row 1: header + play ------------------------------------------------- + lv_obj_t* row1 = makeRow(); + mcBackBtn(row1, cbBack, mc, MC_W_M); + lv_obj_t* titleLbl = lv_label_create(row1); + lv_label_set_text(titleLbl, "RNG JAMS"); + lv_obj_set_style_text_font(titleLbl, fontNormal, 0); + lv_obj_set_style_text_color(titleLbl, lv_color_hex(0x44FF88), 0); + lv_obj_set_flex_grow(titleLbl, 1); + + rn.playBtn = mcButton(row1); + lv_obj_set_size(rn.playBtn, 224, MC_BTN_H); + lv_obj_set_style_bg_color(rn.playBtn, lv_color_hex(0x00AA44), 0); + lv_obj_add_event_cb(rn.playBtn, cbPlayStop, LV_EVENT_CLICKED, mc); + rn.playLbl = lv_label_create(rn.playBtn); + lv_label_set_text(rn.playLbl, "PLAY"); + lv_obj_set_style_text_font(rn.playLbl, fontSmall, 0); + lv_obj_center(rn.playLbl); + + // -- Row 2: Key + Scale --------------------------------------------------- + lv_obj_t* row2 = makeRow(); + makeHdr(row2, "Key:"); + makeBtn(row2, "-", 96, cbKeyDown); + rn.keyLbl = makeLbl(row2, KEY_NAMES[rn.rootKey], 112, MC_VALUE()); + makeBtn(row2, "+", 96, cbKeyUp); + + makeHdr(row2, " Scale:"); + lv_obj_t* scaleBtn = mcButton(row2); + lv_obj_set_size(scaleBtn, 240, MC_BTN_H); + lv_obj_add_event_cb(scaleBtn, cbScaleCycle, LV_EVENT_CLICKED, mc); + rn.scaleLbl = lv_label_create(scaleBtn); + lv_label_set_text(rn.scaleLbl, MIDI_SCALES[rn.scaleIdx % NUM_MIDI_SCALES].name); + lv_obj_set_style_text_font(rn.scaleLbl, fontSmall, 0); + lv_obj_center(rn.scaleLbl); + + // -- Row 3: Oct range ----------------------------------------------------- + lv_obj_t* row3 = makeRow(); + makeHdr(row3, "Min:"); + makeBtn(row3, "-", 96, cbMinOctDown); + char mob[4]; snprintf(mob, sizeof(mob), "%d", rn.minOctave); + rn.minOctLbl = makeLbl(row3, mob, 96, lv_color_hex(0xFFFFFF)); + makeBtn(row3, "+", 96, cbMinOctUp); + + makeHdr(row3, " Max:"); + makeBtn(row3, "-", 96, cbMaxOctDown); + char maxb[4]; snprintf(maxb, sizeof(maxb), "%d", rn.maxOctave); + rn.maxOctLbl = makeLbl(row3, maxb, 96, lv_color_hex(0xFFFFFF)); + makeBtn(row3, "+", 96, cbMaxOctUp); + + // -- Row 4: Probability --------------------------------------------------- + lv_obj_t* row4 = makeRow(); + makeHdr(row4, "Prob:"); + makeBtn(row4, "-", 96, cbProbDown); + char pb[8]; snprintf(pb, sizeof(pb), "%d%%", rn.probability); + rn.probLbl = makeLbl(row4, pb, 176, lv_color_hex(0xFFE000)); + makeBtn(row4, "+", 96, cbProbUp); + + // -- Row 5: BPM + Subdivision --------------------------------------------- + lv_obj_t* row5 = makeRow(); + makeHdr(row5, "BPM:"); + makeBtn(row5, "-", 96, cbBpmDown); + char bpmb[8]; snprintf(bpmb, sizeof(bpmb), "%d", rn.bpm); + rn.bpmLbl = makeLbl(row5, bpmb, 176, lv_color_hex(0x00DFFF)); + makeBtn(row5, "+", 96, cbBpmUp); + + makeHdr(row5, " Beat:"); + makeBtn(row5, "<", 96, cbSubdivDown); + rn.subdivLbl = makeLbl(row5, SUBDIV_NAMES[rn.subdivision], 144, lv_color_hex(0x00DFFF)); + makeBtn(row5, ">", 96, cbSubdivUp); + + // -- Note display (fills remaining space) -------------------------------- + lv_obj_t* noteArea = mcContainer(root); + lv_obj_set_width(noteArea, LV_PCT(100)); + lv_obj_set_flex_grow(noteArea, 1); + lv_obj_set_style_bg_color(noteArea, lv_color_hex(0x050508), 0); + lv_obj_set_style_bg_opa(noteArea, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(noteArea, 0, 0); + lv_obj_set_style_pad_all(noteArea, 0, 0); + lv_obj_set_scrollable(noteArea, false); + + rn.noteLbl = lv_label_create(noteArea); + lv_label_set_text(rn.noteLbl, ""); + lv_obj_set_style_text_font(rn.noteLbl, fontLarge, 0); + lv_obj_set_style_text_color(rn.noteLbl, lv_color_hex(0x44FF88), 0); + lv_obj_center(rn.noteLbl); +} diff --git a/Apps/MidiController/main/Source/ViewTb3po.cpp b/Apps/MidiController/main/Source/ViewTb3po.cpp new file mode 100644 index 0000000..882f32d --- /dev/null +++ b/Apps/MidiController/main/Source/ViewTb3po.cpp @@ -0,0 +1,606 @@ +#include "MidiController.h" + +#include +#include +#include +#include +#include +#include +#include + +// --------------------------------------------------------------------------- +// TB3PO view - 303-style acid bassline generator +// +// Layout (flex column): +// [ctrl row: < | PLAY | BPM- | N | BPM+ DENS- | D:N | DENS+ REGEN] +// [canvas flex_grow: 4-row step visualization] +// [status row: KEY- | key | KEY+ SCALE- | scale | SCALE+ Seed: XXXXXXXX] +// --------------------------------------------------------------------------- + + +// --------------------------------------------------------------------------- +// Scale data +// --------------------------------------------------------------------------- + +static constexpr int TB3_NUM_SCALES = 6; +static const char* TB3_SCALE_NAMES[TB3_NUM_SCALES] = { + "Major", "Minor", "Dorian", "Penta", "Blues", "Chrome" +}; +static constexpr int TB3_MAX_SCALE_LEN = 12; +static const int TB3_SCALE_INTERVALS[TB3_NUM_SCALES][TB3_MAX_SCALE_LEN] = { + {0, 2, 4, 5, 7, 9, 11}, // Major (7) + {0, 2, 3, 5, 7, 8, 10}, // Minor (7) + {0, 2, 3, 5, 7, 9, 10}, // Dorian (7) + {0, 2, 4, 7, 9}, // Penta (5) + {0, 3, 5, 6, 7, 10}, // Blues (6) + {0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11}, // Chrome (12) +}; +static const int TB3_SCALE_LENS[TB3_NUM_SCALES] = {7, 7, 7, 5, 6, 12}; + +// KEY_NAMES already defined in MidiScales.h - use it directly + +// Get MIDI note for scale degree + octave (root at MIDI 48 = C3) +static int tb3NoteInScale(int scaleIdx, int degree, int octave, int key) { + if (scaleIdx < 0 || scaleIdx >= TB3_NUM_SCALES) scaleIdx = 0; + int len = TB3_SCALE_LENS[scaleIdx]; + // Proper modular arithmetic for negative degrees + while (degree < 0) degree += len; + int deg = degree % len; + int octShift = degree / len; + int rootMidi = 48 + key; // C3 + key offset + int note = rootMidi + TB3_SCALE_INTERVALS[scaleIdx][deg] + (octave - 3 + octShift) * 12; + if (note < 0) note = 0; + if (note > 127) note = 127; + return note; +} + +// --------------------------------------------------------------------------- +// Pattern generation +// --------------------------------------------------------------------------- + +static void tb3Regenerate(MidiController::Tb3poState& ts) { + // Use seed-based PRNG + uint32_t rng = ts.seed; + auto rnd = [&](int lo, int hi) -> int { + rng = rng * 1664525u + 1013904223u; + if (hi <= lo) return lo; + return lo + (int)((rng >> 16) % (uint32_t)(hi - lo)); + }; + auto rndBit = [&](int prob) -> bool { + return rnd(0, 100) < prob; + }; + + int scaleIdx = ts.scale; + if (scaleIdx < 0 || scaleIdx >= TB3_NUM_SCALES) scaleIdx = 0; + int scaleLen = TB3_SCALE_LENS[scaleIdx]; + int pitchDens = std::min(ts.density, 8); + int available; + if (pitchDens > 7) available = scaleLen - 1; + else if (pitchDens < 2) available = pitchDens; + else { + int fromScale = std::max(4, scaleLen - 3); + available = 3 + (pitchDens - 3) * fromScale / 4; + available = std::max(1, std::min(scaleLen - 1, available)); + } + + // Generate pitches, oct flags - use direct bit position (1u << i) to avoid inversion + uint32_t oct_ups = 0, oct_downs = 0; + int noteDegs[32] = {}; + for (int i = 0; i < 32; i++) { + int forceRepeatProb = 50 - (pitchDens * 6); + if (i > 0 && rndBit(forceRepeatProb)) { + noteDegs[i] = noteDegs[i-1]; + } else { + noteDegs[i] = rnd(0, available + 1); + if (rndBit(40)) { + if (rndBit(50)) oct_ups |= (1u << i); + else oct_downs |= (1u << i); + } + } + } + + // Generate gates, slides, accents - direct bit position, no shift accumulation + rng = ts.seed + 1000u; + // Gate probability rises monotonically with density (10% at 0, 90% at 16). + int densProb = 10 + ts.density * 5; + uint32_t gates = 0, slides = 0, accents = 0; + int latestSlide = 0, latestAccent = 0; + for (int i = 0; i < 32; i++) { + if (rndBit(densProb)) gates |= (1u << i); + latestSlide = rndBit(latestSlide ? 10 : 18); + if (latestSlide) slides |= (1u << i); + latestAccent = rndBit(latestAccent ? 7 : 16); + if (latestAccent) accents |= (1u << i); + } + + // Fill steps array + for (int i = 0; i < 32; i++) { + auto& s = ts.steps[i]; + int scaleDeg = noteDegs[i]; + bool octUp = (oct_ups & (1u << i)) != 0; + bool octDown = (oct_downs & (1u << i)) != 0; + s.note = scaleDeg; // scale degree (resolved to MIDI in timer) + s.oct_up = octUp; + s.oct_down = octDown; + s.accent = (accents & (1u << i)) != 0; + s.slide = (slides & (1u << i)) != 0; + s.gate = (gates & (1u << i)) != 0; + } +} + +static int tb3StepToMidi(const MidiController::Tb3poState& ts, int stepIdx) { + const auto& s = ts.steps[stepIdx]; + int octOff = ts.octave + (s.oct_up ? 1 : (s.oct_down ? -1 : 0)); + return tb3NoteInScale(ts.scale, s.note, octOff, ts.key); +} + +// --------------------------------------------------------------------------- +// Canvas draw (called from timer + after regen) +// --------------------------------------------------------------------------- + +static void tb3DrawCanvas(MidiController::Tb3poState& ts) { + if (!ts.canvas) return; + lv_obj_clean(ts.canvas); + + int W = ts.canvasW; + int H = ts.canvasH; + if (W <= 0 || H <= 0) return; + + int stepW = W / 32; + int rowH = H / 4; + + for (int i = 0; i < 32; i++) { + const auto& s = ts.steps[i]; + int x = i * stepW; + bool isCurrent = (ts.playing && i == ts.currentStep); + + // Row 0: Note name + { + lv_obj_t* cell = mcContainer(ts.canvas); + lv_obj_set_pos(cell, x, 0); + lv_obj_set_size(cell, stepW - 1, rowH - 1); + lv_obj_set_style_border_width(cell, 0, 0); + lv_obj_set_style_pad_all(cell, 0, 0); + lv_obj_set_scrollable(cell, false); + uint32_t bg = isCurrent ? 0x44BBFF : 0x1A2030; + lv_obj_set_style_bg_color(cell, lv_color_hex(bg), 0); + lv_obj_set_style_bg_opa(cell, LV_OPA_COVER, 0); + + lv_obj_t* lbl = lv_label_create(cell); + int midi = tb3StepToMidi(ts, i); + static const char* NOTES[12] = {"C","C#","D","D#","E","F","F#","G","G#","A","A#","B"}; + lv_label_set_text(lbl, NOTES[midi % 12]); + lv_obj_set_style_text_color(lbl, lv_color_hex(isCurrent ? 0x000000 : 0xDDDDDD), 0); + lv_obj_center(lbl); + } + // Row 1: Oct up/down + { + lv_obj_t* cell = mcContainer(ts.canvas); + lv_obj_set_pos(cell, x, rowH); + lv_obj_set_size(cell, stepW - 1, rowH - 1); + lv_obj_set_style_border_width(cell, 0, 0); + lv_obj_set_style_pad_all(cell, 0, 0); + lv_obj_set_scrollable(cell, false); + uint32_t bg = s.oct_up ? 0x006622 : (s.oct_down ? 0x662200 : 0x111820); + lv_obj_set_style_bg_color(cell, lv_color_hex(bg), 0); + lv_obj_set_style_bg_opa(cell, LV_OPA_COVER, 0); + if (s.oct_up || s.oct_down) { + lv_obj_t* lbl = lv_label_create(cell); + lv_label_set_text(lbl, s.oct_up ? "+" : "-"); + lv_obj_set_style_text_color(lbl, lv_color_hex(0xFFFFFF), 0); + lv_obj_center(lbl); + } + } + // Row 2: Accent / Slide + { + lv_obj_t* cell = mcContainer(ts.canvas); + lv_obj_set_pos(cell, x, rowH * 2); + lv_obj_set_size(cell, stepW - 1, rowH - 1); + lv_obj_set_style_border_width(cell, 0, 0); + lv_obj_set_style_pad_all(cell, 0, 0); + lv_obj_set_scrollable(cell, false); + uint32_t bg = s.accent ? 0x886600 : (s.slide ? 0x004488 : 0x111820); + lv_obj_set_style_bg_color(cell, lv_color_hex(bg), 0); + lv_obj_set_style_bg_opa(cell, LV_OPA_COVER, 0); + if (s.accent || s.slide) { + lv_obj_t* lbl = lv_label_create(cell); + lv_label_set_text(lbl, (s.accent && s.slide) ? "AS" : (s.accent ? "A" : "S")); + lv_obj_set_style_text_color(lbl, lv_color_hex(0xFFFFFF), 0); + lv_obj_center(lbl); + } + } + // Row 3: Gate dot + { + lv_obj_t* cell = mcContainer(ts.canvas); + lv_obj_set_pos(cell, x, rowH * 3); + lv_obj_set_size(cell, stepW - 1, rowH - 1); + lv_obj_set_style_border_width(cell, 0, 0); + lv_obj_set_style_pad_all(cell, 0, 0); + lv_obj_set_scrollable(cell, false); + lv_obj_set_style_bg_color(cell, lv_color_hex(0x111820), 0); + lv_obj_set_style_bg_opa(cell, LV_OPA_COVER, 0); + if (s.gate) { + lv_obj_t* dot = mcContainer(cell); + int dotSz = std::min(stepW - 4, rowH - 4); + lv_obj_set_size(dot, dotSz, dotSz); + lv_obj_set_style_radius(dot, LV_RADIUS_CIRCLE, 0); + lv_obj_set_style_bg_color(dot, lv_color_hex(0x0088FF), 0); + lv_obj_set_style_bg_opa(dot, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(dot, 0, 0); + lv_obj_set_scrollable(dot, false); + lv_obj_center(dot); + } + } + } +} + +// --------------------------------------------------------------------------- +// Timer callback +// --------------------------------------------------------------------------- + +static void cbTb3poTimer(lv_timer_t* t) { + auto* mc = static_cast(lv_timer_get_user_data(t)); + if (!mc) return; + auto& ts = mc->tb3po_; + if (!ts.playing) return; + + uint32_t now = lv_tick_get(); + + // Note-off. A slide flag on the step that owns lastNote holds it into the + // next step, so suppress the normal 90%-gate Note Off until that slide ends. + if (ts.lastNote >= 0 && now >= ts.noteOffMs) { + bool heldBySlide = ts.lastNoteStep >= 0 && ts.steps[ts.lastNoteStep].slide; + if (!heldBySlide) { + mc->sendMidi(0x80, (uint8_t)ts.lastNote, 0); + ts.lastNote = -1; + ts.lastNoteStep = -1; + } + } + + if (now < ts.nextStepMs) return; + + // Advance step + ts.currentStep = (ts.currentStep + 1) % 32; + const auto& s = ts.steps[ts.currentStep]; + + // BPM → 16th note duration + uint32_t stepMs = 60000u / (uint32_t)ts.bpm / 4u; + if (stepMs < 20) stepMs = 20; + ts.nextStepMs = now + stepMs; + + if (s.gate) { + int note = tb3StepToMidi(ts, ts.currentStep); + int prevStep = (ts.currentStep + 31) % 32; + bool incomingSlide = ts.steps[prevStep].slide; + int oldNote = ts.lastNote; + int vel = s.accent ? 127 : 100; + + if (oldNote < 0) { + mc->sendMidi(0x90, (uint8_t)note, (uint8_t)vel); + } else if (incomingSlide && oldNote != note) { + // Start the new pitch while the old one is still on so a mono synth + // hears a legato transition, then release the old pitch. This also + // prevents a poly synth from leaving the old note hanging. + mc->sendMidi(0x90, (uint8_t)note, (uint8_t)vel); + mc->sendMidi(0x80, (uint8_t)oldNote, 0); + } else if (!incomingSlide) { + mc->sendMidi(0x80, (uint8_t)oldNote, 0); + mc->sendMidi(0x90, (uint8_t)note, (uint8_t)vel); + } else { + // Same pitch: retrigger without an intervening Note Off. + mc->sendMidi(0x90, (uint8_t)note, (uint8_t)vel); + } + + ts.lastNote = note; + ts.lastNoteStep = ts.currentStep; + ts.noteOffMs = now + stepMs * 9 / 10; // 90% gate + } else { + if (ts.lastNote >= 0) { + mc->sendMidi(0x80, (uint8_t)ts.lastNote, 0); + ts.lastNote = -1; + ts.lastNoteStep = -1; + } + } + + tb3DrawCanvas(ts); +} + +// --------------------------------------------------------------------------- +// Callbacks +// --------------------------------------------------------------------------- + +static void cbTb3Back(lv_event_t* e) { + auto* mc = static_cast(lv_event_get_user_data(e)); + if (mc) mc->switchToMode(MidiMode::HOME); +} + +static void cbTb3Play(lv_event_t* e) { + auto* mc = static_cast(lv_event_get_user_data(e)); + if (!mc) return; + auto& ts = mc->tb3po_; + ts.playing = !ts.playing; + if (!ts.playing && ts.lastNote >= 0) { + mc->sendMidi(0x80, (uint8_t)ts.lastNote, 0); + ts.lastNote = -1; + ts.lastNoteStep = -1; + } + if (ts.playing) { + ts.currentStep = 31; // will advance to 0 on first tick + ts.nextStepMs = lv_tick_get(); + } + if (ts.playLbl) lv_label_set_text(ts.playLbl, ts.playing ? "STOP" : "PLAY"); +} + +static void cbTb3BpmDown(lv_event_t* e) { + auto* mc = static_cast(lv_event_get_user_data(e)); + if (!mc) return; + auto& ts = mc->tb3po_; + if (ts.bpm > 40) ts.bpm -= 5; + if (ts.bpmLbl) { + char buf[8]; snprintf(buf, sizeof(buf), "%d", ts.bpm); + lv_label_set_text(ts.bpmLbl, buf); + } +} +static void cbTb3BpmUp(lv_event_t* e) { + auto* mc = static_cast(lv_event_get_user_data(e)); + if (!mc) return; + auto& ts = mc->tb3po_; + if (ts.bpm < 280) ts.bpm += 5; + if (ts.bpmLbl) { + char buf[8]; snprintf(buf, sizeof(buf), "%d", ts.bpm); + lv_label_set_text(ts.bpmLbl, buf); + } +} + +static void cbTb3DensDown(lv_event_t* e) { + auto* mc = static_cast(lv_event_get_user_data(e)); + if (!mc) return; + auto& ts = mc->tb3po_; + if (ts.density > 0) ts.density--; + ts.seed = (uint32_t)esp_random(); + tb3Regenerate(ts); + if (ts.densityLbl) { + char buf[6]; snprintf(buf, sizeof(buf), "D:%d", ts.density); + lv_label_set_text(ts.densityLbl, buf); + } + if (ts.seedLbl) { + char buf[16]; snprintf(buf, sizeof(buf), "%08lX", (unsigned long)ts.seed); + lv_label_set_text(ts.seedLbl, buf); + } + tb3DrawCanvas(ts); +} +static void cbTb3DensUp(lv_event_t* e) { + auto* mc = static_cast(lv_event_get_user_data(e)); + if (!mc) return; + auto& ts = mc->tb3po_; + if (ts.density < 16) ts.density++; + ts.seed = (uint32_t)esp_random(); + tb3Regenerate(ts); + if (ts.densityLbl) { + char buf[6]; snprintf(buf, sizeof(buf), "D:%d", ts.density); + lv_label_set_text(ts.densityLbl, buf); + } + if (ts.seedLbl) { + char buf[16]; snprintf(buf, sizeof(buf), "%08lX", (unsigned long)ts.seed); + lv_label_set_text(ts.seedLbl, buf); + } + tb3DrawCanvas(ts); +} + +static void cbTb3Regen(lv_event_t* e) { + auto* mc = static_cast(lv_event_get_user_data(e)); + if (!mc) return; + auto& ts = mc->tb3po_; + ts.seed = (uint32_t)esp_random(); + tb3Regenerate(ts); + if (ts.seedLbl) { + char buf[16]; snprintf(buf, sizeof(buf), "%08lX", (unsigned long)ts.seed); + lv_label_set_text(ts.seedLbl, buf); + } + tb3DrawCanvas(ts); +} + +static void cbTb3KeyDown(lv_event_t* e) { + auto* mc = static_cast(lv_event_get_user_data(e)); + if (!mc) return; + auto& ts = mc->tb3po_; + ts.key = (ts.key + 11) % 12; + tb3Regenerate(ts); + if (ts.keyLbl) lv_label_set_text(ts.keyLbl, KEY_NAMES[ts.key]); + tb3DrawCanvas(ts); +} +static void cbTb3KeyUp(lv_event_t* e) { + auto* mc = static_cast(lv_event_get_user_data(e)); + if (!mc) return; + auto& ts = mc->tb3po_; + ts.key = (ts.key + 1) % 12; + tb3Regenerate(ts); + if (ts.keyLbl) lv_label_set_text(ts.keyLbl, KEY_NAMES[ts.key]); + tb3DrawCanvas(ts); +} + +static void cbTb3ScaleDown(lv_event_t* e) { + auto* mc = static_cast(lv_event_get_user_data(e)); + if (!mc) return; + auto& ts = mc->tb3po_; + ts.scale = (ts.scale + TB3_NUM_SCALES - 1) % TB3_NUM_SCALES; + tb3Regenerate(ts); + if (ts.scaleLbl) lv_label_set_text(ts.scaleLbl, TB3_SCALE_NAMES[ts.scale]); + tb3DrawCanvas(ts); +} +static void cbTb3ScaleUp(lv_event_t* e) { + auto* mc = static_cast(lv_event_get_user_data(e)); + if (!mc) return; + auto& ts = mc->tb3po_; + ts.scale = (ts.scale + 1) % TB3_NUM_SCALES; + tb3Regenerate(ts); + if (ts.scaleLbl) lv_label_set_text(ts.scaleLbl, TB3_SCALE_NAMES[ts.scale]); + tb3DrawCanvas(ts); +} + +// --------------------------------------------------------------------------- +// Build +// --------------------------------------------------------------------------- + +void buildTb3poView(MidiController* mc, lv_obj_t* parent) { + auto& ts = mc->tb3po_; + + const lv_font_t* fontSmall = lvgl_get_text_font(FONT_SIZE_SMALL); + + int rowH = 88; + int sBtnW = 88; + + lv_obj_t* root = mcContainer(parent); + lv_obj_set_size(root, LV_PCT(100), LV_PCT(100)); + lv_obj_set_flex_flow(root, LV_FLEX_FLOW_COLUMN); + lv_obj_set_style_bg_color(root, lv_color_hex(0x060A0F), 0); + lv_obj_set_style_bg_opa(root, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(root, 0, 0); + lv_obj_set_style_pad_all(root, 0, 0); + lv_obj_set_style_pad_gap(root, 8, 0); + lv_obj_set_scrollable(root, false); + mc->currentViewRoot_ = root; + + auto makeRow = [&]() -> lv_obj_t* { return mcRow(root); }; + + auto makeBtn = [&](lv_obj_t* par, const char* txt, int w, lv_event_cb_t cb, void* ud) -> lv_obj_t* { + return mcSmallBtn(par, txt, w, cb, ud); + }; + + auto makeLbl = [&](lv_obj_t* par, const char* txt, int w, lv_color_t col) -> lv_obj_t* { + return mcValue(par, txt, w, col); + }; + + auto makeSep = [&](lv_obj_t* par) { + lv_obj_t* sep = lv_label_create(par); + lv_label_set_text(sep, "|"); + lv_obj_set_style_text_font(sep, fontSmall, 0); + lv_obj_set_style_text_color(sep, lv_color_hex(0x334455), 0); + }; + + // -- Control row ----------------------------------------------------------- + { + lv_obj_t* row = makeRow(); + mcBackBtn(row, cbTb3Back, mc, MC_W_S); + lv_obj_t* playBtn = makeBtn(row, ts.playing ? "STOP" : "PLAY", 176, cbTb3Play, mc); + ts.playLbl = lv_obj_get_child(playBtn, 0); + makeSep(row); + makeBtn(row, "<", sBtnW, cbTb3BpmDown, mc); + char bpmBuf[8]; snprintf(bpmBuf, sizeof(bpmBuf), "%d", ts.bpm); + ts.bpmLbl = makeLbl(row, bpmBuf, 136, lv_color_hex(0xFFE000)); + makeBtn(row, ">", sBtnW, cbTb3BpmUp, mc); + makeSep(row); + makeBtn(row, "<", sBtnW, cbTb3DensDown, mc); + char densBuf[6]; snprintf(densBuf, sizeof(densBuf), "D:%d", ts.density); + ts.densityLbl = makeLbl(row, densBuf, 112, lv_color_hex(0xFF8844)); + makeBtn(row, ">", sBtnW, cbTb3DensUp, mc); + makeSep(row); + makeBtn(row, "REGEN", 200, cbTb3Regen, mc); + } + + // -- Canvas (flex_grow) ---------------------------------------------------- + { + lv_obj_t* canvasArea = mcContainer(root); + lv_obj_set_width(canvasArea, LV_PCT(100)); + lv_obj_set_flex_grow(canvasArea, 1); + lv_obj_set_style_bg_color(canvasArea, lv_color_hex(0x050810), 0); + lv_obj_set_style_bg_opa(canvasArea, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(canvasArea, 0, 0); + lv_obj_set_style_pad_all(canvasArea, 0, 0); + lv_obj_set_scrollable(canvasArea, false); + ts.canvas = canvasArea; + + // Estimates for the first draw, before LVGL has resolved flex sizes. + // LV_EVENT_SIZE_CHANGED replaces these with the real canvas dimensions. + ts.canvasW = lv_display_get_horizontal_resolution(nullptr); + ts.canvasH = lv_display_get_vertical_resolution(nullptr) - rowH * 2 - 32; + if (ts.canvasH < 240) ts.canvasH = 240; + } + + // -- Status row: KEY SCALE Seed ----------------------------------------- + { + lv_obj_t* row = makeRow(); + makeBtn(row, "<", sBtnW, cbTb3KeyDown, mc); + ts.keyLbl = makeLbl(row, KEY_NAMES[ts.key], 88, lv_color_hex(0x88DDFF)); + makeBtn(row, ">", sBtnW, cbTb3KeyUp, mc); + makeSep(row); + makeBtn(row, "<", sBtnW, cbTb3ScaleDown, mc); + ts.scaleLbl = makeLbl(row, TB3_SCALE_NAMES[ts.scale], 176, lv_color_hex(0xAAFF88)); + makeBtn(row, ">", sBtnW, cbTb3ScaleUp, mc); + makeSep(row); + lv_obj_t* seedHdr = lv_label_create(row); + lv_label_set_text(seedHdr, "Seed:"); + lv_obj_set_style_text_font(seedHdr, fontSmall, 0); + lv_obj_set_style_text_color(seedHdr, lv_color_hex(0x666688), 0); + char seedBuf[16]; snprintf(seedBuf, sizeof(seedBuf), "%08lX", (unsigned long)ts.seed); + ts.seedLbl = makeLbl(row, seedBuf, 256, lv_color_hex(0x8888AA)); + } + + // -- Initial pattern + draw ------------------------------------------------- + if (ts.seed == 0) ts.seed = (uint32_t)esp_random(); + tb3Regenerate(ts); + + // Start timer + mc->tb3poTimer_ = lv_timer_create(cbTb3poTimer, 10, mc); + ts.nextStepMs = lv_tick_get(); + + // Draw immediately with the estimated size, then redraw once the layout pass + // has given the canvas its real dimensions. Without this the four cell rows + // can extend past the bottom of the content area and get clipped. + tb3DrawCanvas(ts); + lv_obj_add_event_cb(ts.canvas, [](lv_event_t* ev) { + auto* mc2 = static_cast(lv_event_get_user_data(ev)); + if (!mc2) return; + auto& ts2 = mc2->tb3po_; + int w = lv_obj_get_width(ts2.canvas); + int h = lv_obj_get_height(ts2.canvas); + if (w > 32 && h > 32) { + ts2.canvasW = w; + ts2.canvasH = h; + tb3DrawCanvas(ts2); + } + }, LV_EVENT_SIZE_CHANGED, mc); +} + +// --------------------------------------------------------------------------- +// Public encoder entry points (called from EncoderInput.cpp) +// --------------------------------------------------------------------------- + +void tb3TogglePlay(MidiController* mc) { + auto& ts = mc->tb3po_; + ts.playing = !ts.playing; + if (!ts.playing && ts.lastNote >= 0) { + mc->sendMidi(0x80, (uint8_t)ts.lastNote, 0); + ts.lastNote = -1; + ts.lastNoteStep = -1; + } + if (ts.playing) { + ts.currentStep = 31; + ts.nextStepMs = lv_tick_get(); + } + if (ts.playLbl) lv_label_set_text(ts.playLbl, ts.playing ? "STOP" : "PLAY"); +} + +void tb3ChangeScale(MidiController* mc, int delta) { + auto& ts = mc->tb3po_; + static constexpr int NUM_SCALES = 6; + ts.scale = ((ts.scale + delta) % NUM_SCALES + NUM_SCALES) % NUM_SCALES; + tb3Regenerate(ts); + if (ts.scaleLbl) lv_label_set_text(ts.scaleLbl, TB3_SCALE_NAMES[ts.scale]); + tb3DrawCanvas(ts); +} + +void tb3Regen(MidiController* mc) { + auto& ts = mc->tb3po_; + ts.seed = (uint32_t)esp_random(); + tb3Regenerate(ts); + if (ts.seedLbl) { + char buf[16]; snprintf(buf, sizeof(buf), "%08lX", (unsigned long)ts.seed); + lv_label_set_text(ts.seedLbl, buf); + } + tb3DrawCanvas(ts); +} + +void tb3Redraw(MidiController* mc) { + if (mc) tb3DrawCanvas(mc->tb3po_); +} diff --git a/Apps/MidiController/main/Source/ViewXYPad.cpp b/Apps/MidiController/main/Source/ViewXYPad.cpp new file mode 100644 index 0000000..e1e07a2 --- /dev/null +++ b/Apps/MidiController/main/Source/ViewXYPad.cpp @@ -0,0 +1,310 @@ +#include "MidiController.h" + +#include +#include +#include + +// --------------------------------------------------------------------------- +// XY PAD view - 2D touch pad for MIDI CC modulation +// +// Layout (flex column): +// [header: < | XY PAD | X CC -/val/+ | | Y CC -/val/+] +// [touch area - flex_grow, LV_EVENT_DRAW_MAIN] +// [value readout footer] +// --------------------------------------------------------------------------- + + +static constexpr int XY_IND_RADIUS = 32; // indicator circle radius (pixels) + +// --------------------------------------------------------------------------- +// Draw event - renders BG, crosshair, indicator circle +// --------------------------------------------------------------------------- + +static void cbXYDraw(lv_event_t* e) { + auto* mc = static_cast(lv_event_get_user_data(e)); + if (!mc) return; + + lv_obj_t* obj = lv_event_get_target_obj(e); + lv_layer_t* layer = lv_event_get_layer(e); + + lv_area_t area; + lv_obj_get_coords(obj, &area); + int32_t ox = area.x1, oy = area.y1; + int32_t cW = lv_area_get_width(&area); + int32_t cH = lv_area_get_height(&area); + + // Background + lv_draw_fill_dsc_t bg; + lv_draw_fill_dsc_init(&bg); + bg.color = lv_color_hex(0x080C12); + bg.opa = LV_OPA_COVER; + lv_draw_fill(layer, &bg, &area); + + // Crosshair lines + lv_draw_line_dsc_t line; + lv_draw_line_dsc_init(&line); + line.color = lv_color_hex(0x1A3040); + line.width = 1; + line.opa = LV_OPA_COVER; + + // Horizontal + line.p1.x = (lv_value_precise_t)ox; + line.p1.y = (lv_value_precise_t)(oy + cH / 2); + line.p2.x = (lv_value_precise_t)(ox + cW - 1); + line.p2.y = (lv_value_precise_t)(oy + cH / 2); + lv_draw_line(layer, &line); + + // Vertical + line.p1.x = (lv_value_precise_t)(ox + cW / 2); + line.p1.y = (lv_value_precise_t)oy; + line.p2.x = (lv_value_precise_t)(ox + cW / 2); + line.p2.y = (lv_value_precise_t)(oy + cH - 1); + lv_draw_line(layer, &line); + +} + +static void updateValLabel(MidiController* mc) { + if (!mc->xy_.valLbl) return; + char buf[24]; + snprintf(buf, sizeof(buf), "X:%3d Y:%3d", mc->xy_.xVal, mc->xy_.yVal); + lv_label_set_text(mc->xy_.valLbl, buf); +} + +// --------------------------------------------------------------------------- +// Touch input on canvas +// --------------------------------------------------------------------------- + +static void cbCanvasTouch(lv_event_t* e) { + auto* mc = static_cast(lv_event_get_user_data(e)); + if (!mc) return; + auto& xy = mc->xy_; + if (xy.canvasW <= 1 || xy.canvasH <= 1) return; + + lv_indev_t* indev = lv_event_get_indev(e); + if (!indev) return; + lv_point_t pt; + lv_indev_get_point(indev, &pt); + + lv_area_t area; + lv_obj_get_coords(xy.canvas, &area); + int lx = (int)pt.x - (int)area.x1; + int ly = (int)pt.y - (int)area.y1; + lx = lx < 0 ? 0 : (lx >= xy.canvasW ? xy.canvasW - 1 : lx); + ly = ly < 0 ? 0 : (ly >= xy.canvasH ? xy.canvasH - 1 : ly); + + int newX = (lx * 127) / (xy.canvasW - 1); + int newY = 127 - (ly * 127) / (xy.canvasH - 1); + + bool changed = (newX != xy.xVal) || (newY != xy.yVal); + if (!changed) return; + + xy.xVal = newX; + xy.yVal = newY; + + mc->sendMidi(0xB0, (uint8_t)xy.xCC, (uint8_t)xy.xVal); + mc->sendMidi(0xB0, (uint8_t)xy.yCC, (uint8_t)xy.yVal); + + // Move indicator widget immediately + if (xy.indicator && xy.canvasW > 0 && xy.canvasH > 0) { + int indR = XY_IND_RADIUS; + int px = (xy.xVal * (xy.canvasW - 1)) / 127; + int py = ((127 - xy.yVal) * (xy.canvasH - 1)) / 127; + lv_obj_set_pos(xy.indicator, (lv_coord_t)(px - indR), (lv_coord_t)(py - indR)); + } + + lv_obj_invalidate(xy.canvas); + updateValLabel(mc); +} + +static void cbCanvasSize(lv_event_t* e) { + auto* mc = static_cast(lv_event_get_user_data(e)); + if (!mc) return; + auto& xy = mc->xy_; + lv_obj_t* obj = lv_event_get_target_obj(e); + xy.canvasW = (int)lv_obj_get_content_width(obj); + xy.canvasH = (int)lv_obj_get_content_height(obj); + // Place indicator at current value position + if (xy.indicator && xy.canvasW > 0 && xy.canvasH > 0) { + int indR = XY_IND_RADIUS; + int px = (xy.xVal * (xy.canvasW - 1)) / 127; + int py = ((127 - xy.yVal) * (xy.canvasH - 1)) / 127; + lv_obj_set_pos(xy.indicator, (lv_coord_t)(px - indR), (lv_coord_t)(py - indR)); + } +} + +// --------------------------------------------------------------------------- +// CC control callbacks +// --------------------------------------------------------------------------- + +static void cbBack(lv_event_t* e) { + auto* mc = static_cast(lv_event_get_user_data(e)); + if (mc) mc->switchToMode(MidiMode::HOME); +} + +static void cbXCCDown(lv_event_t* e) { + auto* mc = static_cast(lv_event_get_user_data(e)); + if (!mc) return; + mc->xy_.xCC = (mc->xy_.xCC - 1 + 128) % 128; + if (mc->xy_.xCCLbl) { + char buf[12]; snprintf(buf, sizeof(buf), "X CC:%d", mc->xy_.xCC); + lv_label_set_text(mc->xy_.xCCLbl, buf); + } +} +static void cbXCCUp(lv_event_t* e) { + auto* mc = static_cast(lv_event_get_user_data(e)); + if (!mc) return; + mc->xy_.xCC = (mc->xy_.xCC + 1) % 128; + if (mc->xy_.xCCLbl) { + char buf[12]; snprintf(buf, sizeof(buf), "X CC:%d", mc->xy_.xCC); + lv_label_set_text(mc->xy_.xCCLbl, buf); + } +} +static void cbYCCDown(lv_event_t* e) { + auto* mc = static_cast(lv_event_get_user_data(e)); + if (!mc) return; + mc->xy_.yCC = (mc->xy_.yCC - 1 + 128) % 128; + if (mc->xy_.yCCLbl) { + char buf[12]; snprintf(buf, sizeof(buf), "Y CC:%d", mc->xy_.yCC); + lv_label_set_text(mc->xy_.yCCLbl, buf); + } +} +static void cbYCCUp(lv_event_t* e) { + auto* mc = static_cast(lv_event_get_user_data(e)); + if (!mc) return; + mc->xy_.yCC = (mc->xy_.yCC + 1) % 128; + if (mc->xy_.yCCLbl) { + char buf[12]; snprintf(buf, sizeof(buf), "Y CC:%d", mc->xy_.yCC); + lv_label_set_text(mc->xy_.yCCLbl, buf); + } +} + +// --------------------------------------------------------------------------- +// Build function +// --------------------------------------------------------------------------- + +void buildXYPadView(MidiController* mc, lv_obj_t* parent) { + auto& xy = mc->xy_; + + const lv_font_t* fontSmall = lvgl_get_text_font(FONT_SIZE_SMALL); + const lv_font_t* fontNormal = lvgl_get_text_font(FONT_SIZE_DEFAULT); + + int hdrH = 88; + int footH = 64; + + lv_obj_t* root = mcContainer(parent); + lv_obj_set_size(root, LV_PCT(100), LV_PCT(100)); + lv_obj_set_flex_flow(root, LV_FLEX_FLOW_COLUMN); + lv_obj_set_style_bg_color(root, lv_color_hex(0x080C12), 0); + lv_obj_set_style_bg_opa(root, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(root, 0, 0); + lv_obj_set_style_pad_all(root, 0, 0); + lv_obj_set_style_pad_gap(root, 0, 0); + lv_obj_set_scrollable(root, false); + mc->currentViewRoot_ = root; + + // -- Header ---------------------------------------------------------------- + lv_obj_t* hdr = mcRow(root, hdrH); + + int btnH = MC_BTN_H; + + mcBackBtn(hdr, cbBack, mc, MC_W_S); + + lv_obj_t* titleLbl = lv_label_create(hdr); + lv_label_set_text(titleLbl, "XY PAD"); + lv_obj_set_style_text_font(titleLbl, fontNormal, 0); + lv_obj_set_style_text_color(titleLbl, lv_color_hex(0x00DFFF), 0); + lv_obj_set_flex_grow(titleLbl, 1); + + // X CC controls + lv_obj_t* xDown = mcButton(hdr); + lv_obj_set_size(xDown, 72, btnH); + lv_obj_add_event_cb(xDown, cbXCCDown, LV_EVENT_CLICKED, mc); + lv_obj_t* xdl = lv_label_create(xDown); lv_label_set_text(xdl, "-"); + lv_obj_set_style_text_font(xdl, fontSmall, 0); lv_obj_center(xdl); + + xy.xCCLbl = lv_label_create(hdr); + char xbuf[12]; snprintf(xbuf, sizeof(xbuf), "X CC:%d", xy.xCC); + lv_label_set_text(xy.xCCLbl, xbuf); + lv_obj_set_style_text_font(xy.xCCLbl, fontSmall, 0); + lv_obj_set_style_text_color(xy.xCCLbl, lv_color_hex(0x00DFFF), 0); + lv_obj_set_width(xy.xCCLbl, 128); + lv_obj_set_style_text_align(xy.xCCLbl, LV_TEXT_ALIGN_CENTER, 0); + + lv_obj_t* xUp = mcButton(hdr); + lv_obj_set_size(xUp, 72, btnH); + lv_obj_add_event_cb(xUp, cbXCCUp, LV_EVENT_CLICKED, mc); + lv_obj_t* xul = lv_label_create(xUp); lv_label_set_text(xul, "+"); + lv_obj_set_style_text_font(xul, fontSmall, 0); lv_obj_center(xul); + + lv_obj_t* divLbl = lv_label_create(hdr); + lv_label_set_text(divLbl, "|"); + lv_obj_set_style_text_color(divLbl, lv_color_hex(0x334455), 0); + + // Y CC controls + lv_obj_t* yDown = mcButton(hdr); + lv_obj_set_size(yDown, 72, btnH); + lv_obj_add_event_cb(yDown, cbYCCDown, LV_EVENT_CLICKED, mc); + lv_obj_t* ydl = lv_label_create(yDown); lv_label_set_text(ydl, "-"); + lv_obj_set_style_text_font(ydl, fontSmall, 0); lv_obj_center(ydl); + + xy.yCCLbl = lv_label_create(hdr); + char ybuf[12]; snprintf(ybuf, sizeof(ybuf), "Y CC:%d", xy.yCC); + lv_label_set_text(xy.yCCLbl, ybuf); + lv_obj_set_style_text_font(xy.yCCLbl, fontSmall, 0); + lv_obj_set_style_text_color(xy.yCCLbl, lv_color_hex(0xFF8000), 0); + lv_obj_set_width(xy.yCCLbl, 128); + lv_obj_set_style_text_align(xy.yCCLbl, LV_TEXT_ALIGN_CENTER, 0); + + lv_obj_t* yUp = mcButton(hdr); + lv_obj_set_size(yUp, 72, btnH); + lv_obj_add_event_cb(yUp, cbYCCUp, LV_EVENT_CLICKED, mc); + lv_obj_t* yul = lv_label_create(yUp); lv_label_set_text(yul, "+"); + lv_obj_set_style_text_font(yul, fontSmall, 0); lv_obj_center(yul); + + // -- Touch/Draw area ------------------------------------------------------- + lv_obj_t* padObj = mcContainer(root); + lv_obj_set_width(padObj, LV_PCT(100)); + lv_obj_set_flex_grow(padObj, 1); + lv_obj_set_style_bg_color(padObj, lv_color_hex(0x080C12), 0); + lv_obj_set_style_bg_opa(padObj, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(padObj, 0, 0); + lv_obj_set_style_pad_all(padObj, 0, 0); + lv_obj_set_scrollable(padObj, false); + lv_obj_set_clickable(padObj, true); + lv_obj_add_event_cb(padObj, cbXYDraw, LV_EVENT_DRAW_MAIN, mc); + lv_obj_add_event_cb(padObj, cbCanvasSize, LV_EVENT_SIZE_CHANGED, mc); + lv_obj_add_event_cb(padObj, cbCanvasTouch, LV_EVENT_PRESSING, mc); + xy.canvas = padObj; + + // Indicator circle widget (positioned absolutely within canvas) + int indR = XY_IND_RADIUS; + lv_obj_t* ind = mcContainer(padObj); + lv_obj_set_size(ind, indR * 2, indR * 2); + lv_obj_set_style_radius(ind, LV_RADIUS_CIRCLE, 0); + lv_obj_set_style_bg_color(ind, lv_color_hex(0x00DFFF), 0); + lv_obj_set_style_bg_opa(ind, LV_OPA_COVER, 0); + lv_obj_set_style_border_color(ind, lv_color_hex(0xFFFFFF), 0); + lv_obj_set_style_border_width(ind, 4, 0); + lv_obj_set_style_pad_all(ind, 0, 0); + lv_obj_set_scrollable(ind, false); + lv_obj_set_clickable(ind, false); + lv_obj_set_pos(ind, 0, 0); // will be updated on first draw/touch + xy.indicator = ind; + + // -- Footer: value readout ------------------------------------------------ + lv_obj_t* footer = mcContainer(root); + lv_obj_set_size(footer, LV_PCT(100), footH); + lv_obj_set_style_bg_color(footer, lv_color_hex(0x0A0C14), 0); + lv_obj_set_style_bg_opa(footer, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(footer, 0, 0); + lv_obj_set_style_pad_all(footer, 8, 0); + lv_obj_set_scrollable(footer, false); + + xy.valLbl = lv_label_create(footer); + lv_label_set_text(xy.valLbl, "X: 64 Y: 64"); + lv_obj_set_style_text_font(xy.valLbl, fontSmall, 0); + lv_obj_set_style_text_color(xy.valLbl, lv_color_hex(0x666688), 0); + lv_obj_center(xy.valLbl); + + updateValLabel(mc); +} diff --git a/Apps/MidiController/main/Source/ViewZen.cpp b/Apps/MidiController/main/Source/ViewZen.cpp new file mode 100644 index 0000000..3e3e979 --- /dev/null +++ b/Apps/MidiController/main/Source/ViewZen.cpp @@ -0,0 +1,501 @@ +#include "MidiController.h" + +#include +#include +#include +#include +#include +#include + +// --------------------------------------------------------------------------- +// ZEN view - Ambient bouncing ball physics + note triggering +// +// Layout (flex column): +// [physics area - flex_grow, LV_EVENT_DRAW_MAIN] +// [control row: < | ADD | RESET | SCALE | KEY- | KEY+ | OCT] +// --------------------------------------------------------------------------- + + +static constexpr int TOP_SEGS = 8; +static constexpr int BOTTOM_SEGS = 8; +static constexpr int SIDE_SEGS = 4; +static constexpr int TOTAL_WALL_SEGS = TOP_SEGS + BOTTOM_SEGS + SIDE_SEGS * 2; + +struct WallSeg { + float x1, y1, x2, y2; // normalized 0..1 + int noteIdx; + int scaleDegree; + int octaveOffset; +}; + +static void buildWalls(WallSeg walls[]) { + float segW = 1.0f / TOP_SEGS; + for (int i = 0; i < TOP_SEGS; i++) { + walls[i] = { i * segW, 0.0f, (i+1) * segW, 0.0f, i, i, 0 }; + } + for (int i = 0; i < BOTTOM_SEGS; i++) { + walls[TOP_SEGS + i] = { i * segW, 1.0f, (i+1) * segW, 1.0f, + TOP_SEGS + i, BOTTOM_SEGS - 1 - i, 0 }; + } + float segH = 1.0f / SIDE_SEGS; + for (int i = 0; i < SIDE_SEGS; i++) { + int idx = TOP_SEGS + BOTTOM_SEGS + i; + walls[idx] = { 0.0f, i * segH, 0.0f, (i+1) * segH, idx, i, 1 }; + } + for (int i = 0; i < SIDE_SEGS; i++) { + int idx = TOP_SEGS + BOTTOM_SEGS + SIDE_SEGS + i; + walls[idx] = { 1.0f, i * segH, 1.0f, (i+1) * segH, + idx, SIDE_SEGS - 1 - i, 1 }; + } +} + +static const WallSeg* getWalls() { + static WallSeg s_walls[TOTAL_WALL_SEGS]; + static bool s_built = false; + if (!s_built) { + buildWalls(s_walls); + s_built = true; + } + return s_walls; +} + +static constexpr uint32_t ZEN_COLORS[] = { + 0xFF4444, 0xFF8800, 0xFFFF00, 0x44FF44, +}; + +// --------------------------------------------------------------------------- +// Shared first-ball initializer (called from cbCanvasSize and cbZenTimer fallback) +// --------------------------------------------------------------------------- + +// Note: does NOT update ballCntLbl - callers are responsible for label refresh. +// cbZenTimer updates it explicitly; cbCanvasSize relies on buildZenView initializing it to "Balls: x1". +static void initFirstBall(MidiController::ZenState& zn, int w, int h) { + float speedScale = (float)w / 220.0f; + zn.balls[0].x = w * 0.3f; + zn.balls[0].y = h * 0.4f; + zn.balls[0].vx = 2.0f * speedScale; + zn.balls[0].vy = 1.5f * speedScale; + zn.balls[0].radius = 20; + zn.balls[0].color = ZEN_COLORS[0]; + zn.balls[0].active = true; + zn.numBalls = 1; +} + +// --------------------------------------------------------------------------- +// Draw event - draws background and wall segments only; balls are lv_obj_t children +// --------------------------------------------------------------------------- + +static void cbZenDraw(lv_event_t* e) { + auto* mc = static_cast(lv_event_get_user_data(e)); + if (!mc) return; + auto& zn = mc->zen_; + + lv_obj_t* obj = lv_event_get_target_obj(e); + lv_layer_t* layer = lv_event_get_layer(e); + + lv_area_t area; + lv_obj_get_coords(obj, &area); + int32_t ox = area.x1, oy = area.y1; + int32_t cW = lv_area_get_width(&area); + int32_t cH = lv_area_get_height(&area); + + // Update dimensions only if they look valid (never write 0 to physics state) + if (cW > 0 && cH > 0) { + zn.canvasW = (int)cW; + zn.canvasH = (int)cH; + } + + // Background fill + lv_draw_fill_dsc_t bg; + lv_draw_fill_dsc_init(&bg); + bg.color = lv_color_hex(0x080C12); + bg.opa = LV_OPA_COVER; + lv_draw_fill(layer, &bg, &area); + + uint32_t nowMs = lv_tick_get(); + + const WallSeg* walls = getWalls(); + + // Draw wall segments + lv_draw_line_dsc_t line; + lv_draw_line_dsc_init(&line); + line.width = 12; + line.opa = LV_OPA_COVER; + + for (int w = 0; w < TOTAL_WALL_SEGS; w++) { + bool flashing = (nowMs < zn.wallFlashUntilMs[w]); + line.color = flashing ? lv_color_hex(0xFFFFFF) : lv_color_hex(0x1A3050); + line.p1.x = ox + (lv_value_precise_t)(walls[w].x1 * (float)(cW - 1)); + line.p1.y = oy + (lv_value_precise_t)(walls[w].y1 * (float)(cH - 1)); + line.p2.x = ox + (lv_value_precise_t)(walls[w].x2 * (float)(cW - 1)); + line.p2.y = oy + (lv_value_precise_t)(walls[w].y2 * (float)(cH - 1)); + lv_draw_line(layer, &line); + } +} + +// --------------------------------------------------------------------------- +// Physics timer - updates state, invalidates object to trigger redraw +// --------------------------------------------------------------------------- + +// Create or show a ball widget for slot b +static void ensureBallWidget(MidiController* mc, int b) { + auto& zn = mc->zen_; + if (zn.ballObj[b]) return; + int r = zn.balls[b].radius; + lv_obj_t* w = mcContainer(zn.canvas); + lv_obj_set_size(w, r * 2, r * 2); + lv_obj_set_style_radius(w, LV_RADIUS_CIRCLE, 0); + lv_obj_set_style_bg_color(w, lv_color_hex(ZEN_COLORS[b % 4]), 0); + lv_obj_set_style_bg_opa(w, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(w, 0, 0); + lv_obj_set_style_pad_all(w, 0, 0); + lv_obj_set_scrollable(w, false); + lv_obj_set_clickable(w, false); + lv_obj_set_pos(w, (lv_coord_t)(zn.balls[b].x - r), (lv_coord_t)(zn.balls[b].y - r)); + zn.ballObj[b] = w; +} + +static void scheduleZenNoteOff(MidiController* mc, uint8_t note, uint32_t delayMs) { + auto& zn = mc->zen_; + uint32_t offAt = lv_tick_get() + delayMs; + // Find a free slot + for (int i = 0; i < MidiController::ZenState::MAX_PENDING_OFFS; i++) { + if (zn.pendingOffMs[i] == 0) { + zn.pendingOffNote[i] = note; + zn.pendingOffMs[i] = offAt ? offAt : 1; // avoid 0 sentinel + return; + } + } + // No free slot - send immediately as fallback + mc->sendMidi(0x80, note, 0); +} + +static void cbZenTimer(lv_timer_t* t) { + auto* mc = static_cast(lv_timer_get_user_data(t)); + if (!mc) return; + auto& zn = mc->zen_; + lv_obj_t* canvas = zn.canvas; // snapshot - must not use zn.canvas after ensureBallWidget + if (!canvas || zn.canvasW <= 0 || zn.canvasH <= 0) return; + + // Process pending note-offs + uint32_t nowMs = lv_tick_get(); + for (int i = 0; i < MidiController::ZenState::MAX_PENDING_OFFS; i++) { + if (zn.pendingOffMs[i] != 0 && (int32_t)(nowMs - zn.pendingOffMs[i]) >= 0) { + mc->sendMidi(0x80, zn.pendingOffNote[i], 0); + zn.pendingOffMs[i] = 0; + } + } + + // Fallback: initialize first ball if SIZE_CHANGED fired before state was ready + if (zn.numBalls == 0) { + initFirstBall(zn, zn.canvasW, zn.canvasH); + if (zn.ballCntLbl) lv_label_set_text(zn.ballCntLbl, "Balls: x1"); + } + + float cW = (float)zn.canvasW; + float cH = (float)zn.canvasH; + float minSpd = 1.5f * cW / 220.0f; + + const WallSeg* walls = getWalls(); + + for (int b = 0; b < MidiController::ZenState::MAX_BALLS; b++) { + auto& ball = zn.balls[b]; + if (!ball.active) continue; + + ball.x += ball.vx; + ball.y += ball.vy; + + // Top bounce + if (ball.y - ball.radius <= 0) { + ball.y = (float)ball.radius; + ball.vy = fabsf(ball.vy); + if (fabsf(ball.vy) < minSpd) ball.vy = minSpd; + float fx = ball.x / cW; + int seg = (int)(fx * TOP_SEGS); + seg = seg < 0 ? 0 : (seg >= TOP_SEGS ? TOP_SEGS - 1 : seg); + int note = std::max(0, std::min(127, getNoteInScale(zn.scale, walls[seg].scaleDegree, zn.octave + walls[seg].octaveOffset, zn.key))); + mc->sendMidi(0x90, (uint8_t)note, 70 + (int)(esp_random() % 40)); + scheduleZenNoteOff(mc, (uint8_t)note, 150); + zn.wallFlashUntilMs[walls[seg].noteIdx] = nowMs + 200; + } + // Bottom bounce + if (ball.y + ball.radius >= cH) { + ball.y = cH - ball.radius; + ball.vy = -fabsf(ball.vy); + if (fabsf(ball.vy) < minSpd) ball.vy = -minSpd; + float fx = ball.x / cW; + int seg = (int)(fx * BOTTOM_SEGS); + seg = seg < 0 ? 0 : (seg >= BOTTOM_SEGS ? BOTTOM_SEGS - 1 : seg); + int wallIdx = TOP_SEGS + seg; + int note = std::max(0, std::min(127, getNoteInScale(zn.scale, walls[wallIdx].scaleDegree, zn.octave + walls[wallIdx].octaveOffset, zn.key))); + mc->sendMidi(0x90, (uint8_t)note, 70 + (int)(esp_random() % 40)); + scheduleZenNoteOff(mc, (uint8_t)note, 150); + zn.wallFlashUntilMs[walls[wallIdx].noteIdx] = nowMs + 200; + } + // Left bounce + if (ball.x - ball.radius <= 0) { + ball.x = (float)ball.radius; + ball.vx = fabsf(ball.vx); + if (fabsf(ball.vx) < minSpd) ball.vx = minSpd; + float fy = ball.y / cH; + int seg = (int)(fy * SIDE_SEGS); + seg = seg < 0 ? 0 : (seg >= SIDE_SEGS ? SIDE_SEGS - 1 : seg); + int wallIdx = TOP_SEGS + BOTTOM_SEGS + seg; + int note = std::max(0, std::min(127, getNoteInScale(zn.scale, walls[wallIdx].scaleDegree, zn.octave + walls[wallIdx].octaveOffset, zn.key))); + mc->sendMidi(0x90, (uint8_t)note, 70 + (int)(esp_random() % 40)); + scheduleZenNoteOff(mc, (uint8_t)note, 150); + zn.wallFlashUntilMs[walls[wallIdx].noteIdx] = nowMs + 200; + } + // Right bounce + if (ball.x + ball.radius >= cW) { + ball.x = cW - ball.radius; + ball.vx = -fabsf(ball.vx); + if (fabsf(ball.vx) < minSpd) ball.vx = -minSpd; + float fy = ball.y / cH; + int seg = (int)(fy * SIDE_SEGS); + seg = seg < 0 ? 0 : (seg >= SIDE_SEGS ? SIDE_SEGS - 1 : seg); + int wallIdx = TOP_SEGS + BOTTOM_SEGS + SIDE_SEGS + seg; + int note = std::max(0, std::min(127, getNoteInScale(zn.scale, walls[wallIdx].scaleDegree, zn.octave + walls[wallIdx].octaveOffset, zn.key))); + mc->sendMidi(0x90, (uint8_t)note, 70 + (int)(esp_random() % 40)); + scheduleZenNoteOff(mc, (uint8_t)note, 150); + zn.wallFlashUntilMs[walls[wallIdx].noteIdx] = nowMs + 200; + } + } + + // Update ball widget positions (create if needed, hide if inactive) + for (int b = 0; b < MidiController::ZenState::MAX_BALLS; b++) { + auto& ball = zn.balls[b]; + if (ball.active) { + ensureBallWidget(mc, b); + int r = ball.radius; + lv_obj_set_pos(zn.ballObj[b], (lv_coord_t)(ball.x - r), (lv_coord_t)(ball.y - r)); + lv_obj_set_hidden(zn.ballObj[b], false); + } else if (zn.ballObj[b]) { + lv_obj_set_hidden(zn.ballObj[b], true); + } + } + + // Request redraw of walls (background) - use local snapshot, not zn.canvas + if (canvas && canvas == zn.canvas) lv_obj_invalidate(canvas); +} + +// --------------------------------------------------------------------------- +// Canvas size event - initialize balls after first layout +// --------------------------------------------------------------------------- + +static void cbCanvasSize(lv_event_t* e) { + auto* mc = static_cast(lv_event_get_user_data(e)); + if (!mc) return; + auto& zn = mc->zen_; + lv_obj_t* obj = lv_event_get_target_obj(e); + int w = (int)lv_obj_get_content_width(obj); + int h = (int)lv_obj_get_content_height(obj); + if (w <= 0 || h <= 0) return; + + zn.canvasW = w; + zn.canvasH = h; + + // Initialize first ball on first valid layout + if (zn.numBalls == 0) { + initFirstBall(zn, w, h); + } +} + +// --------------------------------------------------------------------------- +// Control callbacks +// --------------------------------------------------------------------------- + +static void cbBack(lv_event_t* e) { + auto* mc = static_cast(lv_event_get_user_data(e)); + if (mc) mc->switchToMode(MidiMode::HOME); +} + +static void cbAddBall(lv_event_t* e) { + auto* mc = static_cast(lv_event_get_user_data(e)); + if (!mc) return; + auto& zn = mc->zen_; + if (zn.numBalls >= MidiController::ZenState::MAX_BALLS) return; + + int b = zn.numBalls; + zn.balls[b].x = zn.canvasW * 0.3f + (float)(esp_random() % 100) * 0.01f * zn.canvasW * 0.4f; + zn.balls[b].y = zn.canvasH * 0.3f + (float)(esp_random() % 100) * 0.01f * zn.canvasH * 0.4f; + float speedScale = (float)zn.canvasW / 220.0f; + float spd = (1.5f + (float)(esp_random() % 14) * 0.1f) * speedScale; + float ang = (float)(esp_random() % 628) * 0.01f; + zn.balls[b].vx = cosf(ang) * spd; + zn.balls[b].vy = sinf(ang) * spd; + zn.balls[b].radius = 20; + zn.balls[b].color = ZEN_COLORS[b % 4]; + zn.balls[b].active = true; + zn.numBalls++; + if (zn.ballCntLbl) { + char buf[24]; snprintf(buf, sizeof(buf), "Balls: x%d", zn.numBalls); + lv_label_set_text(zn.ballCntLbl, buf); + } +} + +static void cbReset(lv_event_t* e) { + auto* mc = static_cast(lv_event_get_user_data(e)); + if (!mc) return; + auto& zn = mc->zen_; + for (int b = 1; b < MidiController::ZenState::MAX_BALLS; b++) zn.balls[b].active = false; + zn.numBalls = 1; + if (zn.ballCntLbl) lv_label_set_text(zn.ballCntLbl, "Balls: x1"); +} + +static void cbScaleCycle(lv_event_t* e) { + auto* mc = static_cast(lv_event_get_user_data(e)); + if (!mc) return; + mc->zen_.scale = (mc->zen_.scale + 1) % NUM_MIDI_SCALES; + if (mc->zen_.statusLbl) { + char buf[24]; + snprintf(buf, sizeof(buf), "%s %s", KEY_NAMES[mc->zen_.key], MIDI_SCALES[mc->zen_.scale].name); + lv_label_set_text(mc->zen_.statusLbl, buf); + } +} + +static void cbKeyDown(lv_event_t* e) { + auto* mc = static_cast(lv_event_get_user_data(e)); + if (!mc) return; + mc->zen_.key = (mc->zen_.key + 11) % 12; + if (mc->zen_.statusLbl) { + char buf[24]; + snprintf(buf, sizeof(buf), "%s %s", KEY_NAMES[mc->zen_.key], MIDI_SCALES[mc->zen_.scale].name); + lv_label_set_text(mc->zen_.statusLbl, buf); + } +} +static void cbKeyUp(lv_event_t* e) { + auto* mc = static_cast(lv_event_get_user_data(e)); + if (!mc) return; + mc->zen_.key = (mc->zen_.key + 1) % 12; + if (mc->zen_.statusLbl) { + char buf[24]; + snprintf(buf, sizeof(buf), "%s %s", KEY_NAMES[mc->zen_.key], MIDI_SCALES[mc->zen_.scale].name); + lv_label_set_text(mc->zen_.statusLbl, buf); + } +} + +static void cbOctCycle(lv_event_t* e) { + auto* mc = static_cast(lv_event_get_user_data(e)); + if (!mc) return; + mc->zen_.octave++; + if (mc->zen_.octave > 6) mc->zen_.octave = 2; + if (mc->zen_.octLbl) { + char buf[6]; snprintf(buf, sizeof(buf), "O:%d", mc->zen_.octave); + lv_label_set_text(mc->zen_.octLbl, buf); + } +} + +// --------------------------------------------------------------------------- +// Teardown +// --------------------------------------------------------------------------- + +static void cbZenRootDelete(lv_event_t* e) { + auto* mc = static_cast(lv_event_get_user_data(e)); + if (!mc) return; + auto& zn = mc->zen_; + + // Stop and delete the zen timer so it cannot fire after widgets are gone + if (mc->zenTimer_) { + lv_timer_delete(mc->zenTimer_); + mc->zenTimer_ = nullptr; + } + + // Flush all pending note-offs immediately so no notes get stuck + for (int i = 0; i < MidiController::ZenState::MAX_PENDING_OFFS; i++) { + if (zn.pendingOffMs[i] != 0) { + mc->sendMidi(0x80, zn.pendingOffNote[i], 0); + zn.pendingOffMs[i] = 0; + } + } + + // Null all stored LVGL pointers + zn.canvas = nullptr; + zn.statusLbl = nullptr; + zn.ballCntLbl = nullptr; + zn.octLbl = nullptr; + for (int b = 0; b < MidiController::ZenState::MAX_BALLS; b++) + zn.ballObj[b] = nullptr; +} + +// --------------------------------------------------------------------------- +// Build function +// --------------------------------------------------------------------------- + +void buildZenView(MidiController* mc, lv_obj_t* parent) { + auto& zn = mc->zen_; + + const lv_font_t* fontSmall = lvgl_get_text_font(FONT_SIZE_SMALL); + int ctrlH = 88; + int btnH = MC_BTN_H; + + lv_obj_t* root = mcContainer(parent); + lv_obj_add_event_cb(root, cbZenRootDelete, LV_EVENT_DELETE, mc); + lv_obj_set_size(root, LV_PCT(100), LV_PCT(100)); + lv_obj_set_flex_flow(root, LV_FLEX_FLOW_COLUMN); + lv_obj_set_style_bg_color(root, lv_color_hex(0x080C12), 0); + lv_obj_set_style_bg_opa(root, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(root, 0, 0); + lv_obj_set_style_pad_all(root, 0, 0); + lv_obj_set_style_pad_gap(root, 0, 0); + lv_obj_set_scrollable(root, false); + mc->currentViewRoot_ = root; + + // -- Control row (TOP) ---------------------------------------------------- + lv_obj_t* ctrl = mcRow(root, ctrlH); + + auto makeCtrlBtn = [&](const char* lbl, lv_event_cb_t cb) { + lv_obj_t* btn = mcButton(ctrl); + lv_obj_set_size(btn, 112, btnH); + lv_obj_add_event_cb(btn, cb, LV_EVENT_CLICKED, mc); + lv_obj_t* l = lv_label_create(btn); + lv_label_set_text(l, lbl); + lv_obj_set_style_text_font(l, fontSmall, 0); + lv_obj_center(l); + }; + + mcBackBtn(ctrl, cbBack, mc, MC_W_XS); + + makeCtrlBtn("ADD", cbAddBall); + makeCtrlBtn("RESET", cbReset); + makeCtrlBtn("SCALE", cbScaleCycle); + makeCtrlBtn("KEY-", cbKeyDown); + makeCtrlBtn("KEY+", cbKeyUp); + makeCtrlBtn("OCT", cbOctCycle); + + zn.octLbl = lv_label_create(ctrl); + { char buf[6]; snprintf(buf, sizeof(buf), "O:%d", zn.octave); lv_label_set_text(zn.octLbl, buf); } + lv_obj_set_style_text_font(zn.octLbl, fontSmall, 0); + lv_obj_set_style_text_color(zn.octLbl, lv_color_hex(0x00DFFF), 0); + lv_obj_set_width(zn.octLbl, 112); + lv_obj_set_style_text_align(zn.octLbl, LV_TEXT_ALIGN_CENTER, 0); + + zn.ballCntLbl = lv_label_create(ctrl); + lv_label_set_text(zn.ballCntLbl, "Balls: x1"); + lv_obj_set_style_text_font(zn.ballCntLbl, fontSmall, 0); + lv_obj_set_style_text_color(zn.ballCntLbl, lv_color_hex(0x666688), 0); + + zn.statusLbl = lv_label_create(ctrl); + { char sbuf[24]; snprintf(sbuf, sizeof(sbuf), "%s %s", KEY_NAMES[zn.key], MIDI_SCALES[zn.scale].name); + lv_label_set_text(zn.statusLbl, sbuf); } + lv_obj_set_style_text_font(zn.statusLbl, fontSmall, 0); + lv_obj_set_style_text_color(zn.statusLbl, lv_color_hex(0x00DFFF), 0); + lv_obj_set_flex_grow(zn.statusLbl, 1); + lv_obj_set_style_text_align(zn.statusLbl, LV_TEXT_ALIGN_RIGHT, 0); + + // -- Physics area (fills remaining space below controls) ------------------- + lv_obj_t* physArea = mcContainer(root); + lv_obj_set_width(physArea, LV_PCT(100)); + lv_obj_set_flex_grow(physArea, 1); + lv_obj_set_style_bg_color(physArea, lv_color_hex(0x080C12), 0); + lv_obj_set_style_bg_opa(physArea, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(physArea, 0, 0); + lv_obj_set_style_pad_all(physArea, 0, 0); + lv_obj_set_scrollable(physArea, false); + lv_obj_add_event_cb(physArea, cbZenDraw, LV_EVENT_DRAW_MAIN, mc); + lv_obj_add_event_cb(physArea, cbCanvasSize, LV_EVENT_SIZE_CHANGED, mc); + zn.canvas = physArea; + + // Start timer + mc->zenTimer_ = lv_timer_create(cbZenTimer, 16, mc); +} diff --git a/Apps/MidiController/main/Source/main.cpp b/Apps/MidiController/main/Source/main.cpp new file mode 100644 index 0000000..02d0641 --- /dev/null +++ b/Apps/MidiController/main/Source/main.cpp @@ -0,0 +1,62 @@ +#include "MidiController.h" + +#include +#include +#include + +#include + +#include + +#include + +extern "C" { + +int main(int argc, char* argv[]) { + AppInstanceId app_instance_id = app_scheduler_current_app_id(); + + // Heap-allocated: the BT event subscription (bluetooth_event_subscribe, see + // MidiController::createWidgetsImpl) captures the instance's address, so it can't be a stack + // frame that goes away while a subscription might still be active. + auto mc = std::make_unique(); + mc->appInstanceId = app_instance_id; + + struct TaskEventGroup event_group {}; + task_event_group_construct(&event_group); + + struct AppEventSubscription sub {}; + check(app_event_subscribe(&sub, &event_group) == ERROR_NONE); + + // BLE and USB MIDI start on this task: the LVGL task can't do the flash and NVS work that they need + mc->attachEventGroup(&event_group); + mc->detectBleState(); + + WindowId window = window_manager_create(app_instance_id, MidiController::createWidgets, mc.get()); + mc->window = window; + + bool should_close = false; + while (!should_close) { + task_event_group_wait_any(&event_group, nullptr, portMAX_DELAY); + + struct AppEvent event; + while (app_event_poll(&sub, &event) == ERROR_NONE) { + if (event.type == APP_EVENT_CLOSE) { + should_close = true; + break; + } + } + + if (!should_close) { + mc->processEvents(); + } + } + + window_manager_remove(window); + app_event_unsubscribe(&sub); + mc->teardown(); + task_event_group_destruct(&event_group); + + return 0; +} + +} diff --git a/Apps/MidiController/manifest.properties b/Apps/MidiController/manifest.properties new file mode 100644 index 0000000..a55c90d --- /dev/null +++ b/Apps/MidiController/manifest.properties @@ -0,0 +1,11 @@ +manifest.version=0.3 +target.sdk=0.8.0-dev +target.platforms=esp32p4 +id=tactility.midicontroller +version.name=0.3.0 +version.code=3 +name=Midi Controller +description=Midi Controller +app.0.id=tactility.midicontroller +app.0.name=Midi Controller +app.0.binary=midicontroller diff --git a/Apps/MystifyDemo/manifest.properties b/Apps/MystifyDemo/manifest.properties index 8dbf615..db50e5e 100644 --- a/Apps/MystifyDemo/manifest.properties +++ b/Apps/MystifyDemo/manifest.properties @@ -2,8 +2,8 @@ manifest.version=0.3 target.sdk=0.8.0-dev target.platforms=esp32,esp32s3,esp32c6,esp32p4 id=tactility.mystifydemo -version.name=0.18.0 -version.code=18 +version.name=0.19.0 +version.code=19 name=Mystify Demo description=A graphics demo that is inspired by a Windows 95 screensaver. app.0.id=tactility.mystifydemo diff --git a/Apps/Neatvi/manifest.properties b/Apps/Neatvi/manifest.properties index 873c498..4350f67 100644 --- a/Apps/Neatvi/manifest.properties +++ b/Apps/Neatvi/manifest.properties @@ -1,9 +1,9 @@ manifest.version=0.3 target.sdk=0.8.0-dev -target.platforms=esp32,esp32s3,esp32c6,esp32p4,posix-x86_64 +target.platforms=all id=tactility.neatvi -version.name=0.4.0 -version.code=4 +version.name=0.5.0 +version.code=5 requires.ram=2M name=Neatvi description=Neatvi text editor diff --git a/Apps/Rcc700/manifest.properties b/Apps/Rcc700/manifest.properties index ec82b08..84cf1d3 100644 --- a/Apps/Rcc700/manifest.properties +++ b/Apps/Rcc700/manifest.properties @@ -1,9 +1,9 @@ manifest.version=0.3 target.sdk=0.8.0-dev -target.platforms=esp32,esp32s3,esp32c6,esp32p4,posix-x86_64 +target.platforms=all id=tactility.rcc700 -version.name=0.3.0 -version.code=3 +version.name=0.4.0 +version.code=4 requires.ram=2M name=rcc700 description=Mini C compiler for ESP32 RISC-V diff --git a/Apps/SatelliteTracker/manifest.properties b/Apps/SatelliteTracker/manifest.properties index 89056fa..0d17fc4 100644 --- a/Apps/SatelliteTracker/manifest.properties +++ b/Apps/SatelliteTracker/manifest.properties @@ -1,9 +1,9 @@ manifest.version=0.3 target.sdk=0.8.0-dev -target.platforms=esp32,esp32s3,esp32c6,esp32p4,posix-x86_64 +target.platforms=all id=tactility.satellitetracker -version.name=0.2.0 -version.code=2 +version.name=0.3.0 +version.code=3 name=Satellite Tracker description=World map with live satellite positions and passes app.0.id=tactility.satellitetracker diff --git a/Apps/SerialConsole/manifest.properties b/Apps/SerialConsole/manifest.properties index d0086ac..4b3f149 100644 --- a/Apps/SerialConsole/manifest.properties +++ b/Apps/SerialConsole/manifest.properties @@ -2,8 +2,8 @@ manifest.version=0.3 target.sdk=0.8.0-dev target.platforms=esp32,esp32s3,esp32c6,esp32p4 id=tactility.serialconsole -version.name=0.19.0 -version.code=19 +version.name=0.20.0 +version.code=20 name=Serial Console app.0.id=tactility.serialconsole app.0.name=Serial Console diff --git a/Apps/Snake/manifest.properties b/Apps/Snake/manifest.properties index aa9560d..87c947d 100644 --- a/Apps/Snake/manifest.properties +++ b/Apps/Snake/manifest.properties @@ -2,8 +2,8 @@ manifest.version=0.3 target.sdk=0.8.0-dev target.platforms=esp32,esp32s3,esp32c6,esp32p4 id=tactility.snake -version.name=0.20.0 -version.code=20 +version.name=0.21.0 +version.code=21 name=Snake description=Classic Snake game app.0.id=tactility.snake diff --git a/Apps/TamaTac/manifest.properties b/Apps/TamaTac/manifest.properties index d9a5da0..8268fc5 100644 --- a/Apps/TamaTac/manifest.properties +++ b/Apps/TamaTac/manifest.properties @@ -2,8 +2,8 @@ manifest.version=0.3 target.sdk=0.8.0-dev target.platforms=esp32,esp32s3,esp32c6,esp32p4 id=tactility.tamatac -version.name=0.15.0 -version.code=15 +version.name=0.16.0 +version.code=16 name=TamaTac description=Virtual pet inspired by Tamagotchi. Only runs on devices with PSRAM. app.0.id=tactility.tamatac diff --git a/Apps/TodoList/manifest.properties b/Apps/TodoList/manifest.properties index 0032ce3..fec17d3 100644 --- a/Apps/TodoList/manifest.properties +++ b/Apps/TodoList/manifest.properties @@ -2,8 +2,8 @@ manifest.version=0.3 target.sdk=0.8.0-dev target.platforms=esp32,esp32s3,esp32c6,esp32p4 id=tactility.todolist -version.name=0.18.0 -version.code=18 +version.name=0.19.0 +version.code=19 name=Todo List description=Simple task list manager app.0.id=tactility.todolist diff --git a/Apps/Tone/manifest.properties b/Apps/Tone/manifest.properties index 283add6..3e3ed69 100644 --- a/Apps/Tone/manifest.properties +++ b/Apps/Tone/manifest.properties @@ -1,9 +1,9 @@ manifest.version=0.3 target.sdk=0.8.0-dev -target.platforms=esp32,esp32s3,esp32c6,esp32p4,posix-x86_64 +target.platforms=all id=tactility.tone -version.name=0.1.0 -version.code=1 +version.name=0.2.0 +version.code=2 name=Tone description=Tone generator and frequency sweeper app.0.id=tactility.tone diff --git a/Apps/TwoEleven/manifest.properties b/Apps/TwoEleven/manifest.properties index a9a558c..9f2be8a 100644 --- a/Apps/TwoEleven/manifest.properties +++ b/Apps/TwoEleven/manifest.properties @@ -2,8 +2,8 @@ manifest.version=0.3 target.sdk=0.8.0-dev target.platforms=esp32,esp32s3,esp32c6,esp32p4 id=tactility.twoeleven -version.name=0.19.0 -version.code=19 +version.name=0.20.0 +version.code=20 name=2048 description=A fun, customizable 2048 sliding tile game for tactility!\nSlide tiles to combine numbers and reach 2048.\nChoose grid sizes: 3x3 (easy), 4x4 (classic), 5x5, or 6x6 (expert). app.0.id=tactility.twoeleven diff --git a/Apps/WebServer/manifest.properties b/Apps/WebServer/manifest.properties index 6845ad3..73d9cc0 100644 --- a/Apps/WebServer/manifest.properties +++ b/Apps/WebServer/manifest.properties @@ -2,8 +2,8 @@ manifest.version=0.3 target.sdk=0.8.0-dev target.platforms=esp32,esp32s3,esp32c6,esp32p4 id=tactility.webserver -version.name=0.1.0 -version.code=1 +version.name=0.2.0 +version.code=2 name=Web Server description=Web dashboard with a file browser, app manager and system information app.0.id=tactility.webserver diff --git a/Apps/Xcc700/manifest.properties b/Apps/Xcc700/manifest.properties index 2097d3e..ab07104 100644 --- a/Apps/Xcc700/manifest.properties +++ b/Apps/Xcc700/manifest.properties @@ -1,9 +1,9 @@ manifest.version=0.3 target.sdk=0.8.0-dev -target.platforms=esp32,esp32s3,esp32c6,esp32p4,posix-x86_64 +target.platforms=all id=tactility.xcc700 -version.name=0.3.0 -version.code=3 +version.name=0.4.0 +version.code=4 name=xcc700 description=Mini C compiler for ESP32 Xtensa app.0.id=tactility.xcc700 diff --git a/Scripts/release.py b/Scripts/release.py index d5b57d7..7b9ec99 100644 --- a/Scripts/release.py +++ b/Scripts/release.py @@ -137,6 +137,9 @@ def manifest_config_to_flat_json(manifest): target_sdk = manifest.get("target.sdk", "") platforms_raw = manifest.get("target.platforms", "") target_platforms = [p.strip() for p in str(platforms_raw).split(",") if p.strip()] if platforms_raw is not None else [] + # An empty list means every platform + if target_platforms == ["all"]: + target_platforms = [] requires_device_id_raw = manifest.get("requires.device.id", "") requires_device_id = [d.strip() for d in str(requires_device_id_raw).split(",") if d.strip()] From 9e37652218ac93ff88565d0446d57b8466365d34 Mon Sep 17 00:00:00 2001 From: Ken Van Hoeylandt Date: Fri, 23 Oct 2026 20:05:37 +0200 Subject: [PATCH 2/2] Remove MidiController --- Apps/MidiController/CMakeLists.txt | 13 - ...ntrol-Change-Messages_Another-Producer.pdf | Bin 182253 -> 0 bytes Apps/MidiController/docs/README.md | 25 - Apps/MidiController/docs/architecture.md | 144 --- Apps/MidiController/docs/encoder.md | 158 --- Apps/MidiController/docs/hardware.md | 125 --- Apps/MidiController/docs/lcd.md | 162 --- Apps/MidiController/docs/modes.md | 300 ------ Apps/MidiController/docs/scales.md | 73 -- Apps/MidiController/docs/units.md | 317 ------ Apps/MidiController/main/CMakeLists.txt | 5 - .../main/Source/EncoderInput.cpp | 375 ------- .../main/Source/GroveLookup.cpp | 45 - Apps/MidiController/main/Source/GroveLookup.h | 12 - .../MidiController/main/Source/LcdDisplay.cpp | 803 --------------- Apps/MidiController/main/Source/LcdDisplay.h | 49 - .../main/Source/MidiController.cpp | 965 ------------------ .../main/Source/MidiController.h | 567 ---------- Apps/MidiController/main/Source/MidiScales.h | 76 -- Apps/MidiController/main/Source/Sequencer.cpp | 107 -- Apps/MidiController/main/Source/Sequencer.h | 58 -- Apps/MidiController/main/Source/UiKit.h | 159 --- Apps/MidiController/main/Source/ViewArp.cpp | 541 ---------- Apps/MidiController/main/Source/ViewBaby8.cpp | 417 -------- Apps/MidiController/main/Source/ViewBeats.cpp | 347 ------- Apps/MidiController/main/Source/ViewChord.cpp | 298 ------ .../main/Source/ViewDimensions.cpp | 575 ----------- Apps/MidiController/main/Source/ViewDrop.cpp | 505 --------- .../MidiController/main/Source/ViewEuclid.cpp | 662 ------------ Apps/MidiController/main/Source/ViewGrid.cpp | 289 ------ Apps/MidiController/main/Source/ViewKeys.cpp | 427 -------- Apps/MidiController/main/Source/ViewLfo.cpp | 449 -------- Apps/MidiController/main/Source/ViewRaga.cpp | 554 ---------- Apps/MidiController/main/Source/ViewRng.cpp | 352 ------- Apps/MidiController/main/Source/ViewTb3po.cpp | 606 ----------- Apps/MidiController/main/Source/ViewXYPad.cpp | 310 ------ Apps/MidiController/main/Source/ViewZen.cpp | 501 --------- Apps/MidiController/main/Source/main.cpp | 62 -- Apps/MidiController/manifest.properties | 11 - 39 files changed, 11444 deletions(-) delete mode 100644 Apps/MidiController/CMakeLists.txt delete mode 100644 Apps/MidiController/docs/MIDI-Control-Change-Messages_Another-Producer.pdf delete mode 100644 Apps/MidiController/docs/README.md delete mode 100644 Apps/MidiController/docs/architecture.md delete mode 100644 Apps/MidiController/docs/encoder.md delete mode 100644 Apps/MidiController/docs/hardware.md delete mode 100644 Apps/MidiController/docs/lcd.md delete mode 100644 Apps/MidiController/docs/modes.md delete mode 100644 Apps/MidiController/docs/scales.md delete mode 100644 Apps/MidiController/docs/units.md delete mode 100644 Apps/MidiController/main/CMakeLists.txt delete mode 100644 Apps/MidiController/main/Source/EncoderInput.cpp delete mode 100644 Apps/MidiController/main/Source/GroveLookup.cpp delete mode 100644 Apps/MidiController/main/Source/GroveLookup.h delete mode 100644 Apps/MidiController/main/Source/LcdDisplay.cpp delete mode 100644 Apps/MidiController/main/Source/LcdDisplay.h delete mode 100644 Apps/MidiController/main/Source/MidiController.cpp delete mode 100644 Apps/MidiController/main/Source/MidiController.h delete mode 100644 Apps/MidiController/main/Source/MidiScales.h delete mode 100644 Apps/MidiController/main/Source/Sequencer.cpp delete mode 100644 Apps/MidiController/main/Source/Sequencer.h delete mode 100644 Apps/MidiController/main/Source/UiKit.h delete mode 100644 Apps/MidiController/main/Source/ViewArp.cpp delete mode 100644 Apps/MidiController/main/Source/ViewBaby8.cpp delete mode 100644 Apps/MidiController/main/Source/ViewBeats.cpp delete mode 100644 Apps/MidiController/main/Source/ViewChord.cpp delete mode 100644 Apps/MidiController/main/Source/ViewDimensions.cpp delete mode 100644 Apps/MidiController/main/Source/ViewDrop.cpp delete mode 100644 Apps/MidiController/main/Source/ViewEuclid.cpp delete mode 100644 Apps/MidiController/main/Source/ViewGrid.cpp delete mode 100644 Apps/MidiController/main/Source/ViewKeys.cpp delete mode 100644 Apps/MidiController/main/Source/ViewLfo.cpp delete mode 100644 Apps/MidiController/main/Source/ViewRaga.cpp delete mode 100644 Apps/MidiController/main/Source/ViewRng.cpp delete mode 100644 Apps/MidiController/main/Source/ViewTb3po.cpp delete mode 100644 Apps/MidiController/main/Source/ViewXYPad.cpp delete mode 100644 Apps/MidiController/main/Source/ViewZen.cpp delete mode 100644 Apps/MidiController/main/Source/main.cpp delete mode 100644 Apps/MidiController/manifest.properties diff --git a/Apps/MidiController/CMakeLists.txt b/Apps/MidiController/CMakeLists.txt deleted file mode 100644 index c90c5ec..0000000 --- a/Apps/MidiController/CMakeLists.txt +++ /dev/null @@ -1,13 +0,0 @@ -# tactility-cmakelists-version: 1 -cmake_minimum_required(VERSION 3.20) - -if (NOT DEFINED ENV{TACTILITY_SDK_PATH}) - message(FATAL_ERROR "TACTILITY_SDK_PATH environment variable is not set") -endif() - -get_filename_component(TACTILITY_SDK_PATH "$ENV{TACTILITY_SDK_PATH}" ABSOLUTE) -include("${TACTILITY_SDK_PATH}/TactilitySDK.cmake") - -tactility_project_pre(midicontroller) -project(midicontroller) -tactility_project_post(midicontroller) diff --git a/Apps/MidiController/docs/MIDI-Control-Change-Messages_Another-Producer.pdf b/Apps/MidiController/docs/MIDI-Control-Change-Messages_Another-Producer.pdf deleted file mode 100644 index d113dfce7fc35abccf86f5f94cb716e3c799a0af..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 182253 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

$vs?R^n;q6p&zT1zzWd(QwfOZ-PJaI8 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

#x-VVTV=yLy1*!7VdO8mxu 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 Ab5q*KXfl@NRQWtCHRECi9R}X zJxI@6!iL%R3>@_gU5iivr;NR%beTnN8Gg%80_VwjL)b*_7(l1d`+y@g;D{1vTDEWa zk|#d^MA(7I;2pzn`v%pEO{lwXA9;U-EDJV)4eT|VF}d%11*26bEO4~P@a|_4@6qsh z)NOC_6@OdAF6Jvf2l6^)Z0-u40ddZaN|R=|>>j-Pe5MEBP!g!t&|zNT9zjW(OuK`f z4Y!F?(8-HrpH^Mt)gxt+vHwjRt)js5*D1LJXJ{Fsv7l1%?+Sqn#8Q`ERXL-5f|ZF& z6#?xCEAa^)wb=o9SU!~S64`02Uv%HMjDg93Y4dScJ$M>6zF!+MsEpZKau8Bf*(8gCEpLUL- z{e@=XsxU;qE5&7EA(Dn&d>bcIC{Cx4Xb%{c`U4^Zx0LT) zMFhSXP#o1&R<_K1h_s>N+9XM*Zh%JT(yX&yFm{Ucab7xWU3`>QK0UQ7VuN)e>i#~~ zE9*p)YVf8;4)4;z)U=L4G`4f( z=!e}Yfjb|4I5Cy;)CJ}yZU1pLRyMbeiIYr~WLqH6d5e=CeR~Y|=(5Uu0EHbG^$diDgF| z-T2%A%}s8i(lfbBv8&D0HxcTL?*(zOs)R&!F+%>~y-MVSsfpBUP+|y04hh8VO2_t^ zJeTBdDSxXTWnMvg$%XqD%O5oUNUJk z4Ua~sdPRiT0XFn?hF)KpUw&I5f%04jgJ9Lb-3;1a1<#d^Z`_bLIQlk0C&MVg1}C*y zpnhRm$5R138>bq5E!x#s&bpCS&I|dx?kAUYyY6D4d)HQV0XHF3e@s0aDKA)l8oICQ z=l032QrXFxJeAzj*4D~FTxP~g5l$K=e1Q(vL!uVi_vs$=XDZWzYcp$+8!i*9qT!^T zCr%6&w%t z&T#AO>vl4`@pV!xKDR_jqHuQ|glsNd`ED9Da+ES^QB)=>j~?x`5ccttq<2r8qkv5ciqYP3D-6bkI77AtVVx6 z-sB(pIFhMvJAS>Nno4(a5gqky#QJi0hwY5vE)R-lbJaUAIa7jY?gy42?v`bM(xg&I zr39iO62o5Sjid}Z-7@7l6Ew!jT*l7p9MW6R31fd@!Q1>{RNj?!^JM(G zTsB-Q=b!FA2{Me{$9SD356*5jC=cN#)=g*4iMEc_@Pr&1#%KJUM4HZe%NquFWxfdK z^SR)Uz9RDDM2hMo_s6mhL+YIBSmo#K^T)ti$fmZwXQor`an@Nl3`>`AKn%92lv6k$~j=?~TQ+35kjb)B2Y@9wx^2uQEzfq^>SgIkgz=!mi-(yFN|& zh6Y+Hvy)E(OW8l+JYE65nSM1fCh2N97T%FdTsMbS#~5!R@&> zu&KWALjF=~hA*T@yue;&ebrwBgiiua;-k1I=d#I%48kZ}rhN29>k`RyYdV`DVxLxj zZkbjts!)rIcUs@KwO^rgCy{s=4xP4=fSm_Fg^allE;UF>4@p4O`Tj`pxho*3c|xU+ zW|)Lov^(#JKB4$BNmxLwl#xaZ>~Uo;%kVAQnLZ-ck+`2!jSw*dHA0`^aG04R{tK!8 z*@a80_|^k1|V%zPFCM)9fm_|9kEF!skBi}tHX=i7R&SmZO&pwDs0KqPNTr-gY+`e*}Ko0Rw6>R!Y@@Xex zFS$vwOS^S$XGxX>bJ`9Y*b6Y+#;;BV6r>Xt+mTr#gB^4vL-g}jpzl?d)4yI$qs!Mk zg|6NY_jY`#M@LsQ9G2?OX%>9nPtEq)B;cOYoGpV9Vq8w97gTxOJ`FOC{9{HmFVj`j z#H(h$L~YoNn4;{!Bb$VRKsV*~ocIy7_P^Y7BZ4Nc z@VGQfH|=Q6o*L>w1a^bwgoJ(-dq4HK$hn`d%o_bMPN$h30>A$C zhER73-*@XB7R=d{{FDxpE)aL)G$PGPK=G~N#Jw6n%ou?*V9wJBzi)tG1Y83)tk)V@ zv__(n8O_jHGh`j;=!eF4tl@oJQbMdzu}6;tt;ry@7B{Z_Fr%MPu$#L+B-iUK^&OkBQwb3I1qwDH?#f#1kqE4#*xoy=$?BC-5j@8Ew_n9EY1vw=Sln9Njfr2iS3 zZrNe8TsD$8H1%>z1-xH19=TSpGY`2=nr(I2*!kO1$cb_X<7iD^a4@lH_m@+dflm`k zHG^I{t|t-su7TSSyYoS6;?6XbUz%?~9@1cU5Ed)9i4ID6y$Wu*Z-Do*QDdm@YGTxW zozxAFBTKsjXVexwOP`ty;HXJ%=kTW-fh}dzB3TR5`a?;Y%{^dLeXws{eQ;a?YsqG- z)8$hb=iq-}W~&qRtm7GJtn0NqUruI#`??!BRBMx?LbG6~4Z(=H-$Kt5j>OrMNqSz0rpb@z*6;eHi~=FDN1^ni2c|Q5{UKm&8*iLLs}>$5NtJ^tBcs(A zuCdNe&{y=XPf;;mNBuvrTvBA|#iu9&Z@nLHX^@SHAT+*R`k+}t+^UeS#l^4F+d9Af z`mIdXlhov1nG?@1b<;^RS+pWNSE|C&`Tnw!nu3Ed*yM&}d%@>@Q#4pulj~!$63@Mp z>q_c^k(Jo=T_I!-cl=dA;!G;oKkS zADB9AHG1ER9RqEo%JgoA!9gJ0!ns%m2rdIKL{UW2Yi}Tmcx&x!ZBo@TSyrtG6T4T5 zZ1>rqqfRQZQgOnZ)Rqzkw3W_ibbERhyXwIp+i z&b}USALMw1m)+#>XrZ6N7sLT=W)*6~V079yy>5YH_+#b+9t(w-Hf)RZt0Zy##Cfn- z{;8Iyziz-S0UvHweE?E@q>b8teDQR+zJOput5Eu~{#|BBFH@QV9*h0Ap{n%|@t<>R zI#P<#)H>7tA4E0WES|1uzXK^6Bo8Ar3o$XRI&Cq7wVS|iUr!MA8=Vw{0w@P=SgA}N zy#x-_TkkU;ozMt)Y20#~HQr#h{8!<3h-cNiI+6J--PNfPRIca^+(6iEQvoqqwOV%# z6u<0{ICXiRol*QuZ++SSSi8O67oE1*Cjx9&DdsF2eZR?iACfp8(JcC;Eb=t8*Yg(7 z4W2}DYMkOU@}Q|mFY3&{mw;&ATm(hj%)xRL;13nX1#ZD+giZ7^E98hFX6T zPOi(QtA!W?6f-fgP&lSE1#L{IysMYT#x0}z3VD0q-yY07Skb-uKlyrhKb(cTS1)fE zL3z`bCvKpL7+~SF=7K%cqem3C@Tl`R!*2A3b$wdyE}KL1ah+-wTB7m3reMtV0`Pd% z09Gs6HIiqzJ}q9smG6tE8?PV|G)t^fW#}yGT4YKou@r}|^h>@v5-BbMAO~_~!;d|P zpq4W=k|R}kZu%>PUF+78Q)3%0splsO z)Iay=C?#!QFKfhW-mcbCbT`H*k4LXyC#FP%DF<~h_46lS^AItD%*oOextx0+xtGqA zIw}?=&j5Ivo}bnhn>6EcZJ8T1K}=EHd5}_s=ERHnOyb-i|IH~ zt6K^F90R`~2pf0K4GAy167!yy>$2S&)E{#Pbq!W*&y`BXpHFWxb6*%)F6-?@ zVOI`}h9_^D>8p&jz}f29`J&-{l zAfNMe+rmHCqN?)K;Rw1(s;Hb*ymaVgj@zMEs22Gl*U0`T&f~dW8rw z^r00D6JE0gtf~7B`>p{Ffv$Ot+@cYpzEtaspxKOUDr<(JmzR6k^ z%G_r*ruO3_2NNsrUD!~R_w!9`vTllx&qQxxsZK8&#fh_yV0r<TN_?#4lR(U3 zfD^fWY95nq%M6Azw1(wOZC(PGWEie%o8`v@r^QR!qM};pX0*1dje<&5CetOgg{-knbiBz`P_SA(d~<)|anbjF zxVHi|+wi40NlN0fse|#bXHTC%l@>CgutIj`X&{&+xVN+ZU~J4lghrhaYB?F=7TD0W zaHLkAhYy7oy?kqo%y_4Gps{EE+IEYgSMv67^(0bVfd27y471p1b7B7ay>awDW5)V& z9*H9R2;@=?!{M1G^zsaeF`GR$e&ljN?m&_tA}KDOkF(%Bs~BY8sty89C;4}XrODqP9>+?F zjONnYE@5jYUdQdapUwOS)1LsxhdOx_`L~Hn??1d|(1?WJVenziY&~78lXQqA@|6HT z$zk&f>N{{d68|)>>Nb z{?@a)(9e|B&%{|i-2Px$D%3aoT&*&k{*Z?7?aOVeGyNpotBZ7X?tI<34D8aGoziA6 zyy^==&SY~&9?(kM^w#NGxHvw>Rs(dp&DVWoHy(0-y=aQ`q8s4clwqWy!Chuyc)zaI zp68phEqSOJE;QdIl*N5+Ep4x-tnT09nxgG<&*TJvAU{p zU)j_wX+e?M)XWCi6$j<~QCKt8h(}VBtf~<~nQJDo5E7 zDpuSuu?xiC!|FHl)xqBBkI@rj&*9n2RI1~!upXBAUpPa0(3W3g?ZAG;weDLx3R~XLLYz_we8yzwGPETrPb+{hsFzIj!O5sU5 z1`nSgwV@Ts>yGAqn+`{akcnm3Fzhw?eO+{OTk8wobG9QQu|uFE7E0U|M{}QyKIIXd z8~;;^BN=ym@*X(dbA}^)6e6wTWiZIhWGj41B)t7)s0M=~+fspSj&QD*V5qx0H_D`Y%$w>$SO~!Z7K!Wv3MczQEm33&N=519Gehb6wg^fp_n#vgWu%MK zv*eW~GW`k24E;3W<>f&MMFbQmFd?QWsSP5dNydkNfhfmRuigX%atOBj?YavBG_^i#h5fgiuJVSGKhqQtE;OM#wElfR^{f+QlwCo zr*air_3VwmNcM8eU0arY4fcq;p(Tnm&|m@tLVY*$V;`&TWe&!ni_)hahg(Gzge_Uv z5$%Hy9!R4y%rfXe2??QwEgmN40+)7E)kiI%sC5VvI{B4gLErpULrn`uiOeCDGrr1Y zFkc*%MVuasqD1`ZJ7F(9=-n7iN)nSc%e>U!B22h_m8pjbX4RkqQqs@wAtKRV7Q+Q7 z21^@=(xTBPjiih(f-q`!Zv&|p2OT8PZ3pZaxy~`VdwMKH2P3+9s8iM8S$eSN<&8TR z>66l z1^Q!QVZdi$|CN>hx>4_cPkv`+{BP;o-ip$;f%GsPcT^7NaH##_6k@0f zl9BU$ya1b0a|kG#Pyl$+tl;N+z=kX->i6k}t+9s0W!cob;&P3hu9Y@5eJa*u3YyEa zKpMF!drb`4!GfxYKS9Tu=eL7**>2A3%`KW4(ihUCy}qs^-j=Zj^fYHIyzU>BG&3qD zRN%QMqdN=v=05hJC8++X#0CA)n#UZrI=47bxki1!7f#Ba*qjr|H}+O?U-U-?P@KbG zN$I8!O(Hjm?PLi_(>;;krB}wNRI1rj+Z>!8=fa;Hd?6t%(!CT}_s`P`Df9Kldnh0T z89C{`n}+U^HTTP;qa~{3Eav74^#}MWi{oeP?>Y@?(26)P5IG-Fy>=p63O+AWU?S3F zFl2H?9R?ZLFlTLWoOCLVuQE83|42Y6iefGVD;y_)H1g6+JYOPaI@K|WSUJh=I9(C! z)`S9xBYcTOA({}|!aVIH_%{DFD+a1SrgBgz81wE|rC%T-guAJdSPzE$d=(eEpBx`| z+Dw__SBySu=?&!z(o;r=fgTMu5M~#r$97F!jNeiwifq{xo}XWa>fy@&mpcOs@PhdN zPUB}`XJ?@Q&oq7}W)8OhwhQ9i8`?{0q49C`C@bA4L7XwsD1ksQfe8aifOMaQzz9E1 zTnvN&gy7r6H6sBsBswB7rJBFm!Or#cQ$-EsAHqKxukJ`zu!|M|u?6VGrbU(4MuC~d zO1WuEJT>vo&rMTO@czZC&DX}qS7)^6n#Mn?mNgxZtFewEq5M%{)Ot`Xiz-XZbUcd; z{>yOaIEfaObm{i4dpcNkH1tBYY0Y`H`x;YpcVez))?M}1)h=TtKMT|;^4VXCl&3 zCxZ#6TfP!$1wXLQR&}vm=5g;aNZKN*pt@?S=%{|WFI5ajWk#0X@G@QtX>4^M1fSyE zsnJ=P*-*K@m?i1|D8@cGWU*Ft8E^X7)-kBmA*4%@Vc>MT(;%_AdYu>XMcFTk@sz!gu?z zU^i? zr6eexXFQ&vPb{dZ$K3d&;y%Gq&Z;BxstZGahIsG?)2Y&7o)^e#K{RLZR%0!naCXR* ze5gYh$f$JLV|VP>LfM+=7z&+xGM8BiV`4(|E4jA4Ttvd{=ae@>&KV!HcpKPgmlJBD~YpX%(CwWKFT>h4)m# zYqkoh{5-He`t3i=P1HtV9$8+cd{!7n$;>%Zp>{Z6g+lG*x7<9C>_QJxFNg#XBjkcU za)?b8ulNddi=#RTc|y5vedX(_;vo*8e&cDEB+W(FP6)We-hX)neetXG!)nF5Ca=X< zCVCq+E3OZ*N!l#-dPbLylidb3ifGt>G!L&2V;#07*u(@p?p=o9P2(jre|7Y0_pbqQ zh3aBKiL1QvDl3`b=c>g`1F-^C0bvU|PX(b8!X+q{JaNg}znVca%rfN`&n#p#W7=Ph z|NSc;tCRldNBLe;*W#h=Oe#*zlUGr^bSLx`?_K;B8^I6Ik?wc$PsA&kQu7nBb!LqU zZvVDezuf!}#txazA6M$R(+=+67hjycLfR6uLI#pm|z!(yyEeoX?QV;J7)5{c7PaaAdms%pv!!m0Oh+v)7d11xL z3%sYMO4&>*RI1FVijkjNz;w>`=)hEZWVf9#EP`kChQfpi(*yfrh3*`Dh0 ziE1aoh16Tj^cYsC;0Vah66hOP)Wdz>Qyn_iD*bE?SslJ z-fDwW6x-+hdhz0%gx!MPfbi$b-``vJ9P5W#>$5COL|)WHSJ6E!B4{`$W-P?RVwr{p z(+Ucsz=c!L5186ba%U9UHL#cpm6_Ttw9u59rg)f29*=Jwb41HSB|Wt#Acas59KdfNU-{7HLQVX05jm{jNSVf6s|4GVYY?tho0S%UvNru+r(GZJZtet(&nE0*!}a>NfT=;!9BvXY1HY}^4o)7 zh*5Mo8f)d?@`s((+rKb7kB?SBvvI1uGHX>-VNaK%QZ6QchNeKOSE_GlV6X4~@*N_8 zumq;D>cD_ESFY;7xGGgAI$D8nh2T~>=d*pQ)YhIzEwVFO>9?tZ4;#3-uu^QyIjDT- zSZbpB{h9qU(&VEMxiawKr{oNM(kTC#1@HcXwO2-C<-+zP9K(XPfyHu-#lV+we!LHo?IrJK5_9{A?EjrBNEkP3)y@&U3*^|}Vn-u#x%wBr_b4GBU zNgep{%#z9;e7XQ!q#KjYoW|ZG*MoED{{=umzrU*%mA&0i;8KG;VpO)4?6x=$p>A+u zenV4BZH+_BUXy0DOgm+FoPyraIAUgY_yh}2IrJPgEO0f<*LOi@n6)v%dc5l zJJ}SmLOh(x_g-C7gO)UZF<+`WwX;pXI4WJva);A8;?wv=)uA(o-H$WC&s@-Q5@U42ElL3wcdXxMrkzs|)L9Z&>g*_V?($ z9kjX+F�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 — create new LVGL tree - 11. lv_indev_wait_release() — discard any touch still in-flight - -teardown() (after the window is removed) - └─ encoderStop() — stop poll timer, LEDs off, lcdStop() - └─ delete uiTimer; stopAllModeTimers() - └─ stop/reset the Sequencer - └─ stop BLE/USB, unsubscribe BT events, release device references - └─ resetAllState() — reset all state structs and UI pointers -``` - -### Why `lv_indev_wait_release` is called twice - -A tap on a home tile fires `switchToMode`. The finger-up event from that same tap can land on a button in the freshly built view if both share the same screen coordinates. The first `wait_release` runs before view construction but LVGL may still queue the event. A second call after construction ensures it is discarded before any button in the new view can process it. - ---- - -## State management - -Each mode has its own state struct nested inside `MidiController` (e.g. `RagaState raga_`). Data fields (bpm, notes, parameters) **persist** across mode switches; LVGL widget pointers are **nulled** before the old tree is destroyed and **re-populated** when the new view is built. - -`resetAllState()` (called from `teardown()`) resets every per-mode state struct and its UI pointers to defaults. - ---- - -## MIDI transport - -MIDI is sent over either Bluetooth LE or USB. On launch the app shows a transport chooser (unless BLE MIDI is already running). The toolbar `USB`/`BLE` button reopens the chooser while sending is stopped. The choice is stored in `activeTransport_` for the session. - -The toolbar switch calls `handleTransportToggle(true/false)`, which starts or stops the selected transport. - -**BLE:** uses Tactility's `bluetooth_midi` driver. -1. If the BT radio is off, `bluetooth_set_radio_enabled(true)` is called and `startBleMidi()` runs from the `BT_RADIO_STATE_ON` event callback. -2. `bluetooth_midi_start()` enables the MIDI profile; the device advertises as **TT MIDI**. -3. `sendMidi(cmd, d1, d2)` calls `bluetooth_midi_send()`. - -**USB:** `startUsbMidi()` calls `usb_midi_device_start()` and `sendMidi()` calls `usb_midi_device_send()`. - -On stop or hide, `CC 123` (All Notes Off) is sent before the transport is stopped. Every `*_get()` helper returns a referenced `Device`, so each caller pairs it with `device_put()`. - ---- - -## LVGL patterns used - -### Canvas-style drawing -ZEN, DROP, LFO, and XY draw into a plain container via `LV_EVENT_DRAW_MAIN`; TB3PO and EUCLID build child widgets (labels, lines, rings) inside their container and rebuild them when the pattern changes. No `lv_canvas` buffer is allocated — the container is freed with the view tree. - -### Touch handling -KEYS uses `LV_EVENT_PRESSING` on the piano row plus per-key `PRESSED`/`RELEASED` handlers so a finger can slide across keys. XY uses `LV_EVENT_PRESSING` on the pad and derives CC values from finger X/Y. GRID uses per-cell `PRESSED`/`RELEASED` handlers with one held note per pointer slot. - -### Encoder poll timer -`EncoderInput.cpp` runs a single 100 ms LVGL timer. On each tick: -1. Check `btnSeedNext` — if set, read buttons silently to seed `btnPrev` and return. -2. Route to the per-mode dispatch function based on `currentMode_`. -3. Dispatch reads deltas + buttons, updates state, refreshes LVGL labels, flushes LEDs, calls `lcdUpdate`. - -### LED dirty-caching -`Unit8Encoder::flushLeds` maintains a 9-entry colour cache (8 encoder LEDs + 1 switch LED). It sends the 24-byte encoder-LED burst only if one of those eight changed, and writes the switch LED separately only if that entry changed. - ---- - -## MIDI messages sent - -| Command | Hex | Used for | -|---------|-----|----------| -| Note On | `0x90` | All melodic/drum note triggers | -| Note Off | `0x80` | All note releases | -| Control Change | `0xB0` | XY pad (CC 74/71 default), LFO modulation, All Notes Off (CC 123) | -| Pitch Bend | `0xE0` | LFO in pitch-bend mode | - -All messages are sent on channel 1 (command bytes `0x90`/`0x80`/`0xB0`/`0xE0` without channel offset). diff --git a/Apps/MidiController/docs/encoder.md b/Apps/MidiController/docs/encoder.md deleted file mode 100644 index 894bfb5..0000000 --- a/Apps/MidiController/docs/encoder.md +++ /dev/null @@ -1,158 +0,0 @@ -# 8Encoder - -The M5Stack 8Encoder V2 unit normally connects via **PaHub CH4**, though the app probes every PaHub channel and accepts it on any port. It provides 8 rotary encoders with push-buttons, individually addressable RGB LEDs, and one toggle switch with its own LED. - -The encoder is completely optional. If the PaHub or the encoder unit is not detected, the app operates in touch-only mode with no degradation. - ---- - -## Per-mode mappings - -Only five modes use the encoder. In all other modes the encoder is idle (LEDs off). - -### RAGA - -| Control | Action | -|---------|--------| -| Knob 0 | Raga selection (0–7, wraps) | -| Knob 1 | Tala selection (0–2, wraps) | -| Knob 2 | Root note (MIDI 24–96) | -| Knob 3 | BPM (40–240) | -| Knobs 4–7 | Unused | -| Button 0 | Play / Stop toggle | -| Button 1 | Drone On / Off toggle | -| Buttons 2–7 | Unused | - -**LED colours:** - -| Knob | Colour | Meaning | -|------|--------|---------| -| 0 | Orange `0xFF8800` | Raga | -| 1 | Deep orange `0xFF6600` | Tala | -| 2 | Cyan `0x00FFCC` | Root | -| 3 | Yellow `0xFFE000` | BPM | -| 4–7 | Off | — | - ---- - -### DIMENSIONS - -| Control | Action | -|---------|--------| -| Knob 0 | Parameter A (0–100) | -| Knob 1 | Parameter B (0–100) | -| Knob 2 | Parameter C (0–100) | -| Knob 3 | Parameter D (0–100) | -| Knob 4 | Equation (1–20) | -| Knob 5 | BPM (40–280) | -| Knob 6 | Octave offset (−4 to +4) | -| Knob 7 | Random amount (0–64) | -| Button 0 | Play / Stop toggle | -| Button 1 | Reset (A/B/C/D → 50, t → 0) | -| Buttons 2–7 | Unused | - -**LED colours** (match the UI label colours): - -| Knob | Colour | Parameter | -|------|--------|-----------| -| 0 | `0x88DDFF` (light blue) | A | -| 1 | `0x88FFCC` (mint) | B | -| 2 | `0xFFCC88` (peach) | C | -| 3 | `0xFF88CC` (pink) | D | -| 4 | `0xFF8844` (orange) | Equation | -| 5 | `0xFFE000` (yellow) | BPM | -| 6 | `0xAAFF88` (lime) | Octave | -| 7 | `0xFFAA44` (amber) | Random | - ---- - -### TB3PO - -| Control | Action | -|---------|--------| -| Knob 0 | Density (0–16) | -| Knob 1 | BPM (40–280) | -| Knob 2 | Key root (0–11, wraps) | -| Knob 3 | Octave (1–6) | -| Knob 4 | Scale selection (6 options, wraps) | -| Knob 5 | Regen (any delta draws a new random seed) | -| Knobs 6–7 | Unused | -| Button 0 | Play / Stop toggle | -| Button 1 | Regen (new random seed and pattern) | -| Buttons 2–7 | Unused | - -**LED colours:** While playing, animate with the current 32-step playback position. The steps are grouped into eight 4-step ranges: knob 0 = steps 1–4, knob 1 = steps 5–8, … knob 7 = steps 29–32. The knob for the active range is lit orange `0xFF6600`; the other seven are dark `0x221100`. All eight are dark while stopped. - ---- - -### BABY8 - -| Control | Action | -|---------|--------| -| Knob 0 | Step 1 note (0–127) | -| Knob 1 | Step 2 note | -| Knob 2 | Step 3 note | -| Knob 3 | Step 4 note | -| Knob 4 | Step 5 note | -| Knob 5 | Step 6 note | -| Knob 6 | Step 7 note | -| Knob 7 | Step 8 note | -| Button 0 | Toggle gate for step 1 | -| Button 1 | Toggle gate for step 2 | -| … | … | -| Button 7 | Toggle gate for step 8 | - -**LED colours:** -- Current playback step (while playing): white `0xFFFFFF` -- Gate on (not current): dark green `0x004400` -- Gate off: dark red `0x440000` - ---- - -### EUCLID - -| Control | Action | -|---------|--------| -| Knob 0 | Selected voice — Steps (1–32) | -| Knob 1 | Selected voice — Events (0–steps) | -| Knob 2 | Selected voice — Rotation (0–steps-1, wraps) | -| Knob 3 | Selected voice — Note (0–127) | -| Knob 4 | BPM (40–280) | -| Knob 5 | Voice 6 — Steps | -| Knob 6 | Voice 7 — Steps | -| Knob 7 | Voice 8 — Steps | -| Button 0 | Select voice 1 | -| Button 1 | Select voice 2 | -| … | … | -| Button 7 | Select voice 8 | - -**LED colours** (per-voice accent colours): - -| Knob/Button | Colour | Voice | -|-------------|--------|-------| -| 0 | `0xFF4040` Red | Voice 1 | -| 1 | `0xFF9900` Orange | Voice 2 | -| 2 | `0x44DD44` Green | Voice 3 | -| 3 | `0x44BBFF` Blue | Voice 4 | -| 4 | `0xFF44FF` Magenta | Voice 5 | -| 5 | `0xFFDD00` Yellow | Voice 6 | -| 6 | `0x00FFCC` Cyan | Voice 7 | -| 7 | `0xFF8888` Pink | Voice 8 | - -Selected voice LED is at full brightness; all others are dimmed to ¼ intensity (`>> 2` each channel). - ---- - -## Button edge detection and mode-switch safety - -Buttons use a rising-edge detector: `if (btn && !btnPrev[i])`. This means holding a button across a mode switch will not register as a press in the new mode. - -On each mode switch `btnSeedNext` is set. The very next encoder poll reads the live button state silently into `btnPrev` without firing any actions, then returns. This seeds the edge detector correctly regardless of which buttons are physically held. - ---- - -## Poll rate - -The encoder poll timer fires every **100 ms** (10 Hz). This is a balance between responsiveness and I2C bus traffic: each poll requires up to 16 individual I2C reads (8 deltas + 8 buttons) across the PaHub multiplexer. - -Delta values accumulate in the encoder hardware between polls, so fast knob turns are not lost — the delta register counts all movement since the last read. diff --git a/Apps/MidiController/docs/hardware.md b/Apps/MidiController/docs/hardware.md deleted file mode 100644 index 25fa0701..0000000 --- a/Apps/MidiController/docs/hardware.md +++ /dev/null @@ -1,125 +0,0 @@ -# Hardware - -## Target device — M5Stack Tab5 - -| Property | Value | -|----------|-------| -| SoC | ESP32-P4 | -| Display | 1280×720 MIPI DSI (built-in) | -| OS | Tactility RTOS / FreeRTOS | -| Port A | Grove port in I2C mode — SDA=53, SCL=54 | - -All external hardware hangs off **Port A** via the PaHub multiplexer. - ---- - -## Wiring - -``` -Tab5 Port A - └─ PaHub (TCA9548A, 0x70) - ├─ CH4 ── 8Encoder Unit (0x41) - └─ CH5 ── LCD Unit (0x3E) -``` - -The app probes all six PaHub channels and uses the first matching unit it finds, so CH4/CH5 are the usual wiring rather than a hard requirement. - -Both units are **optional**. If the PaHub is not detected the app operates in touch-only mode. -If the PaHub is present but a unit is missing, that unit is silently disabled. - ---- - -## PaHub channel map - -| Channel | Unit | I2C address | -|---------|------|-------------| -| CH4 | 8Encoder V2 (typical) | 0x41 | -| CH5 | LCD Unit (135×240, typical) | 0x3E | -| CH0–3 | unused / available | — | - -The app scans CH0–CH5 at startup, so either unit can be moved to another free channel. - ---- - -## I2C unit summary - -### 8Encoder V2 (0x41) -8 rotary encoders with push-buttons, individually addressable RGB LEDs (3-byte RGB), and one toggle switch with its own LED. - -Key registers: - -| Register | Description | -|----------|-------------| -| `0x20 + i*4` | Counter delta since last read (int32_t, 4 pulses/detent) | -| `0x50 + i` | Button state (0 = released, non-zero = pressed) | -| `0x60` | Toggle-switch state (0 = off, 1 = on) | -| `0x70 + i*3` | Encoder LED RGB (3-byte stride, `i` = 0–7) | -| `0x88` | Toggle-switch LED RGB (3 bytes) | - -### PaHub v2 (0x70 — TCA9548A) -TCA9548A-based I2C multiplexer with **6 exposed channels (CH0–CH5)**; the chip itself supports 8, but only six ports are wired on the unit. Write a single bitmask byte to select a channel; write `0x00` to deselect all. - -``` -select(ch) → write(1 << ch) -deselect() → write(0x00) -``` - -### LCD Unit (0x3E — ESP32-PICO inside) -135×240 IPS display (portrait default). The internal ESP32 bridges I2C commands directly to an ST7789V2. -Unlike the STM32-based units it uses a **command-buffer protocol** — no STOP+delay pattern needed. - -Key commands: - -| Command | Description | -|---------|-------------| -| `0x22 [brightness]` | Set backlight (0=off, 255=max) | -| `0x36 [rotation]` | Set rotation (0–3 = 0°/90°/180°/270°) | -| `0x6A [x0][y0][x1][y1][hi][lo]` | Fill rectangle with inline RGB565 colour | -| `0x62 [x][y][hi][lo]` | Draw single pixel RGB565 | -| `0x2A [x0][x1]` / `0x2B [y0][y1]` | Set active window (column / row range) | -| `0x42 [pixels…]` | Write raw RGB565 stream into active window | -| `0x09` | Read buffer-remaining count (1-byte response) | - -Used in portrait orientation (rotation 0): **135 px wide × 240 px tall**. - -### ByteButton (0x47) — available, not wired -8 push-buttons with 4-byte LE RGB888 LEDs per button. - -| Register | Description | -|----------|-------------| -| `0x00` | Button bitmask (bit i = button i) | -| `0x60 + i` | Individual button state | -| `0x20 + i*4` | LED colour [B, G, R, 0x00] | -| `0x19 = 0x00` | Show-mode register (must be written on init) | - -### Joystick2 (0x63) — available, not wired -Dual-axis thumbstick with button and RGB LED. - -| Register | Description | -|----------|-------------| -| `0x50` | XY 12-bit offset-corrected (2×int16) | -| `0x20` | Button (0 = pressed) | -| `0x30` | LED LE uint32 RGB | - -### Scroll (0x40) — available, not wired -Single rotary encoder with button and RGB LED. - -| Register | Description | -|----------|-------------| -| `0x50` | Delta since last read (int16_t, resets on read) | -| `0x20` | Button (0 = pressed) | -| `0x30` | LED [0x00, R, G, B] | -| `0x40` | Reset encoder counter (write 1) | - ---- - -## I2C protocol notes - -**STM32-based units** (ByteButton, Joystick2, Scroll, 8Encoder): -- Use a **write-then-read** pattern with a mandatory 2 ms delay between the address write (STOP) and the data read. -- A repeated-START without the STOP causes the STM32 HAL to return stale data. -- Helper: `unitReadReg()` in `UnitCommon.h` handles this automatically. - -**ESP32-based units** (LCD Unit): -- Standard I2C — no delay needed. Each write is a self-contained command. -- Pixel streaming ends at the I2C STOP condition. diff --git a/Apps/MidiController/docs/lcd.md b/Apps/MidiController/docs/lcd.md deleted file mode 100644 index c0a4ae1..0000000 --- a/Apps/MidiController/docs/lcd.md +++ /dev/null @@ -1,162 +0,0 @@ -# LCD Unit Display - -The M5Stack LCD Unit (135×240 IPS, portrait) normally connects via **PaHub CH5**, though the app probes all six PaHub channels and accepts it on any port. It shows a live secondary display that mirrors and extends the current mode's state. - -The LCD is completely optional. If the PaHub or the LCD unit is not detected, `lcdUpdate` is a no-op throughout. - ---- - -## General layout - -The display is **135 px wide × 240 px tall** in portrait orientation (rotation 0). - -Every layout starts with a **16 px coloured header bar** (plus a 1 px separator) at the top. For the five encoder modes it shows the mode name in 2× scale text, using that mode's accent colour; other modes show a generic `MIDI` header. - -A **5×7 bitmap font** (printable ASCII 32–126) is used for all text rendering. Two scale factors are used: -- **1× scale** — 5×7 px glyphs (very compact, for labels and values) -- **2× scale** — 10×14 px glyphs (for names and primary values) - -Colours are RGB565. The background is always black (`0x0000`). - ---- - -## Per-mode displays - -### HOME / Other non-encoder modes - -``` -┌─────────────────┐ -│ MIDI │ ← 2× text on grey background (16 px) -│ │ -│ │ -│ (blank) │ -└─────────────────┘ -``` - -Shows the generic `MIDI` header. The content area is blank (these modes don't use the encoder or LCD detail view). - ---- - -### RAGA - -``` -┌─────────────────┐ y=0 -│ RAGA │ header (16 px, orange) -│ │ -│ Bhairavi │ ← raga name, 2× orange (y=22) -│ │ -│ TALA Teental │ y=68 -│ ROOT C3 120 │ y=82 (root + BPM on same row) -├─────────────────┤ y=98 -│ PLAYING │ ← red/dark 28 px bar (y=100) -│ DRONE ON │ ← blue/dark 20 px bar (y=132) -│ │ -│ K1 RAGA │ ← knob legend (1× text, y=170) -│ K2 TALA │ -│ K3 ROOT │ -│ K4 BPM │ -│ B1 PLAY │ -│ B2 DRON │ -└─────────────────┘ -``` - -**Dirty fields:** Raga name, tala, root note, BPM, play state, drone state — each redraws only when its value changes. - ---- - -### DIMENSIONS - -``` -┌─────────────────┐ -│ DIM │ header (cyan) -│ A 50 B 50 │ ← labels + values, y=20 -│ [████] [████] │ ← filled colour bars, y=30–38 -│ C 50 D 50 │ ← y=50 -│ [████] [████] │ ← bars, y=60–68 -│ EQ 3 BPM 120│ ← y=82 -│ OCT +0 RND 0 │ ← y=96 -├─────────────────┤ y=110 -│ STOPPED │ ← green=playing, dark=stopped (y=112) -└─────────────────┘ -``` - -A/B share the top row (x=2 and x=67); C/D share the next row. Each has a colour bar showing the 0–100 value as a filled rectangle. - -**Bar colours** match the encoder LED and UI label colours: A=`#88DDFF`, B=`#88FFCC`, C=`#FFCC88`, D=`#FF88CC`. - ---- - -### TB3PO - -``` -┌─────────────────┐ -│ TB-3PO │ header (green) -│ ┌──┬──┬──┬──┐ │ ← 32-step piano roll (2 rows of 16) -│ │ │██│ │ │… │ each step: 6×12 px cell -│ └──┴──┴──┴──┘ │ white=current (while playing), green=gate on, dark=no gate -│ │ yellow accent dot = accent step -│ KEY C SCALE Major│ -│ DEN 8 BPM 120 │ -├─────────────────┤ -│ STOPPED │ -└─────────────────┘ -``` - -The piano roll starts at y=20, with two rows on a 15 px pitch (cells are 6×12 px). Row 0 = steps 0–15, row 1 = steps 16–31. Each cell has a 2 px gap between neighbours. Accent steps show a small yellow dot at the top-left corner. - ---- - -### BABY8 - -``` -┌─────────────────┐ -│ BABY8 │ header (magenta) -├─────────────────┤ -│ 1 C4 [■] │ ← step row 27 px tall; note name 2×, gate dot -│ 2 D4 [■] │ white bg = current step (gate on) -│ 3 E4 [ ] │ white bg + dark dot = current step (gate off) -│ 4 F4 [■] │ -│ 5 G4 [■] │ -│ 6 A4 [■] │ -│ 7 B4 [■] │ -│ 8 C5 [■] │ -└─────────────────┘ -``` - -Each step gets 25 px of height (27 px total with a 2 px separator line). The step number is on the left, note name (2×) in the centre-left, and a filled gate dot on the right. While playing, the current step has a white background with black text; when its gate is off the gate dot is dark instead of green. No step is highlighted while stopped. - ---- - -### EUCLID - -``` -┌─────────────────┐ -│ EUCLID │ header (red) -│ [1][2][3][4][5][6][7][8] ← voice tabs y=18–33, 15×15 px -│ VOICE 3 │ ← selected voice name, 2× in voice colour (y=38) -│ │ -│ STEPS 8 │ ← y=80 -│ EVENTS 5 │ ← y=94 -│ ROT 2 │ ← y=108 -│ NOTE C2 │ ← y=122 -│ BPM 120 │ ← y=140 -├─────────────────┤ y=154 -│ STOPPED │ ← y=156–174 -│ K1 STEPS │ ← knob legend y=176, 10 px rows -│ K2 EVENTS │ -│ K3 ROT │ -│ K4 NOTE │ -│ K5 BPM │ -│ B VOICE │ -└─────────────────┘ -``` - -Voice tabs start at y=18 with a 17 px pitch. The selected voice tab is drawn at full colour; others are dark grey. When the voice changes, the name, all parameter values, and the knob legend are all redrawn. - ---- - -## Dirty-caching - -`lcdUpdate` is called from the 100 ms encoder poll for the five encoder modes, and immediately on mode switch. Each mode maintains a cache of the last-drawn values. Only fields that have changed are redrawn, keeping bus traffic low. - -On a mode switch `lcdSetMode` clears the per-mode cache structs, seeds their change sentinels, and sets `s_dirty = true`, forcing a full screen redraw on the next `lcdUpdate` call. diff --git a/Apps/MidiController/docs/modes.md b/Apps/MidiController/docs/modes.md deleted file mode 100644 index 30e936d..0000000 --- a/Apps/MidiController/docs/modes.md +++ /dev/null @@ -1,300 +0,0 @@ -# Modes - -The home screen shows a 3×5 grid of tiles. Tapping any tile switches to that mode. -Tap the **←** back button (top-left of most views) to return home. - ---- - -## KEYS — Chromatic Piano - -Single octave of piano keys. Drag your finger across keys for continuous glide. Multiple fingers play independently; each finger tracks its own note. - -**Parameters:** -- Octave (1–8) — base octave of the keyboard -- Scale / key root — shown in the status and control rows only; the keyboard stays fully chromatic and is not transposed - -**Controls:** OCT− / OCT+ buttons; SCALE cycle; KEY− / KEY+ - -**MIDI:** Note On on finger contact, Note Off on release. Each touch slot holds and slides its own note; lifting one finger does not release the others. - -**Scale/key controls** use the same scale table as RNG, ZEN, and DROP, but KEYS keeps its own selection and does not apply it to the keyboard. CHORD uses the same scale table with its key fixed at C — see [scales.md](scales.md). - ---- - -## BEATS — 4-Track Drum Sequencer - -Classic 4×16 step sequencer. Steps loop continuously while playing. - -**Tracks:** -| Track | Default MIDI note | Colour | -|-------|------------------|--------| -| KICK | 36 (C2) | Red | -| SNARE | 38 (D2) | Yellow | -| HIHAT | 42 (F#2) | Cyan | -| OPEN | 46 (A#2) | Purple | - -**Controls:** Tap any step cell to toggle it. PLAY/STOP, CLEAR (stops playback and turns all steps off), BPM− / BPM+. - -**Note duration:** Each track has a fixed note-off delay (KICK 200 ms, SNARE 150 ms, HIHAT 50 ms, OPEN 300 ms). - ---- - -## ZEN — Ambient Physics - -Up to 4 balls bounce around a rectangular arena. Each wall segment triggers a scale note on collision. - -**Wall layout:** 8 top segments, 8 bottom segments, 4 left segments, 4 right segments (24 total). Each segment maps to a scale degree; walls flash white briefly on hit. - -**Controls:** ADD (spawn ball), RESET (remove all but one), SCALE cycle, KEY± , OCT. - -**Parameters:** Scale, key, octave (2–6), ball count (1–4). - -**MIDI:** Random velocity (70–109). Note Off follows 150 ms after each Note On; walls flash for 200 ms on impact. - ---- - -## DROP — Gravity Platform Sequencer - -Balls roll and fall under gravity, triggering notes when they land on platforms. - -Two modes — **EDIT** (tap to place horizontal platforms) and **DROP** (tap to spawn balls). - -**Controls:** Toggle EDIT/DROP, CLEAR (removes balls and platforms), scale/key/octave. - -**Parameters:** Scale, key, octave (2–7), up to 6 platform positions. Platforms are always horizontal and receive a note automatically from the selected scale by platform index (degrees 0, 2, 4, 6, 1, 3). - ---- - -## RNG — Random Melody Generator - -Generates random melodies constrained to a scale, key, octave range, and probability. - -**Parameters:** -| Parameter | Range | Default | -|-----------|-------|---------| -| BPM | 40–300 | 120 | -| Subdivision | ¼ / ⅛ / 1/16 | ¼ | -| Min octave | 1–8 (≤ max) | 3 | -| Max octave | 1–8 (≥ min) | 6 | -| Probability | 0–100 % | 50 % | -| Scale | 6 options | Major | -| Key | C–B | C | - -**Controls:** PLAY/STOP, BPM± (step 5), KEY±, SCALE cycle, PROB± (step 5), MinOct±, MaxOct±, Subdivision cycle. - -On each tick, a note is chosen randomly from `(scale, key, octave range)` with the given probability. It plays for 75 % of the step duration, then Note Off. - ---- - -## XY — 2D CC Pad - -Full-screen touch pad. Finger X maps to one CC, finger Y maps to another. - -**Parameters:** -| Axis | Default CC | Suggested use | -|------|-----------|---------------| -| X | 74 | Filter cutoff | -| Y | 71 | Resonance | - -**Controls:** X CC± / Y CC± buttons to change target CC numbers (0–127). - -MIDI CC is sent continuously while the finger is held. Values range 0–127 across the full pad width/height. - ---- - -## ARP — Chord Arpeggiator - -Touch a note on the mini piano to latch an arpeggio of the selected chord type at the selected pattern and speed. - -**Chord types:** Major, Minor, Dominant 7th. - -**Patterns:** UP, DOWN, UP-DOWN, RANDOM, CHANCE (probabilistic skips). - -**Speed:** Quarter / Eighth / Sixteenth / 32nd note. - -**Parameters:** BPM, arpeggio pattern, chord type, octave range (1–4 octaves), piano octave (1–7). - -**Controls:** Tap piano key (bottom row) to start arp. Pattern/Chord/OctRange/Speed/BPM controls at top. - ---- - -## GRID — Isomorphic Grid - -8 columns × 5 rows. Each row is offset by a perfect 4th (5 semitones) from the row below — the same layout as a Linnstrument. Every cell adds one semitone across the row. - -**Note layout:** -``` -Row 4 (top): root + 20 … 27 semitones -Row 3: root + 15 … 22 -Row 2: root + 10 … 17 -Row 1: root + 5 … 12 -Row 0 (bot): root + 0 … 7 (chromatic: C, C#, D, D#, E, F, F#, G at octave 3) -``` - -**Cell colours:** C notes bright cyan; black-key notes dark blue; white-key notes medium blue; pressed cells bright cyan. - -**Controls:** OCT− / OCT+ (octave 1–6). - -Cells show the note name. Touch for Note On, release for Note Off. Multi-touch is supported: each pointer slot tracks its own held cell, so lifting one finger does not release notes held by the others. - ---- - -## CHORD — Diatonic Chord Player - -8 large buttons play chords built on the selected scale, with the root fixed at C. Chord VIII is chord I one octave up. - -**Chord voicings:** MAJ (root+M3+P5), MIN (root+m3+P5), DIM (root+m3+dim5). The chord qualities follow the major-scale degree pattern: I/IV/V major, ii/iii/vi minor, vii diminished, I+ major. The selected scale supplies the chord roots. - -**Controls:** Press a chord button to play; release to stop. OCT± / SCALE cycle / CLEAR. - -**Parameters:** Octave (1–7), scale. The key is fixed at C. - ---- - -## LFO — Low-Frequency Oscillator - -Continuously modulates a MIDI CC (or pitch bend) with a selectable waveform. - -**Waveforms:** Sine, Triangle, Square, Sawtooth. - -**Parameters:** -| Parameter | Range | Default | -|-----------|-------|---------| -| Rate | 0.1–10 Hz | 1 Hz | -| Amount | 0–127 | 64 | -| CC target | 0–127 | 1 (modwheel) | -| Mode | CC / Pitch | CC | - -**Controls:** START/STOP, Rate±, WAVE cycle, Amount±, CC±, PITCH mode toggle. - -In **Pitch** mode, a 14-bit pitch-bend message centred at 8192 is used instead of a CC. The actual semitone range depends on the receiver's pitch-bend setting (commonly ±2 semitones). The waveform is drawn live on the canvas. - ---- - -## RAGA — Indian Classical Generator - -Generates melodic phrases based on 8 classical Indian ragas with tala (rhythmic cycle) structure and an optional drone note. - -**Ragas:** -| # | Name | Character | -|---|------|-----------| -| 0 | Bhairavi | Morning, devotional | -| 1 | Lalit | Pre-dawn | -| 2 | Bhupali | Evening, peaceful | -| 3 | Todi | Morning, intense | -| 4 | Madhuvanti | Afternoon | -| 5 | Meghmalhar | Monsoon | -| 6 | Yaman | Evening, romantic | -| 7 | Malkauns | Night, serious | - -**Talas:** -| # | Name | Beats | -|---|------|-------| -| 0 | Teental | 16 | -| 1 | Rupak | 7 | -| 2 | Jhaptal | 10 | - -**Parameters:** Raga (0–7), tala (0–2), root note (MIDI 24–96), BPM (40–240). - -**Controls:** RAGA±, TALA±, ROOT±, BPM±, PLAY/STOP, DRONE toggle. - -**How it works:** Bhairavi phrases use a 2nd-order Markov chain; the other seven ragas use a degree random walk. A new pitch is emitted every 16th note (BPM/4), while the selected tala supplies the beat-accent cycle. The drone note (root) sustains independently of the melody. - -**Encoder:** Knob 0=raga, 1=tala, 2=root, 3=BPM; Button 0=play/stop, Button 1=drone. - ---- - -## DIMENSIONS — Parametric Equation Sequencer - -20 parametric equations map 4 parameters (A, B, C, D) and a running time variable (t) to a 3D position (px, py, pz). The position is quantised to a MIDI note each tick. - -**Parameters:** -| Param | Range | Description | -|-------|-------|-------------| -| Equation | 1–20 | Selects the formula | -| A, B, C, D | 0–100 | Equation coefficients | -| Octave | −4 to +4 | Note range offset | -| Random | 0–64 | Adds stochastic perturbation | -| BPM | 40–280 | Tick rate | - -**Controls:** Equation±, PLAY/STOP, RESET (resets A/B/C/D to 50 and t to 0), BPM±, OCT±, Rnd±. - -**Equation variety:** Includes Lissajous figures, chaotic attractors, Fourier synthesis, and other 3D parametric forms. Each produces a different melodic contour as t advances. - -**Encoder:** Knobs 0–3 = A/B/C/D; Knob 4 = equation; Knob 5 = BPM; Knob 6 = octave; Knob 7 = random; Button 0 = play/stop; Button 1 = reset. - ---- - -## BABY8 — 8-Step Melodic Sequencer - -Classic Baby-8 / analogue-sequencer style. 8 steps, each with a note, velocity, and gate. Up to 8 patterns. - -**Step display:** -| State | Colour | -|-------|--------| -| Current playing step, gate on | White | -| Current playing step, gate off | Blue | -| Selected (not playing), gate on | Orange | -| Selected (not playing), gate off | Brown | -| Other step, gate on | Dark green | -| Other step, gate off | Dark grey | - -**Parameters per step:** Note (0–127), velocity (0–127), gate (on/off). - -**Global parameters:** BPM (40–280), swing (50–80 %), active pattern (1–8, stored as index 0–7). - -**Controls:** PLAY/STOP, BPM±, SWING±, tap step to select, NOTE±, VEL±, GATE toggle, PATTERN±. - -**Swing:** Lengthens even-numbered steps and shortens odd-numbered steps by the same amount, so the pair still fills two 16th notes and the tempo stays constant. At 50 % there is no swing; at 80 % the groove is heavy. - -**Encoder:** Knobs 0–7 = step note (by index); Buttons 0–7 = toggle step gate. - ---- - -## TB3PO — 303-Style Acid Bass - -Procedurally generated 32-step bass patterns in the spirit of the Roland TB-303. - -**Step properties:** Note degree, octave shift (up/down), accent (higher velocity), slide (legato transition into the next gated step), gate (active/silent). - -**Parameters:** -| Parameter | Range | Default | -|-----------|-------|---------| -| Density | 0–16 | 8 | -| BPM | 40–280 | 120 | -| Key | C–B | C | -| Octave | 1–6 | 3 | -| Scale | 6 options | Major | -| Seed | uint32 (randomized on REGEN) | 12345 | - -**Controls:** PLAY/STOP, REGEN (new random seed and pattern), Density±, BPM±, KEY±, SCALE cycle. Octave has no touch control; use encoder knob 3. - -**Pattern generation (REGEN):** An LCG PRNG seeded by the current seed value generates note degrees, gate probability (higher density = more gates), accents, and slides. REGEN and density changes draw a new random seed; key and scale changes reuse the current seed. The same seed and parameters always produce the same pattern. - -**Slide:** A slide flag holds the step's note past its nominal 90%-gate Note Off. If the next step is gated, that step's Note On is sent before the old note's Note Off, so a mono synth hears a legato transition. The old note is then released so a poly synth does not leave it hanging. If the next step is silent, the held note ends at the step boundary. - -**Encoder:** Knob 0=density, 1=BPM, 2=key, 3=octave, 4=scale, 5=regen (any delta triggers regen); Button 0=play/stop, Button 1=regen. - ---- - -## EUCLID — 8-Voice Euclidean Sequencer - -8 independent voices, each with a Euclidean rhythm pattern. All voices share a global BPM and step clock. The canvas shows 8 concentric rings (one per voice) with a rotating sweep line indicating the current position. - -**Euclidean patterns:** A bucket (Bresenham-style) accumulator distributes `events` triggers as evenly as possible across `steps` total slots. Before rotation the first trigger lands on step 0; rotation shifts the whole pattern. For example, E(3,8) with rotation 0 produces `[1,0,0,1,0,0,1,0]` (triggers on steps 0, 3, and 6). - -**Per-voice parameters:** -| Parameter | Range | Description | -|-----------|-------|-------------| -| Steps | 1–32 | Total pattern length | -| Events | 0–steps | Number of triggers | -| Rotation | 0–steps-1 | Pattern phase offset | -| Note | 0–127 | MIDI note triggered | - -**Global parameters:** BPM (40–280), triplet mode (2/3 timing). - -**Voice colours:** Each voice has a distinct colour both on the canvas ring and its tab button. - -**Controls:** PLAY/STOP, TRIPLET toggle, BPM±; per-voice: tap voice tab to select, Steps±, Events±, Rotation±, Note±. - -**Encoder:** Knob 0=sel.steps, 1=sel.events, 2=sel.rotation, 3=sel.note, 4=BPM; Knobs 5–7=steps for voices 6–8; Buttons 0–7 = select voice. diff --git a/Apps/MidiController/docs/scales.md b/Apps/MidiController/docs/scales.md deleted file mode 100644 index 854f650..0000000 --- a/Apps/MidiController/docs/scales.md +++ /dev/null @@ -1,73 +0,0 @@ -# Scales and Keys - -Defined in `MidiScales.h`. The shared scale/key helpers are used for note generation by RNG, ZEN, DROP, and CHORD (CHORD always uses a C root). KEYS keeps a scale/key selection for its status row but remains fully chromatic. TB3PO has its own internal six-scale table; RAGA uses raga-specific pitch sets. - ---- - -## Scales - -| Index | Name | Intervals (semitones from root) | Notes | -|-------|------|---------------------------------|-------| -| 0 | Major | 0 2 4 5 7 9 11 | 7 | -| 1 | Minor (natural) | 0 2 3 5 7 8 10 | 7 | -| 2 | Dorian | 0 2 3 5 7 9 10 | 7 | -| 3 | Pentatonic | 0 2 4 7 9 | 5 | -| 4 | Blues | 0 3 5 6 7 10 | 6 | -| 5 | Chromatic | 0 1 2 3 4 5 6 7 8 9 10 11 | 12 | - ---- - -## Keys - -12 root notes, index 0–11: - -`C C# D D# E F F# G G# A A# B` - -The key is a transpose offset applied on top of the scale intervals. Key 0 = C, Key 2 = D, etc. - ---- - -## Helper functions - -### `getNoteInScale(scaleIdx, degree, octave, keyTranspose)` -Returns the MIDI note number for a given scale degree, octave, and key. `degree` wraps around the scale length automatically. - -``` -getNoteInScale(0, 0, 4, 0) → 60 (C4, Major scale, degree 0) -getNoteInScale(0, 1, 4, 0) → 62 (D4, Major scale, degree 1) -getNoteInScale(3, 0, 4, 2) → 62 (D4, Pentatonic scale, root=D) -``` - -### `isNoteInScale(scaleIdx, keyTranspose, midiNote)` -Returns `true` if the note belongs to the given scale/key. Available for scale-aware highlighting; the current KEYS view is fully chromatic. - -### `isRootNote(keyTranspose, midiNote)` -Returns `true` if the note is the root (tonic) of the key. GRID is chromatic and highlights C notes directly. - -### `midiNoteName(note, buf, len)` -Formats a MIDI note as `"C#4"`, `"A#3"`, etc. All accidentals use sharps: C#, D#, F#, G#, A#. MIDI 60 is C4 (scientific pitch notation). - -### `midiNoteClass(note, buf, len)` -Returns only the pitch class without octave: `"C#"`, `"G"`, etc. - ---- - -## Scale usage by mode - -| Mode | Uses scale | Notes | -|------|-----------|-------| -| KEYS | Label only | Fully chromatic; scale/key shown in the status row but not applied to key colours or pitches | -| BEATS | No | Fixed drum notes | -| ZEN | Yes | Wall segments map to scale degrees | -| DROP | Yes | Platform note derived from scale | -| RNG | Yes | Random note drawn from scale | -| XY | No | Raw CC values | -| ARP | No | Chromatic piano plus fixed major/minor/7th chord intervals | -| GRID | No | Chromatic isomorphic layout; C notes highlighted | -| CHORD | Yes | Roots from scale with fixed C key; chord qualities follow the major-scale degree pattern | -| LFO | No | CC/pitch bend only | -| RAGA | Custom | Raga-specific pitch sets (not using scale index) | -| DIMENSIONS | No | Equation output mapped to MIDI range | -| BABY8 | No | Notes set freely per step | -| TB3PO | Yes | Own six-scale table; generated steps quantised to scale (its Chrome option is the full 12-note chromatic scale) | -| EUCLID | No | Notes set freely per voice | diff --git a/Apps/MidiController/docs/units.md b/Apps/MidiController/docs/units.md deleted file mode 100644 index 591632a..0000000 --- a/Apps/MidiController/docs/units.md +++ /dev/null @@ -1,317 +0,0 @@ -# I2C Unit Drivers - -The unit drivers live in `Libraries/M5UnitModules/`: - -- `Include/UnitXxx.h` -- `Source/UnitXxx.cpp` - -The app compiles them through `Apps/MidiController/main/CMakeLists.txt`, which globs both the app's `Source/*.c*` files and `../../../Libraries/M5UnitModules/Source/*.c*`. - -Each I2C driver follows the same pattern: - -- `begin(Device* dev, uint8_t addr = DEFAULT_ADDR)` — probes the device; returns `false` if not found -- Methods are no-ops (or return `false`) if `begin()` was not called or returned `false` -- No exceptions, no dynamic allocation - -`UnitMidi` is the exception: it drives a SAM2695 over a UART at 31250 bps, so its `begin()` takes a UART controller instead of an I2C address. - ---- - -## UnitCommon.h - -Shared helpers for **STM32-based** M5Stack units (8Encoder, ByteButton, Joystick2, Scroll). - -STM32 units use a register-pointer protocol: write the register address (STOP), wait 2 ms, then read the data. A repeated-START without the STOP will return stale or zero data because the STM32 HAL fires its receive callback on the STOP condition. - -```cpp -// Write data to a register. len is limited to UNIT_MAX_WRITE_PAYLOAD (32 bytes). -bool unitWriteReg(Device* dev, uint8_t addr, - uint8_t reg, const uint8_t* buf, uint16_t len); - -// Write just the register pointer (no data) — prepares for a subsequent read -bool unitWriteRegPtr(Device* dev, uint8_t addr, uint8_t reg); - -// Full read sequence: writeRegPtr → vTaskDelay(2ms) → read -bool unitReadReg(Device* dev, uint8_t addr, - uint8_t reg, uint8_t* buf, uint16_t len); - -// Check that a device ACKs at the given address (zero-length write) -bool unitProbe(Device* dev, uint8_t addr); -``` - -`KNOWN_UNIT_ADDRS` lists the addresses the app probes when scanning PaHub channels: `0x28` (Rfid2), `0x3E` (LCD), `0x40` (Scroll), `0x41` (8Encoder), `0x47` (ByteButton), `0x5F` (CardKB2), and `0x63` (Joystick2). - ---- - -## Unit8Encoder - -**I2C address:** `0x41` (default, configurable) -**Protocol:** STM32-based (STOP+delay pattern) - -### Register map - -| Register | Description | -|----------|-------------| -| `0x00 + i*4` | Absolute counter for knob `i` (int32_t, LE), `i` = 0–7 | -| `0x20 + i*4` | Delta since last read for knob `i` (int32_t, LE), resets on read | -| `0x40 + i` | Write 1 to reset counter `i` | -| `0x50 + i` | Button `i` state (0 = released, 1 = pressed) | -| `0x60` | Toggle-switch state (0 = off, 1 = on) | -| `0x70 + i*3` | Encoder LED `i` RGB (3 bytes: R, G, B), `i` = 0–7 | -| `0x88` | Toggle-switch LED RGB (3 bytes) | -| `0xF0` | Firmware version | -| `0xFF` | I2C address (R/W) | - -### API - -```cpp -bool begin(Device* dev, uint8_t addr = 0x41); -bool isPresent() const; - -// Read all 8 deltas and button states in one call. -// Deltas are returned in detents (hardware counts ÷4). -// Performs 16 individual register reads. -bool readAll(int32_t deltas[8], uint8_t buttons[8]); - -// Read the toggle switch. Returns false on I2C error (state is then unchanged). -bool readSwitch(bool& state); - -// Set one LED immediately. rgb = 0x00RRGGBB. -// idx 0-7 = encoder LEDs, idx 8 = switch LED. -void setLed(uint8_t idx, uint32_t rgb); -void setSwitchLed(uint32_t rgb); - -// Set all 9 LEDs (8 encoders + switch) to the same colour. -void setAllLeds(uint32_t rgb); - -// Compare pending[9] against cached state and write only the changed LEDs. -// pending[0-7] = encoder LEDs, pending[8] = switch LED. -void flushLeds(const uint32_t pending[LED_COUNT]); - -// Cached colours, index 0-7 = encoders, 8 = switch. -const uint32_t* ledColors() const; - -static constexpr uint8_t LED_COUNT = 9; -static constexpr uint8_t ENCODER_LED_COUNT = 8; -``` - -### Notes - -- Delta registers accumulate movement between reads — no encoder ticks are lost even at a 100 ms poll rate. -- `flushLeds` sends a 24-byte payload (25-byte transaction) for the eight encoder LEDs and a separate 3-byte payload (4-byte transaction) for the switch LED, but only when the corresponding cached colours changed. - ---- - -## UnitPaHub - -**I2C address:** `0x70` (TCA9548A-based, fixed) -**Protocol:** Direct I2C write (no register pointer) - -The module exposes **6 channels (CH0–CH5)**. The TCA9548A chip itself supports 8 channels, but only six are wired to Grove ports on this unit. - -A single-byte bitmask write selects which downstream channel is active. Only one channel should be active at a time. - -```cpp -bool begin(Device* dev, uint8_t addr = 0x70); -bool isPresent() const; - -// Activate one channel (0–5). Writes (1 << channel) to the device. -bool select(uint8_t channel); - -// Deactivate all channels. Writes 0x00. -bool deselect(); - -uint8_t currentChannel() const; - -static constexpr uint8_t NUM_CHANNELS = 6; -static constexpr uint8_t NO_CHANNEL = 0xFF; -``` - -### Channel assignments in this app - -The app probes **every** channel (0–5) for the 8Encoder and LCD Unit and uses the first device that responds. The usual wiring is: - -| Channel | Connected unit | -|---------|---------------| -| CH4 | 8Encoder V2 (0x41) | -| CH5 | LCD Unit (0x3E) | - -Always call `select(channel)` before any transaction with a unit on that channel. - ---- - -## UnitLcd - -**I2C address:** `0x3E` -**Protocol:** ESP32-internal command buffer (no STOP+delay needed) - -See [hardware.md](hardware.md) for the command reference. - -```cpp -bool begin(Device* dev, uint8_t addr = 0x3E); -bool isPresent() const; - -uint16_t width() const; // current rotation's logical width -uint16_t height() const; // current rotation's logical height -uint8_t rotation() const; - -void setBrightness(uint8_t brightness); // 0=off, 255=max -void setRotation(uint8_t rotation); // 0=portrait, 1=landscape, 2/3=flipped - -void fillScreen(uint16_t rgb565); -void fillRect(uint8_t x0, uint8_t y0, uint8_t x1, uint8_t y1, uint16_t rgb565); -void drawPixel(uint8_t x, uint8_t y, uint16_t rgb565); - -// Set active drawing window (CASET + RASET commands). -bool setWindow(uint8_t x0, uint8_t y0, uint8_t x1, uint8_t y1); - -// Write raw RGB565 pixels into the active window. -// Sent in chunks of 126 pixels (253 bytes including the command byte). -void writePixels(const uint16_t* pixels, uint32_t len); - -void drawHLine(uint8_t x, uint8_t y, uint8_t len, uint16_t rgb565); -void drawVLine(uint8_t x, uint8_t y, uint8_t len, uint16_t rgb565); -void drawLine(int16_t x0, int16_t y0, int16_t x1, int16_t y1, uint16_t rgb565); -void drawRect(uint8_t x, uint8_t y, uint8_t w, uint8_t h, uint16_t rgb565); - -void fillCircle(int16_t cx, int16_t cy, int16_t r, uint16_t rgb565); -void drawCircle(int16_t cx, int16_t cy, int16_t r, uint16_t rgb565); -void fillRoundRect(int16_t x, int16_t y, int16_t w, int16_t h, int16_t r, uint16_t rgb565); -void drawRoundRect(int16_t x, int16_t y, int16_t w, int16_t h, int16_t r, uint16_t rgb565); -void fillTriangle(int16_t x0, int16_t y0, int16_t x1, int16_t y1, - int16_t x2, int16_t y2, uint16_t rgb565); -void drawTriangle(int16_t x0, int16_t y0, int16_t x1, int16_t y1, - int16_t x2, int16_t y2, uint16_t rgb565); - -// Angles in degrees (0=right, 90=down, 180=left, 270=up). -void fillArc(int16_t cx, int16_t cy, int16_t r0, int16_t r1, - float startDeg, float endDeg, uint16_t rgb565); -void drawArc(int16_t cx, int16_t cy, int16_t r0, int16_t r1, - float startDeg, float endDeg, uint16_t rgb565); - -void drawChar(uint8_t x, uint8_t y, char c, uint16_t fg, uint16_t bg, uint8_t scale = 1); -void drawText(uint8_t x, uint8_t y, const char* str, uint16_t fg, uint16_t bg, uint8_t scale = 1); - -// Query remaining command buffer space (0–255). -// Returns UINT8_MAX on I2C error (treat as "plenty of space" / safe fallback). -uint8_t bufferRemaining(); - -// Convert 0x00RRGGBB → RGB565. -static uint16_t rgb888to565(uint32_t rgb888); -static uint16_t color565(uint8_t r, uint8_t g, uint8_t b); -``` - -### Pixel chunking - -The ESP32 I2C driver has a 256-byte buffer. `writePixels` splits the stream into chunks of **126 pixels** (252 bytes plus the `0x42` command byte = 253 bytes). Each chunk is a separate I2C transaction. - ---- - -## UnitByteButton - -**I2C address:** `0x47` -**Protocol:** STM32-based (STOP+delay pattern) - -8 push-buttons with per-button RGB LEDs. - -```cpp -bool begin(Device* dev, uint8_t addr = 0x47); -bool isPresent() const; - -// Read all 8 buttons as a bitmask (bit i = button i, 1=pressed). -// Sets *ok=false on I2C error (ok may be nullptr). -uint8_t readButtons(bool* ok = nullptr); - -// Read a single button (true = pressed). -bool readButton(uint8_t idx); - -// Set one LED (0x00RRGGBB, idx 0-7) and send it immediately. -void setLed(uint8_t idx, uint32_t rgb); - -// Flush pending[8] to hardware in one burst if anything changed. -void flushLeds(const uint32_t pending[8]); - -// Set all 8 LEDs to one colour immediately. -void setAllLeds(uint32_t rgb); - -const uint32_t* ledColors() const; -``` - -**Init note:** The show-mode register `0x19` is written `0x00` during `begin()`; otherwise LEDs will not update. - -**Wire format:** each LED is sent as a little-endian `uint32_t` `[B, G, R, 0x00]`. - ---- - -## UnitJoystick2 - -**I2C address:** `0x63` -**Protocol:** STM32-based (STOP+delay pattern) - -Dual-axis thumbstick with push-button and RGB LED. - -```cpp -bool begin(Device* dev, uint8_t addr = 0x63); -bool isPresent() const; - -// Read offset-corrected 12-bit X/Y. Centre = 0, range ~±2048. -bool readXY12(int16_t* x, int16_t* y); - -// Read raw 12-bit X/Y (0-4095, centre ~2048). -bool readXY12Raw(uint16_t* x, uint16_t* y); - -// Read offset-corrected 8-bit X/Y. Centre = 0, range ~±128. -bool readXY8(int8_t* x, int8_t* y); - -// True when the button is pressed (inverts the hardware's 0=pressed convention). -bool isPressed() const; - -// Set LED colour (0x00RRGGBB). Returns false on I2C error. -bool setLed(uint32_t rgb); -``` - ---- - -## UnitScroll - -**I2C address:** `0x40` -**Protocol:** STM32-based (STOP+delay pattern) - -Single rotary encoder with push-button and RGB LED. - -```cpp -bool begin(Device* dev, uint8_t addr = 0x40); -bool isPresent() const; - -// Read accumulated delta since last call (int16_t, resets on read). -int16_t readDelta(); - -// Read the absolute encoder value. -int16_t readAbsolute() const; - -// True when the button is pressed. -bool isPressed() const; - -// Set LED colour (0x00RRGGBB). -// Wire format: [0x00, R, G, B] at register 0x30. -void setLed(uint32_t rgb); - -// Reset the internal counter to zero. -void resetEncoder(); -``` - ---- - -## Other drivers in the library - -`Libraries/M5UnitModules/` also contains drivers for `UnitCardKB2`, `UnitDualButton`, `UnitMidi`, and `UnitRfid2`. They are not used by this app, but are compiled by the app's CMake glob. `UnitMidi` uses UART rather than I2C; the others follow the I2C driver pattern. - ---- - -## Adding a new unit - -1. Create `Libraries/M5UnitModules/Include/UnitXxx.h` and `Libraries/M5UnitModules/Source/UnitXxx.cpp`. -2. Include `UnitCommon.h` for the shared I2C helpers. -3. Implement `begin(Device*, uint8_t addr)` returning `false` if `unitProbe` fails. -4. Guard every method with `if (!dev_) return;` (or return `false`). -5. The file is picked up automatically by the `GLOB_RECURSE` rules in `Apps/MidiController/main/CMakeLists.txt`. diff --git a/Apps/MidiController/main/CMakeLists.txt b/Apps/MidiController/main/CMakeLists.txt deleted file mode 100644 index 866a653..0000000 --- a/Apps/MidiController/main/CMakeLists.txt +++ /dev/null @@ -1,5 +0,0 @@ -include("$ENV{TACTILITY_SDK_PATH}/TactilitySDK.cmake") - -file(GLOB_RECURSE SOURCE_FILES Source/*.c*) -file(GLOB_RECURSE UNIT_MODULE_FILES ../../../Libraries/M5UnitModules/Source/*.c*) -tactility_component_register(SRCS ${SOURCE_FILES} ${UNIT_MODULE_FILES} INCLUDE_DIRS ../../../Libraries/M5UnitModules/Include) diff --git a/Apps/MidiController/main/Source/EncoderInput.cpp b/Apps/MidiController/main/Source/EncoderInput.cpp deleted file mode 100644 index d6fe3f8..0000000 --- a/Apps/MidiController/main/Source/EncoderInput.cpp +++ /dev/null @@ -1,375 +0,0 @@ -#include "MidiController.h" -#include "LcdDisplay.h" -#include "GroveLookup.h" -#include -#include - -#include -#include -#include -#include - -// --------------------------------------------------------------------------- -// M5Stack 8Encoder (V2, addr 0x41) + LCD Unit (addr 0x3E) -// Both are on PaHub (addr 0x70) on the grove port in I2C mode (Tab5 Port A, SDA=53 SCL=54). -// Looked up via findGroveI2cDevice() (GroveLookup.h) rather than a hardcoded device name: the -// grove port device itself ("grove0") is a mode-switchable I2C/UART router (GROVE_TYPE), not an -// I2C controller - passing it directly into i2c_controller_*() calls crashes (wrong driver data -// layout). The actual I2C-master child device it spawns in I2C mode is named "grove0_i2c", but -// even that name isn't hardcoded here since grove port naming isn't guaranteed. -// -// Usually 8Encoder on PaHub CH4 and LCD on CH5, but encoderInit() probes -// every channel (0-5) and accepts either unit on any free port. -// -// Per-mode encoder → parameter mapping: -// EUCLID : knob 0 → sel.steps; knob 1 → sel.events; knob 2 → sel.rot; knob 3 → sel.note -// knob 4 → BPM; knobs 5-7 → steps for voices 6-8; button i → select voice i -// BABY8 : knob i (0-7) → note for step i; button i → toggle gate i -// DIMENSIONS : knob 0-3 → A/B/C/D; knob 4 → equation; knob 5 → BPM; knob 6 → oct; knob 7 → rnd -// button 0 → play/stop; button 1 → reset -// TB3PO : knob 0 → density; knob 1 → BPM; knob 2 → key; knob 3 → octave -// knob 4 → scale; knob 5 → regen-on-turn; button 0 → play/stop; button 1 → regen -// RAGA : knob 0 → raga; knob 1 → tala; knob 2 → root; knob 3 → BPM -// button 0 → play/stop; button 1 → drone toggle -// --------------------------------------------------------------------------- - -static constexpr auto* TAG = "EncoderInput"; -static constexpr int POLL_MS = 100; - -static UnitPaHub s_hub; -static Unit8Encoder s_enc; -static Device* s_i2cDev = nullptr; -// Discovered in encoderInit() by probing every PaHub channel (0-5), the 8Encoder can be plugged into any port on the hub. -static uint8_t s_encChannel = UnitPaHub::NO_CHANNEL; - -// --------------------------------------------------------------------------- -// LED colour tables -// --------------------------------------------------------------------------- - -// flushLeds() expects LED_COUNT (9) entries: 8 encoder LEDs + the switch LED. -// The app leaves the switch LED off, so the ninth entry is always 0. -static constexpr uint32_t EUCLID_COLORS[Unit8Encoder::LED_COUNT] = { - 0xFF4040, 0xFF9900, 0x44DD44, 0x44BBFF, - 0xFF44FF, 0xFFDD00, 0x00FFCC, 0xFF8888, - 0x000000 -}; -static constexpr uint32_t RAGA_COLORS[Unit8Encoder::LED_COUNT] = { - 0xFF8800, 0xFF6600, 0x00FFCC, 0xFFE000, - 0x000000, 0x000000, 0x000000, 0x000000, - 0x000000 -}; -static constexpr uint32_t DIM_COLORS[Unit8Encoder::LED_COUNT] = { - 0x88DDFF, 0x88FFCC, 0xFFCC88, 0xFF88CC, - 0xFF8844, 0xFFE000, - 0xAAFF88, 0xFFAA44, - 0x000000 -}; - -// --------------------------------------------------------------------------- -// PaHub channel helpers -// --------------------------------------------------------------------------- - -static bool selectEncoder() { - return s_encChannel != UnitPaHub::NO_CHANNEL && s_hub.select(s_encChannel); -} - -// --------------------------------------------------------------------------- -// Per-mode dispatch (encoder side only - LED flush goes through Unit8Encoder) -// --------------------------------------------------------------------------- - -static void dispatchEuclid(MidiController* mc) { - auto& es = mc->euclid_; - int32_t deltas[8] = {}; - uint8_t buttons[8] = {}; - if (!selectEncoder()) return; - if (!s_enc.readAll(deltas, buttons)) return; - - bool changed = false; - auto& sv = es.voice[es.selectedVoice]; - - if (deltas[0]) { int s = sv.steps + deltas[0]; s = std::max(1,std::min(32,s)); sv.steps = s; if (sv.events > sv.steps) sv.events = sv.steps; changed = true; } - if (deltas[1]) { int e = sv.events + deltas[1]; e = std::max(0,std::min(sv.steps,e)); sv.events = e; changed = true; } - if (deltas[2]) { int safeSteps = std::max(1, sv.steps); int r = sv.rotation + deltas[2]; r = ((r % safeSteps) + safeSteps) % safeSteps; sv.rotation = r; changed = true; } - if (deltas[3]) { int n = sv.note + deltas[3]; n = std::max(0,std::min(127,n)); sv.note = n; changed = true; } - if (deltas[4]) { int b = es.bpm + deltas[4]; b = std::max(40,std::min(280,b)); es.bpm = b; if (es.bpmLbl) { char buf[8]; snprintf(buf,sizeof(buf),"%d",b); lv_label_set_text(es.bpmLbl,buf); } } - for (int i = 5; i <= 7; i++) { - if (deltas[i]) { auto& v = es.voice[i]; int s = v.steps + deltas[i]; s = std::max(1,std::min(32,s)); v.steps = s; if (v.events > v.steps) v.events = v.steps; changed = true; } - } - for (int i = 0; i < 8; i++) { - bool btn = (buttons[i] != 0); - if (btn && !mc->encoder_.btnPrev[i]) { es.selectedVoice = i; changed = true; } - mc->encoder_.btnPrev[i] = btn; - } - if (changed) euclidOnEncoderUpdate(mc); - - uint32_t pending[Unit8Encoder::LED_COUNT] = {}; - for (int i = 0; i < 8; i++) { - uint32_t c = EUCLID_COLORS[i]; - pending[i] = (i == es.selectedVoice) ? c : (c >> 2) & 0x3F3F3F; - } - selectEncoder(); - s_enc.flushLeds(pending); - lcdUpdate(mc); -} - -static void dispatchBaby8(MidiController* mc) { - auto& bs = mc->baby8_; - int32_t deltas[8] = {}; - uint8_t buttons[8] = {}; - if (!selectEncoder()) return; - if (!s_enc.readAll(deltas, buttons)) return; - - static const char* NOTE_NAMES[] = {"C","C#","D","D#","E","F","F#","G","G#","A","A#","B"}; - bool stepChanged = false; - for (int i = 0; i < 8; i++) { - if (deltas[i]) { - int n = bs.steps[i].note + deltas[i]; - n = std::max(0, std::min(127, n)); - bs.steps[i].note = n; - if (bs.noteLbl[i]) { - char buf[8]; snprintf(buf,sizeof(buf),"%s%d",NOTE_NAMES[n%12],n/12-1); - lv_label_set_text(bs.noteLbl[i], buf); - } - stepChanged = true; - } - bool btn = (buttons[i] != 0); - if (btn && !mc->encoder_.btnPrev[i]) { - bs.steps[i].gate = !bs.steps[i].gate; - stepChanged = true; - } - mc->encoder_.btnPrev[i] = btn; - } - // Encoder edits must repaint the step buttons immediately; the timer only - // refreshes them while the sequencer is running. - if (stepChanged) baby8UpdateStepColors(mc); - - uint32_t pending[Unit8Encoder::LED_COUNT] = {}; - for (int i = 0; i < 8; i++) - pending[i] = (bs.playing && i == bs.currentStep) ? 0xFFFFFF - : (bs.steps[i].gate ? 0x004400 : 0x440000); - selectEncoder(); - s_enc.flushLeds(pending); - lcdUpdate(mc); -} - -static void dispatchDimensions(MidiController* mc) { - auto& ds = mc->dim_; - int32_t deltas[8] = {}; - uint8_t buttons[8] = {}; - if (!selectEncoder()) return; - if (!s_enc.readAll(deltas, buttons)) return; - - float* params[4] = {&ds.a,&ds.b,&ds.c,&ds.d}; - lv_obj_t** lbls[4] = {&ds.aLbl,&ds.bLbl,&ds.cLbl,&ds.dLbl}; - static const char* NAMES[4] = {"A","B","C","D"}; - for (int i = 0; i < 4; i++) { - if (deltas[i]) { - float v = *params[i] + deltas[i]; - v = std::max(0.0f, std::min(100.0f, v)); - *params[i] = v; - if (*lbls[i]) { char b[12]; snprintf(b,sizeof(b),"%s:%.0f",NAMES[i],v); lv_label_set_text(*lbls[i],b); } - } - } - if (deltas[4]) { int eq = ds.equation + deltas[4]; eq = std::max(1,std::min(20,eq)); ds.equation = eq; if (ds.eqLbl) { char b[12]; snprintf(b,sizeof(b),"Eq:%d",eq); lv_label_set_text(ds.eqLbl,b); } } - if (deltas[5]) { int bpm = ds.bpm + deltas[5]; bpm = std::max(40,std::min(280,bpm)); ds.bpm = bpm; if (ds.bpmLbl) { char b[8]; snprintf(b,sizeof(b),"%d",bpm); lv_label_set_text(ds.bpmLbl,b); } } - if (deltas[6]) dimChangeOctave(mc, deltas[6]); - if (deltas[7]) dimChangeRandom(mc, deltas[7]); - - for (int i = 0; i < 2; i++) { - bool btn = (buttons[i] != 0); - if (btn && !mc->encoder_.btnPrev[i]) { if (i==0) dimTogglePlay(mc); else dimReset(mc); } - mc->encoder_.btnPrev[i] = btn; - } - selectEncoder(); - s_enc.flushLeds(DIM_COLORS); - lcdUpdate(mc); -} - -static void dispatchTb3po(MidiController* mc) { - auto& ts = mc->tb3po_; - int32_t deltas[8] = {}; - uint8_t buttons[8] = {}; - if (!selectEncoder()) return; - if (!s_enc.readAll(deltas, buttons)) return; - - if (deltas[0]) { - int d = ts.density + deltas[0]; - d = std::max(0, std::min(16, d)); - if (d != ts.density) { - ts.density = d; - if (ts.densityLbl) { char b[8]; snprintf(b, sizeof(b), "D:%d", d); lv_label_set_text(ts.densityLbl, b); } - // Match the touch UI: a density change also draws a fresh seed and pattern. - tb3Regen(mc); - } - } - if (deltas[1]) { int bpm = ts.bpm + deltas[1]; bpm = std::max(40,std::min(280,bpm)); ts.bpm = bpm; if (ts.bpmLbl) { char b[8]; snprintf(b,sizeof(b),"%d",bpm); lv_label_set_text(ts.bpmLbl,b); } } - bool keyOrOctaveChanged = false; - if (deltas[2]) { ts.key = ((ts.key + deltas[2]) % 12 + 12) % 12; static const char* KS[] = {"C","C#","D","D#","E","F","F#","G","G#","A","A#","B"}; if (ts.keyLbl) lv_label_set_text(ts.keyLbl, KS[ts.key]); keyOrOctaveChanged = true; } - if (deltas[3]) { int oct = ts.octave + deltas[3]; oct = std::max(1,std::min(6,oct)); ts.octave = oct; keyOrOctaveChanged = true; } - if (deltas[4]) tb3ChangeScale(mc, deltas[4]); - if (deltas[5]) tb3Regen(mc); - // The piano roll shows resolved note names; key/octave changes need a repaint - // even when the sequencer is stopped. - if (keyOrOctaveChanged) tb3Redraw(mc); - for (int i = 0; i < 2; i++) { - bool btn = (buttons[i] != 0); - if (btn && !mc->encoder_.btnPrev[i]) { if (i==0) tb3TogglePlay(mc); else tb3Regen(mc); } - mc->encoder_.btnPrev[i] = btn; - } - - uint32_t pending[Unit8Encoder::LED_COUNT] = {}; - for (int i = 0; i < 8; i++) - pending[i] = (ts.playing && i == (ts.currentStep / 4)) ? 0xFF6600 : 0x221100; - selectEncoder(); - s_enc.flushLeds(pending); - lcdUpdate(mc); -} - -static void dispatchRaga(MidiController* mc) { - auto& rs = mc->raga_; - int32_t deltas[8] = {}; - uint8_t buttons[8] = {}; - if (!selectEncoder()) return; - if (!s_enc.readAll(deltas, buttons)) return; - - static const char* RAGA_NAMES[] = {"Bhairavi","Lalit","Bhupali","Todi","Madhuvanti","Meghmalhar","Yaman","Malkauns"}; - static const char* TALA_NAMES[] = {"Teental","Rupak","Jhaptal"}; - - if (deltas[0]) { rs.raga = ((rs.raga + deltas[0]) % 8 + 8) % 8; if (rs.ragaLbl) lv_label_set_text(rs.ragaLbl, RAGA_NAMES[rs.raga]); if (rs.ragaBigLbl) lv_label_set_text(rs.ragaBigLbl, RAGA_NAMES[rs.raga]); } - if (deltas[1]) { rs.tala = ((rs.tala + deltas[1]) % 3 + 3) % 3; rs.talaBeat = 0; if (rs.talaLbl) lv_label_set_text(rs.talaLbl, TALA_NAMES[rs.tala]); if (rs.talaBigLbl) lv_label_set_text(rs.talaBigLbl, TALA_NAMES[rs.tala]); } - if (deltas[2]) { int r = rs.rootNote + deltas[2]; r = std::max(24,std::min(96,r)); rs.rootNote = r; if (rs.rootLbl) { static const char* N[] = {"C","C#","D","D#","E","F","F#","G","G#","A","A#","B"}; char b[8]; snprintf(b,sizeof(b),"%s%d",N[r%12],r/12-1); lv_label_set_text(rs.rootLbl,b); } } - if (deltas[3]) { int bpm = rs.bpm + deltas[3]; bpm = std::max(40,std::min(240,bpm)); rs.bpm = bpm; if (rs.bpmLbl) { char b[8]; snprintf(b,sizeof(b),"%d",bpm); lv_label_set_text(rs.bpmLbl,b); } } - for (int i = 0; i < 2; i++) { - bool btn = (buttons[i] != 0); - if (btn && !mc->encoder_.btnPrev[i]) { if (i==0) ragaTogglePlay(mc); else ragaToggleDrone(mc); } - mc->encoder_.btnPrev[i] = btn; - } - selectEncoder(); - s_enc.flushLeds(RAGA_COLORS); - lcdUpdate(mc); -} - -// --------------------------------------------------------------------------- -// Poll timer -// --------------------------------------------------------------------------- - -static void cbEncoderPoll(lv_timer_t* t) { - auto* mc = static_cast(lv_timer_get_user_data(t)); - if (!mc) return; - // With no encoder, this timer still refreshes a detected LCD unit. - if (!mc->encoder_.present) { - lcdUpdate(mc); - return; - } - if (!s_hub.isPresent()) return; - - // After a mode switch, do one silent read to seed btnPrev with the live - // button state - prevents a physically-held button from registering as a - // fresh press in the new mode's dispatch. - if (mc->encoder_.btnSeedNext) { - mc->encoder_.btnSeedNext = false; - int32_t deltas[8] = {}; - uint8_t buttons[8] = {}; - if (selectEncoder()) { - s_enc.readAll(deltas, buttons); - for (int i = 0; i < 8; i++) - mc->encoder_.btnPrev[i] = (buttons[i] != 0); - } - return; - } - - switch (mc->currentMode_) { - case MidiMode::EUCLID: dispatchEuclid (mc); break; - case MidiMode::BABY8: dispatchBaby8 (mc); break; - case MidiMode::DIMENSIONS: dispatchDimensions(mc); break; - case MidiMode::TB3PO: dispatchTb3po (mc); break; - case MidiMode::RAGA: dispatchRaga (mc); break; - default: break; - } -} - -// --------------------------------------------------------------------------- -// Public API -// --------------------------------------------------------------------------- - -void encoderSetLed(MidiController* mc, uint8_t idx, uint32_t rgb) { - if (!mc->encoder_.present || idx >= 8) return; - if (!s_hub.isPresent()) return; - selectEncoder(); - s_enc.setLed(idx, rgb); - // sync cache - mc->encoder_.ledColor[idx] = rgb; -} - -void encoderSetAllLeds(MidiController* mc, uint32_t rgb) { - if (!mc->encoder_.present) return; - if (!s_hub.isPresent()) return; - selectEncoder(); - s_enc.setAllLeds(rgb); - for (int i = 0; i < 8; i++) mc->encoder_.ledColor[i] = rgb; -} - -void encoderInit(MidiController* mc) { - if (mc->encoderTimer_) return; - - Device* dev = findGroveI2cDevice(); - if (!dev) { - ESP_LOGE(TAG, "grove0_i2c not ready"); - mc->encoder_.present = false; - return; - } - s_i2cDev = dev; - - // Init PaHub - if (!s_hub.begin(dev)) { - ESP_LOGW(TAG, "PaHub not found - no encoder or LCD"); - mc->encoder_.present = false; - s_i2cDev = nullptr; - return; - } - - // Find the 8Encoder by probing every PaHub channel - Unit8Encoder::begin() - // itself probes the I2C address and returns false if nothing answers, so trying each - // channel in turn and keeping the first hit is safe/cheap (6 channels, worst case). - s_encChannel = UnitPaHub::NO_CHANNEL; - for (uint8_t ch = 0; ch < UnitPaHub::NUM_CHANNELS; ch++) { - if (!s_hub.select(ch)) continue; - if (s_enc.begin(dev)) { - s_encChannel = ch; - break; - } - } - if (s_encChannel == UnitPaHub::NO_CHANNEL) { - ESP_LOGW(TAG, "8Encoder not found on any PaHub channel"); - mc->encoder_.present = false; - // Still try LCD even without encoder - } else { - mc->encoder_.present = true; - memset(mc->encoder_.ledColor, 0, sizeof(mc->encoder_.ledColor)); - ESP_LOGI(TAG, "8Encoder ready on PaHub CH%d", (int)s_encChannel); - } - - // Find the LCD the same way (LcdDisplay.cpp's own channel scan). - lcdInit(&s_hub); - lcdSetMode(mc->currentMode_); - - if (mc->encoder_.present || lcdIsPresent()) { - mc->encoderTimer_ = lv_timer_create(cbEncoderPoll, POLL_MS, mc); - } -} - -void encoderStop(MidiController* mc) { - if (mc->encoderTimer_) { - lv_timer_delete(mc->encoderTimer_); - mc->encoderTimer_ = nullptr; - } - if (mc->encoder_.present && s_hub.isPresent()) { - selectEncoder(); - s_enc.setAllLeds(0x000000); - } - lcdStop(); - memset(mc->encoder_.ledColor, 0, sizeof(mc->encoder_.ledColor)); - mc->encoder_.present = false; - s_i2cDev = nullptr; - s_encChannel = UnitPaHub::NO_CHANNEL; -} diff --git a/Apps/MidiController/main/Source/GroveLookup.cpp b/Apps/MidiController/main/Source/GroveLookup.cpp deleted file mode 100644 index 795da04..0000000 --- a/Apps/MidiController/main/Source/GroveLookup.cpp +++ /dev/null @@ -1,45 +0,0 @@ -#include "GroveLookup.h" -#include -#include - -namespace { - -struct GroveChildSearch { - const DeviceType* childType; - Device* result; -}; - -bool onGroveDevice(Device* groveDevice, void* context) { - auto* search = static_cast(context); - - GroveMode mode; - if (grove_get_mode(groveDevice, &mode) != ERROR_NONE) return true; - if (mode != (search->childType == &I2C_CONTROLLER_TYPE ? GROVE_MODE_I2C : GROVE_MODE_UART)) return true; - - device_for_each_child(groveDevice, search, [](Device* child, void* ctx) -> bool { - auto* s = static_cast(ctx); - if (device_get_type(child) == s->childType && device_is_ready(child)) { - s->result = child; - return false; // stop - found it - } - return true; - }); - - return search->result == nullptr; // stop outer iteration once found -} - -Device* findGroveChild(const DeviceType* childType) { - GroveChildSearch search{childType, nullptr}; - device_for_each_of_type(&GROVE_TYPE, &search, onGroveDevice); - return search.result; -} - -} // namespace - -Device* findGroveI2cDevice() { - return findGroveChild(&I2C_CONTROLLER_TYPE); -} - -Device* findGroveUartDevice() { - return findGroveChild(&UART_CONTROLLER_TYPE); -} diff --git a/Apps/MidiController/main/Source/GroveLookup.h b/Apps/MidiController/main/Source/GroveLookup.h deleted file mode 100644 index 9711e5a..0000000 --- a/Apps/MidiController/main/Source/GroveLookup.h +++ /dev/null @@ -1,12 +0,0 @@ -#pragma once - -#include -#include - -// Finds the I2C controller device exposed by the first grove port currently -// in GROVE_MODE_I2C, by checking each grove port's child device type -// (not by name - grove port names are not guaranteed, e.g. "port_a"). -Device* findGroveI2cDevice(); - -// Same as findGroveI2cDevice() but for UART mode. -Device* findGroveUartDevice(); diff --git a/Apps/MidiController/main/Source/LcdDisplay.cpp b/Apps/MidiController/main/Source/LcdDisplay.cpp deleted file mode 100644 index 6166f52..0000000 --- a/Apps/MidiController/main/Source/LcdDisplay.cpp +++ /dev/null @@ -1,803 +0,0 @@ -#include "LcdDisplay.h" -#include "GroveLookup.h" - -#include -#include -#include -#include - -static constexpr auto* TAG = "LcdDisplay"; - -// --------------------------------------------------------------------------- -// Hardware -// --------------------------------------------------------------------------- - -static UnitPaHub* s_hub = nullptr; -static UnitLcd s_lcd; -static Device* s_i2cDev = nullptr; -// Discovered in lcdInit() by probing every PaHub channel (0-5), the LCD unit can be plugged into any port on the hub. -static uint8_t s_lcdChannel = UnitPaHub::NO_CHANNEL; - -// --------------------------------------------------------------------------- -// State cache - only redraw when something actually changed -// --------------------------------------------------------------------------- - -static MidiMode s_mode = MidiMode::HOME; -static bool s_dirty = true; // force full redraw on first update - -// Per-mode cached values (simple structs with sentinels so first draw always fires) -struct RagaCache { int raga=-1, tala=-1, root=-1, bpm=-1; bool playing=false, drone=false; }; -struct DimCache { int a=-1, b=-1, c=-1, d=-1, eq=-1, bpm=-1, octave=INT_MIN, rnd_cached=INT_MIN; bool playing=false; }; -struct Tb3Cache { int step=-1, key=-1, scale=-1, bpm=-1, density=-1; int patScale=-1, patDensity=-1; uint32_t seed=0; bool playing=false; }; -struct Baby8Cache { int note[8]={-1,-1,-1,-1,-1,-1,-1,-1}; bool gate[8]={}; int step=-1; bool playing=false; }; -struct EuclidCache{ int voice=-1, steps=-1, events=-1, rot=-1, note=-1, bpm=-1; bool playing=false; }; - -static RagaCache s_raga; -static DimCache s_dim; -static Tb3Cache s_tb3; -static Baby8Cache s_b8; -static EuclidCache s_euc; - -// --------------------------------------------------------------------------- -// Colours (RGB565) -// --------------------------------------------------------------------------- - -static constexpr uint16_t C_BG = 0x0000; // black -static constexpr uint16_t C_WHITE = 0xFFFF; -static constexpr uint16_t C_GRAY = 0x4A69; -static constexpr uint16_t C_DGRAY = 0x2104; -static constexpr uint16_t C_RED = 0xF800; -static constexpr uint16_t C_GREEN = 0x07E0; -static constexpr uint16_t C_BLUE = 0x001F; -static constexpr uint16_t C_ORANGE = 0xFD20; -static constexpr uint16_t C_YELLOW = 0xFFE0; -static constexpr uint16_t C_CYAN = 0x07FF; -static constexpr uint16_t C_MAGENTA = 0xF81F; -static constexpr uint16_t C_PURPLE = 0x801F; - -// Voice colours matching EUCLID_COLORS in EncoderInput -static constexpr uint16_t VOICE_COL[8] = { - 0xF800, 0xFD20, 0x47E0, 0x45FF, - 0xF81F, 0xFFE0, 0x07FE, 0xFC11 -}; - -// --------------------------------------------------------------------------- -// Drawing primitives -// --------------------------------------------------------------------------- - -static bool selectLcd() { - return s_hub && s_lcdChannel != UnitPaHub::NO_CHANNEL && s_hub->select(s_lcdChannel); -} - -static void fillRect(uint8_t x, uint8_t y, uint8_t w, uint8_t h, uint16_t col) { - if (w == 0 || h == 0) return; - s_lcd.fillRect(x, y, (uint8_t)(x + w - 1), (uint8_t)(y + h - 1), col); -} - -static void hline(uint8_t x, uint8_t y, uint8_t w, uint16_t col) { - fillRect(x, y, w, 1, col); -} - -// --------------------------------------------------------------------------- -// Minimal 5×7 bitmap font (printable ASCII 0x20-0x7E) -// Each character is 5 columns × 7 rows, packed into 5 bytes (LSB = top row). -// --------------------------------------------------------------------------- - -static const uint8_t FONT5x7[][5] = { - {0x00,0x00,0x00,0x00,0x00}, // ' ' - {0x00,0x00,0x5F,0x00,0x00}, // '!' - {0x00,0x07,0x00,0x07,0x00}, // '"' - {0x14,0x7F,0x14,0x7F,0x14}, // '#' - {0x24,0x2A,0x7F,0x2A,0x12}, // '$' - {0x23,0x13,0x08,0x64,0x62}, // '%' - {0x36,0x49,0x55,0x22,0x50}, // '&' - {0x00,0x05,0x03,0x00,0x00}, // ''' - {0x00,0x1C,0x22,0x41,0x00}, // '(' - {0x00,0x41,0x22,0x1C,0x00}, // ')' - {0x08,0x2A,0x1C,0x2A,0x08}, // '*' - {0x08,0x08,0x3E,0x08,0x08}, // '+' - {0x00,0x50,0x30,0x00,0x00}, // ',' - {0x08,0x08,0x08,0x08,0x08}, // '-' - {0x00,0x60,0x60,0x00,0x00}, // '.' - {0x20,0x10,0x08,0x04,0x02}, // '/' - {0x3E,0x51,0x49,0x45,0x3E}, // '0' - {0x00,0x42,0x7F,0x40,0x00}, // '1' - {0x42,0x61,0x51,0x49,0x46}, // '2' - {0x21,0x41,0x45,0x4B,0x31}, // '3' - {0x18,0x14,0x12,0x7F,0x10}, // '4' - {0x27,0x45,0x45,0x45,0x39}, // '5' - {0x3C,0x4A,0x49,0x49,0x30}, // '6' - {0x01,0x71,0x09,0x05,0x03}, // '7' - {0x36,0x49,0x49,0x49,0x36}, // '8' - {0x06,0x49,0x49,0x29,0x1E}, // '9' - {0x00,0x36,0x36,0x00,0x00}, // ':' - {0x00,0x56,0x36,0x00,0x00}, // ';' - {0x00,0x08,0x14,0x22,0x41}, // '<' - {0x14,0x14,0x14,0x14,0x14}, // '=' - {0x41,0x22,0x14,0x08,0x00}, // '>' - {0x02,0x01,0x51,0x09,0x06}, // '?' - {0x32,0x49,0x79,0x41,0x3E}, // '@' - {0x7E,0x11,0x11,0x11,0x7E}, // 'A' - {0x7F,0x49,0x49,0x49,0x36}, // 'B' - {0x3E,0x41,0x41,0x41,0x22}, // 'C' - {0x7F,0x41,0x41,0x22,0x1C}, // 'D' - {0x7F,0x49,0x49,0x49,0x41}, // 'E' - {0x7F,0x09,0x09,0x09,0x01}, // 'F' - {0x3E,0x41,0x49,0x49,0x7A}, // 'G' - {0x7F,0x08,0x08,0x08,0x7F}, // 'H' - {0x00,0x41,0x7F,0x41,0x00}, // 'I' - {0x20,0x40,0x41,0x3F,0x01}, // 'J' - {0x7F,0x08,0x14,0x22,0x41}, // 'K' - {0x7F,0x40,0x40,0x40,0x40}, // 'L' - {0x7F,0x02,0x04,0x02,0x7F}, // 'M' - {0x7F,0x04,0x08,0x10,0x7F}, // 'N' - {0x3E,0x41,0x41,0x41,0x3E}, // 'O' - {0x7F,0x09,0x09,0x09,0x06}, // 'P' - {0x3E,0x41,0x51,0x21,0x5E}, // 'Q' - {0x7F,0x09,0x19,0x29,0x46}, // 'R' - {0x46,0x49,0x49,0x49,0x31}, // 'S' - {0x01,0x01,0x7F,0x01,0x01}, // 'T' - {0x3F,0x40,0x40,0x40,0x3F}, // 'U' - {0x1F,0x20,0x40,0x20,0x1F}, // 'V' - {0x3F,0x40,0x38,0x40,0x3F}, // 'W' - {0x63,0x14,0x08,0x14,0x63}, // 'X' - {0x07,0x08,0x70,0x08,0x07}, // 'Y' - {0x61,0x51,0x49,0x45,0x43}, // 'Z' - {0x00,0x7F,0x41,0x41,0x00}, // '[' - {0x02,0x04,0x08,0x10,0x20}, // '\' - {0x00,0x41,0x41,0x7F,0x00}, // ']' - {0x04,0x02,0x01,0x02,0x04}, // '^' - {0x40,0x40,0x40,0x40,0x40}, // '_' - {0x00,0x01,0x02,0x04,0x00}, // '`' - {0x20,0x54,0x54,0x54,0x78}, // 'a' - {0x7F,0x48,0x44,0x44,0x38}, // 'b' - {0x38,0x44,0x44,0x44,0x20}, // 'c' - {0x38,0x44,0x44,0x48,0x7F}, // 'd' - {0x38,0x54,0x54,0x54,0x18}, // 'e' - {0x08,0x7E,0x09,0x01,0x02}, // 'f' - {0x08,0x14,0x54,0x54,0x3C}, // 'g' - {0x7F,0x08,0x04,0x04,0x78}, // 'h' - {0x00,0x44,0x7D,0x40,0x00}, // 'i' - {0x20,0x40,0x44,0x3D,0x00}, // 'j' - {0x7F,0x10,0x28,0x44,0x00}, // 'k' - {0x00,0x41,0x7F,0x40,0x00}, // 'l' - {0x7C,0x04,0x18,0x04,0x78}, // 'm' - {0x7C,0x08,0x04,0x04,0x78}, // 'n' - {0x38,0x44,0x44,0x44,0x38}, // 'o' - {0x7C,0x14,0x14,0x14,0x08}, // 'p' - {0x08,0x14,0x14,0x18,0x7C}, // 'q' - {0x7C,0x08,0x04,0x04,0x08}, // 'r' - {0x48,0x54,0x54,0x54,0x20}, // 's' - {0x04,0x3F,0x44,0x40,0x20}, // 't' - {0x3C,0x40,0x40,0x40,0x7C}, // 'u' - {0x1C,0x20,0x40,0x20,0x1C}, // 'v' - {0x3C,0x40,0x30,0x40,0x3C}, // 'w' - {0x44,0x28,0x10,0x28,0x44}, // 'x' - {0x0C,0x50,0x50,0x50,0x3C}, // 'y' - {0x44,0x64,0x54,0x4C,0x44}, // 'z' - {0x00,0x08,0x36,0x41,0x00}, // '{' - {0x00,0x00,0x7F,0x00,0x00}, // '|' - {0x00,0x41,0x36,0x08,0x00}, // '}' - {0x08,0x04,0x08,0x10,0x08}, // '~' -}; - -// Draw one character at pixel position (x,y), scale 1 or 2, colour/bg. -static void drawChar(uint8_t x, uint8_t y, char c, uint16_t col, uint16_t bg, uint8_t scale = 1) { - if (c < 0x20 || c > 0x7E) c = '?'; - const uint8_t* glyph = FONT5x7[c - 0x20]; - for (int col_i = 0; col_i < 5; col_i++) { - uint8_t col_data = glyph[col_i]; - for (int row_i = 0; row_i < 7; row_i++) { - uint16_t px_col = (col_data & (1 << row_i)) ? col : bg; - uint8_t px = (uint8_t)(x + col_i * scale); - uint8_t py = (uint8_t)(y + row_i * scale); - if (scale == 1) - s_lcd.drawPixel(px, py, px_col); - else - fillRect(px, py, scale, scale, px_col); - } - } -} - -// Draw a string. Returns x after last character. -static uint8_t drawStr(uint8_t x, uint8_t y, const char* str, uint16_t col, - uint16_t bg = C_BG, uint8_t scale = 1) { - uint8_t cw = (uint8_t)(6 * scale); // 5px + 1px gap - while (*str) { - drawChar(x, y, *str++, col, bg, scale); - x = (uint8_t)(x + cw); - } - return x; -} - -// Draw a string centred in a width-w region starting at x. -static void drawStrCentred(uint8_t x, uint8_t y, uint8_t w, const char* str, - uint16_t col, uint16_t bg = C_BG, uint8_t scale = 1) { - int len = (int)strlen(str); - int sw = len * 6 * scale - scale; // total pixel width (no trailing gap) - int offset = ((int)w - sw) / 2; - if (offset < 0) offset = 0; - drawStr((uint8_t)(x + offset), y, str, col, bg, scale); -} - -// Erase a text row (fill with background). -static void clearRow(uint8_t y, uint8_t h) { - fillRect(0, y, 135, h, C_BG); -} - -// --------------------------------------------------------------------------- -// Mode header (top 18px) -// --------------------------------------------------------------------------- - -static const char* modeName(MidiMode m) { - switch (m) { - case MidiMode::RAGA: return "RAGA"; - case MidiMode::DIMENSIONS: return "DIM"; - case MidiMode::TB3PO: return "TB-3PO"; - case MidiMode::BABY8: return "BABY8"; - case MidiMode::EUCLID: return "EUCLID"; - default: return "MIDI"; - } -} - -static uint16_t modeAccent(MidiMode m) { - switch (m) { - case MidiMode::RAGA: return C_ORANGE; - case MidiMode::DIMENSIONS: return C_CYAN; - case MidiMode::TB3PO: return C_GREEN; - case MidiMode::BABY8: return C_MAGENTA; - case MidiMode::EUCLID: return C_RED; - default: return C_GRAY; - } -} - -static void drawHeader(MidiMode m) { - uint16_t acc = modeAccent(m); - fillRect(0, 0, 135, 16, acc); - drawStrCentred(0, 4, 135, modeName(m), C_BG, acc, 2); - hline(0, 16, 135, C_DGRAY); -} - -// --------------------------------------------------------------------------- -// Note name helper -// --------------------------------------------------------------------------- - -static const char* NOTE_NAMES[] = { - "C","C#","D","D#","E","F","F#","G","G#","A","A#","B" -}; - -static void noteName(int midi, char* buf, int bufsz) { - int n = midi % 12; - int oct = midi / 12 - 1; - snprintf(buf, (size_t)bufsz, "%s%d", NOTE_NAMES[n], oct); -} - -// --------------------------------------------------------------------------- -// Per-mode draw functions -// --------------------------------------------------------------------------- - -// -- RAGA --------------------------------------------------------------- -// Layout (y=0..239, portrait): -// 0-16 header -// 18-65 raga name (large, 2×scale) -// 68-79 tala -// 82-93 root + BPM on same row -// 96- play/drone status bars - -static void drawRaga(const MidiController::RagaState& rs) { - static const char* RAGA_NAMES[] = { - "Bhairavi","Lalit","Bhupali","Todi", - "Madhuvanti","Meghmalhar","Yaman","Malkauns" - }; - static const char* TALA_NAMES[] = {"Teental","Rupak","Jhaptal"}; - - auto& c = s_raga; - bool fullRedraw = s_dirty; - - if (fullRedraw) { - fillRect(0, 0, 135, 240, C_BG); - drawHeader(MidiMode::RAGA); - // Static labels - drawStr(2, 70, "TALA", C_GRAY); - drawStr(2, 84, "ROOT", C_GRAY); - drawStr(70, 84, "BPM", C_GRAY); - hline(0, 98, 135, C_DGRAY); - } - - static constexpr int RAGA_NAMES_COUNT = (int)(sizeof(RAGA_NAMES) / sizeof(RAGA_NAMES[0])); - static constexpr int TALA_NAMES_COUNT = (int)(sizeof(TALA_NAMES) / sizeof(TALA_NAMES[0])); - - // Raga name (big, 2× scale) - if (fullRedraw || c.raga != rs.raga) { - clearRow(20, 46); - if (rs.raga >= 0 && rs.raga < RAGA_NAMES_COUNT) { - drawStrCentred(0, 22, 135, RAGA_NAMES[rs.raga], C_ORANGE, C_BG, 2); - } else { - drawStrCentred(0, 22, 135, "--", C_ORANGE, C_BG, 2); - } - c.raga = rs.raga; - } - - // Tala - if (fullRedraw || c.tala != rs.tala) { - fillRect(36, 68, 99, 10, C_BG); - if (rs.tala >= 0 && rs.tala < TALA_NAMES_COUNT) { - drawStr(36, 68, TALA_NAMES[rs.tala], C_YELLOW); - } else { - drawStr(36, 68, "--", C_YELLOW); - } - c.tala = rs.tala; - } - - // Root - if (fullRedraw || c.root != rs.rootNote) { - fillRect(36, 82, 32, 10, C_BG); - char buf[8]; noteName(rs.rootNote, buf, sizeof(buf)); - drawStr(36, 82, buf, C_CYAN); - c.root = rs.rootNote; - } - - // BPM - if (fullRedraw || c.bpm != rs.bpm) { - fillRect(96, 82, 39, 10, C_BG); - char buf[6]; snprintf(buf, sizeof(buf), "%d", rs.bpm); - drawStr(96, 82, buf, C_WHITE); - c.bpm = rs.bpm; - } - - // Play status bar - if (fullRedraw || c.playing != rs.playing) { - uint16_t col = rs.playing ? C_RED : C_DGRAY; - fillRect(0, 100, 135, 28, col); - drawStrCentred(0, 107, 135, rs.playing ? "PLAYING" : "STOPPED", C_WHITE, col, 2); - c.playing = rs.playing; - } - - // Drone bar - if (fullRedraw || c.drone != rs.droneOn) { - uint16_t col = rs.droneOn ? C_BLUE : C_DGRAY; - fillRect(0, 132, 135, 20, col); - drawStrCentred(0, 137, 135, rs.droneOn ? "DRONE ON" : "DRONE OFF", C_WHITE, col); - c.drone = rs.droneOn; - } - - // Knob legend at bottom - if (fullRedraw) { - uint8_t ly = 170; - struct { const char* k; const char* v; uint16_t col; } rows[] = { - {"K1","RAGA", C_ORANGE}, - {"K2","TALA", C_YELLOW}, - {"K3","ROOT", C_CYAN}, - {"K4","BPM", C_WHITE}, - {"B1","PLAY", C_RED}, - {"B2","DRON", C_BLUE}, - }; - for (auto& r : rows) { - fillRect(0, ly, 135, 10, C_BG); - drawStr(2, ly, r.k, C_GRAY); - drawStr(22, ly, r.v, r.col); - ly = (uint8_t)(ly + 12); - } - } -} - -// -- DIMENSIONS --------------------------------------------------------------- -// Layout: -// 0-16 header -// 18-77 A/B top row, C/D bottom row - coloured value bars -// 80-91 Eq + BPM -// 94-105 Oct + Rnd -// 108- play status + knob legend - -static void drawBar(uint8_t x, uint8_t y, uint8_t w, uint8_t h, - float val, float maxVal, uint16_t col) { - if (maxVal <= 0.0f) { fillRect(x, y, w, h, C_DGRAY); return; } - uint8_t filled = (uint8_t)((val / maxVal) * w); - if (filled > w) filled = w; - fillRect(x, y, filled, h, col); - if (filled < w) fillRect((uint8_t)(x + filled), y, (uint8_t)(w - filled), h, C_DGRAY); -} - -static void drawDimensions(const MidiController::DimensionsState& ds) { - auto& c = s_dim; - bool fullRedraw = s_dirty; - - if (fullRedraw) { - fillRect(0, 0, 135, 240, C_BG); - drawHeader(MidiMode::DIMENSIONS); - // Static labels - drawStr(2, 20, "A", UnitLcd::rgb888to565(0x88DDFF)); - drawStr(69, 20, "B", UnitLcd::rgb888to565(0x88FFCC)); - drawStr(2, 50, "C", UnitLcd::rgb888to565(0xFFCC88)); - drawStr(69, 50, "D", UnitLcd::rgb888to565(0xFF88CC)); - drawStr(2, 82, "EQ", C_ORANGE); - drawStr(70, 82, "BPM", C_YELLOW); - drawStr(2, 96, "OCT", UnitLcd::rgb888to565(0xAAFF88)); - drawStr(70, 96, "RND", UnitLcd::rgb888to565(0xFFAA44)); - hline(0, 110, 135, C_DGRAY); - } - - static const uint16_t BC[4] = { - UnitLcd::rgb888to565(0x88DDFF), UnitLcd::rgb888to565(0x88FFCC), - UnitLcd::rgb888to565(0xFFCC88), UnitLcd::rgb888to565(0xFF88CC) - }; - float vals[4] = { ds.a, ds.b, ds.c, ds.d }; - int cvals[4]= { (int)ds.a, (int)ds.b, (int)ds.c, (int)ds.d }; - int* cprev[4]= { &c.a, &c.b, &c.c, &c.d }; - - for (int i = 0; i < 4; i++) { - if (fullRedraw || *cprev[i] != cvals[i]) { - uint8_t bx = (i & 1) ? 67 : 2; - uint8_t by = (i < 2) ? 30 : 60; - // value text - fillRect(bx, by - 10, 60, 10, C_BG); - char buf[8]; snprintf(buf, sizeof(buf), "%.0f", vals[i]); - drawStr((uint8_t)(bx + 12), (uint8_t)(by - 10), buf, BC[i]); - // bar - drawBar(bx, by, 60, 8, vals[i], 100.0f, BC[i]); - *cprev[i] = cvals[i]; - } - } - - if (fullRedraw || c.eq != ds.equation) { - fillRect(20, 82, 46, 10, C_BG); - char buf[8]; snprintf(buf, sizeof(buf), "%d", ds.equation); - drawStr(20, 82, buf, C_ORANGE); - c.eq = ds.equation; - } - if (fullRedraw || c.bpm != ds.bpm) { - fillRect(90, 82, 45, 10, C_BG); - char buf[6]; snprintf(buf, sizeof(buf), "%d", ds.bpm); - drawStr(90, 82, buf, C_YELLOW); - c.bpm = ds.bpm; - } - if (fullRedraw || c.octave != ds.octave) { - fillRect(22, 96, 46, 10, C_BG); - char buf[8]; snprintf(buf, sizeof(buf), "%+d", ds.octave); - drawStr(22, 96, buf, UnitLcd::rgb888to565(0xAAFF88)); - c.octave = ds.octave; - } - int rndRounded = (int)ds.rnd; - if (fullRedraw || c.rnd_cached != rndRounded) { - fillRect(90, 96, 45, 10, C_BG); - char buf[8]; snprintf(buf, sizeof(buf), "%.0f", ds.rnd); - drawStr(90, 96, buf, UnitLcd::rgb888to565(0xFFAA44)); - c.rnd_cached = rndRounded; - } - - if (fullRedraw || c.playing != ds.playing) { - uint16_t col = ds.playing ? C_GREEN : C_DGRAY; - fillRect(0, 112, 135, 20, col); - drawStrCentred(0, 117, 135, ds.playing ? "PLAYING" : "STOPPED", C_WHITE, col); - c.playing = ds.playing; - } -} - -// -- TB3PO --------------------------------------------------------------- -// Layout: -// 0-16 header -// 18-49 32-step mini piano roll (6×12 px cells at an 8 px pitch, 2 rows of 16) -// 52-63 key + scale -// 66-77 density + BPM -// 80-91 play status - -static constexpr const char* TB3_SCALE_NAMES[] = { - "Major","Minor","Dorian","Penta","Blues","Chrome" -}; -static constexpr int TB3_SCALE_NAMES_COUNT = (int)(sizeof(TB3_SCALE_NAMES) / sizeof(TB3_SCALE_NAMES[0])); -static void drawTb3po(const MidiController::Tb3poState& ts) { - auto& c = s_tb3; - bool fullRedraw = s_dirty; - - if (fullRedraw) { - fillRect(0, 0, 135, 240, C_BG); - drawHeader(MidiMode::TB3PO); - drawStr(2, 54, "KEY", C_GREEN); - drawStr(70, 54, "SCALE", C_CYAN); - drawStr(2, 68, "DEN", C_YELLOW); - drawStr(70, 68, "BPM", C_WHITE); - hline(0, 82, 135, C_DGRAY); - } - - // 32-step piano roll - two rows of 16 cells (6×12 px, 2 px gaps) - // Row 0: steps 0-15, Row 1: steps 16-31 - // The pattern itself changes on regen, density, or scale edits even when the - // playback position does not, so those also invalidate the step cells. - if (fullRedraw || c.step != ts.currentStep || c.seed != ts.seed || - c.playing != ts.playing || c.patDensity != ts.density || c.patScale != ts.scale) { - for (int i = 0; i < 32; i++) { - int row = i / 16; - int col_i = i % 16; - uint8_t x = (uint8_t)(2 + col_i * 8); - uint8_t y = (uint8_t)(20 + row * 15); - bool active = ts.playing && (i == ts.currentStep); - bool hasNote = ts.steps[i].gate; - uint16_t col; - if (active) col = C_WHITE; - else if (hasNote) col = C_GREEN; - else col = C_DGRAY; - fillRect(x, y, 6, 12, col); - // Accent dot - if (ts.steps[i].accent && !active) - fillRect((uint8_t)(x+2), (uint8_t)(y+1), 2, 2, C_YELLOW); - } - c.step = ts.currentStep; - c.seed = ts.seed; - c.patDensity = ts.density; - c.patScale = ts.scale; - } - - if (fullRedraw || c.key != ts.key) { - fillRect(22, 54, 46, 10, C_BG); - int safeKey = ((ts.key % 12) + 12) % 12; - drawStr(22, 54, KEY_NAMES[safeKey], C_GREEN); - c.key = ts.key; - } - if (fullRedraw || c.scale != ts.scale) { - fillRect(96, 54, 39, 10, C_BG); - drawStr(96, 54, TB3_SCALE_NAMES[ts.scale % TB3_SCALE_NAMES_COUNT], C_CYAN); - c.scale = ts.scale; - } - if (fullRedraw || c.density != ts.density) { - fillRect(22, 68, 46, 10, C_BG); - char buf[4]; snprintf(buf, sizeof(buf), "%d", ts.density); - drawStr(22, 68, buf, C_YELLOW); - c.density = ts.density; - } - if (fullRedraw || c.bpm != ts.bpm) { - fillRect(96, 68, 39, 10, C_BG); - char buf[6]; snprintf(buf, sizeof(buf), "%d", ts.bpm); - drawStr(96, 68, buf, C_WHITE); - c.bpm = ts.bpm; - } - if (fullRedraw || c.playing != ts.playing) { - uint16_t col = ts.playing ? C_GREEN : C_DGRAY; - fillRect(0, 84, 135, 20, col); - drawStrCentred(0, 89, 135, ts.playing ? "PLAYING" : "STOPPED", C_WHITE, col); - c.playing = ts.playing; - } -} - -// -- BABY8 --------------------------------------------------------------- -// Layout: -// 0-16 header -// 18- 8 step cells, each 28px tall: -// note name, gate indicator, current step highlight - -static void drawBaby8(const MidiController::Baby8State& bs) { - auto& c = s_b8; - bool fullRedraw = s_dirty; - - if (fullRedraw) { - fillRect(0, 0, 135, 240, C_BG); - drawHeader(MidiMode::BABY8); - } - - for (int i = 0; i < 8; i++) { - bool stepChanged = fullRedraw || c.step != bs.currentStep || c.playing != bs.playing; - bool noteChanged = fullRedraw || c.note[i] != bs.steps[i].note; - bool gateChanged = fullRedraw || c.gate[i] != bs.steps[i].gate; - - if (!stepChanged && !noteChanged && !gateChanged) continue; - - uint8_t y = (uint8_t)(18 + i * 27); - bool isCurrent = bs.playing && (i == bs.currentStep); - bool gateOn = bs.steps[i].gate; - - uint16_t bgcol = isCurrent ? C_WHITE : (gateOn ? C_DGRAY : C_BG); - uint16_t fgcol = isCurrent ? C_BG : (gateOn ? C_GREEN : C_GRAY); - - fillRect(0, y, 135, 25, bgcol); - - // Step number - char sbuf[4]; snprintf(sbuf, sizeof(sbuf), "%d", i + 1); - drawStr(4, (uint8_t)(y + 8), sbuf, isCurrent ? C_BG : C_GRAY, bgcol); - - // Note name - char nbuf[8]; noteName(bs.steps[i].note, nbuf, sizeof(nbuf)); - drawStr(24, (uint8_t)(y + 4), nbuf, fgcol, bgcol, 2); - - // Gate dot - uint16_t dotcol = gateOn ? (isCurrent ? C_BG : C_GREEN) : C_DGRAY; - fillRect(115, (uint8_t)(y + 8), 12, 8, dotcol); - - hline(0, (uint8_t)(y + 25), 135, C_DGRAY); - - c.note[i] = bs.steps[i].note; - c.gate[i] = bs.steps[i].gate; - } - c.step = bs.currentStep; - c.playing = bs.playing; -} - -// -- EUCLID --------------------------------------------------------------- -// Layout: -// 0-16 header -// 18-33 voice tabs (8 mini boxes) -// 38-53 selected voice name (2x scale) -// 80-150 steps / events / rotation / note / BPM -// 156-174 play status, 176-236 knob legend - -static void drawEuclid(const MidiController::EuclidState& es) { - auto& c = s_euc; - bool fullRedraw = s_dirty; - - if (fullRedraw) { - fillRect(0, 0, 135, 240, C_BG); - drawHeader(MidiMode::EUCLID); - drawStr(2, 80, "STEPS", C_GRAY); - drawStr(2, 94, "EVENTS", C_GRAY); - drawStr(2, 108, "ROT", C_GRAY); - drawStr(2, 122, "NOTE", C_GRAY); - drawStr(2, 140, "BPM", C_GRAY); - hline(0, 154, 135, C_DGRAY); - } - - // Capture voice-changed flag before updating the cache - bool voiceChanged = (c.voice != es.selectedVoice); - - // Selected voice index - int idx = es.selectedVoice & 7; - - // Voice tabs - if (fullRedraw || voiceChanged) { - for (int i = 0; i < 8; i++) { - uint8_t tx = (uint8_t)(i * 17); - uint16_t col = VOICE_COL[i]; - uint16_t bg = (i == idx) ? col : C_DGRAY; - fillRect(tx, 18, 15, 15, bg); - char vb[2] = { (char)('1' + i), 0 }; - drawChar((uint8_t)(tx + 4), 22, vb[0], - (i == idx) ? C_BG : C_GRAY, bg); - } - c.voice = es.selectedVoice; - } - - // Selected voice name - if (fullRedraw || voiceChanged) { - clearRow(36, 18); - char vbuf[16]; snprintf(vbuf, sizeof(vbuf), "VOICE %d", idx + 1); - drawStrCentred(0, 38, 135, vbuf, VOICE_COL[idx], C_BG, 2); - } - - const auto& sv = es.voice[idx]; - - auto drawVal = [&](uint8_t y, int val, int& cached, uint16_t col) { - if (fullRedraw || cached != val || voiceChanged) { - fillRect(54, y, 81, 10, C_BG); - char b[8]; snprintf(b, sizeof(b), "%d", val); - drawStr(54, y, b, col); - cached = val; - } - }; - - drawVal(80, sv.steps, c.steps, C_YELLOW); - drawVal(94, sv.events, c.events, C_CYAN); - drawVal(108, sv.rotation, c.rot, UnitLcd::rgb888to565(0xAAFF88)); - - if (fullRedraw || c.note != sv.note || voiceChanged) { - fillRect(54, 122, 81, 10, C_BG); - char nb[8]; noteName(sv.note, nb, sizeof(nb)); - drawStr(54, 122, nb, UnitLcd::rgb888to565(0xFF88CC)); - c.note = sv.note; - } - - if (fullRedraw || c.bpm != es.bpm) { - fillRect(54, 140, 81, 10, C_BG); - char b[8]; snprintf(b, sizeof(b), "%d", es.bpm); - drawStr(54, 140, b, C_WHITE); - c.bpm = es.bpm; - } - - if (fullRedraw || c.playing != es.playing) { - uint16_t col = es.playing ? C_RED : C_DGRAY; - fillRect(0, 156, 135, 18, col); - drawStrCentred(0, 160, 135, es.playing ? "PLAYING" : "STOPPED", C_WHITE, col); - c.playing = es.playing; - } - - // Knob legend - if (fullRedraw || voiceChanged) { - uint8_t ly = 176; - struct { const char* k; const char* v; uint16_t col; } rows[] = { - {"K1","STEPS", C_YELLOW}, - {"K2","EVENTS", C_CYAN}, - {"K3","ROT", UnitLcd::rgb888to565(0xAAFF88)}, - {"K4","NOTE", UnitLcd::rgb888to565(0xFF88CC)}, - {"K5","BPM", C_WHITE}, - {"B", "VOICE", VOICE_COL[idx]}, - }; - for (auto& r : rows) { - fillRect(0, ly, 135, 10, C_BG); - drawStr(2, ly, r.k, C_GRAY); - drawStr(22, ly, r.v, r.col); - ly = (uint8_t)(ly + 10); - } - } -} - -// --------------------------------------------------------------------------- -// Public API -// --------------------------------------------------------------------------- - -bool lcdInit(UnitPaHub* hub) { - s_hub = hub; - if (!s_hub) return false; - - // findGroveI2cDevice() (GroveLookup.h), not a hardcoded "i2c1" name - the grove port device - // is a mode-switchable I2C/UART router, not an I2C controller itself, so device_get_by_name - // on the router's own name crashes (wrong driver data layout for i2c_controller_*() calls). - Device* i2cDev = findGroveI2cDevice(); - if (!i2cDev) { - ESP_LOGE(TAG, "grove0_i2c not found"); - return false; - } - s_i2cDev = i2cDev; - - // Find the LCD by probing every PaHub channel - UnitLcd::begin() - // itself probes the I2C address and returns false if nothing answers, so trying each channel - // in turn and keeping the first hit is safe/cheap (6 channels, worst case). - s_lcdChannel = UnitPaHub::NO_CHANNEL; - for (uint8_t ch = 0; ch < UnitPaHub::NUM_CHANNELS; ch++) { - if (!s_hub->select(ch)) continue; - if (s_lcd.begin(i2cDev, UnitLcd::DEFAULT_ADDR)) { - s_lcdChannel = ch; - break; - } - } - if (s_lcdChannel == UnitPaHub::NO_CHANNEL) { - ESP_LOGW(TAG, "LCD unit not found on any PaHub channel"); - s_i2cDev = nullptr; - return false; - } - s_lcd.setRotation(0); // portrait 135×240 - s_lcd.setBrightness(180); - s_lcd.fillScreen(C_BG); - ESP_LOGI(TAG, "LCD ready on PaHub CH%d", (int)s_lcdChannel); - return true; -} - -void lcdStop() { - if (s_lcd.isPresent() && selectLcd()) { - s_lcd.setBrightness(0); - s_lcd.fillScreen(C_BG); - } - s_i2cDev = nullptr; - s_lcdChannel = UnitPaHub::NO_CHANNEL; -} - -bool lcdIsPresent() { - return s_lcd.isPresent(); -} - -void lcdSetMode(MidiMode mode) { - s_mode = mode; - s_dirty = true; - // Reset all caches so next lcdUpdate does a full redraw - s_raga = {}; s_raga.raga = -1; - s_dim = {}; s_dim.a = -1; - s_tb3 = {}; s_tb3.step = -1; - s_b8 = {}; for (auto& n : s_b8.note) n = -1; - s_euc = {}; s_euc.voice = -1; -} - -void lcdUpdate(MidiController* mc) { - if (!s_lcd.isPresent() || !s_hub) return; - if (!selectLcd()) return; - - switch (s_mode) { - case MidiMode::RAGA: drawRaga (mc->raga_); break; - case MidiMode::DIMENSIONS: drawDimensions(mc->dim_); break; - case MidiMode::TB3PO: drawTb3po (mc->tb3po_); break; - case MidiMode::BABY8: drawBaby8 (mc->baby8_); break; - case MidiMode::EUCLID: drawEuclid (mc->euclid_);break; - default: { - if (s_dirty) { - fillRect(0, 0, 135, 240, C_BG); - drawHeader(s_mode); - } - break; - } - } - s_dirty = false; -} diff --git a/Apps/MidiController/main/Source/LcdDisplay.h b/Apps/MidiController/main/Source/LcdDisplay.h deleted file mode 100644 index 5881271..0000000 --- a/Apps/MidiController/main/Source/LcdDisplay.h +++ /dev/null @@ -1,49 +0,0 @@ -/* - * LcdDisplay.h - per-mode LCD content for the M5Stack LCD Unit (135×240) - * - * The LCD unit usually sits on PaHub CH5, but lcdInit() probes every channel. - * All public functions select the discovered channel, draw, then leave it - * selected. EncoderInput re-selects the encoder channel before it reads, so - * there is no conflict as long as they don't run concurrently - both are - * called from the same LVGL timer thread. - * - * Layout per mode (portrait 135×240, white text on dark background): - * - * HOME / idle: - * Full-screen mode name + "MIDI CTRL" branding - * - * RAGA: - * Large raga name, tala, root note, BPM, play/drone status - * - * DIMENSIONS: - * A B C D values as a 2×2 grid with colour bars, equation, BPM - * - * TB3PO: - * 32-step piano-roll mini view (current step highlighted), key, scale, BPM - * - * BABY8: - * 8-step note grid - note name per step, current step highlighted, gates - * - * EUCLID: - * Selected voice name + params (steps/events/rotation/note), BPM - */ -#pragma once - -#include -#include -#include "MidiController.h" - -// Initialise LCD subsystem. Pass the already-initialised PaHub. -// Returns false if the LCD unit is not found on any PaHub channel. -bool lcdInit(UnitPaHub* hub); - -// Release resources (called from encoderStop). -void lcdStop(); - -bool lcdIsPresent(); - -// Called from switchToMode - clears screen and draws static mode chrome. -void lcdSetMode(MidiMode mode); - -// Called from each dispatch function after state changes. -void lcdUpdate(MidiController* mc); diff --git a/Apps/MidiController/main/Source/MidiController.cpp b/Apps/MidiController/main/Source/MidiController.cpp deleted file mode 100644 index fe22ebe..0000000 --- a/Apps/MidiController/main/Source/MidiController.cpp +++ /dev/null @@ -1,965 +0,0 @@ -#include "MidiController.h" -#include "LcdDisplay.h" - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -static constexpr auto* TAG = "MidiController"; - -MidiController* MidiController::s_instance = nullptr; - -// --------------------------------------------------------------------------- -// Theme colors -// --------------------------------------------------------------------------- - -static lv_color_t MC_WARNING() { return lv_color_hex(0xFFE000); } -static lv_color_t MC_ERROR() { return lv_color_hex(0xFF2020); } - -// MIDI note numbers for the four drum tracks (KICK, SNARE, HIHAT, OPEN) -static constexpr uint8_t DRUM_NOTES[] = { 36, 38, 42, 46 }; - -// Per-track colors for BEATS mode (KICK, SNARE, HIHAT, OPEN) -static lv_color_t TRACK_COLOR(int t) { - switch (t) { - case 0: return MC_ERROR(); - case 1: return MC_WARNING(); - case 2: return MC_PRIMARY(); - case 3: return lv_color_hex(0xCC44FF); - default: return MC_SURFACE(); - } -} - -// --------------------------------------------------------------------------- -// Scaling helper - always based on display width, never parent -// --------------------------------------------------------------------------- - - -static inline lv_coord_t dispW() { - return (lv_coord_t)lv_display_get_horizontal_resolution(nullptr); -} - -static inline lv_coord_t dispH() { - return (lv_coord_t)lv_display_get_vertical_resolution(nullptr); -} - -// --------------------------------------------------------------------------- -// Style helpers -// --------------------------------------------------------------------------- - -static void styleContainer(lv_obj_t* obj, lv_color_t bg = {}, bool transparent = true) { - if (transparent) { - lv_obj_set_style_bg_opa(obj, LV_OPA_TRANSP, 0); - } else { - lv_obj_set_style_bg_color(obj, bg, 0); - lv_obj_set_style_bg_opa(obj, LV_OPA_COVER, 0); - } - lv_obj_set_style_border_width(obj, 0, 0); - lv_obj_set_style_pad_all(obj, 0, 0); - lv_obj_set_scrollable(obj, false); -} - -// --------------------------------------------------------------------------- -// MIDI transport - BLE (MediaKeys-style radio management) or USB, chosen from the transport -// selector overlay before the toolbar switch is turned on. sendMidi() dispatches to whichever -// one is currently running (only one of bleRunning_/usbRunning_ is ever true). -// --------------------------------------------------------------------------- - -// Only reached via handleTransportToggle() (radio already on) or handleBtEvent() (radio just -// turned on after handleTransportToggle() requested it) - btDevice_ is always the ref'd pointer -// device_get_first_by_type() gave handleTransportToggle() in both cases, so every path that -// clears it here must device_put() first. -void MidiController::startBleMidi() { - radioEnabling_ = false; - struct Device* midiDev = bluetooth_midi_get(); - if (!midiDev) { - ESP_LOGE(TAG, "No BLE MIDI device after radio on"); - if (btDevice_) { - unsubscribeBtEvents(); - device_put(btDevice_); - } - btDevice_ = nullptr; - bleRunning_ = false; - failTransportStart(); - return; - } - error_t startErr = bluetooth_midi_start(midiDev); - device_put(midiDev); // bluetooth_midi_get() returns a referenced device - if (startErr != ERROR_NONE) { - ESP_LOGW(TAG, "bluetooth_midi_start failed"); - if (btDevice_) { - unsubscribeBtEvents(); - device_put(btDevice_); - } - btDevice_ = nullptr; - bleRunning_ = false; - failTransportStart(); - return; - } - bleRunning_ = true; - ESP_LOGI(TAG, "BLE MIDI started"); -} - -void MidiController::stopBleMidi() { - if (bleRunning_) { - sendMidi(0xB0, 123, 0); // CC 123 = All Notes Off - struct Device* midiDev = bluetooth_midi_get(); - if (midiDev) { - bluetooth_midi_stop(midiDev); - device_put(midiDev); // bluetooth_midi_get() returns a referenced device - } - bleRunning_ = false; - } -} - -void MidiController::startUsbMidi() { - struct Device* dev = usb_midi_device_get(); - if (!dev) { - ESP_LOGE(TAG, "No USB MIDI device available"); - failTransportStart(); - return; - } - usb_midi_device_set_name(dev, "TT MIDI"); - error_t err = usb_midi_device_start(dev); - device_put(dev); - if (err != ERROR_NONE) { - ESP_LOGW(TAG, "usb_midi_device_start failed: %d (slot busy?)", (int)err); - failTransportStart(); - return; - } - usbRunning_ = true; - ESP_LOGI(TAG, "USB MIDI started"); -} - -void MidiController::stopUsbMidi() { - if (usbRunning_) { - sendMidi(0xB0, 123, 0); // CC 123 = All Notes Off - struct Device* dev = usb_midi_device_get(); - if (dev) { - usb_midi_device_stop(dev); - device_put(dev); - } - usbRunning_ = false; - } -} - -void MidiController::handleBtEvent(const struct BtEvent& event) { - if (event.type == BT_EVENT_RADIO_STATE_CHANGED) { - ESP_LOGI(TAG, "BT radio state: %d", (int)event.radio_state); - if (event.radio_state == BT_RADIO_STATE_ON && radioEnabling_) { - startBleMidi(); - } else if (event.radio_state == BT_RADIO_STATE_OFF && bleRunning_) { - ESP_LOGI(TAG, "BT radio turned off externally, stopping MIDI"); - sendMidi(0xB0, 123, 0); // best-effort All Notes Off; the radio may already be down - bleRunning_ = false; - failTransportStart(); - } - } -} - -void MidiController::pollBtEvents() { - if (!btEventSubscribed_) return; - struct BtEvent event {}; - while (bluetooth_event_poll(&btEventSub_, &event) == ERROR_NONE) { - handleBtEvent(event); - } - // If the radio dropped externally, the event handler only updated the UI. - // Do not unsubscribe from inside the poll callback (that would destroy the - // subscription while the loop is still using it); clean up once the queue - // is drained and no enable request is in flight. - if (!bleRunning_ && !radioEnabling_ && btEventSubscribed_) { - unsubscribeBtEvents(); - if (btDevice_) { - device_put(btDevice_); - btDevice_ = nullptr; - } - radioWasOff_ = false; - } -} - -void MidiController::subscribeBtEvents() { - if (btEventSubscribed_ || btDevice_ == nullptr) return; - btEventSub_ = {}; - bluetooth_event_subscribe(btDevice_, &btEventSub_, eventGroup_); - btEventSubscribed_ = true; -} - -void MidiController::unsubscribeBtEvents() { - if (!btEventSubscribed_) return; - if (btDevice_ != nullptr) { - bluetooth_event_unsubscribe(btDevice_, &btEventSub_); - } - btEventSubscribed_ = false; -} - -void MidiController::failTransportStart() { - lvgl_lock(); - if (switchWidget_) lv_obj_remove_state(switchWidget_, LV_STATE_CHECKED); - if (transportBtn_) lv_obj_remove_state(transportBtn_, LV_STATE_DISABLED); - lvgl_unlock(); -} - -void MidiController::enableTransportButton() { - lvgl_lock(); - if (transportBtn_) lv_obj_remove_state(transportBtn_, LV_STATE_DISABLED); - lvgl_unlock(); -} - -// Runs on the LVGL task: only the UI changes here. Starting a transport can write to flash or NVS, -// which isn't possible on the LVGL task, so the app's task does that in applyTransport(). -void MidiController::handleTransportToggle(bool enabled) { - ESP_LOGI(TAG, "Transport toggle: %s", enabled ? "ON" : "OFF"); - if (enabled) { - // Close the transport chooser if it is still open - if (transportOverlay_) { - lv_obj_del(transportOverlay_); - transportOverlay_ = nullptr; - // First launch: the chooser was the only content. Open HOME before starting, - // otherwise the switch would leave the content area empty. - if (currentViewRoot_ == nullptr) { - currentMode_ = MidiMode::HOME; - buildHomeView(contentArea_); - } - } - if (transportBtn_) lv_obj_add_state(transportBtn_, LV_STATE_DISABLED); - } - transportRequest_.store(enabled ? 1 : 0); - task_event_group_signal(eventGroup_, transportRequestBit_); -} - -// Runs on the app's task -void MidiController::applyTransport(bool enabled) { - radioEnabling_ = false; - if (enabled) { - if (activeTransport_ == MidiTransport::USB) { - startUsbMidi(); - return; - } - - // bluetooth_find_first_ready_device() only finds an already-started device, so it can't - // be used here: on a cold boot the BT device may not be started yet so this toggle would silently do nothing. - // device_get_first_by_type() finds the device regardless of ready state; start it on demand. - if (device_get_first_by_type(&BLUETOOTH_TYPE, &btDevice_) != ERROR_NONE) { - ESP_LOGE(TAG, "No Bluetooth device found"); - failTransportStart(); - return; - } - if (!device_is_ready(btDevice_) && device_start(btDevice_) != ERROR_NONE) { - ESP_LOGE(TAG, "Failed to start Bluetooth device"); - device_put(btDevice_); - btDevice_ = nullptr; - failTransportStart(); - return; - } - bluetooth_set_device_name(btDevice_, "TT MIDI"); - subscribeBtEvents(); - - enum BtRadioState radioState; - bluetooth_get_radio_state(btDevice_, &radioState); - if (radioState == BT_RADIO_STATE_ON) { - radioWasOff_ = false; - startBleMidi(); - } else { - ESP_LOGI(TAG, "BT radio not on (state=%d), enabling...", (int)radioState); - radioWasOff_ = true; - radioEnabling_ = true; - bluetooth_set_radio_enabled(btDevice_, true); - } - } else { - if (activeTransport_ == MidiTransport::USB) { - stopUsbMidi(); - } else { - stopBleMidi(); - if (btDevice_) { - unsubscribeBtEvents(); - if (radioWasOff_) bluetooth_set_radio_enabled(btDevice_, false); - device_put(btDevice_); - btDevice_ = nullptr; - } - radioWasOff_ = false; - } - enableTransportButton(); - } -} - -void MidiController::attachEventGroup(struct TaskEventGroup* group) { - eventGroup_ = group; - task_event_group_claim_bit(group, &transportRequestBit_); -} - -void MidiController::processEvents() { - const int request = transportRequest_.exchange(-1); - if (request >= 0) { - applyTransport(request == 1); - } - pollBtEvents(); -} - -void MidiController::sendMidi(uint8_t cmd, uint8_t d1, uint8_t d2) { - uint8_t msg[3] = { cmd, d1, d2 }; - if (bleRunning_) { - struct Device* dev = bluetooth_midi_get(); - if (dev) { - bluetooth_midi_send(dev, msg, 3); - device_put(dev); // bluetooth_midi_get() returns a referenced device - } - } else if (usbRunning_) { - struct Device* dev = usb_midi_device_get(); - if (dev) { - usb_midi_device_send(dev, msg, 3); - device_put(dev); - } - } -} - -void MidiController::stopAllNotes() { - for (int i = 0; i < 128; i++) { - sendMidi(0x80, (uint8_t)i, 0); - } -} - -// --------------------------------------------------------------------------- -// Transport selector -// --------------------------------------------------------------------------- - -void MidiController::updateTransportButton() { - if (!transportBtn_) return; - lv_obj_t* lbl = lv_obj_get_child(transportBtn_, 0); - if (lbl) lv_label_set_text(lbl, activeTransport_ == MidiTransport::USB ? "USB" : "BLE"); -} - -void MidiController::showTransportSelect() { - if (transportOverlay_ || bleRunning_ || usbRunning_) return; - transportOverlay_ = buildTransportSelectOverlay(contentArea_); -} - -lv_obj_t* MidiController::buildTransportSelectOverlay(lv_obj_t* parent) { - const lv_font_t* fontSmall = lvgl_get_text_font(FONT_SIZE_SMALL); - const lv_font_t* fontDefault = lvgl_get_text_font(FONT_SIZE_DEFAULT); - const lv_font_t* fontLarge = lvgl_get_text_font(FONT_SIZE_LARGE); - - lv_obj_t* root = mcContainer(parent); - lv_obj_set_size(root, LV_PCT(100), LV_PCT(100)); - lv_obj_set_flex_flow(root, LV_FLEX_FLOW_COLUMN); - lv_obj_set_flex_align(root, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); - lv_obj_set_style_bg_color(root, MC_BG(), 0); - lv_obj_set_style_bg_opa(root, LV_OPA_COVER, 0); - lv_obj_set_style_pad_all(root, 32, 0); - lv_obj_set_style_pad_gap(root, 18, 0); - lv_obj_set_scrollable(root, false); - lv_obj_set_clickable(root, true); // swallow taps on empty space - lv_obj_move_foreground(root); - - mcLabel(root, "SELECT MIDI TRANSPORT", fontLarge, MC_PRIMARY()); - mcLabel(root, "Choose how this app sends MIDI.", fontDefault, MC_LABEL()); - - lv_obj_t* row = mcContainer(root); - lv_obj_set_size(row, LV_PCT(100), 280); - lv_obj_set_flex_flow(row, LV_FLEX_FLOW_ROW); - lv_obj_set_flex_align(row, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); - lv_obj_set_style_pad_gap(row, 24, 0); - - struct CardSpec { - const char* title; - const char* subtitle; - MidiTransport transport; - }; - static const CardSpec CARDS[] = { - { "BLUETOOTH LE", "Wireless MIDI", MidiTransport::BLE }, - { "USB", "Wired MIDI device", MidiTransport::USB }, - }; - - for (const CardSpec& spec : CARDS) { - bool selected = (activeTransport_ == spec.transport); - - lv_obj_t* card = mcButton(row); - lv_obj_set_size(card, 420, 260); - lv_obj_set_style_bg_color(card, selected ? MC_SURFACE() : MC_PANEL(), 0); - lv_obj_set_style_bg_opa(card, LV_OPA_COVER, 0); - lv_obj_set_style_radius(card, 20, 0); - lv_obj_set_style_border_width(card, selected ? 3 : 1, 0); - lv_obj_set_style_border_color(card, selected ? MC_PRIMARY() : lv_color_hex(0x223344), 0); - lv_obj_set_style_pad_all(card, 20, 0); - lv_obj_set_style_pad_gap(card, 10, 0); - lv_obj_set_flex_flow(card, LV_FLEX_FLOW_COLUMN); - lv_obj_set_flex_align(card, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); - lv_obj_set_user_data(card, (void*)(intptr_t)(spec.transport == MidiTransport::USB ? 1 : 0)); - lv_obj_add_event_cb(card, cbTransportCard, LV_EVENT_CLICKED, this); - - mcLabel(card, spec.title, fontLarge, selected ? MC_PRIMARY() : MC_TEXT()); - mcLabel(card, spec.subtitle, fontSmall, MC_LABEL()); - if (selected) mcLabel(card, "CURRENT", fontSmall, MC_PRIMARY()); - } - - if (currentViewRoot_ != nullptr) { - mcLabel(root, "The toolbar switch starts and stops sending.", fontSmall, MC_LABEL()); - mcBtn(root, "CANCEL", 160, MC_BTN_H, cbTransportCancel, this); - } else { - mcLabel(root, "Choose a transport to open the controller.", fontSmall, MC_LABEL()); - } - - return root; -} - -void MidiController::cbTransportSelect(lv_event_t* e) { - auto* mc = static_cast(lv_event_get_user_data(e)); - if (mc) mc->showTransportSelect(); -} - -void MidiController::cbTransportCard(lv_event_t* e) { - auto* mc = static_cast(lv_event_get_user_data(e)); - lv_obj_t* card = lv_event_get_target_obj(e); - if (!mc || !card) return; - - intptr_t id = (intptr_t)lv_obj_get_user_data(card); - mc->activeTransport_ = (id == 1) ? MidiTransport::USB : MidiTransport::BLE; - mc->updateTransportButton(); - - if (mc->transportOverlay_) { - lv_obj_del(mc->transportOverlay_); - mc->transportOverlay_ = nullptr; - } - - // First launch: there is no view behind the overlay yet - open the home grid. - if (mc->currentViewRoot_ == nullptr) { - mc->currentMode_ = MidiMode::HOME; - mc->buildHomeView(mc->contentArea_); - } -} - -void MidiController::cbTransportCancel(lv_event_t* e) { - auto* mc = static_cast(lv_event_get_user_data(e)); - if (!mc || !mc->transportOverlay_) return; - lv_obj_del(mc->transportOverlay_); - mc->transportOverlay_ = nullptr; -} - -// --------------------------------------------------------------------------- -// Mode timer management -// --------------------------------------------------------------------------- - -void MidiController::stopAllModeTimers() { - if (arpTimer_) { lv_timer_delete(arpTimer_); arpTimer_ = nullptr; } - if (lfoTimer_) { lv_timer_delete(lfoTimer_); lfoTimer_ = nullptr; } - if (zenTimer_) { lv_timer_delete(zenTimer_); zenTimer_ = nullptr; } - if (dropTimer_) { lv_timer_delete(dropTimer_); dropTimer_ = nullptr; } - if (rngTimer_) { lv_timer_delete(rngTimer_); rngTimer_ = nullptr; } - if (ragaTimer_) { lv_timer_delete(ragaTimer_); ragaTimer_ = nullptr; } - if (dimTimer_) { lv_timer_delete(dimTimer_); dimTimer_ = nullptr; } - if (baby8Timer_) { lv_timer_delete(baby8Timer_); baby8Timer_ = nullptr; } - if (tb3poTimer_) { lv_timer_delete(tb3poTimer_); tb3poTimer_ = nullptr; } - if (euclidTimer_){ lv_timer_delete(euclidTimer_);euclidTimer_= nullptr; } -} - -// --------------------------------------------------------------------------- -// Navigation -// --------------------------------------------------------------------------- - -void MidiController::switchToMode(MidiMode mode) { - // Close the transport chooser if it is still open - if (transportOverlay_) { - lv_obj_del(transportOverlay_); - transportOverlay_ = nullptr; - } - - // Stop active mode timers - stopAllModeTimers(); - - // Stop sequencer if leaving BEATS - if (currentMode_ == MidiMode::BEATS && seq_ && seq_->isPlaying()) { - seq_->stop(); - beats_.playing = false; - } - - // Send All-Notes-Off (CC 123) - single packet instead of 128 note-offs - sendMidi(0xB0, 123, 0); - - // Null all LVGL widget pointers BEFORE deleting the tree, so any callbacks - // fired during deletion (LV_EVENT_DELETE, pending timers) see null pointers. - // Data fields (octave, bpm, scale, etc.) are preserved across mode switches. - zen_.canvas = nullptr; zen_.statusLbl = nullptr; zen_.ballCntLbl = nullptr; zen_.octLbl = nullptr; - for (auto& b : zen_.ballObj) b = nullptr; - - drop_.canvas = nullptr; drop_.statusLbl = nullptr; - drop_.modeLbl = nullptr; drop_.octLbl = nullptr; drop_.modeBtn = nullptr; - for (auto& b : drop_.ballObj) b = nullptr; - for (auto& l : drop_.platLbl) l = nullptr; - - xy_.canvas = nullptr; xy_.indicator = nullptr; - xy_.xCCLbl = nullptr; xy_.yCCLbl = nullptr; xy_.valLbl = nullptr; - - for (auto& k : keys_.whiteKey) k = nullptr; - for (auto& k : keys_.blackKey) k = nullptr; - keys_.pianoArea = nullptr; - keys_.octLbl = nullptr; keys_.keyLbl = nullptr; - keys_.scaleLbl = nullptr; keys_.statusLbl = nullptr; - - beats_.playBtn = nullptr; beats_.playLbl = nullptr; beats_.bpmLbl = nullptr; - for (int t = 0; t < Sequencer::NUM_TRACKS; t++) - for (int s = 0; s < Sequencer::NUM_STEPS; s++) - beats_.stepBtn[t][s] = nullptr; - - for (auto& k : arp_.pianoKey) k = nullptr; - arp_.patternLbl = nullptr; arp_.chordLbl = nullptr; - arp_.speedLbl = nullptr; arp_.bpmLbl = nullptr; - arp_.octLbl = nullptr; arp_.pianoOctLbl = nullptr; - arp_.statusLbl = nullptr; arp_.noteLbl = nullptr; - - for (auto& b : chord_.chordBtn) b = nullptr; - chord_.octLbl = nullptr; chord_.scaleLbl = nullptr; - - for (int r = 0; r < 5; r++) - for (int c = 0; c < 8; c++) - grid_.cell[r][c] = nullptr; - grid_.octLbl = nullptr; grid_.noteLbl = nullptr; - - lfo_.canvas = nullptr; lfo_.startBtn = nullptr; lfo_.startLbl = nullptr; - lfo_.statusLbl = nullptr; lfo_.rateLbl = nullptr; lfo_.waveLbl = nullptr; - lfo_.amountLbl = nullptr; lfo_.ccLbl = nullptr; lfo_.valueLbl = nullptr; - - rng_.playBtn = nullptr; rng_.playLbl = nullptr; rng_.keyLbl = nullptr; - rng_.scaleLbl = nullptr; rng_.minOctLbl = nullptr; rng_.maxOctLbl = nullptr; - rng_.probLbl = nullptr; rng_.bpmLbl = nullptr; rng_.subdivLbl = nullptr; - rng_.noteLbl = nullptr; - rng_.playing = false; rng_.currentNote = -1; - - raga_.ragaLbl = nullptr; raga_.talaLbl = nullptr; raga_.rootLbl = nullptr; - raga_.bpmLbl = nullptr; raga_.statusLbl = nullptr; raga_.playBtn = nullptr; - raga_.playLbl = nullptr; raga_.droneBtn = nullptr; raga_.droneLbl = nullptr; - raga_.ragaBigLbl= nullptr; raga_.talaBigLbl = nullptr; - raga_.playing = false; raga_.droneOn = false; - if (raga_.droneActive) { sendMidi(0x80, raga_.droneNote, 0); raga_.droneActive = false; } - if (raga_.noteActive) { sendMidi(0x80, raga_.currentNote, 0); raga_.noteActive = false; } - - dim_.eqLbl = nullptr; dim_.aLbl = nullptr; dim_.bLbl = nullptr; - dim_.cLbl = nullptr; dim_.dLbl = nullptr; dim_.outLbl = nullptr; - dim_.octLbl = nullptr; dim_.rndLbl = nullptr; dim_.bpmLbl = nullptr; dim_.playLbl = nullptr; - dim_.playing = false; - if (dim_.lastNote >= 0) { sendMidi(0x80, (uint8_t)dim_.lastNote, 0); dim_.lastNote = -1; } - - for (int i = 0; i < 8; i++) { baby8_.stepBtn[i] = nullptr; baby8_.noteLbl[i] = nullptr; } - baby8_.bpmLbl = nullptr; baby8_.swingLbl = nullptr; baby8_.patLbl = nullptr; - baby8_.editNoteLbl = nullptr; baby8_.editVelLbl = nullptr; baby8_.playLbl = nullptr; - baby8_.playing = false; - if (baby8_.lastNote >= 0) { sendMidi(0x80, (uint8_t)baby8_.lastNote, 0); baby8_.lastNote = -1; } - - tb3po_.canvas = nullptr; tb3po_.densityLbl = nullptr; tb3po_.seedLbl = nullptr; - tb3po_.bpmLbl = nullptr; tb3po_.scaleLbl = nullptr; tb3po_.keyLbl = nullptr; - tb3po_.playLbl = nullptr; - tb3po_.playing = false; - if (tb3po_.lastNote >= 0) { sendMidi(0x80, (uint8_t)tb3po_.lastNote, 0); tb3po_.lastNote = -1; } - tb3po_.lastNoteStep = -1; - - euclid_.canvas = nullptr; euclid_.bpmLbl = nullptr; euclid_.stepsLbl = nullptr; - euclid_.eventsLbl= nullptr; euclid_.rotLbl = nullptr; euclid_.noteLbl = nullptr; - euclid_.voiceLbl = nullptr; euclid_.playLbl = nullptr; euclid_.tripLbl = nullptr; - euclid_.voiceTabRow = nullptr; - euclid_.playing = false; - - // Turn off encoder LEDs before building the new view - // (each view's dispatch sets its own colors on first poll) - encoderSetAllLeds(this, 0x000000); - - // On the next encoder poll, seed btnPrev from the live button state - // without firing any actions - prevents a held button from registering - // as a fresh press in the new view. - encoder_.btnSeedNext = true; - - // Discard any touch still in progress so the tap that triggered this mode - // switch doesn't land as a click on a button in the newly built view. - lv_indev_t* indev = lv_indev_get_next(nullptr); - while (indev) { - lv_indev_wait_release(indev); - indev = lv_indev_get_next(indev); - } - - // Destroy current view tree - if (currentViewRoot_) { - lv_obj_del(currentViewRoot_); - currentViewRoot_ = nullptr; - } - - currentMode_ = mode; - lcdSetMode(mode); // marks LCD dirty - lcdUpdate(this); // paint immediately (no-op if LCD not present) - - if (!contentArea_) return; - - switch (mode) { - case MidiMode::HOME: buildHomeView(contentArea_); break; - case MidiMode::KEYS: buildKeysView (this, contentArea_); break; - case MidiMode::BEATS: buildBeatsView(this, contentArea_); break; - case MidiMode::ZEN: buildZenView (this, contentArea_); break; - case MidiMode::DROP: buildDropView (this, contentArea_); break; - case MidiMode::RNG: buildRngView (this, contentArea_); break; - case MidiMode::XY: buildXYPadView(this, contentArea_); break; - case MidiMode::ARP: buildArpView (this, contentArea_); break; - case MidiMode::GRID: buildGridView (this, contentArea_); break; - case MidiMode::CHORD: buildChordView (this, contentArea_); break; - case MidiMode::LFO: buildLfoView (this, contentArea_); break; - case MidiMode::RAGA: buildRagaView (this, contentArea_); break; - case MidiMode::DIMENSIONS: buildDimensionsView(this, contentArea_); break; - case MidiMode::BABY8: buildBaby8View (this, contentArea_); break; - case MidiMode::TB3PO: buildTb3poView (this, contentArea_); break; - case MidiMode::EUCLID: buildEuclidView (this, contentArea_); break; - default: buildHomeView(contentArea_); break; - } - - // Re-arm wait_release after view construction so any touch that was in - // flight when we entered this function cannot land on buttons in the new view. - { - lv_indev_t* indev = lv_indev_get_next(nullptr); - while (indev) { - lv_indev_wait_release(indev); - indev = lv_indev_get_next(indev); - } - } -} - -// --------------------------------------------------------------------------- -// HOME view - 2×5 mode selection grid -// --------------------------------------------------------------------------- - -static const char* MODE_LABELS[] = { - "KEYS", "BEATS", "ZEN", "DROP", "RNG", - "XY", "ARP", "GRID", "CHORD", "LFO", - "RAGA", "DIM", "BABY8", "TB3PO", "EUCLID" -}; - -static lv_color_t MODE_COLOR(int idx) { - static const uint32_t COLORS[] = { - 0xFF3333, 0xFF8800, 0xFFFF00, 0x33CC33, 0x3399FF, - 0xAA33FF, 0xFF33CC, 0x00FFFF, 0xFF9944, 0x99FF44, - 0xFF8844, 0x44BBFF, 0xFF88FF, 0x88FF44, 0xFF4488 - }; - if (idx >= 0 && idx < 15) return lv_color_hex(COLORS[idx]); - return lv_color_hex(0x888888); -} - -void MidiController::cbModeBtn(lv_event_t* e) { - if (!s_instance) return; - int idx = (int)(intptr_t)lv_event_get_user_data(e); - if (idx >= 0 && idx < (int)MidiMode::COUNT) { - s_instance->switchToMode((MidiMode)idx); - } -} - -void MidiController::buildHomeView(lv_obj_t* parent) { - lv_coord_t W = dispW(); - lv_coord_t H = dispH(); - - lv_obj_t* root = mcContainer(parent); - lv_obj_set_size(root, LV_PCT(100), LV_PCT(100)); - styleContainer(root); - lv_obj_set_flex_flow(root, LV_FLEX_FLOW_COLUMN); - lv_obj_set_style_bg_color(root, MC_BG(), 0); - lv_obj_set_style_bg_opa(root, LV_OPA_COVER, 0); - lv_obj_set_style_pad_all(root, 16, 0); - lv_obj_set_style_pad_gap(root, 16, 0); - currentViewRoot_ = root; - - // Button grid - 3 rows of 5, each row flex_grow=1 so buttons always fill height - lv_coord_t gap = 8; - // Root has pad_all 16 on each side; 5 buttons share the rest minus 4 gaps. - lv_coord_t btnW = (W - 16 * 2 - gap * 4) / 5; - const lv_font_t* btnFont = W < 400 ? lvgl_get_text_font(FONT_SIZE_SMALL) - : lvgl_get_text_font(FONT_SIZE_DEFAULT); - - for (int row = 0; row < 3; row++) { - lv_obj_t* rowObj = mcContainer(root); - lv_obj_set_width(rowObj, LV_PCT(100)); - lv_obj_set_flex_grow(rowObj, 1); - lv_obj_set_flex_flow(rowObj, LV_FLEX_FLOW_ROW); - lv_obj_set_flex_align(rowObj, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); - styleContainer(rowObj); - lv_obj_set_style_bg_color(rowObj, MC_BG(), 0); - lv_obj_set_style_bg_opa(rowObj, LV_OPA_COVER, 0); - lv_obj_set_style_pad_gap(rowObj, gap, 0); - lv_obj_set_style_pad_all(rowObj, 0, 0); - - for (int col = 0; col < 5; col++) { - int i = row * 5 + col; - lv_obj_t* btn = mcButton(rowObj); - lv_obj_set_size(btn, btnW, LV_PCT(100)); - lv_obj_set_style_bg_color(btn, MC_SURFACE(), 0); - lv_obj_set_style_bg_opa(btn, LV_OPA_COVER, 0); - lv_obj_set_style_border_color(btn, MODE_COLOR(i), 0); - lv_obj_set_style_border_width(btn, 8, 0); - lv_obj_set_style_radius(btn, 24, 0); - lv_obj_set_style_pad_all(btn, 8, 0); - lv_obj_add_event_cb(btn, cbModeBtn, LV_EVENT_CLICKED, - (void*)(intptr_t)i); - - // Color accent bar at top of button - lv_obj_t* bar = mcContainer(btn); - lv_obj_set_size(bar, LV_PCT(100), 16); - lv_obj_set_style_bg_color(bar, MODE_COLOR(i), 0); - lv_obj_set_style_bg_opa(bar, LV_OPA_COVER, 0); - lv_obj_set_style_border_width(bar, 0, 0); - lv_obj_set_style_radius(bar, 0, 0); - lv_obj_set_clickable(bar, false); - lv_obj_set_align(bar, LV_ALIGN_TOP_MID); - lv_obj_set_scrollable(bar, false); - - lv_obj_t* lbl = lv_label_create(btn); - lv_label_set_text(lbl, MODE_LABELS[i]); - lv_obj_set_style_text_color(lbl, MC_TEXT(), 0); - lv_obj_set_style_text_font(lbl, btnFont, 0); - lv_obj_center(lbl); - lv_obj_set_clickable(lbl, false); - } - } - (void)H; -} - -// --------------------------------------------------------------------------- -// UI timer - BEATS step indicator -// --------------------------------------------------------------------------- - -void MidiController::cbTransportSwitch(lv_event_t* e) { - if (lv_event_get_code(e) != LV_EVENT_VALUE_CHANGED) return; - auto* mc = static_cast(lv_event_get_user_data(e)); - if (!mc) return; - bool on = lv_obj_has_state(lv_event_get_target_obj(e), LV_STATE_CHECKED); - mc->handleTransportToggle(on); -} - -void MidiController::cbUiTimer(lv_timer_t* t) { - auto* mc = static_cast(lv_timer_get_user_data(t)); - if (!mc) return; - - // BEATS: update step highlight - if (mc->currentMode_ == MidiMode::BEATS && mc->seq_) { - int step = mc->seqPlayStep.load(); - int lastStep = mc->seqLastStep.load(); - if (step != lastStep) { - int prev = lastStep; - mc->seqLastStep.store(step); - - // De-highlight previous step across all tracks - if (prev >= 0 && prev < Sequencer::NUM_STEPS) { - for (int t = 0; t < Sequencer::NUM_TRACKS; t++) { - lv_obj_t* btn = mc->beats_.stepBtn[t][prev]; - if (btn) { - bool active = mc->seq_->getStep(t, prev); - lv_obj_set_style_bg_color(btn, - active ? TRACK_COLOR(t) : MC_SURFACE(), LV_PART_MAIN); - lv_obj_set_style_border_width(btn, 0, LV_PART_MAIN); - } - } - } - // Highlight current step - if (step >= 0 && step < Sequencer::NUM_STEPS) { - for (int t = 0; t < Sequencer::NUM_TRACKS; t++) { - lv_obj_t* btn = mc->beats_.stepBtn[t][step]; - if (btn) { - lv_obj_set_style_bg_color(btn, lv_color_hex(0xFFFFFF), LV_PART_MAIN); - lv_obj_set_style_border_width(btn, 4, LV_PART_MAIN); - lv_obj_set_style_border_color(btn, MC_PRIMARY(), LV_PART_MAIN); - } - } - } - } - - // Check note-offs - uint64_t now = (uint64_t)esp_timer_get_time(); - for (int t = 0; t < Sequencer::NUM_TRACKS; t++) { - if (mc->beats_.noteOffAtUs[t] > 0 && now >= mc->beats_.noteOffAtUs[t]) { - mc->sendMidi(0x80, DRUM_NOTES[t], 0); - mc->beats_.noteOffAtUs[t] = 0; - } - } - } -} - -// --------------------------------------------------------------------------- -// App lifecycle -// --------------------------------------------------------------------------- - -void MidiController::detectBleState() { - // Check if BLE MIDI is already active (e.g. system BT was on before app launched). - // If so, mark bleRunning_ so the app is ready to send. No USB - // equivalent - unlike BLE's system-wide radio, USB device mode has no "already running from - // some other app" case worth auto-detecting (only one class can hold the slot at a time, and - // if MIDI already holds it, that's almost certainly this same app from a previous session). - // - // Uses the same ref-counted device_get_first_by_type() lookup handleTransportToggle() uses - // (rather than bluetooth_find_first_ready_device(), which would also work here since this - // check only fires if the radio's already on) so btDevice_ is always ref'd whenever non-null, - // uniformly - every btDevice_ = nullptr elsewhere in this file can then unconditionally - // device_put() first without needing to track which code path originally acquired it. - if (device_get_first_by_type(&BLUETOOTH_TYPE, &btDevice_) == ERROR_NONE) { - enum BtRadioState radioState; - bluetooth_get_radio_state(btDevice_, &radioState); - if (radioState == BT_RADIO_STATE_ON) { - // Radio is already on - register callback and start MIDI immediately. - radioWasOff_ = false; - bluetooth_set_device_name(btDevice_, "TT MIDI"); - subscribeBtEvents(); - struct Device* midiDev = bluetooth_midi_get(); - bool midiStarted = false; - if (midiDev) { - midiStarted = (bluetooth_midi_start(midiDev) == ERROR_NONE); - device_put(midiDev); // bluetooth_midi_get() returns a referenced device - } - if (midiStarted) { - bleRunning_ = true; - activeTransport_ = MidiTransport::BLE; - } else { - // MIDI start failed - clean up subscription - unsubscribeBtEvents(); - device_put(btDevice_); - btDevice_ = nullptr; - } - } else { - device_put(btDevice_); - btDevice_ = nullptr; - } - } -} - -void MidiController::createWidgets(lv_obj_t* parent, void* userData) { - auto* mc = static_cast(userData); - s_instance = mc; - mc->createWidgetsImpl(parent); -} - -void MidiController::createWidgetsImpl(lv_obj_t* parent) { - lv_obj_set_scrollable(parent, false); - lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN); - lv_obj_set_style_bg_color(parent, MC_BG(), 0); - lv_obj_set_style_bg_opa(parent, LV_OPA_COVER, 0); - - // Toolbar - toolbar_ = lvgl_toolbar_create(parent, "Midi Controller"); - lv_obj_align(toolbar_, LV_ALIGN_TOP_MID, 0, 0); - - // Transport selector button (BLE/USB), added before the switch so it sits to its left within - // the toolbar's action area. Opens the full-screen chooser; disabled while the switch is on so - // the transport can't change mid-stream. - transportBtn_ = lvgl_toolbar_add_text_button_action(toolbar_, "BLE", cbTransportSelect, this); - - switchWidget_ = lvgl_toolbar_add_switch_action(toolbar_); - lv_obj_add_event_cb(switchWidget_, cbTransportSwitch, LV_EVENT_VALUE_CHANGED, this); - - // Main wrapper - mainWrapper_ = mcContainer(parent); - lv_obj_set_width(mainWrapper_, LV_PCT(100)); - lv_obj_set_flex_grow(mainWrapper_, 1); - styleContainer(mainWrapper_); - lv_obj_set_style_bg_color(mainWrapper_, MC_BG(), 0); - lv_obj_set_style_bg_opa(mainWrapper_, LV_OPA_COVER, 0); - - // Content area (fills mainWrapper) - contentArea_ = mcContainer(mainWrapper_); - lv_obj_set_size(contentArea_, LV_PCT(100), LV_PCT(100)); - styleContainer(contentArea_); - lv_obj_set_style_bg_color(contentArea_, MC_BG(), 0); - lv_obj_set_style_bg_opa(contentArea_, LV_OPA_COVER, 0); - - // Create Sequencer - seq_ = std::make_unique(); - seq_->onStep = [](int /*step*/, int track) { - if (!s_instance) return; - static const uint32_t NOTE_OFF_US[] = { 200000, 150000, 50000, 300000 }; - s_instance->sendMidi(0x90, DRUM_NOTES[track], 100); - s_instance->beats_.noteOffAtUs[track] = - (uint64_t)esp_timer_get_time() + NOTE_OFF_US[track]; - }; - seq_->onTick = [](int step) { - if (s_instance) s_instance->seqPlayStep.store(step); - }; - - // detectBleState() found a BLE MIDI session that is already running (e.g. system BT was on - // before the app launched): the switch shows it - if (bleRunning_) { - lv_obj_add_state(switchWidget_, LV_STATE_CHECKED); - lv_obj_add_state(transportBtn_, LV_STATE_DISABLED); - } - - // Start HOME view, or show the transport chooser first (unless BLE was already running). - currentMode_ = MidiMode::HOME; - if (bleRunning_) { - buildHomeView(contentArea_); - } else { - showTransportSelect(); - } - - // UI timer - uiTimer_ = lv_timer_create(cbUiTimer, 20, this); - - // 8Encoder (optional hardware - graceful no-op if not present) - encoderInit(this); -} - -void MidiController::teardown() { - // Stop 8Encoder poll + turn off LEDs - encoderStop(this); - - // Stop UI timer first - if (uiTimer_) { lv_timer_delete(uiTimer_); uiTimer_ = nullptr; } - - // Stop mode timers - stopAllModeTimers(); - - // Stop sequencer - if (seq_) { - seq_->stop(); - seq_.reset(); - } - - // MIDI transport + BLE radio cleanup (MediaKeys pattern). - radioEnabling_ = false; - stopBleMidi(); - stopUsbMidi(); - if (btDevice_) { - unsubscribeBtEvents(); - if (radioWasOff_) bluetooth_set_radio_enabled(btDevice_, false); - device_put(btDevice_); - btDevice_ = nullptr; - } - radioWasOff_ = false; - - resetAllState(); - s_instance = nullptr; -} - -void MidiController::resetAllState() { - toolbar_ = nullptr; - transportBtn_ = nullptr; - transportOverlay_ = nullptr; - mainWrapper_ = nullptr; - switchWidget_ = nullptr; - contentArea_ = nullptr; - currentViewRoot_ = nullptr; - - keys_ = {}; keys_.octave = 4; - for (auto& n : keys_.lastNote) n = -1; - beats_ = {}; beats_.bpm = 120; - xy_ = {}; xy_.xCC = 74; xy_.yCC = 71; xy_.xVal = 64; xy_.yVal = 64; - arp_ = {}; arp_.bpm = 120; arp_.speedIdx = 1; arp_.pianoOctave = 4; - arp_.triggeredNote = -1; arp_.lastNote = -1; - grid_ = {}; for (auto& n : grid_.pressedNote) n = -1; - chord_ = {}; chord_.octave = 4; - lfo_ = {}; lfo_.rate = 1.0f; lfo_.amount = 64; lfo_.ccTarget = 1; lfo_.lastValue = 64; - zen_ = {}; zen_.octave = 4; - drop_ = {}; drop_.octave = 4; - rng_ = {}; rng_.bpm = 120; rng_.subdivision = 0; - rng_.minOctave = 3; rng_.maxOctave = 6; rng_.probability = 50; - raga_ = {}; raga_.rootNote = 48; raga_.bpm = 120; - dim_ = {}; dim_.equation = 1; dim_.a = 50; dim_.b = 50; dim_.c = 50; dim_.d = 50; - dim_.bpm = 120; dim_.t2 = 100; dim_.gran = 1.0f; - dim_.xOff = 64; dim_.yOff = 64; dim_.lastNote = -1; - baby8_ = {}; baby8_.bpm = 120; baby8_.swing = 50; baby8_.lastNote = -1; - for (int i = 0; i < 8; i++) { baby8_.steps[i].note = 60 + i; baby8_.steps[i].velocity = 100; baby8_.steps[i].gate = true; } - for (int p = 0; p < 8; p++) for (int s = 0; s < 8; s++) { baby8_.patterns[p][s].note = 60 + s; baby8_.patterns[p][s].velocity = 100; baby8_.patterns[p][s].gate = true; } - tb3po_ = {}; tb3po_.density = 8; tb3po_.bpm = 120; tb3po_.octave = 3; tb3po_.lastNote = -1; - euclid_= {}; euclid_.bpm = 120; -} diff --git a/Apps/MidiController/main/Source/MidiController.h b/Apps/MidiController/main/Source/MidiController.h deleted file mode 100644 index 86e20c4..0000000 --- a/Apps/MidiController/main/Source/MidiController.h +++ /dev/null @@ -1,567 +0,0 @@ -#pragma once - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include "MidiScales.h" -#include "Sequencer.h" -#include "UiKit.h" - -// --------------------------------------------------------------------------- -// Transport selection - which MIDI output carries sendMidi()'s bytes -// --------------------------------------------------------------------------- - -enum class MidiTransport { - BLE = 0, - USB = 1, -}; - -// --------------------------------------------------------------------------- -// Mode enum -// --------------------------------------------------------------------------- - -enum class MidiMode { - HOME = -1, - KEYS = 0, - BEATS = 1, - ZEN = 2, - DROP = 3, - RNG = 4, - XY = 5, - ARP = 6, - GRID = 7, - CHORD = 8, - LFO = 9, - RAGA = 10, - DIMENSIONS = 11, - BABY8 = 12, - TB3PO = 13, - EUCLID = 14, - COUNT = 15, -}; - -// --------------------------------------------------------------------------- -// Forward declarations for per-view build functions (each in ViewXxx.cpp) -// --------------------------------------------------------------------------- - -class MidiController; -void buildKeysView (MidiController* mc, lv_obj_t* parent); -void buildBeatsView (MidiController* mc, lv_obj_t* parent); -void buildXYPadView (MidiController* mc, lv_obj_t* parent); -void buildArpView (MidiController* mc, lv_obj_t* parent); -void buildGridView (MidiController* mc, lv_obj_t* parent); -void buildChordView (MidiController* mc, lv_obj_t* parent); -void buildLfoView (MidiController* mc, lv_obj_t* parent); -void buildZenView (MidiController* mc, lv_obj_t* parent); -void buildDropView (MidiController* mc, lv_obj_t* parent); -void buildRngView (MidiController* mc, lv_obj_t* parent); -void buildRagaView (MidiController* mc, lv_obj_t* parent); -void buildDimensionsView(MidiController* mc, lv_obj_t* parent); -void buildBaby8View (MidiController* mc, lv_obj_t* parent); -void buildTb3poView (MidiController* mc, lv_obj_t* parent); -void buildEuclidView (MidiController* mc, lv_obj_t* parent); -void euclidOnEncoderUpdate (MidiController* mc); // regen patterns + refresh UI after knob change - -// Encoder action entry points - implemented in each View*.cpp -void ragaTogglePlay (MidiController* mc); -void ragaToggleDrone (MidiController* mc); -void dimTogglePlay (MidiController* mc); -void dimReset (MidiController* mc); -void dimChangeOctave (MidiController* mc, int delta); -void dimChangeRandom (MidiController* mc, int delta); -void tb3TogglePlay (MidiController* mc); -void tb3ChangeScale (MidiController* mc, int delta); -void tb3Regen (MidiController* mc); -void tb3Redraw (MidiController* mc); // repaint piano roll after key/octave changes -void baby8UpdateStepColors(MidiController* mc); // refresh step colours after an encoder gate change - -// 8Encoder hardware support (EncoderInput.cpp) -void encoderInit (MidiController* mc); // detect hardware, start poll timer -void encoderStop (MidiController* mc); // stop poll timer, turn LEDs off -void encoderSetLed(MidiController* mc, uint8_t idx, uint32_t rgb); // set one LED -void encoderSetAllLeds(MidiController* mc, uint32_t rgb); // set all LEDs same color - -// --------------------------------------------------------------------------- -// MidiController -// --------------------------------------------------------------------------- - -class MidiController final { -public: - // window_manager_create()'s WindowCreateWidgetsFn - userData is the MidiController* for this instance. - static void createWidgets(lv_obj_t* parent, void* userData); - - // Call once, after the window has been torn down. - void teardown(); - - // Send a 3-byte MIDI message via BLE MIDI (no-op if not connected) - void sendMidi(uint8_t cmd, uint8_t d1, uint8_t d2); - - // Send Note Off for all 128 notes on channel 1 - void stopAllNotes(); - - // Switch to a mode (or HOME). Stops timers, sends All-Notes-Off, swaps view. - void switchToMode(MidiMode mode); - - // BLE state. Written by the app's own task, read by sendMidi() on any task. - std::atomic bleRunning_ {false}; - - // Called by main() on the app's own task. BLE and USB MIDI start and stop there, as they do - // flash and NVS work, which the LVGL task can't do: its stack is in external RAM. - // attachEventGroup() claims the bit that the LVGL task signals transport requests with. - void attachEventGroup(struct TaskEventGroup* group); - // Picks up a BLE MIDI session that is already running, before the window is created - void detectBleState(); - // Handles the pending transport request and Bluetooth events. Call when the event group wakes. - void processEvents(); - - // Active mode timers - each view creates its own, stored here so - // switchToMode() can delete them before swapping the view tree. - lv_timer_t* arpTimer_ = nullptr; - lv_timer_t* lfoTimer_ = nullptr; - lv_timer_t* zenTimer_ = nullptr; - lv_timer_t* dropTimer_ = nullptr; - lv_timer_t* rngTimer_ = nullptr; - lv_timer_t* ragaTimer_ = nullptr; - lv_timer_t* dimTimer_ = nullptr; - lv_timer_t* baby8Timer_ = nullptr; - lv_timer_t* tb3poTimer_ = nullptr; - lv_timer_t* euclidTimer_ = nullptr; - lv_timer_t* encoderTimer_ = nullptr; // 8Encoder/LCD poll (100 ms) - - // M5Stack 8Encoder state (I2C address 0x41, optional hardware) - struct EncoderState { - bool present = false; // true if detected at 0x41 on startup - int32_t counter[8] = {}; // last read absolute counter per knob - bool btnPrev[8] = {}; // previous button state for edge detection - bool btnSeedNext = false; // if true, next poll seeds btnPrev without firing actions - // LED colors per knob (0xRRGGBB, 0 = off) - uint32_t ledColor[8] = {}; - } encoder_; - - // Drum sequencer (owned here, accessed by ViewBeats) - std::unique_ptr seq_; - std::atomic seqPlayStep{-1}; - std::atomic seqLastStep{-1}; // previous step for highlight refresh - - // Singleton for static callbacks - static MidiController* s_instance; - - // Current view root - set by each buildXxxView, deleted in switchToMode - lv_obj_t* currentViewRoot_ = nullptr; - - // ----------------------------------------------------------------------- - // Per-mode state (inner structs - widget pointers nulled by switchToMode/teardown) - // ----------------------------------------------------------------------- - - struct KeysState { - int octave = 4, scale = 0, key = 0; - // One "currently sounding note" per touch slot (see lvgl_pointer_get_slot_index() in - // ViewKeys.cpp's cbPianoPressing/cbPianoReleased) rather than a single lastNote - lets - // multiple fingers each independently slide/hold their own key. -1 = no note held in - // that slot. Explicitly initialized (not just `= {}`, which would zero-init to note 0 - - // a real playable note, not "empty") both here and again in resetAllState(). The literal - // count below must track LVGL_POINTER_MAX_SLOTS - static_assert catches drift. - int lastNote[LVGL_POINTER_MAX_SLOTS] = { -1, -1, -1, -1, -1 }; - static_assert(LVGL_POINTER_MAX_SLOTS == 5, "update the lastNote initializer above to match"); - // Single octave visible at a time (OCT-/OCT+ shifts it), like SoundBench. - lv_obj_t* pianoArea = nullptr; - lv_obj_t* whiteKey[7] = {}; - lv_obj_t* blackKey[5] = {}; - lv_obj_t* statusLbl = nullptr; - lv_obj_t* scaleLbl = nullptr; - lv_obj_t* keyLbl = nullptr; - lv_obj_t* octLbl = nullptr; - } keys_; - - struct BeatsState { - int bpm = 120; - bool playing = false; - lv_obj_t* stepBtn[Sequencer::NUM_TRACKS][Sequencer::NUM_STEPS] = {}; - lv_obj_t* playBtn = nullptr; - lv_obj_t* playLbl = nullptr; - lv_obj_t* bpmLbl = nullptr; - // per-track note-off scheduling (filled in onStep callback) - uint64_t noteOffAtUs[Sequencer::NUM_TRACKS] = {}; - } beats_; - - struct XYState { - int xCC = 74, yCC = 71; - int xVal = 64, yVal = 64; - lv_obj_t* canvas = nullptr; - lv_obj_t* indicator = nullptr; // circle widget tracking touch position - lv_obj_t* xCCLbl = nullptr; - lv_obj_t* yCCLbl = nullptr; - lv_obj_t* valLbl = nullptr; - // canvas pixel dimensions (set after layout) - int canvasW = 0, canvasH = 0; - } xy_; - - struct ArpState { - int bpm = 120; - int speedIdx = 1; // 0=quarter 1=eighth 2=sixteenth 3=32nd - int pattern = 0; // 0=UP 1=DOWN 2=UPDN 3=RAND 4=CHANCE - int chordType = 0; // 0=MAJ 1=MIN 2=7TH - int octaveRange = 2; - int pianoOctave = 4; - int triggeredNote = -1; // root note being arped (-1=stopped) - bool isPlaying = false; - int arpStep = 0; - int arpDir = 1; // +1 or -1 for UPDN - lv_obj_t* pianoKey[12] = {}; - lv_obj_t* statusLbl = nullptr; - lv_obj_t* noteLbl = nullptr; - lv_obj_t* patternLbl = nullptr; - lv_obj_t* chordLbl = nullptr; - lv_obj_t* speedLbl = nullptr; - lv_obj_t* bpmLbl = nullptr; - lv_obj_t* octLbl = nullptr; - lv_obj_t* pianoOctLbl = nullptr; - int lastNote = -1; // for note-off - } arp_; - - struct GridState { - int octave = 3; - // One held note per touch slot, same pattern as KeysState::lastNote - lets multiple - // fingers each hold their own grid cell instead of the last touch stealing the others'. - // -1 = no note held in that slot. - int pressedNote[LVGL_POINTER_MAX_SLOTS] = { -1, -1, -1, -1, -1 }; - static_assert(LVGL_POINTER_MAX_SLOTS == 5, "update the pressedNote initializer above to match"); - lv_obj_t* cell[5][8] = {}; - lv_obj_t* octLbl = nullptr; - lv_obj_t* noteLbl = nullptr; - } grid_; - - struct ChordState { - int octave = 4; - int scale = 0; - int key = 0; - bool chordPlaying[8] = {}; - // up to 4 notes per chord - int chordNotes[8][4] = {}; - lv_obj_t* chordBtn[8] = {}; - lv_obj_t* octLbl = nullptr; - lv_obj_t* scaleLbl = nullptr; - } chord_; - - struct LfoState { - float rate = 1.0f; // Hz - int amount = 64; // 0-127 scale factor - int ccTarget = 1; // CC number (0-127) when not in pitch mode - bool pitchMode = false; - bool running = false; - int waveform = 0; // 0=SINE 1=TRI 2=SQR 3=SAW - float phase = 0.0f; // 0.0 - 1.0 - int lastValue = 64; - uint64_t lastUpdateUs = 0; - int updateCounter = 0; // throttle label updates - lv_obj_t* canvas = nullptr; - lv_obj_t* statusLbl = nullptr; - lv_obj_t* rateLbl = nullptr; - lv_obj_t* amountLbl = nullptr; - lv_obj_t* ccLbl = nullptr; - lv_obj_t* waveLbl = nullptr; - lv_obj_t* valueLbl = nullptr; - lv_obj_t* startBtn = nullptr; - lv_obj_t* startLbl = nullptr; - int canvasW = 0, canvasH = 0; - } lfo_; - - struct ZenState { - struct Ball { - float x, y, vx, vy; - int radius; - uint32_t color; - bool active = false; - }; - static constexpr int MAX_BALLS = 4; - Ball balls[MAX_BALLS] = {}; - lv_obj_t* ballObj[MAX_BALLS] = {}; // LVGL circle widgets for each ball - int numBalls = 0; - int scale = 0, key = 0, octave = 4; - // Wall flash timers (ms, 0 = no flash) - uint32_t wallFlashUntilMs[24] = {}; // TOP_SEGS(8)+BOTTOM_SEGS(8)+SIDE_SEGS*2(8) = 24 - // Pending note-offs: note value + time to send (0 = empty slot) - static constexpr int MAX_PENDING_OFFS = 8; - uint8_t pendingOffNote[MAX_PENDING_OFFS] = {}; - uint32_t pendingOffMs [MAX_PENDING_OFFS] = {}; - lv_obj_t* canvas = nullptr; - int canvasW = 0, canvasH = 0; - lv_obj_t* statusLbl = nullptr; - lv_obj_t* ballCntLbl = nullptr; - lv_obj_t* octLbl = nullptr; - } zen_; - - struct DropState { - struct DropBall { - float x, y, vx, vy; - bool active = false; - uint32_t spawnedMs = 0; - }; - struct Platform { - float x, y, w; - float angle; // radians, tilt - int note; - }; - static constexpr int MAX_BALLS = 8; - static constexpr int MAX_PLATFORMS = 6; - DropBall balls[MAX_BALLS] = {}; - lv_obj_t* ballObj[MAX_BALLS] = {}; // LVGL circle widgets - lv_obj_t* platLbl[MAX_PLATFORMS] = {}; // LVGL label widgets on canvas - Platform platforms[MAX_PLATFORMS] = {}; - int numPlatforms = 0; - int scale = 0, key = 0, octave = 4; - bool editMode = false; - lv_obj_t* canvas = nullptr; - int canvasW = 0, canvasH = 0; - lv_obj_t* statusLbl = nullptr; - lv_obj_t* modeLbl = nullptr; // shows "DROP" or "EDIT" - lv_obj_t* octLbl = nullptr; - lv_obj_t* modeBtn = nullptr; - } drop_; - - struct RngState { - int bpm = 120; - int subdivision = 0; // index into SUBDIV_NAMES: 0=1/4 1=1/8 2=1/16 - int minOctave = 3; - int maxOctave = 6; - int probability = 50; // 0-100% - int scaleIdx = 0; - int rootKey = 0; // 0-11 - bool playing = false; - int currentNote = -1; - uint64_t nextNoteUs = 0; - uint64_t noteOffUs = 0; - lv_obj_t* noteLbl = nullptr; - lv_obj_t* playBtn = nullptr; - lv_obj_t* playLbl = nullptr; - lv_obj_t* bpmLbl = nullptr; - lv_obj_t* probLbl = nullptr; - lv_obj_t* scaleLbl = nullptr; - lv_obj_t* keyLbl = nullptr; - lv_obj_t* minOctLbl = nullptr; - lv_obj_t* maxOctLbl = nullptr; - lv_obj_t* subdivLbl = nullptr; - } rng_; - - // ----------------------------------------------------------------------- - // New mode states - // ----------------------------------------------------------------------- - - struct RagaState { - int raga = 0; // 0-7 - int tala = 0; // 0-2 - int rootNote = 48; - int bpm = 120; - bool playing = false; - bool droneOn = false; - // Markov / phrase state - int8_t phrase[128] = {}; - int phraseLen = 0, phraseIdx = 0; - int8_t history[2] = {0, 0}; // 2nd-order Markov history - uint32_t nextNoteMs = 0; - uint32_t noteOffMs = 0; - bool noteActive = false; - uint8_t currentNote = 0; - bool droneActive = false; - uint8_t droneNote = 0; - int talaBeat = 0; - uint32_t noteInterval = 150; - uint32_t noteDuration = 120; - // UI - lv_obj_t* ragaLbl = nullptr; - lv_obj_t* talaLbl = nullptr; - lv_obj_t* rootLbl = nullptr; - lv_obj_t* bpmLbl = nullptr; - lv_obj_t* statusLbl = nullptr; - lv_obj_t* playBtn = nullptr; - lv_obj_t* playLbl = nullptr; - lv_obj_t* droneBtn = nullptr; - lv_obj_t* droneLbl = nullptr; - lv_obj_t* ragaBigLbl = nullptr; // right-side display - lv_obj_t* talaBigLbl = nullptr; - } raga_; - - struct DimensionsState { - int equation = 1; // 1-20 - float a = 50, b = 50, c = 50, d = 50; - float t = 0.0f; - float t2 = 100.0f; - float gran = 1.0f; // t step per tick - float rnd = 0.0f; - float xOff = 64.0f, yOff = 64.0f; - float px = 0, py = 0, pz = 0; - int octave = 0; - int bpm = 120; - bool playing = false; - int lastNote = -1; - uint32_t tickMs = 0; // next tick timestamp - uint32_t noteOffMs = 0; - // UI - lv_obj_t* eqLbl = nullptr; - lv_obj_t* aLbl = nullptr; - lv_obj_t* bLbl = nullptr; - lv_obj_t* cLbl = nullptr; - lv_obj_t* dLbl = nullptr; - lv_obj_t* outLbl = nullptr; // "px:N py:N pz:N t:N" - lv_obj_t* octLbl = nullptr; - lv_obj_t* rndLbl = nullptr; - lv_obj_t* bpmLbl = nullptr; - lv_obj_t* playLbl = nullptr; - } dim_; - - struct Baby8State { - struct Step { int note = 60; int velocity = 100; bool gate = true; }; - Step steps[8] = {}; - Step patterns[8][8] = {}; - int currentStep = 0; - int selectedStep = 0; - bool playing = false; - int bpm = 120; - int swing = 50; // 50-80 - int patternIdx = 0; - int lastNote = -1; - uint32_t nextStepMs = 0; - // UI - lv_obj_t* stepBtn[8] = {}; - lv_obj_t* noteLbl[8] = {}; - lv_obj_t* bpmLbl = nullptr; - lv_obj_t* swingLbl = nullptr; - lv_obj_t* patLbl = nullptr; - lv_obj_t* editNoteLbl = nullptr; - lv_obj_t* editVelLbl = nullptr; - lv_obj_t* playLbl = nullptr; - } baby8_; - - struct Tb3poState { - struct Step { - int note = 0; - bool oct_up = false, oct_down = false; - bool accent = false, slide = false, gate = false; - }; - Step steps[32] = {}; - uint32_t seed = 12345; - int density = 8; // 0-16 - int scale = 0; - int key = 0; - int octave = 3; - int currentStep = 0; - bool playing = false; - int bpm = 120; - int lastNote = -1; - int lastNoteStep = -1; // step that owns lastNote (slide bookkeeping) - uint32_t nextStepMs = 0; - uint32_t noteOffMs = 0; - // UI - lv_obj_t* canvas = nullptr; - lv_obj_t* densityLbl = nullptr; - lv_obj_t* seedLbl = nullptr; - lv_obj_t* bpmLbl = nullptr; - lv_obj_t* scaleLbl = nullptr; - lv_obj_t* keyLbl = nullptr; - lv_obj_t* playLbl = nullptr; - int canvasW = 0, canvasH = 0; - } tb3po_; - - struct EuclidState { - struct Voice { - int steps = 16; - int events = 4; - int rotation = 0; - int note = 36; - bool pattern[64] = {}; - }; - Voice voice[8] = {}; - uint32_t currentStep = 0; // free-running master clock (each voice uses modulo its own steps) - bool playing = false; - int bpm = 120; - bool triplet = false; - int selectedVoice = 0; - uint32_t nextStepMs = 0; - // UI - lv_obj_t* canvas = nullptr; - lv_obj_t* bpmLbl = nullptr; - lv_obj_t* stepsLbl = nullptr; - lv_obj_t* eventsLbl = nullptr; - lv_obj_t* rotLbl = nullptr; - lv_obj_t* noteLbl = nullptr; - lv_obj_t* voiceLbl = nullptr; - lv_obj_t* playLbl = nullptr; - lv_obj_t* tripLbl = nullptr; // TRIP ON / TRIP OFF - lv_obj_t* voiceTabRow = nullptr; // tab button row - for encoder voice-select highlight - int canvasW = 0, canvasH = 0; - } euclid_; - - // Current mode - read by EncoderInput.cpp for per-mode dispatch - MidiMode currentMode_ = MidiMode::HOME; - -public: - AppInstanceId appInstanceId = 0; - WindowId window = 0; - -private: - lv_obj_t* toolbar_ = nullptr; - lv_obj_t* transportBtn_ = nullptr; // toolbar button: current transport - opens the selector - lv_obj_t* transportOverlay_ = nullptr; // full-content transport chooser (null when closed) - lv_obj_t* mainWrapper_ = nullptr; - lv_obj_t* contentArea_ = nullptr; - lv_obj_t* switchWidget_ = nullptr; - lv_timer_t* uiTimer_ = nullptr; // 20ms general-purpose UI refresh - - // Which transport sendMidi() targets. Chosen in the transport selector before the switch is - // turned on; only one of bleRunning_/usbRunning_ is ever true. - MidiTransport activeTransport_ = MidiTransport::BLE; - - // BLE radio management (MediaKeys pattern) - struct Device* btDevice_ = nullptr; - bool radioWasOff_ = false; // true if we turned the radio on (must turn it back off) - bool radioEnabling_ = false; // true while waiting for BT_RADIO_STATE_ON - - // The app's event group, owned by main(). Bluetooth events and transport requests wake its loop. - struct TaskEventGroup* eventGroup_ = nullptr; - uint32_t transportRequestBit_ = 0; - // -1: none, 0: turn the transport off, 1: turn it on. Set by the LVGL task, taken by the app's task. - std::atomic transportRequest_ {-1}; - struct BtEventSubscription btEventSub_ {}; - bool btEventSubscribed_ = false; - - // USB MIDI state - no radio-enable dance needed, claim/release is synchronous. - std::atomic usbRunning_ {false}; - - void handleTransportToggle(bool enabled); // LVGL task: updates the UI and requests the transport change - void applyTransport(bool enabled); // App task: starts or stops the transport - void failTransportStart(); // Switches the UI back to off - void enableTransportButton(); - void startBleMidi(); // called once radio is confirmed ON - void stopBleMidi(); // sends all-notes-off and stops BLE MIDI - void startUsbMidi(); - void stopUsbMidi(); // sends all-notes-off and stops USB MIDI - - void showTransportSelect(); // opens the BLE/USB chooser over the current view - void updateTransportButton(); // refresh the toolbar button label - lv_obj_t* buildTransportSelectOverlay(lv_obj_t* parent); - - void createWidgetsImpl(lv_obj_t* parent); - void buildHomeView(lv_obj_t* parent); - void stopAllModeTimers(); - void resetAllState(); - - void handleBtEvent(const struct BtEvent& event); - void pollBtEvents(); - void subscribeBtEvents(); - void unsubscribeBtEvents(); - - static void cbUiTimer(lv_timer_t* t); - static void cbModeBtn(lv_event_t* e); - static void cbTransportSwitch(lv_event_t* e); - static void cbTransportSelect(lv_event_t* e); // toolbar transport button - static void cbTransportCard(lv_event_t* e); // card inside the transport chooser - static void cbTransportCancel(lv_event_t* e); -}; diff --git a/Apps/MidiController/main/Source/MidiScales.h b/Apps/MidiController/main/Source/MidiScales.h deleted file mode 100644 index c5cbb5c..0000000 --- a/Apps/MidiController/main/Source/MidiScales.h +++ /dev/null @@ -1,76 +0,0 @@ -#pragma once - -#include -#include - -// --------------------------------------------------------------------------- -// Scale definitions -// --------------------------------------------------------------------------- - -struct MidiScale { - const char* name; - uint8_t intervals[12]; - int numNotes; -}; - -static constexpr MidiScale MIDI_SCALES[] = { - { "Major", { 0, 2, 4, 5, 7, 9, 11 }, 7 }, - { "Minor", { 0, 2, 3, 5, 7, 8, 10 }, 7 }, - { "Dorian", { 0, 2, 3, 5, 7, 9, 10 }, 7 }, - { "Penta", { 0, 2, 4, 7, 9 }, 5 }, - { "Blues", { 0, 3, 5, 6, 7, 10 }, 6 }, - { "Chrome", { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11 }, 12 }, -}; -static constexpr int NUM_MIDI_SCALES = sizeof(MIDI_SCALES) / sizeof(MIDI_SCALES[0]); - -// Returns the MIDI note number for a given scale degree, octave, and key transpose. -// degree can exceed numNotes - it wraps and increments the octave. -inline int getNoteInScale(int scaleIdx, int degree, int octave, int keyTranspose = 0) { - scaleIdx = ((scaleIdx % NUM_MIDI_SCALES) + NUM_MIDI_SCALES) % NUM_MIDI_SCALES; - const MidiScale& s = MIDI_SCALES[scaleIdx]; - int actualDegree = ((degree % s.numNotes) + s.numNotes) % s.numNotes; - int octaveOffset = (degree >= 0) ? (degree / s.numNotes) : ((degree - s.numNotes + 1) / s.numNotes); - int note = 60 + s.intervals[actualDegree] + ((octave - 4 + octaveOffset) * 12) + keyTranspose; - // Clamp to valid MIDI range - if (note < 0) note = 0; - if (note > 127) note = 127; - return note; -} - -// Returns true if a given MIDI pitch class (0-11) is in the specified scale + key. -inline bool isNoteInScale(int scaleIdx, int keyTranspose, int midiNote) { - scaleIdx = ((scaleIdx % NUM_MIDI_SCALES) + NUM_MIDI_SCALES) % NUM_MIDI_SCALES; - const MidiScale& s = MIDI_SCALES[scaleIdx]; - int pitchClass = ((midiNote - keyTranspose) % 12 + 12) % 12; - for (int i = 0; i < s.numNotes; i++) { - if (s.intervals[i] == pitchClass) return true; - } - return false; -} - -// Returns true if the pitch class is the root note. -inline bool isRootNote(int keyTranspose, int midiNote) { - return (((midiNote - keyTranspose) % 12 + 12) % 12) == 0; -} - -// Fills buf with a sharp-based note name like "C4", "C#4", "A#3", etc. -inline const char* midiNoteName(int note, char* buf, int bufLen) { - static const char* NAMES[] = { "C","C#","D","D#","E","F","F#","G","G#","A","A#","B" }; - if (note < 0) note = 0; - int octave = (note / 12) - 1; // MIDI 60 = C4 (scientific pitch notation) - snprintf(buf, bufLen, "%s%d", NAMES[note % 12], octave); - return buf; -} - -// Returns short note name without octave (e.g. "C#") -inline const char* midiNoteClass(int note, char* buf, int bufLen) { - static const char* NAMES[] = { "C","C#","D","D#","E","F","F#","G","G#","A","A#","B" }; - if (note < 0) note = 0; - snprintf(buf, bufLen, "%s", NAMES[note % 12]); - return buf; -} - -// Key names for root note display (C, C#, D, ...) -static constexpr const char* KEY_NAMES[] = { - "C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B" -}; diff --git a/Apps/MidiController/main/Source/Sequencer.cpp b/Apps/MidiController/main/Source/Sequencer.cpp deleted file mode 100644 index 07e1088..0000000 --- a/Apps/MidiController/main/Source/Sequencer.cpp +++ /dev/null @@ -1,107 +0,0 @@ -#include "Sequencer.h" - -#include -#include -#include - -static constexpr auto* TAG = "MidiSeq"; - -Sequencer::Sequencer() { - memset(steps_, 0, sizeof(steps_)); - - esp_timer_create_args_t args = { - .callback = timerCb, - .arg = this, - .dispatch_method = ESP_TIMER_TASK, - .name = "midi_seq_tick", - .skip_unhandled_events = true, - }; - if (esp_timer_create(&args, &timer_) != ESP_OK) { - ESP_LOGE(TAG, "Failed to create timer"); - timer_ = nullptr; - } -} - -Sequencer::~Sequencer() { - stop(); - if (timer_) { - esp_timer_delete(timer_); - timer_ = nullptr; - } -} - -void Sequencer::start() { - if (playing_.load()) return; - currentStep.store(0); - playing_.store(true); - restartTimer(); -} - -void Sequencer::stop() { - if (!playing_.load()) return; - playing_.store(false); - if (timer_) esp_timer_stop(timer_); - currentStep.store(-1); -} - -void Sequencer::setBpm(float bpm) { - if (bpm < 40.0f) bpm = 40.0f; - if (bpm > 300.0f) bpm = 300.0f; - bpm_.store(bpm); - if (playing_.load()) restartTimer(); -} - -void Sequencer::toggleStep(int track, int step) { - if (track >= 0 && track < NUM_TRACKS && step >= 0 && step < NUM_STEPS) { - std::lock_guard lock(mutex_); - steps_[track][step] = !steps_[track][step]; - } -} - -bool Sequencer::getStep(int track, int step) const { - if (track >= 0 && track < NUM_TRACKS && step >= 0 && step < NUM_STEPS) { - std::lock_guard lock(const_cast(mutex_)); - return steps_[track][step]; - } - return false; -} - -void Sequencer::clearAll() { - // Stop playback first so the timer callback cannot observe a partially-cleared array - stop(); - std::lock_guard lock(mutex_); - memset(steps_, 0, sizeof(steps_)); -} - -void Sequencer::restartTimer() { - if (!timer_) return; - esp_timer_stop(timer_); - // 16th-note interval: 60s / BPM / 4 * 1e6 us = 15,000,000 / BPM - uint64_t intervalUs = (uint64_t)(15000000.0f / bpm_.load()); - if (esp_timer_start_periodic(timer_, intervalUs) != ESP_OK) { - ESP_LOGE(TAG, "Failed to start sequencer timer"); - } -} - -void Sequencer::timerCb(void* arg) { - static_cast(arg)->onTimerFired(); -} - -void Sequencer::onTimerFired() { - if (!playing_.load()) return; - - int step = currentStep.load(); - - if (onStep) { - std::lock_guard lock(mutex_); - for (int t = 0; t < NUM_TRACKS; t++) { - if (steps_[t][step]) { - onStep(step, t); - } - } - } - - currentStep.store((step + 1) % NUM_STEPS); - - if (onTick) onTick(step); -} diff --git a/Apps/MidiController/main/Source/Sequencer.h b/Apps/MidiController/main/Source/Sequencer.h deleted file mode 100644 index 61f68c7..0000000 --- a/Apps/MidiController/main/Source/Sequencer.h +++ /dev/null @@ -1,58 +0,0 @@ -#pragma once - -#include -#include -#include -#include -#include - -// 4-track 16-step drum sequencer using esp_timer for BPM-accurate step timing. - -class Sequencer { -public: - static constexpr int NUM_TRACKS = 4; - static constexpr int NUM_STEPS = 16; - - // Called from esp_timer task when a step fires for an active track. - // onStep(step, track) - only fires for tracks that have that step enabled. - using StepCallback = std::function; - - // Called each step tick regardless of active steps - for UI playhead advance. - // Runs in esp_timer task - must be fast (store to atomic, no LVGL calls). - using TickCallback = std::function; - - Sequencer(); - ~Sequencer(); - - void start(); - void stop(); - bool isPlaying() const { return playing_.load(); } - - void setBpm(float bpm); - float getBpm() const { return bpm_.load(); } - - void toggleStep(int track, int step); - bool getStep(int track, int step) const; - void clearAll(); - - // Currently playing step (-1 if stopped). Written from esp_timer task, - // read from LVGL task - atomic for safe cross-task access. - std::atomic currentStep{-1}; - - // Callbacks - must be set before start() is called; not safe to modify - // while the sequencer is running. - StepCallback onStep; - TickCallback onTick; - -private: - bool steps_[NUM_TRACKS][NUM_STEPS] = {}; - std::atomic bpm_{120.0f}; - std::atomic playing_{false}; - std::mutex mutex_; // guards steps_ - - esp_timer_handle_t timer_ = nullptr; - - static void timerCb(void* arg); - void onTimerFired(); - void restartTimer(); -}; diff --git a/Apps/MidiController/main/Source/UiKit.h b/Apps/MidiController/main/Source/UiKit.h deleted file mode 100644 index e433811..0000000 --- a/Apps/MidiController/main/Source/UiKit.h +++ /dev/null @@ -1,159 +0,0 @@ -#pragma once - -#include -#include - -// --------------------------------------------------------------------------- -// MidiController UI kit -// -// Fixed-pixel instrument UI. The global LVGL theme (Material) styles every -// button/container with focus-ring margins, large paddings, pill radii and its -// own colours, so all widgets are created through these helpers, which reset -// those theme defaults back to the stock values the layouts are written for. -// -// Layout metrics are deliberately shared: every view uses the same row height, -// padding, gap and control widths so the transport/control rows look identical -// from one mode to the next. -// --------------------------------------------------------------------------- - -// --- Palette --------------------------------------------------------------- - -static inline lv_color_t MC_BG() { return lv_color_hex(0x080C12); } // view background -static inline lv_color_t MC_PANEL() { return lv_color_hex(0x0A0C14); } // row / control strip -static inline lv_color_t MC_SURFACE() { return lv_color_hex(0x1A2A40); } // raised cells / keys -static inline lv_color_t MC_BTN() { return lv_color_hex(0x2F3237); } // neutral button -static inline lv_color_t MC_BTN_ALT() { return lv_color_hex(0x222233); } // back / secondary button -static inline lv_color_t MC_LABEL() { return lv_color_hex(0x666688); } // captions -static inline lv_color_t MC_VALUE() { return lv_color_hex(0x00DFFF); } // readouts -static inline lv_color_t MC_PRIMARY() { return lv_color_hex(0x00DFFF); } // accent / highlight -static inline lv_color_t MC_OK() { return lv_color_hex(0x00AA44); } // play / start -static inline lv_color_t MC_STOP() { return lv_color_hex(0xFF2020); } // stop / recording -static inline lv_color_t MC_TEXT() { return lv_color_white(); } - -// --- Metrics --------------------------------------------------------------- - -constexpr int MC_ROW_H = 88; // transport/control row height -constexpr int MC_ROW_PAD = 12; // row inner padding -constexpr int MC_ROW_GAP = 12; // gap between row children -constexpr int MC_BTN_H = MC_ROW_H - 2 * MC_ROW_PAD; // 64 -constexpr int MC_HDR_H = 88; // header row -constexpr int MC_STATUS_H = 56; // bottom status strip - -// Control widths (shared tiers - pick the smallest that fits the row). -constexpr int MC_W_XS = 80; -constexpr int MC_W_S = 88; -constexpr int MC_W_M = 112; -constexpr int MC_W_L = 144; -constexpr int MC_W_XL = 176; -constexpr int MC_W_XXL = 224; - -// Readout-label widths. -constexpr int MC_LBL_S = 96; -constexpr int MC_LBL_M = 144; -constexpr int MC_LBL_L = 176; -constexpr int MC_LBL_XL = 224; - -// --- Widget primitives ----------------------------------------------------- - -static inline lv_obj_t* mcContainer(lv_obj_t* parent) { - lv_obj_t* obj = lv_obj_create(parent); - lv_obj_set_style_bg_opa(obj, LV_OPA_TRANSP, 0); - lv_obj_set_style_border_width(obj, 0, 0); - lv_obj_set_style_radius(obj, 0, 0); - lv_obj_set_style_pad_all(obj, 0, 0); - lv_obj_set_style_pad_gap(obj, 0, 0); - return obj; -} - -static inline lv_obj_t* mcButton(lv_obj_t* parent) { - lv_obj_t* btn = lv_button_create(parent); - // Stock dark-theme button defaults: no focus-ring margin, rounded rect, grey fill. - lv_obj_set_style_margin_all(btn, 0, 0); - lv_obj_set_style_radius(btn, 16, 0); - lv_obj_set_style_bg_color(btn, MC_BTN(), 0); - lv_obj_set_style_pad_hor(btn, 14, 0); - lv_obj_set_style_pad_ver(btn, 8, 0); - return btn; -} - -static inline lv_obj_t* mcLabel(lv_obj_t* parent, const char* txt, - const lv_font_t* font, lv_color_t color, - int width = 0) { - lv_obj_t* lbl = lv_label_create(parent); - lv_label_set_text(lbl, txt); - lv_obj_set_style_text_font(lbl, font, 0); - lv_obj_set_style_text_color(lbl, color, 0); - if (width > 0) { - lv_obj_set_width(lbl, width); - lv_obj_set_style_text_align(lbl, LV_TEXT_ALIGN_CENTER, 0); - } - return lbl; -} - -// --- Row / control builders ------------------------------------------------ - -// Panel-styled flex row with the shared height/padding/gap. -static inline lv_obj_t* mcRow(lv_obj_t* parent, int height = MC_ROW_H) { - lv_obj_t* row = mcContainer(parent); - lv_obj_set_size(row, LV_PCT(100), height); - lv_obj_set_flex_flow(row, LV_FLEX_FLOW_ROW); - lv_obj_set_flex_align(row, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); - lv_obj_set_style_bg_color(row, MC_PANEL(), 0); - lv_obj_set_style_bg_opa(row, LV_OPA_COVER, 0); - lv_obj_set_style_pad_all(row, MC_ROW_PAD, 0); - lv_obj_set_style_pad_gap(row, MC_ROW_GAP, 0); - lv_obj_set_scrollable(row, false); - return row; -} - -// Small text button on a neutral fill. -static inline lv_obj_t* mcBtn(lv_obj_t* parent, const char* txt, int w, int h, - lv_event_cb_t cb, void* user) { - lv_obj_t* btn = mcButton(parent); - lv_obj_set_size(btn, w, h); - if (cb != nullptr) { - lv_obj_add_event_cb(btn, cb, LV_EVENT_CLICKED, user); - } - lv_obj_t* lbl = mcLabel(btn, txt, lvgl_get_text_font(FONT_SIZE_SMALL), MC_TEXT()); - lv_obj_center(lbl); - return btn; -} - -static inline lv_obj_t* mcSmallBtn(lv_obj_t* parent, const char* txt, int w, - lv_event_cb_t cb, void* user) { - return mcBtn(parent, txt, w, MC_BTN_H, cb, user); -} - -// Primary transport button ("PLAY"/"START"). -static inline lv_obj_t* mcPrimaryBtn(lv_obj_t* parent, const char* txt, int w, - lv_event_cb_t cb, void* user) { - lv_obj_t* btn = mcSmallBtn(parent, txt, w, cb, user); - lv_obj_set_style_bg_color(btn, MC_OK(), 0); - return btn; -} - -// Back / secondary navigation button. -static inline lv_obj_t* mcBackBtn(lv_obj_t* parent, lv_event_cb_t cb, void* user, - int w = MC_W_M) { - lv_obj_t* btn = mcSmallBtn(parent, "<", w, cb, user); - lv_obj_set_style_bg_color(btn, MC_BTN_ALT(), 0); - return btn; -} - -// Centered readout value. -static inline lv_obj_t* mcValue(lv_obj_t* parent, const char* txt, int w, - lv_color_t color = MC_VALUE()) { - return mcLabel(parent, txt, lvgl_get_text_font(FONT_SIZE_SMALL), color, w); -} - -// Small caption ("Rate:", "Key:", ...). -static inline lv_obj_t* mcHead(lv_obj_t* parent, const char* txt) { - return mcLabel(parent, txt, lvgl_get_text_font(FONT_SIZE_SMALL), MC_LABEL()); -} - -// Per-mode title: normal font, accent colour, grows to fill the row. -static inline lv_obj_t* mcTitle(lv_obj_t* parent, const char* txt, lv_color_t accent) { - lv_obj_t* lbl = mcLabel(parent, txt, lvgl_get_text_font(FONT_SIZE_DEFAULT), accent); - lv_obj_set_flex_grow(lbl, 1); - return lbl; -} diff --git a/Apps/MidiController/main/Source/ViewArp.cpp b/Apps/MidiController/main/Source/ViewArp.cpp deleted file mode 100644 index d5b2dba..0000000 --- a/Apps/MidiController/main/Source/ViewArp.cpp +++ /dev/null @@ -1,541 +0,0 @@ -#include "MidiController.h" - -#include -#include -#include -#include -#include - -// --------------------------------------------------------------------------- -// ARP view - Piano-triggered chord arpeggiator -// -// Layout (flex column): -// [header: < | ARP] -// [controls: Pattern | Chord | Octaves | Speed | BPM] -// [status label] -// [12-key chromatic piano row] -// --------------------------------------------------------------------------- - -static const char* ARP_PATTERNS[] = { "UP", "DOWN", "UP/DN", "RAND", "CHANCE" }; -static const char* CHORD_NAMES[] = { "MAJOR", "MINOR", "7TH" }; -// Chord intervals relative to root (in semitones) -static const int CHORD_INTERVALS[3][4] = { - { 0, 4, 7, -1 }, // MAJ - { 0, 3, 7, -1 }, // MIN - { 0, 4, 7, 10 }, // 7TH -}; -static const char* SPEED_NAMES[] = { "1/4", "1/8", "1/16", "1/32" }; -static const int SPEED_DIVS[] = { 1, 2, 4, 8 }; // subdivisions of quarter note -static const bool IS_SHARP[12] = { false,true,false,true,false,false,true,false,true,false,true,false }; - -static inline lv_coord_t dispW() { - return (lv_coord_t)lv_display_get_horizontal_resolution(nullptr); -} - -// --------------------------------------------------------------------------- -// Arpeggio timer -// --------------------------------------------------------------------------- - -static int getArpNoteCount(MidiController* mc) { - auto& ap = mc->arp_; - const int* iv = CHORD_INTERVALS[ap.chordType]; - int count = 0; - while (count < 4 && iv[count] >= 0) count++; - return count * ap.octaveRange; -} - -static int getArpNote(MidiController* mc, int step) { - auto& ap = mc->arp_; - if (ap.triggeredNote < 0) return -1; - - const int* iv = CHORD_INTERVALS[ap.chordType]; - int numIntervals = 0; - while (numIntervals < 4 && iv[numIntervals] >= 0) numIntervals++; - int total = numIntervals * ap.octaveRange; - if (total <= 0) return ap.triggeredNote; - - step = step % total; - int octOffset = step / numIntervals; - int intOffset = step % numIntervals; - int note = ap.triggeredNote + iv[intOffset] + octOffset * 12; - return std::max(0, std::min(127, note)); -} - -static void cbArpTimer(lv_timer_t* t) { - auto* mc = static_cast(lv_timer_get_user_data(t)); - if (!mc || !mc->arp_.isPlaying) return; - auto& ap = mc->arp_; - - if (ap.triggeredNote < 0) return; - - int total = getArpNoteCount(mc); - if (total <= 0) return; - - // Note off for previous note - if (ap.lastNote >= 0) { - mc->sendMidi(0x80, (uint8_t)ap.lastNote, 0); - ap.lastNote = -1; - } - - int note = -1; - switch (ap.pattern) { - case 0: // UP - note = getArpNote(mc, ap.arpStep); - ap.arpStep = (ap.arpStep + 1) % total; - break; - case 1: // DOWN - note = getArpNote(mc, total - 1 - ap.arpStep); - ap.arpStep = (ap.arpStep + 1) % total; - break; - case 2: // UP/DN - note = getArpNote(mc, ap.arpStep); - ap.arpStep += ap.arpDir; - if (ap.arpStep >= total) { ap.arpStep = std::max(0, total - 2); ap.arpDir = -1; } - if (ap.arpStep < 0) { ap.arpStep = std::min(1, total - 1); ap.arpDir = 1; } - break; - case 3: // RAND - note = getArpNote(mc, (int)(esp_random() % (uint32_t)total)); - break; - case 4: // CHANCE - UP with 30% skip - if ((esp_random() % 100) >= 30) { - note = getArpNote(mc, ap.arpStep); - } - ap.arpStep = (ap.arpStep + 1) % total; - break; - } - - if (note >= 0) { - mc->sendMidi(0x90, (uint8_t)note, 100); - ap.lastNote = note; - } - - // Update note display - if (ap.noteLbl && note >= 0) { - char buf[8]; - midiNoteName(note, buf, sizeof(buf)); - lv_label_set_text(ap.noteLbl, buf); - } -} - -static void updateArpTimer(MidiController* mc) { - auto& ap = mc->arp_; - if (mc->arpTimer_) { - // Interval: quarter note at bpm / subdivision factor - uint32_t intervalMs = (uint32_t)(60000.0f / ap.bpm / SPEED_DIVS[ap.speedIdx]); - if (intervalMs < 20) intervalMs = 20; - lv_timer_set_period(mc->arpTimer_, intervalMs); - } -} - -static void startArp(MidiController* mc, int rootNote) { - auto& ap = mc->arp_; - ap.triggeredNote = rootNote; - ap.arpStep = 0; - ap.arpDir = 1; - ap.isPlaying = true; - - uint32_t intervalMs = (uint32_t)(60000.0f / ap.bpm / SPEED_DIVS[ap.speedIdx]); - if (intervalMs < 20) intervalMs = 20; - - if (!mc->arpTimer_) { - mc->arpTimer_ = lv_timer_create(cbArpTimer, intervalMs, mc); - } else { - lv_timer_set_period(mc->arpTimer_, intervalMs); - lv_timer_reset(mc->arpTimer_); - } - - if (ap.statusLbl) { - char buf[24]; - char noteName[8]; - midiNoteName(rootNote, noteName, sizeof(noteName)); - snprintf(buf, sizeof(buf), "Arping: %s %s", noteName, CHORD_NAMES[ap.chordType]); - lv_label_set_text(ap.statusLbl, buf); - } -} - -static void stopArp(MidiController* mc) { - auto& ap = mc->arp_; - ap.isPlaying = false; - ap.triggeredNote = -1; - if (ap.lastNote >= 0) { - mc->sendMidi(0x80, (uint8_t)ap.lastNote, 0); - ap.lastNote = -1; - } - if (ap.noteLbl) lv_label_set_text(ap.noteLbl, ""); - if (ap.statusLbl) lv_label_set_text(ap.statusLbl, "Tap a key to start"); -} - -// --------------------------------------------------------------------------- -// Control callbacks -// --------------------------------------------------------------------------- - -static void cbBack(lv_event_t* e) { - auto* mc = static_cast(lv_event_get_user_data(e)); - if (mc) mc->switchToMode(MidiMode::HOME); -} - -static void cbPatternPrev(lv_event_t* e) { - auto* mc = static_cast(lv_event_get_user_data(e)); - if (!mc) return; - mc->arp_.pattern = (mc->arp_.pattern + 4) % 5; - if (mc->arp_.patternLbl) lv_label_set_text(mc->arp_.patternLbl, ARP_PATTERNS[mc->arp_.pattern]); -} -static void cbPatternNext(lv_event_t* e) { - auto* mc = static_cast(lv_event_get_user_data(e)); - if (!mc) return; - mc->arp_.pattern = (mc->arp_.pattern + 1) % 5; - if (mc->arp_.patternLbl) lv_label_set_text(mc->arp_.patternLbl, ARP_PATTERNS[mc->arp_.pattern]); -} - -static void cbChordCycle(lv_event_t* e) { - auto* mc = static_cast(lv_event_get_user_data(e)); - if (!mc) return; - mc->arp_.chordType = (mc->arp_.chordType + 1) % 3; - if (mc->arp_.chordLbl) lv_label_set_text(mc->arp_.chordLbl, CHORD_NAMES[mc->arp_.chordType]); -} - -static void cbOctDown(lv_event_t* e) { - auto* mc = static_cast(lv_event_get_user_data(e)); - if (!mc) return; - if (mc->arp_.octaveRange > 1) mc->arp_.octaveRange--; - if (mc->arp_.octLbl) { - char buf[4]; snprintf(buf, sizeof(buf), "%d", mc->arp_.octaveRange); - lv_label_set_text(mc->arp_.octLbl, buf); - } -} -static void cbOctUp(lv_event_t* e) { - auto* mc = static_cast(lv_event_get_user_data(e)); - if (!mc) return; - if (mc->arp_.octaveRange < 4) mc->arp_.octaveRange++; - if (mc->arp_.octLbl) { - char buf[4]; snprintf(buf, sizeof(buf), "%d", mc->arp_.octaveRange); - lv_label_set_text(mc->arp_.octLbl, buf); - } -} - -static void cbSpeedDown(lv_event_t* e) { - auto* mc = static_cast(lv_event_get_user_data(e)); - if (!mc) return; - if (mc->arp_.speedIdx > 0) mc->arp_.speedIdx--; - if (mc->arp_.speedLbl) lv_label_set_text(mc->arp_.speedLbl, SPEED_NAMES[mc->arp_.speedIdx]); - updateArpTimer(mc); -} -static void cbSpeedUp(lv_event_t* e) { - auto* mc = static_cast(lv_event_get_user_data(e)); - if (!mc) return; - if (mc->arp_.speedIdx < 3) mc->arp_.speedIdx++; - if (mc->arp_.speedLbl) lv_label_set_text(mc->arp_.speedLbl, SPEED_NAMES[mc->arp_.speedIdx]); - updateArpTimer(mc); -} - -static void cbBpmDown(lv_event_t* e) { - auto* mc = static_cast(lv_event_get_user_data(e)); - if (!mc) return; - mc->arp_.bpm -= 5; if (mc->arp_.bpm < 40) mc->arp_.bpm = 40; - if (mc->arp_.bpmLbl) { - char buf[8]; snprintf(buf, sizeof(buf), "%d", mc->arp_.bpm); - lv_label_set_text(mc->arp_.bpmLbl, buf); - } - updateArpTimer(mc); -} -static void cbBpmUp(lv_event_t* e) { - auto* mc = static_cast(lv_event_get_user_data(e)); - if (!mc) return; - mc->arp_.bpm += 5; if (mc->arp_.bpm > 300) mc->arp_.bpm = 300; - if (mc->arp_.bpmLbl) { - char buf[8]; snprintf(buf, sizeof(buf), "%d", mc->arp_.bpm); - lv_label_set_text(mc->arp_.bpmLbl, buf); - } - updateArpTimer(mc); -} - -static void updatePianoOctLabel(MidiController* mc) { - if (mc->arp_.pianoOctLbl) { - char buf[4]; snprintf(buf, sizeof(buf), "%d", mc->arp_.pianoOctave); - lv_label_set_text(mc->arp_.pianoOctLbl, buf); - } -} - -static void rebuildPianoKeyNotes(MidiController* mc) { - auto& ap = mc->arp_; - // Reset all key highlights before re-assigning notes (octave change invalidates old highlight) - for (int k = 0; k < 12; k++) { - if (!ap.pianoKey[k]) continue; - bool isSharp = IS_SHARP[k]; - lv_obj_set_style_bg_color(ap.pianoKey[k], - isSharp ? lv_color_hex(0xDDDDCC) : lv_color_hex(0x111111), - LV_PART_MAIN); - } - ap.triggeredNote = -1; // old note no longer valid in new octave - stopArp(mc); - - for (int k = 0; k < 12; k++) { - if (!ap.pianoKey[k]) continue; - int note = std::max(0, std::min(127, (ap.pianoOctave + 1) * 12 + k)); - lv_obj_set_user_data(ap.pianoKey[k], (void*)(intptr_t)note); - // Update label (child 0) - lv_obj_t* lbl = lv_obj_get_child(ap.pianoKey[k], 0); - if (lbl) { - char noteName[8]; midiNoteName(note, noteName, sizeof(noteName)); - lv_label_set_text(lbl, noteName); - } - } - updatePianoOctLabel(mc); -} - -static void cbPianoOctDown(lv_event_t* e) { - auto* mc = static_cast(lv_event_get_user_data(e)); - if (!mc) return; - if (mc->arp_.pianoOctave > 1) mc->arp_.pianoOctave--; - rebuildPianoKeyNotes(mc); -} -static void cbPianoOctUp(lv_event_t* e) { - auto* mc = static_cast(lv_event_get_user_data(e)); - if (!mc) return; - if (mc->arp_.pianoOctave < 7) mc->arp_.pianoOctave++; - rebuildPianoKeyNotes(mc); -} - -static void cbPianoKey(lv_event_t* e) { - auto* mc = static_cast(lv_event_get_user_data(e)); - if (!mc) return; - auto& ap = mc->arp_; - - lv_obj_t* btn = lv_event_get_target_obj(e); - int note = (int)(intptr_t)lv_obj_get_user_data(btn); - - // isSharp: sharps (C#,D#,F#,G#,A#) are light keys - auto keyColor = [](int semitone) -> uint32_t { - return IS_SHARP[semitone] ? 0xDDDDCC : 0x111111; - }; - - if (ap.triggeredNote == note && ap.isPlaying) { - // Tap same key to stop - stopArp(mc); - lv_obj_set_style_bg_color(btn, lv_color_hex(keyColor(note % 12)), LV_PART_MAIN); - } else { - // Reset previously highlighted key - if (ap.triggeredNote >= 0) { - int prevKey = ap.triggeredNote % 12; - if (ap.pianoKey[prevKey]) { - lv_obj_set_style_bg_color(ap.pianoKey[prevKey], - lv_color_hex(keyColor(prevKey)), - LV_PART_MAIN); - } - } - startArp(mc, note); - lv_obj_set_style_bg_color(btn, lv_color_hex(0x00DFFF), LV_PART_MAIN); - } -} - -// --------------------------------------------------------------------------- -// Teardown -// --------------------------------------------------------------------------- - -static void cbArpRootDelete(lv_event_t* e) { - auto* mc = static_cast(lv_event_get_user_data(e)); - if (!mc) return; - auto& ap = mc->arp_; - - // Stop and delete the arp timer so it cannot fire after the widgets are gone - if (mc->arpTimer_) { - lv_timer_delete(mc->arpTimer_); - mc->arpTimer_ = nullptr; - } - stopArp(mc); - - // Null all stored LVGL pointers - objects are being destroyed - ap.noteLbl = nullptr; - ap.statusLbl = nullptr; - ap.pianoOctLbl = nullptr; - for (int k = 0; k < 12; k++) ap.pianoKey[k] = nullptr; -} - -// --------------------------------------------------------------------------- -// Build function -// --------------------------------------------------------------------------- - -void buildArpView(MidiController* mc, lv_obj_t* parent) { - auto& ap = mc->arp_; - - lv_coord_t W = dispW(); - const lv_font_t* fontSmall = lvgl_get_text_font(FONT_SIZE_SMALL); - const lv_font_t* fontNormal = lvgl_get_text_font(FONT_SIZE_DEFAULT); - - int hdrH = MC_HDR_H; - int ctrlH = 88; - int statusH = 56; - - lv_obj_t* root = mcContainer(parent); - lv_obj_add_event_cb(root, cbArpRootDelete, LV_EVENT_DELETE, mc); - lv_obj_set_size(root, LV_PCT(100), LV_PCT(100)); - lv_obj_set_flex_flow(root, LV_FLEX_FLOW_COLUMN); - lv_obj_set_style_bg_color(root, lv_color_hex(0x080C12), 0); - lv_obj_set_style_bg_opa(root, LV_OPA_COVER, 0); - lv_obj_set_style_border_width(root, 0, 0); - lv_obj_set_style_pad_all(root, 0, 0); - lv_obj_set_style_pad_gap(root, 1, 0); - lv_obj_set_scrollable(root, false); - mc->currentViewRoot_ = root; - - // Helper lambda to create a control row - auto makeRow = [&](int h) -> lv_obj_t* { return mcRow(root, h); }; - - auto makeSmallBtn = [&](lv_obj_t* parent_, const char* txt, lv_event_cb_t cb) -> lv_obj_t* { - return mcSmallBtn(parent_, txt, MC_W_S, cb, mc); - }; - - auto makeLbl = [&](lv_obj_t* parent_, const char* txt, int w, lv_color_t col) -> lv_obj_t* { - return mcValue(parent_, txt, w, col); - }; - - // -- Header row ----------------------------------------------------------- - lv_obj_t* hdr = makeRow(hdrH); - mcBackBtn(hdr, cbBack, mc, MC_W_M); - - lv_obj_t* titleLbl = lv_label_create(hdr); - lv_label_set_text(titleLbl, "ARPEGGIATOR"); - lv_obj_set_style_text_font(titleLbl, fontNormal, 0); - lv_obj_set_style_text_color(titleLbl, lv_color_hex(0xFF8000), 0); - lv_obj_set_flex_grow(titleLbl, 1); - - // -- Pattern row --------------------------------------------------------- - lv_obj_t* patRow = makeRow(ctrlH); - lv_obj_t* patLblHead = lv_label_create(patRow); - lv_label_set_text(patLblHead, "PTRN:"); - lv_obj_set_style_text_font(patLblHead, fontSmall, 0); - lv_obj_set_style_text_color(patLblHead, lv_color_hex(0x666688), 0); - makeSmallBtn(patRow, "<", cbPatternPrev); - ap.patternLbl = makeLbl(patRow, ARP_PATTERNS[ap.pattern], 144, lv_color_hex(0x00DFFF)); - makeSmallBtn(patRow, ">", cbPatternNext); - - lv_obj_t* sep1 = lv_label_create(patRow); - lv_label_set_text(sep1, " CHORD:"); - lv_obj_set_style_text_font(sep1, fontSmall, 0); - lv_obj_set_style_text_color(sep1, lv_color_hex(0x666688), 0); - lv_obj_t* chordBtn = mcButton(patRow); - lv_obj_set_size(chordBtn, 144, MC_BTN_H); - lv_obj_add_event_cb(chordBtn, cbChordCycle, LV_EVENT_CLICKED, mc); - ap.chordLbl = lv_label_create(chordBtn); - lv_label_set_text(ap.chordLbl, CHORD_NAMES[ap.chordType]); - lv_obj_set_style_text_font(ap.chordLbl, fontSmall, 0); - lv_obj_center(ap.chordLbl); - - // -- Octave / Speed / BPM row --------------------------------------------- - lv_obj_t* paramRow = makeRow(ctrlH); - lv_obj_t* octHead = lv_label_create(paramRow); - lv_label_set_text(octHead, "OCT:"); - lv_obj_set_style_text_font(octHead, fontSmall, 0); - lv_obj_set_style_text_color(octHead, lv_color_hex(0x666688), 0); - makeSmallBtn(paramRow, "-", cbOctDown); - char octBuf[4]; snprintf(octBuf, sizeof(octBuf), "%d", ap.octaveRange); - ap.octLbl = makeLbl(paramRow, octBuf, 80, lv_color_hex(0xFFFFFF)); - makeSmallBtn(paramRow, "+", cbOctUp); - - lv_obj_t* spdHead = lv_label_create(paramRow); - lv_label_set_text(spdHead, " SPD:"); - lv_obj_set_style_text_font(spdHead, fontSmall, 0); - lv_obj_set_style_text_color(spdHead, lv_color_hex(0x666688), 0); - makeSmallBtn(paramRow, "<", cbSpeedDown); - ap.speedLbl = makeLbl(paramRow, SPEED_NAMES[ap.speedIdx], 144, lv_color_hex(0x00DFFF)); - makeSmallBtn(paramRow, ">", cbSpeedUp); - - lv_obj_t* bpmHead = lv_label_create(paramRow); - lv_label_set_text(bpmHead, " BPM:"); - lv_obj_set_style_text_font(bpmHead, fontSmall, 0); - lv_obj_set_style_text_color(bpmHead, lv_color_hex(0x666688), 0); - makeSmallBtn(paramRow, "-", cbBpmDown); - char bpmBuf[8]; snprintf(bpmBuf, sizeof(bpmBuf), "%d", ap.bpm); - ap.bpmLbl = makeLbl(paramRow, bpmBuf, 160, lv_color_hex(0xFFE000)); - makeSmallBtn(paramRow, "+", cbBpmUp); - - // -- Status + note label row ----------------------------------------------- - lv_obj_t* statusRow = mcContainer(root); - lv_obj_set_size(statusRow, LV_PCT(100), statusH); - lv_obj_set_flex_flow(statusRow, LV_FLEX_FLOW_ROW); - lv_obj_set_flex_align(statusRow, LV_FLEX_ALIGN_SPACE_BETWEEN, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); - lv_obj_set_style_bg_color(statusRow, lv_color_hex(0x0A0C14), 0); - lv_obj_set_style_bg_opa(statusRow, LV_OPA_COVER, 0); - lv_obj_set_style_border_width(statusRow, 0, 0); - lv_obj_set_style_pad_all(statusRow, 8, 0); - lv_obj_set_scrollable(statusRow, false); - - ap.statusLbl = lv_label_create(statusRow); - lv_label_set_text(ap.statusLbl, "Tap a key to start"); - lv_obj_set_style_text_font(ap.statusLbl, fontSmall, 0); - lv_obj_set_style_text_color(ap.statusLbl, lv_color_hex(0x666688), 0); - - ap.noteLbl = lv_label_create(statusRow); - lv_label_set_text(ap.noteLbl, ""); - lv_obj_set_style_text_font(ap.noteLbl, fontSmall, 0); - lv_obj_set_style_text_color(ap.noteLbl, lv_color_hex(0x00DFFF), 0); - - // -- Piano octave controls + piano row -------------------------------------- - lv_obj_t* pianoHdrRow = makeRow(72); - lv_obj_t* pOctHead = lv_label_create(pianoHdrRow); - lv_label_set_text(pOctHead, "Piano Oct:"); - lv_obj_set_style_text_font(pOctHead, fontSmall, 0); - lv_obj_set_style_text_color(pOctHead, lv_color_hex(0x666688), 0); - lv_obj_t* pOctDown = mcButton(pianoHdrRow); - lv_obj_set_size(pOctDown, 88, 56); - lv_obj_add_event_cb(pOctDown, cbPianoOctDown, LV_EVENT_CLICKED, mc); - lv_obj_t* podl = lv_label_create(pOctDown); lv_label_set_text(podl, "-"); - lv_obj_set_style_text_font(podl, fontSmall, 0); lv_obj_center(podl); - char pianoOctBuf[4]; snprintf(pianoOctBuf, sizeof(pianoOctBuf), "%d", ap.pianoOctave); - ap.pianoOctLbl = lv_label_create(pianoHdrRow); - lv_label_set_text(ap.pianoOctLbl, pianoOctBuf); - lv_obj_set_style_text_font(ap.pianoOctLbl, fontSmall, 0); - lv_obj_set_style_text_color(ap.pianoOctLbl, lv_color_hex(0xFFFFFF), 0); - lv_obj_set_width(ap.pianoOctLbl, 72); - lv_obj_set_style_text_align(ap.pianoOctLbl, LV_TEXT_ALIGN_CENTER, 0); - lv_obj_t* pOctUp = mcButton(pianoHdrRow); - lv_obj_set_size(pOctUp, 88, 56); - lv_obj_add_event_cb(pOctUp, cbPianoOctUp, LV_EVENT_CLICKED, mc); - lv_obj_t* poul = lv_label_create(pOctUp); lv_label_set_text(poul, "+"); - lv_obj_set_style_text_font(poul, fontSmall, 0); lv_obj_center(poul); - - // Piano row - flex_grow fills the rest - lv_obj_t* pianoArea = mcContainer(root); - lv_obj_set_width(pianoArea, LV_PCT(100)); - lv_obj_set_flex_grow(pianoArea, 1); - lv_obj_set_style_bg_color(pianoArea, lv_color_hex(0x050508), 0); - lv_obj_set_style_bg_opa(pianoArea, LV_OPA_COVER, 0); - lv_obj_set_style_border_width(pianoArea, 0, 0); - lv_obj_set_style_pad_all(pianoArea, 0, 0); - lv_obj_set_scrollable(pianoArea, false); - - // 12 chromatic keys - all same height, equal width. - // Original style: naturals (C,D,E,F,G,A,B) = dark keys, sharps = light keys with labels. - int keyW = (int)W / 12; - - for (int k = 0; k < 12; k++) { - int note = (ap.pianoOctave + 1) * 12 + k; - note = std::max(0, std::min(127, note)); - - lv_obj_t* btn = mcButton(pianoArea); - lv_obj_set_pos(btn, k * keyW, 0); - bool isSharp = IS_SHARP[k]; - lv_obj_set_size(btn, keyW - 1, LV_PCT(100)); - // Sharps = light (cream), naturals = dark - lv_obj_set_style_bg_color(btn, isSharp ? lv_color_hex(0xDDDDCC) : lv_color_hex(0x111111), 0); - lv_obj_set_style_bg_opa(btn, LV_OPA_COVER, 0); - lv_obj_set_style_border_color(btn, lv_color_hex(0x444444), 0); - lv_obj_set_style_border_width(btn, 1, 0); - lv_obj_set_style_radius(btn, 2, 0); - lv_obj_set_style_pad_all(btn, 0, 0); - lv_obj_set_user_data(btn, (void*)(intptr_t)note); - lv_obj_add_event_cb(btn, cbPianoKey, LV_EVENT_CLICKED, mc); - - // Note name label on all keys - char noteName[8]; - midiNoteName(note, noteName, sizeof(noteName)); - lv_obj_t* lbl = lv_label_create(btn); - lv_label_set_text(lbl, noteName); - lv_obj_set_style_text_font(lbl, fontSmall, 0); - lv_obj_set_style_text_color(lbl, isSharp ? lv_color_hex(0x333333) : lv_color_hex(0x888888), 0); - lv_obj_align(lbl, LV_ALIGN_BOTTOM_MID, 0, -2); - lv_obj_set_clickable(lbl, false); - - ap.pianoKey[k] = btn; - } -} diff --git a/Apps/MidiController/main/Source/ViewBaby8.cpp b/Apps/MidiController/main/Source/ViewBaby8.cpp deleted file mode 100644 index d2e0a29..0000000 --- a/Apps/MidiController/main/Source/ViewBaby8.cpp +++ /dev/null @@ -1,417 +0,0 @@ -#include "MidiController.h" - -#include -#include -#include -#include -#include - -// --------------------------------------------------------------------------- -// BABY8 view - 8-step melodic sequencer with swing -// -// Layout (flex column): -// [ctrl row: < | PLAY | BPM- | N | BPM+ | SWING- | N% | SWING+] -// [step grid row (flex_grow): 8 step cells] -// [edit row: NOTE- | noteName | NOTE+ VEL- | vel | VEL+ GATE PAT- | P:N | PAT+] -// --------------------------------------------------------------------------- - - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -void baby8UpdateStepColors(MidiController* mc) { - auto& bs = mc->baby8_; - for (int i = 0; i < 8; i++) { - if (!bs.stepBtn[i]) continue; - const auto& s = bs.steps[i]; - uint32_t col; - if (i == bs.currentStep && bs.playing) { - col = s.gate ? 0xFFFFFF : 0x4488FF; - } else if (i == bs.selectedStep) { - col = s.gate ? 0xFF8800 : 0x886633; - } else { - col = s.gate ? 0x006622 : 0x222222; - } - lv_obj_set_style_bg_color(bs.stepBtn[i], lv_color_hex(col), 0); - // Update note label - if (bs.noteLbl[i] && s.gate) { - char buf[8]; midiNoteName(s.note, buf, sizeof(buf)); - lv_label_set_text(bs.noteLbl[i], buf); - } else if (bs.noteLbl[i]) { - lv_label_set_text(bs.noteLbl[i], "--"); - } - } -} - -static void baby8UpdateEditRow(MidiController* mc) { - auto& bs = mc->baby8_; - const auto& s = bs.steps[bs.selectedStep]; - if (bs.editNoteLbl) { - char buf[8]; midiNoteName(s.note, buf, sizeof(buf)); - lv_label_set_text(bs.editNoteLbl, buf); - } - if (bs.editVelLbl) { - char buf[6]; snprintf(buf, sizeof(buf), "%d", s.velocity); - lv_label_set_text(bs.editVelLbl, buf); - } -} - -// --------------------------------------------------------------------------- -// Timer callback -// --------------------------------------------------------------------------- - -static void cbBaby8Timer(lv_timer_t* t) { - auto* mc = static_cast(lv_timer_get_user_data(t)); - if (!mc) return; - auto& bs = mc->baby8_; - if (!bs.playing) return; - - uint32_t now = lv_tick_get(); - - // Note-off - if (bs.lastNote >= 0 && bs.nextStepMs >= 10 && now >= bs.nextStepMs - 10) { - mc->sendMidi(0x80, (uint8_t)bs.lastNote, 0); - bs.lastNote = -1; - } - - if (now < bs.nextStepMs) return; - - // Advance step - bs.currentStep = (bs.currentStep + 1) % 8; - bool even = (bs.currentStep % 2 == 0); - - // BPM → 16th note duration - uint32_t beatMs = 60000u / (uint32_t)bs.bpm; - uint32_t sixteenth = beatMs / 4; - - // Swing: lengthen even steps and shorten odd steps by the same amount so a - // pair still fills two 16th notes and the overall tempo does not drift. - // swing=50 → no swing (both equal); swing>50 → even step longer. - uint32_t stepDur = sixteenth; - if (bs.swing > 50) { - if (even) stepDur = (uint32_t)(sixteenth * bs.swing / 50u); - else stepDur = (uint32_t)(sixteenth * (100u - bs.swing) / 50u); - if (stepDur < 20) stepDur = 20; - } - - bs.nextStepMs = now + stepDur; - - // Play current step - const auto& step = bs.steps[bs.currentStep]; - if (step.gate) { - mc->sendMidi(0x90, (uint8_t)step.note, (uint8_t)step.velocity); - bs.lastNote = step.note; - } - - // Update step highlight - baby8UpdateStepColors(mc); -} - -// --------------------------------------------------------------------------- -// Callbacks -// --------------------------------------------------------------------------- - -static void cbBaby8Back(lv_event_t* e) { - auto* mc = static_cast(lv_event_get_user_data(e)); - if (mc) mc->switchToMode(MidiMode::HOME); -} - -static void cbBaby8Play(lv_event_t* e) { - auto* mc = static_cast(lv_event_get_user_data(e)); - if (!mc) return; - auto& bs = mc->baby8_; - bs.playing = !bs.playing; - if (bs.playing) { - bs.currentStep = 7; // will advance to 0 on first tick - uint32_t beatMs = 60000u / (uint32_t)bs.bpm; - bs.nextStepMs = lv_tick_get() + beatMs / 4; - } else { - if (bs.lastNote >= 0) { - mc->sendMidi(0x80, (uint8_t)bs.lastNote, 0); - bs.lastNote = -1; - } - baby8UpdateStepColors(mc); - } - if (bs.playLbl) lv_label_set_text(bs.playLbl, bs.playing ? "STOP" : "PLAY"); -} - -static void cbBaby8BpmDown(lv_event_t* e) { - auto* mc = static_cast(lv_event_get_user_data(e)); - if (!mc) return; - auto& bs = mc->baby8_; - if (bs.bpm > 40) bs.bpm -= 5; - if (bs.bpmLbl) { - char buf[8]; snprintf(buf, sizeof(buf), "%d", bs.bpm); - lv_label_set_text(bs.bpmLbl, buf); - } -} -static void cbBaby8BpmUp(lv_event_t* e) { - auto* mc = static_cast(lv_event_get_user_data(e)); - if (!mc) return; - auto& bs = mc->baby8_; - if (bs.bpm < 280) bs.bpm += 5; - if (bs.bpmLbl) { - char buf[8]; snprintf(buf, sizeof(buf), "%d", bs.bpm); - lv_label_set_text(bs.bpmLbl, buf); - } -} - -static void cbBaby8SwingDown(lv_event_t* e) { - auto* mc = static_cast(lv_event_get_user_data(e)); - if (!mc) return; - auto& bs = mc->baby8_; - if (bs.swing > 50) bs.swing -= 5; - if (bs.swingLbl) { - char buf[8]; snprintf(buf, sizeof(buf), "%d%%", bs.swing); - lv_label_set_text(bs.swingLbl, buf); - } -} -static void cbBaby8SwingUp(lv_event_t* e) { - auto* mc = static_cast(lv_event_get_user_data(e)); - if (!mc) return; - auto& bs = mc->baby8_; - if (bs.swing < 80) bs.swing += 5; - if (bs.swingLbl) { - char buf[8]; snprintf(buf, sizeof(buf), "%d%%", bs.swing); - lv_label_set_text(bs.swingLbl, buf); - } -} - -static void cbBaby8StepTap(lv_event_t* e) { - auto* mc = static_cast(lv_event_get_user_data(e)); - if (!mc) return; - auto& bs = mc->baby8_; - // Find which step button was tapped by matching lv_obj - lv_obj_t* obj = lv_event_get_target_obj(e); - for (int i = 0; i < 8; i++) { - if (bs.stepBtn[i] == obj) { - bs.selectedStep = i; - break; - } - } - baby8UpdateStepColors(mc); - baby8UpdateEditRow(mc); -} - -static void cbBaby8NoteDown(lv_event_t* e) { - auto* mc = static_cast(lv_event_get_user_data(e)); - if (!mc) return; - auto& bs = mc->baby8_; - auto& s = bs.steps[bs.selectedStep]; - if (s.note > 0) s.note--; - baby8UpdateEditRow(mc); - baby8UpdateStepColors(mc); -} -static void cbBaby8NoteUp(lv_event_t* e) { - auto* mc = static_cast(lv_event_get_user_data(e)); - if (!mc) return; - auto& bs = mc->baby8_; - auto& s = bs.steps[bs.selectedStep]; - if (s.note < 127) s.note++; - baby8UpdateEditRow(mc); - baby8UpdateStepColors(mc); -} - -static void cbBaby8VelDown(lv_event_t* e) { - auto* mc = static_cast(lv_event_get_user_data(e)); - if (!mc) return; - auto& bs = mc->baby8_; - auto& s = bs.steps[bs.selectedStep]; - if (s.velocity > 10) s.velocity -= 10; - else s.velocity = 1; - baby8UpdateEditRow(mc); -} -static void cbBaby8VelUp(lv_event_t* e) { - auto* mc = static_cast(lv_event_get_user_data(e)); - if (!mc) return; - auto& bs = mc->baby8_; - auto& s = bs.steps[bs.selectedStep]; - if (s.velocity <= 117) s.velocity += 10; - else s.velocity = 127; - baby8UpdateEditRow(mc); -} - -static void cbBaby8Gate(lv_event_t* e) { - auto* mc = static_cast(lv_event_get_user_data(e)); - if (!mc) return; - auto& bs = mc->baby8_; - bs.steps[bs.selectedStep].gate = !bs.steps[bs.selectedStep].gate; - baby8UpdateEditRow(mc); - baby8UpdateStepColors(mc); -} - -static void cbBaby8PatDown(lv_event_t* e) { - auto* mc = static_cast(lv_event_get_user_data(e)); - if (!mc) return; - auto& bs = mc->baby8_; - // Save current pattern before switching - memcpy(bs.patterns[bs.patternIdx], bs.steps, sizeof(bs.steps)); - bs.patternIdx = (bs.patternIdx + 7) % 8; - memcpy(bs.steps, bs.patterns[bs.patternIdx], sizeof(bs.steps)); - if (bs.patLbl) { - char buf[6]; snprintf(buf, sizeof(buf), "P:%d", bs.patternIdx + 1); - lv_label_set_text(bs.patLbl, buf); - } - baby8UpdateStepColors(mc); - baby8UpdateEditRow(mc); -} -static void cbBaby8PatUp(lv_event_t* e) { - auto* mc = static_cast(lv_event_get_user_data(e)); - if (!mc) return; - auto& bs = mc->baby8_; - // Save current pattern before switching - memcpy(bs.patterns[bs.patternIdx], bs.steps, sizeof(bs.steps)); - bs.patternIdx = (bs.patternIdx + 1) % 8; - memcpy(bs.steps, bs.patterns[bs.patternIdx], sizeof(bs.steps)); - if (bs.patLbl) { - char buf[6]; snprintf(buf, sizeof(buf), "P:%d", bs.patternIdx + 1); - lv_label_set_text(bs.patLbl, buf); - } - baby8UpdateStepColors(mc); - baby8UpdateEditRow(mc); -} - -// --------------------------------------------------------------------------- -// Build -// --------------------------------------------------------------------------- - -void buildBaby8View(MidiController* mc, lv_obj_t* parent) { - auto& bs = mc->baby8_; - - const lv_font_t* fontSmall = lvgl_get_text_font(FONT_SIZE_SMALL); - const lv_font_t* fontNormal = lvgl_get_text_font(FONT_SIZE_DEFAULT); - - int sBtnW = 88; - - lv_obj_t* root = mcContainer(parent); - lv_obj_set_size(root, LV_PCT(100), LV_PCT(100)); - lv_obj_set_flex_flow(root, LV_FLEX_FLOW_COLUMN); - lv_obj_set_style_bg_color(root, lv_color_hex(0x070A10), 0); - lv_obj_set_style_bg_opa(root, LV_OPA_COVER, 0); - lv_obj_set_style_border_width(root, 0, 0); - lv_obj_set_style_pad_all(root, 0, 0); - lv_obj_set_style_pad_gap(root, 8, 0); - lv_obj_set_scrollable(root, false); - mc->currentViewRoot_ = root; - - auto makeRow = [&]() -> lv_obj_t* { return mcRow(root); }; - - auto makeBtn = [&](lv_obj_t* par, const char* txt, int w, lv_event_cb_t cb, void* ud) -> lv_obj_t* { - return mcSmallBtn(par, txt, w, cb, ud); - }; - - auto makeLbl = [&](lv_obj_t* par, const char* txt, int w, lv_color_t col) -> lv_obj_t* { - return mcValue(par, txt, w, col); - }; - - auto makeSep = [&](lv_obj_t* par) { - lv_obj_t* sep = lv_label_create(par); - lv_label_set_text(sep, "|"); - lv_obj_set_style_text_font(sep, fontSmall, 0); - lv_obj_set_style_text_color(sep, lv_color_hex(0x334455), 0); - }; - - // -- Control row ----------------------------------------------------------- - { - lv_obj_t* row = makeRow(); - mcBackBtn(row, cbBaby8Back, mc, MC_W_S); - lv_obj_t* titleLbl = lv_label_create(row); - lv_label_set_text(titleLbl, "BABY-8"); - lv_obj_set_style_text_font(titleLbl, fontNormal, 0); - lv_obj_set_style_text_color(titleLbl, lv_color_hex(0x44BBFF), 0); - lv_obj_set_flex_grow(titleLbl, 1); - lv_obj_t* playBtn = makeBtn(row, bs.playing ? "STOP" : "PLAY", 176, cbBaby8Play, mc); - bs.playLbl = lv_obj_get_child(playBtn, 0); - makeSep(row); - makeBtn(row, "<", sBtnW, cbBaby8BpmDown, mc); - char bufBpm[8]; snprintf(bufBpm, sizeof(bufBpm), "%d", bs.bpm); - bs.bpmLbl = makeLbl(row, bufBpm, 136, lv_color_hex(0xFFE000)); - makeBtn(row, ">", sBtnW, cbBaby8BpmUp, mc); - makeSep(row); - makeBtn(row, "<", sBtnW, cbBaby8SwingDown, mc); - char bufSw[8]; snprintf(bufSw, sizeof(bufSw), "%d%%", bs.swing); - bs.swingLbl = makeLbl(row, bufSw, 128, lv_color_hex(0xAADDFF)); - makeBtn(row, ">", sBtnW, cbBaby8SwingUp, mc); - } - - // -- Step grid (flex_grow) ----------------------------------------------------------- - { - lv_obj_t* gridArea = mcContainer(root); - lv_obj_set_width(gridArea, LV_PCT(100)); - lv_obj_set_flex_grow(gridArea, 1); - lv_obj_set_flex_flow(gridArea, LV_FLEX_FLOW_ROW); - lv_obj_set_flex_align(gridArea, LV_FLEX_ALIGN_SPACE_EVENLY, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); - lv_obj_set_style_bg_color(gridArea, lv_color_hex(0x050810), 0); - lv_obj_set_style_bg_opa(gridArea, LV_OPA_COVER, 0); - lv_obj_set_style_border_width(gridArea, 0, 0); - lv_obj_set_style_pad_all(gridArea, 16, 0); - lv_obj_set_style_pad_gap(gridArea, 12, 0); - lv_obj_set_scrollable(gridArea, false); - - // Compute cell size from available space - int cellW = (lv_display_get_horizontal_resolution(nullptr) - 32 - 7 * 12) / 8; - - for (int i = 0; i < 8; i++) { - lv_obj_t* cell = mcContainer(gridArea); - lv_obj_set_size(cell, cellW, LV_PCT(100)); - lv_obj_set_flex_flow(cell, LV_FLEX_FLOW_COLUMN); - lv_obj_set_flex_align(cell, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); - lv_obj_set_style_radius(cell, 16, 0); - lv_obj_set_style_border_width(cell, 1, 0); - lv_obj_set_style_border_color(cell, lv_color_hex(0x334455), 0); - lv_obj_set_style_bg_color(cell, lv_color_hex(bs.steps[i].gate ? 0x006622u : 0x222222u), 0); - lv_obj_set_style_bg_opa(cell, LV_OPA_COVER, 0); - lv_obj_set_style_pad_all(cell, 8, 0); - lv_obj_set_style_pad_gap(cell, 8, 0); - lv_obj_set_scrollable(cell, false); - lv_obj_set_clickable(cell, true); - lv_obj_add_event_cb(cell, cbBaby8StepTap, LV_EVENT_CLICKED, mc); - bs.stepBtn[i] = cell; - - // Step number - lv_obj_t* numLbl = lv_label_create(cell); - char nbuf[4]; snprintf(nbuf, sizeof(nbuf), "%d", i + 1); - lv_label_set_text(numLbl, nbuf); - lv_obj_set_style_text_font(numLbl, fontSmall, 0); - lv_obj_set_style_text_color(numLbl, lv_color_hex(0x888888), 0); - - // Note name - lv_obj_t* noteLbl = lv_label_create(cell); - char noteBuf[8]; midiNoteName(bs.steps[i].note, noteBuf, sizeof(noteBuf)); - lv_label_set_text(noteLbl, bs.steps[i].gate ? noteBuf : "--"); - lv_obj_set_style_text_font(noteLbl, fontSmall, 0); - lv_obj_set_style_text_color(noteLbl, lv_color_hex(0xFFFFFF), 0); - bs.noteLbl[i] = noteLbl; - } - } - - // -- Edit row ----------------------------------------------------------- - { - lv_obj_t* row = makeRow(); - // Note - makeBtn(row, "<", sBtnW, cbBaby8NoteDown, mc); - char noteBuf[8]; midiNoteName(bs.steps[bs.selectedStep].note, noteBuf, sizeof(noteBuf)); - bs.editNoteLbl = makeLbl(row, noteBuf, 128, lv_color_hex(0x88DDFF)); - makeBtn(row, ">", sBtnW, cbBaby8NoteUp, mc); - // Velocity - makeBtn(row, "<", sBtnW, cbBaby8VelDown, mc); - char velBuf[6]; snprintf(velBuf, sizeof(velBuf), "%d", bs.steps[bs.selectedStep].velocity); - bs.editVelLbl = makeLbl(row, velBuf, 112, lv_color_hex(0xFFCC88)); - makeBtn(row, ">", sBtnW, cbBaby8VelUp, mc); - // Gate - makeBtn(row, "GATE", 160, cbBaby8Gate, mc); - // Pattern - makeBtn(row, "<", sBtnW, cbBaby8PatDown, mc); - char patBuf[16]; snprintf(patBuf, sizeof(patBuf), "P:%d", bs.patternIdx + 1); - bs.patLbl = makeLbl(row, patBuf, 112, lv_color_hex(0xAA88FF)); - makeBtn(row, ">", sBtnW, cbBaby8PatUp, mc); - } - - // -- Start timer ----------------------------------------------------------- - bs.nextStepMs = lv_tick_get() + 60000u / (uint32_t)bs.bpm / 4u; - if (mc->baby8Timer_) { - lv_timer_delete(mc->baby8Timer_); - } - mc->baby8Timer_ = lv_timer_create(cbBaby8Timer, 10, mc); -} diff --git a/Apps/MidiController/main/Source/ViewBeats.cpp b/Apps/MidiController/main/Source/ViewBeats.cpp deleted file mode 100644 index 8d758b2..0000000 --- a/Apps/MidiController/main/Source/ViewBeats.cpp +++ /dev/null @@ -1,347 +0,0 @@ -#include "MidiController.h" - -#include -#include -#include - -// --------------------------------------------------------------------------- -// BEATS view - 4-track 16-step drum sequencer -// -// Layout (flex column): -// [transport row: BACK | PLAY/STOP | CLR | BPM- | BPM | BPM+ | "BPM"] -// [step-marker header row (beat numbers 1/5/9/13)] -// [4 × 16 step button grid - flex_grow, each row = track label + 16 buttons] -// --------------------------------------------------------------------------- - -// Drum track names and colors -static const char* TRACK_NAMES[] = { "KICK", "SNRE", "HHAT", "OPEN" }; - -static lv_color_t trackColor(int t) { - switch (t) { - case 0: return lv_color_hex(0xFF2020); // red - case 1: return lv_color_hex(0xFFE000); // yellow - case 2: return lv_color_hex(0x00DFFF); // cyan - case 3: return lv_color_hex(0xCC44FF); // purple - default: return lv_color_hex(0x888888); - } -} - -static inline lv_coord_t dispW() { - return (lv_coord_t)lv_display_get_horizontal_resolution(nullptr); -} - -// Beat group separator: every 4 steps gets a slightly different shade -static bool isBeatBoundary(int step) { return (step % 4) == 0; } - -static void refreshStepBtn(MidiController* mc, int track, int step) { - lv_obj_t* btn = mc->beats_.stepBtn[track][step]; - if (!btn) return; - bool active = mc->seq_ && mc->seq_->getStep(track, step); - lv_obj_set_style_bg_color(btn, - active ? trackColor(track) : lv_color_hex(0x1A2A40), - LV_PART_MAIN); - // Restore beat-boundary left border (overwritten by playback cursor highlight) - if (isBeatBoundary(step)) { - lv_obj_set_style_border_width(btn, 1, LV_PART_MAIN); - lv_obj_set_style_border_side(btn, LV_BORDER_SIDE_LEFT, LV_PART_MAIN); - lv_obj_set_style_border_color(btn, lv_color_hex(0x334455), LV_PART_MAIN); - } else { - lv_obj_set_style_border_width(btn, 0, LV_PART_MAIN); - } -} - -static void refreshAllStepBtns(MidiController* mc) { - for (int t = 0; t < Sequencer::NUM_TRACKS; t++) { - for (int s = 0; s < Sequencer::NUM_STEPS; s++) { - refreshStepBtn(mc, t, s); - } - } -} - -static void refreshPlayBtn(MidiController* mc) { - if (!mc->beats_.playLbl) return; - lv_label_set_text(mc->beats_.playLbl, - mc->beats_.playing ? "STOP" : "PLAY"); - lv_obj_set_style_bg_color(mc->beats_.playBtn, - mc->beats_.playing ? lv_color_hex(0xFF2020) : lv_color_hex(0x00AA44), - LV_PART_MAIN); -} - -static void refreshBpmLbl(MidiController* mc) { - if (!mc->beats_.bpmLbl) return; - char buf[8]; snprintf(buf, sizeof(buf), "%d", mc->beats_.bpm); - lv_label_set_text(mc->beats_.bpmLbl, buf); -} - -// --------------------------------------------------------------------------- -// Callbacks -// --------------------------------------------------------------------------- - -static void cbBack(lv_event_t* e) { - auto* mc = static_cast(lv_event_get_user_data(e)); - if (mc) mc->switchToMode(MidiMode::HOME); -} - -static void cbStepBtn(lv_event_t* e) { - auto* mc = static_cast(lv_event_get_user_data(e)); - if (!mc || !mc->seq_) return; - lv_obj_t* btn = lv_event_get_target_obj(e); - int packed = (int)(intptr_t)lv_obj_get_user_data(btn); - int track = packed >> 8; - int step = packed & 0xFF; - mc->seq_->toggleStep(track, step); - refreshStepBtn(mc, track, step); -} - -static void cbPlayStop(lv_event_t* e) { - auto* mc = static_cast(lv_event_get_user_data(e)); - if (!mc || !mc->seq_) return; - if (mc->beats_.playing) { - mc->seq_->stop(); - mc->beats_.playing = false; - mc->stopAllNotes(); - mc->seqPlayStep.store(-1); - mc->seqLastStep = -1; - refreshAllStepBtns(mc); // self-heals borders and colors - } else { - mc->seqLastStep = -1; - mc->seq_->start(); - mc->beats_.playing = true; - } - refreshPlayBtn(mc); -} - -static void cbClear(lv_event_t* e) { - auto* mc = static_cast(lv_event_get_user_data(e)); - if (!mc || !mc->seq_) return; - mc->seq_->clearAll(); - // clearAll() also stops the sequencer, so bring the transport UI back in sync. - mc->beats_.playing = false; - mc->seqPlayStep.store(-1); - mc->seqLastStep = -1; - mc->stopAllNotes(); - refreshPlayBtn(mc); - refreshAllStepBtns(mc); -} - -static void cbBpmDown(lv_event_t* e) { - auto* mc = static_cast(lv_event_get_user_data(e)); - if (!mc) return; - mc->beats_.bpm -= 5; - if (mc->beats_.bpm < 40) mc->beats_.bpm = 40; - if (mc->seq_) mc->seq_->setBpm((float)mc->beats_.bpm); - refreshBpmLbl(mc); -} - -static void cbBpmUp(lv_event_t* e) { - auto* mc = static_cast(lv_event_get_user_data(e)); - if (!mc) return; - mc->beats_.bpm += 5; - if (mc->beats_.bpm > 300) mc->beats_.bpm = 300; - if (mc->seq_) mc->seq_->setBpm((float)mc->beats_.bpm); - refreshBpmLbl(mc); -} - -// --------------------------------------------------------------------------- -// Build function -// --------------------------------------------------------------------------- - -void buildBeatsView(MidiController* mc, lv_obj_t* parent) { - auto& bs = mc->beats_; - if (mc->seq_) mc->seq_->setBpm((float)bs.bpm); - - lv_coord_t W = dispW(); - const lv_font_t* fontSmall = lvgl_get_text_font(FONT_SIZE_SMALL); - const lv_font_t* fontNormal = lvgl_get_text_font(FONT_SIZE_DEFAULT); - - int labelW = 152; // track label column width - int ctrlH = 88; // transport row height (matches KEYS) - int btnH = MC_BTN_H; - - // Available width for step grid (excluding track label and row padding) - lv_coord_t gridW = W - labelW - 8 * 2; // row has pad_all=4 each side - // Step button width: account for 15 gaps between 16 steps (pad_gap=4) - int stepW = (gridW - 4 * 15) / Sequencer::NUM_STEPS; - - // Root - lv_obj_t* root = mcContainer(parent); - lv_obj_set_size(root, LV_PCT(100), LV_PCT(100)); - lv_obj_set_flex_flow(root, LV_FLEX_FLOW_COLUMN); - lv_obj_set_style_bg_color(root, lv_color_hex(0x080C12), 0); - lv_obj_set_style_bg_opa(root, LV_OPA_COVER, 0); - lv_obj_set_style_border_width(root, 0, 0); - lv_obj_set_style_pad_all(root, 0, 0); - lv_obj_set_style_pad_gap(root, 0, 0); - lv_obj_set_scrollable(root, false); - mc->currentViewRoot_ = root; - - // -- Transport row (TOP) -------------------------------- - lv_obj_t* transport = mcRow(root, ctrlH); - - // Back button - mcBackBtn(transport, cbBack, mc, MC_W_S); - - lv_obj_t* titleLbl = lv_label_create(transport); - lv_label_set_text(titleLbl, "BEATS"); - lv_obj_set_style_text_font(titleLbl, fontNormal, 0); - lv_obj_set_style_text_color(titleLbl, lv_color_hex(0x44BBFF), 0); - lv_obj_set_flex_grow(titleLbl, 1); - - // PLAY/STOP - lv_obj_t* playBtn = mcButton(transport); - lv_obj_set_size(playBtn, 176, btnH); - lv_obj_set_style_bg_color(playBtn, lv_color_hex(0x00AA44), 0); - lv_obj_add_event_cb(playBtn, cbPlayStop, LV_EVENT_CLICKED, mc); - bs.playBtn = playBtn; - lv_obj_t* playLbl = lv_label_create(playBtn); - lv_label_set_text(playLbl, "PLAY"); - lv_obj_set_style_text_font(playLbl, fontSmall, 0); - lv_obj_center(playLbl); - bs.playLbl = playLbl; - - // CLEAR - lv_obj_t* clearBtn = mcButton(transport); - lv_obj_set_size(clearBtn, 144, btnH); - lv_obj_set_style_bg_color(clearBtn, lv_color_hex(0x331A00), 0); - lv_obj_add_event_cb(clearBtn, cbClear, LV_EVENT_CLICKED, mc); - lv_obj_t* clearLbl = lv_label_create(clearBtn); - lv_label_set_text(clearLbl, "CLEAR"); - lv_obj_set_style_text_font(clearLbl, fontSmall, 0); - lv_obj_center(clearLbl); - - // BPM - - lv_obj_t* bpmDown = mcButton(transport); - lv_obj_set_size(bpmDown, 80, btnH); - lv_obj_add_event_cb(bpmDown, cbBpmDown, LV_EVENT_CLICKED, mc); - lv_obj_t* bdLbl = lv_label_create(bpmDown); - lv_label_set_text(bdLbl, "-"); - lv_obj_set_style_text_font(bdLbl, fontSmall, 0); - lv_obj_center(bdLbl); - - // BPM label - bs.bpmLbl = lv_label_create(transport); - char bpmBuf[8]; snprintf(bpmBuf, sizeof(bpmBuf), "%d", bs.bpm); - lv_label_set_text(bs.bpmLbl, bpmBuf); - lv_obj_set_style_text_font(bs.bpmLbl, fontSmall, 0); - lv_obj_set_style_text_color(bs.bpmLbl, lv_color_hex(0xFFE000), 0); - lv_obj_set_width(bs.bpmLbl, 128); - lv_obj_set_style_text_align(bs.bpmLbl, LV_TEXT_ALIGN_CENTER, 0); - - // BPM + - lv_obj_t* bpmUp = mcButton(transport); - lv_obj_set_size(bpmUp, 80, btnH); - lv_obj_add_event_cb(bpmUp, cbBpmUp, LV_EVENT_CLICKED, mc); - lv_obj_t* buLbl = lv_label_create(bpmUp); - lv_label_set_text(buLbl, "+"); - lv_obj_set_style_text_font(buLbl, fontSmall, 0); - lv_obj_center(buLbl); - - // BPM unit label - lv_obj_t* bpmUnit = lv_label_create(transport); - lv_label_set_text(bpmUnit, "BPM"); - lv_obj_set_style_text_font(bpmUnit, fontSmall, 0); - lv_obj_set_style_text_color(bpmUnit, lv_color_hex(0x666688), 0); - - // -- Track header row (step markers only) -------------------------------- - lv_obj_t* hdrRow = mcContainer(root); - lv_obj_set_size(hdrRow, LV_PCT(100), 64); - lv_obj_set_flex_flow(hdrRow, LV_FLEX_FLOW_ROW); - lv_obj_set_flex_align(hdrRow, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); - lv_obj_set_style_bg_color(hdrRow, lv_color_hex(0x0A0C14), 0); - lv_obj_set_style_bg_opa(hdrRow, LV_OPA_COVER, 0); - lv_obj_set_style_border_width(hdrRow, 0, 0); - lv_obj_set_style_pad_all(hdrRow, 4, 0); - lv_obj_set_style_pad_gap(hdrRow, 0, 0); - lv_obj_set_scrollable(hdrRow, false); - - // Spacer to align step markers with step buttons below - lv_obj_t* hdrSpacer = mcContainer(hdrRow); - lv_obj_set_size(hdrSpacer, labelW + 4, 1); - lv_obj_set_style_bg_opa(hdrSpacer, LV_OPA_TRANSP, 0); - lv_obj_set_style_border_width(hdrSpacer, 0, 0); - - // Step index labels (beat markers: 1, 5, 9, 13) - for (int s = 0; s < Sequencer::NUM_STEPS; s++) { - if (s % 4 == 0) { - lv_obj_t* marker = lv_label_create(hdrRow); - char buf[4]; snprintf(buf, sizeof(buf), "%d", s + 1); - lv_label_set_text(marker, buf); - lv_obj_set_width(marker, stepW * 4 + 4 * 3); - lv_obj_set_style_text_font(marker, fontSmall, 0); - lv_obj_set_style_text_color(marker, lv_color_hex(0x666688), 0); - lv_obj_set_style_text_align(marker, LV_TEXT_ALIGN_LEFT, 0); - } - } - - // -- Step grid (4 tracks × 16 steps) -------------------------------- - lv_obj_t* gridArea = mcContainer(root); - lv_obj_set_width(gridArea, LV_PCT(100)); - lv_obj_set_flex_grow(gridArea, 1); - lv_obj_set_style_bg_color(gridArea, lv_color_hex(0x080C12), 0); - lv_obj_set_style_bg_opa(gridArea, LV_OPA_COVER, 0); - lv_obj_set_style_border_width(gridArea, 0, 0); - lv_obj_set_style_pad_all(gridArea, 0, 0); - lv_obj_set_style_pad_gap(gridArea, 0, 0); - lv_obj_set_scrollable(gridArea, false); - lv_obj_set_flex_flow(gridArea, LV_FLEX_FLOW_COLUMN); - - for (int t = 0; t < Sequencer::NUM_TRACKS; t++) { - // Track row - lv_obj_t* trackRow = mcContainer(gridArea); - lv_obj_set_width(trackRow, LV_PCT(100)); - lv_obj_set_flex_grow(trackRow, 1); - lv_obj_set_flex_flow(trackRow, LV_FLEX_FLOW_ROW); - lv_obj_set_flex_align(trackRow, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); - lv_obj_set_style_bg_color(trackRow, lv_color_hex(0x080C12), 0); - lv_obj_set_style_bg_opa(trackRow, LV_OPA_COVER, 0); - lv_obj_set_style_border_width(trackRow, 0, 0); - lv_obj_set_style_pad_all(trackRow, 4, 0); - lv_obj_set_style_pad_gap(trackRow, 4, 0); - lv_obj_set_scrollable(trackRow, false); - - // Track label - lv_obj_t* trackLbl = mcContainer(trackRow); - lv_obj_set_size(trackLbl, labelW, LV_PCT(100)); - lv_obj_set_style_bg_color(trackLbl, lv_color_hex(0x0A0C14), 0); - lv_obj_set_style_bg_opa(trackLbl, LV_OPA_COVER, 0); - lv_obj_set_style_border_width(trackLbl, 2, 0); - lv_obj_set_style_border_side(trackLbl, LV_BORDER_SIDE_RIGHT, 0); - lv_obj_set_style_border_color(trackLbl, trackColor(t), 0); - lv_obj_set_style_pad_all(trackLbl, 8, 0); - lv_obj_set_scrollable(trackLbl, false); - - lv_obj_t* tNameLbl = lv_label_create(trackLbl); - lv_label_set_text(tNameLbl, TRACK_NAMES[t]); - lv_obj_set_style_text_font(tNameLbl, fontSmall, 0); - lv_obj_set_style_text_color(tNameLbl, trackColor(t), 0); - lv_obj_center(tNameLbl); - - // 16 step buttons - for (int s = 0; s < Sequencer::NUM_STEPS; s++) { - lv_obj_t* btn = mcButton(trackRow); - lv_obj_set_size(btn, stepW, LV_PCT(90)); - lv_obj_set_style_bg_color(btn, lv_color_hex(0x1A2A40), 0); - lv_obj_set_style_bg_opa(btn, LV_OPA_COVER, 0); - lv_obj_set_style_radius(btn, 2, 0); - lv_obj_set_style_pad_all(btn, 0, 0); - lv_obj_set_style_border_width(btn, 0, 0); - - // Beat boundary: slight highlight on every 4th step column - if (isBeatBoundary(s)) { - lv_obj_set_style_border_width(btn, 1, LV_PART_MAIN); - lv_obj_set_style_border_side(btn, LV_BORDER_SIDE_LEFT, LV_PART_MAIN); - lv_obj_set_style_border_color(btn, lv_color_hex(0x334455), LV_PART_MAIN); - } - - // Pack track+step into user_data - lv_obj_set_user_data(btn, (void*)(intptr_t)((t << 8) | s)); - lv_obj_add_event_cb(btn, cbStepBtn, LV_EVENT_CLICKED, mc); - - bs.stepBtn[t][s] = btn; - } - } - - // Sync UI state with current playback status - refreshPlayBtn(mc); - refreshAllStepBtns(mc); -} diff --git a/Apps/MidiController/main/Source/ViewChord.cpp b/Apps/MidiController/main/Source/ViewChord.cpp deleted file mode 100644 index 83cf5a1..0000000 --- a/Apps/MidiController/main/Source/ViewChord.cpp +++ /dev/null @@ -1,298 +0,0 @@ -#include "MidiController.h" - -#include -#include -#include -#include - -// --------------------------------------------------------------------------- -// CHORD view - Diatonic chord player -// -// 8 chord buttons (I-VII + I octave up) filling the main area. -// Control row at bottom: OCT- | OCT+ | SCALE | CLEAR -// --------------------------------------------------------------------------- - -// Diatonic chord Roman numeral labels -static const char* CHORD_LABELS[] = { "I", "ii", "iii", "IV", "V", "vi", "vii\xc2\xb0", "I+" }; -// Chord type per diatonic degree: 0=MAJ 1=MIN 2=DIM -static const int CHORD_TYPE[8] = { 0, 1, 1, 0, 0, 1, 2, 0 }; -// Diatonic scale degree index for getNoteInScale (degree 0-6, degree 7 = degree 0 +1 octave) -static const int DEGREE_IDX[8] = { 0, 1, 2, 3, 4, 5, 6, 0 }; -// Octave offset per degree (0=same octave, 1=octave up for I+) -static const int DEGREE_OCT[8] = { 0, 0, 0, 0, 0, 0, 0, 1 }; -// Per-degree color palette -static const uint32_t DEGREE_COLORS[8] = { - 0xFF3333, 0xFF8800, 0xFFFF00, 0x33CC33, - 0x3399FF, 0xAA33FF, 0xFF33CC, 0xFF3333 -}; - -// Chord voicing intervals (up to 4 notes, -1 = unused) -static const int CHORD_VOICING[3][4] = { - { 0, 4, 7, -1 }, // MAJ - { 0, 3, 7, -1 }, // MIN - { 0, 3, 6, -1 }, // DIM -}; - - -static void playChord(MidiController* mc, int degree) { - auto& ch = mc->chord_; - const int* voicing = CHORD_VOICING[CHORD_TYPE[degree]]; - int rootNote = getNoteInScale(ch.scale, DEGREE_IDX[degree], ch.octave + DEGREE_OCT[degree], ch.key); - rootNote = std::max(0, std::min(127, rootNote)); - - for (int n = 0; n < 4; n++) { - if (voicing[n] < 0) { - ch.chordNotes[degree][n] = -1; - continue; - } - int note = std::max(0, std::min(127, rootNote + voicing[n])); - ch.chordNotes[degree][n] = note; - mc->sendMidi(0x90, (uint8_t)note, 100); - } -} - -static void refreshChordBtn(MidiController* mc, int degree) { - lv_obj_t* btn = mc->chord_.chordBtn[degree]; - if (!btn) return; - bool pressed = mc->chord_.chordPlaying[degree]; - lv_color_t col = lv_color_hex(DEGREE_COLORS[degree]); - if (pressed) { - lv_obj_set_style_bg_color(btn, col, LV_PART_MAIN); - lv_obj_set_style_bg_opa(btn, LV_OPA_COVER, LV_PART_MAIN); - // Dark text on bright bg - lv_obj_t* lbl = lv_obj_get_child(btn, 0); - if (lbl) lv_obj_set_style_text_color(lbl, lv_color_hex(0x080C12), LV_PART_MAIN); - } else { - lv_obj_set_style_bg_color(btn, lv_color_hex(0x1A2A40), LV_PART_MAIN); - lv_obj_set_style_bg_opa(btn, LV_OPA_COVER, LV_PART_MAIN); - lv_obj_t* lbl = lv_obj_get_child(btn, 0); - if (lbl) lv_obj_set_style_text_color(lbl, col, LV_PART_MAIN); - } -} - -static void clearAllChordState(MidiController* mc) { - for (int i = 0; i < 8; i++) { - mc->chord_.chordPlaying[i] = false; - refreshChordBtn(mc, i); - } -} - -static void rebuildChordBtnLabels(MidiController* mc) { - auto& ch = mc->chord_; - for (int i = 0; i < 8; i++) { - if (!ch.chordBtn[i]) continue; - lv_obj_t* lblObj = lv_obj_get_child(ch.chordBtn[i], 0); - if (lblObj) { - int rootNote = std::max(0, std::min(127, getNoteInScale(ch.scale, DEGREE_IDX[i], ch.octave + DEGREE_OCT[i], ch.key))); - char noteName[8]; midiNoteName(rootNote, noteName, sizeof(noteName)); - char buf[16]; snprintf(buf, sizeof(buf), "%s\n%s", CHORD_LABELS[i], noteName); - lv_label_set_text(lblObj, buf); - } - } -} - -// --------------------------------------------------------------------------- -// Callbacks -// --------------------------------------------------------------------------- - -static void cbBack(lv_event_t* e) { - auto* mc = static_cast(lv_event_get_user_data(e)); - if (mc) mc->switchToMode(MidiMode::HOME); -} - -static void cbChordPressed(lv_event_t* e) { - auto* mc = static_cast(lv_event_get_user_data(e)); - if (!mc) return; - lv_obj_t* btn = lv_event_get_target_obj(e); - int degree = (int)(intptr_t)lv_obj_get_user_data(btn); - mc->chord_.chordPlaying[degree] = true; - playChord(mc, degree); - refreshChordBtn(mc, degree); -} - -static void cbChordReleased(lv_event_t* e) { - auto* mc = static_cast(lv_event_get_user_data(e)); - if (!mc) return; - lv_obj_t* btn = lv_event_get_target_obj(e); - int degree = (int)(intptr_t)lv_obj_get_user_data(btn); - mc->chord_.chordPlaying[degree] = false; - // Use stored notes from press to avoid note-off mismatch if octave/scale changed - for (int n = 0; n < 4; n++) { - int note = mc->chord_.chordNotes[degree][n]; - if (note >= 0) mc->sendMidi(0x80, (uint8_t)note, 0); - } - refreshChordBtn(mc, degree); -} - -static void cbOctDown(lv_event_t* e) { - auto* mc = static_cast(lv_event_get_user_data(e)); - if (!mc) return; - mc->stopAllNotes(); - clearAllChordState(mc); - if (mc->chord_.octave > 1) mc->chord_.octave--; - rebuildChordBtnLabels(mc); - if (mc->chord_.octLbl) { - char buf[8]; snprintf(buf, sizeof(buf), "Oct %d", mc->chord_.octave); - lv_label_set_text(mc->chord_.octLbl, buf); - } -} -static void cbOctUp(lv_event_t* e) { - auto* mc = static_cast(lv_event_get_user_data(e)); - if (!mc) return; - mc->stopAllNotes(); - clearAllChordState(mc); - if (mc->chord_.octave < 7) mc->chord_.octave++; - rebuildChordBtnLabels(mc); - if (mc->chord_.octLbl) { - char buf[8]; snprintf(buf, sizeof(buf), "Oct %d", mc->chord_.octave); - lv_label_set_text(mc->chord_.octLbl, buf); - } -} - -static void cbScaleCycle(lv_event_t* e) { - auto* mc = static_cast(lv_event_get_user_data(e)); - if (!mc) return; - mc->stopAllNotes(); - clearAllChordState(mc); - mc->chord_.scale = (mc->chord_.scale + 1) % NUM_MIDI_SCALES; - rebuildChordBtnLabels(mc); - if (mc->chord_.scaleLbl) { - lv_label_set_text(mc->chord_.scaleLbl, MIDI_SCALES[mc->chord_.scale].name); - } -} - -static void cbClear(lv_event_t* e) { - auto* mc = static_cast(lv_event_get_user_data(e)); - if (!mc) return; - mc->stopAllNotes(); - for (int i = 0; i < 8; i++) { - mc->chord_.chordPlaying[i] = false; - refreshChordBtn(mc, i); - } -} - -// --------------------------------------------------------------------------- -// Build function -// --------------------------------------------------------------------------- - -void buildChordView(MidiController* mc, lv_obj_t* parent) { - auto& ch = mc->chord_; - // Mode switching sends All Notes Off, so any held-chord flags from the - // previous visit are stale; rebuild the buttons in the released state. - for (auto& playing : ch.chordPlaying) playing = false; - - const lv_font_t* fontSmall = lvgl_get_text_font(FONT_SIZE_SMALL); - const lv_font_t* fontNormal = lvgl_get_text_font(FONT_SIZE_DEFAULT); - int ctrlH = 88; - int btnH = MC_BTN_H; - - lv_obj_t* root = mcContainer(parent); - lv_obj_set_size(root, LV_PCT(100), LV_PCT(100)); - lv_obj_set_flex_flow(root, LV_FLEX_FLOW_COLUMN); - lv_obj_set_style_bg_color(root, lv_color_hex(0x080C12), 0); - lv_obj_set_style_bg_opa(root, LV_OPA_COVER, 0); - lv_obj_set_style_border_width(root, 0, 0); - lv_obj_set_style_pad_all(root, 0, 0); - lv_obj_set_style_pad_gap(root, 0, 0); - lv_obj_set_scrollable(root, false); - mc->currentViewRoot_ = root; - - // -- Control row (TOP) ---------------------------------------------------- - lv_obj_t* ctrl = mcRow(root, ctrlH); - - // Back - mcBackBtn(ctrl, cbBack, mc, 96); - - lv_obj_t* titleLbl = lv_label_create(ctrl); - lv_label_set_text(titleLbl, "CHORD"); - lv_obj_set_style_text_font(titleLbl, fontNormal, 0); - lv_obj_set_style_text_color(titleLbl, lv_color_hex(0x44BBFF), 0); - lv_obj_set_flex_grow(titleLbl, 1); - - // OCT - - lv_obj_t* octDown = mcButton(ctrl); - lv_obj_set_size(octDown, 128, btnH); - lv_obj_add_event_cb(octDown, cbOctDown, LV_EVENT_CLICKED, mc); - lv_obj_t* odl = lv_label_create(octDown); - lv_label_set_text(odl, "OCT-"); - lv_obj_set_style_text_font(odl, fontSmall, 0); - lv_obj_center(odl); - - // OCT label - ch.octLbl = lv_label_create(ctrl); - char obuf[8]; snprintf(obuf, sizeof(obuf), "Oct %d", ch.octave); - lv_label_set_text(ch.octLbl, obuf); - lv_obj_set_style_text_font(ch.octLbl, fontSmall, 0); - lv_obj_set_style_text_color(ch.octLbl, lv_color_hex(0x666688), 0); - lv_obj_set_width(ch.octLbl, 144); - lv_obj_set_style_text_align(ch.octLbl, LV_TEXT_ALIGN_CENTER, 0); - - // OCT + - lv_obj_t* octUp = mcButton(ctrl); - lv_obj_set_size(octUp, 128, btnH); - lv_obj_add_event_cb(octUp, cbOctUp, LV_EVENT_CLICKED, mc); - lv_obj_t* oul = lv_label_create(octUp); lv_label_set_text(oul, "OCT+"); - lv_obj_set_style_text_font(oul, fontSmall, 0); lv_obj_center(oul); - - // SCALE - lv_obj_t* scaleBtn = mcButton(ctrl); - lv_obj_set_size(scaleBtn, 224, btnH); - lv_obj_add_event_cb(scaleBtn, cbScaleCycle, LV_EVENT_CLICKED, mc); - ch.scaleLbl = lv_label_create(scaleBtn); - lv_label_set_text(ch.scaleLbl, MIDI_SCALES[ch.scale].name); - lv_obj_set_style_text_font(ch.scaleLbl, fontSmall, 0); - lv_obj_center(ch.scaleLbl); - - // CLEAR - lv_obj_t* clearBtn = mcButton(ctrl); - lv_obj_set_size(clearBtn, 176, btnH); - lv_obj_set_style_bg_color(clearBtn, lv_color_hex(0x331A00), 0); - lv_obj_add_event_cb(clearBtn, cbClear, LV_EVENT_CLICKED, mc); - lv_obj_t* clearLbl = lv_label_create(clearBtn); - lv_label_set_text(clearLbl, "CLEAR"); - lv_obj_set_style_text_font(clearLbl, fontSmall, 0); - lv_obj_center(clearLbl); - - // -- Chord button row (fills remaining space) ----------------------------- - lv_obj_t* chordRow = mcContainer(root); - lv_obj_set_width(chordRow, LV_PCT(100)); - lv_obj_set_flex_grow(chordRow, 1); - lv_obj_set_flex_flow(chordRow, LV_FLEX_FLOW_ROW); - lv_obj_set_flex_align(chordRow, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); - lv_obj_set_style_bg_color(chordRow, lv_color_hex(0x080C12), 0); - lv_obj_set_style_bg_opa(chordRow, LV_OPA_COVER, 0); - lv_obj_set_style_border_width(chordRow, 0, 0); - lv_obj_set_style_pad_all(chordRow, 8, 0); - lv_obj_set_style_pad_gap(chordRow, 8, 0); - lv_obj_set_scrollable(chordRow, false); - - for (int i = 0; i < 8; i++) { - lv_obj_t* btn = mcButton(chordRow); - lv_obj_set_flex_grow(btn, 1); - lv_obj_set_height(btn, LV_PCT(100)); - lv_obj_set_style_bg_color(btn, lv_color_hex(0x1A2A40), 0); - lv_obj_set_style_bg_opa(btn, LV_OPA_COVER, 0); - lv_obj_set_style_border_color(btn, lv_color_hex(DEGREE_COLORS[i]), 0); - lv_obj_set_style_border_width(btn, 8, 0); - lv_obj_set_style_radius(btn, 16, 0); - lv_obj_set_style_pad_all(btn, 8, 0); - lv_obj_set_user_data(btn, (void*)(intptr_t)i); - lv_obj_add_event_cb(btn, cbChordPressed, LV_EVENT_PRESSED, mc); - lv_obj_add_event_cb(btn, cbChordReleased, LV_EVENT_RELEASED, mc); - - // Label: Roman numeral + root note name with octave (two lines) - int rootNote = std::max(0, std::min(127, getNoteInScale(ch.scale, DEGREE_IDX[i], ch.octave + DEGREE_OCT[i], ch.key))); - char rootName[8]; midiNoteName(rootNote, rootName, sizeof(rootName)); - char lbuf[16]; snprintf(lbuf, sizeof(lbuf), "%s\n%s", CHORD_LABELS[i], rootName); - lv_obj_t* lbl = lv_label_create(btn); - lv_label_set_text(lbl, lbuf); - lv_obj_set_style_text_font(lbl, fontSmall, 0); - lv_obj_set_style_text_color(lbl, lv_color_hex(DEGREE_COLORS[i]), 0); - lv_obj_set_style_text_align(lbl, LV_TEXT_ALIGN_CENTER, 0); - lv_obj_center(lbl); - lv_obj_set_clickable(lbl, false); - - ch.chordBtn[i] = btn; - } - -} diff --git a/Apps/MidiController/main/Source/ViewDimensions.cpp b/Apps/MidiController/main/Source/ViewDimensions.cpp deleted file mode 100644 index 1227f1e..0000000 --- a/Apps/MidiController/main/Source/ViewDimensions.cpp +++ /dev/null @@ -1,575 +0,0 @@ -#include "MidiController.h" - -#include -#include -#include -#include -#include -#include -#include - -// --------------------------------------------------------------------------- -// DIMENSIONS view - parametric equation sequencer (20 equations) -// -// Layout (flex column): -// [row1: < | EQ- | Eq:N | EQ+ | PLAY | RESET] -// [row2: A- | A:N | A+ B- | B:N | B+] -// [row3: C- | C:N | C+ D- | D:N | D+] -// [outArea flex_grow: px:N py:N pz:N t:N] -// [row5: OCT- | Oct N | OCT+ RND- | Rnd:N | RND+ BPM- | N | BPM+] -// --------------------------------------------------------------------------- - - -// --------------------------------------------------------------------------- -// Parametric equations -// --------------------------------------------------------------------------- - -static inline float sq(float x) { return x * x; } - -// Arduino random() compat - uniform int in [lo, hi) -static inline int rng_range(int lo, int hi) { - if (hi <= lo) return lo; - return lo + (int)((uint32_t)(esp_random()) % (uint32_t)(hi - lo)); -} - -static void dimsEval(int eq, float t, float a, float b, float c, float d, - float rnd, float x, float y, - float& px, float& py, float& pz) { - if (a == 0) a = 0.001f; - if (b == 0) b = 0.001f; - if (c == 0) c = 0.001f; - if (d == 0) d = 0.001f; - - constexpr float PI = 3.14159265f; - float px_f, py_f, pz_f; - - switch (eq) { - case 1: - px_f = x + t * sinf(a * t + PI/2.0f); - py_f = y + t * sinf(b * t); - pz_f = t * cosf(a * t / PI * c); - break; - case 2: { - float denom2 = (a - b); - if (fabsf(denom2) < 0.01f) denom2 = (denom2 >= 0.0f) ? 0.01f : -0.01f; - px_f = x + t * sinf(a * t + PI/2.0f) + cosf(sq(a+b)/PI) * tanf(a); - py_f = y + t * sinf(b * t) * (tanf((t * a)/denom2) / (b * 4.0f)); - pz_f = t * cosf(a * t / PI); - } - break; - case 3: - px_f = x + t * sinf(a * t + PI/(c + 1.0f)) + cosf(sq(a+b)/(c + 0.5f)); - py_f = y + t * sinf(c * t) * (tanf((t * a)/(c-b + 0.12f)) / ((b * 4.0f) + 0.5f)); - pz_f = t * sinf(a * t / PI); - break; - case 4: - px_f = x + t * sinf(a * t + PI/c) + cosf(sq(PI/2.0f)/c); - py_f = y + t * sinf(b * t/2.0f) * cosf(t/PI * a); - pz_f = t * sinf(c * t / PI); - break; - case 5: - px_f = x + t * sinf(a * t/2.0f) + (rnd * (PI / (float)rng_range(2,30) * 1.75f)); - py_f = y + t * sinf(b * t) + (rnd * (PI / (float)rng_range(5,25) * c)); - pz_f = t * cosf(a * t / PI * c); - break; - case 6: - px_f = x + (float)rng_range(-64,64); - py_f = y + (float)rng_range(-64,64); - pz_f = t * (float)rng_range(0,127); - break; - case 7: - px_f = x + t * sinf(a * t + PI/3.0f) * (PI / powf(b, 2.0f)); - py_f = y + t * sinf(b * t); - pz_f = t * cosf(a * t / PI * c * t); - break; - case 8: - px_f = x + t * sinf(a * t + PI/2.0f) - (t * cosf(a * t + PI/4.0f)); - py_f = y + t * sinf(b * t) + (y - t * cosf(0.25f * b * t)); - pz_f = t * cosf(a * t / PI * c); - break; - case 9: - px_f = x + t * sinf(a * t + PI/25.0f) * (d * 0.5f); - py_f = y + t * cosf(-b * t + PI/130.0f) * (d * 0.5f) + t * sinf(a * t + PI/25.0f); - pz_f = t * sinf(t / PI * c); - break; - case 10: - px_f = x + t * sinf(a * t + PI/2.0f) * logf(16.0f)/b * 2.0f; - py_f = y + t * sinf(b * t) + sinf(d * t * 100.0f) + tanf(PI * t); - pz_f = t * 0.5f * sinf(t / PI * c + 0.25f); - break; - case 11: - px_f = x + t * sinf(a * t + PI/3.0f * 0.025f); - py_f = y + t * sinf(b * 0.05f * t * cosf(b * t * 0.05f)) + rnd; - pz_f = t * tanf(a * t / PI * c * 0.025f) + rnd * 1.0f + d; - break; - case 12: - px_f = x + t * sinf(a * t + PI/2.0f); - py_f = y + (float)rng_range(-64,64) * rnd * 1.0f/64.0f; - pz_f = t * cosf(d * t / PI * c) * 1.0f + b * sinf(t * 2.25f); - break; - case 13: { - float bmod = (b == 0) ? 1.0f : b; - px_f = x + t * sinf(fmodf(a, bmod) * t) + sinf(PI/3.0f * c); - py_f = y + t * sinf(t * c) + cosf(fmodf(a, bmod)) * 20.25f; - pz_f = t * cosf(a * t / PI); - break; - } - case 14: - px_f = x + (float)rng_range(-64,64) * rnd * 1.0f/64.0f; - py_f = y + t * sinf(a * t + PI/2.0f); - pz_f = t * sinf(b * t / PI) * 2.0f + b * cosf(t * 20.0f); - break; - case 15: - px_f = x + t * tanf(a * t + PI * sinf(b * t)) * 0.75f; - py_f = y + t * tanf(b * t) - cosf(a * t * 0.5f) * 0.75f; - pz_f = t * cosf(2.0f * a * t / PI * c); - break; - case 16: - px_f = x + t * sinf(a * t + PI/3.0f) - (1.75f * t) * sinf(1.5f * a * t + PI/3.0f) * 1.0f/c * 0.5f; - py_f = y + t * sinf(b * t + PI/3.0f) - (1.75f * t) * sinf(1.5f * b * t + PI/3.0f) * 1.0f/c * 0.5f; - pz_f = y + t * sinf(d * t + PI/3.0f) - (1.75f * t) * cosf(1.5f * d * t + PI/2.0f) * 1.0f/c * 0.5f; - break; - case 17: - px_f = x + t * sinf(a * t + PI); - py_f = y + t * tanf(b * t - PI); - pz_f = t * cosf(a * t / PI * c); - break; - case 18: { - int r = (int)fabsf(rnd); - if (r == 0) r = 1; - px_f = x + (float)rng_range(-r, r); - py_f = y + (float)rng_range(-r, r); - pz_f = t * (float)rng_range(0, r); - break; - } - case 19: - px_f = x + t * sinf(a * t + PI/2.0f) + cosf(sq(a+b)/PI) * tanf(a) + rnd; - py_f = y + t * sinf(b/c * t) * (tanf((t * a)/(a+b)) / (b * 4.0f)) + d/2.25f; - pz_f = t * tanf(c * t / PI); - break; - case 20: - px_f = x + t * sinf(a * t + (PI / powf(c, 3.0f))); - py_f = y + (t * sinf(b * t/2.0f) * (tanf(t/PI * a) * 0.25f)) - d; - pz_f = t/2.25f * tanf(c * t/4.0f) * rnd; - break; - default: - px_f = x; py_f = y; pz_f = 64.0f; - break; - } - - // Clamp 0-127; guard NaN/Inf - auto guard = [](float v) -> float { - if (!std::isfinite(v)) return 64.0f; - return std::max(0.0f, std::min(127.0f, v)); - }; - px = guard(px_f); - py = guard(py_f); - pz = guard(pz_f); -} - -// px (0-127) → BPM-derived ms per step (10 clock-division tiers) -static uint32_t pxToMs(float px_val, int bpm) { - // Tiers range from a whole note (16 sixteenths) to a 32nd note (0.5). - static const float DIVIDERS[10] = { - 16.0f, 12.0f, 8.0f, 6.0f, 4.0f, 3.0f, 2.0f, 4.0f/3.0f, 1.0f, 0.5f - }; - int idx = (int)(px_val / 127.0f * 9.0f + 0.5f); - if (idx < 0) idx = 0; - if (idx > 9) idx = 9; - float beatMs = 60000.0f / (float)bpm; - uint32_t ms = (uint32_t)(beatMs * DIVIDERS[idx] / 4.0f); - if (ms < 20) ms = 20; - return ms; -} - -// --------------------------------------------------------------------------- -// Timer callback -// --------------------------------------------------------------------------- - -static void cbDimTimer(lv_timer_t* t) { - auto* mc = static_cast(lv_timer_get_user_data(t)); - if (!mc) return; - auto& ds = mc->dim_; - if (!ds.playing) return; - - uint32_t now = lv_tick_get(); - - // Note-off - if (ds.lastNote >= 0 && now >= ds.noteOffMs) { - mc->sendMidi(0x80, (uint8_t)ds.lastNote, 0); - ds.lastNote = -1; - } - - if (now < ds.tickMs) return; - - // Evaluate equation - dimsEval(ds.equation, ds.t, ds.a, ds.b, ds.c, ds.d, ds.rnd, - ds.xOff, ds.yOff, ds.px, ds.py, ds.pz); - - // Advance t (wraps at t2) - ds.t += ds.gran; - if (ds.t > ds.t2) ds.t = 0.0f; - - // py → note (0-127 mapped to C2-C7, chromatic, before the octave offset) - int note = (int)(ds.py / 127.0f * 60.0f) + 36; // MIDI 36-96 - note += ds.octave * 12; - if (note < 0) note = 0; - if (note > 127) note = 127; - - // pz → velocity - int vel = std::max(1, std::min(127, (int)ds.pz)); - - // Trigger if py above rest threshold (low gate: ~4%) - if (ds.py >= 5.0f) { - if (ds.lastNote >= 0) { - mc->sendMidi(0x80, (uint8_t)ds.lastNote, 0); - } - mc->sendMidi(0x90, (uint8_t)note, (uint8_t)vel); - ds.lastNote = note; - ds.noteOffMs = now + 80; // 80ms gate - } - - // Schedule next tick based on px - uint32_t stepMs = pxToMs(ds.px, ds.bpm); - ds.tickMs = now + stepMs; - - // Update output label - if (ds.outLbl) { - char buf[48]; - snprintf(buf, sizeof(buf), "px:%-3d py:%-3d pz:%-3d t:%-5.1f", - (int)ds.px, (int)ds.py, (int)ds.pz, ds.t); - lv_label_set_text(ds.outLbl, buf); - } -} - -// Forward declarations for public encoder entry points (defined at file end) -void dimTogglePlay(MidiController* mc); -void dimReset (MidiController* mc); - -// --------------------------------------------------------------------------- -// Callbacks -// --------------------------------------------------------------------------- - -static void cbDimBack(lv_event_t* e) { - auto* mc = static_cast(lv_event_get_user_data(e)); - if (mc) mc->switchToMode(MidiMode::HOME); -} - -static void cbDimPlay(lv_event_t* e) { - auto* mc = static_cast(lv_event_get_user_data(e)); - if (mc) dimTogglePlay(mc); -} - -static void cbDimReset(lv_event_t* e) { - auto* mc = static_cast(lv_event_get_user_data(e)); - if (!mc) return; - dimReset(mc); -} - -static void cbDimEqDown(lv_event_t* e) { - auto* mc = static_cast(lv_event_get_user_data(e)); - if (!mc) return; - auto& ds = mc->dim_; - ds.equation = (ds.equation > 1) ? ds.equation - 1 : 20; - if (ds.eqLbl) { - char buf[16]; snprintf(buf, sizeof(buf), "Eq:%d", ds.equation); - lv_label_set_text(ds.eqLbl, buf); - } -} -static void cbDimEqUp(lv_event_t* e) { - auto* mc = static_cast(lv_event_get_user_data(e)); - if (!mc) return; - auto& ds = mc->dim_; - ds.equation = (ds.equation < 20) ? ds.equation + 1 : 1; - if (ds.eqLbl) { - char buf[16]; snprintf(buf, sizeof(buf), "Eq:%d", ds.equation); - lv_label_set_text(ds.eqLbl, buf); - } -} - -// Generic param adjust helpers - user_data encodes which param + direction -// We use a small enum packing: low bit = direction (0=down 1=up), rest = param idx (0=a,1=b,2=c,3=d) -struct DimParamEvt { MidiController* mc; int param; int dir; }; -static DimParamEvt s_paramEvt[8]; // 4 params × 2 directions - -static void cbDimParam(lv_event_t* e) { - auto* pe = static_cast(lv_event_get_user_data(e)); - if (!pe || !pe->mc) return; - auto& ds = pe->mc->dim_; - float* p[4] = {&ds.a, &ds.b, &ds.c, &ds.d}; - lv_obj_t** lbls[4] = {&ds.aLbl, &ds.bLbl, &ds.cLbl, &ds.dLbl}; - static const char* names[4] = {"A", "B", "C", "D"}; - float& v = *p[pe->param]; - v += (pe->dir > 0) ? 1.0f : -1.0f; - if (v < 0) v = 0; - if (v > 100) v = 100; - if (*lbls[pe->param]) { - char buf[12]; snprintf(buf, sizeof(buf), "%s:%.0f", names[pe->param], v); - lv_label_set_text(*lbls[pe->param], buf); - } -} - -static void cbDimOctDown(lv_event_t* e) { - auto* mc = static_cast(lv_event_get_user_data(e)); - if (!mc) return; - auto& ds = mc->dim_; - if (ds.octave > -4) ds.octave--; - if (ds.octLbl) { - char buf[10]; snprintf(buf, sizeof(buf), "Oct%+d", ds.octave); - lv_label_set_text(ds.octLbl, buf); - } -} -static void cbDimOctUp(lv_event_t* e) { - auto* mc = static_cast(lv_event_get_user_data(e)); - if (!mc) return; - auto& ds = mc->dim_; - if (ds.octave < 4) ds.octave++; - if (ds.octLbl) { - char buf[10]; snprintf(buf, sizeof(buf), "Oct%+d", ds.octave); - lv_label_set_text(ds.octLbl, buf); - } -} - -static void cbDimRndDown(lv_event_t* e) { - auto* mc = static_cast(lv_event_get_user_data(e)); - if (!mc) return; - auto& ds = mc->dim_; - if (ds.rnd > 0) ds.rnd -= 5.0f; - if (ds.rnd < 0) ds.rnd = 0; - if (ds.rndLbl) { - char buf[10]; snprintf(buf, sizeof(buf), "R:%.0f", ds.rnd); - lv_label_set_text(ds.rndLbl, buf); - } -} -static void cbDimRndUp(lv_event_t* e) { - auto* mc = static_cast(lv_event_get_user_data(e)); - if (!mc) return; - auto& ds = mc->dim_; - if (ds.rnd < 64) ds.rnd += 5.0f; - if (ds.rndLbl) { - char buf[10]; snprintf(buf, sizeof(buf), "R:%.0f", ds.rnd); - lv_label_set_text(ds.rndLbl, buf); - } -} - -static void cbDimBpmDown(lv_event_t* e) { - auto* mc = static_cast(lv_event_get_user_data(e)); - if (!mc) return; - auto& ds = mc->dim_; - if (ds.bpm > 40) ds.bpm -= 5; - if (ds.bpmLbl) { - char buf[8]; snprintf(buf, sizeof(buf), "%d", ds.bpm); - lv_label_set_text(ds.bpmLbl, buf); - } -} -static void cbDimBpmUp(lv_event_t* e) { - auto* mc = static_cast(lv_event_get_user_data(e)); - if (!mc) return; - auto& ds = mc->dim_; - if (ds.bpm < 280) ds.bpm += 5; - if (ds.bpmLbl) { - char buf[8]; snprintf(buf, sizeof(buf), "%d", ds.bpm); - lv_label_set_text(ds.bpmLbl, buf); - } -} - -// --------------------------------------------------------------------------- -// Build -// --------------------------------------------------------------------------- - -void buildDimensionsView(MidiController* mc, lv_obj_t* parent) { - auto& ds = mc->dim_; - - const lv_font_t* fontSmall = lvgl_get_text_font(FONT_SIZE_SMALL); - const lv_font_t* fontNormal = lvgl_get_text_font(FONT_SIZE_DEFAULT); - - int smallBtnW = 88; - int valW = 152; - - lv_obj_t* root = mcContainer(parent); - lv_obj_set_size(root, LV_PCT(100), LV_PCT(100)); - lv_obj_set_flex_flow(root, LV_FLEX_FLOW_COLUMN); - lv_obj_set_style_bg_color(root, lv_color_hex(0x080C14), 0); - lv_obj_set_style_bg_opa(root, LV_OPA_COVER, 0); - lv_obj_set_style_border_width(root, 0, 0); - lv_obj_set_style_pad_all(root, 0, 0); - lv_obj_set_style_pad_gap(root, 8, 0); - lv_obj_set_scrollable(root, false); - mc->currentViewRoot_ = root; - - auto makeRow = [&]() -> lv_obj_t* { return mcRow(root); }; - - auto makeBtn = [&](lv_obj_t* par, const char* txt, int w, lv_event_cb_t cb, void* ud) -> lv_obj_t* { - return mcSmallBtn(par, txt, w, cb, ud); - }; - - auto makeLbl = [&](lv_obj_t* par, const char* txt, int w, lv_color_t col) -> lv_obj_t* { - return mcValue(par, txt, w, col); - }; - - auto makeSepLbl = [&](lv_obj_t* par) { - lv_obj_t* sep = lv_label_create(par); - lv_label_set_text(sep, "|"); - lv_obj_set_style_text_font(sep, fontSmall, 0); - lv_obj_set_style_text_color(sep, lv_color_hex(0x334455), 0); - }; - - // -- Row 1: [<] [EQ-] [Eq:N] [EQ+] | [PLAY] [RESET] ----------------------- - { - lv_obj_t* row = makeRow(); - mcBackBtn(row, cbDimBack, mc, MC_W_S); - lv_obj_t* titleLbl = lv_label_create(row); - lv_label_set_text(titleLbl, "DIMENSIONS"); - lv_obj_set_style_text_font(titleLbl, fontNormal, 0); - lv_obj_set_style_text_color(titleLbl, lv_color_hex(0x44BBFF), 0); - lv_obj_set_flex_grow(titleLbl, 1); - makeBtn(row, "<", smallBtnW, cbDimEqDown, mc); - char buf[16]; snprintf(buf, sizeof(buf), "Eq:%d", ds.equation); - ds.eqLbl = makeLbl(row, buf, 144, lv_color_hex(0xFF8844)); - makeBtn(row, ">", smallBtnW, cbDimEqUp, mc); - makeSepLbl(row); - lv_obj_t* playBtn = makeBtn(row, ds.playing ? "STOP" : "PLAY", 176, cbDimPlay, mc); - ds.playLbl = lv_obj_get_child(playBtn, 0); - makeBtn(row, "RESET", 192, cbDimReset, mc); - } - - // -- Row 2: A and B params -------------------------------------------------- - { - lv_obj_t* row = makeRow(); - // A - s_paramEvt[0] = {mc, 0, -1}; - s_paramEvt[1] = {mc, 0, +1}; - makeBtn(row, "A-", smallBtnW, cbDimParam, &s_paramEvt[0]); - char bufA[12]; snprintf(bufA, sizeof(bufA), "A:%.0f", ds.a); - ds.aLbl = makeLbl(row, bufA, valW, lv_color_hex(0x88DDFF)); - makeBtn(row, "A+", smallBtnW, cbDimParam, &s_paramEvt[1]); - makeSepLbl(row); - // B - s_paramEvt[2] = {mc, 1, -1}; - s_paramEvt[3] = {mc, 1, +1}; - makeBtn(row, "B-", smallBtnW, cbDimParam, &s_paramEvt[2]); - char bufB[12]; snprintf(bufB, sizeof(bufB), "B:%.0f", ds.b); - ds.bLbl = makeLbl(row, bufB, valW, lv_color_hex(0x88FFCC)); - makeBtn(row, "B+", smallBtnW, cbDimParam, &s_paramEvt[3]); - } - - // -- Row 3: C and D params -------------------------------------------------- - { - lv_obj_t* row = makeRow(); - // C - s_paramEvt[4] = {mc, 2, -1}; - s_paramEvt[5] = {mc, 2, +1}; - makeBtn(row, "C-", smallBtnW, cbDimParam, &s_paramEvt[4]); - char bufC[12]; snprintf(bufC, sizeof(bufC), "C:%.0f", ds.c); - ds.cLbl = makeLbl(row, bufC, valW, lv_color_hex(0xFFCC88)); - makeBtn(row, "C+", smallBtnW, cbDimParam, &s_paramEvt[5]); - makeSepLbl(row); - // D - s_paramEvt[6] = {mc, 3, -1}; - s_paramEvt[7] = {mc, 3, +1}; - makeBtn(row, "D-", smallBtnW, cbDimParam, &s_paramEvt[6]); - char bufD[12]; snprintf(bufD, sizeof(bufD), "D:%.0f", ds.d); - ds.dLbl = makeLbl(row, bufD, valW, lv_color_hex(0xFF88CC)); - makeBtn(row, "D+", smallBtnW, cbDimParam, &s_paramEvt[7]); - } - - // -- Output area (flex_grow) ------------------------------------------------ - { - lv_obj_t* outArea = mcContainer(root); - lv_obj_set_width(outArea, LV_PCT(100)); - lv_obj_set_flex_grow(outArea, 1); - lv_obj_set_style_bg_color(outArea, lv_color_hex(0x05080F), 0); - lv_obj_set_style_bg_opa(outArea, LV_OPA_COVER, 0); - lv_obj_set_style_border_width(outArea, 0, 0); - lv_obj_set_style_pad_all(outArea, 24, 0); - lv_obj_set_scrollable(outArea, false); - lv_obj_set_flex_flow(outArea, LV_FLEX_FLOW_COLUMN); - lv_obj_set_flex_align(outArea, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); - - ds.outLbl = lv_label_create(outArea); - lv_label_set_text(ds.outLbl, "px: -- py: -- pz: -- t: --"); - lv_obj_set_style_text_font(ds.outLbl, fontNormal, 0); - lv_obj_set_style_text_color(ds.outLbl, lv_color_hex(0x44BBEE), 0); - lv_obj_set_width(ds.outLbl, LV_PCT(100)); - lv_obj_set_style_text_align(ds.outLbl, LV_TEXT_ALIGN_CENTER, 0); - } - - // -- Row 5: OCT RND BPM -------------------------------------------------- - { - lv_obj_t* row = makeRow(); - // Octave - makeBtn(row, "<", smallBtnW, cbDimOctDown, mc); - char bufOct[10]; snprintf(bufOct, sizeof(bufOct), "Oct%+d", ds.octave); - ds.octLbl = makeLbl(row, bufOct, 168, lv_color_hex(0xAAFF88)); - makeBtn(row, ">", smallBtnW, cbDimOctUp, mc); - makeSepLbl(row); - // Rnd - makeBtn(row, "<", smallBtnW, cbDimRndDown, mc); - char bufRnd[10]; snprintf(bufRnd, sizeof(bufRnd), "R:%.0f", ds.rnd); - ds.rndLbl = makeLbl(row, bufRnd, 144, lv_color_hex(0xFFAA44)); - makeBtn(row, ">", smallBtnW, cbDimRndUp, mc); - makeSepLbl(row); - // BPM - makeBtn(row, "<", smallBtnW, cbDimBpmDown, mc); - char bufBpm[8]; snprintf(bufBpm, sizeof(bufBpm), "%d", ds.bpm); - ds.bpmLbl = makeLbl(row, bufBpm, 144, lv_color_hex(0xFFE000)); - makeBtn(row, ">", smallBtnW, cbDimBpmUp, mc); - } - - // -- Start timer (20ms poll) ------------------------------------------------ - mc->dimTimer_ = lv_timer_create(cbDimTimer, 20, mc); - ds.tickMs = lv_tick_get(); -} - -// --------------------------------------------------------------------------- -// Public encoder entry points (called from EncoderInput.cpp) -// --------------------------------------------------------------------------- - -void dimTogglePlay(MidiController* mc) { - auto& ds = mc->dim_; - ds.playing = !ds.playing; - if (!ds.playing && ds.lastNote >= 0) { - mc->sendMidi(0x80, (uint8_t)ds.lastNote, 0); - ds.lastNote = -1; - } - if (ds.playing) ds.tickMs = lv_tick_get(); - if (ds.playLbl) lv_label_set_text(ds.playLbl, ds.playing ? "STOP" : "PLAY"); -} - -void dimReset(MidiController* mc) { - auto& ds = mc->dim_; - ds.t = 0.0f; - ds.a = 50; ds.b = 50; ds.c = 50; ds.d = 50; - if (ds.aLbl) lv_label_set_text(ds.aLbl, "A:50"); - if (ds.bLbl) lv_label_set_text(ds.bLbl, "B:50"); - if (ds.cLbl) lv_label_set_text(ds.cLbl, "C:50"); - if (ds.dLbl) lv_label_set_text(ds.dLbl, "D:50"); - if (ds.lastNote >= 0) { - mc->sendMidi(0x80, (uint8_t)ds.lastNote, 0); - ds.lastNote = -1; - } -} - -void dimChangeOctave(MidiController* mc, int delta) { - auto& ds = mc->dim_; - int oct = ds.octave + delta; - if (oct < -4) oct = -4; - if (oct > 4) oct = 4; - ds.octave = oct; - if (ds.octLbl) { - char buf[10]; snprintf(buf, sizeof(buf), "Oct%+d", oct); - lv_label_set_text(ds.octLbl, buf); - } -} - -void dimChangeRandom(MidiController* mc, int delta) { - auto& ds = mc->dim_; - float r = ds.rnd + (float)delta; - if (r < 0) r = 0; - if (r > 64) r = 64; - ds.rnd = r; - if (ds.rndLbl) { - char buf[8]; snprintf(buf, sizeof(buf), "R:%.0f", r); - lv_label_set_text(ds.rndLbl, buf); - } -} diff --git a/Apps/MidiController/main/Source/ViewDrop.cpp b/Apps/MidiController/main/Source/ViewDrop.cpp deleted file mode 100644 index 8ff754c..0000000 --- a/Apps/MidiController/main/Source/ViewDrop.cpp +++ /dev/null @@ -1,505 +0,0 @@ -#include "MidiController.h" - -#include -#include -#include -#include -#include -#include - -// --------------------------------------------------------------------------- -// DROP view - Gravity-drop physics + platform note triggering -// -// Layout (flex column): -// [physics area - flex_grow, touch to drop/place] -// [control row: < | MODE | OCT- | Oct N | OCT+ | CLEAR | SCALE | KEY- | KEY+ | status] -// -// Two modes: -// DROP (default): touch spawns a ball at touch point -// EDIT: touch places a horizontal platform (up to MAX_PLATFORMS); CLEAR removes all -// --------------------------------------------------------------------------- - - -static const uint32_t DROP_BALL_COLORS[] = { - 0xFF8800, 0xFF4444, 0x44FFAA, 0xAA44FF, - 0xFFFF44, 0x44AAFF, 0xFF44AA, 0x44FF44 -}; - -static constexpr float GRAVITY_BASE = 0.16f; -static constexpr float BOUNCE_COEFF = 0.55f; -static constexpr float FRICTION = 0.98f; -static constexpr float BALL_LIFE_MS = 8000.0f; - -// --------------------------------------------------------------------------- -// Physics helpers -// --------------------------------------------------------------------------- - -struct Vec2 { float x, y; }; -static float dot(Vec2 a, Vec2 b) { return a.x*b.x + a.y*b.y; } - -static void updatePlatformNote(MidiController* mc, int p) { - auto& dr = mc->drop_; - int degree = (p * 2) % 7; - dr.platforms[p].note = getNoteInScale(dr.scale, degree, dr.octave, dr.key); -} - -static void refreshPlatformLabel(MidiController* mc, int p) { - auto& dr = mc->drop_; - if (!dr.canvas || p < 0 || p >= dr.numPlatforms) return; - auto& plt = dr.platforms[p]; - - // Create label widget if it doesn't exist yet - if (!dr.platLbl[p]) { - lv_obj_t* lbl = lv_label_create(dr.canvas); - lv_obj_set_style_text_color(lbl, lv_color_hex(0xFFFFFF), 0); - lv_obj_set_style_text_font(lbl, lvgl_get_text_font(FONT_SIZE_SMALL), 0); - lv_obj_set_style_bg_color(lbl, lv_color_hex(0x000000), 0); - lv_obj_set_style_bg_opa(lbl, LV_OPA_70, 0); - lv_obj_set_style_border_width(lbl, 0, 0); - lv_obj_set_style_pad_all(lbl, 4, 0); - lv_obj_set_style_radius(lbl, 8, 0); - lv_obj_set_scrollable(lbl, false); - lv_obj_set_clickable(lbl, false); - dr.platLbl[p] = lbl; - } - - char noteName[8]; midiNoteName(plt.note, noteName, sizeof(noteName)); - lv_label_set_text(dr.platLbl[p], noteName); - // Size to content so dark bg pill fits text exactly - lv_obj_set_size(dr.platLbl[p], LV_SIZE_CONTENT, LV_SIZE_CONTENT); - // Let LVGL compute content size first, then center on platform - lv_obj_update_layout(dr.platLbl[p]); - lv_coord_t lblW = lv_obj_get_width(dr.platLbl[p]); - lv_coord_t lblH = lv_obj_get_height(dr.platLbl[p]); - lv_obj_set_pos(dr.platLbl[p], - (lv_coord_t)(plt.x - lblW / 2), - (lv_coord_t)(plt.y - lblH / 2)); -} - -static void rebuildPlatformNotes(MidiController* mc) { - auto& dr = mc->drop_; - for (int p = 0; p < dr.numPlatforms; p++) { - updatePlatformNote(mc, p); - refreshPlatformLabel(mc, p); - } -} - -// --------------------------------------------------------------------------- -// Draw event -// --------------------------------------------------------------------------- - -static void cbDropDraw(lv_event_t* e) { - auto* mc = static_cast(lv_event_get_user_data(e)); - if (!mc) return; - auto& dr = mc->drop_; - - lv_obj_t* obj = lv_event_get_target_obj(e); - lv_layer_t* layer = lv_event_get_layer(e); - - lv_area_t area; - lv_obj_get_coords(obj, &area); - int32_t ox = area.x1, oy = area.y1; - - // Background - lv_draw_fill_dsc_t bg; - lv_draw_fill_dsc_init(&bg); - bg.color = lv_color_hex(0x080C12); - bg.opa = LV_OPA_COVER; - lv_draw_fill(layer, &bg, &area); - - if (dr.numPlatforms == 0) return; - - // Note-based platform colors (hue cycle per semitone) - static const uint32_t NOTE_COLORS[12] = { - 0xFF3333, 0xFF6600, 0xFFAA00, 0xFFFF00, - 0x88FF00, 0x00FF88, 0x00FFFF, 0x0088FF, - 0x0033FF, 0x8800FF, 0xFF00FF, 0xFF0088 - }; - - // Draw platforms - line width matches physics platH (half-thickness × 2) - float speedScale = (dr.canvasW > 0) ? (float)dr.canvasW / 220.0f : 1.0f; - lv_draw_line_dsc_t line; - lv_draw_line_dsc_init(&line); - line.width = std::max(2, (int)(6.0f * speedScale * 2.0f)); - line.opa = LV_OPA_COVER; - - for (int p = 0; p < dr.numPlatforms; p++) { - auto& plt = dr.platforms[p]; - float cosA = cosf(plt.angle); - float sinA = sinf(plt.angle); - float hw = plt.w * 0.5f; - line.color = lv_color_hex(NOTE_COLORS[plt.note % 12]); - line.p1.x = (lv_value_precise_t)(ox + plt.x - hw * cosA); - line.p1.y = (lv_value_precise_t)(oy + plt.y - hw * sinA); - line.p2.x = (lv_value_precise_t)(ox + plt.x + hw * cosA); - line.p2.y = (lv_value_precise_t)(oy + plt.y + hw * sinA); - lv_draw_line(layer, &line); - } -} - -// --------------------------------------------------------------------------- -// Physics timer -// --------------------------------------------------------------------------- - -static void cbDropTimer(lv_timer_t* t) { - auto* mc = static_cast(lv_timer_get_user_data(t)); - if (!mc) return; - auto& dr = mc->drop_; - if (!dr.canvas || dr.canvasW <= 0 || dr.canvasH <= 0) return; - - float cW = (float)dr.canvasW; - float cH = (float)dr.canvasH; - float speedScale = cW / 220.0f; - float gravity = GRAVITY_BASE * speedScale; - // Platform half-thickness scales with display so balls hit reliably - float platH = 6.0f * speedScale; - int ballR = 16; - uint32_t nowMs = lv_tick_get(); - - for (int b = 0; b < MidiController::DropState::MAX_BALLS; b++) { - auto& ball = dr.balls[b]; - if (!ball.active) continue; - - if ((nowMs - ball.spawnedMs) > (uint32_t)BALL_LIFE_MS) { - ball.active = false; - continue; - } - - ball.vy += gravity; - ball.vx *= FRICTION; - ball.x += ball.vx; - ball.y += ball.vy; - - // Floor bounce - if (ball.y >= cH - ballR) { - ball.y = cH - ballR; - ball.vy = -ball.vy * BOUNCE_COEFF; - if (fabsf(ball.vy) < 0.5f * speedScale) ball.vy = 0; - } - // Wall clamp - if (ball.x < ballR) ball.x = ballR; - if (ball.x > cW - ballR) ball.x = cW - ballR; - - // Platform collisions - for (int p = 0; p < dr.numPlatforms; p++) { - auto& plt = dr.platforms[p]; - float cosA = cosf(plt.angle); - float sinA = sinf(plt.angle); - float dx = ball.x - plt.x; - float dy = ball.y - plt.y; - float localX = dx * cosA + dy * sinA; - float localY = -dx * sinA + dy * cosA; - float halfW = plt.w * 0.5f; - - if (fabsf(localX) < halfW && localY >= -platH && localY <= platH) { - // Normal perpendicular to platform, pointing toward the ball - Vec2 rawNormal = {-sinA, cosA}; - Vec2 vel = {ball.vx, ball.vy}; - float rawVn = dot(vel, rawNormal); - // Pick the normal direction that opposes ball's velocity - Vec2 normal = (rawVn < 0) ? rawNormal : Vec2{sinA, -cosA}; - float vn = dot(vel, normal); - if (vn < 0) { - ball.vx -= 2.0f * vn * normal.x; - ball.vy -= 2.0f * vn * normal.y; - ball.vx *= BOUNCE_COEFF; - ball.vy *= BOUNCE_COEFF; - // Push ball out along the normal so it clears the platform surface - ball.x = plt.x + (localX) * cosA + (platH + 1.0f) * normal.x; - ball.y = plt.y + (localX) * sinA + (platH + 1.0f) * normal.y; - - if (fabsf(vn) > 1.0f * speedScale) { - int vel_midi = std::min(127, 70 + (int)(fabsf(vn) / speedScale * 5)); - mc->sendMidi(0x90, (uint8_t)plt.note, vel_midi); - mc->sendMidi(0x80, (uint8_t)plt.note, 0); - } - } - } - } - } - - // Update ball widget positions - for (int b = 0; b < MidiController::DropState::MAX_BALLS; b++) { - if (dr.ballObj[b]) { - if (dr.balls[b].active) { - lv_obj_set_pos(dr.ballObj[b], - (lv_coord_t)(dr.balls[b].x - ballR), - (lv_coord_t)(dr.balls[b].y - ballR)); - lv_obj_set_hidden(dr.ballObj[b], false); - } else { - lv_obj_set_hidden(dr.ballObj[b], true); - } - } - } - - lv_obj_invalidate(dr.canvas); -} - -// --------------------------------------------------------------------------- -// Canvas size event - just update dimensions, no auto-platform creation -// --------------------------------------------------------------------------- - -static void cbCanvasSize(lv_event_t* e) { - auto* mc = static_cast(lv_event_get_user_data(e)); - if (!mc) return; - auto& dr = mc->drop_; - lv_obj_t* obj = lv_event_get_target_obj(e); - int w = (int)lv_obj_get_content_width(obj); - int h = (int)lv_obj_get_content_height(obj); - if (w <= 0 || h <= 0) return; - - // If canvas size changed, scale existing platform positions - if (dr.canvasW > 0 && dr.canvasH > 0 && (w != dr.canvasW || h != dr.canvasH)) { - float sx = (float)w / dr.canvasW; - float sy = (float)h / dr.canvasH; - for (int p = 0; p < dr.numPlatforms; p++) { - dr.platforms[p].x *= sx; - dr.platforms[p].y *= sy; - dr.platforms[p].w *= sx; - refreshPlatformLabel(mc, p); - } - } - dr.canvasW = w; - dr.canvasH = h; -} - -// --------------------------------------------------------------------------- -// Touch callback - spawns ball (DROP mode) or places platform (EDIT mode) -// --------------------------------------------------------------------------- - -static void cbCanvasTouch(lv_event_t* e) { - auto* mc = static_cast(lv_event_get_user_data(e)); - if (!mc) return; - auto& dr = mc->drop_; - if (dr.canvasW <= 0 || dr.canvasH <= 0) return; - - lv_indev_t* indev = lv_event_get_indev(e); - if (!indev) return; - lv_point_t pt; - lv_indev_get_point(indev, &pt); - - lv_area_t area; - lv_obj_get_coords(dr.canvas, &area); - float lx = (float)((int)pt.x - (int)area.x1); - float ly = (float)((int)pt.y - (int)area.y1); - if (lx < 0 || ly < 0 || lx > dr.canvasW || ly > dr.canvasH) return; - - if (dr.editMode) { - // Place a new horizontal platform at touch point (up to MAX_PLATFORMS) - if (dr.numPlatforms >= MidiController::DropState::MAX_PLATFORMS) return; - int p = dr.numPlatforms; - dr.platforms[p].x = lx; - dr.platforms[p].y = ly; - dr.platforms[p].w = (float)dr.canvasW * 0.22f; - dr.platforms[p].angle = 0.0f; // always horizontal - updatePlatformNote(mc, p); - dr.numPlatforms++; - refreshPlatformLabel(mc, p); - lv_obj_invalidate(dr.canvas); - } else { - // Spawn a ball at touch point - int ballR = 16; - for (int b = 0; b < MidiController::DropState::MAX_BALLS; b++) { - if (!dr.balls[b].active) { - dr.balls[b].x = lx; - dr.balls[b].y = ly; - float vx = ((float)(esp_random() % 201) - 100.0f) * 0.012f * ((float)dr.canvasW / 220.0f); - dr.balls[b].vx = vx; - dr.balls[b].vy = 0.0f; - dr.balls[b].active = true; - dr.balls[b].spawnedMs = lv_tick_get(); - if (!dr.ballObj[b]) { - lv_obj_t* w = mcContainer(dr.canvas); - lv_obj_set_size(w, ballR * 2, ballR * 2); - lv_obj_set_style_radius(w, LV_RADIUS_CIRCLE, 0); - lv_obj_set_style_bg_color(w, lv_color_hex(DROP_BALL_COLORS[b % 8]), 0); - lv_obj_set_style_bg_opa(w, LV_OPA_COVER, 0); - lv_obj_set_style_border_width(w, 0, 0); - lv_obj_set_style_pad_all(w, 0, 0); - lv_obj_set_scrollable(w, false); - lv_obj_set_clickable(w, false); - dr.ballObj[b] = w; - } - lv_obj_set_pos(dr.ballObj[b], (lv_coord_t)(lx - ballR), (lv_coord_t)(ly - ballR)); - lv_obj_set_hidden(dr.ballObj[b], false); - break; - } - } - } -} - -// --------------------------------------------------------------------------- -// Control callbacks -// --------------------------------------------------------------------------- - -static void cbBack(lv_event_t* e) { - auto* mc = static_cast(lv_event_get_user_data(e)); - if (mc) mc->switchToMode(MidiMode::HOME); -} - -static void cbModeToggle(lv_event_t* e) { - auto* mc = static_cast(lv_event_get_user_data(e)); - if (!mc) return; - auto& dr = mc->drop_; - dr.editMode = !dr.editMode; - // Button label shows what pressing again WILL do (toggle label) - if (dr.modeLbl) lv_label_set_text(dr.modeLbl, dr.editMode ? "DROP" : "EDIT"); - if (dr.modeBtn) { - lv_obj_set_style_bg_color(dr.modeBtn, - dr.editMode ? lv_color_hex(0x008833) : lv_color_hex(0x003388), 0); - } - if (dr.canvas) lv_obj_invalidate(dr.canvas); -} - -static void cbClear(lv_event_t* e) { - auto* mc = static_cast(lv_event_get_user_data(e)); - if (!mc) return; - auto& dr = mc->drop_; - // Clear balls - for (int b = 0; b < MidiController::DropState::MAX_BALLS; b++) - dr.balls[b].active = false; - // Clear platform labels - for (int p = 0; p < MidiController::DropState::MAX_PLATFORMS; p++) { - if (dr.platLbl[p]) { - lv_obj_delete(dr.platLbl[p]); - dr.platLbl[p] = nullptr; - } - } - dr.numPlatforms = 0; - mc->stopAllNotes(); - if (dr.canvas) lv_obj_invalidate(dr.canvas); -} - -static void cbScaleCycle(lv_event_t* e) { - auto* mc = static_cast(lv_event_get_user_data(e)); - if (!mc) return; - mc->drop_.scale = (mc->drop_.scale + 1) % NUM_MIDI_SCALES; - rebuildPlatformNotes(mc); - if (mc->drop_.statusLbl) lv_label_set_text(mc->drop_.statusLbl, MIDI_SCALES[mc->drop_.scale].name); -} - -static void cbKeyDown(lv_event_t* e) { - auto* mc = static_cast(lv_event_get_user_data(e)); - if (!mc) return; - mc->drop_.key = (mc->drop_.key + 11) % 12; - rebuildPlatformNotes(mc); -} -static void cbKeyUp(lv_event_t* e) { - auto* mc = static_cast(lv_event_get_user_data(e)); - if (!mc) return; - mc->drop_.key = (mc->drop_.key + 1) % 12; - rebuildPlatformNotes(mc); -} - -static void cbOctDown(lv_event_t* e) { - auto* mc = static_cast(lv_event_get_user_data(e)); - if (!mc) return; - if (mc->drop_.octave > 2) mc->drop_.octave--; - rebuildPlatformNotes(mc); - if (mc->drop_.octLbl) { - char buf[8]; snprintf(buf, sizeof(buf), "Oct %d", mc->drop_.octave); - lv_label_set_text(mc->drop_.octLbl, buf); - } -} -static void cbOctUp(lv_event_t* e) { - auto* mc = static_cast(lv_event_get_user_data(e)); - if (!mc) return; - if (mc->drop_.octave < 7) mc->drop_.octave++; - rebuildPlatformNotes(mc); - if (mc->drop_.octLbl) { - char buf[8]; snprintf(buf, sizeof(buf), "Oct %d", mc->drop_.octave); - lv_label_set_text(mc->drop_.octLbl, buf); - } -} - -// --------------------------------------------------------------------------- -// Build function -// --------------------------------------------------------------------------- - -void buildDropView(MidiController* mc, lv_obj_t* parent) { - auto& dr = mc->drop_; - - // Reset volatile state on view entry. ballObj[]/platLbl[] were nulled by - // switchToMode (previous root destroyed), null again here to be safe. - dr.numPlatforms = 0; - dr.editMode = false; - for (auto& ball : dr.balls) ball.active = false; - for (auto& obj : dr.ballObj) obj = nullptr; - for (auto& lbl : dr.platLbl) lbl = nullptr; - - // Clamp octave to valid range [2,7] - dr.octave = std::clamp(dr.octave, 2, 7); - - const lv_font_t* fontSmall = lvgl_get_text_font(FONT_SIZE_SMALL); - int ctrlH = 88; - - lv_obj_t* root = mcContainer(parent); - lv_obj_set_size(root, LV_PCT(100), LV_PCT(100)); - lv_obj_set_flex_flow(root, LV_FLEX_FLOW_COLUMN); - lv_obj_set_style_bg_color(root, lv_color_hex(0x080C12), 0); - lv_obj_set_style_bg_opa(root, LV_OPA_COVER, 0); - lv_obj_set_style_border_width(root, 0, 0); - lv_obj_set_style_pad_all(root, 0, 0); - lv_obj_set_style_pad_gap(root, 0, 0); - lv_obj_set_scrollable(root, false); - mc->currentViewRoot_ = root; - - // -- Control row (TOP) ---------------------------------------------------- - lv_obj_t* ctrl = mcRow(root, ctrlH); - - auto makeBtn = [&](const char* lbl, int w, lv_event_cb_t cb) -> lv_obj_t* { - return mcSmallBtn(ctrl, lbl, w, cb, mc); - }; - - // < (back) - mcBackBtn(ctrl, cbBack, mc, 96); - - // MODE toggle: DROP / EDIT - dr.modeBtn = makeBtn("EDIT", 176, cbModeToggle); - lv_obj_set_style_bg_color(dr.modeBtn, lv_color_hex(0x003388), 0); - dr.modeLbl = lv_obj_get_child(dr.modeBtn, 0); - - // OCT- label OCT+ - makeBtn("OCT-", 96, cbOctDown); - char octBuf[8]; snprintf(octBuf, sizeof(octBuf), "Oct %d", dr.octave); - dr.octLbl = lv_label_create(ctrl); - lv_label_set_text(dr.octLbl, octBuf); - lv_obj_set_style_text_font(dr.octLbl, fontSmall, 0); - lv_obj_set_style_text_color(dr.octLbl, lv_color_hex(0xFFFFFF), 0); - lv_obj_set_width(dr.octLbl, 64); - lv_obj_set_style_text_align(dr.octLbl, LV_TEXT_ALIGN_CENTER, 0); - makeBtn("OCT+", 96, cbOctUp); - - // CLR (clears balls and platforms) - makeBtn("CLEAR", 128, cbClear); - - // SCALE | KEY- | KEY+ - lv_obj_t* scaleBtn = mcButton(ctrl); - lv_obj_set_size(scaleBtn, 208, MC_BTN_H); - lv_obj_add_event_cb(scaleBtn, cbScaleCycle, LV_EVENT_CLICKED, mc); - dr.statusLbl = lv_label_create(scaleBtn); - lv_label_set_text(dr.statusLbl, MIDI_SCALES[dr.scale].name); - lv_obj_set_style_text_font(dr.statusLbl, fontSmall, 0); - lv_obj_center(dr.statusLbl); - - makeBtn("KEY-", 96, cbKeyDown); - makeBtn("KEY+", 96, cbKeyUp); - - // -- Physics area (fills remaining space below controls) ------------------- - lv_obj_t* physArea = mcContainer(root); - lv_obj_set_width(physArea, LV_PCT(100)); - lv_obj_set_flex_grow(physArea, 1); - lv_obj_set_style_bg_color(physArea, lv_color_hex(0x080C12), 0); - lv_obj_set_style_bg_opa(physArea, LV_OPA_COVER, 0); - lv_obj_set_style_border_width(physArea, 0, 0); - lv_obj_set_style_pad_all(physArea, 0, 0); - lv_obj_set_scrollable(physArea, false); - lv_obj_set_clickable(physArea, true); - lv_obj_add_event_cb(physArea, cbDropDraw, LV_EVENT_DRAW_MAIN, mc); - lv_obj_add_event_cb(physArea, cbCanvasSize, LV_EVENT_SIZE_CHANGED, mc); - lv_obj_add_event_cb(physArea, cbCanvasTouch, LV_EVENT_CLICKED, mc); - dr.canvas = physArea; - - // Start timer - mc->dropTimer_ = lv_timer_create(cbDropTimer, 16, mc); -} diff --git a/Apps/MidiController/main/Source/ViewEuclid.cpp b/Apps/MidiController/main/Source/ViewEuclid.cpp deleted file mode 100644 index 3c9e9b5..0000000 --- a/Apps/MidiController/main/Source/ViewEuclid.cpp +++ /dev/null @@ -1,662 +0,0 @@ -#include "MidiController.h" -#include "LcdDisplay.h" - -#include -#include -#include -#include -#include -#include - -// --------------------------------------------------------------------------- -// EUCLID view - 8-voice Euclidean rhythm sequencer -// -// (toolbar ~40px, statusbar ~20px → content ~1280×660) -// -// Layout (flex column, fills content area): -// TOP ROW (88px, full width): < | PLAY | BPM- N BPM+ | TRIP ON/OFF -// BODY (flex_grow, flex row): -// LEFT (flex_grow): canvas - 8 concentric rings + sweep line -// RIGHT (500px): controls stacked in flex column -// ├ voice tab row: 8 equal tabs -// ├ voice name label (large, colored) -// └ param rows (flex_grow each): Steps / Events / Rotation / Note -// --------------------------------------------------------------------------- - -// Fixed pixel constants -static constexpr int ROW_H = 88; // matches ViewKeys rowH -static constexpr int COL_W = 500; -static constexpr int CHROME_H = 60; // toolbar (~40px) + statusbar (~20px) -static constexpr int BTN_H = 64; // matches ViewKeys btnH (rowH-24) -static constexpr int BTN_W_SM = 88; // small button (< >) -static constexpr int BTN_W_MD = 144; // medium button (PLAY) -static constexpr int BTN_W_LG = 88; // back button -static constexpr int LBL_W = 100; // value label width -static constexpr int PAD = 12; // row padding / gap - matches ViewKeys - -// Per-voice accent colors (8 voices) -static constexpr uint32_t VOICE_COLORS[8] = { - 0xFF4040, 0xFF9900, 0x44DD44, 0x44BBFF, - 0xFF44FF, 0xFFDD00, 0x00FFCC, 0xFF8888 -}; - -// --------------------------------------------------------------------------- -// Bucket (Bresenham-style) Euclidean pattern generator -// --------------------------------------------------------------------------- - -static void euclidGenPattern(MidiController::EuclidState::Voice& v) { - memset(v.pattern, 0, sizeof(v.pattern)); - if (v.events == 0 || v.steps == 0) return; - int events = std::min(v.events, v.steps); - // Pre-fill the accumulator so the first event lands on step 0. Starting at - // zero instead delays the first event and produces uneven gaps for some - // pairs (for example E(3,8) became 00100110 rather than 10010010). - int bucket = v.steps - events; - for (int i = 0; i < v.steps; i++) { - bucket += events; - if (bucket >= v.steps) { - bucket -= v.steps; - int pos = (i + v.rotation) % v.steps; - if (pos < 0) pos += v.steps; - v.pattern[pos] = true; - } - } -} - -// --------------------------------------------------------------------------- -// Canvas draw -// --------------------------------------------------------------------------- - -static void euclidDrawCanvas(MidiController::EuclidState& es) { - if (!es.canvas) return; - lv_obj_clean(es.canvas); - - int W = lv_obj_get_width(es.canvas); - int H = lv_obj_get_height(es.canvas); - if (W <= 4 || H <= 4) { - // Layout hasn't settled yet - use stored estimates - W = es.canvasW; - H = es.canvasH; - } - if (W <= 0 || H <= 0) return; - - int cx = W / 2; - int cy = H / 2; - int maxR = std::min(cx, cy) - 8; - if (maxR < 20) return; - int ringSpacing = maxR / 9; // 8 rings + small gap from center - - for (int vi = 0; vi < 8; vi++) { - const auto& v = es.voice[vi]; - if (v.steps == 0) continue; - int r = maxR - vi * ringSpacing; - if (r < 8) continue; - - // Selected voice: draw a subtle ring outline behind the dots - if (vi == es.selectedVoice) { - lv_obj_t* ring = mcContainer(es.canvas); - lv_obj_set_size(ring, r * 2 + 8, r * 2 + 8); - lv_obj_set_pos(ring, cx - r - 4, cy - r - 4); - lv_obj_set_ignore_layout(ring, true); - lv_obj_set_style_radius(ring, LV_RADIUS_CIRCLE, 0); - lv_obj_set_style_bg_opa(ring, LV_OPA_TRANSP, 0); - lv_obj_set_style_border_width(ring, 2, 0); - lv_obj_set_style_border_color(ring, lv_color_hex(VOICE_COLORS[vi]), 0); - lv_obj_set_style_border_opa(ring, LV_OPA_50, 0); - lv_obj_set_scrollable(ring, false); - lv_obj_set_clickable(ring, false); - } - - for (int step = 0; step < v.steps; step++) { - float angle = (float)(2.0 * M_PI * step) / v.steps - (float)(M_PI / 2.0); - int px = cx + (int)(cosf(angle) * r); - int py = cy + (int)(sinf(angle) * r); - - bool active = v.pattern[step]; - bool isCurrent = (es.playing && step == (es.currentStep % v.steps)); - - uint32_t col; - if (isCurrent && active) col = 0xFFFFFF; - else if (isCurrent) col = 0x44BBFF; - else if (active) col = VOICE_COLORS[vi]; - else col = 0x1A2030; - - int dotR = active ? 7 : 4; - lv_obj_t* dot = mcContainer(es.canvas); - lv_obj_set_size(dot, dotR * 2, dotR * 2); - lv_obj_set_pos(dot, px - dotR, py - dotR); - lv_obj_set_ignore_layout(dot, true); - lv_obj_set_style_radius(dot, LV_RADIUS_CIRCLE, 0); - lv_obj_set_style_bg_color(dot, lv_color_hex(col), 0); - lv_obj_set_style_bg_opa(dot, LV_OPA_COVER, 0); - lv_obj_set_style_border_width(dot, 0, 0); - lv_obj_set_scrollable(dot, false); - lv_obj_set_clickable(dot, false); - } - } - - // Sweep line - radial from center to edge - if (es.playing && es.voice[0].steps > 0) { - int steps = es.voice[0].steps; - float angle = (float)(2.0 * M_PI * (es.currentStep % steps)) / steps - (float)(M_PI / 2.0); - int ex = cx + (int)(cosf(angle) * maxR); - int ey = cy + (int)(sinf(angle) * maxR); - static lv_point_precise_t pts[2]; - pts[0] = {(lv_value_precise_t)cx, (lv_value_precise_t)cy}; - pts[1] = {(lv_value_precise_t)ex, (lv_value_precise_t)ey}; - lv_obj_t* ln = lv_line_create(es.canvas); - lv_line_set_points(ln, pts, 2); - lv_obj_set_ignore_layout(ln, true); - lv_obj_set_style_line_color(ln, lv_color_hex(0x2244AA), 0); - lv_obj_set_style_line_opa(ln, LV_OPA_60, 0); - lv_obj_set_style_line_width(ln, 2, 0); - lv_obj_set_clickable(ln, false); - } -} - -// --------------------------------------------------------------------------- -// Update param labels for selected voice -// --------------------------------------------------------------------------- - -static void euclidUpdateParamLabels(MidiController::EuclidState& es) { - const auto& v = es.voice[es.selectedVoice]; - if (es.stepsLbl) { - char buf[8]; snprintf(buf, sizeof(buf), "S:%d", v.steps); - lv_label_set_text(es.stepsLbl, buf); - } - if (es.eventsLbl) { - char buf[8]; snprintf(buf, sizeof(buf), "E:%d", v.events); - lv_label_set_text(es.eventsLbl, buf); - } - if (es.rotLbl) { - char buf[8]; snprintf(buf, sizeof(buf), "R:%d", v.rotation); - lv_label_set_text(es.rotLbl, buf); - } - if (es.noteLbl) { - char buf[8]; midiNoteName(v.note, buf, sizeof(buf)); - lv_label_set_text(es.noteLbl, buf); - } - if (es.voiceLbl) { - char buf[24]; snprintf(buf, sizeof(buf), "Voice %d", es.selectedVoice + 1); - lv_label_set_text(es.voiceLbl, buf); - lv_obj_set_style_text_color(es.voiceLbl, lv_color_hex(VOICE_COLORS[es.selectedVoice]), 0); - } -} - -// --------------------------------------------------------------------------- -// Called by EncoderInput after modifying voice params via knob/button -// --------------------------------------------------------------------------- - -void euclidOnEncoderUpdate(MidiController* mc) { - auto& es = mc->euclid_; - // Regenerate pattern and refresh UI for all 8 voices (knobs may have - // changed any of them, and the canvas shows all voices simultaneously) - for (int i = 0; i < 8; i++) { - euclidGenPattern(es.voice[i]); - } - euclidUpdateParamLabels(es); - euclidDrawCanvas(es); - // Update voice tab button opacities (selected = full, others = dim) - if (es.voiceTabRow) { - uint32_t childCnt = lv_obj_get_child_count(es.voiceTabRow); - for (uint32_t i = 0; i < childCnt && i < 8; i++) { - lv_obj_t* t = lv_obj_get_child(es.voiceTabRow, i); - lv_obj_set_style_bg_opa(t, (i == (uint32_t)es.selectedVoice) ? LV_OPA_COVER : LV_OPA_30, 0); - } - } -} - -// --------------------------------------------------------------------------- -// Timer callback -// --------------------------------------------------------------------------- - -static void cbEuclidTimer(lv_timer_t* t) { - auto* mc = static_cast(lv_timer_get_user_data(t)); - if (!mc) return; - auto& es = mc->euclid_; - if (!es.playing) return; - - uint32_t now = lv_tick_get(); - if (now < es.nextStepMs) return; - - mc->sendMidi(0xB0, 123, 0); // all-notes-off - - for (int vi = 0; vi < 8; vi++) { - auto& v = es.voice[vi]; - if (v.steps == 0) continue; - int stepIdx = es.currentStep % v.steps; - if (v.pattern[stepIdx]) { - mc->sendMidi(0x90, (uint8_t)v.note, 110); - } - } - - // Free-running clock: each voice takes currentStep modulo its own length. - // Wrapping at 64 would restart voices whose step count does not divide 64. - es.currentStep++; - - uint32_t stepMs = 60000u / (uint32_t)es.bpm / 4u; - if (es.triplet) stepMs = stepMs * 2 / 3; - if (stepMs < 20) stepMs = 20; - es.nextStepMs = now + stepMs; - - if (es.bpmLbl) { - char buf[8]; snprintf(buf, sizeof(buf), "%d", es.bpm); - lv_label_set_text(es.bpmLbl, buf); - } - - euclidDrawCanvas(es); -} - -// --------------------------------------------------------------------------- -// Callbacks -// --------------------------------------------------------------------------- - -static void cbEuclidBack(lv_event_t* e) { - auto* mc = static_cast(lv_event_get_user_data(e)); - if (mc) mc->switchToMode(MidiMode::HOME); -} - -static void cbEuclidPlay(lv_event_t* e) { - auto* mc = static_cast(lv_event_get_user_data(e)); - if (!mc) return; - auto& es = mc->euclid_; - es.playing = !es.playing; - if (!es.playing) { - mc->sendMidi(0xB0, 123, 0); - es.currentStep = 0; - } else { - es.nextStepMs = lv_tick_get(); - } - if (es.playLbl) lv_label_set_text(es.playLbl, es.playing ? "STOP" : "PLAY"); - // Stopping resets the playhead; repaint the rings so it is not left on the - // previous step until the next timer tick (which no longer runs when stopped). - euclidDrawCanvas(es); - lcdUpdate(mc); -} - -static void cbEuclidTrip(lv_event_t* e) { - auto* mc = static_cast(lv_event_get_user_data(e)); - if (!mc) return; - auto& es = mc->euclid_; - es.triplet = !es.triplet; - if (es.tripLbl) lv_label_set_text(es.tripLbl, es.triplet ? "TRIP ON" : "TRIP OFF"); -} - -static void cbEuclidBpmDown(lv_event_t* e) { - auto* mc = static_cast(lv_event_get_user_data(e)); - if (!mc) return; - auto& es = mc->euclid_; - if (es.bpm > 40) es.bpm -= 5; - if (es.bpmLbl) { - char buf[8]; snprintf(buf, sizeof(buf), "%d", es.bpm); - lv_label_set_text(es.bpmLbl, buf); - } -} -static void cbEuclidBpmUp(lv_event_t* e) { - auto* mc = static_cast(lv_event_get_user_data(e)); - if (!mc) return; - auto& es = mc->euclid_; - if (es.bpm < 280) es.bpm += 5; - if (es.bpmLbl) { - char buf[8]; snprintf(buf, sizeof(buf), "%d", es.bpm); - lv_label_set_text(es.bpmLbl, buf); - } -} - -// Voice tab - mc is stored as object user_data, voice index as event user_data -static void cbEuclidVoiceTab(lv_event_t* e) { - lv_obj_t* tab = lv_event_get_target_obj(e); - auto* mc = static_cast(lv_obj_get_user_data(tab)); - if (!mc) return; - auto& es = mc->euclid_; - intptr_t idx = (intptr_t)lv_event_get_user_data(e); - es.selectedVoice = (int)idx; - - // Update tab button opacities: selected = full, others = dim - lv_obj_t* tabRow = lv_obj_get_parent(tab); - if (tabRow) { - uint32_t childCnt = lv_obj_get_child_count(tabRow); - for (uint32_t i = 0; i < childCnt && i < 8; i++) { - lv_obj_t* t = lv_obj_get_child(tabRow, i); - lv_obj_set_style_bg_opa(t, (i == (uint32_t)idx) ? LV_OPA_COVER : LV_OPA_30, 0); - } - } - - euclidUpdateParamLabels(es); - euclidDrawCanvas(es); - lcdUpdate(mc); -} - -static void cbEuclidStepsDown(lv_event_t* e) { - auto* mc = static_cast(lv_event_get_user_data(e)); - if (!mc) return; - auto& v = mc->euclid_.voice[mc->euclid_.selectedVoice]; - if (v.steps > 1) v.steps--; - if (v.events > v.steps) v.events = v.steps; - euclidGenPattern(v); - euclidUpdateParamLabels(mc->euclid_); - euclidDrawCanvas(mc->euclid_); -} -static void cbEuclidStepsUp(lv_event_t* e) { - auto* mc = static_cast(lv_event_get_user_data(e)); - if (!mc) return; - auto& v = mc->euclid_.voice[mc->euclid_.selectedVoice]; - if (v.steps < 32) v.steps++; - euclidGenPattern(v); - euclidUpdateParamLabels(mc->euclid_); - euclidDrawCanvas(mc->euclid_); -} - -static void cbEuclidEvtsDown(lv_event_t* e) { - auto* mc = static_cast(lv_event_get_user_data(e)); - if (!mc) return; - auto& v = mc->euclid_.voice[mc->euclid_.selectedVoice]; - if (v.events > 0) v.events--; - euclidGenPattern(v); - euclidUpdateParamLabels(mc->euclid_); - euclidDrawCanvas(mc->euclid_); -} -static void cbEuclidEvtsUp(lv_event_t* e) { - auto* mc = static_cast(lv_event_get_user_data(e)); - if (!mc) return; - auto& v = mc->euclid_.voice[mc->euclid_.selectedVoice]; - if (v.events < v.steps) v.events++; - euclidGenPattern(v); - euclidUpdateParamLabels(mc->euclid_); - euclidDrawCanvas(mc->euclid_); -} - -static void cbEuclidRotDown(lv_event_t* e) { - auto* mc = static_cast(lv_event_get_user_data(e)); - if (!mc) return; - auto& v = mc->euclid_.voice[mc->euclid_.selectedVoice]; - v.rotation = (v.rotation - 1 + v.steps) % std::max(1, v.steps); - euclidGenPattern(v); - euclidUpdateParamLabels(mc->euclid_); - euclidDrawCanvas(mc->euclid_); -} -static void cbEuclidRotUp(lv_event_t* e) { - auto* mc = static_cast(lv_event_get_user_data(e)); - if (!mc) return; - auto& v = mc->euclid_.voice[mc->euclid_.selectedVoice]; - v.rotation = (v.rotation + 1) % std::max(1, v.steps); - euclidGenPattern(v); - euclidUpdateParamLabels(mc->euclid_); - euclidDrawCanvas(mc->euclid_); -} - -static void cbEuclidNoteDown(lv_event_t* e) { - auto* mc = static_cast(lv_event_get_user_data(e)); - if (!mc) return; - auto& v = mc->euclid_.voice[mc->euclid_.selectedVoice]; - if (v.note > 0) v.note--; - euclidUpdateParamLabels(mc->euclid_); -} -static void cbEuclidNoteUp(lv_event_t* e) { - auto* mc = static_cast(lv_event_get_user_data(e)); - if (!mc) return; - auto& v = mc->euclid_.voice[mc->euclid_.selectedVoice]; - if (v.note < 127) v.note++; - euclidUpdateParamLabels(mc->euclid_); -} - -// --------------------------------------------------------------------------- -// Build -// --------------------------------------------------------------------------- - -void buildEuclidView(MidiController* mc, lv_obj_t* parent) { - auto& es = mc->euclid_; - - const lv_font_t* fontSmall = lvgl_get_text_font(FONT_SIZE_SMALL); - const lv_font_t* fontNormal = lvgl_get_text_font(FONT_SIZE_DEFAULT); - const lv_font_t* fontLarge = lvgl_get_text_font(FONT_SIZE_LARGE); - - // Default voice params if not yet set - static const int DEFAULT_STEPS[8] = {16, 16, 16, 16, 16, 16, 16, 16}; - static const int DEFAULT_EVENTS[8] = {4, 4, 8, 5, 3, 6, 7, 2}; - static const int DEFAULT_ROT[8] = {0, 2, 0, 1, 0, 1, 3, 2}; - static const int DEFAULT_NOTE[8] = {36, 38, 42, 39, 45, 47, 49, 51}; - for (int i = 0; i < 8; i++) { - auto& v = es.voice[i]; - if (v.steps == 0) { - v.steps = DEFAULT_STEPS[i]; - v.events = DEFAULT_EVENTS[i]; - v.rotation = DEFAULT_ROT[i]; - v.note = DEFAULT_NOTE[i]; - } - euclidGenPattern(v); - } - - // -- Root: flex COLUMN - full content area -------------------------------- - lv_obj_t* root = mcContainer(parent); - lv_obj_set_size(root, LV_PCT(100), LV_PCT(100)); - lv_obj_set_flex_flow(root, LV_FLEX_FLOW_COLUMN); - lv_obj_set_flex_align(root, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START); - lv_obj_set_style_bg_color(root, MC_BG(), 0); - lv_obj_set_style_bg_opa(root, LV_OPA_COVER, 0); - lv_obj_set_style_border_width(root, 0, 0); - lv_obj_set_style_pad_all(root, 0, 0); - lv_obj_set_style_pad_gap(root, 0, 0); - lv_obj_set_scrollable(root, false); - mc->currentViewRoot_ = root; - - // Shared helpers (used in both transport row and right column) - auto makeBtn = [&](lv_obj_t* par, const char* txt, int w, - lv_event_cb_t cb, void* ud) -> lv_obj_t* { - return mcSmallBtn(par, txt, w, cb, ud); - }; - - auto makeLbl = [&](lv_obj_t* par, const char* txt, int w, - lv_color_t col, const lv_font_t* font = nullptr) -> lv_obj_t* { - lv_obj_t* lbl = lv_label_create(par); - lv_label_set_text(lbl, txt); - lv_obj_set_style_text_font(lbl, font ? font : fontSmall, 0); - lv_obj_set_style_text_color(lbl, col, 0); - lv_obj_set_width(lbl, w); - lv_obj_set_style_text_align(lbl, LV_TEXT_ALIGN_CENTER, 0); - return lbl; - }; - - // -- Row 1: Full-width transport (ROW_H) ---------------------------------- - // < | PLAY | BPM- N BPM+ | TRIP ON/OFF - { - lv_obj_t* topRow = mcRow(root); - - mcBackBtn(topRow, cbEuclidBack, mc, BTN_W_LG); - - lv_obj_t* title = lv_label_create(topRow); - lv_label_set_text(title, "EUCLID"); - lv_obj_set_style_text_font(title, fontNormal, 0); - lv_obj_set_style_text_color(title, lv_color_hex(0x44BBFF), 0); - lv_obj_set_flex_grow(title, 1); - - lv_obj_t* playBtn = makeBtn(topRow, es.playing ? "STOP" : "PLAY", - BTN_W_MD, cbEuclidPlay, mc); - es.playLbl = lv_obj_get_child(playBtn, 0); - - lv_obj_t* sep = lv_label_create(topRow); - lv_label_set_text(sep, "|"); - lv_obj_set_style_text_font(sep, fontSmall, 0); - lv_obj_set_style_text_color(sep, lv_color_hex(0x334455), 0); - - makeBtn(topRow, "<", BTN_W_SM, cbEuclidBpmDown, mc); - char bpmBuf[8]; snprintf(bpmBuf, sizeof(bpmBuf), "%d", es.bpm); - es.bpmLbl = makeLbl(topRow, bpmBuf, LBL_W, lv_color_hex(0xFFE000)); - makeBtn(topRow, ">", BTN_W_SM, cbEuclidBpmUp, mc); - - sep = lv_label_create(topRow); - lv_label_set_text(sep, "|"); - lv_obj_set_style_text_font(sep, fontSmall, 0); - lv_obj_set_style_text_color(sep, lv_color_hex(0x334455), 0); - - lv_obj_t* tripBtn = makeBtn(topRow, es.triplet ? "TRIP ON" : "TRIP OFF", - 120, cbEuclidTrip, mc); - es.tripLbl = lv_obj_get_child(tripBtn, 0); - } - - // -- Body: flex ROW (flex_grow) - canvas LEFT + controls RIGHT ------------ - { - lv_obj_t* body = mcContainer(root); - lv_obj_set_size(body, LV_PCT(100), 0); - lv_obj_set_flex_grow(body, 1); - lv_obj_set_flex_flow(body, LV_FLEX_FLOW_ROW); - lv_obj_set_flex_align(body, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START); - lv_obj_set_style_bg_opa(body, LV_OPA_TRANSP, 0); - lv_obj_set_style_border_width(body, 0, 0); - lv_obj_set_style_pad_all(body, 0, 0); - lv_obj_set_style_pad_gap(body, 0, 0); - lv_obj_set_scrollable(body, false); - - // -- LEFT: canvas area (flex_grow, LV_PCT(100) height fills body) ---- - { - lv_obj_t* canvasArea = mcContainer(body); - lv_obj_set_flex_grow(canvasArea, 1); - lv_obj_set_height(canvasArea, LV_PCT(100)); - lv_obj_set_style_bg_color(canvasArea, lv_color_hex(0x040610), 0); - lv_obj_set_style_bg_opa(canvasArea, LV_OPA_COVER, 0); - lv_obj_set_style_border_width(canvasArea, 0, 0); - lv_obj_set_style_pad_all(canvasArea, 0, 0); - lv_obj_set_scrollable(canvasArea, false); - lv_obj_set_layout(canvasArea, LV_LAYOUT_NONE); // children use absolute lv_obj_set_pos() - es.canvas = canvasArea; - // Estimates for first draw before layout settles - es.canvasW = lv_display_get_horizontal_resolution(nullptr) - COL_W; - es.canvasH = lv_display_get_vertical_resolution(nullptr) - CHROME_H - ROW_H; - } - - // -- RIGHT: controls column (500px wide) ------------------------------ - { - - lv_obj_t* rightCol = mcContainer(body); - lv_obj_set_size(rightCol, COL_W, LV_PCT(100)); - lv_obj_set_flex_flow(rightCol, LV_FLEX_FLOW_COLUMN); - lv_obj_set_flex_align(rightCol, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START); - lv_obj_set_style_bg_color(rightCol, MC_PANEL(), 0); - lv_obj_set_style_bg_opa(rightCol, LV_OPA_COVER, 0); - lv_obj_set_style_border_width(rightCol, 0, 0); - lv_obj_set_style_pad_all(rightCol, PAD, 0); - lv_obj_set_style_pad_gap(rightCol, PAD, 0); - lv_obj_set_scrollable(rightCol, false); - - auto makeRow = [&](int h) -> lv_obj_t* { - lv_obj_t* row = mcContainer(rightCol); - lv_obj_set_size(row, LV_PCT(100), h); - lv_obj_set_flex_flow(row, LV_FLEX_FLOW_ROW); - lv_obj_set_flex_align(row, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); - lv_obj_set_style_bg_opa(row, LV_OPA_TRANSP, 0); - lv_obj_set_style_border_width(row, 0, 0); - lv_obj_set_style_pad_all(row, 0, 0); - lv_obj_set_style_pad_gap(row, PAD, 0); - lv_obj_set_scrollable(row, false); - return row; - }; - - // -- Voice tabs (8 equal, full width) ---------------------------- - { - lv_obj_t* row = makeRow(ROW_H); - es.voiceTabRow = row; - int tabW = (COL_W - PAD * 2 - PAD * 7) / 8; - for (int i = 0; i < 8; i++) { - lv_obj_t* tab = mcButton(row); - lv_obj_set_size(tab, tabW, BTN_H); - lv_obj_set_user_data(tab, mc); - lv_obj_add_event_cb(tab, cbEuclidVoiceTab, LV_EVENT_CLICKED, (void*)(intptr_t)i); - lv_obj_set_style_bg_color(tab, lv_color_hex(VOICE_COLORS[i]), 0); - lv_obj_set_style_bg_opa(tab, (i == es.selectedVoice) ? LV_OPA_COVER : LV_OPA_30, 0); - lv_obj_t* lbl = lv_label_create(tab); - char vbuf[4]; snprintf(vbuf, sizeof(vbuf), "%d", i + 1); - lv_label_set_text(lbl, vbuf); - lv_obj_set_style_text_font(lbl, fontSmall, 0); - lv_obj_center(lbl); - } - } - - // -- Voice name (large, colored) ---------------------------------- - { - lv_obj_t* row = makeRow(ROW_H); - char vbuf[24]; snprintf(vbuf, sizeof(vbuf), "Voice %d", es.selectedVoice + 1); - es.voiceLbl = makeLbl(row, vbuf, COL_W - PAD * 2, - lv_color_hex(VOICE_COLORS[es.selectedVoice]), fontLarge); - } - - // Helper: param row with colored header + < value > buttons - // flex_grow=1 so all 4 rows share remaining space equally - auto makeParamRow = [&](const char* name, lv_color_t col) -> lv_obj_t* { - lv_obj_t* row = mcContainer(rightCol); - lv_obj_set_width(row, LV_PCT(100)); - lv_obj_set_flex_grow(row, 1); - lv_obj_set_flex_flow(row, LV_FLEX_FLOW_ROW); - lv_obj_set_flex_align(row, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); - lv_obj_set_style_bg_color(row, lv_color_hex(0x0C0E1A), 0); - lv_obj_set_style_bg_opa(row, LV_OPA_COVER, 0); - lv_obj_set_style_border_width(row, 1, 0); - lv_obj_set_style_border_color(row, lv_color_hex(0x1A2030), 0); - lv_obj_set_style_pad_all(row, PAD, 0); - lv_obj_set_style_pad_gap(row, PAD, 0); - lv_obj_set_scrollable(row, false); - lv_obj_t* hdr = lv_label_create(row); - lv_label_set_text(hdr, name); - lv_obj_set_style_text_font(hdr, fontSmall, 0); - lv_obj_set_style_text_color(hdr, col, 0); - lv_obj_set_width(hdr, 120); - return row; - }; - - // -- Steps -------------------------------------------------------- - { - lv_obj_t* row = makeParamRow("Steps", lv_color_hex(0xFFCC88)); - makeBtn(row, "<", BTN_W_SM, cbEuclidStepsDown, mc); - char sBuf[8]; snprintf(sBuf, sizeof(sBuf), "S:%d", es.voice[es.selectedVoice].steps); - es.stepsLbl = makeLbl(row, sBuf, LBL_W, lv_color_hex(0xFFCC88)); - makeBtn(row, ">", BTN_W_SM, cbEuclidStepsUp, mc); - } - - // -- Events ------------------------------------------------------- - { - lv_obj_t* row = makeParamRow("Events", lv_color_hex(0x88DDFF)); - makeBtn(row, "<", BTN_W_SM, cbEuclidEvtsDown, mc); - char eBuf[8]; snprintf(eBuf, sizeof(eBuf), "E:%d", es.voice[es.selectedVoice].events); - es.eventsLbl = makeLbl(row, eBuf, LBL_W, lv_color_hex(0x88DDFF)); - makeBtn(row, ">", BTN_W_SM, cbEuclidEvtsUp, mc); - } - - // -- Rotation ----------------------------------------------------- - { - lv_obj_t* row = makeParamRow("Rotation", lv_color_hex(0xAAFF88)); - makeBtn(row, "<", BTN_W_SM, cbEuclidRotDown, mc); - char rBuf[8]; snprintf(rBuf, sizeof(rBuf), "R:%d", es.voice[es.selectedVoice].rotation); - es.rotLbl = makeLbl(row, rBuf, LBL_W, lv_color_hex(0xAAFF88)); - makeBtn(row, ">", BTN_W_SM, cbEuclidRotUp, mc); - } - - // -- Note --------------------------------------------------------- - { - lv_obj_t* row = makeParamRow("Note", lv_color_hex(0xFF88CC)); - makeBtn(row, "<", BTN_W_SM, cbEuclidNoteDown, mc); - char nBuf[8]; midiNoteName(es.voice[es.selectedVoice].note, nBuf, sizeof(nBuf)); - es.noteLbl = makeLbl(row, nBuf, LBL_W, lv_color_hex(0xFF88CC)); - makeBtn(row, ">", BTN_W_SM, cbEuclidNoteUp, mc); - } - } - } - - // -- Redraw when canvas gets its real size (first layout pass) ------------ - // At build time LV_PCT(100) hasn't resolved yet, so estimates are used. - // LV_EVENT_SIZE_CHANGED fires once the layout pass completes. - lv_obj_add_event_cb(es.canvas, [](lv_event_t* ev) { - auto* mc2 = static_cast(lv_event_get_user_data(ev)); - if (!mc2) return; - auto& es2 = mc2->euclid_; - int w = lv_obj_get_width(es2.canvas); - int h = lv_obj_get_height(es2.canvas); - if (w > 4 && h > 4) { - es2.canvasW = w; - es2.canvasH = h; - euclidDrawCanvas(es2); - } - }, LV_EVENT_SIZE_CHANGED, mc); - - // -- Start timer ---------------------------------------------------------- - mc->euclidTimer_ = lv_timer_create(cbEuclidTimer, 10, mc); - es.nextStepMs = lv_tick_get(); -} diff --git a/Apps/MidiController/main/Source/ViewGrid.cpp b/Apps/MidiController/main/Source/ViewGrid.cpp deleted file mode 100644 index 219aa63..0000000 --- a/Apps/MidiController/main/Source/ViewGrid.cpp +++ /dev/null @@ -1,289 +0,0 @@ -#include "MidiController.h" - -#include -#include -#include -#include -#include - -// --------------------------------------------------------------------------- -// GRID view - Isomorphic 8×5 Linnstrument-style grid -// -// Layout: perfect-4ths tuning - each row is 5 semitones higher, each column +1. -// Base note = (octave + 1) * 12 - at oct=3: baseNote=48 (C3). -// --------------------------------------------------------------------------- - -static inline lv_coord_t dispW() { - return (lv_coord_t)lv_display_get_horizontal_resolution(nullptr); -} -// Whether a semitone class is a "black" key -static bool isBlackKey(int semitone) { - semitone = ((semitone % 12) + 12) % 12; - return (semitone == 1 || semitone == 3 || semitone == 6 || semitone == 8 || semitone == 10); -} - -static lv_color_t cellTextColor(int note) { - int sc12 = note % 12; - if (isBlackKey(sc12)) return lv_color_hex(0x808080); // dark cell - light text - return lv_color_hex(0x000000); // C notes and white keys - dark text -} - -static int gridNote(int row, int col, int baseNote) { - // Row 0 = lowest, row 4 = highest (display top = row 4) - int note = baseNote + (row * 5) + col; - return std::max(0, std::min(127, note)); -} - -static bool isNotePressed(const MidiController::GridState& gs, int note) { - for (int slot = 0; slot < LVGL_POINTER_MAX_SLOTS; slot++) { - if (gs.pressedNote[slot] == note) return true; - } - return false; -} - -static void refreshGridColors(MidiController* mc) { - if (!mc) return; - auto& gs = mc->grid_; - int baseNote = (gs.octave + 1) * 12; - for (int r = 0; r < 5; r++) { - for (int c = 0; c < 8; c++) { - if (!gs.cell[r][c]) continue; - int note = gridNote(r, c, baseNote); - int sc12 = note % 12; - lv_color_t cellColor; - if (isNotePressed(gs, note)) { - cellColor = lv_color_hex(0x00DFFF); // pressed - } else if (sc12 == 0) { - cellColor = lv_color_hex(0x00FFCC); // C notes - } else if (isBlackKey(sc12)) { - cellColor = lv_color_hex(0x1A2A40); // black key - } else { - cellColor = lv_color_hex(0x2A3A50); // white key - } - lv_obj_set_style_bg_color(gs.cell[r][c], cellColor, LV_PART_MAIN); - lv_obj_set_user_data(gs.cell[r][c], (void*)(intptr_t)note); - // Update note name label (child 0) - lv_obj_t* lbl = lv_obj_get_child(gs.cell[r][c], 0); - if (lbl) { - char buf[8]; midiNoteName(note, buf, sizeof(buf)); - lv_label_set_text(lbl, buf); - lv_obj_set_style_text_color(lbl, cellTextColor(note), 0); - } - } - } -} - -static void refreshOctLabel(MidiController* mc) { - if (!mc->grid_.octLbl) return; - char buf[10]; snprintf(buf, sizeof(buf), "Oct %d", mc->grid_.octave); - lv_label_set_text(mc->grid_.octLbl, buf); -} - -// --------------------------------------------------------------------------- -// Callbacks -// --------------------------------------------------------------------------- - -static void cbBack(lv_event_t* e) { - auto* mc = static_cast(lv_event_get_user_data(e)); - if (mc) mc->switchToMode(MidiMode::HOME); -} - -static void cbCellPressed(lv_event_t* e) { - auto* mc = static_cast(lv_event_get_user_data(e)); - if (!mc) return; - auto& gs = mc->grid_; - - lv_indev_t* indev = lv_event_get_indev(e); - if (!indev) return; - int8_t slotIdx = lvgl_pointer_get_slot_index(indev); - if (slotIdx < 0) return; - - lv_obj_t* btn = lv_event_get_target_obj(e); - int note = (int)(intptr_t)lv_obj_get_user_data(btn); - if (gs.pressedNote[slotIdx] >= 0) mc->sendMidi(0x80, (uint8_t)gs.pressedNote[slotIdx], 0); - gs.pressedNote[slotIdx] = note; - mc->sendMidi(0x90, (uint8_t)note, 100); - refreshGridColors(mc); - if (mc->grid_.noteLbl) { - char buf[8]; midiNoteName(note, buf, sizeof(buf)); - lv_label_set_text(mc->grid_.noteLbl, buf); - } -} - -static void cbCellReleased(lv_event_t* e) { - auto* mc = static_cast(lv_event_get_user_data(e)); - if (!mc) return; - auto& gs = mc->grid_; - - lv_indev_t* indev = lv_event_get_indev(e); - if (!indev) return; - int8_t slotIdx = lvgl_pointer_get_slot_index(indev); - if (slotIdx < 0) return; - - lv_obj_t* btn = lv_event_get_target_obj(e); - int note = (int)(intptr_t)lv_obj_get_user_data(btn); - if (gs.pressedNote[slotIdx] == note) { - mc->sendMidi(0x80, (uint8_t)note, 0); - gs.pressedNote[slotIdx] = -1; - refreshGridColors(mc); - if (mc->grid_.noteLbl) lv_label_set_text(mc->grid_.noteLbl, ""); - } -} - -static void releaseAllGridNotes(MidiController* mc) { - auto& gs = mc->grid_; - for (int slot = 0; slot < LVGL_POINTER_MAX_SLOTS; slot++) { - if (gs.pressedNote[slot] >= 0) { - mc->sendMidi(0x80, (uint8_t)gs.pressedNote[slot], 0); - gs.pressedNote[slot] = -1; - } - } - if (mc->grid_.noteLbl) lv_label_set_text(mc->grid_.noteLbl, ""); -} - -static void cbOctDown(lv_event_t* e) { - auto* mc = static_cast(lv_event_get_user_data(e)); - if (!mc) return; - if (mc->grid_.octave > 1) { - releaseAllGridNotes(mc); - mc->grid_.octave--; - refreshOctLabel(mc); - refreshGridColors(mc); - } -} - -static void cbOctUp(lv_event_t* e) { - auto* mc = static_cast(lv_event_get_user_data(e)); - if (!mc) return; - if (mc->grid_.octave < 6) { - releaseAllGridNotes(mc); - mc->grid_.octave++; - refreshOctLabel(mc); - refreshGridColors(mc); - } -} - -// --------------------------------------------------------------------------- -// Build function -// --------------------------------------------------------------------------- - -void buildGridView(MidiController* mc, lv_obj_t* parent) { - if (!mc) return; - auto& gs = mc->grid_; - for (auto& n : gs.pressedNote) n = -1; // Ensure clean state when entering grid view - - lv_coord_t W = dispW(); - const lv_font_t* fontSmall = lvgl_get_text_font(FONT_SIZE_SMALL); - const lv_font_t* fontNormal = lvgl_get_text_font(FONT_SIZE_DEFAULT); - - int ctrlH = 88; - int btnH = MC_BTN_H; - - lv_obj_t* root = mcContainer(parent); - lv_obj_set_size(root, LV_PCT(100), LV_PCT(100)); - lv_obj_set_flex_flow(root, LV_FLEX_FLOW_COLUMN); - lv_obj_set_style_bg_color(root, lv_color_hex(0x080C12), 0); - lv_obj_set_style_bg_opa(root, LV_OPA_COVER, 0); - lv_obj_set_style_border_width(root, 0, 0); - lv_obj_set_style_pad_all(root, 0, 0); - lv_obj_set_style_pad_gap(root, 0, 0); - lv_obj_set_scrollable(root, false); - mc->currentViewRoot_ = root; - - // -- Control row (TOP) ---------------------------------------------------- - lv_obj_t* ctrl = mcRow(root, ctrlH); - - mcBackBtn(ctrl, cbBack, mc, MC_W_S); - lv_obj_t* titleLbl = lv_label_create(ctrl); - lv_label_set_text(titleLbl, "GRID KEYS"); - lv_obj_set_style_text_font(titleLbl, fontNormal, 0); - lv_obj_set_style_text_color(titleLbl, lv_color_hex(0x44BBFF), 0); - lv_obj_set_flex_grow(titleLbl, 1); - - lv_obj_t* octDown = mcButton(ctrl); - lv_obj_set_size(octDown, 112, btnH); - lv_obj_add_event_cb(octDown, cbOctDown, LV_EVENT_CLICKED, mc); - lv_obj_t* odl = lv_label_create(octDown); lv_label_set_text(odl, "OCT-"); - lv_obj_set_style_text_font(odl, fontSmall, 0); lv_obj_center(odl); - - gs.octLbl = lv_label_create(ctrl); - refreshOctLabel(mc); - lv_obj_set_style_text_font(gs.octLbl, fontSmall, 0); - lv_obj_set_style_text_color(gs.octLbl, lv_color_hex(0x00DFFF), 0); - lv_obj_set_width(gs.octLbl, 176); - lv_obj_set_style_text_align(gs.octLbl, LV_TEXT_ALIGN_CENTER, 0); - - lv_obj_t* octUp = mcButton(ctrl); - lv_obj_set_size(octUp, 112, btnH); - lv_obj_add_event_cb(octUp, cbOctUp, LV_EVENT_CLICKED, mc); - lv_obj_t* oul = lv_label_create(octUp); lv_label_set_text(oul, "OCT+"); - lv_obj_set_style_text_font(oul, fontSmall, 0); lv_obj_center(oul); - - gs.noteLbl = lv_label_create(ctrl); - lv_label_set_text(gs.noteLbl, ""); - lv_obj_set_style_text_font(gs.noteLbl, fontSmall, 0); - lv_obj_set_style_text_color(gs.noteLbl, lv_color_hex(0x00DFFF), 0); - lv_obj_set_flex_grow(gs.noteLbl, 1); - lv_obj_set_style_text_align(gs.noteLbl, LV_TEXT_ALIGN_RIGHT, 0); - - // -- Grid (5 rows × 8 cols, fills available space) ----------------------- - lv_obj_t* gridArea = mcContainer(root); - lv_obj_set_width(gridArea, LV_PCT(100)); - lv_obj_set_flex_grow(gridArea, 1); - lv_obj_set_style_bg_color(gridArea, lv_color_hex(0x050508), 0); - lv_obj_set_style_bg_opa(gridArea, LV_OPA_COVER, 0); - lv_obj_set_style_border_width(gridArea, 0, 0); - lv_obj_set_style_pad_top(gridArea, 8, 0); - lv_obj_set_style_pad_bottom(gridArea, 16, 0); - lv_obj_set_style_pad_left(gridArea, 16, 0); - lv_obj_set_style_pad_right(gridArea, 16, 0); - lv_obj_set_style_pad_gap(gridArea, 4, 0); - lv_obj_set_scrollable(gridArea, false); - lv_obj_set_flex_flow(gridArea, LV_FLEX_FLOW_COLUMN); - - int cellW = ((int)W - 16 * 2) / 8; - - // Build rows top to bottom: row 4 (highest) at top, row 0 at bottom - for (int r = 4; r >= 0; r--) { - lv_obj_t* rowObj = mcContainer(gridArea); - lv_obj_set_width(rowObj, LV_PCT(100)); - lv_obj_set_flex_grow(rowObj, 1); - lv_obj_set_flex_flow(rowObj, LV_FLEX_FLOW_ROW); - lv_obj_set_flex_align(rowObj, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); - lv_obj_set_style_bg_opa(rowObj, LV_OPA_TRANSP, 0); - lv_obj_set_style_border_width(rowObj, 0, 0); - lv_obj_set_style_pad_all(rowObj, 0, 0); - lv_obj_set_style_pad_gap(rowObj, 4, 0); - lv_obj_set_scrollable(rowObj, false); - - for (int c = 0; c < 8; c++) { - lv_obj_t* btn = mcButton(rowObj); - lv_obj_set_size(btn, cellW - 4, LV_PCT(100)); - lv_obj_set_style_bg_color(btn, lv_color_hex(0x2A3A50), 0); - lv_obj_set_style_bg_opa(btn, LV_OPA_COVER, 0); - lv_obj_set_style_radius(btn, 8, 0); - lv_obj_set_style_pad_all(btn, 0, 0); - lv_obj_set_style_border_width(btn, 0, 0); - lv_obj_add_event_cb(btn, cbCellPressed, LV_EVENT_PRESSED, mc); - lv_obj_add_event_cb(btn, cbCellReleased, LV_EVENT_RELEASED, mc); - - // Note label (small, aligned center) - int note = gridNote(r, c, (gs.octave + 1) * 12); - lv_obj_set_user_data(btn, (void*)(intptr_t)note); - { - char buf[8]; midiNoteName(note, buf, sizeof(buf)); - lv_obj_t* lbl = lv_label_create(btn); - lv_label_set_text(lbl, buf); - lv_obj_set_style_text_font(lbl, fontSmall, 0); - lv_obj_set_style_text_color(lbl, cellTextColor(note), 0); - lv_obj_align(lbl, LV_ALIGN_CENTER, 0, 0); - lv_obj_set_clickable(lbl, false); - } - - gs.cell[r][c] = btn; - } - } - - // Apply initial colors - refreshGridColors(mc); -} diff --git a/Apps/MidiController/main/Source/ViewKeys.cpp b/Apps/MidiController/main/Source/ViewKeys.cpp deleted file mode 100644 index 1846c8e..0000000 --- a/Apps/MidiController/main/Source/ViewKeys.cpp +++ /dev/null @@ -1,427 +0,0 @@ -#include "MidiController.h" - -#include -#include -#include -#include -#include - -// --------------------------------------------------------------------------- -// KEYS view - single-octave chromatic keyboard -// -// Layout (flex column): -// [control row: < | OCT- | octave | OCT+ | SCALE | KEY- | key | KEY+] -// [piano area: 7 white keys with 5 black keys overlaid] -// [status label] -// --------------------------------------------------------------------------- - -static constexpr int WHITE_OFF[7] = { 0, 2, 4, 5, 7, 9, 11 }; -static constexpr int BLACK_OFF[5] = { 1, 3, 6, 8, 10 }; -// Center X of each black key as fraction of white-key-width × 100 -static constexpr int BLACK_FRAC[5] = { 65, 165, 365, 465, 565 }; - -static const char* WHITE_NAME[7] = { "C","D","E","F","G","A","B" }; - -// Key colors by role -static lv_color_t keyColorWhiteDefault() { return lv_color_hex(0xE8E8D4); } -static lv_color_t keyColorBlackDefault() { return lv_color_hex(0x111111); } - -static inline lv_coord_t dispW() { - return (lv_coord_t)lv_display_get_horizontal_resolution(nullptr); -} - -static void resetKeyColors(MidiController* mc) { - auto& ks = mc->keys_; - - for (int i = 0; i < 7; i++) { - if (!ks.whiteKey[i]) continue; - lv_obj_set_style_bg_color(ks.whiteKey[i], keyColorWhiteDefault(), LV_PART_MAIN); - } - - for (int i = 0; i < 5; i++) { - if (!ks.blackKey[i]) continue; - lv_obj_set_style_bg_color(ks.blackKey[i], keyColorBlackDefault(), LV_PART_MAIN); - } -} - -// Rebuild note assignments after an octave change, and refresh the scale/key labels -static void rebuildKeyNotes(MidiController* mc) { - auto& ks = mc->keys_; - - // White keys - for (int k = 0; k < 7; k++) { - if (!ks.whiteKey[k]) continue; - int note = std::max(0, std::min(127, (ks.octave + 1) * 12 + WHITE_OFF[k])); - lv_obj_set_user_data(ks.whiteKey[k], (void*)(intptr_t)note); - - // Update C note label - if (k == 0) { - lv_obj_t* lbl = lv_obj_get_child(ks.whiteKey[k], 0); - if (lbl) { - char buf[16]; snprintf(buf, sizeof(buf), "C%d", ks.octave); - lv_label_set_text(lbl, buf); - } - } - } - - // Black keys - for (int k = 0; k < 5; k++) { - if (!ks.blackKey[k]) continue; - int note = std::max(0, std::min(127, (ks.octave + 1) * 12 + BLACK_OFF[k])); - lv_obj_set_user_data(ks.blackKey[k], (void*)(intptr_t)note); - } - - // Update status label - if (ks.statusLbl) { - char buf[48]; - snprintf(buf, sizeof(buf), "Oct %d | %s | Key %s", - ks.octave, - MIDI_SCALES[ks.scale % NUM_MIDI_SCALES].name, - KEY_NAMES[ks.key % 12]); - lv_label_set_text(ks.statusLbl, buf); - } - if (ks.octLbl) { - char buf[16]; snprintf(buf, sizeof(buf), "Oct %d", ks.octave); - lv_label_set_text(ks.octLbl, buf); - } - if (ks.scaleLbl) { - lv_label_set_text(ks.scaleLbl, MIDI_SCALES[ks.scale % NUM_MIDI_SCALES].name); - } - if (ks.keyLbl) { - lv_label_set_text(ks.keyLbl, KEY_NAMES[ks.key % 12]); - } - - resetKeyColors(mc); -} - -// --------------------------------------------------------------------------- -// Touch handling - drag across keys (pressing on pianoArea) -// --------------------------------------------------------------------------- - -static void cbPianoPressing(lv_event_t* e) { - auto* mc = static_cast(lv_event_get_user_data(e)); - if (!mc) return; - auto& ks = mc->keys_; - - // lv_event_get_indev() (the indev that actually fired THIS event), not lv_indev_active() - - // the latter is a process-global "whichever indev is being processed right now" and would - // make every finger's hit-test collapse onto whichever touch happened to be read last, once - // multiple simultaneous touch slots exist (see lvgl-module's pointer pool). slotIdx indexes - // ks.lastNote so each finger tracks its own held note independently. - lv_indev_t* indev = lv_event_get_indev(e); - if (!indev) return; - int8_t slotIdx = lvgl_pointer_get_slot_index(indev); - if (slotIdx < 0) return; - lv_point_t pt; - lv_indev_get_point(indev, &pt); - - if (!ks.pianoArea) return; - lv_area_t rowArea; - lv_obj_get_coords(ks.pianoArea, &rowArea); - if (pt.y < rowArea.y1 || pt.y > rowArea.y2) return; - - // Black keys take priority - check them first - int newNote = -1; - for (int i = 0; i < 5; i++) { - if (!ks.blackKey[i]) continue; - lv_area_t area; - lv_obj_get_coords(ks.blackKey[i], &area); - if (pt.x >= area.x1 && pt.x <= area.x2 && pt.y >= area.y1 && pt.y <= area.y2) { - newNote = (int)(intptr_t)lv_obj_get_user_data(ks.blackKey[i]); - break; - } - } - - // Then white keys - if (newNote < 0) { - for (int i = 0; i < 7; i++) { - if (!ks.whiteKey[i]) continue; - lv_area_t area; - lv_obj_get_coords(ks.whiteKey[i], &area); - if (pt.x >= area.x1 && pt.x <= area.x2) { - newNote = (int)(intptr_t)lv_obj_get_user_data(ks.whiteKey[i]); - break; - } - } - } - - if (newNote < 0) return; - if (newNote == ks.lastNote[slotIdx]) return; - - // Slide: off old note, on new note (only this finger's note - other slots' held notes are - // untouched) - if (ks.lastNote[slotIdx] >= 0) { - mc->sendMidi(0x80, (uint8_t)ks.lastNote[slotIdx], 0); - } - mc->sendMidi(0x90, (uint8_t)newNote, 100); - ks.lastNote[slotIdx] = newNote; -} - -static void cbPianoReleased(lv_event_t* e) { - auto* mc = static_cast(lv_event_get_user_data(e)); - if (!mc) return; - auto& ks = mc->keys_; - - lv_indev_t* indev = lv_event_get_indev(e); - if (!indev) return; - int8_t slotIdx = lvgl_pointer_get_slot_index(indev); - if (slotIdx < 0) return; - - if (ks.lastNote[slotIdx] >= 0) { - mc->sendMidi(0x80, (uint8_t)ks.lastNote[slotIdx], 0); - ks.lastNote[slotIdx] = -1; - } -} - -// --------------------------------------------------------------------------- -// Control callbacks -// --------------------------------------------------------------------------- - -static void cbOctDown(lv_event_t* e) { - auto* mc = static_cast(lv_event_get_user_data(e)); - if (!mc) return; - if (mc->keys_.octave > 1) { - mc->keys_.octave--; - rebuildKeyNotes(mc); - } -} - -static void cbOctUp(lv_event_t* e) { - auto* mc = static_cast(lv_event_get_user_data(e)); - if (!mc) return; - if (mc->keys_.octave < 8) { - mc->keys_.octave++; - rebuildKeyNotes(mc); - } -} - -static void cbScaleCycle(lv_event_t* e) { - auto* mc = static_cast(lv_event_get_user_data(e)); - if (!mc) return; - mc->keys_.scale = (mc->keys_.scale + 1) % NUM_MIDI_SCALES; - rebuildKeyNotes(mc); -} - -static void cbKeyDown(lv_event_t* e) { - auto* mc = static_cast(lv_event_get_user_data(e)); - if (!mc) return; - mc->keys_.key = (mc->keys_.key + 11) % 12; - rebuildKeyNotes(mc); -} - -static void cbKeyUp(lv_event_t* e) { - auto* mc = static_cast(lv_event_get_user_data(e)); - if (!mc) return; - mc->keys_.key = (mc->keys_.key + 1) % 12; - rebuildKeyNotes(mc); -} - -static void cbBack(lv_event_t* e) { - auto* mc = static_cast(lv_event_get_user_data(e)); - if (mc) mc->switchToMode(MidiMode::HOME); -} - -// --------------------------------------------------------------------------- -// Build function -// --------------------------------------------------------------------------- - -void buildKeysView(MidiController* mc, lv_obj_t* parent) { - auto& ks = mc->keys_; - // A note held when the view was left is already silenced by the mode-switch - // All Notes Off; clear the per-slot state so the next tap always sends Note On. - for (auto& n : ks.lastNote) n = -1; - - lv_coord_t W = dispW(); - const lv_font_t* fontSmall = lvgl_get_text_font(FONT_SIZE_SMALL); - const lv_font_t* fontNormal = lvgl_get_text_font(FONT_SIZE_DEFAULT); - - int rowH = 88; // single combined control row height - int statusH = 56; - - // Root container - lv_obj_t* root = mcContainer(parent); - lv_obj_set_size(root, LV_PCT(100), LV_PCT(100)); - lv_obj_set_flex_flow(root, LV_FLEX_FLOW_COLUMN); - lv_obj_set_style_bg_color(root, lv_color_hex(0x080C12), 0); - lv_obj_set_style_bg_opa(root, LV_OPA_COVER, 0); - lv_obj_set_style_border_width(root, 0, 0); - lv_obj_set_style_pad_all(root, 0, 0); - lv_obj_set_style_pad_gap(root, 0, 0); - lv_obj_set_scrollable(root, false); - mc->currentViewRoot_ = root; - - // -- Single control row: [<] [OCT-] [OCT] [OCT+] | [SCALE] [KEY-] [KEY] [KEY+] -- - lv_obj_t* ctrl = mcRow(root, rowH); - - int btnH = MC_BTN_H; - - // Back button - mcBackBtn(ctrl, cbBack, mc, MC_W_S); - - // OCT - - lv_obj_t* octDownBtn = mcButton(ctrl); - lv_obj_set_size(octDownBtn, 144, btnH); - lv_obj_add_event_cb(octDownBtn, cbOctDown, LV_EVENT_CLICKED, mc); - lv_obj_t* odLbl = lv_label_create(octDownBtn); - lv_label_set_text(odLbl, "OCT-"); - lv_obj_set_style_text_font(odLbl, fontSmall, 0); - lv_obj_center(odLbl); - - // OCT label - ks.octLbl = lv_label_create(ctrl); - char obuf[16]; snprintf(obuf, sizeof(obuf), "Oct %d", ks.octave); - lv_label_set_text(ks.octLbl, obuf); - lv_obj_set_style_text_font(ks.octLbl, fontSmall, 0); - lv_obj_set_style_text_color(ks.octLbl, lv_color_hex(0x00DFFF), 0); - lv_obj_set_width(ks.octLbl, 144); - lv_obj_set_style_text_align(ks.octLbl, LV_TEXT_ALIGN_CENTER, 0); - - // OCT + - lv_obj_t* octUpBtn = mcButton(ctrl); - lv_obj_set_size(octUpBtn, 144, btnH); - lv_obj_add_event_cb(octUpBtn, cbOctUp, LV_EVENT_CLICKED, mc); - lv_obj_t* ouLbl = lv_label_create(octUpBtn); - lv_label_set_text(ouLbl, "OCT+"); - lv_obj_set_style_text_font(ouLbl, fontSmall, 0); - lv_obj_center(ouLbl); - - // Divider label - lv_obj_t* div = lv_label_create(ctrl); - lv_label_set_text(div, "|"); - lv_obj_set_style_text_color(div, lv_color_hex(0x334455), 0); - lv_obj_set_style_text_font(div, fontSmall, 0); - - // Scale cycle button - lv_obj_t* scaleBtn = mcButton(ctrl); - lv_obj_set_size(scaleBtn, 224, btnH); - lv_obj_add_event_cb(scaleBtn, cbScaleCycle, LV_EVENT_CLICKED, mc); - ks.scaleLbl = lv_label_create(scaleBtn); - lv_label_set_text(ks.scaleLbl, MIDI_SCALES[ks.scale % NUM_MIDI_SCALES].name); - lv_obj_set_style_text_font(ks.scaleLbl, fontSmall, 0); - lv_obj_center(ks.scaleLbl); - - // KEY - - lv_obj_t* keyDownBtn = mcButton(ctrl); - lv_obj_set_size(keyDownBtn, 112, btnH); - lv_obj_add_event_cb(keyDownBtn, cbKeyDown, LV_EVENT_CLICKED, mc); - lv_obj_t* kdLbl = lv_label_create(keyDownBtn); - lv_label_set_text(kdLbl, "KEY-"); - lv_obj_set_style_text_font(kdLbl, fontSmall, 0); - lv_obj_center(kdLbl); - - // Key label - ks.keyLbl = lv_label_create(ctrl); - lv_label_set_text(ks.keyLbl, KEY_NAMES[ks.key % 12]); - lv_obj_set_style_text_font(ks.keyLbl, fontNormal, 0); - lv_obj_set_style_text_color(ks.keyLbl, MC_VALUE(), 0); - lv_obj_set_width(ks.keyLbl, 96); - lv_obj_set_style_text_align(ks.keyLbl, LV_TEXT_ALIGN_CENTER, 0); - - // KEY + - lv_obj_t* keyUpBtn = mcButton(ctrl); - lv_obj_set_size(keyUpBtn, 112, btnH); - lv_obj_add_event_cb(keyUpBtn, cbKeyUp, LV_EVENT_CLICKED, mc); - lv_obj_t* kuLbl = lv_label_create(keyUpBtn); - lv_label_set_text(kuLbl, "KEY+"); - lv_obj_set_style_text_font(kuLbl, fontSmall, 0); - lv_obj_center(kuLbl); - - // -- Piano area (single row/octave, flex_grow fills remaining height) ----- - lv_obj_t* pianoArea = mcContainer(root); - lv_obj_set_width(pianoArea, LV_PCT(100)); - lv_obj_set_flex_grow(pianoArea, 1); - lv_obj_set_style_bg_color(pianoArea, lv_color_hex(0x050508), 0); - lv_obj_set_style_bg_opa(pianoArea, LV_OPA_COVER, 0); - lv_obj_set_style_border_width(pianoArea, 0, 0); - lv_obj_set_style_radius(pianoArea, 0, 0); - lv_obj_set_style_pad_all(pianoArea, 0, 0); - lv_obj_set_scrollable(pianoArea, false); - - lv_obj_add_event_cb(pianoArea, cbPianoPressing, LV_EVENT_PRESSING, mc); - lv_obj_add_event_cb(pianoArea, cbPianoReleased, LV_EVENT_RELEASED, mc); - - ks.pianoArea = pianoArea; - - int wkGap = 2; // seam between white keys (dark bg shows through) - int wkw = W / 7; // white key cell width - int bkw = (wkw * 3) / 5; // black key width (~60%) - - // White keys (7), full height, thin dark seam between each - no border stroke - for (int k = 0; k < 7; k++) { - int midiNote = std::max(0, std::min(127, (ks.octave + 1) * 12 + WHITE_OFF[k])); - - lv_obj_t* btn = mcButton(pianoArea); - lv_obj_set_pos(btn, k * wkw, 0); - lv_obj_set_size(btn, wkw - wkGap, LV_PCT(100)); - lv_obj_set_style_bg_color(btn, keyColorWhiteDefault(), 0); - lv_obj_set_style_bg_opa(btn, LV_OPA_COVER, 0); - lv_obj_set_style_border_width(btn, 0, 0); - lv_obj_set_style_radius(btn, 0, 0); - lv_obj_set_style_pad_all(btn, 0, 0); - lv_obj_set_style_shadow_width(btn, 0, 0); - // Without this, LVGL locks the press onto the first key touched and never - // re-detects the object under the finger while sliding (see lv_indev.c - // indev_proc_press: press-locked objects skip re-search) - breaks piano slide. - lv_obj_set_press_lock(btn, false); - lv_obj_set_user_data(btn, (void*)(intptr_t)midiNote); - // Note On/Off via the row container's PRESSING/RELEASED handlers - // but also handle individual taps via PRESSED/RELEASED on each key - lv_obj_add_event_cb(btn, cbPianoPressing, LV_EVENT_PRESSED, mc); - lv_obj_add_event_cb(btn, cbPianoReleased, LV_EVENT_RELEASED, mc); - - // Note label (C note shows octave, others show letter) - lv_obj_t* lbl = lv_label_create(btn); - if (k == 0) { - char buf[16]; snprintf(buf, sizeof(buf), "C%d", ks.octave); - lv_label_set_text(lbl, buf); - } else { - lv_label_set_text(lbl, WHITE_NAME[k]); - } - lv_obj_set_style_text_font(lbl, fontSmall, 0); - lv_obj_set_style_text_color(lbl, lv_color_hex(0x555566), 0); - lv_obj_align(lbl, LV_ALIGN_BOTTOM_MID, 0, -6); - - ks.whiteKey[k] = btn; - } - - // Black keys (5), shorter, straddling the seam between white keys, on top - for (int k = 0; k < 5; k++) { - int midiNote = std::max(0, std::min(127, (ks.octave + 1) * 12 + BLACK_OFF[k])); - int centerX = (BLACK_FRAC[k] * wkw) / 100; - int leftX = centerX - bkw / 2; - - lv_obj_t* btn = mcButton(pianoArea); - lv_obj_set_pos(btn, leftX, 0); - lv_obj_set_size(btn, bkw, LV_PCT(60)); - lv_obj_set_style_bg_color(btn, keyColorBlackDefault(), 0); - lv_obj_set_style_bg_opa(btn, LV_OPA_COVER, 0); - lv_obj_set_style_border_width(btn, 0, 0); - lv_obj_set_style_radius(btn, 0, 0); - lv_obj_set_style_pad_all(btn, 0, 0); - lv_obj_set_style_shadow_width(btn, 0, 0); - lv_obj_set_press_lock(btn, false); - lv_obj_set_user_data(btn, (void*)(intptr_t)midiNote); - lv_obj_add_event_cb(btn, cbPianoPressing, LV_EVENT_PRESSED, mc); - lv_obj_add_event_cb(btn, cbPianoReleased, LV_EVENT_RELEASED, mc); - lv_obj_move_foreground(btn); // render on top of white keys - - ks.blackKey[k] = btn; - } - - // -- Status bar ------------------------------------------------------------ - lv_obj_t* statusBar = mcContainer(root); - lv_obj_set_size(statusBar, LV_PCT(100), statusH); - lv_obj_set_style_bg_color(statusBar, lv_color_hex(0x0A0C14), 0); - lv_obj_set_style_bg_opa(statusBar, LV_OPA_COVER, 0); - lv_obj_set_style_border_width(statusBar, 0, 0); - lv_obj_set_style_pad_all(statusBar, 8, 0); - lv_obj_set_scrollable(statusBar, false); - - ks.statusLbl = lv_label_create(statusBar); - lv_obj_set_style_text_font(ks.statusLbl, fontSmall, 0); - lv_obj_set_style_text_color(ks.statusLbl, lv_color_hex(0x666688), 0); - lv_obj_center(ks.statusLbl); - - // Apply initial labels and default key colours - rebuildKeyNotes(mc); -} diff --git a/Apps/MidiController/main/Source/ViewLfo.cpp b/Apps/MidiController/main/Source/ViewLfo.cpp deleted file mode 100644 index 28893b8..0000000 --- a/Apps/MidiController/main/Source/ViewLfo.cpp +++ /dev/null @@ -1,449 +0,0 @@ -#include "MidiController.h" - -#include -#include -#include -#include -#include - -// --------------------------------------------------------------------------- -// LFO view - Low-frequency oscillator for CC/Pitch modulation -// -// Layout (flex column): -// [row 1: < | LFO MOD | status dot] -// [row 2: START/STOP | Rate -/val/Hz/+ | WAVE] -// [row 3: Amt -/val/+ | Tgt CC -/val/+ | PITCH] -// [waveform area (LV_EVENT_DRAW_MAIN)] -// [value readout (flex_grow)] -// --------------------------------------------------------------------------- - -static const char* WAVE_NAMES[] = { "SINE", "TRI", "SQR", "SAW" }; - - -// --------------------------------------------------------------------------- -// Waveform computation -// --------------------------------------------------------------------------- - -static float computeWave(int waveform, float phase) { - switch (waveform) { - case 0: return sinf(phase * (float)(2.0 * M_PI)); - case 1: return (phase < 0.5f) ? (phase * 4.0f - 1.0f) : (3.0f - phase * 4.0f); - case 2: return (phase < 0.5f) ? 1.0f : -1.0f; - case 3: return (2.0f * phase) - 1.0f; - default: return 0.0f; - } -} - -static int waveToCCValue(float wave, int amount, int center = 64) { - int val = center + (int)(wave * (float)amount * 0.5f); - return val < 0 ? 0 : (val > 127 ? 127 : val); -} - -// --------------------------------------------------------------------------- -// Waveform draw event -// --------------------------------------------------------------------------- - -static void cbWaveDraw(lv_event_t* e) { - auto* mc = static_cast(lv_event_get_user_data(e)); - if (!mc) return; - auto& lf = mc->lfo_; - - lv_obj_t* obj = lv_event_get_target_obj(e); - lv_layer_t* layer = lv_event_get_layer(e); - - lv_area_t area; - lv_obj_get_coords(obj, &area); - int32_t ox = area.x1, oy = area.y1; - int32_t cW = lv_area_get_width(&area); - int32_t cH = lv_area_get_height(&area); - - lf.canvasW = (int)cW; - lf.canvasH = (int)cH; - - // Background - lv_draw_fill_dsc_t bg; - lv_draw_fill_dsc_init(&bg); - bg.color = lv_color_hex(0x080C12); - bg.opa = LV_OPA_COVER; - lv_draw_fill(layer, &bg, &area); - - if (cW <= 1 || cH <= 1) return; - - lv_draw_line_dsc_t line; - lv_draw_line_dsc_init(&line); - line.opa = LV_OPA_COVER; - - // Center line - line.color = lv_color_hex(0x1A2A40); - line.width = 1; - line.p1.x = (lv_value_precise_t)ox; - line.p1.y = (lv_value_precise_t)(oy + cH / 2); - line.p2.x = (lv_value_precise_t)(ox + cW - 1); - line.p2.y = (lv_value_precise_t)(oy + cH / 2); - lv_draw_line(layer, &line); - - // Waveform - initialize prevY from waveform value at phase 0 (not hardcoded cH/2) - line.color = lv_color_hex(0x00DFFF); - line.width = 1; - float v0 = computeWave(lf.waveform, 0.0f); - int prevY = (int)((1.0f - (v0 + 1.0f) * 0.5f) * (float)(cH - 1)); - prevY = prevY < 0 ? 0 : (prevY >= cH ? cH - 1 : prevY); - for (int x = 1; x < cW; x++) { - float ph = (float)x / (float)cW; - float val = computeWave(lf.waveform, ph); - int y = (int)((1.0f - (val + 1.0f) * 0.5f) * (float)(cH - 1)); - y = y < 0 ? 0 : (y >= cH ? cH - 1 : y); - line.p1.x = (lv_value_precise_t)(ox + x - 1); - line.p1.y = (lv_value_precise_t)(oy + prevY); - line.p2.x = (lv_value_precise_t)(ox + x); - line.p2.y = (lv_value_precise_t)(oy + y); - lv_draw_line(layer, &line); - prevY = y; - } -} - -// --------------------------------------------------------------------------- -// LFO timer -// --------------------------------------------------------------------------- - -static void cbLfoTimer(lv_timer_t* t) { - auto* mc = static_cast(lv_timer_get_user_data(t)); - if (!mc || !mc->lfo_.running) return; - auto& lf = mc->lfo_; - - uint64_t nowUs = (uint64_t)esp_timer_get_time(); - uint64_t deltaUs = nowUs - lf.lastUpdateUs; - lf.lastUpdateUs = nowUs; - - float phaseInc = lf.rate * (float)deltaUs / 1000000.0f; - lf.phase = fmodf(lf.phase + phaseInc, 1.0f); - - float wave = computeWave(lf.waveform, lf.phase); - - if (lf.pitchMode) { - int bend = 8192 + (int)(wave * (float)lf.amount * 64.0f); - bend = bend < 0 ? 0 : (bend > 16383 ? 16383 : bend); - uint8_t lsb = bend & 0x7F; - uint8_t msb = (bend >> 7) & 0x7F; - if (lf.lastValue != bend / 128) { - mc->sendMidi(0xE0, lsb, msb); - lf.lastValue = bend / 128; - } - } else { - int ccVal = waveToCCValue(wave, lf.amount); - if (ccVal != lf.lastValue) { - mc->sendMidi(0xB0, (uint8_t)lf.ccTarget, (uint8_t)ccVal); - lf.lastValue = ccVal; - } - } - - lf.updateCounter++; - if (lf.updateCounter >= 5) { - lf.updateCounter = 0; - if (lf.valueLbl) { - char buf[12]; snprintf(buf, sizeof(buf), "Val: %d", lf.lastValue); - lv_label_set_text(lf.valueLbl, buf); - } - if (lf.statusLbl) { - lv_label_set_text(lf.statusLbl, - lf.pitchMode ? "RUN PITCH" : "RUN CC"); - } - } -} - -// --------------------------------------------------------------------------- -// Control callbacks -// --------------------------------------------------------------------------- - -static void cbBack(lv_event_t* e) { - auto* mc = static_cast(lv_event_get_user_data(e)); - if (mc) mc->switchToMode(MidiMode::HOME); -} - -static void cbStartStop(lv_event_t* e) { - auto* mc = static_cast(lv_event_get_user_data(e)); - if (!mc) return; - auto& lf = mc->lfo_; - lf.running = !lf.running; - lf.lastUpdateUs = (uint64_t)esp_timer_get_time(); - if (lf.running) { - if (!mc->lfoTimer_) { - mc->lfoTimer_ = lv_timer_create(cbLfoTimer, 20, mc); - } - } else { - if (mc->lfoTimer_) { lv_timer_delete(mc->lfoTimer_); mc->lfoTimer_ = nullptr; } - if (lf.pitchMode) mc->sendMidi(0xE0, 0x00, 0x40); - } - if (lf.startBtn) { - lv_obj_set_style_bg_color(lf.startBtn, - lf.running ? lv_color_hex(0xFF2020) : lv_color_hex(0x00AA44), 0); - } - if (lf.startLbl) lv_label_set_text(lf.startLbl, lf.running ? "STOP" : "START"); - if (lf.statusLbl) lv_label_set_text(lf.statusLbl, lf.running ? "RUN" : "IDLE"); -} - -static void cbRateDown(lv_event_t* e) { - auto* mc = static_cast(lv_event_get_user_data(e)); - if (!mc) return; - mc->lfo_.rate -= 0.1f; - if (mc->lfo_.rate < 0.1f) mc->lfo_.rate = 0.1f; - if (mc->lfo_.rateLbl) { - char buf[8]; snprintf(buf, sizeof(buf), "%.1f", mc->lfo_.rate); - lv_label_set_text(mc->lfo_.rateLbl, buf); - } -} -static void cbRateUp(lv_event_t* e) { - auto* mc = static_cast(lv_event_get_user_data(e)); - if (!mc) return; - mc->lfo_.rate += 0.1f; - if (mc->lfo_.rate > 10.0f) mc->lfo_.rate = 10.0f; - if (mc->lfo_.rateLbl) { - char buf[8]; snprintf(buf, sizeof(buf), "%.1f", mc->lfo_.rate); - lv_label_set_text(mc->lfo_.rateLbl, buf); - } -} - -static void cbWaveCycle(lv_event_t* e) { - auto* mc = static_cast(lv_event_get_user_data(e)); - if (!mc) return; - mc->lfo_.waveform = (mc->lfo_.waveform + 1) % 4; - if (mc->lfo_.waveLbl) lv_label_set_text(mc->lfo_.waveLbl, WAVE_NAMES[mc->lfo_.waveform]); - // Invalidate canvas to trigger redraw - if (mc->lfo_.canvas) lv_obj_invalidate(mc->lfo_.canvas); -} - -static void cbAmountDown(lv_event_t* e) { - auto* mc = static_cast(lv_event_get_user_data(e)); - if (!mc) return; - mc->lfo_.amount -= 5; if (mc->lfo_.amount < 0) mc->lfo_.amount = 0; - if (mc->lfo_.amountLbl) { - char buf[10]; snprintf(buf, sizeof(buf), "Amt:%d", mc->lfo_.amount); - lv_label_set_text(mc->lfo_.amountLbl, buf); - } -} -static void cbAmountUp(lv_event_t* e) { - auto* mc = static_cast(lv_event_get_user_data(e)); - if (!mc) return; - mc->lfo_.amount += 5; if (mc->lfo_.amount > 127) mc->lfo_.amount = 127; - if (mc->lfo_.amountLbl) { - char buf[10]; snprintf(buf, sizeof(buf), "Amt:%d", mc->lfo_.amount); - lv_label_set_text(mc->lfo_.amountLbl, buf); - } -} - -static void cbCCDown(lv_event_t* e) { - auto* mc = static_cast(lv_event_get_user_data(e)); - if (!mc || mc->lfo_.pitchMode) return; - mc->lfo_.ccTarget = (mc->lfo_.ccTarget - 1 + 128) % 128; - if (mc->lfo_.ccLbl) { - char buf[8]; snprintf(buf, sizeof(buf), "CC %d", mc->lfo_.ccTarget); - lv_label_set_text(mc->lfo_.ccLbl, buf); - } -} -static void cbCCUp(lv_event_t* e) { - auto* mc = static_cast(lv_event_get_user_data(e)); - if (!mc || mc->lfo_.pitchMode) return; - mc->lfo_.ccTarget = (mc->lfo_.ccTarget + 1) % 128; - if (mc->lfo_.ccLbl) { - char buf[8]; snprintf(buf, sizeof(buf), "CC %d", mc->lfo_.ccTarget); - lv_label_set_text(mc->lfo_.ccLbl, buf); - } -} - -static void cbPitchToggle(lv_event_t* e) { - auto* mc = static_cast(lv_event_get_user_data(e)); - if (!mc) return; - auto& lf = mc->lfo_; - bool wasPitch = lf.pitchMode; - lf.pitchMode = !lf.pitchMode; - // Leaving pitch mode must release the bend, otherwise the receiver stays - // detuned. Force the next CC update as well so the CC target is refreshed. - if (wasPitch) mc->sendMidi(0xE0, 0x00, 0x40); - lf.lastValue = -1; - if (mc->lfo_.ccLbl) { - if (mc->lfo_.pitchMode) { - lv_label_set_text(mc->lfo_.ccLbl, "PITCH"); - lv_obj_set_style_text_color(mc->lfo_.ccLbl, lv_color_hex(0xFF8000), 0); - } else { - char buf[8]; snprintf(buf, sizeof(buf), "CC %d", mc->lfo_.ccTarget); - lv_label_set_text(mc->lfo_.ccLbl, buf); - lv_obj_set_style_text_color(mc->lfo_.ccLbl, lv_color_hex(0x00DFFF), 0); - } - } -} - -// --------------------------------------------------------------------------- -// Teardown -// --------------------------------------------------------------------------- - -static void cbLfoRootDelete(lv_event_t* e) { - auto* mc = static_cast(lv_event_get_user_data(e)); - if (!mc) return; - auto& lf = mc->lfo_; - - // Stop and delete the LFO timer so it cannot fire after widgets are gone - if (mc->lfoTimer_) { - lv_timer_delete(mc->lfoTimer_); - mc->lfoTimer_ = nullptr; - } - // Do not leave a pitch-bend receiver detuned when leaving the view. - if (lf.pitchMode) mc->sendMidi(0xE0, 0x00, 0x40); - lf.running = false; - - // Null all stored LVGL pointers - lf.canvas = nullptr; - lf.statusLbl = nullptr; - lf.rateLbl = nullptr; - lf.amountLbl = nullptr; - lf.ccLbl = nullptr; - lf.waveLbl = nullptr; - lf.valueLbl = nullptr; - lf.startBtn = nullptr; - lf.startLbl = nullptr; -} - -// --------------------------------------------------------------------------- -// Build function -// --------------------------------------------------------------------------- - -void buildLfoView(MidiController* mc, lv_obj_t* parent) { - auto& lf = mc->lfo_; - - const lv_font_t* fontSmall = lvgl_get_text_font(FONT_SIZE_SMALL); - const lv_font_t* fontNormal = lvgl_get_text_font(FONT_SIZE_DEFAULT); - - int rowH = 88; - int waveH = 160; - - lv_obj_t* root = mcContainer(parent); - lv_obj_add_event_cb(root, cbLfoRootDelete, LV_EVENT_DELETE, mc); - lv_obj_set_size(root, LV_PCT(100), LV_PCT(100)); - lv_obj_set_flex_flow(root, LV_FLEX_FLOW_COLUMN); - lv_obj_set_style_bg_color(root, lv_color_hex(0x080C12), 0); - lv_obj_set_style_bg_opa(root, LV_OPA_COVER, 0); - lv_obj_set_style_border_width(root, 0, 0); - lv_obj_set_style_pad_all(root, 0, 0); - lv_obj_set_style_pad_gap(root, 1, 0); - lv_obj_set_scrollable(root, false); - mc->currentViewRoot_ = root; - - auto makeRow = [&]() -> lv_obj_t* { return mcRow(root); }; - - auto makeBtn = [&](lv_obj_t* par, const char* txt, int w, lv_event_cb_t cb) -> lv_obj_t* { - return mcSmallBtn(par, txt, w, cb, mc); - }; - - auto makeLbl = [&](lv_obj_t* par, const char* txt, int w, lv_color_t col) -> lv_obj_t* { - return mcValue(par, txt, w, col); - }; - - // -- Row 1: [<] [LFO MOD] [status] ---------------------------------------- - lv_obj_t* row1 = makeRow(); - mcBackBtn(row1, cbBack, mc, MC_W_M); - lv_obj_t* title = lv_label_create(row1); - lv_label_set_text(title, "LFO MOD"); - lv_obj_set_style_text_font(title, fontNormal, 0); - lv_obj_set_style_text_color(title, lv_color_hex(0xCC44FF), 0); - lv_obj_set_flex_grow(title, 1); - lf.statusLbl = lv_label_create(row1); - lv_label_set_text(lf.statusLbl, "IDLE"); - lv_obj_set_style_text_font(lf.statusLbl, fontSmall, 0); - lv_obj_set_style_text_color(lf.statusLbl, MC_LABEL(), 0); - - // -- Row 2: START/STOP | Rate -/val/Hz/+ | WAVE --------------------------- - lv_obj_t* row2 = makeRow(); - lf.startBtn = mcButton(row2); - lv_obj_set_size(lf.startBtn, 224, MC_BTN_H); - lv_obj_set_style_bg_color(lf.startBtn, lv_color_hex(0x00AA44), 0); - lv_obj_add_event_cb(lf.startBtn, cbStartStop, LV_EVENT_CLICKED, mc); - lf.startLbl = lv_label_create(lf.startBtn); - lv_label_set_text(lf.startLbl, "START"); - lv_obj_set_style_text_font(lf.startLbl, fontSmall, 0); - lv_obj_center(lf.startLbl); - - lv_obj_t* rHdr = lv_label_create(row2); - lv_label_set_text(rHdr, " Rate:"); - lv_obj_set_style_text_font(rHdr, fontSmall, 0); - lv_obj_set_style_text_color(rHdr, lv_color_hex(0x666688), 0); - makeBtn(row2, "-", 96, cbRateDown); - char rbuf[8]; snprintf(rbuf, sizeof(rbuf), "%.1f", lf.rate); - lf.rateLbl = makeLbl(row2, rbuf, 144, lv_color_hex(0x00DFFF)); - lv_obj_t* rHz = lv_label_create(row2); - lv_label_set_text(rHz, "Hz"); - lv_obj_set_style_text_font(rHz, fontSmall, 0); - lv_obj_set_style_text_color(rHz, lv_color_hex(0x666688), 0); - makeBtn(row2, "+", 96, cbRateUp); - - lv_obj_t* waveBtn = mcButton(row2); - lv_obj_set_size(waveBtn, 208, MC_BTN_H); - lv_obj_add_event_cb(waveBtn, cbWaveCycle, LV_EVENT_CLICKED, mc); - lf.waveLbl = lv_label_create(waveBtn); - lv_label_set_text(lf.waveLbl, WAVE_NAMES[lf.waveform]); - lv_obj_set_style_text_font(lf.waveLbl, fontSmall, 0); - lv_obj_center(lf.waveLbl); - - // -- Row 3: Amount -/val/+ | CC -/val/+ | PITCH --------------------------- - lv_obj_t* row3 = makeRow(); - - lv_obj_t* aHdr = lv_label_create(row3); - lv_label_set_text(aHdr, "Amt:"); - lv_obj_set_style_text_font(aHdr, fontSmall, 0); - lv_obj_set_style_text_color(aHdr, lv_color_hex(0x666688), 0); - makeBtn(row3, "-", 96, cbAmountDown); - char abuf[10]; snprintf(abuf, sizeof(abuf), "Amt:%d", lf.amount); - lf.amountLbl = makeLbl(row3, abuf, 208, lv_color_hex(0xFFE000)); - makeBtn(row3, "+", 96, cbAmountUp); - - lv_obj_t* cHdr = lv_label_create(row3); - lv_label_set_text(cHdr, " Tgt:"); - lv_obj_set_style_text_font(cHdr, fontSmall, 0); - lv_obj_set_style_text_color(cHdr, lv_color_hex(0x666688), 0); - makeBtn(row3, "-", 96, cbCCDown); - char ccbuf[10]; snprintf(ccbuf, sizeof(ccbuf), "CC %d", lf.ccTarget); - lf.ccLbl = makeLbl(row3, ccbuf, 192, lv_color_hex(0x00DFFF)); - makeBtn(row3, "+", 96, cbCCUp); - - lv_obj_t* pitchBtn = mcButton(row3); - lv_obj_set_size(pitchBtn, 192, MC_BTN_H); - lv_obj_set_style_bg_color(pitchBtn, lv_color_hex(0x220033), 0); - lv_obj_add_event_cb(pitchBtn, cbPitchToggle, LV_EVENT_CLICKED, mc); - lv_obj_t* pitchLbl = lv_label_create(pitchBtn); - lv_label_set_text(pitchLbl, "PITCH"); - lv_obj_set_style_text_font(pitchLbl, fontSmall, 0); - lv_obj_center(pitchLbl); - - // -- Spacer row (pushes waveform area down) -------------------------------- - lv_obj_t* spacer = mcContainer(root); - lv_obj_set_size(spacer, LV_PCT(100), rowH); - lv_obj_set_style_bg_opa(spacer, LV_OPA_TRANSP, 0); - lv_obj_set_style_border_width(spacer, 0, 0); - lv_obj_set_style_pad_all(spacer, 0, 0); - lv_obj_set_scrollable(spacer, false); - - // -- Waveform display (draw event) ----------------------------------------- - lv_obj_t* waveArea = mcContainer(root); - lv_obj_set_size(waveArea, LV_PCT(100), waveH); - lv_obj_set_style_bg_color(waveArea, lv_color_hex(0x080C12), 0); - lv_obj_set_style_bg_opa(waveArea, LV_OPA_COVER, 0); - lv_obj_set_style_border_width(waveArea, 0, 0); - lv_obj_set_style_pad_all(waveArea, 0, 0); - lv_obj_set_scrollable(waveArea, false); - lv_obj_add_event_cb(waveArea, cbWaveDraw, LV_EVENT_DRAW_MAIN, mc); - lf.canvas = waveArea; - - // -- Value readout --------------------------------------------------------- - lv_obj_t* footer = mcContainer(root); - lv_obj_set_width(footer, LV_PCT(100)); - lv_obj_set_flex_grow(footer, 1); - lv_obj_set_style_bg_color(footer, lv_color_hex(0x080C12), 0); - lv_obj_set_style_bg_opa(footer, LV_OPA_COVER, 0); - lv_obj_set_style_border_width(footer, 0, 0); - lv_obj_set_style_pad_all(footer, 16, 0); - lv_obj_set_scrollable(footer, false); - - lf.valueLbl = lv_label_create(footer); - lv_label_set_text(lf.valueLbl, "Val: 64"); - lv_obj_set_style_text_font(lf.valueLbl, fontNormal, 0); - lv_obj_set_style_text_color(lf.valueLbl, lv_color_hex(0x00DFFF), 0); - lv_obj_center(lf.valueLbl); -} diff --git a/Apps/MidiController/main/Source/ViewRaga.cpp b/Apps/MidiController/main/Source/ViewRaga.cpp deleted file mode 100644 index 7d2eba7..0000000 --- a/Apps/MidiController/main/Source/ViewRaga.cpp +++ /dev/null @@ -1,554 +0,0 @@ -#include "MidiController.h" - -#include -#include -#include -#include -#include -#include - -// --------------------------------------------------------------------------- -// RAGA view - Indian classical raga Markov-chain phrase generator -// -// Layout (flex column): -// [ctrl row: < | RAGA | status] -// [raga row: Raga- | name | Raga+] -// [tala row: Tala- | name | Tala+] -// [root row: Root- | C3 | Root+] -// [bpm row: BPM- | 120 | BPM+ ] -// [spacer flex_grow] -// [play row: PLAY/STOP | DRONE] -// --------------------------------------------------------------------------- - -// --------------------------------------------------------------------------- -// Raga data -// --------------------------------------------------------------------------- - -static constexpr int RAGA_COUNT = 8; -static constexpr int TALA_COUNT = 3; - -static const char* RAGA_NAMES[RAGA_COUNT] = { - "Bhairavi", "Lalit", "Bhupali", "Todi", - "Madhuvanti", "Meghmalhar", "Yaman", "Malkauns" -}; -static const char* TALA_NAMES[TALA_COUNT] = { - "Teental", "Rupak", "Jhaptal" -}; - -static const uint8_t RAGA_INTERVALS[RAGA_COUNT][7] = { - {0, 1, 3, 5, 7, 8, 10}, // Bhairavi - {0, 1, 4, 5, 6, 9, 11}, // Lalit - {0, 2, 4, 7, 9, 0, 0}, // Bhupali (5 notes) - {0, 1, 3, 6, 7, 8, 11}, // Todi - {0, 2, 3, 6, 7, 9, 11}, // Madhuvanti - {0, 2, 5, 7, 10, 0, 0}, // Meghmalhar (5 notes) - {0, 2, 4, 6, 7, 9, 11}, // Yaman - {0, 3, 5, 8, 10, 0, 0}, // Malkauns (5 notes) -}; -static const uint8_t RAGA_LENGTHS[RAGA_COUNT] = {7, 7, 5, 7, 7, 5, 7, 5}; - -// Tala accent patterns (2=sam/strong, 1=khali/medium, 0=weak) -static const uint8_t TEENTAL_ACCENTS[16] = {2,0,0,0, 1,0,0,0, 1,0,0,0, 1,0,0,0}; -static const uint8_t RUPAK_ACCENTS[7] = {2,0,0, 1,0,0,0}; -static const uint8_t JHAPTAL_ACCENTS[10] = {2,0,0, 1,0,0, 0,1,0,0}; - -static const int TALA_BEATS[TALA_COUNT] = {16, 7, 10}; -static const uint8_t* TALA_ACCENTS[TALA_COUNT] = { - TEENTAL_ACCENTS, RUPAK_ACCENTS, JHAPTAL_ACCENTS -}; - -// 2nd-order Markov weights for Bhairavi [prev_prev][prev][next] -static const uint8_t BHAIRAVI_WEIGHTS[7][7][7] = { - {{40,30,15,25,10,5,5},{20,20,40,35,10,5,5},{15,25,20,50,10,5,5}, - {30,20,30,30,15,10,5},{25,15,20,40,20,15,10},{20,15,10,25,15,20,30},{50,20,10,15,10,10,15}}, - {{40,30,20,20,10,5,5},{25,35,30,20,10,5,5},{15,20,25,45,15,5,5}, - {25,20,35,30,15,10,5},{20,25,20,35,20,10,5},{15,20,15,25,15,25,20},{45,25,15,15,10,10,10}}, - {{35,30,20,25,10,5,5},{35,25,25,25,10,5,5},{20,25,25,35,15,10,5}, - {20,15,20,40,20,20,10},{15,20,25,40,20,15,10},{10,15,15,30,15,25,25},{40,25,15,15,10,10,15}}, - {{45,30,20,20,10,5,5},{30,25,30,25,10,5,5},{25,35,25,25,10,5,5}, - {25,20,25,40,20,15,10},{20,15,20,40,25,15,10},{10,10,15,30,15,25,35},{50,20,15,20,10,10,10}}, - {{40,25,20,25,15,5,5},{25,25,25,30,15,10,5},{20,25,25,40,15,10,5}, - {20,20,25,40,20,15,10},{25,15,20,40,25,20,10},{15,15,15,30,15,30,25},{45,20,15,20,15,10,10}}, - {{40,25,20,20,15,10,10},{25,30,25,25,15,15,10},{20,25,25,35,15,15,10}, - {20,20,25,35,20,20,15},{20,20,20,35,25,20,15},{15,15,15,25,15,30,30},{45,20,15,20,15,15,15}}, - {{50,25,15,20,10,10,10},{30,30,25,25,15,10,5},{25,25,25,30,15,10,10}, - {25,20,25,35,15,15,10},{25,20,20,35,20,15,10},{20,20,15,25,15,25,25},{45,25,15,20,10,10,15}}, -}; - -// --------------------------------------------------------------------------- -// Algorithm helpers (file-static, operate on RagaState&) -// --------------------------------------------------------------------------- - -static int ragaSelectNextBhairavi(const int8_t hist[2]) { - int h0 = ((int)hist[0] % 7 + 7) % 7; - int h1 = ((int)hist[1] % 7 + 7) % 7; - const uint8_t* w = BHAIRAVI_WEIGHTS[h0][h1]; - int total = 0; - for (int i = 0; i < 7; i++) total += w[i]; - if (total <= 0) return 0; - int sel = (int)((uint32_t)esp_random() % (uint32_t)total); - int cum = 0; - for (int i = 0; i < 7; i++) { - cum += w[i]; - if (sel < cum) return i; - } - return 0; -} - -static void ragaGeneratePhrase(MidiController::RagaState& rs) { - int scaleLen = RAGA_LENGTHS[rs.raga]; - const uint8_t* scale = RAGA_INTERVALS[rs.raga]; - int total = 32; - rs.phraseLen = total; - - if (rs.raga == 0) { - // Bhairavi: 2nd-order Markov - int8_t hist[2] = {rs.history[0], rs.history[1]}; - int octave = 0; - for (int i = 0; i < total; i++) { - int degree = ragaSelectNextBhairavi(hist); - if ((uint32_t)(esp_random() % 100) < 8) { - int shift = (int)((uint32_t)(esp_random() % 3)) - 1; - octave = std::max(-1, std::min(1, octave + shift)); - } - rs.phrase[i] = (int8_t)(scale[degree] + octave * 12); - hist[0] = hist[1]; - hist[1] = (int8_t)degree; - } - rs.history[0] = hist[0]; - rs.history[1] = hist[1]; - } else { - // Other ragas: random walk - int degree = (int)((uint32_t)(esp_random() % (uint32_t)scaleLen)); - int octave = 0; - for (int i = 0; i < total; i++) { - int step = (int)((uint32_t)(esp_random() % 3)) - 1; - degree = std::max(0, std::min(scaleLen - 1, degree + step)); - if ((uint32_t)(esp_random() % 100) < 10) { - int shift = (int)((uint32_t)(esp_random() % 3)) - 1; - octave = std::max(-1, std::min(1, octave + shift)); - } - rs.phrase[i] = (int8_t)(scale[degree] + octave * 12); - } - } - rs.phraseIdx = 0; -} - -static void ragaUpdateTempo(MidiController::RagaState& rs) { - if (rs.bpm < 1) rs.bpm = 120; // Safe default - uint32_t interval = 60000u / (uint32_t)rs.bpm; - rs.noteInterval = std::max((uint32_t)40, interval / 4); - rs.noteDuration = std::max((uint32_t)30, rs.noteInterval * 8 / 10); -} - -static void ragaStopNote(MidiController* mc) { - auto& rs = mc->raga_; - if (rs.noteActive) { - mc->sendMidi(0x80, rs.currentNote, 0); - rs.noteActive = false; - } -} - -static void ragaUpdateDrone(MidiController* mc) { - auto& rs = mc->raga_; - if (rs.droneOn) { - if (!rs.droneActive || rs.droneNote != (uint8_t)rs.rootNote) { - if (rs.droneActive) mc->sendMidi(0x80, rs.droneNote, 0); - rs.droneNote = (uint8_t)rs.rootNote; - rs.droneActive = true; - mc->sendMidi(0x90, rs.droneNote, 80); - } - } else if (rs.droneActive) { - mc->sendMidi(0x80, rs.droneNote, 0); - rs.droneActive = false; - } -} - -// --------------------------------------------------------------------------- -// Timer callback -// --------------------------------------------------------------------------- - -static void cbRagaTimer(lv_timer_t* t) { - auto* mc = static_cast(lv_timer_get_user_data(t)); - if (!mc) return; - auto& rs = mc->raga_; - - ragaUpdateDrone(mc); - if (!rs.playing) return; - - uint32_t now = lv_tick_get(); - - // Note-off check - if (rs.noteActive && now >= rs.noteOffMs) { - ragaStopNote(mc); - } - - // Schedule next note - if (now >= rs.nextNoteMs) { - if (rs.phraseLen == 0 || rs.phraseIdx >= rs.phraseLen) - ragaGeneratePhrase(rs); - - ragaStopNote(mc); - uint8_t note = (uint8_t)std::max(0, std::min(127, rs.rootNote + rs.phrase[rs.phraseIdx++])); - // Ensure talaBeat is within bounds for current tala - if (rs.talaBeat >= TALA_BEATS[rs.tala]) rs.talaBeat = 0; - uint8_t accent = TALA_ACCENTS[rs.tala][rs.talaBeat]; - uint8_t vel = (uint8_t)std::min(127, 90 + (int)accent * 10); - mc->sendMidi(0x90, note, vel); - rs.currentNote = note; - rs.noteActive = true; - rs.noteOffMs = now + rs.noteDuration; - - // Update status label - if (rs.statusLbl) { - char buf[32]; - char noteName[8]; midiNoteName(note, noteName, sizeof(noteName)); - snprintf(buf, sizeof(buf), "%s | beat %d/%d", - noteName, rs.talaBeat + 1, TALA_BEATS[rs.tala]); - lv_label_set_text(rs.statusLbl, buf); - } - rs.talaBeat = (rs.talaBeat + 1) % TALA_BEATS[rs.tala]; - rs.nextNoteMs = now + rs.noteInterval; - } -} - -// Forward declarations for public encoder entry points (defined at file end) -void ragaTogglePlay (MidiController* mc); -void ragaToggleDrone(MidiController* mc); - -// --------------------------------------------------------------------------- -// Callbacks -// --------------------------------------------------------------------------- - -static void cbBack(lv_event_t* e) { - auto* mc = static_cast(lv_event_get_user_data(e)); - if (mc) mc->switchToMode(MidiMode::HOME); -} - -static void cbPlayStop(lv_event_t* e) { - auto* mc = static_cast(lv_event_get_user_data(e)); - if (mc) ragaTogglePlay(mc); -} - -static void cbDrone(lv_event_t* e) { - auto* mc = static_cast(lv_event_get_user_data(e)); - if (mc) ragaToggleDrone(mc); -} - -static void cbRagaDown(lv_event_t* e) { - auto* mc = static_cast(lv_event_get_user_data(e)); - if (!mc) return; - auto& rs = mc->raga_; - rs.raga = (rs.raga + RAGA_COUNT - 1) % RAGA_COUNT; - ragaGeneratePhrase(rs); - if (rs.ragaLbl) lv_label_set_text(rs.ragaLbl, RAGA_NAMES[rs.raga]); - if (rs.ragaBigLbl) lv_label_set_text(rs.ragaBigLbl, RAGA_NAMES[rs.raga]); -} -static void cbRagaUp(lv_event_t* e) { - auto* mc = static_cast(lv_event_get_user_data(e)); - if (!mc) return; - auto& rs = mc->raga_; - rs.raga = (rs.raga + 1) % RAGA_COUNT; - ragaGeneratePhrase(rs); - if (rs.ragaLbl) lv_label_set_text(rs.ragaLbl, RAGA_NAMES[rs.raga]); - if (rs.ragaBigLbl) lv_label_set_text(rs.ragaBigLbl, RAGA_NAMES[rs.raga]); -} - -static void cbTalaDown(lv_event_t* e) { - auto* mc = static_cast(lv_event_get_user_data(e)); - if (!mc) return; - auto& rs = mc->raga_; - rs.tala = (rs.tala + TALA_COUNT - 1) % TALA_COUNT; - rs.talaBeat = 0; - if (rs.talaLbl) lv_label_set_text(rs.talaLbl, TALA_NAMES[rs.tala]); - if (rs.talaBigLbl) lv_label_set_text(rs.talaBigLbl, TALA_NAMES[rs.tala]); -} -static void cbTalaUp(lv_event_t* e) { - auto* mc = static_cast(lv_event_get_user_data(e)); - if (!mc) return; - auto& rs = mc->raga_; - rs.tala = (rs.tala + 1) % TALA_COUNT; - rs.talaBeat = 0; - if (rs.talaLbl) lv_label_set_text(rs.talaLbl, TALA_NAMES[rs.tala]); - if (rs.talaBigLbl) lv_label_set_text(rs.talaBigLbl, TALA_NAMES[rs.tala]); -} - -static void cbRootDown(lv_event_t* e) { - auto* mc = static_cast(lv_event_get_user_data(e)); - if (!mc) return; - auto& rs = mc->raga_; - if (rs.rootNote > 24) rs.rootNote--; - if (rs.rootLbl) { - char buf[8]; midiNoteName(rs.rootNote, buf, sizeof(buf)); - lv_label_set_text(rs.rootLbl, buf); - } -} -static void cbRootUp(lv_event_t* e) { - auto* mc = static_cast(lv_event_get_user_data(e)); - if (!mc) return; - auto& rs = mc->raga_; - if (rs.rootNote < 96) rs.rootNote++; - if (rs.rootLbl) { - char buf[8]; midiNoteName(rs.rootNote, buf, sizeof(buf)); - lv_label_set_text(rs.rootLbl, buf); - } -} - -static void cbBpmDown(lv_event_t* e) { - auto* mc = static_cast(lv_event_get_user_data(e)); - if (!mc) return; - auto& rs = mc->raga_; - if (rs.bpm > 40) rs.bpm -= 5; - ragaUpdateTempo(rs); - if (rs.bpmLbl) { - char buf[8]; snprintf(buf, sizeof(buf), "%d", rs.bpm); - lv_label_set_text(rs.bpmLbl, buf); - } -} -static void cbBpmUp(lv_event_t* e) { - auto* mc = static_cast(lv_event_get_user_data(e)); - if (!mc) return; - auto& rs = mc->raga_; - if (rs.bpm < 240) rs.bpm += 5; - ragaUpdateTempo(rs); - if (rs.bpmLbl) { - char buf[8]; snprintf(buf, sizeof(buf), "%d", rs.bpm); - lv_label_set_text(rs.bpmLbl, buf); - } -} - -// --------------------------------------------------------------------------- -// Build -// --------------------------------------------------------------------------- - -void buildRagaView(MidiController* mc, lv_obj_t* parent) { - auto& rs = mc->raga_; - - const lv_font_t* fontSmall = lvgl_get_text_font(FONT_SIZE_SMALL); - const lv_font_t* fontNormal = lvgl_get_text_font(FONT_SIZE_DEFAULT); - const lv_font_t* fontLarge = lvgl_get_text_font(FONT_SIZE_LARGE); - - static constexpr int rowH = 88; - static constexpr int btnH = 64; - static constexpr int pad = 12; - - ragaUpdateTempo(rs); - - // -- Root: flex column ----------------------------------------------------- - lv_obj_t* root = mcContainer(parent); - lv_obj_set_size(root, LV_PCT(100), LV_PCT(100)); - lv_obj_set_flex_flow(root, LV_FLEX_FLOW_COLUMN); - lv_obj_set_style_bg_color(root, lv_color_hex(0x080C12), 0); - lv_obj_set_style_bg_opa(root, LV_OPA_COVER, 0); - lv_obj_set_style_border_width(root, 0, 0); - lv_obj_set_style_pad_all(root, 0, 0); - lv_obj_set_style_pad_gap(root, 0, 0); - lv_obj_set_scrollable(root, false); - mc->currentViewRoot_ = root; - - // -- Top control row ------------------------------------------------------ - { - lv_obj_t* ctrl = mcRow(root, rowH); - - // Back - mcBackBtn(ctrl, cbBack, mc, MC_W_S); - - // Title - lv_obj_t* title = lv_label_create(ctrl); - lv_label_set_text(title, "RAGA"); - lv_obj_set_style_text_font(title, fontNormal, 0); - lv_obj_set_style_text_color(title, lv_color_hex(0xFF8844), 0); - lv_obj_set_flex_grow(title, 1); - - // Play / Stop button in the top bar - sync to current state - rs.playBtn = mcButton(ctrl); - lv_obj_set_size(rs.playBtn, 160, btnH); - lv_obj_set_style_bg_color(rs.playBtn, - rs.playing ? lv_color_hex(0xFF2020) : lv_color_hex(0x00AA44), 0); - lv_obj_add_event_cb(rs.playBtn, cbPlayStop, LV_EVENT_CLICKED, mc); - rs.playLbl = lv_label_create(rs.playBtn); - lv_label_set_text(rs.playLbl, rs.playing ? "STOP" : "PLAY"); - lv_obj_set_style_text_font(rs.playLbl, fontSmall, 0); - lv_obj_center(rs.playLbl); - - // Drone button - sync to current state - rs.droneBtn = mcButton(ctrl); - lv_obj_set_size(rs.droneBtn, 200, btnH); - lv_obj_set_style_bg_color(rs.droneBtn, - rs.droneOn ? lv_color_hex(0x00AA44) : lv_color_hex(0x1A2A40), LV_PART_MAIN); - lv_obj_add_event_cb(rs.droneBtn, cbDrone, LV_EVENT_CLICKED, mc); - rs.droneLbl = lv_label_create(rs.droneBtn); - lv_label_set_text(rs.droneLbl, rs.droneOn ? "DRONE ON" : "DRONE OFF"); - lv_obj_set_style_text_font(rs.droneLbl, fontSmall, 0); - lv_obj_center(rs.droneLbl); - - // Status - rs.statusLbl = lv_label_create(ctrl); - lv_label_set_text(rs.statusLbl, "IDLE"); - lv_obj_set_style_text_font(rs.statusLbl, fontSmall, 0); - lv_obj_set_style_text_color(rs.statusLbl, lv_color_hex(0x666688), 0); - lv_obj_set_width(rs.statusLbl, 260); - lv_obj_set_style_text_align(rs.statusLbl, LV_TEXT_ALIGN_RIGHT, 0); - } - - // -- Body: flex row - left params | right display -------------------------- - lv_obj_t* body = mcContainer(root); - lv_obj_set_width(body, LV_PCT(100)); - lv_obj_set_flex_grow(body, 1); - lv_obj_set_flex_flow(body, LV_FLEX_FLOW_ROW); - lv_obj_set_flex_align(body, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START); - lv_obj_set_style_bg_opa(body, LV_OPA_TRANSP, 0); - lv_obj_set_style_border_width(body, 0, 0); - lv_obj_set_style_pad_all(body, 0, 0); - lv_obj_set_style_pad_gap(body, 0, 0); - lv_obj_set_scrollable(body, false); - - // -- LEFT: parameter column (flex column, 600px) --------------------------- - { - lv_obj_t* col = mcContainer(body); - lv_obj_set_size(col, 600, LV_PCT(100)); - lv_obj_set_flex_flow(col, LV_FLEX_FLOW_COLUMN); - lv_obj_set_flex_align(col, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START); - lv_obj_set_style_bg_opa(col, LV_OPA_TRANSP, 0); - lv_obj_set_style_border_width(col, 0, 0); - lv_obj_set_style_pad_all(col, 0, 0); - lv_obj_set_style_pad_gap(col, 0, 0); - lv_obj_set_scrollable(col, false); - - // Each param gets flex_grow=1 so all 4 fill the column equally - auto makeParamRow = [&](const char* name, lv_color_t nameCol) -> lv_obj_t* { - lv_obj_t* row = mcContainer(col); - lv_obj_set_width(row, LV_PCT(100)); - lv_obj_set_flex_grow(row, 1); - lv_obj_set_flex_flow(row, LV_FLEX_FLOW_ROW); - lv_obj_set_flex_align(row, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); - lv_obj_set_style_bg_color(row, lv_color_hex(0x0A0C14), 0); - lv_obj_set_style_bg_opa(row, LV_OPA_COVER, 0); - lv_obj_set_style_border_width(row, 0, 0); - lv_obj_set_style_border_side(row, LV_BORDER_SIDE_BOTTOM, 0); - lv_obj_set_style_border_color(row, lv_color_hex(0x1A2030), 0); - lv_obj_set_style_pad_all(row, pad, 0); - lv_obj_set_style_pad_gap(row, pad, 0); - lv_obj_set_scrollable(row, false); - lv_obj_t* hdr = lv_label_create(row); - lv_label_set_text(hdr, name); - lv_obj_set_style_text_font(hdr, fontSmall, 0); - lv_obj_set_style_text_color(hdr, nameCol, 0); - lv_obj_set_width(hdr, 120); - return row; - }; - - auto makeBtn = [&](lv_obj_t* par, const char* txt, int w, lv_event_cb_t cb) -> lv_obj_t* { - return mcSmallBtn(par, txt, w, cb, mc); - }; - - // Value label uses flex_grow=1 so it takes remaining width - prevents > button overflow - auto makeValLbl = [&](lv_obj_t* par, const char* txt, lv_color_t col) -> lv_obj_t* { - lv_obj_t* lbl = lv_label_create(par); - lv_label_set_text(lbl, txt); - lv_obj_set_style_text_font(lbl, fontNormal, 0); - lv_obj_set_style_text_color(lbl, col, 0); - lv_obj_set_flex_grow(lbl, 1); - lv_obj_set_style_text_align(lbl, LV_TEXT_ALIGN_CENTER, 0); - return lbl; - }; - - // Raga - { - lv_obj_t* row = makeParamRow("Raga", lv_color_hex(0x888888)); - makeBtn(row, "<", 88, cbRagaDown); - rs.ragaLbl = makeValLbl(row, RAGA_NAMES[rs.raga], lv_color_hex(0xFF8844)); - makeBtn(row, ">", 88, cbRagaUp); - } - // Tala - { - lv_obj_t* row = makeParamRow("Tala", lv_color_hex(0x888888)); - makeBtn(row, "<", 88, cbTalaDown); - rs.talaLbl = makeValLbl(row, TALA_NAMES[rs.tala], lv_color_hex(0xAADDFF)); - makeBtn(row, ">", 88, cbTalaUp); - } - // Root - { - lv_obj_t* row = makeParamRow("Root", lv_color_hex(0x888888)); - makeBtn(row, "<", 88, cbRootDown); - char rootBuf[8]; midiNoteName(rs.rootNote, rootBuf, sizeof(rootBuf)); - rs.rootLbl = makeValLbl(row, rootBuf, MC_VALUE()); - makeBtn(row, ">", 88, cbRootUp); - } - // BPM - { - lv_obj_t* row = makeParamRow("BPM", lv_color_hex(0x888888)); - makeBtn(row, "<", 88, cbBpmDown); - char bpmBuf[8]; snprintf(bpmBuf, sizeof(bpmBuf), "%d", rs.bpm); - rs.bpmLbl = makeValLbl(row, bpmBuf, lv_color_hex(0xFFE000)); - makeBtn(row, ">", 88, cbBpmUp); - } - } - - // -- RIGHT: status display (flex_grow) ------------------------------------- - { - lv_obj_t* display = mcContainer(body); - lv_obj_set_height(display, LV_PCT(100)); - lv_obj_set_flex_grow(display, 1); - lv_obj_set_flex_flow(display, LV_FLEX_FLOW_COLUMN); - lv_obj_set_flex_align(display, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); - lv_obj_set_style_bg_color(display, lv_color_hex(0x05080F), 0); - lv_obj_set_style_bg_opa(display, LV_OPA_COVER, 0); - lv_obj_set_style_border_width(display, 0, 0); - lv_obj_set_style_pad_all(display, pad * 2, 0); - lv_obj_set_style_pad_gap(display, pad, 0); - lv_obj_set_scrollable(display, false); - - // Large raga name (updates when raga changes) - rs.ragaBigLbl = lv_label_create(display); - lv_label_set_text(rs.ragaBigLbl, RAGA_NAMES[rs.raga]); - lv_obj_set_style_text_font(rs.ragaBigLbl, fontLarge, 0); - lv_obj_set_style_text_color(rs.ragaBigLbl, lv_color_hex(0xFF8844), 0); - lv_obj_set_style_text_align(rs.ragaBigLbl, LV_TEXT_ALIGN_CENTER, 0); - lv_obj_set_width(rs.ragaBigLbl, LV_PCT(100)); - - // Tala info (updates when tala changes) - rs.talaBigLbl = lv_label_create(display); - lv_label_set_text(rs.talaBigLbl, TALA_NAMES[rs.tala]); - lv_obj_set_style_text_font(rs.talaBigLbl, fontNormal, 0); - lv_obj_set_style_text_color(rs.talaBigLbl, lv_color_hex(0xAADDFF), 0); - lv_obj_set_style_text_align(rs.talaBigLbl, LV_TEXT_ALIGN_CENTER, 0); - lv_obj_set_width(rs.talaBigLbl, LV_PCT(100)); - } - - // -- Start timer ---------------------------------------------------------- - mc->ragaTimer_ = lv_timer_create(cbRagaTimer, 20, mc); -} - -// --------------------------------------------------------------------------- -// Public encoder entry points (called from EncoderInput.cpp) -// --------------------------------------------------------------------------- - -void ragaTogglePlay(MidiController* mc) { - auto& rs = mc->raga_; - rs.playing = !rs.playing; - if (rs.playing) { - ragaUpdateTempo(rs); - ragaGeneratePhrase(rs); - rs.talaBeat = 0; - rs.nextNoteMs = lv_tick_get(); - } else { - ragaStopNote(mc); - if (rs.statusLbl) lv_label_set_text(rs.statusLbl, "IDLE"); - } - if (rs.playLbl) lv_label_set_text(rs.playLbl, rs.playing ? "STOP" : "PLAY"); - if (rs.playBtn) - lv_obj_set_style_bg_color(rs.playBtn, - rs.playing ? lv_color_hex(0xFF2020) : lv_color_hex(0x00AA44), 0); -} - -void ragaToggleDrone(MidiController* mc) { - auto& rs = mc->raga_; - rs.droneOn = !rs.droneOn; - if (rs.droneLbl) lv_label_set_text(rs.droneLbl, rs.droneOn ? "DRONE ON" : "DRONE OFF"); - if (rs.droneBtn) { - lv_obj_set_style_bg_color(rs.droneBtn, - rs.droneOn ? lv_color_hex(0x00AA44) : lv_color_hex(0x1A2A40), - LV_PART_MAIN); - } -} diff --git a/Apps/MidiController/main/Source/ViewRng.cpp b/Apps/MidiController/main/Source/ViewRng.cpp deleted file mode 100644 index db972ec..0000000 --- a/Apps/MidiController/main/Source/ViewRng.cpp +++ /dev/null @@ -1,352 +0,0 @@ -#include "MidiController.h" - -#include -#include -#include -#include -#include -#include - -// --------------------------------------------------------------------------- -// RNG view - Random melody generator -// -// All controls, no canvas. -// Layout (flex column): -// [header row] -// [play controls: PLAY/STOP | Key -/+/label | Scale] -// [octave range: MinOct -/label/+ | MaxOct -/label/+] -// [probability: Prob -/bar/+] -// [BPM + subdivision] -// [current note display - flex_grow] -// --------------------------------------------------------------------------- - -static const char* SUBDIV_NAMES[] = { "1/4", "1/8", "1/16" }; -static const int SUBDIV_DIVS[] = { 1, 2, 4 }; - - -static uint64_t rngNoteIntervalUs(MidiController* mc) { - auto& rn = mc->rng_; - int idx = rn.subdivision % (sizeof(SUBDIV_DIVS) / sizeof(SUBDIV_DIVS[0])); - float quarterUs = 60000000.0f / (float)rn.bpm; - return (uint64_t)(quarterUs / (float)SUBDIV_DIVS[idx]); -} - -static void cbRngTimer(lv_timer_t* t) { - auto* mc = static_cast(lv_timer_get_user_data(t)); - if (!mc || !mc->rng_.playing) return; - auto& rn = mc->rng_; - - uint64_t nowUs = (uint64_t)esp_timer_get_time(); - - // Handle note-off - if (rn.noteOffUs > 0 && nowUs >= rn.noteOffUs) { - if (rn.currentNote >= 0) { - mc->sendMidi(0x80, (uint8_t)rn.currentNote, 0); - rn.currentNote = -1; - } - rn.noteOffUs = 0; - } - - // Trigger next note - if (nowUs >= rn.nextNoteUs) { - uint64_t interval = rngNoteIntervalUs(mc); - rn.nextNoteUs = nowUs + interval; - - if ((int)(esp_random() % 100) < rn.probability) { - // Pick random degree within scale - int numDegrees = MIDI_SCALES[rn.scaleIdx % NUM_MIDI_SCALES].numNotes; - int degree = (int)(esp_random() % (uint32_t)numDegrees); - int octRange = rn.maxOctave - rn.minOctave + 1; - if (octRange < 1) octRange = 1; - int octave = rn.minOctave + (int)(esp_random() % (uint32_t)octRange); - int note = getNoteInScale(rn.scaleIdx, degree, octave, rn.rootKey); - - // Note off for previous if still playing - if (rn.currentNote >= 0) { - mc->sendMidi(0x80, (uint8_t)rn.currentNote, 0); - } - - mc->sendMidi(0x90, (uint8_t)note, 80 + (int)(esp_random() % 40)); - rn.currentNote = note; - rn.noteOffUs = nowUs + interval * 3 / 4; // note length = 75% of interval - - if (rn.noteLbl) { - char buf[16]; - char noteName[8]; - midiNoteName(note, noteName, sizeof(noteName)); - snprintf(buf, sizeof(buf), "%s", noteName); - lv_label_set_text(rn.noteLbl, buf); - } - } else { - // Rest - no note - if (rn.noteLbl) lv_label_set_text(rn.noteLbl, "( rest )"); - } - } -} - -// --------------------------------------------------------------------------- -// Callbacks -// --------------------------------------------------------------------------- - -static void cbBack(lv_event_t* e) { - auto* mc = static_cast(lv_event_get_user_data(e)); - if (mc) mc->switchToMode(MidiMode::HOME); -} - -static void cbPlayStop(lv_event_t* e) { - auto* mc = static_cast(lv_event_get_user_data(e)); - if (!mc) return; - auto& rn = mc->rng_; - rn.playing = !rn.playing; - if (rn.playing) { - rn.nextNoteUs = (uint64_t)esp_timer_get_time(); - rn.noteOffUs = 0; - rn.currentNote = -1; - if (!mc->rngTimer_) { - mc->rngTimer_ = lv_timer_create(cbRngTimer, 10, mc); - } - } else { - if (rn.currentNote >= 0) { - mc->sendMidi(0x80, (uint8_t)rn.currentNote, 0); - rn.currentNote = -1; - } - if (mc->rngTimer_) { lv_timer_delete(mc->rngTimer_); mc->rngTimer_ = nullptr; } - if (rn.noteLbl) lv_label_set_text(rn.noteLbl, ""); - } - if (rn.playLbl) lv_label_set_text(rn.playLbl, rn.playing ? "STOP" : "PLAY"); - if (rn.playBtn) { - lv_obj_set_style_bg_color(rn.playBtn, - rn.playing ? lv_color_hex(0xFF2020) : lv_color_hex(0x00AA44), 0); - } -} - -static void cbKeyDown(lv_event_t* e) { - auto* mc = static_cast(lv_event_get_user_data(e)); - if (!mc) return; - mc->rng_.rootKey = (mc->rng_.rootKey + 11) % 12; - if (mc->rng_.keyLbl) lv_label_set_text(mc->rng_.keyLbl, KEY_NAMES[mc->rng_.rootKey]); -} -static void cbKeyUp(lv_event_t* e) { - auto* mc = static_cast(lv_event_get_user_data(e)); - if (!mc) return; - mc->rng_.rootKey = (mc->rng_.rootKey + 1) % 12; - if (mc->rng_.keyLbl) lv_label_set_text(mc->rng_.keyLbl, KEY_NAMES[mc->rng_.rootKey]); -} - -static void cbScaleCycle(lv_event_t* e) { - auto* mc = static_cast(lv_event_get_user_data(e)); - if (!mc) return; - mc->rng_.scaleIdx = (mc->rng_.scaleIdx + 1) % NUM_MIDI_SCALES; - if (mc->rng_.scaleLbl) lv_label_set_text(mc->rng_.scaleLbl, MIDI_SCALES[mc->rng_.scaleIdx].name); -} - -static void cbMinOctDown(lv_event_t* e) { - auto* mc = static_cast(lv_event_get_user_data(e)); - if (!mc) return; - if (mc->rng_.minOctave > 1) mc->rng_.minOctave--; - if (mc->rng_.minOctLbl) { - char buf[4]; snprintf(buf, sizeof(buf), "%d", mc->rng_.minOctave); - lv_label_set_text(mc->rng_.minOctLbl, buf); - } -} -static void cbMinOctUp(lv_event_t* e) { - auto* mc = static_cast(lv_event_get_user_data(e)); - if (!mc) return; - if (mc->rng_.minOctave < mc->rng_.maxOctave) mc->rng_.minOctave++; - if (mc->rng_.minOctLbl) { - char buf[4]; snprintf(buf, sizeof(buf), "%d", mc->rng_.minOctave); - lv_label_set_text(mc->rng_.minOctLbl, buf); - } -} -static void cbMaxOctDown(lv_event_t* e) { - auto* mc = static_cast(lv_event_get_user_data(e)); - if (!mc) return; - if (mc->rng_.maxOctave > mc->rng_.minOctave) mc->rng_.maxOctave--; - if (mc->rng_.maxOctLbl) { - char buf[4]; snprintf(buf, sizeof(buf), "%d", mc->rng_.maxOctave); - lv_label_set_text(mc->rng_.maxOctLbl, buf); - } -} -static void cbMaxOctUp(lv_event_t* e) { - auto* mc = static_cast(lv_event_get_user_data(e)); - if (!mc) return; - if (mc->rng_.maxOctave < 8) mc->rng_.maxOctave++; - if (mc->rng_.maxOctLbl) { - char buf[4]; snprintf(buf, sizeof(buf), "%d", mc->rng_.maxOctave); - lv_label_set_text(mc->rng_.maxOctLbl, buf); - } -} - -static void cbProbDown(lv_event_t* e) { - auto* mc = static_cast(lv_event_get_user_data(e)); - if (!mc) return; - mc->rng_.probability -= 5; if (mc->rng_.probability < 0) mc->rng_.probability = 0; - if (mc->rng_.probLbl) { - char buf[8]; snprintf(buf, sizeof(buf), "%d%%", mc->rng_.probability); - lv_label_set_text(mc->rng_.probLbl, buf); - } -} -static void cbProbUp(lv_event_t* e) { - auto* mc = static_cast(lv_event_get_user_data(e)); - if (!mc) return; - mc->rng_.probability += 5; if (mc->rng_.probability > 100) mc->rng_.probability = 100; - if (mc->rng_.probLbl) { - char buf[8]; snprintf(buf, sizeof(buf), "%d%%", mc->rng_.probability); - lv_label_set_text(mc->rng_.probLbl, buf); - } -} - -static void cbBpmDown(lv_event_t* e) { - auto* mc = static_cast(lv_event_get_user_data(e)); - if (!mc) return; - mc->rng_.bpm -= 5; if (mc->rng_.bpm < 40) mc->rng_.bpm = 40; - if (mc->rng_.bpmLbl) { - char buf[8]; snprintf(buf, sizeof(buf), "%d", mc->rng_.bpm); - lv_label_set_text(mc->rng_.bpmLbl, buf); - } -} -static void cbBpmUp(lv_event_t* e) { - auto* mc = static_cast(lv_event_get_user_data(e)); - if (!mc) return; - mc->rng_.bpm += 5; if (mc->rng_.bpm > 300) mc->rng_.bpm = 300; - if (mc->rng_.bpmLbl) { - char buf[8]; snprintf(buf, sizeof(buf), "%d", mc->rng_.bpm); - lv_label_set_text(mc->rng_.bpmLbl, buf); - } -} - -static void cbSubdivDown(lv_event_t* e) { - auto* mc = static_cast(lv_event_get_user_data(e)); - if (!mc) return; - if (mc->rng_.subdivision > 0) mc->rng_.subdivision--; - if (mc->rng_.subdivLbl) lv_label_set_text(mc->rng_.subdivLbl, SUBDIV_NAMES[mc->rng_.subdivision]); -} -static void cbSubdivUp(lv_event_t* e) { - auto* mc = static_cast(lv_event_get_user_data(e)); - if (!mc) return; - if (mc->rng_.subdivision < 2) mc->rng_.subdivision++; - if (mc->rng_.subdivLbl) lv_label_set_text(mc->rng_.subdivLbl, SUBDIV_NAMES[mc->rng_.subdivision]); -} - -// --------------------------------------------------------------------------- -// Build function -// --------------------------------------------------------------------------- - -void buildRngView(MidiController* mc, lv_obj_t* parent) { - auto& rn = mc->rng_; - - const lv_font_t* fontSmall = lvgl_get_text_font(FONT_SIZE_SMALL); - const lv_font_t* fontNormal = lvgl_get_text_font(FONT_SIZE_DEFAULT); - const lv_font_t* fontLarge = lvgl_get_text_font(FONT_SIZE_LARGE); - - lv_obj_t* root = mcContainer(parent); - lv_obj_set_size(root, LV_PCT(100), LV_PCT(100)); - lv_obj_set_flex_flow(root, LV_FLEX_FLOW_COLUMN); - lv_obj_set_style_bg_color(root, lv_color_hex(0x080C12), 0); - lv_obj_set_style_bg_opa(root, LV_OPA_COVER, 0); - lv_obj_set_style_border_width(root, 0, 0); - lv_obj_set_style_pad_all(root, 0, 0); - lv_obj_set_style_pad_gap(root, 1, 0); - lv_obj_set_scrollable(root, false); - mc->currentViewRoot_ = root; - - // Helper to make a row - auto makeRow = [&]() -> lv_obj_t* { return mcRow(root); }; - - auto makeBtn = [&](lv_obj_t* parent_, const char* txt, int w, lv_event_cb_t cb) { - mcSmallBtn(parent_, txt, w, cb, mc); - }; - - auto makeLbl = [&](lv_obj_t* parent_, const char* txt, int w, lv_color_t col) -> lv_obj_t* { - return mcValue(parent_, txt, w, col); - }; - - auto makeHdr = [&](lv_obj_t* parent_, const char* txt) { - mcHead(parent_, txt); - }; - - // -- Row 1: header + play ------------------------------------------------- - lv_obj_t* row1 = makeRow(); - mcBackBtn(row1, cbBack, mc, MC_W_M); - lv_obj_t* titleLbl = lv_label_create(row1); - lv_label_set_text(titleLbl, "RNG JAMS"); - lv_obj_set_style_text_font(titleLbl, fontNormal, 0); - lv_obj_set_style_text_color(titleLbl, lv_color_hex(0x44FF88), 0); - lv_obj_set_flex_grow(titleLbl, 1); - - rn.playBtn = mcButton(row1); - lv_obj_set_size(rn.playBtn, 224, MC_BTN_H); - lv_obj_set_style_bg_color(rn.playBtn, lv_color_hex(0x00AA44), 0); - lv_obj_add_event_cb(rn.playBtn, cbPlayStop, LV_EVENT_CLICKED, mc); - rn.playLbl = lv_label_create(rn.playBtn); - lv_label_set_text(rn.playLbl, "PLAY"); - lv_obj_set_style_text_font(rn.playLbl, fontSmall, 0); - lv_obj_center(rn.playLbl); - - // -- Row 2: Key + Scale --------------------------------------------------- - lv_obj_t* row2 = makeRow(); - makeHdr(row2, "Key:"); - makeBtn(row2, "-", 96, cbKeyDown); - rn.keyLbl = makeLbl(row2, KEY_NAMES[rn.rootKey], 112, MC_VALUE()); - makeBtn(row2, "+", 96, cbKeyUp); - - makeHdr(row2, " Scale:"); - lv_obj_t* scaleBtn = mcButton(row2); - lv_obj_set_size(scaleBtn, 240, MC_BTN_H); - lv_obj_add_event_cb(scaleBtn, cbScaleCycle, LV_EVENT_CLICKED, mc); - rn.scaleLbl = lv_label_create(scaleBtn); - lv_label_set_text(rn.scaleLbl, MIDI_SCALES[rn.scaleIdx % NUM_MIDI_SCALES].name); - lv_obj_set_style_text_font(rn.scaleLbl, fontSmall, 0); - lv_obj_center(rn.scaleLbl); - - // -- Row 3: Oct range ----------------------------------------------------- - lv_obj_t* row3 = makeRow(); - makeHdr(row3, "Min:"); - makeBtn(row3, "-", 96, cbMinOctDown); - char mob[4]; snprintf(mob, sizeof(mob), "%d", rn.minOctave); - rn.minOctLbl = makeLbl(row3, mob, 96, lv_color_hex(0xFFFFFF)); - makeBtn(row3, "+", 96, cbMinOctUp); - - makeHdr(row3, " Max:"); - makeBtn(row3, "-", 96, cbMaxOctDown); - char maxb[4]; snprintf(maxb, sizeof(maxb), "%d", rn.maxOctave); - rn.maxOctLbl = makeLbl(row3, maxb, 96, lv_color_hex(0xFFFFFF)); - makeBtn(row3, "+", 96, cbMaxOctUp); - - // -- Row 4: Probability --------------------------------------------------- - lv_obj_t* row4 = makeRow(); - makeHdr(row4, "Prob:"); - makeBtn(row4, "-", 96, cbProbDown); - char pb[8]; snprintf(pb, sizeof(pb), "%d%%", rn.probability); - rn.probLbl = makeLbl(row4, pb, 176, lv_color_hex(0xFFE000)); - makeBtn(row4, "+", 96, cbProbUp); - - // -- Row 5: BPM + Subdivision --------------------------------------------- - lv_obj_t* row5 = makeRow(); - makeHdr(row5, "BPM:"); - makeBtn(row5, "-", 96, cbBpmDown); - char bpmb[8]; snprintf(bpmb, sizeof(bpmb), "%d", rn.bpm); - rn.bpmLbl = makeLbl(row5, bpmb, 176, lv_color_hex(0x00DFFF)); - makeBtn(row5, "+", 96, cbBpmUp); - - makeHdr(row5, " Beat:"); - makeBtn(row5, "<", 96, cbSubdivDown); - rn.subdivLbl = makeLbl(row5, SUBDIV_NAMES[rn.subdivision], 144, lv_color_hex(0x00DFFF)); - makeBtn(row5, ">", 96, cbSubdivUp); - - // -- Note display (fills remaining space) -------------------------------- - lv_obj_t* noteArea = mcContainer(root); - lv_obj_set_width(noteArea, LV_PCT(100)); - lv_obj_set_flex_grow(noteArea, 1); - lv_obj_set_style_bg_color(noteArea, lv_color_hex(0x050508), 0); - lv_obj_set_style_bg_opa(noteArea, LV_OPA_COVER, 0); - lv_obj_set_style_border_width(noteArea, 0, 0); - lv_obj_set_style_pad_all(noteArea, 0, 0); - lv_obj_set_scrollable(noteArea, false); - - rn.noteLbl = lv_label_create(noteArea); - lv_label_set_text(rn.noteLbl, ""); - lv_obj_set_style_text_font(rn.noteLbl, fontLarge, 0); - lv_obj_set_style_text_color(rn.noteLbl, lv_color_hex(0x44FF88), 0); - lv_obj_center(rn.noteLbl); -} diff --git a/Apps/MidiController/main/Source/ViewTb3po.cpp b/Apps/MidiController/main/Source/ViewTb3po.cpp deleted file mode 100644 index 882f32d..0000000 --- a/Apps/MidiController/main/Source/ViewTb3po.cpp +++ /dev/null @@ -1,606 +0,0 @@ -#include "MidiController.h" - -#include -#include -#include -#include -#include -#include -#include - -// --------------------------------------------------------------------------- -// TB3PO view - 303-style acid bassline generator -// -// Layout (flex column): -// [ctrl row: < | PLAY | BPM- | N | BPM+ DENS- | D:N | DENS+ REGEN] -// [canvas flex_grow: 4-row step visualization] -// [status row: KEY- | key | KEY+ SCALE- | scale | SCALE+ Seed: XXXXXXXX] -// --------------------------------------------------------------------------- - - -// --------------------------------------------------------------------------- -// Scale data -// --------------------------------------------------------------------------- - -static constexpr int TB3_NUM_SCALES = 6; -static const char* TB3_SCALE_NAMES[TB3_NUM_SCALES] = { - "Major", "Minor", "Dorian", "Penta", "Blues", "Chrome" -}; -static constexpr int TB3_MAX_SCALE_LEN = 12; -static const int TB3_SCALE_INTERVALS[TB3_NUM_SCALES][TB3_MAX_SCALE_LEN] = { - {0, 2, 4, 5, 7, 9, 11}, // Major (7) - {0, 2, 3, 5, 7, 8, 10}, // Minor (7) - {0, 2, 3, 5, 7, 9, 10}, // Dorian (7) - {0, 2, 4, 7, 9}, // Penta (5) - {0, 3, 5, 6, 7, 10}, // Blues (6) - {0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11}, // Chrome (12) -}; -static const int TB3_SCALE_LENS[TB3_NUM_SCALES] = {7, 7, 7, 5, 6, 12}; - -// KEY_NAMES already defined in MidiScales.h - use it directly - -// Get MIDI note for scale degree + octave (root at MIDI 48 = C3) -static int tb3NoteInScale(int scaleIdx, int degree, int octave, int key) { - if (scaleIdx < 0 || scaleIdx >= TB3_NUM_SCALES) scaleIdx = 0; - int len = TB3_SCALE_LENS[scaleIdx]; - // Proper modular arithmetic for negative degrees - while (degree < 0) degree += len; - int deg = degree % len; - int octShift = degree / len; - int rootMidi = 48 + key; // C3 + key offset - int note = rootMidi + TB3_SCALE_INTERVALS[scaleIdx][deg] + (octave - 3 + octShift) * 12; - if (note < 0) note = 0; - if (note > 127) note = 127; - return note; -} - -// --------------------------------------------------------------------------- -// Pattern generation -// --------------------------------------------------------------------------- - -static void tb3Regenerate(MidiController::Tb3poState& ts) { - // Use seed-based PRNG - uint32_t rng = ts.seed; - auto rnd = [&](int lo, int hi) -> int { - rng = rng * 1664525u + 1013904223u; - if (hi <= lo) return lo; - return lo + (int)((rng >> 16) % (uint32_t)(hi - lo)); - }; - auto rndBit = [&](int prob) -> bool { - return rnd(0, 100) < prob; - }; - - int scaleIdx = ts.scale; - if (scaleIdx < 0 || scaleIdx >= TB3_NUM_SCALES) scaleIdx = 0; - int scaleLen = TB3_SCALE_LENS[scaleIdx]; - int pitchDens = std::min(ts.density, 8); - int available; - if (pitchDens > 7) available = scaleLen - 1; - else if (pitchDens < 2) available = pitchDens; - else { - int fromScale = std::max(4, scaleLen - 3); - available = 3 + (pitchDens - 3) * fromScale / 4; - available = std::max(1, std::min(scaleLen - 1, available)); - } - - // Generate pitches, oct flags - use direct bit position (1u << i) to avoid inversion - uint32_t oct_ups = 0, oct_downs = 0; - int noteDegs[32] = {}; - for (int i = 0; i < 32; i++) { - int forceRepeatProb = 50 - (pitchDens * 6); - if (i > 0 && rndBit(forceRepeatProb)) { - noteDegs[i] = noteDegs[i-1]; - } else { - noteDegs[i] = rnd(0, available + 1); - if (rndBit(40)) { - if (rndBit(50)) oct_ups |= (1u << i); - else oct_downs |= (1u << i); - } - } - } - - // Generate gates, slides, accents - direct bit position, no shift accumulation - rng = ts.seed + 1000u; - // Gate probability rises monotonically with density (10% at 0, 90% at 16). - int densProb = 10 + ts.density * 5; - uint32_t gates = 0, slides = 0, accents = 0; - int latestSlide = 0, latestAccent = 0; - for (int i = 0; i < 32; i++) { - if (rndBit(densProb)) gates |= (1u << i); - latestSlide = rndBit(latestSlide ? 10 : 18); - if (latestSlide) slides |= (1u << i); - latestAccent = rndBit(latestAccent ? 7 : 16); - if (latestAccent) accents |= (1u << i); - } - - // Fill steps array - for (int i = 0; i < 32; i++) { - auto& s = ts.steps[i]; - int scaleDeg = noteDegs[i]; - bool octUp = (oct_ups & (1u << i)) != 0; - bool octDown = (oct_downs & (1u << i)) != 0; - s.note = scaleDeg; // scale degree (resolved to MIDI in timer) - s.oct_up = octUp; - s.oct_down = octDown; - s.accent = (accents & (1u << i)) != 0; - s.slide = (slides & (1u << i)) != 0; - s.gate = (gates & (1u << i)) != 0; - } -} - -static int tb3StepToMidi(const MidiController::Tb3poState& ts, int stepIdx) { - const auto& s = ts.steps[stepIdx]; - int octOff = ts.octave + (s.oct_up ? 1 : (s.oct_down ? -1 : 0)); - return tb3NoteInScale(ts.scale, s.note, octOff, ts.key); -} - -// --------------------------------------------------------------------------- -// Canvas draw (called from timer + after regen) -// --------------------------------------------------------------------------- - -static void tb3DrawCanvas(MidiController::Tb3poState& ts) { - if (!ts.canvas) return; - lv_obj_clean(ts.canvas); - - int W = ts.canvasW; - int H = ts.canvasH; - if (W <= 0 || H <= 0) return; - - int stepW = W / 32; - int rowH = H / 4; - - for (int i = 0; i < 32; i++) { - const auto& s = ts.steps[i]; - int x = i * stepW; - bool isCurrent = (ts.playing && i == ts.currentStep); - - // Row 0: Note name - { - lv_obj_t* cell = mcContainer(ts.canvas); - lv_obj_set_pos(cell, x, 0); - lv_obj_set_size(cell, stepW - 1, rowH - 1); - lv_obj_set_style_border_width(cell, 0, 0); - lv_obj_set_style_pad_all(cell, 0, 0); - lv_obj_set_scrollable(cell, false); - uint32_t bg = isCurrent ? 0x44BBFF : 0x1A2030; - lv_obj_set_style_bg_color(cell, lv_color_hex(bg), 0); - lv_obj_set_style_bg_opa(cell, LV_OPA_COVER, 0); - - lv_obj_t* lbl = lv_label_create(cell); - int midi = tb3StepToMidi(ts, i); - static const char* NOTES[12] = {"C","C#","D","D#","E","F","F#","G","G#","A","A#","B"}; - lv_label_set_text(lbl, NOTES[midi % 12]); - lv_obj_set_style_text_color(lbl, lv_color_hex(isCurrent ? 0x000000 : 0xDDDDDD), 0); - lv_obj_center(lbl); - } - // Row 1: Oct up/down - { - lv_obj_t* cell = mcContainer(ts.canvas); - lv_obj_set_pos(cell, x, rowH); - lv_obj_set_size(cell, stepW - 1, rowH - 1); - lv_obj_set_style_border_width(cell, 0, 0); - lv_obj_set_style_pad_all(cell, 0, 0); - lv_obj_set_scrollable(cell, false); - uint32_t bg = s.oct_up ? 0x006622 : (s.oct_down ? 0x662200 : 0x111820); - lv_obj_set_style_bg_color(cell, lv_color_hex(bg), 0); - lv_obj_set_style_bg_opa(cell, LV_OPA_COVER, 0); - if (s.oct_up || s.oct_down) { - lv_obj_t* lbl = lv_label_create(cell); - lv_label_set_text(lbl, s.oct_up ? "+" : "-"); - lv_obj_set_style_text_color(lbl, lv_color_hex(0xFFFFFF), 0); - lv_obj_center(lbl); - } - } - // Row 2: Accent / Slide - { - lv_obj_t* cell = mcContainer(ts.canvas); - lv_obj_set_pos(cell, x, rowH * 2); - lv_obj_set_size(cell, stepW - 1, rowH - 1); - lv_obj_set_style_border_width(cell, 0, 0); - lv_obj_set_style_pad_all(cell, 0, 0); - lv_obj_set_scrollable(cell, false); - uint32_t bg = s.accent ? 0x886600 : (s.slide ? 0x004488 : 0x111820); - lv_obj_set_style_bg_color(cell, lv_color_hex(bg), 0); - lv_obj_set_style_bg_opa(cell, LV_OPA_COVER, 0); - if (s.accent || s.slide) { - lv_obj_t* lbl = lv_label_create(cell); - lv_label_set_text(lbl, (s.accent && s.slide) ? "AS" : (s.accent ? "A" : "S")); - lv_obj_set_style_text_color(lbl, lv_color_hex(0xFFFFFF), 0); - lv_obj_center(lbl); - } - } - // Row 3: Gate dot - { - lv_obj_t* cell = mcContainer(ts.canvas); - lv_obj_set_pos(cell, x, rowH * 3); - lv_obj_set_size(cell, stepW - 1, rowH - 1); - lv_obj_set_style_border_width(cell, 0, 0); - lv_obj_set_style_pad_all(cell, 0, 0); - lv_obj_set_scrollable(cell, false); - lv_obj_set_style_bg_color(cell, lv_color_hex(0x111820), 0); - lv_obj_set_style_bg_opa(cell, LV_OPA_COVER, 0); - if (s.gate) { - lv_obj_t* dot = mcContainer(cell); - int dotSz = std::min(stepW - 4, rowH - 4); - lv_obj_set_size(dot, dotSz, dotSz); - lv_obj_set_style_radius(dot, LV_RADIUS_CIRCLE, 0); - lv_obj_set_style_bg_color(dot, lv_color_hex(0x0088FF), 0); - lv_obj_set_style_bg_opa(dot, LV_OPA_COVER, 0); - lv_obj_set_style_border_width(dot, 0, 0); - lv_obj_set_scrollable(dot, false); - lv_obj_center(dot); - } - } - } -} - -// --------------------------------------------------------------------------- -// Timer callback -// --------------------------------------------------------------------------- - -static void cbTb3poTimer(lv_timer_t* t) { - auto* mc = static_cast(lv_timer_get_user_data(t)); - if (!mc) return; - auto& ts = mc->tb3po_; - if (!ts.playing) return; - - uint32_t now = lv_tick_get(); - - // Note-off. A slide flag on the step that owns lastNote holds it into the - // next step, so suppress the normal 90%-gate Note Off until that slide ends. - if (ts.lastNote >= 0 && now >= ts.noteOffMs) { - bool heldBySlide = ts.lastNoteStep >= 0 && ts.steps[ts.lastNoteStep].slide; - if (!heldBySlide) { - mc->sendMidi(0x80, (uint8_t)ts.lastNote, 0); - ts.lastNote = -1; - ts.lastNoteStep = -1; - } - } - - if (now < ts.nextStepMs) return; - - // Advance step - ts.currentStep = (ts.currentStep + 1) % 32; - const auto& s = ts.steps[ts.currentStep]; - - // BPM → 16th note duration - uint32_t stepMs = 60000u / (uint32_t)ts.bpm / 4u; - if (stepMs < 20) stepMs = 20; - ts.nextStepMs = now + stepMs; - - if (s.gate) { - int note = tb3StepToMidi(ts, ts.currentStep); - int prevStep = (ts.currentStep + 31) % 32; - bool incomingSlide = ts.steps[prevStep].slide; - int oldNote = ts.lastNote; - int vel = s.accent ? 127 : 100; - - if (oldNote < 0) { - mc->sendMidi(0x90, (uint8_t)note, (uint8_t)vel); - } else if (incomingSlide && oldNote != note) { - // Start the new pitch while the old one is still on so a mono synth - // hears a legato transition, then release the old pitch. This also - // prevents a poly synth from leaving the old note hanging. - mc->sendMidi(0x90, (uint8_t)note, (uint8_t)vel); - mc->sendMidi(0x80, (uint8_t)oldNote, 0); - } else if (!incomingSlide) { - mc->sendMidi(0x80, (uint8_t)oldNote, 0); - mc->sendMidi(0x90, (uint8_t)note, (uint8_t)vel); - } else { - // Same pitch: retrigger without an intervening Note Off. - mc->sendMidi(0x90, (uint8_t)note, (uint8_t)vel); - } - - ts.lastNote = note; - ts.lastNoteStep = ts.currentStep; - ts.noteOffMs = now + stepMs * 9 / 10; // 90% gate - } else { - if (ts.lastNote >= 0) { - mc->sendMidi(0x80, (uint8_t)ts.lastNote, 0); - ts.lastNote = -1; - ts.lastNoteStep = -1; - } - } - - tb3DrawCanvas(ts); -} - -// --------------------------------------------------------------------------- -// Callbacks -// --------------------------------------------------------------------------- - -static void cbTb3Back(lv_event_t* e) { - auto* mc = static_cast(lv_event_get_user_data(e)); - if (mc) mc->switchToMode(MidiMode::HOME); -} - -static void cbTb3Play(lv_event_t* e) { - auto* mc = static_cast(lv_event_get_user_data(e)); - if (!mc) return; - auto& ts = mc->tb3po_; - ts.playing = !ts.playing; - if (!ts.playing && ts.lastNote >= 0) { - mc->sendMidi(0x80, (uint8_t)ts.lastNote, 0); - ts.lastNote = -1; - ts.lastNoteStep = -1; - } - if (ts.playing) { - ts.currentStep = 31; // will advance to 0 on first tick - ts.nextStepMs = lv_tick_get(); - } - if (ts.playLbl) lv_label_set_text(ts.playLbl, ts.playing ? "STOP" : "PLAY"); -} - -static void cbTb3BpmDown(lv_event_t* e) { - auto* mc = static_cast(lv_event_get_user_data(e)); - if (!mc) return; - auto& ts = mc->tb3po_; - if (ts.bpm > 40) ts.bpm -= 5; - if (ts.bpmLbl) { - char buf[8]; snprintf(buf, sizeof(buf), "%d", ts.bpm); - lv_label_set_text(ts.bpmLbl, buf); - } -} -static void cbTb3BpmUp(lv_event_t* e) { - auto* mc = static_cast(lv_event_get_user_data(e)); - if (!mc) return; - auto& ts = mc->tb3po_; - if (ts.bpm < 280) ts.bpm += 5; - if (ts.bpmLbl) { - char buf[8]; snprintf(buf, sizeof(buf), "%d", ts.bpm); - lv_label_set_text(ts.bpmLbl, buf); - } -} - -static void cbTb3DensDown(lv_event_t* e) { - auto* mc = static_cast(lv_event_get_user_data(e)); - if (!mc) return; - auto& ts = mc->tb3po_; - if (ts.density > 0) ts.density--; - ts.seed = (uint32_t)esp_random(); - tb3Regenerate(ts); - if (ts.densityLbl) { - char buf[6]; snprintf(buf, sizeof(buf), "D:%d", ts.density); - lv_label_set_text(ts.densityLbl, buf); - } - if (ts.seedLbl) { - char buf[16]; snprintf(buf, sizeof(buf), "%08lX", (unsigned long)ts.seed); - lv_label_set_text(ts.seedLbl, buf); - } - tb3DrawCanvas(ts); -} -static void cbTb3DensUp(lv_event_t* e) { - auto* mc = static_cast(lv_event_get_user_data(e)); - if (!mc) return; - auto& ts = mc->tb3po_; - if (ts.density < 16) ts.density++; - ts.seed = (uint32_t)esp_random(); - tb3Regenerate(ts); - if (ts.densityLbl) { - char buf[6]; snprintf(buf, sizeof(buf), "D:%d", ts.density); - lv_label_set_text(ts.densityLbl, buf); - } - if (ts.seedLbl) { - char buf[16]; snprintf(buf, sizeof(buf), "%08lX", (unsigned long)ts.seed); - lv_label_set_text(ts.seedLbl, buf); - } - tb3DrawCanvas(ts); -} - -static void cbTb3Regen(lv_event_t* e) { - auto* mc = static_cast(lv_event_get_user_data(e)); - if (!mc) return; - auto& ts = mc->tb3po_; - ts.seed = (uint32_t)esp_random(); - tb3Regenerate(ts); - if (ts.seedLbl) { - char buf[16]; snprintf(buf, sizeof(buf), "%08lX", (unsigned long)ts.seed); - lv_label_set_text(ts.seedLbl, buf); - } - tb3DrawCanvas(ts); -} - -static void cbTb3KeyDown(lv_event_t* e) { - auto* mc = static_cast(lv_event_get_user_data(e)); - if (!mc) return; - auto& ts = mc->tb3po_; - ts.key = (ts.key + 11) % 12; - tb3Regenerate(ts); - if (ts.keyLbl) lv_label_set_text(ts.keyLbl, KEY_NAMES[ts.key]); - tb3DrawCanvas(ts); -} -static void cbTb3KeyUp(lv_event_t* e) { - auto* mc = static_cast(lv_event_get_user_data(e)); - if (!mc) return; - auto& ts = mc->tb3po_; - ts.key = (ts.key + 1) % 12; - tb3Regenerate(ts); - if (ts.keyLbl) lv_label_set_text(ts.keyLbl, KEY_NAMES[ts.key]); - tb3DrawCanvas(ts); -} - -static void cbTb3ScaleDown(lv_event_t* e) { - auto* mc = static_cast(lv_event_get_user_data(e)); - if (!mc) return; - auto& ts = mc->tb3po_; - ts.scale = (ts.scale + TB3_NUM_SCALES - 1) % TB3_NUM_SCALES; - tb3Regenerate(ts); - if (ts.scaleLbl) lv_label_set_text(ts.scaleLbl, TB3_SCALE_NAMES[ts.scale]); - tb3DrawCanvas(ts); -} -static void cbTb3ScaleUp(lv_event_t* e) { - auto* mc = static_cast(lv_event_get_user_data(e)); - if (!mc) return; - auto& ts = mc->tb3po_; - ts.scale = (ts.scale + 1) % TB3_NUM_SCALES; - tb3Regenerate(ts); - if (ts.scaleLbl) lv_label_set_text(ts.scaleLbl, TB3_SCALE_NAMES[ts.scale]); - tb3DrawCanvas(ts); -} - -// --------------------------------------------------------------------------- -// Build -// --------------------------------------------------------------------------- - -void buildTb3poView(MidiController* mc, lv_obj_t* parent) { - auto& ts = mc->tb3po_; - - const lv_font_t* fontSmall = lvgl_get_text_font(FONT_SIZE_SMALL); - - int rowH = 88; - int sBtnW = 88; - - lv_obj_t* root = mcContainer(parent); - lv_obj_set_size(root, LV_PCT(100), LV_PCT(100)); - lv_obj_set_flex_flow(root, LV_FLEX_FLOW_COLUMN); - lv_obj_set_style_bg_color(root, lv_color_hex(0x060A0F), 0); - lv_obj_set_style_bg_opa(root, LV_OPA_COVER, 0); - lv_obj_set_style_border_width(root, 0, 0); - lv_obj_set_style_pad_all(root, 0, 0); - lv_obj_set_style_pad_gap(root, 8, 0); - lv_obj_set_scrollable(root, false); - mc->currentViewRoot_ = root; - - auto makeRow = [&]() -> lv_obj_t* { return mcRow(root); }; - - auto makeBtn = [&](lv_obj_t* par, const char* txt, int w, lv_event_cb_t cb, void* ud) -> lv_obj_t* { - return mcSmallBtn(par, txt, w, cb, ud); - }; - - auto makeLbl = [&](lv_obj_t* par, const char* txt, int w, lv_color_t col) -> lv_obj_t* { - return mcValue(par, txt, w, col); - }; - - auto makeSep = [&](lv_obj_t* par) { - lv_obj_t* sep = lv_label_create(par); - lv_label_set_text(sep, "|"); - lv_obj_set_style_text_font(sep, fontSmall, 0); - lv_obj_set_style_text_color(sep, lv_color_hex(0x334455), 0); - }; - - // -- Control row ----------------------------------------------------------- - { - lv_obj_t* row = makeRow(); - mcBackBtn(row, cbTb3Back, mc, MC_W_S); - lv_obj_t* playBtn = makeBtn(row, ts.playing ? "STOP" : "PLAY", 176, cbTb3Play, mc); - ts.playLbl = lv_obj_get_child(playBtn, 0); - makeSep(row); - makeBtn(row, "<", sBtnW, cbTb3BpmDown, mc); - char bpmBuf[8]; snprintf(bpmBuf, sizeof(bpmBuf), "%d", ts.bpm); - ts.bpmLbl = makeLbl(row, bpmBuf, 136, lv_color_hex(0xFFE000)); - makeBtn(row, ">", sBtnW, cbTb3BpmUp, mc); - makeSep(row); - makeBtn(row, "<", sBtnW, cbTb3DensDown, mc); - char densBuf[6]; snprintf(densBuf, sizeof(densBuf), "D:%d", ts.density); - ts.densityLbl = makeLbl(row, densBuf, 112, lv_color_hex(0xFF8844)); - makeBtn(row, ">", sBtnW, cbTb3DensUp, mc); - makeSep(row); - makeBtn(row, "REGEN", 200, cbTb3Regen, mc); - } - - // -- Canvas (flex_grow) ---------------------------------------------------- - { - lv_obj_t* canvasArea = mcContainer(root); - lv_obj_set_width(canvasArea, LV_PCT(100)); - lv_obj_set_flex_grow(canvasArea, 1); - lv_obj_set_style_bg_color(canvasArea, lv_color_hex(0x050810), 0); - lv_obj_set_style_bg_opa(canvasArea, LV_OPA_COVER, 0); - lv_obj_set_style_border_width(canvasArea, 0, 0); - lv_obj_set_style_pad_all(canvasArea, 0, 0); - lv_obj_set_scrollable(canvasArea, false); - ts.canvas = canvasArea; - - // Estimates for the first draw, before LVGL has resolved flex sizes. - // LV_EVENT_SIZE_CHANGED replaces these with the real canvas dimensions. - ts.canvasW = lv_display_get_horizontal_resolution(nullptr); - ts.canvasH = lv_display_get_vertical_resolution(nullptr) - rowH * 2 - 32; - if (ts.canvasH < 240) ts.canvasH = 240; - } - - // -- Status row: KEY SCALE Seed ----------------------------------------- - { - lv_obj_t* row = makeRow(); - makeBtn(row, "<", sBtnW, cbTb3KeyDown, mc); - ts.keyLbl = makeLbl(row, KEY_NAMES[ts.key], 88, lv_color_hex(0x88DDFF)); - makeBtn(row, ">", sBtnW, cbTb3KeyUp, mc); - makeSep(row); - makeBtn(row, "<", sBtnW, cbTb3ScaleDown, mc); - ts.scaleLbl = makeLbl(row, TB3_SCALE_NAMES[ts.scale], 176, lv_color_hex(0xAAFF88)); - makeBtn(row, ">", sBtnW, cbTb3ScaleUp, mc); - makeSep(row); - lv_obj_t* seedHdr = lv_label_create(row); - lv_label_set_text(seedHdr, "Seed:"); - lv_obj_set_style_text_font(seedHdr, fontSmall, 0); - lv_obj_set_style_text_color(seedHdr, lv_color_hex(0x666688), 0); - char seedBuf[16]; snprintf(seedBuf, sizeof(seedBuf), "%08lX", (unsigned long)ts.seed); - ts.seedLbl = makeLbl(row, seedBuf, 256, lv_color_hex(0x8888AA)); - } - - // -- Initial pattern + draw ------------------------------------------------- - if (ts.seed == 0) ts.seed = (uint32_t)esp_random(); - tb3Regenerate(ts); - - // Start timer - mc->tb3poTimer_ = lv_timer_create(cbTb3poTimer, 10, mc); - ts.nextStepMs = lv_tick_get(); - - // Draw immediately with the estimated size, then redraw once the layout pass - // has given the canvas its real dimensions. Without this the four cell rows - // can extend past the bottom of the content area and get clipped. - tb3DrawCanvas(ts); - lv_obj_add_event_cb(ts.canvas, [](lv_event_t* ev) { - auto* mc2 = static_cast(lv_event_get_user_data(ev)); - if (!mc2) return; - auto& ts2 = mc2->tb3po_; - int w = lv_obj_get_width(ts2.canvas); - int h = lv_obj_get_height(ts2.canvas); - if (w > 32 && h > 32) { - ts2.canvasW = w; - ts2.canvasH = h; - tb3DrawCanvas(ts2); - } - }, LV_EVENT_SIZE_CHANGED, mc); -} - -// --------------------------------------------------------------------------- -// Public encoder entry points (called from EncoderInput.cpp) -// --------------------------------------------------------------------------- - -void tb3TogglePlay(MidiController* mc) { - auto& ts = mc->tb3po_; - ts.playing = !ts.playing; - if (!ts.playing && ts.lastNote >= 0) { - mc->sendMidi(0x80, (uint8_t)ts.lastNote, 0); - ts.lastNote = -1; - ts.lastNoteStep = -1; - } - if (ts.playing) { - ts.currentStep = 31; - ts.nextStepMs = lv_tick_get(); - } - if (ts.playLbl) lv_label_set_text(ts.playLbl, ts.playing ? "STOP" : "PLAY"); -} - -void tb3ChangeScale(MidiController* mc, int delta) { - auto& ts = mc->tb3po_; - static constexpr int NUM_SCALES = 6; - ts.scale = ((ts.scale + delta) % NUM_SCALES + NUM_SCALES) % NUM_SCALES; - tb3Regenerate(ts); - if (ts.scaleLbl) lv_label_set_text(ts.scaleLbl, TB3_SCALE_NAMES[ts.scale]); - tb3DrawCanvas(ts); -} - -void tb3Regen(MidiController* mc) { - auto& ts = mc->tb3po_; - ts.seed = (uint32_t)esp_random(); - tb3Regenerate(ts); - if (ts.seedLbl) { - char buf[16]; snprintf(buf, sizeof(buf), "%08lX", (unsigned long)ts.seed); - lv_label_set_text(ts.seedLbl, buf); - } - tb3DrawCanvas(ts); -} - -void tb3Redraw(MidiController* mc) { - if (mc) tb3DrawCanvas(mc->tb3po_); -} diff --git a/Apps/MidiController/main/Source/ViewXYPad.cpp b/Apps/MidiController/main/Source/ViewXYPad.cpp deleted file mode 100644 index e1e07a2..0000000 --- a/Apps/MidiController/main/Source/ViewXYPad.cpp +++ /dev/null @@ -1,310 +0,0 @@ -#include "MidiController.h" - -#include -#include -#include - -// --------------------------------------------------------------------------- -// XY PAD view - 2D touch pad for MIDI CC modulation -// -// Layout (flex column): -// [header: < | XY PAD | X CC -/val/+ | | Y CC -/val/+] -// [touch area - flex_grow, LV_EVENT_DRAW_MAIN] -// [value readout footer] -// --------------------------------------------------------------------------- - - -static constexpr int XY_IND_RADIUS = 32; // indicator circle radius (pixels) - -// --------------------------------------------------------------------------- -// Draw event - renders BG, crosshair, indicator circle -// --------------------------------------------------------------------------- - -static void cbXYDraw(lv_event_t* e) { - auto* mc = static_cast(lv_event_get_user_data(e)); - if (!mc) return; - - lv_obj_t* obj = lv_event_get_target_obj(e); - lv_layer_t* layer = lv_event_get_layer(e); - - lv_area_t area; - lv_obj_get_coords(obj, &area); - int32_t ox = area.x1, oy = area.y1; - int32_t cW = lv_area_get_width(&area); - int32_t cH = lv_area_get_height(&area); - - // Background - lv_draw_fill_dsc_t bg; - lv_draw_fill_dsc_init(&bg); - bg.color = lv_color_hex(0x080C12); - bg.opa = LV_OPA_COVER; - lv_draw_fill(layer, &bg, &area); - - // Crosshair lines - lv_draw_line_dsc_t line; - lv_draw_line_dsc_init(&line); - line.color = lv_color_hex(0x1A3040); - line.width = 1; - line.opa = LV_OPA_COVER; - - // Horizontal - line.p1.x = (lv_value_precise_t)ox; - line.p1.y = (lv_value_precise_t)(oy + cH / 2); - line.p2.x = (lv_value_precise_t)(ox + cW - 1); - line.p2.y = (lv_value_precise_t)(oy + cH / 2); - lv_draw_line(layer, &line); - - // Vertical - line.p1.x = (lv_value_precise_t)(ox + cW / 2); - line.p1.y = (lv_value_precise_t)oy; - line.p2.x = (lv_value_precise_t)(ox + cW / 2); - line.p2.y = (lv_value_precise_t)(oy + cH - 1); - lv_draw_line(layer, &line); - -} - -static void updateValLabel(MidiController* mc) { - if (!mc->xy_.valLbl) return; - char buf[24]; - snprintf(buf, sizeof(buf), "X:%3d Y:%3d", mc->xy_.xVal, mc->xy_.yVal); - lv_label_set_text(mc->xy_.valLbl, buf); -} - -// --------------------------------------------------------------------------- -// Touch input on canvas -// --------------------------------------------------------------------------- - -static void cbCanvasTouch(lv_event_t* e) { - auto* mc = static_cast(lv_event_get_user_data(e)); - if (!mc) return; - auto& xy = mc->xy_; - if (xy.canvasW <= 1 || xy.canvasH <= 1) return; - - lv_indev_t* indev = lv_event_get_indev(e); - if (!indev) return; - lv_point_t pt; - lv_indev_get_point(indev, &pt); - - lv_area_t area; - lv_obj_get_coords(xy.canvas, &area); - int lx = (int)pt.x - (int)area.x1; - int ly = (int)pt.y - (int)area.y1; - lx = lx < 0 ? 0 : (lx >= xy.canvasW ? xy.canvasW - 1 : lx); - ly = ly < 0 ? 0 : (ly >= xy.canvasH ? xy.canvasH - 1 : ly); - - int newX = (lx * 127) / (xy.canvasW - 1); - int newY = 127 - (ly * 127) / (xy.canvasH - 1); - - bool changed = (newX != xy.xVal) || (newY != xy.yVal); - if (!changed) return; - - xy.xVal = newX; - xy.yVal = newY; - - mc->sendMidi(0xB0, (uint8_t)xy.xCC, (uint8_t)xy.xVal); - mc->sendMidi(0xB0, (uint8_t)xy.yCC, (uint8_t)xy.yVal); - - // Move indicator widget immediately - if (xy.indicator && xy.canvasW > 0 && xy.canvasH > 0) { - int indR = XY_IND_RADIUS; - int px = (xy.xVal * (xy.canvasW - 1)) / 127; - int py = ((127 - xy.yVal) * (xy.canvasH - 1)) / 127; - lv_obj_set_pos(xy.indicator, (lv_coord_t)(px - indR), (lv_coord_t)(py - indR)); - } - - lv_obj_invalidate(xy.canvas); - updateValLabel(mc); -} - -static void cbCanvasSize(lv_event_t* e) { - auto* mc = static_cast(lv_event_get_user_data(e)); - if (!mc) return; - auto& xy = mc->xy_; - lv_obj_t* obj = lv_event_get_target_obj(e); - xy.canvasW = (int)lv_obj_get_content_width(obj); - xy.canvasH = (int)lv_obj_get_content_height(obj); - // Place indicator at current value position - if (xy.indicator && xy.canvasW > 0 && xy.canvasH > 0) { - int indR = XY_IND_RADIUS; - int px = (xy.xVal * (xy.canvasW - 1)) / 127; - int py = ((127 - xy.yVal) * (xy.canvasH - 1)) / 127; - lv_obj_set_pos(xy.indicator, (lv_coord_t)(px - indR), (lv_coord_t)(py - indR)); - } -} - -// --------------------------------------------------------------------------- -// CC control callbacks -// --------------------------------------------------------------------------- - -static void cbBack(lv_event_t* e) { - auto* mc = static_cast(lv_event_get_user_data(e)); - if (mc) mc->switchToMode(MidiMode::HOME); -} - -static void cbXCCDown(lv_event_t* e) { - auto* mc = static_cast(lv_event_get_user_data(e)); - if (!mc) return; - mc->xy_.xCC = (mc->xy_.xCC - 1 + 128) % 128; - if (mc->xy_.xCCLbl) { - char buf[12]; snprintf(buf, sizeof(buf), "X CC:%d", mc->xy_.xCC); - lv_label_set_text(mc->xy_.xCCLbl, buf); - } -} -static void cbXCCUp(lv_event_t* e) { - auto* mc = static_cast(lv_event_get_user_data(e)); - if (!mc) return; - mc->xy_.xCC = (mc->xy_.xCC + 1) % 128; - if (mc->xy_.xCCLbl) { - char buf[12]; snprintf(buf, sizeof(buf), "X CC:%d", mc->xy_.xCC); - lv_label_set_text(mc->xy_.xCCLbl, buf); - } -} -static void cbYCCDown(lv_event_t* e) { - auto* mc = static_cast(lv_event_get_user_data(e)); - if (!mc) return; - mc->xy_.yCC = (mc->xy_.yCC - 1 + 128) % 128; - if (mc->xy_.yCCLbl) { - char buf[12]; snprintf(buf, sizeof(buf), "Y CC:%d", mc->xy_.yCC); - lv_label_set_text(mc->xy_.yCCLbl, buf); - } -} -static void cbYCCUp(lv_event_t* e) { - auto* mc = static_cast(lv_event_get_user_data(e)); - if (!mc) return; - mc->xy_.yCC = (mc->xy_.yCC + 1) % 128; - if (mc->xy_.yCCLbl) { - char buf[12]; snprintf(buf, sizeof(buf), "Y CC:%d", mc->xy_.yCC); - lv_label_set_text(mc->xy_.yCCLbl, buf); - } -} - -// --------------------------------------------------------------------------- -// Build function -// --------------------------------------------------------------------------- - -void buildXYPadView(MidiController* mc, lv_obj_t* parent) { - auto& xy = mc->xy_; - - const lv_font_t* fontSmall = lvgl_get_text_font(FONT_SIZE_SMALL); - const lv_font_t* fontNormal = lvgl_get_text_font(FONT_SIZE_DEFAULT); - - int hdrH = 88; - int footH = 64; - - lv_obj_t* root = mcContainer(parent); - lv_obj_set_size(root, LV_PCT(100), LV_PCT(100)); - lv_obj_set_flex_flow(root, LV_FLEX_FLOW_COLUMN); - lv_obj_set_style_bg_color(root, lv_color_hex(0x080C12), 0); - lv_obj_set_style_bg_opa(root, LV_OPA_COVER, 0); - lv_obj_set_style_border_width(root, 0, 0); - lv_obj_set_style_pad_all(root, 0, 0); - lv_obj_set_style_pad_gap(root, 0, 0); - lv_obj_set_scrollable(root, false); - mc->currentViewRoot_ = root; - - // -- Header ---------------------------------------------------------------- - lv_obj_t* hdr = mcRow(root, hdrH); - - int btnH = MC_BTN_H; - - mcBackBtn(hdr, cbBack, mc, MC_W_S); - - lv_obj_t* titleLbl = lv_label_create(hdr); - lv_label_set_text(titleLbl, "XY PAD"); - lv_obj_set_style_text_font(titleLbl, fontNormal, 0); - lv_obj_set_style_text_color(titleLbl, lv_color_hex(0x00DFFF), 0); - lv_obj_set_flex_grow(titleLbl, 1); - - // X CC controls - lv_obj_t* xDown = mcButton(hdr); - lv_obj_set_size(xDown, 72, btnH); - lv_obj_add_event_cb(xDown, cbXCCDown, LV_EVENT_CLICKED, mc); - lv_obj_t* xdl = lv_label_create(xDown); lv_label_set_text(xdl, "-"); - lv_obj_set_style_text_font(xdl, fontSmall, 0); lv_obj_center(xdl); - - xy.xCCLbl = lv_label_create(hdr); - char xbuf[12]; snprintf(xbuf, sizeof(xbuf), "X CC:%d", xy.xCC); - lv_label_set_text(xy.xCCLbl, xbuf); - lv_obj_set_style_text_font(xy.xCCLbl, fontSmall, 0); - lv_obj_set_style_text_color(xy.xCCLbl, lv_color_hex(0x00DFFF), 0); - lv_obj_set_width(xy.xCCLbl, 128); - lv_obj_set_style_text_align(xy.xCCLbl, LV_TEXT_ALIGN_CENTER, 0); - - lv_obj_t* xUp = mcButton(hdr); - lv_obj_set_size(xUp, 72, btnH); - lv_obj_add_event_cb(xUp, cbXCCUp, LV_EVENT_CLICKED, mc); - lv_obj_t* xul = lv_label_create(xUp); lv_label_set_text(xul, "+"); - lv_obj_set_style_text_font(xul, fontSmall, 0); lv_obj_center(xul); - - lv_obj_t* divLbl = lv_label_create(hdr); - lv_label_set_text(divLbl, "|"); - lv_obj_set_style_text_color(divLbl, lv_color_hex(0x334455), 0); - - // Y CC controls - lv_obj_t* yDown = mcButton(hdr); - lv_obj_set_size(yDown, 72, btnH); - lv_obj_add_event_cb(yDown, cbYCCDown, LV_EVENT_CLICKED, mc); - lv_obj_t* ydl = lv_label_create(yDown); lv_label_set_text(ydl, "-"); - lv_obj_set_style_text_font(ydl, fontSmall, 0); lv_obj_center(ydl); - - xy.yCCLbl = lv_label_create(hdr); - char ybuf[12]; snprintf(ybuf, sizeof(ybuf), "Y CC:%d", xy.yCC); - lv_label_set_text(xy.yCCLbl, ybuf); - lv_obj_set_style_text_font(xy.yCCLbl, fontSmall, 0); - lv_obj_set_style_text_color(xy.yCCLbl, lv_color_hex(0xFF8000), 0); - lv_obj_set_width(xy.yCCLbl, 128); - lv_obj_set_style_text_align(xy.yCCLbl, LV_TEXT_ALIGN_CENTER, 0); - - lv_obj_t* yUp = mcButton(hdr); - lv_obj_set_size(yUp, 72, btnH); - lv_obj_add_event_cb(yUp, cbYCCUp, LV_EVENT_CLICKED, mc); - lv_obj_t* yul = lv_label_create(yUp); lv_label_set_text(yul, "+"); - lv_obj_set_style_text_font(yul, fontSmall, 0); lv_obj_center(yul); - - // -- Touch/Draw area ------------------------------------------------------- - lv_obj_t* padObj = mcContainer(root); - lv_obj_set_width(padObj, LV_PCT(100)); - lv_obj_set_flex_grow(padObj, 1); - lv_obj_set_style_bg_color(padObj, lv_color_hex(0x080C12), 0); - lv_obj_set_style_bg_opa(padObj, LV_OPA_COVER, 0); - lv_obj_set_style_border_width(padObj, 0, 0); - lv_obj_set_style_pad_all(padObj, 0, 0); - lv_obj_set_scrollable(padObj, false); - lv_obj_set_clickable(padObj, true); - lv_obj_add_event_cb(padObj, cbXYDraw, LV_EVENT_DRAW_MAIN, mc); - lv_obj_add_event_cb(padObj, cbCanvasSize, LV_EVENT_SIZE_CHANGED, mc); - lv_obj_add_event_cb(padObj, cbCanvasTouch, LV_EVENT_PRESSING, mc); - xy.canvas = padObj; - - // Indicator circle widget (positioned absolutely within canvas) - int indR = XY_IND_RADIUS; - lv_obj_t* ind = mcContainer(padObj); - lv_obj_set_size(ind, indR * 2, indR * 2); - lv_obj_set_style_radius(ind, LV_RADIUS_CIRCLE, 0); - lv_obj_set_style_bg_color(ind, lv_color_hex(0x00DFFF), 0); - lv_obj_set_style_bg_opa(ind, LV_OPA_COVER, 0); - lv_obj_set_style_border_color(ind, lv_color_hex(0xFFFFFF), 0); - lv_obj_set_style_border_width(ind, 4, 0); - lv_obj_set_style_pad_all(ind, 0, 0); - lv_obj_set_scrollable(ind, false); - lv_obj_set_clickable(ind, false); - lv_obj_set_pos(ind, 0, 0); // will be updated on first draw/touch - xy.indicator = ind; - - // -- Footer: value readout ------------------------------------------------ - lv_obj_t* footer = mcContainer(root); - lv_obj_set_size(footer, LV_PCT(100), footH); - lv_obj_set_style_bg_color(footer, lv_color_hex(0x0A0C14), 0); - lv_obj_set_style_bg_opa(footer, LV_OPA_COVER, 0); - lv_obj_set_style_border_width(footer, 0, 0); - lv_obj_set_style_pad_all(footer, 8, 0); - lv_obj_set_scrollable(footer, false); - - xy.valLbl = lv_label_create(footer); - lv_label_set_text(xy.valLbl, "X: 64 Y: 64"); - lv_obj_set_style_text_font(xy.valLbl, fontSmall, 0); - lv_obj_set_style_text_color(xy.valLbl, lv_color_hex(0x666688), 0); - lv_obj_center(xy.valLbl); - - updateValLabel(mc); -} diff --git a/Apps/MidiController/main/Source/ViewZen.cpp b/Apps/MidiController/main/Source/ViewZen.cpp deleted file mode 100644 index 3e3e979..0000000 --- a/Apps/MidiController/main/Source/ViewZen.cpp +++ /dev/null @@ -1,501 +0,0 @@ -#include "MidiController.h" - -#include -#include -#include -#include -#include -#include - -// --------------------------------------------------------------------------- -// ZEN view - Ambient bouncing ball physics + note triggering -// -// Layout (flex column): -// [physics area - flex_grow, LV_EVENT_DRAW_MAIN] -// [control row: < | ADD | RESET | SCALE | KEY- | KEY+ | OCT] -// --------------------------------------------------------------------------- - - -static constexpr int TOP_SEGS = 8; -static constexpr int BOTTOM_SEGS = 8; -static constexpr int SIDE_SEGS = 4; -static constexpr int TOTAL_WALL_SEGS = TOP_SEGS + BOTTOM_SEGS + SIDE_SEGS * 2; - -struct WallSeg { - float x1, y1, x2, y2; // normalized 0..1 - int noteIdx; - int scaleDegree; - int octaveOffset; -}; - -static void buildWalls(WallSeg walls[]) { - float segW = 1.0f / TOP_SEGS; - for (int i = 0; i < TOP_SEGS; i++) { - walls[i] = { i * segW, 0.0f, (i+1) * segW, 0.0f, i, i, 0 }; - } - for (int i = 0; i < BOTTOM_SEGS; i++) { - walls[TOP_SEGS + i] = { i * segW, 1.0f, (i+1) * segW, 1.0f, - TOP_SEGS + i, BOTTOM_SEGS - 1 - i, 0 }; - } - float segH = 1.0f / SIDE_SEGS; - for (int i = 0; i < SIDE_SEGS; i++) { - int idx = TOP_SEGS + BOTTOM_SEGS + i; - walls[idx] = { 0.0f, i * segH, 0.0f, (i+1) * segH, idx, i, 1 }; - } - for (int i = 0; i < SIDE_SEGS; i++) { - int idx = TOP_SEGS + BOTTOM_SEGS + SIDE_SEGS + i; - walls[idx] = { 1.0f, i * segH, 1.0f, (i+1) * segH, - idx, SIDE_SEGS - 1 - i, 1 }; - } -} - -static const WallSeg* getWalls() { - static WallSeg s_walls[TOTAL_WALL_SEGS]; - static bool s_built = false; - if (!s_built) { - buildWalls(s_walls); - s_built = true; - } - return s_walls; -} - -static constexpr uint32_t ZEN_COLORS[] = { - 0xFF4444, 0xFF8800, 0xFFFF00, 0x44FF44, -}; - -// --------------------------------------------------------------------------- -// Shared first-ball initializer (called from cbCanvasSize and cbZenTimer fallback) -// --------------------------------------------------------------------------- - -// Note: does NOT update ballCntLbl - callers are responsible for label refresh. -// cbZenTimer updates it explicitly; cbCanvasSize relies on buildZenView initializing it to "Balls: x1". -static void initFirstBall(MidiController::ZenState& zn, int w, int h) { - float speedScale = (float)w / 220.0f; - zn.balls[0].x = w * 0.3f; - zn.balls[0].y = h * 0.4f; - zn.balls[0].vx = 2.0f * speedScale; - zn.balls[0].vy = 1.5f * speedScale; - zn.balls[0].radius = 20; - zn.balls[0].color = ZEN_COLORS[0]; - zn.balls[0].active = true; - zn.numBalls = 1; -} - -// --------------------------------------------------------------------------- -// Draw event - draws background and wall segments only; balls are lv_obj_t children -// --------------------------------------------------------------------------- - -static void cbZenDraw(lv_event_t* e) { - auto* mc = static_cast(lv_event_get_user_data(e)); - if (!mc) return; - auto& zn = mc->zen_; - - lv_obj_t* obj = lv_event_get_target_obj(e); - lv_layer_t* layer = lv_event_get_layer(e); - - lv_area_t area; - lv_obj_get_coords(obj, &area); - int32_t ox = area.x1, oy = area.y1; - int32_t cW = lv_area_get_width(&area); - int32_t cH = lv_area_get_height(&area); - - // Update dimensions only if they look valid (never write 0 to physics state) - if (cW > 0 && cH > 0) { - zn.canvasW = (int)cW; - zn.canvasH = (int)cH; - } - - // Background fill - lv_draw_fill_dsc_t bg; - lv_draw_fill_dsc_init(&bg); - bg.color = lv_color_hex(0x080C12); - bg.opa = LV_OPA_COVER; - lv_draw_fill(layer, &bg, &area); - - uint32_t nowMs = lv_tick_get(); - - const WallSeg* walls = getWalls(); - - // Draw wall segments - lv_draw_line_dsc_t line; - lv_draw_line_dsc_init(&line); - line.width = 12; - line.opa = LV_OPA_COVER; - - for (int w = 0; w < TOTAL_WALL_SEGS; w++) { - bool flashing = (nowMs < zn.wallFlashUntilMs[w]); - line.color = flashing ? lv_color_hex(0xFFFFFF) : lv_color_hex(0x1A3050); - line.p1.x = ox + (lv_value_precise_t)(walls[w].x1 * (float)(cW - 1)); - line.p1.y = oy + (lv_value_precise_t)(walls[w].y1 * (float)(cH - 1)); - line.p2.x = ox + (lv_value_precise_t)(walls[w].x2 * (float)(cW - 1)); - line.p2.y = oy + (lv_value_precise_t)(walls[w].y2 * (float)(cH - 1)); - lv_draw_line(layer, &line); - } -} - -// --------------------------------------------------------------------------- -// Physics timer - updates state, invalidates object to trigger redraw -// --------------------------------------------------------------------------- - -// Create or show a ball widget for slot b -static void ensureBallWidget(MidiController* mc, int b) { - auto& zn = mc->zen_; - if (zn.ballObj[b]) return; - int r = zn.balls[b].radius; - lv_obj_t* w = mcContainer(zn.canvas); - lv_obj_set_size(w, r * 2, r * 2); - lv_obj_set_style_radius(w, LV_RADIUS_CIRCLE, 0); - lv_obj_set_style_bg_color(w, lv_color_hex(ZEN_COLORS[b % 4]), 0); - lv_obj_set_style_bg_opa(w, LV_OPA_COVER, 0); - lv_obj_set_style_border_width(w, 0, 0); - lv_obj_set_style_pad_all(w, 0, 0); - lv_obj_set_scrollable(w, false); - lv_obj_set_clickable(w, false); - lv_obj_set_pos(w, (lv_coord_t)(zn.balls[b].x - r), (lv_coord_t)(zn.balls[b].y - r)); - zn.ballObj[b] = w; -} - -static void scheduleZenNoteOff(MidiController* mc, uint8_t note, uint32_t delayMs) { - auto& zn = mc->zen_; - uint32_t offAt = lv_tick_get() + delayMs; - // Find a free slot - for (int i = 0; i < MidiController::ZenState::MAX_PENDING_OFFS; i++) { - if (zn.pendingOffMs[i] == 0) { - zn.pendingOffNote[i] = note; - zn.pendingOffMs[i] = offAt ? offAt : 1; // avoid 0 sentinel - return; - } - } - // No free slot - send immediately as fallback - mc->sendMidi(0x80, note, 0); -} - -static void cbZenTimer(lv_timer_t* t) { - auto* mc = static_cast(lv_timer_get_user_data(t)); - if (!mc) return; - auto& zn = mc->zen_; - lv_obj_t* canvas = zn.canvas; // snapshot - must not use zn.canvas after ensureBallWidget - if (!canvas || zn.canvasW <= 0 || zn.canvasH <= 0) return; - - // Process pending note-offs - uint32_t nowMs = lv_tick_get(); - for (int i = 0; i < MidiController::ZenState::MAX_PENDING_OFFS; i++) { - if (zn.pendingOffMs[i] != 0 && (int32_t)(nowMs - zn.pendingOffMs[i]) >= 0) { - mc->sendMidi(0x80, zn.pendingOffNote[i], 0); - zn.pendingOffMs[i] = 0; - } - } - - // Fallback: initialize first ball if SIZE_CHANGED fired before state was ready - if (zn.numBalls == 0) { - initFirstBall(zn, zn.canvasW, zn.canvasH); - if (zn.ballCntLbl) lv_label_set_text(zn.ballCntLbl, "Balls: x1"); - } - - float cW = (float)zn.canvasW; - float cH = (float)zn.canvasH; - float minSpd = 1.5f * cW / 220.0f; - - const WallSeg* walls = getWalls(); - - for (int b = 0; b < MidiController::ZenState::MAX_BALLS; b++) { - auto& ball = zn.balls[b]; - if (!ball.active) continue; - - ball.x += ball.vx; - ball.y += ball.vy; - - // Top bounce - if (ball.y - ball.radius <= 0) { - ball.y = (float)ball.radius; - ball.vy = fabsf(ball.vy); - if (fabsf(ball.vy) < minSpd) ball.vy = minSpd; - float fx = ball.x / cW; - int seg = (int)(fx * TOP_SEGS); - seg = seg < 0 ? 0 : (seg >= TOP_SEGS ? TOP_SEGS - 1 : seg); - int note = std::max(0, std::min(127, getNoteInScale(zn.scale, walls[seg].scaleDegree, zn.octave + walls[seg].octaveOffset, zn.key))); - mc->sendMidi(0x90, (uint8_t)note, 70 + (int)(esp_random() % 40)); - scheduleZenNoteOff(mc, (uint8_t)note, 150); - zn.wallFlashUntilMs[walls[seg].noteIdx] = nowMs + 200; - } - // Bottom bounce - if (ball.y + ball.radius >= cH) { - ball.y = cH - ball.radius; - ball.vy = -fabsf(ball.vy); - if (fabsf(ball.vy) < minSpd) ball.vy = -minSpd; - float fx = ball.x / cW; - int seg = (int)(fx * BOTTOM_SEGS); - seg = seg < 0 ? 0 : (seg >= BOTTOM_SEGS ? BOTTOM_SEGS - 1 : seg); - int wallIdx = TOP_SEGS + seg; - int note = std::max(0, std::min(127, getNoteInScale(zn.scale, walls[wallIdx].scaleDegree, zn.octave + walls[wallIdx].octaveOffset, zn.key))); - mc->sendMidi(0x90, (uint8_t)note, 70 + (int)(esp_random() % 40)); - scheduleZenNoteOff(mc, (uint8_t)note, 150); - zn.wallFlashUntilMs[walls[wallIdx].noteIdx] = nowMs + 200; - } - // Left bounce - if (ball.x - ball.radius <= 0) { - ball.x = (float)ball.radius; - ball.vx = fabsf(ball.vx); - if (fabsf(ball.vx) < minSpd) ball.vx = minSpd; - float fy = ball.y / cH; - int seg = (int)(fy * SIDE_SEGS); - seg = seg < 0 ? 0 : (seg >= SIDE_SEGS ? SIDE_SEGS - 1 : seg); - int wallIdx = TOP_SEGS + BOTTOM_SEGS + seg; - int note = std::max(0, std::min(127, getNoteInScale(zn.scale, walls[wallIdx].scaleDegree, zn.octave + walls[wallIdx].octaveOffset, zn.key))); - mc->sendMidi(0x90, (uint8_t)note, 70 + (int)(esp_random() % 40)); - scheduleZenNoteOff(mc, (uint8_t)note, 150); - zn.wallFlashUntilMs[walls[wallIdx].noteIdx] = nowMs + 200; - } - // Right bounce - if (ball.x + ball.radius >= cW) { - ball.x = cW - ball.radius; - ball.vx = -fabsf(ball.vx); - if (fabsf(ball.vx) < minSpd) ball.vx = -minSpd; - float fy = ball.y / cH; - int seg = (int)(fy * SIDE_SEGS); - seg = seg < 0 ? 0 : (seg >= SIDE_SEGS ? SIDE_SEGS - 1 : seg); - int wallIdx = TOP_SEGS + BOTTOM_SEGS + SIDE_SEGS + seg; - int note = std::max(0, std::min(127, getNoteInScale(zn.scale, walls[wallIdx].scaleDegree, zn.octave + walls[wallIdx].octaveOffset, zn.key))); - mc->sendMidi(0x90, (uint8_t)note, 70 + (int)(esp_random() % 40)); - scheduleZenNoteOff(mc, (uint8_t)note, 150); - zn.wallFlashUntilMs[walls[wallIdx].noteIdx] = nowMs + 200; - } - } - - // Update ball widget positions (create if needed, hide if inactive) - for (int b = 0; b < MidiController::ZenState::MAX_BALLS; b++) { - auto& ball = zn.balls[b]; - if (ball.active) { - ensureBallWidget(mc, b); - int r = ball.radius; - lv_obj_set_pos(zn.ballObj[b], (lv_coord_t)(ball.x - r), (lv_coord_t)(ball.y - r)); - lv_obj_set_hidden(zn.ballObj[b], false); - } else if (zn.ballObj[b]) { - lv_obj_set_hidden(zn.ballObj[b], true); - } - } - - // Request redraw of walls (background) - use local snapshot, not zn.canvas - if (canvas && canvas == zn.canvas) lv_obj_invalidate(canvas); -} - -// --------------------------------------------------------------------------- -// Canvas size event - initialize balls after first layout -// --------------------------------------------------------------------------- - -static void cbCanvasSize(lv_event_t* e) { - auto* mc = static_cast(lv_event_get_user_data(e)); - if (!mc) return; - auto& zn = mc->zen_; - lv_obj_t* obj = lv_event_get_target_obj(e); - int w = (int)lv_obj_get_content_width(obj); - int h = (int)lv_obj_get_content_height(obj); - if (w <= 0 || h <= 0) return; - - zn.canvasW = w; - zn.canvasH = h; - - // Initialize first ball on first valid layout - if (zn.numBalls == 0) { - initFirstBall(zn, w, h); - } -} - -// --------------------------------------------------------------------------- -// Control callbacks -// --------------------------------------------------------------------------- - -static void cbBack(lv_event_t* e) { - auto* mc = static_cast(lv_event_get_user_data(e)); - if (mc) mc->switchToMode(MidiMode::HOME); -} - -static void cbAddBall(lv_event_t* e) { - auto* mc = static_cast(lv_event_get_user_data(e)); - if (!mc) return; - auto& zn = mc->zen_; - if (zn.numBalls >= MidiController::ZenState::MAX_BALLS) return; - - int b = zn.numBalls; - zn.balls[b].x = zn.canvasW * 0.3f + (float)(esp_random() % 100) * 0.01f * zn.canvasW * 0.4f; - zn.balls[b].y = zn.canvasH * 0.3f + (float)(esp_random() % 100) * 0.01f * zn.canvasH * 0.4f; - float speedScale = (float)zn.canvasW / 220.0f; - float spd = (1.5f + (float)(esp_random() % 14) * 0.1f) * speedScale; - float ang = (float)(esp_random() % 628) * 0.01f; - zn.balls[b].vx = cosf(ang) * spd; - zn.balls[b].vy = sinf(ang) * spd; - zn.balls[b].radius = 20; - zn.balls[b].color = ZEN_COLORS[b % 4]; - zn.balls[b].active = true; - zn.numBalls++; - if (zn.ballCntLbl) { - char buf[24]; snprintf(buf, sizeof(buf), "Balls: x%d", zn.numBalls); - lv_label_set_text(zn.ballCntLbl, buf); - } -} - -static void cbReset(lv_event_t* e) { - auto* mc = static_cast(lv_event_get_user_data(e)); - if (!mc) return; - auto& zn = mc->zen_; - for (int b = 1; b < MidiController::ZenState::MAX_BALLS; b++) zn.balls[b].active = false; - zn.numBalls = 1; - if (zn.ballCntLbl) lv_label_set_text(zn.ballCntLbl, "Balls: x1"); -} - -static void cbScaleCycle(lv_event_t* e) { - auto* mc = static_cast(lv_event_get_user_data(e)); - if (!mc) return; - mc->zen_.scale = (mc->zen_.scale + 1) % NUM_MIDI_SCALES; - if (mc->zen_.statusLbl) { - char buf[24]; - snprintf(buf, sizeof(buf), "%s %s", KEY_NAMES[mc->zen_.key], MIDI_SCALES[mc->zen_.scale].name); - lv_label_set_text(mc->zen_.statusLbl, buf); - } -} - -static void cbKeyDown(lv_event_t* e) { - auto* mc = static_cast(lv_event_get_user_data(e)); - if (!mc) return; - mc->zen_.key = (mc->zen_.key + 11) % 12; - if (mc->zen_.statusLbl) { - char buf[24]; - snprintf(buf, sizeof(buf), "%s %s", KEY_NAMES[mc->zen_.key], MIDI_SCALES[mc->zen_.scale].name); - lv_label_set_text(mc->zen_.statusLbl, buf); - } -} -static void cbKeyUp(lv_event_t* e) { - auto* mc = static_cast(lv_event_get_user_data(e)); - if (!mc) return; - mc->zen_.key = (mc->zen_.key + 1) % 12; - if (mc->zen_.statusLbl) { - char buf[24]; - snprintf(buf, sizeof(buf), "%s %s", KEY_NAMES[mc->zen_.key], MIDI_SCALES[mc->zen_.scale].name); - lv_label_set_text(mc->zen_.statusLbl, buf); - } -} - -static void cbOctCycle(lv_event_t* e) { - auto* mc = static_cast(lv_event_get_user_data(e)); - if (!mc) return; - mc->zen_.octave++; - if (mc->zen_.octave > 6) mc->zen_.octave = 2; - if (mc->zen_.octLbl) { - char buf[6]; snprintf(buf, sizeof(buf), "O:%d", mc->zen_.octave); - lv_label_set_text(mc->zen_.octLbl, buf); - } -} - -// --------------------------------------------------------------------------- -// Teardown -// --------------------------------------------------------------------------- - -static void cbZenRootDelete(lv_event_t* e) { - auto* mc = static_cast(lv_event_get_user_data(e)); - if (!mc) return; - auto& zn = mc->zen_; - - // Stop and delete the zen timer so it cannot fire after widgets are gone - if (mc->zenTimer_) { - lv_timer_delete(mc->zenTimer_); - mc->zenTimer_ = nullptr; - } - - // Flush all pending note-offs immediately so no notes get stuck - for (int i = 0; i < MidiController::ZenState::MAX_PENDING_OFFS; i++) { - if (zn.pendingOffMs[i] != 0) { - mc->sendMidi(0x80, zn.pendingOffNote[i], 0); - zn.pendingOffMs[i] = 0; - } - } - - // Null all stored LVGL pointers - zn.canvas = nullptr; - zn.statusLbl = nullptr; - zn.ballCntLbl = nullptr; - zn.octLbl = nullptr; - for (int b = 0; b < MidiController::ZenState::MAX_BALLS; b++) - zn.ballObj[b] = nullptr; -} - -// --------------------------------------------------------------------------- -// Build function -// --------------------------------------------------------------------------- - -void buildZenView(MidiController* mc, lv_obj_t* parent) { - auto& zn = mc->zen_; - - const lv_font_t* fontSmall = lvgl_get_text_font(FONT_SIZE_SMALL); - int ctrlH = 88; - int btnH = MC_BTN_H; - - lv_obj_t* root = mcContainer(parent); - lv_obj_add_event_cb(root, cbZenRootDelete, LV_EVENT_DELETE, mc); - lv_obj_set_size(root, LV_PCT(100), LV_PCT(100)); - lv_obj_set_flex_flow(root, LV_FLEX_FLOW_COLUMN); - lv_obj_set_style_bg_color(root, lv_color_hex(0x080C12), 0); - lv_obj_set_style_bg_opa(root, LV_OPA_COVER, 0); - lv_obj_set_style_border_width(root, 0, 0); - lv_obj_set_style_pad_all(root, 0, 0); - lv_obj_set_style_pad_gap(root, 0, 0); - lv_obj_set_scrollable(root, false); - mc->currentViewRoot_ = root; - - // -- Control row (TOP) ---------------------------------------------------- - lv_obj_t* ctrl = mcRow(root, ctrlH); - - auto makeCtrlBtn = [&](const char* lbl, lv_event_cb_t cb) { - lv_obj_t* btn = mcButton(ctrl); - lv_obj_set_size(btn, 112, btnH); - lv_obj_add_event_cb(btn, cb, LV_EVENT_CLICKED, mc); - lv_obj_t* l = lv_label_create(btn); - lv_label_set_text(l, lbl); - lv_obj_set_style_text_font(l, fontSmall, 0); - lv_obj_center(l); - }; - - mcBackBtn(ctrl, cbBack, mc, MC_W_XS); - - makeCtrlBtn("ADD", cbAddBall); - makeCtrlBtn("RESET", cbReset); - makeCtrlBtn("SCALE", cbScaleCycle); - makeCtrlBtn("KEY-", cbKeyDown); - makeCtrlBtn("KEY+", cbKeyUp); - makeCtrlBtn("OCT", cbOctCycle); - - zn.octLbl = lv_label_create(ctrl); - { char buf[6]; snprintf(buf, sizeof(buf), "O:%d", zn.octave); lv_label_set_text(zn.octLbl, buf); } - lv_obj_set_style_text_font(zn.octLbl, fontSmall, 0); - lv_obj_set_style_text_color(zn.octLbl, lv_color_hex(0x00DFFF), 0); - lv_obj_set_width(zn.octLbl, 112); - lv_obj_set_style_text_align(zn.octLbl, LV_TEXT_ALIGN_CENTER, 0); - - zn.ballCntLbl = lv_label_create(ctrl); - lv_label_set_text(zn.ballCntLbl, "Balls: x1"); - lv_obj_set_style_text_font(zn.ballCntLbl, fontSmall, 0); - lv_obj_set_style_text_color(zn.ballCntLbl, lv_color_hex(0x666688), 0); - - zn.statusLbl = lv_label_create(ctrl); - { char sbuf[24]; snprintf(sbuf, sizeof(sbuf), "%s %s", KEY_NAMES[zn.key], MIDI_SCALES[zn.scale].name); - lv_label_set_text(zn.statusLbl, sbuf); } - lv_obj_set_style_text_font(zn.statusLbl, fontSmall, 0); - lv_obj_set_style_text_color(zn.statusLbl, lv_color_hex(0x00DFFF), 0); - lv_obj_set_flex_grow(zn.statusLbl, 1); - lv_obj_set_style_text_align(zn.statusLbl, LV_TEXT_ALIGN_RIGHT, 0); - - // -- Physics area (fills remaining space below controls) ------------------- - lv_obj_t* physArea = mcContainer(root); - lv_obj_set_width(physArea, LV_PCT(100)); - lv_obj_set_flex_grow(physArea, 1); - lv_obj_set_style_bg_color(physArea, lv_color_hex(0x080C12), 0); - lv_obj_set_style_bg_opa(physArea, LV_OPA_COVER, 0); - lv_obj_set_style_border_width(physArea, 0, 0); - lv_obj_set_style_pad_all(physArea, 0, 0); - lv_obj_set_scrollable(physArea, false); - lv_obj_add_event_cb(physArea, cbZenDraw, LV_EVENT_DRAW_MAIN, mc); - lv_obj_add_event_cb(physArea, cbCanvasSize, LV_EVENT_SIZE_CHANGED, mc); - zn.canvas = physArea; - - // Start timer - mc->zenTimer_ = lv_timer_create(cbZenTimer, 16, mc); -} diff --git a/Apps/MidiController/main/Source/main.cpp b/Apps/MidiController/main/Source/main.cpp deleted file mode 100644 index 02d0641..0000000 --- a/Apps/MidiController/main/Source/main.cpp +++ /dev/null @@ -1,62 +0,0 @@ -#include "MidiController.h" - -#include -#include -#include - -#include - -#include - -#include - -extern "C" { - -int main(int argc, char* argv[]) { - AppInstanceId app_instance_id = app_scheduler_current_app_id(); - - // Heap-allocated: the BT event subscription (bluetooth_event_subscribe, see - // MidiController::createWidgetsImpl) captures the instance's address, so it can't be a stack - // frame that goes away while a subscription might still be active. - auto mc = std::make_unique(); - mc->appInstanceId = app_instance_id; - - struct TaskEventGroup event_group {}; - task_event_group_construct(&event_group); - - struct AppEventSubscription sub {}; - check(app_event_subscribe(&sub, &event_group) == ERROR_NONE); - - // BLE and USB MIDI start on this task: the LVGL task can't do the flash and NVS work that they need - mc->attachEventGroup(&event_group); - mc->detectBleState(); - - WindowId window = window_manager_create(app_instance_id, MidiController::createWidgets, mc.get()); - mc->window = window; - - bool should_close = false; - while (!should_close) { - task_event_group_wait_any(&event_group, nullptr, portMAX_DELAY); - - struct AppEvent event; - while (app_event_poll(&sub, &event) == ERROR_NONE) { - if (event.type == APP_EVENT_CLOSE) { - should_close = true; - break; - } - } - - if (!should_close) { - mc->processEvents(); - } - } - - window_manager_remove(window); - app_event_unsubscribe(&sub); - mc->teardown(); - task_event_group_destruct(&event_group); - - return 0; -} - -} diff --git a/Apps/MidiController/manifest.properties b/Apps/MidiController/manifest.properties deleted file mode 100644 index a55c90d..0000000 --- a/Apps/MidiController/manifest.properties +++ /dev/null @@ -1,11 +0,0 @@ -manifest.version=0.3 -target.sdk=0.8.0-dev -target.platforms=esp32p4 -id=tactility.midicontroller -version.name=0.3.0 -version.code=3 -name=Midi Controller -description=Midi Controller -app.0.id=tactility.midicontroller -app.0.name=Midi Controller -app.0.binary=midicontroller