edfcore 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +319 -0
- package/dist/bytes/latin1.d.ts +43 -0
- package/dist/bytes/latin1.d.ts.map +1 -0
- package/dist/bytes/latin1.js +84 -0
- package/dist/bytes/latin1.js.map +1 -0
- package/dist/bytes/numbers.d.ts +45 -0
- package/dist/bytes/numbers.d.ts.map +1 -0
- package/dist/bytes/numbers.js +87 -0
- package/dist/bytes/numbers.js.map +1 -0
- package/dist/bytes/view.d.ts +27 -0
- package/dist/bytes/view.d.ts.map +1 -0
- package/dist/bytes/view.js +53 -0
- package/dist/bytes/view.js.map +1 -0
- package/dist/constants.d.ts +113 -0
- package/dist/constants.d.ts.map +1 -0
- package/dist/constants.js +83 -0
- package/dist/constants.js.map +1 -0
- package/dist/decode/digital.d.ts +49 -0
- package/dist/decode/digital.d.ts.map +1 -0
- package/dist/decode/digital.js +180 -0
- package/dist/decode/digital.js.map +1 -0
- package/dist/decode/physical.d.ts +37 -0
- package/dist/decode/physical.d.ts.map +1 -0
- package/dist/decode/physical.js +178 -0
- package/dist/decode/physical.js.map +1 -0
- package/dist/diagnostics/codes.d.ts +38 -0
- package/dist/diagnostics/codes.d.ts.map +1 -0
- package/dist/diagnostics/codes.js +134 -0
- package/dist/diagnostics/codes.js.map +1 -0
- package/dist/diagnostics/collector.d.ts +76 -0
- package/dist/diagnostics/collector.d.ts.map +1 -0
- package/dist/diagnostics/collector.js +95 -0
- package/dist/diagnostics/collector.js.map +1 -0
- package/dist/diagnostics/format.d.ts +21 -0
- package/dist/diagnostics/format.d.ts.map +1 -0
- package/dist/diagnostics/format.js +125 -0
- package/dist/diagnostics/format.js.map +1 -0
- package/dist/errors.d.ts +143 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +135 -0
- package/dist/errors.js.map +1 -0
- package/dist/header/dates.d.ts +101 -0
- package/dist/header/dates.d.ts.map +1 -0
- package/dist/header/dates.js +378 -0
- package/dist/header/dates.js.map +1 -0
- package/dist/header/fields.d.ts +105 -0
- package/dist/header/fields.d.ts.map +1 -0
- package/dist/header/fields.js +363 -0
- package/dist/header/fields.js.map +1 -0
- package/dist/header/identification.d.ts +47 -0
- package/dist/header/identification.d.ts.map +1 -0
- package/dist/header/identification.js +156 -0
- package/dist/header/identification.js.map +1 -0
- package/dist/header/lookup.d.ts +33 -0
- package/dist/header/lookup.d.ts.map +1 -0
- package/dist/header/lookup.js +67 -0
- package/dist/header/lookup.js.map +1 -0
- package/dist/header/parse.d.ts +32 -0
- package/dist/header/parse.d.ts.map +1 -0
- package/dist/header/parse.js +319 -0
- package/dist/header/parse.js.map +1 -0
- package/dist/header/scale.d.ts +68 -0
- package/dist/header/scale.d.ts.map +1 -0
- package/dist/header/scale.js +154 -0
- package/dist/header/scale.js.map +1 -0
- package/dist/header/signals.d.ts +64 -0
- package/dist/header/signals.d.ts.map +1 -0
- package/dist/header/signals.js +374 -0
- package/dist/header/signals.js.map +1 -0
- package/dist/header/variant.d.ts +67 -0
- package/dist/header/variant.d.ts.map +1 -0
- package/dist/header/variant.js +180 -0
- package/dist/header/variant.js.map +1 -0
- package/dist/index.d.ts +46 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +59 -0
- package/dist/index.js.map +1 -0
- package/dist/inspect.d.ts +31 -0
- package/dist/inspect.d.ts.map +1 -0
- package/dist/inspect.js +165 -0
- package/dist/inspect.js.map +1 -0
- package/dist/io/blob.d.ts +14 -0
- package/dist/io/blob.d.ts.map +1 -0
- package/dist/io/blob.js +29 -0
- package/dist/io/blob.js.map +1 -0
- package/dist/io/bytes.d.ts +11 -0
- package/dist/io/bytes.d.ts.map +1 -0
- package/dist/io/bytes.js +26 -0
- package/dist/io/bytes.js.map +1 -0
- package/dist/io/cached.d.ts +19 -0
- package/dist/io/cached.d.ts.map +1 -0
- package/dist/io/cached.js +129 -0
- package/dist/io/cached.js.map +1 -0
- package/dist/io/http.d.ts +20 -0
- package/dist/io/http.d.ts.map +1 -0
- package/dist/io/http.js +225 -0
- package/dist/io/http.js.map +1 -0
- package/dist/io/read.d.ts +42 -0
- package/dist/io/read.d.ts.map +1 -0
- package/dist/io/read.js +119 -0
- package/dist/io/read.js.map +1 -0
- package/dist/io/source.d.ts +33 -0
- package/dist/io/source.d.ts.map +1 -0
- package/dist/io/source.js +72 -0
- package/dist/io/source.js.map +1 -0
- package/dist/node.d.ts +70 -0
- package/dist/node.d.ts.map +1 -0
- package/dist/node.js +109 -0
- package/dist/node.js.map +1 -0
- package/dist/record-index.d.ts +58 -0
- package/dist/record-index.d.ts.map +1 -0
- package/dist/record-index.js +290 -0
- package/dist/record-index.js.map +1 -0
- package/dist/recording.d.ts +57 -0
- package/dist/recording.d.ts.map +1 -0
- package/dist/recording.js +178 -0
- package/dist/recording.js.map +1 -0
- package/dist/tal/annotations.d.ts +36 -0
- package/dist/tal/annotations.d.ts.map +1 -0
- package/dist/tal/annotations.js +437 -0
- package/dist/tal/annotations.js.map +1 -0
- package/dist/tal/grammar.d.ts +111 -0
- package/dist/tal/grammar.d.ts.map +1 -0
- package/dist/tal/grammar.js +324 -0
- package/dist/tal/grammar.js.map +1 -0
- package/dist/tal/ticks.d.ts +69 -0
- package/dist/tal/ticks.d.ts.map +1 -0
- package/dist/tal/ticks.js +137 -0
- package/dist/tal/ticks.js.map +1 -0
- package/dist/time/segments.d.ts +33 -0
- package/dist/time/segments.d.ts.map +1 -0
- package/dist/time/segments.js +87 -0
- package/dist/time/segments.js.map +1 -0
- package/dist/time/timeline.d.ts +62 -0
- package/dist/time/timeline.d.ts.map +1 -0
- package/dist/time/timeline.js +227 -0
- package/dist/time/timeline.js.map +1 -0
- package/dist/time/window.d.ts +54 -0
- package/dist/time/window.d.ts.map +1 -0
- package/dist/time/window.js +232 -0
- package/dist/time/window.js.map +1 -0
- package/dist/types.d.ts +409 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +16 -0
- package/dist/types.js.map +1 -0
- package/dist/validate.d.ts +53 -0
- package/dist/validate.d.ts.map +1 -0
- package/dist/validate.js +483 -0
- package/dist/validate.js.map +1 -0
- package/package.json +77 -0
- package/src/bytes/latin1.ts +87 -0
- package/src/bytes/numbers.ts +115 -0
- package/src/bytes/view.ts +59 -0
- package/src/constants.ts +96 -0
- package/src/decode/digital.ts +258 -0
- package/src/decode/physical.ts +235 -0
- package/src/diagnostics/codes.ts +209 -0
- package/src/diagnostics/collector.ts +129 -0
- package/src/diagnostics/format.ts +143 -0
- package/src/errors.ts +208 -0
- package/src/header/dates.ts +453 -0
- package/src/header/fields.ts +455 -0
- package/src/header/identification.ts +196 -0
- package/src/header/lookup.ts +81 -0
- package/src/header/parse.ts +398 -0
- package/src/header/scale.ts +268 -0
- package/src/header/signals.ts +550 -0
- package/src/header/variant.ts +239 -0
- package/src/index.ts +151 -0
- package/src/inspect.ts +181 -0
- package/src/io/blob.ts +31 -0
- package/src/io/bytes.ts +29 -0
- package/src/io/cached.ts +136 -0
- package/src/io/http.ts +299 -0
- package/src/io/read.ts +152 -0
- package/src/io/source.ts +87 -0
- package/src/node.ts +138 -0
- package/src/record-index.ts +389 -0
- package/src/recording.ts +234 -0
- package/src/tal/annotations.ts +574 -0
- package/src/tal/grammar.ts +540 -0
- package/src/tal/ticks.ts +172 -0
- package/src/time/segments.ts +109 -0
- package/src/time/timeline.ts +290 -0
- package/src/time/window.ts +304 -0
- package/src/types.ts +490 -0
- package/src/validate.ts +610 -0
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Windows in seconds, resolved to records and then to samples.
|
|
3
|
+
*
|
|
4
|
+
* Layer 4. Pure and synchronous, both halves of it. `resolveTimeWindow` answers "which records
|
|
5
|
+
* does this window cost?" before a byte is read, so the price of a window is auditable rather
|
|
6
|
+
* than discovered; `trimToWindow` narrows a record-aligned chunk to the samples actually asked
|
|
7
|
+
* for.
|
|
8
|
+
*
|
|
9
|
+
* The window is the half-open interval `[startSeconds, startSeconds + durationSeconds)` in
|
|
10
|
+
* elapsed recording time — `t = 0` is the start of record 0, the axis `time/timeline.ts` fixes.
|
|
11
|
+
*
|
|
12
|
+
* Every comparison below is integer or rational arithmetic on ticks, records and
|
|
13
|
+
* `samplesPerRecord`. `round(t * sampleRateHz)` appears nowhere: `sampleRateHz` is derived and
|
|
14
|
+
* often not representable (256/3 Hz is a real record duration of 3 s with 256 samples), so
|
|
15
|
+
* rounding through it walks the answer off by a sample near every large `t` — which is the exact
|
|
16
|
+
* mistake edfcore exists to stop a consumer from re-implementing.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { TICKS_PER_SECOND } from '../constants.js';
|
|
20
|
+
import { EdfChannelNotFoundError } from '../errors.js';
|
|
21
|
+
import { secondsToTicks, ticksToSeconds } from '../tal/ticks.js';
|
|
22
|
+
import type {
|
|
23
|
+
EdfChunkSignal,
|
|
24
|
+
EdfHeader,
|
|
25
|
+
EdfRecordIndex,
|
|
26
|
+
EdfSignal,
|
|
27
|
+
EdfTimeline,
|
|
28
|
+
RecordRange,
|
|
29
|
+
} from '../types.js';
|
|
30
|
+
|
|
31
|
+
const NO_RANGES: readonly RecordRange[] = Object.freeze([]);
|
|
32
|
+
|
|
33
|
+
/** Exact: 10^7 is far below 2^53. */
|
|
34
|
+
const TICKS_PER_SECOND_FLOAT = Number(TICKS_PER_SECOND);
|
|
35
|
+
|
|
36
|
+
/** `b` must be positive. Bigint `/` truncates toward zero, so negatives need the correction. */
|
|
37
|
+
function floorDiv(a: bigint, b: bigint): bigint {
|
|
38
|
+
const quotient = a / b;
|
|
39
|
+
return a % b === 0n || a > 0n ? quotient : quotient - 1n;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function ceilDiv(a: bigint, b: bigint): bigint {
|
|
43
|
+
const quotient = a / b;
|
|
44
|
+
return a % b === 0n || a < 0n ? quotient : quotient + 1n;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Clamping in bigint first, because `Number()` on a large bigint silently loses digits. */
|
|
48
|
+
function clampToInt(value: bigint, low: number, high: number): number {
|
|
49
|
+
if (value <= BigInt(low)) return low;
|
|
50
|
+
if (value >= BigInt(high)) return high;
|
|
51
|
+
return Number(value);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The record duration in exact ticks, recovered from the timeline's float.
|
|
56
|
+
*
|
|
57
|
+
* `resolveTimeWindow`'s signature takes no header, so `header.recordDurationTicks` is not
|
|
58
|
+
* available here. The round-trip is exact anyway: the seconds value was itself produced from a
|
|
59
|
+
* header field of at most a few decimals, and `secondsToTicks` rounds to the nearest tick, so
|
|
60
|
+
* every duration a file can declare below ~10^9 s comes back to the tick it came from.
|
|
61
|
+
*/
|
|
62
|
+
function recordDurationTicksOf(timeline: EdfTimeline): bigint {
|
|
63
|
+
return secondsToTicks(timeline.recordDurationSeconds);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function signalAt(header: EdfHeader, signalIndex: number): EdfSignal {
|
|
67
|
+
const signal = header.signals[signalIndex];
|
|
68
|
+
if (signal !== undefined) return signal;
|
|
69
|
+
throw new EdfChannelNotFoundError(
|
|
70
|
+
`signalIndex ${signalIndex} is not one of the ${header.signals.length} signals in this ` +
|
|
71
|
+
'header, so trimToWindow() cannot know how many samples per record it holds. Next: pass ' +
|
|
72
|
+
'the header the chunk was read with.',
|
|
73
|
+
{ selector: signalIndex, availableLabels: header.signals.map((s) => s.label) },
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The records a window needs, one `RecordRange` per contiguous run it overlaps, in time order.
|
|
79
|
+
*
|
|
80
|
+
* Empty when the window falls entirely inside a gap, entirely outside the recording, or has a
|
|
81
|
+
* non-positive duration — the interval is half-open, so a zero-length window contains no time
|
|
82
|
+
* and therefore no samples.
|
|
83
|
+
*
|
|
84
|
+
* Ranges are RECORD-ALIGNED and are therefore usually wider than the window: a record is the
|
|
85
|
+
* smallest unit the file can be read by, and `trimToWindow` is how a caller narrows the samples
|
|
86
|
+
* afterwards.
|
|
87
|
+
*
|
|
88
|
+
* With `index.segments` present (`coverage === 'complete'`) the answer is exact. With a probed
|
|
89
|
+
* index it is exact only while the file is contiguous, which is precisely what
|
|
90
|
+
* `spanSeconds === coveredSeconds` states; when it is not, the records a window maps to depend
|
|
91
|
+
* on onsets nobody has read, and this function refuses rather than guessing them.
|
|
92
|
+
*/
|
|
93
|
+
export function resolveTimeWindow(
|
|
94
|
+
timeline: EdfTimeline,
|
|
95
|
+
index: EdfRecordIndex,
|
|
96
|
+
startSeconds: number,
|
|
97
|
+
durationSeconds: number,
|
|
98
|
+
): readonly RecordRange[] {
|
|
99
|
+
const recordCount = timeline.recordCount;
|
|
100
|
+
if (recordCount <= 0) return NO_RANGES;
|
|
101
|
+
|
|
102
|
+
const windowStartTicks = secondsToTicks(startSeconds);
|
|
103
|
+
const windowDurationTicks = secondsToTicks(durationSeconds);
|
|
104
|
+
if (windowDurationTicks <= 0n) return NO_RANGES;
|
|
105
|
+
const windowEndTicks = windowStartTicks + windowDurationTicks;
|
|
106
|
+
|
|
107
|
+
const durationTicks = recordDurationTicksOf(timeline);
|
|
108
|
+
const segments = index.segments;
|
|
109
|
+
if (segments !== undefined) {
|
|
110
|
+
const ranges: RecordRange[] = [];
|
|
111
|
+
for (const segment of segments) {
|
|
112
|
+
const segmentCount = segment.records.count;
|
|
113
|
+
if (segmentCount <= 0) continue;
|
|
114
|
+
const segmentStartTicks = segment.startTicks;
|
|
115
|
+
|
|
116
|
+
// A zero record duration puts every record of the segment at one instant, so the segment
|
|
117
|
+
// is either wholly inside the window or wholly outside it.
|
|
118
|
+
if (durationTicks === 0n) {
|
|
119
|
+
if (windowStartTicks <= segmentStartTicks && segmentStartTicks < windowEndTicks) {
|
|
120
|
+
ranges.push({ start: segment.records.start, count: segmentCount });
|
|
121
|
+
}
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const segmentEndTicks = segmentStartTicks + BigInt(segmentCount) * durationTicks;
|
|
126
|
+
if (segmentEndTicks <= windowStartTicks || segmentStartTicks >= windowEndTicks) continue;
|
|
127
|
+
|
|
128
|
+
const firstOffset = clampToInt(
|
|
129
|
+
floorDiv(windowStartTicks - segmentStartTicks, durationTicks),
|
|
130
|
+
0,
|
|
131
|
+
segmentCount - 1,
|
|
132
|
+
);
|
|
133
|
+
const lastOffset = clampToInt(
|
|
134
|
+
ceilDiv(windowEndTicks - segmentStartTicks, durationTicks) - 1n,
|
|
135
|
+
0,
|
|
136
|
+
segmentCount - 1,
|
|
137
|
+
);
|
|
138
|
+
if (lastOffset < firstOffset) continue;
|
|
139
|
+
ranges.push({
|
|
140
|
+
start: segment.records.start + firstOffset,
|
|
141
|
+
count: lastOffset - firstOffset + 1,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
return Object.freeze(ranges);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (timeline.spanSeconds !== timeline.coveredSeconds) {
|
|
148
|
+
throw new RangeError(
|
|
149
|
+
`resolveTimeWindow() cannot map seconds to records on this file: its ${recordCount} ` +
|
|
150
|
+
`records span ${timeline.spanSeconds} s but cover only ${timeline.coveredSeconds} s, so ` +
|
|
151
|
+
'it contains at least one gap, and a probed index knows where neither the gap nor the ' +
|
|
152
|
+
'records after it start. Next: await buildRecordIndex(recording) and pass the index it ' +
|
|
153
|
+
'returns, or locate the window with index.locate(seconds).',
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Contiguous as far as the probes can tell: record r starts at exactly r * recordDuration.
|
|
158
|
+
if (durationTicks === 0n) {
|
|
159
|
+
return windowStartTicks <= 0n && windowEndTicks > 0n
|
|
160
|
+
? Object.freeze([{ start: 0, count: recordCount }])
|
|
161
|
+
: NO_RANGES;
|
|
162
|
+
}
|
|
163
|
+
if (windowEndTicks <= 0n) return NO_RANGES;
|
|
164
|
+
if (windowStartTicks >= BigInt(recordCount) * durationTicks) return NO_RANGES;
|
|
165
|
+
const first = clampToInt(floorDiv(windowStartTicks, durationTicks), 0, recordCount - 1);
|
|
166
|
+
const last = clampToInt(ceilDiv(windowEndTicks, durationTicks) - 1n, 0, recordCount - 1);
|
|
167
|
+
if (last < first) return NO_RANGES;
|
|
168
|
+
return Object.freeze([{ start: first, count: last - first + 1 }]);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Sample `firstIndex` of the chunk, in seconds, as an exact rational.
|
|
173
|
+
*
|
|
174
|
+
* The sample sits at `chunkStart + firstIndex * recordDuration / samplesPerRecord`, and that
|
|
175
|
+
* division is usually not a whole number of ticks. The whole part goes through `ticksToSeconds`
|
|
176
|
+
* and only the remainder is divided, so the sub-tick part costs one rounding instead of poisoning
|
|
177
|
+
* the seconds and the ticks together. Bigint `/` and `%` agree in sign, so a negative chunk start
|
|
178
|
+
* (a pre-stimulus window) sums correctly.
|
|
179
|
+
*/
|
|
180
|
+
function sampleStartSeconds(
|
|
181
|
+
chunkStartTicks: bigint,
|
|
182
|
+
firstIndex: bigint,
|
|
183
|
+
durationTicks: bigint,
|
|
184
|
+
samplesPerRecord: bigint,
|
|
185
|
+
): number {
|
|
186
|
+
const scaled = chunkStartTicks * samplesPerRecord + firstIndex * durationTicks;
|
|
187
|
+
const wholeTicks = scaled / samplesPerRecord;
|
|
188
|
+
const remainder = scaled % samplesPerRecord;
|
|
189
|
+
return (
|
|
190
|
+
ticksToSeconds(wholeTicks) +
|
|
191
|
+
Number(remainder) / (Number(samplesPerRecord) * TICKS_PER_SECOND_FLOAT)
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function countOutOfDigitalRange(digital: Int32Array, signal: EdfSignal): number {
|
|
196
|
+
const low = Math.min(signal.digitalMinimum, signal.digitalMaximum);
|
|
197
|
+
const high = Math.max(signal.digitalMinimum, signal.digitalMaximum);
|
|
198
|
+
let count = 0;
|
|
199
|
+
for (let i = 0; i < digital.length; i += 1) {
|
|
200
|
+
// biome-ignore lint/style/noNonNullAssertion: i is bounded by digital.length.
|
|
201
|
+
const value = digital[i]!;
|
|
202
|
+
if (value < low || value > high) count += 1;
|
|
203
|
+
}
|
|
204
|
+
return count;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function trimmed(
|
|
208
|
+
chunkSignal: EdfChunkSignal,
|
|
209
|
+
signal: EdfSignal,
|
|
210
|
+
firstIndex: number,
|
|
211
|
+
sampleCount: number,
|
|
212
|
+
startSeconds: number,
|
|
213
|
+
): EdfChunkSignal {
|
|
214
|
+
const digital = chunkSignal.digital.subarray(firstIndex, firstIndex + sampleCount);
|
|
215
|
+
const keptEverything = firstIndex === 0 && digital.length === chunkSignal.digital.length;
|
|
216
|
+
return {
|
|
217
|
+
signalIndex: chunkSignal.signalIndex,
|
|
218
|
+
// From the view, so the count and the data cannot disagree even if the chunk they came from
|
|
219
|
+
// declared a length its array did not have.
|
|
220
|
+
sampleCount: digital.length,
|
|
221
|
+
digital,
|
|
222
|
+
firstSampleIndex: chunkSignal.firstSampleIndex + firstIndex,
|
|
223
|
+
startSeconds,
|
|
224
|
+
// Re-counted only when it can have changed and only when there is something to find: a
|
|
225
|
+
// chunk with no out-of-range samples cannot acquire one by being narrowed.
|
|
226
|
+
outOfDigitalRangeCount:
|
|
227
|
+
keptEverything || chunkSignal.outOfDigitalRangeCount === 0
|
|
228
|
+
? chunkSignal.outOfDigitalRangeCount
|
|
229
|
+
: countOutOfDigitalRange(digital, signal),
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* The exact per-signal trim of a record-aligned chunk to `[startSeconds, startSeconds + duration)`.
|
|
235
|
+
*
|
|
236
|
+
* Sample `j` of the chunk starts at `chunkSignal.startSeconds + j * recordDuration /
|
|
237
|
+
* samplesPerRecord`, so the samples inside the window are those with
|
|
238
|
+
* `j * recordDuration >= relativeStart * samplesPerRecord` and
|
|
239
|
+
* `j * recordDuration < relativeEnd * samplesPerRecord`. Both comparisons are integer bigint
|
|
240
|
+
* products of on-disk quantities — no division, no sample rate, no float bound — which is what
|
|
241
|
+
* makes the boundary sample the same one every time and on every platform.
|
|
242
|
+
*
|
|
243
|
+
* The chunk must be one contiguous run of records (what `readWindow` returns), because that is
|
|
244
|
+
* what makes the sample grid uniform across it.
|
|
245
|
+
*
|
|
246
|
+
* `digital` in the result is a SUBARRAY view of the input's, so trimming allocates nothing and
|
|
247
|
+
* the two share memory. A window that only partly overlaps the chunk is clamped to the samples
|
|
248
|
+
* that exist; one that misses it entirely yields a zero-length result rather than an error.
|
|
249
|
+
*/
|
|
250
|
+
export function trimToWindow(
|
|
251
|
+
header: EdfHeader,
|
|
252
|
+
chunkSignal: EdfChunkSignal,
|
|
253
|
+
startSeconds: number,
|
|
254
|
+
durationSeconds: number,
|
|
255
|
+
): EdfChunkSignal {
|
|
256
|
+
const signal = signalAt(header, chunkSignal.signalIndex);
|
|
257
|
+
const samplesPerRecord = signal.samplesPerRecord;
|
|
258
|
+
const durationTicks = header.recordDurationTicks;
|
|
259
|
+
const available = Math.min(chunkSignal.sampleCount, chunkSignal.digital.length);
|
|
260
|
+
|
|
261
|
+
const windowStartTicks = secondsToTicks(startSeconds);
|
|
262
|
+
const windowDurationTicks = secondsToTicks(durationSeconds);
|
|
263
|
+
// The chunk's own start is a float only because `EdfChunkSignal` publishes seconds; it was
|
|
264
|
+
// produced from exact ticks by `ticksToSeconds`, and rounding back to the nearest tick recovers
|
|
265
|
+
// them for any recording shorter than ~28.5 years.
|
|
266
|
+
const chunkStartTicks = secondsToTicks(chunkSignal.startSeconds);
|
|
267
|
+
|
|
268
|
+
// Nothing advances in time within the chunk: a zero record duration puts every sample at the
|
|
269
|
+
// chunk's start instant, and a signal with no samples per record has no grid at all. The chunk
|
|
270
|
+
// is then either wholly inside the window or wholly outside it.
|
|
271
|
+
if (durationTicks === 0n || samplesPerRecord === 0) {
|
|
272
|
+
const inside =
|
|
273
|
+
windowDurationTicks > 0n &&
|
|
274
|
+
windowStartTicks <= chunkStartTicks &&
|
|
275
|
+
chunkStartTicks < windowStartTicks + windowDurationTicks;
|
|
276
|
+
return inside
|
|
277
|
+
? trimmed(chunkSignal, signal, 0, available, chunkSignal.startSeconds)
|
|
278
|
+
: trimmed(chunkSignal, signal, 0, 0, chunkSignal.startSeconds);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const samplesPerRecordTicks = BigInt(samplesPerRecord);
|
|
282
|
+
const relativeStartTicks = windowStartTicks - chunkStartTicks;
|
|
283
|
+
const relativeEndTicks = relativeStartTicks + windowDurationTicks;
|
|
284
|
+
|
|
285
|
+
const firstIndex = clampToInt(
|
|
286
|
+
ceilDiv(relativeStartTicks * samplesPerRecordTicks, durationTicks),
|
|
287
|
+
0,
|
|
288
|
+
available,
|
|
289
|
+
);
|
|
290
|
+
const lastIndex = clampToInt(
|
|
291
|
+
ceilDiv(relativeEndTicks * samplesPerRecordTicks, durationTicks) - 1n,
|
|
292
|
+
-1,
|
|
293
|
+
available - 1,
|
|
294
|
+
);
|
|
295
|
+
const sampleCount = lastIndex < firstIndex ? 0 : lastIndex - firstIndex + 1;
|
|
296
|
+
|
|
297
|
+
return trimmed(
|
|
298
|
+
chunkSignal,
|
|
299
|
+
signal,
|
|
300
|
+
firstIndex,
|
|
301
|
+
sampleCount,
|
|
302
|
+
sampleStartSeconds(chunkStartTicks, BigInt(firstIndex), durationTicks, samplesPerRecordTicks),
|
|
303
|
+
);
|
|
304
|
+
}
|