From 13bf24fdbc36f5c6650e37ee68d900414b4a7718 Mon Sep 17 00:00:00 2001 From: Chadwick Boulay Date: Thu, 8 May 2025 15:51:04 -0400 Subject: [PATCH 01/11] Add pylsl as optional dependency --- pyproject.toml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 86f94d5..c90ecf0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", From 283ae1eec2731d543fd170edb569f8dbe528e012 Mon Sep 17 00:00:00 2001 From: Chadwick Boulay Date: Thu, 8 May 2025 15:51:37 -0400 Subject: [PATCH 02/11] Fix playback error when not looping. --- src/pyxdf/cli/playback_lsl.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pyxdf/cli/playback_lsl.py b/src/pyxdf/cli/playback_lsl.py index 5409213..f546d47 100644 --- a/src/pyxdf/cli/playback_lsl.py +++ b/src/pyxdf/cli/playback_lsl.py @@ -171,7 +171,7 @@ 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} From 9e678e67625e13f2dc659222300e4af24b905f65 Mon Sep 17 00:00:00 2001 From: Chadwick Boulay Date: Thu, 8 May 2025 15:52:39 -0400 Subject: [PATCH 03/11] playback type checker fixes --- src/pyxdf/cli/playback_lsl.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/pyxdf/cli/playback_lsl.py b/src/pyxdf/cli/playback_lsl.py index f546d47..df1b40e 100644 --- a/src/pyxdf/cli/playback_lsl.py +++ b/src/pyxdf/cli/playback_lsl.py @@ -195,20 +195,21 @@ def main( 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 From b7a5ad39b9e1ad886a6cd05933a0760770acf8a7 Mon Sep 17 00:00:00 2001 From: Chadwick Boulay Date: Thu, 8 May 2025 16:17:23 -0400 Subject: [PATCH 04/11] playback - add docstring and update code comments --- src/pyxdf/cli/playback_lsl.py | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/src/pyxdf/cli/playback_lsl.py b/src/pyxdf/cli/playback_lsl.py index df1b40e..340fb6e 100644 --- a/src/pyxdf/cli/playback_lsl.py +++ b/src/pyxdf/cli/playback_lsl.py @@ -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 " @@ -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: @@ -175,7 +185,8 @@ def main( 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: @@ -184,7 +195,6 @@ 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() @@ -211,7 +221,6 @@ def main( 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: From 670c7040b4b5e8bdfa1bdcee49cfbf2d4a18f13c Mon Sep 17 00:00:00 2001 From: Chadwick Boulay Date: Thu, 8 May 2025 16:17:50 -0400 Subject: [PATCH 05/11] playback - Fix issue with early termination. --- src/pyxdf/cli/playback_lsl.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/pyxdf/cli/playback_lsl.py b/src/pyxdf/cli/playback_lsl.py index 340fb6e..65391a2 100644 --- a/src/pyxdf/cli/playback_lsl.py +++ b/src/pyxdf/cli/playback_lsl.py @@ -202,12 +202,10 @@ def main( 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 = 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] @@ -223,7 +221,9 @@ def main( ) 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() From 5013b50fc9a928f34fadca70ad0008f74a646104 Mon Sep 17 00:00:00 2001 From: Chadwick Boulay Date: Thu, 8 May 2025 16:31:19 -0400 Subject: [PATCH 06/11] playback fix - Changelog entry --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d288c95..841a94a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 From 7f411d715704e5c53b99b423f8e69b617ad5a8e5 Mon Sep 17 00:00:00 2001 From: Chadwick Boulay Date: Sat, 10 May 2025 12:18:04 -0400 Subject: [PATCH 07/11] playback - do not require source_id --- src/pyxdf/cli/playback_lsl.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pyxdf/cli/playback_lsl.py b/src/pyxdf/cli/playback_lsl.py index 65391a2..2233f20 100644 --- a/src/pyxdf/cli/playback_lsl.py +++ b/src/pyxdf/cli/playback_lsl.py @@ -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: From fe67ea9e0e517655bd1a3bc79b918f8e527998f8 Mon Sep 17 00:00:00 2001 From: Chadwick Boulay Date: Sat, 10 May 2025 12:27:22 -0400 Subject: [PATCH 08/11] playback - add pytest --- test/test_lsl_playback.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 test/test_lsl_playback.py diff --git a/test/test_lsl_playback.py b/test/test_lsl_playback.py new file mode 100644 index 0000000..70e7d9a --- /dev/null +++ b/test/test_lsl_playback.py @@ -0,0 +1,19 @@ +import importlib +from pathlib import Path + +import pytest + + +path = Path(__file__).parents[1] / "example-files" / "minimal.xdf" + + +@pytest.mark.skipif(not path.exists(), reason="File not found.") +@pytest.mark.skipif( + not importlib.util.find_spec("pylsl"), reason="requires the pylsl library" +) +def test_lsl_playback(): + """ + Test the LSL playback functionality. + """ + from pyxdf.cli.playback_lsl import main as playback_main + playback_main(str(path), playback_speed=10.0, loop=False, wait_for_consumer=False) From c9143c1c3e7970049c19f36d27b1cd7826680f23 Mon Sep 17 00:00:00 2001 From: Chadwick Boulay Date: Sat, 10 May 2025 12:28:31 -0400 Subject: [PATCH 09/11] ruff format --- test/test_lsl_playback.py | 1 + 1 file changed, 1 insertion(+) diff --git a/test/test_lsl_playback.py b/test/test_lsl_playback.py index 70e7d9a..3f59592 100644 --- a/test/test_lsl_playback.py +++ b/test/test_lsl_playback.py @@ -16,4 +16,5 @@ def test_lsl_playback(): Test the LSL playback functionality. """ from pyxdf.cli.playback_lsl import main as playback_main + playback_main(str(path), playback_speed=10.0, loop=False, wait_for_consumer=False) From a9dec9e4e412914cbfc2a99f908f89951d364337 Mon Sep 17 00:00:00 2001 From: Chadwick Boulay Date: Sat, 10 May 2025 12:36:06 -0400 Subject: [PATCH 10/11] Test GHA now installs liblsl --- .github/workflows/test.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 5435f39..d0aa32a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -8,6 +8,10 @@ on: - main workflow_dispatch: +env: + LSL_RELEASE_URL: "https://github.com/sccn/liblsl/releases/download/v1.16.2" + LSL_RELEASE: "1.16.2" + jobs: style: name: Check style @@ -30,6 +34,12 @@ jobs: - uses: actions/checkout@v4 - name: Install uv uses: astral-sh/setup-uv@v4 + - name: Install liblsl + run: | + sudo apt install -y libpugixml-dev + echo ${LSL_RELEASE_URL}/liblsl-${LSL_RELEASE}-$(lsb_release -sc)_amd64.deb + curl -L ${LSL_RELEASE_URL}/liblsl-${LSL_RELEASE}-$(lsb_release -sc)_amd64.deb -o liblsl.deb + sudo apt install ./liblsl.deb - name: Build run: uv sync --all-extras - name: Run tests From a8c58f1fa50280432c56d4f0a8be2c05d2b98784 Mon Sep 17 00:00:00 2001 From: Chadwick Boulay Date: Fri, 16 May 2025 23:57:20 -0400 Subject: [PATCH 11/11] Remove pylsl playback test --- .github/workflows/test.yml | 10 ---------- test/test_lsl_playback.py | 20 -------------------- 2 files changed, 30 deletions(-) delete mode 100644 test/test_lsl_playback.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d0aa32a..5435f39 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -8,10 +8,6 @@ on: - main workflow_dispatch: -env: - LSL_RELEASE_URL: "https://github.com/sccn/liblsl/releases/download/v1.16.2" - LSL_RELEASE: "1.16.2" - jobs: style: name: Check style @@ -34,12 +30,6 @@ jobs: - uses: actions/checkout@v4 - name: Install uv uses: astral-sh/setup-uv@v4 - - name: Install liblsl - run: | - sudo apt install -y libpugixml-dev - echo ${LSL_RELEASE_URL}/liblsl-${LSL_RELEASE}-$(lsb_release -sc)_amd64.deb - curl -L ${LSL_RELEASE_URL}/liblsl-${LSL_RELEASE}-$(lsb_release -sc)_amd64.deb -o liblsl.deb - sudo apt install ./liblsl.deb - name: Build run: uv sync --all-extras - name: Run tests diff --git a/test/test_lsl_playback.py b/test/test_lsl_playback.py deleted file mode 100644 index 3f59592..0000000 --- a/test/test_lsl_playback.py +++ /dev/null @@ -1,20 +0,0 @@ -import importlib -from pathlib import Path - -import pytest - - -path = Path(__file__).parents[1] / "example-files" / "minimal.xdf" - - -@pytest.mark.skipif(not path.exists(), reason="File not found.") -@pytest.mark.skipif( - not importlib.util.find_spec("pylsl"), reason="requires the pylsl library" -) -def test_lsl_playback(): - """ - Test the LSL playback functionality. - """ - from pyxdf.cli.playback_lsl import main as playback_main - - playback_main(str(path), playback_speed=10.0, loop=False, wait_for_consumer=False)