BWF Analyser: browser page and macOS app
Reads and edits BWF metadata for production sound. One source tree builds a single self-contained page and a native Tauri app with a Rust audio engine and WAV writer. Around 370 checks across seven test suites. First commit of the existing state, so that from here every change can be seen and undone.
This commit is contained in:
+16
@@ -0,0 +1,16 @@
|
||||
# Rust build output. Nearly a gigabyte, and cargo rebuilds it.
|
||||
mac-app/src-tauri/target/
|
||||
|
||||
# The assembled .app bundle.
|
||||
mac-app/*.app/
|
||||
|
||||
node_modules/
|
||||
|
||||
# Built pages. Generated by build/build.py from everything in build/src,
|
||||
# build/body.html and build/overrides.css, and stamped with a build time, so
|
||||
# they change on every run and would make every diff unreadable. Run
|
||||
# `python3 build/build.py` (and `--tauri`) to produce them.
|
||||
/index.html
|
||||
/mac-app/dist/index.html
|
||||
|
||||
.DS_Store
|
||||
@@ -0,0 +1,120 @@
|
||||
# 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.
|
||||
Two things get built from one source tree:
|
||||
|
||||
- `index.html` at the root: a single self-contained page, no server, no
|
||||
network, opens in a browser.
|
||||
- `mac-app/`: a native macOS app (Tauri v2 + Rust) that wraps the same page
|
||||
and adds real file access, a Rust audio engine and a Rust WAV writer.
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
python3 build/build.py # writes ./index.html
|
||||
python3 build/build.py --tauri # writes ./mac-app/dist/index.html
|
||||
```
|
||||
|
||||
Both, every time: a change to shared code affects both pages, and a feature
|
||||
wired into only one looks exactly like a broken feature (a visible button that
|
||||
does nothing). There is a test for that specific trap.
|
||||
|
||||
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.
|
||||
|
||||
## Test
|
||||
|
||||
```bash
|
||||
node build/test.js # the browser page, in jsdom
|
||||
node build/test-tauri.js # the Mac 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 370 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` (`SHARED_APP_JS_PATCHES`, `APP_JS_PATCHES`, `PLAYER_PATCHES`,
|
||||
`SPECTRO_PATCHES`, `MIXER_PATCHES`). Each patch asserts its anchor is found
|
||||
exactly once, so a stale anchor fails the build loudly 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.
|
||||
- `SHARED_*` patches apply to both builds; the others only to `--tauri`.
|
||||
- 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.
|
||||
|
||||
**Two pages, one behaviour.** If a feature only makes sense on the Mac build,
|
||||
it still needs to not look broken on the other one.
|
||||
|
||||
**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 both pages, 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
|
||||
```
|
||||
@@ -0,0 +1,380 @@
|
||||
# BWF Analyser — standalone
|
||||
|
||||
Two builds of the same tool, from the same source.
|
||||
|
||||
**`index.html`** — the browser version. Double-click it and it runs: no server, no build step, no internet. Everything the WordPress plugin did is in that one file, jsPDF included.
|
||||
|
||||
**`mac-app/`** — a real macOS app. Double-click **Build BWF Analyser.command** once and it produces `BWF Analyser.app` next to itself.
|
||||
|
||||
## 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 browser build
|
||||
|
||||
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, both builds
|
||||
|
||||
Both live in `build/overrides.css`, which applies to the browser build too, without touching the plugin's own stylesheet.
|
||||
|
||||
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_RATE`** is a rational — `30/1`, and 29.97 is exactly `30000/1001`, never `29.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_SPEED` and `CURRENT_SPEED`** follow the rate, since some readers take the frame rate from `MASTER_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's `separate` with zero spacing now, which looks identical; each cell draws its own bottom border, which is what collapse was doing anyway.
|
||||
- `-webkit-overflow-scrolling: touch` on 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](https://github.com/RustAudio/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 `AudioContext`s. 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:
|
||||
|
||||
1. **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.
|
||||
2. **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.
|
||||
3. **A body** with one padding, from `--modal-pad`.
|
||||
4. **A footer**, when there are actions, always ruled off and right-aligned.
|
||||
5. **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.** `.WAV` stays `.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 `../../escape` can't become a path, `CON` can'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.
|
||||
|
||||
In the browser build, one thing was added: a warning if the browser blocks the folder-write permission. Opening a page from a `file://` address gives it no real origin, and Chrome refuses the File System Access API on that basis, so metadata editing needs the folder served over http:
|
||||
|
||||
```
|
||||
cd /path/to/this/folder
|
||||
python3 -m http.server
|
||||
```
|
||||
|
||||
Then open <http://localhost:8000/>. Or just use the Mac app, where this doesn't come up.
|
||||
|
||||
## 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 # -> index.html (browser)
|
||||
python3 build/build.py --tauri # -> 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.js # browser build — 35 checks
|
||||
node build/test-tauri.js # Mac app frontend + bridge — 94 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 — 95 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
|
||||
```
|
||||
Binary file not shown.
Binary file not shown.
+404
@@ -0,0 +1,404 @@
|
||||
<div id="bwfa-app-1" class="bwfa-scope bwfa-app" data-bwfa-app aria-label="BWF Metadata Analyser">
|
||||
<div class="bwfa-header">
|
||||
<h2 class="bwfa-title">BWF Metadata Analyser</h2>
|
||||
<p class="bwfa-privacy-note">
|
||||
<span class="badge badge-success" aria-hidden="true">Local only</span>
|
||||
Your files are read directly in this browser tab and never leave your computer.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="bwfa-dropzone" data-bwfa-dropzone tabindex="0" role="button"
|
||||
aria-label="Select or drop a folder or files of BWF/WAV recordings">
|
||||
<p class="bwfa-dropzone-text" data-bwfa-dropzone-text></p>
|
||||
<div class="bwfa-dropzone-actions">
|
||||
<button type="button" class="btn btn-primary" data-bwfa-select-folder>Select Folder</button>
|
||||
<button type="button" class="btn btn-secondary" data-bwfa-select-files>Select Files</button>
|
||||
<button type="button" class="btn btn-outline" data-bwfa-clear disabled>Clear</button>
|
||||
</div>
|
||||
<p class="bwfa-dropzone-subnote">
|
||||
On iPhone/iPad, if the folder picker won't let you select a folder you've navigated into, use "Select Files" and choose the recordings directly instead — this is a known iOS quirk with folder pickers in general, not specific to this tool.
|
||||
</p>
|
||||
<input
|
||||
type="file"
|
||||
class="bwfa-visually-hidden"
|
||||
data-bwfa-file-input
|
||||
webkitdirectory
|
||||
directory
|
||||
multiple
|
||||
accept=".wav,.bwf,.broadcastwave"
|
||||
/>
|
||||
<input
|
||||
type="file"
|
||||
class="bwfa-visually-hidden"
|
||||
data-bwfa-files-input
|
||||
multiple
|
||||
accept=".wav,.bwf,.broadcastwave"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="bwfa-edit-entry" data-bwfa-edit-entry hidden>
|
||||
<div class="bwfa-edit-divider"><span>or</span></div>
|
||||
<div class="bwfa-launch-logo" aria-hidden="true"></div>
|
||||
<button type="button" class="btn btn-outline" data-bwfa-edit-folder>Edit Metadata in a Folder…</button>
|
||||
<span class="bwfa-current-folder" data-bwfa-current-folder hidden></span>
|
||||
<p class="bwfa-dropzone-subnote">
|
||||
This opens files for editing rather than just viewing. Saving a change writes it directly back to the original file on your disk — there is no undo, and no automatic backup is made. Available in Chromium-based browsers only (Chrome, Edge, Opera, Arc); Firefox and Safari, including iPhone/iPad, cannot write to local files from a webpage at all.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p class="bwfa-fs-warning" data-bwfa-fs-warning>
|
||||
Your browser blocked the folder-write permission because this page was opened straight from disk
|
||||
(a <code>file://</code> address), where writing to local files isn't allowed. Reading, playback and
|
||||
export all still work. To edit metadata, serve this folder over http instead: run
|
||||
<code>python3 -m http.server</code> in the folder holding this file, then open
|
||||
<code>http://localhost:8000/</code>.
|
||||
</p>
|
||||
|
||||
<div class="bwfa-status" data-bwfa-status role="status" aria-live="polite"></div>
|
||||
|
||||
<div class="bwfa-progress progress" data-bwfa-progress-wrap hidden>
|
||||
<div class="progress-bar" data-bwfa-progress-bar style="width:0%"></div>
|
||||
</div>
|
||||
|
||||
<div class="bwfa-results" data-bwfa-results hidden>
|
||||
<div class="bwfa-toolbar">
|
||||
<div class="form-group bwfa-search-group">
|
||||
<input
|
||||
type="search"
|
||||
class="form-control"
|
||||
data-bwfa-search
|
||||
placeholder="Filter by filename, scene, take…"
|
||||
/>
|
||||
</div>
|
||||
<div class="bwfa-export-actions">
|
||||
<!-- Columns moved to the cogwheel in the table's first header
|
||||
cell. The button stays here, hidden, because the analyser
|
||||
wires a listener to it at startup. -->
|
||||
<div class="dropdown bwfa-legacy-export" data-bwfa-columns-dropdown>
|
||||
<button type="button" class="btn btn-outline" data-bwfa-columns-toggle>Columns</button>
|
||||
</div>
|
||||
<button type="button" class="btn btn-outline" data-bwfa-bulk-edit-toggle hidden>Bulk Edit</button>
|
||||
<button type="button" class="btn btn-outline" data-bwfa-report-open>Sound Report</button>
|
||||
<!-- The three buttons the report modal replaced. Kept in the DOM,
|
||||
hidden by CSS: the analyser wires its export pipeline to
|
||||
them at startup, and the modal drives them rather than
|
||||
duplicating any of it. -->
|
||||
<button type="button" class="btn btn-outline bwfa-legacy-export" data-bwfa-export-fields-toggle>Export Fields</button>
|
||||
<button type="button" class="btn btn-secondary bwfa-legacy-export" data-bwfa-export-csv>Export CSV</button>
|
||||
<button type="button" class="btn btn-secondary bwfa-legacy-export" data-bwfa-export-pdf>Export PDF</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bwfa-editing-note" data-bwfa-editing-note hidden>
|
||||
<span class="badge badge-warning">Editing</span>
|
||||
Changes save directly to the original files on disk.
|
||||
</div>
|
||||
|
||||
<div class="bwfa-bulk-edit-panel" data-bwfa-bulk-edit-panel hidden>
|
||||
<div class="bwfa-bulk-edit-header">
|
||||
<h4>Bulk Edit</h4>
|
||||
<p data-bwfa-bulk-edit-count></p>
|
||||
</div>
|
||||
<div class="bwfa-bulk-edit-fields" data-bwfa-bulk-edit-fields></div>
|
||||
<div class="bwfa-bulk-tracks" data-bwfa-bulk-tracks hidden></div>
|
||||
<div class="bwfa-bulk-edit-actions">
|
||||
<div class="bwfa-bulk-edit-progress" data-bwfa-bulk-edit-progress hidden></div>
|
||||
<button type="button" class="btn btn-outline" data-bwfa-bulk-edit-cancel>Cancel</button>
|
||||
<button type="button" class="btn btn-primary" data-bwfa-bulk-edit-apply disabled>Apply</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bwfa-player" data-bwfa-player hidden>
|
||||
<div class="bwfa-player-label">
|
||||
<span class="badge badge-info" data-bwfa-player-badge>Now playing</span>
|
||||
<span class="bwfa-player-filename" data-bwfa-player-filename></span>
|
||||
<button type="button" class="btn btn-sm btn-outline bwfa-player-edit"
|
||||
data-bwfa-player-edit>Edit</button>
|
||||
<span class="bwfa-player-decoding" data-bwfa-player-decoding hidden>Decoding…</span>
|
||||
<button type="button" class="bwfa-round-btn bwfa-mixer-open"
|
||||
data-bwfa-mixer-open aria-label="Mixer">
|
||||
<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true">
|
||||
<circle cx="8" cy="8" r="5.6" fill="none" stroke="currentColor"
|
||||
stroke-width="1.4"/>
|
||||
<path d="M8 3.6 V 8" stroke="currentColor" stroke-width="1.6"
|
||||
stroke-linecap="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button type="button" class="bwfa-round-btn bwfa-spectro-open"
|
||||
data-bwfa-spectro-open aria-label="Spectrogram">
|
||||
<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true">
|
||||
<rect x="1" y="9" width="2" height="5" rx="0.6"/>
|
||||
<rect x="4.5" y="5" width="2" height="9" rx="0.6"/>
|
||||
<rect x="8" y="2" width="2" height="12" rx="0.6"/>
|
||||
<rect x="11.5" y="6.5" width="2" height="7.5" rx="0.6"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-backdrop bwfa-mixer-backdrop" data-bwfa-mixer-backdrop hidden></div>
|
||||
<div class="modal bwfa-mixer-modal" data-bwfa-mixer hidden>
|
||||
<div class="modal-dialog bwfa-mixer-dialog" role="dialog" aria-modal="true"
|
||||
aria-label="Mixer">
|
||||
<button type="button" class="modal-close" data-bwfa-mixer-close
|
||||
aria-label="Close">×</button>
|
||||
<div class="modal-header">
|
||||
<div class="bwfa-modal-title-group">
|
||||
<h3 class="modal-title">Mixer</h3>
|
||||
<span class="bwfa-modal-filename" data-bwfa-mixer-filename></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="bwfa-mixer-strips" data-bwfa-mixer-strips></div>
|
||||
<div class="bwfa-player-transport bwfa-mixer-transport">
|
||||
<button type="button" class="btn btn-sm btn-primary bwfa-player-playpause"
|
||||
data-bwfa-mixer-playpause><span class="bwfa-btn-label"
|
||||
data-bwfa-label>Play</span></button>
|
||||
<span class="bwfa-player-time" data-bwfa-mixer-elapsed>00:00:00</span>
|
||||
<canvas class="bwfa-player-waveform" data-bwfa-mixer-waveform
|
||||
width="700" height="56"
|
||||
aria-label="Waveform — click to seek"></canvas>
|
||||
<span class="bwfa-player-time" data-bwfa-mixer-duration>00:00:00</span>
|
||||
</div>
|
||||
<p class="bwfa-mixer-note">This mix is for listening only. Exports and
|
||||
reports ignore it.</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<div class="bwfa-modal-nav bwfa-modal-nav-back">
|
||||
<button type="button" class="btn btn-sm btn-outline"
|
||||
data-bwfa-mixer-prev aria-label="Previous file">‹</button>
|
||||
<span class="bwfa-modal-nav-name" data-bwfa-mixer-prev-name></span>
|
||||
</div>
|
||||
<div class="bwfa-modal-nav bwfa-modal-nav-on">
|
||||
<span class="bwfa-modal-nav-name" data-bwfa-mixer-next-name></span>
|
||||
<button type="button" class="btn btn-sm btn-outline"
|
||||
data-bwfa-mixer-next aria-label="Next file">›</button>
|
||||
</div>
|
||||
<button type="button" class="btn btn-outline" data-bwfa-mixer-close>Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-backdrop bwfa-spectro-backdrop" data-bwfa-spectro-backdrop hidden></div>
|
||||
<div class="modal bwfa-spectro-modal" data-bwfa-spectro hidden>
|
||||
<div class="modal-dialog bwfa-spectro-dialog" role="dialog" aria-modal="true"
|
||||
aria-label="Spectrogram">
|
||||
<button type="button" class="modal-close" data-bwfa-spectro-close
|
||||
aria-label="Close">×</button>
|
||||
<div class="bwfa-spectro-head">
|
||||
<h4 data-bwfa-spectro-title>Spectrogram</h4>
|
||||
<p data-bwfa-spectro-note></p>
|
||||
<button type="button" class="btn btn-sm btn-outline bwfa-spectro-save"
|
||||
data-bwfa-spectro-save>Save Image</button>
|
||||
</div>
|
||||
<div class="bwfa-spectro-plot">
|
||||
<div class="bwfa-spectro-scale" data-bwfa-spectro-scale></div>
|
||||
<canvas class="bwfa-spectro-canvas" data-bwfa-spectro-canvas></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bwfa-player-transport">
|
||||
<button type="button" class="btn btn-sm btn-primary bwfa-player-playpause" data-bwfa-player-playpause><span class="bwfa-btn-label" data-bwfa-label>Play</span></button>
|
||||
<span class="bwfa-player-time" data-bwfa-player-elapsed>00:00:00</span>
|
||||
<canvas class="bwfa-player-waveform" data-bwfa-player-waveform width="700" height="56"
|
||||
aria-label="Waveform — click to seek"></canvas>
|
||||
<span class="bwfa-player-time" data-bwfa-player-duration>00:00:00</span>
|
||||
</div>
|
||||
<div class="bwfa-channel-row" data-bwfa-channel-row>
|
||||
<span class="bwfa-channel-hint">Click a channel to mute it, double-click to solo it:</span>
|
||||
<div class="chip-group bwfa-channel-chips" data-bwfa-channel-chips></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-hover bwfa-table">
|
||||
<thead data-bwfa-table-head></thead>
|
||||
<tbody data-bwfa-table-body></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bwfa-empty" data-bwfa-empty>No files analysed yet.</div>
|
||||
|
||||
<!-- Which columns the table shows. Opened by the cogwheel above the Play
|
||||
column, since that's where you are when you want it. -->
|
||||
<div class="modal-backdrop" data-bwfa-columns-backdrop hidden></div>
|
||||
<div class="modal" data-bwfa-columns-modal role="dialog" aria-modal="true"
|
||||
aria-labelledby="bwfa-app-1-columns-title" hidden>
|
||||
<div class="modal-dialog bwfa-columns-dialog">
|
||||
<div class="modal-header">
|
||||
<h3 class="modal-title" id="bwfa-app-1-columns-title">Settings</h3>
|
||||
<button type="button" class="modal-close" data-bwfa-columns-close aria-label="Close">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<details class="bwfa-settings-section" data-bwfa-settings="appearance">
|
||||
<summary>Appearance</summary>
|
||||
<div class="bwfa-settings-body">
|
||||
<div class="bwfa-choice-list">
|
||||
<label class="bwfa-choice">
|
||||
<input type="radio" name="bwfa-app-1-theme" value="auto" checked data-bwfa-theme-mode>
|
||||
<span>Match the system<small>Follow macOS as it changes through the day</small></span>
|
||||
</label>
|
||||
<label class="bwfa-choice">
|
||||
<input type="radio" name="bwfa-app-1-theme" value="light" data-bwfa-theme-mode>
|
||||
<span>Light<small>Paper white, whatever the system is doing</small></span>
|
||||
</label>
|
||||
<label class="bwfa-choice">
|
||||
<input type="radio" name="bwfa-app-1-theme" value="dark" data-bwfa-theme-mode>
|
||||
<span>Dark<small>Anthracite, for a night shoot or a dark suite</small></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
<details class="bwfa-settings-section" data-bwfa-settings="playback">
|
||||
<summary>When a file finishes</summary>
|
||||
<div class="bwfa-settings-body">
|
||||
<div class="bwfa-choice-list">
|
||||
<label class="bwfa-choice">
|
||||
<input type="radio" name="bwfa-app-1-playback-mode" value="stop" checked
|
||||
data-bwfa-playback-mode>
|
||||
<span>Stop<small>Hold it in the player, ready to play again</small></span>
|
||||
</label>
|
||||
<label class="bwfa-choice">
|
||||
<input type="radio" name="bwfa-app-1-playback-mode" value="next" data-bwfa-playback-mode>
|
||||
<span>Play the next file<small>Work down the list as it's sorted and filtered</small></span>
|
||||
</label>
|
||||
<label class="bwfa-choice">
|
||||
<input type="radio" name="bwfa-app-1-playback-mode" value="repeat" data-bwfa-playback-mode>
|
||||
<span>Repeat it<small>Loop the same file until you stop it</small></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="bwfa-settings-action" data-bwfa-audio-reset-row>
|
||||
<button type="button" class="btn btn-sm btn-outline" data-bwfa-audio-reset>Restart the app</button>
|
||||
<small>If a file plays with no sound at all, this rebuilds the audio path
|
||||
and reloads the window — the same thing quitting and reopening does,
|
||||
without the quitting. The folder comes back with it.</small>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
<details class="bwfa-settings-section" data-bwfa-settings="columns">
|
||||
<summary>Columns in the table</summary>
|
||||
<div class="bwfa-settings-body">
|
||||
<div class="bwfa-columns-menu" data-bwfa-columns-menu></div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-primary" data-bwfa-columns-close>Done</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- The sound report: who it's for, what format it lands in, and which
|
||||
fields it carries. One field list for both formats, because a sound
|
||||
report is a sound report whichever way it's written. -->
|
||||
<div class="modal-backdrop" data-bwfa-export-backdrop hidden></div>
|
||||
<div class="modal" data-bwfa-export-modal role="dialog" aria-modal="true"
|
||||
aria-labelledby="bwfa-app-1-export-title" hidden>
|
||||
<div class="modal-dialog bwfa-export-dialog">
|
||||
<div class="modal-header">
|
||||
<div class="bwfa-modal-title-group">
|
||||
<h3 class="modal-title" id="bwfa-app-1-export-title">Sound Report</h3>
|
||||
<span class="bwfa-modal-filename" data-bwfa-report-subject></span>
|
||||
</div>
|
||||
<button type="button" class="modal-close" data-bwfa-export-close aria-label="Close">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="bwfa-report-details">
|
||||
<div class="bwfa-report-row">
|
||||
<label class="form-label" for="bwfa-app-1-report-company">Production company</label>
|
||||
<input type="text" class="form-control" id="bwfa-app-1-report-company"
|
||||
data-bwfa-report-field="company" autocomplete="off">
|
||||
</div>
|
||||
<div class="bwfa-report-row">
|
||||
<label class="form-label" for="bwfa-app-1-report-project">Project / show name</label>
|
||||
<input type="text" class="form-control" id="bwfa-app-1-report-project"
|
||||
data-bwfa-report-field="project" autocomplete="off">
|
||||
</div>
|
||||
<div class="bwfa-report-row">
|
||||
<label class="form-label" for="bwfa-app-1-report-director">Director</label>
|
||||
<input type="text" class="form-control" id="bwfa-app-1-report-director"
|
||||
data-bwfa-report-field="director" autocomplete="off">
|
||||
</div>
|
||||
<div class="bwfa-report-row">
|
||||
<label class="form-label" for="bwfa-app-1-report-mixer">Sound mixer</label>
|
||||
<input type="text" class="form-control" id="bwfa-app-1-report-mixer"
|
||||
data-bwfa-report-field="mixer" autocomplete="off">
|
||||
</div>
|
||||
<div class="bwfa-report-row">
|
||||
<label class="form-label" for="bwfa-app-1-report-phone">Mixer phone</label>
|
||||
<input type="text" class="form-control" id="bwfa-app-1-report-phone"
|
||||
data-bwfa-report-field="phone" autocomplete="off">
|
||||
</div>
|
||||
<div class="bwfa-report-row">
|
||||
<label class="form-label" for="bwfa-app-1-report-email">Mixer email</label>
|
||||
<input type="text" class="form-control" id="bwfa-app-1-report-email"
|
||||
data-bwfa-report-field="email" autocomplete="off">
|
||||
</div>
|
||||
<div class="bwfa-report-row">
|
||||
<label class="form-label" for="bwfa-app-1-report-note">Note</label>
|
||||
<input type="text" class="form-control" id="bwfa-app-1-report-note"
|
||||
data-bwfa-report-field="note" autocomplete="off">
|
||||
</div>
|
||||
<div class="bwfa-report-row">
|
||||
<label class="form-label" for="bwfa-app-1-report-format">Output format</label>
|
||||
<select class="form-select" id="bwfa-app-1-report-format" data-bwfa-report-format>
|
||||
<option value="pdf" selected>PDF (sound report)</option>
|
||||
<option value="csv">CSV (spreadsheet)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bwfa-report-fields-head">
|
||||
<h4>Fields in the report</h4>
|
||||
<span class="bwfa-report-count" data-bwfa-export-count></span>
|
||||
<div class="bwfa-export-actions-row">
|
||||
<button type="button" class="btn btn-sm btn-outline" data-bwfa-export-all>Select all</button>
|
||||
<button type="button" class="btn btn-sm btn-outline" data-bwfa-export-none>Select none</button>
|
||||
<button type="button" class="btn btn-sm btn-outline" data-bwfa-export-defaults>Restore defaults</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bwfa-export-fields" data-bwfa-export-fields></div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline" data-bwfa-export-close>Cancel</button>
|
||||
<button type="button" class="btn btn-primary" data-bwfa-report-create>Create Report</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-backdrop" data-bwfa-modal-backdrop hidden></div>
|
||||
<div
|
||||
class="modal"
|
||||
data-bwfa-modal
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="bwfa-app-1-modal-title"
|
||||
hidden
|
||||
>
|
||||
<div class="modal-dialog bwfa-modal-dialog">
|
||||
<div class="modal-header">
|
||||
<div class="bwfa-modal-title-group">
|
||||
<h3 class="modal-title" id="bwfa-app-1-modal-title">File metadata</h3>
|
||||
<span class="bwfa-modal-filename" data-bwfa-modal-filename></span>
|
||||
</div>
|
||||
<button type="button" class="modal-close" data-bwfa-modal-close aria-label="Close">×</button>
|
||||
</div>
|
||||
<div class="modal-body" data-bwfa-modal-body></div>
|
||||
<div class="modal-footer">
|
||||
<div class="bwfa-modal-nav bwfa-modal-nav-back">
|
||||
<button type="button" class="btn btn-sm btn-outline" data-bwfa-modal-prev
|
||||
aria-label="Previous file">‹</button>
|
||||
<span class="bwfa-modal-nav-name" data-bwfa-modal-prev-name></span>
|
||||
</div>
|
||||
<div class="bwfa-modal-nav bwfa-modal-nav-on">
|
||||
<span class="bwfa-modal-nav-name" data-bwfa-modal-next-name></span>
|
||||
<button type="button" class="btn btn-sm btn-outline" data-bwfa-modal-next
|
||||
aria-label="Next file">›</button>
|
||||
</div>
|
||||
<button type="button" class="btn btn-primary" data-bwfa-modal-save hidden disabled>Save</button>
|
||||
<button type="button" class="btn btn-secondary" data-bwfa-modal-play>Play</button>
|
||||
<button type="button" class="btn btn-outline" data-bwfa-modal-close>Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
+3089
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,274 @@
|
||||
/**
|
||||
* A log you can read inside the app.
|
||||
*
|
||||
* The app ships without developer tools, so when something goes wrong the
|
||||
* usual answer — "open the console and tell me what it says" — isn't
|
||||
* available. This captures everything the console would have shown, plus the
|
||||
* things that turn out to matter when a control looks fine and does nothing,
|
||||
* and puts it behind Cmd-Shift-L with a Copy button.
|
||||
*
|
||||
* Loaded before the app so it catches errors thrown during start-up.
|
||||
*/
|
||||
( function () {
|
||||
"use strict";
|
||||
|
||||
var LIMIT = 800;
|
||||
var lines = [];
|
||||
var panel = null;
|
||||
var output = null;
|
||||
|
||||
function stamp() {
|
||||
var now = new Date();
|
||||
function two( n ) { return ( n < 10 ? "0" : "" ) + n; }
|
||||
return two( now.getHours() ) + ":" + two( now.getMinutes() ) + ":" +
|
||||
two( now.getSeconds() ) + "." +
|
||||
( "00" + now.getMilliseconds() ).slice( -3 );
|
||||
}
|
||||
|
||||
/** Anything at all, as one short readable string. */
|
||||
function describe( value ) {
|
||||
if ( value === null ) { return "null"; }
|
||||
if ( value === undefined ) { return "undefined"; }
|
||||
if ( typeof value === "string" ) { return value; }
|
||||
if ( typeof value !== "object" ) { return String( value ); }
|
||||
if ( value instanceof Error ) {
|
||||
return value.name + ": " + value.message +
|
||||
( value.stack ? "\n " + String( value.stack ).split( "\n" ).slice( 1, 4 )
|
||||
.join( "\n " ) : "" );
|
||||
}
|
||||
if ( value.nodeType === 1 ) { return element( value ); }
|
||||
try {
|
||||
var json = JSON.stringify( value );
|
||||
return json && json.length > 300 ? json.slice( 0, 300 ) + "…" : String( json );
|
||||
} catch ( e ) {
|
||||
return Object.prototype.toString.call( value );
|
||||
}
|
||||
}
|
||||
|
||||
/** An element as you would point at it: tag, class, and its data hooks. */
|
||||
function element( node ) {
|
||||
if ( ! node || node.nodeType !== 1 ) { return String( node ); }
|
||||
var out = node.tagName.toLowerCase();
|
||||
if ( node.id ) { out += "#" + node.id; }
|
||||
var cls = typeof node.className === "string" ? node.className :
|
||||
( node.getAttribute && node.getAttribute( "class" ) ) || "";
|
||||
if ( cls ) { out += "." + cls.trim().split( /\s+/ ).join( "." ); }
|
||||
var hooks = [];
|
||||
if ( node.getAttributeNames ) {
|
||||
node.getAttributeNames().forEach( function ( name ) {
|
||||
if ( name.indexOf( "data-bwfa" ) === 0 ) {
|
||||
var v = node.getAttribute( name );
|
||||
hooks.push( v ? name + "=" + v : name );
|
||||
}
|
||||
} );
|
||||
}
|
||||
if ( hooks.length ) { out += " [" + hooks.join( " " ) + "]"; }
|
||||
return out;
|
||||
}
|
||||
|
||||
function add( kind, text ) {
|
||||
lines.push( stamp() + " " + kind + " " + text );
|
||||
if ( lines.length > LIMIT ) { lines.shift(); }
|
||||
if ( output ) { render(); }
|
||||
}
|
||||
|
||||
// Console, kept working as well as captured.
|
||||
[ "log", "info", "warn", "error" ].forEach( function ( name ) {
|
||||
var original = window.console && window.console[ name ];
|
||||
window.console[ name ] = function () {
|
||||
var parts = Array.prototype.slice.call( arguments ).map( describe );
|
||||
add( name.toUpperCase(), parts.join( " " ) );
|
||||
if ( original ) { original.apply( window.console, arguments ); }
|
||||
};
|
||||
} );
|
||||
|
||||
window.addEventListener( "error", function ( e ) {
|
||||
if ( e.error ) {
|
||||
add( "THROWN", describe( e.error ) );
|
||||
} else {
|
||||
add( "THROWN", e.message + " (" + e.filename + ":" + e.lineno + ")" );
|
||||
}
|
||||
} );
|
||||
|
||||
window.addEventListener( "unhandledrejection", function ( e ) {
|
||||
add( "REJECTED", describe( e.reason ) );
|
||||
} );
|
||||
|
||||
/**
|
||||
* Every click, with what was actually hit.
|
||||
*
|
||||
* This is the line that matters when a button looks alive and isn't: the
|
||||
* thing under the pointer is not the button you aimed at, either because
|
||||
* something covers it or because it has been squashed to no width at all.
|
||||
*/
|
||||
document.addEventListener( "click", function ( e ) {
|
||||
var hit = e.target;
|
||||
var button = hit && hit.closest ? hit.closest( "button" ) : null;
|
||||
var note = "hit " + element( hit );
|
||||
if ( button && button !== hit ) { note += " inside " + element( button ); }
|
||||
if ( ! button ) { note += " (no button in the ancestry)"; }
|
||||
add( "CLICK", note );
|
||||
}, true );
|
||||
|
||||
/**
|
||||
* Measures the controls, because a control can be visible and unclickable.
|
||||
* For each one: its box, and whatever the browser says is on top at the
|
||||
* middle of that box. Those two disagreeing is the whole diagnosis.
|
||||
*/
|
||||
function probe() {
|
||||
var selectors = [
|
||||
"[data-bwfa-mixer-open]", "[data-bwfa-spectro-open]",
|
||||
"[data-bwfa-player-edit]", "[data-bwfa-player-export]",
|
||||
"[data-bwfa-player-playpause]"
|
||||
];
|
||||
add( "PROBE", "measuring the player's controls" );
|
||||
// This panel covers the bottom of the window, so measuring with it on
|
||||
// screen reports the panel as the topmost thing over every control.
|
||||
// Ask it to step out of the way first.
|
||||
var wasShowing = panel && panel.style.display !== "none";
|
||||
if ( wasShowing ) {
|
||||
panel.style.display = "none";
|
||||
}
|
||||
selectors.forEach( function ( selector ) {
|
||||
var node = document.querySelector( selector );
|
||||
if ( ! node ) {
|
||||
add( "PROBE", selector + " MISSING from the page" );
|
||||
return;
|
||||
}
|
||||
var box = node.getBoundingClientRect();
|
||||
var size = Math.round( box.width ) + "x" + Math.round( box.height ) +
|
||||
" at " + Math.round( box.left ) + "," + Math.round( box.top );
|
||||
// elementFromPoint is the whole point of this, but it does not
|
||||
// exist everywhere the tests run.
|
||||
var mid = document.elementFromPoint ? document.elementFromPoint(
|
||||
box.left + box.width / 2, box.top + box.height / 2 ) : null;
|
||||
var reaches = ! document.elementFromPoint ||
|
||||
( mid && ( mid === node || ( mid.closest && mid.closest( selector ) ) ) );
|
||||
add( "PROBE", selector + " " + size +
|
||||
( box.width < 4 || box.height < 4 ? " SQUASHED" : "" ) +
|
||||
" topmost: " + element( mid ) +
|
||||
( reaches ? " reachable" : " NOT REACHABLE — clicks land elsewhere" ) );
|
||||
} );
|
||||
if ( wasShowing ) {
|
||||
panel.style.display = "flex";
|
||||
}
|
||||
var modal = document.querySelector( "[data-bwfa-mixer]" );
|
||||
add( "PROBE", "mixer modal in the page: " +
|
||||
( modal ? "yes, hidden=" + modal.hidden + ", open class=" +
|
||||
modal.classList.contains( "open" ) : "NO — markup missing" ) );
|
||||
// Unhidden is not the same as visible, which is exactly the trap that
|
||||
// made this look like a dead button. Report what is actually painted.
|
||||
add( "PROBE", "mixer modal display: " +
|
||||
( modal ? window.getComputedStyle( modal ).display : "n/a" ) );
|
||||
add( "PROBE", "page built " + ( window.BWFA_BUILD || "unknown" ) );
|
||||
}
|
||||
|
||||
function render() {
|
||||
output.textContent = lines.join( "\n" );
|
||||
output.scrollTop = output.scrollHeight;
|
||||
}
|
||||
|
||||
function build() {
|
||||
panel = document.createElement( "div" );
|
||||
// Inline styles throughout: this has to work when the app's own
|
||||
// stylesheet is the thing that's broken.
|
||||
panel.setAttribute( "style", [
|
||||
"position:fixed", "left:0", "right:0", "bottom:0", "height:45vh",
|
||||
"z-index:2147483647", "background:#1c1c1e", "color:#e8e8ed",
|
||||
"font:11px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace",
|
||||
"display:flex", "flex-direction:column",
|
||||
"box-shadow:0 -8px 24px rgba(0,0,0,.4)"
|
||||
].join( ";" ) );
|
||||
|
||||
var bar = document.createElement( "div" );
|
||||
bar.setAttribute( "style", [
|
||||
"display:flex", "align-items:center", "gap:8px", "padding:8px 12px",
|
||||
"border-bottom:1px solid #3a3a3c", "flex:none"
|
||||
].join( ";" ) );
|
||||
|
||||
var title = document.createElement( "strong" );
|
||||
title.textContent = "BWF Analyser log";
|
||||
title.setAttribute( "style", "margin-right:auto;font-weight:600" );
|
||||
bar.appendChild( title );
|
||||
|
||||
function chip( label, onClick ) {
|
||||
var b = document.createElement( "button" );
|
||||
b.type = "button";
|
||||
b.textContent = label;
|
||||
b.setAttribute( "style", [
|
||||
"font:inherit", "padding:3px 10px", "border-radius:6px",
|
||||
"border:1px solid #5a5a5e", "background:#2c2c2e", "color:inherit",
|
||||
"cursor:pointer"
|
||||
].join( ";" ) );
|
||||
b.addEventListener( "click", onClick );
|
||||
bar.appendChild( b );
|
||||
return b;
|
||||
}
|
||||
|
||||
chip( "Copy", function () {
|
||||
var text = lines.join( "\n" );
|
||||
if ( navigator.clipboard && navigator.clipboard.writeText ) {
|
||||
navigator.clipboard.writeText( text ).then( function () {
|
||||
title.textContent = "BWF Analyser log — copied";
|
||||
}, function () {
|
||||
fallbackCopy( text, title );
|
||||
} );
|
||||
} else {
|
||||
fallbackCopy( text, title );
|
||||
}
|
||||
} );
|
||||
chip( "Measure controls", function () { probe(); } );
|
||||
chip( "Clear", function () { lines = []; render(); } );
|
||||
chip( "Close", function () { toggle( false ); } );
|
||||
|
||||
output = document.createElement( "pre" );
|
||||
output.setAttribute( "style", [
|
||||
"margin:0", "padding:10px 12px", "overflow:auto", "flex:1",
|
||||
"white-space:pre-wrap", "word-break:break-word"
|
||||
].join( ";" ) );
|
||||
|
||||
panel.appendChild( bar );
|
||||
panel.appendChild( output );
|
||||
document.body.appendChild( panel );
|
||||
}
|
||||
|
||||
function fallbackCopy( text, title ) {
|
||||
var area = document.createElement( "textarea" );
|
||||
area.value = text;
|
||||
document.body.appendChild( area );
|
||||
area.select();
|
||||
try {
|
||||
document.execCommand( "copy" );
|
||||
title.textContent = "BWF Analyser log — copied";
|
||||
} catch ( e ) {
|
||||
title.textContent = "BWF Analyser log — select the text and copy it";
|
||||
}
|
||||
area.remove();
|
||||
}
|
||||
|
||||
function toggle( wanted ) {
|
||||
if ( ! panel ) { build(); }
|
||||
var show = wanted === undefined ? panel.style.display === "none" : wanted;
|
||||
panel.style.display = show ? "flex" : "none";
|
||||
if ( show ) {
|
||||
probe();
|
||||
render();
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener( "keydown", function ( e ) {
|
||||
if ( ( e.metaKey || e.ctrlKey ) && e.shiftKey &&
|
||||
String( e.key ).toLowerCase() === "l" ) {
|
||||
e.preventDefault();
|
||||
toggle();
|
||||
}
|
||||
} );
|
||||
|
||||
window.BWFA_DIAG = {
|
||||
lines: function () { return lines.slice(); },
|
||||
open: function () { toggle( true ); },
|
||||
close: function () { toggle( false ); },
|
||||
probe: probe,
|
||||
describe: element
|
||||
};
|
||||
}() );
|
||||
+228
@@ -0,0 +1,228 @@
|
||||
{
|
||||
"dropHere": "Drag & drop a folder here, or",
|
||||
"dropHereActive": "Release to add these files",
|
||||
"notSupported": "Folder selection is not supported in this browser. You can still pick individual files.",
|
||||
"selectFolder": "Select Folder",
|
||||
"selectFiles": "Select Files",
|
||||
"clear": "Clear",
|
||||
"statusScanning": "Scanning folder…",
|
||||
"statusParsing": "Reading file %1$d of %2$d…",
|
||||
"statusDone": "Done — %1$d file(s) analysed, %2$d skipped.",
|
||||
"noFilesYet": "No files analysed yet.",
|
||||
"noFilesFound": "No BWF/WAV files were found in that selection.",
|
||||
"noResultsFilter": "No files match your search.",
|
||||
"colFileName": "File",
|
||||
"colFolder": "Folder",
|
||||
"colScene": "Scene",
|
||||
"colTake": "Take",
|
||||
"colTape": "Tape/Reel",
|
||||
"colCircled": "Circled",
|
||||
"colTimecode": "Start TC",
|
||||
"colDuration": "Duration",
|
||||
"colFrameRate": "FPS",
|
||||
"colSampleRate": "Sample Rate",
|
||||
"colBitDepth": "Bit Depth",
|
||||
"colChannels": "Channels",
|
||||
"colTrackNames": "Track Names",
|
||||
"trackNamePlaceholder": "Unnamed",
|
||||
"trackFileCount": "%1$d file(s)",
|
||||
"trackWasCount": "was %1$s · %2$d file(s)",
|
||||
"trackUnnamedChannel": "Channel %1$d — no name",
|
||||
"colOriginator": "Originator",
|
||||
"colOriginatorRef": "Originator Ref.",
|
||||
"colOriginationDate": "Origination Date",
|
||||
"colOriginationTime": "Origination Time",
|
||||
"colDescription": "Description",
|
||||
"colNote": "Note",
|
||||
"colProject": "Project",
|
||||
"colFileSize": "File Size",
|
||||
"colMarkers": "Markers",
|
||||
"colLoudness": "Integrated LUFS",
|
||||
"colCodingHistory": "Coding History",
|
||||
"colDetails": "Edit",
|
||||
"colPlay": "Play",
|
||||
"detailsBtn": "Edit",
|
||||
"play": "Play",
|
||||
"pause": "Pause",
|
||||
"nowPlaying": "Now playing",
|
||||
"playbackError": "This file could not be played back (unsupported codec or unreadable data).",
|
||||
"timecodeFromDescription": "from Description tag, no iXML present",
|
||||
"channelLabel": "Ch",
|
||||
"colWaveform": "Waveform",
|
||||
"columnsBtn": "Settings",
|
||||
"editFolder": "Edit Metadata in a Folder…",
|
||||
"editing": "Editing",
|
||||
"edit": "Edit",
|
||||
"save": "Save",
|
||||
"saving": "Saving…",
|
||||
"saved": "Saved.",
|
||||
"saveError": "Could not save this file — it may have been moved, deleted, or you may need to re-grant permission.",
|
||||
"discardConfirm": "You have unsaved changes. Discard them?",
|
||||
"fieldScene": "Scene",
|
||||
"fieldTake": "Take",
|
||||
"fieldTape": "Tape/Reel",
|
||||
"fieldProject": "Project",
|
||||
"fieldCircled": "Circled",
|
||||
"fieldWildTrack": "Wild Track",
|
||||
"fieldUbits": "UBits",
|
||||
"fieldFrameRate": "Frame Rate",
|
||||
"fieldFrameRateFlag": "Drop Frame",
|
||||
"fieldTcSampleRate": "TC Sample Rate",
|
||||
"fieldDigitizerRate": "Digitizer Rate",
|
||||
"fieldNote": "Note",
|
||||
"notEditable": "This file was opened for viewing only — use \"Edit Metadata in a Folder…\" to make it editable.",
|
||||
"bulkEditBtn": "Bulk Edit…",
|
||||
"bulkEditTitle": "Bulk Edit",
|
||||
"bulkEditCount": "Applies to %1$d file(s) matching the current search/filter.",
|
||||
"bulkEditApply": "Apply",
|
||||
"bulkEditApplyN": "Apply to %1$d file(s)",
|
||||
"bulkEditCancel": "Cancel",
|
||||
"bulkEditConfirm": "This will overwrite %1$d file(s) on disk. Continue?",
|
||||
"bulkEditConfirmBtn": "Yes, rewrite %1$d file(s)",
|
||||
"bulkEditConfirmLine": "Rewrite the metadata in %1$d file(s)? The audio is not touched.",
|
||||
"bulkEditProgress": "Saving %1$d of %2$d…",
|
||||
"bulkEditDone": "Done — %1$d saved, %2$d failed.",
|
||||
"ndf": "Non-drop (NDF)",
|
||||
"df": "Drop-frame (DF)",
|
||||
"noChange": "(no change)",
|
||||
"modalTitle": "File metadata",
|
||||
"sectionFormat": "Format",
|
||||
"sectionBext": "Broadcast Extension (bext)",
|
||||
"sectionIxml": "iXML",
|
||||
"sectionInfo": "RIFF INFO",
|
||||
"sectionCue": "Cue Points / Markers",
|
||||
"sectionCart": "Cart Chunk",
|
||||
"sectionOther": "Other chunks found (not decoded)",
|
||||
"yes": "Yes",
|
||||
"no": "No",
|
||||
"unknown": "—",
|
||||
"errorParsing": "Could not read this file.",
|
||||
"errorNotWav": "Not a recognised WAV/BWF file.",
|
||||
"errorJsPdfLoad": "The PDF library could not be loaded (check your internet connection) — try again, or use Export CSV instead.",
|
||||
"exportCsv": "Export CSV",
|
||||
"exportPdf": "Export PDF",
|
||||
"pdfGenerating": "Building PDF…",
|
||||
"pdfTitle": "BWF Metadata Report",
|
||||
"pdfGeneratedOn": "Generated",
|
||||
"pdfFileCount": "Files",
|
||||
"close": "Close",
|
||||
"playerPlaying": "Now playing",
|
||||
"playerReady": "Ready to play",
|
||||
"playerNothing": "Player",
|
||||
"playerIdle": "Nothing loaded yet",
|
||||
"audioReopened": "Audio stopped coming through — the output was reopened.",
|
||||
"asRecorded": "(as recorded)",
|
||||
"hints": {
|
||||
"bwfa-edit-folder": "Open a folder of recordings",
|
||||
"bwfa-reset": "Close the folder and start again",
|
||||
"bwfa-play-btn": "Play this take",
|
||||
"bwfa-details-btn": "Edit this file's metadata",
|
||||
"bwfa-bulk-edit-toggle": "Change a field across every file listed",
|
||||
"bwfa-export-audio": "Write copies to another folder",
|
||||
"bwfa-report-open": "Make a sound report as CSV or PDF",
|
||||
"bwfa-settings-open": "Columns, appearance and playback",
|
||||
"bwfa-player-playpause": "Play or pause",
|
||||
"bwfa-player-edit": "Edit the file that's loaded",
|
||||
"bwfa-player-export": "Export just this file",
|
||||
"bwfa-export-choose": "Pick where the copies go",
|
||||
"bwfa-export-run": "Start writing the copies",
|
||||
"bwfa-export-depth": "Convert deep files to 24-bit, or leave them as recorded",
|
||||
"bwfa-export-channels": "Keep, split to mono, or combine into one poly",
|
||||
"bwfa-export-normalize": "Lift levels to a target, or only stop clipping",
|
||||
"bwfa-export-target": "The peak to aim for, in dBFS",
|
||||
"bwfa-export-collision": "What to do about a file that's already there",
|
||||
"bwfa-export-naming": "Name the copies from their metadata",
|
||||
"bwfa-export-pattern": "Tokens in braces, plus any text you like",
|
||||
"bwfa-bulk-edit-apply": "Write these changes to every file listed",
|
||||
"bwfa-bulk-edit-cancel": "Close without changing anything",
|
||||
"bwfa-result-save": "Save what happened as a text file",
|
||||
"bwfa-result-done": "Close this report",
|
||||
"bwfa-modal-save": "Write these changes to the file",
|
||||
"bwfa-audio-reset": "Restart the app if sound stops",
|
||||
"bwfa-channel-chip": "Click to mute, double-click to solo",
|
||||
"bwfa-name-chip": "Click to rename this track",
|
||||
"bwfa-bulk-track": "Rename this track everywhere it appears",
|
||||
"bwfa-status": "What the app is doing, and what it found",
|
||||
"bwfa-player-badge": "Which file the player is holding",
|
||||
"bwfa-player-elapsed": "How far into the file playback has reached",
|
||||
"bwfa-player-duration": "How long the file runs",
|
||||
"bwfa-search": "Narrow the list by filename, scene or take",
|
||||
"bwfa-settings-cog": "Choose which columns to show",
|
||||
"bwfa-sort": "Click to sort by this column",
|
||||
"bwfa-sort=waveform": "The shape of the audio. Click to sort",
|
||||
"bwfa-sort=fileName": "The name on disk. Click to sort",
|
||||
"bwfa-sort=folder": "The subfolder on the card. Click to sort",
|
||||
"bwfa-sort=scene": "Scene, from the recorder's iXML. Click to sort",
|
||||
"bwfa-sort=take": "Take number, from the recorder's iXML. Click to sort",
|
||||
"bwfa-sort=tape": "Roll or reel, usually the shoot day. Click to sort",
|
||||
"bwfa-sort=circled": "Marked as a good take. Click to sort",
|
||||
"bwfa-sort=startTimecode": "Where the take starts on the day's clock. Click to sort",
|
||||
"bwfa-sort=durationSeconds": "How long the take runs. Click to sort",
|
||||
"bwfa-sort=sampleRate": "Samples per second. Click to sort",
|
||||
"bwfa-sort=bitDepth": "Bits per sample, or 32-bit float. Click to sort",
|
||||
"bwfa-sort=channels": "How many tracks are in the file. Click to sort",
|
||||
"bwfa-sort=frameRate": "Frames per second the timecode counts in. Click to sort",
|
||||
"bwfa-sort=originator": "The recorder that made the file. Click to sort",
|
||||
"bwfa-sort=description": "The bext description field. Click to sort",
|
||||
"bwfa-sort=note": "The iXML note field. Click to sort",
|
||||
"bwfa-sheet-close": "Close this without doing anything",
|
||||
"bwfa-modal-close": "Close. You'll be asked about unsaved changes",
|
||||
"bwfa-modal-prev": "The previous file in the list",
|
||||
"bwfa-modal-next": "The next file in the list",
|
||||
"bwfa-modal-play": "Play this file",
|
||||
"bwfa-modal-filename": "The file this is about",
|
||||
"bwfa-export-cancel": "Close without exporting",
|
||||
"bwfa-export-dest": "Where the copies will be written",
|
||||
"bwfa-export-scope": "How many files this will act on",
|
||||
"bwfa-export-summary": "What the combined file would come out as",
|
||||
"bwfa-export-names": "What the copies would be called",
|
||||
"bwfa-export-tracks": "Choose which tracks to export from this file",
|
||||
"bwfa-export-track-chips": "Click a track to leave it out",
|
||||
"bwfa-export-report": "What happened to each file",
|
||||
"bwfa-edit-field=scene": "Scene, as the slate says it",
|
||||
"bwfa-edit-field=take": "Take number",
|
||||
"bwfa-edit-field=tape": "Roll or reel, usually the shoot day",
|
||||
"bwfa-edit-field=project": "Production name, the same on every file",
|
||||
"bwfa-edit-field=circled": "Mark this as a good take",
|
||||
"bwfa-edit-field=wildTrack": "Mark this as a wild track, recorded without picture",
|
||||
"bwfa-edit-field=ubits": "User bits, free text the recorder carries in timecode",
|
||||
"bwfa-edit-field=frameRate": "Frames per second the timecode counts in",
|
||||
"bwfa-edit-field=frameRateFlag": "Drop frame or not. Only 29.97 and 59.94 ever drop",
|
||||
"bwfa-edit-field=tcSampleRate": "The rate the timecode's sample count is based on",
|
||||
"bwfa-edit-field=digitizerRate": "The rate the converter actually ran at",
|
||||
"bwfa-edit-field=note": "Free text for post: anything worth knowing about this take",
|
||||
"bwfa-edit-field=description": "The bext description, 256 characters, read by most tools",
|
||||
"bwfa-bulk-tracks": "Every track name across these files. Click one to rename it everywhere",
|
||||
"bwfa-bulk-edit-panel": "Fill in only what you want to change",
|
||||
"bwfa-report-create": "Write the report",
|
||||
"bwfa-report-format": "CSV for a spreadsheet, PDF to hand over",
|
||||
"bwfa-report-subject": "Which files go in the report",
|
||||
"bwfa-report-field=company": "Your company, printed at the top",
|
||||
"bwfa-report-field=project": "Production name",
|
||||
"bwfa-report-field=director": "Director's name",
|
||||
"bwfa-report-field=mixer": "Whoever recorded it",
|
||||
"bwfa-report-field=phone": "Contact number on the report",
|
||||
"bwfa-report-field=email": "Contact address on the report",
|
||||
"bwfa-report-field=note": "Anything post should read first",
|
||||
"bwfa-settings=appearance": "Light, dark, or follow the system",
|
||||
"bwfa-settings=playback": "What happens when a file finishes",
|
||||
"bwfa-settings=columns": "Which columns the table shows",
|
||||
"bwfa-theme-mode": "Follow the system, or pick one and stay there",
|
||||
"bwfa-playback-mode": "Stop, play the next file, or loop this one",
|
||||
"bwfa-columns-modal": "Tick the columns you want to see",
|
||||
"bwfa-spectro-open": "See the frequencies in this take",
|
||||
"bwfa-spectro-canvas": "Time across, frequency up. Brighter is louder",
|
||||
"bwfa-mixer-open": "Balance the tracks while you listen",
|
||||
"bwfa-mixer-seek": "Drag to move through the file",
|
||||
"bwfa-mixer-strips": "One fader per track. Listening only",
|
||||
"bwfa-mixer-playpause": "Start or stop playback",
|
||||
"bwfa-mixer-prev": "Mix the previous file",
|
||||
"bwfa-mixer-next": "Mix the next file",
|
||||
"bwfa-mixer-close": "Close the mixer"
|
||||
},
|
||||
"spectroReading": "Reading the file…",
|
||||
"spectroFailed": "That file couldn't be read as a spectrogram.",
|
||||
"mixerNoFile": "Play a file to mix it.",
|
||||
"mixerMute": "M",
|
||||
"mixerSolo": "S"
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Draws the app icon and packs the .icns, with no Xcode tooling involved.
|
||||
|
||||
macOS rounds the corners of nothing: an app icon has to bring its own squircle.
|
||||
This draws one at 1024px, puts a waveform on it, then downsamples to the sizes
|
||||
macOS asks for. The .icns container is written by hand — it is just a header
|
||||
plus one length-prefixed PNG per size.
|
||||
"""
|
||||
|
||||
import math
|
||||
import pathlib
|
||||
import struct
|
||||
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
OUT = pathlib.Path(__file__).resolve().parent.parent / "mac-app" / "src-tauri" / "icons"
|
||||
|
||||
BASE = 1024
|
||||
SUPERSAMPLE = 2 # draw big, shrink down: cheap anti-aliasing
|
||||
|
||||
BACKGROUND_TOP = (39, 39, 42)
|
||||
BACKGROUND_BOTTOM = (24, 24, 27)
|
||||
WAVE = (250, 250, 250)
|
||||
ACCENT = (96, 165, 250)
|
||||
|
||||
|
||||
def squircle_mask(size, radius_ratio=0.2237):
|
||||
"""Apple's icon shape is close enough to a rounded rect at this radius."""
|
||||
mask = Image.new("L", (size, size), 0)
|
||||
draw = ImageDraw.Draw(mask)
|
||||
inset = int(size * 0.085)
|
||||
box = [inset, inset, size - inset, size - inset]
|
||||
draw.rounded_rectangle(box, radius=int(size * radius_ratio), fill=255)
|
||||
return mask
|
||||
|
||||
|
||||
def vertical_gradient(size, top, bottom):
|
||||
gradient = Image.new("RGB", (1, size))
|
||||
for y in range(size):
|
||||
t = y / max(1, size - 1)
|
||||
gradient.putpixel(
|
||||
(0, y),
|
||||
tuple(int(top[i] + (bottom[i] - top[i]) * t) for i in range(3)),
|
||||
)
|
||||
return gradient.resize((size, size), Image.NEAREST)
|
||||
|
||||
|
||||
def draw_icon(size):
|
||||
canvas = Image.new("RGBA", (size, size), (0, 0, 0, 0))
|
||||
canvas.paste(vertical_gradient(size, BACKGROUND_TOP, BACKGROUND_BOTTOM), (0, 0))
|
||||
canvas.putalpha(squircle_mask(size))
|
||||
|
||||
draw = ImageDraw.Draw(canvas)
|
||||
|
||||
# A waveform: a few overlaid sines so it reads as audio rather than a
|
||||
# perfect textbook sine.
|
||||
centre = size / 2
|
||||
left = size * 0.20
|
||||
right = size * 0.80
|
||||
span = right - left
|
||||
bars = 23
|
||||
bar_width = span / (bars * 1.9)
|
||||
gap = (span - bars * bar_width) / (bars - 1)
|
||||
|
||||
for index in range(bars):
|
||||
phase = index / (bars - 1)
|
||||
envelope = (
|
||||
0.55 * math.sin(phase * math.pi)
|
||||
+ 0.30 * math.sin(phase * math.pi * 3.7 + 0.6)
|
||||
+ 0.18 * math.sin(phase * math.pi * 7.3 + 1.9)
|
||||
)
|
||||
height = abs(envelope) * size * 0.30 + size * 0.022
|
||||
x0 = left + index * (bar_width + gap)
|
||||
colour = ACCENT if index % 5 == 2 else WAVE
|
||||
draw.rounded_rectangle(
|
||||
[x0, centre - height, x0 + bar_width, centre + height],
|
||||
radius=bar_width / 2,
|
||||
fill=colour,
|
||||
)
|
||||
|
||||
return canvas
|
||||
|
||||
|
||||
def render(size):
|
||||
big = draw_icon(size * SUPERSAMPLE)
|
||||
return big.resize((size, size), Image.LANCZOS)
|
||||
|
||||
|
||||
def write_icns(path, images):
|
||||
"""ICNS: 'icns' + total length, then (type, length, PNG payload) records."""
|
||||
# Type codes macOS reads PNG data from, per size.
|
||||
types = {
|
||||
16: b"icp4",
|
||||
32: b"icp5",
|
||||
64: b"icp6",
|
||||
128: b"ic07",
|
||||
256: b"ic08",
|
||||
512: b"ic09",
|
||||
1024: b"ic10",
|
||||
}
|
||||
|
||||
chunks = []
|
||||
for size, png in images.items():
|
||||
if size not in types:
|
||||
continue
|
||||
chunks.append(types[size] + struct.pack(">I", len(png) + 8) + png)
|
||||
|
||||
body = b"".join(chunks)
|
||||
path.write_bytes(b"icns" + struct.pack(">I", len(body) + 8) + body)
|
||||
|
||||
|
||||
def main():
|
||||
OUT.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
sizes = [16, 32, 64, 128, 256, 512, 1024]
|
||||
rendered = {size: render(size) for size in sizes}
|
||||
|
||||
# What tauri.conf.json points at.
|
||||
rendered[32].save(OUT / "32x32.png")
|
||||
rendered[128].save(OUT / "128x128.png")
|
||||
rendered[256].save(OUT / "128x128@2x.png")
|
||||
rendered[512].save(OUT / "icon.png")
|
||||
|
||||
pngs = {}
|
||||
for size, image in rendered.items():
|
||||
temp = OUT / ("_%d.png" % size)
|
||||
image.save(temp)
|
||||
pngs[size] = temp.read_bytes()
|
||||
temp.unlink()
|
||||
|
||||
write_icns(OUT / "icon.icns", pngs)
|
||||
|
||||
for name in ("32x32.png", "128x128.png", "128x128@2x.png", "icon.png", "icon.icns"):
|
||||
print("%-16s %6d bytes" % (name, (OUT / name).stat().st_size))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,224 @@
|
||||
/**
|
||||
* 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 + "</" + tag + ">");
|
||||
};
|
||||
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(
|
||||
'<?xml version="1.0" encoding="UTF-8"?>\n' +
|
||||
"<BWFXML>\n" +
|
||||
" <IXML_VERSION>1.5</IXML_VERSION>\n" +
|
||||
" <PROJECT>" + (opts.project || "Test Shoot") + "</PROJECT>\n" +
|
||||
" <SCENE>" + (opts.scene === undefined ? "12A" : opts.scene) + "</SCENE>\n" +
|
||||
" <TAKE>" + (opts.take === undefined ? 1 : opts.take) + "</TAKE>\n" +
|
||||
" <TAPE>" + (opts.tape === undefined ? "26AUG12" : opts.tape) + "</TAPE>\n" +
|
||||
" <CIRCLED>" + (opts.circled ? "TRUE" : "FALSE") + "</CIRCLED>\n" +
|
||||
" <NOTE>" + (opts.note || "boom a bit hot") + "</NOTE>\n" +
|
||||
" <SPEED>\n" +
|
||||
" <NOTE></NOTE>\n" +
|
||||
(speedEntries.length ? speedEntries.join("\n") + "\n" : "") +
|
||||
" <FILE_SAMPLE_RATE>" + sampleRate + "</FILE_SAMPLE_RATE>\n" +
|
||||
" <AUDIO_BIT_DEPTH>" + bits + "</AUDIO_BIT_DEPTH>\n" +
|
||||
" <DIGITIZER_SAMPLE_RATE>" + sampleRate + "</DIGITIZER_SAMPLE_RATE>\n" +
|
||||
" <TIMESTAMP_SAMPLES_SINCE_MIDNIGHT_HI>" + Math.floor(tcSamples / 4294967296) + "</TIMESTAMP_SAMPLES_SINCE_MIDNIGHT_HI>\n" +
|
||||
" <TIMESTAMP_SAMPLES_SINCE_MIDNIGHT_LO>" + (tcSamples >>> 0) + "</TIMESTAMP_SAMPLES_SINCE_MIDNIGHT_LO>\n" +
|
||||
" </SPEED>\n" +
|
||||
" <TRACK_LIST>\n" +
|
||||
" <TRACK_COUNT>" + trackNames.length + "</TRACK_COUNT>\n" +
|
||||
trackNames.map(function (name, i) {
|
||||
return " <TRACK><CHANNEL_INDEX>" + (i + 1) + "</CHANNEL_INDEX><INTERLEAVE_INDEX>" +
|
||||
(i + 1) + "</INTERLEAVE_INDEX><NAME>" + name + "</NAME></TRACK>\n";
|
||||
}).join("") +
|
||||
" </TRACK_LIST>\n" +
|
||||
"</BWFXML>\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");
|
||||
}
|
||||
+1557
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,266 @@
|
||||
/**
|
||||
* Draws the report.
|
||||
*
|
||||
* Two rules, in this order: nothing is ever cut off, and nothing ever
|
||||
* wraps. The original broke both — it sized columns as fixed fractions of
|
||||
* A4 landscape, so all 26 fields gave each column 31pt while a header like
|
||||
* "Originator Reference" needs 69pt, and it clipped whatever didn't fit
|
||||
* with an ellipsis. A report with "Origina…" and "…VERSIO…" in it is not a
|
||||
* report.
|
||||
*
|
||||
* So: every cell is flattened to one line, every column is measured from
|
||||
* its own widest content, and the page grows sideways to hold the lot. A
|
||||
* wide page is not a problem for a report — every print dialog scales to
|
||||
* fit, and on screen it just scrolls. Past a limit the *type* shrinks
|
||||
* rather than the columns, because scaling columns means cutting text and
|
||||
* scaling type doesn't: widths are linear in font size, so a smaller face
|
||||
* fits exactly the same words in less room.
|
||||
*/
|
||||
function buildPdf( rows ) {
|
||||
if ( ! window.jspdf || ! window.jspdf.jsPDF ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
var columns = pdfColumnsForSelection();
|
||||
var margin = 32;
|
||||
var padding = 5;
|
||||
var rowHeight = 18;
|
||||
var fontSize = columns.length > 16 ? 6.5 : ( columns.length > 11 ? 7 : 8 );
|
||||
|
||||
var A4_WIDTH = 841.89;
|
||||
var A4_HEIGHT = 595.28;
|
||||
/** ~85cm. Past this the type shrinks instead of the page growing. */
|
||||
var MAX_PAGE = 2400;
|
||||
/** Smaller than this stops being readable, at which point a wider page
|
||||
* is the better trade — a cut value is worse than either. */
|
||||
var MIN_FONT = 4.5;
|
||||
/** Float noise in the width arithmetic must never cost a glyph. */
|
||||
var SLACK = 0.75;
|
||||
|
||||
/**
|
||||
* One line, always.
|
||||
*
|
||||
* Coding History is the obvious offender — EBU 3285 defines it as CRLF
|
||||
* separated lines, and there are usually two or three — but a Note or a
|
||||
* Description can carry newlines too. jsPDF renders those as extra
|
||||
* lines drawn *below* the baseline it was given, straight through the
|
||||
* rows underneath. Each break becomes a visible separator instead, so
|
||||
* the text is all still there and reads as the list it is.
|
||||
*/
|
||||
function oneLine( text ) {
|
||||
return String( text )
|
||||
.replace( /\s*[\r\n]+\s*/g, " \u00b7 " )
|
||||
.replace( /[\t\v\f]+/g, " " )
|
||||
.replace( / +/g, " " )
|
||||
.replace( / \u00b7 $/, "" )
|
||||
.trim();
|
||||
}
|
||||
|
||||
function cellText( row, col ) {
|
||||
var raw = extract( row, col.key );
|
||||
if ( col.render ) {
|
||||
return oneLine( col.render( raw ) );
|
||||
}
|
||||
return ( raw === null || raw === undefined || raw === "" ) ? "-" : oneLine( raw );
|
||||
}
|
||||
|
||||
// Measuring needs a document to measure in, at the same font the table
|
||||
// will use. Headers are drawn bold, values normal, so each is measured
|
||||
// as it will be drawn.
|
||||
var doc = new window.jspdf.jsPDF( { orientation: "landscape", unit: "pt", format: "a4" } );
|
||||
|
||||
/** The width each column needs at a given size, cell padding included. */
|
||||
function measureColumns( size ) {
|
||||
doc.setFontSize( size );
|
||||
return columns.map( function ( col ) {
|
||||
doc.setFont( "helvetica", "bold" );
|
||||
var widest = doc.getTextWidth( col.label );
|
||||
doc.setFont( "helvetica", "normal" );
|
||||
rows.forEach( function ( row ) {
|
||||
var width = doc.getTextWidth( cellText( row, col ) );
|
||||
if ( width > widest ) {
|
||||
widest = width;
|
||||
}
|
||||
} );
|
||||
return widest + padding * 2 + SLACK;
|
||||
} );
|
||||
}
|
||||
|
||||
function total( list ) {
|
||||
return list.reduce( function ( sum, width ) { return sum + width; }, 0 );
|
||||
}
|
||||
|
||||
var widths = measureColumns( fontSize );
|
||||
|
||||
// Too wide for the page limit: shrink the type until it isn't, or until
|
||||
// the type would stop being readable — in which case the page grows
|
||||
// past the limit instead. Text is never the thing that gives.
|
||||
if ( total( widths ) + margin * 2 > MAX_PAGE && fontSize > MIN_FONT ) {
|
||||
var wanted = fontSize * ( MAX_PAGE - margin * 2 ) / total( widths );
|
||||
fontSize = Math.max( MIN_FONT, wanted );
|
||||
widths = measureColumns( fontSize );
|
||||
}
|
||||
|
||||
var naturalWidth = total( widths );
|
||||
var pageWidth = Math.max( A4_WIDTH, naturalWidth + margin * 2 );
|
||||
var pageHeight = A4_HEIGHT;
|
||||
var usableWidth = pageWidth - margin * 2;
|
||||
|
||||
// Spare room is shared out proportionally so the table spans the page
|
||||
// exactly rather than stopping short of the edge. This only ever widens
|
||||
// columns: the page is never narrower than the content needs.
|
||||
var scale = usableWidth / naturalWidth;
|
||||
if ( scale > 1 ) {
|
||||
widths = widths.map( function ( width ) { return width * scale; } );
|
||||
}
|
||||
|
||||
if ( pageWidth !== A4_WIDTH ) {
|
||||
doc = new window.jspdf.jsPDF( {
|
||||
orientation: "landscape",
|
||||
unit: "pt",
|
||||
format: [ pageWidth, pageHeight ]
|
||||
} );
|
||||
}
|
||||
|
||||
var y = margin;
|
||||
|
||||
function drawTitle() {
|
||||
doc.setFont( "helvetica", "bold" );
|
||||
doc.setFontSize( 14 );
|
||||
doc.text( t( "pdfTitle" ), margin, y );
|
||||
doc.setFont( "helvetica", "normal" );
|
||||
doc.setFontSize( 9 );
|
||||
doc.setTextColor( 100 );
|
||||
var meta = t( "pdfGeneratedOn" ) + ": " + new Date().toLocaleString() + " " +
|
||||
t( "pdfFileCount" ) + ": " + rows.length;
|
||||
doc.text( meta, margin, y + 16 );
|
||||
doc.setTextColor( 0 );
|
||||
y += 34;
|
||||
|
||||
// The production details. Two to a line where they fit, so seven of
|
||||
// them cost four lines rather than pushing the table down the page —
|
||||
// but measured first, because a long project name in a narrow column
|
||||
// would otherwise print into its neighbour. Only what was filled in
|
||||
// appears: an empty "Director:" is worse than no line at all.
|
||||
var pairs = reportDetailPairs();
|
||||
if ( pairs.length ) {
|
||||
var detailSize = 9;
|
||||
var lineHeight = 12;
|
||||
|
||||
function widestPair( size ) {
|
||||
doc.setFontSize( size );
|
||||
return pairs.reduce( function ( widest, pair ) {
|
||||
doc.setFont( "helvetica", "bold" );
|
||||
var width = doc.getTextWidth( pair[ 0 ] + ": " );
|
||||
doc.setFont( "helvetica", "normal" );
|
||||
width += doc.getTextWidth( oneLine( pair[ 1 ] ) );
|
||||
return Math.max( widest, width );
|
||||
}, 0 );
|
||||
}
|
||||
|
||||
var widest = widestPair( detailSize );
|
||||
var gutter = 24;
|
||||
// One pair per line if two won't fit; and if even one won't fit,
|
||||
// the type shrinks rather than the text being cut.
|
||||
var perLine = ( widest * 2 + gutter <= usableWidth ) ? 2 : 1;
|
||||
if ( widest > usableWidth ) {
|
||||
detailSize = Math.max( 6, detailSize * usableWidth / widest );
|
||||
widest = widestPair( detailSize );
|
||||
}
|
||||
// The second column starts just past the longest pair, not at half
|
||||
// the page: on a 1600pt sheet, half-and-half leaves a lake of
|
||||
// white between a label and its value.
|
||||
var columnWidth = perLine === 2
|
||||
? Math.min( widest + gutter, ( usableWidth - gutter ) / 2 + gutter )
|
||||
: usableWidth;
|
||||
|
||||
doc.setFontSize( detailSize );
|
||||
pairs.forEach( function ( pair, index ) {
|
||||
var x = margin + ( index % perLine ) * columnWidth;
|
||||
var top = y + Math.floor( index / perLine ) * lineHeight;
|
||||
doc.setFont( "helvetica", "bold" );
|
||||
var label = pair[ 0 ] + ": ";
|
||||
var labelWidth = doc.getTextWidth( label );
|
||||
doc.text( label, x, top );
|
||||
doc.setFont( "helvetica", "normal" );
|
||||
doc.text( oneLine( pair[ 1 ] ), x + labelWidth, top );
|
||||
} );
|
||||
y += Math.ceil( pairs.length / perLine ) * lineHeight + 10;
|
||||
}
|
||||
}
|
||||
|
||||
function drawHeaderRow() {
|
||||
doc.setFillColor( 242, 242, 242 );
|
||||
doc.rect( margin, y, usableWidth, rowHeight, "F" );
|
||||
doc.setFont( "helvetica", "bold" );
|
||||
doc.setFontSize( fontSize );
|
||||
|
||||
var x = margin;
|
||||
columns.forEach( function ( col, index ) {
|
||||
doc.text( col.label, x + padding, y + rowHeight - 6 );
|
||||
x += widths[ index ];
|
||||
} );
|
||||
|
||||
doc.setFont( "helvetica", "normal" );
|
||||
y += rowHeight;
|
||||
}
|
||||
|
||||
/** Column separators. With this many columns, a value and the one to
|
||||
* its right otherwise read as a single sentence. */
|
||||
function drawColumnRules( top, bottom ) {
|
||||
doc.setDrawColor( 232, 232, 232 );
|
||||
var x = margin;
|
||||
for ( var index = 0; index < widths.length - 1; index++ ) {
|
||||
x += widths[ index ];
|
||||
doc.line( x, top, x, bottom );
|
||||
}
|
||||
}
|
||||
|
||||
function ensureSpace() {
|
||||
if ( y + rowHeight > pageHeight - margin ) {
|
||||
drawColumnRules( bandTop, y );
|
||||
doc.addPage();
|
||||
y = margin;
|
||||
drawHeaderRow();
|
||||
bandTop = y - rowHeight;
|
||||
}
|
||||
}
|
||||
|
||||
drawTitle();
|
||||
drawHeaderRow();
|
||||
var bandTop = y - rowHeight;
|
||||
doc.setFontSize( fontSize );
|
||||
|
||||
rows.forEach( function ( row, rowIndex ) {
|
||||
ensureSpace();
|
||||
|
||||
if ( rowIndex % 2 === 1 ) {
|
||||
doc.setFillColor( 250, 250, 250 );
|
||||
doc.rect( margin, y, usableWidth, rowHeight, "F" );
|
||||
}
|
||||
|
||||
var x = margin;
|
||||
columns.forEach( function ( col, index ) {
|
||||
doc.text( cellText( row, col ), x + padding, y + rowHeight - 6 );
|
||||
x += widths[ index ];
|
||||
} );
|
||||
|
||||
doc.setDrawColor( 225, 225, 225 );
|
||||
doc.line( margin, y + rowHeight, margin + usableWidth, y + rowHeight );
|
||||
|
||||
y += rowHeight;
|
||||
} );
|
||||
|
||||
drawColumnRules( bandTop, y );
|
||||
|
||||
var totalPages = doc.internal.getNumberOfPages();
|
||||
for ( var p = 1; p <= totalPages; p++ ) {
|
||||
doc.setPage( p );
|
||||
doc.setFontSize( 8 );
|
||||
doc.setTextColor( 130 );
|
||||
doc.text( "Page " + p + " / " + totalPages, pageWidth - margin - 60, pageHeight - 14 );
|
||||
doc.setTextColor( 0 );
|
||||
}
|
||||
|
||||
return doc;
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
/* ---------------------------------------------------------------
|
||||
Standalone page shell. The framework above is scoped to
|
||||
.bwfa-scope, so the page around it needs its own few rules.
|
||||
--------------------------------------------------------------- */
|
||||
html {
|
||||
-webkit-text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: #f4f4f5;
|
||||
color: #18181b;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
font-size: 16px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.page {
|
||||
max-width: 1500px;
|
||||
margin: 0 auto;
|
||||
padding: 24px 20px 64px;
|
||||
}
|
||||
|
||||
.page-foot {
|
||||
max-width: 1500px;
|
||||
margin: 0 auto;
|
||||
padding: 0 20px 40px;
|
||||
font-size: 13px;
|
||||
color: #71717a;
|
||||
}
|
||||
|
||||
.page-foot a {
|
||||
color: #52525b;
|
||||
}
|
||||
|
||||
.bwfa-scope.bwfa-app {
|
||||
background: #fff;
|
||||
border: 1px solid #e4e4e7;
|
||||
border-radius: 10px;
|
||||
padding: 24px;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, .04);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.page {
|
||||
padding: 12px 10px 48px;
|
||||
}
|
||||
|
||||
.bwfa-scope.bwfa-app {
|
||||
padding: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Shown only when the browser refuses the folder-write API on file:// */
|
||||
.bwfa-fs-warning {
|
||||
display: none;
|
||||
margin: 12px 0 0;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #f0c36d;
|
||||
background: #fdf6e3;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
color: #6b4e00;
|
||||
}
|
||||
|
||||
.bwfa-fs-warning code {
|
||||
background: rgba(0, 0, 0, .06);
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Standalone shim.
|
||||
*
|
||||
* The only thing this adds over the original plugin: a clearer message when
|
||||
* the browser refuses the File System Access API. Opening this page straight
|
||||
* off disk (file://) gives it an opaque origin in Chrome, and the folder
|
||||
* picker used for *writing* metadata rejects opaque origins with a
|
||||
* SecurityError. Reading, playback, CSV and PDF are unaffected — only the
|
||||
* "Edit Metadata in a Folder…" path needs the page served over http(s),
|
||||
* which locally means something like `python3 -m http.server` in this file's
|
||||
* folder and then visiting http://localhost:8000/.
|
||||
*
|
||||
* The app's own catch block runs before this one resolves its message, so
|
||||
* the warning is written on the next tick to make sure it lands last.
|
||||
*/
|
||||
( function () {
|
||||
"use strict";
|
||||
|
||||
if ( typeof window.showDirectoryPicker !== "function" ) {
|
||||
return;
|
||||
}
|
||||
|
||||
var original = window.showDirectoryPicker.bind( window );
|
||||
|
||||
window.showDirectoryPicker = function ( options ) {
|
||||
return original( options ).catch( function ( err ) {
|
||||
if ( err && ( err.name === "SecurityError" || err.name === "NotAllowedError" ) &&
|
||||
window.location.protocol === "file:" ) {
|
||||
window.setTimeout( function () {
|
||||
var warning = document.querySelector( "[data-bwfa-fs-warning]" );
|
||||
if ( warning ) {
|
||||
warning.style.display = "block";
|
||||
}
|
||||
var status = document.querySelector( "[data-bwfa-status]" );
|
||||
if ( status ) {
|
||||
status.textContent = "";
|
||||
}
|
||||
}, 0 );
|
||||
}
|
||||
throw err;
|
||||
} );
|
||||
};
|
||||
}() );
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Vendored
+398
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,862 @@
|
||||
/* ---------------------------------------------------------------
|
||||
Native app shell.
|
||||
|
||||
In a window of its own there's no page to sit on, so the card
|
||||
chrome, the outer margin and the grey backdrop all go: the app is
|
||||
the window. The read-only entry points go too — this build opens
|
||||
folders read-write and nothing else.
|
||||
--------------------------------------------------------------- */
|
||||
html {
|
||||
-webkit-text-size-adjust: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
font-size: 15px;
|
||||
line-height: 1.5;
|
||||
/* The window itself is the scroll container; nothing here should
|
||||
ever produce a horizontal one. */
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.page {
|
||||
max-width: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.bwfa-scope.bwfa-app {
|
||||
background: var(--color-bg);
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
/* The plugin caps itself at --container-lg (960px) and centres inside
|
||||
that, which is right for a page in a theme and wrong for a window:
|
||||
widen the window past 960 and the extra space just sat there. In an
|
||||
app the window IS the container, so the cap goes and the padding
|
||||
below becomes the only gutter. */
|
||||
max-width: none;
|
||||
width: 100%;
|
||||
margin-inline: 0;
|
||||
padding: 18px 20px 28px;
|
||||
min-height: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Read-only selection is gone, and so is the in-app title: the window
|
||||
already says what this is, and the privacy note was written for a
|
||||
web page. The markup stays put — the analyser wires listeners to
|
||||
these nodes at startup and the bridge still hands files back through
|
||||
the inputs inside them — it just isn't shown. */
|
||||
.bwfa-scope .bwfa-header,
|
||||
.bwfa-scope .bwfa-dropzone,
|
||||
.bwfa-scope .bwfa-fs-warning,
|
||||
.bwfa-scope .bwfa-edit-divider {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Which promotes the editor to the main event. */
|
||||
.bwfa-scope .bwfa-edit-entry {
|
||||
margin: 0 0 18px;
|
||||
padding: 18px 20px;
|
||||
border: 1px dashed var(--color-border);
|
||||
border-radius: 10px;
|
||||
background: var(--color-surface);
|
||||
text-align: center;
|
||||
transition: border-color .12s ease, background-color .12s ease;
|
||||
}
|
||||
|
||||
.bwfa-scope .bwfa-edit-entry.is-dragover {
|
||||
border-color: var(--color-info);
|
||||
border-style: solid;
|
||||
background: var(--color-info-bg);
|
||||
}
|
||||
|
||||
.bwfa-scope .bwfa-edit-entry .btn {
|
||||
font-size: 15px;
|
||||
padding: 9px 20px;
|
||||
}
|
||||
|
||||
.bwfa-scope .bwfa-edit-entry .bwfa-dropzone-subnote {
|
||||
max-width: 62ch;
|
||||
margin: 12px auto 0;
|
||||
font-size: 12.5px;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* Header sits tighter now that it's a title bar rather than a page
|
||||
heading. */
|
||||
.bwfa-scope .bwfa-header {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.bwfa-scope .bwfa-title {
|
||||
font-size: 19px;
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
|
||||
/* Let the table use the full width of the window. */
|
||||
.bwfa-scope .table-responsive {
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.bwfa-scope .bwfa-table {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Keep both action columns tight; the rest of the width belongs to
|
||||
the metadata. */
|
||||
.bwfa-scope .bwfa-table thead th:nth-child(1),
|
||||
.bwfa-scope .bwfa-table thead th:nth-child(2) {
|
||||
width: 1%;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ---- Sticky table header ------------------------------------------
|
||||
Two things in the framework break `position: sticky` on a table
|
||||
header in WebKit specifically, 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 — rows bleed over
|
||||
the header and its underline detaches. `separate` with zero
|
||||
spacing looks identical and behaves.
|
||||
|
||||
- `-webkit-overflow-scrolling: touch` on the scroll container.
|
||||
It hands scrolling to a separate compositing layer, which is a
|
||||
long-standing way to get stale pixels and sticky elements that
|
||||
scroll away. Momentum scrolling is the platform's job on macOS
|
||||
anyway.
|
||||
------------------------------------------------------------------ */
|
||||
.bwfa-scope .table-responsive {
|
||||
-webkit-overflow-scrolling: auto;
|
||||
}
|
||||
|
||||
.bwfa-scope .bwfa-table {
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
}
|
||||
|
||||
/* Collapse was what drew the row lines; with separate borders each cell
|
||||
draws its own, which comes to the same thing visually. */
|
||||
.bwfa-scope .bwfa-table th,
|
||||
.bwfa-scope .bwfa-table td {
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
/*
|
||||
* Both selectors, deliberately. The plugin gives every *sortable* header
|
||||
* `position: relative` (for the sort arrow), and
|
||||
* `.bwfa-table th[data-bwfa-sort]` outranks `.bwfa-table thead th` — so
|
||||
* naming only the second left fourteen of seventeen headers relative
|
||||
* rather than sticky. The three that held were the ones that aren't
|
||||
* sortable: the two action columns, Description and Note. Hence a header
|
||||
* that was half stuck and half scrolling, with rows painting through the
|
||||
* gap.
|
||||
*
|
||||
* Sticky is itself a positioned value, so the absolutely-positioned sort
|
||||
* arrow still anchors to its own cell.
|
||||
*/
|
||||
.bwfa-scope .bwfa-table thead th,
|
||||
.bwfa-scope .bwfa-table thead th[data-bwfa-sort] {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
/* Above the rows, which are unpositioned, and above the row
|
||||
backgrounds that were painting over it. */
|
||||
z-index: 3;
|
||||
background: var(--color-bg);
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
/* Nothing in the body may promote itself above the header. */
|
||||
.bwfa-scope .bwfa-table tbody td {
|
||||
position: static;
|
||||
z-index: auto;
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.bwfa-scope.bwfa-app {
|
||||
padding: 12px 12px 20px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- Toolbar ------------------------------------------------------
|
||||
The window's minWidth is set so this row always fits; nowrap makes
|
||||
that a hard guarantee rather than a hope, and keeps the search field
|
||||
as the only thing that gives.
|
||||
------------------------------------------------------------------ */
|
||||
.bwfa-scope .bwfa-toolbar {
|
||||
flex-wrap: nowrap;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.bwfa-scope .bwfa-search-group {
|
||||
flex: 1 1 auto;
|
||||
min-width: 180px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.bwfa-scope .bwfa-export-actions {
|
||||
flex: none;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.bwfa-scope .bwfa-export-actions .btn,
|
||||
.bwfa-scope .bwfa-toolbar .btn {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ---- Selection ----------------------------------------------------
|
||||
This is an app, not a document: dragging a selection across a table
|
||||
of takes only ever looks like a mistake. Fields you type into keep
|
||||
selection, since editing without it is miserable.
|
||||
------------------------------------------------------------------ */
|
||||
.bwfa-scope,
|
||||
body {
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.bwfa-scope input,
|
||||
.bwfa-scope textarea,
|
||||
.bwfa-scope select,
|
||||
.bwfa-scope [contenteditable="true"] {
|
||||
-webkit-user-select: text;
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
/* ---- The folder bar, once a folder is open -------------------------
|
||||
Before: a dashed panel inviting you to open something. After: one
|
||||
line saying which folder you're in, with the button to change it.
|
||||
The invitation has served its purpose and the table needs the room.
|
||||
------------------------------------------------------------------ */
|
||||
.bwfa-scope .bwfa-edit-entry.is-folder-open {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin: 0 0 14px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: none;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.bwfa-scope .bwfa-edit-entry.is-folder-open .bwfa-dropzone-subnote {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.bwfa-scope .bwfa-edit-entry.is-folder-open .btn {
|
||||
font-size: var(--fs-sm);
|
||||
padding: 6px 14px;
|
||||
}
|
||||
|
||||
.bwfa-scope .bwfa-current-folder {
|
||||
font-size: 15px;
|
||||
font-weight: var(--fw-medium);
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.bwfa-scope .bwfa-current-folder::before {
|
||||
content: "";
|
||||
display: inline-block;
|
||||
width: 13px;
|
||||
height: 11px;
|
||||
margin-right: 8px;
|
||||
vertical-align: -1px;
|
||||
background-color: var(--color-text-muted);
|
||||
-webkit-mask-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 14 12'%3E%3Cpath d='M1 2.5A1.5 1.5 0 0 1 2.5 1h2.2c.4 0 .8.2 1 .5l.7.9h5.1A1.5 1.5 0 0 1 13 3.9v5.6A1.5 1.5 0 0 1 11.5 11h-9A1.5 1.5 0 0 1 1 9.5v-7Z'/%3E%3C/svg%3E");
|
||||
mask-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 14 12'%3E%3Cpath d='M1 2.5A1.5 1.5 0 0 1 2.5 1h2.2c.4 0 .8.2 1 .5l.7.9h5.1A1.5 1.5 0 0 1 13 3.9v5.6A1.5 1.5 0 0 1 11.5 11h-9A1.5 1.5 0 0 1 1 9.5v-7Z'/%3E%3C/svg%3E");
|
||||
-webkit-mask-repeat: no-repeat;
|
||||
mask-repeat: no-repeat;
|
||||
}
|
||||
|
||||
/* ---- Window layout: the table is the only thing that scrolls -------
|
||||
The player has to stay put while a long day scrolls past it. Pinning
|
||||
it with position:fixed and reserving page padding didn't hold up —
|
||||
the plugin sets .table-responsive to overflow-y:hidden, so the table
|
||||
never scrolls itself and the window does, which puts the last rows
|
||||
under a fixed footer no matter how much padding is reserved.
|
||||
|
||||
So 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 is no
|
||||
clearance to get wrong, because the scrolling box ends where the
|
||||
player begins. The sticky table header now sticks to the top of that
|
||||
box, which is also what you want.
|
||||
------------------------------------------------------------------ */
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.page {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.bwfa-scope.bwfa-app {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
padding-bottom: 14px;
|
||||
}
|
||||
|
||||
.bwfa-scope .bwfa-results {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.bwfa-scope .bwfa-results > .table-responsive {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow-x: auto;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Ordered last regardless of where it sits in the markup. */
|
||||
.bwfa-scope .bwfa-player {
|
||||
order: 99;
|
||||
flex: none;
|
||||
margin: 12px 0 0;
|
||||
padding: 10px 14px 12px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.bwfa-scope .bwfa-player-waveform {
|
||||
height: 34px;
|
||||
}
|
||||
|
||||
/* The plugin spaced the status line by giving the results panel a top
|
||||
margin; making that panel a flex child meant zeroing it, which left
|
||||
"Done — 35 files analysed" sitting on top of the search field. The
|
||||
status carries its own spacing now, and keeps its min-height so the
|
||||
layout doesn't jump when the line appears. */
|
||||
.bwfa-scope .bwfa-status {
|
||||
margin: 6px 0 14px;
|
||||
}
|
||||
|
||||
/* ---- Launch state --------------------------------------------------
|
||||
With nothing open there's nothing to report, so the status line and
|
||||
the "No files analysed yet" placeholder are noise. What's left is one
|
||||
panel, centred, which is the only thing there is to do.
|
||||
------------------------------------------------------------------ */
|
||||
.bwfa-scope.bwfa-app:not(.has-folder) {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.bwfa-scope.bwfa-app:not(.has-folder) .bwfa-status,
|
||||
.bwfa-scope.bwfa-app:not(.has-folder) .bwfa-empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.bwfa-scope.bwfa-app:not(.has-folder) .bwfa-edit-entry {
|
||||
max-width: 560px;
|
||||
margin: 0 auto;
|
||||
padding: 32px 28px;
|
||||
}
|
||||
|
||||
/* The app's own icon, inlined at build time from the .icns source set so
|
||||
the frontend stays a single self-contained file. Launch screen only —
|
||||
once you're working, the window's title bar and Dock icon are enough. */
|
||||
.bwfa-scope .bwfa-launch-logo {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.bwfa-scope.bwfa-app:not(.has-folder) .bwfa-launch-logo {
|
||||
display: block;
|
||||
width: 88px;
|
||||
height: 88px;
|
||||
margin: 0 auto 22px;
|
||||
background-image: url("@@LAUNCH_LOGO@@");
|
||||
background-size: contain;
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
}
|
||||
|
||||
/* Every folder this build opens is opened read-write, so a banner saying
|
||||
so on every screen is telling you something you can't change and
|
||||
already know. The warning that matters is on the launch panel, where
|
||||
you choose the folder in the first place. */
|
||||
.bwfa-scope .bwfa-editing-note {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* ---- Bulk edit and export live in modals ---------------------------
|
||||
Both panels used to open as siblings of the table inside a column
|
||||
exactly as tall as the window. The table region 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 and flooring the table held, but only by dividing a
|
||||
space neither of them wanted to share.
|
||||
|
||||
As modals they're out of that fight: the dialog is positioned against
|
||||
the viewport and scrolls itself, and the table keeps the whole column.
|
||||
The panel inside gives up its own card — the dialog is the card now.
|
||||
------------------------------------------------------------------ */
|
||||
.bwfa-scope .bwfa-sheet-dialog {
|
||||
position: relative;
|
||||
/* Width and height come from --modal-width with every other modal's.
|
||||
Setting them here is how the sheets ended up a different size from
|
||||
the rest, twice. */
|
||||
}
|
||||
|
||||
.bwfa-scope .bwfa-sheet-wide {
|
||||
max-width: 1120px;
|
||||
}
|
||||
|
||||
/* Padding comes from --modal-pad with every other modal's. This file is
|
||||
loaded after overrides.css, so anything set here silently beats the
|
||||
shared rules — which is how Bulk Edit ended up with this panel's 24px
|
||||
on top of the shared header's 32px while Sound Report had only the 32.
|
||||
Three rounds of "why is that one different" traced back to here. */
|
||||
|
||||
/* The close button is a traffic light now, styled once for every modal in
|
||||
overrides.css. This rule used to pin it top right with padding of its
|
||||
own, which quietly made the sheets' button a different size and place
|
||||
from everything else's — it loaded after the shared rule and won. */
|
||||
.bwfa-scope .bwfa-sheet-close {
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
/* Room for the close button, so a long heading can't run under it. */
|
||||
/* Header padding comes from --modal-pad with every other modal's. */
|
||||
|
||||
.bwfa-scope .bwfa-results > .table-responsive {
|
||||
min-height: 20vh;
|
||||
}
|
||||
|
||||
|
||||
/* ---- Reset ---------------------------------------------------------
|
||||
Sits left of the folder button and undoes the session: closes the
|
||||
folder, empties the table, forgets what to reopen. Only meaningful
|
||||
once something is open, so that's the only time it's there.
|
||||
------------------------------------------------------------------ */
|
||||
.bwfa-scope .bwfa-reset {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.bwfa-scope .bwfa-edit-entry.is-folder-open .bwfa-reset {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: none;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
padding: 0;
|
||||
font-size: 0;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.bwfa-scope .bwfa-reset::before {
|
||||
content: "";
|
||||
display: block;
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
background-color: currentColor;
|
||||
-webkit-mask-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M8 2.5 14.2 10.2H1.8z' fill='black'/%3E%3Crect x='1.8' y='11.9' width='12.4' height='2.1' rx='.7' fill='black'/%3E%3C/svg%3E");
|
||||
mask-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M8 2.5 14.2 10.2H1.8z' fill='black'/%3E%3Crect x='1.8' y='11.9' width='12.4' height='2.1' rx='.7' fill='black'/%3E%3C/svg%3E");
|
||||
-webkit-mask-repeat: no-repeat;
|
||||
mask-repeat: no-repeat;
|
||||
-webkit-mask-size: contain;
|
||||
mask-size: contain;
|
||||
}
|
||||
|
||||
/* ---- Export panel --------------------------------------------------
|
||||
Wears the bulk-edit panel's classes, so the card, the 38px control
|
||||
height, the label treatment and the 40vh cap all come for free. Only
|
||||
the parts it doesn't share are here: the destination row, which is a
|
||||
field with a button welded to its end, and the report.
|
||||
------------------------------------------------------------------ */
|
||||
/* Its own row class rather than the bulk-edit one: the app enumerates
|
||||
.bwfa-bulk-edit-row to work out which fields to write, so sharing the
|
||||
name would hand it five controls that have nothing to do with editing.
|
||||
The look is shared here instead, which is the part worth sharing. */
|
||||
.bwfa-scope .bwfa-export-fields {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||
gap: 16px 20px;
|
||||
margin-bottom: var(--space-4);
|
||||
max-width: 1080px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.bwfa-scope .bwfa-export-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.bwfa-scope .bwfa-export-row > .form-label {
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
color: var(--color-text-muted);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.bwfa-scope .bwfa-export-row > .form-control,
|
||||
.bwfa-scope .bwfa-export-row > .form-select {
|
||||
width: 100%;
|
||||
height: 38px;
|
||||
min-height: 38px;
|
||||
padding: 0 10px;
|
||||
line-height: 1.2;
|
||||
box-sizing: border-box;
|
||||
font-size: var(--fs-sm);
|
||||
}
|
||||
|
||||
.bwfa-scope .bwfa-export-row > .form-select {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
padding-right: 30px;
|
||||
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 8'%3E%3Cpath d='M1 1.5 6 6.5l5-5' fill='none' stroke='%2371717a' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 11px center;
|
||||
background-size: 11px;
|
||||
}
|
||||
|
||||
.bwfa-scope .bwfa-export-row input[type="number"] {
|
||||
-moz-appearance: textfield;
|
||||
}
|
||||
|
||||
.bwfa-scope .bwfa-export-row input[type="number"]::-webkit-outer-spin-button,
|
||||
.bwfa-scope .bwfa-export-row input[type="number"]::-webkit-inner-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Nothing to type into means nothing to look at: a disabled target reads
|
||||
as inactive rather than as a field you're failing to fill in. */
|
||||
.bwfa-scope .bwfa-export-row > .form-control:disabled {
|
||||
background-color: var(--color-surface-alt, #f4f4f5);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* The path is the long one, so it gets the width when there is any. */
|
||||
.bwfa-scope .bwfa-export-dest {
|
||||
grid-column: span 2;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.bwfa-scope .bwfa-export-dest-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: stretch;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.bwfa-scope .bwfa-export-dest-row > .form-control {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
height: 38px;
|
||||
padding: 0 10px;
|
||||
font-size: var(--fs-sm);
|
||||
box-sizing: border-box;
|
||||
direction: ltr;
|
||||
/* A long path should show its end — the folder — not its beginning. */
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.bwfa-scope .bwfa-export-dest-row > .btn {
|
||||
flex: none;
|
||||
height: 38px;
|
||||
padding: 0 14px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ---- The report modal ----------------------------------------------
|
||||
A finished export is not a form, so it stops looking like one. The
|
||||
dialog is the only one 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.
|
||||
------------------------------------------------------------------ */
|
||||
.bwfa-scope .bwfa-sheet-report {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
/* Width comes from --modal-width with every other modal's. This used
|
||||
to set its own, which is exactly how six dialogs ended up six
|
||||
different sizes. */
|
||||
}
|
||||
|
||||
.bwfa-scope .bwfa-sheet-report .bwfa-result-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.bwfa-scope .bwfa-result-panel .bwfa-bulk-edit-header {
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.bwfa-scope .bwfa-result-panel .bwfa-bulk-edit-header p {
|
||||
margin: 4px 0 0;
|
||||
font-size: var(--fs-sm);
|
||||
color: var(--color-text-muted);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.bwfa-scope .bwfa-result-tally {
|
||||
display: flex;
|
||||
gap: var(--space-5);
|
||||
flex: none;
|
||||
padding-bottom: var(--space-4);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.bwfa-scope .bwfa-result-stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.bwfa-scope .bwfa-result-stat strong {
|
||||
font-size: 22px;
|
||||
font-weight: var(--fw-semibold);
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.bwfa-scope .bwfa-result-stat span {
|
||||
font-size: var(--fs-xs, 12px);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* A count of nothing still belongs on screen — "0 failed" is the whole
|
||||
reassurance — but it shouldn't compete with the counts that matter. */
|
||||
.bwfa-scope .bwfa-result-stat.is-zero strong,
|
||||
.bwfa-scope .bwfa-result-stat.is-zero span {
|
||||
color: var(--gray-400, #b0b0b8);
|
||||
}
|
||||
|
||||
.bwfa-scope .bwfa-result-stat.is-bad strong {
|
||||
color: var(--color-danger, #b42318);
|
||||
}
|
||||
|
||||
.bwfa-scope .bwfa-result-note {
|
||||
flex: none;
|
||||
margin: var(--space-3) 0 0;
|
||||
font-size: var(--fs-sm);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.bwfa-scope .bwfa-result-panel .bwfa-bulk-edit-actions {
|
||||
flex: none;
|
||||
margin-top: var(--space-4);
|
||||
padding-top: var(--space-4);
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.bwfa-scope .bwfa-export-report {
|
||||
margin-top: var(--space-3);
|
||||
font-size: var(--fs-sm);
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
/* Room for the scrollbar so the last column doesn't sit under it. */
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.bwfa-scope .bwfa-export-report ul {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.bwfa-scope .bwfa-export-report li {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
padding: 7px 2px;
|
||||
color: var(--color-text-muted);
|
||||
border-bottom: 1px solid var(--color-border-subtle, var(--gray-100, #f1f1f4));
|
||||
}
|
||||
|
||||
.bwfa-scope .bwfa-export-report li:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.bwfa-scope .bwfa-export-report li.is-problem {
|
||||
color: var(--color-danger, #b42318);
|
||||
}
|
||||
|
||||
.bwfa-scope .bwfa-export-report-name {
|
||||
flex: none;
|
||||
width: 38%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--color-text);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.bwfa-scope .bwfa-export-report-detail {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* ---- Track picker in the export panel ------------------------------
|
||||
The same chips as the player's channel strip, because they answer
|
||||
the same question about the same file — there they mute, here they
|
||||
choose what gets written. Only offered for a single file: across a
|
||||
folder of takes, "track 3" isn't the same thing twice.
|
||||
------------------------------------------------------------------ */
|
||||
.bwfa-scope .bwfa-export-tracks {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.bwfa-scope .bwfa-export-tracks .bwfa-channel-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.bwfa-scope .bwfa-export-tracks .chip {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Off reads as off: struck through and faded, the way a muted channel
|
||||
already reads in the player. */
|
||||
.bwfa-scope .bwfa-export-tracks .chip.is-off {
|
||||
opacity: 0.45;
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
/* ---- The audio escape hatch ----------------------------------------
|
||||
Only in the app, where reloading the window costs a folder re-scan
|
||||
and nothing else: the last folder reopens by itself.
|
||||
------------------------------------------------------------------ */
|
||||
.bwfa-scope .bwfa-settings-action {
|
||||
display: block;
|
||||
margin-top: 14px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
/* ---- What a combine would make ------------------------------------
|
||||
Shown before anything is written, because the answer to "can this be
|
||||
done" is already in the metadata: rates, timecodes, lengths. A
|
||||
problem here disables Export rather than interrupting with a dialog.
|
||||
------------------------------------------------------------------ */
|
||||
.bwfa-scope .bwfa-export-summary-line {
|
||||
margin: 4px 0 14px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm, 6px);
|
||||
background-color: var(--color-surface);
|
||||
font-size: var(--fs-sm);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.bwfa-scope .bwfa-export-summary-line.is-blocked {
|
||||
border-color: var(--color-danger);
|
||||
background-color: var(--color-danger-bg);
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.bwfa-scope .bwfa-export-summary-line small {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.bwfa-scope .bwfa-export-summary-line.is-blocked small {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
/* ---- Naming the copies ---------------------------------------------
|
||||
The pattern row and the preview both span, because a filename is a
|
||||
long thing and reading old against new is the entire point of
|
||||
showing it at all.
|
||||
------------------------------------------------------------------ */
|
||||
.bwfa-scope .bwfa-export-pattern {
|
||||
grid-column: 1 / -1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.bwfa-scope .bwfa-export-tokens {
|
||||
margin: 6px 0 0;
|
||||
font-size: var(--fs-xs, 12px);
|
||||
color: var(--color-text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
word-spacing: 4px;
|
||||
}
|
||||
|
||||
.bwfa-scope .bwfa-export-names {
|
||||
margin: 4px 0 12px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm, 6px);
|
||||
background-color: var(--color-surface);
|
||||
font-size: var(--fs-sm);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.bwfa-scope .bwfa-export-names.is-blocked {
|
||||
border-color: var(--color-danger);
|
||||
background-color: var(--color-danger-bg);
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.bwfa-scope .bwfa-export-name-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
padding: 2px 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* The arrow is drawn rather than typed, so a name containing one can't
|
||||
be mistaken for the thing pointing at it. */
|
||||
.bwfa-scope .bwfa-export-name-row > span::after {
|
||||
content: "\2192";
|
||||
margin-left: 10px;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.bwfa-scope .bwfa-export-name-row > span {
|
||||
flex: none;
|
||||
max-width: 46%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.bwfa-scope .bwfa-export-name-row > strong {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-weight: var(--fw-medium, 500);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.bwfa-scope .bwfa-export-names small {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,374 @@
|
||||
/**
|
||||
* Frame-rate writing, against the standards rather than against one reader.
|
||||
*
|
||||
* A frame rate in a BWF file lives in up to five places, and different software
|
||||
* reads different ones:
|
||||
*
|
||||
* iXML SPEED/TIMECODE_RATE rational — "30/1", 29.97 is 30000/1001
|
||||
* iXML SPEED/TIMECODE_FLAG NDF or DF, and DF only means anything on the
|
||||
* 1000/1001 rates
|
||||
* iXML SPEED/MASTER_SPEED same rational, unless the file describes a
|
||||
* iXML SPEED/CURRENT_SPEED pull-up/pull-down, in which case: hands off
|
||||
* bext Description a recorder's own "aSPEED=025.000-ND" tag; bext
|
||||
* has no frame-rate field of its own
|
||||
*
|
||||
* Each case here drives the real app through a real save, then reads the bytes
|
||||
* back off disk.
|
||||
*
|
||||
* Run: npm i jsdom && node build/test-framerate.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 INDEX = path.join(__dirname, "..", "mac-app", "dist", "index.html");
|
||||
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "bwf-rate-"));
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Rust commands, mirrored (see test-tauri.js) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
let dialogQueue = [];
|
||||
|
||||
function describe(filePath, relativePath) {
|
||||
const stat = fs.statSync(filePath);
|
||||
return {
|
||||
path: filePath,
|
||||
relativePath,
|
||||
name: path.basename(filePath),
|
||||
size: stat.size,
|
||||
lastModified: Math.floor(stat.mtimeMs),
|
||||
};
|
||||
}
|
||||
|
||||
function hexDecode(hex) {
|
||||
return Buffer.from(hex, "hex").toString("utf8");
|
||||
}
|
||||
|
||||
function mockInvoke(realm) {
|
||||
const toRealmBuffer = (buffer) => {
|
||||
const view = new realm.Uint8Array(buffer.length);
|
||||
view.set(buffer);
|
||||
return view.buffer;
|
||||
};
|
||||
|
||||
return function invoke(command, payload, options) {
|
||||
if (command === "bwf_write") {
|
||||
const headers = (options && options.headers) || {};
|
||||
const target = hexDecode(headers["x-bwf-path"]);
|
||||
const position = parseInt(headers["x-bwf-position"] || "0", 10);
|
||||
const truncate = headers["x-bwf-truncate"] === "1";
|
||||
const bytes = Buffer.from(payload.buffer ? new Uint8Array(payload) : payload);
|
||||
const fd = fs.openSync(target, "r+");
|
||||
fs.writeSync(fd, bytes, 0, bytes.length, position);
|
||||
if (truncate) fs.ftruncateSync(fd, position + bytes.length);
|
||||
fs.closeSync(fd);
|
||||
return Promise.resolve(bytes.length);
|
||||
}
|
||||
if (command === "plugin:dialog|open") {
|
||||
const next = dialogQueue.shift();
|
||||
return Promise.resolve(next === undefined ? null : next);
|
||||
}
|
||||
if (command === "bwf_list_dir") {
|
||||
return Promise.resolve(
|
||||
fs.readdirSync(payload.path).sort()
|
||||
.filter((name) => !name.startsWith("."))
|
||||
.map((name) => ({
|
||||
name,
|
||||
path: path.join(payload.path, name),
|
||||
kind: fs.statSync(path.join(payload.path, name)).isDirectory() ? "directory" : "file",
|
||||
}))
|
||||
);
|
||||
}
|
||||
if (command === "bwf_stat") {
|
||||
return Promise.resolve(describe(payload.path, path.basename(payload.path)));
|
||||
}
|
||||
if (command === "bwf_read_range") {
|
||||
const size = fs.statSync(payload.path).size;
|
||||
if (payload.offset >= size || payload.length === 0) {
|
||||
return Promise.resolve(toRealmBuffer(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(toRealmBuffer(buffer));
|
||||
}
|
||||
if (command === "bwf_read_all") {
|
||||
return Promise.resolve(toRealmBuffer(fs.readFileSync(payload.path)));
|
||||
}
|
||||
return Promise.reject(new Error("unexpected command " + command));
|
||||
};
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Reading the result back off disk */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function inspect(file) {
|
||||
const d = fs.readFileSync(file);
|
||||
let offset = 12;
|
||||
const chunks = {};
|
||||
while (offset + 8 <= d.length) {
|
||||
const id = d.slice(offset, offset + 4).toString("latin1");
|
||||
const size = d.readUInt32LE(offset + 4);
|
||||
chunks[id] = { start: offset + 8, size };
|
||||
offset += 8 + size + (size % 2);
|
||||
}
|
||||
const xml = chunks.iXML
|
||||
? d.slice(chunks.iXML.start, chunks.iXML.start + chunks.iXML.size).toString("utf8")
|
||||
: "";
|
||||
const tag = (name) => {
|
||||
const match = new RegExp("<" + name + ">([^<]*)</" + name + ">").exec(xml);
|
||||
return match ? match[1] : null;
|
||||
};
|
||||
return {
|
||||
bytes: d,
|
||||
xml,
|
||||
rate: tag("TIMECODE_RATE"),
|
||||
flag: tag("TIMECODE_FLAG"),
|
||||
master: tag("MASTER_SPEED"),
|
||||
current: tag("CURRENT_SPEED"),
|
||||
description: chunks.bext
|
||||
? d.slice(chunks.bext.start, chunks.bext.start + 256).toString("latin1").replace(/\0.*$/, "")
|
||||
: null,
|
||||
audio: chunks.data
|
||||
? d.slice(chunks.data.start, chunks.data.start + chunks.data.size)
|
||||
: Buffer.alloc(0),
|
||||
};
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* One case: open a folder, set the rate (and flag), save */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const results = [];
|
||||
function check(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
results.push(["PASS", name]);
|
||||
} catch (e) {
|
||||
results.push(["FAIL", name + " — " + e.message]);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
})();
|
||||
});
|
||||
}
|
||||
|
||||
async function runCase(label, fileOpts, choose) {
|
||||
const dir = fs.mkdtempSync(path.join(root, label.replace(/\W+/g, "-") + "-"));
|
||||
const file = path.join(dir, "A001.wav");
|
||||
fs.writeFileSync(file, build(fileOpts));
|
||||
const before = inspect(file);
|
||||
|
||||
const consoleErrors = [];
|
||||
const virtualConsole = new VirtualConsole();
|
||||
virtualConsole.on("jsdomError", (e) => consoleErrors.push(e.message));
|
||||
|
||||
const dom = new JSDOM(fs.readFileSync(INDEX, "utf8"), {
|
||||
runScripts: "dangerously",
|
||||
// jsdom treats a custom scheme as an opaque origin and then
|
||||
// refuses localStorage, which would leave every persistence path
|
||||
// untested. Tauri itself serves the app from this origin on Windows and
|
||||
// from tauri://localhost on macOS; either way the app code is the same.
|
||||
url: "http://tauri.localhost/",
|
||||
pretendToBeVisual: true,
|
||||
virtualConsole,
|
||||
beforeParse(win) {
|
||||
const ctxStub = new Proxy({}, {
|
||||
get: (target, prop) => (prop === "canvas" ? null : () => {}),
|
||||
set: () => true,
|
||||
});
|
||||
win.HTMLCanvasElement.prototype.getContext = () => ctxStub;
|
||||
win.URL.createObjectURL = () => "blob:stub";
|
||||
win.URL.revokeObjectURL = () => {};
|
||||
win.__TAURI__ = {
|
||||
core: { invoke: mockInvoke(win) },
|
||||
event: { listen: () => Promise.resolve(() => {}) },
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const { window } = dom;
|
||||
await new Promise((r) => window.addEventListener("load", r));
|
||||
const doc = window.document;
|
||||
|
||||
dialogQueue = [dir];
|
||||
doc.querySelector("[data-bwfa-edit-folder]").dispatchEvent(
|
||||
new window.MouseEvent("click", { bubbles: true }));
|
||||
|
||||
const status = doc.querySelector("[data-bwfa-status]");
|
||||
await waitFor(() => /Done/i.test(status.textContent), "folder open");
|
||||
|
||||
const row = doc.querySelector("[data-bwfa-table-body] tr");
|
||||
const detailsBtn = Array.from(row.querySelectorAll("button"))
|
||||
.find((b) => /^(details|edit)$/i.test(b.textContent.trim()));
|
||||
detailsBtn.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
|
||||
|
||||
await waitFor(() => doc.querySelector('[data-bwfa-edit-field="frameRate"]'), "edit form");
|
||||
choose(doc, window);
|
||||
|
||||
const saveBtn = doc.querySelector("[data-bwfa-modal-save]");
|
||||
saveBtn.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
|
||||
await waitFor(() => /saved/i.test(saveBtn.textContent), "save (" + label + ")");
|
||||
|
||||
assert.strictEqual(consoleErrors.length, 0, consoleErrors.join(" | "));
|
||||
window.close();
|
||||
return { before, after: inspect(file) };
|
||||
}
|
||||
|
||||
function setField(doc, window, key, value) {
|
||||
const el = doc.querySelector('[data-bwfa-edit-field="' + key + '"]');
|
||||
assert(el, "no field " + key);
|
||||
el.value = value;
|
||||
el.dispatchEvent(new window.Event("change", { bubbles: true }));
|
||||
el.dispatchEvent(new window.Event("input", { bubbles: true }));
|
||||
}
|
||||
|
||||
(async () => {
|
||||
/* --- 25 -> 30 on a complete, well-formed file --- */
|
||||
|
||||
const plain = await runCase("25 to 30", {
|
||||
description: "aSPEED=025.000-ND",
|
||||
speed: { masterSpeed: "25/1", currentSpeed: "25/1", timecodeRate: "25/1", timecodeFlag: "NDF" },
|
||||
}, (doc, win) => setField(doc, win, "frameRate", "30"));
|
||||
|
||||
check("the rate is written as a rational, not a decimal", () => {
|
||||
assert.strictEqual(plain.after.rate, "30/1",
|
||||
"TIMECODE_RATE is " + JSON.stringify(plain.after.rate));
|
||||
});
|
||||
|
||||
check("MASTER_SPEED and CURRENT_SPEED follow the rate", () => {
|
||||
assert.strictEqual(plain.after.master, "30/1", "MASTER_SPEED is " + plain.after.master);
|
||||
assert.strictEqual(plain.after.current, "30/1", "CURRENT_SPEED is " + plain.after.current);
|
||||
});
|
||||
|
||||
check("the recorder's own SPEED tag in bext is brought along", () => {
|
||||
assert.strictEqual(plain.after.description, "aSPEED=030.000-ND",
|
||||
"Description is " + JSON.stringify(plain.after.description));
|
||||
});
|
||||
|
||||
check("the flag stays valid and the audio is untouched", () => {
|
||||
assert.strictEqual(plain.after.flag, "NDF", "flag is " + plain.after.flag);
|
||||
assert(plain.before.audio.equals(plain.after.audio), "audio bytes changed");
|
||||
});
|
||||
|
||||
/* --- 25 -> 29.97 drop frame --- */
|
||||
|
||||
const drop = await runCase("25 to 29.97 DF", {
|
||||
description: "sSPEED=025.000-NDF",
|
||||
speed: { masterSpeed: "25/1", currentSpeed: "25/1", timecodeRate: "25/1", timecodeFlag: "NDF" },
|
||||
}, (doc, win) => {
|
||||
setField(doc, win, "frameRate", "29.97");
|
||||
setField(doc, win, "frameRateFlag", "DF");
|
||||
});
|
||||
|
||||
check("29.97 is written as 30000/1001", () => {
|
||||
assert.strictEqual(drop.after.rate, "30000/1001", "rate is " + drop.after.rate);
|
||||
assert.strictEqual(drop.after.master, "30000/1001", "master is " + drop.after.master);
|
||||
});
|
||||
|
||||
check("drop frame is accepted on a 1000/1001 rate", () =>
|
||||
assert.strictEqual(drop.after.flag, "DF", "flag is " + drop.after.flag));
|
||||
|
||||
check("the bext tag keeps the file's own NDF/DF spelling", () => {
|
||||
// This file wrote the long form, so it gets the long form back.
|
||||
assert.strictEqual(drop.after.description, "sSPEED=029.970-DF",
|
||||
"Description is " + JSON.stringify(drop.after.description));
|
||||
});
|
||||
|
||||
/* --- drop frame asked for on a rate that cannot drop frames --- */
|
||||
|
||||
const impossible = await runCase("DF on 25", {
|
||||
description: "aSPEED=030.000-DF",
|
||||
speed: { masterSpeed: "30/1", currentSpeed: "30/1", timecodeRate: "30/1", timecodeFlag: "DF" },
|
||||
}, (doc, win) => {
|
||||
setField(doc, win, "frameRate", "25");
|
||||
setField(doc, win, "frameRateFlag", "DF");
|
||||
});
|
||||
|
||||
check("drop frame is refused on a rate that can't drop frames", () => {
|
||||
assert.strictEqual(impossible.after.rate, "25/1", "rate is " + impossible.after.rate);
|
||||
assert.strictEqual(impossible.after.flag, "NDF",
|
||||
"DF was accepted on 25fps, which is meaningless: flag is " + impossible.after.flag);
|
||||
assert.strictEqual(impossible.after.description, "aSPEED=025.000-ND",
|
||||
"Description is " + JSON.stringify(impossible.after.description));
|
||||
});
|
||||
|
||||
/* --- a file with no flag at all, and almost no iXML slack: the write
|
||||
has to grow the chunk, which means a full rebuild --- */
|
||||
|
||||
const grown = await runCase("no flag, no slack", {
|
||||
description: "aSPEED=025.000-ND",
|
||||
speed: { masterSpeed: null, currentSpeed: null, timecodeRate: "25/1", timecodeFlag: null },
|
||||
slack: 4,
|
||||
}, (doc, win) => setField(doc, win, "frameRate", "30"));
|
||||
|
||||
check("a missing flag is supplied, even when the chunk has to grow", () => {
|
||||
assert.strictEqual(grown.after.rate, "30/1", "rate is " + grown.after.rate);
|
||||
assert.strictEqual(grown.after.flag, "NDF", "flag is " + grown.after.flag);
|
||||
assert.strictEqual(grown.after.master, "30/1", "master missing: " + grown.after.master);
|
||||
assert(grown.before.audio.equals(grown.after.audio),
|
||||
"audio changed during the rebuild path");
|
||||
});
|
||||
|
||||
/* --- pull-down: master and current disagree, so they are not ours --- */
|
||||
|
||||
const pulldown = await runCase("pulldown untouched", {
|
||||
description: "aSPEED=023.976-ND",
|
||||
speed: { masterSpeed: "24/1", currentSpeed: "24000/1001", timecodeRate: "24/1", timecodeFlag: "NDF" },
|
||||
}, (doc, win) => setField(doc, win, "frameRate", "25"));
|
||||
|
||||
check("a pull-down relationship is left alone", () => {
|
||||
assert.strictEqual(pulldown.after.rate, "25/1", "rate is " + pulldown.after.rate);
|
||||
assert.strictEqual(pulldown.after.master, "24/1",
|
||||
"MASTER_SPEED was overwritten: " + pulldown.after.master);
|
||||
assert.strictEqual(pulldown.after.current, "24000/1001",
|
||||
"CURRENT_SPEED was overwritten: " + pulldown.after.current);
|
||||
});
|
||||
|
||||
/* --- saving something unrelated must not touch the SPEED block --- */
|
||||
|
||||
const unrelated = await runCase("scene only", {
|
||||
description: "aSPEED=025.000-ND",
|
||||
speed: { masterSpeed: "25/1", currentSpeed: "25/1", timecodeRate: "25/1", timecodeFlag: "NDF" },
|
||||
}, (doc, win) => setField(doc, win, "scene", "77"));
|
||||
|
||||
check("saving another field leaves the rate as it was", () => {
|
||||
assert(/<SCENE>77<\/SCENE>/.test(unrelated.after.xml), "scene not written");
|
||||
assert.strictEqual(unrelated.after.rate, "25/1",
|
||||
"the rate was rewritten on an unrelated save: " + unrelated.after.rate);
|
||||
assert.strictEqual(unrelated.after.description, "aSPEED=025.000-ND",
|
||||
"Description touched on an unrelated save: " + JSON.stringify(unrelated.after.description));
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
@@ -0,0 +1,385 @@
|
||||
/**
|
||||
* The PDF, checked geometrically rather than by eye.
|
||||
*
|
||||
* With 26 fields selected the old writer gave every column 1/26th of A4
|
||||
* landscape — 31pt — while a header like "Originator Reference" needs 69pt at
|
||||
* that size, so labels printed straight over their neighbours and values
|
||||
* truncated to junk. That's a measurable defect: parse the text-drawing
|
||||
* operators out of the generated PDF, measure each string with the same font
|
||||
* metrics jsPDF used, and assert nothing overlaps the column to its right.
|
||||
*
|
||||
* Run: npm i jsdom jspdf && node build/test-pdf.js
|
||||
*/
|
||||
|
||||
const fs = require("fs");
|
||||
const os = require("os");
|
||||
const path = require("path");
|
||||
const zlib = require("zlib");
|
||||
const assert = require("assert");
|
||||
const { JSDOM, VirtualConsole } = require("jsdom");
|
||||
const { jsPDF } = require("jspdf");
|
||||
const { build } = require("./make-sample.js");
|
||||
|
||||
const INDEX = path.join(__dirname, "..", "index.html");
|
||||
|
||||
const results = [];
|
||||
function check(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
results.push(["PASS", name]);
|
||||
} catch (e) {
|
||||
results.push(["FAIL", name + " — " + e.message]);
|
||||
}
|
||||
}
|
||||
|
||||
function waitFor(fn, label, timeout = 20000) {
|
||||
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, 40);
|
||||
})();
|
||||
});
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Reading a PDF back */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function pageWidthOf(bytes) {
|
||||
const match = /\/MediaBox\s*\[([^\]]*)\]/.exec(bytes.toString("latin1"));
|
||||
assert(match, "no MediaBox in the PDF");
|
||||
return parseFloat(match[1].trim().split(/\s+/)[2]);
|
||||
}
|
||||
|
||||
/** Every text-drawing op on the first page, with its font size and position. */
|
||||
function textOps(bytes) {
|
||||
const raw = bytes.toString("latin1");
|
||||
const streams = [];
|
||||
const re = /stream\r?\n([\s\S]*?)endstream/g;
|
||||
let m;
|
||||
while ((m = re.exec(raw)) !== null) {
|
||||
const body = Buffer.from(m[1], "latin1");
|
||||
try {
|
||||
streams.push(zlib.inflateSync(body).toString("latin1"));
|
||||
} catch (e) {
|
||||
streams.push(m[1]);
|
||||
}
|
||||
}
|
||||
const ops = [];
|
||||
streams.forEach((content) => {
|
||||
const opRe = /BT\s*\/F\d+\s+([\d.]+)\s+Tf[\s\S]*?([\d.]+)\s+([\d.]+)\s+Td\s*\((.*?)\)\s*Tj/g;
|
||||
let op;
|
||||
while ((op = opRe.exec(content)) !== null) {
|
||||
ops.push({
|
||||
size: parseFloat(op[1]),
|
||||
x: parseFloat(op[2]),
|
||||
y: parseFloat(op[3]),
|
||||
text: op[4].replace(/\\([()\\])/g, "$1"),
|
||||
});
|
||||
}
|
||||
});
|
||||
return ops;
|
||||
}
|
||||
|
||||
/** The header row: the widest band of ops sharing a y, below the title. */
|
||||
function headerRow(ops) {
|
||||
const byY = new Map();
|
||||
ops.forEach((op) => {
|
||||
if (op.size > 10) return; // title and its metadata line
|
||||
byY.set(op.y, (byY.get(op.y) || []).concat(op));
|
||||
});
|
||||
let best = [];
|
||||
byY.forEach((row) => {
|
||||
if (row.length > best.length) best = row;
|
||||
});
|
||||
return best.slice().sort((a, b) => a.x - b.x);
|
||||
}
|
||||
|
||||
function measure(text, size, bold) {
|
||||
const probe = new jsPDF({ orientation: "landscape", unit: "pt", format: "a4" });
|
||||
probe.setFont("helvetica", bold ? "bold" : "normal");
|
||||
probe.setFontSize(size);
|
||||
return probe.getTextWidth(text);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Driving a real export */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
async function exportWith(fieldKeys, details) {
|
||||
const pdfBytes = { value: null };
|
||||
const consoleErrors = [];
|
||||
const virtualConsole = new VirtualConsole();
|
||||
virtualConsole.on("jsdomError", (e) => consoleErrors.push(e.message));
|
||||
|
||||
const dom = new JSDOM(fs.readFileSync(INDEX, "utf8"), {
|
||||
runScripts: "dangerously",
|
||||
url: "http://tauri.localhost/",
|
||||
pretendToBeVisual: true,
|
||||
virtualConsole,
|
||||
beforeParse(win) {
|
||||
const ctxStub = new Proxy({}, {
|
||||
get: (target, prop) => (prop === "canvas" ? null : () => {}),
|
||||
set: () => true,
|
||||
});
|
||||
win.HTMLCanvasElement.prototype.getContext = () => ctxStub;
|
||||
win.URL.createObjectURL = () => "blob:stub";
|
||||
win.URL.revokeObjectURL = () => {};
|
||||
},
|
||||
});
|
||||
|
||||
const { window } = dom;
|
||||
await new Promise((r) => window.addEventListener("load", r));
|
||||
const doc = window.document;
|
||||
|
||||
// jsPDF writes via an anchor; intercept at the instance instead.
|
||||
const RealJsPDF = window.jspdf.jsPDF;
|
||||
window.jspdf.jsPDF = function (options) {
|
||||
const instance = new RealJsPDF(options);
|
||||
instance.save = function () {
|
||||
pdfBytes.value = Buffer.from(new Uint8Array(this.output("arraybuffer")));
|
||||
return this;
|
||||
};
|
||||
return instance;
|
||||
};
|
||||
Object.keys(RealJsPDF).forEach((key) => { window.jspdf.jsPDF[key] = RealJsPDF[key]; });
|
||||
|
||||
const files = [
|
||||
["A001_12A_T1.wav", {
|
||||
scene: "12A", take: 1,
|
||||
// A note with a line break in it, and the multi-line coding history
|
||||
// EBU 3285 actually describes: both used to be drawn as extra lines
|
||||
// straight through the rows below.
|
||||
note: "boom a little hot on the wide,\r\nwatch it on the close",
|
||||
codingHistory: "A=PCM,F=48000,W=24,M=stereo,T=833\r\n" +
|
||||
"A=ANALOGUE,M=stereo,T=Schoeps CMIT 5U\r\n",
|
||||
}],
|
||||
["A002_12A_T2.wav", { scene: "12A", take: 2, note: "plane overhead from 00:12" }],
|
||||
["A003_14B_T3.wav", { scene: "14B", take: 3, circled: true, description: "aSPEED=025.000-ND" }],
|
||||
].map(([name, opts]) => {
|
||||
const bytes = build(opts);
|
||||
const view = new window.Uint8Array(bytes.length);
|
||||
view.set(bytes);
|
||||
return new window.File([view], name, { type: "audio/wav" });
|
||||
});
|
||||
|
||||
const input = doc.querySelector("[data-bwfa-files-input]");
|
||||
files.item = (i) => files[i];
|
||||
Object.defineProperty(input, "files", { value: files, configurable: true });
|
||||
input.dispatchEvent(new window.Event("change", { bubbles: true }));
|
||||
await waitFor(() => /Done/i.test(doc.querySelector("[data-bwfa-status]").textContent), "parse");
|
||||
|
||||
// Set the export selection through the picker, as a user would.
|
||||
doc.querySelector("[data-bwfa-export-fields-toggle]")
|
||||
.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
|
||||
Array.from(doc.querySelectorAll("[data-bwfa-export-field]")).forEach((box) => {
|
||||
const wanted = fieldKeys === "all" || fieldKeys.includes(box.getAttribute("data-bwfa-export-field"));
|
||||
if (box.checked !== wanted) {
|
||||
box.checked = wanted;
|
||||
box.dispatchEvent(new window.Event("change", { bubbles: true }));
|
||||
}
|
||||
});
|
||||
doc.querySelector("[data-bwfa-export-close]")
|
||||
.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
|
||||
|
||||
if (details) {
|
||||
// Through the report modal, the way a user gets here: fill in the
|
||||
// production details, leave the format on PDF, press Create Report.
|
||||
doc.querySelector("[data-bwfa-report-open]")
|
||||
.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
|
||||
Object.keys(details).forEach((key) => {
|
||||
const input = doc.querySelector('[data-bwfa-report-field="' + key + '"]');
|
||||
assert(input, "no report field called " + key);
|
||||
input.value = details[key];
|
||||
});
|
||||
doc.querySelector("[data-bwfa-report-format]").value = "pdf";
|
||||
doc.querySelector("[data-bwfa-report-create]")
|
||||
.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
|
||||
} else {
|
||||
doc.querySelector("[data-bwfa-export-pdf]")
|
||||
.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
|
||||
}
|
||||
await waitFor(() => pdfBytes.value, "pdf");
|
||||
assert.strictEqual(consoleErrors.length, 0, consoleErrors.join(" | "));
|
||||
window.close();
|
||||
return pdfBytes.value;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
(async () => {
|
||||
const everything = await exportWith("all");
|
||||
const withDetails = await exportWith(
|
||||
["fileName", "scene", "take", "startTimecode"],
|
||||
{
|
||||
company: "Acme Films",
|
||||
project: "The Long Weekend",
|
||||
director: "R. Okonjo",
|
||||
mixer: "Vincent Rozenberg",
|
||||
phone: "+31 6 1234 5678",
|
||||
email: "vincent@example.com",
|
||||
note: "Day 4, ext. night",
|
||||
}
|
||||
);
|
||||
const compact = await exportWith([
|
||||
"fileName", "scene", "take", "startTimecode", "frameRate", "durationSeconds",
|
||||
]);
|
||||
|
||||
const wideHeader = headerRow(textOps(everything));
|
||||
const compactHeader = headerRow(textOps(compact));
|
||||
|
||||
check("all 26 fields reach the page", () => {
|
||||
assert(wideHeader.length >= 24,
|
||||
"only " + wideHeader.length + " header cells: " + wideHeader.map((o) => o.text).join(" | "));
|
||||
});
|
||||
|
||||
check("no header prints over the column to its right", () => {
|
||||
for (let i = 0; i < wideHeader.length - 1; i++) {
|
||||
const cell = wideHeader[i];
|
||||
const width = measure(cell.text, cell.size, true);
|
||||
assert(cell.x + width <= wideHeader[i + 1].x + 0.5,
|
||||
'"' + cell.text + '" ends at ' + (cell.x + width).toFixed(1) +
|
||||
' but "' + wideHeader[i + 1].text + '" starts at ' + wideHeader[i + 1].x.toFixed(1));
|
||||
}
|
||||
});
|
||||
|
||||
check("the page grew sideways to hold them", () => {
|
||||
const width = pageWidthOf(everything);
|
||||
assert(width > 841.9, "still A4 landscape at " + width.toFixed(0) + "pt");
|
||||
assert(width <= 2400, "page ran away to " + width.toFixed(0) + "pt");
|
||||
});
|
||||
|
||||
check("every column is wide enough for its own header", () => {
|
||||
for (let i = 0; i < wideHeader.length - 1; i++) {
|
||||
const available = wideHeader[i + 1].x - wideHeader[i].x;
|
||||
assert(available > 8, '"' + wideHeader[i].text + '" got only ' + available.toFixed(1) + "pt");
|
||||
}
|
||||
});
|
||||
|
||||
check("nothing anywhere is cut off", () => {
|
||||
// The ellipsis was the writer's own doing — nothing in a BWF file
|
||||
// contains one — so its presence is the defect, wherever it turns up.
|
||||
[["all 26 fields", everything], ["a short selection", compact],
|
||||
["a report with details", withDetails]].forEach(([what, bytes]) => {
|
||||
const cut = textOps(bytes).filter((op) => /\u2026|\u0085/.test(op.text));
|
||||
assert.deepStrictEqual(cut.map((op) => op.text), [],
|
||||
what + " came out clipped: " + cut.map((op) => op.text).join(" | "));
|
||||
});
|
||||
});
|
||||
|
||||
check("no value prints over the column to its right", () => {
|
||||
const ops = textOps(everything);
|
||||
// The first data row: every cell measured as drawn, against where the
|
||||
// next column starts.
|
||||
const row = ops.filter((op) => op.y === ops.filter((o) => /^A001_/.test(o.text))[0].y)
|
||||
.slice().sort((a, b) => a.x - b.x);
|
||||
assert(row.length >= 20, "expected a cell per column, got " + row.length);
|
||||
for (let i = 0; i < row.length - 1; i++) {
|
||||
const width = measure(row[i].text, row[i].size, false);
|
||||
assert(row[i].x + width <= row[i + 1].x + 0.5,
|
||||
'"' + row[i].text + '" runs into "' + row[i + 1].text + '"');
|
||||
}
|
||||
});
|
||||
|
||||
check("a multi-line field is flattened, not spread over the rows below", () => {
|
||||
const ops = textOps(everything);
|
||||
ops.forEach((op) => {
|
||||
assert(!/[\r\n]/.test(op.text), "a drawn string still has a line break: " + op.text);
|
||||
});
|
||||
|
||||
const history = ops.filter((op) => /^A=PCM,F=48000,W=24,M=stereo,T=833/.test(op.text));
|
||||
assert(history.length, "the coding history didn't make it into the report");
|
||||
assert(/A=ANALOGUE/.test(history[0].text),
|
||||
"the second history line was dropped rather than joined: " + history[0].text);
|
||||
assert(/ \u00b7 /.test(history[0].text),
|
||||
"the lines were run together with no separator: " + history[0].text);
|
||||
|
||||
const note = ops.filter((op) => /^boom a little hot/.test(op.text));
|
||||
assert(note.length, "the note didn't make it into the report");
|
||||
assert(/watch it on the close$/.test(note[0].text),
|
||||
"the note lost its second line: " + note[0].text);
|
||||
|
||||
// Every row of the table on one baseline pitch: an extra line drawn by
|
||||
// jsPDF for a \n would show up as a y that isn't on the grid.
|
||||
const rowYs = Array.from(new Set(ops.filter((op) => op.size < 10).map((op) => op.y)))
|
||||
.sort((a, b) => b - a);
|
||||
const gaps = rowYs.slice(1).map((y, i) => rowYs[i] - y).filter((gap) => gap < 30);
|
||||
gaps.forEach((gap) => assert(Math.abs(gap - 18) < 0.5 || Math.abs(gap - 12) < 0.5,
|
||||
"an unexpected baseline gap of " + gap.toFixed(1) + "pt"));
|
||||
});
|
||||
|
||||
check("values are not truncated to nonsense", () => {
|
||||
const ops = textOps(everything);
|
||||
const filenames = ops.filter((op) => /^A00\d_/.test(op.text));
|
||||
assert(filenames.length >= 3, "expected a filename per row, got " + filenames.length);
|
||||
assert(filenames.every((op) => /\.wav$/.test(op.text)),
|
||||
"filenames lost their extension: " + filenames.map((o) => o.text).join(", "));
|
||||
});
|
||||
|
||||
check("a short selection still fits A4 landscape", () => {
|
||||
const width = pageWidthOf(compact);
|
||||
assert(Math.abs(width - 841.89) < 1, "six columns should not resize the page: " + width.toFixed(1));
|
||||
assert(compactHeader.length >= 6, "expected six header cells, got " + compactHeader.length);
|
||||
});
|
||||
|
||||
check("a short selection spans the full page width", () => {
|
||||
// Columns share out the spare room rather than huddling on the left.
|
||||
const last = compactHeader[compactHeader.length - 1];
|
||||
assert(last.x > 600, "the table stops at " + last.x.toFixed(0) + "pt of 842");
|
||||
});
|
||||
|
||||
console.log("");
|
||||
/** Where the table's own header sits, found by one of its column labels —
|
||||
* headerRow() picks the widest band, and with four columns selected the
|
||||
* detail block ties with it. */
|
||||
const tableHeaderY = (ops) => {
|
||||
const scene = ops.filter((op) => op.text === "Scene");
|
||||
assert(scene.length, "no Scene column header in the PDF");
|
||||
return scene[0].y;
|
||||
};
|
||||
|
||||
check("the report carries the production details, above the table", () => {
|
||||
const ops = textOps(withDetails);
|
||||
const text = ops.map((op) => op.text).join(" | ");
|
||||
["Acme Films", "The Long Weekend", "R. Okonjo", "Vincent Rozenberg",
|
||||
"+31 6 1234 5678", "vincent@example.com", "Day 4, ext. night"].forEach((value) => {
|
||||
assert(text.indexOf(value) !== -1, "missing from the report: " + value);
|
||||
});
|
||||
|
||||
// In PDF space y counts up from the bottom, so "above" means larger.
|
||||
const headerY = tableHeaderY(ops);
|
||||
const detail = ops.filter((op) => /Acme Films|R\. Okonjo/.test(op.text));
|
||||
detail.forEach((op) => {
|
||||
assert(op.y > headerY,
|
||||
"a detail line at y=" + op.y + " is below the table header at y=" + headerY);
|
||||
});
|
||||
|
||||
// Two to a line, so seven details cost four lines, not seven.
|
||||
const lines = new Set(detail.concat(ops.filter((op) =>
|
||||
/Production company|Sound mixer|Mixer phone/.test(op.text))).map((op) => op.y));
|
||||
assert(lines.size <= 4, "the details take " + lines.size + " lines");
|
||||
});
|
||||
|
||||
check("the details push the table down rather than printing over it", () => {
|
||||
const plain = tableHeaderY(textOps(compact));
|
||||
const withHeader = tableHeaderY(textOps(withDetails));
|
||||
assert(withHeader < plain,
|
||||
"the table header didn't move: " + withHeader + " vs " + plain);
|
||||
});
|
||||
|
||||
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");
|
||||
process.exit(failed ? 1 : 0);
|
||||
})().catch((e) => {
|
||||
console.error("harness error:", e);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,348 @@
|
||||
/**
|
||||
* Playback maths.
|
||||
*
|
||||
* The device glue is cpal and cannot be compiled here, so what this covers is
|
||||
* everything the glue hands work to: the waveform bucketing, the resampler
|
||||
* that runs when a 44.1k file meets a 48k output, the per-frame channel sum
|
||||
* behind the mute and solo chips, and the arithmetic the elapsed time is read
|
||||
* from. Those are the parts that can be wrong in a way you would hear rather
|
||||
* than a way that fails outright.
|
||||
*
|
||||
* Run: node build/test-play.js
|
||||
*/
|
||||
|
||||
const fs = require("fs");
|
||||
const os = require("os");
|
||||
const path = require("path");
|
||||
const assert = require("assert");
|
||||
const { build } = require("./make-sample.js");
|
||||
const wav = require("./wav-convert.js");
|
||||
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "bwf-play-"));
|
||||
const results = [];
|
||||
|
||||
function check(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
results.push(["PASS", name]);
|
||||
} catch (e) {
|
||||
results.push(["FAIL", name + " — " + e.message]);
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- the waveform ---- */
|
||||
|
||||
const stereo = path.join(root, "stereo.wav");
|
||||
fs.writeFileSync(stereo, build({ bits: 24, channels: 2, seconds: 1, amplitude: 0.5 }));
|
||||
|
||||
check("peaks come back one column at a time, with the file's shape", () => {
|
||||
const p = wav.peaks(stereo, 100);
|
||||
assert.strictEqual(p.min.length, 100);
|
||||
assert.strictEqual(p.max.length, 100);
|
||||
assert.strictEqual(p.channels, 2);
|
||||
assert.strictEqual(p.sampleRate, 48000);
|
||||
assert.strictEqual(p.frames, 48000);
|
||||
assert(Math.abs(p.seconds - 1) < 1e-9, "duration reads " + p.seconds);
|
||||
});
|
||||
|
||||
check("a column spans the file, not just the first samples", () => {
|
||||
// A 440 Hz sine at 0.5: every column of a 1-second file holds whole
|
||||
// cycles, so each one should reach close to the peak in both directions.
|
||||
const p = wav.peaks(stereo, 50);
|
||||
for (let i = 0; i < 50; i++) {
|
||||
assert(p.max[i] > 0.4, "column " + i + " tops out at " + p.max[i]);
|
||||
assert(p.min[i] < -0.4, "column " + i + " bottoms out at " + p.min[i]);
|
||||
}
|
||||
});
|
||||
|
||||
check("the loudest channel is the one that shows", () => {
|
||||
// make-sample puts channel 2 at half of channel 1, so the picture should
|
||||
// follow channel 1 rather than an average of the two.
|
||||
const p = wav.peaks(stereo, 20);
|
||||
const loudest = Math.max.apply(null, Array.from(p.max));
|
||||
assert(loudest > 0.49 && loudest <= 0.5001, "peak reads " + loudest);
|
||||
});
|
||||
|
||||
check("silence draws a line rather than nothing", () => {
|
||||
const quiet = path.join(root, "quiet.wav");
|
||||
fs.writeFileSync(quiet, build({ bits: 24, channels: 1, seconds: 1, amplitude: 0 }));
|
||||
const p = wav.peaks(quiet, 16);
|
||||
assert(Array.from(p.min).every((v) => v === 0), "min is not flat");
|
||||
assert(Array.from(p.max).every((v) => v === 0), "max is not flat");
|
||||
});
|
||||
|
||||
check("more columns than frames still draws a line, not a row of gaps", () => {
|
||||
// Asked for 64 columns of an 8-frame file, the browser build gives every
|
||||
// column a sample by widening any empty one. Leaving the gaps at zero
|
||||
// would draw eight spikes on a flat line instead of a waveform, and the
|
||||
// two builds would disagree about the same file.
|
||||
const tiny = path.join(root, "tiny.wav");
|
||||
fs.writeFileSync(tiny, build({ bits: 16, channels: 1, seconds: 1, sampleRate: 8 }));
|
||||
// 440 Hz sampled at 8 Hz is 55 whole cycles per sample, so make-sample's
|
||||
// sine comes out as eight zeroes. Written by hand instead.
|
||||
const shape = wav.parse(tiny);
|
||||
const bytes = Buffer.from(shape.buf);
|
||||
for (let f = 0; f < 8; f++) {
|
||||
bytes.writeInt16LE((f + 1) * 4000, shape.dataOffset + f * 2);
|
||||
}
|
||||
fs.writeFileSync(tiny, bytes);
|
||||
const p = wav.peaks(tiny, 64);
|
||||
assert.strictEqual(p.min.length, 64);
|
||||
assert.strictEqual(p.frames, 8);
|
||||
const drawn = Array.from(p.max).map((v) => Math.round(v * 32768 / 4000));
|
||||
// Eight frames spread evenly over sixty-four columns: each one drawn
|
||||
// eight times, so the picture is a staircase rather than eight spikes.
|
||||
const want = [];
|
||||
for (let f = 1; f <= 8; f++) {
|
||||
for (let i = 0; i < 8; i++) {
|
||||
want.push(f);
|
||||
}
|
||||
}
|
||||
assert.deepStrictEqual(drawn, want, "columns read " + drawn.join(", "));
|
||||
});
|
||||
|
||||
check("a column boundary lands where the browser build puts it", () => {
|
||||
// Ten frames into four columns is 2, 3, 2, 3 — not 3, 2, 3, 2. One frame
|
||||
// either side of every boundary, which is invisible on a real take and
|
||||
// exactly the sort of thing that quietly diverges between two builds.
|
||||
const ten = path.join(root, "ten.wav");
|
||||
fs.writeFileSync(ten, build({ bits: 16, channels: 1, seconds: 1, sampleRate: 10 }));
|
||||
const source = wav.parse(ten);
|
||||
const raw = Buffer.from(source.buf);
|
||||
// A ramp, so which frames landed in which column can be read off directly.
|
||||
for (let f = 0; f < 10; f++) {
|
||||
raw.writeInt16LE(Math.round((f + 1) * 3000), source.dataOffset + f * 2);
|
||||
}
|
||||
fs.writeFileSync(ten, raw);
|
||||
const p = wav.peaks(ten, 4);
|
||||
const tops = Array.from(p.max).map((v) => Math.round(v * 32768 / 3000));
|
||||
assert.deepStrictEqual(tops, [2, 5, 7, 10], "columns topped out at " + tops.join(", "));
|
||||
});
|
||||
|
||||
/* ---- the resampler ---- */
|
||||
|
||||
/** Runs a whole signal through the resampler in blocks, as the reader does. */
|
||||
function through(samples, channels, ratio, blockFrames) {
|
||||
const state = wav.resampleState();
|
||||
const out = [];
|
||||
for (let at = 0; at < samples.length; at += blockFrames * channels) {
|
||||
const block = samples.slice(at, at + blockFrames * channels);
|
||||
wav.resample(block, channels, ratio, state, out);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
check("matching rates come through untouched, sample for sample", () => {
|
||||
// The reader skips the resampler entirely at 1:1, but the maths has to
|
||||
// agree with that decision or a rate change would sound like a step.
|
||||
const input = new Float32Array(1000);
|
||||
for (let i = 0; i < input.length; i++) {
|
||||
input[i] = Math.sin(i / 10);
|
||||
}
|
||||
const out = through(input, 1, 1, 128);
|
||||
for (let i = 0; i < out.length; i++) {
|
||||
assert(Math.abs(out[i] - input[i]) < 1e-6, "sample " + i + " moved to " + out[i]);
|
||||
}
|
||||
});
|
||||
|
||||
check("a ramp stays a ramp across block boundaries", () => {
|
||||
// The join between two blocks is where a resampler goes wrong: it either
|
||||
// repeats a sample or drops one, and a straight line makes that visible.
|
||||
const frames = 4800;
|
||||
const input = new Float32Array(frames);
|
||||
for (let i = 0; i < frames; i++) {
|
||||
input[i] = i / frames;
|
||||
}
|
||||
const ratio = 44100 / 48000;
|
||||
const out = through(input, 1, ratio, 512);
|
||||
for (let i = 1; i < out.length; i++) {
|
||||
const step = out[i] - out[i - 1];
|
||||
assert(step > 0, "the ramp went backwards at " + i);
|
||||
assert(Math.abs(step - ratio / frames) < 1e-6,
|
||||
"uneven step at " + i + ": " + step);
|
||||
}
|
||||
});
|
||||
|
||||
check("the output length follows the ratio", () => {
|
||||
const frames = 48000;
|
||||
const input = new Float32Array(frames);
|
||||
const out = through(input, 1, 44100 / 48000, 1024);
|
||||
// 44.1k of source at 48k out is about 48000/44100 as many frames, less
|
||||
// the one frame the interpolator always holds back.
|
||||
const want = frames * 48000 / 44100;
|
||||
assert(Math.abs(out.length - want) < 4, "got " + out.length + ", wanted about " + want);
|
||||
});
|
||||
|
||||
check("channels stay in their own lanes", () => {
|
||||
const frames = 600;
|
||||
const input = new Float32Array(frames * 2);
|
||||
for (let i = 0; i < frames; i++) {
|
||||
input[i * 2] = 1;
|
||||
input[i * 2 + 1] = -1;
|
||||
}
|
||||
const out = through(input, 2, 96000 / 48000, 64);
|
||||
for (let i = 0; i < out.length; i += 2) {
|
||||
assert(Math.abs(out[i] - 1) < 1e-6, "left drifted at " + i);
|
||||
assert(Math.abs(out[i + 1] + 1) < 1e-6, "right drifted at " + i);
|
||||
}
|
||||
});
|
||||
|
||||
check("one frame in is held, not emitted as a guess", () => {
|
||||
const state = wav.resampleState();
|
||||
const out = [];
|
||||
wav.resample(new Float32Array([0.5]), 1, 0.5, state, out);
|
||||
assert.strictEqual(out.length, 0, "it invented " + out.length + " frames");
|
||||
assert.strictEqual(state.carry.length, 1, "it didn't keep the frame");
|
||||
});
|
||||
|
||||
/* ---- the channel sum behind the chips ---- */
|
||||
|
||||
check("every channel on sums them all", () => {
|
||||
assert.strictEqual(wav.mixFrame([0.25, 0.25], [1, 1]), 0.5);
|
||||
});
|
||||
|
||||
check("a muted channel contributes nothing", () => {
|
||||
assert.strictEqual(wav.mixFrame([0.5, 0.5], [1, 0]), 0.5);
|
||||
assert.strictEqual(wav.mixFrame([0.5, 0.5], [0, 0]), 0);
|
||||
});
|
||||
|
||||
check("soloing one track is every other gain at zero", () => {
|
||||
assert.strictEqual(wav.mixFrame([0.1, 0.7, 0.2, 0.3], [0, 1, 0, 0]), 0.7);
|
||||
});
|
||||
|
||||
check("a sum past full scale is clamped, not wrapped", () => {
|
||||
// Four hot tracks summed will pass 1.0. Wrapping sounds like the file is
|
||||
// broken; clamping sounds like the monitor is loud, which is the truth.
|
||||
assert.strictEqual(wav.mixFrame([0.5, 0.5, 0.5, 0.5], [1, 1, 1, 1]), 1);
|
||||
assert.strictEqual(wav.mixFrame([-0.5, -0.5, -0.5], [1, 1, 1]), -1);
|
||||
});
|
||||
|
||||
check("a channel with no gain given is treated as on", () => {
|
||||
// The gains array is whatever the frontend last sent; a file with more
|
||||
// channels than that must not fall silent.
|
||||
assert.strictEqual(wav.mixFrame([0.25, 0.25], [1]), 0.5);
|
||||
});
|
||||
|
||||
/* ---- the transport clock ---- */
|
||||
|
||||
check("elapsed counts from where the file was started", () => {
|
||||
// Seeking restarts the stream, so the frames the device has taken are
|
||||
// counted from the seek point rather than from the top of the file.
|
||||
assert.strictEqual(wav.elapsed(48000 * 10, 48000, 48000), 11);
|
||||
assert.strictEqual(wav.elapsed(0, 0, 48000), 0);
|
||||
});
|
||||
|
||||
check("elapsed uses the device's rate, not the file's", () => {
|
||||
// The frames counted are the ones written to the output, so a 44.1k file
|
||||
// on a 48k device still reports real seconds.
|
||||
assert.strictEqual(wav.elapsed(0, 48000, 48000), 1);
|
||||
});
|
||||
|
||||
check("no output means no clock, rather than a divide by zero", () => {
|
||||
assert.strictEqual(wav.elapsed(0, 1000, 0), 0);
|
||||
});
|
||||
|
||||
/* ---- the transform behind the spectrogram ---- */
|
||||
|
||||
check("the fast transform agrees with the slow, obviously-correct one", () => {
|
||||
// The FFT is written by hand because nothing here can be compiled where
|
||||
// it is written, so it is checked against a plain DFT: the one version
|
||||
// nobody can get subtly wrong.
|
||||
const n = 64;
|
||||
const signal = [];
|
||||
for (let i = 0; i < n; i++) {
|
||||
signal.push(Math.sin(i / 3) + 0.3 * Math.cos(i / 7) - 0.1 * i / n);
|
||||
}
|
||||
const slow = wav.dft(signal);
|
||||
const re = Float32Array.from(signal);
|
||||
const im = new Float32Array(n);
|
||||
wav.fft(re, im);
|
||||
for (let k = 0; k < n; k++) {
|
||||
assert(Math.abs(re[k] - slow[k][0]) < 1e-3,
|
||||
"bin " + k + " real: " + re[k] + " vs " + slow[k][0]);
|
||||
assert(Math.abs(im[k] - slow[k][1]) < 1e-3,
|
||||
"bin " + k + " imaginary: " + im[k] + " vs " + slow[k][1]);
|
||||
}
|
||||
});
|
||||
|
||||
check("a pure tone lands in the bin it belongs to", () => {
|
||||
// Eight cycles across 256 samples is bin 8, and nowhere else.
|
||||
const n = 256;
|
||||
const re = new Float32Array(n);
|
||||
const im = new Float32Array(n);
|
||||
for (let i = 0; i < n; i++) {
|
||||
re[i] = Math.sin(2 * Math.PI * 8 * i / n);
|
||||
}
|
||||
wav.fft(re, im);
|
||||
const power = [];
|
||||
for (let k = 0; k < n / 2; k++) {
|
||||
power.push(Math.sqrt(re[k] * re[k] + im[k] * im[k]));
|
||||
}
|
||||
let loudest = 0;
|
||||
for (let k = 1; k < power.length; k++) {
|
||||
if (power[k] > power[loudest]) loudest = k;
|
||||
}
|
||||
assert.strictEqual(loudest, 8, "the tone landed in bin " + loudest);
|
||||
});
|
||||
|
||||
check("a spectrogram is the shape it was asked for", () => {
|
||||
const p = wav.spectrogram(stereo, 40, 256, [1, 1]);
|
||||
assert.strictEqual(p.columns, 40);
|
||||
assert.strictEqual(p.bins, 128);
|
||||
assert.strictEqual(p.cells.length, 40 * 128);
|
||||
assert.strictEqual(p.sampleRate, 48000);
|
||||
});
|
||||
|
||||
check("the 440 Hz test tone shows up where 440 Hz belongs", () => {
|
||||
// 48k over a 1024-point window is 46.9 Hz a bin, so 440 Hz is bin 9.
|
||||
const p = wav.spectrogram(stereo, 8, 1024, [1, 1]);
|
||||
const bins = p.bins;
|
||||
let loudest = 1;
|
||||
for (let bin = 2; bin < bins; bin++) {
|
||||
if (p.cells[4 * bins + bin] > p.cells[4 * bins + loudest]) loudest = bin;
|
||||
}
|
||||
assert(Math.abs(loudest - 9) <= 1, "the tone read as bin " + loudest + ", not 9");
|
||||
});
|
||||
|
||||
check("silence is the floor, not a picture of nothing in particular", () => {
|
||||
const quiet = path.join(root, "hush.wav");
|
||||
fs.writeFileSync(quiet, build({ bits: 24, channels: 1, seconds: 1, amplitude: 0 }));
|
||||
const p = wav.spectrogram(quiet, 10, 256, [1]);
|
||||
assert(Array.from(p.cells).every((v) => v === 0),
|
||||
"silence came back with something in it");
|
||||
});
|
||||
|
||||
check("muting a channel takes it out of the picture", () => {
|
||||
// The point of following the chips: solo the boom and you see the boom,
|
||||
// not the mono sum of everything.
|
||||
const both = wav.spectrogram(stereo, 6, 512, [1, 1]);
|
||||
const muted = wav.spectrogram(stereo, 6, 512, [0, 0]);
|
||||
assert(Array.from(muted.cells).every((v) => v === 0),
|
||||
"muting every channel still drew something");
|
||||
assert(Array.from(both.cells).some((v) => v > 0), "nothing was drawn at all");
|
||||
});
|
||||
|
||||
check("a meter reads each channel's own peak", () => {
|
||||
// What the audio callback raises into its meter cells: the largest
|
||||
// magnitude seen per channel across a block, positive or negative.
|
||||
const peaks = wav.channelPeaks([0.1, -0.9, 0.5, 0.2, -0.3, 0.4], 2);
|
||||
assert.deepStrictEqual(peaks, [0.5, 0.9],
|
||||
"read " + JSON.stringify(peaks) + " — a negative trough counts as level");
|
||||
});
|
||||
|
||||
check("a meter on silence reads nothing, and no channel is left out", () => {
|
||||
assert.deepStrictEqual(wav.channelPeaks(new Array(64).fill(0), 4), [0, 0, 0, 0],
|
||||
"silence metered above zero");
|
||||
// A ragged tail must not spill one channel's samples into another's peak.
|
||||
const ragged = wav.channelPeaks([0.2, 0.4, 0.8], 2);
|
||||
assert.deepStrictEqual(ragged, [0.2, 0.4],
|
||||
"a half frame at the end leaked: " + JSON.stringify(ragged));
|
||||
assert.deepStrictEqual(wav.channelPeaks([], 2), [0, 0], "an empty block should read zero");
|
||||
});
|
||||
|
||||
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);
|
||||
@@ -0,0 +1,246 @@
|
||||
/**
|
||||
* Reopening the last folder on launch.
|
||||
*
|
||||
* Three cases matter: the folder is remembered when you open one, it comes
|
||||
* back by itself next launch, and a folder that has since moved or been
|
||||
* ejected leaves the launch screen up rather than an error — with the stale
|
||||
* path dropped so it can't fail twice.
|
||||
*
|
||||
* Run: npm i jsdom && node build/test-restore.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 INDEX = path.join(__dirname, "..", "mac-app", "dist", "index.html");
|
||||
const STORAGE_KEY = "bwfa_last_folder";
|
||||
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "bwf-restore-"));
|
||||
const card = path.join(root, "Day 14 PR-2");
|
||||
fs.mkdirSync(card, { recursive: true });
|
||||
["A001.wav", "A002.wav"].forEach((name, i) =>
|
||||
fs.writeFileSync(path.join(card, name), build({ scene: "1", take: i + 1 })));
|
||||
|
||||
const results = [];
|
||||
function check(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
results.push(["PASS", name]);
|
||||
} catch (e) {
|
||||
results.push(["FAIL", name + " — " + e.message]);
|
||||
}
|
||||
}
|
||||
|
||||
function waitFor(fn, label, timeout = 10000) {
|
||||
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);
|
||||
})();
|
||||
});
|
||||
}
|
||||
|
||||
/** A window, with the Rust commands mirrored and a note of what was asked. */
|
||||
function launch({ remembered, dialogAnswers = [] } = {}) {
|
||||
const asked = { dialog: 0 };
|
||||
const dialogQueue = dialogAnswers.slice();
|
||||
|
||||
const dom = new JSDOM(fs.readFileSync(INDEX, "utf8"), {
|
||||
runScripts: "dangerously",
|
||||
url: "http://tauri.localhost/",
|
||||
pretendToBeVisual: true,
|
||||
virtualConsole: new VirtualConsole(),
|
||||
beforeParse(win) {
|
||||
win.HTMLCanvasElement.prototype.getContext = () => new Proxy({}, {
|
||||
get: (target, prop) => (prop === "canvas" ? null : () => {}),
|
||||
set: () => true,
|
||||
});
|
||||
win.URL.createObjectURL = () => "blob:stub";
|
||||
win.URL.revokeObjectURL = () => {};
|
||||
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: () => Promise.resolve(() => {}) },
|
||||
core: {
|
||||
invoke(command, payload) {
|
||||
if (command === "plugin:dialog|open") {
|
||||
asked.dialog++;
|
||||
const next = dialogQueue.shift();
|
||||
return Promise.resolve(next === undefined ? null : next);
|
||||
}
|
||||
if (command === "bwf_list_dir") {
|
||||
// Rejects for a folder that isn't there, exactly as the
|
||||
// Rust command does.
|
||||
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)));
|
||||
}
|
||||
return Promise.reject(new Error("unexpected command " + command));
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
return new Promise((resolve) => {
|
||||
dom.window.addEventListener("load", () => resolve({ dom, window: dom.window, asked }));
|
||||
});
|
||||
}
|
||||
|
||||
const rowsIn = (doc) => Array.from(doc.querySelectorAll("[data-bwfa-table-body] tr"));
|
||||
|
||||
(async () => {
|
||||
/* --- opening a folder is remembered --- */
|
||||
|
||||
const first = await launch({ dialogAnswers: [card] });
|
||||
first.window.document.querySelector("[data-bwfa-edit-folder]")
|
||||
.dispatchEvent(new first.window.MouseEvent("click", { bubbles: true }));
|
||||
await waitFor(() => rowsIn(first.window.document).length === 2, "first open");
|
||||
|
||||
check("opening a folder remembers it", () =>
|
||||
assert.strictEqual(first.window.localStorage.getItem(STORAGE_KEY), card,
|
||||
"stored: " + first.window.localStorage.getItem(STORAGE_KEY)));
|
||||
first.window.close();
|
||||
|
||||
/* --- next launch reopens it, with no dialog --- */
|
||||
|
||||
const again = await launch({ remembered: card });
|
||||
await waitFor(() => rowsIn(again.window.document).length === 2, "reopen on launch");
|
||||
const doc = again.window.document;
|
||||
|
||||
check("the folder comes back on the next launch", () => {
|
||||
assert.strictEqual(rowsIn(doc).length, 2, "got " + rowsIn(doc).length + " rows");
|
||||
assert.strictEqual(again.asked.dialog, 0, "it opened a file dialog instead of restoring");
|
||||
assert.strictEqual(doc.querySelector("[data-bwfa-current-folder]").textContent, "Day 14 PR-2");
|
||||
assert(doc.querySelector("[data-bwfa-app]").classList.contains("has-folder"),
|
||||
"still showing the launch screen");
|
||||
});
|
||||
|
||||
check("it comes back read-write, not read-only", () =>
|
||||
assert.strictEqual(doc.querySelector("[data-bwfa-editing-note]").hidden, false,
|
||||
"restored without edit mode, so saving would be impossible"));
|
||||
again.window.close();
|
||||
|
||||
/* --- a folder that has gone leaves the launch screen up --- */
|
||||
|
||||
const missing = path.join(root, "Ejected Card");
|
||||
const gone = await launch({ remembered: missing });
|
||||
// Nothing to wait for, so give the check a beat to have happened.
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
const goneDoc = gone.window.document;
|
||||
|
||||
check("a folder that isn't there leaves the launch screen alone", () => {
|
||||
assert.strictEqual(rowsIn(goneDoc).length, 0, "it loaded something");
|
||||
assert.strictEqual(goneDoc.querySelector("[data-bwfa-app]").classList.contains("has-folder"), false,
|
||||
"the app left its launch state for a folder that doesn't exist");
|
||||
assert.strictEqual(gone.asked.dialog, 0, "it prompted on launch, which is not the ask");
|
||||
assert.strictEqual(goneDoc.querySelector("[data-bwfa-status]").textContent.trim(), "",
|
||||
"an error was left on screen: " + goneDoc.querySelector("[data-bwfa-status]").textContent);
|
||||
});
|
||||
|
||||
check("the stale path is dropped so it can't fail twice", () =>
|
||||
assert.strictEqual(gone.window.localStorage.getItem(STORAGE_KEY), null,
|
||||
"still remembered: " + gone.window.localStorage.getItem(STORAGE_KEY)));
|
||||
gone.window.close();
|
||||
|
||||
/* --- eject puts the app back to a first run --- */
|
||||
|
||||
const open = await launch({ dialogAnswers: [card] });
|
||||
const openDoc = open.window.document;
|
||||
openDoc.querySelector("[data-bwfa-edit-folder]")
|
||||
.dispatchEvent(new open.window.MouseEvent("click", { bubbles: true }));
|
||||
await waitFor(() => rowsIn(openDoc).length === 2, "open before reset");
|
||||
|
||||
check("eject only offers itself once a folder is open", () => {
|
||||
const reset = openDoc.querySelector("[data-bwfa-reset]");
|
||||
assert(reset, "no reset control in the markup");
|
||||
assert.notStrictEqual(open.window.getComputedStyle(reset).display, "none",
|
||||
"reset is hidden while a folder is open");
|
||||
});
|
||||
|
||||
openDoc.querySelector("[data-bwfa-reset]")
|
||||
.dispatchEvent(new open.window.MouseEvent("click", { bubbles: true }));
|
||||
await waitFor(() => rowsIn(openDoc).length === 0, "reset to clear the table", 5000);
|
||||
|
||||
check("eject returns the app to its empty state", () => {
|
||||
const app = openDoc.querySelector("[data-bwfa-app]");
|
||||
assert.strictEqual(app.classList.contains("has-folder"), false, "still in folder mode");
|
||||
assert.strictEqual(openDoc.querySelector("[data-bwfa-results]").hidden, true,
|
||||
"the results panel survived");
|
||||
assert.strictEqual(openDoc.querySelector("[data-bwfa-player]").hidden, true,
|
||||
"the player survived");
|
||||
assert.strictEqual(openDoc.querySelector("[data-bwfa-current-folder]").hidden, true,
|
||||
"the folder name is still shown");
|
||||
assert(/open folder/i.test(openDoc.querySelector("[data-bwfa-edit-folder]").textContent),
|
||||
"the button still says Change Folder: " +
|
||||
openDoc.querySelector("[data-bwfa-edit-folder]").textContent);
|
||||
assert.strictEqual(openDoc.querySelector("[data-bwfa-status]").textContent.trim(), "",
|
||||
"a status message was left behind");
|
||||
assert.strictEqual(open.window.getComputedStyle(
|
||||
openDoc.querySelector("[data-bwfa-reset]")).display, "none",
|
||||
"reset is still offered with nothing open");
|
||||
});
|
||||
|
||||
check("eject also forgets what to reopen", () =>
|
||||
assert.strictEqual(open.window.localStorage.getItem(STORAGE_KEY), null,
|
||||
"next launch would reopen " + open.window.localStorage.getItem(STORAGE_KEY)));
|
||||
open.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);
|
||||
});
|
||||
+2217
File diff suppressed because it is too large
Load Diff
+626
@@ -0,0 +1,626 @@
|
||||
/**
|
||||
* Loads the built index.html in jsdom exactly as a browser would (scripts
|
||||
* executed in the page's own realm), feeds it synthetic BWF files through
|
||||
* the real file input, and asserts the table renders the right metadata.
|
||||
*
|
||||
* Run: npm i jsdom && node build/test.js
|
||||
*/
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const assert = require("assert");
|
||||
const { JSDOM, VirtualConsole } = require("jsdom");
|
||||
const { build } = require("./make-sample.js");
|
||||
|
||||
const INDEX = path.join(__dirname, "..", "index.html");
|
||||
const css = fs.readFileSync(INDEX, "utf8");
|
||||
|
||||
const createdBlobs = [];
|
||||
const consoleErrors = [];
|
||||
const virtualConsole = new VirtualConsole();
|
||||
virtualConsole.on("jsdomError", (e) => consoleErrors.push("jsdomError: " + e.message));
|
||||
virtualConsole.on("error", (...a) => consoleErrors.push("console.error: " + a.join(" ")));
|
||||
|
||||
const dom = new JSDOM(fs.readFileSync(INDEX, "utf8"), {
|
||||
runScripts: "dangerously",
|
||||
url: "file:///tmp/bwftest/index.html",
|
||||
pretendToBeVisual: true,
|
||||
virtualConsole,
|
||||
beforeParse(win) {
|
||||
// jsdom ships no canvas backend and no URL.createObjectURL; stub both
|
||||
// before any page script runs. The waveform paths are draw-only, and
|
||||
// blob URLs are only ever handed to a download anchor.
|
||||
const ctxStub = new Proxy({}, {
|
||||
get: (target, prop) => {
|
||||
if (prop === "canvas") return null;
|
||||
if (prop === "createLinearGradient") return () => ({ addColorStop() {} });
|
||||
if (prop === "getImageData") return () => ({ data: new Uint8ClampedArray(4) });
|
||||
if (typeof target[prop] === "undefined") return () => {};
|
||||
return target[prop];
|
||||
},
|
||||
set: () => true,
|
||||
});
|
||||
win.HTMLCanvasElement.prototype.getContext = () => ctxStub;
|
||||
win.URL.createObjectURL = (blob) => {
|
||||
createdBlobs.push(blob);
|
||||
return "blob:stub-" + createdBlobs.length;
|
||||
};
|
||||
win.URL.revokeObjectURL = () => {};
|
||||
},
|
||||
});
|
||||
|
||||
const { window } = dom;
|
||||
|
||||
function fileFrom(name, buf) {
|
||||
const f = new window.File([new window.Uint8Array(buf)], name, { type: "audio/wav" });
|
||||
Object.defineProperty(f, "webkitRelativePath", { value: "Day14/" + name });
|
||||
return f;
|
||||
}
|
||||
|
||||
function waitFor(fn, label, timeout = 20000) {
|
||||
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, 50);
|
||||
})();
|
||||
});
|
||||
}
|
||||
|
||||
const results = [];
|
||||
function check(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
results.push(["PASS", name]);
|
||||
} catch (e) {
|
||||
results.push(["FAIL", name + " — " + e.message]);
|
||||
}
|
||||
}
|
||||
|
||||
(async () => {
|
||||
await new Promise((r) => window.addEventListener("load", r));
|
||||
|
||||
const doc = window.document;
|
||||
const container = doc.querySelector("[data-bwfa-app]");
|
||||
assert(container, "app container missing");
|
||||
|
||||
check("bwfaL10n is defined", () => assert.strictEqual(typeof window.bwfaL10n, "object"));
|
||||
check("parser exposed on window.BWFA", () => assert(window.BWFA && window.BWFA.RiffParser));
|
||||
check("jsPDF bundled and loaded", () => assert(window.jspdf && window.jspdf.jsPDF));
|
||||
check("dropzone got its localized text", () =>
|
||||
assert(doc.querySelector("[data-bwfa-dropzone-text]").textContent.trim().length > 0));
|
||||
check("table head rendered on init", () =>
|
||||
assert(doc.querySelector("[data-bwfa-table-head] th")));
|
||||
|
||||
check("detail metadata stays two pairs to a row", () => {
|
||||
// The plugin's own rule (auto-fill, minmax(180px, 1fr)) lands on three
|
||||
// columns at this modal width, which splits the dt/dd pairs across
|
||||
// rows. The override pins it to label, value, label, value.
|
||||
const probe = doc.createElement("dl");
|
||||
probe.className = "bwfa-meta-grid";
|
||||
container.appendChild(probe);
|
||||
const style = window.getComputedStyle(probe);
|
||||
assert.strictEqual((style.gridTemplateColumns.match(/minmax/g) || []).length, 4,
|
||||
"expected 4 tracks, got: " + style.gridTemplateColumns);
|
||||
probe.remove();
|
||||
});
|
||||
|
||||
// Feed three files through the real input, as a folder selection would.
|
||||
const files = [
|
||||
fileFrom("A001_12A_T1.wav", build({ scene: "12A", take: 1 })),
|
||||
fileFrom("A002_12A_T2.wav", build({ scene: "12A", take: 2, note: "plane overhead" })),
|
||||
fileFrom("A003_14B_T3.wav", build({ scene: "14B", take: 3, circled: true, tcSamples: 11 * 3600 * 48000 })),
|
||||
];
|
||||
const notWav = fileFrom("notes.txt", Buffer.from("ignore me"));
|
||||
|
||||
// jsdom has no webkitdirectory support, so the app correctly removes the
|
||||
// folder input and leaves the plain multi-file one — use that.
|
||||
const input = doc.querySelector("[data-bwfa-files-input]");
|
||||
const list = files.concat([notWav]);
|
||||
list.item = (i) => list[i];
|
||||
Object.defineProperty(input, "files", { value: list, configurable: true });
|
||||
input.dispatchEvent(new window.Event("change", { bubbles: true }));
|
||||
|
||||
const status = doc.querySelector("[data-bwfa-status]");
|
||||
await waitFor(() => /Done/i.test(status.textContent), "parsing to finish");
|
||||
|
||||
const rowsOf = () => Array.from(doc.querySelectorAll("[data-bwfa-table-body] tr"));
|
||||
|
||||
check("three files parsed, non-wav skipped", () => {
|
||||
assert.strictEqual(rowsOf().length, 3, "got " + rowsOf().length + " rows");
|
||||
assert(/1 skipped/.test(status.textContent), status.textContent);
|
||||
});
|
||||
|
||||
const headers = Array.from(doc.querySelectorAll("[data-bwfa-table-head] th")).map((th) =>
|
||||
th.textContent.replace(/[▲▼\s]+$/, "").trim());
|
||||
const cellsFor = (rowIndex) => Array.from(rowsOf()[rowIndex].querySelectorAll("td")).map((td) => td.textContent.trim());
|
||||
const col = (rowIndex, header) => cellsFor(rowIndex)[headers.indexOf(header)];
|
||||
|
||||
check("the table reports the frame rate", () => {
|
||||
assert(headers.includes("FPS"), "no FPS column: " + headers.join(" | "));
|
||||
assert(/25/.test(col(0, "FPS")), "FPS cell is empty: " + col(0, "FPS"));
|
||||
});
|
||||
|
||||
check("the report modal offers every field, all on", () => {
|
||||
container.querySelector("[data-bwfa-report-open]")
|
||||
.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
|
||||
const boxes = Array.from(doc.querySelectorAll("[data-bwfa-export-field]"));
|
||||
assert(boxes.length >= 20, "only " + boxes.length + " fields offered");
|
||||
assert(boxes.every((b) => b.checked), "some fields start unticked");
|
||||
});
|
||||
|
||||
check("the columns cog sits in the table's first header cell", () => {
|
||||
const cells = Array.from(doc.querySelectorAll("[data-bwfa-table-head] th"));
|
||||
const cog = cells[0].querySelector("[data-bwfa-columns-open]");
|
||||
assert(cog, "no cog in the first header cell: " + cells[0].innerHTML);
|
||||
assert.strictEqual(cog.textContent.trim(), "", "it should be a glyph, not a word");
|
||||
assert(cog.getAttribute("aria-label"), "an icon-only button needs a label");
|
||||
// A smaller sibling of the eject button in the folder bar: round,
|
||||
// hairline border, glyph only.
|
||||
const style = window.getComputedStyle(cog);
|
||||
assert.strictEqual(style.borderRadius, "50%", "it isn't round: " + style.borderRadius);
|
||||
assert.strictEqual(style.width, style.height, "it isn't square, so it can't be a circle");
|
||||
assert.strictEqual(style.width, "22px", "it should be smaller than the 30px eject: " + style.width);
|
||||
// The border is declared with a custom property for its colour, which
|
||||
// jsdom won't substitute into a computed shorthand — so read the rule.
|
||||
const rules = Array.from(doc.querySelectorAll("style"))
|
||||
.map((el) => el.textContent).join("\n");
|
||||
const rule = rules.slice(rules.indexOf(".bwfa-columns-cog {"));
|
||||
assert(/border: 1px solid/.test(rule.slice(0, rule.indexOf("}"))),
|
||||
"no hairline border on the cog");
|
||||
// The dropdown it replaced is gone from view.
|
||||
assert.strictEqual(
|
||||
window.getComputedStyle(container.querySelector("[data-bwfa-columns-dropdown]")).display,
|
||||
"none", "the Columns dropdown is still in the toolbar");
|
||||
});
|
||||
|
||||
check("the cog opens a modal listing every column", () => {
|
||||
const modal = doc.querySelector("[data-bwfa-columns-modal]");
|
||||
assert.strictEqual(modal.hidden, true, "it was open before anything was clicked");
|
||||
doc.querySelector("[data-bwfa-columns-open]")
|
||||
.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
|
||||
assert.strictEqual(modal.hidden, false, "the cog didn't open it");
|
||||
assert(modal.classList.contains("open"), "the framework needs .open to show a modal");
|
||||
const boxes = Array.from(modal.querySelectorAll("[data-bwfa-columns-menu] input[type=checkbox]"));
|
||||
assert(boxes.length >= 15, "only " + boxes.length + " columns offered");
|
||||
});
|
||||
|
||||
check("unticking a column takes it out of the table, then Done closes up", () => {
|
||||
const modal = doc.querySelector("[data-bwfa-columns-modal]");
|
||||
const labels = Array.from(modal.querySelectorAll("[data-bwfa-columns-menu] label"));
|
||||
const tape = labels.filter((row) => /^Tape\/Reel$/.test(row.textContent.trim()))[0];
|
||||
assert(tape, "no Tape column in the list: " + labels.map((l) => l.textContent.trim()).join(", "));
|
||||
const box = tape.querySelector("input");
|
||||
box.checked = false;
|
||||
box.dispatchEvent(new window.Event("change", { bubbles: true }));
|
||||
|
||||
const headers = Array.from(doc.querySelectorAll("[data-bwfa-table-head] th"))
|
||||
.map((th) => th.textContent.replace(/[▲▼\s]+$/, "").trim());
|
||||
assert(!headers.includes("Tape/Reel"), "Tape is still a column: " + headers.join(" | "));
|
||||
// And the cog survived the redraw, since the head is rebuilt each time.
|
||||
assert(doc.querySelector("[data-bwfa-table-head] th [data-bwfa-columns-open]"),
|
||||
"the cog went missing when the table head was redrawn");
|
||||
|
||||
box.checked = true;
|
||||
box.dispatchEvent(new window.Event("change", { bubbles: true }));
|
||||
doc.querySelector("[data-bwfa-columns-close]")
|
||||
.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
|
||||
assert.strictEqual(modal.hidden, true, "Done didn't close it");
|
||||
});
|
||||
|
||||
check("one button in the toolbar, not three", () => {
|
||||
const visible = (selector) => {
|
||||
const el = container.querySelector(selector);
|
||||
assert(el, "no " + selector);
|
||||
return window.getComputedStyle(el).display !== "none";
|
||||
};
|
||||
assert(visible("[data-bwfa-report-open]"), "the Sound Report button is hidden");
|
||||
// And it looks like the rest of the row rather than announcing itself.
|
||||
const row = Array.from(container.querySelectorAll(".bwfa-export-actions .btn"))
|
||||
.filter((el) => window.getComputedStyle(el).display !== "none");
|
||||
const looks = new Set(row.map((el) => el.className.replace(/\s+/g, " ").trim()));
|
||||
assert.deepStrictEqual(Array.from(looks), ["btn btn-outline"],
|
||||
"the toolbar mixes button styles: " + Array.from(looks).join(" / "));
|
||||
// The originals stay in the DOM — the analyser wires the export
|
||||
// pipeline to them and the modal drives them — but out of sight.
|
||||
["[data-bwfa-export-csv]", "[data-bwfa-export-pdf]", "[data-bwfa-export-fields-toggle]"]
|
||||
.forEach((selector) => assert(!visible(selector), selector + " is still on show"));
|
||||
});
|
||||
|
||||
check("rates read in kHz everywhere, pull rates included", () => {
|
||||
// 47952 is the 0.1% pull, a rate in its own right — one decimal used to
|
||||
// round it to "48.0 kHz", which is a different rate.
|
||||
assert.strictEqual(window.BWFA.RiffParser.formatSampleRate(48000), "48 kHz");
|
||||
assert.strictEqual(window.BWFA.RiffParser.formatSampleRate(44100), "44.1 kHz");
|
||||
assert.strictEqual(window.BWFA.RiffParser.formatSampleRate(47952), "47.952 kHz");
|
||||
assert.strictEqual(window.BWFA.RiffParser.formatSampleRate(176400), "176.4 kHz");
|
||||
|
||||
// The table, and the file's own detail view.
|
||||
assert(/kHz/.test(col(0, "Sample Rate")), "table cell: " + col(0, "Sample Rate"));
|
||||
// The table and the detail view. The report's field list is exempt: it
|
||||
// names CSV columns, and that column really is in hertz.
|
||||
const onScreen = [doc.querySelector("[data-bwfa-table]"),
|
||||
doc.querySelector("[data-bwfa-modal-body]")]
|
||||
.map((el) => (el && el.textContent) || "").join(" ");
|
||||
assert(!/\bHz\b/.test(onScreen.replace(/kHz/g, "")),
|
||||
"something on screen still counts in hertz");
|
||||
});
|
||||
|
||||
check("and the sample-rate menus read the same way", () => {
|
||||
container.querySelector("[data-bwfa-bulk-edit-toggle]")
|
||||
.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
|
||||
const rows = Array.from(doc.querySelectorAll(".bwfa-bulk-edit-row"));
|
||||
const rateRow = rows.find((row) => /tc sample rate/i.test(row.textContent));
|
||||
assert(rateRow, "no TC Sample Rate field");
|
||||
const labels = Array.from(rateRow.querySelectorAll("option")).map((o) => o.textContent);
|
||||
assert(labels.includes("48 kHz"), "options read: " + labels.join(" | "));
|
||||
assert(labels.includes("47.952 kHz"), "the pull rate is missing: " + labels.join(" | "));
|
||||
assert(!labels.some((label) => / Hz$/.test(label)), "still in hertz: " + labels.join(" | "));
|
||||
doc.querySelector("[data-bwfa-bulk-edit-cancel]")
|
||||
.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
check("the two free-text fields sit side by side, the same size", () => {
|
||||
// They used to be laid out as leftovers: Note in whatever cell was
|
||||
// going spare, Description alone on a row below it.
|
||||
container.querySelector("[data-bwfa-bulk-edit-toggle]")
|
||||
.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
|
||||
const areas = Array.from(doc.querySelectorAll("[data-bwfa-bulk-edit-fields] textarea"));
|
||||
assert.strictEqual(areas.length, 2, "expected Note and Description, got " + areas.length);
|
||||
|
||||
const rows = areas.map((area) => area.closest(".bwfa-bulk-edit-row"));
|
||||
const styles = rows.map((row) => window.getComputedStyle(row));
|
||||
styles.forEach((style, i) => assert.strictEqual(style.gridColumn, "span 2",
|
||||
"textarea " + i + " isn't half a row: " + style.gridColumn));
|
||||
assert.strictEqual(window.getComputedStyle(rows[0]).gridColumnStart, "1",
|
||||
"the first of the pair doesn't start a fresh row, so they won't line up");
|
||||
|
||||
const heights = areas.map((area) => window.getComputedStyle(area).height);
|
||||
assert.strictEqual(new Set(heights).size, 1, "different heights: " + heights.join(" / "));
|
||||
|
||||
doc.querySelector("[data-bwfa-bulk-edit-cancel]")
|
||||
.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
check("the audio escape hatch is app-only", () => {
|
||||
// In a browser tab a reload loses the folder along with the audio, so
|
||||
// the button that reloads is not offered there.
|
||||
const action = container.querySelector("[data-bwfa-audio-reset-row]");
|
||||
assert(action, "the markup should be shared, only hidden");
|
||||
assert.strictEqual(window.getComputedStyle(action).display, "none",
|
||||
"the reload button is on show in the browser build");
|
||||
});
|
||||
|
||||
check("the report asks who it's for, and in what format", () => {
|
||||
["company", "project", "director", "mixer", "phone", "email", "note"].forEach((key) => {
|
||||
const input = container.querySelector('[data-bwfa-report-field="' + key + '"]');
|
||||
assert(input, "no field for " + key);
|
||||
assert.strictEqual(input.value, "", key + " starts filled in: " + input.value);
|
||||
});
|
||||
const format = container.querySelector("[data-bwfa-report-format]");
|
||||
assert(format, "no output format control");
|
||||
assert.deepStrictEqual(Array.from(format.options).map((o) => o.value), ["pdf", "csv"]);
|
||||
assert.strictEqual(format.value, "pdf", "the default should be the report, not the spreadsheet");
|
||||
});
|
||||
|
||||
container.querySelector("[data-bwfa-export-close]")
|
||||
.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
|
||||
|
||||
check("scene/take read from iXML", () => {
|
||||
const scenes = rowsOf().map((_, i) => col(i, "Scene"));
|
||||
const takes = rowsOf().map((_, i) => col(i, "Take"));
|
||||
assert.deepStrictEqual(scenes.slice().sort(), ["12A", "12A", "14B"], scenes.join(","));
|
||||
assert.deepStrictEqual(takes.slice().sort(), ["1", "2", "3"], takes.join(","));
|
||||
});
|
||||
|
||||
check("start timecode reconstructed at 25fps", () => {
|
||||
const tcs = rowsOf().map((_, i) => col(i, "Start TC"));
|
||||
assert(tcs.includes("10:00:00:00"), "expected 10:00:00:00 among " + tcs.join(" | "));
|
||||
assert(tcs.includes("11:00:00:00"), "expected 11:00:00:00 among " + tcs.join(" | "));
|
||||
});
|
||||
|
||||
check("format fields read from fmt chunk", () => {
|
||||
assert(/48/.test(col(0, "Sample Rate")), col(0, "Sample Rate"));
|
||||
assert.strictEqual(col(0, "Bit Depth"), "24-bit");
|
||||
assert.strictEqual(col(0, "Channels"), "2");
|
||||
});
|
||||
|
||||
check("circled take flagged", () => {
|
||||
const circled = rowsOf().map((_, i) => col(i, "Circled"));
|
||||
assert.strictEqual(circled.filter((v) => /yes/i.test(v)).length, 1, circled.join(","));
|
||||
});
|
||||
|
||||
check("duration computed from data chunk", () => {
|
||||
const d = col(0, "Duration");
|
||||
assert(/00:00:01/.test(d), d);
|
||||
});
|
||||
|
||||
// Search filter
|
||||
const search = doc.querySelector("[data-bwfa-search]");
|
||||
search.value = "14B";
|
||||
search.dispatchEvent(new window.Event("input", { bubbles: true }));
|
||||
await waitFor(() => rowsOf().length === 1, "filter to apply", 5000);
|
||||
check("search filters rows", () => assert.strictEqual(rowsOf().length, 1));
|
||||
search.value = "";
|
||||
search.dispatchEvent(new window.Event("input", { bubbles: true }));
|
||||
await waitFor(() => rowsOf().length === 3, "filter to clear", 5000);
|
||||
|
||||
// Sorting
|
||||
const sceneHeader = Array.from(doc.querySelectorAll("[data-bwfa-table-head] th"))
|
||||
.find((th) => /Scene/.test(th.textContent));
|
||||
const clickable = sceneHeader.querySelector("button") || sceneHeader;
|
||||
clickable.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
|
||||
check("sorting by a column works", () => {
|
||||
const scenes = rowsOf().map((_, i) => col(i, "Scene"));
|
||||
const sorted = scenes.slice().sort();
|
||||
const reversed = sorted.slice().reverse();
|
||||
assert(
|
||||
JSON.stringify(scenes) === JSON.stringify(sorted) ||
|
||||
JSON.stringify(scenes) === JSON.stringify(reversed),
|
||||
scenes.join(",")
|
||||
);
|
||||
});
|
||||
|
||||
// Details modal (Track Names live here and in the CSV, not in the table).
|
||||
const detailsBtn = Array.from(rowsOf()[0].querySelectorAll("button"))
|
||||
.find((b) => /^(details|edit)$/i.test(b.textContent.trim()));
|
||||
assert(detailsBtn, "no Edit button in the row");
|
||||
detailsBtn.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
|
||||
const modal = doc.querySelector("[data-bwfa-modal]");
|
||||
check("details modal opens with metadata sections", () => {
|
||||
assert.strictEqual(modal.hidden, false, "modal still hidden");
|
||||
const text = doc.querySelector("[data-bwfa-modal-body]").textContent;
|
||||
assert(/iXML/i.test(text), "no iXML section");
|
||||
assert(/Broadcast Extension|bext/i.test(text), "no bext section");
|
||||
assert(/Sound Devices 833/.test(text), "originator missing");
|
||||
assert(/slate/.test(text), "cue label missing");
|
||||
assert(/BWF Analyser test harness/.test(text), "RIFF INFO missing");
|
||||
// Track names are fields now, not labels, so they live in values
|
||||
// rather than in the modal's text.
|
||||
const trackNames = Array.from(modal.querySelectorAll("[data-bwfa-track-name]"))
|
||||
.map((input) => input.value);
|
||||
assert(trackNames.indexOf("Boom") !== -1 && trackNames.indexOf("Lav Anna") !== -1,
|
||||
"iXML track names missing: " + trackNames.join(", "));
|
||||
});
|
||||
doc.querySelector("[data-bwfa-modal-close]").dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
|
||||
|
||||
// CSV export — the blob is captured by the stubbed createObjectURL rather
|
||||
// than actually downloaded.
|
||||
window.HTMLAnchorElement.prototype.click = function () {};
|
||||
const isCsv = (b) => b && b.type && b.type.indexOf("csv") !== -1;
|
||||
doc.querySelector("[data-bwfa-export-csv]").dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
|
||||
await waitFor(() => createdBlobs.filter(isCsv)[0], "csv blob", 5000);
|
||||
const csvBlob = createdBlobs.filter(isCsv)[0];
|
||||
// Blob.text() decodes as UTF-8, which swallows the BOM — check the raw
|
||||
// bytes for it separately.
|
||||
const csvBytes = new Uint8Array(await csvBlob.arrayBuffer());
|
||||
const csvText = await csvBlob.text();
|
||||
check("but the spreadsheet still counts in hertz, as it should", () => {
|
||||
// kHz is how a rate is read on screen and in a printed report. A CSV of
|
||||
// metadata is machine-read, and the convention there — Wave Agent, the
|
||||
// recorders' own exports, the file itself — is the integer in hertz.
|
||||
const head = csvText.replace(/^\ufeff/, "").split("\r\n")[0].split(",");
|
||||
const row = csvText.replace(/^\ufeff/, "").split("\r\n")[1].split(",");
|
||||
const at = head.indexOf("Sample Rate (Hz)");
|
||||
assert(at !== -1, "the column was relabelled: " + head.join(" | "));
|
||||
assert.strictEqual(row[at], "48000", "the cell says " + row[at]);
|
||||
});
|
||||
|
||||
check("CSV export contains header and all rows", () => {
|
||||
assert.deepStrictEqual(Array.from(csvBytes.slice(0, 3)), [0xef, 0xbb, 0xbf], "missing UTF-8 BOM");
|
||||
assert(/\r\n/.test(csvText), "expected CRLF line endings (RFC 4180)");
|
||||
assert(/Scene/.test(csvText.split("\n")[0]), "no header row");
|
||||
["A001_12A_T1.wav", "A002_12A_T2.wav", "A003_14B_T3.wav"].forEach((n) =>
|
||||
assert(csvText.indexOf(n) !== -1, "missing " + n));
|
||||
assert(/12A/.test(csvText) && /14B/.test(csvText), "scenes missing");
|
||||
assert(/Boom/.test(csvText) && /Lav Anna/.test(csvText), "track names missing from CSV");
|
||||
assert(/Track Names/.test(csvText.split("\n")[0]), "Track Names column missing");
|
||||
});
|
||||
// The same CSV, but asked for through the report modal with the production
|
||||
// details filled in.
|
||||
const details = {
|
||||
company: "Acme Films",
|
||||
project: "The Long Weekend",
|
||||
director: "R. Okonjo",
|
||||
mixer: "Vincent Rozenberg",
|
||||
phone: "+31 6 1234 5678",
|
||||
email: "vincent@example.com",
|
||||
note: "Day 4, ext. night",
|
||||
};
|
||||
createdBlobs.length = 0;
|
||||
doc.querySelector("[data-bwfa-report-open]").dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
|
||||
Object.keys(details).forEach((key) => {
|
||||
doc.querySelector('[data-bwfa-report-field="' + key + '"]').value = details[key];
|
||||
});
|
||||
doc.querySelector("[data-bwfa-report-format]").value = "csv";
|
||||
doc.querySelector("[data-bwfa-report-create]").dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
|
||||
await waitFor(() => createdBlobs.filter(isCsv)[0], "report csv blob", 5000);
|
||||
const reportCsv = await createdBlobs.filter(isCsv)[0].text();
|
||||
|
||||
check("Create Report writes the format that was chosen", () => {
|
||||
assert.strictEqual(createdBlobs.filter(isCsv).length, 1,
|
||||
"expected one CSV, got " + createdBlobs.filter(isCsv).length);
|
||||
assert.strictEqual(doc.querySelector("[data-bwfa-export-modal]").hidden, true,
|
||||
"the modal stayed open over the save panel");
|
||||
});
|
||||
|
||||
check("the CSV leads with the production details, then a blank line", () => {
|
||||
const lines = reportCsv.replace(/^\ufeff/, "").split("\r\n");
|
||||
assert.strictEqual(lines[0], 'Production company,Acme Films', "first line: " + lines[0]);
|
||||
assert.strictEqual(lines[1], 'Project / show,The Long Weekend', "second line: " + lines[1]);
|
||||
assert.strictEqual(lines[7], "", "no blank line between the details and the table: " + lines[7]);
|
||||
assert(/^File Name|Scene/.test(lines[8]) || /Scene/.test(lines[8]),
|
||||
"the table header should follow the blank line: " + lines[8]);
|
||||
assert(reportCsv.indexOf("A001_12A_T1.wav") !== -1, "the rows went missing");
|
||||
});
|
||||
|
||||
check("a detail with a comma in it is still one field", () => {
|
||||
assert(reportCsv.indexOf('"Day 4, ext. night"') !== -1,
|
||||
"the note wasn't quoted: " + reportCsv.split("\r\n").slice(0, 8).join(" / "));
|
||||
});
|
||||
|
||||
// PDF export — jsPDF copies its API onto each instance, so the only
|
||||
// reliable interception point is the constructor itself.
|
||||
let pdfBytes = null;
|
||||
const RealJsPDF = window.jspdf.jsPDF;
|
||||
window.jspdf.jsPDF = function (options) {
|
||||
const instance = new RealJsPDF(options);
|
||||
instance.save = function () {
|
||||
pdfBytes = this.output("arraybuffer");
|
||||
return this;
|
||||
};
|
||||
return instance;
|
||||
};
|
||||
doc.querySelector("[data-bwfa-export-pdf]").dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
|
||||
await waitFor(() => pdfBytes, "pdf generation", 15000);
|
||||
window.jspdf.jsPDF = RealJsPDF;
|
||||
check("PDF export produces a valid PDF", () => {
|
||||
const head = Buffer.from(new Uint8Array(pdfBytes).slice(0, 5)).toString("latin1");
|
||||
assert.strictEqual(head, "%PDF-", "not a PDF: " + head);
|
||||
assert(pdfBytes.byteLength > 1000, "suspiciously small: " + pdfBytes.byteLength);
|
||||
});
|
||||
|
||||
// Metadata writer: round-trip an edit through the parser.
|
||||
const writer = window.BWFA && window.BWFA.MetadataWriter;
|
||||
check("metadata writer is available", () => assert(writer, "BWFA.MetadataWriter missing"));
|
||||
|
||||
// Clear hides the results panel and re-shows the empty state.
|
||||
const resultsEl = doc.querySelector("[data-bwfa-results]");
|
||||
const emptyEl = doc.querySelector("[data-bwfa-empty]");
|
||||
doc.querySelector("[data-bwfa-clear]").dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
|
||||
await waitFor(() => resultsEl.hidden === true, "clear", 5000);
|
||||
check("clear resets back to the empty state", () => {
|
||||
assert.strictEqual(resultsEl.hidden, true, "results still visible");
|
||||
assert.strictEqual(emptyEl.hidden, false, "empty state not shown");
|
||||
assert.strictEqual(doc.querySelector("[data-bwfa-clear]").disabled, true, "clear still enabled");
|
||||
assert.strictEqual(doc.querySelector("[data-bwfa-status]").textContent.trim(), "", "status not cleared");
|
||||
});
|
||||
|
||||
// A feature wired into one build only looks exactly like a broken feature:
|
||||
// the control is there, pressing it does nothing. So check the knob in the
|
||||
// plain browser page as well, where there is no native engine.
|
||||
check("the mixer knob works in the browser build too", () => {
|
||||
const knob = doc.querySelector("[data-bwfa-mixer-open]");
|
||||
assert(knob, "no mixer knob on the page");
|
||||
knob.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
|
||||
const modal = doc.querySelector("[data-bwfa-mixer]");
|
||||
assert.strictEqual(modal.hidden, false, "the knob is dead: the modal stayed shut");
|
||||
// Unhidden is not visible. This framework keeps .modal at display:none
|
||||
// until it also has .open, so a test that only checks the attribute
|
||||
// passes while the user sees nothing at all — which is what happened.
|
||||
assert(modal.classList.contains("open"),
|
||||
"the modal is unhidden but has no .open class, so it is still display:none");
|
||||
const spectro = doc.querySelector("[data-bwfa-spectro]");
|
||||
assert(spectro, "no spectrogram modal to compare against");
|
||||
assert.strictEqual(window.getComputedStyle(modal).display,
|
||||
window.getComputedStyle(spectro).display === "none"
|
||||
? window.getComputedStyle(modal).display : "flex",
|
||||
"the mixer isn't shown the way the other modals are");
|
||||
const said = doc.querySelector("[data-bwfa-mixer-strips]").textContent;
|
||||
assert(said.trim().length > 0, "the mixer opened empty and said nothing");
|
||||
doc.querySelector("[data-bwfa-mixer-close]")
|
||||
.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
|
||||
assert.strictEqual(modal.hidden, true, "it wouldn't close");
|
||||
assert(!modal.classList.contains("open"), "closing left the .open class behind");
|
||||
});
|
||||
|
||||
check("the player's round buttons cannot be squashed", () => {
|
||||
// The bug this exists for: a flex item with a set width still shrinks,
|
||||
// and because the icon inside overflows, the button goes on looking
|
||||
// perfectly normal while its hit area collapses to nothing. jsdom has
|
||||
// no layout, so no amount of clicking in a test can catch it — read
|
||||
// the rule instead.
|
||||
const probe = doc.createElement("button");
|
||||
probe.className = "bwfa-round-btn";
|
||||
container.appendChild(probe);
|
||||
const style = window.getComputedStyle(probe);
|
||||
const shrink = style.flexShrink || style.getPropertyValue("flex-shrink");
|
||||
probe.remove();
|
||||
assert.strictEqual(String(shrink), "0",
|
||||
"a round button can shrink (flex-shrink: " + shrink + "), which kills its hit area");
|
||||
assert(/\.bwfa-round-btn\s*\{[^}]*flex:\s*none/.test(css),
|
||||
"no flex: none on .bwfa-round-btn in the shipped stylesheet");
|
||||
});
|
||||
|
||||
check("the app carries a log you can read without developer tools", () => {
|
||||
// The app ships without an inspector, so "what does the console say"
|
||||
// has to be answerable from inside the window.
|
||||
assert(window.BWFA_DIAG, "no diagnostics on the page");
|
||||
window.console.error("a deliberate error, for the log");
|
||||
const captured = window.BWFA_DIAG.lines().join("\n");
|
||||
assert(/a deliberate error, for the log/.test(captured),
|
||||
"the log didn't catch a console error");
|
||||
assert(/page built/.test(window.BWFA_DIAG.lines().join("\n")) ||
|
||||
/ERROR/.test(captured), "nothing useful in the log");
|
||||
// And it measures the controls, which is what tells us a visible
|
||||
// button has no hit area — the failure that started all this.
|
||||
window.BWFA_DIAG.probe();
|
||||
const measured = window.BWFA_DIAG.lines().join("\n");
|
||||
assert(/PROBE.*data-bwfa-mixer-open/.test(measured),
|
||||
"the probe didn't measure the mixer knob");
|
||||
// jsdom reports every box as zero, so don't assert on the numbers —
|
||||
// only that the knob and the modal were both found on the page.
|
||||
assert(!/data-bwfa-mixer-open\] MISSING/.test(measured),
|
||||
"the probe says the knob isn't on the page at all");
|
||||
assert(/mixer modal in the page: yes/.test(measured),
|
||||
"the probe says the mixer modal is missing");
|
||||
consoleErrors.length = 0;
|
||||
});
|
||||
|
||||
check("mute and solo are dead centre in their circles", () => {
|
||||
// .chip pads 0.85rem left against 0.5rem right, which reads fine under
|
||||
// a word and visibly lopsided under a single letter.
|
||||
const probe = doc.createElement("button");
|
||||
probe.className = "chip bwfa-mixer-mute";
|
||||
container.appendChild(probe);
|
||||
const style = window.getComputedStyle(probe);
|
||||
const pad = [style.paddingLeft, style.paddingRight, style.paddingTop, style.paddingBottom];
|
||||
probe.remove();
|
||||
assert(pad.every((v) => parseFloat(v || 0) === 0),
|
||||
"the letter is pushed off centre by padding: " + pad.join(" "));
|
||||
assert(/\.bwfa-mixer-mute[^{]*\{[^}]*justify-content:\s*center/.test(css),
|
||||
"nothing centres the letter horizontally");
|
||||
assert(/\.bwfa-mixer-mute[^{]*\{[^}]*align-items:\s*center/.test(css),
|
||||
"nothing centres the letter vertically");
|
||||
});
|
||||
|
||||
check("the mixer borrows the app's accent, not the system's", () => {
|
||||
// The faders came up macOS blue because they asked for a --accent
|
||||
// variable this app has never defined, so every one of them fell
|
||||
// through to the hardcoded fallback.
|
||||
assert(!/accent-color:\s*var\(\s*--accent\b/.test(css),
|
||||
"something still asks for --accent, which this app doesn't define");
|
||||
assert(!/#0a84ff/i.test(css), "a hardcoded system blue is still in the stylesheet");
|
||||
assert(/accent-color:\s*var\(\s*--color-action\s*\)/.test(css),
|
||||
"the faders don't use the app's own action colour");
|
||||
});
|
||||
|
||||
check("the page stamps when it was built", () => {
|
||||
// So "is the app running the page I just built?" stops being a matter
|
||||
// of opinion. The native build embeds this page at compile time.
|
||||
assert(/^\d{4}-\d\d-\d\d \d\d:\d\d:\d\d$/.test(window.BWFA_BUILD || ""),
|
||||
"no build stamp on the page: " + window.BWFA_BUILD);
|
||||
assert.strictEqual(container.getAttribute("data-bwfa-build"), window.BWFA_BUILD,
|
||||
"the stamp on the page and the stamp on the app disagree");
|
||||
});
|
||||
|
||||
check("no script errors on the page", () =>
|
||||
assert.strictEqual(consoleErrors.length, 0, consoleErrors.join(" | ")));
|
||||
|
||||
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");
|
||||
process.exit(failed ? 1 : 0);
|
||||
})().catch((e) => {
|
||||
console.error("harness error:", e);
|
||||
console.error(consoleErrors.join("\n"));
|
||||
process.exit(1);
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
Executable
+268
@@ -0,0 +1,268 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Double-click this file. It builds BWF Analyser.app next to itself.
|
||||
#
|
||||
# What it does, in order: makes sure Apple's command line tools and Rust are
|
||||
# present (installing Rust itself if it isn't), compiles the Rust binary,
|
||||
# assembles the .app bundle around it, signs it locally, and opens it.
|
||||
#
|
||||
# Safe to run again: a rebuild after the first one takes seconds, since cargo
|
||||
# caches everything.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
BOLD=$'\033[1m'
|
||||
DIM=$'\033[2m'
|
||||
GREEN=$'\033[32m'
|
||||
RED=$'\033[31m'
|
||||
YELLOW=$'\033[33m'
|
||||
RESET=$'\033[0m'
|
||||
|
||||
APP_NAME="BWF Analyser"
|
||||
BUNDLE_ID="com.vincentrozenberg.bwf-analyser"
|
||||
VERSION="1.5.1"
|
||||
BINARY_NAME="bwf-analyser"
|
||||
APP_DIR="$PWD/$APP_NAME.app"
|
||||
|
||||
step() { printf "\n%s==>%s %s%s%s\n" "$GREEN" "$RESET" "$BOLD" "$1" "$RESET"; }
|
||||
info() { printf " %s%s%s\n" "$DIM" "$1" "$RESET"; }
|
||||
warn() { printf " %s%s%s\n" "$YELLOW" "$1" "$RESET"; }
|
||||
|
||||
fail() {
|
||||
printf "\n%sBuild failed:%s %s\n\n" "$RED" "$RESET" "$1"
|
||||
printf "This window stays open so you can read the error above.\n"
|
||||
printf "Press Return to close it.\n"
|
||||
read -r _ || true
|
||||
exit 1
|
||||
}
|
||||
|
||||
trap 'fail "see the last message above"' ERR
|
||||
|
||||
printf "%s%s %s — build%s\n" "$BOLD" "$APP_NAME" "$VERSION" "$RESET"
|
||||
printf "%sThis compiles a native macOS app from source. First run takes a while;%s\n" "$DIM" "$RESET"
|
||||
printf "%severything after that is quick.%s\n" "$DIM" "$RESET"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. Apple command line tools (needed for the linker)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
step "Checking Apple command line tools"
|
||||
|
||||
if xcode-select -p >/dev/null 2>&1; then
|
||||
info "already installed at $(xcode-select -p)"
|
||||
else
|
||||
warn "not installed — asking macOS to install them now."
|
||||
warn "A system dialog will appear: click Install and accept the licence."
|
||||
xcode-select --install >/dev/null 2>&1 || true
|
||||
|
||||
info "waiting for the installation to finish (this can take 10+ minutes)…"
|
||||
waited=0
|
||||
until xcode-select -p >/dev/null 2>&1; do
|
||||
sleep 15
|
||||
waited=$((waited + 15))
|
||||
if [ "$waited" -ge 3600 ]; then
|
||||
fail "command line tools still aren't installed. Finish the Apple installer, then run this again."
|
||||
fi
|
||||
if [ $((waited % 120)) -eq 0 ]; then
|
||||
info "still waiting… ($((waited / 60)) min)"
|
||||
fi
|
||||
done
|
||||
info "installed."
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. Rust
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
step "Checking Rust"
|
||||
|
||||
# Some of Tauri's dependencies are published with edition 2024, which older
|
||||
# toolchains refuse to even parse. An existing-but-ancient Rust is the most
|
||||
# likely thing to go wrong here, so check the version, not just the presence.
|
||||
MIN_MAJOR=1
|
||||
MIN_MINOR=85
|
||||
|
||||
if ! command -v cargo >/dev/null 2>&1; then
|
||||
if [ -f "$HOME/.cargo/env" ]; then
|
||||
# shellcheck disable=SC1091
|
||||
. "$HOME/.cargo/env"
|
||||
fi
|
||||
fi
|
||||
|
||||
rust_version() {
|
||||
# "rustc 1.81.0 (2dbb1af80 2024-08-20)" -> "1.81.0"
|
||||
rustc --version 2>/dev/null | awk '{print $2}'
|
||||
}
|
||||
|
||||
rust_is_recent_enough() {
|
||||
local version major minor
|
||||
version="$(rust_version)"
|
||||
[ -n "$version" ] || return 1
|
||||
major="${version%%.*}"
|
||||
minor="${version#*.}"
|
||||
minor="${minor%%.*}"
|
||||
[ "$major" -gt "$MIN_MAJOR" ] && return 0
|
||||
[ "$major" -eq "$MIN_MAJOR" ] && [ "$minor" -ge "$MIN_MINOR" ]
|
||||
}
|
||||
|
||||
install_rustup() {
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs |
|
||||
sh -s -- -y --profile minimal --default-toolchain stable --no-modify-path ||
|
||||
fail "could not install Rust — check your internet connection."
|
||||
# shellcheck disable=SC1091
|
||||
. "$HOME/.cargo/env"
|
||||
}
|
||||
|
||||
if ! command -v cargo >/dev/null 2>&1; then
|
||||
warn "not found — installing Rust into ~/.rustup and ~/.cargo (nothing else on your"
|
||||
warn "system is touched, and 'rustup self uninstall' removes it completely)."
|
||||
install_rustup
|
||||
fi
|
||||
|
||||
if rust_is_recent_enough; then
|
||||
info "$(cargo --version)"
|
||||
else
|
||||
warn "Rust $(rust_version) is too old — this needs $MIN_MAJOR.$MIN_MINOR or newer."
|
||||
|
||||
if command -v rustup >/dev/null 2>&1; then
|
||||
info "updating your existing toolchain…"
|
||||
rustup update stable || fail "rustup update failed."
|
||||
rustup default stable || true
|
||||
# shellcheck disable=SC1091
|
||||
[ -f "$HOME/.cargo/env" ] && . "$HOME/.cargo/env"
|
||||
else
|
||||
warn "your Rust wasn't installed by rustup (Homebrew or a package, most likely),"
|
||||
warn "so it can't be updated from here. Installing rustup alongside it."
|
||||
install_rustup
|
||||
fi
|
||||
|
||||
if ! rust_is_recent_enough; then
|
||||
fail "Rust is still $(rust_version), and $MIN_MAJOR.$MIN_MINOR+ is required.
|
||||
If you installed Rust with Homebrew, either run 'brew upgrade rust', or remove it
|
||||
('brew uninstall rust') and let this script install rustup instead. If rustup is
|
||||
present, check that '$HOME/.cargo/bin' comes first on your PATH."
|
||||
fi
|
||||
|
||||
info "now on $(cargo --version)"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. Compile
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
step "Assembling the app's page"
|
||||
|
||||
# This script used to compile whatever dist/index.html happened to be sitting
|
||||
# on disk. If that copy was stale, the app came out looking almost right — new
|
||||
# button visible, nothing behind it — which is a miserable thing to debug. So
|
||||
# build the page here, from source, every time.
|
||||
if command -v python3 >/dev/null 2>&1 && [ -f "../build/build.py" ]; then
|
||||
( cd .. && python3 build/build.py --tauri ) || fail "assembling dist/index.html failed. The error is above."
|
||||
else
|
||||
warn "python3 or build/build.py not found — using the dist/index.html already on disk."
|
||||
fi
|
||||
|
||||
[ -f "dist/index.html" ] || fail "dist/index.html is missing — the app's frontend should sit next to this script."
|
||||
info "$(wc -c < dist/index.html | tr -d ' ') bytes, written $(date -r dist/index.html '+%H:%M:%S')"
|
||||
|
||||
step "Compiling (first build downloads a few hundred crates — 5 to 20 minutes)"
|
||||
|
||||
# The page is baked into the binary by a macro, and cargo does not reliably
|
||||
# notice that the folder it reads changed — so a page-only change can compile
|
||||
# to "nothing to do" and produce an app with the previous page still inside.
|
||||
# Touching main.rs forces the macro to run again. Cheap: it is one crate.
|
||||
touch src-tauri/src/main.rs
|
||||
|
||||
cargo build --release --manifest-path src-tauri/Cargo.toml ||
|
||||
fail "the Rust build failed. The compiler error is above."
|
||||
|
||||
BUILT_BINARY="src-tauri/target/release/$BINARY_NAME"
|
||||
[ -f "$BUILT_BINARY" ] || fail "expected a binary at $BUILT_BINARY but it isn't there."
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. Assemble the .app bundle
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
step "Building $APP_NAME.app"
|
||||
|
||||
# A running copy has to go first. Replacing the bundle underneath a live
|
||||
# process is allowed, but `open` at the end would then just activate the
|
||||
# instance already running — the old code — which looks exactly like a build
|
||||
# that silently did nothing.
|
||||
if pgrep -x "$APP_NAME" >/dev/null 2>&1; then
|
||||
info "quitting the running copy first"
|
||||
pkill -x "$APP_NAME" 2>/dev/null || true
|
||||
waited=0
|
||||
while pgrep -x "$APP_NAME" >/dev/null 2>&1 && [ "$waited" -lt 10 ]; do
|
||||
sleep 1
|
||||
waited=$((waited + 1))
|
||||
done
|
||||
fi
|
||||
|
||||
rm -rf "$APP_DIR"
|
||||
mkdir -p "$APP_DIR/Contents/MacOS" "$APP_DIR/Contents/Resources"
|
||||
|
||||
cp "$BUILT_BINARY" "$APP_DIR/Contents/MacOS/$APP_NAME"
|
||||
chmod +x "$APP_DIR/Contents/MacOS/$APP_NAME"
|
||||
cp "src-tauri/icons/icon.icns" "$APP_DIR/Contents/Resources/icon.icns"
|
||||
|
||||
cat > "$APP_DIR/Contents/Info.plist" <<PLIST
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>en</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>$APP_NAME</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$APP_NAME</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>icon.icns</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$BUNDLE_ID</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$APP_NAME</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>$VERSION</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$VERSION</string>
|
||||
<key>LSApplicationCategoryType</key>
|
||||
<string>public.app-category.music</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>10.15</string>
|
||||
<key>NSHighResolutionCapable</key>
|
||||
<true/>
|
||||
<key>NSHumanReadableCopyright</key>
|
||||
<string>MIT licensed</string>
|
||||
<key>NSSupportsAutomaticGraphicsSwitching</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
PLIST
|
||||
|
||||
printf 'APPL????' > "$APP_DIR/Contents/PkgInfo"
|
||||
|
||||
# Ad-hoc signature. Not a developer certificate — it just satisfies macOS's
|
||||
# requirement that a bundle be signed at all, which matters on Apple silicon.
|
||||
codesign --force --sign - --timestamp=none "$APP_DIR" >/dev/null 2>&1 ||
|
||||
warn "could not sign the bundle; it should still run, since you built it yourself."
|
||||
|
||||
# Locally built files aren't quarantined, but clear it anyway in case the
|
||||
# folder came from a download.
|
||||
xattr -cr "$APP_DIR" 2>/dev/null || true
|
||||
|
||||
step "Done"
|
||||
printf " %s%s%s\n" "$BOLD" "$APP_DIR" "$RESET"
|
||||
info "Drag it to /Applications if you want it in Launchpad."
|
||||
|
||||
open "$APP_DIR" || true
|
||||
|
||||
printf "\nPress Return to close this window.\n"
|
||||
read -r _ || true
|
||||
Generated
+4828
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,40 @@
|
||||
[package]
|
||||
name = "bwf-analyser"
|
||||
version = "1.5.1"
|
||||
description = "Broadcast Wave metadata analyser for location sound"
|
||||
authors = ["Vincent Rozenberg"]
|
||||
license = "MIT"
|
||||
edition = "2021"
|
||||
# Tauri itself only asks for 1.77, but crates deep in its dependency tree are
|
||||
# published as edition 2024, which older toolchains won't even parse. Declaring
|
||||
# the real floor here turns that into a clear message instead of a confusing
|
||||
# manifest error from some transitive dependency.
|
||||
rust-version = "1.85"
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
# devtools: right-click gives Inspect Element in the built app. Without it a
|
||||
# problem in the page can only be guessed at from the outside.
|
||||
tauri = { version = "2", features = ["devtools"] }
|
||||
tauri-plugin-dialog = "2"
|
||||
# Remembers the window's size and position between launches.
|
||||
tauri-plugin-window-state = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
# Playback. The app owns its own output stream rather than borrowing the
|
||||
# webview's: WebKit's audio dies after the machine has been left alone and
|
||||
# only a relaunch brings it back, which is not something a page can fix.
|
||||
cpal = "0.16"
|
||||
|
||||
# Reading metadata is all disk and no arithmetic, but converting a card of
|
||||
# 32-bit float files is a per-sample loop over tens of gigabytes, so the
|
||||
# optimiser gets its head. `codegen-units = 1` and LTO keep the binary small
|
||||
# anyway.
|
||||
[profile.release]
|
||||
codegen-units = 1
|
||||
lto = true
|
||||
opt-level = 3
|
||||
# Unwinding is left on: an Objective-C exception coming back through the
|
||||
# webview should produce a usable crash report, not an immediate abort.
|
||||
strip = true
|
||||
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"identifier": "default",
|
||||
"description": "Core APIs plus the native open dialog. The app's own bwf_* commands are not listed here: commands defined by the application itself, called from its own local frontend, are allowed without an ACL entry. Only plugin commands need one.",
|
||||
"windows": [
|
||||
"main"
|
||||
],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"dialog:allow-open",
|
||||
"dialog:allow-save",
|
||||
"window-state:default"
|
||||
]
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
{"default":{"identifier":"default","description":"Core APIs plus the native open dialog. The app's own bwf_* commands are not listed here: commands defined by the application itself, called from its own local frontend, are allowed without an ACL entry. Only plugin commands need one.","local":true,"windows":["main"],"permissions":["core:default","dialog:allow-open","dialog:allow-save","window-state:default"]}}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 5.9 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 8.9 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.8 KiB |
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,639 @@
|
||||
// BWF Analyser — native file access for the macOS app.
|
||||
//
|
||||
// The frontend is the same client-side analyser that runs in a browser. The
|
||||
// one thing it can't do inside a WKWebView is touch the disk: Safari's engine
|
||||
// has no File System Access API, and its folder input is unreliable. So every
|
||||
// read and write goes through the commands below instead, and a small JS
|
||||
// bridge in the frontend presents them as the File / FileSystemFileHandle
|
||||
// objects the app already knows how to use.
|
||||
//
|
||||
// Reads are ranged on purpose. Pulling metadata out of a 4 GB take should read
|
||||
// a few kilobytes of chunk headers, not the whole file, which is exactly what
|
||||
// the parser asks for when it slices a File.
|
||||
//
|
||||
// Every command that touches the filesystem runs on a blocking thread. A card
|
||||
// full of takes is a lot of syscalls, and the async workers here also carry
|
||||
// event and channel traffic — stalling one of those stalls the UI.
|
||||
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
use std::fs;
|
||||
use std::io::{Read, Seek, SeekFrom, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::UNIX_EPOCH;
|
||||
|
||||
use serde::Serialize;
|
||||
use tauri::async_runtime::spawn_blocking;
|
||||
use tauri::ipc::{InvokeBody, Request, Response};
|
||||
|
||||
mod convert;
|
||||
mod play;
|
||||
|
||||
/// How deep a folder scan will recurse before giving up. Symlinks are followed,
|
||||
/// so this is a genuine loop guard, not just a sanity limit.
|
||||
const MAX_DEPTH: usize = 24;
|
||||
|
||||
/// A single read is capped well below what the IPC layer will happily try to
|
||||
/// copy. The bridge splits anything larger into ranged reads.
|
||||
const MAX_SINGLE_READ: u64 = 512 * 1024 * 1024;
|
||||
|
||||
/// Matches the extensions the frontend accepts.
|
||||
fn is_recording(name: &str) -> bool {
|
||||
let lower = name.to_lowercase();
|
||||
lower.ends_with(".wav") || lower.ends_with(".bwf") || lower.ends_with(".broadcastwave")
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct FileEntry {
|
||||
path: String,
|
||||
relative_path: String,
|
||||
name: String,
|
||||
size: u64,
|
||||
/// Milliseconds since the epoch, to match JS `File.lastModified`.
|
||||
last_modified: u64,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct DirEntry {
|
||||
name: String,
|
||||
path: String,
|
||||
/// "file" or "directory", mirroring FileSystemHandle.kind.
|
||||
kind: String,
|
||||
}
|
||||
|
||||
fn modified_ms(meta: &fs::Metadata) -> u64 {
|
||||
meta.modified()
|
||||
.ok()
|
||||
.and_then(|t| t.duration_since(UNIX_EPOCH).ok())
|
||||
.map(|d| d.as_millis() as u64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
fn file_name_of(path: &Path) -> String {
|
||||
path.file_name()
|
||||
.map(|n| n.to_string_lossy().to_string())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn describe(path: &Path, relative_path: String) -> Result<FileEntry, String> {
|
||||
let meta = fs::metadata(path).map_err(|e| format!("{}: {}", path.display(), e))?;
|
||||
Ok(FileEntry {
|
||||
path: path.to_string_lossy().to_string(),
|
||||
relative_path,
|
||||
name: file_name_of(path),
|
||||
size: meta.len(),
|
||||
last_modified: modified_ms(&meta),
|
||||
})
|
||||
}
|
||||
|
||||
fn walk(dir: &Path, prefix: &str, depth: usize, out: &mut Vec<FileEntry>) -> Result<(), String> {
|
||||
if depth > MAX_DEPTH {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let reader = fs::read_dir(dir).map_err(|e| format!("{}: {}", dir.display(), e))?;
|
||||
let mut children: Vec<fs::DirEntry> = reader.filter_map(|entry| entry.ok()).collect();
|
||||
children.sort_by_key(|entry| entry.file_name());
|
||||
|
||||
for child in children {
|
||||
let name = child.file_name().to_string_lossy().to_string();
|
||||
// Skip dotfiles: ._resource forks and .DS_Store are never recordings.
|
||||
if name.starts_with('.') {
|
||||
continue;
|
||||
}
|
||||
|
||||
let path = child.path();
|
||||
let relative = if prefix.is_empty() {
|
||||
name.clone()
|
||||
} else {
|
||||
format!("{}/{}", prefix, name)
|
||||
};
|
||||
|
||||
// fs::metadata follows symlinks; DirEntry::file_type does not, and a
|
||||
// card with an aliased folder on it should still be scanned.
|
||||
let meta = match fs::metadata(&path) {
|
||||
Ok(meta) => meta,
|
||||
Err(_) => continue, // Broken link or a file that just went away.
|
||||
};
|
||||
|
||||
if meta.is_dir() {
|
||||
walk(&path, &relative, depth + 1, out)?;
|
||||
} else if meta.is_file() && is_recording(&name) {
|
||||
out.push(FileEntry {
|
||||
path: path.to_string_lossy().to_string(),
|
||||
relative_path: relative,
|
||||
name,
|
||||
size: meta.len(),
|
||||
last_modified: modified_ms(&meta),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn scan_blocking(paths: Vec<String>) -> Result<Vec<FileEntry>, String> {
|
||||
let mut out: Vec<FileEntry> = Vec::new();
|
||||
|
||||
for raw in paths {
|
||||
let path = PathBuf::from(&raw);
|
||||
// One unreadable path shouldn't discard everything else that was
|
||||
// dropped alongside it.
|
||||
let meta = match fs::metadata(&path) {
|
||||
Ok(meta) => meta,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
if meta.is_dir() {
|
||||
let base = file_name_of(&path);
|
||||
walk(&path, &base, 0, &mut out)?;
|
||||
} else {
|
||||
let name = file_name_of(&path);
|
||||
if is_recording(&name) {
|
||||
out.push(describe(&path, name)?);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn list_dir_blocking(path: String) -> Result<Vec<DirEntry>, String> {
|
||||
let dir = PathBuf::from(&path);
|
||||
let reader = fs::read_dir(&dir).map_err(|e| format!("{}: {}", dir.display(), e))?;
|
||||
|
||||
let mut children: Vec<fs::DirEntry> = reader.filter_map(|entry| entry.ok()).collect();
|
||||
children.sort_by_key(|entry| entry.file_name());
|
||||
|
||||
let mut out = Vec::new();
|
||||
for child in children {
|
||||
let name = child.file_name().to_string_lossy().to_string();
|
||||
if name.starts_with('.') {
|
||||
continue;
|
||||
}
|
||||
let child_path = child.path();
|
||||
let meta = match fs::metadata(&child_path) {
|
||||
Ok(meta) => meta,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let kind = if meta.is_dir() {
|
||||
"directory"
|
||||
} else if meta.is_file() {
|
||||
"file"
|
||||
} else {
|
||||
continue;
|
||||
};
|
||||
out.push(DirEntry {
|
||||
name,
|
||||
path: child_path.to_string_lossy().to_string(),
|
||||
kind: kind.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn read_range_blocking(path: String, offset: u64, length: u64) -> Result<Vec<u8>, String> {
|
||||
let mut file = fs::File::open(&path).map_err(|e| format!("{}: {}", path, e))?;
|
||||
let size = file
|
||||
.metadata()
|
||||
.map_err(|e| format!("{}: {}", path, e))?
|
||||
.len();
|
||||
|
||||
if offset >= size || length == 0 {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let take = std::cmp::min(length, size - offset);
|
||||
if take > MAX_SINGLE_READ {
|
||||
return Err(format!(
|
||||
"refusing to read {} bytes in one go — read it in ranges instead",
|
||||
take
|
||||
));
|
||||
}
|
||||
|
||||
file.seek(SeekFrom::Start(offset))
|
||||
.map_err(|e| format!("{}: {}", path, e))?;
|
||||
|
||||
let mut buffer = vec![0u8; take as usize];
|
||||
file.read_exact(&mut buffer)
|
||||
.map_err(|e| format!("{}: {}", path, e))?;
|
||||
|
||||
Ok(buffer)
|
||||
}
|
||||
|
||||
/// Expands whatever the user picked or dropped — folders, single files, a mix —
|
||||
/// into a flat list of recordings, each with a relative path the table uses as
|
||||
/// its "Folder" column.
|
||||
#[tauri::command]
|
||||
async fn bwf_scan(paths: Vec<String>) -> Result<Vec<FileEntry>, String> {
|
||||
match spawn_blocking(move || scan_blocking(paths)).await {
|
||||
Ok(result) => result,
|
||||
Err(e) => Err(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// One level of a directory, for the JS side's FileSystemDirectoryHandle shim.
|
||||
#[tauri::command]
|
||||
async fn bwf_list_dir(path: String) -> Result<Vec<DirEntry>, String> {
|
||||
match spawn_blocking(move || list_dir_blocking(path)).await {
|
||||
Ok(result) => result,
|
||||
Err(e) => Err(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-reads size/mtime. The editor calls this before every save so it works
|
||||
/// from the file as it is on disk right now, not as it was at scan time.
|
||||
#[tauri::command]
|
||||
async fn bwf_stat(path: String) -> Result<FileEntry, String> {
|
||||
match spawn_blocking(move || {
|
||||
let target = PathBuf::from(&path);
|
||||
let name = file_name_of(&target);
|
||||
describe(&target, name)
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(result) => result,
|
||||
Err(e) => Err(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The workhorse: a byte range, returned as raw bytes rather than a JSON array
|
||||
/// so it arrives in the webview as an ArrayBuffer with no serialization cost.
|
||||
/// Out-of-range requests clamp instead of failing, matching Blob.slice().
|
||||
#[tauri::command]
|
||||
async fn bwf_read_range(path: String, offset: u64, length: u64) -> Result<Response, String> {
|
||||
match spawn_blocking(move || read_range_blocking(path, offset, length)).await {
|
||||
Ok(result) => result.map(Response::new),
|
||||
Err(e) => Err(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whole-file read, for audio playback and for the writer when it needs the
|
||||
/// original bytes. Anything genuinely large is refused here and fetched by the
|
||||
/// bridge in ranges instead, so a 4 GB take never sits in memory three times
|
||||
/// over (once in Rust, once in the response, once in JS).
|
||||
#[tauri::command]
|
||||
async fn bwf_read_all(path: String) -> Result<Response, String> {
|
||||
match spawn_blocking(move || {
|
||||
let size = fs::metadata(&path)
|
||||
.map_err(|e| format!("{}: {}", path, e))?
|
||||
.len();
|
||||
read_range_blocking(path, 0, size)
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(result) => result.map(Response::new),
|
||||
Err(e) => Err(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads a recording's audio format, and optionally scans it for its peak.
|
||||
///
|
||||
/// The scan reads every sample, which is the point: a 32-bit float file can
|
||||
/// legally sit above 0 dBFS, and nothing else can tell you whether converting
|
||||
/// it to fixed point would clip.
|
||||
#[tauri::command]
|
||||
async fn bwf_probe(path: String, scan: bool) -> Result<convert::Probe, String> {
|
||||
match spawn_blocking(move || convert::probe(&path, scan)).await {
|
||||
Ok(result) => result,
|
||||
Err(e) => Err(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes one converted copy of a recording into a new folder.
|
||||
#[tauri::command]
|
||||
async fn bwf_export(
|
||||
src: String,
|
||||
dest: String,
|
||||
bits: u16,
|
||||
float: bool,
|
||||
gain: f64,
|
||||
overwrite: bool,
|
||||
channels: Vec<u16>,
|
||||
) -> Result<convert::Exported, String> {
|
||||
match spawn_blocking(move || {
|
||||
convert::export(&src, &dest, bits, float, gain, overwrite, &channels)
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(result) => result,
|
||||
Err(e) => Err(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Splits a poly recording into one mono file per channel.
|
||||
#[tauri::command]
|
||||
async fn bwf_export_split(
|
||||
src: String,
|
||||
dest: String,
|
||||
names: Vec<String>,
|
||||
bits: u16,
|
||||
float: bool,
|
||||
gain: f64,
|
||||
overwrite: bool,
|
||||
channels: Vec<u16>,
|
||||
) -> Result<convert::SplitExported, String> {
|
||||
match spawn_blocking(move || {
|
||||
convert::export_split(&src, &dest, &names, bits, float, gain, overwrite, &channels)
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(result) => result,
|
||||
Err(e) => Err(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// What combining a set of files would produce, without producing it.
|
||||
#[tauri::command]
|
||||
async fn bwf_combine_plan(
|
||||
sources: Vec<String>,
|
||||
bits: u16,
|
||||
float: bool,
|
||||
) -> Result<convert::CombinePlan, String> {
|
||||
match spawn_blocking(move || convert::combine_plan(&sources, bits, float)).await {
|
||||
Ok(result) => result,
|
||||
Err(e) => Err(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes several recordings into one poly file, aligned by timecode.
|
||||
#[tauri::command]
|
||||
async fn bwf_combine(
|
||||
sources: Vec<String>,
|
||||
dest: String,
|
||||
bits: u16,
|
||||
float: bool,
|
||||
gain: f64,
|
||||
overwrite: bool,
|
||||
) -> Result<convert::Combined, String> {
|
||||
match spawn_blocking(move || convert::combine(&sources, &dest, bits, float, gain, overwrite))
|
||||
.await
|
||||
{
|
||||
Ok(result) => result,
|
||||
Err(e) => Err(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Copies a recording to the export folder untouched.
|
||||
///
|
||||
/// This is the path taken when nothing about the audio is changing. A byte-for-
|
||||
/// byte copy is a stronger promise about metadata than any rebuild, however
|
||||
/// careful, so it's worth having as its own case.
|
||||
#[tauri::command]
|
||||
async fn bwf_copy_file(src: String, dest: String, overwrite: bool) -> Result<u64, String> {
|
||||
match spawn_blocking(move || {
|
||||
let target = PathBuf::from(&dest);
|
||||
if target.exists() && !overwrite {
|
||||
return Err("bwf:exists".to_string());
|
||||
}
|
||||
if let Ok(a) = fs::canonicalize(&src) {
|
||||
if let Ok(b) = fs::canonicalize(&dest) {
|
||||
if a == b {
|
||||
return Err("bwf:same-file".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(parent) = target.parent() {
|
||||
fs::create_dir_all(parent).map_err(|e| format!("{}: {}", parent.display(), e))?;
|
||||
}
|
||||
fs::copy(&src, &dest).map_err(|e| format!("{}: {}", dest, e))
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(result) => result,
|
||||
Err(e) => Err(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates the export folder, and reports whether it already had files in it.
|
||||
#[tauri::command]
|
||||
async fn bwf_prepare_dir(path: String) -> Result<u64, String> {
|
||||
match spawn_blocking(move || {
|
||||
fs::create_dir_all(&path).map_err(|e| format!("{}: {}", path, e))?;
|
||||
let count = fs::read_dir(&path)
|
||||
.map_err(|e| format!("{}: {}", path, e))?
|
||||
.filter(|entry| {
|
||||
entry
|
||||
.as_ref()
|
||||
.map(|e| is_recording(&file_name_of(&e.path())))
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.count();
|
||||
Ok(count as u64)
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(result) => result,
|
||||
Err(e) => Err(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn decode_hex(input: &str) -> Result<Vec<u8>, String> {
|
||||
if input.len() % 2 != 0 {
|
||||
return Err("malformed path encoding".to_string());
|
||||
}
|
||||
let bytes = input.as_bytes();
|
||||
let mut out = Vec::with_capacity(input.len() / 2);
|
||||
for pair in bytes.chunks(2) {
|
||||
let hi = (pair[0] as char)
|
||||
.to_digit(16)
|
||||
.ok_or_else(|| "malformed path encoding".to_string())?;
|
||||
let lo = (pair[1] as char)
|
||||
.to_digit(16)
|
||||
.ok_or_else(|| "malformed path encoding".to_string())?;
|
||||
out.push((hi * 16 + lo) as u8);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn header<'a>(request: &'a Request<'_>, name: &str) -> Option<&'a str> {
|
||||
request.headers().get(name).and_then(|v| v.to_str().ok())
|
||||
}
|
||||
|
||||
/// Writes bytes back to a recording.
|
||||
///
|
||||
/// The payload is the raw request body, so even a full multi-gigabyte rebuild
|
||||
/// never becomes a JSON array. Path and position ride along as headers; the
|
||||
/// path is hex-encoded because header values have to be ASCII and filenames
|
||||
/// very much do not.
|
||||
///
|
||||
/// Headers:
|
||||
/// x-bwf-path hex-encoded UTF-8 absolute path
|
||||
/// x-bwf-position byte offset to write at
|
||||
/// x-bwf-truncate "1" to cut the file to `position + len` after writing
|
||||
/// x-bwf-create "1" to create the file if it doesn't exist (exports)
|
||||
///
|
||||
/// This one is deliberately synchronous: it keeps the borrowed `Request`
|
||||
/// simple, and the bridge sends everything in bounded chunks, so no single
|
||||
/// call holds the main thread for long.
|
||||
#[tauri::command]
|
||||
fn bwf_write(request: Request<'_>) -> Result<u64, String> {
|
||||
let data = match request.body() {
|
||||
InvokeBody::Raw(bytes) => bytes,
|
||||
// Tauri falls back to the postMessage IPC if the custom protocol ever
|
||||
// fails, and that path JSON-encodes the body. Nothing here can recover
|
||||
// from it, but the message should at least point at the real cause.
|
||||
InvokeBody::Json(_) => {
|
||||
return Err("write payload did not arrive as raw bytes (IPC fell back to JSON)".to_string())
|
||||
}
|
||||
};
|
||||
|
||||
let path_hex = header(&request, "x-bwf-path").ok_or("missing x-bwf-path header")?;
|
||||
let path = String::from_utf8(decode_hex(path_hex)?).map_err(|e| e.to_string())?;
|
||||
|
||||
let position: u64 = header(&request, "x-bwf-position")
|
||||
.unwrap_or("0")
|
||||
.parse()
|
||||
.map_err(|_| "invalid x-bwf-position header".to_string())?;
|
||||
|
||||
let truncate = header(&request, "x-bwf-truncate").unwrap_or("0") == "1";
|
||||
let create = header(&request, "x-bwf-create").unwrap_or("0") == "1";
|
||||
|
||||
let mut file = fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create(create)
|
||||
.open(&path)
|
||||
.map_err(|e| format!("{}: {}", path, e))?;
|
||||
|
||||
file.seek(SeekFrom::Start(position))
|
||||
.map_err(|e| format!("{}: {}", path, e))?;
|
||||
file.write_all(data)
|
||||
.map_err(|e| format!("{}: {}", path, e))?;
|
||||
|
||||
if truncate {
|
||||
file.set_len(position + data.len() as u64)
|
||||
.map_err(|e| format!("{}: {}", path, e))?;
|
||||
}
|
||||
|
||||
file.flush().map_err(|e| format!("{}: {}", path, e))?;
|
||||
|
||||
Ok(data.len() as u64)
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Playback */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/// The waveform, without decoding the file into memory to get it.
|
||||
#[tauri::command]
|
||||
async fn bwf_peaks(path: String, buckets: usize) -> Result<convert::Peaks, String> {
|
||||
match spawn_blocking(move || convert::peaks(&path, buckets)).await {
|
||||
Ok(result) => result,
|
||||
Err(e) => Err(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// A picture of the file: time across, frequency up.
|
||||
#[tauri::command]
|
||||
async fn bwf_spectrogram(
|
||||
path: String,
|
||||
columns: usize,
|
||||
window: usize,
|
||||
gains: Vec<f32>,
|
||||
) -> Result<convert::Spectrogram, String> {
|
||||
match spawn_blocking(move || convert::spectrogram(&path, columns, window, &gains)).await {
|
||||
Ok(result) => result,
|
||||
Err(e) => Err(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Starts a file on the app's own output. `gains` is one linear gain per
|
||||
/// source channel, which is how the channel chips mute and solo.
|
||||
#[tauri::command]
|
||||
fn bwf_play(path: String, offset: f64, gains: Vec<f32>) -> Result<(), String> {
|
||||
play::play(path, offset, gains)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn bwf_pause() -> Result<(), String> {
|
||||
play::pause()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn bwf_resume() -> Result<(), String> {
|
||||
play::resume()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn bwf_stop() -> Result<(), String> {
|
||||
play::stop()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn bwf_seek(seconds: f64) -> Result<(), String> {
|
||||
play::seek(seconds)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn bwf_gains(gains: Vec<f32>) -> Result<(), String> {
|
||||
play::gains(gains)
|
||||
}
|
||||
|
||||
/// Restarts the app.
|
||||
///
|
||||
/// The last resort behind the settings. It used to reload the page, which was
|
||||
/// the wrong instrument: a reload builds a new document and a new audio
|
||||
/// context and the sound stayed gone, which is what proved the fault was
|
||||
/// below the page in the first place. Playback is the app's own now, so this
|
||||
/// should never be needed; it stays because the failure it covers took four
|
||||
/// attempts to find, and the folder is reopened on the way back up.
|
||||
#[tauri::command]
|
||||
fn bwf_restart(app: tauri::AppHandle) {
|
||||
app.restart();
|
||||
}
|
||||
|
||||
fn main() {
|
||||
tauri::Builder::default()
|
||||
// Restores the window's size and position from the last run, and
|
||||
// saves them on exit. Fullscreen and visibility are left out: an app
|
||||
// quit while fullscreen should come back as a window, and "was it
|
||||
// visible" is not a question worth persisting for a single-window
|
||||
// tool.
|
||||
.plugin(
|
||||
tauri_plugin_window_state::Builder::new()
|
||||
.with_state_flags(
|
||||
tauri_plugin_window_state::StateFlags::SIZE
|
||||
| tauri_plugin_window_state::StateFlags::POSITION
|
||||
| tauri_plugin_window_state::StateFlags::MAXIMIZED,
|
||||
)
|
||||
.build(),
|
||||
)
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
bwf_scan,
|
||||
bwf_list_dir,
|
||||
bwf_stat,
|
||||
bwf_read_range,
|
||||
bwf_read_all,
|
||||
bwf_write,
|
||||
bwf_probe,
|
||||
bwf_export,
|
||||
bwf_export_split,
|
||||
bwf_combine_plan,
|
||||
bwf_combine,
|
||||
bwf_copy_file,
|
||||
bwf_prepare_dir,
|
||||
bwf_peaks,
|
||||
bwf_spectrogram,
|
||||
bwf_play,
|
||||
bwf_pause,
|
||||
bwf_resume,
|
||||
bwf_stop,
|
||||
bwf_seek,
|
||||
bwf_gains,
|
||||
bwf_restart
|
||||
])
|
||||
// The audio engine is a thread of the app's own, started once and
|
||||
// living as long as the app does. It holds the output stream, which
|
||||
// is not Send on macOS, so it can't be parked in a global and handed
|
||||
// around: commands reach it over a channel instead.
|
||||
.setup(|app| {
|
||||
play::launch(app.handle().clone());
|
||||
Ok(())
|
||||
})
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running BWF Analyser");
|
||||
}
|
||||
@@ -0,0 +1,828 @@
|
||||
//! Playing a take, in the app rather than in the webview.
|
||||
//!
|
||||
//! This used to be the Web Audio API inside the WKWebView, and it kept
|
||||
//! failing the same way: after the machine had been left alone for a while,
|
||||
//! the transport ran, the clock advanced, and nothing came out of the
|
||||
//! speakers. Reloading the page didn't fix it. Only quitting the app did,
|
||||
//! which is the tell: a page reload builds a brand new document and a brand
|
||||
//! new AudioContext, so if that is still silent then the fault is below the
|
||||
//! page, in the WebKit content process that renders our audio. Nothing in
|
||||
//! JavaScript can reach that, which is why three attempts to fix it from
|
||||
//! there could never have worked.
|
||||
//!
|
||||
//! So the app owns its own output stream now. When a device goes away or a
|
||||
//! stream faults, this rebuilds it in process, and there is no WebKit audio
|
||||
//! path left to lose.
|
||||
//!
|
||||
//! The shape is a single engine thread that owns the cpal stream (a stream is
|
||||
//! not `Send` on macOS, so it can't be parked in a global) and takes commands
|
||||
//! over a channel. A reader thread streams the file from disk, because a day
|
||||
//! file is tens of gigabytes and the old player decoded whole files into
|
||||
//! memory to play them.
|
||||
|
||||
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};
|
||||
use std::sync::mpsc::{self, Receiver, Sender, SyncSender, TryRecvError};
|
||||
use std::sync::{Arc, LazyLock, Mutex};
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
|
||||
use serde::Serialize;
|
||||
use tauri::{AppHandle, Emitter};
|
||||
|
||||
use crate::convert::Take;
|
||||
|
||||
/// Frames per block handed from the reader to the audio callback.
|
||||
const BLOCK: usize = 4096;
|
||||
|
||||
/// Blocks in flight. Four at 48 kHz is roughly a third of a second: enough
|
||||
/// that a busy disk doesn't stutter, short enough that a seek doesn't have an
|
||||
/// audible tail of the old position.
|
||||
const BLOCKS: usize = 4;
|
||||
|
||||
/// How often the engine looks at the world: emits a position, notices the end
|
||||
/// of a file, notices the output device changed underneath it.
|
||||
const TICK: Duration = Duration::from_millis(100);
|
||||
|
||||
/// Rebuilds in a row before the output is called dead. Each one waits a little
|
||||
/// longer than the last.
|
||||
const REBUILD_LIMIT: u32 = 5;
|
||||
|
||||
/// What the frontend can ask for.
|
||||
enum Cmd {
|
||||
Play { path: String, offset: f64, gains: Vec<f32> },
|
||||
Pause,
|
||||
Resume,
|
||||
Stop,
|
||||
Seek(f64),
|
||||
Gains(Vec<f32>),
|
||||
}
|
||||
|
||||
/// What the frontend is told, on `bwf://playback`.
|
||||
#[derive(Serialize, Clone)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Status {
|
||||
pub playing: bool,
|
||||
pub seconds: f64,
|
||||
pub duration: f64,
|
||||
pub channels: u16,
|
||||
pub sample_rate: u32,
|
||||
pub ended: bool,
|
||||
/// Set when the output was rebuilt under a playing file, so the status
|
||||
/// line can say so rather than leaving a gap nobody can explain.
|
||||
pub reopened: bool,
|
||||
/// Peak level per source channel since the last status, 0.0 to 1.0, taken
|
||||
/// before the faders so a meter shows what is on the track rather than
|
||||
/// what you have done to it. Empty when nothing is playing.
|
||||
pub levels: Vec<f32>,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
/// State shared with the audio callback. Everything here is touched from a
|
||||
/// real-time thread, so it is all atomics: no locks, no allocation.
|
||||
struct Shared {
|
||||
/// One f32, as bits, per source channel.
|
||||
gains: Vec<AtomicU32>,
|
||||
/// Peak magnitude per source channel, as f32 bits, raised by the callback
|
||||
/// and taken by the status tick. For values that are never negative the
|
||||
/// IEEE bit pattern orders the same way the numbers do, which is what
|
||||
/// makes fetch_max correct here.
|
||||
meters: Vec<AtomicU32>,
|
||||
/// Output frames the callback has written since this stream started.
|
||||
played: AtomicU64,
|
||||
/// Where the file was when the stream started, in output frames.
|
||||
start: AtomicU64,
|
||||
/// The reader reached the end of the file.
|
||||
finished: AtomicBool,
|
||||
/// The reader stopped because it couldn't read, which is a different
|
||||
/// thing from the file ending and must not be reported as one.
|
||||
unreadable: AtomicBool,
|
||||
/// The callback ran out of audio after the reader had finished.
|
||||
drained: AtomicBool,
|
||||
/// cpal reported an error on the stream.
|
||||
broken: AtomicBool,
|
||||
}
|
||||
|
||||
impl Shared {
|
||||
fn gain(&self, channel: usize) -> f32 {
|
||||
match self.gains.get(channel) {
|
||||
Some(cell) => f32::from_bits(cell.load(Ordering::Relaxed)),
|
||||
// A channel nobody sent a gain for is on, matching set_gains.
|
||||
None => 1.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Raises a channel's peak. Called once per channel per callback, not per
|
||||
/// sample: the callback maxes into its own stack array first.
|
||||
fn raise(&self, channel: usize, level: f32) {
|
||||
if let Some(cell) = self.meters.get(channel) {
|
||||
cell.fetch_max(level.to_bits(), Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads the peaks and clears them, so each status covers the period since
|
||||
/// the last one rather than the loudest thing that ever happened.
|
||||
fn take_levels(&self) -> Vec<f32> {
|
||||
self.meters
|
||||
.iter()
|
||||
.map(|cell| f32::from_bits(cell.swap(0f32.to_bits(), Ordering::Relaxed)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn set_gains(&self, gains: &[f32]) {
|
||||
for (index, cell) in self.gains.iter().enumerate() {
|
||||
let value = gains.get(index).copied().unwrap_or(1.0);
|
||||
cell.store(value.to_bits(), Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
fn seconds(&self, device_rate: u32) -> f64 {
|
||||
if device_rate == 0 {
|
||||
return 0.0;
|
||||
}
|
||||
let frames = self.start.load(Ordering::Relaxed) + self.played.load(Ordering::Relaxed);
|
||||
frames as f64 / device_rate as f64
|
||||
}
|
||||
}
|
||||
|
||||
/// One file, open and playing (or paused).
|
||||
struct Playing {
|
||||
path: String,
|
||||
/// Dropped to close the output. Not `Send`, hence the engine thread.
|
||||
stream: cpal::Stream,
|
||||
shared: Arc<Shared>,
|
||||
/// Tells the reader thread to stop and let go of the file.
|
||||
halt: Arc<AtomicBool>,
|
||||
channels: u16,
|
||||
source_rate: u32,
|
||||
device_rate: u32,
|
||||
duration: f64,
|
||||
/// Consecutive rebuilds without a stretch of successful playback between
|
||||
/// them. A dead output must not be retried forever.
|
||||
attempts: u32,
|
||||
/// What the person asked for, which is not the same as what the stream
|
||||
/// is doing: a resume that failed leaves the stream stopped, and the
|
||||
/// rebuild has to know it was meant to be playing.
|
||||
wanted: bool,
|
||||
paused: bool,
|
||||
device: String,
|
||||
gains: Vec<f32>,
|
||||
}
|
||||
|
||||
static ENGINE: LazyLock<Mutex<Option<Sender<Cmd>>>> = LazyLock::new(|| Mutex::new(None));
|
||||
|
||||
fn send(cmd: Cmd) -> Result<(), String> {
|
||||
let engine = ENGINE.lock().map_err(|_| "the player is wedged".to_string())?;
|
||||
match engine.as_ref() {
|
||||
Some(tx) => tx.send(cmd).map_err(|_| "the player has stopped".to_string()),
|
||||
None => Err("the player hasn't started".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* What the frontend calls */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
pub fn play(path: String, offset: f64, gains: Vec<f32>) -> Result<(), String> {
|
||||
send(Cmd::Play { path, offset, gains })
|
||||
}
|
||||
|
||||
pub fn pause() -> Result<(), String> {
|
||||
send(Cmd::Pause)
|
||||
}
|
||||
|
||||
pub fn resume() -> Result<(), String> {
|
||||
send(Cmd::Resume)
|
||||
}
|
||||
|
||||
pub fn stop() -> Result<(), String> {
|
||||
send(Cmd::Stop)
|
||||
}
|
||||
|
||||
pub fn seek(seconds: f64) -> Result<(), String> {
|
||||
send(Cmd::Seek(seconds))
|
||||
}
|
||||
|
||||
pub fn gains(values: Vec<f32>) -> Result<(), String> {
|
||||
send(Cmd::Gains(values))
|
||||
}
|
||||
|
||||
/// Starts the engine thread. Called once, as the app comes up.
|
||||
pub fn launch(app: AppHandle) {
|
||||
let (tx, rx) = mpsc::channel::<Cmd>();
|
||||
if let Ok(mut engine) = ENGINE.lock() {
|
||||
*engine = Some(tx);
|
||||
}
|
||||
if thread::Builder::new()
|
||||
.name("bwf-audio".to_string())
|
||||
.spawn(move || run(app, rx))
|
||||
.is_err()
|
||||
{
|
||||
// Otherwise every command afterwards succeeds into a channel nobody
|
||||
// is reading, which looks exactly like playback that does nothing.
|
||||
if let Ok(mut engine) = ENGINE.lock() {
|
||||
*engine = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* The engine thread */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
fn run(app: AppHandle, rx: Receiver<Cmd>) {
|
||||
let mut current: Option<Playing> = None;
|
||||
|
||||
loop {
|
||||
match rx.recv_timeout(TICK) {
|
||||
Ok(Cmd::Play { path, offset, gains }) => {
|
||||
current = None; // Closes the old stream before opening a new one.
|
||||
match open(&path, offset, &gains, true) {
|
||||
Ok(playing) => {
|
||||
report(&app, &playing, false, false, None);
|
||||
current = Some(playing);
|
||||
}
|
||||
Err(e) => fail(&app, e),
|
||||
}
|
||||
}
|
||||
Ok(Cmd::Stop) => {
|
||||
current = None;
|
||||
let _ = app.emit("bwf://playback", Status {
|
||||
playing: false,
|
||||
seconds: 0.0,
|
||||
duration: 0.0,
|
||||
channels: 0,
|
||||
sample_rate: 0,
|
||||
ended: false,
|
||||
reopened: false,
|
||||
levels: Vec::new(),
|
||||
error: None,
|
||||
});
|
||||
}
|
||||
Ok(Cmd::Pause) => {
|
||||
if let Some(playing) = current.as_mut() {
|
||||
playing.wanted = false;
|
||||
if !playing.paused {
|
||||
let _ = playing.stream.pause();
|
||||
playing.paused = true;
|
||||
}
|
||||
report(&app, playing, false, false, None);
|
||||
}
|
||||
}
|
||||
Ok(Cmd::Resume) => {
|
||||
if let Some(playing) = current.as_mut() {
|
||||
playing.wanted = true;
|
||||
if playing.paused {
|
||||
if playing.stream.play().is_err() {
|
||||
// Left for the tick to rebuild, which now knows
|
||||
// it was meant to be playing.
|
||||
playing.shared.broken.store(true, Ordering::Relaxed);
|
||||
} else {
|
||||
playing.paused = false;
|
||||
}
|
||||
}
|
||||
report(&app, playing, false, false, None);
|
||||
}
|
||||
}
|
||||
Ok(Cmd::Seek(seconds)) => {
|
||||
if let Some(old) = current.take() {
|
||||
let at = seconds.max(0.0).min(old.duration);
|
||||
let paused = !old.wanted;
|
||||
let path = old.path.clone();
|
||||
let gains = old.gains.clone();
|
||||
drop(old);
|
||||
match open(&path, at, &gains, !paused) {
|
||||
Ok(playing) => {
|
||||
report(&app, &playing, false, false, None);
|
||||
current = Some(playing);
|
||||
}
|
||||
Err(e) => fail(&app, e),
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Cmd::Gains(values)) => {
|
||||
if let Some(playing) = current.as_mut() {
|
||||
playing.shared.set_gains(&values);
|
||||
playing.gains = values;
|
||||
}
|
||||
}
|
||||
Err(mpsc::RecvTimeoutError::Timeout) => {
|
||||
current = tick(&app, current);
|
||||
}
|
||||
Err(mpsc::RecvTimeoutError::Disconnected) => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The regular look around: where are we, has the file ended, is the output
|
||||
/// still the one we opened.
|
||||
fn tick(app: &AppHandle, current: Option<Playing>) -> Option<Playing> {
|
||||
let mut playing = current?;
|
||||
|
||||
if playing.shared.drained.load(Ordering::Relaxed) {
|
||||
if playing.shared.unreadable.load(Ordering::Relaxed) {
|
||||
fail(app, format!("{}: stopped reading part way through", playing.path));
|
||||
return None;
|
||||
}
|
||||
let _ = app.emit("bwf://playback", Status {
|
||||
playing: false,
|
||||
seconds: playing.duration,
|
||||
duration: playing.duration,
|
||||
channels: playing.channels,
|
||||
sample_rate: playing.source_rate,
|
||||
ended: true,
|
||||
reopened: false,
|
||||
levels: Vec::new(),
|
||||
error: None,
|
||||
});
|
||||
return None;
|
||||
}
|
||||
|
||||
// The two ways an output goes away underneath a running app: the stream
|
||||
// itself faults, or the default device changes because something was
|
||||
// plugged in, woke up, or was switched in System Settings. Both used to
|
||||
// be unrecoverable because the audio belonged to WebKit. Now the file is
|
||||
// simply reopened where it was, on whatever the output is now.
|
||||
//
|
||||
// A device name that comes back empty is a CoreAudio hiccup, not a new
|
||||
// device, and tearing the stream down for one would put an audible gap in
|
||||
// a take for no reason.
|
||||
let now = current_device_name();
|
||||
let moved = !now.is_empty() && now != playing.device;
|
||||
if playing.shared.broken.load(Ordering::Relaxed) || moved {
|
||||
// A device that enumerates but won't play would otherwise be rebuilt
|
||||
// ten times a second for as long as the app is open: a new header
|
||||
// parse, a new file handle, a new thread and a new audio unit each
|
||||
// time. Backed off, and given up on.
|
||||
if playing.attempts >= REBUILD_LIMIT {
|
||||
fail(app, "the audio output stopped responding".to_string());
|
||||
return None;
|
||||
}
|
||||
let at = playing.shared.seconds(playing.device_rate).min(playing.duration);
|
||||
let path = playing.path.clone();
|
||||
let gains = playing.gains.clone();
|
||||
let wanted = playing.wanted;
|
||||
let attempts = playing.attempts + 1;
|
||||
drop(playing);
|
||||
thread::sleep(Duration::from_millis(120 * attempts as u64));
|
||||
match open(&path, at, &gains, wanted) {
|
||||
Ok(mut fresh) => {
|
||||
fresh.attempts = attempts;
|
||||
report(app, &fresh, false, true, None);
|
||||
return Some(fresh);
|
||||
}
|
||||
Err(e) => {
|
||||
fail(app, e);
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A stream that has played for a while is a stream that works, so the
|
||||
// count of consecutive rebuilds is forgotten.
|
||||
if playing.shared.played.load(Ordering::Relaxed) > playing.device_rate as u64 {
|
||||
playing.attempts = 0;
|
||||
}
|
||||
|
||||
report(app, &playing, false, false, None);
|
||||
Some(playing)
|
||||
}
|
||||
|
||||
fn report(app: &AppHandle, playing: &Playing, ended: bool, reopened: bool, error: Option<String>) {
|
||||
let _ = app.emit("bwf://playback", Status {
|
||||
playing: !playing.paused,
|
||||
seconds: playing.shared.seconds(playing.device_rate).min(playing.duration),
|
||||
duration: playing.duration,
|
||||
channels: playing.channels,
|
||||
sample_rate: playing.source_rate,
|
||||
ended,
|
||||
reopened,
|
||||
levels: playing.shared.take_levels(),
|
||||
error,
|
||||
});
|
||||
}
|
||||
|
||||
fn fail(app: &AppHandle, message: String) {
|
||||
let _ = app.emit("bwf://playback", Status {
|
||||
playing: false,
|
||||
seconds: 0.0,
|
||||
duration: 0.0,
|
||||
channels: 0,
|
||||
sample_rate: 0,
|
||||
ended: false,
|
||||
reopened: false,
|
||||
levels: Vec::new(),
|
||||
error: Some(message),
|
||||
});
|
||||
}
|
||||
|
||||
fn current_device_name() -> String {
|
||||
cpal::default_host()
|
||||
.default_output_device()
|
||||
.and_then(|device| device.name().ok())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Opening one file on the output */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/// Opens a file on the output, already stopped when it isn't wanted playing,
|
||||
/// so a seek or a rebuild while paused doesn't leak a buffer of sound at the
|
||||
/// new position before the pause lands.
|
||||
fn open(path: &str, offset: f64, gains: &[f32], wanted: bool) -> Result<Playing, String> {
|
||||
let mut take = Take::open(path)?;
|
||||
let channels = take.channels();
|
||||
let source_rate = take.sample_rate();
|
||||
let duration = take.seconds();
|
||||
if channels == 0 || source_rate == 0 {
|
||||
return Err(format!("{}: nothing to play", path));
|
||||
}
|
||||
|
||||
let host = cpal::default_host();
|
||||
let device = host
|
||||
.default_output_device()
|
||||
.ok_or_else(|| "no audio output to play through".to_string())?;
|
||||
let device_name = device.name().unwrap_or_default();
|
||||
|
||||
// The file's own rate if the device will take it, which on a location
|
||||
// card and a Mac is nearly always the case, so nearly always no
|
||||
// resampling at all.
|
||||
let config = pick_config(&device, source_rate)?;
|
||||
let device_rate = config.sample_rate().0;
|
||||
let out_channels = config.channels() as usize;
|
||||
let format = config.sample_format();
|
||||
let stream_config: cpal::StreamConfig = config.into();
|
||||
|
||||
let start_frame = (offset.max(0.0) * source_rate as f64).round() as u64;
|
||||
take.seek(start_frame)?;
|
||||
|
||||
let shared = Arc::new(Shared {
|
||||
gains: (0..channels).map(|_| AtomicU32::new(1f32.to_bits())).collect(),
|
||||
meters: (0..channels).map(|_| AtomicU32::new(0f32.to_bits())).collect(),
|
||||
played: AtomicU64::new(0),
|
||||
start: AtomicU64::new(
|
||||
(offset.max(0.0) * device_rate as f64).round() as u64,
|
||||
),
|
||||
finished: AtomicBool::new(false),
|
||||
unreadable: AtomicBool::new(false),
|
||||
drained: AtomicBool::new(false),
|
||||
broken: AtomicBool::new(false),
|
||||
});
|
||||
shared.set_gains(gains);
|
||||
|
||||
let (blocks_tx, blocks_rx) = mpsc::sync_channel::<Vec<f32>>(BLOCKS);
|
||||
// Bounded, so returning a spent block from the audio callback is a
|
||||
// fixed-size store rather than a queue that occasionally allocates.
|
||||
let (spare_tx, spare_rx) = mpsc::sync_channel::<Vec<f32>>(BLOCKS + 1);
|
||||
let halt = Arc::new(AtomicBool::new(false));
|
||||
|
||||
spawn_reader(
|
||||
take,
|
||||
channels as usize,
|
||||
source_rate,
|
||||
device_rate,
|
||||
blocks_tx,
|
||||
spare_rx,
|
||||
Arc::clone(&shared),
|
||||
Arc::clone(&halt),
|
||||
);
|
||||
|
||||
let stream = build_stream(
|
||||
&device,
|
||||
&stream_config,
|
||||
format,
|
||||
channels as usize,
|
||||
out_channels,
|
||||
blocks_rx,
|
||||
spare_tx,
|
||||
Arc::clone(&shared),
|
||||
)?;
|
||||
if wanted {
|
||||
stream.play().map_err(|e| format!("the output refused to start: {}", e))?;
|
||||
}
|
||||
|
||||
Ok(Playing {
|
||||
path: path.to_string(),
|
||||
stream,
|
||||
shared,
|
||||
halt,
|
||||
channels,
|
||||
source_rate,
|
||||
device_rate,
|
||||
duration,
|
||||
attempts: 0,
|
||||
wanted,
|
||||
paused: !wanted,
|
||||
device: device_name,
|
||||
gains: gains.to_vec(),
|
||||
})
|
||||
}
|
||||
|
||||
impl Drop for Playing {
|
||||
fn drop(&mut self) {
|
||||
self.halt.store(true, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
/// The output config to open: the file's own rate when the device supports
|
||||
/// it, the device's preference otherwise.
|
||||
fn pick_config(
|
||||
device: &cpal::Device,
|
||||
wanted: u32,
|
||||
) -> Result<cpal::SupportedStreamConfig, String> {
|
||||
let default = device
|
||||
.default_output_config()
|
||||
.map_err(|e| format!("no usable audio output: {}", e))?;
|
||||
if default.sample_rate().0 == wanted {
|
||||
return Ok(default);
|
||||
}
|
||||
if let Ok(ranges) = device.supported_output_configs() {
|
||||
for range in ranges {
|
||||
let matches_format = range.sample_format() == default.sample_format();
|
||||
let holds_rate = range.min_sample_rate().0 <= wanted && wanted <= range.max_sample_rate().0;
|
||||
if matches_format && holds_rate {
|
||||
return Ok(range.with_sample_rate(cpal::SampleRate(wanted)));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(default)
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* The reader thread */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn spawn_reader(
|
||||
mut take: Take,
|
||||
channels: usize,
|
||||
source_rate: u32,
|
||||
device_rate: u32,
|
||||
blocks: SyncSender<Vec<f32>>,
|
||||
spare: Receiver<Vec<f32>>,
|
||||
shared: Arc<Shared>,
|
||||
halt: Arc<AtomicBool>,
|
||||
) {
|
||||
thread::Builder::new()
|
||||
.name("bwf-audio-read".to_string())
|
||||
.spawn(move || {
|
||||
let ratio = source_rate as f64 / device_rate as f64;
|
||||
let straight = (ratio - 1.0).abs() < 1e-9;
|
||||
let mut input = vec![0f32; BLOCK * channels];
|
||||
let mut carry: Vec<f32> = Vec::new();
|
||||
let mut position = 0f64;
|
||||
|
||||
loop {
|
||||
if halt.load(Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
let got = match take.read(&mut input, BLOCK) {
|
||||
Ok(got) => got,
|
||||
Err(_) => {
|
||||
// A card pulled mid-take, or a file that lied about
|
||||
// its length. It didn't end, it broke, and saying
|
||||
// "finished" would be the app inventing a clean stop.
|
||||
shared.unreadable.store(true, Ordering::Relaxed);
|
||||
shared.finished.store(true, Ordering::Relaxed);
|
||||
return;
|
||||
}
|
||||
};
|
||||
if got == 0 {
|
||||
shared.finished.store(true, Ordering::Relaxed);
|
||||
return;
|
||||
}
|
||||
|
||||
let mut out = spare.try_recv().unwrap_or_default();
|
||||
out.clear();
|
||||
if straight {
|
||||
out.extend_from_slice(&input[..got * channels]);
|
||||
} else {
|
||||
resample(
|
||||
&input[..got * channels],
|
||||
channels,
|
||||
ratio,
|
||||
&mut carry,
|
||||
&mut position,
|
||||
&mut out,
|
||||
);
|
||||
}
|
||||
|
||||
if out.is_empty() {
|
||||
continue;
|
||||
}
|
||||
// Blocking, not polling: a paused transport leaves the queue
|
||||
// full, and a thread waking two hundred times a second to
|
||||
// find that out is a thread nobody asked for. The stream
|
||||
// being dropped disconnects the channel, which is the way
|
||||
// out that matters.
|
||||
if blocks.send(out).is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
|
||||
/// Linear interpolation from the file's rate to the device's.
|
||||
///
|
||||
/// Kept as a plain function over plain slices so the test harness can run the
|
||||
/// same arithmetic: this is the one part of playback that can be wrong in a
|
||||
/// way you'd hear rather than a way that fails outright.
|
||||
///
|
||||
/// `carry` holds the last source frame from the previous call, so a block
|
||||
/// boundary interpolates across itself rather than restarting; `position` is
|
||||
/// where we are between frames, in source frames.
|
||||
pub fn resample(
|
||||
input: &[f32],
|
||||
channels: usize,
|
||||
ratio: f64,
|
||||
carry: &mut Vec<f32>,
|
||||
position: &mut f64,
|
||||
out: &mut Vec<f32>,
|
||||
) {
|
||||
if channels == 0 {
|
||||
return;
|
||||
}
|
||||
let mut work: Vec<f32> = Vec::with_capacity(carry.len() + input.len());
|
||||
work.extend_from_slice(carry);
|
||||
work.extend_from_slice(input);
|
||||
let frames = work.len() / channels;
|
||||
if frames < 2 {
|
||||
*carry = work;
|
||||
return;
|
||||
}
|
||||
|
||||
// `position` was left relative to the frame that is now work[0], so it
|
||||
// needs no rebasing here.
|
||||
let mut at = *position;
|
||||
while (at.floor() as usize) + 1 < frames {
|
||||
let index = at.floor() as usize;
|
||||
let fraction = (at - index as f64) as f32;
|
||||
let here = index * channels;
|
||||
let next = here + channels;
|
||||
for channel in 0..channels {
|
||||
let a = work[here + channel];
|
||||
let b = work[next + channel];
|
||||
out.push(a + (b - a) * fraction);
|
||||
}
|
||||
at += ratio;
|
||||
}
|
||||
|
||||
let keep = (at.floor() as usize).min(frames - 1);
|
||||
*carry = work[keep * channels..(keep + 1) * channels].to_vec();
|
||||
*position = at - keep as f64;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* The audio callback */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/// How many channels a meter covers. Past this, audio still plays.
|
||||
const METER_CHANNELS: usize = 64;
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn build_stream(
|
||||
device: &cpal::Device,
|
||||
config: &cpal::StreamConfig,
|
||||
format: cpal::SampleFormat,
|
||||
channels: usize,
|
||||
out_channels: usize,
|
||||
blocks: Receiver<Vec<f32>>,
|
||||
spare: SyncSender<Vec<f32>>,
|
||||
shared: Arc<Shared>,
|
||||
) -> Result<cpal::Stream, String> {
|
||||
let faulted = Arc::clone(&shared);
|
||||
let on_error = move |_| {
|
||||
faulted.broken.store(true, Ordering::Relaxed);
|
||||
};
|
||||
|
||||
let mut pump = Pump {
|
||||
channels,
|
||||
out_channels,
|
||||
blocks,
|
||||
spare,
|
||||
shared: Arc::clone(&shared),
|
||||
current: None,
|
||||
cursor: 0,
|
||||
};
|
||||
|
||||
let stream = match format {
|
||||
cpal::SampleFormat::F32 => device.build_output_stream(
|
||||
config,
|
||||
move |data: &mut [f32], _: &cpal::OutputCallbackInfo| pump.fill(data, |v| v),
|
||||
on_error,
|
||||
None,
|
||||
),
|
||||
cpal::SampleFormat::I16 => device.build_output_stream(
|
||||
config,
|
||||
move |data: &mut [i16], _: &cpal::OutputCallbackInfo| {
|
||||
pump.fill(data, |v| (v * 32767.0) as i16)
|
||||
},
|
||||
on_error,
|
||||
None,
|
||||
),
|
||||
other => {
|
||||
return Err(format!("this output wants {:?} samples, which we don't write", other))
|
||||
}
|
||||
};
|
||||
|
||||
stream.map_err(|e| format!("could not open the audio output: {}", e))
|
||||
}
|
||||
|
||||
/// Feeds the device: takes blocks from the reader, sums the channels the
|
||||
/// person has left switched on, and writes the result to every output.
|
||||
struct Pump {
|
||||
channels: usize,
|
||||
out_channels: usize,
|
||||
blocks: Receiver<Vec<f32>>,
|
||||
spare: SyncSender<Vec<f32>>,
|
||||
shared: Arc<Shared>,
|
||||
current: Option<Vec<f32>>,
|
||||
cursor: usize,
|
||||
}
|
||||
|
||||
impl Pump {
|
||||
fn fill<S: Copy, F: Fn(f32) -> S>(&mut self, data: &mut [S], convert: F) {
|
||||
if self.out_channels == 0 {
|
||||
return;
|
||||
}
|
||||
let frames = data.len() / self.out_channels;
|
||||
let mut written = 0u64;
|
||||
// On the stack, so the callback allocates nothing. A file with more
|
||||
// channels than this still plays; only the channels past the end go
|
||||
// unmetered, and no field recorder writes 64 tracks to one file.
|
||||
let mut peaks = [0f32; METER_CHANNELS];
|
||||
|
||||
for frame in 0..frames {
|
||||
if !self.ensure() {
|
||||
// Nothing to play: silence, and say so if the file is done.
|
||||
for channel in 0..self.out_channels {
|
||||
data[frame * self.out_channels + channel] = convert(0.0);
|
||||
}
|
||||
if self.shared.finished.load(Ordering::Relaxed) {
|
||||
self.shared.drained.store(true, Ordering::Relaxed);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
let block = self.current.as_ref().unwrap();
|
||||
let base = self.cursor * self.channels;
|
||||
let mut sum = 0f32;
|
||||
for channel in 0..self.channels {
|
||||
let raw = block[base + channel];
|
||||
if channel < METER_CHANNELS {
|
||||
let magnitude = raw.abs();
|
||||
if magnitude > peaks[channel] {
|
||||
peaks[channel] = magnitude;
|
||||
}
|
||||
}
|
||||
sum += raw * self.shared.gain(channel);
|
||||
}
|
||||
// Summing several channels can pass full scale, exactly as the
|
||||
// old Web Audio graph could. Clamped rather than wrapped: this is
|
||||
// a monitor path, and a wrap sounds like the file is broken.
|
||||
let sample = convert(sum.clamp(-1.0, 1.0));
|
||||
for channel in 0..self.out_channels {
|
||||
data[frame * self.out_channels + channel] = sample;
|
||||
}
|
||||
self.cursor += 1;
|
||||
written += 1;
|
||||
}
|
||||
|
||||
self.shared.played.fetch_add(written, Ordering::Relaxed);
|
||||
|
||||
// One atomic per channel for the whole callback. Doing this per sample
|
||||
// would put a compare-exchange loop in the hot path for no benefit: a
|
||||
// meter reads at tens of hertz, not at forty-eight thousand.
|
||||
for channel in 0..self.channels.min(METER_CHANNELS) {
|
||||
if peaks[channel] > 0.0 {
|
||||
self.shared.raise(channel, peaks[channel]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Makes sure there's a block with something left in it.
|
||||
fn ensure(&mut self) -> bool {
|
||||
loop {
|
||||
if let Some(block) = self.current.as_ref() {
|
||||
// A whole frame, not one sample: the loop below reads every
|
||||
// channel of it, and an index past the end here is a panic
|
||||
// unwinding out of a C callback rather than an error.
|
||||
if (self.cursor + 1) * self.channels <= block.len() {
|
||||
return true;
|
||||
}
|
||||
let spent = self.current.take().unwrap();
|
||||
let _ = self.spare.try_send(spent);
|
||||
self.cursor = 0;
|
||||
}
|
||||
match self.blocks.try_recv() {
|
||||
Ok(block) => {
|
||||
self.current = Some(block);
|
||||
self.cursor = 0;
|
||||
}
|
||||
Err(TryRecvError::Empty) | Err(TryRecvError::Disconnected) => return false,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "BWF Analyser",
|
||||
"version": "1.5.1",
|
||||
"identifier": "com.vincentrozenberg.bwf-analyser",
|
||||
"build": {
|
||||
"frontendDist": "../dist"
|
||||
},
|
||||
"app": {
|
||||
"withGlobalTauri": true,
|
||||
"windows": [
|
||||
{
|
||||
"label": "main",
|
||||
"title": "BWF Analyser",
|
||||
"width": 1360,
|
||||
"height": 900,
|
||||
"minWidth": 1040,
|
||||
"minHeight": 560,
|
||||
"resizable": true,
|
||||
"dragDropEnabled": true,
|
||||
"hiddenTitle": true
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": null
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": [
|
||||
"app"
|
||||
],
|
||||
"category": "public.app-category.music",
|
||||
"copyright": "MIT licensed",
|
||||
"shortDescription": "Broadcast Wave metadata analyser",
|
||||
"longDescription": "Reads scene, take, timecode and iXML metadata from a folder of BWF/WAV recordings, and writes edits back to the original files. Everything stays on this machine.",
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns"
|
||||
],
|
||||
"macOS": {
|
||||
"minimumSystemVersion": "10.15"
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+752
@@ -0,0 +1,752 @@
|
||||
{
|
||||
"name": "BWF Analyser",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"dependencies": {
|
||||
"jsdom": "^30.0.1",
|
||||
"jspdf": "^4.2.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@asamuzakjp/css-color": {
|
||||
"version": "6.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-6.0.7.tgz",
|
||||
"integrity": "sha512-vC/bk1Lz7Tn/EfU9/apOTBk80/8dyGyWMowPoV1tJ52muDGsDqt2HPT2klrFUiY60MQmQv9q8yIht15JnBgDGw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@csstools/css-calc": "^3.3.0",
|
||||
"@csstools/css-color-parser": "^4.1.10",
|
||||
"@csstools/css-parser-algorithms": "^4.0.0",
|
||||
"@csstools/css-tokenizer": "^4.0.0",
|
||||
"lru-cache": "^11.5.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^22.13.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@asamuzakjp/dom-selector": {
|
||||
"version": "8.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-8.3.2.tgz",
|
||||
"integrity": "sha512-93Z1N+BQNXysodoicpOIyNh2drHfz/CTf9nnT0FEx72GJcIiwgydD7tGAr78j41LsYn3hlRn+LdGPuBLn1Bl8Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bidi-js": "^1.0.3",
|
||||
"css-tree": "^3.2.1",
|
||||
"is-potential-custom-element-name": "^1.0.1",
|
||||
"lru-cache": "^11.5.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^22.13.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/runtime": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
|
||||
"integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@bramus/specificity": {
|
||||
"version": "2.4.2",
|
||||
"resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz",
|
||||
"integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"css-tree": "^3.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"specificity": "bin/cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/color-helpers": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.0.tgz",
|
||||
"integrity": "sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT-0",
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-calc": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.3.0.tgz",
|
||||
"integrity": "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@csstools/css-parser-algorithms": "^4.0.0",
|
||||
"@csstools/css-tokenizer": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-color-parser": {
|
||||
"version": "4.1.10",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.10.tgz",
|
||||
"integrity": "sha512-UZhQLIUyJaaMepqehrCODwCg2KW25vFvLWBmqYFaPclYvvxzj/sG8LBOhBFCp11i9uE7t1EyS+RAoV9tztPFyw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@csstools/color-helpers": "^6.1.0",
|
||||
"@csstools/css-calc": "^3.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@csstools/css-parser-algorithms": "^4.0.0",
|
||||
"@csstools/css-tokenizer": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-parser-algorithms": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz",
|
||||
"integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@csstools/css-tokenizer": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-syntax-patches-for-csstree": {
|
||||
"version": "1.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.7.tgz",
|
||||
"integrity": "sha512-fQ+05118eQS1cofO3aJpB5efgpBZMvIzwr/sbC8kDLVA5XLG8q1kJV5yzrUAI1f7lvhPnm8fgIjzFB8/O/5Dig==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT-0",
|
||||
"peerDependencies": {
|
||||
"css-tree": "^3.2.1"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"css-tree": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-tokenizer": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz",
|
||||
"integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@exodus/bytes": {
|
||||
"version": "1.15.1",
|
||||
"resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz",
|
||||
"integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@noble/hashes": "^1.8.0 || ^2.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@noble/hashes": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@types/pako": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz",
|
||||
"integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/raf": {
|
||||
"version": "3.4.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz",
|
||||
"integrity": "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@types/trusted-types": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/base64-arraybuffer": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz",
|
||||
"integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">= 0.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bidi-js": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
|
||||
"integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"require-from-string": "^2.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/canvg": {
|
||||
"version": "3.0.11",
|
||||
"resolved": "https://registry.npmjs.org/canvg/-/canvg-3.0.11.tgz",
|
||||
"integrity": "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.12.5",
|
||||
"@types/raf": "^3.4.0",
|
||||
"core-js": "^3.8.3",
|
||||
"raf": "^3.4.1",
|
||||
"regenerator-runtime": "^0.13.7",
|
||||
"rgbcolor": "^1.0.1",
|
||||
"stackblur-canvas": "^2.0.0",
|
||||
"svg-pathdata": "^6.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/core-js": {
|
||||
"version": "3.50.0",
|
||||
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.50.0.tgz",
|
||||
"integrity": "sha512-BRWgOLKkFeCgRudR6zrs8p9XJZcE14grzKMMssoYrk6krtuEZ7MTKPIY5RzOnqsEKIR9kst7wNzphttraT+Yqw==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": "*"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/core-js"
|
||||
}
|
||||
},
|
||||
"node_modules/css-line-break": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz",
|
||||
"integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"utrie": "^1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/css-tree": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
|
||||
"integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mdn-data": "2.27.1",
|
||||
"source-map-js": "^1.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/data-urls": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
|
||||
"integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"whatwg-mimetype": "^5.0.0",
|
||||
"whatwg-url": "^16.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/data-urls/node_modules/whatwg-url": {
|
||||
"version": "16.0.1",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz",
|
||||
"integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@exodus/bytes": "^1.11.0",
|
||||
"tr46": "^6.0.0",
|
||||
"webidl-conversions": "^8.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/decimal.js": {
|
||||
"version": "10.6.0",
|
||||
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
|
||||
"integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dompurify": {
|
||||
"version": "3.4.13",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.13.tgz",
|
||||
"integrity": "sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ==",
|
||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||
"optional": true,
|
||||
"optionalDependencies": {
|
||||
"@types/trusted-types": "^2.0.7"
|
||||
}
|
||||
},
|
||||
"node_modules/entities": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz",
|
||||
"integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==",
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-png": {
|
||||
"version": "6.4.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-png/-/fast-png-6.4.0.tgz",
|
||||
"integrity": "sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/pako": "^2.0.3",
|
||||
"iobuffer": "^5.3.2",
|
||||
"pako": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fflate": {
|
||||
"version": "0.8.3",
|
||||
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
|
||||
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/html-encoding-sniffer": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz",
|
||||
"integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@exodus/bytes": "^1.6.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/html2canvas": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz",
|
||||
"integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"css-line-break": "^2.1.0",
|
||||
"text-segmentation": "^1.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/iobuffer": {
|
||||
"version": "5.4.0",
|
||||
"resolved": "https://registry.npmjs.org/iobuffer/-/iobuffer-5.4.0.tgz",
|
||||
"integrity": "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/is-potential-custom-element-name": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
|
||||
"integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/jsdom": {
|
||||
"version": "30.0.1",
|
||||
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.0.1.tgz",
|
||||
"integrity": "sha512-52v7mUVUfNQVYYqE1lcdaymWL0njO7lTLUog6ZvW2U5KsbiLk/GnZlVJ+qx0xfNJZ6Gn+KSpPNE52vurbxZwrA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@asamuzakjp/css-color": "^6.0.5",
|
||||
"@asamuzakjp/dom-selector": "^8.3.0",
|
||||
"@bramus/specificity": "^2.4.2",
|
||||
"@csstools/css-syntax-patches-for-csstree": "^1.1.7",
|
||||
"@exodus/bytes": "^1.15.1",
|
||||
"css-tree": "^3.2.1",
|
||||
"data-urls": "^7.0.0",
|
||||
"decimal.js": "^10.6.0",
|
||||
"html-encoding-sniffer": "^6.0.0",
|
||||
"is-potential-custom-element-name": "^1.0.1",
|
||||
"lru-cache": "^11.5.2",
|
||||
"parse5": "^8.0.1",
|
||||
"saxes": "^6.0.0",
|
||||
"symbol-tree": "^3.2.4",
|
||||
"tough-cookie": "^6.0.2",
|
||||
"undici": "^8.9.0",
|
||||
"w3c-xmlserializer": "^5.0.0",
|
||||
"webidl-conversions": "^8.0.1",
|
||||
"whatwg-mimetype": "^5.0.0",
|
||||
"whatwg-url": "^17.1.0",
|
||||
"xml-name-validator": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"canvas": "^3.2.3"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"canvas": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/jspdf": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/jspdf/-/jspdf-4.2.1.tgz",
|
||||
"integrity": "sha512-YyAXyvnmjTbR4bHQRLzex3CuINCDlQnBqoSYyjJwTP2x9jDLuKDzy7aKUl0hgx3uhcl7xzg32agn5vlie6HIlQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.28.6",
|
||||
"fast-png": "^6.2.0",
|
||||
"fflate": "^0.8.1"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"canvg": "^3.0.11",
|
||||
"core-js": "^3.6.0",
|
||||
"dompurify": "^3.3.1",
|
||||
"html2canvas": "^1.0.0-rc.5"
|
||||
}
|
||||
},
|
||||
"node_modules/lru-cache": {
|
||||
"version": "11.5.2",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz",
|
||||
"integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/mdn-data": {
|
||||
"version": "2.27.1",
|
||||
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz",
|
||||
"integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==",
|
||||
"license": "CC0-1.0"
|
||||
},
|
||||
"node_modules/pako": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/pako/-/pako-2.2.0.tgz",
|
||||
"integrity": "sha512-zJq6RP/5q+TO2OpFV3FHzlPnFjmkb7Nc99a5SNjJE+uu/PkpChs+NIZSSzbBoD+6kjiISXjfYdwj1ZRQ81dz/w==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/puzrin"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/nodeca"
|
||||
}
|
||||
],
|
||||
"license": "(MIT AND Zlib)"
|
||||
},
|
||||
"node_modules/parse5": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz",
|
||||
"integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"entities": "^8.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/inikulin/parse5?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/performance-now": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz",
|
||||
"integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/punycode": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/raf": {
|
||||
"version": "3.4.1",
|
||||
"resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz",
|
||||
"integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"performance-now": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/regenerator-runtime": {
|
||||
"version": "0.13.11",
|
||||
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
|
||||
"integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/require-from-string": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
||||
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/rgbcolor": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/rgbcolor/-/rgbcolor-1.0.1.tgz",
|
||||
"integrity": "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==",
|
||||
"license": "MIT OR SEE LICENSE IN FEEL-FREE.md",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">= 0.8.15"
|
||||
}
|
||||
},
|
||||
"node_modules/saxes": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
|
||||
"integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"xmlchars": "^2.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=v12.22.7"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/stackblur-canvas": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz",
|
||||
"integrity": "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=0.1.14"
|
||||
}
|
||||
},
|
||||
"node_modules/svg-pathdata": {
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/svg-pathdata/-/svg-pathdata-6.0.3.tgz",
|
||||
"integrity": "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/symbol-tree": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
|
||||
"integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/text-segmentation": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz",
|
||||
"integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"utrie": "^1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/tldts": {
|
||||
"version": "7.4.10",
|
||||
"resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.10.tgz",
|
||||
"integrity": "sha512-GgouD1B+sWwvkaEq8vXC15DjQitxbvs12oIXELpconwm+Tg3zfcEv4jgzq3vtKverDXsg3VI8aRgNL2Nra0Iog==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tldts-core": "^7.4.10"
|
||||
},
|
||||
"bin": {
|
||||
"tldts": "bin/cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/tldts-core": {
|
||||
"version": "7.4.10",
|
||||
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.10.tgz",
|
||||
"integrity": "sha512-KnQjp53ZekKgm/r3l+u8kJGGzYgrWdP8+Mql7a4vijh2WE0IrZWspQj/TpTxDho/YxO+AnOZnIjQcCD+q6iJsw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tough-cookie": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz",
|
||||
"integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"tldts": "^7.0.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/tr46": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz",
|
||||
"integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"punycode": "^2.3.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/undici": {
|
||||
"version": "8.10.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-8.10.0.tgz",
|
||||
"integrity": "sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=22.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/utrie": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz",
|
||||
"integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"base64-arraybuffer": "^1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/w3c-xmlserializer": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
|
||||
"integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"xml-name-validator": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/webidl-conversions": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
|
||||
"integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==",
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/whatwg-mimetype": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz",
|
||||
"integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/whatwg-url": {
|
||||
"version": "17.1.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.1.0.tgz",
|
||||
"integrity": "sha512-3GeworPmc2ZfEEHP7lEbUfBX/L75wdEsi0rLNhXcXxnoN5jyq0SL5gCy06SGW2cyTIZdTvWIDQNQoza++vKeaw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@exodus/bytes": "^1.15.1",
|
||||
"tr46": "^6.0.0",
|
||||
"webidl-conversions": "^8.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^22.14.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/xml-name-validator": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
|
||||
"integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/xmlchars": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
|
||||
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
|
||||
"license": "MIT"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"dependencies": {
|
||||
"jsdom": "^30.0.1",
|
||||
"jspdf": "^4.2.1"
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user