From 593ef50916844aab309a13803ead974e7a18b0bd Mon Sep 17 00:00:00 2001 From: Stefan Appelhoff Date: Mon, 15 Jun 2026 21:43:27 +0200 Subject: [PATCH 1/4] perf(inlet): speed up pull_chunk with bulk buffer slicing Convert the ctypes data and timestamp buffers to Python lists with a single bulk slice instead of indexing element-by-element inside a nested comprehension. This is ~3-4x faster at extracting multi-channel chunks (measured on the extraction step alone) and produces byte-identical output. Also use integer floor division for the sample count instead of float division plus repeated int() truncation. --- src/pylsl/inlet.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/pylsl/inlet.py b/src/pylsl/inlet.py index 95c03a9..029d99e 100644 --- a/src/pylsl/inlet.py +++ b/src/pylsl/inlet.py @@ -263,18 +263,22 @@ def pull_chunk(self, timeout=0.0, max_samples=1024, dest_obj=None): handle_error(errcode) # return results (note: could offer a more efficient format in the # future, e.g., a numpy array) - num_samples = num_elements / num_channels + num_samples = num_elements // num_channels if dest_obj is None: + # Convert the whole ctypes buffer to a Python list in a single + # bulk slice (far faster than indexing the array element by + # element), then split it into one list per sample. + flat = data_buff[:num_elements] samples = [ - [data_buff[s * num_channels + c] for c in range(num_channels)] - for s in range(int(num_samples)) + flat[s * num_channels : (s + 1) * num_channels] + for s in range(num_samples) ] if self.channel_format == cf_string: samples = [[v.decode("utf-8") for v in s] for s in samples] free_char_p_array_memory(data_buff, max_values) else: samples = None - timestamps = [ts_buff[s] for s in range(int(num_samples))] + timestamps = ts_buff[:num_samples] return samples, timestamps def samples_available(self): From 4528b80c23f0738534bb63d423314908d5c9187b Mon Sep 17 00:00:00 2001 From: Stefan Appelhoff Date: Tue, 16 Jun 2026 10:33:21 +0200 Subject: [PATCH 2/4] perf(inlet): drop the explanatory comment on the bulk slice The rationale lives in the commit history; the code itself does not need it. Addresses review feedback on #111. --- src/pylsl/inlet.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/pylsl/inlet.py b/src/pylsl/inlet.py index 029d99e..6dbfdd7 100644 --- a/src/pylsl/inlet.py +++ b/src/pylsl/inlet.py @@ -265,9 +265,6 @@ def pull_chunk(self, timeout=0.0, max_samples=1024, dest_obj=None): # future, e.g., a numpy array) num_samples = num_elements // num_channels if dest_obj is None: - # Convert the whole ctypes buffer to a Python list in a single - # bulk slice (far faster than indexing the array element by - # element), then split it into one list per sample. flat = data_buff[:num_elements] samples = [ flat[s * num_channels : (s + 1) * num_channels] From 396eee08dce93e23b80527319734bb03cfe59f37 Mon Sep 17 00:00:00 2001 From: Stefan Appelhoff Date: Tue, 16 Jun 2026 10:33:21 +0200 Subject: [PATCH 3/4] test(inlet): add pull_chunk round-trip test Cover the two paths the bulk-slice extraction must preserve: a multi-channel numeric chunk and a variable-length string chunk. Pushes a known chunk and pulls it back, asserting identical values, list[list] shape, and timestamp list type. The string case (empty and multi-byte values) exercises the cf_string decode path that previously lacked coverage. --- test/test_pull_chunk.py | 67 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 test/test_pull_chunk.py diff --git a/test/test_pull_chunk.py b/test/test_pull_chunk.py new file mode 100644 index 0000000..0b3e751 --- /dev/null +++ b/test/test_pull_chunk.py @@ -0,0 +1,67 @@ +"""Round-trip test for StreamInlet.pull_chunk. + +Pushes a known chunk and pulls it back, asserting the extracted data is +identical in value, shape, and type. Covers both paths the bulk-slice +extraction must preserve: a multi-channel numeric chunk and a +variable-length string ("Markers"-style) chunk. +""" + +import time + +import pytest + +import pylsl + +# (channel_format, samples) — distinct values per channel/sample, including +# empty and multi-byte strings to exercise variable-length decoding. +CASES = { + "double64": ( + pylsl.cf_double64, + [[1.0, 2.5, -3.25], [4.0, 5.5, 6.75], [7.0, 8.5, 9.25]], + ), + "string": ( + pylsl.cf_string, + [["a", "bb", ""], ["ccc", "dddd", "e"], ["", "f", "ééé"]], + ), +} + + +@pytest.mark.parametrize("channel_format,samples", CASES.values(), ids=CASES.keys()) +def test_pull_chunk_roundtrip(channel_format: int, samples: list): + n_samples = len(samples) + n_channels = len(samples[0]) + + info = pylsl.StreamInfo( + name="test_pull_chunk", + type="test", + channel_count=n_channels, + nominal_srate=0, + channel_format=channel_format, + source_id="test_pull_chunk_id", + ) + outlet = pylsl.StreamOutlet(info) + + streams = pylsl.resolve_byprop("source_id", "test_pull_chunk_id", timeout=2) + assert streams, "outlet was not discovered" + inlet = pylsl.StreamInlet(streams[0]) + + # Subscribe before pushing so the only chunk isn't sent before the inlet's + # data connection is established. + inlet.open_stream(timeout=2) + time.sleep(0.5) + outlet.push_chunk(samples) + + # Data may arrive in more than one chunk; collect until complete. + got_samples: list = [] + got_ts: list = [] + deadline = time.time() + 5 + while len(got_samples) < n_samples and time.time() < deadline: + chunk, stamps = inlet.pull_chunk(timeout=1.0) + if chunk: + assert isinstance(stamps, list) + assert all(isinstance(row, list) for row in chunk) + got_samples.extend(chunk) + got_ts.extend(stamps) + + assert got_samples == samples + assert len(got_ts) == n_samples From 7948d3619889bf5d2651dafa662a94d444f54eb1 Mon Sep 17 00:00:00 2001 From: Stefan Appelhoff Date: Tue, 16 Jun 2026 10:33:21 +0200 Subject: [PATCH 4/4] chore: ignore local Claude Code config .claude/ and CLAUDE.md are local tooling artifacts that should not be tracked. --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index ecc95a7..f741155 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,6 @@ uv.lock /src/pylsl/__version__.py /src/pylsl/lib/lslver.exe liblsl.zip + +.claude +CLAUDE.md