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
package/src/node.ts ADDED
@@ -0,0 +1,138 @@
1
+ /**
2
+ * The Node adapters — the ONLY module in edfcore that imports anything from `node:`.
3
+ *
4
+ * Layer 7, published as `edfcore/node`. Keeping the import in exactly one file is what lets the
5
+ * universal entry (`edfcore`) be bundled for a browser without a polyfill and without a
6
+ * resolution alias, and a packaging test greps the built universal bundle for `node:` to prove it.
7
+ *
8
+ * Two decisions are load-bearing.
9
+ *
10
+ * 1. Reads are POSITIONAL and LOOP. `FileHandle.read` is allowed to return fewer bytes than asked
11
+ * — a signal interrupted the syscall, the file lives on a network mount — and a `ByteSource`
12
+ * that passed a short buffer on would be indistinguishable from a truncated file. The loop
13
+ * runs until `length` bytes have arrived; a genuine end of file falls out as an
14
+ * `EdfSourceError` naming both counts.
15
+ * 2. `fs.openAsBlob` is NEVER used, however tempting `blobSource(await openAsBlob(path))` looks.
16
+ * It reports `size` modulo 2**32 and yields zeros above 4 GiB, which turns a 13 GB BDF into a
17
+ * file that reads as silence with no error anywhere.
18
+ *
19
+ * TYPING. `src/` compiles with `types: []`, so `@types/node` is not available and must never be:
20
+ * the published `.d.ts` may not reference it, or every consumer inherits a dependency on it. The
21
+ * import below is therefore UNTYPED, and the minimal structural shape edfcore needs is declared
22
+ * here instead — `FileHandleLike` and `NodeFsPromises`. A standing CI job compiles a consumer
23
+ * against the real `@types/node` and asserts `fs.promises.FileHandle` is still assignable, which
24
+ * is what stops the two drifting.
25
+ *
26
+ * The suppression on the import is the unconditional `ts-ignore` directive rather than
27
+ * `@ts-expect-error`, and that is not laziness. Whether the specifier resolves depends on whether
28
+ * a `@types/node` happens to be reachable at all: `tsconfig.build.json` compiles `src/` alone and
29
+ * it is not, while `tsconfig.json` also pulls in a dev tool carrying a triple-slash reference to
30
+ * it and it may be. `@ts-expect-error` errors for being UNUSED in the second case, so it would
31
+ * break whichever of the two configurations the machine happens to disagree with.
32
+ */
33
+
34
+ // biome-ignore lint/suspicious/noTsIgnore: @ts-expect-error errors when unused — see above.
35
+ // @ts-ignore 'node:fs/promises' has no declarations under `types: []`; its shape is below.
36
+ import * as nodeFsPromises from 'node:fs/promises';
37
+ import { EdfSourceError } from './errors.js';
38
+ import { assertExactRead, assertReadRange, throwIfAborted } from './io/source.js';
39
+ import type { ByteSource, ReadOptions } from './types.js';
40
+
41
+ /**
42
+ * A positional reader with a lifetime. A real `fs.promises.FileHandle` satisfies this.
43
+ *
44
+ * Structural on purpose, so `@types/node` never enters the published `.d.ts` and so a test double
45
+ * — or a handle from another runtime with the same API — is usable without a cast.
46
+ */
47
+ export interface FileHandleLike {
48
+ read(
49
+ buffer: Uint8Array,
50
+ offset: number,
51
+ length: number,
52
+ position: number,
53
+ ): Promise<{ bytesRead: number }>;
54
+ close(): Promise<void>;
55
+ }
56
+
57
+ /** The one member of `node:fs/promises` this module uses, and nothing else. */
58
+ interface NodeFsPromises {
59
+ open(
60
+ path: string,
61
+ flags: string,
62
+ ): Promise<FileHandleLike & { stat(): Promise<{ size: number }> }>;
63
+ }
64
+
65
+ const fs: NodeFsPromises = nodeFsPromises as unknown as NodeFsPromises;
66
+
67
+ /**
68
+ * A `ByteSource` over an open file handle.
69
+ *
70
+ * `byteLength` is supplied by the caller rather than read from the handle, because the two ways
71
+ * of learning it differ in what they promise: `fileSource` takes it from the handle it just
72
+ * opened, while a caller wrapping a handle it already had may know something better (a range it
73
+ * intends to expose, a size it verified). Neither is guessed here.
74
+ *
75
+ * `close()` closes the handle. edfcore has no other lifetime mechanism in v0.1 —
76
+ * `Symbol.asyncDispose` is not Baseline yet — so a caller that opened a file is the one that
77
+ * closes it.
78
+ */
79
+ export function fileHandleSource(handle: FileHandleLike, byteLength: number): ByteSource {
80
+ return {
81
+ byteLength,
82
+ async read(offset: number, length: number, options?: ReadOptions): Promise<Uint8Array> {
83
+ throwIfAborted(options);
84
+ assertReadRange(offset, length, byteLength);
85
+ if (length === 0) return new Uint8Array(0);
86
+
87
+ const buffer = new Uint8Array(length);
88
+ let filled = 0;
89
+ while (filled < length) {
90
+ throwIfAborted(options);
91
+ // Positional: the handle's own file position is never used, so concurrent reads through
92
+ // one handle cannot interleave into each other's buffers.
93
+ const { bytesRead } = await handle.read(buffer, filled, length - filled, offset + filled);
94
+ // Zero bytes means end of file. Looping again would spin forever on a file that is
95
+ // shorter than `byteLength` claimed, so the short read is reported instead.
96
+ if (bytesRead <= 0) break;
97
+ filled += bytesRead;
98
+ }
99
+ return assertExactRead(buffer.subarray(0, filled), offset, length);
100
+ },
101
+ async close(): Promise<void> {
102
+ await handle.close();
103
+ },
104
+ };
105
+ }
106
+
107
+ /**
108
+ * Open a file for reading and expose it as a `ByteSource`.
109
+ *
110
+ * The size comes from the handle rather than from a separate `stat(path)` call, so the size and
111
+ * the bytes describe the same file even if the path is replaced between the two — on a rotating
112
+ * log or a rsync target that is not hypothetical.
113
+ *
114
+ * The handle is closed if anything goes wrong before it has an owner; after that, closing is the
115
+ * caller's job through `source.close()`.
116
+ */
117
+ export async function fileSource(path: string): Promise<ByteSource> {
118
+ const handle = await fs.open(path, 'r');
119
+ try {
120
+ const stats = await handle.stat();
121
+ const byteLength = stats.size;
122
+ if (!Number.isSafeInteger(byteLength) || byteLength < 0) {
123
+ throw new EdfSourceError(
124
+ `fileSource(): the operating system reported a size of ${byteLength} bytes for ` +
125
+ `${JSON.stringify(path)}, which is not a byte count edfcore can address. Next: check ` +
126
+ 'that the path names a regular file rather than a directory, a pipe or a device.',
127
+ { offset: 0, requestedLength: 0 },
128
+ );
129
+ }
130
+ return fileHandleSource(handle, byteLength);
131
+ } catch (error) {
132
+ // A failure to close is not what the caller needs to hear about: the reason the file is
133
+ // unusable is `error`, and letting close() replace it would hide the diagnosis behind the
134
+ // cleanup. The descriptor is leaked in that case, which is the lesser of the two.
135
+ await handle.close().catch(() => undefined);
136
+ throw error;
137
+ }
138
+ }
@@ -0,0 +1,389 @@
1
+ /**
2
+ * The record index: two probes at open, one targeted probe on demand, a full scan only if asked.
3
+ *
4
+ * Layer 6. This module owns the I/O STRATEGY for record onsets and nothing else — what a valid
5
+ * timeline is belongs to `time/timeline.ts`, what a timekeeping TAL is belongs to
6
+ * `tal/annotations.ts`, and segmentation belongs to `time/segments.ts`. Every onset that reaches
7
+ * those modules from here came out of `decodeAnnotations`, so the "first TAL of the first
8
+ * annotation signal" rule has exactly one implementation.
9
+ *
10
+ * Cost is the design constraint. Opening a million-record EDF+D over HTTP must not read the file,
11
+ * so:
12
+ *
13
+ * - a file with no annotation signal is probed ZERO times: without a timekeeping TAL there is no
14
+ * per-record onset on disk, and record `r` starts at `r * recordDuration` by definition;
15
+ * - otherwise `buildTimeline` probes exactly two records, the first and the last, which detects
16
+ * any NET drift of the timeline for two reads. It is not a proof of contiguity, and
17
+ * `time/timeline.ts` says so in the diagnostic it emits;
18
+ * - `onsetTicks(r)` reads that ONE record and memoises the answer, so `locate()` costs
19
+ * O(log recordCount) reads and a second `locate()` nearby costs almost none;
20
+ * - `buildRecordIndex()` is the only function here that touches every record, it is chunked so
21
+ * memory stays bounded whatever the file size, and it is never called implicitly.
22
+ *
23
+ * A probe reads a whole data record rather than just the annotation signal's region. That is
24
+ * decision 7 of the design — the unit of I/O is the record range, never the channel range — and
25
+ * it is also what lets `decodeAnnotations` own the timekeeping rule: it requires the record's
26
+ * full bytes, and reading less would mean reimplementing that rule here.
27
+ */
28
+
29
+ import { DEFAULT_MAX_MATERIALIZE_BYTES } from './constants.js';
30
+ import { EdfRangeError } from './errors.js';
31
+ import { readRecordBytes } from './io/read.js';
32
+ import { decodeAnnotations } from './tal/annotations.js';
33
+ import { secondsToTicks, ticksToSeconds } from './tal/ticks.js';
34
+ import { buildSegmentation } from './time/segments.js';
35
+ import {
36
+ assertMonotonicOnsetArray,
37
+ assertMonotonicOnsets,
38
+ buildTimelineFromProbes,
39
+ type RecordOnsetProbe,
40
+ } from './time/timeline.js';
41
+ import type {
42
+ BuildIndexOptions,
43
+ ByteSource,
44
+ DecodeAnnotationsOptions,
45
+ EdfDiagnostic,
46
+ EdfGap,
47
+ EdfHeader,
48
+ EdfLocation,
49
+ EdfRecordIndex,
50
+ EdfRecording,
51
+ EdfSegment,
52
+ EdfTimeline,
53
+ OpenOptions,
54
+ ReadOptions,
55
+ RecordRange,
56
+ } from './types.js';
57
+
58
+ /**
59
+ * How much a full scan is allowed to hold at once, independently of `maxMaterializeBytes`.
60
+ *
61
+ * The materialisation budget is a ceiling on what one call may allocate; this is the working set
62
+ * of a traversal that could otherwise read a 13 GB BDF into a single buffer just because the
63
+ * budget permitted it. A scan is sequential, so a bigger block buys nothing.
64
+ */
65
+ const SCAN_BLOCK_TARGET_BYTES = 4 * 1024 * 1024;
66
+
67
+ /** Records per chunk of a full traversal: bounded memory, and never fewer than one record. */
68
+ export function scanChunkRecords(header: EdfHeader, maxMaterializeBytes?: number): number {
69
+ const budget = Math.min(
70
+ SCAN_BLOCK_TARGET_BYTES,
71
+ maxMaterializeBytes ?? DEFAULT_MAX_MATERIALIZE_BYTES,
72
+ );
73
+ if (header.recordByteLength <= 0) return 1;
74
+ return Math.max(1, Math.floor(budget / header.recordByteLength));
75
+ }
76
+
77
+ /**
78
+ * The onset a record has when the file carries no timekeeping TAL to read.
79
+ *
80
+ * Not a fallback for a missing TAL — `decodeAnnotations` owns that case and reports it — but the
81
+ * definition for a plain EDF or BDF file, where record onsets are not stored at all.
82
+ */
83
+ function nominalOnsetTicks(header: EdfHeader, recordIndex: number): bigint {
84
+ return BigInt(recordIndex) * header.recordDurationTicks;
85
+ }
86
+
87
+ /** True when the file stores per-record onsets, i.e. when probing can learn anything. */
88
+ function hasTimekeeping(header: EdfHeader): boolean {
89
+ return header.annotationSignalIndices.length > 0;
90
+ }
91
+
92
+ interface OnsetProbe {
93
+ readonly ticks: bigint;
94
+ readonly diagnostics: readonly EdfDiagnostic[];
95
+ }
96
+
97
+ async function probeOnset(
98
+ source: ByteSource,
99
+ header: EdfHeader,
100
+ recordIndex: number,
101
+ options?: DecodeAnnotationsOptions & ReadOptions,
102
+ ): Promise<OnsetProbe> {
103
+ const records: RecordRange = { start: recordIndex, count: 1 };
104
+ const bytes = await readRecordBytes(source, header, records, options);
105
+ const decoded = decodeAnnotations(header, bytes, records, options);
106
+ // decodeAnnotations fills one entry per record in the range, always; the range is one record.
107
+ const ticks = decoded.recordOnsetTicks[0] ?? nominalOnsetTicks(header, recordIndex);
108
+ return { ticks, diagnostics: decoded.diagnostics };
109
+ }
110
+
111
+ interface IndexInput {
112
+ readonly coverage: 'probed' | 'complete';
113
+ readonly recordCount: number;
114
+ readonly recordDurationTicks: bigint;
115
+ /** Record 0's own onset. Subtracting it turns a stored onset into elapsed recording time. */
116
+ readonly startOffsetTicks: bigint;
117
+ readonly segments: readonly EdfSegment[] | undefined;
118
+ readonly gaps: readonly EdfGap[] | undefined;
119
+ readonly onsetOf: (recordIndex: number, options?: ReadOptions) => Promise<bigint>;
120
+ }
121
+
122
+ /**
123
+ * The one `EdfRecordIndex` implementation.
124
+ *
125
+ * `locate` is written against `onsetOf` alone, so the probed index and the complete one share it:
126
+ * the first pays one read per probe and memoises, the second answers from an array it already
127
+ * has. The search is the same either way, and so is its behaviour at a gap.
128
+ */
129
+ function createIndex(input: IndexInput): EdfRecordIndex {
130
+ const { recordCount, recordDurationTicks, startOffsetTicks, onsetOf } = input;
131
+
132
+ function assertRecordIndex(recordIndex: number): void {
133
+ if (Number.isSafeInteger(recordIndex) && recordIndex >= 0 && recordIndex < recordCount) {
134
+ return;
135
+ }
136
+ throw new EdfRangeError(
137
+ `record ${recordIndex} is not one of the ${recordCount} data records this file contains, ` +
138
+ 'so it has no onset to read. Next: pass an index in ' +
139
+ `0..${recordCount - 1}, or call locate(seconds) to find one for a time.`,
140
+ { requested: { start: recordIndex, count: 1 }, available: { start: 0, count: recordCount } },
141
+ );
142
+ }
143
+
144
+ async function onsetTicks(recordIndex: number, options?: ReadOptions): Promise<bigint> {
145
+ assertRecordIndex(recordIndex);
146
+ return onsetOf(recordIndex, options);
147
+ }
148
+
149
+ /**
150
+ * The last record whose onset is at or before `targetTicks`, or `undefined` when every record
151
+ * starts after it.
152
+ *
153
+ * Binary search over a monotonic sequence, and monotonicity is verified at every pair the
154
+ * search actually observes: a violation makes every time-based answer for the file wrong, so
155
+ * `assertMonotonicOnsets` throws rather than letting the search return a plausible record.
156
+ */
157
+ async function findRecordAtOrBefore(
158
+ targetTicks: bigint,
159
+ options?: ReadOptions,
160
+ ): Promise<{ recordIndex: number; onsetTicks: bigint } | undefined> {
161
+ let low = 0;
162
+ let lowTicks = await onsetOf(0, options);
163
+ if (targetTicks < lowTicks) return undefined;
164
+
165
+ let high = recordCount - 1;
166
+ if (high === low) return { recordIndex: low, onsetTicks: lowTicks };
167
+
168
+ let highTicks = await onsetOf(high, options);
169
+ assertMonotonicOnsets(
170
+ { recordIndex: low, onsetTicks: lowTicks },
171
+ { recordIndex: high, onsetTicks: highTicks },
172
+ );
173
+ if (targetTicks >= highTicks) return { recordIndex: high, onsetTicks: highTicks };
174
+
175
+ // Invariant: lowTicks <= target < highTicks, and low < high.
176
+ while (high - low > 1) {
177
+ const middle = low + Math.floor((high - low) / 2);
178
+ const middleTicks = await onsetOf(middle, options);
179
+ assertMonotonicOnsets(
180
+ { recordIndex: low, onsetTicks: lowTicks },
181
+ { recordIndex: middle, onsetTicks: middleTicks },
182
+ );
183
+ assertMonotonicOnsets(
184
+ { recordIndex: middle, onsetTicks: middleTicks },
185
+ { recordIndex: high, onsetTicks: highTicks },
186
+ );
187
+ if (middleTicks <= targetTicks) {
188
+ low = middle;
189
+ lowTicks = middleTicks;
190
+ } else {
191
+ high = middle;
192
+ highTicks = middleTicks;
193
+ }
194
+ }
195
+ return { recordIndex: low, onsetTicks: lowTicks };
196
+ }
197
+
198
+ async function locate(seconds: number, options?: ReadOptions): Promise<EdfLocation | undefined> {
199
+ if (recordCount <= 0) return undefined;
200
+ // `seconds` is elapsed recording time; stored onsets are relative to the header start time.
201
+ const targetTicks = secondsToTicks(seconds) + startOffsetTicks;
202
+
203
+ const found = await findRecordAtOrBefore(targetTicks, options);
204
+ if (found === undefined) return undefined;
205
+
206
+ const recordStartSeconds = ticksToSeconds(found.onsetTicks - startOffsetTicks);
207
+ if (recordDurationTicks === 0n) {
208
+ // Zero-duration records occupy no time, so only the instant itself is inside one. The last
209
+ // record sharing that instant is the one returned, which is what the search converges on.
210
+ if (targetTicks !== found.onsetTicks) return undefined;
211
+ return {
212
+ recordIndex: found.recordIndex,
213
+ recordStartSeconds,
214
+ offsetInRecordSeconds: 0,
215
+ };
216
+ }
217
+
218
+ // Past the end of the record it follows: the time is in a gap, or after the recording.
219
+ if (targetTicks >= found.onsetTicks + recordDurationTicks) return undefined;
220
+ return {
221
+ recordIndex: found.recordIndex,
222
+ recordStartSeconds,
223
+ offsetInRecordSeconds: ticksToSeconds(targetTicks - found.onsetTicks),
224
+ };
225
+ }
226
+
227
+ return {
228
+ coverage: input.coverage,
229
+ recordCount,
230
+ segments: input.segments,
231
+ gaps: input.gaps,
232
+ onsetTicks,
233
+ locate,
234
+ };
235
+ }
236
+
237
+ /**
238
+ * The timeline and a lazily probing index, for two reads at most.
239
+ *
240
+ * The probes are records 0 and `recordCount - 1` (one probe for a single-record file, none at all
241
+ * when the file has no annotation signal). Both are memoised into the index, so `onsetTicks(0)`
242
+ * and `onsetTicks(recordCount - 1)` are free after `openEdf`.
243
+ *
244
+ * `index.coverage` stays `'probed'` and `index.segments`/`index.gaps` stay `undefined` until
245
+ * `buildRecordIndex()` promotes them. Nothing on the returned object can be mistaken for a
246
+ * verified statement that the recording is continuous.
247
+ */
248
+ export async function buildTimeline(
249
+ source: ByteSource,
250
+ header: EdfHeader,
251
+ options?: OpenOptions,
252
+ ): Promise<{ timeline: EdfTimeline; index: EdfRecordIndex }> {
253
+ const recordCount = header.recordCount;
254
+ const timekept = hasTimekeeping(header);
255
+ const strict = options?.strict === true;
256
+
257
+ const probeOptions = (
258
+ readOptions: ReadOptions | undefined,
259
+ ): DecodeAnnotationsOptions & ReadOptions =>
260
+ readOptions === undefined ? { strict } : { ...readOptions, strict };
261
+
262
+ const memo = new Map<number, bigint>();
263
+ const probes: RecordOnsetProbe[] = [];
264
+ const probeDiagnostics: EdfDiagnostic[] = [];
265
+
266
+ const probeIndices = recordCount === 0 ? [] : recordCount === 1 ? [0] : [0, recordCount - 1];
267
+ for (const recordIndex of probeIndices) {
268
+ if (!timekept) {
269
+ const ticks = nominalOnsetTicks(header, recordIndex);
270
+ memo.set(recordIndex, ticks);
271
+ probes.push({ recordIndex, onsetTicks: ticks });
272
+ continue;
273
+ }
274
+ const probe = await probeOnset(source, header, recordIndex, probeOptions(options));
275
+ memo.set(recordIndex, probe.ticks);
276
+ probeDiagnostics.push(...probe.diagnostics);
277
+ probes.push({ recordIndex, onsetTicks: probe.ticks });
278
+ }
279
+
280
+ const timeline = buildTimelineFromProbes({ header, probes, probeDiagnostics }, options);
281
+
282
+ async function onsetOf(recordIndex: number, readOptions?: ReadOptions): Promise<bigint> {
283
+ const cached = memo.get(recordIndex);
284
+ if (cached !== undefined) return cached;
285
+ if (!timekept) {
286
+ const ticks = nominalOnsetTicks(header, recordIndex);
287
+ memo.set(recordIndex, ticks);
288
+ return ticks;
289
+ }
290
+ const probe = await probeOnset(source, header, recordIndex, probeOptions(readOptions));
291
+ memo.set(recordIndex, probe.ticks);
292
+ return probe.ticks;
293
+ }
294
+
295
+ const index = createIndex({
296
+ coverage: 'probed',
297
+ recordCount,
298
+ recordDurationTicks: header.recordDurationTicks,
299
+ startOffsetTicks: timeline.startOffsetTicks,
300
+ segments: undefined,
301
+ gaps: undefined,
302
+ onsetOf,
303
+ });
304
+
305
+ return { timeline, index };
306
+ }
307
+
308
+ /**
309
+ * Every record's onset, read in bounded chunks.
310
+ *
311
+ * `onProgress` is called once per chunk with the number of records finished, so a caller can show
312
+ * a bar for the one operation in edfcore whose cost is proportional to the file.
313
+ *
314
+ * A file with no annotation signal is not scanned: its record onsets are arithmetic, so reading
315
+ * the data would answer a question the bytes do not contain. `onProgress` is still called once,
316
+ * with the traversal complete, so a caller's bar finishes.
317
+ */
318
+ async function scanOnsets(
319
+ recording: EdfRecording,
320
+ options: BuildIndexOptions | undefined,
321
+ ): Promise<BigInt64Array> {
322
+ const { source, header } = recording;
323
+ const recordCount = header.recordCount;
324
+ const onsets = new BigInt64Array(recordCount);
325
+
326
+ if (!hasTimekeeping(header)) {
327
+ for (let recordIndex = 0; recordIndex < recordCount; recordIndex += 1) {
328
+ onsets[recordIndex] = nominalOnsetTicks(header, recordIndex);
329
+ }
330
+ options?.onProgress?.(recordCount, recordCount);
331
+ return onsets;
332
+ }
333
+
334
+ const chunkRecords = scanChunkRecords(header, options?.maxMaterializeBytes);
335
+ let scanned = 0;
336
+ while (scanned < recordCount) {
337
+ const records: RecordRange = {
338
+ start: scanned,
339
+ count: Math.min(chunkRecords, recordCount - scanned),
340
+ };
341
+ const bytes = await readRecordBytes(source, header, records, options);
342
+ const decoded = decodeAnnotations(header, bytes, records, options);
343
+ onsets.set(decoded.recordOnsetTicks, scanned);
344
+ scanned += records.count;
345
+ options?.onProgress?.(scanned, recordCount);
346
+ }
347
+ return onsets;
348
+ }
349
+
350
+ /**
351
+ * A `'complete'` index: every onset verified, with the segments and gaps they imply.
352
+ *
353
+ * This is one of only two functions that read the whole file, the other being `validateRecording`, and it is never called
354
+ * implicitly. Its diagnostics are deliberately not returned — an `EdfRecordIndex` is a
355
+ * structural answer, and `validateRecording()` is the call that reports on a traversal — but a
356
+ * non-monotonic timeline still throws, because no index over it would mean anything.
357
+ *
358
+ * `EdfRecording` is a plain struct, so the returned index is used by rebuilding one:
359
+ * `readWindow({ ...recording, index }, selection)`.
360
+ */
361
+ export async function buildRecordIndex(
362
+ recording: EdfRecording,
363
+ options?: BuildIndexOptions,
364
+ ): Promise<EdfRecordIndex> {
365
+ const { header, timeline } = recording;
366
+ const onsets = await scanOnsets(recording, options);
367
+ assertMonotonicOnsetArray(onsets);
368
+
369
+ const segmentation = buildSegmentation(
370
+ onsets,
371
+ header.recordDurationTicks,
372
+ timeline.startOffsetTicks,
373
+ );
374
+
375
+ async function onsetOf(recordIndex: number): Promise<bigint> {
376
+ // Bounds were checked by `createIndex` before this is reached.
377
+ return onsets[recordIndex] ?? nominalOnsetTicks(header, recordIndex);
378
+ }
379
+
380
+ return createIndex({
381
+ coverage: 'complete',
382
+ recordCount: header.recordCount,
383
+ recordDurationTicks: header.recordDurationTicks,
384
+ startOffsetTicks: timeline.startOffsetTicks,
385
+ segments: segmentation.segments,
386
+ gaps: segmentation.gaps,
387
+ onsetOf,
388
+ });
389
+ }