edfcore 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +319 -0
- package/dist/bytes/latin1.d.ts +43 -0
- package/dist/bytes/latin1.d.ts.map +1 -0
- package/dist/bytes/latin1.js +84 -0
- package/dist/bytes/latin1.js.map +1 -0
- package/dist/bytes/numbers.d.ts +45 -0
- package/dist/bytes/numbers.d.ts.map +1 -0
- package/dist/bytes/numbers.js +87 -0
- package/dist/bytes/numbers.js.map +1 -0
- package/dist/bytes/view.d.ts +27 -0
- package/dist/bytes/view.d.ts.map +1 -0
- package/dist/bytes/view.js +53 -0
- package/dist/bytes/view.js.map +1 -0
- package/dist/constants.d.ts +113 -0
- package/dist/constants.d.ts.map +1 -0
- package/dist/constants.js +83 -0
- package/dist/constants.js.map +1 -0
- package/dist/decode/digital.d.ts +49 -0
- package/dist/decode/digital.d.ts.map +1 -0
- package/dist/decode/digital.js +180 -0
- package/dist/decode/digital.js.map +1 -0
- package/dist/decode/physical.d.ts +37 -0
- package/dist/decode/physical.d.ts.map +1 -0
- package/dist/decode/physical.js +178 -0
- package/dist/decode/physical.js.map +1 -0
- package/dist/diagnostics/codes.d.ts +38 -0
- package/dist/diagnostics/codes.d.ts.map +1 -0
- package/dist/diagnostics/codes.js +134 -0
- package/dist/diagnostics/codes.js.map +1 -0
- package/dist/diagnostics/collector.d.ts +76 -0
- package/dist/diagnostics/collector.d.ts.map +1 -0
- package/dist/diagnostics/collector.js +95 -0
- package/dist/diagnostics/collector.js.map +1 -0
- package/dist/diagnostics/format.d.ts +21 -0
- package/dist/diagnostics/format.d.ts.map +1 -0
- package/dist/diagnostics/format.js +125 -0
- package/dist/diagnostics/format.js.map +1 -0
- package/dist/errors.d.ts +143 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +135 -0
- package/dist/errors.js.map +1 -0
- package/dist/header/dates.d.ts +101 -0
- package/dist/header/dates.d.ts.map +1 -0
- package/dist/header/dates.js +378 -0
- package/dist/header/dates.js.map +1 -0
- package/dist/header/fields.d.ts +105 -0
- package/dist/header/fields.d.ts.map +1 -0
- package/dist/header/fields.js +363 -0
- package/dist/header/fields.js.map +1 -0
- package/dist/header/identification.d.ts +47 -0
- package/dist/header/identification.d.ts.map +1 -0
- package/dist/header/identification.js +156 -0
- package/dist/header/identification.js.map +1 -0
- package/dist/header/lookup.d.ts +33 -0
- package/dist/header/lookup.d.ts.map +1 -0
- package/dist/header/lookup.js +67 -0
- package/dist/header/lookup.js.map +1 -0
- package/dist/header/parse.d.ts +32 -0
- package/dist/header/parse.d.ts.map +1 -0
- package/dist/header/parse.js +319 -0
- package/dist/header/parse.js.map +1 -0
- package/dist/header/scale.d.ts +68 -0
- package/dist/header/scale.d.ts.map +1 -0
- package/dist/header/scale.js +154 -0
- package/dist/header/scale.js.map +1 -0
- package/dist/header/signals.d.ts +64 -0
- package/dist/header/signals.d.ts.map +1 -0
- package/dist/header/signals.js +374 -0
- package/dist/header/signals.js.map +1 -0
- package/dist/header/variant.d.ts +67 -0
- package/dist/header/variant.d.ts.map +1 -0
- package/dist/header/variant.js +180 -0
- package/dist/header/variant.js.map +1 -0
- package/dist/index.d.ts +46 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +59 -0
- package/dist/index.js.map +1 -0
- package/dist/inspect.d.ts +31 -0
- package/dist/inspect.d.ts.map +1 -0
- package/dist/inspect.js +165 -0
- package/dist/inspect.js.map +1 -0
- package/dist/io/blob.d.ts +14 -0
- package/dist/io/blob.d.ts.map +1 -0
- package/dist/io/blob.js +29 -0
- package/dist/io/blob.js.map +1 -0
- package/dist/io/bytes.d.ts +11 -0
- package/dist/io/bytes.d.ts.map +1 -0
- package/dist/io/bytes.js +26 -0
- package/dist/io/bytes.js.map +1 -0
- package/dist/io/cached.d.ts +19 -0
- package/dist/io/cached.d.ts.map +1 -0
- package/dist/io/cached.js +129 -0
- package/dist/io/cached.js.map +1 -0
- package/dist/io/http.d.ts +20 -0
- package/dist/io/http.d.ts.map +1 -0
- package/dist/io/http.js +225 -0
- package/dist/io/http.js.map +1 -0
- package/dist/io/read.d.ts +42 -0
- package/dist/io/read.d.ts.map +1 -0
- package/dist/io/read.js +119 -0
- package/dist/io/read.js.map +1 -0
- package/dist/io/source.d.ts +33 -0
- package/dist/io/source.d.ts.map +1 -0
- package/dist/io/source.js +72 -0
- package/dist/io/source.js.map +1 -0
- package/dist/node.d.ts +70 -0
- package/dist/node.d.ts.map +1 -0
- package/dist/node.js +109 -0
- package/dist/node.js.map +1 -0
- package/dist/record-index.d.ts +58 -0
- package/dist/record-index.d.ts.map +1 -0
- package/dist/record-index.js +290 -0
- package/dist/record-index.js.map +1 -0
- package/dist/recording.d.ts +57 -0
- package/dist/recording.d.ts.map +1 -0
- package/dist/recording.js +178 -0
- package/dist/recording.js.map +1 -0
- package/dist/tal/annotations.d.ts +36 -0
- package/dist/tal/annotations.d.ts.map +1 -0
- package/dist/tal/annotations.js +437 -0
- package/dist/tal/annotations.js.map +1 -0
- package/dist/tal/grammar.d.ts +111 -0
- package/dist/tal/grammar.d.ts.map +1 -0
- package/dist/tal/grammar.js +324 -0
- package/dist/tal/grammar.js.map +1 -0
- package/dist/tal/ticks.d.ts +69 -0
- package/dist/tal/ticks.d.ts.map +1 -0
- package/dist/tal/ticks.js +137 -0
- package/dist/tal/ticks.js.map +1 -0
- package/dist/time/segments.d.ts +33 -0
- package/dist/time/segments.d.ts.map +1 -0
- package/dist/time/segments.js +87 -0
- package/dist/time/segments.js.map +1 -0
- package/dist/time/timeline.d.ts +62 -0
- package/dist/time/timeline.d.ts.map +1 -0
- package/dist/time/timeline.js +227 -0
- package/dist/time/timeline.js.map +1 -0
- package/dist/time/window.d.ts +54 -0
- package/dist/time/window.d.ts.map +1 -0
- package/dist/time/window.js +232 -0
- package/dist/time/window.js.map +1 -0
- package/dist/types.d.ts +409 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +16 -0
- package/dist/types.js.map +1 -0
- package/dist/validate.d.ts +53 -0
- package/dist/validate.d.ts.map +1 -0
- package/dist/validate.js +483 -0
- package/dist/validate.js.map +1 -0
- package/package.json +77 -0
- package/src/bytes/latin1.ts +87 -0
- package/src/bytes/numbers.ts +115 -0
- package/src/bytes/view.ts +59 -0
- package/src/constants.ts +96 -0
- package/src/decode/digital.ts +258 -0
- package/src/decode/physical.ts +235 -0
- package/src/diagnostics/codes.ts +209 -0
- package/src/diagnostics/collector.ts +129 -0
- package/src/diagnostics/format.ts +143 -0
- package/src/errors.ts +208 -0
- package/src/header/dates.ts +453 -0
- package/src/header/fields.ts +455 -0
- package/src/header/identification.ts +196 -0
- package/src/header/lookup.ts +81 -0
- package/src/header/parse.ts +398 -0
- package/src/header/scale.ts +268 -0
- package/src/header/signals.ts +550 -0
- package/src/header/variant.ts +239 -0
- package/src/index.ts +151 -0
- package/src/inspect.ts +181 -0
- package/src/io/blob.ts +31 -0
- package/src/io/bytes.ts +29 -0
- package/src/io/cached.ts +136 -0
- package/src/io/http.ts +299 -0
- package/src/io/read.ts +152 -0
- package/src/io/source.ts +87 -0
- package/src/node.ts +138 -0
- package/src/record-index.ts +389 -0
- package/src/recording.ts +234 -0
- package/src/tal/annotations.ts +574 -0
- package/src/tal/grammar.ts +540 -0
- package/src/tal/ticks.ts +172 -0
- package/src/time/segments.ts +109 -0
- package/src/time/timeline.ts +290 -0
- package/src/time/window.ts +304 -0
- package/src/types.ts +490 -0
- package/src/validate.ts +610 -0
package/src/recording.ts
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The convenience layer: composition over the primitives, and no behaviour of its own.
|
|
3
|
+
*
|
|
4
|
+
* Layer 6. Everything here is a few lines of arrangement around `io/read.ts`, `decode/` and
|
|
5
|
+
* `time/`. That is the point — a facade that made its own decisions would be a second place where
|
|
6
|
+
* the library's rules live, and the rules are what edfcore is.
|
|
7
|
+
*
|
|
8
|
+
* Three shapes are load-bearing:
|
|
9
|
+
*
|
|
10
|
+
* - `readRecords` returns exactly ONE chunk and costs exactly one read. The caller named the
|
|
11
|
+
* records, so a gap inside them cannot surprise anyone.
|
|
12
|
+
* - `readWindow` ALWAYS returns an array, one chunk per contiguous run, including for a
|
|
13
|
+
* continuous file where the array always has one element. If two shapes existed, consumers
|
|
14
|
+
* would write against the easy one and misbehave on EDF+D. A window entirely inside a gap
|
|
15
|
+
* returns `[]`, and nothing is ever filled in: there is no gap-fill and no gap-fill option.
|
|
16
|
+
* - Chunks stay RECORD-ALIGNED and are therefore usually wider than the window asked for. The
|
|
17
|
+
* exact per-signal narrowing is `trimToWindow`, which is pure and testable without I/O.
|
|
18
|
+
*
|
|
19
|
+
* Every chunk carries the onsets of the records it contains, verified from the bytes that were
|
|
20
|
+
* already read — annotation regions live inside those bytes, so this costs no extra I/O and makes
|
|
21
|
+
* a sparsely indexed file safe for the data you actually received.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { decodeDigitalCounted } from './decode/digital.js';
|
|
25
|
+
import { EdfChannelNotFoundError } from './errors.js';
|
|
26
|
+
import { readHeader, readRecordBytes } from './io/read.js';
|
|
27
|
+
import { buildTimeline } from './record-index.js';
|
|
28
|
+
import { decodeAnnotations } from './tal/annotations.js';
|
|
29
|
+
import { ticksToSeconds } from './tal/ticks.js';
|
|
30
|
+
import { assertMonotonicOnsetArray } from './time/timeline.js';
|
|
31
|
+
import { resolveTimeWindow } from './time/window.js';
|
|
32
|
+
import type {
|
|
33
|
+
ByteSource,
|
|
34
|
+
DecodeAnnotationsOptions,
|
|
35
|
+
EdfAnnotationsResult,
|
|
36
|
+
EdfChunk,
|
|
37
|
+
EdfChunkSignal,
|
|
38
|
+
EdfGap,
|
|
39
|
+
EdfHeader,
|
|
40
|
+
EdfRecordIndex,
|
|
41
|
+
EdfRecording,
|
|
42
|
+
EdfSignal,
|
|
43
|
+
OpenOptions,
|
|
44
|
+
ReadOptions,
|
|
45
|
+
RecordRange,
|
|
46
|
+
RecordSelection,
|
|
47
|
+
WindowSelection,
|
|
48
|
+
} from './types.js';
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Open a recording: the header, then the timeline.
|
|
52
|
+
*
|
|
53
|
+
* Never scans the file. On a plain EDF or BDF this is two reads in total; on an EDF+ or BDF+ file
|
|
54
|
+
* it is two more, probing the first and last records for their timekeeping onsets.
|
|
55
|
+
*/
|
|
56
|
+
export async function openEdf(source: ByteSource, options?: OpenOptions): Promise<EdfRecording> {
|
|
57
|
+
const header = await readHeader(source, options);
|
|
58
|
+
const { timeline, index } = await buildTimeline(source, header, options);
|
|
59
|
+
return { source, header, timeline, index };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The signals to decode, deduplicated and in the order given.
|
|
64
|
+
*
|
|
65
|
+
* An annotation signal is refused with a plain `RangeError` rather than an `EdfError`: its bytes
|
|
66
|
+
* are TAL text, and decoding them as samples produces numbers that look exactly like a signal.
|
|
67
|
+
* That is the failure this library exists to prevent, and it can only ever be a caller's mistake.
|
|
68
|
+
*/
|
|
69
|
+
function resolveSignals(header: EdfHeader, signalIndices: readonly number[]): readonly EdfSignal[] {
|
|
70
|
+
const seen = new Set<number>();
|
|
71
|
+
const signals: EdfSignal[] = [];
|
|
72
|
+
for (const signalIndex of signalIndices) {
|
|
73
|
+
if (seen.has(signalIndex)) continue;
|
|
74
|
+
seen.add(signalIndex);
|
|
75
|
+
|
|
76
|
+
const signal = header.signals[signalIndex];
|
|
77
|
+
if (signal === undefined) {
|
|
78
|
+
throw new EdfChannelNotFoundError(
|
|
79
|
+
`signalIndex ${signalIndex} is outside the ${header.signals.length} signals this file ` +
|
|
80
|
+
'declares. Next: pass an index from header.dataSignalIndices, or resolve one with ' +
|
|
81
|
+
'getSignal(header, label).',
|
|
82
|
+
{ selector: signalIndex, availableLabels: header.signals.map((s) => s.label) },
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
if (signal.kind === 'annotations') {
|
|
86
|
+
throw new RangeError(
|
|
87
|
+
`signal ${signalIndex} (${JSON.stringify(signal.label)}) is this file's annotations ` +
|
|
88
|
+
'channel: its bytes are TAL text, not samples, so decoding them as samples would ' +
|
|
89
|
+
'produce numbers that look like a signal. Next: call readAnnotations(recording, ' +
|
|
90
|
+
'records) for it, and pass only header.dataSignalIndices here.',
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
signals.push(signal);
|
|
94
|
+
}
|
|
95
|
+
return signals;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The gap immediately before `recordStart`, when the index knows where the gaps are.
|
|
100
|
+
*
|
|
101
|
+
* `undefined` for a probed index, and that is not a claim that there is no gap — it is the honest
|
|
102
|
+
* answer that nobody has read the onsets in between. `buildRecordIndex()` is what turns the
|
|
103
|
+
* question into an answerable one.
|
|
104
|
+
*/
|
|
105
|
+
function gapBefore(index: EdfRecordIndex, recordStart: number): EdfGap | undefined {
|
|
106
|
+
const { segments, gaps } = index;
|
|
107
|
+
if (segments === undefined || gaps === undefined) return undefined;
|
|
108
|
+
for (const gap of gaps) {
|
|
109
|
+
const after = segments[gap.afterSegmentIndex];
|
|
110
|
+
if (after !== undefined && after.records.start === recordStart) return gap;
|
|
111
|
+
}
|
|
112
|
+
return undefined;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* One record range in, one chunk out, one read.
|
|
117
|
+
*
|
|
118
|
+
* The annotation regions of these records are already inside the bytes that were read, so their
|
|
119
|
+
* onsets are decoded and checked for free. That is what makes `chunk.startSeconds` the record's
|
|
120
|
+
* TRUE start on an EDF+D file rather than a nominal `start * recordDuration` that a gap would
|
|
121
|
+
* have invalidated.
|
|
122
|
+
*/
|
|
123
|
+
async function readChunk(
|
|
124
|
+
recording: EdfRecording,
|
|
125
|
+
records: RecordRange,
|
|
126
|
+
signalIndices: readonly number[],
|
|
127
|
+
options?: ReadOptions,
|
|
128
|
+
): Promise<EdfChunk> {
|
|
129
|
+
const { source, header, timeline, index } = recording;
|
|
130
|
+
const signals = resolveSignals(header, signalIndices);
|
|
131
|
+
|
|
132
|
+
const bytes = await readRecordBytes(source, header, records, options);
|
|
133
|
+
const byteOffset = header.headerByteLength + records.start * header.recordByteLength;
|
|
134
|
+
|
|
135
|
+
// Never strict, and not because the flag was lost: a read that threw on an impolite TAL in a
|
|
136
|
+
// record the caller asked for would return no samples at all over a defect in a different
|
|
137
|
+
// channel. The defects land on `chunk.diagnostics`, next to the data they were found beside.
|
|
138
|
+
const annotations = decodeAnnotations(header, bytes, records);
|
|
139
|
+
const onsets = annotations.recordOnsetTicks;
|
|
140
|
+
assertMonotonicOnsetArray(onsets, records.start);
|
|
141
|
+
|
|
142
|
+
const durationTicks = header.recordDurationTicks;
|
|
143
|
+
const nominalFirstTicks = timeline.startOffsetTicks + BigInt(records.start) * durationTicks;
|
|
144
|
+
const firstOnsetTicks = onsets[0] ?? nominalFirstTicks;
|
|
145
|
+
const lastOnsetTicks =
|
|
146
|
+
records.count > 0 ? (onsets[records.count - 1] ?? firstOnsetTicks) : firstOnsetTicks;
|
|
147
|
+
const startTicks = firstOnsetTicks - timeline.startOffsetTicks;
|
|
148
|
+
// The SPAN of the chunk, not the time it covers: they are equal for one contiguous run, which
|
|
149
|
+
// is what readWindow produces, and they differ when a caller names records across a gap.
|
|
150
|
+
const spanTicks = records.count > 0 ? lastOnsetTicks + durationTicks - firstOnsetTicks : 0n;
|
|
151
|
+
const startSeconds = ticksToSeconds(startTicks);
|
|
152
|
+
|
|
153
|
+
const chunkSignals: EdfChunkSignal[] = signals.map((signal) => {
|
|
154
|
+
const decoded = decodeDigitalCounted(header, bytes, records, signal.index, undefined, options);
|
|
155
|
+
return {
|
|
156
|
+
signalIndex: signal.index,
|
|
157
|
+
sampleCount: decoded.digital.length,
|
|
158
|
+
digital: decoded.digital,
|
|
159
|
+
firstSampleIndex: records.start * signal.samplesPerRecord,
|
|
160
|
+
startSeconds,
|
|
161
|
+
outOfDigitalRangeCount: decoded.outOfDigitalRangeCount,
|
|
162
|
+
};
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
return {
|
|
166
|
+
records,
|
|
167
|
+
startSeconds,
|
|
168
|
+
durationSeconds: ticksToSeconds(spanTicks),
|
|
169
|
+
byteOffset,
|
|
170
|
+
byteLength: bytes.length,
|
|
171
|
+
signals: Object.freeze(chunkSignals),
|
|
172
|
+
precededByGap: gapBefore(index, records.start),
|
|
173
|
+
diagnostics: annotations.diagnostics,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Exactly one chunk and exactly one read: you named the records, so gaps cannot surprise you. */
|
|
178
|
+
export async function readRecords(
|
|
179
|
+
recording: EdfRecording,
|
|
180
|
+
selection: RecordSelection,
|
|
181
|
+
options?: ReadOptions,
|
|
182
|
+
): Promise<EdfChunk> {
|
|
183
|
+
return readChunk(recording, selection.records, selection.signalIndices, options);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* A time window, as one chunk per contiguous run of records.
|
|
188
|
+
*
|
|
189
|
+
* Always an array. `[]` means the window is entirely inside a gap or entirely outside the
|
|
190
|
+
* recording — never that the read failed. Chunks are record-aligned and may be wider than asked
|
|
191
|
+
* for; `trimToWindow(header, chunkSignal, startSeconds, durationSeconds)` narrows them exactly.
|
|
192
|
+
*
|
|
193
|
+
* Runs are read one after another rather than concurrently, so the read pattern a caller observes
|
|
194
|
+
* is the one this function issued, in order, with no burst it did not ask for. Concurrency over a
|
|
195
|
+
* `ByteSource` belongs to the source — `httpSource` has `maxConcurrency` — not here.
|
|
196
|
+
*
|
|
197
|
+
* On a discontinuous file a probed index cannot map seconds to records; `resolveTimeWindow`
|
|
198
|
+
* refuses rather than guessing. Build a complete index and rebuild the recording around it:
|
|
199
|
+
* `const index = await buildRecordIndex(rec); await readWindow({ ...rec, index }, selection)`.
|
|
200
|
+
*/
|
|
201
|
+
export async function readWindow(
|
|
202
|
+
recording: EdfRecording,
|
|
203
|
+
selection: WindowSelection,
|
|
204
|
+
options?: ReadOptions,
|
|
205
|
+
): Promise<readonly EdfChunk[]> {
|
|
206
|
+
const ranges = resolveTimeWindow(
|
|
207
|
+
recording.timeline,
|
|
208
|
+
recording.index,
|
|
209
|
+
selection.startSeconds,
|
|
210
|
+
selection.durationSeconds,
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
const chunks: EdfChunk[] = [];
|
|
214
|
+
for (const records of ranges) {
|
|
215
|
+
chunks.push(await readChunk(recording, records, selection.signalIndices, options));
|
|
216
|
+
}
|
|
217
|
+
return Object.freeze(chunks);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* The annotations in a record range, in one read.
|
|
222
|
+
*
|
|
223
|
+
* `records` is required and has no default. A full-file annotation scan is a legitimate thing to
|
|
224
|
+
* want and an expensive thing to do by accident, so it is always visible in the caller's source
|
|
225
|
+
* as `{ start: 0, count: recording.header.recordCount }`.
|
|
226
|
+
*/
|
|
227
|
+
export async function readAnnotations(
|
|
228
|
+
recording: EdfRecording,
|
|
229
|
+
records: RecordRange,
|
|
230
|
+
options?: DecodeAnnotationsOptions & ReadOptions,
|
|
231
|
+
): Promise<EdfAnnotationsResult> {
|
|
232
|
+
const bytes = await readRecordBytes(recording.source, recording.header, records, options);
|
|
233
|
+
return decodeAnnotations(recording.header, bytes, records, options);
|
|
234
|
+
}
|