Skip to content

135 playback lsl - Fix for errors when not looping or when sleep time is shorter than sample interval #136

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 11 commits into from
May 27, 2025
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