WFDB-x

A fast, lightweight implementation of the WFDB library and command-line tools, with identical behavior across seven programming languages

View the Project on GitHub alphanumericslab/wfdb-x

Deliberate divergences from the C implementation

WFDB-x reproduces the WFDB Software Package 10.7.0 exactly, with the exceptions described here: it has no built-in signal parameters; it finds files only where they are named (no search path, and no network access unless the user asks for it); it sorts out-of-order annotation files itself, instead of running a separate program, and without losing annotations as the original sortann does; it never writes a signal file over a signal file it is reading; and its tools report write errors in their exit status. It also gives a defined result for input on which the C package crashes (see Input on which the C package crashes). They are deliberate, the same in all seven implementations, and tested like everything else: the reference results of the conformance suite come from the C package with tests/reference/divergences.patch applied (see tests/reference/build_reference.sh). The patch is the precise definition of the divergences; this page explains them. The original behavior remains available in all cases, on request (see Legacy mode).

These design decisions were made by Reza Sameni in 2026, for the reasons below. Each source file affected by them says so in its header.

No built-in sampling frequency, gain, ADC resolution or units

Why

The C package falls back on built-in values for four parameters of a signal whenever a record or the user does not give them:

Parameter Built-in value in C Where it came from
sampling frequency 250 Hz (WFDB_DEFFREQ), 500 Hz in parsescp the AHA database (250 Hz)
gain 200 adu/mV (WFDB_DEFGAIN); 400 adu/mV for AHA files; 12.5 or 6400 adu/mV for WAV files the MIT-BIH and AHA digitizers
ADC resolution 12 bits (WFDB_DEFRES), or the storage width of the format the 12-bit digitizers of these databases
units millivolts ECG

These values describe the equipment with which the first PhysioNet databases were recorded, in the 1970s and 1980s, and they made sense for a package built around those databases. They do not describe modern recordings: today’s acquisition systems sample at anything from a few hertz to tens of kilohertz, with gains, resolutions (often 16 or 24 bits) and units that depend on the device and on the signal, which is often not an ECG at all.

A silent default is worse than an error. Every time, duration, interval, resampled signal and physical value computed from a wrong default is wrong, the error propagates to every file and result derived from it, and nothing in the output says that a value was assumed. WFDB-x therefore never assumes any of these four parameters: it uses the values given in the record, or values given explicitly by the user, and reports an error when an operation needs a value that is not available. Where an output must still show a missing unit, it shows nd (“not defined”, a notation the C package already used for empty units) rather than mV.

The only exception is parsescp, whose internal processing is defined for its own output: it writes a gain of 200 adu/mV because it rescales the SCP samples to units of 5 µV, so that value is exact, not assumed.

Reproducing the results of the original package

To obtain the results of the original WFDB tools, give the values they assumed — explicitly. Nothing is defaulted internally.

Tool Values to give
wrsamp (writing a record, -o) -F 250 -G 200 -U mV, and -R with the storage width of the output format (-R 12 for format 212, -R 16 for 16, 61, 160 and 516, -R 8 for 8, 80 and 508, -R 10 for 310 and 311, -R 24 for 24 and 524, -R 32 for 32)
wav2mit -G 12.5 -U mV for 8-bit files, -G 6400 -U mV for 16-bit files (the frequency and resolution are those in the WAV file)
ad2m -F 250 -G 400 -R 12 -U mV (the AHA database)
ahaecg2mit -F 250 -G 400 -R 12 -U mV (the AHA database)
a2m, rr2ann, rdann, ann2rr, sortann, m2a, for a record without a header (or without a frequency in its header) -F 250
xform (interactive) the ADC resolution that C offered: the storage width of the output format, or 12 bits for 16-bit formats
library setsampfreq(250) before opening a record whose header gives no frequency

The output then contains the same samples, times and values as the original. One difference remains by construction: a header written with -U mV says 200/mV where the C tools wrote 200 and left millivolts to be assumed.

What changes, in detail

Library

Tools. A tool that writes these parameters into a record requires them; a tool that needs a parameter missing from its input stops with an error only when the requested operation needs it:

