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.
This commit is contained in:
2026-08-17 22:50:39 +08:00
commit 2d0b7fe8b5
50 changed files with 42383 additions and 0 deletions
+224
View File
@@ -0,0 +1,224 @@
/**
* Builds a synthetic BWF/WAVE file with fmt / bext / iXML / cue / LIST
* chunks, the way a location recorder would write one. Used both by the
* jsdom test harness and as a sample file to open in the real app.
*
* Usage: node make-sample.js out.wav [scene] [take]
*/
const fs = require("fs");
function chunk(id, body) {
const pad = body.length % 2;
const head = Buffer.alloc(8);
head.write(id, 0, 4, "latin1");
head.writeUInt32LE(body.length, 4);
return Buffer.concat([head, body, Buffer.alloc(pad)]);
}
function fixed(text, length) {
const buf = Buffer.alloc(length);
buf.write(String(text).slice(0, length), 0, "latin1");
return buf;
}
function build(opts) {
const sampleRate = opts.sampleRate || 48000;
const channels = opts.channels || 2;
const bits = opts.bits || 24;
const seconds = opts.seconds || 1;
const bytesPerSample = bits / 8;
const blockAlign = channels * bytesPerSample;
// 32-bit float is what a modern mixer-recorder writes, so the converter
// needs samples of that shape to be tested against. `extensible` wraps the
// same thing in WAVE_FORMAT_EXTENSIBLE, which is how plenty of recorders
// declare anything past two channels.
const float = !!opts.float;
const tag = opts.extensible ? 0xFFFE : (float ? 3 : 1);
const fmt = opts.extensible
? Buffer.alloc(40)
: Buffer.alloc(16);
fmt.writeUInt16LE(tag, 0);
fmt.writeUInt16LE(channels, 2);
fmt.writeUInt32LE(sampleRate, 4);
fmt.writeUInt32LE(sampleRate * blockAlign, 8);
fmt.writeUInt16LE(blockAlign, 12);
fmt.writeUInt16LE(bits, 14);
if (opts.extensible) {
fmt.writeUInt16LE(22, 16); // cbSize
fmt.writeUInt16LE(bits, 18); // valid bits
fmt.writeUInt32LE(channels === 2 ? 3 : 0, 20); // channel mask
fmt.writeUInt16LE(float ? 3 : 1, 24);
Buffer.from([0x00, 0x00, 0x00, 0x00, 0x10, 0x00, 0x80, 0x00,
0x00, 0xAA, 0x00, 0x38, 0x9B, 0x71]).copy(fmt, 26);
}
// A quiet sine so playback and the waveform have something to draw.
// `amplitude` above 1.0 is only meaningful for float, and is exactly the
// case that clips on the way to fixed point.
const amplitude = opts.amplitude === undefined ? 0.3 : opts.amplitude;
const frames = sampleRate * seconds;
const data = Buffer.alloc(frames * blockAlign);
for (let i = 0; i < frames; i++) {
const v = Math.sin((2 * Math.PI * 440 * i) / sampleRate) * amplitude;
for (let c = 0; c < channels; c++) {
// Each channel at its own level, so a split can be checked for
// having put the right audio in the right file. Channel 2 stays at
// half, which is what it always was.
const amp = v / (c + 1);
const off = i * blockAlign + c * bytesPerSample;
if (float && bits === 64) {
data.writeDoubleLE(amp, off);
} else if (float) {
data.writeFloatLE(amp, off);
} else if (bits === 32) {
data.writeInt32LE(Math.round(amp * 2147483647), off);
} else if (bits === 24) {
const s = Math.round(amp * 8388607);
data.writeIntLE(s, off, 3);
} else {
data.writeInt16LE(Math.round(amp * 32767), off);
}
}
}
// Speed block values are configurable so the frame-rate writer can be
// tested against realistic shapes: a complete block, one with no flag, one
// with a pull-down relationship between master and current.
const speed = opts.speed === undefined ? {} : opts.speed;
const speedEntries = [];
const pushSpeed = (tag, value) => {
if (value === null) return; // omit the element entirely
speedEntries.push(" <" + tag + ">" + value + "</" + tag + ">");
};
pushSpeed("MASTER_SPEED", speed.masterSpeed === undefined ? "25/1" : speed.masterSpeed);
pushSpeed("CURRENT_SPEED", speed.currentSpeed === undefined ? "25/1" : speed.currentSpeed);
pushSpeed("TIMECODE_RATE", speed.timecodeRate === undefined ? "25/1" : speed.timecodeRate);
pushSpeed("TIMECODE_FLAG", speed.timecodeFlag === undefined ? "NDF" : speed.timecodeFlag);
// 10:00:00:00 at 48k, in samples since midnight.
const tcSamples = opts.tcSamples === undefined ? 10 * 3600 * sampleRate : opts.tcSamples;
const bext = Buffer.concat([
fixed(opts.description || "sSPEED=025.000-ND", 256),
fixed("Sound Devices 833", 32),
fixed("SD833-001-" + (opts.take || 1), 32),
fixed("2026-08-12", 10),
fixed("09:41:12", 8),
(() => {
const b = Buffer.alloc(8);
b.writeUInt32LE(tcSamples >>> 0, 0);
b.writeUInt32LE(Math.floor(tcSamples / 4294967296), 4);
return b;
})(),
(() => {
const b = Buffer.alloc(2);
b.writeUInt16LE(2, 0);
return b;
})(),
Buffer.alloc(64), // UMID
(() => {
const b = Buffer.alloc(10); // loudness values
b.writeInt16LE(-230, 0);
b.writeInt16LE(-15, 2);
b.writeInt16LE(-40, 4);
b.writeInt16LE(70, 6);
b.writeInt16LE(-60, 8);
return b;
})(),
Buffer.alloc(180), // reserved
Buffer.from(opts.codingHistory === undefined
? "A=PCM,F=48000,W=24,M=stereo,T=833\r\n"
: opts.codingHistory, "latin1"),
]);
// One track per channel. The two-channel default is the pair the other
// tests already expect to find.
const trackNames = opts.tracks || (channels === 2
? ["Boom", "Lav Anna"]
: Array.from({ length: channels }, function (unused, i) { return "Track " + (i + 1); }));
const ixml = Buffer.from(
'<?xml version="1.0" encoding="UTF-8"?>\n' +
"<BWFXML>\n" +
" <IXML_VERSION>1.5</IXML_VERSION>\n" +
" <PROJECT>" + (opts.project || "Test Shoot") + "</PROJECT>\n" +
" <SCENE>" + (opts.scene === undefined ? "12A" : opts.scene) + "</SCENE>\n" +
" <TAKE>" + (opts.take === undefined ? 1 : opts.take) + "</TAKE>\n" +
" <TAPE>" + (opts.tape === undefined ? "26AUG12" : opts.tape) + "</TAPE>\n" +
" <CIRCLED>" + (opts.circled ? "TRUE" : "FALSE") + "</CIRCLED>\n" +
" <NOTE>" + (opts.note || "boom a bit hot") + "</NOTE>\n" +
" <SPEED>\n" +
" <NOTE></NOTE>\n" +
(speedEntries.length ? speedEntries.join("\n") + "\n" : "") +
" <FILE_SAMPLE_RATE>" + sampleRate + "</FILE_SAMPLE_RATE>\n" +
" <AUDIO_BIT_DEPTH>" + bits + "</AUDIO_BIT_DEPTH>\n" +
" <DIGITIZER_SAMPLE_RATE>" + sampleRate + "</DIGITIZER_SAMPLE_RATE>\n" +
" <TIMESTAMP_SAMPLES_SINCE_MIDNIGHT_HI>" + Math.floor(tcSamples / 4294967296) + "</TIMESTAMP_SAMPLES_SINCE_MIDNIGHT_HI>\n" +
" <TIMESTAMP_SAMPLES_SINCE_MIDNIGHT_LO>" + (tcSamples >>> 0) + "</TIMESTAMP_SAMPLES_SINCE_MIDNIGHT_LO>\n" +
" </SPEED>\n" +
" <TRACK_LIST>\n" +
" <TRACK_COUNT>" + trackNames.length + "</TRACK_COUNT>\n" +
trackNames.map(function (name, i) {
return " <TRACK><CHANNEL_INDEX>" + (i + 1) + "</CHANNEL_INDEX><INTERLEAVE_INDEX>" +
(i + 1) + "</INTERLEAVE_INDEX><NAME>" + name + "</NAME></TRACK>\n";
}).join("") +
" </TRACK_LIST>\n" +
"</BWFXML>\n" +
" ".repeat(opts.slack === undefined ? 512 : opts.slack),
"utf8"
);
const cuePoints = Buffer.alloc(4 + 24);
cuePoints.writeUInt32LE(1, 0);
cuePoints.writeUInt32LE(1, 4); // id
cuePoints.writeUInt32LE(0, 8); // position
cuePoints.write("data", 12, 4, "latin1");
cuePoints.writeUInt32LE(0, 16);
cuePoints.writeUInt32LE(0, 20);
cuePoints.writeUInt32LE(Math.floor(sampleRate / 2), 24);
const labl = chunk("labl", Buffer.concat([
(() => { const b = Buffer.alloc(4); b.writeUInt32LE(1, 0); return b; })(),
Buffer.from("slate\0", "latin1"),
]));
const adtl = chunk("LIST", Buffer.concat([Buffer.from("adtl", "latin1"), labl]));
const info = chunk("LIST", Buffer.concat([
Buffer.from("INFO", "latin1"),
chunk("ISFT", Buffer.from("BWF Analyser test harness\0", "latin1")),
chunk("INAM", Buffer.from("Scene " + (opts.scene || "12A") + "\0", "latin1")),
]));
// Anything the recorder wrote that we don't understand. Odd-sized on
// purpose in the tests: a chunk with an odd body needs a pad byte after it,
// and getting that wrong shifts every chunk that follows.
const extra = (opts.extra || []).map((pair) => chunk(pair[0], Buffer.from(pair[1], "latin1")));
const body = Buffer.concat([
Buffer.from("WAVE", "latin1"),
chunk("fmt ", fmt),
chunk("bext", bext),
chunk("iXML", ixml),
...extra,
chunk("data", data),
chunk("cue ", cuePoints),
adtl,
info,
]);
const riff = Buffer.alloc(8);
riff.write("RIFF", 0, 4, "latin1");
riff.writeUInt32LE(body.length, 4);
return Buffer.concat([riff, body]);
}
module.exports = { build };
if (require.main === module) {
const out = process.argv[2] || "sample.wav";
const scene = process.argv[3] || "12A";
const take = process.argv[4] || "1";
fs.writeFileSync(out, build({ scene: scene, take: Number(take), circled: take === "3" }));
console.log("wrote", out, fs.statSync(out).size, "bytes");
}