Skip to content

Commit 514df38

Browse files
committed
Add configurable system-delay timestamp compensation; document it
Subtract a configurable system/pipeline latency (systemDelayMs, default 4.5 ms) from every pushed LSL timestamp -- EEG, Physio16, and DIN alike -- to compensate for the time between digitization and the sample being available to time-stamp in this client (device firmware + network transmission + read path). Measured ~5 ms in native mode via the audio latency test; the default deliberately under-compensates so a sample is never back-dated before its event. Applied unconditionally at the point the batch timestamp is captured, on top of the decimated-only FPGA filter offset. Document the full timestamp-compensation behaviour in the README (system delay, FPGA filter offset, Physio16 realignment), replacing the stale --align-timestamps section and correcting the filter-delay table to the measured 111/61/36 ms. Also gitignore the audio-latency-test recordings and Python caches.
1 parent 0524e86 commit 514df38

6 files changed

Lines changed: 79 additions & 28 deletions

File tree

.gitignore

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,3 +20,13 @@ settings.local.json
2020

2121
# macOS
2222
.DS_Store
23+
24+
# misc
25+
.codegraph/
26+
delay_capture*/
27+
28+
# Audio latency test outputs (large XDF recordings, plots, CLI logs)
29+
scripts/audio_latency_test/recordings/
30+
31+
# Python cache
32+
__pycache__/

README.md

