|
| 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