edfcore 0.2.66 → 0.2.68

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/CHANGELOG.md CHANGED
@@ -6,6 +6,53 @@ alone does not tell you whether you were affected.
6
6
  edfcore is pre-1.0. Patch releases have carried behaviour changes where the old behaviour was a
7
7
  defect; those are called out below.
8
8
 
9
+ ## 0.2.68
10
+
11
+ Two defects in `sample-locate.ts`, both introduced by me in 0.2.61 and found by an adversarial
12
+ sweep of it seven releases later.
13
+
14
+ - **Fixed** `sampleAt` consulting the net-drift check before the scanned index. `spanSeconds !==
15
+ coveredSeconds` is what TWO PROBES can see, and this project's own documentation says three
16
+ times that it is not a proof of contiguity: a gap that an overlap elsewhere cancels exactly
17
+ leaves span equal to coverage. On such a file — which opens with no diagnostic at all —
18
+ `sampleAt` took the nominal branch while a complete index sat on the same object reporting two
19
+ gaps, and returned a sample one whole record away from the one `readWindow` reads. It also
20
+ reported a sample inside a hole that `gapAt`, `segmentAt`, `index.locate` and `readWindow` all
21
+ report as empty. `resolveTimeWindow` has always had this precedence right; this module inverted
22
+ it. **The seventh instance of the defect this project has spent six releases on, and the first
23
+ one I introduced myself.**
24
+ - **Fixed** the discontinuous branch being unbounded. `segmentAt` compares float seconds and the
25
+ arithmetic after it compares exact ticks, so a time within half a tick of a segment end is inside
26
+ the segment for one and past it for the other: it named record 6 and sample 24 of a six-record,
27
+ 24-sample file. `sampleStartTicksOf` had the matching hole — a sample index past the end fell
28
+ through to the nominal grid and came back 6.75 s EARLIER than the last real sample. Both are now
29
+ bounded, and the second refuses rather than answering.
30
+ - **Corrected** an over-claim in the 0.2.61 entry. It said the round-trip "the sample at a sample's
31
+ start is that sample" was pinned "for every sample in the file". That is false when two records
32
+ cover the same instant — repeated onsets, which EDF+ does not forbid — because two samples exist
33
+ at that time and no function can return both. The claim holds for files whose records do not
34
+ overlap. Now stated in the source and pinned by a test that uses a genuinely overlapping fixture.
35
+
36
+ ## 0.2.67
37
+
38
+ Three defects in `formatHeader`, found by an adversarial sweep of the modules no earlier pass had
39
+ covered.
40
+
41
+ - **Fixed** the duration line losing a whole second. It computed `recordCount *
42
+ recordDurationSeconds` in float64 and truncated, and a record duration with no exact binary form
43
+ makes that product land just under the true value: 100 records of 0.29 s is exactly 29 s,
44
+ computes as 28.999999999999996, and printed `00:00:28`. It is now computed from
45
+ `recordDurationTicks`, which is exact. A genuine fraction still truncates rather than rounding —
46
+ 7 × 0.7 s is 4.9 and prints `00:00:04`, because rounding would name a time the file never reaches.
47
+ - **Fixed** control characters in a signal label being printed verbatim. EDF pads labels with
48
+ spaces and says nothing about what else may be in them, so a writer can put a newline there and
49
+ the label renders as TWO rows — forging a signal the file does not contain — while a tab shifts
50
+ every column after it. They are replaced with a dot in the rendering only; `signal.raw.label`
51
+ still holds the bytes.
52
+ - **Fixed** the diagnostic severity summary being ordered by arrival, so two files with the same
53
+ diagnostics could summarise them differently. Now error-warning-info, matching
54
+ `formatValidationReport` since 0.2.15 and sharing its counting.
55
+
9
56
  ## 0.2.66
10
57
 
11
58
  - **Fixed** the changelog numbering, which had drifted a second time, and **fixed the cause** so it
@@ -109,5 +109,5 @@ export declare const SIGNAL_FIELD_BLOCK_OFFSETS: {
109
109
  readonly reserved: 224;
110
110
  };
111
111
  /** Published package version. Kept in sync with package.json by a test. */
112
- export declare const VERSION = "0.2.66";
112
+ export declare const VERSION = "0.2.68";
113
113
  //# sourceMappingURL=constants.d.ts.map
package/dist/constants.js CHANGED
@@ -79,5 +79,5 @@ export const SIGNAL_FIELD_BLOCK_OFFSETS = {
79
79
  reserved: 224,
80
80
  };
