Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
68ce755
feat(ipc): plan an export before it runs, tell a reloaded window
wow Nov 5, 2026
a0cdf1a
feat(ui): export from the app, with a confirm step and row states
wow Nov 5, 2026
1b0a8e3
test(ui): run a whole export through the screen on the stand-in
wow Nov 5, 2026
4d33d25
docs: describe exporting from the app
wow Nov 5, 2026
a9cb573
feat(cli)!: export to a folder unless in place is asked for
wow Nov 5, 2026
4dab992
feat(io): let an export replace a copy SoundCheck wrote
wow Nov 5, 2026
8b03e7a
feat(engine): keep one XML and report per folder
wow Nov 5, 2026
c5ed137
feat(ipc): confirm against identity and settings; keep more for a reload
wow Nov 5, 2026
d2460d2
feat(ui): confirm what is written, against the settings as they land
wow Nov 5, 2026
365bc0d
fix(ui): say a file's error of this file, not its long path
wow Nov 5, 2026
48af956
refactor(engine): check a confirmed file once, after its hash
wow Nov 5, 2026
2408d04
chore(cli): keep process within its lint limits
wow Nov 5, 2026
356ad0b
feat(engine): tell copies apart by source, refuse unreadable artefacts
wow Nov 5, 2026
2a9913d
test(io): cover replacing a copy, links, unreadable artefacts and skips
wow Nov 5, 2026
465c787
feat(ui): retry what can be cured, and ask for the folder
wow Nov 5, 2026
73776b7
test(io): make each window and strict read fail on its own
wow Nov 5, 2026
4bdd205
refactor(engine): keep process_file within its lint limit
wow Nov 5, 2026
ee2e3be
refactor(cli): keep run_process within its lint limit
wow Nov 5, 2026
5a5d288
test(cli): expect two copies on a case-sensitive volume
wow Nov 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,13 @@ SoundCheck is a free, open-source desktop app for DJs and producers. Drop a fold
> **Status: in development, no usable release yet.** What works today, from source:
> - **The app** analyses a dropped library (loudness, BPM, meter and bar 1, with a Needs-review queue) and opens any track in a grid view to inspect and fix its grid while a click plays along, with live IN/OUT meters, an original/processed A/B and a volume control. It does not write files yet.
> - **`sc-cli apply`** already changes WAV, AIFF and FLAC files: gain and an optional head trim (snapped back up to 1 ms to the quietest frame and faded in over 2 ms), every other chunk, block and tag carried byte for byte, verified, with a backup in `~/Music/SoundCheck Backups` and `sc-cli undo`.
> - **`sc-cli process <files> --batch-mode prepare|library`** exports: the planned gain, the Prepare cut and the tags written in place after a backup (or as copies with `--out`), verified against the planned length, with a sidecar recording the exported grid; grid edits and confirmations made in the app carry over to the exported file.
> - **`sc-cli process <files> --batch-mode prepare|library --out <dir>`** exports: the planned gain, the Prepare cut and the tags written as copies (or, with `--in-place`, in place after a backup; one of the two is required), verified against the planned length, with a sidecar recording the exported grid; grid edits and confirmations made in the app carry over to the exported file.
> - **`sc-cli process`** also writes the batch's **rekordbox XML** (`soundcheck-rekordbox.xml`: each gridded track's location, tags and a 4/4 `TEMPO` with bar 1 where the export put it; importing a track overwrites its rekordbox grid and info, so try one first) and a **`grid-report.csv`**, into the `--out` folder or `~/Music/SoundCheck/exports/<date time>/`; **`sc-cli xml <files>`** writes the XML for files already exported or only analysed.
> - **`sc-cli plan --batch-mode prepare|library`** previews what an export will do to each file: the gain, the head cut (`Cut 0.21 s`, `Starts on bar 1`, `Starts on a bar line (bar 1 at 8.00 s)`, `Not cut: bar 1 1.00 s in`, or `Not cut: grid needs review` until the grid is confirmed), the tags, or why a file is left to the rekordbox XML (MP3/AAC for now) or skipped.
>
> Exporting from the app and MP3 output come next; `sc-cli process` already checks every exported file's grid against the written file's own analysis. The first pre-release, `v0.1.0-alpha.1`, follows once exported files pass rekordbox 7's own analysis; `v0.1.0` is the first release for everyone. Watch the releases page or the changelog.
> - **Exporting from the app**: the rail's Export section, a summary of what will happen to each file before anything is written, row states with what was verified and each file's grid check, Revert, and the batch's rekordbox XML and `grid-report.csv` in the footer.
>
> MP3 output comes next; every exported file's grid is already checked against the written file's own analysis. The first pre-release, `v0.1.0-alpha.1`, follows once exported files pass rekordbox 7's own analysis; `v0.1.0` is the first release for everyone. Watch the releases page or the changelog.

