CLI
edfcore ships a command line so you can look at a file before writing any code. Every command reads one file, prints to stdout, and returns an exit code a script can branch on.
npx edfcore header <file> # the header, the signals, and any diagnostics
npx edfcore validate <file> # a full conformance sweep, scanning every sample
npx edfcore events <file> # the annotations, counted by text
npx edfcore signals <file> # one tab-separated line per signal, for grep and awk
npx edfcore gaps <file> # the discontinuities, from a full scan
npx edfcore json <file> # the header as JSON, for piping into jq
npx edfcore --version # the installed version
Flags: --patient includes both identification fields, local patient and local recording
(header, validate, json), and stops the diagnostics that quote them being redacted. --list
makes
events print one event per line instead of counting them by text, and --limit <n> caps the
diagnostics, events or gaps printed (header, validate, events --list, gaps). Each is accepted and ignored
by the commands it does not name, and the counted events output is never capped.
npx edfcore events recording.edf --list --limit 100
# 0<TAB>30<TAB>Sleep stage W<TAB>
The onset column is onsetSecondsFromFirstRecord — the axis gaps and every read use, where
t = 0 is the start of record 0. A truncated listing says how many events it withheld, because a
silently cut one reads as a complete one.
json emits the geometry, the start, one entry per signal and the diagnostic codes. A signal entry carries the four declared range numbers and scale, so the document is enough to reach physical units on its own — scale.bitValue * (scale.offset + digital). The key is absent when the header has no usable gain, the way sampleRateHz is absent for a legal zero record duration — and always for an annotations channel, whose bytes are TAL text rather than measurements, so there is nothing to scale however well formed its declared range is. That is the case you will actually see: the range an EDF+ writer puts on an annotations signal is usually a perfectly usable -1..1 over -32768..32767. A diagnostic carries signalIndex when it is about a channel rather than the file. It reports
spanSeconds and coveredSeconds as a pair, and two numbers that were measured rather than
declared are worth more here than variant, which carries only the claim the writer made. Read
their difference with its sign: spanSeconds - coveredSeconds positive is a hole, negative means
records overlap. The
diagnostics are both sets the library holds — the header’s and the record probes’ — each entry
carrying source: "header" or source: "recordProbe". Before 0.6.12 only the header’s were
emitted, so an EDF+C file with a real hole reported nothing about it here while edfcore gaps
reported the gap. That release is why the pair is no longer the document’s only measured answer:
DISCONTINUITY_IN_CONTINUOUS_FILE and RECORD_ONSET_SPACING_VIOLATION both arrive with
source: "recordProbe", and both contradict a variant that says the file is continuous. The
start
carries dateSource and clockSource alongside the values, because a null clock and a file that
starts at midnight are otherwise the same output, and a date resolved from the EDF+ recording
identification is the unambiguous one. It was added in 0.6.6; before that the command reported
DATE_CLIPPED_TO_1985_2084 and not the field that diagnostic tells you to read.
"start": { "date": "2095-04-24", "dateSource": "recordingIdField",
"clock": "22:30:00", "clockSource": "headerField",
"offsetSeconds": 0.25 }
offsetSeconds is the sub-second start carried by record 0’s timekeeping TAL, in [0, 1), and it
is what joins the two clocks in this document. date and clock are header fields, on the
header’s timebase; spanSeconds, coveredSeconds and the onsets events prints are on record
0’s. Adding a clock to an events onset without it lands up to a second early — silently, and
only on the files that carry one. formatHeader cannot print it, because a header alone does not
know it; this command opens the recording and has already paid for the probe that reads it.
header is for reading and signals is for piping. The second emits six tab-separated columns,
in this order, annotations channel included:
| # | Column | Note |
|---|---|---|
| 1 | index |
|
| 2 | label |
trimmed |
| 3 | kind |
data or annotations |
| 4 | sampleRateHz |
empty for a legal zero record duration — it is derived |
| 5 | physicalDimension |
trimmed |
| 6 | samplesPerRecord |
the authoritative count; index by this, never by the rate |
Column 6 was added in 0.2.42 and appended rather than inserted, so nothing that parsed the first
five by position moved. Before that this page claimed the command emitted samples per record when
it emitted kind instead, and the authoritative field was in no column at all. gaps runs a full scan rather
than the two probes openEdf makes, because a probed index cannot see a gap in the middle and
reporting “none” from it would be a claim nobody verified.
Exit codes are the contract, so a script can act on them without parsing the output:
| Code | Meaning |
|---|---|
0 |
success |
1 |
the file could not be read, or validation failed |
2 |
bad usage — unknown command or option, missing file, extra files, bad flag value |
Bad usage really does exit 2 since 0.2.27; before that parseArgs threw a plain RangeError that
the shell reported as 1, so --limit all was indistinguishable from a corrupt recording to the job
gating on it. Three related things changed with it:
- An unknown option is refused rather than ignored. A misspelled
--patinetused to be dropped silently, so the command printed the identification the caller was trying to withhold, and exited 0. - Extra files are refused.
edfcore validate *.edfused to validate the first file the shell expanded, exit 0, and say nothing about the rest — inside the CI gate the exit code exists for. Loop instead:for f in *.edf; do edfcore validate "$f" || exit 1; done --helpand-hexit 0. They are flags, andparseArgsnever puts a dash-prefixed argument in the command slot, so the oldcommand === '--help'branch was unreachable andnpx edfcore --helpfell through to “no command” and exited 2.
edfcore validate exiting non-zero is the intended way to gate a CI job on file conformance.
Patient identification is omitted from header, validate and json unless --patient is passed, for the
same reason formatHeader withholds it: the obvious thing to do with CLI output is pipe it
somewhere.
That covers the diagnostics too, which is the part that is easy to miss. A diagnostic names the
raw bytes as written — that is the message contract, and it is what makes a report actionable —
so a NON-CONFORMANT identification field had its whole content printed in the diagnostics block
underneath the summary that had just withheld it. That is not a rare file: a writer that packs the
name into a single token fails the EDF+ grammar, and a file that behaves oddly is exactly the one
someone runs edfcore header on and pastes into an issue. Since 0.2.26 both are gated by the same
flag, and the diagnostic still reports its code, severity, byte offset and rule with the value
replaced by [redacted].
formatDiagnostics and formatValidationReport take redactFields for the same purpose:
formatDiagnostics(header.diagnostics, { redactFields: ['patientId', 'recordingId'] });
The brackets are load-bearing. A string is iterable, so redactFields: 'patientId' walked its
characters and refused with options.redactFields names "p" — a value nobody wrote, about a
vocabulary that was never the problem. It is named as the string it is from 0.6.143 on.