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.
Files changed (188) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +319 -0
  3. package/dist/bytes/latin1.d.ts +43 -0
  4. package/dist/bytes/latin1.d.ts.map +1 -0
  5. package/dist/bytes/latin1.js +84 -0
  6. package/dist/bytes/latin1.js.map +1 -0
  7. package/dist/bytes/numbers.d.ts +45 -0
  8. package/dist/bytes/numbers.d.ts.map +1 -0
  9. package/dist/bytes/numbers.js +87 -0
  10. package/dist/bytes/numbers.js.map +1 -0
  11. package/dist/bytes/view.d.ts +27 -0
  12. package/dist/bytes/view.d.ts.map +1 -0
  13. package/dist/bytes/view.js +53 -0
  14. package/dist/bytes/view.js.map +1 -0
  15. package/dist/constants.d.ts +113 -0
  16. package/dist/constants.d.ts.map +1 -0
  17. package/dist/constants.js +83 -0
  18. package/dist/constants.js.map +1 -0
  19. package/dist/decode/digital.d.ts +49 -0
  20. package/dist/decode/digital.d.ts.map +1 -0
  21. package/dist/decode/digital.js +180 -0
  22. package/dist/decode/digital.js.map +1 -0
  23. package/dist/decode/physical.d.ts +37 -0
  24. package/dist/decode/physical.d.ts.map +1 -0
  25. package/dist/decode/physical.js +178 -0
  26. package/dist/decode/physical.js.map +1 -0
  27. package/dist/diagnostics/codes.d.ts +38 -0
  28. package/dist/diagnostics/codes.d.ts.map +1 -0
  29. package/dist/diagnostics/codes.js +134 -0
  30. package/dist/diagnostics/codes.js.map +1 -0
  31. package/dist/diagnostics/collector.d.ts +76 -0
  32. package/dist/diagnostics/collector.d.ts.map +1 -0
  33. package/dist/diagnostics/collector.js +95 -0
  34. package/dist/diagnostics/collector.js.map +1 -0
  35. package/dist/diagnostics/format.d.ts +21 -0
  36. package/dist/diagnostics/format.d.ts.map +1 -0
  37. package/dist/diagnostics/format.js +125 -0
  38. package/dist/diagnostics/format.js.map +1 -0
  39. package/dist/errors.d.ts +143 -0
  40. package/dist/errors.d.ts.map +1 -0
  41. package/dist/errors.js +135 -0
  42. package/dist/errors.js.map +1 -0
  43. package/dist/header/dates.d.ts +101 -0
  44. package/dist/header/dates.d.ts.map +1 -0
  45. package/dist/header/dates.js +378 -0
  46. package/dist/header/dates.js.map +1 -0
  47. package/dist/header/fields.d.ts +105 -0
  48. package/dist/header/fields.d.ts.map +1 -0
  49. package/dist/header/fields.js +363 -0
  50. package/dist/header/fields.js.map +1 -0
  51. package/dist/header/identification.d.ts +47 -0
  52. package/dist/header/identification.d.ts.map +1 -0
  53. package/dist/header/identification.js +156 -0
  54. package/dist/header/identification.js.map +1 -0
  55. package/dist/header/lookup.d.ts +33 -0
  56. package/dist/header/lookup.d.ts.map +1 -0
  57. package/dist/header/lookup.js +67 -0
  58. package/dist/header/lookup.js.map +1 -0
  59. package/dist/header/parse.d.ts +32 -0
  60. package/dist/header/parse.d.ts.map +1 -0
  61. package/dist/header/parse.js +319 -0
  62. package/dist/header/parse.js.map +1 -0
  63. package/dist/header/scale.d.ts +68 -0
  64. package/dist/header/scale.d.ts.map +1 -0
  65. package/dist/header/scale.js +154 -0
  66. package/dist/header/scale.js.map +1 -0
  67. package/dist/header/signals.d.ts +64 -0
  68. package/dist/header/signals.d.ts.map +1 -0
  69. package/dist/header/signals.js +374 -0
  70. package/dist/header/signals.js.map +1 -0
  71. package/dist/header/variant.d.ts +67 -0
  72. package/dist/header/variant.d.ts.map +1 -0
  73. package/dist/header/variant.js +180 -0
  74. package/dist/header/variant.js.map +1 -0
  75. package/dist/index.d.ts +46 -0
  76. package/dist/index.d.ts.map +1 -0
  77. package/dist/index.js +59 -0
  78. package/dist/index.js.map +1 -0
  79. package/dist/inspect.d.ts +31 -0
  80. package/dist/inspect.d.ts.map +1 -0
  81. package/dist/inspect.js +165 -0
  82. package/dist/inspect.js.map +1 -0
  83. package/dist/io/blob.d.ts +14 -0
  84. package/dist/io/blob.d.ts.map +1 -0
  85. package/dist/io/blob.js +29 -0
  86. package/dist/io/blob.js.map +1 -0
  87. package/dist/io/bytes.d.ts +11 -0
  88. package/dist/io/bytes.d.ts.map +1 -0
  89. package/dist/io/bytes.js +26 -0
  90. package/dist/io/bytes.js.map +1 -0
  91. package/dist/io/cached.d.ts +19 -0
  92. package/dist/io/cached.d.ts.map +1 -0
  93. package/dist/io/cached.js +129 -0
  94. package/dist/io/cached.js.map +1 -0
  95. package/dist/io/http.d.ts +20 -0
  96. package/dist/io/http.d.ts.map +1 -0
  97. package/dist/io/http.js +225 -0
  98. package/dist/io/http.js.map +1 -0
  99. package/dist/io/read.d.ts +42 -0
  100. package/dist/io/read.d.ts.map +1 -0
  101. package/dist/io/read.js +119 -0
  102. package/dist/io/read.js.map +1 -0
  103. package/dist/io/source.d.ts +33 -0
  104. package/dist/io/source.d.ts.map +1 -0
  105. package/dist/io/source.js +72 -0
  106. package/dist/io/source.js.map +1 -0
  107. package/dist/node.d.ts +70 -0
  108. package/dist/node.d.ts.map +1 -0
  109. package/dist/node.js +109 -0
  110. package/dist/node.js.map +1 -0
  111. package/dist/record-index.d.ts +58 -0
  112. package/dist/record-index.d.ts.map +1 -0
  113. package/dist/record-index.js +290 -0
  114. package/dist/record-index.js.map +1 -0
  115. package/dist/recording.d.ts +57 -0
  116. package/dist/recording.d.ts.map +1 -0
  117. package/dist/recording.js +178 -0
  118. package/dist/recording.js.map +1 -0
  119. package/dist/tal/annotations.d.ts +36 -0
  120. package/dist/tal/annotations.d.ts.map +1 -0
  121. package/dist/tal/annotations.js +437 -0
  122. package/dist/tal/annotations.js.map +1 -0
  123. package/dist/tal/grammar.d.ts +111 -0
  124. package/dist/tal/grammar.d.ts.map +1 -0
  125. package/dist/tal/grammar.js +324 -0
  126. package/dist/tal/grammar.js.map +1 -0
  127. package/dist/tal/ticks.d.ts +69 -0
  128. package/dist/tal/ticks.d.ts.map +1 -0
  129. package/dist/tal/ticks.js +137 -0
  130. package/dist/tal/ticks.js.map +1 -0
  131. package/dist/time/segments.d.ts +33 -0
  132. package/dist/time/segments.d.ts.map +1 -0
  133. package/dist/time/segments.js +87 -0
  134. package/dist/time/segments.js.map +1 -0
  135. package/dist/time/timeline.d.ts +62 -0
  136. package/dist/time/timeline.d.ts.map +1 -0
  137. package/dist/time/timeline.js +227 -0
  138. package/dist/time/timeline.js.map +1 -0
  139. package/dist/time/window.d.ts +54 -0
  140. package/dist/time/window.d.ts.map +1 -0
  141. package/dist/time/window.js +232 -0
  142. package/dist/time/window.js.map +1 -0
  143. package/dist/types.d.ts +409 -0
  144. package/dist/types.d.ts.map +1 -0
  145. package/dist/types.js +16 -0
  146. package/dist/types.js.map +1 -0
  147. package/dist/validate.d.ts +53 -0
  148. package/dist/validate.d.ts.map +1 -0
  149. package/dist/validate.js +483 -0
  150. package/dist/validate.js.map +1 -0
  151. package/package.json +77 -0
  152. package/src/bytes/latin1.ts +87 -0
  153. package/src/bytes/numbers.ts +115 -0
  154. package/src/bytes/view.ts +59 -0
  155. package/src/constants.ts +96 -0
  156. package/src/decode/digital.ts +258 -0
  157. package/src/decode/physical.ts +235 -0
  158. package/src/diagnostics/codes.ts +209 -0
  159. package/src/diagnostics/collector.ts +129 -0
  160. package/src/diagnostics/format.ts +143 -0
  161. package/src/errors.ts +208 -0
  162. package/src/header/dates.ts +453 -0
  163. package/src/header/fields.ts +455 -0
  164. package/src/header/identification.ts +196 -0
  165. package/src/header/lookup.ts +81 -0
  166. package/src/header/parse.ts +398 -0
  167. package/src/header/scale.ts +268 -0
  168. package/src/header/signals.ts +550 -0
  169. package/src/header/variant.ts +239 -0
  170. package/src/index.ts +151 -0
  171. package/src/inspect.ts +181 -0
  172. package/src/io/blob.ts +31 -0
  173. package/src/io/bytes.ts +29 -0
  174. package/src/io/cached.ts +136 -0
  175. package/src/io/http.ts +299 -0
  176. package/src/io/read.ts +152 -0
  177. package/src/io/source.ts +87 -0
  178. package/src/node.ts +138 -0
  179. package/src/record-index.ts +389 -0
  180. package/src/recording.ts +234 -0
  181. package/src/tal/annotations.ts +574 -0
  182. package/src/tal/grammar.ts +540 -0
  183. package/src/tal/ticks.ts +172 -0
  184. package/src/time/segments.ts +109 -0
  185. package/src/time/timeline.ts +290 -0
  186. package/src/time/window.ts +304 -0
  187. package/src/types.ts +490 -0
  188. package/src/validate.ts +610 -0