Lines changed: 44 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,6 @@ The CLI provides a lightweight alternative to the GUI:
3535
- `--amp-id <id>` - Amplifier ID (default: 0)
3636
- `--sample-rate <hz>` - Sample rate in Hz (default: 1000). Forces amplifier to this rate if already running at a different rate. Valid rates: 250, 500, 1000 (decimated) or 500, 1000, 2000, 4000, 8000 (native).
3737
- `--fast-recovery` - Use native rate mode (no FPGA anti-alias filter) for lower latency. See [Sample Rate Modes](#sample-rate-modes).
38-
- `--align-timestamps` - Adjust timestamps to compensate for anti-alias filter delay. See [Timestamp Alignment](#timestamp-alignment).
3938
- `--impedance` - Enable impedance testing mode
4039
- `--native-format` - Transmit raw int32 ADC counts instead of float microvolts
4140
- `--shutdown` - Shutdown the Amp Server (terminates all connections)
@@ -52,11 +51,11 @@ The CLI provides a lightweight alternative to the GUI:
5251
# With native format (int32 ADC counts)
5352
./EGIAmpServerCLI --address 10.10.10.51 --native-format
5453

55-
# Low-latency mode (fast recovery)
54+
# Low-latency mode (fast recovery / native)
5655
./EGIAmpServerCLI --address 10.10.10.51 --sample-rate 1000 --fast-recovery
5756

58-
# With timestamp alignment for filter delay
59-
./EGIAmpServerCLI --address 10.10.10.51 --sample-rate 1000 --align-timestamps
57+
# Decimated mode — timestamp compensation is applied automatically
58+
./EGIAmpServerCLI --address 10.10.10.51 --sample-rate 1000
6059
```
6160

6261
## Connecting to an Already-Running Amplifier
@@ -78,9 +77,8 @@ This is safe to use alongside Net Station — it will not stop or reconfigure th
7877

7978
The following flags cause the CLI to stop, reconfigure, and restart the amplifier — even if it was started by another application:
8079

81-
- `--sample-rate <hz>` — reinitializes if the detected rate differs from the requested rate
80+
- `--sample-rate <hz>` — reinitializes if the detected rate differs from the requested rate. At 500/1000 Hz, where native and decimated are indistinguishable in the data stream, requesting a decimated rate also forces reinitialization to guarantee decimated mode (so the automatic [timestamp compensation](#timestamp-compensation) is correct)
8281
- `--fast-recovery` — reinitializes to ensure native (unfiltered) mode
83-
- `--align-timestamps` — reinitializes at 500/1000 Hz to ensure decimated (filtered) mode, since the operating mode cannot be distinguished from the data stream alone
8482

8583
**Warning**: Reinitialization will interrupt any active Net Station recording. If you need to coexist with Net Station, omit these flags and let the CLI match the existing configuration.
8684

@@ -100,14 +98,14 @@ The NA400/NA410 amplifiers support two operating modes that affect anti-aliasing
10098

10199
Uses the FPGA's digital anti-aliasing filter to downsample from the ADC's native rate. This provides:
102100
- Better frequency response (~400 Hz bandwidth at 1000 Hz sample rate)
103-
- Higher latency due to filter delay (36-112 samples depending on rate)
101+
- Higher latency due to the filter group delay (36-111 ms depending on rate), which the app compensates for automatically — see [Timestamp Compensation](#timestamp-compensation)
104102

105103
Available decimated rates: 250, 500, 1000 Hz
106104

107105
### Native Mode (Fast Recovery)
108106

109107
Bypasses the FPGA filter and samples directly at the requested rate. This provides:
110-
- Lower latency (~3 samples)
108+
- Lower latency (no filter group delay)
111109
- Reduced bandwidth (~1/4 of sample rate, e.g., 250 Hz at 1000 Hz sample rate)
112110
- Optimized for EEG-TMS and real-time BCI applications
113111

@@ -117,32 +115,52 @@ Use `--fast-recovery` to enable native mode for rates that support both modes (5
117115

118116
### Filter Delay by Sample Rate
119117

120-
| Mode | Sample Rate | Filter Delay (samples) | Filter Delay (ms) |
121-
|------|-------------|------------------------|-------------------|
122-
| Decimated | 250 Hz | 112 | 448 ms |
123-
| Decimated | 500 Hz | 66 | 132 ms |
124-
| Decimated | 1000 Hz | 36 | 36 ms |
125-
| Native | 500-8000 Hz | ~3 | ~3 ms |
118+
DIN→EEG group delay of the FPGA anti-alias filter (decimated mode only), measured with `scripts/delay_capture_sweep.py`:
126119

127-
## Timestamp Alignment
120+
| Mode | Sample Rate | Filter delay (ms) | (samples) |
121+
|------|-------------|-------------------|-----------|
122+
| Decimated | 250 Hz | 111 | ~28 |
123+
| Decimated | 500 Hz | 61 | ~30 |
124+
| Decimated | 1000 Hz | 36 | 36 |
125+
| Native | 500-8000 Hz | 0 | 0 |
128126

129-
When using decimated mode, the FPGA anti-aliasing filter introduces a delay between when brain activity occurs and when it appears in the data stream. The `--align-timestamps` option compensates for this by adjusting LSL timestamps backward by the filter delay amount.
127+
## Timestamp Compensation
130128

131-
### When to Use
129+
The application adjusts the LSL timestamp it assigns to each sample so that the timestamp reflects **when the signal actually occurred**, not when the bytes happened to arrive at this client. This makes EEG, Physio16, and DIN events line up with each other and with external event markers. Three corrections are applied; together they are what we call timestamp compensation.
132130

133-
- **ERP analysis**: Enable `--align-timestamps` to align EEG data with event markers
134-
- **Real-time BCI**: Use `--fast-recovery` instead (no filter delay to compensate)
135-
- **Raw recording**: Disable alignment if you prefer unmodified timestamps
131+
There is **no flag to toggle this** — it follows the configured mode automatically. (The old `--align-timestamps` flag was removed.) The app trusts the mode you configure: a decimated rate means the filter corrections apply; a native rate (`--fast-recovery`, or any rate above 1000 Hz) means they do not. At 500/1000 Hz, where native and decimated are indistinguishable in the data stream, the app assumes the mode you asked for (and, when forcing a rate, reinitializes to guarantee it).
136132

137-
### Limitations
133+
### 1. System (pipeline) delay — always applied
138134

139-
**Important**: Timestamp alignment only works correctly when this application initializes the amplifier. If Net Station or another application previously initialized the amplifier, the current operating mode (decimated vs native) cannot be queried from AmpServer. In this case:
135+
Every pushed timestamp (EEG, Physio16, **and** DIN) is moved earlier by `systemdelayms` to account for the time between digitization and the sample becoming available here: device firmware + network transmission + our read path. Measured at ~5 ms in native mode using `scripts/audio_latency_test`.
140136

141-
1. The application will reinitialize the amplifier to ensure the correct mode
142-
2. This will interrupt any existing Net Station recording
143-
3. To avoid this, start EGIAmpServer before Net Station, or restart the amplifier
137+
The default is **4.5 ms** — deliberately a little *under* the measured value. Under-compensating is the safe direction: it guarantees a sample is never back-dated to *before* the event that produced it (a response must not precede its stimulus). It is user-configurable; raise it toward the measured latency if you prefer tighter alignment and can accept that risk.
144138

145-
If you need to join an existing Net Station session without reinitialization, do not use `--align-timestamps` unless you are certain of the current mode.
139+
### 2. FPGA filter offset — decimated mode only
140+
141+
In decimated mode the anti-alias filter delays the EEG relative to the (unfiltered) DIN by the group delay in the table above (111/61/36 ms at 250/500/1000 Hz). The app subtracts this from the EEG/Physio timestamps so filtered EEG lines up with DIN. Native mode has no filter, so nothing is subtracted. DIN itself is never filter-shifted (only the system delay applies to it).
142+
143+
### 3. Physio16 realignment — decimated mode only
144+
145+
The Physio16 (PNS/PIB) acquisition path runs **ahead** of the FPGA-filtered EEG by a fixed ~33 ms in decimated mode, so the same event lands at different sample indices in the EEG vs physio channels of the combined stream. To keep them sample-aligned, the physio channels are buffered (delayed) by `physioaligndelayms` (default **33 ms**, applied as `floor(33 ms × rate)` samples). The physio channels therefore read **zeros for the first ~33 ms** after streaming starts, then real data. Native mode applies no physio delay.
146+
147+
### Configuration
148+
149+
Both values live in the config file (and persist via File → Save Configuration):
150+
151+
```xml
152+
<settings>
153+
...
154+
<physioaligndelayms>33</physioaligndelayms> <!-- Physio16 realignment, decimated only -->
155+
<systemdelayms>4.5</systemdelayms> <!-- pipeline-latency compensation, always -->
156+
</settings>
157+
```
158+
159+
The filter-offset values (111/61/36 ms) are built in. All three were measured on real hardware; see `scripts/audio_latency_test/README.md` (system delay) and `notebooks/delay_inspection.ipynb` (filter and physio delays).
160+
161+
### Coexisting with Net Station
162+
163+
The filter corrections are only correct when the operating mode is known. If another application (e.g. Net Station) already started the amplifier at an ambiguous rate (500/1000 Hz), the app applies the corrections for the mode **you configured**, assuming it matches. If you are unsure of the running mode, attach with a native configuration (no filter corrections) or let the app reinitialize to a known mode (which interrupts the existing session).
146164

147165
## Impedance Testing
148166

ampserver_config.cfg

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,4 +15,10 @@
1515
Applied as floor(33 ms x rate) samples -> 32 ms @ 250/500 Hz, 33 ms @ 1000 Hz.
1616
Decimated rates only; native rates have ~0 skew. -->
1717
<physioaligndelayms>33</physioaligndelayms>
18+
<!-- System/pipeline latency (ms) subtracted from every pushed timestamp
19+
(EEG, physio, DIN) to compensate digitization->client delay (firmware +
20+
network + read path). Measured ~5 ms via scripts/audio_latency_test;
21+
default 4.5 deliberately under-compensates so a sample is never back-dated
22+
before its event. User-tunable. -->
23+
<systemdelayms>4.5</systemdelayms>
1824
</settings>

src/core/include/egiamp/AmpServerConfig.h

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,15 @@ struct AmpServerConfig {
2424
// which the device quantizes to 8 @ 250 Hz and 16 @ 500 Hz (both 32 ms) and
2525
// 33 @ 1000 Hz. Applied only in decimated mode; ignored for native rates.
2626
int physioAlignDelayMs = 33;
27+
// System/pipeline latency (ms) subtracted from every pushed LSL timestamp
28+
// (EEG, physio, and DIN alike) to compensate for the time between
29+
// digitization and the sample being available to time-stamp in this client:
30+
// device firmware + network transmission + our read path. Measured ~5 ms via
31+
// scripts/audio_latency_test (native modes). Applied unconditionally, on top
32+
// of the decimated-only FPGA filter offset. Default intentionally slightly
33+
// under the measured value so we never back-date a sample *before* its event
34+
// (a response cannot precede its stimulus); user-tunable.
35+
double systemDelayMs = 4.5;
2736
bool impedance = false;
2837
bool nativeFormat = false; // When true, transmit raw int32 ADC counts instead of float microvolts
2938

src/core/src/AmpServerConfig.cpp

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,9 @@ AmpServerConfig AmpServerConfig::loadFromFile(const std::string& filename) {
6060
if (auto node = settings.child("physioaligndelayms")) {
6161
config.physioAlignDelayMs = node.text().as_int(config.physioAlignDelayMs);
6262
}
63+
if (auto node = settings.child("systemdelayms")) {
64+
config.systemDelayMs = node.text().as_double(config.systemDelayMs);
65+
}
6366
}
6467

6568
return config;
@@ -83,6 +86,7 @@ void AmpServerConfig::saveToFile(const std::string& filename) const {
8386
settings.append_child("nativeformat").text().set(nativeFormat);
8487
settings.append_child("fastrecovery").text().set(fastRecovery);
8588
settings.append_child("physioaligndelayms").text().set(physioAlignDelayMs);
89+
settings.append_child("systemdelayms").text().set(systemDelayMs);
8690

8791
if (!doc.save_file(filename.c_str())) {
8892
throw ConfigError("Could not write to config file: " + filename);

src/core/src/EGIAmpClient.cpp

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -818,8 +818,12 @@ void EGIAmpClient::readPacketFormat2() {
818818
stream.read(reinterpret_cast<char*>(&header), sizeof(header));
819819

820820
// Capture arrival time before any per-sample processing so that
821-
// all samples in this batch share the same base timestamp.
822-
double batchTimestamp = lsl::local_clock();
821+
// all samples in this batch share the same base timestamp. Subtract
822+
// the system/pipeline delay (firmware + network + read path) so the
823+
// timestamp reflects digitization time, not arrival. This flows to
824+
// both the EEG/physio chunk and the DIN events (derived below), and
825+
// is on top of the decimated-only filter offset applied in pushChunk.
826+
double batchTimestamp = lsl::local_clock() - config_.systemDelayMs / 1000.0;
823827

824828
header.ampID = big_to_native(header.ampID);
825829
header.length = big_to_native(header.length);

0 commit comments

Comments
 (0)