/** * Node mirror of src-tauri/src/convert.rs. * * The Rust is the shipping code; this is a line-for-line port of it so the * algorithm can be tested here, where there is no macOS toolchain. Anything * that changes in one has to change in the other, which is a real cost, and * the reason to pay it is that this is the only part of the app that rewrites * audio: a bug here is a damaged master, not a wrong label. * * Deliberately unclever. It reads whole chunks where the Rust streams them, * because the test files are small and mirroring the *arithmetic* is what * matters, not the buffering. */ const fs = require("fs"); const path = require("path"); const BEXT_FIXED = 602; const BEXT_LEVEL_FIELDS = [412, 416, 418, 420]; const LOUDNESS_UNSET = 0x7FFF; // Overridable so the RF64 promotion path can be tested without writing 4 GB. // The Rust has this as a constant; only the value moves, never the logic. let RIFF_LIMIT = 0xFFFFFFF0; const PCM_GUID = Buffer.from([ 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x10, 0x00, 0x80, 0x00, 0x00, 0xAA, 0x00, 0x38, 0x9B, 0x71, ]); const FLOAT_GUID = Buffer.from([ 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x10, 0x00, 0x80, 0x00, 0x00, 0xAA, 0x00, 0x38, 0x9B, 0x71, ]); function parse(file) { const buf = fs.readFileSync(file); if (buf.length < 12) throw new Error(file + ": not a WAV file (too short)"); const magic = buf.toString("latin1", 0, 4); const rf64 = magic === "RF64" || magic === "BW64"; if (magic !== "RIFF" && !rf64) throw new Error(file + ": not a RIFF/RF64 file"); if (buf.toString("latin1", 8, 12) !== "WAVE") throw new Error(file + ": not a WAVE file"); let ds64DataSize = null; const ds64Table = []; if (rf64) { if (buf.toString("latin1", 12, 16) !== "ds64") { throw new Error(file + ": RF64 file with no ds64 chunk"); } const size = buf.readUInt32LE(16); if (size < 28) throw new Error(file + ": ds64 chunk is too small"); ds64DataSize = Number(buf.readBigUInt64LE(28)); const tableLen = buf.readUInt32LE(44); for (let i = 0; i < tableLen; i++) { const at = 48 + i * 12; if (at + 12 > buf.length) break; ds64Table.push([buf.toString("latin1", at, at + 4), Number(buf.readBigUInt64LE(at + 4))]); } } const chunks = []; let at = 12; while (at + 8 <= buf.length && chunks.length < 4096) { const id = buf.toString("latin1", at, at + 4); const declared = buf.readUInt32LE(at + 4); let size = declared; if (rf64 && declared === 0xFFFFFFFF) { if (id === "data") { size = ds64DataSize || 0; } else { const found = ds64Table.find((entry) => entry[0] === id); size = found ? found[1] : declared; } } const body = at + 8; if (body + size > buf.length) { chunks.push({ id, offset: body, size: Math.max(0, buf.length - body) }); break; } chunks.push({ id, offset: body, size }); at = body + size + (size & 1); } const fmt = chunks.find((c) => c.id === "fmt "); if (!fmt) throw new Error(file + ": no fmt chunk"); if (fmt.size < 16) throw new Error(file + ": fmt chunk is too small"); const fmtBody = buf.subarray(fmt.offset, fmt.offset + Math.min(fmt.size, 4096)); const tag = fmtBody.readUInt16LE(0); const channels = fmtBody.readUInt16LE(2); const sampleRate = fmtBody.readUInt32LE(4); const blockAlign = fmtBody.readUInt16LE(12); const bits = fmtBody.readUInt16LE(14); const extensible = tag === 0xFFFE; if (extensible && fmtBody.length < 40) { throw new Error(file + ": extensible fmt chunk is truncated"); } const resolved = extensible ? fmtBody.readUInt16LE(24) : tag; let encoding; if (resolved === 1) encoding = "pcm"; else if (resolved === 3) encoding = "float"; else { throw new Error(file + ": audio is not PCM or IEEE float (format 0x" + resolved.toString(16).padStart(4, "0") + "), so it can't be converted"); } if (!channels || !blockAlign || !bits) throw new Error(file + ": fmt chunk describes no audio"); if (!sampleRate || sampleRate > 6144000) { throw new Error(file + ": sample rate of " + sampleRate + " is not real"); } if (bits % 8 !== 0 || blockAlign !== channels * (bits / 8)) { throw new Error(file + ": header contradicts itself (" + channels + " channels of " + bits + "-bit in a " + blockAlign + "-byte frame)"); } if (encoding === "float" && bits !== 32 && bits !== 64) { throw new Error(file + ": " + bits + "-bit float is not a thing"); } if (encoding === "pcm" && ![8, 16, 24, 32].includes(bits)) { throw new Error(file + ": " + bits + "-bit PCM is not supported"); } const data = chunks.find((c) => c.id === "data"); if (!data) throw new Error(file + ": no data chunk"); return { buf, rf64, chunks, encoding, extensible, fmtBody, channels, sampleRate, bits, blockAlign, dataOffset: data.offset, dataSize: data.size, frames: Math.floor(data.size / blockAlign), width: blockAlign / channels, }; } function formatName(encoding, bits) { return depthName(encoding === "float", bits); } function depthName(float, bits) { return float ? bits + "-bit float" : bits + "-bit PCM"; } /** * The format to write, as a pair: 32 bits means two different things and a * writer that has to guess which one will eventually guess wrong. */ function targetOf(bits, float) { const ok = float ? [32, 64].includes(bits) : [16, 24, 32].includes(bits); if (!ok) throw new Error(depthName(float, bits) + " is not an output format"); return { bits, float: !!float, width: bits / 8, encoding: float ? "float" : "pcm", name: depthName(float, bits), }; } /** Whether a source is already exactly this format. */ function targetMatches(target, source) { return source.encoding === target.encoding && source.bits === target.bits; } function decode(buf, at, encoding, bits) { if (encoding === "float") { return bits === 32 ? buf.readFloatLE(at) : buf.readDoubleLE(at); } if (bits === 8) return (buf[at] - 128) / 128; if (bits === 16) return buf.readInt16LE(at) / 32768; if (bits === 24) return buf.readIntLE(at, 3) / 8388608; return buf.readInt32LE(at) / 2147483648; } function encodeSample(value, target, out, at) { // Float has headroom above full scale by design, so nothing is clamped and // nothing is ever reported as clipped. if (target.float) { if (target.bits === 32) out.writeFloatLE(Math.fround(value), at); else out.writeDoubleLE(value, at); return false; } const scale = target.bits === 24 ? 8388608 : (target.bits === 32 ? 2147483648 : 32768); const scaled = roundHalfAway(value * scale); const clamped = Math.min(Math.max(scaled, -scale), scale - 1); if (target.bits === 24) out.writeIntLE(clamped, at, 3); else if (target.bits === 32) out.writeInt32LE(clamped, at); else out.writeInt16LE(clamped, at); return scaled !== clamped; } /** * Rust's f64::round takes a half away from zero; JavaScript's Math.round takes * it towards +Infinity. Half-integers are common — about half the samples in * the top octave of a float file land on one when scaled to 24-bit — so left * alone the mirror and the shipping converter would disagree on a quarter of a * loud take. */ function roundHalfAway(value) { return value < 0 ? -Math.round(-value) : Math.round(value); } /** 1 for integer PCM, 3 for IEEE float. */ function tagFor(target) { return target.float ? 3 : 1; } /** Header read, and optionally a full peak scan. */ function probe(file, scan) { const source = parse(file); let peak = -1; let nonFinite = 0; if (scan) { peak = 0; const samples = source.frames * source.channels; for (let i = 0; i < samples; i++) { const value = decode(source.buf, source.dataOffset + i * source.width, source.encoding, source.bits); if (Number.isFinite(value)) { const magnitude = Math.abs(value); if (magnitude > peak) peak = magnitude; } else { nonFinite++; } } } return { format: formatName(source.encoding, source.bits), bits: source.bits, sampleRate: source.sampleRate, channels: source.channels, frames: source.frames, rf64: source.rf64, peak, nonFinite, }; } function newFmt(source, target) { const wide = source.channels * target.width; if (wide > 0xFFFF) { throw new Error(source.channels + " channels of " + target.name + " is more than a WAV frame can describe"); } const blockAlign = wide; const byteRate = source.sampleRate * blockAlign; let body; if (source.extensible) { body = Buffer.from(source.fmtBody.subarray(0, 40)); body.writeUInt16LE(0xFFFE, 0); body.writeUInt16LE(22, 16); body.writeUInt16LE(target.bits, 18); (target.float ? FLOAT_GUID : PCM_GUID).copy(body, 24); } else { body = Buffer.from(source.fmtBody.subarray(0, 16)); body.writeUInt16LE(tagFor(target), 0); // Only WAVE_FORMAT_PCM may leave cbSize out. if (target.float) body = Buffer.concat([body, Buffer.alloc(2)]); } body.writeUInt32LE(byteRate, 8); body.writeUInt16LE(blockAlign, 12); body.writeUInt16LE(target.bits, 14); return body; } /** The channels to keep, 1-based, in output order — empty means all. */ function wantedChannels(channels, total) { if (!channels || !channels.length) { return Array.from({ length: total }, (unused, i) => i + 1); } channels.forEach((channel) => { if (channel < 1 || channel > total) { throw new Error("channel " + channel + " was asked for, but the file has " + total); } }); // Nothing stops a caller asking for the same channel over and over, and // enough repeats describe a frame no WAV header can hold. if (channels.length > 0xFFFF / 8) { throw new Error(channels.length + " channels is more than a WAV frame can hold"); } return channels.slice(); } function isEveryChannel(channels, total) { return channels.length === total && channels.every((c, i) => c === i + 1); } /** A plain 16-byte PCM fmt body for a given channel count. */ function monoStyleFmt(source, target, channels) { if (channels * target.width > 0xFFFF) { throw new Error(channels + " channels of " + target.name + " is more than a WAV frame can describe"); } const blockAlign = channels * target.width; // Float declares cbSize; integer PCM is the one format allowed to omit it. const body = Buffer.alloc(target.float ? 18 : 16); body.writeUInt16LE(tagFor(target), 0); body.writeUInt16LE(channels, 2); body.writeUInt32LE(source.sampleRate, 4); body.writeUInt32LE(source.sampleRate * blockAlign, 8); body.writeUInt16LE(blockAlign, 12); body.writeUInt16LE(target.bits, 14); return body; } function modeToken(channels) { if (channels === 1) return "mono"; if (channels === 2) return "stereo"; return "multichannel"; } function newBext(original, source, target, outChannels, gainDb, extra) { if (original.length < BEXT_FIXED) return Buffer.from(original); const fixed = Buffer.from(original.subarray(0, BEXT_FIXED)); const version = fixed.readUInt16LE(346); if (version >= 2 && gainDb !== 0) { for (const at of BEXT_LEVEL_FIELDS) { const value = fixed.readInt16LE(at); // Zero isn't the standard's "unset" marker, but it is what a // recorder that never measured loudness leaves behind. if (value === 0 || value === LOUDNESS_UNSET) continue; const moved = roundHalfAway(value + gainDb * 100); fixed.writeInt16LE(Math.min(Math.max(moved, -32768), 32767), at); } } let history = Buffer.from(original.subarray(BEXT_FIXED)); let end = history.length; while (end > 0 && history[end - 1] === 0) end--; history = history.subarray(0, end); let text = history.toString("latin1"); if (text.length && !text.endsWith("\r\n")) text += "\r\n"; // A= has no registered token for float, and W=32 beside A=PCM would read as // 32-bit integer, which is a different file. let line = "A=" + (target.float ? "FLOAT" : "PCM") + ",F=" + source.sampleRate + ",W=" + target.bits + ",M=" + modeToken(outChannels) + ",T=BWF Analyser: converted from " + formatName(source.encoding, source.bits); if (extra) line += extra; if (gainDb !== 0) { line += ", gain " + (gainDb >= 0 ? "+" : "") + gainDb.toFixed(2) + " dB"; } line += "\r\n"; return Buffer.concat([fixed, Buffer.from(text + line, "latin1")]); } function chunkOut(id, body) { const head = Buffer.alloc(8); head.write(id, 0, 4, "latin1"); head.writeUInt32LE(body.length > 0xFFFFFFFF ? 0xFFFFFFFF : body.length, 4); return body.length & 1 ? Buffer.concat([head, body, Buffer.alloc(1)]) : Buffer.concat([head, body]); } /** One converted copy. Mirrors convert::export. */ function exportFile(src, dest, bits, float, gain, overwrite, channels) { const target = targetOf(bits, float); if (!Number.isFinite(gain) || gain <= 0) throw new Error("gain must be a positive number"); if (fs.existsSync(dest) && !overwrite) throw new Error("bwf:exists"); if (fs.existsSync(dest) && fs.realpathSync(src) === fs.realpathSync(dest)) { throw new Error("bwf:same-file"); } fs.mkdirSync(path.dirname(dest), { recursive: true }); const source = parse(src); const sourceFormat = formatName(source.encoding, source.bits); const keep = wantedChannels(channels, source.channels); const every = isEveryChannel(keep, source.channels); if (targetMatches(target, source) && gain === 1 && every) { fs.copyFileSync(src, dest); return { bytes: fs.statSync(dest).size, copied: true, frames: source.frames, sourceBits: source.bits, targetBits: target.bits, targetFloat: target.float, sourceFormat, targetFormat: target.name, clipped: 0, }; } const gainDb = gain === 1 ? 0 : 20 * Math.log10(gain); const outWidth = target.width; const outAlign = keep.length * outWidth; // The audio. const audio = Buffer.alloc(source.frames * outAlign); let clipped = 0; for (let frame = 0; frame < source.frames; frame++) { const base = source.dataOffset + frame * source.blockAlign; keep.forEach((channel, position) => { let value = decode(source.buf, base + (channel - 1) * source.width, source.encoding, source.bits); value = Number.isFinite(value) ? value * gain : 0; if (encodeSample(value, target, audio, frame * outAlign + position * outWidth)) clipped++; }); } const fmtBody = every ? newFmt(source, target) : monoStyleFmt(source, target, keep.length); let bextBody = null; const bext = source.chunks.find((c) => c.id === "bext"); if (bext && bext.size > 0 && bext.size < 1024 * 1024) { const note = every ? "" : ", tracks " + keep.join(", ") + " of " + source.channels; bextBody = newBext(source.buf.subarray(bext.offset, bext.offset + bext.size), source, target, keep.length, gainDb, note); } // iXML states the word length too; a file whose fmt says 24 and whose iXML // says 32 contradicts itself. Only touched when the depth changes. let ixmlBody = null; const ixml = source.chunks.find((c) => c.id === "iXML"); // Past 8 MB it isn't metadata any more; it gets copied rather than parsed, // the same cap convert.rs::read_chunk applies. if (ixml && ixml.size <= 8 * 1024 * 1024 && (!targetMatches(target, source) || !every)) { const text = source.buf.subarray(ixml.offset, ixml.offset + ixml.size).toString("utf8"); const updated = every ? replaceElement(text, "AUDIO_BIT_DEPTH", String(target.bits)) : ixmlForChannels(text, target.bits, keep); if (updated !== null) ixmlBody = Buffer.from(updated, "utf8"); } const parts = []; let seenData = false; for (const chunk of source.chunks) { if (chunk.id === "ds64") continue; if (chunk.id === "fmt ") parts.push(["fmt ", fmtBody]); else if (chunk.id === "data") { // Only the first data chunk was parsed as audio; a second one in a // malformed file is dropped rather than written twice. if (seenData) continue; seenData = true; parts.push(["data", audio]); } else if (chunk.id === "bext" && bextBody) parts.push(["bext", bextBody]); else if (chunk.id === "iXML" && ixmlBody) parts.push(["iXML", ixmlBody]); else if (chunk.id === "levl") continue; else parts.push([chunk.id, source.buf.subarray(chunk.offset, chunk.offset + chunk.size)]); } let payload = 4; for (const [, body] of parts) payload += 8 + body.length + (body.length & 1); const rf64Out = payload + 8 > RIFF_LIMIT; if (rf64Out) payload += 8 + 28; const pieces = []; const head = Buffer.alloc(12); head.write(rf64Out ? "RF64" : "RIFF", 0, 4, "latin1"); head.writeUInt32LE(rf64Out ? 0xFFFFFFFF : payload, 4); head.write("WAVE", 8, 4, "latin1"); pieces.push(head); if (rf64Out) { const ds64 = Buffer.alloc(28); ds64.writeBigUInt64LE(BigInt(payload), 0); ds64.writeBigUInt64LE(BigInt(audio.length), 8); ds64.writeBigUInt64LE(BigInt(audio.length / outAlign), 16); ds64.writeUInt32LE(0, 24); pieces.push(chunkOut("ds64", ds64)); } for (const [id, body] of parts) pieces.push(chunkOut(id, body)); const temp = path.join(path.dirname(dest), "." + path.basename(dest) + ".bwfa-part"); fs.writeFileSync(temp, Buffer.concat(pieces)); fs.renameSync(temp, dest); return { bytes: fs.statSync(dest).size, copied: false, frames: source.frames, sourceBits: source.bits, targetBits: target.bits, targetFloat: target.float, sourceFormat, targetFormat: target.name, clipped, }; } /* ---- iXML: targeted surgery, or nothing at all ---- */ function replaceElement(xml, tag, value) { const open = "<" + tag + ">"; const close = ""; const at = xml.indexOf(open); if (at === -1) return null; const start = at + open.length; const end = xml.indexOf(close, start); if (end === -1) return null; return xml.slice(0, start) + value + xml.slice(end); } function elementText(xml, tag) { const open = "<" + tag + ">"; const close = ""; const at = xml.indexOf(open); if (at === -1) return null; const start = at + open.length; const end = xml.indexOf(close, start); if (end === -1) return null; return xml.slice(start, end).trim(); } function trackBlocks(list) { const out = []; let at = 0; for (;;) { const start = list.indexOf("", at); if (start === -1) break; const end = list.indexOf("", start); if (end === -1) break; out.push(list.slice(start, end + "".length)); at = end + "".length; } return out; } function chosenTrack(xml, channel) { const open = ""; const close = ""; const a = xml.indexOf(open); const b = xml.indexOf(close); if (a === -1 || b === -1 || b <= a) return null; const blocks = trackBlocks(xml.slice(a + open.length, b)); if (!blocks.length) return null; const wanted = String(channel + 1); const found = blocks.find((block) => elementText(block, "INTERLEAVE_INDEX") === wanted); return { start: a + open.length, end: b, block: found || blocks[channel] || null }; } function ixmlForChannels(original, bits, channels) { let xml = original; const depth = replaceElement(xml, "AUDIO_BIT_DEPTH", String(bits)); if (depth !== null) xml = depth; const first = chosenTrack(xml, channels[0] - 1); if (!first || !first.block) return xml; const kept = []; for (let i = 0; i < channels.length; i++) { const picked = chosenTrack(xml, channels[i] - 1); if (!picked || !picked.block) return xml; kept.push((replaceElement(picked.block, "INTERLEAVE_INDEX", String(i + 1)) || picked.block).trim()); } const rebuilt = "\n " + kept.length + "\n " + kept.join("\n ") + "\n "; return xml.slice(0, first.start) + rebuilt + xml.slice(first.end); } function trackName(ixmlText, channel) { if (!ixmlText) return null; const picked = chosenTrack(ixmlText, channel); if (!picked || !picked.block) return null; const name = elementText(picked.block, "NAME"); if (!name) return null; // CodingHistory is ASCII (EBU Tech 3285). Dropping the rest keeps this and // the Rust writing the same bytes for a track called "Lav Café". const ascii = name.replace(/[^\x20-\x7E]/g, "").trim(); return ascii ? ascii : null; } /** One mono file per channel, in one pass. Mirrors convert::export_split. */ function exportSplit(src, destDir, names, bits, float, gain, overwrite, channels) { const target = targetOf(bits, float); if (!Number.isFinite(gain) || gain <= 0) throw new Error("gain must be a positive number"); const source = parse(src); const keep = wantedChannels(channels, source.channels); if (names.length !== keep.length) { throw new Error(src + ": " + keep.length + " channels asked for but " + names.length + " names to write them under"); } names.forEach((name, index) => { if (!name || name.includes("/")) throw new Error(name + ": not a file name"); if (names.slice(0, index).includes(name)) { throw new Error(name + ": two channels can't share a name"); } }); fs.mkdirSync(destDir, { recursive: true }); const targets = names.map((name) => path.join(destDir, name)); if (!overwrite && targets.some((made) => fs.existsSync(made))) { throw new Error("bwf:exists"); } if (targets.some((made) => fs.existsSync(made) && fs.realpathSync(made) === fs.realpathSync(src))) { throw new Error("bwf:same-file"); } const gainDb = gain === 1 ? 0 : 20 * Math.log10(gain); const outWidth = target.width; const outData = source.frames * outWidth; const fmtBody = monoStyleFmt(source, target, 1); // Past this it isn't metadata any more, and it gets copied verbatim rather // than parsed. Same cap as convert.rs::read_chunk. const metadataCap = 8 * 1024 * 1024; const usable = (chunk) => chunk && chunk.size > 0 && chunk.size <= metadataCap; const bextChunk = source.chunks.find((c) => c.id === "bext"); const bextOriginal = usable(bextChunk) ? source.buf.subarray(bextChunk.offset, bextChunk.offset + bextChunk.size) : null; const ixmlChunk = source.chunks.find((c) => c.id === "iXML"); const ixmlText = usable(ixmlChunk) ? source.buf.subarray(ixmlChunk.offset, ixmlChunk.offset + ixmlChunk.size).toString("utf8") : null; // De-interleave once. const audio = []; for (let c = 0; c < keep.length; c++) audio.push(Buffer.alloc(outData)); let clipped = 0; for (let frame = 0; frame < source.frames; frame++) { const base = source.dataOffset + frame * source.blockAlign; keep.forEach((channel, position) => { let value = decode(source.buf, base + (channel - 1) * source.width, source.encoding, source.bits); value = Number.isFinite(value) ? value * gain : 0; if (encodeSample(value, target, audio[position], frame * outWidth)) clipped++; }); } let bytes = 0; for (let position = 0; position < keep.length; position++) { const c = keep[position] - 1; const parts = []; let seenData = false; for (const chunk of source.chunks) { if (chunk.id === "ds64" || chunk.id === "levl") continue; if (chunk.id === "fmt ") parts.push(["fmt ", fmtBody]); else if (chunk.id === "data") { if (seenData) continue; seenData = true; parts.push(["data", audio[position]]); } else if (chunk.id === "bext" && bextOriginal) { const name = trackName(ixmlText, c); const note = ", channel " + (c + 1) + " of " + source.channels + (name ? " (" + name + ")" : ""); // One channel out, so the history line says mono. parts.push(["bext", newBext(bextOriginal, source, target, 1, gainDb, note)]); } else if (chunk.id === "iXML" && ixmlText) { parts.push(["iXML", Buffer.from(ixmlForChannels(ixmlText, target.bits, [c + 1]), "utf8")]); } else { parts.push([chunk.id, source.buf.subarray(chunk.offset, chunk.offset + chunk.size)]); } } let payload = 4; for (const [, body] of parts) payload += 8 + body.length + (body.length & 1); // One 24-bit mono channel passes 4 GB at about eight hours, so this // needs the same promotion the poly path has. const rf64Out = payload + 8 > RIFF_LIMIT; if (rf64Out) payload += 8 + 28; const head = Buffer.alloc(12); head.write(rf64Out ? "RF64" : "RIFF", 0, 4, "latin1"); head.writeUInt32LE(rf64Out ? 0xFFFFFFFF : payload, 4); head.write("WAVE", 8, 4, "latin1"); const pieces = [head]; if (rf64Out) { const ds64 = Buffer.alloc(28); ds64.writeBigUInt64LE(BigInt(payload), 0); ds64.writeBigUInt64LE(BigInt(outData), 8); ds64.writeBigUInt64LE(BigInt(source.frames), 16); ds64.writeUInt32LE(0, 24); pieces.push(chunkOut("ds64", ds64)); } for (const [id, body] of parts) pieces.push(chunkOut(id, body)); const temp = path.join(destDir, "." + names[position] + ".bwfa-part"); fs.writeFileSync(temp, Buffer.concat(pieces)); fs.renameSync(temp, targets[position]); bytes += fs.statSync(targets[position]).size; } return { files: names.slice(), frames: source.frames, channels: keep.length, sourceBits: source.bits, targetBits: target.bits, targetFloat: target.float, sourceFormat: formatName(source.encoding, source.bits), targetFormat: target.name, clipped, bytes, }; } /* ------------------------------------------------------------------ */ /* Playback: the parts that can be wrong in a way you'd hear */ /* ------------------------------------------------------------------ */ /** * The waveform, bucketed. Mirrors convert::peaks, which streams the file * rather than decoding it whole, and has to agree column for column with the * browser build's own version so both draw the same picture. */ function peaks(file, buckets) { const source = parse(file); const channels = source.channels; const frames = source.frames; buckets = Math.min(Math.max(buckets, 1), 100000); const min = new Float32Array(buckets); const max = new Float32Array(buckets); if (!frames || !channels) { return { min, max, frames, sampleRate: source.sampleRate, channels, seconds: 0 }; } // The same walk the Rust does, and the same column boundaries the browser // build works out from a decoded buffer. const columns = Math.max(1, Math.min(buckets, frames)); const wideMin = new Float32Array(columns); const wideMax = new Float32Array(columns); const edge = (column) => Math.floor((column + 1) * frames / columns); let column = 0; let boundary = edge(0); for (let frame = 0; frame < frames; frame++) { while (column + 1 < columns && frame >= boundary) { column++; boundary = edge(column); } const base = source.dataOffset + frame * source.blockAlign; for (let c = 0; c < channels; c++) { const v = decode(source.buf, base + c * source.width, source.encoding, source.bits); if (v < wideMin[column]) wideMin[column] = v; if (v > wideMax[column]) wideMax[column] = v; } } for (let x = 0; x < buckets; x++) { const from = Math.floor(x * columns / buckets); min[x] = wideMin[from]; max[x] = wideMax[from]; } return { min, max, frames, sampleRate: source.sampleRate, channels, seconds: frames / source.sampleRate, }; } /** * Linear interpolation from the file's rate to the device's. Mirrors * play::resample. * * `carry` holds the last source frame of the previous call so a block * boundary interpolates across itself instead of restarting, and `position` * is where we are between frames. Both are carried in a state object. */ function resample(input, channels, ratio, state, out) { if (!channels) return; const work = new Float32Array(state.carry.length + input.length); work.set(state.carry, 0); work.set(input, state.carry.length); const frames = work.length / channels; if (frames < 2) { state.carry = work; return; } let at = state.position; while (Math.floor(at) + 1 < frames) { const index = Math.floor(at); const fraction = at - index; const here = index * channels; const next = here + channels; for (let c = 0; c < channels; c++) { const a = work[here + c]; const b = work[next + c]; out.push(a + (b - a) * fraction); } at += ratio; } const keep = Math.min(Math.floor(at), frames - 1); state.carry = work.slice(keep * channels, (keep + 1) * channels); state.position = at - keep; } function resampleState() { return { carry: new Float32Array(0), position: 0 }; } /** * One output frame: the enabled channels summed, then clamped. Mirrors the * body of Pump::fill, which is what the audio callback does per frame. */ function mixFrame(frame, gains) { let sum = 0; for (let c = 0; c < frame.length; c++) { sum += frame[c] * (gains[c] === undefined ? 1 : gains[c]); } return Math.min(1, Math.max(-1, sum)); } /** What the player shows: elapsed seconds from frames the device has taken. */ function elapsed(startFrames, playedFrames, deviceRate) { if (!deviceRate) return 0; return (startFrames + playedFrames) / deviceRate; } /** * In-place radix-2 FFT. Mirrors convert::fft, which is written by hand rather * than pulled in because nothing here can be compiled where it is written: a * dependency that can't be checked is worse than sixty lines that can. */ function fft(re, im) { const n = re.length; if (n < 2 || (n & (n - 1)) !== 0 || im.length !== n) return; let target = 0; for (let at = 0; at < n; at++) { if (target > at) { let t = re[at]; re[at] = re[target]; re[target] = t; t = im[at]; im[at] = im[target]; im[target] = t; } let mask = n >> 1; while (target & mask) { target &= ~mask; mask >>= 1; } target |= mask; } for (let span = 2; span <= n; span <<= 1) { const step = -2 * Math.PI / span; for (let start = 0; start < n; start += span) { for (let pair = 0; pair < span / 2; pair++) { const angle = step * pair; const cos = Math.cos(angle); const sin = Math.sin(angle); const a = start + pair; const b = a + span / 2; const tr = cos * re[b] - sin * im[b]; const ti = sin * re[b] + cos * im[b]; re[b] = re[a] - tr; im[b] = im[a] - ti; re[a] += tr; im[a] += ti; } } } } /** The slow, obviously-correct transform, for the tests to disagree with. */ function dft(input) { const n = input.length; const out = []; for (let k = 0; k < n; k++) { let re = 0; let im = 0; for (let t = 0; t < n; t++) { const angle = -2 * Math.PI * k * t / n; re += input[t] * Math.cos(angle); im += input[t] * Math.sin(angle); } out.push([re, im]); } return out; } function hann(size) { const out = new Float32Array(size); for (let i = 0; i < size; i++) { out[i] = 0.5 - 0.5 * Math.cos(2 * Math.PI * i / size); } return out; } const SPECTRO_FLOOR_DB = -100; /** Mirrors convert::spectrogram. */ function spectrogram(file, columns, window, gains) { columns = Math.min(Math.max(columns, 1), 4000); window = Math.min(Math.max(window, 64), 8192); const bins = window / 2; const source = parse(file); const channels = source.channels; const frames = source.frames; const cells = new Uint8Array(columns * bins); if (!frames || !channels) { return { columns, bins, seconds: 0, sampleRate: source.sampleRate, cells }; } const shape = hann(window); const re = new Float32Array(window); const im = new Float32Array(window); const last = Math.max(0, frames - window); for (let column = 0; column < columns; column++) { const at = columns === 1 ? 0 : Math.floor(last * column / (columns - 1)); for (let i = 0; i < window; i++) { let sum = 0; const frame = at + i; if (frame < frames) { for (let c = 0; c < channels; c++) { const gain = (gains && gains[c] !== undefined) ? gains[c] : 1; sum += decode(source.buf, source.dataOffset + frame * source.blockAlign + c * source.width, source.encoding, source.bits) * gain; } } re[i] = sum * shape[i]; im[i] = 0; } fft(re, im); for (let bin = 0; bin < bins; bin++) { const power = re[bin] * re[bin] + im[bin] * im[bin]; const magnitude = Math.sqrt(power) / (window / 4); const db = magnitude > 0 ? 20 * Math.log10(magnitude) : SPECTRO_FLOOR_DB; const lit = Math.min(1, Math.max(0, (db - SPECTRO_FLOOR_DB) / -SPECTRO_FLOOR_DB)); cells[column * bins + bin] = Math.round(lit * 255); } } return { columns, bins, seconds: frames / source.sampleRate, sampleRate: source.sampleRate, cells }; } function copyFile(src, dest, overwrite) { if (fs.existsSync(dest) && !overwrite) throw new Error("bwf:exists"); if (fs.existsSync(dest) && fs.realpathSync(src) === fs.realpathSync(dest)) { throw new Error("bwf:same-file"); } fs.mkdirSync(path.dirname(dest), { recursive: true }); fs.copyFileSync(src, dest); return fs.statSync(dest).size; } /** * Peak magnitude per channel across one block of interleaved frames, which is * what the audio callback raises into its meter cells. * * Pre-fader on purpose: a meter beside a track should say what is on the * track, not what the fader is doing to it. Mute a channel and the meter * carries on telling you there is content there. */ function channelPeaks( interleaved, channels ) { const peaks = new Array( channels ).fill( 0 ); if ( channels <= 0 ) { return peaks; } const frames = Math.floor( interleaved.length / channels ); for ( let frame = 0; frame < frames; frame++ ) { for ( let channel = 0; channel < channels; channel++ ) { const magnitude = Math.abs( interleaved[ frame * channels + channel ] ); if ( magnitude > peaks[ channel ] ) { peaks[ channel ] = magnitude; } } } return peaks; } module.exports = { channelPeaks, parse, probe, exportFile, exportSplit, copyFile, formatName, combine, combinePlan, targetOf, peaks, resample, resampleState, mixFrame, elapsed, fft, dft, spectrogram, /** Test hook: shrinks the point at which output is written as RF64. */ setRiffLimit: (value) => { RIFF_LIMIT = value; }, }; /* ------------------------------------------------------------------ */ /* Combining several files into one poly */ /* ------------------------------------------------------------------ */ const halfDay = (rate) => rate * 12 * 3600; const wholeDay = (rate) => rate * 24 * 3600; /** bext TimeReference, in samples since midnight, or null. */ function startOf(source) { const bext = source.chunks.find((c) => c.id === "bext"); if (!bext || bext.size < 346) return null; const samples = Number(source.buf.readBigUInt64LE(bext.offset + 338)); // Zero is what a recorder with no timecode writes; a file genuinely at // midnight lands at the start of the timeline either way. Anything past a // day isn't a time of day, and an unbounded offset is an unbounded file. if (samples === 0 || samples >= wholeDay(source.sampleRate)) return null; return samples; } function trackNamesOf(source, name) { const ixml = source.chunks.find((c) => c.id === "iXML"); const text = ixml ? source.buf.subarray(ixml.offset, ixml.offset + ixml.size).toString("utf8") : null; const names = []; for (let channel = 0; channel < source.channels; channel++) { names.push(trackName(text, channel) || (name + " " + (channel + 1))); } return names; } /** Where every file sits on one timeline. Mirrors convert::plan. */ function planCombine(sources, want) { if (sources.length < 2) throw new Error("combining takes more than one file"); const placed = []; const problems = []; const notes = []; let rate = 0; let channels = 0; let deepestFloat = 0; let deepestInt = 0; const formats = []; sources.forEach((file) => { let source; try { source = parse(file); } catch (e) { problems.push(e.message); return; } const name = path.basename(file); if (!rate) rate = source.sampleRate; else if (source.sampleRate !== rate) { problems.push(name + " is " + source.sampleRate + " Hz and the others are " + rate + " Hz — combining needs one rate"); } channels += source.channels; if (source.encoding === "float") deepestFloat = Math.max(deepestFloat, source.bits); else deepestInt = Math.max(deepestInt, source.bits); const thisFormat = formatName(source.encoding, source.bits); if (!formats.includes(thisFormat)) formats.push(thisFormat); placed.push({ path: file, name, channels: source.channels, frames: source.frames, start: startOf(source), offset: 0, tracks: trackNamesOf(source, name), }); }); if (channels > 0xFFFF / 8) { problems.push(channels + " channels is more than a WAV frame can hold"); } // What gives a midnight crossing away is where the space between the // timecodes is, not how wide the spread is: a night shoot leaves a hole of // most of a day in the middle, with takes bunched at either end of the // clock. An ordinary long day has no such hole and must be left alone. const known = placed.map((f) => f.start).filter((s) => s !== null).sort((a, b) => a - b); if (known.length > 1 && rate) { let widest = 0; let boundary = 0; for (let i = 1; i < known.length; i++) { const gap = known[i] - known[i - 1]; if (gap > widest) { widest = gap; boundary = known[i]; } } if (widest > halfDay(rate)) { placed.forEach((file) => { if (file.start !== null && file.start < boundary) file.start += wholeDay(rate); }); notes.push("Timecodes cross midnight; the small hours were read as the next day"); } } const starts = placed.map((f) => f.start).filter((s) => s !== null); const origin = starts.length ? Math.min(...starts) : 0; const unplaced = placed.filter((f) => f.start === null).map((f) => f.name); if (unplaced.length) { notes.push("No timecode in " + unplaced.join(", ") + " — placed at the start"); } placed.forEach((file) => { file.offset = file.start === null ? 0 : file.start - origin; }); const frames = placed.reduce((most, file) => Math.max(most, file.offset + file.frames), 0); if (rate && frames > wholeDay(rate)) { problems.push("Those timecodes span more than a day; nothing sensible comes out of that"); } else if (rate && frames > rate * 4 * 3600) { notes.push("That's " + Math.floor(frames / (rate * 3600)) + " hours end to end, most of it silence"); } // With nothing asked for, the output keeps what the sources are: float stays // float, and a set of integer files comes out at the deepest of them. // f32 carries a 24-bit significand, so a set mixing float with a 32-bit // integer file goes to 64-bit rather than quietly losing eight bits. const target = want || (deepestFloat ? targetOf(deepestFloat > 32 || deepestInt > 24 ? 64 : 32, true) : targetOf(Math.max(deepestInt, 16), false)); if (formats.length > 1 || (formats.length === 1 && formats[0] !== target.name)) { notes.push("Sources are " + formats.join(" and ") + "; the poly is " + target.name); } const plan = { sampleRate: rate, channels, frames, seconds: rate ? frames / rate : 0, origin, bytes: frames * channels * target.width, targetBits: target.bits, targetFloat: target.float, targetFormat: target.name, tracks: placed.reduce((all, file) => all.concat(file.tracks), []), problems, notes, }; return { placed, plan }; } /** `bits` of zero means "whatever the sources already are". */ function combinePlan(sources, bits, float) { return planCombine(sources, bits ? targetOf(bits, float) : null).plan; } function xmlEscape(text) { return String(text).replace(/&/g, "&").replace(//g, ">"); } const BLANK_IXML = '\n\n' + " 1.5\n\n"; /** Adds a track list to iXML that hasn't got one. */ function addTrackList(xml, tracks) { let list = " \n " + tracks.length + ""; tracks.forEach((name, index) => { list += "\n " + (index + 1) + "" + "" + (index + 1) + "" + "" + xmlEscape(name) + ""; }); list += "\n \n"; const at = xml.indexOf(""); return at === -1 ? xml : xml.slice(0, at) + list + xml.slice(at); } function ixmlForCombined(original, bits, tracks) { let xml = original; const depth = replaceElement(xml, "AUDIO_BIT_DEPTH", String(bits)); if (depth !== null) xml = depth; const open = ""; const close = ""; const a = xml.indexOf(open); const b = xml.indexOf(close); // No track list to replace: the file would otherwise ship the lead's names // for everybody's channels, which is worse than no names at all. if (a === -1 || b === -1 || b <= a) return addTrackList(xml, tracks); let rebuilt = "\n " + tracks.length + ""; tracks.forEach((name, index) => { rebuilt += "\n " + (index + 1) + "" + "" + (index + 1) + "" + "" + xmlEscape(name) + ""; }); rebuilt += "\n "; return xml.slice(0, a + open.length) + rebuilt + xml.slice(b); } /** One poly file from many, aligned by timecode. Mirrors convert::combine. */ function combine(sources, dest, bits, float, gain, overwrite) { const want = bits ? targetOf(bits, float) : null; if (!Number.isFinite(gain) || gain <= 0) throw new Error("gain must be a positive number"); const { placed, plan } = planCombine(sources, want); if (plan.problems.length) throw new Error(plan.problems[0]); const target = targetOf(plan.targetBits, plan.targetFloat); if (fs.existsSync(dest) && !overwrite) throw new Error("bwf:exists"); if (placed.some((file) => fs.existsSync(dest) && fs.realpathSync(file.path) === fs.realpathSync(dest))) { throw new Error("bwf:same-file"); } fs.mkdirSync(path.dirname(dest), { recursive: true }); const outWidth = target.width; const outAlign = plan.channels * outWidth; // Interleave everything, silence where a file hasn't started or has ended. const audio = Buffer.alloc(plan.frames * outAlign); let clipped = 0; let firstChannel = 0; placed.forEach((file) => { const source = parse(file.path); for (let frame = 0; frame < file.frames; frame++) { const base = source.dataOffset + frame * source.blockAlign; for (let channel = 0; channel < source.channels; channel++) { let value = decode(source.buf, base + channel * source.width, source.encoding, source.bits); value = Number.isFinite(value) ? value * gain : 0; const at = (file.offset + frame) * outAlign + (firstChannel + channel) * outWidth; if (encodeSample(value, target, audio, at)) clipped++; } } firstChannel += source.channels; }); // The metadata comes from the file that starts first: its timecode already // matches the output's start, and its markers are measured from it. const lead = placed.reduce((first, file) => (file.offset < first.offset ? file : first), placed[0]); const source = parse(lead.path); const gainDb = gain === 1 ? 0 : 20 * Math.log10(gain); const note = ", combined from " + placed.length + " files"; const fmtBody = monoStyleFmt(source, target, plan.channels); fmtBody.writeUInt32LE(plan.sampleRate, 4); fmtBody.writeUInt32LE(plan.sampleRate * outAlign, 8); const parts = []; let seenData = false; for (const chunk of source.chunks) { if (chunk.id === "ds64" || chunk.id === "levl") continue; if (chunk.id === "fmt ") parts.push(["fmt ", fmtBody]); else if (chunk.id === "data") { if (seenData) continue; seenData = true; parts.push(["data", audio]); } else if (chunk.id === "bext" && chunk.size >= 346) { const body = newBext(source.buf.subarray(chunk.offset, chunk.offset + chunk.size), source, target, plan.channels, gainDb, note); body.writeBigUInt64LE(BigInt(plan.origin), 338); parts.push(["bext", body]); } else if (chunk.id === "iXML") { const text = source.buf.subarray(chunk.offset, chunk.offset + chunk.size).toString("utf8"); parts.push(["iXML", Buffer.from(ixmlForCombined(text, target.bits, plan.tracks), "utf8")]); } else { parts.push([chunk.id, source.buf.subarray(chunk.offset, chunk.offset + chunk.size)]); } } // The lead is whichever file starts earliest, which is also where a file // with no timecode lands — so the file most likely to lead is the one most // likely to have no bext. A combined file with no TimeReference has lost // the one thing the alignment was for. if (!parts.some(([id]) => id === "bext")) { const blank = Buffer.alloc(BEXT_FIXED); blank.writeUInt16LE(1, 346); const made = newBext(blank, source, target, plan.channels, gainDb, note); made.writeBigUInt64LE(BigInt(plan.origin), 338); parts.splice(1, 0, ["bext", made]); } if (!parts.some(([id]) => id === "iXML")) { parts.push(["iXML", Buffer.from(ixmlForCombined(BLANK_IXML, target.bits, plan.tracks), "utf8")]); } let payload = 4; for (const [, body] of parts) payload += 8 + body.length + (body.length & 1); const rf64Out = payload + 8 > RIFF_LIMIT; if (rf64Out) payload += 8 + 28; const head = Buffer.alloc(12); head.write(rf64Out ? "RF64" : "RIFF", 0, 4, "latin1"); head.writeUInt32LE(rf64Out ? 0xFFFFFFFF : payload, 4); head.write("WAVE", 8, 4, "latin1"); const pieces = [head]; if (rf64Out) { const ds64 = Buffer.alloc(28); ds64.writeBigUInt64LE(BigInt(payload), 0); ds64.writeBigUInt64LE(BigInt(audio.length), 8); ds64.writeBigUInt64LE(BigInt(plan.frames), 16); ds64.writeUInt32LE(0, 24); pieces.push(chunkOut("ds64", ds64)); } for (const [id, body] of parts) pieces.push(chunkOut(id, body)); const temp = path.join(path.dirname(dest), "." + path.basename(dest) + ".bwfa-part"); fs.writeFileSync(temp, Buffer.concat(pieces)); fs.renameSync(temp, dest); return { bytes: fs.statSync(dest).size, frames: plan.frames, channels: plan.channels, sources: placed.length, targetBits: target.bits, targetFloat: target.float, targetFormat: target.name, clipped, seconds: plan.seconds, origin: plan.origin, }; }