API — primitives
Every function here takes bytes or plain values and returns plain values. No promises, no source, no hidden state. Each one is testable from a Uint8Array literal with no mocks.
The primitives are the layer the reading functions are built out of. Reach for them directly when you already hold bytes (from a WebSocket, a zip entry, a database blob), or to decode the same buffer twice without re-reading it.
Each function’s result shape is summarised with it. Types lists every public shape in full.
import {
parseHeader,
decodeDigital,
toPhysical,
clampToDigitalRange,
decodeAnnotations,
resolveTimeWindow,
trimToWindow,
findSignals,
getSignal,
isAnnotationLabel,
decodeHeaderLatin1,
formatStartTimeNaive,
formatDiagnostics,
} from 'edfcore';
parseHeader
function parseHeader(
headerBytes: Uint8Array,
sourceByteLength: number,
options?: ParseOptions,
): EdfHeader
Parses an EDF, EDF+, BDF or BDF+ header.
headerBytes must hold at least 256 * (ns + 1) bytes. Anything beyond that is ignored, so a caller that over-read is free to pass its whole buffer.
sourceByteLength is a required positional argument, and it is the size of the whole file, not the length of headerBytes. Two parsing steps need it, and neither can be skipped:
- Recovering
recordCount = -1. That value is the EDF+ sanctioned way of saying the writer never closed the file, and it’s common in real corpora. The only way to learn the real count isfloor((sourceByteLength - headerByteLength) / recordByteLength). Without the file size there is no answer. - Detecting truncation. A header declaring 2880 records over a file that only holds 2879 whole ones is a truncated download. The difference between those two numbers is the only evidence of it. edfcore reports
TRUNCATED_FILEand setsrecordCountto what is actually present.
Pass bytes.byteLength for an in-memory file, or source.byteLength for a ByteSource. Passing the length of a header-only buffer isn’t an error edfcore can detect. It reports recordCount: 0 with recordCountSource: 'sourceByteLength', which is the correct answer to the question you asked.
Throws a plain RangeError when sourceByteLength is not a non-negative safe integer, which is a caller bug rather than a file defect. Throws EdfFormatError for a defect that makes further parsing meaningless: fewer than 256 bytes, a header shorter than the signal count implies, or a zero-byte data record. An EDF+ file with no annotations signal throws too.
import { parseHeader } from 'edfcore';
const bytes = new Uint8Array(await file.arrayBuffer());
const header = parseHeader(bytes, bytes.byteLength);
console.log(header.variant); // 'EDF+C'
console.log(header.recordCount); // 10
console.log(header.recordCountSource); // 'headerField'
Validation order is pinned and tested. Under strict: true the first would-be diagnostic throws, so the order decides which error a broken file reports. Moving a check is a behavioural change even when every individual check is unaltered.
EdfHeader
| Field | Type | Meaning |
|---|---|---|
variant |
EdfVariant |
'EDF' | 'EDF+C' | 'EDF+D' | 'BDF' | 'BDF+C' | 'BDF+D'. |
continuity |
'continuous' | 'discontinuous' |
From the reserved marker. Plain EDF and BDF are continuous. |
bytesPerSample |
2 | 3 |
2 for EDF, 3 for BDF. |
headerByteLength |
number |
Always the computed 256 * (ns + 1), never the declared value. |
declaredHeaderByteLength |
number |
What the file claims at offset 184, kept so a mismatch stays visible. |
recordByteLength |
number |
bytesPerSample * sum(samplesPerRecord). Every data offset steps by this. |
dataByteLength |
number |
recordCount * recordByteLength. |
recordDurationSeconds |
number |
May legitimately be 0. Never divide by it. |
recordDurationTicks |
bigint |
The same value in exact 100 ns ticks, taken from the digits on disk rather than from the parsed float. |
recordCount |
number |
Resolved and non-negative. What is actually readable. |
declaredRecordCount |
number |
Verbatim. -1 means the writer never closed the file. |
recordCountSource |
'headerField' | 'sourceByteLength' |
Which of the two produced recordCount. |
startTime |
EdfStartTime |
Date and clock, with both candidate dates kept. |
patient |
EdfPatientId |
Parsed local patient identification. |
recording |
EdfRecordingId |
Parsed local recording identification. |
signals |
readonly EdfSignal[] |
Every signal, data and annotations alike, in file order. |
dataSignalIndices |
readonly number[] |
Indices of the sample-carrying signals. |
annotationSignalIndices |
readonly number[] |
Indices of the annotations signals. Element 0 is the one carrying timekeeping. |
reserved |
string |
The full 44 reserved bytes, verbatim. |
raw |
EdfRawHeaderFields |
Every fixed-header field as written, before trimming or interpretation. |
rawBytes |
Uint8Array |
A copy of the whole header, for hexdumps and bug reports. |
diagnostics |
readonly EdfDiagnostic[] |
Everything the parse found. Empty under strict, because the first one threw. |
raw holds version, patientId, recordingId, startDate, startTime, headerByteLength, reserved, recordCount, recordDuration and signalCount, all as string. Anything that can be checked against the file is exposed twice: as parsed value, and as the bytes it came from.
EdfSignal
| Field | Type | Meaning |
|---|---|---|
index |
number |
Position in header.signals. |
kind |
'data' | 'annotations' |
Decided by the reserved label, not by anything you pass in. |
label |
string |
Trimmed. raw.label keeps the padding. |
transducerType |
string |
Trimmed. |
prefiltering |
string |
Trimmed. |
physicalDimension |
string |
Exactly as written. |
unit |
string |
Normalised for comparison only. The several encodings of micro all become u. |
physicalMinimum |
number |
As declared. |
physicalMaximum |
number |
May be less than the minimum. That is a negative amplifier gain, it is legal, and edfcore never “fixes” it. |
digitalMinimum |
number |
As declared. |
digitalMaximum |
number |
As declared. |
samplesPerRecord |
number |
Authoritative. Sample indexing uses this, never a rate. |
sampleRateHz |
number | undefined |
Derived. undefined exactly when recordDurationSeconds === 0. Never index by this. |
sampleCount |
number |
samplesPerRecord * header.recordCount. |
scale |
EdfScale | undefined |
{ bitValue, offset }, or undefined when scaling is impossible or unsafe. |
recordByteOffset |
number |
Byte offset of this signal’s block within one data record. |
recordByteLength |
number |
samplesPerRecord * header.bytesPerSample. |
raw |
EdfRawSignalFields |
All ten per-signal fields as written. |
scale is undefined for a degenerate or inverted digital range, a degenerate physical range, or a log-transformed channel. toPhysical then throws; decodeDigital keeps working.
EdfStartTime
EDF records local time at the patient with no timezone, so edfcore never produces a Date. A Date applies the reader’s zone, and is worst exactly at DST boundaries.
| Field | Type | Meaning |
|---|---|---|
headerDate |
EdfCalendarDate | undefined |
From the dd.mm.yy field, through the 1985–2084 rule. |
recordingIdDate |
EdfCalendarDate | undefined |
From the EDF+ recording-identification Startdate subfield (the only unambiguous four-digit year, and the only way past 2084). |
resolvedDate |
EdfCalendarDate | undefined |
recordingIdDate when present, otherwise headerDate. |
dateSource |
'headerField' | 'recordingIdField' | 'none' |
Which one won. |
clock |
EdfClockTime |
{ hour, minute, second }. Reports midnight when the field was unparseable, and says so in a diagnostic. |
secondsSinceMidnight |
number |
Derived from clock. |
EdfCalendarDate is { year, month, day } with month in 1–12, not a JavaScript month index. A disagreement between the two dates is always reported as DATE_FIELDS_DISAGREE, and both stay on the result.
decodeDigital
function decodeDigital(
header: EdfHeader,
recordBytes: Uint8Array,
records: RecordRange,
signalIndex: number,
out?: Int32Array,
options?: MaterializeOptions,
): Int32Array
De-interleaves one signal out of a record range and sign-extends its samples. This is the sole owner of the 2- and 3-byte two’s complement expressions. EDF is little-endian 16-bit; BDF is little-endian 24-bit sign-extended from bit 23. There’s no big-endian variant of either.
recordBytes must be exactly records.count * header.recordByteLength bytes and must begin at record records.start. Pass the buffer readRecordBytes returned, unsliced. The range check catches asking for records the file doesn’t have. The length check catches a buffer that doesn’t start where records.start says it does. Nothing in the bytes identifies which record they came from, so that case is unrecoverable rather than merely wrong.
Returns an Int32Array of records.count * signal.samplesPerRecord samples, still in digital units.
out is the reuse path. When supplied and long enough it is written in place and no allocation happens. A longer array is narrowed with subarray, which shares its memory, so result.length still equals the true sample count. An out that is too short throws a plain RangeError.
Throws EdfChannelNotFoundError for a signal the header does not have, EdfRangeError for a bad range or a mis-sized buffer, and EdfBudgetError when the allocation would exceed maxMaterializeBytes. The budget check runs before the allocation, never during.
import { readRecordBytes, decodeDigital } from 'edfcore';
const records = { start: 0, count: 2 };
const bytes = await readRecordBytes(source, header, records);
// Fresh allocation.
const digital = decodeDigital(header, bytes, records, 0);
// Or reuse across a loop — zero allocations after the first.
const scratch = new Int32Array(64);
const again = decodeDigital(header, bytes, records, 0, scratch);
console.log(again.buffer === scratch.buffer); // true
Warning Never pass an annotations signal’s index here. Its bytes are TAL text; decoded as samples they produce numbers that look exactly like a signal.
decodeDigitaldoes not check for it (the check lives inreadRecords), so select fromheader.dataSignalIndices.
MaterializeOptions
The trailing options argument on every primitive that can allocate.
| Field | Type | Default | Meaning |
|---|---|---|---|
maxMaterializeBytes |
number |
268435456 (256 MiB) |
Refused before the allocation, never during it. |
Passing nothing keeps the default. ReadOptions also carries this field, so the same value flows through a read and the decode that follows it.
toPhysical
function toPhysical(
signal: EdfSignal,
digital: ArrayLike<number>,
out?: Float64Array,
options?: MaterializeOptions,
): Float64Array
Converts digital counts into the signal’s own physical units, in float64 throughout. digital is any ArrayLike<number>: an Int32Array from decodeDigital, or a plain array.
The expression is physical = bitValue * (offset + digital), and it is pinned. It is numerically worse than the obvious physicalMinimum + (digital - digitalMinimum) * gain. It is EDFlib’s exact form, kept verbatim so that edfcore’s float64 output can be compared bit for bit against pyEDFlib and EDFlib. That parity is the design target. It isn’t yet demonstrated by a golden-value harness, so treat it as intent rather than a measured guarantee. The two forms disagree by up to about 9.3e-10 LSB (ten orders of magnitude below the quantisation floor). On asymmetric ranges they differ on nearly half the samples by one ULP. If your pipeline cross-validates against a Python reader, that shared expression is why the numbers are expected to agree.
Float64 is a contract, never Float32. Float32 carries 24 significand bits, so a 24-bit BDF sample scaled into it loses about a quarter of a quantisation step.
Throws EdfScalingError when signal.scale is undefined. The error carries code, signalIndex and label, and its message names the raw header fields it read. decodeDigital keeps working on that signal.
EdfScalingError.code |
Cause |
|---|---|
DEGENERATE_DIGITAL_RANGE |
digitalMinimum === digitalMaximum, which makes the gain a division by zero. |
DEGENERATE_PHYSICAL_RANGE |
physicalMinimum === physicalMaximum, so every sample maps to one value. |
INVERTED_DIGITAL_RANGE |
digitalMinimum > digitalMaximum, and edfcore will not guess which the writer meant. |
LOG_TRANSFORMED_CHANNEL |
Physical dimension Filtered: the values are log-compressed and the linear formula would be wrong by orders of magnitude. |
SCALE_UNAVAILABLE |
No scale, and none of the above explains it. |
out behaves exactly as in decodeDigital: reused when long enough, narrowed with subarray when longer, a plain RangeError when shorter.
import { getSignal, toPhysical } from 'edfcore';
const signal = getSignal(header, 'Fp1');
const microvolts = toPhysical(signal, digital);
// Same length, same order, now in signal.physicalDimension units.
console.log(microvolts.length === digital.length); // true
console.log(signal.physicalDimension); // 'uV'
clampToDigitalRange
function clampToDigitalRange(
signal: EdfSignal,
digital: Int32Array,
out?: Int32Array,
options?: MaterializeOptions,
): Int32Array
Clamps samples to the declared digital range. Post-hoc only. Nothing on the read path calls this.
It exists for one job: cross-validating against a consumer that clamps. EDFlib clamps samples to the declared digital range when it loads them. If your numbers differ from a Python reader’s only at the extremes, this is the difference.
Clamping is to [min(digMin, digMax), max(digMin, digMax)] rather than to [digMin, digMax]. That matters for an inverted declaration, where the naive bounds are empty and collapse every sample onto a single value.
Throws a plain RangeError when either bound is not a finite number. Every comparison against NaN is false, so a clamp against one returns the input unchanged.
const clamped = clampToDigitalRange(signal, digital);
decodeAnnotations
function decodeAnnotations(
header: EdfHeader,
recordBytes: Uint8Array,
records: RecordRange,
options?: DecodeAnnotationsOptions,
): EdfAnnotationsResult
Decodes the TALs in the annotation regions of a record range. Same buffer contract as decodeDigital: exactly records.count * header.recordByteLength bytes, beginning at records.start, or EdfRangeError.
The returned shape is documented under readAnnotations, which is this function plus one read.
Three rules the rest of the library depends on:
- The first TAL of the first annotations signal of the file is that record’s timekeeping TAL. “First” is a position, not “the first one that parsed”. “First annotations signal” means
header.annotationSignalIndices[0], whatever this call was asked for. An additional annotations signal carries no timekeeping, so its first TAL is a real event and stays. recordOnsetTickshas one entry for every record in the decoded range, always.- Onsets are exposed under both conventions as separately named fields, never as an option.
Passing signalIndices that omits the timekeeping signal is legal and means no timekeeping is read. Every recordOnsetTicks entry then falls back to the nominal grid. Passing a data-signal index throws a plain RangeError.
Diagnostic volume is bounded. TIMEKEEPING_TAL_MISSING is reported per record, because it names a record whose onset was derived and that information exists nowhere else. NEGATIVE_ANNOTATION_ONSET and TIMEKEEPING_TAL_NONCONFORMANT are reported once per call. Grammar defects are deduplicated per region and carry an occurrence count.
import { readRecordBytes, decodeAnnotations } from 'edfcore';
const records = { start: 0, count: header.recordCount };
const bytes = await readRecordBytes(source, header, records);
const { annotations, recordOnsetTicks } = decodeAnnotations(header, bytes, records);
// Text verbatim, onset exact in 100 ns ticks.
console.log(annotations[0].text, annotations[0].onsetTicks);
// One entry per record, always — record 1 of a 1 s file starts at 10000000n.
console.log(recordOnsetTicks.length === records.count);
Record-onset spacing and monotonicity are not checked here. This function produces recordOnsetTicks, and the timeline layer owns what a valid timeline is.
resolveTimeWindow
function resolveTimeWindow(
timeline: EdfTimeline,
index: EdfRecordIndex,
startSeconds: number,
durationSeconds: number,
): readonly RecordRange[]
Answers “which records does this window cost?” before a byte is read, so the price of a window is auditable. This is the function readWindow calls first.
Returns one RecordRange per contiguous run the window overlaps, in time order. Empty when the window falls entirely inside a gap, entirely outside the recording, or has a non-positive duration. The interval is half-open [start, start + duration), so a zero-length window contains no time and therefore no samples.
Ranges are record-aligned and are therefore usually wider than the window. A record is the smallest unit the file can be read by; trimToWindow is how you narrow the samples afterwards.
With index.segments present (coverage === 'complete') the answer is exact. With a probed index it is exact only while the file is contiguous, which is precisely what spanSeconds === coveredSeconds states. Otherwise this function throws a plain RangeError rather than guessing at onsets nobody has read.
Every comparison inside is integer or rational arithmetic on ticks, records and samplesPerRecord. round(t * sampleRateHz) appears nowhere. sampleRateHz is derived and often not representable (256/3 Hz is a real record duration of 3 s with 256 samples). Rounding through it walks the answer off by a sample near every large t.
// One second from t = 2.5, on a file with 1 s records: it straddles records 2 and 3.
const ranges = resolveTimeWindow(recording.timeline, recording.index, 2.5, 1);
// [ { start: 2, count: 2 } ]
const bytes = ranges.reduce(
(total, r) => total + r.count * recording.header.recordByteLength,
0,
);
console.log(`this window costs ${bytes} bytes in ${ranges.length} read(s)`);
trimToWindow
function trimToWindow(
header: EdfHeader,
chunkSignal: EdfChunkSignal,
startSeconds: number,
durationSeconds: number,
): EdfChunkSignal
Narrows one record-aligned EdfChunkSignal to exactly the samples inside [startSeconds, startSeconds + durationSeconds).
Sample j of the chunk starts at chunkSignal.startSeconds + j * recordDuration / samplesPerRecord, so the samples inside the window are those with j * recordDuration >= relativeStart * samplesPerRecord and j * recordDuration < relativeEnd * samplesPerRecord. Both comparisons are integer bigint products of on-disk quantities (no division, no sample rate, no float bound). The boundary sample is therefore the same one on every platform.
digital in the result is a subarray view of the input’s, so trimming allocates nothing and the two share memory. sampleCount is taken from the view, so the count and the data cannot disagree. outOfDigitalRangeCount is re-counted only when it can have changed and only when there is something to find.
The chunk must be one contiguous run of records (what readWindow returns), because that’s what makes the sample grid uniform across it. A window that only partly overlaps the chunk is clamped to the samples that exist. One that misses it entirely yields a zero-length result rather than an error.
Throws EdfChannelNotFoundError when chunkSignal.signalIndex is not in header.signals, which means you passed a different header than the chunk was read with.
import { readWindow, trimToWindow } from 'edfcore';
const chunks = await readWindow(recording, {
startSeconds: 1.4,
durationSeconds: 0.5,
signalIndices: [0, 1],
});
for (const chunk of chunks) {
for (const signal of chunk.signals) {
const exact = trimToWindow(header, signal, 1.4, 0.5);
console.log(signal.signalIndex, exact.sampleCount, exact.startSeconds);
}
}
// On a file with 1 s records, signal 0 at 256 samples/record and signal 1 at 3:
// 0 128 1.40234375 <- 256 Hz: the first sample at or after 1.4 s
// 1 1 1.6666666666666667 <- 3 Hz: a coarser grid, so the boundary lands later
That per-signal difference is why startSeconds lives on EdfChunkSignal rather than only on the chunk. Two signals of different rates have different first samples inside the same window.
findSignals
function findSignals(header: EdfHeader, label: string): readonly EdfSignal[]
Every signal with this label, in signal order. Empty when none matches. Matching is exact on the trimmed label and is case-sensitive; the argument is trimmed too, so ' Fp1 ' matches 'Fp1'.
Nothing else is normalised. EDF labels are electrode names, 'Fp1' and 'FP1' are written by different systems, and edfcore has no montage vocabulary to decide they are the same thing.
const matches = findSignals(header, 'T8-P8');
console.log(matches.map((s) => s.index)); // [ 0, 1 ] — CHB-MIT really does this
getSignal
function getSignal(header: EdfHeader, selector: number | string): EdfSignal
One signal, by index or by label. A numeric selector indexes header.signals directly.
Throws EdfChannelNotFoundError when nothing matches, carrying selector and availableLabels so the message can list what the file actually has. Throws EdfAmbiguousChannelError when a label matches more than one signal, carrying label and matchingIndices.
That second case is the reason this function exists. signals.find(s => s.label === label) returns the first of a duplicate pair and reports nothing about the second.
import { getSignal, EdfAmbiguousChannelError, type EdfHeader, type EdfSignal } from 'edfcore';
function resolve(header: EdfHeader, label: string): EdfSignal {
try {
return getSignal(header, label);
} catch (error) {
if (error instanceof EdfAmbiguousChannelError) {
// Duplicates are real. Decide deliberately which one you meant.
console.warn(`${label} matches indices ${error.matchingIndices.join(', ')}`);
return getSignal(header, error.matchingIndices[0]);
}
throw error;
}
}
instanceof is fine inside a single realm; if the header could have crossed a worker or iframe boundary, branch on isEdfError(error) && error.edfErrorKind === 'channel' instead.
isAnnotationLabel
function isAnnotationLabel(label: string): boolean
True for the two reserved annotation labels, 'EDF Annotations' and 'BDF Annotations', matched on the trimmed text and case-sensitively.
Both are accepted for either family: the label identifies the channel’s role, and a BDF+ file written by an EDF+ library carries 'EDF Annotations'.
You rarely need this, since signal.kind already says 'annotations'. It’s the same predicate the parser uses, so it’s here for code that classifies a label before it has a header.
isAnnotationLabel('EDF Annotations '); // true
isAnnotationLabel('Fp1'); // false
decodeHeaderLatin1
function decodeHeaderLatin1(bytes: Uint8Array): string
Decodes header bytes as ISO-8859-1: byte b becomes code point U+00b, always. No trimming, no interpretation.
TextDecoder is not used on this path. Verified on Node v24.4.0, TextDecoder('latin1'), 'iso-8859-1', 'ascii' and 'windows-1252' all report encoding === 'windows-1252' yet decode 0x80 as U+0080. The WHATWG Encoding Standard mandates U+20AC for those labels, so the same header bytes produce different strings in Node and in a spec-compliant browser. String.fromCharCode is the identity map ISO-8859-1 actually is, and it’s the same everywhere.
decodeHeaderLatin1(bytes.subarray(0, 8)); // '0 '
formatStartTimeNaive
function formatStartTimeNaive(startTime: EdfStartTime): string | undefined
Renders the recording start as '1951-08-02T09:00:00.000'. There is no zone designator, because EDF has no zone. Returns undefined when startTime.resolvedDate is undefined, i.e. the file carries no resolvable date.
The milliseconds are always .000. The header stores whole seconds. The sub-second start of an EDF+ recording lives in record 0’s timekeeping TAL (timeline.startOffsetSeconds).
Do not feed the result to new Date() without deciding what zone you mean.
formatStartTimeNaive(header.startTime); // '1951-08-02T09:00:00.000', or undefined
formatDiagnostics
function formatDiagnostics(
diagnostics: readonly EdfDiagnostic[],
options?: FormatDiagnosticsOptions,
): string
Renders a diagnostic array as a multi-line report, one block per diagnostic. Returns '' for an empty list, so the result concatenates into a larger report without a stray blank line.
Layout only. By the message contract a diagnostic’s own message already names the field, the raw bytes as written, the rule and the next step. This adds structure: severity marker, code, location, bytes as hex. Output is deterministic: no locale-sensitive formatting, no unordered iteration, and no ANSI escapes unless you ask for them.
FormatDiagnosticsOptions |
Type | Default | Meaning |
|---|---|---|---|
color |
boolean |
false |
ANSI colour by severity: red for error, yellow for warning, cyan for info, dim for detail lines. |
maxItems |
number |
all | Show at most this many, then a dim ... and N more. A non-finite value is ignored; 0 shows only the summary line. |
Raw byte runs are elided after 24 bytes with a +N more count: a report is a summary, not a hex dump.
import { formatDiagnostics } from 'edfcore';
console.log(formatDiagnostics(header.diagnostics, { color: true, maxItems: 10 }));
warning [DATE_CLIPPED_TO_1985_2084] startdate field (8 bytes at offset 168) is "01.01.20": its
two-digit year was resolved to 2020 by the EDF+ rule that 85..99 mean 1985..1999 and 00..84
mean 2000..2084, so the field cannot express a year outside that span. ...
at byte offset 168 (8 bytes), startDate
raw: "01.01.20"
expected: 1985..2084
actual: 2020
spec: EDF+ additional specification 2 (startdate and starttime)
Constants
| Constant | Value | Meaning |
|---|---|---|
EDF_HEADER_BLOCK_BYTES |
256 |
The fixed header is one block, and each signal adds exactly one more. Total header size is always 256 * (signalCount + 1). |
EDF_ANNOTATIONS_LABEL |
'EDF Annotations' |
The reserved label, trimmed and case-sensitive. On disk the field is 'EDF Annotations ' (15 characters plus a pad). |
BDF_ANNOTATIONS_LABEL |
'BDF Annotations' |
The BDF spelling. Either is accepted for either family. |
EDF_DIGITAL_MIN |
-32768 |
16-bit two’s complement (the EDF sample range). |
EDF_DIGITAL_MAX |
32767 |
|
BDF_DIGITAL_MIN |
-8388608 |
24-bit two’s complement, sign-extended from bit 23 (the BDF sample range). |
BDF_DIGITAL_MAX |
8388607 |
|
EDF_RECOMMENDED_MAX_RECORD_BYTES |
61440 |
An EDF specification recommendation, not a limit. Exceeding it is a warning, never an error. Reads are record-aligned, so it is also the smallest amount of data any read of that file can return. |
TICKS_PER_SECOND |
10000000n |
A bigint. Time is compared in exact 100 ns ticks, never in floats. Float equality on event times is how ERP alignment breaks. |
VERSION |
'0.1.0' |
The published package version, kept in sync with package.json by a test. |
import { TICKS_PER_SECOND, EDF_DIGITAL_MAX, VERSION } from 'edfcore';
const halfSecond = TICKS_PER_SECOND / 2n; // 5000000n
const saturated = digital.filter((v) => v === EDF_DIGITAL_MAX).length;
console.log(`edfcore ${VERSION}`);
VERSION is '0.1.0' at the time of writing, and that number is meant literally. edfcore is checked against real public corpora (see the installation page). It has not been compared element by element against pyEDFlib or MNE, so treat what it tells you about a file as a well-argued claim rather than a settled one.