biodream

CI crates.io docs.rs License: MIT OR Apache-2.0

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?

Capabilitybiodreambioread
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 versionsv30–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 BiopacError variant 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 footprintdefault = ["read", "csv"]; Arrow, Parquet, HDF5, write support, and serde are opt-in.
  • no_std core — the parser and domain modules compile under #![no_std] with alloc; only I/O adapters and the CLI require std.

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

FlagDefaultDescription
readRead .acq files. Enables the parser, domain types, and read_file / read_stream API. Requires std.
csvCSV export via to_csv / CsvOptions.
writeWrite .acq files. Round-trip fidelity: read → modify → write → diff = clean.
arrowApache Arrow IPC export (to_arrow_ipc). Compatible with Polars, R arrow, and Julia.
parquetParquet export (to_parquet). Requires arrow. Compatible with DuckDB, Spark, Pandas.
hdf5HDF5 export (to_hdf5). Requires the libhdf5-dev system library.
serdeSerialize / 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:

FunctionDescription
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

VariantBehavior
TimeFormat::SecondsElapsed seconds column (default)
TimeFormat::MillisecondsElapsed milliseconds column
TimeFormat::SamplesInteger 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

FlagDescription
--helpPrint help
--versionPrint version
-v, --verboseVerbose 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

FlagDefaultDescription
--format <FORMAT>csvOutput format: csv, arrow, parquet
--output <PATH>(derived)Output file path. Defaults to input stem + new extension.
--time-format <FMT>secondsTime column format: seconds, ms, samples
--no-markersOmit 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

FlagDescription
--jsonOutput markers as JSON array
--csvOutput 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

FlagDescription
--hexPrint a hex dump of each section header
--unknownPrint 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

Moduleno_stdNotes
biodream::domainAll typed domain types
biodream::parserBinary parser (binrw, alloc)
biodream::export::csvRequires std I/O
biodream::export::arrowRequires std
biodream::export::parquetRequires std
biodream::export::hdf5Requires std + system lib
biodream::read_fileRequires std filesystem
biodream::read_streamWorks 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 + Seek shim. The core2 crate provides core2::io::Cursor which works in no_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 rangeEra
30–40Classic Mac (68k / PPC), big-endian
41–66Windows transition era
67–84Modern 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 via pkg-config and runs cargo test -p biodream --features hdf5 only when available; otherwise it emits an explicit skip notice instead of failing.

  • Docs: expanded HDF5 installation guidance with HDF5_DIR and PKG_CONFIG_PATH overrides plus a pkg-config verification command.

[0.2.6] - 2026-05-16

Added

  • biopac info — lazy header load: for file-path arguments, info now uses LazyDatafile (headers + markers only) instead of reading all sample data. Channel sample-rate is derived from frequency_divider; --json output includes duration_seconds computed from sample counts.

  • biopac markers — lazy header load: same optimisation as info; for file-path arguments, markers are read via LazyDatafile.markers without loading any sample data. Stdin input continues to use the full read_acq path.

  • 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 --channels and --channel-name.
  • biopac convert — CSV output options: the CSV export now exposes the full CsvOptions surface 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 or tab (default: ,)
    • --include-raw — emit a <name>_raw integer column alongside each scaled column
    • --fill-value <STR> — value written for absent samples (default: empty string)
  • biopac signals subcommand (--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 as sample<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

  • physio feature — new biodream::signals module 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 crossing
    • sync_window — returns the (start, end) sample span between the first two rising edges; useful for gating analysis to the recording window
    • detect_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 window
    • median_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 of find_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 lLength field in ForeignDataRaw is the total byte count of the section (including the 4-byte lLength field itself), not the payload byte count. biodream was reading lLength bytes 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 = 456 instead of nType = 1, f64). The payload count is now computed as (n_length - 4).max(0).

0.2.3 - 2026-05-15

Fixed

  • Parser: corrected ChannelHeaderRaw binary layout for the V_20a channel-header format. The struct previously placed lBufLength, dAmplScale, dAmplOffset, and nVarSampleDivider at the wrong offsets; the correct layout is: szCommentText at offset 6, lBufLength at 88, dAmplScale at 92, dAmplOffset at 100 (total fixed region = 112 bytes). CHANNEL_HEADER_MIN_LEN updated from 86 → 112.

  • Parser: added support for the hExpectedPaddings field 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-byte UnknownPaddingHeader blocks between the graph header and the first channel header. biodream previously read the first padding block as a channel header, saw lChanHeaderLen = 40 < 112, and returned a parse error. The parser now reads hExpectedPaddings from 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/--end time-window clipping. Gated behind the optional plot feature.

