/** * Windows in seconds, resolved to records and then to samples. * * Layer 4. Pure and synchronous, both halves of it. `resolveTimeWindow` answers "which records * does this window cost?" before a byte is read, so the price of a window is auditable rather * than discovered; `trimToWindow` narrows a record-aligned chunk to the samples actually asked * for. * * The window is the half-open interval `[startSeconds, startSeconds + durationSeconds)` in * elapsed recording time — `t = 0` is the start of record 0, the axis `time/timeline.ts` fixes. * * Every comparison below is integer or rational arithmetic on ticks, records and * `samplesPerRecord`. `round(t * sampleRateHz)` appears nowhere: `sampleRateHz` is derived and * often not representable (256/3 Hz is a real record duration of 3 s with 256 samples), so * rounding through it walks the answer off by a sample near every large `t` — which is the exact * mistake edfcore exists to stop a consumer from re-implementing. */ import { TICKS_PER_SECOND } from '../constants.js'; import { EdfChannelNotFoundError } from '../errors.js'; import { ceilDiv, floorDiv, secondsToTicks, ticksToSeconds } from '../tal/ticks.js'; import { describeValue } from '../text/describe.js'; import type { EdfChunkSignal, EdfHeader, EdfRecordIndex, EdfSignal, EdfTimeline, RecordRange, } from '../types.js'; const NO_RANGES: readonly RecordRange[] = Object.freeze([]); /** Exact: 10^7 is far below 2^53. */ const TICKS_PER_SECOND_FLOAT = Number(TICKS_PER_SECOND); /** Clamping in bigint first, because `Number()` on a large bigint silently loses digits. */ function clampToInt(value: bigint, low: number, high: number): number { if (value <= BigInt(low)) return low; if (value >= BigInt(high)) return high; return Number(value); } /** * The chunk signal, before the header is asked anything about its index. * * `EdfSignal` and `EdfChunkSignal` are the two per-signal shapes in this package, and they name the * index differently: `index` on the header's, `signalIndex` on the chunk's. Passing the header's — * the one a reader already holds, from `getSignal` or `header.signals[i]` — sent `undefined` into * `signalAt` and earned "signalIndex undefined is not one of the 7 signals in this header … Next: * pass the header the chunk was read with". The header was the argument that was right, and the * advice named it (fixed in 0.6.97). */ export function assertChunkSignal(chunkSignal: EdfChunkSignal, call: string, verb: string): void { if (typeof chunkSignal?.signalIndex === 'number') { if (ArrayBuffer.isView((chunkSignal as { digital?: unknown }).digital)) return; /* * An ENVELOPE signal, which the check above cannot tell from a chunk signal. * * `EdfEnvelopeSignal` and `EdfChunkSignal` share eight of their nine fields — `signalIndex`, * `sampleCount`, `firstSampleIndex`, `startSeconds`, `startTicks` and `outOfDigitalRangeCount` * among them — and differ only in the one that holds the data: `digital` against * `min`/`max`/`counts`. They are also reached identically, as `chunk.signals[i]` off what a read * resolved to, so a reader who has drawn one window with `readEnvelope` and wants a coarser pass * over it hands this exactly the wrong one of the two. * * Both callers then read `.digital` and threw V8's `Cannot read properties of undefined * (reading 'length')`: an internal field, no `Next:` clause, and nothing about the argument — * which is the failure 0.6.97 added this guard to remove. * * `digital` is the field to test, because it is the one both callers read. */ throw new RangeError( `${call}(): that is an envelope signal, not a chunk signal — an envelope carries the ` + `smallest and largest sample of each bucket rather than the samples, so there is none ` + `here to ${verb}. Next: pass one element of chunk.signals from readWindow() or ` + 'readRecords(); readEnvelope() has already reduced its samples away.', ); } const headerSignal = typeof (chunkSignal as unknown as EdfSignal | undefined)?.index === 'number'; throw new RangeError( `${call}(): the signal is ${ headerSignal ? `a header signal, which carries no samples to ${verb}` : `${describeValue(chunkSignal)} with no signalIndex on it` }. Next: pass one element of chunk.signals — the array readWindow() and readRecords() fill.`, ); } function signalAt(header: EdfHeader, signalIndex: number): EdfSignal { // The header itself, before a signal is taken out of it. `trimToWindow` is the only caller, its // OTHER argument is a chunk signal, and the two live one field apart on what a reader holds — // so `trimToWindow(recording, chunkSignal, ...)` is the call that gets written, and it read // `recording.signals[0]` and threw V8's `Cannot read properties of undefined (reading '0')`. // That names the signal index, which was the argument that was right. 0.6.127 swept the same // mistake out of `header/lookup.ts`; this is the last entry point in the package that took a // header without checking one. const signals = (header as { signals?: unknown } | null | undefined)?.signals; if (!Array.isArray(signals)) { /* * A FORGOTTEN AWAIT, in the last entry point that took a header without naming it. * * The comment above calls this "the last entry point in the package that took a header without * checking one", and the check it got says only that the argument has no signals — true of a * pending Promise, and true of almost everything else. 0.6.217 made the argument for the three * lookups and 0.6.236 finished the guards whose advice named `parseHeader`; this one names * `recording.header`, which a reader who called `readHeader(source)` does not have. */ if (typeof (header as { then?: unknown } | null | undefined)?.then === 'function') { throw new RangeError( 'trimToWindow(): that is a pending Promise, not a header. Next: await readHeader(source) ' + '— it resolves to the header this takes.', ); } throw new RangeError( 'trimToWindow(): that is not a header — it has no signals, and this call needs the ' + 'samples-per-record the chunk signal does not carry. Next: pass recording.header.', ); } /* * The CHUNK, which is the one object whose `signals` array holds exactly what the message above * says this call cannot use. * * Both arguments come off the same chunk — `trimToWindow(header, chunk.signals[0], …)` — so the * chunk is what is in hand, and `trimToWindow(chunk, chunk.signals[0], …)` is the pair that gets * written. `chunk.signals[signalIndex]` is then defined, so the lookup below succeeded and * returned a CHUNK signal typed as an `EdfSignal`. Its `samplesPerRecord` is `undefined`, and the * trim arithmetic reached `BigInt(undefined)`: V8's `Cannot convert undefined to a BigInt`, from * a guard whose own sentence had already named the shape it let through. * * 0.6.183 made the same fix for the three lookups in `header/lookup.ts`: being an array is not * the test, being an array of the right signals is. */ if (typeof (signals[0] as { signalIndex?: unknown } | undefined)?.signalIndex === 'number') { throw new RangeError( 'trimToWindow(): that is a chunk, not a header — its signals array holds the very chunk ' + 'signals this call cannot take the samples-per-record from, which is why it needs the ' + 'header beside them. Next: pass recording.header, and keep chunk.signals[i] as the second ' + 'argument.', ); } const signal = header.signals[signalIndex]; if (signal !== undefined) return signal; throw new EdfChannelNotFoundError( `signalIndex ${signalIndex} is not one of the ${header.signals.length} signals in this ` + 'header, so trimToWindow() cannot know how many samples per record it holds. Next: pass ' + 'the header the chunk was read with.', { selector: signalIndex, availableLabels: header.signals.map((s) => s.label) }, ); } /** * The records a window needs, one `RecordRange` per contiguous run it overlaps, in time order. * * Empty when the window falls entirely inside a gap, entirely outside the recording, or has a * non-positive duration — the interval is half-open, so a zero-length window contains no time * and therefore no samples. * * Ranges are RECORD-ALIGNED and are therefore usually wider than the window: a record is the * smallest unit the file can be read by, and `trimToWindow` is how a caller narrows the samples * afterwards. * * With `index.segments` present (`coverage === 'complete'`) the answer is exact. With a probed * index it is exact only while the file is contiguous, which is precisely what * `spanTicks === coveredTicks` states; when it is not, the records a window maps to depend * on onsets nobody has read, and this function refuses rather than guessing them. */ export function resolveTimeWindow( timeline: EdfTimeline, index: EdfRecordIndex, startSeconds: number, durationSeconds: number, ): readonly RecordRange[] { /* * The timeline, and the index, before either decides anything. * * `resolveTimeWindow(header, index, …)` returned an answer. The header carries `recordCount` and * `recordDurationTicks` under the same names with the same meanings, so every branch below ran — * except the one that needs `spanTicks` and `coveredTicks`, which are the timeline's alone. * `undefined !== undefined` is false, so the discontinuity check simply did not fire, and a * window over an EDF+D file with a five-second hole came back as `[{ start: 0, count: 4 }]`: every * record, mapped onto the nominal grid, as if the file were continuous. The correct call throws. * * That is the exact failure this function exists to prevent — "the records a window maps to depend * on onsets nobody has read, and this function refuses rather than guessing them" — reachable by * passing the wrong first argument (fixed in 0.6.123). */ if (typeof (timeline as { spanTicks?: unknown } | null | undefined)?.spanTicks !== 'bigint') { throw new RangeError( 'resolveTimeWindow(): that is not a timeline — it has no spanTicks, and the check that ' + 'refuses to map a window onto a file with an unseen gap is the one that reads it. Next: ' + 'pass recording.timeline.', ); } if (typeof (index as { coverage?: unknown } | null | undefined)?.coverage !== 'string') { throw new RangeError( 'resolveTimeWindow(): the second argument is not a record index — it has no coverage. ' + 'Next: pass recording.index, or the index buildRecordIndex(recording) returns.', ); } /* * And that the two describe the SAME recording. * * They arrive as separate arguments and are only ever paired by a caller. `buildRecordIndex` * resolves to a bare index, so `{ ...recording, index: await buildRecordIndex(recording) }` is the * documented way to put one on — which makes holding the wrong one an ordinary slip: * `validate-index-reuse.test.ts` lists it as "a viewer holding indices for several open * recordings, a helper that caches one per session, a loop that forgets to rebuild", and states * the cost: "not a wrong number but a wrong FILE: the segments and gaps of recording A reported * as the structure of recording B". * * `validateRecording` refuses a mismatched index. Every READ went through here and did not: a * continuous eight-record file carrying a gapped file's index returned records 0..3 for a window * over the whole recording — half the data missing, silently — and `[]` for a window inside the * other file's gap, which reads as a hole in a file that has none. * * A complete index's last segment ends at the recording's span, by construction, so the two * numbers disagreeing is the contradiction. The record counts are compared first because they are * the cheaper test and the commoner mistake; the span is what catches two files of the same * length. A file with no records has no segments and is left to the branch below. */ if (index.recordCount !== timeline.recordCount) { throw new RangeError( `resolveTimeWindow(): the index counts ${index.recordCount} records and the timeline ` + `${timeline.recordCount}, so they were built from different files. Next: pass the index ` + 'this recording was scanned with — buildRecordIndex(recording) returns it.', ); } const lastSegment = index.segments?.[index.segments.length - 1]; if (lastSegment !== undefined && lastSegment.endTicks !== timeline.spanTicks) { throw new RangeError( `resolveTimeWindow(): the index describes a recording spanning ${lastSegment.endTicks} ticks ` + `and the timeline one spanning ${timeline.spanTicks}, so they were built from different ` + "files. Reading through the wrong one reports another recording's gaps as this one's, " + 'which is data missing rather than data wrong. Next: pass the index this recording was ' + 'scanned with — buildRecordIndex(recording) returns it.', ); } const recordCount = timeline.recordCount; if (recordCount <= 0) return NO_RANGES; const windowStartTicks = secondsToTicks(startSeconds, 'startSeconds'); const windowDurationTicks = secondsToTicks(durationSeconds, 'durationSeconds'); if (windowDurationTicks <= 0n) return NO_RANGES; const windowEndTicks = windowStartTicks + windowDurationTicks; // From the timeline, not recovered from its seconds. `resolveTimeWindow` takes no header, so // until 0.3.8 this rounded `recordDurationSeconds` back with `secondsToTicks` and reasoned that // the trip was exact for anything a header can declare. The timeline now carries the value. const durationTicks = timeline.recordDurationTicks; const segments = index.segments; if (segments !== undefined) { const ranges: RecordRange[] = []; for (const segment of segments) { const segmentCount = segment.records.count; if (segmentCount <= 0) continue; const segmentStartTicks = segment.startTicks; // A zero record duration puts every record of the segment at one instant, so the segment // is either wholly inside the window or wholly outside it. if (durationTicks === 0n) { if (windowStartTicks <= segmentStartTicks && segmentStartTicks < windowEndTicks) { ranges.push({ start: segment.records.start, count: segmentCount }); } continue; } const segmentEndTicks = segmentStartTicks + BigInt(segmentCount) * durationTicks; if (segmentEndTicks <= windowStartTicks || segmentStartTicks >= windowEndTicks) continue; const firstOffset = clampToInt( floorDiv(windowStartTicks - segmentStartTicks, durationTicks), 0, segmentCount - 1, ); const lastOffset = clampToInt( ceilDiv(windowEndTicks - segmentStartTicks, durationTicks) - 1n, 0, segmentCount - 1, ); if (lastOffset < firstOffset) continue; ranges.push({ start: segment.records.start + firstOffset, count: lastOffset - firstOffset + 1, }); } return Object.freeze(ranges); } // Ticks, not the seconds beside them: those are float64 conversions, and two different tick // counts can round to the same one. On a long enough recording a real gap then vanished from // this comparison and the window was mapped on the nominal grid (fixed in 0.3.4). if (timeline.spanTicks !== timeline.coveredTicks) { // The test is TWO-SIDED and the message used to name only one side. A span that exceeds the // coverage is a gap; a coverage that exceeds the span means records OVERLAP, and saying // "span 3.5 s but cover only 4 s, so it contains at least one gap" is both arithmetic // nonsense — "only" for the larger number — and the opposite of what the bytes say. The same // file's open-time diagnostic already calls it an overlap, so one file produced two edfcore // messages that contradicted each other. 0.3.3 stated the rule for `edfcore gaps`: a gap is // time no record covers; an overlap is one instant two records both claim (fixed in 0.3.33). const overlapping = timeline.coveredTicks > timeline.spanTicks; const shape = overlapping ? `records covering ${timeline.coveredSeconds} s are packed into a ${timeline.spanSeconds} s ` + 'span, so at least one record starts before the previous one ends' : `its ${recordCount} records span ${timeline.spanSeconds} s but cover only ` + `${timeline.coveredSeconds} s, so it contains at least one gap`; throw new RangeError( `this file cannot be mapped from seconds to records: ${shape}, ` + 'and a probed index knows where neither the discontinuity nor the ' + 'records after it start. ' + // The two seconds above can PRINT the same on a long recording, since that is exactly the // rounding this check stopped relying on. The ticks always differ here, so they are // stated: a message that appears to contradict itself is a message nobody acts on. `Exactly: ${timeline.spanTicks} against ${timeline.coveredTicks} ticks of 100 ns. ` + 'Next: await buildRecordIndex(recording) and pass the index it ' + 'returns, or locate the window with index.locate(seconds).', ); } // Contiguous as far as the probes can tell: record r starts at exactly r * recordDuration. if (durationTicks === 0n) { return windowStartTicks <= 0n && windowEndTicks > 0n ? Object.freeze([{ start: 0, count: recordCount }]) : NO_RANGES; } if (windowEndTicks <= 0n) return NO_RANGES; if (windowStartTicks >= BigInt(recordCount) * durationTicks) return NO_RANGES; const first = clampToInt(floorDiv(windowStartTicks, durationTicks), 0, recordCount - 1); const last = clampToInt(ceilDiv(windowEndTicks, durationTicks) - 1n, 0, recordCount - 1); if (last < first) return NO_RANGES; return Object.freeze([{ start: first, count: last - first + 1 }]); } /** * Sample `firstIndex` of the chunk, as an exact rational, in both units. * * The sample sits at `chunkStart + firstIndex * recordDuration / samplesPerRecord`, and that * division is usually not a whole number of ticks. The whole part goes through `ticksToSeconds` * and only the remainder is divided, so the sub-tick part costs one rounding instead of poisoning * the seconds and the ticks together. * * `ticks` is FLOORED — `floorDiv`, not bigint `/`, which truncates toward zero and would round a * negative chunk start (a pre-stimulus window) the wrong way. Flooring is the rule every boundary * decision in the package follows: a sample covers from its own start to the next one's, so the * tick a sample starts in is the one it is already running in. The remainder is then non-negative * and the two parts still sum to the same seconds. */ function gridSampleStart( chunkStartTicks: bigint, firstIndex: bigint, durationTicks: bigint, samplesPerRecord: bigint, ): { ticks: bigint; seconds: number } { const scaled = chunkStartTicks * samplesPerRecord + firstIndex * durationTicks; const wholeTicks = floorDiv(scaled, samplesPerRecord); const remainder = scaled - wholeTicks * samplesPerRecord; return { ticks: wholeTicks, seconds: ticksToSeconds(wholeTicks) + Number(remainder) / (Number(samplesPerRecord) * TICKS_PER_SECOND_FLOAT), }; } function countOutOfDigitalRange(digital: Int32Array, signal: EdfSignal): number { const low = Math.min(signal.digitalMinimum, signal.digitalMaximum); const high = Math.max(signal.digitalMinimum, signal.digitalMaximum); let count = 0; for (let i = 0; i < digital.length; i += 1) { // biome-ignore lint/style/noNonNullAssertion: i is bounded by digital.length. const value = digital[i]!; if (value < low || value > high) count += 1; } return count; } function trimmed( chunkSignal: EdfChunkSignal, signal: EdfSignal, firstIndex: number, sampleCount: number, start: { ticks: bigint; seconds: number }, ): EdfChunkSignal { const digital = chunkSignal.digital.subarray(firstIndex, firstIndex + sampleCount); // The LENGTH check is what decides this; `firstIndex === 0` follows from it for any non-empty // chunk and is kept because it states the intent. The test named for this line put its // out-of-range sample at index 0, where a head-anchored trim keeps the offender and re-counting // and reusing agree — so `&&` and `||` were indistinguishable across the whole suite, and under // `||` a head trim that dropped the offender still reported it (pinned in 0.3.113). const keptEverything = firstIndex === 0 && digital.length === chunkSignal.digital.length; return { signalIndex: chunkSignal.signalIndex, // From the view, so the count and the data cannot disagree even if the chunk they came from // declared a length its array did not have. sampleCount: digital.length, digital, firstSampleIndex: chunkSignal.firstSampleIndex + firstIndex, startSeconds: start.seconds, startTicks: start.ticks, // Re-counted only when it can have changed and only when there is something to find: a // chunk with no out-of-range samples cannot acquire one by being narrowed. outOfDigitalRangeCount: keptEverything || chunkSignal.outOfDigitalRangeCount === 0 ? chunkSignal.outOfDigitalRangeCount : countOutOfDigitalRange(digital, signal), }; } /** * The exact per-signal trim of a record-aligned chunk to `[startSeconds, startSeconds + duration)`. * * Sample `j` of the chunk is inside the window when the tick edfcore PUBLISHES for it — * `ceil(j * recordDuration / samplesPerRecord)`, the value `gridSampleStartTicks` and * `sampleStartTicksOf` report — falls in `[relativeStart, relativeEnd)`. Since `ceil(x) >= R` iff * `x > R - 1`, both edges stay integer bigint products of on-disk quantities: no division, no * sample rate, no float bound, so the boundary sample is the same one on every platform. * * The comparison is against the PUBLISHED tick, not the sample's exact rational start. Those * differ whenever a boundary is not a whole tick — 256 samples in a one-second record puts sample 1 * at 39,062.5 ticks, published as 39,063 — and selecting on the exact start excluded the sample a * caller had aligned the window to. This docblock stated that older rule until 0.3.95, three * releases after 0.3.56 replaced it. * * The chunk must be one contiguous run of records (what `readWindow` returns), because that is * what makes the sample grid uniform across it. * * `digital` in the result is a SUBARRAY view of the input's, so trimming allocates nothing and * the two share memory. A window that only partly overlaps the chunk is clamped to the samples * that exist; one that misses it entirely yields a zero-length result rather than an error. */ export function trimToWindow( header: EdfHeader, chunkSignal: EdfChunkSignal, startSeconds: number, durationSeconds: number, ): EdfChunkSignal { assertChunkSignal(chunkSignal, 'trimToWindow', 'trim'); const signal = signalAt(header, chunkSignal.signalIndex); const samplesPerRecord = signal.samplesPerRecord; const durationTicks = header.recordDurationTicks; const available = Math.min(chunkSignal.sampleCount, chunkSignal.digital.length); const windowStartTicks = secondsToTicks(startSeconds, 'startSeconds'); const windowDurationTicks = secondsToTicks(durationSeconds, 'durationSeconds'); // The chunk's own start, as the chunk itself recorded it. Until 0.3.7 `EdfChunkSignal` published // only seconds, so this rounded them back — a round trip the comment here bounded at "any // recording shorter than ~28.5 years", which is where 10^7 ticks per second passes 2^53. The // library accepts declared spans up to the int64 tick range, three orders of magnitude past // that, so the bound was reachable rather than theoretical. The value is now carried. const chunkStartTicks = chunkSignal.startTicks; // Nothing advances in time within the chunk: a zero record duration puts every sample at the // chunk's start instant, and a signal with no samples per record has no grid at all. The chunk // is then either wholly inside the window or wholly outside it. if (durationTicks === 0n || samplesPerRecord === 0) { const inside = windowDurationTicks > 0n && windowStartTicks <= chunkStartTicks && chunkStartTicks < windowStartTicks + windowDurationTicks; const unchanged = { ticks: chunkStartTicks, seconds: chunkSignal.startSeconds }; return inside ? trimmed(chunkSignal, signal, 0, available, unchanged) : trimmed(chunkSignal, signal, 0, 0, unchanged); } const samplesPerRecordTicks = BigInt(samplesPerRecord); const relativeStartTicks = windowStartTicks - chunkStartTicks; const relativeEndTicks = relativeStartTicks + windowDurationTicks; /* * Compared against the start tick each sample PUBLISHES, not against its exact rational start. * * `gridSampleStartTicks` and `sampleStartTicksOf` round a sample's start UP to a whole tick, on * purpose, so that flooring it back names the same sample. A boundary need not fall on a whole * tick: 256 samples in a one-second record — the commonest EEG geometry there is — puts sample 1 * at 39,062.5 ticks, published as 39,063. Selecting on the EXACT start then excluded that sample * from a window beginning at its own published start, because 39,063 is later than 39,062.5. Half * of all indices were affected at that rate; at 128 samples per 0.29 s a one-sample-wide window * aligned to a sample start came back EMPTY. * * 0.3.32 fixed the same mismatch in `readTriggers` and recorded the rule it settled on: * "`sampleAt`, `sampleStartTicksOf`, a window bound and `readTriggers` all name the same sample." * The window bound was the one of the four still using the other rounding (fixed in 0.3.56). * * Sample j is in the window when `ceil(j * D / S)` is in `[R, Rend)`, and since * `ceil(x) >= R <=> x > R - 1`, both edges stay a bigint product of on-disk quantities: * first: smallest j with j*D > (R-1)*S -> floorDiv((R-1)*S, D) + 1 * last: largest j with j*D <= (Rend-1)*S -> floorDiv((Rend-1)*S, D) * Identical to the old form whenever a boundary is a whole tick, so no window on a * power-of-ten geometry moves; a sample admitted by the new rule starts at most one tick — 100 * ns, below the resolution edfcore reports in — before the bound, and it is precisely the sample * the caller aligned to. */ const firstIndex = clampToInt( floorDiv((relativeStartTicks - 1n) * samplesPerRecordTicks, durationTicks) + 1n, 0, available, ); const lastIndex = clampToInt( floorDiv((relativeEndTicks - 1n) * samplesPerRecordTicks, durationTicks), -1, available - 1, ); const sampleCount = lastIndex < firstIndex ? 0 : lastIndex - firstIndex + 1; return trimmed( chunkSignal, signal, firstIndex, sampleCount, gridSampleStart(chunkStartTicks, BigInt(firstIndex), durationTicks, samplesPerRecordTicks), ); }