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.
2318 lines
104 KiB
JavaScript
2318 lines
104 KiB
JavaScript
/**
|
||
* Exporting copies: 32-bit float to 24-bit, normalising, and above all not
|
||
* losing anything on the way.
|
||
*
|
||
* Two halves. The first drives the converter directly (build/wav-convert.js,
|
||
* the Node mirror of the Rust) and checks the arithmetic and the chunk
|
||
* bookkeeping. The second runs the real app in jsdom with those commands wired
|
||
* up, clicks through the export panel, and then reopens the exported folder in
|
||
* the app itself — which is the only check that really matters, because it uses
|
||
* the same parser the user will.
|
||
*
|
||
* Run: npm i jsdom && node build/test-export.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 wav = require("./wav-convert.js");
|
||
|
||
const INDEX = path.join(__dirname, "..", "mac-app", "dist", "index.html");
|
||
const STORAGE_KEY = "bwfa_last_folder";
|
||
|
||
/* ------------------------------------------------------------------ */
|
||
/* The playback engine, as far as the page can tell */
|
||
/* ------------------------------------------------------------------ */
|
||
|
||
/**
|
||
* Playback is Rust now, so the harness answers for it. The real engine owns a
|
||
* cpal stream and cannot run here, but everything the page does with it is
|
||
* commands out and one event back, and both of those are testable.
|
||
*/
|
||
function makePlayer(emit) {
|
||
const player = { state: null, calls: [] };
|
||
|
||
player.commands = {
|
||
bwf_peaks: (p) => {
|
||
const read = wav.peaks(p.path, p.buckets);
|
||
return Promise.resolve({
|
||
min: Array.from(read.min),
|
||
max: Array.from(read.max),
|
||
frames: read.frames,
|
||
sampleRate: read.sampleRate,
|
||
channels: read.channels,
|
||
seconds: read.seconds,
|
||
});
|
||
},
|
||
bwf_play: (p) => {
|
||
player.calls.push("play");
|
||
const read = wav.probe(p.path, false);
|
||
player.state = {
|
||
path: p.path,
|
||
seconds: p.offset || 0,
|
||
duration: read.frames / read.sampleRate,
|
||
channels: read.channels,
|
||
sampleRate: read.sampleRate,
|
||
gains: (p.gains || []).slice(),
|
||
playing: true,
|
||
};
|
||
player.report({});
|
||
return Promise.resolve();
|
||
},
|
||
bwf_pause: () => {
|
||
player.calls.push("pause");
|
||
if (player.state) player.state.playing = false;
|
||
player.report({});
|
||
return Promise.resolve();
|
||
},
|
||
bwf_resume: () => {
|
||
player.calls.push("resume");
|
||
if (player.state) player.state.playing = true;
|
||
player.report({});
|
||
return Promise.resolve();
|
||
},
|
||
bwf_stop: () => {
|
||
player.calls.push("stop");
|
||
player.state = null;
|
||
return Promise.resolve();
|
||
},
|
||
bwf_seek: (p) => {
|
||
player.calls.push("seek");
|
||
if (player.state) player.state.seconds = p.seconds;
|
||
player.report({});
|
||
return Promise.resolve();
|
||
},
|
||
bwf_gains: (p) => {
|
||
player.calls.push("gains");
|
||
if (player.state) player.state.gains = (p.gains || []).slice();
|
||
player.report({});
|
||
return Promise.resolve();
|
||
},
|
||
};
|
||
|
||
/** One status message, the shape convert's Status serialises to. */
|
||
player.report = (extra) => {
|
||
const at = player.state;
|
||
emit("bwf://playback", Object.assign({
|
||
playing: !!(at && at.playing),
|
||
seconds: at ? at.seconds : 0,
|
||
duration: at ? at.duration : 0,
|
||
channels: at ? at.channels : 0,
|
||
sampleRate: at ? at.sampleRate : 0,
|
||
ended: false,
|
||
reopened: false,
|
||
error: null,
|
||
}, extra || {}));
|
||
};
|
||
|
||
/** Moves the transport, the way the engine's tick does. */
|
||
player.advance = (seconds) => {
|
||
if (!player.state) return;
|
||
player.state.seconds = Math.min(player.state.duration, player.state.seconds + seconds);
|
||
player.report({});
|
||
};
|
||
|
||
player.finish = () => {
|
||
if (!player.state) return;
|
||
player.report({ seconds: player.state.duration, ended: true });
|
||
player.state = null;
|
||
};
|
||
|
||
/** The output went away and was rebuilt underneath a playing file. */
|
||
player.reopen = () => player.report({ reopened: true });
|
||
|
||
return player;
|
||
}
|
||
|
||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "bwf-export-"));
|
||
const results = [];
|
||
|
||
function check(name, fn) {
|
||
try {
|
||
fn();
|
||
results.push(["PASS", name]);
|
||
} catch (e) {
|
||
results.push(["FAIL", name + " — " + e.message]);
|
||
}
|
||
}
|
||
|
||
async function checkAsync(name, fn) {
|
||
try {
|
||
await fn();
|
||
results.push(["PASS", name]);
|
||
} catch (e) {
|
||
results.push(["FAIL", name + " — " + e.message]);
|
||
}
|
||
}
|
||
|
||
/** Every chunk in a file, as id/size pairs, in the order they appear. */
|
||
function chunkList(file) {
|
||
const parsed = wav.parse(file);
|
||
return parsed.chunks.map((c) => [c.id, c.size]);
|
||
}
|
||
|
||
function bodyOf(file, id) {
|
||
const parsed = wav.parse(file);
|
||
const chunk = parsed.chunks.find((c) => c.id === id);
|
||
assert(chunk, "no " + id + " chunk in " + path.basename(file));
|
||
return parsed.buf.subarray(chunk.offset, chunk.offset + chunk.size);
|
||
}
|
||
|
||
/* ------------------------------------------------------------------ */
|
||
/* The converter */
|
||
/* ------------------------------------------------------------------ */
|
||
|
||
const unit = path.join(root, "unit");
|
||
fs.mkdirSync(unit, { recursive: true });
|
||
|
||
const floatSrc = path.join(unit, "float.wav");
|
||
fs.writeFileSync(floatSrc, build({
|
||
float: true, bits: 32, seconds: 1, amplitude: 0.4,
|
||
extra: [["SMED", "seven!!"]], // odd body: needs a pad byte
|
||
}));
|
||
|
||
const out24 = path.join(unit, "out", "float.wav");
|
||
const converted = wav.exportFile(floatSrc, out24, 24, false, 1, false);
|
||
|
||
check("a 32-bit float file comes out as 24-bit PCM", () => {
|
||
const info = wav.probe(out24, false);
|
||
assert.strictEqual(info.format, "24-bit PCM", "got " + info.format);
|
||
assert.strictEqual(info.bits, 24);
|
||
assert.strictEqual(converted.sourceFormat, "32-bit float");
|
||
assert.strictEqual(converted.copied, false);
|
||
});
|
||
|
||
check("the sample rate, channel count and length are untouched", () => {
|
||
const before = wav.probe(floatSrc, false);
|
||
const after = wav.probe(out24, false);
|
||
assert.strictEqual(after.sampleRate, before.sampleRate, "sample rate moved");
|
||
assert.strictEqual(after.channels, before.channels, "channel count moved");
|
||
assert.strictEqual(after.frames, before.frames, "length moved");
|
||
});
|
||
|
||
check("fmt is rewritten consistently, not just relabelled", () => {
|
||
const fmt = bodyOf(out24, "fmt ");
|
||
assert.strictEqual(fmt.readUInt16LE(0), 1, "format tag is not PCM");
|
||
assert.strictEqual(fmt.readUInt16LE(14), 24, "bit depth");
|
||
assert.strictEqual(fmt.readUInt16LE(12), 6, "block align should be 2 channels x 3 bytes");
|
||
assert.strictEqual(fmt.readUInt32LE(8), 48000 * 6, "byte rate doesn't match the new block align");
|
||
});
|
||
|
||
check("every sample is the source value quantised, not something else", () => {
|
||
const source = wav.parse(floatSrc);
|
||
const result = wav.parse(out24);
|
||
// Spot-check across the file rather than all 96,000: the ones that matter
|
||
// are the peaks, where rounding and clamping happen.
|
||
for (const i of [0, 1, 12, 4999, 24000, 47999, 95999]) {
|
||
const before = source.buf.readFloatLE(source.dataOffset + i * 4);
|
||
const after = result.buf.readIntLE(result.dataOffset + i * 3, 3);
|
||
// Away from zero on a half, which is what Rust's f64::round does.
|
||
const scaled = before * 8388608;
|
||
const rounded = scaled < 0 ? -Math.round(-scaled) : Math.round(scaled);
|
||
const want = Math.min(Math.max(rounded, -8388608), 8388607);
|
||
assert.strictEqual(after, want, "sample " + i + ": got " + after + ", wanted " + want);
|
||
}
|
||
assert.strictEqual(converted.clipped, 0, "nothing should clip at 0.4 full scale");
|
||
});
|
||
|
||
check("bext, iXML and the recorder's own chunk all survive", () => {
|
||
const before = wav.parse(floatSrc);
|
||
const after = wav.parse(out24);
|
||
["SMED", "cue ", "LIST"].forEach((id) => {
|
||
const a = before.chunks.find((c) => c.id === id);
|
||
const b = after.chunks.find((c) => c.id === id);
|
||
assert(b, id + " is missing from the export");
|
||
assert.strictEqual(b.size, a.size, id + " changed size");
|
||
assert(before.buf.subarray(a.offset, a.offset + a.size)
|
||
.equals(after.buf.subarray(b.offset, b.offset + b.size)), id + " changed content");
|
||
});
|
||
});
|
||
|
||
check("iXML changes in exactly one place: the word length it states", () => {
|
||
const before = bodyOf(floatSrc, "iXML").toString("utf8");
|
||
const after = bodyOf(out24, "iXML").toString("utf8");
|
||
assert(/<AUDIO_BIT_DEPTH>32<\/AUDIO_BIT_DEPTH>/.test(before), "the sample lost its bit depth element");
|
||
assert(/<AUDIO_BIT_DEPTH>24<\/AUDIO_BIT_DEPTH>/.test(after),
|
||
"iXML still claims the old depth, so the file contradicts itself");
|
||
assert.strictEqual(after.replace("<AUDIO_BIT_DEPTH>24<", "<AUDIO_BIT_DEPTH>32<"), before,
|
||
"something else in the iXML moved as well");
|
||
});
|
||
|
||
check("the chunk order is the recorder's, including the ones after data", () => {
|
||
const before = chunkList(floatSrc).map((c) => c[0]);
|
||
const after = chunkList(out24).map((c) => c[0]);
|
||
assert.deepStrictEqual(after, before, "before: " + before + " / after: " + after);
|
||
});
|
||
|
||
check("an odd-sized chunk still gets its pad byte", () => {
|
||
// SMED is 7 bytes. If the pad were dropped, every chunk after it would be
|
||
// misaligned and the parse would have failed above — but assert the size
|
||
// itself as well, since a silently rounded-up size would also "work".
|
||
const smed = chunkList(out24).find((c) => c[0] === "SMED");
|
||
assert.strictEqual(smed[1], 7, "SMED came out as " + smed[1] + " bytes");
|
||
});
|
||
|
||
check("timecode is left exactly where it was", () => {
|
||
const before = bodyOf(floatSrc, "bext").subarray(338, 346);
|
||
const after = bodyOf(out24, "bext").subarray(338, 346);
|
||
assert(before.equals(after), "TimeReference changed");
|
||
});
|
||
|
||
check("bext keeps everything but gains a coding history line", () => {
|
||
const before = bodyOf(floatSrc, "bext");
|
||
const after = bodyOf(out24, "bext");
|
||
assert(before.subarray(0, 602).equals(after.subarray(0, 602)),
|
||
"the fixed part of bext changed with no gain applied");
|
||
const history = after.subarray(602).toString("latin1");
|
||
assert(history.startsWith("A=PCM,F=48000,W=24,M=stereo,T=833\r\n"),
|
||
"the recorder's own history line was lost: " + JSON.stringify(history));
|
||
assert(/A=PCM,F=48000,W=24,M=stereo,T=BWF Analyser: converted from 32-bit float\r\n$/.test(history),
|
||
"no conversion line was added: " + JSON.stringify(history));
|
||
});
|
||
|
||
/* ---- extensible ---- */
|
||
|
||
const extSrc = path.join(unit, "ext.wav");
|
||
fs.writeFileSync(extSrc, build({ float: true, bits: 32, extensible: true, seconds: 1 }));
|
||
const extOut = path.join(unit, "out", "ext.wav");
|
||
wav.exportFile(extSrc, extOut, 24, false, 1, false);
|
||
|
||
check("an extensible file stays extensible, with the PCM sub-format", () => {
|
||
const fmt = bodyOf(extOut, "fmt ");
|
||
assert.strictEqual(fmt.length, 40, "fmt came out " + fmt.length + " bytes");
|
||
assert.strictEqual(fmt.readUInt16LE(0), 0xFFFE, "format tag stopped being extensible");
|
||
assert.strictEqual(fmt.readUInt16LE(24), 1, "sub-format is not PCM");
|
||
assert.strictEqual(fmt.readUInt16LE(18), 24, "valid bits still says the old depth");
|
||
assert.strictEqual(fmt.readUInt32LE(20), 3, "the channel mask was lost");
|
||
});
|
||
|
||
/* ---- above full scale ---- */
|
||
|
||
const hotSrc = path.join(unit, "hot.wav");
|
||
fs.writeFileSync(hotSrc, build({ float: true, bits: 32, seconds: 1, amplitude: 1.5 }));
|
||
|
||
check("a float file above 0 dBFS is measured as such", () => {
|
||
const info = wav.probe(hotSrc, true);
|
||
assert(info.peak > 1.4 && info.peak < 1.6, "peak read as " + info.peak);
|
||
});
|
||
|
||
check("converting it flat does clip, and says so", () => {
|
||
const dest = path.join(unit, "out", "hot-flat.wav");
|
||
const result = wav.exportFile(hotSrc, dest, 24, false, 1, false);
|
||
assert(result.clipped > 0, "1.5 full scale into fixed point should clip");
|
||
});
|
||
|
||
check("turning it down first doesn't clip at all", () => {
|
||
const dest = path.join(unit, "out", "hot-safe.wav");
|
||
const peak = wav.probe(hotSrc, true).peak;
|
||
// The ceiling the panel uses: 8388607 of 8388608, one step short of 1.0.
|
||
// Aiming at 1.0 dead on would clip the peak sample by a single LSB.
|
||
const ceiling = 1 - Math.pow(2, -23);
|
||
const result = wav.exportFile(hotSrc, dest, 24, false, ceiling / peak, false);
|
||
assert.strictEqual(result.clipped, 0, result.clipped + " samples still clipped");
|
||
const after = wav.probe(dest, true);
|
||
assert(Math.abs(after.peak - 1) < 0.001, "peak came out at " + after.peak);
|
||
});
|
||
|
||
check("a gain move takes bext's level fields with it", () => {
|
||
const dest = path.join(unit, "out", "hot-loudness.wav");
|
||
wav.exportFile(hotSrc, dest, 24, false, 0.5, true);
|
||
const before = bodyOf(hotSrc, "bext");
|
||
const after = bodyOf(dest, "bext");
|
||
// -6.02 dB, in hundredths of a dB, off a starting -23.0 LUFS.
|
||
assert.strictEqual(after.readInt16LE(412), before.readInt16LE(412) - 602,
|
||
"LoudnessValue: " + after.readInt16LE(412));
|
||
assert.strictEqual(after.readInt16LE(416), before.readInt16LE(416) - 602,
|
||
"MaxTruePeakLevel: " + after.readInt16LE(416));
|
||
assert.strictEqual(after.readInt16LE(414), before.readInt16LE(414),
|
||
"LoudnessRange moved, but a range doesn't shift with gain");
|
||
assert(/gain -6\.02 dB/.test(after.subarray(602).toString("latin1")),
|
||
"the coding history doesn't mention the gain");
|
||
});
|
||
|
||
/* ---- files that need nothing done ---- */
|
||
|
||
const pcmSrc = path.join(unit, "already24.wav");
|
||
fs.writeFileSync(pcmSrc, build({ bits: 24, seconds: 1 }));
|
||
|
||
check("a 24-bit file that needs no gain is copied byte for byte", () => {
|
||
const dest = path.join(unit, "out", "already24.wav");
|
||
const result = wav.exportFile(pcmSrc, dest, 24, false, 1, false);
|
||
assert.strictEqual(result.copied, true, "it was rebuilt rather than copied");
|
||
assert(fs.readFileSync(pcmSrc).equals(fs.readFileSync(dest)), "the copy differs from the original");
|
||
});
|
||
|
||
check("16-bit stays 16-bit when it's only being normalised", () => {
|
||
const src = path.join(unit, "small.wav");
|
||
fs.writeFileSync(src, build({ bits: 16, seconds: 1 }));
|
||
const dest = path.join(unit, "out", "small.wav");
|
||
const result = wav.exportFile(src, dest, 16, false, 2, false);
|
||
assert.strictEqual(result.targetBits, 16);
|
||
assert.strictEqual(wav.probe(dest, false).bits, 16);
|
||
});
|
||
|
||
check("an existing file is refused unless replacing was asked for", () => {
|
||
const dest = path.join(unit, "out", "already24.wav");
|
||
assert.throws(() => wav.exportFile(pcmSrc, dest, 24, false, 1, false), /bwf:exists/);
|
||
wav.exportFile(pcmSrc, dest, 24, false, 1, true); // and allowed when it was
|
||
});
|
||
|
||
check("a half-written export leaves nothing behind", () => {
|
||
const leftovers = fs.readdirSync(path.join(unit, "out")).filter((n) => n.includes("bwfa-part"));
|
||
assert.deepStrictEqual(leftovers, [], "temp files left: " + leftovers);
|
||
});
|
||
|
||
/* ---- RF64 ---- */
|
||
|
||
check("an RF64 file is read through its ds64 chunk", () => {
|
||
// A real one runs past 4 GB; this is the same header shape at a size that
|
||
// fits in a test, which is what the parser actually keys off.
|
||
const plain = build({ float: true, bits: 32, seconds: 1 });
|
||
const dataAt = (() => {
|
||
const parsed = wav.parse(path.join(unit, "float.wav"));
|
||
return parsed.chunks.find((c) => c.id === "data");
|
||
})();
|
||
assert(dataAt, "no data chunk to model");
|
||
|
||
const src = path.join(unit, "rf64.wav");
|
||
const ds64 = Buffer.alloc(36);
|
||
ds64.write("ds64", 0, 4, "latin1");
|
||
ds64.writeUInt32LE(28, 4);
|
||
ds64.writeBigUInt64LE(BigInt(plain.length - 8 + 36), 8); // riff size
|
||
ds64.writeBigUInt64LE(BigInt(dataAt.size), 16); // data size
|
||
ds64.writeBigUInt64LE(BigInt(dataAt.size / 8), 24); // frames
|
||
ds64.writeUInt32LE(0, 32);
|
||
|
||
const body = Buffer.from(plain);
|
||
body.write("RF64", 0, 4, "latin1");
|
||
body.writeUInt32LE(0xFFFFFFFF, 4);
|
||
// Blank the data chunk's own size so it has to come from ds64.
|
||
const rebuilt = Buffer.concat([body.subarray(0, 12), ds64, body.subarray(12)]);
|
||
const at = rebuilt.indexOf(Buffer.from("data", "latin1"), 12);
|
||
rebuilt.writeUInt32LE(0xFFFFFFFF, at + 4);
|
||
fs.writeFileSync(src, rebuilt);
|
||
|
||
const info = wav.probe(src, false);
|
||
assert.strictEqual(info.rf64, true, "not recognised as RF64");
|
||
assert.strictEqual(info.frames, dataAt.size / 8, "frame count came from the wrong field");
|
||
|
||
const dest = path.join(unit, "out", "rf64.wav");
|
||
wav.exportFile(src, dest, 24, false, 1, false);
|
||
const after = wav.probe(dest, false);
|
||
assert.strictEqual(after.rf64, false, "a 24-bit copy of this size fits in RIFF");
|
||
assert.strictEqual(after.frames, info.frames, "frames changed");
|
||
assert.strictEqual(chunkList(dest).filter((c) => c[0] === "ds64").length, 0,
|
||
"a stale ds64 chunk was carried over");
|
||
});
|
||
|
||
check("a header that contradicts itself is refused, not guessed at", () => {
|
||
const src = path.join(unit, "broken.wav");
|
||
const buf = Buffer.from(fs.readFileSync(path.join(unit, "float.wav")));
|
||
const fmtAt = wav.parse(path.join(unit, "float.wav")).chunks.find((c) => c.id === "fmt ").offset;
|
||
buf.writeUInt32LE(0xFFFFFFFF, fmtAt + 4); // a sample rate no recorder wrote
|
||
fs.writeFileSync(src, buf);
|
||
assert.throws(() => wav.probe(src, false), /sample rate of/);
|
||
|
||
const wonky = path.join(unit, "wonky.wav");
|
||
const other = Buffer.from(fs.readFileSync(path.join(unit, "float.wav")));
|
||
other.writeUInt16LE(4, fmtAt + 12); // 2 channels of 32-bit in a 4-byte frame
|
||
fs.writeFileSync(wonky, other);
|
||
assert.throws(() => wav.probe(wonky, false), /contradicts itself/);
|
||
});
|
||
|
||
check("audio that isn't PCM or float is refused rather than mangled", () => {
|
||
const src = path.join(unit, "mp3ish.wav");
|
||
const buf = Buffer.from(build({ bits: 16, seconds: 1 }));
|
||
const parsed = wav.parse(path.join(unit, "small.wav"));
|
||
const fmtAt = parsed.chunks.find((c) => c.id === "fmt ").offset;
|
||
buf.writeUInt16LE(0x0055, fmtAt); // MPEG layer 3
|
||
fs.writeFileSync(src, buf);
|
||
assert.throws(() => wav.probe(src, false), /not PCM or IEEE float/);
|
||
});
|
||
|
||
/* ---- poly to mono ---- */
|
||
|
||
const polySrc = path.join(unit, "poly.wav");
|
||
fs.writeFileSync(polySrc, build({
|
||
float: true, bits: 32, channels: 4, seconds: 1, amplitude: 0.8,
|
||
tracks: ["Boom", "Lav Anna", "Plant FX", "Mix L"],
|
||
extra: [["SMED", "seven!!"]],
|
||
}));
|
||
const splitDir = path.join(unit, "split");
|
||
const monoNames = ["P_1_Boom.wav", "P_2_Lav_Anna.wav", "P_3_Plant_FX.wav", "P_4_Mix_L.wav"];
|
||
const split = wav.exportSplit(polySrc, splitDir, monoNames, 24, false, 1, false);
|
||
|
||
check("a four-channel poly file becomes four mono files", () => {
|
||
assert.deepStrictEqual(split.files, monoNames);
|
||
assert.deepStrictEqual(fs.readdirSync(splitDir).sort(), monoNames.slice().sort());
|
||
monoNames.forEach((name) => {
|
||
const info = wav.probe(path.join(splitDir, name), false);
|
||
assert.strictEqual(info.channels, 1, name + " came out with " + info.channels + " channels");
|
||
assert.strictEqual(info.bits, 24, name + " is " + info.bits + "-bit");
|
||
assert.strictEqual(info.frames, split.frames, name + " is a different length");
|
||
assert.strictEqual(info.sampleRate, 48000, name + " changed sample rate");
|
||
});
|
||
});
|
||
|
||
check("each file holds its own channel, not a copy of the first", () => {
|
||
// The sample writes channel n at 1/n of full level, so the peaks identify
|
||
// which channel ended up where.
|
||
const peaks = monoNames.map((name) => wav.probe(path.join(splitDir, name), true).peak);
|
||
peaks.forEach((peak, i) => {
|
||
const want = 0.8 / (i + 1);
|
||
assert(Math.abs(peak - want) < 0.002,
|
||
monoNames[i] + " peaks at " + peak.toFixed(4) + ", wanted " + want.toFixed(4));
|
||
});
|
||
});
|
||
|
||
check("the mono fmt chunk is plain PCM, sized for one channel", () => {
|
||
const fmt = bodyOf(path.join(splitDir, monoNames[1]), "fmt ");
|
||
assert.strictEqual(fmt.length, 16, "fmt is " + fmt.length + " bytes");
|
||
assert.strictEqual(fmt.readUInt16LE(0), 1, "not PCM");
|
||
assert.strictEqual(fmt.readUInt16LE(2), 1, "channel count");
|
||
assert.strictEqual(fmt.readUInt16LE(12), 3, "block align should be one 24-bit sample");
|
||
assert.strictEqual(fmt.readUInt32LE(8), 48000 * 3, "byte rate");
|
||
});
|
||
|
||
check("timecode is identical in every mono file", () => {
|
||
const want = bodyOf(polySrc, "bext").subarray(338, 346);
|
||
monoNames.forEach((name) => {
|
||
const got = bodyOf(path.join(splitDir, name), "bext").subarray(338, 346);
|
||
assert(want.equals(got), name + " has a different TimeReference");
|
||
});
|
||
});
|
||
|
||
check("bext is otherwise the recorder's own, with a line about the channel", () => {
|
||
const before = bodyOf(polySrc, "bext");
|
||
const after = bodyOf(path.join(splitDir, monoNames[2]), "bext");
|
||
assert(before.subarray(0, 602).equals(after.subarray(0, 602)), "the fixed part of bext changed");
|
||
const history = after.subarray(602).toString("latin1");
|
||
assert(history.startsWith("A=PCM,F=48000,W=24,M=stereo,T=833\r\n"), "the original history was lost");
|
||
assert(/M=mono,T=BWF Analyser: converted from 32-bit float, channel 3 of 4 \(Plant FX\)/.test(history),
|
||
"the new line doesn't describe a mono channel: " + JSON.stringify(history));
|
||
});
|
||
|
||
check("iXML describes one track: this one", () => {
|
||
const xml = bodyOf(path.join(splitDir, monoNames[2]), "iXML").toString("utf8");
|
||
const list = xml.match(/<TRACK_LIST>[\s\S]*?<\/TRACK_LIST>/)[0];
|
||
assert(/<TRACK_COUNT>1<\/TRACK_COUNT>/.test(list), "track count is still the poly one: " + list);
|
||
assert.strictEqual((list.match(/<TRACK>/g) || []).length, 1, "more than one track survived: " + list);
|
||
assert(/<CHANNEL_INDEX>3<\/CHANNEL_INDEX>/.test(list),
|
||
"the original input number was lost, and that's the provenance: " + list);
|
||
assert(/<INTERLEAVE_INDEX>1<\/INTERLEAVE_INDEX>/.test(list),
|
||
"interleave index should be 1 in a mono file: " + list);
|
||
assert(/<NAME>Plant FX<\/NAME>/.test(list), "the track name was lost: " + list);
|
||
assert(/<AUDIO_BIT_DEPTH>24<\/AUDIO_BIT_DEPTH>/.test(xml), "iXML still claims 32-bit");
|
||
assert(/<SCENE>12A<\/SCENE>/.test(xml), "the rest of the iXML went missing");
|
||
assert(/TIMESTAMP_SAMPLES_SINCE_MIDNIGHT_LO>/.test(xml), "the iXML timestamp went missing");
|
||
});
|
||
|
||
check("markers and unknown chunks come along too", () => {
|
||
const before = wav.parse(polySrc);
|
||
const after = wav.parse(path.join(splitDir, monoNames[0]));
|
||
["SMED", "cue ", "LIST"].forEach((id) => {
|
||
const a = before.chunks.find((c) => c.id === id);
|
||
const b = after.chunks.find((c) => c.id === id);
|
||
assert(b, id + " is missing");
|
||
assert(before.buf.subarray(a.offset, a.offset + a.size)
|
||
.equals(after.buf.subarray(b.offset, b.offset + b.size)), id + " changed");
|
||
});
|
||
assert.deepStrictEqual(after.chunks.map((c) => c.id), before.chunks.map((c) => c.id),
|
||
"the chunk order changed");
|
||
});
|
||
|
||
check("a poly file can come out with only the tracks you asked for", () => {
|
||
const dest = path.join(unit, "out", "subset.wav");
|
||
const result = wav.exportFile(polySrc, dest, 24, false, 1, false, [1, 3]);
|
||
assert.strictEqual(result.copied, false);
|
||
|
||
const info = wav.probe(dest, false);
|
||
assert.strictEqual(info.channels, 2, "got " + info.channels + " channels");
|
||
assert.strictEqual(info.frames, wav.probe(polySrc, false).frames, "length changed");
|
||
|
||
// Channel n was written at 1/n of full scale, so the peaks say which
|
||
// channels actually came through.
|
||
const parsed = wav.parse(dest);
|
||
const peakOf = (channel) => {
|
||
let peak = 0;
|
||
for (let frame = 0; frame < parsed.frames; frame++) {
|
||
const at = parsed.dataOffset + frame * parsed.blockAlign + channel * parsed.width;
|
||
peak = Math.max(peak, Math.abs(parsed.buf.readIntLE(at, 3) / 8388608));
|
||
}
|
||
return peak;
|
||
};
|
||
assert(Math.abs(peakOf(0) - 0.8) < 0.002, "first kept track: " + peakOf(0).toFixed(3));
|
||
assert(Math.abs(peakOf(1) - 0.8 / 3) < 0.002, "second kept track: " + peakOf(1).toFixed(3));
|
||
});
|
||
|
||
check("the subset's fmt and iXML describe the file that came out", () => {
|
||
const dest = path.join(unit, "out", "subset.wav");
|
||
const fmt = bodyOf(dest, "fmt ");
|
||
assert.strictEqual(fmt.length, 16, "a subset has no channel mask worth keeping");
|
||
assert.strictEqual(fmt.readUInt16LE(0), 1, "not plain PCM");
|
||
assert.strictEqual(fmt.readUInt16LE(2), 2, "channel count");
|
||
assert.strictEqual(fmt.readUInt16LE(12), 6, "block align should be 2 channels x 3 bytes");
|
||
|
||
const xml = bodyOf(dest, "iXML").toString("utf8");
|
||
const list = xml.match(/<TRACK_LIST>[\s\S]*?<\/TRACK_LIST>/)[0];
|
||
assert(/<TRACK_COUNT>2<\/TRACK_COUNT>/.test(list), "track count: " + list);
|
||
assert.strictEqual((list.match(/<TRACK>/g) || []).length, 2, "wrong number of tracks: " + list);
|
||
assert(/<CHANNEL_INDEX>1<\/CHANNEL_INDEX><INTERLEAVE_INDEX>1</.test(list),
|
||
"first track isn't at position 1: " + list);
|
||
assert(/<CHANNEL_INDEX>3<\/CHANNEL_INDEX><INTERLEAVE_INDEX>2</.test(list),
|
||
"the third track kept its old interleave index: " + list);
|
||
assert(/<NAME>Plant FX<\/NAME>/.test(list), "names went missing: " + list);
|
||
|
||
const history = bodyOf(dest, "bext").subarray(602).toString("latin1");
|
||
assert(/tracks 1, 3 of 4/.test(history), "the history doesn't say what was kept: " + history);
|
||
});
|
||
|
||
check("splitting can take a subset too", () => {
|
||
const dir = path.join(unit, "subset-split");
|
||
const result = wav.exportSplit(polySrc, dir, ["p_2.wav", "p_4.wav"], 24, false, 1, false, [2, 4]);
|
||
assert.strictEqual(result.channels, 2);
|
||
assert.deepStrictEqual(fs.readdirSync(dir).sort(), ["p_2.wav", "p_4.wav"]);
|
||
assert(Math.abs(wav.probe(path.join(dir, "p_2.wav"), true).peak - 0.4) < 0.002,
|
||
"p_2 holds the wrong channel");
|
||
assert(Math.abs(wav.probe(path.join(dir, "p_4.wav"), true).peak - 0.2) < 0.002,
|
||
"p_4 holds the wrong channel");
|
||
const list = bodyOf(path.join(dir, "p_4.wav"), "iXML").toString("utf8")
|
||
.match(/<TRACK_LIST>[\s\S]*?<\/TRACK_LIST>/)[0];
|
||
assert(/<CHANNEL_INDEX>4<\/CHANNEL_INDEX>/.test(list), "it lost which input it was: " + list);
|
||
});
|
||
|
||
check("a channel that isn't there is refused", () => {
|
||
assert.throws(() => wav.exportFile(polySrc, path.join(unit, "out", "nope.wav"), 24, false, 1, true, [9]),
|
||
/channel 9 was asked for/);
|
||
});
|
||
|
||
check("a split that would overwrite is refused as a whole", () => {
|
||
assert.throws(() => wav.exportSplit(polySrc, splitDir, monoNames, 24, false, 1, false), /bwf:exists/);
|
||
const leftovers = fs.readdirSync(splitDir).filter((n) => n.includes("bwfa-part"));
|
||
assert.deepStrictEqual(leftovers, [], "temp files left behind: " + leftovers);
|
||
});
|
||
|
||
check("a name that isn't a name is refused", () => {
|
||
assert.throws(() => wav.exportSplit(polySrc, splitDir, ["a/b.wav", "c.wav", "d.wav", "e.wav"], 24, false, 1, true),
|
||
/not a file name/);
|
||
assert.throws(() => wav.exportSplit(polySrc, splitDir, ["one.wav"], 24, false, 1, true),
|
||
/4 channels asked for but 1 names/);
|
||
// Both writers would truncate the same file, and the second rename would
|
||
// fail on a file that no longer exists.
|
||
assert.throws(() => wav.exportSplit(polySrc, splitDir, ["s.wav", "s.wav", "t.wav", "u.wav"], 24, false, 1, true),
|
||
/can't share a name/);
|
||
});
|
||
|
||
check("a split can't be written over the file it came from", () => {
|
||
assert.throws(
|
||
() => wav.exportSplit(polySrc, unit, ["poly.wav", "x.wav", "y.wav", "z.wav"], 24, false, 1, true),
|
||
/bwf:same-file/);
|
||
});
|
||
|
||
check("output past 4 GB is written as RF64, poly or mono", () => {
|
||
// The threshold is the only thing moved; a real one of these is 4 GB of
|
||
// audio, which is not a test file.
|
||
const source = path.join(unit, "big.wav");
|
||
fs.writeFileSync(source, build({ float: true, bits: 32, channels: 2, seconds: 1 }));
|
||
const out = path.join(unit, "rf64out");
|
||
fs.mkdirSync(out, { recursive: true });
|
||
wav.setRiffLimit(1000);
|
||
try {
|
||
wav.exportFile(source, path.join(out, "poly.wav"), 24, false, 1, false);
|
||
wav.exportSplit(source, out, ["mono_1.wav", "mono_2.wav"], 24, false, 1, false);
|
||
} finally {
|
||
wav.setRiffLimit(0xFFFFFFF0);
|
||
}
|
||
|
||
// A subset written as RF64 too: the frame count in ds64 has its own
|
||
// arithmetic, and it used to be divided by the source's channel count.
|
||
wav.setRiffLimit(1000);
|
||
try {
|
||
wav.exportFile(source, path.join(out, "subset.wav"), 24, false, 1, false, [1]);
|
||
} finally {
|
||
wav.setRiffLimit(0xFFFFFFF0);
|
||
}
|
||
const subset = wav.probe(path.join(out, "subset.wav"), false);
|
||
assert.strictEqual(subset.rf64, true, "the subset stayed RIFF past the limit");
|
||
assert.strictEqual(subset.channels, 1, "the subset came out with " + subset.channels + " channels");
|
||
assert.strictEqual(subset.frames, 48000, "the subset lost its length: " + subset.frames);
|
||
|
||
["poly.wav", "mono_1.wav", "mono_2.wav"].forEach((name) => {
|
||
const file = path.join(out, name);
|
||
const info = wav.probe(file, false);
|
||
assert.strictEqual(info.rf64, true, name + " stayed RIFF past the limit");
|
||
assert.strictEqual(info.frames, 48000, name + " lost its length: " + info.frames);
|
||
assert.strictEqual(chunkList(file)[0][0], "ds64", name + " has no ds64 first");
|
||
// The real proof: the size in the header has to be findable again.
|
||
const parsed = wav.parse(file);
|
||
const data = parsed.chunks.find((c) => c.id === "data");
|
||
assert.strictEqual(data.size, info.frames * (name === "poly.wav" ? 6 : 3),
|
||
name + " data size came back as " + data.size);
|
||
});
|
||
});
|
||
|
||
/* ---- combining, aligned by timecode ---- */
|
||
|
||
const combineDir = path.join(unit, "combine");
|
||
fs.mkdirSync(combineDir, { recursive: true });
|
||
const RATE = 48000;
|
||
const TEN = 10 * 3600 * RATE; // 10:00:00:00 in samples
|
||
|
||
// Boom from 10:00:00:00, Lav two seconds later. One second each.
|
||
fs.writeFileSync(path.join(combineDir, "A.wav"),
|
||
build({ channels: 1, bits: 24, seconds: 1, tcSamples: TEN, tracks: ["Boom"], amplitude: 0.5 }));
|
||
fs.writeFileSync(path.join(combineDir, "B.wav"),
|
||
build({ channels: 1, bits: 24, seconds: 1, tcSamples: TEN + 2 * RATE, tracks: ["Lav Anna"], amplitude: 0.25 }));
|
||
|
||
const combined = path.join(combineDir, "out.wav");
|
||
const combineSources = [path.join(combineDir, "A.wav"), path.join(combineDir, "B.wav")];
|
||
const combineResult = wav.combine(combineSources, combined, 24, false, 1, false);
|
||
|
||
/** The loudest sample on one channel over a range of frames. */
|
||
function peakOfChannel(file, channel, from, to) {
|
||
const parsed = wav.parse(file);
|
||
let peak = 0;
|
||
for (let frame = from; frame < to; frame++) {
|
||
const at = parsed.dataOffset + frame * parsed.blockAlign + channel * parsed.width;
|
||
peak = Math.max(peak, Math.abs(parsed.buf.readIntLE(at, 3) / 8388608));
|
||
}
|
||
return peak;
|
||
}
|
||
|
||
check("two files become one poly, as long as the span between them", () => {
|
||
assert.strictEqual(combineResult.channels, 2, "got " + combineResult.channels + " channels");
|
||
assert.strictEqual(combineResult.sources, 2);
|
||
// Two seconds apart, one second each: three seconds end to end.
|
||
assert.strictEqual(combineResult.frames, 3 * RATE, "frames: " + combineResult.frames);
|
||
assert.strictEqual(wav.probe(combined, false).channels, 2);
|
||
});
|
||
|
||
check("each file lands on its own timecode, to the sample", () => {
|
||
// Channel 1 plays in the first second and is silent in the third; channel 2
|
||
// the other way round. Silence either side is what fills the gap.
|
||
assert(Math.abs(peakOfChannel(combined, 0, 0, RATE) - 0.5) < 0.002, "boom isn't at the start");
|
||
assert.strictEqual(peakOfChannel(combined, 0, 2 * RATE, 3 * RATE), 0, "boom leaked into the tail");
|
||
assert.strictEqual(peakOfChannel(combined, 1, 0, RATE), 0, "the lav leaked into the head");
|
||
assert(Math.abs(peakOfChannel(combined, 1, 2 * RATE, 3 * RATE) - 0.25) < 0.002,
|
||
"the lav isn't two seconds in");
|
||
|
||
// And the boundary is exact, not approximately: the last silent frame and
|
||
// the first sounding one.
|
||
const parsed = wav.parse(combined);
|
||
const sampleAt = (frame, channel) => parsed.buf.readIntLE(
|
||
parsed.dataOffset + frame * parsed.blockAlign + channel * parsed.width, 3);
|
||
assert.strictEqual(sampleAt(2 * RATE - 1, 1), 0, "the lav starts a sample early");
|
||
});
|
||
|
||
check("the poly file starts at the earliest timecode", () => {
|
||
const bext = bodyOf(combined, "bext");
|
||
assert.strictEqual(Number(bext.readBigUInt64LE(338)), TEN,
|
||
"TimeReference is " + Number(bext.readBigUInt64LE(338)) + ", not the earliest start");
|
||
const history = bext.subarray(602).toString("latin1");
|
||
assert(/combined from 2 files/.test(history), "the history doesn't say where it came from");
|
||
});
|
||
|
||
check("the track list names every channel by where it came from", () => {
|
||
const list = bodyOf(combined, "iXML").toString("utf8")
|
||
.match(/<TRACK_LIST>[\s\S]*?<\/TRACK_LIST>/)[0];
|
||
assert(/<TRACK_COUNT>2<\/TRACK_COUNT>/.test(list), list);
|
||
assert(/<NAME>Boom<\/NAME>/.test(list) && /<NAME>Lav Anna<\/NAME>/.test(list), list);
|
||
assert(/<CHANNEL_INDEX>2<\/CHANNEL_INDEX><INTERLEAVE_INDEX>2</.test(list),
|
||
"the second channel isn't numbered as the second: " + list);
|
||
});
|
||
|
||
check("the plan says what it would make before it makes it", () => {
|
||
const plan = wav.combinePlan(combineSources, 0, false);
|
||
assert.deepStrictEqual(plan.problems, []);
|
||
assert.strictEqual(plan.channels, 2);
|
||
assert.strictEqual(plan.frames, 3 * RATE);
|
||
assert.strictEqual(plan.seconds, 3);
|
||
assert.strictEqual(plan.sampleRate, RATE);
|
||
assert.strictEqual(plan.origin, TEN);
|
||
assert.strictEqual(plan.bytes, 3 * RATE * 2 * 3, "size estimate: " + plan.bytes);
|
||
assert.deepStrictEqual(plan.tracks, ["Boom", "Lav Anna"]);
|
||
});
|
||
|
||
check("mixed sample rates are refused, and the odd one out is named", () => {
|
||
const odd = path.join(combineDir, "C_96k.wav");
|
||
fs.writeFileSync(odd, build({ channels: 1, bits: 24, seconds: 1, sampleRate: 96000, tcSamples: TEN }));
|
||
const plan = wav.combinePlan([combineSources[0], odd], 0, false);
|
||
assert.strictEqual(plan.problems.length, 1, JSON.stringify(plan.problems));
|
||
assert(/C_96k\.wav/.test(plan.problems[0]), "the file isn't named: " + plan.problems[0]);
|
||
assert(/one rate/.test(plan.problems[0]), plan.problems[0]);
|
||
|
||
// And the writer refuses too, not just the panel.
|
||
assert.throws(() => wav.combine([combineSources[0], odd], path.join(combineDir, "no.wav"), 24, false, 1, false),
|
||
/one rate/);
|
||
assert.strictEqual(fs.existsSync(path.join(combineDir, "no.wav")), false,
|
||
"it wrote something anyway");
|
||
});
|
||
|
||
check("a night that runs past midnight still lines up", () => {
|
||
// 23:59:59 and 00:00:01 are two seconds apart in reality and 24 hours
|
||
// apart in the arithmetic.
|
||
const late = path.join(combineDir, "late.wav");
|
||
const early = path.join(combineDir, "early.wav");
|
||
const midnight = 24 * 3600 * RATE;
|
||
fs.writeFileSync(late, build({ channels: 1, bits: 24, seconds: 1,
|
||
tcSamples: midnight - RATE, tracks: ["Late"] }));
|
||
fs.writeFileSync(early, build({ channels: 1, bits: 24, seconds: 1,
|
||
tcSamples: RATE, tracks: ["Early"] }));
|
||
|
||
const plan = wav.combinePlan([late, early], 0, false);
|
||
assert.deepStrictEqual(plan.problems, []);
|
||
assert.strictEqual(plan.frames, 3 * RATE,
|
||
"it made a day-long file: " + (plan.frames / RATE).toFixed(0) + " seconds");
|
||
assert(plan.notes.some((note) => /midnight/i.test(note)),
|
||
"it fixed the timecodes silently: " + JSON.stringify(plan.notes));
|
||
});
|
||
|
||
check("a file with no timecode is placed at the start, and says so", () => {
|
||
const blind = path.join(combineDir, "blind.wav");
|
||
fs.writeFileSync(blind, build({ channels: 1, bits: 24, seconds: 1, tcSamples: 0 }));
|
||
const plan = wav.combinePlan([combineSources[0], blind], 0, false);
|
||
assert.deepStrictEqual(plan.problems, []);
|
||
assert.strictEqual(plan.origin, TEN, "it dragged the origin to zero");
|
||
assert(plan.notes.some((note) => /no timecode/i.test(note)),
|
||
"nothing was said about it: " + JSON.stringify(plan.notes));
|
||
});
|
||
|
||
check("an ordinary long day is not a midnight crossing", () => {
|
||
// Eight in the morning to nine at night is a long day, not a night shoot.
|
||
// The old rule keyed on the spread and would have wrapped the morning take
|
||
// into tomorrow; what actually gives a crossing away is a hole of most of a
|
||
// day in the middle of the set.
|
||
const morning = path.join(combineDir, "morning.wav");
|
||
const evening = path.join(combineDir, "evening.wav");
|
||
fs.writeFileSync(morning, build({ channels: 1, bits: 24, seconds: 1,
|
||
tcSamples: 8 * 3600 * RATE, tracks: ["Morning"] }));
|
||
fs.writeFileSync(evening, build({ channels: 1, bits: 24, seconds: 1,
|
||
tcSamples: 20 * 3600 * RATE, tracks: ["Evening"] }));
|
||
|
||
const plan = wav.combinePlan([morning, evening], 0, false);
|
||
assert.strictEqual(plan.origin, 8 * 3600 * RATE, "the morning take was moved");
|
||
assert(!plan.notes.some((note) => /midnight/i.test(note)),
|
||
"it called an ordinary day a midnight crossing: " + JSON.stringify(plan.notes));
|
||
assert(plan.notes.some((note) => /hours end to end/.test(note)),
|
||
"a 12-hour file should say so: " + JSON.stringify(plan.notes));
|
||
});
|
||
|
||
check("a timecode that isn't a time of day is ignored, not obeyed", () => {
|
||
// Recorders do write nonsense here, and an unbounded offset would become
|
||
// an unbounded file.
|
||
const nonsense = path.join(combineDir, "nonsense.wav");
|
||
const file = build({ channels: 1, bits: 24, seconds: 1, tcSamples: 0 });
|
||
const parsed = wav.parse(path.join(combineDir, "A.wav"));
|
||
const bextAt = wav.parse(path.join(combineDir, "A.wav")).chunks.find((c) => c.id === "bext").offset;
|
||
file.writeBigUInt64LE(BigInt("18000000000000000000"), bextAt + 338);
|
||
fs.writeFileSync(nonsense, file);
|
||
|
||
const plan = wav.combinePlan([combineSources[0], nonsense], 0, false);
|
||
assert.deepStrictEqual(plan.problems, [], JSON.stringify(plan.problems));
|
||
assert.strictEqual(plan.frames, RATE, "it made a file " + (plan.frames / RATE) + " seconds long");
|
||
assert(plan.notes.some((note) => /no timecode/i.test(note)), JSON.stringify(plan.notes));
|
||
});
|
||
|
||
check("a lead with no timecode still gives the poly one", () => {
|
||
// The earliest file leads, and a file with no timecode lands at the start —
|
||
// so the likeliest lead is the one likeliest to have no bext at all.
|
||
const blind = path.join(combineDir, "no-bext.wav");
|
||
const stripped = (() => {
|
||
const source = wav.parse(path.join(combineDir, "B.wav"));
|
||
const bext = source.chunks.find((c) => c.id === "bext");
|
||
return Buffer.concat([
|
||
source.buf.subarray(0, bext.offset - 8),
|
||
source.buf.subarray(bext.offset + bext.size + (bext.size & 1)),
|
||
]);
|
||
})();
|
||
// The RIFF size shrinks with the chunk that was cut out.
|
||
stripped.writeUInt32LE(stripped.length - 8, 4);
|
||
fs.writeFileSync(blind, stripped);
|
||
|
||
const out = path.join(combineDir, "led-blind.wav");
|
||
wav.combine([blind, combineSources[0]], out, 24, false, 1, true);
|
||
const bext = bodyOf(out, "bext");
|
||
// The output carries the earliest *known* timecode; the file without one
|
||
// sits at the start of the timeline, which is that instant.
|
||
assert.strictEqual(Number(bext.readBigUInt64LE(338)), TEN,
|
||
"the synthesised bext didn't get the timeline's start");
|
||
const list = bodyOf(out, "iXML").toString("utf8").match(/<TRACK_LIST>[\s\S]*?<\/TRACK_LIST>/)[0];
|
||
assert(/<TRACK_COUNT>2<\/TRACK_COUNT>/.test(list), "the track list is the lead's: " + list);
|
||
});
|
||
|
||
check("a combined file past 4 GB is written as RF64", () => {
|
||
const out = path.join(combineDir, "big.wav");
|
||
wav.setRiffLimit(1000);
|
||
try {
|
||
wav.combine(combineSources, out, 24, false, 1, true);
|
||
} finally {
|
||
wav.setRiffLimit(0xFFFFFFF0);
|
||
}
|
||
const info = wav.probe(out, false);
|
||
assert.strictEqual(info.rf64, true, "it stayed RIFF past the limit");
|
||
assert.strictEqual(info.channels, 2);
|
||
assert.strictEqual(info.frames, 3 * RATE, "the length came back as " + info.frames);
|
||
assert.strictEqual(chunkList(out)[0][0], "ds64", "no ds64 first");
|
||
});
|
||
|
||
check("one file is not a combine", () => {
|
||
assert.throws(() => wav.combinePlan([combineSources[0]], 0, false), /more than one file/);
|
||
});
|
||
|
||
/* ---- every output format the writers claim to produce ---- */
|
||
|
||
const formats = path.join(root, "formats");
|
||
fs.mkdirSync(formats, { recursive: true });
|
||
|
||
// The same pair as above, but off a 32-bit float recorder.
|
||
const floatPair = ["FA.wav", "FB.wav"].map((name) => path.join(formats, name));
|
||
fs.writeFileSync(floatPair[0], build({
|
||
float: true, bits: 32, channels: 1, seconds: 1, tcSamples: TEN,
|
||
tracks: ["Boom"], amplitude: 0.5,
|
||
}));
|
||
fs.writeFileSync(floatPair[1], build({
|
||
float: true, bits: 32, channels: 1, seconds: 1, tcSamples: TEN + 2 * RATE,
|
||
tracks: ["Lav Anna"], amplitude: 0.25,
|
||
}));
|
||
|
||
/** The fmt chunk's tag, and the sub-format GUID when it's extensible. */
|
||
function formatTag(file) {
|
||
const fmt = bodyOf(file, "fmt ");
|
||
return {
|
||
tag: fmt.readUInt16LE(0),
|
||
bits: fmt.readUInt16LE(14),
|
||
blockAlign: fmt.readUInt16LE(12),
|
||
guid: fmt.length >= 40 ? fmt.readUInt16LE(24) : null,
|
||
};
|
||
}
|
||
|
||
[
|
||
{ bits: 16, float: false, name: "16-bit PCM", tag: 1 },
|
||
{ bits: 24, float: false, name: "24-bit PCM", tag: 1 },
|
||
{ bits: 32, float: false, name: "32-bit PCM", tag: 1 },
|
||
{ bits: 32, float: true, name: "32-bit float", tag: 3 },
|
||
{ bits: 64, float: true, name: "64-bit float", tag: 3 },
|
||
].forEach((want) => {
|
||
check("a float source can be written as " + want.name, () => {
|
||
const out = path.join(formats, want.name.replace(/[ -]/g, "_") + ".wav");
|
||
// Gain, so the passthrough copy can't be what's being tested.
|
||
const result = wav.exportFile(floatSrc, out, want.bits, want.float, 0.5, true);
|
||
const info = wav.probe(out, false);
|
||
assert.strictEqual(info.format, want.name, "read back as " + info.format);
|
||
assert.strictEqual(result.targetFormat, want.name);
|
||
assert.strictEqual(result.targetBits, want.bits);
|
||
assert.strictEqual(result.targetFloat, want.float);
|
||
const fmt = formatTag(out);
|
||
assert.strictEqual(fmt.tag, want.tag, "wFormatTag is " + fmt.tag);
|
||
assert.strictEqual(fmt.bits, want.bits);
|
||
assert.strictEqual(fmt.blockAlign, 2 * (want.bits / 8), "block align is " + fmt.blockAlign);
|
||
assert.strictEqual(info.frames, wav.probe(floatSrc, false).frames, "length moved");
|
||
});
|
||
});
|
||
|
||
check("nothing else is an output format", () => {
|
||
const out = path.join(formats, "nope.wav");
|
||
assert.throws(() => wav.exportFile(floatSrc, out, 8, false, 1, true), /not an output format/);
|
||
assert.throws(() => wav.exportFile(floatSrc, out, 24, true, 1, true), /not an output format/);
|
||
assert.throws(() => wav.exportFile(floatSrc, out, 20, false, 1, true), /not an output format/);
|
||
});
|
||
|
||
check("float out of float keeps the samples, not just the header", () => {
|
||
const out = path.join(formats, "half.wav");
|
||
wav.exportFile(floatSrc, out, 32, true, 0.5, true);
|
||
const before = wav.parse(floatSrc);
|
||
const after = wav.parse(out);
|
||
for (let i = 0; i < 200; i++) {
|
||
const was = before.buf.readFloatLE(before.dataOffset + i * 4);
|
||
const now = after.buf.readFloatLE(after.dataOffset + i * 4);
|
||
assert.strictEqual(now, Math.fround(was * 0.5), "sample " + i + " came back as " + now);
|
||
}
|
||
});
|
||
|
||
check("a float file asked for as float, at its own level, is copied whole", () => {
|
||
const out = path.join(formats, "same.wav");
|
||
const result = wav.exportFile(floatSrc, out, 32, true, 1, true);
|
||
assert.strictEqual(result.copied, true, "it was rebuilt when it could have been copied");
|
||
assert(fs.readFileSync(floatSrc).equals(fs.readFileSync(out)), "the bytes differ");
|
||
});
|
||
|
||
check("an extensible float source stays extensible, with the float GUID", () => {
|
||
const src = path.join(formats, "ext-src.wav");
|
||
fs.writeFileSync(src, build({ float: true, bits: 32, extensible: true, seconds: 1 }));
|
||
const out = path.join(formats, "ext-out.wav");
|
||
wav.exportFile(src, out, 32, true, 0.5, true);
|
||
const fmt = formatTag(out);
|
||
assert.strictEqual(fmt.tag, 0xFFFE, "it lost WAVE_FORMAT_EXTENSIBLE");
|
||
assert.strictEqual(fmt.guid, 3, "the sub-format GUID says " + fmt.guid + ", not float");
|
||
assert.strictEqual(wav.probe(out, false).format, "32-bit float");
|
||
});
|
||
|
||
check("the coding history says float when the file is float", () => {
|
||
const out = path.join(formats, "history.wav");
|
||
wav.exportFile(floatSrc, out, 32, true, 0.5, true);
|
||
const history = bodyOf(out, "bext").subarray(602).toString("latin1").replace(/\0+$/, "");
|
||
assert(/A=FLOAT,F=48000,W=32/.test(history), "history reads: " + history);
|
||
const fixed24 = path.join(formats, "history24.wav");
|
||
wav.exportFile(floatSrc, fixed24, 24, false, 0.5, true);
|
||
const other = bodyOf(fixed24, "bext").subarray(602).toString("latin1").replace(/\0+$/, "");
|
||
assert(/A=PCM,F=48000,W=24/.test(other), "history reads: " + other);
|
||
});
|
||
|
||
check("splitting to float gives float monos", () => {
|
||
const src = path.join(formats, "poly-float.wav");
|
||
fs.writeFileSync(src, build({ float: true, bits: 32, channels: 2, seconds: 1 }));
|
||
const dir = path.join(formats, "split-float");
|
||
const result = wav.exportSplit(src, dir, ["a.wav", "b.wav"], 32, true, 1, true, []);
|
||
assert.strictEqual(result.targetFormat, "32-bit float");
|
||
result.files.forEach((name) => {
|
||
const info = wav.probe(path.join(dir, name), false);
|
||
assert.strictEqual(info.format, "32-bit float", name + " came out " + info.format);
|
||
assert.strictEqual(info.channels, 1);
|
||
});
|
||
});
|
||
|
||
check("a combine of float sources stays float unless told otherwise", () => {
|
||
const plan = wav.combinePlan(floatPair, 0, false);
|
||
assert.strictEqual(plan.targetFormat, "32-bit float", "planned " + plan.targetFormat);
|
||
const out = path.join(formats, "combined-float.wav");
|
||
const result = wav.combine(floatPair, out, 0, false, 1, true);
|
||
assert.strictEqual(result.targetFormat, "32-bit float");
|
||
assert.strictEqual(wav.probe(out, false).format, "32-bit float");
|
||
// And the size the panel quoted is the size that got written.
|
||
assert.strictEqual(plan.bytes, plan.frames * plan.channels * 4);
|
||
});
|
||
|
||
check("asking a float combine for 24-bit gets 24-bit", () => {
|
||
const plan = wav.combinePlan(floatPair, 24, false);
|
||
assert.strictEqual(plan.targetFormat, "24-bit PCM");
|
||
const out = path.join(formats, "combined-24.wav");
|
||
wav.combine(floatPair, out, 24, false, 1, true);
|
||
assert.strictEqual(wav.probe(out, false).format, "24-bit PCM");
|
||
});
|
||
|
||
check("a float file declares cbSize, whatever wrote it", () => {
|
||
// Only WAVE_FORMAT_PCM may leave cbSize out of the fmt chunk, so every
|
||
// writer that can produce float has to be checked, not just the first one.
|
||
const whole = path.join(formats, "cb-whole.wav");
|
||
wav.exportFile(floatSrc, whole, 32, true, 0.5, true);
|
||
assert(bodyOf(whole, "fmt ").length >= 18,
|
||
"a whole-file export wrote a " + bodyOf(whole, "fmt ").length + "-byte fmt chunk");
|
||
|
||
const subset = path.join(formats, "cb-subset.wav");
|
||
wav.exportFile(floatSrc, subset, 32, true, 1, true, [1]);
|
||
assert.strictEqual(bodyOf(subset, "fmt ").length, 18, "the subset export left cbSize out");
|
||
assert.strictEqual(bodyOf(subset, "fmt ").readUInt16LE(16), 0, "cbSize isn't zero");
|
||
|
||
const dir = path.join(formats, "cb-split");
|
||
wav.exportSplit(floatSrc, dir, ["one.wav", "two.wav"], 32, true, 1, true, []);
|
||
assert.strictEqual(bodyOf(path.join(dir, "one.wav"), "fmt ").length, 18,
|
||
"a split mono left cbSize out");
|
||
|
||
const poly = path.join(formats, "cb-poly.wav");
|
||
wav.combine(floatPair, poly, 0, false, 1, true);
|
||
assert.strictEqual(bodyOf(poly, "fmt ").length, 18, "the combined poly left cbSize out");
|
||
|
||
// And integer PCM keeps the plain 16-byte header it always had.
|
||
const pcm = path.join(formats, "cb-pcm.wav");
|
||
wav.exportFile(floatSrc, pcm, 24, false, 0.5, true);
|
||
assert.strictEqual(bodyOf(pcm, "fmt ").length, 16,
|
||
"a 24-bit file grew a cbSize field it doesn't need");
|
||
});
|
||
|
||
check("a half-step rounds away from zero, the way the Rust does", () => {
|
||
// Rust's f64::round takes a half away from zero and JavaScript's Math.round
|
||
// takes it towards +Infinity. Half the samples in the top octave of a float
|
||
// take land on a half-step when scaled to 24-bit, so this is not a corner.
|
||
const src = path.join(formats, "halves.wav");
|
||
fs.writeFileSync(src, build({ float: true, bits: 32, channels: 1, seconds: 1 }));
|
||
const parsed = wav.parse(src);
|
||
const raw = Buffer.from(parsed.buf);
|
||
const wanted = [1.5, -1.5, 2.5, -2.5, 0.5, -0.5];
|
||
wanted.forEach((step, i) => raw.writeFloatLE(step / 8388608, parsed.dataOffset + i * 4));
|
||
fs.writeFileSync(src, raw);
|
||
|
||
const out = path.join(formats, "halves-24.wav");
|
||
wav.exportFile(src, out, 24, false, 1, true);
|
||
const after = wav.parse(out);
|
||
const got = wanted.map((unused, i) => after.buf.readIntLE(after.dataOffset + i * 3, 3));
|
||
assert.deepStrictEqual(got, [2, -2, 3, -3, 1, -1], "rounded to " + got.join(", "));
|
||
});
|
||
|
||
check("float plus a 32-bit integer file combines at 64-bit", () => {
|
||
// f32 holds 24 bits of significand, which is not enough for the integer
|
||
// file, so leaving the depth alone has to widen rather than narrow.
|
||
const deep = path.join(formats, "int32.wav");
|
||
fs.writeFileSync(deep, build({
|
||
bits: 32, channels: 1, seconds: 1, tcSamples: TEN + RATE, tracks: ["Deep"],
|
||
}));
|
||
const plan = wav.combinePlan([floatPair[0], deep], 0, false);
|
||
assert.strictEqual(plan.targetFormat, "64-bit float", "planned " + plan.targetFormat);
|
||
const out = path.join(formats, "combined-64.wav");
|
||
wav.combine([floatPair[0], deep], out, 0, false, 1, true);
|
||
assert.strictEqual(wav.probe(out, false).format, "64-bit float");
|
||
assert.strictEqual(plan.bytes, plan.frames * plan.channels * 8, "the size was quoted wrong");
|
||
});
|
||
|
||
check("a frame no header could describe is refused, not wrapped", () => {
|
||
const many = [];
|
||
for (let i = 0; i < 9000; i++) many.push(1);
|
||
assert.throws(() => wav.exportFile(floatSrc, path.join(formats, "wide.wav"),
|
||
64, true, 1, true, many), /more than a WAV frame can hold/);
|
||
});
|
||
|
||
check("a mixed set says which format the poly came out as", () => {
|
||
const mixed = [floatPair[0], combineSources[0]];
|
||
const plan = wav.combinePlan(mixed, 0, false);
|
||
assert.strictEqual(plan.targetFormat, "32-bit float",
|
||
"a set with a float file in it planned " + plan.targetFormat);
|
||
assert(plan.notes.some((note) => /32-bit float and 24-bit PCM/.test(note)),
|
||
"nothing said about the mix: " + plan.notes.join(" | "));
|
||
});
|
||
|
||
/* ------------------------------------------------------------------ */
|
||
/* The panel, in the app */
|
||
/* ------------------------------------------------------------------ */
|
||
|
||
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);
|
||
})();
|
||
});
|
||
}
|
||
|
||
/** The app, with the Rust commands answered by the mirror above. */
|
||
function launch({ remembered, dialogAnswers = [], saveAnswers = [] } = {}) {
|
||
const asked = { dialog: 0 };
|
||
// Playback is Rust now; the page only ever sends commands and reads an
|
||
// event back, and this is the other end of that.
|
||
const listeners = new Map();
|
||
const savePanel = { asked: [] };
|
||
const engine = makePlayer((name, payload) =>
|
||
(listeners.get(name) || []).forEach((fn) => fn({ event: name, payload })));
|
||
const queue = dialogAnswers.slice();
|
||
const saves = saveAnswers.slice();
|
||
|
||
const dom = new JSDOM(fs.readFileSync(INDEX, "utf8"), {
|
||
runScripts: "dangerously",
|
||
url: "http://tauri.localhost/",
|
||
pretendToBeVisual: true,
|
||
virtualConsole: (function () {
|
||
const vc = new VirtualConsole();
|
||
// BWF_LOUD=1 surfaces page errors, which are otherwise swallowed
|
||
// and turn a broken build into a mysterious timeout.
|
||
if (process.env.BWF_LOUD) {
|
||
vc.on("jsdomError", (e) => console.error("JSDOM:", e.message));
|
||
vc.on("error", (...a) => console.error("PAGE:", ...a));
|
||
}
|
||
return vc;
|
||
}()),
|
||
beforeParse(win) {
|
||
// The waveform drawing asks for a gradient and for pixels back, so
|
||
// the stub has to answer those or playback dies in its own painting.
|
||
win.HTMLCanvasElement.prototype.getContext = () => new Proxy({}, {
|
||
get: (target, prop) => {
|
||
if (prop === "canvas") return null;
|
||
if (prop === "createLinearGradient") return () => ({ addColorStop() {} });
|
||
if (prop === "getImageData") return () => ({ data: new Uint8ClampedArray(4) });
|
||
return typeof target[prop] === "undefined" ? () => {} : target[prop];
|
||
},
|
||
set: () => true,
|
||
});
|
||
win.URL.createObjectURL = () => "blob:stub";
|
||
win.URL.revokeObjectURL = () => {};
|
||
// Just enough Web Audio to let a file "play", since the player is
|
||
// where Export now lives.
|
||
class Node { connect() { return this; } disconnect() {} }
|
||
win.AudioContext = win.webkitAudioContext = class {
|
||
constructor() {
|
||
this.state = "running";
|
||
this.currentTime = 0;
|
||
this.destination = new Node();
|
||
}
|
||
addEventListener() {}
|
||
resume() { return Promise.resolve(); }
|
||
close() { return Promise.resolve(); }
|
||
createBufferSource() { return Object.assign(new Node(), { start() {}, stop() {} }); }
|
||
createGain() { return Object.assign(new Node(), { gain: { value: 1, setValueAtTime() {} } }); }
|
||
createChannelSplitter() { return new Node(); }
|
||
decodeAudioData() {
|
||
return Promise.resolve({
|
||
numberOfChannels: 2, duration: 1, length: 48000, sampleRate: 48000,
|
||
getChannelData: () => new Float32Array(2048),
|
||
});
|
||
}
|
||
};
|
||
if (remembered !== undefined) win.localStorage.setItem(STORAGE_KEY, remembered);
|
||
|
||
const toRealm = (buffer) => {
|
||
const view = new win.Uint8Array(buffer.length);
|
||
view.set(buffer);
|
||
return view.buffer;
|
||
};
|
||
|
||
win.__TAURI__ = {
|
||
event: {
|
||
listen: (name, handler) => {
|
||
listeners.set(name, (listeners.get(name) || []).concat(handler));
|
||
return Promise.resolve(() => {});
|
||
},
|
||
},
|
||
core: {
|
||
invoke(command, payload, options) {
|
||
try {
|
||
if (engine.commands[command]) {
|
||
return engine.commands[command](payload || {});
|
||
}
|
||
// The same header protocol the metadata editor uses;
|
||
// the log file goes out through it.
|
||
if (command === "bwf_write") {
|
||
const headers = (options && options.headers) || {};
|
||
const target = Buffer.from(headers["x-bwf-path"], "hex").toString("utf8");
|
||
const position = parseInt(headers["x-bwf-position"] || "0", 10);
|
||
const bytes = Buffer.from(payload.buffer
|
||
? new Uint8Array(payload) : payload);
|
||
if (!fs.existsSync(target)) fs.writeFileSync(target, Buffer.alloc(0));
|
||
const fd = fs.openSync(target, "r+");
|
||
fs.writeSync(fd, bytes, 0, bytes.length, position);
|
||
if (headers["x-bwf-truncate"] === "1") {
|
||
fs.ftruncateSync(fd, position + bytes.length);
|
||
}
|
||
fs.closeSync(fd);
|
||
return Promise.resolve(bytes.length);
|
||
}
|
||
if (command === "plugin:dialog|open") {
|
||
asked.dialog++;
|
||
const next = queue.shift();
|
||
return Promise.resolve(next === undefined ? null : next);
|
||
}
|
||
if (command === "plugin:dialog|save") {
|
||
savePanel.asked.push(
|
||
((payload || {}).options || {}).defaultPath || "");
|
||
const next = saves.shift();
|
||
return Promise.resolve(next === undefined ? null : next);
|
||
}
|
||
if (command === "bwf_list_dir") {
|
||
if (!fs.existsSync(payload.path)) {
|
||
return Promise.reject(new Error(payload.path + ": No such file or directory"));
|
||
}
|
||
return Promise.resolve(fs.readdirSync(payload.path).sort().map((name) => ({
|
||
name,
|
||
path: path.join(payload.path, name),
|
||
kind: fs.statSync(path.join(payload.path, name)).isDirectory() ? "directory" : "file",
|
||
})));
|
||
}
|
||
if (command === "bwf_stat") {
|
||
const stat = fs.statSync(payload.path);
|
||
return Promise.resolve({
|
||
path: payload.path,
|
||
relativePath: path.basename(payload.path),
|
||
name: path.basename(payload.path),
|
||
size: stat.size,
|
||
lastModified: Math.floor(stat.mtimeMs),
|
||
});
|
||
}
|
||
if (command === "bwf_read_range") {
|
||
const size = fs.statSync(payload.path).size;
|
||
if (payload.offset >= size || !payload.length) return Promise.resolve(toRealm(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(toRealm(buffer));
|
||
}
|
||
if (command === "bwf_read_all") {
|
||
return Promise.resolve(toRealm(fs.readFileSync(payload.path)));
|
||
}
|
||
if (command === "bwf_prepare_dir") {
|
||
fs.mkdirSync(payload.path, { recursive: true });
|
||
return Promise.resolve(fs.readdirSync(payload.path)
|
||
.filter((n) => /\.wav$/i.test(n)).length);
|
||
}
|
||
if (command === "bwf_probe") {
|
||
return Promise.resolve(wav.probe(payload.path, payload.scan));
|
||
}
|
||
if (command === "bwf_export") {
|
||
return Promise.resolve(wav.exportFile(payload.src, payload.dest,
|
||
payload.bits, payload.float, payload.gain, payload.overwrite,
|
||
payload.channels));
|
||
}
|
||
if (command === "bwf_export_split") {
|
||
return Promise.resolve(wav.exportSplit(payload.src, payload.dest,
|
||
payload.names, payload.bits, payload.float, payload.gain,
|
||
payload.overwrite, payload.channels));
|
||
}
|
||
if (command === "bwf_combine_plan") {
|
||
return Promise.resolve(wav.combinePlan(payload.sources,
|
||
payload.bits, payload.float));
|
||
}
|
||
if (command === "bwf_combine") {
|
||
return Promise.resolve(wav.combine(payload.sources, payload.dest,
|
||
payload.bits, payload.float, payload.gain, payload.overwrite));
|
||
}
|
||
if (command === "bwf_copy_file") {
|
||
return Promise.resolve(wav.copyFile(payload.src, payload.dest, payload.overwrite));
|
||
}
|
||
} catch (e) {
|
||
return Promise.reject(e);
|
||
}
|
||
return Promise.reject(new Error("unexpected command " + command));
|
||
},
|
||
},
|
||
};
|
||
},
|
||
});
|
||
|
||
return new Promise((resolve) => {
|
||
dom.window.addEventListener("load", () =>
|
||
resolve({ dom, window: dom.window, asked, engine, savePanel }));
|
||
});
|
||
}
|
||
|
||
const rowsIn = (doc) => Array.from(doc.querySelectorAll("[data-bwfa-table-body] tr"));
|
||
|
||
(async () => {
|
||
/* A card of two float takes at different levels, plus one already at
|
||
24-bit, which is the mix a real day produces. */
|
||
const card = path.join(root, "Day 21 PR-2");
|
||
fs.mkdirSync(card, { recursive: true });
|
||
fs.writeFileSync(path.join(card, "A001.wav"),
|
||
build({ float: true, bits: 32, scene: "12A", take: 1, amplitude: 0.5 }));
|
||
fs.writeFileSync(path.join(card, "A002.wav"),
|
||
build({ float: true, bits: 32, scene: "12A", take: 2, amplitude: 0.25 }));
|
||
fs.writeFileSync(path.join(card, "A003.wav"),
|
||
build({ bits: 24, scene: "12B", take: 1 }));
|
||
|
||
const dest = path.join(root, "Exported");
|
||
|
||
const logFile = path.join(root, "run.txt");
|
||
const app = await launch({ dialogAnswers: [card, dest], saveAnswers: [logFile] });
|
||
const doc = app.window.document;
|
||
const click = (el) => el.dispatchEvent(new app.window.MouseEvent("click", { bubbles: true }));
|
||
|
||
doc.querySelector("[data-bwfa-edit-folder]").dispatchEvent(
|
||
new app.window.MouseEvent("click", { bubbles: true }));
|
||
await waitFor(() => rowsIn(doc).length === 3, "the card to open");
|
||
|
||
const panel = doc.querySelector("[data-bwfa-export-panel]");
|
||
|
||
check("the export panel stays out of the way until asked for", () =>
|
||
assert.strictEqual(panel.hidden, true, "it was open before anything was clicked"));
|
||
|
||
click(doc.querySelector("[data-bwfa-export-audio]"));
|
||
|
||
check("Export Files opens the panel, scoped to what's on screen", () => {
|
||
assert.strictEqual(panel.hidden, false, "the panel didn't open");
|
||
assert(/3 files/.test(doc.querySelector("[data-bwfa-export-scope]").textContent),
|
||
"scope reads: " + doc.querySelector("[data-bwfa-export-scope]").textContent);
|
||
});
|
||
|
||
check("with nowhere to write, Export is not offered", () =>
|
||
assert.strictEqual(doc.querySelector("[data-bwfa-export-run]").disabled, true,
|
||
"Export was enabled with no destination chosen"));
|
||
|
||
click(doc.querySelector("[data-bwfa-export-choose]"));
|
||
await waitFor(() => doc.querySelector("[data-bwfa-export-dest]").value === dest,
|
||
"the destination to be picked up");
|
||
|
||
check("choosing a folder enables the export", () =>
|
||
assert.strictEqual(doc.querySelector("[data-bwfa-export-run]").disabled, false,
|
||
"Export is still disabled"));
|
||
|
||
click(doc.querySelector("[data-bwfa-export-run]"));
|
||
await waitFor(() => !doc.querySelector("[data-bwfa-result-panel]").hidden, "the report", 20000);
|
||
|
||
await checkAsync("all three files are written, and only what needed converting was", async () => {
|
||
const written = fs.readdirSync(dest).sort();
|
||
assert.deepStrictEqual(written, ["A001.wav", "A002.wav", "A003.wav"], "wrote: " + written);
|
||
assert.strictEqual(wav.probe(path.join(dest, "A001.wav"), false).format, "24-bit PCM");
|
||
assert.strictEqual(wav.probe(path.join(dest, "A002.wav"), false).format, "24-bit PCM");
|
||
// The 24-bit take needed nothing done, so it should be the same bytes.
|
||
assert(fs.readFileSync(path.join(card, "A003.wav"))
|
||
.equals(fs.readFileSync(path.join(dest, "A003.wav"))),
|
||
"the 24-bit file was rebuilt when it could have been copied");
|
||
});
|
||
|
||
/** The four counts in the report modal's strip, by name. */
|
||
const tallyOf = (where) => {
|
||
const counts = {};
|
||
where.querySelectorAll("[data-bwfa-result-count]").forEach((stat) => {
|
||
counts[stat.getAttribute("data-bwfa-result-count")] =
|
||
Number(stat.querySelector("strong").textContent);
|
||
});
|
||
return counts;
|
||
};
|
||
|
||
check("the form gets out of the way and the report takes its place", () => {
|
||
assert.strictEqual(panel.hidden, true, "the export panel is still on screen");
|
||
assert.strictEqual(doc.querySelector("[data-bwfa-result-panel]").hidden, false,
|
||
"the report modal didn't open");
|
||
assert.strictEqual(doc.querySelector("[data-bwfa-sheet=\"result\"]").hidden, false,
|
||
"the report's modal shell stayed hidden");
|
||
assert.strictEqual(doc.querySelector("[data-bwfa-sheet=\"export\"]").hidden, true,
|
||
"the export modal's shell is still up");
|
||
});
|
||
|
||
check("the counts are their own thing, not a sentence to read", () => {
|
||
assert.deepStrictEqual(tallyOf(doc),
|
||
{ converted: 2, copied: 1, skipped: 0, failed: 0 },
|
||
"tally: " + doc.querySelector("[data-bwfa-result-tally]").textContent);
|
||
assert(/Export finished/.test(doc.querySelector("[data-bwfa-result-title]").textContent));
|
||
assert(doc.querySelector("[data-bwfa-result-where]").textContent.indexOf(dest) !== -1,
|
||
"the destination isn't named");
|
||
});
|
||
|
||
check("the report lists every file, one row each", () => {
|
||
const rows = doc.querySelectorAll("[data-bwfa-export-report] li");
|
||
assert.strictEqual(rows.length, 3, "got " + rows.length + " rows");
|
||
const text = doc.querySelector("[data-bwfa-export-report]").textContent;
|
||
assert(/A001\.wav/.test(text) && /A003\.wav/.test(text), "files aren't listed: " + text);
|
||
// Name and detail are separate cells, or the list can't line up.
|
||
assert.strictEqual(rows[0].querySelectorAll("span").length, 2);
|
||
});
|
||
|
||
await checkAsync("Save Log offers the folder the export went to", async () => {
|
||
// The log is about the files that were just written, so it belongs
|
||
// with them rather than back on the card they came from.
|
||
click(doc.querySelector("[data-bwfa-result-save]"));
|
||
await waitFor(() => app.savePanel.asked.length > 0, "the save panel", 5000);
|
||
const suggested = app.savePanel.asked[app.savePanel.asked.length - 1];
|
||
assert(String(suggested).indexOf(dest) === 0,
|
||
"it opened at " + suggested + " rather than in " + dest);
|
||
});
|
||
|
||
await checkAsync("Save Log writes the run to a text file", async () => {
|
||
click(doc.querySelector("[data-bwfa-result-save]"));
|
||
await waitFor(() => fs.existsSync(logFile), "the log to be written", 5000);
|
||
const log = fs.readFileSync(logFile, "utf8");
|
||
// The settings that produced it, not just the outcome.
|
||
assert(/To:\s+\Q/.source && log.indexOf(dest) !== -1, "the destination isn't in the log");
|
||
assert(log.indexOf(card) !== -1, "the source folder isn't in the log");
|
||
assert(/Bit depth:\s+Convert to 24-bit/.test(log), "the settings are missing:\n" + log);
|
||
assert(/Normalise:\s+Off/.test(log), "the normalise setting is missing:\n" + log);
|
||
assert(/2 files converted, 1 file copied\./.test(log), "no summary:\n" + log);
|
||
// One line per file, names in a column.
|
||
["A001.wav", "A002.wav", "A003.wav"].forEach((name) => {
|
||
assert(new RegExp(" " + name + " +\\S").test(log), name + " isn't a padded row:\n" + log);
|
||
});
|
||
});
|
||
|
||
check("Done puts the report away", () => {
|
||
click(doc.querySelector("[data-bwfa-result-done]"));
|
||
assert.strictEqual(doc.querySelector("[data-bwfa-result-panel]").hidden, true,
|
||
"the report modal stayed open");
|
||
});
|
||
|
||
check("the originals are untouched", () => {
|
||
assert.strictEqual(wav.probe(path.join(card, "A001.wav"), false).format, "32-bit float",
|
||
"the source file was converted in place");
|
||
});
|
||
|
||
/* ---- running it again ---- */
|
||
|
||
const before = fs.readFileSync(path.join(dest, "A001.wav"));
|
||
click(doc.querySelector("[data-bwfa-export-audio]"));
|
||
click(doc.querySelector("[data-bwfa-export-run]"));
|
||
await waitFor(() => !doc.querySelector("[data-bwfa-result-panel]").hidden &&
|
||
tallyOf(doc).skipped === 3, "the second run", 20000);
|
||
|
||
check("a file that's already there is skipped, not silently overwritten", () => {
|
||
assert(fs.readFileSync(path.join(dest, "A001.wav")).equals(before), "it was rewritten anyway");
|
||
assert.deepStrictEqual(tallyOf(doc),
|
||
{ converted: 0, copied: 0, skipped: 3, failed: 0 },
|
||
"tally: " + doc.querySelector("[data-bwfa-result-tally]").textContent);
|
||
});
|
||
|
||
/* ---- normalising, one gain for the batch ---- */
|
||
|
||
const linked = path.join(root, "Normalised");
|
||
const app2 = await launch({ remembered: card, dialogAnswers: [linked] });
|
||
const doc2 = app2.window.document;
|
||
const click2 = (el) => el.dispatchEvent(new app2.window.MouseEvent("click", { bubbles: true }));
|
||
await waitFor(() => rowsIn(doc2).length === 3, "the card to reopen");
|
||
|
||
click2(doc2.querySelector("[data-bwfa-export-audio]"));
|
||
click2(doc2.querySelector("[data-bwfa-export-choose]"));
|
||
await waitFor(() => doc2.querySelector("[data-bwfa-export-dest]").value === linked, "the destination");
|
||
|
||
const mode = doc2.querySelector("[data-bwfa-export-normalize]");
|
||
mode.value = "linked";
|
||
mode.dispatchEvent(new app2.window.Event("change", { bubbles: true }));
|
||
|
||
check("the target is only editable when there's a target to hit", () => {
|
||
assert.strictEqual(doc2.querySelector("[data-bwfa-export-target]").disabled, false,
|
||
"target stayed disabled after turning normalising on");
|
||
});
|
||
|
||
doc2.querySelector("[data-bwfa-export-target]").value = "-3";
|
||
click2(doc2.querySelector("[data-bwfa-export-run]"));
|
||
await waitFor(() => !doc2.querySelector("[data-bwfa-result-panel]").hidden, "the report", 20000);
|
||
|
||
await checkAsync("one gain lifts the loudest file to the target", async () => {
|
||
const peak = wav.probe(path.join(linked, "A001.wav"), true).peak;
|
||
const want = Math.pow(10, -3 / 20);
|
||
assert(Math.abs(peak - want) < 0.002, "loudest file peaks at " + peak + ", wanted " + want);
|
||
});
|
||
|
||
await checkAsync("and the quieter take keeps its distance", async () => {
|
||
// A002 was recorded 6 dB down and must still be 6 dB down afterwards.
|
||
const loud = wav.probe(path.join(linked, "A001.wav"), true).peak;
|
||
const quiet = wav.probe(path.join(linked, "A002.wav"), true).peak;
|
||
const ratio = 20 * Math.log10(quiet / loud);
|
||
assert(Math.abs(ratio + 6.02) < 0.1, "the gap between takes came out as " + ratio.toFixed(2) + " dB");
|
||
});
|
||
|
||
check("the report names the single gain it used", () => {
|
||
const text = doc2.querySelector("[data-bwfa-result-note]").textContent;
|
||
assert(/One gain of \+/.test(text), "report: " + text);
|
||
});
|
||
|
||
app2.window.close();
|
||
|
||
/* ---- switching folders drops what the panel was holding ---- */
|
||
|
||
const second = path.join(root, "Day 22 PR-2");
|
||
fs.mkdirSync(second, { recursive: true });
|
||
fs.writeFileSync(path.join(second, "B001.wav"),
|
||
build({ float: true, bits: 32, scene: "20", take: 1, amplitude: 0.4 }));
|
||
|
||
const afterSwitch = path.join(root, "After Switch");
|
||
const app5 = await launch({ dialogAnswers: [card, path.join(root, "Unused"), second, afterSwitch] });
|
||
const doc5 = app5.window.document;
|
||
const click5 = (el) => el.dispatchEvent(new app5.window.MouseEvent("click", { bubbles: true }));
|
||
click5(doc5.querySelector("[data-bwfa-edit-folder]"));
|
||
await waitFor(() => rowsIn(doc5).length === 3, "the first card");
|
||
|
||
click5(doc5.querySelector("[data-bwfa-export-audio]"));
|
||
click5(doc5.querySelector("[data-bwfa-export-choose]"));
|
||
await waitFor(() => doc5.querySelector("[data-bwfa-export-dest]").value.length, "a destination");
|
||
|
||
// Change Folder, with the export panel still open and holding three files.
|
||
click5(doc5.querySelector("[data-bwfa-edit-folder]"));
|
||
await waitFor(() => rowsIn(doc5).length === 1, "the second card", 20000);
|
||
|
||
check("changing folder closes the export panel and forgets its destination", () => {
|
||
assert.strictEqual(doc5.querySelector("[data-bwfa-export-panel]").hidden, true,
|
||
"the panel is still open over a folder it knows nothing about");
|
||
assert.strictEqual(doc5.querySelector('[data-bwfa-sheet="export"]').hidden, true,
|
||
"the modal shell is still up");
|
||
assert.strictEqual(doc5.querySelector("[data-bwfa-export-dest]").value, "",
|
||
"the old destination is still filled in");
|
||
});
|
||
|
||
click5(doc5.querySelector("[data-bwfa-export-audio]"));
|
||
|
||
check("and the panel reopens scoped to the folder that's actually open", () => {
|
||
assert(/One file: B001\.wav/.test(doc5.querySelector("[data-bwfa-export-scope]").textContent),
|
||
"scope reads: " + doc5.querySelector("[data-bwfa-export-scope]").textContent);
|
||
});
|
||
|
||
click5(doc5.querySelector("[data-bwfa-export-choose]"));
|
||
await waitFor(() => doc5.querySelector("[data-bwfa-export-dest]").value === afterSwitch, "the new destination");
|
||
click5(doc5.querySelector("[data-bwfa-export-run]"));
|
||
await waitFor(() => !doc5.querySelector("[data-bwfa-result-panel]").hidden, "the report", 20000);
|
||
|
||
check("only the new folder's files are written", () => {
|
||
const written = fs.readdirSync(afterSwitch).sort();
|
||
assert.deepStrictEqual(written, ["B001.wav"], "wrote: " + written);
|
||
});
|
||
|
||
app5.window.close();
|
||
|
||
/* ---- the app's own parser reads the result ---- */
|
||
|
||
const reopened = await launch({ remembered: dest });
|
||
const doc3 = reopened.window.document;
|
||
await waitFor(() => rowsIn(doc3).length === 3, "the exported folder to open");
|
||
|
||
check("the exported folder opens in the app with its metadata intact", () => {
|
||
const text = doc3.querySelector("[data-bwfa-table-body]").textContent;
|
||
assert(/24-bit/.test(text), "no 24-bit rows: " + text.slice(0, 200));
|
||
assert(!/32-bit/.test(text), "something came out still 32-bit");
|
||
assert(/12A/.test(text) && /12B/.test(text), "the scene didn't survive");
|
||
assert(/10:00:00:00/.test(text), "the timecode didn't survive");
|
||
});
|
||
|
||
reopened.window.close();
|
||
|
||
/* ---- one file, from the player ---- */
|
||
|
||
const single = path.join(root, "Single");
|
||
const app4 = await launch({ remembered: card, dialogAnswers: [single] });
|
||
const doc4 = app4.window.document;
|
||
const click4 = (el) => el.dispatchEvent(new app4.window.MouseEvent("click", { bubbles: true }));
|
||
await waitFor(() => rowsIn(doc4).length === 3, "the card to reopen");
|
||
|
||
// Play the first file, then export what the player is holding.
|
||
click4(rowsIn(doc4)[0].querySelector("[data-bwfa-play-btn]"));
|
||
// The filename appears before the audio is decoded; wait for the graph.
|
||
await waitFor(() => app4.window.BWFA_STATE.playback &&
|
||
/A001/.test(doc4.querySelector("[data-bwfa-player-filename]").textContent),
|
||
"the first file to be playing");
|
||
click4(doc4.querySelector("[data-bwfa-player-export]"));
|
||
|
||
check("Export in the player scopes the panel to the file it's holding", () => {
|
||
assert.strictEqual(doc4.querySelector("[data-bwfa-export-panel]").hidden, false,
|
||
"the panel didn't open");
|
||
assert(/One file: A001\.wav/.test(doc4.querySelector("[data-bwfa-export-scope]").textContent),
|
||
"scope reads: " + doc4.querySelector("[data-bwfa-export-scope]").textContent);
|
||
assert.strictEqual(doc4.querySelector("[data-bwfa-modal-export]"), null,
|
||
"the button is still in the detail modal as well");
|
||
});
|
||
|
||
check("and it still works once the file has finished", () => {
|
||
// The bug this replaces: after a file ended the player forgot what it
|
||
// had, so neither Edit nor Export did anything.
|
||
app4.engine.finish();
|
||
const row = app4.window.BWFA_STATE.playerRow();
|
||
assert(row, "the player let go of the file the moment it finished");
|
||
assert(/A001/.test(row.parsed.fileName), "it's holding " + row.parsed.fileName);
|
||
});
|
||
|
||
click4(doc4.querySelector("[data-bwfa-export-choose]"));
|
||
await waitFor(() => doc4.querySelector("[data-bwfa-export-dest]").value === single, "the destination");
|
||
click4(doc4.querySelector("[data-bwfa-export-run]"));
|
||
await waitFor(() => !doc4.querySelector("[data-bwfa-result-panel]").hidden, "the report", 20000);
|
||
|
||
check("only that file is exported", () => {
|
||
const written = fs.readdirSync(single).sort();
|
||
assert.deepStrictEqual(written, ["A001.wav"], "wrote: " + written);
|
||
});
|
||
|
||
/* ---- splitting from the panel ---- */
|
||
|
||
const polyCard = path.join(root, "Day 23 poly");
|
||
fs.mkdirSync(polyCard, { recursive: true });
|
||
fs.writeFileSync(path.join(polyCard, "A001.wav"), build({
|
||
float: true, bits: 32, channels: 4, seconds: 1, amplitude: 0.6,
|
||
tracks: ["Boom", "Lav Anna", "Plant FX", "Mix L"],
|
||
}));
|
||
// A mono take on the same card: splitting shouldn't rename what has nothing
|
||
// to split.
|
||
fs.writeFileSync(path.join(polyCard, "A002.wav"),
|
||
build({ float: true, bits: 32, channels: 1, seconds: 1, tracks: ["Boom"] }));
|
||
|
||
const monoOut = path.join(root, "Mono");
|
||
const app6 = await launch({ remembered: polyCard, dialogAnswers: [monoOut] });
|
||
const doc6 = app6.window.document;
|
||
const click6 = (el) => el.dispatchEvent(new app6.window.MouseEvent("click", { bubbles: true }));
|
||
await waitFor(() => rowsIn(doc6).length === 2, "the poly card");
|
||
|
||
click6(doc6.querySelector("[data-bwfa-export-audio]"));
|
||
click6(doc6.querySelector("[data-bwfa-export-choose]"));
|
||
await waitFor(() => doc6.querySelector("[data-bwfa-export-dest]").value === monoOut, "the destination");
|
||
|
||
const channels = doc6.querySelector("[data-bwfa-export-channels]");
|
||
channels.value = "split";
|
||
channels.dispatchEvent(new app6.window.Event("change", { bubbles: true }));
|
||
click6(doc6.querySelector("[data-bwfa-export-run]"));
|
||
await waitFor(() => !doc6.querySelector("[data-bwfa-result-panel]").hidden, "the report", 20000);
|
||
|
||
check("with normalising off, a loud file keeps its level", () => {
|
||
// The ceiling for the safety attenuation is full scale, not the
|
||
// normalise target — otherwise every take above -3 dBFS would quietly
|
||
// come out quieter than it went in.
|
||
const written = wav.probe(path.join(monoOut, "A001_1_Boom.wav"), true);
|
||
assert(Math.abs(written.peak - 0.6) < 0.002,
|
||
"it came out at " + written.peak.toFixed(4) + " rather than the 0.6 it was recorded at");
|
||
});
|
||
|
||
/* ---- picking tracks, from the player's Export ---- */
|
||
|
||
const pickCard = path.join(root, "Day 24 poly");
|
||
fs.mkdirSync(pickCard, { recursive: true });
|
||
fs.writeFileSync(path.join(pickCard, "P001.wav"), build({
|
||
float: true, bits: 32, channels: 4, seconds: 1, amplitude: 0.8,
|
||
tracks: ["Boom", "Lav Anna", "Plant FX", "Mix L"],
|
||
}));
|
||
const picked = path.join(root, "Picked");
|
||
|
||
const app7 = await launch({ remembered: pickCard, dialogAnswers: [picked] });
|
||
const doc7 = app7.window.document;
|
||
const click7 = (el) => el.dispatchEvent(new app7.window.MouseEvent("click", { bubbles: true }));
|
||
await waitFor(() => rowsIn(doc7).length === 1, "the poly card");
|
||
|
||
click7(rowsIn(doc7)[0].querySelector("[data-bwfa-play-btn]"));
|
||
await waitFor(() => app7.window.BWFA_STATE.playback, "a file to be playing");
|
||
click7(doc7.querySelector("[data-bwfa-player-export]"));
|
||
|
||
check("one file offers its tracks as chips, all on", () => {
|
||
const block = doc7.querySelector("[data-bwfa-export-tracks]");
|
||
assert.strictEqual(block.hidden, false, "no track picker for a four-track file");
|
||
const chips = Array.from(doc7.querySelectorAll("[data-bwfa-export-track]"));
|
||
assert.strictEqual(chips.length, 4, "got " + chips.length + " chips");
|
||
assert(/Boom/.test(chips[0].textContent), "chips: " + chips.map((c) => c.textContent).join(" | "));
|
||
assert(chips.every((chip) => !chip.classList.contains("is-off")), "something starts off");
|
||
});
|
||
|
||
// Turn off 2 and 4, leaving Boom and Plant FX.
|
||
click7(doc7.querySelectorAll("[data-bwfa-export-track]")[1]);
|
||
click7(doc7.querySelectorAll("[data-bwfa-export-track]")[3]);
|
||
click7(doc7.querySelector("[data-bwfa-export-choose]"));
|
||
await waitFor(() => doc7.querySelector("[data-bwfa-export-dest]").value === picked, "the destination");
|
||
click7(doc7.querySelector("[data-bwfa-export-run]"));
|
||
await waitFor(() => !doc7.querySelector("[data-bwfa-result-panel]").hidden, "the report", 20000);
|
||
|
||
check("only the chosen tracks are written, as one poly file", () => {
|
||
assert.deepStrictEqual(fs.readdirSync(picked).sort(), ["P001.wav"]);
|
||
const info = wav.probe(path.join(picked, "P001.wav"), false);
|
||
assert.strictEqual(info.channels, 2, "got " + info.channels + " channels");
|
||
const list = bodyOf(path.join(picked, "P001.wav"), "iXML").toString("utf8")
|
||
.match(/<TRACK_LIST>[\s\S]*?<\/TRACK_LIST>/)[0];
|
||
assert(/<NAME>Boom<\/NAME>/.test(list) && /<NAME>Plant FX<\/NAME>/.test(list),
|
||
"the wrong tracks came through: " + list);
|
||
assert(!/Lav Anna/.test(list), "a track that was switched off came through: " + list);
|
||
assert(/tracks 1, 3/.test(doc7.querySelector("[data-bwfa-export-report]").textContent),
|
||
"the report doesn't say which tracks went");
|
||
});
|
||
|
||
// The same selection, as separate monos.
|
||
const pickedMono = path.join(root, "Picked mono");
|
||
const app8 = await launch({ remembered: pickCard, dialogAnswers: [pickedMono] });
|
||
const doc8 = app8.window.document;
|
||
const click8 = (el) => el.dispatchEvent(new app8.window.MouseEvent("click", { bubbles: true }));
|
||
await waitFor(() => rowsIn(doc8).length === 1, "the poly card again");
|
||
click8(rowsIn(doc8)[0].querySelector("[data-bwfa-play-btn]"));
|
||
await waitFor(() => app8.window.BWFA_STATE.playback, "a file to be playing");
|
||
click8(doc8.querySelector("[data-bwfa-player-export]"));
|
||
click8(doc8.querySelectorAll("[data-bwfa-export-track]")[0]);
|
||
click8(doc8.querySelectorAll("[data-bwfa-export-track]")[1]);
|
||
const channelsSelect = doc8.querySelector("[data-bwfa-export-channels]");
|
||
channelsSelect.value = "split";
|
||
channelsSelect.dispatchEvent(new app8.window.Event("change", { bubbles: true }));
|
||
click8(doc8.querySelector("[data-bwfa-export-choose]"));
|
||
await waitFor(() => doc8.querySelector("[data-bwfa-export-dest]").value === pickedMono, "the destination");
|
||
click8(doc8.querySelector("[data-bwfa-export-run]"));
|
||
await waitFor(() => !doc8.querySelector("[data-bwfa-result-panel]").hidden, "the report", 20000);
|
||
|
||
check("or as one mono file per chosen track, numbered as recorded", () => {
|
||
assert.deepStrictEqual(fs.readdirSync(pickedMono).sort(),
|
||
["P001_3_Plant_FX.wav", "P001_4_Mix_L.wav"], "wrote: " + fs.readdirSync(pickedMono).join(", "));
|
||
assert(Math.abs(wav.probe(path.join(pickedMono, "P001_3_Plant_FX.wav"), true).peak - 0.8 / 3) < 0.002,
|
||
"the third track holds the wrong audio");
|
||
});
|
||
|
||
check("a whole folder gets no track picker", () => {
|
||
click8(doc8.querySelector("[data-bwfa-export-cancel]"));
|
||
click8(doc8.querySelector("[data-bwfa-export-audio]"));
|
||
// One file in this folder, so scope it by opening from the toolbar on a
|
||
// card that has more than one.
|
||
assert.strictEqual(doc8.querySelector("[data-bwfa-export-tracks]").hidden, false,
|
||
"this card has a single four-track file, so the picker still applies");
|
||
});
|
||
|
||
app7.window.close();
|
||
app8.window.close();
|
||
|
||
check("splitting names the files by channel and track", () => {
|
||
const written = fs.readdirSync(monoOut).sort();
|
||
assert.deepStrictEqual(written, [
|
||
"A001_1_Boom.wav",
|
||
"A001_2_Lav_Anna.wav",
|
||
"A001_3_Plant_FX.wav",
|
||
"A001_4_Mix_L.wav",
|
||
"A002.wav",
|
||
], "wrote: " + written.join(", "));
|
||
});
|
||
|
||
check("a mono take is left as one file, not renamed", () => {
|
||
const info = wav.probe(path.join(monoOut, "A002.wav"), false);
|
||
assert.strictEqual(info.channels, 1);
|
||
assert.strictEqual(info.bits, 24, "the mono take should still have been converted");
|
||
});
|
||
|
||
check("the report says what the poly file turned into", () => {
|
||
const text = doc6.querySelector("[data-bwfa-export-report]").textContent;
|
||
assert(/split into 4 mono files/.test(text), "report: " + text);
|
||
});
|
||
|
||
await checkAsync("the mono files open in the app with their own track name", async () => {
|
||
const reader = await launch({ remembered: monoOut });
|
||
const readerDoc = reader.window.document;
|
||
await waitFor(() => rowsIn(readerDoc).length === 5, "the mono folder to open");
|
||
|
||
const rows = rowsIn(readerDoc);
|
||
const names = rows.map((row) => row.textContent);
|
||
assert(/A001_3_Plant_FX\.wav/.test(names[2]), "rows came out in another order: " + names[2]);
|
||
|
||
// Track names aren't a default column, so the reading that matters is
|
||
// the detail view — which is the app's own parse of what was written.
|
||
rows[2].querySelectorAll("button")[1].dispatchEvent(
|
||
new reader.window.MouseEvent("click", { bubbles: true }));
|
||
await waitFor(() => !readerDoc.querySelector("[data-bwfa-modal]").hidden, "the detail modal");
|
||
const body = readerDoc.querySelector("[data-bwfa-modal-body]").textContent;
|
||
assert(/Plant FX/.test(body), "the track name didn't come through: " + body.slice(0, 400));
|
||
assert(/10:00:00:00/.test(body), "the timecode didn't come through");
|
||
assert(/24-bit/.test(body), "the word length is wrong");
|
||
reader.window.close();
|
||
});
|
||
|
||
app6.window.close();
|
||
|
||
/* ---- combining, from the panel ---- */
|
||
|
||
const takeCard = path.join(root, "Day 25 mono set");
|
||
fs.mkdirSync(takeCard, { recursive: true });
|
||
const tenAM = 10 * 3600 * 48000;
|
||
fs.writeFileSync(path.join(takeCard, "A007_1_Boom.wav"), build({
|
||
channels: 1, bits: 24, seconds: 1, tcSamples: tenAM, tracks: ["Boom"], amplitude: 0.5,
|
||
}));
|
||
fs.writeFileSync(path.join(takeCard, "A007_2_Lav.wav"), build({
|
||
channels: 1, bits: 24, seconds: 1, tcSamples: tenAM + 48000, tracks: ["Lav Anna"], amplitude: 0.25,
|
||
}));
|
||
const polyOut = path.join(root, "Combined");
|
||
|
||
const app9 = await launch({ remembered: takeCard, dialogAnswers: [polyOut] });
|
||
const doc9 = app9.window.document;
|
||
const click9 = (el) => el.dispatchEvent(new app9.window.MouseEvent("click", { bubbles: true }));
|
||
await waitFor(() => rowsIn(doc9).length === 2, "the mono set");
|
||
|
||
click9(doc9.querySelector("[data-bwfa-export-audio]"));
|
||
const channels9 = doc9.querySelector("[data-bwfa-export-channels]");
|
||
assert(Array.from(channels9.options).some((o) => o.value === "combine"),
|
||
"no combine option in the panel");
|
||
channels9.value = "combine";
|
||
channels9.dispatchEvent(new app9.window.Event("change", { bubbles: true }));
|
||
await waitFor(() => /channels/.test(doc9.querySelector("[data-bwfa-export-summary]").textContent),
|
||
"the summary line", 5000);
|
||
|
||
check("the panel says what the combine would make, before anything is written", () => {
|
||
const line = doc9.querySelector("[data-bwfa-export-summary]");
|
||
assert.strictEqual(line.hidden, false, "no summary shown");
|
||
const text = line.textContent;
|
||
assert(/2 files/.test(text), "summary: " + text);
|
||
assert(/2 channels/.test(text), "summary: " + text);
|
||
assert(/48 kHz/.test(text), "summary: " + text);
|
||
assert(/00:00:02/.test(text), "the length is wrong: " + text);
|
||
assert(!line.classList.contains("is-blocked"), "it thinks it can't do it");
|
||
});
|
||
|
||
click9(doc9.querySelector("[data-bwfa-export-choose]"));
|
||
await waitFor(() => doc9.querySelector("[data-bwfa-export-dest]").value === polyOut, "the destination");
|
||
click9(doc9.querySelector("[data-bwfa-export-run]"));
|
||
await waitFor(() => !doc9.querySelector("[data-bwfa-result-panel]").hidden, "the report", 20000);
|
||
|
||
check("and writes one poly file named after what they share", () => {
|
||
assert.deepStrictEqual(fs.readdirSync(polyOut).sort(), ["A007_POLY.wav"],
|
||
"wrote: " + fs.readdirSync(polyOut).join(", "));
|
||
const info = wav.probe(path.join(polyOut, "A007_POLY.wav"), false);
|
||
assert.strictEqual(info.channels, 2);
|
||
assert.strictEqual(info.frames, 2 * 48000, "the span is wrong: " + info.frames);
|
||
assert(/2 channels/.test(doc9.querySelector("[data-bwfa-export-report]").textContent),
|
||
"report: " + doc9.querySelector("[data-bwfa-export-report]").textContent);
|
||
});
|
||
|
||
// A rate that doesn't match blocks it, in the panel and not just in Rust.
|
||
const mixedCard = path.join(root, "Day 26 mixed");
|
||
fs.mkdirSync(mixedCard, { recursive: true });
|
||
fs.writeFileSync(path.join(mixedCard, "M001.wav"),
|
||
build({ channels: 1, bits: 24, seconds: 1, tcSamples: tenAM }));
|
||
fs.writeFileSync(path.join(mixedCard, "M002_96k.wav"),
|
||
build({ channels: 1, bits: 24, seconds: 1, sampleRate: 96000, tcSamples: tenAM }));
|
||
|
||
const app10 = await launch({ remembered: mixedCard });
|
||
const doc10 = app10.window.document;
|
||
const click10 = (el) => el.dispatchEvent(new app10.window.MouseEvent("click", { bubbles: true }));
|
||
await waitFor(() => rowsIn(doc10).length === 2, "the mixed card");
|
||
click10(doc10.querySelector("[data-bwfa-export-audio]"));
|
||
const channels10 = doc10.querySelector("[data-bwfa-export-channels]");
|
||
channels10.value = "combine";
|
||
channels10.dispatchEvent(new app10.window.Event("change", { bubbles: true }));
|
||
await waitFor(() => doc10.querySelector("[data-bwfa-export-summary]").classList.contains("is-blocked"),
|
||
"the refusal", 5000);
|
||
|
||
check("mismatched rates block it in the panel, with the file named", () => {
|
||
const text = doc10.querySelector("[data-bwfa-export-summary]").textContent;
|
||
assert(/M002_96k\.wav/.test(text), "the odd file isn't named: " + text);
|
||
assert(/one rate/.test(text), text);
|
||
assert.strictEqual(doc10.querySelector("[data-bwfa-export-run]").disabled, true,
|
||
"Export is still offered");
|
||
});
|
||
|
||
app9.window.close();
|
||
app10.window.close();
|
||
|
||
/* ---- every combination of the panel's controls ---- */
|
||
|
||
/* One float take and one 24-bit take, both with timecode so they can also
|
||
be combined, and the whole grid of settings run over them. The rule being
|
||
checked is the one the panel promises: convert means 24-bit, and leave as
|
||
recorded means leave as recorded — including when normalising, splitting
|
||
or combining forces the audio to be rewritten anyway. */
|
||
const grid = path.join(root, "Day 30 grid");
|
||
fs.mkdirSync(grid, { recursive: true });
|
||
fs.writeFileSync(path.join(grid, "G001.wav"), build({
|
||
float: true, bits: 32, channels: 2, seconds: 1, tcSamples: 10 * 3600 * 48000,
|
||
tracks: ["Boom", "Lav"], amplitude: 0.5,
|
||
}));
|
||
fs.writeFileSync(path.join(grid, "G002.wav"), build({
|
||
bits: 24, channels: 2, seconds: 1, tcSamples: 10 * 3600 * 48000 + 48000,
|
||
tracks: ["Plant L", "Plant R"], amplitude: 0.5,
|
||
}));
|
||
|
||
const runs = [
|
||
{ depth: "0", normalise: "off", channels: "keep",
|
||
expect: { "G001.wav": "32-bit float", "G002.wav": "24-bit PCM" }, copies: 2 },
|
||
{ depth: "0", normalise: "linked", channels: "keep",
|
||
expect: { "G001.wav": "32-bit float", "G002.wav": "24-bit PCM" } },
|
||
{ depth: "0", normalise: "each", channels: "keep",
|
||
expect: { "G001.wav": "32-bit float", "G002.wav": "24-bit PCM" } },
|
||
{ depth: "0", normalise: "off", channels: "split", expect: {
|
||
"G001_1_Boom.wav": "32-bit float", "G001_2_Lav.wav": "32-bit float",
|
||
"G002_1_Plant_L.wav": "24-bit PCM", "G002_2_Plant_R.wav": "24-bit PCM",
|
||
} },
|
||
{ depth: "0", normalise: "linked", channels: "combine", poly: "32-bit float" },
|
||
{ depth: "24", normalise: "off", channels: "keep",
|
||
expect: { "G001.wav": "24-bit PCM", "G002.wav": "24-bit PCM" }, copies: 1 },
|
||
{ depth: "24", normalise: "linked", channels: "keep",
|
||
expect: { "G001.wav": "24-bit PCM", "G002.wav": "24-bit PCM" } },
|
||
{ depth: "24", normalise: "off", channels: "split", expect: {
|
||
"G001_1_Boom.wav": "24-bit PCM", "G001_2_Lav.wav": "24-bit PCM",
|
||
"G002_1_Plant_L.wav": "24-bit PCM", "G002_2_Plant_R.wav": "24-bit PCM",
|
||
} },
|
||
{ depth: "24", normalise: "off", channels: "combine", poly: "24-bit PCM" },
|
||
];
|
||
|
||
const gridDests = runs.map((unused, index) => path.join(root, "grid-out-" + index));
|
||
const app11 = await launch({ remembered: grid, dialogAnswers: gridDests });
|
||
const doc11 = app11.window.document;
|
||
const click11 = (el) => el.dispatchEvent(new app11.window.MouseEvent("click", { bubbles: true }));
|
||
await waitFor(() => rowsIn(doc11).length === 2, "the grid card to open");
|
||
|
||
const set = (selector, value) => {
|
||
const control = doc11.querySelector(selector);
|
||
control.value = value;
|
||
control.dispatchEvent(new app11.window.Event("change", { bubbles: true }));
|
||
};
|
||
|
||
for (let index = 0; index < runs.length; index++) {
|
||
const run = runs[index];
|
||
const where = gridDests[index];
|
||
const label = (run.depth === "0" ? "leave as recorded" : "convert to 24-bit") +
|
||
", normalise " + run.normalise + ", " + run.channels;
|
||
|
||
click11(doc11.querySelector("[data-bwfa-export-audio]"));
|
||
set("[data-bwfa-export-depth]", run.depth);
|
||
set("[data-bwfa-export-normalize]", run.normalise);
|
||
set("[data-bwfa-export-channels]", run.channels);
|
||
click11(doc11.querySelector("[data-bwfa-export-choose]"));
|
||
await waitFor(() => doc11.querySelector("[data-bwfa-export-dest]").value === where,
|
||
"the destination for " + label);
|
||
if (run.channels === "combine") {
|
||
await waitFor(() => /48 kHz/.test(
|
||
doc11.querySelector("[data-bwfa-export-summary]").textContent),
|
||
"the plan for " + label, 5000);
|
||
}
|
||
click11(doc11.querySelector("[data-bwfa-export-run]"));
|
||
await waitFor(() => !doc11.querySelector("[data-bwfa-result-panel]").hidden,
|
||
"the report for " + label, 20000);
|
||
|
||
// eslint-disable-next-line no-loop-func
|
||
check(label + " writes what it says", () => {
|
||
const written = fs.readdirSync(where).sort();
|
||
if (run.poly) {
|
||
assert.strictEqual(written.length, 1, "wrote: " + written.join(", "));
|
||
const info = wav.probe(path.join(where, written[0]), false);
|
||
assert.strictEqual(info.format, run.poly,
|
||
written[0] + " came out " + info.format + ", not " + run.poly);
|
||
assert.strictEqual(info.channels, 4, "the poly has " + info.channels + " channels");
|
||
return;
|
||
}
|
||
assert.deepStrictEqual(written, Object.keys(run.expect).sort(),
|
||
"wrote: " + written.join(", "));
|
||
Object.keys(run.expect).forEach((name) => {
|
||
const info = wav.probe(path.join(where, name), false);
|
||
assert.strictEqual(info.format, run.expect[name],
|
||
name + " came out " + info.format + ", not " + run.expect[name]);
|
||
});
|
||
if (run.copies) {
|
||
const copied = Number(doc11.querySelector(
|
||
"[data-bwfa-result-count=\"copied\"] strong").textContent);
|
||
assert.strictEqual(copied, run.copies,
|
||
"copied " + copied + ", expected " + run.copies);
|
||
}
|
||
});
|
||
}
|
||
|
||
/* ---- naming the copies from the metadata ---- */
|
||
|
||
/* A card as it comes off the recorder — T-numbers, nothing meaningful in
|
||
the name — with the scene and take that post actually wants in it. One
|
||
take has neither, and two share a slate, because both of those turn up
|
||
on a real day and both have to behave. */
|
||
const nameCard = path.join(root, "Day 40 naming");
|
||
fs.mkdirSync(path.join(nameCard, "260814"), { recursive: true });
|
||
const takes = [
|
||
{ file: "T001.WAV", scene: "12A", take: "3" },
|
||
{ file: "T002.WAV", scene: "12A", take: "4" },
|
||
{ file: "T003.WAV", scene: "", take: "" },
|
||
{ file: "T004.WAV", scene: "12A", take: "3" }
|
||
];
|
||
takes.forEach((one) => {
|
||
fs.writeFileSync(path.join(nameCard, one.file), build({
|
||
bits: 24, channels: 2, seconds: 1, scene: one.scene, take: one.take,
|
||
tape: "A007", tracks: ["Boom", "Lav"],
|
||
}));
|
||
});
|
||
// Same slate again, one subfolder down: legitimate, and must not be
|
||
// mistaken for a clash.
|
||
fs.writeFileSync(path.join(nameCard, "260814", "T001.WAV"), build({
|
||
bits: 24, channels: 2, seconds: 1, scene: "12A", take: "3", tape: "A007",
|
||
}));
|
||
|
||
const namedOut = [0, 1, 2, 3].map((n) => path.join(root, "named-" + n));
|
||
const nameLog = path.join(root, "named.txt");
|
||
const app12 = await launch({
|
||
remembered: nameCard,
|
||
dialogAnswers: namedOut,
|
||
saveAnswers: [nameLog]
|
||
});
|
||
const doc12 = app12.window.document;
|
||
const click12 = (el) => el.dispatchEvent(new app12.window.MouseEvent("click", { bubbles: true }));
|
||
const set12 = (selector, value) => {
|
||
const control = doc12.querySelector(selector);
|
||
control.value = value;
|
||
control.dispatchEvent(new app12.window.Event("change", { bubbles: true }));
|
||
};
|
||
const type12 = (selector, value) => {
|
||
const control = doc12.querySelector(selector);
|
||
control.value = value;
|
||
control.dispatchEvent(new app12.window.Event("input", { bubbles: true }));
|
||
};
|
||
await waitFor(() => rowsIn(doc12).length === 5, "the naming card to open");
|
||
|
||
click12(doc12.querySelector("[data-bwfa-export-audio]"));
|
||
click12(doc12.querySelector("[data-bwfa-export-choose]"));
|
||
await waitFor(() => doc12.querySelector("[data-bwfa-export-dest]").value === namedOut[0],
|
||
"the destination");
|
||
|
||
check("names are left alone unless asked otherwise", () => {
|
||
assert.strictEqual(doc12.querySelector("[data-bwfa-export-naming]").value, "",
|
||
"the naming control didn't start on leave-alone");
|
||
assert.strictEqual(doc12.querySelector("[data-bwfa-export-names]").hidden, true,
|
||
"a preview appeared with nothing to preview");
|
||
assert.strictEqual(doc12.querySelector("[data-bwfa-export-pattern-row]").hidden, true,
|
||
"the pattern field is showing without Custom chosen");
|
||
});
|
||
|
||
set12("[data-bwfa-export-naming]", "custom");
|
||
type12("[data-bwfa-export-pattern]", "{scene}-{take}_{n}");
|
||
|
||
check("the new names are shown before anything is written", () => {
|
||
const box = doc12.querySelector("[data-bwfa-export-names]");
|
||
assert.strictEqual(box.hidden, false, "no preview");
|
||
const rows = box.querySelectorAll(".bwfa-export-name-row");
|
||
assert.strictEqual(rows.length, 4, "previewed " + rows.length + " rows");
|
||
// The subfolder sorts first, which is the order the table is in and
|
||
// therefore the order {n} counts in.
|
||
assert.strictEqual(rows[0].querySelector("span").textContent, "260814/T001.WAV");
|
||
assert.strictEqual(rows[0].querySelector("strong").textContent,
|
||
"260814/12A-3_001.WAV");
|
||
assert(/and 1 more/.test(box.textContent), "the count of the rest is missing");
|
||
});
|
||
|
||
set12("[data-bwfa-export-naming]", "{scene}-{take}");
|
||
|
||
check("two takes on the same slate stop the run and are named", () => {
|
||
// Two of these are both 12A-3. Better to refuse than to deliver a
|
||
// folder where one take has quietly overwritten another.
|
||
const box = doc12.querySelector("[data-bwfa-export-names]");
|
||
assert(/12A-3\.WAV/.test(box.textContent) && /Add \{n\}/.test(box.textContent),
|
||
"preview reads: " + box.textContent);
|
||
assert.strictEqual(doc12.querySelector("[data-bwfa-export-run]").disabled, true,
|
||
"Export was still offered with a name clash");
|
||
});
|
||
|
||
check("a clash is refused by the run itself, not just by a grey button", () => {
|
||
// A disabled attribute is a piece of UI state, not a guarantee. If
|
||
// anything ever gets past it the run has to stop on its own, because
|
||
// two takes on one path is a take that no longer exists.
|
||
click12(doc12.querySelector("[data-bwfa-export-run]"));
|
||
assert.strictEqual(doc12.querySelector("[data-bwfa-result-panel]").hidden, true,
|
||
"the run started with a name clash outstanding");
|
||
assert(!fs.existsSync(namedOut[0]), "it wrote something anyway");
|
||
});
|
||
|
||
set12("[data-bwfa-export-naming]", "custom");
|
||
|
||
check("Custom pattern reveals the field and the tokens it takes", () => {
|
||
assert.strictEqual(doc12.querySelector("[data-bwfa-export-pattern-row]").hidden, false,
|
||
"the pattern field stayed hidden");
|
||
const tokens = doc12.querySelector(".bwfa-export-tokens").textContent;
|
||
["{scene}", "{take}", "{tape}", "{date}", "{tc}", "{name}", "{n}"].forEach((token) => {
|
||
assert(tokens.indexOf(token) !== -1, token + " isn't offered");
|
||
});
|
||
});
|
||
|
||
type12("[data-bwfa-export-pattern]", "{scene}-{take}-{nope}");
|
||
|
||
check("a token nobody has heard of is refused, by name", () => {
|
||
const box = doc12.querySelector("[data-bwfa-export-names]");
|
||
assert(/\{nope\} isn't a name I know/.test(box.textContent), "reads: " + box.textContent);
|
||
assert.strictEqual(doc12.querySelector("[data-bwfa-export-run]").disabled, true);
|
||
});
|
||
|
||
type12("[data-bwfa-export-pattern]", "{scene}/{take}");
|
||
|
||
check("a pattern that would make folders is refused", () => {
|
||
assert(/can't contain a slash/.test(
|
||
doc12.querySelector("[data-bwfa-export-names]").textContent));
|
||
assert.strictEqual(doc12.querySelector("[data-bwfa-export-run]").disabled, true);
|
||
});
|
||
|
||
type12("[data-bwfa-export-pattern]", "{tape}_{scene}-{take}_{n}");
|
||
click12(doc12.querySelector("[data-bwfa-export-run]"));
|
||
await waitFor(() => !doc12.querySelector("[data-bwfa-result-panel]").hidden,
|
||
"the naming run", 20000);
|
||
|
||
check("the files come out under the names the preview promised", () => {
|
||
const written = fs.readdirSync(namedOut[0]).sort();
|
||
assert.deepStrictEqual(written, [
|
||
"260814",
|
||
"A007_004.WAV",
|
||
"A007_12A-3_002.WAV",
|
||
"A007_12A-3_005.WAV",
|
||
"A007_12A-4_003.WAV"
|
||
], "wrote: " + written.join(", "));
|
||
});
|
||
|
||
check("the extension is left exactly as the recorder wrote it", () => {
|
||
// .WAV stays .WAV. Changing the case of a name is invisible on a Mac
|
||
// and breaks a relink on anything case-sensitive.
|
||
assert(fs.readdirSync(namedOut[0]).every((name) =>
|
||
name === "260814" || /\.WAV$/.test(name)),
|
||
"wrote: " + fs.readdirSync(namedOut[0]).join(", "));
|
||
});
|
||
|
||
check("a take with no scene or take loses the separator too", () => {
|
||
// T003 has neither, so {scene}-{take} contributes nothing and the dash
|
||
// that joined them goes with it, rather than leaving "A007_-_004".
|
||
assert(fs.existsSync(path.join(namedOut[0], "A007_004.WAV")),
|
||
"wrote: " + fs.readdirSync(namedOut[0]).join(", "));
|
||
});
|
||
|
||
check("the same name in two folders is not a clash", () => {
|
||
const inner = fs.readdirSync(path.join(namedOut[0], "260814"));
|
||
assert.deepStrictEqual(inner, ["A007_12A-3_001.WAV"], "wrote: " + inner.join(", "));
|
||
});
|
||
|
||
check("the report says what each file was called", () => {
|
||
const text = doc12.querySelector("[data-bwfa-export-report]").textContent;
|
||
assert(/as A007_12A-4_003\.WAV/.test(text), "report: " + text);
|
||
});
|
||
|
||
await checkAsync("the log carries the pattern as well as the names", async () => {
|
||
click12(doc12.querySelector("[data-bwfa-result-save]"));
|
||
await waitFor(() => fs.existsSync(nameLog), "the log", 5000);
|
||
const log = fs.readFileSync(nameLog, "utf8");
|
||
assert(/File names:\s+\{tape\}_\{scene\}-\{take\}_\{n\}/.test(log),
|
||
"the pattern isn't in the log:\n" + log);
|
||
assert(/as A007_12A-4_003\.WAV/.test(log), "the new names aren't in the log:\n" + log);
|
||
});
|
||
|
||
check("the originals still have the names the recorder gave them", () => {
|
||
const still = fs.readdirSync(nameCard).sort();
|
||
assert.deepStrictEqual(still, ["260814", "T001.WAV", "T002.WAV", "T003.WAV", "T004.WAV"],
|
||
"the card was renamed: " + still.join(", "));
|
||
});
|
||
|
||
/* ---- naming and the other channel modes ---- */
|
||
|
||
click12(doc12.querySelector("[data-bwfa-export-audio]"));
|
||
set12("[data-bwfa-export-naming]", "custom");
|
||
type12("[data-bwfa-export-pattern]", "{scene}-{take}_{n}");
|
||
set12("[data-bwfa-export-channels]", "split");
|
||
click12(doc12.querySelector("[data-bwfa-export-choose]"));
|
||
await waitFor(() => doc12.querySelector("[data-bwfa-export-dest]").value === namedOut[1],
|
||
"the second destination");
|
||
click12(doc12.querySelector("[data-bwfa-export-run]"));
|
||
await waitFor(() => !doc12.querySelector("[data-bwfa-result-panel]").hidden,
|
||
"the split run", 20000);
|
||
|
||
check("split monos are numbered off the new name, not the old one", () => {
|
||
const written = fs.readdirSync(namedOut[1]).sort();
|
||
assert(written.indexOf("12A-3_002_1_Boom.WAV") !== -1, "wrote: " + written.join(", "));
|
||
assert(written.indexOf("12A-3_002_2_Lav.WAV") !== -1, "wrote: " + written.join(", "));
|
||
assert(written.every((name) => !/^T00/.test(name)),
|
||
"something kept its recorder name: " + written.join(", "));
|
||
});
|
||
|
||
click12(doc12.querySelector("[data-bwfa-export-audio]"));
|
||
set12("[data-bwfa-export-channels]", "combine");
|
||
|
||
check("a combine can't be started before its plan comes back", () => {
|
||
// The plan is a round trip. Between asking and answering there is
|
||
// nothing to check the export against, so there is nothing to export.
|
||
assert.strictEqual(doc12.querySelector("[data-bwfa-export-run]").disabled, true,
|
||
"Export was live while the combine plan was still in flight");
|
||
});
|
||
|
||
check("combining has nothing to name a file after, and says so by absence", () => {
|
||
// One file out of many: a per-file pattern has nothing to resolve
|
||
// against, so the control goes rather than sitting there lying.
|
||
assert.strictEqual(doc12.querySelector("[data-bwfa-export-naming-row]").hidden, true,
|
||
"the naming control is still offered for a combine");
|
||
assert.strictEqual(doc12.querySelector("[data-bwfa-export-names]").hidden, true,
|
||
"a name preview is showing for a combine");
|
||
});
|
||
|
||
set12("[data-bwfa-export-channels]", "keep");
|
||
|
||
check("and it comes back when the mode does", () => {
|
||
assert.strictEqual(doc12.querySelector("[data-bwfa-export-naming-row]").hidden, false);
|
||
assert.strictEqual(doc12.querySelector("[data-bwfa-export-names]").hidden, false,
|
||
"the preview didn't return with the control");
|
||
});
|
||
|
||
app12.window.close();
|
||
|
||
/* ---- names off a card somebody else made ---- */
|
||
|
||
/* Every token value came out of a file this app didn't write, so the
|
||
fields are treated as hostile: a scene that is a path, a scene that is a
|
||
Windows device, a scene padded with dots. None of these are theoretical
|
||
on a card that has been through three assistants and a Windows laptop. */
|
||
const nastyCard = path.join(root, "Day 41 nasty");
|
||
fs.mkdirSync(nastyCard, { recursive: true });
|
||
const nasties = [
|
||
{ file: "X001.WAV", scene: "../../escape", take: "1" },
|
||
{ file: "X002.WAV", scene: "CON", take: "2" },
|
||
{ file: "X003.WAV", scene: "12A.", take: "3" },
|
||
{ file: "X004.WAV", scene: "a:b*c?d", take: "4" },
|
||
{ file: "X005.WAV", scene: "12B", take: "5" }
|
||
];
|
||
nasties.forEach((one) => {
|
||
fs.writeFileSync(path.join(nastyCard, one.file), build({
|
||
bits: 24, channels: 1, seconds: 1, scene: one.scene, take: one.take, tape: "A008",
|
||
}));
|
||
});
|
||
|
||
const nastyOut = path.join(root, "nasty-out");
|
||
const app13 = await launch({ remembered: nastyCard, dialogAnswers: [nastyOut] });
|
||
const doc13 = app13.window.document;
|
||
const click13 = (el) => el.dispatchEvent(new app13.window.MouseEvent("click", { bubbles: true }));
|
||
await waitFor(() => rowsIn(doc13).length === 5, "the hostile card to open");
|
||
|
||
click13(doc13.querySelector("[data-bwfa-export-audio]"));
|
||
const naming13 = doc13.querySelector("[data-bwfa-export-naming]");
|
||
naming13.value = "custom";
|
||
naming13.dispatchEvent(new app13.window.Event("change", { bubbles: true }));
|
||
const pattern13 = doc13.querySelector("[data-bwfa-export-pattern]");
|
||
pattern13.value = "{scene}";
|
||
pattern13.dispatchEvent(new app13.window.Event("input", { bubbles: true }));
|
||
naming13.dispatchEvent(new app13.window.Event("change", { bubbles: true }));
|
||
click13(doc13.querySelector("[data-bwfa-export-choose]"));
|
||
await waitFor(() => doc13.querySelector("[data-bwfa-export-dest]").value === nastyOut,
|
||
"the destination");
|
||
click13(doc13.querySelector("[data-bwfa-export-run]"));
|
||
await waitFor(() => !doc13.querySelector("[data-bwfa-result-panel]").hidden,
|
||
"the hostile run", 20000);
|
||
|
||
check("a scene that is a path cannot become one", () => {
|
||
const written = fs.readdirSync(nastyOut).sort();
|
||
assert.strictEqual(written.length, 5, "wrote: " + written.join(", "));
|
||
written.forEach((name) => {
|
||
assert(name.indexOf("/") === -1, name + " kept a separator in it");
|
||
});
|
||
// Flat inside the destination, and nothing beside it.
|
||
assert(!fs.existsSync(path.join(root, "escape.WAV")), "a file escaped the folder");
|
||
assert(!fs.existsSync(path.join(nastyOut, "..", "escape.WAV")), "a file escaped upwards");
|
||
});
|
||
|
||
check("a Windows device name is defused", () => {
|
||
// CON.WAV cannot be opened on any Windows machine the delivery reaches,
|
||
// and nothing goes wrong on the Mac that wrote it, so this is found by
|
||
// the client rather than by the operator.
|
||
const written = fs.readdirSync(nastyOut);
|
||
assert(written.indexOf("CON.WAV") === -1, "wrote a file Windows cannot open");
|
||
assert(written.indexOf("CON_.WAV") !== -1, "wrote: " + written.join(", "));
|
||
});
|
||
|
||
check("forbidden characters and trailing dots are dealt with", () => {
|
||
const written = fs.readdirSync(nastyOut).sort();
|
||
written.forEach((name) => {
|
||
assert(!/[:*?"<>|\\]/.test(name), name + " has a character Windows refuses");
|
||
});
|
||
assert(written.indexOf("a_b_c_d.WAV") !== -1, "wrote: " + written.join(", "));
|
||
assert(written.indexOf("12A.WAV") !== -1,
|
||
"a trailing dot survived: " + written.join(", "));
|
||
});
|
||
|
||
check("an invisible character can't make two names that look the same", () => {
|
||
// "12B" and "12B" with a zero-width space are one name to a reader and
|
||
// two to a comparison, which is how a take goes missing without anybody
|
||
// being able to see why.
|
||
const written = fs.readdirSync(nastyOut);
|
||
assert(written.indexOf("12B.WAV") !== -1, "wrote: " + written.join(", "));
|
||
written.forEach((name) => {
|
||
assert(!/[\u200b-\u200f\u202a-\u202e\ufeff]/.test(name),
|
||
"an invisible character survived into " + JSON.stringify(name));
|
||
});
|
||
});
|
||
|
||
/* ---- exporting into the same folder twice ---- */
|
||
|
||
click13(doc13.querySelector("[data-bwfa-export-audio]"));
|
||
click13(doc13.querySelector("[data-bwfa-export-run]"));
|
||
await waitFor(() => !doc13.querySelector("[data-bwfa-result-panel]").hidden &&
|
||
/left alone/.test(doc13.querySelector("[data-bwfa-export-report]").textContent),
|
||
"the second hostile run", 20000);
|
||
|
||
check("a skipped file is reported under the name it would have had", () => {
|
||
// The file already in the folder is the renamed one. Reporting the
|
||
// recorder's name sends the reader looking for something that was
|
||
// never written.
|
||
const text = doc13.querySelector("[data-bwfa-export-report]").textContent;
|
||
assert(/already in that folder as \S+, left alone/.test(text), "report: " + text);
|
||
});
|
||
|
||
check("the report says how much was in the folder before the run", () => {
|
||
const note = doc13.querySelector("[data-bwfa-result-note]").textContent;
|
||
assert(/5 recordings were already in that folder/.test(note), "note: " + note);
|
||
});
|
||
|
||
app13.window.close();
|
||
|
||
check("the sources are all still what they were", () => {
|
||
assert.strictEqual(wav.probe(path.join(grid, "G001.wav"), false).format, "32-bit float");
|
||
assert.strictEqual(wav.probe(path.join(grid, "G002.wav"), false).format, "24-bit PCM");
|
||
});
|
||
|
||
check("the summary line names the format the poly will be", () => {
|
||
const text = doc11.querySelector("[data-bwfa-export-summary]").textContent;
|
||
assert(/24-bit PCM/.test(text), "summary reads: " + text);
|
||
});
|
||
|
||
app11.window.close();
|
||
|
||
check("no script errors on the page", () => {
|
||
assert.strictEqual(doc4.querySelectorAll("[data-bwfa-export-panel]").length, 1);
|
||
});
|
||
|
||
app4.window.close();
|
||
app.window.close();
|
||
|
||
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);
|
||
});
|