bluefile.js

/**
 * @license apache-2.0
 * @file bluefile.js
 * Copyright (c) 2012-2020, LGS Innovations Inc., All rights reserved.
 *
 * This file is part of SigFile.
 *
 * Licensed to the LGS Innovations (LGS) under one
 * or more contributor license agreements.  See the NOTICE file
 * distributed with this work for additional information
 * regarding copyright ownership.  LGS licenses this file
 * to you under the Apache License, Version 2.0 (the
 * "License"); you may not use this file except in compliance
 * with the License.  You may obtain a copy of the License at
 *
 * http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing,
 * software distributed under the License is distributed on an
 * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
 * KIND, either express or implied.  See the License for the
 * specific language governing permissions and limitations
 * under the License.
 */
import BitArray from './bitarray';
import { BaseFileReader } from './basefilereader';
import { endianness, ab2str, getInt64 } from './util';

/**
 * Bluefiles are a binary format directly supported by SigPlot.  A Bluefile consists of a 512-byte header
 * followed by binary data.
 *
 * For more information on BLUEFILES, please visit http://nextmidas.techma.com/nm/htdocs/usersguide/BlueFiles.html
 *
 * | Offset  |  Name      | Size |  Type      | Description                                |
 * |:--------|:-----------|:-----|:-----------|:-------------------------------------------|
 * |  0      |  version   | 4    |  char[4]   | Header version                             |
 * |  4      |  head_rep  | 4    |  char[4]   | Header representation                      |
 * |  8      |  data_rep  | 4    |  char[4]   | Data representation                        |
 * | 12      |  detached  | 4    |  int_4     | Detached header                            |
 * | 16      |  protected | 4    |  int_4     | Protected from overwrite                   |
 * | 20      |  pipe      | 4    |  int_4     | Pipe mode (N/A)                            |
 * | 24      |  ext_start | 4    |  int_4     | Extended header start, in 512-byte blocks  |
 * | 28      |  ext_size  | 4    |  int_4     | Extended header size in bytes              |
 * | 32      |  data_start| 8    |  real_8    | Data start in bytes                        |
 * | 40      |  data_size | 8    |  real_8    | Data size in bytes                         |
 * | 48      |  type      | 4    |  int_4     | File type code                             |
 * | 52      |  format    | 2    |  char[2]   | Data format code                           |
 * | 54      |  flagmask  | 2    |  int_2     | 16-bit flagmask (1=flagbit)                |
 * | 56      |  timecode  | 8    |  real_8    | Time code field                            |
 * | 64      |  inlet     | 2    |  int_2     | Inlet owner                                |
 * | 66      |  outlets   | 2    |  int_2     | Number of outlets                          |
 * | 68      |  outmask   | 4    |  int_4     | Outlet async mask                          |
 * | 72      |  pipeloc   | 4    |  int_4     | Pipe location                              |
 * | 76      |  pipesize  | 4    |  int_4     | Pipe size in bytes                         |
 * | 80      |  in_byte   | 8    |  real_8    | Next input byte                            |
 * | 88      |  out_byte  | 8    |  real_8    | Next out byte (cumulative)                 |
 * | 96      |  outbytes  | 64   |  real_8[8] | Next out byte (each outlet)                |
 * | 160     |  keylength | 4    |  int_4     | Length of keyword string                   |
 * | 164     |  keywords  | 92   |  char[92]  | User defined keyword string                |
 * | 256     |  Adjunct   | 256  |  char[256] | Type-specific adjunct union (See below for 1000 and 2000 type bluefiles)|
 *
 *
 * Type-1000 Adjunct
 *
 * | Offset | Name | Size | Type | Description                      |
 * :--------|:-----|:-----|:-----|:---------------------------------|
 * |  0     |xstart| 8    |real_8| Abscissa value for first sample  |
 * |  8     |xdelta| 8    |real_8| Abscissa interval between samples|
 * | 16     |xunits| 4    | int_4| Units for abscissa values        |
 *
 * Type-2000 Adjunct
 *
 * | Offset | Name  | Size | Type | Description                          |
 * |:-------|:------|:-----|:-----|:-------------------------------------|
 * |  0     |xstart |  8   |real_8| Frame (column) starting value        |
 * |  8     |xdelta |  8   |real_8| Increment between samples in frame   |
 * | 16     |xunits |  4   |int_4 | Frame (column) units                 |
 * | 20     |subsize|  4   |int_4 | Number of data points per frame (row)|
 * | 24     |ystart |  8   |real_8| Abscissa (row) start                 |
 * | 32     |ydelta |  8   |real_8| Increment between frames             |
 * | 36     |yunits |  4   |int_4 | Abscissa (row) unit code             |
 */
