diff --git a/README.md b/README.md index 119e01b..f9b2354 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,7 @@ pnpm install ./scripts/verify.sh # fmt, clippy, tests, typecheck, vitest pnpm tauri dev # run the app pnpm dev:mock # the UI alone in a browser, with synthetic tracks (open /?demo=1) + # start-up recovery banners: /?demo=recovery, /?demo=recovery-failed, /?demo=recovery-finished cargo run --release -p sc-cli -- analyze # loudness, BPM, meter and bar 1 cargo run --release -p sc-cli -- plan # what processing would do to each file cargo run --release -p sc-cli -- plan --batch-mode prepare # ... and what exporting would write diff --git a/crates/sc-core/src/ipc/recovery.rs b/crates/sc-core/src/ipc/recovery.rs index 33a6d9c..db68064 100644 --- a/crates/sc-core/src/ipc/recovery.rs +++ b/crates/sc-core/src/ipc/recovery.rs @@ -67,4 +67,7 @@ pub struct PendingChange { pub path: String, /// Why it was left (volume not mounted, busy, cleanup failed). pub reason: String, + /// It was writing a copy into a folder (`path` is the copy), not changing the file in place: + /// until its rename the copy is not there, or is an empty file holding its name. + pub to_folder: bool, } diff --git a/crates/sc-engine/src/txn.rs b/crates/sc-engine/src/txn.rs index b347588..474276d 100644 --- a/crates/sc-engine/src/txn.rs +++ b/crates/sc-engine/src/txn.rs @@ -274,6 +274,7 @@ pub fn recovery_status(report: &RecoveryReport) -> RecoveryStatus { txn: p.txn.clone(), path: p.path.display().to_string(), reason: p.reason.clone(), + to_folder: p.to_folder, }) .collect(), } diff --git a/crates/sc-engine/src/txn/gate/tests.rs b/crates/sc-engine/src/txn/gate/tests.rs index 00101ac..ce9648d 100644 --- a/crates/sc-engine/src/txn/gate/tests.rs +++ b/crates/sc-engine/src/txn/gate/tests.rs @@ -87,6 +87,7 @@ fn updates_at_once_lose_nothing() { txn: format!("t{n}"), path: format!("/p{n}"), reason: "gone".to_owned(), + to_folder: false, }; let gate = Arc::new(RecoveryGate::new(RecoveryStatus::Finished { recovered: Vec::new(), diff --git a/crates/sc-io/src/txn/forget.rs b/crates/sc-io/src/txn/forget.rs index e44e7ce..590c38f 100644 --- a/crates/sc-io/src/txn/forget.rs +++ b/crates/sc-io/src/txn/forget.rs @@ -2,8 +2,9 @@ //! leaving pending (its volume will never be mounted again, its folder was moved away). //! //! A `forgotten` line ends it, so recovery skips it from then on. Nothing is deleted: the -//! backup of the original, its temp copy and the transaction's temp file stay where they are, -//! and [`Forgotten::kept`] lists the ones that may still exist so the user can find them. Only +//! backup of the original, its temp copy, the transaction's temp file and the empty placeholder +//! a copy to an exFAT or FAT folder holds its name with stay where they are, and +//! [`Forgotten::kept`] lists the ones that may still exist so the user can find them. Only //! a transaction that has not ended can be forgotten, and not while it runs. use std::path::{Path, PathBuf}; @@ -11,7 +12,7 @@ use std::path::{Path, PathBuf}; use sc_core::{Error, Result}; use super::fsx::exists; -use super::journal::{Journal, Line, State, TxnKind, TxnLock, is_txn_id}; +use super::journal::{Entry, Journal, Line, State, TxnKind, TxnLock, is_txn_id}; /// What [`forget`] did. #[derive(Debug, Clone, PartialEq, Eq)] @@ -94,6 +95,9 @@ pub fn forget(backup_root: &Path, txn: &str) -> Result { kept.push(path); } } + if placeholder_may_remain(&entry) && !kept.contains(&entry.path) { + kept.push(entry.path.clone()); + } let mut line = Line::new(txn, State::Forgotten); line.notes = kept .iter() @@ -111,6 +115,20 @@ pub fn forget(backup_root: &Path, txn: &str) -> Result { }) } +/// Whether a copy to a folder may have left the empty placeholder that holds its name on a +/// volume without an exclusive rename (exFAT, FAT32; see `fsx::rename_noreplace`): it had not +/// renamed, and its target is an empty file, or sits in a folder that cannot be reached now. A +/// later export to that folder would find a file of that name that is not SoundCheck's. +fn placeholder_may_remain(e: &Entry) -> bool { + if e.kind != TxnKind::ToFolder || e.reached >= State::Renamed { + return false; + } + if e.path.parent().is_some_and(|d| !d.is_dir()) { + return true; + } + std::fs::symlink_metadata(&e.path).is_ok_and(|m| m.is_file() && m.len() == 0) +} + /// Whether `path` exists, or its folder cannot be reached so it cannot be known. fn may_exist(path: &Path) -> bool { exists(path) || path.parent().is_some_and(|d| !d.is_dir()) diff --git a/crates/sc-io/src/txn/forget/tests.rs b/crates/sc-io/src/txn/forget/tests.rs index de2d950..c0a855d 100644 --- a/crates/sc-io/src/txn/forget/tests.rs +++ b/crates/sc-io/src/txn/forget/tests.rs @@ -51,6 +51,7 @@ fn a_pending_change_is_forgotten_and_recovery_skips_it() { let s = scene(); let report = recover(&s.root).expect("recovery runs"); assert_eq!(report.pending.len(), 1, "unreachable, so pending"); + assert!(!report.pending[0].to_folder, "it changed the file in place"); let f = forget(&s.root, ID).expect("forgotten"); assert_eq!(f.path, s.target); @@ -133,3 +134,66 @@ fn an_id_that_is_a_path_touches_nothing() { assert!(TxnLock::try_acquire(&s.journal, "../../victim").is_err()); assert!(victim.exists()); } +/// A journal in `root` holding a copy into `/a.wav` that reached `reached`. +fn copy_to(root: &std::path::Path, dir: &std::path::Path, reached: State) -> (Journal, PathBuf) { + let journal = Journal::open(root).expect("journal"); + let target = dir.join("a.wav"); + let mut planned = Line::new(ID, State::Planned); + planned.kind = Some(TxnKind::ToFolder); + planned.path = Some(target.clone()); + planned.source = Some(root.join("a.wav")); + planned.temp = Some(dir.join(".a.wav.soundcheck-tmp-t")); + planned.output_bytes = Some(4096); + journal.append(&planned).expect("planned"); + for state in [State::TempWritten, State::Verified, State::Renamed] { + if state <= reached { + journal.append(&Line::new(ID, state)).expect("step"); + } + } + (journal, target) +} + +#[test] +fn a_copy_to_an_unmounted_stick_lists_the_placeholder_it_may_have_left() { + let dir = tempfile::tempdir().expect("temp dir"); + let base = dir.path().canonicalize().expect("resolves"); + let root = base.join("backups"); + // Whether the empty file is on the stick cannot be known while it is away, so it is listed. + let gone = base.join("Volumes/STICK/Crate"); + let (_journal, target) = copy_to(&root, &gone, State::Verified); + let report = recover(&root).expect("recovery runs"); + let pending = report.pending.first().expect("unreachable, so pending"); + assert!(pending.to_folder, "recovery says it was a copy"); + let f = forget(&root, ID).expect("forgotten"); + assert!(f.kept.contains(&target), "{:?}", f.kept); + assert_eq!(f.backup, None, "a copy makes no backup"); +} + +#[test] +fn an_empty_placeholder_is_listed_and_kept_but_a_real_file_is_not() { + let dir = tempfile::tempdir().expect("temp dir"); + let base = dir.path().canonicalize().expect("resolves"); + let folder = base.join("Crate"); + std::fs::create_dir_all(&folder).expect("folder"); + + let root = base.join("backups"); + let (_journal, target) = copy_to(&root, &folder, State::Verified); + std::fs::write(&target, b"").expect("placeholder"); + let f = forget(&root, ID).expect("forgotten"); + assert!(f.kept.contains(&target), "{:?}", f.kept); + assert!(target.exists(), "nothing deleted"); + + // A file of that name with something in it is not the change's placeholder. + let root = base.join("backups-2"); + let (_journal, target) = copy_to(&root, &folder, State::Verified); + std::fs::write(&target, b"someone else's").expect("other file"); + let f = forget(&root, ID).expect("forgotten"); + assert!(!f.kept.contains(&target), "{:?}", f.kept); + + // Past the rename, the file there is the copy itself, not a placeholder. + let root = base.join("backups-3"); + let (_journal, target) = copy_to(&root, &folder, State::Renamed); + std::fs::write(&target, b"").expect("empty"); + let f = forget(&root, ID).expect("forgotten"); + assert!(!f.kept.contains(&target), "{:?}", f.kept); +} diff --git a/crates/sc-io/src/txn/recover.rs b/crates/sc-io/src/txn/recover.rs index 98179f3..a3d96fd 100644 --- a/crates/sc-io/src/txn/recover.rs +++ b/crates/sc-io/src/txn/recover.rs @@ -63,6 +63,8 @@ pub struct Pending { pub path: PathBuf, /// Why it was left (running, folder not reachable, cleanup failed). pub reason: String, + /// It was writing a copy into a folder, not changing the file in place. + pub to_folder: bool, } /// What [`recover`] did. @@ -119,6 +121,7 @@ pub fn recover(backup_root: &Path) -> Result { txn: first.txn.clone(), path: first.path.clone(), reason, + to_folder: first.kind == TxnKind::ToFolder, }; let lock = match TxnLock::try_acquire(&journal, &first.txn) { Ok(Some(lock)) => lock, diff --git a/docs/PRODUCT.md b/docs/PRODUCT.md index db46696..a613743 100644 --- a/docs/PRODUCT.md +++ b/docs/PRODUCT.md @@ -20,8 +20,8 @@ SoundCheck levels a DJ library to one loudness and makes new tracks start exactl 2. **Loudness fix** behind a top-level **DJ | Streaming** switch. DJ: one fixed, persisted **DJ target** on S-P95 (default -11 until measured; "Calibrate from my library" sets it once) and **Club hot** -8. Streaming: Spotify -14, Apple -16 on Integrated. Targets can be set from -30 to -4 LUFS and ceilings from -6 to 0 dBTP. Gain only, ceiling-capped (-0.5 dBTP DJ, -1.0 streaming), "Short by X LU" when headroom runs out; MP3 via lossless `global_gain` in 1.5 dB steps with the residual shown. Per-track override. 3. **Grid inspect and fix** per track (`Enter` or a double-click on an analysed row opens it, `Esc` returns with the row selected): waveform at beat zoom auto-located to bar 1 (the original in grey, the planned gain lighter inside it), bar ruler numbering every beat or pulse when zoomed in (9/8 2+2+2+3 reads `1 2 3 2 5 2 7 2 3`), grid overlay, residual lane (per bar / per beat), an overview strip of the whole track that moves the view, a cursor readout (time, bar and beat or pulse and group, residual, short-term loudness), the fit with its numbers coloured against the verdict limits, drift card with numbers; while playing, the waveform scrolls under the playhead once it reaches the middle of the view (with macOS Reduce motion it pages instead); in a window narrower than 1000 px the rail folds into a one-line fit strip above the waveform (confidence, BPM, meter, P95 and max, verdict) with the rest behind Details (`I`), and the toolbars keep their icons (Before / after, Beat 1 here) and the time signature; keys: `Space` play (from one beat before bar 1 the first time), `Home` back to bar 1, `C` click, `A` the analysed grid dashed under the edited one, `D` bar 1 at the pointer, `,` / `.` nudge, `1`-`9`, `T` tap, `M` meter, `R` reset, `Cmd+Z` / `Cmd+Shift+Z`, arrows pan, `+` / `-` zoom, `Enter` confirm and open the next Needs-review track, `N` next without confirming, `B` original / processed, `Shift+B` match level, `Shift+Up` / `Shift+Down` volume +/-1 dB; fixes: drag anchor (kick-snapped), nudge +/-1 ms (10 ms, 1 beat), type/tap BPM, x2 / /2, `1`-`n` set which beat is beat 1, **fit to the whole track or its start** (offered when the tempo changes within the track: Start fits the grid to the opening from the first kick, up to 128 beats and fewer when the tempo already changes within them, where a DJ mixes in, each option showing the share of kicks it holds there; the rest still reads Drifts), meter picker (runner-up first), re-fit from here, reset, undo; ruler and click accents follow the grouping; **click audition** (accented downbeat) on a minimal in-app player, which plays the track at its planned gain (a settings change applies to the open track at once; the click follows that level and stays 6 dB over the music). **Live meters** either side of the waveform, its full height: IN (the original) on the left, OUT (at the planned gain, what an export holds) on the right, 40 px wide (16 px in a window narrower than 1000 px, the numbers then in a tooltip), one scale from -36 to +3 dB with ticks every 6 dB; the bar is the true peak (dBTP) with an instant attack, coloured in fixed zones (green below -12 dB, yellow from -12 to -3 dB, red above -3 dB), a peak-hold tick that holds 1.5 s then falls 20 dB per 1.7 s, and the momentary loudness (400 ms, LUFS) as a tick with a small arrow; the held peak and the momentary loudness are written above each bar in 12 px numbers, their units in the tooltip; OUT also draws the loudness target (dashed: the S-P95 target in DJ mode, the integrated target in Streaming mode) and the true-peak ceiling; a peak over the ceiling (OUT) or over 0 dBTP (IN) turns the peak readout and tick red until clicked or another track opens; stopped, the bars are empty and the strips show the track's numbers instead (IN `S-P95 -9.2 · TP -0.3`, OUT the planned values, Integrated in Streaming mode); OUT always reads IN plus the planned gain, whatever is heard and whatever the volume. **A/B audition** in the transport: `Original | Processed -2.3 dB` switches what is heard at the same position without a gap (a 10 ms ramp; every opened track starts on Processed), the meter of the version heard at full brightness and the other dimmed; **Match level** plays both at the same loudness (with gain only the two then sound the same; it matters once the limiter changes the sound); the click follows the switch. **Volume** right of play/pause: a 96 px slider (an icon with a popover in a window narrower than 1000 px, or when the transport row is too narrow for it), 0 dB at the right end, -60 dB then off at the left, the level shown on hover and while dragging, `Shift`+drag for fine steps, the speaker icon mutes; it only sets what is heard (not the meters, the planned gain or any file) and is remembered across tracks and restarts. A stereo file longer than 20 minutes plays as mono (left and right averaged), and its meter readings are marked as such. Edits are saved (under `~/Library/Application Support/app.soundcheck.desktop/grid-edits`, so they survive restarts and a cleared cache) and apply while the audio is unchanged (a DJ app writing tags keeps them; another master of the track drops them). A grid the user confirms by ear leaves the Needs-review queue; its drift, residual and BPM-range marks stay on the row, and should a new analysis give a different grid for the same edit, the track returns to the queue. 4. **Two modes**: **Prepare** (new tracks; lossless files cut at the head so the first bar line lands a lead after the start (5 ms until calibrated against rekordbox; the bar line is bar 1 or one extrapolated from it by whole bars at the written two-decimal BPM), removing at most the bar's last beat ("Cut 0.21 s"); the cut moves back up to 1 ms to the quietest moment, so the bar line lands up to 1 ms past the lead, and the first 2 ms fade in from silence, so the cut never clicks; a file whose first bar line already lies before the lead is not cut, since that would mean adding silence ("Starts on bar 1" when that line is bar 1 itself, else "Starts on a bar line (bar 1 at 8.00 s)"); when the first bar line lies more than a beat after the lead, nothing is cut ("Not cut: bar 1 1.00 s in"; "Not cut: first bar line 1.00 s in (bar 1 at 9.00 s)") and the grid travels in tags and the XML, because cutting more would remove music; a track whose grid needs review is not cut until the user confirms its grid by ear ("Not cut: grid needs review"), because a cut at an untrusted bar 1 would start the file in the wrong place; its gain and loudness tags are still written, but not its tempo or grid (no BPM tag, and the `SOUNDCHECK` record says `bpm=none;bar1=none`), so a BPM tag that disagreed with the grid stays and the track still asks for review after the export; Library mode does not write such a grid either, and the row says the BPM and bar 1 were withheld; "grid only" skips such a track ("its grid needs review"), because it would write nothing but an empty record; the cut shown, the frame count and the bar 1 recorded in the file are planned from where the cut actually falls after the 1 ms move; in place allowed; Serato data is detected when a file is added (ID3 `GEOB` objects described `Serato ...` in WAV, AIFF and MP3, `SERATO_*` Vorbis fields in FLAC; M4A is not read): an in-place cut of such a file is refused, because its Serato cue points are positions in the audio and would move (also when the file's tags cannot be read to rule Serato data out), while a cut copy in another folder keeps its Serato cue points and beat grid byte for byte, so they sit late by the cut in Serato and the row says so; MP3/AAC get a metadata-only grid in the rekordbox XML ("XML only", noted as not yet verified against the encoder delay; with the XML off, nothing is written; an MP3/AAC file without a grid, or whose grid needs review and was not confirmed, is skipped, since the XML would carry nothing for it, and so is one in Library mode whose grid was not confirmed) + "Convert to AIFF copy + cut") and **Library** (never change length; loudness only by default; rekordbox XML grid opt-in: the batch's XML lists a Library track only when its grid was confirmed in SoundCheck, because importing it replaces the DJ app's own grid; cut-to-grid only into a new folder). A batch holding files with Serato data runs in Library even with Prepare selected, and the rail says so; choosing Prepare for those files' folder is remembered per folder. The saved mode is left alone, so the next batch of new tracks is Prepare again. -5. **Export**: from the app, the rail's Export section (Prepare | Library; To folder, the default, with a folder picker that is remembered | In place with backup, an explicit choice; depth source / 16 / 24; Grid only; Write TBPM, on by default in Prepare and off in Library, each mode remembering its own; Write rekordbox XML) and the Export button, also in the top bar of a narrow window, which takes every analysed row with no finished export. A file already exported is never written again without asking: a row exported with other settings says so ("Exported at -11.0", "Exported to ~/Old", likewise depth and mode), and the confirm step says "18 exported at -11.0 stay as they are" with an unticked "Export them again at -8.0"; a saved grid that differs from what was written, a source that changed on disk and was analysed again, or a revert, brings a file back as new work. A row skipped or left to the XML goes along with a batch but makes none on its own, unless the settings changed; turning the XML on brings back a row only the XML could carry. What is being typed in a field is committed and reaches the engine first. The button then shows what the engine plans, file by file, with nothing written until it is confirmed ("Write 10 files"): "12 files: 9 gain, 3 cut, 2 skipped; backups in ~/Music/SoundCheck Backups" (a file with both a gain and a cut counts under each), the target and ceiling, where the XML and report go, when a cut will shift positions and by how much, why each skipped file is skipped, which earlier copies are replaced and which files in the folder are left. Write waits until the plan has answered and no earlier plan is still reading the files. An export in place that would refuse a file (a symbolic link, a locked, read-only or hard-linked file, one on a rekordbox USB export) says so on its row, marked refused, and the file stays out of the job and out of the count; the table keeps the refusal on its row once the summary closes, as it does for an export that failed. A file that changed since it was analysed, or after the summary, says so on its row and to press Analyse, which is lit for it and brings the row up to date. A skipped row stays out of the count: it goes along with a batch and is written only if the plan now says otherwise, which is what a confirmed grid, other settings or a cleared folder can change; a skip about the file itself (its sample rate, channels, format or silence) changes with none of them. The job refuses settings that changed after the summary, and fails the row of a file that changed after it ("changed since you confirmed") before anything is written. Export again to one folder replaces a copy SoundCheck wrote there, when its sidecar still proves it, so the path and whatever a DJ app keyed to it stay; any other file of that name is left alone and its row skipped, and so is a copy of another track of that name: a copy is replaced only when it was made from this file (the same path, or a moved file byte for byte the same). Two files of one batch that would share a name write the first in the list that plans a write and skip the later one, comparing names as the folder's volume does: a file that is itself skipped, left to the XML or not plannable takes no name, and a file the summary said was skipped stays skipped when the job runs. Each skip says which it is and what to do ("rename one, or choose another folder"): a file earlier in the export takes the name, or the folder holds a copy of that name made from another file, or from this one before it was moved and changed. One folder has one rekordbox XML and report, describing that folder: a batch adds to them, entries whose file is gone are dropped, and rows are sorted by location, so the result does not depend on the order of the batches. An XML or report in the folder that starts like SoundCheck's but cannot be read whole refuses the export, naming the file, rather than lose what it lists: move it away or choose another folder; one that turns unreadable during the job is left as it is and the banner above the table says so. Export is disabled with its reason while another job runs, a revert runs, the check for changes a crash interrupted runs, no folder is chosen ("Choose a folder", which opens the picker; in the top bar of a narrow window Export itself does), or tracks were analysed under another BPM range ("3 tracks need Analyse for this BPM range"). Files go to a folder by default, the originals untouched, or in place with a backup when asked for; format source / AIFF / WAV 24-bit / FLAC; DJ-safe validation (44.1/48 kHz, integer PCM, stereo, plain PCM header, FORM AIFF); tags `TBPM` + `TXXX:BPM`, ReplayGain 2.0, `SOUNDCHECK` record; **rekordbox XML per batch** (carrier, with Analysis Lock advice): `soundcheck-rekordbox.xml` lists only files with a grid it may carry, each at its path now, spelled exactly as its folder lists the name (what the Finder, and rekordbox when it added the file, see: macOS opens a file under other Unicode spellings and cases too, and a FAT32 stick even reports back whichever spelling reached the file first, but rekordbox matches an existing track by its location, so any other spelling would add the track a second time), named by its own title and artist tags (the file name when it has no title), with one 4/4 `TEMPO` whose `Inizio` is the first beat in the file (after a Prepare cut, bar 1 at the lead), `Bpm` the written two-decimal BPM and `Battito` that beat's place in its bar; nothing else about the track is written (`Name`, `Artist`, `TotalTime`, `AverageBpm`, `Location` and the `TEMPO`, plus the XML's own `TrackID` that its playlist refers to; rekordbox matches the track by its location and keeps its own id), because **importing a track from the XML into rekordbox's collection overwrites that track's information, beat grid and BPM with the XML's: import one track first and check it before importing the rest**; a track whose grid needs review and was not confirmed, without a grid, or in a meter other than 4/4 is left out of the XML (importing it could only change its information, or clear rekordbox's own grid), and the report says why; its playlist is named after the batch (`SoundCheck 2026-10-11 14.05.33`), since rekordbox replaces a playlist of the same name on import; `grid-report.csv` (one row per file: file, mode, action, gain, cut, BPM, bar 1, what the XML carries or why not, grid check (the self-check's result), notes; UTF-8 with a byte-order mark so spreadsheets show Turkish names; comma-separated with decimal points, so Excel set to a Turkish or other locale that uses `;` and decimal commas splits it wrongly when opened by double-click: import it through Data > From Text/CSV choosing comma and point, or open it in Numbers); both go into the export folder, or for an export in place into a new `~/Music/SoundCheck/exports//` folder, and replace only files of those names that SoundCheck wrote (a foreign file of either name in the export folder refuses the export before any file is written); output self-check (grid check): every written file is analysed again and its grid compared with the source's own analysis: the detector's grid of the source (for a grid the user edited, solved with the user's meter, tempo octave and fitted part, but not the bar line they placed, the beat 1 they chose or the tempo they typed) put its bar line at some offset from the exported bar 1, and the written file's analysis is expected to put one within 5 ms of the same place (after a Prepare cut, bar 1 sits at the lead), compared modulo the bar, at a tempo within 0.005 BPM of the source's; so a nudged bar 1, a chosen beat 1 or a typed tempo is neither confirmed nor contradicted. **What it proves and what it does not**: it is SoundCheck's own detector reading the written audio again, and that detector is not stable under gain or a sub-millisecond shift: gain alone can change the beat model's downbeat votes and move its bar 1 by a whole beat when nothing moved in time (the write's exact frame count pins how much was cut, not where the music landed). So agreement shows only that the detector still reads the grid where the export put it, not that rekordbox or another DJ app will agree (the release checklist's rekordbox protocol checks that), and disagreement does not show the file is wrong, though it cannot rule that out: on a confirmed grid a real misplacement would show only as an advisory with its numbers. It has three outcomes besides `pass` and `not checked`: (1) on a grid you confirmed, any disagreement is advisory: `the analysis reads this differently; your confirmed grid was kept: `, never shown as a failure; (2) on a grid you did not confirm, when its bar lines sit a whole number of the meter's beats away (a quarter note in 4/4, three eighth notes in 6/8) within the same 5 ms, `check bar 1: the analysis reads it 1 beat earlier (...)`, because the beats line up and only which beat is bar 1 is in question, so listen and choose (the row's amber state); (3) on a grid you did not confirm, any other disagreement is reported with its numbers: `off by X ms`, `BPM differs by Y`, `meter differs` or `no grid found`. `not checked` gives the reason (no grid exported, grid withheld because it needs review, a meter whose beats differ in length such as 9/8 as 2+2+2+3, a file left to the XML). The result, with its raw numbers in every outcome, is recorded in the file's sidecar and the grid report's `grid_check` column, printed by `sc-cli process` and carried to the app for the row's chip, which tells the outcomes apart by the result's type; no outcome fails the export or a release, and a sidecar written by an earlier version with a disagreement on a confirmed grid reads as advisory; rekordbox USB exports refused with explanation; FLAC re-analysis notice; loudness tags a gain change makes stale are reported and carried unchanged: album Replay Gain, `RVA2`, iTunes `iTunNORM` (`COMM`), R 128 gains and Serato Autotags (auto gain); the track Replay Gain is replaced; the sidecar `.soundcheck.json` of an exported file records the export settings, the plans, the grid as exported (bar 1 and the first bar line in the output's samples, the written BPM) and the source's loudness; a grid the user edited or confirmed is carried over to the exported file (bar 1 moved back by the cut), so a confirmed grid stays confirmed after the export, and the original's edit is kept beside it, so an undo brings it back; an export is refused, before anything is written, when the file changed between its plan and its write (for example, another export wrote it); Library keeps the file's modification time, Prepare does not; export waits for the start-up recovery, so no file is written while an interrupted change may still be repaired; a file's `SOUNDCHECK` record is read back when it is added, so a later run can see it was already processed (a record of a later format or a damaged one still marks the file as processed, with the reason it could not be read). -6. **Trust surface**: Action column (what will happen, in dB/LU/ms) separate from Status (where it is); "Done, verified" only after tiered verification; on-screen tag diff ("Verified: 27/27 identical, 3 added") with the cover rendered from the output file; Revert (the original's bytes and modification time back, its measurements and saved grid edit shown again; a file with no change to revert is refused); a file another job or command is working on is refused, not queued, so pressing Export or Revert twice changes it once; export, Revert and the rekordbox XML button are refused while the check for changes a crash interrupted runs at start, and changes it leaves pending can be forgotten without deleting anything; three-line skip/error copy; positions-shift badge on Prepare rows. During an export a row goes Queued, Processing, Writing, Checking (already verified, only the grid check still running), then Done, verified, Skipped or Failed (the file not changed); Cancel stops the batch, and a cancelled or failed row keeps what it showed before. The Action column shows `Gain -2.3 dB`, `Cut 0.21 s` with its positions-shift badge, `Grid only` or `Grid: XML only (MP3)`, with what was verified under it ("Verified: 27/27 carried, 3 added": every tag block but the format and audio blocks written anew, and a block left out is never hidden). A row exported fine whose grid still asks for a look says "Done, verified" with the reason under it and stays in the Needs review tab, not also in Done, so every row counts in one tab. A row left to the XML that the XML lists says "XML only", and counts under Skipped. The Status column carries the grid check, which only reports, since the export already succeeded: quiet green when it agrees; neutral, never red or amber, when the analysis reads a grid the user confirmed differently ("Reads differently", the confirmed grid is kept, the numbers on hover); amber "Check bar 1" for an unconfirmed grid a whole number of beats off, with count and direction; a quiet warning with the measured difference otherwise; muted "Not checked" with its reason. Selecting a row shows the whole story under the table: the change, what was verified, the check in words, every note, why it was skipped or failed, and Revert. Revert takes about 10 s on a 6-minute track, with a spinner on its row and nothing else blocked. The rows of a running batch cannot be reverted or opened until it ends, since a batch holds all its files; Revert survives a window reload. A window reloaded during an export stops it, waits for the file in flight, and still finds the batch's files and summary; a file it wrote in place whose analysis did not finish is analysed again. If the job has not stopped after a minute, the app says to restart. The footer shows progress and Cancel, then links that show `soundcheck-rekordbox.xml` and `grid-report.csv` in the Finder and "Show in Finder"; it says so plainly when an XML lists no tracks, and the banner above the table says so when the XML or report could not be written (the exported files are fine). The "rekordbox XML" button writes an XML of every analysed row, an exported copy where it is, leaving the files untouched, and each row it leaves out says why. +5. **Export**: from the app, the rail's Export section (Prepare | Library; To folder, the default, with a folder picker that is remembered | In place with backup, an explicit choice; depth source / 16 / 24; Grid only; Write TBPM, on by default in Prepare and off in Library, each mode remembering its own; Write rekordbox XML) and the Export button, also in the top bar of a narrow window, which takes every analysed row with no finished export. A file already exported is never written again without asking: a row exported with other settings says so ("Exported at -11.0", "Exported to ~/Old", likewise depth and mode), and the confirm step says "18 exported at -11.0 stay as they are" with an unticked "Export them again at -8.0"; a saved grid that differs from what was written, a source that changed on disk and was analysed again, or a revert, brings a file back as new work. A row skipped or left to the XML goes along with a batch but makes none on its own, unless the settings changed; turning the XML on brings back a row only the XML could carry. What is being typed in a field is committed and reaches the engine first. The button then shows what the engine plans, file by file, with nothing written until it is confirmed ("Write 10 files"): "12 files: 9 gain, 3 cut, 2 skipped; backups in ~/Music/SoundCheck Backups" (a file with both a gain and a cut counts under each), the target and ceiling, where the XML and report go, when a cut will shift positions and by how much, why each skipped file is skipped, which earlier copies are replaced and which files in the folder are left, and, when the batch writes FLAC files in place, that rekordbox must analyse them again before a CDJ plays them well (rekordbox keeps an index of where each part of a FLAC it knows sits, writing in place moves them, and until then a track can skip; that analysis can put rekordbox's own beat grid in place of the exported one, which importing the track from the rekordbox XML afterwards puts back, and Analysis Lock then keeps); a FLAC copy in another folder is new to rekordbox, analysed when it is added, and gets no such notice. Write waits until the plan has answered and no earlier plan is still reading the files. An export in place that would refuse a file (a symbolic link, a locked, read-only or hard-linked file, one on a rekordbox USB export) says so on its row, marked refused, and the file stays out of the job and out of the count; the table keeps the refusal on its row once the summary closes, as it does for an export that failed. A file that changed since it was analysed, or after the summary, says so on its row and to press Analyse, which is lit for it and brings the row up to date. A skipped row stays out of the count: it goes along with a batch and is written only if the plan now says otherwise, which is what a confirmed grid, other settings or a cleared folder can change; a skip about the file itself (its sample rate, channels, format or silence) changes with none of them. The job refuses settings that changed after the summary, and fails the row of a file that changed after it ("changed since you confirmed") before anything is written. Export again to one folder replaces a copy SoundCheck wrote there, when its sidecar still proves it, so the path and whatever a DJ app keyed to it stay; any other file of that name is left alone and its row skipped, and so is a copy of another track of that name: a copy is replaced only when it was made from this file (the same path, or a moved file byte for byte the same). Two files of one batch that would share a name write the first in the list that plans a write and skip the later one, comparing names as the folder's volume does: a file that is itself skipped, left to the XML or not plannable takes no name, and a file the summary said was skipped stays skipped when the job runs. Each skip says which it is and what to do ("rename one, or choose another folder"): a file earlier in the export takes the name, or the folder holds a copy of that name made from another file, or from this one before it was moved and changed. One folder has one rekordbox XML and report, describing that folder: a batch adds to them, entries whose file is gone are dropped, and rows are sorted by location, so the result does not depend on the order of the batches. An XML or report in the folder that starts like SoundCheck's but cannot be read whole refuses the export, naming the file, rather than lose what it lists: move it away or choose another folder; one that turns unreadable during the job is left as it is and the banner above the table says so. Export is disabled with its reason while another job runs, a revert runs, the check for changes a crash interrupted runs, no folder is chosen ("Choose a folder", which opens the picker; in the top bar of a narrow window Export itself does), or tracks were analysed under another BPM range ("3 tracks need Analyse for this BPM range"). Files go to a folder by default, the originals untouched, or in place with a backup when asked for; format source / AIFF / WAV 24-bit / FLAC; DJ-safe validation (44.1/48 kHz, integer PCM, stereo, plain PCM header, FORM AIFF); tags `TBPM` + `TXXX:BPM`, ReplayGain 2.0, `SOUNDCHECK` record; **rekordbox XML per batch** (carrier, with Analysis Lock advice): `soundcheck-rekordbox.xml` lists only files with a grid it may carry, each at its path now, spelled exactly as its folder lists the name (what the Finder, and rekordbox when it added the file, see: macOS opens a file under other Unicode spellings and cases too, and a FAT32 stick even reports back whichever spelling reached the file first, but rekordbox matches an existing track by its location, so any other spelling would add the track a second time), named by its own title and artist tags (the file name when it has no title), with one 4/4 `TEMPO` whose `Inizio` is the first beat in the file (after a Prepare cut, bar 1 at the lead), `Bpm` the written two-decimal BPM and `Battito` that beat's place in its bar; nothing else about the track is written (`Name`, `Artist`, `TotalTime`, `AverageBpm`, `Location` and the `TEMPO`, plus the XML's own `TrackID` that its playlist refers to; rekordbox matches the track by its location and keeps its own id), because **importing a track from the XML into rekordbox's collection overwrites that track's information, beat grid and BPM with the XML's: import one track first and check it before importing the rest**; a track whose grid needs review and was not confirmed, without a grid, or in a meter other than 4/4 is left out of the XML (importing it could only change its information, or clear rekordbox's own grid), and the report says why; its playlist is named after the batch (`SoundCheck 2026-10-11 14.05.33`), since rekordbox replaces a playlist of the same name on import; `grid-report.csv` (one row per file: file, mode, action, gain, cut, BPM, bar 1, what the XML carries or why not, grid check (the self-check's result), notes; UTF-8 with a byte-order mark so spreadsheets show Turkish names; comma-separated with decimal points, so Excel set to a Turkish or other locale that uses `;` and decimal commas splits it wrongly when opened by double-click: import it through Data > From Text/CSV choosing comma and point, or open it in Numbers); both go into the export folder, or for an export in place into a new `~/Music/SoundCheck/exports//` folder, and replace only files of those names that SoundCheck wrote (a foreign file of either name in the export folder refuses the export before any file is written); output self-check (grid check): every written file is analysed again and its grid compared with the source's own analysis: the detector's grid of the source (for a grid the user edited, solved with the user's meter, tempo octave and fitted part, but not the bar line they placed, the beat 1 they chose or the tempo they typed) put its bar line at some offset from the exported bar 1, and the written file's analysis is expected to put one within 5 ms of the same place (after a Prepare cut, bar 1 sits at the lead), compared modulo the bar, at a tempo within 0.005 BPM of the source's; so a nudged bar 1, a chosen beat 1 or a typed tempo is neither confirmed nor contradicted. **What it proves and what it does not**: it is SoundCheck's own detector reading the written audio again, and that detector is not stable under gain or a sub-millisecond shift: gain alone can change the beat model's downbeat votes and move its bar 1 by a whole beat when nothing moved in time (the write's exact frame count pins how much was cut, not where the music landed). So agreement shows only that the detector still reads the grid where the export put it, not that rekordbox or another DJ app will agree (the release checklist's rekordbox protocol checks that), and disagreement does not show the file is wrong, though it cannot rule that out: on a confirmed grid a real misplacement would show only as an advisory with its numbers. It has three outcomes besides `pass` and `not checked`: (1) on a grid you confirmed, any disagreement is advisory: `the analysis reads this differently; your confirmed grid was kept: `, never shown as a failure; (2) on a grid you did not confirm, when its bar lines sit a whole number of the meter's beats away (a quarter note in 4/4, three eighth notes in 6/8) within the same 5 ms, `check bar 1: the analysis reads it 1 beat earlier (...)`, because the beats line up and only which beat is bar 1 is in question, so listen and choose (the row's amber state); (3) on a grid you did not confirm, any other disagreement is reported with its numbers: `off by X ms`, `BPM differs by Y`, `meter differs` or `no grid found`. `not checked` gives the reason (no grid exported, grid withheld because it needs review, a meter whose beats differ in length such as 9/8 as 2+2+2+3, a file left to the XML). The result, with its raw numbers in every outcome, is recorded in the file's sidecar and the grid report's `grid_check` column, printed by `sc-cli process` and carried to the app for the row's chip, which tells the outcomes apart by the result's type; no outcome fails the export or a release, and a sidecar written by an earlier version with a disagreement on a confirmed grid reads as advisory; rekordbox USB exports refused with explanation; FLAC re-analysis notice; loudness tags a gain change makes stale are reported and carried unchanged: album Replay Gain, `RVA2`, iTunes `iTunNORM` (`COMM`), R 128 gains and Serato Autotags (auto gain); the track Replay Gain is replaced; the sidecar `.soundcheck.json` of an exported file records the export settings, the plans, the grid as exported (bar 1 and the first bar line in the output's samples, the written BPM) and the source's loudness; a grid the user edited or confirmed is carried over to the exported file (bar 1 moved back by the cut), so a confirmed grid stays confirmed after the export, and the original's edit is kept beside it, so an undo brings it back; an export is refused, before anything is written, when the file changed between its plan and its write (for example, another export wrote it); Library keeps the file's modification time, Prepare does not; export waits for the start-up recovery, so no file is written while an interrupted change may still be repaired; a file's `SOUNDCHECK` record is read back when it is added, so a later run can see it was already processed (a record of a later format or a damaged one still marks the file as processed, with the reason it could not be read). +6. **Trust surface**: Action column (what will happen, in dB/LU/ms) separate from Status (where it is); "Done, verified" only after tiered verification; on-screen tag diff ("Verified: 27/27 identical, 3 added") with the cover rendered from the output file; Revert (the original's bytes and modification time back, its measurements and saved grid edit shown again; a file with no change to revert is refused); a file another job or command is working on is refused, not queued, so pressing Export or Revert twice changes it once; export, Revert and the rekordbox XML button are refused while the check for changes a crash interrupted runs at start, and a banner above the table, in the page's flow so it never covers the list, says what that check found: "Finishing changes from the last session…" while it runs (shown only once it has run a moment; a status that cannot be read is read again three times, after 250, 500 and 1000 ms, before the banner says the check could not be read), "2 changes from the last session could not be finished" with each file's name (its path on hover), why ("/Volumes/STICK/Crate is not reachable (volume not mounted?)") and Forget, "2 changes from the last session were finished" (or undone, the file as it was) as a line that can be closed, or that the check could not run, why and what to do; a skipped check (no home folder, so nothing can have been interrupted) says nothing; Forget first says what it means and then ends the change in the journal so it is not tried again at start: the file is left as it is now (for a change in place, the original or the new version, depending on how far it got; for a copy into a folder, no copy yet, the empty file that holds its name on an exFAT or FAT drive, or the new copy), and nothing is deleted, the backup of the original, any half-written hidden copy (which may be whole, and which the Finder hides) and such an empty file staying where they are; the line after it lists each of them by its path, and says to remove an empty file holding a copy's name before exporting to that folder again, since that export would skip the name as another file's; a Forget refused because the change ended meanwhile reads the status again and drops its row, and one still pending says why; the banner's text reaches screen readers through one live region present from the start; every refusal and failure reads in three lines, what happened, why (the engine's words, "This file" on its own row) and what to do, the last worded for where it shows (the row, the confirm step or the banner) and the engine's own advice used where it gives one ("Unlock it, or write a copy to a folder"); three-line skip/error copy; positions-shift badge on Prepare rows. During an export a row goes Queued, Processing, Writing, Checking (already verified, only the grid check still running), then Done, verified, Skipped or Failed (the file not changed); Cancel stops the batch, and a cancelled or failed row keeps what it showed before. The Action column shows `Gain -2.3 dB`, `Cut 0.21 s` with its positions-shift badge, `Grid only` or `Grid: XML only (MP3)`, with what was verified under it ("Verified: 27/27 carried, 3 added": every tag block but the format and audio blocks written anew, and a block left out is never hidden). A row exported fine whose grid still asks for a look says "Done, verified" with the reason under it and stays in the Needs review tab, not also in Done, so every row counts in one tab. A row left to the XML that the XML lists says "XML only", and counts under Skipped. The Status column carries the grid check, which only reports, since the export already succeeded: quiet green when it agrees; neutral, never red or amber, when the analysis reads a grid the user confirmed differently ("Reads differently", the confirmed grid is kept, the numbers on hover); amber "Check bar 1" for an unconfirmed grid a whole number of beats off, with count and direction; a quiet warning with the measured difference otherwise; muted "Not checked" with its reason. Selecting a row shows the whole story under the table: the change, what was verified, the check in words, every note, why it was skipped or failed, and Revert; once a file is written it also names, in plain words, the loudness tags its gain left at the old level ("Album ReplayGain, RVA2 and iTunNORM were left as they are; players that use them will play this file at the old level."), says, for a file with Serato Autotags whose gain changed, that Serato's Auto Gain was measured at the old level (analyse it again in Serato with Set Auto Gain on, or turn Auto Gain off), and, for a FLAC written in place, that rekordbox must analyse it again before a CDJ plays it, then how to keep the exported grid (import the track from the rekordbox XML after that analysis, then turn on Analysis Lock); the footer's last-export summary adds how many of that export's files keep loudness tags at the old level. Revert takes about 10 s on a 6-minute track, with a spinner on its row and nothing else blocked. The rows of a running batch cannot be reverted or opened until it ends, since a batch holds all its files; Revert survives a window reload. A window reloaded during an export stops it, waits for the file in flight, and still finds the batch's files and summary; a file it wrote in place whose analysis did not finish is analysed again. If the job has not stopped after a minute, the app says to restart. The footer shows progress and Cancel, then links that show `soundcheck-rekordbox.xml` and `grid-report.csv` in the Finder and "Show in Finder"; it says so plainly when an XML lists no tracks, and the banner above the table says so when the XML or report could not be written (the exported files are fine). The "rekordbox XML" button writes an XML of every analysed row, an exported copy where it is, leaving the files untouched, and each row it leaves out says why. 7. **Batch UX**: drop folders/files, analyse-only, Needs-review queue (`N`/`Enter`/`Esc`), filter chips (`Cmd+F` filters by name), `Cmd+,` for the settings (it moves focus to the settings rail; in a window narrower than 1000 px the rail is a drawer, opened by `Cmd+,` or a toolbar button showing the target, such as `DJ · −11.0 LUFS`, the name filter folds into an icon that opens in the chips' place (a chip still filtering shows in it), and Add folder and Add tracks become one Add menu; a table narrower than 820 px, as in any window under about 1100 px, shows the true peak under the loudness and the meter under the BPM, so each row still fits without scrolling sideways), progress and cancel, resume from cache, per-batch CSV. The track list is remembered across restarts (analyses come back from the cache; a file moved or deleted since shows as "File not found"); **Clear list** empties it without touching any file. 8. **`sc-cli`** with `analyze ` (text, or `--json` with exactly the table's fields, one document per file; `--no-grid` for loudness only, `--bpm-range 70-180` for the DJ app's range, `--model small|full`, `--evidence` to include the model beats, activations and onsets a refit uses, `--no-cache`, `--jobs N` files at once with reports still printed in the order given), `plan ` (what processing would do to each file, decided exactly as the table decides it: `--mode dj|streaming`, `--target`, `--ceiling`; the Action, Status and review reasons per file, then the counts the filter chips show; grid edits saved in the app apply; `--batch-mode prepare|library` adds a line per file with what exporting would do: the gain, the head cut (`Cut 0.21 s`, where the cut falls after its move to the quietest frame, `Starts on bar 1`, `Starts on a bar line (bar 1 at 8.00 s)`, `Not cut: bar 1 1.00 s in`, `Not cut: grid needs review (bar 1 at 2.30 s; confirm it in the app)`, `length kept` in Library mode), the tags written (`BPM` in Prepare mode, Replay Gain 2.0 track gain and peak, the `SOUNDCHECK` record) and notes (a cut copy's Serato cues), or `XML only` with the reason (MP3/AAC until their writes arrive; with `--grid-only`, FLAC, sources other than 16- or 24-bit integer PCM and files without a tag), or "skipped" in three lines (what, why, what to do: Serato data found in the file's tags, or tags that could not be read to rule it out, and a cut in place, a sample rate other than 44.1/48 kHz, more than two channels, an analysed-only codec, silence, no grid, nothing to write with `--no-xml`); `--grid-only` plans no gain and no cut, `--lead-ms N` (2-50, default 5; the floor is the 2 ms fade after the cut, so bar 1's attack never sits inside it) sets the lead, `--no-xml` turns the batch's rekordbox XML off; with `--json` the outcome is the document's `export` field), `labels ` (evaluation labels from each file's grid as the app shows it, edits applied: `file,bpm,bar1_s,meter,grouping,confirmed,fit`, `fit` being `whole` or `start`; `--confirmed-only`), `bench ` (time and real-time factor of each stage), `eval --labels --dir ` (scores BPM, meter, bar 1 (modulo the label's bar; only for rows with a labelled tempo; a row with `fit` `start` against the analysis refitted to the start) and integrated loudness against hand labels; `scripts/ffmpeg-reference.sh` writes the loudness reference), `cache path|clear` (the analysis cache lives under `~/Library/Caches/app.soundcheck.desktop/analysis`, keyed by the file's identity (inode and change time, not the disk, so a replugged USB stick keeps its analyses), size and time, settings and app version, and a file's entry is dropped whenever SoundCheck changes, undoes or recovers it, so a file SoundCheck rewrote at the same length and time, or put back by an undo, is analysed again), `apply --gain-db ` (WAV, AIFF, FLAC, one after the other in the order given; a file listed twice under any spelling (`T.wav`, `./T.wav`, its full path, another case) is changed once and its later mentions refused, and with `--out` two files that would get the same name there are caught before anything is written, only the first being written; the gain on every sample, a boost that would clip refused with the largest gain that would not; `--trim-samples N` cuts the start: the cut lands up to 1 ms before sample N per channel, on the quietest frame there (never after N), and the first 2 ms after it fade in from silence so the cut never clicks; the line gives the cut made and the one asked for (`500 frames trimmed (520 requested)`) and cue points and markers move by the cut made, `--bits 16|24` sets the output depth (default the source's, TPDF dither only at 16-bit), `--tag NAME=VALUE` (repeatable) adds or replaces a tag item by a name that is the same for every format: `BPM` (ID3 `TBPM` with the value rounded plus `TXXX:BPM` with the value as given in WAV/AIFF, `BPM` in FLAC), `INITIALKEY` (`TKEY` / `INITIALKEY`), any other name of capitals, digits and `_` (`TXXX:NAME` / `NAME`), written into a WAV/AIFF's existing ID3 chunk or a FLAC's Vorbis comment, never creating a tag; ID3v2.3/2.4 frame ids (`TBPM`, `TKEY`, and the iTunes `TCMP`, `GRP1`, ...) and labels with `:` are refused before any file is touched, while other four-letter names (`MOOD`, `YEAR`) are ordinary names; in place by default: the original is first copied to the backup folder, `~/Music/SoundCheck Backups///` (`--backup-root ` or `SC_BACKUP_ROOT` chooses another; a backup is never overwritten), the new version is written next to it as a hidden temp file, read back and checked, then put in place atomically with the original's extended attributes (Finder tags, comments; macOS's own provenance stamp is the new file's, as for any file the app writes), creation date, permissions and modification time (`--no-keep-mtime` gives it the current time); `--out ` writes copies there instead and leaves the files untouched, never replacing an existing one; next to each written file `.soundcheck.json` records what was asked, what was carried, patched and added, the hashes of original and output and where the backup is (`--no-sidecar` skips it); one line per file, starting with the path as given: gain, depth, frames trimmed, blocks carried/patched, tags added or why not, stale loudness tags, "verified", the backup's path; a refusal prints three lines on stderr (what, why, what to do: rekordbox USB export, symbolic link, Finder-locked, read-only file or folder, access control list, other owner, hard links, not enough free space with the bytes needed and free, not DJ-safe, would clip, changed meanwhile, unsupported format, existing output); `--json` prints one document per file with `schema`, `file`, `ok`, the transaction id, output, backup, sidecar, the request, the render summary, BLAKE3 hashes, notes and per-step timings, or the error class, message, why and what to do), `undo ` (puts back the version before each file's newest change from the backup, read back and checked against its hash, with its metadata (what could not be restored is printed as a note), and says how many earlier changes remain; refused when the file changed since, so nothing is lost; the sidecar is removed, or rewritten for the change before; the backup is kept), `journal` (every change recorded in the backup folder's `journal.jsonl`, newest first: time, id, kind, how it ended or where it stopped, file, backup; `--incomplete` for unfinished ones only; `--forget ` gives up on a pending change so recovery leaves it alone from then on, deleting nothing and saying where its backup is; `--json`; JSON fields and their values are listed in the sc-cli README), `recover` (finishes or rolls back changes a crash or a pulled cable interrupted: before the new version was in place the temp file is removed and the original was never touched; after it, metadata and sidecar are completed; changes on a volume that is not mounted, or on a file another change is working on, stay pending and are tried again, and the exit code is then 3; the app runs it at start (what it found is in the app's log, `~/Library/Logs/app.soundcheck.desktop/soundcheck.log`) and `apply`/`undo` run it first), `process --batch-mode prepare|library` (exports exactly as the app's Export button does, through the same engine job: recovery first, then per file the analysis (cached, grid edits applied), the plan worded as `plan --batch-mode` words it, the write in place after a backup or with `--out ` as a copy, verified, refused before anything is replaced unless the output has the planned length (Library: unchanged; Prepare: shorter by the cut exactly), and a fresh analysis of the written file, whose grid is checked against the exported one (` grid check: pass (bar line +0.4 ms, BPM +0.0003)`, or one of the outcomes above, under the file's line, `gridCheck` in `--json`); `--grid-only`, `--depth source|16|24`, `--lead-ms N`, `--no-tbpm`, `--mode dj|streaming`, `--target`, `--ceiling`, `--json`; one line per file (`: prepare: Gain -2.0 dB, Cut 0.21 s; tags ...; written and verified; backup `, `XML only: ...`, or `skipped` with why and what to do) and a summary `N files: W written, X XML only, S skipped, F failed`; `--no-xml`; unless it, the batch's rekordbox XML and grid report are written into `--out` or a new `~/Music/SoundCheck/exports//` folder and named in two lines), `xml [--out FILE]` (the rekordbox XML of the files named, Library ones included since naming them is the choice: a grid edited or confirmed in the app is used first, else an exported file's grid read back from its sidecar while the file is unchanged, else the analysis; files without a grid it may carry are left out with the reason; to stdout, or to `--out`, which must end in `.xml` and never replaces a file SoundCheck did not write: an audio file a glob put there, or another app's XML, is refused in three lines and left as it is), `grid-check` (later). A file that fails prints an error document and the exit code is 2; a missing beat-tracking model stops the run before any file with the directories searched. diff --git a/src/App.tsx b/src/App.tsx index bd4114a..1d1283c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -23,7 +23,8 @@ import { startSettingsSync } from '@/features/settings/sync'; import { ExportConfirm } from '@/features/export/ExportConfirm'; import { ExportDetail } from '@/features/export/ExportDetail'; import { useExportFlow } from '@/features/export/flow'; -import { startRecoveryWatch } from '@/state/recovery'; +import { startRecoveryWatch, useRecovery } from '@/state/recovery'; +import { RecoveryBanner } from '@/features/recovery/RecoveryBanner'; /** Keys for the review loop: arrows move, N jumps to the next row that needs a look. */ @@ -34,6 +35,8 @@ function useKeys() { if (useTrack.getState().fileId !== null) return; // The confirm step, while open, has the keys to itself. if (useExportFlow.getState().phase !== 'closed') return; + // So does the step that asks before forgetting a change. + if (useRecovery.getState().asking) return; const panels = usePanels.getState(); if (e.metaKey && e.key === ',') { // Settings: the drawer in a narrow window, else the rail's first control. @@ -154,6 +157,7 @@ function Library() {
+ {notice && (
{notice} @@ -161,12 +165,12 @@ function Library() { )} {aborted && (
- {abortedKind === 'export' ? 'Export' : 'Analysis'} could not start: {errorCopy(aborted)} + {abortedKind === 'export' ? 'Export' : 'Analysis'} could not start: {errorCopy(aborted, 'banner')}
)} {artefactsFailed && (
- The rekordbox XML and the grid report could not be written: {errorCopy(artefactsFailed)} + The rekordbox XML and the grid report could not be written: {errorCopy(artefactsFailed, 'banner')} {exported && ' The exported files are fine.'}
)} diff --git a/src/dev/mockBackend.ts b/src/dev/mockBackend.ts index 98c162e..d628340 100644 --- a/src/dev/mockBackend.ts +++ b/src/dev/mockBackend.ts @@ -7,6 +7,7 @@ import { mockIPC } from '@tauri-apps/api/mocks'; import { gridCommand } from './mockGrid'; import { exportCommand } from './mockExport'; +import { mockRecovery, recoveryDemoOf, type RecoveryDemo } from './mockRecovery'; import type { Channel } from '@tauri-apps/api/core'; import type { Codec, @@ -94,7 +95,8 @@ function fileEntry(s: Synthetic, folder: string, fileId: number): FileEntry { album: null, djUnsafe: s.float ? 'float' : null, serato: s.serato ?? false, - seratoTags: s.serato ? ['markers', 'beatGrid'] : [], + // Analysed in Serato: its auto gain too. + seratoTags: s.serato ? ['markers', 'beatGrid', 'autotags'] : [], seratoUnknown: false, soundcheck: null, soundcheckUnreadable: null, @@ -204,7 +206,9 @@ function runJob(jobId: number, fileIds: number[], channel: unknown) { /** Where the mock keeps the monitor volume between reloads. */ const MONITOR_KEY = 'soundcheck-mock-monitor'; -export function installMockBackend(): void { +/** `recovery`: which start-up recovery to show (see `mockRecovery.ts`); the page's URL by default. */ +export function installMockBackend(recovery: RecoveryDemo = recoveryDemoOf(globalThis.location?.search ?? '')): void { + const recovered = mockRecovery(recovery); const session = { decide: () => settings, analysed, @@ -247,7 +251,9 @@ export function installMockBackend(): void { case 'restore_session': return { revision, rows: [], runningJobs: [], artefacts: null, artefactsFailed: null, lastExport: null }; case 'recovery_status': - return { state: 'finished', recovered: [], pending: [] }; + return recovered.status(); + case 'forget_pending': + return recovered.forget(a.txn as string); case 'clear_session': analysed.clear(); entries.clear(); diff --git a/src/dev/mockExport.ts b/src/dev/mockExport.ts index 22f229b..c4b1916 100644 --- a/src/dev/mockExport.ts +++ b/src/dev/mockExport.ts @@ -124,7 +124,13 @@ function row(planned: Planned, stage: ExportRow['stage'], req: ExportRequest, de }; } -function verification(s: Synthetic): ExportVerification { +/** Loudness tags each track keeps at the old level once its gain changes, as the engine labels them. */ +const STALE: Record = { + 'Quiet Classic': ['TXXX:REPLAYGAIN_ALBUM_GAIN', 'TXXX:REPLAYGAIN_ALBUM_PEAK', 'RVA2', 'COMM:iTunNORM'], + 'Serato Cued': ['GEOB:Serato Autotags'], +}; + +function verification(s: Synthetic, gainDb: number): ExportVerification { return { blocks: 29, carried: 26, @@ -138,7 +144,7 @@ function verification(s: Synthetic): ExportVerification { bitsOut: s.bits ?? 16, exact: false, dithered: false, - staleLoudnessTags: [], + staleLoudnessTags: gainDb === 0 ? [] : (STALE[s.title] ?? []), }; } @@ -199,7 +205,7 @@ async function exportOne(session: MockSession, jobId: number, fileId: number, re send({ type: 'failed', jobId, fileId, error: { kind: 'verifyFailed', message, fileId } }); return; } - const wrote = { ...row(planned, 'writing', req, decide), verification: verification(s) }; + const wrote = { ...row(planned, 'writing', req, decide), verification: verification(s, planned.action.gainDb) }; send({ type: 'written', jobId, fileId, row: wrote }); if (req.outDir !== null) written.add(`${req.outDir}/${fileId}`); await wait(STEP_MS); diff --git a/src/dev/mockNotices.test.tsx b/src/dev/mockNotices.test.tsx new file mode 100644 index 0000000..b71b84c --- /dev/null +++ b/src/dev/mockNotices.test.tsx @@ -0,0 +1,112 @@ +import { act, fireEvent, render, screen, within } from '@testing-library/react'; +import App from '@/App'; +import { addPaths, pipeline } from '@/features/pipeline/actions'; +import { useLibrary } from '@/state/library'; +import { resetRecovery } from '@/state/recovery'; +import { useSettings } from '@/state/settings'; +import { installMockBackend } from './mockBackend'; + +const advance = (ms: number) => act(async () => vi.advanceTimersByTimeAsync(ms)); + +/** The drawn lines, leaving out the live region that repeats the banner for screen readers. */ +const DRAWN = { ignore: '[data-testid="recovery-live"], script, style' }; + +function select(title: string) { + const id = Object.values(useLibrary.getState().rows).find((r) => r.entry.info.title === title) + ?.entry.fileId; + act(() => useLibrary.getState().select(id ?? null)); +} + +beforeEach(() => { + vi.useFakeTimers(); + vi.stubGlobal( + 'ResizeObserver', + class { + observe() {} + disconnect() {} + }, + ); + resetRecovery(); +}); +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); +}); + +// Whole flows on fake timers: quick in time, but each renders the app many times over. +describe('the browser stand-in, for the notices and the recovery banner', { timeout: 20_000 }, () => { + it('exports rows with stale tags, Serato Autotags and a FLAC, each saying so', async () => { + installMockBackend('none'); + render(); + await advance(100); + await act(async () => addPaths(['/Users/mock/Music/Crate'])); + await advance(10_000); + act(() => useSettings.getState().setPlace('inPlace')); + fireEvent.click(screen.getByRole('button', { name: /^Export \d+/ })); + await advance(500); + const dialog = screen.getByRole('dialog', { name: 'Confirm export' }); + expect( + within(dialog).getByText(/FLAC files written in place again in rekordbox/), + ).toBeInTheDocument(); + fireEvent.click(within(dialog).getByRole('button', { name: /^Write \d+ files$/ })); + await advance(30_000); + expect(pipeline().getSnapshot().value).toBe('idle'); + + select('Quiet Classic'); + const details = () => screen.getByRole('region', { name: 'Export details' }); + expect( + within(details()).getByText(/^Album ReplayGain, RVA2 and iTunNORM were left as they are/), + ).toBeInTheDocument(); + select('Serato Cued'); + expect( + within(details()).getByText( + /^Serato's Auto Gain for this file was measured at the old level/, + ), + ).toBeInTheDocument(); + select('Night Drive'); + expect(within(details()).getByText(/^Analyse this FLAC again in rekordbox/)).toBeInTheDocument(); + expect(screen.getByText(/keep loudness tags at the old level/)).toBeInTheDocument(); + }); + + it('?demo=recovery: the check runs, then two changes are left with Forget', async () => { + installMockBackend('pending'); + render(); + await advance(800); + expect(screen.getByText('Finishing changes from the last session…', DRAWN)).toBeInTheDocument(); + await advance(3000); + const banner = screen.getByRole('region', { name: 'Changes from the last session' }); + expect( + within(banner).getByText('2 changes from the last session could not be finished'), + ).toBeInTheDocument(); + await act(async () => addPaths(['/Users/mock/Music/Crate'])); + await advance(10_000); + fireEvent.click( + within(banner).getAllByRole('button', { name: /^Forget the change to/ })[0] as HTMLElement, + ); + // While the step asks, the table's keys wait: the arrows do not move the selection. + const before = useLibrary.getState().selected; + fireEvent.keyDown(window, { key: 'ArrowDown' }); + expect(useLibrary.getState().selected).toBe(before); + fireEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Forget' })); + await advance(100); + expect( + screen.getByText('1 change from the last session could not be finished', DRAWN), + ).toBeInTheDocument(); + expect(screen.getByText(/is forgotten\. Nothing was deleted\./, DRAWN)).toBeInTheDocument(); + }); + + it('?demo=recovery-failed and ?demo=recovery-finished', async () => { + installMockBackend('failed'); + const { unmount } = render(); + await advance(100); + expect(screen.getByRole('alert')).toHaveTextContent(/could not be checked.*Permission denied/); + unmount(); + resetRecovery(); + installMockBackend('finished'); + render(); + await advance(100); + expect( + screen.getByText(/^2 changes from the last session were settled: 1 finished, 1 undone/, DRAWN), + ).toBeInTheDocument(); + }); +}); diff --git a/src/dev/mockRecovery.test.ts b/src/dev/mockRecovery.test.ts new file mode 100644 index 0000000..1f6fccb --- /dev/null +++ b/src/dev/mockRecovery.test.ts @@ -0,0 +1,35 @@ +import { mockRecovery, recoveryDemoOf } from './mockRecovery'; + +describe('the stand-in recovery', () => { + it('is picked by the URL', () => { + expect(recoveryDemoOf('?demo=recovery')).toBe('pending'); + expect(recoveryDemoOf('?demo=recovery-failed')).toBe('failed'); + expect(recoveryDemoOf('?demo=recovery-finished')).toBe('finished'); + expect(recoveryDemoOf('?demo=1')).toBe('none'); + expect(recoveryDemoOf('')).toBe('none'); + }); + + it('runs a moment, refuses Forget meanwhile, then forgets as the shell does', () => { + let t = 0; + const r = mockRecovery('pending', () => t); + expect(r.status()).toEqual({ state: 'running' }); + expect(() => r.forget('6ac5b4f4-75821-0')).toThrow(); + t = 10_000; + const before = r.status(); + expect(before.state === 'finished' && before.pending).toHaveLength(2); + // The copy to the stick keeps its hidden temp file and the empty file holding its name. + const copy = r.forget('6ac5b4f4-75821-0'); + expect(copy.backup).toBeNull(); + expect(copy.kept).toHaveLength(2); + expect(copy.kept[0]).toMatch( + /\/\.Mock Artist A - Night Drive \(Extended Mix\)\.flac\.soundcheck-tmp-/, + ); + expect(copy.kept[1]).toBe(copy.path); + expect(copy.status.state === 'finished' && copy.status.pending).toHaveLength(1); + expect(() => r.forget('6ac5b4f4-75821-0')).toThrow(); + // The change in place keeps the backup of the original. + const inPlace = r.forget('6ac5b4f4-75821-1'); + expect(inPlace.backup).toMatch(/SoundCheck Backups/); + expect(inPlace.kept).toEqual([inPlace.backup]); + }); +}); diff --git a/src/dev/mockRecovery.ts b/src/dev/mockRecovery.ts new file mode 100644 index 0000000..6f57394 --- /dev/null +++ b/src/dev/mockRecovery.ts @@ -0,0 +1,124 @@ +/** + * The start-up recovery of the browser stand-in, picked by the URL so each banner can be looked + * at: `?demo=recovery` (the check runs for a moment, then two changes are left pending and one is + * finished), `?demo=recovery-failed`, `?demo=recovery-finished`; anything else finds nothing. + * Reached through `mockBackend.ts` only. + */ +import type { ForgetResult, RecoveryStatus } from '@/lib/ipc'; + +export type RecoveryDemo = 'none' | 'pending' | 'failed' | 'finished'; + +/** How long the pending demo's check runs before it answers. */ +const RUNNING_MS = 2500; +const BACKUPS = '/Users/mock/Music/SoundCheck Backups/2026-11-04'; + +/** The demo the page's URL asks for. */ +export function recoveryDemoOf(search: string): RecoveryDemo { + switch (new URLSearchParams(search).get('demo')) { + case 'recovery': + return 'pending'; + case 'recovery-failed': + return 'failed'; + case 'recovery-finished': + return 'finished'; + default: + return 'none'; + } +} + +const PENDING = [ + { + txn: '6ac5b4f4-75821-0', + path: '/Volumes/GIG STICK/Crates/Friday/Mock Artist A - Night Drive (Extended Mix).flac', + reason: '/Volumes/GIG STICK/Crates/Friday is not reachable (volume not mounted?)', + toFolder: true, + }, + { + txn: '6ac5b4f4-75821-1', + path: '/Users/mock/Music/Crate/Mock Artist J - Big Room.wav', + reason: 'cleanup incomplete', + toFolder: false, + }, +]; + +function initial(demo: RecoveryDemo): RecoveryStatus { + switch (demo) { + case 'pending': + return { + state: 'finished', + recovered: [ + { + txn: '6ac5b4f4-75820-3', + path: '/Users/mock/Music/Crate/Mock Artist C - Quiet Classic.aiff', + outcome: 'completed', + }, + ], + pending: PENDING, + }; + case 'failed': + return { + state: 'failed', + message: 'I/O error on /Users/mock/Music/SoundCheck Backups/journal.jsonl: Permission denied (os error 13)', + }; + case 'finished': + return { + state: 'finished', + recovered: [ + { + txn: '6ac5b4f4-75820-1', + path: '/Users/mock/Music/Crate/Mock Artist A - Night Drive.flac', + outcome: 'completed', + }, + { + txn: '6ac5b4f4-75820-2', + path: '/Users/mock/Music/Crate/Mock Artist J - Big Room.wav', + outcome: 'rolledBack', + }, + ], + pending: [], + }; + case 'none': + return { state: 'finished', recovered: [], pending: [] }; + } +} + +/** A recovery that answers `recovery_status` and `forget_pending` as the shell does. */ +export function mockRecovery(demo: RecoveryDemo, now: () => number = Date.now) { + const started = now(); + let status = initial(demo); + const running = () => demo === 'pending' && now() - started < RUNNING_MS; + return { + status(): RecoveryStatus { + return running() ? { state: 'running' } : status; + }, + forget(txn: string): ForgetResult { + if (running()) { + throw { + kind: 'recoveryRunning', + message: 'the check for changes a crash interrupted is still running; nothing was done', + fileId: null, + }; + } + if (status.state !== 'finished' || !status.pending.some((p) => p.txn === txn)) { + throw { + kind: 'invalidArgument', + message: `change ${txn} is not left pending by recovery`, + fileId: null, + }; + } + const change = status.pending.find((p) => p.txn === txn); + status = { ...status, pending: status.pending.filter((p) => p.txn !== txn) }; + const path = change?.path ?? ''; + const slash = path.lastIndexOf('/'); + const name = path.slice(slash + 1); + // As the shell answers: a copy keeps its hidden temp file and the empty file holding its + // name; a change in place keeps the backup of the original. + if (change?.toFolder) { + const temp = `${path.slice(0, slash)}/.${name}.soundcheck-tmp-4242-7f3a`; + return { path, backup: null, kept: [temp, path], status }; + } + const backup = `${BACKUPS}/${name}`; + return { path, backup, kept: [backup], status }; + }, + }; +} diff --git a/src/features/export/ExportConfirm.notices.test.tsx b/src/features/export/ExportConfirm.notices.test.tsx new file mode 100644 index 0000000..519ca29 --- /dev/null +++ b/src/features/export/ExportConfirm.notices.test.tsx @@ -0,0 +1,92 @@ +import { render, screen } from '@testing-library/react'; +import { mockIPC } from '@tauri-apps/api/mocks'; +import type { ExportPreview, FileEntry } from '@/lib/ipc'; +import { pipeline } from '@/features/pipeline/actions'; +import { useLibrary } from '@/state/library'; +import { resetRecovery } from '@/state/recovery'; +import { useSettings } from '@/state/settings'; +import { analysed, entry, exportPreview, exportRow, previewRow } from '@/test/fixtures'; +import { ExportConfirm } from './ExportConfirm'; +import { openExportConfirm, useExportFlow } from './flow'; + +const initialLibrary = useLibrary.getState(); +const initialSettings = useSettings.getState(); + +function wav(fileId: number): FileEntry { + const e = entry(fileId); + return { ...e, path: `/music/Track ${fileId}.wav`, info: { ...e.info, codec: 'wav' } }; +} + +function add(...entries: FileEntry[]) { + useLibrary.getState().add(entries); + for (const e of entries) useLibrary.getState().applyEvent(analysed(e.fileId)); +} + +function backend(answer: ExportPreview) { + mockIPC((cmd) => (cmd === 'plan_export' ? answer : undefined)); +} + +const xmlOnly = exportRow({ + stage: 'skipped', + action: { type: 'xmlOnly', reason: { type: 'gridOnlyFlac' } }, + positionsShift: false, + notes: ['grid only on FLAC'], +}); + +beforeEach(() => { + useLibrary.setState(initialLibrary, true); + useSettings.setState({ ...initialSettings, place: 'inPlace' }, true); + resetRecovery(); + useExportFlow.setState({ + phase: 'closed', + request: null, + preview: null, + error: null, + leftOut: 0, + differing: [], + along: [], + planned: [], + again: false, + stays: '', + offer: '', + }); + pipeline().send({ type: 'FINISHED' }); +}); + +describe('the confirm step, about rekordbox and FLAC', () => { + it('says once, for the batch, that written FLAC files need analysing in rekordbox', async () => { + add(entry(1), entry(2), wav(3)); + backend( + exportPreview([ + previewRow(1, exportRow()), + previewRow(2, exportRow()), + previewRow(3, exportRow()), + ]), + ); + render(); + void openExportConfirm(); + const said = await screen.findAllByText(/written in place again in rekordbox before playing them from a CDJ/); + expect(said).toHaveLength(1); + expect(said[0]).toHaveTextContent(/^Analyse the 2 FLAC files written in place/); + expect(said[0]).toHaveTextContent(/import the tracks from the rekordbox XML after it, then turn on Analysis Lock\.$/); + }); + + it('says nothing about it for FLAC copies in another folder, which rekordbox analyses as new', async () => { + useSettings.setState({ place: 'folder', outDir: '/Users/me/Exports' }); + add(entry(1), entry(2)); + backend(exportPreview([previewRow(1, exportRow()), previewRow(2, exportRow())])); + render(); + void openExportConfirm(); + await screen.findByRole('button', { name: 'Write 2 files' }); + expect(screen.queryByText(/FLAC files? written in place/)).toBeNull(); + }); + + it('says nothing about it when no FLAC is written', async () => { + add(entry(1), wav(2)); + backend(exportPreview([previewRow(1, xmlOnly), previewRow(2, exportRow())])); + render(); + void openExportConfirm(); + await screen.findByRole('button', { name: 'Write 1 file' }); + expect(screen.queryByText(/FLAC files? written in place/)).toBeNull(); + }); +}); diff --git a/src/features/export/ExportConfirm.test.tsx b/src/features/export/ExportConfirm.test.tsx index 91969c1..afcbf38 100644 --- a/src/features/export/ExportConfirm.test.tsx +++ b/src/features/export/ExportConfirm.test.tsx @@ -260,11 +260,14 @@ describe('the confirm step', () => { it('a refusal shows the real copy, not [object Object], with no way to export', async () => { add(1); - backend({ throws: { kind: 'busy', message: 'raw', fileId: 1 } }); + const message = '/music/Track 1.flac is being reverted; try again when that has finished'; + backend({ throws: { kind: 'busy', message, fileId: 1 } }); render(); void openExportConfirm(); const alert = await screen.findByRole('alert'); - expect(alert).toHaveTextContent('Another job is working on one of these files'); + expect(alert).toHaveTextContent( + '/music/Track 1.flac is being reverted. Wait for it to finish, then press Export again.', + ); expect(alert).not.toHaveTextContent('[object'); expect(screen.queryByRole('button', { name: /^(Write|Export)/ })).toBeNull(); await userEvent.click(screen.getByRole('button', { name: 'Close' })); diff --git a/src/features/export/ExportConfirm.tsx b/src/features/export/ExportConfirm.tsx index ef390cd..8860038 100644 --- a/src/features/export/ExportConfirm.tsx +++ b/src/features/export/ExportConfirm.tsx @@ -1,12 +1,13 @@ import { Loader2 } from 'lucide-react'; import { useSelector } from '@xstate/react'; import { Modal } from '@/components/Modal'; -import { rowErrorCopy, sentence, type DecideSettings, type ExportPreviewRow, type ExportRequest } from '@/lib/ipc'; +import { errorCopy, rowErrorCopy, sentence, type DecideSettings, type ExportPreviewRow, type ExportRequest } from '@/lib/ipc'; import { displayTitle, level } from '@/lib/format'; import { pipeline } from '@/features/pipeline/actions'; import { Toggle } from '@/features/settings/controls'; import { useLibrary } from '@/state/library'; import { confirmExport, dismissExportConfirm, setExportAgain, useExportFlow } from './flow'; +import { flacConfirmText, flacWrites } from './notices'; import { countPreview, isRefusal, shortPath, summaryLine, type ExportCounts } from './summary'; const buttonClass = @@ -44,6 +45,14 @@ function CutWarning({ counts }: { counts: ExportCounts }) { ); } +/** Said once for the batch: rekordbox analyses a written FLAC again, before a CDJ plays it well. */ +function FlacNotice({ rows: planned, inPlace }: { rows: ExportPreviewRow[]; inPlace: boolean }) { + const count = useLibrary((l) => flacWrites(planned, l.rows, inPlace)); + const text = flacConfirmText(count); + if (!text) return null; + return

{text}

; +} + /** Each file the step has something to say about: why it is skipped, or what to know about it. */ function Particulars({ rows: planned }: { rows: ExportPreviewRow[] }) { const rows = useLibrary((l) => l.rows); @@ -51,7 +60,7 @@ function Particulars({ rows: planned }: { rows: ExportPreviewRow[] }) { const entry = rows[fileId]?.entry; const name = entry ? displayTitle(entry.info, entry.path) : `Track ${fileId}`; if (error) { - const text = entry ? rowErrorCopy(error, entry.path) : sentence(error.message); + const text = entry ? rowErrorCopy(error, entry.path, 'confirm') : errorCopy(error, 'confirm'); return [{ fileId, name, head: isRefusal(error) ? 'Refused' : 'Cannot be planned', text }]; } if (!row) return []; @@ -116,6 +125,7 @@ export function ExportConfirm() {

{settingsLine(request, preview.decide)}

+ {counts.replacing > 0 && (

{counts.replacing === 1 diff --git a/src/features/export/ExportDetail.test.tsx b/src/features/export/ExportDetail.test.tsx index 9cbe9dd..1c4e6a5 100644 --- a/src/features/export/ExportDetail.test.tsx +++ b/src/features/export/ExportDetail.test.tsx @@ -3,11 +3,28 @@ import userEvent from '@testing-library/user-event'; import { mockIPC } from '@tauri-apps/api/mocks'; import { useLibrary, type Row } from '@/state/library'; import { useRecovery } from '@/state/recovery'; -import { analysis, doneRow, entry, exportRow, plan } from '@/test/fixtures'; +import type { Codec, ExportedWith } from '@/lib/ipc'; +import type { SeratoTag } from '@/lib/ipc/generated/SeratoTag'; +import { analysis, doneRow, entry, exportRow, plan, verification } from '@/test/fixtures'; import { ExportDetail } from './ExportDetail'; const initial = useLibrary.getState(); +/** What an export was made with, in place or to a folder. */ +function madeWith(place: 'inPlace' | 'folder'): ExportedWith { + return { + decide: { mode: 'dj', target: -11, ceiling: -0.5, bpmRange: [70, 180] }, + settings: { batchMode: 'prepare', place, depth: null, gridOnly: false, tbpm: true, xml: true, leadMs: 5 }, + outDir: place === 'folder' ? '/Users/me/Exports' : null, + }; +} + +/** Track 1 as another format, with Serato data when given. */ +function track(codec: Codec, seratoTags: SeratoTag[] = []) { + const e = entry(1); + return { ...e, path: `/music/Track 1.${codec}`, info: { ...e.info, codec, serato: seratoTags.length > 0, seratoTags } }; +} + function show(overrides: Partial) { useLibrary.setState({ rows: { @@ -60,11 +77,74 @@ describe('the row details', () => { expect(screen.getByText(/no loudness to align/)).toBeInTheDocument(); }); - it('a failure: what, why, and that the file was not changed', () => { - show({ state: 'failed', exportError: { kind: 'busy', message: 'raw', fileId: 1 } }); + it("a failure in three lines: what happened, why in the engine's words, what to do", () => { + const message = '/music/Track 1.flac is being reverted; try again when that has finished'; + show({ state: 'failed', exportError: { kind: 'busy', message, fileId: 1 } }); + expect(screen.getByText('Failed: the file was not changed')).toBeInTheDocument(); + expect(screen.getByText('This file is being reverted.')).toBeInTheDocument(); + expect(screen.getByText('Wait for it to finish, then try again.')).toBeInTheDocument(); + }); + + it("a refusal says so, with the engine's own advice", () => { + const message = + '/music/Track 1.flac cannot be changed in place: it is locked (Finder: Get Info > Locked). Unlock it, or write a copy to a folder'; + show({ state: 'failed', exportError: { kind: 'inPlaceRefused', message, fileId: 1 } }); + expect(screen.getByText('Refused: the file was not changed')).toBeInTheDocument(); + expect(screen.getByText(/^This file cannot be changed in place: it is locked/)).toBeInTheDocument(); + expect(screen.getByText('Unlock it, or write a copy to a folder.')).toBeInTheDocument(); + }); + + it('a file that changed on disk: what to do is Analyse', () => { + const message = '/music/Track 1.flac changed since you confirmed the export'; + show({ state: 'failed', changedOnDisk: true, exportError: { kind: 'fileChanged', message, fileId: 1 } }); expect(screen.getByText('Failed')).toBeInTheDocument(); - expect(screen.getByText(/Another job is working/)).toBeInTheDocument(); - expect(screen.getByText('The file was not changed.')).toBeInTheDocument(); + expect(screen.getByText('Press Analyse to read it again.')).toBeInTheDocument(); + }); + + it('names the loudness tags a gain left at the old level, in plain words', () => { + const stale = ['TXXX:REPLAYGAIN_ALBUM_GAIN', 'TXXX:REPLAYGAIN_ALBUM_PEAK', 'RVA2', 'COMM:iTunNORM']; + show({ entry: track('aiff'), export: doneRow({ verification: verification({ staleLoudnessTags: stale }) }) }); + expect( + screen.getByText( + 'Album ReplayGain, RVA2 and iTunNORM were left as they are; players that use them will play this file at the old level.', + ), + ).toBeInTheDocument(); + expect(screen.queryByText(/Serato's Auto Gain/)).toBeNull(); + expect(screen.queryByText(/Analyse this FLAC/)).toBeNull(); + }); + + it("says Serato's Auto Gain is out of date only when the file has Autotags and its gain changed", () => { + const tagged = track('wav', ['markers', 'autotags']); + const stale = verification({ staleLoudnessTags: ['GEOB:Serato Autotags'] }); + const { unmount } = show({ entry: tagged, export: doneRow({ verification: stale }) }); + expect(screen.getByText(/^Serato's Auto Gain for this file was measured at the old level/)).toBeInTheDocument(); + // Said on a line of its own, not among the other tags. + expect(screen.queryByText(/were left as they are|was left as it is/)).toBeNull(); + unmount(); + // No gain change: the engine lists nothing, and nothing is said. + const same = doneRow({ action: { type: 'write', gainDb: 0, cut: { type: 'library' } } }); + const { unmount: gone } = show({ entry: tagged, export: same }); + expect(screen.queryByText(/Serato's Auto Gain/)).toBeNull(); + gone(); + // No Autotags in the file: nothing either. + show({ entry: track('wav', ['markers']), export: doneRow() }); + expect(screen.queryByText(/Serato's Auto Gain/)).toBeNull(); + }); + + it('tells a FLAC written in place to be analysed again in rekordbox, once it is written', () => { + const { unmount } = show({ export: doneRow({ exportedWith: madeWith('inPlace') }) }); + expect( + screen.getByText(/^Analyse this FLAC again in rekordbox before playing it from a CDJ/), + ).toHaveTextContent( + /import the track from the rekordbox XML after it, then turn on Analysis Lock, which stops rekordbox analysing it again or changing its grid\.$/, + ); + unmount(); + const { unmount: queued } = show({ state: 'exportQueued', export: exportRow({ exportedWith: madeWith('inPlace') }) }); + expect(screen.queryByText(/Analyse this FLAC/)).toBeNull(); + queued(); + // A copy in another folder is new to rekordbox and analysed when it is added: nothing to say. + show({ export: doneRow({ exportedWith: madeWith('folder') }) }); + expect(screen.queryByText(/Analyse this FLAC/)).toBeNull(); }); it('every note the row carries', () => { @@ -83,7 +163,7 @@ describe('the row details', () => { revertible: true, revertError: { kind: 'fileChanged', message: 'it changed since it was written', fileId: 1 }, }); - expect(screen.getByText('It changed since it was written')).toHaveClass('text-err'); + expect(screen.getByText(/^It changed since it was written\. Press Analyse/)).toHaveClass('text-err'); }); it('Revert is disabled for a file in the running batch, with why', () => { diff --git a/src/features/export/ExportDetail.tsx b/src/features/export/ExportDetail.tsx index d55f24e..ceb41ca 100644 --- a/src/features/export/ExportDetail.tsx +++ b/src/features/export/ExportDetail.tsx @@ -1,9 +1,11 @@ import { useLibrary, isLocked, type Row } from '@/state/library'; -import { rowErrorCopy, sentence } from '@/lib/ipc'; +import { refusal, rowErrorCopy, sentence } from '@/lib/ipc'; import { useRecovery } from '@/state/recovery'; import { revert, revertBlock } from './actions'; import { GridCheckBadge } from './ExportCells'; import { gridCheckChip } from './gridCheck'; +import { isRefusal } from './summary'; +import { AUTOTAGS_TEXT, autotagsStale, FLAC_ROW_TEXT, flacWritten, rowStaleTagsText } from './notices'; import { exportActionText, startText, verifiedText } from './text'; import { exportDifference, useExportNow, type ExportNow } from './work'; @@ -16,11 +18,15 @@ function Told({ row, now }: { row: Row; now: ExportNow }) { const shown = row.export; const different = exportDifference(row, now)?.text; if (row.state === 'failed' && row.exportError) { + // What happened, why (the engine's words), what to do. + const { why, todo } = refusal(row.exportError, 'row', row.entry.path); return ( <> - Failed - {rowErrorCopy(row.exportError, row.entry.path)} - {row.changedOnDisk ? 'Press Analyse to read it again.' : 'The file was not changed.'} + + {`${isRefusal(row.exportError) ? 'Refused' : 'Failed'}${row.changedOnDisk ? '' : ': the file was not changed'}`} + + {`${why}.`} + {todo} ); } @@ -54,6 +60,19 @@ function Told({ row, now }: { row: Row; now: ExportNow }) { {shown.notes.map((note) => ( {note} ))} + + + ); +} + +/** What to know about the written file in the DJ apps: tags at the old level, Serato, rekordbox. */ +function Notices({ row }: { row: Row }) { + const stale = rowStaleTagsText(row); + return ( + <> + {stale && {stale}} + {autotagsStale(row) && {AUTOTAGS_TEXT}} + {flacWritten(row) && {FLAC_ROW_TEXT}} ); } diff --git a/src/features/export/cells.test.tsx b/src/features/export/cells.test.tsx index 229280b..0214d16 100644 --- a/src/features/export/cells.test.tsx +++ b/src/features/export/cells.test.tsx @@ -110,7 +110,7 @@ describe('the Action column of an export', () => { } }); - it('a failed export says why and that the file was not changed, in red', () => { + it('a failed export says why, in red, and what to do', () => { render( { />, ); expect(screen.getByText('No space left')).toHaveClass('text-err'); - expect(screen.getByText('The file was not changed')).toBeInTheDocument(); + expect(screen.getByText('Free some space on that drive, or choose another folder')).toBeInTheDocument(); }); it('a row an export in place would refuse says why, once the confirm step is closed', () => { @@ -128,8 +128,8 @@ describe('the Action column of an export', () => { ]); const refused = useLibrary.getState().rows[1] as Row; render(); - expect(screen.getByText(/is a symbolic link/)).toBeInTheDocument(); - expect(screen.getByText('The file was not changed')).toBeInTheDocument(); + expect(screen.getByText('/music/Track 1.wav is a symbolic link')).toBeInTheDocument(); + expect(screen.getByText('Fix that and export it again, or export it To folder')).toBeInTheDocument(); }); it('before any export it still shows the plan', () => { diff --git a/src/features/export/flow.ts b/src/features/export/flow.ts index 7ffc402..335bbc7 100644 --- a/src/features/export/flow.ts +++ b/src/features/export/flow.ts @@ -163,7 +163,7 @@ async function planRows(mine: number): Promise { const refused = rows.flatMap(({ fileId, error }) => (error && isRefusal(error) ? [{ fileId, error }] : [])); if (refused.length > 0) useLibrary.getState().markRefused(refused); } catch (err) { - if (mine === attempt) useExportFlow.setState({ phase: 'failed', error: errorCopy(ipcErrorOf(err)) }); + if (mine === attempt) useExportFlow.setState({ phase: 'failed', error: errorCopy(ipcErrorOf(err), 'confirm') }); } } diff --git a/src/features/export/notices.test.ts b/src/features/export/notices.test.ts new file mode 100644 index 0000000..849d13f --- /dev/null +++ b/src/features/export/notices.test.ts @@ -0,0 +1,91 @@ +import type { Row } from '@/state/library'; +import { analysis, doneRow, entry, exportRow, plan, verification } from '@/test/fixtures'; +import { autotagsStale, flacConfirmText, hasStaleTags, staleTags, staleTagsText } from './notices'; + +function row(overrides: Partial): Row { + return { + entry: entry(1), + state: 'done', + progress: 1, + analysis: analysis(), + plan: plan(), + ...overrides, + }; +} + +describe('loudness tags left at the old level', () => { + it('names each kind once, as a DJ knows it, in the order of the file', () => { + const labels = [ + 'TXXX:REPLAYGAIN_ALBUM_GAIN', + 'TXXX:REPLAYGAIN_ALBUM_PEAK', + 'RVA2', + 'COMM:iTunNORM', + 'replaygain_track_gain', + 'R128_ALBUM_GAIN', + 'ITUNNORM', + ]; + expect(staleTags(labels).names).toEqual([ + 'Album ReplayGain', + 'RVA2', + 'iTunNORM', + 'Track ReplayGain', + 'Album R128 gain', + ]); + }); + + it('reads one tag in the singular', () => { + expect(staleTagsText(['RVA2'])).toBe( + 'RVA2 was left as it is; players that use it will play this file at the old level.', + ); + }); + + it("keeps Serato's for its own notice", () => { + expect(staleTags(['GEOB:Serato Autotags']).autotags).toBe(true); + expect(staleTags(['SERATO_AUTOGAIN']).autotags).toBe(true); + expect(staleTagsText(['GEOB:Serato Autotags', 'SERATO_AUTOTAGS'])).toBeNull(); + }); + + it('says there are more when the engine cut the list short', () => { + expect(staleTagsText(['RVA2', 'TXXX:REPLAYGAIN_AL...'])).toBe( + 'RVA2 and other loudness tags were left as they are; players that use them will play this file at the old level.', + ); + }); + + it('says nothing when there are none', () => { + expect(staleTagsText([])).toBeNull(); + }); + + it('counts a finished export only, for the footer', () => { + const stale = verification({ staleLoudnessTags: ['RVA2'] }); + expect(hasStaleTags(row({ export: doneRow({ verification: stale }) }))).toBe(true); + expect(hasStaleTags(row({ export: doneRow() }))).toBe(false); + expect( + hasStaleTags(row({ export: exportRow({ stage: 'writing', verification: stale }) })), + ).toBe(false); + }); +}); + +describe("Serato's auto gain", () => { + const tagged = { + ...entry(1), + info: { ...entry(1).info, serato: true, seratoTags: ['autotags' as const] }, + }; + + it('is out of date once a gain was written to a file that has Autotags', () => { + expect(autotagsStale(row({ entry: tagged, export: doneRow() }))).toBe(true); + }); + + it('is not, before the file is written, without a gain, or without Autotags', () => { + expect(autotagsStale(row({ entry: tagged, export: exportRow() }))).toBe(false); + const same = doneRow({ action: { type: 'write', gainDb: 0, cut: { type: 'library' } } }); + expect(autotagsStale(row({ entry: tagged, export: same }))).toBe(false); + expect(autotagsStale(row({ export: doneRow() }))).toBe(false); + }); +}); + +describe('the FLAC notice of the confirm step', () => { + it('counts the files, and is not there for none', () => { + expect(flacConfirmText(0)).toBeNull(); + expect(flacConfirmText(1)).toMatch(/^Analyse the 1 FLAC file written in place again in rekordbox before playing it/); + }); +}); diff --git a/src/features/export/notices.ts b/src/features/export/notices.ts new file mode 100644 index 0000000..1a4e5ca --- /dev/null +++ b/src/features/export/notices.ts @@ -0,0 +1,164 @@ +import type { ExportPreviewRow } from '@/lib/ipc'; +import type { Row } from '@/state/library'; + +/** + * What a DJ should know about a file once it is written, beyond what changed in it: loudness tags + * the gain left at the old level, Serato's auto gain, and rekordbox analysing a FLAC again. All of + * it comes from what the engine sent for the row (the verified write, its stale tags) and what it + * read of the file when it was added (its codec and Serato data). + */ + +/** How the engine marks a list item it had to cut short to stay within the row's budget. */ +const CUT_SHORT = '...'; + +/** A tag label as the engine reports it (`TXXX:REPLAYGAIN_ALBUM_GAIN`, `RVA2`, `SERATO_AUTOGAIN`). */ +function bare(label: string): string { + const colon = label.indexOf(':'); + return (colon >= 0 && colon <= 4 ? label.slice(colon + 1) : label).toUpperCase(); +} + +function isAutotags(label: string): boolean { + const name = bare(label); + return name === 'SERATO AUTOTAGS' || name === 'SERATO_AUTOTAGS' || name === 'SERATO_AUTOGAIN'; +} + +/** The name a DJ knows a loudness tag by; `null` for Serato's, which has a notice of its own. */ +function plainName(label: string): string | null { + if (isAutotags(label)) return null; + const name = bare(label); + if (name.startsWith('REPLAYGAIN_TRACK_')) return 'Track ReplayGain'; + if (name.startsWith('REPLAYGAIN_ALBUM_')) return 'Album ReplayGain'; + if (name.startsWith('REPLAYGAIN_')) return 'ReplayGain'; + if (name.startsWith('R128_TRACK_')) return 'Track R128 gain'; + if (name.startsWith('R128_ALBUM_')) return 'Album R128 gain'; + if (name.startsWith('R128_')) return 'R128 gain'; + if (name === 'RVA2') return 'RVA2'; + if (name === 'ITUNNORM') return 'iTunNORM'; + return label; +} + +function list(names: string[]): string { + if (names.length <= 1) return names[0] ?? ''; + return `${names.slice(0, -1).join(', ')} and ${names[names.length - 1] ?? ''}`; +} + +export interface StaleTags { + /** Plain names, each once, in the file's order. */ + names: string[]; + /** The engine cut the list short: there are more than named. */ + more: boolean; + /** Serato Autotags is among them. */ + autotags: boolean; +} + +/** The loudness tags a written file kept at the old level, by name. */ +export function staleTags(labels: readonly string[]): StaleTags { + const names: string[] = []; + let more = false; + let autotags = false; + for (const label of labels) { + if (label.endsWith(CUT_SHORT)) { + more = true; + continue; + } + if (isAutotags(label)) autotags = true; + const name = plainName(label); + if (name && !names.includes(name)) names.push(name); + } + return { names, more, autotags }; +} + +/** + * "Album ReplayGain, RVA2 and iTunNORM were left as they are; players that use them will play this + * file at the old level." `null` when there is none (Serato's has its own line). + */ +export function staleTagsText(labels: readonly string[]): string | null { + const { names, more } = staleTags(labels); + if (names.length === 0 && !more) return null; + const named = more + ? [...names, names.length > 0 ? 'other loudness tags' : 'some loudness tags'] + : names; + const many = named.length > 1 || more; + return `${list(named)} ${many ? 'were left as they are' : 'was left as it is'}; players that use ${many ? 'them' : 'it'} will play this file at the old level.`; +} + +/** Words for Serato's auto gain gone stale, as Serato names its settings. */ +export const AUTOTAGS_TEXT = + "Serato's Auto Gain for this file was measured at the old level: in Serato, analyse it again with Set Auto Gain on, or turn Auto Gain off."; + +/** The written file's change, when it was written: the gain the engine applied. */ +function writtenGain(row: Row): number | null { + const shown = row.export; + if (!shown?.verification || shown.action.type !== 'write') return null; + return shown.action.gainDb; +} + +/** + * The file holds Serato Autotags and its gain changed: Serato's auto gain now describes the old + * level. The engine lists the Autotags among the stale tags of exactly such a file; the Serato data + * read when the file was added says the same when that list was cut short. + */ +export function autotagsStale(row: Row): boolean { + const gain = writtenGain(row); + if (gain === null) return false; + const stale = row.export?.verification?.staleLoudnessTags ?? []; + if (staleTags(stale).autotags) return true; + return gain !== 0 && row.entry.info.seratoTags.includes('autotags'); +} + +/** The stale tags line of a written row, Serato's aside. */ +export function rowStaleTagsText(row: Row): string | null { + return writtenGain(row) === null + ? null + : staleTagsText(row.export?.verification?.staleLoudnessTags ?? []); +} + +/** Whether the row's finished export kept loudness tags at the old level, Serato's included. */ +export function hasStaleTags(row: Row): boolean { + if (row.export?.stage !== 'done') return false; + return rowStaleTagsText(row) !== null || autotagsStale(row); +} + +/** + * rekordbox keeps a seek index of each FLAC it has analysed, mapping positions to where they sit in + * the file's frames. Written in place, the file keeps its path, so rekordbox still knows it, but + * its frames are new: the index no longer matches until rekordbox analyses the file again, and + * that analysis makes its own beat grid. Importing the track from the rekordbox XML puts the + * exported grid back, and Analysis Lock then stops rekordbox analysing it again or changing its + * grid. A copy in another folder is a new file to rekordbox, analysed when it is added, so it + * needs no notice. + */ +export const FLAC_ROW_TEXT = + "Analyse this FLAC again in rekordbox before playing it from a CDJ: rekordbox keeps an index of where each part of a FLAC it knows sits, and writing it in place moved them, so until then the track can skip. That analysis can put rekordbox's own beat grid in place of the one written here; to keep this grid, import the track from the rekordbox XML after it, then turn on Analysis Lock, which stops rekordbox analysing it again or changing its grid."; + +/** The row holds a FLAC SoundCheck wrote in place, one rekordbox may already know. */ +export function flacWritten(row: Row): boolean { + const shown = row.export; + return ( + row.entry.info.codec === "flac" && + shown?.verification != null && + shown.exportedWith?.settings.place === "inPlace" + ); +} + +/** The confirm step's line when the batch writes FLAC files in place; `null` when it writes none. */ +export function flacConfirmText(count: number): string | null { + if (count === 0) return null; + const one = count === 1; + const files = one ? "1 FLAC file" : `${count} FLAC files`; + return `Analyse the ${files} written in place again in rekordbox before playing ${one ? "it" : "them"} from a CDJ: rekordbox keeps an index of where each part of a FLAC it knows sits, and writing in place moves them, so until then a track can skip. That analysis can put rekordbox's own beat grid in place of the exported one; to keep the exported grid, import ${one ? "the track" : "the tracks"} from the rekordbox XML after it, then turn on Analysis Lock.`; +} + +/** How many of the planned rows write a FLAC in place (`inPlace`: the export's place). */ +export function flacWrites( + planned: readonly ExportPreviewRow[], + rows: Record, + inPlace: boolean, +): number { + if (!inPlace) return 0; + return planned.filter( + (p) => + p.row?.action.type === "write" && + rows[p.fileId]?.entry.info.codec === "flac", + ).length; +} diff --git a/src/features/export/text.ts b/src/features/export/text.ts index 151ef9b..c2b3d11 100644 --- a/src/features/export/text.ts +++ b/src/features/export/text.ts @@ -94,6 +94,12 @@ export function lastExportText(last: LastExport): string { return `Last export: ${parts.join(', ')}${time}${end}`; } +/** The footer's word on files that kept loudness tags at the old level; `null` for none. */ +export function staleTagsSummary(files: number): string | null { + if (files === 0) return null; + return `${files} ${files === 1 ? 'keeps' : 'keep'} loudness tags at the old level`; +} + /** What the XML holds, said plainly: an empty one still is a file on disk. */ export function xmlListedText(artefacts: ExportArtefacts, leftOut: number): string { if (artefacts.listed === 0) return 'The rekordbox XML lists no tracks: none has a grid it may carry'; diff --git a/src/features/library/Footer.test.tsx b/src/features/library/Footer.test.tsx index ed4170e..d574410 100644 --- a/src/features/library/Footer.test.tsx +++ b/src/features/library/Footer.test.tsx @@ -3,7 +3,7 @@ import userEvent from '@testing-library/user-event'; import { mockIPC } from '@tauri-apps/api/mocks'; import { useLibrary } from '@/state/library'; import { useRecovery } from '@/state/recovery'; -import { analysed, artefacts, doneRow, entry } from '@/test/fixtures'; +import { analysed, artefacts, doneRow, entry, verification } from '@/test/fixtures'; import { Footer } from './Footer'; const initial = useLibrary.getState(); @@ -20,7 +20,14 @@ function finished() { lib().add([entry(1)]); lib().applyEvent(analysed(1)); lib().beginExport([1], []); - lib().applyEvent({ type: 'exportDone', jobId: 3, fileId: 1, row: doneRow(), inPlace: false, update: null }); + lib().applyEvent({ + type: 'exportDone', + jobId: 3, + fileId: 1, + row: doneRow(), + inPlace: false, + update: null, + }); } describe('the footer', () => { @@ -46,10 +53,11 @@ describe('the footer', () => { expect(screen.getByRole('button', { name: 'Cancelling…' })).toBeDisabled(); }); - it('links the batch\'s files after it, each showing in the Finder', async () => { + it("links the batch's files after it, each showing in the Finder", async () => { const shown: unknown[] = []; mockIPC((cmd, args) => { - if (cmd === 'plugin:opener|reveal_item_in_dir') shown.push((args as { path?: unknown; items?: unknown })); + if (cmd === 'plugin:opener|reveal_item_in_dir') + shown.push(args as { path?: unknown; items?: unknown }); return undefined; }); finished(); @@ -57,13 +65,111 @@ describe('the footer', () => { lib().applyEvent({ type: 'finished', jobId: 3, cancelled: false }); render(