Types

All of these live in one file, src/types.ts, and none of them emits runtime code. Import them with `import type { EdfHeader } from 'edfcore'` and they disappear at build time.

Conventions

A field that may be absent is T | undefined, and the key is always there. Optional (?) is reserved for options you pass in. Reading a result never requires knowing whether a key exists. On a file with no readable date, header.startTime.resolvedDate is undefined rather than missing.

Anything checkable against the file is exposed twice, as the parsed value and as the raw bytes it came from. signal.digitalMinimum is -2048; signal.raw.digitalMinimum is '-2048 ', with the padding intact. A header field that disagrees with what edfcore made of it is what you need to see when a file misbehaves, so both stay on the object.

The header model

openEdf and readHeader both give you an EdfHeader, and so does parseHeader if you already hold the bytes.

import { openEdf } from 'edfcore';
import { fileSource } from 'edfcore/node';

const source = await fileSource('night.edf');
try {
  const { header, timeline, index } = await openEdf(source);

  console.log(header.variant, header.continuity);   // 'EDF+D' 'discontinuous'
  console.log(`${header.recordCount} records of ${header.recordDurationSeconds} s`);
  console.log(`${timeline.spanSeconds} s span, ${timeline.coveredSeconds} s covered`);
  console.log(index.coverage);                      // 'probed'
} finally {
  await source.close?.();
}

EdfHeader

field type meaning
variant EdfVariant the dialect the file claims
continuity 'continuous' | 'discontinuous' 'discontinuous' only for EDF+D and BDF+D
bytesPerSample 2 | 3 2 for EDF, 3 for BDF; decided by the version block alone
headerByteLength number the computed 256 * (signalCount + 1), never the declared value
declaredHeaderByteLength number what the file writes at byte offset 184
recordByteLength number bytes of one data record, summed over all signals
dataByteLength number recordCount * recordByteLength
recordDurationSeconds number may legitimately be 0; never divide by it
recordDurationTicks bigint the same duration in exact 100 ns ticks
recordCount number resolved and non-negative
declaredRecordCount number verbatim; -1 means the writer never closed the file
recordCountSource 'headerField' | 'sourceByteLength' which of the two recordCount came from
startTime EdfStartTime the date and clock, and where each came from
patient EdfPatientId the 80 bytes at offset 8, parsed against the EDF+ subfield grammar
recording EdfRecordingId the 80 bytes at offset 88, likewise
signals readonly EdfSignal[] every signal, in file order, annotation channels included
dataSignalIndices readonly number[] indices into signals whose kind is 'data'
annotationSignalIndices readonly number[] indices whose kind is 'annotations'
reserved string the full 44 reserved bytes, verbatim, padding included
raw EdfRawHeaderFields the ten fixed-header fields as written
rawBytes Uint8Array the whole header, for hexdumps and bug reports
diagnostics readonly EdfDiagnostic[] what parsing found, frozen

signals holds every channel, so signals.length is the file’s declared signal count and signals[i].index === i. Pass dataSignalIndices when you want samples: an annotations channel holds TAL text, and decoding it as samples produces numbers that look exactly like a signal.

Declared and computed values

Two pairs ship both numbers, and in both cases edfcore uses the computed one:

header.headerByteLength;          // 768 — always 256 * (signalCount + 1)
header.declaredHeaderByteLength;  // 768 — what byte 184 says

header.recordCount;               // 6 — resolved
header.declaredRecordCount;       // 6 — verbatim, may be -1
header.recordCountSource;         // 'headerField'

The header byte count is derived, and the writer also had to write it down. A mismatch means the writer’s arithmetic and edfcore’s disagree, and every signal offset in the file depends on that arithmetic. edfcore takes the computed value, records HEADER_SIZE_MISMATCH, and leaves the declared value on the header so you can see which writer produced it.

recordCount is the more common one. A writer that streams to disk and never gets to close the file leaves -1 in the field, which is legal and explicitly means “count the file yourself”. When that happens recordCount comes from the source length, declaredRecordCount stays -1, and recordCountSource is 'sourceByteLength'. RECORD_COUNT_RECOVERED records the substitution. The same substitution happens for a truncated file, with TRUNCATED_FILE instead.

EdfVariant

