All files precheck.js

100% Statements 205/205
94.94% Branches 94/99
100% Functions 14/14
100% Lines 205/205

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 2069x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 178x 178x 178x 178x 178x 178x 9x 9x 2505x 2505x 2504x 2504x 2504x 2505x 92x 92x 92x 92x 2504x 2504x 2504x 2505x 26409572x 26409572x 26409572x 26409572x 26409572x 26409572x 2504x 2504x 2504x 2505x 2505x 9x 9x 92x 92x 92x 92x 92x 9x 9x 278x 278x 9x 9x 59x 59x 59x 9x 262x 262x 262x 9x 9x 9x 9x 9x 9x 9x 159x 159x 60x 159x 159x 159x 159x 159x 159x 152x 152x 152x 159x 139x 139x 123x 139x 159x 159x 159x 9x 156x 156x 156x 156x 156x 156x 156x 156x 156x 156x 156x 156x 156x 156x 156x 156x 9x 9x 9x 101x 101x 101x 101x 97x 101x 101x 101x 101x 101x 101x 101x 101x 101x 101x 101x 101x 101x 93x 93x 93x 96x 101x 9x 9x 9x 9x 9x 9x 55x 55x 55x 55x 55x 55x 55x 55x 55x 55x 55x 55x 55x 9x 9x 9x 9x 9x 9x 9x 26x 26x 26x 26x 26x 3x 3x 3x 3x 22x 26x 9x 9x 9x 9x 9x 9x 128x 128x 5x 5x 4x 4x 4x 8x 4x 4x 4x 4x 4x 5x 1x 123x 128x  
// Local audio precheck (DESIGN 2.9). Thresholds of 90103 equal the platform's silence guard, so a file
// rejected here is one the platform would have scored 0.
import { LOCAL_TABLE } from './error-table.js';
import { createError, localError } from './errors.js';
import { parseWav, detectFormat } from './audio.js';
import { toUint8 } from './util.js';
import { MAX_UPLOAD_BYTES, MAX_STREAM_BYTES, MAX_AUDIO_SECONDS, MIN_AUDIO_SECONDS } from './constants.js';
 
export const AudioPrecheckMode = Object.freeze({ OFF: 'OFF', WARN: 'WARN', REJECT: 'REJECT' });
 
const SILENT_PEAK = 200;
const SILENT_RMS = 30;
const LOW_VOLUME_DBFS = -45;
const REJECT_CODES = [90101, 90102, 90103, 90105];
 
export function isPrecheckMode(v) {
  return v === 'OFF' || v === 'WARN' || v === 'REJECT';
}
 
/** Running peak and RMS of little-endian signed 16 bit samples, fed chunk by chunk. */
export class PcmStats {
  constructor() {
    this.samples = 0;
    this.peak = 0;
    this.sumSquares = 0;
    this.bytes = 0;
    this._carry = -1;
  }
 
  add(chunk) {
    const u8 = toUint8(chunk);
    if (!u8 || !u8.length) return;
    const n = u8.length;
    this.bytes += n;
    let i = 0;
    if (this._carry >= 0) {
      this._acc((((u8[0] << 8) | this._carry) << 16) >> 16);
      this._carry = -1;
      i = 1;
    }
    let peak = this.peak;
    let sum = 0;
    let count = 0;
    for (; i + 1 < n; i += 2) {
      const s = (((u8[i + 1] << 8) | u8[i]) << 16) >> 16;
      const a = s < 0 ? -s : s;
      if (a > peak) peak = a;
      sum += s * s;
      count++;
    }
    this.peak = peak;
    this.sumSquares += sum;
    this.samples += count;
    if (i < n) this._carry = u8[i];
  }
 
  _acc(s) {
    const a = s < 0 ? -s : s;
    if (a > this.peak) this.peak = a;
    this.sumSquares += s * s;
    this.samples++;
  }
 
  rms() {
    return this.samples ? Math.sqrt(this.sumSquares / this.samples) : 0;
  }
}
 
function warning(code) {
  return { code, message: LOCAL_TABLE[code].message };
}
 
function dbfs(rms) {
  return rms > 0 ? 20 * Math.log10(rms / 32768) : -Infinity;
}
 
/**
 * Evaluates the precheck rules on facts about the audio.
 * facts: {sizeBytes, maxBytes, durationSec, pcm16: boolean, stats: PcmStats|null, formatOk: boolean, sampleRate}
 * maxBytes: 50 MB for an upload (the default), 10 MB for one streaming round.
 */
export function precheckFacts(facts) {
  const codes = [];
  const add = (c) => {
    if (codes.indexOf(c) < 0) codes.push(c);
  };
  const maxBytes = typeof facts.maxBytes === 'number' ? facts.maxBytes : MAX_UPLOAD_BYTES;
  if (facts.sizeBytes > maxBytes) add(90102);
  if (facts.formatOk === false) add(90105);
  if (typeof facts.sampleRate === 'number' && facts.sampleRate > 0 && facts.sampleRate < 16000) add(90105);
  if (typeof facts.durationSec === 'number') {
    if (facts.durationSec < MIN_AUDIO_SECONDS) add(90101);
    if (facts.durationSec > MAX_AUDIO_SECONDS) add(90102);
  }
  if (facts.pcm16 && facts.stats && facts.stats.samples > 0) {
    const rms = facts.stats.rms();
    if (facts.stats.peak < SILENT_PEAK && rms < SILENT_RMS) add(90103);
    else if (dbfs(rms) < LOW_VOLUME_DBFS) add(90104);
  }
  codes.sort((a, b) => a - b);
  return codes.map(warning);
}
 
