A lightweight command-line bridge for reading Cambridge Electronic Design
(CED) Spike2 data files. sonpipe extracts data from Spike2 files with CED's
sonpy library (GPLv3) and streams it as
raw binary bytes to standard output, so a host environment — MATLAB, Python,
or anything else that can run a subprocess — can ingest it in chunks: quickly,
predictably, and cross-platform.
It supports both Spike2 file formats transparently:
- 32-bit
.smr(legacy "son32") - 64-bit
.smrx("son64")
The command-line tools imitate the reading functions of the
ndr.format.ced package in NDR-matlab,
and a companion MATLAB package (+sonpipe) provides drop-in analogues of those
functions that call the CLI for you.
Talking to sonpy directly is harder than it looks — and, as the next section
explains, that is true even when the host is itself written in Python.
-
Interpreter and architecture isolation. The reader runs in its own process, invoked by a system call. It never shares the host's memory space, so there are no version locks or environment conflicts — and, critically, the reader process does not have to be the same Python, or even the same CPU architecture, as the host.
-
Crash containment.
sonpyis a compiled C++ library that, on some files and channels, fails an internal assertion and callsabort()(SIGABRT) rather than raising a catchable error. In-process that takes the host down with it. Out-of-process it is just a dead child, and the completion sentinel described under Troubleshooting turns it into an ordinary error instead of silently truncated data. -
Licensing via pip. CED's
sonpyis licensed under the GPL v3. sonpipe does not vendor it; insteadpip install sonpipedeclaressonpyas a dependency, so pip fetches the official build from PyPI. Keeping it a runtime dependency (rather than bundling) leaves sonpipe's own MIT distribution free of GPL copyleft. (On Apple Silicon, CED's current wheel is x86_64-only — see the Apple Silicon note under Installation.) -
No text-parsing overhead. Waveforms and event times are written as raw little-endian binary, not JSON/CSV text. The host captures the byte stream and reinterprets it directly (MATLAB
typecast/fread, NumPyfrombuffer) with no number→text→number round-trips. -
Controlled chunking. The host drives ingestion, requesting blocks by sample index (
--start/--count) or time window (--t0/--t1), keeping memory usage low and predictable even for multi-gigabyte recordings.
The obvious question from Python is why not skip the subprocess and
import sonpy. Usually you cannot. CED publishes sonpy only as prebuilt
binaries, and the current release (1.9.12) covers this much:
| Platform | Wheels |
|---|---|
Linux x86_64 (manylinux_2_39) |
CPython 3.14 only |
macOS (universal2) |
CPython 3.14 only |
| Windows x86_64 | CPython 3.9 – 3.14 |
| Linux aarch64 | none |
Older releases do not fill the gaps, because each is pinned by
Requires-Python to exactly one minor version — 1.7.x to 3.7, 1.8.x to 3.8,
1.9.1 through 1.9.5 to 3.9. There is an sdist, but it cannot be built from
source: the SON64 library it wraps is CED's proprietary binary.
The practical result is that on Linux and macOS no sonpy release installs
at all on CPython 3.10 through 3.13 — pip install sonpy there fails
outright, rather than picking an older version — and on Linux aarch64 none
installs on any version. Apple Silicon is the sharpest case: the
macOS wheel is x86_64-only despite its universal2 label, and is linked against
the python.org framework build, so a native arm64 interpreter can never
import it — no Python version fixes that. Spawning an x86_64 process under
Rosetta is the only mechanism that works.
The subprocess boundary is what makes those constraints someone else's problem.
sonpipe lives in its own environment — the right Python, the right
architecture, the arch -x86_64 wrapper where one is needed — and the host
talks to it over stdout. A Python 3.11 host on an M-series Mac can read .smrx
files it could not otherwise open at all, and it does not have to pin its own
interpreter to CED's release schedule to keep doing so.
A Python host should therefore drive the CLI the same way MATLAB does, and
should apply the same completion-sentinel check: subprocess.returncode has the
identical blind spot, since the arch -x86_64 wrapper can mask a signal death
as a zero exit status.
The install scripts set up sonpipe in an isolated virtual environment and
put the sonpipe command on your PATH, so it never collides with other Python
packages. From a checkout of this repo:
# Linux / macOS
./install.sh# Windows (PowerShell)
./install.ps1 -AddToPathOn Linux/macOS this creates a venv at ~/.local/share/sonpipe/venv and links
the command at ~/.local/bin/sonpipe. On Windows the venv lives under
%LOCALAPPDATA%\sonpipe. Both print the exact sonpipe.executable(...) line to
paste into MATLAB. Run ./install.sh --help for options (custom prefix, Python,
installing from PyPI, etc.).
Updating. After a git pull, re-run ./install.sh (or ./update.sh) — it
reuses the existing environment and upgrades the sonpipe package in place, which
is fast and doesn't re-download sonpy. Use ./install.sh --recreate to force
a clean rebuild.
Python version. CED ships
sonpywheels for Python 3.14 on Linux and macOS (and Python 3.9–3.14 on Windows). The installer prefers apython3.14interpreter; ifsonpycannot be imported it tells you to install 3.14 and re-run with--python "$(command -v python3.14)".Apple Silicon. CED's macOS
sonpyis x86_64-only (despite itsuniversal2label) and is linked against the official python.org framework build — so it will not load under Homebrew oruvPython. On an Apple Silicon Mac:
- Install Python 3.14 from python.org (the universal2 installer).
- Run
./install.sh— it detects Apple Silicon, finds that framework Python automatically, builds an x86_64 venv under Rosetta 2, and installs thesonpipecommand as anarch -x86_64wrapper so it runs correctly even when launched from MATLAB.Everything else (the CLI logic, the MATLAB package, and the fake-sonpy test suites) runs natively on arm64.
pip install sonpipe # once published to PyPI
pip install . # from a checkout
sonpipe --versionThis also installs sonpy (from CED, via PyPI) and numpy. For an isolated,
PATH-managed command you can alternatively use pipx install sonpipe.
Note on the
sonpylicense.sonpyis CED software licensed under the GPL v3 and distributed by CED as prebuilt binaries (the underlying SON64 C source is not published). It is fetched by pip at install time and is intentionally not bundled in this repository, so sonpipe's own MIT distribution stays free of GPL copyleft. GPL places no restrictions on use (reading your own files); obligations attach only to redistribution.
sonpipe has four sub-commands. Metadata commands emit JSON; read emits raw
binary (except for markers, which are JSON).
| Sub-command | NDR-matlab analogue |
|---|---|
sonpipe header |
ndr.format.ced.read_SOMSMR_header |
sonpipe sampleinterval |
ndr.format.ced.read_SOMSMR_sampleinterval |
sonpipe read |
ndr.format.ced.read_SOMSMR_datafile |
sonpipe channels |
(convenience listing) |
sonpipe header recording.smrx --prettyChannels are reported by their Spike2 channel number (1-based). The kind
field is the CED data-type code (see table below), which matches both sonpy's
DataType enum and NDR-matlab's channelinfo.kind.
sonpipe sampleinterval recording.smrx -c 21
# {"channel":21,"sampleinterval":4e-05,"samplerate":25000.0,"total_samples":...,"total_time":...}Waveform, by sample block (raw little-endian double, scaled to real units):
sonpipe read recording.smrx -c 21 --start 0 --count 500000 > block0.binWaveform, by time window; raw unscaled 16-bit ADC values:
sonpipe read recording.smrx -c 21 --t0 0 --t1 10 --raw > adc.binEvent channel (event times in seconds, as double):
sonpipe read recording.smrx -c 24 > events.binMarker / TextMark channel (JSON — times, code bytes, optional text):
sonpipe read recording.smrx -c 30Useful read flags: --raw (int16 ADC values), --dtype
{double,single,int16,int32,int64}, --endian {little,big,native}, --json
(debugging), --no-size-warning.
Large reads. Because the pipe can be slow for very large transfers, sonpipe prints a warning to stderr when a read exceeds 50 MB. Read in smaller blocks, or pass
--no-size-warningto silence it.
sonpipe channels recording.smrx
# 21 Adc analog_in 25000.0000 Hz Vm| kind | name | ndr_type | read as |
|---|---|---|---|
| 1 | Adc | analog_in | binary waveform (int16→real) |
| 2/3/4 | Event* | event | binary event times (double) |
| 5 | Marker | mark | JSON (time + code bytes) |
| 6 | AdcMark | mark | JSON (WaveMark) |
| 7 | RealMark | mark | JSON |
| 8 | TextMark | text | JSON (time + text) |
| 9 | RealWave | analog_in | binary waveform (float) |
The matlab/+sonpipe package provides drop-in analogues of the
ndr.format.ced.* functions, backed by the CLI.
Setup
pip install sonpipe- Add the folder that contains
+sonpipeto the MATLAB path:addpath('/path/to/sonpipe/matlab') - If the
sonpipecommand is not on the system PATH, tell MATLAB where it is:sonpipe.executable('/full/path/to/sonpipe') % or 'python3 -m sonpipe'
Example
f = '/data/recording.smrx'; % or a legacy .smr file
h = sonpipe.read_SOMSMR_header(f); % file + channel header
sr = 1 / sonpipe.read_SOMSMR_sampleinterval(f, h, 21);
% Read waveform channel 21 from t = 0 to t = 100 s
[data, total_samples, total_time, ~, t] = ...
sonpipe.read_SOMSMR_datafile(f, h, 21, 0, 100);
plot(t, data); xlabel('Time (s)'); ylabel(h.channelinfo(1).units);Because these mirror ndr.format.ced.*, existing code often ports by swapping
the package prefix (ndr.format.ced → sonpipe).
Helpers: sonpipe.channels(f) (NDR-style channel struct array),
sonpipe.channelinfo(h, n) (one channel's header entry), sonpipe.executable
(locate/set the CLI command).
CED's sonpy is a compiled C++ library. On some files/channels it fails an
internal assertion and calls abort() (SIGABRT) instead of raising a
Python error. abort() cannot be caught with try/except — it terminates
the whole reader process immediately — so there is no Python traceback, and the
host may otherwise see only truncated or empty output.
Two mechanisms help you catch and locate such a crash:
-
The host layer detects it. A successful
readprints a completion sentinel (sonpipe: wrote N …) to stderr as its final act. The MATLABinvoke_binaryhelper requires that sentinel and checks thatNmatches the bytes captured; if the reader died mid-stream (even when an intermediatearch -x86_64wrapper masks the non-zero exit status), you get asonpipe:crash/sonpipe:truncatederror naming the exact command instead of silently short data. Any host should do the same — a process exit status alone is not enough to notice this, in MATLAB or anywhere else. -
Breadcrumb logging pinpoints where it crashed. Set the
SONPIPE_LOGenvironment variable and re-run the command that crashes. sonpipe writes one line immediately before and after every call intosonpy, flushed to disk so it survives theabort(). The last line in the log is then thesonpycall — with its exact arguments — that triggered the crash.# shell SONPIPE_LOG=1 sonpipe read recording.smrx -c 21 --t0 100 --t1 110 > /dev/null # -> logs to ~/.local/var/log/sonpipe-<uid>.log SONPIPE_LOG=/tmp/sonpipe.log sonpipe read … # or an explicit path
% MATLAB: turn on for the session, re-run the failing read, then turn off setenv('SONPIPE_LOG', '1'); ... % the call that crashes setenv('SONPIPE_LOG', '');
Accepted values:
1/true/on→ default path~/.local/var/log/sonpipe-<uid>.log; any other value → that path (~is expanded); unset/0/false/off→ disabled (zero overhead).Every call into sonpy is logged —
SonFile(open), the metadata accessors (ChannelType,ChannelDivide,ChannelMaxTime,GetChannelScale, …), the reads (ReadInts/ReadFloats/ReadEvents/ marker reads), and theClose/teardown — plus adoneline when the command finishes cleanly. Reading the last line tells you where it died:- ends on a dangling
-> ReadInts args=…(no matching<- ReadInts) — that sonpy read aborted; theread_waveform/read_events/read_markerscontext line just above shows the resolvedtfrom/tupto/nmax, so you can see the exact arguments sonpipe passed; - ends on a dangling
-> Close/del SonFile— sonpy aborted while releasing the file handle (a teardown-order assertion); - ends on
done command=… rc=0followed byhard_exit rc=0— the command completed and the data is valid.
Shutdown-crash workaround. On some files CED's sonpy passes an internal assertion during normal work but then calls
abort()(SIGABRT) during Python's interpreter shutdown — in a static/atexitdestructor that runs after the command has already finished and delivered all of its output. That abort cannot be caught from Python, but it also does no harm to the result. sonpipe therefore does two things to keep a finished read from turning into a crash:- it closes the sonpy file handle explicitly at the end of each command, while the interpreter is still healthy (rather than at garbage collection);
- the process entry point (
sonpipe.cli:run, used by both thesonpipecommand andpython -m sonpipe) flushes all output and then callsos._exit(), which terminates immediately without running the interpreter-shutdown code where sonpy aborts.
Because every stream is flushed before the hard exit, no data is lost; the crash simply never happens. The
hard_exitbreadcrumb marks this point in the log. (main()itself does not hard-exit, so importing and calling it in-process — as the tests do — is unaffected.) - ends on a dangling
Python (CLI) tests use a fake sonpy shim, so they run anywhere:
pip install -e . --no-deps
pip install numpy pytest
pytest -qMATLAB tests live in test/+sonpipe/+unittest and drive a fake CLI
(fakecli.py, which runs the real sonpipe code against synthetic data), so
they too need no CED binaries — only Python + numpy:
addpath('matlab'); addpath('test');
results = runtests('sonpipe.unittest');The default suites use no real Spike2 files. tests/fakesonpy.py is a
pure-Python stand-in for CED's sonpy that serves synthetic in-memory channels
(a ramp waveform, a sine RealWave, events, markers, text markers); the MATLAB
tests drive fakecli.py, which runs the real sonpipe code against that same
fake. This keeps the suites deterministic and lets them run everywhere —
including Linux, where CED's sonpy does not install cleanly.
To additionally validate the real sonpy binding, there is an integration
suite that runs the actual CED sonpy end-to-end (both the SmrxFile wrapper and
the CLI as a subprocess) against the checked-in example/spike2data.smrx. It is
skipped unless sonpy is importable; point SONPIPE_TEST_FILE at another file
to use your own:
pip install sonpy # real CED sonpy (Python 3.14 on Linux/macOS)
pytest -m integration # uses example/spike2data.smrx by defaultIn CI, the integration suite runs on Linux, Windows, and macOS Apple Silicon
using Python 3.14. On the macOS runner it exercises the x86_64 sonpy under
Rosetta 2 (arch -x86_64), since CED ships no native arm64 build.
Continuous integration (see .github/workflows/) mirrors the matbox style used
by NDR-matlab and runs on Linux, Windows, and macOS Apple Silicon. The
macOS runner covers both architectures: the CLI and MATLAB suites run natively
on arm64, while the real-sonpy suite runs x86_64 under Rosetta 2:
- CLI tests build the Python package (
compileall+python -m build) and run the pytest suite. - MATLAB tests run the
matlab.unittestsuite (which drives the CLI via a fake). matlab-actions provides MathWorks licensing for free on public repos; no secret is needed.
The workflows trigger on pushes to main, pull requests targeting main, and
manual dispatch.
sonpipe/
├── src/sonpipe/ # Python package (CLI + sonpy wrapper)
├── tests/ # Python tests (fake sonpy + real-sonpy integration)
├── matlab/+sonpipe/ # MATLAB client package (imitates ndr.format.ced.*)
├── test/+sonpipe/+unittest/# MATLAB unit tests + fake CLI
├── example/spike2data.smrx # a real 64-bit Spike2 file used by integration tests
└── .github/workflows/ # cross-platform CI (CLI, MATLAB, real-sonpy)
sonpipe is released under the MIT License (see LICENSE). It depends on, but
does not include, CED's sonpy library, which is licensed separately under the
GPL v3.