/** * Every public data shape in edfcore. * * Layer 0. Types only — this module emits no runtime code, so any layer may import it without * creating a dependency edge. * * Two conventions run through the whole file: * * 1. A field that may be absent is declared `T | undefined` and is always present as a key. * Optional (`?`) is reserved for *options* the caller passes in. Reading a result should * never require knowing whether a key exists. * 2. Anything that can be checked against the file is exposed twice — as parsed value and as * the raw bytes it came from. edfcore never destroys evidence. */ import type { EdfDiagnosticCode, EdfSeverity } from './diagnostics/codes.js'; export type { EdfDiagnosticCode, EdfKnownDiagnosticCode, EdfSeverity, } from './diagnostics/codes.js'; // =========================================================================== // Structural platform shims // // edfcore compiles with `lib: ["ES2022"]` and `types: []`, so neither the DOM nor // @types/node can leak into the published .d.ts. These structural interfaces are what the // real platform types are checked against — `tests/types/shim-assignability.test-d.ts` // asserts the real ones remain assignable. // =========================================================================== /** * A stand-in for `AbortSignal`, structural so the published types name no DOM lib. A real * `AbortSignal` is assignable to it, so callers pass one normally; only `aborted` is read, and * the runtime uses the object it was actually given. */ export interface AbortSignalLike { readonly aborted: boolean; } /** * A stand-in for `Blob`, structural for the same reason as `AbortSignalLike`. A browser `File` * from an `` satisfies it directly, which is what lets `blobSource(file)` * take one with no cast and no DOM lib in the published types. */ export interface BlobLike { readonly size: number; slice(start?: number, end?: number): BlobLike; arrayBuffer(): Promise; } /** * A stand-in for `Response`, structural like the two shims above. Only three members are named, * which is the whole point: a test double for `httpSource` needs a status, a header lookup and * the bytes, not a conforming `Response`. */ export interface HttpResponseLike { readonly status: number; readonly headers: { get(name: string): string | null }; arrayBuffer(): Promise; } /** * `globalThis.fetch` is assignable to this. * * `signal` is deliberately absent from `init`: naming it would drag in the real `AbortSignal` * by parameter contravariance, which is the exact DOM dependency these shims exist to avoid. * It is still passed at runtime. */ export type FetchLike = ( url: string, init: { headers: Record; method?: string }, ) => Promise; // =========================================================================== // I/O // =========================================================================== /** * The trailing argument on everything that touches bytes. Both fields bound a cost rather than * change an answer: cancelling a read and capping an allocation never alter what a completed * call returns. */ export interface ReadOptions { readonly signal?: AbortSignalLike; /** Refuse before allocating rather than dying inside it. Default 256 MiB. */ readonly maxMaterializeBytes?: number; } /** * The one option that changes what a parse does rather than what it costs. Everything else in * edfcore reports a departure as a diagnostic on the result; `strict` turns the first one into a * rejection instead, for callers who would rather not receive a file at all than inspect it. */ export interface ParseOptions { /** * When true the first would-be diagnostic throws `EdfFormatError` carrying it, so a file that * has one comes back as a rejection rather than as a header with a list. * * `info` codes are EXEMPT and are still collected: they explain something the file got right, * and `DATE_CLIPPED_TO_1985_2084` is carried by nearly every conforming EDF file, so throwing * on it would make `strict` reject the files it exists to accept. A strict parse of a file * whose only note is `info` therefore resolves, with that note present — which is why this said * "every `diagnostics` array is consequently empty" until 0.3.76 and was wrong twice over. * * Check order is pinned and tested, which is what makes error identity stable across refactors. */ readonly strict?: boolean; } /** * What `openEdf` takes: how to parse, and how to read. It is an intersection rather than a type * of its own because opening declares no policy — the two halves are the same ones every parse * and every read already answer to. */ export type OpenOptions = ParseOptions & ReadOptions; /** * A random-access byte range reader. * * CONTRACT, verified on every call including user-supplied sources: `read` resolves with * EXACTLY `length` bytes or rejects. It never pads and never truncates. The returned array is * owned by the caller, so a caching implementation must hand back a copy. */ export interface ByteSource { readonly byteLength: number; read(offset: number, length: number, options?: ReadOptions): Promise; close?(): Promise | void; } /** * A `ByteSource` that owns something and therefore has a `close`. * * `close` is optional on `ByteSource` because most sources own nothing: `byteSource` holds an array * the caller already had, and `blobSource` holds a `Blob`. A source over a file descriptor is not * like that, and `api-sources.md` says so — "After that, **closing is yours**. Call * `source.close()` when you're done." * * That sentence did not typecheck. `fileSource` was declared as returning a plain `ByteSource`, so * the documented call was an invocation of a possibly-undefined member and every strict project * got `TS2722` on the line the page told them to write. Every snippet on the site that opened a * file went on to not close it, which is not a coincidence (fixed in 0.6.17). */ export interface ClosableByteSource extends ByteSource { close(): Promise; } /** * Configuration for `httpSource`, which needs the server to honour range requests. Concurrency * belongs here rather than in the reading calls: a source owns how many requests it issues, so * the read pattern a caller observes stays the one they asked for. */ export interface HttpSourceOptions extends ReadOptions { readonly fetch?: FetchLike; readonly headers?: Readonly>; readonly byteLength?: number; readonly maxConcurrency?: number; /** Server answered 200 instead of 206. Default false, i.e. throw rather than silently * buffering gigabytes the caller did not ask for. */ readonly allowFullDownload?: boolean; } /** * Tuning for `cachedSource`, the only cache of file BYTES in edfcore — every other source reads * through. The record index's onset memo is the other thing that remembers, and no option here * reaches it. * That is why this is opt-in and visible at the call site: caching is removed by deleting one * wrapper, so it can never be the hidden reason two reads disagreed. */ export interface CacheOptions { /** Block size in bytes. Default 1 MiB. Blocks are byte-aligned, not record-aligned: the * cache is format-independent by construction and never sees a header to learn a record * size from. Round this to a multiple of `header.recordByteLength` yourself if you want * block boundaries to fall on record boundaries. */ readonly blockBytes?: number; /** LRU budget. Default 64 MiB. */ readonly maxBytes?: number; } // =========================================================================== // Header model // =========================================================================== /** * Which dialect the file declares. `BDF*` means 24-bit samples rather than 16; the `+D` suffix * means the writer says records are discontinuous. Neither is a promise — `header.continuity` * carries the declared claim, and only a complete index can confirm it. */ export type EdfVariant = 'EDF' | 'EDF+C' | 'EDF+D' | 'BDF' | 'BDF+C' | 'BDF+D'; /** * A date as the file states it, with no zone attached and none implied. Passing these straight * into `new Date(y, m, d)` is wrong twice over: the month is 1-based, and the constructor would * apply the reader's timezone to a date the recording never expressed one in. */ export interface EdfCalendarDate { readonly year: number; /** 1-12. Not a JavaScript month index. */ readonly month: number; readonly day: number; } /** * Wall-clock time as the file states it. Local at the patient, with no zone and no offset field * anywhere in EDF to recover one from — so two readers in different zones must agree these are * the same digits, not the same instant. */ export interface EdfClockTime { readonly hour: number; readonly minute: number; readonly second: number; } /** * EDF records local time at the patient with no timezone, so edfcore never produces a `Date`: * a `Date` silently applies the reader's zone, and is worst exactly at DST boundaries. */ export interface EdfStartTime { /** From the `dd.mm.yy` header field, through the 1985-2084 rule. */ readonly headerDate: EdfCalendarDate | undefined; /** From the EDF+ recording-identification `Startdate` subfield — the only unambiguous * four-digit year, and the only way past 2084. */ readonly recordingIdDate: EdfCalendarDate | undefined; readonly resolvedDate: EdfCalendarDate | undefined; readonly dateSource: 'headerField' | 'recordingIdField' | 'none'; /** * The wall clock, or MIDNIGHT when the `hh.mm.ss` field could not be read — the type admits no * absent clock. `clockSource` is how to tell those two apart, and `secondsSinceMidnight` is 0 * in the second case for the same reason. */ readonly clock: EdfClockTime; /** * `'none'` when the starttime field failed its grammar, so `clock` is a substituted midnight * rather than a time the file states. * * The counterpart of `dateSource`, and added for the same reason: without it a refused clock * and a genuine 00:00:00 are the same value, and midnight is an entirely believable start for * a sleep study. `formatHeader` prints `unknown` on it and `formatStartTimeNaive` returns * `undefined`, neither of which was possible before 0.3.17. */ readonly clockSource: 'headerField' | 'none'; readonly secondsSinceMidnight: number; } /** * The patient field, parsed into EDF+ subfields where it follows the convention. Check * `conformant` before trusting any of them: a plain EDF file may put anything in this field, and * `raw` is the only value guaranteed to mean what the writer intended. */ export interface EdfPatientId { readonly raw: string; readonly conformant: boolean; readonly code: string | undefined; readonly sex: 'F' | 'M' | undefined; readonly birthDate: EdfCalendarDate | undefined; readonly name: string | undefined; readonly extraSubfields: readonly string[]; } /** * The recording field, parsed the way `EdfPatientId` is and with the same `conformant` caveat. * Its `startDate` subfield is the EDF+ one, which can carry a four-digit year the 8-character * date field at the top of the header cannot. */ export interface EdfRecordingId { readonly raw: string; readonly conformant: boolean; readonly startDate: EdfCalendarDate | undefined; readonly investigationCode: string | undefined; readonly technicianCode: string | undefined; readonly equipmentCode: string | undefined; readonly extraSubfields: readonly string[]; } /** * `physical = bitValue * (offset + digital)`. * * This is EDFlib's exact expression, kept verbatim for float64 bit-parity with pyEDFlib. * It is *not* the numerically better form, and it must not be "simplified" — see * `src/decode/physical.ts`. */ export interface EdfScale { readonly bitValue: number; readonly offset: number; } /** Header text exactly as written, before trimming or interpretation. */ export interface EdfRawSignalFields { readonly label: string; readonly transducerType: string; readonly physicalDimension: string; readonly physicalMinimum: string; readonly physicalMaximum: string; readonly digitalMinimum: string; readonly digitalMaximum: string; readonly prefiltering: string; readonly samplesPerRecord: string; readonly reserved: string; } /** Header text exactly as written, before trimming or interpretation. */ export interface EdfRawHeaderFields { readonly version: string; readonly patientId: string; readonly recordingId: string; readonly startDate: string; readonly startTime: string; readonly headerByteLength: string; readonly reserved: string; readonly recordCount: string; readonly recordDuration: string; readonly signalCount: string; } /** * One channel of the header. Two fields decide most of what a caller can do with it: `kind`, * because an annotations channel holds TAL text rather than samples, and `scale`, which is * `undefined` when no gain can be derived and makes `toPhysical` throw rather than invent one. */ export interface EdfSignal { readonly index: number; readonly kind: 'data' | 'annotations'; /** Trimmed. `raw.label` keeps the padding. */ readonly label: string; readonly transducerType: string; readonly prefiltering: string; /** Trimmed. `raw.physicalDimension` keeps the padding and the exact bytes. */ readonly physicalDimension: string; /** Normalised for comparison only: the several encodings of micro all become `u`. */ readonly unit: string; readonly physicalMinimum: number; /** MAY be less than the minimum: that is a negative amplifier gain, it is legal, and * edfcore never "fixes" it. */ readonly physicalMaximum: number; readonly digitalMinimum: number; readonly digitalMaximum: number; /** Authoritative. Sample indexing uses this, never a rate. */ readonly samplesPerRecord: number; /** Derived. `undefined` exactly when `recordDurationSeconds === 0`, which is legal. * Never index by this. */ readonly sampleRateHz: number | undefined; readonly sampleCount: number; /** `undefined` when scaling is impossible or unsafe: a degenerate or inverted digital * range, a degenerate physical range, or a log-transformed channel. `toPhysical` then * throws `EdfScalingError`; `decodeDigital` keeps working. */ readonly scale: EdfScale | undefined; /** Byte offset of this signal's block within one data record. */ readonly recordByteOffset: number; readonly recordByteLength: number; readonly raw: EdfRawSignalFields; } /** * The parsed header, and the only thing every read needs. Values are resolved rather than * verbatim — `recordCount` is what the file actually holds, not what it declared — and each * declared counterpart is kept beside it so a disagreement stays visible. */ export interface EdfHeader { readonly variant: EdfVariant; readonly continuity: 'continuous' | 'discontinuous'; readonly bytesPerSample: 2 | 3; /** Always the computed `256 * (ns + 1)`, never the declared value. */ readonly headerByteLength: number; /** What the file claims at offset 184. Kept so a mismatch stays visible. */ readonly declaredHeaderByteLength: number; readonly recordByteLength: number; readonly dataByteLength: number; /** MAY legitimately be 0 — then never divide by it. */ readonly recordDurationSeconds: number; readonly recordDurationTicks: bigint; /** Resolved and non-negative. */ readonly recordCount: number; /** Verbatim. `-1` means the writer never closed the file. */ readonly declaredRecordCount: number; readonly recordCountSource: 'headerField' | 'sourceByteLength'; readonly startTime: EdfStartTime; readonly patient: EdfPatientId; readonly recording: EdfRecordingId; readonly signals: readonly EdfSignal[]; readonly dataSignalIndices: readonly number[]; readonly annotationSignalIndices: readonly number[]; /** The full 44 reserved bytes, verbatim. */ readonly reserved: string; readonly raw: EdfRawHeaderFields; /** The whole header, for hexdumps and bug reports. */ readonly rawBytes: Uint8Array; readonly diagnostics: readonly EdfDiagnostic[]; } // =========================================================================== // Time // =========================================================================== /** Start plus count, never start plus end: there is no inclusive/exclusive ambiguity. */ export interface RecordRange { readonly start: number; readonly count: number; } /** * One contiguous run of records. * * Every second here is a float64 conversion of the tick beside it, and the ticks are what a * boundary decision must use — `endTicks` is the first instant the run no longer covers, and it is * exact. `segmentAt` searches on the ticks for that reason. */ export interface EdfSegment { readonly index: number; readonly records: RecordRange; readonly startSeconds: number; readonly startTicks: bigint; readonly durationSeconds: number; readonly durationTicks: bigint; readonly endSeconds: number; readonly endTicks: bigint; } /** * The interval between two segments. * * A NEGATIVE duration is an OVERLAP, not a gap: the later segment starts before the earlier one * ends, and `endTicks < startTicks` says so exactly. `validateRecording` reports it as * `RECORD_ONSET_SPACING_VIOLATION`. Sum `durationTicks`, not `durationSeconds`, to total the time * a recording lost — the ticks are exact and the sign is part of the answer. */ export interface EdfGap { readonly beforeSegmentIndex: number; readonly afterSegmentIndex: number; readonly startSeconds: number; readonly startTicks: bigint; readonly endSeconds: number; readonly endTicks: bigint; readonly durationSeconds: number; readonly durationTicks: bigint; } /** * A time resolved to a place in the file: which record, and how far into it. Split in two * because the record is what a read takes and the offset is what survives a gap — adding them * back together only reconstructs elapsed time on a contiguous recording. */ export interface EdfLocation { readonly recordIndex: number; readonly recordStartSeconds: number; readonly recordStartTicks: bigint; readonly offsetInRecordSeconds: number; readonly offsetInRecordTicks: bigint; } /** * Where the recording begins and how far it runs, from the two probes `openEdf` issues. * `spanTicks === coveredTicks` is its contiguity verdict, and it is only what two reads can see: * a gap that a later overlap cancels leaves both ends exactly where a contiguous file would. */ export interface EdfTimeline { readonly recordCount: number; readonly recordDurationSeconds: number; /** From the header, carried here so a timeline alone is enough to do exact arithmetic. */ readonly recordDurationTicks: bigint; /** Sub-second start carried by record 0's timekeeping TAL. In [0, 1). */ readonly startOffsetSeconds: number; readonly startOffsetTicks: bigint; /** Last record end minus first record start. Includes gaps. */ readonly spanSeconds: number; readonly spanTicks: bigint; /** Sum of record durations. */ readonly coveredSeconds: number; /** * `spanTicks === coveredTicks` is the two-probe contiguity verdict, and the ticks are what it * must be asked of. * * The seconds beside them are float64 conversions of these, and two different tick counts can * round to one float: once the span is large enough that an ulp exceeds a tick — around 4e8 * seconds, which a free-form `recordDuration` field reaches in three bytes — a real * discontinuity disappears from the comparison. `resolveTimeWindow` then mapped a window on the * nominal grid of a file its own scanned index reports two segments for (fixed in 0.3.4). * * Equality here is still only what TWO PROBES can see, which is net drift. A gap that an * overlap elsewhere cancels exactly leaves both ends where a contiguous file would put them; * only `buildRecordIndex()` reads every onset. */ readonly coveredTicks: bigint; readonly diagnostics: readonly EdfDiagnostic[]; } /** * What is known about where records start, and how much of it was actually read. `coverage` is * the field to branch on: under `probed` the answers come from two reads, and `segments` and * `gaps` are absent rather than empty so nothing here can read as "continuous" unchecked. */ export interface EdfRecordIndex { /** `probed` = record 0 and the last record only. `complete` = every record verified. */ readonly coverage: 'probed' | 'complete'; readonly recordCount: number; /** Present only when `coverage === 'complete'`. Deliberately absent otherwise: no property * on this object may ever read as "continuous" when we have not checked. */ readonly segments: readonly EdfSegment[] | undefined; readonly gaps: readonly EdfGap[] | undefined; /** * One read of that WHOLE data record — `header.recordByteLength` bytes. Memoised. * * Not the annotation region alone, which this said until 0.3.71. The unit of I/O in edfcore is * the record, never the channel, and `decodeAnnotations` owns the timekeeping rule * and needs the record's full bytes to apply it. On a 64-channel file the region is 32 bytes of * a 16,416-byte record, so "targeted" understated the read by 513x — and `locate()` issues * O(log recordCount) of them, which is exactly the number a caller planning HTTP range requests * is reading this line to compute. */ onsetTicks(recordIndex: number, options?: ReadOptions): Promise; /** O(log recordCount) probes. Onsets are monotonic; any observed violation is fatal. */ locate(seconds: number, options?: ReadOptions): Promise; } /** * Options for the one call that reads every record's onset. `onProgress` is here and nowhere * else in the reading API, because this is the only operation whose cost scales with the file * rather than with the window — long enough on a million-record recording to want a progress bar. */ export interface BuildIndexOptions extends ParseOptions, ReadOptions { readonly onProgress?: (done: number, total: number) => void; } // =========================================================================== // Samples and annotations // =========================================================================== /** * One signal's samples out of a chunk. `digital` is raw stored ADC counts, not the signal's * units — `toPhysical(signal, digital)` is the conversion, and it is a separate call because it * can fail where decoding cannot. */ export interface EdfChunkSignal { readonly signalIndex: number; /** The truth. Never padded to a round number. */ readonly sampleCount: number; readonly digital: Int32Array; /** Index of the first sample on this signal's own sample grid. */ readonly firstSampleIndex: number; /** Start of this signal's samples. A record-aligned read gives every signal the same * value, since they all begin at the same record; it becomes genuinely per-signal after * `trimToWindow`, where each signal's own sample grid decides the boundary. */ readonly startSeconds: number; /** * The same instant, exact. This is what `trimToWindow` measures a window from. * * It is not always a whole number of ticks: after a trim, a signal starts at * `chunkStart + firstIndex * recordDuration / samplesPerRecord`, and that division rarely * lands on a tick. `startTicks` is then the tick the sample starts in, floored, and * `startSeconds` keeps the sub-tick remainder. The floor is the same one every boundary * decision in the package uses: a sample covers from its own start to the next one's. */ readonly startTicks: bigint; /** Counted during decode, so it costs nothing. A non-zero count means the declared * digital range is wrong, not that the samples are. */ readonly outOfDigitalRangeCount: number; } /** * One contiguous run of records, as read. Chunks stay RECORD-ALIGNED, so a chunk is usually * wider than the window that asked for it — `trimToWindow` narrows it exactly — and * `precededByGap` is `undefined` on a probed index, which is "nobody looked" rather than "no gap". */ export interface EdfChunk { readonly records: RecordRange; readonly startSeconds: number; readonly startTicks: bigint; readonly durationSeconds: number; /** * The chunk's SPAN in exact ticks — its last record's end minus its first record's start. * * Equal to the time it covers for one contiguous run, which is what `readWindow` returns, and * larger when a caller names records across a gap with `readRecords`. */ readonly durationTicks: bigint; readonly byteOffset: number; /** Bytes actually read from the source. Makes overread visible instead of invisible. */ readonly byteLength: number; readonly signals: readonly EdfChunkSignal[]; readonly precededByGap: EdfGap | undefined; readonly diagnostics: readonly EdfDiagnostic[]; } /** * One signal's min/max envelope over a window, at a resolution the caller chose. * * The unit is the bucket, not the sample: `min[i]` and `max[i]` are the extremes of every sample * that fell in bucket `i`. Drawing a twelve-hour recording into a thousand pixels needs exactly * this and nothing else — the peaks are what a reader of an EEG trace is looking at, and they * are the first thing naive subsampling throws away. */ export interface EdfEnvelopeSignal { readonly signalIndex: number; /** Digital extremes per bucket. Convert with `toPhysicalEnvelope`, never with `toPhysical`. */ readonly min: Int32Array; readonly max: Int32Array; /** Samples that landed in each bucket. Zero where the window had no samples to cover it. */ readonly counts: Int32Array; /** Total samples reduced, i.e. the sum of `counts`. */ readonly sampleCount: number; readonly firstSampleIndex: number; readonly startSeconds: number; readonly startTicks: bigint; readonly outOfDigitalRangeCount: number; } /** A contiguous run of records, reduced to buckets. One per run, exactly as `readWindow` splits. */ export interface EdfEnvelopeChunk { readonly records: RecordRange; readonly startSeconds: number; readonly startTicks: bigint; readonly durationSeconds: number; /** The chunk's SPAN in exact ticks, on the same terms as `EdfChunk.durationTicks`. */ readonly durationTicks: bigint; /** * Buckets in the grid, filled or not. * * `signals[i].counts[b]` is how many samples landed in bucket `b`, and `0` is an ordinary * answer — `toPhysicalEnvelope` turns an empty bucket into `NaN`, which every plotting library * breaks the line at. * * `readEnvelope` clamps this to the densest signal's sample count, so it is never more than the * `buckets` asked for and is fewer for a short run. `readEnvelopeAtResolution` does NOT: its * count is `ceil(runTicks / bucketTicks)`, because reducing it would shorten the grid rather * than coarsen it, and a resolution finer than the sample interval leaves buckets empty on * purpose (0.3.30). A 4 s run of a 2 Hz signal at 0.25 s per bucket reports 16 here with 8 * filled. This docblock said "buckets actually filled" until 0.3.70, which was true only of the * clamped rule and only before that change. */ readonly bucketCount: number; readonly secondsPerBucket: number; readonly byteLength: number; readonly signals: readonly EdfEnvelopeSignal[]; readonly precededByGap: EdfGap | undefined; readonly diagnostics: readonly EdfDiagnostic[]; } /** * A window to reduce to min/max pairs rather than read in full. This is what makes plotting a * twelve-hour recording tractable: the file is still read, but only the extremes per bucket are * kept, so memory is bounded by the plot rather than by the window. */ export interface EnvelopeSelection extends WindowSelection { /** * How many buckets to reduce each contiguous RUN into — in a viewer, a pixel width. * * Per run, not per window. `readEnvelope` returns one chunk per run the way `readWindow` does, * and each gets this many, so a window spanning two gaps answers a 1000-pixel width with three * chunks of 1000. Each run is divided evenly into them, so the widths differ run to run; * `readEnvelopeAtResolution` is the one whose buckets are the same width across runs. * * A bucket per pixel is the point: asking for more buckets than the window has samples wastes * work and yields empty buckets, so the count is clamped to the sample count of the densest * signal in the run. */ readonly buckets: number; } /** A physical-unit envelope. Separate from the digital one for the same reason `toPhysical` is. */ export interface EdfPhysicalEnvelope { readonly min: Float64Array; readonly max: Float64Array; } /** * Options for `formatValidationReport`. Unlike `formatHeader`, this one cannot withhold by * default: a diagnostic about a non-conformant identification field has to quote the field to be * useful, so `redactFields` is the deliberate step before sharing a report. */ export interface FormatReportOptions { /** Only used to name signals; a report reads fine without it. */ readonly header?: EdfHeader; /** Individual diagnostics to print. Defaults to 20 — the counts above them are the summary. */ readonly maxItems?: number; /** * Forwarded to `formatDiagnostics`. Pass `['patientId', 'recordingId']` for a report that will * be pasted somewhere: a non-conformant identification field is reported with its content, and * that content is a person's name. */ readonly redactFields?: readonly string[]; } /** * Options for `formatHeader`. They point opposite ways on purpose. `includePatientId` is opt-IN: a * formatted header is something people paste into issues and logs, so identification is withheld * unless a caller asks for it rather than redacted only when they remember to. `diagnosticsHint` * is opt-OUT: the hint is right for the caller who has a header and nothing else, and wrong only * for the one already printing the detail it points at. */ export interface FormatHeaderOptions { /** Off by default: a header carries a name and a birth date, and a summary gets pasted around. */ readonly includePatientId?: boolean; /** * Whether to append "Call formatDiagnostics(header.diagnostics) for the detail" under the * diagnostic counts. On by default, which is right for a caller holding a header and no detail. * * `edfcore header` sets it off, because it prints that detail immediately below: a reader * looking at the answer was being told to call a JavaScript function to get it. */ readonly diagnosticsHint?: boolean; } /** Where a time lands on a signal's own sample grid. */ export interface EdfSampleLocation { readonly sampleIndex: number; readonly recordIndex: number; readonly sampleWithinRecord: number; } /** A time window for narrowing annotations already in hand. */ export interface EdfAnnotationWindow { readonly startSeconds: number; readonly durationSeconds: number; } /** * A window to iterate rather than materialise. `streamRecords` yields one chunk at a time, so * peak memory is `chunkRecords` worth of records instead of the whole window — the difference * between processing a 22-hour recording and refusing it on the budget. */ export interface StreamSelection extends WindowSelection { /** Records held at once. Defaults to 256. The record is the only unit every signal shares. */ readonly chunkRecords?: number; } /** One decoded BioSemi Status sample. Only the bits BioSemi documents are named. */ export interface EdfStatusWord { /** All 24 bits, unsigned. Decode rig-specific conventions from this. */ readonly raw: number; /** The parallel trigger input: the low 16 bits. */ readonly trigger: number; /** Bit 16. */ readonly newEpoch: boolean; /** Bit 20 — NOT bit 17, which is speed bit 0. */ readonly cmsInRange: boolean; /** Bit 22 — NOT bit 18, which is speed bit 1. */ readonly batteryLow: boolean; } /** * A change of the trigger word, located on the Status channel's own sample grid and timed on the * recording's axis. * * Those are two different things and the distinction is load-bearing. `sampleIndex` counts samples * of the Status channel from the start of the file. `seconds`/`ticks` are elapsed recording time — * `t = 0` is the start of record 0 — derived from the containing record's TRUE onset, so on an * EDF+D file the gaps are in them. Deriving the time from `sampleIndex` instead would place every * post-gap event early by the whole gap. */ export interface EdfTriggerEvent { readonly sampleIndex: number; readonly seconds: number; /** Exact, in 100 ns units. Compare with this, never with the float. */ readonly ticks: bigint; readonly trigger: number; readonly status: EdfStatusWord; /** * The gap immediately before this event, when there is one — the same field `EdfChunk` carries, * and it means the same thing. * * Set on the event whose tick IS the run's resume instant, and on no other — so an event that * reports the code in force where the recording RESUMED is distinguishable from a transition the * hardware actually latched. * * A window that begins part-way into the first record after a gap therefore yields events and * none of them carries this: the gap precedes the RUN, not whichever sample the window happened * to admit first. This said "the FIRST event of each contiguous run" until 0.3.92, which is the * rule 0.3.67 replaced. * * `undefined` everywhere else, and always `undefined` on a probed index, because nobody has read * the onsets in between. */ readonly precededByGap: EdfGap | undefined; } /** * A window of BioSemi trigger events to read. It names no `signalIndices` because there is only * one channel to read: `readTriggers` finds the Status channel itself, and decoding any other as * a trigger word would produce plausible events out of ordinary samples. */ export interface TriggerSelection { readonly startSeconds: number; readonly durationSeconds: number; } /** * One event. Its onset is exposed on both axes as separately named fields rather than behind an * option: `onsetTicks` is the header's timebase, `onsetTicksFromFirstRecord` is the one every * read measures from. Comparing an event against a window needs the second. */ export interface EdfAnnotation { /** Verbatim on-disk value, relative to the header startdate/starttime (EDF+ 2.2.4). */ readonly onsetSecondsFromHeaderStart: number; /** Rebased to the first record's true start — the EDFlib/pyEDFlib/MNE convention. */ readonly onsetSecondsFromFirstRecord: number; /** * Exact, in 100 ns units, on the HEADER's timebase — the number the file wrote, unrebased. * * This is the right field for comparing one annotation against another, and the wrong one for * comparing an annotation against a window: `resolveTimeWindow`, `readWindow` and `readEnvelope` * all put `t = 0` at the start of record 0, which is `onsetTicksFromFirstRecord`. */ readonly onsetTicks: bigint; /** * Exact, in 100 ns units, on the same axis as every read in the package: `t = 0` is the start of * record 0. This is `onsetSecondsFromFirstRecord` without the float. * * It differs from `onsetTicks` by the sub-second start offset a file may declare in record 0's * timekeeping TAL, so the two are equal on most files and up to a second apart on some. */ readonly onsetTicksFromFirstRecord: bigint; /** The original digits, so precision is never lost to a round-trip. */ readonly onsetRaw: string; readonly durationSeconds: number | undefined; readonly durationTicks: bigint | undefined; readonly durationRaw: string | undefined; /** Verbatim. Never trimmed, never case-folded. */ readonly text: string; /** From the EDF+ `description@@channel` convention. */ readonly channelLabel: string | undefined; readonly signalIndex: number; readonly recordIndex: number; readonly byteOffsetInRecord: number; readonly textEncoding: 'utf-8' | 'latin-1-fallback'; } /** * What one annotation decode yields: the events, and the per-record onsets read from the same * bytes. The onsets are not a by-product — they are what the timeline is built from, which is * why decoding annotations is also how edfcore learns where records truly start. */ export interface EdfAnnotationsResult { /** Timekeeping TALs and empty texts excluded. Stable sort by * (onsetTicks, signalIndex, byteOffsetInRecord). */ readonly annotations: readonly EdfAnnotation[]; /** One entry per record in the decoded range. This is the primitive the timeline is * built from. */ readonly recordOnsetTicks: BigInt64Array; readonly diagnostics: readonly EdfDiagnostic[]; } // =========================================================================== // Diagnostics // =========================================================================== /** * Something the file got wrong that edfcore could proceed past. These are VALUES on the result, * never exceptions and never console output: if edfcore had to invent something it would have * thrown instead, so a diagnostic always describes a file it read anyway. */ export interface EdfDiagnostic { readonly code: EdfDiagnosticCode; readonly severity: EdfSeverity; /** Names the field, the raw bytes as written, the rule, and an actionable next step. */ readonly message: string; readonly field: string | undefined; readonly byteOffset: number | undefined; readonly byteLength: number | undefined; readonly rawBytes: Uint8Array | undefined; readonly raw: string | undefined; readonly expected: string | undefined; readonly actual: string | undefined; readonly signalIndex: number | undefined; readonly recordIndex: number | undefined; /** A spec clause, e.g. 'EDF+ additional specification 5'. Verifiable and stable, unlike a * claim about how some other library behaves. */ readonly specReference: string | undefined; } // =========================================================================== // Recording and selections // =========================================================================== /** * What `openEdf` returns, and a plain struct on purpose: swapping in a better index is * `{ ...recording, index }` rather than a method call, which is how a scanned index reaches * `readWindow`. */ export interface EdfRecording { readonly source: ByteSource; readonly header: EdfHeader; readonly timeline: EdfTimeline; readonly index: EdfRecordIndex; } /** * Records to read, named directly rather than resolved from a time. This is the selection that * cannot be surprised by a gap — you said which records you wanted — which is why `readRecords` * returns exactly one chunk where `readWindow` returns an array. */ export interface RecordSelection { readonly records: RecordRange; /** Required, with no "all signals" default: a 256-channel file must never be read * wholesale because an argument was omitted. */ readonly signalIndices: readonly number[]; } /** * A time window to read. Seconds here are on the recording axis, where `t = 0` is the start of * record 0 — the same axis `onsetTicksFromFirstRecord` uses. Unlike the reported times, these * bounds are a caller's request, so rounding them to the tick grid is by design. */ export interface WindowSelection { readonly startSeconds: number; readonly durationSeconds: number; readonly signalIndices: readonly number[]; } /** * Options for decoding a record range's annotations. Two of these carry record 0's true start, * consumed at different sites and each falling back to the other, because a range that does not * contain record 0 cannot derive it and would otherwise place every event on the wrong origin. */ export interface DecodeAnnotationsOptions extends ParseOptions { /** Defaults to every annotation signal. Only the first carries timekeeping. */ readonly signalIndices?: readonly number[]; /** * Record 0's sub-second start offset, in ticks — the origin `onsetTicksFromFirstRecord` and * `onsetSecondsFromFirstRecord` are measured from. * * This is a property of the FILE, not of the range being decoded, but a range that does not * contain record 0 cannot see it: the derivation from an observed onset only works when the * records in between are contiguous, so on an EDF+D file it produces a value outside [0, 1) and * the rebasing switches off. The same annotation then reads one way from a whole-file decode and * another from a partial one, differing by the offset — while `readWindow` reports the record's * true start either way. * * `readAnnotations` passes `timeline.startOffsetTicks` for you. Pass it yourself when calling * `decodeAnnotations` directly on a range that does not start at record 0. */ readonly startOffsetTicks?: bigint; /** * The recording's own time origin, for deriving the onset of a record whose timekeeping TAL * is missing. * * A missing timekeeping TAL is a warning, not a fatal error, and such a record is documented * to get `start + recordIndex * recordDuration`. That `start` can only be known by a caller * who has already seen record 0. Without it the derivation falls back to an origin of zero, * which is right only for a file whose first record starts at zero — and makes the answer * depend on which records happened to share the call, because a range containing no observed * onset at all gets a different origin from one that contains one. * * Pass `timeline.startOffsetTicks` whenever it is known. Decoding a range in isolation, as * `openEdf` does before any timeline exists, correctly omits it. * * `startOffsetTicks` above is the same quantity under the other name — record 0's true start — * and is used when this is absent. The two are consumed in different places (this one by the * record-onset grid, that one by the annotation rebasing), and until 0.3.14 neither fell back * to the other, so a caller who passed only one got the origin applied in only one of them. */ readonly originTicks?: bigint; } /** Header-only triage. Reads at most 128 KiB and never throws on malformed content. */ export interface EdfInspection { readonly ok: boolean; readonly variant: EdfVariant | undefined; readonly header: EdfHeader | undefined; readonly byteLength: number; readonly bytesRead: number; readonly headerBytes: Uint8Array | undefined; readonly diagnostics: readonly EdfDiagnostic[]; } // =========================================================================== // Validation (edfcore/validate) // =========================================================================== /** * How much of the file a conformance sweep should read. `scanSamples` is the expensive half — * it is what turns declared digital ranges into observed ones — and `index` exists so a * recording you have already scanned is not traversed a second time. */ export interface ValidateOptions extends ReadOptions { /** Reuse a completed index so conformance costs one traversal, not two. */ readonly index?: EdfRecordIndex; readonly scanSamples?: boolean; readonly onProgress?: (done: number, total: number) => void; } /** * What a signal's samples actually contained, against what its header declared. A narrow * observed range on a long recording usually means the declared range is wrong rather than the * amplifier idle, and `outOfDigitalRangeCount` above zero means it is definitely wrong. */ export interface ObservedSignalStats { readonly signalIndex: number; readonly observedDigitalMin: number; readonly observedDigitalMax: number; readonly outOfDigitalRangeCount: number; readonly sampleCount: number; } /** * The result of a full conformance sweep. `recordsScanned` and `bytesRead` are reported because * this is the one call in edfcore that reads the whole file, so what it cost is part of the * answer rather than something a caller has to infer. */ export interface ValidationReport { readonly ok: boolean; readonly diagnostics: readonly EdfDiagnostic[]; readonly recordsScanned: number; readonly bytesRead: number; readonly signalStats: readonly ObservedSignalStats[]; }