edf2csv 0.9.0 → 0.9.2

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.
@@ -416,6 +416,36 @@ export function assertRecordShape(input) {
416
416
  `${describeValue(recordStarts)}. It is where the records really sit, which is what ` +
417
417
  `makes a discontinuous recording span more time than it holds.`);
418
418
  }
419
+ /*
420
+ And what is in the list, which is where the failure above actually lands.
421
+
422
+ That check asks whether the argument is a list and stops, and the contradiction its own
423
+ paragraph describes for a string comes straight back from a list of them:
424
+
425
+ resolveRange({ recordDuration: 1, recordCount: 3, recordStarts: ['a', 'b', 'c'] })
426
+ { startSeconds: 0, endSeconds: 3, startRecord: 0, endRecord: 0, isWholeRecording: true }
427
+
428
+ A range that calls itself the whole recording and covers none of it. `span` reads each
429
+ start to find the earliest and the latest, and every comparison against a string is false,
430
+ so it falls back to the contiguous span — which is why `endSeconds` looks right. Then
431
+ `selectRecords` compares the same strings again and matches no record at all. `NaN` takes
432
+ the identical route, and `NaN` is what a list built by parsing text arrives as.
433
+
434
+ A record whose position is unknown is `null`, which `readAnnotations` really does hand
435
+ back and which the code below already places from its neighbours. That is the one
436
+ non-number this takes.
437
+ */
438
+ if (recordStarts !== null && recordStarts !== undefined) {
439
+ const starts = recordStarts;
440
+ for (let at = 0; at < starts.length; at++) {
441
+ const start = starts[at];
442
+ if (start === null || start === undefined || Number.isFinite(start))
443
+ continue;
444
+ throw new OptionError(`recordStarts[${at}] must be the second that record starts at, or null where it is ` +
445
+ `not known, got ${describeValue(start)}. A start that is not a number matches no ` +
446
+ `record, so the window comes back empty and calls itself the whole recording.`);
447
+ }
448
+ }
419
449
  }
