edfcore 0.1.2 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/constants.d.ts +1 -1
- package/dist/constants.js +1 -1
- package/dist/io/cached.d.ts.map +1 -1
- package/dist/io/cached.js +3 -3
- package/dist/io/cached.js.map +1 -1
- package/dist/io/http.d.ts.map +1 -1
- package/dist/io/http.js +95 -22
- package/dist/io/http.js.map +1 -1
- package/dist/io/source.d.ts +24 -1
- package/dist/io/source.d.ts.map +1 -1
- package/dist/io/source.js +34 -1
- package/dist/io/source.js.map +1 -1
- package/dist/record-index.d.ts.map +1 -1
- package/dist/record-index.js +18 -4
- package/dist/record-index.js.map +1 -1
- package/dist/recording.d.ts.map +1 -1
- package/dist/recording.js +8 -1
- package/dist/recording.js.map +1 -1
- package/dist/tal/annotations.d.ts.map +1 -1
- package/dist/tal/annotations.js +7 -1
- package/dist/tal/annotations.js.map +1 -1
- package/dist/types.d.ts +15 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/validate.d.ts.map +1 -1
- package/dist/validate.js +28 -4
- package/dist/validate.js.map +1 -1
- package/package.json +1 -1
- package/src/constants.ts +1 -1
- package/src/io/cached.ts +9 -3
- package/src/io/http.ts +111 -24
- package/src/io/source.ts +41 -2
- package/src/record-index.ts +24 -5
- package/src/recording.ts +8 -1
- package/src/tal/annotations.ts +7 -1
- package/src/types.ts +15 -0
- package/src/validate.ts +32 -4
package/src/record-index.ts
CHANGED
|
@@ -256,8 +256,11 @@ export async function buildTimeline(
|
|
|
256
256
|
|
|
257
257
|
const probeOptions = (
|
|
258
258
|
readOptions: ReadOptions | undefined,
|
|
259
|
-
|
|
260
|
-
|
|
259
|
+
originTicks?: bigint,
|
|
260
|
+
): DecodeAnnotationsOptions & ReadOptions => {
|
|
261
|
+
const base = readOptions === undefined ? { strict } : { ...readOptions, strict };
|
|
262
|
+
return originTicks === undefined ? base : { ...base, originTicks };
|
|
263
|
+
};
|
|
261
264
|
|
|
262
265
|
const memo = new Map<number, bigint>();
|
|
263
266
|
const probes: RecordOnsetProbe[] = [];
|
|
@@ -271,7 +274,11 @@ export async function buildTimeline(
|
|
|
271
274
|
probes.push({ recordIndex, onsetTicks: ticks });
|
|
272
275
|
continue;
|
|
273
276
|
}
|
|
274
|
-
|
|
277
|
+
// Record 0 is probed first and defines the origin, so by the time the last record is probed
|
|
278
|
+
// its true onset is in `memo`. Without handing it over, a last record with no timekeeping TAL
|
|
279
|
+
// derived its onset from zero and appeared to sit `startOffset` seconds early — enough to
|
|
280
|
+
// fake a discontinuity in a conforming file and make readWindow refuse every window in it.
|
|
281
|
+
const probe = await probeOnset(source, header, recordIndex, probeOptions(options, memo.get(0)));
|
|
275
282
|
memo.set(recordIndex, probe.ticks);
|
|
276
283
|
probeDiagnostics.push(...probe.diagnostics);
|
|
277
284
|
probes.push({ recordIndex, onsetTicks: probe.ticks });
|
|
@@ -287,7 +294,12 @@ export async function buildTimeline(
|
|
|
287
294
|
memo.set(recordIndex, ticks);
|
|
288
295
|
return ticks;
|
|
289
296
|
}
|
|
290
|
-
const probe = await probeOnset(
|
|
297
|
+
const probe = await probeOnset(
|
|
298
|
+
source,
|
|
299
|
+
header,
|
|
300
|
+
recordIndex,
|
|
301
|
+
probeOptions(readOptions, timeline.startOffsetTicks),
|
|
302
|
+
);
|
|
291
303
|
memo.set(recordIndex, probe.ticks);
|
|
292
304
|
return probe.ticks;
|
|
293
305
|
}
|
|
@@ -339,7 +351,14 @@ async function scanOnsets(
|
|
|
339
351
|
count: Math.min(chunkRecords, recordCount - scanned),
|
|
340
352
|
};
|
|
341
353
|
const bytes = await readRecordBytes(source, header, records, options);
|
|
342
|
-
|
|
354
|
+
// The origin comes from the recording, not from whatever this chunk happens to contain.
|
|
355
|
+
// Chunking is a memory-bounding detail and must not change the answer: without this, a chunk
|
|
356
|
+
// holding no observed onset derived from zero, so the onsets, the segments, the gaps and
|
|
357
|
+
// even a fatal TIMELINE_NOT_MONOTONIC varied with maxMaterializeBytes.
|
|
358
|
+
const decoded = decodeAnnotations(header, bytes, records, {
|
|
359
|
+
...options,
|
|
360
|
+
originTicks: recording.timeline.startOffsetTicks,
|
|
361
|
+
});
|
|
343
362
|
onsets.set(decoded.recordOnsetTicks, scanned);
|
|
344
363
|
scanned += records.count;
|
|
345
364
|
options?.onProgress?.(scanned, recordCount);
|
package/src/recording.ts
CHANGED
|
@@ -135,7 +135,14 @@ async function readChunk(
|
|
|
135
135
|
// Never strict, and not because the flag was lost: a read that threw on an impolite TAL in a
|
|
136
136
|
// record the caller asked for would return no samples at all over a defect in a different
|
|
137
137
|
// channel. The defects land on `chunk.diagnostics`, next to the data they were found beside.
|
|
138
|
-
|
|
138
|
+
// `originTicks` is the recording's own start, not this range's. It is already known here — the
|
|
139
|
+
// next few lines rebase against it — and without passing it down, a first record whose
|
|
140
|
+
// timekeeping TAL is missing derived its onset from zero. The same record then reported one
|
|
141
|
+
// start time when read alone and another when read beside a neighbour that did carry a TAL,
|
|
142
|
+
// and that start is the grid origin trimToWindow measures from.
|
|
143
|
+
const annotations = decodeAnnotations(header, bytes, records, {
|
|
144
|
+
originTicks: timeline.startOffsetTicks,
|
|
145
|
+
});
|
|
139
146
|
const onsets = annotations.recordOnsetTicks;
|
|
140
147
|
assertMonotonicOnsetArray(onsets, records.start);
|
|
141
148
|
|
package/src/tal/annotations.ts
CHANGED
|
@@ -445,9 +445,15 @@ export function decodeAnnotations(
|
|
|
445
445
|
|
|
446
446
|
// Record 0's onset, observed when it was decoded and derived from the first record that was
|
|
447
447
|
// otherwise. For a continuous file the derivation is exact; see the rebasing note below.
|
|
448
|
+
//
|
|
449
|
+
// With no observed onset anywhere in this range there is nothing local to derive from, and the
|
|
450
|
+
// origin has to come from the caller. Falling back to zero instead made the result depend on
|
|
451
|
+
// the range: the same record got one onset when read alone and another when read alongside a
|
|
452
|
+
// neighbour that did carry a timekeeping TAL, which in turn made chunk boundaries, segment
|
|
453
|
+
// boundaries and even a fatal TIMELINE_NOT_MONOTONIC a function of the scan chunk size.
|
|
448
454
|
const baseTicks =
|
|
449
455
|
firstObserved === undefined
|
|
450
|
-
? 0n
|
|
456
|
+
? (options?.originTicks ?? 0n)
|
|
451
457
|
: firstObserved.ticks - BigInt(firstObserved.recordIndex) * durationTicks;
|
|
452
458
|
|
|
453
459
|
const recordOnsetTicks = new BigInt64Array(records.count);
|
package/src/types.ts
CHANGED
|
@@ -449,6 +449,21 @@ export interface WindowSelection {
|
|
|
449
449
|
export interface DecodeAnnotationsOptions extends ParseOptions {
|
|
450
450
|
/** Defaults to every annotation signal. Only the first carries timekeeping. */
|
|
451
451
|
readonly signalIndices?: readonly number[];
|
|
452
|
+
/**
|
|
453
|
+
* The recording's own time origin, for deriving the onset of a record whose timekeeping TAL
|
|
454
|
+
* is missing.
|
|
455
|
+
*
|
|
456
|
+
* A missing timekeeping TAL is a warning, not a fatal error, and such a record is documented
|
|
457
|
+
* to get `start + recordIndex * recordDuration`. That `start` can only be known by a caller
|
|
458
|
+
* who has already seen record 0. Without it the derivation falls back to an origin of zero,
|
|
459
|
+
* which is right only for a file whose first record starts at zero — and makes the answer
|
|
460
|
+
* depend on which records happened to share the call, because a range containing no observed
|
|
461
|
+
* onset at all gets a different origin from one that contains one.
|
|
462
|
+
*
|
|
463
|
+
* Pass `timeline.startOffsetTicks` whenever it is known. Decoding a range in isolation, as
|
|
464
|
+
* `openEdf` does before any timeline exists, correctly omits it.
|
|
465
|
+
*/
|
|
466
|
+
readonly originTicks?: bigint;
|
|
452
467
|
}
|
|
453
468
|
|
|
454
469
|
/** Header-only triage. Reads at most 128 KiB and never throws on malformed content. */
|
package/src/validate.ts
CHANGED
|
@@ -19,9 +19,10 @@
|
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
21
|
import { trimEdfField } from './bytes/latin1.js';
|
|
22
|
-
import { EDF_RECOMMENDED_MAX_RECORD_BYTES } from './constants.js';
|
|
22
|
+
import { DEFAULT_MAX_MATERIALIZE_BYTES, EDF_RECOMMENDED_MAX_RECORD_BYTES } from './constants.js';
|
|
23
23
|
import { decodeDigitalCounted } from './decode/digital.js';
|
|
24
24
|
import { createDiagnostic } from './diagnostics/collector.js';
|
|
25
|
+
import { EdfBudgetError } from './errors.js';
|
|
25
26
|
import { calendarDatesEqual, formatCalendarDate, isValidCalendarDate } from './header/dates.js';
|
|
26
27
|
import { signalFieldOffset } from './header/signals.js';
|
|
27
28
|
import { readRecordBytes } from './io/read.js';
|
|
@@ -55,6 +56,9 @@ export type { ObservedSignalStats, ValidateOptions, ValidationReport } from './t
|
|
|
55
56
|
const LABEL_SPEC = 'EDF+ additional specification 9 (standard texts and labels)';
|
|
56
57
|
const TIMEKEEPING_SPEC = 'EDF+ specification 2.2.1 (time keeping of data records)';
|
|
57
58
|
|
|
59
|
+
/** The sample-scan scratch buffer is an `Int32Array`, so four bytes per sample. */
|
|
60
|
+
const BYTES_PER_SCRATCH_SAMPLE = 4;
|
|
61
|
+
|
|
58
62
|
/**
|
|
59
63
|
* The signal types EDF+ additional specification 9 names.
|
|
60
64
|
*
|
|
@@ -479,8 +483,17 @@ async function traverse(
|
|
|
479
483
|
: [];
|
|
480
484
|
|
|
481
485
|
const chunkRecords = scanChunkRecords(header, options?.maxMaterializeBytes);
|
|
482
|
-
|
|
483
|
-
|
|
486
|
+
/*
|
|
487
|
+
* One scratch array for every signal and every chunk.
|
|
488
|
+
*
|
|
489
|
+
* This is normally bounded by the chunk's own byte size, because `scanChunkRecords` fits the
|
|
490
|
+
* chunk into a scan block. That bound fails for a single record larger than the whole block:
|
|
491
|
+
* the record count floors to 1 and the scratch size becomes `samplesPerRecord` unclamped, up
|
|
492
|
+
* to the 99,999,999 an 8-byte EDF field can hold — a 400 MB allocation reachable from one
|
|
493
|
+
* corrupted digit in a 512-byte file, made before any read, so no downstream check can catch
|
|
494
|
+
* it. Validation exists to be pointed at untrusted files, so it is exactly the caller who
|
|
495
|
+
* needs the budget honoured rather than a documented cap that only the read path respects.
|
|
496
|
+
*/
|
|
484
497
|
let scratch: Int32Array | undefined;
|
|
485
498
|
if (scanSamples) {
|
|
486
499
|
let maxSamplesPerRecord = 0;
|
|
@@ -490,6 +503,18 @@ async function traverse(
|
|
|
490
503
|
maxSamplesPerRecord = Math.max(maxSamplesPerRecord, signal.samplesPerRecord);
|
|
491
504
|
}
|
|
492
505
|
}
|
|
506
|
+
const scratchBytes = chunkRecords * maxSamplesPerRecord * BYTES_PER_SCRATCH_SAMPLE;
|
|
507
|
+
const budgetBytes = options?.maxMaterializeBytes ?? DEFAULT_MAX_MATERIALIZE_BYTES;
|
|
508
|
+
if (scratchBytes > budgetBytes) {
|
|
509
|
+
throw new EdfBudgetError(
|
|
510
|
+
`Scanning samples needs a ${scratchBytes}-byte scratch buffer for ${chunkRecords} ` +
|
|
511
|
+
`record(s) of up to ${maxSamplesPerRecord} samples, above the ${budgetBytes}-byte ` +
|
|
512
|
+
'maxMaterializeBytes budget, so the scan was refused before anything was allocated. ' +
|
|
513
|
+
'Next: raise options.maxMaterializeBytes, or drop scanSamples and validate the ' +
|
|
514
|
+
'header alone.',
|
|
515
|
+
{ requiredBytes: scratchBytes, budgetBytes },
|
|
516
|
+
);
|
|
517
|
+
}
|
|
493
518
|
scratch = new Int32Array(chunkRecords * maxSamplesPerRecord);
|
|
494
519
|
}
|
|
495
520
|
|
|
@@ -504,7 +529,10 @@ async function traverse(
|
|
|
504
529
|
bytesRead += bytes.length;
|
|
505
530
|
|
|
506
531
|
// Never strict: a sweep whose job is to list every defect must not stop at the first one.
|
|
507
|
-
|
|
532
|
+
// The origin is the recording's, so the sweep's verdict does not depend on its chunk size.
|
|
533
|
+
const decoded = decodeAnnotations(header, bytes, records, {
|
|
534
|
+
originTicks: recording.timeline.startOffsetTicks,
|
|
535
|
+
});
|
|
508
536
|
onsets.set(decoded.recordOnsetTicks, records.start);
|
|
509
537
|
diagnostics.push(...decoded.diagnostics);
|
|
510
538
|
|