All files audio.js

100% Statements 347/347
96.51% Branches 166/172
100% Functions 21/21
100% Lines 347/347

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 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 34815x 15x 15x 15x 15x 15x 15x 15x 15x 15x 15x 15x 15x 15x 15x 15x 15x 15x 15x 15x 15x 15x 15x 15x 15x 892x 892x 892x 892x 892x 15x 15x 15x 291x 291x 291x 291x 291x 291x 291x 4x 291x 15x 15x 15x 15x 15x 15x 15x 15x 15x 15x 15x 15x 15x 15x 15x 15x 112x 112x 110x 110x 110x 110x 110x 110x 110x 110x 110x 110x 110x 110x 110x 110x 112x 308x 308x 308x 308x 109x 109x 109x 109x 109x 109x 109x 109x 1x 1x 109x 308x 107x 107x 107x 107x 107x 107x 201x 201x 112x 112x 112x 112x 112x 15x 15x 15x 15x 211x 211x 211x 211x 211x 211x 15x 15x 4754x 4754x 4752x 4752x 4754x 92x 92x 92x 92x 4754x 4754x 4754x 15x 15x 25591570x 25591570x 25591570x 25591570x 25591570x 25591570x 15x 15x 200x 200x 15x 15x 15x 15x 87x 87x 87x 87x 15x 236x 236x 226x 236x 15x 15x 15x 33x 33x 33x 15x 123x 123x 113x 123x 15x 127x 127x 127x 127x 15x 15x 15x 15x 15x 15x 15x 15x 95x 95x 95x 95x 95x 95x 95x 95x 95x 95x 95x 95x 95x 95x 95x 95x 95x 95x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 95x 4x 4x 4x 88x 95x 95x 95x 95x 4x 4x 4x 4x 4x 4x 95x 95x 95x 95x 95x 95x 95x 95x 15x 15x 15x 37x 37x 37x 37x 37x 37x 15x 15x 15x 15x 15x 15x 15x 110x 110x 8x 8x 6x 6x 6x 6x 6x 8x 103x 110x 15x 15x 15x 197x 197x 15x 15x 15x 121x 121x 55865x 55865x 55864x 55865x 55865x 121x 121x 15x 15x 15x 13x 13x 13x 13x 4x 4x 3x 3x 12x 12x 12x 12x 48x 12x 12x 12x 12x 12x 12x 12x 12x 12x 12x 12x 12x 12x 12x 13x 12x 13x 15x 15x 15x 3x 3x 15x 15x 15x 15x 15x 15x 83x 83x 83x 83x 83x 83x 7x 7x 7x 83x 76x 76x 76x 83x 83x 2x 2x 2x 2x 80x 80x 80x 80x 80x 83x 83x 83x 83x  
// Copyright 2026 优谷雅言 open.shengzhiai.com. Licensed under the Apache License, Version 2.0.
// Audio input handling (C-06), WAV parsing and encoding, and the local precheck (C-04, DESIGN 2.9).
 
import { LOCAL_TABLE } from './error-table.js';
import { AudioQualityException, localError } from './errors.js';
import { isBlobLike, readBlob, toBytes } from './util.js';
 
/** Precheck modes. */
export const AudioPrecheck = Object.freeze({ OFF: 'OFF', WARN: 'WARN', REJECT: 'REJECT' });
 
/** Upload limit of one REST evaluation, same as the platform (50 MB). */
export const MAX_UPLOAD_BYTES = 50 * 1024 * 1024;
 
/** Audio limit of one streaming round, same as the platform (10 MB). */
export const MAX_STREAM_BYTES = 10 * 1024 * 1024;
 
const MIN_DURATION_MS = 1000;
const MAX_DURATION_MS = 300000;
const SILENT_PEAK = 200;
const SILENT_RMS = 30;
const LOW_VOLUME_DBFS = -45;
 
/** Codes that REJECT mode turns into an exception. 90104 stays a warning. */
const REJECTING = new Set([90101, 90102, 90103, 90105]);
 
function ascii(bytes, off, len) {
  let s = '';
  for (let i = 0; i < len && off + i < bytes.length; i++) s += String.fromCharCode(bytes[off + i]);
  return s;
}
 