class BlueHeader {
  /**
   * Static member that indicates the endianness of the system,
   * BlueHeader.ARRAY_BUFFER_ENDIANNESS.
   * @memberOf BlueHeader
   * @type {string}
   */
  static ARRAY_BUFFER_ENDIANNESS = endianness();

  /**
   * Mapping from character to
   * @memberOf BlueHeader
   */
  static _SPA = {
    S: 1,
    C: 2,
    V: 3,
    Q: 4,
    M: 9,
    X: 10,
    T: 16,
    U: 1,
    '1': 1,
    '2': 2,
    '3': 3,
    '4': 4,
    '5': 5,
    '6': 6,
    '7': 7,
    '8': 8,
    '9': 9,
  };

  /**
   * @memberOf bluefile
   */
  static _BPS = {
    P: 0.125,
    A: 1,
    O: 1,
    B: 1,
    I: 2,
    L: 4,
    X: 8,
    F: 4,
    D: 8,
  };

  /**
   * @memberOf bluefile
   */
  static _XM_TO_TYPEDARRAY = {
    P: BitArray,
    A: null,
    O: Uint8Array,
    B: Int8Array,
    I: Int16Array,
    L: Int32Array,
    X: null,
    F: Float32Array,
    D: Float64Array,
  };

  /**
   * @memberOf bluefile
   */
  static _XM_TO_DATAVIEW = {
    P: null,
    A: null,
    O: 'getUint8',
    B: 'getInt8',
    I: 'getInt16',
    L: 'getInt32',
    X: getInt64,
    F: 'getFloat32',
    D: 'getFloat64',
  };

  /**
   * Constructor for a BlueHeader that extracts parameters from the 512-byte
   * Bluefile binary header.  If the data segment of the bluefile is also
   * included in the provided buffer it will be accessible as well
   * via the dview property.
   *
   * @memberof bluefile
   * @param {(ArrayBuffer|array)} buf - An existing ArrayBuffer of Bluefile data.
   * @param {object?} options - options that affect how the bluefile is read
   * @param {string} [options.ext_header_type="dict"] - if the BlueFile contains
   *       extended header keywords, extract them either as a dictionary
   *       ("dict", "json", {}, "XMTable", "JSON", "DICT") or as a list of
   *       key value pairs.  The extended header keywords
   *       will be accessible on the hdr.ext_header property
   *       after the file has been read.
   *
   * See http://nextmidas.techma.com/nm/nxm/sys/docs/MidasBlueFileFormat.pdf for
   * more details on header properties.
   *
   * @property {ArrayBuffer} buf
   * @property {object} options
   * @property {String} version - the header version extracted from the file, always 'BLUE'
   * @property {String} headrep - endianness of header 'IEEE' or 'EEEI'
   * @property {String} datarep - endianness of data 'IEEE' or 'EEEI'
   * @property {Number} ext_start - byte offset for extended header binary data
   * @property {Number} ext_size - byte size for extended header data
   * @property {Number} type - the BLUEFILE type (1000 = 1-D data, 2000 = 2-D data)
   * @property {Number} class - the BLUEFILE class (i.e. type/1000)
   * @property {String} format - the BLUEFILE format, the format is a two character diagraph, such as SF.
   * @property {Number} timecode - absolute time reference for the file (in seconds since Jan 1st 1950)
   * @property {Number} xstart - relative offset for the first sample on the x-axis
   * @property {Number} xdelta - delta between points on the x-axis
   * @property {Number} xunits - the unitcode for the x-axis (see m.UNITS)
   * @property {Number} ystart - relative offset for the first sample on the y-axis
   * @property {Number} ydelta - delta between points on the y-axis
   * @property {Number} yunits - the unitcode for the y-axis (see m.UNITS)
   * @property {Number} subsize - the number of columns for a 2-D data file
   * @property {Number} data_start - byte offset for data
   * @property {Number} data_size - byte size for data
   * @property {Object} ext_header - extracted extended header keywords
   * @property {Number} spa - scalars per atom
   * @property {Number} bps - bytes per scalar
   * @property {Number} bpa - bytes per atom
   * @property {Number} ape - atoms per element
   * @property {Number} bpe - bytes per element
   * @property {Number} size - number of elements in dview
   * @property {DataView} dview - a Data
   * @see {@link http://nextmidas.techma.com/nm/nxm/sys/docs/MidasBlueFileFormat.pdf}
   */
  constructor(buf, options) {
    if (options === undefined) {
      options = {};
    }
    this.options = Object.assign({ ext_header_type: 'dict' }, options);
    this.buf = buf;
    if (this.buf != null) {
      // Parse the header and keywords
      this.setHeader();
      const ds = this.data_start;
      const de = this.data_start + this.data_size;

      // Parse the data
      this.setData(this.buf, ds, de, this.littleEndianData);
    }
  }