81
81
  /** Published package version. Kept in sync with package.json by a test. */
82
- export const VERSION = '0.2.66';
82
+ export const VERSION = '0.2.68';
83
83
  //# sourceMappingURL=constants.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"format-header.d.ts","sourceRoot":"","sources":["../src/format-header.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,OAAO,KAAK,EAAmB,SAAS,EAAE,mBAAmB,EAAE,MAAM,YAAY,CAAC;AAwBlF;;;;;;GAMG;AACH,wBAAgB,YAAY,CAAC,MAAM,EAAE,SAAS,EAAE,OAAO,CAAC,EAAE,mBAAmB,GAAG,MAAM,CA0DrF"}
1
+ {"version":3,"file":"format-header.d.ts","sourceRoot":"","sources":["../src/format-header.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAIH,OAAO,KAAK,EAAmB,SAAS,EAAE,mBAAmB,EAAE,MAAM,YAAY,CAAC;AA0ClF;;;;;;GAMG;AACH,wBAAgB,YAAY,CAAC,MAAM,EAAE,SAAS,EAAE,OAAO,CAAC,EAAE,mBAAmB,GAAG,MAAM,CAsErF"}
@@ -11,6 +11,8 @@
11
11
  * identification unless asked — a header carries a name and a birth date, and a summary that
12
12
  * lands in a chat log or an issue tracker should not carry them by default.
13
13
  */
14
+ import { TICKS_PER_SECOND } from './constants.js';
15
+ import { summarizeDiagnostics } from './diagnostics/summary.js';
14
16
  function formatDate(date) {
15
17
  if (date === undefined)
16
18
  return 'unknown';
@@ -18,16 +20,33 @@ function formatDate(date) {
18
20
  const day = String(date.day).padStart(2, '0');
19
21
  return `${date.year}-${month}-${day}`;
20
22
  }
21
- function formatDuration(seconds) {
22
- if (!Number.isFinite(seconds) || seconds < 0)
23
+ /**
24
+ * `hh:mm:ss` from an exact tick count.
25
+ *
26
+ * Ticks, not `recordCount * recordDurationSeconds`. That product is float64, and a record duration
27
+ * with no exact binary representation makes it land just under the true value: 100 records of
28
+ * 0.29 s is exactly 29 s and computes as 28.999999999999996, which floors to 28. The header line
29
+ * then reports a recording a whole second shorter than it is (fixed in 0.2.67).
30
+ */
31
+ function formatDurationTicks(ticks) {
32
+ if (ticks < 0n)
23
33
  return 'unknown';
24
- const whole = Math.floor(seconds);
34
+ const whole = Number(ticks / TICKS_PER_SECOND);
25
35
  const hours = Math.floor(whole / 3600);
26
36
  const minutes = Math.floor((whole % 3600) / 60);
27
37
  const rest = whole % 60;
28
38
  const pad = (n) => String(n).padStart(2, '0');
29
39
  return `${pad(hours)}:${pad(minutes)}:${pad(rest)}`;
30
40
  }
41
+ /** Control characters become a dot, so one field can never become two rows or shift a column. */
42
+ function printable(text) {
43
+ let out = '';
44
+ for (const character of text) {
45
+ const code = character.codePointAt(0) ?? 0;
46
+ out += code < 0x20 || code === 0x7f ? '.' : character;
47
+ }
48
+ return out;
49
+ }
31
50
  function formatRate(signal) {
32
51
  // undefined is the honest answer for a zero record duration, which is legal EDF.
33
52
  return signal.sampleRateHz === undefined ? '—' : `${signal.sampleRateHz} Hz`;
@@ -48,7 +67,7 @@ export function formatHeader(header, options) {
48
67
  ` (local, no timezone)`);
49
68
  lines.push(`record ${header.recordDurationSeconds} s · ${header.recordByteLength} bytes · ` +
50
69
  `${header.bytesPerSample} bytes/sample`);
51
- lines.push(`duration ${formatDuration(header.recordCount * header.recordDurationSeconds)} ` +
70
+ lines.push(`duration ${formatDurationTicks(header.recordDurationTicks * BigInt(header.recordCount))} ` +
52
71
  `(${header.recordCount} × ${header.recordDurationSeconds} s)`);
53
72
  if (header.recordCountSource === 'sourceByteLength') {
54
73
  // Worth saying out loud: the count came from the file size, not from the header field.
@@ -62,7 +81,11 @@ export function formatHeader(header, options) {
62
81
  lines.push(' # label kind rate range');
63
82
  for (const signal of header.signals) {
64
83
  const index = String(signal.index).padStart(3);
65
- const label = signal.label.slice(0, 20).padEnd(21);
84
+ // Control characters are replaced, not printed. A label holding a newline would otherwise
85
+ // render as two rows and forge a signal the file does not contain; a tab would shift every
86
+ // column after it. EDF pads labels with spaces and says nothing about what else may be in
87
+ // them, so a writer can put anything there and a reader must not be steered by it.
88
+ const label = printable(signal.label).slice(0, 20).padEnd(21);
66
89
  const kind = signal.kind.padEnd(12);
67
90
  const rate = formatRate(signal).padEnd(9);
68
91
  const range = signal.kind === 'annotations'
@@ -74,11 +97,17 @@ export function formatHeader(header, options) {
74
97
  }
75
98
  if (header.diagnostics.length > 0) {
76
99
  lines.push('');
77
- const counts = new Map();
78
- for (const diagnostic of header.diagnostics) {
79
- counts.set(diagnostic.severity, (counts.get(diagnostic.severity) ?? 0) + 1);
80
- }
81
- const summary = [...counts].map(([severity, count]) => `${count} ${severity}`).join(', ');
100
+ // Fixed error-warning-info order, matching `formatValidationReport` since 0.2.15. Ordering by
101
+ // arrival meant two files with the same diagnostics could summarise them differently.
102
+ const counted = summarizeDiagnostics(header.diagnostics);
103
+ const summary = [
104
+ [counted.errors, 'error'],
105
+ [counted.warnings, 'warning'],
106
+ [counted.infos, 'info'],
107
+ ]
108
+ .filter(([count]) => count > 0)
109
+ .map(([count, severity]) => `${count} ${severity}`)
110
+ .join(', ');
82
111
  lines.push(`${header.diagnostics.length} diagnostic(s): ${summary}`);
83
112
  lines.push('Call formatDiagnostics(header.diagnostics) for the detail.');
84
113
  }
@@ -1 +1 @@
1
- {"version":3,"file":"format-header.js","sourceRoot":"","sources":["../src/format-header.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"format-header.js","sourceRoot":"","sources":["../src/format-header.ts"],"names":[],"mappings":"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"}
@@ -16,6 +16,14 @@
16
16
  * `segmentAt` does: `undefined` from `sampleAt` means "no sample exists here", and an index that
17
17
  * has read record 0 and the last record cannot say that about anything in between. Merging "there
18
18
  * is a gap here" with "nobody looked" is the confusion this whole area of the API avoids.
19
+ *
20
+ * ONE LIMIT, and it belongs to the file rather than to these functions. If two records cover the
21
+ * same instant — a timeline whose onsets repeat, which EDF+ does not forbid and which edfcore
22
+ * reports without a diagnostic — then more than one sample exists at that time and no function can
23
+ * return both. `sampleAt` returns the one whose segment `segmentAt` finds. The round-trip
24
+ * "the sample at a sample's start is that sample" therefore holds for files whose records do not
25
+ * overlap, which is every file anyone is likely to have; it is not a universal law and 0.2.60
26
+ * claimed it as one.
19
27
  */
20
28
  import type { EdfRecording, EdfSampleLocation } from './types.js';
21
29
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"sample-locate.d.ts","sourceRoot":"","sources":["../src/sample-locate.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAKH,OAAO,KAAK,EAAE,YAAY,EAAE,iBAAiB,EAAyB,MAAM,YAAY,CAAC;AAkFzF;;;;;;;;;;;GAWG;AACH,wBAAgB,QAAQ,CACtB,SAAS,EAAE,YAAY,EACvB,WAAW,EAAE,MAAM,EACnB,OAAO,EAAE,MAAM,GACd,iBAAiB,GAAG,SAAS,CAqC/B;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,kBAAkB,CAChC,SAAS,EAAE,YAAY,EACvB,WAAW,EAAE,MAAM,EACnB,WAAW,EAAE,MAAM,GAClB,MAAM,CA0BR;AAED,wFAAwF;AACxF,wBAAgB,oBAAoB,CAClC,SAAS,EAAE,YAAY,EACvB,WAAW,EAAE,MAAM,EACnB,WAAW,EAAE,MAAM,GAClB,MAAM,CAER"}
1
+ {"version":3,"file":"sample-locate.d.ts","sourceRoot":"","sources":["../src/sample-locate.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AAKH,OAAO,KAAK,EAAE,YAAY,EAAE,iBAAiB,EAAyB,MAAM,YAAY,CAAC;AAwFzF;;;;;;;;;;;GAWG;AACH,wBAAgB,QAAQ,CACtB,SAAS,EAAE,YAAY,EACvB,WAAW,EAAE,MAAM,EACnB,OAAO,EAAE,MAAM,GACd,iBAAiB,GAAG,SAAS,CAyD/B;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,kBAAkB,CAChC,SAAS,EAAE,YAAY,EACvB,WAAW,EAAE,MAAM,EACnB,WAAW,EAAE,MAAM,GAClB,MAAM,CAsCR;AAED,wFAAwF;AACxF,wBAAgB,oBAAoB,CAClC,SAAS,EAAE,YAAY,EACvB,WAAW,EAAE,MAAM,EACnB,WAAW,EAAE,MAAM,GAClB,MAAM,CAER"}
@@ -16,6 +16,14 @@
16
16
  * `segmentAt` does: `undefined` from `sampleAt` means "no sample exists here", and an index that
17
17
  * has read record 0 and the last record cannot say that about anything in between. Merging "there
18
18
  * is a gap here" with "nobody looked" is the confusion this whole area of the API avoids.
19
+ *
20
+ * ONE LIMIT, and it belongs to the file rather than to these functions. If two records cover the
21
+ * same instant — a timeline whose onsets repeat, which EDF+ does not forbid and which edfcore
22
+ * reports without a diagnostic — then more than one sample exists at that time and no function can
23
+ * return both. `sampleAt` returns the one whose segment `segmentAt` finds. The round-trip
24
+ * "the sample at a sample's start is that sample" therefore holds for files whose records do not
25
+ * overlap, which is every file anyone is likely to have; it is not a universal law and 0.2.60
26
+ * claimed it as one.
19
27
  */
20
28
  import { EdfChannelNotFoundError } from './errors.js';
21
29
  import { segmentAt } from './record-index.js';
@@ -77,12 +85,18 @@ function segmentOfRecord(recording, recordIndex) {
77
85
  return undefined;
78
86
  }
79
87
  /**
80
- * Whether this recording needs a scanned index before a time can be located at all.
88
+ * Whether a PROBED recording needs a scan before a time can be located.
89
+ *
90
+ * This is a statement about a probed index and nothing else. It compares net drift — span against
91
+ * coverage — which is what two probes can see, and edfcore's own documentation says three times
92
+ * that net drift is not a proof of contiguity: a gap that an overlap elsewhere cancels exactly
93
+ * leaves span equal to coverage and is still a gap.
81
94
  *
82
- * A file whose records cover exactly its span is contiguous, and the nominal grid IS the true one
83
- * so a probed index is enough and no scan is demanded of the caller for nothing.
95
+ * So it must never be consulted while a COMPLETE index is available; `resolveTimeWindow` gets that
96
+ * precedence right and this module originally did not, which made `sampleAt` take the nominal
97
+ * branch on a file whose scanned index reported gaps (fixed in 0.2.68).
84
98
  */
85
- function requiresScan(recording) {
99
+ function probedIndexNeedsScan(recording) {
86
100
  return recording.timeline.spanSeconds !== recording.timeline.coveredSeconds;
87
101
  }
88
102
  /**
@@ -105,12 +119,27 @@ export function sampleAt(recording, signalIndex, seconds) {
105
119
  const duration = recording.header.recordDurationTicks;
106
120
  const perRecord = BigInt(signal.samplesPerRecord);
107
121
  const ticks = secondsToTicks(seconds);
108
- if (!requiresScan(recording)) {
109
- // Contiguous: the nominal grid is the true one, but the answer is still bounded by the file.
110
- const recordIndex = Number(floorDiv(ticks, duration));
111
- if (recordIndex < 0 || recordIndex >= recording.header.recordCount)
122
+ // A SCANNED index takes precedence over any statement about net drift, the same order
123
+ // `resolveTimeWindow` uses. Asking `probedIndexNeedsScan` first meant a file whose gap and
124
+ // overlap cancel — span equal to coverage, no diagnostic on open — took the nominal branch while
125
+ // a complete index sat on the same object reporting two gaps.
126
+ if (recording.index.segments !== undefined) {
127
+ // `segmentAt` owns "is there data here at all" and throws for a probed index rather than
128
+ // guessing; it cannot be reached with one here, because segments only exist on a scanned index.
129
+ const segment = segmentAt(recording.index, seconds);
130
+ if (segment === undefined)
131
+ return undefined;
132
+ const offsetTicks = ticks - segment.startTicks;
133
+ const recordOffset = floorDiv(offsetTicks, duration);
134
+ // Bounded by the SEGMENT, as the nominal branch is bounded by the record count. `segmentAt`
135
+ // compares float seconds while this compares exact ticks, and `secondsToTicks` rounds to the
136
+ // nearest tick — so a time within half a tick of `segment.endSeconds` is inside the segment
137
+ // for one and at its end for the other, and without this it walked into the next segment or
138
+ // off the end of the file.
139
+ if (recordOffset < 0n || recordOffset >= BigInt(segment.records.count))
112
140
  return undefined;
113
- const withinTicks = ticks - BigInt(recordIndex) * duration;
141
+ const recordIndex = segment.records.start + Number(recordOffset);
142
+ const withinTicks = offsetTicks - recordOffset * duration;
114
143
  const sampleWithinRecord = Number(floorDiv(withinTicks * perRecord, duration));
115
144
  return {
116
145
  sampleIndex: recordIndex * signal.samplesPerRecord + sampleWithinRecord,
@@ -118,14 +147,16 @@ export function sampleAt(recording, signalIndex, seconds) {
118
147
  sampleWithinRecord,
119
148
  };
120
149
  }
121
- // Discontinuous: `segmentAt` owns the "is there data here at all" question, and throws for a
122
- // probed index rather than guessing.
123
- const segment = segmentAt(recording.index, seconds);
124
- if (segment === undefined)
150
+ if (probedIndexNeedsScan(recording)) {
151
+ throw new RangeError('sampleAt(): this file has gaps and its index has not been scanned, so which records cover ' +
152
+ 'a time is not known. Next: await buildRecordIndex(recording) and read the result into ' +
153
+ 'the recording.');
154
+ }
155
+ // Contiguous, probed: the nominal grid is the true one, and the answer is bounded by the file.
156
+ const recordIndex = Number(floorDiv(ticks, duration));
157
+ if (recordIndex < 0 || recordIndex >= recording.header.recordCount)
125
158
  return undefined;
126
- const offsetTicks = ticks - segment.startTicks;
127
- const recordIndex = segment.records.start + Number(floorDiv(offsetTicks, duration));
128
- const withinTicks = offsetTicks - floorDiv(offsetTicks, duration) * duration;
159
+ const withinTicks = ticks - BigInt(recordIndex) * duration;
129
160
  const sampleWithinRecord = Number(floorDiv(withinTicks * perRecord, duration));
130
161
  return {
131
162
  sampleIndex: recordIndex * signal.samplesPerRecord + sampleWithinRecord,
@@ -154,7 +185,16 @@ export function sampleStartTicksOf(recording, signalIndex, sampleIndex) {
154
185
  const duration = recording.header.recordDurationTicks;
155
186
  const recordIndex = Math.floor(sampleIndex / perRecord);
156
187
  const within = BigInt(sampleIndex - recordIndex * perRecord);
157
- if (requiresScan(recording) && recording.index.segments === undefined) {
188
+ // Bounded by the file. An index past the end used to fall through `segmentOfRecord` to the
189
+ // nominal grid and come back EARLIER than the last real sample — on a file with a 7 s gap,
190
+ // sample 24 of a 24-sample file reported 6.75 s before sample 23. Refusing is the only honest
191
+ // answer for a sample that does not exist.
192
+ if (sampleIndex < 0 || recordIndex >= recording.header.recordCount) {
193
+ throw new RangeError(`sampleStartTicksOf(): sample ${sampleIndex} is outside the ` +
194
+ `${recording.header.recordCount * perRecord} samples signal ${signalIndex} has. ` +
195
+ 'Next: clamp the index, or read signal.sampleCount first.');
196
+ }
197
+ if (recording.index.segments === undefined && probedIndexNeedsScan(recording)) {
158
198
  throw new RangeError('sampleStartTicksOf(): this file has gaps and its index has not been scanned, so the true ' +
159
199
  'start of a record after a gap is not known. Next: await buildRecordIndex(recording) and ' +
160
200
  'read the result into the recording.');
@@ -1 +1 @@
1
- {"version":3,"file":"sample-locate.js","sourceRoot":"","sources":["../src/sample-locate.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"sample-locate.js","sourceRoot":"","sources":["../src/sample-locate.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "edfcore",
3
- "version": "0.2.66",
3
+ "version": "0.2.68",
4
4
  "description": "Modern, typed, zero-dependency reader for EDF, EDF+, BDF and BDF+ biosignal files. Works in browsers and Node with true random access.",
5
5
  "keywords": [
6
6
  "edf",
package/src/constants.ts CHANGED
@@ -93,4 +93,4 @@ export const SIGNAL_FIELD_BLOCK_OFFSETS = {
93
93
  } as const;
94
94
 
95
95
  /** Published package version. Kept in sync with package.json by a test. */
96
- export const VERSION = '0.2.66';
96
+ export const VERSION = '0.2.68';
@@ -12,6 +12,8 @@
12
12
  * lands in a chat log or an issue tracker should not carry them by default.
13
13
  */
14
14
 
15
+ import { TICKS_PER_SECOND } from './constants.js';
16
+ import { summarizeDiagnostics } from './diagnostics/summary.js';
15
17
  import type { EdfCalendarDate, EdfHeader, FormatHeaderOptions } from './types.js';
16
18
 
17
19
  function formatDate(date: EdfCalendarDate | undefined): string {
@@ -21,9 +23,17 @@ function formatDate(date: EdfCalendarDate | undefined): string {
21
23
  return `${date.year}-${month}-${day}`;
22
24
  }
23
25
 
24
- function formatDuration(seconds: number): string {
25
- if (!Number.isFinite(seconds) || seconds < 0) return 'unknown';
26
- const whole = Math.floor(seconds);
26
+ /**
27
+ * `hh:mm:ss` from an exact tick count.
28
+ *
29
+ * Ticks, not `recordCount * recordDurationSeconds`. That product is float64, and a record duration
30
+ * with no exact binary representation makes it land just under the true value: 100 records of
31
+ * 0.29 s is exactly 29 s and computes as 28.999999999999996, which floors to 28. The header line
32
+ * then reports a recording a whole second shorter than it is (fixed in 0.2.67).
33
+ */
34
+ function formatDurationTicks(ticks: bigint): string {
35
+ if (ticks < 0n) return 'unknown';
36
+ const whole = Number(ticks / TICKS_PER_SECOND);
27
37
  const hours = Math.floor(whole / 3600);
28
38
  const minutes = Math.floor((whole % 3600) / 60);
29
39
  const rest = whole % 60;
@@ -31,6 +41,16 @@ function formatDuration(seconds: number): string {
31
41
  return `${pad(hours)}:${pad(minutes)}:${pad(rest)}`;
32
42
  }
33
43
 
44
+ /** Control characters become a dot, so one field can never become two rows or shift a column. */
45
+ function printable(text: string): string {
46
+ let out = '';
47
+ for (const character of text) {
48
+ const code = character.codePointAt(0) ?? 0;
49
+ out += code < 0x20 || code === 0x7f ? '.' : character;
50
+ }
51
+ return out;
52
+ }
53
+
34
54
  function formatRate(signal: EdfHeader['signals'][number]): string {
35
55
  // undefined is the honest answer for a zero record duration, which is legal EDF.
36
56
  return signal.sampleRateHz === undefined ? '—' : `${signal.sampleRateHz} Hz`;
@@ -60,7 +80,7 @@ export function formatHeader(header: EdfHeader, options?: FormatHeaderOptions):
60
80
  `${header.bytesPerSample} bytes/sample`,
61
81
  );
62
82
  lines.push(
63
- `duration ${formatDuration(header.recordCount * header.recordDurationSeconds)} ` +
83
+ `duration ${formatDurationTicks(header.recordDurationTicks * BigInt(header.recordCount))} ` +
64
84
  `(${header.recordCount} × ${header.recordDurationSeconds} s)`,
65
85
  );
66
86
  if (header.recordCountSource === 'sourceByteLength') {
@@ -77,7 +97,11 @@ export function formatHeader(header: EdfHeader, options?: FormatHeaderOptions):
77
97
  lines.push(' # label kind rate range');
78
98
  for (const signal of header.signals) {
79
99
  const index = String(signal.index).padStart(3);
80
- const label = signal.label.slice(0, 20).padEnd(21);
100
+ // Control characters are replaced, not printed. A label holding a newline would otherwise
101
+ // render as two rows and forge a signal the file does not contain; a tab would shift every
102
+ // column after it. EDF pads labels with spaces and says nothing about what else may be in
103
+ // them, so a writer can put anything there and a reader must not be steered by it.
104
+ const label = printable(signal.label).slice(0, 20).padEnd(21);
81
105
  const kind = signal.kind.padEnd(12);
82
106
  const rate = formatRate(signal).padEnd(9);
83
107
  const range =
@@ -91,11 +115,19 @@ export function formatHeader(header: EdfHeader, options?: FormatHeaderOptions):
91
115
 
92
116
  if (header.diagnostics.length > 0) {
93
117
  lines.push('');
94
- const counts = new Map<string, number>();
95
- for (const diagnostic of header.diagnostics) {
96
- counts.set(diagnostic.severity, (counts.get(diagnostic.severity) ?? 0) + 1);
97
- }
98
- const summary = [...counts].map(([severity, count]) => `${count} ${severity}`).join(', ');
118
+ // Fixed error-warning-info order, matching `formatValidationReport` since 0.2.15. Ordering by
119
+ // arrival meant two files with the same diagnostics could summarise them differently.
120
+ const counted = summarizeDiagnostics(header.diagnostics);
121
+ const summary = (
122
+ [
123
+ [counted.errors, 'error'],
124
+ [counted.warnings, 'warning'],
125
+ [counted.infos, 'info'],
126
+ ] as const
127
+ )
128
+ .filter(([count]) => count > 0)
129
+ .map(([count, severity]) => `${count} ${severity}`)
130
+ .join(', ');
99
131
  lines.push(`${header.diagnostics.length} diagnostic(s): ${summary}`);
100
132
  lines.push('Call formatDiagnostics(header.diagnostics) for the detail.');
101
133
  }
@@ -16,6 +16,14 @@
16
16
  * `segmentAt` does: `undefined` from `sampleAt` means "no sample exists here", and an index that
17
17
  * has read record 0 and the last record cannot say that about anything in between. Merging "there
18
18
  * is a gap here" with "nobody looked" is the confusion this whole area of the API avoids.
19
+ *
20
+ * ONE LIMIT, and it belongs to the file rather than to these functions. If two records cover the
21
+ * same instant — a timeline whose onsets repeat, which EDF+ does not forbid and which edfcore
22
+ * reports without a diagnostic — then more than one sample exists at that time and no function can
23
+ * return both. `sampleAt` returns the one whose segment `segmentAt` finds. The round-trip
24
+ * "the sample at a sample's start is that sample" therefore holds for files whose records do not
25
+ * overlap, which is every file anyone is likely to have; it is not a universal law and 0.2.60
26
+ * claimed it as one.
19
27
  */
20
28
 
21
29
  import { EdfChannelNotFoundError } from './errors.js';
@@ -94,12 +102,18 @@ function segmentOfRecord(recording: EdfRecording, recordIndex: number): EdfSegme
94
102
  }
95
103
 
96
104
  /**
97
- * Whether this recording needs a scanned index before a time can be located at all.
105
+ * Whether a PROBED recording needs a scan before a time can be located.
106
+ *
107
+ * This is a statement about a probed index and nothing else. It compares net drift — span against
108
+ * coverage — which is what two probes can see, and edfcore's own documentation says three times
109
+ * that net drift is not a proof of contiguity: a gap that an overlap elsewhere cancels exactly
110
+ * leaves span equal to coverage and is still a gap.
98
111
  *
99
- * A file whose records cover exactly its span is contiguous, and the nominal grid IS the true one
100
- * so a probed index is enough and no scan is demanded of the caller for nothing.
112
+ * So it must never be consulted while a COMPLETE index is available; `resolveTimeWindow` gets that
113
+ * precedence right and this module originally did not, which made `sampleAt` take the nominal
114
+ * branch on a file whose scanned index reported gaps (fixed in 0.2.68).
101
115
  */
102
- function requiresScan(recording: EdfRecording): boolean {
116
+ function probedIndexNeedsScan(recording: EdfRecording): boolean {
103
117
  return recording.timeline.spanSeconds !== recording.timeline.coveredSeconds;
104
118
  }
105
119
 
@@ -129,11 +143,27 @@ export function sampleAt(
129
143
  const perRecord = BigInt(signal.samplesPerRecord);
130
144
  const ticks = secondsToTicks(seconds);
131
145
 
132
- if (!requiresScan(recording)) {
133
- // Contiguous: the nominal grid is the true one, but the answer is still bounded by the file.
134
- const recordIndex = Number(floorDiv(ticks, duration));
135
- if (recordIndex < 0 || recordIndex >= recording.header.recordCount) return undefined;
136
- const withinTicks = ticks - BigInt(recordIndex) * duration;
146
+ // A SCANNED index takes precedence over any statement about net drift, the same order
147
+ // `resolveTimeWindow` uses. Asking `probedIndexNeedsScan` first meant a file whose gap and
148
+ // overlap cancel — span equal to coverage, no diagnostic on open — took the nominal branch while
149
+ // a complete index sat on the same object reporting two gaps.
150
+ if (recording.index.segments !== undefined) {
151
+ // `segmentAt` owns "is there data here at all" and throws for a probed index rather than
152
+ // guessing; it cannot be reached with one here, because segments only exist on a scanned index.
153
+ const segment = segmentAt(recording.index, seconds);
154
+ if (segment === undefined) return undefined;
155
+
156
+ const offsetTicks = ticks - segment.startTicks;
157
+ const recordOffset = floorDiv(offsetTicks, duration);
158
+ // Bounded by the SEGMENT, as the nominal branch is bounded by the record count. `segmentAt`
159
+ // compares float seconds while this compares exact ticks, and `secondsToTicks` rounds to the
160
+ // nearest tick — so a time within half a tick of `segment.endSeconds` is inside the segment
161
+ // for one and at its end for the other, and without this it walked into the next segment or
162
+ // off the end of the file.
163
+ if (recordOffset < 0n || recordOffset >= BigInt(segment.records.count)) return undefined;
164
+
165
+ const recordIndex = segment.records.start + Number(recordOffset);
166
+ const withinTicks = offsetTicks - recordOffset * duration;
137
167
  const sampleWithinRecord = Number(floorDiv(withinTicks * perRecord, duration));
138
168
  return {
139
169
  sampleIndex: recordIndex * signal.samplesPerRecord + sampleWithinRecord,
@@ -142,14 +172,18 @@ export function sampleAt(
142
172
  };
143
173
  }
144
174
 
145
- // Discontinuous: `segmentAt` owns the "is there data here at all" question, and throws for a
146
- // probed index rather than guessing.
147
- const segment = segmentAt(recording.index, seconds);
148
- if (segment === undefined) return undefined;
175
+ if (probedIndexNeedsScan(recording)) {
176
+ throw new RangeError(
177
+ 'sampleAt(): this file has gaps and its index has not been scanned, so which records cover ' +
178
+ 'a time is not known. Next: await buildRecordIndex(recording) and read the result into ' +
179
+ 'the recording.',
180
+ );
181
+ }
149
182
 
150
- const offsetTicks = ticks - segment.startTicks;
151
- const recordIndex = segment.records.start + Number(floorDiv(offsetTicks, duration));
152
- const withinTicks = offsetTicks - floorDiv(offsetTicks, duration) * duration;
183
+ // Contiguous, probed: the nominal grid is the true one, and the answer is bounded by the file.
184
+ const recordIndex = Number(floorDiv(ticks, duration));
185
+ if (recordIndex < 0 || recordIndex >= recording.header.recordCount) return undefined;
186
+ const withinTicks = ticks - BigInt(recordIndex) * duration;
153
187
  const sampleWithinRecord = Number(floorDiv(withinTicks * perRecord, duration));
154
188
  return {
155
189
  sampleIndex: recordIndex * signal.samplesPerRecord + sampleWithinRecord,
@@ -187,7 +221,19 @@ export function sampleStartTicksOf(
187
221
  const recordIndex = Math.floor(sampleIndex / perRecord);
188
222
  const within = BigInt(sampleIndex - recordIndex * perRecord);
189
223
 
190
- if (requiresScan(recording) && recording.index.segments === undefined) {
224
+ // Bounded by the file. An index past the end used to fall through `segmentOfRecord` to the
225
+ // nominal grid and come back EARLIER than the last real sample — on a file with a 7 s gap,
226
+ // sample 24 of a 24-sample file reported 6.75 s before sample 23. Refusing is the only honest
227
+ // answer for a sample that does not exist.
228
+ if (sampleIndex < 0 || recordIndex >= recording.header.recordCount) {
229
+ throw new RangeError(
230
+ `sampleStartTicksOf(): sample ${sampleIndex} is outside the ` +
231
+ `${recording.header.recordCount * perRecord} samples signal ${signalIndex} has. ` +
232
+ 'Next: clamp the index, or read signal.sampleCount first.',
233
+ );
234
+ }
235
+
236
+ if (recording.index.segments === undefined && probedIndexNeedsScan(recording)) {
191
237
  throw new RangeError(
192
238
  'sampleStartTicksOf(): this file has gaps and its index has not been scanned, so the true ' +
193
239
  'start of a record after a gap is not known. Next: await buildRecordIndex(recording) and ' +