Files
bwf-analyser/CLAUDE.md
T
Vincent 16c3e6e103 Drop the browser build; space works the transport; fix bulk track rename
The browser page is gone, with its shim, its shell CSS and its suite. build.py
has no --tauri flag and one output: the app's page. The patch lists lose the
SHARED_ prefix, which only ever meant "shared between the two builds", and
test-pdf.js was quietly loading the browser page — it reads the app's now.

Space plays and pauses, in the main window off the player at the bottom and in
the mixer off the same transport. It is swallowed rather than passed on: that
stops the window scrolling, and stops a focused button taking the same press as
a second activation, which toggles twice and looks like a dead key. Typing, and
any dialog other than the mixer, keep it. Deciding what is in front needs both
markers — closing does not always remove .open, and .modal without it is
display:none regardless of the hidden attribute.

The mixer footer put the next file's name adrift in the middle of the row. The
margin-left: auto meant to park it beside Close could do nothing, because the
framework's flex: 1 1 auto had both nav groups growing to fill the row and left
no free space to absorb. Pinned to flex: 0 0 auto. The file-detail modal shares
the markup and follows.

Bulk track rename wrote nothing, on every file, and had never worked. A rename
is keyed on the name it replaces rather than a path, so the frame-rate pass
threw reading edit.path off it — before any byte was written, so nothing was
corrupted, only untouched. The failure handler discarded the reason and the run
still ended on a tidy Done, which is why 0 saved read as progress; it now names
the file and the cause in the log.

429 checks. The gap that hid the rename bug was end-to-end: the writer was unit
tested and the panel rendered, and nothing drove one into the other.
2026-08-18 00:14:36 +08:00

6.2 KiB

BWF Analyser

Reads and edits BWF (Broadcast Wave) metadata for production sound: scene, take, timecode, track names, plus export, split, combine and a sound report.

One thing gets built: mac-app/, a native macOS app (Tauri v2 + Rust) whose frontend is a single self-contained page, wrapping real file access, a Rust audio engine and a Rust WAV writer.

There used to be a second, browser-only page built from the same sources, and much of the shape of this repo still shows it — a vendored WordPress plugin patched on the way in, a shim/bridge split, tests that load a page into jsdom. It is gone. Don't build it, don't write for it, and don't reintroduce a --tauri flag or a second output: there is one page and one build.

Build

python3 build/build.py    # writes ./mac-app/dist/index.html

The Mac app is built by double-clicking mac-app/Build BWF Analyser.command. That script regenerates the page, touches main.rs so the page is re-embedded (cargo does not reliably notice a changed dist/), compiles, assembles and signs the bundle.

cargo build on its own is not that. It produces the bare binary and leaves the bundle alone, so the .app sitting there is still the old one — which looks exactly like a change that didn't work.

Restart the app after compiling it, every time, without being asked. Quit the running copy and open the newly built one, so there is something on screen to try rather than a description of what should happen. A compiled-but-not- restarted app goes on showing the previous page and nothing in the window says so. Then say in the reply that the app was rebuilt and relaunched, and give the build stamp it should be showing — window.BWFA_BUILD, also on data-bwfa-build, and printed by the build script. Stating it is the point: it lets a mismatch be spotted from the reply instead of being mistaken for a broken feature.

Test

node build/test-tauri.js     # the page against a stubbed engine
node build/test-export.js    # every export/split/combine/format combination
node build/test-play.js      # playback maths, peaks, spectrogram, meters
node build/test-restore.js   build/test-framerate.js   build/test-pdf.js
BWF_TINY_CHUNKS=1 node build/test-tauri.js   # tiny-read paths

All of them should pass before anything ships. Roughly 420 checks.

How the build works, and its traps

build/build.py inlines CSS and JS into one HTML file. The app's JavaScript (build/src/bwf-analyser-app.js) is vendored from a WordPress plugin and is kept pristine: changes to it are made as exact-string patches in lists in build.py (BASE_APP_JS_PATCHES, APP_JS_PATCHES, PLAYER_PATCHES, SPECTRO_PATCHES, MIXER_PATCHES, TRANSPORT_KEY_PATCHES). A patch whose anchor has gone stops the build rather than silently doing nothing.

  • Patch strings are Python non-raw triple-quoted strings, so tabs in the JavaScript are written \t. Get this wrong and the anchor never matches.
  • When inserting into the page, use the last </body>, not the first: the page contains inlined JavaScript that writes HTML, and the first match is inside a string literal.

The Rust side

mac-app/src-tauri/src/:

  • main.rs — the commands the page calls (bwf_scan, bwf_export, bwf_combine, bwf_peaks, bwf_spectrogram, bwf_play, bwf_gains, …).
  • convert.rs — RIFF/BWF parsing and writing, peaks, spectrogram, FFT.
  • play.rs — the audio engine. cpal, one engine thread owning the stream (cpal::Stream is !Send on macOS), a reader thread streaming blocks over bounded channels, per-channel gains and meters as atomics read in the callback. Playback deliberately does not use Web Audio: the webview's audio would die after a while and no JavaScript could recover it.

build/wav-convert.js is a line-for-line Node mirror of the Rust converter, and the tests drive both. Anything added to the Rust side that has maths in it should be mirrored and tested, including the rounding: Rust's f64::round and JavaScript's Math.round disagree on negative ties.

Conventions worth keeping

Mutation-test every new test. Break the thing on purpose, confirm the test fails with a message that names the problem, put it back. Several tests in this suite passed for the wrong reason until this caught them.

jsdom has no layout engine. Nothing here can see that a button has no hit area, that a grid is scrambled, or that an element collapsed to zero width. Every one of those has shipped at least once. Layout changes need a screenshot from the user; keep them small and ask.

"Is a modal on screen" needs both markers. .modal is display: none without .open, and closing does not always take .open back off — so a modal that has been opened once and shut again still carries it. Neither the class nor the hidden attribute answers the question alone: .modal.open:not([hidden]) does. Modals also stack (the mixer opens over the file sheet), so "something else is open" says nothing about what is in front.

The page stamps itself. window.BWFA_BUILD holds the build time, also on data-bwfa-build on the app root. Use it to settle "is the app running the page I just built" rather than guessing.

There is an in-app log. Cmd-Shift-L opens it (build/diagnostics.js): it captures console output and errors from before the app starts, traces clicks with what was actually hit, and measures the controls' boxes against elementFromPoint. This is the fastest route to a diagnosis when a control looks fine and does nothing. Devtools are compiled in too (right-click → Inspect Element).

Text lives in build/l10n.json, including the hover hints, keyed by data attribute.

Where things are

build/
  build.py            assembles the page, holds all the patches
  body.html           markup
  overrides.css       the app's own styling on top of the plugin's
  l10n.json           every string, plus hints
  diagnostics.js      the in-app log
  src/                the vendored plugin (JS + CSS), kept pristine
  wav-convert.js      Node mirror of the Rust converter, for tests
  test*.js            the suites
mac-app/
  src-tauri/src/      the Rust
  dist/index.html     generated
  Build BWF Analyser.command