  /**
   * Internal method to parse the 512 byte header
   * and unpack the extended header keywords
   *
   * @memberOf BlueHeader
   * @private
   */
  setHeader() {
    const dvhdr = new DataView(this.buf);
    this.version = ab2str(this.buf.slice(0, 4));
    this.headrep = ab2str(this.buf.slice(4, 8));
    this.datarep = ab2str(this.buf.slice(8, 12));
    const littleEndianHdr = this.headrep === 'EEEI';
    this.littleEndianData = this.datarep === 'EEEI';
    this.ext_start = dvhdr.getInt32(24, littleEndianHdr);
    this.ext_size = dvhdr.getInt32(28, littleEndianHdr);
    this.type = dvhdr.getUint32(48, littleEndianHdr);
    this['class'] = this.type / 1000;
    this.format = ab2str(this.buf.slice(52, 54));
    this.timecode = dvhdr.getFloat64(56, littleEndianHdr);
    // the adjunct starts at offset 0x100
    if (this['class'] === 1) {
      this.xstart = dvhdr.getFloat64(0x100, littleEndianHdr);
      this.xdelta = dvhdr.getFloat64(0x100 + 8, littleEndianHdr);
      this.xunits = dvhdr.getInt32(0x100 + 16, littleEndianHdr);
      this.yunits = dvhdr.getInt32(0x100 + 40, littleEndianHdr);
      this.subsize = 1;
    } else if (this['class'] === 2) {
      this.xstart = dvhdr.getFloat64(0x100, littleEndianHdr);
      this.xdelta = dvhdr.getFloat64(0x100 + 8, littleEndianHdr);
      this.xunits = dvhdr.getInt32(0x100 + 16, littleEndianHdr);
      this.subsize = dvhdr.getInt32(0x100 + 20, littleEndianHdr);
      this.ystart = dvhdr.getFloat64(0x100 + 24, littleEndianHdr);
      this.ydelta = dvhdr.getFloat64(0x100 + 32, littleEndianHdr);
      this.yunits = dvhdr.getInt32(0x100 + 40, littleEndianHdr);
    }
    this.data_start = dvhdr.getFloat64(32, littleEndianHdr);
    this.data_size = dvhdr.getFloat64(40, littleEndianHdr);
    if (this.ext_size) {
      this.ext_header = this.unpack_keywords(
        this.buf,
        this.ext_size,
        this.ext_start * 512,
        littleEndianHdr
      );
    }
  }

  /**
   * Internal method that sets the dview up based off the
   * provided buffer and fields extracted from the header.
   *
   * @private
   * @memberOf BlueHeader
   * @param {(ArrayBuffer|array)} buf
   * @param {number} offset
   * @param {number} data_end
   * @param {boolean?} littleEndian
   */
  setData(buf, offset, data_end, littleEndian) {
    if (littleEndian === undefined) {
      littleEndian = BlueHeader.ARRAY_BUFFER_ENDIANNESS === 'LE';
    }

    this.spa = BlueHeader._SPA[this.format[0]];
    this.bps = BlueHeader._BPS[this.format[1]];
    this.bpa = this.spa * this.bps;

    // atoms per element (ape) differs between
    // type 1000 and type 2000
    if (this['class'] === 1) {
      this.ape = 1;
    } else if (this['class'] === 2) {
      this.ape = this.subsize;
    }

    this.bpe = this.ape * this.bpa;

    // TODO handle mismatch between host and data endianness using arrayBufferEndianness
    const arrayBufferLittleEndian = BlueHeader.ARRAY_BUFFER_ENDIANNESS === 'LE';
    const arrayBufferBigEndian = BlueHeader.ARRAY_BUFFER_ENDIANNESS === 'BE';
    if (
      (arrayBufferLittleEndian && !littleEndian) ||
      (arrayBufferBigEndian && this.littleEndianData)
    ) {
      throw `Not supported ${BlueHeader.ARRAY_BUFFER_ENDIANNESS} ${littleEndian}`;
    }
    if (buf) {
      if (offset && data_end) {
        const length = (data_end - offset) / this.bps;
        this.dview = this.createArray(buf, offset, length);
      } else {
        this.dview = this.createArray(buf);
      }
      this.size = this.dview.length / (this.spa * this.ape);
    } else {
      this.dview = this.createArray(null, null, this.size);
    }
  }

