├── .github └── workflows │ └── cppcmake.yml ├── .gitignore ├── .travis.yml ├── BUILD.md ├── CMakeLists.txt ├── LICENSE ├── LabRecorder.cfg ├── LabRecorder_BIDS.cfg ├── LabRecorder_Legacy.cfg ├── README.md ├── cmake └── MacOSXBundleInfo.plist.in ├── doc ├── controls.png ├── labrecorder-default.png ├── labrecorder-running.png ├── labrecorder-study.png └── templates.md ├── src ├── clirecorder.cpp ├── fptest.cpp ├── main.cpp ├── mainwindow.cpp ├── mainwindow.h ├── mainwindow.ui ├── recording.cpp ├── recording.h ├── tcpinterface.cpp └── tcpinterface.h └── xdfwriter ├── CMakeLists.txt ├── conversions.h ├── test_iec559_and_little_endian.cpp ├── test_xdf_writer.cpp ├── xdfwriter.cpp └── xdfwriter.h /.github/workflows/cppcmake.yml: -------------------------------------------------------------------------------- 1 | name: C/C++ CI 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches: ['*'] 7 | tags: 8 | - v*.* 9 | pull_request: 10 | branches: 11 | - master 12 | release: 13 | types: ['created'] 14 | 15 | env: 16 | LSL_RELEASE_URL: 'https://github.com/sccn/liblsl/releases/download' 17 | LSL_RELEASE: '1.16.2' 18 | 19 | defaults: 20 | run: 21 | shell: bash 22 | 23 | # Check qt_ver on # https://download.qt.io/online/qtsdkrepository/ 24 | jobs: 25 | build: 26 | name: ${{ matrix.config.name }} 27 | runs-on: ${{ matrix.config.os }} 28 | strategy: 29 | fail-fast: false 30 | matrix: 31 | config: 32 | - name: "ubuntu-24.04" 33 | os: "ubuntu-24.04" 34 | - name: "ubuntu-22.04" 35 | os: "ubuntu-22.04" 36 | - name: "windows-x64" 37 | os: "windows-latest" 38 | cmake_extra: "-T v142,host=x86" 39 | arch: "amd64" 40 | qt_arch: "win64_msvc2019_64" 41 | qt_ver: "6.4.0" 42 | - name: "windows-x86" 43 | os: "windows-latest" 44 | cmake_extra: "-T v142,host=x86 -A Win32" 45 | arch: "i386" 46 | qt_arch: "win32_msvc2019" 47 | qt_ver: "5.15.2" 48 | - name: "macOS-latest" 49 | os: "macOS-latest" 50 | steps: 51 | - uses: actions/checkout@v4 52 | 53 | - name: Install liblsl (Ubuntu) 54 | if: startsWith(matrix.config.os, 'ubuntu-') 55 | run: | 56 | sudo apt install -y libpugixml-dev 57 | curl -L ${LSL_RELEASE_URL}/v${LSL_RELEASE}/liblsl-${LSL_RELEASE}-$(lsb_release -sc)_amd64.deb -o liblsl.deb 58 | sudo apt install ./liblsl.deb 59 | 60 | - name: Download liblsl (Windows) 61 | if: matrix.config.os == 'windows-latest' 62 | run: | 63 | curl -L ${LSL_RELEASE_URL}/v${LSL_RELEASE}/liblsl-${LSL_RELEASE}-Win_${{ matrix.config.arch}}.zip -o liblsl.zip 64 | 7z x liblsl.zip -oLSL 65 | 66 | - name: Download liblsl (macOS) 67 | if: startsWith(matrix.config.os, 'macos-') 68 | run: brew install labstreaminglayer/tap/lsl 69 | 70 | - name: Install Qt (Window) 71 | if: (matrix.config.os == 'windows-latest') 72 | uses: jurplel/install-qt-action@v4 73 | with: 74 | version: ${{ matrix.config.qt_ver }} 75 | arch: ${{ matrix.config.qt_arch }} 76 | 77 | - name: Install Qt (Ubuntu) 78 | if: startsWith(matrix.config.os, 'ubuntu-') 79 | run: sudo apt install qt6-base-dev freeglut3-dev 80 | 81 | - name: Install Qt (MacOS) 82 | if: startsWith(matrix.config.os, 'macos-') 83 | run: brew install qt 84 | 85 | - name: Configure CMake 86 | run: | 87 | cmake --version 88 | cmake -S . -B build \ 89 | -DCMAKE_BUILD_TYPE=Release \ 90 | -DCMAKE_INSTALL_PREFIX=${PWD}/install \ 91 | -DCPACK_PACKAGE_DIRECTORY=${PWD}/package \ 92 | -DLSL_INSTALL_ROOT=$PWD/LSL/ \ 93 | -DCPACK_DEBIAN_PACKAGE_SHLIBDEPS=ON \ 94 | ${{ matrix.config.cmake_extra }} 95 | if [[ "${{ matrix.config.name }}" = ubuntu-* ]]; then 96 | cmake -DLSL_UNIXFOLDERS=ON build 97 | fi 98 | 99 | - name: make 100 | run: cmake --build build --config Release -j --target install 101 | 102 | - name: package 103 | run: | 104 | export LD_LIBRARY_PATH=$Qt5_DIR/lib:$Qt6_DIR/lib:$LD_LIBRARY_PATH 105 | cmake --build build --config Release -j --target package 106 | cmake -E remove_directory package/_CPack_Packages 107 | 108 | - name: Upload Artifacts 109 | uses: actions/upload-artifact@v4 110 | with: 111 | name: pkg-${{ matrix.config.name }} 112 | path: package 113 | 114 | - name: upload to release page 115 | if: github.event_name == 'release' 116 | env: 117 | TOKEN: "token ${{ secrets.GITHUB_TOKEN }}" 118 | UPLOAD_URL: ${{ github.event.release.upload_url }} 119 | run: | 120 | UPLOAD_URL=${UPLOAD_URL%\{*} # remove "{name,label}" suffix 121 | for pkg in package/*.*; do 122 | NAME=$(basename $pkg) 123 | MIME=$(file --mime-type $pkg|cut -d ' ' -f2) 124 | curl -X POST -H "Accept: application/vnd.github.v3+json" -H "Authorization: $TOKEN" -H "Content-Type: $MIME" --data-binary @$pkg $UPLOAD_URL?name=$NAME 125 | done 126 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.ui.autosave 2 | ui_*.h 3 | /build*/ 4 | /package*/ 5 | /install*/ 6 | /CMakeLists.txt.user 7 | /CMakeLists.json 8 | /.vs/ 9 | /.vscode/ 10 | /out/ 11 | /CMakeSettings.json 12 | # CLion 13 | .idea/ 14 | cmake-build-debug/ 15 | cmake-build-release/ 16 | # Generated by CI scripts - or maintainers debugging CI scripts: 17 | liblsl.deb 18 | install-qt.sh 19 | .DS_Store 20 | 21 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: cpp 2 | compiler: clang 3 | env: 4 | APP_VERSION="1.13.1" 5 | matrix: 6 | include: 7 | - os: osx 8 | osx_image: xcode10.1 9 | before_install: 10 | - mkdir LSL 11 | - brew update 12 | - brew upgrade cmake 13 | install: 14 | - git clone https://github.com/sccn/liblsl.git 15 | - cmake -S liblsl -B liblsl/build -DCMAKE_INSTALL_PREFIX=$PWD/LSL/ -DLSL_UNIXFOLDERS=ON .. 16 | - cmake --build liblsl/build --config Release --target install 17 | before_script: 18 | - brew install qt; 19 | - export LSL_INSTALL_ROOT=LSL 20 | script: 21 | - cmake --version 22 | - cmake -S . -B build -DLSL_INSTALL_ROOT=${LSL_INSTALL_ROOT} -DQt5_DIR=/usr/local/opt/qt/lib/cmake/Qt5 23 | - cmake --build build --config Release --target install 24 | - cd build/install/Labrecorder 25 | - if [[ "$TRAVIS_OS_NAME" == "osx" ]]; then for app in *.app; do /usr/local/opt/qt/bin/macdeployqt 26 | ${app} -dmg; mv "${app%.app}.dmg" "${app%.app}-${APP_VERSION}-${TRAVIS_OSX_IMAGE}.dmg"; 27 | done; fi 28 | deploy: 29 | provider: releases 30 | skip_cleanup: true 31 | api_key: 32 | 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= 33 | file: 34 | - LabRecorder-${APP_VERSION}-${TRAVIS_OSX_IMAGE}.dmg 35 | - LabRecorderCLI-${APP_VERSION}-${TRAVIS_OSX_IMAGE}.dmg 36 | on: 37 | repo: labstreaminglayer/App-LabRecorder 38 | tags: true 39 | -------------------------------------------------------------------------------- /BUILD.md: -------------------------------------------------------------------------------- 1 | # Building Lab Recorder 2 | 3 | This file includes quick start recipes. To see general principles, look [here](https://github.com/labstreaminglayer/labstreaminglayer/blob/master/doc/BUILD.md). 4 | 5 | 6 | ## Windows - CMake - Visual Studio 2017 7 | 8 | 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. 9 | 10 | You will need to download and install:
11 | * [The full LabStreamingLayer meta project](https://github.com/labstreaminglayer/labstreaminglayer) -> Clone (include --recursive flag) or download 12 | * [Visual Studio Community 2017](https://imagine.microsoft.com/en-us/Catalog/Product/530) 13 | * [CMake 3.12.1](https://cmake.org/files/v3.12/) 14 | * [Qt 5.11.1](https://download.qt.io/archive/qt/5.11/) 15 | * [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) 16 | 17 | 18 | From Visual Studio:
19 | * File -> Open -> CMake -> labstreaminglayer/CMakeLists.txt 20 | * Wait while CMake configures automatically and until CMake menu turns black 21 | * Select x86-Release 22 | * CMake menu -> Change CMake Settings -> LabStreamingLayer 23 | 24 | Add the "variable" section to the x86-Release group, so that it looks approximately like this: 25 | ``` 26 | { 27 | "name": "x86-Release", 28 | "generator": "Ninja", 29 | "configurationType": "RelWithDebInfo", 30 | "inheritEnvironments": [ "msvc_x86" ], 31 | "buildRoot": "${env.USERPROFILE}\\CMakeBuilds\\${workspaceHash}\\build\\${name}", 32 | "installRoot": "${env.USERPROFILE}\\CMakeBuilds\\${workspaceHash}\\install\\${name}", 33 | "cmakeCommandArgs": "", 34 | "buildCommandArgs": "-v", 35 | "ctestCommandArgs": "", 36 | "variables": [ 37 | { 38 | "name": "Qt5_DIR", 39 | "value": "C:\\Qt\\5.11.1\\msvc2015\\lib\\cmake\\Qt5 " 40 | }, 41 | { 42 | "name": "BOOST_ROOT", 43 | "value": "C:\\local\\boost_1_65_1" 44 | }, 45 | { 46 | "name": "LSLAPPS_LabRecorder", 47 | "value": "ON" 48 | } 49 | ] 50 | } 51 | ``` 52 | 53 | * 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). 54 | 55 | * Note that it is not a typo that Qt refers to msvc2015 rather than msvc2017. 56 | 57 | * Select Startup Item (green arrow dropdown) -> LabRecorder.exe (Install) 58 | 59 | * Click the green arrow. The application should launch. 60 | 61 | 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. 62 | 63 | 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. 64 | 65 | 66 | ## Windows - CMake - Legacy 67 | 68 | This procedure also works in Visual Studio 2017, if desired. Generally, I'd recommend sticking with the newer procedure and Visual Studio 2017. 69 | 70 | This procedure generates a Visual Studio type project from the CMake files, which can then be opened in Visual Studio. 71 | 72 | You will need to download and install:
73 | * The full [LabStreamingLayer meta project](https://github.com/labstreaminglayer/labstreaminglayer) -> Clone (include --recursive flag) or download 74 | * Desired Visual Studio Version (the example uses 2015). 75 | * [CMake 3.12.1](https://cmake.org/files/v3.12/) 76 | * [Qt 5.11.1](https://download.qt.io/archive/qt/5.11/) 77 | * [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) 78 | 79 | From the command line, from the labstreaminglayer folder: 80 | * 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 81 | 82 | * labstreaminglayer\build>cmake --build . --config Release --target install 83 | 84 | To see a list of possible generators, run the command with garbage in the -G option. 85 | 86 | The executable is in labstreaminglayer\build\install\LabRecorder. 87 | 88 | You can open the Visual Studio Project with labstreaminglayer\build\LabStreamingLayer.sln. 89 | 90 | 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.
91 | 92 | 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. 93 | 94 | 95 | ## Linux 96 | 97 | * Ubuntu (/Debian) 98 | * `sudo apt-get install build-essential cmake qt5-default libboost-all-dev` 99 | 100 | 1. Open a Command Prompt / Terminal (Windows: MSVC Command Prompt) and change into this directory. 101 | 1. Make a build subdirectory: `mkdir build && cd build` 102 | 1. Call cmake 103 | * `cmake ..` 104 | * If your liblsl binaries are not in `../../LSL/liblsl/build/install`, add the path (`cmake -DLSL_INSTALL_ROOT=/path/to/liblsl/binaries ..`) 105 | * Optional: Use a [generator](https://cmake.org/cmake/help/latest/manual/cmake-generators.7.html#visual-studio-generators) 106 | 1. You may need to specify additional cmake options. 107 | . Build everything and copy the files to the `install` folder: 108 | * `cmake --build . --target install` 109 | 110 | 111 | ## OS X 112 | 113 | * Use [homebrew](https://brew.sh/) 114 | * `brew install cmake qt boost` 115 | 116 | 1. Open a Command Prompt / Terminal (Windows: MSVC Command Prompt) and change into this directory. 117 | 1. Make a build subdirectory: `mkdir build && cd build` 118 | 1. Call cmake 119 | * `cmake ..` 120 | * If your liblsl binaries are not in `../../LSL/liblsl/build/install`, add the path (`cmake -DLSL_INSTALL_ROOT=/path/to/liblsl/binaries ..`) 121 | * Optional: Use a [generator](https://cmake.org/cmake/help/latest/manual/cmake-generators.7.html#visual-studio-generators) 122 | 1. You may need to specify additional cmake options. 123 | . Build everything and copy the files to the `install` folder: 124 | * `cmake --build . --target install` 125 | 126 | -------------------------------------------------------------------------------- /CMakeLists.txt: -------------------------------------------------------------------------------- 1 | cmake_minimum_required(VERSION 3.12) 2 | #set(CMAKE_OSX_DEPLOYMENT_TARGET "10.15" CACHE STRING "Minimum MacOS deployment version") 3 | 4 | project(LabRecorder 5 | DESCRIPTION "Record and write LabStreamingLayer streams to an XDF file" 6 | HOMEPAGE_URL "https://github.com/labstreaminglayer/App-LabRecorder/" 7 | LANGUAGES C CXX 8 | VERSION 1.16.4) 9 | 10 | # Needed for customized MacOSXBundleInfo.plist.in 11 | SET(CMAKE_MODULE_PATH "${CMAKE_CURRENT_LIST_DIR}/cmake" ${CMAKE_MODULE_PATH}) 12 | 13 | set(CMAKE_CXX_STANDARD 17) 14 | set(CMAKE_CXX_STANDARD_REQUIRED On) 15 | 16 | option(BUILD_GUI "Build the GUI, set to off for CLI only build" ON) 17 | 18 | set(CMAKE_XCODE_ATTRIBUTE_CODE_SIGNING_REQUIRED "NO") 19 | set(CMAKE_XCODE_ATTRIBUTE_CODE_SIGN_IDENTITY "") 20 | 21 | # Dependencies 22 | 23 | ## LSL 24 | if(ANDROID) 25 | set(LIBLSL_SOURCE_PATH "../../LSL/liblsl" CACHE STRING "Path to liblsl sources") 26 | 27 | # force include liblsl as target to build with the android toolchain 28 | # as path of the normal build process 29 | add_subdirectory(${LIBLSL_SOURCE_PATH} liblsl_bin) 30 | add_library(LSL::lsl ALIAS lsl) 31 | else() 32 | find_package(LSL REQUIRED 33 | HINTS ${LSL_INSTALL_ROOT} 34 | "${CMAKE_CURRENT_LIST_DIR}/../../LSL/liblsl/build/" 35 | "${CMAKE_CURRENT_LIST_DIR}/../../LSL/liblsl/build/install" 36 | "${CMAKE_CURRENT_LIST_DIR}/../../LSL/liblsl/out/build/x64-Release" 37 | "${CMAKE_CURRENT_LIST_DIR}/../../LSL/liblsl/out/install/x64-Release" 38 | PATH_SUFFIXES share/LSL 39 | ) 40 | endif() 41 | 42 | if (BUILD_GUI) 43 | ## Qt 44 | set(CMAKE_AUTOMOC ON) # The later version of this in LSLCMake is somehow not enough. 45 | set(CMAKE_AUTORCC ON) 46 | set(CMAKE_AUTOUIC ON) 47 | find_package(QT NAMES Qt6 Qt5 REQUIRED COMPONENTS Core) 48 | find_package(Qt${QT_VERSION_MAJOR} REQUIRED COMPONENTS Core Widgets Network DBus) 49 | endif(BUILD_GUI) 50 | 51 | ## Threads 52 | find_package(Threads REQUIRED) 53 | 54 | # Targets 55 | 56 | ## xdfwriter - stand alone library 57 | add_subdirectory(xdfwriter) 58 | 59 | if (BUILD_GUI) 60 | add_executable(${PROJECT_NAME} MACOSX_BUNDLE) 61 | 62 | target_sources(${PROJECT_NAME} PRIVATE 63 | src/main.cpp 64 | src/mainwindow.cpp 65 | src/mainwindow.h 66 | src/mainwindow.ui 67 | src/recording.h 68 | src/recording.cpp 69 | src/tcpinterface.h 70 | src/tcpinterface.cpp 71 | ) 72 | 73 | target_link_libraries(${PROJECT_NAME} 74 | PRIVATE 75 | xdfwriter 76 | Qt${QT_VERSION_MAJOR}::Core 77 | Qt${QT_VERSION_MAJOR}::Widgets 78 | Qt${QT_VERSION_MAJOR}::Network 79 | Qt${QT_VERSION_MAJOR}::DBus 80 | Threads::Threads 81 | LSL::lsl 82 | ) 83 | endif(BUILD_GUI) 84 | 85 | 86 | add_executable(LabRecorderCLI MACOSX_BUNDLE 87 | src/clirecorder.cpp 88 | src/recording.h 89 | src/recording.cpp 90 | ) 91 | 92 | target_link_libraries(LabRecorderCLI 93 | PRIVATE 94 | xdfwriter 95 | Threads::Threads 96 | LSL::lsl 97 | ) 98 | 99 | installLSLApp(xdfwriter) 100 | installLSLApp(testxdfwriter) 101 | installLSLApp(LabRecorderCLI) 102 | if (BUILD_GUI) 103 | installLSLApp(${PROJECT_NAME}) 104 | installLSLAuxFiles(${PROJECT_NAME} 105 | ${PROJECT_NAME}.cfg 106 | LICENSE 107 | README.md 108 | ) 109 | else() 110 | installLSLAuxFiles(LabRecorderCLI 111 | ${PROJECT_NAME}.cfg 112 | LICENSE 113 | README.md 114 | ) 115 | endif(BUILD_GUI) 116 | 117 | 118 | if (WIN32) 119 | if(BUILD_GUI) 120 | get_target_property(QT_QMAKE_EXECUTABLE Qt::qmake IMPORTED_LOCATION) 121 | get_filename_component(QT_WINDEPLOYQT_EXECUTABLE ${QT_QMAKE_EXECUTABLE} PATH) 122 | set(QT_WINDEPLOYQT_EXECUTABLE "${QT_WINDEPLOYQT_EXECUTABLE}/windeployqt.exe") 123 | 124 | add_custom_command( 125 | TARGET ${PROJECT_NAME} POST_BUILD 126 | COMMAND ${QT_WINDEPLOYQT_EXECUTABLE} 127 | --no-translations --no-system-d3d-compiler 128 | --qmldir ${CMAKE_CURRENT_SOURCE_DIR} 129 | $) 130 | 131 | add_custom_command(TARGET ${PROJECT_NAME} POST_BUILD 132 | COMMAND ${CMAKE_COMMAND} -E copy_if_different 133 | $ 134 | $ 135 | $) 136 | else() 137 | add_custom_command(TARGET LabRecorderCLI POST_BUILD 138 | COMMAND ${CMAKE_COMMAND} -E copy_if_different 139 | $ 140 | $ 141 | $) 142 | endif(BUILD_GUI) 143 | endif() 144 | 145 | if (BUILD_GUI) 146 | add_custom_command( 147 | TARGET ${PROJECT_NAME} POST_BUILD 148 | COMMAND ${CMAKE_COMMAND} -E copy 149 | ${CMAKE_CURRENT_SOURCE_DIR}//${PROJECT_NAME}.cfg 150 | $) 151 | else() 152 | add_custom_command( 153 | TARGET LabRecorderCLI POST_BUILD 154 | COMMAND ${CMAKE_COMMAND} -E copy 155 | ${CMAKE_CURRENT_SOURCE_DIR}//${PROJECT_NAME}.cfg 156 | $) 157 | endif(BUILD_GUI) 158 | 159 | if(Qt6_FOUND AND BUILD_GUI) 160 | set_target_properties(${PROJECT_NAME} PROPERTIES 161 | QT_ANDROID_EXTRA_LIBS "${CMAKE_CURRENT_BINARY_DIR}/liblsl_bin/liblsl.so") 162 | qt_finalize_executable(${PROJECT_NAME}) 163 | endif(Qt6_FOUND AND BUILD_GUI) 164 | 165 | set(CPACK_DEBIAN_LABRECORDER_PACKAGE_SECTION "science" CACHE INTERNAL "") 166 | LSLGenerateCPackConfig() 167 | 168 | if(APPLE AND NOT DEFINED ENV{GITHUB_ACTIONS}) 169 | # Qt6 QtNetwork depends on libbrotidec which depends on libbroticommon but whose search path uses @loader_path. 170 | # Unfortunately, macdeployqt does not seem to traverse @loader_path dependencies. 171 | # So we are forced to call `fixup_bundle`. For now, we only do this if homebrew is present 172 | # because that seems to be where the bad dependency is coming from. 173 | # Note that fixup_bundle also destroys the codesigning so we have to redo that. 174 | # TODO: Checkout supercollider apple-specific stuff, e.g.: https://github.com/supercollider/supercollider/blob/develop/CMakeLists.txt#L260-L262 175 | 176 | # Detect Apple Silicon 177 | execute_process( 178 | COMMAND uname -m 179 | OUTPUT_VARIABLE ARCH 180 | OUTPUT_STRIP_TRAILING_WHITESPACE 181 | ) 182 | 183 | # Check for Homebrew 184 | execute_process( 185 | COMMAND brew --prefix 186 | RESULT_VARIABLE BREW_LIB 187 | OUTPUT_VARIABLE BREW_PREFIX 188 | OUTPUT_STRIP_TRAILING_WHITESPACE 189 | ) 190 | 191 | if (BREW_LIB EQUAL 0 AND EXISTS "${BREW_PREFIX}") 192 | install(CODE 193 | " 194 | include(BundleUtilities) 195 | fixup_bundle(\"${CMAKE_INSTALL_PREFIX}/${PROJECT_NAME}/${PROJECT_NAME}.app\" \"\" \"${BREW_PREFIX}/lib\") 196 | 197 | # Fix Qt plugin references specifically for Apple Silicon 198 | if(\"${ARCH}\" STREQUAL \"arm64\") 199 | execute_process(COMMAND install_name_tool -change @rpath/QtGui.framework/Versions/A/QtGui @executable_path/../Frameworks/QtGui 200 | \"${CMAKE_INSTALL_PREFIX}/${PROJECT_NAME}/${PROJECT_NAME}.app/Contents/PlugIns/platforms/libqcocoa.dylib\") 201 | endif() 202 | 203 | # Re-sign with the same approach the project already uses 204 | execute_process(COMMAND codesign --remove-signature \"${CMAKE_INSTALL_PREFIX}/${PROJECT_NAME}/${PROJECT_NAME}.app\") 205 | execute_process(COMMAND find \"${CMAKE_INSTALL_PREFIX}/${PROJECT_NAME}/${PROJECT_NAME}.app/Contents/Frameworks\" -type f -exec codesign --force --sign - {} \\; 2>/dev/null || true) 206 | execute_process(COMMAND find \"${CMAKE_INSTALL_PREFIX}/${PROJECT_NAME}/${PROJECT_NAME}.app/Contents/PlugIns\" -type f -exec codesign --force --sign - {} \\; 2>/dev/null || true) 207 | execute_process(COMMAND codesign --force --deep --sign - \"${CMAKE_INSTALL_PREFIX}/${PROJECT_NAME}/${PROJECT_NAME}.app\") " 208 | ) 209 | endif() 210 | endif(APPLE AND NOT DEFINED ENV{GITHUB_ACTIONS}) -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2012 Christian Kothe 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /LabRecorder.cfg: -------------------------------------------------------------------------------- 1 | ; === Storage Location === 2 | ; the default file name can be something like C:\\Recordings\\untitled.xdf, but can also contain 3 | ; placeholders. Two placeholder formats are supported: Legacy and BIDS. 4 | ; 5 | ; For BIDS format, only a StudyRoot can be provided. 6 | ; If the full StorageLocation or PathTemplate is provided 7 | ; then it will be assumed that a non-BIDS format is being used. 8 | ; 9 | ; Legacy may contain a running number (incremented per experiment session) called %n, and a 10 | ; placeholder for a "block" label %b (if the config script provides a list of block names that 11 | ; consitute a session. 12 | ; 13 | ; For BIDS, the path may contain %p for participant label, %s for session label, 14 | ; %b for task label (same as block in Legacy), %a for name of acquisition parameter set, and %r index. 15 | ; The BIDS syntax is: path/to/StudyRoot/sub-%p/ses-%s/eeg/sub-%p_ses-%s_task-%b[_acq-%a]_run-%r_eeg.xdf 16 | ; 17 | ; If neither StorageLocation or StudyRoot is provided then the default root is QStandardPaths::DocumentsLocation/CurrentStudy/ 18 | ; 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. 19 | 20 | ; StorageLocation=C:/Recordings/CurrentStudy/sub-%p/ses-%s/behav/sub-%p_ses-%s_task-%b_acq-%a_run-%r_eeg.xdf 21 | ; StudyRoot=C:/Recordings/CurrentStudy/ 22 | ; PathTemplate=exp%n/block_%b.xdf 23 | 24 | ; === Block Names === 25 | ; This is optionally a list of blocks that make up a recording session. The blocks are displayed in 26 | ; a list box where the experiment can select a block before pressing record. If used, the blocks 27 | ; may serve as a reminder of where they are in the experiment, but more practically, can be 28 | ; used to determine the file name of the recording. Power users can define scriptable actions 29 | ; associated with selecting a block or pressing Start/Stop for a given block (e.g., for remote 30 | ; control). 31 | ; The syntax is as in: SessionBlocks = "Training","PreBaseline","MainSection","PostBaseline" 32 | ; SessionBlocks="T1", "T2", "T3" 33 | 34 | ; === BIDS modalities === 35 | ; This is optionally a list of BIDS modalities to be added to the default list defined in the source code. 36 | ; Defaults are: "eeg", "ieeg", "meg", "beh" 37 | ; The selected value will replace the %m placeholder in the filename template. 38 | ; physio is an example from: 39 | ; https://bids-specification.readthedocs.io/en/stable/04-modality-specific-files/06-physiological-and-other-continuous-recordings.html 40 | ; BidsModalities="eeg", "ieeg", "meg", "beh", "physio" 41 | 42 | ; === Required Streams === 43 | ; This is optionally a list of streams that are required for the recording; 44 | ; a warning is issued if one of the streams is not present when the record button is pressed 45 | ; The syntax is as in: RequiredStreams = "BioSemi (MyHostname)","PhaseSpace (MyHostname)","Eyelink (AnotherHostname)" 46 | ; where the format is identical to what the LabRecorder displays in the "Record from streams" list. 47 | ; There must not be any spaces within the parentheses. 48 | ; RequiredStreams="RequiredExample (PC-HOSTNAME)" 49 | 50 | ; === Online Sync === 51 | ; A list of sync settings. Each setting follows the following format: "SrcStreamName (SrcHostName) post_FLAG" 52 | ; where post_FLAG is described here: https://github.com/sccn/liblsl/blob/master/src/common.h#L77-L89 53 | ; Note that it is not necessary to set any flags for correct storage because all of the post-processing synchronization 54 | ; can be achieved during file import using either the Matlab or Python importers. 55 | ; Examples: 56 | ; OnlineSync="ActiChamp-0 (DM-Laptop) post_ALL", "LiveAmpSN-054211-0237 (User-PC) post_ALL" 57 | ; OnlineSync=["ActiChamp-0 (User-PC)" post_ALL] 58 | ; OnlineSync="SendDataC (Testpc) post_ALL", "Test (Testpc) post_clocksync" 59 | 60 | ; === Remote Control Socket === 61 | ; A list of options containing 2 possible values: 62 | ; RCSEnabled to control the state of the remote control stream on launch : 1/0; default 1 63 | ; RCSPort to set the port number for the socket; default 22345 64 | RCSEnabled=1 65 | RCSPort=22345 66 | 67 | ; === Auto Start Recording === 68 | ; Set AutoStart to 1 to automatically start recording as soon as the config file has concluded parsing. 69 | ; AutoStart=1 -------------------------------------------------------------------------------- /LabRecorder_BIDS.cfg: -------------------------------------------------------------------------------- 1 | ; BIDS config, provide only the StudyRoot 2 | StudyRoot=C:/Recordings/CurrentStudy 3 | -------------------------------------------------------------------------------- /LabRecorder_Legacy.cfg: -------------------------------------------------------------------------------- 1 | ; Legacy config, the StudyRoot and TemplatePath are extracted automatically 2 | ; from the StorageLocation 3 | StorageLocation = "C:/Recordings/CurrentStudy/expi_%n/blockvar_%b.xdf" 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Overview 2 | 3 | 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. 4 | 5 | # File Format 6 | 7 | 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. 8 | 9 | # Getting LabRecorder 10 | 11 | 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. 12 | 13 | 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). 14 | 15 | ## Dependencies 16 | 17 | For LabRecorder to work on your system, you might need to first install some dependencies, specifically liblsl and optionally Qt. 18 | 19 | ### Windows 20 | 21 | 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). 22 | 23 | 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. 24 | 25 | ### MacOS 26 | 27 | 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/): 28 | * Install homebrew: `/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"` 29 | * `brew install labstreaminglayer/tap/lsl` 30 | * `brew install qt` 31 | 32 | You can then install LabRecorder directly from homebrew: `brew install labrecorder` 33 | 34 | Run it with `open /usr/local/opt/labrecorder/LabRecorder/LabRecorder.app` 35 | 36 | ### Linux Ubuntu 37 | 38 | The Ubuntu releases do not typically ship with their dependencies, so you must download and install those: 39 | * Download, extract, and install the latest [liblsl-{version}-{target}_amd64.deb from its release page](https://github.com/sccn/liblsl/releases) 40 | * We hope to make this available via a package manager soon. 41 | * 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` 42 | * 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` 43 | * You can install liblsl directly by double-clicking on the deb, or with `sudo dpkg -i {filename}.deb` or `sudo apt install {filename}.deb` 44 | * See the bottom of the [lsl build env docs](https://labstreaminglayer.readthedocs.io/dev/build_env.html). 45 | * For most cases, this will amount to installing Qt and its dependencies: 46 | * Ubuntu 18.xx or 20.xx: `sudo apt-get install build-essential qtbase5-dev libpugixml-dev` 47 | * Ubuntu >= 22.04: `sudo apt-get install qt6-base-dev freeglut3-dev` 48 | 49 | # Usage 50 | 51 | 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). 52 | > 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). 53 | 54 | If you cannot see streams that are provided on another computer, read the section Network Troubleshooting on the NetworkConnectivity page. 55 | 56 | You can select which streams you want to record from and which not by checking the checkboxes next to them. 57 | > ![labrecorder-default.png](doc/labrecorder-default.png) 58 | 59 | 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. 60 | 61 | 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. 62 | 63 | The Block/Task field can be overwriten or selected among a list of items found in the configuration file. 64 | 65 | 66 | 67 | 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). 68 | 69 | 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. 70 | 71 | ## Preparing a Full Study 72 | 73 | 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. 74 | 75 | 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. 76 | 77 | 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. 78 | 79 | 80 | 81 | 83 | 84 | ## Remote Control 85 | 86 | If you check the box to EnableRCS then LabRecorder exposes some rudimentary controls via TCP socket. 87 | 88 | Currently supported commands include: 89 | * `select all` 90 | * `select none` 91 | * `start` 92 | * `stop` 93 | * `update` 94 | * `filename ...` 95 | 96 | `filename` is followed by a series of space-delimited options enclosed in curly braces. e.g. {root:C:\root_data_dir} 97 | * `root` - Sets the root data directory. 98 | * `template` - sets the File Name / Template. Will unselect BIDS option. May contain wildcards. 99 | * `task` - will replace %b in template 100 | * `run` - will replace %n in template (not working?) 101 | * `participant` - will replace %p in template 102 | * `session` - will replace %s in template 103 | * `acquisition` - will replace %a in template 104 | * `modality` - will replace %m in template. suggested values: eeg, ieeg, meg, beh 105 | 106 | 107 | For example, in Python: 108 | 109 | ```python 110 | import socket 111 | s = socket.create_connection(("localhost", 22345)) 112 | s.sendall(b"select all\n") 113 | s.sendall(b"filename {root:C:\\Data\\} {template:exp%n\\%p_block_%b.xdf} {run:2} {participant:P003} {task:MemoryGuided}\n") 114 | s.sendall(b"start\n") 115 | ``` 116 | 117 | ```Matlab 118 | lr = tcpip('localhost', 22345); 119 | fopen(lr) 120 | fprintf(lr, 'select all'); 121 | fprintf(lr, ['filename {root:C:\Data\} '... 122 | '{task:MemoryGuided} ' ... 123 | '{template:s_%p_%n.xdf ' ... 124 | '{modality:ieeg}']); 125 | fprintf(lr, 'start'); 126 | ``` 127 | 128 | ## Misc Features 129 | 130 | The LabRecorder has some useful features that can add robustness if things go wrong during the experiment: 131 | 132 | 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. 133 | 134 | 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. 135 | 136 | 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). 137 | 138 | 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. 139 | 140 | # Build Instructions 141 | 142 | Please follow the general [LSL App build instructions](https://labstreaminglayer.readthedocs.io/dev/app_build.html). 143 | -------------------------------------------------------------------------------- /cmake/MacOSXBundleInfo.plist.in: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | English 7 | CFBundleExecutable 8 | ${MACOSX_BUNDLE_EXECUTABLE_NAME} 9 | CFBundleGetInfoString 10 | ${MACOSX_BUNDLE_INFO_STRING} 11 | CFBundleIconFile 12 | ${MACOSX_BUNDLE_ICON_FILE} 13 | CFBundleIdentifier 14 | ${MACOSX_BUNDLE_GUI_IDENTIFIER} 15 | CFBundleInfoDictionaryVersion 16 | 6.0 17 | CFBundleLongVersionString 18 | ${MACOSX_BUNDLE_LONG_VERSION_STRING} 19 | CFBundleName 20 | ${MACOSX_BUNDLE_BUNDLE_NAME} 21 | CFBundlePackageType 22 | APPL 23 | CFBundleShortVersionString 24 | ${MACOSX_BUNDLE_SHORT_VERSION_STRING} 25 | CFBundleSignature 26 | ???? 27 | CFBundleVersion 28 | ${MACOSX_BUNDLE_BUNDLE_VERSION} 29 | CSResourcesFileMapped 30 | 31 | LSRequiresCarbon 32 | 33 | NSHumanReadableCopyright 34 | ${MACOSX_BUNDLE_COPYRIGHT} 35 | NSHighResolutionCapable 36 | 37 | NSPrincipalClass 38 | NSApplication 39 | 40 | 41 | -------------------------------------------------------------------------------- /doc/controls.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/labstreaminglayer/App-LabRecorder/684df663db8fec3a7c39774949ca0924fb5a071d/doc/controls.png -------------------------------------------------------------------------------- /doc/labrecorder-default.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/labstreaminglayer/App-LabRecorder/684df663db8fec3a7c39774949ca0924fb5a071d/doc/labrecorder-default.png -------------------------------------------------------------------------------- /doc/labrecorder-running.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/labstreaminglayer/App-LabRecorder/684df663db8fec3a7c39774949ca0924fb5a071d/doc/labrecorder-running.png -------------------------------------------------------------------------------- /doc/labrecorder-study.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/labstreaminglayer/App-LabRecorder/684df663db8fec3a7c39774949ca0924fb5a071d/doc/labrecorder-study.png -------------------------------------------------------------------------------- /doc/templates.md: -------------------------------------------------------------------------------- 1 | # Working with file name templates 2 | 3 | Most sites have specific formats for paths and file names or even switched to 4 | the [BIDS](#BIDS) standard. LabRecorder makes it easier to file and name your 5 | correctly by providing path and file name templates. 6 | 7 | All variables (starting with `%`) are replaced with the corresponding field's 8 | contents upon starting the recording. 9 | 10 | Lets assume your institution requires your file to be saved in 11 | `/mnt/server/studies/your_name///.xdf`. 12 | 13 | You can let LabRecorder create the files and folders with the following 14 | settings (configuration file keys are `highlighted`): 15 | 16 | - `StudyRoot`=`/mnt/server/studies/your_name` 17 | - File Name / Template: `%p/%s/%b.xdf` 18 | - `SessionBlocks`=`"Task 1", "Resting State", "Task 2"` 19 | 20 | Before recording, you only need to fill in the fields you're using. 21 | The paths in the File Name Template are created automatically. 22 | 23 | The final folder and file path are previewed in the `Saving to...` box (above 24 | `16` in the screenshot). 25 | 26 | ## BIDS 27 | 28 | [BIDS](https://bids.neuroimaging.io/) is a set of conventions for organizing 29 | your research data. 30 | Checking the `BIDS` checkbox (`10` in the screenshot) automatically generates 31 | the correct File Name 32 | Template depending on which variables you set in the fields below. 33 | 34 | ![LabRecorder Inputs](controls.png) 35 | -------------------------------------------------------------------------------- /src/clirecorder.cpp: -------------------------------------------------------------------------------- 1 | #include "recording.h" 2 | #include "xdfwriter.h" 3 | 4 | int main(int argc, char **argv) { 5 | if (argc < 3 || (argc == 2 && std::string(argv[1]) == "-h")) { 6 | std::cout << "Usage: " << argv[0] << " outputfile.xdf 'searchstr' ['searchstr2' ...]\n\n" 7 | << "searchstr can be anything accepted by lsl_resolve_bypred\n"; 8 | std::cout << "Keep in mind that your shell might remove quotes\n"; 9 | std::cout << "Examples:\n\t" << argv[0] << " foo.xdf 'type=\"EEG\"' "; 10 | std::cout << " 'host=\"LabPC1\" or host=\"LabPC2\"'\n\t"; 11 | std::cout << argv[0] << " foo.xdf'name=\"Tobii and type=\"Eyetracker\"'\n"; 12 | return 1; 13 | } 14 | 15 | std::vector infos = lsl::resolve_streams(), recordstreams; 16 | 17 | for (int i = 2; i < argc; ++i) { 18 | bool matched = false; 19 | for (const auto &info : infos) { 20 | if (info.matches_query(argv[i])) { 21 | std::cout << "Found " << info.name() << '@' << info.hostname(); 22 | std::cout << " matching '" << argv[i] << "'\n"; 23 | matched = true; 24 | recordstreams.emplace_back(info); 25 | } 26 | } 27 | if (!matched) { 28 | std::cout << '"' << argv[i] << "\" matched no stream!\n"; 29 | return 2; 30 | } 31 | } 32 | 33 | std::vector watchfor; 34 | std::map sync_options; 35 | std::cout << "Starting the recording, press Enter to quit" << std::endl; 36 | recording r(argv[1], recordstreams, watchfor, sync_options, true); 37 | std::cin.get(); 38 | return 0; 39 | } 40 | -------------------------------------------------------------------------------- /src/fptest.cpp: -------------------------------------------------------------------------------- 1 | #include 2 | 3 | int main() { 4 | const double sampling_rate = 500, sample_interval = 1.0 / sampling_rate, 5 | first_timestamp = 60 * 60 * 24 * 365 * 48; // e.g. a unix timestamp 6 | double last_timestamp = first_timestamp; 7 | int full = 0, deduced = 0; 8 | const int iterations = 100000; 9 | double timestamps[iterations]; 10 | for (int i = 0; i < iterations; ++i) timestamps[i] = first_timestamp + i / sampling_rate; 11 | for (int i = 0; i < iterations; ++i) { 12 | if (last_timestamp + sample_interval == timestamps[i]) 13 | deduced++; 14 | else 15 | full++; 16 | last_timestamp = timestamps[i]; 17 | } 18 | std::cout << "Deduced: " << deduced << "\nFully written: " << full << std::endl; 19 | return 0; 20 | } 21 | -------------------------------------------------------------------------------- /src/main.cpp: -------------------------------------------------------------------------------- 1 | #include "mainwindow.h" 2 | #include 3 | 4 | int main(int argc, char *argv[]) { 5 | 6 | // determine the startup config file... 7 | const char *config_file = nullptr; 8 | for (int k = 1; k < argc; k++) 9 | if (std::string(argv[k]) == "-c" || std::string(argv[k]) == "--config") 10 | config_file = argv[k + 1]; 11 | 12 | QApplication a(argc, argv); 13 | MainWindow w(nullptr, config_file); 14 | w.show(); 15 | return a.exec(); 16 | } 17 | -------------------------------------------------------------------------------- /src/mainwindow.cpp: -------------------------------------------------------------------------------- 1 | #include "mainwindow.h" 2 | #include "ui_mainwindow.h" 3 | 4 | #include 5 | #include 6 | #include 7 | #include 8 | #include 9 | #include 10 | #if QT_VERSION_MAJOR < 6 11 | #include 12 | #else 13 | #include 14 | using QRegExp = QRegularExpression; 15 | #endif 16 | 17 | #include 18 | #include 19 | 20 | // recording class 21 | #include "recording.h" 22 | #include "tcpinterface.h" 23 | 24 | const QStringList bids_modalities_default = QStringList({"eeg", "ieeg", "meg", "beh"}); 25 | 26 | MainWindow::MainWindow(QWidget *parent, const char *config_file) 27 | : QMainWindow(parent), ui(new Ui::MainWindow) { 28 | ui->setupUi(this); 29 | connect(ui->actionLoad_Configuration, &QAction::triggered, this, [this]() { 30 | load_config(QFileDialog::getOpenFileName( 31 | this, "Load Configuration File", "", "Configuration Files (*.cfg)")); 32 | }); 33 | connect(ui->actionSave_Configuration, &QAction::triggered, this, [this]() { 34 | save_config(QFileDialog::getSaveFileName( 35 | this, "Save Configuration File", "", "Configuration Files (*.cfg)")); 36 | }); 37 | connect(ui->actionQuit, &QAction::triggered, this, &MainWindow::close); 38 | 39 | // Signals for stream finding/selecting/starting/stopping 40 | connect(ui->refreshButton, &QPushButton::clicked, this, &MainWindow::refreshStreams); 41 | connect(ui->selectAllButton, &QPushButton::clicked, this, &MainWindow::selectAllStreams); 42 | connect(ui->selectNoneButton, &QPushButton::clicked, this, &MainWindow::selectNoStreams); 43 | connect(ui->startButton, &QPushButton::clicked, this, &MainWindow::startRecording); 44 | connect(ui->stopButton, &QPushButton::clicked, this, &MainWindow::stopRecording); 45 | connect(ui->actionAbout, &QAction::triggered, this, [this]() { 46 | QString infostr = QStringLiteral("LSL library version: ") + 47 | QString::number(lsl::library_version()) + 48 | "\nLSL library info:" + lsl::library_info(); 49 | QMessageBox::about(this, "About this app", infostr); 50 | }); 51 | 52 | // Signals for Remote Control Socket 53 | connect(ui->rcsCheckBox, &QCheckBox::toggled, this, &MainWindow::rcsCheckBoxChanged); 54 | connect(ui->rcsport, QOverload::of(&QSpinBox::valueChanged), this, &MainWindow::rcsportValueChangedInt); 55 | 56 | // Wheenver lineEdit_template is changed, print the final result. 57 | connect( 58 | ui->lineEdit_template, &QLineEdit::textChanged, this, &MainWindow::printReplacedFilename); 59 | auto spinchanged = static_cast(&QSpinBox::valueChanged); 60 | connect(ui->spin_counter, spinchanged, this, &MainWindow::printReplacedFilename); 61 | 62 | // Signals for builder-related edits -> buildFilename 63 | connect(ui->rootBrowseButton, &QPushButton::clicked, this, [this]() { 64 | this->ui->rootEdit->setText(QDir::toNativeSeparators( 65 | QFileDialog::getExistingDirectory(this, "Study root folder..."))); 66 | this->buildFilename(); 67 | }); 68 | connect(ui->rootEdit, &QLineEdit::editingFinished, this, &MainWindow::buildFilename); 69 | connect( 70 | ui->lineEdit_participant, &QLineEdit::editingFinished, this, &MainWindow::buildFilename); 71 | connect(ui->lineEdit_session, &QLineEdit::editingFinished, this, &MainWindow::buildFilename); 72 | connect(ui->lineEdit_acq, &QLineEdit::editingFinished, this, &MainWindow::buildFilename); 73 | connect(ui->input_blocktask, &QComboBox::currentTextChanged, this, &MainWindow::buildFilename); 74 | connect(ui->input_modality, &QComboBox::currentTextChanged, this, &MainWindow::buildFilename); 75 | connect(ui->check_bids, &QCheckBox::toggled, this, [this](bool checked) { 76 | auto &box = *ui->lineEdit_template; 77 | box.setReadOnly(checked); 78 | if (checked) { 79 | legacyTemplate = box.text(); 80 | box.setText(QDir::toNativeSeparators( 81 | QStringLiteral("sub-%p/ses-%s/%m/sub-%p_ses-%s_task-%b[_acq-%a]_run-%r_%m.xdf"))); 82 | ui->label_counter->setText("Run (%r)"); 83 | } else { 84 | box.setText(QDir::toNativeSeparators(legacyTemplate)); 85 | ui->label_counter->setText("Exp num (%n)"); 86 | } 87 | }); 88 | 89 | timer = std::make_unique(this); 90 | connect(&*timer, &QTimer::timeout, this, &MainWindow::statusUpdate); 91 | timer->start(1000); 92 | 93 | QString cfgfilepath = find_config_file(config_file); 94 | load_config(cfgfilepath); 95 | } 96 | 97 | void MainWindow::statusUpdate() const { 98 | if (currentRecording) { 99 | auto elapsed = static_cast(lsl::local_clock() - startTime); 100 | QString recFilename = replaceFilename(QDir::cleanPath(ui->lineEdit_template->text())); 101 | auto fileinfo = QFileInfo(QDir::cleanPath(ui->rootEdit->text()) + '/' + recFilename); 102 | fileinfo.refresh(); 103 | auto size = fileinfo.size(); 104 | QString timeString = QStringLiteral("Recording to %1 (%2; %3kb)") 105 | .arg(QDir::toNativeSeparators(recFilename), 106 | QTime(0,0).addSecs(elapsed).toString("hh:mm:ss"), 107 | QString::number(size / 1000)); 108 | statusBar()->showMessage(timeString); 109 | } 110 | } 111 | 112 | void MainWindow::closeEvent(QCloseEvent *ev) { 113 | if (currentRecording) ev->ignore(); 114 | } 115 | 116 | void MainWindow::blockSelected(const QString &block) { 117 | if (currentRecording) 118 | QMessageBox::information(this, "Still recording", 119 | "Please stop recording before switching blocks.", QMessageBox::Ok); 120 | else { 121 | printReplacedFilename(); 122 | // scripted action code here... 123 | } 124 | } 125 | 126 | void MainWindow::load_config(QString filename) { 127 | qInfo() << "loading config file " << QDir::toNativeSeparators(filename); 128 | bool auto_start = false; 129 | try { 130 | QSettings pt(QDir::cleanPath(filename), QSettings::Format::IniFormat); 131 | 132 | // ---------------------------- 133 | // required streams 134 | // ---------------------------- 135 | auto required = pt.value("RequiredStreams").toStringList(); 136 | #if QT_VERSION >= QT_VERSION_CHECK(5,14,0) 137 | missingStreams = QSet(required.begin(), required.end()); 138 | #else 139 | missingStreams = required.toSet(); 140 | #endif 141 | 142 | // ---------------------------- 143 | // online sync streams 144 | // ---------------------------- 145 | QStringList onlineSyncStreams = pt.value("OnlineSync", QStringList()).toStringList(); 146 | for (QString &oss : onlineSyncStreams) { 147 | #if QT_VERSION >= QT_VERSION_CHECK(5,14,0) 148 | auto skipEmpty = Qt::SkipEmptyParts; 149 | #else 150 | auto skipEmpty = QString::SkipEmptyParts; 151 | #endif 152 | 153 | QStringList words = oss.split(' ', skipEmpty); 154 | // The first two words ("StreamName (PC)") are the stream identifier 155 | if (words.length() < 2) { 156 | qInfo() << "Invalid sync stream config: " << oss; 157 | continue; 158 | } 159 | QString key = words.takeFirst() + ' ' + words.takeFirst(); 160 | 161 | int val = 0; 162 | for (const auto &word : std::as_const(words)) { 163 | if (word == "post_clocksync") { val |= lsl::post_clocksync; } 164 | if (word == "post_dejitter") { val |= lsl::post_dejitter; } 165 | if (word == "post_monotonize") { val |= lsl::post_monotonize; } 166 | if (word == "post_threadsafe") { val |= lsl::post_threadsafe; } 167 | if (word == "post_ALL") { val = lsl::post_ALL; } 168 | } 169 | syncOptionsByStreamName[key.toStdString()] = val; 170 | qInfo() << "stream sync options: " << key << ": " << val; 171 | } 172 | 173 | // ---------------------------- 174 | // Block/Task Names 175 | // ---------------------------- 176 | QStringList taskNames; 177 | if (pt.contains("SessionBlocks")) { taskNames = pt.value("SessionBlocks").toStringList(); } 178 | ui->input_blocktask->clear(); 179 | ui->input_blocktask->insertItems(0, taskNames); 180 | 181 | // StorageLocation 182 | QString studyRoot; 183 | legacyTemplate.clear(); 184 | 185 | if (pt.contains("StorageLocation")) { 186 | if (pt.contains("StudyRoot")) 187 | throw std::runtime_error("StorageLocation cannot be used if StudyRoot is also specified."); 188 | if (pt.contains("PathTemplate")) 189 | throw std::runtime_error("StorageLocation cannot be used if PathTemplate is also specified."); 190 | 191 | QString str_path = pt.value("StorageLocation").toString(); 192 | QString path_root; 193 | auto index = str_path.indexOf('%'); 194 | if (index != -1) { 195 | // When a % is encountered, the studyroot gets set to the 196 | // longest path before the placeholder, e.g. 197 | // foo/bar/baz%a/untitled.xdf gets split into 198 | // foo/bar and baz%a/untitled.xdf 199 | path_root = str_path.left(index); 200 | } else { 201 | // Otherwise, it's split into folder and constant filename 202 | path_root = str_path; 203 | } 204 | studyRoot = QFileInfo(path_root).absolutePath(); 205 | legacyTemplate = str_path.remove(0, studyRoot.length() + 1); 206 | // absolute path, nothing to be done 207 | // studyRoot = QFileInfo(path_root).absolutePath(); 208 | } 209 | // StudyRoot 210 | if (pt.contains("StudyRoot")) { studyRoot = pt.value("StudyRoot").toString(); } 211 | if (pt.contains("PathTemplate")) { legacyTemplate = pt.value("PathTemplate").toString(); } 212 | 213 | if (studyRoot.isEmpty()) 214 | studyRoot = QStandardPaths::writableLocation(QStandardPaths::DocumentsLocation) + 215 | QDir::separator() + "CurrentStudy"; 216 | ui->rootEdit->setText(QDir::toNativeSeparators(studyRoot)); 217 | 218 | if (legacyTemplate.isEmpty()) { 219 | ui->check_bids->setChecked(true); 220 | // Use default, exp%n/%b.xdf , only to be used if BIDS gets unchecked. 221 | legacyTemplate = "exp%n/block_%b.xdf"; 222 | } else { 223 | ui->check_bids->setChecked(false); 224 | ui->lineEdit_template->setText(QDir::toNativeSeparators(legacyTemplate)); 225 | } 226 | 227 | // Append BIDS modalities to the default list. 228 | ui->input_modality->insertItems( 229 | ui->input_modality->count(), pt.value("BidsModalities", bids_modalities_default).toStringList()); 230 | ui->input_modality->setCurrentIndex(0); 231 | 232 | buildFilename(); 233 | 234 | // Remote Control Socket options 235 | if (pt.contains("RCSPort")) { 236 | int rcs_port = pt.value("RCSPort").toInt(); 237 | ui->rcsport->setValue(rcs_port); 238 | // In case it's already running (how?), stop the RCS listener. 239 | ui->rcsCheckBox->setChecked(false); 240 | } 241 | 242 | if (pt.contains("RCSEnabled")) { 243 | bool b_enable_rcs = pt.value("RCSEnabled").toBool(); 244 | ui->rcsCheckBox->setChecked(b_enable_rcs); 245 | } 246 | 247 | // Check the wild-card-replaced filename to see if it exists already. 248 | // If it does then increment the exp number. 249 | // We only do this on settings-load because manual spin changes might indicate purposeful 250 | // overwriting. 251 | QString recFilename = QDir::cleanPath(ui->lineEdit_template->text()); 252 | // Spin Number 253 | if (recFilename.contains(counterPlaceholder())) { 254 | for (int i = 1; i < 1001; i++) { 255 | ui->spin_counter->setValue(i); 256 | if (!QFileInfo::exists(replaceFilename(recFilename))) break; 257 | } 258 | } 259 | 260 | if (pt.contains("AutoStart")) { 261 | auto_start = pt.value("AutoStart").toBool(); 262 | } 263 | 264 | } catch (std::exception &e) { qWarning() << "Problem parsing config file: " << e.what(); } 265 | // std::cout << "refreshing streams ..." <rootEdit->text())); 274 | if (!ui->check_bids->isChecked()) 275 | settings.setValue("PathTemplate", QDir::cleanPath(ui->lineEdit_template->text())); 276 | // Build QStringList from missingStreams and knownStreams that are missing. 277 | QStringList requiredStreams = missingStreams.values(); 278 | for (auto &k : knownStreams) { 279 | if (k.checked) { requiredStreams.append(k.listName()); } 280 | } 281 | qInfo() << missingStreams; 282 | settings.setValue("RequiredStreams", requiredStreams); 283 | // Stub. 284 | } 285 | 286 | QString info_to_listName(const lsl::stream_info& info) { 287 | return QString::fromStdString(info.name() + " (" + info.hostname() + ")"); 288 | } 289 | 290 | /** 291 | * @brief MainWindow::refreshStreams Find streams, generate a list of missing streams 292 | * and fill the UI streamlist. 293 | * @return A vector of found stream_infos 294 | */ 295 | std::vector MainWindow::refreshStreams() { 296 | const std::vector resolvedStreams = lsl::resolve_streams(1.0); 297 | 298 | // For each item in resolvedStreams, ignore if already in knownStreams, otherwise add to knownStreams. 299 | // if in missingStreams then also mark it as required (--> checked by default) and remove from missingStreams. 300 | for (const auto& s : resolvedStreams) { 301 | bool known = false; 302 | for (auto &k : knownStreams) { 303 | known |= s.name() == k.name && s.type() == k.type && s.source_id() == k.id; 304 | } 305 | if (!known) { 306 | bool found = missingStreams.contains(info_to_listName(s)); 307 | knownStreams << StreamItem(s.name(), s.type(), s.source_id(), s.hostname(), found); 308 | if (found) { missingStreams.remove(info_to_listName(s)); } 309 | } 310 | } 311 | // For each item in knownStreams, update its checked status from GUI. (only works for streams found on a previous refresh) 312 | // 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. 313 | for (auto &k : knownStreams) { 314 | QList foundItems = ui->streamList->findItems(k.listName(), Qt::MatchCaseSensitive); 315 | if (foundItems.count() > 0) { 316 | bool checked = false; 317 | for (auto &fi : foundItems) { checked |= fi->checkState() == Qt::Checked; } 318 | k.checked = checked; 319 | } 320 | } 321 | // For each item in knownStreams; if it is not resolved then drop it. If it was checked then add back to missingStreams. 322 | int k_ind = 0; 323 | while (k_ind < knownStreams.count()) { 324 | StreamItem k = knownStreams.at(k_ind); 325 | bool resolved = false; 326 | size_t r_ind = 0; 327 | while (!resolved && r_ind < resolvedStreams.size()) { 328 | const lsl::stream_info r = resolvedStreams[r_ind]; 329 | resolved |= (r.name() == k.name) && (r.type() == k.type) && (r.source_id() == k.id); 330 | r_ind++; 331 | } 332 | if (!resolved) { 333 | if (k.checked) { missingStreams += k.listName(); } 334 | knownStreams.removeAt(k_ind); 335 | } else { 336 | k_ind++; 337 | } 338 | } 339 | // Clear the streamList 340 | // Add missing items first. 341 | // Then add knownStreams (only in list if resolved). 342 | const QBrush good_brush(QColor(0, 128, 0)), bad_brush(QColor(255, 0, 0)); 343 | ui->streamList->clear(); 344 | for (auto& m : std::as_const(missingStreams)) { 345 | auto *item = new QListWidgetItem(m, ui->streamList); 346 | item->setCheckState(Qt::Checked); 347 | item->setForeground(bad_brush); 348 | ui->streamList->addItem(item); 349 | } 350 | for (auto& k : knownStreams) { 351 | auto *item = new QListWidgetItem(k.listName(), ui->streamList); 352 | item->setCheckState(k.checked ? Qt::Checked : Qt::Unchecked); 353 | item->setForeground(good_brush); 354 | ui->streamList->addItem(item); 355 | } 356 | 357 | // return a std::vector of streams of checked and not missing streams. 358 | std::vector requestedAndAvailableStreams; 359 | for (const auto &r : resolvedStreams) { 360 | for (auto &k : knownStreams) { 361 | if ((r.name() == k.name) && (r.type() == k.type) && (r.source_id() == k.id)) { 362 | if (k.checked) { requestedAndAvailableStreams.push_back(r); } 363 | break; 364 | } 365 | } 366 | } 367 | return requestedAndAvailableStreams; 368 | } 369 | 370 | void MainWindow::startRecording() { 371 | if (!currentRecording) { 372 | 373 | // automatically refresh streams 374 | const std::vector requestedAndAvailableStreams = refreshStreams(); 375 | 376 | if (!hideWarnings) { 377 | // if a checked stream is now missing 378 | if (!missingStreams.isEmpty()) { 379 | // are you sure? 380 | QMessageBox msgBox(QMessageBox::Warning, "Stream not found", 381 | "At least one of the streams that you checked seems to be offline", 382 | QMessageBox::Yes | QMessageBox::No, this); 383 | msgBox.setInformativeText("Do you want to start recording anyway?"); 384 | msgBox.setDefaultButton(QMessageBox::No); 385 | if (msgBox.exec() != QMessageBox::Yes) return; 386 | } 387 | 388 | if (requestedAndAvailableStreams.size() == 0) { 389 | QMessageBox msgBox(QMessageBox::Warning, "No available streams selected", 390 | "You have selected no streams", QMessageBox::Yes | QMessageBox::No, this); 391 | msgBox.setInformativeText("Do you want to start recording anyway?"); 392 | msgBox.setDefaultButton(QMessageBox::No); 393 | if (msgBox.exec() != QMessageBox::Yes) return; 394 | } 395 | } 396 | 397 | // don't hide critical errors. 398 | QString recFilename = replaceFilename(QDir::cleanPath(ui->lineEdit_template->text())); 399 | if (recFilename.isEmpty()) { 400 | QMessageBox::critical(this, "Filename empty", "Can not record without a file name"); 401 | return; 402 | } 403 | recFilename.prepend(QDir::cleanPath(ui->rootEdit->text()) + '/'); 404 | 405 | QFileInfo recFileInfo(recFilename); 406 | if (recFileInfo.exists()) { 407 | if (recFileInfo.isDir()) { 408 | QMessageBox::warning( 409 | this, "Error", "Recording path already exists and is a directory"); 410 | return; 411 | } 412 | QString rename_to = recFileInfo.absolutePath() + '/' + recFileInfo.baseName() + 413 | "_old%1." + recFileInfo.suffix(); 414 | // search for highest _oldN 415 | int i = 1; 416 | while (QFileInfo::exists(rename_to.arg(i))) i++; 417 | QString newname = rename_to.arg(i); 418 | if (!QFile::rename(recFileInfo.absoluteFilePath(), newname)) { 419 | QMessageBox::warning(this, "Permissions issue", 420 | "Cannot rename the file " + recFilename + " to " + newname); 421 | return; 422 | } 423 | qInfo() << "Moved existing file to " << newname; 424 | recFileInfo.refresh(); 425 | } 426 | 427 | // regardless, we need to create the directory if it doesn't exist 428 | if (!recFileInfo.dir().mkpath(".")) { 429 | QMessageBox::warning(this, "Permissions issue", 430 | "Can not create the directory " + recFileInfo.dir().path() + 431 | ". Please check your permissions."); 432 | return; 433 | } 434 | 435 | std::vector watchfor; 436 | for (const QString &missing : std::as_const(missingStreams)) { 437 | std::string query; 438 | // Convert missing to query expected by lsl::resolve_stream 439 | // name='BioSemi' and hostname=AASDFSDF 440 | QRegularExpression re("(.+)\\s+\\((\\S+)\\)"); 441 | QRegularExpressionMatch match = re.match(missing); 442 | if (match.hasMatch()) 443 | { 444 | QString name = match.captured(1); 445 | QString host = match.captured(2); 446 | query = "name='" + match.captured(1).toStdString() + "'"; 447 | if (host.size() > 1) { 448 | query += " and hostname='" + host.toStdString() + "'"; 449 | } 450 | } else { 451 | // Regexp failed but we can try using the entire string as the stream name. 452 | query = "name='" + missing.toStdString() + "'"; 453 | } 454 | watchfor.push_back(query); 455 | } 456 | qInfo() << "Missing: " << missingStreams; 457 | 458 | currentRecording = std::make_unique(recFilename.toStdString(), 459 | requestedAndAvailableStreams, watchfor, syncOptionsByStreamName, true); 460 | ui->stopButton->setEnabled(true); 461 | ui->startButton->setEnabled(false); 462 | startTime = (int)lsl::local_clock(); 463 | 464 | } else if (!hideWarnings) { 465 | QMessageBox::information( 466 | this, "Already recording", "The recording is already running", QMessageBox::Ok); 467 | } 468 | } 469 | 470 | void MainWindow::stopRecording() { 471 | 472 | if (currentRecording) { 473 | try { 474 | currentRecording = nullptr; 475 | } catch (std::exception &e) { qWarning() << "exception on stop: " << e.what(); } 476 | ui->startButton->setEnabled(true); 477 | ui->stopButton->setEnabled(false); 478 | statusBar()->showMessage("Stopped"); 479 | } else if (!hideWarnings) { 480 | QMessageBox::information( 481 | this, "Not recording", "There is not ongoing recording", QMessageBox::Ok); 482 | } 483 | } 484 | 485 | void MainWindow::selectAllStreams() { 486 | for (int i = 0; i < ui->streamList->count(); i++) { 487 | QListWidgetItem *item = ui->streamList->item(i); 488 | item->setCheckState(Qt::Checked); 489 | } 490 | } 491 | 492 | void MainWindow::selectNoStreams() { 493 | for (int i = 0; i < ui->streamList->count(); i++) { 494 | QListWidgetItem *item = ui->streamList->item(i); 495 | item->setCheckState(Qt::Unchecked); 496 | } 497 | } 498 | 499 | void MainWindow::buildBidsTemplate() { 500 | // path/to/CurrentStudy/sub-%p/ses-%s/eeg/sub-%p_ses-%s_task-%b[_acq-%a]_run-%r_eeg.xdf 501 | 502 | // Make sure the BIDS required fields are full. 503 | if (ui->lineEdit_participant->text().isEmpty()) { ui->lineEdit_participant->setText("P001"); } 504 | if (ui->lineEdit_session->text().isEmpty()) { ui->lineEdit_session->setText("S001"); } 505 | if (ui->input_blocktask->currentText().isEmpty()) { 506 | ui->input_blocktask->setCurrentText("Default"); 507 | } 508 | // BIDS modality selection 509 | if (ui->input_modality->currentText().isEmpty()) { 510 | ui->input_modality->insertItems(0, bids_modalities_default); 511 | ui->input_modality->setCurrentIndex(0); 512 | } 513 | 514 | // Folder hierarchy 515 | QStringList fileparts{"sub-%p", "ses-%s", "%m"}; 516 | 517 | // filename 518 | QString fname = "sub-%p_ses-%s_task-%b"; 519 | if (!ui->lineEdit_acq->text().isEmpty()) { fname.append("_acq-%a"); } 520 | fname.append("_run-%r_%m.xdf"); 521 | fileparts << fname; 522 | ui->lineEdit_template->setText(QDir::toNativeSeparators(fileparts.join('/'))); 523 | } 524 | 525 | void MainWindow::buildFilename() { 526 | // This function is only called when a widget within Location Builder is activated. 527 | 528 | // Build the file location in parts, starting with the root folder. 529 | if (ui->check_bids->isChecked()) { buildBidsTemplate(); } 530 | QString tpl = QDir::cleanPath(ui->lineEdit_template->text()); 531 | 532 | // Auto-increment Spin/Run Number if necessary. 533 | if (tpl.contains(counterPlaceholder())) { 534 | for (int i = 1; i < 1001; i++) { 535 | ui->spin_counter->setValue(i); 536 | if (!QFileInfo::exists(replaceFilename(tpl))) break; 537 | } 538 | } 539 | // Sometimes lineEdit_template doesn't change so printReplacedFilename isn't triggered. 540 | // So trigger manually. 541 | printReplacedFilename(); 542 | } 543 | 544 | QString MainWindow::replaceFilename(QString fullfile) const { 545 | // Replace wildcards. 546 | // There are two different wildcard formats: legacy, BIDS 547 | 548 | // Legacy takes the form path/to/study/exp%n/%b.xdf 549 | // Where %n will be replaced by the contents of the spin_counter widget 550 | // and %b will be replaced by the contents of the blockList widget. 551 | fullfile.replace("%b", ui->input_blocktask->currentText()); 552 | 553 | // BIDS 554 | // See https://docs.google.com/document/d/1ArMZ9Y_quTKXC-jNXZksnedK2VHHoKP3HCeO5HPcgLE/ 555 | // path/to/study/sub-/ses-/eeg/sub-_ses-_task-[_acq-]_run-_eeg.xdf 556 | // path/to/study/sub-%p/ses-%s/eeg/sub-%p_ses-%s_task-%b[_acq-%a]_run-%r_eeg.xdf 557 | // %b already replaced above. 558 | fullfile.replace("%p", ui->lineEdit_participant->text()); 559 | fullfile.replace("%s", ui->lineEdit_session->text()); 560 | fullfile.replace("%a", ui->lineEdit_acq->text()); 561 | fullfile.replace("%m", ui->input_modality->currentText()); 562 | 563 | // Replace either %r or %n with the counter 564 | QString run = QString("%1").arg(ui->spin_counter->value(), 3, 10, QChar('0')); 565 | fullfile.replace(counterPlaceholder(), run); 566 | 567 | return fullfile.trimmed(); 568 | } 569 | 570 | /** 571 | * Find a config file to load. This is (in descending order or preference): 572 | * - a file supplied on the command line 573 | * - [executablename].cfg in one the the following folders: 574 | * - the current working directory 575 | * - the default config folder, e.g. '~/Library/Preferences' on OS X 576 | * - the executable folder 577 | * @param filename Optional file name supplied e.g. as command line parameter 578 | * @return Path to a found config file 579 | */ 580 | QString MainWindow::find_config_file(const char *filename) { 581 | if (filename) { 582 | QString qfilename(filename); 583 | if (!QFileInfo::exists(qfilename)) 584 | QMessageBox(QMessageBox::Warning, "Config file not found", 585 | QStringLiteral("The file '%1' doesn't exist").arg(qfilename), QMessageBox::Ok, 586 | this); 587 | else 588 | return qfilename; 589 | } 590 | QFileInfo exeInfo(QCoreApplication::applicationFilePath()); 591 | QString defaultCfgFilename(exeInfo.completeBaseName() + ".cfg"); 592 | qInfo() << defaultCfgFilename; 593 | QStringList cfgpaths; 594 | cfgpaths << QDir::currentPath() 595 | << QStandardPaths::standardLocations(QStandardPaths::AppConfigLocation) 596 | << QStandardPaths::standardLocations(QStandardPaths::AppDataLocation) 597 | << exeInfo.path(); 598 | for (const auto &path : std::as_const(cfgpaths)) { 599 | QString cfgfilepath = path + QDir::separator() + defaultCfgFilename; 600 | qInfo() << cfgfilepath; 601 | if (QFileInfo::exists(cfgfilepath)) return cfgfilepath; 602 | } 603 | QMessageBox msgBox; 604 | msgBox.setWindowTitle("Config file not found"); 605 | msgBox.setText("Config file not found."); 606 | msgBox.setInformativeText("Continuing with default config."); 607 | msgBox.setStandardButtons(QMessageBox::Ok); 608 | msgBox.exec(); 609 | return ""; 610 | } 611 | 612 | QString MainWindow::counterPlaceholder() const { return ui->check_bids->isChecked() ? "%r" : "%n"; } 613 | 614 | void MainWindow::printReplacedFilename() { 615 | ui->locationLabel->setText( 616 | ui->rootEdit->text() + '\n' + replaceFilename(ui->lineEdit_template->text())); 617 | } 618 | 619 | MainWindow::~MainWindow() noexcept = default; 620 | 621 | void MainWindow::rcsCheckBoxChanged(bool checked) { enableRcs(checked); } 622 | 623 | void MainWindow::enableRcs(bool bEnable) { 624 | if (rcs) { 625 | if (!bEnable) { 626 | disconnect(rcs.get()); 627 | rcs = nullptr; 628 | } 629 | } else if (bEnable) { 630 | uint16_t port = ui->rcsport->value(); 631 | rcs = std::make_unique(port); 632 | // TODO: Add some method to RemoteControlSocket to report if its server is listening (i.e. was successful). 633 | connect(rcs.get(), &RemoteControlSocket::refresh_streams, this, &MainWindow::refreshStreams); 634 | connect(rcs.get(), &RemoteControlSocket::start, this, &MainWindow::rcsStartRecording); 635 | connect(rcs.get(), &RemoteControlSocket::stop, this, &MainWindow::rcsStopRecording); 636 | connect(rcs.get(), &RemoteControlSocket::filename, this, &MainWindow::rcsUpdateFilename); 637 | connect(rcs.get(), &RemoteControlSocket::select_all, this, &MainWindow::selectAllStreams); 638 | connect(rcs.get(), &RemoteControlSocket::select_none, this, &MainWindow::selectNoStreams); 639 | } 640 | bool oldState = ui->rcsCheckBox->blockSignals(true); 641 | ui->rcsCheckBox->setChecked(bEnable); 642 | ui->rcsCheckBox->blockSignals(oldState); 643 | } 644 | 645 | void MainWindow::rcsportValueChangedInt(int value) { 646 | if (rcs) { 647 | enableRcs(false); // Will also uncheck box. 648 | enableRcs(true); // Will also check box. 649 | } 650 | } 651 | 652 | void MainWindow::rcsStartRecording() { 653 | // since we want to avoid a pop-up window when streams are missing or unchecked, 654 | // we'll check all the streams and start recording 655 | hideWarnings = true; 656 | selectAllStreams(); 657 | startRecording(); 658 | } 659 | 660 | void MainWindow::rcsStopRecording() { 661 | hideWarnings = true; 662 | stopRecording(); 663 | } 664 | 665 | void MainWindow::rcsUpdateFilename(QString s) { 666 | // 667 | // format: "filename {option:value}{option:value} 668 | // Options are: 669 | // root: full path to Study root; 670 | // template: legacy filename template, left to default (bids) if unspecified; 671 | // task; run; participant; session; acquisition: base options 672 | // (BIDS) modality: from either the defaults eeg, ieeg, meg, beh or adding a new 673 | // potentially unsupported value. 674 | QRegularExpression re("{(?P