## What v0.1 will do

Expand Down Expand Up @@ -40,7 +42,7 @@ pnpm dev:mock # the UI alone in a browser, with synthetic tracks
cargo run --release -p sc-cli -- analyze <file> # loudness, BPM, meter and bar 1
cargo run --release -p sc-cli -- plan <files> # what processing would do to each file
cargo run --release -p sc-cli -- plan <files> --batch-mode prepare # ... and what exporting would write
cargo run --release -p sc-cli -- process <files> --batch-mode prepare # export (backup first; --out <dir> for copies)
cargo run --release -p sc-cli -- process <files> --batch-mode prepare --out <dir> # export copies (--in-place replaces the files, backup first)
cargo run --release -p sc-cli -- apply <files> --gain-db -3 # change files (backup first; --out <dir> for copies)
cargo run --release -p sc-cli -- undo <files> # put the previous version back
cargo run --release -p sc-cli -- journal # recorded changes; `recover` finishes interrupted ones
Expand Down
3 changes: 2 additions & 1 deletion crates/sc-cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ The headless SoundCheck binary. `sc-cli analyze <file> --json` prints exactly th
File changes go through the write transaction (`sc_engine::apply_file`, `sc_io::txn`):

- `apply <files> --gain-db <dB> [--trim-samples N] [--bits 16|24] [--tag NAME=VALUE]... [--out <dir>] [--backup-root <dir>] [--no-keep-mtime] [--no-sidecar] [--json]` changes WAV, AIFF and FLAC files in place after backing them up (or writes copies into `--out`), verified. `--trim-samples N` asks for a cut of N frames: it is made up to 1 ms earlier (44 frames at 44.1 kHz, 48 at 48 kHz), at the frame whose loudest channel is quietest (the latest on a tie, never later than N), and the first 2 ms after it fade in (raised cosine from silence); the line says `500 frames trimmed (520 requested)`, and cue points, markers, loops, `bext` TimeReference and the FLAC CUESHEET move by the cut made. Tags use neutral names: `BPM` (ID3 `TBPM` + `TXXX:BPM`, Vorbis `BPM`), `INITIALKEY` (`TKEY` / `INITIALKEY`), any other `NAME` (`TXXX:NAME` / `NAME`); ID3v2.3/2.4 frame ids (and the iTunes ones, e.g. `TCMP`) and labels with `:` stop the run before any file; other four-letter names such as `MOOD` are ordinary names. A file listed twice, and with `--out` a second file with the same output name, is refused before anything is written.
- `process <files> --batch-mode prepare|library [--grid-only] [--out <dir>] [--depth source|16|24] [--lead-ms N] [--no-tbpm] [--no-xml] [--mode dj|streaming] [--target LUFS] [--ceiling dBTP] [--backup-root <dir>] [--jobs N] [--json]` exports files exactly as the app's Export button does (the engine's process job): crash recovery runs first, then each file is analysed (the cache is used, the grid edits saved in the app are applied), planned as `plan --batch-mode` plans it, written in place after a backup (or as a copy into `--out`) with its gain, Prepare cut and tags, verified, and analysed again, which refreshes its cache entry, and its grid checked against the one exported: a second line ` grid check: <result>` (`pass` when the written file's analysis gives a tempo within 0.005 BPM of the source's own analysis and a bar line within 5 ms of where the source's analysis put one relative to the exported bar 1, modulo the bar: for a grid the user did not edit, the exported bar 1 and tempo themselves; for an edited one both analyses are solved with the user's meter, tempo octave and fitted part only, so a nudged bar 1, a chosen beat 1 or a typed tempo neither agrees nor disagrees by itself. On a grid the user confirmed, any disagreement reads `the analysis reads this differently; your confirmed grid was kept: <detail>`, the detail worded as below. On a grid not confirmed, bar lines 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, read `check bar 1: the analysis reads it 1 beat earlier (bar line -416.7 ms, BPM +0.0000)`, and anything else `off by`, `BPM differs`, `meter differs` or `no grid found`. Otherwise `not checked: <why>`), `written.gridCheck` with `--json` (`{"result": "pass", "offsetMs", "bpmDiff"}`; `{"result": "advisory", "disagreement": {"kind": "offBy", "offsetMs", "bpmDiff"}}`, `kind` also `bpmDiffers` (`bpmDiff`, `found`), `meterDiffers` (`found`, `exported`) or `noGridFound`; `{"result": "checkBar1", "offsetMs", "bpmDiff", "beats"}`; `offBy`, `bpmDiffers`, `meterDiffers` and `noGridFound` with the same fields as those kinds; `{"result": "notChecked", "reason": {"type": "noGrid"|"gridWithheld"|"oddMeter"|"xmlOnly"|"notAnalysed"}}`; a reader should take a `result` it does not know as no result), and the sidecar's `export.gridCheck` (next to `export.grid.detectorOffsetMs`, `detectorBpm` and `meter`, what the source's analysis gave and what was exported). Every outcome is a result, never a failure of the run (exit code 0): it is the same detector reading its own output, not stable under gain, so a disagreement does not mean the file is wrong, though on a confirmed grid a real misplacement would show only as an advisory with its numbers, and an agreement is not proof that rekordbox agrees; a grid edit saved for it is carried over to the written file, bar 1 moved back by the cut. The output must have the planned frame count (Library: the source's; Prepare: the source's minus the cut) or nothing is replaced. Library keeps the modification time, Prepare does not. The tempo tag is written in Prepare only, unless `--no-tbpm`. One line per file in the order given, worded as `plan` words it: `<path>: prepare: Gain -2.0 dB, Cut 0.21 s; tags BPM, ...; written and verified; backup <path>` (or `; written to <path>`), `<path>: library: XML only: <why>`, or `<path>: prepare: skipped` with ` why:` and ` what to do:` lines; then `N files: W written, X XML only, S skipped, F failed`. Unless `--no-xml`, the batch's rekordbox XML (`soundcheck-rekordbox.xml`) and grid report (`grid-report.csv`) are written last, into `--out`, or for an export in place into a new folder `~/Music/SoundCheck/exports/<yyyy-mm-dd hh.mm.ss>/` (`SC_EXPORTS_ROOT` names another root), replacing only files of those names that SoundCheck wrote (with `--out`, a file of either name that SoundCheck did not write refuses the whole run in three lines, exit code 2, before anything is exported), and two lines name them (on stderr with `--json`). The XML lists each written file at its path now, and each file left to it (MP3/AAC), when it has a grid the XML may carry, spelled as its folder lists the name; each `TRACK` has its `TrackID` (1, 2, ... in the order listed, the key its playlist entries use, `KeyType` 0) and otherwise only `Name` and `Artist` (from the file's title and artist tags; the file name without a title tag, no `Artist` without an artist tag), `TotalTime`, `AverageBpm`, `Location` and one 4/4 `TEMPO`: `Inizio` the first beat at or after the start (after a Prepare cut, bar 1 at the lead), `Bpm` the written two-decimal BPM, `Battito` that beat's number in its bar. Importing a track from the XML overwrites its information, beat grid and BPM in rekordbox's collection: try one track first. A grid that needs review and was not confirmed, no grid, or a meter other than 4/4 keeps the file out of the XML. In Library mode only rows whose grid was confirmed in the app are listed (importing the XML would replace the DJ app's own grid); an MP3/AAC file in Library mode without a confirmed grid, or anywhere without a grid or with one that needs review, is skipped, since the XML would carry nothing for it; the others carry a note that their bar 1 is not yet verified against the encoder delay. The playlist is named after the batch (`SoundCheck <yyyy-mm-dd hh.mm.ss>`), since rekordbox replaces a playlist of the same name on import. The report has one row per file: `file,mode,action,gain_db,cut_s,bpm,bar1_s,grid,grid_check,notes` (UTF-8 with a byte-order mark so spreadsheet apps read Turkish names, CRLF, RFC 4180 quoting, text cells that would start a formula prefixed with `'`; `grid_check` the self-check of the exported grid, as worded on the `grid check:` line, with its numbers in every outcome). It is comma-separated with decimal points whatever the locale: Excel set to a locale that uses `;` and decimal commas (Turkish, German, ...) splits it wrongly on a double-click, so import it through Data > From Text/CSV (comma, point) or open it in Numbers. The sidecar (schema 2) records the export: settings, the plans, the grid as exported in the output's samples and the source's measurements.
- `process <files> --batch-mode prepare|library (--out <dir> | --in-place) [--grid-only] [--depth source|16|24] [--lead-ms N] [--no-tbpm] [--no-xml] [--mode dj|streaming] [--target LUFS] [--ceiling dBTP] [--backup-root <dir>] [--jobs N] [--json]` exports files exactly as the app's Export button does (the engine's process job): crash recovery runs first, then each file is analysed (the cache is used, the grid edits saved in the app are applied), planned as `plan --batch-mode` plans it, written in place after a backup (or as a copy into `--out`) with its gain, Prepare cut and tags, verified, and analysed again, which refreshes its cache entry, and its grid checked against the one exported: a second line ` grid check: <result>` (`pass` when the written file's analysis gives a tempo within 0.005 BPM of the source's own analysis and a bar line within 5 ms of where the source's analysis put one relative to the exported bar 1, modulo the bar: for a grid the user did not edit, the exported bar 1 and tempo themselves; for an edited one both analyses are solved with the user's meter, tempo octave and fitted part only, so a nudged bar 1, a chosen beat 1 or a typed tempo neither agrees nor disagrees by itself. On a grid the user confirmed, any disagreement reads `the analysis reads this differently; your confirmed grid was kept: <detail>`, the detail worded as below. On a grid not confirmed, bar lines 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, read `check bar 1: the analysis reads it 1 beat earlier (bar line -416.7 ms, BPM +0.0000)`, and anything else `off by`, `BPM differs`, `meter differs` or `no grid found`. Otherwise `not checked: <why>`), `written.gridCheck` with `--json` (`{"result": "pass", "offsetMs", "bpmDiff"}`; `{"result": "advisory", "disagreement": {"kind": "offBy", "offsetMs", "bpmDiff"}}`, `kind` also `bpmDiffers` (`bpmDiff`, `found`), `meterDiffers` (`found`, `exported`) or `noGridFound`; `{"result": "checkBar1", "offsetMs", "bpmDiff", "beats"}`; `offBy`, `bpmDiffers`, `meterDiffers` and `noGridFound` with the same fields as those kinds; `{"result": "notChecked", "reason": {"type": "noGrid"|"gridWithheld"|"oddMeter"|"xmlOnly"|"notAnalysed"}}`; a reader should take a `result` it does not know as no result), and the sidecar's `export.gridCheck` (next to `export.grid.detectorOffsetMs`, `detectorBpm` and `meter`, what the source's analysis gave and what was exported). Every outcome is a result, never a failure of the run (exit code 0): it is the same detector reading its own output, not stable under gain, so a disagreement does not mean the file is wrong, though on a confirmed grid a real misplacement would show only as an advisory with its numbers, and an agreement is not proof that rekordbox agrees; a grid edit saved for it is carried over to the written file, bar 1 moved back by the cut. The output must have the planned frame count (Library: the source's; Prepare: the source's minus the cut) or nothing is replaced. Library keeps the modification time, Prepare does not. The tempo tag is written in Prepare only, unless `--no-tbpm`. One line per file in the order given, worded as `plan` words it: `<path>: prepare: Gain -2.0 dB, Cut 0.21 s; tags BPM, ...; written and verified; backup <path>` (or `; written to <path>`), `<path>: library: XML only: <why>`, or `<path>: prepare: skipped` with ` why:` and ` what to do:` lines; then `N files: W written, X XML only, S skipped, F failed`. Unless `--no-xml`, the batch's rekordbox XML (`soundcheck-rekordbox.xml`) and grid report (`grid-report.csv`) are written last, into `--out`, or for an export in place into a new folder `~/Music/SoundCheck/exports/<yyyy-mm-dd hh.mm.ss>/` (`SC_EXPORTS_ROOT` names another root), replacing only files of those names that SoundCheck wrote (with `--out`, a file of either name that SoundCheck did not write refuses the whole run in three lines, exit code 2, before anything is exported), and two lines name them (on stderr with `--json`). The XML lists each written file at its path now, and each file left to it (MP3/AAC), when it has a grid the XML may carry, spelled as its folder lists the name; each `TRACK` has its `TrackID` (1, 2, ... in the order listed, the key its playlist entries use, `KeyType` 0) and otherwise only `Name` and `Artist` (from the file's title and artist tags; the file name without a title tag, no `Artist` without an artist tag), `TotalTime`, `AverageBpm`, `Location` and one 4/4 `TEMPO`: `Inizio` the first beat at or after the start (after a Prepare cut, bar 1 at the lead), `Bpm` the written two-decimal BPM, `Battito` that beat's number in its bar. Importing a track from the XML overwrites its information, beat grid and BPM in rekordbox's collection: try one track first. A grid that needs review and was not confirmed, no grid, or a meter other than 4/4 keeps the file out of the XML. In Library mode only rows whose grid was confirmed in the app are listed (importing the XML would replace the DJ app's own grid); an MP3/AAC file in Library mode without a confirmed grid, or anywhere without a grid or with one that needs review, is skipped, since the XML would carry nothing for it; the others carry a note that their bar 1 is not yet verified against the encoder delay. The playlist is named after the batch (`SoundCheck <yyyy-mm-dd hh.mm.ss>`), since rekordbox replaces a playlist of the same name on import. The report has one row per file: `file,mode,action,gain_db,cut_s,bpm,bar1_s,grid,grid_check,notes` (UTF-8 with a byte-order mark so spreadsheet apps read Turkish names, CRLF, RFC 4180 quoting, text cells that would start a formula prefixed with `'`; `grid_check` the self-check of the exported grid, as worded on the `grid check:` line, with its numbers in every outcome). It is comma-separated with decimal points whatever the locale: Excel set to a locale that uses `;` and decimal commas (Turkish, German, ...) splits it wrongly on a double-click, so import it through Data > From Text/CSV (comma, point) or open it in Numbers. The sidecar (schema 2) records the export: settings, the plans, the grid as exported in the output's samples and the source's measurements.
It must be told where files go: `--out <dir>` writes copies, `--in-place` replaces the files after a backup, and with neither it refuses in three lines and exits 2, because changing the originals is never a default. Exporting again with `--out` replaces a copy of the same file that SoundCheck wrote there, when its sidecar still proves it; any other file of that name, a copy of another track included, is left alone and the file reported as skipped. The folder's XML and report keep what they say about its other files, and one that starts like SoundCheck's but cannot be read whole refuses the batch.
- `xml <files> [--out FILE] [--jobs N] [--no-grid] [--bpm-range LOW-HIGH] [--model small|full]` writes a rekordbox XML of the files named, in the order given, Library ones included (naming them is the choice). Every file is analysed (cache used, grid edits saved in the app applied); a grid edited or confirmed in the app is used first, else an exported file's grid read back from its sidecar (schema 2) while the file is still the bytes that export wrote, else the analysis, withheld when it needs review and was not confirmed. Only files with a grid the XML may carry are listed, as `process` lists them. The XML goes to stdout or to `--out`, which must end in `.xml` and never replaces a file SoundCheck did not write (a glob such as `--out *.wav` putting an audio file there, another app's XML): that is refused in three lines (`<path>: not written`, ` why:`, ` what to do:`) with exit code 2 before anything is analysed. One line per file says what the XML carries or why it leaves the file out (`<path>: exported earlier; tempo: beat 1 at 0.005 s; 128.00 BPM`, `<path>: analysed; not in the XML: grid needs review`), then a summary with the playlist name, on stdout with `--out` and on stderr otherwise. Exit code 2 when a file failed (the XML lists the others).
- `undo <files>` puts back the version before each file's newest change.
- `journal [--incomplete] [--forget <id>] [--json]` lists the changes recorded in the backup folder, newest first, or gives up on a pending one (ids look like `6ac5b4f4-75821-0`).
Expand Down
1 change: 1 addition & 0 deletions crates/sc-cli/src/apply.rs
Original file line number Diff line number Diff line change
Expand Up @@ -459,6 +459,7 @@ pub fn run_apply(args: ApplyArgs) -> anyhow::Result<usize> {
keep_mtime: !args.no_keep_mtime,
sidecar: !args.no_sidecar,
cache: Some(cache),
replace_own_copy: false,
};
let cancel = CancelToken::new();
let mut failed = 0;
Expand Down
Loading
Loading