/** * 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(/32<\/AUDIO_BIT_DEPTH>/.test(before), "the sample lost its bit depth element"); assert(/24<\/AUDIO_BIT_DEPTH>/.test(after), "iXML still claims the old depth, so the file contradicts itself"); assert.strictEqual(after.replace("24<", "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(/[\s\S]*?<\/TRACK_LIST>/)[0]; assert(/1<\/TRACK_COUNT>/.test(list), "track count is still the poly one: " + list); assert.strictEqual((list.match(//g) || []).length, 1, "more than one track survived: " + list); assert(/3<\/CHANNEL_INDEX>/.test(list), "the original input number was lost, and that's the provenance: " + list); assert(/1<\/INTERLEAVE_INDEX>/.test(list), "interleave index should be 1 in a mono file: " + list); assert(/Plant FX<\/NAME>/.test(list), "the track name was lost: " + list); assert(/24<\/AUDIO_BIT_DEPTH>/.test(xml), "iXML still claims 32-bit"); assert(/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(/[\s\S]*?<\/TRACK_LIST>/)[0]; assert(/2<\/TRACK_COUNT>/.test(list), "track count: " + list); assert.strictEqual((list.match(//g) || []).length, 2, "wrong number of tracks: " + list); assert(/1<\/CHANNEL_INDEX>13<\/CHANNEL_INDEX>2Plant 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(/[\s\S]*?<\/TRACK_LIST>/)[0]; assert(/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(/[\s\S]*?<\/TRACK_LIST>/)[0]; assert(/2<\/TRACK_COUNT>/.test(list), list); assert(/Boom<\/NAME>/.test(list) && /Lav Anna<\/NAME>/.test(list), list); assert(/2<\/CHANNEL_INDEX>2 { 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(/[\s\S]*?<\/TRACK_LIST>/)[0]; assert(/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(/[\s\S]*?<\/TRACK_LIST>/)[0]; assert(/Boom<\/NAME>/.test(list) && /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); });