type EdfVariant = 'EDF' | 'EDF+C' | 'EDF+D' | 'BDF' | 'BDF+C' | 'BDF+D';

The family (EDF or BDF, and therefore 2 or 3 bytes per sample) comes from the 8-byte version block at offset 0 and from nothing else. EDF+ keeps that block as '0 ' so legacy readers still open the file, so the +C/+D part comes from the reserved field at offset 192 instead. BDF’s version block is not ASCII at all: byte 0 is 0xFF, followed by 'BIOSEMI'.

EdfSignal

import { getSignal } from 'edfcore';

const signal = getSignal(header, 'EEG Fpz-Cz');

signal.index;              // 0
signal.kind;               // 'data'
signal.samplesPerRecord;   // 100 — authoritative
signal.sampleRateHz;       // 100 — derived
signal.sampleCount;        // 600 — samplesPerRecord * header.recordCount
signal.scale;              // { bitValue: 0.1221001221001221, offset: 0.5 }
signal.raw.digitalMinimum; // '-2048   '
field type meaning
index number position in header.signals, and the value you pass as a signalIndex
kind 'data' | 'annotations' 'annotations' for the reserved EDF Annotations / BDF Annotations labels
label string trimmed; raw.label keeps the padding
transducerType string trimmed free text, e.g. 'AgAgCl electrode'
prefiltering string trimmed free text, e.g. 'HP:0.1Hz LP:75Hz'; never parsed
physicalDimension string the unit exactly as written
unit string physicalDimension with every spelling of micro collapsed to u, for comparison only
physicalMinimum number as declared
physicalMaximum number as declared; may be below the minimum
digitalMinimum number as declared
digitalMaximum number as declared
samplesPerRecord number authoritative — sample indexing uses this, never a rate
sampleRateHz number | undefined samplesPerRecord / recordDurationSeconds; undefined exactly when that duration is 0
sampleCount number samplesPerRecord * header.recordCount
scale EdfScale | undefined undefined when the header does not define a usable gain
recordByteOffset number byte offset of this signal’s block within one data record
recordByteLength number samplesPerRecord * header.bytesPerSample
raw EdfRawSignalFields the ten per-signal fields as written

Three of these repay a second look.

physicalMaximum below physicalMinimum is legal: it encodes a negative amplifier gain, and the EDF FAQ sanctions it. edfcore never swaps the two, because swapping them inverts the signal’s polarity. You get INVERTED_PHYSICAL_RANGE at info severity and a scale that maps the file’s intent.

sampleRateHz is derived and samplesPerRecord is not. A record duration of 0 is legal in EDF+, which makes a sample rate meaningless (hence undefined rather than Infinity). samplesPerRecord is always there, and every offset in the file is computed from it. Index by samplesPerRecord.

unit exists so you can compare units without deciding what the file meant. 'µV', 'μV' and 'uV' all normalise to 'uV'. Nothing else is touched, case is meaningful (mV is not MV), and edfcore does not convert to SI volts. To print what the file said, print physicalDimension.

An annotations signal carries the same fields, but they describe a text region rather than a channel. scale is undefined, unit is '', and recordByteLength is the size of the TAL region in that record.

EdfScale

interface EdfScale {
  readonly bitValue: number;
  readonly offset: number;
}

The conversion is physical = bitValue * (offset + digital). That’s EDFlib’s exact expression, kept verbatim rather than rewritten into the numerically better form. It targets float64 bit-parity with pyEDFlib, so the same file gives the same doubles in both libraries.

scale is undefined when the declared ranges do not define a usable gain: a degenerate or inverted digital range, a degenerate physical range, or a log-transformed channel. decodeDigital keeps working on such a signal, and toPhysical throws EdfScalingError. See physical values for the whole story.

EdfStartTime, EdfCalendarDate, EdfClockTime

EDF records local time at the patient with no timezone, so edfcore never produces a Date. You get plain fields instead. Design decisions has the reasoning.

import { formatStartTimeNaive } from 'edfcore';

const { startTime } = header;

startTime.headerDate;            // { year: 2002, month: 3, day: 2 }
startTime.recordingIdDate;       // { year: 2002, month: 3, day: 2 }
startTime.resolvedDate;          // { year: 2002, month: 3, day: 2 }
startTime.dateSource;            // 'recordingIdField'
startTime.clock;                 // { hour: 22, minute: 30, second: 0 }
startTime.secondsSinceMidnight;  // 81000

