/** * The record index: two probes at open, one targeted probe on demand, a full scan only if asked. * * Layer 6. This module owns the I/O STRATEGY for record onsets and nothing else — what a valid * timeline is belongs to `time/timeline.ts`, what a timekeeping TAL is belongs to * `tal/annotations.ts`, and segmentation belongs to `time/segments.ts`. Every onset that reaches * those modules from here came out of `decodeAnnotations`, so the "first TAL of the first * annotation signal" rule has exactly one implementation. * * Cost is the design constraint. Opening a million-record EDF+D over HTTP must not read the file, * so: * * - a file with no annotation signal is probed ZERO times: without a timekeeping TAL there is no * per-record onset on disk, and record `r` starts at `r * recordDuration` by definition; * - otherwise `buildTimeline` probes exactly two records, the first and the last, which detects * any NET drift of the timeline for two reads. It is not a proof of contiguity, and * `time/timeline.ts` says so in the diagnostic it emits; * - `onsetTicks(r)` reads that ONE record and memoises the answer, so `locate()` costs * O(log recordCount) reads and a second `locate()` nearby costs almost none; * - `buildRecordIndex()` is the only function here that touches every record, it is chunked so * memory stays bounded whatever the file size, and it is never called implicitly. * * A probe reads a whole data record rather than just the annotation signal's region. That is the * design's "unit of I/O" decision — the unit is the record range, never the channel range — and * it is also what lets `decodeAnnotations` own the timekeeping rule: it requires the record's * full bytes, and reading less would mean reimplementing that rule here. */ import { appendDiagnostics, assertParseOptions } from './diagnostics/collector.js'; import { EdfRangeError } from './errors.js'; import { readRecordBytes } from './io/read.js'; import { assertByteSource, assertReadOptions } from './io/source.js'; import { requireFunctionOption, resolveMaterializeBudget } from './options.js'; import { decodeAnnotations } from './tal/annotations.js'; import { saturateToInt64, secondsToTicks, ticksToSeconds } from './tal/ticks.js'; import { describeValue } from './text/describe.js'; import { buildSegmentation } from './time/segments.js'; import { assertMonotonicOnsetArray, assertMonotonicOnsets, buildTimelineFromProbes, type RecordOnsetProbe, } from './time/timeline.js'; import type { BuildIndexOptions, ByteSource, DecodeAnnotationsOptions, EdfDiagnostic, EdfGap, EdfHeader, EdfLocation, EdfRecordIndex, EdfRecording, EdfSegment, EdfTimeline, OpenOptions, ReadOptions, RecordRange, } from './types.js'; /** * How much a full scan is allowed to hold at once, independently of `maxMaterializeBytes`. * * The materialisation budget is a ceiling on what one call may allocate; this is the working set * of a traversal that could otherwise read a 13 GB BDF into a single buffer just because the * budget permitted it. A scan is sequential, so a bigger block buys nothing. */ const SCAN_BLOCK_TARGET_BYTES = 4 * 1024 * 1024; /** Records per chunk of a full traversal: bounded memory, and never fewer than one record. */ export function scanChunkRecords(header: EdfHeader, maxMaterializeBytes?: number): number { const budget = Math.min(SCAN_BLOCK_TARGET_BYTES, resolveMaterializeBudget(maxMaterializeBytes)); if (header.recordByteLength <= 0) return 1; return Math.max(1, Math.floor(budget / header.recordByteLength)); } /** * The onset a record has when the file carries no timekeeping TAL to read. * * Not a fallback for a missing TAL — `decodeAnnotations` owns that case and reports it — but the * definition for a plain EDF or BDF file, where record onsets are not stored at all. */ function nominalOnsetTicks(header: EdfHeader, recordIndex: number): bigint { // Saturated, because every onset array here is a BigInt64Array and assignment to one wraps // rather than throwing. A file with no annotation channel gets its onsets purely from this // arithmetic, so an overflowing recordDuration produced an array that jumped backwards — read // downstream as one segment per record with negative gaps, reported as a 'complete' index with // no diagnostic at all. decodeAnnotations already saturated its own derived onsets; this path // was the one that did not. return saturateToInt64(BigInt(recordIndex) * header.recordDurationTicks); } /** Whether a probe found no timekeeping TAL at all, so its onset was derived rather than read. */ function observedNoTimekeeping(diagnostics: readonly EdfDiagnostic[]): boolean { return diagnostics.some((diagnostic) => diagnostic.code === 'TIMEKEEPING_TAL_MISSING'); } /** True when the file stores per-record onsets, i.e. when probing can learn anything. */ function hasTimekeeping(header: EdfHeader): boolean { return header.annotationSignalIndices.length > 0; } interface OnsetProbe { readonly ticks: bigint; readonly diagnostics: readonly EdfDiagnostic[]; } async function probeOnset( source: ByteSource, header: EdfHeader, recordIndex: number, options?: DecodeAnnotationsOptions & ReadOptions, ): Promise { const records: RecordRange = { start: recordIndex, count: 1 }; const bytes = await readRecordBytes(source, header, records, options); const decoded = decodeAnnotations(header, bytes, records, options); // decodeAnnotations fills one entry per record in the range, always; the range is one record. const ticks = decoded.recordOnsetTicks[0] ?? nominalOnsetTicks(header, recordIndex); return { ticks, diagnostics: decoded.diagnostics }; } interface IndexInput { readonly coverage: 'probed' | 'complete'; readonly recordCount: number; readonly recordDurationTicks: bigint; /** Record 0's own onset. Subtracting it turns a stored onset into elapsed recording time. */ readonly startOffsetTicks: bigint; readonly segments: readonly EdfSegment[] | undefined; readonly gaps: readonly EdfGap[] | undefined; readonly onsetOf: (recordIndex: number, options?: ReadOptions) => Promise; } /** * The one `EdfRecordIndex` implementation. * * `locate` is written against `onsetOf` alone, so the probed index and the complete one share it: * the first pays one read per probe and memoises, the second answers from an array it already * has. The search is the same either way, and so is its behaviour at a gap. */ function createIndex(input: IndexInput): EdfRecordIndex { const { recordCount, recordDurationTicks, startOffsetTicks, onsetOf } = input; function assertRecordIndex(recordIndex: number): void { if (Number.isSafeInteger(recordIndex) && recordIndex >= 0 && recordIndex < recordCount) { return; } /* * Branched on WHY, because the flat sentence was false for two of the three reasons a value * reaches here. A string interpolates as its digits, so `onsetTicks('2')` on an eight-record * file was refused with "record 2 is not one of the 8 data records this file contains" — * naming a rule the value satisfies, about a record that is right there. And 1.5 is not * outside the eight either; it falls between two of them, which is the distinction 0.6.93 * drew for `getSignal`'s own index. */ const why = !Number.isFinite(recordIndex) ? `is ${typeof recordIndex === 'number' ? 'not finite' : 'not a number'}, so it names no ` + 'record at all' : !Number.isInteger(recordIndex) ? 'is not a whole number, so it falls between two records rather than outside them' : `is not one of the ${recordCount} data records this file contains`; throw new EdfRangeError( `record ${describeValue(recordIndex)} ${why}, so it has no onset to read. ` + 'Next: pass a whole index in ' + `0..${recordCount - 1}, or call locate(seconds) to find one for a time.`, { requested: { start: recordIndex, count: 1 }, available: { start: 0, count: recordCount } }, ); } /* * The read options, HERE, because nothing downstream of this line can ever see them. * * `buildTimeline` builds every probe's options by spreading these — `{ ...readOptions, strict }` * — and spreading an `AbortSignal` or a number yields `{ strict }`: a perfectly good `ReadOptions` * by the time `assertReadOptions` meets it, with the `signal` and the `maxMaterializeBytes` gone. * The comment beside `assertParseOptions` in `buildTimeline` makes exactly this argument for the * parse half and stops there; the read half launders the same way and had no guard at all. * * So `index.locate(seconds, controller.signal)` and `index.onsetTicks(r, controller.signal)` * resolved, uncancellable, on every file — and these two are the calls whose cost this type's own * docblock spells out in reads, "which is exactly the number a caller planning HTTP range requests * is reading this line to compute". Memoisation makes it worse rather than better: record 0 and the * last record are already in hand, so the same mistake issued no read at all for them. */ async function onsetTicks(recordIndex: number, options?: ReadOptions): Promise { assertRecordIndex(recordIndex); assertReadOptions(options); return onsetOf(recordIndex, options); } /** * The last record whose onset is at or before `targetTicks`, or `undefined` when every record * starts after it. * * Binary search over a monotonic sequence, and monotonicity is verified at every pair the * search actually observes: a violation makes every time-based answer for the file wrong, so * `assertMonotonicOnsets` throws rather than letting the search return a plausible record. */ async function findRecordAtOrBefore( targetTicks: bigint, options?: ReadOptions, ): Promise<{ recordIndex: number; onsetTicks: bigint } | undefined> { let low = 0; let lowTicks = await onsetOf(0, options); if (targetTicks < lowTicks) return undefined; let high = recordCount - 1; if (high === low) return { recordIndex: low, onsetTicks: lowTicks }; let highTicks = await onsetOf(high, options); assertMonotonicOnsets( { recordIndex: low, onsetTicks: lowTicks }, { recordIndex: high, onsetTicks: highTicks }, ); if (targetTicks >= highTicks) return { recordIndex: high, onsetTicks: highTicks }; // Invariant: lowTicks <= target < highTicks, and low < high. while (high - low > 1) { const middle = low + Math.floor((high - low) / 2); const middleTicks = await onsetOf(middle, options); assertMonotonicOnsets( { recordIndex: low, onsetTicks: lowTicks }, { recordIndex: middle, onsetTicks: middleTicks }, ); assertMonotonicOnsets( { recordIndex: middle, onsetTicks: middleTicks }, { recordIndex: high, onsetTicks: highTicks }, ); if (middleTicks <= targetTicks) { low = middle; lowTicks = middleTicks; } else { high = middle; highTicks = middleTicks; } } return { recordIndex: low, onsetTicks: lowTicks }; } async function locate(seconds: number, options?: ReadOptions): Promise { // Before the record count decides anything: a file with no records answers `undefined`, which // is one of this call's real answers, so it must not be reachable with options nobody checked. assertReadOptions(options); if (recordCount <= 0) return undefined; // `seconds` is elapsed recording time; stored onsets are relative to the header start time. const targetTicks = secondsToTicks(seconds, 'seconds') + startOffsetTicks; const found = await findRecordAtOrBefore(targetTicks, options); if (found === undefined) return undefined; // Both values are exact here and both are published as such. The record's start on the // recording's axis is a rebased onset, and the offset is a difference of two onsets. const recordStartTicks = found.onsetTicks - startOffsetTicks; const recordStartSeconds = ticksToSeconds(recordStartTicks); if (recordDurationTicks === 0n) { // Zero-duration records occupy no time, so only the instant itself is inside one. The last // record sharing that instant is the one returned, which is what the search converges on. if (targetTicks !== found.onsetTicks) return undefined; return { recordIndex: found.recordIndex, recordStartSeconds, recordStartTicks, offsetInRecordSeconds: 0, offsetInRecordTicks: 0n, }; } // Past the end of the record it follows: the time is in a gap, or after the recording. if (targetTicks >= found.onsetTicks + recordDurationTicks) return undefined; const offsetInRecordTicks = targetTicks - found.onsetTicks; return { recordIndex: found.recordIndex, recordStartSeconds, recordStartTicks, offsetInRecordSeconds: ticksToSeconds(offsetInRecordTicks), offsetInRecordTicks, }; } return { coverage: input.coverage, recordCount, segments: input.segments, gaps: input.gaps, onsetTicks, locate, }; } /** * The timeline and a lazily probing index, for two reads at most. * * The probes are records 0 and `recordCount - 1` (one probe for a single-record file, none at all * when the file has no annotation signal). Both are memoised into the index, so `onsetTicks(0)` * and `onsetTicks(recordCount - 1)` are free after `openEdf`. * * `index.coverage` stays `'probed'` and `index.segments`/`index.gaps` stay `undefined` until * `buildRecordIndex()` promotes them. Nothing on the returned object can be mistaken for a * verified statement that the recording is continuous. */ export async function buildTimeline( source: ByteSource, header: EdfHeader, options?: OpenOptions, ): Promise<{ timeline: EdfTimeline; index: EdfRecordIndex }> { /* * The two functions this module exports side by side take different shapes: `buildRecordIndex` * takes the recording, and this one takes the source and the header separately, because it is * what `openEdf` calls to BUILD a recording and there is none yet. So `buildTimeline(recording)` * is the shape its sibling teaches, and it read `undefined.recordCount` — V8's `Cannot read * properties of undefined`, naming the field rather than the argument, and saying nothing about * the two this call wants (fixed in 0.6.106). */ if (typeof (source as { header?: unknown } | null | undefined)?.header === 'object') { throw new RangeError( 'buildTimeline(): that is a recording, and this call takes the source and the header ' + 'separately — it is what builds a recording, so there is none yet. Next: pass ' + '(recording.source, recording.header), or call buildRecordIndex(recording) for the full ' + 'scan.', ); } assertByteSource(source); /* * And the SECOND argument, which nothing checked at all. * * 0.6.106 guarded the first one, for the confusion between this call's shape and its sibling's. * The header beside it was left open, and it is the argument with the forgotten `await` in it: * `api-reading.md` writes the pair out as `await buildTimeline(source, header)`, and the header * there comes from `readHeader(source)`, which is async. `buildTimeline(source, * readHeader(source))` is that line one keyword short. * * `recordCount` read back `undefined` without complaining, and `hasTimekeeping` then reached * `header.annotationSignalIndices.length` and threw V8's `Cannot read properties of undefined` — * a `TypeError` naming an internal field, with no `Next:` clause, from a published export. * * Checked on `signals`, which is what every other header guard in the package checks, and the * pending Promise is named the way 0.6.217, 0.6.229 and 0.6.232 name it. */ if (!Array.isArray((header as { signals?: unknown } | null | undefined)?.signals)) { if (typeof (header as { then?: unknown } | null | undefined)?.then === 'function') { throw new RangeError( 'buildTimeline(): that is a pending Promise, not a header. Next: await ' + 'readHeader(source) — it resolves to the header this takes.', ); } throw new RangeError( 'buildTimeline(): that is not a header — it has no signals, and this call reads the ' + 'annotation channels off it to find the timekeeping. Next: pass what readHeader(source) ' + 'resolved to, or what parseHeader(bytes, sourceByteLength) returned.', ); } /* * A CHUNK, whose `signals` array satisfies the test above and whose entries are samples. * * The same shape `validateHeader` and the three lookups each earned a branch for — "being an * array of the right signals is the test", as 0.6.183 and 0.6.186 settled — and here it reached * `hasTimekeeping` and threw the same `Cannot read properties of undefined` the absent header * did, one step further in. */ if ( typeof (header.signals[0] as { signalIndex?: unknown } | undefined)?.signalIndex === 'number' ) { throw new RangeError( 'buildTimeline(): that is a chunk, not a header — a chunk has a signals array too, but its ' + 'entries carry samples rather than the declarations the timekeeping is found from. Next: ' + 'pass recording.header.', ); } const recordCount = header.recordCount; const timekept = hasTimekeeping(header); // Its own, before any sink exists: this function reads `strict` itself and hands the resolved // boolean down, so a bare value here would build `{ strict: false }` and reach the sink as a // perfectly good object. assertParseOptions(options); const strict = options?.strict === true; const probeOptions = ( readOptions: ReadOptions | undefined, originTicks?: bigint, ): DecodeAnnotationsOptions & ReadOptions => { const base = readOptions === undefined ? { strict } : { ...readOptions, strict }; return originTicks === undefined ? base : { ...base, originTicks }; }; const memo = new Map(); const probes: RecordOnsetProbe[] = []; const probeDiagnostics: EdfDiagnostic[] = []; const probeIndices = recordCount === 0 ? [] : recordCount === 1 ? [0] : [0, recordCount - 1]; for (const recordIndex of probeIndices) { if (!timekept) { const ticks = nominalOnsetTicks(header, recordIndex); memo.set(recordIndex, ticks); probes.push({ recordIndex, onsetTicks: ticks }); continue; } // Record 0 is probed first and defines the origin, so by the time the last record is probed // its true onset is in `memo`. Without handing it over, a last record with no timekeeping TAL // derived its onset from zero and appeared to sit `startOffset` seconds early — enough to // fake a discontinuity in a conforming file and make readWindow refuse every window in it. const probe = await probeOnset(source, header, recordIndex, probeOptions(options, memo.get(0))); memo.set(recordIndex, probe.ticks); appendDiagnostics(probeDiagnostics, probe.diagnostics); probes.push({ recordIndex, onsetTicks: probe.ticks }); /* * RECORD 0 IS THE ONE THE COMMENT ABOVE CANNOT HELP. * * Every other probe is handed record 0's onset as its origin. Record 0 has no origin to be * handed, so when ITS timekeeping TAL is missing the derivation falls back to zero — and * `startOffsetTicks` becomes 0 rather than the recording's true sub-second start. * * The consequences are the ones 0.1.4 fixed for the LAST record, on a file that is perfectly * contiguous: `spanTicks` exceeds `coveredTicks` by the start offset, `openEdf` reports * DISCONTINUITY_IN_CONTINUOUS_FILE, `readWindow` refuses EVERY window in the file, and * `buildRecordIndex` reports two segments with a gap that does not exist. `t = 0` also stops * being the start of record 0, so the whole axis shifts against the identical file with its * TAL intact (fixed in 0.3.29). * * Recovered from record 1, not from the last record: adjacent records are the weakest * assumption available — only that ONE pair is contiguous — whereas deriving from the last * record would absorb every gap in the file into the offset and hide a real discontinuity * instead of inventing one. One extra read, and only on a file that is already defective. */ if (recordIndex === 0 && recordCount > 1 && observedNoTimekeeping(probe.diagnostics)) { const neighbour = await probeOnset(source, header, 1, probeOptions(options)); const recovered = saturateToInt64(neighbour.ticks - header.recordDurationTicks); memo.set(0, recovered); memo.set(1, neighbour.ticks); probes[probes.length - 1] = { recordIndex: 0, onsetTicks: recovered }; } } const timeline = buildTimelineFromProbes({ header, probes, probeDiagnostics }, options); async function onsetOf(recordIndex: number, readOptions?: ReadOptions): Promise { const cached = memo.get(recordIndex); if (cached !== undefined) return cached; if (!timekept) { const ticks = nominalOnsetTicks(header, recordIndex); memo.set(recordIndex, ticks); return ticks; } const probe = await probeOnset( source, header, recordIndex, probeOptions(readOptions, timeline.startOffsetTicks), ); memo.set(recordIndex, probe.ticks); return probe.ticks; } const index = createIndex({ coverage: 'probed', recordCount, recordDurationTicks: header.recordDurationTicks, startOffsetTicks: timeline.startOffsetTicks, segments: undefined, gaps: undefined, onsetOf, }); return { timeline, index }; } /** * Every record's onset, read in bounded chunks. * * `onProgress` is called once per chunk with the number of records finished, so a caller can show * a bar for the one operation in edfcore whose cost is proportional to the file. * * A file with no annotation signal is not scanned: its record onsets are arithmetic, so reading * the data would answer a question the bytes do not contain. `onProgress` is still called once, * with the traversal complete, so a caller's bar finishes. */ async function scanOnsets( recording: EdfRecording, options: BuildIndexOptions | undefined, ): Promise { const { source, header } = recording; const recordCount = header.recordCount; const onsets = new BigInt64Array(recordCount); if (!hasTimekeeping(header)) { for (let recordIndex = 0; recordIndex < recordCount; recordIndex += 1) { onsets[recordIndex] = nominalOnsetTicks(header, recordIndex); } options?.onProgress?.(recordCount, recordCount); return onsets; } const chunkRecords = scanChunkRecords(header, options?.maxMaterializeBytes); let scanned = 0; // A file with no records has a traversal that is already complete, and the branch above says // what to do about that: report it once "so a caller's bar finishes". The loop cannot, because // it never runs — so the caller who asked for progress on an empty file was told nothing at all. if (recordCount === 0) options?.onProgress?.(0, 0); while (scanned < recordCount) { const records: RecordRange = { start: scanned, count: Math.min(chunkRecords, recordCount - scanned), }; const bytes = await readRecordBytes(source, header, records, options); // The origin comes from the recording, not from whatever this chunk happens to contain. // Chunking is a memory-bounding detail and must not change the answer: without this, a chunk // holding no observed onset derived from zero, so the onsets, the segments, the gaps and // even a fatal TIMELINE_NOT_MONOTONIC varied with maxMaterializeBytes. const decoded = decodeAnnotations(header, bytes, records, { ...options, originTicks: recording.timeline.startOffsetTicks, }); onsets.set(decoded.recordOnsetTicks, scanned); scanned += records.count; options?.onProgress?.(scanned, recordCount); } return onsets; } /** * A `'complete'` index: every onset verified, with the segments and gaps they imply. * * This is one of only two functions that read the whole file, the other being * `validateRecording`, and it is never called implicitly. Its diagnostics are deliberately not * returned — an `EdfRecordIndex` is a structural answer, and `validateRecording()` is the call * that reports on a traversal — but a non-monotonic timeline still throws, because no index over * it would mean anything. * * `EdfRecording` is a plain struct, so the returned index is used by rebuilding one: * `readWindow({ ...recording, index }, selection)`. */ export async function buildRecordIndex( recording: EdfRecording, options?: BuildIndexOptions, ): Promise { // The mirror of the check in `buildTimeline`: this one takes the recording, and the header is // what a reader holding the other function's arguments reaches for. const given = recording as { header?: unknown; signals?: unknown } | null | undefined; if (given == null || typeof given.header !== 'object' || given.header === null) { /* * A FORGOTTEN AWAIT, which the other two copies of this sentence both name and this one did not. * * `openEdf(source)` is async, so a pending Promise is what it returns and the recording is what * that Promise resolves to. 0.6.231 said so in `assertRecording`, the guard the five reading * calls share; here the Promise fell into the arm written for a number or a string and was told * it "is not the object openEdf() returns" — which it is exactly. * * Both of these calls are reached with the recording as their FIRST argument on the line after * `openEdf`, so the keyword is the thing most likely to be missing. */ if (typeof (given as { then?: unknown } | undefined)?.then === 'function') { throw new RangeError( 'buildRecordIndex(): the recording is a pending Promise — openEdf(source) is async, so ' + 'that is what it returns, and the recording is what it resolves to. Next: pass `await ' + 'openEdf(source)`.', ); } throw new RangeError( `buildRecordIndex(): ${ Array.isArray(given?.signals) ? 'that is a header, and a full scan needs the source and the timeline too' : 'the recording is not the object openEdf() returns' }. Next: pass \`await openEdf(source)\`.`, ); } // `scanOnsets` SPREADS these into the decode options, and spreading a bare value yields `{}` — // a perfectly good object by the time a sink sees it, with `strict` gone. assertParseOptions(options); // And the callback, which optional-call syntax guards against absence and not against a wrong // kind. It is called from inside the scan loop, so a file with no records reached it never and a // long one reached it partway through — the data-dependent guard 0.6.169 and 0.6.177 removed. requireFunctionOption( options?.onProgress, 'onProgress', 'with the records scanned so far and the total', ); const { header, timeline } = recording; const onsets = await scanOnsets(recording, options); assertMonotonicOnsetArray(onsets); const segmentation = buildSegmentation( onsets, header.recordDurationTicks, timeline.startOffsetTicks, ); async function onsetOf(recordIndex: number): Promise { // Bounds were checked by `createIndex` before this is reached. return onsets[recordIndex] ?? nominalOnsetTicks(header, recordIndex); } return createIndex({ coverage: 'complete', recordCount: header.recordCount, recordDurationTicks: header.recordDurationTicks, startOffsetTicks: timeline.startOffsetTicks, segments: segmentation.segments, gaps: segmentation.gaps, onsetOf, }); } /** * The index itself, before its coverage decides anything. * * The three functions below all branch on `coverage !== 'complete'`, and every one of them read that * off whatever arrived. So a wrong argument took the probed-index branch: `gapAt(recording)` was * told "this one is probed, so it has read record 0 and the last record and nothing between", a * precise description of something the caller never passed, and `contiguityOf(recording)` returned * `'unknown'` outright (0.6.91, extended to its two siblings in 0.6.109). * * `because` is the call's own reason, because the three differ: two would misdiagnose, and one * would answer. */ function assertIndex(index: EdfRecordIndex, call: string, because: string): void { if (typeof (index as { coverage?: unknown } | null | undefined)?.coverage === 'string') return; /* * A FORGOTTEN AWAIT, which the next step points a reader straight at. * * This message's own advice is "the index buildRecordIndex(recording) returns" — and that call is * async, so following it without the keyword hands these three the pending Promise and earns this * same sentence again. `contiguityOf(buildRecordIndex(recording))` is the whole loop: read the * advice, take it, get the advice. * * 0.6.89 coined the phrase for the recording and said why it is worth a branch: a message that * names a field rather than the argument says "nothing about the one keyword that fixes it". * 0.6.214 taught `describeValue` to say it, which covers every message that reads its subject out * of that helper — this one names its subject in fixed text, so it is the family that needs saying * separately, and it is the one whose advice leads here. */ const pending = typeof (index as { then?: unknown } | null | undefined)?.then === 'function'; throw new RangeError( `${call}(): that is ${ pending ? 'a pending Promise, not a record index' : 'not a record index — it has no `coverage`' }, and ${because}. Next: ${ pending ? 'await buildRecordIndex(recording) — it resolves to the index this takes.' : 'pass recording.index, or the index buildRecordIndex(recording) returns.' }`, ); } /** * Whether the records run without gaps — or whether nobody has checked. * * Three answers, not two. A probed index has read record 0 and the last record and nothing in * between, so it cannot rule out a gap in the middle; `'unknown'` is the truthful answer there, * and collapsing it into `false` would report a discontinuity nobody observed, while collapsing * it into `true` would claim a contiguity nobody verified. * * `buildRecordIndex()` is what turns `'unknown'` into a real answer. */ export function contiguityOf(index: EdfRecordIndex): 'contiguous' | 'discontinuous' | 'unknown' { /* * `'unknown'` is one of the three real answers, which is what makes a wrong argument dangerous * here rather than merely unhelpful. `contiguityOf(recording)` — the shape the name invites, * since every other question a reader asks is asked of the recording — answered `'unknown'`, * indistinguishable from a probed index, and nothing told the caller otherwise. `segmentAt` * makes this argument for its own `undefined` already (fixed in 0.6.91). */ assertIndex( index, 'contiguityOf', '"unknown" is one of this function\'s real answers, so a wrong argument must not be able to ' + 'produce it', ); if (index.coverage !== 'complete' || index.gaps === undefined) return 'unknown'; return index.gaps.length === 0 ? 'contiguous' : 'discontinuous'; } /** * The segment covering an instant, or `undefined` when the instant falls in a gap or outside the * recording. * * Pure and synchronous, which is the point: `index.locate()` answers the same question by probing * the file, and a viewer that asks on every mouse move should not be issuing reads. A completed * index already holds the segments, so this is a binary search over them. * * THROWS on a probed index rather than returning `undefined`. `undefined` here means "no records * cover this time", and a probed index has read record 0 and the last record and nothing between — * it does not know where the segments are, so it cannot say that about any instant in the middle. * Returning `undefined` would merge "there is a gap here" with "nobody looked", which are the two * answers a caller most needs to keep apart. * * `seconds` is on the recording's own axis: `t = 0` is the start of record 0, matching * `segment.startSeconds`, `readWindow` and `readEnvelope`. * * ON A ZERO RECORD DURATION this returns `undefined` for every time, and that is correct rather * than a gap in the implementation. Records then occupy no time at all, so each segment's * half-open interval `[start, start)` is empty and no instant is inside one. A real sleep-staging * file is shaped exactly like that — legal EDF, and the same reason `sampleAt` refuses such a file * outright. Index by record with `readRecords` instead. */ export function segmentAt(index: EdfRecordIndex, seconds: number): EdfSegment | undefined { assertIndex( index, 'segmentAt', 'the probed-index refusal below it would describe something you never passed', ); const segments = index.segments; if (index.coverage !== 'complete' || segments === undefined) { throw new RangeError( 'segmentAt() needs a complete index: this one is probed, so it knows record 0 and the last ' + 'record and nothing between, and cannot say which segment covers a time in the middle. ' + 'Next: await buildRecordIndex(recording) and pass the index it returns.', ); } // NaN fails every comparison below, so the binary search would walk to an arbitrary segment and // return it. Refusing is the only honest answer for a time that is not a time. if (!Number.isFinite(seconds)) { throw new RangeError( `segmentAt() needs a finite time in seconds, received ${describeValue(seconds)}. ` + `Next: pass a number, ` + 'not the result of dividing by a zero recordDurationSeconds.', ); } // The comparison is in TICKS. The bounds on a segment are float64 conversions of exact tick // values, and a boundary is precisely where a lossy conversion decides the answer: `sampleAt` // picks a segment through this function and then measures the offset from `segment.startTicks`, // so a boundary resolved on the seconds and an offset measured in ticks can disagree about // which segment an instant belongs to (fixed in 0.3.6). const ticks = secondsToTicks(seconds, 'seconds'); let low = 0; let high = segments.length - 1; while (low <= high) { const middle = (low + high) >> 1; const segment = segments[middle] as EdfSegment; // Half-open, so a segment that ends exactly where the next begins is not both. if (ticks < segment.startTicks) high = middle - 1; else if (ticks >= segment.endTicks) low = middle + 1; else return segment; } return undefined; } /** * The gap covering an instant, or `undefined` when a record covers it. * * The complement of `segmentAt`, and the reason it exists separately: `segmentAt` returning * `undefined` tells a viewer there is no data under the cursor and nothing else. What a viewer * then wants — how long the hole is, and when the recording resumes — is on the `EdfGap`. * * Exactly one of the two returns a value for any instant strictly inside the recording, and * neither does for a time before the first record or after the last. Refuses a probed index and a * non-finite time for the same reasons `segmentAt` does. */ export function gapAt(index: EdfRecordIndex, seconds: number): EdfGap | undefined { assertIndex( index, 'gapAt', 'the probed-index refusal below it would describe something you never passed', ); const gaps = index.gaps; if (index.coverage !== 'complete' || gaps === undefined) { throw new RangeError( 'gapAt() needs a complete index: this one is probed, so it has read record 0 and the last ' + 'record and nothing between, and has not looked for gaps at all. ' + 'Next: await buildRecordIndex(recording) and pass the index it returns.', ); } if (!Number.isFinite(seconds)) { throw new RangeError( `gapAt() needs a finite time in seconds, received ${describeValue(seconds)}. ` + `Next: pass a number, the ` + 'way segmentAt() requires one — the two agree about every boundary.', ); } // In ticks, for the reason `segmentAt` states: these two must agree about every boundary, and // they can only be relied on to do that while they compare the same exact values. const ticks = secondsToTicks(seconds, 'seconds'); let low = 0; let high = gaps.length - 1; while (low <= high) { const middle = (low + high) >> 1; const gap = gaps[middle] as EdfGap; // Half-open, matching `segmentAt`: `gap.endTicks` is the first instant with data again, so it // belongs to the segment after the gap and not to the gap. if (ticks < gap.startTicks) high = middle - 1; else if (ticks >= gap.endTicks) low = middle + 1; else return gap; } return undefined; }