Reading signals

An EDF file stores its channels interleaved inside fixed-size data records, so the unit you read is a record range, not a channel. This page covers turning a time window into samples and what a read returns.

The shortest useful program

import { openEdf, getSignal, readWindow, toPhysical } from 'edfcore';
import { fileSource } from 'edfcore/node';

const recording = await openEdf(await fileSource('./overnight.edf'));
const fp1 = getSignal(recording.header, 'Fp1');

const [chunk] = await readWindow(recording, {
  startSeconds: 30,
  durationSeconds: 10,
  signalIndices: [fp1.index],
});

const microvolts = toPhysical(fp1, chunk.signals[0].digital);

openEdf reads the header and nothing else. It never scans the file, so opening a twelve-hour recording costs the same as opening a twelve-second one. It returns an EdfRecording: a plain object holding the source you passed, the parsed header, a timeline, and a record index.

Nothing on it is a handle you have to release. If your source owns an OS resource, as fileSource does, you close the source yourself with await recording.source.close?.(). close is optional on ByteSource (the in-memory and blob adapters have nothing to release), so the call is spelled with ?..

In a browser, swap the source and the rest is identical:

import { blobSource, openEdf } from 'edfcore';

const recording = await openEdf(blobSource(fileFromInput));

Every adapter is covered in data sources; nothing else on this page depends on which one you chose.

Data records

An EDF file is a header followed by a run of equal-sized data records. Each record covers the same fixed slice of time (one second is typical, but the header can declare any duration). Inside a record, every channel’s samples for that slice appear one after another. A 30-channel recording at 256 Hz with one-second records stores 256 samples of channel 0, then 256 of channel 1, and so on, 15,360 bytes per record.

Two consequences run through the whole API. First, a channel’s samples are a stripe repeated every recordByteLength bytes. No byte range holds one channel and nothing else, so asking for one channel still reads the records that contain it. Second, the smallest amount of a file you can read is one record. Any window you ask for is served by a record-aligned range that’s usually wider than the window itself.

chunk.byteLength reports the number of bytes actually read. trimToWindow is the separate, pure step that narrows a record-aligned chunk to the samples you asked for.

readWindow and readRecords

readWindow takes seconds and works out which records they need. readRecords takes the records directly. Use readWindow when your question is about time (a viewport, an epoch around an event, the first minute). Use readRecords when you’re walking the file structurally, or when you’ve already resolved a record range yourself and want exactly one read for it.

import { readRecords, readWindow } from 'edfcore';

// By time: one chunk per contiguous run of records the window touches.
const chunks = await readWindow(recording, {
  startSeconds: 3600,
  durationSeconds: 30,
  signalIndices: [0, 1, 2],
});

// By record: exactly one chunk, exactly one read.
const chunk = await readRecords(recording, {
  records: { start: 3600, count: 30 },
  signalIndices: [0, 1, 2],
});

Record ranges are always { start, count }, never { start, end }, so there’s no inclusive-versus-exclusive question to get wrong.

signalIndices is required and has no “all signals” default, so a 256-channel file is never read wholesale because an argument was left off.

readWindow always returns an array

Even on a plainly continuous file, readWindow returns readonly EdfChunk[] with one element. It never returns a bare chunk.

The shape comes from EDF+D, the discontinuous variant, where the recording pauses and resumes. The records after a gap carry onsets that jump forward. A window straddling such a gap covers two separate runs of records with a hole between them, and one chunk can’t represent that.

A window inside a gap, a window before the recording starts, a window past its end, and a window with zero or negative duration all return []. An empty array means “no samples exist there”, never “the read failed”. Nothing is ever filled in. There’s no gap-fill, and no option to enable one.

const chunks = await readWindow(recording, {
  startSeconds: 20,
  durationSeconds: 10,
  signalIndices: [0],
});

if (chunks.length === 0) {
  // The window is inside a gap, or outside the recording entirely.
}
for (const chunk of chunks) {
  // One contiguous run of records each, in time order.
  // chunk.precededByGap is defined when a gap sits immediately before it.
}

Note On a discontinuous file, readWindow maps seconds to records only after the gaps have been located. Opening a file never scans it, so recording.index.coverage starts as 'probed' (records 0 and n−1 only). readWindow then throws a RangeError explaining that a probed index knows where neither the gap nor the records after it start. EdfRecording is a plain struct, so you build a complete index and rebuild the recording around it:

