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 +47 -0
- package/dist/constants.d.ts +1 -1
- package/dist/constants.js +1 -1
- package/dist/format-header.d.ts.map +1 -1
- package/dist/format-header.js +39 -10
- package/dist/format-header.js.map +1 -1
- package/dist/sample-locate.d.ts +8 -0
- package/dist/sample-locate.d.ts.map +1 -1
- package/dist/sample-locate.js +57 -17
- package/dist/sample-locate.js.map +1 -1
- package/package.json +1 -1
- package/src/constants.ts +1 -1
- package/src/format-header.ts +42 -10
- package/src/sample-locate.ts +63 -17
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
|
package/dist/constants.d.ts
CHANGED
|
@@ -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.
|
|
112
|
+
export declare const VERSION = "0.2.68";
|
|
113
113
|
//# sourceMappingURL=constants.d.ts.map
|
package/dist/constants.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"format-header.d.ts","sourceRoot":"","sources":["../src/format-header.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;
|
|
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"}
|
package/dist/format-header.js
CHANGED
|
@@ -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
|
-
|
|
22
|
-
|
|
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 =
|
|
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 ${
|
|
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
|
-
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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":"AAAA;;;;;;;;;;;;GAYG;
|
|
1
|
+
{"version":3,"file":"format-header.js","sourceRoot":"","sources":["../src/format-header.ts"],"names":[],"mappings":"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"}
|
package/dist/sample-locate.d.ts
CHANGED
|
@@ -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
|
|
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"}
|
package/dist/sample-locate.js
CHANGED
|
@@ -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
|
|
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
|
-
*
|
|
83
|
-
*
|
|
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
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
|
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
|
-
|
|
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":"AAAA
|
|
1
|
+
{"version":3,"file":"sample-locate.js","sourceRoot":"","sources":["../src/sample-locate.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AAEH,OAAO,EAAE,uBAAuB,EAAE,MAAM,aAAa,CAAC;AACtD,OAAO,EAAE,SAAS,EAAE,MAAM,mBAAmB,CAAC;AAC9C,OAAO,EAAE,cAAc,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAC;AAGhE,gGAAgG;AAChG,SAAS,QAAQ,CAAC,CAAS,EAAE,CAAS;IACpC,MAAM,QAAQ,GAAG,CAAC,GAAG,CAAC,CAAC;IACvB,OAAO,CAAC,GAAG,CAAC,KAAK,EAAE,IAAI,CAAC,GAAG,EAAE,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAC,QAAQ,GAAG,EAAE,CAAC;AAC3D,CAAC;AAED,SAAS,aAAa,CAAC,SAAuB,EAAE,WAAmB,EAAE,MAAc;IACjF,MAAM,MAAM,GAAG,SAAS,CAAC,MAAM,CAAC,OAAO,CAAC,WAAW,CAAC,CAAC;IACrD,IAAI,MAAM,KAAK,SAAS,EAAE,CAAC;QACzB,MAAM,IAAI,uBAAuB,CAC/B,GAAG,MAAM,mBAAmB,WAAW,kBAAkB;YACvD,GAAG,SAAS,CAAC,MAAM,CAAC,OAAO,CAAC,MAAM,wDAAwD;YAC1F,yEAAyE,EAC3E;YACE,QAAQ,EAAE,WAAW;YACrB,eAAe,EAAE,SAAS,CAAC,MAAM,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,KAAK,CAAC;SAC9D,CACF,CAAC;IACJ,CAAC;IACD,IAAI,MAAM,CAAC,IAAI,KAAK,aAAa,EAAE,CAAC;QAClC,MAAM,IAAI,UAAU,CAClB,GAAG,MAAM,cAAc,WAAW,KAAK,IAAI,CAAC,SAAS,CAAC,MAAM,CAAC,KAAK,CAAC,sBAAsB;YACvF,sFAAsF;YACtF,oEAAoE,CACvE,CAAC;IACJ,CAAC;IACD,IAAI,MAAM,CAAC,gBAAgB,IAAI,CAAC,EAAE,CAAC;QACjC,MAAM,IAAI,UAAU,CAClB,GAAG,MAAM,cAAc,WAAW,KAAK,IAAI,CAAC,SAAS,CAAC,MAAM,CAAC,KAAK,CAAC,aAAa;YAC9E,GAAG,MAAM,CAAC,gBAAgB,0DAA0D;YACpF,6DAA6D,CAChE,CAAC;IACJ,CAAC;IACD,IAAI,SAAS,CAAC,MAAM,CAAC,mBAAmB,IAAI,EAAE,EAAE,CAAC;QAC/C,MAAM,IAAI,UAAU,CAClB,GAAG,MAAM,iFAAiF;YACxF,yFAAyF;YACzF,kDAAkD,CACrD,CAAC;IACJ,CAAC;IACD,OAAO,MAAM,CAAC;AAChB,CAAC;AAED;;;;;GAKG;AACH,SAAS,gBAAgB,CACvB,SAAuB,EACvB,OAA+B,EAC/B,WAAmB;IAEnB,MAAM,QAAQ,GAAG,SAAS,CAAC,MAAM,CAAC,mBAAmB,CAAC;IACtD,IAAI,OAAO,KAAK,SAAS;QAAE,OAAO,MAAM,CAAC,WAAW,CAAC,GAAG,QAAQ,CAAC;IACjE,OAAO,OAAO,CAAC,UAAU,GAAG,MAAM,CAAC,WAAW,GAAG,OAAO,CAAC,OAAO,CAAC,KAAK,CAAC,GAAG,QAAQ,CAAC;AACrF,CAAC;AAED,iGAAiG;AACjG,SAAS,eAAe,CAAC,SAAuB,EAAE,WAAmB;IACnE,MAAM,QAAQ,GAAG,SAAS,CAAC,KAAK,CAAC,QAAQ,CAAC;IAC1C,IAAI,QAAQ,KAAK,SAAS;QAAE,OAAO,SAAS,CAAC;IAC7C,KAAK,MAAM,OAAO,IAAI,QAAQ,EAAE,CAAC;QAC/B,MAAM,KAAK,GAAG,OAAO,CAAC,OAAO,CAAC,KAAK,CAAC;QACpC,IAAI,WAAW,IAAI,KAAK,IAAI,WAAW,GAAG,KAAK,GAAG,OAAO,CAAC,OAAO,CAAC,KAAK;YAAE,OAAO,OAAO,CAAC;IAC1F,CAAC;IACD,OAAO,SAAS,CAAC;AACnB,CAAC;AAED;;;;;;;;;;;GAWG;AACH,SAAS,oBAAoB,CAAC,SAAuB;IACnD,OAAO,SAAS,CAAC,QAAQ,CAAC,WAAW,KAAK,SAAS,CAAC,QAAQ,CAAC,cAAc,CAAC;AAC9E,CAAC;AAED;;;;;;;;;;;GAWG;AACH,MAAM,UAAU,QAAQ,CACtB,SAAuB,EACvB,WAAmB,EACnB,OAAe;IAEf,MAAM,MAAM,GAAG,aAAa,CAAC,SAAS,EAAE,WAAW,EAAE,UAAU,CAAC,CAAC;IACjE,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,OAAO,CAAC,EAAE,CAAC;QAC9B,MAAM,IAAI,UAAU,CAAC,yDAAyD,OAAO,GAAG,CAAC,CAAC;IAC5F,CAAC;IAED,MAAM,QAAQ,GAAG,SAAS,CAAC,MAAM,CAAC,mBAAmB,CAAC;IACtD,MAAM,SAAS,GAAG,MAAM,CAAC,MAAM,CAAC,gBAAgB,CAAC,CAAC;IAClD,MAAM,KAAK,GAAG,cAAc,CAAC,OAAO,CAAC,CAAC;IAEtC,sFAAsF;IACtF,2FAA2F;IAC3F,iGAAiG;IACjG,8DAA8D;IAC9D,IAAI,SAAS,CAAC,KAAK,CAAC,QAAQ,KAAK,SAAS,EAAE,CAAC;QAC3C,yFAAyF;QACzF,gGAAgG;QAChG,MAAM,OAAO,GAAG,SAAS,CAAC,SAAS,CAAC,KAAK,EAAE,OAAO,CAAC,CAAC;QACpD,IAAI,OAAO,KAAK,SAAS;YAAE,OAAO,SAAS,CAAC;QAE5C,MAAM,WAAW,GAAG,KAAK,GAAG,OAAO,CAAC,UAAU,CAAC;QAC/C,MAAM,YAAY,GAAG,QAAQ,CAAC,WAAW,EAAE,QAAQ,CAAC,CAAC;QACrD,4FAA4F;QAC5F,6FAA6F;QAC7F,4FAA4F;QAC5F,4FAA4F;QAC5F,2BAA2B;QAC3B,IAAI,YAAY,GAAG,EAAE,IAAI,YAAY,IAAI,MAAM,CAAC,OAAO,CAAC,OAAO,CAAC,KAAK,CAAC;YAAE,OAAO,SAAS,CAAC;QAEzF,MAAM,WAAW,GAAG,OAAO,CAAC,OAAO,CAAC,KAAK,GAAG,MAAM,CAAC,YAAY,CAAC,CAAC;QACjE,MAAM,WAAW,GAAG,WAAW,GAAG,YAAY,GAAG,QAAQ,CAAC;QAC1D,MAAM,kBAAkB,GAAG,MAAM,CAAC,QAAQ,CAAC,WAAW,GAAG,SAAS,EAAE,QAAQ,CAAC,CAAC,CAAC;QAC/E,OAAO;YACL,WAAW,EAAE,WAAW,GAAG,MAAM,CAAC,gBAAgB,GAAG,kBAAkB;YACvE,WAAW;YACX,kBAAkB;SACnB,CAAC;IACJ,CAAC;IAED,IAAI,oBAAoB,CAAC,SAAS,CAAC,EAAE,CAAC;QACpC,MAAM,IAAI,UAAU,CAClB,4FAA4F;YAC1F,wFAAwF;YACxF,gBAAgB,CACnB,CAAC;IACJ,CAAC;IAED,+FAA+F;IAC/F,MAAM,WAAW,GAAG,MAAM,CAAC,QAAQ,CAAC,KAAK,EAAE,QAAQ,CAAC,CAAC,CAAC;IACtD,IAAI,WAAW,GAAG,CAAC,IAAI,WAAW,IAAI,SAAS,CAAC,MAAM,CAAC,WAAW;QAAE,OAAO,SAAS,CAAC;IACrF,MAAM,WAAW,GAAG,KAAK,GAAG,MAAM,CAAC,WAAW,CAAC,GAAG,QAAQ,CAAC;IAC3D,MAAM,kBAAkB,GAAG,MAAM,CAAC,QAAQ,CAAC,WAAW,GAAG,SAAS,EAAE,QAAQ,CAAC,CAAC,CAAC;IAC/E,OAAO;QACL,WAAW,EAAE,WAAW,GAAG,MAAM,CAAC,gBAAgB,GAAG,kBAAkB;QACvE,WAAW;QACX,kBAAkB;KACnB,CAAC;AACJ,CAAC;AAED;;;;;;;;;;;GAWG;AACH,MAAM,UAAU,kBAAkB,CAChC,SAAuB,EACvB,WAAmB,EACnB,WAAmB;IAEnB,MAAM,MAAM,GAAG,aAAa,CAAC,SAAS,EAAE,WAAW,EAAE,oBAAoB,CAAC,CAAC;IAC3E,IAAI,CAAC,MAAM,CAAC,aAAa,CAAC,WAAW,CAAC,EAAE,CAAC;QACvC,MAAM,IAAI,UAAU,CAClB,sEAAsE,WAAW,GAAG,CACrF,CAAC;IACJ,CAAC;IAED,MAAM,SAAS,GAAG,MAAM,CAAC,gBAAgB,CAAC;IAC1C,MAAM,QAAQ,GAAG,SAAS,CAAC,MAAM,CAAC,mBAAmB,CAAC;IACtD,MAAM,WAAW,GAAG,IAAI,CAAC,KAAK,CAAC,WAAW,GAAG,SAAS,CAAC,CAAC;IACxD,MAAM,MAAM,GAAG,MAAM,CAAC,WAAW,GAAG,WAAW,GAAG,SAAS,CAAC,CAAC;IAE7D,2FAA2F;IAC3F,2FAA2F;IAC3F,8FAA8F;IAC9F,2CAA2C;IAC3C,IAAI,WAAW,GAAG,CAAC,IAAI,WAAW,IAAI,SAAS,CAAC,MAAM,CAAC,WAAW,EAAE,CAAC;QACnE,MAAM,IAAI,UAAU,CAClB,gCAAgC,WAAW,kBAAkB;YAC3D,GAAG,SAAS,CAAC,MAAM,CAAC,WAAW,GAAG,SAAS,mBAAmB,WAAW,QAAQ;YACjF,0DAA0D,CAC7D,CAAC;IACJ,CAAC;IAED,IAAI,SAAS,CAAC,KAAK,CAAC,QAAQ,KAAK,SAAS,IAAI,oBAAoB,CAAC,SAAS,CAAC,EAAE,CAAC;QAC9E,MAAM,IAAI,UAAU,CAClB,2FAA2F;YACzF,0FAA0F;YAC1F,qCAAqC,CACxC,CAAC;IACJ,CAAC;IAED,MAAM,KAAK,GAAG,gBAAgB,CAAC,SAAS,EAAE,eAAe,CAAC,SAAS,EAAE,WAAW,CAAC,EAAE,WAAW,CAAC,CAAC;IAChG,MAAM,SAAS,GAAG,MAAM,GAAG,QAAQ,CAAC;IACpC,MAAM,MAAM,GAAG,SAAS,GAAG,MAAM,CAAC,SAAS,CAAC,CAAC;IAC7C,qCAAqC;IACrC,OAAO,KAAK,GAAG,CAAC,SAAS,GAAG,MAAM,CAAC,SAAS,CAAC,KAAK,EAAE,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,MAAM,GAAG,EAAE,CAAC,CAAC;AAC/E,CAAC;AAED,wFAAwF;AACxF,MAAM,UAAU,oBAAoB,CAClC,SAAuB,EACvB,WAAmB,EACnB,WAAmB;IAEnB,OAAO,cAAc,CAAC,kBAAkB,CAAC,SAAS,EAAE,WAAW,EAAE,WAAW,CAAC,CAAC,CAAC;AACjF,CAAC"}
|
package/package.json
CHANGED
package/src/constants.ts
CHANGED
package/src/format-header.ts
CHANGED
|
@@ -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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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 ${
|
|
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
|
-
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
}
|
package/src/sample-locate.ts
CHANGED
|
@@ -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
|
|
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
|
-
*
|
|
100
|
-
*
|
|
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
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
-
|
|
151
|
-
const recordIndex =
|
|
152
|
-
|
|
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
|
-
|
|
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 ' +
|