Changed

  • Dependencies: upgraded arrow and parquet from 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 the Lint job to fail since the write feature was added.
  • CI: build.rs triggered clippy::map_unwrap_or; replaced .map(…).unwrap_or(false) with is_ok_and(…).
  • CI: Restored RUSTSEC-2024-0436 advisory ignore in deny.toml that was inadvertently dropped during the arrow/parquet 54 → 58 upgrade; paste 1.0.15 remains a transitive dependency via parquet and has no patched version available.
  • Packaging: excluded .mcp.json, .vscode/, .github/, deny.toml, plan.toml, docs/, and reference_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: --version now reports the git commit SHA and commit date (e.g. biopac 0.2.1 (git:abc12345 2026-05-15)). Falls back to crates.io when installed from the registry.

Fixed

  • CLI: biopac with no arguments now prints help instead of an error. Unknown flags and subcommands exit 2 with a --help hint; parse errors are handled explicitly via try_parse() rather than clap's internal exit.
  • CI: Silenced cargo-deny false-positive for RUSTSEC-2024-0436 (paste unmaintained); the crate is a transitive dependency via parquetahash and is not directly actionable.

0.2.0 - 2026-05-15

Fixed

  • Parser: corrected file-version offset — skips the unused i16 prefix 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.toml hardened with stricter advisory, license, and source policies; cargo-deny and cargo-audit added as scheduled CI checks via security.yml.
  • Style: rustfmt formatting pass across the writer, inspect, and arrow_export modules.

Added

  • CI/CD pipeline: complete GitHub Actions workflow suite —
    • ci.yml extended with fmt, docs (RUSTDOCFLAGS=-D warnings), and msrv (1.95.0) gates alongside the existing test and deny jobs.
    • auto-tag.yml: creates an annotated semver tag after CI passes on a chore(release): commit, using cargo metadata to read the version.
    • release.yml: builds cross-platform biopac binaries (Linux x86-64, macOS ARM/x86, Windows x86-64), publishes to crates.io, and creates a GitHub Release with checksums. Guarded by a verify-ci polling step.
    • security.yml: weekly secret scan (gitleaks), cargo audit, and cargo deny on 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 --staged pre-commit hook.

0.1.0 - 2025-07-01

Added

Parser & Core (T01–T06)

  • Binary parser for BIOPAC AcqKnowledge .acq files across all known format versions (v30 through v84+) using declarative binrw-based header structs.
  • Version-dispatched parsing: FileRevision determines 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_second and frequency_divider, correctly computed from the global rate stored in the graph header.
  • Event-marker parsing: Marker, MarkerStyle, and Timestamp domain 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-fatal Warnings alongside the value; callers iterate result.warnings before calling result.into_value().

Domain Model (T02–T03)

  • Rich domain types: Datafile, GraphMetadata, Channel, ChannelData, Marker, MarkerStyle, Journal, Timestamp, FileRevision, ByteOrder.
  • Channel::scaled_samples() converts raw i16 integers to f64 via per- channel scale and offset; linear-interpolation upsampling for sub-rate channels.
  • ChannelData enum: Scaled { raw, scale, offset } for the common case; Raw(Vec<i16>) for unprocessed access.
  • Typed error hierarchy via thiserror: BiopacError with variants carrying byte offsets and expected-vs-actual values for triage of corrupt files.

Write Support (T07, feature write)

  • Round-trip write support: write_file serialises a Datafile back to the BIOPAC binary format with bitwise fidelity on read-modify-write cycles.
  • WriteOptions for controlling output behaviour (byte order, version).
  • Feature-gated behind write to keep the default dependency footprint minimal.

Export (T08–T10)

  • CSV (default): to_csv with CsvOptions (delimiter, time column, TimeFormat enum for elapsed seconds vs. sample index).
  • Arrow IPC (feature arrow): export::arrow::to_arrow_ipc writes an Arrow IPC stream compatible with Polars, R arrow, and Julia.
  • Parquet (feature parquet): export::parquet::to_parquet writes a Parquet file suitable for direct loading in DuckDB, Spark, or Pandas.
  • HDF5 (feature hdf5): export::hdf5::to_hdf5 writes a hierarchical HDF5 dataset per channel.

CLI (T11)

  • biodream binary 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 with anyhow.

Lazy / Streaming Reader (T12)

  • LazyDatafile / ReadOptions for 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 .acq binary files (v30–v84+ with and without compression), write round-trip tests, and property-based tests via proptest.
  • Proptest strategies generate arbitrary valid Datafile structures and verify write → read → write produces bitwise-identical output.
  • cargo test --workspace --all-features runs the full suite in CI.

Documentation & Publishing (T15)

  • Full rustdoc coverage (#![warn(missing_docs)]); cargo doc --all-features --no-deps produces zero warnings.
  • Four runnable examples: read_file, convert_csv, arrow_export, write_file.
  • README.md with feature comparison table, installation instructions, feature flag reference, quick-start code, no_std usage notes, and CLI examples.
  • Cargo.toml publish metadata: description, repository, license, keywords, and categories.

Architecture

  • no_std-compatible core (parser + domain) with alloc; std required 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 / perf profile with zero warnings.