/** * Joining chunks that a read returned as several. * * Layer 7, and pure: nothing here reads. `readWindow` splits at every discontinuity, so a window * over an EDF+D file comes back as one chunk per contiguous run ONCE THE INDEX IS COMPLETE. With * the probed index `openEdf` hands you it does not return several chunks — it throws, because a * pair of probes cannot say where the gap is, and `buildRecordIndex` is what changes that. This * said the first half flatly until 0.6.71, which described a call that throws on the index a * reader has at that moment. * * Code that then wants ONE array — a filter, an FFT, a CSV writer — has to join them, and joining * is where the gap gets lost. * * Concatenating two runs separated by five minutes produces an array in which sample `i` and * sample `i + 1` are five minutes apart. Every time derived from an index past that point is * wrong by five minutes, and nothing in the result says so. `mergeChunks` refuses instead. A * caller who genuinely wants the samples end to end can concatenate them in three lines and own * the consequence; what they should not get is a helper that hides it. * * The refusals are caller mistakes, not file defects, so they are plain `RangeError`s — the same * convention every option check in the package follows. */ import { appendDiagnostics } from './diagnostics/collector.js'; import { ticksToSeconds } from './tal/ticks.js'; import { pluralise } from './text/counted.js'; import { describeValue } from './text/describe.js'; import type { EdfChunk, EdfChunkSignal, EdfDiagnostic } from './types.js'; /** Reads as one line at the call site, and keeps the `chunks[i]` non-null assertions out of it. */ function at(chunks: readonly EdfChunk[], index: number): EdfChunk { const chunk = chunks[index]; /* * `null` as well as `undefined`, because JSON is how a hole arrives. * * The advice below is about a hole, and the transport that produces one spells it `null`: * `JSON.stringify` writes an absent element as `null`, so a chunk array crossing a worker * boundary, a cache or a message channel comes back with `null` where nothing was. That is the * same route `a-selection-from-json.test.ts` follows for the selection, and * `design-decisions.md` follows for a chunk — "what parses back has no `.length` where a caller * expects one". * * `undefined` was refused here in a sentence; `null` reached `chunk.records` on the next check * and threw V8's `Cannot read properties of null (reading 'records')`. Every other wrong element * — a number, a string, an object, one signal of a chunk — already had a sentence of its own, so * `null` was the one value in this array that left the package without a `Next:` clause. */ if (chunk === undefined || chunk === null) { throw new RangeError( `mergeChunks: no chunk at ${index}. Next: pass the array readWindow() returned, with no ` + 'holes and nothing spliced out of it — JSON writes one as null.', ); } /* * The ELEMENTS, which the array guard below never reached. * * That guard names one wrong argument — "one chunk rather than an array of them" — and an array * of the wrong thing passes it. `mergeChunks(chunk.signals)` is the mistake: `chunk.signals` IS * an array, `EdfChunkSignal` carries `startSeconds` and `startTicks` under the same names * `EdfChunk` does, and "merge the chunk's signals" is a sentence a caller writes. * * What it did depended on how many channels had been selected. On a single-signal read — the * common case, and the one a viewer drawing one trace makes — the array holds one element, so * `chunks.length === 1` returned it AS the merged chunk: an object with no `signals` and no * `durationSeconds`, handed back typed as an `EdfChunk`. On a two-signal read the same call * reached `previous.records.start` and threw V8's "Cannot read properties of undefined * (reading 'start')". One mistake, silently accepted or reported with an internal name * depending on the selection, which is the shape 0.6.79, 0.6.86 and 0.6.103 were each spent on. */ if (typeof (chunk as { records?: unknown }).records !== 'object') { throw new RangeError( `mergeChunks: the value at ${index} is ${ typeof (chunk as unknown as EdfChunkSignal).signalIndex === 'number' ? 'one signal of a chunk rather than a chunk' : `${describeValue(chunk)}, not a chunk` }. Next: pass what readWindow() resolved to — its elements are whole chunks, and each one ` + 'carries every signal you selected on its own signals array.', ); } return chunk; } /** * Everything that makes two chunks joinable, checked before a byte is allocated. * * Three tests, and the second and third are the ones that earn their place. * * `precededByGap` alone is not enough, because it is `undefined` in two different situations: no * gap, and nobody looked. `openEdf` returns a probed index, `gapBefore` has nothing to report from * one, and `readRecords` reads by record number without ever consulting the timeline — so two * chunks a minute apart on an EDF+D file arrive record-adjacent with `precededByGap: undefined` on * both, and the field the refusal was keyed on says nothing at all. Every chunk carries its own * `startSeconds`, decoded from the annotation regions in its own bytes, so the evidence was in hand * the whole time: the second test compares the clock instead of asking the index. * * The per-signal sample-index test is the third: `trimToWindow` narrows a chunk on each signal's * own grid without changing the chunk's `durationSeconds`, so a trimmed chunk passes both the * record and the clock test while the samples between the two are gone. Comparing * `firstSampleIndex` against the previous chunk's end catches exactly that, per signal, which is * the granularity at which it actually happens. */ function assertJoinable(previous: EdfChunk, next: EdfChunk, index: number): void { if (next.precededByGap !== undefined) { // Branch on the sign. An overlap travels in `index.gaps` with a NEGATIVE duration (0.2.69), so // `chunk.precededByGap` carries it too, and a hardcoded gap reading produced "preceded by a // gap of -0.2 s" — a gap of negative duration — with an explanation that inverts what an // overlap does: across a gap two samples are seconds APART, across an overlap they cover the // SAME time, so concatenating duplicates it rather than skipping it. // // 0.3.3 stated the partition and 0.3.33 applied it to "the two places that still said it was". // This is the third; `src/chunks.ts` contained no mention of an overlap at all (fixed in // 0.3.41). The refusal itself is unchanged and right either way. const gap = next.precededByGap; const overlapping = gap.durationTicks < 0n; throw new RangeError( overlapping ? `mergeChunks: chunk ${index} is preceded by an overlap of ${-gap.durationSeconds} s — ` + 'the records on either side of the join both claim that time. Concatenating them would ' + 'store it twice and date every sample after the join late by it. Next: merge each ' + 'contiguous run separately.' : `mergeChunks: chunk ${index} is preceded by a gap of ${gap.durationSeconds} s. ` + 'Concatenating across it would put two samples that are seconds apart next to each ' + 'other in one array, and every time computed from an index after the join would be ' + 'wrong by the gap. Next: merge each contiguous run separately.', ); } const expectedStart = previous.records.start + previous.records.count; if (next.records.start !== expectedStart) { throw new RangeError( `mergeChunks: chunk ${index} starts at record ${next.records.start}, but the chunk before ` + `it ends at ${expectedStart}. Chunks must be adjacent and in order. Next: pass them in ` + 'the order readWindow() returned them, with none dropped.', ); } // In exact ticks, never in float seconds: a float comparison here would let a sub-tick // discrepancy through, and an epsilon would let a real one through. // // The ticks are read off the chunks. Until 0.3.7 they were rounded BACK out of the seconds, // which recovered them only "for any recording shorter than ~28.5 years" — and rounded two // values independently before adding them, so a single lost tick in either produced a refusal // naming a discontinuity of 1e-7 s on chunks that are genuinely adjacent. const previousEndTicks = previous.startTicks + previous.durationTicks; const nextStartTicks = next.startTicks; if (previousEndTicks !== nextStartTicks) { // Branched on the SIGN, like the `precededByGap` path above. A negative difference is an // overlap, not a gap, and this path is the one that actually fires for an overlap after // `openEdf`: a probed index reports no gaps at all, so `precededByGap` is `undefined` and the // branch that was taught the distinction in 0.3.41 never runs. It printed "a discontinuity of // -0.2 s ... this is a gap in TIME", which names the wrong thing twice — a fourth site of the // defect 0.3.33 and 0.3.41 swept, forty lines below the third (fixed in 0.3.59). const deltaTicks = nextStartTicks - previousEndTicks; const overlapping = deltaTicks < 0n; const magnitude = ticksToSeconds(overlapping ? -deltaTicks : deltaTicks); throw new RangeError( `mergeChunks: chunk ${index} starts at ${next.startSeconds} s, but the chunk before it ends ` + `at ${ticksToSeconds(previousEndTicks)} s — ` + (overlapping ? `an overlap of ${magnitude} s. The two are record-adjacent, so the records on either ` + 'side of the join both claim that time and the record numbers cannot show it. ' + 'Concatenating them would store it twice and date every sample after the join late ' + 'by it.' : `a discontinuity of ${magnitude} s. The two are record-adjacent, so this is a gap in ` + 'TIME that the record numbers cannot show. Concatenating them would date every ' + 'sample after the join wrong by that much.') + ' Either the index was never scanned, or these chunks came from separate reads. Next: ' + 'await buildRecordIndex(recording) and merge each contiguous run separately.', ); } /* * And that the two chunks came from the SAME FILE, which nothing above can see. * * Every test so far is about time and record numbers, and those are properties a second recording * can satisfy exactly: two files of the same geometry, read at adjacent record ranges, pass the * gap test, the record test, the tick test and the per-signal sample test. The result is one array * holding half of one recording and half of another, with `records` and `durationSeconds` claiming * it is a single run — which is worse than the gap this function exists to refuse, and reported * even less. * * `byteOffset` and `byteLength` are the two fields that know where the samples came from. Within * one recording they are fixed by the record range — `headerByteLength + start * recordByteLength` * and `count * recordByteLength` — so record-adjacent chunks are byte-adjacent as well, at every * chunk size and after a merge. When they are not, the two chunks were read from files whose * header or record sizes differ, and no arrangement of records makes that one file. * * It does NOT catch two files of identical geometry: nothing on `EdfChunk` identifies a recording, * so that pair stays indistinguishable. This closes the case where the files differ at all, which * is the usual one — a different channel count is a different record size. */ const expectedByteOffset = previous.byteOffset + previous.byteLength; if (next.byteOffset !== expectedByteOffset) { throw new RangeError( `mergeChunks: chunk ${index} begins at byte ${next.byteOffset}, but the chunk before it ` + `ends at ${expectedByteOffset}. Within one recording a record range fixes both numbers, so ` + 'these two were read from files whose headers or records are different sizes — different ' + "recordings. Concatenating them would put one file's samples after another's in an array " + 'that says it is one run. Next: merge the chunks of each recording separately.', ); } if (next.signals.length !== previous.signals.length) { throw new RangeError( `mergeChunks: chunk ${index} carries ${pluralise(next.signals.length, 'signal')}, the chunk ` + `before it ` + `${previous.signals.length}. Every chunk must have been read with the same signal ` + 'selection. Next: reuse one signalIndices array across every read you intend to merge.', ); } for (let i = 0; i < next.signals.length; i += 1) { const before = previous.signals[i] as EdfChunkSignal; const after = next.signals[i] as EdfChunkSignal; if (after.signalIndex !== before.signalIndex) { throw new RangeError( `mergeChunks: chunk ${index} has signal ${after.signalIndex} in position ${i}, the chunk ` + `before it signal ${before.signalIndex}. The selection must be in the same order too. ` + 'Next: pass signalIndices in one fixed order — readWindow() preserves the order given.', ); } const expectedSample = before.firstSampleIndex + before.sampleCount; if (after.firstSampleIndex !== expectedSample) { throw new RangeError( `mergeChunks: signal ${after.signalIndex} of chunk ${index} starts at sample ` + `${after.firstSampleIndex}, but the chunk before it ends at ${expectedSample}. ` + 'A trimmed chunk cannot be merged with the one after it. Next: trim after merging, not ' + 'before — trimToWindow() takes the merged chunk.', ); } } } /** * One chunk covering every input chunk, or a `RangeError` explaining why they do not join. * * Accepts only chunks that are adjacent, in order, gapless, and read with the same signals in the * same order. A single chunk is returned as-is, so the common continuous-file case costs nothing. * * The samples are copied, so the result holds a second copy of the data the inputs already hold. * That is unavoidable — `Int32Array`s are not splices of one buffer — and it is why this is a * separate call rather than something `readWindow` does on the way out. */ export function mergeChunks(chunks: readonly EdfChunk[]): EdfChunk { /* * The ARRAY, before a length is read off it. * * `readWindow` resolves to one chunk per contiguous run, and on a continuous file that is an * array of one — so a caller who has only ever seen one chunk holds a chunk, and * `mergeChunks(chunk)` is what they write. `chunks.length` was then `undefined`, which is not * `0`, and `at(chunks, 0)` refused with "no chunk at 0. Next: pass the array readWindow() * returned, with no holes and nothing spliced out of it" — a message about holes in an array, * to someone who never had one. `null` did not even get that far: `Cannot read properties of * null (reading 'length')`. */ if (!Array.isArray(chunks)) { throw new RangeError( `mergeChunks: the chunks are ${ typeof (chunks as unknown as EdfChunk | undefined)?.records === 'object' ? 'one chunk rather than an array of them' : `${describeValue(chunks)}, not an array` }. Next: pass what readWindow() resolved to — it is the array this takes even on a ` + 'continuous file, where it holds exactly one chunk.', ); } if (chunks.length === 0) { throw new RangeError( 'mergeChunks: nothing to merge. `readWindow` returns [] for a window that lands past the ' + 'end of the recording, and for one that lands entirely inside a gap. Next: check the ' + 'length before merging.', ); } const first = at(chunks, 0); if (chunks.length === 1) return first; for (let i = 1; i < chunks.length; i += 1) { assertJoinable(at(chunks, i - 1), at(chunks, i), i); } const last = at(chunks, chunks.length - 1); const signals = first.signals.map((firstSignal, position) => { let total = 0; let outOfRange = 0; for (const chunk of chunks) { const signal = chunk.signals[position] as EdfChunkSignal; total += signal.sampleCount; outOfRange += signal.outOfDigitalRangeCount; } const digital = new Int32Array(total); let written = 0; for (const chunk of chunks) { const signal = chunk.signals[position] as EdfChunkSignal; // `digital` may be longer than `sampleCount` — the decoder is allowed to hand back a buffer // it sized for whole records. `sampleCount` is the truth, so the subarray is taken from it. digital.set(signal.digital.subarray(0, signal.sampleCount), written); written += signal.sampleCount; } return { signalIndex: firstSignal.signalIndex, sampleCount: total, digital, firstSampleIndex: firstSignal.firstSampleIndex, startSeconds: firstSignal.startSeconds, startTicks: firstSignal.startTicks, outOfDigitalRangeCount: outOfRange, } satisfies EdfChunkSignal; }); let byteLength = 0; const diagnostics: EdfDiagnostic[] = []; for (const chunk of chunks) { byteLength += chunk.byteLength; // Not `push(...chunk.diagnostics)`: a scan over a damaged annotation section reports one // diagnostic per record, and the spread blows the call stack past ~125,000 of them (0.1.6). appendDiagnostics(diagnostics, chunk.diagnostics); } return { records: { start: first.records.start, count: last.records.start + last.records.count - first.records.start, }, startSeconds: first.startSeconds, startTicks: first.startTicks, // Measured against the ENDS, not summed over N durations: the run is contiguous, so the ends // are the truth. In ticks the distinction is about which value is right rather than about // accumulated rounding, and the seconds are then one conversion of that one exact number // instead of three float operations on three converted ones. durationTicks: last.startTicks + last.durationTicks - first.startTicks, durationSeconds: ticksToSeconds(last.startTicks + last.durationTicks - first.startTicks), byteOffset: first.byteOffset, byteLength, signals: Object.freeze(signals), // The gap BEFORE the whole run survives the merge. It describes what precedes the first // chunk, which is still what precedes the merged one. precededByGap: first.precededByGap, diagnostics: Object.freeze(diagnostics), }; }