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/package.json
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "edfcore",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Modern, typed, zero-dependency reader for EDF, EDF+, BDF and BDF+ biosignal files. Works in browsers and Node with true random access.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"edf",
|
|
7
|
+
"edf+",
|
|
8
|
+
"bdf",
|
|
9
|
+
"bdf+",
|
|
10
|
+
"eeg",
|
|
11
|
+
"ecg",
|
|
12
|
+
"emg",
|
|
13
|
+
"polysomnography",
|
|
14
|
+
"sleep",
|
|
15
|
+
"biosignal",
|
|
16
|
+
"european-data-format",
|
|
17
|
+
"neuroscience",
|
|
18
|
+
"neurotech",
|
|
19
|
+
"parser",
|
|
20
|
+
"typescript"
|
|
21
|
+
],
|
|
22
|
+
"license": "MIT",
|
|
23
|
+
"author": "Sarthak Tayal",
|
|
24
|
+
"repository": {
|
|
25
|
+
"type": "git",
|
|
26
|
+
"url": "git+https://github.com/tayal-sarthak/edfcore.git"
|
|
27
|
+
},
|
|
28
|
+
"bugs": {
|
|
29
|
+
"url": "https://github.com/tayal-sarthak/edfcore/issues"
|
|
30
|
+
},
|
|
31
|
+
"homepage": "https://edfcore.vercel.app",
|
|
32
|
+
"type": "module",
|
|
33
|
+
"sideEffects": false,
|
|
34
|
+
"files": [
|
|
35
|
+
"dist",
|
|
36
|
+
"src"
|
|
37
|
+
],
|
|
38
|
+
"exports": {
|
|
39
|
+
".": {
|
|
40
|
+
"types": "./dist/index.d.ts",
|
|
41
|
+
"default": "./dist/index.js"
|
|
42
|
+
},
|
|
43
|
+
"./node": {
|
|
44
|
+
"types": "./dist/node.d.ts",
|
|
45
|
+
"default": "./dist/node.js"
|
|
46
|
+
},
|
|
47
|
+
"./validate": {
|
|
48
|
+
"types": "./dist/validate.d.ts",
|
|
49
|
+
"default": "./dist/validate.js"
|
|
50
|
+
},
|
|
51
|
+
"./package.json": "./package.json"
|
|
52
|
+
},
|
|
53
|
+
"engines": {
|
|
54
|
+
"node": ">=22.12.0"
|
|
55
|
+
},
|
|
56
|
+
"devDependencies": {
|
|
57
|
+
"@biomejs/biome": "2.5.6",
|
|
58
|
+
"@types/node": "^26.1.2",
|
|
59
|
+
"fast-check": "4.9.0",
|
|
60
|
+
"typescript": "5.9.3",
|
|
61
|
+
"vitest": "4.1.10"
|
|
62
|
+
},
|
|
63
|
+
"scripts": {
|
|
64
|
+
"build": "tsc -p tsconfig.build.json",
|
|
65
|
+
"typecheck": "tsc -p tsconfig.build.json --noEmit && tsc -p tsconfig.json",
|
|
66
|
+
"test": "vitest run",
|
|
67
|
+
"test:watch": "vitest",
|
|
68
|
+
"lint": "biome check .",
|
|
69
|
+
"format": "biome format --write .",
|
|
70
|
+
"check": "npm run lint && npm run typecheck && npm run test",
|
|
71
|
+
"corpus:fetch": "node scripts/fetch-corpus.mjs",
|
|
72
|
+
"prepublishOnly": "npm run check && npm run build"
|
|
73
|
+
},
|
|
74
|
+
"publishConfig": {
|
|
75
|
+
"access": "public"
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
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
|
+
/** Padding bytes an EDF field may carry. Space is the spec's; NUL is what writers emit. */
|
|
15
|
+
const CHAR_NUL = 0x00;
|
|
16
|
+
const CHAR_SPACE = 0x20;
|
|
17
|
+
|
|
18
|
+
/** The spec's legal header alphabet. Anything outside it is Latin-1 in practice. */
|
|
19
|
+
const PRINTABLE_ASCII_MIN = 0x20;
|
|
20
|
+
const PRINTABLE_ASCII_MAX = 0x7e;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Well under every engine's argument-count limit (Safari's is the lowest, ~65k), so a
|
|
24
|
+
* maximal 2.56 MB header costs 625 calls rather than one that throws.
|
|
25
|
+
*/
|
|
26
|
+
const CHUNK_BYTES = 4096;
|
|
27
|
+
|
|
28
|
+
function fromCharCodes(bytes: Uint8Array): string {
|
|
29
|
+
// A Uint8Array is an array-like at runtime, which is all `apply` needs; the strict
|
|
30
|
+
// `Function.prototype.apply` signature only admits `number[]`, hence the cast.
|
|
31
|
+
return String.fromCharCode.apply(null, bytes as unknown as number[]);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Decode header bytes as ISO-8859-1: byte `b` becomes code point U+00`b`, always.
|
|
36
|
+
*
|
|
37
|
+
* Never `TextDecoder` — see the module comment.
|
|
38
|
+
*/
|
|
39
|
+
export function decodeHeaderLatin1(bytes: Uint8Array): string {
|
|
40
|
+
if (bytes.length <= CHUNK_BYTES) return fromCharCodes(bytes);
|
|
41
|
+
const parts: string[] = [];
|
|
42
|
+
for (let start = 0; start < bytes.length; start += CHUNK_BYTES) {
|
|
43
|
+
parts.push(fromCharCodes(bytes.subarray(start, Math.min(start + CHUNK_BYTES, bytes.length))));
|
|
44
|
+
}
|
|
45
|
+
return parts.join('');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* True when the range holds any byte outside printable ASCII 32..126 — the alphabet the EDF
|
|
50
|
+
* spec allows in a header field. Drives `NON_ASCII_HEADER_FIELD`, which is a warning: the
|
|
51
|
+
* field still decodes truthfully as Latin-1, and real equipment writes accented names and a
|
|
52
|
+
* bare 0xB5 for micro.
|
|
53
|
+
*
|
|
54
|
+
* Trailing NUL padding counts, deliberately: it is outside the alphabet too, and the caller
|
|
55
|
+
* that wants to tolerate it can check the padding itself.
|
|
56
|
+
*/
|
|
57
|
+
export function hasNonPrintableAscii(bytes: Uint8Array): boolean {
|
|
58
|
+
for (const byte of bytes) {
|
|
59
|
+
if (byte < PRINTABLE_ASCII_MIN || byte > PRINTABLE_ASCII_MAX) return true;
|
|
60
|
+
}
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function isPadding(code: number): boolean {
|
|
65
|
+
return code === CHAR_SPACE || code === CHAR_NUL;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Strip EDF field padding: spaces and NULs, from both ends.
|
|
70
|
+
*
|
|
71
|
+
* EDF pads on the RIGHT with spaces, so trailing padding is the expected case. Leading
|
|
72
|
+
* padding is stripped as well, and that choice is deliberate: a label written `' Fp1'`
|
|
73
|
+
* names the same electrode as `'Fp1 '`, and text fields are compared, not measured. No
|
|
74
|
+
* evidence is lost by it — every field is also exposed raw and untrimmed, and for numeric
|
|
75
|
+
* fields the non-conformance is reported separately by `parseEdfInteger`/`parseEdfNumber`,
|
|
76
|
+
* which read the untrimmed string.
|
|
77
|
+
*
|
|
78
|
+
* Only 0x20 and 0x00 are stripped. A trailing TAB or CR is not padding, it is content the
|
|
79
|
+
* file should not contain, and hiding it here would hide it from `NON_ASCII_HEADER_FIELD`.
|
|
80
|
+
*/
|
|
81
|
+
export function trimEdfField(text: string): string {
|
|
82
|
+
let start = 0;
|
|
83
|
+
let end = text.length;
|
|
84
|
+
while (end > start && isPadding(text.charCodeAt(end - 1))) end--;
|
|
85
|
+
while (start < end && isPadding(text.charCodeAt(start))) start++;
|
|
86
|
+
return text.slice(start, end);
|
|
87
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
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
|
+
/**
|
|
13
|
+
* The outcome of parsing one numeric field.
|
|
14
|
+
*
|
|
15
|
+
* `ok === false` means `value` is meaningless — it is NaN, so a caller that ignores this flag
|
|
16
|
+
* fails loudly instead of quietly recording a plausible 0.
|
|
17
|
+
*
|
|
18
|
+
* `problem` is single-valued and ordered by how much it matters: a field that is both
|
|
19
|
+
* right-justified and comma-separated reports `'comma-decimal'`, because that is the one the
|
|
20
|
+
* caller must refuse.
|
|
21
|
+
*/
|
|
22
|
+
export interface EdfNumberParse {
|
|
23
|
+
readonly ok: boolean;
|
|
24
|
+
readonly value: number;
|
|
25
|
+
/** The field exactly as it was read, padding included. */
|
|
26
|
+
readonly raw: string;
|
|
27
|
+
readonly problem: 'none' | 'empty' | 'comma-decimal' | 'not-left-justified' | 'malformed';
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
type EdfNumberProblem = EdfNumberParse['problem'];
|
|
31
|
+
|
|
32
|
+
const CHAR_NUL = 0x00;
|
|
33
|
+
const CHAR_SPACE = 0x20;
|
|
34
|
+
|
|
35
|
+
/** No decimal point and no exponent — see `parseEdfInteger`. */
|
|
36
|
+
const INTEGER_GRAMMAR = /^[+-]?[0-9]+$/;
|
|
37
|
+
|
|
38
|
+
/** `'.5'`, `'+22'`, `'1E3'` and `'-1.23E-4'` are all emitted by real writers. */
|
|
39
|
+
const NUMBER_GRAMMAR = /^[+-]?(?:[0-9]+(?:\.[0-9]*)?|\.[0-9]+)(?:[eE][+-]?[0-9]+)?$/;
|
|
40
|
+
|
|
41
|
+
function isPadding(code: number): boolean {
|
|
42
|
+
return code === CHAR_SPACE || code === CHAR_NUL;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function failure(raw: string, problem: EdfNumberProblem): EdfNumberParse {
|
|
46
|
+
return { ok: false, value: Number.NaN, raw, problem };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function containsNul(text: string): boolean {
|
|
50
|
+
for (let i = 0; i < text.length; i++) {
|
|
51
|
+
if (text.charCodeAt(i) === CHAR_NUL) return true;
|
|
52
|
+
}
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function parseField(raw: string, grammar: RegExp, integral: boolean): EdfNumberParse {
|
|
57
|
+
let start = 0;
|
|
58
|
+
let end = raw.length;
|
|
59
|
+
while (start < end && isPadding(raw.charCodeAt(start))) start++;
|
|
60
|
+
while (end > start && isPadding(raw.charCodeAt(end - 1))) end--;
|
|
61
|
+
const core = raw.slice(start, end);
|
|
62
|
+
|
|
63
|
+
// Distinct from malformed: an all-space field is a writer omitting a value, not corrupting
|
|
64
|
+
// one, and several fields are legally blank.
|
|
65
|
+
if (core.length === 0) return failure(raw, 'empty');
|
|
66
|
+
|
|
67
|
+
if (core.includes(',')) {
|
|
68
|
+
// '0,5' (half) and '1,024' (a thousand and twenty-four) are indistinguishable, and
|
|
69
|
+
// substituting '.' in the second silently turns 1024 into 1.024. Fatal, never guessed.
|
|
70
|
+
// Reported as comma-decimal only when the field is otherwise numeric; 'a,b' is just junk.
|
|
71
|
+
const substituted = core.replaceAll(',', '.');
|
|
72
|
+
return failure(raw, NUMBER_GRAMMAR.test(substituted) ? 'comma-decimal' : 'malformed');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Embedded whitespace inside the digits reaches here intact: '20 48' fails the grammar and
|
|
76
|
+
// is malformed, never 2048 and never 20.
|
|
77
|
+
if (!grammar.test(core)) return failure(raw, 'malformed');
|
|
78
|
+
|
|
79
|
+
const value = Number(core);
|
|
80
|
+
// Guards a digit string too long for exact float64 (integral) or one like '1e999' that
|
|
81
|
+
// overflows to Infinity. Neither can come from an 8-byte field, but this function takes a
|
|
82
|
+
// string, not a field.
|
|
83
|
+
if (integral ? !Number.isSafeInteger(value) : !Number.isFinite(value)) {
|
|
84
|
+
return failure(raw, 'malformed');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// The value is trustworthy; the layout is not. Leading padding means right-justified, and a
|
|
88
|
+
// NUL anywhere in the padding means the writer used the wrong pad byte. Both parse, and
|
|
89
|
+
// both are worth a NUMERIC_FIELD_NOT_LEFT_JUSTIFIED warning.
|
|
90
|
+
const leftJustified = start === 0 && !containsNul(raw.slice(end));
|
|
91
|
+
return { ok: true, value, raw, problem: leftJustified ? 'none' : 'not-left-justified' };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Parse a field that must be a whole number: signal count, samples per record, record count,
|
|
96
|
+
* digital minimum and maximum.
|
|
97
|
+
*
|
|
98
|
+
* Exponent forms are deliberately rejected. Every field parsed by this function sizes the
|
|
99
|
+
* file geometry, and `'1E3'` in one of them is far likelier to be corruption than a writer's
|
|
100
|
+
* idea of 1000 — accepting it would turn unreadable bytes into a confidently wrong offset.
|
|
101
|
+
* `'256.0'` is rejected for the same reason.
|
|
102
|
+
*/
|
|
103
|
+
export function parseEdfInteger(raw: string): EdfNumberParse {
|
|
104
|
+
return parseField(raw, INTEGER_GRAMMAR, true);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Parse a field that may be fractional: physical minimum and maximum, record duration.
|
|
109
|
+
*
|
|
110
|
+
* Accepts a leading sign, a bare leading or trailing point, and an exponent, because writers
|
|
111
|
+
* in the wild emit `'+22'`, `'.5'`, `'1E3'` and `'-1.23E-4'`.
|
|
112
|
+
*/
|
|
113
|
+
export function parseEdfNumber(raw: string): EdfNumberParse {
|
|
114
|
+
return parseField(raw, NUMBER_GRAMMAR, false);
|
|
115
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
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
|
+
import { decodeHeaderLatin1 } from './latin1.js';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* A plain `RangeError`, deliberately not `EdfRangeError`: reaching here means edfcore
|
|
14
|
+
* computed an offset wrongly, or a caller mixed a header with bytes from another file.
|
|
15
|
+
* `EdfRangeError` is reserved for the honest case of a caller asking for records that do not
|
|
16
|
+
* exist, and conflating the two would let an internal bug present itself as user error.
|
|
17
|
+
*/
|
|
18
|
+
function assertInBounds(bytes: Uint8Array, offset: number, length: number): void {
|
|
19
|
+
if (!Number.isSafeInteger(offset) || offset < 0) {
|
|
20
|
+
throw new RangeError(`byte offset must be a non-negative safe integer, got ${offset}`);
|
|
21
|
+
}
|
|
22
|
+
if (!Number.isSafeInteger(length) || length < 0) {
|
|
23
|
+
throw new RangeError(`byte length must be a non-negative safe integer, got ${length}`);
|
|
24
|
+
}
|
|
25
|
+
if (offset + length > bytes.length) {
|
|
26
|
+
throw new RangeError(
|
|
27
|
+
`byte range [${offset}, ${offset + length}) is outside the ${bytes.length}-byte buffer`,
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* A VIEW of `length` bytes at `offset`, sharing memory with `bytes` — no copy is made.
|
|
34
|
+
*
|
|
35
|
+
* Nothing in edfcore mutates a source buffer, so a view is safe to read for as long as the
|
|
36
|
+
* buffer lives. A caller that pools or reuses buffers must not retain the view past that
|
|
37
|
+
* reuse: use `copyBytes` when the bytes have to outlive the read that produced them.
|
|
38
|
+
*/
|
|
39
|
+
export function sliceBytes(bytes: Uint8Array, offset: number, length: number): Uint8Array {
|
|
40
|
+
assertInBounds(bytes, offset, length);
|
|
41
|
+
return bytes.subarray(offset, offset + length);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** `length` bytes at `offset`, copied into a buffer of their own. */
|
|
45
|
+
export function copyBytes(bytes: Uint8Array, offset: number, length: number): Uint8Array {
|
|
46
|
+
assertInBounds(bytes, offset, length);
|
|
47
|
+
return bytes.slice(offset, offset + length);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The RAW, untrimmed text of a fixed-width header field, decoded as ISO-8859-1.
|
|
52
|
+
*
|
|
53
|
+
* Untrimmed on purpose: `raw.*` on the header exposes what the file actually wrote, and the
|
|
54
|
+
* numeric grammars need the padding to tell a right-justified field from a conformant one.
|
|
55
|
+
* Callers that want the value trim it themselves with `trimEdfField`.
|
|
56
|
+
*/
|
|
57
|
+
export function readAsciiField(bytes: Uint8Array, offset: number, length: number): string {
|
|
58
|
+
return decodeHeaderLatin1(sliceBytes(bytes, offset, length));
|
|
59
|
+
}
|
package/src/constants.ts
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Format constants.
|
|
3
|
+
*
|
|
4
|
+
* Layer 0. Imports nothing. Every magic number the parser relies on lives here with the
|
|
5
|
+
* clause it comes from, so no other file needs a bare literal.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** The fixed header is one of these, and each signal adds exactly one more. */
|
|
9
|
+
export const EDF_HEADER_BLOCK_BYTES = 256 as const;
|
|
10
|
+
|
|
11
|
+
/** Total header size is always `EDF_HEADER_BLOCK_BYTES * (signalCount + 1)`. */
|
|
12
|
+
export const EDF_SIGNAL_HEADER_BYTES = 256 as const;
|
|
13
|
+
|
|
14
|
+
/** Trimmed, case-sensitive. On disk the field is `'EDF Annotations '` — 15 chars plus a pad. */
|
|
15
|
+
export const EDF_ANNOTATIONS_LABEL = 'EDF Annotations' as const;
|
|
16
|
+
export const BDF_ANNOTATIONS_LABEL = 'BDF Annotations' as const;
|
|
17
|
+
|
|
18
|
+
/** EDF spec recommendation, not a limit. Exceeding it is a warning, never an error. */
|
|
19
|
+
export const EDF_RECOMMENDED_MAX_RECORD_BYTES = 61440 as const;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Time is compared in exact 100 ns ticks, never in floats. Float equality on event times is
|
|
23
|
+
* how ERP alignment silently breaks.
|
|
24
|
+
*/
|
|
25
|
+
export const TICKS_PER_SECOND = 10000000n;
|
|
26
|
+
|
|
27
|
+
/** 16-bit two's complement. */
|
|
28
|
+
export const EDF_DIGITAL_MIN = -32768 as const;
|
|
29
|
+
export const EDF_DIGITAL_MAX = 32767 as const;
|
|
30
|
+
|
|
31
|
+
/** 24-bit two's complement, sign-extended from bit 23. */
|
|
32
|
+
export const BDF_DIGITAL_MIN = -8388608 as const;
|
|
33
|
+
export const BDF_DIGITAL_MAX = 8388607 as const;
|
|
34
|
+
|
|
35
|
+
/** ns lives in a 4-byte field, so it cannot exceed four digits. */
|
|
36
|
+
export const EDF_MAX_SIGNAL_COUNT = 9999 as const;
|
|
37
|
+
|
|
38
|
+
/** Default ceiling for any read that allocates. Refused before allocating, not during. */
|
|
39
|
+
export const DEFAULT_MAX_MATERIALIZE_BYTES: number = 256 * 1024 * 1024;
|
|
40
|
+
|
|
41
|
+
/** TAL structural bytes. Every byte of a multi-byte UTF-8 sequence is >= 0x80, so these can
|
|
42
|
+
* never collide with text — which is why splitting on structure before decoding is safe. */
|
|
43
|
+
export const TAL_ONSET_DURATION_SEPARATOR = 0x15 as const;
|
|
44
|
+
export const TAL_FIELD_TERMINATOR = 0x14 as const;
|
|
45
|
+
export const TAL_TERMINATOR = 0x00 as const;
|
|
46
|
+
|
|
47
|
+
/** Fixed-header field offsets and lengths, per the EDF specification. */
|
|
48
|
+
export const HEADER_FIELDS = {
|
|
49
|
+
version: { offset: 0, length: 8 },
|
|
50
|
+
patientId: { offset: 8, length: 80 },
|
|
51
|
+
recordingId: { offset: 88, length: 80 },
|
|
52
|
+
startDate: { offset: 168, length: 8 },
|
|
53
|
+
startTime: { offset: 176, length: 8 },
|
|
54
|
+
headerByteLength: { offset: 184, length: 8 },
|
|
55
|
+
reserved: { offset: 192, length: 44 },
|
|
56
|
+
recordCount: { offset: 236, length: 8 },
|
|
57
|
+
recordDuration: { offset: 244, length: 8 },
|
|
58
|
+
signalCount: { offset: 252, length: 4 },
|
|
59
|
+
} as const;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Per-signal header field widths, in the order they appear.
|
|
63
|
+
*
|
|
64
|
+
* The layout is FIELD-MAJOR, not one struct per signal: all `ns` labels, then all `ns`
|
|
65
|
+
* transducers, and so on. For signal `i`, a field's address is
|
|
66
|
+
* `256 + ns * (sum of widths before it) + i * (its own width)`.
|
|
67
|
+
*/
|
|
68
|
+
export const SIGNAL_FIELD_WIDTHS = {
|
|
69
|
+
label: 16,
|
|
70
|
+
transducerType: 80,
|
|
71
|
+
physicalDimension: 8,
|
|
72
|
+
physicalMinimum: 8,
|
|
73
|
+
physicalMaximum: 8,
|
|
74
|
+
digitalMinimum: 8,
|
|
75
|
+
digitalMaximum: 8,
|
|
76
|
+
prefiltering: 80,
|
|
77
|
+
samplesPerRecord: 8,
|
|
78
|
+
reserved: 32,
|
|
79
|
+
} as const;
|
|
80
|
+
|
|
81
|
+
/** Cumulative width preceding each per-signal field. Multiply by ns to get its block start. */
|
|
82
|
+
export const SIGNAL_FIELD_BLOCK_OFFSETS = {
|
|
83
|
+
label: 0,
|
|
84
|
+
transducerType: 16,
|
|
85
|
+
physicalDimension: 96,
|
|
86
|
+
physicalMinimum: 104,
|
|
87
|
+
physicalMaximum: 112,
|
|
88
|
+
digitalMinimum: 120,
|
|
89
|
+
digitalMaximum: 128,
|
|
90
|
+
prefiltering: 136,
|
|
91
|
+
samplesPerRecord: 216,
|
|
92
|
+
reserved: 224,
|
|
93
|
+
} as const;
|
|
94
|
+
|
|
95
|
+
/** Published package version. Kept in sync with package.json by a test. */
|
|
96
|
+
export const VERSION = '0.1.0';
|