From 9317f52fc469adb76ef4874656aa4adc91ec872b Mon Sep 17 00:00:00 2001 From: Seyed Yahya Shirazi Date: Sun, 19 Jul 2026 12:01:23 -0700 Subject: [PATCH] Move LabRecorder into the monorepo as a component Vendored at apps/LabRecorder/ from neuromechanist/Secure-LabRecorder branch feature/security-status, based on upstream 684df66. Per the component licensing policy, upstream's MIT license (Copyright (c) 2012 Christian Kothe) is restored over the fork's proprietary replacement, and the README license section is corrected to match. The fork's "Update license to proprietary" commits are deliberately not carried across: the security integration reads stream status through the public LSL API and contains no cryptographic implementation, so nothing in this component requires the proprietary grant. - COMPONENT.md records upstream provenance and base commit - THIRD-PARTY-NOTICES.md gains the LabRecorder entry - docs/integration/labrecorder.md documents building the in-repo component alongside the existing drop-in route, which stays the zero-change path; the LSL_INSTALL_ROOT flag was checked against the component's CMakeLists before documenting it - Dropped vendored .github workflows so upstream CI does not fire here Closes #9 --- THIRD-PARTY-NOTICES.md | 32 + apps/LabRecorder/.gitignore | 20 + apps/LabRecorder/.travis.yml | 38 + apps/LabRecorder/BUILD.md | 125 +++ apps/LabRecorder/CMakeLists.txt | 210 +++++ apps/LabRecorder/COMPONENT.md | 27 + apps/LabRecorder/LICENSE | 21 + apps/LabRecorder/LabRecorder.cfg | 69 ++ apps/LabRecorder/LabRecorder_BIDS.cfg | 2 + apps/LabRecorder/LabRecorder_Legacy.cfg | 3 + apps/LabRecorder/README.md | 153 ++++ .../cmake/MacOSXBundleInfo.plist.in | 40 + apps/LabRecorder/doc/controls.png | Bin 0 -> 9362 bytes apps/LabRecorder/doc/labrecorder-default.png | Bin 0 -> 25656 bytes apps/LabRecorder/doc/labrecorder-running.png | Bin 0 -> 10824 bytes apps/LabRecorder/doc/labrecorder-study.png | Bin 0 -> 10848 bytes apps/LabRecorder/doc/templates.md | 34 + apps/LabRecorder/src/clirecorder.cpp | 39 + apps/LabRecorder/src/fptest.cpp | 20 + apps/LabRecorder/src/main.cpp | 16 + apps/LabRecorder/src/mainwindow.cpp | 792 ++++++++++++++++++ apps/LabRecorder/src/mainwindow.h | 96 +++ apps/LabRecorder/src/mainwindow.ui | 370 ++++++++ apps/LabRecorder/src/recording.cpp | 447 ++++++++++ apps/LabRecorder/src/recording.h | 188 +++++ apps/LabRecorder/src/tcpinterface.cpp | 41 + apps/LabRecorder/src/tcpinterface.h | 28 + apps/LabRecorder/xdfwriter/CMakeLists.txt | 63 ++ apps/LabRecorder/xdfwriter/conversions.h | 141 ++++ .../test_iec559_and_little_endian.cpp | 18 + .../LabRecorder/xdfwriter/test_xdf_writer.cpp | 61 ++ apps/LabRecorder/xdfwriter/xdfwriter.cpp | 92 ++ apps/LabRecorder/xdfwriter/xdfwriter.h | 158 ++++ docs/integration/labrecorder.md | 19 + 34 files changed, 3363 insertions(+) create mode 100644 apps/LabRecorder/.gitignore create mode 100644 apps/LabRecorder/.travis.yml create mode 100644 apps/LabRecorder/BUILD.md create mode 100644 apps/LabRecorder/CMakeLists.txt create mode 100644 apps/LabRecorder/COMPONENT.md create mode 100644 apps/LabRecorder/LICENSE create mode 100644 apps/LabRecorder/LabRecorder.cfg create mode 100644 apps/LabRecorder/LabRecorder_BIDS.cfg create mode 100644 apps/LabRecorder/LabRecorder_Legacy.cfg create mode 100644 apps/LabRecorder/README.md create mode 100644 apps/LabRecorder/cmake/MacOSXBundleInfo.plist.in create mode 100644 apps/LabRecorder/doc/controls.png create mode 100644 apps/LabRecorder/doc/labrecorder-default.png create mode 100644 apps/LabRecorder/doc/labrecorder-running.png create mode 100644 apps/LabRecorder/doc/labrecorder-study.png create mode 100644 apps/LabRecorder/doc/templates.md create mode 100644 apps/LabRecorder/src/clirecorder.cpp create mode 100644 apps/LabRecorder/src/fptest.cpp create mode 100644 apps/LabRecorder/src/main.cpp create mode 100644 apps/LabRecorder/src/mainwindow.cpp create mode 100644 apps/LabRecorder/src/mainwindow.h create mode 100644 apps/LabRecorder/src/mainwindow.ui create mode 100644 apps/LabRecorder/src/recording.cpp create mode 100644 apps/LabRecorder/src/recording.h create mode 100644 apps/LabRecorder/src/tcpinterface.cpp create mode 100644 apps/LabRecorder/src/tcpinterface.h create mode 100644 apps/LabRecorder/xdfwriter/CMakeLists.txt create mode 100644 apps/LabRecorder/xdfwriter/conversions.h create mode 100644 apps/LabRecorder/xdfwriter/test_iec559_and_little_endian.cpp create mode 100644 apps/LabRecorder/xdfwriter/test_xdf_writer.cpp create mode 100644 apps/LabRecorder/xdfwriter/xdfwriter.cpp create mode 100644 apps/LabRecorder/xdfwriter/xdfwriter.h diff --git a/THIRD-PARTY-NOTICES.md b/THIRD-PARTY-NOTICES.md index d86d36a..92e08d2 100644 --- a/THIRD-PARTY-NOTICES.md +++ b/THIRD-PARTY-NOTICES.md @@ -37,6 +37,38 @@ Catch2 (https://github.com/catchorg/Catch2), Boost Software License --- +## LabRecorder (MIT License) + +Recording application, vendored as a component at `apps/LabRecorder/`. Retains +its upstream MIT license, including the security integration added here; the +notice is also kept at `apps/LabRecorder/LICENSE`. + +``` +MIT License + +Copyright (c) 2012 Christian Kothe + +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. +``` + +--- + ## asio (Boost Software License 1.0) Copyright (c) 2003-2021 Christopher M. Kohlhoff (chris at kohlhoff dot com) diff --git a/apps/LabRecorder/.gitignore b/apps/LabRecorder/.gitignore new file mode 100644 index 0000000..c536661 --- /dev/null +++ b/apps/LabRecorder/.gitignore @@ -0,0 +1,20 @@ +*.ui.autosave +ui_*.h +/build*/ +/package*/ +/install*/ +/CMakeLists.txt.user +/CMakeLists.json +/.vs/ +/.vscode/ +/out/ +/CMakeSettings.json +# CLion +.idea/ +cmake-build-debug/ +cmake-build-release/ +# Generated by CI scripts - or maintainers debugging CI scripts: +liblsl.deb +install-qt.sh +.DS_Store + diff --git a/apps/LabRecorder/.travis.yml b/apps/LabRecorder/.travis.yml new file mode 100644 index 0000000..e4722ce --- /dev/null +++ b/apps/LabRecorder/.travis.yml @@ -0,0 +1,38 @@ +language: cpp +compiler: clang +env: + APP_VERSION="1.13.1" +matrix: + include: + - os: osx + osx_image: xcode10.1 +before_install: +- mkdir LSL +- brew update +- brew upgrade cmake +install: +- git clone https://github.com/sccn/liblsl.git +- cmake -S liblsl -B liblsl/build -DCMAKE_INSTALL_PREFIX=$PWD/LSL/ -DLSL_UNIXFOLDERS=ON .. +- cmake --build liblsl/build --config Release --target install +before_script: +- brew install qt; +- export LSL_INSTALL_ROOT=LSL +script: +- cmake --version +- cmake -S . -B build -DLSL_INSTALL_ROOT=${LSL_INSTALL_ROOT} -DQt5_DIR=/usr/local/opt/qt/lib/cmake/Qt5 +- cmake --build build --config Release --target install +- cd build/install/Labrecorder +- if [[ "$TRAVIS_OS_NAME" == "osx" ]]; then for app in *.app; do /usr/local/opt/qt/bin/macdeployqt + ${app} -dmg; mv "${app%.app}.dmg" "${app%.app}-${APP_VERSION}-${TRAVIS_OSX_IMAGE}.dmg"; + done; fi +deploy: + provider: releases + skip_cleanup: true + api_key: + secure: FQ+xqPuImk2ie4KeoDrP1u+6TB2qIoLPTk0wr7EEv+7oBfLodExVSyC67TEUVKUVnAYv2E1FFfHLZPUzMeiuFT6o40jkwekHEte3dbgWTgmW9iqiDw4eK5X0obZbDoYLJBbOiQfUsgH8nKpE/U5MprTpQ3KKQr2CjbcqGZnDY6k2yNope70bMe//4zBgv+qjvR1CDeI8sSrSUkBgDgVhujqNzS8I0FPoLAmJuBuFiA5Y7f/uwO17M1Nfso9WnNiWA8XhgJ1mgoA8BYrIx9hP2niK4gkFJ3p/iV0IK51KNxEELIQ7IKw/U12CLYD7+hKFBpyrZGcgCwXCHXR/G3/kNsxJrk395u+78gTLdiE3AuksWQZ+f+6br2pBG3UBTu/Qm3cVcVQRtNArKXDgiCaMc0qECL51o6qNTzPhLAHvGVGZCOjp34vW44MMWtKh584LqShojN/DH1OBUR3PjaHHiuxQMuaUXHto8SDfl0ZRSaeyElI+6kmU3XfTJfqFq2DpTX5LMYoiZUXwMKKQtWTGp8I2axL1LCnUsX/nlY63AoCY7CJTwX+DE1//YBwOsfafQ/VOMIpzQCXJNuHR3EAFVk+qcFt0wTVu/aa2oTkBaGN67LE22/pkwT4kxpI3kaXDo94CE0diEZWu4hHHNoQF1C/WuH/DH4HErJW8tPGrEm8= + file: + - LabRecorder-${APP_VERSION}-${TRAVIS_OSX_IMAGE}.dmg + - LabRecorderCLI-${APP_VERSION}-${TRAVIS_OSX_IMAGE}.dmg + on: + repo: labstreaminglayer/App-LabRecorder + tags: true diff --git a/apps/LabRecorder/BUILD.md b/apps/LabRecorder/BUILD.md new file mode 100644 index 0000000..c3973b9 --- /dev/null +++ b/apps/LabRecorder/BUILD.md @@ -0,0 +1,125 @@ +# Building Lab Recorder + +This file includes quick start recipes. To see general principles, look [here](https://github.com/labstreaminglayer/labstreaminglayer/blob/master/doc/BUILD.md). + + +## Windows - CMake - Visual Studio 2017 + +Starting with Visual Studio 2017, Microsoft began to support much closer integration with CMake. Generally, this uses the Visual Studio GUI as a wrapper around the CMake build files, so you should not expect to see most of the Visual Studio Project configuration options that you are familiar with, and CMake projects cannot be directly blended with non-CMake Visual Studio projects. There are also some weird gotchas, described below. + +You will need to download and install:
+ * [The full LabStreamingLayer meta project](https://github.com/labstreaminglayer/labstreaminglayer) -> Clone (include --recursive flag) or download + * [Visual Studio Community 2017](https://imagine.microsoft.com/en-us/Catalog/Product/530) + * [CMake 3.12.1](https://cmake.org/files/v3.12/) + * [Qt 5.11.1](https://download.qt.io/archive/qt/5.11/) + * [Boost 1.65.1](https://sourceforge.net/projects/boost/files/boost-binaries/1.65.1/boost_1_65_1-msvc-14.1-32.exe/download) + + +From Visual Studio:
+ * File -> Open -> CMake -> labstreaminglayer/CMakeLists.txt + * Wait while CMake configures automatically and until CMake menu turns black + * Select x86-Release + * CMake menu -> Change CMake Settings -> LabStreamingLayer + +Add the "variable" section to the x86-Release group, so that it looks approximately like this: +``` +{ + "name": "x86-Release", + "generator": "Ninja", + "configurationType": "RelWithDebInfo", + "inheritEnvironments": [ "msvc_x86" ], + "buildRoot": "${env.USERPROFILE}\\CMakeBuilds\\${workspaceHash}\\build\\${name}", + "installRoot": "${env.USERPROFILE}\\CMakeBuilds\\${workspaceHash}\\install\\${name}", + "cmakeCommandArgs": "", + "buildCommandArgs": "-v", + "ctestCommandArgs": "", + "variables": [ + { + "name": "Qt5_DIR", + "value": "C:\\Qt\\5.11.1\\msvc2015\\lib\\cmake\\Qt5 " + }, + { + "name": "BOOST_ROOT", + "value": "C:\\local\\boost_1_65_1" + }, + { + "name": "LSLAPPS_LabRecorder", + "value": "ON" + } + ] +} +``` + + * Also consider changing the build and install roots, as the default path is obscure. When you save this file, CMake should reconfigure, and show the output in the output window (including any CMake configuration errors). + + * Note that it is not a typo that Qt refers to msvc2015 rather than msvc2017. + + * Select Startup Item (green arrow dropdown) -> LabRecorder.exe (Install) + + * Click the green arrow. The application should launch. + +Note that if you change Qt versions, or other significant changes, it will be necessary to do a full rebuild before CMake correctly notices the change. CMake -> Clean All is not sufficient to force this. + +Just deleting the build folder causes an unfortunate error (build folder not found) on rebuild. To do a full rebuild, it is necessary to change the build and install folder paths in CMake -> Change CMake Settings -> LabStreamingLayer, build, then delete the old folder, change the path back, and build again. + + +## Windows - CMake - Legacy + +This procedure also works in Visual Studio 2017, if desired. Generally, I'd recommend sticking with the newer procedure and Visual Studio 2017. + +This procedure generates a Visual Studio type project from the CMake files, which can then be opened in Visual Studio. + +You will need to download and install:
+ * The full [LabStreamingLayer meta project](https://github.com/labstreaminglayer/labstreaminglayer) -> Clone (include --recursive flag) or download + * Desired Visual Studio Version (the example uses 2015). + * [CMake 3.12.1](https://cmake.org/files/v3.12/) + * [Qt 5.11.1](https://download.qt.io/archive/qt/5.11/) + * [Boost 1.65.1](https://sourceforge.net/projects/boost/files/boost-binaries/1.65.1/boost_1_65_1-msvc-14.1-32.exe/download) + +From the command line, from the labstreaminglayer folder: + * labstreaminglayer\build>cmake .. -G "Visual Studio 14 2015" -DQt5_DIR="C:/Qt/5.11.1/msvc2015/lib/cmake/Qt5" -DBOOST_ROOT=C:\boost\boost_1_65_1 -DLSLAPPS_LabRecorder=ON + + * labstreaminglayer\build>cmake --build . --config Release --target install + +To see a list of possible generators, run the command with garbage in the -G option. + +The executable is in labstreaminglayer\build\install\LabRecorder. + +You can open the Visual Studio Project with labstreaminglayer\build\LabStreamingLayer.sln. + +The command line install feature does not put build products in the sample place as when you build inside of Visual Studio. To get running to work inside of Visual Studio, it is necessary to copy the support files from labstreaminglayer\build\install\LabRecorder to labstreaminglayer\build\Apps\LabRecorder\Release. You must also right click LabRecorder -> Set as Startup Project and select Release and Win32.
+ +If any significant changes are made to the project (such as changing Qt or Visual Stuido version) it is recommended that you delete or rename the build folder and start over. Various partial cleaning processes do not work well. + + +## Linux + + * Ubuntu (/Debian) + * `sudo apt-get install build-essential cmake qt5-default libboost-all-dev` + +1. Open a Command Prompt / Terminal (Windows: MSVC Command Prompt) and change into this directory. +1. Make a build subdirectory: `mkdir build && cd build` +1. Call cmake + * `cmake ..` + * If your liblsl binaries are not in `../../LSL/liblsl/build/install`, add the path (`cmake -DLSL_INSTALL_ROOT=/path/to/liblsl/binaries ..`) + * Optional: Use a [generator](https://cmake.org/cmake/help/latest/manual/cmake-generators.7.html#visual-studio-generators) +1. You may need to specify additional cmake options. + . Build everything and copy the files to the `install` folder: + * `cmake --build . --target install` + + +## OS X + + * Use [homebrew](https://brew.sh/) + * `brew install cmake qt boost` + +1. Open a Command Prompt / Terminal (Windows: MSVC Command Prompt) and change into this directory. +1. Make a build subdirectory: `mkdir build && cd build` +1. Call cmake + * `cmake ..` + * If your liblsl binaries are not in `../../LSL/liblsl/build/install`, add the path (`cmake -DLSL_INSTALL_ROOT=/path/to/liblsl/binaries ..`) + * Optional: Use a [generator](https://cmake.org/cmake/help/latest/manual/cmake-generators.7.html#visual-studio-generators) +1. You may need to specify additional cmake options. + . Build everything and copy the files to the `install` folder: + * `cmake --build . --target install` + diff --git a/apps/LabRecorder/CMakeLists.txt b/apps/LabRecorder/CMakeLists.txt new file mode 100644 index 0000000..71933ce --- /dev/null +++ b/apps/LabRecorder/CMakeLists.txt @@ -0,0 +1,210 @@ +cmake_minimum_required(VERSION 3.12) +#set(CMAKE_OSX_DEPLOYMENT_TARGET "10.15" CACHE STRING "Minimum MacOS deployment version") + +project(LabRecorder + DESCRIPTION "Record and write LabStreamingLayer streams to an XDF file" + HOMEPAGE_URL "https://github.com/labstreaminglayer/App-LabRecorder/" + LANGUAGES C CXX + VERSION 1.16.4) + +# 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) + +option(BUILD_GUI "Build the GUI, set to off for CLI only build" ON) + +set(CMAKE_XCODE_ATTRIBUTE_CODE_SIGNING_REQUIRED "NO") +set(CMAKE_XCODE_ATTRIBUTE_CODE_SIGN_IDENTITY "") + +# Dependencies + +## LSL +if(ANDROID) + set(LIBLSL_SOURCE_PATH "../../LSL/liblsl" CACHE STRING "Path to liblsl sources") + + # force include liblsl as target to build with the android toolchain + # as path of the normal build process + add_subdirectory(${LIBLSL_SOURCE_PATH} liblsl_bin) + add_library(LSL::lsl ALIAS lsl) +else() + find_package(LSL REQUIRED + HINTS ${LSL_INSTALL_ROOT} + "${CMAKE_CURRENT_LIST_DIR}/../../LSL/liblsl/build/" + "${CMAKE_CURRENT_LIST_DIR}/../../LSL/liblsl/build/install" + "${CMAKE_CURRENT_LIST_DIR}/../../LSL/liblsl/out/build/x64-Release" + "${CMAKE_CURRENT_LIST_DIR}/../../LSL/liblsl/out/install/x64-Release" + PATH_SUFFIXES share/LSL + ) +endif() + +if (BUILD_GUI) + ## Qt + set(CMAKE_AUTOMOC ON) # The later version of this in LSLCMake is somehow not enough. + set(CMAKE_AUTORCC ON) + set(CMAKE_AUTOUIC ON) + find_package(QT NAMES Qt6 Qt5 REQUIRED COMPONENTS Core) + find_package(Qt${QT_VERSION_MAJOR} REQUIRED COMPONENTS Core Widgets Network DBus) +endif(BUILD_GUI) + +## Threads +find_package(Threads REQUIRED) + +# Targets + +## xdfwriter - stand alone library +add_subdirectory(xdfwriter) + +if (BUILD_GUI) + add_executable(${PROJECT_NAME} MACOSX_BUNDLE) + + target_sources(${PROJECT_NAME} PRIVATE + src/main.cpp + src/mainwindow.cpp + src/mainwindow.h + src/mainwindow.ui + src/recording.h + src/recording.cpp + src/tcpinterface.h + src/tcpinterface.cpp + ) + + target_link_libraries(${PROJECT_NAME} + PRIVATE + xdfwriter + Qt${QT_VERSION_MAJOR}::Core + Qt${QT_VERSION_MAJOR}::Widgets + Qt${QT_VERSION_MAJOR}::Network + Qt${QT_VERSION_MAJOR}::DBus + Threads::Threads + LSL::lsl + ) +endif(BUILD_GUI) + + +add_executable(LabRecorderCLI MACOSX_BUNDLE + src/clirecorder.cpp + src/recording.h + src/recording.cpp +) + +target_link_libraries(LabRecorderCLI + PRIVATE + xdfwriter + Threads::Threads + LSL::lsl +) + +installLSLApp(xdfwriter) +installLSLApp(testxdfwriter) +installLSLApp(LabRecorderCLI) +if (BUILD_GUI) + installLSLApp(${PROJECT_NAME}) + installLSLAuxFiles(${PROJECT_NAME} + ${PROJECT_NAME}.cfg + LICENSE + README.md + ) +else() + installLSLAuxFiles(LabRecorderCLI + ${PROJECT_NAME}.cfg + LICENSE + README.md + ) +endif(BUILD_GUI) + + +if (WIN32) + if(BUILD_GUI) + get_target_property(QT_QMAKE_EXECUTABLE Qt::qmake IMPORTED_LOCATION) + get_filename_component(QT_WINDEPLOYQT_EXECUTABLE ${QT_QMAKE_EXECUTABLE} PATH) + set(QT_WINDEPLOYQT_EXECUTABLE "${QT_WINDEPLOYQT_EXECUTABLE}/windeployqt.exe") + + add_custom_command( + TARGET ${PROJECT_NAME} POST_BUILD + COMMAND ${QT_WINDEPLOYQT_EXECUTABLE} + --no-translations --no-system-d3d-compiler + --qmldir ${CMAKE_CURRENT_SOURCE_DIR} + $) + + add_custom_command(TARGET ${PROJECT_NAME} POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_if_different + $ + $ + $) + else() + add_custom_command(TARGET LabRecorderCLI POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_if_different + $ + $ + $) + endif(BUILD_GUI) +endif() + +if (BUILD_GUI) + add_custom_command( + TARGET ${PROJECT_NAME} POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy + ${CMAKE_CURRENT_SOURCE_DIR}//${PROJECT_NAME}.cfg + $) +else() + add_custom_command( + TARGET LabRecorderCLI POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy + ${CMAKE_CURRENT_SOURCE_DIR}//${PROJECT_NAME}.cfg + $) +endif(BUILD_GUI) + +if(Qt6_FOUND AND BUILD_GUI) + set_target_properties(${PROJECT_NAME} PROPERTIES + QT_ANDROID_EXTRA_LIBS "${CMAKE_CURRENT_BINARY_DIR}/liblsl_bin/liblsl.so") + qt_finalize_executable(${PROJECT_NAME}) +endif(Qt6_FOUND AND BUILD_GUI) + +set(CPACK_DEBIAN_LABRECORDER_PACKAGE_SECTION "science" CACHE INTERNAL "") +LSLGenerateCPackConfig() + +if(APPLE AND NOT DEFINED ENV{GITHUB_ACTIONS}) + # Qt6 QtNetwork depends on libbrotidec which depends on libbroticommon but whose search path uses @loader_path. + # Unfortunately, macdeployqt does not seem to traverse @loader_path dependencies. + # So we are forced to call `fixup_bundle`. For now, we only do this if homebrew is present + # because that seems to be where the bad dependency is coming from. + # Note that fixup_bundle also destroys the codesigning so we have to redo that. + # TODO: Checkout supercollider apple-specific stuff, e.g.: https://github.com/supercollider/supercollider/blob/develop/CMakeLists.txt#L260-L262 + + # Detect Apple Silicon + execute_process( + COMMAND uname -m + OUTPUT_VARIABLE ARCH + OUTPUT_STRIP_TRAILING_WHITESPACE + ) + + # Check for Homebrew + execute_process( + COMMAND brew --prefix + RESULT_VARIABLE BREW_LIB + OUTPUT_VARIABLE BREW_PREFIX + OUTPUT_STRIP_TRAILING_WHITESPACE + ) + + if (BREW_LIB EQUAL 0 AND EXISTS "${BREW_PREFIX}") + install(CODE + " + include(BundleUtilities) + fixup_bundle(\"${CMAKE_INSTALL_PREFIX}/${PROJECT_NAME}/${PROJECT_NAME}.app\" \"\" \"${BREW_PREFIX}/lib\") + + # Fix Qt plugin references specifically for Apple Silicon + if(\"${ARCH}\" STREQUAL \"arm64\") + execute_process(COMMAND install_name_tool -change @rpath/QtGui.framework/Versions/A/QtGui @executable_path/../Frameworks/QtGui + \"${CMAKE_INSTALL_PREFIX}/${PROJECT_NAME}/${PROJECT_NAME}.app/Contents/PlugIns/platforms/libqcocoa.dylib\") + endif() + + # Re-sign with the same approach the project already uses + execute_process(COMMAND codesign --remove-signature \"${CMAKE_INSTALL_PREFIX}/${PROJECT_NAME}/${PROJECT_NAME}.app\") + execute_process(COMMAND find \"${CMAKE_INSTALL_PREFIX}/${PROJECT_NAME}/${PROJECT_NAME}.app/Contents/Frameworks\" -type f -exec codesign --force --sign - {} \\; 2>/dev/null || true) + execute_process(COMMAND find \"${CMAKE_INSTALL_PREFIX}/${PROJECT_NAME}/${PROJECT_NAME}.app/Contents/PlugIns\" -type f -exec codesign --force --sign - {} \\; 2>/dev/null || true) + execute_process(COMMAND codesign --force --deep --sign - \"${CMAKE_INSTALL_PREFIX}/${PROJECT_NAME}/${PROJECT_NAME}.app\") " + ) + endif() +endif(APPLE AND NOT DEFINED ENV{GITHUB_ACTIONS}) \ No newline at end of file diff --git a/apps/LabRecorder/COMPONENT.md b/apps/LabRecorder/COMPONENT.md new file mode 100644 index 0000000..43f3076 --- /dev/null +++ b/apps/LabRecorder/COMPONENT.md @@ -0,0 +1,27 @@ +# LabRecorder component + +The LabRecorder recording application with security-status support, vendored +into this monorepo as a component. + +## Licensing + +This component retains its upstream license (MIT, Copyright (c) 2012 Christian +Kothe); see `LICENSE` in this directory. The security integration added here is +contributed under that same MIT license. It reads stream security status through +the public LSL API and contains no cryptographic implementation, so the +proprietary Secure LSL terms do not apply to it. See COMPONENT LICENSING POLICY +in the repository-root `LICENSE`. + +## Upstream tracking + +- Upstream: https://github.com/labstreaminglayer/App-LabRecorder +- Based on upstream commit: `684df66` +- Security integration: security-status display for encrypted streams, version + API information in the About dialog, and security-mismatch detection with + actionable error dialogs + +## Behavior against a stock liblsl + +The security queries are optional. Linked against a stock `liblsl` that does not +export them, the application builds and runs as upstream does, without the +security indicators. diff --git a/apps/LabRecorder/LICENSE b/apps/LabRecorder/LICENSE new file mode 100644 index 0000000..8174806 --- /dev/null +++ b/apps/LabRecorder/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2012 Christian Kothe + +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. diff --git a/apps/LabRecorder/LabRecorder.cfg b/apps/LabRecorder/LabRecorder.cfg new file mode 100644 index 0000000..6923a61 --- /dev/null +++ b/apps/LabRecorder/LabRecorder.cfg @@ -0,0 +1,69 @@ +; === Storage Location === +; the default file name can be something like C:\\Recordings\\untitled.xdf, but can also contain +; placeholders. Two placeholder formats are supported: Legacy and BIDS. +; +; For BIDS format, only a StudyRoot can be provided. +; If the full StorageLocation or PathTemplate is provided +; then it will be assumed that a non-BIDS format is being used. +; +; Legacy may contain a running number (incremented per experiment session) called %n, and a +; placeholder for a "block" label %b (if the config script provides a list of block names that +; consitute a session. +; +; For BIDS, the path may contain %p for participant label, %s for session label, +; %b for task label (same as block in Legacy), %a for name of acquisition parameter set, and %r index. +; The BIDS syntax is: path/to/StudyRoot/sub-%p/ses-%s/eeg/sub-%p_ses-%s_task-%b[_acq-%a]_run-%r_eeg.xdf +; +; If neither StorageLocation or StudyRoot is provided then the default root is QStandardPaths::DocumentsLocation/CurrentStudy/ +; If neither StorageLocation or PathTemplate are provided, then the BIDS format is assumed, or the file template exp%n/block_%b.xdf if BIDS is unchecked. + +; StorageLocation=C:/Recordings/CurrentStudy/sub-%p/ses-%s/behav/sub-%p_ses-%s_task-%b_acq-%a_run-%r_eeg.xdf +; StudyRoot=C:/Recordings/CurrentStudy/ +; PathTemplate=exp%n/block_%b.xdf + +; === Block Names === +; This is optionally a list of blocks that make up a recording session. The blocks are displayed in +; a list box where the experiment can select a block before pressing record. If used, the blocks +; may serve as a reminder of where they are in the experiment, but more practically, can be +; used to determine the file name of the recording. Power users can define scriptable actions +; associated with selecting a block or pressing Start/Stop for a given block (e.g., for remote +; control). +; The syntax is as in: SessionBlocks = "Training","PreBaseline","MainSection","PostBaseline" +; SessionBlocks="T1", "T2", "T3" + +; === BIDS modalities === +; This is optionally a list of BIDS modalities to be added to the default list defined in the source code. +; Defaults are: "eeg", "ieeg", "meg", "beh" +; The selected value will replace the %m placeholder in the filename template. +; physio is an example from: +; https://bids-specification.readthedocs.io/en/stable/04-modality-specific-files/06-physiological-and-other-continuous-recordings.html +; BidsModalities="eeg", "ieeg", "meg", "beh", "physio" + +; === Required Streams === +; This is optionally a list of streams that are required for the recording; +; a warning is issued if one of the streams is not present when the record button is pressed +; The syntax is as in: RequiredStreams = "BioSemi (MyHostname)","PhaseSpace (MyHostname)","Eyelink (AnotherHostname)" +; where the format is identical to what the LabRecorder displays in the "Record from streams" list. +; There must not be any spaces within the parentheses. +; RequiredStreams="RequiredExample (PC-HOSTNAME)" + +; === Online Sync === +; A list of sync settings. Each setting follows the following format: "SrcStreamName (SrcHostName) post_FLAG" +; where post_FLAG is described here: https://github.com/sccn/liblsl/blob/master/src/common.h#L77-L89 +; Note that it is not necessary to set any flags for correct storage because all of the post-processing synchronization +; can be achieved during file import using either the Matlab or Python importers. +; Examples: +; OnlineSync="ActiChamp-0 (DM-Laptop) post_ALL", "LiveAmpSN-054211-0237 (User-PC) post_ALL" +; OnlineSync=["ActiChamp-0 (User-PC)" post_ALL] +; OnlineSync="SendDataC (Testpc) post_ALL", "Test (Testpc) post_clocksync" + +; === Remote Control Socket === +; A list of options containing 2 possible values: +; RCSEnabled to control the state of the remote control stream on launch : 1/0; default 1 +; RCSPort to set the port number for the socket; default 22345 +RCSEnabled=1 +RCSPort=22345 + +; === Auto Start Recording === +; Set AutoStart to 1 to automatically start recording as soon as the config file has concluded parsing. +; AutoStart=1 \ No newline at end of file diff --git a/apps/LabRecorder/LabRecorder_BIDS.cfg b/apps/LabRecorder/LabRecorder_BIDS.cfg new file mode 100644 index 0000000..46fd522 --- /dev/null +++ b/apps/LabRecorder/LabRecorder_BIDS.cfg @@ -0,0 +1,2 @@ +; BIDS config, provide only the StudyRoot +StudyRoot=C:/Recordings/CurrentStudy diff --git a/apps/LabRecorder/LabRecorder_Legacy.cfg b/apps/LabRecorder/LabRecorder_Legacy.cfg new file mode 100644 index 0000000..5902e59 --- /dev/null +++ b/apps/LabRecorder/LabRecorder_Legacy.cfg @@ -0,0 +1,3 @@ +; Legacy config, the StudyRoot and TemplatePath are extracted automatically +; from the StorageLocation +StorageLocation = "C:/Recordings/CurrentStudy/expi_%n/blockvar_%b.xdf" diff --git a/apps/LabRecorder/README.md b/apps/LabRecorder/README.md new file mode 100644 index 0000000..5ebde53 --- /dev/null +++ b/apps/LabRecorder/README.md @@ -0,0 +1,153 @@ +# Overview + +The LabRecorder is the default recording program that comes with LSL. It allows to record all streams on the lab network (or a subset) into a single file, with time synchronization between streams. + +# File Format + +The file format used by the LabRecorder is XDF. This is an open general-purpose format that was designed concurrently with LSL and supports all features of LSL streams. The project page is [here](https://github.com/sccn/xdf). There are importers for MATLAB, EEGLAB, BCILAB, Python, and MoBILAB. + +# Getting LabRecorder + +The [releases page](https://github.com/labstreaminglayer/App-LabRecorder/releases) contains archives of past LabRecorder builds. Try downloading and installing an archive that matches your platform. Note for Ubuntu users: The deb will install LabRecorder to `/usr/LabRecorder` though we might change this to `/usr/local/bin/LabRecorder` in the future. + +If there are no archives matching your target platform, or the ones available don't run, then continue reading below. If the instructions don't help then please post an issue to the [repo's issues page](https://github.com/labstreaminglayer/App-LabRecorder/issues). + +## Dependencies + +For LabRecorder to work on your system, you might need to first install some dependencies, specifically liblsl and optionally Qt. + +### Windows + +The Windows archives ship with nearly all required dependencies. If you have not already installed it via another program, you may need to install the [Visual C++ Runtime Redistributable](https://learn.microsoft.com/en-us/cpp/windows/latest-supported-vc-redist?view=msvc-170). + +If you suspect you are missing a dependency, try running [DependenciesGui.exe](https://github.com/lucasg/Dependencies/releases) then navigating to the LabRecorder.exe. It's important to launch Dependencies.exe from the same environment that you would use to launch this application: if you launch this application by double-clicking the executable in Windows' finder then do the same on the Dependencies.exe icon; if you launch this application in a Terminal window, then use that same Terminal to launch Dependencies. + +### MacOS + +In the near future, many LSL Apps (especially LabRecorder) will not ship with their dependencies and will look for the dependencies to be installed on the system. The easiest way to manage the dependencies is by using [homebrew](https://brew.sh/): +* Install homebrew: `/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"` +* `brew install labstreaminglayer/tap/lsl` +* `brew install qt` + +You can then install LabRecorder directly from homebrew: `brew install labrecorder` + +Run it with `open /usr/local/opt/labrecorder/LabRecorder/LabRecorder.app` + +### Linux Ubuntu + +The Ubuntu releases do not typically ship with their dependencies, so you must download and install those: +* Download, extract, and install the latest [liblsl-{version}-{target}_amd64.deb from its release page](https://github.com/sccn/liblsl/releases) + * We hope to make this available via a package manager soon. + * Quick ref Ubuntu 20.04: `curl -L https://github.com/sccn/liblsl/releases/download/v1.16.0/liblsl-1.16.0-bionic_amd64.deb -o liblsl.deb` + * Quick ref Ubuntu 22.04: `curl -L https://github.com/sccn/liblsl/releases/download/v1.16.0/liblsl-1.16.0-jammy_amd64.deb -o liblsl.deb` + * You can install liblsl directly by double-clicking on the deb, or with `sudo dpkg -i {filename}.deb` or `sudo apt install {filename}.deb` +* See the bottom of the [lsl build env docs](https://labstreaminglayer.readthedocs.io/dev/build_env.html). + * For most cases, this will amount to installing Qt and its dependencies: + * Ubuntu 18.xx or 20.xx: `sudo apt-get install build-essential qtbase5-dev libpugixml-dev` + * Ubuntu >= 22.04: `sudo apt-get install qt6-base-dev freeglut3-dev` + +# Usage + +The LabRecorder displays a list of currently present device streams under "Record from Streams". If you have turned on a device after you have already started the recorder, click the "Update" button to update the list (this takes ca. 2 seconds). +> For testing, you can use a "dummy" device from the `lslexamples` found in the [liblsl release assets](https://github.com/sccn/liblsl/releases) (for example SendData). + +If you cannot see streams that are provided on another computer, read the section Network Troubleshooting on the NetworkConnectivity page. + +You can select which streams you want to record from and which not by checking the checkboxes next to them. +> ![labrecorder-default.png](doc/labrecorder-default.png) + +Note that if you have multiple streams with the same name and host, it is impossible to check only 1. If any is checked then they will all be recorded. + +The entry in "Saving to..." shows you the file name (or file name template) where your recording will be stored. You can change this by modifying the Study Root folder (e.g., by clicking the browse button) and the `File Name / Template` field. If the respective directory does not yet exist, it will be created automatically (except if you do not have the permissions to create it). The file name string may contain placeholders that will be replaced by the values in the fields below. Checking the BIDS box will automatically change the filename template to be BIDS compliant. If the file that you are trying to record to already exists, the existing file will be renamed (the string `_oldX` will be appended where X is the lowest number that is not yet occupied by another existing file). This way, it is impossible to accidentally overwrite data. + +The Block/Task field can be overwriten or selected among a list of items found in the configuration file. + + + +Click "Start" to start a recording. If everything goes well, the status bar will now display the time since you started the recording, and more importantly, the current file size (the number before the kb) will grow slowly. This is a way to check whether you are still in fact recording data. The recording program cannot be closed while you are recording (as a safety measure). + +When you are done recording, click the "Stop" button. You can now close the program. See [the xdf repository](https://github.com/sccn/xdf) for tools and information on how to use the XDF files. + +## Preparing a Full Study + +When preparing a new study, it is a good idea to make a custom configuration file which at least sets up a custom storage location for the study. See the documentation in the file `LabRecorder.cfg` for how to do this -- it is very easy! You can override this by making a shortcut for the LabRecorder program (e.g. on the desktop) and appending in its properties the command-line arguments `-c name_of_you_config.cfg`. You can also create a batch script. You can also load the config while the program is already running, but this can easily be forgotten during an experiment, so we recommend to follow the shortcut route. + +In addition to the storage location, if your experiment has multiple blocks (e.g., SubjectTraining, PreBaseline, MainBlock, PostBaseline or the like) you can make the recording process even more straightforward for the experimenters by setting up a default list of block names. Again, take a look at the existing config files. + +Since it is too easy to forget to turn on or check all necessary recording devices for a study, we recommend to also make a list of "required" streams (by their name) and put it into the config file. These streams will be pre-checked when starting the program, and any missing stream will be displayed in red. If such a stream is still not green when starting the recording, the experimenter will get a message box to confirm that he/she really wants to record without including the device. + + + + + +## Remote Control + +If you check the box to EnableRCS then LabRecorder exposes some rudimentary controls via TCP socket. + +Currently supported commands include: +* `select all` +* `select none` +* `start` +* `stop` +* `update` +* `filename ...` + +`filename` is followed by a series of space-delimited options enclosed in curly braces. e.g. {root:C:\root_data_dir} +* `root` - Sets the root data directory. +* `template` - sets the File Name / Template. Will unselect BIDS option. May contain wildcards. +* `task` - will replace %b in template +* `run` - will replace %n in template (not working?) +* `participant` - will replace %p in template +* `session` - will replace %s in template +* `acquisition` - will replace %a in template +* `modality` - will replace %m in template. suggested values: eeg, ieeg, meg, beh + + +For example, in Python: + +```python +import socket +s = socket.create_connection(("localhost", 22345)) +s.sendall(b"select all\n") +s.sendall(b"filename {root:C:\\Data\\} {template:exp%n\\%p_block_%b.xdf} {run:2} {participant:P003} {task:MemoryGuided}\n") +s.sendall(b"start\n") +``` + +```Matlab +lr = tcpip('localhost', 22345); +fopen(lr) +fprintf(lr, 'select all'); +fprintf(lr, ['filename {root:C:\Data\} '... + '{task:MemoryGuided} ' ... + '{template:s_%p_%n.xdf ' ... + '{modality:ieeg}']); +fprintf(lr, 'start'); +``` + +## Misc Features + +The LabRecorder has some useful features that can add robustness if things go wrong during the experiment: + +If a network connectivity error happens while recording (e.g., a network cable pops out that connects to the source of a data stream), you have usually 6 minutes (think 5) to plug it back it in during which the data will be buffered on the sending machine. If it takes you longer to fix the problem, you will have some gap in your recording. + +If a device program or computer crashes while recording, you will for sure lose data, but any device program that transmits an associated device serial number will be picked up automatically by the recorder when it comes back online (these programs are called "recoverable"), without a need to stop and re-start the recording. + +You should check the health of your device to be sure, however, for example using an online stream viewer program (see, for example, ViewingStreamsInMatlab). Also, be sure to test whether it is in fact recoverable before relying on this feature (you can test this with a viewer by turning the device app off and back on). + +If a device is displayed in red when you start recording (and it is checked), it will be added to the ongoing recording by the time when it comes online. This can be useful when a device can only be turned on while the recording is already in progress. Again, it is advisable to check that the device is in fact discoverable and added. The LabRecorder brings up a console window in the background which shows a list of all streams that are added to the recording -- this is a good place to check whether a late stream did get picked up successfully during a live recording. + +# Build Instructions + +Please follow the general [LSL App build instructions](https://labstreaminglayer.readthedocs.io/dev/app_build.html). + +# License + +MIT License. Copyright (c) 2012 Christian Kothe; see [LICENSE](LICENSE). + +This component is the [LabRecorder](https://github.com/labstreaminglayer/App-LabRecorder) +application with security-status support added, distributed as a component of +the Secure LSL monorepo. It retains its upstream MIT license, and the security +integration added here is contributed under that same license: it reads stream +security status through the public LSL API and contains no cryptographic +implementation. See COMPONENT LICENSING POLICY in the repository-root LICENSE. diff --git a/apps/LabRecorder/cmake/MacOSXBundleInfo.plist.in b/apps/LabRecorder/cmake/MacOSXBundleInfo.plist.in new file mode 100644 index 0000000..f6d1f95 --- /dev/null +++ b/apps/LabRecorder/cmake/MacOSXBundleInfo.plist.in @@ -0,0 +1,40 @@ + + + + + CFBundleDevelopmentRegion + English + CFBundleExecutable + ${MACOSX_BUNDLE_EXECUTABLE_NAME} + CFBundleGetInfoString + ${MACOSX_BUNDLE_INFO_STRING} + CFBundleIconFile + ${MACOSX_BUNDLE_ICON_FILE} + CFBundleIdentifier + ${MACOSX_BUNDLE_GUI_IDENTIFIER} + CFBundleInfoDictionaryVersion + 6.0 + CFBundleLongVersionString + ${MACOSX_BUNDLE_LONG_VERSION_STRING} + CFBundleName + ${MACOSX_BUNDLE_BUNDLE_NAME} + CFBundlePackageType + APPL + CFBundleShortVersionString + ${MACOSX_BUNDLE_SHORT_VERSION_STRING} + CFBundleSignature + ???? + CFBundleVersion + ${MACOSX_BUNDLE_BUNDLE_VERSION} + CSResourcesFileMapped + + LSRequiresCarbon + + NSHumanReadableCopyright + ${MACOSX_BUNDLE_COPYRIGHT} + NSHighResolutionCapable + + NSPrincipalClass + NSApplication + + diff --git a/apps/LabRecorder/doc/controls.png b/apps/LabRecorder/doc/controls.png new file mode 100644 index 0000000000000000000000000000000000000000..2f6933fad775b7513b1739678d54a64790aec281 GIT binary patch literal 9362 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 Kn5Xc>|9=3`W>6sj literal 0 HcmV?d00001 diff --git a/apps/LabRecorder/doc/labrecorder-default.png b/apps/LabRecorder/doc/labrecorder-default.png new file mode 100644 index 0000000000000000000000000000000000000000..baac039e95e3865f8b56bd642870427192740820 GIT binary patch literal 25656 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 ax=!2Ku^YP#A$yhxm0fmO=f(_8vJVro6Ek*| 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 literal 0 HcmV?d00001 diff --git a/apps/LabRecorder/doc/labrecorder-study.png b/apps/LabRecorder/doc/labrecorder-study.png new file mode 100644 index 0000000000000000000000000000000000000000..2bfb6356700fa0b182c969209256813560f33d8d GIT binary patch literal 10848 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 W8!7!E@%g_pL0aniYSs7P5&s7*KJgm> literal 0 HcmV?d00001 diff --git a/apps/LabRecorder/doc/templates.md b/apps/LabRecorder/doc/templates.md new file mode 100644 index 0000000..c966983 --- /dev/null +++ b/apps/LabRecorder/doc/templates.md @@ -0,0 +1,34 @@ +# Working with file name templates + +Most sites have specific formats for paths and file names or even switched to +the [BIDS](#BIDS) standard. LabRecorder makes it easier to file and name your +correctly by providing path and file name templates. + +All variables (starting with `%`) are replaced with the corresponding field's +contents upon starting the recording. + +Lets assume your institution requires your file to be saved in +`/mnt/server/studies/your_name///.xdf`. + +You can let LabRecorder create the files and folders with the following +settings (configuration file keys are `highlighted`): + +- `StudyRoot`=`/mnt/server/studies/your_name` +- File Name / Template: `%p/%s/%b.xdf` +- `SessionBlocks`=`"Task 1", "Resting State", "Task 2"` + +Before recording, you only need to fill in the fields you're using. +The paths in the File Name Template are created automatically. + +The final folder and file path are previewed in the `Saving to...` box (above +`16` in the screenshot). + +## BIDS + +[BIDS](https://bids.neuroimaging.io/) is a set of conventions for organizing +your research data. +Checking the `BIDS` checkbox (`10` in the screenshot) automatically generates +the correct File Name +Template depending on which variables you set in the fields below. + +![LabRecorder Inputs](controls.png) diff --git a/apps/LabRecorder/src/clirecorder.cpp b/apps/LabRecorder/src/clirecorder.cpp new file mode 100644 index 0000000..59e3736 --- /dev/null +++ b/apps/LabRecorder/src/clirecorder.cpp @@ -0,0 +1,39 @@ +#include "recording.h" +#include "xdfwriter.h" + +int main(int argc, char **argv) { + if (argc < 3 || (argc == 2 && std::string(argv[1]) == "-h")) { + std::cout << "Usage: " << argv[0] << " outputfile.xdf 'searchstr' ['searchstr2' ...]\n\n" + << "searchstr can be anything accepted by lsl_resolve_bypred\n"; + std::cout << "Keep in mind that your shell might remove quotes\n"; + std::cout << "Examples:\n\t" << argv[0] << " foo.xdf 'type=\"EEG\"' "; + std::cout << " 'host=\"LabPC1\" or host=\"LabPC2\"'\n\t"; + std::cout << argv[0] << " foo.xdf'name=\"Tobii and type=\"Eyetracker\"'\n"; + return 1; + } + + std::vector infos = lsl::resolve_streams(), recordstreams; + + for (int i = 2; i < argc; ++i) { + bool matched = false; + for (const auto &info : infos) { + if (info.matches_query(argv[i])) { + std::cout << "Found " << info.name() << '@' << info.hostname(); + std::cout << " matching '" << argv[i] << "'\n"; + matched = true; + recordstreams.emplace_back(info); + } + } + if (!matched) { + std::cout << '"' << argv[i] << "\" matched no stream!\n"; + return 2; + } + } + + std::vector watchfor; + std::map sync_options; + std::cout << "Starting the recording, press Enter to quit" << std::endl; + recording r(argv[1], recordstreams, watchfor, sync_options, true); + std::cin.get(); + return 0; +} diff --git a/apps/LabRecorder/src/fptest.cpp b/apps/LabRecorder/src/fptest.cpp new file mode 100644 index 0000000..5ace7de --- /dev/null +++ b/apps/LabRecorder/src/fptest.cpp @@ -0,0 +1,20 @@ +#include + +int main() { + const double sampling_rate = 500, sample_interval = 1.0 / sampling_rate, + first_timestamp = 60 * 60 * 24 * 365 * 48; // e.g. a unix timestamp + double last_timestamp = first_timestamp; + int full = 0, deduced = 0; + const int iterations = 100000; + double timestamps[iterations]; + for (int i = 0; i < iterations; ++i) timestamps[i] = first_timestamp + i / sampling_rate; + for (int i = 0; i < iterations; ++i) { + if (last_timestamp + sample_interval == timestamps[i]) + deduced++; + else + full++; + last_timestamp = timestamps[i]; + } + std::cout << "Deduced: " << deduced << "\nFully written: " << full << std::endl; + return 0; +} diff --git a/apps/LabRecorder/src/main.cpp b/apps/LabRecorder/src/main.cpp new file mode 100644 index 0000000..f61461a --- /dev/null +++ b/apps/LabRecorder/src/main.cpp @@ -0,0 +1,16 @@ +#include "mainwindow.h" +#include + +int main(int argc, char *argv[]) { + + // determine the startup config file... + const char *config_file = nullptr; + for (int k = 1; k < argc; k++) + if (std::string(argv[k]) == "-c" || std::string(argv[k]) == "--config") + config_file = argv[k + 1]; + + QApplication a(argc, argv); + MainWindow w(nullptr, config_file); + w.show(); + return a.exec(); +} diff --git a/apps/LabRecorder/src/mainwindow.cpp b/apps/LabRecorder/src/mainwindow.cpp new file mode 100644 index 0000000..38d63bf --- /dev/null +++ b/apps/LabRecorder/src/mainwindow.cpp @@ -0,0 +1,792 @@ +#include "mainwindow.h" +#include "ui_mainwindow.h" + +#include +#include +#include +#include +#include +#include +#if QT_VERSION_MAJOR < 6 +#include +#else +#include +using QRegExp = QRegularExpression; +#endif + +#include +#include + +// recording class +#include "recording.h" +#include "tcpinterface.h" + +const QStringList bids_modalities_default = QStringList({"eeg", "ieeg", "meg", "beh"}); + +MainWindow::MainWindow(QWidget *parent, const char *config_file) + : QMainWindow(parent), ui(new Ui::MainWindow) { + ui->setupUi(this); + connect(ui->actionLoad_Configuration, &QAction::triggered, this, [this]() { + load_config(QFileDialog::getOpenFileName( + this, "Load Configuration File", "", "Configuration Files (*.cfg)")); + }); + connect(ui->actionSave_Configuration, &QAction::triggered, this, [this]() { + save_config(QFileDialog::getSaveFileName( + this, "Save Configuration File", "", "Configuration Files (*.cfg)")); + }); + connect(ui->actionQuit, &QAction::triggered, this, &MainWindow::close); + + // Signals for stream finding/selecting/starting/stopping + connect(ui->refreshButton, &QPushButton::clicked, this, &MainWindow::refreshStreams); + connect(ui->selectAllButton, &QPushButton::clicked, this, &MainWindow::selectAllStreams); + connect(ui->selectNoneButton, &QPushButton::clicked, this, &MainWindow::selectNoStreams); + connect(ui->startButton, &QPushButton::clicked, this, &MainWindow::startRecording); + connect(ui->stopButton, &QPushButton::clicked, this, &MainWindow::stopRecording); + connect(ui->actionAbout, &QAction::triggered, this, [this]() { + QString infostr = QStringLiteral("LSL library version: ") + + QString::number(lsl::library_version()) + + "\nLSL library info: " + lsl::library_info(); + // Add security version information if available + if (lsl::is_secure_build()) { + infostr += "\n\nSecurity: Enabled"; + infostr += "\nBase version: " + QString(lsl::base_version()); + infostr += "\nSecurity version: " + QString(lsl::security_version()); + infostr += "\nFull version: " + QString(lsl::full_version()); + } else { + infostr += "\n\nSecurity: Not available"; + } + QMessageBox::about(this, "About this app", infostr); + }); + + // Signals for Remote Control Socket + connect(ui->rcsCheckBox, &QCheckBox::toggled, this, &MainWindow::rcsCheckBoxChanged); + connect(ui->rcsport, QOverload::of(&QSpinBox::valueChanged), this, &MainWindow::rcsportValueChangedInt); + + // Wheenver lineEdit_template is changed, print the final result. + connect( + ui->lineEdit_template, &QLineEdit::textChanged, this, &MainWindow::printReplacedFilename); + auto spinchanged = static_cast(&QSpinBox::valueChanged); + connect(ui->spin_counter, spinchanged, this, &MainWindow::printReplacedFilename); + + // Signals for builder-related edits -> buildFilename + connect(ui->rootBrowseButton, &QPushButton::clicked, this, [this]() { + this->ui->rootEdit->setText(QDir::toNativeSeparators( + QFileDialog::getExistingDirectory(this, "Study root folder..."))); + this->buildFilename(); + }); + connect(ui->rootEdit, &QLineEdit::editingFinished, this, &MainWindow::buildFilename); + connect( + ui->lineEdit_participant, &QLineEdit::editingFinished, this, &MainWindow::buildFilename); + connect(ui->lineEdit_session, &QLineEdit::editingFinished, this, &MainWindow::buildFilename); + connect(ui->lineEdit_acq, &QLineEdit::editingFinished, this, &MainWindow::buildFilename); + connect(ui->input_blocktask, &QComboBox::currentTextChanged, this, &MainWindow::buildFilename); + connect(ui->input_modality, &QComboBox::currentTextChanged, this, &MainWindow::buildFilename); + connect(ui->check_bids, &QCheckBox::toggled, this, [this](bool checked) { + auto &box = *ui->lineEdit_template; + box.setReadOnly(checked); + if (checked) { + legacyTemplate = box.text(); + box.setText(QDir::toNativeSeparators( + QStringLiteral("sub-%p/ses-%s/%m/sub-%p_ses-%s_task-%b[_acq-%a]_run-%r_%m.xdf"))); + ui->label_counter->setText("Run (%r)"); + } else { + box.setText(QDir::toNativeSeparators(legacyTemplate)); + ui->label_counter->setText("Exp num (%n)"); + } + }); + + timer = std::make_unique(this); + connect(&*timer, &QTimer::timeout, this, &MainWindow::statusUpdate); + timer->start(1000); + + QString cfgfilepath = find_config_file(config_file); + load_config(cfgfilepath); +} + +void MainWindow::statusUpdate() { + if (currentRecording) { + // Check for errors from the recording threads + auto errors = currentRecording->getErrors(); + for (const auto& error : errors) { + QString errorMsg = QString::fromStdString(error.error_message); + QString streamName = QString::fromStdString(error.stream_name); + + if (error.is_security_error) { + // Extract the most relevant part of the security error message + QString displayMsg = errorMsg; + if (errorMsg.contains("403")) { + // Extract the 403 message for cleaner display + int idx = errorMsg.indexOf("403"); + if (idx >= 0) { + displayMsg = errorMsg.mid(idx); + } + } + // Show security errors prominently + QMessageBox::warning(const_cast(this), + "Security Error - " + streamName, + "Failed to connect to stream '" + streamName + "':\n\n" + displayMsg + + "\n\nMake sure all devices have matching security configurations."); + } else { + // Log non-security errors to console + qWarning() << "Stream error for" << streamName << ":" << errorMsg; + } + } + + auto elapsed = static_cast(lsl::local_clock() - startTime); + QString recFilename = replaceFilename(QDir::cleanPath(ui->lineEdit_template->text())); + auto fileinfo = QFileInfo(QDir::cleanPath(ui->rootEdit->text()) + '/' + recFilename); + fileinfo.refresh(); + auto size = fileinfo.size(); + QString timeString = QStringLiteral("Recording to %1 (%2; %3kb)") + .arg(QDir::toNativeSeparators(recFilename), + QTime(0,0).addSecs(elapsed).toString("hh:mm:ss"), + QString::number(size / 1000)); + statusBar()->showMessage(timeString); + } +} + +void MainWindow::closeEvent(QCloseEvent *ev) { + if (currentRecording) ev->ignore(); +} + +void MainWindow::blockSelected(const QString &block) { + if (currentRecording) + QMessageBox::information(this, "Still recording", + "Please stop recording before switching blocks.", QMessageBox::Ok); + else { + printReplacedFilename(); + // scripted action code here... + } +} + +void MainWindow::load_config(QString filename) { + qInfo() << "loading config file " << QDir::toNativeSeparators(filename); + bool auto_start = false; + try { + QSettings pt(QDir::cleanPath(filename), QSettings::Format::IniFormat); + + // ---------------------------- + // required streams + // ---------------------------- + auto required = pt.value("RequiredStreams").toStringList(); +#if QT_VERSION >= QT_VERSION_CHECK(5,14,0) + missingStreams = QSet(required.begin(), required.end()); +#else + missingStreams = required.toSet(); +#endif + + // ---------------------------- + // online sync streams + // ---------------------------- + QStringList onlineSyncStreams = pt.value("OnlineSync", QStringList()).toStringList(); + for (QString &oss : onlineSyncStreams) { +#if QT_VERSION >= QT_VERSION_CHECK(5,14,0) + auto skipEmpty = Qt::SkipEmptyParts; +#else + auto skipEmpty = QString::SkipEmptyParts; +#endif + + QStringList words = oss.split(' ', skipEmpty); + // The first two words ("StreamName (PC)") are the stream identifier + if (words.length() < 2) { + qInfo() << "Invalid sync stream config: " << oss; + continue; + } + QString key = words.takeFirst() + ' ' + words.takeFirst(); + + int val = 0; + for (const auto &word : std::as_const(words)) { + if (word == "post_clocksync") { val |= lsl::post_clocksync; } + if (word == "post_dejitter") { val |= lsl::post_dejitter; } + if (word == "post_monotonize") { val |= lsl::post_monotonize; } + if (word == "post_threadsafe") { val |= lsl::post_threadsafe; } + if (word == "post_ALL") { val = lsl::post_ALL; } + } + syncOptionsByStreamName[key.toStdString()] = val; + qInfo() << "stream sync options: " << key << ": " << val; + } + + // ---------------------------- + // Block/Task Names + // ---------------------------- + QStringList taskNames; + if (pt.contains("SessionBlocks")) { taskNames = pt.value("SessionBlocks").toStringList(); } + ui->input_blocktask->clear(); + ui->input_blocktask->insertItems(0, taskNames); + + // StorageLocation + QString studyRoot; + legacyTemplate.clear(); + + if (pt.contains("StorageLocation")) { + if (pt.contains("StudyRoot")) + throw std::runtime_error("StorageLocation cannot be used if StudyRoot is also specified."); + if (pt.contains("PathTemplate")) + throw std::runtime_error("StorageLocation cannot be used if PathTemplate is also specified."); + + QString str_path = pt.value("StorageLocation").toString(); + QString path_root; + auto index = str_path.indexOf('%'); + if (index != -1) { + // When a % is encountered, the studyroot gets set to the + // longest path before the placeholder, e.g. + // foo/bar/baz%a/untitled.xdf gets split into + // foo/bar and baz%a/untitled.xdf + path_root = str_path.left(index); + } else { + // Otherwise, it's split into folder and constant filename + path_root = str_path; + } + studyRoot = QFileInfo(path_root).absolutePath(); + legacyTemplate = str_path.remove(0, studyRoot.length() + 1); + // absolute path, nothing to be done + // studyRoot = QFileInfo(path_root).absolutePath(); + } + // StudyRoot + if (pt.contains("StudyRoot")) { studyRoot = pt.value("StudyRoot").toString(); } + if (pt.contains("PathTemplate")) { legacyTemplate = pt.value("PathTemplate").toString(); } + + if (studyRoot.isEmpty()) + studyRoot = QStandardPaths::writableLocation(QStandardPaths::DocumentsLocation) + + QDir::separator() + "CurrentStudy"; + ui->rootEdit->setText(QDir::toNativeSeparators(studyRoot)); + + if (legacyTemplate.isEmpty()) { + ui->check_bids->setChecked(true); + // Use default, exp%n/%b.xdf , only to be used if BIDS gets unchecked. + legacyTemplate = "exp%n/block_%b.xdf"; + } else { + ui->check_bids->setChecked(false); + ui->lineEdit_template->setText(QDir::toNativeSeparators(legacyTemplate)); + } + + // Append BIDS modalities to the default list. + ui->input_modality->insertItems( + ui->input_modality->count(), pt.value("BidsModalities", bids_modalities_default).toStringList()); + ui->input_modality->setCurrentIndex(0); + + buildFilename(); + + // Remote Control Socket options + if (pt.contains("RCSPort")) { + int rcs_port = pt.value("RCSPort").toInt(); + ui->rcsport->setValue(rcs_port); + // In case it's already running (how?), stop the RCS listener. + ui->rcsCheckBox->setChecked(false); + } + + if (pt.contains("RCSEnabled")) { + bool b_enable_rcs = pt.value("RCSEnabled").toBool(); + ui->rcsCheckBox->setChecked(b_enable_rcs); + } + + // Check the wild-card-replaced filename to see if it exists already. + // If it does then increment the exp number. + // We only do this on settings-load because manual spin changes might indicate purposeful + // overwriting. + QString recFilename = QDir::cleanPath(ui->lineEdit_template->text()); + // Spin Number + if (recFilename.contains(counterPlaceholder())) { + for (int i = 1; i < 1001; i++) { + ui->spin_counter->setValue(i); + if (!QFileInfo::exists(replaceFilename(recFilename))) break; + } + } + + if (pt.contains("AutoStart")) { + auto_start = pt.value("AutoStart").toBool(); + } + + } catch (std::exception &e) { qWarning() << "Problem parsing config file: " << e.what(); } + // std::cout << "refreshing streams ..." <rootEdit->text())); + if (!ui->check_bids->isChecked()) + settings.setValue("PathTemplate", QDir::cleanPath(ui->lineEdit_template->text())); + // Build QStringList from missingStreams and knownStreams that are missing. + QStringList requiredStreams = missingStreams.values(); + for (auto &k : knownStreams) { + if (k.checked) { requiredStreams.append(k.listName()); } + } + qInfo() << missingStreams; + settings.setValue("RequiredStreams", requiredStreams); + // Stub. +} + +QString info_to_listName(const lsl::stream_info& info) { + QString suffix = info.security_enabled() ? QString::fromUtf8(" \xF0\x9F\x94\x92") : QString(""); // Lock emoji at end + return QString::fromStdString(info.name() + " (" + info.hostname() + ")") + suffix; +} + +/** + * @brief MainWindow::refreshStreams Find streams, generate a list of missing streams + * and fill the UI streamlist. + * @return A vector of found stream_infos + */ +std::vector MainWindow::refreshStreams() { + const std::vector resolvedStreams = lsl::resolve_streams(1.0); + + // For each item in resolvedStreams, ignore if already in knownStreams, otherwise add to knownStreams. + // if in missingStreams then also mark it as required (--> checked by default) and remove from missingStreams. + for (const auto& s : resolvedStreams) { + bool known = false; + for (auto &k : knownStreams) { + known |= s.name() == k.name && s.type() == k.type && s.source_id() == k.id; + } + if (!known) { + bool found = missingStreams.contains(info_to_listName(s)); + knownStreams << StreamItem(s.name(), s.type(), s.source_id(), s.hostname(), found, + s.security_enabled(), s.security_fingerprint()); + if (found) { missingStreams.remove(info_to_listName(s)); } + } + } + // For each item in knownStreams, update its checked status from GUI. (only works for streams found on a previous refresh) + // Because we search by name + host, entries aren't guaranteed to be unique, so checking one entry with matching name and host checks them all. + for (auto &k : knownStreams) { + QList foundItems = ui->streamList->findItems(k.listName(), Qt::MatchCaseSensitive); + if (foundItems.count() > 0) { + bool checked = false; + for (auto &fi : foundItems) { checked |= fi->checkState() == Qt::Checked; } + k.checked = checked; + } + } + // For each item in knownStreams; if it is not resolved then drop it. If it was checked then add back to missingStreams. + int k_ind = 0; + while (k_ind < knownStreams.count()) { + StreamItem k = knownStreams.at(k_ind); + bool resolved = false; + size_t r_ind = 0; + while (!resolved && r_ind < resolvedStreams.size()) { + const lsl::stream_info r = resolvedStreams[r_ind]; + resolved |= (r.name() == k.name) && (r.type() == k.type) && (r.source_id() == k.id); + r_ind++; + } + if (!resolved) { + if (k.checked) { missingStreams += k.listName(); } + knownStreams.removeAt(k_ind); + } else { + k_ind++; + } + } + // Clear the streamList + // Add missing items first. + // Then add knownStreams (only in list if resolved). + const QBrush good_brush(QColor(0, 128, 0)), bad_brush(QColor(255, 0, 0)); + ui->streamList->clear(); + for (auto& m : std::as_const(missingStreams)) { + auto *item = new QListWidgetItem(m, ui->streamList); + item->setCheckState(Qt::Checked); + item->setForeground(bad_brush); + ui->streamList->addItem(item); + } + for (auto& k : knownStreams) { + auto *item = new QListWidgetItem(k.listName(), ui->streamList); + item->setCheckState(k.checked ? Qt::Checked : Qt::Unchecked); + item->setForeground(good_brush); + ui->streamList->addItem(item); + } + + // return a std::vector of streams of checked and not missing streams. + std::vector requestedAndAvailableStreams; + for (const auto &r : resolvedStreams) { + for (auto &k : knownStreams) { + if ((r.name() == k.name) && (r.type() == k.type) && (r.source_id() == k.id)) { + if (k.checked) { requestedAndAvailableStreams.push_back(r); } + break; + } + } + } + return requestedAndAvailableStreams; +} + +void MainWindow::startRecording() { + if (!currentRecording) { + + // automatically refresh streams + const std::vector requestedAndAvailableStreams = refreshStreams(); + + if (!hideWarnings) { + // if a checked stream is now missing + if (!missingStreams.isEmpty()) { + // are you sure? + QMessageBox msgBox(QMessageBox::Warning, "Stream not found", + "At least one of the streams that you checked seems to be offline", + QMessageBox::Yes | QMessageBox::No, this); + msgBox.setInformativeText("Do you want to start recording anyway?"); + msgBox.setDefaultButton(QMessageBox::No); + if (msgBox.exec() != QMessageBox::Yes) return; + } + + if (requestedAndAvailableStreams.size() == 0) { + QMessageBox msgBox(QMessageBox::Warning, "No available streams selected", + "You have selected no streams", QMessageBox::Yes | QMessageBox::No, this); + msgBox.setInformativeText("Do you want to start recording anyway?"); + msgBox.setDefaultButton(QMessageBox::No); + if (msgBox.exec() != QMessageBox::Yes) return; + } + + // Check for security mismatches between local config and selected streams + bool localSecurityEnabled = lsl::local_security_enabled(); + QStringList securityMismatchStreams; + for (const auto& stream : requestedAndAvailableStreams) { + bool streamSecure = stream.security_enabled(); + if (streamSecure && !localSecurityEnabled) { + // Stream requires security but local security is not enabled + securityMismatchStreams.append(QString::fromStdString(stream.name())); + } else if (!streamSecure && localSecurityEnabled) { + // Stream is insecure but local security is enabled + securityMismatchStreams.append(QString::fromStdString(stream.name()) + " (insecure)"); + } + } + if (!securityMismatchStreams.isEmpty()) { + QString errorMsg; + // Format stream names as a bulleted list with blue color + QString streamList; + for (const auto& s : securityMismatchStreams) { + streamList += "  • " + s + "
"; + } + if (!localSecurityEnabled) { + errorMsg = "The following streams require security, but Lab Recorder does not have " + "security credentials configured:

" + streamList + + "
To fix this:
" + "  1. Run 'lsl-keygen' to generate credentials, or
" + "  2. Import shared credentials from an authorized device

" + "Recording cannot proceed with mismatched security settings."; + } else { + errorMsg = "Security mismatch detected for the following streams:

" + + streamList + + "
All devices must have the same security configuration " + "(either all secure or all insecure).

" + "Recording cannot proceed with mismatched security settings."; + } + QMessageBox msgBox(this); + msgBox.setWindowTitle("Security Mismatch"); + msgBox.setIcon(QMessageBox::Critical); + msgBox.setTextFormat(Qt::RichText); + msgBox.setText(errorMsg); + msgBox.exec(); + return; + } + } + + // don't hide critical errors. + QString recFilename = replaceFilename(QDir::cleanPath(ui->lineEdit_template->text())); + if (recFilename.isEmpty()) { + QMessageBox::critical(this, "Filename empty", "Can not record without a file name"); + return; + } + recFilename.prepend(QDir::cleanPath(ui->rootEdit->text()) + '/'); + + QFileInfo recFileInfo(recFilename); + if (recFileInfo.exists()) { + if (recFileInfo.isDir()) { + QMessageBox::warning( + this, "Error", "Recording path already exists and is a directory"); + return; + } + QString rename_to = recFileInfo.absolutePath() + '/' + recFileInfo.baseName() + + "_old%1." + recFileInfo.suffix(); + // search for highest _oldN + int i = 1; + while (QFileInfo::exists(rename_to.arg(i))) i++; + QString newname = rename_to.arg(i); + if (!QFile::rename(recFileInfo.absoluteFilePath(), newname)) { + QMessageBox::warning(this, "Permissions issue", + "Cannot rename the file " + recFilename + " to " + newname); + return; + } + qInfo() << "Moved existing file to " << newname; + recFileInfo.refresh(); + } + + // regardless, we need to create the directory if it doesn't exist + if (!recFileInfo.dir().mkpath(".")) { + QMessageBox::warning(this, "Permissions issue", + "Can not create the directory " + recFileInfo.dir().path() + + ". Please check your permissions."); + return; + } + + std::vector watchfor; + for (const QString &missing : std::as_const(missingStreams)) { + std::string query; + // Convert missing to query expected by lsl::resolve_stream + // name='BioSemi' and hostname=AASDFSDF + QRegularExpression re("(.+)\\s+\\((\\S+)\\)"); + QRegularExpressionMatch match = re.match(missing); + if (match.hasMatch()) + { + QString name = match.captured(1); + QString host = match.captured(2); + query = "name='" + match.captured(1).toStdString() + "'"; + if (host.size() > 1) { + query += " and hostname='" + host.toStdString() + "'"; + } + } else { + // Regexp failed but we can try using the entire string as the stream name. + query = "name='" + missing.toStdString() + "'"; + } + watchfor.push_back(query); + } + qInfo() << "Missing: " << missingStreams; + + currentRecording = std::make_unique(recFilename.toStdString(), + requestedAndAvailableStreams, watchfor, syncOptionsByStreamName, true); + ui->stopButton->setEnabled(true); + ui->startButton->setEnabled(false); + startTime = (int)lsl::local_clock(); + + } else if (!hideWarnings) { + QMessageBox::information( + this, "Already recording", "The recording is already running", QMessageBox::Ok); + } +} + +void MainWindow::stopRecording() { + + if (currentRecording) { + try { + currentRecording = nullptr; + } catch (std::exception &e) { qWarning() << "exception on stop: " << e.what(); } + ui->startButton->setEnabled(true); + ui->stopButton->setEnabled(false); + statusBar()->showMessage("Stopped"); + } else if (!hideWarnings) { + QMessageBox::information( + this, "Not recording", "There is not ongoing recording", QMessageBox::Ok); + } +} + +void MainWindow::selectAllStreams() { + for (int i = 0; i < ui->streamList->count(); i++) { + QListWidgetItem *item = ui->streamList->item(i); + item->setCheckState(Qt::Checked); + } +} + +void MainWindow::selectNoStreams() { + for (int i = 0; i < ui->streamList->count(); i++) { + QListWidgetItem *item = ui->streamList->item(i); + item->setCheckState(Qt::Unchecked); + } +} + +void MainWindow::buildBidsTemplate() { + // path/to/CurrentStudy/sub-%p/ses-%s/eeg/sub-%p_ses-%s_task-%b[_acq-%a]_run-%r_eeg.xdf + + // Make sure the BIDS required fields are full. + if (ui->lineEdit_participant->text().isEmpty()) { ui->lineEdit_participant->setText("P001"); } + if (ui->lineEdit_session->text().isEmpty()) { ui->lineEdit_session->setText("S001"); } + if (ui->input_blocktask->currentText().isEmpty()) { + ui->input_blocktask->setCurrentText("Default"); + } + // BIDS modality selection + if (ui->input_modality->currentText().isEmpty()) { + ui->input_modality->insertItems(0, bids_modalities_default); + ui->input_modality->setCurrentIndex(0); + } + + // Folder hierarchy + QStringList fileparts{"sub-%p", "ses-%s", "%m"}; + + // filename + QString fname = "sub-%p_ses-%s_task-%b"; + if (!ui->lineEdit_acq->text().isEmpty()) { fname.append("_acq-%a"); } + fname.append("_run-%r_%m.xdf"); + fileparts << fname; + ui->lineEdit_template->setText(QDir::toNativeSeparators(fileparts.join('/'))); +} + +void MainWindow::buildFilename() { + // This function is only called when a widget within Location Builder is activated. + + // Build the file location in parts, starting with the root folder. + if (ui->check_bids->isChecked()) { buildBidsTemplate(); } + QString tpl = QDir::cleanPath(ui->lineEdit_template->text()); + + // Auto-increment Spin/Run Number if necessary. + if (tpl.contains(counterPlaceholder())) { + for (int i = 1; i < 1001; i++) { + ui->spin_counter->setValue(i); + if (!QFileInfo::exists(replaceFilename(tpl))) break; + } + } + // Sometimes lineEdit_template doesn't change so printReplacedFilename isn't triggered. + // So trigger manually. + printReplacedFilename(); +} + +QString MainWindow::replaceFilename(QString fullfile) const { + // Replace wildcards. + // There are two different wildcard formats: legacy, BIDS + + // Legacy takes the form path/to/study/exp%n/%b.xdf + // Where %n will be replaced by the contents of the spin_counter widget + // and %b will be replaced by the contents of the blockList widget. + fullfile.replace("%b", ui->input_blocktask->currentText()); + + // BIDS + // See https://docs.google.com/document/d/1ArMZ9Y_quTKXC-jNXZksnedK2VHHoKP3HCeO5HPcgLE/ + // path/to/study/sub-/ses-/eeg/sub-_ses-_task-[_acq-]_run-_eeg.xdf + // path/to/study/sub-%p/ses-%s/eeg/sub-%p_ses-%s_task-%b[_acq-%a]_run-%r_eeg.xdf + // %b already replaced above. + fullfile.replace("%p", ui->lineEdit_participant->text()); + fullfile.replace("%s", ui->lineEdit_session->text()); + fullfile.replace("%a", ui->lineEdit_acq->text()); + fullfile.replace("%m", ui->input_modality->currentText()); + + // Replace either %r or %n with the counter + QString run = QString("%1").arg(ui->spin_counter->value(), 3, 10, QChar('0')); + fullfile.replace(counterPlaceholder(), run); + + return fullfile.trimmed(); +} + +/** + * Find a config file to load. This is (in descending order or preference): + * - a file supplied on the command line + * - [executablename].cfg in one the the following folders: + * - the current working directory + * - the default config folder, e.g. '~/Library/Preferences' on OS X + * - the executable folder + * @param filename Optional file name supplied e.g. as command line parameter + * @return Path to a found config file + */ +QString MainWindow::find_config_file(const char *filename) { + if (filename) { + QString qfilename(filename); + if (!QFileInfo::exists(qfilename)) + QMessageBox(QMessageBox::Warning, "Config file not found", + QStringLiteral("The file '%1' doesn't exist").arg(qfilename), QMessageBox::Ok, + this); + else + return qfilename; + } + QFileInfo exeInfo(QCoreApplication::applicationFilePath()); + QString defaultCfgFilename(exeInfo.completeBaseName() + ".cfg"); + qInfo() << defaultCfgFilename; + QStringList cfgpaths; + cfgpaths << QDir::currentPath() + << QStandardPaths::standardLocations(QStandardPaths::AppConfigLocation) + << QStandardPaths::standardLocations(QStandardPaths::AppDataLocation) + << exeInfo.path(); + for (const auto &path : std::as_const(cfgpaths)) { + QString cfgfilepath = path + QDir::separator() + defaultCfgFilename; + qInfo() << cfgfilepath; + if (QFileInfo::exists(cfgfilepath)) return cfgfilepath; + } + QMessageBox msgBox; + msgBox.setWindowTitle("Config file not found"); + msgBox.setText("Config file not found."); + msgBox.setInformativeText("Continuing with default config."); + msgBox.setStandardButtons(QMessageBox::Ok); + msgBox.exec(); + return ""; +} + +QString MainWindow::counterPlaceholder() const { return ui->check_bids->isChecked() ? "%r" : "%n"; } + +void MainWindow::printReplacedFilename() { + ui->locationLabel->setText( + ui->rootEdit->text() + '\n' + replaceFilename(ui->lineEdit_template->text())); +} + +MainWindow::~MainWindow() noexcept = default; + +void MainWindow::rcsCheckBoxChanged(bool checked) { enableRcs(checked); } + +void MainWindow::enableRcs(bool bEnable) { + if (rcs) { + if (!bEnable) { + disconnect(rcs.get()); + rcs = nullptr; + } + } else if (bEnable) { + uint16_t port = ui->rcsport->value(); + rcs = std::make_unique(port); + // TODO: Add some method to RemoteControlSocket to report if its server is listening (i.e. was successful). + connect(rcs.get(), &RemoteControlSocket::refresh_streams, this, &MainWindow::refreshStreams); + connect(rcs.get(), &RemoteControlSocket::start, this, &MainWindow::rcsStartRecording); + connect(rcs.get(), &RemoteControlSocket::stop, this, &MainWindow::rcsStopRecording); + connect(rcs.get(), &RemoteControlSocket::filename, this, &MainWindow::rcsUpdateFilename); + connect(rcs.get(), &RemoteControlSocket::select_all, this, &MainWindow::selectAllStreams); + connect(rcs.get(), &RemoteControlSocket::select_none, this, &MainWindow::selectNoStreams); + } + bool oldState = ui->rcsCheckBox->blockSignals(true); + ui->rcsCheckBox->setChecked(bEnable); + ui->rcsCheckBox->blockSignals(oldState); +} + +void MainWindow::rcsportValueChangedInt(int value) { + if (rcs) { + enableRcs(false); // Will also uncheck box. + enableRcs(true); // Will also check box. + } +} + +void MainWindow::rcsStartRecording() { + // since we want to avoid a pop-up window when streams are missing or unchecked, + // we'll check all the streams and start recording + hideWarnings = true; + selectAllStreams(); + startRecording(); +} + +void MainWindow::rcsStopRecording() { + hideWarnings = true; + stopRecording(); +} + +void MainWindow::rcsUpdateFilename(QString s) { + // + // format: "filename {option:value}{option:value} + // Options are: + // root: full path to Study root; + // template: legacy filename template, left to default (bids) if unspecified; + // task; run; participant; session; acquisition: base options + // (BIDS) modality: from either the defaults eeg, ieeg, meg, beh or adding a new + // potentially unsupported value. + QRegularExpression re("{(?P