@@ -0,0 +1,239 @@
1
+ /**
2
+ * Which dialect the file claims to be.
3
+ *
4
+ * Layer 2. Sole owner of the version-block and reserved-field grammars — the two fields that
5
+ * decide sample width, EDF+ dialect and continuity, and therefore every byte offset and every
6
+ * time value computed downstream.
7
+ *
8
+ * Two rules deserve stating because they look wrong:
9
+ *
10
+ * - The 8-byte version block is the ONLY reliable EDF-vs-BDF discriminator. EDF+ deliberately
11
+ * keeps `'0 '` so legacy readers still open the file, so nothing in the reserved field
12
+ * can be trusted to identify the family.
13
+ * - BDF's version block is not ASCII: byte 0 is 0xFF, then `'BIOSEMI'`.
14
+ */
15
+
16
+ import { trimEdfField } from '../bytes/latin1.js';
17
+ import { readAsciiField, sliceBytes } from '../bytes/view.js';
18
+ import {
19
+ BDF_ANNOTATIONS_LABEL,
20
+ BDF_DIGITAL_MAX,
21
+ BDF_DIGITAL_MIN,
22
+ EDF_ANNOTATIONS_LABEL,
23
+ EDF_DIGITAL_MAX,
24
+ EDF_DIGITAL_MIN,
25
+ HEADER_FIELDS,
26
+ } from '../constants.js';
27
+ import { type DiagnosticSink, fatalError } from '../diagnostics/collector.js';
28
+ import type { EdfVariant } from '../types.js';
29
+
30
+ /** ASCII `'0'`: the whole of EDF's version block, before padding. */
31
+ const EDF_VERSION_BYTE = 0x30;
32
+ /** Not ASCII. This single byte is what separates a BDF file from an EDF one. */
33
+ const BDF_VERSION_BYTE = 0xff;
34
+ const BDF_VERSION_TEXT = 'BIOSEMI';
35
+
36
+ const CHAR_NUL = 0x00;
37
+ const CHAR_SPACE = 0x20;
38
+
39
+ const EDF_BYTES_PER_SAMPLE = 2 as const;
40
+ const BDF_BYTES_PER_SAMPLE = 3 as const;
41
+
42
+ export type EdfFamily = 'EDF' | 'BDF';
43
+
44
+ /** The recognised reserved-field prefixes. Matched on the first five bytes, never trimmed. */
45
+ export type EdfReservedMarker = 'EDF+C' | 'EDF+D' | 'BDF+C' | 'BDF+D' | '24BIT';
46
+
47
+ const RESERVED_MARKERS: readonly EdfReservedMarker[] = [
48
+ 'EDF+C',
49
+ 'EDF+D',
50
+ 'BDF+C',
51
+ 'BDF+D',
52
+ '24BIT',
53
+ ];
54
+
55
+ export interface EdfVariantInfo {
56
+ readonly variant: EdfVariant;
57
+ /** From the version block alone. Sample width follows from this and nothing else. */
58
+ readonly family: EdfFamily;
59
+ readonly bytesPerSample: 2 | 3;
60
+ readonly continuity: 'continuous' | 'discontinuous';
61
+ /** True for EDF+C/EDF+D/BDF+C/BDF+D: the file claims the EDF+ dialect, so the subfield
62
+ * grammars in `identification.ts` apply and an annotation signal is mandatory. */
63
+ readonly isPlus: boolean;
64
+ /** The five-byte prefix that actually matched, or `undefined` when the field is blank or
65
+ * unrecognised. */
66
+ readonly reservedMarker: EdfReservedMarker | undefined;
67
+ /** Trimmed, case-sensitive annotation label for this family. */
68
+ readonly annotationsLabel: string;
69
+ /** What the sample width can represent. Drives `DIGITAL_RANGE_EXCEEDS_FORMAT`. */
70
+ readonly digitalMinimumLimit: number;
71
+ readonly digitalMaximumLimit: number;
72
+ }
73
+
74
+ /**
75
+ * EDF's version block: ASCII `'0'` then seven spaces.
76
+ *
77
+ * NUL is accepted as padding alongside space. The spec says space, but a writer that pads with
78
+ * NUL has produced the same value, and `NOT_AN_EDF_FILE` — the only alternative here — would
79
+ * refuse a file whose every other byte is fine.
80
+ */
81
+ export function isEdfVersionBlock(versionBytes: Uint8Array): boolean {
82
+ if (versionBytes[0] !== EDF_VERSION_BYTE) return false;
83
+ for (let i = 1; i < versionBytes.length; i++) {
84
+ const byte = versionBytes[i];
85
+ if (byte !== CHAR_SPACE && byte !== CHAR_NUL) return false;
86
+ }
87
+ return true;
88
+ }
89
+
90
+ /** BDF's version block: byte 0 = 0xFF, bytes 1..7 = `'BIOSEMI'`. Exact, no padding tolerance. */
91
+ export function isBdfVersionBlock(versionBytes: Uint8Array): boolean {
92
+ if (versionBytes[0] !== BDF_VERSION_BYTE) return false;
93
+ for (let i = 0; i < BDF_VERSION_TEXT.length; i++) {
94
+ if (versionBytes[i + 1] !== BDF_VERSION_TEXT.charCodeAt(i)) return false;
95
+ }
96
+ return true;
97
+ }
98
+
99
+ /**
100
+ * The dialect marker carried by the reserved field, matched on its five-byte PREFIX.
101
+ *
102
+ * The prefix rule is what makes `'EDF+D v2.1'` an EDF+D file rather than an unrecognised one.
103
+ */
104
+ export function reservedMarkerOf(reserved: string): EdfReservedMarker | undefined {
105
+ for (const marker of RESERVED_MARKERS) {
106
+ if (reserved.startsWith(marker)) return marker;
107
+ }
108
+ return undefined;
109
+ }
110
+
111
+ function markerFamily(marker: EdfReservedMarker): EdfFamily {
112
+ return marker === 'EDF+C' || marker === 'EDF+D' ? 'EDF' : 'BDF';
113
+ }
114
+
115
+ function hexBytes(bytes: Uint8Array): string {
116
+ const parts: string[] = [];
117
+ for (const byte of bytes) parts.push(byte.toString(16).padStart(2, '0'));
118
+ return parts.join(' ');
119
+ }
120
+
121
+ function variantOf(
122
+ family: EdfFamily,
123
+ isPlus: boolean,
124
+ continuity: 'continuous' | 'discontinuous',
125
+ ): EdfVariant {
126
+ if (!isPlus) return family;
127
+ if (family === 'BDF') return continuity === 'discontinuous' ? 'BDF+D' : 'BDF+C';
128
+ return continuity === 'discontinuous' ? 'EDF+D' : 'EDF+C';
129
+ }
130
+
131
+ /**
132
+ * Read the version block and the reserved field, and decide the variant.
133
+ *
134
+ * `headerBytes` must cover at least the 256-byte fixed header; the caller has already refused
135
+ * anything shorter with `SOURCE_TOO_SMALL`.
136
+ *
137
+ * When the reserved marker names a different family than the version block does — `'BDF+C'` on
138
+ * a file whose version block is EDF — the version block wins for the family, but the marker is
139
+ * still honoured for continuity and EDF+ dialect, and the disagreement is reported. Dropping
140
+ * the `D` would silently turn a discontinuous recording into one whose every timestamp is
141
+ * wrong; keeping the wrong sample width would make every sample wrong instead.
142
+ */
143
+ export function detectVariant(headerBytes: Uint8Array, sink: DiagnosticSink): EdfVariantInfo {
144
+ const versionBytes = sliceBytes(
145
+ headerBytes,
146
+ HEADER_FIELDS.version.offset,
147
+ HEADER_FIELDS.version.length,
148
+ );
149
+ const isBdf = isBdfVersionBlock(versionBytes);
150
+ const isEdf = !isBdf && isEdfVersionBlock(versionBytes);
151
+
152
+ if (!isBdf && !isEdf) {
153
+ const raw = readAsciiField(
154
+ headerBytes,
155
+ HEADER_FIELDS.version.offset,
156
+ HEADER_FIELDS.version.length,
157
+ );
158
+ throw fatalError({
159
+ code: 'NOT_AN_EDF_FILE',
160
+ message:
161
+ `version field (8 bytes at offset ${HEADER_FIELDS.version.offset}) is ` +
162
+ `${JSON.stringify(raw)}, bytes ${hexBytes(versionBytes)}: this is neither EDF's ` +
163
+ `"0" followed by seven spaces nor BDF's 0xFF followed by "BIOSEMI". ` +
164
+ 'EDF specification, header record bytes 0-7 (version of this data format). ' +
165
+ 'Next: confirm the bytes are an uncompressed EDF or BDF recording — a gzip, zip or ' +
166
+ 'vendor container has to be unpacked before edfcore sees it.',
167
+ field: 'version',
168
+ byteOffset: HEADER_FIELDS.version.offset,
169
+ byteLength: HEADER_FIELDS.version.length,
170
+ rawBytes: versionBytes,
171
+ raw,
172
+ expected: '"0 " (EDF) or 0xFF "BIOSEMI" (BDF)',
173
+ actual: hexBytes(versionBytes),
174
+ specReference: 'EDF specification, header record bytes 0-7',
175
+ });
176
+ }
177
+
178
+ const family: EdfFamily = isBdf ? 'BDF' : 'EDF';
179
+ const reservedRaw = readAsciiField(
180
+ headerBytes,
181
+ HEADER_FIELDS.reserved.offset,
182
+ HEADER_FIELDS.reserved.length,
183
+ );
184
+ const marker = reservedMarkerOf(reservedRaw);
185
+ const reservedText = trimEdfField(reservedRaw);
186
+
187
+ if (marker === undefined && reservedText.length > 0) {
188
+ sink.report({
189
+ code: 'NONSTANDARD_RESERVED_FIELD',
190
+ message:
191
+ `reserved field (44 bytes at offset ${HEADER_FIELDS.reserved.offset}) is ` +
192
+ `${JSON.stringify(reservedText)}, which is neither blank nor one of the recognised ` +
193
+ 'five-byte markers "EDF+C", "EDF+D", "BDF+C", "BDF+D" or "24BIT". EDF+ specification ' +
194
+ '2.1.1: the reserved field must start with "EDF+C" when the recording is ' +
195
+ 'uninterrupted and "EDF+D" when it is not. Next: the file is read as plain ' +
196
+ `${family}; header.reserved keeps all 44 bytes verbatim.`,
197
+ field: 'reserved',
198
+ byteOffset: HEADER_FIELDS.reserved.offset,
199
+ byteLength: HEADER_FIELDS.reserved.length,
200
+ raw: reservedRaw,
201
+ expected: '"" or one of "EDF+C", "EDF+D", "BDF+C", "BDF+D", "24BIT"',
202
+ actual: reservedText,
203
+ specReference: 'EDF+ specification 2.1.1 (the EDF+ header)',
204
+ });
205
+ } else if (marker !== undefined && markerFamily(marker) !== family) {
206
+ sink.report({
207
+ code: 'NONSTANDARD_RESERVED_FIELD',
208
+ message:
209
+ `reserved field (44 bytes at offset ${HEADER_FIELDS.reserved.offset}) declares ` +
210
+ `${JSON.stringify(marker)} but the version block says this file is ${family}, and ` +
211
+ 'the version block is the only reliable discriminator (EDF+ keeps "0 " so ' +
212
+ `legacy readers still open the file). Next: the file is read as ${family} with ` +
213
+ `${family === 'BDF' ? 3 : 2} bytes per sample; the marker's continuity is still ` +
214
+ 'honoured, because ignoring a "D" would make every reported time wrong.',
215
+ field: 'reserved',
216
+ byteOffset: HEADER_FIELDS.reserved.offset,
217
+ byteLength: HEADER_FIELDS.reserved.length,
218
+ raw: reservedRaw,
219
+ expected: family === 'BDF' ? '"BDF+C", "BDF+D" or "24BIT"' : '"EDF+C" or "EDF+D"',
220
+ actual: marker,
221
+ specReference: 'EDF specification, header record bytes 0-7',
222
+ });
223
+ }
224
+
225
+ const isPlus = marker !== undefined && marker !== '24BIT';
226
+ const continuity = marker === 'EDF+D' || marker === 'BDF+D' ? 'discontinuous' : 'continuous';
227
+
228
+ return {
229
+ variant: variantOf(family, isPlus, continuity),
230
+ family,
231
+ bytesPerSample: family === 'BDF' ? BDF_BYTES_PER_SAMPLE : EDF_BYTES_PER_SAMPLE,
232
+ continuity,
233
+ isPlus,
234
+ reservedMarker: marker,
235
+ annotationsLabel: family === 'BDF' ? BDF_ANNOTATIONS_LABEL : EDF_ANNOTATIONS_LABEL,
236
+ digitalMinimumLimit: family === 'BDF' ? BDF_DIGITAL_MIN : EDF_DIGITAL_MIN,
237
+ digitalMaximumLimit: family === 'BDF' ? BDF_DIGITAL_MAX : EDF_DIGITAL_MAX,
238
+ };
239
+ }
package/src/index.ts ADDED
@@ -0,0 +1,151 @@
1
+ /**
2
+ * `edfcore` — the universal entry point.
3
+ *
4
+ * Layer 7. Re-exports only: this file contains no logic, so nothing can be true here that is not
5
+ * true in the module that owns it.
6
+ *
7
+ * Two rules hold for everything reachable from this file, transitively:
8
+ *
9
+ * - NO import of a Node built-in, transitively. `edfcore/node` is the only module allowed one, and
10
+ * a packaging test greps the built universal bundle for the scheme prefix to prove it — which is
11
+ * also why that prefix is not written out anywhere in this file. It is what lets one build serve
12
+ * Node, browsers, Deno and Bun with no environment conditions in the exports map: conditions are
13
+ * the single largest source of ecosystem incompatibility, and a dual CJS/ESM build would load
14
+ * two copies of `EdfFormatError` and break every `instanceof`.
15
+ * - NO top-level `await`, which is what makes `require(esm)` on Node >= 22.12 safe.
16
+ *
17
+ * The API is three layers, and they are meant to be visible as three. PRIMITIVES are pure,
18
+ * synchronous and take bytes; the I/O LAYER is thin, async and does no caching; the CONVENIENCE
19
+ * LAYER is composition over both. A consumer who outgrows the top layer drops to the one below it
20
+ * without leaving the package.
21
+ */
22
+
23
+ // ===========================================================================
24
+ // Types — every public data shape. `export type` throughout, for
25
+ // verbatimModuleSyntax and so nothing here emits a runtime import.
26
+ // ===========================================================================
27
+
28
+ /** The trailing `options` argument shared by every primitive that can allocate. */
29
+ export type { MaterializeOptions } from './decode/digital.js';
30
+ export type { FormatDiagnosticsOptions } from './diagnostics/format.js';
31
+ export type {
32
+ AbortSignalLike,
33
+ BlobLike,
34
+ BuildIndexOptions,
35
+ ByteSource,
36
+ CacheOptions,
37
+ DecodeAnnotationsOptions,
38
+ EdfAnnotation,
39
+ EdfAnnotationsResult,
40
+ EdfCalendarDate,
41
+ EdfChunk,
42
+ EdfChunkSignal,
43
+ EdfClockTime,
44
+ EdfDiagnostic,
45
+ EdfDiagnosticCode,
46
+ EdfGap,
47
+ EdfHeader,
48
+ EdfInspection,
49
+ EdfKnownDiagnosticCode,
50
+ EdfLocation,
51
+ EdfPatientId,
52
+ EdfRawHeaderFields,
53
+ EdfRawSignalFields,
54
+ EdfRecordIndex,
55
+ EdfRecording,
56
+ EdfRecordingId,
57
+ EdfScale,
58
+ EdfSegment,
59
+ EdfSeverity,
60
+ EdfSignal,
61
+ EdfStartTime,
62
+ EdfTimeline,
63
+ EdfVariant,
64
+ FetchLike,
65
+ HttpResponseLike,
66
+ HttpSourceOptions,
67
+ OpenOptions,
68
+ ParseOptions,
69
+ ReadOptions,
70
+ RecordRange,
71
+ RecordSelection,
72
+ WindowSelection,
73
+ } from './types.js';
74
+
75
+ // ===========================================================================
76
+ // Errors
77
+ //
78
+ // `edfErrorKind` is the supported way to discriminate: `instanceof` is false across a realm
79
+ // boundary — an iframe, a worker, two copies of the package in one tree — and `isEdfError`
80
+ // exists so that check has one spelling.
81
+ // ===========================================================================
82
+
83
+ export type { AnyEdfError, EdfErrorKind, EdfFormatErrorInit } from './errors.js';
84
+ export {
85
+ EdfAmbiguousChannelError,
86
+ EdfBudgetError,
87
+ EdfChannelNotFoundError,
88
+ EdfError,
89
+ EdfFormatError,
90
+ EdfRangeError,
91
+ EdfScalingError,
92
+ EdfSourceError,
93
+ isEdfError,
94
+ } from './errors.js';
95
+
96
+ // ===========================================================================
97
+ // Constants
98
+ // ===========================================================================
99
+
100
+ export {
101
+ BDF_ANNOTATIONS_LABEL,
102
+ BDF_DIGITAL_MAX,
103
+ BDF_DIGITAL_MIN,
104
+ EDF_ANNOTATIONS_LABEL,
105
+ EDF_DIGITAL_MAX,
106
+ EDF_DIGITAL_MIN,
107
+ EDF_HEADER_BLOCK_BYTES,
108
+ EDF_RECOMMENDED_MAX_RECORD_BYTES,
109
+ TICKS_PER_SECOND,
110
+ VERSION,
111
+ } from './constants.js';
112
+
113
+ // ===========================================================================
114
+ // Primitives — pure, synchronous, zero I/O. Every one of these is testable
115
+ // from a Uint8Array literal with no mocks.
116
+ // ===========================================================================
117
+
118
+ export { decodeHeaderLatin1 } from './bytes/latin1.js';
119
+ export { decodeDigital } from './decode/digital.js';
120
+ export { clampToDigitalRange, toPhysical } from './decode/physical.js';
121
+ export { formatDiagnostics } from './diagnostics/format.js';
122
+ export { formatStartTimeNaive } from './header/dates.js';
123
+ export { findSignals, getSignal, isAnnotationLabel } from './header/lookup.js';
124
+ export { parseHeader } from './header/parse.js';
125
+ export { decodeAnnotations } from './tal/annotations.js';
126
+ export { resolveTimeWindow, trimToWindow } from './time/window.js';
127
+
128
+ // ===========================================================================
129
+ // I/O adapters — universal, format-independent, and none of them caches.
130
+ // `cachedSource` is the only cache in edfcore: opt-in, visible at the call
131
+ // site, and removed by deleting one wrapper.
132
+ // ===========================================================================
133
+
134
+ export { blobSource } from './io/blob.js';
135
+ export { byteSource } from './io/bytes.js';
136
+ export { cachedSource } from './io/cached.js';
137
+ export { httpSource } from './io/http.js';
138
+
139
+ // ===========================================================================
140
+ // I/O layer — thin, async, no caching.
141
+ // ===========================================================================
142
+
143
+ export { readHeader, readRecordBytes } from './io/read.js';
144
+ export { buildRecordIndex, buildTimeline } from './record-index.js';
145
+
146
+ // ===========================================================================
147
+ // Convenience layer
148
+ // ===========================================================================
149
+
150
+ export { inspectEdf } from './inspect.js';
151
+ export { openEdf, readAnnotations, readRecords, readWindow } from './recording.js';
package/src/inspect.ts ADDED
@@ -0,0 +1,181 @@
1
+ /**
2
+ * Header-only triage that does not throw.
3
+ *
4
+ * Layer 6. This is the path that has to work when every other path has failed, which makes it the
5
+ * one place in edfcore where `try`/`catch` is the right tool rather than a way of losing
6
+ * information. Everywhere else a malformed file throws or records a diagnostic; here a malformed
7
+ * file becomes `ok: false` plus the diagnostic that would have been thrown, so a caller can
8
+ * triage a directory of unknown files without wrapping each call.
9
+ *
10
+ * Two boundaries keep that promise honest:
11
+ *
12
+ * - Only an `EdfError` is converted. Anything else is a bug in edfcore and is rethrown, because
13
+ * swallowing it would turn "this file is broken" into a claim we cannot support.
14
+ * - The reads happen OUTSIDE the catch, so a source-level failure — a dead socket, a file that
15
+ * vanished — still rejects. `inspectEdf` promises not to throw about CONTENT; it never promised
16
+ * to hide I/O.
17
+ *
18
+ * At most 128 KiB is read, which is the full header of a file with up to 511 signals. A header
19
+ * larger than that is reported as such instead of being half-parsed.
20
+ */
21
+
22
+ import { parseEdfInteger } from './bytes/numbers.js';
23
+ import { readAsciiField } from './bytes/view.js';
24
+ import { EDF_HEADER_BLOCK_BYTES, EDF_MAX_SIGNAL_COUNT, HEADER_FIELDS } from './constants.js';
25
+ import { createDiagnostic, DiagnosticSink } from './diagnostics/collector.js';
26
+ import { type EdfError, type EdfFormatError, isEdfError } from './errors.js';
27
+ import { parseHeader } from './header/parse.js';
28
+ import { detectVariant } from './header/variant.js';
29
+ import { assertExactRead } from './io/source.js';
30
+ import type { ByteSource, EdfDiagnostic, EdfInspection, EdfVariant, ReadOptions } from './types.js';
31
+
32
+ /** 128 KiB is exactly `256 * 512`, i.e. the whole header of a 511-signal file. */
33
+ const MAX_INSPECT_BYTES = 128 * 1024;
34
+
35
+ /**
36
+ * A prefetch hint only: how many bytes the second read should ask for.
37
+ *
38
+ * Every judgement about this field belongs to `header/parse.ts`. Returning `undefined` here just
39
+ * means we hand `parseHeader` the 256 bytes we have and let it report the real problem.
40
+ */
41
+ function signalCountHint(fixedHeader: Uint8Array): number | undefined {
42
+ if (fixedHeader.length < EDF_HEADER_BLOCK_BYTES) return undefined;
43
+ const { offset, length } = HEADER_FIELDS.signalCount;
44
+ const parse = parseEdfInteger(readAsciiField(fixedHeader, offset, length));
45
+ if (!parse.ok || parse.value < 1 || parse.value > EDF_MAX_SIGNAL_COUNT) return undefined;
46
+ return parse.value;
47
+ }
48
+
49
+ /**
50
+ * The family and dialect, when the header could not be parsed as a whole.
51
+ *
52
+ * The version block and the reserved field are the first 8 and 44 bytes of the fixed header and
53
+ * are readable long after everything else has stopped making sense, so a file whose signal count
54
+ * is garbage can still be reported as BDF rather than as nothing at all.
55
+ */
56
+ function variantHint(headerBytes: Uint8Array): EdfVariant | undefined {
57
+ if (headerBytes.length < EDF_HEADER_BLOCK_BYTES) return undefined;
58
+ try {
59
+ return detectVariant(headerBytes, new DiagnosticSink()).variant;
60
+ } catch {
61
+ return undefined;
62
+ }
63
+ }
64
+
65
+ /**
66
+ * The error, as the diagnostic it was carrying.
67
+ *
68
+ * `EdfFormatError` already holds the diagnostic it was built from, which keeps the byte offset,
69
+ * the raw bytes and the spec reference intact — reconstructing a diagnostic from the message
70
+ * would lose exactly the evidence triage needs.
71
+ */
72
+ function diagnosticOf(error: EdfError): EdfDiagnostic {
73
+ // `edfErrorKind`, not `instanceof`: the latter is false across realms, and triage is exactly
74
+ // where a worker or an iframe boundary is most likely to be in play.
75
+ if (error.edfErrorKind === 'format') {
76
+ const formatError = error as EdfFormatError;
77
+ return (
78
+ formatError.diagnostic ??
79
+ createDiagnostic({ code: formatError.code, message: formatError.message })
80
+ );
81
+ }
82
+ return createDiagnostic({
83
+ // Not a format code: the header did not fail its grammar, some other rule refused it. The
84
+ // code union is open precisely so a case like this does not have to borrow a wrong name.
85
+ code: 'INSPECTION_FAILED',
86
+ message:
87
+ `the header could not be inspected: ${error.message} Next: this is not a statement about ` +
88
+ 'the file grammar — read the message above, which names what was refused.',
89
+ });
90
+ }
91
+
92
+ function budgetDiagnostic(signalCount: number, headerByteLength: number): EdfDiagnostic {
93
+ return createDiagnostic({
94
+ code: 'HEADER_EXCEEDS_INSPECTION_BUDGET',
95
+ message:
96
+ `the header of a file declaring ${signalCount} signals is ${headerByteLength} bytes ` +
97
+ `(256 * (${signalCount} + 1)), above the ${MAX_INSPECT_BYTES}-byte ceiling inspectEdf() ` +
98
+ 'reads, so it was not parsed. Rule: inspectEdf() is triage and is bounded by design. ' +
99
+ 'Next: call readHeader(source) or openEdf(source), which read the whole header however ' +
100
+ 'large it is.',
101
+ field: 'signalCount',
102
+ byteOffset: HEADER_FIELDS.signalCount.offset,
103
+ byteLength: HEADER_FIELDS.signalCount.length,
104
+ expected: `at most ${MAX_INSPECT_BYTES} header bytes`,
105
+ actual: `${headerByteLength} header bytes`,
106
+ specReference: 'EDF specification, header record bytes 252-255 (number of signals)',
107
+ });
108
+ }
109
+
110
+ /**
111
+ * Read at most 128 KiB and say what the file is.
112
+ *
113
+ * `ok` is true only when the header parsed AND carried no error-severity diagnostic — a signal
114
+ * whose scale edfcore refuses is an error even though the header itself is readable, because
115
+ * physical units are unavailable for it. Warnings leave `ok` true: the file is impolite, and what
116
+ * it reports is still true.
117
+ */
118
+ export async function inspectEdf(
119
+ source: ByteSource,
120
+ options?: ReadOptions,
121
+ ): Promise<EdfInspection> {
122
+ const byteLength = source.byteLength;
123
+ const budget = Math.min(MAX_INSPECT_BYTES, byteLength);
124
+
125
+ const firstLength = Math.min(EDF_HEADER_BLOCK_BYTES, budget);
126
+ const fixedHeader = assertExactRead(await source.read(0, firstLength, options), 0, firstLength);
127
+ let bytesRead = firstLength;
128
+ let headerBytes = fixedHeader;
129
+ let overBudget: EdfDiagnostic | undefined;
130
+
131
+ const signalCount = signalCountHint(fixedHeader);
132
+ if (signalCount !== undefined) {
133
+ const headerByteLength = EDF_HEADER_BLOCK_BYTES * (signalCount + 1);
134
+ if (headerByteLength > MAX_INSPECT_BYTES) {
135
+ overBudget = budgetDiagnostic(signalCount, headerByteLength);
136
+ }
137
+ const wanted = Math.min(headerByteLength, budget);
138
+ const remaining = wanted - firstLength;
139
+ if (remaining > 0) {
140
+ const rest = assertExactRead(
141
+ await source.read(firstLength, remaining, options),
142
+ firstLength,
143
+ remaining,
144
+ );
145
+ bytesRead += remaining;
146
+ const combined = new Uint8Array(wanted);
147
+ combined.set(fixedHeader, 0);
148
+ combined.set(rest, firstLength);
149
+ headerBytes = combined;
150
+ }
151
+ }
152
+
153
+ try {
154
+ // Never strict: a triage call that threw on the first impolite field would be useless for
155
+ // exactly the files it exists to describe.
156
+ const header = parseHeader(headerBytes, byteLength);
157
+ const diagnostics =
158
+ overBudget === undefined ? header.diagnostics : [overBudget, ...header.diagnostics];
159
+ return {
160
+ ok: diagnostics.every((diagnostic) => diagnostic.severity !== 'error'),
161
+ variant: header.variant,
162
+ header,
163
+ byteLength,
164
+ bytesRead,
165
+ headerBytes,
166
+ diagnostics: Object.freeze(diagnostics),
167
+ };
168
+ } catch (error) {
169
+ if (!isEdfError(error)) throw error;
170
+ const failure = diagnosticOf(error);
171
+ return {
172
+ ok: false,
173
+ variant: variantHint(headerBytes),
174
+ header: undefined,
175
+ byteLength,
176
+ bytesRead,
177
+ headerBytes,
178
+ diagnostics: Object.freeze(overBudget === undefined ? [failure] : [overBudget, failure]),
179
+ };
180
+ }
181
+ }
package/src/io/blob.ts ADDED
@@ -0,0 +1,31 @@
1
+ /**
2
+ * The `Blob`/`File` adapter.
3
+ *
4
+ * Layer 5. The DOM `Blob` is never named: `BlobLike` is the structural shim from `types.ts`, so
5
+ * a real `File` from an `<input type="file">` remains assignable while `lib: ["DOM"]` stays out
6
+ * of the published `.d.ts`.
7
+ *
8
+ * A `Blob` read is the one place where the platform can legitimately hand back fewer bytes than
9
+ * asked (a `File` whose backing file changed on disk since the picker ran), so the exact-length
10
+ * contract is verified rather than assumed.
11
+ */
12
+
13
+ import type { BlobLike, ByteSource, ReadOptions } from '../types.js';
14
+ import { assertExactRead, assertReadRange, throwIfAborted } from './source.js';
15
+
16
+ export function blobSource(blob: BlobLike): ByteSource {
17
+ const byteLength = blob.size;
18
+
19
+ return {
20
+ byteLength,
21
+ async read(offset: number, length: number, options?: ReadOptions): Promise<Uint8Array> {
22
+ throwIfAborted(options);
23
+ assertReadRange(offset, length, byteLength);
24
+ if (length === 0) return new Uint8Array(0);
25
+ // `Blob.slice` takes an EXCLUSIVE end, unlike an HTTP byte range.
26
+ const buffer = await blob.slice(offset, offset + length).arrayBuffer();
27
+ throwIfAborted(options);
28
+ return assertExactRead(new Uint8Array(buffer), offset, length);
29
+ },
30
+ };
31
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * The in-memory adapter.
3
+ *
4
+ * Layer 5. Zero-copy by construction: a read hands back a `subarray` view over the caller's own
5
+ * buffer. That is safe precisely because it is the caller's buffer — the "returned array is
6
+ * owned by the caller" rule exists to stop an adapter handing out a view into state it retains,
7
+ * and this adapter retains nothing the caller does not already hold.
8
+ */
9
+
10
+ import type { ByteSource, ReadOptions } from '../types.js';
11
+ import { assertExactRead, assertReadRange, throwIfAborted } from './source.js';
12
+
13
+ export function byteSource(bytes: ArrayBuffer | Uint8Array): ByteSource {
14
+ // `ArrayBuffer.isView` rather than `instanceof Uint8Array`: `instanceof` is false for a view
15
+ // that crossed a realm boundary (a worker, an iframe), and this is a public entry point.
16
+ const view: Uint8Array = ArrayBuffer.isView(bytes) ? bytes : new Uint8Array(bytes);
17
+ const byteLength = view.byteLength;
18
+
19
+ return {
20
+ byteLength,
21
+ async read(offset: number, length: number, options?: ReadOptions): Promise<Uint8Array> {
22
+ throwIfAborted(options);
23
+ assertReadRange(offset, length, byteLength);
24
+ // `subarray` is relative to this view, so a Uint8Array handed in with a non-zero
25
+ // byteOffset over a larger buffer is respected with no offset arithmetic here.
26
+ return assertExactRead(view.subarray(offset, offset + length), offset, length);
27
+ },
28
+ };
29
+ }