diff --git a/.github/workflows/compile.yml b/.github/workflows/compile.yml new file mode 100644 index 0000000..01f0db8 --- /dev/null +++ b/.github/workflows/compile.yml @@ -0,0 +1,77 @@ +name: CI + +on: + push: + branches: + - main + - master + pull_request: + workflow_dispatch: + +jobs: + unit-tests: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Build and Run Unit Tests + run: | + cd test + cmake -B build -DCMAKE_BUILD_TYPE=Release + cmake --build build --config Release + cd build && ctest --output-on-failure + + compile: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Arduino CLI + uses: arduino/setup-arduino-cli@v2 + + - name: Configure Arduino CLI + run: | + arduino-cli config init --overwrite + arduino-cli config set board_manager.additional_urls https://www.pjrc.com/teensy/package_teensy_index.json + arduino-cli config set library.enable_unsafe_install true + + - name: Install Teensy Core + run: | + arduino-cli core update-index + arduino-cli core install teensy:avr + + - name: Install Libraries from Arduino Library Manager + run: | + arduino-cli lib update-index + arduino-cli lib install "ArduinoJson@6.21.5" + arduino-cli lib install "MIDI Library" + arduino-cli lib install "LiquidCrystal I2C" + arduino-cli lib install "MD_MIDIFile" + + - name: Patch MD_MIDIFile for SdFat (Teensy) + # MD_MIDIFile v2.6.0 unconditionally uses 'typedef File SDDIR/SDFILE'. + # Teensy's SD library is SdFat-based and doesn't expose the Arduino 'File' type, + # but 'SdFile' is available. Replace the typedef so the library compiles on Teensy. + run: | + CONFIG="$HOME/Arduino/libraries/MD_MIDIFile/src/MD_MIDIFile.h" + echo "=== typedef before patch ===" + grep -n "typedef File SD" "$CONFIG" || echo "(no match yet)" + sed -i 's/typedef File SDDIR;/typedef SdFile SDDIR;/; s/typedef File SDFILE;/typedef SdFile SDFILE;/' "$CONFIG" + echo "=== typedef after patch ===" + grep -n "typedef.*SD" "$CONFIG" || echo "(no match)" + + - name: Install debounce library from git + # kimballa/button-debounce: provides debounce.h with Button(id, callback), + # BTN_PRESSED/BTN_OPEN constants, and update(bool). Not in Arduino Library Manager. + run: | + arduino-cli lib install --git-url https://github.com/kimballa/button-debounce.git + + - name: Compile Teensy 4.1 Firmware + run: | + arduino-cli compile \ + --fqbn teensy:avr:teensy41 \ + --warnings all \ + midicontroller/midicontroller.ino diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3b7e5ee --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +test/build/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..50cadc1 --- /dev/null +++ b/README.md @@ -0,0 +1,198 @@ +# Synapse MIDI Controller + +A feature-rich MIDI foot controller built on the **Teensy 4.1** platform. Designed for live performance, it sends MIDI Program Change (PC) and Control Change (CC) messages over DIN-5 and USB MIDI, plays back MIDI files from an SD card, and displays the current state on a 20×4 LCD. + +## Features + +### Preset System +- Presets are stored as individual `.json` files on a micro-SD card. +- Up to **150 presets** are loaded at boot, sorted numerically by filename (e.g. `1.json`, `2.json`, …). +- Navigate presets with dedicated **Next / Previous** foot switches. +- The active preset index is saved to EEPROM so the controller remembers its position across power cycles. +- Presets are prefetched in the direction of navigation for instant loading. + +### Three Configurable Foot Switches +Each preset defines up to three foot switch actions (`Switch1`, `Switch2`, `Switch3`): + +| Capability | Description | +|---|---| +| **Program Change** | Send one or more PC messages on any MIDI channel, via DIN or USB. | +| **Control Change** | Send one or more CC messages on any MIDI channel, via DIN or USB. | +| **Toggle Mode** | A switch can be configured as a toggle — alternating between on (CC 127) and off (CC 0) with each press. The LCD shows `On!` / `Off!` indicators and a `*` marker on the display row. | + +### On-Load Events +Each preset can define `OnLoad` PC and CC messages that are sent automatically when the preset is selected — useful for switching amp channels, enabling default effects, etc. + +### MIDI File Playback +- Assign a `.mid` file to a preset via the `FileInfo` key. +- Switch 1 becomes **Play / Stop** for that file. +- Configurable BPM and output channel. +- Optional `StopCC` array to send CC messages when playback stops (e.g. silence a looper). + +### Program Change (PC) Mode +- Long-hold either navigation switch for 3 seconds to enter **PC Mode**. +- In PC Mode, Switch 2 and Switch 3 step through program numbers (0–127). +- Every PC change is sent over USB MIDI and the current program number is persisted to EEPROM. +- Long-hold again to exit back to preset mode. + +### MIDI I/O +| Port | Direction | Purpose | +|---|---|---| +| **Serial1 (DIN MIDI OUT)** | Output | PC and CC messages from presets and PC Mode. | +| **Serial2 (DIN MIDI IN 1)** | Input | Passes through to Serial1 (MIDI merge). | +| **Serial3 (DIN MIDI IN 2)** | Input | Passes through to Serial1 (MIDI merge). | +| **USB Host MIDI** | Output | PC and CC messages when `"USB": true` in the preset. | + +### Display +- **20×4 I2C LCD** (address `0x27`). +- Row 0: Preset name (or "Prog Change Mode"). +- Row 1: Contextual info (current PC number in PC Mode). +- Row 2: Toggle indicators (`*` per switch when toggled on). +- Row 3: Switch labels from the preset JSON. +- Temporary overlay messages (e.g. "DIST On!") display for 1.5 seconds then return to the preset view. + +### Boot Screen +Displays "TA Audio / SYNAPSE / MIDI CONTROLLER / v0.1.0" for 2 seconds on power-up. + +## Hardware + +| Component | Detail | +|---|---| +| MCU | Teensy 4.1 | +| Display | 20×4 I2C LCD (PCF8574, address 0x27) | +| Storage | Micro-SD card (FAT32) | +| Foot switches | 5 momentary switches on pins 2–6 (active-low with internal/external pull-ups) | +| MIDI OUT | DIN-5, Serial1 | +| MIDI IN 1 | DIN-5, Serial2 | +| MIDI IN 2 | DIN-5, Serial3 | +| USB Host | USB MIDI device via USBHost_t36 | + +### Pin Assignments + +| Pin | Function | +|---|---| +| 2 | Switch 1 | +| 3 | Switch 2 | +| 4 | Switch 3 | +| 5 | Next Preset | +| 6 | Previous Preset | +| 13 | LED (onboard) | +| BUILTIN_SDCARD | SD card | + +## Preset JSON Format + +Each preset is a `.json` file on the SD card root. Filenames should be numbered (e.g. `1.json`, `2.json`) for sort order. + +### Basic Preset + +```json +{ + "Name": "My Preset", + "OnLoad": { + "PC": [{ "PC": 5, "Channel": 1, "USB": false }], + "CC": [{ "CC": 1, "Value": 127, "Channel": 1 }] + }, + "Switch1": { + "Name": "DRIVE", + "Toggle": true, + "CC": [{ "CC": 50, "Value": 127, "Channel": 1 }], + "PC": null + }, + "Switch2": { + "Name": "DELAY", + "Toggle": false, + "CC": null, + "PC": [{ "PC": 10, "Channel": 1, "USB": true }] + }, + "Switch3": { + "Name": "REVERB", + "Toggle": true, + "CC": [{ "CC": 51, "Value": 127, "Channel": 1 }], + "PC": null + } +} +``` + +### MIDI File Preset + +When `FileInfo` is present, Switch 1 becomes a play/stop control for the specified MIDI file: + +```json +{ + "Name": "Backing Track", + "FileInfo": { + "FileName": "song.mid", + "BPM": 120, + "Channel": 10, + "StopCC": [{ "CC": 4, "Value": 0, "Channel": 11 }] + }, + "OnLoad": { + "PC": [{ "PC": 98, "Channel": 11 }], + "CC": null + }, + "Switch1": { "Name": "", "Toggle": false, "CC": null, "PC": null }, + "Switch2": { "Name": "", "Toggle": false, "CC": null, "PC": null }, + "Switch3": { "Name": "", "Toggle": false, "CC": null, "PC": null } +} +``` + +### Field Reference + +| Field | Type | Description | +|---|---|---| +| `Name` | string | Display name shown on the LCD. | +| `OnLoad.PC[]` | array | Program Change messages sent when the preset loads. | +| `OnLoad.CC[]` | array | Control Change messages sent when the preset loads. | +| `Switch1/2/3.Name` | string | Label shown on the LCD for this switch. | +| `Switch1/2/3.Toggle` | bool | If `true`, the switch alternates between on/off states. | +| `Switch1/2/3.CC[]` | array | CC messages sent on press. In toggle mode, value is overridden to 127 (on) or 0 (off). | +| `Switch1/2/3.PC[]` | array | PC messages sent on press. `PC` value is 1-indexed (internally decremented by 1). | +| `FileInfo.FileName` | string | Name of the `.mid` file on the SD card. | +| `FileInfo.BPM` | int | Playback tempo. | +| `FileInfo.Channel` | int | MIDI output channel for file playback. | +| `FileInfo.StopCC[]` | array | CC messages sent when playback stops. | +| `*.USB` | bool | If `true`, the message is sent over USB Host MIDI instead of DIN. | +| `*.Channel` | int | MIDI channel (1–16). | + +## Building + +### Prerequisites + +- [Arduino CLI](https://arduino.github.io/arduino-cli/) or the Arduino IDE with Teensy support. +- Teensy 4.1 board package from [PJRC](https://www.pjrc.com/teensy/td_download.html). + +### Libraries + +| Library | Source | +|---|---| +| ArduinoJson 6.21.5 | Arduino Library Manager | +| MIDI Library | Arduino Library Manager | +| LiquidCrystal I2C | Arduino Library Manager | +| MD_MIDIFile | Arduino Library Manager (requires Teensy SdFat patch) | +| button-debounce | [GitHub](https://github.com/kimballa/button-debounce) (git install) | + +### Quick Start (Local) + +Run the included setup script to install all tooling, libraries, compile, and optionally upload: + +```powershell +.\setup-local.ps1 # install prerequisites and compile +.\setup-local.ps1 -Upload # compile and upload to connected Teensy +``` + +See [setup-local.ps1](setup-local.ps1) for details. + +### Manual Build + +```bash +arduino-cli compile --fqbn teensy:avr:teensy41 midicontroller/midicontroller.ino +arduino-cli upload --fqbn teensy:avr:teensy41 -p midicontroller/midicontroller.ino +``` + +## CI + +A GitHub Actions workflow (`.github/workflows/compile.yml`) compiles the firmware on every push and pull request. It automatically patches the MD_MIDIFile library for Teensy compatibility. + +## License + +See [LICENSE](LICENSE). diff --git a/generateexamplepresets.ps1 b/generateexamplepresets.ps1 index 345db26..4f2f72d 100644 --- a/generateexamplepresets.ps1 +++ b/generateexamplepresets.ps1 @@ -1,20 +1,21 @@ # Define the list of preset and switch names $ampManufacturers = @("Fender", "Marshall", "Vox", "Peavey", "Orange", "Mesa Boogie", "Roland", "Line 6", "Ampeg") $switchNames = @("DIST", "PITCH", "WAH", "DLAY", "REV", "FUZZ") +$rng = [System.Random]::new() # Create a directory to save the JSON files if it doesn't exist $directory = "JSON_Variations" if (-not (Test-Path -Path $directory -PathType Container)) { - New-Item -Path $directory -ItemType Directory + New-Item -Path $directory -ItemType Directory | Out-Null } # Generate and save 100 JSON variations for ($i = 1; $i -le 100; $i++) { - # Randomly select preset and switch names - $presetName = $ampManufacturers | Get-Random - $switch1Name = $switchNames | Get-Random - $switch2Name = $switchNames | Get-Random - $switch3Name = $switchNames | Get-Random + # Randomly select preset and switch names using one RNG instance. + $presetName = $ampManufacturers[$rng.Next($ampManufacturers.Count)] + $switch1Name = $switchNames[$rng.Next($switchNames.Count)] + $switch2Name = $switchNames[$rng.Next($switchNames.Count)] + $switch3Name = $switchNames[$rng.Next($switchNames.Count)] # Create an object representing the JSON structure $jsonStructure = @{ @@ -79,8 +80,8 @@ for ($i = 1; $i -le 100; $i++) { } # Convert the object to JSON and save it to a file - $jsonText = $jsonStructure | ConvertTo-Json -Depth 100 - $jsonText | Set-Content -Path (Join-Path -Path $directory -ChildPath "$i.json") + $jsonText = $jsonStructure | ConvertTo-Json -Depth 6 + Set-Content -Path (Join-Path -Path $directory -ChildPath "$i.json") -Value $jsonText -Encoding utf8 } Write-Host "Generated 100 JSON variations." \ No newline at end of file diff --git a/midicontroller/logic.h b/midicontroller/logic.h new file mode 100644 index 0000000..752f428 --- /dev/null +++ b/midicontroller/logic.h @@ -0,0 +1,303 @@ +#pragma once + +// When building for Arduino/Teensy, Arduino.h provides standard types. +// For native builds (unit tests), use standard C++ headers. +#ifndef ARDUINO +#include +#include +#include +#endif + +// ── Pin assignments ────────────────────────────────────────────────────────── + +static constexpr int switch1Pin = 2; +static constexpr int switch2Pin = 3; +static constexpr int switch3Pin = 4; +static constexpr int switchCount = 3; +static constexpr int nextPresetPin = 5; +static constexpr int prevPresetPin = 6; + +// ── Timing ─────────────────────────────────────────────────────────────────── + +static constexpr unsigned long initialLoadDelayMs = 1000; +static constexpr unsigned long switchDisplayPeriodMs = 1500; +static constexpr unsigned long longHoldToggleMs = 3000; +static constexpr unsigned long eepromCommitDelayMs = 200; + +// ── Display ────────────────────────────────────────────────────────────────── + +static constexpr int lcdColumnCount = 20; +static constexpr int uiTextBufferLength = 50; + +// ── Presets ────────────────────────────────────────────────────────────────── + +static constexpr int maxPresetListSize = 150; +static constexpr int maxPresetNameLength = 25; + +// ── MIDI ───────────────────────────────────────────────────────────────────── + +static constexpr int midiValueMin = 0; +static constexpr int midiValueMax = 127; + +// ── EEPROM addresses ───────────────────────────────────────────────────────── + +static constexpr int presetEepromAddress = 0; +static constexpr int pcModeEepromAddress = 1000; + +// ── Pure logic functions ───────────────────────────────────────────────────── + +/// Clamp a MIDI value to the valid range (0–127). +inline int clampMidi(int value) { + if (value < midiValueMin) return midiValueMin; + if (value > midiValueMax) return midiValueMax; + return value; +} + +/// Check whether `durationMs` has elapsed since `start`, using `currentMs` as +/// the reference time. Handles unsigned wrap-around correctly. +inline bool hasElapsed(unsigned long currentMs, unsigned long start, unsigned long durationMs) { + return (currentMs - start) >= durationMs; +} + +/// Extract the leading decimal number from a filename (e.g. "12.json" -> 12). +/// Returns 0 for filenames that don't start with a digit. +inline int extractNumber(const char *filename) { + int num = 0; + int i = 0; + while (filename[i] != '\0' && filename[i] >= '0' && filename[i] <= '9') { + num = num * 10 + (filename[i] - '0'); + i++; + } + return num; +} + +/// qsort comparator – sorts preset filenames by leading number, falling back +/// to lexicographic order when numbers are equal. +inline int comparePresetNames(const void *lhs, const void *rhs) { + const char *a = static_cast(lhs); + const char *b = static_cast(rhs); + const int numA = extractNumber(a); + const int numB = extractNumber(b); + if (numA < numB) return -1; + if (numA > numB) return 1; + return strncmp(a, b, maxPresetNameLength); +} + +/// Calculate left-padding to centre `textLength` characters on the LCD row. +inline int calculateCenterPadding(int textLength) { + const int safeLength = (textLength > lcdColumnCount) ? lcdColumnCount : textLength; + return (lcdColumnCount - safeLength) / 2; +} + +/// Calculate padding between three switch labels so they span the LCD evenly. +/// `leftPad` goes between sw1 and sw2; `rightPad` between sw2 and sw3. +inline void calculateSwitchPadding(int sw1Len, int sw2Len, int sw3Len, + int &leftPad, int &rightPad) { + const int totalLength = sw1Len + sw2Len + sw3Len; + leftPad = (totalLength < lcdColumnCount) ? (lcdColumnCount - totalLength) / 2 : 0; + rightPad = (totalLength < lcdColumnCount) ? (lcdColumnCount - totalLength + 1) / 2 : 0; +} + +/// Return true when the preset index can be incremented. +inline bool canNavigateNext(int currentPreset, int presetCount) { + return currentPreset < (presetCount - 1); +} + +/// Return true when the preset index can be decremented. +inline bool canNavigatePrev(int currentPreset) { + return currentPreset > 0; +} + +/// Increment or decrement a PC program number, clamping to 0–127. +inline int adjustPcProgram(int currentProgram, bool decrement) { + if (decrement) { + if (currentProgram >= 1) currentProgram--; + } else { + currentProgram++; + } + return clampMidi(currentProgram); +} + +// ── EEPROM validation ──────────────────────────────────────────────────────── + +/// Validate a preset index read from EEPROM. Returns 0 if out of range. +inline int validateStoredPreset(int value, int maxPresets) { + if (value < 0 || value >= maxPresets) return 0; + return value; +} + +/// Validate a PC program value read from EEPROM. Returns 0 if out of MIDI range. +inline int validateStoredPcProgram(int value) { + if (value < midiValueMin || value > midiValueMax) return 0; + return value; +} + +// ── Toggle logic ───────────────────────────────────────────────────────────── + +/// Determine the CC value to send for a toggle switch. +/// Returns 127 when turning on, 0 when turning off. +inline int toggleCcValue(bool wasToggled) { + return wasToggled ? 0 : 127; +} + +// ── Preset prefetch ────────────────────────────────────────────────────────── + +/// Compute the next preset index to prefetch based on navigation direction. +/// Returns -1 if no valid prefetch candidate exists. +inline int computePrefetchCandidate(int currentPreset, int presetCount, + int navigationDirection, int alreadyPrefetchedIndex) { + if (presetCount <= 1) return -1; + + int candidate = currentPreset + navigationDirection; + if (candidate < 0 || candidate >= presetCount) { + candidate = currentPreset - navigationDirection; + } + + if (candidate < 0 || candidate >= presetCount || candidate == currentPreset) { + return -1; + } + + if (candidate == alreadyPrefetchedIndex) { + return -1; // already prefetched + } + + return candidate; +} + +// ── Filename utilities ─────────────────────────────────────────────────────── + +/// Check whether a filename ends with ".json" (case-sensitive). +inline bool hasJsonExtension(const char *filename) { + if (filename == nullptr) return false; + const int nameLength = (int)strlen(filename); + const int extLength = 5; // ".json" + if (nameLength < extLength) return false; + return strcmp(filename + nameLength - extLength, ".json") == 0; +} + +// ── PC offset ──────────────────────────────────────────────────────────────── + +/// Convert a 1-indexed program change value (from JSON) to the 0-indexed MIDI value. +inline int pcJsonToMidi(int jsonPcValue) { + return clampMidi(jsonPcValue - 1); +} + +// ── Switch message formatting ──────────────────────────────────────────────── + +/// Format a switch action message into `outBuffer`. +/// Returns true if the result is non-empty (should be displayed). +inline bool formatSwitchActionMessage(const char *text, const char *suffix, + char *outBuffer, int bufSize) { + if (bufSize <= 0) return false; + const char *safeText = (text != nullptr) ? text : ""; + if (suffix != nullptr && suffix[0] != '\0') { + snprintf(outBuffer, bufSize, "%s%s", safeText, suffix); + } else { + snprintf(outBuffer, bufSize, "%s", safeText); + } + return outBuffer[0] != '\0'; +} + +// ── Preset bounds clamping ─────────────────────────────────────────────────── + +/// Clamp a preset index to valid range. If out of bounds, returns 0. +inline int clampPresetIndex(int index, int presetCount) { + if (presetCount <= 0) return 0; + if (index < 0 || index >= presetCount) return 0; + return index; +} + +// ── Debounce state machine ─────────────────────────────────────────────────── + +/// Debounce constants matching kimballa/button-debounce behaviour. +static constexpr unsigned long debounceIntervalMs = 25; + +static constexpr uint8_t BTN_STATE_OPEN = 1; +static constexpr uint8_t BTN_STATE_PRESSED = 0; + +/// A minimal pure-logic debounce state machine for unit testing. +/// Mirrors the algorithm in kimballa/button-debounce without Arduino dependencies. +struct DebounceState { + uint8_t currentState; // BTN_STATE_OPEN or BTN_STATE_PRESSED + uint8_t priorPoll; // last raw sample + unsigned long readStartTime; // when the current candidate reading began + unsigned long pushInterval; // ms to confirm a press + unsigned long releaseInterval; // ms to confirm a release + + /// Initialize to open (unpressed) state. + void init(unsigned long pushMs = debounceIntervalMs, unsigned long releaseMs = debounceIntervalMs) { + currentState = BTN_STATE_OPEN; + priorPoll = BTN_STATE_OPEN; + readStartTime = 0; + pushInterval = pushMs; + releaseInterval = releaseMs; + } + + /// Feed a new sample at the given time. Returns true if state changed. + bool update(uint8_t sample, unsigned long currentMs) { + sample = (sample != 0) ? 1 : 0; // collapse to 0/1 + + if (sample != priorPoll) { + readStartTime = currentMs; // signal changed — reset timer + } + priorPoll = sample; + + unsigned long interval = (currentState == BTN_STATE_PRESSED) ? releaseInterval : pushInterval; + + if ((currentMs - readStartTime) > interval) { + if (sample != currentState) { + currentState = sample; + return true; // state changed + } + } + return false; + } +}; + +// ── Switch handler routing logic ───────────────────────────────────────────── + +/// Determines the action to take when a debounced button event occurs. +/// This mirrors the firmware's switchHandler decision tree. +enum class SwitchAction { + None, // no action (not loaded, or invalid) + ExecuteSwitch, // fire switch logic for button 1/2/3 + TogglePcMode, // long-hold on nav button toggled PC mode + NavigateNext, // short press on next preset button + NavigatePrev, // short press on prev preset button + RecordHoldStart // BTN_PRESSED on nav button — just record timestamp +}; + +/// Determine what action to take given a button event. +/// `btnId`: 1–5, `btnState`: BTN_STATE_PRESSED or BTN_STATE_OPEN +/// `hasLoaded`: whether initial load is complete +/// `longHoldElapsed`: whether the long-hold threshold was reached +inline SwitchAction classifySwitchEvent(uint8_t btnId, uint8_t btnState, + bool hasLoaded, bool longHoldElapsed) { + if (btnState == BTN_STATE_PRESSED) { + if (hasLoaded && btnId >= 1 && btnId <= 3) { + return SwitchAction::ExecuteSwitch; + } + if (btnId == 4 || btnId == 5) { + return SwitchAction::RecordHoldStart; + } + return SwitchAction::None; + } + + // BTN_STATE_OPEN (release) + if (!hasLoaded) { + return SwitchAction::None; + } + + if ((btnId == 4 || btnId == 5) && longHoldElapsed) { + return SwitchAction::TogglePcMode; + } + + if (btnId == 4) { + return SwitchAction::NavigateNext; + } + if (btnId == 5) { + return SwitchAction::NavigatePrev; + } + + return SwitchAction::None; +} diff --git a/midicontroller/midicontroller.ino b/midicontroller/midicontroller.ino index dd63c29..4b0b3c4 100644 --- a/midicontroller/midicontroller.ino +++ b/midicontroller/midicontroller.ino @@ -6,51 +6,245 @@ #include #include "USBHost_t36.h" #include +#include +#include +#include "logic.h" -static constexpr int switch1 = 2; -static constexpr int switch2 = 3; -static constexpr int switch3 = 4; -static constexpr int nextPreset = 5; -static constexpr int prevPreset = 6; bool hasLoaded = false; -bool switchOneToggled = false; -bool switchTwoToggled = false; -bool switchThreeToggled = false; +bool switchToggled[switchCount] = {}; unsigned long startMillis; unsigned long currentMillis; -const unsigned long period = 1000; -const unsigned long switchDisplayPeriod = 1500; +unsigned long switchDisplayStartMillis = 0; bool resetPresetDisplay = false; -LiquidCrystal_I2C lcd(0x27, 20, 4); // I2C address 0x27, 20 column and 4 rows -JsonVariant preset; +LiquidCrystal_I2C lcd(0x27, 20, 4); // I2C address 0x27, 20 columns and 4 rows +JsonVariant activePreset; int currentPreset = 0; -int numPrograms = 0; -const int maxListSize = 150; // Maximum number of words -const int maxStringLength = 25; // Maximum length of each word -char presetList[maxListSize][maxStringLength]; -DynamicJsonDocument doc(1024); -int address = 0; -int pcAddress = 1000; -int currentIndex = 0; -FsFile dir; -FsFile file; -MD_MIDIFile SMF; -int midiFileChannel; +int presetCount = 0; +DMAMEM char presetList[maxPresetListSize][maxPresetNameLength]; +DMAMEM StaticJsonDocument<4096> presetDoc; +FsFile rootDir; +FsFile sdFile; +MD_MIDIFile midiFilePlayer; +int midiFileOutputChannel; bool playingMidiFile = false; bool stoppingMidiFile = false; -int pcModePCValue = 0; +int pcModeProgram = 0; bool pcModeOn = false; unsigned long longHoldStartMillis; - +bool pendingDisplayRefresh = false; +bool pendingPresetSave = false; +bool pendingPcSave = false; +int pendingPresetValue = 0; +int pendingPcValue = 0; +unsigned long eepromDirtyMillis = 0; +int presetNavigationDirection = 1; +int prefetchedPresetIndex = -1; +int prefetchTargetIndex = -1; +bool prefetchRequested = false; +DMAMEM StaticJsonDocument<4096> prefetchedPresetDoc; MIDI_CREATE_INSTANCE(HardwareSerial, Serial1, MIDI1); MIDI_CREATE_INSTANCE(HardwareSerial, Serial2, MIDI2); MIDI_CREATE_INSTANCE(HardwareSerial, Serial3, MIDI3); USBHost usbHost; -MIDIDevice USBMIDI(usbHost); +MIDIDevice usbMidiDevice(usbHost); + +bool loadPresetDocumentByIndex(int presetIndex, StaticJsonDocument<4096> &targetDoc); +bool applyPresetByIndex(int presetIndex); +void queueDirectionalPresetPrefetch(); +void servicePresetPrefetch(); +void changePreset(); +void executeSwitchLogic(int switchNo); +void setPresetDisplayInfo(); +void showError(const char *errorMessageLine1, const char *errorMessageLine2); + +static inline void sendProgramChange(int pcValue, int channel, bool usbEvent) { + const int safePc = clampMidi(pcValue); + if (usbEvent) { + usbMidiDevice.sendProgramChange(safePc, channel); + } else { + MIDI1.sendProgramChange(safePc, channel); + } +} + +static inline void sendControlChange(int ccNumber, int ccValue, int channel, bool usbEvent) { + const int safeCcNumber = clampMidi(ccNumber); + const int safeCcValue = clampMidi(ccValue); + if (usbEvent) { + usbMidiDevice.sendControlChange(safeCcNumber, safeCcValue, channel); + } else { + MIDI1.sendControlChange(safeCcNumber, safeCcValue, channel); + } +} + +static void sendPcArray(JsonArray pcArray) { + if (pcArray.isNull()) return; + for (JsonVariant pcEvent : pcArray) { + sendProgramChange(pcJsonToMidi(pcEvent["PC"].as()), pcEvent["Channel"], pcEvent["USB"]); + } +} + +static void sendCcArray(JsonArray ccArray) { + if (ccArray.isNull()) return; + for (JsonVariant ccEvent : ccArray) { + sendControlChange(ccEvent["CC"], ccEvent["Value"], ccEvent["Channel"], ccEvent["USB"]); + } +} + +static void setUiMessageTimeout() { + switchDisplayStartMillis = currentMillis; + resetPresetDisplay = true; +} + +static inline void requestPresetDisplayRefresh() { + pendingDisplayRefresh = true; +} + +static inline void queuePresetSave(int value) { + pendingPresetValue = value; + pendingPresetSave = true; + eepromDirtyMillis = currentMillis; +} + +static inline void queuePcSave(int value) { + pendingPcValue = value; + pendingPcSave = true; + eepromDirtyMillis = currentMillis; +} + +static void commitPendingEepromWrites() { + const bool hasPendingWrite = pendingPresetSave || pendingPcSave; + if (!hasPendingWrite || !hasElapsed(currentMillis, eepromDirtyMillis, eepromCommitDelayMs)) { + return; + } + + if (pendingPresetSave) { + EEPROM.put(presetEepromAddress, pendingPresetValue); + pendingPresetSave = false; + } + + if (pendingPcSave) { + EEPROM.put(pcModeEepromAddress, pendingPcValue); + pendingPcSave = false; + } +} + +static void servicePresetDisplayRefresh() { + if (!pendingDisplayRefresh || resetPresetDisplay) { + return; + } + + pendingDisplayRefresh = false; + setPresetDisplayInfo(); +} + +static void serviceMidiPassthrough() { + while (MIDI2.read()) { + MIDI1.send(MIDI2.getType(), + MIDI2.getData1(), + MIDI2.getData2(), + MIDI2.getChannel()); + } + + while (MIDI3.read()) { + MIDI1.send(MIDI3.getType(), + MIDI3.getData1(), + MIDI3.getData2(), + MIDI3.getChannel()); + } +} + +FLASHMEM bool loadPresetDocumentByIndex(int presetIndex, StaticJsonDocument<4096> &targetDoc) { + if (presetIndex < 0 || presetIndex >= presetCount) { + return false; + } + + const char *fileName = presetList[presetIndex]; + if (!sdFile.open(fileName, O_READ)) { + return false; + } + + targetDoc.clear(); + DeserializationError error = deserializeJson(targetDoc, sdFile); + sdFile.close(); + + return !error; +} + +bool applyPresetByIndex(int presetIndex) { + if (prefetchedPresetIndex == presetIndex) { + presetDoc.clear(); + presetDoc.set(prefetchedPresetDoc.as()); + return true; + } + + return loadPresetDocumentByIndex(presetIndex, presetDoc); +} + +void queueDirectionalPresetPrefetch() { + int candidateIndex = computePrefetchCandidate(currentPreset, presetCount, + presetNavigationDirection, prefetchedPresetIndex); + if (candidateIndex < 0) { + prefetchRequested = false; + prefetchTargetIndex = -1; + if (presetCount <= 1) { + prefetchedPresetIndex = -1; + prefetchedPresetDoc.clear(); + } + return; + } + + prefetchTargetIndex = candidateIndex; + prefetchRequested = true; +} + +void servicePresetPrefetch() { + if (!prefetchRequested || prefetchTargetIndex < 0 || prefetchTargetIndex >= presetCount) { + return; + } + + if (loadPresetDocumentByIndex(prefetchTargetIndex, prefetchedPresetDoc)) { + prefetchedPresetIndex = prefetchTargetIndex; + } + + prefetchRequested = false; + prefetchTargetIndex = -1; +} + +FLASHMEM static void displayCenteredLine(int row, const char *text) { + if (text == nullptr) { + return; + } + + const int textLength = (int)strlen(text); + const int safeLength = textLength > lcdColumnCount ? lcdColumnCount : textLength; + const int padding = calculateCenterPadding(textLength); + + lcd.setCursor(0, row); + for (int i = 0; i < padding; i++) { + lcd.print(' '); + } + for (int i = 0; i < safeLength; i++) { + lcd.print(text[i]); + } + for (int i = padding + safeLength; i < lcdColumnCount; i++) { + lcd.print(' '); + } +} + +FLASHMEM static void showSwitchActionMessage(const char *text, const char *suffix) { + char lineBuffer[uiTextBufferLength]; + if (!formatSwitchActionMessage(text, suffix, lineBuffer, sizeof(lineBuffer))) { + return; + } -void ShowError(const char *errorMessageLine1, const char *errorMessageLine2) { + lcd.clear(); + displayCenteredLine(1, lineBuffer); + setUiMessageTimeout(); +} + +FLASHMEM void showError(const char *errorMessageLine1, const char *errorMessageLine2) { lcd.clear(); lcd.setCursor(0, 0); lcd.print(errorMessageLine1); @@ -58,12 +252,12 @@ void ShowError(const char *errorMessageLine1, const char *errorMessageLine2) { lcd.print(errorMessageLine2); } -void SetPresetDisplayInfo() { +void setPresetDisplayInfo() { lcd.clear(); lcd.setCursor(0, 0); - const char *sw1; - const char *sw2; - const char *sw3; + const char *sw1 = ""; + const char *sw2 = ""; + const char *sw3 = ""; if (pcModeOn) { lcd.print("Prog Change Mode"); @@ -72,11 +266,11 @@ void SetPresetDisplayInfo() { sw3 = "Up"; lcd.setCursor(0, 1); lcd.print("Current PC: "); - lcd.print(pcModePCValue); + lcd.print(pcModeProgram); } else { - lcd.print(preset["Name"].as()); // print message at the second row + lcd.print(activePreset["Name"] | ""); - JsonObject fileInfo = preset["FileInfo"]; + JsonObject fileInfo = activePreset["FileInfo"]; if (!fileInfo.isNull()) { if (playingMidiFile) { @@ -86,39 +280,43 @@ void SetPresetDisplayInfo() { } } else { - sw1 = preset["Switch1"]["Name"].as(); + sw1 = activePreset["Switch1"]["Name"] | ""; } - sw2 = preset["Switch2"]["Name"].as(); - sw3 = preset["Switch3"]["Name"].as(); + sw2 = activePreset["Switch2"]["Name"] | ""; + sw3 = activePreset["Switch3"]["Name"] | ""; } + int sw1Length = (int)strlen(sw1); + int sw2Length = (int)strlen(sw2); + int sw3Length = (int)strlen(sw3); - int sw1Length = strlen(sw1); - int sw2Length = strlen(sw2); - int sw3Length = strlen(sw3); + int padding1, padding3; + calculateSwitchPadding(sw1Length, sw2Length, sw3Length, padding1, padding3); - // Calculate the padding needed for each string - int totalLength = sw1Length + sw2Length + sw3Length; - int padding1 = (totalLength < 20) ? (20 - totalLength) / 2 : 0; - int padding3 = (totalLength < 20) ? (20 - totalLength + 1) / 2 : 0; + char sw1Indicator[maxPresetNameLength]; + char sw2Indicator[maxPresetNameLength]; + char sw3Indicator[maxPresetNameLength]; - char *sw1Indicator = new char[sw1Length + 1](); // Allocate memory and initialize to 0 - char *sw2Indicator = new char[sw2Length + 1](); // Allocate memory and initialize to 0 - char *sw3Indicator = new char[sw3Length + 1](); // Allocate memory and initialize to 0 + const int sw1CopyLength = sw1Length < (maxPresetNameLength - 1) ? sw1Length : (maxPresetNameLength - 1); + const int sw2CopyLength = sw2Length < (maxPresetNameLength - 1) ? sw2Length : (maxPresetNameLength - 1); + const int sw3CopyLength = sw3Length < (maxPresetNameLength - 1) ? sw3Length : (maxPresetNameLength - 1); lcd.setCursor(0, 2); - for (int i = 0; i < sw1Length; i++) { - sw1Indicator[i] = (switchOneToggled) ? '*' : ' '; + for (int i = 0; i < sw1CopyLength; i++) { + sw1Indicator[i] = switchToggled[0] ? '*' : ' '; } + sw1Indicator[sw1CopyLength] = '\0'; - for (int i = 0; i < sw2Length; i++) { - sw2Indicator[i] = (switchTwoToggled) ? '*' : ' '; + for (int i = 0; i < sw2CopyLength; i++) { + sw2Indicator[i] = switchToggled[1] ? '*' : ' '; } + sw2Indicator[sw2CopyLength] = '\0'; - for (int i = 0; i < sw3Length; i++) { - sw3Indicator[i] = (switchThreeToggled) ? '*' : ' '; + for (int i = 0; i < sw3CopyLength; i++) { + sw3Indicator[i] = switchToggled[2] ? '*' : ' '; } + sw3Indicator[sw3CopyLength] = '\0'; lcd.print(sw1Indicator); for (int i = 0; i < padding1; i++) { @@ -144,310 +342,159 @@ void SetPresetDisplayInfo() { lcd.print(" "); } lcd.print(sw3); - - - - // Free allocated memory for indicators - delete[] sw1Indicator; - delete[] sw2Indicator; - delete[] sw3Indicator; } static void switchHandler(uint8_t btnId, uint8_t btnState) { - - if (btnState == BTN_PRESSED && (btnId == 4 || btnId == 5) && millis() > (longHoldStartMillis + 3000) && hasLoaded == true) { - if (pcModeOn) { - pcModeOn = false; - } else { - pcModeOn = true; + if (btnState == BTN_PRESSED) { + longHoldStartMillis = currentMillis; + if (hasLoaded && btnId <= 3) { + executeSwitchLogic(btnId); } - - USBMIDI.sendProgramChange(pcModePCValue, 1); - SetPresetDisplayInfo(); - - return; } - if (btnState == BTN_PRESSED && hasLoaded == true) { - if (btnId == 1 || btnId == 2 || btnId == 3) { - ExecuteSwitchLogic(btnId); - } else if (btnId == 4) { - if (currentPreset + 1 >= numPrograms - 1) { - return; - } else { - currentPreset++; - } - - ChangePreset(); - } else if (btnId == 5) { - currentPreset--; - if (currentPreset < 0) { - currentPreset = 0; - } - - ChangePreset(); - } + // BTN_OPEN — handle nav buttons on release to support long-hold detection + if (!hasLoaded) { + return; } - if (btnState == BTN_OPEN) { - longHoldStartMillis = millis(); + if ((btnId == 4 || btnId == 5) && hasElapsed(currentMillis, longHoldStartMillis, longHoldToggleMs)) { + pcModeOn = !pcModeOn; + sendProgramChange(pcModeProgram, 1, true); + requestPresetDisplayRefresh(); + return; } -} -void MidiFileStop() { - JsonArray stopCC = preset["FileInfo"]["StopCC"]; - - if (!stopCC.isNull()) { - for (JsonVariant ccEvent : stopCC) { - int ccNumber = ccEvent["CC"]; - int ccValue = ccEvent["Value"]; - int ccChannel = ccEvent["Channel"]; - bool usbEvent = ccEvent["USB"]; - - - if (usbEvent) { - USBMIDI.sendControlChange(ccNumber, ccValue, ccChannel); - } else { - MIDI1.sendControlChange(ccNumber, ccValue, ccChannel); - } + if (btnId == 4) { + presetNavigationDirection = 1; + if (!canNavigateNext(currentPreset, presetCount)) { + return; + } + currentPreset++; + changePreset(); + } else if (btnId == 5) { + presetNavigationDirection = -1; + if (!canNavigatePrev(currentPreset)) { + return; } + currentPreset--; + changePreset(); } } -void PlayStopMidiFile(JsonObject fileInfo) { +FLASHMEM void stopMidiFile() { + sendCcArray(activePreset["FileInfo"]["StopCC"]); +} - char text[50]; - const char *playingText = " Playing"; - const char *stoppingText = " Stopping"; +FLASHMEM void toggleMidiFilePlayback(JsonObject fileInfo) { const char *midiFile = fileInfo["FileName"].as(); if (!playingMidiFile) { - int err = SMF.load(midiFile); + int err = midiFilePlayer.load(midiFile); if (err != MD_MIDIFile::E_OK) { - ShowError("Midi File load Error ", ""); + showError("Midi File load Error ", ""); } else { - - SMF.setTempo(fileInfo["BPM"].as()); - midiFileChannel = fileInfo["Channel"].as(); + midiFilePlayer.setTempo(fileInfo["BPM"].as()); + midiFileOutputChannel = fileInfo["Channel"].as(); + playingMidiFile = true; } - playingMidiFile = true; } else { playingMidiFile = false; stoppingMidiFile = true; } - strcpy(text, midiFile); - int textLength = strlen(text); + char text[uiTextBufferLength]; + snprintf(text, sizeof(text), "%s", midiFile != nullptr ? midiFile : ""); - if (textLength > 1) { + if (strlen(text) > 1) { lcd.clear(); - lcd.setCursor(0, 1); - int padding = (20 - textLength) / 2; - - // Print leading spaces for centering - for (int i = 0; i < padding; i++) { - lcd.print(" "); - } - - // Print the text - lcd.print(text); + displayCenteredLine(1, text); lcd.setCursor(0, 2); - if (playingMidiFile) { - lcd.print(playingText); - } else { - lcd.print(stoppingText); - } - startMillis = millis(); - resetPresetDisplay = true; + lcd.print(playingMidiFile ? " Playing" : " Stopping"); + setUiMessageTimeout(); } } -void PCModeEvent(int switchNo) { - - // bool usbEvent = preset["PCMode"]["USB"].as(); - // int channel = preset["PCMode"]["Channel"].as(); - - - if (switchNo == 2) { - if (pcModePCValue >= 1) { - pcModePCValue--; - } - - } else { - pcModePCValue++; - } - - // if (usbEvent) { - USBMIDI.sendProgramChange(pcModePCValue, 1); - // } else { - // MIDI1.sendProgramChange(pcModePCValue, channel); - // } - - EEPROM.put(pcAddress, pcModePCValue); - - SetPresetDisplayInfo(); +void handlePcModeEvent(int switchNo) { + pcModeProgram = adjustPcProgram(pcModeProgram, switchNo == 2); + sendProgramChange(pcModeProgram, 1, true); + queuePcSave(pcModeProgram); + requestPresetDisplayRefresh(); } -void ExecuteSwitchLogic(int switchNo) { - +void executeSwitchLogic(int switchNo) { JsonObject switchLogic; switch (switchNo) { case 1: - switchLogic = preset["Switch1"]; + switchLogic = activePreset["Switch1"]; break; case 2: - switchLogic = preset["Switch2"]; + switchLogic = activePreset["Switch2"]; break; case 3: - switchLogic = preset["Switch3"]; + switchLogic = activePreset["Switch3"]; break; } - JsonArray switchPC = switchLogic["PC"]; - JsonObject fileInfo = preset["FileInfo"]; - - - if (!fileInfo.isNull() && switchNo == 1) { - PlayStopMidiFile(fileInfo); - } else { + // MIDI file playback only triggers on switch 1 — skip FileInfo lookup for 2/3 + if (switchNo == 1) { + JsonObject fileInfo = activePreset["FileInfo"]; + if (!fileInfo.isNull()) { + toggleMidiFilePlayback(fileInfo); + return; + } + } - if (pcModeOn) { - PCModeEvent(switchNo); + if (pcModeOn) { + handlePcModeEvent(switchNo); + return; + } - } else { + const char *switchName = switchLogic["Name"].as(); + bool toggle = switchLogic["Toggle"].as(); + const bool wasToggled = switchToggled[switchNo - 1]; - const char *tempText = switchLogic["Name"].as(); - char text[50]; - bool toggle = switchLogic["Toggle"].as(); - - const char *onText = " On!"; - const char *offText = " Off!"; - - if (toggle) { - switch (switchNo) { - case 1: - if (!switchOneToggled) { - strcpy(text, tempText); - strcat(text, onText); - } else { - strcpy(text, tempText); - strcat(text, offText); - } - break; - case 2: - if (!switchTwoToggled) { - strcpy(text, tempText); - strcat(text, onText); - } else { - strcpy(text, tempText); - strcat(text, offText); - } - break; - case 3: - if (!switchThreeToggled) { - strcpy(text, tempText); - strcat(text, onText); - } else { - strcpy(text, tempText); - strcat(text, offText); - } - break; - } - } else { - strcpy(text, tempText); - } + sendPcArray(switchLogic["PC"]); - int textLength = strlen(text); + JsonArray ccArray = switchLogic["CC"]; + if (!ccArray.isNull()) { + bool nextToggleState = false; + bool useToggleValue = false; - if (textLength > 1) { - lcd.clear(); - lcd.setCursor(0, 1); - int padding = (20 - textLength) / 2; + if (toggle) { + nextToggleState = !wasToggled; + useToggleValue = true; + } - // Print leading spaces for centering - for (int i = 0; i < padding; i++) { - lcd.print(" "); - } + for (JsonVariant cc : ccArray) { + int ccNumber = cc["CC"]; + int ccValue = cc["Value"]; - // Print the text - lcd.print(text); - startMillis = millis(); - resetPresetDisplay = true; + if (useToggleValue) { + ccValue = toggleCcValue(wasToggled); } - if (!switchPC.isNull()) { - for (JsonVariant pcEvent : switchPC) { - int pc = pcEvent["PC"]; - int channel = pcEvent["Channel"]; - bool usbEvent = pcEvent["USB"]; - - if (usbEvent) { - USBMIDI.sendProgramChange(pc - 1, channel); - } else { - MIDI1.sendProgramChange(pc - 1, channel); - } - } - } + int ccChannel = cc["Channel"]; + bool usbEvent = cc["USB"]; - JsonArray ccArray = switchLogic["CC"]; - - if (!ccArray.isNull()) { - for (JsonVariant cc : ccArray) { - int ccNumber = cc["CC"]; - int ccValue = cc["Value"]; - - switch (switchNo) { - case 1: - if (toggle && switchOneToggled) { - ccValue = 0; - switchOneToggled = false; - } else if (toggle && !switchOneToggled) { - ccValue = 127; - switchOneToggled = true; - } - - break; - case 2: - if (toggle && switchTwoToggled) { - ccValue = 0; - switchTwoToggled = false; - } else if (toggle && !switchTwoToggled) { - ccValue = 127; - switchTwoToggled = true; - } - - break; - case 3: - if (toggle && switchThreeToggled) { - ccValue = 0; - switchThreeToggled = false; - } else if (toggle && !switchThreeToggled) { - ccValue = 127; - switchThreeToggled = true; - } - - break; - } - - int ccChannel = cc["Channel"]; - bool usbEvent = cc["USB"]; - - if (usbEvent) { - USBMIDI.sendControlChange(ccNumber, ccValue, ccChannel); - } else { - MIDI1.sendControlChange(ccNumber, ccValue, ccChannel); - } - } - } + sendControlChange(ccNumber, ccValue, ccChannel, usbEvent); + } + + if (useToggleValue) { + switchToggled[switchNo - 1] = nextToggleState; } } -} -void ChangePreset() { + if (toggle) { + showSwitchActionMessage(switchName, !wasToggled ? " On!" : " Off!"); + } else { + showSwitchActionMessage(switchName, ""); + } +} +FLASHMEM void changePreset() { if (pcModeOn) { return; } @@ -456,6 +503,11 @@ void ChangePreset() { resetPresetDisplay = false; } + if (presetCount <= 0) { + showError("No presets found", "Add .json files to SD"); + return; + } + if (currentPreset < 0) { currentPreset = 0; } @@ -465,151 +517,97 @@ void ChangePreset() { stoppingMidiFile = true; } - switchOneToggled = false; - switchTwoToggled = false; - switchThreeToggled = false; + memset(switchToggled, 0, sizeof(switchToggled)); // if saved currentPreset value is greater than the number of presets, reset to 0 - if (currentPreset + 1 >= numPrograms) { - currentPreset = 0; - ChangePreset(); - } - + currentPreset = clampPresetIndex(currentPreset, presetCount); - char *fileName = presetList[currentPreset]; - - if (!file.open(fileName, O_READ)) { - ShowError("SD Error", ""); + if (!applyPresetByIndex(currentPreset)) { + showError("Preset load error", "Check JSON / SD card"); + return; } - DeserializationError error = deserializeJson(doc, file); - if (error) { - ShowError(error.c_str(), ""); - } + activePreset = presetDoc; - preset = doc; + sendPcArray(activePreset["OnLoad"]["PC"]); + sendCcArray(activePreset["OnLoad"]["CC"]); - JsonArray onLoadPC = preset["OnLoad"]["PC"]; - JsonArray onLoadCC = preset["OnLoad"]["CC"]; + setPresetDisplayInfo(); + queuePresetSave(currentPreset); + queueDirectionalPresetPrefetch(); +} +FLASHMEM void showBootScreen() { + // Custom character: solid block for loading bar + byte fullBlock[8] = {0x1F, 0x1F, 0x1F, 0x1F, 0x1F, 0x1F, 0x1F, 0x1F}; + byte emptyBlock[8] = {0x1F, 0x11, 0x11, 0x11, 0x11, 0x11, 0x1F, 0x00}; + lcd.createChar(0, fullBlock); + lcd.createChar(1, emptyBlock); - if (!onLoadPC.isNull()) { - for (JsonVariant pcEvent : onLoadPC) { - int pc = pcEvent["PC"]; - int channel = pcEvent["Channel"]; - bool usbEvent = pcEvent["USB"]; + lcd.setCursor(0, 0); + lcd.print("TA Audio"); + lcd.setCursor(0, 1); + lcd.print("SYNAPSE"); + lcd.setCursor(0, 2); + lcd.print("MIDI CONTROLLER"); - if (usbEvent) { - USBMIDI.sendProgramChange(pc - 1, channel); - } else { - MIDI1.sendProgramChange(pc - 1, channel); - } - } + // Draw empty loading bar frame on row 3 + for (int i = 0; i < lcdColumnCount; i++) { + lcd.setCursor(i, 3); + lcd.write((uint8_t)1); } - if (!onLoadCC.isNull()) { - for (JsonVariant ccEvent : onLoadCC) { - int ccNumber = ccEvent["CC"]; - int ccValue = ccEvent["Value"]; - int ccChannel = ccEvent["Channel"]; - bool usbEvent = ccEvent["USB"]; - - - if (usbEvent) { - USBMIDI.sendControlChange(ccNumber, ccValue, ccChannel); - } else { - MIDI1.sendControlChange(ccNumber, ccValue, ccChannel); - } - } + // Animate the loading bar filling left to right + for (int i = 0; i < lcdColumnCount; i++) { + lcd.setCursor(i, 3); + lcd.write((uint8_t)0); + delay(80); } - - SetPresetDisplayInfo(); - EEPROM.put(address, currentPreset); -} - -void BootLCD() { - - lcd.setCursor(0, 0); // move cursor the first row - lcd.print("TA Audio"); // print message at the first row - lcd.setCursor(0, 1); // move cursor to the second row - lcd.print("SYNAPSE"); // print message at the second row - lcd.setCursor(0, 2); // move cursor to the third row - lcd.print("MIDI CONTROLLER"); // print message at the third row - lcd.setCursor(0, 3); // move cursor to the fourth row - lcd.print("v0.1.0"); // print message the fourth row - - // Change to use millis to setup can continue whilst lcb boot seq is shown - delay(2000); + delay(400); } -void GetPresets() { +FLASHMEM void loadPresetList() { + presetCount = 0; + rootDir.open("/"); - // Open root directory - dir.open("/"); + while (sdFile.openNext(&rootDir, O_RDONLY)) { + char fileName[maxPresetNameLength]; + sdFile.getName(fileName, maxPresetNameLength); - - - while (file.openNext(&dir, O_RDONLY)) { - - int max_characters = 25; // guess the needed characters - char f_name[max_characters]; // the filename variable you want - file.getName(f_name, max_characters); - - // Check if the file has a ".json" extension - const char *extension = ".json"; - int nameLength = strlen(f_name); - int extensionLength = strlen(extension); - - // Check if the file ends with ".json" - if (nameLength >= extensionLength && strcmp(f_name + nameLength - extensionLength, extension) == 0) { - // If it ends with ".json", copy the filename to presetList - strncpy(presetList[currentIndex], f_name, maxStringLength); - currentIndex++; // Increment the list length + if (hasJsonExtension(fileName)) { + if (presetCount >= maxPresetListSize) { + sdFile.close(); + break; + } + strncpy(presetList[presetCount], fileName, maxPresetNameLength); + presetList[presetCount][maxPresetNameLength - 1] = '\0'; + presetCount++; } - file.close(); + sdFile.close(); } - if (dir.getError()) { - ShowError("Error opening SD", ""); + if (rootDir.getError()) { + showError("Error opening SD", ""); } + rootDir.close(); + // Sort presetList numerically - for (int i = 0; i < currentIndex - 1; i++) { - for (int j = 0; j < currentIndex - i - 1; j++) { - // Extract numbers from filenames - int num1 = extractNumber(presetList[j]); - int num2 = extractNumber(presetList[j + 1]); - - // Compare the extracted numbers - if (num1 > num2) { - char temp[maxStringLength]; - strncpy(temp, presetList[j], maxStringLength); - temp[maxStringLength - 1] = '\0'; // Ensure null termination - strncpy(presetList[j], presetList[j + 1], maxStringLength); - strncpy(presetList[j + 1], temp, maxStringLength); - } - } + if (presetCount > 1) { + qsort(presetList, presetCount, sizeof(presetList[0]), comparePresetNames); } - numPrograms = currentIndex; -} - -// Function to extract number from filename -int extractNumber(const char *filename) { - int num = 0; - int i = 0; - while (filename[i] != '\0' && filename[i] >= '0' && filename[i] <= '9') { - num = num * 10 + (filename[i] - '0'); - i++; - } - return num; + prefetchedPresetIndex = -1; + prefetchTargetIndex = -1; + prefetchRequested = false; + prefetchedPresetDoc.clear(); } void midiFileCallback(midi_event *pev) { if ((pev->data[0] >= 0x80) && (pev->data[0] <= 0xe0)) { - Serial1.write(pev->data[0] | (midiFileChannel - 1)); + Serial1.write(pev->data[0] | (midiFileOutputChannel - 1)); Serial1.write(&pev->data[1], pev->size - 1); } } @@ -620,11 +618,11 @@ static Button switch3Button(3, switchHandler); static Button nextPresetButton(4, switchHandler); static Button prevPresetButton(5, switchHandler); -void setup() { +FLASHMEM void setup() { lcd.init(); // initialize the lcd lcd.backlight(); - BootLCD(); + showBootScreen(); // Wait 1.5 seconds before turning on USB Host. If connected USB devices // use too much power, Teensy at least completes USB enumeration, which @@ -632,13 +630,11 @@ void setup() { delay(1500); usbHost.begin(); - // initialize the digital pin as an output. - pinMode(13, OUTPUT); - pinMode(switch1, INPUT); - pinMode(switch2, INPUT); - pinMode(switch3, INPUT); - pinMode(nextPreset, INPUT); - pinMode(prevPreset, INPUT); + pinMode(switch1Pin, INPUT); + pinMode(switch2Pin, INPUT); + pinMode(switch3Pin, INPUT); + pinMode(nextPresetPin, INPUT); + pinMode(prevPresetPin, INPUT); MIDI1.begin(MIDI_CHANNEL_OMNI); @@ -647,43 +643,38 @@ void setup() { MIDI3.begin(MIDI_CHANNEL_OMNI); MIDI3.turnThruOff(); - - if (!SD.begin(BUILTIN_SDCARD)) { - ShowError("SD Card Error", "Is the card inserted and fat32?"); + showError("SD Card Error", "Is the card inserted and fat32?"); while (true) ; } - SMF.begin(&(SdFat &)SD); - SMF.setMidiHandler(midiFileCallback); - + midiFilePlayer.begin(&(SdFat &)SD); + midiFilePlayer.setMidiHandler(midiFileCallback); - GetPresets(); + loadPresetList(); - // delay(500); + EEPROM.get(presetEepromAddress, currentPreset); + EEPROM.get(pcModeEepromAddress, pcModeProgram); - EEPROM.get(address, currentPreset); - EEPROM.get(pcAddress, pcModePCValue); + currentPreset = validateStoredPreset(currentPreset, maxPresetListSize); - if (pcModePCValue < 0 || pcModePCValue > 127) { - pcModePCValue = 0; - EEPROM.put(pcAddress, pcModePCValue); + const int validatedPc = validateStoredPcProgram(pcModeProgram); + if (validatedPc != pcModeProgram) { + pcModeProgram = validatedPc; + queuePcSave(pcModeProgram); } - Serial.println(currentPreset); - startMillis = millis(); } static void pollButtons() { - // update() will call buttonHandler() if PIN transitions to a new state and stays there - // for multiple reads over 25+ ms. - switch1Button.update(digitalRead(switch1)); - switch2Button.update(digitalRead(switch2)); - switch3Button.update(digitalRead(switch3)); - nextPresetButton.update(digitalRead(nextPreset)); - prevPresetButton.update(digitalRead(prevPreset)); + // digitalReadFast compiles to a single register read vs digitalRead's pin lookup table. + switch1Button.update(digitalReadFast(switch1Pin)); + switch2Button.update(digitalReadFast(switch2Pin)); + switch3Button.update(digitalReadFast(switch3Pin)); + nextPresetButton.update(digitalReadFast(nextPresetPin)); + prevPresetButton.update(digitalReadFast(prevPresetPin)); } void loop() { @@ -692,50 +683,41 @@ void loop() { usbHost.Task(); - // USBMIDI.sendProgramChange(1, 1); - MIDI1.read(); - - if (MIDI2.read()) { - MIDI1.send(MIDI2.getType(), - MIDI2.getData1(), - MIDI2.getData2(), - MIDI2.getChannel()); - } - - if (MIDI3.read()) { - MIDI1.send(MIDI3.getType(), - MIDI3.getData1(), - MIDI3.getData2(), - MIDI3.getChannel()); - } + serviceMidiPassthrough(); pollButtons(); - if (!hasLoaded && currentMillis - startMillis >= period) { + if (!hasLoaded && (currentMillis - startMillis >= initialLoadDelayMs)) { hasLoaded = true; - ChangePreset(); + changePreset(); } - if (resetPresetDisplay && currentMillis > (startMillis + switchDisplayPeriod)) { + if (resetPresetDisplay && (currentMillis - switchDisplayStartMillis >= switchDisplayPeriodMs)) { resetPresetDisplay = false; - SetPresetDisplayInfo(); + requestPresetDisplayRefresh(); } if (playingMidiFile) { - if (!SMF.isEOF()) { - SMF.getNextEvent(); + if (!midiFilePlayer.isEOF()) { + midiFilePlayer.getNextEvent(); } else { playingMidiFile = false; stoppingMidiFile = true; } } else { - if (stoppingMidiFile == true) { - SMF.close(); - MidiFileStop(); + if (stoppingMidiFile) { + midiFilePlayer.close(); + stopMidiFile(); stoppingMidiFile = false; playingMidiFile = false; - SetPresetDisplayInfo(); + requestPresetDisplayRefresh(); } } + + servicePresetDisplayRefresh(); + servicePresetPrefetch(); + commitPendingEepromWrites(); } + + diff --git a/setup-local.ps1 b/setup-local.ps1 new file mode 100644 index 0000000..8c456b0 --- /dev/null +++ b/setup-local.ps1 @@ -0,0 +1,188 @@ +<# +.SYNOPSIS + Installs all prerequisites for building the Synapse MIDI Controller firmware + and optionally uploads it to a connected Teensy 4.1. + +.DESCRIPTION + This script: + 1. Downloads and installs arduino-cli (if not already on PATH). + 2. Installs the Teensy board package. + 3. Installs all required Arduino libraries. + 4. Patches the MD_MIDIFile library for Teensy/SdFat compatibility. + 5. Compiles the firmware. + 6. Optionally uploads to a connected Teensy 4.1. + +.PARAMETER Upload + If specified, uploads the compiled firmware to the Teensy after building. + +.PARAMETER Port + Serial port for upload (e.g. COM3). If omitted, arduino-cli will attempt + auto-detection. + +.EXAMPLE + .\setup-local.ps1 + .\setup-local.ps1 -Upload + .\setup-local.ps1 -Upload -Port COM5 +#> +[CmdletBinding()] +param( + [switch]$Upload, + [string]$Port +) + +$ErrorActionPreference = 'Stop' + +$fqbn = 'teensy:avr:teensy41' +$sketchPath = Join-Path $PSScriptRoot 'midicontroller' 'midicontroller.ino' +$boardManagerUrl = 'https://www.pjrc.com/teensy/package_teensy_index.json' + +# ── Helper ────────────────────────────────────────────────────────────────────── + +function Write-Step { + param([string]$Message) + Write-Host "`n▶ $Message" -ForegroundColor Cyan +} + +# ── 1. Ensure arduino-cli is installed ────────────────────────────────────────── + +Write-Step 'Checking for arduino-cli...' + +if (-not (Get-Command arduino-cli -ErrorAction SilentlyContinue)) { + Write-Host ' arduino-cli not found. Installing via winget...' + if (-not (Get-Command winget -ErrorAction SilentlyContinue)) { + Write-Error 'winget is not available. Please install arduino-cli manually: https://arduino.github.io/arduino-cli/installation/' + exit 1 + } + winget install --id ArduinoSA.CLI --accept-source-agreements --accept-package-agreements + # Refresh PATH for current session + $env:PATH = [System.Environment]::GetEnvironmentVariable('PATH', 'Machine') + ';' + + [System.Environment]::GetEnvironmentVariable('PATH', 'User') + if (-not (Get-Command arduino-cli -ErrorAction SilentlyContinue)) { + Write-Error 'arduino-cli installed but not found on PATH. Please restart your terminal and run this script again.' + exit 1 + } +} + +Write-Host " Found: $(arduino-cli version)" + +# ── 2. Configure arduino-cli ──────────────────────────────────────────────────── + +Write-Step 'Configuring arduino-cli...' +arduino-cli config init --overwrite 2>$null +$arduinoUserDir = Join-Path $env:TEMP 'ArduinoMidiController' +arduino-cli config set directories.user $arduinoUserDir +arduino-cli config set board_manager.additional_urls $boardManagerUrl +arduino-cli config set library.enable_unsafe_install true +Write-Host " Arduino user directory set to $arduinoUserDir" +Write-Host ' Board manager URL and unsafe install configured.' + +# ── 3. Install Teensy board package ───────────────────────────────────────────── + +Write-Step 'Installing Teensy board package (this may take a few minutes)...' +arduino-cli core update-index +arduino-cli core install teensy:avr + +# ── 4. Install libraries ─────────────────────────────────────────────────────── + +Write-Step 'Installing libraries...' + +# Ensure the Arduino libraries directory exists +$libDir = Join-Path $arduinoUserDir 'libraries' +if (-not (Test-Path $libDir)) { + New-Item -ItemType Directory -Path $libDir -Force | Out-Null + Write-Host " Created $libDir" +} + +arduino-cli lib update-index +arduino-cli lib install 'ArduinoJson@6.21.5' +arduino-cli lib install 'MIDI Library' +arduino-cli lib install 'LiquidCrystal I2C' +arduino-cli lib install 'MD_MIDIFile' +arduino-cli lib install --git-url https://github.com/kimballa/button-debounce.git +Write-Host ' All libraries installed.' + +# ── 5. Patch MD_MIDIFile for Teensy SdFat ─────────────────────────────────────── + +Write-Step 'Patching MD_MIDIFile for Teensy SdFat compatibility...' + +$mdHeader = Join-Path $arduinoUserDir 'libraries' 'MD_MIDIFile' 'src' 'MD_MIDIFile.h' + +if (Test-Path $mdHeader) { + $content = Get-Content $mdHeader -Raw + $patched = $content -replace 'typedef File SDDIR;', 'typedef SdFile SDDIR;' ` + -replace 'typedef File SDFILE;', 'typedef SdFile SDFILE;' + if ($content -ne $patched) { + Set-Content $mdHeader -Value $patched -NoNewline + Write-Host ' Patched typedef File → SdFile in MD_MIDIFile.h' + } else { + Write-Host ' MD_MIDIFile.h already patched or does not need patching.' + } +} else { + Write-Warning "Could not locate MD_MIDIFile.h at $mdHeader — you may need to patch it manually." +} + +# ── 6. Compile ────────────────────────────────────────────────────────────────── + +Write-Step 'Compiling firmware...' +$buildDir = Join-Path $env:TEMP 'ArduinoMidiController_build' +# The Teensy post-build hook (teensy_post_compile) fails on Windows CLI with +# "WaitForSingleObject: The handle is invalid." This is cosmetic — the actual +# compilation and hex generation succeed. We capture output, filter that noise, +# and verify success by checking the hex file was produced. +$ErrorActionPreference = 'Continue' +$compileOutput = arduino-cli compile --fqbn $fqbn --warnings all --build-path $buildDir $sketchPath 2>&1 +$ErrorActionPreference = 'Stop' +$compileOutput | ForEach-Object { + $line = if ($_ -is [System.Management.Automation.ErrorRecord]) { $_.ToString() } else { $_ } + if ($line -notmatch 'WaitForSingleObject') { Write-Host $line } +} +$hexFile = Join-Path $buildDir 'midicontroller.ino.hex' +if (-not (Test-Path $hexFile)) { + Write-Error 'Compile failed — no hex file produced.' + exit 1 +} + +Write-Host "`n✔ Build succeeded." -ForegroundColor Green + +# ── 7. Upload (optional) ─────────────────────────────────────────────────────── + +if ($Upload) { + Write-Step 'Uploading firmware to Teensy 4.1...' + + # Auto-detect Teensy COM port if not specified + if (-not $Port) { + Write-Host ' Detecting Teensy serial port...' + $teensyPort = Get-CimInstance Win32_PnPEntity | + Where-Object { $_.Name -match 'USB Serial.*\(COM\d+\)' -and $_.Manufacturer -match 'PJRC|Teensy' } | + ForEach-Object { if ($_.Name -match '\((COM\d+)\)') { $Matches[1] } } | + Select-Object -First 1 + + if (-not $teensyPort) { + # Fallback: use arduino-cli board list to find a Teensy + $boardJson = arduino-cli board list --format json | ConvertFrom-Json + $teensyBoard = $boardJson | Where-Object { + $_.matching_boards | Where-Object { $_.fqbn -eq $fqbn } + } | Select-Object -First 1 + if ($teensyBoard) { + $teensyPort = $teensyBoard.port.address + } + } + + if ($teensyPort) { + Write-Host " Found Teensy on $teensyPort" -ForegroundColor Green + $Port = $teensyPort + } else { + Write-Warning 'Could not auto-detect Teensy port. Letting arduino-cli attempt upload without -p flag.' + } + } + + $uploadArgs = @('upload', '--fqbn', $fqbn, '--input-dir', $buildDir) + if ($Port) { + $uploadArgs += @('-p', $Port) + } + $uploadArgs += $sketchPath + & arduino-cli @uploadArgs + Write-Host "`n✔ Upload complete." -ForegroundColor Green +} else { + Write-Host "`nTo upload, run: .\setup-local.ps1 -Upload" -ForegroundColor Yellow +} diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt new file mode 100644 index 0000000..d107569 --- /dev/null +++ b/test/CMakeLists.txt @@ -0,0 +1,27 @@ +cmake_minimum_required(VERSION 3.14) +project(MidiControllerTests CXX) + +set(CMAKE_CXX_STANDARD 17) + +include(FetchContent) +FetchContent_Declare( + googletest + GIT_REPOSITORY https://github.com/google/googletest.git + GIT_TAG v1.14.0 +) +FetchContent_Declare( + arduinojson + GIT_REPOSITORY https://github.com/bblanchon/ArduinoJson.git + GIT_TAG v6.21.5 +) +set(gtest_force_shared_crt ON CACHE BOOL "" FORCE) +FetchContent_MakeAvailable(googletest arduinojson) + +enable_testing() + +add_executable(midi_controller_tests test_logic.cpp test_midi_messages.cpp test_debounce.cpp) +target_include_directories(midi_controller_tests PRIVATE ${CMAKE_SOURCE_DIR}/../midicontroller) +target_link_libraries(midi_controller_tests GTest::gtest_main ArduinoJson) + +include(GoogleTest) +gtest_discover_tests(midi_controller_tests) diff --git a/test/midi_mock.h b/test/midi_mock.h new file mode 100644 index 0000000..e035f6b --- /dev/null +++ b/test/midi_mock.h @@ -0,0 +1,39 @@ +#pragma once + +// Minimal mock for MIDI output recording. +// Provides the same interface as the firmware's send functions, +// but records messages into a vector for test assertions. + +#include +#include + +struct MidiMessage { + enum Type { ProgramChange, ControlChange }; + Type type; + int value1; // PC program number, or CC number + int value2; // unused for PC (0), CC value for CC + int channel; + bool usb; +}; + +// Global log of all MIDI messages sent during a test +inline std::vector &midiLog() { + static std::vector log; + return log; +} + +inline void clearMidiLog() { + midiLog().clear(); +} + +// Mock send functions matching firmware signatures +inline void sendProgramChange(int pcValue, int channel, bool usbEvent) { + const int safePc = clampMidi(pcValue); + midiLog().push_back({MidiMessage::ProgramChange, safePc, 0, channel, usbEvent}); +} + +inline void sendControlChange(int ccNumber, int ccValue, int channel, bool usbEvent) { + const int safeCcNumber = clampMidi(ccNumber); + const int safeCcValue = clampMidi(ccValue); + midiLog().push_back({MidiMessage::ControlChange, safeCcNumber, safeCcValue, channel, usbEvent}); +} diff --git a/test/test_debounce.cpp b/test/test_debounce.cpp new file mode 100644 index 0000000..a5ab123 --- /dev/null +++ b/test/test_debounce.cpp @@ -0,0 +1,301 @@ +#include + +#ifndef ARDUINO +#define ARDUINO_MOCK +#endif + +#include "logic.h" + +// ═══════════════════════════════════════════════════════════════════════════════ +// Debounce State Machine Tests +// ═══════════════════════════════════════════════════════════════════════════════ + +class DebounceTest : public ::testing::Test { +protected: + DebounceState state; + + void SetUp() override { + state.init(); + } +}; + +TEST_F(DebounceTest, InitialStateIsOpen) { + EXPECT_EQ(state.currentState, BTN_STATE_OPEN); +} + +TEST_F(DebounceTest, SingleLowSampleDoesNotTriggerPress) { + // One sample at time 0 shouldn't trigger — debounce interval not elapsed + bool changed = state.update(0, 0); + EXPECT_FALSE(changed); + EXPECT_EQ(state.currentState, BTN_STATE_OPEN); +} + +TEST_F(DebounceTest, StableLowSignalTriggersPress) { + // Signal goes low at t=0, stays low past debounce interval + state.update(0, 0); + bool changed = state.update(0, 26); // 26ms > 25ms threshold + EXPECT_TRUE(changed); + EXPECT_EQ(state.currentState, BTN_STATE_PRESSED); +} + +TEST_F(DebounceTest, SignalAtExactThresholdDoesNotTrigger) { + // Must be GREATER than interval, not equal + state.update(0, 0); + bool changed = state.update(0, 25); + EXPECT_FALSE(changed); + EXPECT_EQ(state.currentState, BTN_STATE_OPEN); +} + +TEST_F(DebounceTest, BouncingResetsTimer) { + // Signal goes low at t=0 + state.update(0, 0); + // Bounces back high at t=10 — resets timer + state.update(1, 10); + // Goes low again at t=20 — resets timer again + state.update(0, 20); + // At t=40 (only 20ms since last reset) — should NOT trigger + bool changed = state.update(0, 40); + EXPECT_FALSE(changed); + EXPECT_EQ(state.currentState, BTN_STATE_OPEN); + // At t=46 (26ms since last reset at t=20) — NOW triggers + changed = state.update(0, 46); + EXPECT_TRUE(changed); + EXPECT_EQ(state.currentState, BTN_STATE_PRESSED); +} + +TEST_F(DebounceTest, ReleaseAfterPress) { + // Press first + state.update(0, 0); + state.update(0, 26); + ASSERT_EQ(state.currentState, BTN_STATE_PRESSED); + + // Now release — signal goes high + state.update(1, 50); + bool changed = state.update(1, 76); // 26ms after release began + EXPECT_TRUE(changed); + EXPECT_EQ(state.currentState, BTN_STATE_OPEN); +} + +TEST_F(DebounceTest, RapidBouncingNeverTriggers) { + // Simulate rapid bouncing that never settles + for (unsigned long t = 0; t < 200; t += 10) { + uint8_t sample = (t / 10) % 2; // alternates every 10ms + state.update(sample, t); + } + // Should still be in initial state + EXPECT_EQ(state.currentState, BTN_STATE_OPEN); +} + +TEST_F(DebounceTest, NoChangeWhenSignalMatchesCurrentState) { + // Signal is high (matches BTN_STATE_OPEN) — no transition + bool changed = state.update(1, 0); + EXPECT_FALSE(changed); + changed = state.update(1, 100); + EXPECT_FALSE(changed); + EXPECT_EQ(state.currentState, BTN_STATE_OPEN); +} + +TEST_F(DebounceTest, CustomPushInterval) { + state.init(50, 25); // 50ms push debounce, 25ms release + + state.update(0, 0); + // At 26ms — would trigger with default but not with 50ms + bool changed = state.update(0, 26); + EXPECT_FALSE(changed); + EXPECT_EQ(state.currentState, BTN_STATE_OPEN); + + // At 51ms — triggers + changed = state.update(0, 51); + EXPECT_TRUE(changed); + EXPECT_EQ(state.currentState, BTN_STATE_PRESSED); +} + +TEST_F(DebounceTest, CustomReleaseInterval) { + state.init(25, 50); // 25ms push, 50ms release debounce + + // Press normally + state.update(0, 0); + state.update(0, 26); + ASSERT_EQ(state.currentState, BTN_STATE_PRESSED); + + // Release — needs 50ms + state.update(1, 100); + bool changed = state.update(1, 126); // only 26ms — not enough + EXPECT_FALSE(changed); + EXPECT_EQ(state.currentState, BTN_STATE_PRESSED); + + changed = state.update(1, 151); // 51ms — triggers + EXPECT_TRUE(changed); + EXPECT_EQ(state.currentState, BTN_STATE_OPEN); +} + +TEST_F(DebounceTest, TimerOverflowHandledCorrectly) { + // Simulate unsigned long overflow + unsigned long nearMax = (unsigned long)(-50); // 50ms before overflow + + state.update(0, nearMax); + // After overflow: nearMax + 26 wraps around + unsigned long afterOverflow = nearMax + 26; + bool changed = state.update(0, afterOverflow); + EXPECT_TRUE(changed); + EXPECT_EQ(state.currentState, BTN_STATE_PRESSED); +} + +TEST_F(DebounceTest, MultipleTransitions) { + // Full press-release-press cycle + state.update(0, 0); + state.update(0, 26); + EXPECT_EQ(state.currentState, BTN_STATE_PRESSED); + + state.update(1, 100); + state.update(1, 126); + EXPECT_EQ(state.currentState, BTN_STATE_OPEN); + + state.update(0, 200); + state.update(0, 226); + EXPECT_EQ(state.currentState, BTN_STATE_PRESSED); +} + +TEST_F(DebounceTest, UpdateReturnsFalseWhenNoChange) { + // Already pressed, signal stays low + state.update(0, 0); + state.update(0, 26); // pressed + bool changed = state.update(0, 100); + EXPECT_FALSE(changed); +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// Switch Handler Routing Logic Tests +// ═══════════════════════════════════════════════════════════════════════════════ + +class SwitchHandlerTest : public ::testing::Test {}; + +// --- BTN_PRESSED on switch buttons (1-3) --- + +TEST_F(SwitchHandlerTest, PressButton1WhenLoaded) { + auto action = classifySwitchEvent(1, BTN_STATE_PRESSED, true, false); + EXPECT_EQ(action, SwitchAction::ExecuteSwitch); +} + +TEST_F(SwitchHandlerTest, PressButton2WhenLoaded) { + auto action = classifySwitchEvent(2, BTN_STATE_PRESSED, true, false); + EXPECT_EQ(action, SwitchAction::ExecuteSwitch); +} + +TEST_F(SwitchHandlerTest, PressButton3WhenLoaded) { + auto action = classifySwitchEvent(3, BTN_STATE_PRESSED, true, false); + EXPECT_EQ(action, SwitchAction::ExecuteSwitch); +} + +TEST_F(SwitchHandlerTest, PressButton1NotLoaded) { + auto action = classifySwitchEvent(1, BTN_STATE_PRESSED, false, false); + EXPECT_EQ(action, SwitchAction::None); +} + +TEST_F(SwitchHandlerTest, PressButton2NotLoaded) { + auto action = classifySwitchEvent(2, BTN_STATE_PRESSED, false, false); + EXPECT_EQ(action, SwitchAction::None); +} + +TEST_F(SwitchHandlerTest, PressButton3NotLoaded) { + auto action = classifySwitchEvent(3, BTN_STATE_PRESSED, false, false); + EXPECT_EQ(action, SwitchAction::None); +} + +// --- BTN_PRESSED on nav buttons (4-5) --- + +TEST_F(SwitchHandlerTest, PressButton4RecordsHoldStart) { + auto action = classifySwitchEvent(4, BTN_STATE_PRESSED, true, false); + EXPECT_EQ(action, SwitchAction::RecordHoldStart); +} + +TEST_F(SwitchHandlerTest, PressButton5RecordsHoldStart) { + auto action = classifySwitchEvent(5, BTN_STATE_PRESSED, true, false); + EXPECT_EQ(action, SwitchAction::RecordHoldStart); +} + +TEST_F(SwitchHandlerTest, PressNavButtonRecordsEvenWhenNotLoaded) { + // The firmware records longHoldStartMillis regardless of hasLoaded + auto action = classifySwitchEvent(4, BTN_STATE_PRESSED, false, false); + EXPECT_EQ(action, SwitchAction::RecordHoldStart); +} + +// --- BTN_OPEN (release) on nav buttons — short press --- + +TEST_F(SwitchHandlerTest, ReleaseButton4ShortPressNavigatesNext) { + auto action = classifySwitchEvent(4, BTN_STATE_OPEN, true, false); + EXPECT_EQ(action, SwitchAction::NavigateNext); +} + +TEST_F(SwitchHandlerTest, ReleaseButton5ShortPressNavigatesPrev) { + auto action = classifySwitchEvent(5, BTN_STATE_OPEN, true, false); + EXPECT_EQ(action, SwitchAction::NavigatePrev); +} + +// --- BTN_OPEN (release) on nav buttons — long hold --- + +TEST_F(SwitchHandlerTest, ReleaseButton4LongHoldTogglesPcMode) { + auto action = classifySwitchEvent(4, BTN_STATE_OPEN, true, true); + EXPECT_EQ(action, SwitchAction::TogglePcMode); +} + +TEST_F(SwitchHandlerTest, ReleaseButton5LongHoldTogglesPcMode) { + auto action = classifySwitchEvent(5, BTN_STATE_OPEN, true, true); + EXPECT_EQ(action, SwitchAction::TogglePcMode); +} + +// --- BTN_OPEN before loaded --- + +TEST_F(SwitchHandlerTest, ReleaseButton4NotLoadedNoAction) { + auto action = classifySwitchEvent(4, BTN_STATE_OPEN, false, false); + EXPECT_EQ(action, SwitchAction::None); +} + +TEST_F(SwitchHandlerTest, ReleaseButton5NotLoadedNoAction) { + auto action = classifySwitchEvent(5, BTN_STATE_OPEN, false, false); + EXPECT_EQ(action, SwitchAction::None); +} + +TEST_F(SwitchHandlerTest, ReleaseButton4NotLoadedLongHoldNoAction) { + auto action = classifySwitchEvent(4, BTN_STATE_OPEN, false, true); + EXPECT_EQ(action, SwitchAction::None); +} + +// --- Release on switch buttons (1-3) does nothing --- + +TEST_F(SwitchHandlerTest, ReleaseButton1NoAction) { + auto action = classifySwitchEvent(1, BTN_STATE_OPEN, true, false); + EXPECT_EQ(action, SwitchAction::None); +} + +TEST_F(SwitchHandlerTest, ReleaseButton2NoAction) { + auto action = classifySwitchEvent(2, BTN_STATE_OPEN, true, false); + EXPECT_EQ(action, SwitchAction::None); +} + +TEST_F(SwitchHandlerTest, ReleaseButton3NoAction) { + auto action = classifySwitchEvent(3, BTN_STATE_OPEN, true, false); + EXPECT_EQ(action, SwitchAction::None); +} + +// --- Invalid button IDs --- + +TEST_F(SwitchHandlerTest, PressButton0NoAction) { + auto action = classifySwitchEvent(0, BTN_STATE_PRESSED, true, false); + EXPECT_EQ(action, SwitchAction::None); +} + +TEST_F(SwitchHandlerTest, PressButton6NoAction) { + auto action = classifySwitchEvent(6, BTN_STATE_PRESSED, true, false); + EXPECT_EQ(action, SwitchAction::None); +} + +TEST_F(SwitchHandlerTest, ReleaseButton0NoAction) { + auto action = classifySwitchEvent(0, BTN_STATE_OPEN, true, false); + EXPECT_EQ(action, SwitchAction::None); +} + +TEST_F(SwitchHandlerTest, ReleaseButton6NoAction) { + auto action = classifySwitchEvent(6, BTN_STATE_OPEN, true, false); + EXPECT_EQ(action, SwitchAction::None); +} diff --git a/test/test_logic.cpp b/test/test_logic.cpp new file mode 100644 index 0000000..1516cdb --- /dev/null +++ b/test/test_logic.cpp @@ -0,0 +1,842 @@ +#include +#include "logic.h" + +// ═══════════════════════════════════════════════════════════════════════════════ +// ClampMidi +// ═══════════════════════════════════════════════════════════════════════════════ + +TEST(ClampMidi, BelowMinimum) { + EXPECT_EQ(clampMidi(-1), 0); +} + +TEST(ClampMidi, AtMinimum) { + EXPECT_EQ(clampMidi(0), 0); +} + +TEST(ClampMidi, InRange) { + EXPECT_EQ(clampMidi(64), 64); +} + +TEST(ClampMidi, AtMaximum) { + EXPECT_EQ(clampMidi(127), 127); +} + +TEST(ClampMidi, AboveMaximum) { + EXPECT_EQ(clampMidi(128), 127); +} + +TEST(ClampMidi, LargeNegative) { + EXPECT_EQ(clampMidi(-1000), 0); +} + +TEST(ClampMidi, LargePositive) { + EXPECT_EQ(clampMidi(1000), 127); +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// ExtractNumber +// ═══════════════════════════════════════════════════════════════════════════════ + +TEST(ExtractNumber, SingleDigit) { + EXPECT_EQ(extractNumber("1.json"), 1); +} + +TEST(ExtractNumber, MultiDigit) { + EXPECT_EQ(extractNumber("100.json"), 100); +} + +TEST(ExtractNumber, NonNumericFilename) { + EXPECT_EQ(extractNumber("abc.json"), 0); +} + +TEST(ExtractNumber, MixedNumericPrefix) { + EXPECT_EQ(extractNumber("12abc.json"), 12); +} + +TEST(ExtractNumber, EmptyString) { + EXPECT_EQ(extractNumber(""), 0); +} + +TEST(ExtractNumber, JustDigits) { + EXPECT_EQ(extractNumber("42"), 42); +} + +TEST(ExtractNumber, LeadingZeros) { + EXPECT_EQ(extractNumber("007.json"), 7); +} + +TEST(ExtractNumber, LargeNumber) { + EXPECT_EQ(extractNumber("999.json"), 999); +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// ComparePresetNames +// ═══════════════════════════════════════════════════════════════════════════════ + +TEST(ComparePresetNames, FirstSmaller) { + char a[maxPresetNameLength] = "1.json"; + char b[maxPresetNameLength] = "2.json"; + EXPECT_LT(comparePresetNames(a, b), 0); +} + +TEST(ComparePresetNames, FirstLarger) { + char a[maxPresetNameLength] = "10.json"; + char b[maxPresetNameLength] = "2.json"; + EXPECT_GT(comparePresetNames(a, b), 0); +} + +TEST(ComparePresetNames, Equal) { + char a[maxPresetNameLength] = "5.json"; + char b[maxPresetNameLength] = "5.json"; + EXPECT_EQ(comparePresetNames(a, b), 0); +} + +TEST(ComparePresetNames, NumericSortNotLexicographic) { + // Lexicographic: "10" < "2", but numeric: 10 > 2 + char a[maxPresetNameLength] = "10.json"; + char b[maxPresetNameLength] = "9.json"; + EXPECT_GT(comparePresetNames(a, b), 0); +} + +TEST(ComparePresetNames, NonNumericFallsBackToLexicographic) { + char a[maxPresetNameLength] = "abc.json"; + char b[maxPresetNameLength] = "def.json"; + // Both extract 0, so falls back to strncmp + EXPECT_LT(comparePresetNames(a, b), 0); +} + +TEST(ComparePresetNames, NumericVsNonNumeric) { + char a[maxPresetNameLength] = "1.json"; + char b[maxPresetNameLength] = "abc.json"; + // a extracts 1, b extracts 0 → a > b + EXPECT_GT(comparePresetNames(a, b), 0); +} + +TEST(ComparePresetNames, SortFullList) { + char names[][maxPresetNameLength] = { + "10.json", "2.json", "1.json", "20.json", "3.json", "100.json" + }; + qsort(names, 6, sizeof(names[0]), comparePresetNames); + EXPECT_STREQ(names[0], "1.json"); + EXPECT_STREQ(names[1], "2.json"); + EXPECT_STREQ(names[2], "3.json"); + EXPECT_STREQ(names[3], "10.json"); + EXPECT_STREQ(names[4], "20.json"); + EXPECT_STREQ(names[5], "100.json"); +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// HasElapsed +// ═══════════════════════════════════════════════════════════════════════════════ + +TEST(HasElapsed, NotYetElapsed) { + EXPECT_FALSE(hasElapsed(100, 50, 100)); +} + +TEST(HasElapsed, ExactlyElapsed) { + EXPECT_TRUE(hasElapsed(150, 50, 100)); +} + +TEST(HasElapsed, PastElapsed) { + EXPECT_TRUE(hasElapsed(200, 50, 100)); +} + +TEST(HasElapsed, ZeroDuration) { + EXPECT_TRUE(hasElapsed(100, 100, 0)); +} + +TEST(HasElapsed, UnsignedOverflowElapsed) { + // Simulates millis() wrap-around: start near max, current past zero + // On Arduino/Teensy unsigned long is 32-bit. On 64-bit Linux it's 64-bit. + // Use values that work correctly under both widths. + const unsigned long start = (unsigned long)(-50); // near max + const unsigned long current = start + 100; // 100ms after start (wraps on 32-bit) + EXPECT_TRUE(hasElapsed(current, start, 100)); +} + +TEST(HasElapsed, UnsignedOverflowNotYetElapsed) { + const unsigned long start = (unsigned long)(-50); + const unsigned long current = start + 30; // only 30ms after start + EXPECT_FALSE(hasElapsed(current, start, 100)); +} + +TEST(HasElapsed, LongHoldThreshold) { + // Real-world: long hold of 3000ms + EXPECT_FALSE(hasElapsed(2999, 0, longHoldToggleMs)); + EXPECT_TRUE(hasElapsed(3000, 0, longHoldToggleMs)); + EXPECT_TRUE(hasElapsed(5000, 0, longHoldToggleMs)); +} + +TEST(HasElapsed, EepromCommitDelay) { + EXPECT_FALSE(hasElapsed(199, 0, eepromCommitDelayMs)); + EXPECT_TRUE(hasElapsed(200, 0, eepromCommitDelayMs)); +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// CalculateCenterPadding +// ═══════════════════════════════════════════════════════════════════════════════ + +TEST(CalculateCenterPadding, ShortText) { + // "PLAY" = 4 chars → (20-4)/2 = 8 + EXPECT_EQ(calculateCenterPadding(4), 8); +} + +TEST(CalculateCenterPadding, EmptyText) { + EXPECT_EQ(calculateCenterPadding(0), 10); +} + +TEST(CalculateCenterPadding, FullWidth) { + EXPECT_EQ(calculateCenterPadding(20), 0); +} + +TEST(CalculateCenterPadding, ExceedsWidth) { + // Text longer than LCD is clamped to lcdColumnCount before padding + EXPECT_EQ(calculateCenterPadding(30), 0); +} + +TEST(CalculateCenterPadding, OddLength) { + // 5 chars → (20-5)/2 = 7 (integer division floors) + EXPECT_EQ(calculateCenterPadding(5), 7); +} + +TEST(CalculateCenterPadding, SingleChar) { + EXPECT_EQ(calculateCenterPadding(1), 9); +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// CalculateSwitchPadding +// ═══════════════════════════════════════════════════════════════════════════════ + +TEST(CalculateSwitchPadding, EvenDistribution) { + int left, right; + calculateSwitchPadding(4, 4, 4, left, right); + // total=12, remaining=8 → left=4, right=4 + EXPECT_EQ(left, 4); + EXPECT_EQ(right, 4); +} + +TEST(CalculateSwitchPadding, OddRemainder) { + int left, right; + calculateSwitchPadding(4, 4, 3, left, right); + // total=11, remaining=9 → left=4, right=5 + EXPECT_EQ(left, 4); + EXPECT_EQ(right, 5); +} + +TEST(CalculateSwitchPadding, OverflowsWidth) { + int left, right; + calculateSwitchPadding(10, 10, 10, left, right); + // total=30 >= 20 → both 0 + EXPECT_EQ(left, 0); + EXPECT_EQ(right, 0); +} + +TEST(CalculateSwitchPadding, EmptyLabels) { + int left, right; + calculateSwitchPadding(0, 0, 0, left, right); + // total=0, remaining=20 → left=10, right=10 + EXPECT_EQ(left, 10); + EXPECT_EQ(right, 10); +} + +TEST(CalculateSwitchPadding, TotalFillsExactly) { + int left, right; + calculateSwitchPadding(8, 6, 6, left, right); + // total=20, remaining=0 → both 0 + EXPECT_EQ(left, 0); + EXPECT_EQ(right, 0); +} + +TEST(CalculateSwitchPadding, TypicalPresetLayout) { + // e.g. "DIST" (4) + "DELAY" (5) + "REV" (3) = 12 → remaining=8 + int left, right; + calculateSwitchPadding(4, 5, 3, left, right); + EXPECT_EQ(left, 4); + EXPECT_EQ(right, 4); + // Verify total fills LCD: 4 + 4 + 5 + 4 + 3 = 20 + EXPECT_EQ(4 + left + 5 + right + 3, lcdColumnCount); +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// CanNavigateNext +// ═══════════════════════════════════════════════════════════════════════════════ + +TEST(CanNavigateNext, CanAdvance) { + EXPECT_TRUE(canNavigateNext(0, 10)); +} + +TEST(CanNavigateNext, AtLastPreset) { + EXPECT_FALSE(canNavigateNext(9, 10)); +} + +TEST(CanNavigateNext, OnlyOnePreset) { + EXPECT_FALSE(canNavigateNext(0, 1)); +} + +TEST(CanNavigateNext, EmptyList) { + // presetCount=0, currentPreset=0 → 0 < -1 is false + EXPECT_FALSE(canNavigateNext(0, 0)); +} + +TEST(CanNavigateNext, MiddleOfList) { + EXPECT_TRUE(canNavigateNext(5, 150)); +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// CanNavigatePrev +// ═══════════════════════════════════════════════════════════════════════════════ + +TEST(CanNavigatePrev, CanGoBack) { + EXPECT_TRUE(canNavigatePrev(5)); +} + +TEST(CanNavigatePrev, AtFirstPreset) { + EXPECT_FALSE(canNavigatePrev(0)); +} + +TEST(CanNavigatePrev, SecondPreset) { + EXPECT_TRUE(canNavigatePrev(1)); +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// AdjustPcProgram +// ═══════════════════════════════════════════════════════════════════════════════ + +TEST(AdjustPcProgram, IncrementFromZero) { + EXPECT_EQ(adjustPcProgram(0, false), 1); +} + +TEST(AdjustPcProgram, IncrementMidRange) { + EXPECT_EQ(adjustPcProgram(63, false), 64); +} + +TEST(AdjustPcProgram, IncrementAtMax) { + // 127 + 1 = 128 → clamped to 127 + EXPECT_EQ(adjustPcProgram(127, false), 127); +} + +TEST(AdjustPcProgram, IncrementNearMax) { + EXPECT_EQ(adjustPcProgram(126, false), 127); +} + +TEST(AdjustPcProgram, DecrementFromMax) { + EXPECT_EQ(adjustPcProgram(127, true), 126); +} + +TEST(AdjustPcProgram, DecrementMidRange) { + EXPECT_EQ(adjustPcProgram(64, true), 63); +} + +TEST(AdjustPcProgram, DecrementAtZero) { + // Already at 0, can't go lower + EXPECT_EQ(adjustPcProgram(0, true), 0); +} + +TEST(AdjustPcProgram, DecrementFromOne) { + EXPECT_EQ(adjustPcProgram(1, true), 0); +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// Constants validation +// ═══════════════════════════════════════════════════════════════════════════════ + +TEST(Constants, MidiRange) { + EXPECT_EQ(midiValueMin, 0); + EXPECT_EQ(midiValueMax, 127); +} + +TEST(Constants, LcdWidth) { + EXPECT_EQ(lcdColumnCount, 20); +} + +TEST(Constants, SwitchCount) { + EXPECT_EQ(switchCount, 3); +} + +TEST(Constants, PresetLimits) { + EXPECT_EQ(maxPresetListSize, 150); + EXPECT_EQ(maxPresetNameLength, 25); +} + +TEST(Constants, TimingValues) { + EXPECT_EQ(initialLoadDelayMs, 1000UL); + EXPECT_EQ(switchDisplayPeriodMs, 1500UL); + EXPECT_EQ(longHoldToggleMs, 3000UL); + EXPECT_EQ(eepromCommitDelayMs, 200UL); +} + +TEST(Constants, EepromAddresses) { + EXPECT_EQ(presetEepromAddress, 0); + EXPECT_EQ(pcModeEepromAddress, 1000); + // Ensure addresses don't overlap (preset stores an int = 4 bytes) + EXPECT_GT(pcModeEepromAddress, presetEepromAddress + (int)sizeof(int)); +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// Integration: Toggle state management +// ═══════════════════════════════════════════════════════════════════════════════ + +TEST(ToggleState, ArrayInitializedToFalse) { + bool toggles[switchCount] = {}; + for (int i = 0; i < switchCount; i++) { + EXPECT_FALSE(toggles[i]); + } +} + +TEST(ToggleState, IndependentPerSwitch) { + bool toggles[switchCount] = {}; + toggles[0] = true; + EXPECT_TRUE(toggles[0]); + EXPECT_FALSE(toggles[1]); + EXPECT_FALSE(toggles[2]); +} + +TEST(ToggleState, ToggleOnOff) { + bool toggles[switchCount] = {}; + // Toggle on + toggles[1] = !toggles[1]; + EXPECT_TRUE(toggles[1]); + // Toggle off + toggles[1] = !toggles[1]; + EXPECT_FALSE(toggles[1]); +} + +TEST(ToggleState, ResetAll) { + bool toggles[switchCount] = {true, true, true}; + memset(toggles, 0, sizeof(toggles)); + for (int i = 0; i < switchCount; i++) { + EXPECT_FALSE(toggles[i]); + } +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// ValidateStoredPreset (EEPROM validation — new on branch) +// ═══════════════════════════════════════════════════════════════════════════════ + +TEST(ValidateStoredPreset, ValidIndex) { + EXPECT_EQ(validateStoredPreset(5, 150), 5); +} + +TEST(ValidateStoredPreset, ZeroIndex) { + EXPECT_EQ(validateStoredPreset(0, 150), 0); +} + +TEST(ValidateStoredPreset, AtMax) { + // Index 149 is the last valid with maxPresets=150 + EXPECT_EQ(validateStoredPreset(149, 150), 149); +} + +TEST(ValidateStoredPreset, NegativeReturnsZero) { + EXPECT_EQ(validateStoredPreset(-1, 150), 0); +} + +TEST(ValidateStoredPreset, LargeNegativeReturnsZero) { + EXPECT_EQ(validateStoredPreset(-9999, 150), 0); +} + +TEST(ValidateStoredPreset, EqualToMaxReturnsZero) { + EXPECT_EQ(validateStoredPreset(150, 150), 0); +} + +TEST(ValidateStoredPreset, AboveMaxReturnsZero) { + EXPECT_EQ(validateStoredPreset(200, 150), 0); +} + +TEST(ValidateStoredPreset, CorruptedLargeValueReturnsZero) { + // Simulates uninitialized EEPROM (0xFFFF... = large negative or positive) + EXPECT_EQ(validateStoredPreset(0x7FFFFFFF, 150), 0); +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// ValidateStoredPcProgram (EEPROM validation — new on branch) +// ═══════════════════════════════════════════════════════════════════════════════ + +TEST(ValidateStoredPcProgram, ValidZero) { + EXPECT_EQ(validateStoredPcProgram(0), 0); +} + +TEST(ValidateStoredPcProgram, ValidMidRange) { + EXPECT_EQ(validateStoredPcProgram(64), 64); +} + +TEST(ValidateStoredPcProgram, ValidMax) { + EXPECT_EQ(validateStoredPcProgram(127), 127); +} + +TEST(ValidateStoredPcProgram, NegativeReturnsZero) { + EXPECT_EQ(validateStoredPcProgram(-1), 0); +} + +TEST(ValidateStoredPcProgram, AboveMaxReturnsZero) { + EXPECT_EQ(validateStoredPcProgram(128), 0); +} + +TEST(ValidateStoredPcProgram, LargeCorruptedValue) { + EXPECT_EQ(validateStoredPcProgram(65535), 0); +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// ToggleCcValue (refactored toggle logic — new on branch) +// ═══════════════════════════════════════════════════════════════════════════════ + +TEST(ToggleCcValue, TurningOnSends127) { + // wasToggled=false means switch is currently off, so we're turning ON + EXPECT_EQ(toggleCcValue(false), 127); +} + +TEST(ToggleCcValue, TurningOffSends0) { + // wasToggled=true means switch is currently on, so we're turning OFF + EXPECT_EQ(toggleCcValue(true), 0); +} + +TEST(ToggleCcValue, ConsecutiveToggles) { + bool state = false; + // First press: off→on + int val1 = toggleCcValue(state); + EXPECT_EQ(val1, 127); + state = !state; // now on + + // Second press: on→off + int val2 = toggleCcValue(state); + EXPECT_EQ(val2, 0); + state = !state; // now off + + // Third press: off→on again + int val3 = toggleCcValue(state); + EXPECT_EQ(val3, 127); +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// ComputePrefetchCandidate (directional prefetch — new on branch) +// ═══════════════════════════════════════════════════════════════════════════════ + +TEST(ComputePrefetchCandidate, ForwardFromStart) { + // current=0, count=10, direction=+1, nothing prefetched + EXPECT_EQ(computePrefetchCandidate(0, 10, 1, -1), 1); +} + +TEST(ComputePrefetchCandidate, ForwardFromMiddle) { + EXPECT_EQ(computePrefetchCandidate(5, 10, 1, -1), 6); +} + +TEST(ComputePrefetchCandidate, BackwardFromEnd) { + EXPECT_EQ(computePrefetchCandidate(9, 10, -1, -1), 8); +} + +TEST(ComputePrefetchCandidate, BackwardFromMiddle) { + EXPECT_EQ(computePrefetchCandidate(5, 10, -1, -1), 4); +} + +TEST(ComputePrefetchCandidate, ForwardAtEndFallsBackToReverse) { + // current=9 (last), direction=+1 → candidate=10 OOB → tries 9-1=8 + EXPECT_EQ(computePrefetchCandidate(9, 10, 1, -1), 8); +} + +TEST(ComputePrefetchCandidate, BackwardAtStartFallsForward) { + // current=0, direction=-1 → candidate=-1 OOB → tries 0+1=1 + EXPECT_EQ(computePrefetchCandidate(0, 10, -1, -1), 1); +} + +TEST(ComputePrefetchCandidate, SinglePresetReturnsNegative) { + EXPECT_EQ(computePrefetchCandidate(0, 1, 1, -1), -1); +} + +TEST(ComputePrefetchCandidate, EmptyListReturnsNegative) { + EXPECT_EQ(computePrefetchCandidate(0, 0, 1, -1), -1); +} + +TEST(ComputePrefetchCandidate, AlreadyPrefetchedReturnsNegative) { + // Candidate would be 6, but it's already prefetched + EXPECT_EQ(computePrefetchCandidate(5, 10, 1, 6), -1); +} + +TEST(ComputePrefetchCandidate, DifferentPrefetchStillReturnsCandidate) { + // Candidate is 6, but index 3 is prefetched (different) → valid + EXPECT_EQ(computePrefetchCandidate(5, 10, 1, 3), 6); +} + +TEST(ComputePrefetchCandidate, TwoPresetsForward) { + // current=0, count=2, direction=+1 → candidate=1 + EXPECT_EQ(computePrefetchCandidate(0, 2, 1, -1), 1); +} + +TEST(ComputePrefetchCandidate, TwoPresetsBackward) { + // current=1, count=2, direction=-1 → candidate=0 + EXPECT_EQ(computePrefetchCandidate(1, 2, -1, -1), 0); +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// HasJsonExtension (file filtering — new on branch) +// ═══════════════════════════════════════════════════════════════════════════════ + +TEST(HasJsonExtension, ValidJsonFile) { + EXPECT_TRUE(hasJsonExtension("1.json")); +} + +TEST(HasJsonExtension, MultiDigitJson) { + EXPECT_TRUE(hasJsonExtension("100.json")); +} + +TEST(HasJsonExtension, TextFile) { + EXPECT_FALSE(hasJsonExtension("readme.txt")); +} + +TEST(HasJsonExtension, NoExtension) { + EXPECT_FALSE(hasJsonExtension("noext")); +} + +TEST(HasJsonExtension, DotOnly) { + EXPECT_TRUE(hasJsonExtension(".json")); // ".json" is length 5, ends with ".json" → true +} + +TEST(HasJsonExtension, EmptyString) { + EXPECT_FALSE(hasJsonExtension("")); +} + +TEST(HasJsonExtension, NullPointer) { + EXPECT_FALSE(hasJsonExtension(nullptr)); +} + +TEST(HasJsonExtension, SimilarExtensionNotJson) { + EXPECT_FALSE(hasJsonExtension("file.jsonl")); +} + +TEST(HasJsonExtension, UpperCaseNotMatched) { + // Case-sensitive: .JSON doesn't match .json + EXPECT_FALSE(hasJsonExtension("file.JSON")); +} + +TEST(HasJsonExtension, HiddenJsonFile) { + EXPECT_TRUE(hasJsonExtension(".hidden.json")); +} + +TEST(HasJsonExtension, ShortFilename) { + EXPECT_FALSE(hasJsonExtension("a.js")); +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// PcJsonToMidi (PC offset conversion — new on branch) +// ═══════════════════════════════════════════════════════════════════════════════ + +TEST(PcJsonToMidi, OneBecomes0) { + // JSON PC=1 → MIDI PC=0 + EXPECT_EQ(pcJsonToMidi(1), 0); +} + +TEST(PcJsonToMidi, TwoBecomes1) { + EXPECT_EQ(pcJsonToMidi(2), 1); +} + +TEST(PcJsonToMidi, MaxValidPC128Becomes127) { + EXPECT_EQ(pcJsonToMidi(128), 127); +} + +TEST(PcJsonToMidi, ZeroClampedToZero) { + // JSON PC=0 → 0-1=-1 → clamped to 0 + EXPECT_EQ(pcJsonToMidi(0), 0); +} + +TEST(PcJsonToMidi, NegativeClampedToZero) { + EXPECT_EQ(pcJsonToMidi(-5), 0); +} + +TEST(PcJsonToMidi, LargeValueClamped) { + // JSON PC=200 → 199 → clamped to 127 + EXPECT_EQ(pcJsonToMidi(200), 127); +} + +TEST(PcJsonToMidi, Pc64Becomes63) { + EXPECT_EQ(pcJsonToMidi(64), 63); +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// FormatSwitchActionMessage (UI message formatting — new on branch) +// ═══════════════════════════════════════════════════════════════════════════════ + +TEST(FormatSwitchActionMessage, TextWithSuffix) { + char buf[50]; + EXPECT_TRUE(formatSwitchActionMessage("Dist", " On!", buf, sizeof(buf))); + EXPECT_STREQ(buf, "Dist On!"); +} + +TEST(FormatSwitchActionMessage, TextWithOffSuffix) { + char buf[50]; + EXPECT_TRUE(formatSwitchActionMessage("Reverb", " Off!", buf, sizeof(buf))); + EXPECT_STREQ(buf, "Reverb Off!"); +} + +TEST(FormatSwitchActionMessage, TextWithEmptySuffix) { + char buf[50]; + EXPECT_TRUE(formatSwitchActionMessage("Delay", "", buf, sizeof(buf))); + EXPECT_STREQ(buf, "Delay"); +} + +TEST(FormatSwitchActionMessage, TextWithNullSuffix) { + char buf[50]; + EXPECT_TRUE(formatSwitchActionMessage("Boost", nullptr, buf, sizeof(buf))); + EXPECT_STREQ(buf, "Boost"); +} + +TEST(FormatSwitchActionMessage, NullTextWithSuffix) { + char buf[50]; + EXPECT_TRUE(formatSwitchActionMessage(nullptr, " On!", buf, sizeof(buf))); + EXPECT_STREQ(buf, " On!"); +} + +TEST(FormatSwitchActionMessage, NullTextNullSuffix) { + char buf[50]; + EXPECT_FALSE(formatSwitchActionMessage(nullptr, nullptr, buf, sizeof(buf))); +} + +TEST(FormatSwitchActionMessage, NullTextEmptySuffix) { + char buf[50]; + EXPECT_FALSE(formatSwitchActionMessage(nullptr, "", buf, sizeof(buf))); +} + +TEST(FormatSwitchActionMessage, EmptyTextEmptySuffix) { + char buf[50]; + EXPECT_FALSE(formatSwitchActionMessage("", "", buf, sizeof(buf))); +} + +TEST(FormatSwitchActionMessage, EmptyTextWithSuffix) { + char buf[50]; + EXPECT_TRUE(formatSwitchActionMessage("", " On!", buf, sizeof(buf))); + EXPECT_STREQ(buf, " On!"); +} + +TEST(FormatSwitchActionMessage, TruncationHandled) { + char buf[10]; + EXPECT_TRUE(formatSwitchActionMessage("LongSwitchName", " On!", buf, sizeof(buf))); + EXPECT_EQ(strlen(buf), 9u); // truncated to bufSize-1 +} + +TEST(FormatSwitchActionMessage, ZeroBufferReturnsFalse) { + char buf[1] = {0}; + EXPECT_FALSE(formatSwitchActionMessage("X", " On!", buf, 0)); +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// ClampPresetIndex (preset bounds — new on branch) +// ═══════════════════════════════════════════════════════════════════════════════ + +TEST(ClampPresetIndex, ValidIndex) { + EXPECT_EQ(clampPresetIndex(5, 10), 5); +} + +TEST(ClampPresetIndex, ZeroIndex) { + EXPECT_EQ(clampPresetIndex(0, 10), 0); +} + +TEST(ClampPresetIndex, LastValid) { + EXPECT_EQ(clampPresetIndex(9, 10), 9); +} + +TEST(ClampPresetIndex, EqualToCountResetsToZero) { + EXPECT_EQ(clampPresetIndex(10, 10), 0); +} + +TEST(ClampPresetIndex, AboveCountResetsToZero) { + EXPECT_EQ(clampPresetIndex(100, 10), 0); +} + +TEST(ClampPresetIndex, NegativeResetsToZero) { + EXPECT_EQ(clampPresetIndex(-1, 10), 0); +} + +TEST(ClampPresetIndex, ZeroPresetCountAlwaysReturnsZero) { + EXPECT_EQ(clampPresetIndex(0, 0), 0); + EXPECT_EQ(clampPresetIndex(5, 0), 0); +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// Integration: Full switch toggle cycle with CC values +// ═══════════════════════════════════════════════════════════════════════════════ + +TEST(SwitchToggleCycle, FullOnOffCycleForThreeSwitches) { + // Simulates the refactored executeSwitchLogic toggle behavior + bool switchToggled[switchCount] = {}; + + for (int sw = 0; sw < switchCount; sw++) { + // Press 1: turn on + bool wasToggled = switchToggled[sw]; + int ccVal = toggleCcValue(wasToggled); + EXPECT_EQ(ccVal, 127); + switchToggled[sw] = !wasToggled; + EXPECT_TRUE(switchToggled[sw]); + + // Press 2: turn off + wasToggled = switchToggled[sw]; + ccVal = toggleCcValue(wasToggled); + EXPECT_EQ(ccVal, 0); + switchToggled[sw] = !wasToggled; + EXPECT_FALSE(switchToggled[sw]); + } +} + +TEST(SwitchToggleCycle, ResetAllAfterPresetChange) { + bool switchToggled[switchCount] = {true, true, true}; + // Simulates memset(switchToggled, 0, ...) done in changePreset() + memset(switchToggled, 0, sizeof(switchToggled)); + for (int i = 0; i < switchCount; i++) { + EXPECT_FALSE(switchToggled[i]); + EXPECT_EQ(toggleCcValue(switchToggled[i]), 127); // next press would be ON + } +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// Integration: Navigation + prefetch scenario +// ═══════════════════════════════════════════════════════════════════════════════ + +TEST(NavigationPrefetch, ForwardThroughList) { + const int presetCount = 5; + int currentPreset = 0; + int direction = 1; + int prefetchedIndex = -1; + + // Navigate forward through all presets + for (int i = 0; i < presetCount - 1; i++) { + EXPECT_TRUE(canNavigateNext(currentPreset, presetCount)); + currentPreset++; + int candidate = computePrefetchCandidate(currentPreset, presetCount, direction, prefetchedIndex); + if (candidate >= 0) { + prefetchedIndex = candidate; + } + } + // At the end + EXPECT_EQ(currentPreset, 4); + EXPECT_FALSE(canNavigateNext(currentPreset, presetCount)); +} + +TEST(NavigationPrefetch, BackwardThroughList) { + const int presetCount = 5; + int currentPreset = 4; + int direction = -1; + int prefetchedIndex = -1; + + for (int i = 0; i < presetCount - 1; i++) { + EXPECT_TRUE(canNavigatePrev(currentPreset)); + currentPreset--; + int candidate = computePrefetchCandidate(currentPreset, presetCount, direction, prefetchedIndex); + if (candidate >= 0) { + prefetchedIndex = candidate; + } + } + EXPECT_EQ(currentPreset, 0); + EXPECT_FALSE(canNavigatePrev(currentPreset)); +} + +TEST(NavigationPrefetch, DirectionReversal) { + // Going forward, then back — prefetch should adapt + int currentPreset = 5; + int presetCount = 10; + int prefetchedIndex = -1; + + // Forward + int candidate = computePrefetchCandidate(currentPreset, presetCount, 1, prefetchedIndex); + EXPECT_EQ(candidate, 6); + prefetchedIndex = candidate; + + // Reverse direction + candidate = computePrefetchCandidate(currentPreset, presetCount, -1, prefetchedIndex); + EXPECT_EQ(candidate, 4); +} diff --git a/test/test_midi_messages.cpp b/test/test_midi_messages.cpp new file mode 100644 index 0000000..7ac3151 --- /dev/null +++ b/test/test_midi_messages.cpp @@ -0,0 +1,765 @@ +#include +#include +#include "logic.h" +#include "midi_mock.h" + +// ═══════════════════════════════════════════════════════════════════════════════ +// Helpers — replicate firmware logic using mock sends +// ═══════════════════════════════════════════════════════════════════════════════ + +static void sendPcArray(JsonArray pcArray) { + if (pcArray.isNull()) return; + for (JsonVariant pcEvent : pcArray) { + sendProgramChange(pcJsonToMidi(pcEvent["PC"].as()), pcEvent["Channel"], pcEvent["USB"]); + } +} + +static void sendCcArray(JsonArray ccArray) { + if (ccArray.isNull()) return; + for (JsonVariant ccEvent : ccArray) { + sendControlChange(ccEvent["CC"], ccEvent["Value"], ccEvent["Channel"], ccEvent["USB"]); + } +} + +/// Simulates executeSwitchLogic: processes a switch press and logs MIDI messages. +/// Returns the new toggle state for the switch. +static bool executeSwitchPress(JsonObject switchLogic, bool wasToggled) { + sendPcArray(switchLogic["PC"]); + + JsonArray ccArray = switchLogic["CC"]; + if (!ccArray.isNull()) { + bool toggle = switchLogic["Toggle"].as(); + bool nextToggleState = false; + bool useToggleValue = false; + + if (toggle) { + nextToggleState = !wasToggled; + useToggleValue = true; + } + + for (JsonVariant cc : ccArray) { + int ccNumber = cc["CC"]; + int ccValue = cc["Value"]; + + if (useToggleValue) { + ccValue = toggleCcValue(wasToggled); + } + + int ccChannel = cc["Channel"]; + bool usbEvent = cc["USB"]; + sendControlChange(ccNumber, ccValue, ccChannel, usbEvent); + } + + if (useToggleValue) { + return nextToggleState; + } + } + + return wasToggled; +} + +/// Simulates handlePcModeEvent: adjust PC program and send. +static int handlePcModePress(int currentProgram, int switchNo) { + int newProgram = adjustPcProgram(currentProgram, switchNo == 2); + sendProgramChange(newProgram, 1, true); + return newProgram; +} + +/// Simulates changePreset OnLoad: sends PC and CC arrays from preset OnLoad. +static void processPresetOnLoad(JsonObject preset) { + sendPcArray(preset["OnLoad"]["PC"]); + sendCcArray(preset["OnLoad"]["CC"]); +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// Test fixture +// ═══════════════════════════════════════════════════════════════════════════════ + +class MidiMessageTest : public ::testing::Test { +protected: + void SetUp() override { + clearMidiLog(); + } +}; + +// ═══════════════════════════════════════════════════════════════════════════════ +// Preset OnLoad — MIDI messages sent when navigating to a new preset +// ═══════════════════════════════════════════════════════════════════════════════ + +TEST_F(MidiMessageTest, PresetOnLoad_SendsPcAndCc) { + // Matches configExample.json: OnLoad has PC=2 ch11 and CC=1 val=127 ch11 + StaticJsonDocument<1024> doc; + deserializeJson(doc, R"({ + "Name": "Preset1", + "OnLoad": { + "PC": [{"PC": 2, "Channel": 11}], + "CC": [{"CC": 1, "Value": 127, "Channel": 11}] + } + })"); + + processPresetOnLoad(doc.as()); + + ASSERT_EQ(midiLog().size(), 2u); + + // PC: JSON PC=2 → MIDI PC=1 (pcJsonToMidi applies -1), channel 11, serial (USB defaults false) + EXPECT_EQ(midiLog()[0].type, MidiMessage::ProgramChange); + EXPECT_EQ(midiLog()[0].value1, 1); // 2-1=1 + EXPECT_EQ(midiLog()[0].channel, 11); + EXPECT_FALSE(midiLog()[0].usb); + + // CC: CC#1, Value 127, channel 11, serial + EXPECT_EQ(midiLog()[1].type, MidiMessage::ControlChange); + EXPECT_EQ(midiLog()[1].value1, 1); + EXPECT_EQ(midiLog()[1].value2, 127); + EXPECT_EQ(midiLog()[1].channel, 11); + EXPECT_FALSE(midiLog()[1].usb); +} + +TEST_F(MidiMessageTest, PresetOnLoad_UsbFlag) { + StaticJsonDocument<512> doc; + deserializeJson(doc, R"({ + "OnLoad": { + "PC": [{"PC": 5, "Channel": 1, "USB": true}], + "CC": null + } + })"); + + processPresetOnLoad(doc.as()); + + ASSERT_EQ(midiLog().size(), 1u); + EXPECT_EQ(midiLog()[0].type, MidiMessage::ProgramChange); + EXPECT_EQ(midiLog()[0].value1, 4); // 5-1=4 + EXPECT_EQ(midiLog()[0].channel, 1); + EXPECT_TRUE(midiLog()[0].usb); +} + +TEST_F(MidiMessageTest, PresetOnLoad_NullOnLoad_NoMessages) { + StaticJsonDocument<256> doc; + deserializeJson(doc, R"({"Name": "Empty", "OnLoad": null})"); + + processPresetOnLoad(doc.as()); + + EXPECT_EQ(midiLog().size(), 0u); +} + +TEST_F(MidiMessageTest, PresetOnLoad_MultiplePcEvents) { + StaticJsonDocument<512> doc; + deserializeJson(doc, R"({ + "OnLoad": { + "PC": [ + {"PC": 10, "Channel": 1, "USB": false}, + {"PC": 20, "Channel": 2, "USB": true} + ], + "CC": null + } + })"); + + processPresetOnLoad(doc.as()); + + ASSERT_EQ(midiLog().size(), 2u); + EXPECT_EQ(midiLog()[0].value1, 9); // 10-1 + EXPECT_EQ(midiLog()[0].channel, 1); + EXPECT_FALSE(midiLog()[0].usb); + EXPECT_EQ(midiLog()[1].value1, 19); // 20-1 + EXPECT_EQ(midiLog()[1].channel, 2); + EXPECT_TRUE(midiLog()[1].usb); +} + +TEST_F(MidiMessageTest, PresetOnLoad_MidiFilePreset) { + // configExampleWithFile.json: OnLoad PC=98 ch11 + StaticJsonDocument<512> doc; + deserializeJson(doc, R"({ + "Name": "Unsustainable", + "OnLoad": {"PC": [{"Channel": 11, "PC": 98}], "CC": null} + })"); + + processPresetOnLoad(doc.as()); + + ASSERT_EQ(midiLog().size(), 1u); + EXPECT_EQ(midiLog()[0].value1, 97); // 98-1 + EXPECT_EQ(midiLog()[0].channel, 11); +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// Switch button press — non-toggle switch sends CC values as-is +// ═══════════════════════════════════════════════════════════════════════════════ + +TEST_F(MidiMessageTest, SwitchPress_NonToggle_SendsCcAsIs) { + // configExample.json Switch2: Toggle=false, CC=[{CC:3, Value:127, Ch:11}, {CC:5, Value:0, Ch:11}] + StaticJsonDocument<512> doc; + deserializeJson(doc, R"({ + "Name": "QECUN", + "Toggle": false, + "CC": [ + {"CC": 3, "Value": 127, "Channel": 11}, + {"CC": 5, "Value": 0, "Channel": 11} + ], + "PC": null + })"); + + executeSwitchPress(doc.as(), false); + + ASSERT_EQ(midiLog().size(), 2u); + // CC#3 = 127 + EXPECT_EQ(midiLog()[0].type, MidiMessage::ControlChange); + EXPECT_EQ(midiLog()[0].value1, 3); + EXPECT_EQ(midiLog()[0].value2, 127); + EXPECT_EQ(midiLog()[0].channel, 11); + // CC#5 = 0 + EXPECT_EQ(midiLog()[1].type, MidiMessage::ControlChange); + EXPECT_EQ(midiLog()[1].value1, 5); + EXPECT_EQ(midiLog()[1].value2, 0); + EXPECT_EQ(midiLog()[1].channel, 11); +} + +TEST_F(MidiMessageTest, SwitchPress_NonToggle_SendsPc) { + // configExample.json Switch3: PC=[{PC:2, Channel:11, USB:true}] + StaticJsonDocument<512> doc; + deserializeJson(doc, R"({ + "Name": "GLAKX", + "Toggle": false, + "CC": null, + "PC": [{"PC": 2, "Channel": 11, "USB": true}] + })"); + + executeSwitchPress(doc.as(), false); + + ASSERT_EQ(midiLog().size(), 1u); + EXPECT_EQ(midiLog()[0].type, MidiMessage::ProgramChange); + EXPECT_EQ(midiLog()[0].value1, 1); // 2-1=1 + EXPECT_EQ(midiLog()[0].channel, 11); + EXPECT_TRUE(midiLog()[0].usb); +} + +TEST_F(MidiMessageTest, SwitchPress_NonToggle_NoPcNoCc_NoMessages) { + StaticJsonDocument<256> doc; + deserializeJson(doc, R"({ + "Name": "Empty", + "Toggle": false, + "CC": null, + "PC": null + })"); + + executeSwitchPress(doc.as(), false); + + EXPECT_EQ(midiLog().size(), 0u); +} + +TEST_F(MidiMessageTest, SwitchPress_NonToggle_PcAndCc) { + StaticJsonDocument<512> doc; + deserializeJson(doc, R"({ + "Name": "Combo", + "Toggle": false, + "PC": [{"PC": 5, "Channel": 1, "USB": false}], + "CC": [{"CC": 10, "Value": 100, "Channel": 2, "USB": true}] + })"); + + executeSwitchPress(doc.as(), false); + + ASSERT_EQ(midiLog().size(), 2u); + // PC sent first + EXPECT_EQ(midiLog()[0].type, MidiMessage::ProgramChange); + EXPECT_EQ(midiLog()[0].value1, 4); // 5-1 + EXPECT_EQ(midiLog()[0].channel, 1); + EXPECT_FALSE(midiLog()[0].usb); + // Then CC + EXPECT_EQ(midiLog()[1].type, MidiMessage::ControlChange); + EXPECT_EQ(midiLog()[1].value1, 10); + EXPECT_EQ(midiLog()[1].value2, 100); + EXPECT_EQ(midiLog()[1].channel, 2); + EXPECT_TRUE(midiLog()[1].usb); +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// Switch button press — toggle switch overrides CC value +// ═══════════════════════════════════════════════════════════════════════════════ + +TEST_F(MidiMessageTest, SwitchPress_Toggle_FirstPress_Sends127) { + // configExample.json Switch1: Toggle=true, CC=[{CC:1,Val:127,Ch:11}, {CC:4,Val:127,Ch:11}] + StaticJsonDocument<512> doc; + deserializeJson(doc, R"({ + "Name": "JAXDF", + "Toggle": true, + "CC": [ + {"CC": 1, "Value": 127, "Channel": 11}, + {"CC": 4, "Value": 127, "Channel": 11} + ], + "PC": null + })"); + + // wasToggled=false → turning ON → CC value = 127 + bool newState = executeSwitchPress(doc.as(), false); + + EXPECT_TRUE(newState); // Toggle is now ON + ASSERT_EQ(midiLog().size(), 2u); + // Both CCs get value 127 (toggle ON overrides JSON value) + EXPECT_EQ(midiLog()[0].value1, 1); + EXPECT_EQ(midiLog()[0].value2, 127); + EXPECT_EQ(midiLog()[0].channel, 11); + EXPECT_EQ(midiLog()[1].value1, 4); + EXPECT_EQ(midiLog()[1].value2, 127); + EXPECT_EQ(midiLog()[1].channel, 11); +} + +TEST_F(MidiMessageTest, SwitchPress_Toggle_SecondPress_Sends0) { + StaticJsonDocument<512> doc; + deserializeJson(doc, R"({ + "Name": "JAXDF", + "Toggle": true, + "CC": [ + {"CC": 1, "Value": 127, "Channel": 11}, + {"CC": 4, "Value": 127, "Channel": 11} + ], + "PC": null + })"); + + // wasToggled=true → turning OFF → CC value = 0 + bool newState = executeSwitchPress(doc.as(), true); + + EXPECT_FALSE(newState); // Toggle is now OFF + ASSERT_EQ(midiLog().size(), 2u); + EXPECT_EQ(midiLog()[0].value2, 0); + EXPECT_EQ(midiLog()[1].value2, 0); +} + +TEST_F(MidiMessageTest, SwitchPress_Toggle_FullCycle) { + StaticJsonDocument<512> doc; + deserializeJson(doc, R"({ + "Name": "Effect", + "Toggle": true, + "CC": [{"CC": 50, "Value": 64, "Channel": 5, "USB": true}], + "PC": null + })"); + + // Press 1: OFF → ON (sends 127) + bool state = executeSwitchPress(doc.as(), false); + EXPECT_TRUE(state); + ASSERT_EQ(midiLog().size(), 1u); + EXPECT_EQ(midiLog()[0].value2, 127); + EXPECT_TRUE(midiLog()[0].usb); + + clearMidiLog(); + + // Press 2: ON → OFF (sends 0) + state = executeSwitchPress(doc.as(), state); + EXPECT_FALSE(state); + ASSERT_EQ(midiLog().size(), 1u); + EXPECT_EQ(midiLog()[0].value2, 0); + + clearMidiLog(); + + // Press 3: OFF → ON again (sends 127) + state = executeSwitchPress(doc.as(), state); + EXPECT_TRUE(state); + ASSERT_EQ(midiLog().size(), 1u); + EXPECT_EQ(midiLog()[0].value2, 127); +} + +TEST_F(MidiMessageTest, SwitchPress_Toggle_WithPc_BothSent) { + // Toggle switch that also sends a PC + StaticJsonDocument<512> doc; + deserializeJson(doc, R"({ + "Name": "Combo", + "Toggle": true, + "PC": [{"PC": 3, "Channel": 1, "USB": false}], + "CC": [{"CC": 20, "Value": 64, "Channel": 1}] + })"); + + bool state = executeSwitchPress(doc.as(), false); + EXPECT_TRUE(state); + ASSERT_EQ(midiLog().size(), 2u); + // PC sent first (always, regardless of toggle) + EXPECT_EQ(midiLog()[0].type, MidiMessage::ProgramChange); + EXPECT_EQ(midiLog()[0].value1, 2); // 3-1 + // CC with toggle value + EXPECT_EQ(midiLog()[1].type, MidiMessage::ControlChange); + EXPECT_EQ(midiLog()[1].value2, 127); // toggle ON +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// PC Mode — up/down buttons send program change +// ═══════════════════════════════════════════════════════════════════════════════ + +TEST_F(MidiMessageTest, PcMode_IncrementFromZero) { + int program = handlePcModePress(0, 3); // switchNo != 2 → increment + + EXPECT_EQ(program, 1); + ASSERT_EQ(midiLog().size(), 1u); + EXPECT_EQ(midiLog()[0].type, MidiMessage::ProgramChange); + EXPECT_EQ(midiLog()[0].value1, 1); + EXPECT_EQ(midiLog()[0].channel, 1); + EXPECT_TRUE(midiLog()[0].usb); +} + +TEST_F(MidiMessageTest, PcMode_DecrementFromOne) { + int program = handlePcModePress(1, 2); // switchNo == 2 → decrement + + EXPECT_EQ(program, 0); + ASSERT_EQ(midiLog().size(), 1u); + EXPECT_EQ(midiLog()[0].value1, 0); + EXPECT_TRUE(midiLog()[0].usb); +} + +TEST_F(MidiMessageTest, PcMode_IncrementAtMax_Clamped) { + int program = handlePcModePress(127, 1); // increment from 127 + + EXPECT_EQ(program, 127); // clamped + ASSERT_EQ(midiLog().size(), 1u); + EXPECT_EQ(midiLog()[0].value1, 127); +} + +TEST_F(MidiMessageTest, PcMode_DecrementAtZero_Stays) { + int program = handlePcModePress(0, 2); // decrement from 0 + + EXPECT_EQ(program, 0); + ASSERT_EQ(midiLog().size(), 1u); + EXPECT_EQ(midiLog()[0].value1, 0); +} + +TEST_F(MidiMessageTest, PcMode_MultipleIncrements) { + int program = 0; + for (int i = 0; i < 5; i++) { + clearMidiLog(); + program = handlePcModePress(program, 3); + } + EXPECT_EQ(program, 5); + ASSERT_EQ(midiLog().size(), 1u); + EXPECT_EQ(midiLog()[0].value1, 5); +} + +TEST_F(MidiMessageTest, PcMode_IncrementThenDecrement) { + int program = 64; + clearMidiLog(); + program = handlePcModePress(program, 1); // increment + EXPECT_EQ(program, 65); + EXPECT_EQ(midiLog()[0].value1, 65); + + clearMidiLog(); + program = handlePcModePress(program, 2); // decrement + EXPECT_EQ(program, 64); + EXPECT_EQ(midiLog()[0].value1, 64); +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// Long-hold toggle — PC mode activation sends current PC program +// ═══════════════════════════════════════════════════════════════════════════════ + +TEST_F(MidiMessageTest, PcModeToggle_SendsCurrentProgram) { + // When PC mode is toggled via long hold, the current pcModeProgram is sent + int pcModeProgram = 42; + sendProgramChange(pcModeProgram, 1, true); + + ASSERT_EQ(midiLog().size(), 1u); + EXPECT_EQ(midiLog()[0].type, MidiMessage::ProgramChange); + EXPECT_EQ(midiLog()[0].value1, 42); + EXPECT_EQ(midiLog()[0].channel, 1); + EXPECT_TRUE(midiLog()[0].usb); +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// Value clamping — out-of-range values in JSON are safely clamped +// ═══════════════════════════════════════════════════════════════════════════════ + +TEST_F(MidiMessageTest, Clamping_PcAbove127) { + StaticJsonDocument<256> doc; + deserializeJson(doc, R"({"OnLoad": {"PC": [{"PC": 200, "Channel": 1}], "CC": null}})"); + + processPresetOnLoad(doc.as()); + + ASSERT_EQ(midiLog().size(), 1u); + EXPECT_EQ(midiLog()[0].value1, 127); // 200-1=199 → clamped to 127 +} + +TEST_F(MidiMessageTest, Clamping_PcZero) { + StaticJsonDocument<256> doc; + deserializeJson(doc, R"({"OnLoad": {"PC": [{"PC": 0, "Channel": 1}], "CC": null}})"); + + processPresetOnLoad(doc.as()); + + ASSERT_EQ(midiLog().size(), 1u); + EXPECT_EQ(midiLog()[0].value1, 0); // 0-1=-1 → clamped to 0 +} + +TEST_F(MidiMessageTest, Clamping_CcNumberAbove127) { + StaticJsonDocument<256> doc; + deserializeJson(doc, R"({"OnLoad": {"PC": null, "CC": [{"CC": 200, "Value": 50, "Channel": 1}]}})"); + + processPresetOnLoad(doc.as()); + + ASSERT_EQ(midiLog().size(), 1u); + EXPECT_EQ(midiLog()[0].value1, 127); // CC# clamped + EXPECT_EQ(midiLog()[0].value2, 50); +} + +TEST_F(MidiMessageTest, Clamping_CcValueAbove127) { + StaticJsonDocument<256> doc; + deserializeJson(doc, R"({"OnLoad": {"PC": null, "CC": [{"CC": 10, "Value": 255, "Channel": 1}]}})"); + + processPresetOnLoad(doc.as()); + + ASSERT_EQ(midiLog().size(), 1u); + EXPECT_EQ(midiLog()[0].value1, 10); + EXPECT_EQ(midiLog()[0].value2, 127); // value clamped +} + +TEST_F(MidiMessageTest, Clamping_NegativeCcValue) { + StaticJsonDocument<256> doc; + deserializeJson(doc, R"({"OnLoad": {"PC": null, "CC": [{"CC": 1, "Value": -5, "Channel": 1}]}})"); + + processPresetOnLoad(doc.as()); + + ASSERT_EQ(midiLog().size(), 1u); + EXPECT_EQ(midiLog()[0].value2, 0); // clamped to 0 +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// Full preset scenario — simulates complete preset navigation + switch presses +// ═══════════════════════════════════════════════════════════════════════════════ + +TEST_F(MidiMessageTest, FullScenario_LoadPresetThenPressSwitch) { + // Load a preset → get OnLoad messages + StaticJsonDocument<2048> doc; + deserializeJson(doc, R"({ + "Name": "Preset1", + "OnLoad": { + "PC": [{"PC": 2, "Channel": 11}], + "CC": [{"CC": 1, "Value": 127, "Channel": 11}] + }, + "Switch1": { + "Name": "Drive", + "Toggle": true, + "CC": [{"CC": 1, "Value": 127, "Channel": 11}, {"CC": 4, "Value": 127, "Channel": 11}], + "PC": null + }, + "Switch3": { + "Name": "Solo", + "Toggle": false, + "CC": null, + "PC": [{"PC": 2, "Channel": 11, "USB": true}] + } + })"); + + // Step 1: Navigate to preset → OnLoad fires + processPresetOnLoad(doc.as()); + ASSERT_EQ(midiLog().size(), 2u); + EXPECT_EQ(midiLog()[0].type, MidiMessage::ProgramChange); + EXPECT_EQ(midiLog()[0].value1, 1); // PC 2→1 + EXPECT_EQ(midiLog()[1].type, MidiMessage::ControlChange); + EXPECT_EQ(midiLog()[1].value1, 1); + EXPECT_EQ(midiLog()[1].value2, 127); + + clearMidiLog(); + + // Step 2: Press Switch1 (toggle ON) + bool sw1State = executeSwitchPress(doc["Switch1"], false); + EXPECT_TRUE(sw1State); + ASSERT_EQ(midiLog().size(), 2u); + EXPECT_EQ(midiLog()[0].value1, 1); // CC#1 = 127 + EXPECT_EQ(midiLog()[0].value2, 127); + EXPECT_EQ(midiLog()[1].value1, 4); // CC#4 = 127 + EXPECT_EQ(midiLog()[1].value2, 127); + + clearMidiLog(); + + // Step 3: Press Switch1 again (toggle OFF) + sw1State = executeSwitchPress(doc["Switch1"], sw1State); + EXPECT_FALSE(sw1State); + ASSERT_EQ(midiLog().size(), 2u); + EXPECT_EQ(midiLog()[0].value2, 0); // CC#1 = 0 + EXPECT_EQ(midiLog()[1].value2, 0); // CC#4 = 0 + + clearMidiLog(); + + // Step 4: Press Switch3 (non-toggle, sends PC via USB) + executeSwitchPress(doc["Switch3"], false); + ASSERT_EQ(midiLog().size(), 1u); + EXPECT_EQ(midiLog()[0].type, MidiMessage::ProgramChange); + EXPECT_EQ(midiLog()[0].value1, 1); // PC 2→1 + EXPECT_TRUE(midiLog()[0].usb); +} + +TEST_F(MidiMessageTest, FullScenario_NavigateBetweenPresets) { + // Simulates pressing next preset button: old preset → new preset OnLoad + StaticJsonDocument<512> doc1; + deserializeJson(doc1, R"({ + "OnLoad": {"PC": [{"PC": 10, "Channel": 1}], "CC": null} + })"); + + StaticJsonDocument<512> doc2; + deserializeJson(doc2, R"({ + "OnLoad": {"PC": [{"PC": 20, "Channel": 2, "USB": true}], "CC": [{"CC": 80, "Value": 100, "Channel": 2}]} + })"); + + // Load first preset + processPresetOnLoad(doc1.as()); + ASSERT_EQ(midiLog().size(), 1u); + EXPECT_EQ(midiLog()[0].value1, 9); // 10-1 + + clearMidiLog(); + + // Navigate to second preset + processPresetOnLoad(doc2.as()); + ASSERT_EQ(midiLog().size(), 2u); + EXPECT_EQ(midiLog()[0].type, MidiMessage::ProgramChange); + EXPECT_EQ(midiLog()[0].value1, 19); // 20-1 + EXPECT_EQ(midiLog()[0].channel, 2); + EXPECT_TRUE(midiLog()[0].usb); + EXPECT_EQ(midiLog()[1].type, MidiMessage::ControlChange); + EXPECT_EQ(midiLog()[1].value1, 80); + EXPECT_EQ(midiLog()[1].value2, 100); +} + +TEST_F(MidiMessageTest, FullScenario_PcModeUpDown) { + // Enter PC mode (simulated by long hold), then press up/down + int program = 0; + + // Press switch 3 (up) 5 times + for (int i = 0; i < 5; i++) { + clearMidiLog(); + program = handlePcModePress(program, 3); + } + EXPECT_EQ(program, 5); + + // Press switch 2 (down) 2 times + for (int i = 0; i < 2; i++) { + clearMidiLog(); + program = handlePcModePress(program, 2); + } + EXPECT_EQ(program, 3); + ASSERT_EQ(midiLog().size(), 1u); + EXPECT_EQ(midiLog()[0].value1, 3); +} + +TEST_F(MidiMessageTest, FullScenario_TogglesResetOnPresetChange) { + // Verify that toggle states don't carry over between presets + StaticJsonDocument<512> doc; + deserializeJson(doc, R"({ + "Name": "Toggle", + "Toggle": true, + "CC": [{"CC": 50, "Value": 0, "Channel": 1}], + "PC": null + })"); + + // First press: toggle ON + bool sw1 = executeSwitchPress(doc.as(), false); + EXPECT_TRUE(sw1); + EXPECT_EQ(midiLog()[0].value2, 127); + + clearMidiLog(); + + // Simulate preset change → toggles reset + bool togglesAfterPresetChange[switchCount] = {}; + + // Now press the same switch on the "new" preset (toggle starts at false again) + bool sw1AfterChange = executeSwitchPress(doc.as(), togglesAfterPresetChange[0]); + EXPECT_TRUE(sw1AfterChange); // starts fresh → ON + EXPECT_EQ(midiLog()[0].value2, 127); // sends 127 again (reset worked) +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// Edge cases — USB routing +// ═══════════════════════════════════════════════════════════════════════════════ + +TEST_F(MidiMessageTest, UsbRouting_DefaultsToFalse) { + // When JSON doesn't specify "USB" field, it should default to false + StaticJsonDocument<256> doc; + deserializeJson(doc, R"({"OnLoad": {"PC": [{"PC": 1, "Channel": 1}], "CC": null}})"); + + processPresetOnLoad(doc.as()); + + ASSERT_EQ(midiLog().size(), 1u); + EXPECT_FALSE(midiLog()[0].usb); +} + +TEST_F(MidiMessageTest, UsbRouting_ExplicitTrue) { + StaticJsonDocument<256> doc; + deserializeJson(doc, R"({"OnLoad": {"PC": [{"PC": 1, "Channel": 1, "USB": true}], "CC": null}})"); + + processPresetOnLoad(doc.as()); + + ASSERT_EQ(midiLog().size(), 1u); + EXPECT_TRUE(midiLog()[0].usb); +} + +TEST_F(MidiMessageTest, UsbRouting_ExplicitFalse) { + StaticJsonDocument<256> doc; + deserializeJson(doc, R"({"OnLoad": {"PC": [{"PC": 1, "Channel": 1, "USB": false}], "CC": null}})"); + + processPresetOnLoad(doc.as()); + + ASSERT_EQ(midiLog().size(), 1u); + EXPECT_FALSE(midiLog()[0].usb); +} + +TEST_F(MidiMessageTest, UsbRouting_MixedInSamePreset) { + StaticJsonDocument<1024> doc; + deserializeJson(doc, R"({ + "OnLoad": { + "PC": [ + {"PC": 1, "Channel": 1, "USB": true}, + {"PC": 2, "Channel": 2, "USB": false} + ], + "CC": [ + {"CC": 1, "Value": 127, "Channel": 1, "USB": true}, + {"CC": 2, "Value": 64, "Channel": 2, "USB": false} + ] + } + })"); + + processPresetOnLoad(doc.as()); + + ASSERT_EQ(midiLog().size(), 4u); + EXPECT_TRUE(midiLog()[0].usb); // PC USB + EXPECT_FALSE(midiLog()[1].usb); // PC serial + EXPECT_TRUE(midiLog()[2].usb); // CC USB + EXPECT_FALSE(midiLog()[3].usb); // CC serial +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// Channel routing — messages go to correct MIDI channel +// ═══════════════════════════════════════════════════════════════════════════════ + +TEST_F(MidiMessageTest, ChannelRouting_DifferentChannelsPerEvent) { + StaticJsonDocument<1024> doc; + deserializeJson(doc, R"({ + "Name": "MultiChannel", + "Toggle": false, + "PC": [{"PC": 1, "Channel": 3, "USB": false}], + "CC": [ + {"CC": 1, "Value": 127, "Channel": 5}, + {"CC": 2, "Value": 64, "Channel": 10} + ] + })"); + + executeSwitchPress(doc.as(), false); + + ASSERT_EQ(midiLog().size(), 3u); + EXPECT_EQ(midiLog()[0].channel, 3); // PC to ch3 + EXPECT_EQ(midiLog()[1].channel, 5); // CC to ch5 + EXPECT_EQ(midiLog()[2].channel, 10); // CC to ch10 +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// PC offset verification — JSON PC values are 1-indexed, MIDI sends 0-indexed +// ═══════════════════════════════════════════════════════════════════════════════ + +TEST_F(MidiMessageTest, PcOffset_Pc1SendsZero) { + StaticJsonDocument<256> doc; + deserializeJson(doc, R"({"OnLoad": {"PC": [{"PC": 1, "Channel": 1}], "CC": null}})"); + processPresetOnLoad(doc.as()); + EXPECT_EQ(midiLog()[0].value1, 0); +} + +TEST_F(MidiMessageTest, PcOffset_Pc128Sends127) { + StaticJsonDocument<256> doc; + deserializeJson(doc, R"({"OnLoad": {"PC": [{"PC": 128, "Channel": 1}], "CC": null}})"); + processPresetOnLoad(doc.as()); + EXPECT_EQ(midiLog()[0].value1, 127); +} + +TEST_F(MidiMessageTest, PcOffset_Pc64Sends63) { + StaticJsonDocument<256> doc; + deserializeJson(doc, R"({"OnLoad": {"PC": [{"PC": 64, "Channel": 1}], "CC": null}})"); + processPresetOnLoad(doc.as()); + EXPECT_EQ(midiLog()[0].value1, 63); +}