biodream
biodream is a zero-copy, streaming-capable Rust toolkit for reading and
writing BIOPAC AcqKnowledge .acq
files across all known format versions (v30 through v84+).
It replaces the Python bioread library, the C# AckReader, and the Windows-only ACKAPI DLL with a single cross-platform Rust crate that handles compressed and uncompressed files, mixed sampling rates, event markers, journals, and foreign data sections.
Why biodream?
| Capability | biodream | bioread |
|---|---|---|
| Read uncompressed .acq | ✅ | ✅ |
| Read compressed .acq | ✅ | ✅ |
| Mixed sampling rates | ✅ | ✅ (fixed in 1.0.0) |
| Typed errors with byte offsets | ✅ | ❌ (silent swallow) |
| Round-trip write | ✅ | ❌ |
| no_std / WASM | ✅ (core parser) | ❌ |
| CSV export | ✅ | ✅ |
| Apache Arrow IPC | ✅ | ❌ |
| Parquet | ✅ | ❌ |
| HDF5 | ✅ (opt-in) | ✅ |
| Format versions | v30–v84+ | v30–v84+ |
Design principles
- Zero-copy streaming — the chunked reader never buffers more than one interleave pattern; large recordings don't blow up memory.
- Typed errors — every
BiopacErrorvariant carries the byte offset and expected-vs-actual value so corrupt files can be triaged precisely. - Parse, don't validate — untrusted input is parsed once at the adapter boundary into typed domain values; raw bytes never leak into application code.
- Feature-gated footprint —
default = ["read", "csv"]; Arrow, Parquet, HDF5, write support, and serde are opt-in. no_stdcore — the parser and domain modules compile under#![no_std]withalloc; only I/O adapters and the CLI requirestd.
Repository layout
src/
lib.rs public API surface
domain/ typed value objects — no I/O
parser/ binary layout knowledge (binrw)
export/ CSV, Arrow, Parquet, HDF5
cli/ biopac binary entry point
examples/ runnable examples
tests/ integration tests + 14 synthetic fixture files
book/ this documentation
docs/
adr/ architectural decision records
dev/ developer notes
Installation
Library
Add biodream to your Cargo.toml:
[dependencies]
biodream = "0.2"
With optional features:
[dependencies]
biodream = { version = "0.2", features = ["arrow", "parquet", "write"] }
See Feature Flags for the full list.
CLI
Install the biopac binary:
cargo install biodream
Or build from source:
git clone https://github.com/greysquirr3l/biodream
cd biodream
cargo build --release --features "read,write,csv,arrow,parquet"
# binary is at target/release/biopac
Pre-built binaries
Pre-built binaries for Linux x86-64, macOS ARM, macOS x86, and Windows x86-64 are attached to each GitHub Release.
MSRV
Rust 1.95.0 stable. Edition 2024.
HDF5 system dependency
The hdf5 feature requires libhdf5-dev to be installed on the system:
# Ubuntu / Debian
sudo apt-get install libhdf5-dev
# macOS
brew install hdf5
# Windows: download from https://www.hdfgroup.org/downloads/hdf5/
If the HDF5 library is installed in a non-default location, set one of:
# Preferred: point directly at the HDF5 installation root
export HDF5_DIR="/path/to/hdf5"
# Alternative: make pkg-config aware of HDF5
export PKG_CONFIG_PATH="/path/to/hdf5/lib/pkgconfig:${PKG_CONFIG_PATH}"
Quick verification:
pkg-config --modversion hdf5 || echo "hdf5 not found via pkg-config"
Quick Start
Reading a file
#![allow(unused)] fn main() { use biodream::read_file; let result = read_file("recording.acq")?; // Non-fatal parse warnings (unknown section types, unexpected padding, etc.) for w in &result.warnings { eprintln!("warning: {w}"); } let df = result.into_value(); println!("{}", df.summary()); for channel in &df.channels { let samples = channel.scaled_samples(); println!( "{}: {} samples @ {} Hz", channel.name, samples.len(), channel.samples_per_second, ); } }
Reading from a stream
Any Read + Seek source works:
#![allow(unused)] fn main() { use std::io::Cursor; use biodream::read_stream; let bytes: Vec<u8> = std::fs::read("recording.acq")?; let result = read_stream(Cursor::new(&bytes))?; let df = result.into_value(); }
Lazy / streaming reader
For large files, load only channel headers up front and stream samples on demand:
#![allow(unused)] fn main() { use biodream::{LazyDatafile, ReadOptions}; let opts = ReadOptions::default(); let lazy = LazyDatafile::open("recording.acq", &opts)?; println!("channels: {}", lazy.channel_count()); // Load one channel at a time — never buffers the entire file for i in 0..lazy.channel_count() { let ch = lazy.load_channel(i)?; println!("{}: {} samples", ch.name, ch.scaled_samples().len()); } }
Accessing markers
#![allow(unused)] fn main() { let df = biodream::read_file("recording.acq")?.into_value(); for marker in &df.markers { println!( "[{:.3}s] {} — {}", marker.time_ms / 1000.0, marker.style.label, marker.text.as_deref().unwrap_or(""), ); } }
Error handling
All errors are typed BiopacError variants carrying the byte offset where the
problem was detected:
#![allow(unused)] fn main() { use biodream::BiopacError; match biodream::read_file("corrupt.acq") { Ok(r) => { /* ... */ } Err(BiopacError::UnexpectedEof { offset, expected }) => { eprintln!("truncated at byte {offset}: expected {expected} more bytes"); } Err(e) => eprintln!("parse error: {e}"), } }
Feature Flags
| Flag | Default | Description |
|---|---|---|
read | ✅ | Read .acq files. Enables the parser, domain types, and read_file / read_stream API. Requires std. |
csv | ✅ | CSV export via to_csv / CsvOptions. |
write | ❌ | Write .acq files. Round-trip fidelity: read → modify → write → diff = clean. |
arrow | ❌ | Apache Arrow IPC export (to_arrow_ipc). Compatible with Polars, R arrow, and Julia. |
parquet | ❌ | Parquet export (to_parquet). Requires arrow. Compatible with DuckDB, Spark, Pandas. |
hdf5 | ❌ | HDF5 export (to_hdf5). Requires the libhdf5-dev system library. |
serde | ❌ | Serialize / Deserialize derives on all public domain types. |
Combining features
# Read + write + all export formats
biodream = { version = "0.2", features = ["write", "arrow", "parquet", "serde"] }
# Minimal read-only, no CSV
biodream = { version = "0.2", default-features = false, features = ["read"] }
# no_std core (parser + domain only — no I/O or export)
biodream = { version = "0.2", default-features = false }
Feature dependency graph
parquet → arrow → read
write → read
csv → read
hdf5 → read
serde (standalone)
CI matrix
The CI suite tests with --features "read,write,csv,arrow,parquet,serde".
The hdf5 feature is excluded from CI because it requires a system library;
it is tested separately in the full integration suite.
Reading .acq Files
API surface
The read feature exposes three entry points:
| Function | Description |
|---|---|
biodream::read_file(path) | Read a .acq file from disk. |
biodream::read_stream(reader) | Read from any Read + Seek. |
biodream::LazyDatafile::open(path, opts) | Deferred streaming reader. |
All return ParseResult<Datafile> (or LazyDatafile for the lazy variant).
ParseResult and warnings
Biodream distinguishes between fatal errors (returned as Err(BiopacError))
and non-fatal warnings (accumulated in ParseResult::warnings). Warnings
cover things like unknown section types, unexpected padding bytes, or fields
that are out of expected range but still parse cleanly.
#![allow(unused)] fn main() { let result = biodream::read_file("recording.acq")?; if !result.is_clean() { for w in &result.warnings { eprintln!("warning: {w}"); } } // Consumes result, giving you the Datafile let df = result.into_value(); }
Always check result.warnings — a clean parse on a healthy file returns zero
warnings.
Datafile structure
Datafile
├── metadata: GraphMetadata — title, date/time, byte order, revision
├── channels: Vec<Channel> — ordered by channel index
├── markers: Vec<Marker> — event markers (may be empty)
└── journal: Option<Journal> — free-text journal section
GraphMetadata
#![allow(unused)] fn main() { let meta = &df.metadata; println!("recorded: {}", meta.recorded_at); // chrono::NaiveDateTime println!("revision: {}", meta.revision); // FileRevision (v30–v84+) println!("byte order: {:?}", meta.byte_order); // ByteOrder::LittleEndian / BigEndian }
Channel
#![allow(unused)] fn main() { for ch in &df.channels { // Scaled f64 samples (raw i16 converted via scale + offset) let samples: Vec<f64> = ch.scaled_samples(); // Raw i16 samples without scaling let raw: &[i16] = ch.raw_samples(); println!( "{} [{}]: {} samples @ {} Hz", ch.name, ch.units, samples.len(), ch.samples_per_second, ); } }
Channels with a sampling rate lower than the base rate are upsampled to the
base rate via linear interpolation in scaled_samples().
Mixed sampling rates
AcqKnowledge supports channels at different fractions of the base rate. Each
channel carries a frequency_divider; biodream computes samples_per_second
correctly for all channels regardless of their divider.
#![allow(unused)] fn main() { let base_hz = df.metadata.samples_per_second; for ch in &df.channels { println!("{}: {} Hz (divider {})", ch.name, ch.samples_per_second, ch.frequency_divider); } }
Lazy reader
For recordings with many channels or large sample buffers, LazyDatafile
reads only the channel headers on open and loads individual channel data
on demand:
#![allow(unused)] fn main() { use biodream::{LazyDatafile, ReadOptions}; let lazy = LazyDatafile::open("large.acq", &ReadOptions::default())?; // Load channels selectively — no unnecessary I/O let ecg = lazy.load_channel(0)?; let resp = lazy.load_channel(2)?; }
This is the recommended approach for batch-processing pipelines that only need a subset of channels.
Byte order
AcqKnowledge pre-4 files were often written on big-endian Mac hardware.
Biodream detects the byte order from the lVersion field sign bit and
handles both transparently. The detected order is exposed in
GraphMetadata::byte_order.
Writing .acq Files
Requires the write feature:
biodream = { version = "0.2", features = ["write"] }
Basic write
#![allow(unused)] fn main() { use biodream::{write_file, WriteOptions}; let df = biodream::read_file("input.acq")?.into_value(); // Modify channels, markers, etc. write_file(&df, "output.acq", &WriteOptions::default())?; }
Round-trip fidelity
Every write → read → write cycle produces bitwise-identical output. The test
suite verifies this against all 14 synthetic fixture files (v30–v84+, with and
without compression).
#![allow(unused)] fn main() { // Verify round-trip let original = std::fs::read("input.acq")?; let df = biodream::read_stream(std::io::Cursor::new(&original))?.into_value(); let mut buf = Vec::new(); biodream::write_stream(&df, &mut buf, &WriteOptions::default())?; assert_eq!(original, buf, "round-trip produced different bytes"); }
WriteOptions
#![allow(unused)] fn main() { use biodream::{WriteOptions, ByteOrder}; let opts = WriteOptions { // Override the output byte order (default: preserves the source file's order) byte_order: Some(ByteOrder::LittleEndian), }; }
Creating a Datafile from scratch
#![allow(unused)] fn main() { use biodream::domain::{ Datafile, GraphMetadata, Channel, ChannelData, FileRevision, ByteOrder, }; let meta = GraphMetadata { revision: FileRevision::V84, byte_order: ByteOrder::LittleEndian, samples_per_second: 1000.0, // ... ..GraphMetadata::default() }; let samples: Vec<i16> = vec![0i16; 5000]; let channel = Channel { name: "ECG".to_string(), units: "mV".to_string(), samples_per_second: 1000.0, frequency_divider: 1, data: ChannelData::Scaled { raw: samples, scale: 0.001, offset: 0.0, }, ..Channel::default() }; let df = Datafile { metadata: meta, channels: vec![channel], markers: vec![], journal: None, }; biodream::write_file(&df, "synthetic.acq", &WriteOptions::default())?; }
Compression
Compressed output (per-channel zlib) is supported for revision ≥ 68 files when
the source file used compression. To force compressed output on a new file,
set the appropriate flag in WriteOptions (see the API docs).
Exporting Data
CSV
Available with the default csv feature.
#![allow(unused)] fn main() { use biodream::{CsvOptions, TimeFormat}; let df = biodream::read_file("recording.acq")?.into_value(); let opts = CsvOptions { time_format: TimeFormat::Seconds, include_markers: true, delimiter: b',', }; df.to_csv("output.csv", &opts)?; }
TimeFormat
| Variant | Behavior |
|---|---|
TimeFormat::Seconds | Elapsed seconds column (default) |
TimeFormat::Milliseconds | Elapsed milliseconds column |
TimeFormat::Samples | Integer sample index column |
Multi-rate channels in CSV
When channels have different sampling rates, all channels are resampled to the base rate (highest sampling rate in the file). Resampling uses linear interpolation to fill lower-rate channels.
Apache Arrow IPC
Requires features = ["arrow"].
#![allow(unused)] fn main() { use biodream::ArrowOptions; let df = biodream::read_file("recording.acq")?.into_value(); // Write Arrow IPC stream format df.to_arrow_ipc("output.arrow", &ArrowOptions::default())?; }
Arrow IPC files can be read by:
- Python/Polars:
pl.read_ipc("output.arrow") - R:
arrow::read_ipc_file("output.arrow") - Julia:
Arrow.Table("output.arrow")
Parquet
Requires features = ["arrow", "parquet"].
#![allow(unused)] fn main() { use biodream::ParquetOptions; let df = biodream::read_file("recording.acq")?.into_value(); df.to_parquet("output.parquet", &ParquetOptions::default())?; }
Parquet output is compatible with DuckDB, Apache Spark, Pandas, and Polars.
Parquet compression
#![allow(unused)] fn main() { use biodream::ParquetOptions; use parquet::basic::Compression; let opts = ParquetOptions { compression: Compression::SNAPPY, ..ParquetOptions::default() }; df.to_parquet("output.parquet", &opts)?; }
HDF5
Requires features = ["hdf5"] and the system libhdf5-dev library.
#![allow(unused)] fn main() { use biodream::Hdf5Options; let df = biodream::read_file("recording.acq")?.into_value(); df.to_hdf5("output.h5", &Hdf5Options::default())?; }
The HDF5 file has the following layout:
/channels/{channel_name}/data — f64 dataset of scaled samples
/channels/{channel_name}/attrs — name, units, samples_per_second
/markers/ — marker dataset with time, label, text
/metadata/ — revision, recorded_at, byte_order
CLI export
All export formats are also accessible via the biopac convert command. See
CLI Reference for full details.
CLI Reference
The biopac binary is the command-line interface for biodream. It provides
four subcommands for inspecting and converting .acq files.
Usage
biopac <COMMAND> [OPTIONS] <FILE>
Global flags
| Flag | Description |
|---|---|
--help | Print help |
--version | Print version |
-v, --verbose | Verbose output (repeat for more: -vv) |
biopac info
Print a summary of the file: revision, channel count, sample rate, duration, marker count, and journal presence.
biopac info recording.acq
Output:
File: recording.acq
Revision: v84
Channels: 4
Base rate: 1000.00 Hz
Duration: 60.000 s
Markers: 12
Journal: yes
Byte order: LittleEndian
Channels:
[0] ECG 1000.00 Hz mV (divider 1)
[1] EDA 250.00 Hz µS (divider 4)
[2] RESP 125.00 Hz % (divider 8)
[3] TEMP 10.00 Hz °C (divider 100)
biopac convert
Convert a .acq file to CSV, Arrow IPC, or Parquet.
biopac convert recording.acq --format csv --output output.csv
biopac convert recording.acq --format arrow --output output.arrow
biopac convert recording.acq --format parquet --output output.parquet
Flags
| Flag | Default | Description |
|---|---|---|
--format <FORMAT> | csv | Output format: csv, arrow, parquet |
--output <PATH> | (derived) | Output file path. Defaults to input stem + new extension. |
--time-format <FMT> | seconds | Time column format: seconds, ms, samples |
--no-markers | — | Omit markers column from CSV output |
--channels <LIST> | (all) | Comma-separated channel names or indices to export |
biopac markers
Print the event marker list as tab-separated text.
biopac markers recording.acq
Output:
TIME_S LABEL TEXT
0.000 event Baseline start
30.450 stim Stimulus onset
31.200 resp Response
60.000 event End
Flags
| Flag | Description |
|---|---|
--json | Output markers as JSON array |
--csv | Output as CSV with header row |
biopac inspect
Low-level binary dump: print each section header, its type code, byte offset, and length. Useful for debugging corrupt or unusual files.
biopac inspect recording.acq
Output:
offset=0x0000 type=GraphHeader len=506
offset=0x01FA type=ChannelHeader len=182 ch=0 ECG
offset=0x02B0 type=ChannelHeader len=182 ch=1 EDA
offset=0x0366 type=ChannelHeader len=182 ch=2 RESP
offset=0x041C type=ChannelHeader len=182 ch=3 TEMP
offset=0x04D2 type=DataSection len=12000000
offset=0xB75D2 type=MarkerSection len=412
Flags
| Flag | Description |
|---|---|
--hex | Print a hex dump of each section header |
--unknown | Print unknown section types encountered (useful for format research) |
no_std Usage
The parser and domain modules compile under #![no_std] with alloc. This
makes biodream usable in embedded environments, WASM, and other
resource-constrained targets.
What's available in no_std
| Module | no_std | Notes |
|---|---|---|
biodream::domain | ✅ | All typed domain types |
biodream::parser | ✅ | Binary parser (binrw, alloc) |
biodream::export::csv | ❌ | Requires std I/O |
biodream::export::arrow | ❌ | Requires std |
biodream::export::parquet | ❌ | Requires std |
biodream::export::hdf5 | ❌ | Requires std + system lib |
biodream::read_file | ❌ | Requires std filesystem |
biodream::read_stream | ✅ | Works with any Read + Seek |
Cargo.toml setup for no_std
[dependencies]
biodream = { version = "0.2", default-features = false }
This gives you the parser + domain types only, with no I/O. You supply the bytes and the seek implementation.
WASM example
#![allow(unused)] #![no_std] fn main() { extern crate alloc; use alloc::vec::Vec; use biodream::read_stream; // In a WASM target, bytes come from the JS side pub fn parse_acq(bytes: Vec<u8>) -> Result<alloc::string::String, alloc::string::String> { let cursor = core2::io::Cursor::new(&bytes); let result = read_stream(cursor).map_err(|e| alloc::format!("{e}"))?; let df = result.into_value(); Ok(alloc::format!( "{} channels, {} Hz, {} markers", df.channels.len(), df.metadata.samples_per_second, df.markers.len(), )) } }
Note: In WASM you need a
Read + Seekshim. Thecore2crate providescore2::io::Cursorwhich works inno_std + alloc.
Allocator requirement
The alloc crate is required. You must provide a global allocator in your
final binary or WASM module:
#![allow(unused)] fn main() { // Example: wee_alloc for WASM #[global_allocator] static ALLOC: wee_alloc::WeeAlloc = wee_alloc::WeeAlloc::INIT; }
Compile verification
To verify the no_std build locally:
cargo build --target wasm32-unknown-unknown --no-default-features
Format Overview
BIOPAC AcqKnowledge .acq files are binary files used to store multi-channel
physiological data recorded with BIOPAC hardware. The format has evolved
through more than 50 revisions since the early 1990s, spanning big-endian
Classic Mac 68k builds through modern Windows/macOS 64-bit applications.
Format versions
All version identifiers are stored in the lVersion field of the graph header
as a signed 32-bit integer. Negative values indicate big-endian byte order
(legacy Mac builds).
| Version range | Era |
|---|---|
| 30–40 | Classic Mac (68k / PPC), big-endian |
| 41–66 | Windows transition era |
| 67–84 | Modern Windows/macOS |
| 84+ | Current AcqKnowledge 5.x |
File structure (top-level sections)
Every .acq file is a flat sequence of typed sections:
[GraphHeader]
[ChannelHeader × channel_count]
[CompressionHeader × channel_count] — only in revision ≥ 68
[DataSection] — interleaved sample data
[MarkerSection] — event markers (optional)
[JournalSection] — free-text journal (optional)
[ForeignDataSection …] — unknown/vendor-specific sections
Interleave pattern
Sample data is written in interleaved blocks. Each channel writes
frequency_divider worth of samples per "tick" of the base rate. A full
interleave block is:
ch0_samples[0..n0] | ch1_samples[0..n1] | … | chN_samples[0..nN]
where nI = base_samples_per_block / channel[I].frequency_divider.
Biodream's streaming reader consumes exactly one interleave block at a time, so memory usage is O(block_size) not O(file_size).
Further reading
The field-by-field binary layout (graph header, channel header, compression
header, all section types) is documented in the internal ADR and in the parser
source at src/parser/.
Rust 1.78 → 1.95 Catchup
Notes on Rust language and library changes relevant to biodream development, covering the gap from Rust 1.78 to 1.95 (edition 2024 baseline).
{{#include ../../../docs/dev/rust-catchup-1.78-1.95.0.md}}
Counterintuitive Rust Patterns
A collection of Rust patterns that frequently surprise developers coming from other languages, or from older editions of Rust.
{{#include ../../../docs/dev/rust-counterintuitive-patterns.md}}
Changelog
Changelog
All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
Unreleased
[0.2.7] - 2026-05-16
Changed
-
CI: added an optional HDF5 feature job to
.github/workflows/ci.yml. The job now probes for a system HDF5 library viapkg-configand runscargo test -p biodream --features hdf5only when available; otherwise it emits an explicit skip notice instead of failing. -
Docs: expanded HDF5 installation guidance with
HDF5_DIRandPKG_CONFIG_PATHoverrides plus apkg-configverification command.
[0.2.6] - 2026-05-16
Added
-
biopac info— lazy header load: for file-path arguments,infonow usesLazyDatafile(headers + markers only) instead of reading all sample data. Channel sample-rate is derived fromfrequency_divider;--jsonoutput includesduration_secondscomputed from sample counts. -
biopac markers— lazy header load: same optimisation asinfo; for file-path arguments, markers are read viaLazyDatafile.markerswithout loading any sample data. Stdin input continues to use the fullread_acqpath. -
biopac convert— channel selection by name:--channel-name <NAME>— select channels by exact name; may be specified multiple times. Resolves indices via a lazy header scan.--channel-contains <NEEDLE>— select the first channel whose name contains the substring (case-insensitive). Resolves indices via a lazy header scan. Conflicts with--channelsand--channel-name.
-
biopac convert— CSV output options: the CSV export now exposes the fullCsvOptionssurface through CLI flags:--time-format <seconds|milliseconds|hms>— time-column format (default:seconds)--precision <N>— decimal places for float values (default:6)--delimiter <CHAR>— field separator; accepts a single ASCII character ortab(default:,)--include-raw— emit a<name>_rawinteger column alongside each scaled column--fill-value <STR>— value written for absent samples (default: empty string)
-
biopac signalssubcommand (--features physio): new command group for physiological signal processing:biopac signals detect-peaks --channel <IDX> --fs <HZ> [--json]— loads one ECG channel, runs the Pan-Tompkins R-peak detector, and prints each peak assample<TAB>time_s(or JSON array).biopac signals ptt --ecg <IDX> --ppg <IDX> --fs <HZ> [--json]— loads ECG and PPG channels, computes per-beat PTT, and prints a summary table with median PTT and mean heart rate (or JSON object).
Added
-
physiofeature — newbiodream::signalsmodule with pure-Rust physiological signal processing algorithms (no external dependencies,no_std-compatible):rising_edges/falling_edges— detect digital trigger-pulse edges in a Sync channel by threshold crossingsync_window— returns the(start, end)sample span between the first two rising edges; useful for gating analysis to the recording windowdetect_r_peaks— Pan-Tompkins–inspired QRS R-peak detector (5-point derivative → square → 150 ms moving-window integration → adaptive percentile threshold)detect_ppg_feet— PPG pulse-onset detector (100 ms smoothing → 1 s baseline removal → local-minimum search with 300 ms minimum distance)beat_ptt— per-beat pulse-transit time (ms) from ECG R-peaks to the next PPG foot within a configurable search windowmedian_ptt— convenience wrapper returning the median PTT across all matched beats (default search window: 30–380 ms)heart_rate_bpm— mean heart rate in BPM derived from RR intervals, with physiological bounds filtering (0.25–2.0 s)
-
LazyDatafile::find_channel_by_name— returns the zero-based index of the first channel whose name exactly matches the given string without triggering a sample-data load. -
LazyDatafile::find_channel_containing— case-insensitive substring variant offind_channel_by_name; useful when channel names vary slightly across recordings (e.g."ECG - Filtered"vs"ECG"). -
LazyDatafile::load_channel_by_name— loads and returns a channel by exact name; errors include the full list of available channel names. -
LazyDatafile::load_channel_containing— loads and returns the first channel whose name contains the given substring (case-insensitive).
0.2.4 - 2026-05-16
Fixed
- Parser: corrected foreign data section length interpretation for Post-4
files. The
lLengthfield inForeignDataRawis the total byte count of the section (including the 4-bytelLengthfield itself), not the payload byte count. biodream was readinglLengthbytes as payload and then consuming the 4-byte field on top, overreading by 4 bytes. For a typical Post-4 big-endian file (lLength = 8), this shifted every subsequent dtype header by 4 bytes, causing channel 5's dtype to be read from garbage data (nType = 456instead ofnType = 1, f64). The payload count is now computed as(n_length - 4).max(0).
0.2.3 - 2026-05-15
Fixed
-
Parser: corrected
ChannelHeaderRawbinary layout for the V_20a channel-header format. The struct previously placedlBufLength,dAmplScale,dAmplOffset, andnVarSampleDividerat the wrong offsets; the correct layout is:szCommentTextat offset 6,lBufLengthat 88,dAmplScaleat 92,dAmplOffsetat 100 (total fixed region = 112 bytes).CHANNEL_HEADER_MIN_LENupdated from 86 → 112. -
Parser: added support for the
hExpectedPaddingsfield in Post-4 graph headers (AcqKnowledge≥ 4.3.0, file revision ≥ 124). Files from BIOPAC hardware in big-endian mode include one or more 40-byteUnknownPaddingHeaderblocks between the graph header and the first channel header. biodream previously read the first padding block as a channel header, sawlChanHeaderLen = 40 < 112, and returned a parse error. The parser now readshExpectedPaddingsfrom graph-header offset 2398 and skips that many padding blocks before reading channel headers. -
Parser:
nVarSampleDivider(per-channel variable sample divider) is now read from its correct version-dependent offset instead of the channel-header fixed struct: offset 152 for Post-4 files (revision ≥ 68, channel header ≥ 154 bytes), offset 250 for Pre-4 files (revision ≥ 44, channel header ≥ 252 bytes). Older files default to divider = 1.
0.2.2 - 2026-05-15
Added
- CLI:
biopac plot <file.acq>— renders channel waveforms as a tiled PNG or SVG image using plotters. Supports--output,--format png|svg,--width,--height-per-channel,--channels(by name or 0-based index), and--start/--endtime-window clipping. Gated behind the optionalplotfeature.
Changed
- Dependencies: upgraded
arrowandparquetfrom 54 → 58.
Fixed
- CI: Four clippy lint errors in
examples/write_file.rs(single_match_else,option_if_let_else,cast_precision_loss×2) that were causing theLintjob to fail since thewritefeature was added. - CI:
build.rstriggeredclippy::map_unwrap_or; replaced.map(…).unwrap_or(false)withis_ok_and(…). - CI: Restored
RUSTSEC-2024-0436advisory ignore indeny.tomlthat was inadvertently dropped during the arrow/parquet 54 → 58 upgrade;paste 1.0.15remains a transitive dependency viaparquetand has no patched version available. - Packaging: excluded
.mcp.json,.vscode/,.github/,deny.toml,plan.toml,docs/, andreference_projects/from the crates.io package manifest to prevent publish failures caused by dangling symlinks in the working tree.
0.2.1 - 2026-05-15
Added
- CLI:
--versionnow reports the git commit SHA and commit date (e.g.biopac 0.2.1 (git:abc12345 2026-05-15)). Falls back tocrates.iowhen installed from the registry.
Fixed
- CLI:
biopacwith no arguments now prints help instead of an error. Unknown flags and subcommands exit 2 with a--helphint; parse errors are handled explicitly viatry_parse()rather than clap's internal exit. - CI: Silenced
cargo-denyfalse-positive forRUSTSEC-2024-0436(pasteunmaintained); the crate is a transitive dependency viaparquet→ahashand is not directly actionable.
0.2.0 - 2026-05-15
Fixed
- Parser: corrected file-version offset — skips the unused
i16prefix at byte offset 0 that was being misread as part of the version field, fixing version detection on all v30+ files.
Changed
- Security (T16–T18):
deny.tomlhardened with stricter advisory, license, and source policies;cargo-denyandcargo-auditadded as scheduled CI checks viasecurity.yml. - Style:
rustfmtformatting pass across the writer, inspect, andarrow_exportmodules.
Added
- CI/CD pipeline: complete GitHub Actions workflow suite —
ci.ymlextended withfmt,docs(RUSTDOCFLAGS=-D warnings), andmsrv(1.95.0) gates alongside the existing test and deny jobs.auto-tag.yml: creates an annotated semver tag after CI passes on achore(release):commit, usingcargo metadatato read the version.release.yml: builds cross-platformbiopacbinaries (Linux x86-64, macOS ARM/x86, Windows x86-64), publishes to crates.io, and creates a GitHub Release with checksums. Guarded by averify-cipolling step.security.yml: weekly secret scan (gitleaks),cargo audit, andcargo denyon a schedule and on Cargo file changes.dependabot-automerge.yml+dependabot.yml: auto-merge patch/minor Dependabot PRs for both Cargo and GitHub Actions ecosystems.
- Local secret scanning:
gitleaks protect --stagedpre-commit hook.
0.1.0 - 2025-07-01
Added
Parser & Core (T01–T06)
- Binary parser for BIOPAC AcqKnowledge
.acqfiles across all known format versions (v30 through v84+) using declarativebinrw-based header structs. - Version-dispatched parsing:
FileRevisiondetermines which header layout is read; single code path handles all variants cleanly. - Support for both uncompressed and zlib-compressed data payloads.
- Mixed sampling-rate support: each channel carries its own
samples_per_secondandfrequency_divider, correctly computed from the global rate stored in the graph header. - Event-marker parsing:
Marker,MarkerStyle, andTimestampdomain types with full textual label support. - Journal section parsing: raw journal text exposed as
Journal::as_text(). - Foreign-data section detection and graceful skip-forward with a
Warning. ParseResult<T>wrapper that accumulates non-fatalWarnings alongside the value; callers iterateresult.warningsbefore callingresult.into_value().
Domain Model (T02–T03)
- Rich domain types:
Datafile,GraphMetadata,Channel,ChannelData,Marker,MarkerStyle,Journal,Timestamp,FileRevision,ByteOrder. Channel::scaled_samples()converts rawi16integers tof64via per- channel scale and offset; linear-interpolation upsampling for sub-rate channels.ChannelDataenum:Scaled { raw, scale, offset }for the common case;Raw(Vec<i16>)for unprocessed access.- Typed error hierarchy via
thiserror:BiopacErrorwith variants carrying byte offsets and expected-vs-actual values for triage of corrupt files.
Write Support (T07, feature write)
- Round-trip write support:
write_fileserialises aDatafileback to the BIOPAC binary format with bitwise fidelity on read-modify-write cycles. WriteOptionsfor controlling output behaviour (byte order, version).- Feature-gated behind
writeto keep the default dependency footprint minimal.
Export (T08–T10)
- CSV (
default):to_csvwithCsvOptions(delimiter, time column,TimeFormatenum for elapsed seconds vs. sample index). - Arrow IPC (feature
arrow):export::arrow::to_arrow_ipcwrites an Arrow IPC stream compatible with Polars, Rarrow, and Julia. - Parquet (feature
parquet):export::parquet::to_parquetwrites a Parquet file suitable for direct loading in DuckDB, Spark, or Pandas. - HDF5 (feature
hdf5):export::hdf5::to_hdf5writes a hierarchical HDF5 dataset per channel.
CLI (T11)
biodreambinary with sub-commands:info,csv,arrow,parquet.info: human-readable summary of file metadata and channel list.csv/arrow/parquet: batch conversion with feature-gated availability.- Colorised output via
owo-colors; structured error reporting withanyhow.
Lazy / Streaming Reader (T12)
LazyDatafile/ReadOptionsfor deferred channel loading: reads only the channel headers on open, then streams individual channels on demand without buffering the entire file.
Testing (T13–T14)
- 222-test suite covering: unit tests, integration tests against 14 synthetic
fixture
.acqbinary files (v30–v84+ with and without compression), write round-trip tests, and property-based tests viaproptest. - Proptest strategies generate arbitrary valid
Datafilestructures and verifywrite → read → writeproduces bitwise-identical output. cargo test --workspace --all-featuresruns the full suite in CI.
Documentation & Publishing (T15)
- Full rustdoc coverage (
#![warn(missing_docs)]);cargo doc --all-features --no-depsproduces zero warnings. - Four runnable examples:
read_file,convert_csv,arrow_export,write_file. README.mdwith feature comparison table, installation instructions, feature flag reference, quick-start code,no_stdusage notes, and CLI examples.Cargo.tomlpublish metadata: description, repository, license, keywords, and categories.
Architecture
no_std-compatible core (parser + domain) withalloc;stdrequired only by I/O adapters and the CLI binary.- Feature gates:
default = ["read", "csv"]; optional:write,arrow,parquet,hdf5,serde. - MSRV: Rust 1.95.0 (edition 2024, stable toolchain only).
- Full Clippy
-W pedantic / nursery / cargo / perfprofile with zero warnings.