Synchronization
What the timestamps mean, what they leave out, and how to line up records from different modules.
How it works
Every module runs on the same computer and reads the same two clocks. When a piece of data reaches a module, the module reads both clocks at once and writes the two values into the row as record_time_unix and record_time_mono. That is the whole mechanism. There is no clock distribution to the devices and no correction for how long each device took to deliver its data.
| Clock | Column | Properties |
|---|---|---|
| Wall clock | record_time_unix | Seconds since 1970-01-01 UTC. Comparable with other computers whose clocks are set. Can jump if the operating system adjusts the clock during a session |
| Monotonic clock | record_time_mono | Python’s perf_counter. Never jumps. The zero point is arbitrary, so only differences mean anything, and only within one boot of one computer |
Use record_time_mono to compare rows from different modules recorded on the same computer. Use record_time_unix when you need the time of day or need to relate the session to something recorded elsewhere.
What each module’s timestamp means
record_time_* is when the data reached RSLogger, not when the event happened. The gap between the two depends on the device and its connection, and RSLogger does not measure it. Where a device has its own clock, the module also records that.
| Module | record_time_* is taken | Device clock |
|---|---|---|
| Cameras, USB | When the frame is read from the camera driver | None |
| Cameras, CSI | When the frame arrives from the camera stack | sensor_timestamp_ns, the sensor’s exposure timestamp |
| Audio | In the driver callback that delivers each chunk | device_time_unix, the driver’s ADC time, when it provides one |
| Eye tracker | When each sample arrives over the network | device_time_unix and device_time_ns, the Neon’s own timestamp |
| DRT | When the device’s result message for a stimulus arrives | device_time_offset, stimulus onset in ms on the device’s clock. The reaction time itself is measured on the device and does not depend on any host timestamp |
| Goggles | When the trial’s result arrives from the controller | TSOT and TSCT are accumulated on the controller and do not depend on any host timestamp |
| GPS | When the NMEA sentence is parsed | device_time_unix, the UTC time inside the sentence. RSLogger reads it from the serial text and does not use a pulse-per-second signal |
| Notes | When you post the note | None |
| Control file | When you press a button in the main window | None |
The DRT and goggle results are the clearest case. A reaction time of 342 ms is 342 ms on the device’s clock whatever the host was doing. The host timestamp on that row tells you roughly where in the session the stimulus fell, to within the time the result took to travel over USB or the radio. The wired unit sends the result when the participant responds, or at the end of the stimulus interval on a miss. The wireless unit sends it at the end of the interval. Neither sends it at onset.
RSLogger does not measure device latency, so the only way to know how well two streams line up on your equipment is to test it. Record an event that several sensors see at once, such as a clap in view of the camera and the microphone, or a lens closing in view of the eye tracker’s scene camera, and compare where it lands in each file.
Finding the same moment in different files
The examples use Python and pandas. Column names are from the data output reference.
Trial boundaries
The control file records every press of Record and Pause on the shared clock, so it is the place to start.
import pandas as pd
control = pd.read_csv("20260318_143022_CONTROL.csv")
starts = control[control["event_type"] == "trial_start"]
stops = control[control["event_type"] == "trial_stop"]
t0 = starts.iloc[0]["record_time_mono"] # start of trial 1
t1 = stops.iloc[0]["record_time_mono"] The video frame at a given time
timing = pd.read_csv("20260318_143022_CAM_trial001_hdprowebcamc920_timing.csv")
target = 373235.004822011 # a record_time_mono from any module
i = (timing["record_time_mono"] - target).abs().idxmin()
frame = timing.loc[i, "frame_index"] # frames are numbered from 1 Seek to frame in the MP4. frame_index counts frames as written, so it is a safer index than time × frame rate when a frame was dropped.
The audio sample at a given time
Each row of the audio timing file is one chunk, and the WAV holds the chunks end to end. The timing file does not record how many samples each chunk held, and RSLogger lets the driver choose the chunk size, so an exact sample position cannot be rebuilt from the timing file alone. Two usable estimates:
import soundfile as sf
audio, rate = sf.read("20260318_143022_AUD_trial001_MIC0_usbmicrophone.wav")
timing = pd.read_csv("20260318_143022_AUD_trial001_MIC0_usbmicrophone_timing.csv")
# 1. By elapsed time from the first chunk. Assumes the driver dropped nothing.
sample = int((target - timing.loc[0, "record_time_mono"]) * rate)
# 2. By chunk. Assumes the driver kept the chunk size constant, which most do.
chunk_len = len(audio) / len(timing)
before = timing[timing["record_time_mono"] <= target]
chunk = before.iloc[-1]
sample = int((chunk["chunk_number"] - 1) * chunk_len
+ (target - chunk["record_time_mono"]) * rate) If the two estimates disagree by more than a chunk, the driver dropped audio during the trial. The module logs a warning when that happens.
Gaze samples during a video frame
gaze = pd.read_csv("20260318_143022_ET_trial001_GAZE.csv")
frame_time = timing.loc[100, "record_time_mono"] # frame 101 of the camera file
half_frame = 0.5 / 30 # for a 30 fps camera
during = gaze[(gaze["record_time_mono"] >= frame_time - half_frame) &
(gaze["record_time_mono"] < frame_time + half_frame)] The DRT stimulus nearest a video frame
drt = pd.read_csv("20260318_143022_DRT_dev_ttyacm0.csv")
frame_time = timing.loc[100, "record_time_mono"]
i = (drt["record_time_mono"] - frame_time).abs().idxmin()
row = drt.loc[i] # row["reaction_time_ms"], row["responses"], row["trial"] The DRT row’s record_time_mono is when the result arrived, not the onset. On a hit from the wired unit that is about the reaction time after onset. On a miss, or from the wireless unit, it is at the end of the stimulus interval. Differences in device_time_offset between rows give the spacing of onsets on the device’s own clock.
A note and the frame it was typed during
notes = pd.read_csv("20260318_143022_NTS_trial001_notes.csv")
note_time = notes.loc[0, "record_time_mono"]
i = (timing["record_time_mono"] - note_time).abs().idxmin()
print(notes.loc[0, "content"], "at frame", timing.loc[i, "frame_index"]) Sample rates
Modules record at different rates, so match rows by nearest neighbour or interpolate as suits the analysis.
- Video: the frame rate set in the camera module, 30 fps by default
- Audio: the sample rate set in the audio module, 48 kHz by default, delivered in chunks
- Gaze: up to 200 Hz from the Neon
- DRT: one row per stimulus, every 3 to 5 s by default
- Goggles: one row per trial
- GPS: one row per NMEA sentence, at the receiver’s update rate