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