Files
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

375 lines
14 KiB
JavaScript

/**
* Frame-rate writing, against the standards rather than against one reader.
*
* A frame rate in a BWF file lives in up to five places, and different software
* reads different ones:
*
* iXML SPEED/TIMECODE_RATE rational — "30/1", 29.97 is 30000/1001
* iXML SPEED/TIMECODE_FLAG NDF or DF, and DF only means anything on the
* 1000/1001 rates
* iXML SPEED/MASTER_SPEED same rational, unless the file describes a
* iXML SPEED/CURRENT_SPEED pull-up/pull-down, in which case: hands off
* bext Description a recorder's own "aSPEED=025.000-ND" tag; bext
* has no frame-rate field of its own
*
* Each case here drives the real app through a real save, then reads the bytes
* back off disk.
*
* Run: npm i jsdom && node build/test-framerate.js
*/
const fs = require("fs");
const os = require("os");
const path = require("path");
const assert = require("assert");
const { JSDOM, VirtualConsole } = require("jsdom");
const { build } = require("./make-sample.js");
const INDEX = path.join(__dirname, "..", "mac-app", "dist", "index.html");
const root = fs.mkdtempSync(path.join(os.tmpdir(), "bwf-rate-"));
/* ------------------------------------------------------------------ */
/* Rust commands, mirrored (see test-tauri.js) */
/* ------------------------------------------------------------------ */
let dialogQueue = [];
function describe(filePath, relativePath) {
const stat = fs.statSync(filePath);
return {
path: filePath,
relativePath,
name: path.basename(filePath),
size: stat.size,
lastModified: Math.floor(stat.mtimeMs),
};
}
function hexDecode(hex) {
return Buffer.from(hex, "hex").toString("utf8");
}
function mockInvoke(realm) {
const toRealmBuffer = (buffer) => {
const view = new realm.Uint8Array(buffer.length);
view.set(buffer);
return view.buffer;
};
return function invoke(command, payload, options) {
if (command === "bwf_write") {
const headers = (options && options.headers) || {};
const target = hexDecode(headers["x-bwf-path"]);
const position = parseInt(headers["x-bwf-position"] || "0", 10);
const truncate = headers["x-bwf-truncate"] === "1";
const bytes = Buffer.from(payload.buffer ? new Uint8Array(payload) : payload);
const fd = fs.openSync(target, "r+");
fs.writeSync(fd, bytes, 0, bytes.length, position);
if (truncate) fs.ftruncateSync(fd, position + bytes.length);
fs.closeSync(fd);
return Promise.resolve(bytes.length);
}
if (command === "plugin:dialog|open") {
const next = dialogQueue.shift();
return Promise.resolve(next === undefined ? null : next);
}
if (command === "bwf_list_dir") {
return Promise.resolve(
fs.readdirSync(payload.path).sort()
.filter((name) => !name.startsWith("."))
.map((name) => ({
name,
path: path.join(payload.path, name),
kind: fs.statSync(path.join(payload.path, name)).isDirectory() ? "directory" : "file",
}))
);
}
if (command === "bwf_stat") {
return Promise.resolve(describe(payload.path, path.basename(payload.path)));
}
if (command === "bwf_read_range") {
const size = fs.statSync(payload.path).size;
if (payload.offset >= size || payload.length === 0) {
return Promise.resolve(toRealmBuffer(Buffer.alloc(0)));
}
const take = Math.min(payload.length, size - payload.offset);
const fd = fs.openSync(payload.path, "r");
const buffer = Buffer.alloc(take);
fs.readSync(fd, buffer, 0, take, payload.offset);
fs.closeSync(fd);
return Promise.resolve(toRealmBuffer(buffer));
}
if (command === "bwf_read_all") {
return Promise.resolve(toRealmBuffer(fs.readFileSync(payload.path)));
}
return Promise.reject(new Error("unexpected command " + command));
};
}
/* ------------------------------------------------------------------ */
/* Reading the result back off disk */
/* ------------------------------------------------------------------ */
function inspect(file) {
const d = fs.readFileSync(file);
let offset = 12;
const chunks = {};
while (offset + 8 <= d.length) {
const id = d.slice(offset, offset + 4).toString("latin1");
const size = d.readUInt32LE(offset + 4);
chunks[id] = { start: offset + 8, size };
offset += 8 + size + (size % 2);
}
const xml = chunks.iXML
? d.slice(chunks.iXML.start, chunks.iXML.start + chunks.iXML.size).toString("utf8")
: "";
const tag = (name) => {
const match = new RegExp("<" + name + ">([^<]*)</" + name + ">").exec(xml);
return match ? match[1] : null;
};
return {
bytes: d,
xml,
rate: tag("TIMECODE_RATE"),
flag: tag("TIMECODE_FLAG"),
master: tag("MASTER_SPEED"),
current: tag("CURRENT_SPEED"),
description: chunks.bext
? d.slice(chunks.bext.start, chunks.bext.start + 256).toString("latin1").replace(/\0.*$/, "")
: null,
audio: chunks.data
? d.slice(chunks.data.start, chunks.data.start + chunks.data.size)
: Buffer.alloc(0),
};
}
/* ------------------------------------------------------------------ */
/* One case: open a folder, set the rate (and flag), save */
/* ------------------------------------------------------------------ */
const results = [];
function check(name, fn) {
try {
fn();
results.push(["PASS", name]);
} catch (e) {
results.push(["FAIL", name + " — " + e.message]);
}
}
function waitFor(fn, label, timeout = 15000) {
return new Promise((resolve, reject) => {
const started = Date.now();
(function poll() {
let value;
try {
value = fn();
} catch (e) {
return reject(e);
}
if (value) return resolve(value);
if (Date.now() - started > timeout) return reject(new Error("timed out waiting for " + label));
setTimeout(poll, 30);
})();
});
}
async function runCase(label, fileOpts, choose) {
const dir = fs.mkdtempSync(path.join(root, label.replace(/\W+/g, "-") + "-"));
const file = path.join(dir, "A001.wav");
fs.writeFileSync(file, build(fileOpts));
const before = inspect(file);
const consoleErrors = [];
const virtualConsole = new VirtualConsole();
virtualConsole.on("jsdomError", (e) => consoleErrors.push(e.message));
const dom = new JSDOM(fs.readFileSync(INDEX, "utf8"), {
runScripts: "dangerously",
// jsdom treats a custom scheme as an opaque origin and then
// refuses localStorage, which would leave every persistence path
// untested. Tauri itself serves the app from this origin on Windows and
// from tauri://localhost on macOS; either way the app code is the same.
url: "http://tauri.localhost/",
pretendToBeVisual: true,
virtualConsole,
beforeParse(win) {
const ctxStub = new Proxy({}, {
get: (target, prop) => (prop === "canvas" ? null : () => {}),
set: () => true,
});
win.HTMLCanvasElement.prototype.getContext = () => ctxStub;
win.URL.createObjectURL = () => "blob:stub";
win.URL.revokeObjectURL = () => {};
win.__TAURI__ = {
core: { invoke: mockInvoke(win) },
event: { listen: () => Promise.resolve(() => {}) },
};
},
});
const { window } = dom;
await new Promise((r) => window.addEventListener("load", r));
const doc = window.document;
dialogQueue = [dir];
doc.querySelector("[data-bwfa-edit-folder]").dispatchEvent(
new window.MouseEvent("click", { bubbles: true }));
const status = doc.querySelector("[data-bwfa-status]");
await waitFor(() => /Done/i.test(status.textContent), "folder open");
const row = doc.querySelector("[data-bwfa-table-body] tr");
const detailsBtn = Array.from(row.querySelectorAll("button"))
.find((b) => /^(details|edit)$/i.test(b.textContent.trim()));
detailsBtn.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
await waitFor(() => doc.querySelector('[data-bwfa-edit-field="frameRate"]'), "edit form");
choose(doc, window);
const saveBtn = doc.querySelector("[data-bwfa-modal-save]");
saveBtn.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
await waitFor(() => /saved/i.test(saveBtn.textContent), "save (" + label + ")");
assert.strictEqual(consoleErrors.length, 0, consoleErrors.join(" | "));
window.close();
return { before, after: inspect(file) };
}
function setField(doc, window, key, value) {
const el = doc.querySelector('[data-bwfa-edit-field="' + key + '"]');
assert(el, "no field " + key);
el.value = value;
el.dispatchEvent(new window.Event("change", { bubbles: true }));
el.dispatchEvent(new window.Event("input", { bubbles: true }));
}
(async () => {
/* --- 25 -> 30 on a complete, well-formed file --- */
const plain = await runCase("25 to 30", {
description: "aSPEED=025.000-ND",
speed: { masterSpeed: "25/1", currentSpeed: "25/1", timecodeRate: "25/1", timecodeFlag: "NDF" },
}, (doc, win) => setField(doc, win, "frameRate", "30"));
check("the rate is written as a rational, not a decimal", () => {
assert.strictEqual(plain.after.rate, "30/1",
"TIMECODE_RATE is " + JSON.stringify(plain.after.rate));
});
check("MASTER_SPEED and CURRENT_SPEED follow the rate", () => {
assert.strictEqual(plain.after.master, "30/1", "MASTER_SPEED is " + plain.after.master);
assert.strictEqual(plain.after.current, "30/1", "CURRENT_SPEED is " + plain.after.current);
});
check("the recorder's own SPEED tag in bext is brought along", () => {
assert.strictEqual(plain.after.description, "aSPEED=030.000-ND",
"Description is " + JSON.stringify(plain.after.description));
});
check("the flag stays valid and the audio is untouched", () => {
assert.strictEqual(plain.after.flag, "NDF", "flag is " + plain.after.flag);
assert(plain.before.audio.equals(plain.after.audio), "audio bytes changed");
});
/* --- 25 -> 29.97 drop frame --- */
const drop = await runCase("25 to 29.97 DF", {
description: "sSPEED=025.000-NDF",
speed: { masterSpeed: "25/1", currentSpeed: "25/1", timecodeRate: "25/1", timecodeFlag: "NDF" },
}, (doc, win) => {
setField(doc, win, "frameRate", "29.97");
setField(doc, win, "frameRateFlag", "DF");
});
check("29.97 is written as 30000/1001", () => {
assert.strictEqual(drop.after.rate, "30000/1001", "rate is " + drop.after.rate);
assert.strictEqual(drop.after.master, "30000/1001", "master is " + drop.after.master);
});
check("drop frame is accepted on a 1000/1001 rate", () =>
assert.strictEqual(drop.after.flag, "DF", "flag is " + drop.after.flag));
check("the bext tag keeps the file's own NDF/DF spelling", () => {
// This file wrote the long form, so it gets the long form back.
assert.strictEqual(drop.after.description, "sSPEED=029.970-DF",
"Description is " + JSON.stringify(drop.after.description));
});
/* --- drop frame asked for on a rate that cannot drop frames --- */
const impossible = await runCase("DF on 25", {
description: "aSPEED=030.000-DF",
speed: { masterSpeed: "30/1", currentSpeed: "30/1", timecodeRate: "30/1", timecodeFlag: "DF" },
}, (doc, win) => {
setField(doc, win, "frameRate", "25");
setField(doc, win, "frameRateFlag", "DF");
});
check("drop frame is refused on a rate that can't drop frames", () => {
assert.strictEqual(impossible.after.rate, "25/1", "rate is " + impossible.after.rate);
assert.strictEqual(impossible.after.flag, "NDF",
"DF was accepted on 25fps, which is meaningless: flag is " + impossible.after.flag);
assert.strictEqual(impossible.after.description, "aSPEED=025.000-ND",
"Description is " + JSON.stringify(impossible.after.description));
});
/* --- a file with no flag at all, and almost no iXML slack: the write
has to grow the chunk, which means a full rebuild --- */
const grown = await runCase("no flag, no slack", {
description: "aSPEED=025.000-ND",
speed: { masterSpeed: null, currentSpeed: null, timecodeRate: "25/1", timecodeFlag: null },
slack: 4,
}, (doc, win) => setField(doc, win, "frameRate", "30"));
check("a missing flag is supplied, even when the chunk has to grow", () => {
assert.strictEqual(grown.after.rate, "30/1", "rate is " + grown.after.rate);
assert.strictEqual(grown.after.flag, "NDF", "flag is " + grown.after.flag);
assert.strictEqual(grown.after.master, "30/1", "master missing: " + grown.after.master);
assert(grown.before.audio.equals(grown.after.audio),
"audio changed during the rebuild path");
});
/* --- pull-down: master and current disagree, so they are not ours --- */
const pulldown = await runCase("pulldown untouched", {
description: "aSPEED=023.976-ND",
speed: { masterSpeed: "24/1", currentSpeed: "24000/1001", timecodeRate: "24/1", timecodeFlag: "NDF" },
}, (doc, win) => setField(doc, win, "frameRate", "25"));
check("a pull-down relationship is left alone", () => {
assert.strictEqual(pulldown.after.rate, "25/1", "rate is " + pulldown.after.rate);
assert.strictEqual(pulldown.after.master, "24/1",
"MASTER_SPEED was overwritten: " + pulldown.after.master);
assert.strictEqual(pulldown.after.current, "24000/1001",
"CURRENT_SPEED was overwritten: " + pulldown.after.current);
});
/* --- saving something unrelated must not touch the SPEED block --- */
const unrelated = await runCase("scene only", {
description: "aSPEED=025.000-ND",
speed: { masterSpeed: "25/1", currentSpeed: "25/1", timecodeRate: "25/1", timecodeFlag: "NDF" },
}, (doc, win) => setField(doc, win, "scene", "77"));
check("saving another field leaves the rate as it was", () => {
assert(/<SCENE>77<\/SCENE>/.test(unrelated.after.xml), "scene not written");
assert.strictEqual(unrelated.after.rate, "25/1",
"the rate was rewritten on an unrelated save: " + unrelated.after.rate);
assert.strictEqual(unrelated.after.description, "aSPEED=025.000-ND",
"Description touched on an unrelated save: " + JSON.stringify(unrelated.after.description));
});
console.log("");
results.forEach(([s, n]) => console.log((s === "PASS" ? " ok " : " FAIL") + " " + n));
const failed = results.filter(([s]) => s === "FAIL").length;
console.log("\n" + (results.length - failed) + "/" + results.length + " checks passed");
fs.rmSync(root, { recursive: true, force: true });
process.exit(failed ? 1 : 0);
})().catch((e) => {
console.error("harness error:", e);
process.exit(1);
});