A 16.7 MB take had been committed and made up the whole of the repository. It is out of the history now, and ignored so it cannot come back: the tests build their own fixtures in temp directories and never need one here.
BWF Analyser — standalone
mac-app/ — a real macOS app. Double-click Build BWF Analyser.command once and it produces BWF Analyser.app next to itself. No server, no internet, no dependencies to install beyond Rust; everything the WordPress plugin did is in it, jsPDF included.
There was a second, browser-only build of the same source — a self-contained index.html you opened directly. It has been removed, along with its shim, its shell CSS and its test suite. Passages below that compare the app against "the browser build" are describing that history, not something you can still build.
The Mac app
Building it
Open the mac-app folder and double-click Build BWF Analyser.command. It checks for Apple's command line tools and Rust, installs or updates Rust as needed (into ~/.cargo, removable with rustup self uninstall), compiles, assembles the .app, signs it locally, and opens it.
Rust 1.85 or newer is required — not by Tauri, which asks for 1.77, but by crates deep in its dependency tree that are published as edition 2024. An older toolchain fails with a confusing manifest error from some transitive dependency, so the script checks the version and runs rustup update stable itself. If your Rust came from Homebrew rather than rustup, it says so and installs rustup alongside it.
First run takes 5 to 20 minutes, mostly Rust compiling a few hundred crates. Every run after that is seconds. If something fails, the Terminal window stays open with the error.
Rebuilding replaces the .app in place — nothing to delete first. If it's running, the script quits it before rebuilding: replacing a bundle under a live process is allowed, but open would then just activate the instance already running, which looks exactly like a build that silently did nothing. A copy you dragged to /Applications is not updated; re-copy it.
Nothing is downloaded except Rust and the crates it needs. The app itself never touches the network.
How it differs from the plugin it came from
It's editor-only. There's one way in — Open Folder, or drop a folder anywhere in the window — and it opens every recording read-write. The browser build's read-only Select Folder / Select Files / Clear row is gone, along with the page framing and the in-app title: the app fills the window at any width (the plugin's 960px centred column is lifted), the window's own title bar is left blank next to the traffic lights, and the table header stays put while you scroll a long day.
The read-only markup is still in the file, hidden by CSS rather than deleted, because the analyser wires event listeners to those nodes at startup and the bridge still hands files back through the inputs inside them. Unhiding it is a one-line CSS change if you ever want it back.
Underneath, file access goes through Rust rather than the webview, because Safari's engine has no File System Access API and an unreliable folder input — the editing features would simply be dead otherwise:
- Reading is ranged. Pulling scene and take out of a 4 GB file reads a few KB of chunk headers, not 4 GB. Files above 64 MB are transferred in pieces so nothing sits in memory three times over.
- Editing works, which it can't in Safari or Firefox. Fixed-width fields are patched in place; the whole-file rebuild path is chunked.
- Both exports open a save panel. CSV and PDF take different routes to it: the app's CSV download goes through a link, while jsPDF clicks a link it never adds to the document, so its
save()is intercepted at the instance instead. - Drag and drop works, including folders. A dropped folder is parked and the app's own button clicked, so it opens through exactly the same path as picking one.
- Exporting copies converts 32-bit float to 24-bit, or leaves the depth exactly as recorded, and can normalise on the way, streaming through a fixed buffer so file size doesn't matter. See below.
In the table, Play and Details now sit together in the first two columns rather than at opposite ends of the row. Both are ordinary text buttons, exactly as the plugin styles them.
The native behaviour comes from a bridge that hands the app the File and FileSystemFileHandle objects it already expects — nothing in the app itself knows it isn't in a browser. The plugin's CSS is untouched; its JavaScript gets three small patches on the way into the app build, all of them moving the Details column, applied in build.py and listed there. build/src/ keeps the originals, and a patch that stops matching fails the build rather than quietly doing nothing.
Window state
The window remembers its size and position between launches, via tauri-plugin-window-state. Maximised state is remembered too; fullscreen deliberately isn't — quitting from fullscreen and reopening into it is more startling than useful. The state file lives in the app's own config directory; delete it and you're back to the 1360x900 default.
macOS permissions
The first time you pick a folder inside Documents, Desktop or Downloads, macOS may ask whether the app can access it. That prompt is macOS being macOS; the app has no network access and no other entitlements.
Since you built it yourself, there's no Gatekeeper warning. It also isn't notarised, so copying the .app to another machine will trip Gatekeeper there.
Layout fixes
These live in build/overrides.css, on top of the plugin's own stylesheet rather than inside it.
The detail modal laid its metadata out with repeat(auto-fill, minmax(180px, 1fr)), and dt/dd are separate grid items flowing in sequence — so any odd number of columns splits the pairs, and every other row reads inside out. It's pinned to exactly two label/value pairs per row now, dropping to one when the window is narrow, and the modal is wider to suit.
Both edit forms were ragged. The per-file one for two reasons. Its grid used align-items: end, which bottom-aligns cells of differing heights and scatters the labels; that's stretch now, with each control pinned to the bottom of its cell so a row shares one baseline. And a native <select> sizes itself from the platform's own metrics, ignoring the padding that gives an <input> its height — so both are given an explicit 38px, with the select's appearance reset and its own chevron drawn in. Number-field steppers are hidden too; they were noise on fields nobody nudges.
Bulk edit had its own version of the problem, and a redundant control on top of it. It used to arm each field with a checkbox; now a field you fill in gets written to every file and a field left blank is left alone, which is the same information without the extra click. That needed three things to hold: the two boolean fields (Circled, Wild Track) became three-way selects, since a checkbox has no blank state to mean "don't touch this"; the frame-rate selects gained the (no change) first option the per-file form already had, or they'd have applied on every run; and Apply arms itself off whether anything is filled in. Layout follows the per-file form — label over control, one height throughout, grid capped at 1080px.
The trade-off is that you can no longer deliberately blank a field across a selection. Clearing one file at a time still works.
Note and Description are the only free-text fields in either form, and they were being laid out as leftovers — Note in whatever cell was going spare, Description alone on a row below it. They're a pair on their own row now, same width and same height, which gives the form a bottom edge instead of a ragged tail.
Nothing in the cog's settings is prose, just switches and short labels, so the whole dialog steps down a size — type, radios and the switch track together, since scaling only the text leaves the controls looking like they belong to a different form.
Sample rate is a menu, and reads in kHz
The two sample-rate fields were free number entry, and a typo there is a file that lies about itself. There are maybe nine rates in professional use, so they're a list: 32k, 44.1k, 47952, 48k, 48048, 88.2k, 96k, 176.4k, 192k. The pull rates are in there because this is a location tool — 47952 and 48048 are what a 0.1% pull looks like. A file carrying a rate that isn't on the list gets it added as an option marked "(as recorded)", so the form can show what's there without offering to change it by accident.
Rates read in kHz wherever a person reads them — the table, the detail view, the menus, the PDF — because nobody says "forty-eight thousand hertz". That needed a fix of its own: the formatter used one decimal, which turns 47952 into "48.0 kHz", and 47.952 kHz is a rate in its own right rather than a rounded 48. Three decimals with the trailing zeros trimmed handles both.
What stays in hertz: everything written to a file, and the CSV. The fmt chunk and iXML's rate fields are integers in hertz by specification and the menus only ever change the label, never the value. The CSV is machine-read and the convention there — Wave Agent, the recorders' own exports, the file itself — is the integer, so its column is still Sample Rate (Hz) carrying 48000. Displaying kHz is normal; exporting it would not be.
Frame rate is not one field
Writing frame rate correctly means writing it in every place a reader might look, in the form the standards define. The original wrote one field, as a decimal, which is why other software showed the rate as blank:
- iXML
SPEED/TIMECODE_RATEis a rational —30/1, and 29.97 is exactly30000/1001, never29.97. A strict reader rejects the decimal form rather than guessing. This was the main bug. TIMECODE_FLAG(NDF/DF) belongs with it, and is supplied if the file lacks one. Drop-frame only means anything on the 1000/1001 rates, so DF asked for on 25fps is written as NDF rather than as nonsense.MASTER_SPEEDandCURRENT_SPEEDfollow the rate, since some readers take the frame rate fromMASTER_SPEED. They're only touched when they agree with each other and with the rate being replaced — when they disagree they describe a pull-up/pull-down relationship, and flattening that would destroy real information about how the file was recorded.- bext has no frame-rate field; EBU 3285 never defined one. What it has is a 256-byte free-text Description, into which Sound Devices-style recorders pack tags like
aSPEED=025.000-ND. Plenty of software reads that tag, so it's rewritten in step — number and NDF/DF only, preserving the recorder's own prefix letter, digit padding and separator. A file that never had the tag doesn't get one invented.
Only a genuine rate change triggers the extra writes. The rate select is pre-filled from the file, so it's submitted on every save; saving a scene name shouldn't rewrite the SPEED block.
Two consequences worth knowing. Adding a missing element can push the iXML past the slack the recorder reserved, which means rewriting the whole file — correct, but slow on a large take. And the timecode itself legitimately re-renders: the sample count doesn't change, so the same instant reads as 01:06:53:19 at 25fps and 01:06:53:23 at 30.
The sticky header, in WebKit
Rows painted over the column header when scrolling. Two framework rules each break position: sticky on a table header in Safari on their own, and both were in play:
border-collapse: collapse— collapsed borders belong to the table grid rather than to the cells, and Safari has never painted a sticky header correctly through one. It'sseparatewith zero spacing now, which looks identical; each cell draws its own bottom border, which is what collapse was doing anyway.-webkit-overflow-scrolling: touchon the scroll container — it hands scrolling to a separate compositing layer, a long-standing source of stale pixels and sticky elements that scroll away. Momentum scrolling is the platform's job on macOS regardless.
And the actual culprit, found only after those two didn't fix it: the plugin gives every sortable header position: relative for its sort arrow, through a selector that outranks a plain thead th. Fourteen of seventeen headers were therefore never sticky at all. The three that held — the two action columns, Description and Note — are the ones that aren't sortable, which is exactly the half-stuck header you'd see. The rule now names both selectors.
The header also gets an explicit opaque background and a z-index above the row backgrounds that were showing through it.
The test that missed this checked only the first header cell, which happened to be one of the three that worked. It now checks every cell and reports which ones failed by name; reverting the fix in a scratch build makes it fail with "13 of 17 headers aren't sticky".
Chrome and state
State used to be signalled with coloured slivers — a gold inset down the first cell of a circled take, a blue one on the playing row, a blue rule under the header. All three are gone. A playing row is now green across its whole width, which reads from across a room; circled takes are still reported by their own column.
Play and Pause are different lengths as words, so the button used to resize mid-playback and shove the row sideways. Both states now share one width.
Text selection is off outside form fields. Dragging a selection across a table of takes only ever looks like a mistake; fields you type into keep it.
Launch and reopen
One rule underpins all of this: .bwfa-scope [hidden] { display: none !important }. [hidden] { display: none } comes from the UA stylesheet, so any author rule setting display silently beats it — the plugin sets display: flex on the player and the app shell does the same for the results panel, which left the toolbar, an empty table and the transport on screen before a folder was ever opened, hidden attribute and all.
With nothing open the window is a launch screen: the app's icon (inlined from the .icns source set, so the frontend stays one file) above the one button there is to press, centred, with the status line and the "No files analysed yet" placeholder hidden — there's nothing yet to report.
The last folder is remembered and reopened on the next launch. It's checked first: an ejected card, an unmounted drive, a renamed folder — any of those just leave the launch screen up, with the stale path dropped so it can't fail twice. Reopening goes through the app's own button, the same route a drop takes, so there's one code path into edit mode rather than three.
Once a folder is open, the panel collapses to a single line — the folder's name, a round eject button, and a button that now reads "Change Folder". The table gets the room back.
Eject is the way back out. It clears the table, forgets the remembered path, drops the folder name and puts the button back to "Open Folder", so you land on the same launch screen a first run gives you — and the next launch starts there too, rather than reopening the folder you just closed. It only appears while something is open, since with an empty app there's nothing to eject.
The player stays put while a long day scrolls past it. The first attempt — position: fixed plus reserved page padding — didn't hold: the plugin sets .table-responsive to overflow-y: hidden, so the table never scrolls itself, the window does, and the last rows end up under the footer no matter how much padding is reserved. The layout is explicit instead: the app is a column exactly as tall as the window, the table region is the one scrolling box, and the player is an ordinary block ordered after it. There's no clearance to get wrong, because the scrolling box ends where the player begins — and the sticky table header now sticks to the top of that box, which is what you want anyway.
Opening a folder with nothing in it
The analyser reports "no files found" and returns without touching anything — which left the previous folder's rows, its player and its playback on screen, under a warning about a folder they had nothing to do with. The bridge now walks the folder before handing over a handle: if there's nothing to open it clears the table through the app's own Clear button, names the folder you actually chose, writes the warning, and rejects with AbortError so the app treats it as a cancelled pick. A scan that fails outright isn't evidence of an empty folder, so that case carries on and lets the app report the real error.
Clear resets the app's state and hides the results but leaves the rendered rows in the DOM, so the bridge empties the table body too — invisible rows are still there for anything that goes looking.
Button labels
Labels lost their ellipses. "Bulk Edit…" became "Bulk Edit", "Open a Folder…" became "Open Folder", and so on; the convention is worth keeping for progress text ("Reading 3 of 40…") and nowhere else. A test fails the build if a button label starts trailing off again.
The player gained an Edit button next to the filename, which opens the metadata of whatever is playing. Without it, finding the row you started from means scrolling back up a long day.
Button colour is the framework's own: primary blue, secondary grey, outline. Tinting them by what they do was tried and reverted — it read as busy rather than as helpful.
Playback is the app's, not the webview's
The player used to be the Web Audio API inside the WKWebView, and it kept failing the same way: the machine sat idle for a while, and afterwards the transport ran, the clock advanced, the waveform moved, and nothing came out of the speakers. Loading another folder didn't help. Only quitting and reopening the app did.
I fixed this three times and it came back three times, because all three fixes were aimed at the wrong layer. What settled it was one question: does the Reopen the audio output button, which reloaded the page, bring the sound back? It doesn't. A page reload builds a brand new document and a brand new AudioContext, so if that is still silent the fault is below the page, in the WebKit content process that renders our audio. Nothing in JavaScript can reach that. Rebuilding a context inside a process whose audio is already dead just builds a second dead context. It is a known WebKit failure, reported for years, with "close and recreate the context" listed as the workaround and noted as not working for everybody.
So the page stopped making sound. src-tauri/src/play.rs owns a cpal output stream; the page asks it to play and reads the position back off an event. When a device changes or a stream faults, the engine reopens it in process, at the position the file was at, and says so in the status line. That case used to be the unrecoverable one.
The shape is a single engine thread, because a cpal stream is not Send on macOS and can't be parked in a global, so commands reach it over a channel. A reader thread streams the file from disk: the old player decoded whole files into memory to play them, which works on the takes you test with and falls over on a day file. The reader blocks when the queue is full rather than polling, so a paused transport costs nothing. Gains are one atomic per channel, read in the audio callback, so muting a track takes effect on the next buffer rather than after whatever was already queued.
The callback does the summing rather than the reader, for that reason, and the resampler runs on the reader thread. The device is opened at the file's own rate whenever it supports it, which on a location card and a Mac is nearly always, so nearly always there is no resampling at all. When there is, it's linear interpolation with no anti-alias filter, which is a monitor path and not a deliverable.
A rebuild is backed off and then given up on: a device that enumerates but won't play would otherwise be reopened ten times a second forever, each time a fresh header parse, file handle, thread and audio unit. A read that fails part way through is reported as a fault rather than as the end of the file, because a card pulled mid-take and a take that finished are not the same thing.
The waveform comes from Rust too, bucketed into columns by a streaming pass, so drawing one no longer means decoding a four-hour file into memory. It has to agree column for column with the browser build, which draws the same picture from a decoded buffer, and that turned out to be the subtle part: the browser assigns frames to a column by working out each column's range, not by dividing each frame's index, and the two differ by one frame at every boundary. A short file is a second difference, since the browser widens any empty column so eight frames across sixty-four columns draw a staircase rather than eight spikes on a flat line.
What can't be verified here. The device glue needs macOS and a compiler, and this project has neither. What build/test-play.js covers is everything the glue hands work to: the bucketing, the resampler across block boundaries, the channel sum behind the mute and solo chips, and the clock arithmetic across a seek and a rebuild. build/test-tauri.js covers the other side, the page's half of the conversation, including the one assertion that would catch this regressing: the page must create zero AudioContexts. A review pass over the Rust found nine real bugs, listed above where they're interesting; it found nothing that wouldn't compile, which is not the same as knowing it compiles.
Reopen the audio output is now Restart the app, and it restarts rather than reloads. The reload was always the wrong instrument. It should never be needed now, and it stays because the failure it covers took four attempts to find.
Appearance, and the settings behind it
The cog holds three topics now, each folded behind its own heading and all closed to start with — the point of the cog is that you go looking for one thing, not that you read a wall of options every time. <details> does the folding, so it's the platform's own disclosure behaviour rather than a reimplementation of it.
Dark mode is one of them: light, dark, or follow the system. The framework maps every surface, border and text colour onto a single grayscale ramp, so the whole app turns over by redefining the ramp — no per-component overrides, and anything added later comes along for free. Anthracite rather than black, because a true-black window in a dark room is a light source with a hole in it and the greys need somewhere to sit below the page. The ramp isn't a straight inversion either: dark interfaces need less contrast at the top (white on near-black glares) and more separation at the bottom, so the surfaces sit closer together and the text stops short of white.
Following the system means prefers-color-scheme plus a listener, so it turns over as macOS does through the day. color-scheme is set on the root as well, which is what tells the engine which way round scrollbars, form controls and the flash of background on a resize should go. The waveform picks up its colours from --color-info and --color-action at draw time, so it followed the theme without being asked.
The spectrogram
A round button at the end of the player's header row opens a picture of the loaded take: time across, frequency up, brightness as level.
It is computed in Rust, in one streaming pass, one column per pixel of the width asked for — so a four-hour day file costs a read rather than its own weight in memory, exactly like the waveform peaks. 2048-point window, Hann, magnitudes in dB against a fixed −100 dB floor rather than auto-ranged, so two takes look the same when they are the same.
The FFT is written by hand. Nothing in this project can be compiled in the environment it's written in, and a dependency that can't be compiled is a dependency that can't be checked. Sixty lines of radix-2 Cooley-Tukey is a pure function, so the harness runs the same arithmetic against a plain DFT and they have to agree to a thousandth. There are also checks that a tone lands in the bin it belongs to, that the 440 Hz test signal reads as 440 Hz through the whole path, and that silence comes back as the floor rather than as a picture of nothing in particular.
It follows the channel chips. Solo the boom and you see the boom's spectrum, not the mono sum. You are looking at what you are hearing, which is the reason for putting it in the player rather than in the table.
The palette is viridis, which is perceptually even: a bright patch means a loud patch rather than an artefact of the colour ramp. The canvas is one pixel per column and per bin, stretched to fit with image-rendering: pixelated, because smoothing it would invent detail that isn't in the file.
One modal, six times over
The dialogs grew one at a time and it showed. Three had a rule under the heading and three didn't. The titles came in three sizes. The widths ran from 660 to 1120. The traffic light sat anywhere from level with the title to a centimetre above it. Individually each one looked deliberate; side by side they looked like six people had built them.
There are five rules now, and every modal follows all of them:
- One width, from
--modal-width. No exceptions: Export Files is a shorter form and there was a case for narrowing it, but dialogs that open at the same size read as one application and dialogs that don't read as several. - A header block: title at one size and weight, at most one line beneath it, always ruled off, always the same room above for the traffic light.
- A body with one padding, from
--modal-pad. - A footer, when there are actions, always ruled off and right-aligned.
- The middle scrolls, capped at 88vh, with the heading and the buttons pinned. The export report already worked this way and nothing else did — which is why the Sound Report simply lost its bottom half once it had 26 fields in it. A dialog that scrolls as a whole makes you hunt for the button that dismisses it.
Two variables, so changing the proportions of every dialog is two numbers. The gutter is wide enough to clear the traffic light, so the title starts to the right of it rather than against it, and the title, the fields and the buttons all line up on one edge. The check that keeps it honest walks every .modal-dialog in the built page and fails if any of them sets a width of its own — which is exactly how six dialogs became six sizes in the first place, and how the Sound Report sheet had quietly kept width: 100% from an earlier layout.
Changing the wording
Every string the app shows lives in build/l10n.json: button labels, column headings, status messages, the sound report's field names, and the hover hints. Edit it, run the build, and the wording changes in both builds. There is no second place to look, no strings hidden in markup, and nothing that needs a translator to touch JavaScript.
The file is a flat map of key to text. Where a string takes a value it uses positional markers — "bulkEditApplyN": "Apply to %1$d file(s)" — so a language that needs a different word order can move them.
Hover hints are the hints section, keyed by the data attribute an element already carries: "bwfa-export-audio": "Write copies to another folder". A control with no entry simply has no hint, so adding one is a line in a file rather than a change to the app.
Where one attribute is shared by many elements, the value can be part of the key. Every column heading is data-bwfa-sort, so "bwfa-sort=tape": "Roll or reel, usually the shoot day. Click to sort" gives that column its own line while "bwfa-sort" covers anything without one. That is what makes the headings worth hinting at all: what Tape/Reel or FPS is matters more than the fact that clicking sorts by it.
They're applied on first hover rather than up front. Half these controls are built when a panel opens, so hanging an observer on the whole app to catch them would cost more than answering the question at the moment it's asked. The delay before one appears, and how it looks, are the system's own — which is the right call for something that should feel like the rest of the machine rather than like a web page.
Pointing at a label answers for the control it names. The word is what you actually aim at; the box is next to it, and a label that says nothing is the usual reason a panel feels like it has no hints when it does.
Keep them to one short line. A hint that needs a sentence is a control that needs a better label.
A press from inside a field
Pressing a button while a text field has focus did nothing the first time. Press again and it worked. It showed up most obviously on bulk edit's Apply, where you have just typed into a field by definition, and I spent a while blaming a broken window.confirm before Vincent named it exactly: the cursor is in a field, the click takes focus out of the field, and that's all it does.
The mechanism is that pressing a button blurs whatever you were typing in, a blur can re-render the panel around that button, and a browser only generates a click if the press and the release land on the same element. Re-render in between and there is no click at all. The second press works because focus has already left the field, so nothing re-renders.
The fix is at the cause: a press on a button no longer moves focus. Nothing in the app depends on blur — every field is read as it is typed into, or straight off the DOM at the moment it is needed — so there is nothing to lose by keeping the caret where it is. It's the same technique toolbars use, and it makes the whole app feel less like a web page, which is what it was really complaining about.
Modals
Bulk edit and export both used to open as siblings of the table, inside a column exactly as tall as the window. The table is the flexible one — min-height: 0, so it may shrink to nothing — so a panel at its natural height squeezed the table out of existence, and since the window itself doesn't scroll, that left nothing scrollable anywhere. Capping the panel at 40% of the viewport and flooring the table at 20% did hold, but only by dividing a space neither of them wanted to share.
They're modals now, so they take no height from the column at all. The panels themselves are untouched: each is wrapped in the app's own modal furniture at build time, keeps its own hidden attribute — the app toggles bulk edit's, the bridge toggles export's — and a MutationObserver mirrors that onto the shell. Neither side has to learn about the other. :has() in CSS would have done the same job without the observer, but it can't be asserted anywhere without a layout engine, and this is the sort of thing that should be tested rather than hoped about.
Closing goes through the panel's own Cancel button rather than around it, so the app's state resets with the window. Escape and a click on the dimmed page both do it too.
Every modal, in both builds, now sits over a page that's dimmed and blurred (backdrop-filter). The point of a modal is that the thing behind it isn't what you're working on, and blur says that better than opacity alone.
Exporting copies: 32-bit float to 24-bit, or to itself
Export Files writes copies into another folder. The originals are never opened for writing. Export Copy in the detail modal does the same thing for the one file you're looking at. Both open a modal over a dimmed, blurred page — see Modals, below.
The audio work is in src-tauri/src/convert.rs, in Rust, because an 8-channel 32-bit float day file runs to tens of gigabytes and none of it should cross the IPC boundary. build/wav-convert.js is a line-for-line Node mirror of it, which is what the tests drive, since there's no macOS toolchain in the environment this was built in.
"32-bit" is ambiguous in a WAV header and getting it wrong converts the wrong thing. It's either IEEE float (fmt tag 3) or 32-bit integer PCM (tag 1), and on plenty of recorders it's WAVE_FORMAT_EXTENSIBLE (0xFFFE) with the real format hidden in a sub-format GUID. All three are resolved before anything is read, and audio that's neither PCM nor float is refused rather than mangled. A header whose block align contradicts its own word length is refused too: every read takes bits/8 bytes per sample, so believing that header walks off the end of a frame.
What changes is fmt , data and bext. Nothing else is touched. iXML, cue points, markers, UMIDs, the recorder's own proprietary blocks — all copied byte for byte, in the source's own order, including the chunks that sit after data. Positions inside them are counted in sample frames rather than bytes, so timecode and markers stay correct at a different word length without being rewritten. An extensible file stays extensible, because that's where the channel mask lives and a multichannel file without one loses its speaker layout.
Two chunks are exceptions. A levl peak envelope describes audio that no longer exists at that level or that word length, so it's dropped — readers rebuild it, and a stale one is worse than none. And bext gains a coding history line saying what was done, which is what the field is for. bext v2's level fields are absolute, so a gain move takes LoudnessValue, MaxTruePeakLevel and the two maximum-loudness fields with it; LoudnessRange is left alone, since a range doesn't shift with gain.
The clipping trap. A 32-bit float recorder has headroom above 0 dBFS and uses it, so plenty of files peak at +6 and higher. Straight conversion to fixed point clips those hard. This is why peak measurement isn't only for normalising: with normalising off, a file that would clip is turned down by exactly enough and the amount is reported, and everything else is left bit-accurate. Integer sources are never scanned when normalising is off — they can't exceed full scale, so there's nothing to find out. Nor is a float file that's staying float: the reason to measure it was that fixed point has no room above 0 dBFS, and float does.
The ceiling is one step short of 1.0 — 8388607 of a possible 8388608 at 24-bit. Aiming at 1.0 dead on clips the peak sample by a single LSB and then reports it, which is a confusing way to describe a successful export.
Normalising offers one gain for the whole batch or each file to its own target. With normalising off the ceiling for that safety attenuation is full scale, not the normalise target — a bug worth naming, because for a while it wasn't: every take above -3 dBFS was quietly coming out quieter than it went in, which is a gain change by the back door. The per-track tests caught it. Batch is the one to reach for: per-file normalising flattens the difference between a whispered line and a shout, which is information. Gain is applied in the float domain before quantising, so nothing rounds twice, and it's linked across channels — per-channel gain would take the stereo image apart.
Sample rate is never touched. Resampling is a different job with different trade-offs and no location workflow wants it done silently.
What each file comes out as. Two settings, and both mean what they say.
Convert to 24-bit brings float and anything deeper than 24-bit down to 24-bit PCM. A 16-bit file stays 16-bit — converting means bringing deep files down, not padding shallow ones up, which would invent precision.
Leave as recorded leaves the word length alone, including when the audio has to be rewritten anyway. A 32-bit float take that gets normalised, split into monos, cut down to a few tracks or combined with others comes back as 32-bit float. This is worth stating plainly because for a while it wasn't true: the writers only produced 16- and 24-bit, so anything that touched a float file's audio silently turned it into 24-bit PCM whatever the control said. They now produce 16-, 24- and 32-bit integer PCM and 32- and 64-bit float. The one exception is 8-bit, which comes out as 16-bit: it's below the writer's floor and not a format to hand a location workflow.
A file that needs nothing done at all is copied byte for byte rather than rebuilt, because that's a stronger guarantee about its metadata than any amount of careful reconstruction. Subfolders are recreated in the destination, worked out from the absolute paths rather than the row's relative path, which means one thing when a folder was walked for editing and another when it was scanned.
Writing float brings two header details with it. wFormatTag becomes 3, or the sub-format GUID becomes the IEEE float one on an extensible file; and the fmt chunk grows to 18 bytes, because only WAVE_FORMAT_PCM may leave cbSize out and a float file with a bare 16-byte header is one some readers are right to refuse. The coding history says A=FLOAT rather than A=PCM, since W=32 beside A=PCM would read as 32-bit integer, which is a different file.
Choosing tracks. Exporting a single file offers its tracks as chips — the same chips the player uses to mute channels, because they answer the same question about the same file — all on to start with, and clicking one drops it from the export. The last one can't be switched off, since that isn't an export. Either output takes the selection: one poly file holding just those tracks, or one mono file per chosen track. A subset is never a byte-for-byte copy, so it's always rewritten: fmt becomes plain PCM (a subset has no honest channel mask), the iXML track list keeps only those tracks with INTERLEAVE_INDEX renumbered to the new layout and CHANNEL_INDEX left as recorded, and the coding history says which tracks were kept. Mono files keep their original channel numbers in their names — pull tracks 3 and 4 out of a four-track file and you get _3_ and _4_, not _1_ and _2_.
A folder of takes doesn't get the picker: across a card, "track 3" isn't the same thing twice, so it would be a promise the files can't keep.
Combining several files into one poly. The other direction: pick the takes that belong together and get one file whose channel count is the sum of theirs. Placement comes from each file's bext TimeReference, which is a count of samples since midnight, so alignment is integer arithmetic and exact to the sample — no drift, nothing to round. The output runs from the earliest start to the latest end, and every channel spans that whole timeline with digital silence filling the head before a file starts and the tail after it runs out. Nothing is mixed: each source's channels get their own channels in the output, so two files that overlap in time are simply two sets of channels.
The output's own bext carries the earliest timecode, its iXML track list names every channel after the file it came from, and its coding history records how many files it was assembled from. Markers and the recorder's own blocks come from the file that starts first, because that's the only one whose positions still mean anything — they're counted from the instant the output now starts.
One file has one format, so a combine has to resolve several. Left as recorded, a set of float files stays float and a set of integer files comes out at the deepest of them; a set that mixes float with 32-bit integer goes to 64-bit float, because f32 carries a 24-bit significand and couldn't hold the integer file. Whenever the poly isn't simply what went in, the summary line says so.
It says what it would make before it makes it. Rates, timecodes and lengths are all already in the metadata, so the panel shows the shape of the result the moment you choose Combine: 2 files · 6 channels · 48 kHz · 00:04:12 · about 340 MB. When it can't be done, that same line carries the reason and Export goes disabled — no dialog to dismiss. Mixed sample rates are the one hard blocker, and the file that doesn't match is named, because "sample rates don't match" is a hunt across forty takes. The plan comes from Rust rather than being worked out twice: the panel shows what the writer will enforce.
Three things it handles rather than trips over. Midnight: a night shoot leaves half the takes at 23-something and half at 00-something, which is 24 hours apart by the arithmetic and a few minutes apart in reality. What gives it away is not how wide the spread is but where the hole in it falls — takes bunched at either end of the clock with most of a day between them — so that's what's tested for, and an ordinary eight-in-the-morning-to-nine-at-night day is left alone. A file with no timecode lands at the start of the timeline and is named in the report. A timecode that isn't a time of day — recorders do write nonsense there — is ignored rather than obeyed, since an unbounded offset would otherwise become an unbounded file.
Splitting poly into mono. A multichannel file can come out as one mono file per channel, named A001_1_Boom.wav: channel number first so a folder of them sorts into channel order, then the recorder's own track name where iXML has one, sanitised hard because these become filenames on someone else's machine. Every mono file carries the full metadata of the original — same bext, same timecode, same markers, same proprietary chunks — with two corrections. iXML's TRACK_LIST is reduced to the one track the file actually contains (TRACK_COUNT 1, INTERLEAVE_INDEX 1, CHANNEL_INDEX left as recorded, since that says which input this was and is the provenance worth keeping), and the coding history gains a line naming the channel. A mono file is written as plain PCM even from an extensible source: the only thing extensible carries that matters here is the channel mask, and one track has no speaker layout worth asserting. Mono takes on the same card are left alone rather than renamed.
It's one pass. A pass per channel would have been simpler and eight times the reading on an eight-track day file, so every output is opened at once, the headers are written from sizes known up front, and the audio is de-interleaved as it streams past. The 4 MB buffer is shared out between the outputs rather than handed to each of them, because 64 tracks at 4 MB apiece is 256 MB of buffering for a job whose whole point is that it streams.
Two things are refused rather than attempted: two channels under one name (both writers would truncate the same file) and a split that would write over the file it came from (the renames happen after all the reading, so nothing would notice until the take was gone).
iXML also states the word length, so AUDIO_BIT_DEPTH is corrected when the depth changes — a file whose fmt chunk says 24 while its iXML says 32 contradicts itself. That's the only edit made to iXML on a straight conversion; the rest of the chunk, trailing slack included, is byte for byte.
RF64/BW64 input is read through its ds64 chunk. Output picks its own container: a 24-bit copy is three quarters the size, so an RF64 source often comes out as an ordinary RIFF file, and the stale ds64 goes rather than being carried over as a lie. Past 4 GB the output gets a ds64 of its own — including a single mono channel, which passes 4 GB at around eight hours.
Each file is written to a hidden .name.bwfa-part beside the target and renamed on success, so an interrupted export can't leave something that looks like a finished recording. A file that's already in the destination is skipped unless you asked for it to be replaced.
Naming the copies. Files come off a recorder as T001.WAV and post wants 12A-3. Every field needed to bridge that is already parsed, so this is a naming decision and nothing else: the writers take a destination path and don't care how it was arrived at. There are presets for the usual shapes, and a custom pattern taking {scene}, {take}, {tape}, {project}, {date}, {time}, {tc}, {name} and {n}.
Only copies are renamed. The originals keep the names the recorder gave them, which is the one thing on the card that ties a file back to the machine that made it, and a rename in place has no way back.
Four rules make it survivable:
- An empty field takes one separator with it. A take with no scene under
{scene}-{take}would otherwise come out as-3, and a card of those sorts into nonsense. The tokens are marked rather than simply blanked, so the collapse can tell a missing scene from a dash somebody typed:{name} - {scene}keeps its spaced dash when there's a scene to put after it. A pattern that resolves to nothing at all falls back to the original name, because a file that didn't get renamed beats a file called.wav. - Two files that would land on the same path stop the run, with the clash named and
{n}suggested. Silently overwriting one take with another is the worst thing this feature could do. Clashes are checked on the whole path inside the destination rather than the name, since a card of date subfolders can legitimately hold two takes called the same thing and refusing that would be refusing the recorder's own layout. - The extension is left exactly as it was.
.WAVstays.WAV. Changing the case of a name is invisible on a Mac and breaks a relink on anything case-sensitive. - Every token value is treated as hostile. It came out of a file this app didn't write. A scene of
../../escapecan't become a path,CONcan't become a file Windows refuses to open, and zero-width and direction-override characters are stripped, because two names that look identical are two files that quietly sit side by side. The length cap counts bytes rather than characters — a filename is 255 bytes on APFS, so a scene in Japanese runs out three times sooner than one in English — and never cuts a surrogate pair in half. The sanitiser is the only thing between a metadata field and a path the writers will happily create directories for, so it has its own tests.
The preview updates on every keystroke and is the same plan the run uses, so what you read is what gets written, including in split mode where the preview names the first mono file and counts the rest rather than showing a poly name that never reaches the disk. Combining is exempt and the control goes rather than sitting there lying: its output is one file from many, and a per-file pattern has nothing to resolve against.
The uniqueness check runs whether or not anything is being renamed, which it didn't before: two files landing on one path is a lost take either way, and a card holding the same name in two folders flattens into a collision as soon as the folder it came from isn't known. The run makes that check itself rather than trusting the Export button to be disabled. A disabled attribute is a piece of UI state, not a guarantee, and removing the check in a scratch build writes four files from five takes.
What happened, once it has happened. A run over a card produces a hundred lines, and they used to land underneath the controls that started it, in a modal already as tall as the window — the reader had to scroll past the form they'd just filled in to find out what it did. A finished export isn't a form any more, so it stops looking like one: the export modal closes and a report takes its place.
The report is the only modal in the app that doesn't scroll as a whole. The heading, the counts and the buttons stay put and the list moves under them, which is the difference between reading a hundred rows and hunting for the button that dismisses them. The counts are their own strip rather than a sentence — converted, copied, skipped, failed — and a zero stays on screen greyed rather than disappearing, because "0 failed" is the whole reassurance and a missing line doesn't give it.
Save Log writes the run out as plain text through the save panel. It carries the settings that produced it, not just the outcome: which folder to which folder, bit depth, channels, normalise and its target, the tracks if a subset was chosen, and what was to happen to files already there. Names are padded into a column and a failed line is marked with !, so the file is scannable in any text editor and greppable for the one thing that went wrong. This is the artefact that answers "what did you send me, and how" a week later, which no amount of on-screen reporting does.
Table and exports
The cog in the table's own first header cell — the empty one above Play, which was doing nothing — opens the settings: what happens when a file finishes, and which columns the table shows — the empty one above Play, which was doing nothing — rather than a dropdown in the toolbar describing something that happens in the table. It's a 22px circle with a hairline border, built to read as a smaller sibling of the eject button in the folder bar, and the column list inside it is two switches to a row. The dropdown's button is still in the DOM and hidden, because the analyser wires a listener to it at startup; the menu markup moved wholesale into the modal, so the code that renders it is untouched. The cog is rebuilt every time the table head is drawn, so its click handler is delegated rather than attached.
The table shows FPS next to Start TC — a start timecode without its frame rate is half a reading. Column visibility is remembered by key, and a list saved before that column existed would have kept hiding it forever, so the storage key was bumped: your column choices reset once, then stick.
Sound Report is the one way to a CSV or a PDF. The modal asks who the report is for — production company, project, director, sound mixer, phone, email, a note — then the output format, then which of the 26 fields to include. The details are typed once and remembered, since they're the same answers every day of the same job, and they print at the top of the PDF two to a line so seven of them cost four lines rather than pushing the table down the page. In the CSV they lead as their own key/value block followed by a blank line, which is the convention every sound report follows: a spreadsheet shows the header, a parser skips to the blank line.
Export Fields, Export CSV and Export PDF were three buttons for one decision. They're still in the DOM, hidden, because the analyser wires its export pipeline to them at startup — naming, save panel, the app build's native interception — and the modal drives them rather than reimplementing any of it.
The field picker still offers all 26 fields, shared by CSV and PDF. Everything is ticked by default, deliberately: a default that quietly drops columns from an export you already rely on is the worse failure, since an over-wide PDF is obvious and one click from fixed while a CSV missing Track Names might go unnoticed for months. The selection persists.
The PDF writer was rewritten to cope, under two rules: nothing is ever cut off, and nothing ever wraps. It used to size columns as fixed fractions of A4 landscape, which works for the eleven it shipped with and falls apart past that: all 26 fields gave each column 31pt while a header like "Originator Reference" needs 69pt, so labels printed on top of each other and values truncated to junk like 00:2. Now each column is measured from its own content — header at bold, values at normal, capped so one Coding History field can't dominate — and the page grows sideways to hold them, up to about 85cm, past which columns scale down instead. Six fields still produce an ordinary A4 landscape page; all 26 produce a 1426pt one, which every print dialog scales to fit. Spare width is shared out proportionally, so the table always spans the page exactly — and only ever widens columns, never narrows them.
Two defects that survived that rewrite are gone as well. Truncation is out entirely. Renormalising the columns multiplied every width by usable / natural, which is 1.0 in theory and 0.9999999999999999 in floating point, so a column measured to fit its own header ended up a hair too narrow and printed Ci…, Origina…, Mark…. There was also a 150pt cap per column that quietly clipped a long Coding History to …VERSIO…. Both are gone: columns are as wide as their content, and when the total would run past the page limit the type shrinks rather than the columns, because text width is linear in font size — a smaller face fits the same words in less room, where a narrower column can only lose them. If even 4.5pt won't fit, the page grows past its limit instead. Text is never the thing that gives.
Every cell is flattened to one line. Coding History is CRLF-separated by definition (EBU 3285) and there are usually two or three lines of it; a Note or a Description can carry breaks too. jsPDF draws those as extra lines below the baseline it was given, straight through the rows underneath. Each break is now a · separator instead, so the text is all still there and reads as the list it is.
Exports are named after the folder they describe — PR-2 2026-08-12.csv rather than bwf-metadata-2026-08-12.csv, which matters the moment you have two of them — and default to saving into that folder, since the save panel is handed a full path rather than a bare filename (a bare name leaves the panel wherever it was last). The PDF's title line is the folder name, with the report label moved into the meta line beneath it. The app knows the name outright; the browser build derives it from the first row's relative path.
The window won't shrink below 1040px wide, which is where the toolbar row stops fitting. The row is also nowrap, so that's a guarantee rather than a hope.
In the app, right-click no longer opens the webview's own menu — it offered Reload, which throws away the open folder and any unsaved edit for no stated reason. Text fields keep their menu, since Cut/Copy/Paste there is worth having.
Text selection is macOS blue rather than the framework's near-black.
What changed from the plugin
The PHP is gone. The shortcode markup is now static HTML and the wp_localize_script strings live in a plain window.bwfaL10n object near the bottom of the file — edit any value there to relabel the UI. jsPDF is bundled inline instead of fetched from cdnjs.
The browser build used to need a warning here, because a page opened from a file:// address has no real origin and Chrome refuses the File System Access API on that basis, so editing metadata meant serving the folder over http first. The app routes every read and write through Rust, so none of that arises.
Rebuilding
build/ holds the sources and the assembler. build/pdf-writer.js is the rewritten PDF writer itself, read by build.py and substituted for the plugin's own — 200 lines of drawing code is unreadable as a patch string.
python3 build/build.py # -> mac-app/dist/index.html
python3 build/make-icons.py # regenerates the app icon and .icns
To pick up a new version of the plugin, drop its CSS/JS into build/src/ and rebuild.
Tests
npm i jsdom first, then:
node build/test-tauri.js # app frontend + bridge — 114 checks
node build/test-framerate.js # frame-rate writing across all five fields — 11 checks
node build/test-pdf.js # PDF geometry, clipping, wrapping, the report header — 12 checks
node build/test-restore.js # remembering, reopening and ejecting the folder — 8 checks
node build/test-play.js # waveform bucketing, resampling, the channel sum, the clock, the FFT — 25 checks
node build/test-export.js # conversion, normalising, track picking, combining, naming, the report — 140 checks
BWF_TINY_CHUNKS=1 node build/test-tauri.js # every transfer forced to chunk — 115 checks
The Tauri suite re-implements the Rust commands in Node, mirroring them exactly, and runs the real bridge and the real analyser against real files on disk. Web Audio is faked, so an interrupted session, one that refuses to resume, and one whose clock stops while it still claims to be running can all be staged on purpose. It covers opening a folder read-write, timecode reconstruction, a metadata edit landing on the actual file with the audio bytes untouched and the result re-parsing cleanly, both exports being written where the save panel pointed, and a dropped folder taking the same route as a picked one.
build/test-pdf.js checks the PDF geometrically rather than by eye: it parses the text-drawing operators out of the generated file, measures each string with the same font metrics jsPDF used, and asserts no header overlaps the column to its right. That's the defect the old writer had, expressed as an assertion.
build/test-framerate.js drives real saves against files shaped like real recordings — a complete SPEED block, one with no flag and almost no slack so the write has to grow the chunk, one with a pull-down relationship that must survive untouched — and reads the bytes back off disk each time. It caught one real bug: DF is used for drop by every vendor in evidence, so it says nothing about which non-drop spelling a file prefers, and my first attempt at matching the file's style turned ND into NDF.
The Rust itself is the one part that couldn't be compiled here — it needs macOS. It was reviewed line by line against the Tauri 2.11 sources instead.
build/make-sample.js writes a synthetic BWF file with fmt/bext/iXML/cue/LIST chunks if you want something to click around with:
node build/make-sample.js A001_12A_T1.wav 12A 1