Quick start
The shortest path from a file the user picked, or a path on disk, to a Float64Array of microvolts. Both runtimes use the same five functions; only the first one differs.
The shape of every read
Five calls do the work, and only the first one is runtime-specific.
- Wrap the bytes in a
ByteSource(blobSource(file)in a browser,await fileSource(path)in Node). openEdf(source)parses the header and builds a time axis. It doesn’t read the data.getSignal(header, label)resolves one channel by label or index.readWindow(recording, selection)reads a stretch of time for the channels you name.toPhysical(signal, digital)converts the stored integers into the signal’s own units.
Everything else in the library is a variation on those.
How do I read an EDF file in the browser?
Start with a file input. blobSource accepts a File straight from the picker. There’s no intermediate ArrayBuffer, so a twelve-hour recording is read in ranges rather than loaded whole.
<input type="file" id="picker" accept=".edf,.bdf">
import { blobSource, getSignal, openEdf, readWindow, toPhysical } from 'edfcore';
const picker = document.querySelector<HTMLInputElement>('#picker')!;
picker.addEventListener('change', async () => {
const file = picker.files?.[0];
if (file === undefined) return;
const recording = await openEdf(blobSource(file));
const { header } = recording;
const firstDataIndex = header.dataSignalIndices[0];
if (firstDataIndex === undefined) return; // a file with no data channels
const signal = getSignal(header, firstDataIndex);
const chunks = await readWindow(recording, {
startSeconds: 0,
durationSeconds: 10,
signalIndices: [signal.index],
});
const chunk = chunks[0];
if (chunk === undefined) return; // the window is outside the recording
const values = toPhysical(signal, chunk.signals[0]!.digital);
console.log(
`${signal.label}: ${values.length} samples of ${signal.physicalDimension}, ` +
`${chunk.byteLength} bytes read from disk`,
);
console.log(values.subarray(0, 5));
});
Against the three-channel file listed in the next section (EEG at 256 Hz, respiration at 16 Hz, and an annotations channel, in one-second records), that prints:
EEG Fpz-Cz: 2560 samples of uV, 6040 bytes read from disk
Float64Array(5) [
0.007629510948348211,
11.13145647364004,
21.583886472877087,
30.75455863279164,
38.063630121309224
]
Two things in that output are worth pausing on. values is a Float64Array, because a 24-bit BDF sample scaled into float32 loses about a quarter of a quantisation step. And chunk.byteLength is the bytes that actually left the source, which is more than the 5120 bytes those 2560 samples occupy. EDF stores every channel interleaved, and the smallest readable unit is a whole data record. Concepts has the arithmetic.
readWindow returns an array of chunks, which is why the example indexes into it. For a continuous file that array holds exactly one chunk whenever the window overlaps the recording. It holds more than one when the window crosses a gap in a discontinuous recording. It’s empty when the window contains no records at all, either outside the recording or entirely inside a gap.
How do I read an EDF file in Node.js?
It’s identical after the first line. fileSource returns a promise because it opens a file handle, and you close it when you’re done. edfcore has no other lifetime mechanism in 0.1, so wrap the work in try/finally.
import { getSignal, openEdf, readWindow, toPhysical } from 'edfcore';
import { fileSource } from 'edfcore/node';
const source = await fileSource('./overnight.edf');
try {
const recording = await openEdf(source);
const { header } = recording;
console.log(
`${header.variant}: ${header.recordCount} records of ${header.recordDurationSeconds} s`,
);
for (const signal of header.signals) {
console.log(
` [${signal.index}] ${signal.label} — ${signal.kind}, ` +
`${signal.samplesPerRecord} samples/record`,
);
}
const eeg = getSignal(header, 'EEG Fpz-Cz');
const chunks = await readWindow(recording, {
startSeconds: 60,
durationSeconds: 10,
signalIndices: [eeg.index],
});
const chunk = chunks[0];
if (chunk !== undefined) {
const microvolts = toPhysical(eeg, chunk.signals[0]!.digital);
console.log(`${microvolts.length} samples, ${chunk.byteLength} bytes read`);
console.log(Array.from(microvolts.subarray(0, 5)));
}
} finally {
await source.close?.();
}
EDF+C: 300 records of 1 s
[0] EEG Fpz-Cz — data, 256 samples/record
[1] Resp oro-nasal — data, 16 samples/record
[2] EDF Annotations — annotations, 30 samples/record
2560 samples, 6040 bytes read
[
-13.939116502632181,
-24.116884107728694,
-32.845044632639045,
-39.604791332875564,
-43.99938963912413
]
Note the third channel. The bytes of EDF Annotations are timestamped text, so edfcore reports its kind as 'annotations' and keeps it out of header.dataSignalIndices. Passing its index to readWindow throws.
getSignal matches the trimmed label exactly and case-sensitively. When nothing matches it throws EdfChannelNotFoundError, listing every label in the file. When two channels share the label, which real files do, it throws EdfAmbiguousChannelError, listing the indices.
How do I read the events in an EDF+ file?
Annotations come out of their own call, because they live in their own channel and have nothing to do with the sample grid.
import { openEdf, readAnnotations } from 'edfcore';
import { fileSource } from 'edfcore/node';
const source = await fileSource('./overnight.edf');
try {
const recording = await openEdf(source);
const { annotations } = await readAnnotations(recording, {
start: 0,
count: recording.header.recordCount,
});
for (const annotation of annotations) {
const duration = annotation.durationSeconds;
console.log(
`${annotation.onsetSecondsFromFirstRecord.toFixed(3)} s` +
(duration === undefined ? '' : ` (+${duration} s)`) +
` ${annotation.text}`,
);
}
} finally {
await source.close?.();
}
30.000 s (+30 s) Sleep stage W
60.000 s (+30 s) Sleep stage 1
95.500 s Arousal
The record range is required and has no default. Scanning a whole file for annotations is expensive, so { start: 0, count: recording.header.recordCount } has to appear in your source.
Each annotation carries its onset three ways. onsetSecondsFromFirstRecord is rebased to the start of record 0, which is the convention EDFlib, pyEDFlib and MNE use. onsetSecondsFromHeaderStart is the verbatim on-disk value, and onsetTicks is an exact bigint in 100-nanosecond units. Print the seconds; compare the ticks.
Next
Concepts explains the record grid that all of the above sits on: the record range as the unit of I/O, samplesPerRecord as the authoritative field, and the two functions behind digital and physical values. It also covers what changes when a recording has gaps in it.