├── .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 | > 
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 | 
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