Skip to content

Commit 939593d

Browse files
committed
Add audio_latency_test Python app
1 parent 79d56fc commit 939593d

7 files changed

Lines changed: 1141 additions & 0 deletions

File tree

.gitignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,8 @@ ui_*.h
88
.idea
99
.cmake-build-*/
1010
.cache/
11+
12+
# Python venv and latency-test outputs
13+
/.venv/
14+
scripts/audio_latency_test/recordings/
15+
scripts/audio_latency_test/__pycache__/
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
# Audio ↔ LSL Latency Test (MeasurementComputing)
2+
3+
Measures the latency **and jitter** between an LSL marker that says "a tone was
4+
played" and the moment that tone's waveform actually shows up in the MCC DAQ
5+
data stream. It is the LSL/MeasurementComputing analogue of Blackrock's
6+
[`analog_latency_test.py`](https://github.com/BlackrockNeurotech/orion/blob/dev/Python/examples/analog_latency_test.py).
7+
8+
The primary goal is **minimizing jitter** (run-to-run variation of the latency),
9+
which is what determines how well you can align events to neural/physio data.
10+
11+
## What it does
12+
13+
1. Launches `MCCOutletCLI` streaming **one channel** of a USB-1608FS-Plus at a
14+
high audio-band rate (default 96 kHz, single-channel max is 100 kHz), full
15+
±10 V range (`--mcc-range 5`).
16+
2. Creates an LSL `Markers` stream and plays pure sine bursts at a fixed
17+
interval, emitting one marker per tone.
18+
3. Records both streams to XDF using **`LabRecorderCLI`** (headless,
19+
deterministic — it resolves the streams once and records until told to stop),
20+
auto-detected at `../App-LabRecorder/.../install/LabRecorderCLI`, on `PATH`, or
21+
via `$LABRECORDER_CLI`. Override the path with `--labrecorder-cli`.
22+
4. Stops after `--n-tones` and measures, per marker, the latency to the tone's
23+
threshold crossing in the MCC data; prints mean/median latency and the
24+
jitter (std, peak-to-peak, IQR), plus a plot.
25+
26+
## Physical setup
27+
28+
Connect a computer audio output (headphone jack or audio-interface output) to
29+
**MCC analog input channel 0** and AGND. Keep the signal inside ±10 V. A simple
30+
3.5 mm jack → bare-wire / BNC cable works. Start with a modest output volume and
31+
raise `--amplitude` / system volume until the tone is well above the noise floor
32+
but not clipping the ±10 V range.
33+
34+
## Install
35+
36+
```bash
37+
# from the repo root
38+
python3.13 -m venv .venv
39+
.venv/bin/pip install -r scripts/audio_latency_test/requirements.txt
40+
# pyaudio needs portaudio: brew install portaudio
41+
```
42+
43+
## Run
44+
45+
```bash
46+
cd scripts/audio_latency_test
47+
../../.venv/bin/python run_test.py # defaults: sd-callback, 96 kHz, 100 tones
48+
```
49+
50+
Useful options:
51+
52+
```bash
53+
# Compare back-ends (the experiment that matters):
54+
run_test.py --method sd-trigger # marker stamped at trigger time
55+
run_test.py --method sd-callback # DAC-time stamped (low jitter) [default]
56+
run_test.py --method pyaudio-callback # PyAudio equivalent of sd-callback
57+
58+
# Tuning:
59+
run_test.py --blocksize 64 # smaller callback block -> lower latency
60+
run_test.py --audio-device "USB" # pick a specific output device
61+
run_test.py --n-tones 200 --isi 0.5
62+
63+
# Point at a specific LabRecorderCLI:
64+
run_test.py --labrecorder-cli /path/to/LabRecorderCLI
65+
66+
# Re-analyze an existing recording (no hardware):
67+
run_test.py --analyze-xdf recordings/audio_latency_xxx.xdf
68+
```
69+
70+
Outputs land in `scripts/audio_latency_test/recordings/`:
71+
`audio_latency_<tag>.xdf`, `latency_<tag>.png`, `latency_<tag>.npz`, and the
72+
`mcc_<tag>.log` CLI log.
73+
74+
## How the marker is time-stamped (and why jitter differs)
75+
76+
All three keep a persistent output stream open and play a pre-loaded tone on a
77+
flag trigger — they differ only in **what timestamp the marker gets**:
78+
79+
| Method | Marker timestamp | Expectation |
80+
|---|---|---|
81+
| `sd-trigger` | `local_clock()` at the `play()` trigger | Includes the buffering between trigger and output → shows the real, naively-logged latency and its jitter |
82+
| `sd-callback` | PortAudio `outputBufferDacTime` (predicted DAC time of the first tone sample) mapped into the LSL clock | Removes buffering jitter → **low jitter** |
83+
| `pyaudio-callback` | PyAudio `output_buffer_dac_time`, same idea | Comparison point for PortAudio vs PyAudio |
84+
85+
A constant clock-offset error shifts the *absolute* latency but cancels out of
86+
the jitter, so the std/peak-to-peak numbers are the meaningful comparison.
87+
88+
## Ideas to reduce jitter further
89+
90+
- Smaller `--blocksize` (e.g. 64/128) and `latency='low'` (already used).
91+
- Pre-built tone buffer + arm/trigger (already done in all players — the tone is
92+
generated once and triggered by a flag the callback reads).
93+
- Try a dedicated audio interface (more deterministic than built-in audio).
94+
- Raise process/thread priority for the audio callback.
95+
- Use a hard onset (`--ramp 0`, default) for a crisp, consistently-detected edge.
96+
97+
## How latency is measured
98+
99+
For each marker time `tm`, the analyzer looks in `[tm−pre, tm+window]`, estimates
100+
a robust baseline/noise from the pre-marker samples, then finds the first
101+
post-marker sample whose deviation exceeds a threshold (default 20 % of the
102+
burst peak, auto-scaling to the physical level), and linearly interpolates the
103+
crossing time to beat sample quantization. See `analysis.compute_latencies`.
104+
105+
`--window` defaults to **auto** (~95 % of the ISI) and each epoch is clamped to
106+
end just before the next marker, so even a large audio buffer is captured
107+
without bleeding into the following tone. (A fixed small window risks missing a
108+
high-latency tone entirely, leaving the epoch plot showing only baseline noise.)
109+
110+
## Files
111+
112+
| File | Purpose |
113+
|---|---|
114+
| `run_test.py` | Orchestrator / CLI entry point |
115+
| `audio.py` | Tone generation + playback back-ends (the experiment knobs) |
116+
| `labrecorder.py` | LabRecorderCLI discovery + recording session |
117+
| `analysis.py` | Threshold-crossing latency, stats, plots, XDF loader |

0 commit comments

Comments
 (0)