edfcore 0.1.5 → 0.1.7

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 (47) hide show
  1. package/dist/constants.d.ts +1 -1
  2. package/dist/constants.js +1 -1
  3. package/dist/diagnostics/codes.d.ts +1 -1
  4. package/dist/diagnostics/codes.d.ts.map +1 -1
  5. package/dist/diagnostics/codes.js +1 -0
  6. package/dist/diagnostics/codes.js.map +1 -1
  7. package/dist/diagnostics/collector.d.ts +14 -0
  8. package/dist/diagnostics/collector.d.ts.map +1 -1
  9. package/dist/diagnostics/collector.js +17 -0
  10. package/dist/diagnostics/collector.js.map +1 -1
  11. package/dist/envelope.d.ts +48 -0
  12. package/dist/envelope.d.ts.map +1 -0
  13. package/dist/envelope.js +263 -0
  14. package/dist/envelope.js.map +1 -0
  15. package/dist/header/parse.d.ts.map +1 -1
  16. package/dist/header/parse.js +33 -0
  17. package/dist/header/parse.js.map +1 -1
  18. package/dist/index.d.ts +2 -1
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +1 -0
  21. package/dist/index.js.map +1 -1
  22. package/dist/record-index.d.ts.map +1 -1
  23. package/dist/record-index.js +10 -3
  24. package/dist/record-index.js.map +1 -1
  25. package/dist/tal/annotations.d.ts.map +1 -1
  26. package/dist/tal/annotations.js +1 -15
  27. package/dist/tal/annotations.js.map +1 -1
  28. package/dist/tal/ticks.d.ts +15 -0
  29. package/dist/tal/ticks.d.ts.map +1 -1
  30. package/dist/tal/ticks.js +23 -0
  31. package/dist/tal/ticks.js.map +1 -1
  32. package/dist/types.d.ts +49 -0
  33. package/dist/types.d.ts.map +1 -1
  34. package/dist/validate.js +3 -3
  35. package/dist/validate.js.map +1 -1
  36. package/package.json +1 -1
  37. package/src/constants.ts +1 -1
  38. package/src/diagnostics/codes.ts +2 -0
  39. package/src/diagnostics/collector.ts +17 -0
  40. package/src/envelope.ts +352 -0
  41. package/src/header/parse.ts +36 -0
  42. package/src/index.ts +5 -0
  43. package/src/record-index.ts +10 -3
  44. package/src/tal/annotations.ts +1 -15
  45. package/src/tal/ticks.ts +23 -0
  46. package/src/types.ts +53 -0
  47. package/src/validate.ts +3 -3
