/** * Chunked reader for EDF / EDF+ files. * * Data records are read in batches sized by a byte budget rather than all at once, * so peak memory stays flat regardless of how long the recording is. A 4 GB file * and a 4 MB file use the same working set. */ import type { Diagnostic } from './errors.js'; import type { EdfHeader, EdfSignal } from './header.js'; import type { Annotation } from './annotations.js'; /** Default read budget per batch. Large enough to amortise syscalls, small enough to stay cheap. */ export declare const DEFAULT_CHUNK_BYTES: number; export interface RecordBatch { /** Index of the first record in this batch, relative to the whole file. */ firstRecordIndex: number; recordCount: number; /** * Raw record bytes, `recordCount * header.recordBytes` long. * * The buffer is reused between iterations. Copy anything you need to keep past * the current loop turn. */ data: Uint8Array; } export interface ReadRecordsOptions { /** First record to read, inclusive. Defaults to 0. */ startRecord?: number; /** Last record to read, exclusive. Defaults to the file's record count. */ endRecord?: number; chunkBytes?: number; } export declare class EdfFile { #private; readonly path: string; readonly fileSize: number; /** * Last-modified time when this file was opened, in milliseconds, for the same reason as * `fileSize`. * * Kept as the raw number rather than a Date because `new Date(ms).getTime()` truncates to * whole milliseconds: comparing that against a later `fstat`, which carries the * filesystem's sub-millisecond precision, reported every undisturbed conversion as one * whose input had changed underneath it. */ readonly modifiedAtOpenMs: number; readonly header: EdfHeader; /** Records actually present in the file, which may differ from the header's claim. */ readonly recordCount: number; readonly trailingBytes: number; readonly diagnostics: Diagnostic[]; private constructor(); /** * SHA-256 of the bytes this conversion actually read. * * Hashed through the open descriptor, over exactly the `fileSize` bytes that were there * when the file was opened — the same number every record count and window in the output * was derived from. Re-opening the path to hash it afterwards described whatever was at * that name by then: a recording still being written grew from 2,000 records to 3,000 * mid-conversion and metadata.json recorded `data_records: 2000` beside the checksum and * byte count of the 3,000-record file, which is provenance for bytes nobody converted. * Replacing the file at that path did the same thing more completely. */ sha256(): Promise; /** * Whether the file has changed since it was opened, by size or by modification time. * * Checked through the descriptor, so it answers for the bytes that were read rather than * for whatever now answers to the same name. A recording still being written is the * ordinary cause, and the conversion is still correct for the data it saw — it is the * claim that the output describes the file as it now stands that stops being true. */ changedSinceOpen(): Promise; static open(path: string): Promise; /** Signal channels, excluding the EDF+ annotations channel. */ get dataSignals(): EdfSignal[]; /** * The annotation channel a record's start time is read from. * * EDF+ puts the timekeeping TAL first in the first annotation channel, and this was read as * `annotationSignals[0]` — the first one declared, whether or not it can hold anything. A * writer that declares an annotation channel and gives it zero samples per record leaves a * slot of zero bytes, so nothing was read from it, and the timekeeping in the channel after * it went unread: a three-record EDF+D reported "3 of 3 data records carry no readable * timekeeping annotation" about three that were perfectly readable, and timed the file from * zero. * * A channel with no room carries nothing, so it is not the one the TAL is in. */ get timekeepingSignal(): EdfSignal | undefined; get annotationSignals(): EdfSignal[]; /** Total recording duration in seconds, based on records actually present. */ get durationSeconds(): number; /** Read a half-open range of records in batches. */ readRecords(options?: ReadRecordsOptions): AsyncGenerator; /** Read one sample as its raw digital value. */ sampleAt(batch: RecordBatch, recordOffset: number, signal: EdfSignal, sampleIndex: number): number; /** Byte offset of a signal's samples within a batch. */ offsetOf(batch: RecordBatch, recordOffset: number, signal: EdfSignal): number; /** The annotation channel's raw bytes for one record in a batch. */ annotationBytes(batch: RecordBatch, recordOffset: number, signal: EdfSignal): Uint8Array; /** * Where this continuous recording begins, from the first record that says. * * A few records' worth of annotation bytes rather than the whole channel. A continuous * recording's origin is the fraction of a second by which its first record follows the * header's start time, and `--info` needs that to place a requested window — but it does * not need the events, and finding one number by reading every record costs a seek per * record across the whole file, which is the scan `--info` was deliberately spared. * * It reads on past record 0 because a conversion does. This used to stop there, so the * moment one timekeeping TAL was unreadable the two disagreed: the conversion took the * origin from record 1 and timed the file from 0.5s, while `--info` found nothing at * record 0 and reported a recording starting at zero — the same file described two ways by * one tool. Records are contiguous, so record `i` beginning at `t` puts the origin at * `t - i * duration`, and any one of them settles it. * * The bound is what keeps this cheap: a file whose first `RECORDS_SEARCHED_FOR_ORIGIN` * timekeeping entries are all unreadable reports an origin of zero here, and converting it * raises ANNOTATION_DECODE_FAILED for every one of them. * * That mitigation covers records that could not be read, and not records that said nothing: * an empty annotation slot is not a TAL that failed, so nothing is counted and nothing is * raised. Twenty records whose only timekeeping entry is in record 16 therefore convert with * `time_s` from the origin it states and are reported here as beginning at zero, in silence * on both sides — and `--start` and `--end` are read against that same clock. The bound * stays, since it is what makes `--info` a header read on a file of any size; what was * wrong was the account of what it costs, which every page giving it said was a warning. * * Returns null when there is nothing to read it from, in which case the origin is zero. */ readOrigin(): Promise; /** * The origin, and what the search saw on the way to it. * * `--info` takes this route for a continuous recording rather than reading every record, * and reported nothing when the timekeeping it read was unreadable: the count was hard-coded * to zero at the call site, so a file whose first TAL cannot be parsed raised * ANNOTATION_DECODE_FAILED when converted and nothing under `--info`. Its byte-identical * EDF+D twin — same bytes but for the reserved field, which has nothing to do with the * defect — raised it both ways, because that path reads every record and counts as it goes. * * The failure was being read and then thrown away. `readOrigin` keeps its shape for callers * who only want the number. * * All three counters, not one. A first-position TAL may carry events after the start time, * and when it cannot be parsed those go with it — which is what `malformedTimekeepingWithText` * counts and what decides whether the warning says "No event was lost" or names the events * that were. Counting only the first meant `--info` took the first sentence every time: it * announced that a record had lost its position and that nothing else had gone, over a file * whose conversion said, correctly, that an event had gone with it. One file, two answers, * and the confident one was `--info`, which is the command run first to find out what a * conversion will say. * * `malformed` comes back for the same reason one sentence further on: that hint ends "and is * counted above", which is only true where the entry warning is printed too. * * All three are of the records this actually read, which is as far as the first record that * states a time — so they are lower bounds on the file, as `malformedTimekeeping` has been * since it was returned at all. A conversion reads every record and may count more. What * they must not be is inconsistent with each other, which is what a hard-coded zero made * them. */ scanOrigin(): Promise<{ origin: number | null; malformed: number; malformedTimekeeping: number; malformedTimekeepingWithText: number; /** * What each record it read said its own start time was, or null where it said nothing. * * One entry per record searched, so shorter than the file — a lower bound like the three * counters above, and for the same reason. `--info` compares these against where * continuity puts them, which is how an `EDF+C` file that contradicts itself is reported * without reading every record of it. */ recordStarts: (number | null)[]; }>; /** * Read every EDF+ annotation in the file, plus the start time each record declares. * * Only the annotation channel is read, seeking straight to it inside each record * rather than pulling whole records through memory. On a multi-gigabyte recording * that is the difference between a few kilobytes of I/O and all of it. * * The whole file is always scanned, never just the records inside a requested * window: writers are not obliged to store an annotation in the record its onset * falls in, and some put every annotation in the first record. Reading only the * window's records would drop those entirely. */ readAnnotations(): Promise<{ annotations: Annotation[]; recordStarts: (number | null)[]; malformed: number; /** Unreadable TALs in first position, which carry timing rather than an event. */ malformedTimekeeping: number; /** How many of those also carried event text, so events were lost with the position. */ malformedTimekeepingWithText: number; /** Events kept whose stated duration could not be read; see Annotation.duration. */ unreadableDurations: number; /** Events kept whose stated duration read as a number below zero. */ negativeDurations: number; }>; close(): Promise; }