Annotations
EDF+ stores events as text inside a signal, not in a table beside the samples. This page covers how to read them and the two onset conventions every reader chooses between. It also covers the integer arithmetic that turns an event time into a sample index without drifting.
Where the events live
EDF+ did not add an events table to the format. It added a channel. One signal in the header is
labelled EDF Annotations, or BDF Annotations in a BDF+ file. edfcore accepts either label in
either family, because the label names the channel’s role. In every data record, that signal’s
block of bytes holds text instead of samples.
The text is a Time-stamped Annotations List, a TAL:
Onset [ 0x15 Duration ] 0x14 *( Text 0x14 ) 0x00
The onset carries a mandatory sign. The duration never does. 0x14 terminates the timestamp and
each text run, 0x15 separates onset from duration, and 0x00 ends the TAL. A record’s region
holds as many TALs as fit, and the rest of the region is 0x00 padding.
The region is exactly samplesPerRecord * bytesPerSample bytes. The annotations signal declares a
samplesPerRecord like any other, and that number is what buys the writer room for text. A signal
declaring 60 samples in a 2-byte EDF file gives every record 120 bytes of annotation space:
const { header } = recording;
const region = header.signals[header.annotationSignalIndices[0]];
region.kind; // 'annotations'
region.label; // 'EDF Annotations'
region.samplesPerRecord; // 60
region.recordByteLength; // 120 — the region size, per record
region.recordByteOffset; // 768 — where it begins inside each record
region.scale; // undefined — there is nothing to scale
Parsing is hard-bounded to that region. A TAL with no terminating 0x00 before the region ends is
discarded rather than continued into the next signal’s block. The bytes past the bound are samples,
and decoding them as text produces annotations that aren’t in the file.
How do I read EDF+ annotations and event times?
readAnnotations takes a recording and a record range, and returns everything in it:
import { openEdf, readAnnotations } from 'edfcore';
import { fileSource } from 'edfcore/node';
const recording = await openEdf(await fileSource('./study.edf'));
const { annotations, recordOnsetTicks, diagnostics } = await readAnnotations(recording, {
start: 0,
count: recording.header.recordCount,
});
for (const event of annotations) {
console.log(event.onsetSecondsFromFirstRecord, event.durationSeconds, event.text);
}
// -0.75 undefined pre-stimulus baseline
// 1 30 Sleep stage W
// 1.25 undefined spike
// 2 30 Sleep stage 1
The record range is required and has no default. Annotation regions are interleaved with the samples inside each record, so there’s no way to read only the events. A full-file scan reads every byte of every record:
a 200-record file with 632-byte records
{ start: 0, count: 200 } one read of 126,400 bytes the whole file
{ start: 0, count: 10 } one read of 6,320 bytes
A full scan of a twelve-hour study is a full download of it. Spelling
{ start: 0, count: recording.header.recordCount } at the call site keeps that cost visible in
your own source. See large files for the read patterns of every other call.
One annotation object is produced per non-empty text run, not per TAL. A TAL carrying two texts at the same timestamp yields two annotations that share an onset and a duration. Empty runs are dropped, since an empty text is how the grammar terminates a timestamp.
The list is sorted by onsetTicks, then signalIndex, then byteOffsetInRecord, then the order
the TALs appear on disk. That order is total and is pinned by a test, so two runs over the same
file produce the same array.
The timekeeping TAL
EDF+ specification 2.2.1 reserves the first TAL of the first annotations signal in every data record for timekeeping. It carries that record’s own start time relative to the header startdate/starttime, no duration, and one empty text:
+13.25 0x14 0x14 0x00
“First” here is a position, not “the first one that parsed”. A record whose slot-0 TAL is malformed does not get its second TAL promoted into the timekeeping role.
edfcore strips it from annotations, since it’s structure rather than an event, and surfaces it
separately:
result.recordOnsetTicks; // BigInt64Array, one entry per record in the decoded range
There’s always exactly one entry per record, with no holes and no sentinel. A record whose
timekeeping TAL is missing or unparseable gets the derived onset
start + recordIndex * recordDuration. edfcore also reports a TIMEKEEPING_TAL_MISSING diagnostic
naming that record. This array is the primitive every timeline in edfcore is built from. See
discontinuous recordings.
A plain EDF or BDF has no annotations signal at all, and readAnnotations still works on such a
file. It returns an empty list with no diagnostics. recordOnsetTicks is the arithmetic grid
recordIndex * recordDuration, which is what a record onset means in a file that doesn’t store one.
Two onset conventions
Every EDF+ reader has to answer one question: an onset written +13.25 is 13.25 seconds after
what?
The spec’s answer (EDF+ 2.2.4) is the header’s startdate and starttime. The header start time is
only accurate to the second, since it’s an hh.mm.ss field. The true start of record 0 is the
onset in record 0’s own timekeeping TAL, which is allowed to be a sub-second value in [0, 1). The
two answers differ by that offset. EDFlib, pyEDFlib and MNE all report the second one.
edfcore reports both, as two separately named fields:
| field | measured from | value in the example below |
|---|---|---|
onsetSecondsFromHeaderStart |
the header startdate/starttime, verbatim on disk | 1.25 |
onsetSecondsFromFirstRecord |
the true start of record 0 | 1 |
They differ by recording.timeline.startOffsetSeconds, which is record 0’s timekeeping onset
(0.25 in the example file):
recording.timeline.startOffsetSeconds; // 0.25
recording.timeline.startOffsetTicks; // 2500000n
event.onsetSecondsFromHeaderStart; // 1.25
event.onsetSecondsFromFirstRecord; // 1
onsetSecondsFromFirstRecord is the one that lines up with everything else edfcore reports. t = 0
in this library is the start of record 0. chunk.startSeconds, segment.startSeconds,
gap.durationSeconds and the bounds you pass to readWindow and trimToWindow all live on that
axis. Reach for onsetSecondsFromHeaderStart when you’re reconciling against a wall clock, a paper
report, or another tool that quotes the on-disk value.
edfcore exposes two named fields rather than one field plus an option. Picking the wrong convention shifts every event in the recording by that sub-second offset: too small to look wrong, large enough to move an ERP epoch onto the other side of a stimulus.
Note When the decoded range does not start at record 0, record 0’s start is not in the bytes you asked for. edfcore derives it as
onset(first decoded record) − recordIndex × recordDuration. That’s exact for a contiguous file and wrong by the elapsed gaps for an EDF+D one, so the derived value is used only when it lands inside[0, 1). Otherwise rebasing is switched off and the two fields come back equal. Decode from record 0 whenever you intend to useonsetSecondsFromFirstRecord.
How do I convert an event onset to a sample index?
Both seconds fields are float64 and both are lossy. The exact value is onsetTicks, a bigint
count of 100 ns ticks:
event.onsetTicks; // 12500000n
event.onsetRaw; // '+1.25' — the digits exactly as the file wrote them
Onsets are parsed digit by digit into bigint. parseFloat and Number() appear nowhere on that
path. An onset written +0.1 and one written +0.3 are integers by the time you see them.
Equality, ordering and subtraction follow the digits on disk rather than binary floating-point
rounding. Sorting, grouping into epochs, and testing whether two markers coincide should all use
onsetTicks:
const stimulus = annotations.find((a) => a.text === 'stim');
const response = annotations.find((a) => a.text === 'button');
if (stimulus !== undefined && response !== undefined) {
const latencyTicks = response.onsetTicks - stimulus.onsetTicks; // exact
console.log(Number(latencyTicks) / 10_000, 'ms'); // 10^4 ticks per millisecond
}
Fractional digits beyond the seventh are below tick resolution and are truncated, never
rounded, since a rounded onset names a time that is in no file. For a stimulus marker that’s the
difference between a pre- and a post-stimulus sample. The extra digits still have to be digits:
+1.00000000x fails the grammar and is reported rather than accepted.
onsetRaw keeps the original text, so a round-trip through edfcore never loses precision you had.
Durations
A TAL carries a duration only when it contains a 0x15 separator. When it does not, all three
duration fields are undefined:
event.durationSeconds; // 30 | undefined
event.durationTicks; // 300000000n | undefined
event.durationRaw; // '30' | undefined
undefined is not 0. An instantaneous marker and an event whose writer omitted the duration are
the same thing on disk, and edfcore doesn’t distinguish them. A duration is never signed. A 0x15
followed by something that isn’t 1*DIGIT [ "." 1*DIGIT ] makes the whole TAL malformed, and it’s
skipped with a TAL_MALFORMED diagnostic rather than half-read.
Negative onsets
An onset before the file start is legal EDF+ and is how a pre-stimulus event is written:
event.onsetSecondsFromHeaderStart; // -0.5
event.onsetTicks; // -5000000n
edfcore reports NEGATIVE_ANNOTATION_ONSET for it, at severity info. At that severity the file
is correct and nothing needs fixing. It’s emitted once per readAnnotations call, not once per
event.
Any arithmetic on onsets has to handle negatives. bigint division truncates toward zero, which
isn’t the same as flooring. Getting that wrong puts a pre-stimulus event one sample later than it
belongs. The worked example below floors explicitly.
description@@channel
EDF+ scopes an event to a channel by suffixing the description with @@ and the channel’s label.
edfcore splits it for you, at the last @@, because the channel label is the suffix and a
description is free to contain anything:
event.text; // 'spike'
event.channelLabel; // 'EEG Fpz-Cz'
A trailing @@ with nothing after it isn’t a channel label, and the text keeps it verbatim.
@@Fp1 with nothing before it gives an empty description and the channel, since the run itself is
not empty.
channelLabel is the text the file wrote. It isn’t resolved to a signal. The writer may have
scoped an event to a channel that isn’t in this file. Resolve it yourself when you need the signal,
and be ready for it to fail:
import { findSignals } from 'edfcore';
const [target] = event.channelLabel === undefined
? []
: findSignals(recording.header, event.channelLabel);
findSignals returns every match. Real files ship duplicate labels (CHB-MIT has T8-P8 twice),
and getSignal throws EdfAmbiguousChannelError for them rather than picking one.
More than one annotations signal
EDF+ permits several. All of them are read by default, and only the first one carries timekeeping:
recording.header.annotationSignalIndices; // [1, 2]
The first TAL of signal 2 is an ordinary annotation, and it stays in the list.
Restrict the read with signalIndices when you only want one of them:
const result = await readAnnotations(
recording,
{ start: 0, count: recording.header.recordCount },
{ signalIndices: [2] },
);
Leaving out the file’s first annotations signal means no timekeeping TAL is read at all. Every
entry of result.recordOnsetTicks then falls back to the nominal grid, and no diagnostic is
emitted for it. Read them all when you need both the events of a secondary signal and true record
onsets.
Passing a data signal index throws a plain RangeError rather than an EdfError, since parsing
samples as text is a caller’s mistake rather than a file’s:
await readAnnotations(recording, records, { signalIndices: [0] });
// RangeError: decodeAnnotations(): signal 0 is not an annotation signal. This file's
// annotation signals are [1]. Next: pass one of those, or omit signalIndices to read them all.
Provenance
Every annotation says where it came from, down to the byte:
| field | meaning |
|---|---|
signalIndex |
which annotations signal held it |
recordIndex |
which data record it was stored in |
byteOffsetInRecord |
offset of the text run within that record |
onsetRaw, durationRaw |
the digits exactly as written |
textEncoding |
'utf-8' or 'latin-1-fallback' |
recordIndex is where the event was stored, which is not necessarily the record its onset falls
in. Writers usually put an event in the record covering it, but nothing in the format requires
that, so use the onset for time and recordIndex only for provenance.
Between recordIndex and byteOffsetInRecord you can point a hexdump at the exact bytes:
header.headerByteLength + recordIndex * header.recordByteLength + byteOffsetInRecord.
Text encoding
Annotation text is UTF-8 in EDF+. edfcore decodes it with a strict decoder, so invalid UTF-8 is
detected rather than replaced with U+FFFD. When a run fails, it falls back to ISO-8859-1 and says
so:
event.text; // 'café'
event.textEncoding; // 'latin-1-fallback'
The fallback also raises ANNOTATION_TEXT_NOT_UTF8, carrying the offending bytes:
warning [ANNOTATION_TEXT_NOT_UTF8] annotation region of signal 1 ("EDF Annotations") in record 0:
the text run is not valid UTF-8 and was decoded as ISO-8859-1 instead. ...
at byte offset 810 (4 bytes), annotation region, signal 1, record 0
raw: "caf\xe9"
bytes: 63 61 66 e9 |caf.|
spec: EDF+ specification 2.2 (the 'EDF Annotations' signal)
Latin-1 is a guess, and it round-trips every byte to exactly one character. If your writer used a
different code page, byteOffsetInRecord and the diagnostic’s rawBytes give you the bytes to
re-decode yourself.
Text is exposed verbatim: never trimmed, never case-folded, and a leading byte-order mark is kept as a character rather than stripped.
From an event onset to a sample index
Math.round(onset * sampleRateHz) drifts. sampleRateHz is derived, and a file declaring 256
samples in a 3-second record has a rate of 85.333… Hz that no float holds exactly. The error grows
with time, and the rate is undefined outright when the record duration is zero, which is legal
EDF.
Do it in integers instead. Every quantity below comes from the header as written:
import { openEdf, readAnnotations, readRecords, getSignal, toPhysical } from 'edfcore';
import { fileSource } from 'edfcore/node';
const recording = await openEdf(await fileSource('./study.edf'));
const { header, timeline } = recording;
const signal = getSignal(header, 'EEG Fpz-Cz');
const { annotations } = await readAnnotations(recording, {
start: 0,
count: header.recordCount,
});
/** `recordDurationTicks` is positive; bigint `/` truncates, so negatives need the correction. */
function floorDiv(a: bigint, b: bigint): bigint {
const quotient = a / b;
return a % b === 0n || a > 0n ? quotient : quotient - 1n;
}
function sampleIndexOf(onsetTicks: bigint): bigint {
// 1. Put the event on edfcore's axis, where t = 0 is the start of record 0.
const elapsedTicks = onsetTicks - timeline.startOffsetTicks;
// 2. Sample n sits at n * recordDuration / samplesPerRecord. Invert that, in ticks.
return floorDiv(elapsedTicks * BigInt(signal.samplesPerRecord), header.recordDurationTicks);
}
for (const event of annotations) {
if (!event.text.startsWith('Sleep stage')) continue;
const sampleIndex = sampleIndexOf(event.onsetTicks);
// 3. A pre-stimulus event lands before the first sample; there is nothing to read.
if (sampleIndex < 0n || sampleIndex >= BigInt(signal.sampleCount)) continue;
// 4. Records are the unit of I/O, so split the index into a record and an offset in it.
const samplesPerRecord = BigInt(signal.samplesPerRecord);
const recordIndex = Number(sampleIndex / samplesPerRecord);
const offsetInRecord = Number(sampleIndex % samplesPerRecord);
const chunk = await readRecords(recording, {
records: { start: recordIndex, count: 1 },
signalIndices: [signal.index],
});
const microvolts = toPhysical(signal, chunk.signals[0].digital);
console.log(event.text, sampleIndex, microvolts[offsetInRecord]);
}
// Sleep stage W 256n 0.007629510948348211
// Sleep stage 1 512n 0.007629510948348211
Step by step:
Step 1 rebases the onset. onsetTicks is measured from the header start time; every other
second in edfcore is measured from the start of record 0. Subtracting timeline.startOffsetTicks
moves the event onto the same axis as chunk.startSeconds and trimToWindow’s bounds. Both values
are exact ticks, so nothing is lost. (onsetSecondsFromFirstRecord is this same quantity as a
float: use it for display, and the ticks for arithmetic.)
Step 2 is the whole conversion. Sample n of a signal starts at
n × recordDuration / samplesPerRecord, so the sample containing time t is
floor(t × samplesPerRecord / recordDuration). Written in ticks, that’s one bigint multiply and
one floored divide, with no sample rate, no division before the comparison and no float bound. The
floor matters for negative onsets: bigint division truncates toward zero, which rounds a
pre-stimulus event up, toward the file start, by one sample.
Step 3 handles events outside the samples. A negative index means the event precedes the
recording, which is normal for pre-stimulus markers. signal.sampleCount is
samplesPerRecord × recordCount, so the upper bound catches an event stamped past the end.
Step 4 turns a sample index into a read. sampleIndex is non-negative by now, so plain / and
% on the bigint are already a floor. chunk.signals[0].firstSampleIndex comes back as
recordIndex × samplesPerRecord, which is the check that the two agree.
Each signal has its own grid. Run the same function with a 128 Hz channel’s samplesPerRecord and
you get that channel’s index for the same instant. Do not reuse an index computed for another
signal.
Warning This formula assumes record r starts at
r × recordDuration, which holds for EDF, BDF and EDF+C but not for EDF+D. On a discontinuous file it gives you a position on the time axis, which is not a position on the sample grid: the samples do not exist during a gap. Useindex.locate(seconds)to find the record first. See discontinuous recordings.
Where to go next
- Discontinuous recordings: what the timekeeping TALs mean when the recording has gaps in it, and how to read across one.
- Diagnostics: the TAL and timekeeping codes in full, and what
strictdoes to them. - Reading signals: chunks,
trimToWindow, and the sample grid these onsets are being mapped onto.