max / audiofiles
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
59 files changed,
+568 insertions,
-532 deletions
| @@ -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. |
| @@ -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" |
| @@ -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" |
| @@ -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] |