formatStartTimeNaive(startTime);  // '2002-03-02T22:30:00.000'
field type meaning
headerDate EdfCalendarDate | undefined from the 8-byte dd.mm.yy field, through the 1985–2084 rule
recordingIdDate EdfCalendarDate | undefined from the EDF+ recording-identification Startdate subfield
resolvedDate EdfCalendarDate | undefined the one edfcore uses; undefined when neither could be read
dateSource 'headerField' | 'recordingIdField' | 'none' which field resolvedDate came from
clock EdfClockTime always present; the header stores whole seconds
secondsSinceMidnight number clock as a single number

recordingIdDate wins when both are readable, because it is the only unambiguous four-digit year and the only way past 2084. The two-digit header field resolves 8599 to 1985–1999 and 0084 to 2000–2084, which is why nearly every EDF file carries DATE_CLIPPED_TO_1985_2084. If the two fields name different days you get DATE_FIELDS_DISAGREE. Both values stay on the object, and dateSource says which one was used.

interface EdfCalendarDate {
  readonly year: number;
  readonly month: number;  // 1-12, NOT a JavaScript month index
  readonly day: number;
}

interface EdfClockTime {
  readonly hour: number;
  readonly minute: number;
  readonly second: number;
}

The month is 1-based. It’s the single most likely thing to go wrong when you hand these numbers to a Date constructor, which is 0-based.

EdfPatientId and EdfRecordingId

Both fields are 80 bytes of free text in plain EDF and a defined subfield grammar in EDF+. edfcore parses the grammar, reports whether it fitted, and keeps the raw text either way.

EdfPatientId type meaning
raw string the 80 bytes as written, padding included
conformant boolean true when the EDF+ four-subfield grammar fitted
code string | undefined subfield 1, the hospital’s patient code
sex 'F' | 'M' | undefined subfield 2
birthDate EdfCalendarDate | undefined subfield 3, dd-MMM-yyyy
name string | undefined subfield 4
extraSubfields readonly string[] anything past the fourth, in order
EdfRecordingId type meaning
raw string the 80 bytes as written
conformant boolean true when the EDF+ grammar fitted
startDate EdfCalendarDate | undefined the Startdate subfield, dd-MMM-yyyy
investigationCode string | undefined the hospital’s investigation code
technicianCode string | undefined who ran the recording
equipmentCode string | undefined what recorded it
extraSubfields readonly string[] anything past the fifth

A subfield that the grammar could not place is undefined rather than guessed. conformant: false means the text is not in the EDF+ shape, which is normal for plain EDF. The conformance checks in edfcore/validate skip these two fields entirely for non-+ files.

The raw field interfaces

Every property of both is a string, holding the field exactly as written before trimming or interpretation. A diagnostic (or you) can then quote the bytes rather than a reconstruction of them.

EdfRawHeaderFields covers the ten fixed-header fields, at these offsets:

field offset length
version 0 8
patientId 8 80
recordingId 88 80
startDate 168 8
startTime 176 8
headerByteLength 184 8
reserved 192 44
recordCount 236 8
recordDuration 244 8
signalCount 252 4

EdfRawSignalFields covers the ten per-signal fields, whose widths are:

field width
label 16
transducerType 80
physicalDimension 8
physicalMinimum 8
physicalMaximum 8
digitalMinimum 8
digitalMaximum 8
prefiltering 80
samplesPerRecord 8
reserved 32

Those widths sum to 256, which is why each signal adds exactly one 256-byte block. The layout is field-major: all ns labels, then all ns transducer types, and so on. Reading it as a struct per signal produces plausible output for a one-signal file, which is how that bug survives a first test.

The time axis

t = 0 is the start of record 0, not the header start time. Record 0’s own timekeeping onset is timeline.startOffsetTicks, the bridge back to the header clock. Every other second edfcore reports (segment, gap, chunk, window bound) is elapsed recording time. This is the EDFlib/pyEDFlib/MNE convention. Under it, sample n of a signal sits at exactly n * recordDuration / samplesPerRecord with no sub-second constant to remember.

RecordRange

interface RecordRange {
  readonly start: number;
  readonly count: number;
}