@@ -0,0 +1,352 @@
1
+ /**
2
+ * Min/max envelope decimation.
3
+ *
4
+ * Layer 7. A twelve-hour recording at 256 Hz is eleven million samples per channel, and a plot is
5
+ * a thousand pixels wide. Something has to reduce eleven million numbers to a thousand, and which
6
+ * reduction you pick decides whether the picture is true.
7
+ *
8
+ * Taking every 11,000th sample is the obvious choice and the wrong one: a spike, a spindle or an
9
+ * artifact is a handful of samples wide, so subsampling hits it with probability near zero and
10
+ * the trace looks calm exactly where a reader most needs it not to. Keeping the MINIMUM and
11
+ * MAXIMUM of each bucket keeps every extreme, at two numbers per pixel. That is the reduction a
12
+ * waveform viewer wants, and it is why this exists as its own function rather than as an option
13
+ * on `readWindow`: the return type is different, so an option would have to change it.
14
+ *
15
+ * This is decimation for display, not analysis. There is no filtering and no anti-aliasing —
16
+ * an envelope is a faithful summary of the samples that are there, not a resampled signal, and
17
+ * resampling remains a permanent non-goal.
18
+ *
19
+ * Memory is bounded by the record chunk, never by the window: a run of a million records is
20
+ * folded into the buckets a chunk at a time, so an envelope over a whole recording costs the
21
+ * buckets plus one chunk.
22
+ */
23
+
24
+ import { decodeDigitalCounted } from './decode/digital.js';
25
+ import { EdfScalingError } from './errors.js';
26
+ import { readRecordBytes } from './io/read.js';
27
+ import { scanChunkRecords } from './record-index.js';
28
+ import { decodeAnnotations } from './tal/annotations.js';
29
+ import { ticksToSeconds } from './tal/ticks.js';
30
+ import { resolveTimeWindow } from './time/window.js';
31
+ import type {
32
+ EdfChunkSignal,
33
+ EdfDiagnostic,
34
+ EdfEnvelopeChunk,
35
+ EdfEnvelopeSignal,
36
+ EdfHeader,
37
+ EdfPhysicalEnvelope,
38
+ EdfRecording,
39
+ EdfSignal,
40
+ EnvelopeSelection,
41
+ ReadOptions,
42
+ RecordRange,
43
+ } from './types.js';
44
+
45
+ /** Per-signal accumulator, reused across every chunk of one contiguous run. */
46
+ interface Accumulator {
47
+ readonly signal: EdfSignal;
48
+ readonly min: Int32Array;
49
+ readonly max: Int32Array;
50
+ readonly counts: Int32Array;
51
+ /** Samples of this signal already folded in, i.e. its position on the run's sample grid. */
52
+ consumed: number;
53
+ outOfRange: number;
54
+ scratch: Int32Array | undefined;
55
+ }
56
+
57
+ function assertPositiveInteger(value: number, name: string): void {
58
+ if (Number.isSafeInteger(value) && value > 0) return;
59
+ throw new RangeError(
60
+ `readEnvelope(): ${name} must be a positive whole number, received ${value}. ` +
61
+ 'Next: pass the pixel width of the plot you are drawing into.',
62
+ );
63
+ }
64
+
65
+ /**
66
+ * Reduces a time window to per-bucket minima and maxima, one chunk per contiguous run.
67
+ *
68
+ * The shape mirrors `readWindow` exactly — an array of chunks, one per run, empty when the window
69
+ * selects nothing — so a caller that already handles gaps handles envelopes for free.
70
+ */
71
+ export async function readEnvelope(
72
+ recording: EdfRecording,
73
+ selection: EnvelopeSelection,
74
+ options?: ReadOptions,
75
+ ): Promise<readonly EdfEnvelopeChunk[]> {
76
+ assertPositiveInteger(selection.buckets, 'buckets');
77
+ // Validated before the window is resolved, for the same reason readWindow does it: a bad
78
+ // signalIndices must not read back as an empty stretch of recording.
79
+ resolveEnvelopeSignals(recording.header, selection.signalIndices);
80
+
81
+ const ranges = resolveTimeWindow(
82
+ recording.timeline,
83
+ recording.index,
84
+ selection.startSeconds,
85
+ selection.durationSeconds,
86
+ );
87
+
88
+ const chunks: EdfEnvelopeChunk[] = [];
89
+ for (const records of ranges) {
90
+ chunks.push(await reduceRange(recording, records, selection, options));
91
+ }
92
+ return Object.freeze(chunks);
93
+ }
94
+
95
+ function resolveEnvelopeSignals(
96
+ header: EdfHeader,
97
+ signalIndices: readonly number[],
98
+ ): readonly EdfSignal[] {
99
+ const seen = new Set<number>();
100
+ const signals: EdfSignal[] = [];
101
+ for (const signalIndex of signalIndices) {
102
+ if (seen.has(signalIndex)) continue;
103
+ seen.add(signalIndex);
104
+ const signal = header.signals[signalIndex];
105
+ if (signal === undefined) {
106
+ throw new RangeError(
107
+ `readEnvelope(): signalIndex ${signalIndex} is outside the ${header.signals.length} ` +
108
+ 'signals this file declares. Next: pass an index from header.dataSignalIndices.',
109
+ );
110
+ }
111
+ if (signal.kind === 'annotations') {
112
+ throw new RangeError(
113
+ `readEnvelope(): signal ${signalIndex} (${JSON.stringify(signal.label)}) is this file's ` +
114
+ 'annotations channel; its bytes are TAL text, not samples, so an envelope over them ' +
115
+ 'would be an envelope over ASCII. Next: call readAnnotations() instead.',
116
+ );
117
+ }
118
+ signals.push(signal);
119
+ }
120
+ return signals;
121
+ }
122
+
123
+ async function reduceRange(
124
+ recording: EdfRecording,
125
+ records: RecordRange,
126
+ selection: EnvelopeSelection,
127
+ options?: ReadOptions,
128
+ ): Promise<EdfEnvelopeChunk> {
129
+ const { source, header, timeline } = recording;
130
+ const signals = resolveEnvelopeSignals(header, selection.signalIndices);
131
+ const diagnostics: EdfDiagnostic[] = [];
132
+
133
+ // More buckets than the densest signal has samples in this run would leave holes that mean
134
+ // nothing, so the request is clamped rather than honoured literally.
135
+ const densestSamples = signals.reduce(
136
+ (most, signal) => Math.max(most, signal.samplesPerRecord * records.count),
137
+ 0,
138
+ );
139
+ const bucketCount = Math.max(1, Math.min(selection.buckets, densestSamples));
140
+
141
+ const accumulators: Accumulator[] = signals.map((signal) => ({
142
+ signal,
143
+ // Int32Array zero-fills, so the sentinels have to be written explicitly: a bucket nothing
144
+ // landed in must be distinguishable from a bucket whose samples were all zero.
145
+ min: new Int32Array(bucketCount).fill(0),
146
+ max: new Int32Array(bucketCount).fill(0),
147
+ counts: new Int32Array(bucketCount),
148
+ consumed: 0,
149
+ outOfRange: 0,
150
+ scratch: undefined,
151
+ }));
152
+
153
+ const chunkRecords = scanChunkRecords(header, options?.maxMaterializeBytes);
154
+ let byteLength = 0;
155
+ let firstOnsetTicks: bigint | undefined;
156
+ let lastOnsetTicks: bigint | undefined;
157
+ let scanned = 0;
158
+
159
+ while (scanned < records.count) {
160
+ const slice: RecordRange = {
161
+ start: records.start + scanned,
162
+ count: Math.min(chunkRecords, records.count - scanned),
163
+ };
164
+ const bytes = await readRecordBytes(source, header, slice, options);
165
+ byteLength += bytes.length;
166
+
167
+ // Never strict: a defect in one record must not cost the caller the whole picture.
168
+ const annotations = decodeAnnotations(header, bytes, slice, {
169
+ originTicks: timeline.startOffsetTicks,
170
+ });
171
+ for (const diagnostic of annotations.diagnostics) diagnostics.push(diagnostic);
172
+ const onsets = annotations.recordOnsetTicks;
173
+ if (firstOnsetTicks === undefined) firstOnsetTicks = onsets[0];
174
+ const lastInSlice = onsets[slice.count - 1];
175
+ if (lastInSlice !== undefined) lastOnsetTicks = lastInSlice;
176
+
177
+ for (const accumulator of accumulators) {
178
+ foldChunk(accumulator, header, bytes, slice, records, bucketCount, options);
179
+ }
180
+
181
+ scanned += slice.count;
182
+ }
183
+
184
+ const durationTicks = header.recordDurationTicks;
185
+ const startTicks = (firstOnsetTicks ?? timeline.startOffsetTicks) - timeline.startOffsetTicks;
186
+ const spanTicks =
187
+ firstOnsetTicks !== undefined && lastOnsetTicks !== undefined
188
+ ? lastOnsetTicks + durationTicks - firstOnsetTicks
189
+ : durationTicks * BigInt(records.count);
190
+ const startSeconds = ticksToSeconds(startTicks);
191
+ const durationSeconds = ticksToSeconds(spanTicks);
192
+
193
+ const envelopeSignals: EdfEnvelopeSignal[] = accumulators.map((accumulator) => ({
194
+ signalIndex: accumulator.signal.index,
195
+ min: accumulator.min,
196
+ max: accumulator.max,
197
+ counts: accumulator.counts,
198
+ sampleCount: accumulator.consumed,
199
+ firstSampleIndex: records.start * accumulator.signal.samplesPerRecord,
200
+ startSeconds,
201
+ outOfDigitalRangeCount: accumulator.outOfRange,
202
+ }));
203
+
204
+ return Object.freeze({
205
+ records,
206
+ startSeconds,
207
+ durationSeconds,
208
+ bucketCount,
209
+ secondsPerBucket: bucketCount > 0 ? durationSeconds / bucketCount : 0,
210
+ byteLength,
211
+ signals: Object.freeze(envelopeSignals),
212
+ precededByGap: undefined,
213
+ diagnostics: Object.freeze(diagnostics),
214
+ });
215
+ }
216
+
217
+ /**
218
+ * Folds one chunk of one signal into the buckets.
219
+ *
220
+ * The bucket of a sample is decided by its position on the WHOLE run's grid, not the chunk's, so
221
+ * the chunk size cannot move a sample from one bucket to another. That is the same rule the
222
+ * record scan learned the hard way: chunking bounds memory and must never change the answer.
223
+ */
224
+ function foldChunk(
225
+ accumulator: Accumulator,
226
+ header: EdfHeader,
227
+ bytes: Uint8Array,
228
+ slice: RecordRange,
229
+ run: RecordRange,
230
+ bucketCount: number,
231
+ options?: ReadOptions,
232
+ ): void {
233
+ const totalSamples = accumulator.signal.samplesPerRecord * run.count;
234
+ if (totalSamples === 0) return;
235
+
236
+ const decoded = decodeDigitalCounted(
237
+ header,
238
+ bytes,
239
+ slice,
240
+ accumulator.signal.index,
241
+ accumulator.scratch,
242
+ options,
243
+ );
244
+ // decodeDigitalCounted reuses the buffer when it is large enough, so the allocation happens
245
+ // once per signal per run rather than once per chunk.
246
+ accumulator.scratch = decoded.digital;
247
+ accumulator.outOfRange += decoded.outOfDigitalRangeCount;
248
+
249
+ const samples = decoded.digital;
250
+ // The buffer is reused across chunks, so it can be LONGER than this slice. The slice's own
251
+ // sample count is what must be folded, or the tail of a previous, larger chunk is counted again.
252
+ const sampleCount = accumulator.signal.samplesPerRecord * slice.count;
253
+ const { min, max, counts } = accumulator;
254
+ let position = accumulator.consumed;
255
+
256
+ for (let i = 0; i < sampleCount; i += 1) {
257
+ const value = samples[i] as number;
258
+ // Integer arithmetic on the run grid: floor(position * buckets / totalSamples).
259
+ const bucket = Math.min(bucketCount - 1, Math.floor((position * bucketCount) / totalSamples));
260
+ const seen = counts[bucket] as number;
261
+ if (seen === 0) {
262
+ min[bucket] = value;
263
+ max[bucket] = value;
264
+ } else {
265
+ if (value < (min[bucket] as number)) min[bucket] = value;
266
+ if (value > (max[bucket] as number)) max[bucket] = value;
267
+ }
268
+ counts[bucket] = seen + 1;
269
+ position += 1;
270
+ }
271
+
272
+ accumulator.consumed = position;
273
+ }
274
+
275
+ /**
276
+ * Converts a digital envelope to physical units.
277
+ *
278
+ * Not `toPhysical` applied twice, and the reason is the sign of the gain. The affine transform
279
+ * `bitValue * (offset + digital)` is DECREASING when `bitValue` is negative — a spec-sanctioned
280
+ * arrangement that edfcore reports rather than rejects — and a decreasing map sends the smallest
281
+ * digital value to the largest physical one. Mapping `min` to `min` would then produce an
282
+ * envelope whose lower bound is above its upper bound, and a viewer would draw it inside out.
283
+ */
284
+ export function toPhysicalEnvelope(
285
+ signal: EdfSignal,
286
+ envelope: EdfEnvelopeSignal,
287
+ ): EdfPhysicalEnvelope {
288
+ const scale = signal.scale;
289
+ if (scale === undefined) {
290
+ throw new EdfScalingError(
291
+ `signal ${signal.index} (${JSON.stringify(signal.label)}) has no usable scale, so its ` +
292
+ 'envelope has no physical units. Next: check signal.scale before converting, or plot ' +
293
+ 'the digital envelope as it is.',
294
+ { code: 'SCALE_UNAVAILABLE', signalIndex: signal.index, label: signal.label },
295
+ );
296
+ }
297
+
298
+ const length = envelope.min.length;
299
+ const low = new Float64Array(length);
300
+ const high = new Float64Array(length);
301
+ const decreasing = scale.bitValue < 0;
302
+
303
+ for (let i = 0; i < length; i += 1) {
304
+ const a = scale.bitValue * (scale.offset + (envelope.min[i] as number));
305
+ const b = scale.bitValue * (scale.offset + (envelope.max[i] as number));
306
+ low[i] = decreasing ? b : a;
307
+ high[i] = decreasing ? a : b;
308
+ }
309
+ return { min: low, max: high };
310
+ }
311
+
312
+ /**
313
+ * The envelope of an already-decoded chunk signal, without another read.
314
+ *
315
+ * For a caller who has samples in hand and wants them plotted: same reduction, same bucket rule,
316
+ * no I/O.
317
+ */
318
+ export function envelopeOfSamples(chunkSignal: EdfChunkSignal, buckets: number): EdfEnvelopeSignal {
319
+ assertPositiveInteger(buckets, 'buckets');
320
+ const samples = chunkSignal.digital;
321
+ const total = samples.length;
322
+ const bucketCount = Math.max(1, Math.min(buckets, total));
323
+
324
+ const min = new Int32Array(bucketCount);
325
+ const max = new Int32Array(bucketCount);
326
+ const counts = new Int32Array(bucketCount);
327
+
328
+ for (let i = 0; i < total; i += 1) {
329
+ const value = samples[i] as number;
330
+ const bucket = Math.min(bucketCount - 1, Math.floor((i * bucketCount) / total));
331
+ const seen = counts[bucket] as number;
332
+ if (seen === 0) {
333
+ min[bucket] = value;
334
+ max[bucket] = value;
335
+ } else {
336
+ if (value < (min[bucket] as number)) min[bucket] = value;
337
+ if (value > (max[bucket] as number)) max[bucket] = value;
338
+ }
339
+ counts[bucket] = seen + 1;
340
+ }
341
+
342
+ return {
343
+ signalIndex: chunkSignal.signalIndex,
344
+ min,
345
+ max,
346
+ counts,
347
+ sampleCount: total,
348
+ firstSampleIndex: chunkSignal.firstSampleIndex,
349
+ startSeconds: chunkSignal.startSeconds,
350
+ outOfDigitalRangeCount: chunkSignal.outOfDigitalRangeCount,
351
+ };
352
+ }
@@ -47,6 +47,9 @@ import { parsePatientId, parseRecordingId } from './identification.js';
47
47
  import { buildSignals, parseSignalHeaders, signalFieldOffset } from './signals.js';
