Skip to content

Commit fc32bc2

Browse files
authored
135 playback lsl - Fix for errors when not looping or when sleep time is shorter than sample interval (#136)
* Add pylsl as optional dependency * Fix playback error when not looping. * playback type checker fixes * playback - add docstring and update code comments * playback - Fix issue with early termination. * playback fix - Changelog entry * playback - do not require source_id * playback - add pytest * ruff format * Test GHA now installs liblsl * Remove pylsl playback test
1 parent 7c8219d commit fc32bc2

File tree

3 files changed

+31
-15
lines changed

3 files changed

+31
-15
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
- Ensure empty stream segments are initialised ([#129](https://github.com/xdf-modules/pyxdf/pull/129) by [Jamie Forth](https://github.com/jamieforth))
1212
- 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))
1313
- 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))
14+
- Fix file playback when not looping ([#136](https://github.com/xdf-modules/pyxdf/pull/136) by [Chadwick Boulay](https://github.com/cboulay))
1415

1516
## [1.17.0] - 2025-01-07
1617
### Fixed

pyproject.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,11 @@ Repository = "https://github.com/xdf-modules/pyxdf"
4343
Issues = "https://github.com/xdf-modules/pyxdf/issues"
4444
Changelog = "https://github.com/xdf-modules/pyxdf/blob/main/CHANGELOG.md"
4545

46+
[project.optional-dependencies]
47+
playback = [
48+
"pylsl>=1.17.6",
49+
]
50+
4651
[dependency-groups]
4752
dev = [
4853
"pytest>=8.3.4",

src/pyxdf/cli/playback_lsl.py

Lines changed: 25 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ def _create_info_from_xdf_stream_header(header):
1717
channel_count=int(header["channel_count"][0]),
1818
nominal_srate=float(header["nominal_srate"][0]),
1919
channel_format=header["channel_format"][0],
20-
source_id=header["source_id"][0],
20+
source_id=header["source_id"][0] if "source_id" in header else "",
2121
)
2222
desc = new_info.desc()
2323
if "desc" in header and header["desc"][0] is not None:
@@ -60,6 +60,16 @@ def __init__(
6060
loop_time: float = 0.0,
6161
max_sample_rate: Optional[float] = None,
6262
):
63+
"""
64+
Create an object that tracks file playback time at optional non-realtime rate.
65+
66+
Args:
67+
rate: Speed of playback. 1.0 is real time.
68+
loop_time: What relative time in the file to stop and loop back. 0.0 means no looping.
69+
max_sample_rate: The maximum sampling rate we might want to accommodate for sample-by-sample
70+
playback. This is used to determine the sleep time between iterations.
71+
If None, the sleep time will be 5 msec.
72+
"""
6373
if rate != 1.0:
6474
print(
6575
"WARNING!! rate != 1.0; it is impossible to synchronize playback "
@@ -70,10 +80,10 @@ def __init__(
7080
self._max_srate = max_sample_rate
7181
decr = (1 / self._max_srate) if self._max_srate else 2 * sys.float_info.epsilon
7282
self._wall_start: float = pylsl.local_clock() - decr / 2
73-
self._file_read_s: float = 0 # File read header in seconds
74-
self._prev_file_read_s: float = (
75-
0 # File read header in seconds for previous iteration
76-
)
83+
# File read header in seconds
84+
self._file_read_s: float = 0
85+
# File read header in seconds for previous iteration
86+
self._prev_file_read_s: float = 0
7787
self._n_loop: int = 0
7888

7989
def reset(self, reset_file_position: bool = False) -> None:
@@ -171,11 +181,12 @@ def main(
171181
# Create timer to manage playback.
172182
timer = LSLPlaybackClock(
173183
rate=playback_speed,
174-
loop_time=wrap_dur if loop else None,
184+
loop_time=wrap_dur if loop else 0.0,
175185
max_sample_rate=max_rate,
176186
)
177187
read_heads = {_.name: 0 for _ in streamers}
178-
b_push = not wait_for_consumer # A flag to indicate we can push samples.
188+
# A flag to indicate we can push samples.
189+
b_push = not wait_for_consumer
179190
try:
180191
while True:
181192
if not b_push:
@@ -184,36 +195,35 @@ def main(
184195
have_consumers = [
185196
streamer.outlet.have_consumers() for streamer in streamers
186197
]
187-
# b_push = any(have_consumers)
188198
b_push = all(have_consumers)
189199
if b_push:
190200
timer.reset()
191201
else:
192202
continue
193203
timer.update()
194204
t_start, t_stop = timer.step_range
195-
all_streams_exhausted = True
196205
for streamer in streamers:
197206
start_idx = read_heads[streamer.name] if t_start > 0 else 0
198-
stop_idx = np.searchsorted(streamer.tvec, t_stop)
207+
stop_idx = int(np.searchsorted(streamer.tvec, t_stop))
199208
if stop_idx > start_idx:
200-
all_streams_exhausted = False
201209
if streamer.srate > 0:
202210
sl = np.s_[start_idx:stop_idx]
203211
push_dat = streams[streamer.stream_ix]["time_series"][sl]
204-
push_ts = timer.t0 + streamer.tvec[sl][-1]
212+
push_ts = timer.t0 + float(streamer.tvec[sl][-1])
205213
streamer.outlet.push_chunk(push_dat, timestamp=push_ts)
206214
else:
207215
# Irregular rate, like events and markers
208216
for dat_idx in range(start_idx, stop_idx):
209217
sample = streams[streamer.stream_ix]["time_series"][dat_idx]
210218
streamer.outlet.push_sample(
211-
sample, timestamp=timer.t0 + streamer.tvec[dat_idx]
219+
sample,
220+
timestamp=timer.t0 + float(streamer.tvec[dat_idx]),
212221
)
213-
# print(f"Pushed sample: {sample}")
214222
read_heads[streamer.name] = stop_idx
215223

216-
if not loop and all_streams_exhausted:
224+
if not loop and all(
225+
[t_stop >= streamer.tvec[-1] for streamer in streamers]
226+
):
217227
print("Playback finished.")
218228
break
219229
timer.sleep()

0 commit comments

Comments
 (0)