420
450
  /**
421
451
  * The recording to read, checked before it is opened.
@@ -1 +1 @@
1
- {"version":3,"file":"options.js","sourceRoot":"","sources":["../../src/convert/options.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAEH,yFAAyF;AACzF,MAAM,OAAO,WAAY,SAAQ,KAAK;IACpC,YAAY,OAAe;QACzB,KAAK,CAAC,OAAO,CAAC,CAAC;QACf,IAAI,CAAC,IAAI,GAAG,aAAa,CAAC;IAC5B,CAAC;CACF;AAED;;;;;;;;;;;GAWG;AACH,MAAM,CAAC,MAAM,YAAY,GAAG,EAAE,CAAC;AAE/B,MAAM,UAAU,aAAa,CAAC,OAkB7B;IACC;;;;;;;;;;;;;;;;;MAiBE;IACF,KAAK,MAAM,IAAI,IAAI,CAAC,WAAW,EAAE,cAAc,EAAE,SAAS,CAAU,EAAE,CAAC;QACrE,MAAM,KAAK,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;QAC5B,IAAI,KAAK,KAAK,SAAS,IAAI,OAAO,KAAK,KAAK,QAAQ,EAAE,CAAC;YACrD,MAAM,IAAI,WAAW,CACnB,GAAG,IAAI,2CAA2C,aAAa,CAAC,KAAK,CAAC,iBAAiB;gBACrF,oFAAoF,CACvF,CAAC;QACJ,CAAC;IACH,CAAC;IACD,MAAM,EAAE,QAAQ,EAAE,GAAG,OAAO,CAAC;IAC7B,IAAI,QAAQ,KAAK,SAAS,EAAE,CAAC;QAC3B,IAAI,CAAC,MAAM,CAAC,SAAS,CAAC,QAAQ,CAAC,IAAI,QAAQ,GAAG,CAAC,IAAI,QAAQ,GAAG,YAAY,EAAE,CAAC;YAC3E,MAAM,IAAI,WAAW,CACnB,iDAAiD,YAAY,SAAS,aAAa,CAAC,QAAQ,CAAC,GAAG,CACjG,CAAC;QACJ,CAAC;IACH,CAAC;IAED;;;;;;;;;;;MAWE;IACF,KAAK,MAAM,IAAI,IAAI,CAAC,OAAO,EAAE,UAAU,EAAE,KAAK,CAAU,EAAE,CAAC;QACzD,MAAM,KAAK,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;QAC5B,IAAI,KAAK,KAAK,SAAS;YAAE,SAAS;QAClC,IAAI,OAAO,KAAK,KAAK,QAAQ,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,KAAK,CAAC,EAAE,CAAC;YACzD,MAAM,IAAI,WAAW,CAAC,GAAG,IAAI,qCAAqC,aAAa,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC;QAC7F,CAAC;QACD;;;;;;;;;UASE;QACF,IAAI,IAAI,KAAK,UAAU,IAAI,KAAK,GAAG,CAAC,EAAE,CAAC;YACrC,MAAM,IAAI,WAAW,CAAC,iDAAiD,aAAa,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC;QAClG,CAAC;IACH,CAAC;IAED;;;;;;;;MAQE;IACF,MAAM,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC;IAC3B,IAAI,MAAM,KAAK,SAAS,IAAI,MAAM,KAAK,MAAM,IAAI,MAAM,KAAK,MAAM,EAAE,CAAC;QACnE,MAAM,IAAI,WAAW,CAAC,wCAAwC,aAAa,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC;IAC1F,CAAC;IAED;;;;;;;;;;;;;;;MAeE;IACF;;;;;;;;;;;;;;MAcE;IACF;;;;;;;;;;;;;;;MAeE;IACF,MAAM,EAAE,SAAS,EAAE,GAAG,OAAO,CAAC;IAC9B,IAAI,SAAS,KAAK,SAAS,IAAI,OAAO,SAAS,KAAK,QAAQ,EAAE,CAAC;QAC7D,MAAM,IAAI,WAAW,CAAC,iCAAiC,aAAa,CAAC,SAAS,CAAC,GAAG,CAAC,CAAC;IACtF,CAAC;IACD,IAAI,SAAS,KAAK,EAAE,EAAE,CAAC;QACrB,MAAM,IAAI,WAAW,CAAC,kEAAkE,CAAC,CAAC;IAC5F,CAAC;IAED;;;;;;;;;;;;;;;MAeE;IACF,KAAK,MAAM,IAAI,IAAI,CAAC,iBAAiB,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,EAAE,UAAU,EAAE,UAAU,CAAU,EAAE,CAAC;QAChG,MAAM,KAAK,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;QAC5B,IAAI,KAAK,KAAK,SAAS,IAAI,OAAO,KAAK,KAAK,SAAS,EAAE,CAAC;YACtD,MAAM,IAAI,WAAW,CAAC,GAAG,IAAI,+BAA+B,aAAa,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC;QACvF,CAAC;IACH,CAAC;IAED;;;;;;;;;;;;;;;;;MAiBE;IACF,MAAM,EAAE,UAAU,EAAE,GAAG,OAAO,CAAC;IAC/B,IAAI,UAAU,KAAK,SAAS,IAAI,OAAO,UAAU,KAAK,UAAU,EAAE,CAAC;QACjE,MAAM,IAAI,WAAW,CAAC,sCAAsC,aAAa,CAAC,UAAU,CAAC,GAAG,CAAC,CAAC;IAC5F,CAAC;IAED,MAAM,EAAE,QAAQ,EAAE,GAAG,OAAO,CAAC;IAC7B,IAAI,QAAQ,KAAK,SAAS,EAAE,CAAC;QAC3B;;;;;;;UAOE;QACF,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,QAAQ,CAAC,IAAI,QAAQ,CAAC,IAAI,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,OAAO,IAAI,KAAK,QAAQ,CAAC,EAAE,CAAC;YAClF,MAAM,IAAI,WAAW,CAAC,iDAAiD,aAAa,CAAC,QAAQ,CAAC,GAAG,CAAC,CAAC;QACrG,CAAC;QACD,IAAI,QAAQ,CAAC,KAAK,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,IAAI,CAAC,IAAI,EAAE,KAAK,EAAE,CAAC,EAAE,CAAC;YACjD,MAAM,IAAI,WAAW,CAAC,gDAAgD,CAAC,CAAC;QAC1E,CAAC;IACH,CAAC;AACH,CAAC;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,MAAM,UAAU,aAAa,CAAC,OAAgB;IAC5C,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,OAAO,CAAC,EAAE,CAAC;QAC5B,MAAM,IAAI,WAAW,CACnB,uDAAuD,aAAa,CAAC,OAAO,CAAC,GAAG,CACjF,CAAC;IACJ,CAAC;IACD;;;;;;;;;;;;;;;;;;;;;MAqBE;IACF,MAAM,MAAM,GAAG;QACb,CAAC,OAAO,EAAE,QAAQ,CAAC;QACnB,CAAC,OAAO,EAAE,QAAQ,CAAC;QACnB,CAAC,eAAe,EAAE,SAAS,CAAC;KACpB,CAAC;IACX,KAAK,MAAM,CAAC,EAAE,EAAE,MAAM,CAAC,IAAI,OAAO,CAAC,OAAO,EAAE,EAAE,CAAC;QAC7C,IAAI,OAAO,MAAM,KAAK,QAAQ,IAAI,MAAM,KAAK,IAAI,EAAE,CAAC;YAClD,MAAM,IAAI,WAAW,CACnB,WAAW,EAAE,yCAAyC,aAAa,CAAC,MAAM,CAAC,GAAG,CAC/E,CAAC;QACJ,CAAC;QACD,MAAM,MAAM,GAAG,MAAiC,CAAC;QACjD,MAAM,KAAK,GAAG,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,EAAE,IAAI,CAAC,EAAE,EAAE,CAAC,OAAO,MAAM,CAAC,IAAI,CAAC,KAAK,IAAI,CAAC,CAAC;QAC1E,IAAI,KAAK,KAAK,SAAS;YAAE,SAAS;QAClC,MAAM,CAAC,IAAI,EAAE,IAAI,CAAC,GAAG,KAAK,CAAC;QAC3B,MAAM,IAAI,WAAW,CACnB,WAAW,EAAE,KAAK,IAAI,cAAc,IAAI,SAAS,aAAa,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC,cAAc;YACxF,yBAAyB,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,GAAG,CAChE,CAAC;IACJ,CAAC;AACH,CAAC;AAED;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,MAAM,UAAU,eAAe,CAAC,KAI/B;IACC,aAAa,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC;IAC7B,iBAAiB,CAAC,KAAK,CAAC,CAAC;IACzB,sBAAsB,CAAC,KAAK,CAAC,OAA6C,CAAC,CAAC;AAC9E,CAAC;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,SAAS,sBAAsB,CAAC,OAA2C;IACzE,KAAK,MAAM,CAAC,EAAE,EAAE,MAAM,CAAC,IAAI,OAAO,CAAC,OAAO,EAAE,EAAE,CAAC;QAC7C,MAAM,IAAI,GAAG,MAAM,CAAC,cAAc,CAAC,CAAC;QACpC,IAAI,OAAO,IAAI,KAAK,QAAQ,IAAI,MAAM,CAAC,KAAK,CAAC,IAAI,CAAC,IAAI,IAAI,GAAG,CAAC,EAAE,CAAC;YAC/D,MAAM,IAAI,WAAW,CACnB,WAAW,EAAE,uDAAuD;gBAClE,GAAG,aAAa,CAAC,IAAI,CAAC,+DAA+D;gBACrF,qDAAqD,CACxD,CAAC;QACJ,CAAC;QACD,MAAM,OAAO,GAAG,MAAM,CAAC,kBAAkB,CAAC,CAAC;QAC3C,IAAI,CAAC,MAAM,CAAC,SAAS,CAAC,OAAO,CAAC,IAAK,OAAkB,GAAG,CAAC,EAAE,CAAC;YAC1D,MAAM,IAAI,WAAW,CACnB,WAAW,EAAE,4DAA4D;gBACvE,GAAG,aAAa,CAAC,OAAO,CAAC,6CAA6C,CACzE,CAAC;QACJ,CAAC;IACH,CAAC;AACH,CAAC;AAED;;;;;;;;;;;;;;;;;;GAkBG;AACH,MAAM,UAAU,iBAAiB,CAAC,KAIjC;IACC,MAAM,EAAE,cAAc,EAAE,WAAW,EAAE,YAAY,EAAE,GAAG,KAAK,IAAI,EAAE,CAAC;IAClE,IAAI,OAAO,cAAc,KAAK,QAAQ,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,cAAc,CAAC,IAAI,cAAc,IAAI,CAAC,EAAE,CAAC;QAClG,MAAM,IAAI,WAAW,CACnB,4DAA4D,aAAa,CAAC,cAAc,CAAC,GAAG,CAC7F,CAAC;IACJ,CAAC;IACD,IAAI,CAAC,MAAM,CAAC,SAAS,CAAC,WAAW,CAAC,IAAK,WAAsB,GAAG,CAAC,EAAE,CAAC;QAClE,MAAM,IAAI,WAAW,CACnB,2DAA2D,aAAa,CAAC,WAAW,CAAC,GAAG,CACzF,CAAC;IACJ,CAAC;IACD;;;;;;;;;;;;;;;;;;;MAmBE;IACF,IACE,YAAY,KAAK,IAAI;QACrB,YAAY,KAAK,SAAS;QAC1B,CAAC,KAAK,CAAC,OAAO,CAAC,YAAY,CAAC;QAC5B,CAAC,WAAW,CAAC,MAAM,CAAC,YAAY,CAAC,EACjC,CAAC;QACD,MAAM,IAAI,WAAW,CACnB,kEAAkE;YAChE,GAAG,aAAa,CAAC,YAAY,CAAC,sDAAsD;YACpF,+DAA+D,CAClE,CAAC;IACJ,CAAC;AACH,CAAC;AAED;;;;;;;;;;;;;GAaG;AACH,MAAM,UAAU,eAAe,CAAC,KAAc;IAC5C,IAAI,OAAO,KAAK,KAAK,QAAQ,EAAE,CAAC;QAC9B,MAAM,IAAI,WAAW,CAAC,4CAA4C,aAAa,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC;IAC7F,CAAC;AACH,CAAC;AAED;;;;;;;;;;;;;;GAcG;AACH,MAAM,UAAU,aAAa,CAAC,KAAc;IAC1C,OAAO,OAAO,KAAK,KAAK,QAAQ,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,SAAS,CAAC,KAAK,CAAC,IAAI,MAAM,CAAC,KAAK,CAAC,CAAC;AAC5F,CAAC","sourcesContent":["/**\n * Checking the options a caller passed, before anything is written.\n *\n * The command line has always validated these — `--decimals 1.5` is a usage error and always\n * has been — and the library did not, so the same value behaved differently depending on how\n * it arrived. `convert(file, { decimals: NaN })` resolved successfully having written whole\n * numbers into a column the caller had asked for decimals in, which is the worst of the\n * three: no error, no warning, and output that looks like a deliberate choice. `decimals: -1`\n * reached `toFixed` and came back as a bare RangeError from deep inside the formatter, naming\n * nothing the caller had written. `start: NaN` created the output directory, wrote\n * signals.csv, and then failed with a message about the input being unreadable — a partial\n * conversion, blamed on the file.\n *\n * These run at the top of `buildPlan`, which every path goes through before a directory is\n * created or a stream is opened, so a rejected option leaves nothing behind.\n */\n\n/** A problem with the options a caller passed, as opposed to a problem with the file. */\nexport class OptionError extends Error {\n constructor(message: string) {\n super(message);\n this.name = 'OptionError';\n }\n}\n\n/**\n * The largest `--decimals` accepts, and what both documentation pages state.\n *\n * Not a limit of `toFixed`, whatever this comment used to say. `toFixed` takes 0 to 100 and\n * throws a RangeError at 101 — which is exactly the belief `MAX_DERIVED_DECIMALS` in\n * edf/scale.ts exists to correct, having once clamped the *derived* precision to 20 on the\n * same wrong grounds and rounded a magnetometer channel needing 23 places onto a grid three\n * digital codes wide, losing 69% of its samples in silence.\n *\n * Twenty is a bound on a number a person types by hand, not on what the format can express.\n * The derived precision, which nobody types, runs to 100 and says so.\n */\nexport const MAX_DECIMALS = 20;\n\nexport function assertOptions(options: {\n decimals?: number | undefined;\n start?: number | undefined;\n duration?: number | undefined;\n end?: number | undefined;\n layout?: string | undefined;\n channels?: readonly string[] | undefined;\n outputDir?: string | undefined;\n annotationsOnly?: boolean | undefined;\n gzip?: boolean | undefined;\n bom?: boolean | undefined;\n force?: boolean | undefined;\n checksum?: boolean | undefined;\n toStdout?: boolean | undefined;\n onProgress?: unknown;\n startText?: unknown;\n durationText?: unknown;\n endText?: unknown;\n}): void {\n /*\n The three options that exist only to be quoted back, quoted back unexamined.\n\n `startText`, `durationText` and `endText` carry the value exactly as the caller's user\n typed it, so a refusal names that rather than its parsed form — \"--start \\\"4h\\\"\" rather\n than \"--start 14400s\". Nothing asked whether they were text, and they reach the sentence\n as they are:\n\n convert(file, { start: 99, startText: {} })\n TimeRangeError: --start \"[object Object]\" is at or past the end of this 3s recording.\n\n convert(file, { start: 1, end: 0.5, endText: [] })\n TimeRangeError: The requested window ends at \"\", which is not after its start at 1s.\n\n `[object Object]` is the string `assertInputPath`'s own docstring names as the reason that\n function exists, and the empty quotation is the hole `describeValue` was written to stop —\n both in the one place whose entire purpose is to show the reader what they typed.\n */\n for (const name of ['startText', 'durationText', 'endText'] as const) {\n const value = options[name];\n if (value !== undefined && typeof value !== 'string') {\n throw new OptionError(\n `${name} must be the value as it was typed, got ${describeValue(value)}. It is quoted ` +\n `back in the window errors so they name what was given rather than its parsed form.`,\n );\n }\n }\n const { decimals } = options;\n if (decimals !== undefined) {\n if (!Number.isInteger(decimals) || decimals < 0 || decimals > MAX_DECIMALS) {\n throw new OptionError(\n `decimals must be a whole number between 0 and ${MAX_DECIMALS}, got ${describeValue(decimals)}.`,\n );\n }\n }\n\n /*\n `start` and `end` are positions on the recording's own clock, `duration` is a length.\n\n All three were held above zero, which is right for a length and wrong for a position: a\n recording timed from its first record's timekeeping annotation may sit before zero, and\n -100 is then where its first sample is. So a caller could read `plan.range` back as\n `recordingStartSeconds: -100` and not be allowed to ask for it — the same wall\n `parseTimeSpec` put in front of the command line until 0.5.120.\n\n Non-finite is still refused for all three, since NaN reaches a comparison as false and\n would take the whole recording without saying so.\n */\n for (const name of ['start', 'duration', 'end'] as const) {\n const value = options[name];\n if (value === undefined) continue;\n if (typeof value !== 'number' || !Number.isFinite(value)) {\n throw new OptionError(`${name} must be a number of seconds, got ${describeValue(value)}.`);\n }\n /*\n A length below zero is refused for what it is, rather than for not being a number.\n\n `duration: -1` came back as \"duration must be a number of seconds, got -1\", which is\n not the reason and not true: -1 is a number of seconds, and this same call accepts it\n for `start` and for `end`, where a recording timed from before zero makes it an\n ordinary position. What is wrong is that a duration is a length, and no length is\n negative — which is what the command line says for the same value, and what the API\n reference has always said this check enforces.\n */\n if (name === 'duration' && value < 0) {\n throw new OptionError(`duration is a length of time, so it cannot be ${describeValue(value)}.`);\n }\n }\n\n /*\n Two words, and a caller who writes a third means something the tool cannot do.\n\n The command line has always rejected `--layout tall`. The library took it, put it in\n `plan.layout` for the caller to read back, and wrote the wide layout — so a programmatic\n caller with a typo got a conversion that was not the one they asked for, described by a\n plan that agreed with the typo. Every other option that has a shape is checked here; this\n one was added in 0.5.0 and never joined them.\n */\n const { layout } = options;\n if (layout !== undefined && layout !== 'wide' && layout !== 'long') {\n throw new OptionError(`layout must be \"wide\" or \"long\", got ${describeValue(layout)}.`);\n }\n\n /*\n A selection that names nothing is not the absence of a selection.\n\n `buildPlan` asks `options.channels.length > 0` before selecting, so an empty array fell\n through to the branch that means \"no channels option was given\" — and `convert(file, {\n channels: [] })` wrote every channel in the recording, resolved, and said nothing. That is\n the one shape of this the command line has always refused, in as many words: \"Returning\n undefined here would mean 'no --channels given' and convert everything, which is the\n opposite of what someone passing an empty list is asking for.\"\n\n A list of blanks is the same request written differently — it is what `''.split(',')`\n produces, which is how a caller building the array from user input arrives here — and it\n reached `selectChannels` and came back \"No channels were selected\", a sentence about the\n file rather than about the call. Both are the option being wrong, so both are refused\n here, before a directory exists.\n */\n /*\n An empty destination, refused here rather than by the filesystem.\n\n `convert(file, { outputDir: '' })` went the whole way to `mkdir('')` and came back a\n `ConversionError`: \"Cannot create \\\"\\\": part of the path does not exist. Check the path\n exists and that you have permission to write there.\" Advice about a path and a permission\n for a value that is neither, and a failure class that means the conversion went wrong\n where the option did.\n\n The command line refused this at 0.6.x, with the reasoning that `--out \"$DEST\"` and `DEST`\n unset is how it gets written by accident — and left the library, which a caller building\n the path in code reaches the same way. Not trimmed, for the reason given there: a\n directory whose name is a space is a strange thing to ask for, but it is a thing the\n filesystem has and a path is not a keyword.\n */\n /*\n And its shape, which was the one option with a value and no check on it.\n\n The empty string was refused and nothing else was, so `outputDir` failed two ways that the\n paragraph above describes for the flags. A value of the wrong type reached `path.join` and\n came back as a Node error about an argument this caller never passed:\n\n convert('rec.edf', { outputDir: 42 })\n TypeError: The \"path\" argument must be of type string. Received type number (42)\n\n And `null` — which is what `JSON.parse` of a config file gives for a field left unset, the\n same door `1` and `'true'` come through — was not an error at all. It is not `undefined`,\n so it never meant \"use the default\", but every read of it is `?? default` or a truthiness\n test, so that is what it did: the rows went to `<recording>_csv` beside the input, a\n directory the caller had not named, and the run reported success.\n */\n const { outputDir } = options;\n if (outputDir !== undefined && typeof outputDir !== 'string') {\n throw new OptionError(`outputDir must be a path, got ${describeValue(outputDir)}.`);\n }\n if (outputDir === '') {\n throw new OptionError('outputDir is empty. Give a directory, for example \"./converted\".');\n }\n\n /*\n The six flags, every one of which is read as `=== true` where it is read.\n\n Which means a value that is not a boolean is not merely tolerated: it is taken as the\n opposite of what it says. `convert(file, { annotationsOnly: 'true' })` wrote every signal\n the caller had asked to leave out; `{ gzip: 1 }` wrote plain CSVs under names ending\n `.csv`, so a caller who then opened `signals.csv.gz` found nothing there. No error, no\n warning, and output that looks like a deliberate choice — which is the sentence at the\n top of this file, describing the case it was written for.\n\n `1` and `'true'` are how a flag arrives from `JSON.parse` of a config file, from a query\n string, or from a CLI wrapper that did not coerce; none of them is a caller being\n careless in a way TypeScript would catch, since the callers this reaches are the ones not\n using it. The layout check above states the rule these were missing from: \"Every other\n option that has a shape is checked here.\"\n */\n for (const name of ['annotationsOnly', 'gzip', 'bom', 'force', 'checksum', 'toStdout'] as const) {\n const value = options[name];\n if (value !== undefined && typeof value !== 'boolean') {\n throw new OptionError(`${name} must be true or false, got ${describeValue(value)}.`);\n }\n }\n\n /*\n The one option that is called rather than read, and the only one that was not checked.\n\n `convert` invokes it as `options.onProgress?.(...)` once a record has been written, so a\n value that is not a function passes every check here, opens the destination, writes rows\n into it and then fails from inside the loop:\n\n convert('rec.edf', { outputDir: 'out', onProgress: 'every record' })\n ConversionError: The onProgress callback threw: options.onProgress is not a function\n\n A callback that threw is what that sentence reports, and no callback was given; the text\n after the colon names an expression inside this package. Worse is what it leaves: `out`\n exists with a half-written signals.csv in it, which is the case the paragraph at the top\n of this file describes — \"`start: NaN` created the output directory, wrote signals.csv,\n and then failed with a message about the input being unreadable — a partial conversion,\n blamed on the file\". Checked here, the same call writes nothing and says which argument\n is wrong.\n */\n const { onProgress } = options;\n if (onProgress !== undefined && typeof onProgress !== 'function') {\n throw new OptionError(`onProgress must be a function, got ${describeValue(onProgress)}.`);\n }\n\n const { channels } = options;\n if (channels !== undefined) {\n /*\n A list of strings, checked as one. `selectChannels` calls `.trim()` on every term, so a\n caller who passed the string `'ECG'` had it iterated character by character and was told\n `No channel named \"E\"`, and one who passed `[1]` — a position, reasonably enough — got\n `TypeError: rawTerm.trim is not a function` out of the middle of the selector, naming\n nothing they had written. Both are the option being the wrong shape, which is the case\n this function exists for.\n */\n if (!Array.isArray(channels) || channels.some((term) => typeof term !== 'string')) {\n throw new OptionError(`channels must be a list of channel names, got ${describeValue(channels)}.`);\n }\n if (channels.every((term) => term.trim() === '')) {\n throw new OptionError('channels was given but lists no channel names.');\n }\n }\n}\n\n/**\n * The channel list two exported functions take, checked the way their other argument is.\n *\n * `selectChannels(signals, terms)` has checked `terms` since 0.6.x — \"`'ECG'` was iterated\n * character by character and answered `No channel named \\\"E\\\"`… naming nothing the caller had\n * written\" — and never checked `signals`, which is the argument in front of it. Passing one\n * signal where the list goes, or a header where its `signals` goes, came back as\n * `TypeError: signals.filter is not a function`: a local of this package, over a value the\n * caller did write.\n *\n * `buildColumnNames` is worse off, because a string is iterable. `buildColumnNames('ECG')`\n * returned `Map { null => 'undefined_chundefined' }` and no error at all — a column name for a\n * channel that does not exist, keyed by a position that is not one.\n *\n * The bad entry is named by position rather than the whole list being printed back: a header\n * may declare hundreds of channels, and a message is not the place for all of them.\n */\nexport function assertSignals(signals: unknown): void {\n if (!Array.isArray(signals)) {\n throw new OptionError(\n `signals must be the channel list from a header, got ${describeValue(signals)}.`,\n );\n }\n /*\n The fields the callers read, not just the one that identifies a channel.\n\n This asked for `index` and stopped, and its callers go on to read `label` and\n `isAnnotations` off the same objects. So a list of channel-shaped objects without them\n passed the check that exists to say \"these are not channels from a header\" and failed one\n line further in, or did not fail at all:\n\n selectChannels([{ index: 0 }], ['ECG'])\n TypeError: Cannot read properties of undefined (reading 'toLowerCase')\n\n selectChannels([{ index: 0, label: 42 }], ['42'])\n TypeError: signal.label.toLowerCase is not a function\n\n buildColumnNames([{ index: 0 }])\n Map { 0 => null }\n\n The first two are the failure this function was written to remove, one level down — a\n local of this package named at a caller who wrote neither. The third is worse: a column\n name of `null`, where a channel with no label at all is named `signal_0`, so `null` is not\n a name this tool ever writes.\n */\n const FIELDS = [\n ['index', 'number'],\n ['label', 'string'],\n ['isAnnotations', 'boolean'],\n ] as const;\n for (const [at, signal] of signals.entries()) {\n if (typeof signal !== 'object' || signal === null) {\n throw new OptionError(\n `signals[${at}] is not a channel from a header, got ${describeValue(signal)}.`,\n );\n }\n const fields = signal as Record<string, unknown>;\n const wrong = FIELDS.find(([name, kind]) => typeof fields[name] !== kind);\n if (wrong === undefined) continue;\n const [name, kind] = wrong;\n throw new OptionError(\n `signals[${at}].${name} must be a ${kind}, got ${describeValue(fields[name])}. A channel ` +\n `from a header carries ${FIELDS.map(([f]) => f).join(', ')}.`,\n );\n }\n}\n\n/**\n * What `buildPlan` is told about the recording, checked the way what it is asked for is.\n *\n * `assertOptions` runs at the top of `buildPlan` and covers the second argument completely.\n * The first was not looked at, and it is the one carrying the numbers every figure in the plan\n * is derived from. Two of them missing produced a plan rather than an error:\n *\n * buildPlan({ signals, recordDuration: 1 }, {})\n * // groups: 3, estimate.rows: 0, range.endSeconds: null\n *\n * A plan saying the conversion writes nothing, handed back as an answer — which is the \"takes\n * the whole recording without saying so\" this checker exists to stop, one field over. A record\n * count below zero was worse: it came back as\n *\n * TimeRangeError: --start 0s is at or past the end of this -5s recording.\n *\n * a flag the caller never passed, about a recording that cannot exist, blaming the request for\n * the input. And `recordDuration: '1'` was coerced by the arithmetic and accepted, where the\n * same string is refused for `end` two functions down.\n *\n * A real header cannot produce any of them: the parser refuses a record duration that is not a\n * positive number, \"Infinity\" included.\n */\nexport function assertPlanInput(input: {\n signals?: unknown;\n recordDuration?: unknown;\n recordCount?: unknown;\n}): void {\n assertSignals(input.signals);\n assertRecordShape(input);\n assertPlannableSignals(input.signals as readonly Record<string, unknown>[]);\n}\n\n/**\n * The two numbers on a channel that `buildPlan` reads and `assertSignals` does not ask about.\n *\n * `assertSignals` asks for `index`, `label` and `isAnnotations`, which is what its other two\n * callers read — they name columns and match terms. `buildPlan` goes further: it groups the\n * channels by `samplingRate` and counts rows from `samplesPerRecord`, and asked nothing of\n * either. So a list of channel-shaped objects carrying the three fields it does check reached\n * the rate formatter and came back\n *\n * buildPlan({ signals: [{ index: 0, label: 'ECG', isAnnotations: false }], … }, {})\n * OptionError: hz must be a sampling rate in hertz, got undefined.\n *\n * naming `hz`, a parameter of a function three calls down, at a caller who passed `signals`.\n * That is the failure `assertSignals` exists to remove, and it is the same one its own\n * docstring quotes for `label`.\n *\n * The values it takes are the ones a header can really state, which is wider than it looks:\n * `samplesPerRecord` may be zero — that is what `NO_SAMPLES` reports — and a rate may be zero\n * or `Infinity`, because a record duration small enough to overflow the division is five\n * characters in an eight-character field. What a header cannot state is a fractional or\n * negative sample count, and those went through as arithmetic:\n *\n * samplesPerRecord: 2.5 // estimate.rows: 394.5, half a row\n * samplesPerRecord: -4 // estimate.rows falls, with nothing said\n *\n * Split out rather than folded into `assertSignals`, for the reason `assertRecordShape` gives\n * one function down: `selectChannels` and `buildColumnNames` never look at either field, and a\n * checker should not demand what its caller does not read.\n */\nfunction assertPlannableSignals(signals: readonly Record<string, unknown>[]): void {\n for (const [at, signal] of signals.entries()) {\n const rate = signal['samplingRate'];\n if (typeof rate !== 'number' || Number.isNaN(rate) || rate < 0) {\n throw new OptionError(\n `signals[${at}].samplingRate must be a sampling rate in hertz, got ` +\n `${describeValue(rate)}. It is samplesPerRecord over the record duration, and it is ` +\n `what the channels are grouped into output files by.`,\n );\n }\n const samples = signal['samplesPerRecord'];\n if (!Number.isInteger(samples) || (samples as number) < 0) {\n throw new OptionError(\n `signals[${at}].samplesPerRecord must be a whole number of samples, got ` +\n `${describeValue(samples)}. Every row this plan counts comes from it.`,\n );\n }\n }\n}\n\n/**\n * The two numbers a window is measured against, apart from the channel list.\n *\n * `resolveRange` is exported on its own and has its own signature block on the api page, and\n * `buildPlan` calls it — so it was covered only from above. Called directly it took both\n * numbers unexamined and answered with a range:\n *\n * resolveRange({ recordDuration: 1 }) // recordCount undefined\n * // { startSeconds: 0, endSeconds: null, startRecord: 0, endRecord: 0 }\n *\n * A range over no records, returned as a fact about a recording. Its own opening comment\n * already says why that is the wrong answer — \"no error, no warning, and a range read back as\n * `startSeconds: null, startRecord: null`, which is the 'takes the whole recording without\n * saying so'\" — about the three fields it does check. `resolveRange(42)` went the same way,\n * since reading `.start` off a number is `undefined` rather than a throw.\n *\n * Split out rather than calling `assertPlanInput`, which would demand a channel list this\n * function never looks at.\n */\nexport function assertRecordShape(input: {\n recordDuration?: unknown;\n recordCount?: unknown;\n recordStarts?: unknown;\n}): void {\n const { recordDuration, recordCount, recordStarts } = input ?? {};\n if (typeof recordDuration !== 'number' || !Number.isFinite(recordDuration) || recordDuration <= 0) {\n throw new OptionError(\n `recordDuration must be a positive number of seconds, got ${describeValue(recordDuration)}.`,\n );\n }\n if (!Number.isInteger(recordCount) || (recordCount as number) < 0) {\n throw new OptionError(\n `recordCount must be a whole number of data records, got ${describeValue(recordCount)}.`,\n );\n }\n /*\n And where the records really sit, which is what makes a discontinuous file's span longer\n than its duration. Nothing asked what it was, and the two ways of getting it wrong fail\n differently:\n\n resolveRange({ recordDuration: 1, recordCount: 3, recordStarts: 'x' })\n { startSeconds: 0, endSeconds: 3, startRecord: 0, endRecord: 0, isWholeRecording: true }\n\n A string is iterable, so it spreads to its characters and the span comes out over no\n records at all — a range that says it is the whole recording and covers none of it, which\n is a contradiction rather than an answer.\n\n resolveRange({ ..., recordStarts: 42 })\n TypeError: recordStarts is not iterable\n\n which names this function's own parameter at a caller holding the wrong thing. A list is\n what it takes: the `Float64Array` the reader builds, or an ordinary array — `readAnnotations`\n hands back `(number | null)[]`, and a null start is a record whose position is not known,\n which this already allows for.\n */\n if (\n recordStarts !== null &&\n recordStarts !== undefined &&\n !Array.isArray(recordStarts) &&\n !ArrayBuffer.isView(recordStarts)\n ) {\n throw new OptionError(\n `recordStarts must be a list of record start times, or null, got ` +\n `${describeValue(recordStarts)}. It is where the records really sit, which is what ` +\n `makes a discontinuous recording span more time than it holds.`,\n );\n }\n}\n\n/**\n * The recording to read, checked before it is opened.\n *\n * `EdfFile.open` hands whatever it is given to `fs`, and the refusal comes back as an\n * `EdfError` coded `UNREADABLE`, hinted \"Check the path is spelled the way it is on disk and\n * that you have permission to read it\" — advice about a path, over a value that is not one,\n * filed as a problem with the recording rather than with the call. `convert({ input: 'a.edf' })`,\n * which is the option-bag shape the second parameter has, answered `Cannot read \"[object\n * Object]\"`; `convert(['a.edf', 'b.edf'])` answered `Cannot read \"a.edf,b.edf\"`, a path the\n * caller never wrote, because `String` of an array joins it with commas.\n *\n * The empty string is left to `fs`, which has no such file and says so truthfully — the same\n * reasoning `outputDir` states for not trimming: a path is not a keyword.\n */\nexport function assertInputPath(input: unknown): void {\n if (typeof input !== 'string') {\n throw new OptionError(`input must be a path to a recording, got ${describeValue(input)}.`);\n }\n}\n\n/**\n * How a rejected value reads in the refusal: numbers bare, everything else quoted so its\n * type is visible.\n *\n * `JSON.stringify` has no text for a function or a symbol — it returns `undefined`, not a\n * string — so `layout: () => 'long'` came back as `layout must be \"wide\" or \"long\", got\n * undefined.`, which names the one value that does not raise this: every option here is\n * optional, and `undefined` is how a caller says they are not passing it. `input` was worse,\n * since `convert(undefined)` and `convert(someFunction)` then produced the same sentence, and\n * the first is a forgotten argument while the second is a wrong one.\n *\n * Exported because time-range.ts had the identical function, fixed there and not here — the\n * same two-copies-of-one-helper the derived precision and the pluraliser were each pulled\n * together for.\n */\nexport function describeValue(value: unknown): string {\n return typeof value === 'number' ? String(value) : JSON.stringify(value) ?? String(value);\n}\n"]}
1
+ {"version":3,"file":"options.js","sourceRoot":"","sources":["../../src/convert/options.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAEH,yFAAyF;AACzF,MAAM,OAAO,WAAY,SAAQ,KAAK;IACpC,YAAY,OAAe;QACzB,KAAK,CAAC,OAAO,CAAC,CAAC;QACf,IAAI,CAAC,IAAI,GAAG,aAAa,CAAC;IAC5B,CAAC;CACF;AAED;;;;;;;;;;;GAWG;AACH,MAAM,CAAC,MAAM,YAAY,GAAG,EAAE,CAAC;AAE/B,MAAM,UAAU,aAAa,CAAC,OAkB7B;IACC;;;;;;;;;;;;;;;;;MAiBE;IACF,KAAK,MAAM,IAAI,IAAI,CAAC,WAAW,EAAE,cAAc,EAAE,SAAS,CAAU,EAAE,CAAC;QACrE,MAAM,KAAK,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;QAC5B,IAAI,KAAK,KAAK,SAAS,IAAI,OAAO,KAAK,KAAK,QAAQ,EAAE,CAAC;YACrD,MAAM,IAAI,WAAW,CACnB,GAAG,IAAI,2CAA2C,aAAa,CAAC,KAAK,CAAC,iBAAiB;gBACrF,oFAAoF,CACvF,CAAC;QACJ,CAAC;IACH,CAAC;IACD,MAAM,EAAE,QAAQ,EAAE,GAAG,OAAO,CAAC;IAC7B,IAAI,QAAQ,KAAK,SAAS,EAAE,CAAC;QAC3B,IAAI,CAAC,MAAM,CAAC,SAAS,CAAC,QAAQ,CAAC,IAAI,QAAQ,GAAG,CAAC,IAAI,QAAQ,GAAG,YAAY,EAAE,CAAC;YAC3E,MAAM,IAAI,WAAW,CACnB,iDAAiD,YAAY,SAAS,aAAa,CAAC,QAAQ,CAAC,GAAG,CACjG,CAAC;QACJ,CAAC;IACH,CAAC;IAED;;;;;;;;;;;MAWE;IACF,KAAK,MAAM,IAAI,IAAI,CAAC,OAAO,EAAE,UAAU,EAAE,KAAK,CAAU,EAAE,CAAC;QACzD,MAAM,KAAK,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;QAC5B,IAAI,KAAK,KAAK,SAAS;YAAE,SAAS;QAClC,IAAI,OAAO,KAAK,KAAK,QAAQ,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,KAAK,CAAC,EAAE,CAAC;YACzD,MAAM,IAAI,WAAW,CAAC,GAAG,IAAI,qCAAqC,aAAa,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC;QAC7F,CAAC;QACD;;;;;;;;;UASE;QACF,IAAI,IAAI,KAAK,UAAU,IAAI,KAAK,GAAG,CAAC,EAAE,CAAC;YACrC,MAAM,IAAI,WAAW,CAAC,iDAAiD,aAAa,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC;QAClG,CAAC;IACH,CAAC;IAED;;;;;;;;MAQE;IACF,MAAM,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC;IAC3B,IAAI,MAAM,KAAK,SAAS,IAAI,MAAM,KAAK,MAAM,IAAI,MAAM,KAAK,MAAM,EAAE,CAAC;QACnE,MAAM,IAAI,WAAW,CAAC,wCAAwC,aAAa,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC;IAC1F,CAAC;IAED;;;;;;;;;;;;;;;MAeE;IACF;;;;;;;;;;;;;;MAcE;IACF;;;;;;;;;;;;;;;MAeE;IACF,MAAM,EAAE,SAAS,EAAE,GAAG,OAAO,CAAC;IAC9B,IAAI,SAAS,KAAK,SAAS,IAAI,OAAO,SAAS,KAAK,QAAQ,EAAE,CAAC;QAC7D,MAAM,IAAI,WAAW,CAAC,iCAAiC,aAAa,CAAC,SAAS,CAAC,GAAG,CAAC,CAAC;IACtF,CAAC;IACD,IAAI,SAAS,KAAK,EAAE,EAAE,CAAC;QACrB,MAAM,IAAI,WAAW,CAAC,kEAAkE,CAAC,CAAC;IAC5F,CAAC;IAED;;;;;;;;;;;;;;;MAeE;IACF,KAAK,MAAM,IAAI,IAAI,CAAC,iBAAiB,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,EAAE,UAAU,EAAE,UAAU,CAAU,EAAE,CAAC;QAChG,MAAM,KAAK,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;QAC5B,IAAI,KAAK,KAAK,SAAS,IAAI,OAAO,KAAK,KAAK,SAAS,EAAE,CAAC;YACtD,MAAM,IAAI,WAAW,CAAC,GAAG,IAAI,+BAA+B,aAAa,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC;QACvF,CAAC;IACH,CAAC;IAED;;;;;;;;;;;;;;;;;MAiBE;IACF,MAAM,EAAE,UAAU,EAAE,GAAG,OAAO,CAAC;IAC/B,IAAI,UAAU,KAAK,SAAS,IAAI,OAAO,UAAU,KAAK,UAAU,EAAE,CAAC;QACjE,MAAM,IAAI,WAAW,CAAC,sCAAsC,aAAa,CAAC,UAAU,CAAC,GAAG,CAAC,CAAC;IAC5F,CAAC;IAED,MAAM,EAAE,QAAQ,EAAE,GAAG,OAAO,CAAC;IAC7B,IAAI,QAAQ,KAAK,SAAS,EAAE,CAAC;QAC3B;;;;;;;UAOE;QACF,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,QAAQ,CAAC,IAAI,QAAQ,CAAC,IAAI,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,OAAO,IAAI,KAAK,QAAQ,CAAC,EAAE,CAAC;YAClF,MAAM,IAAI,WAAW,CAAC,iDAAiD,aAAa,CAAC,QAAQ,CAAC,GAAG,CAAC,CAAC;QACrG,CAAC;QACD,IAAI,QAAQ,CAAC,KAAK,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,IAAI,CAAC,IAAI,EAAE,KAAK,EAAE,CAAC,EAAE,CAAC;YACjD,MAAM,IAAI,WAAW,CAAC,gDAAgD,CAAC,CAAC;QAC1E,CAAC;IACH,CAAC;AACH,CAAC;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,MAAM,UAAU,aAAa,CAAC,OAAgB;IAC5C,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,OAAO,CAAC,EAAE,CAAC;QAC5B,MAAM,IAAI,WAAW,CACnB,uDAAuD,aAAa,CAAC,OAAO,CAAC,GAAG,CACjF,CAAC;IACJ,CAAC;IACD;;;;;;;;;;;;;;;;;;;;;MAqBE;IACF,MAAM,MAAM,GAAG;QACb,CAAC,OAAO,EAAE,QAAQ,CAAC;QACnB,CAAC,OAAO,EAAE,QAAQ,CAAC;QACnB,CAAC,eAAe,EAAE,SAAS,CAAC;KACpB,CAAC;IACX,KAAK,MAAM,CAAC,EAAE,EAAE,MAAM,CAAC,IAAI,OAAO,CAAC,OAAO,EAAE,EAAE,CAAC;QAC7C,IAAI,OAAO,MAAM,KAAK,QAAQ,IAAI,MAAM,KAAK,IAAI,EAAE,CAAC;YAClD,MAAM,IAAI,WAAW,CACnB,WAAW,EAAE,yCAAyC,aAAa,CAAC,MAAM,CAAC,GAAG,CAC/E,CAAC;QACJ,CAAC;QACD,MAAM,MAAM,GAAG,MAAiC,CAAC;QACjD,MAAM,KAAK,GAAG,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,EAAE,IAAI,CAAC,EAAE,EAAE,CAAC,OAAO,MAAM,CAAC,IAAI,CAAC,KAAK,IAAI,CAAC,CAAC;QAC1E,IAAI,KAAK,KAAK,SAAS;YAAE,SAAS;QAClC,MAAM,CAAC,IAAI,EAAE,IAAI,CAAC,GAAG,KAAK,CAAC;QAC3B,MAAM,IAAI,WAAW,CACnB,WAAW,EAAE,KAAK,IAAI,cAAc,IAAI,SAAS,aAAa,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC,cAAc;YACxF,yBAAyB,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,GAAG,CAChE,CAAC;IACJ,CAAC;AACH,CAAC;AAED;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,MAAM,UAAU,eAAe,CAAC,KAI/B;IACC,aAAa,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC;IAC7B,iBAAiB,CAAC,KAAK,CAAC,CAAC;IACzB,sBAAsB,CAAC,KAAK,CAAC,OAA6C,CAAC,CAAC;AAC9E,CAAC;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,SAAS,sBAAsB,CAAC,OAA2C;IACzE,KAAK,MAAM,CAAC,EAAE,EAAE,MAAM,CAAC,IAAI,OAAO,CAAC,OAAO,EAAE,EAAE,CAAC;QAC7C,MAAM,IAAI,GAAG,MAAM,CAAC,cAAc,CAAC,CAAC;QACpC,IAAI,OAAO,IAAI,KAAK,QAAQ,IAAI,MAAM,CAAC,KAAK,CAAC,IAAI,CAAC,IAAI,IAAI,GAAG,CAAC,EAAE,CAAC;YAC/D,MAAM,IAAI,WAAW,CACnB,WAAW,EAAE,uDAAuD;gBAClE,GAAG,aAAa,CAAC,IAAI,CAAC,+DAA+D;gBACrF,qDAAqD,CACxD,CAAC;QACJ,CAAC;QACD,MAAM,OAAO,GAAG,MAAM,CAAC,kBAAkB,CAAC,CAAC;QAC3C,IAAI,CAAC,MAAM,CAAC,SAAS,CAAC,OAAO,CAAC,IAAK,OAAkB,GAAG,CAAC,EAAE,CAAC;YAC1D,MAAM,IAAI,WAAW,CACnB,WAAW,EAAE,4DAA4D;gBACvE,GAAG,aAAa,CAAC,OAAO,CAAC,6CAA6C,CACzE,CAAC;QACJ,CAAC;IACH,CAAC;AACH,CAAC;AAED;;;;;;;;;;;;;;;;;;GAkBG;AACH,MAAM,UAAU,iBAAiB,CAAC,KAIjC;IACC,MAAM,EAAE,cAAc,EAAE,WAAW,EAAE,YAAY,EAAE,GAAG,KAAK,IAAI,EAAE,CAAC;IAClE,IAAI,OAAO,cAAc,KAAK,QAAQ,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,cAAc,CAAC,IAAI,cAAc,IAAI,CAAC,EAAE,CAAC;QAClG,MAAM,IAAI,WAAW,CACnB,4DAA4D,aAAa,CAAC,cAAc,CAAC,GAAG,CAC7F,CAAC;IACJ,CAAC;IACD,IAAI,CAAC,MAAM,CAAC,SAAS,CAAC,WAAW,CAAC,IAAK,WAAsB,GAAG,CAAC,EAAE,CAAC;QAClE,MAAM,IAAI,WAAW,CACnB,2DAA2D,aAAa,CAAC,WAAW,CAAC,GAAG,CACzF,CAAC;IACJ,CAAC;IACD;;;;;;;;;;;;;;;;;;;MAmBE;IACF,IACE,YAAY,KAAK,IAAI;QACrB,YAAY,KAAK,SAAS;QAC1B,CAAC,KAAK,CAAC,OAAO,CAAC,YAAY,CAAC;QAC5B,CAAC,WAAW,CAAC,MAAM,CAAC,YAAY,CAAC,EACjC,CAAC;QACD,MAAM,IAAI,WAAW,CACnB,kEAAkE;YAChE,GAAG,aAAa,CAAC,YAAY,CAAC,sDAAsD;YACpF,+DAA+D,CAClE,CAAC;IACJ,CAAC;IACD;;;;;;;;;;;;;;;;;;MAkBE;IACF,IAAI,YAAY,KAAK,IAAI,IAAI,YAAY,KAAK,SAAS,EAAE,CAAC;QACxD,MAAM,MAAM,GAAG,YAAkC,CAAC;QAClD,KAAK,IAAI,EAAE,GAAG,CAAC,EAAE,EAAE,GAAG,MAAM,CAAC,MAAM,EAAE,EAAE,EAAE,EAAE,CAAC;YAC1C,MAAM,KAAK,GAAG,MAAM,CAAC,EAAE,CAAC,CAAC;YACzB,IAAI,KAAK,KAAK,IAAI,IAAI,KAAK,KAAK,SAAS,IAAI,MAAM,CAAC,QAAQ,CAAC,KAAK,CAAC;gBAAE,SAAS;YAC9E,MAAM,IAAI,WAAW,CACnB,gBAAgB,EAAE,kEAAkE;gBAClF,kBAAkB,aAAa,CAAC,KAAK,CAAC,4CAA4C;gBAClF,8EAA8E,CACjF,CAAC;QACJ,CAAC;IACH,CAAC;AACH,CAAC;AAED;;;;;;;;;;;;;GAaG;AACH,MAAM,UAAU,eAAe,CAAC,KAAc;IAC5C,IAAI,OAAO,KAAK,KAAK,QAAQ,EAAE,CAAC;QAC9B,MAAM,IAAI,WAAW,CAAC,4CAA4C,aAAa,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC;IAC7F,CAAC;AACH,CAAC;AAED;;;;;;;;;;;;;;GAcG;AACH,MAAM,UAAU,aAAa,CAAC,KAAc;IAC1C,OAAO,OAAO,KAAK,KAAK,QAAQ,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,SAAS,CAAC,KAAK,CAAC,IAAI,MAAM,CAAC,KAAK,CAAC,CAAC;AAC5F,CAAC","sourcesContent":["/**\n * Checking the options a caller passed, before anything is written.\n *\n * The command line has always validated these — `--decimals 1.5` is a usage error and always\n * has been — and the library did not, so the same value behaved differently depending on how\n * it arrived. `convert(file, { decimals: NaN })` resolved successfully having written whole\n * numbers into a column the caller had asked for decimals in, which is the worst of the\n * three: no error, no warning, and output that looks like a deliberate choice. `decimals: -1`\n * reached `toFixed` and came back as a bare RangeError from deep inside the formatter, naming\n * nothing the caller had written. `start: NaN` created the output directory, wrote\n * signals.csv, and then failed with a message about the input being unreadable — a partial\n * conversion, blamed on the file.\n *\n * These run at the top of `buildPlan`, which every path goes through before a directory is\n * created or a stream is opened, so a rejected option leaves nothing behind.\n */\n\n/** A problem with the options a caller passed, as opposed to a problem with the file. */\nexport class OptionError extends Error {\n constructor(message: string) {\n super(message);\n this.name = 'OptionError';\n }\n}\n\n/**\n * The largest `--decimals` accepts, and what both documentation pages state.\n *\n * Not a limit of `toFixed`, whatever this comment used to say. `toFixed` takes 0 to 100 and\n * throws a RangeError at 101 — which is exactly the belief `MAX_DERIVED_DECIMALS` in\n * edf/scale.ts exists to correct, having once clamped the *derived* precision to 20 on the\n * same wrong grounds and rounded a magnetometer channel needing 23 places onto a grid three\n * digital codes wide, losing 69% of its samples in silence.\n *\n * Twenty is a bound on a number a person types by hand, not on what the format can express.\n * The derived precision, which nobody types, runs to 100 and says so.\n */\nexport const MAX_DECIMALS = 20;\n\nexport function assertOptions(options: {\n decimals?: number | undefined;\n start?: number | undefined;\n duration?: number | undefined;\n end?: number | undefined;\n layout?: string | undefined;\n channels?: readonly string[] | undefined;\n outputDir?: string | undefined;\n annotationsOnly?: boolean | undefined;\n gzip?: boolean | undefined;\n bom?: boolean | undefined;\n force?: boolean | undefined;\n checksum?: boolean | undefined;\n toStdout?: boolean | undefined;\n onProgress?: unknown;\n startText?: unknown;\n durationText?: unknown;\n endText?: unknown;\n}): void {\n /*\n The three options that exist only to be quoted back, quoted back unexamined.\n\n `startText`, `durationText` and `endText` carry the value exactly as the caller's user\n typed it, so a refusal names that rather than its parsed form — \"--start \\\"4h\\\"\" rather\n than \"--start 14400s\". Nothing asked whether they were text, and they reach the sentence\n as they are:\n\n convert(file, { start: 99, startText: {} })\n TimeRangeError: --start \"[object Object]\" is at or past the end of this 3s recording.\n\n convert(file, { start: 1, end: 0.5, endText: [] })\n TimeRangeError: The requested window ends at \"\", which is not after its start at 1s.\n\n `[object Object]` is the string `assertInputPath`'s own docstring names as the reason that\n function exists, and the empty quotation is the hole `describeValue` was written to stop —\n both in the one place whose entire purpose is to show the reader what they typed.\n */\n for (const name of ['startText', 'durationText', 'endText'] as const) {\n const value = options[name];\n if (value !== undefined && typeof value !== 'string') {\n throw new OptionError(\n `${name} must be the value as it was typed, got ${describeValue(value)}. It is quoted ` +\n `back in the window errors so they name what was given rather than its parsed form.`,\n );\n }\n }\n const { decimals } = options;\n if (decimals !== undefined) {\n if (!Number.isInteger(decimals) || decimals < 0 || decimals > MAX_DECIMALS) {\n throw new OptionError(\n `decimals must be a whole number between 0 and ${MAX_DECIMALS}, got ${describeValue(decimals)}.`,\n );\n }\n }\n\n /*\n `start` and `end` are positions on the recording's own clock, `duration` is a length.\n\n All three were held above zero, which is right for a length and wrong for a position: a\n recording timed from its first record's timekeeping annotation may sit before zero, and\n -100 is then where its first sample is. So a caller could read `plan.range` back as\n `recordingStartSeconds: -100` and not be allowed to ask for it — the same wall\n `parseTimeSpec` put in front of the command line until 0.5.120.\n\n Non-finite is still refused for all three, since NaN reaches a comparison as false and\n would take the whole recording without saying so.\n */\n for (const name of ['start', 'duration', 'end'] as const) {\n const value = options[name];\n if (value === undefined) continue;\n if (typeof value !== 'number' || !Number.isFinite(value)) {\n throw new OptionError(`${name} must be a number of seconds, got ${describeValue(value)}.`);\n }\n /*\n A length below zero is refused for what it is, rather than for not being a number.\n\n `duration: -1` came back as \"duration must be a number of seconds, got -1\", which is\n not the reason and not true: -1 is a number of seconds, and this same call accepts it\n for `start` and for `end`, where a recording timed from before zero makes it an\n ordinary position. What is wrong is that a duration is a length, and no length is\n negative — which is what the command line says for the same value, and what the API\n reference has always said this check enforces.\n */\n if (name === 'duration' && value < 0) {\n throw new OptionError(`duration is a length of time, so it cannot be ${describeValue(value)}.`);\n }\n }\n\n /*\n Two words, and a caller who writes a third means something the tool cannot do.\n\n The command line has always rejected `--layout tall`. The library took it, put it in\n `plan.layout` for the caller to read back, and wrote the wide layout — so a programmatic\n caller with a typo got a conversion that was not the one they asked for, described by a\n plan that agreed with the typo. Every other option that has a shape is checked here; this\n one was added in 0.5.0 and never joined them.\n */\n const { layout } = options;\n if (layout !== undefined && layout !== 'wide' && layout !== 'long') {\n throw new OptionError(`layout must be \"wide\" or \"long\", got ${describeValue(layout)}.`);\n }\n\n /*\n A selection that names nothing is not the absence of a selection.\n\n `buildPlan` asks `options.channels.length > 0` before selecting, so an empty array fell\n through to the branch that means \"no channels option was given\" — and `convert(file, {\n channels: [] })` wrote every channel in the recording, resolved, and said nothing. That is\n the one shape of this the command line has always refused, in as many words: \"Returning\n undefined here would mean 'no --channels given' and convert everything, which is the\n opposite of what someone passing an empty list is asking for.\"\n\n A list of blanks is the same request written differently — it is what `''.split(',')`\n produces, which is how a caller building the array from user input arrives here — and it\n reached `selectChannels` and came back \"No channels were selected\", a sentence about the\n file rather than about the call. Both are the option being wrong, so both are refused\n here, before a directory exists.\n */\n /*\n An empty destination, refused here rather than by the filesystem.\n\n `convert(file, { outputDir: '' })` went the whole way to `mkdir('')` and came back a\n `ConversionError`: \"Cannot create \\\"\\\": part of the path does not exist. Check the path\n exists and that you have permission to write there.\" Advice about a path and a permission\n for a value that is neither, and a failure class that means the conversion went wrong\n where the option did.\n\n The command line refused this at 0.6.x, with the reasoning that `--out \"$DEST\"` and `DEST`\n unset is how it gets written by accident — and left the library, which a caller building\n the path in code reaches the same way. Not trimmed, for the reason given there: a\n directory whose name is a space is a strange thing to ask for, but it is a thing the\n filesystem has and a path is not a keyword.\n */\n /*\n And its shape, which was the one option with a value and no check on it.\n\n The empty string was refused and nothing else was, so `outputDir` failed two ways that the\n paragraph above describes for the flags. A value of the wrong type reached `path.join` and\n came back as a Node error about an argument this caller never passed:\n\n convert('rec.edf', { outputDir: 42 })\n TypeError: The \"path\" argument must be of type string. Received type number (42)\n\n And `null` — which is what `JSON.parse` of a config file gives for a field left unset, the\n same door `1` and `'true'` come through — was not an error at all. It is not `undefined`,\n so it never meant \"use the default\", but every read of it is `?? default` or a truthiness\n test, so that is what it did: the rows went to `<recording>_csv` beside the input, a\n directory the caller had not named, and the run reported success.\n */\n const { outputDir } = options;\n if (outputDir !== undefined && typeof outputDir !== 'string') {\n throw new OptionError(`outputDir must be a path, got ${describeValue(outputDir)}.`);\n }\n if (outputDir === '') {\n throw new OptionError('outputDir is empty. Give a directory, for example \"./converted\".');\n }\n\n /*\n The six flags, every one of which is read as `=== true` where it is read.\n\n Which means a value that is not a boolean is not merely tolerated: it is taken as the\n opposite of what it says. `convert(file, { annotationsOnly: 'true' })` wrote every signal\n the caller had asked to leave out; `{ gzip: 1 }` wrote plain CSVs under names ending\n `.csv`, so a caller who then opened `signals.csv.gz` found nothing there. No error, no\n warning, and output that looks like a deliberate choice — which is the sentence at the\n top of this file, describing the case it was written for.\n\n `1` and `'true'` are how a flag arrives from `JSON.parse` of a config file, from a query\n string, or from a CLI wrapper that did not coerce; none of them is a caller being\n careless in a way TypeScript would catch, since the callers this reaches are the ones not\n using it. The layout check above states the rule these were missing from: \"Every other\n option that has a shape is checked here.\"\n */\n for (const name of ['annotationsOnly', 'gzip', 'bom', 'force', 'checksum', 'toStdout'] as const) {\n const value = options[name];\n if (value !== undefined && typeof value !== 'boolean') {\n throw new OptionError(`${name} must be true or false, got ${describeValue(value)}.`);\n }\n }\n\n /*\n The one option that is called rather than read, and the only one that was not checked.\n\n `convert` invokes it as `options.onProgress?.(...)` once a record has been written, so a\n value that is not a function passes every check here, opens the destination, writes rows\n into it and then fails from inside the loop:\n\n convert('rec.edf', { outputDir: 'out', onProgress: 'every record' })\n ConversionError: The onProgress callback threw: options.onProgress is not a function\n\n A callback that threw is what that sentence reports, and no callback was given; the text\n after the colon names an expression inside this package. Worse is what it leaves: `out`\n exists with a half-written signals.csv in it, which is the case the paragraph at the top\n of this file describes — \"`start: NaN` created the output directory, wrote signals.csv,\n and then failed with a message about the input being unreadable — a partial conversion,\n blamed on the file\". Checked here, the same call writes nothing and says which argument\n is wrong.\n */\n const { onProgress } = options;\n if (onProgress !== undefined && typeof onProgress !== 'function') {\n throw new OptionError(`onProgress must be a function, got ${describeValue(onProgress)}.`);\n }\n\n const { channels } = options;\n if (channels !== undefined) {\n /*\n A list of strings, checked as one. `selectChannels` calls `.trim()` on every term, so a\n caller who passed the string `'ECG'` had it iterated character by character and was told\n `No channel named \"E\"`, and one who passed `[1]` — a position, reasonably enough — got\n `TypeError: rawTerm.trim is not a function` out of the middle of the selector, naming\n nothing they had written. Both are the option being the wrong shape, which is the case\n this function exists for.\n */\n if (!Array.isArray(channels) || channels.some((term) => typeof term !== 'string')) {\n throw new OptionError(`channels must be a list of channel names, got ${describeValue(channels)}.`);\n }\n if (channels.every((term) => term.trim() === '')) {\n throw new OptionError('channels was given but lists no channel names.');\n }\n }\n}\n\n/**\n * The channel list two exported functions take, checked the way their other argument is.\n *\n * `selectChannels(signals, terms)` has checked `terms` since 0.6.x — \"`'ECG'` was iterated\n * character by character and answered `No channel named \\\"E\\\"`… naming nothing the caller had\n * written\" — and never checked `signals`, which is the argument in front of it. Passing one\n * signal where the list goes, or a header where its `signals` goes, came back as\n * `TypeError: signals.filter is not a function`: a local of this package, over a value the\n * caller did write.\n *\n * `buildColumnNames` is worse off, because a string is iterable. `buildColumnNames('ECG')`\n * returned `Map { null => 'undefined_chundefined' }` and no error at all — a column name for a\n * channel that does not exist, keyed by a position that is not one.\n *\n * The bad entry is named by position rather than the whole list being printed back: a header\n * may declare hundreds of channels, and a message is not the place for all of them.\n */\nexport function assertSignals(signals: unknown): void {\n if (!Array.isArray(signals)) {\n throw new OptionError(\n `signals must be the channel list from a header, got ${describeValue(signals)}.`,\n );\n }\n /*\n The fields the callers read, not just the one that identifies a channel.\n\n This asked for `index` and stopped, and its callers go on to read `label` and\n `isAnnotations` off the same objects. So a list of channel-shaped objects without them\n passed the check that exists to say \"these are not channels from a header\" and failed one\n line further in, or did not fail at all:\n\n selectChannels([{ index: 0 }], ['ECG'])\n TypeError: Cannot read properties of undefined (reading 'toLowerCase')\n\n selectChannels([{ index: 0, label: 42 }], ['42'])\n TypeError: signal.label.toLowerCase is not a function\n\n buildColumnNames([{ index: 0 }])\n Map { 0 => null }\n\n The first two are the failure this function was written to remove, one level down — a\n local of this package named at a caller who wrote neither. The third is worse: a column\n name of `null`, where a channel with no label at all is named `signal_0`, so `null` is not\n a name this tool ever writes.\n */\n const FIELDS = [\n ['index', 'number'],\n ['label', 'string'],\n ['isAnnotations', 'boolean'],\n ] as const;\n for (const [at, signal] of signals.entries()) {\n if (typeof signal !== 'object' || signal === null) {\n throw new OptionError(\n `signals[${at}] is not a channel from a header, got ${describeValue(signal)}.`,\n );\n }\n const fields = signal as Record<string, unknown>;\n const wrong = FIELDS.find(([name, kind]) => typeof fields[name] !== kind);\n if (wrong === undefined) continue;\n const [name, kind] = wrong;\n throw new OptionError(\n `signals[${at}].${name} must be a ${kind}, got ${describeValue(fields[name])}. A channel ` +\n `from a header carries ${FIELDS.map(([f]) => f).join(', ')}.`,\n );\n }\n}\n\n/**\n * What `buildPlan` is told about the recording, checked the way what it is asked for is.\n *\n * `assertOptions` runs at the top of `buildPlan` and covers the second argument completely.\n * The first was not looked at, and it is the one carrying the numbers every figure in the plan\n * is derived from. Two of them missing produced a plan rather than an error:\n *\n * buildPlan({ signals, recordDuration: 1 }, {})\n * // groups: 3, estimate.rows: 0, range.endSeconds: null\n *\n * A plan saying the conversion writes nothing, handed back as an answer — which is the \"takes\n * the whole recording without saying so\" this checker exists to stop, one field over. A record\n * count below zero was worse: it came back as\n *\n * TimeRangeError: --start 0s is at or past the end of this -5s recording.\n *\n * a flag the caller never passed, about a recording that cannot exist, blaming the request for\n * the input. And `recordDuration: '1'` was coerced by the arithmetic and accepted, where the\n * same string is refused for `end` two functions down.\n *\n * A real header cannot produce any of them: the parser refuses a record duration that is not a\n * positive number, \"Infinity\" included.\n */\nexport function assertPlanInput(input: {\n signals?: unknown;\n recordDuration?: unknown;\n recordCount?: unknown;\n}): void {\n assertSignals(input.signals);\n assertRecordShape(input);\n assertPlannableSignals(input.signals as readonly Record<string, unknown>[]);\n}\n\n/**\n * The two numbers on a channel that `buildPlan` reads and `assertSignals` does not ask about.\n *\n * `assertSignals` asks for `index`, `label` and `isAnnotations`, which is what its other two\n * callers read — they name columns and match terms. `buildPlan` goes further: it groups the\n * channels by `samplingRate` and counts rows from `samplesPerRecord`, and asked nothing of\n * either. So a list of channel-shaped objects carrying the three fields it does check reached\n * the rate formatter and came back\n *\n * buildPlan({ signals: [{ index: 0, label: 'ECG', isAnnotations: false }], … }, {})\n * OptionError: hz must be a sampling rate in hertz, got undefined.\n *\n * naming `hz`, a parameter of a function three calls down, at a caller who passed `signals`.\n * That is the failure `assertSignals` exists to remove, and it is the same one its own\n * docstring quotes for `label`.\n *\n * The values it takes are the ones a header can really state, which is wider than it looks:\n * `samplesPerRecord` may be zero — that is what `NO_SAMPLES` reports — and a rate may be zero\n * or `Infinity`, because a record duration small enough to overflow the division is five\n * characters in an eight-character field. What a header cannot state is a fractional or\n * negative sample count, and those went through as arithmetic:\n *\n * samplesPerRecord: 2.5 // estimate.rows: 394.5, half a row\n * samplesPerRecord: -4 // estimate.rows falls, with nothing said\n *\n * Split out rather than folded into `assertSignals`, for the reason `assertRecordShape` gives\n * one function down: `selectChannels` and `buildColumnNames` never look at either field, and a\n * checker should not demand what its caller does not read.\n */\nfunction assertPlannableSignals(signals: readonly Record<string, unknown>[]): void {\n for (const [at, signal] of signals.entries()) {\n const rate = signal['samplingRate'];\n if (typeof rate !== 'number' || Number.isNaN(rate) || rate < 0) {\n throw new OptionError(\n `signals[${at}].samplingRate must be a sampling rate in hertz, got ` +\n `${describeValue(rate)}. It is samplesPerRecord over the record duration, and it is ` +\n `what the channels are grouped into output files by.`,\n );\n }\n const samples = signal['samplesPerRecord'];\n if (!Number.isInteger(samples) || (samples as number) < 0) {\n throw new OptionError(\n `signals[${at}].samplesPerRecord must be a whole number of samples, got ` +\n `${describeValue(samples)}. Every row this plan counts comes from it.`,\n );\n }\n }\n}\n\n/**\n * The two numbers a window is measured against, apart from the channel list.\n *\n * `resolveRange` is exported on its own and has its own signature block on the api page, and\n * `buildPlan` calls it — so it was covered only from above. Called directly it took both\n * numbers unexamined and answered with a range:\n *\n * resolveRange({ recordDuration: 1 }) // recordCount undefined\n * // { startSeconds: 0, endSeconds: null, startRecord: 0, endRecord: 0 }\n *\n * A range over no records, returned as a fact about a recording. Its own opening comment\n * already says why that is the wrong answer — \"no error, no warning, and a range read back as\n * `startSeconds: null, startRecord: null`, which is the 'takes the whole recording without\n * saying so'\" — about the three fields it does check. `resolveRange(42)` went the same way,\n * since reading `.start` off a number is `undefined` rather than a throw.\n *\n * Split out rather than calling `assertPlanInput`, which would demand a channel list this\n * function never looks at.\n */\nexport function assertRecordShape(input: {\n recordDuration?: unknown;\n recordCount?: unknown;\n recordStarts?: unknown;\n}): void {\n const { recordDuration, recordCount, recordStarts } = input ?? {};\n if (typeof recordDuration !== 'number' || !Number.isFinite(recordDuration) || recordDuration <= 0) {\n throw new OptionError(\n `recordDuration must be a positive number of seconds, got ${describeValue(recordDuration)}.`,\n );\n }\n if (!Number.isInteger(recordCount) || (recordCount as number) < 0) {\n throw new OptionError(\n `recordCount must be a whole number of data records, got ${describeValue(recordCount)}.`,\n );\n }\n /*\n And where the records really sit, which is what makes a discontinuous file's span longer\n than its duration. Nothing asked what it was, and the two ways of getting it wrong fail\n differently:\n\n resolveRange({ recordDuration: 1, recordCount: 3, recordStarts: 'x' })\n { startSeconds: 0, endSeconds: 3, startRecord: 0, endRecord: 0, isWholeRecording: true }\n\n A string is iterable, so it spreads to its characters and the span comes out over no\n records at all — a range that says it is the whole recording and covers none of it, which\n is a contradiction rather than an answer.\n\n resolveRange({ ..., recordStarts: 42 })\n TypeError: recordStarts is not iterable\n\n which names this function's own parameter at a caller holding the wrong thing. A list is\n what it takes: the `Float64Array` the reader builds, or an ordinary array — `readAnnotations`\n hands back `(number | null)[]`, and a null start is a record whose position is not known,\n which this already allows for.\n */\n if (\n recordStarts !== null &&\n recordStarts !== undefined &&\n !Array.isArray(recordStarts) &&\n !ArrayBuffer.isView(recordStarts)\n ) {\n throw new OptionError(\n `recordStarts must be a list of record start times, or null, got ` +\n `${describeValue(recordStarts)}. It is where the records really sit, which is what ` +\n `makes a discontinuous recording span more time than it holds.`,\n );\n }\n /*\n And what is in the list, which is where the failure above actually lands.\n\n That check asks whether the argument is a list and stops, and the contradiction its own\n paragraph describes for a string comes straight back from a list of them:\n\n resolveRange({ recordDuration: 1, recordCount: 3, recordStarts: ['a', 'b', 'c'] })\n { startSeconds: 0, endSeconds: 3, startRecord: 0, endRecord: 0, isWholeRecording: true }\n\n A range that calls itself the whole recording and covers none of it. `span` reads each\n start to find the earliest and the latest, and every comparison against a string is false,\n so it falls back to the contiguous span — which is why `endSeconds` looks right. Then\n `selectRecords` compares the same strings again and matches no record at all. `NaN` takes\n the identical route, and `NaN` is what a list built by parsing text arrives as.\n\n A record whose position is unknown is `null`, which `readAnnotations` really does hand\n back and which the code below already places from its neighbours. That is the one\n non-number this takes.\n */\n if (recordStarts !== null && recordStarts !== undefined) {\n const starts = recordStarts as ArrayLike<unknown>;\n for (let at = 0; at < starts.length; at++) {\n const start = starts[at];\n if (start === null || start === undefined || Number.isFinite(start)) continue;\n throw new OptionError(\n `recordStarts[${at}] must be the second that record starts at, or null where it is ` +\n `not known, got ${describeValue(start)}. A start that is not a number matches no ` +\n `record, so the window comes back empty and calls itself the whole recording.`,\n );\n }\n }\n}\n\n/**\n * The recording to read, checked before it is opened.\n *\n * `EdfFile.open` hands whatever it is given to `fs`, and the refusal comes back as an\n * `EdfError` coded `UNREADABLE`, hinted \"Check the path is spelled the way it is on disk and\n * that you have permission to read it\" — advice about a path, over a value that is not one,\n * filed as a problem with the recording rather than with the call. `convert({ input: 'a.edf' })`,\n * which is the option-bag shape the second parameter has, answered `Cannot read \"[object\n * Object]\"`; `convert(['a.edf', 'b.edf'])` answered `Cannot read \"a.edf,b.edf\"`, a path the\n * caller never wrote, because `String` of an array joins it with commas.\n *\n * The empty string is left to `fs`, which has no such file and says so truthfully — the same\n * reasoning `outputDir` states for not trimming: a path is not a keyword.\n */\nexport function assertInputPath(input: unknown): void {\n if (typeof input !== 'string') {\n throw new OptionError(`input must be a path to a recording, got ${describeValue(input)}.`);\n }\n}\n\n/**\n * How a rejected value reads in the refusal: numbers bare, everything else quoted so its\n * type is visible.\n *\n * `JSON.stringify` has no text for a function or a symbol — it returns `undefined`, not a\n * string — so `layout: () => 'long'` came back as `layout must be \"wide\" or \"long\", got\n * undefined.`, which names the one value that does not raise this: every option here is\n * optional, and `undefined` is how a caller says they are not passing it. `input` was worse,\n * since `convert(undefined)` and `convert(someFunction)` then produced the same sentence, and\n * the first is a forgotten argument while the second is a wrong one.\n *\n * Exported because time-range.ts had the identical function, fixed there and not here — the\n * same two-copies-of-one-helper the derived precision and the pluraliser were each pulled\n * together for.\n */\nexport function describeValue(value: unknown): string {\n return typeof value === 'number' ? String(value) : JSON.stringify(value) ?? String(value);\n}\n"]}
@@ -267,6 +267,28 @@ export class EdfFile {
267
267
  /** Read a half-open range of records in batches. */
268
268
  async *readRecords(options = {}) {
269
269
  this.#assertOpen();
270
+ /*
271
+ The bag the three options arrive in, which nothing looked at.
272
+
273
+ The default `= {}` covers `undefined` and nothing else, and every read below is
274
+ `options.startRecord` — so a value that is not an object had its properties read off it
275
+ and came back `undefined`, which is how a caller says they are not passing one:
276
+
277
+ file.readRecords(42) // every record, as though no options were given
278
+ file.readRecords('x') // "
279
+ file.readRecords(null) // TypeError: Cannot read properties of null
280
+
281
+ `null` is what `JSON.parse` of a config gives for a field left unset, which is the door
282
+ `assertOptions` names for the flags; the other two are a caller who thought this took a
283
+ record index. The first two are the worse pair, because reading the whole file is a
284
+ plausible answer and they got it in silence. `resolveRange` was given this same check on
285
+ its own bag in 0.8.75, for the same reason: reading `.start` off a number is `undefined`
286
+ rather than a throw.
287
+ */
288
+ if (typeof options !== 'object' || options === null) {
289
+ throw new OptionError(`readRecords: options must be an object, got ${describeValue(options)}. It carries ` +
290
+ 'startRecord, endRecord and chunkBytes; omit it to read every record.');
291
+ }
270
292
  /*
271
293
  Record bounds have to be whole records.
272
294
 
@@ -1 +1 @@
1
- {"version":3,"file":"reader.js","sourceRoot":"","sources":["../../src/edf/reader.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Chunked reader for EDF / EDF+ files.\n *\n * Data records are read in batches sized by a byte budget rather than all at once,\n * so peak memory stays flat regardless of how long the recording is. A 4 GB file\n * and a 4 MB file use the same working set.\n */\n\nimport { open, stat } from 'node:fs/promises';\nimport type { FileHandle } from 'node:fs/promises';\n\nimport { createHash } from 'node:crypto';\n\nimport { EdfError } from './errors.js';\nimport type { Diagnostic } from './errors.js';\nimport { FIXED_HEADER_BYTES, SIGNAL_HEADER_BYTES, parseHeader, peekSignalCount } from './header.js';\nimport type { EdfHeader, EdfSignal } from './header.js';\nimport { decodeRecordAnnotations } from './annotations.js';\nimport type { Annotation } from './annotations.js';\nimport { readInt16LE } from './bytes.js';\nimport { counted, grouped } from '../format/list.js';\n// Crossing into convert/ as header.ts already does for `typeable`: the check belongs to the\n// call rather than to the file, and there is one of it.\nimport { OptionError, assertInputPath, describeValue } from '../convert/options.js';\n// A path comes out of the filesystem and nobody vets it; see printable's own comment.\nimport { printable } from '../format/unprintable.js';\n\n/**\n * How far `readOrigin` looks for a record that states its own start time.\n *\n * Enough that one or two unreadable timekeeping entries at the top of a file cost nothing,\n * few enough that `--info` stays a header read rather than a scan.\n */\nconst RECORDS_SEARCHED_FOR_ORIGIN = 16;\n\n/** Default read budget per batch. Large enough to amortise syscalls, small enough to stay cheap. */\nexport const DEFAULT_CHUNK_BYTES = 8 * 1024 * 1024;\n\nexport interface RecordBatch {\n /** Index of the first record in this batch, relative to the whole file. */\n firstRecordIndex: number;\n recordCount: number;\n /**\n * Raw record bytes, `recordCount * header.recordBytes` long.\n *\n * The buffer is reused between iterations. Copy anything you need to keep past\n * the current loop turn.\n */\n data: Uint8Array;\n}\n\nexport interface ReadRecordsOptions {\n /** First record to read, inclusive. Defaults to 0. */\n startRecord?: number;\n /** Last record to read, exclusive. Defaults to the file's record count. */\n endRecord?: number;\n chunkBytes?: number;\n}\n\nexport class EdfFile {\n readonly path: string;\n readonly fileSize: number;\n /**\n * Last-modified time when this file was opened, in milliseconds, for the same reason as\n * `fileSize`.\n *\n * Kept as the raw number rather than a Date because `new Date(ms).getTime()` truncates to\n * whole milliseconds: comparing that against a later `fstat`, which carries the\n * filesystem's sub-millisecond precision, reported every undisturbed conversion as one\n * whose input had changed underneath it.\n */\n readonly modifiedAtOpenMs: number;\n readonly header: EdfHeader;\n /** Records actually present in the file, which may differ from the header's claim. */\n readonly recordCount: number;\n readonly trailingBytes: number;\n readonly diagnostics: Diagnostic[];\n\n #handle: FileHandle;\n #closed = false;\n /** The last answer `changedSinceOpen` computed, so it survives the file being closed. */\n #changed: boolean | null = null;\n\n private constructor(init: {\n path: string;\n fileSize: number;\n modifiedAtOpenMs: number;\n header: EdfHeader;\n recordCount: number;\n trailingBytes: number;\n diagnostics: Diagnostic[];\n handle: FileHandle;\n }) {\n this.path = init.path;\n this.fileSize = init.fileSize;\n this.modifiedAtOpenMs = init.modifiedAtOpenMs;\n this.header = init.header;\n this.recordCount = init.recordCount;\n this.trailingBytes = init.trailingBytes;\n this.diagnostics = init.diagnostics;\n this.#handle = init.handle;\n }\n\n /**\n * SHA-256 of the bytes this conversion actually read.\n *\n * Hashed through the open descriptor, over exactly the `fileSize` bytes that were there\n * when the file was opened — the same number every record count and window in the output\n * was derived from. Re-opening the path to hash it afterwards described whatever was at\n * that name by then: a recording still being written grew from 2,000 records to 3,000\n * mid-conversion and metadata.json recorded `data_records: 2000` beside the checksum and\n * byte count of the 3,000-record file, which is provenance for bytes nobody converted.\n * Replacing the file at that path did the same thing more completely.\n */\n async sha256(): Promise<string> {\n this.#assertOpen();\n const hash = createHash('sha256');\n const buffer = Buffer.alloc(Math.min(this.fileSize, 4 * 1024 * 1024) || 1);\n for (let at = 0; at < this.fileSize; ) {\n const want = Math.min(buffer.length, this.fileSize - at);\n const { bytesRead } = await this.#handle.read(buffer, 0, want, at);\n if (bytesRead <= 0) {\n throw new EdfError(\n 'UNREADABLE',\n // Both figures grouped, like the shortfall message this file raises one function\n // over — `Expected 8,386,560 bytes of data at record 0 but only 2,899,456 bytes\n // were available` — and for the reason 0.8.5 gives: the sentence exists to put one\n // against the other, and at nine digits that is work the separators do.\n `Expected ${grouped(this.fileSize)} bytes to checksum but the file ended at ` +\n `${grouped(at)}; it appears to have changed size while it was being read.`,\n 'Make sure the recording is not still being written to, then try again.',\n );\n }\n hash.update(buffer.subarray(0, bytesRead));\n at += bytesRead;\n }\n return hash.digest('hex');\n }\n\n /**\n * Whether the file has changed since it was opened, by size or by modification time.\n *\n * Checked through the descriptor, so it answers for the bytes that were read rather than\n * for whatever now answers to the same name. A recording still being written is the\n * ordinary cause, and the conversion is still correct for the data it saw — it is the\n * claim that the output describes the file as it now stands that stops being true.\n */\n async changedSinceOpen(): Promise<boolean> {\n /*\n A closed file remembers its last answer rather than inventing a new one.\n\n Returning false once closed asserted \"it did not change\", which is not something a\n closed descriptor can know — and `convert()` closes the file before it returns, so\n `result.file.changedSinceOpen()` denied the very change the INPUT_CHANGED diagnostic\n in the same result object had just reported. One object, two answers.\n\n `convert()` always asks before closing, so the cached answer is the true one. A caller\n who closed the file without ever asking gets an error, which is the same treatment\n every other method on a closed file gets.\n */\n if (this.#closed) {\n if (this.#changed !== null) return this.#changed;\n throw new EdfError(\n 'UNREADABLE',\n `\"${this.path}\" is closed, and whether it changed while it was open was never checked.`,\n 'Ask before closing the file. A ConvertResult carries the answer already, since ' +\n 'convert() checks it on the way out.',\n );\n }\n const now = await this.#handle.stat().catch(() => null);\n if (now === null) return this.#changed ?? false;\n this.#changed = now.size !== this.fileSize || now.mtimeMs !== this.modifiedAtOpenMs;\n return this.#changed;\n }\n\n /*\n A sentence, and advice under it, like the destination-side twin.\n\n `Cannot read \"rec.edf\": no such file` was the one diagnostic this tool prints that does not\n end in a full stop — 68 of its 69 do — and the only member of its family with nothing\n indented under it. `Cannot create \"out\": part of the path does not exist.` has carried\n advice under it since the destination errors were given sentences — one line for every\n cause until 0.8.12, and the cause's own since — the mid-conversion UNREADABLE beside it\n carries one too, and this is the form a mistyped path actually reaches.\n */\n static readonly #UNREADABLE_HINT =\n 'Check the path is spelled the way it is on disk and that you have permission to read it.';\n\n static async open(path: string): Promise<EdfFile> {\n /*\n A path, checked before `fs` is asked about it.\n\n `assertInputPath` was written for exactly this and applied one level up. Its docstring\n names the function it was describing — \"`EdfFile.open` hands whatever it is given to\n `fs`, and the refusal comes back as an `EdfError` coded `UNREADABLE`, hinted 'Check the\n path is spelled the way it is on disk and that you have permission to read it' — advice\n about a path, over a value that is not one, filed as a problem with the recording rather\n than with the call\" — and then went into `convert`, leaving `EdfFile.open` itself, which\n is exported from the package root and is how the api page says to read a header without\n converting anything, doing the thing being described:\n\n EdfFile.open({ path: 'a.edf' })\n EdfError[UNREADABLE]: Cannot read \"[object Object]\": The \"path\" argument must be of\n type string or an instance of Buffer or URL. Received an instance of Object.\n\n Node's own argument-type text, under a hint about spelling and permissions, over a\n value that has neither. `EdfFile.open(['a.edf', 'b.edf'])` was worse: it answered\n `Cannot read \"a.edf,b.edf\"`, quoting a path the caller never wrote, because `String` of\n an array joins it with commas.\n\n The same `OptionError` `convert` raises for the same mistake, so one mistake has one\n answer whichever entry point it arrives at.\n */\n assertInputPath(path);\n const info = await stat(path).catch((cause: unknown) => {\n throw new EdfError(\n 'UNREADABLE',\n `Cannot read \"${printable(path)}\": ${describe(cause)}.`,\n EdfFile.#UNREADABLE_HINT,\n );\n });\n /*\n Both of these said what was wrong and nothing about what to do, which is the gap\n `#UNREADABLE_HINT` two lines up was added to close for the third member of this family.\n\n Neither is reached from the command line, and that is the point: a folder there is\n expanded to the recordings inside it and a socket is filtered out by the walk, so the\n caller who arrives here is holding a path in code and has no walk behind them.\n */\n if (info.isDirectory()) {\n throw new EdfError(\n 'UNREADABLE',\n `\"${printable(path)}\" is a directory, not an EDF file.`,\n 'Name a recording inside it. The command line expands a folder to the recordings it ' +\n 'holds; this takes one file.',\n );\n }\n if (!info.isFile()) {\n throw new EdfError(\n 'UNREADABLE',\n `\"${printable(path)}\" is not a regular file.`,\n 'A pipe, socket or device cannot be read as a recording: the parser seeks to a byte ' +\n 'offset inside the file, which only a real file supports.',\n );\n }\n\n /*\n Opening is a second chance to be refused, and it was the one that got through.\n\n `stat` needs the parent directory searchable and says nothing about the file's own mode,\n so a recording with no read permission passes it and fails here — the commonest\n permission failure there is. Unwrapped, it escaped as Node's own error: the CLI printed\n `error: EACCES: permission denied, open '...'` where every neighbouring failure prints\n the tool's sentence, and the library threw a plain Error whose `code` was the errno.\n\n api.md says `UNREADABLE` \"covers a missing file, a directory passed where a file was\n expected, a permission failure, and a file that changed size while being read. Branch on\n `code`, never on the message text.\" A consumer doing exactly that fell through to its\n generic handler.\n */\n const handle = await open(path, 'r').catch((cause: unknown) => {\n throw new EdfError(\n 'UNREADABLE',\n `Cannot read \"${printable(path)}\": ${describe(cause)}.`,\n EdfFile.#UNREADABLE_HINT,\n );\n });\n try {\n const fixed = Buffer.alloc(Math.min(FIXED_HEADER_BYTES, info.size));\n if (fixed.length > 0) {\n const bytesRead = await readFully(handle, fixed, 0, fixed.length, 0);\n if (bytesRead < fixed.length) throw changedWhileReading(0, fixed.length, bytesRead);\n }\n\n // The signal count decides how much more header there is to read. Read by the header\n // parser itself, so the two cannot disagree about which files are readable: this used\n // to have its own Number(), which tolerated the NUL padding sloppy writers emit but\n // not the comma decimal separator that COMMA_DECIMAL exists to accept.\n let headerBuffer = fixed;\n if (fixed.length === FIXED_HEADER_BYTES) {\n const ns = peekSignalCount(fixed);\n if (ns !== null) {\n const total = FIXED_HEADER_BYTES + ns * SIGNAL_HEADER_BYTES;\n if (total <= info.size) {\n headerBuffer = Buffer.alloc(total);\n const bytesRead = await readFully(handle, headerBuffer, 0, total, 0);\n if (bytesRead < total) throw changedWhileReading(0, total, bytesRead);\n }\n }\n }\n\n const { header, recordCount, trailingBytes, diagnostics } = parseHeader(\n headerBuffer,\n info.size,\n );\n\n return new EdfFile({\n path,\n fileSize: info.size,\n modifiedAtOpenMs: info.mtimeMs,\n header,\n recordCount,\n trailingBytes,\n diagnostics,\n handle,\n });\n } catch (error) {\n await handle.close().catch(() => {});\n throw error;\n }\n }\n\n /** Signal channels, excluding the EDF+ annotations channel. */\n get dataSignals(): EdfSignal[] {\n return this.header.signals.filter((s) => !s.isAnnotations);\n }\n\n /**\n * The annotation channel a record's start time is read from.\n *\n * EDF+ puts the timekeeping TAL first in the first annotation channel, and this was read as\n * `annotationSignals[0]` — the first one declared, whether or not it can hold anything. A\n * writer that declares an annotation channel and gives it zero samples per record leaves a\n * slot of zero bytes, so nothing was read from it, and the timekeeping in the channel after\n * it went unread: a three-record EDF+D reported \"3 of 3 data records carry no readable\n * timekeeping annotation\" about three that were perfectly readable, and timed the file from\n * zero.\n *\n * A channel with no room carries nothing, so it is not the one the TAL is in.\n */\n get timekeepingSignal(): EdfSignal | undefined {\n return this.annotationSignals.find((signal) => signal.samplesPerRecord > 0);\n }\n\n get annotationSignals(): EdfSignal[] {\n return this.header.signals.filter((s) => s.isAnnotations);\n }\n\n /** Total recording duration in seconds, based on records actually present. */\n get durationSeconds(): number {\n return this.recordCount * this.header.recordDuration;\n }\n\n /** Read a half-open range of records in batches. */\n async *readRecords(options: ReadRecordsOptions = {}): AsyncGenerator<RecordBatch> {\n this.#assertOpen();\n\n /*\n Record bounds have to be whole records.\n\n A fractional `startRecord` was carried straight into `position = headerBytes +\n record * recordBytes`, so reading from 1.5 began half a record in and every sample\n after it was decoded from the wrong offset: on the two-channel test fixture it\n returned channel 2's values under channel 1's signal, with no error. Clamping\n silently would be no better, since a caller asking for record 1.5 has a bug the\n library should name rather than paper over.\n */\n for (const [name, value] of [\n ['startRecord', options.startRecord],\n ['endRecord', options.endRecord],\n ] as const) {\n if (value !== undefined && !Number.isInteger(value)) {\n /*\n An `OptionError`, because it is the call that is wrong and not the recording.\n\n This raised an `EdfError` coded `BAD_HEADER_FIELD` — a code the reference defines\n as \"a field that should contain a number doesn't\", about the file's header — for a\n number the *caller* passed. A script branching on that code to report a corrupt\n recording blamed the recording for its own bug, and `chunkBytes` below did the same\n under `UNREADABLE`, which means the file could not be read.\n\n The same class `EdfFile.open` raises for a path that is not one and `parseHeader`\n for a byte count that is not one, both settled in this same series, and for the\n reason `assertInputPath` gives.\n */\n throw new OptionError(\n /*\n Through `describeValue`, like every other refusal that quotes a rejected value.\n\n Its rule is \"numbers bare, everything else quoted so its type is visible\", and\n these two were the sites that never used it — so a refusal *for not being a\n number* showed the value as one: `readRecords({ startRecord: '1' })` came back\n `startRecord must be a whole record index, got 1.`, where 1 is a whole record\n index and the caller is left looking for what else could be wrong. An array came\n back `got .`, a hole where the value should be, and an object came back\n `got [object Object]` — the string `assertInputPath`'s own docstring names as the\n reason it exists.\n */\n `readRecords: ${name} must be a whole record index, got ${describeValue(value)}. ` +\n 'Record boundaries are the unit the file can be read in; a fractional index ' +\n 'would decode samples from the middle of a record.',\n );\n }\n }\n\n const start = Math.max(0, options.startRecord ?? 0);\n const end = Math.min(this.recordCount, options.endRecord ?? this.recordCount);\n if (start >= end) return;\n\n const { recordBytes } = this.header;\n /*\n Checked rather than handed to Buffer.alloc.\n\n `chunkBytes: NaN` came back as `RangeError: The value of \"size\" is out of range` from\n inside Node, with no mention of the option that caused it — while a fractional\n `startRecord` two lines up gets a typed EdfError naming the field. Every other option\n here is checked; this one reached the allocator.\n */\n const budget = options.chunkBytes ?? DEFAULT_CHUNK_BYTES;\n if (!Number.isFinite(budget) || budget < 1) {\n // `OptionError` for the same reason as the record bounds above; see there.\n throw new OptionError(\n `chunkBytes must be a positive number of bytes, got ${describeValue(options.chunkBytes)}. ` +\n 'It is a ceiling on how much of the file is held at once; one record is read ' +\n 'whatever it says.',\n );\n }\n /*\n The budget is a ceiling, not an amount to reserve.\n\n `Math.floor(budget / recordBytes)` is how many records would fit in it, and the buffer\n was that many — whether or not the file had that many. A 848-byte fixture read with a\n 512 MB budget allocated 536,870,880 bytes for its two records, and every ordinary read\n of a small file reserved the full 8 MB default. Nothing was wrong with the data; the\n memory just had nothing to do with it.\n\n Bounded by what is actually going to be read, so a batch of five hundred short\n recordings costs five hundred short buffers rather than five hundred 8 MB ones.\n */\n const perChunk = Math.max(1, Math.min(Math.floor(budget / recordBytes), end - start));\n const buffer = Buffer.alloc(perChunk * recordBytes);\n\n for (let record = start; record < end; record += perChunk) {\n const count = Math.min(perChunk, end - record);\n const bytes = count * recordBytes;\n const position = this.header.headerBytes + record * recordBytes;\n\n const bytesRead = await readFully(this.#handle, buffer, 0, bytes, position);\n if (bytesRead < bytes) {\n // The file is shorter than its own size said. Quietly stopping here would\n // hand back a conversion missing its tail with nothing to show for it.\n //\n // Through the shared builder rather than a second copy of its sentence: the two were\n // character-for-character identical, which is how a wording fixed in one of them\n // would have been fixed in only one of them.\n throw changedWhileReading(record, bytes, bytesRead);\n }\n\n yield { firstRecordIndex: record, recordCount: count, data: buffer.subarray(0, bytes) };\n }\n }\n\n /**\n * The channel, confirmed to be one of this recording's.\n *\n * The three methods that take an `EdfSignal` turn its `byteOffsetInRecord` and\n * `samplesPerRecord` into a position in a batch of this file's bytes. Nothing said the\n * channel had to come from this file, and a channel from another one reads as though it\n * did: handing `sampleAt` a `.bdf` channel — three bytes a sample, its own offset — while\n * reading a `.edf` batch returned the first EDF channel's samples, `0 74 147 219 290`,\n * every one of them a real number from the recording and none of them the caller's.\n *\n * Two open files is how it arrives. It is also what a plain object gets: `{}` and `42`\n * both have an undefined offset, which the arithmetic below turns into `NaN` and then\n * into a sample of 0.\n *\n * By identity at its own index, not by scanning the list: `sampleAt` is called once per\n * sample, and the caller already holds these objects — `header.signals[i]`, or the subsets\n * `annotationSignals` and `selectChannels` filter out of it, which are the same references.\n */\n #assertSignalHere(signal: EdfSignal, method: string): void {\n if (this.header.signals[(signal as { index?: number } | null)?.index as number] === signal) {\n return;\n }\n /*\n A channel-shaped argument is placed rather than dumped.\n\n `describeValue` renders an object as its JSON, and a channel is fourteen fields — a\n 458-character refusal, most of it the caller's own data handed back. Its index is the\n part that locates the mistake, and it is the field this check just read. Anything that\n is not object-shaped is quoted the ordinary way, since there it is the value itself\n that is wrong.\n */\n const elsewhere =\n `A channel read out of a different file names a position in that file's records, ` +\n `not this one's.`;\n throw new OptionError(\n signal !== null && typeof signal === 'object'\n ? `${method}: signal is a channel object, but not one of this recording's — ` +\n `header.signals at index ${describeValue((signal as { index?: unknown }).index)} ` +\n `is a different channel. ${elsewhere}`\n : `${method}: signal must be one of this recording's own channels, from ` +\n `header.signals — got ${describeValue(signal)}. ${elsewhere}`,\n );\n }\n\n /**\n * The record, confirmed to be one this batch holds.\n *\n * 0.8.62 put this on `sampleAt`, where it turns a position into a sample. `offsetOf` does\n * the same arithmetic and hands the position back, and `annotationBytes` slices at it, and\n * neither asked anything of it:\n *\n * file.offsetOf(batch, -5, signal) // -1300\n * file.offsetOf(batch, 1.5, signal) // 390, half a record in\n * file.annotationBytes(batch, 99, s) // Uint8Array(0)\n *\n * A negative byte position, a position that decodes the second half of one record against\n * the first half of the next — the failure `readRecords` refuses a fractional `startRecord`\n * for — and an empty slice that reads as \"this record carries no annotations\" for a record\n * that is not in the batch at all.\n *\n * The batch is checked first, because the bound is read off it: `offsetOf` never touched\n * `batch` before this, so a caller who passed the wrong thing got no complaint from it. Its\n * bytes are checked with it, for the reason given where that check sits.\n */\n #assertRecordOffset(batch: RecordBatch, recordOffset: number, method: string): void {\n if (!Number.isInteger((batch as RecordBatch | null)?.recordCount)) {\n throw new OptionError(\n `${method}: batch must be one of the batches readRecords yields, got ` +\n `${describeValue(batch)}.`,\n );\n }\n /*\n And the bytes, which are what the position is a position into.\n\n The count above was the whole of what this asked, and all three methods go on to read\n `batch.data`: `sampleAt` indexes it, `annotationBytes` slices it, and `offsetOf` hands\n back a position for it. A batch-shaped object without any failed differently depending\n on which one was called, and two of those failures were answers:\n\n file.sampleAt({ recordCount: 2, data: [1, 2, 3, 4] }, 0, signal, 0) // 513\n file.sampleAt({ recordCount: 2, data: new Float64Array(64) }, ...) // 0\n file.annotationBytes({ recordCount: 2, data: new Float64Array(8) }, …) // 20 values\n\n 513 is a digital code this recording could have held; 0 is the commonest sample in any\n recording; and the third is a run of numbers that are not the bytes of anything,\n returned as the annotation channel's own. `ArrayBuffer.isView` is true of all of them\n and of a `DataView`, which is why the check is the one 0.8.84 and 0.8.85 settled on for\n the two other places this parser is handed bytes: a view whose elements are one byte.\n */\n const bytes = (batch as { data?: unknown }).data;\n if (!ArrayBuffer.isView(bytes) || (bytes as { BYTES_PER_ELEMENT?: number }).BYTES_PER_ELEMENT !== 1) {\n const carries = 'A batch carries firstRecordIndex, recordCount, and the record bytes themselves.';\n // Named rather than quoted back: a batch is megabytes, and the kind of thing it is is\n // the part that locates the mistake — the same reasoning `#assertSignalHere` gives for\n // placing a channel by its index instead of printing its fourteen fields. The article\n // is worked out because `Array` and `Int16Array` both arrive here and \"a Array\" is not\n // a sentence.\n const kind =\n typeof bytes === 'object' && bytes !== null\n ? ((bytes as object).constructor?.name ?? 'object')\n : null;\n throw new OptionError(\n kind !== null\n ? `${method}: batch.data is ${/^[AEIOU]/u.test(kind) ? 'an' : 'a'} ${kind}, which is ` +\n `not a view of bytes. ${carries}`\n : `${method}: batch.data must be the record bytes, got ${describeValue(bytes)}. ${carries}`,\n );\n }\n if (!Number.isInteger(recordOffset) || recordOffset < 0 || recordOffset >= batch.recordCount) {\n throw new OptionError(\n `${method}: recordOffset must be a record's position within this batch, 0 to ` +\n `${batch.recordCount - 1}, got ${describeValue(recordOffset)}. Absolute record ` +\n `indexes are batch.firstRecordIndex higher.`,\n );\n }\n }\n\n /** Read one sample as its raw digital value. */\n sampleAt(batch: RecordBatch, recordOffset: number, signal: EdfSignal, sampleIndex: number): number {\n this.#assertSignalHere(signal, 'sampleAt');\n /*\n In range, because out of it this invented a number.\n\n The arithmetic below turns four values into a byte position and reads there. Nothing\n stopped that position from landing outside the sample it names. Past the end of the\n buffer, `bytes[position]` is `undefined`, which `| 0` and `<< 8` both turn into 0 — so a\n read past the batch came back as a plausible sample of zero. Inside the buffer but past\n the channel's own samples, it came back as the *next channel's* data: a 256-sample\n channel asked for sample 261 returned 243, which is a real number from the recording\n and belongs to another column.\n\n Both are reachable from the mistake the api page warns about in the sentence that\n describes this method — \"`recordOffset` is the record's position within the batch, from\n 0 to `batch.recordCount - 1`, not its index in the file\". A caller who passes the\n absolute index reads past the batch and gets zeros for every sample of it.\n\n Two integer comparisons each, on a call that then formats a number.\n */\n this.#assertRecordOffset(batch, recordOffset, 'sampleAt');\n if (\n !Number.isInteger(sampleIndex) ||\n sampleIndex < 0 ||\n sampleIndex >= signal.samplesPerRecord\n ) {\n throw new OptionError(\n `sampleIndex must be 0 to ${signal.samplesPerRecord - 1} for this channel, got ` +\n `${describeValue(sampleIndex)}.`,\n );\n }\n const position =\n recordOffset * this.header.recordBytes +\n signal.byteOffsetInRecord +\n sampleIndex * this.header.bytesPerSample;\n\n if (this.header.bytesPerSample === 3) {\n // BDF stores 24-bit little-endian two's complement. Loading the three bytes\n // into the top of a 32-bit word and shifting back down sign-extends them.\n const data = batch.data;\n return (\n ((data[position] as number) << 8) |\n ((data[position + 1] as number) << 16) |\n ((data[position + 2] as number) << 24)\n ) >> 8;\n }\n return readInt16LE(batch.data, position);\n }\n\n /** Byte offset of a signal's samples within a batch. */\n offsetOf(batch: RecordBatch, recordOffset: number, signal: EdfSignal): number {\n this.#assertSignalHere(signal, 'offsetOf');\n this.#assertRecordOffset(batch, recordOffset, 'offsetOf');\n return recordOffset * this.header.recordBytes + signal.byteOffsetInRecord;\n }\n\n /** The annotation channel's raw bytes for one record in a batch. */\n annotationBytes(batch: RecordBatch, recordOffset: number, signal: EdfSignal): Uint8Array {\n // Before delegating, so the refusal names the method the caller called rather than the\n // one underneath it.\n this.#assertSignalHere(signal, 'annotationBytes');\n this.#assertRecordOffset(batch, recordOffset, 'annotationBytes');\n const start = this.offsetOf(batch, recordOffset, signal);\n return batch.data.subarray(start, start + signal.samplesPerRecord * this.header.bytesPerSample);\n }\n\n /**\n * Where this continuous recording begins, from the first record that says.\n *\n * A few records' worth of annotation bytes rather than the whole channel. A continuous\n * recording's origin is the fraction of a second by which its first record follows the\n * header's start time, and `--info` needs that to place a requested window — but it does\n * not need the events, and finding one number by reading every record costs a seek per\n * record across the whole file, which is the scan `--info` was deliberately spared.\n *\n * It reads on past record 0 because a conversion does. This used to stop there, so the\n * moment one timekeeping TAL was unreadable the two disagreed: the conversion took the\n * origin from record 1 and timed the file from 0.5s, while `--info` found nothing at\n * record 0 and reported a recording starting at zero — the same file described two ways by\n * one tool. Records are contiguous, so record `i` beginning at `t` puts the origin at\n * `t - i * duration`, and any one of them settles it.\n *\n * The bound is what keeps this cheap: a file whose first `RECORDS_SEARCHED_FOR_ORIGIN`\n * timekeeping entries are all unreadable reports an origin of zero here, and converting it\n * raises ANNOTATION_DECODE_FAILED for every one of them.\n *\n * That mitigation covers records that could not be read, and not records that said nothing:\n * an empty annotation slot is not a TAL that failed, so nothing is counted and nothing is\n * raised. Twenty records whose only timekeeping entry is in record 16 therefore convert with\n * `time_s` from the origin it states and are reported here as beginning at zero, in silence\n * on both sides — and `--start` and `--end` are read against that same clock. The bound\n * stays, since it is what makes `--info` a header read on a file of any size; what was\n * wrong was the account of what it costs, which every page giving it said was a warning.\n *\n * Returns null when there is nothing to read it from, in which case the origin is zero.\n */\n async readOrigin(): Promise<number | null> {\n return (await this.scanOrigin()).origin;\n }\n\n /**\n * The origin, and what the search saw on the way to it.\n *\n * `--info` takes this route for a continuous recording rather than reading every record,\n * and reported nothing when the timekeeping it read was unreadable: the count was hard-coded\n * to zero at the call site, so a file whose first TAL cannot be parsed raised\n * ANNOTATION_DECODE_FAILED when converted and nothing under `--info`. Its byte-identical\n * EDF+D twin — same bytes but for the reserved field, which has nothing to do with the\n * defect — raised it both ways, because that path reads every record and counts as it goes.\n *\n * The failure was being read and then thrown away. `readOrigin` keeps its shape for callers\n * who only want the number.\n *\n * All three counters, not one. A first-position TAL may carry events after the start time,\n * and when it cannot be parsed those go with it — which is what `malformedTimekeepingWithText`\n * counts and what decides whether the warning says \"No event was lost\" or names the events\n * that were. Counting only the first meant `--info` took the first sentence every time: it\n * announced that a record had lost its position and that nothing else had gone, over a file\n * whose conversion said, correctly, that an event had gone with it. One file, two answers,\n * and the confident one was `--info`, which is the command run first to find out what a\n * conversion will say.\n *\n * `malformed` comes back for the same reason one sentence further on: that hint ends \"and is\n * counted above\", which is only true where the entry warning is printed too.\n *\n * All three are of the records this actually read, which is as far as the first record that\n * states a time — so they are lower bounds on the file, as `malformedTimekeeping` has been\n * since it was returned at all. A conversion reads every record and may count more. What\n * they must not be is inconsistent with each other, which is what a hard-coded zero made\n * them.\n */\n async scanOrigin(): Promise<{\n origin: number | null;\n malformed: number;\n malformedTimekeeping: number;\n malformedTimekeepingWithText: number;\n }> {\n this.#assertOpen();\n\n const counts = { malformed: 0, malformedTimekeeping: 0, malformedTimekeepingWithText: 0 };\n const channel = this.timekeepingSignal;\n if (!channel || this.recordCount === 0) return { origin: null, ...counts };\n\n const { headerBytes, bytesPerSample, recordBytes, recordDuration } = this.header;\n const buffer = Buffer.alloc(channel.samplesPerRecord * bytesPerSample);\n if (buffer.length === 0) return { origin: null, ...counts };\n\n const searched = Math.min(this.recordCount, RECORDS_SEARCHED_FOR_ORIGIN);\n for (let record = 0; record < searched; record++) {\n const offset = headerBytes + record * recordBytes + channel.byteOffsetInRecord;\n const bytesRead = await readFully(this.#handle, buffer, 0, buffer.length, offset);\n if (bytesRead < buffer.length) return { origin: null, ...counts };\n\n const decoded = decodeRecordAnnotations(buffer, record);\n counts.malformed += decoded.malformed;\n counts.malformedTimekeeping += decoded.malformedTimekeeping;\n counts.malformedTimekeepingWithText += decoded.malformedTimekeepingWithText;\n if (decoded.recordStart !== null) {\n return { origin: decoded.recordStart - record * recordDuration, ...counts };\n }\n }\n return { origin: null, ...counts };\n }\n\n /**\n * Read every EDF+ annotation in the file, plus the start time each record declares.\n *\n * Only the annotation channel is read, seeking straight to it inside each record\n * rather than pulling whole records through memory. On a multi-gigabyte recording\n * that is the difference between a few kilobytes of I/O and all of it.\n *\n * The whole file is always scanned, never just the records inside a requested\n * window: writers are not obliged to store an annotation in the record its onset\n * falls in, and some put every annotation in the first record. Reading only the\n * window's records would drop those entirely.\n */\n async readAnnotations(): Promise<{\n annotations: Annotation[];\n recordStarts: (number | null)[];\n malformed: number;\n /** Unreadable TALs in first position, which carry timing rather than an event. */\n malformedTimekeeping: number;\n /** How many of those also carried event text, so events were lost with the position. */\n malformedTimekeepingWithText: number;\n /** Events kept whose stated duration could not be read; see Annotation.duration. */\n unreadableDurations: number;\n /** Events kept whose stated duration read as a number below zero. */\n negativeDurations: number;\n }> {\n this.#assertOpen();\n\n const annotations: Annotation[] = [];\n const recordStarts: (number | null)[] = new Array<number | null>(this.recordCount).fill(null);\n let malformed = 0;\n let malformedTimekeeping = 0;\n let malformedTimekeepingWithText = 0;\n let unreadableDurations = 0;\n let negativeDurations = 0;\n\n const channels = this.annotationSignals;\n if (channels.length === 0) {\n return {\n annotations,\n recordStarts,\n malformed,\n malformedTimekeeping,\n malformedTimekeepingWithText,\n unreadableDurations,\n negativeDurations,\n };\n }\n\n const { headerBytes, recordBytes, bytesPerSample } = this.header;\n const buffers = channels.map((c) => Buffer.alloc(c.samplesPerRecord * bytesPerSample));\n const timekeeping = this.timekeepingSignal;\n\n for (let record = 0; record < this.recordCount; record++) {\n for (const [position, channel] of channels.entries()) {\n const buffer = buffers[position];\n if (!buffer || buffer.length === 0) continue;\n\n const offset = headerBytes + record * recordBytes + channel.byteOffsetInRecord;\n const bytesRead = await readFully(this.#handle, buffer, 0, buffer.length, offset);\n if (bytesRead < buffer.length) {\n throw changedWhileReading(record, buffer.length, bytesRead, 'annotation data');\n }\n\n // Only the timekeeping channel carries the record's start; see timekeepingSignal.\n const decoded = decodeRecordAnnotations(buffer, record, channel === timekeeping);\n if (channel === timekeeping) recordStarts[record] = decoded.recordStart;\n for (const annotation of decoded.annotations) annotations.push(annotation);\n malformed += decoded.malformed;\n malformedTimekeeping += decoded.malformedTimekeeping;\n malformedTimekeepingWithText += decoded.malformedTimekeepingWithText;\n unreadableDurations += decoded.unreadableDurations;\n negativeDurations += decoded.negativeDurations;\n }\n }\n\n annotations.sort((a, b) => a.onset - b.onset || a.recordIndex - b.recordIndex);\n return {\n annotations,\n recordStarts,\n malformed,\n malformedTimekeeping,\n malformedTimekeepingWithText,\n unreadableDurations,\n negativeDurations,\n };\n }\n\n async close(): Promise<void> {\n if (this.#closed) return;\n this.#closed = true;\n await this.#handle.close();\n }\n\n #assertOpen(): void {\n if (this.#closed) {\n throw new EdfError(\n 'UNREADABLE',\n 'This EDF file has already been closed.',\n // The same advice `changedSinceOpen` gives for the same mistake, which is the only\n // other method that has anything to say about a closed file.\n 'Open it again, or keep it open until the last read.',\n );\n }\n }\n}\n\nfunction describe(cause: unknown): string {\n if (cause instanceof Error) {\n const code = (cause as NodeJS.ErrnoException).code;\n if (code === 'ENOENT') return 'no such file';\n // EPERM beside EACCES, because everywhere else in this codebase that reads an errno pairs\n // the two, and ENOTDIR because a path that runs through a regular file — `rec.edf/inner`,\n // which a shell completes and a script builds by joining — is otherwise the one input\n // failure that answers in errno text while its output-side twin answers in a sentence.\n if (code === 'EACCES' || code === 'EPERM') return 'permission denied';\n if (code === 'ENOTDIR') return 'part of the path is a file, not a directory';\n return cause.message;\n }\n return String(cause);\n}\n\n/**\n * The most `fs.read` will accept as a length.\n *\n * Node asserts on a length that does not fit in a signed 32-bit integer, and it asserts in\n * C++: `Assertion failed: args[3]->IsInt32()`, forty frames of native stack, SIGABRT. Not an\n * exception — nothing in JavaScript sees it, so no catch block and no `uncaughtException`\n * handler runs, and a library consumer's whole process goes down with it.\n *\n * A round gigabyte rather than the exact limit, so the loop below does whole even reads.\n */\nconst MAX_READ_BYTES = 1024 * 1024 * 1024;\n\n/** Fill a requested region unless EOF is reached; regular-file reads may legally be short. */\nasync function readFully(\n handle: FileHandle,\n buffer: Buffer,\n offset: number,\n length: number,\n position: number,\n): Promise<number> {\n let total = 0;\n while (total < length) {\n /*\n Capped, because one data record can be larger than a single read may be.\n\n A record is read in one call when it exceeds the chunk budget — there is nothing\n smaller to divide it by, since a record is the unit the format is addressed in. EDF's\n samples-per-record field is 8 characters, so eleven channels at 99,999,999 samples make\n a record of 2.2 GB, and a long record duration at ordinary rates gets there too. That\n went to `fs.read` as a single length over 2^31-1 and took the process out with a native\n assertion rather than an error.\n\n Looping was already how a short read is handled, so the cap costs one more iteration\n per gigabyte and nothing else.\n */\n const want = Math.min(length - total, MAX_READ_BYTES);\n const { bytesRead } = await handle.read(buffer, offset + total, want, position + total);\n if (bytesRead === 0) break;\n total += bytesRead;\n }\n return total;\n}\n\nfunction changedWhileReading(\n record: number,\n expected: number,\n actual: number,\n subject = 'data',\n): EdfError {\n return new EdfError(\n 'UNREADABLE',\n `Expected ${grouped(expected)} bytes of ${subject} at record ${record} but only ` +\n `${counted(actual, 'byte')} ${actual === 1 ? 'was' : 'were'} available; the file appears ` +\n `to have changed size while it was being read.`,\n 'Make sure the recording is not still being written to, then try again.',\n );\n}\n"]}
1
+ {"version":3,"file":"reader.js","sourceRoot":"","sources":["../../src/edf/reader.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Chunked reader for EDF / EDF+ files.\n *\n * Data records are read in batches sized by a byte budget rather than all at once,\n * so peak memory stays flat regardless of how long the recording is. A 4 GB file\n * and a 4 MB file use the same working set.\n */\n\nimport { open, stat } from 'node:fs/promises';\nimport type { FileHandle } from 'node:fs/promises';\n\nimport { createHash } from 'node:crypto';\n\nimport { EdfError } from './errors.js';\nimport type { Diagnostic } from './errors.js';\nimport { FIXED_HEADER_BYTES, SIGNAL_HEADER_BYTES, parseHeader, peekSignalCount } from './header.js';\nimport type { EdfHeader, EdfSignal } from './header.js';\nimport { decodeRecordAnnotations } from './annotations.js';\nimport type { Annotation } from './annotations.js';\nimport { readInt16LE } from './bytes.js';\nimport { counted, grouped } from '../format/list.js';\n// Crossing into convert/ as header.ts already does for `typeable`: the check belongs to the\n// call rather than to the file, and there is one of it.\nimport { OptionError, assertInputPath, describeValue } from '../convert/options.js';\n// A path comes out of the filesystem and nobody vets it; see printable's own comment.\nimport { printable } from '../format/unprintable.js';\n\n/**\n * How far `readOrigin` looks for a record that states its own start time.\n *\n * Enough that one or two unreadable timekeeping entries at the top of a file cost nothing,\n * few enough that `--info` stays a header read rather than a scan.\n */\nconst RECORDS_SEARCHED_FOR_ORIGIN = 16;\n\n/** Default read budget per batch. Large enough to amortise syscalls, small enough to stay cheap. */\nexport const DEFAULT_CHUNK_BYTES = 8 * 1024 * 1024;\n\nexport interface RecordBatch {\n /** Index of the first record in this batch, relative to the whole file. */\n firstRecordIndex: number;\n recordCount: number;\n /**\n * Raw record bytes, `recordCount * header.recordBytes` long.\n *\n * The buffer is reused between iterations. Copy anything you need to keep past\n * the current loop turn.\n */\n data: Uint8Array;\n}\n\nexport interface ReadRecordsOptions {\n /** First record to read, inclusive. Defaults to 0. */\n startRecord?: number;\n /** Last record to read, exclusive. Defaults to the file's record count. */\n endRecord?: number;\n chunkBytes?: number;\n}\n\nexport class EdfFile {\n readonly path: string;\n readonly fileSize: number;\n /**\n * Last-modified time when this file was opened, in milliseconds, for the same reason as\n * `fileSize`.\n *\n * Kept as the raw number rather than a Date because `new Date(ms).getTime()` truncates to\n * whole milliseconds: comparing that against a later `fstat`, which carries the\n * filesystem's sub-millisecond precision, reported every undisturbed conversion as one\n * whose input had changed underneath it.\n */\n readonly modifiedAtOpenMs: number;\n readonly header: EdfHeader;\n /** Records actually present in the file, which may differ from the header's claim. */\n readonly recordCount: number;\n readonly trailingBytes: number;\n readonly diagnostics: Diagnostic[];\n\n #handle: FileHandle;\n #closed = false;\n /** The last answer `changedSinceOpen` computed, so it survives the file being closed. */\n #changed: boolean | null = null;\n\n private constructor(init: {\n path: string;\n fileSize: number;\n modifiedAtOpenMs: number;\n header: EdfHeader;\n recordCount: number;\n trailingBytes: number;\n diagnostics: Diagnostic[];\n handle: FileHandle;\n }) {\n this.path = init.path;\n this.fileSize = init.fileSize;\n this.modifiedAtOpenMs = init.modifiedAtOpenMs;\n this.header = init.header;\n this.recordCount = init.recordCount;\n this.trailingBytes = init.trailingBytes;\n this.diagnostics = init.diagnostics;\n this.#handle = init.handle;\n }\n\n /**\n * SHA-256 of the bytes this conversion actually read.\n *\n * Hashed through the open descriptor, over exactly the `fileSize` bytes that were there\n * when the file was opened — the same number every record count and window in the output\n * was derived from. Re-opening the path to hash it afterwards described whatever was at\n * that name by then: a recording still being written grew from 2,000 records to 3,000\n * mid-conversion and metadata.json recorded `data_records: 2000` beside the checksum and\n * byte count of the 3,000-record file, which is provenance for bytes nobody converted.\n * Replacing the file at that path did the same thing more completely.\n */\n async sha256(): Promise<string> {\n this.#assertOpen();\n const hash = createHash('sha256');\n const buffer = Buffer.alloc(Math.min(this.fileSize, 4 * 1024 * 1024) || 1);\n for (let at = 0; at < this.fileSize; ) {\n const want = Math.min(buffer.length, this.fileSize - at);\n const { bytesRead } = await this.#handle.read(buffer, 0, want, at);\n if (bytesRead <= 0) {\n throw new EdfError(\n 'UNREADABLE',\n // Both figures grouped, like the shortfall message this file raises one function\n // over — `Expected 8,386,560 bytes of data at record 0 but only 2,899,456 bytes\n // were available` — and for the reason 0.8.5 gives: the sentence exists to put one\n // against the other, and at nine digits that is work the separators do.\n `Expected ${grouped(this.fileSize)} bytes to checksum but the file ended at ` +\n `${grouped(at)}; it appears to have changed size while it was being read.`,\n 'Make sure the recording is not still being written to, then try again.',\n );\n }\n hash.update(buffer.subarray(0, bytesRead));\n at += bytesRead;\n }\n return hash.digest('hex');\n }\n\n /**\n * Whether the file has changed since it was opened, by size or by modification time.\n *\n * Checked through the descriptor, so it answers for the bytes that were read rather than\n * for whatever now answers to the same name. A recording still being written is the\n * ordinary cause, and the conversion is still correct for the data it saw — it is the\n * claim that the output describes the file as it now stands that stops being true.\n */\n async changedSinceOpen(): Promise<boolean> {\n /*\n A closed file remembers its last answer rather than inventing a new one.\n\n Returning false once closed asserted \"it did not change\", which is not something a\n closed descriptor can know — and `convert()` closes the file before it returns, so\n `result.file.changedSinceOpen()` denied the very change the INPUT_CHANGED diagnostic\n in the same result object had just reported. One object, two answers.\n\n `convert()` always asks before closing, so the cached answer is the true one. A caller\n who closed the file without ever asking gets an error, which is the same treatment\n every other method on a closed file gets.\n */\n if (this.#closed) {\n if (this.#changed !== null) return this.#changed;\n throw new EdfError(\n 'UNREADABLE',\n `\"${this.path}\" is closed, and whether it changed while it was open was never checked.`,\n 'Ask before closing the file. A ConvertResult carries the answer already, since ' +\n 'convert() checks it on the way out.',\n );\n }\n const now = await this.#handle.stat().catch(() => null);\n if (now === null) return this.#changed ?? false;\n this.#changed = now.size !== this.fileSize || now.mtimeMs !== this.modifiedAtOpenMs;\n return this.#changed;\n }\n\n /*\n A sentence, and advice under it, like the destination-side twin.\n\n `Cannot read \"rec.edf\": no such file` was the one diagnostic this tool prints that does not\n end in a full stop — 68 of its 69 do — and the only member of its family with nothing\n indented under it. `Cannot create \"out\": part of the path does not exist.` has carried\n advice under it since the destination errors were given sentences — one line for every\n cause until 0.8.12, and the cause's own since — the mid-conversion UNREADABLE beside it\n carries one too, and this is the form a mistyped path actually reaches.\n */\n static readonly #UNREADABLE_HINT =\n 'Check the path is spelled the way it is on disk and that you have permission to read it.';\n\n static async open(path: string): Promise<EdfFile> {\n /*\n A path, checked before `fs` is asked about it.\n\n `assertInputPath` was written for exactly this and applied one level up. Its docstring\n names the function it was describing — \"`EdfFile.open` hands whatever it is given to\n `fs`, and the refusal comes back as an `EdfError` coded `UNREADABLE`, hinted 'Check the\n path is spelled the way it is on disk and that you have permission to read it' — advice\n about a path, over a value that is not one, filed as a problem with the recording rather\n than with the call\" — and then went into `convert`, leaving `EdfFile.open` itself, which\n is exported from the package root and is how the api page says to read a header without\n converting anything, doing the thing being described:\n\n EdfFile.open({ path: 'a.edf' })\n EdfError[UNREADABLE]: Cannot read \"[object Object]\": The \"path\" argument must be of\n type string or an instance of Buffer or URL. Received an instance of Object.\n\n Node's own argument-type text, under a hint about spelling and permissions, over a\n value that has neither. `EdfFile.open(['a.edf', 'b.edf'])` was worse: it answered\n `Cannot read \"a.edf,b.edf\"`, quoting a path the caller never wrote, because `String` of\n an array joins it with commas.\n\n The same `OptionError` `convert` raises for the same mistake, so one mistake has one\n answer whichever entry point it arrives at.\n */\n assertInputPath(path);\n const info = await stat(path).catch((cause: unknown) => {\n throw new EdfError(\n 'UNREADABLE',\n `Cannot read \"${printable(path)}\": ${describe(cause)}.`,\n EdfFile.#UNREADABLE_HINT,\n );\n });\n /*\n Both of these said what was wrong and nothing about what to do, which is the gap\n `#UNREADABLE_HINT` two lines up was added to close for the third member of this family.\n\n Neither is reached from the command line, and that is the point: a folder there is\n expanded to the recordings inside it and a socket is filtered out by the walk, so the\n caller who arrives here is holding a path in code and has no walk behind them.\n */\n if (info.isDirectory()) {\n throw new EdfError(\n 'UNREADABLE',\n `\"${printable(path)}\" is a directory, not an EDF file.`,\n 'Name a recording inside it. The command line expands a folder to the recordings it ' +\n 'holds; this takes one file.',\n );\n }\n if (!info.isFile()) {\n throw new EdfError(\n 'UNREADABLE',\n `\"${printable(path)}\" is not a regular file.`,\n 'A pipe, socket or device cannot be read as a recording: the parser seeks to a byte ' +\n 'offset inside the file, which only a real file supports.',\n );\n }\n\n /*\n Opening is a second chance to be refused, and it was the one that got through.\n\n `stat` needs the parent directory searchable and says nothing about the file's own mode,\n so a recording with no read permission passes it and fails here — the commonest\n permission failure there is. Unwrapped, it escaped as Node's own error: the CLI printed\n `error: EACCES: permission denied, open '...'` where every neighbouring failure prints\n the tool's sentence, and the library threw a plain Error whose `code` was the errno.\n\n api.md says `UNREADABLE` \"covers a missing file, a directory passed where a file was\n expected, a permission failure, and a file that changed size while being read. Branch on\n `code`, never on the message text.\" A consumer doing exactly that fell through to its\n generic handler.\n */\n const handle = await open(path, 'r').catch((cause: unknown) => {\n throw new EdfError(\n 'UNREADABLE',\n `Cannot read \"${printable(path)}\": ${describe(cause)}.`,\n EdfFile.#UNREADABLE_HINT,\n );\n });\n try {\n const fixed = Buffer.alloc(Math.min(FIXED_HEADER_BYTES, info.size));\n if (fixed.length > 0) {\n const bytesRead = await readFully(handle, fixed, 0, fixed.length, 0);\n if (bytesRead < fixed.length) throw changedWhileReading(0, fixed.length, bytesRead);\n }\n\n // The signal count decides how much more header there is to read. Read by the header\n // parser itself, so the two cannot disagree about which files are readable: this used\n // to have its own Number(), which tolerated the NUL padding sloppy writers emit but\n // not the comma decimal separator that COMMA_DECIMAL exists to accept.\n let headerBuffer = fixed;\n if (fixed.length === FIXED_HEADER_BYTES) {\n const ns = peekSignalCount(fixed);\n if (ns !== null) {\n const total = FIXED_HEADER_BYTES + ns * SIGNAL_HEADER_BYTES;\n if (total <= info.size) {\n headerBuffer = Buffer.alloc(total);\n const bytesRead = await readFully(handle, headerBuffer, 0, total, 0);\n if (bytesRead < total) throw changedWhileReading(0, total, bytesRead);\n }\n }\n }\n\n const { header, recordCount, trailingBytes, diagnostics } = parseHeader(\n headerBuffer,\n info.size,\n );\n\n return new EdfFile({\n path,\n fileSize: info.size,\n modifiedAtOpenMs: info.mtimeMs,\n header,\n recordCount,\n trailingBytes,\n diagnostics,\n handle,\n });\n } catch (error) {\n await handle.close().catch(() => {});\n throw error;\n }\n }\n\n /** Signal channels, excluding the EDF+ annotations channel. */\n get dataSignals(): EdfSignal[] {\n return this.header.signals.filter((s) => !s.isAnnotations);\n }\n\n /**\n * The annotation channel a record's start time is read from.\n *\n * EDF+ puts the timekeeping TAL first in the first annotation channel, and this was read as\n * `annotationSignals[0]` — the first one declared, whether or not it can hold anything. A\n * writer that declares an annotation channel and gives it zero samples per record leaves a\n * slot of zero bytes, so nothing was read from it, and the timekeeping in the channel after\n * it went unread: a three-record EDF+D reported \"3 of 3 data records carry no readable\n * timekeeping annotation\" about three that were perfectly readable, and timed the file from\n * zero.\n *\n * A channel with no room carries nothing, so it is not the one the TAL is in.\n */\n get timekeepingSignal(): EdfSignal | undefined {\n return this.annotationSignals.find((signal) => signal.samplesPerRecord > 0);\n }\n\n get annotationSignals(): EdfSignal[] {\n return this.header.signals.filter((s) => s.isAnnotations);\n }\n\n /** Total recording duration in seconds, based on records actually present. */\n get durationSeconds(): number {\n return this.recordCount * this.header.recordDuration;\n }\n\n /** Read a half-open range of records in batches. */\n async *readRecords(options: ReadRecordsOptions = {}): AsyncGenerator<RecordBatch> {\n this.#assertOpen();\n\n /*\n The bag the three options arrive in, which nothing looked at.\n\n The default `= {}` covers `undefined` and nothing else, and every read below is\n `options.startRecord` — so a value that is not an object had its properties read off it\n and came back `undefined`, which is how a caller says they are not passing one:\n\n file.readRecords(42) // every record, as though no options were given\n file.readRecords('x') // \"\n file.readRecords(null) // TypeError: Cannot read properties of null\n\n `null` is what `JSON.parse` of a config gives for a field left unset, which is the door\n `assertOptions` names for the flags; the other two are a caller who thought this took a\n record index. The first two are the worse pair, because reading the whole file is a\n plausible answer and they got it in silence. `resolveRange` was given this same check on\n its own bag in 0.8.75, for the same reason: reading `.start` off a number is `undefined`\n rather than a throw.\n */\n if (typeof options !== 'object' || options === null) {\n throw new OptionError(\n `readRecords: options must be an object, got ${describeValue(options)}. It carries ` +\n 'startRecord, endRecord and chunkBytes; omit it to read every record.',\n );\n }\n\n /*\n Record bounds have to be whole records.\n\n A fractional `startRecord` was carried straight into `position = headerBytes +\n record * recordBytes`, so reading from 1.5 began half a record in and every sample\n after it was decoded from the wrong offset: on the two-channel test fixture it\n returned channel 2's values under channel 1's signal, with no error. Clamping\n silently would be no better, since a caller asking for record 1.5 has a bug the\n library should name rather than paper over.\n */\n for (const [name, value] of [\n ['startRecord', options.startRecord],\n ['endRecord', options.endRecord],\n ] as const) {\n if (value !== undefined && !Number.isInteger(value)) {\n /*\n An `OptionError`, because it is the call that is wrong and not the recording.\n\n This raised an `EdfError` coded `BAD_HEADER_FIELD` — a code the reference defines\n as \"a field that should contain a number doesn't\", about the file's header — for a\n number the *caller* passed. A script branching on that code to report a corrupt\n recording blamed the recording for its own bug, and `chunkBytes` below did the same\n under `UNREADABLE`, which means the file could not be read.\n\n The same class `EdfFile.open` raises for a path that is not one and `parseHeader`\n for a byte count that is not one, both settled in this same series, and for the\n reason `assertInputPath` gives.\n */\n throw new OptionError(\n /*\n Through `describeValue`, like every other refusal that quotes a rejected value.\n\n Its rule is \"numbers bare, everything else quoted so its type is visible\", and\n these two were the sites that never used it — so a refusal *for not being a\n number* showed the value as one: `readRecords({ startRecord: '1' })` came back\n `startRecord must be a whole record index, got 1.`, where 1 is a whole record\n index and the caller is left looking for what else could be wrong. An array came\n back `got .`, a hole where the value should be, and an object came back\n `got [object Object]` — the string `assertInputPath`'s own docstring names as the\n reason it exists.\n */\n `readRecords: ${name} must be a whole record index, got ${describeValue(value)}. ` +\n 'Record boundaries are the unit the file can be read in; a fractional index ' +\n 'would decode samples from the middle of a record.',\n );\n }\n }\n\n const start = Math.max(0, options.startRecord ?? 0);\n const end = Math.min(this.recordCount, options.endRecord ?? this.recordCount);\n if (start >= end) return;\n\n const { recordBytes } = this.header;\n /*\n Checked rather than handed to Buffer.alloc.\n\n `chunkBytes: NaN` came back as `RangeError: The value of \"size\" is out of range` from\n inside Node, with no mention of the option that caused it — while a fractional\n `startRecord` two lines up gets a typed EdfError naming the field. Every other option\n here is checked; this one reached the allocator.\n */\n const budget = options.chunkBytes ?? DEFAULT_CHUNK_BYTES;\n if (!Number.isFinite(budget) || budget < 1) {\n // `OptionError` for the same reason as the record bounds above; see there.\n throw new OptionError(\n `chunkBytes must be a positive number of bytes, got ${describeValue(options.chunkBytes)}. ` +\n 'It is a ceiling on how much of the file is held at once; one record is read ' +\n 'whatever it says.',\n );\n }\n /*\n The budget is a ceiling, not an amount to reserve.\n\n `Math.floor(budget / recordBytes)` is how many records would fit in it, and the buffer\n was that many — whether or not the file had that many. A 848-byte fixture read with a\n 512 MB budget allocated 536,870,880 bytes for its two records, and every ordinary read\n of a small file reserved the full 8 MB default. Nothing was wrong with the data; the\n memory just had nothing to do with it.\n\n Bounded by what is actually going to be read, so a batch of five hundred short\n recordings costs five hundred short buffers rather than five hundred 8 MB ones.\n */\n const perChunk = Math.max(1, Math.min(Math.floor(budget / recordBytes), end - start));\n const buffer = Buffer.alloc(perChunk * recordBytes);\n\n for (let record = start; record < end; record += perChunk) {\n const count = Math.min(perChunk, end - record);\n const bytes = count * recordBytes;\n const position = this.header.headerBytes + record * recordBytes;\n\n const bytesRead = await readFully(this.#handle, buffer, 0, bytes, position);\n if (bytesRead < bytes) {\n // The file is shorter than its own size said. Quietly stopping here would\n // hand back a conversion missing its tail with nothing to show for it.\n //\n // Through the shared builder rather than a second copy of its sentence: the two were\n // character-for-character identical, which is how a wording fixed in one of them\n // would have been fixed in only one of them.\n throw changedWhileReading(record, bytes, bytesRead);\n }\n\n yield { firstRecordIndex: record, recordCount: count, data: buffer.subarray(0, bytes) };\n }\n }\n\n /**\n * The channel, confirmed to be one of this recording's.\n *\n * The three methods that take an `EdfSignal` turn its `byteOffsetInRecord` and\n * `samplesPerRecord` into a position in a batch of this file's bytes. Nothing said the\n * channel had to come from this file, and a channel from another one reads as though it\n * did: handing `sampleAt` a `.bdf` channel — three bytes a sample, its own offset — while\n * reading a `.edf` batch returned the first EDF channel's samples, `0 74 147 219 290`,\n * every one of them a real number from the recording and none of them the caller's.\n *\n * Two open files is how it arrives. It is also what a plain object gets: `{}` and `42`\n * both have an undefined offset, which the arithmetic below turns into `NaN` and then\n * into a sample of 0.\n *\n * By identity at its own index, not by scanning the list: `sampleAt` is called once per\n * sample, and the caller already holds these objects — `header.signals[i]`, or the subsets\n * `annotationSignals` and `selectChannels` filter out of it, which are the same references.\n */\n #assertSignalHere(signal: EdfSignal, method: string): void {\n if (this.header.signals[(signal as { index?: number } | null)?.index as number] === signal) {\n return;\n }\n /*\n A channel-shaped argument is placed rather than dumped.\n\n `describeValue` renders an object as its JSON, and a channel is fourteen fields — a\n 458-character refusal, most of it the caller's own data handed back. Its index is the\n part that locates the mistake, and it is the field this check just read. Anything that\n is not object-shaped is quoted the ordinary way, since there it is the value itself\n that is wrong.\n */\n const elsewhere =\n `A channel read out of a different file names a position in that file's records, ` +\n `not this one's.`;\n throw new OptionError(\n signal !== null && typeof signal === 'object'\n ? `${method}: signal is a channel object, but not one of this recording's — ` +\n `header.signals at index ${describeValue((signal as { index?: unknown }).index)} ` +\n `is a different channel. ${elsewhere}`\n : `${method}: signal must be one of this recording's own channels, from ` +\n `header.signals — got ${describeValue(signal)}. ${elsewhere}`,\n );\n }\n\n /**\n * The record, confirmed to be one this batch holds.\n *\n * 0.8.62 put this on `sampleAt`, where it turns a position into a sample. `offsetOf` does\n * the same arithmetic and hands the position back, and `annotationBytes` slices at it, and\n * neither asked anything of it:\n *\n * file.offsetOf(batch, -5, signal) // -1300\n * file.offsetOf(batch, 1.5, signal) // 390, half a record in\n * file.annotationBytes(batch, 99, s) // Uint8Array(0)\n *\n * A negative byte position, a position that decodes the second half of one record against\n * the first half of the next — the failure `readRecords` refuses a fractional `startRecord`\n * for — and an empty slice that reads as \"this record carries no annotations\" for a record\n * that is not in the batch at all.\n *\n * The batch is checked first, because the bound is read off it: `offsetOf` never touched\n * `batch` before this, so a caller who passed the wrong thing got no complaint from it. Its\n * bytes are checked with it, for the reason given where that check sits.\n */\n #assertRecordOffset(batch: RecordBatch, recordOffset: number, method: string): void {\n if (!Number.isInteger((batch as RecordBatch | null)?.recordCount)) {\n throw new OptionError(\n `${method}: batch must be one of the batches readRecords yields, got ` +\n `${describeValue(batch)}.`,\n );\n }\n /*\n And the bytes, which are what the position is a position into.\n\n The count above was the whole of what this asked, and all three methods go on to read\n `batch.data`: `sampleAt` indexes it, `annotationBytes` slices it, and `offsetOf` hands\n back a position for it. A batch-shaped object without any failed differently depending\n on which one was called, and two of those failures were answers:\n\n file.sampleAt({ recordCount: 2, data: [1, 2, 3, 4] }, 0, signal, 0) // 513\n file.sampleAt({ recordCount: 2, data: new Float64Array(64) }, ...) // 0\n file.annotationBytes({ recordCount: 2, data: new Float64Array(8) }, …) // 20 values\n\n 513 is a digital code this recording could have held; 0 is the commonest sample in any\n recording; and the third is a run of numbers that are not the bytes of anything,\n returned as the annotation channel's own. `ArrayBuffer.isView` is true of all of them\n and of a `DataView`, which is why the check is the one 0.8.84 and 0.8.85 settled on for\n the two other places this parser is handed bytes: a view whose elements are one byte.\n */\n const bytes = (batch as { data?: unknown }).data;\n if (!ArrayBuffer.isView(bytes) || (bytes as { BYTES_PER_ELEMENT?: number }).BYTES_PER_ELEMENT !== 1) {\n const carries = 'A batch carries firstRecordIndex, recordCount, and the record bytes themselves.';\n // Named rather than quoted back: a batch is megabytes, and the kind of thing it is is\n // the part that locates the mistake — the same reasoning `#assertSignalHere` gives for\n // placing a channel by its index instead of printing its fourteen fields. The article\n // is worked out because `Array` and `Int16Array` both arrive here and \"a Array\" is not\n // a sentence.\n const kind =\n typeof bytes === 'object' && bytes !== null\n ? ((bytes as object).constructor?.name ?? 'object')\n : null;\n throw new OptionError(\n kind !== null\n ? `${method}: batch.data is ${/^[AEIOU]/u.test(kind) ? 'an' : 'a'} ${kind}, which is ` +\n `not a view of bytes. ${carries}`\n : `${method}: batch.data must be the record bytes, got ${describeValue(bytes)}. ${carries}`,\n );\n }\n if (!Number.isInteger(recordOffset) || recordOffset < 0 || recordOffset >= batch.recordCount) {\n throw new OptionError(\n `${method}: recordOffset must be a record's position within this batch, 0 to ` +\n `${batch.recordCount - 1}, got ${describeValue(recordOffset)}. Absolute record ` +\n `indexes are batch.firstRecordIndex higher.`,\n );\n }\n }\n\n /** Read one sample as its raw digital value. */\n sampleAt(batch: RecordBatch, recordOffset: number, signal: EdfSignal, sampleIndex: number): number {\n this.#assertSignalHere(signal, 'sampleAt');\n /*\n In range, because out of it this invented a number.\n\n The arithmetic below turns four values into a byte position and reads there. Nothing\n stopped that position from landing outside the sample it names. Past the end of the\n buffer, `bytes[position]` is `undefined`, which `| 0` and `<< 8` both turn into 0 — so a\n read past the batch came back as a plausible sample of zero. Inside the buffer but past\n the channel's own samples, it came back as the *next channel's* data: a 256-sample\n channel asked for sample 261 returned 243, which is a real number from the recording\n and belongs to another column.\n\n Both are reachable from the mistake the api page warns about in the sentence that\n describes this method — \"`recordOffset` is the record's position within the batch, from\n 0 to `batch.recordCount - 1`, not its index in the file\". A caller who passes the\n absolute index reads past the batch and gets zeros for every sample of it.\n\n Two integer comparisons each, on a call that then formats a number.\n */\n this.#assertRecordOffset(batch, recordOffset, 'sampleAt');\n if (\n !Number.isInteger(sampleIndex) ||\n sampleIndex < 0 ||\n sampleIndex >= signal.samplesPerRecord\n ) {\n throw new OptionError(\n `sampleIndex must be 0 to ${signal.samplesPerRecord - 1} for this channel, got ` +\n `${describeValue(sampleIndex)}.`,\n );\n }\n const position =\n recordOffset * this.header.recordBytes +\n signal.byteOffsetInRecord +\n sampleIndex * this.header.bytesPerSample;\n\n if (this.header.bytesPerSample === 3) {\n // BDF stores 24-bit little-endian two's complement. Loading the three bytes\n // into the top of a 32-bit word and shifting back down sign-extends them.\n const data = batch.data;\n return (\n ((data[position] as number) << 8) |\n ((data[position + 1] as number) << 16) |\n ((data[position + 2] as number) << 24)\n ) >> 8;\n }\n return readInt16LE(batch.data, position);\n }\n\n /** Byte offset of a signal's samples within a batch. */\n offsetOf(batch: RecordBatch, recordOffset: number, signal: EdfSignal): number {\n this.#assertSignalHere(signal, 'offsetOf');\n this.#assertRecordOffset(batch, recordOffset, 'offsetOf');\n return recordOffset * this.header.recordBytes + signal.byteOffsetInRecord;\n }\n\n /** The annotation channel's raw bytes for one record in a batch. */\n annotationBytes(batch: RecordBatch, recordOffset: number, signal: EdfSignal): Uint8Array {\n // Before delegating, so the refusal names the method the caller called rather than the\n // one underneath it.\n this.#assertSignalHere(signal, 'annotationBytes');\n this.#assertRecordOffset(batch, recordOffset, 'annotationBytes');\n const start = this.offsetOf(batch, recordOffset, signal);\n return batch.data.subarray(start, start + signal.samplesPerRecord * this.header.bytesPerSample);\n }\n\n /**\n * Where this continuous recording begins, from the first record that says.\n *\n * A few records' worth of annotation bytes rather than the whole channel. A continuous\n * recording's origin is the fraction of a second by which its first record follows the\n * header's start time, and `--info` needs that to place a requested window — but it does\n * not need the events, and finding one number by reading every record costs a seek per\n * record across the whole file, which is the scan `--info` was deliberately spared.\n *\n * It reads on past record 0 because a conversion does. This used to stop there, so the\n * moment one timekeeping TAL was unreadable the two disagreed: the conversion took the\n * origin from record 1 and timed the file from 0.5s, while `--info` found nothing at\n * record 0 and reported a recording starting at zero — the same file described two ways by\n * one tool. Records are contiguous, so record `i` beginning at `t` puts the origin at\n * `t - i * duration`, and any one of them settles it.\n *\n * The bound is what keeps this cheap: a file whose first `RECORDS_SEARCHED_FOR_ORIGIN`\n * timekeeping entries are all unreadable reports an origin of zero here, and converting it\n * raises ANNOTATION_DECODE_FAILED for every one of them.\n *\n * That mitigation covers records that could not be read, and not records that said nothing:\n * an empty annotation slot is not a TAL that failed, so nothing is counted and nothing is\n * raised. Twenty records whose only timekeeping entry is in record 16 therefore convert with\n * `time_s` from the origin it states and are reported here as beginning at zero, in silence\n * on both sides — and `--start` and `--end` are read against that same clock. The bound\n * stays, since it is what makes `--info` a header read on a file of any size; what was\n * wrong was the account of what it costs, which every page giving it said was a warning.\n *\n * Returns null when there is nothing to read it from, in which case the origin is zero.\n */\n async readOrigin(): Promise<number | null> {\n return (await this.scanOrigin()).origin;\n }\n\n /**\n * The origin, and what the search saw on the way to it.\n *\n * `--info` takes this route for a continuous recording rather than reading every record,\n * and reported nothing when the timekeeping it read was unreadable: the count was hard-coded\n * to zero at the call site, so a file whose first TAL cannot be parsed raised\n * ANNOTATION_DECODE_FAILED when converted and nothing under `--info`. Its byte-identical\n * EDF+D twin — same bytes but for the reserved field, which has nothing to do with the\n * defect — raised it both ways, because that path reads every record and counts as it goes.\n *\n * The failure was being read and then thrown away. `readOrigin` keeps its shape for callers\n * who only want the number.\n *\n * All three counters, not one. A first-position TAL may carry events after the start time,\n * and when it cannot be parsed those go with it — which is what `malformedTimekeepingWithText`\n * counts and what decides whether the warning says \"No event was lost\" or names the events\n * that were. Counting only the first meant `--info` took the first sentence every time: it\n * announced that a record had lost its position and that nothing else had gone, over a file\n * whose conversion said, correctly, that an event had gone with it. One file, two answers,\n * and the confident one was `--info`, which is the command run first to find out what a\n * conversion will say.\n *\n * `malformed` comes back for the same reason one sentence further on: that hint ends \"and is\n * counted above\", which is only true where the entry warning is printed too.\n *\n * All three are of the records this actually read, which is as far as the first record that\n * states a time — so they are lower bounds on the file, as `malformedTimekeeping` has been\n * since it was returned at all. A conversion reads every record and may count more. What\n * they must not be is inconsistent with each other, which is what a hard-coded zero made\n * them.\n */\n async scanOrigin(): Promise<{\n origin: number | null;\n malformed: number;\n malformedTimekeeping: number;\n malformedTimekeepingWithText: number;\n }> {\n this.#assertOpen();\n\n const counts = { malformed: 0, malformedTimekeeping: 0, malformedTimekeepingWithText: 0 };\n const channel = this.timekeepingSignal;\n if (!channel || this.recordCount === 0) return { origin: null, ...counts };\n\n const { headerBytes, bytesPerSample, recordBytes, recordDuration } = this.header;\n const buffer = Buffer.alloc(channel.samplesPerRecord * bytesPerSample);\n if (buffer.length === 0) return { origin: null, ...counts };\n\n const searched = Math.min(this.recordCount, RECORDS_SEARCHED_FOR_ORIGIN);\n for (let record = 0; record < searched; record++) {\n const offset = headerBytes + record * recordBytes + channel.byteOffsetInRecord;\n const bytesRead = await readFully(this.#handle, buffer, 0, buffer.length, offset);\n if (bytesRead < buffer.length) return { origin: null, ...counts };\n\n const decoded = decodeRecordAnnotations(buffer, record);\n counts.malformed += decoded.malformed;\n counts.malformedTimekeeping += decoded.malformedTimekeeping;\n counts.malformedTimekeepingWithText += decoded.malformedTimekeepingWithText;\n if (decoded.recordStart !== null) {\n return { origin: decoded.recordStart - record * recordDuration, ...counts };\n }\n }\n return { origin: null, ...counts };\n }\n\n /**\n * Read every EDF+ annotation in the file, plus the start time each record declares.\n *\n * Only the annotation channel is read, seeking straight to it inside each record\n * rather than pulling whole records through memory. On a multi-gigabyte recording\n * that is the difference between a few kilobytes of I/O and all of it.\n *\n * The whole file is always scanned, never just the records inside a requested\n * window: writers are not obliged to store an annotation in the record its onset\n * falls in, and some put every annotation in the first record. Reading only the\n * window's records would drop those entirely.\n */\n async readAnnotations(): Promise<{\n annotations: Annotation[];\n recordStarts: (number | null)[];\n malformed: number;\n /** Unreadable TALs in first position, which carry timing rather than an event. */\n malformedTimekeeping: number;\n /** How many of those also carried event text, so events were lost with the position. */\n malformedTimekeepingWithText: number;\n /** Events kept whose stated duration could not be read; see Annotation.duration. */\n unreadableDurations: number;\n /** Events kept whose stated duration read as a number below zero. */\n negativeDurations: number;\n }> {\n this.#assertOpen();\n\n const annotations: Annotation[] = [];\n const recordStarts: (number | null)[] = new Array<number | null>(this.recordCount).fill(null);\n let malformed = 0;\n let malformedTimekeeping = 0;\n let malformedTimekeepingWithText = 0;\n let unreadableDurations = 0;\n let negativeDurations = 0;\n\n const channels = this.annotationSignals;\n if (channels.length === 0) {\n return {\n annotations,\n recordStarts,\n malformed,\n malformedTimekeeping,\n malformedTimekeepingWithText,\n unreadableDurations,\n negativeDurations,\n };\n }\n\n const { headerBytes, recordBytes, bytesPerSample } = this.header;\n const buffers = channels.map((c) => Buffer.alloc(c.samplesPerRecord * bytesPerSample));\n const timekeeping = this.timekeepingSignal;\n\n for (let record = 0; record < this.recordCount; record++) {\n for (const [position, channel] of channels.entries()) {\n const buffer = buffers[position];\n if (!buffer || buffer.length === 0) continue;\n\n const offset = headerBytes + record * recordBytes + channel.byteOffsetInRecord;\n const bytesRead = await readFully(this.#handle, buffer, 0, buffer.length, offset);\n if (bytesRead < buffer.length) {\n throw changedWhileReading(record, buffer.length, bytesRead, 'annotation data');\n }\n\n // Only the timekeeping channel carries the record's start; see timekeepingSignal.\n const decoded = decodeRecordAnnotations(buffer, record, channel === timekeeping);\n if (channel === timekeeping) recordStarts[record] = decoded.recordStart;\n for (const annotation of decoded.annotations) annotations.push(annotation);\n malformed += decoded.malformed;\n malformedTimekeeping += decoded.malformedTimekeeping;\n malformedTimekeepingWithText += decoded.malformedTimekeepingWithText;\n unreadableDurations += decoded.unreadableDurations;\n negativeDurations += decoded.negativeDurations;\n }\n }\n\n annotations.sort((a, b) => a.onset - b.onset || a.recordIndex - b.recordIndex);\n return {\n annotations,\n recordStarts,\n malformed,\n malformedTimekeeping,\n malformedTimekeepingWithText,\n unreadableDurations,\n negativeDurations,\n };\n }\n\n async close(): Promise<void> {\n if (this.#closed) return;\n this.#closed = true;\n await this.#handle.close();\n }\n\n #assertOpen(): void {\n if (this.#closed) {\n throw new EdfError(\n 'UNREADABLE',\n 'This EDF file has already been closed.',\n // The same advice `changedSinceOpen` gives for the same mistake, which is the only\n // other method that has anything to say about a closed file.\n 'Open it again, or keep it open until the last read.',\n );\n }\n }\n}\n\nfunction describe(cause: unknown): string {\n if (cause instanceof Error) {\n const code = (cause as NodeJS.ErrnoException).code;\n if (code === 'ENOENT') return 'no such file';\n // EPERM beside EACCES, because everywhere else in this codebase that reads an errno pairs\n // the two, and ENOTDIR because a path that runs through a regular file — `rec.edf/inner`,\n // which a shell completes and a script builds by joining — is otherwise the one input\n // failure that answers in errno text while its output-side twin answers in a sentence.\n if (code === 'EACCES' || code === 'EPERM') return 'permission denied';\n if (code === 'ENOTDIR') return 'part of the path is a file, not a directory';\n return cause.message;\n }\n return String(cause);\n}\n\n/**\n * The most `fs.read` will accept as a length.\n *\n * Node asserts on a length that does not fit in a signed 32-bit integer, and it asserts in\n * C++: `Assertion failed: args[3]->IsInt32()`, forty frames of native stack, SIGABRT. Not an\n * exception — nothing in JavaScript sees it, so no catch block and no `uncaughtException`\n * handler runs, and a library consumer's whole process goes down with it.\n *\n * A round gigabyte rather than the exact limit, so the loop below does whole even reads.\n */\nconst MAX_READ_BYTES = 1024 * 1024 * 1024;\n\n/** Fill a requested region unless EOF is reached; regular-file reads may legally be short. */\nasync function readFully(\n handle: FileHandle,\n buffer: Buffer,\n offset: number,\n length: number,\n position: number,\n): Promise<number> {\n let total = 0;\n while (total < length) {\n /*\n Capped, because one data record can be larger than a single read may be.\n\n A record is read in one call when it exceeds the chunk budget — there is nothing\n smaller to divide it by, since a record is the unit the format is addressed in. EDF's\n samples-per-record field is 8 characters, so eleven channels at 99,999,999 samples make\n a record of 2.2 GB, and a long record duration at ordinary rates gets there too. That\n went to `fs.read` as a single length over 2^31-1 and took the process out with a native\n assertion rather than an error.\n\n Looping was already how a short read is handled, so the cap costs one more iteration\n per gigabyte and nothing else.\n */\n const want = Math.min(length - total, MAX_READ_BYTES);\n const { bytesRead } = await handle.read(buffer, offset + total, want, position + total);\n if (bytesRead === 0) break;\n total += bytesRead;\n }\n return total;\n}\n\nfunction changedWhileReading(\n record: number,\n expected: number,\n actual: number,\n subject = 'data',\n): EdfError {\n return new EdfError(\n 'UNREADABLE',\n `Expected ${grouped(expected)} bytes of ${subject} at record ${record} but only ` +\n `${counted(actual, 'byte')} ${actual === 1 ? 'was' : 'were'} available; the file appears ` +\n `to have changed size while it was being read.`,\n 'Make sure the recording is not still being written to, then try again.',\n );\n}\n"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "edf2csv",
3
- "version": "0.9.0",
3
+ "version": "0.9.2",
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",