48
48
  import { detectVariant } from './variant.js';
49
49
 
50
+ /** The largest value a `BigInt64Array` element holds: about 29,000 years of 100 ns ticks. */
51
+ const MAX_REPRESENTABLE_TICKS: bigint = 2n ** 63n - 1n;
52
+
50
53
  interface RecordCountResolution {
51
54
  readonly recordCount: number;
52
55
  readonly recordCountSource: EdfHeader['recordCountSource'];
@@ -333,6 +336,39 @@ export function parseHeader(
333
336
  sink,
334
337
  );
335
338
 
339
+ // ---- 9b. A declared span no tick count can hold. ----------------------------------------
340
+ //
341
+ // Every onset array in edfcore is a BigInt64Array, and assignment to one wraps modulo 2^64
342
+ // rather than throwing. Saturating keeps such an array non-decreasing, but it cannot make it
343
+ // meaningful: clamping collapses the spacing between records, so a file declaring a span past
344
+ // the tick range indexes as one segment per record either way. There is no honest onset to
345
+ // report, which is what makes this fatal rather than a warning.
346
+ //
347
+ // Unreachable for a real recording — the bound is over 29,000 years — but `recordDuration` is
348
+ // free-form ASCII and `parseEdfNumber` accepts exponent notation, so `1E30` in an 8-byte field
349
+ // is enough to ask for it.
350
+ const declaredSpanTicks = recordDurationTicks * BigInt(recordCount);
351
+ if (declaredSpanTicks > MAX_REPRESENTABLE_TICKS) {
352
+ throw fatalError({
353
+ code: 'RECORDING_SPAN_UNREPRESENTABLE',
354
+ message:
355
+ `the header declares ${recordCount} records of ${recordDurationSeconds} s, a total span ` +
356
+ `of ${declaredSpanTicks} ticks of 100 ns. edfcore stores every record onset as a signed ` +
357
+ `64-bit tick count, whose largest value is ${MAX_REPRESENTABLE_TICKS} — about 29,000 ` +
358
+ 'years — so the later onsets in this file have no representable value and every time it ' +
359
+ 'reported would be invented. EDF specification, header (duration of a data record). ' +
360
+ `Next: the record duration lives at offset ${HEADER_FIELDS.recordDuration.offset} and ` +
361
+ `is written as ${JSON.stringify(raw.recordDuration)}; header.raw keeps it as written.`,
362
+ field: 'recordDuration',
363
+ byteOffset: HEADER_FIELDS.recordDuration.offset,
364
+ byteLength: HEADER_FIELDS.recordDuration.length,
365
+ raw: raw.recordDuration,
366
+ expected: `a span of at most ${MAX_REPRESENTABLE_TICKS} ticks`,
367
+ actual: `${declaredSpanTicks} ticks`,
368
+ specReference: 'EDF specification, header (duration of a data record)',
369
+ });
370
+ }
371
+
336
372
  // ---- 10. EDF+ without an annotations signal has no per-record timing to report. ---------
337
373
  if (variant.isPlus && parsed.annotationSignalIndices.length === 0) {
338
374
  throw fatalError({
package/src/index.ts CHANGED
@@ -43,12 +43,15 @@ export type {
43
43
  EdfClockTime,
44
44
  EdfDiagnostic,
45
45
  EdfDiagnosticCode,
46
+ EdfEnvelopeChunk,
47
+ EdfEnvelopeSignal,
46
48
  EdfGap,
47
49
  EdfHeader,
48
50
  EdfInspection,
49
51
  EdfKnownDiagnosticCode,
50
52
  EdfLocation,
51
53
  EdfPatientId,
54
+ EdfPhysicalEnvelope,
52
55
  EdfRawHeaderFields,
53
56
  EdfRawSignalFields,
54
57
  EdfRecordIndex,
@@ -61,6 +64,7 @@ export type {
61
64
  EdfStartTime,
62
65
  EdfTimeline,
63
66
  EdfVariant,
67
+ EnvelopeSelection,
64
68
  FetchLike,
65
69
  HttpResponseLike,
66
70
  HttpSourceOptions,
@@ -147,5 +151,6 @@ export { buildRecordIndex, buildTimeline } from './record-index.js';
147
151
  // Convenience layer
148
152
  // ===========================================================================
149
153
 
154
+ export { envelopeOfSamples, readEnvelope, toPhysicalEnvelope } from './envelope.js';
150
155
  export { inspectEdf } from './inspect.js';
151
156
  export { openEdf, readAnnotations, readRecords, readWindow } from './recording.js';
@@ -27,10 +27,11 @@
27
27
  */
28
28
 
29
29
  import { DEFAULT_MAX_MATERIALIZE_BYTES } from './constants.js';
30
+ import { appendDiagnostics } from './diagnostics/collector.js';
30
31
  import { EdfRangeError } from './errors.js';
31
32
  import { readRecordBytes } from './io/read.js';
32
33
  import { decodeAnnotations } from './tal/annotations.js';
33
- import { secondsToTicks, ticksToSeconds } from './tal/ticks.js';
34
+ import { saturateToInt64, secondsToTicks, ticksToSeconds } from './tal/ticks.js';
34
35
  import { buildSegmentation } from './time/segments.js';
35
36
  import {
36
37
  assertMonotonicOnsetArray,
@@ -81,7 +82,13 @@ export function scanChunkRecords(header: EdfHeader, maxMaterializeBytes?: number
81
82
  * definition for a plain EDF or BDF file, where record onsets are not stored at all.
82
83
  */
83
84
  function nominalOnsetTicks(header: EdfHeader, recordIndex: number): bigint {
84
- return BigInt(recordIndex) * header.recordDurationTicks;
85
+ // Saturated, because every onset array here is a BigInt64Array and assignment to one wraps
86
+ // rather than throwing. A file with no annotation channel gets its onsets purely from this
87
+ // arithmetic, so an overflowing recordDuration produced an array that jumped backwards — read
88
+ // downstream as one segment per record with negative gaps, reported as a 'complete' index with
89
+ // no diagnostic at all. decodeAnnotations already saturated its own derived onsets; this path
90
+ // was the one that did not.
91
+ return saturateToInt64(BigInt(recordIndex) * header.recordDurationTicks);
85
92
  }
86
93
 
87
94
  /** True when the file stores per-record onsets, i.e. when probing can learn anything. */
@@ -280,7 +287,7 @@ export async function buildTimeline(
280
287
  // fake a discontinuity in a conforming file and make readWindow refuse every window in it.
281
288
  const probe = await probeOnset(source, header, recordIndex, probeOptions(options, memo.get(0)));
282
289
  memo.set(recordIndex, probe.ticks);
283
- probeDiagnostics.push(...probe.diagnostics);
290
+ appendDiagnostics(probeDiagnostics, probe.diagnostics);
284
291
  probes.push({ recordIndex, onsetTicks: probe.ticks });
285
292
  }
286
293
 
@@ -54,7 +54,7 @@ import {
54
54
  type TalIssueCode,
55
55
  type TalTextEncoding,
56
56
  } from './grammar.js';
57
- import { ticksToSeconds } from './ticks.js';
57
+ import { saturateToInt64, ticksToSeconds } from './ticks.js';
58
58
 
59
59
  const ANNOTATIONS_SPEC = "EDF+ specification 2.2 (the 'EDF Annotations' signal)";
60
60
  const TIMEKEEPING_SPEC = 'EDF+ specification 2.2.1 (time keeping of data records)';
@@ -133,20 +133,6 @@ interface ObservedOnset {
133
133
  readonly raw: string;
134
134
  }
135
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
136
  function describeRange(range: RecordRange): string {
151
137
  return `{ start: ${range.start}, count: ${range.count} }`;
152
138
  }
package/src/tal/ticks.ts CHANGED
@@ -150,6 +150,29 @@ export function ticksToSeconds(ticks: bigint): number {
150
150
  return Number(wholeSeconds) + Number(remainder) / TICKS_PER_SECOND_FLOAT;
151
151
  }
152
152
 
153
+ const INT64_MIN: bigint = -(2n ** 63n);
154
+ const INT64_MAX: bigint = 2n ** 63n - 1n;
155
+
156
+ /**
157
+ * Clamps a tick count to what a `BigInt64Array` element can hold.
158
+ *
159
+ * Assignment to a `BigInt64Array` wraps modulo 2^64 rather than throwing, and every onset array
160
+ * in edfcore is one. Wrapping turns a monotonically increasing series into one that jumps
161
+ * backwards, which downstream code reads as a genuine discontinuity: a file whose declared
162
+ * record duration overflows the range then indexes as one segment per record, with negative
163
+ * gaps between them and no diagnostic anywhere.
164
+ *
165
+ * Saturating keeps the array non-decreasing, so an absurd geometry stays visibly absurd instead
166
+ * of becoming plausibly wrong. Reaching either bound needs a declared geometry that is already
167
+ * impossible — over 29,000 years of records — but `recordDuration` is a free-form ASCII field
168
+ * that accepts exponent notation, so three bytes are enough to ask for it.
169
+ */
170
+ export function saturateToInt64(ticks: bigint): bigint {
171
+ if (ticks > INT64_MAX) return INT64_MAX;
172
+ if (ticks < INT64_MIN) return INT64_MIN;
173
+ return ticks;
174
+ }
175
+
153
176
  /**
154
177
  * Seconds to ticks, rounded to the NEAREST tick (ties away from zero is not required; ties go
155
178
  * toward +Infinity, as `Math.round` does).
package/src/types.ts CHANGED
@@ -367,6 +367,59 @@ export interface EdfChunk {
367
367
  readonly diagnostics: readonly EdfDiagnostic[];
368
368
  }
369
369
 
370
+ /**
371
+ * One signal's min/max envelope over a window, at a resolution the caller chose.
372
+ *
373
+ * The unit is the bucket, not the sample: `min[i]` and `max[i]` are the extremes of every sample
374
+ * that fell in bucket `i`. Drawing a twelve-hour recording into a thousand pixels needs exactly
375
+ * this and nothing else — the peaks are what a reader of an EEG trace is looking at, and they
376
+ * are the first thing naive subsampling throws away.
377
+ */
378
+ export interface EdfEnvelopeSignal {
379
+ readonly signalIndex: number;
380
+ /** Digital extremes per bucket. Convert with `toPhysicalEnvelope`, never with `toPhysical`. */
381
+ readonly min: Int32Array;
382
+ readonly max: Int32Array;
383
+ /** Samples that landed in each bucket. Zero where the window had no samples to cover it. */
384
+ readonly counts: Int32Array;
385
+ /** Total samples reduced, i.e. the sum of `counts`. */
386
+ readonly sampleCount: number;
387
+ readonly firstSampleIndex: number;
388
+ readonly startSeconds: number;
389
+ readonly outOfDigitalRangeCount: number;
390
+ }
391
+
392
+ /** A contiguous run of records, reduced to buckets. One per run, exactly as `readWindow` splits. */
393
+ export interface EdfEnvelopeChunk {
394
+ readonly records: RecordRange;
395
+ readonly startSeconds: number;
396
+ readonly durationSeconds: number;
397
+ /** Buckets actually filled. Never more than requested, and fewer for a short run. */
398
+ readonly bucketCount: number;
399
+ readonly secondsPerBucket: number;
400
+ readonly byteLength: number;
401
+ readonly signals: readonly EdfEnvelopeSignal[];
402
+ readonly precededByGap: EdfGap | undefined;
403
+ readonly diagnostics: readonly EdfDiagnostic[];
404
+ }
405
+
406
+ export interface EnvelopeSelection extends WindowSelection {
407
+ /**
408
+ * How many buckets to reduce the window into — in a viewer, the pixel width of the plot.
409
+ *
410
+ * A bucket per pixel is the point: asking for more buckets than the window has samples wastes
411
+ * work and yields empty buckets, so the count is clamped to the sample count of the densest
412
+ * signal in the run.
413
+ */
414
+ readonly buckets: number;
415
+ }
416
+
417
+ /** A physical-unit envelope. Separate from the digital one for the same reason `toPhysical` is. */
418
+ export interface EdfPhysicalEnvelope {
419
+ readonly min: Float64Array;
420
+ readonly max: Float64Array;
421
+ }
422
+
370
423
  export interface EdfAnnotation {
371
424
  /** Verbatim on-disk value, relative to the header startdate/starttime (EDF+ 2.2.4). */
372
425
  readonly onsetSecondsFromHeaderStart: number;
package/src/validate.ts CHANGED
@@ -21,7 +21,7 @@
21
21
  import { trimEdfField } from './bytes/latin1.js';
22
22
  import { DEFAULT_MAX_MATERIALIZE_BYTES, EDF_RECOMMENDED_MAX_RECORD_BYTES } from './constants.js';
23
23
  import { decodeDigitalCounted } from './decode/digital.js';
24
- import { createDiagnostic } from './diagnostics/collector.js';
24
+ import { appendDiagnostics, createDiagnostic } from './diagnostics/collector.js';
25
25
  import { EdfBudgetError } from './errors.js';
26
26
  import { calendarDatesEqual, formatCalendarDate, isValidCalendarDate } from './header/dates.js';
27
27
  import { signalFieldOffset } from './header/signals.js';
@@ -534,7 +534,7 @@ async function traverse(
534
534
  originTicks: recording.timeline.startOffsetTicks,
535
535
  });
536
536
  onsets.set(decoded.recordOnsetTicks, records.start);
537
- diagnostics.push(...decoded.diagnostics);
537
+ appendDiagnostics(diagnostics, decoded.diagnostics);
538
538
 
539
539
  for (const accumulator of accumulators) {
540
540
  const digital = decodeDigitalCounted(
@@ -604,7 +604,7 @@ export async function validateRecording(
604
604
  const mustReadOnsets = supplied === undefined && !onsetsAreArithmetic;
605
605
  const traversal =
606
606
  scanSamples || mustReadOnsets ? await traverse(recording, options, scanSamples) : undefined;
607
- if (traversal !== undefined) diagnostics.push(...traversal.diagnostics);
607
+ if (traversal !== undefined) appendDiagnostics(diagnostics, traversal.diagnostics);
608
608
 
609
609
  let segmentCount: number;
610
610
  let gaps: readonly EdfGap[];