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/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Sarthak Tayal
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
# edfcore
|
|
2
|
+
|
|
3
|
+
edfcore reads EDF, EDF+, BDF and BDF+ biosignal files in TypeScript, in the browser and in
|
|
4
|
+
Node. Real random access, no dependencies.
|
|
5
|
+
|
|
6
|
+
```bash
|
|
7
|
+
npm install edfcore
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
## How do I read an EDF file in JavaScript?
|
|
11
|
+
|
|
12
|
+
Open a source, pick a signal, read a time window, convert it to physical units. In the browser,
|
|
13
|
+
a `File` from an `<input type="file">` works directly:
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
import { openEdf, blobSource, getSignal, readWindow, toPhysical } from 'edfcore';
|
|
17
|
+
|
|
18
|
+
const recording = await openEdf(blobSource(file));
|
|
19
|
+
const fp1 = getSignal(recording.header, 'Fp1');
|
|
20
|
+
|
|
21
|
+
const [chunk] = await readWindow(recording, {
|
|
22
|
+
signalIndices: [fp1.index],
|
|
23
|
+
startSeconds: 30,
|
|
24
|
+
durationSeconds: 10,
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const microvolts = toPhysical(fp1, chunk.signals[0].digital); // Float64Array
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
In Node, swap the source and nothing else changes:
|
|
31
|
+
|
|
32
|
+
```ts
|
|
33
|
+
import { openEdf, getSignal, readWindow, toPhysical } from 'edfcore';
|
|
34
|
+
import { fileSource } from 'edfcore/node';
|
|
35
|
+
|
|
36
|
+
const recording = await openEdf(await fileSource('./overnight.edf'));
|
|
37
|
+
|
|
38
|
+
console.log(recording.header.variant); // 'EDF+C'
|
|
39
|
+
console.log(recording.header.signals.map((s) => s.label));
|
|
40
|
+
console.log(recording.timeline.spanSeconds);
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Reading those ten seconds out of a twelve-hour recording reads roughly ten seconds' worth of
|
|
44
|
+
bytes. Nothing loads the whole file.
|
|
45
|
+
|
|
46
|
+
Events come from the same recording, with exact times:
|
|
47
|
+
|
|
48
|
+
```ts
|
|
49
|
+
import { readAnnotations } from 'edfcore';
|
|
50
|
+
|
|
51
|
+
const { annotations } = await readAnnotations(recording, {
|
|
52
|
+
start: 0,
|
|
53
|
+
count: recording.header.recordCount,
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
for (const event of annotations) {
|
|
57
|
+
console.log(event.onsetSecondsFromFirstRecord, event.durationSeconds, event.text);
|
|
58
|
+
}
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
> **Status: 0.1.0, early.** edfcore runs 1,100+ tests on generated fixtures, and it's checked
|
|
62
|
+
> against public corpora it didn't author: the EDF, EDF+ and 24-bit BDF+ test files from
|
|
63
|
+
> teuniz.net, and PhysioNet's sleep-edfx (a real 22-hour polysomnography recording and its
|
|
64
|
+
> sleep-staging file). Those checks are numeric. Channels labelled `sine 8.5 Hz` decode to
|
|
65
|
+
> 8.5 Hz at their stated amplitude, the 24-bit and 16-bit paths agree on identical signals,
|
|
66
|
+
> and the rectal temperature channel of a real recording reads 37 °C. Run them with
|
|
67
|
+
> `npm run corpus:fetch && npm test`.
|
|
68
|
+
>
|
|
69
|
+
> One thing is missing: a golden-value harness comparing edfcore's float64 output against
|
|
70
|
+
> pyEDFlib and MNE element by element. Until it exists, edfcore claims no numeric parity with
|
|
71
|
+
> those readers. The API can still move. See [Roadmap](#roadmap).
|
|
72
|
+
|
|
73
|
+
---
|
|
74
|
+
|
|
75
|
+
## When to use edfcore, and when not to
|
|
76
|
+
|
|
77
|
+
**Use it when** you need to read EDF, EDF+, BDF or BDF+ in JavaScript or TypeScript. That
|
|
78
|
+
covers browser-based EEG and sleep viewers, Electron desktop tools, Node data pipelines, and
|
|
79
|
+
research upload portals that must not send recordings to a server.
|
|
80
|
+
|
|
81
|
+
**Use something else when:**
|
|
82
|
+
|
|
83
|
+
- **You are already in Python.** pyEDFlib and MNE are mature, validated against enormous
|
|
84
|
+
amounts of real data, and have years of clinical use behind them. Don't port a working Python
|
|
85
|
+
pipeline to JavaScript for the sake of it.
|
|
86
|
+
- **You want a whole viewer stack.** [`@epicurrents/edf-reader`](https://github.com/epicurrents/edf-reader)
|
|
87
|
+
is strong prior art and arrives with an EEG application framework attached.
|
|
88
|
+
- **You need analysis, not file access.** edfcore does not filter, resample, re-reference,
|
|
89
|
+
build montages, run ICA, reject artifacts, or detect events. Those are permanent non-goals.
|
|
90
|
+
- **You need to *write* EDF.** edfcore is read-only through 1.0.
|
|
91
|
+
|
|
92
|
+
---
|
|
93
|
+
|
|
94
|
+
## Why this exists
|
|
95
|
+
|
|
96
|
+
EDF is the standard container for EEG, sleep studies, ECG and EMG. Python has excellent tooling
|
|
97
|
+
for it. JavaScript didn't. This is every EDF package on npm, surveyed in mid-2026:
|
|
98
|
+
|
|
99
|
+
| | Before edfcore |
|
|
100
|
+
|---|---|
|
|
101
|
+
| TypeScript types | No standalone reader ships them |
|
|
102
|
+
| Random access | Nothing published does byte-range reads; a 24 h study is loaded whole or not at all |
|
|
103
|
+
| BDF (24-bit) | No published package can read it. BioSemi hardware has no JS support |
|
|
104
|
+
| EDF+D (gaps) | Unsupported or, worse, decoded as if contiguous, producing a silently wrong timeline |
|
|
105
|
+
| Errors | `console.warn` and `null`, or bare thrown strings; nothing typed or located |
|
|
106
|
+
| Header validation | Essentially none. No size checks, no degenerate-range checks |
|
|
107
|
+
|
|
108
|
+
So people building EEG viewers don't reach for an EDF package. They hand-roll a parser and
|
|
109
|
+
reproduce the same bugs.
|
|
110
|
+
|
|
111
|
+
The strongest existing implementation is
|
|
112
|
+
[`@epicurrents/edf-reader`](https://github.com/epicurrents/edf-reader): real TAL parsing, real
|
|
113
|
+
partial reads, BDF support. It's a plugin inside an EEG application framework, so it arrives
|
|
114
|
+
with that framework attached. Use it if you want a whole viewer stack. edfcore is a small,
|
|
115
|
+
standalone, dependency-free file-format library you can drop into any project.
|
|
116
|
+
|
|
117
|
+
---
|
|
118
|
+
|
|
119
|
+
## Design in one page
|
|
120
|
+
|
|
121
|
+
**A bad file either throws or reports itself.** One rule decides which. If edfcore can't
|
|
122
|
+
continue without inventing something, it throws. If it can continue on what the file says, it
|
|
123
|
+
records a diagnostic on the result. There's no third option, and nothing is written to the
|
|
124
|
+
console.
|
|
125
|
+
|
|
126
|
+
```ts
|
|
127
|
+
const recording = await openEdf(source);
|
|
128
|
+
for (const d of recording.header.diagnostics) {
|
|
129
|
+
console.log(d.code, d.message, d.byteOffset); // your logger, your call
|
|
130
|
+
}
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
Every diagnostic carries the field, the byte offset, the raw bytes as written, the spec clause it
|
|
134
|
+
violates, and what to do next. Pass `{ strict: true }` and the first one throws instead.
|
|
135
|
+
|
|
136
|
+
**Digital and physical are two functions.** `chunk.signals[i].digital` is an `Int32Array` of
|
|
137
|
+
the values as stored. `toPhysical(signal, digital)` returns a `Float64Array` in the signal's own
|
|
138
|
+
units. There's no `{ physical: true }` option, because it would change the return type, and no
|
|
139
|
+
`Float32` output (it costs about a quarter of a quantisation step on 24-bit BDF).
|
|
140
|
+
|
|
141
|
+
**Scaling can be refused.** A header that declares `digitalMinimum === digitalMaximum` defines
|
|
142
|
+
no scale: the gain is a division by zero. edfcore sets `signal.scale` to `undefined`, so
|
|
143
|
+
`toPhysical` throws and `strictNullChecks` catches the call you didn't guard. `decodeDigital`
|
|
144
|
+
keeps working. EDFlib substitutes a gain of 1 here and returns ADC counts labelled as microvolts.
|
|
145
|
+
|
|
146
|
+
**Sample rates stay per-signal.** An EDF file can hold EEG at 256 Hz, ECG at 512 Hz and
|
|
147
|
+
temperature at 1 Hz. There's no recording-wide rate. Sample indexing uses `samplesPerRecord`
|
|
148
|
+
rather than a floating-point rate, and `sampleRateHz` is `undefined` when the record duration is
|
|
149
|
+
zero (which is legal EDF).
|
|
150
|
+
|
|
151
|
+
**Event times are exact.** Annotation onsets are parsed digit by digit into `bigint`
|
|
152
|
+
hundred-nanosecond ticks, not through `parseFloat`. Compare `onsetTicks`, not the float.
|
|
153
|
+
|
|
154
|
+
**Gaps are structural.** `readWindow` always returns an array of chunks, one per contiguous
|
|
155
|
+
run, including for continuous files. A window that falls inside a gap returns `[]`. There's no
|
|
156
|
+
gap-filling and no option to enable it.
|
|
157
|
+
|
|
158
|
+
**No `Date`.** EDF stores local time at the patient with no timezone, and a `Date` would apply
|
|
159
|
+
the reader's zone instead. You get `EdfCalendarDate` and `EdfClockTime`, plus
|
|
160
|
+
`formatStartTimeNaive()` for a zone-free ISO-like string.
|
|
161
|
+
|
|
162
|
+
---
|
|
163
|
+
|
|
164
|
+
## Reading data
|
|
165
|
+
|
|
166
|
+
The unit of I/O is the **record range**, not the channel range. EDF interleaves every channel
|
|
167
|
+
inside each data record, so there's no cheap single-channel read: asking for one channel over a
|
|
168
|
+
window still reads the records containing it. `chunk.byteLength` reports the bytes actually
|
|
169
|
+
read.
|
|
170
|
+
|
|
171
|
+
```ts
|
|
172
|
+
import { openEdf, readWindow, resolveTimeWindow } from 'edfcore';
|
|
173
|
+
import { fileSource } from 'edfcore/node';
|
|
174
|
+
|
|
175
|
+
const recording = await openEdf(await fileSource('./overnight.edf'));
|
|
176
|
+
|
|
177
|
+
// resolveTimeWindow is pure and does no I/O, so you can audit the cost first.
|
|
178
|
+
const ranges = resolveTimeWindow(recording.timeline, recording.index, 3600, 30);
|
|
179
|
+
|
|
180
|
+
const chunks = await readWindow(recording, {
|
|
181
|
+
signalIndices: [0, 1, 2],
|
|
182
|
+
startSeconds: 3600,
|
|
183
|
+
durationSeconds: 30,
|
|
184
|
+
});
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
Chunks are record-aligned and may be slightly wider than requested. `trimToWindow()` narrows
|
|
188
|
+
them to a sample-exact window using integer arithmetic on `(record, sampleWithinRecord)` rather
|
|
189
|
+
than `round(t * rate)`.
|
|
190
|
+
|
|
191
|
+
### Sources
|
|
192
|
+
|
|
193
|
+
One interface, four adapters, all universal:
|
|
194
|
+
|
|
195
|
+
```ts
|
|
196
|
+
byteSource(arrayBufferOrUint8Array)
|
|
197
|
+
blobSource(fileOrBlob) // structural, no DOM types required
|
|
198
|
+
httpSource(url, { headers }) // HTTP Range requests
|
|
199
|
+
cachedSource(inner, { blockBytes }) // the only caching, opt-in
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
and from `edfcore/node`:
|
|
203
|
+
|
|
204
|
+
```ts
|
|
205
|
+
fileSource('./recording.edf')
|
|
206
|
+
fileHandleSource(handle, byteLength)
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
Bring your own by implementing `ByteSource`, which has three members. The contract is *exactly
|
|
210
|
+
`length` bytes or reject*, and edfcore verifies it on every call, including on sources you
|
|
211
|
+
supplied.
|
|
212
|
+
|
|
213
|
+
### Discontinuous recordings
|
|
214
|
+
|
|
215
|
+
Opening a file never scans it. For EDF+D the index starts out `'probed'`, meaning record 0 and
|
|
216
|
+
the last record only. It throws rather than map a timestamp to a record across an unmapped gap:
|
|
217
|
+
|
|
218
|
+
```ts
|
|
219
|
+
const index = await buildRecordIndex(recording, { onProgress: (done, total) => … });
|
|
220
|
+
const located = { ...recording, index };
|
|
221
|
+
|
|
222
|
+
index.coverage; // 'complete'
|
|
223
|
+
index.segments; // EdfSegment[] — only defined once coverage is complete
|
|
224
|
+
index.gaps; // EdfGap[]
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
`segments` is `undefined` while coverage is `'probed'`, so no property reads as
|
|
228
|
+
"continuous" before anything has checked.
|
|
229
|
+
|
|
230
|
+
### Triage
|
|
231
|
+
|
|
232
|
+
`inspectEdf()` reads at most 128 KiB and never throws on malformed content. It is the right first
|
|
233
|
+
call for an unfamiliar file:
|
|
234
|
+
|
|
235
|
+
```ts
|
|
236
|
+
const { ok, variant, header, diagnostics } = await inspectEdf(source);
|
|
237
|
+
console.log(formatDiagnostics(diagnostics));
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
---
|
|
241
|
+
|
|
242
|
+
## What it deliberately does not do
|
|
243
|
+
|
|
244
|
+
edfcore is a file-format library. It is not an EEG analysis framework. These are permanent non-goals: filtering, resampling,
|
|
245
|
+
re-referencing, montages, ICA, artifact rejection, bad-channel detection, spectral analysis,
|
|
246
|
+
event detection, channel-type inference from labels, unit normalisation to SI volts, and
|
|
247
|
+
anything involving AI or a network service.
|
|
248
|
+
|
|
249
|
+
Writing EDF is out of scope through 1.0. Producing a *correct* file is a much larger normative
|
|
250
|
+
commitment than tolerating an incorrect one, and a subtly non-conformant writer would undermine
|
|
251
|
+
the reason to trust the reader.
|
|
252
|
+
|
|
253
|
+
---
|
|
254
|
+
|
|
255
|
+
## Compatibility
|
|
256
|
+
|
|
257
|
+
- **Node** ≥ 22.12.0 · **Chrome/Edge** 94+ · **Firefox** 93+ · **Safari** 15.4+
|
|
258
|
+
- ESM only. `require()` works on Node ≥ 22.12 (there is no top-level `await` anywhere in the
|
|
259
|
+
module graph, which is what makes that safe).
|
|
260
|
+
- Zero runtime dependencies, permanently. `edfcore/node` imports `node:fs/promises` and nothing
|
|
261
|
+
else.
|
|
262
|
+
- Three entry points, no environment conditions in the exports map: `edfcore`, `edfcore/node`,
|
|
263
|
+
`edfcore/validate`.
|
|
264
|
+
|
|
265
|
+
---
|
|
266
|
+
|
|
267
|
+
## Roadmap
|
|
268
|
+
|
|
269
|
+
**0.1 — reads everything, lies about nothing.** All six variants, the primitive/I/O/convenience
|
|
270
|
+
layers, `inspectEdf`, `edfcore/validate`, six source adapters, the full diagnostic vocabulary.
|
|
271
|
+
|
|
272
|
+
**Before 1.0.** Validation against public corpora (sleep-edfx, CHB-MIT, the BioSemi and
|
|
273
|
+
edfplus.info test files) and a golden-value harness cross-checking physical values against
|
|
274
|
+
pyEDFlib and MNE. Until that harness exists this README makes no numerical-interop claim, and it
|
|
275
|
+
will not make one that a test did not produce.
|
|
276
|
+
|
|
277
|
+
**Later, additive.** Min/max envelope decimation as its own type. The `edffloat` logarithmic
|
|
278
|
+
inverse transform, opt-in (currently detected and rejected). BioSemi
|
|
279
|
+
Status-byte helpers. JSR publication.
|
|
280
|
+
|
|
281
|
+
---
|
|
282
|
+
|
|
283
|
+
## Documentation
|
|
284
|
+
|
|
285
|
+
The full documentation site lives in [`website/`](website/), an Astro build with twenty pages.
|
|
286
|
+
It includes a **local inspector** that opens an EDF file and shows its header, channels, events
|
|
287
|
+
and waveforms in your browser, with nothing uploaded.
|
|
288
|
+
|
|
289
|
+
```bash
|
|
290
|
+
npm install --prefix website
|
|
291
|
+
npm run dev --prefix website
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
Start with **Concepts**, which is the mental model the rest of the API follows from. Then the guides (reading signals, physical values, annotations, discontinuous recordings,
|
|
295
|
+
diagnostics, data sources, large files, validation), the API reference, and the background
|
|
296
|
+
pages. Those include a standalone primer on the EDF format itself.
|
|
297
|
+
|
|
298
|
+
### Deploying the site
|
|
299
|
+
|
|
300
|
+
[`vercel.json`](vercel.json) is set up so linking this repository to Vercel works as is.
|
|
301
|
+
**Leave the Root Directory as the repository root** and don't pick a framework preset. The
|
|
302
|
+
build compiles the library first (the site imports `edfcore` from the parent package), then
|
|
303
|
+
builds the site into `website/dist`.
|
|
304
|
+
|
|
305
|
+
Nothing needs configuring for URLs: Astro reads Vercel's own production-domain variable, so the
|
|
306
|
+
sitemap and canonical links are correct on the first deploy. If you later point a custom domain
|
|
307
|
+
at it, set `SITE_URL` to that origin in the project's environment variables.
|
|
308
|
+
|
|
309
|
+
**Design decisions** — why the API is shaped the way it is, including the choices that look
|
|
310
|
+
like bugs and are not (the pinned scaling expression, `readWindow` always returning an array,
|
|
311
|
+
`scale` being `undefined` rather than fabricated) are documented on the site under
|
|
312
|
+
*Background → Design decisions*.
|
|
313
|
+
|
|
314
|
+
- [`tests/README.md`](tests/README.md) covers how the suite builds every fixture in memory,
|
|
315
|
+
and why no binaries are committed.
|
|
316
|
+
|
|
317
|
+
## License
|
|
318
|
+
|
|
319
|
+
MIT © Sarthak Tayal
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Header text decoding.
|
|
3
|
+
*
|
|
4
|
+
* Layer 0. Imports nothing. Sole owner of turning header bytes into strings.
|
|
5
|
+
*
|
|
6
|
+
* ISO-8859-1 is the identity map onto U+0000..U+00FF, so the whole decoder is
|
|
7
|
+
* `String.fromCharCode`. `TextDecoder` is banned here and everywhere outside `src/tal/`:
|
|
8
|
+
* verified on Node v24.4.0, `TextDecoder('latin1' | 'iso-8859-1' | 'ascii' | 'windows-1252')`
|
|
9
|
+
* all report `encoding === 'windows-1252'` yet decode 0x80 as U+0080, while the WHATWG
|
|
10
|
+
* Encoding Standard mandates U+20AC for those labels. The same header bytes would therefore
|
|
11
|
+
* produce different strings in Node and in a spec-compliant browser.
|
|
12
|
+
*/
|
|
13
|
+
/**
|
|
14
|
+
* Decode header bytes as ISO-8859-1: byte `b` becomes code point U+00`b`, always.
|
|
15
|
+
*
|
|
16
|
+
* Never `TextDecoder` — see the module comment.
|
|
17
|
+
*/
|
|
18
|
+
export declare function decodeHeaderLatin1(bytes: Uint8Array): string;
|
|
19
|
+
/**
|
|
20
|
+
* True when the range holds any byte outside printable ASCII 32..126 — the alphabet the EDF
|
|
21
|
+
* spec allows in a header field. Drives `NON_ASCII_HEADER_FIELD`, which is a warning: the
|
|
22
|
+
* field still decodes truthfully as Latin-1, and real equipment writes accented names and a
|
|
23
|
+
* bare 0xB5 for micro.
|
|
24
|
+
*
|
|
25
|
+
* Trailing NUL padding counts, deliberately: it is outside the alphabet too, and the caller
|
|
26
|
+
* that wants to tolerate it can check the padding itself.
|
|
27
|
+
*/
|
|
28
|
+
export declare function hasNonPrintableAscii(bytes: Uint8Array): boolean;
|
|
29
|
+
/**
|
|
30
|
+
* Strip EDF field padding: spaces and NULs, from both ends.
|
|
31
|
+
*
|
|
32
|
+
* EDF pads on the RIGHT with spaces, so trailing padding is the expected case. Leading
|
|
33
|
+
* padding is stripped as well, and that choice is deliberate: a label written `' Fp1'`
|
|
34
|
+
* names the same electrode as `'Fp1 '`, and text fields are compared, not measured. No
|
|
35
|
+
* evidence is lost by it — every field is also exposed raw and untrimmed, and for numeric
|
|
36
|
+
* fields the non-conformance is reported separately by `parseEdfInteger`/`parseEdfNumber`,
|
|
37
|
+
* which read the untrimmed string.
|
|
38
|
+
*
|
|
39
|
+
* Only 0x20 and 0x00 are stripped. A trailing TAB or CR is not padding, it is content the
|
|
40
|
+
* file should not contain, and hiding it here would hide it from `NON_ASCII_HEADER_FIELD`.
|
|
41
|
+
*/
|
|
42
|
+
export declare function trimEdfField(text: string): string;
|
|
43
|
+
//# sourceMappingURL=latin1.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"latin1.d.ts","sourceRoot":"","sources":["../../src/bytes/latin1.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAsBH;;;;GAIG;AACH,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,UAAU,GAAG,MAAM,CAO5D;AAED;;;;;;;;GAQG;AACH,wBAAgB,oBAAoB,CAAC,KAAK,EAAE,UAAU,GAAG,OAAO,CAK/D;AAMD;;;;;;;;;;;;GAYG;AACH,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAMjD"}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Header text decoding.
|
|
3
|
+
*
|
|
4
|
+
* Layer 0. Imports nothing. Sole owner of turning header bytes into strings.
|
|
5
|
+
*
|
|
6
|
+
* ISO-8859-1 is the identity map onto U+0000..U+00FF, so the whole decoder is
|
|
7
|
+
* `String.fromCharCode`. `TextDecoder` is banned here and everywhere outside `src/tal/`:
|
|
8
|
+
* verified on Node v24.4.0, `TextDecoder('latin1' | 'iso-8859-1' | 'ascii' | 'windows-1252')`
|
|
9
|
+
* all report `encoding === 'windows-1252'` yet decode 0x80 as U+0080, while the WHATWG
|
|
10
|
+
* Encoding Standard mandates U+20AC for those labels. The same header bytes would therefore
|
|
11
|
+
* produce different strings in Node and in a spec-compliant browser.
|
|
12
|
+
*/
|
|
13
|
+
/** Padding bytes an EDF field may carry. Space is the spec's; NUL is what writers emit. */
|
|
14
|
+
const CHAR_NUL = 0x00;
|
|
15
|
+
const CHAR_SPACE = 0x20;
|
|
16
|
+
/** The spec's legal header alphabet. Anything outside it is Latin-1 in practice. */
|
|
17
|
+
const PRINTABLE_ASCII_MIN = 0x20;
|
|
18
|
+
const PRINTABLE_ASCII_MAX = 0x7e;
|
|
19
|
+
/**
|
|
20
|
+
* Well under every engine's argument-count limit (Safari's is the lowest, ~65k), so a
|
|
21
|
+
* maximal 2.56 MB header costs 625 calls rather than one that throws.
|
|
22
|
+
*/
|
|
23
|
+
const CHUNK_BYTES = 4096;
|
|
24
|
+
function fromCharCodes(bytes) {
|
|
25
|
+
// A Uint8Array is an array-like at runtime, which is all `apply` needs; the strict
|
|
26
|
+
// `Function.prototype.apply` signature only admits `number[]`, hence the cast.
|
|
27
|
+
return String.fromCharCode.apply(null, bytes);
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Decode header bytes as ISO-8859-1: byte `b` becomes code point U+00`b`, always.
|
|
31
|
+
*
|
|
32
|
+
* Never `TextDecoder` — see the module comment.
|
|
33
|
+
*/
|
|
34
|
+
export function decodeHeaderLatin1(bytes) {
|
|
35
|
+
if (bytes.length <= CHUNK_BYTES)
|
|
36
|
+
return fromCharCodes(bytes);
|
|
37
|
+
const parts = [];
|
|
38
|
+
for (let start = 0; start < bytes.length; start += CHUNK_BYTES) {
|
|
39
|
+
parts.push(fromCharCodes(bytes.subarray(start, Math.min(start + CHUNK_BYTES, bytes.length))));
|
|
40
|
+
}
|
|
41
|
+
return parts.join('');
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* True when the range holds any byte outside printable ASCII 32..126 — the alphabet the EDF
|
|
45
|
+
* spec allows in a header field. Drives `NON_ASCII_HEADER_FIELD`, which is a warning: the
|
|
46
|
+
* field still decodes truthfully as Latin-1, and real equipment writes accented names and a
|
|
47
|
+
* bare 0xB5 for micro.
|
|
48
|
+
*
|
|
49
|
+
* Trailing NUL padding counts, deliberately: it is outside the alphabet too, and the caller
|
|
50
|
+
* that wants to tolerate it can check the padding itself.
|
|
51
|
+
*/
|
|
52
|
+
export function hasNonPrintableAscii(bytes) {
|
|
53
|
+
for (const byte of bytes) {
|
|
54
|
+
if (byte < PRINTABLE_ASCII_MIN || byte > PRINTABLE_ASCII_MAX)
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
function isPadding(code) {
|
|
60
|
+
return code === CHAR_SPACE || code === CHAR_NUL;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Strip EDF field padding: spaces and NULs, from both ends.
|
|
64
|
+
*
|
|
65
|
+
* EDF pads on the RIGHT with spaces, so trailing padding is the expected case. Leading
|
|
66
|
+
* padding is stripped as well, and that choice is deliberate: a label written `' Fp1'`
|
|
67
|
+
* names the same electrode as `'Fp1 '`, and text fields are compared, not measured. No
|
|
68
|
+
* evidence is lost by it — every field is also exposed raw and untrimmed, and for numeric
|
|
69
|
+
* fields the non-conformance is reported separately by `parseEdfInteger`/`parseEdfNumber`,
|
|
70
|
+
* which read the untrimmed string.
|
|
71
|
+
*
|
|
72
|
+
* Only 0x20 and 0x00 are stripped. A trailing TAB or CR is not padding, it is content the
|
|
73
|
+
* file should not contain, and hiding it here would hide it from `NON_ASCII_HEADER_FIELD`.
|
|
74
|
+
*/
|
|
75
|
+
export function trimEdfField(text) {
|
|
76
|
+
let start = 0;
|
|
77
|
+
let end = text.length;
|
|
78
|
+
while (end > start && isPadding(text.charCodeAt(end - 1)))
|
|
79
|
+
end--;
|
|
80
|
+
while (start < end && isPadding(text.charCodeAt(start)))
|
|
81
|
+
start++;
|
|
82
|
+
return text.slice(start, end);
|
|
83
|
+
}
|
|
84
|
+
//# sourceMappingURL=latin1.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"latin1.js","sourceRoot":"","sources":["../../src/bytes/latin1.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,2FAA2F;AAC3F,MAAM,QAAQ,GAAG,IAAI,CAAC;AACtB,MAAM,UAAU,GAAG,IAAI,CAAC;AAExB,oFAAoF;AACpF,MAAM,mBAAmB,GAAG,IAAI,CAAC;AACjC,MAAM,mBAAmB,GAAG,IAAI,CAAC;AAEjC;;;GAGG;AACH,MAAM,WAAW,GAAG,IAAI,CAAC;AAEzB,SAAS,aAAa,CAAC,KAAiB;IACtC,mFAAmF;IACnF,+EAA+E;IAC/E,OAAO,MAAM,CAAC,YAAY,CAAC,KAAK,CAAC,IAAI,EAAE,KAA4B,CAAC,CAAC;AACvE,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,kBAAkB,CAAC,KAAiB;IAClD,IAAI,KAAK,CAAC,MAAM,IAAI,WAAW;QAAE,OAAO,aAAa,CAAC,KAAK,CAAC,CAAC;IAC7D,MAAM,KAAK,GAAa,EAAE,CAAC;IAC3B,KAAK,IAAI,KAAK,GAAG,CAAC,EAAE,KAAK,GAAG,KAAK,CAAC,MAAM,EAAE,KAAK,IAAI,WAAW,EAAE,CAAC;QAC/D,KAAK,CAAC,IAAI,CAAC,aAAa,CAAC,KAAK,CAAC,QAAQ,CAAC,KAAK,EAAE,IAAI,CAAC,GAAG,CAAC,KAAK,GAAG,WAAW,EAAE,KAAK,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,CAAC;IAChG,CAAC;IACD,OAAO,KAAK,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;AACxB,CAAC;AAED;;;;;;;;GAQG;AACH,MAAM,UAAU,oBAAoB,CAAC,KAAiB;IACpD,KAAK,MAAM,IAAI,IAAI,KAAK,EAAE,CAAC;QACzB,IAAI,IAAI,GAAG,mBAAmB,IAAI,IAAI,GAAG,mBAAmB;YAAE,OAAO,IAAI,CAAC;IAC5E,CAAC;IACD,OAAO,KAAK,CAAC;AACf,CAAC;AAED,SAAS,SAAS,CAAC,IAAY;IAC7B,OAAO,IAAI,KAAK,UAAU,IAAI,IAAI,KAAK,QAAQ,CAAC;AAClD,CAAC;AAED;;;;;;;;;;;;GAYG;AACH,MAAM,UAAU,YAAY,CAAC,IAAY;IACvC,IAAI,KAAK,GAAG,CAAC,CAAC;IACd,IAAI,GAAG,GAAG,IAAI,CAAC,MAAM,CAAC;IACtB,OAAO,GAAG,GAAG,KAAK,IAAI,SAAS,CAAC,IAAI,CAAC,UAAU,CAAC,GAAG,GAAG,CAAC,CAAC,CAAC;QAAE,GAAG,EAAE,CAAC;IACjE,OAAO,KAAK,GAAG,GAAG,IAAI,SAAS,CAAC,IAAI,CAAC,UAAU,CAAC,KAAK,CAAC,CAAC;QAAE,KAAK,EAAE,CAAC;IACjE,OAAO,IAAI,CAAC,KAAK,CAAC,KAAK,EAAE,GAAG,CAAC,CAAC;AAChC,CAAC"}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The two EDF numeric grammars.
|
|
3
|
+
*
|
|
4
|
+
* Layer 0. Imports nothing. Sole owner of turning an 8-byte ASCII field into a number.
|
|
5
|
+
*
|
|
6
|
+
* The spec says these fields are ASCII, left-justified and space-padded. Real files break
|
|
7
|
+
* that in every way imaginable, so parsing reports *why* it failed rather than returning NaN
|
|
8
|
+
* and leaving the caller to guess which diagnostic the bytes deserve: a comma decimal is
|
|
9
|
+
* fatal, a right-justified field is a warning, and `'20 48'` is neither of those.
|
|
10
|
+
*/
|
|
11
|
+
/**
|
|
12
|
+
* The outcome of parsing one numeric field.
|
|
13
|
+
*
|
|
14
|
+
* `ok === false` means `value` is meaningless — it is NaN, so a caller that ignores this flag
|
|
15
|
+
* fails loudly instead of quietly recording a plausible 0.
|
|
16
|
+
*
|
|
17
|
+
* `problem` is single-valued and ordered by how much it matters: a field that is both
|
|
18
|
+
* right-justified and comma-separated reports `'comma-decimal'`, because that is the one the
|
|
19
|
+
* caller must refuse.
|
|
20
|
+
*/
|
|
21
|
+
export interface EdfNumberParse {
|
|
22
|
+
readonly ok: boolean;
|
|
23
|
+
readonly value: number;
|
|
24
|
+
/** The field exactly as it was read, padding included. */
|
|
25
|
+
readonly raw: string;
|
|
26
|
+
readonly problem: 'none' | 'empty' | 'comma-decimal' | 'not-left-justified' | 'malformed';
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Parse a field that must be a whole number: signal count, samples per record, record count,
|
|
30
|
+
* digital minimum and maximum.
|
|
31
|
+
*
|
|
32
|
+
* Exponent forms are deliberately rejected. Every field parsed by this function sizes the
|
|
33
|
+
* file geometry, and `'1E3'` in one of them is far likelier to be corruption than a writer's
|
|
34
|
+
* idea of 1000 — accepting it would turn unreadable bytes into a confidently wrong offset.
|
|
35
|
+
* `'256.0'` is rejected for the same reason.
|
|
36
|
+
*/
|
|
37
|
+
export declare function parseEdfInteger(raw: string): EdfNumberParse;
|
|
38
|
+
/**
|
|
39
|
+
* Parse a field that may be fractional: physical minimum and maximum, record duration.
|
|
40
|
+
*
|
|
41
|
+
* Accepts a leading sign, a bare leading or trailing point, and an exponent, because writers
|
|
42
|
+
* in the wild emit `'+22'`, `'.5'`, `'1E3'` and `'-1.23E-4'`.
|
|
43
|
+
*/
|
|
44
|
+
export declare function parseEdfNumber(raw: string): EdfNumberParse;
|
|
45
|
+
//# sourceMappingURL=numbers.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"numbers.d.ts","sourceRoot":"","sources":["../../src/bytes/numbers.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH;;;;;;;;;GASG;AACH,MAAM,WAAW,cAAc;IAC7B,QAAQ,CAAC,EAAE,EAAE,OAAO,CAAC;IACrB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,0DAA0D;IAC1D,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,GAAG,eAAe,GAAG,oBAAoB,GAAG,WAAW,CAAC;CAC3F;AAkED;;;;;;;;GAQG;AACH,wBAAgB,eAAe,CAAC,GAAG,EAAE,MAAM,GAAG,cAAc,CAE3D;AAED;;;;;GAKG;AACH,wBAAgB,cAAc,CAAC,GAAG,EAAE,MAAM,GAAG,cAAc,CAE1D"}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The two EDF numeric grammars.
|
|
3
|
+
*
|
|
4
|
+
* Layer 0. Imports nothing. Sole owner of turning an 8-byte ASCII field into a number.
|
|
5
|
+
*
|
|
6
|
+
* The spec says these fields are ASCII, left-justified and space-padded. Real files break
|
|
7
|
+
* that in every way imaginable, so parsing reports *why* it failed rather than returning NaN
|
|
8
|
+
* and leaving the caller to guess which diagnostic the bytes deserve: a comma decimal is
|
|
9
|
+
* fatal, a right-justified field is a warning, and `'20 48'` is neither of those.
|
|
10
|
+
*/
|
|
11
|
+
const CHAR_NUL = 0x00;
|
|
12
|
+
const CHAR_SPACE = 0x20;
|
|
13
|
+
/** No decimal point and no exponent — see `parseEdfInteger`. */
|
|
14
|
+
const INTEGER_GRAMMAR = /^[+-]?[0-9]+$/;
|
|
15
|
+
/** `'.5'`, `'+22'`, `'1E3'` and `'-1.23E-4'` are all emitted by real writers. */
|
|
16
|
+
const NUMBER_GRAMMAR = /^[+-]?(?:[0-9]+(?:\.[0-9]*)?|\.[0-9]+)(?:[eE][+-]?[0-9]+)?$/;
|
|
17
|
+
function isPadding(code) {
|
|
18
|
+
return code === CHAR_SPACE || code === CHAR_NUL;
|
|
19
|
+
}
|
|
20
|
+
function failure(raw, problem) {
|
|
21
|
+
return { ok: false, value: Number.NaN, raw, problem };
|
|
22
|
+
}
|
|
23
|
+
function containsNul(text) {
|
|
24
|
+
for (let i = 0; i < text.length; i++) {
|
|
25
|
+
if (text.charCodeAt(i) === CHAR_NUL)
|
|
26
|
+
return true;
|
|
27
|
+
}
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
function parseField(raw, grammar, integral) {
|
|
31
|
+
let start = 0;
|
|
32
|
+
let end = raw.length;
|
|
33
|
+
while (start < end && isPadding(raw.charCodeAt(start)))
|
|
34
|
+
start++;
|
|
35
|
+
while (end > start && isPadding(raw.charCodeAt(end - 1)))
|
|
36
|
+
end--;
|
|
37
|
+
const core = raw.slice(start, end);
|
|
38
|
+
// Distinct from malformed: an all-space field is a writer omitting a value, not corrupting
|
|
39
|
+
// one, and several fields are legally blank.
|
|
40
|
+
if (core.length === 0)
|
|
41
|
+
return failure(raw, 'empty');
|
|
42
|
+
if (core.includes(',')) {
|
|
43
|
+
// '0,5' (half) and '1,024' (a thousand and twenty-four) are indistinguishable, and
|
|
44
|
+
// substituting '.' in the second silently turns 1024 into 1.024. Fatal, never guessed.
|
|
45
|
+
// Reported as comma-decimal only when the field is otherwise numeric; 'a,b' is just junk.
|
|
46
|
+
const substituted = core.replaceAll(',', '.');
|
|
47
|
+
return failure(raw, NUMBER_GRAMMAR.test(substituted) ? 'comma-decimal' : 'malformed');
|
|
48
|
+
}
|
|
49
|
+
// Embedded whitespace inside the digits reaches here intact: '20 48' fails the grammar and
|
|
50
|
+
// is malformed, never 2048 and never 20.
|
|
51
|
+
if (!grammar.test(core))
|
|
52
|
+
return failure(raw, 'malformed');
|
|
53
|
+
const value = Number(core);
|
|
54
|
+
// Guards a digit string too long for exact float64 (integral) or one like '1e999' that
|
|
55
|
+
// overflows to Infinity. Neither can come from an 8-byte field, but this function takes a
|
|
56
|
+
// string, not a field.
|
|
57
|
+
if (integral ? !Number.isSafeInteger(value) : !Number.isFinite(value)) {
|
|
58
|
+
return failure(raw, 'malformed');
|
|
59
|
+
}
|
|
60
|
+
// The value is trustworthy; the layout is not. Leading padding means right-justified, and a
|
|
61
|
+
// NUL anywhere in the padding means the writer used the wrong pad byte. Both parse, and
|
|
62
|
+
// both are worth a NUMERIC_FIELD_NOT_LEFT_JUSTIFIED warning.
|
|
63
|
+
const leftJustified = start === 0 && !containsNul(raw.slice(end));
|
|
64
|
+
return { ok: true, value, raw, problem: leftJustified ? 'none' : 'not-left-justified' };
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Parse a field that must be a whole number: signal count, samples per record, record count,
|
|
68
|
+
* digital minimum and maximum.
|
|
69
|
+
*
|
|
70
|
+
* Exponent forms are deliberately rejected. Every field parsed by this function sizes the
|
|
71
|
+
* file geometry, and `'1E3'` in one of them is far likelier to be corruption than a writer's
|
|
72
|
+
* idea of 1000 — accepting it would turn unreadable bytes into a confidently wrong offset.
|
|
73
|
+
* `'256.0'` is rejected for the same reason.
|
|
74
|
+
*/
|
|
75
|
+
export function parseEdfInteger(raw) {
|
|
76
|
+
return parseField(raw, INTEGER_GRAMMAR, true);
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Parse a field that may be fractional: physical minimum and maximum, record duration.
|
|
80
|
+
*
|
|
81
|
+
* Accepts a leading sign, a bare leading or trailing point, and an exponent, because writers
|
|
82
|
+
* in the wild emit `'+22'`, `'.5'`, `'1E3'` and `'-1.23E-4'`.
|
|
83
|
+
*/
|
|
84
|
+
export function parseEdfNumber(raw) {
|
|
85
|
+
return parseField(raw, NUMBER_GRAMMAR, false);
|
|
86
|
+
}
|
|
87
|
+
//# sourceMappingURL=numbers.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"numbers.js","sourceRoot":"","sources":["../../src/bytes/numbers.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bounds-checked slicing.
|
|
3
|
+
*
|
|
4
|
+
* Layer 0. Every offset here is a plain JS number — exact to 2^53 — and is never touched by
|
|
5
|
+
* `|0`, `<<`, `>>` or `>>>`. A data offset in a multi-gigabyte BDF routinely exceeds 2^31,
|
|
6
|
+
* where every bitwise operator silently wraps it to a negative number. (Bitwise ops are
|
|
7
|
+
* correct and required for SAMPLE decoding, which operates on 16- and 24-bit values.)
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* A VIEW of `length` bytes at `offset`, sharing memory with `bytes` — no copy is made.
|
|
11
|
+
*
|
|
12
|
+
* Nothing in edfcore mutates a source buffer, so a view is safe to read for as long as the
|
|
13
|
+
* buffer lives. A caller that pools or reuses buffers must not retain the view past that
|
|
14
|
+
* reuse: use `copyBytes` when the bytes have to outlive the read that produced them.
|
|
15
|
+
*/
|
|
16
|
+
export declare function sliceBytes(bytes: Uint8Array, offset: number, length: number): Uint8Array;
|
|
17
|
+
/** `length` bytes at `offset`, copied into a buffer of their own. */
|
|
18
|
+
export declare function copyBytes(bytes: Uint8Array, offset: number, length: number): Uint8Array;
|
|
19
|
+
/**
|
|
20
|
+
* The RAW, untrimmed text of a fixed-width header field, decoded as ISO-8859-1.
|
|
21
|
+
*
|
|
22
|
+
* Untrimmed on purpose: `raw.*` on the header exposes what the file actually wrote, and the
|
|
23
|
+
* numeric grammars need the padding to tell a right-justified field from a conformant one.
|
|
24
|
+
* Callers that want the value trim it themselves with `trimEdfField`.
|
|
25
|
+
*/
|
|
26
|
+
export declare function readAsciiField(bytes: Uint8Array, offset: number, length: number): string;
|
|
27
|
+
//# sourceMappingURL=view.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"view.d.ts","sourceRoot":"","sources":["../../src/bytes/view.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAwBH;;;;;;GAMG;AACH,wBAAgB,UAAU,CAAC,KAAK,EAAE,UAAU,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,UAAU,CAGxF;AAED,qEAAqE;AACrE,wBAAgB,SAAS,CAAC,KAAK,EAAE,UAAU,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,UAAU,CAGvF;AAED;;;;;;GAMG;AACH,wBAAgB,cAAc,CAAC,KAAK,EAAE,UAAU,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,MAAM,CAExF"}
|