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,574 @@
1
+ /**
2
+ * `decodeAnnotations`: TALs plus EDF+ semantics.
3
+ *
4
+ * Layer 3. Sole owner of timekeeping extraction and of the annotation sort. The byte grammar
5
+ * lives in `grammar.ts`; everything here is about which TAL means what.
6
+ *
7
+ * Three rules the rest of the library depends on:
8
+ *
9
+ * 1. The FIRST TAL of the FIRST annotation signal of the file is that record's timekeeping TAL.
10
+ * "First" is a position, not "the first one that parsed", and "first annotation signal" is
11
+ * `header.annotationSignalIndices[0]` — not the first signal this call was asked for. An
12
+ * additional annotation signal carries NO timekeeping TAL, and stripping its first TAL would
13
+ * silently delete a real event.
14
+ * 2. `recordOnsetTicks` has one entry for EVERY record in the decoded range, always. A record
15
+ * whose timekeeping TAL is missing gets the derived onset `start + recordIndex * duration`
16
+ * rather than a hole or a sentinel, and `TIMEKEEPING_TAL_MISSING` carries the record index so
17
+ * the derivation is never invisible. Every timeline in edfcore is built from this array.
18
+ * 3. Onsets are exposed under both conventions as separately named fields, never as an option:
19
+ * `onsetSecondsFromHeaderStart` is the verbatim on-disk value and
20
+ * `onsetSecondsFromFirstRecord` is rebased to record 0's true start. `onsetTicks` is exact
21
+ * and is the only one worth comparing.
22
+ *
23
+ * Diagnostic volume is bounded on purpose, by one test: does another occurrence of this code
24
+ * carry information available nowhere else? `TIMEKEEPING_TAL_MISSING` does — it names a record
25
+ * whose onset was derived — so it is reported per record. `NEGATIVE_ANNOTATION_ONSET` and
26
+ * `TIMEKEEPING_TAL_NONCONFORMANT` do not: the onsets are in the result and the shorthand is a
27
+ * property of the writer, so they are reported once per call. The grammar's own defects are
28
+ * deduplicated per region and carry an occurrence count.
29
+ *
30
+ * Record-onset spacing and monotonicity are deliberately NOT checked here. This module produces
31
+ * `recordOnsetTicks`; `time/timeline.ts` owns enforcing what a valid timeline is, and splitting
32
+ * that would give one rule two owners.
33
+ */
34
+
35
+ import { sliceBytes } from '../bytes/view.js';
36
+ import { TICKS_PER_SECOND } from '../constants.js';
37
+ import { DiagnosticSink } from '../diagnostics/collector.js';
38
+ import { EdfRangeError } from '../errors.js';
39
+ import type {
40
+ DecodeAnnotationsOptions,
41
+ EdfAnnotation,
42
+ EdfAnnotationsResult,
43
+ EdfHeader,
44
+ EdfSignal,
45
+ RecordRange,
46
+ } from '../types.js';
47
+ import {
48
+ type ParsedTal,
49
+ parseTalRegion,
50
+ previewBytes,
51
+ splitChannelLabel,
52
+ TAL_PREVIEW_MAX_BYTES,
53
+ type TalIssue,
54
+ type TalIssueCode,
55
+ type TalTextEncoding,
56
+ } from './grammar.js';
57
+ import { ticksToSeconds } from './ticks.js';
58
+
59
+ const ANNOTATIONS_SPEC = "EDF+ specification 2.2 (the 'EDF Annotations' signal)";
60
+ const TIMEKEEPING_SPEC = 'EDF+ specification 2.2.1 (time keeping of data records)';
61
+ const ONSET_SPEC = 'EDF+ specification 2.2.4 (onsets are relative to the startdate/starttime)';
62
+
63
+ const ISSUE_RULES: Readonly<Record<TalIssueCode, string>> = {
64
+ TAL_MALFORMED:
65
+ 'a TAL is Onset [ 0x15 Duration ] 0x14 *( Text 0x14 ) 0x00, the onset carries a mandatory ' +
66
+ 'sign and the duration never does',
67
+ TAL_TRUNCATED_AT_REGION_END:
68
+ 'a TAL is contained in its own annotation region; the bytes past the region are the next ' +
69
+ "signal's samples",
70
+ TAL_REGION_NOT_NUL_TERMINATED: 'the bytes after the last TAL of a region are all 0x00',
71
+ ANNOTATION_TEXT_NOT_UTF8: 'annotation text is UTF-8',
72
+ };
73
+
74
+ const ISSUE_NEXT_STEPS: Readonly<Record<TalIssueCode, string>> = {
75
+ TAL_MALFORMED:
76
+ 'the rest of the region and the rest of the file were kept; hexdump the file at the byte ' +
77
+ 'offset above to see what the writer emitted',
78
+ TAL_TRUNCATED_AT_REGION_END:
79
+ "check the writer's samplesPerRecord for this annotation signal — a region too small for " +
80
+ 'the TALs written into it is the usual cause',
81
+ TAL_REGION_NOT_NUL_TERMINATED:
82
+ 'hexdump the file at the byte offset above; trailing bytes from an earlier, longer record ' +
83
+ 'mean the writer reused a buffer without clearing it',
84
+ ANNOTATION_TEXT_NOT_UTF8:
85
+ 'the affected annotations report textEncoding "latin-1-fallback", so you can re-decode the ' +
86
+ 'bytes yourself if the writer used another code page',
87
+ };
88
+
89
+ const ISSUE_SPEC_REFERENCES: Readonly<Record<TalIssueCode, string>> = {
90
+ TAL_MALFORMED: ANNOTATIONS_SPEC,
91
+ TAL_TRUNCATED_AT_REGION_END: ANNOTATIONS_SPEC,
92
+ TAL_REGION_NOT_NUL_TERMINATED: ANNOTATIONS_SPEC,
93
+ ANNOTATION_TEXT_NOT_UTF8: ANNOTATIONS_SPEC,
94
+ };
95
+
96
+ /** Enough of a region to show what went wrong, without copying a 60 KB region per diagnostic. */
97
+ const RAW_EVIDENCE_MAX_BYTES = TAL_PREVIEW_MAX_BYTES;
98
+
99
+ /** Everything one region contributes to a diagnostic, so the reporters stay readable. */
100
+ interface RegionContext {
101
+ readonly signal: EdfSignal;
102
+ readonly recordIndex: number;
103
+ /** The buffer the caller passed in. */
104
+ readonly bytes: Uint8Array;
105
+ /** Region start within that buffer. */
106
+ readonly offset: number;
107
+ /** Region start within the FILE — what a hexdump needs. */
108
+ readonly fileOffset: number;
109
+ }
110
+
111
+ /**
112
+ * An annotation before rebasing. The rebase quantity is record 0's start, which is only known
113
+ * once every record in the range has been read, so the public objects are built at the end.
114
+ */
115
+ interface PendingAnnotation {
116
+ readonly onsetTicks: bigint;
117
+ readonly onsetRaw: string;
118
+ readonly durationTicks: bigint | undefined;
119
+ readonly durationRaw: string | undefined;
120
+ readonly text: string;
121
+ readonly channelLabel: string | undefined;
122
+ readonly signalIndex: number;
123
+ readonly recordIndex: number;
124
+ readonly byteOffsetInRecord: number;
125
+ readonly textEncoding: TalTextEncoding;
126
+ /** Insertion order: record-major, then signal index ascending, then on-disk order. */
127
+ readonly order: number;
128
+ }
129
+
130
+ interface ObservedOnset {
131
+ readonly recordIndex: number;
132
+ readonly ticks: bigint;
133
+ readonly raw: string;
134
+ }
135
+
136
+ /**
137
+ * A `BigInt64Array` wraps silently on assignment, so a derived onset is saturated rather than
138
+ * wrapped. Reaching either bound needs a declared geometry that is already impossible (~29,000
139
+ * years of records); saturating keeps the array non-decreasing where wrapping would invert it.
140
+ */
141
+ const INT64_MIN: bigint = -(2n ** 63n);
142
+ const INT64_MAX: bigint = 2n ** 63n - 1n;
143
+
144
+ function saturateToInt64(ticks: bigint): bigint {
145
+ if (ticks > INT64_MAX) return INT64_MAX;
146
+ if (ticks < INT64_MIN) return INT64_MIN;
147
+ return ticks;
148
+ }
149
+
150
+ function describeRange(range: RecordRange): string {
151
+ return `{ start: ${range.start}, count: ${range.count} }`;
152
+ }
153
+
154
+ function assertRecordRange(header: EdfHeader, recordBytes: Uint8Array, records: RecordRange): void {
155
+ const available: RecordRange = { start: 0, count: header.recordCount };
156
+ const validIndices =
157
+ Number.isSafeInteger(records.start) &&
158
+ records.start >= 0 &&
159
+ Number.isSafeInteger(records.count) &&
160
+ records.count >= 0;
161
+ if (!validIndices || records.start + records.count > header.recordCount) {
162
+ throw new EdfRangeError(
163
+ `decodeAnnotations(): records ${describeRange(records)} is not inside the ` +
164
+ `${header.recordCount} records this file has. ` +
165
+ `Next: clamp the range to [0, ${header.recordCount}).`,
166
+ { requested: records, available },
167
+ );
168
+ }
169
+
170
+ const expected = records.count * header.recordByteLength;
171
+ if (recordBytes.length !== expected) {
172
+ const whole =
173
+ header.recordByteLength > 0 ? Math.floor(recordBytes.length / header.recordByteLength) : 0;
174
+ throw new EdfRangeError(
175
+ `decodeAnnotations(): recordBytes is ${recordBytes.length} bytes, but records ` +
176
+ `${describeRange(records)} of this file is exactly ${expected} bytes ` +
177
+ `(${records.count} x ${header.recordByteLength}). ` +
178
+ 'Next: pass the buffer readRecordBytes() returned for this exact range, unsliced.',
179
+ { requested: records, available: { start: records.start, count: whole } },
180
+ );
181
+ }
182
+ }
183
+
184
+ /**
185
+ * The annotation signals to read, ascending and deduplicated.
186
+ *
187
+ * A non-annotation index is refused with a plain `RangeError`, not an `EdfError`: parsing a data
188
+ * signal's samples as text is exactly the garbage this module exists to prevent, and it can only
189
+ * happen through a caller's mistake, never through a file's.
190
+ */
191
+ function resolveSignals(
192
+ header: EdfHeader,
193
+ requested: readonly number[] | undefined,
194
+ ): readonly EdfSignal[] {
195
+ const indices = requested ?? header.annotationSignalIndices;
196
+ const seen = new Set<number>();
197
+ const signals: EdfSignal[] = [];
198
+ for (const index of indices) {
199
+ if (seen.has(index)) continue;
200
+ seen.add(index);
201
+ const signal = header.signals[index];
202
+ if (signal === undefined || signal.kind !== 'annotations') {
203
+ throw new RangeError(
204
+ `decodeAnnotations(): signal ${index} is not an annotation signal. This file's ` +
205
+ `annotation signals are [${header.annotationSignalIndices.join(', ')}]. ` +
206
+ 'Next: pass one of those, or omit signalIndices to read them all.',
207
+ );
208
+ }
209
+ signals.push(signal);
210
+ }
211
+ return signals.sort((a, b) => a.index - b.index);
212
+ }
213
+
214
+ function reportIssue(sink: DiagnosticSink, context: RegionContext, issue: TalIssue): void {
215
+ const repeats =
216
+ issue.occurrences > 1
217
+ ? ` (${issue.occurrences} occurrences in this region; the first is described)`
218
+ : '';
219
+ const evidenceLength = Math.min(issue.byteLength, RAW_EVIDENCE_MAX_BYTES);
220
+ sink.report({
221
+ code: issue.code,
222
+ message:
223
+ `annotation region of signal ${context.signal.index} ("${context.signal.label}") in ` +
224
+ `record ${context.recordIndex}: ${issue.detail}${repeats}. ` +
225
+ `Bytes at that offset: "${issue.raw}". Rule: ${ISSUE_RULES[issue.code]}. ` +
226
+ `Next: ${ISSUE_NEXT_STEPS[issue.code]}.`,
227
+ field: 'annotation region',
228
+ byteOffset: context.fileOffset + issue.byteOffsetInRegion,
229
+ byteLength: issue.byteLength,
230
+ rawBytes: sliceBytes(context.bytes, context.offset + issue.byteOffsetInRegion, evidenceLength),
231
+ raw: issue.raw,
232
+ signalIndex: context.signal.index,
233
+ recordIndex: context.recordIndex,
234
+ specReference: ISSUE_SPEC_REFERENCES[issue.code],
235
+ });
236
+ }
237
+
238
+ function reportTimekeepingMissing(sink: DiagnosticSink, context: RegionContext): void {
239
+ const shown = Math.min(context.signal.recordByteLength, RAW_EVIDENCE_MAX_BYTES);
240
+ sink.report({
241
+ code: 'TIMEKEEPING_TAL_MISSING',
242
+ message:
243
+ `record ${context.recordIndex} has no timekeeping TAL: the first TAL slot of annotation ` +
244
+ `signal ${context.signal.index} ("${context.signal.label}") is empty or did not parse. ` +
245
+ `Region starts with: "${previewBytes(context.bytes, context.offset, shown)}". ` +
246
+ 'Rule: the first TAL of the first annotation signal of every data record gives that ' +
247
+ "record's start relative to the file start, and carries no text. " +
248
+ `Next: this record's onset was derived as start + ${context.recordIndex} x ` +
249
+ 'recordDuration and is in recordOnsetTicks; treat times inside it as nominal, and run ' +
250
+ 'validateRecording() to see how many records are affected.',
251
+ field: 'timekeeping TAL',
252
+ byteOffset: context.fileOffset,
253
+ byteLength: context.signal.recordByteLength,
254
+ rawBytes: sliceBytes(context.bytes, context.offset, shown),
255
+ signalIndex: context.signal.index,
256
+ recordIndex: context.recordIndex,
257
+ specReference: TIMEKEEPING_SPEC,
258
+ });
259
+ }
260
+
261
+ /**
262
+ * What is non-conformant about a timekeeping TAL, or `undefined` when it is exactly
263
+ * `+onset 0x14 0x14 0x00`.
264
+ *
265
+ * Zero texts is the widespread `+t 0x14 0x00` shorthand. EDFlib rejects that file outright; we
266
+ * treat "zero texts" and "one empty text" as the same thing and keep going, because the onset is
267
+ * unambiguous either way and refusing the file would refuse a large part of the real corpus.
268
+ */
269
+ function timekeepingDefect(tal: ParsedTal): string | undefined {
270
+ if (tal.durationRaw !== undefined) {
271
+ return `carries the duration "${tal.durationRaw}", which a timekeeping TAL never has`;
272
+ }
273
+ if (tal.texts.length === 0) {
274
+ return (
275
+ 'omits the mandatory empty text and is written "+onset 0x14 0x00" — the widespread ' +
276
+ 'shorthand, which EDFlib rejects outright'
277
+ );
278
+ }
279
+ const texts = tal.texts.map((run) => run.text).filter((text) => text.length > 0);
280
+ if (texts.length > 0) {
281
+ return (
282
+ `carries the text ${texts.map((text) => `"${text}"`).join(', ')}, which is dropped: the ` +
283
+ 'timekeeping TAL is not an annotation'
284
+ );
285
+ }
286
+ if (tal.texts.length > 1) {
287
+ return `carries ${tal.texts.length} empty texts, where the grammar allows exactly one`;
288
+ }
289
+ return undefined;
290
+ }
291
+
292
+ function reportTimekeepingDefect(
293
+ sink: DiagnosticSink,
294
+ context: RegionContext,
295
+ tal: ParsedTal,
296
+ defect: string,
297
+ ): void {
298
+ sink.report({
299
+ code: 'TIMEKEEPING_TAL_NONCONFORMANT',
300
+ message:
301
+ `the timekeeping TAL of record ${context.recordIndex} (annotation signal ` +
302
+ `${context.signal.index}, onset "${tal.onsetRaw}") ${defect}. ` +
303
+ 'Rule: a timekeeping TAL is written "+onset 0x14 0x14 0x00" — one signed onset, no ' +
304
+ 'duration, one empty text. ' +
305
+ "Next: the onset was used as this record's start and the file was kept; this is " +
306
+ 'reported once per decodeAnnotations() call, so later records are not re-reported.',
307
+ field: 'timekeeping TAL',
308
+ byteOffset: context.fileOffset + tal.byteOffsetInRegion,
309
+ byteLength: tal.byteLength,
310
+ raw: tal.onsetRaw,
311
+ signalIndex: context.signal.index,
312
+ recordIndex: context.recordIndex,
313
+ specReference: TIMEKEEPING_SPEC,
314
+ });
315
+ }
316
+
317
+ function reportNegativeOnset(sink: DiagnosticSink, context: RegionContext, tal: ParsedTal): void {
318
+ sink.report({
319
+ code: 'NEGATIVE_ANNOTATION_ONSET',
320
+ message:
321
+ `annotation onset "${tal.onsetRaw}" in record ${context.recordIndex} (signal ` +
322
+ `${context.signal.index}) is negative: ${ticksToSeconds(tal.onsetTicks)} s before ` +
323
+ 'the file start. ' +
324
+ 'Rule: EDF+ allows a negative onset and it is how a pre-stimulus event is written, so ' +
325
+ 'this is information, not a warning. ' +
326
+ 'Next: nothing to fix — compare event times with onsetTicks, which is exact and signed. ' +
327
+ 'Reported once per decodeAnnotations() call.',
328
+ field: 'annotation onset',
329
+ byteOffset: context.fileOffset + tal.byteOffsetInRegion,
330
+ byteLength: tal.byteLength,
331
+ raw: tal.onsetRaw,
332
+ signalIndex: context.signal.index,
333
+ recordIndex: context.recordIndex,
334
+ specReference: ONSET_SPEC,
335
+ });
336
+ }
337
+
338
+ /**
339
+ * The pinned total order: onset, then signal, then byte offset in the record, then insertion
340
+ * order. The last key is what makes it total — two annotations in different records can share
341
+ * all three of the others — and it is spelled out rather than left to `Array.prototype.sort`
342
+ * being stable, because a test pins this order.
343
+ */
344
+ function compareAnnotations(a: PendingAnnotation, b: PendingAnnotation): number {
345
+ if (a.onsetTicks !== b.onsetTicks) return a.onsetTicks < b.onsetTicks ? -1 : 1;
346
+ if (a.signalIndex !== b.signalIndex) return a.signalIndex - b.signalIndex;
347
+ if (a.byteOffsetInRecord !== b.byteOffsetInRecord) {
348
+ return a.byteOffsetInRecord - b.byteOffsetInRecord;
349
+ }
350
+ return a.order - b.order;
351
+ }
352
+
353
+ export function decodeAnnotations(
354
+ header: EdfHeader,
355
+ recordBytes: Uint8Array,
356
+ records: RecordRange,
357
+ options?: DecodeAnnotationsOptions,
358
+ ): EdfAnnotationsResult {
359
+ assertRecordRange(header, recordBytes, records);
360
+
361
+ const sink = new DiagnosticSink(options);
362
+ const signals = resolveSignals(header, options?.signalIndices);
363
+ // Timekeeping belongs to the file's first annotation signal, not to the first one this call
364
+ // was asked for: an additional annotation signal's first TAL is an ordinary annotation. A call
365
+ // that leaves that signal out reads no timekeeping at all, and every recordOnsetTicks entry is
366
+ // then the nominal grid — silence the caller asked for, not a missing TAL.
367
+ const timekeepingSignalIndex = header.annotationSignalIndices[0];
368
+ const durationTicks = header.recordDurationTicks;
369
+
370
+ const observedOnsets = new Array<bigint | undefined>(records.count).fill(undefined);
371
+ const pending: PendingAnnotation[] = [];
372
+ let firstObserved: ObservedOnset | undefined;
373
+ let negativeOnsetReported = false;
374
+ let timekeepingDefectReported = false;
375
+
376
+ for (let position = 0; position < records.count; position += 1) {
377
+ const recordIndex = records.start + position;
378
+ const recordOffset = position * header.recordByteLength;
379
+
380
+ for (const signal of signals) {
381
+ const context: RegionContext = {
382
+ signal,
383
+ recordIndex,
384
+ bytes: recordBytes,
385
+ offset: recordOffset + signal.recordByteOffset,
386
+ fileOffset:
387
+ header.headerByteLength + recordIndex * header.recordByteLength + signal.recordByteOffset,
388
+ };
389
+ const parse = parseTalRegion(recordBytes, context.offset, signal.recordByteLength);
390
+ for (const issue of parse.issues) reportIssue(sink, context, issue);
391
+
392
+ const first = parse.tals[0];
393
+ const timekeeping =
394
+ signal.index === timekeepingSignalIndex && first !== undefined && first.ordinal === 0
395
+ ? first
396
+ : undefined;
397
+
398
+ if (signal.index === timekeepingSignalIndex) {
399
+ if (timekeeping === undefined) {
400
+ reportTimekeepingMissing(sink, context);
401
+ } else {
402
+ observedOnsets[position] = timekeeping.onsetTicks;
403
+ firstObserved ??= {
404
+ recordIndex,
405
+ ticks: timekeeping.onsetTicks,
406
+ raw: timekeeping.onsetRaw,
407
+ };
408
+ const defect = timekeepingDefect(timekeeping);
409
+ if (defect !== undefined && !timekeepingDefectReported) {
410
+ timekeepingDefectReported = true;
411
+ reportTimekeepingDefect(sink, context, timekeeping, defect);
412
+ }
413
+ }
414
+ }
415
+
416
+ for (const tal of parse.tals) {
417
+ if (tal === timekeeping) continue;
418
+ for (const run of tal.texts) {
419
+ // An empty run is structure, not an event: it is how the grammar terminates a
420
+ // timestamp, and every record would otherwise carry a phantom annotation.
421
+ if (run.text.length === 0) continue;
422
+ const parts = splitChannelLabel(run.text);
423
+ const annotation: PendingAnnotation = {
424
+ onsetTicks: tal.onsetTicks,
425
+ onsetRaw: tal.onsetRaw,
426
+ durationTicks: tal.durationTicks,
427
+ durationRaw: tal.durationRaw,
428
+ text: parts.text,
429
+ channelLabel: parts.channelLabel,
430
+ signalIndex: signal.index,
431
+ recordIndex,
432
+ byteOffsetInRecord: signal.recordByteOffset + run.byteOffsetInRegion,
433
+ textEncoding: run.encoding,
434
+ order: pending.length,
435
+ };
436
+ pending.push(annotation);
437
+ if (annotation.onsetTicks < 0n && !negativeOnsetReported) {
438
+ negativeOnsetReported = true;
439
+ reportNegativeOnset(sink, context, tal);
440
+ }
441
+ }
442
+ }
443
+ }
444
+ }
445
+
446
+ // Record 0's onset, observed when it was decoded and derived from the first record that was
447
+ // otherwise. For a continuous file the derivation is exact; see the rebasing note below.
448
+ const baseTicks =
449
+ firstObserved === undefined
450
+ ? 0n
451
+ : firstObserved.ticks - BigInt(firstObserved.recordIndex) * durationTicks;
452
+
453
+ const recordOnsetTicks = new BigInt64Array(records.count);
454
+ for (let position = 0; position < records.count; position += 1) {
455
+ const observed = observedOnsets[position];
456
+ recordOnsetTicks[position] =
457
+ observed ?? saturateToInt64(baseTicks + BigInt(records.start + position) * durationTicks);
458
+ }
459
+
460
+ const startOffsetTicks = resolveStartOffsetTicks(
461
+ sink,
462
+ header,
463
+ records,
464
+ recordOnsetTicks,
465
+ baseTicks,
466
+ firstObserved,
467
+ );
468
+
469
+ pending.sort(compareAnnotations);
470
+ const annotations: EdfAnnotation[] = pending.map((item) => ({
471
+ onsetSecondsFromHeaderStart: ticksToSeconds(item.onsetTicks),
472
+ onsetSecondsFromFirstRecord: ticksToSeconds(item.onsetTicks - startOffsetTicks),
473
+ onsetTicks: item.onsetTicks,
474
+ onsetRaw: item.onsetRaw,
475
+ durationSeconds:
476
+ item.durationTicks === undefined ? undefined : ticksToSeconds(item.durationTicks),
477
+ durationTicks: item.durationTicks,
478
+ durationRaw: item.durationRaw,
479
+ text: item.text,
480
+ channelLabel: item.channelLabel,
481
+ signalIndex: item.signalIndex,
482
+ recordIndex: item.recordIndex,
483
+ byteOffsetInRecord: item.byteOffsetInRecord,
484
+ textEncoding: item.textEncoding,
485
+ }));
486
+
487
+ return {
488
+ annotations: Object.freeze(annotations),
489
+ recordOnsetTicks,
490
+ diagnostics: sink.diagnostics,
491
+ };
492
+ }
493
+
494
+ /**
495
+ * Record 0's true start, which is what `onsetSecondsFromFirstRecord` is rebased by.
496
+ *
497
+ * When the range starts at record 0 the value is read from the file and used as written, even if
498
+ * it is outside the [0, 1) second the spec allows — it is still that record's start.
499
+ *
500
+ * When the range starts later, record 0's onset is not in the bytes we were given, so it is
501
+ * derived as `onset(first decoded record) - recordIndex * recordDuration`. That is exact for a
502
+ * continuous file and wrong by the elapsed gaps for an EDF+D one, so the derived value is used
503
+ * only when it lands inside [0, 1) — where a gap cannot hide, unless it is itself shorter than a
504
+ * second. Otherwise rebasing is switched off and the two onset conventions become equal.
505
+ *
506
+ * A derivation that lands outside [0, 1) is only *reported* for a file that claims to be
507
+ * continuous, where `onset(r) = start + r * duration` must hold and a violation is the file's
508
+ * fault. On an EDF+D file it is the expected outcome of not decoding from record 0, and a
509
+ * diagnostic there would make `strict` reject every conformant discontinuous recording — the
510
+ * record-index probes read exactly one late record.
511
+ */
512
+ function resolveStartOffsetTicks(
513
+ sink: DiagnosticSink,
514
+ header: EdfHeader,
515
+ records: RecordRange,
516
+ recordOnsetTicks: BigInt64Array,
517
+ baseTicks: bigint,
518
+ firstObserved: ObservedOnset | undefined,
519
+ ): bigint {
520
+ if (records.count === 0) return 0n;
521
+
522
+ const origin =
523
+ firstObserved === undefined
524
+ ? 'no timekeeping TAL was found in the decoded range'
525
+ : firstObserved.recordIndex === 0
526
+ ? `raw timekeeping onset "${firstObserved.raw}"`
527
+ : `derived from record ${firstObserved.recordIndex}, timekeeping onset ` +
528
+ `"${firstObserved.raw}"`;
529
+
530
+ if (records.start === 0) {
531
+ const onset = recordOnsetTicks[0] ?? 0n;
532
+ if (onset < 0n || onset >= TICKS_PER_SECOND) {
533
+ sink.report({
534
+ code: 'START_OFFSET_OUT_OF_RANGE',
535
+ message:
536
+ `record 0 starts ${ticksToSeconds(onset)} s after the header start time, but a ` +
537
+ 'sub-second start offset must be in [0, 1). ' +
538
+ `Origin: ${origin}. ` +
539
+ "Rule: the onset of the first record's timekeeping TAL is the recording's sub-second " +
540
+ 'start offset, and the whole-second part of the start time lives in the header. ' +
541
+ 'Next: the value was used as written; compare it with the header starttime field, ' +
542
+ 'because a writer that encodes the start time twice produces exactly this.',
543
+ field: 'timekeeping TAL',
544
+ raw: firstObserved?.raw ?? '',
545
+ recordIndex: 0,
546
+ specReference: TIMEKEEPING_SPEC,
547
+ });
548
+ }
549
+ return onset;
550
+ }
551
+
552
+ if (baseTicks >= 0n && baseTicks < TICKS_PER_SECOND) return baseTicks;
553
+
554
+ if (header.continuity === 'continuous') {
555
+ sink.report({
556
+ code: 'START_OFFSET_OUT_OF_RANGE',
557
+ message:
558
+ `the decoded range starts at record ${records.start}, so record 0's start offset had ` +
559
+ `to be derived (${origin}); the derived value ${ticksToSeconds(baseTicks)} s is outside ` +
560
+ '[0, 1), which a continuous file cannot produce — its record onsets are start + ' +
561
+ 'recordIndex * recordDuration by definition. ' +
562
+ "Rule: the onset of record 0's timekeeping TAL is the recording's sub-second start " +
563
+ 'offset, and it is the only correct rebasing origin. ' +
564
+ 'Next: onsetSecondsFromFirstRecord equals onsetSecondsFromHeaderStart for this call; ' +
565
+ 'the file is either discontinuous while claiming EDF+C, or its record onsets drift — ' +
566
+ 'buildRecordIndex() will say which.',
567
+ field: 'timekeeping TAL',
568
+ raw: firstObserved?.raw ?? '',
569
+ recordIndex: records.start,
570
+ specReference: TIMEKEEPING_SPEC,
571
+ });
572
+ }
573
+ return 0n;
574
+ }