Start plus count, never start plus end, so there’s no inclusive/exclusive question to get wrong. It’s the argument type for readRecords and readAnnotations, and it comes back on chunk.records and segment.records.

EdfTimeline

openEdf builds one from at most two reads: the timekeeping onsets of the first and last records.

field type meaning
recordCount number records the timeline covers
recordDurationSeconds number copied from the header
startOffsetSeconds number record 0’s sub-second start, in [0, 1)
startOffsetTicks bigint the same value in exact 100 ns ticks
spanSeconds number last record end minus first record start; includes gaps
coveredSeconds number sum of the record durations
diagnostics readonly EdfDiagnostic[] including whatever decoding the probed records turned up

spanSeconds and coveredSeconds are computed independently rather than derived from each other. Their being equal is the statement “this file is contiguous as far as two reads can tell”. On the discontinuous file used throughout this page (six one-second records with a ten-second gap in the middle) they are 16 and 6.

Note Two probes detect any net drift of the timeline, but they are not a proof of contiguity: a gap that a later overlap cancels exactly leaves both ends where a contiguous file puts them. buildRecordIndex reads every onset and does see it.

EdfRecordIndex

The index answers “which record holds second t”. How much it can answer depends on coverage.

import { buildRecordIndex } from 'edfcore';

const index = await buildRecordIndex(recording);

index.coverage;   // 'complete'
index.segments;   // two EdfSegment entries
index.gaps;       // one EdfGap between them

await index.onsetTicks(3);  // 130000000n
await index.locate(12.5);   // undefined — that second is inside the gap
await index.locate(13);     // { recordIndex: 3, recordStartSeconds: 13, offsetInRecordSeconds: 0 }
field type meaning
coverage 'probed' | 'complete' 'probed' = record 0 and the last record only; 'complete' = every record verified
recordCount number records the index covers
segments readonly EdfSegment[] | undefined present only when coverage === 'complete'
gaps readonly EdfGap[] | undefined likewise
onsetTicks (recordIndex: number, options?: ReadOptions) => Promise<bigint> one targeted read of that record’s annotation region, memoised
locate (seconds: number, options?: ReadOptions) => Promise<EdfLocation | undefined> binary search, O(log recordCount) probes

segments and gaps are undefined on a probed index (the one openEdf hands you), which isn’t a claim that the file has no gaps. Nobody has read the onsets in between.

locate returns undefined for a second that falls inside a gap or outside the recording, which is a different thing from an error. It verifies monotonicity at every pair its search touches, and a violation throws EdfFormatError with TIMELINE_NOT_MONOTONIC rather than returning a plausible record. onsetTicks throws EdfRangeError for a record index that does not exist.

EdfSegment, EdfGap, EdfLocation

A segment is a maximal run of records whose onsets are spaced by exactly one record duration, in exact ticks. The comparison is exact rather than “within an epsilon”: a float tolerance is how a one-sample overlap becomes invisible.

EdfSegment type meaning
index number position in index.segments
records RecordRange the records in this run
startSeconds number elapsed recording time at the run’s first record
startTicks bigint the same instant, exact
durationSeconds number records.count * recordDurationSeconds
endSeconds number startSeconds + durationSeconds
EdfGap type meaning
beforeSegmentIndex number the segment that ends at the gap
afterSegmentIndex number the segment that starts after it
startSeconds number where the earlier segment ends
endSeconds number where the later segment starts
durationSeconds number endSeconds - startSeconds

There is exactly one gap per adjacent pair of segments, so gaps.length === segments.length - 1 (or 0). A gap’s duration is non-negative on any file whose onsets are monotonic and correctly spaced. A negative one means two records overlap in time, which validateRecording reports as RECORD_ONSET_SPACING_VIOLATION.

EdfLocation type meaning
recordIndex number the record containing the requested second
recordStartSeconds number that record’s start, in elapsed recording time
offsetInRecordSeconds number how far into the record the requested second falls

Samples and annotations

EdfChunk and EdfChunkSignal

A chunk is what a read returns. It’s always record-aligned, so it’s usually wider than the window you asked for. trimToWindow narrows one signal exactly.

import { readRecords } from 'edfcore';

const chunk = await readRecords(
  { ...recording, index },
  { records: { start: 3, count: 1 }, signalIndices: [0] },
);

