/**
* 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 = "" + tag + ">";
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 = "" + tag + ">";
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("", 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 ";
});
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 ";
});
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,
};
}