edf2csv 0.9.13 → 0.9.14

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.
@@ -107,8 +107,19 @@ export function decodeRecordAnnotations(bytes, recordIndex, carriesTimekeeping =
107
107
  A whole non-negative number, like the record bounds `readRecords` and `sampleAt` take:
108
108
  records are counted from zero, and this one is a position in the file rather than in a
109
109
  batch, so there is no upper bound to hold it to here.
110
+
111
+ Safe, though, which is a bound and not a guess. `Number.isInteger` is true of 1e300 and of
112
+ 2^53 + 2, and neither is a position any file has: past 2^53 a double stops being able to
113
+ tell one whole number from the next, so the value cannot name a record even in principle.
114
+ It was written onto every event and into `record_index`, where a join reads it —
115
+
116
+ decodeRecordAnnotations(bytes, 1e300).annotations[0].recordIndex // 1e+300
117
+
118
+ — and `1e+300` is not even a number of the form that column holds, since every other row
119
+ of it is plain digits. A file of 2^53 records at the format's smallest record duration is
120
+ longer than the age of the universe; a value past it came from arithmetic, not from a file.
110
121
  */
111
- if (!Number.isInteger(recordIndex) || recordIndex < 0) {
122
+ if (!Number.isSafeInteger(recordIndex) || recordIndex < 0) {
112
123
  throw new OptionError(`recordIndex must be the record's whole-number position in the file, got ` +
113
124
  `${describeValue(recordIndex)}. It is written onto every annotation this returns.`);
114
125
  }
@@ -1 +1 @@
1
- {"version":3,"file":"annotations.js","sourceRoot":"","sources":["../../src/edf/annotations.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * EDF+ annotation (TAL) decoding.\n *\n * The annotations channel stores UTF-8 text in place of samples. Its bytes are a\n * run of Time-stamped Annotation Lists, each terminated by a NUL, with the rest\n * of the channel NUL-padded:\n *\n * +<onset>[<0x15><duration>]<0x14><text><0x14>...<0x00>\n *\n * The first TAL of every data record carries that record's start time; that is how an EDF+D\n * file states where each record actually sits in time. It may carry event text after that,\n * which the specification allows and writers use, so an unreadable one costs a position and\n * whatever events went with it — see `malformedTimekeepingWithText`.\n *\n * +1.25<0x15>0.5<0x14>Seizure onset<0x14><0x00>\n */\n\nimport { decodeText } from './bytes.js';\n// Crossing into convert/ as header.ts and reader.ts already do: the check belongs to the call.\nimport { OptionError, describeValue } from '../convert/options.js';\n\nconst SEP_TEXT = 0x14; // separates onset/duration from text, and text from text\nconst SEP_DURATION = 0x15; // separates onset from duration\nconst TAL_END = 0x00;\n\nconst TEXT_SEP_CHAR = String.fromCharCode(SEP_TEXT);\nconst DURATION_SEP_CHAR = String.fromCharCode(SEP_DURATION);\n\n/**\n * What EDF+ allows a TAL duration to look like, which is less than `Number()` allows.\n *\n * The header parser's DECIMAL_FIELD makes this argument at length and lists where the same\n * mistake had already been fixed: `#0x2` reaching channel 2 through `--channels`,\n * `--decimals 0o5` writing five places, `--jobs 0x10` running sixteen, a physical maximum of\n * `0x64` setting a whole calibration to 100. The annotation duration is the one field it did\n * not reach. `+1<0x15>0x10<0x14>Seizure` was exported with a duration_s of 16 — sixteen\n * seconds no writer wrote, in a column the documentation defines as the length the file\n * stated, exit 0 and no diagnostic. `0b11` is three the same way.\n *\n * A duration this cannot read is already a thing the parser knows how to say: the field is\n * left empty, counted, and reported. Sending these there costs nothing and states the truth,\n * which is that the file wrote something that is not a number of seconds.\n */\nconst DECIMAL_DURATION = /^[+-]?(?:\\d+(?:\\.\\d*)?|\\.\\d+)(?:[eE][+-]?\\d+)?$/u;\n\nexport interface Annotation {\n /** Seconds from the start of the recording. */\n onset: number;\n /**\n * Seconds, or null when the TAL stated no duration that could be read.\n *\n * Null covers two cases the file distinguishes and this field does not: a TAL that omitted\n * the duration, and a TAL that stated one which is not a number. They are told apart by\n * `unreadableDurations`, which is what raises the warning; the value itself has nowhere\n * honest to put \"the file said `abc`\".\n */\n duration: number | null;\n text: string;\n /** Index of the data record this annotation was stored in. */\n recordIndex: number;\n /**\n * True when the file stated a duration that could not be read.\n *\n * `duration` is null either way, which is the ambiguity the counts beside it exist to\n * flag — and those counts were of the whole file while `annotations.csv` is filtered to\n * the requested window. A conversion of one second of a recording warned that \"1\n * annotation states a duration that is not a number, so its duration_s cell is empty\"\n * about an event two seconds outside it, and failed `--strict` for it. Carrying the fact\n * on the event lets the count be taken where the window has already been applied.\n */\n durationUnreadable?: boolean;\n}\n\nexport interface DecodedRecordAnnotations {\n /** Record start time in seconds, from the leading timekeeping TAL. */\n recordStart: number | null;\n annotations: Annotation[];\n /** Non-empty chunks that were not valid TALs, so the caller can report them. */\n malformed: number;\n /** Unreadable TALs in first position, which carry a record's start time, not an event. */\n malformedTimekeeping: number;\n /**\n * How many of those also carried event text, and so lost events as well as a position.\n *\n * A TAL in first position holds the record's start time, and may hold events after it — the\n * specification allows both in the one entry, and writers use it. When such a TAL cannot be\n * parsed, both are gone, and counting it only as lost timekeeping let the warning beside it\n * say \"No event was lost\" over a conversion that had just dropped four of them.\n *\n * Counted rather than inferred, because the sentence has to be right in the ordinary case\n * too: a bare timekeeping TAL really does lose no event, and that is nearly all of them.\n */\n malformedTimekeepingWithText: number;\n /**\n * Events kept whose stated duration could not be read.\n *\n * Counted apart again, for the same reason the two above are: the entry was exported and\n * nothing about it is missing except the one field, so calling it an entry that \"could not\n * be exported\" describes a loss that did not happen and hides the one that did.\n */\n unreadableDurations: number;\n /**\n * Events kept whose stated duration is a readable number below zero.\n *\n * Separate from the count above because the value survives: it is written to the CSV as\n * the file gave it, and what is wrong with it is arithmetic rather than parsing.\n */\n negativeDurations: number;\n}\n\n/**\n * Decode one data record's annotation bytes.\n *\n * Malformed TALs are skipped rather than thrown, because a single bad annotation\n * should not cost the user an entire conversion. The count of skipped chunks is\n * returned so the caller can tell the user rather than losing them in silence.\n */\nexport function decodeRecordAnnotations(\n bytes: Uint8Array,\n recordIndex: number,\n carriesTimekeeping = true,\n): DecodedRecordAnnotations {\n /*\n The bytes, checked before anything is read out of them.\n\n The api page offers this function on its own — \"For decoding annotation bytes yourself,\n `decodeRecordAnnotations(bytes, recordIndex)` handles one record's worth of the channel\" —\n so it is reached by a caller holding a slice they cut themselves. Hand it anything but a\n typed array and the loop below reported the shape of its own local:\n\n decodeRecordAnnotations(Buffer.from(text).toString(), 0)\n TypeError: bytes.subarray is not a function\n\n which names a variable inside this function and a method the caller never called. The\n same `OptionError` the two path-taking exports raise, for the reason given there: the call\n is what is wrong, not the recording.\n */\n /*\n A view of *bytes*, which `ArrayBuffer.isView` does not mean.\n\n It is true of every typed array and of `DataView`, and both of those reach past this check\n into the loop below — which reads `bytes[i]` and calls `bytes.subarray`. A typed array of\n wider elements has both, so it does not fail; it decodes the wrong thing and says nothing:\n\n decodeRecordAnnotations(new Float64Array(2), 0)\n { recordStart: null, annotations: [], malformed: 0, ... }\n\n Sixteen bytes of annotation channel, read as two doubles, reported as a record with\n nothing malformed in it — which is what this function says about a record that really is\n empty. A `DataView` has no index accessor at all, so every byte reads `undefined`.\n\n `BYTES_PER_ELEMENT === 1` is the question being asked: true of `Uint8Array`, `Int8Array`,\n `Uint8ClampedArray` and the `Buffer` `annotationBytes` hands this, undefined on a\n `DataView`. The same check `parseHeader` makes of its own bytes.\n */\n if (!ArrayBuffer.isView(bytes) || (bytes as { BYTES_PER_ELEMENT?: number }).BYTES_PER_ELEMENT !== 1) {\n // A view is named rather than dumped, for the reason 0.8.84 gives: a hundred doubles\n // handed back is the caller's own data, not a description of what is wrong with it.\n throw new OptionError(\n ArrayBuffer.isView(bytes)\n ? `bytes is a ${(bytes as object).constructor?.name ?? 'view'}, which is a view of an ` +\n `ArrayBuffer but not of bytes. One record's annotation channel is a Uint8Array or a ` +\n `Buffer, one byte an element — annotationBytes returns one.`\n : `bytes must be one record's annotation channel, got ${describeValue(bytes)}.`,\n );\n }\n /*\n And the second argument, which this function does not read — it writes it.\n\n `recordIndex` is copied onto every `Annotation` this call produces, and `Annotation`\n declares it a number. Nothing checked that it was one, so whatever was passed came back\n out in the event list:\n\n decodeRecordAnnotations(bytes, 'x').annotations[0].recordIndex // 'x'\n decodeRecordAnnotations(bytes).annotations[0].recordIndex // absent\n\n The second is the ordinary mistake, since the page shows this called as\n `decodeRecordAnnotations(bytes, recordIndex)` beside `annotationBytes(batch, recordOffset,\n signal)` and the two take their record different ways. It produces events with the field\n missing altogether, which `record_index` in annotations.csv is written from, and which a\n caller joining events back to records reads as a record of `undefined`.\n\n A whole non-negative number, like the record bounds `readRecords` and `sampleAt` take:\n records are counted from zero, and this one is a position in the file rather than in a\n batch, so there is no upper bound to hold it to here.\n */\n if (!Number.isInteger(recordIndex) || recordIndex < 0) {\n throw new OptionError(\n `recordIndex must be the record's whole-number position in the file, got ` +\n `${describeValue(recordIndex)}. It is written onto every annotation this returns.`,\n );\n }\n const annotations: Annotation[] = [];\n let recordStart: number | null = null;\n let isFirstTal = true;\n let malformed = 0;\n let malformedTimekeeping = 0;\n let malformedTimekeepingWithText = 0;\n let unreadableDurations = 0;\n let negativeDurations = 0;\n\n let start = 0;\n for (let i = 0; i <= bytes.length; i++) {\n if (i !== bytes.length && bytes[i] !== TAL_END) continue;\n\n if (i > start) {\n const chunk = bytes.subarray(start, i);\n /*\n Padding is not a lost annotation.\n\n The spec pads the slot with NUL, which the loop above already skips because it is what\n separates one TAL from the next. Writers pad with spaces instead, and a run of spaces\n after the last TAL is a non-empty chunk — so a file holding one perfectly readable\n event, exported in full, was told \"2 annotation entries were unreadable and could not\n be exported\", one per record. Nothing was lost. Under --strict that is a failed run\n over the whitespace at the end of a slot.\n\n Only whitespace. A chunk of anything else that does not parse is a real loss and is\n still counted, which is the case this warning exists for.\n */\n if (chunk.every(isPaddingByte)) {\n start = i + 1;\n continue;\n }\n const parsed = parseTal(chunk, recordIndex);\n\n // The timekeeping TAL is the one in first POSITION, whether or not it decodes.\n // Clearing this flag only on a successful parse meant that an unreadable first TAL\n // promoted the next ordinary annotation to timekeeping, and its onset silently\n // became the record's start time — shifting every sample in that record. Leaving\n // recordStart null instead is what the caller already handles, with a fallback\n // timestamp and an ANNOTATION_DECODE_FAILED warning naming the record.\n /*\n Only one annotation channel carries a record's start time.\n\n This flagged the first TAL of *every* annotation channel as timekeeping. In a second\n channel the first TAL is an ordinary event — so when one failed to parse, the event\n was dropped and counted as a lost timekeeping entry, which produced the warning\n \"3 data records carry a timekeeping annotation that could not be read\" followed by\n \"No event was lost\". Three events had been lost, and the timekeeping in that file was\n perfectly readable. Both sentences false, about the same three records.\n */\n const isTimekeeping = isFirstTal && carriesTimekeeping;\n isFirstTal = false;\n\n if (parsed) {\n if (isTimekeeping) recordStart = parsed.onset;\n for (const annotation of parsed.annotations) annotations.push(annotation);\n unreadableDurations += parsed.unreadableDurations;\n negativeDurations += parsed.negativeDurations;\n } else {\n /*\n Counted apart from the events, because losing one is a different loss.\n\n A timekeeping TAL is never exported — it says where the record sits, not what\n happened — so counting it among the entries that \"could not be exported\" both\n overstated what was lost from annotations.csv and said nothing about the thing that\n actually went missing, which is a record's position in time. A file with one\n unreadable timekeeping TAL and three perfectly good events reported \"1 annotation\n entry was unreadable and could not be exported\" while exporting all three.\n\n The other direction is just as wrong. A first-position TAL may carry events after\n the start time, and when one of those cannot be parsed the events go with it — so\n counting it only as lost timekeeping produced the opposite false sentence: \"No event\n was lost\", printed over a run whose annotations.csv had gone from six rows to two.\n It is one entry that could not be exported and one record with no position, and it\n is counted as both.\n */\n if (isTimekeeping) {\n malformedTimekeeping++;\n if (carriesAnnotationText(chunk)) {\n malformedTimekeepingWithText++;\n malformed++;\n }\n } else malformed++;\n }\n }\n start = i + 1;\n }\n\n return {\n recordStart,\n annotations,\n malformed,\n malformedTimekeeping,\n malformedTimekeepingWithText,\n unreadableDurations,\n negativeDurations,\n };\n}\n\ninterface ParsedTal {\n onset: number;\n annotations: Annotation[];\n /** How many of those annotations carry a duration the file stated and this could not read. */\n unreadableDurations: number;\n /** How many carry a duration that read as a number below zero. */\n negativeDurations: number;\n}\n\n/**\n * Whether a TAL that could not be parsed still carried event text.\n *\n * A TAL is `onset[<0x15>duration]<0x14>text<0x14>...`, so anything other than padding after\n * the first 0x14 is a description the file meant to export. Read from the raw chunk, since by\n * the time this is asked the parse has already failed and there is no structure to consult.\n */\nfunction carriesAnnotationText(chunk: Uint8Array): boolean {\n let afterSeparator = false;\n for (const byte of chunk) {\n if (byte === 0x14) {\n afterSeparator = true;\n continue;\n }\n if (afterSeparator && !isPaddingByte(byte)) return true;\n }\n return false;\n}\n\n/** Space, tab, CR, LF or NUL — what a writer fills the rest of the slot with. */\nfunction isPaddingByte(byte: number): boolean {\n return byte === 0x20 || byte === 0x09 || byte === 0x0d || byte === 0x0a || byte === 0x00;\n}\n\nfunction parseTal(chunk: Uint8Array, recordIndex: number): ParsedTal | null {\n // The onset must be explicitly signed; anything else is not a TAL.\n const first = chunk[0];\n if (first !== 0x2b /* + */ && first !== 0x2d /* - */) return null;\n\n const text = decodeText(chunk);\n const parts = text.split(TEXT_SEP_CHAR);\n const head = parts[0] ?? '';\n\n let onsetText = head;\n let durationText: string | null = null;\n const durationSep = head.indexOf(DURATION_SEP_CHAR);\n if (durationSep >= 0) {\n onsetText = head.slice(0, durationSep);\n durationText = head.slice(durationSep + 1);\n }\n\n const onset = Number(onsetText);\n if (!Number.isFinite(onset)) return null;\n\n /*\n A duration the file stated and this could not read is not the same as no duration.\n\n Both came out as `null` and so as an empty `duration_s` cell, which the documentation\n defines as meaning the file gave no duration — so an event whose duration was written as\n `abc` was exported as an event with no duration, indistinguishable from one beside it\n that genuinely had none, and nothing anywhere said a field had been dropped. The onset is\n already held to this standard: one that is not a number costs the whole TAL and is\n reported. A duration is one field of an otherwise readable event, so the event is kept —\n but it is counted, and the run says so.\n */\n /*\n Fill in the field is an absent duration, not a zero.\n\n `Number` reads a run of whitespace as 0 — the rule that makes `Number('')` zero, one step\n along — so a TAL whose duration field held nothing but the writer's padding was exported\n with a `duration_s` of `0`, byte-identical to the event beside it whose file really did\n say `0`. An instantaneous event is a claim about the recording, and no writer made it;\n inventing it is the one thing this tool does not do, and it did so in silence, exit 0.\n\n The empty field this condition already declines is the same field without the fill in it,\n so padding takes the same answer: the file stated no duration. `trim` empties exactly the\n strings `Number` would otherwise have swallowed into a zero, so ` 2.5 ` still reads as\n 2.5 and `abc` is still counted as unreadable below.\n */\n let duration: number | null = null;\n let durationUnreadable = false;\n if (durationText !== null && durationText.trim() !== '') {\n const stated = durationText.trim();\n const d = DECIMAL_DURATION.test(stated) ? Number(stated) : NaN;\n if (Number.isFinite(d)) duration = d;\n else durationUnreadable = true;\n }\n\n /*\n A duration is a length of time, and a length below zero is not one.\n\n The value is kept and written as the file gave it — inventing a zero, or dropping it to\n an empty cell, would put a number in annotations.csv that no writer wrote, which is the\n one thing this tool does not do. But it is reported, because everything downstream\n quietly does the wrong thing with it: the recipe this documentation gives for the samples\n an event covers is `onset_s + duration_s`, which for a duration of -3 ends three seconds\n before the event starts and selects nothing at all, with no error anywhere.\n */\n const durationNegative = duration !== null && duration < 0;\n\n const annotations: Annotation[] = [];\n for (const raw of parts.slice(1)) {\n /*\n A trailing separator yields an empty segment; a timekeeping TAL is all empty.\n\n Whitespace counts as empty here, which it did not, and the padding at the end of the\n slot became an event. The chunk loop above already refuses to call a run of spaces a\n lost annotation — but it only sees chunks between NULs, and a writer that leaves its\n last TAL unterminated puts the fill *inside* the chunk, after the final 0x14. Split on\n that separator it is a text segment like any other, and \" \" is not \"\".\n\n A file holding two events exported four rows: `0.5,,Lights off,0` and `0.5,, ,0`,\n twice, sharing the real event's onset, with annotations_written and the run summary\n agreeing with the inflated number and nothing warned. An event whose description is\n genuinely nothing but spaces cannot be told from fill, and inventing rows out of fill\n is the worse of the two answers.\n */\n if ([...raw].every((c) => isPaddingByte(c.charCodeAt(0)))) continue;\n annotations.push({\n onset,\n duration,\n text: raw,\n recordIndex,\n ...(durationUnreadable ? { durationUnreadable: true } : {}),\n });\n }\n\n // Per event rather than per TAL: one TAL may carry several texts, and each becomes a row\n // of annotations.csv with the same cell in it.\n return {\n onset,\n annotations,\n unreadableDurations: durationUnreadable ? annotations.length : 0,\n negativeDurations: durationNegative ? annotations.length : 0,\n };\n}\n\nexport { SEP_TEXT, SEP_DURATION, TAL_END };\n"]}
1
+ {"version":3,"file":"annotations.js","sourceRoot":"","sources":["../../src/edf/annotations.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAEH,OAAO,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AACxC,+FAA+F;AAC/F,OAAO,EAAE,WAAW,EAAE,aAAa,EAAE,MAAM,uBAAuB,CAAC;AAEnE,MAAM,QAAQ,GAAG,IAAI,CAAC,CAAC,yDAAyD;AAChF,MAAM,YAAY,GAAG,IAAI,CAAC,CAAC,gCAAgC;AAC3D,MAAM,OAAO,GAAG,IAAI,CAAC;AAErB,MAAM,aAAa,GAAG,MAAM,CAAC,YAAY,CAAC,QAAQ,CAAC,CAAC;AACpD,MAAM,iBAAiB,GAAG,MAAM,CAAC,YAAY,CAAC,YAAY,CAAC,CAAC;AAE5D;;;;;;;;;;;;;;GAcG;AACH,MAAM,gBAAgB,GAAG,kDAAkD,CAAC;AAmE5E;;;;;;GAMG;AACH,MAAM,UAAU,uBAAuB,CACrC,KAAiB,EACjB,WAAmB,EACnB,kBAAkB,GAAG,IAAI;IAEzB;;;;;;;;;;;;;;MAcE;IACF;;;;;;;;;;;;;;;;;MAiBE;IACF,IAAI,CAAC,WAAW,CAAC,MAAM,CAAC,KAAK,CAAC,IAAK,KAAwC,CAAC,iBAAiB,KAAK,CAAC,EAAE,CAAC;QACpG,qFAAqF;QACrF,oFAAoF;QACpF,MAAM,IAAI,WAAW,CACnB,WAAW,CAAC,MAAM,CAAC,KAAK,CAAC;YACvB,CAAC,CAAC,cAAe,KAAgB,CAAC,WAAW,EAAE,IAAI,IAAI,MAAM,0BAA0B;gBACrF,qFAAqF;gBACrF,4DAA4D;YAC9D,CAAC,CAAC,sDAAsD,aAAa,CAAC,KAAK,CAAC,GAAG,CAClF,CAAC;IACJ,CAAC;IACD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;MA8BE;IACF,IAAI,CAAC,MAAM,CAAC,aAAa,CAAC,WAAW,CAAC,IAAI,WAAW,GAAG,CAAC,EAAE,CAAC;QAC1D,MAAM,IAAI,WAAW,CACnB,0EAA0E;YACxE,GAAG,aAAa,CAAC,WAAW,CAAC,qDAAqD,CACrF,CAAC;IACJ,CAAC;IACD,MAAM,WAAW,GAAiB,EAAE,CAAC;IACrC,IAAI,WAAW,GAAkB,IAAI,CAAC;IACtC,IAAI,UAAU,GAAG,IAAI,CAAC;IACtB,IAAI,SAAS,GAAG,CAAC,CAAC;IAClB,IAAI,oBAAoB,GAAG,CAAC,CAAC;IAC7B,IAAI,4BAA4B,GAAG,CAAC,CAAC;IACrC,IAAI,mBAAmB,GAAG,CAAC,CAAC;IAC5B,IAAI,iBAAiB,GAAG,CAAC,CAAC;IAE1B,IAAI,KAAK,GAAG,CAAC,CAAC;IACd,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,IAAI,KAAK,CAAC,MAAM,EAAE,CAAC,EAAE,EAAE,CAAC;QACvC,IAAI,CAAC,KAAK,KAAK,CAAC,MAAM,IAAI,KAAK,CAAC,CAAC,CAAC,KAAK,OAAO;YAAE,SAAS;QAEzD,IAAI,CAAC,GAAG,KAAK,EAAE,CAAC;YACd,MAAM,KAAK,GAAG,KAAK,CAAC,QAAQ,CAAC,KAAK,EAAE,CAAC,CAAC,CAAC;YACvC;;;;;;;;;;;;cAYE;YACF,IAAI,KAAK,CAAC,KAAK,CAAC,aAAa,CAAC,EAAE,CAAC;gBAC/B,KAAK,GAAG,CAAC,GAAG,CAAC,CAAC;gBACd,SAAS;YACX,CAAC;YACD,MAAM,MAAM,GAAG,QAAQ,CAAC,KAAK,EAAE,WAAW,CAAC,CAAC;YAE5C,+EAA+E;YAC/E,mFAAmF;YACnF,+EAA+E;YAC/E,iFAAiF;YACjF,+EAA+E;YAC/E,uEAAuE;YACvE;;;;;;;;;cASE;YACF,MAAM,aAAa,GAAG,UAAU,IAAI,kBAAkB,CAAC;YACvD,UAAU,GAAG,KAAK,CAAC;YAEnB,IAAI,MAAM,EAAE,CAAC;gBACX,IAAI,aAAa;oBAAE,WAAW,GAAG,MAAM,CAAC,KAAK,CAAC;gBAC9C,KAAK,MAAM,UAAU,IAAI,MAAM,CAAC,WAAW;oBAAE,WAAW,CAAC,IAAI,CAAC,UAAU,CAAC,CAAC;gBAC1E,mBAAmB,IAAI,MAAM,CAAC,mBAAmB,CAAC;gBAClD,iBAAiB,IAAI,MAAM,CAAC,iBAAiB,CAAC;YAChD,CAAC;iBAAM,CAAC;gBACN;;;;;;;;;;;;;;;;kBAgBE;gBACF,IAAI,aAAa,EAAE,CAAC;oBAClB,oBAAoB,EAAE,CAAC;oBACvB,IAAI,qBAAqB,CAAC,KAAK,CAAC,EAAE,CAAC;wBACjC,4BAA4B,EAAE,CAAC;wBAC/B,SAAS,EAAE,CAAC;oBACd,CAAC;gBACH,CAAC;;oBAAM,SAAS,EAAE,CAAC;YACrB,CAAC;QACH,CAAC;QACD,KAAK,GAAG,CAAC,GAAG,CAAC,CAAC;IAChB,CAAC;IAED,OAAO;QACL,WAAW;QACX,WAAW;QACX,SAAS;QACT,oBAAoB;QACpB,4BAA4B;QAC5B,mBAAmB;QACnB,iBAAiB;KAClB,CAAC;AACJ,CAAC;AAWD;;;;;;GAMG;AACH,SAAS,qBAAqB,CAAC,KAAiB;IAC9C,IAAI,cAAc,GAAG,KAAK,CAAC;IAC3B,KAAK,MAAM,IAAI,IAAI,KAAK,EAAE,CAAC;QACzB,IAAI,IAAI,KAAK,IAAI,EAAE,CAAC;YAClB,cAAc,GAAG,IAAI,CAAC;YACtB,SAAS;QACX,CAAC;QACD,IAAI,cAAc,IAAI,CAAC,aAAa,CAAC,IAAI,CAAC;YAAE,OAAO,IAAI,CAAC;IAC1D,CAAC;IACD,OAAO,KAAK,CAAC;AACf,CAAC;AAED,iFAAiF;AACjF,SAAS,aAAa,CAAC,IAAY;IACjC,OAAO,IAAI,KAAK,IAAI,IAAI,IAAI,KAAK,IAAI,IAAI,IAAI,KAAK,IAAI,IAAI,IAAI,KAAK,IAAI,IAAI,IAAI,KAAK,IAAI,CAAC;AAC3F,CAAC;AAED,SAAS,QAAQ,CAAC,KAAiB,EAAE,WAAmB;IACtD,mEAAmE;IACnE,MAAM,KAAK,GAAG,KAAK,CAAC,CAAC,CAAC,CAAC;IACvB,IAAI,KAAK,KAAK,IAAI,CAAC,OAAO,IAAI,KAAK,KAAK,IAAI,CAAC,OAAO;QAAE,OAAO,IAAI,CAAC;IAElE,MAAM,IAAI,GAAG,UAAU,CAAC,KAAK,CAAC,CAAC;IAC/B,MAAM,KAAK,GAAG,IAAI,CAAC,KAAK,CAAC,aAAa,CAAC,CAAC;IACxC,MAAM,IAAI,GAAG,KAAK,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC;IAE5B,IAAI,SAAS,GAAG,IAAI,CAAC;IACrB,IAAI,YAAY,GAAkB,IAAI,CAAC;IACvC,MAAM,WAAW,GAAG,IAAI,CAAC,OAAO,CAAC,iBAAiB,CAAC,CAAC;IACpD,IAAI,WAAW,IAAI,CAAC,EAAE,CAAC;QACrB,SAAS,GAAG,IAAI,CAAC,KAAK,CAAC,CAAC,EAAE,WAAW,CAAC,CAAC;QACvC,YAAY,GAAG,IAAI,CAAC,KAAK,CAAC,WAAW,GAAG,CAAC,CAAC,CAAC;IAC7C,CAAC;IAED,MAAM,KAAK,GAAG,MAAM,CAAC,SAAS,CAAC,CAAC;IAChC,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,KAAK,CAAC;QAAE,OAAO,IAAI,CAAC;IAEzC;;;;;;;;;;MAUE;IACF;;;;;;;;;;;;;MAaE;IACF,IAAI,QAAQ,GAAkB,IAAI,CAAC;IACnC,IAAI,kBAAkB,GAAG,KAAK,CAAC;IAC/B,IAAI,YAAY,KAAK,IAAI,IAAI,YAAY,CAAC,IAAI,EAAE,KAAK,EAAE,EAAE,CAAC;QACxD,MAAM,MAAM,GAAG,YAAY,CAAC,IAAI,EAAE,CAAC;QACnC,MAAM,CAAC,GAAG,gBAAgB,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC;QAC/D,IAAI,MAAM,CAAC,QAAQ,CAAC,CAAC,CAAC;YAAE,QAAQ,GAAG,CAAC,CAAC;;YAChC,kBAAkB,GAAG,IAAI,CAAC;IACjC,CAAC;IAED;;;;;;;;;MASE;IACF,MAAM,gBAAgB,GAAG,QAAQ,KAAK,IAAI,IAAI,QAAQ,GAAG,CAAC,CAAC;IAE3D,MAAM,WAAW,GAAiB,EAAE,CAAC;IACrC,KAAK,MAAM,GAAG,IAAI,KAAK,CAAC,KAAK,CAAC,CAAC,CAAC,EAAE,CAAC;QACjC;;;;;;;;;;;;;;UAcE;QACF,IAAI,CAAC,GAAG,GAAG,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,aAAa,CAAC,CAAC,CAAC,UAAU,CAAC,CAAC,CAAC,CAAC,CAAC;YAAE,SAAS;QACpE,WAAW,CAAC,IAAI,CAAC;YACf,KAAK;YACL,QAAQ;YACR,IAAI,EAAE,GAAG;YACT,WAAW;YACX,GAAG,CAAC,kBAAkB,CAAC,CAAC,CAAC,EAAE,kBAAkB,EAAE,IAAI,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;SAC5D,CAAC,CAAC;IACL,CAAC;IAED,yFAAyF;IACzF,+CAA+C;IAC/C,OAAO;QACL,KAAK;QACL,WAAW;QACX,mBAAmB,EAAE,kBAAkB,CAAC,CAAC,CAAC,WAAW,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC;QAChE,iBAAiB,EAAE,gBAAgB,CAAC,CAAC,CAAC,WAAW,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC;KAC7D,CAAC;AACJ,CAAC;AAED,OAAO,EAAE,QAAQ,EAAE,YAAY,EAAE,OAAO,EAAE,CAAC","sourcesContent":["/**\n * EDF+ annotation (TAL) decoding.\n *\n * The annotations channel stores UTF-8 text in place of samples. Its bytes are a\n * run of Time-stamped Annotation Lists, each terminated by a NUL, with the rest\n * of the channel NUL-padded:\n *\n * +<onset>[<0x15><duration>]<0x14><text><0x14>...<0x00>\n *\n * The first TAL of every data record carries that record's start time; that is how an EDF+D\n * file states where each record actually sits in time. It may carry event text after that,\n * which the specification allows and writers use, so an unreadable one costs a position and\n * whatever events went with it — see `malformedTimekeepingWithText`.\n *\n * +1.25<0x15>0.5<0x14>Seizure onset<0x14><0x00>\n */\n\nimport { decodeText } from './bytes.js';\n// Crossing into convert/ as header.ts and reader.ts already do: the check belongs to the call.\nimport { OptionError, describeValue } from '../convert/options.js';\n\nconst SEP_TEXT = 0x14; // separates onset/duration from text, and text from text\nconst SEP_DURATION = 0x15; // separates onset from duration\nconst TAL_END = 0x00;\n\nconst TEXT_SEP_CHAR = String.fromCharCode(SEP_TEXT);\nconst DURATION_SEP_CHAR = String.fromCharCode(SEP_DURATION);\n\n/**\n * What EDF+ allows a TAL duration to look like, which is less than `Number()` allows.\n *\n * The header parser's DECIMAL_FIELD makes this argument at length and lists where the same\n * mistake had already been fixed: `#0x2` reaching channel 2 through `--channels`,\n * `--decimals 0o5` writing five places, `--jobs 0x10` running sixteen, a physical maximum of\n * `0x64` setting a whole calibration to 100. The annotation duration is the one field it did\n * not reach. `+1<0x15>0x10<0x14>Seizure` was exported with a duration_s of 16 — sixteen\n * seconds no writer wrote, in a column the documentation defines as the length the file\n * stated, exit 0 and no diagnostic. `0b11` is three the same way.\n *\n * A duration this cannot read is already a thing the parser knows how to say: the field is\n * left empty, counted, and reported. Sending these there costs nothing and states the truth,\n * which is that the file wrote something that is not a number of seconds.\n */\nconst DECIMAL_DURATION = /^[+-]?(?:\\d+(?:\\.\\d*)?|\\.\\d+)(?:[eE][+-]?\\d+)?$/u;\n\nexport interface Annotation {\n /** Seconds from the start of the recording. */\n onset: number;\n /**\n * Seconds, or null when the TAL stated no duration that could be read.\n *\n * Null covers two cases the file distinguishes and this field does not: a TAL that omitted\n * the duration, and a TAL that stated one which is not a number. They are told apart by\n * `unreadableDurations`, which is what raises the warning; the value itself has nowhere\n * honest to put \"the file said `abc`\".\n */\n duration: number | null;\n text: string;\n /** Index of the data record this annotation was stored in. */\n recordIndex: number;\n /**\n * True when the file stated a duration that could not be read.\n *\n * `duration` is null either way, which is the ambiguity the counts beside it exist to\n * flag — and those counts were of the whole file while `annotations.csv` is filtered to\n * the requested window. A conversion of one second of a recording warned that \"1\n * annotation states a duration that is not a number, so its duration_s cell is empty\"\n * about an event two seconds outside it, and failed `--strict` for it. Carrying the fact\n * on the event lets the count be taken where the window has already been applied.\n */\n durationUnreadable?: boolean;\n}\n\nexport interface DecodedRecordAnnotations {\n /** Record start time in seconds, from the leading timekeeping TAL. */\n recordStart: number | null;\n annotations: Annotation[];\n /** Non-empty chunks that were not valid TALs, so the caller can report them. */\n malformed: number;\n /** Unreadable TALs in first position, which carry a record's start time, not an event. */\n malformedTimekeeping: number;\n /**\n * How many of those also carried event text, and so lost events as well as a position.\n *\n * A TAL in first position holds the record's start time, and may hold events after it — the\n * specification allows both in the one entry, and writers use it. When such a TAL cannot be\n * parsed, both are gone, and counting it only as lost timekeeping let the warning beside it\n * say \"No event was lost\" over a conversion that had just dropped four of them.\n *\n * Counted rather than inferred, because the sentence has to be right in the ordinary case\n * too: a bare timekeeping TAL really does lose no event, and that is nearly all of them.\n */\n malformedTimekeepingWithText: number;\n /**\n * Events kept whose stated duration could not be read.\n *\n * Counted apart again, for the same reason the two above are: the entry was exported and\n * nothing about it is missing except the one field, so calling it an entry that \"could not\n * be exported\" describes a loss that did not happen and hides the one that did.\n */\n unreadableDurations: number;\n /**\n * Events kept whose stated duration is a readable number below zero.\n *\n * Separate from the count above because the value survives: it is written to the CSV as\n * the file gave it, and what is wrong with it is arithmetic rather than parsing.\n */\n negativeDurations: number;\n}\n\n/**\n * Decode one data record's annotation bytes.\n *\n * Malformed TALs are skipped rather than thrown, because a single bad annotation\n * should not cost the user an entire conversion. The count of skipped chunks is\n * returned so the caller can tell the user rather than losing them in silence.\n */\nexport function decodeRecordAnnotations(\n bytes: Uint8Array,\n recordIndex: number,\n carriesTimekeeping = true,\n): DecodedRecordAnnotations {\n /*\n The bytes, checked before anything is read out of them.\n\n The api page offers this function on its own — \"For decoding annotation bytes yourself,\n `decodeRecordAnnotations(bytes, recordIndex)` handles one record's worth of the channel\" —\n so it is reached by a caller holding a slice they cut themselves. Hand it anything but a\n typed array and the loop below reported the shape of its own local:\n\n decodeRecordAnnotations(Buffer.from(text).toString(), 0)\n TypeError: bytes.subarray is not a function\n\n which names a variable inside this function and a method the caller never called. The\n same `OptionError` the two path-taking exports raise, for the reason given there: the call\n is what is wrong, not the recording.\n */\n /*\n A view of *bytes*, which `ArrayBuffer.isView` does not mean.\n\n It is true of every typed array and of `DataView`, and both of those reach past this check\n into the loop below — which reads `bytes[i]` and calls `bytes.subarray`. A typed array of\n wider elements has both, so it does not fail; it decodes the wrong thing and says nothing:\n\n decodeRecordAnnotations(new Float64Array(2), 0)\n { recordStart: null, annotations: [], malformed: 0, ... }\n\n Sixteen bytes of annotation channel, read as two doubles, reported as a record with\n nothing malformed in it — which is what this function says about a record that really is\n empty. A `DataView` has no index accessor at all, so every byte reads `undefined`.\n\n `BYTES_PER_ELEMENT === 1` is the question being asked: true of `Uint8Array`, `Int8Array`,\n `Uint8ClampedArray` and the `Buffer` `annotationBytes` hands this, undefined on a\n `DataView`. The same check `parseHeader` makes of its own bytes.\n */\n if (!ArrayBuffer.isView(bytes) || (bytes as { BYTES_PER_ELEMENT?: number }).BYTES_PER_ELEMENT !== 1) {\n // A view is named rather than dumped, for the reason 0.8.84 gives: a hundred doubles\n // handed back is the caller's own data, not a description of what is wrong with it.\n throw new OptionError(\n ArrayBuffer.isView(bytes)\n ? `bytes is a ${(bytes as object).constructor?.name ?? 'view'}, which is a view of an ` +\n `ArrayBuffer but not of bytes. One record's annotation channel is a Uint8Array or a ` +\n `Buffer, one byte an element — annotationBytes returns one.`\n : `bytes must be one record's annotation channel, got ${describeValue(bytes)}.`,\n );\n }\n /*\n And the second argument, which this function does not read — it writes it.\n\n `recordIndex` is copied onto every `Annotation` this call produces, and `Annotation`\n declares it a number. Nothing checked that it was one, so whatever was passed came back\n out in the event list:\n\n decodeRecordAnnotations(bytes, 'x').annotations[0].recordIndex // 'x'\n decodeRecordAnnotations(bytes).annotations[0].recordIndex // absent\n\n The second is the ordinary mistake, since the page shows this called as\n `decodeRecordAnnotations(bytes, recordIndex)` beside `annotationBytes(batch, recordOffset,\n signal)` and the two take their record different ways. It produces events with the field\n missing altogether, which `record_index` in annotations.csv is written from, and which a\n caller joining events back to records reads as a record of `undefined`.\n\n A whole non-negative number, like the record bounds `readRecords` and `sampleAt` take:\n records are counted from zero, and this one is a position in the file rather than in a\n batch, so there is no upper bound to hold it to here.\n\n Safe, though, which is a bound and not a guess. `Number.isInteger` is true of 1e300 and of\n 2^53 + 2, and neither is a position any file has: past 2^53 a double stops being able to\n tell one whole number from the next, so the value cannot name a record even in principle.\n It was written onto every event and into `record_index`, where a join reads it —\n\n decodeRecordAnnotations(bytes, 1e300).annotations[0].recordIndex // 1e+300\n\n — and `1e+300` is not even a number of the form that column holds, since every other row\n of it is plain digits. A file of 2^53 records at the format's smallest record duration is\n longer than the age of the universe; a value past it came from arithmetic, not from a file.\n */\n if (!Number.isSafeInteger(recordIndex) || recordIndex < 0) {\n throw new OptionError(\n `recordIndex must be the record's whole-number position in the file, got ` +\n `${describeValue(recordIndex)}. It is written onto every annotation this returns.`,\n );\n }\n const annotations: Annotation[] = [];\n let recordStart: number | null = null;\n let isFirstTal = true;\n let malformed = 0;\n let malformedTimekeeping = 0;\n let malformedTimekeepingWithText = 0;\n let unreadableDurations = 0;\n let negativeDurations = 0;\n\n let start = 0;\n for (let i = 0; i <= bytes.length; i++) {\n if (i !== bytes.length && bytes[i] !== TAL_END) continue;\n\n if (i > start) {\n const chunk = bytes.subarray(start, i);\n /*\n Padding is not a lost annotation.\n\n The spec pads the slot with NUL, which the loop above already skips because it is what\n separates one TAL from the next. Writers pad with spaces instead, and a run of spaces\n after the last TAL is a non-empty chunk — so a file holding one perfectly readable\n event, exported in full, was told \"2 annotation entries were unreadable and could not\n be exported\", one per record. Nothing was lost. Under --strict that is a failed run\n over the whitespace at the end of a slot.\n\n Only whitespace. A chunk of anything else that does not parse is a real loss and is\n still counted, which is the case this warning exists for.\n */\n if (chunk.every(isPaddingByte)) {\n start = i + 1;\n continue;\n }\n const parsed = parseTal(chunk, recordIndex);\n\n // The timekeeping TAL is the one in first POSITION, whether or not it decodes.\n // Clearing this flag only on a successful parse meant that an unreadable first TAL\n // promoted the next ordinary annotation to timekeeping, and its onset silently\n // became the record's start time — shifting every sample in that record. Leaving\n // recordStart null instead is what the caller already handles, with a fallback\n // timestamp and an ANNOTATION_DECODE_FAILED warning naming the record.\n /*\n Only one annotation channel carries a record's start time.\n\n This flagged the first TAL of *every* annotation channel as timekeeping. In a second\n channel the first TAL is an ordinary event — so when one failed to parse, the event\n was dropped and counted as a lost timekeeping entry, which produced the warning\n \"3 data records carry a timekeeping annotation that could not be read\" followed by\n \"No event was lost\". Three events had been lost, and the timekeeping in that file was\n perfectly readable. Both sentences false, about the same three records.\n */\n const isTimekeeping = isFirstTal && carriesTimekeeping;\n isFirstTal = false;\n\n if (parsed) {\n if (isTimekeeping) recordStart = parsed.onset;\n for (const annotation of parsed.annotations) annotations.push(annotation);\n unreadableDurations += parsed.unreadableDurations;\n negativeDurations += parsed.negativeDurations;\n } else {\n /*\n Counted apart from the events, because losing one is a different loss.\n\n A timekeeping TAL is never exported — it says where the record sits, not what\n happened — so counting it among the entries that \"could not be exported\" both\n overstated what was lost from annotations.csv and said nothing about the thing that\n actually went missing, which is a record's position in time. A file with one\n unreadable timekeeping TAL and three perfectly good events reported \"1 annotation\n entry was unreadable and could not be exported\" while exporting all three.\n\n The other direction is just as wrong. A first-position TAL may carry events after\n the start time, and when one of those cannot be parsed the events go with it — so\n counting it only as lost timekeeping produced the opposite false sentence: \"No event\n was lost\", printed over a run whose annotations.csv had gone from six rows to two.\n It is one entry that could not be exported and one record with no position, and it\n is counted as both.\n */\n if (isTimekeeping) {\n malformedTimekeeping++;\n if (carriesAnnotationText(chunk)) {\n malformedTimekeepingWithText++;\n malformed++;\n }\n } else malformed++;\n }\n }\n start = i + 1;\n }\n\n return {\n recordStart,\n annotations,\n malformed,\n malformedTimekeeping,\n malformedTimekeepingWithText,\n unreadableDurations,\n negativeDurations,\n };\n}\n\ninterface ParsedTal {\n onset: number;\n annotations: Annotation[];\n /** How many of those annotations carry a duration the file stated and this could not read. */\n unreadableDurations: number;\n /** How many carry a duration that read as a number below zero. */\n negativeDurations: number;\n}\n\n/**\n * Whether a TAL that could not be parsed still carried event text.\n *\n * A TAL is `onset[<0x15>duration]<0x14>text<0x14>...`, so anything other than padding after\n * the first 0x14 is a description the file meant to export. Read from the raw chunk, since by\n * the time this is asked the parse has already failed and there is no structure to consult.\n */\nfunction carriesAnnotationText(chunk: Uint8Array): boolean {\n let afterSeparator = false;\n for (const byte of chunk) {\n if (byte === 0x14) {\n afterSeparator = true;\n continue;\n }\n if (afterSeparator && !isPaddingByte(byte)) return true;\n }\n return false;\n}\n\n/** Space, tab, CR, LF or NUL — what a writer fills the rest of the slot with. */\nfunction isPaddingByte(byte: number): boolean {\n return byte === 0x20 || byte === 0x09 || byte === 0x0d || byte === 0x0a || byte === 0x00;\n}\n\nfunction parseTal(chunk: Uint8Array, recordIndex: number): ParsedTal | null {\n // The onset must be explicitly signed; anything else is not a TAL.\n const first = chunk[0];\n if (first !== 0x2b /* + */ && first !== 0x2d /* - */) return null;\n\n const text = decodeText(chunk);\n const parts = text.split(TEXT_SEP_CHAR);\n const head = parts[0] ?? '';\n\n let onsetText = head;\n let durationText: string | null = null;\n const durationSep = head.indexOf(DURATION_SEP_CHAR);\n if (durationSep >= 0) {\n onsetText = head.slice(0, durationSep);\n durationText = head.slice(durationSep + 1);\n }\n\n const onset = Number(onsetText);\n if (!Number.isFinite(onset)) return null;\n\n /*\n A duration the file stated and this could not read is not the same as no duration.\n\n Both came out as `null` and so as an empty `duration_s` cell, which the documentation\n defines as meaning the file gave no duration — so an event whose duration was written as\n `abc` was exported as an event with no duration, indistinguishable from one beside it\n that genuinely had none, and nothing anywhere said a field had been dropped. The onset is\n already held to this standard: one that is not a number costs the whole TAL and is\n reported. A duration is one field of an otherwise readable event, so the event is kept —\n but it is counted, and the run says so.\n */\n /*\n Fill in the field is an absent duration, not a zero.\n\n `Number` reads a run of whitespace as 0 — the rule that makes `Number('')` zero, one step\n along — so a TAL whose duration field held nothing but the writer's padding was exported\n with a `duration_s` of `0`, byte-identical to the event beside it whose file really did\n say `0`. An instantaneous event is a claim about the recording, and no writer made it;\n inventing it is the one thing this tool does not do, and it did so in silence, exit 0.\n\n The empty field this condition already declines is the same field without the fill in it,\n so padding takes the same answer: the file stated no duration. `trim` empties exactly the\n strings `Number` would otherwise have swallowed into a zero, so ` 2.5 ` still reads as\n 2.5 and `abc` is still counted as unreadable below.\n */\n let duration: number | null = null;\n let durationUnreadable = false;\n if (durationText !== null && durationText.trim() !== '') {\n const stated = durationText.trim();\n const d = DECIMAL_DURATION.test(stated) ? Number(stated) : NaN;\n if (Number.isFinite(d)) duration = d;\n else durationUnreadable = true;\n }\n\n /*\n A duration is a length of time, and a length below zero is not one.\n\n The value is kept and written as the file gave it — inventing a zero, or dropping it to\n an empty cell, would put a number in annotations.csv that no writer wrote, which is the\n one thing this tool does not do. But it is reported, because everything downstream\n quietly does the wrong thing with it: the recipe this documentation gives for the samples\n an event covers is `onset_s + duration_s`, which for a duration of -3 ends three seconds\n before the event starts and selects nothing at all, with no error anywhere.\n */\n const durationNegative = duration !== null && duration < 0;\n\n const annotations: Annotation[] = [];\n for (const raw of parts.slice(1)) {\n /*\n A trailing separator yields an empty segment; a timekeeping TAL is all empty.\n\n Whitespace counts as empty here, which it did not, and the padding at the end of the\n slot became an event. The chunk loop above already refuses to call a run of spaces a\n lost annotation — but it only sees chunks between NULs, and a writer that leaves its\n last TAL unterminated puts the fill *inside* the chunk, after the final 0x14. Split on\n that separator it is a text segment like any other, and \" \" is not \"\".\n\n A file holding two events exported four rows: `0.5,,Lights off,0` and `0.5,, ,0`,\n twice, sharing the real event's onset, with annotations_written and the run summary\n agreeing with the inflated number and nothing warned. An event whose description is\n genuinely nothing but spaces cannot be told from fill, and inventing rows out of fill\n is the worse of the two answers.\n */\n if ([...raw].every((c) => isPaddingByte(c.charCodeAt(0)))) continue;\n annotations.push({\n onset,\n duration,\n text: raw,\n recordIndex,\n ...(durationUnreadable ? { durationUnreadable: true } : {}),\n });\n }\n\n // Per event rather than per TAL: one TAL may carry several texts, and each becomes a row\n // of annotations.csv with the same cell in it.\n return {\n onset,\n annotations,\n unreadableDurations: durationUnreadable ? annotations.length : 0,\n negativeDurations: durationNegative ? annotations.length : 0,\n };\n}\n\nexport { SEP_TEXT, SEP_DURATION, TAL_END };\n"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "edf2csv",
3
- "version": "0.9.13",
3
+ "version": "0.9.14",
4
4
  "description": "Convert EDF, EDF+ and BDF biosignal recordings (European Data Format) to CSV from the command line. Local, streaming, and never resamples or alters units.",
5
5
  "keywords": [
6
6
  "edf",