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,540 @@
1
+ /**
2
+ * The byte-level TAL state machine.
3
+ *
4
+ * Layer 3. Sole owner of turning ONE annotation region into time-stamped annotation lists.
5
+ * `annotations.ts` adds provenance and EDF+ semantics on top; nothing here knows about records,
6
+ * signals, or timekeeping.
7
+ *
8
+ * Two rules define the module, and both are where other EDF readers go wrong:
9
+ *
10
+ * 1. Parsing is HARD-BOUNDED to `[regionStart, regionStart + regionBytes)`. A TAL with no
11
+ * terminating 0x00 inside the region is discarded, never continued past the bound — the bytes
12
+ * after it are the next signal's samples, and parsing those is how a reader invents
13
+ * annotations that are not in the file.
14
+ * 2. The region is split on the STRUCTURAL bytes (0x00, 0x14, 0x15) FIRST and each text run is
15
+ * decoded as UTF-8 LAST. That order is safe in exactly one direction: every byte of a
16
+ * multi-byte UTF-8 sequence is >= 0x80 and can never collide with a structural byte, while a
17
+ * string that has already been decoded can no longer be split on bytes at all.
18
+ *
19
+ * `src/tal/` is the only part of edfcore allowed to use `TextDecoder`, and only for annotation
20
+ * text. Header bytes go through `decodeHeaderLatin1`, for the cross-runtime reason documented in
21
+ * `src/bytes/latin1.ts`.
22
+ *
23
+ * Each defect is reported at most once per region, with an occurrence count: a corrupt region
24
+ * can hold thousands of malformed TALs, and one diagnostic per TAL would be an unbounded
25
+ * allocation dressed up as diligence. Nothing is hidden — `occurrences` says how many there were.
26
+ */
27
+
28
+ import { decodeHeaderLatin1 } from '../bytes/latin1.js';
29
+ import { sliceBytes } from '../bytes/view.js';
30
+ import {
31
+ TAL_FIELD_TERMINATOR,
32
+ TAL_ONSET_DURATION_SEPARATOR,
33
+ TAL_TERMINATOR,
34
+ } from '../constants.js';
35
+ import { parseSignedTicks, parseUnsignedTicks } from './ticks.js';
36
+
37
+ export type TalTextEncoding = 'utf-8' | 'latin-1-fallback';
38
+
39
+ /** One text run of a TAL: the bytes between two 0x14 separators, decoded. */
40
+ export interface TalText {
41
+ /** Verbatim. Never trimmed, never case-folded, and a BOM is kept as a character. */
42
+ readonly text: string;
43
+ readonly encoding: TalTextEncoding;
44
+ readonly byteOffsetInRegion: number;
45
+ readonly byteLength: number;
46
+ }
47
+
48
+ /** The subset of the diagnostic vocabulary this module can observe. */
49
+ export type TalIssueCode =
50
+ | 'TAL_MALFORMED'
51
+ | 'TAL_TRUNCATED_AT_REGION_END'
52
+ | 'TAL_REGION_NOT_NUL_TERMINATED'
53
+ | 'ANNOTATION_TEXT_NOT_UTF8';
54
+
55
+ /**
56
+ * One defect, described once per region.
57
+ *
58
+ * `detail` states what was wrong AND what was done about it, because the disposition differs
59
+ * per defect: a missing onset sign keeps the annotation, a missing duration discards it.
60
+ */
61
+ export interface TalIssue {
62
+ readonly code: TalIssueCode;
63
+ /** Of the first occurrence. Relative to the region start. */
64
+ readonly byteOffsetInRegion: number;
65
+ readonly byteLength: number;
66
+ /** Occurrences in this region. Only the first is described. */
67
+ readonly occurrences: number;
68
+ readonly detail: string;
69
+ /** The first occurrence's bytes, escaped and truncated, for the diagnostic message. */
70
+ readonly raw: string;
71
+ }
72
+
73
+ /** One TAL that survived parsing. Times are exact ticks; the digits they came from are kept. */
74
+ export interface ParsedTal {
75
+ /**
76
+ * Position of this TAL in the region, counting the ones that were skipped. Timekeeping is a
77
+ * property of TAL slot 0, so a record whose first TAL was malformed must not have its second
78
+ * TAL promoted into the timekeeping role.
79
+ */
80
+ readonly ordinal: number;
81
+ readonly byteOffsetInRegion: number;
82
+ /** Includes the terminating 0x00. */
83
+ readonly byteLength: number;
84
+ readonly onsetRaw: string;
85
+ readonly onsetTicks: bigint;
86
+ readonly durationRaw: string | undefined;
87
+ readonly durationTicks: bigint | undefined;
88
+ readonly texts: readonly TalText[];
89
+ }
90
+
91
+ export interface TalRegionParse {
92
+ readonly tals: readonly ParsedTal[];
93
+ readonly issues: readonly TalIssue[];
94
+ }
95
+
96
+ /** The two halves of the EDF+ `description@@channel` convention. */
97
+ export interface TalTextParts {
98
+ /** The description exactly as written, minus a trailing `@@channel` suffix. */
99
+ readonly text: string;
100
+ readonly channelLabel: string | undefined;
101
+ }
102
+
103
+ /** Caps the bytes copied into a diagnostic. The full region is one hexdump from `byteOffset`. */
104
+ export const TAL_PREVIEW_MAX_BYTES: number = 48;
105
+
106
+ /**
107
+ * `recordOnsetTicks` is a `BigInt64Array`, and assigning an out-of-range bigint to one WRAPS
108
+ * silently instead of throwing. A value that cannot round-trip through int64 is therefore
109
+ * refused here rather than stored wrong. The bound is ~29,000 years, so nothing real is lost.
110
+ */
111
+ const INT64_MIN: bigint = -(2n ** 63n);
112
+ const INT64_MAX: bigint = 2n ** 63n - 1n;
113
+
114
+ /**
115
+ * An int64 tick count needs at most 19 digits, plus a sign, a point and 7 fractional digits.
116
+ * The cap exists so a corrupt region of digits cannot drive quadratic bigint work: the digits
117
+ * are counted before any bigint is built.
118
+ */
119
+ const MAX_TIMESTAMP_FIELD_CHARS = 40;
120
+
121
+ const CHANNEL_LABEL_SEPARATOR = '@@';
122
+
123
+ const ASCII_HIGH_BIT = 0x80;
124
+ const ASCII_DEL = 0x7f;
125
+ const ASCII_FIRST_PRINTABLE = 0x20;
126
+
127
+ interface MutableTalIssue {
128
+ code: TalIssueCode;
129
+ byteOffsetInRegion: number;
130
+ byteLength: number;
131
+ occurrences: number;
132
+ detail: string;
133
+ raw: string;
134
+ }
135
+
136
+ type IssueLog = Map<TalIssueCode, MutableTalIssue>;
137
+
138
+ interface TalScan {
139
+ readonly tal: ParsedTal | undefined;
140
+ /** Where the next TAL slot begins. Always greater than the slot that produced it. */
141
+ readonly next: number;
142
+ }
143
+
144
+ interface TextDecoderLike {
145
+ decode(input: Uint8Array): string;
146
+ }
147
+
148
+ type TextDecoderConstructorLike = new (
149
+ label: string,
150
+ options: { fatal: boolean; ignoreBOM: boolean },
151
+ ) => TextDecoderLike;
152
+
153
+ /** `undefined` = not looked up yet, `null` = this runtime has no `TextDecoder`. */
154
+ let cachedUtf8Decoder: TextDecoderLike | null | undefined;
155
+
156
+ /**
157
+ * A strict UTF-8 decoder, or `null`.
158
+ *
159
+ * `fatal` is what makes invalid UTF-8 detectable at all; without it the decoder substitutes
160
+ * U+FFFD and the file's bytes are gone. `ignoreBOM` keeps a leading U+FEFF as a character,
161
+ * because annotation text is exposed verbatim and a silently stripped BOM is a silently changed
162
+ * string. Every runtime edfcore supports has `TextDecoder`; the `null` branch exists so an
163
+ * exotic one degrades to Latin-1 with a diagnostic instead of throwing.
164
+ */
165
+ function utf8Decoder(): TextDecoderLike | null {
166
+ if (cachedUtf8Decoder === undefined) {
167
+ const Decoder = (globalThis as { TextDecoder?: TextDecoderConstructorLike }).TextDecoder;
168
+ cachedUtf8Decoder =
169
+ Decoder === undefined ? null : new Decoder('utf-8', { fatal: true, ignoreBOM: true });
170
+ }
171
+ return cachedUtf8Decoder;
172
+ }
173
+
174
+ /**
175
+ * `noUncheckedIndexedAccess` types `region[i]` as `number | undefined`. -1 is not a byte value,
176
+ * so every structural comparison below is simply false past the end of the region.
177
+ */
178
+ function readByte(region: Uint8Array, index: number): number {
179
+ return region[index] ?? -1;
180
+ }
181
+
182
+ function indexOfByte(region: Uint8Array, start: number, end: number, byte: number): number {
183
+ for (let i = start; i < end; i += 1) {
184
+ if (readByte(region, i) === byte) return i;
185
+ }
186
+ return -1;
187
+ }
188
+
189
+ function escapeControls(text: string): string {
190
+ let out = '';
191
+ for (let i = 0; i < text.length; i += 1) {
192
+ const code = text.charCodeAt(i);
193
+ out +=
194
+ code < ASCII_FIRST_PRINTABLE || code === ASCII_DEL
195
+ ? `\\x${code.toString(16).padStart(2, '0')}`
196
+ : text.charAt(i);
197
+ }
198
+ return out;
199
+ }
200
+
201
+ /**
202
+ * Bytes as a short, escaped, single-line string for a diagnostic message.
203
+ *
204
+ * Latin-1 and not UTF-8 on purpose: this is evidence about bytes, so every byte must map to
205
+ * exactly one visible character even when the run is the invalid UTF-8 being complained about.
206
+ */
207
+ export function previewBytes(bytes: Uint8Array, offset: number, length: number): string {
208
+ const shown = Math.min(length, TAL_PREVIEW_MAX_BYTES);
209
+ const text = escapeControls(decodeHeaderLatin1(sliceBytes(bytes, offset, shown)));
210
+ return length > shown ? `${text}...` : text;
211
+ }
212
+
213
+ function logIssue(
214
+ log: IssueLog,
215
+ code: TalIssueCode,
216
+ region: Uint8Array,
217
+ offset: number,
218
+ length: number,
219
+ detail: string,
220
+ ): void {
221
+ const existing = log.get(code);
222
+ if (existing !== undefined) {
223
+ existing.occurrences += 1;
224
+ return;
225
+ }
226
+ log.set(code, {
227
+ code,
228
+ byteOffsetInRegion: offset,
229
+ byteLength: length,
230
+ occurrences: 1,
231
+ detail,
232
+ raw: previewBytes(region, offset, length),
233
+ });
234
+ }
235
+
236
+ function isAsciiRun(bytes: Uint8Array): boolean {
237
+ for (const byte of bytes) {
238
+ if (byte >= ASCII_HIGH_BIT) return false;
239
+ }
240
+ return true;
241
+ }
242
+
243
+ function decodeTextRun(region: Uint8Array, start: number, length: number, log: IssueLog): TalText {
244
+ const bytes = sliceBytes(region, start, length);
245
+
246
+ // ASCII is UTF-8, byte for byte, and it is what almost every file contains. Taking it here
247
+ // means the decoder is only ever built for text that actually needs one.
248
+ if (isAsciiRun(bytes)) {
249
+ return {
250
+ text: decodeHeaderLatin1(bytes),
251
+ encoding: 'utf-8',
252
+ byteOffsetInRegion: start,
253
+ byteLength: length,
254
+ };
255
+ }
256
+
257
+ const decoder = utf8Decoder();
258
+ if (decoder !== null) {
259
+ try {
260
+ return {
261
+ text: decoder.decode(bytes),
262
+ encoding: 'utf-8',
263
+ byteOffsetInRegion: start,
264
+ byteLength: length,
265
+ };
266
+ } catch {
267
+ // A fatal decoder rejects invalid UTF-8 with a TypeError. Fall through to Latin-1.
268
+ }
269
+ }
270
+
271
+ logIssue(
272
+ log,
273
+ 'ANNOTATION_TEXT_NOT_UTF8',
274
+ region,
275
+ start,
276
+ length,
277
+ decoder === null
278
+ ? 'this runtime has no TextDecoder, so the text run could not be verified as UTF-8 and ' +
279
+ 'was decoded as ISO-8859-1'
280
+ : 'the text run is not valid UTF-8 and was decoded as ISO-8859-1 instead',
281
+ );
282
+ return {
283
+ text: decodeHeaderLatin1(bytes),
284
+ encoding: 'latin-1-fallback',
285
+ byteOffsetInRegion: start,
286
+ byteLength: length,
287
+ };
288
+ }
289
+
290
+ function outsideInt64(ticks: bigint): boolean {
291
+ return ticks < INT64_MIN || ticks > INT64_MAX;
292
+ }
293
+
294
+ /**
295
+ * One TAL slot at `start`, which is known to hold a non-0x00 byte.
296
+ *
297
+ * Returns `tal: undefined` for a slot that was skipped; `next` still advances past it, so the
298
+ * rest of the region is parsed either way. That is the whole point of `TAL_MALFORMED` being a
299
+ * warning: one bad TAL must not cost the file.
300
+ */
301
+ function scanTal(region: Uint8Array, start: number, ordinal: number, log: IssueLog): TalScan {
302
+ const regionLength = region.length;
303
+
304
+ const bodyEnd = indexOfByte(region, start, regionLength, TAL_TERMINATOR);
305
+ if (bodyEnd < 0) {
306
+ logIssue(
307
+ log,
308
+ 'TAL_TRUNCATED_AT_REGION_END',
309
+ region,
310
+ start,
311
+ regionLength - start,
312
+ 'a TAL begins here and has no terminating 0x00 inside the region, so it was discarded ' +
313
+ "rather than continued into the following signal's samples",
314
+ );
315
+ return { tal: undefined, next: regionLength };
316
+ }
317
+ const next = bodyEnd + 1;
318
+
319
+ const timestampEnd = indexOfByte(region, start, bodyEnd, TAL_FIELD_TERMINATOR);
320
+ if (timestampEnd < 0) {
321
+ logIssue(
322
+ log,
323
+ 'TAL_MALFORMED',
324
+ region,
325
+ start,
326
+ bodyEnd - start,
327
+ 'the timestamp is not terminated by 0x14, so the TAL was skipped',
328
+ );
329
+ return { tal: undefined, next };
330
+ }
331
+
332
+ const separator = indexOfByte(region, start, timestampEnd, TAL_ONSET_DURATION_SEPARATOR);
333
+ const onsetEnd = separator < 0 ? timestampEnd : separator;
334
+
335
+ if (onsetEnd - start > MAX_TIMESTAMP_FIELD_CHARS) {
336
+ logIssue(
337
+ log,
338
+ 'TAL_MALFORMED',
339
+ region,
340
+ start,
341
+ onsetEnd - start,
342
+ `the onset field is ${onsetEnd - start} bytes long, which no time in 100 ns ticks needs; ` +
343
+ 'the TAL was skipped',
344
+ );
345
+ return { tal: undefined, next };
346
+ }
347
+
348
+ const onsetRaw = decodeHeaderLatin1(sliceBytes(region, start, onsetEnd - start));
349
+ const signedOnset = parseSignedTicks(onsetRaw);
350
+ let onsetTicks = signedOnset.ticks;
351
+ if (!signedOnset.ok) {
352
+ // `parseSignedTicks` fails for a missing sign and for a bad magnitude alike. Only the first
353
+ // is recoverable, and re-parsing without the sign is how they are told apart.
354
+ const unsignedOnset = parseUnsignedTicks(onsetRaw);
355
+ if (!unsignedOnset.ok) {
356
+ logIssue(
357
+ log,
358
+ 'TAL_MALFORMED',
359
+ region,
360
+ start,
361
+ Math.max(onsetEnd - start, 1),
362
+ `the onset "${onsetRaw}" is not ("+" / "-") 1*DIGIT [ "." 1*DIGIT ], so the TAL was ` +
363
+ 'skipped',
364
+ );
365
+ return { tal: undefined, next };
366
+ }
367
+ onsetTicks = unsignedOnset.ticks;
368
+ logIssue(
369
+ log,
370
+ 'TAL_MALFORMED',
371
+ region,
372
+ start,
373
+ onsetEnd - start,
374
+ `the onset "${onsetRaw}" has no mandatory sign; the value is unambiguous, so the TAL was ` +
375
+ 'kept and the onset read as positive',
376
+ );
377
+ }
378
+ if (outsideInt64(onsetTicks)) {
379
+ logIssue(
380
+ log,
381
+ 'TAL_MALFORMED',
382
+ region,
383
+ start,
384
+ onsetEnd - start,
385
+ `the onset "${onsetRaw}" is outside the +/-2^63 tick range edfcore can represent, so the ` +
386
+ 'TAL was skipped',
387
+ );
388
+ return { tal: undefined, next };
389
+ }
390
+
391
+ let durationRaw: string | undefined;
392
+ let durationTicks: bigint | undefined;
393
+ if (separator >= 0) {
394
+ const durationStart = separator + 1;
395
+ const durationLength = timestampEnd - durationStart;
396
+ if (durationLength > MAX_TIMESTAMP_FIELD_CHARS) {
397
+ logIssue(
398
+ log,
399
+ 'TAL_MALFORMED',
400
+ region,
401
+ durationStart,
402
+ durationLength,
403
+ `the duration field is ${durationLength} bytes long, which no time in 100 ns ticks ` +
404
+ 'needs; the TAL was skipped',
405
+ );
406
+ return { tal: undefined, next };
407
+ }
408
+ durationRaw = decodeHeaderLatin1(sliceBytes(region, durationStart, durationLength));
409
+ const duration = parseUnsignedTicks(durationRaw);
410
+ if (!duration.ok || outsideInt64(duration.ticks)) {
411
+ logIssue(
412
+ log,
413
+ 'TAL_MALFORMED',
414
+ region,
415
+ durationStart,
416
+ Math.max(durationLength, 1),
417
+ `0x15 is present but the duration "${durationRaw}" is not 1*DIGIT [ "." 1*DIGIT ] — a ` +
418
+ 'duration is never signed — so the TAL was skipped',
419
+ );
420
+ return { tal: undefined, next };
421
+ }
422
+ durationTicks = duration.ticks;
423
+ }
424
+
425
+ const texts: TalText[] = [];
426
+ let runStart = timestampEnd + 1;
427
+ for (let i = runStart; i < bodyEnd; i += 1) {
428
+ if (readByte(region, i) !== TAL_FIELD_TERMINATOR) continue;
429
+ texts.push(readTextRun(region, runStart, i, log));
430
+ runStart = i + 1;
431
+ }
432
+ if (runStart < bodyEnd) {
433
+ // The grammar terminates every text with 0x14, so a run left over here means the last text
434
+ // ran straight into the TAL terminator. The bytes are unambiguous, so the text is kept.
435
+ logIssue(
436
+ log,
437
+ 'TAL_MALFORMED',
438
+ region,
439
+ runStart,
440
+ bodyEnd - runStart,
441
+ 'the last text of the TAL is not terminated by 0x14; the text was kept verbatim',
442
+ );
443
+ texts.push(readTextRun(region, runStart, bodyEnd, log));
444
+ }
445
+
446
+ return {
447
+ tal: {
448
+ ordinal,
449
+ byteOffsetInRegion: start,
450
+ byteLength: next - start,
451
+ onsetRaw,
452
+ onsetTicks,
453
+ durationRaw,
454
+ durationTicks,
455
+ texts,
456
+ },
457
+ next,
458
+ };
459
+ }
460
+
461
+ function readTextRun(region: Uint8Array, start: number, end: number, log: IssueLog): TalText {
462
+ if (indexOfByte(region, start, end, TAL_ONSET_DURATION_SEPARATOR) >= 0) {
463
+ logIssue(
464
+ log,
465
+ 'TAL_MALFORMED',
466
+ region,
467
+ start,
468
+ end - start,
469
+ 'a text run contains 0x15, which the grammar reserves for the onset/duration separator; ' +
470
+ 'the text was kept verbatim',
471
+ );
472
+ }
473
+ return decodeTextRun(region, start, end - start, log);
474
+ }
475
+
476
+ /**
477
+ * Every TAL in `[regionStart, regionStart + regionBytes)`, and nothing outside it.
478
+ *
479
+ * `region = *TAL *%x00`, so a 0x00 where a TAL would start means the padding has begun. Content
480
+ * after that point is `TAL_REGION_NOT_NUL_TERMINATED`; parsing then RESUMES at those bytes
481
+ * rather than stopping, because they are still inside this signal's own region and a writer
482
+ * that pads between TALs would otherwise lose every annotation after the first pad. Recovery is
483
+ * bounded: each attempt consumes at least one byte, and a slot that does not parse is skipped
484
+ * to just past its 0x00.
485
+ */
486
+ export function parseTalRegion(
487
+ bytes: Uint8Array,
488
+ regionStart: number,
489
+ regionBytes: number,
490
+ ): TalRegionParse {
491
+ const region = sliceBytes(bytes, regionStart, regionBytes);
492
+ const log: IssueLog = new Map();
493
+ const tals: ParsedTal[] = [];
494
+
495
+ let position = 0;
496
+ let ordinal = 0;
497
+ while (position < regionBytes) {
498
+ if (readByte(region, position) === TAL_TERMINATOR) {
499
+ let scan = position;
500
+ while (scan < regionBytes && readByte(region, scan) === TAL_TERMINATOR) scan += 1;
501
+ if (scan === regionBytes) break;
502
+ logIssue(
503
+ log,
504
+ 'TAL_REGION_NOT_NUL_TERMINATED',
505
+ region,
506
+ scan,
507
+ regionBytes - scan,
508
+ 'the region tail after the last TAL is not all 0x00; parsing resumed at this byte',
509
+ );
510
+ // A TAL found after padding did not begin the region, so it must never be promoted into
511
+ // the timekeeping role: slot 0 is a position, not "the first TAL we managed to parse".
512
+ if (ordinal === 0) ordinal = 1;
513
+ position = scan;
514
+ continue;
515
+ }
516
+
517
+ const scan = scanTal(region, position, ordinal, log);
518
+ if (scan.tal !== undefined) tals.push(scan.tal);
519
+ ordinal += 1;
520
+ position = scan.next;
521
+ }
522
+
523
+ return { tals, issues: [...log.values()] };
524
+ }
525
+
526
+ /**
527
+ * `description@@channel` split at the LAST `@@`, because the channel label is the suffix and a
528
+ * description is free to contain anything.
529
+ *
530
+ * A trailing `@@` with nothing after it is not a channel label: the text keeps it verbatim.
531
+ * `@@Fp1` with nothing before it yields an empty description and the channel — the run itself
532
+ * is not empty, so it is still a real annotation.
533
+ */
534
+ export function splitChannelLabel(run: string): TalTextParts {
535
+ const at = run.lastIndexOf(CHANNEL_LABEL_SEPARATOR);
536
+ if (at < 0 || at + CHANNEL_LABEL_SEPARATOR.length >= run.length) {
537
+ return { text: run, channelLabel: undefined };
538
+ }
539
+ return { text: run.slice(0, at), channelLabel: run.slice(at + CHANNEL_LABEL_SEPARATOR.length) };
540
+ }
@@ -0,0 +1,172 @@
1
+ /**
2
+ * Decimal text to exact 100 ns ticks.
3
+ *
4
+ * Layer 3. Sole owner of the string -> tick conversion, which is the whole reason event times
5
+ * in edfcore compare exactly. `parseFloat`, `Number(text)` and float arithmetic appear nowhere
6
+ * on that path: an onset written `+0.1` and one written `+0.3` are integers here, so equality,
7
+ * ordering and subtraction are decided by the digits on disk rather than by float64 rounding.
8
+ */
9
+
10
+ import { TICKS_PER_SECOND } from '../constants.js';
11
+
12
+ /**
13
+ * One parsed onset or duration field.
14
+ *
15
+ * `ticks` is the authoritative value; `seconds` and any float derived from it are conveniences.
16
+ */
17
+ export interface TickParse {
18
+ /**
19
+ * The text matched the EDF+ grammar for its field. When false, `ticks` is 0n except in the
20
+ * one documented case in `parseSignedTicks` (a valid magnitude with the sign missing).
21
+ */
22
+ readonly ok: boolean;
23
+ readonly ticks: bigint;
24
+ /** The input verbatim, so a caller never has to reconstruct the digits it came from. */
25
+ readonly raw: string;
26
+ /** Lossy by construction. See `ticksToSeconds`. */
27
+ readonly seconds: number;
28
+ }
29
+
30
+ const ASCII_ZERO = 0x30;
31
+ const ASCII_NINE = 0x39;
32
+ const ASCII_PLUS = 0x2b;
33
+ const ASCII_MINUS = 0x2d;
34
+ const ASCII_DOT = 0x2e;
35
+
36
+ /** `TICKS_PER_SECOND` is 10^7, so exactly seven fractional decimal digits are representable. */
37
+ const TICK_FRACTION_DIGITS = 7;
38
+
39
+ /** Exact: 10^7 is far below 2^53, so this conversion loses nothing. */
40
+ const TICKS_PER_SECOND_FLOAT = Number(TICKS_PER_SECOND);
41
+
42
+ interface MagnitudeParse {
43
+ readonly ok: boolean;
44
+ /** Non-negative. The sign is applied by the caller, to the whole value. */
45
+ readonly ticks: bigint;
46
+ }
47
+
48
+ const MAGNITUDE_FAILED: MagnitudeParse = { ok: false, ticks: 0n };
49
+
50
+ function isAsciiDigit(code: number): boolean {
51
+ return code >= ASCII_ZERO && code <= ASCII_NINE;
52
+ }
53
+
54
+ /**
55
+ * `1*DIGIT [ "." 1*DIGIT ]` starting at `start`, accumulated digit by digit into a bigint.
56
+ *
57
+ * Fractional digits beyond the seventh are below tick resolution and are TRUNCATED, never
58
+ * rounded: rounding would move an event to a time that is in no file, and for a stimulus marker
59
+ * that is the difference between a pre- and a post-stimulus sample. The extra digits are still
60
+ * required to be digits, so `+1.00000000x` is rejected rather than silently truncated.
61
+ */
62
+ function parseMagnitudeTicks(text: string, start: number): MagnitudeParse {
63
+ const end = text.length;
64
+ let i = start;
65
+ let whole = 0n;
66
+
67
+ while (i < end) {
68
+ const code = text.charCodeAt(i);
69
+ if (!isAsciiDigit(code)) break;
70
+ whole = whole * 10n + BigInt(code - ASCII_ZERO);
71
+ i += 1;
72
+ }
73
+ if (i === start) return MAGNITUDE_FAILED;
74
+
75
+ let ticks = whole * TICKS_PER_SECOND;
76
+ if (i === end) return { ok: true, ticks };
77
+
78
+ // Anything other than a decimal point here is a space, a second sign, a stray structural
79
+ // byte or trailing padding, and none of those are part of the grammar.
80
+ if (text.charCodeAt(i) !== ASCII_DOT) return MAGNITUDE_FAILED;
81
+ i += 1;
82
+
83
+ let fractionDigits = 0;
84
+ let scale = TICKS_PER_SECOND;
85
+ while (i < end) {
86
+ const code = text.charCodeAt(i);
87
+ if (!isAsciiDigit(code)) return MAGNITUDE_FAILED;
88
+ fractionDigits += 1;
89
+ if (fractionDigits <= TICK_FRACTION_DIGITS) {
90
+ scale = scale / 10n;
91
+ ticks += BigInt(code - ASCII_ZERO) * scale;
92
+ }
93
+ i += 1;
94
+ }
95
+ if (fractionDigits === 0) return MAGNITUDE_FAILED;
96
+
97
+ return { ok: true, ticks };
98
+ }
99
+
100
+ /**
101
+ * An EDF+ onset: `("+" / "-") 1*DIGIT [ "." 1*DIGIT ]`.
102
+ *
103
+ * The sign is mandatory, so an unsigned onset is a spec violation and `ok` is false — the caller
104
+ * emits `TAL_MALFORMED`. The magnitude is parsed anyway and returned in `ticks`, so a lenient
105
+ * caller can still use the value instead of discarding an otherwise readable annotation. That is
106
+ * the only case where `ok: false` comes with a meaningful `ticks`.
107
+ *
108
+ * `-` applies to the WHOLE value, fraction included: `-0.5` is -5000000 ticks, not -0 plus
109
+ * 5000000. Splitting the sign off the fraction flips pre-stimulus event times about zero.
110
+ */
111
+ export function parseSignedTicks(text: string): TickParse {
112
+ const first = text.length > 0 ? text.charCodeAt(0) : -1;
113
+ const negative = first === ASCII_MINUS;
114
+ const signed = negative || first === ASCII_PLUS;
115
+ const magnitude = parseMagnitudeTicks(text, signed ? 1 : 0);
116
+ const ticks = negative ? -magnitude.ticks : magnitude.ticks;
117
+ return { ok: magnitude.ok && signed, ticks, raw: text, seconds: ticksToSeconds(ticks) };
118
+ }
119
+
120
+ /**
121
+ * An EDF+ duration: `1*DIGIT [ "." 1*DIGIT ]`, never signed.
122
+ *
123
+ * A leading `+` or `-` fails the grammar; it is not tolerated and not stripped, because a signed
124
+ * duration means the writer's field layout is not the one we are reading.
125
+ */
126
+ export function parseUnsignedTicks(text: string): TickParse {
127
+ const magnitude = parseMagnitudeTicks(text, 0);
128
+ return {
129
+ ok: magnitude.ok,
130
+ ticks: magnitude.ticks,
131
+ raw: text,
132
+ seconds: ticksToSeconds(magnitude.ticks),
133
+ };
134
+ }
135
+
136
+ /**
137
+ * Ticks as float64 seconds, for ergonomics and display.
138
+ *
139
+ * Lossy by construction — most tick values are not representable in binary floating point, and
140
+ * beyond 2^53 ticks (~28.5 years) even the integer part rounds. The exact value always stays
141
+ * available as ticks, and that is what comparisons must use.
142
+ *
143
+ * Split into whole seconds plus remainder so that only the remainder is ever divided; bigint
144
+ * `/` truncates toward zero and `%` keeps the dividend's sign, so both parts share a sign and
145
+ * the sum is correct for negative onsets.
146
+ */
147
+ export function ticksToSeconds(ticks: bigint): number {
148
+ const wholeSeconds = ticks / TICKS_PER_SECOND;
149
+ const remainder = ticks % TICKS_PER_SECOND;
150
+ return Number(wholeSeconds) + Number(remainder) / TICKS_PER_SECOND_FLOAT;
151
+ }
152
+
153
+ /**
154
+ * Seconds to ticks, rounded to the NEAREST tick (ties away from zero is not required; ties go
155
+ * toward +Infinity, as `Math.round` does).
156
+ *
157
+ * Only for user-supplied bounds such as a time window, never for a value read from disk: a
158
+ * caller's `30.0` must resolve to 300000000 ticks and not to 299999999 because `30 * 1e7` landed
159
+ * one ULP low. On-disk values reach ticks through the digit parsers above, which never round.
160
+ *
161
+ * Throws `RangeError` for a non-finite argument: there is no tick count for NaN or Infinity, and
162
+ * inventing 0 would silently move a window to the file start.
163
+ */
164
+ export function secondsToTicks(seconds: number): bigint {
165
+ if (!Number.isFinite(seconds)) {
166
+ throw new RangeError(
167
+ `secondsToTicks(): expected a finite number of seconds, received ${seconds}. ` +
168
+ 'Next: check the window bound you passed in.',
169
+ );
170
+ }
171
+ return BigInt(Math.round(seconds * TICKS_PER_SECOND_FLOAT));
172
+ }