chunk.records;         // { start: 3, count: 1 }
chunk.startSeconds;    // 13 — this record's TRUE start, read from its timekeeping TAL
chunk.durationSeconds; // 1
chunk.byteOffset;      // 1548 — where in the file these records begin
chunk.byteLength;      // 260 — bytes actually read
chunk.precededByGap;   // the EdfGap immediately before, or undefined

const [first] = chunk.signals;
first.digital;         // Int32Array(100)
first.firstSampleIndex;// 300 — index on this signal's own sample grid
EdfChunk type meaning
records RecordRange the records this chunk holds
startSeconds number the first record’s true start, in elapsed recording time
durationSeconds number the chunk’s span: last record end minus first record start
byteOffset number header.headerByteLength + records.start * header.recordByteLength
byteLength number bytes actually read from the source
signals readonly EdfChunkSignal[] one entry per requested signal, in the order you asked
precededByGap EdfGap | undefined the gap immediately before these records
diagnostics readonly EdfDiagnostic[] defects in the annotation regions inside these records

durationSeconds is a span, not a coverage. The two are equal for one contiguous run, which is what readWindow produces, and they differ when you name records across a gap yourself.

byteLength is on the object so overread is visible. precededByGap is undefined on a probed index for the same reason index.segments is: nobody has read the onsets in between. diagnostics costs nothing. The annotation regions of a record live in the same bytes as its samples, so a defect there is found while decoding the data you already asked for.

EdfChunkSignal type meaning
signalIndex number index into header.signals
sampleCount number the truth; never padded to a round number
digital Int32Array the samples, sign-extended, unscaled and unclamped
firstSampleIndex number records.start * signal.samplesPerRecord, on this signal’s own grid
startSeconds number the chunk’s start; per-signal because samplesPerRecord differs between signals
outOfDigitalRangeCount number samples outside the declared digital range

digital is an Int32Array for both EDF and BDF, because a 24-bit sample doesn’t fit an Int16Array and a Float64Array quadruples the memory to hold integers. Pass it to toPhysical when you want units.

outOfDigitalRangeCount above zero means the declared range is wrong, not that the samples are. edfcore never clamps. The count is produced by the same pass that decodes the samples, so it costs nothing.

EdfAnnotation and EdfAnnotationsResult

import { readAnnotations } from 'edfcore';

const result = await readAnnotations(recording, { start: 0, count: header.recordCount });
const [event] = result.annotations;

event.onsetSecondsFromHeaderStart;  // 1.5
event.onsetSecondsFromFirstRecord;  // 1.5
event.onsetTicks;                   // 15000000n — compare with this
event.onsetRaw;                     // '+1.5'
event.durationSeconds;              // 0.25
event.text;                         // 'Lights off'
event.recordIndex;                  // 1

result.recordOnsetTicks;  // BigInt64Array [0n, 10000000n, 20000000n, 130000000n, ...]
EdfAnnotation type meaning
onsetSecondsFromHeaderStart number the verbatim on-disk value, relative to the header startdate/starttime (EDF+ 2.2.4)
onsetSecondsFromFirstRecord number rebased to the first record’s true start — the EDFlib/pyEDFlib/MNE convention
onsetTicks bigint exact, in 100 ns units, on the same axis as the rebased value
onsetRaw string the original digits, including the mandatory sign
durationSeconds number | undefined undefined when the TAL carried no duration
durationTicks bigint | undefined the same, exact
durationRaw string | undefined the original digits
text string verbatim; never trimmed, never case-folded
channelLabel string | undefined from the EDF+ description@@channel convention
signalIndex number which annotation signal carried it
recordIndex number which data record it was written into
byteOffsetInRecord number where inside that record, for a hexdump
textEncoding 'utf-8' | 'latin-1-fallback' 'latin-1-fallback' when the bytes were not valid UTF-8

Both onset conventions ship as separately named fields rather than as an option, so a downstream comparison never depends on how the call was configured. Compare event times with onsetTicks and nothing else. Float equality on event times is how ERP alignment breaks without anyone noticing.

EdfAnnotationsResult type meaning
annotations readonly EdfAnnotation[] timekeeping TALs and empty texts excluded
recordOnsetTicks BigInt64Array one entry per record in the decoded range
diagnostics readonly EdfDiagnostic[] TAL and timekeeping defects

