From a60b728438aa9eb35b86a26ba38735633f819056 Mon Sep 17 00:00:00 2001 From: Chadwick Boulay Date: Mon, 23 Feb 2026 00:51:21 -0500 Subject: [PATCH] Complete rewrite of Blackrock LSL Application. --- .github/workflows/build.yml | 273 +++++++++++++++ .gitignore | 116 +------ BlackrockOutlet.cfg | 14 + CMakeLists.txt | 269 +++++++++++++++ LICENSE | 17 +- README.md | 195 ++++++++++- app.entitlements | 17 + cbpy/README.md | 36 -- cbpy/blackrock_lsl.py | 51 --- cbpy/test.py | 30 -- cbsdk_Win/.gitignore | 17 - cbsdk_Win/CMakeLists.txt | 98 ------ cbsdk_Win/README.md | 33 -- cbsdk_Win/cmake/FindCBSDK.cmake | 96 ----- cbsdk_Win/src/main.cpp | 124 ------- scripts/sign_and_notarize.sh | 119 +++++++ src/cli/CMakeLists.txt | 20 ++ src/cli/main.cpp | 158 +++++++++ src/core/CMakeLists.txt | 25 ++ src/core/include/blackrock/Config.hpp | 43 +++ src/core/include/blackrock/Device.hpp | 124 +++++++ src/core/include/blackrock/LSLOutlet.hpp | 36 ++ src/core/include/blackrock/StreamThread.hpp | 46 +++ src/core/src/Config.cpp | 243 +++++++++++++ src/core/src/Device.cpp | 365 ++++++++++++++++++++ src/core/src/LSLOutlet.cpp | 86 +++++ src/core/src/StreamThread.cpp | 190 ++++++++++ src/gui/CMakeLists.txt | 52 +++ src/gui/Info.plist.in | 30 ++ src/gui/MainWindow.cpp | 296 ++++++++++++++++ src/gui/MainWindow.hpp | 46 +++ src/gui/MainWindow.ui | 313 +++++++++++++++++ src/gui/main.cpp | 26 ++ 33 files changed, 3007 insertions(+), 597 deletions(-) create mode 100644 .github/workflows/build.yml create mode 100644 BlackrockOutlet.cfg create mode 100644 CMakeLists.txt create mode 100644 app.entitlements delete mode 100644 cbpy/README.md delete mode 100644 cbpy/blackrock_lsl.py delete mode 100644 cbpy/test.py delete mode 100644 cbsdk_Win/.gitignore delete mode 100644 cbsdk_Win/CMakeLists.txt delete mode 100644 cbsdk_Win/README.md delete mode 100644 cbsdk_Win/cmake/FindCBSDK.cmake delete mode 100644 cbsdk_Win/src/main.cpp create mode 100755 scripts/sign_and_notarize.sh create mode 100644 src/cli/CMakeLists.txt create mode 100644 src/cli/main.cpp create mode 100644 src/core/CMakeLists.txt create mode 100644 src/core/include/blackrock/Config.hpp create mode 100644 src/core/include/blackrock/Device.hpp create mode 100644 src/core/include/blackrock/LSLOutlet.hpp create mode 100644 src/core/include/blackrock/StreamThread.hpp create mode 100644 src/core/src/Config.cpp create mode 100644 src/core/src/Device.cpp create mode 100644 src/core/src/LSLOutlet.cpp create mode 100644 src/core/src/StreamThread.cpp create mode 100644 src/gui/CMakeLists.txt create mode 100644 src/gui/Info.plist.in create mode 100644 src/gui/MainWindow.cpp create mode 100644 src/gui/MainWindow.hpp create mode 100644 src/gui/MainWindow.ui create mode 100644 src/gui/main.cpp diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..d514a6f --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,273 @@ +# ============================================================================= +# BlackrockOutlet Build Workflow +# ============================================================================= +# Builds, tests, and packages the BlackrockOutlet application. +# Requires CereLink source to be available. +# ============================================================================= + +name: Build + +on: + push: + branches: [main, master, dev] + tags: ['v*'] + pull_request: + branches: [main, master] + release: + types: [published] + workflow_dispatch: + +env: + BUILD_TYPE: Release + +jobs: + # =========================================================================== + # Build Job - Multi-platform builds + # =========================================================================== + build: + name: ${{ matrix.config.name }} + runs-on: ${{ matrix.config.os }} + strategy: + fail-fast: false + matrix: + config: + - { name: "Ubuntu 22.04", os: ubuntu-22.04 } + - { name: "Ubuntu 24.04", os: ubuntu-24.04 } + - { name: "macOS", os: macos-14, cmake_extra: '-DCMAKE_OSX_ARCHITECTURES="x86_64;arm64"' } + - { name: "Windows", os: windows-latest } + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Checkout CereLink + uses: actions/checkout@v4 + with: + repository: BlackrockMicrosystems/CereLink + path: CereLink + + # ----------------------------------------------------------------------- + # Install CMake 3.28+ (Ubuntu 22.04 ships with 3.22) + # ----------------------------------------------------------------------- + - name: Install CMake + if: runner.os == 'Linux' + uses: lukka/get-cmake@latest + + # ----------------------------------------------------------------------- + # Install Qt6 (6.8 LTS across all platforms) + # ----------------------------------------------------------------------- + - name: Install Linux dependencies + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y libgl1-mesa-dev libxkbcommon-dev libxcb-cursor0 + + - name: Install Qt + uses: jurplel/install-qt-action@v4 + with: + version: '6.8.*' + cache: true + + # ----------------------------------------------------------------------- + # Configure + # ----------------------------------------------------------------------- + - name: Configure CMake + run: > + cmake -S . -B build + -DCMAKE_BUILD_TYPE=${{ env.BUILD_TYPE }} + -DCMAKE_INSTALL_PREFIX=${{ github.workspace }}/install + -DCERELINK_SOURCE_DIR=${{ github.workspace }}/CereLink + -DLSL_FETCH_IF_MISSING=ON + ${{ matrix.config.cmake_extra }} + + # ----------------------------------------------------------------------- + # Build + # ----------------------------------------------------------------------- + - name: Build + run: cmake --build build --config ${{ env.BUILD_TYPE }} --parallel + + # ----------------------------------------------------------------------- + # Install + # ----------------------------------------------------------------------- + - name: Install + run: cmake --install build --config ${{ env.BUILD_TYPE }} + + # ----------------------------------------------------------------------- + # Test CLI + # ----------------------------------------------------------------------- + - name: Test CLI (Linux) + if: runner.os == 'Linux' + run: ./install/bin/BlackrockOutletCLI --help + + - name: Test CLI (macOS) + if: runner.os == 'macOS' + run: ./install/BlackrockOutletCLI --help + + - name: Test CLI (Windows) + if: runner.os == 'Windows' + run: ./install/BlackrockOutletCLI.exe --help + + # ----------------------------------------------------------------------- + # Package + # ----------------------------------------------------------------------- + - name: Package + run: cpack -C ${{ env.BUILD_TYPE }} + working-directory: build + + # ----------------------------------------------------------------------- + # Upload Artifacts + # ----------------------------------------------------------------------- + - name: Upload Artifacts + uses: actions/upload-artifact@v4 + with: + name: package-${{ matrix.config.os }} + path: | + build/*.zip + build/*.tar.gz + build/*.deb + if-no-files-found: ignore + + # =========================================================================== + # macOS Signing and Notarization (Release only) + # =========================================================================== + sign-macos: + name: Sign & Notarize (macOS) + needs: build + if: github.event_name == 'release' + runs-on: macos-14 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Download macOS Artifact + uses: actions/download-artifact@v4 + with: + name: package-macos-14 + path: packages + + - name: Extract Package + run: | + cd packages + tar -xzf *.tar.gz + SUBDIR=$(ls -d BlackrockOutlet-*/ | head -1) + mv "$SUBDIR"/* . + rmdir "$SUBDIR" + ls -la + + # ----------------------------------------------------------------------- + # Install Apple Certificates + # ----------------------------------------------------------------------- + - name: Install Apple Certificates + env: + MACOS_CERTIFICATE: ${{ secrets.PROD_MACOS_CERTIFICATE }} + MACOS_CERTIFICATE_PWD: ${{ secrets.PROD_MACOS_CERTIFICATE_PWD }} + MACOS_CI_KEYCHAIN_PWD: ${{ secrets.PROD_MACOS_CI_KEYCHAIN_PWD }} + run: | + KEYCHAIN_PATH=$RUNNER_TEMP/build.keychain + security create-keychain -p "$MACOS_CI_KEYCHAIN_PWD" $KEYCHAIN_PATH + security default-keychain -s $KEYCHAIN_PATH + security set-keychain-settings -lut 21600 $KEYCHAIN_PATH + security unlock-keychain -p "$MACOS_CI_KEYCHAIN_PWD" $KEYCHAIN_PATH + + CERTIFICATE_PATH=$RUNNER_TEMP/build_certificate.p12 + echo -n "$MACOS_CERTIFICATE" | base64 --decode -o $CERTIFICATE_PATH + security import $CERTIFICATE_PATH -P "$MACOS_CERTIFICATE_PWD" -k $KEYCHAIN_PATH -A -t cert -f pkcs12 + rm $CERTIFICATE_PATH + + security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$MACOS_CI_KEYCHAIN_PWD" $KEYCHAIN_PATH + security list-keychain -d user -s $KEYCHAIN_PATH + + IDENTITY=$(security find-identity -v -p codesigning $KEYCHAIN_PATH | grep "Developer ID Application" | head -1 | awk -F'"' '{print $2}') + echo "APPLE_CODE_SIGN_IDENTITY_APP=$IDENTITY" >> $GITHUB_ENV + + # ----------------------------------------------------------------------- + # Setup Notarization Credentials + # ----------------------------------------------------------------------- + - name: Setup Notarization + env: + NOTARIZATION_APPLE_ID: ${{ secrets.PROD_MACOS_NOTARIZATION_APPLE_ID }} + NOTARIZATION_PWD: ${{ secrets.PROD_MACOS_NOTARIZATION_PWD }} + NOTARIZATION_TEAM_ID: ${{ secrets.PROD_MACOS_NOTARIZATION_TEAM_ID }} + run: | + xcrun notarytool store-credentials "notarize-profile" \ + --apple-id "$NOTARIZATION_APPLE_ID" \ + --password "$NOTARIZATION_PWD" \ + --team-id "$NOTARIZATION_TEAM_ID" + echo "APPLE_NOTARIZE_KEYCHAIN_PROFILE=notarize-profile" >> $GITHUB_ENV + + # ----------------------------------------------------------------------- + # Sign and Notarize + # ----------------------------------------------------------------------- + - name: Sign and Notarize + env: + ENTITLEMENTS_FILE: ${{ github.workspace }}/app.entitlements + run: | + APP_PATH=$(find packages -name "*.app" -type d | head -1) + if [[ -n "$APP_PATH" ]]; then + ./scripts/sign_and_notarize.sh "$APP_PATH" --notarize + fi + + CLI_PATH=$(find packages -name "BlackrockOutletCLI" -type f | head -1) + if [[ -n "$CLI_PATH" ]]; then + CLI_DIR=$(dirname "$CLI_PATH") + if [[ -d "$CLI_DIR/Frameworks/lsl.framework" ]]; then + codesign --force --sign "$APPLE_CODE_SIGN_IDENTITY_APP" --options runtime \ + "$CLI_DIR/Frameworks/lsl.framework" + fi + ./scripts/sign_and_notarize.sh "$CLI_PATH" --notarize + fi + + # ----------------------------------------------------------------------- + # Repackage + # ----------------------------------------------------------------------- + - name: Repackage + run: | + cd packages + echo "Contents of packages directory:" + ls -la + rm -f *.tar.gz + + VERSION=$(grep -A1 'project(BlackrockOutlet' ../CMakeLists.txt | grep VERSION | sed 's/.*VERSION \([0-9.]*\).*/\1/') + echo "Detected version: $VERSION" + + tar -cvzf "BlackrockOutlet-${VERSION}-macOS_universal-signed.tar.gz" \ + BlackrockOutlet.app BlackrockOutletCLI Frameworks + + echo "Created package:" + ls -la *.tar.gz + + - name: Upload Signed Package + uses: actions/upload-artifact@v4 + with: + name: package-macos-signed + path: packages/*-signed.tar.gz + + - name: Upload to Release + if: github.event_name == 'release' + uses: softprops/action-gh-release@v2 + with: + files: packages/*-signed.tar.gz + + # =========================================================================== + # Upload unsigned packages to release + # =========================================================================== + release: + name: Upload to Release + needs: build + if: github.event_name == 'release' + runs-on: ubuntu-latest + + steps: + - name: Download All Artifacts + uses: actions/download-artifact@v4 + with: + path: artifacts + + - name: Upload to Release + uses: softprops/action-gh-release@v2 + with: + files: | + artifacts/**/*.zip + artifacts/package-ubuntu-*/*.tar.gz + artifacts/**/*.deb diff --git a/.gitignore b/.gitignore index 8d2e304..6353099 100644 --- a/.gitignore +++ b/.gitignore @@ -1,107 +1,11 @@ -# Byte-compiled / optimized / DLL files -__pycache__/ -*.py[cod] -*$py.class - -# C extensions -*.so - -# Distribution / packaging -.Python -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -*.egg-info/ -.installed.cfg -*.egg -MANIFEST - -# PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. -*.manifest -*.spec - -# Installer logs -pip-log.txt -pip-delete-this-directory.txt - -# Unit test / coverage reports -htmlcov/ -.tox/ -.coverage -.coverage.* -.cache -nosetests.xml -coverage.xml -*.cover -.hypothesis/ -.pytest_cache/ - -# Translations -*.mo -*.pot - -# Django stuff: -*.log -local_settings.py -db.sqlite3 - -# Flask stuff: -instance/ -.webassets-cache - -# Scrapy stuff: -.scrapy - -# Sphinx documentation -docs/_build/ - -# PyBuilder -target/ - -# Jupyter Notebook -.ipynb_checkpoints - -# pyenv -.python-version - -# celery beat schedule file -celerybeat-schedule - -# SageMath parsed files -*.sage.py - -# Environments -.env -.venv -env/ -venv/ -ENV/ -env.bak/ -venv.bak/ - -# Spyder project settings -.spyderproject -.spyproject - -PyCharm +ui_*.h +/build*/ +/cmake-build-*/ +/CMakeLists.txt.user +/CMakeLists.json +/.vs/ +/CMakeSettings.json +/out/ +.DS_Store .idea - -# Rope project settings -.ropeproject - -# mkdocs documentation -/site - -# mypy -.mypy_cache/ +.cache/ diff --git a/BlackrockOutlet.cfg b/BlackrockOutlet.cfg new file mode 100644 index 0000000..64f0f17 --- /dev/null +++ b/BlackrockOutlet.cfg @@ -0,0 +1,14 @@ +# BlackrockOutlet Configuration + +[Stream] +name= +type=ECEPhys +sample_group=0 +scaled=false +heartbeat=false + +[Device] +device_type=hub1 +custom_address= +custom_port=0 +ccf_file= diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..4ac3e22 --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,269 @@ +# ============================================================================= +# BlackrockOutlet - Blackrock Cerebus/Neuroport LSL Streaming Application +# ============================================================================= +cmake_minimum_required(VERSION 3.28) + +if(POLICY CMP0177) + cmake_policy(SET CMP0177 NEW) +endif() + +project(BlackrockOutlet + VERSION 0.1.0 + DESCRIPTION "Blackrock Cerebus/Neuroport LSL streaming application" + HOMEPAGE_URL "https://github.com/labstreaminglayer/App-BlackrockCerebus" + LANGUAGES CXX +) + +set(CMAKE_CXX_STANDARD 20) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_EXPORT_COMPILE_COMMANDS ON) + +# ============================================================================= +# Build Options +# ============================================================================= +option(BLACKROCK_BUILD_GUI "Build the GUI application (requires Qt6)" ON) +option(BLACKROCK_BUILD_CLI "Build the CLI application" ON) + +# ============================================================================= +# liblsl Dependency +# ============================================================================= +set(LSL_INSTALL_ROOT "" CACHE PATH "Path to installed liblsl (optional)") +set(LSL_FETCH_REF "v1.17.4" CACHE STRING "liblsl version to fetch from GitHub") + +if(LSL_INSTALL_ROOT) + find_package(LSL REQUIRED + HINTS "${LSL_INSTALL_ROOT}" + PATH_SUFFIXES share/LSL lib/cmake/LSL Frameworks/lsl.framework/Resources/CMake + ) + message(STATUS "Using installed liblsl: ${LSL_DIR}") + include("${LSL_DIR}/LSLCMake.cmake") +else() + message(STATUS "Fetching liblsl ${LSL_FETCH_REF} from GitHub...") + include(FetchContent) + set(LSL_BUILD_EXAMPLES OFF CACHE BOOL "" FORCE) + set(LSL_BUILD_TESTING OFF CACHE BOOL "" FORCE) + FetchContent_Declare(liblsl + GIT_REPOSITORY https://github.com/sccn/liblsl.git + GIT_TAG ${LSL_FETCH_REF} + GIT_SHALLOW ON + EXCLUDE_FROM_ALL + ) + FetchContent_MakeAvailable(liblsl) + if(NOT TARGET LSL::lsl) + add_library(LSL::lsl ALIAS lsl) + endif() + include("${liblsl_SOURCE_DIR}/cmake/LSLCMake.cmake") +endif() + +# ============================================================================= +# CereLink (Blackrock Neurotech device SDK) +# ============================================================================= +set(CERELINK_SOURCE_DIR "" CACHE PATH "Path to CereLink source (local development)") +set(CERELINK_FETCH_REF "cboulay/clock_sync" CACHE STRING "CereLink git branch/tag to fetch") + +set(CBSDK_BUILD_TEST OFF CACHE BOOL "" FORCE) +set(CBSDK_BUILD_SAMPLE OFF CACHE BOOL "" FORCE) +set(CBSDK_BUILD_LEGACY OFF CACHE BOOL "" FORCE) + +if(CERELINK_SOURCE_DIR) + set(_cerelink_src "${CERELINK_SOURCE_DIR}") + add_subdirectory("${CERELINK_SOURCE_DIR}" "${CMAKE_BINARY_DIR}/_deps/cerelink-build" EXCLUDE_FROM_ALL) +else() + message(STATUS "Fetching CereLink (${CERELINK_FETCH_REF}) from GitHub...") + include(FetchContent) + FetchContent_Declare(cerelink + GIT_REPOSITORY https://github.com/CerebusOSS/CereLink.git + GIT_TAG ${CERELINK_FETCH_REF} + EXCLUDE_FROM_ALL + ) + FetchContent_MakeAvailable(cerelink) + set(_cerelink_src "${cerelink_SOURCE_DIR}") +endif() + +# ============================================================================= +# xxHash (for source_id hashing) +# ============================================================================= +set(XXHASH_BUILD_XXHSUM OFF CACHE INTERNAL "") +FetchContent_Declare(xxHash + GIT_REPOSITORY https://github.com/Cyan4973/xxHash.git + GIT_TAG dev + SOURCE_SUBDIR build/cmake + EXCLUDE_FROM_ALL +) +FetchContent_MakeAvailable(xxHash) + +# Build ConfigureChannels utility from CereLink examples +add_executable(configure_channels + "${_cerelink_src}/examples/ConfigureChannels/configure_channels.cpp" +) +target_link_libraries(configure_channels cbdev) + +# ============================================================================= +# Qt6 (for GUI build only) +# ============================================================================= +if(BLACKROCK_BUILD_GUI) + set(CMAKE_AUTOMOC ON) + set(CMAKE_AUTORCC ON) + set(CMAKE_AUTOUIC ON) + + find_package(Qt6 REQUIRED COMPONENTS Core Widgets) + + if(APPLE AND TARGET WrapOpenGL::WrapOpenGL) + get_target_property(_wrap_gl_libs WrapOpenGL::WrapOpenGL INTERFACE_LINK_LIBRARIES) + if(_wrap_gl_libs) + list(FILTER _wrap_gl_libs EXCLUDE REGEX ".*AGL.*") + set_target_properties(WrapOpenGL::WrapOpenGL PROPERTIES INTERFACE_LINK_LIBRARIES "${_wrap_gl_libs}") + endif() + endif() +endif() + +# ============================================================================= +# Common Dependencies +# ============================================================================= +find_package(Threads REQUIRED) + +# ============================================================================= +# RPATH Configuration +# ============================================================================= +LSL_configure_rpath() + +# ============================================================================= +# Targets +# ============================================================================= +add_subdirectory(src/core) + +if(BLACKROCK_BUILD_CLI) + add_subdirectory(src/cli) +endif() + +if(BLACKROCK_BUILD_GUI) + add_subdirectory(src/gui) +endif() + +# ============================================================================= +# Installation +# ============================================================================= +include(GNUInstallDirs) + +if(WIN32) + set(INSTALL_BINDIR ".") + set(INSTALL_LIBDIR ".") + set(INSTALL_DATADIR ".") +elseif(APPLE) + set(INSTALL_BINDIR ".") + set(INSTALL_LIBDIR ".") + set(INSTALL_DATADIR ".") +else() + set(INSTALL_BINDIR "${CMAKE_INSTALL_BINDIR}") + set(INSTALL_LIBDIR "${CMAKE_INSTALL_LIBDIR}") + set(INSTALL_DATADIR "${CMAKE_INSTALL_DATADIR}/${PROJECT_NAME}") +endif() + +if(BLACKROCK_BUILD_CLI) + install(TARGETS ${PROJECT_NAME}CLI + RUNTIME DESTINATION "${INSTALL_BINDIR}" + ) +endif() + +install(TARGETS configure_channels + RUNTIME DESTINATION "${INSTALL_BINDIR}" +) + +if(BLACKROCK_BUILD_GUI) + install(TARGETS ${PROJECT_NAME} + RUNTIME DESTINATION "${INSTALL_BINDIR}" + BUNDLE DESTINATION "${INSTALL_BINDIR}" + ) +endif() + +set(_config_dest "${INSTALL_DATADIR}") +if(APPLE AND BLACKROCK_BUILD_GUI) + set(_config_dest "${INSTALL_BINDIR}/${PROJECT_NAME}.app/Contents/MacOS") +endif() +install(FILES ${PROJECT_NAME}.cfg DESTINATION "${_config_dest}") + +# ============================================================================= +# Bundle liblsl +# ============================================================================= +if(APPLE) + if(BLACKROCK_BUILD_GUI) + LSL_install_liblsl( + FRAMEWORK_DESTINATION "${INSTALL_BINDIR}/${PROJECT_NAME}.app/Contents/Frameworks" + ) + endif() + if(BLACKROCK_BUILD_CLI) + LSL_install_liblsl(FRAMEWORK_DESTINATION "Frameworks") + endif() +else() + LSL_install_liblsl(DESTINATION "${INSTALL_LIBDIR}") +endif() + +# ============================================================================= +# Qt Deployment +# ============================================================================= +if(BLACKROCK_BUILD_GUI) + LSL_deploy_qt(TARGET "${PROJECT_NAME}" DESTINATION "${INSTALL_BINDIR}") +endif() + +# ============================================================================= +# MinGW Runtime +# ============================================================================= +LSL_install_mingw_runtime(DESTINATION "${INSTALL_BINDIR}") + +# ============================================================================= +# macOS Code Signing +# ============================================================================= +if(APPLE) + set(MACOSX_BUNDLE_GUI_IDENTIFIER "org.labstreaminglayer.${PROJECT_NAME}") + set(MACOSX_BUNDLE_BUNDLE_VERSION "${PROJECT_VERSION}") + set(MACOSX_BUNDLE_SHORT_VERSION_STRING "${PROJECT_VERSION}") + + if(BLACKROCK_BUILD_GUI) + LSL_codesign( + TARGET "${PROJECT_NAME}" + DESTINATION "${INSTALL_BINDIR}" + ENTITLEMENTS "${CMAKE_CURRENT_SOURCE_DIR}/app.entitlements" + BUNDLE + ) + endif() + + if(BLACKROCK_BUILD_CLI) + LSL_codesign( + TARGET "${PROJECT_NAME}CLI" + DESTINATION "${INSTALL_BINDIR}" + ENTITLEMENTS "${CMAKE_CURRENT_SOURCE_DIR}/app.entitlements" + FRAMEWORK "Frameworks/lsl.framework" + ) + endif() +endif() + +# ============================================================================= +# CPack +# ============================================================================= +LSL_get_target_arch() +LSL_get_os_name() + +set(CPACK_PACKAGE_NAME "${PROJECT_NAME}") +set(CPACK_PACKAGE_VERSION "${PROJECT_VERSION}") +set(CPACK_PACKAGE_VENDOR "Labstreaminglayer") +set(CPACK_PACKAGE_DESCRIPTION_SUMMARY "${PROJECT_DESCRIPTION}") +set(CPACK_PACKAGE_HOMEPAGE_URL "${PROJECT_HOMEPAGE_URL}") +set(CPACK_PACKAGE_FILE_NAME "${PROJECT_NAME}-${PROJECT_VERSION}-${LSL_OS}_${LSL_ARCH}") +set(CPACK_STRIP_FILES ON) + +if(WIN32) + set(CPACK_GENERATOR ZIP) +elseif(APPLE) + set(CPACK_GENERATOR TGZ) +else() + set(CPACK_GENERATOR DEB TGZ) + set(CPACK_DEBIAN_PACKAGE_MAINTAINER "LabStreamingLayer Developers") + set(CPACK_DEBIAN_PACKAGE_SECTION "science") + set(CPACK_DEBIAN_FILE_NAME "${CPACK_PACKAGE_FILE_NAME}.deb") + set(CPACK_DEBIAN_PACKAGE_SHLIBDEPS OFF) + if(BLACKROCK_BUILD_GUI) + set(CPACK_DEBIAN_PACKAGE_DEPENDS "libqt6widgets6") + endif() +endif() + +include(CPack) diff --git a/LICENSE b/LICENSE index a97f811..f680530 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2019 LabStreamingLayer submodules +Copyright (c) 2018 Tristan Stenner Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal @@ -19,3 +19,18 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + +This software uses the following components: + +Qt, LGPL. +https://qt.io/qt-licensing-terms/. + +Boost, Boost Software License +https://www.boost.org/users/license.html + +liblsl, MIT +https://github.com/labstreaminglayer/liblsl/blob/master/LICENSE + +LSL App Template CPP Qt, MIT +https://github.com/labstreaminglayer/AppTemplate_cpp_qt/blob/master/LICENSE.txt diff --git a/README.md b/README.md index 056a719..81f6d6f 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,194 @@ -# App-BlackrockTimestamps +# LSL Application Template -This repository contains two different applications that do the same thing. Each pulls timestamps from Blackrock Cerebus / Neuroport system, then re-streams them over LSL as data samples. As each LSL data sample is timestamped with an LSL timestamp, this effectively creates LSL-Cerebus timestamp pairs. The user can use these pairs to learn a (rolling) linear transform between the two clock domains. +This is the reference template for building Lab Streaming Layer (LSL) applications in C++ with Qt6. Use this as a starting point for creating new LSL-compatible applications. -This approach of re-streaming timestamps only was chosen over re-streaming the data because (a) the data is on the PC anyway, (b) these data tend to be higher bandwidth than most LSL modalities so the cost incurred by re-streaming is great, and (c) XDF is inefficient at storing high-bandwidth data and spike events so the Blackrock native format is preferred for those data types. +## Features -See the `cbpy` folder for a Python script that is compatible with Cerebus / Neuroport firmware 7.5.x via CereLink's cbpy. +- **Modern CMake** (3.28+) with clean, documented structure +- **4-tier liblsl discovery**: source, install_root, system, FetchContent +- **CLI and GUI separation** with shared core library +- **Qt6** for the GUI (with Qt5 intentionally dropped for simplicity) +- **Cross-platform**: Linux, macOS, Windows +- **macOS code signing** with entitlements for network capabilities +- **Automated CI/CD** via GitHub Actions -See the `cbsdk_Win` folder for a C++ application that is compatible with Cerebus / Neuroport firmware via cbsdk, for Windows only. +## Project Structure + +``` +LSLTemplate/ +├── CMakeLists.txt # Root build configuration +├── app.entitlements # macOS network capabilities +├── LSLTemplate.cfg # Default configuration file +├── src/ +│ ├── core/ # Qt-independent core library +│ │ ├── include/lsltemplate/ +│ │ │ ├── Device.hpp # Device interface +│ │ │ ├── LSLOutlet.hpp # LSL outlet wrapper +│ │ │ ├── Config.hpp # Configuration management +│ │ │ └── StreamThread.hpp # Background streaming +│ │ └── src/ +│ ├── cli/ # Command-line application +│ │ └── main.cpp +│ └── gui/ # Qt6 GUI application +│ ├── MainWindow.hpp/cpp +│ ├── MainWindow.ui +│ └── main.cpp +├── scripts/ +│ └── sign_and_notarize.sh # macOS signing script +└── .github/workflows/ + └── build.yml # CI/CD workflow +``` + +## Building + +### Prerequisites + +- CMake 3.28 or later +- C++20 compatible compiler +- Qt6.8 (for GUI build) +- liblsl (optional - will be fetched automatically if not found) + +### Installing Qt 6.8 on Ubuntu + +Ubuntu's default repositories don't include Qt 6.8. Use [aqtinstall](https://github.com/miurahr/aqtinstall) to install it: + +```bash +# Install aqtinstall +pip install aqtinstall + +# Install Qt 6.8 (adjust version as needed) +aqt install-qt linux desktop 6.8.3 gcc_64 -O ~/Qt + +# Set environment for CMake to find Qt +export CMAKE_PREFIX_PATH=~/Qt/6.8.3/gcc_64 + +# Install system dependencies +sudo apt-get install libgl1-mesa-dev libxkbcommon-dev libxcb-cursor0 +``` + +To run the GUI application, ensure Qt libraries are in your library path: + +```bash +export LD_LIBRARY_PATH=~/Qt/6.8.3/gcc_64/lib:$LD_LIBRARY_PATH +``` + +Alternatively, build CLI-only with `-DLSLTEMPLATE_BUILD_GUI=OFF` to avoid the Qt dependency. + +### Quick Start + +```bash +# Clone and build +git clone https://github.com/labstreaminglayer/AppTemplate_cpp_qt.git +cd AppTemplate_cpp_qt + +# Configure (liblsl will be fetched automatically) +cmake -S . -B build -DCMAKE_BUILD_TYPE=Release + +# Build +cmake --build build --parallel + +# Install +cmake --install build --prefix build/install + +# Package +cd build && cpack +``` + +### Build Options + +| Option | Default | Description | +|--------|---------|-------------| +| `LSLTEMPLATE_BUILD_GUI` | ON | Build the GUI application | +| `LSLTEMPLATE_BUILD_CLI` | ON | Build the CLI application | +| `LSL_FETCH_IF_MISSING` | ON | Auto-fetch liblsl from GitHub | +| `LSL_FETCH_REF` | (see CMakeLists.txt) | liblsl git ref to fetch (tag, branch, or commit) | +| `LSL_SOURCE_DIR` | - | Path to liblsl source (for development) | +| `LSL_INSTALL_ROOT` | - | Path to installed liblsl | + +### liblsl Discovery Priority + +The build system searches for liblsl in this order: + +1. **LSL_SOURCE_DIR** - Build from local source (for parallel liblsl development) +2. **LSL_INSTALL_ROOT** - Explicit installation path +3. **System** - Standard CMake search paths +4. **FetchContent** - Automatic download from GitHub + +### CLI-Only Build + +For headless systems or servers: + +```bash +cmake -S . -B build -DLSLTEMPLATE_BUILD_GUI=OFF +cmake --build build +``` + +### Building with Local liblsl + +For parallel development with liblsl: + +```bash +cmake -S . -B build -DLSL_SOURCE_DIR=/path/to/liblsl +``` + +## Usage + +### GUI Application + +```bash +./LSLTemplate # Use default config +./LSLTemplate myconfig.cfg # Use custom config +``` + +### CLI Application + +```bash +./LSLTemplateCLI --help +./LSLTemplateCLI --name MyStream --rate 256 --channels 8 +./LSLTemplateCLI --config myconfig.cfg +``` + +## Customizing for Your Device + +1. **Fork/copy this template** +2. **Rename the project** in `CMakeLists.txt` +3. **Implement your device class** by deriving from `IDevice` in `src/core/include/lsltemplate/Device.hpp` +4. **Update the GUI** for device-specific settings in `src/gui/MainWindow.ui` +5. **Update configuration** fields in `src/core/include/lsltemplate/Config.hpp` + +## macOS Code Signing + +For local development, the build automatically applies ad-hoc signing with network entitlements. This allows the app to use LSL's multicast discovery. + +For distribution, use the signing script: + +```bash +# Sign only +./scripts/sign_and_notarize.sh build/install/LSLTemplate.app + +# Sign and notarize +export APPLE_CODE_SIGN_IDENTITY_APP="Developer ID Application: Your Name" +export APPLE_NOTARIZE_KEYCHAIN_PROFILE="your-profile" +./scripts/sign_and_notarize.sh build/install/LSLTemplate.app --notarize +``` + +## GitHub Actions Secrets + +For automated signing and notarization, the workflow expects these secrets from the `labstreaminglayer` organization: + +| Secret | Description | +|--------|-------------| +| `PROD_MACOS_CERTIFICATE` | Base64-encoded Developer ID Application certificate (.p12) | +| `PROD_MACOS_CERTIFICATE_PWD` | Certificate password | +| `PROD_MACOS_CI_KEYCHAIN_PWD` | Password for temporary CI keychain | +| `PROD_MACOS_NOTARIZATION_APPLE_ID` | Apple ID email for notarization | +| `PROD_MACOS_NOTARIZATION_PWD` | App-specific password for Apple ID | +| `PROD_MACOS_NOTARIZATION_TEAM_ID` | Apple Developer Team ID | + +**Important:** These organization secrets must be shared with your repository. In GitHub: +1. Go to Organization Settings → Secrets and variables → Actions +2. For each secret, click to edit and under "Repository access" select the repositories that need access + +## License + +MIT License - see LICENSE diff --git a/app.entitlements b/app.entitlements new file mode 100644 index 0000000..99d0921 --- /dev/null +++ b/app.entitlements @@ -0,0 +1,17 @@ + + + + + + com.apple.security.network.client + + + + com.apple.security.network.server + + + + com.apple.security.network.multicast + + + diff --git a/cbpy/README.md b/cbpy/README.md deleted file mode 100644 index 8f9c095..0000000 --- a/cbpy/README.md +++ /dev/null @@ -1,36 +0,0 @@ -# BlackrockTimestamps over cbpy - -See the top-level [README](#../README.md) for the motivation behind this script. - -## Usage - -The current version of this script is designed to work with Cerebus / Neuroport firmware 7.5.x. Please check the repo tags for older versions. - -With firmware 7.5 came 64-bit uint timestamps. The `pylsl -> liblsl-outlet -> liblsl-inlet -> arbitrary language` path does not have good support for 64-bit integers because their implementation is not consistent on all platforms. That is somewhat moot anyway, because Blackrock timestamps are uint64, and LSL's nominal support for 64-bit integers is signed only. - -Therefore, this application creates a 2-channel 32-bit signed integer stream. Whether loading the XDF or processing the stream in real-time, the data is initially received as a `[n_sample, 2]` int32 array and must be reinterpreted as a `[n_sample, 1]` uint64 array. This is trivial to do in numpy: - -`arru64 = arr32.view(np.uint64)` - -See the accompanying test.py script for a more detailed example. - -## Requirements - -* Python 3.9 environment with numpy. - * `conda create -n br_lsl python=3.9 numpy` - * `conda activate br_lsl` - * If you need to modify cerebus.cbpy and build, you also need Qt6 and cython - * `conda install pyqt cython` - * If you want to run the test script then you also need a few more packages - * `conda install scipy matplotlib` -* [pylsl](https://github.com/labstreaminglayer/pylsl) - * `pip install pylsl` - * On non-Windows platforms you will need to separately install [liblsl](https://github.com/sccn/liblsl/releases). For most users, this is easiest with [conda-forge](https://anaconda.org/conda-forge/liblsl) or [homebrew](https://github.com/labstreaminglayer/homebrew-tap). -* [cerebus.cbpy](https://github.com/CerebusOSS/CereLink) - * Windows: `pip install https://github.com/CerebusOSS/CereLink/releases/download/v7.5.1b3/cerebus-0.0.5-cp39-cp39-win_amd64.whl` - * MacOS ARM: `pip install https://github.com/CerebusOSS/CereLink/releases/download/v7.5.1b3/cerebus-0.0.5-cp39-cp39-macosx_11_0_arm64.whl` - * Linux x64: `pip install https://github.com/CerebusOSS/CereLink/releases/download/v7.5.1b3/cerebus-0.0.5-cp39-cp39-linux_x86_64.whl` - -## Notes - -[cerebus API](https://github.com/dashesy/CereLink/blob/master/cerebus/cbpy.pyx) diff --git a/cbpy/blackrock_lsl.py b/cbpy/blackrock_lsl.py deleted file mode 100644 index 2a6df41..0000000 --- a/cbpy/blackrock_lsl.py +++ /dev/null @@ -1,51 +0,0 @@ -import platform -import struct -import time - - -import numpy as np -import pylsl -from cerebus import cbpy - - -STREAM_NAME = 'BlackrockTimestamps' -TYPE = 'sync' -SYNC_RATE = 5 # Hz -SOURCE_ID = 'CEREBUS000' -CHANNEL_NAMES = ['TsHigh32', 'TsLow32'] -CHAN_FORMAT = pylsl.cf_int32 - -print("Connecting to Blackrock device...") -conn_params = cbpy.defaultConParams() -cbpy.open(parameter=conn_params) - -print("Creating LSL Outlet") -outlet_info = pylsl.StreamInfo(name=STREAM_NAME, type=TYPE, channel_count=len(CHANNEL_NAMES), - nominal_srate=pylsl.IRREGULAR_RATE, channel_format=CHAN_FORMAT, source_id=SOURCE_ID) -outlet_info.desc().append_child_value("manufacturer", "Blackrock") -channels = outlet_info.desc().append_child("channels") -for c in CHANNEL_NAMES: - channels.append_child("channel").append_child_value("label", c) -outlet = pylsl.StreamOutlet(outlet_info) - -print("Sending timestamps...") -start_time = pylsl.local_clock() -n_samples = 0 -target_isi = 1 / SYNC_RATE -ts_arr = np.zeros((1,), dtype=np.uint64) -view_32 = ts_arr.view(np.int32) -try: - while True: - while (pylsl.local_clock() - start_time) < (n_samples * target_isi): - time.sleep(0.1/SYNC_RATE) - cbpy.trial_config(reset=True, noevent=True, nocontinuous=True, nocomment=True) - res, ts = cbpy.time() - ts_lsl = pylsl.local_clock() - ts_arr[0] = ts - outlet.push_sample(view_32, timestamp=ts_lsl) - n_samples += 1 -except KeyboardInterrupt: - print("Terminating...") - -del outlet -cbpy.close() diff --git a/cbpy/test.py b/cbpy/test.py deleted file mode 100644 index 60107c5..0000000 --- a/cbpy/test.py +++ /dev/null @@ -1,30 +0,0 @@ -import pylsl -import numpy as np - - -N_SAMPLES = 200 -streams = pylsl.resolve_stream('name', 'BlackrockTimestamps') -inlet = pylsl.StreamInlet(streams[0]) - -lsl_clocks = np.zeros((N_SAMPLES,), dtype=np.float) -pull_data = np.zeros((N_SAMPLES, inlet.channel_count), dtype=np.int32) -ts_data = pull_data.view(np.uint64) -for ix in range(N_SAMPLES): - sample, timestamp = inlet.pull_sample() - lsl_clocks[ix] = timestamp - pull_data[ix] = sample - print(lsl_clocks[ix], ts_data[ix]) - - -import matplotlib.pyplot as plt -from scipy import stats -slope, intercept, r_value, p_value, std_err = stats.linregress(lsl_clocks, ts_data[:, 0]) -print("slope: %f intercept: %f" % (slope, intercept)) -print("r-squared: %f" % r_value**2) -plt.plot(lsl_clocks, ts_data, 'o', label='timestamps') -plt.plot(lsl_clocks, intercept + slope * lsl_clocks, '-', label='fit') -plt.xlabel('LSL Times (s)') -plt.ylabel('Blackrock Timestamps (N)') -plt.legend() -plt.show() -print("Done") diff --git a/cbsdk_Win/.gitignore b/cbsdk_Win/.gitignore deleted file mode 100644 index 55fd3ee..0000000 --- a/cbsdk_Win/.gitignore +++ /dev/null @@ -1,17 +0,0 @@ -/build*/ -/package*/ -/install*/ -/CMakeLists.txt.user -/CMakeLists.json -/.vs/ -/.vscode/ -/out/ -/CMakeSettings.json -# CLion -.idea/ -cmake-build-*/ -# Generated by CI scripts - or maintainers debugging CI scripts: -liblsl.deb -install-qt.sh -.DS_Store - diff --git a/cbsdk_Win/CMakeLists.txt b/cbsdk_Win/CMakeLists.txt deleted file mode 100644 index 842544b..0000000 --- a/cbsdk_Win/CMakeLists.txt +++ /dev/null @@ -1,98 +0,0 @@ -cmake_minimum_required(VERSION 3.12) - -project(BlackrockTimestampsLSL - DESCRIPTION "Capture Blackrock Neurotech timestamps and re-stream over an LSL outlet" - HOMEPAGE_URL "https://github.com/labstreaminglayer/App-BlackrockTimestamps/" - LANGUAGES C CXX - VERSION 1.0) - -# Needed for customized MacOSXBundleInfo.plist.in -SET(CMAKE_MODULE_PATH "${CMAKE_CURRENT_LIST_DIR}/cmake" ${CMAKE_MODULE_PATH}) - -set(CMAKE_CXX_STANDARD 17) -set(CMAKE_CXX_STANDARD_REQUIRED On) - -# Dependencies -if(CMAKE_SIZEOF_VOID_P EQUAL 8) - set(TARGET_PLATFORM amd64) -elseif(CMAKE_SIZEOF_VOID_P EQUAL 4) - set(TARGET_PLATFORM i386) -endif() -include(FetchContent) - -## Prefetch -FetchContent_Populate( - lsl_pkg - URL "https://github.com/sccn/liblsl/releases/download/v1.16.1/liblsl-1.16.1-Win_${TARGET_PLATFORM}.zip" - SOURCE_DIR liblsl -) -FetchContent_Declare( - cxxopts - GIT_REPOSITORY https://github.com/jarro2783/cxxopts.git - GIT_TAG v3.1.1 - GIT_SHALLOW TRUE -) -set(CXXOPTS_BUILD_EXAMPLES OFF CACHE BOOL "" FORCE) -set(CXXOPTS_BUILD_TESTS OFF CACHE BOOL "" FORCE) -set(CXXOPTS_ENABLE_INSTALL OFF CACHE BOOL "" FORCE) -set(CXXOPTS_ENABLE_WARNINGS OFF CACHE BOOL "" FORCE) - -## Threads -find_package(Threads REQUIRED) - -## LSL -find_package(LSL REQUIRED - HINTS - ${LSL_INSTALL_ROOT} - "${CMAKE_CURRENT_BINARY_DIR}/liblsl" - PATH_SUFFIXES - lib - share/LSL -) - -## cxxopts -FetchContent_MakeAvailable(cxxopts) - -## cbsdk -find_package(CBSDK REQUIRED) -get_target_property(cbsdk_proto_ver CBSDK::cbsdk VERSION) -string(SUBSTRING ${cbsdk_proto_ver} 0 1 cbsdk_proto_ver_major) - -# Targets -add_executable(${PROJECT_NAME}) - -target_sources(${PROJECT_NAME} PRIVATE - src/main.cpp -) - -if(${cbsdk_proto_ver_major} GREATER 3) - # Protocol 4 introduced cbPKT_HEADER and 64-bit timestamps - target_compile_definitions(${PROJECT_NAME} PUBLIC CBSDK_PROTO_4) -endif() - -target_link_libraries(${PROJECT_NAME} - PRIVATE - Threads::Threads - LSL::lsl - cxxopts - CBSDK::cbsdk -) - -add_custom_command(TARGET ${PROJECT_NAME} POST_BUILD - COMMAND ${CMAKE_COMMAND} -E copy_if_different - $ - $ - COMMAND_EXPAND_LISTS -) - -include(GNUInstallDirs) # definitions of CMAKE_INSTALL_LIBDIR, CMAKE_INSTALL_INCLUDEDIR and others -install(TARGETS ${PROJECT_NAME} - # these get default values from GNUInstallDirs, no need to set them - BUNDLE DESTINATION ${CMAKE_INSTALL_BINDIR} - RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR} # bin -) -install( - FILES - $ - TYPE BIN -) diff --git a/cbsdk_Win/README.md b/cbsdk_Win/README.md deleted file mode 100644 index 066c387..0000000 --- a/cbsdk_Win/README.md +++ /dev/null @@ -1,33 +0,0 @@ -# BlackrockTimestamps over cbsdk on Windows - -See the top-level [README](#../README.md) for the motivation behind this application. - -## Usage - -The current version of this application is designed to work with Cerebus / Neuroport using cbsdk for Windows that ships with Cerebus / NeuroPort Central Suite matching the hardware firmware version. - -The application must be uniquely built for each firmware version. For example, if you build it using cbsdk for firmware 6.05, the application will then fail to work on a device running firmware 7.05. - -To keep this application's stream compatible with the sister Python script's streams targeting newer firmware with 64-bit timestamps, here we also create a 2-channel 32-bit integer stream. - -Whether using this application or the Python script, the receiving inlet code or the XDF analysis code should reinterpret the `[n_sample, 2]` int32 chunks as `[n_sample, 1]` uint64 chunks. This is trivial to do in numpy: - -`arru64 = arr32.view(np.uint64)` - -## Build Instructions - -### Prerequisites - -* Blackrock Cerebus or NeuroPort Windows Suite -* An IDE with integrated CMake like Visual Studio or CLion. - * If using CLion, MSYS2 mingw32 doesn't seem to work with the cbsdkx64.dll. Use MSVC or non msys2 mingw32. -* An internet connection at project config time to download dependencies. - -### Step-by-step - -* Open this `cbsdk_Win` folder in your IDE. (Tested in CLion). -* If necessary, modify the CMake `CBSDK_ROOT` variable to the cbsdk path. -* Build the application. -* Copy QtCore*.dll and QtXml*.dll from your Central folder (parent to cbsdk) to the build directory. -* Open a Terminal window, cd to the build directory, and run `BlackrockTimestampsLSL.exe -h` to see the available arguments. -* Run `BlackrockTimestampsLSL.exe`, optionally passing any arguments. diff --git a/cbsdk_Win/cmake/FindCBSDK.cmake b/cbsdk_Win/cmake/FindCBSDK.cmake deleted file mode 100644 index 9de8336..0000000 --- a/cbsdk_Win/cmake/FindCBSDK.cmake +++ /dev/null @@ -1,96 +0,0 @@ -# FindCBSDK -# ------------- -# -# Find Cerebus SDK CBSDK -# -# Use this module by invoking find_package with the form:: -# -# find_package( CBSDK [REQUIRED] ) -# -# -# target_link_libraries( ${PROJECT_NAME} CBSDK::cbsdk ) -# -# ============================================================================= -# -# Some portions of this script are copied from other similar CMake find package scripts. -# Copyright (c) 2023 Chadwick Boulay -# Distributed under the MIT License. -# -# Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: -# The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -# -# ============================================================================= - - -if(NOT DEFINED CBSDK_ROOT) - set(CBSDK_ROOT "C:\\Program Files (x86)\\Blackrock Microsystems\\NeuroPort Windows Suite\\cbsdk") -endif(NOT DEFINED CBSDK_ROOT) -set(CBSDK_ROOT "${CBSDK_ROOT}" CACHE PATH "CBSDK root directory to look in.") - -# Target Platform -set(TARGET_PLATFORM) -if(CMAKE_SIZEOF_VOID_P EQUAL 8) - set(TARGET_PLATFORM x64) -endif() - -find_library(CBSDK_LIBRARY - NAMES - cbsdk${TARGET_PLATFORM} - PATHS - "${CBSDK_ROOT}" - "C:\\Program Files (x86)\\Blackrock Microsystems\\Cerebus Central Suite\\cbsdk" - "C:\\Program Files\\Blackrock Microsystems\\NeuroPort Windows Suite\\cbsdk" - "C:\\Program Files\\Blackrock Microsystems\\Cerebus Central Suite\\cbsdk" - PATH_SUFFIXES - lib -) - -find_path(CBSDK_INCLUDE_DIR - NAMES - cbsdk.h - PATHS - "${CBSDK_ROOT}" - PATH_SUFFIXES - include -) - -# handle the QUIETLY and REQUIRED arguments and set xxx_FOUND to TRUE if -# all listed variables are TRUE -include(FindPackageHandleStandardArgs) -find_package_handle_standard_args(CBSDK - DEFAULT_MSG - CBSDK_LIBRARY - CBSDK_INCLUDE_DIR -) - -if(CBSDK_FOUND) - set(CBSDK_LIBRARIES cbsdk${TARGET_PLATFORM}) - set(CBSDK_INCLUDE_DIRS ${CBSDK_INCLUDE_DIR}) - - # Get version from cbhwlib.h cbVERSION_MAJOR.cbVERSION_MINOR - # Technically, this is the protocl version, not the library version. - file(READ "${CBSDK_INCLUDE_DIR}/cbhwlib.h" cbhw) - string(REGEX MATCH "cbVERSION_MAJOR +([0-9]+)" proto_ver_major ${cbhw}) - set(proto_ver_major ${CMAKE_MATCH_1}) - string(REGEX MATCH "cbVERSION_MINOR +([0-9]+)" _ ${cbhw}) - set(proto_ver_minor ${CMAKE_MATCH_1}) - - add_library(CBSDK::cbsdk SHARED IMPORTED) - set_target_properties(CBSDK::cbsdk - PROPERTIES - IMPORTED_LOCATION ${CBSDK_ROOT}/lib/cbsdk${TARGET_PLATFORM}.dll # The dll - INTERFACE_INCLUDE_DIRECTORIES ${CBSDK_INCLUDE_DIRS} - IMPORTED_IMPLIB ${CBSDK_ROOT}/lib/cbsdk${TARGET_PLATFORM}.lib - VERSION ${proto_ver_major}.${proto_ver_minor} - ) -endif(CBSDK_FOUND) - -mark_as_advanced( - CBSDK_LIBRARY - CBSDK_INCLUDE_DIR -) - -if(CBSDK_FOUND) - mark_as_advanced(CBSDK_ROOT) -endif() diff --git a/cbsdk_Win/src/main.cpp b/cbsdk_Win/src/main.cpp deleted file mode 100644 index aa3e291..0000000 --- a/cbsdk_Win/src/main.cpp +++ /dev/null @@ -1,124 +0,0 @@ -#define WIN32_LEAN_AND_MEAN -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -using namespace std::chrono_literals; - - -static volatile sig_atomic_t keep_running = 1; -static void sig_handler(int _) -{ - (void)_; - keep_running = 0; -} - - -static int32_t sample[2] = {0, 0}; -void heartbeat_lsl_callback(uint32_t nInstance, const cbSdkPktType type, const void* pEventData, void* pCallbackData) -{ - auto* pOutlet = (lsl::stream_outlet*)pCallbackData; - auto* pPkt = (cbPKT_SYSHEARTBEAT*)pEventData; -#ifdef CBSDK_PROTO_4 - memcpy(sample, &pPkt->cbpkt_header.time, sizeof(pPkt->cbpkt_header.time)); -#else - sample[1] = pPkt->time; -#endif // CBSDK_PROTO_4 - pOutlet->push_sample(sample); -} - - -int main(int argc, char* argv[]) { - // cxxopts - Define and parse commandline arguments. - cxxopts::Options options("BlackrockTimestampsLSL", "Retrieve timestamps from Blackrock hardware and re-stream over LSL outlet."); - - options.add_options() - ("i,inst_ip", "Instrument IP address", cxxopts::value()->default_value("")) - ("c,client_ip", "Client interface IP address", cxxopts::value()->default_value("")) - ("h,help", "Print usage") - ; - - auto result = options.parse(argc, argv); - - if (result.count("help")) - { - std::cout << options.help() << std::endl; - exit(0); - } - - - // instantiate connection to device - std::cout << "initializing cbsdk connection..." << std::endl; - uint32_t nInstance = 0; - cbSdkConnection con = cbSdkConnection(); - con.szInIP = result["client_ip"].as().c_str(); - con.szOutIP = result["inst_ip"].as().c_str(); - cbSdkResult res = cbSdkOpen( - nInstance, - CBSDKCONNECTION_DEFAULT, - con - ); - if (res != CBSDKRESULT_SUCCESS) - { - std::cerr << "cbSdkOpen failed!" << std::endl; - if (res == CBSDKRESULT_ERROFFLINE) - std::cerr << "CBSDK device offline!" << std::endl; - return 1; - } - - // Test version - cbSdkVersion version; - res = cbSdkGetVersion(nInstance, &version); - if (res == CBSDKRESULT_SUCCESS) { - std::cout << "cbSdkGetVersion returned " << version.major << "." << version.minor << "." << version.release << std::endl; - } - else { - std::cerr << "cbSdkGetVersion failed! Possible protocol mismatch." << std::endl; - return 1; - } - - // Setup LSL Outlet - std::cout << "opening outlet..." << std::endl; - lsl::stream_info info( - "BlackrockTimestamps", - "sync", - 2, - lsl::IRREGULAR_RATE, - lsl::cf_int32, - "CEREBUS000" - ); - // Header - lsl::xml_element channels = info.desc().append_child("channels"); - channels.append_child("channel").append_child_value("label", "TsHigh32"); - channels.append_child("channel").append_child_value("label", "TsLow32"); - info.desc().append_child("acquisition") - .append_child_value("manufacturer","Blackrock Neurotech"); - // Instantiate outlet - lsl::stream_outlet outlet(info); - - signal(SIGINT, sig_handler); - - try { - // Register callback for heartbeat packets - res = cbSdkRegisterCallback(nInstance, CBSDKCALLBACK_SYSHEARTBEAT, heartbeat_lsl_callback, &outlet); - // TODO: handle res - while (keep_running) { - std::this_thread::sleep_for(500ms); - } - res = cbSdkUnRegisterCallback(nInstance, CBSDKCALLBACK_SYSHEARTBEAT); - // TODO: handle res - cbSdkClose(nInstance); - } - catch(std::exception &e) { - std::cerr << "Error in BlackrockTimestampLSL: " << e.what() << std::endl; - return 1; - } - return 0; -} diff --git a/scripts/sign_and_notarize.sh b/scripts/sign_and_notarize.sh new file mode 100755 index 0000000..9410e44 --- /dev/null +++ b/scripts/sign_and_notarize.sh @@ -0,0 +1,119 @@ +#!/bin/bash +# ============================================================================= +# Apple Code Signing and Notarization Script +# ============================================================================= +# This script handles identity-based signing and notarization for macOS apps. +# It's designed to be called from CI (GitHub Actions) after the build. +# +# Usage: +# ./scripts/sign_and_notarize.sh [--notarize] +# +# Environment Variables (set in CI): +# APPLE_CODE_SIGN_IDENTITY_APP - Developer ID Application certificate name +# APPLE_NOTARIZE_KEYCHAIN_PROFILE - notarytool credential profile name +# ENTITLEMENTS_FILE - Path to entitlements file (optional) +# +# Examples: +# # Sign only (for testing) +# ./scripts/sign_and_notarize.sh build/install/LSLTemplate.app +# +# # Sign and notarize (for release) +# ./scripts/sign_and_notarize.sh build/install/LSLTemplate.app --notarize +# ============================================================================= + +set -e + +# Parse arguments +APP_PATH="$1" +DO_NOTARIZE=false + +if [[ "$2" == "--notarize" ]]; then + DO_NOTARIZE=true +fi + +if [[ -z "$APP_PATH" ]]; then + echo "Usage: $0 [--notarize]" + exit 1 +fi + +if [[ ! -e "$APP_PATH" ]]; then + echo "Error: $APP_PATH does not exist" + exit 1 +fi + +# Default to ad-hoc signing if no identity specified +SIGN_IDENTITY="${APPLE_CODE_SIGN_IDENTITY_APP:--}" +ENTITLEMENTS_ARG="" + +# Use entitlements if specified and exists +if [[ -n "${ENTITLEMENTS_FILE}" && -f "${ENTITLEMENTS_FILE}" ]]; then + ENTITLEMENTS_ARG="--entitlements ${ENTITLEMENTS_FILE}" +elif [[ -f "$(dirname "$0")/../app.entitlements" ]]; then + ENTITLEMENTS_ARG="--entitlements $(dirname "$0")/../app.entitlements" +fi + +echo "=== Code Signing ===" +echo "Target: $APP_PATH" +echo "Identity: $SIGN_IDENTITY" +echo "Entitlements: ${ENTITLEMENTS_ARG:-none}" + +if [[ -d "$APP_PATH" ]]; then + # App bundle - sign with deep and hardened runtime + codesign --force --deep --sign "$SIGN_IDENTITY" \ + --options runtime \ + $ENTITLEMENTS_ARG \ + "$APP_PATH" +else + # Single binary + codesign --force --sign "$SIGN_IDENTITY" \ + --options runtime \ + $ENTITLEMENTS_ARG \ + "$APP_PATH" +fi + +echo "Verifying signature..." +codesign --verify --verbose "$APP_PATH" + +# Check if we should notarize +if [[ "$DO_NOTARIZE" == true ]]; then + if [[ "$SIGN_IDENTITY" == "-" ]]; then + echo "Warning: Cannot notarize with ad-hoc signature. Skipping notarization." + exit 0 + fi + + if [[ -z "$APPLE_NOTARIZE_KEYCHAIN_PROFILE" ]]; then + echo "Error: APPLE_NOTARIZE_KEYCHAIN_PROFILE not set" + exit 1 + fi + + echo "" + echo "=== Notarizing ===" + + # Create zip for notarization submission + BASENAME=$(basename "$APP_PATH") + ZIP_PATH="/tmp/${BASENAME%.*}_notarize.zip" + + echo "Creating zip for submission: $ZIP_PATH" + ditto -c -k --keepParent "$APP_PATH" "$ZIP_PATH" + + echo "Submitting to Apple notarization service..." + xcrun notarytool submit "$ZIP_PATH" \ + --keychain-profile "$APPLE_NOTARIZE_KEYCHAIN_PROFILE" \ + --wait + + # Staple the notarization ticket + if [[ -d "$APP_PATH" ]]; then + echo "Stapling notarization ticket..." + xcrun stapler staple "$APP_PATH" + xcrun stapler validate "$APP_PATH" + fi + + # Clean up + rm -f "$ZIP_PATH" + + echo "" + echo "=== Notarization Complete ===" +fi + +echo "" +echo "Done!" diff --git a/src/cli/CMakeLists.txt b/src/cli/CMakeLists.txt new file mode 100644 index 0000000..e345bc0 --- /dev/null +++ b/src/cli/CMakeLists.txt @@ -0,0 +1,20 @@ +# CLI Application +add_executable(${PROJECT_NAME}CLI + main.cpp +) + +target_link_libraries(${PROJECT_NAME}CLI + PRIVATE + BlackrockOutlet::core +) + +# Windows: Copy DLLs to build directory for debugging +if(WIN32) + add_custom_command(TARGET ${PROJECT_NAME}CLI POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_if_different + $ + $ + COMMAND_EXPAND_LISTS + COMMENT "Copying runtime DLLs for ${PROJECT_NAME}CLI" + ) +endif() diff --git a/src/cli/main.cpp b/src/cli/main.cpp new file mode 100644 index 0000000..3bf9dc4 --- /dev/null +++ b/src/cli/main.cpp @@ -0,0 +1,158 @@ +/** + * @file main.cpp + * @brief CLI entry point for BlackrockOutlet + */ + +#include +#include +#include + +#include +#include +#include +#include + +namespace { + +std::atomic g_shutdown{false}; + +void signalHandler(int /*signum*/) { + std::cout << "\nShutdown requested..." << std::endl; + g_shutdown = true; +} + +void printUsage(const char* program_name) { + std::cout << "Usage: " << program_name << " [options]\n" + << "\n" + << "Options:\n" + << " -h, --help Show this help message\n" + << " -c, --config FILE Load configuration from FILE\n" + << " -n, --name NAME Stream name (default: auto-generated from device type)\n" + << " -d, --device TYPE Device type: hub1|hub2|hub3|nsp|legacy_nsp|nplay|custom\n" + << " -a, --address ADDR Device address (for custom type)\n" + << " -p, --port PORT Device port (for custom type)\n" + << " --ccf FILE CCF configuration file to send to device\n" + << " -g, --group N Sample group: 1-6, none, or 0=auto-detect\n" + << " --scaled Stream scaled float32 instead of raw int16\n" + << " --heartbeat Stream device heartbeat timestamps\n" + << "\n" + << "Sample groups:\n" + << " 1 = 500 Hz 2 = 1 kHz 3 = 2 kHz\n" + << " 4 = 10 kHz 5 = 30 kHz 6 = 30 kHz (raw)\n" + << " none = heartbeat only (no data stream)\n" + << "\n" + << "Example:\n" + << " " << program_name << " -d nplay -g 5\n" + << std::endl; +} + +void statusCallback(const std::string& message, bool is_error) { + if (is_error) { + std::cerr << "[ERROR] " << message << std::endl; + } else { + std::cout << "[INFO] " << message << std::endl; + } +} + +} // anonymous namespace + +int main(int argc, char* argv[]) { + blackrock::AppConfig config; + std::string config_file; + + for (int i = 1; i < argc; ++i) { + std::string arg = argv[i]; + + if (arg == "-h" || arg == "--help") { + printUsage(argv[0]); + return 0; + } else if ((arg == "-c" || arg == "--config") && i + 1 < argc) { + config_file = argv[++i]; + } else if ((arg == "-n" || arg == "--name") && i + 1 < argc) { + config.stream_name = argv[++i]; + } else if ((arg == "-d" || arg == "--device") && i + 1 < argc) { + config.device_type = argv[++i]; + } else if ((arg == "-a" || arg == "--address") && i + 1 < argc) { + config.custom_address = argv[++i]; + } else if ((arg == "-p" || arg == "--port") && i + 1 < argc) { + config.custom_port = static_cast(std::stoi(argv[++i])); + } else if (arg == "--ccf" && i + 1 < argc) { + config.ccf_file = argv[++i]; + } else if ((arg == "-g" || arg == "--group") && i + 1 < argc) { + std::string val = argv[++i]; + config.sample_group = (val == "none") ? -1 : std::stoi(val); + } else if (arg == "--scaled") { + config.scaled = true; + } else if (arg == "--heartbeat") { + config.heartbeat = true; + } else { + std::cerr << "Unknown option: " << arg << std::endl; + printUsage(argv[0]); + return 1; + } + } + + // Load config file if specified (CLI args override file values) + if (!config_file.empty()) { + auto loaded = blackrock::ConfigManager::load(config_file); + if (loaded) { + config = *loaded; + + // Re-apply CLI args over file config + for (int i = 1; i < argc; ++i) { + std::string arg = argv[i]; + if ((arg == "-c" || arg == "--config") && i + 1 < argc) { ++i; continue; } + if ((arg == "-n" || arg == "--name") && i + 1 < argc) { config.stream_name = argv[++i]; } + else if ((arg == "-d" || arg == "--device") && i + 1 < argc) { config.device_type = argv[++i]; } + else if ((arg == "-a" || arg == "--address") && i + 1 < argc) { config.custom_address = argv[++i]; } + else if ((arg == "-p" || arg == "--port") && i + 1 < argc) { config.custom_port = static_cast(std::stoi(argv[++i])); } + else if (arg == "--ccf" && i + 1 < argc) { config.ccf_file = argv[++i]; } + else if ((arg == "-g" || arg == "--group") && i + 1 < argc) { std::string val = argv[++i]; config.sample_group = (val == "none") ? -1 : std::stoi(val); } + else if (arg == "--scaled") { config.scaled = true; } + else if (arg == "--heartbeat") { config.heartbeat = true; } + } + std::cout << "Loaded configuration from: " << config_file << std::endl; + } else { + std::cerr << "Failed to load config file: " << config_file << std::endl; + return 1; + } + } + + std::signal(SIGINT, signalHandler); + std::signal(SIGTERM, signalHandler); + + auto device_config = blackrock::appConfigToDeviceConfig(config); + + std::cout << "BlackrockOutlet CLI" << std::endl; + std::cout << "Stream: " << device_config.stream_name << " (" << device_config.stream_type << ")" << std::endl; + std::cout << "Device: " << config.device_type << std::endl; + std::cout << "Sample group: "; + if (config.sample_group < 0) { + std::cout << "none (heartbeat only)"; + } else if (config.sample_group > 0) { + std::cout << config.sample_group; + } else { + std::cout << "auto-detect"; + } + std::cout << ", Format: " << (config.scaled ? "scaled (float32)" : "raw (int16)"); + if (config.heartbeat) std::cout << ", Heartbeat: enabled"; + std::cout << std::endl; + std::cout << "Press Ctrl+C to stop..." << std::endl; + auto device = std::make_unique(device_config); + + blackrock::StreamThread stream(std::move(device), statusCallback); + + if (!stream.start()) { + std::cerr << "Failed to start streaming" << std::endl; + return 1; + } + + while (!g_shutdown && stream.isRunning()) { + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + } + + stream.stop(); + + std::cout << "Shutdown complete." << std::endl; + return 0; +} diff --git a/src/core/CMakeLists.txt b/src/core/CMakeLists.txt new file mode 100644 index 0000000..d43e877 --- /dev/null +++ b/src/core/CMakeLists.txt @@ -0,0 +1,25 @@ +# Core library - Qt-independent, shared between CLI and GUI +add_library(blackrock_core STATIC + src/Device.cpp + src/LSLOutlet.cpp + src/Config.cpp + src/StreamThread.cpp +) + +target_include_directories(blackrock_core + PUBLIC + $ + $ +) + +target_link_libraries(blackrock_core + PUBLIC + LSL::lsl + Threads::Threads + cbdev + ccfutils + PRIVATE + xxHash::xxhash +) + +add_library(BlackrockOutlet::core ALIAS blackrock_core) diff --git a/src/core/include/blackrock/Config.hpp b/src/core/include/blackrock/Config.hpp new file mode 100644 index 0000000..5755fe8 --- /dev/null +++ b/src/core/include/blackrock/Config.hpp @@ -0,0 +1,43 @@ +#pragma once +/** + * @file Config.hpp + * @brief Configuration management for BlackrockOutlet + */ + +#include "Device.hpp" +#include +#include +#include + +namespace blackrock { + +std::string deviceTypeDisplayName(cbdev::DeviceType type); + +struct AppConfig { + std::string stream_name; + std::string stream_type = "ECEPhys"; + std::string device_type = "hub1"; + std::string custom_address; + uint16_t custom_port = 0; + std::string ccf_file; + int sample_group = 0; // 0=auto, 1-6=specific group, -1=none (heartbeat only) + bool scaled = false; + bool heartbeat = false; +}; + +cbdev::DeviceType deviceTypeFromString(const std::string& str); +std::string deviceTypeToString(cbdev::DeviceType type); + +DeviceConfig appConfigToDeviceConfig(const AppConfig& app); + +class ConfigManager { +public: + static std::optional load(const std::filesystem::path& path); + static bool save(const AppConfig& config, const std::filesystem::path& path); + static std::filesystem::path findConfigFile( + const std::string& filename, + const std::optional& hint = std::nullopt + ); +}; + +} // namespace blackrock diff --git a/src/core/include/blackrock/Device.hpp b/src/core/include/blackrock/Device.hpp new file mode 100644 index 0000000..eb6a5e2 --- /dev/null +++ b/src/core/include/blackrock/Device.hpp @@ -0,0 +1,124 @@ +#pragma once +/** + * @file Device.hpp + * @brief Blackrock Cerebus/Neuroport device interface for LSL streaming + */ + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace blackrock { + +struct Heartbeat { + uint64_t device_timestamp_ns = 0; + double lsl_timestamp = 0.0; +}; + +struct ChannelScaling { + int16_t digmin = -32768; + int16_t digmax = 32767; + int32_t anamin = -8192; + int32_t anamax = 8192; + std::string unit = "uV"; + + float toAnalog(int16_t raw) const { + if (digmax == digmin) return 0.0f; + return static_cast(anamin) + + (static_cast(raw) - static_cast(digmin)) * + (static_cast(anamax) - static_cast(anamin)) / + (static_cast(digmax) - static_cast(digmin)); + } +}; + +struct DeviceInfo { + std::string name; + std::string type = "ECEPhys"; + int channel_count = 0; + double sample_rate = 0.0; + std::string source_id; + bool scaled = false; + bool heartbeat = false; + std::vector channel_labels; + std::vector channel_scaling; +}; + +struct DeviceConfig { + cbdev::DeviceType device_type = cbdev::DeviceType::HUB1; + std::string custom_address; + uint16_t custom_port = 0; + + std::string ccf_file; + int sample_group = 0; // 0=auto (first active), 1-6=specific group + + std::string stream_name = "BlackrockOutlet"; + std::string stream_type = "ECEPhys"; + bool scaled = false; + bool heartbeat = false; +}; + +class IDevice { +public: + virtual ~IDevice() = default; + virtual bool connect() = 0; + virtual void disconnect() = 0; + virtual bool isConnected() const = 0; + virtual DeviceInfo getInfo() const = 0; + virtual bool getData(std::vector& buffer, double& timestamp) = 0; + virtual std::vector getHeartbeats() = 0; + virtual std::string lastError() const = 0; +}; + +class CerebusDevice : public IDevice { +public: + explicit CerebusDevice(const DeviceConfig& config); + ~CerebusDevice() override; + + bool connect() override; + void disconnect() override; + bool isConnected() const override; + DeviceInfo getInfo() const override; + bool getData(std::vector& buffer, double& timestamp) override; + std::vector getHeartbeats() override; + std::string lastError() const override; + +private: + struct TimestampedChunk { + std::vector samples; + double timestamp; + }; + + void onPacket(const cbPKT_GENERIC& pkt); + bool applyConfiguration(); + bool queryChannelInfo(); + double deviceTimeToLsl(uint64_t device_time_ns) const; + + DeviceConfig config_; + DeviceInfo info_; + std::string last_error_; + std::unique_ptr session_; + cbdev::CallbackHandle callback_handle_ = 0; + std::atomic connected_{false}; + + uint32_t target_group_ = 0; + std::vector active_channel_ids_; + + std::mutex queue_mutex_; + std::condition_variable queue_cv_; + std::deque sample_queue_; + static constexpr size_t MAX_QUEUE_SIZE = 1000; + + std::deque heartbeat_queue_; + static constexpr size_t MAX_HEARTBEAT_QUEUE_SIZE = 200; +}; + +} // namespace blackrock diff --git a/src/core/include/blackrock/LSLOutlet.hpp b/src/core/include/blackrock/LSLOutlet.hpp new file mode 100644 index 0000000..091803d --- /dev/null +++ b/src/core/include/blackrock/LSLOutlet.hpp @@ -0,0 +1,36 @@ +#pragma once +/** + * @file LSLOutlet.hpp + * @brief LSL stream outlet for Blackrock Cerebus devices + */ + +#include "Device.hpp" +#include +#include +#include +#include + +namespace blackrock { + +class LSLOutlet { +public: + explicit LSLOutlet(const DeviceInfo& info); + ~LSLOutlet(); + + LSLOutlet(const LSLOutlet&) = delete; + LSLOutlet& operator=(const LSLOutlet&) = delete; + LSLOutlet(LSLOutlet&&) noexcept = default; + LSLOutlet& operator=(LSLOutlet&&) noexcept = default; + + void pushChunk(const std::vector& data, double timestamp); + void pushChunk(const std::vector& data, double timestamp); + + std::string getStreamName() const; + bool hasConsumers() const; + +private: + std::unique_ptr outlet_; + DeviceInfo info_; +}; + +} // namespace blackrock diff --git a/src/core/include/blackrock/StreamThread.hpp b/src/core/include/blackrock/StreamThread.hpp new file mode 100644 index 0000000..6d9c662 --- /dev/null +++ b/src/core/include/blackrock/StreamThread.hpp @@ -0,0 +1,46 @@ +#pragma once +/** + * @file StreamThread.hpp + * @brief Background thread for Blackrock Cerebus LSL streaming + */ + +#include "Device.hpp" +#include "LSLOutlet.hpp" +#include +#include +#include +#include +#include + +namespace blackrock { + +using StatusCallback = std::function; + +class StreamThread { +public: + explicit StreamThread( + std::unique_ptr device, + StatusCallback callback = nullptr + ); + + ~StreamThread(); + + StreamThread(const StreamThread&) = delete; + StreamThread& operator=(const StreamThread&) = delete; + + bool start(); + void stop(); + bool isRunning() const; + DeviceInfo getDeviceInfo() const; + +private: + void threadFunction(); + + std::unique_ptr device_; + std::unique_ptr thread_; + std::atomic running_{false}; + std::atomic shutdown_{false}; + StatusCallback statusCallback_; +}; + +} // namespace blackrock diff --git a/src/core/src/Config.cpp b/src/core/src/Config.cpp new file mode 100644 index 0000000..4c85aa3 --- /dev/null +++ b/src/core/src/Config.cpp @@ -0,0 +1,243 @@ +#include "blackrock/Config.hpp" +#include +#include +#include +#include +#include + +#ifdef _WIN32 +#include +#include +#else +#include +#include +#ifdef __APPLE__ +#include +#else +#include +#endif +#endif + +namespace blackrock { + +namespace { + +std::string trim(const std::string& str) { + auto start = std::find_if_not(str.begin(), str.end(), + [](unsigned char c) { return std::isspace(c); }); + auto end = std::find_if_not(str.rbegin(), str.rend(), + [](unsigned char c) { return std::isspace(c); }).base(); + return (start < end) ? std::string(start, end) : std::string(); +} + +std::string toLower(std::string s) { + std::transform(s.begin(), s.end(), s.begin(), + [](unsigned char c) { return std::tolower(c); }); + return s; +} + +std::filesystem::path getExecutablePath() { +#ifdef _WIN32 + char buffer[MAX_PATH]; + GetModuleFileNameA(nullptr, buffer, MAX_PATH); + return std::filesystem::path(buffer).parent_path(); +#elif defined(__APPLE__) + char buffer[PATH_MAX]; + uint32_t size = sizeof(buffer); + if (_NSGetExecutablePath(buffer, &size) == 0) { + return std::filesystem::path(buffer).parent_path(); + } + return {}; +#else + char buffer[PATH_MAX]; + ssize_t len = readlink("/proc/self/exe", buffer, sizeof(buffer) - 1); + if (len != -1) { + buffer[len] = '\0'; + return std::filesystem::path(buffer).parent_path(); + } + return {}; +#endif +} + +std::filesystem::path getConfigDirectory() { +#ifdef _WIN32 + char buffer[MAX_PATH]; + if (SUCCEEDED(SHGetFolderPathA(nullptr, CSIDL_APPDATA, nullptr, 0, buffer))) { + return std::filesystem::path(buffer); + } + return {}; +#elif defined(__APPLE__) + const char* home = getenv("HOME"); + if (home) { + return std::filesystem::path(home) / "Library" / "Preferences"; + } + return {}; +#else + const char* xdg_config = getenv("XDG_CONFIG_HOME"); + if (xdg_config && *xdg_config) { + return std::filesystem::path(xdg_config); + } + const char* home = getenv("HOME"); + if (home) { + return std::filesystem::path(home) / ".config"; + } + return {}; +#endif +} + +} // anonymous namespace + +cbdev::DeviceType deviceTypeFromString(const std::string& str) { + std::string s = toLower(str); + if (s == "hub1") return cbdev::DeviceType::HUB1; + if (s == "hub2") return cbdev::DeviceType::HUB2; + if (s == "hub3") return cbdev::DeviceType::HUB3; + if (s == "nsp") return cbdev::DeviceType::NSP; + if (s == "legacy_nsp") return cbdev::DeviceType::LEGACY_NSP; + if (s == "nplay") return cbdev::DeviceType::NPLAY; + if (s == "custom") return cbdev::DeviceType::CUSTOM; + return cbdev::DeviceType::HUB1; +} + +std::string deviceTypeToString(cbdev::DeviceType type) { + switch (type) { + case cbdev::DeviceType::HUB1: return "hub1"; + case cbdev::DeviceType::HUB2: return "hub2"; + case cbdev::DeviceType::HUB3: return "hub3"; + case cbdev::DeviceType::NSP: return "nsp"; + case cbdev::DeviceType::LEGACY_NSP: return "legacy_nsp"; + case cbdev::DeviceType::NPLAY: return "nplay"; + case cbdev::DeviceType::CUSTOM: return "custom"; + default: return "hub1"; + } +} + +std::string deviceTypeDisplayName(cbdev::DeviceType type) { + switch (type) { + case cbdev::DeviceType::HUB1: return "Hub1"; + case cbdev::DeviceType::HUB2: return "Hub2"; + case cbdev::DeviceType::HUB3: return "Hub3"; + case cbdev::DeviceType::NSP: return "NSP"; + case cbdev::DeviceType::LEGACY_NSP: return "LegacyNSP"; + case cbdev::DeviceType::NPLAY: return "nPlay"; + case cbdev::DeviceType::CUSTOM: return "Custom"; + default: return "Hub1"; + } +} + +DeviceConfig appConfigToDeviceConfig(const AppConfig& app) { + DeviceConfig dc; + dc.device_type = deviceTypeFromString(app.device_type); + dc.custom_address = app.custom_address; + dc.custom_port = app.custom_port; + dc.ccf_file = app.ccf_file; + dc.sample_group = app.sample_group; + dc.stream_name = app.stream_name.empty() + ? "Blackrock-" + deviceTypeDisplayName(dc.device_type) + : app.stream_name; + dc.stream_type = app.stream_type; + dc.scaled = app.scaled; + dc.heartbeat = app.heartbeat; + return dc; +} + +std::optional ConfigManager::load(const std::filesystem::path& path) { + std::ifstream file(path); + if (!file.is_open()) return std::nullopt; + + AppConfig config; + std::string line; + std::string current_section; + + while (std::getline(file, line)) { + line = trim(line); + if (line.empty() || line[0] == '#' || line[0] == ';') continue; + + if (line.front() == '[' && line.back() == ']') { + current_section = line.substr(1, line.size() - 2); + continue; + } + + auto eq_pos = line.find('='); + if (eq_pos != std::string::npos) { + std::string key = trim(line.substr(0, eq_pos)); + std::string value = trim(line.substr(eq_pos + 1)); + + if (value.size() >= 2 && value.front() == '"' && value.back() == '"') { + value = value.substr(1, value.size() - 2); + } + + if (key == "name" || key == "stream_name") { + config.stream_name = value; + } else if (key == "type" || key == "stream_type") { + config.stream_type = value; + } else if (key == "device_type") { + config.device_type = value; + } else if (key == "custom_address" || key == "address") { + config.custom_address = value; + } else if (key == "custom_port" || key == "port") { + config.custom_port = static_cast(std::stoi(value)); + } else if (key == "ccf_file" || key == "ccf") { + config.ccf_file = value; + } else if (key == "sample_group" || key == "group") { + config.sample_group = std::stoi(value); + } else if (key == "scaled") { + std::string v = toLower(value); + config.scaled = (v == "true" || v == "1" || v == "yes"); + } else if (key == "heartbeat") { + std::string v = toLower(value); + config.heartbeat = (v == "true" || v == "1" || v == "yes"); + } + } + } + + return config; +} + +bool ConfigManager::save(const AppConfig& config, const std::filesystem::path& path) { + std::ofstream file(path); + if (!file.is_open()) return false; + + file << "# BlackrockOutlet Configuration\n"; + file << "[Stream]\n"; + file << "name=" << config.stream_name << "\n"; + file << "type=" << config.stream_type << "\n"; + file << "sample_group=" << config.sample_group << "\n"; + file << "scaled=" << (config.scaled ? "true" : "false") << "\n"; + file << "heartbeat=" << (config.heartbeat ? "true" : "false") << "\n"; + file << "\n"; + file << "[Device]\n"; + file << "device_type=" << config.device_type << "\n"; + file << "custom_address=" << config.custom_address << "\n"; + file << "custom_port=" << config.custom_port << "\n"; + file << "ccf_file=" << config.ccf_file << "\n"; + + return file.good(); +} + +std::filesystem::path ConfigManager::findConfigFile( + const std::string& filename, + const std::optional& hint +) { + if (hint && std::filesystem::exists(*hint)) { + return *hint; + } + + std::vector search_paths; + search_paths.push_back(std::filesystem::current_path()); + + auto exe_path = getExecutablePath(); + if (!exe_path.empty()) search_paths.push_back(exe_path); + + auto config_dir = getConfigDirectory(); + if (!config_dir.empty()) search_paths.push_back(config_dir); + + for (const auto& dir : search_paths) { + auto full_path = dir / filename; + if (std::filesystem::exists(full_path)) return full_path; + } + + return {}; +} + +} // namespace blackrock diff --git a/src/core/src/Device.cpp b/src/core/src/Device.cpp new file mode 100644 index 0000000..1b0a472 --- /dev/null +++ b/src/core/src/Device.cpp @@ -0,0 +1,365 @@ +#include "blackrock/Device.hpp" +#include "blackrock/Config.hpp" + +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +namespace blackrock { + +namespace { + +double groupToHz(uint32_t group) { + switch (group) { + case 1: return 500.0; + case 2: return 1000.0; + case 3: return 2000.0; + case 4: return 10000.0; + case 5: return 30000.0; + case 6: return 30000.0; + default: return 0.0; + } +} + +} // anonymous namespace + +CerebusDevice::CerebusDevice(const DeviceConfig& config) + : config_(config) +{ +} + +CerebusDevice::~CerebusDevice() { + disconnect(); +} + +bool CerebusDevice::connect() { + if (connected_) return true; + last_error_.clear(); + + // Build connection params + cbdev::ConnectionParams params; + if (config_.device_type == cbdev::DeviceType::CUSTOM) { + params = cbdev::ConnectionParams::custom( + config_.custom_address, "0.0.0.0", + config_.custom_port, config_.custom_port); + } else { + params = cbdev::ConnectionParams::forDevice(config_.device_type); + } + + // Create session + auto result = cbdev::createDeviceSession(params, cbdev::ProtocolVersion::UNKNOWN); + if (result.isError()) { + last_error_ = "Failed to create device session: " + result.error(); + return false; + } + session_ = std::move(result.value()); + + // Start receive thread + auto thread_result = session_->startReceiveThread(); + if (thread_result.isError()) { + last_error_ = "Failed to start receive thread: " + thread_result.error(); + session_.reset(); + return false; + } + + // Handshake - bring device to RUNNING state + auto handshake = session_->performHandshakeSync(std::chrono::milliseconds(5000)); + if (handshake.isError()) { + last_error_ = "Handshake failed: " + handshake.error(); + session_->stopReceiveThread(); + session_.reset(); + return false; + } + + // Apply configuration (CCF or use-existing) + if (!applyConfiguration()) { + session_->stopReceiveThread(); + session_.reset(); + return false; + } + + // Query channel info from device + if (!queryChannelInfo()) { + session_->stopReceiveThread(); + session_.reset(); + return false; + } + + // Register receive callback for group data packets + callback_handle_ = session_->registerReceiveCallback( + [this](const cbPKT_GENERIC& pkt) { onPacket(pkt); }); + + connected_ = true; + return true; +} + +std::string CerebusDevice::lastError() const { + return last_error_; +} + +void CerebusDevice::disconnect() { + if (!session_) return; + + connected_ = false; + + // Wake up any blocked getData() call + { + std::lock_guard lock(queue_mutex_); + queue_cv_.notify_all(); + } + + if (callback_handle_) { + session_->unregisterCallback(callback_handle_); + callback_handle_ = 0; + } + + session_->stopReceiveThread(); + session_.reset(); + + std::lock_guard lock(queue_mutex_); + sample_queue_.clear(); + heartbeat_queue_.clear(); + active_channel_ids_.clear(); +} + +bool CerebusDevice::isConnected() const { + return connected_; +} + +DeviceInfo CerebusDevice::getInfo() const { + return info_; +} + +bool CerebusDevice::getData(std::vector& buffer, double& timestamp) { + std::unique_lock lock(queue_mutex_); + queue_cv_.wait(lock, [this] { + return !sample_queue_.empty() || !connected_; + }); + + if (!connected_ && sample_queue_.empty()) return false; + + auto& chunk = sample_queue_.front(); + buffer = std::move(chunk.samples); + timestamp = chunk.timestamp; + sample_queue_.pop_front(); + return true; +} + +std::vector CerebusDevice::getHeartbeats() { + std::lock_guard lock(queue_mutex_); + std::vector out(heartbeat_queue_.begin(), heartbeat_queue_.end()); + heartbeat_queue_.clear(); + return out; +} + +void CerebusDevice::onPacket(const cbPKT_GENERIC& pkt) { + uint16_t chid = pkt.cbpkt_header.chid; + + // Heartbeat packets: chid has high bit set, type==0x00 + if (config_.heartbeat && (chid & 0x8000) && pkt.cbpkt_header.type == 0x00) { + Heartbeat hb; + hb.device_timestamp_ns = pkt.cbpkt_header.time; + hb.lsl_timestamp = deviceTimeToLsl(pkt.cbpkt_header.time); + std::lock_guard lock(queue_mutex_); + if (heartbeat_queue_.size() < MAX_HEARTBEAT_QUEUE_SIZE) { + heartbeat_queue_.push_back(hb); + } + return; + } + + // Filter for group data packets: chid==0, type matches our target group + if (chid != 0) return; + uint32_t group = pkt.cbpkt_header.type; + if (group != target_group_) return; + + // Number of int16 samples = dlen * 2 + size_t num_samples = static_cast(pkt.cbpkt_header.dlen) * 2; + if (num_samples == 0) return; + + // Limit to active channel count + size_t n = std::min(num_samples, static_cast(info_.channel_count)); + + TimestampedChunk chunk; + chunk.samples.assign(pkt.data_u16, pkt.data_u16 + n); + chunk.timestamp = deviceTimeToLsl(pkt.cbpkt_header.time); + + { + std::lock_guard lock(queue_mutex_); + if (sample_queue_.size() < MAX_QUEUE_SIZE) { + sample_queue_.push_back(std::move(chunk)); + } + queue_cv_.notify_one(); + } +} + +bool CerebusDevice::applyConfiguration() { + // Optionally upload a CCF file to the device + if (!config_.ccf_file.empty()) { + cbCCF ccf_data; + std::memset(&ccf_data, 0, sizeof(ccf_data)); + CCFUtils ccf_loader(false, &ccf_data); + auto ccf_result = ccf_loader.ReadCCF(config_.ccf_file.c_str()); + if (ccf_result < 0) { + last_error_ = "Failed to read CCF file: " + config_.ccf_file; + return false; + } + + auto packets = ccf::buildConfigPackets(ccf_data); + auto send_result = session_->sendPackets(packets); + if (send_result.isError()) { + last_error_ = "Failed to send CCF config to device: " + send_result.error(); + return false; + } + + // Re-request configuration after applying CCF + auto req = session_->requestConfigurationSync(std::chrono::milliseconds(5000)); + if (req.isError()) { + last_error_ = "Failed to request config after CCF upload: " + req.error(); + return false; + } + return true; + } + + // No CCF: just request existing device configuration + auto req = session_->requestConfigurationSync(std::chrono::milliseconds(5000)); + if (req.isError()) { + last_error_ = "Failed to request device configuration: " + req.error(); + return false; + } + return true; +} + +bool CerebusDevice::queryChannelInfo() { + // Helper: check if a channel belongs to a given group. + // Groups 1-5 use smpgroup; group 6 uses the cbAINP_RAWSTREAM flag. + auto channelInGroup = [](const cbPKT_CHANINFO* ci, uint32_t group) -> bool { + if (group == 6) { + return (ci->ainpopts & cbAINP_RAWSTREAM) != 0; + } + return ci->smpgroup == group; + }; + + // Heartbeat-only mode: no data stream + if (config_.sample_group < 0) { + target_group_ = 0; + active_channel_ids_.clear(); + } else { + // Determine target group: user-specified or auto-detect first active + if (config_.sample_group >= 1 && config_.sample_group <= 6) { + target_group_ = static_cast(config_.sample_group); + } else { + // Auto-detect: find the first active group by scanning channels + target_group_ = 0; + for (uint32_t ch = 1; ch <= cbMAXCHANS; ++ch) { + const auto* ci = session_->getChanInfo(ch); + if (!ci) continue; + // Check groups 1-5 via smpgroup + if (ci->smpgroup > 0 && ci->smpgroup <= 5) { + target_group_ = ci->smpgroup; + break; + } + // Check group 6 via raw stream flag + if ((ci->ainpopts & cbAINP_RAWSTREAM) != 0) { + target_group_ = 6; + break; + } + } + if (target_group_ == 0) { + last_error_ = "No active sample group found on device"; + return false; + } + } + + // Collect channels belonging to our target group + active_channel_ids_.clear(); + for (uint32_t ch = 1; ch <= cbMAXCHANS; ++ch) { + const auto* ci = session_->getChanInfo(ch); + if (!ci) continue; + if (channelInGroup(ci, target_group_)) { + active_channel_ids_.push_back(ch); + } + } + + if (active_channel_ids_.empty()) { + last_error_ = "No channels found in sample group " + std::to_string(target_group_); + return false; + } + } + + // Build DeviceInfo + info_.name = config_.stream_name; + info_.type = config_.stream_type; + info_.channel_count = static_cast(active_channel_ids_.size()); + info_.sample_rate = info_.channel_count > 0 ? groupToHz(target_group_) : 0.0; + info_.scaled = config_.scaled; + info_.heartbeat = config_.heartbeat; + info_.channel_labels.clear(); + info_.channel_scaling.clear(); + + for (uint32_t ch_id : active_channel_ids_) { + const auto* ci = session_->getChanInfo(ch_id); + if (ci) { + info_.channel_labels.emplace_back(ci->label); + + ChannelScaling sc; + sc.digmin = ci->physcalin.digmin; + sc.digmax = ci->physcalin.digmax; + sc.anamin = ci->physcalin.anamin; + sc.anamax = ci->physcalin.anamax; + sc.unit = ci->physcalin.anaunit; + info_.channel_scaling.push_back(sc); + } else { + info_.channel_labels.push_back("Ch" + std::to_string(ch_id)); + info_.channel_scaling.emplace_back(); + } + } + + // Build source_id as xxHash of stream-identity-critical fields + { + XXH64_state_t* state = XXH64_createState(); + XXH64_reset(state, 0); + + std::string dev_str = blackrock::deviceTypeToString(config_.device_type); + XXH64_update(state, dev_str.data(), dev_str.size()); + + XXH64_update(state, &target_group_, sizeof(target_group_)); + + uint8_t scaled_flag = config_.scaled ? 1 : 0; + XXH64_update(state, &scaled_flag, sizeof(scaled_flag)); + + int ch_count = info_.channel_count; + XXH64_update(state, &ch_count, sizeof(ch_count)); + + for (const auto& label : info_.channel_labels) { + XXH64_update(state, label.data(), label.size()); + } + + XXH64_hash_t hash = XXH64_digest(state); + XXH64_freeState(state); + + char buf[17]; + std::snprintf(buf, sizeof(buf), "%016" PRIX64, static_cast(hash)); + info_.source_id = buf; + } + + return true; +} + +double CerebusDevice::deviceTimeToLsl(uint64_t device_time_ns) const { + auto local_tp = session_->toLocalTime(device_time_ns); + if (local_tp) { + auto ns = local_tp->time_since_epoch(); + return std::chrono::duration(ns).count(); + } + // Fallback to current LSL time + return lsl::local_clock(); +} + +} // namespace blackrock diff --git a/src/core/src/LSLOutlet.cpp b/src/core/src/LSLOutlet.cpp new file mode 100644 index 0000000..c6aa7a0 --- /dev/null +++ b/src/core/src/LSLOutlet.cpp @@ -0,0 +1,86 @@ +#include "blackrock/LSLOutlet.hpp" + +namespace blackrock { + +LSLOutlet::LSLOutlet(const DeviceInfo& info) + : info_(info) +{ + lsl::channel_format_t format = info.scaled ? lsl::cf_float32 : lsl::cf_int16; + + lsl::stream_info stream_info( + info.name, + info.type, + info.channel_count, + info.sample_rate, + format, + info.source_id + ); + + lsl::xml_element desc = stream_info.desc(); + + // Acquisition metadata + lsl::xml_element acq = desc.append_child("acquisition"); + acq.append_child_value("manufacturer", "Blackrock Neurotech"); + acq.append_child_value("scaled", info.scaled ? "true" : "false"); + + // Channel descriptions + lsl::xml_element channels = desc.append_child("channels"); + for (int i = 0; i < info.channel_count; ++i) { + lsl::xml_element ch = channels.append_child("channel"); + + // Label + if (i < static_cast(info.channel_labels.size())) { + ch.append_child_value("label", info.channel_labels[i]); + } else { + ch.append_child_value("label", "Ch" + std::to_string(i + 1)); + } + + // Unit + if (info.scaled && i < static_cast(info.channel_scaling.size())) { + ch.append_child_value("unit", info.channel_scaling[i].unit); + } else if (!info.scaled) { + ch.append_child_value("unit", "count"); + } else { + ch.append_child_value("unit", "uV"); + } + + ch.append_child_value("type", info.type); + + // Include scaling metadata in raw mode + if (!info.scaled && i < static_cast(info.channel_scaling.size())) { + const auto& sc = info.channel_scaling[i]; + ch.append_child_value("digmin", std::to_string(sc.digmin)); + ch.append_child_value("digmax", std::to_string(sc.digmax)); + ch.append_child_value("anamin", std::to_string(sc.anamin)); + ch.append_child_value("anamax", std::to_string(sc.anamax)); + ch.append_child_value("anaunit", sc.unit); + } + } + + outlet_ = std::make_unique(stream_info); +} + +LSLOutlet::~LSLOutlet() = default; + +void LSLOutlet::pushChunk(const std::vector& data, double timestamp) { + if (outlet_ && !data.empty()) { + outlet_->push_chunk_multiplexed(data, timestamp); + } +} + +void LSLOutlet::pushChunk(const std::vector& data, double timestamp) { + if (outlet_ && !data.empty()) { + // lsl::stream_outlet::push_chunk_multiplexed accepts int16_t (short) + outlet_->push_chunk_multiplexed(data, timestamp); + } +} + +std::string LSLOutlet::getStreamName() const { + return info_.name; +} + +bool LSLOutlet::hasConsumers() const { + return outlet_ && outlet_->have_consumers(); +} + +} // namespace blackrock diff --git a/src/core/src/StreamThread.cpp b/src/core/src/StreamThread.cpp new file mode 100644 index 0000000..6e032d2 --- /dev/null +++ b/src/core/src/StreamThread.cpp @@ -0,0 +1,190 @@ +#include "blackrock/StreamThread.hpp" + +#include + +#include + +namespace blackrock { + +StreamThread::StreamThread( + std::unique_ptr device, + StatusCallback callback +) + : device_(std::move(device)) + , statusCallback_(std::move(callback)) +{ +} + +StreamThread::~StreamThread() { + stop(); +} + +bool StreamThread::start() { + if (running_) return false; + + if (!device_) { + if (statusCallback_) statusCallback_("No device configured", true); + return false; + } + + if (!device_->connect()) { + if (statusCallback_) { + std::string msg = "Failed to connect to device"; + auto err = device_->lastError(); + if (!err.empty()) msg += ": " + err; + statusCallback_(msg, true); + } + return false; + } + + shutdown_ = false; + running_ = true; + thread_ = std::make_unique(&StreamThread::threadFunction, this); + + if (statusCallback_) statusCallback_("Streaming started", false); + return true; +} + +void StreamThread::stop() { + if (!running_) return; + + shutdown_ = true; + + if (thread_ && thread_->joinable()) { + thread_->join(); + } + thread_.reset(); + + if (device_) device_->disconnect(); + + running_ = false; + + if (statusCallback_) statusCallback_("Streaming stopped", false); +} + +bool StreamThread::isRunning() const { + return running_; +} + +DeviceInfo StreamThread::getDeviceInfo() const { + if (device_) return device_->getInfo(); + return {}; +} + +void StreamThread::threadFunction() { + try { + auto info = device_->getInfo(); + + // Create data outlet only if there are channels to stream + std::optional data_outlet; + if (info.channel_count > 0) { + data_outlet.emplace(info); + if (statusCallback_) { + statusCallback_("LSL outlet created: " + info.name + + " (" + std::to_string(info.channel_count) + " ch @ " + + std::to_string(static_cast(info.sample_rate)) + " Hz)", false); + } + } + + // Optional heartbeat outlet + std::unique_ptr heartbeat_outlet; + if (info.heartbeat) { + lsl::stream_info hb_info( + info.name + "_heartbeat", "Heartbeat", + 1, lsl::IRREGULAR_RATE, lsl::cf_int64, + info.source_id + "_heartbeat"); + hb_info.desc().append_child_value("manufacturer", "Blackrock Neurotech"); + heartbeat_outlet = std::make_unique(hb_info); + if (statusCallback_) { + statusCallback_("Heartbeat outlet created: " + info.name + "_heartbeat", false); + } + } + + // Nothing to stream at all + if (!data_outlet && !heartbeat_outlet) { + if (statusCallback_) { + statusCallback_("Nothing to stream: no data channels and heartbeat disabled", true); + } + running_ = false; + return; + } + + if (data_outlet) { + // Data + optional heartbeat loop (existing behavior) + uint64_t sample_count = 0; + auto last_report_time = std::chrono::steady_clock::now(); + + std::vector raw_buffer; + std::vector scaled_buffer; + double timestamp = 0.0; + + while (!shutdown_) { + if (!device_->getData(raw_buffer, timestamp)) { + if (!shutdown_ && statusCallback_) { + statusCallback_("Device acquisition error", true); + } + break; + } + + if (info.scaled) { + scaled_buffer.resize(raw_buffer.size()); + for (size_t i = 0; i < raw_buffer.size(); ++i) { + size_t ch_idx = i % info.channel_count; + if (ch_idx < info.channel_scaling.size()) { + scaled_buffer[i] = info.channel_scaling[ch_idx].toAnalog(raw_buffer[i]); + } else { + scaled_buffer[i] = static_cast(raw_buffer[i]); + } + } + data_outlet->pushChunk(scaled_buffer, timestamp); + } else { + data_outlet->pushChunk(raw_buffer, timestamp); + } + + // Drain heartbeats + if (heartbeat_outlet) { + auto heartbeats = device_->getHeartbeats(); + for (const auto& hb : heartbeats) { + int64_t sample = static_cast(hb.device_timestamp_ns); + heartbeat_outlet->push_sample(&sample, hb.lsl_timestamp); + } + } + + // Rate monitoring + ++sample_count; + auto now = std::chrono::steady_clock::now(); + double elapsed = std::chrono::duration(now - last_report_time).count(); + if (elapsed >= 1.0) { + double rate = static_cast(sample_count) / elapsed; + if (statusCallback_) { + statusCallback_("Effective rate: " + + std::to_string(static_cast(rate + 0.5)) + " smp/s", false); + } + sample_count = 0; + last_report_time = now; + } + } + } else { + // Heartbeat-only loop + while (!shutdown_) { + auto heartbeats = device_->getHeartbeats(); + for (const auto& hb : heartbeats) { + int64_t sample = static_cast(hb.device_timestamp_ns); + heartbeat_outlet->push_sample(&sample, hb.lsl_timestamp); + } + if (heartbeats.empty()) { + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + } + } + } + + } catch (const std::exception& e) { + if (statusCallback_) { + statusCallback_(std::string("Streaming error: ") + e.what(), true); + } + } + + running_ = false; +} + +} // namespace blackrock diff --git a/src/gui/CMakeLists.txt b/src/gui/CMakeLists.txt new file mode 100644 index 0000000..fad533f --- /dev/null +++ b/src/gui/CMakeLists.txt @@ -0,0 +1,52 @@ +# GUI Application +add_executable(${PROJECT_NAME} MACOSX_BUNDLE WIN32 + main.cpp + MainWindow.cpp + MainWindow.hpp + MainWindow.ui +) + +target_link_libraries(${PROJECT_NAME} + PRIVATE + BlackrockOutlet::core + Qt6::Core + Qt6::Widgets +) + +# macOS bundle properties +if(APPLE) + set_target_properties(${PROJECT_NAME} PROPERTIES + MACOSX_BUNDLE_GUI_IDENTIFIER "org.labstreaminglayer.${PROJECT_NAME}" + MACOSX_BUNDLE_BUNDLE_VERSION "${PROJECT_VERSION}" + MACOSX_BUNDLE_SHORT_VERSION_STRING "${PROJECT_VERSION_MAJOR}.${PROJECT_VERSION_MINOR}" + MACOSX_BUNDLE_INFO_PLIST "${CMAKE_CURRENT_SOURCE_DIR}/Info.plist.in" + ) +endif() + +# Windows: Copy DLLs to build directory for debugging +if(WIN32) + add_custom_command(TARGET ${PROJECT_NAME} POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_if_different + $ + $ + COMMAND_EXPAND_LISTS + COMMENT "Copying runtime DLLs for ${PROJECT_NAME}" + ) + # Copy Qt plugins + get_target_property(_qmake_executable Qt6::qmake IMPORTED_LOCATION) + execute_process( + COMMAND ${_qmake_executable} -query QT_INSTALL_PLUGINS + OUTPUT_VARIABLE QT_PLUGINS_DIR + OUTPUT_STRIP_TRAILING_WHITESPACE + ) + add_custom_command(TARGET ${PROJECT_NAME} POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_directory + "${QT_PLUGINS_DIR}/platforms" + "$/platforms" + ) + add_custom_command(TARGET ${PROJECT_NAME} POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_directory + "${QT_PLUGINS_DIR}/styles" + "$/styles" + ) +endif() diff --git a/src/gui/Info.plist.in b/src/gui/Info.plist.in new file mode 100644 index 0000000..6014027 --- /dev/null +++ b/src/gui/Info.plist.in @@ -0,0 +1,30 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + ${MACOSX_BUNDLE_EXECUTABLE_NAME} + CFBundleIconFile + + CFBundleIdentifier + ${MACOSX_BUNDLE_GUI_IDENTIFIER} + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + ${MACOSX_BUNDLE_BUNDLE_NAME} + CFBundlePackageType + APPL + CFBundleShortVersionString + ${MACOSX_BUNDLE_SHORT_VERSION_STRING} + CFBundleVersion + ${MACOSX_BUNDLE_BUNDLE_VERSION} + LSMinimumSystemVersion + ${CMAKE_OSX_DEPLOYMENT_TARGET} + NSHighResolutionCapable + + NSPrincipalClass + NSApplication + + diff --git a/src/gui/MainWindow.cpp b/src/gui/MainWindow.cpp new file mode 100644 index 0000000..3627734 --- /dev/null +++ b/src/gui/MainWindow.cpp @@ -0,0 +1,296 @@ +/** + * @file MainWindow.cpp + * @brief Main window implementation for BlackrockOutlet GUI + */ + +#include "MainWindow.hpp" +#include "ui_MainWindow.h" + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +#include + +MainWindow::MainWindow(const QString& config_file, QWidget* parent) + : QMainWindow(parent) + , ui_(std::make_unique()) +{ + ui_->setupUi(this); + + // Connect signals + connect(ui_->linkButton, &QPushButton::clicked, this, &MainWindow::onLinkButtonClicked); + connect(ui_->combo_device_type, QOverload::of(&QComboBox::currentIndexChanged), + this, &MainWindow::onDeviceTypeChanged); + connect(ui_->btn_browse_ccf, &QPushButton::clicked, this, &MainWindow::onBrowseCCF); + connect(ui_->actionLoad_Configuration, &QAction::triggered, this, &MainWindow::onLoadConfig); + connect(ui_->actionSave_Configuration, &QAction::triggered, this, &MainWindow::onSaveConfig); + connect(ui_->actionQuit, &QAction::triggered, this, &QMainWindow::close); + connect(ui_->actionAbout, &QAction::triggered, this, &MainWindow::onAbout); + + // Auto-check heartbeat when "None (heartbeat only)" is selected + connect(ui_->combo_group, QOverload::of(&QComboBox::currentIndexChanged), + this, [this](int index) { + if (index == 7) { + ui_->check_heartbeat->setChecked(true); + } + }); + + // Initial visibility of custom address/port + onDeviceTypeChanged(ui_->combo_device_type->currentIndex()); + + // Load configuration + QString cfg_path = config_file.isEmpty() ? findDefaultConfigFile() : config_file; + if (!cfg_path.isEmpty()) { + loadConfig(cfg_path); + } +} + +MainWindow::~MainWindow() { + if (stream_) stream_->stop(); +} + +void MainWindow::closeEvent(QCloseEvent* event) { + if (stream_ && stream_->isRunning()) { + auto result = QMessageBox::question( + this, "Streaming Active", + "Streaming is still active. Stop and quit?", + QMessageBox::Yes | QMessageBox::No + ); + if (result == QMessageBox::No) { + event->ignore(); + return; + } + stream_->stop(); + } + event->accept(); +} + +void MainWindow::onLinkButtonClicked() { + if (stream_ && stream_->isRunning()) { + stream_->stop(); + stream_.reset(); + setStreaming(false); + } else { + blackrock::AppConfig app_cfg; + app_cfg.stream_name = ui_->input_name->text().toStdString(); + app_cfg.stream_type = "ECEPhys"; + + // Map combo box index to device type string + static const char* device_types[] = { + "hub1", "hub2", "hub3", "nsp", "legacy_nsp", "nplay", "custom" + }; + int idx = ui_->combo_device_type->currentIndex(); + if (idx >= 0 && idx < 7) { + app_cfg.device_type = device_types[idx]; + } + + app_cfg.custom_address = ui_->input_address->text().toStdString(); + app_cfg.custom_port = static_cast(ui_->input_port->value()); + app_cfg.ccf_file = ui_->input_ccf->text().toStdString(); + + // Sample group: combo index 0=auto(0), 1-6=group 1-6, 7=none(-1) + int group_idx = ui_->combo_group->currentIndex(); + app_cfg.sample_group = (group_idx == 7) ? -1 : group_idx; + + // Data format: index 0=Raw(int16), index 1=Scaled(float32) + app_cfg.scaled = (ui_->combo_format->currentIndex() == 1); + app_cfg.heartbeat = ui_->check_heartbeat->isChecked(); + + auto device_config = blackrock::appConfigToDeviceConfig(app_cfg); + auto device = std::make_unique(device_config); + + auto callback = [this](const std::string& message, bool is_error) { + QMetaObject::invokeMethod(this, [this, message, is_error]() { + updateStatus(QString::fromStdString(message), is_error); + }); + }; + + stream_ = std::make_unique(std::move(device), callback); + + if (stream_->start()) { + setStreaming(true); + } else { + stream_.reset(); + QMessageBox::warning(this, "Error", "Failed to start streaming"); + } + } +} + +void MainWindow::onDeviceTypeChanged(int index) { + bool is_custom = (index == 6); // "Custom" is last item + ui_->label_address->setVisible(is_custom); + ui_->input_address->setVisible(is_custom); + ui_->label_port->setVisible(is_custom); + ui_->input_port->setVisible(is_custom); + + // Update stream name placeholder to reflect auto-generated name + static const cbdev::DeviceType device_type_map[] = { + cbdev::DeviceType::HUB1, cbdev::DeviceType::HUB2, cbdev::DeviceType::HUB3, + cbdev::DeviceType::NSP, cbdev::DeviceType::LEGACY_NSP, + cbdev::DeviceType::NPLAY, cbdev::DeviceType::CUSTOM + }; + if (index >= 0 && index < 7) { + auto display = blackrock::deviceTypeDisplayName(device_type_map[index]); + ui_->input_name->setPlaceholderText( + QString("(auto: Blackrock-%1)").arg(QString::fromStdString(display))); + } +} + +void MainWindow::onBrowseCCF() { + QString filename = QFileDialog::getOpenFileName( + this, "Select CCF File", QString(), + "CCF Files (*.ccf);;All Files (*)" + ); + if (!filename.isEmpty()) { + ui_->input_ccf->setText(filename); + } +} + +void MainWindow::onLoadConfig() { + QString filename = QFileDialog::getOpenFileName( + this, "Load Configuration", last_config_path_, + "Configuration Files (*.cfg);;All Files (*)" + ); + if (!filename.isEmpty()) { + loadConfig(filename); + } +} + +void MainWindow::onSaveConfig() { + QString filename = QFileDialog::getSaveFileName( + this, "Save Configuration", last_config_path_, + "Configuration Files (*.cfg);;All Files (*)" + ); + if (!filename.isEmpty()) { + saveConfig(filename); + } +} + +void MainWindow::onAbout() { + QString info = QString( + "