Tool Behavior
wrsamp -F, -G and the new -R BITS are required when a record is written (-o), and the new -U UNITS for calibrated signals unless the input gives the units; -G 0 declares the signals uncalibrated. -G, -R and -U take one value per signal (the last applies to the rest)
wav2mit new -G GAIN (required) and -U UNITS (required unless the gain is 0)
ad2m, ahaecg2mit new -F FREQ, -G GAIN, -R BITS and -U UNITS, all required (-U unless the gain is 0); ahaecg2mit locates the short-format part of a long-format file from -F
a2m, rr2ann, rdann, ann2rr, sortann, m2a -F FREQ (new except in rr2ann), used if the record has no header or the header has no frequency, and required then when times are converted (always for a2m and rdann; for time arguments and time output formats in ann2rr and sortann; for -s in m2a)
a2m converts annotation times given in milliseconds (AHA file types 1 to 3) at the record’s sampling frequency; C assumed 4 ms per sample (250 Hz) whatever the record’s frequency
rdsamp needs the frequency for time arguments, time columns (-p without -pS, or with -v) and XML output (-X), and the gain of each selected signal for physical units (-p); unspecified units are shown as nd
sigamp needs the frequency (its measurement windows are in seconds), and the gains for -p
snip, wfdb2mat, mit2wav, wfdbtime need the frequency
xform needs the frequency; refuses an output frequency of 0, rescaling an uncalibrated signal to a given gain, and rescaling by ADC resolution when a resolution is unspecified; offers an ADC resolution only if it comes from the input (or the previous output signal) and suits the output format, and stops if none is given
mit2edf needs the frequency, and the gain and ADC resolution of every signal (an EDF file stores physical and digital ranges); unspecified units are written as nd
wfdbdesc prints “not specified” for an unspecified frequency (with lengths in sample intervals only) and ADC resolution, “uncalibrated” for gain 0 (C: “uncalibrated; assume 200”), and adu/nd for unspecified units
wfdb2mat uncalibrated signals stay uncalibrated in the output header (C: 200 adu/mV); unspecified units are written as nd (C: mV)
revise a missing ADC resolution stays unspecified (C: 12 bits)
sampfreq prints 0 for a record whose header does not give a frequency (C: 250)
parsescp writes the sampling frequency given in the SCP file (C always wrote 500 Hz) and does not write a record if the file gives none (C assumed 500 Hz)

The messages are “TOOL: sampling frequency of record RECORD is not specified” (followed by “ (use -F)” for the tools that have that option), “TOOL: signal N of record RECORD is uncalibrated”, “TOOL: ADC resolution of signal N of record RECORD is not specified”, and “TOOL: the ... must be specified (-X)” for a missing required option.

High-level API (read_record, read_annotations, write_record, in every language):

What does not change

Files are found only where they are named

Why

The C library finds a file by searching the WFDB path: a list of locations, tried in order until the file is found. Its default is . /usr/local/database http://physionet.org/physiobank/database: the current directory, an installation directory and, in builds with network support (libcurl), PhysioNet. rdsamp -r 100 (record 100 of the MIT-BIH Arrhythmia Database) therefore reads 100 from whichever of these places has it first, and fetches it from PhysioNet if no local copy exists. That was convenient, but fragile, and it caused problems that are easy to miss:

What WFDB-x does instead

A name says exactly where the file is, and nothing else is tried:

The name is Read from Written to
an absolute path: /data/100, C:\data\100, \\server\share\100 that path that path
a home path: ~/data/100, ~user/data/100 the home directory (expanded by WFDB-x) the same
a URL: https://.../100 that URL, if network access is on (not possible)
anything else: 100, mitdb/100, ../other/100 the input directory + the name the output directory + the name

Whatever is set as the input directory, a local directory or a URL, prefixes every relative name that is read; the output directory prefixes every relative name that is written. Both default to the current directory. A name given as a full path (/Users/...), a ~ path or a URL is used as given. Nothing is ever searched.

Setting Function Environment variable Default
input directory (a directory, ~..., or a URL) wfdbx_set_input_dir, wfdbx_get_input_dir WFDBX_INPUT_DIR the current directory
output directory (local) wfdbx_set_output_dir, wfdbx_get_output_dir WFDBX_OUTPUT_DIR the current directory
network access wfdbx_set_network, wfdbx_get_network WFDBX_NETWORK=1 off