/** Guess the container from magic bytes. */
export function sniffAudioContainer(bytes) {
  if (bytes.length >= 12 && ascii(bytes, 0, 4) === 'RIFF' && ascii(bytes, 8, 4) === 'WAVE') return 'wav';
  if (bytes.length >= 3 && ascii(bytes, 0, 3) === 'ID3') return 'mp3';
  if (bytes.length >= 2 && bytes[0] === 0xff && (bytes[1] & 0xe0) === 0xe0) return 'mp3';
  if (bytes.length >= 4 && ascii(bytes, 0, 4) === 'OggS') return 'ogg';
  if (bytes.length >= 4 && ascii(bytes, 0, 4) === 'fLaC') return 'flac';
  if (bytes.length >= 8 && ascii(bytes, 4, 4) === 'ftyp') return 'mp4';
  if (bytes.length >= 4 && bytes[0] === 0x1a && bytes[1] === 0x45 && bytes[2] === 0xdf && bytes[3] === 0xa3) return 'webm';
  return 'unknown';
}
 
const CONTAINER_INFO = {
  wav: { ext: 'wav', type: 'audio/wav' },
  mp3: { ext: 'mp3', type: 'audio/mpeg' },
  ogg: { ext: 'ogg', type: 'audio/ogg' },
  flac: { ext: 'flac', type: 'audio/flac' },
  mp4: { ext: 'm4a', type: 'audio/mp4' },
  webm: { ext: 'webm', type: 'audio/webm' },
  unknown: { ext: 'wav', type: 'application/octet-stream' },
};
 
/**
 * Parse a RIFF WAVE header by walking its chunks (LIST and other chunks before data are skipped).
 * Returns null when the bytes are not RIFF WAVE.
 */
export function parseWav(input) {
  const bytes = toBytes(input);
  if (!bytes || sniffAudioContainer(bytes) !== 'wav') return null;
  const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
  const info = {
    audioFormat: 0,
    channels: 0,
    sampleRate: 0,
    byteRate: 0,
    blockAlign: 0,
    bitsPerSample: 0,
    dataOffset: -1,
    dataLength: 0,
    isPcm16: false,
  };
  let off = 12;
  let haveFmt = false;
  while (off + 8 <= bytes.length) {
    const id = ascii(bytes, off, 4);
    const size = view.getUint32(off + 4, true);
    const body = off + 8;
    if (id === 'fmt ' && body + 16 <= bytes.length) {
      info.audioFormat = view.getUint16(body, true);
      info.channels = view.getUint16(body + 2, true);
      info.sampleRate = view.getUint32(body + 4, true);
      info.byteRate = view.getUint32(body + 8, true);
      info.blockAlign = view.getUint16(body + 12, true);
      info.bitsPerSample = view.getUint16(body + 14, true);
      // WAVE_FORMAT_EXTENSIBLE: the sub format GUID starts with the real format tag.
      if (info.audioFormat === 0xfffe && size >= 40 && body + 26 <= bytes.length) {
        info.audioFormat = view.getUint16(body + 24, true);
      }
      haveFmt = true;
    } else if (id === 'data') {
      info.dataOffset = body;
      const avail = bytes.length - body;
      // 0 and 0xFFFFFFFF are written by streaming encoders that never patched the header.
      info.dataLength = size === 0 || size === 0xffffffff || size > avail ? avail : size;
      break;
    }
    off = body + size + (size % 2);
  }
  if (!haveFmt) return Object.assign(info, { valid: false });
  info.isPcm16 = info.audioFormat === 1 && info.bitsPerSample === 16;
  info.valid = info.dataOffset >= 0;
  return info;
}
 
/** Running peak and RMS over little endian 16-bit samples, fed in chunks of any length. */
export class PcmStats {
  constructor() {
    this.bytes = 0;
    this.samples = 0;
    this.peak = 0;
    this.sumSquares = 0;
    this._carry = -1;
  }
 
  add(chunk) {
    const b = toBytes(chunk);
    if (!b || b.length === 0) return;
    this.bytes += b.length;
    let i = 0;
    if (this._carry >= 0) {
      this._sample((b[0] << 8) | this._carry);
      this._carry = -1;
      i = 1;
    }
    for (; i + 1 < b.length; i += 2) this._sample((b[i + 1] << 8) | b[i]);
    if (i < b.length) this._carry = b[i];
  }
 
  _sample(u16) {
    const s = u16 >= 0x8000 ? u16 - 0x10000 : u16;
    const a = s < 0 ? -s : s;
    if (a > this.peak) this.peak = a;
    this.sumSquares += s * s;
    this.samples += 1;
  }
 
  get rms() {
    return this.samples ? Math.sqrt(this.sumSquares / this.samples) : 0;
  }
}
 