BlackrockOutlet

" + "

Version 0.1.0

" + "

Blackrock Cerebus/Neuroport LSL streaming application.

" + "
" + "

LSL Library: %1

" + "

Protocol: %2

" + ).arg(QString::number(lsl::library_version()), + QString::fromStdString(lsl::library_info())); + + QMessageBox::about(this, "About BlackrockOutlet", info); +} + +void MainWindow::loadConfig(const QString& filename) { + auto config = blackrock::ConfigManager::load(filename.toStdString()); + if (!config) { + QMessageBox::warning(this, "Load Failed", + QString("Failed to load configuration from:\n%1").arg(filename)); + return; + } + + ui_->input_name->setText(QString::fromStdString(config->stream_name)); + + // Find device type index + static const char* device_types[] = { + "hub1", "hub2", "hub3", "nsp", "legacy_nsp", "nplay", "custom" + }; + for (int i = 0; i < 7; ++i) { + if (config->device_type == device_types[i]) { + ui_->combo_device_type->setCurrentIndex(i); + break; + } + } + + ui_->input_address->setText(QString::fromStdString(config->custom_address)); + ui_->input_port->setValue(config->custom_port); + ui_->input_ccf->setText(QString::fromStdString(config->ccf_file)); + + // Sample group: 0=auto(index 0), 1-6=group(index 1-6), -1=none(index 7) + if (config->sample_group == -1) { + ui_->combo_group->setCurrentIndex(7); + } else if (config->sample_group >= 0 && config->sample_group <= 6) { + ui_->combo_group->setCurrentIndex(config->sample_group); + } + + // Data format: index 0=Raw, index 1=Scaled + ui_->combo_format->setCurrentIndex(config->scaled ? 1 : 0); + + ui_->check_heartbeat->setChecked(config->heartbeat); + + last_config_path_ = filename; + updateStatus("Loaded: " + filename, false); +} + +void MainWindow::saveConfig(const QString& filename) { + blackrock::AppConfig config; + config.stream_name = ui_->input_name->text().toStdString(); + config.stream_type = "ECEPhys"; + + static const char* device_types[] = { + "hub1", "hub2", "hub3", "nsp", "legacy_nsp", "nplay", "custom" + }; + int idx = ui_->combo_device_type->currentIndex(); + if (idx >= 0 && idx < 7) config.device_type = device_types[idx]; + + config.custom_address = ui_->input_address->text().toStdString(); + config.custom_port = static_cast(ui_->input_port->value()); + config.ccf_file = ui_->input_ccf->text().toStdString(); + + int group_idx = ui_->combo_group->currentIndex(); + config.sample_group = (group_idx == 7) ? -1 : group_idx; + config.scaled = (ui_->combo_format->currentIndex() == 1); + config.heartbeat = ui_->check_heartbeat->isChecked(); + + if (blackrock::ConfigManager::save(config, filename.toStdString())) { + last_config_path_ = filename; + updateStatus("Saved: " + filename, false); + } else { + QMessageBox::warning(this, "Save Failed", + QString("Failed to save configuration to:\n%1").arg(filename)); + } +} + +QString MainWindow::findDefaultConfigFile() { + QFileInfo exe_info(QCoreApplication::applicationFilePath()); + QString default_name = "BlackrockOutlet.cfg"; + + QStringList search_paths = { + QDir::currentPath(), + exe_info.absolutePath() + }; + search_paths.append(QStandardPaths::standardLocations(QStandardPaths::ConfigLocation)); + + for (const auto& path : search_paths) { + QString full_path = path + QDir::separator() + default_name; + if (QFileInfo::exists(full_path)) return full_path; + } + return QString(); +} + +void MainWindow::updateStatus(const QString& message, bool is_error) { + ui_->statusbar->showMessage(message, is_error ? 0 : 5000); + ui_->statusbar->setStyleSheet(is_error ? "color: red;" : ""); +} + +void MainWindow::setStreaming(bool streaming) { + ui_->linkButton->setText(streaming ? "Unlink" : "Link"); + + ui_->input_name->setEnabled(!streaming); + ui_->combo_device_type->setEnabled(!streaming); + ui_->input_address->setEnabled(!streaming); + ui_->input_port->setEnabled(!streaming); + ui_->input_ccf->setEnabled(!streaming); + ui_->btn_browse_ccf->setEnabled(!streaming); + ui_->combo_group->setEnabled(!streaming); + ui_->combo_format->setEnabled(!streaming); + ui_->check_heartbeat->setEnabled(!streaming); +} diff --git a/src/gui/MainWindow.hpp b/src/gui/MainWindow.hpp new file mode 100644 index 0000000..32c3115 --- /dev/null +++ b/src/gui/MainWindow.hpp @@ -0,0 +1,46 @@ +#pragma once +/** + * @file MainWindow.hpp + * @brief Main window for BlackrockOutlet GUI application + */ + +#include +#include + +namespace Ui { +class MainWindow; +} + +namespace blackrock { +class StreamThread; +} + +class MainWindow : public QMainWindow { + Q_OBJECT + +public: + explicit MainWindow(const QString& config_file = QString(), QWidget* parent = nullptr); + ~MainWindow() override; + +protected: + void closeEvent(QCloseEvent* event) override; + +private slots: + void onLinkButtonClicked(); + void onDeviceTypeChanged(int index); + void onBrowseCCF(); + void onLoadConfig(); + void onSaveConfig(); + void onAbout(); + +private: + void loadConfig(const QString& filename); + void saveConfig(const QString& filename); + QString findDefaultConfigFile(); + void updateStatus(const QString& message, bool is_error); + void setStreaming(bool streaming); + + std::unique_ptr ui_; + std::unique_ptr stream_; + QString last_config_path_; +}; diff --git a/src/gui/MainWindow.ui b/src/gui/MainWindow.ui new file mode 100644 index 0000000..49a2404 --- /dev/null +++ b/src/gui/MainWindow.ui @@ -0,0 +1,313 @@ + + + MainWindow + + + BlackrockOutlet + + + + 400 + 360 + + + + + + + + + + Device Type + + + + + + + + Hub 1 + + + + + Hub 2 + + + + + Hub 3 + + + + + NSP + + + + + Legacy NSP + + + + + nPlay + + + + + Custom + + + + + + + + Address + + + + + + + 192.168.137.128 + + + + + + + Port + + + + + + + 0 + + + 65535 + + + 51001 + + + + + + + CCF File + + + + + + + + + (optional) + + + + + + + Browse... + + + + 80 + 16777215 + + + + + + + + + + Sample Group + + + + + + + + Auto (first active) + + + + + Group 1 (500 Hz) + + + + + Group 2 (1 kHz) + + + + + Group 3 (2 kHz) + + + + + Group 4 (10 kHz) + + + + + Group 5 (30 kHz) + + + + + Group 6 (30 kHz raw) + + + + + None (heartbeat only) + + + + + + + + Stream Name + + + + + + + + + + (auto: Blackrock-Hub1) + + + + + + + Data Format + + + + + + + + Raw (int16) + + + + + Scaled (uV, float32) + + + + + + + + Stream device heartbeat timestamps + + + + + + + + + Qt::Orientation::Vertical + + + + 20 + 20 + + + + + + + + + 0 + 0 + + + + + 0 + 40 + + + + Link + + + + + + + + + &File + + + + + + + + + &Help + + + + + + + + + + &Load Configuration... + + + Ctrl+L + + + + + &Save Configuration... + + + Ctrl+S + + + + + &Quit + + + Ctrl+Q + + + + + &About... + + + + + + diff --git a/src/gui/main.cpp b/src/gui/main.cpp new file mode 100644 index 0000000..fb8ad75 --- /dev/null +++ b/src/gui/main.cpp @@ -0,0 +1,26 @@ +/** + * @file main.cpp + * @brief GUI entry point for BlackrockOutlet + */ + +#include "MainWindow.hpp" +#include + +int main(int argc, char* argv[]) { + QApplication app(argc, argv); + + app.setApplicationName("BlackrockOutlet"); + app.setApplicationVersion("1.0.0"); + app.setOrganizationName("LabStreamingLayer"); + app.setOrganizationDomain("labstreaminglayer.org"); + + QString config_file; + if (argc > 1) { + config_file = QString::fromLocal8Bit(argv[1]); + } + + MainWindow window(config_file); + window.show(); + + return app.exec(); +}