Files
bwf-analyser/build/wav-convert.js
T
Vincent 2d0b7fe8b5 BWF Analyser: browser page and macOS app
Reads and edits BWF metadata for production sound. One source tree builds a
single self-contained page and a native Tauri app with a Rust audio engine and
WAV writer. Around 370 checks across seven test suites.

First commit of the existing state, so that from here every change can be
seen and undone.
2026-08-17 22:50:39 +08:00

1269 lines
44 KiB
JavaScript

/**
* 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("<TRACK>", at);
if (start === -1) break;
const end = list.indexOf("</TRACK>", start);
if (end === -1) break;
out.push(list.slice(start, end + "</TRACK>".length));
at = end + "</TRACK>".length;
}
return out;
}
function chosenTrack(xml, channel) {
const open = "<TRACK_LIST>";
const close = "</TRACK_LIST>";
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 <TRACK_COUNT>" + kept.length + "</TRACK_COUNT>\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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
const BLANK_IXML = '<?xml version="1.0" encoding="UTF-8"?>\n<BWFXML>\n' +
" <IXML_VERSION>1.5</IXML_VERSION>\n</BWFXML>\n";
/** Adds a track list to iXML that hasn't got one. */
function addTrackList(xml, tracks) {
let list = " <TRACK_LIST>\n <TRACK_COUNT>" + tracks.length + "</TRACK_COUNT>";
tracks.forEach((name, index) => {
list += "\n <TRACK><CHANNEL_INDEX>" + (index + 1) + "</CHANNEL_INDEX>" +
"<INTERLEAVE_INDEX>" + (index + 1) + "</INTERLEAVE_INDEX>" +
"<NAME>" + xmlEscape(name) + "</NAME></TRACK>";
});
list += "\n </TRACK_LIST>\n";
const at = xml.indexOf("</BWFXML>");
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 = "<TRACK_LIST>";
const close = "</TRACK_LIST>";
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 <TRACK_COUNT>" + tracks.length + "</TRACK_COUNT>";
tracks.forEach((name, index) => {
rebuilt += "\n <TRACK><CHANNEL_INDEX>" + (index + 1) + "</CHANNEL_INDEX>" +
"<INTERLEAVE_INDEX>" + (index + 1) + "</INTERLEAVE_INDEX>" +
"<NAME>" + xmlEscape(name) + "</NAME></TRACK>";
});
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,
};
}