/** Peak and RMS of PCM16 samples inside bytes[offset, offset + length). */
export function analyzePcm16(bytes, offset, length) {
  const st = new PcmStats();
  st.add(bytes.subarray(offset, offset + length));
  return { peak: st.peak, rms: st.rms, samples: st.samples };
}
 
function rmsToDbfs(rms) {
  if (rms <= 0) return -Infinity;
  return 20 * Math.log10(rms / 32768);
}
 
/** A local warning object {code, name, message}. */
export function localWarning(code) {
  const e = LOCAL_TABLE[code];
  return Object.freeze({ code, name: e ? e.name : String(code), message: e ? e.message : '' });
}
 
function levelWarnings(peak, rms, warnings) {
  if (peak < SILENT_PEAK && rms < SILENT_RMS) warnings.push(localWarning(90103));
  else if (rmsToDbfs(rms) < LOW_VOLUME_DBFS) warnings.push(localWarning(90104));
}
 
function durationWarnings(durationMs, size, maxBytes, warnings) {
  if (durationMs != null && durationMs < MIN_DURATION_MS) warnings.push(localWarning(90101));
  if ((durationMs != null && durationMs > MAX_DURATION_MS) || size > maxBytes) warnings.push(localWarning(90102));
}
 
/**
 * Local precheck of evaluate input (DESIGN 2.9).
 * WAV and raw PCM (format "pcm") get every check, other containers only the size check.
 * The size limit is maxBytes, by default the 50 MB REST upload limit.
 * Returns the analysis and the list of warnings. Does not throw; see applyPrecheck.
 */
export function precheckAudio(input, options) {
  const opts = options || {};
  const bytes = toBytes(input);
  if (!bytes) throw localError(90010, 'precheckAudio 需要 ArrayBuffer 或 Uint8Array');
  const maxBytes = typeof opts.maxBytes === 'number' && opts.maxBytes > 0 ? opts.maxBytes : MAX_UPLOAD_BYTES;
  const warnings = [];
  const report = {
    container: opts.format === 'pcm' ? 'pcm' : sniffAudioContainer(bytes),
    size: bytes.length,
    durationMs: null,
    sampleRate: null,
    channels: null,
    bitsPerSample: null,
    peak: null,
    rms: null,
    rmsDbfs: null,
    warnings,
  };
  if (report.container === 'pcm') {
    const sampleRate = opts.sampleRate || 16000;
    const channels = opts.channels || 1;
    report.sampleRate = sampleRate;
    report.channels = channels;
    report.bitsPerSample = 16;
    report.durationMs = (bytes.length / (2 * channels * sampleRate)) * 1000;
    durationWarnings(report.durationMs, bytes.length, maxBytes, warnings);
    const a = analyzePcm16(bytes, 0, bytes.length);
    Object.assign(report, { peak: a.peak, rms: a.rms, rmsDbfs: rmsToDbfs(a.rms) });
    levelWarnings(a.peak, a.rms, warnings);
    return report;
  }
  if (report.container !== 'wav') {
    if (bytes.length > maxBytes) warnings.push(localWarning(90102));
    return report;
  }
  const wav = parseWav(bytes);
  report.sampleRate = wav.sampleRate || null;
  report.channels = wav.channels || null;
  report.bitsPerSample = wav.bitsPerSample || null;
  if (!wav.valid || !wav.isPcm16 || wav.sampleRate < 16000) {
    warnings.push(localWarning(90105));
    const byteRate = wav.byteRate || wav.sampleRate * wav.blockAlign;
    if (wav.valid && byteRate > 0) report.durationMs = (wav.dataLength / byteRate) * 1000;
    durationWarnings(report.durationMs, bytes.length, maxBytes, warnings);
    return report;
  }
  const byteRate = wav.byteRate || wav.sampleRate * wav.channels * 2;
  report.durationMs = (wav.dataLength / byteRate) * 1000;
  durationWarnings(report.durationMs, bytes.length, maxBytes, warnings);
  const a = analyzePcm16(bytes, wav.dataOffset, wav.dataLength);
  Object.assign(report, { peak: a.peak, rms: a.rms, rmsDbfs: rmsToDbfs(a.rms) });
  levelWarnings(a.peak, a.rms, warnings);
  return report;
}
 
/** Precheck of streamed PCM16 accumulated in a PcmStats (no format check, 10 MB per round). */
export function precheckPcmStats(stats, sampleRate, maxBytes) {
  const warnings = [];
  const durationMs = (stats.bytes / (2 * (sampleRate || 16000))) * 1000;
  durationWarnings(durationMs, stats.bytes, maxBytes || MAX_STREAM_BYTES, warnings);
  levelWarnings(stats.peak, stats.rms, warnings);
  return { durationMs, size: stats.bytes, peak: stats.peak, rms: stats.rms, rmsDbfs: rmsToDbfs(stats.rms), warnings };
}
 