annotations is stably sorted by (onsetTicks, signalIndex, byteOffsetInRecord), so two files that carry the same events in the same records give you the same order.

recordOnsetTicks always has one entry for every record in the decoded range, never a hole and never a sentinel. A record whose timekeeping TAL is missing gets the derived onset start + recordIndex * duration. TIMEKEEPING_TAL_MISSING carries that record’s index, so the derivation stays visible. Every timeline in edfcore is built from this array.

Results

EdfRecording

interface EdfRecording {
  readonly source: ByteSource;
  readonly header: EdfHeader;
  readonly timeline: EdfTimeline;
  readonly index: EdfRecordIndex;
}

A plain struct with no methods and no hidden state, which is what makes upgrading the index a spread rather than an API:

import { buildRecordIndex, readWindow } from 'edfcore';

const index = await buildRecordIndex(recording);
const chunks = await readWindow(
  { ...recording, index },
  { startSeconds: 0, durationSeconds: 30, signalIndices: [0] },
);

It doesn’t own the source. Closing is yours: await recording.source.close?.().

EdfDiagnostic

field type meaning
code EdfDiagnosticCode a stable identifier, e.g. 'HEADER_SIZE_MISMATCH'
severity EdfSeverity 'error' | 'warning' | 'info', derived from the code
message string names the field, the bytes as written, the rule, and a next step
field string | undefined the header field or structure at fault
byteOffset number | undefined where in the file
byteLength number | undefined how many bytes
rawBytes Uint8Array | undefined a copy of the offending bytes, capped in length
raw string | undefined those bytes as text, padding included
expected string | undefined the rule, as a short string
actual string | undefined the observation
signalIndex number | undefined which signal
recordIndex number | undefined which record
specReference string | undefined the clause it violates, e.g. 'EDF+ additional specification 5'

rawBytes is a copy rather than a view, because a diagnostic outlives the read that produced it and an I/O adapter is free to reuse its buffer. The full code table and the error classes are in errors and diagnostic codes.

EdfInspection

Header-only triage. inspectEdf reads at most 128 KiB and never throws about content, so a file that isn’t an EDF at all comes back as a value rather than an exception.

import { inspectEdf } from 'edfcore';

const inspection = await inspectEdf(source);

inspection.ok;          // true
inspection.variant;     // 'EDF+D'
inspection.byteLength;  // 2328 — the source's size
inspection.bytesRead;   // 768 — what inspection actually cost
field type meaning
ok boolean the header parsed and carried no error-severity diagnostic
variant EdfVariant | undefined a separate best effort; readable long after the rest stops making sense
header EdfHeader | undefined undefined when parsing was refused
byteLength number the source’s size
bytesRead number what the inspection cost
headerBytes Uint8Array | undefined the bytes it looked at, for a hexdump
diagnostics readonly EdfDiagnostic[] including the one that would otherwise have been thrown

A signal with no defined scale makes ok false even when the header itself is readable, because physical units are unavailable for that channel. Warnings leave ok true. See diagnostics and errors for the triage workflow.

The rest of types.ts

The remaining exports are options and selections rather than results. They describe what you hand edfcore, not what it hands back.

type what it is
ReadOptions signal and maxMaterializeBytes, accepted by every call that reads
ParseOptions strict; diagnostics lists which calls take it and which do not
OpenOptions ParseOptions & ReadOptions
ByteSource the random-access reader everything is built over
RecordSelection { records, signalIndices } for readRecords
WindowSelection { startSeconds, durationSeconds, signalIndices } for readWindow
DecodeAnnotationsOptions ParseOptions plus signalIndices
BuildIndexOptions ParseOptions & ReadOptions plus onProgress
HttpSourceOptions fetch, headers, byteLength, maxConcurrency, allowFullDownload
CacheOptions blockBytes and maxBytes for cachedSource
AbortSignalLike, BlobLike, FetchLike, HttpResponseLike structural shims so the published types name neither the DOM nor @types/node

signalIndices is required on both selections, with no “all signals” default: a 256-channel file must never be read wholesale because an argument was omitted.

API — sources is the reference for ByteSource and its adapters, and API — reading is the reference for the calls that consume these options. Data sources, large files and reading signals are the guides behind them.