diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 803b2aee5..f485e32b8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,7 +43,7 @@ jobs: uses: actions/cache@v4 with: path: build - key: cmake-build-${{ runner.os }}-${{ hashFiles('CMakeLists.txt', 'cmake/**', 'crosspad-core', 'crosspad-gui') }} + key: cmake-build-${{ runner.os }}-${{ hashFiles('CMakeLists.txt', 'cmake/**', 'lib/crosspad-core', 'lib/crosspad-gui') }} restore-keys: cmake-build-${{ runner.os }}- - name: Configure diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f5b947bc7..91ccfde08 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -72,7 +72,7 @@ jobs: # Copy SDL2.dll from vcpkg installed dir cp "$RUNVCPKG_VCPKG_ROOT/installed/x64-windows/bin/SDL2.dll" dist/CrossPad/ # Copy assets - cp -r crosspad-gui/assets dist/CrossPad/assets + cp -r lib/crosspad-gui/assets dist/CrossPad/assets - name: Upload artifact uses: actions/upload-artifact@v4 @@ -120,18 +120,44 @@ jobs: - name: Build run: cmake --build build - - name: Package + - name: Create AppImage icon run: | - mkdir -p dist/CrossPad - cp bin/CrossPad dist/CrossPad/CrossPad - chmod +x dist/CrossPad/CrossPad - cp -r crosspad-gui/assets dist/CrossPad/assets + python3 -c " + from PIL import Image + img = Image.open('logo.png') + img.thumbnail((256, 256), Image.LANCZOS) + new_img = Image.new('RGBA', (256, 256), (0, 0, 0, 0)) + x = (256 - img.width) // 2 + y = (256 - img.height) // 2 + new_img.paste(img, (x, y)) + new_img.save('CrossPad.png') + " + + - name: Build AppImage + run: | + # Download linuxdeploy + wget -q https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-x86_64.AppImage + chmod +x linuxdeploy-x86_64.AppImage + + # Prepare AppDir + mkdir -p AppDir/usr/bin + cp bin/CrossPad AppDir/usr/bin/CrossPad + chmod +x AppDir/usr/bin/CrossPad + cp -r lib/crosspad-gui/assets AppDir/usr/bin/assets + + # Build AppImage + export OUTPUT=CrossPad-Linux-x86_64.AppImage + ./linuxdeploy-x86_64.AppImage \ + --appdir=AppDir \ + --desktop-file=CrossPad.desktop \ + --icon-file=CrossPad.png \ + --output=appimage - name: Upload artifact uses: actions/upload-artifact@v4 with: name: CrossPad-Linux-x64 - path: dist/CrossPad + path: CrossPad-Linux-x86_64.AppImage release: needs: [build-windows, build-linux] @@ -149,11 +175,12 @@ jobs: with: name: CrossPad-Linux-x64 path: CrossPad-Linux-x64 + merge-multiple: true - name: Create archives run: | cd CrossPad-Windows-x64 && zip -r ../CrossPad-Windows-x64.zip . && cd .. - tar -czf CrossPad-Linux-x64.tar.gz -C CrossPad-Linux-x64 . + cp CrossPad-Linux-x64/CrossPad-Linux-x86_64.AppImage . - name: Checkout (for changelog generation) uses: actions/checkout@v4 @@ -209,7 +236,7 @@ jobs: prerelease: ${{ steps.tag.outputs.prerelease == 'true' }} files: | CrossPad-Windows-x64.zip - CrossPad-Linux-x64.tar.gz + CrossPad-Linux-x86_64.AppImage - name: Detect release type id: release_type diff --git a/.gitignore b/.gitignore index bf08ef827..817d2e994 100644 --- a/.gitignore +++ b/.gitignore @@ -6,8 +6,10 @@ bin/ .*/ !.github/ -# VSCode -.vscode +# VSCode — ignore all except shared config +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json # MacOS file .DS_Store @@ -23,3 +25,5 @@ _build_*.bat # Screenshots (dev/test artifacts) screenshots/ +app-registry.json +apps.json diff --git a/.gitmodules b/.gitmodules index 3b68a8992..0423a5bdb 100644 --- a/.gitmodules +++ b/.gitmodules @@ -8,8 +8,23 @@ path = FreeRTOS url = https://github.com/FreeRTOS/FreeRTOS-Kernel.git [submodule "crosspad-core"] - path = crosspad-core + path = lib/crosspad-core url = https://github.com/CrossPad/crosspad-core.git [submodule "crosspad-gui"] - path = crosspad-gui + path = lib/crosspad-gui url = https://github.com/CrossPad/crosspad-gui.git +[submodule "src/apps/crosspad-piano"] + path = src/apps/crosspad-piano + url = https://github.com/CrossPad/crosspad-piano.git +[submodule "src/apps/crosspad-instructions"] + path = src/apps/crosspad-instructions + url = https://github.com/CrossPad/crosspad-instructions.git +[submodule "src/apps/crosspad-appstore"] + path = src/apps/crosspad-appstore + url = https://github.com/CrossPad/crosspad-appstore.git +[submodule "src/apps/crosspad-serial-monitor"] + path = src/apps/crosspad-serial-monitor + url = https://github.com/CrossPad/crosspad-serial-monitor.git +[submodule "src/apps/crosspad-mixer"] + path = src/apps/crosspad-mixer + url = https://github.com/CrossPad/crosspad-mixer.git diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 000000000..2a098711b --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,13 @@ +{ + "VsCodeTaskButtons.showCounter": false, + "VsCodeTaskButtons.tasks": [ + { + "label": "$(package) CP Tools", + "task": "CrossPad: App Manager" + }, + { + "label": "$(zap) Run", + "task": "CrossPad: Run" + } + ] +} diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 000000000..71ac0a119 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,28 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "CrossPad: App Manager", + "type": "shell", + "command": "python3", + "args": ["${workspaceFolder}/scripts/app_manager.py"], + "presentation": { + "reveal": "always", + "panel": "dedicated", + "focus": true + }, + "problemMatcher": [] + }, + { + "label": "CrossPad: Run", + "type": "shell", + "command": "${workspaceFolder}/scripts/run.sh", + "presentation": { + "reveal": "always", + "panel": "dedicated", + "focus": true + }, + "problemMatcher": [] + } + ] +} diff --git a/CLAUDE.md b/CLAUDE.md index b1d33df4c..80b88a925 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,15 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Default Tools -**Always prefer CrossPad MCP tools** (`crosspad_build`, `crosspad_run`, `crosspad_screenshot`, `crosspad_log`, `crosspad_test`, etc.) over manual bash commands for building, running, testing, and interacting with the simulator. The MCP server handles MSVC environment setup, paths, and simulator communication automatically. +**Always prefer CrossPad MCP tools** over manual bash commands for building, running, testing, and interacting with the simulator. The MCP server handles MSVC/GCC environment setup, paths, and simulator communication automatically. + +Key tools: +- `crosspad_build` — build/run/check simulator (`action: pc/pc_run/pc_check/pc_log`) and ESP-IDF firmware (`action: idf`) +- `crosspad_test` — run Catch2 tests (`action: run/scaffold`) +- `crosspad_sim` — screenshots, input, stats, settings (`action: screenshot/input/stats/settings_get/settings_set`) +- `crosspad_repo` — git status and submodule diffs (`action: status/diff`) +- `crosspad_code` — search symbols, query interfaces, list apps, scaffold (`action: search/interfaces/apps/scaffold`) +- `crosspad_apps` — app package manager (`action: list/install/remove/update/sync`) ## Project Overview @@ -85,16 +93,47 @@ src/ PcApp.cpp — lightweight App class for launcher (no sequencer/CLI) pc_platform.h — public API: pc_platform_init(), set_midi/audio/synth lib/ml_synth/ — vendored ML_SynthTools FM synth engine +lib/crosspad-core/ — submodule: portable C++ library +lib/crosspad-gui/ — submodule: shared LVGL UI components +scripts/ + app_manager.py — Python app manager (wrapper for crosspad-apps core) + run.sh — Smart build+run script tools/mcp-server/ — MCP development server (TypeScript, 16 tools) ``` ### Submodules +**Shared libraries** (in `lib/`): + - **crosspad-core**: Portable C++ library — AppRegistry, IEventBus, PadManager, PadLedController, CrosspadSettings (IKeyValueStore), Stm32MessageHandler, platform interfaces (IClock, IMidiOutput, ILedStrip, IAudioOutput, ISynthEngine). Originally an ESP-IDF component; sources are listed manually in CMakeLists.txt. - **crosspad-gui**: LVGL UI components — theme, styles, launcher, status bar, widgets (keypad buttons, spinbox, radial menu, VU meter, file explorer, DFU panel, modals/toasts). Originally an ESP-IDF component; sources listed manually. - **lvgl**: LVGL v9.x graphics library - **FreeRTOS**: FreeRTOS Kernel (MSVC-MingW port on Windows, GCC POSIX on Linux/Mac) +**Installable apps** (in `src/apps/crosspad-*/`, managed by app manager): + +- **crosspad-appstore**: Built-in App Store (cannot be removed) +- **crosspad-mixer**, **crosspad-piano**, **crosspad-instructions**, **crosspad-serial-monitor**: Installable via `python3 scripts/app_manager.py install ` or via the App Store UI + +### App Management + +Apps are installed as git submodules in `src/apps/crosspad-*/`. CMake auto-discovers them via `file(GLOB)`. The app manager (`scripts/app_manager.py`) wraps the shared [crosspad-apps](https://github.com/CrossPad/crosspad-apps) core. + +**CLI commands:** + +```bash +python3 scripts/app_manager.py list # List available apps +python3 scripts/app_manager.py install mixer # Install an app +python3 scripts/app_manager.py remove mixer # Remove an app +python3 scripts/app_manager.py update --all # Update all installed apps +python3 scripts/app_manager.py sync # Sync manifest with disk +python3 scripts/app_manager.py # Launch interactive TUI +``` + +**After install/remove:** `cmake -B build -G Ninja && cmake --build build` (CMake reconfigure picks up new sources). + +**Creating a new app:** See the [crosspad-appstore README](https://github.com/CrossPad/crosspad-appstore#creating-a-crosspad-app) for the app repo structure, `crosspad-app.json` format, and registration pattern. + ### Platform Abstraction Pattern crosspad-core defines portable interfaces; this repo provides PC implementations: diff --git a/CMakeLists.txt b/CMakeLists.txt index 779fd9e71..1384fe077 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -14,6 +14,7 @@ set(LVGL_PRO_PROJECT_DIR option(USE_MIDI "Enable MIDI input/output via RtMidi" ON) option(USE_AUDIO "Enable system audio output via RtAudio" ON) +option(USE_BLE "Enable BLE MIDI via SimpleBLE" ON) # FreeRTOS — always enabled (required for crosspad-core event bus & LVGL OSAL) add_library(freertos_config INTERFACE) @@ -109,6 +110,22 @@ if(USE_AUDIO) message(STATUS "Audio support enabled (RtAudio)") endif() +# BLE MIDI support via SimpleBLE +if(USE_BLE) + FetchContent_Declare( + simpleble + GIT_REPOSITORY https://github.com/OpenBluetoothToolbox/SimpleBLE.git + GIT_TAG v0.7.3 + SOURCE_SUBDIR simpleble + ) + FetchContent_MakeAvailable(simpleble) + # SimpleBLE sets /WX /W1 internally — override to suppress warnings + if(MSVC) + target_compile_options(simpleble PRIVATE /w /WX-) + endif() + message(STATUS "BLE support enabled (SimpleBLE)") +endif() + # Add compile definitions based on the selected options add_compile_definitions($<$:LV_USE_DRAW_SDL=1>) add_compile_definitions($<$:LV_USE_LIBPNG=1>) @@ -130,20 +147,24 @@ if(MSVC) endif() # ── crosspad-core portable sources (auto-discover, excluding platform-specific) ── -file(GLOB_RECURSE CROSSPAD_CORE_SOURCES crosspad-core/src/*.cpp) +file(GLOB_RECURSE CROSSPAD_CORE_SOURCES lib/crosspad-core/src/*.cpp) list(FILTER CROSSPAD_CORE_SOURCES EXCLUDE REGEX "EspEventBus\\.cpp$") list(FILTER CROSSPAD_CORE_SOURCES EXCLUDE REGEX "SyncEventBus\\.cpp$") list(FILTER CROSSPAD_CORE_SOURCES EXCLUDE REGEX "Stm32Manager\\.cpp$") +if(MSVC) + # PortableKitLoader uses dirent.h (POSIX-only) + list(FILTER CROSSPAD_CORE_SOURCES EXCLUDE REGEX "PortableKitLoader\\.cpp$") +endif() # FreeRtosEventBus.cpp stays — used as the event bus implementation # ── crosspad-gui sources (auto-discover) ── -file(GLOB_RECURSE CROSSPAD_GUI_SOURCES crosspad-gui/src/*.cpp) +file(GLOB_RECURSE CROSSPAD_GUI_SOURCES lib/crosspad-gui/src/*.cpp) # ── Auto-reconfigure when source files are added/removed ── # Ninja watches these directories and triggers cmake reconfigure on changes set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS - "${CMAKE_SOURCE_DIR}/crosspad-core/src" - "${CMAKE_SOURCE_DIR}/crosspad-gui/src" + "${CMAKE_SOURCE_DIR}/lib/crosspad-core/src" + "${CMAKE_SOURCE_DIR}/lib/crosspad-gui/src" "${CMAKE_SOURCE_DIR}/src/apps" "${CMAKE_SOURCE_DIR}/src/pc_stubs" "${CMAKE_SOURCE_DIR}/src/updater" @@ -199,6 +220,12 @@ if(USE_MIDI) endif() endif() +# BLE MIDI sources and libraries +if(USE_BLE) + list(APPEND MAIN_SOURCES src/midi/PcBleMidi.cpp) + list(APPEND MAIN_LIBS simpleble::simpleble) +endif() + # Audio sources and libraries if(USE_AUDIO) list(APPEND MAIN_SOURCES src/audio/PcAudio.cpp src/audio/PcAudioInput.cpp src/audio/PcAudioModule.cpp) @@ -211,37 +238,42 @@ if(USE_AUDIO) lib/ml_synth/ml_utils_stub.cpp ) - # ML Piano app (own CMakeLists.txt exports ML_PIANO_APP_SOURCES) - add_subdirectory(src/apps/ml_piano) - - # Audio Mixer app - add_subdirectory(src/apps/mixer) - # Synth engine wrapper (platform-level, like ESP32's audio codec) set(ML_PIANO_SYNTH_SOURCES src/synth/MlPianoSynth.cpp) - list(APPEND MAIN_SOURCES ${ML_SYNTH_SOURCES} ${ML_PIANO_SYNTH_SOURCES} ${ML_PIANO_APP_SOURCES} ${MIXER_APP_SOURCES}) + list(APPEND MAIN_SOURCES ${ML_SYNTH_SOURCES} ${ML_PIANO_SYNTH_SOURCES}) endif() -# Settings app (always available) -add_subdirectory(src/apps/settings) -list(APPEND MAIN_SOURCES ${SETTINGS_APP_SOURCES}) - -# CI Test app (audio pipeline integration tests) -add_subdirectory(src/apps/citest) -list(APPEND MAIN_SOURCES ${CITEST_APP_SOURCES}) +# ── Built-in apps (always present) ────────────────────────────────────── +foreach(_builtin settings citest update) + if(EXISTS "${CMAKE_SOURCE_DIR}/src/apps/${_builtin}/CMakeLists.txt") + add_subdirectory(src/apps/${_builtin}) + endif() +endforeach() +list(APPEND MAIN_SOURCES ${SETTINGS_APP_SOURCES} ${CITEST_APP_SOURCES} ${UPDATE_APP_SOURCES}) + +# ── Installable apps (submodules in src/apps/crosspad-*) ──────────────── +file(GLOB _app_submodule_dirs "${CMAKE_SOURCE_DIR}/src/apps/crosspad-*/") +foreach(_app_dir ${_app_submodule_dirs}) + if(NOT IS_DIRECTORY "${_app_dir}") + continue() + endif() + get_filename_component(_app_name "${_app_dir}" NAME) -# Instructions app (markdown-rendered help/shortcuts reference) -add_subdirectory(src/apps/instructions) -list(APPEND MAIN_SOURCES ${INSTRUCTIONS_APP_SOURCES}) + # App Store is dev-only — skip when BUILD_TESTING is OFF (release builds) + if(NOT BUILD_TESTING AND _app_name STREQUAL "crosspad-appstore") + message(STATUS "Skipping dev-only app: ${_app_name}") + continue() + endif() -# Serial Monitor app (UART output viewer) -add_subdirectory(src/apps/serial_monitor) -list(APPEND MAIN_SOURCES ${SERIAL_MONITOR_APP_SOURCES}) + # Collect sources from src/ + file(GLOB _app_sources "${_app_dir}/src/*.cpp") + if(_app_sources) + list(APPEND MAIN_SOURCES ${_app_sources}) + endif() -# Update app (version check, download, auto-update) -add_subdirectory(src/apps/update) -list(APPEND MAIN_SOURCES ${UPDATE_APP_SOURCES}) + message(STATUS "App submodule: ${_app_name} (${_app_sources})") +endforeach() # Updater module (version check, download, self-update) list(APPEND MAIN_SOURCES src/updater/PcUpdater.cpp) @@ -260,7 +292,11 @@ configure_file(cmake/version.h.in "${CMAKE_BINARY_DIR}/generated/crosspad_pc_ver # Auto-generate app registry initialization include(cmake/generate_registry.cmake) set(APP_REGISTRY_FILE "${CMAKE_BINARY_DIR}/app_registry_init.cpp") -generate_app_registry("${APP_REGISTRY_FILE}" "${PROJECT_SOURCE_DIR}/src/apps") +set(_registry_excludes "") +if(NOT BUILD_TESTING) + list(APPEND _registry_excludes "crosspad-appstore") +endif() +generate_app_registry("${APP_REGISTRY_FILE}" "${PROJECT_SOURCE_DIR}/src/apps" ${_registry_excludes}) list(APPEND MAIN_SOURCES "${APP_REGISTRY_FILE}") # Custom target to run the executable @@ -326,16 +362,28 @@ target_compile_definitions(CrossPad PRIVATE CP_LCD_HOR_RES=320 CP_LCD_VER_RES=240 PLATFORM_PC=1 + CROSSPAD_REGISTER_APP_NAMED_FUNCTIONS=1 ) target_include_directories(CrossPad PRIVATE ${PROJECT_SOURCE_DIR}/src - ${PROJECT_SOURCE_DIR}/crosspad-core/include - ${PROJECT_SOURCE_DIR}/crosspad-gui/include + ${PROJECT_SOURCE_DIR}/lib/crosspad-core/include + ${PROJECT_SOURCE_DIR}/lib/crosspad-gui/include ${PROJECT_SOURCE_DIR}/lib/ml_synth ${PROJECT_SOURCE_DIR}/lib ${PROJECT_SOURCE_DIR}/lvgl/src ${CMAKE_BINARY_DIR}/generated ) +# Add include dirs from installable app submodules +foreach(_app_dir ${_app_submodule_dirs}) + if(IS_DIRECTORY "${_app_dir}/include") + target_include_directories(CrossPad PRIVATE "${_app_dir}/include") + # Also add the nested dir so apps can use short includes (e.g. "MixerApp.hpp") + get_filename_component(_app_name "${_app_dir}" NAME) + if(IS_DIRECTORY "${_app_dir}/include/${_app_name}") + target_include_directories(CrossPad PRIVATE "${_app_dir}/include/${_app_name}") + endif() + endif() +endforeach() target_link_libraries(CrossPad ${MAIN_LIBS}) # Winsock + COM (folder picker dialog) for Windows @@ -362,12 +410,21 @@ endif() target_compile_definitions(CrossPad PRIVATE USE_FREERTOS=1) +# Dev build flag — exposed to C++ so apps can show dev-only info +if(BUILD_TESTING) + target_compile_definitions(CrossPad PRIVATE CROSSPAD_DEV_BUILD=1) +endif() + # MIDI compile definition if(USE_MIDI) target_compile_definitions(CrossPad PRIVATE USE_MIDI=1) endif() +if(USE_BLE) + target_compile_definitions(CrossPad PRIVATE USE_BLE=1) +endif() + if(USE_AUDIO) target_compile_definitions(CrossPad PRIVATE USE_AUDIO=1) # Suppress warnings for vendored ML_SynthTools code diff --git a/CrossPad.desktop b/CrossPad.desktop new file mode 100644 index 000000000..dc08241ba --- /dev/null +++ b/CrossPad.desktop @@ -0,0 +1,8 @@ +[Desktop Entry] +Type=Application +Name=CrossPad +Comment=CrossPad device simulator +Exec=CrossPad +Icon=CrossPad +Categories=Audio;Music;Midi; +Terminal=false diff --git a/apps.json b/apps.json new file mode 100644 index 000000000..4582bde01 --- /dev/null +++ b/apps.json @@ -0,0 +1,22 @@ +{ + "installed": { + "piano": { + "version": "73376751", + "ref": "main", + "repo": "https://github.com/CrossPad/crosspad-piano.git", + "installed_at": "2026-04-08T13:23:52.201526+00:00" + }, + "instructions": { + "version": "938aea76", + "ref": "main", + "repo": "https://github.com/CrossPad/crosspad-instructions.git", + "installed_at": "2026-04-08T13:23:53.166029+00:00" + }, + "serial-monitor": { + "version": "a946e264", + "ref": "main", + "repo": "https://github.com/CrossPad/crosspad-serial-monitor.git", + "installed_at": "2026-04-08T18:45:16.225115+00:00" + } + } +} diff --git a/cmake/generate_registry.cmake b/cmake/generate_registry.cmake index e72141ccd..e689b8291 100644 --- a/cmake/generate_registry.cmake +++ b/cmake/generate_registry.cmake @@ -11,6 +11,8 @@ # generate_app_registry("${CMAKE_BINARY_DIR}/app_registry_init.cpp" APP_SCAN_DIRS) function(generate_app_registry OUTPUT_FILE SCAN_DIRS) + # Optional: extra args are exclude patterns (directory names to skip) + set(EXCLUDE_DIRS ${ARGN}) set(APP_NAMES "") foreach(SCAN_DIR ${SCAN_DIRS}) @@ -20,6 +22,16 @@ function(generate_app_registry OUTPUT_FILE SCAN_DIRS) if(NOT EXISTS "${src}") continue() endif() + # Check exclude patterns + set(_skip FALSE) + foreach(_excl ${EXCLUDE_DIRS}) + if("${src}" MATCHES "/${_excl}/") + set(_skip TRUE) + endif() + endforeach() + if(_skip) + continue() + endif() file(READ "${src}" FILE_CONTENT) # Pattern 1: REGISTER_APP(Name, ...) macro diff --git a/crosspad-core b/crosspad-core deleted file mode 160000 index be168f781..000000000 --- a/crosspad-core +++ /dev/null @@ -1 +0,0 @@ -Subproject commit be168f7819246105c6dba12be244c1049a7b1b5b diff --git a/lib/crosspad-core b/lib/crosspad-core new file mode 160000 index 000000000..fcf1b7af7 --- /dev/null +++ b/lib/crosspad-core @@ -0,0 +1 @@ +Subproject commit fcf1b7af71761f7bdae485f3735e38bc53f49284 diff --git a/crosspad-gui b/lib/crosspad-gui similarity index 100% rename from crosspad-gui rename to lib/crosspad-gui diff --git a/lv_conf.h b/lv_conf.h index 551b0e523..b1f7344f2 100644 --- a/lv_conf.h +++ b/lv_conf.h @@ -905,7 +905,11 @@ /** Setting a default driver letter allows skipping the driver prefix in filepaths. * Documentation about how to use the below driver-identifier letters can be found at * https://docs.lvgl.io/master/main-modules/fs.html#lv-fs-identifier-letters . */ -#define LV_FS_DEFAULT_DRIVER_LETTER '\0' +#ifdef _WIN32 + #define LV_FS_DEFAULT_DRIVER_LETTER '\0' +#else + #define LV_FS_DEFAULT_DRIVER_LETTER 'C' /* Linux/Mac: route unprefixed paths to POSIX driver */ +#endif /** API for fopen, fread, etc. */ #define LV_USE_FS_STDIO 1 diff --git a/scripts/app_manager.py b/scripts/app_manager.py new file mode 100755 index 000000000..aa7851c72 --- /dev/null +++ b/scripts/app_manager.py @@ -0,0 +1,89 @@ +#!/usr/bin/env python3 +"""CrossPad PC App Manager — thin wrapper around shared core. + +Downloads crosspad_app_manager.py from CrossPad/crosspad-apps if not cached, +then delegates all commands (list, install, remove, update, sync, tui). + +Usage: + python3 scripts/app_manager.py # Launch TUI + python3 scripts/app_manager.py list # List compatible apps + python3 scripts/app_manager.py install mixer # Install app + python3 scripts/app_manager.py remove mixer # Remove app + python3 scripts/app_manager.py update --all # Update all + python3 scripts/app_manager.py sync # Sync manifest +""" + +import importlib.util +import os +import subprocess +import sys +from pathlib import Path + +CORE_REPO = "CrossPad/crosspad-apps" +CORE_FILE = "crosspad_app_manager.py" +CACHE_DIR = Path(__file__).parent / ".cache" + + +def _download_core() -> Path: + """Download shared core from GitHub via gh CLI.""" + CACHE_DIR.mkdir(exist_ok=True) + dest = CACHE_DIR / CORE_FILE + + # Check freshness (re-download every hour) + if dest.exists(): + import time + age = time.time() - dest.stat().st_mtime + if age < 3600: + return dest + + try: + import base64 + result = subprocess.run( + ["gh", "api", f"repos/{CORE_REPO}/contents/{CORE_FILE}", + "--jq", ".content"], + capture_output=True, text=True, check=True, timeout=15, + ) + content = base64.b64decode(result.stdout.strip()).decode() + dest.write_text(content) + return dest + except (subprocess.CalledProcessError, FileNotFoundError, + subprocess.TimeoutExpired) as e: + if dest.exists(): + print(f"Warning: Could not update core ({e}), using cached version.") + return dest + print(f"Error: Cannot download {CORE_FILE}: {e}") + print("Make sure 'gh' CLI is installed and authenticated (gh auth login).") + sys.exit(1) + + +def _load_core(path: Path): + """Import the shared core module from downloaded file.""" + spec = importlib.util.spec_from_file_location("crosspad_app_manager", str(path)) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +def main(): + # Find project root (parent of scripts/) + project_dir = Path(__file__).resolve().parent.parent + + # Download and import shared core + core_path = _download_core() + core = _load_core(core_path) + + # PC platform config + config = core.PlatformConfig( + platform="pc", + lib_dir="src/apps", + official_org="CrossPad", + lib_prefix="crosspad-", + ) + + # Delegate to shared CLI + os.chdir(project_dir) + core.cli_main(config) + + +if __name__ == "__main__": + main() diff --git a/scripts/run.sh b/scripts/run.sh new file mode 100755 index 000000000..ebc9adfdf --- /dev/null +++ b/scripts/run.sh @@ -0,0 +1,26 @@ +#!/bin/bash +# Smart run: build if needed, then launch simulator +set -e +cd "$(dirname "$0")/.." + +BIN="bin/CrossPad" + +needs_build() { + # No binary at all + [ ! -f "$BIN" ] && return 0 + # No build dir (never configured) + [ ! -d "build" ] && return 0 + # Any source newer than binary + find src lib/crosspad-core/src lib/crosspad-gui/src -name '*.cpp' -o -name '*.hpp' -o -name '*.h' -o -name '*.c' 2>/dev/null \ + | while read f; do [ "$f" -nt "$BIN" ] && echo dirty && break; done | grep -q dirty +} + +if needs_build; then + echo "[CrossPad] Building..." + cmake -B build -G Ninja -DCMAKE_BUILD_TYPE=Debug + cmake --build build + echo "" +fi + +echo "[CrossPad] Launching simulator..." +exec "$BIN" diff --git a/src/apps/citest/CITestApp.cpp b/src/apps/citest/CITestApp.cpp index d1ba35705..02134b1ae 100644 --- a/src/apps/citest/CITestApp.cpp +++ b/src/apps/citest/CITestApp.cpp @@ -9,7 +9,10 @@ #include "pc_stubs/PcApp.hpp" #include "pc_stubs/pc_platform.h" -#include "apps/mixer/AudioMixerEngine.hpp" +#if __has_include("crosspad-mixer/AudioMixerEngine.hpp") +#include "crosspad-mixer/AudioMixerEngine.hpp" +#define HAS_MIXER 1 +#endif #include "synth/MlPianoSynth.hpp" #include "crosspad/app/AppRegistrar.hpp" @@ -162,7 +165,9 @@ static void drainTap(crosspad::AudioRingBuffer& tap) { static void testRunnerTask(void* pvParam) { (void)pvParam; +#ifdef HAS_MIXER auto& mixer = getMixerEngine(); +#endif auto* synth = pc_platform_get_synth_engine(); // Tap buffer: 2 seconds stereo at 48kHz @@ -176,6 +181,14 @@ static void testRunnerTask(void* pvParam) { return; } +#ifndef HAS_MIXER + // No mixer installed — skip all mixer-dependent tests + for (int i = 0; i < NUM_STAGES; i++) + setStage(i, StageResult::FAIL, "mixer not installed"); + s_testRunning.store(false); + vTaskDelete(nullptr); + return; +#else // Save original mixer state float origSynthVol = mixer.getChannelVolume(MixerInput::SYNTH); bool origSynthMuted = mixer.isChannelMuted(MixerInput::SYNTH); @@ -396,6 +409,7 @@ static void testRunnerTask(void* pvParam) { setStage(6, StageResult::PASS, "mixer restored"); } +#endif // HAS_MIXER // Count results int pass = 0, fail = 0; diff --git a/src/apps/crosspad-appstore b/src/apps/crosspad-appstore new file mode 160000 index 000000000..9f670b16c --- /dev/null +++ b/src/apps/crosspad-appstore @@ -0,0 +1 @@ +Subproject commit 9f670b16caaa98b2bbea3859caed2782794cb47a diff --git a/src/apps/crosspad-instructions b/src/apps/crosspad-instructions new file mode 160000 index 000000000..938aea761 --- /dev/null +++ b/src/apps/crosspad-instructions @@ -0,0 +1 @@ +Subproject commit 938aea761ee46d92393a26084f6529568a40a838 diff --git a/src/apps/crosspad-piano b/src/apps/crosspad-piano new file mode 160000 index 000000000..733767510 --- /dev/null +++ b/src/apps/crosspad-piano @@ -0,0 +1 @@ +Subproject commit 7337675102f3b45aebe710566b72d64480131adf diff --git a/src/apps/crosspad-serial-monitor b/src/apps/crosspad-serial-monitor new file mode 160000 index 000000000..a946e264a --- /dev/null +++ b/src/apps/crosspad-serial-monitor @@ -0,0 +1 @@ +Subproject commit a946e264a0da770bf675b88ac83109eced70df33 diff --git a/src/apps/instructions/CMakeLists.txt b/src/apps/instructions/CMakeLists.txt deleted file mode 100644 index 0f68dc427..000000000 --- a/src/apps/instructions/CMakeLists.txt +++ /dev/null @@ -1,4 +0,0 @@ -set(INSTRUCTIONS_APP_SOURCES - ${CMAKE_CURRENT_SOURCE_DIR}/InstructionsApp.cpp - PARENT_SCOPE -) diff --git a/src/apps/instructions/InstructionsApp.cpp b/src/apps/instructions/InstructionsApp.cpp deleted file mode 100644 index 7a3cb0eba..000000000 --- a/src/apps/instructions/InstructionsApp.cpp +++ /dev/null @@ -1,105 +0,0 @@ -#if USE_LVGL - -#include "crosspad/app/AppRegistrar.hpp" -#include "crosspad-gui/platform/IGuiPlatform.h" -#include "crosspad-gui/components/markdown_view.h" -#include "lvgl.h" - -#include -#include -#include - -#ifdef _WIN32 -#define WIN32_LEAN_AND_MEAN -#define NOMINMAX -#include -#undef ERROR -#endif - -/* ── Markdown file locator ───────────────────────────────────────────── */ - -static std::string findInstructionsFile() -{ - namespace fs = std::filesystem; - - const char* candidates[] = { - "docs/instructions.md", - "../docs/instructions.md", - }; - - for (auto& c : candidates) { - std::error_code ec; - if (fs::exists(c, ec)) return fs::canonical(c, ec).string(); - } - -#ifdef _WIN32 - char exePath[MAX_PATH] = {}; - GetModuleFileNameA(NULL, exePath, MAX_PATH); - fs::path exeDir = fs::path(exePath).parent_path(); - { - auto p = exeDir / ".." / "docs" / "instructions.md"; - std::error_code ec; - if (fs::exists(p, ec)) return fs::canonical(p, ec).string(); - } -#endif - - return {}; -} - -/* ── App create / destroy ────────────────────────────────────────────── */ - -static lv_obj_t* lv_CreateInstructions(lv_obj_t* parent, App* a) -{ - (void)a; - - lv_obj_t* container = lv_obj_create(parent); - lv_obj_set_size(container, lv_obj_get_content_width(parent), - lv_obj_get_content_height(parent)); - lv_obj_set_style_bg_color(container, lv_color_black(), 0); - lv_obj_set_style_border_width(container, 0, 0); - lv_obj_set_style_pad_all(container, 0, 0); - lv_obj_set_style_radius(container, 0, 0); - lv_obj_center(container); - - lv_obj_t* content = lv_obj_create(container); - lv_obj_set_size(content, LV_PCT(100), LV_PCT(100)); - lv_obj_set_style_bg_color(content, lv_color_black(), 0); - lv_obj_set_style_border_width(content, 0, 0); - lv_obj_set_style_pad_all(content, 8, 0); - lv_obj_set_style_pad_row(content, 2, 0); - lv_obj_set_style_radius(content, 0, 0); - lv_obj_set_flex_flow(content, LV_FLEX_FLOW_COLUMN); - lv_obj_add_flag(content, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_set_scrollbar_mode(content, LV_SCROLLBAR_MODE_AUTO); - - std::string path = findInstructionsFile(); - if (path.empty()) { - lv_obj_t* lbl = lv_label_create(content); - lv_label_set_text(lbl, "instructions.md not found"); - lv_obj_set_style_text_color(lbl, lv_color_hex(0xFF4444), 0); - } else { - crosspad_gui::markdown_render_file(content, path); - printf("[Instructions] Loaded from %s\n", path.c_str()); - } - - return container; -} - -static void lv_DestroyInstructions(lv_obj_t* obj) -{ - (void)obj; -} - -void _register_Instructions_app() { - static char icon_path[256]; - snprintf(icon_path, sizeof(icon_path), "%sinfo.png", - crosspad_gui::getGuiPlatform().assetPathPrefix()); - static const crosspad::AppEntry entry = { - "Help", icon_path, - lv_CreateInstructions, lv_DestroyInstructions, - nullptr, nullptr, nullptr, nullptr, 0 - }; - crosspad::AppRegistry::getInstance().registerApp(entry); -} - -#endif // USE_LVGL diff --git a/src/apps/mixer/AudioMixerEngine.cpp b/src/apps/mixer/AudioMixerEngine.cpp deleted file mode 100644 index 3128ccd86..000000000 --- a/src/apps/mixer/AudioMixerEngine.cpp +++ /dev/null @@ -1,485 +0,0 @@ -/** - * @file AudioMixerEngine.cpp - * @brief Real-time audio mixer thread implementation. - */ - -#include "AudioMixerEngine.hpp" - -#include "pc_stubs/pc_platform.h" -#include "audio/PcAudio.hpp" -#include "audio/PcAudioInput.hpp" -#include "synth/MlPianoSynth.hpp" - -#include - -#include -#include -#include -#include -#include -#include - -AudioMixerEngine::~AudioMixerEngine() -{ - stop(); -} - -void AudioMixerEngine::start() -{ - if (running_.load()) return; - - running_.store(true); - std::thread(&AudioMixerEngine::mixerThreadFunc, this).detach(); -} - -void AudioMixerEngine::stop() -{ - if (!running_.load()) return; - running_.store(false); - std::this_thread::sleep_for(std::chrono::milliseconds(20)); -} - -// ── Route matrix ── - -void AudioMixerEngine::setRouteEnabled(MixerInput in, MixerOutput out, bool enabled) -{ - routes_[(int)in][(int)out].enabled.store(enabled, std::memory_order_relaxed); -} - -bool AudioMixerEngine::isRouteEnabled(MixerInput in, MixerOutput out) const -{ - return routes_[(int)in][(int)out].enabled.load(std::memory_order_relaxed); -} - -void AudioMixerEngine::setRouteVolume(MixerInput in, MixerOutput out, float vol) -{ - routes_[(int)in][(int)out].volume.store(vol, std::memory_order_relaxed); -} - -float AudioMixerEngine::getRouteVolume(MixerInput in, MixerOutput out) const -{ - return routes_[(int)in][(int)out].volume.load(std::memory_order_relaxed); -} - -// ── Channel control ── - -void AudioMixerEngine::setChannelVolume(MixerInput in, float vol) -{ - channels_[(int)in].volume.store(vol, std::memory_order_relaxed); -} - -float AudioMixerEngine::getChannelVolume(MixerInput in) const -{ - return channels_[(int)in].volume.load(std::memory_order_relaxed); -} - -void AudioMixerEngine::setChannelMute(MixerInput in, bool muted) -{ - channels_[(int)in].muted.store(muted, std::memory_order_relaxed); -} - -bool AudioMixerEngine::isChannelMuted(MixerInput in) const -{ - return channels_[(int)in].muted.load(std::memory_order_relaxed); -} - -void AudioMixerEngine::setChannelSolo(MixerInput in, bool soloed) -{ - channels_[(int)in].soloed.store(soloed, std::memory_order_relaxed); -} - -bool AudioMixerEngine::isChannelSoloed(MixerInput in) const -{ - return channels_[(int)in].soloed.load(std::memory_order_relaxed); -} - -// ── Output bus ── - -void AudioMixerEngine::setOutputVolume(MixerOutput out, float vol) -{ - outputs_[(int)out].volume.store(vol, std::memory_order_relaxed); -} - -float AudioMixerEngine::getOutputVolume(MixerOutput out) const -{ - return outputs_[(int)out].volume.load(std::memory_order_relaxed); -} - -void AudioMixerEngine::setOutputMute(MixerOutput out, bool muted) -{ - outputs_[(int)out].muted.store(muted, std::memory_order_relaxed); -} - -bool AudioMixerEngine::isOutputMuted(MixerOutput out) const -{ - return outputs_[(int)out].muted.load(std::memory_order_relaxed); -} - -// ── Metering ── - -void AudioMixerEngine::getChannelLevel(MixerInput in, int16_t& left, int16_t& right) const -{ - left = channels_[(int)in].peakL.load(std::memory_order_relaxed); - right = channels_[(int)in].peakR.load(std::memory_order_relaxed); -} - -void AudioMixerEngine::getOutputLevel(MixerOutput out, int16_t& left, int16_t& right) const -{ - left = outputs_[(int)out].peakL.load(std::memory_order_relaxed); - right = outputs_[(int)out].peakR.load(std::memory_order_relaxed); -} - -bool AudioMixerEngine::isAnySoloed() const -{ - for (int i = 0; i < MIXER_NUM_INPUTS; i++) { - if (channels_[i].soloed.load(std::memory_order_relaxed)) - return true; - } - return false; -} - -// ── Helper: compute peak from interleaved stereo buffer ── - -static void computePeak(const int16_t* buf, uint32_t frames, - std::atomic& peakL, std::atomic& peakR) -{ - int16_t maxL = 0, maxR = 0; - for (uint32_t i = 0; i < frames; i++) { - int16_t absL = buf[i * 2] < 0 ? (int16_t)-buf[i * 2] : buf[i * 2]; - int16_t absR = buf[i * 2 + 1] < 0 ? (int16_t)-buf[i * 2 + 1] : buf[i * 2 + 1]; - if (absL > maxL) maxL = absL; - if (absR > maxR) maxR = absR; - } - peakL.store(maxL, std::memory_order_relaxed); - peakR.store(maxR, std::memory_order_relaxed); -} - -// ── Mixer thread ── - -void AudioMixerEngine::mixerThreadFunc() -{ - constexpr uint32_t CHUNK = MIXER_CHUNK_FRAMES; - constexpr uint32_t STEREO_SAMPLES = CHUNK * 2; - - // Input buffers (interleaved int16 stereo) - std::vector inBuf[MIXER_NUM_INPUTS]; - for (auto& b : inBuf) b.resize(STEREO_SAMPLES, 0); - - // Output accumulation buffers (int32 to avoid clipping during sum) - std::vector outAccum[MIXER_NUM_OUTPUTS]; - for (auto& b : outAccum) b.resize(STEREO_SAMPLES, 0); - - // Final output buffer - std::vector outBuf(STEREO_SAMPLES, 0); - - printf("[Mixer] Audio mixer thread started\n"); - fflush(stdout); - - // Determine chunk duration for pacing. - // Default to 48000 Hz if no output is open yet. - uint32_t sampleRate = 48000; - auto* pOut = pc_platform_get_audio_output(0); - if (pOut && pOut->isOpen() && pOut->getSampleRate() > 0) - sampleRate = pOut->getSampleRate(); - const auto chunkDuration = std::chrono::microseconds( - (uint64_t)CHUNK * 1000000 / sampleRate); - - // Drain stale input data accumulated before mixer started - { - int16_t junk[512]; - for (int idx = 0; idx < 2; idx++) { - auto* in = pc_platform_get_audio_input(idx); - if (in) { - while (in->read(junk, 256) > 0) {} - } - } - } - - // Check for sample rate mismatches between inputs and outputs - auto* audioIn1 = pc_platform_get_audio_input(0); - auto* audioIn2 = pc_platform_get_audio_input(1); - if (audioIn1 && static_cast(audioIn1)->isOpen()) { - uint32_t inRate = audioIn1->getSampleRate(); - if (inRate != sampleRate) - printf("[Mixer] WARNING: IN1 rate %u Hz != output rate %u Hz\n", inRate, sampleRate); - } - if (audioIn2 && static_cast(audioIn2)->isOpen()) { - uint32_t inRate = audioIn2->getSampleRate(); - if (inRate != sampleRate) - printf("[Mixer] WARNING: IN2 rate %u Hz != output rate %u Hz\n", inRate, sampleRate); - } - - while (running_.load()) { - auto iterStart = std::chrono::steady_clock::now(); - - // ── 1. Read inputs ── - - // IN1 - auto* audioIn1 = pc_platform_get_audio_input(0); - if (audioIn1) { - auto* pcIn = static_cast(audioIn1); - uint32_t got = pcIn->read(inBuf[0].data(), CHUNK); - if (got < CHUNK) { - std::memset(inBuf[0].data() + got * 2, 0, - (CHUNK - got) * 2 * sizeof(int16_t)); - } - } else { - std::memset(inBuf[0].data(), 0, STEREO_SAMPLES * sizeof(int16_t)); - } - - // IN2 - auto* audioIn2 = pc_platform_get_audio_input(1); - if (audioIn2) { - auto* pcIn = static_cast(audioIn2); - uint32_t got = pcIn->read(inBuf[1].data(), CHUNK); - if (got < CHUNK) { - std::memset(inBuf[1].data() + got * 2, 0, - (CHUNK - got) * 2 * sizeof(int16_t)); - } - } else { - std::memset(inBuf[1].data(), 0, STEREO_SAMPLES * sizeof(int16_t)); - } - - // SYNTH - auto* synthEngine = pc_platform_get_synth_engine(); - if (synthEngine) { - auto* synth = static_cast(synthEngine); - synth->process(inBuf[2].data(), CHUNK); - } else { - std::memset(inBuf[2].data(), 0, STEREO_SAMPLES * sizeof(int16_t)); - } - - // ── 2. Compute per-channel peaks ── - for (int ch = 0; ch < MIXER_NUM_INPUTS; ch++) { - computePeak(inBuf[ch].data(), CHUNK, - channels_[ch].peakL, channels_[ch].peakR); - } - - // ── 3. Solo logic ── - bool anySoloed = isAnySoloed(); - - // ── 4. Clear output accumulators ── - for (auto& b : outAccum) { - std::memset(b.data(), 0, STEREO_SAMPLES * sizeof(int32_t)); - } - - // ── 5. Route and mix ── - for (int ch = 0; ch < MIXER_NUM_INPUTS; ch++) { - bool muted = channels_[ch].muted.load(std::memory_order_relaxed); - bool solo = channels_[ch].soloed.load(std::memory_order_relaxed); - - // Skip this channel if muted, or if solo mode is active and this isn't soloed - if (muted) continue; - if (anySoloed && !solo) continue; - - float chVol = channels_[ch].volume.load(std::memory_order_relaxed); - - for (int out = 0; out < MIXER_NUM_OUTPUTS; out++) { - if (!routes_[ch][out].enabled.load(std::memory_order_relaxed)) - continue; - - float routeVol = routes_[ch][out].volume.load(std::memory_order_relaxed); - float gain = chVol * routeVol; - - // Fixed-point gain: gain * 256 - int32_t gainFP = static_cast(gain * 256.0f); - - for (uint32_t s = 0; s < STEREO_SAMPLES; s++) { - outAccum[out][s] += (static_cast(inBuf[ch][s]) * gainFP) >> 8; - } - } - } - - // ── 6. Apply output volume, clamp, write ── - for (int out = 0; out < MIXER_NUM_OUTPUTS; out++) { - bool outMuted = outputs_[out].muted.load(std::memory_order_relaxed); - float outVol = outputs_[out].volume.load(std::memory_order_relaxed); - int32_t outGainFP = static_cast(outVol * 256.0f); - - int16_t maxL = 0, maxR = 0; - - for (uint32_t i = 0; i < CHUNK; i++) { - int32_t sL = outMuted ? 0 : (outAccum[out][i * 2] * outGainFP) >> 8; - int32_t sR = outMuted ? 0 : (outAccum[out][i * 2 + 1] * outGainFP) >> 8; - - // Clamp to int16 range - if (sL > 32767) sL = 32767; - if (sL < -32768) sL = -32768; - if (sR > 32767) sR = 32767; - if (sR < -32768) sR = -32768; - - outBuf[i * 2] = static_cast(sL); - outBuf[i * 2 + 1] = static_cast(sR); - - int16_t absL = sL < 0 ? (int16_t)-sL : (int16_t)sL; - int16_t absR = sR < 0 ? (int16_t)-sR : (int16_t)sR; - if (absL > maxL) maxL = absL; - if (absR > maxR) maxR = absR; - } - - outputs_[out].peakL.store(maxL, std::memory_order_relaxed); - outputs_[out].peakR.store(maxR, std::memory_order_relaxed); - - // Write to audio output — wait for ring space (paced by output callback) - auto* pcOut = pc_platform_get_audio_output(out); - if (pcOut && pcOut->isOpen()) { - uint32_t offset = 0; - uint32_t remaining = CHUNK; - int retries = 0; - while (remaining > 0 && running_.load()) { - uint32_t written = pcOut->write(outBuf.data() + offset * 2, remaining); - offset += written; - remaining -= written; - if (remaining > 0) { - // Output ring full — wait for callback to drain it. - // Use short sleep (1ms) — output callback runs every ~5ms. - std::this_thread::sleep_for(std::chrono::microseconds(500)); - if (++retries > 20) break; // safety: don't block forever - } - } - } - - // Write to tap buffer (for CI audio capture) - if (out == tapOutput_.load(std::memory_order_relaxed)) { - auto* tap = tapBuffer_.load(std::memory_order_relaxed); - if (tap) { - tap->write(outBuf.data(), STEREO_SAMPLES); - } - } - } - - // ── 7. Pace to real-time (fallback if no output provided backpressure) ── - auto elapsed = std::chrono::steady_clock::now() - iterStart; - auto minChunkTime = std::chrono::microseconds( - (uint64_t)CHUNK * 1000000 / sampleRate / 2); // half chunk time minimum - if (elapsed < minChunkTime) { - std::this_thread::sleep_for(minChunkTime - elapsed); - } - } - - printf("[Mixer] Audio mixer thread exited\n"); -} - -// ── Defaults ── - -void AudioMixerEngine::setDefaults() -{ - // Default: SYNTH -> OUT1 enabled at full volume (matches legacy behavior) - for (int i = 0; i < MIXER_NUM_INPUTS; i++) { - for (int o = 0; o < MIXER_NUM_OUTPUTS; o++) { - routes_[i][o].enabled.store(false, std::memory_order_relaxed); - routes_[i][o].volume.store(1.0f, std::memory_order_relaxed); - } - channels_[i].volume.store(1.0f, std::memory_order_relaxed); - channels_[i].muted.store(false, std::memory_order_relaxed); - channels_[i].soloed.store(false, std::memory_order_relaxed); - } - for (int o = 0; o < MIXER_NUM_OUTPUTS; o++) { - outputs_[o].volume.store(1.0f, std::memory_order_relaxed); - outputs_[o].muted.store(false, std::memory_order_relaxed); - } - - routes_[(int)MixerInput::SYNTH][(int)MixerOutput::OUT1].enabled.store(true, std::memory_order_relaxed); -} - -// ── State persistence ── - -void AudioMixerEngine::saveState(const std::string& path) const -{ - JsonDocument doc; - - // Route matrix - JsonArray routesArr = doc["routes"].to(); - for (int i = 0; i < MIXER_NUM_INPUTS; i++) { - for (int o = 0; o < MIXER_NUM_OUTPUTS; o++) { - JsonObject r = routesArr.add(); - r["in"] = i; - r["out"] = o; - r["enabled"] = routes_[i][o].enabled.load(std::memory_order_relaxed); - r["volume"] = routes_[i][o].volume.load(std::memory_order_relaxed); - } - } - - // Channel state - JsonArray chArr = doc["channels"].to(); - for (int i = 0; i < MIXER_NUM_INPUTS; i++) { - JsonObject ch = chArr.add(); - ch["volume"] = channels_[i].volume.load(std::memory_order_relaxed); - ch["muted"] = channels_[i].muted.load(std::memory_order_relaxed); - ch["soloed"] = channels_[i].soloed.load(std::memory_order_relaxed); - } - - // Output bus state - JsonArray outArr = doc["outputs"].to(); - for (int o = 0; o < MIXER_NUM_OUTPUTS; o++) { - JsonObject ob = outArr.add(); - ob["volume"] = outputs_[o].volume.load(std::memory_order_relaxed); - ob["muted"] = outputs_[o].muted.load(std::memory_order_relaxed); - } - - std::ofstream f(path); - if (!f.is_open()) { - printf("[Mixer] Failed to save state to %s\n", path.c_str()); - return; - } - serializeJsonPretty(doc, f); - printf("[Mixer] State saved to %s\n", path.c_str()); -} - -void AudioMixerEngine::loadState(const std::string& path) -{ - std::ifstream f(path); - if (!f.is_open()) { - printf("[Mixer] No saved state at %s, using defaults\n", path.c_str()); - setDefaults(); - return; - } - - JsonDocument doc; - DeserializationError err = deserializeJson(doc, f); - if (err) { - printf("[Mixer] State parse error: %s, using defaults\n", err.c_str()); - setDefaults(); - return; - } - - // Route matrix - JsonArray routesArr = doc["routes"]; - if (routesArr) { - for (JsonObject r : routesArr) { - int i = r["in"] | 0; - int o = r["out"] | 0; - if (i >= 0 && i < MIXER_NUM_INPUTS && o >= 0 && o < MIXER_NUM_OUTPUTS) { - routes_[i][o].enabled.store(r["enabled"] | false, std::memory_order_relaxed); - routes_[i][o].volume.store(r["volume"] | 1.0f, std::memory_order_relaxed); - } - } - } - - // Channel state - JsonArray chArr = doc["channels"]; - if (chArr) { - int idx = 0; - for (JsonObject ch : chArr) { - if (idx >= MIXER_NUM_INPUTS) break; - channels_[idx].volume.store(ch["volume"] | 1.0f, std::memory_order_relaxed); - channels_[idx].muted.store(ch["muted"] | false, std::memory_order_relaxed); - channels_[idx].soloed.store(ch["soloed"] | false, std::memory_order_relaxed); - idx++; - } - } - - // Output bus state - JsonArray outArr = doc["outputs"]; - if (outArr) { - int idx = 0; - for (JsonObject ob : outArr) { - if (idx >= MIXER_NUM_OUTPUTS) break; - outputs_[idx].volume.store(ob["volume"] | 1.0f, std::memory_order_relaxed); - outputs_[idx].muted.store(ob["muted"] | false, std::memory_order_relaxed); - idx++; - } - } - - printf("[Mixer] State loaded from %s\n", path.c_str()); -} diff --git a/src/apps/mixer/AudioMixerEngine.hpp b/src/apps/mixer/AudioMixerEngine.hpp deleted file mode 100644 index af415337b..000000000 --- a/src/apps/mixer/AudioMixerEngine.hpp +++ /dev/null @@ -1,116 +0,0 @@ -#pragma once - -/** - * @file AudioMixerEngine.hpp - * @brief Real-time audio mixing/routing engine for CrossPad PC. - * - * Routes 3 inputs (IN1, IN2, Synth) to 2 outputs (OUT1, OUT2) with per-route - * volume, per-channel mute/solo, and peak level metering. Runs on a dedicated - * thread — always active as the main audio pipeline. - */ - -#include -#include -#include -#include -#include - -static constexpr int MIXER_NUM_INPUTS = 3; // IN1, IN2, SYNTH -static constexpr int MIXER_NUM_OUTPUTS = 2; // OUT1, OUT2 -static constexpr uint32_t MIXER_CHUNK_FRAMES = 256; - -enum class MixerInput : uint8_t { - IN1 = 0, - IN2 = 1, - SYNTH = 2 -}; - -enum class MixerOutput : uint8_t { - OUT1 = 0, - OUT2 = 1 -}; - -struct MixerRoute { - std::atomic volume{1.0f}; - std::atomic enabled{false}; -}; - -struct MixerChannel { - std::atomic volume{1.0f}; - std::atomic muted{false}; - std::atomic soloed{false}; - std::atomic peakL{0}; - std::atomic peakR{0}; -}; - -struct MixerOutputBus { - std::atomic volume{1.0f}; - std::atomic muted{false}; - std::atomic peakL{0}; - std::atomic peakR{0}; -}; - -class AudioMixerEngine { -public: - AudioMixerEngine() = default; - ~AudioMixerEngine(); - - void start(); - void stop(); - bool isRunning() const { return running_.load(); } - - // Route matrix - void setRouteEnabled(MixerInput in, MixerOutput out, bool enabled); - bool isRouteEnabled(MixerInput in, MixerOutput out) const; - void setRouteVolume(MixerInput in, MixerOutput out, float vol); - float getRouteVolume(MixerInput in, MixerOutput out) const; - - // Channel control - void setChannelVolume(MixerInput in, float vol); - float getChannelVolume(MixerInput in) const; - void setChannelMute(MixerInput in, bool muted); - bool isChannelMuted(MixerInput in) const; - void setChannelSolo(MixerInput in, bool soloed); - bool isChannelSoloed(MixerInput in) const; - - // Output bus control - void setOutputVolume(MixerOutput out, float vol); - float getOutputVolume(MixerOutput out) const; - void setOutputMute(MixerOutput out, bool muted); - bool isOutputMuted(MixerOutput out) const; - - // Level metering - void getChannelLevel(MixerInput in, int16_t& left, int16_t& right) const; - void getOutputLevel(MixerOutput out, int16_t& left, int16_t& right) const; - - bool isAnySoloed() const; - - // State persistence - void saveState(const std::string& path) const; - void loadState(const std::string& path); - - // Set defaults (SYNTH->OUT1 enabled) without loading from file - void setDefaults(); - - // ── Tap buffer for audio capture (CI testing) ──────────────── - /// Set a ring buffer to receive a copy of OUT1 audio. - /// Pass nullptr to disable. Caller owns the buffer. - void setTapBuffer(crosspad::AudioRingBuffer* buf) { tapBuffer_.store(buf); } - - /// Which output to tap (default: OUT1) - void setTapOutput(MixerOutput out) { tapOutput_.store(static_cast(out)); } - -private: - MixerRoute routes_[MIXER_NUM_INPUTS][MIXER_NUM_OUTPUTS]; - MixerChannel channels_[MIXER_NUM_INPUTS]; - MixerOutputBus outputs_[MIXER_NUM_OUTPUTS]; - - std::atomic running_{false}; - std::atomic*> tapBuffer_{nullptr}; - std::atomic tapOutput_{0}; // MixerOutput::OUT1 - - void mixerThreadFunc(); -}; - -/// Global mixer engine accessor (initialized in crosspad_app.cpp) -AudioMixerEngine& getMixerEngine(); diff --git a/src/apps/mixer/CMakeLists.txt b/src/apps/mixer/CMakeLists.txt deleted file mode 100644 index 57f2fe8d9..000000000 --- a/src/apps/mixer/CMakeLists.txt +++ /dev/null @@ -1,7 +0,0 @@ -# Audio Mixer app sources -set(MIXER_APP_SOURCES - ${CMAKE_CURRENT_SOURCE_DIR}/AudioMixerEngine.cpp - ${CMAKE_CURRENT_SOURCE_DIR}/MixerPadLogic.cpp - ${CMAKE_CURRENT_SOURCE_DIR}/MixerApp.cpp - PARENT_SCOPE -) diff --git a/src/apps/mixer/MixerApp.cpp b/src/apps/mixer/MixerApp.cpp deleted file mode 100644 index f348fd447..000000000 --- a/src/apps/mixer/MixerApp.cpp +++ /dev/null @@ -1,599 +0,0 @@ -/** - * @file MixerApp.cpp - * @brief Audio Mixer/Router LVGL app — GUI + registration. - * - * The AudioMixerEngine and MixerPadLogic run globally (started at boot). - * This app is a pure view/controller — it connects to the global engine - * for display and control, and disconnects on close without stopping anything. - * - * GUI layout (320x240): - * - Title bar (22px) - * - VU meters section (70px): IN1, IN2, SYN | OUT1, OUT2 - * - Routing matrix (52px): 3x2 toggle grid - * - Channel strips (72px): volume slider + [M][S] per channel - * - Output masters (24px): OUT1 + OUT2 sliders - */ - -#include "MixerApp.hpp" -#include "AudioMixerEngine.hpp" -#include "MixerPadLogic.hpp" - -#include "pc_stubs/PcApp.hpp" -#include "pc_stubs/pc_platform.h" -#include "crosspad_app.hpp" - -#include -#include -#include "crosspad-gui/platform/IGuiPlatform.h" -#include "crosspad-gui/components/vu_meter.h" - -#include "lvgl.h" - -#include - -/* ── Static state ──────────────────────────────────────────────────────── */ - -static App* s_thisApp = nullptr; -static lv_timer_t* s_vuTimer = nullptr; - -// VU bar objects: 0=IN1, 1=IN2, 2=SYNTH, 3=OUT1, 4=OUT2 -static lv_obj_t* s_vuBarsL[5] = {}; -static lv_obj_t* s_vuBarsR[5] = {}; -static lv_obj_t* s_vuLabels[5] = {}; - -// Routing matrix buttons [input][output] -static lv_obj_t* s_routeButtons[3][2] = {}; - -// Channel mute/solo buttons -static lv_obj_t* s_muteButtons[3] = {}; -static lv_obj_t* s_soloButtons[3] = {}; - -// Channel volume sliders -static lv_obj_t* s_channelSliders[3] = {}; - -// Output controls -static lv_obj_t* s_outSliders[2] = {}; -static lv_obj_t* s_outMuteButtons[2] = {}; - -// VU decay values -static int16_t s_vuDecay[5][2] = {}; // [channel][L/R] - -/* ── Color constants ──────────────────────────────────────────────────── */ - -static const lv_color_t COL_BG = lv_color_hex(0x000000); -static const lv_color_t COL_SLIDER_BG = lv_color_hex(0x222244); -static const lv_color_t COL_SLIDER_IND= lv_color_hex(0x6644AA); -static const lv_color_t COL_KNOB = lv_color_hex(0x9966FF); -static const lv_color_t COL_MUTE_ON = lv_color_hex(0xCC2222); -static const lv_color_t COL_MUTE_OFF = lv_color_hex(0x333333); -static const lv_color_t COL_SOLO_ON = lv_color_hex(0xCCAA00); -static const lv_color_t COL_SOLO_OFF = lv_color_hex(0x333333); -static const lv_color_t COL_ROUTE_ON = lv_color_hex(0x0099AA); -static const lv_color_t COL_ROUTE_OFF = lv_color_hex(0x222222); -static const lv_color_t COL_LABEL = lv_color_hex(0x999999); -static const lv_color_t COL_VU_BG = lv_color_hex(0x111111); - -/* ── Forward declarations ─────────────────────────────────────────────── */ - -static void syncGuiFromEngine(); - -/* ── Helpers ───────────────────────────────────────────────────────────── */ - -static lv_color_t vuColor(int16_t level, int16_t max) -{ - if (max <= 0) return lv_color_hex(0x00AA00); - float pct = (float)level / (float)max; - if (pct > 0.85f) return lv_color_hex(0xFF2222); // red - if (pct > 0.60f) return lv_color_hex(0xDDAA00); // yellow - return lv_color_hex(0x00AA00); // green -} - -/* ── Callbacks ─────────────────────────────────────────────────────────── */ - -static void on_close(lv_event_t* e) -{ - (void)e; - if (s_thisApp) s_thisApp->destroyApp(); -} - -static void on_route_toggle(lv_event_t* e) -{ - auto& engine = getMixerEngine(); - // user_data encodes: input * 2 + output - intptr_t code = (intptr_t)lv_event_get_user_data(e); - int in = (int)(code / 2); - int out = (int)(code % 2); - auto mi = static_cast(in); - auto mo = static_cast(out); - - engine.setRouteEnabled(mi, mo, !engine.isRouteEnabled(mi, mo)); - syncGuiFromEngine(); - pc_platform_save_mixer_state(); -} - -static void on_channel_mute(lv_event_t* e) -{ - auto& engine = getMixerEngine(); - intptr_t ch = (intptr_t)lv_event_get_user_data(e); - auto mi = static_cast(ch); - engine.setChannelMute(mi, !engine.isChannelMuted(mi)); - syncGuiFromEngine(); - pc_platform_save_mixer_state(); -} - -static void on_channel_solo(lv_event_t* e) -{ - auto& engine = getMixerEngine(); - intptr_t ch = (intptr_t)lv_event_get_user_data(e); - auto mi = static_cast(ch); - engine.setChannelSolo(mi, !engine.isChannelSoloed(mi)); - syncGuiFromEngine(); - pc_platform_save_mixer_state(); -} - -static void on_channel_volume(lv_event_t* e) -{ - auto& engine = getMixerEngine(); - lv_obj_t* slider = (lv_obj_t*)lv_event_get_target(e); - intptr_t ch = (intptr_t)lv_event_get_user_data(e); - float vol = lv_slider_get_value(slider) / 100.0f; - engine.setChannelVolume(static_cast(ch), vol); - pc_platform_save_mixer_state(); -} - -static void on_output_volume(lv_event_t* e) -{ - auto& engine = getMixerEngine(); - lv_obj_t* slider = (lv_obj_t*)lv_event_get_target(e); - intptr_t out = (intptr_t)lv_event_get_user_data(e); - float vol = lv_slider_get_value(slider) / 100.0f; - engine.setOutputVolume(static_cast(out), vol); - pc_platform_save_mixer_state(); -} - -static void on_output_mute(lv_event_t* e) -{ - auto& engine = getMixerEngine(); - intptr_t out = (intptr_t)lv_event_get_user_data(e); - auto mo = static_cast(out); - engine.setOutputMute(mo, !engine.isOutputMuted(mo)); - syncGuiFromEngine(); - pc_platform_save_mixer_state(); -} - -/* ── Sync GUI state from engine ────────────────────────────────────────── */ - -static void syncGuiFromEngine() -{ - auto& engine = getMixerEngine(); - - // Route buttons - for (int in = 0; in < 3; in++) { - for (int out = 0; out < 2; out++) { - if (!s_routeButtons[in][out]) continue; - auto mi = static_cast(in); - auto mo = static_cast(out); - bool en = engine.isRouteEnabled(mi, mo); - lv_obj_set_style_bg_color(s_routeButtons[in][out], - en ? COL_ROUTE_ON : COL_ROUTE_OFF, 0); - } - } - - // Mute/Solo buttons - for (int i = 0; i < 3; i++) { - if (!s_muteButtons[i] || !s_soloButtons[i]) continue; - auto ch = static_cast(i); - lv_obj_set_style_bg_color(s_muteButtons[i], - engine.isChannelMuted(ch) ? COL_MUTE_ON : COL_MUTE_OFF, 0); - lv_obj_set_style_bg_color(s_soloButtons[i], - engine.isChannelSoloed(ch) ? COL_SOLO_ON : COL_SOLO_OFF, 0); - } - - // Output mute buttons - for (int i = 0; i < 2; i++) { - if (!s_outMuteButtons[i]) continue; - auto mo = static_cast(i); - lv_obj_set_style_bg_color(s_outMuteButtons[i], - engine.isOutputMuted(mo) ? COL_MUTE_ON : COL_MUTE_OFF, 0); - } -} - -/* ── VU meter timer ────────────────────────────────────────────────────── */ - -static void vu_timer_cb(lv_timer_t*) -{ - auto& engine = getMixerEngine(); - - // Sync GUI toggles (mute/solo/route buttons) from engine state - // so pad-driven changes are reflected immediately in the GUI - syncGuiFromEngine(); - - constexpr int DECAY = 230; // 230/256 ~ 0.90 - constexpr int16_t BAR_MAX = 100; - - // Input channels - for (int i = 0; i < 3; i++) { - if (!s_vuBarsL[i] || !s_vuBarsR[i]) continue; - int16_t rawL, rawR; - engine.getChannelLevel(static_cast(i), rawL, rawR); - - int16_t logL = (int16_t)int16toLogScale(rawL, BAR_MAX); - int16_t logR = (int16_t)int16toLogScale(rawR, BAR_MAX); - - // Apply decay - s_vuDecay[i][0] = static_cast((s_vuDecay[i][0] * DECAY) / 256); - s_vuDecay[i][1] = static_cast((s_vuDecay[i][1] * DECAY) / 256); - if (logL > s_vuDecay[i][0]) s_vuDecay[i][0] = logL; - if (logR > s_vuDecay[i][1]) s_vuDecay[i][1] = logR; - - lv_bar_set_value(s_vuBarsL[i], s_vuDecay[i][0], LV_ANIM_OFF); - lv_bar_set_value(s_vuBarsR[i], s_vuDecay[i][1], LV_ANIM_OFF); - - lv_obj_set_style_bg_color(s_vuBarsL[i], vuColor(s_vuDecay[i][0], BAR_MAX), LV_PART_INDICATOR); - lv_obj_set_style_bg_color(s_vuBarsR[i], vuColor(s_vuDecay[i][1], BAR_MAX), LV_PART_INDICATOR); - } - - // Output buses - for (int i = 0; i < 2; i++) { - int idx = 3 + i; - if (!s_vuBarsL[idx] || !s_vuBarsR[idx]) continue; - int16_t rawL, rawR; - engine.getOutputLevel(static_cast(i), rawL, rawR); - - int16_t logL = (int16_t)int16toLogScale(rawL, BAR_MAX); - int16_t logR = (int16_t)int16toLogScale(rawR, BAR_MAX); - - s_vuDecay[idx][0] = static_cast((s_vuDecay[idx][0] * DECAY) / 256); - s_vuDecay[idx][1] = static_cast((s_vuDecay[idx][1] * DECAY) / 256); - if (logL > s_vuDecay[idx][0]) s_vuDecay[idx][0] = logL; - if (logR > s_vuDecay[idx][1]) s_vuDecay[idx][1] = logR; - - lv_bar_set_value(s_vuBarsL[idx], s_vuDecay[idx][0], LV_ANIM_OFF); - lv_bar_set_value(s_vuBarsR[idx], s_vuDecay[idx][1], LV_ANIM_OFF); - - lv_obj_set_style_bg_color(s_vuBarsL[idx], vuColor(s_vuDecay[idx][0], BAR_MAX), LV_PART_INDICATOR); - lv_obj_set_style_bg_color(s_vuBarsR[idx], vuColor(s_vuDecay[idx][1], BAR_MAX), LV_PART_INDICATOR); - } -} - -/* ── GUI builder helpers ───────────────────────────────────────────────── */ - -static lv_obj_t* make_vu_pair(lv_obj_t* parent, int idx, const char* label, - int32_t x, int32_t barH) -{ - // Container for the VU pair + label - lv_obj_t* cont = lv_obj_create(parent); - lv_obj_set_size(cont, 38, barH + 14); - lv_obj_set_pos(cont, x, 0); - lv_obj_set_style_bg_opa(cont, LV_OPA_TRANSP, 0); - lv_obj_set_style_border_width(cont, 0, 0); - lv_obj_set_style_pad_all(cont, 0, 0); - lv_obj_remove_flag(cont, LV_OBJ_FLAG_SCROLLABLE); - - // Left bar - lv_obj_t* barL = lv_bar_create(cont); - lv_obj_set_size(barL, 8, barH); - lv_obj_set_pos(barL, 8, 0); - lv_bar_set_range(barL, 0, 100); - lv_bar_set_value(barL, 0, LV_ANIM_OFF); - lv_obj_set_style_bg_color(barL, COL_VU_BG, 0); - lv_obj_set_style_bg_color(barL, lv_color_hex(0x00AA00), LV_PART_INDICATOR); - lv_obj_set_style_radius(barL, 1, 0); - lv_obj_set_style_radius(barL, 1, LV_PART_INDICATOR); - s_vuBarsL[idx] = barL; - - // Right bar - lv_obj_t* barR = lv_bar_create(cont); - lv_obj_set_size(barR, 8, barH); - lv_obj_set_pos(barR, 20, 0); - lv_bar_set_range(barR, 0, 100); - lv_bar_set_value(barR, 0, LV_ANIM_OFF); - lv_obj_set_style_bg_color(barR, COL_VU_BG, 0); - lv_obj_set_style_bg_color(barR, lv_color_hex(0x00AA00), LV_PART_INDICATOR); - lv_obj_set_style_radius(barR, 1, 0); - lv_obj_set_style_radius(barR, 1, LV_PART_INDICATOR); - s_vuBarsR[idx] = barR; - - // Label - lv_obj_t* lbl = lv_label_create(cont); - lv_label_set_text(lbl, label); - lv_obj_set_style_text_color(lbl, COL_LABEL, 0); - lv_obj_set_style_text_font(lbl, &lv_font_montserrat_10, 0); - lv_obj_align(lbl, LV_ALIGN_BOTTOM_MID, 0, 0); - s_vuLabels[idx] = lbl; - - return cont; -} - -static lv_obj_t* make_small_button(lv_obj_t* parent, const char* text, - lv_color_t bg, lv_event_cb_t cb, - void* userData) -{ - lv_obj_t* btn = lv_button_create(parent); - lv_obj_set_size(btn, 22, 18); - lv_obj_set_style_bg_color(btn, bg, 0); - lv_obj_set_style_radius(btn, 3, 0); - lv_obj_set_style_shadow_width(btn, 0, 0); - lv_obj_set_style_pad_all(btn, 0, 0); - - lv_obj_t* lbl = lv_label_create(btn); - lv_label_set_text(lbl, text); - lv_obj_set_style_text_font(lbl, &lv_font_montserrat_10, 0); - lv_obj_set_style_text_color(lbl, lv_color_white(), 0); - lv_obj_center(lbl); - - lv_obj_add_event_cb(btn, cb, LV_EVENT_CLICKED, userData); - return btn; -} - -/* ── App create / destroy ──────────────────────────────────────────────── */ - -lv_obj_t* Mixer_create(lv_obj_t* parent, App* a) -{ - s_thisApp = a; - auto& engine = getMixerEngine(); - - // Reset VU decay - for (auto& d : s_vuDecay) { d[0] = 0; d[1] = 0; } - - // ── Root container ── - lv_obj_t* cont = lv_obj_create(parent); - lv_obj_set_size(cont, lv_pct(100), lv_pct(100)); - lv_obj_set_style_bg_color(cont, COL_BG, 0); - lv_obj_set_style_bg_opa(cont, LV_OPA_COVER, 0); - lv_obj_set_style_pad_all(cont, 2, 0); - lv_obj_set_flex_flow(cont, LV_FLEX_FLOW_COLUMN); - lv_obj_set_style_pad_row(cont, 1, 0); - lv_obj_remove_flag(cont, LV_OBJ_FLAG_SCROLLABLE); - - /* ── Title bar (22px) ──────────────────────────────────────── */ - lv_obj_t* titleBar = lv_obj_create(cont); - lv_obj_set_size(titleBar, lv_pct(100), 22); - lv_obj_set_style_bg_opa(titleBar, LV_OPA_TRANSP, 0); - lv_obj_set_style_border_width(titleBar, 0, 0); - lv_obj_set_style_pad_all(titleBar, 0, 0); - lv_obj_remove_flag(titleBar, LV_OBJ_FLAG_SCROLLABLE); - - lv_obj_t* titleLabel = lv_label_create(titleBar); - lv_label_set_text(titleLabel, "Mixer"); - lv_obj_set_style_text_color(titleLabel, lv_color_white(), 0); - lv_obj_set_style_text_font(titleLabel, &lv_font_montserrat_14, 0); - lv_obj_align(titleLabel, LV_ALIGN_LEFT_MID, 4, 0); - - lv_obj_t* closeBtn = lv_button_create(titleBar); - lv_obj_set_size(closeBtn, 28, 18); - lv_obj_align(closeBtn, LV_ALIGN_RIGHT_MID, -2, 0); - lv_obj_set_style_bg_color(closeBtn, lv_color_hex(0x662222), 0); - lv_obj_set_style_bg_color(closeBtn, lv_color_hex(0xAA3333), LV_STATE_PRESSED); - lv_obj_set_style_radius(closeBtn, 4, 0); - lv_obj_set_style_shadow_width(closeBtn, 0, 0); - lv_obj_t* closeLbl = lv_label_create(closeBtn); - lv_label_set_text(closeLbl, "X"); - lv_obj_set_style_text_font(closeLbl, &lv_font_montserrat_12, 0); - lv_obj_center(closeLbl); - lv_obj_add_event_cb(closeBtn, on_close, LV_EVENT_CLICKED, nullptr); - - /* ── VU meters section (70px) ──────────────────────────────── */ - lv_obj_t* vuSection = lv_obj_create(cont); - lv_obj_set_size(vuSection, lv_pct(100), 70); - lv_obj_set_style_bg_opa(vuSection, LV_OPA_TRANSP, 0); - lv_obj_set_style_border_width(vuSection, 0, 0); - lv_obj_set_style_pad_all(vuSection, 0, 0); - lv_obj_remove_flag(vuSection, LV_OBJ_FLAG_SCROLLABLE); - - static const char* vuNames[] = {"IN1", "IN2", "SYN", "OUT1", "OUT2"}; - int vuX[] = {4, 48, 92, 172, 224}; - for (int i = 0; i < 5; i++) { - make_vu_pair(vuSection, i, vuNames[i], vuX[i], 52); - } - - // Separator between inputs and outputs - lv_obj_t* sep = lv_obj_create(vuSection); - lv_obj_set_size(sep, 1, 50); - lv_obj_set_pos(sep, 145, 4); - lv_obj_set_style_bg_color(sep, lv_color_hex(0x444444), 0); - lv_obj_set_style_bg_opa(sep, LV_OPA_COVER, 0); - lv_obj_set_style_border_width(sep, 0, 0); - lv_obj_remove_flag(sep, LV_OBJ_FLAG_SCROLLABLE); - - /* ── Routing matrix (52px) ─────────────────────────────────── */ - lv_obj_t* routeSection = lv_obj_create(cont); - lv_obj_set_size(routeSection, lv_pct(100), 52); - lv_obj_set_style_bg_opa(routeSection, LV_OPA_TRANSP, 0); - lv_obj_set_style_border_width(routeSection, 0, 0); - lv_obj_set_style_pad_all(routeSection, 0, 0); - lv_obj_remove_flag(routeSection, LV_OBJ_FLAG_SCROLLABLE); - - // Column headers - lv_obj_t* routeHdrLabel = lv_label_create(routeSection); - lv_label_set_text(routeHdrLabel, "Routing"); - lv_obj_set_style_text_color(routeHdrLabel, lv_color_hex(0x78C8FF), 0); - lv_obj_set_style_text_font(routeHdrLabel, &lv_font_montserrat_10, 0); - lv_obj_set_pos(routeHdrLabel, 4, 0); - - static const char* colHeaders[] = {"OUT1", "OUT2"}; - for (int out = 0; out < 2; out++) { - lv_obj_t* lbl = lv_label_create(routeSection); - lv_label_set_text(lbl, colHeaders[out]); - lv_obj_set_style_text_color(lbl, COL_LABEL, 0); - lv_obj_set_style_text_font(lbl, &lv_font_montserrat_10, 0); - lv_obj_set_pos(lbl, 70 + out * 120, 0); - } - - // Row labels + toggle buttons - static const char* rowLabels[] = {"IN1", "IN2", "SYN"}; - for (int in = 0; in < 3; in++) { - int y = 13 + in * 13; - - lv_obj_t* lbl = lv_label_create(routeSection); - lv_label_set_text(lbl, rowLabels[in]); - lv_obj_set_style_text_color(lbl, COL_LABEL, 0); - lv_obj_set_style_text_font(lbl, &lv_font_montserrat_10, 0); - lv_obj_set_pos(lbl, 4, y + 2); - - for (int out = 0; out < 2; out++) { - intptr_t code = in * 2 + out; - lv_obj_t* btn = lv_button_create(routeSection); - lv_obj_set_size(btn, 80, 12); - lv_obj_set_pos(btn, 50 + out * 120, y); - lv_obj_set_style_bg_color(btn, COL_ROUTE_OFF, 0); - lv_obj_set_style_radius(btn, 2, 0); - lv_obj_set_style_shadow_width(btn, 0, 0); - lv_obj_set_style_pad_all(btn, 0, 0); - - lv_obj_t* btnLbl = lv_label_create(btn); - lv_label_set_text(btnLbl, LV_SYMBOL_RIGHT); - lv_obj_set_style_text_font(btnLbl, &lv_font_montserrat_10, 0); - lv_obj_set_style_text_color(btnLbl, lv_color_white(), 0); - lv_obj_center(btnLbl); - - lv_obj_add_event_cb(btn, on_route_toggle, LV_EVENT_CLICKED, - (void*)code); - s_routeButtons[in][out] = btn; - } - } - - /* ── Channel strips (72px = 24px x 3) ──────────────────────── */ - static const char* chNames[] = {"IN1", "IN2", "SYN"}; - for (int ch = 0; ch < 3; ch++) { - lv_obj_t* row = lv_obj_create(cont); - lv_obj_set_size(row, lv_pct(100), 22); - lv_obj_set_style_bg_opa(row, LV_OPA_TRANSP, 0); - lv_obj_set_style_border_width(row, 0, 0); - lv_obj_set_style_pad_all(row, 0, 0); - lv_obj_remove_flag(row, LV_OBJ_FLAG_SCROLLABLE); - - // Label - lv_obj_t* lbl = lv_label_create(row); - lv_label_set_text(lbl, chNames[ch]); - lv_obj_set_style_text_color(lbl, COL_LABEL, 0); - lv_obj_set_style_text_font(lbl, &lv_font_montserrat_10, 0); - lv_obj_align(lbl, LV_ALIGN_LEFT_MID, 2, 0); - lv_obj_set_width(lbl, 30); - - // Volume slider — initialized from engine state - lv_obj_t* slider = lv_slider_create(row); - lv_obj_set_size(slider, 200, 10); - lv_obj_align(slider, LV_ALIGN_LEFT_MID, 34, 0); - lv_slider_set_range(slider, 0, 100); - int curVol = (int)(engine.getChannelVolume(static_cast(ch)) * 100.0f); - lv_slider_set_value(slider, curVol, LV_ANIM_OFF); - lv_obj_set_style_bg_color(slider, COL_SLIDER_BG, 0); - lv_obj_set_style_bg_color(slider, COL_SLIDER_IND, LV_PART_INDICATOR); - lv_obj_set_style_bg_color(slider, COL_KNOB, LV_PART_KNOB); - lv_obj_set_style_pad_all(slider, 2, LV_PART_KNOB); - lv_obj_add_event_cb(slider, on_channel_volume, LV_EVENT_VALUE_CHANGED, - (void*)(intptr_t)ch); - s_channelSliders[ch] = slider; - - // Mute button - s_muteButtons[ch] = make_small_button(row, "M", COL_MUTE_OFF, - on_channel_mute, - (void*)(intptr_t)ch); - lv_obj_align(s_muteButtons[ch], LV_ALIGN_RIGHT_MID, -26, 0); - - // Solo button - s_soloButtons[ch] = make_small_button(row, "S", COL_SOLO_OFF, - on_channel_solo, - (void*)(intptr_t)ch); - lv_obj_align(s_soloButtons[ch], LV_ALIGN_RIGHT_MID, -2, 0); - } - - /* ── Output masters (24px) ─────────────────────────────────── */ - lv_obj_t* outRow = lv_obj_create(cont); - lv_obj_set_size(outRow, lv_pct(100), 22); - lv_obj_set_style_bg_opa(outRow, LV_OPA_TRANSP, 0); - lv_obj_set_style_border_width(outRow, 0, 0); - lv_obj_set_style_pad_all(outRow, 0, 0); - lv_obj_remove_flag(outRow, LV_OBJ_FLAG_SCROLLABLE); - - static const char* outNames[] = {"O1", "O2"}; - for (int out = 0; out < 2; out++) { - int xBase = out * 158; - - lv_obj_t* lbl = lv_label_create(outRow); - lv_label_set_text(lbl, outNames[out]); - lv_obj_set_style_text_color(lbl, COL_LABEL, 0); - lv_obj_set_style_text_font(lbl, &lv_font_montserrat_10, 0); - lv_obj_set_pos(lbl, xBase + 2, 6); - - lv_obj_t* slider = lv_slider_create(outRow); - lv_obj_set_size(slider, 100, 10); - lv_obj_set_pos(slider, xBase + 22, 6); - lv_slider_set_range(slider, 0, 100); - int curVol = (int)(engine.getOutputVolume(static_cast(out)) * 100.0f); - lv_slider_set_value(slider, curVol, LV_ANIM_OFF); - lv_obj_set_style_bg_color(slider, COL_SLIDER_BG, 0); - lv_obj_set_style_bg_color(slider, COL_SLIDER_IND, LV_PART_INDICATOR); - lv_obj_set_style_bg_color(slider, COL_KNOB, LV_PART_KNOB); - lv_obj_set_style_pad_all(slider, 2, LV_PART_KNOB); - lv_obj_add_event_cb(slider, on_output_volume, LV_EVENT_VALUE_CHANGED, - (void*)(intptr_t)out); - s_outSliders[out] = slider; - - s_outMuteButtons[out] = make_small_button(outRow, "M", COL_MUTE_OFF, - on_output_mute, - (void*)(intptr_t)out); - lv_obj_set_pos(s_outMuteButtons[out], xBase + 128, 2); - } - - /* ── Lifecycle callbacks ─────────────────────────────────────── */ - if (a) { - a->setOnShow([](lv_obj_t*) { - crosspad::getPadManager().setActivePadLogic("Mixer"); - crosspad_app_update_pad_icon(); - }); - a->setOnHide([](lv_obj_t*) { - crosspad::getPadManager().setActivePadLogic(""); - crosspad_app_update_pad_icon(); - }); - } - - /* ── VU meter timer (60 Hz) ────────────────────────────────── */ - s_vuTimer = lv_timer_create(vu_timer_cb, 16, nullptr); - - /* ── Sync initial GUI state from engine ────────────────────── */ - syncGuiFromEngine(); - - printf("[Mixer] App GUI opened\n"); - return cont; -} - -void Mixer_destroy(lv_obj_t* app_obj) -{ - // Stop VU timer - if (s_vuTimer) { - lv_timer_delete(s_vuTimer); - s_vuTimer = nullptr; - } - - // Clear GUI pointers (engine and pad logic keep running) - s_thisApp = nullptr; - for (auto& p : s_vuBarsL) p = nullptr; - for (auto& p : s_vuBarsR) p = nullptr; - for (auto& p : s_vuLabels) p = nullptr; - for (auto& row : s_routeButtons) for (auto& p : row) p = nullptr; - for (auto& p : s_muteButtons) p = nullptr; - for (auto& p : s_soloButtons) p = nullptr; - for (auto& p : s_channelSliders) p = nullptr; - for (auto& p : s_outSliders) p = nullptr; - for (auto& p : s_outMuteButtons) p = nullptr; - - lv_obj_delete_async(app_obj); - printf("[Mixer] App GUI closed (engine keeps running)\n"); -} - -/* ── App registration ──────────────────────────────────────────────────── */ - -void _register_Mixer_app() -{ - static char icon_path[256]; - snprintf(icon_path, sizeof(icon_path), "%smixer.png", - crosspad_gui::getGuiPlatform().assetPathPrefix()); - - static const crosspad::AppEntry entry = { - "Mixer", icon_path, Mixer_create, Mixer_destroy, - nullptr, nullptr, nullptr, nullptr, 0 - }; - crosspad::AppRegistry::getInstance().registerApp(entry); -} diff --git a/src/apps/mixer/MixerApp.hpp b/src/apps/mixer/MixerApp.hpp deleted file mode 100644 index 8a7e1df60..000000000 --- a/src/apps/mixer/MixerApp.hpp +++ /dev/null @@ -1,17 +0,0 @@ -#pragma once - -/** - * @file MixerApp.hpp - * @brief Audio Mixer/Router app for CrossPad PC. - * - * Full routing matrix (3 inputs x 2 outputs) with per-channel volume, - * mute/solo, VU meters, and 4x4 pad control. - */ - -class App; - -struct _lv_obj_t; -typedef struct _lv_obj_t lv_obj_t; - -lv_obj_t* Mixer_create(lv_obj_t* parent, App* app); -void Mixer_destroy(lv_obj_t* app_obj); diff --git a/src/apps/mixer/MixerPadLogic.cpp b/src/apps/mixer/MixerPadLogic.cpp deleted file mode 100644 index c33712ff6..000000000 --- a/src/apps/mixer/MixerPadLogic.cpp +++ /dev/null @@ -1,137 +0,0 @@ -/** - * @file MixerPadLogic.cpp - * @brief Pad handler for mixer mute/solo/route toggles. - */ - -#include "MixerPadLogic.hpp" -#include "AudioMixerEngine.hpp" - -#include -#include - -MixerPadLogic::MixerPadLogic(AudioMixerEngine& engine) - : engine_(engine) {} - -void MixerPadLogic::onActivate(crosspad::PadManager& padManager) -{ - printf("[MixerPad] Activated\n"); - updatePadColors(padManager); -} - -void MixerPadLogic::onDeactivate(crosspad::PadManager& padManager) -{ - printf("[MixerPad] Deactivated\n"); - for (uint8_t i = 0; i < 16; i++) { - padManager.setPadColor(i, 0, 0, 0); - } -} - -void MixerPadLogic::onPadPress(crosspad::PadManager& padManager, - uint8_t padIdx, uint8_t /*velocity*/) -{ - if (padIdx >= 16) return; - - switch (padIdx) { - // Row 0: Channel mute (IN1=0, IN2=1, SYN=2) - case 0: case 1: case 2: { - auto ch = static_cast(padIdx); - engine_.setChannelMute(ch, !engine_.isChannelMuted(ch)); - break; - } - - // Row 1: Channel solo (IN1=4, IN2=5, SYN=6) - case 4: case 5: case 6: { - auto ch = static_cast(padIdx - 4); - engine_.setChannelSolo(ch, !engine_.isChannelSoloed(ch)); - break; - } - - // Row 2: Route to OUT1 (IN1=8, IN2=9, SYN=10) - case 8: case 9: case 10: { - auto ch = static_cast(padIdx - 8); - engine_.setRouteEnabled(ch, MixerOutput::OUT1, - !engine_.isRouteEnabled(ch, MixerOutput::OUT1)); - break; - } - // Pad 11: OUT1 mute - case 11: - engine_.setOutputMute(MixerOutput::OUT1, - !engine_.isOutputMuted(MixerOutput::OUT1)); - break; - - // Row 3: Route to OUT2 (IN1=12, IN2=13, SYN=14) - case 12: case 13: case 14: { - auto ch = static_cast(padIdx - 12); - engine_.setRouteEnabled(ch, MixerOutput::OUT2, - !engine_.isRouteEnabled(ch, MixerOutput::OUT2)); - break; - } - // Pad 15: OUT2 mute - case 15: - engine_.setOutputMute(MixerOutput::OUT2, - !engine_.isOutputMuted(MixerOutput::OUT2)); - break; - - default: - break; - } - - updatePadColors(padManager); - - if (stateChangedCb_) stateChangedCb_(); -} - -void MixerPadLogic::onPadRelease(crosspad::PadManager& /*padManager*/, - uint8_t /*padIdx*/) {} - -void MixerPadLogic::onPadPressure(crosspad::PadManager& /*padManager*/, - uint8_t /*padIdx*/, uint8_t /*pressure*/) {} - -void MixerPadLogic::updatePadColors(crosspad::PadManager& padManager) -{ - // Row 0: Mute status (green = active, red = muted) - for (int i = 0; i < 3; i++) { - auto ch = static_cast(i); - if (engine_.isChannelMuted(ch)) - padManager.setPadColor(i, 80, 0, 0); // red - else - padManager.setPadColor(i, 0, 80, 0); // green - } - padManager.setPadColor(3, 0, 0, 0); // unused - - // Row 1: Solo status (yellow = soloed, dim = off) - for (int i = 0; i < 3; i++) { - auto ch = static_cast(i); - if (engine_.isChannelSoloed(ch)) - padManager.setPadColor(4 + i, 80, 80, 0); // yellow - else - padManager.setPadColor(4 + i, 15, 15, 15); // dim - } - padManager.setPadColor(7, 0, 0, 0); // unused - - // Row 2: Route to OUT1 (cyan = enabled, dark = disabled) + OUT1 mute - for (int i = 0; i < 3; i++) { - auto ch = static_cast(i); - if (engine_.isRouteEnabled(ch, MixerOutput::OUT1)) - padManager.setPadColor(8 + i, 0, 60, 80); // cyan - else - padManager.setPadColor(8 + i, 15, 15, 15); // dim - } - if (engine_.isOutputMuted(MixerOutput::OUT1)) - padManager.setPadColor(11, 80, 0, 0); // red - else - padManager.setPadColor(11, 0, 80, 0); // green - - // Row 3: Route to OUT2 + OUT2 mute - for (int i = 0; i < 3; i++) { - auto ch = static_cast(i); - if (engine_.isRouteEnabled(ch, MixerOutput::OUT2)) - padManager.setPadColor(12 + i, 0, 60, 80); - else - padManager.setPadColor(12 + i, 15, 15, 15); - } - if (engine_.isOutputMuted(MixerOutput::OUT2)) - padManager.setPadColor(15, 80, 0, 0); - else - padManager.setPadColor(15, 0, 80, 0); -} diff --git a/src/apps/mixer/MixerPadLogic.hpp b/src/apps/mixer/MixerPadLogic.hpp deleted file mode 100644 index 4745369a2..000000000 --- a/src/apps/mixer/MixerPadLogic.hpp +++ /dev/null @@ -1,36 +0,0 @@ -#pragma once - -/** - * @file MixerPadLogic.hpp - * @brief Pad handler for mute/solo/route toggles in the Mixer app. - * - * Pad layout (4x4 grid): - * Row 3: 12=IN1->OUT2 13=IN2->OUT2 14=SYN->OUT2 15=OUT2 Mute - * Row 2: 8=IN1->OUT1 9=IN2->OUT1 10=SYN->OUT1 11=OUT1 Mute - * Row 1: 4=IN1 Solo 5=IN2 Solo 6=SYN Solo 7=(unused) - * Row 0: 0=IN1 Mute 1=IN2 Mute 2=SYN Mute 3=(unused) - */ - -#include - -class AudioMixerEngine; - -class MixerPadLogic : public crosspad::IPadLogicHandler { -public: - explicit MixerPadLogic(AudioMixerEngine& engine); - - void onActivate(crosspad::PadManager& padManager) override; - void onDeactivate(crosspad::PadManager& padManager) override; - void onPadPress(crosspad::PadManager& padManager, uint8_t padIdx, uint8_t velocity) override; - void onPadRelease(crosspad::PadManager& padManager, uint8_t padIdx) override; - void onPadPressure(crosspad::PadManager& padManager, uint8_t padIdx, uint8_t pressure) override; - - void updatePadColors(crosspad::PadManager& padManager); - - using StateChangedCb = void(*)(); - void setOnStateChanged(StateChangedCb cb) { stateChangedCb_ = cb; } - -private: - AudioMixerEngine& engine_; - StateChangedCb stateChangedCb_ = nullptr; -}; diff --git a/src/apps/ml_piano/CMakeLists.txt b/src/apps/ml_piano/CMakeLists.txt deleted file mode 100644 index 544876636..000000000 --- a/src/apps/ml_piano/CMakeLists.txt +++ /dev/null @@ -1,6 +0,0 @@ -# ML Piano app sources — pad logic + GUI -set(ML_PIANO_APP_SOURCES - ${CMAKE_CURRENT_SOURCE_DIR}/MlPianoApp.cpp - ${CMAKE_CURRENT_SOURCE_DIR}/PianoPadLogic.cpp - PARENT_SCOPE -) diff --git a/src/apps/ml_piano/MlPianoApp.cpp b/src/apps/ml_piano/MlPianoApp.cpp deleted file mode 100644 index e059b1c27..000000000 --- a/src/apps/ml_piano/MlPianoApp.cpp +++ /dev/null @@ -1,388 +0,0 @@ -/** - * @file MlPianoApp.cpp - * @brief ML Piano app — LVGL GUI with synth parameter sliders - * - * GUI layout (320x240): - * - Title bar with octave info + close (X) button - * - Preset dropdown - * - Synth parameter sliders (Attack, Decay, Sustain, Release, Feedback) - * - Octave +/- buttons at bottom - */ - -#include "MlPianoApp.hpp" -#include "PianoPadLogic.hpp" -#include "synth/MlPianoSynth.hpp" -#include "pc_stubs/PcApp.hpp" -#include "pc_stubs/pc_platform.h" - -#include -#include -#include "crosspad-gui/components/app_lifecycle.h" -#include "crosspad-gui/platform/IGuiPlatform.h" -#include "crosspad_app.hpp" - -#include "lvgl.h" - -#include -#include -#include - -/* ── Preset names (matching FmSynth_Init channel presets) ────────────── */ - -static const char* const PRESET_NAMES[] = { - "E-Piano", // ch 0 - "E-Piano Slow", // ch 1 - "Guitar", // ch 2 - "Hard Voice", // ch 3 - "Bassy Base", // ch 4 - "Organ", // ch 5 - "Some Bass", // ch 6 - "Schnatter", // ch 7 - "Harpsichord", // ch 8 - "Harsh", // ch 9 - "String Bass", // ch 10 - "Kick Bass", // ch 11 - "Wood Bass", // ch 12 - "Saw Bass", // ch 13 - "Plug Sound", // ch 14 - "Fady Stuff", // ch 15 -}; - -/* ── Static state ────────────────────────────────────────────────────── */ - -static App* thisApp = nullptr; -static lv_obj_t* s_octaveLabel = nullptr; -static lv_obj_t* s_presetDropdown = nullptr; -static PianoPadLogic* s_padLogic = nullptr; -static std::shared_ptr s_padLogicShared; - -/* ── Note name helper ────────────────────────────────────────────────── */ - -static const char* noteNameForMidi(uint8_t note) -{ - static const char* names[] = {"C","C#","D","D#","E","F","F#","G","G#","A","A#","B"}; - return names[note % 12]; -} - -static int octaveForMidi(uint8_t note) -{ - return (note / 12) - 1; -} - -static void updateOctaveLabel() -{ - if (!s_octaveLabel || !s_padLogic) return; - uint8_t base = s_padLogic->getBaseNote(); - lv_label_set_text_fmt(s_octaveLabel, "%s%d - %s%d", - noteNameForMidi(base), octaveForMidi(base), - noteNameForMidi(base + 15), octaveForMidi(base + 15)); -} - -/* ── Slider helper ───────────────────────────────────────────────────── */ - -static lv_obj_t* make_slider_row(lv_obj_t* parent, const char* name, - int32_t min, int32_t max, int32_t init, - lv_event_cb_t cb) -{ - lv_obj_t* row = lv_obj_create(parent); - lv_obj_set_size(row, lv_pct(100), 28); - lv_obj_set_style_bg_opa(row, LV_OPA_TRANSP, 0); - lv_obj_set_style_border_width(row, 0, 0); - lv_obj_set_style_pad_all(row, 0, 0); - lv_obj_remove_flag(row, LV_OBJ_FLAG_SCROLLABLE); - - lv_obj_t* lbl = lv_label_create(row); - lv_label_set_text(lbl, name); - lv_obj_set_style_text_color(lbl, lv_color_hex(0x999999), 0); - lv_obj_set_style_text_font(lbl, &lv_font_montserrat_10, 0); - lv_obj_align(lbl, LV_ALIGN_LEFT_MID, 2, 0); - lv_obj_set_width(lbl, 52); - - lv_obj_t* slider = lv_slider_create(row); - lv_obj_set_size(slider, 200, 12); - lv_obj_align(slider, LV_ALIGN_LEFT_MID, 56, 0); - lv_slider_set_range(slider, min, max); - lv_slider_set_value(slider, init, LV_ANIM_OFF); - lv_obj_set_style_bg_color(slider, lv_color_hex(0x222244), 0); - lv_obj_set_style_bg_color(slider, lv_color_hex(0x6644AA), LV_PART_INDICATOR); - lv_obj_set_style_bg_color(slider, lv_color_hex(0x9966FF), LV_PART_KNOB); - lv_obj_set_style_pad_all(slider, 3, LV_PART_KNOB); - lv_obj_add_event_cb(slider, cb, LV_EVENT_VALUE_CHANGED, nullptr); - - // Value label - lv_obj_t* valLbl = lv_label_create(row); - lv_label_set_text_fmt(valLbl, "%d", init); - lv_obj_set_style_text_color(valLbl, lv_color_hex(0xCCCCCC), 0); - lv_obj_set_style_text_font(valLbl, &lv_font_montserrat_10, 0); - lv_obj_align(valLbl, LV_ALIGN_RIGHT_MID, -2, 0); - // Store value label pointer in slider user data - lv_obj_set_user_data(slider, valLbl); - - return slider; -} - -/* ── Callbacks ───────────────────────────────────────────────────────── */ - -static void on_close(lv_event_t* e) -{ - (void)e; - if (thisApp) thisApp->destroyApp(); -} - -static void on_octave_up(lv_event_t* e) -{ - (void)e; - if (!s_padLogic) return; - s_padLogic->octaveUp(); - s_padLogic->colorPads(crosspad::getPadManager()); - updateOctaveLabel(); -} - -static void on_octave_down(lv_event_t* e) -{ - (void)e; - if (!s_padLogic) return; - s_padLogic->octaveDown(); - s_padLogic->colorPads(crosspad::getPadManager()); - updateOctaveLabel(); -} - -static void on_preset_changed(lv_event_t* e) -{ - lv_obj_t* dropdown = (lv_obj_t*)lv_event_get_target(e); - uint32_t sel = lv_dropdown_get_selected(dropdown); - auto* synth = static_cast(pc_platform_get_synth_engine()); - if (synth && sel < 16) { - synth->setMidiChannel(static_cast(sel)); - printf("[MlPiano] Preset: %s (ch %u)\n", PRESET_NAMES[sel], sel); - } -} - -// Helper to update the value label next to a slider -static void update_slider_val_label(lv_obj_t* slider) -{ - lv_obj_t* valLbl = (lv_obj_t*)lv_obj_get_user_data(slider); - if (valLbl) { - lv_label_set_text_fmt(valLbl, "%d", (int)lv_slider_get_value(slider)); - } -} - -static void on_attack(lv_event_t* e) -{ - lv_obj_t* slider = (lv_obj_t*)lv_event_get_target(e); - float val = lv_slider_get_value(slider) / 100.0f; - auto* synth = static_cast(pc_platform_get_synth_engine()); - if (synth) synth->setAttack(val); - update_slider_val_label(slider); -} - -static void on_decay(lv_event_t* e) -{ - lv_obj_t* slider = (lv_obj_t*)lv_event_get_target(e); - float val = lv_slider_get_value(slider) / 100.0f; - auto* synth = static_cast(pc_platform_get_synth_engine()); - if (synth) synth->setDecay(val); - update_slider_val_label(slider); -} - -static void on_sustain(lv_event_t* e) -{ - lv_obj_t* slider = (lv_obj_t*)lv_event_get_target(e); - uint8_t val = static_cast(lv_slider_get_value(slider)); - auto* synth = static_cast(pc_platform_get_synth_engine()); - if (synth) synth->setSustain(val); - update_slider_val_label(slider); -} - -static void on_release(lv_event_t* e) -{ - lv_obj_t* slider = (lv_obj_t*)lv_event_get_target(e); - float val = lv_slider_get_value(slider) / 100.0f; - auto* synth = static_cast(pc_platform_get_synth_engine()); - if (synth) synth->setRelease(val); - update_slider_val_label(slider); -} - -static void on_feedback(lv_event_t* e) -{ - lv_obj_t* slider = (lv_obj_t*)lv_event_get_target(e); - float val = lv_slider_get_value(slider) / 100.0f; - auto* synth = static_cast(pc_platform_get_synth_engine()); - if (synth) synth->setFeedback(val); - update_slider_val_label(slider); -} - -/* ── App create / destroy ────────────────────────────────────────────── */ - -lv_obj_t* MlPiano_create(lv_obj_t* parent, App* a) -{ - thisApp = a; - lv_obj_t* cont = lv_obj_create(parent); - lv_obj_set_size(cont, lv_pct(100), lv_pct(100)); - lv_obj_set_style_bg_color(cont, lv_color_black(), 0); - lv_obj_set_style_bg_opa(cont, LV_OPA_COVER, 0); - lv_obj_set_style_pad_all(cont, 4, 0); - lv_obj_set_flex_flow(cont, LV_FLEX_FLOW_COLUMN); - lv_obj_set_style_pad_row(cont, 2, 0); - lv_obj_remove_flag(cont, LV_OBJ_FLAG_SCROLLABLE); - - /* ── Title bar with close button ─────────────────────────── */ - lv_obj_t* titleBar = lv_obj_create(cont); - lv_obj_set_size(titleBar, lv_pct(100), 24); - lv_obj_set_style_bg_opa(titleBar, LV_OPA_TRANSP, 0); - lv_obj_set_style_border_width(titleBar, 0, 0); - lv_obj_set_style_pad_all(titleBar, 0, 0); - lv_obj_remove_flag(titleBar, LV_OBJ_FLAG_SCROLLABLE); - - lv_obj_t* titleLabel = lv_label_create(titleBar); - lv_label_set_text(titleLabel, "ML Piano"); - lv_obj_set_style_text_color(titleLabel, lv_color_white(), 0); - lv_obj_set_style_text_font(titleLabel, &lv_font_montserrat_14, 0); - lv_obj_align(titleLabel, LV_ALIGN_LEFT_MID, 4, 0); - - // Close (X) button - lv_obj_t* closeBtn = lv_button_create(titleBar); - lv_obj_set_size(closeBtn, 28, 20); - lv_obj_align(closeBtn, LV_ALIGN_RIGHT_MID, -2, 0); - lv_obj_set_style_bg_color(closeBtn, lv_color_hex(0x662222), 0); - lv_obj_set_style_bg_color(closeBtn, lv_color_hex(0xAA3333), LV_STATE_PRESSED); - lv_obj_set_style_radius(closeBtn, 4, 0); - lv_obj_set_style_shadow_width(closeBtn, 0, 0); - lv_obj_t* closeLbl = lv_label_create(closeBtn); - lv_label_set_text(closeLbl, "X"); - lv_obj_set_style_text_font(closeLbl, &lv_font_montserrat_12, 0); - lv_obj_center(closeLbl); - lv_obj_add_event_cb(closeBtn, on_close, LV_EVENT_CLICKED, nullptr); - - /* ── Preset dropdown ─────────────────────────────────────── */ - lv_obj_t* presetRow = lv_obj_create(cont); - lv_obj_set_size(presetRow, lv_pct(100), 28); - lv_obj_set_style_bg_opa(presetRow, LV_OPA_TRANSP, 0); - lv_obj_set_style_border_width(presetRow, 0, 0); - lv_obj_set_style_pad_all(presetRow, 0, 0); - lv_obj_remove_flag(presetRow, LV_OBJ_FLAG_SCROLLABLE); - - lv_obj_t* presetLbl = lv_label_create(presetRow); - lv_label_set_text(presetLbl, "Preset"); - lv_obj_set_style_text_color(presetLbl, lv_color_hex(0x999999), 0); - lv_obj_set_style_text_font(presetLbl, &lv_font_montserrat_10, 0); - lv_obj_align(presetLbl, LV_ALIGN_LEFT_MID, 2, 0); - - s_presetDropdown = lv_dropdown_create(presetRow); - static char presetOptions[512]; - presetOptions[0] = '\0'; - for (int i = 0; i < 16; i++) { - if (i > 0) strcat(presetOptions, "\n"); - strcat(presetOptions, PRESET_NAMES[i]); - } - lv_dropdown_set_options(s_presetDropdown, presetOptions); - lv_obj_set_size(s_presetDropdown, 200, 24); - lv_obj_align(s_presetDropdown, LV_ALIGN_LEFT_MID, 56, 0); - lv_obj_set_style_text_font(s_presetDropdown, &lv_font_montserrat_10, 0); - lv_obj_set_style_bg_color(s_presetDropdown, lv_color_hex(0x222244), 0); - lv_obj_set_style_text_color(s_presetDropdown, lv_color_white(), 0); - lv_obj_add_event_cb(s_presetDropdown, on_preset_changed, LV_EVENT_VALUE_CHANGED, nullptr); - - auto* synth = static_cast(pc_platform_get_synth_engine()); - if (synth) { - lv_dropdown_set_selected(s_presetDropdown, synth->getMidiChannel()); - } - - /* ── Synth parameter sliders ─────────────────────────────── */ - make_slider_row(cont, "Attack", 0, 100, 50, on_attack); - make_slider_row(cont, "Decay", 0, 100, 50, on_decay); - make_slider_row(cont, "Sustain", 0, 127, 80, on_sustain); - make_slider_row(cont, "Release", 0, 100, 30, on_release); - make_slider_row(cont, "Feedback", 0, 100, 10, on_feedback); - - /* ── Octave row ──────────────────────────────────────────── */ - lv_obj_t* octRow = lv_obj_create(cont); - lv_obj_set_size(octRow, lv_pct(100), 26); - lv_obj_set_style_bg_opa(octRow, LV_OPA_TRANSP, 0); - lv_obj_set_style_border_width(octRow, 0, 0); - lv_obj_set_style_pad_all(octRow, 0, 0); - lv_obj_remove_flag(octRow, LV_OBJ_FLAG_SCROLLABLE); - - lv_obj_t* btnDown = lv_button_create(octRow); - lv_obj_set_size(btnDown, 44, 22); - lv_obj_align(btnDown, LV_ALIGN_LEFT_MID, 2, 0); - lv_obj_set_style_bg_color(btnDown, lv_color_hex(0x333355), 0); - lv_obj_set_style_radius(btnDown, 4, 0); - lv_obj_set_style_shadow_width(btnDown, 0, 0); - lv_obj_t* lblDown = lv_label_create(btnDown); - lv_label_set_text(lblDown, LV_SYMBOL_LEFT); - lv_obj_set_style_text_font(lblDown, &lv_font_montserrat_12, 0); - lv_obj_center(lblDown); - lv_obj_add_event_cb(btnDown, on_octave_down, LV_EVENT_CLICKED, nullptr); - - s_octaveLabel = lv_label_create(octRow); - lv_obj_set_style_text_color(s_octaveLabel, lv_color_hex(0x78C8FF), 0); - lv_obj_set_style_text_font(s_octaveLabel, &lv_font_montserrat_12, 0); - lv_obj_align(s_octaveLabel, LV_ALIGN_CENTER, 0, 0); - - lv_obj_t* btnUp = lv_button_create(octRow); - lv_obj_set_size(btnUp, 44, 22); - lv_obj_align(btnUp, LV_ALIGN_RIGHT_MID, -2, 0); - lv_obj_set_style_bg_color(btnUp, lv_color_hex(0x333355), 0); - lv_obj_set_style_radius(btnUp, 4, 0); - lv_obj_set_style_shadow_width(btnUp, 0, 0); - lv_obj_t* lblUp = lv_label_create(btnUp); - lv_label_set_text(lblUp, LV_SYMBOL_RIGHT); - lv_obj_set_style_text_font(lblUp, &lv_font_montserrat_12, 0); - lv_obj_center(lblUp); - lv_obj_add_event_cb(btnUp, on_octave_up, LV_EVENT_CLICKED, nullptr); - - /* ── Register pad logic ──────────────────────────────────── */ - if (synth) { - s_padLogicShared = std::make_shared(*synth); - s_padLogic = s_padLogicShared.get(); - - crosspad::getPadManager().registerPadLogic("MLPiano", s_padLogicShared); - } - - updateOctaveLabel(); - - /* ── Lifecycle callbacks ─────────────────────────────────── */ - if (a) { - a->setOnShow([](lv_obj_t*) { - crosspad::getPadManager().setActivePadLogic("MLPiano"); - crosspad_app_update_pad_icon(); - }); - a->setOnHide([](lv_obj_t*) { - crosspad::getPadManager().setActivePadLogic(""); - crosspad_app_update_pad_icon(); - }); - } - - printf("[MlPiano] App created\n"); - return cont; -} - -void MlPiano_destroy(lv_obj_t* app_obj) -{ - crosspad::getPadManager().setActivePadLogic(""); - crosspad::getPadManager().unregisterPadLogic("MLPiano"); - crosspad_app_update_pad_icon(); - s_padLogic = nullptr; - s_padLogicShared.reset(); - s_octaveLabel = nullptr; - s_presetDropdown = nullptr; - thisApp = nullptr; - - lv_obj_delete_async(app_obj); - printf("[MlPiano] App destroyed\n"); -} - -/* ── App registration ────────────────────────────────────────────────── */ - -void _register_MLPiano_app() { - static char icon_path[256]; - snprintf(icon_path, sizeof(icon_path), "%spiano.png", - crosspad_gui::getGuiPlatform().assetPathPrefix()); - - static const crosspad::AppEntry entry = { - "MLPiano", icon_path, MlPiano_create, MlPiano_destroy, - nullptr, nullptr, nullptr, nullptr, 0 - }; - crosspad::AppRegistry::getInstance().registerApp(entry); -} diff --git a/src/apps/ml_piano/MlPianoApp.hpp b/src/apps/ml_piano/MlPianoApp.hpp deleted file mode 100644 index 3d4251e11..000000000 --- a/src/apps/ml_piano/MlPianoApp.hpp +++ /dev/null @@ -1,18 +0,0 @@ -#pragma once - -/** - * @file MlPianoApp.hpp - * @brief ML Piano app — FM synthesizer with 4x4 chromatic pad grid - * - * LVGL GUI app for CrossPad PC simulator. Uses ML_SynthTools FmSynth engine - * with 16 built-in FM presets selectable via MIDI channel. - */ - -// Forward declarations for LVGL app registration -class App; - -struct _lv_obj_t; -typedef struct _lv_obj_t lv_obj_t; - -lv_obj_t* MlPiano_create(lv_obj_t* parent, App* app); -void MlPiano_destroy(lv_obj_t* app_obj); diff --git a/src/apps/ml_piano/PianoPadLogic.cpp b/src/apps/ml_piano/PianoPadLogic.cpp deleted file mode 100644 index 570582aa0..000000000 --- a/src/apps/ml_piano/PianoPadLogic.cpp +++ /dev/null @@ -1,114 +0,0 @@ -/** - * @file PianoPadLogic.cpp - * @brief Chromatic pad logic for ML Piano app - * - * Maps 16 pads (0-15) to consecutive chromatic notes starting from baseNote_. - * Pad 0 = bottom-left = lowest note. Colors: white for naturals, dark for sharps. - */ - -#include "PianoPadLogic.hpp" -#include "synth/MlPianoSynth.hpp" -#include -#include - -PianoPadLogic::PianoPadLogic(MlPianoSynth& synth) - : synth_(synth) -{ -} - -bool PianoPadLogic::isBlackKey(uint8_t semitone) -{ - // C=0,C#=1,D=2,D#=3,E=4,F=5,F#=6,G=7,G#=8,A=9,A#=10,B=11 - uint8_t mod = semitone % 12; - return (mod == 1 || mod == 3 || mod == 6 || mod == 8 || mod == 10); -} - -uint8_t PianoPadLogic::noteForPad(uint8_t padIdx) const -{ - return baseNote_ + padIdx; -} - -void PianoPadLogic::colorPads(crosspad::PadManager& padManager) -{ - for (uint8_t i = 0; i < 16; i++) { - uint8_t note = noteForPad(i); - if (isBlackKey(note)) { - padManager.setPadColor(i, crosspad::RgbColor(20, 10, 40)); // dark purple for accidentals - } else { - padManager.setPadColor(i, crosspad::RgbColor(60, 60, 80)); // light blue-gray for naturals - } - } -} - -void PianoPadLogic::onActivate(crosspad::PadManager& padManager) -{ - printf("[PianoPad] Activated (baseNote=%u)\n", baseNote_); - colorPads(padManager); -} - -void PianoPadLogic::onDeactivate(crosspad::PadManager& padManager) -{ - allNotesOff(); - for (uint8_t i = 0; i < 16; i++) { - padManager.setPadColor(i, crosspad::RgbColor(0, 0, 0)); - } - printf("[PianoPad] Deactivated\n"); -} - -void PianoPadLogic::onPadPress(crosspad::PadManager& padManager, uint8_t padIdx, uint8_t velocity) -{ - if (padIdx >= 16) return; - uint8_t note = noteForPad(padIdx); - synth_.noteOn(note, velocity); - - // Light up pad bright - if (isBlackKey(note)) { - padManager.setPadColor(padIdx, crosspad::RgbColor(160, 80, 255)); // bright purple - } else { - padManager.setPadColor(padIdx, crosspad::RgbColor(120, 200, 255)); // bright cyan - } -} - -void PianoPadLogic::onPadRelease(crosspad::PadManager& padManager, uint8_t padIdx) -{ - if (padIdx >= 16) return; - uint8_t note = noteForPad(padIdx); - synth_.noteOff(note); - - // Restore idle color - if (isBlackKey(note)) { - padManager.setPadColor(padIdx, crosspad::RgbColor(20, 10, 40)); - } else { - padManager.setPadColor(padIdx, crosspad::RgbColor(60, 60, 80)); - } -} - -void PianoPadLogic::onPadPressure(crosspad::PadManager& /*padManager*/, uint8_t /*padIdx*/, uint8_t /*pressure*/) -{ - // Not used for piano -} - -void PianoPadLogic::octaveUp() -{ - if (baseNote_ + 12 + 15 <= 127) { - allNotesOff(); - baseNote_ += 12; - printf("[PianoPad] Octave up -> baseNote=%u\n", baseNote_); - } -} - -void PianoPadLogic::octaveDown() -{ - if (baseNote_ >= 12) { - allNotesOff(); - baseNote_ -= 12; - printf("[PianoPad] Octave down -> baseNote=%u\n", baseNote_); - } -} - -void PianoPadLogic::allNotesOff() -{ - for (uint8_t i = 0; i < 16; i++) { - synth_.noteOff(noteForPad(i)); - } -} diff --git a/src/apps/ml_piano/PianoPadLogic.hpp b/src/apps/ml_piano/PianoPadLogic.hpp deleted file mode 100644 index 6e0d8295f..000000000 --- a/src/apps/ml_piano/PianoPadLogic.hpp +++ /dev/null @@ -1,39 +0,0 @@ -#pragma once - -/** - * @file PianoPadLogic.hpp - * @brief IPadLogicHandler for ML Piano app — chromatic note mapping on 4x4 pad grid - */ - -#include -#include - -class MlPianoSynth; - -class PianoPadLogic : public crosspad::IPadLogicHandler { -public: - explicit PianoPadLogic(MlPianoSynth& synth); - - void onActivate(crosspad::PadManager& padManager) override; - void onDeactivate(crosspad::PadManager& padManager) override; - void onPadPress(crosspad::PadManager& padManager, uint8_t padIdx, uint8_t velocity) override; - void onPadRelease(crosspad::PadManager& padManager, uint8_t padIdx) override; - void onPadPressure(crosspad::PadManager& padManager, uint8_t padIdx, uint8_t pressure) override; - - void octaveUp(); - void octaveDown(); - uint8_t getBaseNote() const { return baseNote_; } - - /// Returns true if the given semitone offset (0-11) is a black key - static bool isBlackKey(uint8_t semitone); - - /// Set pad colors based on current base note (public for GUI use) - void colorPads(crosspad::PadManager& padManager); - -private: - MlPianoSynth& synth_; - uint8_t baseNote_ = 48; // C3 default - - void allNotesOff(); - uint8_t noteForPad(uint8_t padIdx) const; -}; diff --git a/src/apps/serial_monitor/CMakeLists.txt b/src/apps/serial_monitor/CMakeLists.txt deleted file mode 100644 index e053b9f7c..000000000 --- a/src/apps/serial_monitor/CMakeLists.txt +++ /dev/null @@ -1,4 +0,0 @@ -set(SERIAL_MONITOR_APP_SOURCES - ${CMAKE_CURRENT_SOURCE_DIR}/SerialMonitorApp.cpp - PARENT_SCOPE -) diff --git a/src/apps/serial_monitor/SerialMonitorApp.cpp b/src/apps/serial_monitor/SerialMonitorApp.cpp deleted file mode 100644 index 56ef1b68a..000000000 --- a/src/apps/serial_monitor/SerialMonitorApp.cpp +++ /dev/null @@ -1,367 +0,0 @@ -#if USE_LVGL - -#include "crosspad/app/AppRegistrar.hpp" -#include "crosspad-gui/platform/IGuiPlatform.h" -#include "crosspad_app.hpp" -#include "uart/PcUart.hpp" -#include "lvgl.h" - -#include -#include -#include -#include - -/* ── Layout constants ────────────────────────────────────────────────── */ - -static constexpr int32_t MAX_DISPLAY_LINES = 200; -static constexpr uint32_t POLL_INTERVAL_MS = 50; - -/* ── Per-instance state ──────────────────────────────────────────────── */ - -struct SerialMonitorState { - lv_obj_t* container = nullptr; - lv_obj_t* outputLabel = nullptr; - lv_obj_t* inputTA = nullptr; - lv_obj_t* statusLabel = nullptr; - lv_obj_t* baudDropdown = nullptr; - lv_obj_t* clearBtn = nullptr; - lv_obj_t* autoScrollBtn = nullptr; - lv_obj_t* scrollArea = nullptr; - lv_timer_t* pollTimer = nullptr; - - std::deque lines; - bool autoScroll = true; - bool dirty = false; // text needs refresh -}; - -/* ── Baud rate options ───────────────────────────────────────────────── */ - -struct BaudOption { - const char* label; - PcUart::BaudRate rate; -}; - -static const BaudOption BAUD_OPTIONS[] = { - {"9600", PcUart::BaudRate::B9600}, - {"19200", PcUart::BaudRate::B19200}, - {"38400", PcUart::BaudRate::B38400}, - {"57600", PcUart::BaudRate::B57600}, - {"115200", PcUart::BaudRate::B115200}, - {"230400", PcUart::BaudRate::B230400}, - {"460800", PcUart::BaudRate::B460800}, - {"921600", PcUart::BaudRate::B921600}, -}; -static constexpr int BAUD_COUNT = sizeof(BAUD_OPTIONS) / sizeof(BAUD_OPTIONS[0]); - -static int findBaudIndex(PcUart::BaudRate rate) { - for (int i = 0; i < BAUD_COUNT; i++) { - if (BAUD_OPTIONS[i].rate == rate) return i; - } - return 4; // default 115200 -} - -/* ── Rebuild the displayed text ──────────────────────────────────────── */ - -static void rebuildOutputText(SerialMonitorState* st) -{ - if (!st->outputLabel) return; - - std::string text; - for (auto& line : st->lines) { - text += line; - text += '\n'; - } - - lv_label_set_text(st->outputLabel, text.c_str()); - st->dirty = false; - - if (st->autoScroll && st->scrollArea) { - lv_obj_scroll_to_y(st->scrollArea, LV_COORD_MAX, LV_ANIM_OFF); - } -} - -/* ── Poll timer ──────────────────────────────────────────────────────── */ - -static void onPollTimer(lv_timer_t* t) -{ - auto* st = static_cast(lv_timer_get_user_data(t)); - auto& uart = pc_platform_get_uart(); - - // Update status - if (st->statusLabel) { - if (uart.isOpen()) { - char buf[64]; - snprintf(buf, sizeof(buf), "%s @ %u", - uart.getPortName().c_str(), - static_cast(uart.getBaudRate())); - lv_label_set_text(st->statusLabel, buf); - lv_obj_set_style_text_color(st->statusLabel, lv_color_hex(0xFF9900), 0); - } else { - lv_label_set_text(st->statusLabel, "Not connected"); - lv_obj_set_style_text_color(st->statusLabel, lv_color_hex(0x666666), 0); - } - } - - // Read new lines - auto newLines = uart.readLines(); - if (newLines.empty()) return; - - for (auto& line : newLines) { - st->lines.push_back(std::move(line)); - } - - // Trim to max - while (st->lines.size() > MAX_DISPLAY_LINES) { - st->lines.pop_front(); - } - - rebuildOutputText(st); -} - -/* ── Input send callback ─────────────────────────────────────────────── */ - -static void onInputSend(lv_event_t* e) -{ - auto* st = static_cast(lv_event_get_user_data(e)); - if (!st->inputTA) return; - - const char* text = lv_textarea_get_text(st->inputTA); - if (!text || text[0] == '\0') return; - - auto& uart = pc_platform_get_uart(); - if (uart.isOpen()) { - std::string msg(text); - msg += "\r\n"; - uart.write(msg); - - // Echo to output - st->lines.push_back(std::string("> ") + text); - while (st->lines.size() > MAX_DISPLAY_LINES) - st->lines.pop_front(); - rebuildOutputText(st); - } - - lv_textarea_set_text(st->inputTA, ""); -} - -/* ── Clear button ────────────────────────────────────────────────────── */ - -static void onClearClicked(lv_event_t* e) -{ - auto* st = static_cast(lv_event_get_user_data(e)); - st->lines.clear(); - if (st->outputLabel) { - lv_label_set_text(st->outputLabel, ""); - } -} - -/* ── Auto-scroll toggle ─────────────────────────────────────────────── */ - -static void onAutoScrollClicked(lv_event_t* e) -{ - auto* st = static_cast(lv_event_get_user_data(e)); - st->autoScroll = !st->autoScroll; - - lv_obj_t* btn = (lv_obj_t*)lv_event_get_target(e); - if (st->autoScroll) { - lv_obj_set_style_bg_color(btn, lv_color_hex(0xFF9900), 0); - lv_obj_set_style_text_color(lv_obj_get_child(btn, 0), lv_color_white(), 0); - } else { - lv_obj_set_style_bg_color(btn, lv_color_hex(0x333333), 0); - lv_obj_set_style_text_color(lv_obj_get_child(btn, 0), lv_color_hex(0x888888), 0); - } -} - -/* ── Baud rate change ────────────────────────────────────────────────── */ - -static void onBaudChanged(lv_event_t* e) -{ - lv_obj_t* dd = (lv_obj_t*)lv_event_get_target(e); - uint32_t sel = lv_dropdown_get_selected(dd); - if (sel >= (uint32_t)BAUD_COUNT) return; - - auto& uart = pc_platform_get_uart(); - if (uart.isOpen()) { - std::string port = uart.getPortName(); - uart.close(); - uart.open(port, BAUD_OPTIONS[sel].rate); - } -} - -/* ── App create / destroy ────────────────────────────────────────────── */ - -static lv_obj_t* lv_CreateSerialMonitor(lv_obj_t* parent, App* a) -{ - (void)a; - - auto* st = new SerialMonitorState(); - - // Root container - st->container = lv_obj_create(parent); - lv_obj_set_size(st->container, lv_obj_get_content_width(parent), - lv_obj_get_content_height(parent)); - lv_obj_set_style_bg_color(st->container, lv_color_hex(0x0A0A0A), 0); - lv_obj_set_style_border_width(st->container, 0, 0); - lv_obj_set_style_pad_all(st->container, 4, 0); - lv_obj_set_style_radius(st->container, 0, 0); - lv_obj_center(st->container); - lv_obj_set_flex_flow(st->container, LV_FLEX_FLOW_COLUMN); - lv_obj_set_style_pad_row(st->container, 3, 0); - lv_obj_remove_flag(st->container, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_set_user_data(st->container, st); - - // ── Top toolbar ── - lv_obj_t* toolbar = lv_obj_create(st->container); - lv_obj_set_size(toolbar, LV_PCT(100), 22); - lv_obj_set_style_bg_opa(toolbar, LV_OPA_TRANSP, 0); - lv_obj_set_style_border_width(toolbar, 0, 0); - lv_obj_set_style_pad_all(toolbar, 0, 0); - lv_obj_remove_flag(toolbar, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_set_flex_flow(toolbar, LV_FLEX_FLOW_ROW); - lv_obj_set_style_pad_column(toolbar, 4, 0); - lv_obj_set_flex_align(toolbar, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); - - // Status label (port + baud) - st->statusLabel = lv_label_create(toolbar); - lv_label_set_text(st->statusLabel, "Not connected"); - lv_obj_set_style_text_font(st->statusLabel, &lv_font_montserrat_10, 0); - lv_obj_set_style_text_color(st->statusLabel, lv_color_hex(0x666666), 0); - lv_obj_set_flex_grow(st->statusLabel, 1); - - // Baud rate dropdown - st->baudDropdown = lv_dropdown_create(toolbar); - std::string baudOpts; - for (int i = 0; i < BAUD_COUNT; i++) { - if (i > 0) baudOpts += '\n'; - baudOpts += BAUD_OPTIONS[i].label; - } - lv_dropdown_set_options(st->baudDropdown, baudOpts.c_str()); - lv_dropdown_set_selected(st->baudDropdown, - findBaudIndex(pc_platform_get_uart().getBaudRate())); - lv_obj_set_size(st->baudDropdown, 70, 20); - lv_obj_set_style_text_font(st->baudDropdown, &lv_font_montserrat_10, 0); - lv_obj_set_style_pad_all(st->baudDropdown, 2, 0); - lv_obj_set_style_bg_color(st->baudDropdown, lv_color_hex(0x222222), 0); - lv_obj_set_style_text_color(st->baudDropdown, lv_color_hex(0xCCCCCC), 0); - lv_obj_set_style_border_color(st->baudDropdown, lv_color_hex(0x444444), 0); - lv_obj_set_style_border_width(st->baudDropdown, 1, 0); - lv_obj_set_style_radius(st->baudDropdown, 3, 0); - lv_obj_add_event_cb(st->baudDropdown, onBaudChanged, LV_EVENT_VALUE_CHANGED, st); - - // Clear button - st->clearBtn = lv_button_create(toolbar); - lv_obj_set_size(st->clearBtn, 40, 20); - lv_obj_set_style_bg_color(st->clearBtn, lv_color_hex(0x333333), 0); - lv_obj_set_style_radius(st->clearBtn, 3, 0); - lv_obj_set_style_pad_all(st->clearBtn, 0, 0); - lv_obj_t* clrLbl = lv_label_create(st->clearBtn); - lv_label_set_text(clrLbl, "CLR"); - lv_obj_set_style_text_font(clrLbl, &lv_font_montserrat_10, 0); - lv_obj_set_style_text_color(clrLbl, lv_color_hex(0xCCCCCC), 0); - lv_obj_center(clrLbl); - lv_obj_add_event_cb(st->clearBtn, onClearClicked, LV_EVENT_CLICKED, st); - - // Auto-scroll toggle - st->autoScrollBtn = lv_button_create(toolbar); - lv_obj_set_size(st->autoScrollBtn, 20, 20); - lv_obj_set_style_bg_color(st->autoScrollBtn, lv_color_hex(0xFF9900), 0); - lv_obj_set_style_radius(st->autoScrollBtn, 3, 0); - lv_obj_set_style_pad_all(st->autoScrollBtn, 0, 0); - lv_obj_t* scrollLbl = lv_label_create(st->autoScrollBtn); - lv_label_set_text(scrollLbl, LV_SYMBOL_DOWN); - lv_obj_set_style_text_font(scrollLbl, &lv_font_montserrat_10, 0); - lv_obj_set_style_text_color(scrollLbl, lv_color_white(), 0); - lv_obj_center(scrollLbl); - lv_obj_add_event_cb(st->autoScrollBtn, onAutoScrollClicked, LV_EVENT_CLICKED, st); - - // ── Scroll area for output ── - st->scrollArea = lv_obj_create(st->container); - lv_obj_set_size(st->scrollArea, LV_PCT(100), LV_SIZE_CONTENT); - lv_obj_set_flex_grow(st->scrollArea, 1); - lv_obj_set_style_bg_color(st->scrollArea, lv_color_hex(0x0A0A0A), 0); - lv_obj_set_style_border_width(st->scrollArea, 1, 0); - lv_obj_set_style_border_color(st->scrollArea, lv_color_hex(0x333333), 0); - lv_obj_set_style_pad_all(st->scrollArea, 4, 0); - lv_obj_set_style_radius(st->scrollArea, 2, 0); - lv_obj_add_flag(st->scrollArea, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_set_scrollbar_mode(st->scrollArea, LV_SCROLLBAR_MODE_AUTO); - - // Output label (monospace-style, wrapping) - st->outputLabel = lv_label_create(st->scrollArea); - lv_label_set_text(st->outputLabel, ""); - lv_obj_set_width(st->outputLabel, LV_PCT(100)); - lv_obj_set_style_text_font(st->outputLabel, &lv_font_montserrat_10, 0); - lv_obj_set_style_text_color(st->outputLabel, lv_color_hex(0x00FF66), 0); - lv_label_set_long_mode(st->outputLabel, LV_LABEL_LONG_WRAP); - - // ── Input row (text area + send button) ── - lv_obj_t* inputRow = lv_obj_create(st->container); - lv_obj_set_size(inputRow, LV_PCT(100), 28); - lv_obj_set_style_bg_opa(inputRow, LV_OPA_TRANSP, 0); - lv_obj_set_style_border_width(inputRow, 0, 0); - lv_obj_set_style_pad_all(inputRow, 0, 0); - lv_obj_remove_flag(inputRow, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_set_flex_flow(inputRow, LV_FLEX_FLOW_ROW); - lv_obj_set_style_pad_column(inputRow, 4, 0); - - // Text input - st->inputTA = lv_textarea_create(inputRow); - lv_textarea_set_one_line(st->inputTA, true); - lv_textarea_set_placeholder_text(st->inputTA, "Send..."); - lv_obj_set_flex_grow(st->inputTA, 1); - lv_obj_set_height(st->inputTA, 26); - lv_obj_set_style_text_font(st->inputTA, &lv_font_montserrat_10, 0); - lv_obj_set_style_bg_color(st->inputTA, lv_color_hex(0x1A1A1A), 0); - lv_obj_set_style_text_color(st->inputTA, lv_color_hex(0xDDDDDD), 0); - lv_obj_set_style_border_color(st->inputTA, lv_color_hex(0x444444), 0); - lv_obj_set_style_border_width(st->inputTA, 1, 0); - lv_obj_set_style_radius(st->inputTA, 3, 0); - lv_obj_set_style_pad_all(st->inputTA, 4, 0); - - // Send button - lv_obj_t* sendBtn = lv_button_create(inputRow); - lv_obj_set_size(sendBtn, 44, 26); - lv_obj_set_style_bg_color(sendBtn, lv_color_hex(0xFF9900), 0); - lv_obj_set_style_radius(sendBtn, 3, 0); - lv_obj_set_style_pad_all(sendBtn, 0, 0); - lv_obj_t* sendLbl = lv_label_create(sendBtn); - lv_label_set_text(sendLbl, "Send"); - lv_obj_set_style_text_font(sendLbl, &lv_font_montserrat_10, 0); - lv_obj_set_style_text_color(sendLbl, lv_color_white(), 0); - lv_obj_center(sendLbl); - lv_obj_add_event_cb(sendBtn, onInputSend, LV_EVENT_CLICKED, st); - - // Also send on Enter in text area - lv_obj_add_event_cb(st->inputTA, onInputSend, LV_EVENT_READY, st); - - // ── Poll timer ── - st->pollTimer = lv_timer_create(onPollTimer, POLL_INTERVAL_MS, st); - - printf("[SerialMonitor] App created\n"); - return st->container; -} - -static void lv_DestroySerialMonitor(lv_obj_t* obj) -{ - auto* st = static_cast(lv_obj_get_user_data(obj)); - if (st) { - if (st->pollTimer) { - lv_timer_delete(st->pollTimer); - } - delete st; - } - printf("[SerialMonitor] App destroyed\n"); -} - -/* ── Registration ────────────────────────────────────────────────────── */ - -void _register_SerialMonitor_app() { - static const crosspad::AppEntry entry = { - "Serial", LV_SYMBOL_USB, - lv_CreateSerialMonitor, lv_DestroySerialMonitor, - nullptr, nullptr, nullptr, nullptr, 0 - }; - crosspad::AppRegistry::getInstance().registerApp(entry); -} - -#endif // USE_LVGL diff --git a/src/apps/settings/settings_app.cpp b/src/apps/settings/settings_app.cpp index e630b07c5..5dc5cb420 100644 --- a/src/apps/settings/settings_app.cpp +++ b/src/apps/settings/settings_app.cpp @@ -4,9 +4,21 @@ #include "crosspad/app/AppRegistrar.hpp" #include "crosspad-gui/components/settings_ui.h" #include "crosspad-gui/platform/IGuiPlatform.h" +#include "crosspad/settings/CrosspadSettings.hpp" #include "pc_stubs/pc_platform.h" #include +#ifdef USE_BLE +#include "midi/PcBleMidi.hpp" +#include "crosspad/settings/ISettingsUI.hpp" +#include +#include +#include +#include +#include +extern PcBleMidi bleMidi; +#endif + /* ── PC-specific settings: USB/UART ──────────────────────────────────── */ static bool s_usbAutoconnect = true; @@ -53,6 +65,337 @@ static void cat_build_usb(lv_obj_t* parent, lv_group_t* group) { lv_obj_set_width(info, LV_PCT(100)); } +/* ── PC-specific settings: Bluetooth MIDI ───────────────────────────── */ + +#ifdef USE_BLE + +static uint8_t s_bleOffsetIdx = 4; + +static const int8_t s_bleOffsets[] = {-48, -36, -24, -12, 0, 12, 24, 36, 48}; +static constexpr size_t s_bleOffsetCount = sizeof(s_bleOffsets) / sizeof(s_bleOffsets[0]); + +// UI state +static lv_obj_t* s_bleContent = nullptr; // our wrapper (rebuilt, not the back button parent) +static lv_group_t* s_bleGroup = nullptr; +static lv_timer_t* s_bleRefreshTimer = nullptr; +static std::vector s_bleScanAddresses; // owns addresses for Connect button user_data + +// Live-updated labels (no rebuild needed for these) +static lv_obj_t* s_bleStatusLabel = nullptr; // "Disconnected" / "Connecting..." / "Connected" +static lv_obj_t* s_bleMidiInLabel = nullptr; // last raw MIDI IN +static lv_obj_t* s_bleMidiOutLabel = nullptr; // last raw MIDI OUT + +// Tracked state for rebuild triggers +static bool s_bleLastConnected = false; +static bool s_bleLastScanning = false; +static size_t s_bleLastResultCount = 0; +static std::atomic s_bleConnecting{false}; +static int s_bleDotCount = 0; + +// Last raw MIDI messages (updated from callbacks, read by timer) +static char s_lastMidiIn[48] = "---"; +static char s_lastMidiOut[48] = "---"; +static std::mutex s_midiRawMutex; + +/// Called from BLE MIDI input callbacks to record last raw message +void ble_settings_log_midi_in(uint8_t status, uint8_t d1, uint8_t d2) { + std::lock_guard lock(s_midiRawMutex); + snprintf(s_lastMidiIn, sizeof(s_lastMidiIn), "%02X %02X %02X", status, d1, d2); +} + +/// Called from BLE MIDI output to record last raw message +void ble_settings_log_midi_out(uint8_t status, uint8_t d1, uint8_t d2) { + std::lock_guard lock(s_midiRawMutex); + snprintf(s_lastMidiOut, sizeof(s_lastMidiOut), "%02X %02X %02X", status, d1, d2); +} + +static uint8_t offsetToIndex(int8_t offset) { + for (size_t i = 0; i < s_bleOffsetCount; i++) { + if (s_bleOffsets[i] == offset) return static_cast(i); + } + return 4; +} + +// Forward declarations +static void cat_build_bluetooth(lv_obj_t* parent, lv_group_t* group); +static void ble_build_content(lv_obj_t* parent, lv_group_t* group); + +static void bleRebuildPanel() { + if (!s_bleContent || !lv_obj_is_valid(s_bleContent)) return; + lv_obj_clean(s_bleContent); + ble_build_content(s_bleContent, s_bleGroup); +} + +/// Polling timer — updates live labels + triggers rebuild on state changes +static void bleRefreshTimerCb(lv_timer_t*) { + bool connected = bleMidi.isConnected(); + bool scanning = bleMidi.isScanning(); + size_t resultCount = bleMidi.getScanResults().size(); + bool connecting = s_bleConnecting.load(); + + // Update status label in-place (no rebuild) + if (s_bleStatusLabel && lv_obj_is_valid(s_bleStatusLabel)) { + if (connected) { + lv_label_set_text(s_bleStatusLabel, "Connected"); + } else if (connecting) { + s_bleDotCount = (s_bleDotCount + 1) % 4; + const char* dots[] = {"Connecting", "Connecting.", "Connecting..", "Connecting..."}; + lv_label_set_text(s_bleStatusLabel, dots[s_bleDotCount]); + } else if (scanning) { + s_bleDotCount = (s_bleDotCount + 1) % 4; + const char* dots[] = {"Scanning", "Scanning.", "Scanning..", "Scanning..."}; + lv_label_set_text(s_bleStatusLabel, dots[s_bleDotCount]); + } else { + lv_label_set_text(s_bleStatusLabel, "Disconnected"); + } + } + + // Update raw MIDI labels in-place + { + std::lock_guard lock(s_midiRawMutex); + if (s_bleMidiInLabel && lv_obj_is_valid(s_bleMidiInLabel)) + lv_label_set_text(s_bleMidiInLabel, s_lastMidiIn); + if (s_bleMidiOutLabel && lv_obj_is_valid(s_bleMidiOutLabel)) + lv_label_set_text(s_bleMidiOutLabel, s_lastMidiOut); + } + + // Rebuild only on major state transitions + if (connected != s_bleLastConnected || + scanning != s_bleLastScanning || + resultCount != s_bleLastResultCount) { + s_bleLastConnected = connected; + s_bleLastScanning = scanning; + s_bleLastResultCount = resultCount; + bleRebuildPanel(); + } +} + +static void onBleModeChanged(lv_event_t* e) { + (void)e; + auto* s = crosspad::CrosspadSettings::getInstance(); + auto mode = s->wireless.bleMidiMode == 0 + ? crosspad::BleMidiMode::Host : crosspad::BleMidiMode::Server; + bleMidi.end(); + bleMidi.begin(mode); + bleRebuildPanel(); + auto* ui = crosspad::getSettingsUI(); + if (ui) ui->saveSettings(); +} + +static void onBleNoteOffsetChanged(lv_event_t* e) { + (void)e; + auto* s = crosspad::CrosspadSettings::getInstance(); + int8_t offset = s_bleOffsets[s_bleOffsetIdx]; + s->wireless.bleMidiNoteOffset = offset; + bleMidi.setNoteOffset(offset); + auto* ui = crosspad::getSettingsUI(); + if (ui) ui->saveSettings(); +} + +static void onBleScan(lv_event_t* e) { + (void)e; + bleMidi.startScan(5000); +} + +static void onBleDisconnect(lv_event_t* e) { + (void)e; + bleMidi.disconnect(); +} + +static void onBleConnect(lv_event_t* ev) { + auto* addr = static_cast(lv_event_get_user_data(ev)); + if (addr) { + std::string a = *addr; + s_bleConnecting.store(true); + std::thread([a]() { + bleMidi.connectToDevice(a); + s_bleConnecting.store(false); + }).detach(); + } +} + +/// Build the BLE settings content widgets +static void ble_build_content(lv_obj_t* parent, lv_group_t* group) { + auto* s = crosspad::CrosspadSettings::getInstance(); + + // Reset live label pointers (old objects were cleaned) + s_bleStatusLabel = nullptr; + s_bleMidiInLabel = nullptr; + s_bleMidiOutLabel = nullptr; + + // ── BLE Mode ── + crosspad_gui::settings_section_header(parent, "BLE Mode"); + crosspad_gui::settings_row_dropdown(parent, group, + "Mode", "Host (Central)\nServer (Peripheral)", + &s->wireless.bleMidiMode, onBleModeChanged); + + // ── Connection Status ── + crosspad_gui::settings_section_header(parent, "Connection"); + + // Status row with live-updated label + { + lv_obj_t* row = lv_obj_create(parent); + lv_obj_set_size(row, LV_PCT(100), 28); + lv_obj_set_style_bg_opa(row, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(row, 0, 0); + lv_obj_set_style_pad_hor(row, 4, 0); + lv_obj_set_style_pad_ver(row, 2, 0); + lv_obj_set_flex_flow(row, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(row, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_remove_flag(row, LV_OBJ_FLAG_SCROLLABLE); + + lv_obj_t* lbl = lv_label_create(row); + lv_label_set_text(lbl, "Status"); + lv_obj_set_flex_grow(lbl, 1); + lv_obj_set_style_text_color(lbl, lv_color_make(160, 160, 160), 0); + lv_obj_set_style_text_font(lbl, &lv_font_montserrat_14, 0); + + s_bleStatusLabel = lv_label_create(row); + // Left-align status text, fixed width so dots don't shift layout + lv_obj_set_width(s_bleStatusLabel, 110); + lv_obj_set_style_text_align(s_bleStatusLabel, LV_TEXT_ALIGN_RIGHT, 0); + lv_obj_set_style_text_font(s_bleStatusLabel, &lv_font_montserrat_14, 0); + + bool connecting = s_bleConnecting.load(); + if (bleMidi.isConnected()) { + lv_label_set_text(s_bleStatusLabel, "Connected"); + lv_obj_set_style_text_color(s_bleStatusLabel, lv_color_make(0, 200, 80), 0); + } else if (connecting) { + lv_label_set_text(s_bleStatusLabel, "Connecting..."); + lv_obj_set_style_text_color(s_bleStatusLabel, lv_color_make(255, 200, 0), 0); + } else if (bleMidi.isScanning()) { + lv_label_set_text(s_bleStatusLabel, "Scanning..."); + lv_obj_set_style_text_color(s_bleStatusLabel, lv_color_make(255, 200, 0), 0); + } else { + lv_label_set_text(s_bleStatusLabel, "Disconnected"); + lv_obj_set_style_text_color(s_bleStatusLabel, lv_color_white(), 0); + } + } + + if (bleMidi.isConnected()) { + auto dev = bleMidi.getConnectedDevice(); + crosspad_gui::settings_row_info(parent, "Device", dev.name.c_str()); + crosspad_gui::settings_row_info(parent, "Address", dev.address.c_str()); + crosspad_gui::settings_row_action(parent, group, + "Disconnect", "Disconnect", onBleDisconnect); + } + + // ── Host mode: Scan + Device List ── + if (bleMidi.getMode() == crosspad::BleMidiMode::Host && !bleMidi.isConnected()) { + bool busy = bleMidi.isScanning() || s_bleConnecting.load(); + crosspad_gui::settings_row_action(parent, group, + "Scan for devices", busy ? "..." : "Scan", + onBleScan); + + auto results = bleMidi.getScanResults(); + if (!results.empty()) { + crosspad_gui::settings_section_header(parent, "Devices Found"); + s_bleScanAddresses.clear(); + s_bleScanAddresses.reserve(results.size()); + for (auto& dev : results) { + s_bleScanAddresses.push_back(dev.address); + } + for (size_t i = 0; i < results.size(); i++) { + char label[96]; + snprintf(label, sizeof(label), "%s (%ddBm)", + results[i].name.c_str(), results[i].rssi); + + crosspad_gui::settings_row_action(parent, group, + label, "Connect", onBleConnect, + &s_bleScanAddresses[i]); + } + } + } + + // ── Note Offset ── + crosspad_gui::settings_section_header(parent, "MIDI"); + s_bleOffsetIdx = offsetToIndex(s->wireless.bleMidiNoteOffset); + crosspad_gui::settings_row_dropdown(parent, group, + "Note Offset", "-48\n-36\n-24\n-12\n0\n+12\n+24\n+36\n+48", + &s_bleOffsetIdx, onBleNoteOffsetChanged); + + // ── Raw MIDI Monitor ── + crosspad_gui::settings_section_header(parent, "MIDI Monitor"); + { + // MIDI IN row + lv_obj_t* rowIn = lv_obj_create(parent); + lv_obj_set_size(rowIn, LV_PCT(100), 24); + lv_obj_set_style_bg_opa(rowIn, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(rowIn, 0, 0); + lv_obj_set_style_pad_hor(rowIn, 4, 0); + lv_obj_set_style_pad_ver(rowIn, 0, 0); + lv_obj_set_flex_flow(rowIn, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(rowIn, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_remove_flag(rowIn, LV_OBJ_FLAG_SCROLLABLE); + + lv_obj_t* lblIn = lv_label_create(rowIn); + lv_label_set_text(lblIn, "IN"); + lv_obj_set_width(lblIn, 30); + lv_obj_set_style_text_color(lblIn, lv_color_make(100, 180, 255), 0); + lv_obj_set_style_text_font(lblIn, &lv_font_montserrat_12, 0); + + s_bleMidiInLabel = lv_label_create(rowIn); + lv_obj_set_style_text_font(s_bleMidiInLabel, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(s_bleMidiInLabel, lv_color_make(180, 180, 180), 0); + { + std::lock_guard lock(s_midiRawMutex); + lv_label_set_text(s_bleMidiInLabel, s_lastMidiIn); + } + + // MIDI OUT row + lv_obj_t* rowOut = lv_obj_create(parent); + lv_obj_set_size(rowOut, LV_PCT(100), 24); + lv_obj_set_style_bg_opa(rowOut, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(rowOut, 0, 0); + lv_obj_set_style_pad_hor(rowOut, 4, 0); + lv_obj_set_style_pad_ver(rowOut, 0, 0); + lv_obj_set_flex_flow(rowOut, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(rowOut, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_remove_flag(rowOut, LV_OBJ_FLAG_SCROLLABLE); + + lv_obj_t* lblOut = lv_label_create(rowOut); + lv_label_set_text(lblOut, "OUT"); + lv_obj_set_width(lblOut, 30); + lv_obj_set_style_text_color(lblOut, lv_color_make(100, 255, 140), 0); + lv_obj_set_style_text_font(lblOut, &lv_font_montserrat_12, 0); + + s_bleMidiOutLabel = lv_label_create(rowOut); + lv_obj_set_style_text_font(s_bleMidiOutLabel, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(s_bleMidiOutLabel, lv_color_make(180, 180, 180), 0); + { + std::lock_guard lock(s_midiRawMutex); + lv_label_set_text(s_bleMidiOutLabel, s_lastMidiOut); + } + } +} + +/// Category builder — creates wrapper so rebuilds don't destroy back button +static void cat_build_bluetooth(lv_obj_t* parent, lv_group_t* group) { + s_bleGroup = group; + + // Wrapper container (we rebuild only this, not the back button above) + s_bleContent = lv_obj_create(parent); + lv_obj_set_size(s_bleContent, LV_PCT(100), LV_SIZE_CONTENT); + lv_obj_set_style_bg_opa(s_bleContent, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(s_bleContent, 0, 0); + lv_obj_set_style_pad_all(s_bleContent, 0, 0); + lv_obj_set_style_pad_row(s_bleContent, 4, 0); + lv_obj_set_flex_flow(s_bleContent, LV_FLEX_FLOW_COLUMN); + lv_obj_remove_flag(s_bleContent, LV_OBJ_FLAG_SCROLLABLE); + + ble_build_content(s_bleContent, group); + + // Polling timer (300ms for smooth dot animation) + if (!s_bleRefreshTimer) { + s_bleLastConnected = bleMidi.isConnected(); + s_bleLastScanning = bleMidi.isScanning(); + s_bleLastResultCount = bleMidi.getScanResults().size(); + s_bleRefreshTimer = lv_timer_create(bleRefreshTimerCb, 300, nullptr); + } +} + +#endif // USE_BLE + /* ── App create / destroy ────────────────────────────────────────────── */ lv_obj_t * lv_CreateSettings(lv_obj_t * parent, App * a) { @@ -79,9 +422,12 @@ lv_obj_t * lv_CreateSettings(lv_obj_t * parent, App * a) { lv_obj_add_flag(content, LV_OBJ_FLAG_SCROLLABLE); lv_obj_set_scrollbar_mode(content, LV_SCROLLBAR_MODE_AUTO); - // Register PC-specific USB settings category + // Register PC-specific settings categories crosspad_gui::settings_ui_clear_extra_categories(); crosspad_gui::settings_ui_add_category({"USB / UART", LV_SYMBOL_USB, cat_build_usb}); +#ifdef USE_BLE + crosspad_gui::settings_ui_add_category({"Bluetooth MIDI", LV_SYMBOL_BLUETOOTH, cat_build_bluetooth}); +#endif // Delegate to shared settings UI in crosspad-gui crosspad_gui::settings_ui_create(content, nullptr); @@ -91,6 +437,16 @@ lv_obj_t * lv_CreateSettings(lv_obj_t * parent, App * a) { void lv_DestroySettings(lv_obj_t * obj) { (void)obj; +#ifdef USE_BLE + if (s_bleRefreshTimer) { + lv_timer_delete(s_bleRefreshTimer); + s_bleRefreshTimer = nullptr; + } + s_bleContent = nullptr; + s_bleStatusLabel = nullptr; + s_bleMidiInLabel = nullptr; + s_bleMidiOutLabel = nullptr; +#endif crosspad_gui::settings_ui_destroy(); } diff --git a/src/apps/settings/settings_app.h b/src/apps/settings/settings_app.h index 07da6ef1b..8addeca5a 100644 --- a/src/apps/settings/settings_app.h +++ b/src/apps/settings/settings_app.h @@ -8,3 +8,10 @@ lv_obj_t * lv_CreateSettings(lv_obj_t * parent, App * a); void lv_DestroySettings(lv_obj_t * obj); #endif // USE_LVGL + +#ifdef USE_BLE +#include +/// Log raw BLE MIDI messages for the settings monitor (thread-safe) +void ble_settings_log_midi_in(uint8_t status, uint8_t d1, uint8_t d2); +void ble_settings_log_midi_out(uint8_t status, uint8_t d1, uint8_t d2); +#endif diff --git a/src/apps/update/UpdateApp.cpp b/src/apps/update/UpdateApp.cpp index 65b261c27..d8728de02 100644 --- a/src/apps/update/UpdateApp.cpp +++ b/src/apps/update/UpdateApp.cpp @@ -597,6 +597,49 @@ lv_obj_t* Update_create(lv_obj_t* parent, App* a) lv_obj_add_flag(s_versionListBox, LV_OBJ_FLAG_SCROLLABLE); lv_obj_set_scrollbar_mode(s_versionListBox, LV_SCROLLBAR_MODE_AUTO); +#ifndef CROSSPAD_DEV_BUILD + /* ── Apps (release only) ─────────────────────────────────── */ + create_section_header(cont, "Apps"); + + lv_obj_t* appsInfoLabel = lv_label_create(cont); + lv_label_set_text(appsInfoLabel, + LV_SYMBOL_WARNING " App management is available in the\n" + "developer build (BUILD_TESTING=ON)."); + lv_obj_set_style_text_font(appsInfoLabel, &lv_font_montserrat_10, 0); + lv_obj_set_style_text_color(appsInfoLabel, lv_color_hex(0xFFAA33), 0); + lv_obj_set_width(appsInfoLabel, lv_pct(100)); + lv_label_set_long_mode(appsInfoLabel, LV_LABEL_LONG_WRAP); + + // List registered apps + lv_obj_t* appsInfoLabel2 = lv_label_create(cont); + lv_label_set_text(appsInfoLabel2, "Installed apps:"); + lv_obj_set_style_text_font(appsInfoLabel2, &lv_font_montserrat_10, 0); + lv_obj_set_style_text_color(appsInfoLabel2, lv_color_hex(0x888888), 0); + lv_obj_set_width(appsInfoLabel2, lv_pct(100)); + lv_obj_set_style_pad_top(appsInfoLabel2, 4, 0); + + auto& registry = crosspad::AppRegistry::getInstance(); + for (int i = 0; i < registry.getAppCount(); i++) { + const auto* entry = registry.getApp(i); + if (!entry) continue; + lv_obj_t* appRow = lv_obj_create(cont); + lv_obj_set_size(appRow, lv_pct(100), 20); + lv_obj_set_style_bg_opa(appRow, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(appRow, 0, 0); + lv_obj_set_style_pad_all(appRow, 0, 0); + lv_obj_set_style_pad_left(appRow, 8, 0); + lv_obj_remove_flag(appRow, LV_OBJ_FLAG_SCROLLABLE); + + lv_obj_t* appLbl = lv_label_create(appRow); + static char appBuf[64]; + snprintf(appBuf, sizeof(appBuf), LV_SYMBOL_OK " %s", entry->name); + lv_label_set_text(appLbl, appBuf); + lv_obj_set_style_text_font(appLbl, &lv_font_montserrat_10, 0); + lv_obj_set_style_text_color(appLbl, lv_color_hex(0xBBBBBB), 0); + lv_obj_align(appLbl, LV_ALIGN_LEFT_MID, 0, 0); + } +#endif + /* ── Timer ───────────────────────────────────────────────── */ s_updateTimer = lv_timer_create(update_timer_cb, 500, nullptr); diff --git a/src/crosspad_app.cpp b/src/crosspad_app.cpp index 04ddc7548..6ffe6f3a3 100644 --- a/src/crosspad_app.cpp +++ b/src/crosspad_app.cpp @@ -41,6 +41,7 @@ #include "crosspad-gui/components/app_launcher.h" #include "crosspad-gui/components/main_screen.h" #include "crosspad-gui/components/app_orchestrator.h" +#include "crosspad-gui/platform/IGuiPlatform.h" #include "crosspad-gui/components/volume_overlay.h" #ifdef USE_MIDI @@ -49,14 +50,24 @@ #include "crosspad/status/CrosspadStatus.hpp" #endif +#include "crosspad/midi/MidiInputHandler.hpp" + +#ifdef USE_BLE +#include "midi/PcBleMidi.hpp" +#include "apps/settings/settings_app.h" +#endif + #ifdef USE_AUDIO #include "audio/PcAudio.hpp" #include "audio/PcAudioInput.hpp" #include "crosspad-gui/components/vu_meter.h" #include "crosspad/audio/PeakMeter.hpp" #include "synth/MlPianoSynth.hpp" -#include "apps/mixer/AudioMixerEngine.hpp" -#include "apps/mixer/MixerPadLogic.hpp" +#if __has_include("crosspad-mixer/AudioMixerEngine.hpp") +#include "crosspad-mixer/AudioMixerEngine.hpp" +#include "crosspad-mixer/MixerPadLogic.hpp" +#define HAS_MIXER 1 +#endif #include #endif @@ -76,21 +87,34 @@ static lv_obj_t* app_c = nullptr; static lv_obj_t* s_lcdContainer = nullptr; static Stm32EmuWindow stm32Emu; +// Central MIDI router — distributes pad output to USB + BLE + STM32 +static crosspad::MidiInputHandler s_midiHandler; + +namespace crosspad { +MidiInputHandler& getMidiInputHandler() { return s_midiHandler; } +} + #ifdef USE_MIDI static PcMidi midi; static crosspad::Stm32MessageHandler stm32Handler; extern CrosspadStatus status; #endif +#ifdef USE_BLE +PcBleMidi bleMidi; +#endif + #ifdef USE_AUDIO static PcAudioOutput pcAudio; // OUT1 static PcAudioOutput pcAudio2; // OUT2 static PcAudioInput pcAudioIn1; // IN1 static PcAudioInput pcAudioIn2; // IN2 static MlPianoSynth fmSynth; +#ifdef HAS_MIXER static AudioMixerEngine s_mixerEngine; static std::shared_ptr s_mixerPadLogic; #endif +#endif /* ── Virtual USB/UART ─────────────────────────────────────────────────── */ @@ -108,6 +132,8 @@ struct DevicePreferences { std::string sdcardPath; std::string uartPort; uint32_t uartBaud = 115200; + std::string bleDevice; // Last connected BLE MIDI device address + uint8_t bleMode = 0; // 0=Host, 1=Server }; static DevicePreferences s_devicePrefs; @@ -146,6 +172,8 @@ static void loadDevicePrefs() { if (doc["sdcard_path"].is()) s_devicePrefs.sdcardPath = doc["sdcard_path"].as(); if (doc["uart_port"].is()) s_devicePrefs.uartPort = doc["uart_port"].as(); if (doc["uart_baud"].is()) s_devicePrefs.uartBaud = doc["uart_baud"].as(); + if (doc["ble_device"].is()) s_devicePrefs.bleDevice = doc["ble_device"].as(); + if (doc["ble_mode"].is()) s_devicePrefs.bleMode = doc["ble_mode"].as(); printf("[DevPrefs] Loaded: out1='%s' out2='%s' in1='%s' in2='%s' midiOut='%s' midiIn='%s' sd='%s' uart='%s@%u'\n", s_devicePrefs.audioOut1.c_str(), s_devicePrefs.audioOut2.c_str(), @@ -167,6 +195,8 @@ static void saveDevicePrefs() { doc["sdcard_path"] = s_devicePrefs.sdcardPath; doc["uart_port"] = s_devicePrefs.uartPort; doc["uart_baud"] = s_devicePrefs.uartBaud; + doc["ble_device"] = s_devicePrefs.bleDevice; + doc["ble_mode"] = s_devicePrefs.bleMode; std::ofstream f(path); if (!f.is_open()) { @@ -279,10 +309,26 @@ static crosspad_gui::ILvglApp* pc_app_factory( return new App(container, name, icon, createLVGL, destroyLVGL); } +/// Resolve short icon names (e.g. "info.png") to full asset paths. +/// Icons that already contain a path separator are left unchanged. +static std::vector s_resolvedIcons; +static const char* pc_icon_resolver(size_t /*index*/, const char* icon) { + if (!icon || !*icon) return icon; + // Already a full path? + if (strchr(icon, '/') || strchr(icon, '\\')) return icon; + // LVGL symbols (UTF-8 private use area, start with 0xEF) + if ((unsigned char)icon[0] >= 0xC0) return icon; + // Prepend asset prefix + std::string resolved = crosspad_gui::getGuiPlatform().assetPathPrefix(); + resolved += icon; + s_resolvedIcons.push_back(std::move(resolved)); + return s_resolvedIcons.back().c_str(); +} + static void InitializeOrchestrator() { crosspad_gui::OrchestratorConfig config; config.app_factory = pc_app_factory; - // PC has no pre-launch hook (no kit selector) and no icon resolver needed + config.icon_resolver = pc_icon_resolver; crosspad_gui::AppOrchestrator::getInstance().init(config); } @@ -366,6 +412,12 @@ void crosspad_app_init() lv_obj_remove_flag(overlayLayer, (lv_obj_flag_t)(LV_OBJ_FLAG_CLICKABLE | LV_OBJ_FLAG_SCROLLABLE)); crosspad_gui::setOverlayParent(overlayLayer); + // Init MidiInputHandler — central routing brain (same pattern as ESP32). + // Routes pad output to USB + BLE + STM32 based on KeypadSettings flags. + s_midiHandler.init(crosspad::getPadManager(), crosspad::getEventBus(), + crosspad::CrosspadSettings::getInstance()); + crosspad::getPlatformServices().setMidiOutput(&s_midiHandler); + #ifdef USE_MIDI // Initialize STM32 message handler stm32Handler.init(crosspad::getPadManager(), status); @@ -376,36 +428,40 @@ void crosspad_app_init() int inPort = findMidiPortByName(s_devicePrefs.midiIn, false); if (outPort >= 0 && inPort >= 0) { - // Both saved prefs found — connect to those specific ports midi.begin((unsigned)outPort, (unsigned)inPort); midi.setAutoConnectKeyword("CrossPad"); printf("[MIDI] Connected from saved prefs: out=%d in=%d\n", outPort, inPort); } else { - // Missing or stale prefs — use auto-connect keyword midi.beginAutoConnect("CrossPad"); } - crosspad::getPlatformServices().setMidiOutput(&midi); + s_midiHandler.setUsbOutput(&midi); } midi.setHandleNoteOn([](uint8_t channel, uint8_t note, uint8_t velocity) { printf("[MIDI IN] NoteOn ch=%u note=%u vel=%u\n", channel + 1, note, velocity); - auto& pm = crosspad::getPadManager(); - uint8_t padIdx = pm.getPadForMidiNote(note); - if (padIdx < 16) { - pm.handlePadPress(padIdx, velocity); - } else { - pm.handleMidiNoteOn(channel, note, velocity); - } + struct D { uint8_t ch, note, vel; }; + auto* d = new D{channel, note, velocity}; + lv_async_call([](void* ud) { + auto* n = static_cast(ud); + auto& pm = crosspad::getPadManager(); + uint8_t padIdx = pm.getPadForMidiNote(n->note); + if (padIdx < 16) pm.handlePadPress(padIdx, n->vel); + else pm.handleMidiNoteOn(n->ch, n->note, n->vel); + delete n; + }, d); }); midi.setHandleNoteOff([](uint8_t channel, uint8_t note, uint8_t velocity) { printf("[MIDI IN] NoteOff ch=%u note=%u vel=%u\n", channel + 1, note, velocity); - auto& pm = crosspad::getPadManager(); - uint8_t padIdx = pm.getPadForMidiNote(note); - if (padIdx < 16) { - pm.handlePadRelease(padIdx); - } else { - pm.handleMidiNoteOff(channel, note); - } + struct D { uint8_t ch, note; }; + auto* d = new D{channel, note}; + lv_async_call([](void* ud) { + auto* n = static_cast(ud); + auto& pm = crosspad::getPadManager(); + uint8_t padIdx = pm.getPadForMidiNote(n->note); + if (padIdx < 16) pm.handlePadRelease(padIdx); + else pm.handleMidiNoteOff(n->ch, n->note); + delete n; + }, d); }); midi.setHandleControlChange([](uint8_t channel, uint8_t cc, uint8_t value) { printf("[MIDI IN] CC ch=%u cc=%u val=%u\n", channel + 1, cc, value); @@ -444,6 +500,83 @@ void crosspad_app_init() }, 3000, nullptr); #endif +#ifdef USE_BLE + // ── BLE MIDI — same pattern as USB MIDI ── + { + auto* settings = crosspad::CrosspadSettings::getInstance(); + // Use saved mode from device prefs if available + if (s_devicePrefs.bleMode <= 1) { + settings->wireless.bleMidiMode = s_devicePrefs.bleMode; + } + auto mode = settings->wireless.bleMidiMode == 0 + ? crosspad::BleMidiMode::Host : crosspad::BleMidiMode::Server; + + bleMidi.setNoteOffset(settings->wireless.bleMidiNoteOffset); + bleMidi.begin(mode); + + // Wire into routing system + crosspad::getPlatformServices().setBleMidi(&bleMidi); + s_midiHandler.setBleOutput(&bleMidi); // Route pad output to BLE + + // Input callbacks — dispatch to LVGL thread via lv_async_call. + // RtMidi callbacks run on a separate thread, but PadManager/LED updates + // touch LVGL which is NOT thread-safe. Must dispatch to the LVGL task. + // Echo is already filtered by PcBleMidi's anti-loopback ring buffer. + bleMidi.setHandleNoteOn([](uint8_t channel, uint8_t note, uint8_t velocity) { + printf("[BLE IN] NoteOn ch=%u note=%u vel=%u\n", channel + 1, note, velocity); + ble_settings_log_midi_in(0x90 | channel, note, velocity); + + struct NoteData { uint8_t ch; uint8_t note; uint8_t vel; }; + auto* d = new NoteData{channel, note, velocity}; + lv_async_call([](void* ud) { + auto* n = static_cast(ud); + auto& pm = crosspad::getPadManager(); + uint8_t padIdx = pm.getPadForMidiNote(n->note); + if (padIdx < 16) + pm.handlePadPress(padIdx, n->vel); + else + pm.handleMidiNoteOn(n->ch, n->note, n->vel); + delete n; + }, d); + }); + bleMidi.setHandleNoteOff([](uint8_t channel, uint8_t note, uint8_t velocity) { + printf("[BLE IN] NoteOff ch=%u note=%u vel=%u\n", channel + 1, note, velocity); + ble_settings_log_midi_in(0x80 | channel, note, velocity); + + struct NoteData { uint8_t ch; uint8_t note; }; + auto* d = new NoteData{channel, note}; + lv_async_call([](void* ud) { + auto* n = static_cast(ud); + auto& pm = crosspad::getPadManager(); + uint8_t padIdx = pm.getPadForMidiNote(n->note); + if (padIdx < 16) + pm.handlePadRelease(padIdx); + else + pm.handleMidiNoteOff(n->ch, n->note); + delete n; + }, d); + }); + bleMidi.setHandleControlChange([](uint8_t channel, uint8_t cc, uint8_t value) { + printf("[BLE IN] CC ch=%u cc=%u val=%u\n", channel + 1, cc, value); + ble_settings_log_midi_in(0xB0 | channel, cc, value); + }); + + // Connection state → status bar bluetooth icon + persist device address + bleMidi.setOnConnectionChanged([](bool connected, const crosspad::BleMidiDevice& dev) { + printf("[BLE MIDI] %s: %s (%s)\n", + connected ? "Connected" : "Disconnected", + dev.name.c_str(), dev.address.c_str()); + auto* st = crosspad::getPlatformServices().status; + if (st) st->bluetoothConnected = connected; + + if (connected) { + s_devicePrefs.bleDevice = dev.address; + saveDevicePrefs(); + } + }); + } +#endif + #ifdef USE_AUDIO // ── Audio OUT1: auto-connect saved device or default ── { @@ -506,20 +639,21 @@ void crosspad_app_init() fmSynth.init(); crosspad::getPlatformServices().setSynthEngine(&fmSynth); +#ifdef HAS_MIXER // Load mixer state from preferences (or set defaults) s_mixerEngine.loadState(getMixerStatePath()); // Register mixer pad logic globally (always available) s_mixerPadLogic = std::make_shared(s_mixerEngine); s_mixerPadLogic->setOnStateChanged([]() { - // Save mixer state on every pad-driven change s_mixerEngine.saveState(getMixerStatePath()); }); crosspad::getPadManager().registerPadLogic("Mixer", s_mixerPadLogic); crosspad::getPadManager().setActivePadLogic("Mixer"); - // Start the mixer engine (replaces the old default synth-only audio thread) + // Start the mixer engine s_mixerEngine.start(); +#endif // Save preferences now that we know actual connected device names saveDevicePrefs(); @@ -948,14 +1082,18 @@ PcAudioOutput* pc_platform_get_audio_output(int index) return nullptr; } +#ifdef HAS_MIXER AudioMixerEngine& getMixerEngine() { return s_mixerEngine; } +#endif void pc_platform_save_mixer_state() { +#ifdef HAS_MIXER s_mixerEngine.saveState(getMixerStatePath()); +#endif } #else PcAudioOutput* pc_platform_get_audio_output(int /*index*/) { return nullptr; } diff --git a/src/midi/PcBleMidi.cpp b/src/midi/PcBleMidi.cpp new file mode 100644 index 000000000..62f4e48e0 --- /dev/null +++ b/src/midi/PcBleMidi.cpp @@ -0,0 +1,537 @@ +/** + * @file PcBleMidi.cpp + * @brief BLE MIDI for Linux: SimpleBLE for connection, RtMidi/ALSA for data. + * + * On Linux, after SimpleBLE connects a BLE MIDI device, the kernel's btmidi + * module creates an ALSA sequencer port. We open that port via RtMidi for + * MIDI I/O — same mechanism as USB MIDI, no GATT characteristic access needed. + */ + +#include "PcBleMidi.hpp" +#include "apps/settings/settings_app.h" + +#include +#include +#include +#include +#include +#include + +/* ── Helpers ─────────────────────────────────────────────────────────── */ + +static bool uuidMatch(const std::string& a, const std::string& b) { + if (a.size() != b.size()) return false; + for (size_t i = 0; i < a.size(); i++) { + if (std::tolower(static_cast(a[i])) != + std::tolower(static_cast(b[i]))) + return false; + } + return true; +} + +/* ── Constructor / Destructor ────────────────────────────────────────── */ + +PcBleMidi::PcBleMidi() { + try { + midiOut_ = std::make_unique(); + midiIn_ = std::make_unique(); + } catch (const RtMidiError& e) { + printf("[BLE MIDI] RtMidi init error: %s\n", e.what()); + } +} + +PcBleMidi::~PcBleMidi() { + end(); +} + +/* ── Lifecycle ───────────────────────────────────────────────────────── */ + +bool PcBleMidi::initAdapter() { + if (adapter_) return true; + + auto adapters = SimpleBLE::Adapter::get_adapters(); + if (adapters.empty()) { + printf("[BLE MIDI] No Bluetooth adapters found\n"); + return false; + } + + adapter_ = std::make_unique(std::move(adapters[0])); + printf("[BLE MIDI] Using adapter: %s [%s]\n", + adapter_->identifier().c_str(), + adapter_->address().c_str()); + return true; +} + +bool PcBleMidi::begin(crosspad::BleMidiMode mode) { + end(); + mode_ = mode; + + if (!initAdapter()) return false; + + initialized_.store(true); + printf("[BLE MIDI] Started in %s mode\n", + mode == crosspad::BleMidiMode::Host ? "Host (Central)" : "Server (Peripheral)"); + + if (mode == crosspad::BleMidiMode::Server) { + printf("[BLE MIDI] Server mode is not yet supported on PC\n"); + return false; + } + return true; +} + +void PcBleMidi::end() { + stopScan(); + disconnect(); + if (scanThread_.joinable()) scanThread_.join(); + adapter_.reset(); + initialized_.store(false); +} + +bool PcBleMidi::isConnected() const { + return connected_.load(); +} + +crosspad::BleMidiMode PcBleMidi::getMode() const { + return mode_; +} + +/* ── Host mode: Scan ─────────────────────────────────────────────────── */ + +void PcBleMidi::startScan(uint16_t durationMs) { + if (!initialized_.load() || scanning_.load()) return; + if (mode_ != crosspad::BleMidiMode::Host) return; + + { + std::lock_guard lock(scanMutex_); + scanResults_.clear(); + scannedPeripherals_.clear(); + } + + scanning_.store(true); + printf("[BLE MIDI] Scanning for %u ms...\n", durationMs); + + if (scanThread_.joinable()) scanThread_.join(); + + scanThread_ = std::thread([this, durationMs]() { + try { + adapter_->set_callback_on_scan_found([this](SimpleBLE::Peripheral peripheral) { + bool hasMidiService = false; + for (auto& svc : peripheral.services()) { + if (uuidMatch(svc.uuid(), MIDI_SERVICE_UUID)) { + hasMidiService = true; + break; + } + } + if (!hasMidiService) return; + + crosspad::BleMidiDevice dev; + dev.name = peripheral.identifier(); + dev.address = peripheral.address(); + dev.rssi = peripheral.rssi(); + dev.connected = false; + if (dev.name.empty()) dev.name = "BLE MIDI Device"; + + printf("[BLE MIDI] Found: %s (%s) RSSI=%d\n", + dev.name.c_str(), dev.address.c_str(), dev.rssi); + + std::lock_guard lock(scanMutex_); + auto it = std::find_if(scanResults_.begin(), scanResults_.end(), + [&](const crosspad::BleMidiDevice& d) { return d.address == dev.address; }); + if (it == scanResults_.end()) { + scanResults_.push_back(std::move(dev)); + scannedPeripherals_.push_back(peripheral); + } else { + size_t idx = std::distance(scanResults_.begin(), it); + *it = std::move(dev); + scannedPeripherals_[idx] = peripheral; + } + }); + + adapter_->scan_for(durationMs); + } catch (const std::exception& e) { + printf("[BLE MIDI] Scan error: %s\n", e.what()); + } + + scanning_.store(false); + printf("[BLE MIDI] Scan complete\n"); + + std::lock_guard lock(scanMutex_); + dispatchScanResults(scanResults_); + }); +} + +void PcBleMidi::stopScan() { + if (!initialized_.load() || !scanning_.load()) return; + try { adapter_->scan_stop(); } catch (...) {} +} + +bool PcBleMidi::isScanning() const { + return scanning_.load(); +} + +std::vector PcBleMidi::getScanResults() const { + std::lock_guard lock(scanMutex_); + return scanResults_; +} + +/* ── Host mode: Connect ──────────────────────────────────────────────── */ + +bool PcBleMidi::connectToDevice(const std::string& address) { + if (!initialized_.load()) return false; + if (connected_.load()) disconnect(); + + printf("[BLE MIDI] Connecting to %s...\n", address.c_str()); + + // Stop any active scan + if (scanning_.load()) stopScan(); + if (scanThread_.joinable()) scanThread_.join(); + + try { + // On Linux, BLE MIDI is managed by the system Bluetooth daemon (bluez-midi / + // pipewire-midi-bridge). When a device is already connected, the daemon + // creates an ALSA sequencer port. We should NOT unpair or re-connect — + // that disrupts the daemon's GATT notification subscription. + // + // Strategy: + // 1. Check if ALSA port already exists → just open it + // 2. If not, connect via SimpleBLE → wait for daemon to create port → open it + + std::string deviceName; + { + std::lock_guard lock(scanMutex_); + for (auto& d : scanResults_) { + if (d.address == address) { deviceName = d.name; break; } + } + } + if (deviceName.empty()) deviceName = "Crosspad"; + + // Step 1: Try opening existing ALSA port (device may already be connected) + if (openMidiPorts(deviceName)) { + printf("[BLE MIDI] Using existing ALSA port for %s\n", deviceName.c_str()); + } else { + // Step 2: Connect via SimpleBLE (triggers daemon to create ALSA port) + auto peripherals = adapter_->scan_get_results(); + SimpleBLE::Peripheral* targetPtr = nullptr; + for (auto& p : peripherals) { + if (p.address() == address) { targetPtr = &p; break; } + } + + if (!targetPtr) { + printf("[BLE MIDI] Re-scanning...\n"); + adapter_->scan_for(3000); + peripherals = adapter_->scan_get_results(); + for (auto& p : peripherals) { + if (p.address() == address) { targetPtr = &p; break; } + } + } + + if (!targetPtr) { + printf("[BLE MIDI] Device %s not found\n", address.c_str()); + return false; + } + + printf("[BLE MIDI] Connecting via SimpleBLE...\n"); + targetPtr->connect(); + + if (!targetPtr->is_connected()) { + printf("[BLE MIDI] Connection failed\n"); + return false; + } + + peripheral_ = std::make_unique(std::move(*targetPtr)); + + peripheral_->set_callback_on_disconnected([this]() { + connected_.store(false); + closeMidiPorts(); + printf("[BLE MIDI] Disconnected\n"); + crosspad::BleMidiDevice dev; + { + std::lock_guard lock(connMutex_); + dev = connectedDevice_; + connectedDevice_.connected = false; + } + dispatchConnectionChanged(false, dev); + }); + + // Wait for ALSA port to appear + printf("[BLE MIDI] Waiting for ALSA MIDI port...\n"); + bool portFound = false; + for (int attempt = 0; attempt < 20; attempt++) { + std::this_thread::sleep_for(std::chrono::milliseconds(500)); + if (openMidiPorts(deviceName)) { portFound = true; break; } + if (attempt < 3 || attempt % 5 == 0) + printf("[BLE MIDI] ALSA port not yet available (attempt %d)\n", attempt + 1); + } + + if (!portFound) { + printf("[BLE MIDI] ALSA MIDI port not found after 10s\n"); + } + } + + // Update state + { + std::lock_guard lock(connMutex_); + connectedDevice_.name = deviceName; + connectedDevice_.address = address; + connectedDevice_.connected = true; + } + + connected_.store(true); + printf("[BLE MIDI] Connected to %s (%s)\n", deviceName.c_str(), address.c_str()); + dispatchConnectionChanged(true, connectedDevice_); + return true; + + } catch (const std::exception& e) { + printf("[BLE MIDI] Connect error: %s\n", e.what()); + return false; + } +} + +/* ── RtMidi port management ──────────────────────────────────────────── */ + +bool PcBleMidi::openMidiPorts(const std::string& deviceName) { + if (!midiOut_ || !midiIn_) return false; + + // Search for ALSA port matching the BLE device name + unsigned int outCount = midiOut_->getPortCount(); + unsigned int inCount = midiIn_->getPortCount(); + + int outPort = -1, inPort = -1; + + for (unsigned int i = 0; i < outCount; i++) { + std::string name = midiOut_->getPortName(i); + // Match by device name substring (case-insensitive-ish) + if (name.find(deviceName) != std::string::npos || + name.find("Bluetooth") != std::string::npos) { + outPort = i; + printf("[BLE MIDI] Found ALSA OUT port [%u]: %s\n", i, name.c_str()); + break; + } + } + + for (unsigned int i = 0; i < inCount; i++) { + std::string name = midiIn_->getPortName(i); + if (name.find(deviceName) != std::string::npos || + name.find("Bluetooth") != std::string::npos) { + inPort = i; + printf("[BLE MIDI] Found ALSA IN port [%u]: %s\n", i, name.c_str()); + break; + } + } + + if (outPort < 0 && inPort < 0) return false; + + try { + if (outPort >= 0 && !midiOut_->isPortOpen()) { + midiOut_->openPort(outPort, "CrossPad BLE Out"); + printf("[BLE MIDI] ALSA OUT opened\n"); + } + if (inPort >= 0 && !midiIn_->isPortOpen()) { + midiIn_->openPort(inPort, "CrossPad BLE In"); + midiIn_->setCallback(rtMidiCallback, this); + midiIn_->ignoreTypes(true, true, true); // ignore sysex, timing, sensing + printf("[BLE MIDI] ALSA IN opened — receiving MIDI data\n"); + } + midiPortOpen_ = true; + return true; + } catch (const RtMidiError& e) { + printf("[BLE MIDI] RtMidi port error: %s\n", e.what()); + return false; + } +} + +void PcBleMidi::closeMidiPorts() { + { + std::lock_guard lock(outMutex_); + midiPortOpen_ = false; + try { + if (midiOut_ && midiOut_->isPortOpen()) midiOut_->closePort(); + } catch (...) {} + } + try { + if (midiIn_ && midiIn_->isPortOpen()) midiIn_->closePort(); + } catch (...) {} +} + +/* ── RtMidi input callback ───────────────────────────────────────────── */ + +void PcBleMidi::rtMidiCallback(double, std::vector* message, void* userData) { + auto* self = static_cast(userData); + if (message && !message->empty()) { + // Debug: log every raw byte from ALSA + printf("[BLE MIDI RAW] %zu bytes:", message->size()); + for (auto b : *message) printf(" %02X", b); + printf("\n"); + self->handleMidiMessage(*message); + } +} + +void PcBleMidi::recordSent(uint8_t status, uint8_t d1, uint8_t d2) { + std::lock_guard lock(echoMutex_); + auto& slot = echoBuf_[echoHead_ % ECHO_BUF_SIZE]; + slot.d[0] = status; slot.d[1] = d1; slot.d[2] = d2; + slot.ts = std::chrono::steady_clock::now(); + slot.active = true; + echoHead_++; +} + +bool PcBleMidi::isEcho(const std::vector& msg) { + if (msg.size() < 3) return false; + std::lock_guard lock(echoMutex_); + auto now = std::chrono::steady_clock::now(); + for (size_t i = 0; i < ECHO_BUF_SIZE; i++) { + auto& slot = echoBuf_[i]; + if (!slot.active) continue; + auto age = std::chrono::duration_cast(now - slot.ts).count(); + if (age > ECHO_WINDOW_MS) { slot.active = false; continue; } + if (msg[0] == slot.d[0] && msg[1] == slot.d[1] && msg[2] == slot.d[2]) { + slot.active = false; + return true; + } + } + return false; +} + +void PcBleMidi::handleMidiMessage(std::vector& msg) { + if (msg.size() < 2) return; + + // Filter loopback: ALSA may route our own output back to our input + if (isEcho(msg)) { + printf("[BLE MIDI] Echo filtered: %02X", msg[0]); + for (size_t i = 1; i < msg.size(); i++) printf(" %02X", msg[i]); + printf("\n"); + return; + } + printf("[BLE MIDI] RX: %02X", msg[0]); + for (size_t i = 1; i < msg.size(); i++) printf(" %02X", msg[i]); + printf("\n"); + + uint8_t status = msg[0]; + uint8_t statusType = status & 0xF0; + uint8_t channel = status & 0x0F; + + switch (statusType) { + case 0x90: // Note On + if (msg.size() >= 3) { + uint8_t note = msg[1], vel = msg[2]; + if (vel == 0) + dispatchNoteOff(channel, note, 0); + else + dispatchNoteOn(channel, note, vel); + } + break; + case 0x80: // Note Off + if (msg.size() >= 3) + dispatchNoteOff(channel, msg[1], msg[2]); + break; + case 0xB0: // CC + if (msg.size() >= 3) + dispatchCC(channel, msg[1], msg[2]); + break; + default: + break; + } +} + +/* ── Server mode (stub) ──────────────────────────────────────────────── */ + +void PcBleMidi::startAdvertising(const std::string& deviceName) { + (void)deviceName; + printf("[BLE MIDI] Server mode not yet supported on PC\n"); +} + +void PcBleMidi::stopAdvertising() {} +bool PcBleMidi::isAdvertising() const { return false; } + +/* ── Common ──────────────────────────────────────────────────────────── */ + +void PcBleMidi::disconnect() { + closeMidiPorts(); + + if (connected_.load()) { + try { + if (peripheral_ && peripheral_->is_connected()) + peripheral_->disconnect(); + } catch (const std::exception& e) { + printf("[BLE MIDI] Disconnect error: %s\n", e.what()); + } + } + + peripheral_.reset(); + connected_.store(false); + + crosspad::BleMidiDevice dev; + { + std::lock_guard lock(connMutex_); + dev = connectedDevice_; + connectedDevice_.connected = false; + } + dispatchConnectionChanged(false, dev); +} + +crosspad::BleMidiDevice PcBleMidi::getConnectedDevice() const { + std::lock_guard lock(connMutex_); + return connectedDevice_; +} + +/* ── IMidiOutput (send via RtMidi/ALSA) ──────────────────────────────── */ + +void PcBleMidi::sendRawMidi(uint8_t status, uint8_t d1, uint8_t d2) { + std::lock_guard lock(outMutex_); + if (!midiPortOpen_ || !midiOut_ || !midiOut_->isPortOpen()) return; + + try { + recordSent(status, d1, d2); + std::vector msg = {status, d1, d2}; + midiOut_->sendMessage(&msg); + ble_settings_log_midi_out(status, d1, d2); + } catch (const RtMidiError& e) { + printf("[BLE MIDI] Send error: %s\n", e.what()); + } +} + +void PcBleMidi::sendRawMidi(uint8_t status, uint8_t d1) { + std::lock_guard lock(outMutex_); + if (!midiPortOpen_ || !midiOut_ || !midiOut_->isPortOpen()) return; + + try { + std::vector msg = {status, d1}; + midiOut_->sendMessage(&msg); + ble_settings_log_midi_out(status, d1, 0); + } catch (const RtMidiError& e) { + printf("[BLE MIDI] Send error: %s\n", e.what()); + } +} + +void PcBleMidi::sendNoteOn(uint8_t note, uint8_t channel) { + sendNoteOn(note, 127, channel); +} + +void PcBleMidi::sendNoteOff(uint8_t note, uint8_t channel) { + sendNoteOff(note, 0, channel); +} + +void PcBleMidi::sendNoteOn(uint8_t note, uint8_t velocity, uint8_t channel) { + sendRawMidi(0x90 | (channel & 0x0F), note & 0x7F, velocity & 0x7F); +} + +void PcBleMidi::sendNoteOff(uint8_t note, uint8_t velocity, uint8_t channel) { + sendRawMidi(0x80 | (channel & 0x0F), note & 0x7F, velocity & 0x7F); +} + +void PcBleMidi::sendControlChange(uint8_t controller, uint8_t value, uint8_t channel) { + sendRawMidi(0xB0 | (channel & 0x0F), controller & 0x7F, value & 0x7F); +} + +void PcBleMidi::sendAftertouch(uint8_t note, uint8_t pressure, uint8_t channel) { + sendRawMidi(0xA0 | (channel & 0x0F), note & 0x7F, pressure & 0x7F); +} + +void PcBleMidi::sendProgramChange(uint8_t program, uint8_t channel) { + sendRawMidi(0xC0 | (channel & 0x0F), program & 0x7F); +} + +void PcBleMidi::sendPitchBend(int16_t value, uint8_t channel) { + uint16_t bend14 = static_cast(value + 8192); + sendRawMidi(0xE0 | (channel & 0x0F), bend14 & 0x7F, (bend14 >> 7) & 0x7F); +} diff --git a/src/midi/PcBleMidi.hpp b/src/midi/PcBleMidi.hpp new file mode 100644 index 000000000..13359e8f1 --- /dev/null +++ b/src/midi/PcBleMidi.hpp @@ -0,0 +1,144 @@ +#pragma once + +/** + * @file PcBleMidi.hpp + * @brief SimpleBLE-backed BLE MIDI implementation for PC simulator. + * + * Mirrors PcMidi structure: implements crosspad::IBleMidi (which extends + * IMidiOutput) for integration with MidiInputHandler routing. Uses + * SimpleBLE library for cross-platform BLE access (Linux/Mac/Windows). + * + * Host mode (Central): scan for BLE MIDI peripherals, connect, subscribe + * to MIDI characteristic notifications, send MIDI via characteristic writes. + * + * Server mode (Peripheral): advertise as BLE MIDI device — stubbed for now + * (SimpleBLE peripheral support is limited on some platforms). + */ + +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +// Forward declarations — avoid SimpleBLE header pollution +namespace SimpleBLE { + class Adapter; + class Peripheral; +} + +/** + * On Linux, BLE MIDI data flows through ALSA (kernel btmidi module), + * NOT through SimpleBLE GATT. After SimpleBLE connects a BLE MIDI device, + * the kernel creates an ALSA sequencer port. We open that port via RtMidi + * for actual MIDI data I/O — same mechanism as USB MIDI. + * + * SimpleBLE is used only for: scan, connect, disconnect. + * RtMidi is used for: MIDI input/output data. + */ +class PcBleMidi : public crosspad::IBleMidi { +public: + PcBleMidi(); + ~PcBleMidi() override; + + PcBleMidi(const PcBleMidi&) = delete; + PcBleMidi& operator=(const PcBleMidi&) = delete; + + // ── IBleMidi lifecycle ────────────────────────────────────────── + bool begin(crosspad::BleMidiMode mode) override; + void end() override; + bool isConnected() const override; + crosspad::BleMidiMode getMode() const override; + + // ── Host mode (Central) ───────────────────────────────────────── + void startScan(uint16_t durationMs) override; + void stopScan() override; + bool isScanning() const override; + std::vector getScanResults() const override; + bool connectToDevice(const std::string& address) override; + + // ── Server mode (Peripheral) — stub ───────────────────────────── + void startAdvertising(const std::string& deviceName) override; + void stopAdvertising() override; + bool isAdvertising() const override; + + // ── Common ────────────────────────────────────────────────────── + void disconnect() override; + crosspad::BleMidiDevice getConnectedDevice() const override; + + // ── IMidiOutput (send MIDI over BLE via RtMidi/ALSA) ──────────── + void sendNoteOn(uint8_t note, uint8_t channel) override; + void sendNoteOff(uint8_t note, uint8_t channel) override; + void sendNoteOn(uint8_t note, uint8_t velocity, uint8_t channel) override; + void sendNoteOff(uint8_t note, uint8_t velocity, uint8_t channel) override; + void sendControlChange(uint8_t controller, uint8_t value, uint8_t channel) override; + void sendAftertouch(uint8_t note, uint8_t pressure, uint8_t channel) override; + void sendProgramChange(uint8_t program, uint8_t channel) override; + void sendPitchBend(int16_t value, uint8_t channel) override; + +private: + // BLE MIDI Service UUID (for scan filtering) + static constexpr const char* MIDI_SERVICE_UUID = "03b80e5a-ede8-4b33-a751-6ce34ec4c700"; + + // SimpleBLE objects (scan + connection management only) + std::unique_ptr adapter_; + std::unique_ptr peripheral_; + + // RtMidi objects (MIDI data I/O via ALSA on Linux) + std::unique_ptr midiOut_; + std::unique_ptr midiIn_; + bool midiPortOpen_ = false; + + // Anti-loopback: time-based filter. ALSA may route our own output + // back to our input. Only suppress if identical message arrives + // within ECHO_WINDOW_MS of sending. + static constexpr int ECHO_WINDOW_MS = 50; + static constexpr size_t ECHO_BUF_SIZE = 8; + struct EchoEntry { + uint8_t d[3]; + std::chrono::steady_clock::time_point ts; + bool active = false; + }; + EchoEntry echoBuf_[ECHO_BUF_SIZE] = {}; + size_t echoHead_ = 0; + std::mutex echoMutex_; + void recordSent(uint8_t status, uint8_t d1, uint8_t d2); + bool isEcho(const std::vector& msg); + + // State + crosspad::BleMidiMode mode_ = crosspad::BleMidiMode::Host; + std::atomic connected_{false}; + std::atomic scanning_{false}; + std::atomic initialized_{false}; + + // Scan results + mutable std::mutex scanMutex_; + std::vector scanResults_; + std::vector scannedPeripherals_; + std::thread scanThread_; + + // Connected device info + mutable std::mutex connMutex_; + crosspad::BleMidiDevice connectedDevice_; + + // Thread-safe output + std::mutex outMutex_; + + // RtMidi helpers + bool openMidiPorts(const std::string& deviceName); + void closeMidiPorts(); + void sendRawMidi(uint8_t status, uint8_t d1, uint8_t d2); + void sendRawMidi(uint8_t status, uint8_t d1); + + // RtMidi input callback + static void rtMidiCallback(double timestamp, std::vector* message, void* userData); + void handleMidiMessage(std::vector& message); + + // SimpleBLE adapter init + bool initAdapter(); +}; diff --git a/src/pc_stubs/PcHttpClient.cpp b/src/pc_stubs/PcHttpClient.cpp index 2abfd8dfa..732fc59d0 100644 --- a/src/pc_stubs/PcHttpClient.cpp +++ b/src/pc_stubs/PcHttpClient.cpp @@ -202,10 +202,129 @@ void pc_http_client_init() { printf("[PC] HTTP client initialized (WinHTTP)\n"); } -#else // Linux/Mac — no WinHTTP, use NullHttpClient default +#else // Linux/Mac — use curl command-line + +#include +#include +#include +#include + +namespace { + +class PcHttpClient : public crosspad::IHttpClient { +public: + bool isAvailable() const override { + return system("command -v curl > /dev/null 2>&1") == 0; + } + + crosspad::HttpResponse get(const crosspad::HttpRequest& request) override { + return perform(request, "GET"); + } + + crosspad::HttpResponse post(const crosspad::HttpRequest& request) override { + return perform(request, "POST"); + } + +private: + /// Shell-escape for single-quoted strings (replace ' with '\'') + static std::string shellEscape(const std::string& s) { + std::string out; + out.reserve(s.size() + 8); + for (char c : s) { + if (c == '\'') out += "'\\''"; + else out += c; + } + return out; + } + + crosspad::HttpResponse perform(const crosspad::HttpRequest& request, + const char* method) + { + crosspad::HttpResponse resp; + + // Temp file for response body + char tmpPath[] = "/tmp/crosspad_http_XXXXXX"; + int fd = mkstemp(tmpPath); + if (fd < 0) { + resp.errorMessage = "Failed to create temp file"; + return resp; + } + close(fd); + + // Build curl command: body → temp file, status code → stdout + std::string cmd = "curl -s -L"; + cmd += " -X "; + cmd += method; + cmd += " -o '"; + cmd += tmpPath; + cmd += "' -w '%{http_code}'"; + cmd += " --max-time "; + cmd += std::to_string(std::max(1u, request.timeoutMs / 1000)); + + for (const auto& h : request.headers) { + cmd += " -H '"; + cmd += shellEscape(h.first); + cmd += ": "; + cmd += shellEscape(h.second); + cmd += "'"; + } + + if (!request.contentType.empty()) { + cmd += " -H 'Content-Type: "; + cmd += shellEscape(request.contentType); + cmd += "'"; + } + + if (!request.body.empty()) { + cmd += " --data-raw '"; + cmd += shellEscape(request.body); + cmd += "'"; + } + + cmd += " '"; + cmd += shellEscape(request.url); + cmd += "' 2>/dev/null"; + + FILE* pipe = popen(cmd.c_str(), "r"); + if (!pipe) { + unlink(tmpPath); + resp.errorMessage = "Failed to execute curl"; + return resp; + } + + // curl -w '%{http_code}' prints the status code to stdout + char statusBuf[16] = {}; + if (fgets(statusBuf, sizeof(statusBuf), pipe)) { + resp.statusCode = atoi(statusBuf); + } + pclose(pipe); + + // Read body from temp file + { + std::ifstream f(tmpPath, std::ios::binary); + if (f.is_open()) { + std::ostringstream ss; + ss << f.rdbuf(); + resp.body = ss.str(); + } + } + unlink(tmpPath); + + if (resp.statusCode == 0) { + resp.errorMessage = "curl request failed"; + } + + return resp; + } +}; + +static PcHttpClient s_pcHttpClient; + +} // anonymous namespace void pc_http_client_init() { - printf("[PC] HTTP client not available (no WinHTTP on this platform)\n"); + crosspad::getPlatformServices().setHttpClient(&s_pcHttpClient); + printf("[PC] HTTP client initialized (curl)\n"); } #endif diff --git a/src/pc_stubs/PcPlatformStubs.cpp b/src/pc_stubs/PcPlatformStubs.cpp index dc943e2fd..413cee98a 100644 --- a/src/pc_stubs/PcPlatformStubs.cpp +++ b/src/pc_stubs/PcPlatformStubs.cpp @@ -332,9 +332,9 @@ class PcGuiPlatform : public crosspad_gui::IGuiPlatform { return; } } - // Try exe-relative: exe_dir/../crosspad-gui/assets/ (dev layout) + // Try exe-relative: exe_dir/../lib/crosspad-gui/assets/ (dev layout) { - fs::path candidate = exeDir / ".." / "crosspad-gui" / "assets"; + fs::path candidate = exeDir / ".." / "lib" / "crosspad-gui" / "assets"; std::error_code ec; if (fs::exists(candidate, ec)) { std::string resolved = fs::canonical(candidate, ec).string(); @@ -358,7 +358,7 @@ class PcGuiPlatform : public crosspad_gui::IGuiPlatform { // Fallback: cwd-relative { std::error_code ec; - fs::path candidate = fs::current_path(ec) / "crosspad-gui" / "assets"; + fs::path candidate = fs::current_path(ec) / "lib" / "crosspad-gui" / "assets"; if (fs::exists(candidate, ec)) { std::string resolved = fs::canonical(candidate, ec).string(); for (char& c : resolved) { if (c == '\\') c = '/'; } diff --git a/src/updater/PcUpdater.cpp b/src/updater/PcUpdater.cpp index 4f3a74f52..365f5244a 100644 --- a/src/updater/PcUpdater.cpp +++ b/src/updater/PcUpdater.cpp @@ -1,9 +1,12 @@ /** * @file PcUpdater.cpp - * @brief Auto-update implementation for Windows + * @brief Auto-update implementation for Windows and Linux * - * WinHTTP for downloads, PowerShell for zip extraction, batch script for - * exe replacement. Supports version caching and rollback. + * Cross-platform version check via IHttpClient + GitHub Releases API. + * Windows: WinHTTP for binary downloads, PowerShell for zip extraction, + * batch script for exe replacement. + * Linux: curl for AppImage downloads, shell script for replacement. + * Supports version caching and rollback on all platforms. */ #include "updater/PcUpdater.hpp" @@ -96,6 +99,20 @@ static std::string stripVersionPrefix(const std::string& tag) return t; } +/* ── Platform asset matching ─────────────────────────────────────────── */ + +static bool matchPlatformAsset(const char* name) +{ +#ifdef _WIN32 + return strstr(name, "Windows") && strstr(name, ".zip"); +#elif defined(__linux__) + return strstr(name, "Linux") && strstr(name, ".AppImage"); +#else + (void)name; + return false; +#endif +} + /* ── Helper: parse a single release JSON object into ReleaseInfo ────── */ static bool parseReleaseJson(JsonObject rel, ReleaseInfo& out, const char* currentVersion) @@ -109,11 +126,11 @@ static bool parseReleaseJson(JsonObject rel, ReleaseInfo& out, const char* curre out.releaseNotes = rel["body"] | ""; out.isCurrent = (out.version == currentVersion); - // Find Windows zip asset + // Find platform-specific asset JsonArray assets = rel["assets"].as(); for (JsonObject asset : assets) { const char* name = asset["name"] | ""; - if (strstr(name, "Windows") && strstr(name, ".zip")) { + if (matchPlatformAsset(name)) { out.downloadUrl = asset["browser_download_url"] | ""; out.assetSize = asset["size"] | (uint64_t)0; break; @@ -122,108 +139,7 @@ static bool parseReleaseJson(JsonObject rel, ReleaseInfo& out, const char* curre return !out.downloadUrl.empty(); } -#ifdef _WIN32 - -#include -#include - -#include -#include - -#pragma comment(lib, "winhttp.lib") - -namespace fs = std::filesystem; - -/* ── WinHTTP helpers ─────────────────────────────────────────────────── */ - -static std::wstring to_wide(const std::string& s) -{ - if (s.empty()) return {}; - int len = MultiByteToWideChar(CP_UTF8, 0, s.data(), (int)s.size(), nullptr, 0); - std::wstring ws(len, 0); - MultiByteToWideChar(CP_UTF8, 0, s.data(), (int)s.size(), ws.data(), len); - return ws; -} - -static std::string to_utf8(const std::wstring& ws) -{ - if (ws.empty()) return {}; - int len = WideCharToMultiByte(CP_UTF8, 0, ws.data(), (int)ws.size(), nullptr, 0, nullptr, nullptr); - std::string s(len, 0); - WideCharToMultiByte(CP_UTF8, 0, ws.data(), (int)ws.size(), s.data(), len, nullptr, nullptr); - return s; -} - -struct UrlParts { - std::wstring host; - std::wstring path; - INTERNET_PORT port = INTERNET_DEFAULT_HTTPS_PORT; - bool https = true; -}; - -static bool parse_url(const std::string& url, UrlParts& out) -{ - std::wstring wurl = to_wide(url); - URL_COMPONENTS uc = {}; - uc.dwStructSize = sizeof(uc); - wchar_t hostBuf[256] = {}; - wchar_t pathBuf[2048] = {}; - uc.lpszHostName = hostBuf; - uc.dwHostNameLength = 256; - uc.lpszUrlPath = pathBuf; - uc.dwUrlPathLength = 2048; - if (!WinHttpCrackUrl(wurl.c_str(), (DWORD)wurl.size(), 0, &uc)) return false; - out.host = hostBuf; - out.path = pathBuf; - out.port = uc.nPort; - out.https = (uc.nScheme == INTERNET_SCHEME_HTTPS); - return true; -} - -static std::string getTempUpdateDir() -{ - wchar_t tempPath[MAX_PATH]; - GetTempPathW(MAX_PATH, tempPath); - return to_utf8(std::wstring(tempPath) + L"crosspad_update"); -} - -/* ── Static helpers ──────────────────────────────────────────────────── */ - -std::string PcUpdater::getCurrentExePath() -{ - wchar_t path[MAX_PATH]; - GetModuleFileNameW(nullptr, path, MAX_PATH); - return to_utf8(path); -} - -std::string PcUpdater::getCacheDir() -{ - fs::path exeDir = fs::path(getCurrentExePath()).parent_path(); - return (exeDir / "versions").string(); -} - -bool PcUpdater::isCached(const std::string& version) const -{ - return fs::exists(fs::path(getCacheDir()) / ("CrossPad_v" + version + ".exe")); -} - -std::vector PcUpdater::getCachedVersions() const -{ - std::vector versions; - std::string cacheDir = getCacheDir(); - if (!fs::exists(cacheDir)) return versions; - for (auto& entry : fs::directory_iterator(cacheDir)) { - std::string name = entry.path().filename().string(); - // Pattern: CrossPad_v0.2.7.exe - if (name.find("CrossPad_v") == 0 && name.size() > 14 && - name.substr(name.size() - 4) == ".exe") { - versions.push_back(name.substr(10, name.size() - 14)); - } - } - return versions; -} - -/* ── Version Check ───────────────────────────────────────────────────── */ +/* ── Version Check (shared — uses IHttpClient) ──────────────────────── */ UpdateInfo PcUpdater::checkForUpdate() { @@ -294,7 +210,7 @@ UpdateInfo PcUpdater::checkForUpdate(bool includePrereleases) JsonArray assets = releaseObj["assets"].as(); for (JsonObject asset : assets) { const char* name = asset["name"] | ""; - if (strstr(name, "Windows") && strstr(name, ".zip")) { + if (matchPlatformAsset(name)) { info.downloadUrl = asset["browser_download_url"] | ""; info.assetSize = asset["size"] | (uint64_t)0; break; @@ -302,7 +218,7 @@ UpdateInfo PcUpdater::checkForUpdate(bool includePrereleases) } if (info.downloadUrl.empty()) { - info.errorMessage = "No Windows asset in release"; + info.errorMessage = "No compatible asset in release"; return info; } @@ -314,7 +230,7 @@ UpdateInfo PcUpdater::checkForUpdate(bool includePrereleases) return info; } -/* ── List Releases ───────────────────────────────────────────────────── */ +/* ── List Releases (shared — uses IHttpClient) ──────────────────────── */ std::vector PcUpdater::listReleases() { @@ -348,6 +264,109 @@ std::vector PcUpdater::listReleases() return releases; } +/* ══════════════════════════════════════════════════════════════════════ */ + +#ifdef _WIN32 + +#include +#include + +#include +#include + +#pragma comment(lib, "winhttp.lib") + +namespace fs = std::filesystem; + +/* ── WinHTTP helpers ─────────────────────────────────────────────────── */ + +static std::wstring to_wide(const std::string& s) +{ + if (s.empty()) return {}; + int len = MultiByteToWideChar(CP_UTF8, 0, s.data(), (int)s.size(), nullptr, 0); + std::wstring ws(len, 0); + MultiByteToWideChar(CP_UTF8, 0, s.data(), (int)s.size(), ws.data(), len); + return ws; +} + +static std::string to_utf8(const std::wstring& ws) +{ + if (ws.empty()) return {}; + int len = WideCharToMultiByte(CP_UTF8, 0, ws.data(), (int)ws.size(), nullptr, 0, nullptr, nullptr); + std::string s(len, 0); + WideCharToMultiByte(CP_UTF8, 0, ws.data(), (int)ws.size(), s.data(), len, nullptr, nullptr); + return s; +} + +struct UrlParts { + std::wstring host; + std::wstring path; + INTERNET_PORT port = INTERNET_DEFAULT_HTTPS_PORT; + bool https = true; +}; + +static bool parse_url(const std::string& url, UrlParts& out) +{ + std::wstring wurl = to_wide(url); + URL_COMPONENTS uc = {}; + uc.dwStructSize = sizeof(uc); + wchar_t hostBuf[256] = {}; + wchar_t pathBuf[2048] = {}; + uc.lpszHostName = hostBuf; + uc.dwHostNameLength = 256; + uc.lpszUrlPath = pathBuf; + uc.dwUrlPathLength = 2048; + if (!WinHttpCrackUrl(wurl.c_str(), (DWORD)wurl.size(), 0, &uc)) return false; + out.host = hostBuf; + out.path = pathBuf; + out.port = uc.nPort; + out.https = (uc.nScheme == INTERNET_SCHEME_HTTPS); + return true; +} + +static std::string getTempUpdateDir() +{ + wchar_t tempPath[MAX_PATH]; + GetTempPathW(MAX_PATH, tempPath); + return to_utf8(std::wstring(tempPath) + L"crosspad_update"); +} + +/* ── Static helpers ──────────────────────────────────────────────────── */ + +std::string PcUpdater::getCurrentExePath() +{ + wchar_t path[MAX_PATH]; + GetModuleFileNameW(nullptr, path, MAX_PATH); + return to_utf8(path); +} + +std::string PcUpdater::getCacheDir() +{ + fs::path exeDir = fs::path(getCurrentExePath()).parent_path(); + return (exeDir / "versions").string(); +} + +bool PcUpdater::isCached(const std::string& version) const +{ + return fs::exists(fs::path(getCacheDir()) / ("CrossPad_v" + version + ".exe")); +} + +std::vector PcUpdater::getCachedVersions() const +{ + std::vector versions; + std::string cacheDir = getCacheDir(); + if (!fs::exists(cacheDir)) return versions; + for (auto& entry : fs::directory_iterator(cacheDir)) { + std::string name = entry.path().filename().string(); + // Pattern: CrossPad_v0.2.7.exe + if (name.find("CrossPad_v") == 0 && name.size() > 14 && + name.substr(name.size() - 4) == ".exe") { + versions.push_back(name.substr(10, name.size() - 14)); + } + } + return versions; +} + /* ── WinHTTP binary download helper ──────────────────────────────────── */ static bool winHttpDownload(const std::string& url, const std::string& outputPath, @@ -766,19 +785,332 @@ std::string PcUpdater::getCachedMetadataJson(const std::string& version) std::istreambuf_iterator()); } -#else // ── Non-Windows stubs ─────────────────────────────────────────── */ +#elif defined(__linux__) // ── Linux implementation (AppImage) ────────── */ + +#include +#include +#include +#include +#include +#include +#include + +namespace fs = std::filesystem; -UpdateInfo PcUpdater::checkForUpdate() { return checkForUpdate(false); } +/* ── Linux helpers ──────────────────────────────────────────────────── */ -UpdateInfo PcUpdater::checkForUpdate(bool) +static std::string getTempUpdateDir() { - UpdateInfo info; - info.currentVersion = CROSSPAD_PC_VERSION; - info.errorMessage = "Auto-update not available on this platform"; - return info; + return "/tmp/crosspad_update"; +} + +std::string PcUpdater::getCurrentExePath() +{ + char buf[PATH_MAX]; + ssize_t len = readlink("/proc/self/exe", buf, sizeof(buf) - 1); + if (len < 0) return ""; + buf[len] = '\0'; + return std::string(buf); +} + +std::string PcUpdater::getCacheDir() +{ + const char* home = getenv("HOME"); + if (!home) return "/tmp/crosspad/versions"; + return std::string(home) + "/.local/share/crosspad/versions"; +} + +bool PcUpdater::isCached(const std::string& version) const +{ + return fs::exists(fs::path(getCacheDir()) / ("CrossPad_v" + version + ".AppImage")); +} + +std::vector PcUpdater::getCachedVersions() const +{ + std::vector versions; + std::string cacheDir = getCacheDir(); + if (!fs::exists(cacheDir)) return versions; + for (auto& entry : fs::directory_iterator(cacheDir)) { + std::string name = entry.path().filename().string(); + // Pattern: CrossPad_v0.3.1.AppImage + if (name.find("CrossPad_v") == 0 && name.size() > 19 && + name.substr(name.size() - 9) == ".AppImage") { + versions.push_back(name.substr(10, name.size() - 19)); + } + } + return versions; +} + +/* ── curl-based download with progress ──────────────────────────────── */ + +static bool curlDownload(const std::string& url, const std::string& outputPath, + uint64_t expectedSize, UpdateProgressCallback progressCb) +{ + if (progressCb) progressCb(UpdateState::Downloading, 0, "Connecting..."); + + pid_t pid = fork(); + if (pid == 0) { + execlp("curl", "curl", "-L", "-f", "-s", + "-o", outputPath.c_str(), url.c_str(), (char*)nullptr); + _exit(127); + } + if (pid < 0) { + if (progressCb) progressCb(UpdateState::Error, 0, "Failed to start download"); + return false; + } + + // Poll file size for progress while curl downloads + int status; + while (waitpid(pid, &status, WNOHANG) == 0) { + if (expectedSize > 0 && progressCb) { + std::error_code ec; + if (fs::exists(outputPath, ec)) { + auto size = fs::file_size(outputPath, ec); + if (!ec) { + int pct = (int)((size * 100) / expectedSize); + char msg[64]; + snprintf(msg, sizeof(msg), "%.1f / %.1f MB", + size / 1048576.0, expectedSize / 1048576.0); + progressCb(UpdateState::Downloading, pct, msg); + } + } + } + usleep(500000); + } + + if (!WIFEXITED(status) || WEXITSTATUS(status) != 0) { + if (progressCb) progressCb(UpdateState::Error, 0, "Download failed"); + std::error_code ec; + fs::remove(outputPath, ec); + return false; + } + + if (progressCb) progressCb(UpdateState::Downloading, 100, "Download complete"); + return true; +} + +/* ── Download and cache AppImage ────────────────────────────────────── */ + +static bool downloadAndCacheAppImage(const ReleaseInfo& release, + UpdateProgressCallback progressCb, + std::string& outCachedPath) +{ + std::string cacheDir = PcUpdater::getCacheDir(); + fs::create_directories(cacheDir); + + std::string cachedPath = (fs::path(cacheDir) / + ("CrossPad_v" + release.version + ".AppImage")).string(); + + if (!curlDownload(release.downloadUrl, cachedPath, release.assetSize, progressCb)) + return false; + + // Make executable + chmod(cachedPath.c_str(), 0755); + + // Save release notes + std::string prefix = (fs::path(cacheDir) / ("CrossPad_v" + release.version)).string(); + if (!release.releaseNotes.empty()) { + std::ofstream nf(prefix + ".md"); + if (nf.is_open()) nf << release.releaseNotes; + } + + // Save release metadata JSON + { + JsonDocument meta; + meta["version"] = release.version; + meta["tag_name"] = release.tagName; + meta["release_name"] = release.releaseName; + meta["asset_size"] = release.assetSize; + meta["cached_at"] = (int64_t)std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()).count(); + + std::ofstream mf(prefix + ".json"); + if (mf.is_open()) serializeJsonPretty(meta, mf); + } + + outCachedPath = cachedPath; + printf("[Updater] Cached AppImage: %s\n", cachedPath.c_str()); + return true; +} + +/* ── Public download methods ────────────────────────────────────────── */ + +bool PcUpdater::downloadUpdate(const UpdateInfo& info, UpdateProgressCallback progressCb) +{ + if (info.downloadUrl.empty()) { + if (progressCb) progressCb(UpdateState::Error, 0, "No download URL"); + return false; + } + + ReleaseInfo rel; + rel.version = info.latestVersion; + rel.tagName = "v" + info.latestVersion; + rel.releaseName = ""; + rel.downloadUrl = info.downloadUrl; + rel.releaseNotes = info.releaseNotes; + rel.assetSize = info.assetSize; + + std::string cachedPath; + bool ok = downloadAndCacheAppImage(rel, progressCb, cachedPath); + if (ok) { + extractDir_ = cachedPath; + if (progressCb) progressCb(UpdateState::ReadyToInstall, 100, "Ready to install"); + } + return ok; +} + +bool PcUpdater::downloadAndCache(const ReleaseInfo& release, UpdateProgressCallback progressCb) +{ + if (release.downloadUrl.empty()) { + if (progressCb) progressCb(UpdateState::Error, 0, "No download URL"); + return false; + } + + std::string cachedPath; + bool ok = downloadAndCacheAppImage(release, progressCb, cachedPath); + if (ok && progressCb) + progressCb(UpdateState::ReadyToInstall, 100, "Ready to install"); + return ok; +} + +/* ── Shell script generation ────────────────────────────────────────── */ + +std::string PcUpdater::prepareInstall() +{ + if (extractDir_.empty()) return ""; + + std::string exePath = getCurrentExePath(); + std::string tempDir = getTempUpdateDir(); + fs::create_directories(tempDir); + std::string scriptPath = tempDir + "/update.sh"; + + std::ofstream script(scriptPath); + if (!script.is_open()) return ""; + + script << "#!/bin/bash\n"; + script << "echo 'Updating CrossPad...'\n"; + script << "while kill -0 " << getpid() << " 2>/dev/null; do sleep 0.5; done\n"; + script << "cp '" << extractDir_ << "' '" << exePath << "'\n"; + script << "chmod +x '" << exePath << "'\n"; + script << "echo 'Update complete! Restarting...'\n"; + script << "'" << exePath << "' &\n"; + script << "sleep 2\n"; + script << "rm -rf '" << tempDir << "'\n"; + script.close(); + + chmod(scriptPath.c_str(), 0755); + printf("[Updater] Update script written to %s\n", scriptPath.c_str()); + return scriptPath; +} + +std::string PcUpdater::prepareInstallFromCache(const std::string& version) +{ + std::string cachedAppImage = (fs::path(getCacheDir()) / + ("CrossPad_v" + version + ".AppImage")).string(); + if (!fs::exists(cachedAppImage)) { + printf("[Updater] Cached AppImage not found: %s\n", cachedAppImage.c_str()); + return ""; + } + + std::string exePath = getCurrentExePath(); + std::string tempDir = getTempUpdateDir(); + fs::create_directories(tempDir); + std::string scriptPath = tempDir + "/update.sh"; + + std::ofstream script(scriptPath); + if (!script.is_open()) return ""; + + script << "#!/bin/bash\n"; + script << "echo 'Updating CrossPad...'\n"; + script << "while kill -0 " << getpid() << " 2>/dev/null; do sleep 0.5; done\n"; + script << "cp '" << cachedAppImage << "' '" << exePath << "'\n"; + script << "chmod +x '" << exePath << "'\n"; + script << "echo 'Update complete! Restarting...'\n"; + script << "'" << exePath << "' &\n"; + script << "sleep 2\n"; + script << "rm -rf '" << tempDir << "'\n"; + script.close(); + + chmod(scriptPath.c_str(), 0755); + printf("[Updater] Cache install script written to %s\n", scriptPath.c_str()); + return scriptPath; +} + +/* ── Cache current AppImage before switching ────────────────────────── */ + +static void cacheCurrentExe() +{ + std::string curVersion = CROSSPAD_PC_VERSION; + std::string cacheDir = PcUpdater::getCacheDir(); + fs::create_directories(cacheDir); + std::string cachedPath = (fs::path(cacheDir) / + ("CrossPad_v" + curVersion + ".AppImage")).string(); + + if (fs::exists(cachedPath)) return; + + std::string curExe = PcUpdater::getCurrentExePath(); + std::error_code ec; + fs::copy_file(curExe, cachedPath, fs::copy_options::none, ec); + if (!ec) { + printf("[Updater] Cached current AppImage (v%s)\n", curVersion.c_str()); + } else { + printf("[Updater] Warning: failed to cache current AppImage: %s\n", ec.message().c_str()); + } +} + +/* ── Launch script and exit ─────────────────────────────────────────── */ + +static void launchScriptAndExit(const std::string& scriptPath) +{ + pid_t pid = fork(); + if (pid == 0) { + setsid(); + execl("/bin/bash", "bash", scriptPath.c_str(), (char*)nullptr); + _exit(127); + } + if (pid > 0) { + printf("[Updater] Update script launched (PID %d), exiting...\n", pid); + fflush(stdout); + _exit(0); + } + printf("[Updater] ERROR: Failed to fork for update script\n"); +} + +void PcUpdater::installAndRestart() +{ + std::string scriptPath = prepareInstall(); + if (scriptPath.empty()) return; + cacheCurrentExe(); + launchScriptAndExit(scriptPath); +} + +void PcUpdater::installCachedAndRestart(const std::string& version) +{ + std::string scriptPath = prepareInstallFromCache(version); + if (scriptPath.empty()) return; + cacheCurrentExe(); + launchScriptAndExit(scriptPath); +} + +std::string PcUpdater::getCachedReleaseNotes(const std::string& version) +{ + auto path = fs::path(getCacheDir()) / ("CrossPad_v" + version + ".md"); + if (!fs::exists(path)) return ""; + std::ifstream f(path); + return std::string((std::istreambuf_iterator(f)), + std::istreambuf_iterator()); +} + +std::string PcUpdater::getCachedMetadataJson(const std::string& version) +{ + auto path = fs::path(getCacheDir()) / ("CrossPad_v" + version + ".json"); + if (!fs::exists(path)) return ""; + std::ifstream f(path); + return std::string((std::istreambuf_iterator(f)), + std::istreambuf_iterator()); } -std::vector PcUpdater::listReleases() { return {}; } +#else // ── Stubs for unsupported platforms ───────────────────────────── */ bool PcUpdater::downloadUpdate(const UpdateInfo&, UpdateProgressCallback cb) { diff --git a/src/updater/PcUpdater.hpp b/src/updater/PcUpdater.hpp index bbf7b68f5..0472ccdea 100644 --- a/src/updater/PcUpdater.hpp +++ b/src/updater/PcUpdater.hpp @@ -4,9 +4,11 @@ * @file PcUpdater.hpp * @brief Auto-update system — checks GitHub Releases, downloads, caches, and self-replaces * - * Windows-only. Uses WinHTTP for binary downloads and PowerShell for zip - * extraction. Self-update works via a batch script that replaces the exe - * after the process exits. Supports version caching and rollback. + * Cross-platform version check via IHttpClient + GitHub Releases API. + * Platform-specific download and install: + * - Windows: WinHTTP download, PowerShell zip extraction, batch script replacement + * - Linux: curl download, AppImage caching, shell script replacement + * Supports version caching and rollback on all platforms. */ #include diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 317b35a6c..565ae7fe7 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -13,29 +13,29 @@ FetchContent_MakeAvailable(Catch2) # ── crosspad-core sources needed by tests (explicit list) ── set(CORE_TEST_SOURCES # Platform init + services - ${PROJECT_SOURCE_DIR}/crosspad-core/src/platform/CrosspadPlatformInit.cpp - ${PROJECT_SOURCE_DIR}/crosspad-core/src/platform/PlatformCapabilities.cpp - ${PROJECT_SOURCE_DIR}/crosspad-core/src/platform/PlatformServices.cpp + ${PROJECT_SOURCE_DIR}/lib/crosspad-core/src/platform/CrosspadPlatformInit.cpp + ${PROJECT_SOURCE_DIR}/lib/crosspad-core/src/platform/PlatformCapabilities.cpp + ${PROJECT_SOURCE_DIR}/lib/crosspad-core/src/platform/PlatformServices.cpp # Event bus (FreeRTOS — same as main target) - ${PROJECT_SOURCE_DIR}/crosspad-core/src/event/FreeRtosEventBus.cpp + ${PROJECT_SOURCE_DIR}/lib/crosspad-core/src/event/FreeRtosEventBus.cpp # Pad system - ${PROJECT_SOURCE_DIR}/crosspad-core/src/pad/PadManager.cpp - ${PROJECT_SOURCE_DIR}/crosspad-core/src/pad/PadLedController.cpp - ${PROJECT_SOURCE_DIR}/crosspad-core/src/pad/PadAnimator.cpp - ${PROJECT_SOURCE_DIR}/crosspad-core/src/pad/RGBCallbackManager.cpp + ${PROJECT_SOURCE_DIR}/lib/crosspad-core/src/pad/PadManager.cpp + ${PROJECT_SOURCE_DIR}/lib/crosspad-core/src/pad/PadLedController.cpp + ${PROJECT_SOURCE_DIR}/lib/crosspad-core/src/pad/PadAnimator.cpp + ${PROJECT_SOURCE_DIR}/lib/crosspad-core/src/pad/RGBCallbackManager.cpp # App system - ${PROJECT_SOURCE_DIR}/crosspad-core/src/app/AppRegistry.cpp - ${PROJECT_SOURCE_DIR}/crosspad-core/src/app/AppManagerBase.cpp + ${PROJECT_SOURCE_DIR}/lib/crosspad-core/src/app/AppRegistry.cpp + ${PROJECT_SOURCE_DIR}/lib/crosspad-core/src/app/AppManagerBase.cpp # Settings - ${PROJECT_SOURCE_DIR}/crosspad-core/src/settings/CrosspadSettings.cpp - ${PROJECT_SOURCE_DIR}/crosspad-core/src/settings/ISettingsUI.cpp + ${PROJECT_SOURCE_DIR}/lib/crosspad-core/src/settings/CrosspadSettings.cpp + ${PROJECT_SOURCE_DIR}/lib/crosspad-core/src/settings/ISettingsUI.cpp # MIDI input routing (for e2e tests) - ${PROJECT_SOURCE_DIR}/crosspad-core/src/midi/MidiInputHandler.cpp + ${PROJECT_SOURCE_DIR}/lib/crosspad-core/src/midi/MidiInputHandler.cpp ) # ── Test sources ── @@ -60,8 +60,8 @@ target_compile_definitions(crosspad_tests PRIVATE target_include_directories(crosspad_tests PRIVATE ${PROJECT_SOURCE_DIR}/src - ${PROJECT_SOURCE_DIR}/crosspad-core/include - ${PROJECT_SOURCE_DIR}/crosspad-gui/include + ${PROJECT_SOURCE_DIR}/lib/crosspad-core/include + ${PROJECT_SOURCE_DIR}/lib/crosspad-gui/include ) target_link_libraries(crosspad_tests PRIVATE