/**
 * Applies the precheck mode to a list of warnings.
 * OFF returns []. WARN returns the warnings. REJECT throws AudioQualityException for the first
 * rejecting code (90101, 90102, 90103, 90105) and returns the rest.
 */
export function applyPrecheck(warnings, mode) {
  if (mode === AudioPrecheck.OFF) return [];
  if (mode === AudioPrecheck.REJECT) {
    const bad = warnings.find((w) => REJECTING.has(w.code));
    if (bad) {
      throw new AudioQualityException(`音频预检未通过:${bad.message}`, {
        code: bad.code,
        localWarnings: warnings.slice(),
      });
    }
  }
  return warnings.slice();
}
 
/** True for the precheck modes. */
export function isPrecheckMode(m) {
  return m === AudioPrecheck.OFF || m === AudioPrecheck.WARN || m === AudioPrecheck.REJECT;
}
 
/** Float32 samples in [-1, 1] to Int16 (clamped). */
export function floatToInt16(float32) {
  const out = new Int16Array(float32.length);
  for (let i = 0; i < float32.length; i++) {
    let s = float32[i];
    if (s > 1) s = 1;
    else if (s < -1) s = -1;
    out[i] = s < 0 ? s * 0x8000 : s * 0x7fff;
  }
  return out;
}
 
/** PCM16 mono (or interleaved) samples to a WAV file as bytes. */
export function encodeWavBytes(pcm, sampleRate, channels) {
  const ch = channels || 1;
  const rate = sampleRate || 16000;
  let samples = pcm;
  if (!(pcm instanceof Int16Array)) {
    const b = toBytes(pcm);
    if (!b) throw localError(90010, 'encodeWav 需要 Int16Array、ArrayBuffer 或 Uint8Array');
    samples = new Int16Array(b.slice(0, b.length - (b.length % 2)).buffer);
  }
  const dataSize = samples.length * 2;
  const buffer = new ArrayBuffer(44 + dataSize);
  const v = new DataView(buffer);
  const w = (o, s) => {
    for (let i = 0; i < s.length; i++) v.setUint8(o + i, s.charCodeAt(i));
  };
  w(0, 'RIFF');
  v.setUint32(4, 36 + dataSize, true);
  w(8, 'WAVE');
  w(12, 'fmt ');
  v.setUint32(16, 16, true);
  v.setUint16(20, 1, true);
  v.setUint16(22, ch, true);
  v.setUint32(24, rate, true);
  v.setUint32(28, rate * ch * 2, true);
  v.setUint16(32, ch * 2, true);
  v.setUint16(34, 16, true);
  w(36, 'data');
  v.setUint32(40, dataSize, true);
  for (let i = 0, o = 44; i < samples.length; i++, o += 2) v.setInt16(o, samples[i], true);
  return new Uint8Array(buffer);
}
 
/** PCM16 samples to a WAV Blob (audio/wav). */
export function encodeWav(pcm, sampleRate, channels) {
  return new Blob([encodeWavBytes(pcm, sampleRate, channels)], { type: 'audio/wav' });
}
 
/**
 * Normalize caller audio (Blob, File, ArrayBuffer, any ArrayBufferView) to bytes plus a filename
 * and a content type. format "pcm" wraps raw PCM16 into a WAV container first.
 */
export async function resolveAudioInput(input, options, what) {
  const opts = options || {};
  const label = what || 'audio';
  let bytes;
  let name = null;
  let type = null;
  if (isBlobLike(input)) {
    bytes = new Uint8Array(await readBlob(input));
    if (typeof input.name === 'string' && input.name) name = input.name;
    if (typeof input.type === 'string' && input.type) type = input.type;
  } else {
    bytes = toBytes(input);
    if (!bytes) throw localError(90010, `${label} 必须是 Blob、File、ArrayBuffer 或 Uint8Array`);
  }
  if (bytes.length === 0) throw localError(90010, `${label} 为空`);
  if (opts.audioFormat === 'pcm') {
    bytes = encodeWavBytes(bytes, opts.sampleRate || 16000, 1);
    name = null;
    type = null;
  }
  const container = sniffAudioContainer(bytes);
  const info = CONTAINER_INFO[container];
  return {
    bytes,
    container,
    filename: opts.filename || name || `${label}.${info.ext}`,
    contentType: opts.contentType || type || info.type,
  };
}