Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,13 @@ You are in the **runtime** repo. Read the system overview below first.
## This repo

- Build: `cmake -B build && cmake --build build`. Tests: `cd build && ctest --output-on-failure`
(`-L unit` / `-L component`). Format: `cmake --build build --target format` — CI fails on
unformatted code.
(`-L unit` / `-L component` / `-L cli`). Format: `cmake --build build --target format` — CI
fails on unformatted code.
- Layout: `include/` headers, `src/` impl, `src/parser/Parser.cpp` turns a `.neuroz`/`.pb` file
into a `Scene`, `scene/components/` component classes, `tests/{unit,component}_tests/`.
into a `Scene`, `scene/components/` component classes, `tests/{unit,component,cli}_tests/`.
- Logging: `LOG_*(logging::logger(logging::Subsystem::…), "…{}", …)` from
`<logging/Logging.hpp>` (Quill, README §3 "Logging"); never `std::cout` / `std::cerr`.
Nothing per frame or per sample. Level: `NEURONIDE_LOG_LEVEL`.
- New component: subclass `Component`, add a static creator, and
`REGISTER_COMPONENT(NeuronIDE::Component::k<Name>, Creator)` in its `.cpp`. The proto oneof
entry must exist first — which means a backend change too.
Expand All @@ -20,7 +23,8 @@ You are in the **runtime** repo. Read the system overview below first.
device `config.json` is written by the launcher, which starts the runtime with `[config.json] <experiment.neuroz>`
(#31). The output folder comes from `config.json` (`output.directory`), which the launcher
sets to each run's own folder; distinct exit codes are part of the same contract, because
the launcher shows them to the experimenter (both in runtime#37).
the launcher shows them to the experimenter (both in runtime#37). Diagnostics go to stderr
as `<time> <LEVEL> [<subsystem>] <message>` lines; tell the launcher team if that changes.
- `config.json` is also produced by the launcher (Python). Changing its keys or validation rules
is a cross-team change: bump `config_version` and tell the launcher team.

Expand Down
35 changes: 32 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,11 +41,12 @@ EEG samples and the stimulus markers must share a **common clock domain** so tha
| EEG acquisition | [LSL — Lab Streaming Layer](https://github.com/sccn/liblsl) (`liblsl`) |
| Rendering / windowing | SDL2 (+ SDL2_image), with vsync |
| Inter-thread queues | [moodycamel ConcurrentQueue](https://github.com/cameron314/concurrentqueue) (lock-free) |
| Logging | [Quill](https://github.com/odygrd/quill) (asynchronous), to stderr — see §3 |
| Python scripting (planned)| pybind11 (`ScriptComponent`) |
| Testing | GoogleTest + CTest |
| Tooling | clang-format, clang-tidy, gcovr (coverage) |

`liblsl`, `concurrentqueue`, `nlohmann/json`, and `googletest` are fetched
`liblsl`, `concurrentqueue`, `nlohmann/json`, `quill`, and `googletest` are fetched
automatically by CMake (`FetchContent`). SDL2 and Protobuf are expected to be
installed on the system.

Expand Down Expand Up @@ -159,6 +160,29 @@ struct Context { // passed to Component::update each frame
};
```

### Logging

Diagnostics go to **stderr** through [Quill](https://github.com/odygrd/quill). The
calling thread only copies the message into a queue; a backend thread formats and
writes it, so the render and acquisition threads never wait on I/O to log.

```
2026-10-13 16:43:15.852 WARNING [lslreader] Stream 'obci_eeg1' lost (...); re-resolving
```

- Log through `logging::logger(logging::Subsystem::…)` from `<logging/Logging.hpp>`
with Quill's `LOG_DEBUG` … `LOG_CRITICAL` macros and `{}` placeholders. The
subsystem is printed in brackets, so messages need no `"LSLReader:"`-style prefix.
Do not write to `std::cout` / `std::cerr`; the usage text is the one exception.
- `main()` holds a `logging::Session` for the whole run: it starts the backend and
writes out every queued message on exit. Tests get one from `tests/utils/TestMain.cpp`.
- The level is `info` unless `NEURONIDE_LOG_LEVEL` sets another (`debug`, `warning`,
`none`, …); an unknown value is reported and ignored.
- A crash (SIGSEGV, SIGABRT, …) still flushes the log. SIGINT and SIGTERM are left
to SDL, which turns them into `SDL_QUIT`, so a stopped run still closes its recording.
- Nothing is logged per frame or per sample. Either log an error or throw it, not
both — except on a worker thread, where the log records *when* it failed.

## 4. Clock synchronization (critical)

EEG analysis depends on EEG samples and stimulus markers being **comparable in
Expand Down Expand Up @@ -366,6 +390,7 @@ correct when `SDL_RenderPresent` waits for the refresh.
| Config `validate()` | Implemented | semantic rules on the config types themselves, independent of JSON (see §5) |
| `DataWriter` | Implemented | strategy-based; `CSVFormatStrategy` |
| `DataFormatStrategyFactory` | Implemented | `output.format` → format strategy; unknown formats rejected |
| Logging | Implemented | Quill, one logger per subsystem, stderr (see §3) |
| `Runtime` orchestration | Implemented | owns SDL session, parses both config files, gates the experiment on live EEG, records to a fresh file, drives the render loop, stops workers (see §6) |

The class diagram in older docs is partly aspirational; the table above reflects
Expand All @@ -389,11 +414,14 @@ Neuron-IDE-runtime/ # the C++ runtime (git repo)
│ ├── renderer/ # Renderer
│ ├── lslreader/ # LSLReader
│ ├── datawriter/ # DataWriter, IDataFormatStrategy, CSVFormatStrategy
│ ├── logging/ # subsystem loggers + logging Session (Quill)
│ └── Runtime.hpp
├── src/ # one CMake subdirectory (static lib) per module
└── tests/
├── unit_tests/ # CTest label: "unit"
└── component_tests/ # CTest label: "component"
├── component_tests/ # CTest label: "component"
├── cli_tests/ # CTest label: "cli" — runs the NeuronIDE binary
└── utils/ # shared helpers, test main()
```

Each `src/<module>/` builds a static library; `runtime_core` links them together
Expand All @@ -408,7 +436,7 @@ All commands are run from the `Neuron-IDE-runtime/` directory.
```bash
sudo apt update
sudo apt install cmake ninja-build clang-format clang-tidy-20 libsdl2-dev protobuf-compiler gcovr
# GTest, LSL, concurrentqueue and nlohmann/json are fetched automatically by CMake.
# GTest, LSL, concurrentqueue, nlohmann/json and Quill are fetched automatically by CMake.
```

### Configure & build
Expand Down Expand Up @@ -458,6 +486,7 @@ cd build
ctest --output-on-failure # all tests
ctest -L unit --output-on-failure # unit tests only
ctest -L component --output-on-failure
ctest -L cli --output-on-failure # the NeuronIDE binary end to end
```

> Note: `LSLReader` and `Runtime` unit tests open local LSL streams and exercise a
Expand Down
14 changes: 11 additions & 3 deletions cmake/Dependencies.cmake
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,14 @@ FetchContent_Declare(
)
set(JSON_BuildTests OFF CACHE INTERNAL "")

# 5. Quill (header-only) - asynchronous logging
FetchContent_Declare(
quill
URL https://github.com/odygrd/quill/archive/refs/tags/v13.0.0.tar.gz
URL_HASH SHA256=88b4a1542125577a4d51cf444c51e34d63618c422ba6a4fa9bd23894b49d696b
SYSTEM
)

# Suppress compiler warnings from third-party targets when compiling their source files
set(BACKUP_C_FLAGS "${CMAKE_C_FLAGS}")
set(BACKUP_CXX_FLAGS "${CMAKE_CXX_FLAGS}")
Expand All @@ -46,14 +54,14 @@ elseif(CMAKE_CXX_COMPILER_ID MATCHES "MSVC")
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} /w")
endif()

FetchContent_MakeAvailable(googletest liblsl concurrentqueue nlohmann_json)
FetchContent_MakeAvailable(googletest liblsl concurrentqueue nlohmann_json quill)

# Restore compiler flags for our own project code
set(CMAKE_C_FLAGS "${BACKUP_C_FLAGS}")
set(CMAKE_CXX_FLAGS "${BACKUP_CXX_FLAGS}")

# 5. SDL2 (System installed)
# 6. SDL2 (System installed)
find_package(SDL2 REQUIRED)

# 6. Protobuf (System installed)
# 7. Protobuf (System installed)
find_package(Protobuf REQUIRED)
39 changes: 39 additions & 0 deletions include/logging/Logging.hpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
#ifndef LOGGING_HPP
#define LOGGING_HPP

#include <quill/LogMacros.h>
#include <quill/Logger.h>

#include <cstdint>
#include <optional>
#include <string_view>

namespace logging {

enum class Subsystem : std::uint8_t { App, Runtime, LslReader, Scene };

// Safe to call from any thread. Messages reach stderr only while a Session is
// running; without one they are queued and never printed.
quill::Logger* logger(Subsystem subsystem);

// Parses a NEURONIDE_LOG_LEVEL value such as "debug" or "WARNING". Returns
// nullopt for anything a logger cannot be set to.
std::optional<quill::LogLevel> parseLogLevel(std::string_view name);

// Runs the logging backend thread; the destructor writes out every queued
// message. Create it first in main() so it outlives every thread that logs.
// Keep one at a time: the backend is shared, so destroying any Session stops it.
class Session {
public:
Session();
~Session();

Session(const Session&) = delete;
Session& operator=(const Session&) = delete;
Session(Session&&) = delete;
Session& operator=(Session&&) = delete;
};

} // namespace logging

#endif // LOGGING_HPP
2 changes: 1 addition & 1 deletion include/parser/Parser.hpp
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
#ifndef PARSER_HPP
#define PARSER_HPP

#include <iostream>
#include <iosfwd>
#include <memory>
#include <string>

Expand Down
1 change: 0 additions & 1 deletion include/scene/SceneObject.hpp
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
#ifndef SCENEOBJECT_HPP
#define SCENEOBJECT_HPP

#include <iostream>
#include <memory>
#include <string>
#include <vector>
Expand Down
2 changes: 0 additions & 2 deletions include/scene/components/BlinkComponent.hpp
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
#ifndef BLINKCOMPONENT_HPP
#define BLINKCOMPONENT_HPP

#include <iostream>

#include "Component.hpp"

namespace NeuronIDE {
Expand Down
2 changes: 2 additions & 0 deletions src/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ set_target_properties(neuronide_proto PROPERTIES CXX_CLANG_TIDY "")
target_include_directories(neuronide_proto SYSTEM PUBLIC ${CMAKE_CURRENT_BINARY_DIR})
target_link_libraries(neuronide_proto PUBLIC protobuf::libprotobuf)

add_subdirectory(logging)
add_subdirectory(scene)
add_subdirectory(parser)
add_subdirectory(config)
Expand All @@ -17,6 +18,7 @@ add_library(runtime_core STATIC
)
target_include_directories(runtime_core PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/../include)
target_link_libraries(runtime_core PUBLIC
logging
scene
parser
config
Expand Down
11 changes: 9 additions & 2 deletions src/Runtime.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
#include <exception>
#include <filesystem>
#include <format>
#include <iostream>
#include <logging/Logging.hpp>
#include <lslreader/LSLReader.hpp>
#include <memory>
#include <parser/Parser.hpp>
Expand All @@ -33,6 +33,8 @@ constexpr const char* kFallbackName = "experiment";
// How often the window's events are handled while waiting for the EEG stream.
constexpr auto kEventPollInterval = std::chrono::milliseconds(50);

quill::Logger* logger() { return logging::logger(logging::Subsystem::Runtime); }

std::string sdlError(const char* what) { return std::string(what) + ": " + SDL_GetError(); }

// The experiment name comes from an authored protobuf file and ends up in a file
Expand Down Expand Up @@ -217,7 +219,8 @@ std::string Runtime::makeOutputPath() const {
// True once samples are flowing; false if the window was closed or
// requestStop() was called first.
bool Runtime::awaitAcquisition() {
std::cerr << "Runtime: waiting for EEG stream '" << config.lsl.name << "'...\n";
LOG_INFO(logger(), "Waiting up to {} ms for EEG stream '{}'", acquisitionTimeout.count(),
config.lsl.name);

const std::stop_token token = stopSource.get_token();
const auto deadline = std::chrono::steady_clock::now() + acquisitionTimeout;
Expand Down Expand Up @@ -254,6 +257,7 @@ bool Runtime::awaitAcquisition() {
void Runtime::startRecording() {
outputFilePath = makeOutputPath();
dataWriter->start(outputFilePath, eegQueue, markerQueue);
LOG_INFO(logger(), "Recording to '{}'", outputFilePath);
}

void Runtime::run() {
Expand All @@ -274,6 +278,8 @@ void Runtime::run() {
if (awaitAcquisition()) {
startRecording();
renderer->render(stopSource.get_token());
} else {
LOG_INFO(logger(), "Stopped while waiting for the EEG stream; nothing was recorded");
}
} catch (...) {
shutdown();
Expand All @@ -284,6 +290,7 @@ void Runtime::run() {
if (lslReader->state() == AcquisitionState::Failed) {
throwAcquisitionFailure("during the experiment; the recording was stopped");
}
LOG_INFO(logger(), "Run finished");
}

void Runtime::requestStop() { stopSource.request_stop(); }
Expand Down
7 changes: 7 additions & 0 deletions src/logging/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
add_library(logging OBJECT
Logging.cpp
)

target_include_directories(logging PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/../../include)

target_link_libraries(logging PUBLIC quill::quill)
Loading
Loading