max / audiofiles
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
- Claude-Session
- https://claude.ai/code/session_01MptwXZ8k65v19rFmdGAyki
58 files changed,
+395 insertions,
-713 deletions
| @@ -199,7 +199,7 @@ | |||
| 199 | 199 | ||
| 200 | 200 | Every synced table has triggers that insert into `sync_changelog` on INSERT/UPDATE/DELETE. A `sync_state` row (`applying_remote = '1'`) suppresses triggers during pull operations to prevent recursion. | |
| 201 | 201 | ||
| 202 | - | Per migration M018 (2026-06-02), `sync_changelog.row_id` is hashed via `hash_row_id(row_id_salt, canonical_key)` for sensitive tables (samples, audio_analysis, tags, collection_members) so the server never sees raw sample hashes or tag strings. The salt is generated per device, stored in `sync_state`, never synced. DELETE triggers also emit the canonical PK in the encrypted `data` field, which `resolve::apply_delete` reads to reconstruct WHERE clauses without parsing the (now-opaque) row_id. When adding a new synced table, follow the same pattern: wrap row_id in `hash_row_id(...)` if it carries user content, and emit the canonical PK into `data` for DELETE. | |
| 202 | + | Per migration M018, `sync_changelog.row_id` is hashed via `hash_row_id(row_id_salt, canonical_key)` for sensitive tables (samples, audio_analysis, tags, collection_members) so the server never sees raw sample hashes or tag strings. The salt is generated per device, stored in `sync_state`, never synced. DELETE triggers also emit the canonical PK in the encrypted `data` field, which `resolve::apply_delete` reads to reconstruct WHERE clauses without parsing the (now-opaque) row_id. When adding a new synced table, follow the same pattern: wrap row_id in `hash_row_id(...)` if it carries user content, and emit the canonical PK into `data` for DELETE. | |
| 203 | 203 | ||
| 204 | 204 | ### rusqlite + async | |
| 205 | 205 |
| @@ -43,21 +43,13 @@ | |||
| 43 | 43 | ||
| 44 | 44 | Only against another run on the same machine, the same drive, and the **same | |
| 45 | 45 | corpus contents**. All three are part of the measurement, and only the first two | |
| 46 | - | are recorded in the file. | |
| 46 | + | are recorded in the file. Adding files to the corpus changes the weighting, so a | |
| 47 | + | throughput delta across two different corpora is not a code result. | |
| 47 | 48 | ||
| 48 | - | The corpus one has already bitten. The 2026-07-29 baselines here read 2,241 files | |
| 49 | - | where the earlier figure in wiki `af-benchmarks` read 1,761, because the four | |
| 50 | - | 120-file format arms added for the per-format decode section landed inside | |
| 51 | - | `samples/` and the ingest walk picks them up. Aggregate throughput came out 168 | |
| 52 | - | files/s against the older 204, which looks like a 17% regression and is not one: | |
| 53 | - | it is a different set of files, weighted differently. An A/B of the same corpus | |
| 54 | - | across the two commits showed the change cost nothing (see below). | |
| 49 | + | Rules: | |
| 55 | 50 | ||
| 56 | - | Rules that follow from that: | |
| 57 | - | ||
| 58 | - | - **Take a baseline with the machine and drive idle.** Import is I/O bound. A run | |
| 59 | - | taken while a dataset was downloading to the same drive read 40.7 files/s | |
| 60 | - | against 204 for the same corpus, with no code change. | |
| 51 | + | - **Take a baseline with the machine and drive idle.** Import is I/O bound, and | |
| 52 | + | competing I/O on the same drive can cost most of the throughput on its own. | |
| 61 | 53 | - **Use a fresh vault path.** Pointing at an existing scratch vault measures | |
| 62 | 54 | dedup, not import, because the store finds the blobs already there. | |
| 63 | 55 | - **Do not compare across corpora.** If the corpus changed, re-measure both arms | |
| @@ -70,23 +62,13 @@ | |||
| 70 | 62 | ||
| 71 | 63 | | file | what | | |
| 72 | 64 | |---|---| | |
| 73 | - | | `ingest-2026-07-29-785fbeb.json` | Pre-fanout control: flat blob store root. | | |
| 74 | - | | `ingest-2026-07-29-1258f9e.json` | Was the baseline. Now a pre-`1a0edd0` control. | | |
| 65 | + | | `ingest-2026-07-29-785fbeb.json` | Control: flat blob store root, no shard fanout. | | |
| 66 | + | | `ingest-2026-07-29-1258f9e.json` | Control: sharded store, pre-`1a0edd0` write path. | | |
| 75 | 67 | ||
| 76 | - | Taken back to back, machine and drive idle, identical corpus and batch size, a | |
| 77 | - | fresh vault each. They come out 168.3 against 168.4 files/s and 106.5 MB/s each, | |
| 78 | - | so sharding the blob directory costs nothing measurable at this size. That is the | |
| 79 | - | expected result rather than a disappointing one: at 2,241 blobs a flat directory | |
| 80 | - | was never the problem. The layout change is for the 289k-file case, where the flat | |
| 81 | - | root lost about 90% of its throughput. | |
| 82 | - | ||
| 83 | - | **Neither file is a comparison target any more, and there is no ingest baseline | |
| 84 | - | until someone takes one.** `1a0edd0` changed the import write path underneath | |
| 85 | - | them: the per-file directory fsync became one per shard directory at the end of | |
| 86 | - | the run, and the streaming hash/copy buffers went from 8 KiB to 256 KiB. Both | |
| 87 | - | make import faster, so a run that beats 168.4 files/s says nothing about the code | |
| 88 | - | it is testing until it has a post-`1a0edd0` baseline to sit against. Same trap as | |
| 89 | - | the corpus one above, from the other direction: there, the file set moved under a | |
| 90 | - | fixed measurement; here, the measured code moved under a fixed file set. Take the | |
| 91 | - | new baseline the way the rules above say, machine and drive idle, and this section | |
| 92 | - | can be rewritten around it. | |
| 68 | + | **Neither file is a comparison target, and there is no ingest baseline until | |
| 69 | + | someone takes one.** `1a0edd0` changed the import write path underneath them: the | |
| 70 | + | per-file directory fsync became one per shard directory at the end of the run, and | |
| 71 | + | the streaming hash/copy buffers went from 8 KiB to 256 KiB. Both make import | |
| 72 | + | faster, so a run that beats these numbers says nothing about the code it is | |
| 73 | + | testing until there is a post-`1a0edd0` baseline to sit against. Take the new | |
| 74 | + | baseline the way the rules above say, machine and drive idle. |
| @@ -51,7 +51,7 @@ | |||
| 51 | 51 | ||
| 52 | 52 | ## Database Schema | |
| 53 | 53 | ||
| 54 | - | SQLite with 19 versioned migrations: | |
| 54 | + | SQLite, with versioned inline migrations: | |
| 55 | 55 | ||
| 56 | 56 | | Table | Purpose | | |
| 57 | 57 | |-------|---------| | |
| @@ -81,7 +81,7 @@ | |||
| 81 | 81 | - **BPM**: Onset-based tempo estimation using spectral flux and autocorrelation. | |
| 82 | 82 | - **Key**: Musical key detection from chroma features. | |
| 83 | 83 | - **MFCC**: Mel-Frequency Cepstral Coefficients computed from existing STFT magnitudes (26-band mel filterbank, log energy, DCT-II, 13 coefficients). Aggregated as mean + variance across frames (26 features total). | |
| 84 | - | - **Classification**: Deterministic DSP only, and multi-label. `analysis/features.rs` assembles the 35-feature vector (9 spectral/waveform + 26 MFCC), persists it to `sample_features`, and the layered tag pipeline reads it (rules, exemplar k-NN, optional trained head, `.afcl` layers). The single-label `SampleClass` and the threshold tree behind it were removed: 33.4% strict accuracy with two classes unreachable, and the features carry family structure rather than instrument identity. Nothing model-derived ships in the binary today; the accepted plan is to bundle an official `.afcl` layer built from a CC-BY 4.0 corpus, with attribution carried in the manifest. See `ml_classifier.md`. | |
| 84 | + | - **Classification**: Deterministic DSP only, and multi-label. `analysis/features.rs` assembles the 35-feature vector (9 spectral/waveform + 26 MFCC), persists it to `sample_features`, and the layered tag pipeline reads it (rules, exemplar k-NN, optional trained head, `.afcl` layers). There is no single-label class. Nothing model-derived ships in the binary. See `ml_classifier.md`. | |
| 85 | 85 | - **Loop detection**: Identifies whether a sample is a seamless loop. | |
| 86 | 86 | - **Fingerprinting**: Computes an amplitude envelope fingerprint for near-duplicate detection across the library. | |
| 87 | 87 | - **Starter rules**: `core/src/starter_rules.rs` seeds Layer A with filename keyword rules (disabled until enabled), including the guard rules that suppress an ambiguous layered-hit name. See `ml_classifier.md`. |
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | # audiofiles Database Schema | |
| 2 | 2 | ||
| 3 | - | SQLite schema reference. 23 inline migrations. Migrations are embedded as Rust string constants in `crates/audiofiles-core/src/db.rs` and applied via `PRAGMA user_version` tracking, not separate SQL files. | |
| 3 | + | SQLite schema reference. Migrations are embedded as Rust string constants in `crates/audiofiles-core/src/db.rs` and applied via `PRAGMA user_version` tracking, not separate SQL files. | |
| 4 | 4 | ||
| 5 | 5 | ## Domain Map | |
| 6 | 6 | ||
| @@ -68,10 +68,7 @@ | |||
| 68 | 68 | Indexes: `bpm`, `musical_key`, `duration`, `spectral_centroid`, `spectral_flatness`, | |
| 69 | 69 | `attack_time`. The last three back the measured browse axes (M036). | |
| 70 | 70 | ||
| 71 | - | `classification` (M003) and `classification_confidence` (M011) held the single | |
| 72 | - | sample-class label. Both are gone, along with the classifier that produced them; see | |
| 73 | - | `ml_classifier.md`. The columns were removed from the migration bodies that added them | |
| 74 | - | rather than dropped in a later migration, so no database creates them. | |
| 71 | + | There is no per-sample class column; see `ml_classifier.md`. | |
| 75 | 72 | ||
| 76 | 73 | ### sample_features | |
| 77 | 74 | The 35-element feature vector (9 scalar + 26 MFCC) per sample. Migration 020. Foundation | |
| @@ -143,7 +140,7 @@ | |||
| 143 | 140 | ## Organization | |
| 144 | 141 | ||
| 145 | 142 | ### tags | |
| 146 | - | Flat dot-namespaced tags on samples. Migration 002 replaced the original key-value `tags` table with this simpler model. | |
| 143 | + | Flat dot-namespaced tags on samples. Migration 002. | |
| 147 | 144 | ||
| 148 | 145 | | Column | Type | Notes | | |
| 149 | 146 | |--------|------|-------| | |
| @@ -213,7 +210,7 @@ | |||
| 213 | 210 | ||
| 214 | 211 | PK: `(collection_id, sample_hash)`. | |
| 215 | 212 | ||
| 216 | - | `collections.filter_json` (added in M015): when non-NULL, the collection is a dynamic / saved search; when NULL, it's a manual collection populated via `collection_members`. The standalone `smart_folders` table from M001 was dropped in M015 and migrated into this column. | |
| 213 | + | `collections.filter_json` (M015): when non-NULL, the collection is a dynamic / saved search; when NULL, it's a manual collection populated via `collection_members`. | |
| 217 | 214 | ||
| 218 | 215 | --- | |
| 219 | 216 | ||
| @@ -282,7 +279,7 @@ | |||
| 282 | 279 | - **Inline migrations:** All schema DDL is embedded as Rust `const` strings in `db.rs`, applied transactionally via `PRAGMA user_version`, no external SQL files | |
| 283 | 280 | - **VFS abstraction:** Virtual file systems decouple organization from disk layout; one sample can appear in multiple VFS trees via `vfs_nodes.sample_hash` | |
| 284 | 281 | - **Self-referential tree:** `vfs_nodes.parent_id` references `vfs_nodes.id` for arbitrary directory nesting | |
| 285 | - | - **Dot-namespaced tags:** Flat `tag` strings with dot convention (e.g., `genre.techno`) replace the original key-value tag model (migration 002) | |
| 282 | + | - **Dot-namespaced tags:** Flat `tag` strings with a dot convention (e.g., `genre.techno`), one row per applied tag | |
| 286 | 283 | - **Sync guard triggers:** All sync triggers check `applying_remote != '1'` to prevent echo loops | |
| 287 | 284 | - **Sync-excluded keys:** `user_config` sync triggers skip keys matching `sync_%` to avoid syncing sync-internal state | |
| 288 | 285 | - **Cloud-only samples:** `samples.cloud_only` flag allows local blob eviction while keeping metadata and cloud copy |
| @@ -73,7 +73,7 @@ | |||
| 73 | 73 | - Add/remove samples from any VFS | |
| 74 | 74 | ||
| 75 | 75 | ### Dynamic Collections | |
| 76 | - | - A collection with a non-NULL `filter_json` is a saved search (the old "smart folder" feature, merged into the collections table in migration M015) | |
| 76 | + | - A collection with a non-NULL `filter_json` is a saved search | |
| 77 | 77 | - Sidebar section with collapsible list | |
| 78 | 78 | - Click to apply saved filter instantly | |
| 79 | 79 |
| @@ -1,23 +1,16 @@ | |||
| 1 | - | # Sample deletion: tombstone design (proposal) | |
| 1 | + | # Sample deletion: tombstone design | |
| 2 | 2 | ||
| 3 | - | **Status:** Phases 1-4 implemented (2026-06-19). Schema + read-path filter (Phase 1), tombstone/undelete/purge operations (Phase 2), the Trash section in Settings (Phase 3), and the startup sweep (Phase 4) are live. Phase 5 (pull-side "deleted on another device" notification) remains. | |
| 3 | + | **Status:** Phases 1-4 are live. Phase 5 (pull-side "deleted on another device" notification) remains. | |
| 4 | 4 | ||
| 5 | - | **Author:** Max / Claude (audit session) | |
| 6 | - | ||
| 7 | - | **Scope:** the multi-device sync semantics of deleting a sample. Single-device delete is already correct (placement-only via `vfs_nodes` delete; sample row untouched). This doc covers the open question: what happens when a `samples` row deletion needs to propagate. | |
| 5 | + | **Scope:** the multi-device sync semantics of deleting a sample. Single-device delete is placement-only (a `vfs_nodes` delete; the sample row is untouched). This doc covers what happens when a `samples` row deletion needs to propagate. | |
| 8 | 6 | ||
| 9 | 7 | --- | |
| 10 | 8 | ||
| 11 | - | ## Problem | |
| 9 | + | ## Why soft delete | |
| 12 | 10 | ||
| 13 | - | Today, `apply_remote_changes` in `crates/audiofiles-sync/src/service/resolve.rs` applies a remote `DELETE samples WHERE hash=X` directly. SQLite enforces `ON DELETE CASCADE` on `vfs_nodes.sample_hash`, `tags.sample_hash`, and `collection_members.sample_hash` at the engine level (not via triggers), so `applying_remote='1'` does not suppress it. The receiving device loses every placement, every tag, and every collection membership of that sample, silently, without confirmation. | |
| 11 | + | SQLite enforces `ON DELETE CASCADE` on `vfs_nodes.sample_hash`, `tags.sample_hash`, and `collection_members.sample_hash` at the engine level rather than via triggers, so `applying_remote='1'` does not suppress it. A hard `DELETE samples WHERE hash=X` applied from a remote change therefore takes every placement, tag, and collection membership of that sample on the receiving device, silently. | |
| 14 | 12 | ||
| 15 | - | The user's mental model for an audiofiles library is "my library, accessible from any of my devices, hand-picked launch cohort" (launch plan, 2026-06-01). The current behavior matches the implementation of "global library, but delete on one device wipes everything on every device with no record." Two specific surprises: | |
| 16 | - | ||
| 17 | - | - **Device A deletes a sample they no longer want** → Device B (which had organized that sample into 3 collections and tagged it across 12 placements) loses all that work without warning. | |
| 18 | - | - **Device A runs an automated cleanup** (today: VFS-delete sweep; tomorrow: the new "Cleanup orphans" menu without the `applying_remote` guard we just added) → all of A's placements gone → push → B's placements gone too. | |
| 19 | - | ||
| 20 | - | The path forward is **soft-delete with a tombstone column**, replicating across devices, with a recovery window before hard delete. This document is the design contract before implementation. | |
| 13 | + | The model is **soft delete with a tombstone column**, replicating across devices, with a recovery window before hard delete. A user expects "I deleted this" to mean gone from their library on every device, so a delete has to propagate; the tombstone is what makes the propagation recoverable. | |
| 21 | 14 | ||
| 22 | 15 | --- | |
| 23 | 16 | ||
| @@ -25,7 +18,6 @@ | |||
| 25 | 18 | ||
| 26 | 19 | - **Cross-device "is this sample still in use anywhere?" awareness.** That requires a server-side reference count, which the current sync model (encrypted blob storage + opaque changelog) explicitly does not have. Each device sees only its own references plus the tombstone state. | |
| 27 | 20 | - **Per-VFS or per-collection delete isolation.** Removing a sample from one collection is already handled by `collection_members` delete (no sample deletion involved). Same for VFS placements. | |
| 28 | - | - **Reversing already-shipped purges.** Any `samples` row already deleted before this design lands is gone for good: the sync layer has no way to ask other devices "did you keep a copy." | |
| 29 | 21 | - **Server-side garbage collection of orphaned blobs.** Blob lifecycle on the cloud is a separate concern; this design only changes the DB row lifecycle. | |
| 30 | 22 | ||
| 31 | 23 | --- | |
| @@ -79,7 +71,7 @@ | |||
| 79 | 71 | 2. Surfaces a "Sample deleted on another device: NAME (12 placements affected)" notification. | |
| 80 | 72 | 3. Optionally provides an "Undelete on this device" button that re-sets `deleted_at` to NULL locally (creating a temporary divergence until reconciled). | |
| 81 | 73 | ||
| 82 | - | For the local-only case (the "Cleanup orphans" menu we just shipped): same UPDATE, but trigger-suppressed via `applying_remote='1'`. Other devices never learn about it. | |
| 74 | + | For the local-only case (the "Cleanup orphans" menu): same UPDATE, but trigger-suppressed via `applying_remote='1'`. Other devices never learn about it. | |
| 83 | 75 | ||
| 84 | 76 | ### Sweep | |
| 85 | 77 | ||
| @@ -114,7 +106,7 @@ | |||
| 114 | 106 | ||
| 115 | 107 | ### `cloud_only` interaction | |
| 116 | 108 | ||
| 117 | - | Today, `cloud_only` marks samples whose local blob has been evicted but exist in cloud storage. With tombstones: | |
| 109 | + | `cloud_only` marks samples whose local blob has been evicted but which exist in cloud storage. With tombstones: | |
| 118 | 110 | ||
| 119 | 111 | - Tombstoned samples are NOT automatically marked `cloud_only`. The blob stays on disk during the retention window so undelete is instant. | |
| 120 | 112 | - After the sweep hard-deletes the `samples` row, the blob is removed from disk too (existing `SampleStore::remove` path). | |
| @@ -151,19 +143,17 @@ | |||
| 151 | 143 | ||
| 152 | 144 | --- | |
| 153 | 145 | ||
| 154 | - | ## Rollout phasing | |
| 146 | + | ## Implementation state | |
| 155 | 147 | ||
| 156 | - | **Phase 1: M019 + read-path filter** (DONE). Schema landed, `WHERE deleted_at IS NULL` filter across all read sites. Tombstones don't surface in UI yet; behavior change is invisible to users (everything is still NULL). | |
| 148 | + | **M019 + read-path filter** (live). `WHERE deleted_at IS NULL` across all read sites. | |
| 157 | 149 | ||
| 158 | - | **Phase 2: delete & undelete operations** (DONE, 2026-06-19). `tombstone_sample`, `undelete_sample`, and the unfiltered-extension fix to `SampleStore::remove` (so hard-delete/purge works on tombstoned rows) all live in `core::store`. Note: the cleanup-orphans menu still hard-deletes immediately (orphan = no recovery value), as designed, it does not route through the tombstone path. | |
| 150 | + | **Delete and undelete operations** (live). `tombstone_sample`, `undelete_sample`, and an unfiltered-extension lookup in `SampleStore::remove` (so hard-delete/purge works on tombstoned rows) live in `core::store`. The cleanup-orphans menu hard-deletes immediately by design (an orphan has no recovery value) and does not route through the tombstone path. | |
| 159 | 151 | ||
| 160 | - | **Phase 3: Trash UI** (DONE, 2026-06-19). Trash section in Settings (`ui/settings_panel.rs::draw_trash_section`) lists tombstoned samples with Restore + Delete-permanently (behind a per-row confirm) affordances. Backed by `Backend::{list_tombstoned, undelete_sample, purge_sample}`. | |
| 152 | + | **Trash UI** (live). Trash section in Settings (`ui/settings_panel.rs::draw_trash_section`) lists tombstoned samples with Restore and Delete-permanently (behind a per-row confirm) affordances. Backed by `Backend::{list_tombstoned, undelete_sample, purge_sample}`. | |
| 161 | 153 | ||
| 162 | - | **Phase 4: sweep** (DONE, 2026-06-19). `SampleStore::sweep_expired_tombstones` runs once in `DirectBackend::new` at startup, hard-deleting past-retention tombstones. The `samples` DELETE is not sync-suppressed, so it propagates; receiving devices CASCADE their own data (which, by symmetry, has also been tombstoned for >= retention). | |
| 154 | + | **Sweep** (live). `SampleStore::sweep_expired_tombstones` runs once in `DirectBackend::new` at startup, hard-deleting past-retention tombstones. The `samples` DELETE is not sync-suppressed, so it propagates; receiving devices CASCADE their own data, which by symmetry has also been tombstoned for at least the retention window. | |
| 163 | 155 | ||
| 164 | - | **Phase 5: sync notification** (TODO). On pull, count incoming tombstones and surface a one-shot status: "X samples deleted on another device, see Trash to recover." | |
| 165 | - | ||
| 166 | - | Phases 1-2 are required for correctness. Phases 3-5 are UX polish that can land in any order after Phase 2. | |
| 156 | + | **Sync notification** (TODO). On pull, count incoming tombstones and surface a one-shot status: "X samples deleted on another device, see Trash to recover." | |
| 167 | 157 | ||
| 168 | 158 | --- | |
| 169 | 159 | ||
| @@ -177,16 +167,6 @@ | |||
| 177 | 167 | ||
| 178 | 168 | --- | |
| 179 | 169 | ||
| 180 | - | ## Why not E (per-device orphan management) | |
| 181 | - | ||
| 182 | - | The "each device manages its own purges; samples DELETE never propagates as global destroy" model is lighter (no schema change), but creates an awkward asymmetry: "delete on Device A doesn't propagate to Device B." This breaks the stated product model ("my library, accessible from any of my devices"): users expect "I deleted this" to mean it's gone from their library, not just from this device. E only works for a federation model (collaborator A and B each curate their own subset of a shared blob pool) which isn't what audiofiles is. | |
| 183 | - | ||
| 184 | - | ## Why not G (confirmation-only with current CASCADE) | |
| 185 | - | ||
| 186 | - | G doesn't fix the sync-pull surprise. A confirm dialog on local delete is helpful but doesn't help Device B when Device A purges. The sync-pull cascade still wipes B's placements without B ever seeing a dialog. G is at best an additional safety net under either E or F, not a substitute. | |
| 187 | - | ||
| 188 | - | --- | |
| 189 | - | ||
| 190 | 170 | ## Acceptance criteria | |
| 191 | 171 | ||
| 192 | 172 | This design is implemented when: | |
| @@ -195,5 +175,5 @@ | |||
| 195 | 175 | - Sync push of the tombstone replicates the `deleted_at` field; receiving device marks the same sample as tombstoned without losing placements. | |
| 196 | 176 | - A user can browse the Trash view, see their tombstoned samples, and either undelete or delete permanently. | |
| 197 | 177 | - A 30-day-old tombstone is automatically hard-deleted on next app startup. | |
| 198 | - | - The "Cleanup orphans" menu (already shipped 2026-06-02) is local-only and continues to work; its eventual replacement uses the tombstone path with immediate hard-delete (no retention for things you never placed). | |
| 178 | + | - The "Cleanup orphans" menu is local-only; its eventual replacement uses the tombstone path with immediate hard-delete (no retention for things you never placed). | |
| 199 | 179 | - No existing tests fail; the new behavior is covered by tests per the Test plan section. |
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | # audiofiles Design System | |
| 2 | 2 | ||
| 3 | - | **Status:** Charter (phase-0 deliverable, 2026-05-19). Defines the canonical primitive set for the egui UI. Implementations follow during consolidation against the internal UX audit's divergence map. | |
| 3 | + | **Status:** Charter. Defines the canonical primitive set for the egui UI. | |
| 4 | 4 | ||
| 5 | 5 | **Scope:** `crates/audiofiles-browser/src/ui/`. Native egui dispatch only. This app has no web layer. | |
| 6 | 6 | ||
| @@ -103,7 +103,7 @@ | |||
| 103 | 103 | ||
| 104 | 104 | 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. | |
| 105 | 105 | ||
| 106 | - | **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. Corner radius survives under a `[geometry]` section because the crate carries spacing only. | |
| 106 | + | **Spacing is not themeable, and this is the one hard rule.** A theme overrides colour; geometry is invariant, which is why it belongs to a shared crate rather than to this app. There is no `[spacing]` TOML section. Corner radius is themeable under `[geometry]`, because the crate carries spacing only. | |
| 107 | 107 | ||
| 108 | 108 | ### Typography | |
| 109 | 109 | ||
| @@ -116,9 +116,9 @@ | |||
| 116 | 116 | .with_face(FontFace::new("RecursiveMono", ["RecursiveMonoLnrSt-Bold.ttf"]).weight("700")), | |
| 117 | 117 | ) | |
| 118 | 118 | ||
| 119 | - | **The layer is read here rather than emitted.** MNW and GoingsOn declare an override in a build script and it reaches the page as generated CSS. There is no stylesheet anywhere in this path: egui loads faces itself, so `setup_fonts` reads the declaration back through `Typography::faces` and registers the file under the family name the stack spells. That is what `makeover` 2.10.0 added, and audiofiles is the first renderer on the far side of it. | |
| 119 | + | **The layer is read here rather than emitted.** MNW and GoingsOn declare an override in a build script and it reaches the page as generated CSS. There is no stylesheet anywhere in this path: egui loads faces itself, so `setup_fonts` reads the declaration back through `Typography::faces` and registers the file under the family name the stack spells. | |
| 120 | 120 | ||
| 121 | - | The gain is that the family name is stated once. `setup_fonts` registers the face under it, `toolbar.rs` asks egui for it back, and neither spells it — a mismatch used to be two string literals agreeing by hand, and it fails silently by falling back to Hack. | |
| 121 | + | The family name is therefore stated once. `setup_fonts` registers the face under it and `toolbar.rs` asks egui for it back, neither spelling it. Two literals agreeing by hand would fail silently by falling back to Hack. | |
| 122 | 122 | ||
| 123 | 123 | ### Stroke / radius | |
| 124 | 124 | ||
| @@ -130,10 +130,10 @@ | |||
| 130 | 130 | | `border_default` | 1.0 px | Hovered/active widget border, window stroke. | | |
| 131 | 131 | | `focus_ring` | 1.5 px `action` | Focus outline on text fields. | | |
| 132 | 132 | ||
| 133 | - | One radius became two because Platinum wants square containers and push buttons | |
| 134 | - | that still keep a touch of a corner. Both stay overridable under `[geometry]`. | |
| 135 | - | A theme that still sets the old single `rounding` key gets it as the control | |
| 136 | - | radius and square containers, which is the closest reading of what it asked for. | |
| 133 | + | There are two radii because Platinum wants square containers and push buttons | |
| 134 | + | that keep a touch of a corner. Both are overridable under `[geometry]`. A theme | |
| 135 | + | that sets a single `rounding` key gets it as the control radius, with square | |
| 136 | + | containers. | |
| 137 | 137 | ||
| 138 | 138 | **Square is the default and `radius_control()` is opt-in.** `apply_theme` sets | |
| 139 | 139 | every one of egui's five widget states to the container radius, because those | |
| @@ -291,7 +291,7 @@ | |||
| 291 | 291 | ||
| 292 | 292 | ## Panel shapes | |
| 293 | 293 | ||
| 294 | - | These are de-facto today; the charter codifies them so the surface audits (Phase 1+) can rely on them. | |
| 294 | + | The charter codifies these so surface audits can rely on them. | |
| 295 | 295 | ||
| 296 | 296 | | Shape | Egui type | Conventions | | |
| 297 | 297 | |----------------------|----------------------------|-----------------------------------------------------------------------| |
| @@ -6,11 +6,7 @@ | |||
| 6 | 6 | There is no neural network and no training step at build time. | |
| 7 | 7 | ||
| 8 | 8 | One system does this, the tag pipeline: multi-label, provenance-tracked, per-library. | |
| 9 | - | ||
| 10 | - | There used to be a second one alongside it, a single `SampleClass` label per sample from a | |
| 11 | - | hardcoded threshold tree, feeding the filter panel, theme colours, the `{class}` rename | |
| 12 | - | token, and export resolution. It is gone, tree and column both. Why, and what took over, | |
| 13 | - | is below. | |
| 9 | + | There is no single per-sample class label. | |
| 14 | 10 | ||
| 15 | 11 | ## Per-sample analysis | |
| 16 | 12 | ||
| @@ -35,8 +31,7 @@ | |||
| 35 | 31 | The expensive BPM/key/loop stages are gated on cheap raw features: BPM and loop detection | |
| 36 | 32 | run only for clips long enough to carry tempo, and key detection only for clips that are | |
| 37 | 33 | long enough and not noise-like (high spectral flatness). The gate skips the clearly | |
| 38 | - | pointless cases and otherwise runs. It was keyed off the sample class once, which could | |
| 39 | - | skip wrongly on a misread label; there is no label now, and nothing to misread. | |
| 34 | + | pointless cases and otherwise runs. | |
| 40 | 35 | ||
| 41 | 36 | ## The tag pipeline | |
| 42 | 37 | ||
| @@ -74,44 +69,17 @@ | |||
| 74 | 69 | at library scale: "are these 340 all kicks?" is one question, where the same 340 rows | |
| 75 | 70 | one sample at a time is 340 questions nobody finishes. | |
| 76 | 71 | ||
| 77 | - | ## The threshold tree is gone, and so is the class column | |
| 78 | - | ||
| 79 | - | `classify_full()` assigned a `SampleClass` from a priority-ordered tree of about 40 | |
| 80 | - | hand-set thresholds, first match wins. Measured against 1,049 labelled drum samples it was | |
| 81 | - | 33.4% accurate on exact class match, and two of the seven drum classes, `Clap` and `Tom`, | |
| 82 | - | could not be emitted by any rule, so a sample of either was wrong every time. | |
| 83 | - | ||
| 84 | - | Tuning it could not have worked. Roughly a quarter of the corpus was unreachable by | |
| 85 | - | construction, so a sweep over 40 parameters would have fitted noise against a target it | |
| 86 | - | could not hit. The measurements are in the wiki note `af-browse-axes`; the short version is | |
| 87 | - | that instrument identity is not present in these features and coarse family structure is. | |
| 88 | - | One unfitted threshold on the spectral centroid separates low drums from bright drums at | |
| 89 | - | 92.4%, against 33.4% for seven-way identity with 40 tuned constants. Users also mostly do | |
| 90 | - | not ask for the specific name: across 9,493 Freesound sounds, 60.5% carry a family word | |
| 91 | - | and 13.5% a specific instrument. | |
| 92 | - | ||
| 93 | - | So the label was not replaced, it was dropped, along with `SampleClass` and the | |
| 94 | - | `audio_analysis.classification` / `.classification_confidence` columns. What took over: | |
| 72 | + | ## How samples are identified without a class label | |
| 95 | 73 | ||
| 96 | 74 | - **Browsing** by continuous measured axes (register, length, tonal vs noisy, attack). | |
| 97 | - | Nothing to misclassify, no thresholds to tune. | |
| 75 | + | Nothing to misclassify, no thresholds to tune. Measurements: wiki note `af-browse-axes`. | |
| 98 | 76 | - **Instrument names** from filename rules (Layer A over `RuleField::Name`) plus the | |
| 99 | 77 | user's own tags. `starter_rules.rs` ships the vocabulary as a seedable pack, off | |
| 100 | - | until enabled; see "The starter filename pack" below. Our own ground truth came from filenames: `corpus.py` labelled 1,049 of | |
| 101 | - | 1,074 one-shots that way, 97.7%, on the same files the tree scored 33.4% on. Real packs | |
| 102 | - | name their files `Kick.wav`. | |
| 103 | - | - **"More like this"** by the existing k-NN over the 35-feature vector. | |
| 78 | + | until enabled; see "The starter filename pack" below. Real packs name their files | |
| 79 | + | `Kick.wav`, and filename labelling scores 97.7% against a hand-checked one-shot corpus. | |
| 80 | + | - **"More like this"** by the k-NN over the 35-feature vector. | |
| 104 | 81 | ||
| 105 | - | What went with the column: the `{class}` rename token (a saved pattern containing it now | |
| 106 | - | fails to parse rather than resolving to an empty string), the `classification` key in the | |
| 107 | - | export sidecar, the class filter checkboxes, the class column and its sort, the per-class | |
| 108 | - | badge and colour table, and the class-keyed tag-suggestion table with its dismissal store. | |
| 109 | - | Schema side: the columns came out of the migration bodies that added them (M003, M011), | |
| 110 | - | along with the index (M004, M028) and the changelog-trigger payload keys, so no database | |
| 111 | - | creates them and none needs to drop them. There is no data migration because there is no | |
| 112 | - | deployed data: a pre-existing dev vault keeps two dead columns until it is rebuilt, and a | |
| 113 | - | saved search that filtered on the class silently loses that criterion (`SearchFilter` is | |
| 114 | - | `#[serde(default)]`, so the stored key deserializes into nothing). | |
| 82 | + | There is no `{class}` rename token: a saved pattern containing it fails to parse. | |
| 115 | 83 | ||
| 116 | 84 | ## The starter filename pack | |
| 117 | 85 | ||
| @@ -134,31 +102,15 @@ | |||
| 134 | 102 | also present. `RemoveTag` suppresses an earlier add in the same pass. Only the drum | |
| 135 | 103 | classes guard each other; "Bass Guitar Loop" being both bass and guitar is correct. | |
| 136 | 104 | ||
| 137 | - | The two-letter abbreviations from `corpus.py` (` bd`, `_sd`, ` hh`) are deliberately | |
| 138 | - | not in the pack. They were safe against one specific set of drum-machine packs, where | |
| 139 | - | surrounding whitespace disambiguated them, and are false-positive bait against a | |
| 140 | - | library this has never seen. | |
| 141 | - | ||
| 142 | - | Unmeasured, and the honest gap: how the pack degrades on a badly named library. That | |
| 143 | - | is exactly where the k-NN layer should carry the load instead. | |
| 105 | + | Do not add the two-letter abbreviations from `corpus.py` (` bd`, `_sd`, ` hh`) to the | |
| 106 | + | pack. They are safe only where surrounding whitespace disambiguates them, and are | |
| 107 | + | false-positive bait against an unknown library. | |
| 144 | 108 | ||
| 145 | 109 | ## What ships in the binary, and under what licence | |
| 146 | 110 | ||
| 147 | - | No model and no third-party data. Every number the classifier uses is either computed | |
| 148 | - | from the user's audio at analysis time or written by hand as a threshold. | |
| 149 | - | ||
| 150 | - | That was the position until 2026-07-29, when it was deliberately reversed to allow a | |
| 151 | - | bundled official classifier layer: a `kind = "official"` `.afcl` built from a CC-BY drum | |
| 152 | - | corpus, embedded in the binary and imported on first run. It was never enabled in a | |
| 153 | - | shipped build, and on 2026-08-08 it was retired outright, so the original position holds | |
| 154 | - | again. Stating that plainly rather than letting it be inferred, because the reversal was | |
| 155 | - | written down here and the return should be too. | |
| 156 | - | ||
| 157 | - | Why it was retired, in one line: measured against a user's own labels, the layer changed | |
| 158 | - | no answers and moved accuracy by no points once that user had tagged about 88 samples. It | |
| 159 | - | was a cold-start feature, and the app already gives a new library structure from zero | |
| 160 | - | labels through the measured browse axes. The full argument and the measurements are in the | |
| 161 | - | wiki notes `af-likeness-web` and `af-classifier-pipeline`. | |
| 111 | + | No model and no third-party data, and nothing is bundled. Every number the classifier | |
| 112 | + | uses is either computed from the user's audio at analysis time or written by hand as a | |
| 113 | + | threshold. | |
| 162 | 114 | ||
| 163 | 115 | What that leaves, and it is the whole of it: | |
| 164 | 116 | ||
| @@ -166,14 +118,13 @@ | |||
| 166 | 118 | on real packs. | |
| 167 | 119 | - **The exemplar k-NN** matches against the user's own labels, feeding auto-apply for | |
| 168 | 120 | their own tagging and a review queue for anything else. | |
| 169 | - | - **Imported `.afcl` layers** still work in both directions. Export yours, import someone | |
| 121 | + | - **Imported `.afcl` layers** work in both directions. Export yours, import someone | |
| 170 | 122 | else's; they arrive disabled, weighted below your own labels, and suggest rather than | |
| 171 | - | apply. Nothing is bundled. | |
| 123 | + | apply. | |
| 172 | 124 | - **Browse axes** need no labels at all. | |
| 173 | 125 | ||
| 174 | - | `audiofiles-bench` keeps two meters over the k-NN, `layer-eval` for whether an answer is | |
| 175 | - | right and `layer-stability` for whether it is the same answer next week. They outlived the | |
| 176 | - | layer they were built for because they measure the substrate the app still runs on. | |
| 126 | + | `audiofiles-bench` keeps two meters over the k-NN: `layer-eval` for whether an answer is | |
| 127 | + | right, `layer-stability` for whether it is the same answer next week. | |
| 177 | 128 | ||
| 178 | 129 | ## Feature vector | |
| 179 | 130 |
| @@ -91,7 +91,7 @@ | |||
| 91 | 91 | ||
| 92 | 92 | **Database location:** `~/.config/audiofiles/audiofiles.db` (platform-dependent) | |
| 93 | 93 | ||
| 94 | - | **19 inline migrations** tracked via `PRAGMA user_version`. Run automatically on app startup. | |
| 94 | + | Migrations are tracked via `PRAGMA user_version` and run automatically on app startup. | |
| 95 | 95 | ||
| 96 | 96 | | Symptom | Cause | Fix | | |
| 97 | 97 | |---------|-------|-----| |