Skip to main content

max / audiofiles

Move spacing onto makeover-geometry, and strip AI tells Two changes in one commit. They overlap in 25 files, and separating them would have meant a geometry commit that still carried half the prose edits. Spacing. audiofiles kept a private six-rung ladder of float constants, a third copy of a vocabulary GoingsOn and Balanced Breakfast each maintained separately. It now reads makeover-geometry, the way it already reads makeover for colour. The ladder is gone and every distance is named for what it separates: bound for a control and the thing it belongs to, peer for items of a kind, up through page for the outer shell. hair survives below the relational floor, for the optical nudge inside a chip that is not separating anything. The values resolve through a Surface rather than being constants, because egui lays out in points and rasterises at pixels_per_point. The quantum is one physical pixel, so a gap rounds where the pixels are instead of at a whole point, and apply_theme records the density before anything resolves against it. egui's own Spacing struct maps onto the vocabulary nearly one for one. Themes lose their spacing. A theme overrides colour and nothing else, which is the entire reason this lives in a shared crate, so the [spacing] TOML section and the seven keys under it are gone. No bundled or custom theme set any of them, so nothing on disk changes meaning. rounding moves to [geometry] and stays a theme's to set, since the crate carries spacing only. The prose half is the standing em-dash and AI-tell sweep across comments, docs and copy. No behaviour in it.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-27 02:16 UTC
Signed with PGP, not checked
Commit: a0770cb91618ad04dfb9a683f4d92af65d512bd4
Parent: a03745b
59 files changed, +568 insertions, -532 deletions
M CONTRIBUTING.md +18 -18
@@ -35,7 +35,7 @@
35 35
36 36 ## Content-Addressed Storage
37 37
38 - Samples are identified by SHA-256 hash — the hash IS the primary key. There are no UUIDs or auto-increment IDs for samples.
38 + Samples are identified by SHA-256 hash. The hash IS the primary key. There are no UUIDs or auto-increment IDs for samples.
39 39
40 40 ```rust
41 41 pub struct SampleStore {
@@ -45,8 +45,8 @@
45 45
46 46 **Import flow:**
47 47 1. Stream file through SHA-256 hasher
48 - 2. Check if blob already exists at `samples/{hash}.{ext}` — if so, skip copy (dedup)
49 - 3. `INSERT OR IGNORE INTO samples` — dedup at DB level too
48 + 2. Check if blob already exists at `samples/{hash}.{ext}`; if so, skip copy (dedup)
49 + 3. `INSERT OR IGNORE INTO samples`, dedup at DB level too
50 50 4. Return the hash
51 51
52 52 **Rules:**
@@ -76,7 +76,7 @@
76 76
77 77 **Rules:**
78 78 - `Mutex<Receiver>` satisfies `Send + Sync` requirements for egui state.
79 - - `Arc<AtomicBool>` for lock-free cancellation — worker checks before each sample.
79 + - `Arc<AtomicBool>` for lock-free cancellation; worker checks before each sample.
80 80 - `Drop` implementation sends `Shutdown` command and joins the thread.
81 81 - The browser crate calls `try_recv()` each frame to poll for events.
82 82
@@ -112,14 +112,14 @@
112 112
113 113 The pipeline runs in a worker thread and processes each sample through these stages:
114 114
115 - 1. **Decode** — Symphonia decodes any audio format to mono f32
116 - 2. **Loudness** — Peak dB, RMS dB, LUFS (fast, uses full signal)
117 - 3. **Spectral** — STFT → centroid, flatness, rolloff, bandwidth, ZCR, onset strength
118 - 4. **MFCC + ML** — Extract MFCCs from magnitude frames, run through embedded neural classifier
119 - 5. **BPM** — Tempo detection (skipped for non-rhythmic samples if `smart_skip` enabled)
120 - 6. **Key** — Musical key detection (skipped for non-pitched samples if `smart_skip` enabled)
121 - 7. **Loop** — Loop point detection
122 - 8. **Fingerprint** — Peak envelope for near-duplicate detection (VP-tree similarity search)
115 + 1. **Decode**: Symphonia decodes any audio format to mono f32
116 + 2. **Loudness**: Peak dB, RMS dB, LUFS (fast, uses full signal)
117 + 3. **Spectral**: STFT → centroid, flatness, rolloff, bandwidth, ZCR, onset strength
118 + 4. **MFCC + ML**: Extract MFCCs from magnitude frames, run through embedded neural classifier
119 + 5. **BPM**: Tempo detection (skipped for non-rhythmic samples if `smart_skip` enabled)
120 + 6. **Key**: Musical key detection (skipped for non-pitched samples if `smart_skip` enabled)
121 + 7. **Loop**: Loop point detection
122 + 8. **Fingerprint**: Peak envelope for near-duplicate detection (VP-tree similarity search)
123 123
124 124 All results stored in `audio_analysis` table (one row per hash). The `smart_skip` feature uses ML classification to skip irrelevant stages (e.g., no BPM detection for ambient textures).
125 125
@@ -134,8 +134,8 @@
134 134 ## Unsafe FFI
135 135
136 136 Platform-specific drag-and-drop requires FFI:
137 - - **macOS:** `drag_out/macos.rs` — objc2 message sends, libdispatch async to main thread
138 - - **Windows:** `drag_out/windows.rs` — COM/OLE `DoDragDrop`
137 + - **macOS:** `drag_out/macos.rs`: objc2 message sends, libdispatch async to main thread
138 + - **Windows:** `drag_out/windows.rs`: COM/OLE `DoDragDrop`
139 139
140 140 **Rules:**
141 141 - Every `unsafe` block MUST have a `// SAFETY:` comment explaining the invariant.
@@ -193,7 +193,7 @@
193 193
194 194 Production uses a file-backed SQLite database. Tests use `:memory:`.
195 195
196 - When adding a migration, make it **replay-safe**: every `CREATE TABLE / INDEX / TRIGGER` should be `IF NOT EXISTS` (or preceded by `DROP IF EXISTS` for triggers whose body changes), and any seed insert should be `INSERT OR IGNORE`. The `migration_replay_from_version_two_against_full_schema` test in `db.rs` rolls `user_version` back to 2 and re-runs every migration from M003 onward against a populated schema — non-idempotent CREATEs fail it. M001 (initial schema) and M002 (`DROP TABLE tags; ALTER tags_v2 RENAME TO tags`) are inherently one-shot and excluded from the replay test.
196 + When adding a migration, make it **replay-safe**: every `CREATE TABLE / INDEX / TRIGGER` should be `IF NOT EXISTS` (or preceded by `DROP IF EXISTS` for triggers whose body changes), and any seed insert should be `INSERT OR IGNORE`. The `migration_replay_from_version_two_against_full_schema` test in `db.rs` rolls `user_version` back to 2 and re-runs every migration from M003 onward against a populated schema; non-idempotent CREATEs fail it. M001 (initial schema) and M002 (`DROP TABLE tags; ALTER tags_v2 RENAME TO tags`) are inherently one-shot and excluded from the replay test.
197 197
198 198 The connection registers a custom `hash_row_id(salt, key)` SQLite function on open (rusqlite `functions` feature). It's used by the M018 sync triggers; if you write a migration that creates new sync triggers, prefer it for any row_id that would otherwise leak user content.
199 199
@@ -253,12 +253,12 @@
253 253 |------|-------|---------|---------|
254 254 | `validate_sample` | `info` (sample metadata) | `bool` | Accept/reject sample for device |
255 255 | `transform_filename` | `name`, `ctx` | `String` | Rename for device conventions |
256 - | `pre_export` | `ctx` | — | Run before export batch |
257 - | `post_export` | `ctx` | — | Run after export batch |
256 + | `pre_export` | `ctx` | none | Run before export batch |
257 + | `post_export` | `ctx` | none | Run after export batch |
258 258
259 259 ## Concurrency
260 260
261 - - `parking_lot::Mutex` everywhere (not `std::sync::Mutex`) — no poisoning, shorter lock API.
261 + - `parking_lot::Mutex` everywhere (not `std::sync::Mutex`), no poisoning, shorter lock API.
262 262 - `#[instrument(skip_all)]` on all significant functions.
263 263 - Worker threads for long-running operations (never block the UI thread).
264 264 - The egui render loop polls `backend.poll_events()` each frame for worker results.
M Cargo.lock +7
@@ -434,6 +434,7 @@
434 434 "hound",
435 435 "libc",
436 436 "makeover",
437 + "makeover-geometry",
437 438 "objc2 0.6.4",
438 439 "objc2-app-kit 0.3.2",
439 440 "objc2-foundation 0.3.2",
@@ -2977,6 +2978,12 @@
2977 2978 "toml 1.1.3+spec-1.1.0",
2978 2979 ]
2979 2980
2981 + [[package]]
2982 + name = "makeover-geometry"
2983 + version = "0.1.0"
2984 + source = "registry+https://github.com/rust-lang/crates.io-index"
2985 + checksum = "9d8b94e51333489b0a79bb63ad4100ca49726f2048e2477f6ee3d6dd822f9327"
2986 +
2980 2987 [[package]]
2981 2988 name = "matchers"
2982 2989 version = "0.2.0"
M Cargo.toml +3
@@ -58,6 +58,9 @@
58 58 midir = "0.11"
59 59 tagtree = { path = "../../MNW/shared/tagtree" }
60 60 makeover = { path = "../../Libraries/makeover" }
61 + # The invariant half of the design system: relational spacing. makeover
62 + # resolves colour, which a theme may override; this carries what no theme may.
63 + makeover-geometry = "0.1.0"
61 64
62 65 [workspace.lints.rust]
63 66 unused = "warn"
M synckit.toml +1 -1
@@ -1,4 +1,4 @@
1 - # SyncKit configuration — embedded in distribution builds.
1 + # SyncKit configuration, embedded in distribution builds.
2 2 # The API key is a client identifier (not a secret). It identifies this app
3 3 # to the MNW server. User authentication happens via OAuth2 PKCE.
4 4 api_key = "37cde0c1499190fd54aba024af135d8b25e0e85b400583f4edc9ba7bd4eeb725"
@@ -4,7 +4,7 @@
4 4 WiX toolset) and build-msi.sh (macOS/Linux fallback via msitools wixl).
5 5
6 6 Tokens replaced at build time:
7 - @VERSION@ — semver from crates/audiofiles-app/Cargo.toml, with ".0" suffix
7 + @VERSION@ semver from crates/audiofiles-app/Cargo.toml, with ".0" suffix
8 8 (WiX requires four-part version)
9 9 -->
10 10 <Wix xmlns="http://schemas.microsoft.com/wix/2006/wi">
@@ -5,11 +5,11 @@
5 5
6 6 .DESCRIPTION
7 7 Runs on the windows-x86 build machine. Uses the WiX Toolset (candle.exe,
8 - light.exe) — install from https://wixtoolset.org/ or `winget install
8 + light.exe). Install from https://wixtoolset.org/ or `winget install
9 9 WiXToolset.WiXToolset`.
10 10
11 11 Code signing is opt-in via environment variables. If unset, the script
12 - produces an unsigned MSI/EXE (current state — Azure certificate blocker
12 + produces an unsigned MSI/EXE (current state: Azure certificate blocker
13 13 documented in docs/deploy.md).
14 14
15 15 .EXAMPLE
@@ -96,7 +96,7 @@
96 96 $StagedExe = Join-Path $Staging 'Audiofiles.exe'
97 97 $signed = Invoke-Signtool -Path $StagedExe
98 98 if (-not $signed) {
99 - Write-Host "==> AF_SIGN_CERT unset — producing UNSIGNED build (see docs/deploy.md)" -ForegroundColor Yellow
99 + Write-Host "==> AF_SIGN_CERT unset, producing UNSIGNED build (see docs/deploy.md)" -ForegroundColor Yellow
100 100 }
101 101
102 102 # Step 3: Generate WiX source from template
@@ -57,18 +57,21 @@
57 57
58 58 ### Spacing tokens
59 59
60 - Replace every literal `add_space(N.0)` with one of these. New code may not introduce new spacing magnitudes without adding a token here.
60 + Never a literal `add_space(N.0)`. Pick by what the space separates, not by how big it should be; the size is a consequence of the name.
61 61
62 - | Name | Value | Use |
63 - |--------------------|-------|--------------------------------------------------------------------|
64 - | `space::xs` | 2.0 | Tight inline (button-icon gap, tag chip internal). |
65 - | `space::sm` | 4.0 | After a label, before its control (52 occurrences today). |
66 - | `space::md` | 8.0 | Default between unrelated controls in a row (58 today). |
67 - | `space::lg` | 12.0 | Between minor sections within a panel (19 today). |
68 - | `space::section` | 16.0 | Between major sections (= existing `section_spacing` token). |
69 - | `space::xl` | 20.0 | Reserved for headline padding in empty states. |
62 + | Name | Use |
63 + |--------------------|------------------------------------------------------------------------------|
64 + | `space::bound()` | A control and the thing it belongs to: a label and its field, an icon and its text. Reads as one object. |
65 + | `space::peer()` | Items of a kind: controls in a toolbar, rows in a list, chips in a cluster. |
66 + | `space::group()` | A panel's inner margin, and the distance between sibling groups. |
67 + | `space::section()` | Separated groups and rows of actions. The first gap that reads as a break. |
68 + | `space::pane()` | Panel padding and content shells. Layout, not controls. |
69 + | `space::page()` | The outermost shell margin. |
70 + | `space::hair()` | Below the relational floor: an optical nudge inside a chip. A step, not a relationship, and reaching for it is worth a second look. |
70 71
71 - The existing TOML keys `section_spacing`, `grid_row_spacing`, `item_spacing_x/y`, `button_padding_x/y`, `rounding` remain; the new `space::*` constants live in `theme.rs` as `pub const` and are theme-independent.
72 + The values come from the [`makeover-geometry`](https://makenot.work/git/max/makeover-geometry) crate, which is where Balanced Breakfast and GoingsOn get the same vocabulary. Each is a ratio of a base unit rather than a pixel count, resolved against a surface whose quantum is one physical pixel, so the layout is honest at any display density.
73 +
74 + **Spacing is not themeable, and this is the one hard rule.** A theme overrides colour; geometry is invariant, which is exactly why it belongs to a shared crate rather than to this app. The old `[spacing]` TOML section is gone, along with the `section_spacing`, `grid_row_spacing`, `item_spacing_x/y` and `button_padding_x/y` keys. `rounding` survives under a `[geometry]` section because the crate carries spacing only.
72 75
73 76 ### Stroke / radius
74 77
@@ -92,7 +95,7 @@
92 95
93 96 ### Headers and section structure
94 97
95 - - **`section_header(ui, "Vaults")`**: `.strong()`, `text_secondary()`, followed by `ui.separator()` and `space::sm`.
98 + - **`section_header(ui, "Vaults")`**: `.strong()`, `text_secondary()`, followed by `ui.separator()` and `space::bound()`.
96 99 - **`subsection_label(ui, "Save as Collection")`**: same colour, no separator. For sub-blocks inside an already-headed section.
97 100 - **`filter_section(ui, label, active, |ui| { … })`**: `CollapsingHeader` with `"* "` marker when active and `default_open(active)`. Replaces the 5-way duplication in `filter_panel.rs`.
98 101
@@ -128,7 +131,7 @@
128 131
129 132 ### Banners and notifications
130 133
131 - - **`info_banner(ui, body)`**: frame with `bg_tertiary()`, `corner_radius(4)`, `space::md` inset; used by the existing VFS first-run banner and any future inline tips.
134 + - **`info_banner(ui, body)`**: frame with `bg_tertiary()`, `corner_radius(4)`, `space::group()` inset; used by the existing VFS first-run banner and any future inline tips.
132 135 - **`toast(ctx, severity, body)`**: *new* primitive. Timed transient notification surfaced from a state-owned queue. Replaces the current "set `state.status = '...'`" pattern for errors and ephemeral confirmations (rename success, copy-to-clipboard). The existing footer status label is retained for *persistent* state ("Sync: 3 pending") but should no longer be the channel for transient error feedback.
133 136 - **`loading_spinner(ui)` / `busy_indicator(ui, label)`**: *new* primitive for in-flight operations that aren't full-screen (sidebar refresh, sync running). Import has its own progress screen and is out of scope here.
134 137
@@ -145,9 +148,9 @@
145 148
146 149 | Shape | Egui type | Conventions |
147 150 |----------------------|----------------------------|-----------------------------------------------------------------------|
148 - | Left sidebar | `SidePanel::left` | Sections separated by `section_header` + `space::lg` between groups. |
151 + | Left sidebar | `SidePanel::left` | Sections separated by `section_header` + `space::group()` between groups. |
149 152 | Top toolbar | `TopBottomPanel::top` | Two rows max (breadcrumb + search). Right-aligned actions via `Layout::right_to_left`. |
150 - | Right detail | `SidePanel::right` | Sections paced by `space::section`. Waveform always first. |
153 + | Right detail | `SidePanel::right` | Sections paced by `space::section()`. Waveform always first. |
151 154 | Central | `CentralPanel::default` | Either the file list table or a full-screen wizard (import/export). |
152 155 | Bottom footer | `TopBottomPanel::bottom` | One row. Transport on the left, status text on the right. |
153 156 | Modal | `modal_window` widget | Centered, fixed-size unless explicitly resizable (bulk rename only). |
@@ -13,7 +13,7 @@
13 13 - "No audio data" → File decoded to zero samples (silent or truncated).
14 14
15 15 2. **Batch stuck with no error?**
16 - - Worker thread may be stuck on a very large file
16 + - Worker thread may be stuck on a large file
17 17 - Cancel the batch (UI cancel button sends `WorkerCommand::Cancel` + sets `AtomicBool`)
18 18 - Cancel takes effect after the current sample finishes processing
19 19 - If cancel doesn't work in 30s, quit and restart app
@@ -118,7 +118,7 @@
118 118 |---------|-------|-----|
119 119 | "Auth error: token expired" | SyncKit token expired | Re-authenticate in sync settings |
120 120 | "Sync client error: connection timeout" | Network down or server unreachable | Check internet, verify server URL |
121 - | Sample shows "cloud only" but won't download | Upload failed or remote file deleted | Re-trigger sync. If file truly lost, re-import from original. |
121 + | Sample shows "cloud only" but won't download | Upload failed or remote file deleted | Re-trigger sync. If the file is lost, re-import from original. |
122 122 | Changes don't appear on other devices | Push failed or changelog empty | Check `sync_changelog` table for unpushed entries |
123 123 | Changelog growing unbounded | Old entries never cleaned | `DELETE FROM sync_changelog WHERE pushed = 1 AND timestamp < datetime('now', '-30 days')` |
124 124
@@ -26,6 +26,7 @@
26 26 rusqlite = { workspace = true }
27 27 tracing = { workspace = true }
28 28 makeover = { workspace = true }
29 + makeover-geometry = { workspace = true }
29 30 rayon = { workspace = true, optional = true }
30 31
31 32 [target.'cfg(unix)'.dependencies]