const index = await buildRecordIndex(recording, { onProgress: (done, total) => {} });
const chunks = await readWindow({ ...recording, index }, selection);

Discontinuous recordings covers segments, gaps and the index in full.

Selecting signals

By index, when you know it:

const chunk = await readRecords(recording, {
  records: { start: 0, count: 10 },
  signalIndices: recording.header.dataSignalIndices,
});

header.dataSignalIndices is every signal that carries samples; header.annotationSignalIndices is the rest. Passing an annotation channel to readWindow or readRecords throws a plain RangeError, because its bytes are TAL text rather than samples. Read those with readAnnotations instead (see annotations).

By label, when you know the electrode:

import { findSignals, getSignal } from 'edfcore';

const fp1 = getSignal(recording.header, 'Fp1');

Matching is exact on the trimmed label and is case-sensitive. 'Fp1' and 'FP1' are written by different acquisition systems, and edfcore has no montage vocabulary for deciding they name the same electrode. A label that matches nothing throws EdfChannelNotFoundError. The error carries availableLabels, so you can show the user what the file actually contains.

Duplicate labels

A label that matches more than one signal throws EdfAmbiguousChannelError:

label "T8-P8" matches 2 signals (indices 1, 3), so getSignal cannot choose one — returning
the first is how the wrong channel ends up in a paper. Next: call findSignals() to get them
all, or select by index.

The CHB-MIT scalp EEG corpus ships files with 'T8-P8' declared twice. The obvious one-liner, signals.find(s => s.label === label), returns the first match with no sign that a second exists. When nothing in the file says which one the writer meant, returning either is a guess.

findSignals returns every match in signal order, and an empty array when there are none.

const matches = findSignals(recording.header, 'T8-P8');
if (matches.length > 1) {
  // Ask the user, or take them all: matches.map(s => s.index) is [1, 3].
}

The error carries the same information as matchingIndices, so you can recover without a second lookup.

Read several signals at once

Reading three channels in one call is cheaper than three calls for one channel each. On a three-signal file with 1,538-byte records, a ten-second window measures:

reads issued bytes read
one call, signalIndices: [0, 1, 2] 1 15,380
three calls, one signal each 3 46,140

Every call re-reads the same interleaved bytes, because those bytes are where all three channels live. Over HTTP that’s three round trips for data you already had. De-interleaving happens in memory after the read, so adding a channel to signalIndices costs decode time and an output array, never another request.

Asking for one channel out of thirty does not read 1/30th of the bytes. It reads all of them and decodes one. chunk.byteLength reports the real figure, so the overread is a number in your result rather than a surprise in a network panel.

What a chunk contains

const [chunk] = await readWindow(recording, {
  startSeconds: 30,
  durationSeconds: 10,
  signalIndices: [0],
});

On a file with 1,538-byte records and a 1,024-byte header, that chunk is:

field value meaning
records { start: 30, count: 10 } the record range actually read
startSeconds 30 true start of the first record, in elapsed recording time
durationSeconds 10 span from the first record’s start to the last record’s end
byteOffset 47164 where the read began in the file
byteLength 15380 bytes read, for every signal in the range
precededByGap undefined an EdfGap when one sits immediately before this chunk
diagnostics [] defects found in the annotation regions of these records

startSeconds is the record’s true start. On EDF+D that is not start * recordDuration. The onsets are decoded from the annotation regions already inside the bytes of this read. They cost no extra I/O, and a gap cannot make the number wrong.

precededByGap is undefined whenever the index is still 'probed'. That reports that nobody has read the onsets in between, not that there is no gap. Build a complete index to get the question answered.

Each entry of chunk.signals is an EdfChunkSignal:

field meaning
signalIndex which signal, indexing header.signals
digital Int32Array of raw stored values, exactly as written
sampleCount the real count, never padded to a round number
firstSampleIndex index of the first sample on this signal’s own sample grid
startSeconds when this signal’s first sample starts
outOfDigitalRangeCount samples outside the declared digital range, counted during decode

firstSampleIndex is per-signal because samplesPerRecord differs between signals. On the same ten-record range, a 256 Hz channel starts at sample 7,680 and a 1 Hz channel starts at sample 30. It’s the number you want when writing samples into a longer buffer you’re assembling yourself.

digital holds the stored counts. Turning counts into microvolts is a separate function with its own failure mode (see physical values).

Record alignment and trimToWindow