A function call overrides the environment variable. The high-level functions take the same settings as options (input_dir, network, and dir for the output directory), which apply for the duration of the call. The tools use the environment variables.

The cost: PhysioNet is chosen explicitly

Reading PhysioNet records has to be asked for explicitly, by setting the input directory to the database’s URL and turning network access on. For example, for record 100 of the MIT-BIH Arrhythmia Database:

wfdbx.wfdbx_set_network(1)
wfdbx.wfdbx_set_input_dir("https://physionet.org/files/mitdb/1.0.0/")
rec = wfdbx.read_record("100")

(or WFDBX_NETWORK=1 WFDBX_INPUT_DIR=https://physionet.org/files/mitdb/1.0.0/ rdsamp -r 100 with the tools). rdsamp -r 100 alone reads ./100 and nothing else. A script that relied on the WFDB search path, for example on WFDB=". /data/mitdb /data/nsrdb", has to name its directories instead. In return, every program reads exactly the files it names, the same on every machine, and never contacts a server it was not told to contact.

In legacy mode, the WFDB search path works as in the original reference build, which has no network support: WFDB, the default path, %r substitution, @file indirect paths, absolute names joined to the path entries, and names containing .. refused; URLs are not opened.

Out-of-order annotation files are sorted in-process

When an annotation file written by the library is not in time order, the C library sorts it when the file is closed by running the sortann program (system("sortann -r. REC -a ANN")), unless the environment variable WFDBANNSORT is 0. That makes the result depend on which sortann, if any, is found on the PATH, starts a separate process from inside a library, and passes the record and annotator names through a shell unquoted, so a name with a space or a shell character breaks it.

WFDB-x sorts the file itself, in the same process. The file it writes is byte-for-byte the one sortann writes (except where the original loses annotations; see the next section): the same order (by time, then annotation number, then channel; a later annotation with the same time, number and channel replaces the earlier one), the same treatment of NOTQRS annotations (they are dropped) and of the time resolution, and the same encoding. The messages and the status of the library are the same (Rearranging annotations for output annotator ANN ...done!, or the Use the command: sortann ... advice when sorting is disabled or fails), except that the messages the separate sortann printed itself are gone: for a record without a header, for example, the original printed init: can't open header for record REC in the middle of that line, because sortann looked the header up. WFDBANNSORT=0 turns sorting off, and the high-level write_annotations has a sort option that decides for that call alone (sort, or leave the file as written), whatever WFDBANNSORT says, without changing the environment of the process.

In legacy mode the library runs sortann, as the original does, with one change: the program is started without a shell, its arguments passed directly (the MATLAB/Octave and R implementations, which can start programs only through a shell, quote them instead). Without a shell, a sortann that cannot be found is reported only by the library’s own messages (Annotations still need to be rearranged. and the Use the command: ... advice); the shell’s sortann: not found line is gone. When the library writes into an output directory (see Additions), the program is run in that directory, so that it finds the file. Each implementation runs its own sortann tool, with --legacy: Python as python -m wfdbx.apps.sortann, C++ and Rust from the directory of the running program or else from the PATH, R, Julia and MATLAB/Octave through a new process of the same language, and Kotlin through a new JVM (java -cp CLASSPATH wfdbx.cli.MainKt sortann; an iOS app cannot run it).

Sorting does not lose annotations

The original sortann silently loses annotations (see KNOWN_C_BEHAVIORS.md): when an annotation at time 0, with annotation number 0 and channel 0, arrives after later ones, it is dropped, and so are the annotations later inserted directly behind it (input sample times 5, 0, 3 give a file containing only 5). The search for the insertion point mistakes the list’s empty head entry for an annotation at that location. The same happens when the library sorts a file it wrote, since it runs sortann.

WFDB-x never compares the head entry with an annotation, so such an annotation is put at the beginning of the list, where it belongs (5, 0, 3 give 0, 3, 5). Nothing else about the sorting changes: the order, the replacement of an annotation by a later one at the same time, number and channel, the dropping of NOTQRS annotations, and the messages are those of the original. This applies to the sortann tool and to the library’s automatic sorting.

In legacy mode, both lose these annotations as the original does. Do not use legacy mode to sort annotation files unless reproducing the original’s results, losses included, is the point: nothing in the output says that annotations were dropped.

An output signal file is never one of the input signal files

A record’s files can be shared: the segments of a multi-segment record are other records, so xform -i vlay -o fmt16 reads fmt16.dat (a segment of vlay) and writes fmt16.dat (the output record fmt16), although the two record names differ. The original opens the output file, which truncates it, while still reading it: the input is destroyed, and what is read depends on how much of it the C library had read ahead (on glibc, 4096-byte blocks), so the result differs between platforms.

WFDB-x refuses to create an output signal file that is the same file as a signal file of the record open for reading (the same file, not the same name: links and different spellings of a path are recognized; files are compared by device and inode number, by file ID on Windows, through java.nio.file.Files.isSameFile in MATLAB (Octave uses the device and inode numbers that stat gives), and in R, which has no access to inode numbers, by normalized path and, for files of the same size and modification time, by the device and inode numbers that the stat program reports), with the message osigfopen: output file NAME is also an input file (osigopen: when the file is opened by osigopen); nothing is written. The check is in the library, so every tool and every program using the library functions is protected, and the behavior is identical in all seven implementations (it is part of the conformance suite). The high-level write_record reads nothing: it closes any open record first, so it simply replaces existing files of the same name. Legacy mode does not check, as the original.

The check covers signal files only. Other files are written as the original writes them, even when they are inputs: snip -i 100s -n 100s writes the header 100s.hea of its output record over that of its input record, and wrsamp -i big.dat -o big reads its text input from the file that it writes as the signal file big.dat. Such commands destroy their input, in C and in WFDB-x alike; the output record needs a name of its own.

Write errors give a nonzero exit status

The original tools report a file that cannot be written (a full disk, for example) only through the library’s messages, such as osigclose: write error in signal group 0 or oannclose: write error on annotation file atr, and then exit with status 0, as if they had succeeded; they say nothing at all when the standard output cannot be written, or when a file that a tool writes itself (the EDF file of mit2edf, for example) fails. A script that checks the exit status cannot tell that its output is missing.

WFDB-x’s tools follow the convention of the standard Unix tools: when an output file or the standard output cannot be written, the exit status is 1. The library’s messages are unchanged. A standard output that cannot be written is reported as PROGRAM: write error on standard output, and a file that snip, mit2edf or parsescp writes itself as PROGRAM: write error on FILE. A tool that exits with another nonzero status for another reason keeps that status. The tools keep running after a write error, as the original ones do; only the status and these messages differ.

The library counts the output files that it could not write, flush or close (found when it closes them); wfdbx_write_errors() returns that count, and wfdbx_note_write_error() adds to it for a program that writes a file itself. In legacy mode the tools exit with the original status and print no new messages.

Input on which the C package crashes

Why

Some malformed or unusual input makes the C package crash, run out of memory, or give a result that depends on the machine. These are bugs of the C code; a crash cannot be reproduced, so WFDB-x gives each such input one defined, safe result, the same in all seven implementations. The complete list, with the C behavior and the WFDB-x choice for each, is the table Undefined behaviors (not reproduced) in KNOWN_C_BEHAVIORS.md. Those found while reviewing version 1.1.0:

The conformance suite checks most of these, with results recorded from WFDB-x instead of C (cases with golden_impl; see conformance.md). Legacy mode gives the same results, since the C behavior cannot be reproduced.

Additions

These are new features, not changes of the original behavior, so they have no legacy counterpart:

Legacy mode

For compatibility, the C reference (with divergences.patch) can also behave exactly as the original package, on request; the default is the behavior described above.

Legacy mode reproduces the original package exactly, and it is tested against its results: the legacy suite of the conformance tests (tests/conformance/legacy, 877 cases, run with harness.py check --legacy) runs the original package’s cases with --legacy and compares them with results produced by the unmodified C package, and the library’s legacy values are checked in tests/reference/api_expect.c.

It is kept for compatibility only. The values it assumes are exactly the silent defaults this project removed, so results produced with it carry the same risks: any time, interval, resampled signal or physical value computed from an assumed frequency, gain, resolution or unit is wrong if the assumption is, and nothing in the output says so.