Skip to content
Merged
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
- Ensure empty stream segments are initialised ([#129](https://github.com/xdf-modules/pyxdf/pull/129) by [Jamie Forth](https://github.com/jamieforth))
- Uniformly calculate effective sample rate as `(len(time_stamps) - 1) / duration` ([#129](https://github.com/xdf-modules/pyxdf/pull/129) by [Jamie Forth](https://github.com/jamieforth))
- Fix synchronisation for streams with clock resets and MAD calculation used in clock value segmentation ([#131](https://github.com/xdf-modules/pyxdf/pull/131) by [Jamie Forth](https://github.com/jamieforth))
- Fix file playback when not looping ([#136](https://github.com/xdf-modules/pyxdf/pull/136) by [Chadwick Boulay](https://github.com/cboulay))

## [1.17.0] - 2025-01-07
### Fixed
Expand Down
5 changes: 5 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,11 @@ Repository = "https://github.com/xdf-modules/pyxdf"
Issues = "https://github.com/xdf-modules/pyxdf/issues"
Changelog = "https://github.com/xdf-modules/pyxdf/blob/main/CHANGELOG.md"

[project.optional-dependencies]
playback = [
"pylsl>=1.17.6",
]

[dependency-groups]
dev = [
"pytest>=8.3.4",
Expand Down
40 changes: 25 additions & 15 deletions src/pyxdf/cli/playback_lsl.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ def _create_info_from_xdf_stream_header(header):
channel_count=int(header["channel_count"][0]),
nominal_srate=float(header["nominal_srate"][0]),
channel_format=header["channel_format"][0],
source_id=header["source_id"][0],
source_id=header["source_id"][0] if "source_id" in header else "",
)
desc = new_info.desc()
if "desc" in header and header["desc"][0] is not None:
Expand Down Expand Up @@ -60,6 +60,16 @@ def __init__(
loop_time: float = 0.0,
max_sample_rate: Optional[float] = None,
):
"""
Create an object that tracks file playback time at optional non-realtime rate.

Args:
rate: Speed of playback. 1.0 is real time.
loop_time: What relative time in the file to stop and loop back. 0.0 means no looping.
max_sample_rate: The maximum sampling rate we might want to accommodate for sample-by-sample
playback. This is used to determine the sleep time between iterations.
If None, the sleep time will be 5 msec.
"""
if rate != 1.0:
print(
"WARNING!! rate != 1.0; it is impossible to synchronize playback "
Expand All @@ -70,10 +80,10 @@ def __init__(
self._max_srate = max_sample_rate
decr = (1 / self._max_srate) if self._max_srate else 2 * sys.float_info.epsilon
self._wall_start: float = pylsl.local_clock() - decr / 2
self._file_read_s: float = 0 # File read header in seconds
self._prev_file_read_s: float = (
0 # File read header in seconds for previous iteration
)
# File read header in seconds
self._file_read_s: float = 0
# File read header in seconds for previous iteration
self._prev_file_read_s: float = 0
self._n_loop: int = 0

def reset(self, reset_file_position: bool = False) -> None:
Expand Down Expand Up @@ -171,11 +181,12 @@ def main(
# Create timer to manage playback.
timer = LSLPlaybackClock(
rate=playback_speed,
loop_time=wrap_dur if loop else None,
loop_time=wrap_dur if loop else 0.0,
max_sample_rate=max_rate,
)
read_heads = {_.name: 0 for _ in streamers}
b_push = not wait_for_consumer # A flag to indicate we can push samples.
# A flag to indicate we can push samples.
b_push = not wait_for_consumer
try:
while True:
if not b_push:
Expand All @@ -184,36 +195,35 @@ def main(
have_consumers = [
streamer.outlet.have_consumers() for streamer in streamers
]
# b_push = any(have_consumers)
b_push = all(have_consumers)
if b_push:
timer.reset()
else:
continue
timer.update()
t_start, t_stop = timer.step_range
all_streams_exhausted = True
for streamer in streamers:
start_idx = read_heads[streamer.name] if t_start > 0 else 0
stop_idx = np.searchsorted(streamer.tvec, t_stop)
stop_idx = int(np.searchsorted(streamer.tvec, t_stop))
if stop_idx > start_idx:
all_streams_exhausted = False
if streamer.srate > 0:
sl = np.s_[start_idx:stop_idx]
push_dat = streams[streamer.stream_ix]["time_series"][sl]
push_ts = timer.t0 + streamer.tvec[sl][-1]
push_ts = timer.t0 + float(streamer.tvec[sl][-1])
streamer.outlet.push_chunk(push_dat, timestamp=push_ts)
else:
# Irregular rate, like events and markers
for dat_idx in range(start_idx, stop_idx):
sample = streams[streamer.stream_ix]["time_series"][dat_idx]
streamer.outlet.push_sample(
sample, timestamp=timer.t0 + streamer.tvec[dat_idx]
sample,
timestamp=timer.t0 + float(streamer.tvec[dat_idx]),
)
# print(f"Pushed sample: {sample}")
read_heads[streamer.name] = stop_idx

if not loop and all_streams_exhausted:
if not loop and all(
[t_stop >= streamer.tvec[-1] for streamer in streamers]
):
print("Playback finished.")
break
timer.sleep()
Expand Down