  /**
   * Internal method that unpacks the extended header keywords into
   * either a object (i.e. dictionary) or a list of key-value pairs
   * depending on this.options.ext_header_type.
   *
   * @author Sean Sullivan https://github.com/desean1625
   * @private
   * @memberOf BlueHeader
   * @param {ArrayBuffer} buf - Buffer where the keywords are located
   * @param {number} lbuf - Size of the extended header
   * @param {number} offset - Offset from the extended header
   * @param {boolean} littleEndian - Whether or not to parse as little endian
   * @return {object|Array} Parsed keywords as an object from the header
   */
  unpack_keywords(buf, lbuf, offset, littleEndian) {
    let lkey, lextra, ltag, format, tag, data, ldata, itag, idata;
    const keywords = [];
    const dic_index = {};
    const dict_keywords = {};
    let ii = 0;
    buf = buf.slice(offset, buf.byteLength);
    const dvhdr = new DataView(buf);
    buf = ab2str(buf);
    while (ii < lbuf) {
      idata = ii + 8;
      lkey = dvhdr.getUint32(ii, littleEndian);
      lextra = dvhdr.getInt16(ii + 4, littleEndian);
      ltag = dvhdr.getInt8(ii + 6);
      format = buf.slice(ii + 7, ii + 8);
      ldata = lkey - lextra;
      itag = idata + ldata;
      tag = buf.slice(itag, itag + ltag);
      if (format === 'A') {
        data = buf.slice(idata, idata + ldata);
      } else if (BlueHeader._XM_TO_DATAVIEW[format]) {
        let parseFunc = BlueHeader._XM_TO_DATAVIEW[format];
        if (typeof parseFunc === 'string') {
          data = dvhdr[parseFunc](idata, littleEndian);
        } else {
          data = parseFunc(dvhdr, idata, littleEndian);
        }
      } else {
        // Should never get here.
        throw `Unsupported keyword format ${format} for tag ${tag}`;
      }

      if (typeof dic_index[tag] === 'undefined') {
        dic_index[tag] = 1;
      } else {
        dic_index[tag]++;

        // Force to string just in case the tag is interpreted as a number
        tag = '' + tag + dic_index[tag];
      }
      dict_keywords[tag] = data;
      keywords.push({
        tag: tag,
        value: data,
      });
      ii += lkey;
    }
    const dictTypes = ['dict', 'json', {}, 'XMTable', 'JSON', 'DICT'];
    const ext_header_type = this.options.ext_header_type;

    // Added because {} === {} is `false` in JS
    if (
      typeof ext_header_type === 'object' &&
      ext_header_type !== null &&
      Object.keys(ext_header_type).length === 0 &&
      ext_header_type.constructor === Object
    ) {
      return dict_keywords;
    }
    for (let k in dictTypes) {
      if (dictTypes[k] === ext_header_type) {
        return dict_keywords;
      }
    }
    return keywords;
  }

  /**
   * Internal method to create typed array for the data based on the
   * format extracted from the header.
   *
   * @private
   * @memberOf BlueHeader
   * @param buf
   * @param offset
   * @param length
   * @returns {array}
   */
  createArray(buf, offset, length) {
    const TypedArray = BlueHeader._XM_TO_TYPEDARRAY[this.format[1]];
    if (TypedArray === undefined) {
      throw `unknown format ${this.format[1]}`;
    }
    // backwards compatibility with some implementations of typed array
    // requires this
    if (offset === undefined) {
      offset = 0;
    }
    if (length === undefined) {
      length = buf.length || buf.byteLength / BlueHeader._BPS[this.format[1]];
    }
    let result;
    if (buf) {
      if (Array.isArray(buf) && Array.isArray(buf[0])) {
        // Flatten 2-D array into 1-D
        buf = [].concat.apply([], buf);
        length = buf.length * buf[0].length;
        result = new TypedArray(buf, offset, length);
      } else if (Array.isArray(buf) && ArrayBuffer.isView(buf[0])) {
        // Flatten 2-D array of TypedArrays
        length = buf.length * buf[0].length;
        result = new TypedArray(length);
        for (let ii = 0; ii < buf.length; ++ii) {
          result.set(buf[ii], ii * buf[0].length);
        }
      } else {
        // basic 1-D array
        result = new TypedArray(buf, offset, length);
      }
    } else {
      // no initial data
      result = new TypedArray(length);
    }
    return result;
  }
}

/**
 * @extends BaseFileReader
 */
class BlueFileReader extends BaseFileReader {
  /**
   * Bluefile Reader constructor.
   *
   * @memberof bluefile
   * @param {object?} options - options that affect how the bluefile is read
   * @param {string} options.ext_header_type="dict"
   *       if the BlueFile contains extended header keywords,
   *       extract them either as a dictionary ("dict", "json",
   *       {}, "XMTable", "JSON", "DICT") or as a list of
   *       key value pairs.  The extended header keywords
   *       will be accessible on the hdr.ext_header property
   *       after the file has been read.
   */
  constructor(options) {
    super(BlueHeader, options);
  }
}

export { BlueHeader, BlueFileReader };