function report(format, facts, extra) {
  const stats = facts.stats;
  const rms = stats && stats.samples ? stats.rms() : null;
  return {
    format,
    sizeBytes: facts.sizeBytes,
    durationMs: typeof facts.durationSec === 'number' ? Math.round(facts.durationSec * 1000) : null,
    sampleRate: extra.sampleRate || null,
    channels: extra.channels || null,
    bitsPerSample: extra.bitsPerSample || null,
    peak: stats && stats.samples ? stats.peak : null,
    rms,
    rmsDbfs: rms === null ? null : dbfs(rms),
    warnings: precheckFacts(facts),
  };
}
 
/** Precheck of a WAV file or other container held in memory; maxBytes defaults to the 50 MB upload limit. */
export function analyzeContainer(bytes, format, maxBytes) {
  const b = toUint8(bytes);
  const limit = typeof maxBytes === 'number' ? maxBytes : MAX_UPLOAD_BYTES;
  const fmt = format || detectFormat(b) || 'unknown';
  if (fmt !== 'wav') return report(fmt, { sizeBytes: b.length, maxBytes: limit }, {});
  const wav = parseWav(b);
  if (!wav || !wav.ok) return report('wav', { sizeBytes: b.length, maxBytes: limit, formatOk: false }, {});
  const pcm16 = wav.audioFormat === 1 && wav.bitsPerSample === 16;
  const bytesPerSecond = wav.sampleRate * wav.channels * (wav.bitsPerSample / 8);
  const facts = {
    sizeBytes: b.length,
    maxBytes: limit,
    durationSec: bytesPerSecond > 0 ? wav.dataLength / bytesPerSecond : 0,
    formatOk: pcm16,
    sampleRate: wav.sampleRate,
    pcm16,
    stats: null,
  };
  if (pcm16) {
    facts.stats = new PcmStats();
    facts.stats.add(b.subarray(wav.dataOffset, wav.dataOffset + wav.dataLength));
  }
  return report('wav', facts, wav);
}
 
/**
 * Precheck of raw 16 bit PCM described by running stats: one streaming round at end() (limit 10 MB),
 * or PCM that will be uploaded when maxBytes says so.
 */
export function analyzePcmStats(stats, sampleRate, channels, maxBytes) {
  const sr = sampleRate || 16000;
  const ch = channels || 1;
  const facts = {
    sizeBytes: stats.bytes,
    maxBytes: typeof maxBytes === 'number' ? maxBytes : MAX_STREAM_BYTES,
    durationSec: stats.bytes / (sr * ch * 2),
    formatOk: true,
    sampleRate: sr,
    pcm16: true,
    stats,
  };
  return report('pcm', facts, { sampleRate: sr, channels: ch, bitsPerSample: 16 });
}
 
/**
 * Public precheck helper. input: WAV bytes, or raw PCM with options.format "pcm". The size limit is
 * the upload limit of 50 MB, or 10 MB with options.stream for the audio of one streaming round.
 * Returns {format, sizeBytes, durationMs, sampleRate, channels, bitsPerSample, peak, rms, rmsDbfs, warnings}.
 */
export function precheckAudio(input, options) {
  const o = options || {};
  const b = toUint8(input);
  if (!b) throw localError(90010, 'precheckAudio expects an ArrayBuffer or a typed array');
  const maxBytes = o.stream === true ? MAX_STREAM_BYTES : MAX_UPLOAD_BYTES;
  if (String(o.format || '').toLowerCase() === 'pcm' && detectFormat(b) !== 'wav') {
    const stats = new PcmStats();
    stats.add(b);
    return analyzePcmStats(stats, o.sampleRate, o.numberOfChannels, maxBytes);
  }
  return analyzeContainer(b, null, maxBytes);
}
 
/**
 * Applies a precheck mode to warnings. OFF: none. WARN: all are returned as warnings. REJECT: throws
 * AudioQualityException for 90101, 90102, 90103 and 90105, 90104 stays a warning.
 */
export function applyPrecheckMode(warnings, mode, init) {
  if (mode === 'OFF') return [];
  if (mode === 'REJECT') {
    for (let i = 0; i < warnings.length; i++) {
      if (REJECT_CODES.indexOf(warnings[i].code) >= 0) {
        const o = {};
        const src = init || {};
        Object.keys(src).forEach((k) => {
          o[k] = src[k];
        });
        o.code = warnings[i].code;
        o.warnings = warnings.slice();
        throw createError('AUDIO', 'audio precheck rejected the audio: ' + warnings[i].message, o);
      }
    }
  }
  return warnings.slice();
}