Ask for [30.5, 32.5) on a file with one-second records and you’ll get records 30 through 32. That is three records, 768 samples at 256 Hz, starting at 30 s. The chunk is wider than the window because a record is the smallest thing the file can be read by.

trimToWindow narrows one signal of a chunk to exactly the samples inside the window:

import { trimToWindow } from 'edfcore';

const [chunk] = await readWindow(recording, {
  startSeconds: 30.5,
  durationSeconds: 2,
  signalIndices: [0],
});

const exact = trimToWindow(recording.header, chunk.signals[0], 30.5, 2);
exact.sampleCount;      // 512
exact.firstSampleIndex; // 7808
exact.startSeconds;     // 30.5

The returned digital is a subarray view over the chunk’s own memory, so trimming allocates nothing and the two share storage. Do not write into one expecting the other to be unaffected.

Integer sample indexing

trimToWindow compares integers. Sample j of the chunk is inside the window when j * recordDuration >= relativeStart * samplesPerRecord and j * recordDuration < relativeEnd * samplesPerRecord. Every quantity is taken from the header as written and multiplied out in bigint. It never computes round(t * sampleRateHz): no division, no sample rate, no floating-point bound.

signal.samplesPerRecord is the authoritative quantity and is what indexing uses. signal.sampleRateHz is derived and is provided for display. It is undefined when the record duration is zero, which is legal EDF.

A derived rate is frequently not representable. A file that declares a 3-second record with 256 samples has a sample rate of 85.333… Hz. No float holds that exactly, and the error grows with t:

// A file declaring 256 samples per 3 s record.
const signal = getSignal(recording.header, 'Fp1');
signal.sampleRateHz;  // 85.33333333333333 — not the exact rate, because there isn't one

const [chunk] = await readWindow(recording, {
  startSeconds: 100,
  durationSeconds: 1,
  signalIndices: [signal.index],
});

const exact = trimToWindow(recording.header, chunk.signals[0], 100, 1);
exact.firstSampleIndex;                 // 8534 — the first sample at or after 100 s
Math.round(100 * signal.sampleRateHz);  // 8533 — a sample that starts at 99.996 s

Note On this file the two answers disagree at a third of all integer second boundaries (1,000 of the first 3,001), always by exactly one sample. The float answer lands one sample early, so it pulls data from before the window. A one-sample shift is invisible on a trace and fatal to an average across epochs.

Files with several sample rates

One EDF file can hold EEG at 256 Hz, ECG at 512 Hz and body temperature at 1 Hz. There is no single universal rate. Each signal keeps its own samplesPerRecord, and every per-signal field in a chunk is computed from it.

Reading all three over [10, 14) on a one-second-record file gives one chunk with three entries:

signal samplesPerRecord sampleCount firstSampleIndex
Fp1 256 1,024 2,560
ECG 512 2,048 5,120
Temp 1 4 10

Trimming each to [10.5, 12.5) gives 512, 1,024 and 2 samples respectively. The temperature channel’s trimmed window starts at 11 s rather than 10.5 s. At 1 Hz there’s no sample at 10.5, and the first one inside the window is the one at 11.

Reusing output arrays

A scrolling viewer re-reads a window many times a second and doesn’t want a new Int32Array each time. The decode primitives take an out argument and write into it:

import { decodeDigital, getSignal, readRecordBytes } from 'edfcore';

const signal = getSignal(recording.header, 'Fp1');
const records = { start: 0, count: 10 };
const reuse = new Int32Array(records.count * signal.samplesPerRecord);

const bytes = await readRecordBytes(recording.source, recording.header, records);
const digital = decodeDigital(recording.header, bytes, records, signal.index, reuse);
// digital shares memory with `reuse`; nothing was allocated.

An out array longer than needed is narrowed with subarray. The zero-allocation path survives, and digital.length still equals the true sample count, so spare capacity can’t be mistaken for data. An out array that is too short throws a RangeError naming both lengths.

toPhysical takes an out the same way. The convenience layer (readWindow, readRecords) allocates per call. Drop to readRecordBytes plus decodeDigital when you need the allocation under your control.

Where to go next

  • Physical values: scaling digital counts into the signal’s own units, and the cases with no usable scale.
  • Large files: what each call costs in reads and bytes, the allocation budget, and caching.
  • Discontinuous recordings: segments, gaps, and building a complete index.
  • Annotations: reading the events stored in the annotation channels.