Skip to main content

max / audiofiles

docs: correct the build-from-clone instructions Three things wrong in one block. The clone URLs were sourcehut, which is a backup remote and private, so both 404d. synckit-client stopped being its own repo when it was extracted into synckit on 2026-07-24, so one of those URLs named a repo that does not exist. And the documented layout put the two checkouts side by side, while the actual path dependency is ../../../../synckit/synckit-client, so anyone who followed these instructions got a path error rather than a build. The layout is now written as it actually has to be, including the Apps/ level, with a note that needing that level is the defect: the path dependency encodes this machine's directory layout, so building from a clean clone means reproducing someone else's filesystem. A git or registry dependency is the fix, not more ../.
Author: Max Johnson <me@maxj.phd> · 2026-07-25 14:26 UTC
Signed with PGP, not checked
Commit: fc43aa659a73d15a882c130a919787d7f0a8ed61
Parent: 2b65332
20 files changed, +252 insertions, -215 deletions
@@ -271,21 +271,32 @@
271 271
272 272 ### Clone
273 273
274 - audiofiles depends on the `synckit-client` crate via a relative path. Clone both repositories side by side:
274 + audiofiles depends on the `synckit-client` crate via a relative path, and that path is
275 + `../../../../synckit/synckit-client` (see `crates/audiofiles-sync/Cargo.toml`), so the two
276 + checkouts are not siblings. `synckit-client` is also not its own repository: it lives in the
277 + `synckit` repo alongside `synckit-config`.
275 278
276 279 ```
277 - git clone https://git.sr.ht/~maxmj/synckit-client
278 - git clone https://git.sr.ht/~maxmj/audiofiles
280 + git clone https://makenot.work/git/max/synckit.git
281 + mkdir -p Apps && git clone https://makenot.work/git/max/audiofiles.git Apps/audiofiles
279 282 ```
280 283
281 284 Your directory should look like:
282 285
283 286 ```
284 287 parent/
285 - synckit-client/
286 - audiofiles/
288 + synckit/
289 + synckit-client/
290 + synckit-config/
291 + Apps/
292 + audiofiles/
287 293 ```
288 294
295 + That `Apps/` level is load-bearing, and it is load-bearing because the path dependency
296 + encodes the author's own directory layout rather than anything about the projects. Building
297 + from a clone should not require reproducing someone else's filesystem; the fix is a git or
298 + registry dependency, not a deeper set of `../`s.
299 +
289 300 ### Build the Standalone App
290 301
291 302 ```
@@ -201,7 +201,7 @@
201 201 });
202 202
203 203 // Floating MIDI/instrument window
204 - if state.show_midi_window {
204 + if state.preview.show_midi_window {
205 205 instrument_panel::draw_midi_window(&ctx, state);
206 206 }
207 207
@@ -227,7 +227,7 @@
227 227 // Right detail panel (auto-hide below 700px).
228 228 // 700.0 is the minimum window width at which the detail panel is shown.
229 229 // Below this, the file list alone needs the full width to remain usable.
230 - if state.detail_visible && ctx.content_rect().width() >= 700.0 {
230 + if state.detail.detail_visible && ctx.content_rect().width() >= 700.0 {
231 231 egui::Panel::right("detail")
232 232 .default_size(250.0)
233 233 .size_range(200.0..=400.0)
@@ -337,7 +337,7 @@
337 337
338 338 // Tab: focus the detail-panel tag input (opens the detail panel if hidden).
339 339 if input.key_pressed(egui::Key::Tab) && !input.modifiers.shift {
340 - if !state.detail_visible {
340 + if !state.detail.detail_visible {
341 341 state.set_detail_visible(true);
342 342 }
343 343 state.focus_tag_input = true;
@@ -413,7 +413,7 @@
413 413 }
414 414 // "I" toggles floating MIDI/instrument window
415 415 if input.key_pressed(egui::Key::I) {
416 - state.show_midi_window = !state.show_midi_window;
416 + state.preview.show_midi_window = !state.preview.show_midi_window;
417 417 }
418 418 // "E" toggles floating sample editor window
419 419 if input.key_pressed(egui::Key::E) {
@@ -258,7 +258,7 @@
258 258 let mut all = outcome.auto_applied;
259 259 all.extend(outcome.pending_review);
260 260 let existing: std::collections::HashSet<&String> =
261 - self.selected_tags.iter().collect();
261 + self.detail.selected_tags.iter().collect();
262 262 all.retain(|s| !existing.contains(&s.tag));
263 263 self.status = if all.is_empty() {
264 264 "No tag suggestions from similar samples.".to_string()
@@ -269,7 +269,7 @@
269 269 if all.len() == 1 { "" } else { "s" }
270 270 )
271 271 };
272 - self.selected_ml_suggestions = all;
272 + self.detail.selected_ml_suggestions = all;
273 273 }
274 274 }
275 275 }
@@ -475,7 +475,7 @@
475 475 /// Populate `selected_ml_suggestions` with k-NN suggestions for the selected sample.
476 476 pub fn suggest_ml_for_selected(&mut self) {
477 477 use audiofiles_core::analysis::exemplar::DEFAULT_K;
478 - self.selected_ml_suggestions.clear();
478 + self.detail.selected_ml_suggestions.clear();
479 479 let Some(hash) = self
480 480 .selected_node()
481 481 .and_then(|n| n.node.sample_hash.clone())
@@ -505,12 +505,12 @@
505 505 return;
506 506 };
507 507 if self.backend.accept_ml_tag(&hash, tag).is_ok() {
508 - self.selected_ml_suggestions.retain(|s| s.tag != tag);
508 + self.detail.selected_ml_suggestions.retain(|s| s.tag != tag);
509 509 self.status = format!("Added tag \"{tag}\"");
510 - self.selected_tags =
510 + self.detail.selected_tags =
511 511 std::sync::Arc::new(self.backend.get_sample_tags(&hash).unwrap_or_default());
512 512 if let Ok(prov) = self.backend.sample_tag_provenance(&hash) {
513 - self.selected_tag_sources = prov.into_iter().map(|(t, s, r)| (t, (s, r))).collect();
513 + self.detail.selected_tag_sources = prov.into_iter().map(|(t, s, r)| (t, (s, r))).collect();
514 514 }
515 515 }
516 516 }
@@ -186,12 +186,12 @@
186 186 /// overshoot full scale at an integer target are trimmed to the ceiling;
187 187 /// when off (default) the signal is left untouched and only reported.
188 188 pub fn toggle_forge_auto_trim_overshoot(&mut self) {
189 - self.forge_auto_trim_overshoot = !self.forge_auto_trim_overshoot;
189 + self.preview.forge_auto_trim_overshoot = !self.preview.forge_auto_trim_overshoot;
190 190 super::log_backend_err(
191 191 "set_config forge_auto_trim_overshoot",
192 192 self.backend.set_config(
193 193 crate::backend::ConfigKey::ForgeAutoTrimOvershoot,
194 - if self.forge_auto_trim_overshoot {
194 + if self.preview.forge_auto_trim_overshoot {
195 195 "true"
196 196 } else {
197 197 "false"
@@ -257,7 +257,7 @@
257 257 pub fn poll_workers(&mut self) -> bool {
258 258 // Apply any finished off-thread instrument decodes first (independent of
259 259 // the backend worker channels).
260 - let decodes = self.instrument_decode.drain();
260 + let decodes = self.preview.instrument_decode.drain();
261 261 let decode_activity = !decodes.is_empty();
262 262 for outcome in decodes {
263 263 self.apply_instrument_decode(outcome);
@@ -453,7 +453,7 @@
453 453
454 454 /// Persist the per-classification dismissed-suggestion map.
455 455 fn save_dismissed_suggestions(&self) {
456 - let json = match serde_json::to_string(&self.dismissed_suggestions) {
456 + let json = match serde_json::to_string(&self.detail.dismissed_suggestions) {
457 457 Ok(json) => json,
458 458 Err(e) => {
459 459 // HashMap<String, Vec<String>> serialises cleanly, so this is
@@ -473,7 +473,7 @@
473 473 /// stops appearing on every future sample of that class.
474 474 pub fn dismiss_suggestion(&mut self, classification: &str, tag: &str) {
475 475 let entry = self
476 - .dismissed_suggestions
476 + .detail.dismissed_suggestions
477 477 .entry(classification.to_string())
478 478 .or_default();
479 479 if !entry.iter().any(|t| t == tag) {
@@ -484,7 +484,7 @@
484 484 // last-dismissed marker; older dismissals are no longer reachable
485 485 // via the inline affordance (they're still in `dismissed_suggestions`
486 486 // and recoverable via Settings → Reset suggestions).
487 - self.last_dismissed_suggestion = Some((
487 + self.detail.last_dismissed_suggestion = Some((
488 488 classification.to_string(),
489 489 tag.to_string(),
490 490 std::time::Instant::now(),
@@ -496,13 +496,13 @@
496 496 /// inline-Undo marker on success or when the entry is gone (already cleared
497 497 /// by Settings → Reset suggestions, for example).
498 498 pub fn undo_last_dismissal(&mut self) {
499 - let Some((class, tag, _)) = self.last_dismissed_suggestion.take() else {
499 + let Some((class, tag, _)) = self.detail.last_dismissed_suggestion.take() else {
500 500 return;
501 501 };
502 - if let Some(entry) = self.dismissed_suggestions.get_mut(&class) {
502 + if let Some(entry) = self.detail.dismissed_suggestions.get_mut(&class) {
503 503 entry.retain(|t| t != &tag);
504 504 if entry.is_empty() {
505 - self.dismissed_suggestions.remove(&class);
505 + self.detail.dismissed_suggestions.remove(&class);
506 506 }
507 507 self.save_dismissed_suggestions();
508 508 self.status = format!("Restored suggestion: {tag}");
@@ -514,11 +514,11 @@
514 514 /// during exploration can start fresh.
515 515 pub fn reset_dismissed_suggestions(&mut self) {
516 516 let n: usize = self
517 - .dismissed_suggestions
517 + .detail.dismissed_suggestions
518 518 .values()
519 519 .map(std::vec::Vec::len)
520 520 .sum();
521 - self.dismissed_suggestions.clear();
521 + self.detail.dismissed_suggestions.clear();
522 522 self.save_dismissed_suggestions();
523 523 self.status = format!(
524 524 "Reset {n} dismissed suggestion{}",
@@ -12,7 +12,7 @@
12 12
13 13 use audiofiles_core::analysis::AnalysisResult;
14 14 use audiofiles_core::analysis::config::AnalysisConfig;
15 - use audiofiles_core::analysis::waveform::WaveformData;
15 +
16 16 use audiofiles_core::db::Database;
17 17 use audiofiles_core::search::SearchFilter;
18 18 use audiofiles_core::store::SampleStore;
@@ -147,13 +147,6 @@
147 147 /// Non-blocking native file dialogs (folder/file pickers, save). Results are
148 148 /// applied on the GUI thread via `draw_browser`'s poll.
149 149 pub dialogs: crate::ui::dialog::DialogManager,
150 - pub selected_tags: Arc<Vec<String>>,
151 - /// Per-tag provenance for the selected sample: tag -> (source, rule_id). A tag
152 - /// absent from this map is manual. Refreshed alongside `selected_tags`.
153 - pub selected_tag_sources: std::collections::HashMap<String, (String, Option<String>)>,
154 - /// k-NN tag suggestions for the selected sample, populated on demand by the
155 - /// "Suggest similar tags" action and cleared when the selection changes.
156 - pub selected_ml_suggestions: Vec<audiofiles_core::analysis::exemplar::MlSuggestion>,
157 150 pub status: String,
158 151 /// When the current `status` message was posted. Drives the footer's
159 152 /// time-fade (m-6): fade to muted after 5s, hide after 30s. `None` means
@@ -161,11 +154,8 @@
161 154 /// assignment), the footer treats first-seen-non-empty as freshly-set.
162 155 pub status_set_at: Option<Instant>,
163 156
164 - // Detail panel
165 - pub selected_analysis: Option<AnalysisResult>,
166 - pub selected_waveform: Option<WaveformData>,
167 - pub tag_input: String,
168 - pub detail_visible: bool,
157 + // Detail panel (selected sample analysis/tags, tag entry, dismissal undo)
158 + pub detail: DetailUiState,
169 159 pub sidebar_visible: bool,
170 160
171 161 // Search / filter / similarity
@@ -174,26 +164,9 @@
174 164 // Tags cache
175 165 pub all_tags: Arc<Vec<String>>,
176 166
177 - // Preview
178 - pub previewing_hash: Option<String>,
167 + // Preview + instrument playback UI
168 + pub preview: PreviewUiState,
179 169 pub shared: Arc<SharedState>,
180 - pub sample_rate: f32,
181 - pub loop_enabled: bool,
182 - pub autoplay: bool,
183 - /// Forge overshoot policy: when true, a conform that overshoots full scale at
184 - /// an integer target is trimmed to the ceiling; when false (default) the
185 - /// signal is left untouched and the overshoot is only reported.
186 - pub forge_auto_trim_overshoot: bool,
187 -
188 - // Instrument
189 - pub instrument_visible: bool,
190 - pub instrument_root_note: u8,
191 - /// When true, previewing a sample does NOT auto-load it into the instrument.
192 - pub instrument_locked: bool,
193 - /// MIDI notes currently held by piano mouse clicks.
194 - pub piano_held_notes: Vec<u8>,
195 - /// Whether the floating MIDI/instrument window is open.
196 - pub show_midi_window: bool,
197 170
198 171 // MIDI
199 172 pub midi_state: MidiUiState,
@@ -221,15 +194,6 @@
221 194 /// Set when an inline sidebar editor (collection/tag create or rename) opens,
222 195 /// so the text field auto-focuses on its first frame (P2 visible-focus gap).
223 196 pub focus_inline_editor: bool,
224 - /// Per-classification dismissed tag suggestions: e.g. dismissing
225 - /// "percussion" on a kick suppresses it on every future kick. Persisted
226 - /// under config key "suggestions.dismissed" as a JSON `<class>` → `[tag]` map.
227 - pub dismissed_suggestions: std::collections::HashMap<String, Vec<String>>,
228 - /// Last suggestion that was dismissed plus when. Drives the inline Undo
229 - /// affordance in the detail panel (M-1), visible for ~5 seconds after
230 - /// the dismiss, then fades. `None` means there's nothing to undo right
231 - /// now (initial state or after a successful undo / timeout).
232 - pub last_dismissed_suggestion: Option<(String, String, Instant)>,
233 197 // Theme
234 198 pub current_theme_id: String,
235 199
@@ -242,14 +206,6 @@
242 206 // Forge, floating sample-forge window (chop / conform / batch)
243 207 pub forge: ForgeUiState,
244 208
245 - /// Off-thread decoder for instrument loading (chromatic auto-load + zone
246 - /// adds). Keeps the full-file decode off the egui frame thread.
247 - pub instrument_decode: crate::preview::InstrumentDecodeWorker,
248 - /// Generation of the most recently dispatched chromatic auto-load. Outcomes
249 - /// with an older generation are dropped so rapid arrow-key previewing can't
250 - /// load a stale instrument over the newest one.
251 - pub latest_chromatic_gen: u64,
252 -
253 209 // Display density
254 210 pub row_height: f32,
255 211
@@ -464,32 +420,27 @@
464 420 ..Default::default()
465 421 },
466 422 dialogs: crate::ui::dialog::DialogManager::default(),
467 - selected_tags: Arc::new(Vec::new()),
468 - selected_tag_sources: std::collections::HashMap::new(),
469 - selected_ml_suggestions: Vec::new(),
470 423 status: String::new(),
471 424 status_set_at: None,
472 - selected_analysis: None,
473 - selected_waveform: None,
474 - tag_input: String::new(),
475 - detail_visible,
425 + detail: DetailUiState {
426 + detail_visible,
427 + dismissed_suggestions,
428 + ..Default::default()
429 + },
476 430 sidebar_visible,
477 431 search: SearchUiState {
478 432 filter_panel_open,
479 433 ..Default::default()
480 434 },
481 435 all_tags: Arc::new(all_tags),
482 - previewing_hash: None,
436 + preview: PreviewUiState {
437 + sample_rate,
438 + loop_enabled,
439 + autoplay,
440 + forge_auto_trim_overshoot,
441 + ..Default::default()
442 + },
483 443 shared,
484 - sample_rate,
485 - loop_enabled,
486 - autoplay,
487 - forge_auto_trim_overshoot,
488 - instrument_visible: false,
489 - instrument_root_note: 60,
490 - instrument_locked: false,
491 - piano_held_notes: Vec::new(),
492 - show_midi_window: false,
493 444 midi_state: MidiUiState::default(),
494 445 midi_pending_action: None,
495 446 overlay: OverlayUiState::default(),
@@ -505,8 +456,6 @@
505 456 focus_search: false,
506 457 focus_tag_input: false,
507 458 focus_inline_editor: false,
508 - dismissed_suggestions,
509 - last_dismissed_suggestion: None,
510 459 current_theme_id: theme_id,
511 460 collections_ui: CollectionsUiState {
512 461 collections: Arc::new(collections_list),
@@ -514,8 +463,6 @@
514 463 },
515 464 edit: EditUiState::default(),
516 465 forge: ForgeUiState::default(),
517 - instrument_decode: crate::preview::InstrumentDecodeWorker::new(),
518 - latest_chromatic_gen: 0,
519 466 row_height,
520 467 onboarding: OnboardingUiState {
521 468 show_vfs_banner: !vfs_explained,
@@ -181,20 +181,20 @@
181 181
182 182 /// Reload the tag list for the currently focused sample (shown in the detail panel).
183 183 pub fn refresh_selected_tags(&mut self) {
184 - self.selected_tags = Arc::new(Vec::new());
185 - self.selected_tag_sources.clear();
186 - self.selected_ml_suggestions.clear();
184 + self.detail.selected_tags = Arc::new(Vec::new());
185 + self.detail.selected_tag_sources.clear();
186 + self.detail.selected_ml_suggestions.clear();
187 187 if let Some(node) = self.selected_node()
188 188 && let Some(hash) = &node.node.sample_hash
189 189 {
190 190 let hash = hash.clone();
191 - self.selected_tags =
191 + self.detail.selected_tags =
192 192 Arc::new(self.backend.get_sample_tags(&hash).unwrap_or_else(|e| {
193 193 warn!("Failed to load tags: {e}");
194 194 Vec::new()
195 195 }));
196 196 if let Ok(prov) = self.backend.sample_tag_provenance(&hash) {
197 - self.selected_tag_sources = prov
197 + self.detail.selected_tag_sources = prov
198 198 .into_iter()
199 199 .map(|(tag, source, rule_id)| (tag, (source, rule_id)))
200 200 .collect();
@@ -204,14 +204,14 @@
204 204
205 205 /// Refresh the detail panel (analysis + waveform) for the currently selected sample.
206 206 pub fn refresh_selected_detail(&mut self) {
207 - self.selected_analysis = None;
208 - self.selected_waveform = None;
207 + self.detail.selected_analysis = None;
208 + self.detail.selected_waveform = None;
209 209
210 210 if let Some(node) = self.selected_node()
211 211 && let Some(hash) = &node.node.sample_hash
212 212 {
213 - self.selected_analysis = self.backend.get_analysis(hash).unwrap_or(None);
214 - self.selected_waveform = self.backend.get_waveform(hash).unwrap_or(None);
213 + self.detail.selected_analysis = self.backend.get_analysis(hash).unwrap_or(None);
214 + self.detail.selected_waveform = self.backend.get_waveform(hash).unwrap_or(None);
215 215 }
216 216 }
217 217
@@ -360,14 +360,14 @@
360 360
361 361 /// Toggle the right detail panel and persist the choice across restarts.
362 362 pub fn toggle_detail(&mut self) {
363 - self.set_detail_visible(!self.detail_visible);
363 + self.set_detail_visible(!self.detail.detail_visible);
364 364 }
365 365
366 366 /// Set detail-panel visibility and persist it (used by both the explicit
367 367 /// toggle and the auto-show on selection so the stored state never drifts).
368 368 pub fn set_detail_visible(&mut self, visible: bool) {
369 - if self.detail_visible != visible {
370 - self.detail_visible = visible;
369 + if self.detail.detail_visible != visible {
370 + self.detail.detail_visible = visible;
371 371 super::log_backend_err(
372 372 "set_config detail_visible",
373 373 self.backend.set_config(
@@ -33,7 +33,7 @@
33 33 Ok(p) => p,
34 34 Err(e) => {
35 35 self.status = e.to_string();
36 - self.previewing_hash = None;
36 + self.preview.previewing_hash = None;
37 37 return;
38 38 }
39 39 };
@@ -44,22 +44,22 @@
44 44 .to_string_lossy()
45 45 .to_string();
46 46 // Set the loop flag the streaming thread will honour once it starts.
47 - self.shared.preview.lock().loop_enabled = self.loop_enabled;
47 + self.shared.preview.lock().loop_enabled = self.preview.loop_enabled;
48 48 match crate::preview::start_streaming_decode(&path, &self.shared) {
49 49 Ok(()) => {
50 - self.previewing_hash = Some(hash.to_string());
50 + self.preview.previewing_hash = Some(hash.to_string());
51 51 self.status = format!("Playing: {file_name}");
52 52 }
53 53 Err(e) => {
54 54 self.status = format!("Decode error: {e}");
55 - self.previewing_hash = None;
55 + self.preview.previewing_hash = None;
56 56 return;
57 57 }
58 58 }
59 59
60 60 // Auto-load previewed sample into the instrument (unless locked). The
61 61 // decode runs off-thread; the instrument is built when the result arrives.
62 - if !self.instrument_locked {
62 + if !self.preview.instrument_locked {
63 63 let hash_owned = hash.to_string();
64 64 self.load_chromatic_sample(&hash_owned);
65 65 }
@@ -70,7 +70,7 @@
70 70 let mut playback = self.shared.preview.lock();
71 71 playback.playing = false;
72 72 playback.position_frac = 0.0;
73 - self.previewing_hash = None;
73 + self.preview.previewing_hash = None;
74 74 self.status.clear();
75 75 }
76 76
@@ -88,7 +88,7 @@
88 88
89 89 /// If autoplay is enabled and the focused node is a sample, preview it.
90 90 pub fn autoplay_current(&mut self) {
91 - if !self.autoplay {
91 + if !self.preview.autoplay {
92 92 return;
93 93 }
94 94 if let Some(node) = self.selected_node()
@@ -101,26 +101,26 @@
101 101
102 102 /// Toggle loop mode and persist the setting.
103 103 pub fn toggle_loop(&mut self) {
104 - self.loop_enabled = !self.loop_enabled;
104 + self.preview.loop_enabled = !self.preview.loop_enabled;
105 105 super::log_backend_err(
106 106 "set_config preview_loop",
107 107 self.backend.set_config(
108 108 crate::backend::ConfigKey::PreviewLoop,
109 - if self.loop_enabled { "1" } else { "0" },
109 + if self.preview.loop_enabled { "1" } else { "0" },
110 110 ),
111 111 );
112 112 // Sync to the live playback state
113 - self.shared.preview.lock().loop_enabled = self.loop_enabled;
113 + self.shared.preview.lock().loop_enabled = self.preview.loop_enabled;
114 114 }
115 115
116 116 /// Toggle autoplay mode and persist the setting.
117 117 pub fn toggle_autoplay(&mut self) {
118 - self.autoplay = !self.autoplay;
118 + self.preview.autoplay = !self.preview.autoplay;
119 119 super::log_backend_err(
120 120 "set_config preview_autoplay",
121 121 self.backend.set_config(
122 122 crate::backend::ConfigKey::PreviewAutoplay,
123 - if self.autoplay { "1" } else { "0" },
123 + if self.preview.autoplay { "1" } else { "0" },
124 124 ),
125 125 );
126 126 }
@@ -150,7 +150,7 @@
150 150 .and_then(|k| audiofiles_core::instrument::key_to_root_note(&k))
151 151 .unwrap_or(48);
152 152
153 - self.latest_chromatic_gen = self.instrument_decode.request(
153 + self.preview.latest_chromatic_gen = self.preview.instrument_decode.request(
154 154 hash.to_string(),
155 155 path,
156 156 crate::preview::DecodePurpose::Chromatic { root_note },
@@ -161,8 +161,8 @@
161 161 pub fn toggle_instrument(&mut self) {
162 162 let mut inst = self.shared.instrument.lock();
163 163 inst.active = !inst.active;
164 - self.instrument_visible = inst.active;
165 - self.show_midi_window = inst.active;
164 + self.preview.instrument_visible = inst.active;
165 + self.preview.show_midi_window = inst.active;
166 166 }
167 167
168 168 /// Queue a sample to be added as a new zone in multi-sample instrument mode.
@@ -176,7 +176,7 @@
176 176 }
177 177 };
178 178
179 - self.instrument_decode.request(
179 + self.preview.instrument_decode.request(
180 180 hash.to_string(),
181 181 path,
182 182 crate::preview::DecodePurpose::Zone {
@@ -204,7 +204,7 @@
204 204 match outcome.purpose {
205 205 DecodePurpose::Chromatic { root_note } => {
206 206 // Drop superseded auto-loads (rapid arrow-key previewing).
207 - if outcome.generation != self.latest_chromatic_gen {
207 + if outcome.generation != self.preview.latest_chromatic_gen {
208 208 return;
209 209 }
210 210 let zone = crate::instrument::LoadedZone {
@@ -220,7 +220,7 @@
220 220 inst.zone_buffers.clear();
221 221 inst.zone_buffers.push(zone);
222 222 inst.active = true;
223 - inst.sample_rate = self.sample_rate;
223 + inst.sample_rate = self.preview.sample_rate;
224 224 // Kill all voices
225 225 for voice in &mut inst.voices {
226 226 voice.active = false;
@@ -229,7 +229,7 @@
229 229 }
230 230 drop(inst);
231 231
232 - self.instrument_root_note = root_note;
232 + self.preview.instrument_root_note = root_note;
233 233 }
234 234 DecodePurpose::Zone {
235 235 name,
@@ -249,11 +249,11 @@
249 249 inst.config.mode = audiofiles_core::instrument::InstrumentMode::MultiSample;
250 250 inst.zone_buffers.push(zone);
251 251 inst.active = true;
252 - inst.sample_rate = self.sample_rate;
252 + inst.sample_rate = self.preview.sample_rate;
253 253 drop(inst);
254 254
255 - self.instrument_visible = true;
256 - self.show_midi_window = true;
255 + self.preview.instrument_visible = true;
256 + self.preview.show_midi_window = true;
257 257 self.status = format!("Added zone: {name} ({low}-{high})");
258 258 }
259 259 }
@@ -1867,17 +1867,17 @@
1867 1867 #[test]
1868 1868 fn stop_preview_clears_state() {
1869 1869 let (mut state, _dir) = make_state();
1870 - state.previewing_hash = Some("abc".to_string());
1870 + state.preview.previewing_hash = Some("abc".to_string());
1871 1871 state.status = "Playing: kick.wav".to_string();
1872 1872 state.stop_preview();
1873 - assert!(state.previewing_hash.is_none());
1873 + assert!(state.preview.previewing_hash.is_none());
1874 1874 assert!(state.status.is_empty());
1875 1875 }
1876 1876
1877 1877 #[test]
1878 1878 fn detail_visible_defaults_true() {
1879 1879 let (state, _dir) = make_state();
1880 - assert!(state.detail_visible);
1880 + assert!(state.detail.detail_visible);
1881 1881 assert!(state.sidebar_visible);
1882 1882 }
1883 1883
@@ -2684,15 +2684,15 @@
2684 2684 {
2685 2685 let shared = Arc::new(SharedState::new());
2686 2686 let mut state = BrowserState::new(dir.path(), shared, 44100.0, "Vault").unwrap();
2687 - assert!(!state.forge_auto_trim_overshoot, "default is off");
2687 + assert!(!state.preview.forge_auto_trim_overshoot, "default is off");
2688 2688 state.toggle_forge_auto_trim_overshoot();
2689 - assert!(state.forge_auto_trim_overshoot);
2689 + assert!(state.preview.forge_auto_trim_overshoot);
2690 2690 }
2691 2691 // Reopen the same vault: the toggle is loaded from user-config.
2692 2692 let shared = Arc::new(SharedState::new());
2693 2693 let reloaded = BrowserState::new(dir.path(), shared, 44100.0, "Vault").unwrap();
2694 2694 assert!(
2695 - reloaded.forge_auto_trim_overshoot,
2695 + reloaded.preview.forge_auto_trim_overshoot,
2696 2696 "overshoot toggle should persist across reload"
2697 2697 );
2698 2698 }
@@ -3137,7 +3137,7 @@
3137 3137
3138 3138 state.trigger_preview(&hash);
3139 3139 // Synchronous state set immediately on dispatch.
3140 - assert_eq!(state.previewing_hash.as_deref(), Some(hash.as_str()));
3140 + assert_eq!(state.preview.previewing_hash.as_deref(), Some(hash.as_str()));
3141 3141 assert!(
3142 3142 state.status.starts_with("Playing:"),
3143 3143 "status was: {}",
@@ -3162,7 +3162,7 @@
3162 3162 await_playing(&state);
3163 3163
3164 3164 state.stop_preview();
3165 - assert!(state.previewing_hash.is_none());
3165 + assert!(state.preview.previewing_hash.is_none());
3166 3166 assert!(state.status.is_empty());
3167 3167 let pb = state.shared.preview.lock();
3168 3168 assert!(!pb.playing, "stop must halt playback");
@@ -3187,12 +3187,12 @@
3187 3187
3188 3188 // First toggle: not playing -> starts preview.
3189 3189 state.toggle_preview();
3190 - assert_eq!(state.previewing_hash.as_deref(), Some(hash.as_str()));
3190 + assert_eq!(state.preview.previewing_hash.as_deref(), Some(hash.as_str()));
3191 3191 await_playing(&state);
3192 3192
3193 3193 // Second toggle: playing -> stops.
3194 3194 state.toggle_preview();
3195 - assert!(state.previewing_hash.is_none());
3195 + assert!(state.preview.previewing_hash.is_none());
3196 3196 assert!(!state.shared.preview.lock().playing);
3197 3197 }
3198 3198
@@ -3204,7 +3204,7 @@
3204 3204 state.refresh_contents();
3205 3205
3206 3206 state.trigger_preview("deadbeef");
3207 - assert!(state.previewing_hash.is_none(), "no preview should start");
3207 + assert!(state.preview.previewing_hash.is_none(), "no preview should start");
3208 3208 assert!(
3209 3209 !state.status.is_empty(),
3210 3210 "an error status should be surfaced"
@@ -417,6 +417,85 @@
417 417 pub scroll_to_row: Option<usize>,
418 418 }
419 419
420 + /// Preview + instrument playback UI state: the previewing sample, playback
421 + /// preferences (loop / autoplay / forge overshoot trim), and the instrument
422 + /// panel's keyboard/MIDI window and off-thread chromatic loader.
423 + pub struct PreviewUiState {
424 + /// Content hash of the sample currently loaded into the preview stream.
425 + pub previewing_hash: Option<String>,
426 + /// GUI-side copy of the device output sample rate, used to seed instrument
427 + /// playback. Set from the real device rate at construction.
428 + pub sample_rate: f32,
429 + pub loop_enabled: bool,
430 + pub autoplay: bool,
431 + /// Forge overshoot policy: when true, a conform that overshoots full scale at
432 + /// an integer target is trimmed to the ceiling; when false (default) the
433 + /// signal is left untouched and the overshoot is only reported.
434 + pub forge_auto_trim_overshoot: bool,
435 +
436 + pub instrument_visible: bool,
437 + pub instrument_root_note: u8,
438 + /// When true, previewing a sample does NOT auto-load it into the instrument.
439 + pub instrument_locked: bool,
440 + /// MIDI notes currently held by piano mouse clicks.
441 + pub piano_held_notes: Vec<u8>,
442 + /// Whether the floating MIDI/instrument window is open.
443 + pub show_midi_window: bool,
444 + /// Off-thread decoder for instrument loading (chromatic auto-load + zone
445 + /// adds). Keeps the full-file decode off the egui frame thread.
446 + pub instrument_decode: crate::preview::InstrumentDecodeWorker,
447 + /// Generation of the most recently dispatched chromatic auto-load. Outcomes
448 + /// with an older generation are dropped so rapid arrow-key previewing can't
449 + /// load a stale instrument over the newest one.
450 + pub latest_chromatic_gen: u64,
451 + }
452 +
453 + impl Default for PreviewUiState {
454 + fn default() -> Self {
455 + Self {
456 + previewing_hash: None,
457 + sample_rate: 44100.0,
458 + loop_enabled: false,
459 + autoplay: false,
460 + forge_auto_trim_overshoot: false,
461 + instrument_visible: false,
462 + instrument_root_note: 60,
463 + instrument_locked: false,
464 + piano_held_notes: Vec::new(),
465 + show_midi_window: false,
466 + instrument_decode: crate::preview::InstrumentDecodeWorker::new(),
467 + latest_chromatic_gen: 0,
468 + }
469 + }
470 + }
471 +
472 + /// Detail panel state: the selected sample's analysis/waveform, its tags and
473 + /// tag provenance, the tag-entry buffer, the panel-visible toggle, and the
474 + /// dismissed-suggestion machinery that drives the inline Undo affordance.
475 + #[derive(Default)]
476 + pub struct DetailUiState {
477 + pub selected_analysis: Option<audiofiles_core::analysis::AnalysisResult>,
478 + pub selected_waveform: Option<audiofiles_core::analysis::waveform::WaveformData>,
479 + pub selected_tags: std::sync::Arc<Vec<String>>,
480 + /// Per-tag provenance for the selected sample: tag -> (source, rule_id). A tag
481 + /// absent from this map is manual. Refreshed alongside `selected_tags`.
482 + pub selected_tag_sources: std::collections::HashMap<String, (String, Option<String>)>,
483 + /// k-NN tag suggestions for the selected sample, populated on demand by the
484 + /// "Suggest similar tags" action and cleared when the selection changes.
485 + pub selected_ml_suggestions: Vec<audiofiles_core::analysis::exemplar::MlSuggestion>,
486 + pub tag_input: String,
487 + pub detail_visible: bool,
488 + /// Per-classification dismissed tag suggestions: e.g. dismissing
489 + /// "percussion" on a kick suppresses it on every future kick. Persisted
490 + /// under config key "suggestions.dismissed" as a JSON `<class>` → `[tag]` map.
491 + pub dismissed_suggestions: std::collections::HashMap<String, Vec<String>>,
492 + /// Last suggestion that was dismissed plus when. Drives the inline Undo
493 + /// affordance in the detail panel (M-1), visible for ~5 seconds after
494 + /// the dismiss, then fades. `None` means there's nothing to undo right
495 + /// now (initial state or after a successful undo / timeout).
496 + pub last_dismissed_suggestion: Option<(String, String, Instant)>,
497 + }
498 +
420 499 /// Import / analysis workflow state: the active screen, retry snapshots,
421 500 /// error accumulation, and pending review/save buffers.
422 501 #[derive(Default)]
@@ -20,10 +20,10 @@
20 20 };
21 21
22 22 // Waveform
23 - if let Some(ref waveform_data) = state.selected_waveform {
23 + if let Some(ref waveform_data) = state.detail.selected_waveform {
24 24 // Compute playback position as a 0.0–1.0 fraction for the waveform cursor.
25 25 // Only valid when the currently-playing hash matches this node's hash.
26 - let playback_pos = if state.previewing_hash.as_deref() == node.node.sample_hash.as_deref() {
26 + let playback_pos = if state.preview.previewing_hash.as_deref() == node.node.sample_hash.as_deref() {
27 27 let playback = state.shared.preview.lock();
28 28 if playback.playing {
29 29 if let Some(ref buf) = playback.buffer {
@@ -90,7 +90,7 @@
90 90 let rect = resp.rect;
91 91 let normalized = ((pos.x - rect.left()) / rect.width()).clamp(0.0, 1.0);
92 92 if let Some(hash) = &node.node.sample_hash
93 - && state.previewing_hash.as_deref() == Some(hash)
93 + && state.preview.previewing_hash.as_deref() == Some(hash)
94 94 {
95 95 let mut playback = state.shared.preview.lock();
96 96 if let Some(ref buf) = playback.buffer {
@@ -115,7 +115,7 @@
115 115 ui.add_space(theme::space::MD);
116 116
117 117 // Analysis metadata grid
118 - if let Some(ref analysis) = state.selected_analysis {
118 + if let Some(ref analysis) = state.detail.selected_analysis {
119 119 egui::CollapsingHeader::new("Metadata")
120 120 .id_salt("detail_metadata_section")
121 121 .default_open(true)
@@ -191,11 +191,11 @@
191 191 .id_salt("detail_tags_section")
192 192 .default_open(true)
193 193 .show(ui, |ui| {
194 - if state.selected_tags.is_empty() {
194 + if state.detail.selected_tags.is_empty() {
195 195 ui.label(egui::RichText::new("No tags").color(theme::content_muted()));
196 196 } else {
197 197 ui.horizontal_wrapped(|ui| {
198 - let tags = state.selected_tags.clone();
198 + let tags = state.detail.selected_tags.clone();
199 199 for tag in tags.iter() {
200 200 if widgets::tag_chip_removable(ui, tag, true) {
201 201 // Remove tag and push an undoable entry so Cmd+Z restores it.
@@ -218,7 +218,7 @@
218 218 // Tag input
219 219 ui.horizontal(|ui| {
220 220 let resp = ui.add(
221 - egui::TextEdit::singleline(&mut state.tag_input)
221 + egui::TextEdit::singleline(&mut state.detail.tag_input)
222 222 .hint_text("Add tag (use dots: genre.house)")
223 223 .desired_width(ui.available_width() - 40.0),
224 224 );
@@ -230,13 +230,13 @@
230 230 if (resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)))
231 231 || ui.small_button("+").on_hover_text("Add tag").clicked()
232 232 {
233 - let tag = state.tag_input.trim().to_string();
233 + let tag = state.detail.tag_input.trim().to_string();
234 234 if !tag.is_empty()
235 235 && let Some(ref hash) = node.node.sample_hash
236 236 {
237 237 if audiofiles_core::tags::validate_tag(&tag).is_ok() {
238 238 let _ = state.backend.add_tag(hash, &tag);
239 - state.tag_input.clear();
239 + state.detail.tag_input.clear();
240 240 state.refresh_selected_tags();
241 241 } else {
242 242 state.status = format!("Invalid tag: {tag}");
@@ -247,14 +247,14 @@
247 247
248 248 // Tag provenance: where each tag came from (manual vs rule/ml/cluster/folder),
249 249 // so "why does this sample have this tag?" is answerable at a glance.
250 - if !state.selected_tags.is_empty() {
250 + if !state.detail.selected_tags.is_empty() {
251 251 egui::CollapsingHeader::new("Tag sources")
252 252 .id_salt("detail_tag_sources")
253 253 .default_open(false)
254 254 .show(ui, |ui| {
255 - let tags = state.selected_tags.clone();
255 + let tags = state.detail.selected_tags.clone();
256 256 for tag in tags.iter() {
257 - let (label, color) = match state.selected_tag_sources.get(tag) {
257 + let (label, color) = match state.detail.selected_tag_sources.get(tag) {
258 258 None => ("manual", theme::content_muted()),
259 259 Some((source, _)) => match source.as_str() {
260 260 "rule" => ("rule", theme::action()),
@@ -287,8 +287,8 @@
287 287 {
288 288 state.suggest_ml_for_selected();
289 289 }
290 - if !state.selected_ml_suggestions.is_empty() {
291 - let suggestions = state.selected_ml_suggestions.clone();
290 + if !state.detail.selected_ml_suggestions.is_empty() {
291 + let suggestions = state.detail.selected_ml_suggestions.clone();
292 292 let mut accept: Option<String> = None;
293 293 for s in &suggestions {
294 294 ui.horizontal(|ui| {
@@ -315,17 +315,17 @@
315 315 // Tag suggestions based on classification. Per-classification dismissals
316 316 // let the user say "I never tag kicks with `percussion`" once and have
317 317 // the suggestion stop appearing on every future kick.
318 - if let Some(ref analysis) = state.selected_analysis
318 + if let Some(ref analysis) = state.detail.selected_analysis
319 319 && let Some(ref class) = analysis.classification
320 320 {
321 321 let class_str = class.to_string();
322 322 let dismissed_for_class = state
323 - .dismissed_suggestions
323 + .detail.dismissed_suggestions
324 324 .get(&class_str)
325 325 .cloned()
326 326 .unwrap_or_default();
327 327 let suggestions: Vec<&'static str> =
328 - classification_tag_suggestions(&class_str, &state.selected_tags)
328 + classification_tag_suggestions(&class_str, &state.detail.selected_tags)
329 329 .into_iter()
330 330 .filter(|s| !dismissed_for_class.iter().any(|d| d == s))
331 331 .collect();
@@ -389,7 +389,7 @@
389 389 const UNDO_WINDOW: f32 = 5.0;
390 390 // Bind the tag directly so there's no re-fetch + expect() in render.
391 391 let undo_tag = state
392 - .last_dismissed_suggestion
392 + .detail.last_dismissed_suggestion
393 393 .as_ref()
394 394 .filter(|(c, _, at)| {
395 395 c == &class_str && at.elapsed().as_secs_f32() < UNDO_WINDOW
@@ -460,11 +460,11 @@
460 460 // these the button "works" but always returns zero results,
461 461 // which reads as a broken feature instead of a missing prereq.
462 462 let has_spectral = state
463 - .selected_analysis
463 + .detail.selected_analysis
464 464 .as_ref()
465 465 .is_some_and(|a| a.spectral_centroid.is_some() || a.spectral_bandwidth.is_some());
466 466 let has_fingerprint = state
467 - .selected_analysis
467 + .detail.selected_analysis
468 468 .as_ref()
469 469 .is_some_and(|a| a.fingerprint.is_some());
470 470 egui::CollapsingHeader::new("Discovery")
@@ -71,11 +71,11 @@
71 71
72 72 /// Waveform display with playback cursor and click-to-seek.
73 73 fn draw_waveform_section(ui: &mut egui::Ui, state: &mut BrowserState, hash: &str) {
74 - if let Some(ref waveform_data) = state.selected_waveform {
74 + if let Some(ref waveform_data) = state.detail.selected_waveform {
75 75 // Show the playhead whenever this sample is the active preview and a
76 76 // buffer is loaded, including while paused, so the user can see where
77 77 // playback sits before auditioning a trim.
78 - let playback_pos = if state.previewing_hash.as_deref() == Some(hash) {
78 + let playback_pos = if state.preview.previewing_hash.as_deref() == Some(hash) {
79 79 let playback = state.shared.preview.lock();
80 80 playback.buffer.as_ref().and_then(|buf| {
81 81 let total_frames = if playback.streaming {
@@ -154,7 +154,7 @@
154 154 {
155 155 let rect = resp.rect;
156 156 let normalized = ((pos.x - rect.left()) / rect.width()).clamp(0.0, 1.0);
157 - if state.previewing_hash.as_deref() != Some(hash) {
157 + if state.preview.previewing_hash.as_deref() != Some(hash) {
158 158 state.trigger_preview(hash);
159 159 }
160 160 let mut playback = state.shared.preview.lock();
@@ -177,7 +177,7 @@
177 177 /// the main file-list selection so the user can audition before committing a
178 178 /// destructive edit.
179 179 fn draw_transport_section(ui: &mut egui::Ui, state: &mut BrowserState, hash: &str) {
180 - let is_current = state.previewing_hash.as_deref() == Some(hash);
180 + let is_current = state.preview.previewing_hash.as_deref() == Some(hash);
181 181 let playing = is_current && state.shared.preview.lock().playing;
182 182 ui.horizontal(|ui| {
183 183 if widgets::secondary_button(ui, if playing { "Pause" } else { "Play" }).clicked() {
@@ -227,7 +227,7 @@
227 227
228 228 /// Info line: name, sample rate, duration, peak dB.
229 229 fn draw_info_line(ui: &mut egui::Ui, state: &BrowserState) {
230 - if let Some(ref analysis) = state.selected_analysis {
230 + if let Some(ref analysis) = state.detail.selected_analysis {
231 231 let name = state
232 232 .selected_node()
233 233 .map(|n| n.node.name.clone())
@@ -260,7 +260,7 @@
260 260 ui.label(egui::RichText::new("Trim").strong());
261 261
262 262 let sample_rate = state
263 - .selected_analysis
263 + .detail.selected_analysis
264 264 .as_ref()
265 265 .map_or(44100, |a| a.sample_rate);
266 266 let total = state.edit.total_frames;
@@ -307,7 +307,7 @@
307 307 ui.label(egui::RichText::new("Levels").strong());
308 308
309 309 // Current peak display + gain clipping warning
310 - let current_peak = state.selected_analysis.as_ref().and_then(|a| a.peak_db);
310 + let current_peak = state.detail.selected_analysis.as_ref().and_then(|a| a.peak_db);
311 311 if let Some(peak) = current_peak {
312 312 let predicted = peak + state.edit.gain_db;
313 313 if predicted > 0.0 {
@@ -464,7 +464,7 @@
464 464 // range only if duration is missing (un-analyzed sample). Prevents the
465 465 // silent-failure / undefined-behaviour case where positions exceed length.
466 466 let duration_ms_cap = state
467 - .selected_analysis
467 + .detail.selected_analysis
468 468 .as_ref()
469 469 .map_or(f64::MAX, |a| a.duration * 1000.0);
470 470
@@ -447,7 +447,7 @@
447 447 }
448 448 }
449 449 } else {
450 - let is_playing = state.previewing_hash.as_deref() == Some(hash)
450 + let is_playing = state.preview.previewing_hash.as_deref() == Some(hash)
451 451 && state.shared.preview.lock().playing;
452 452 let btn_text = if is_playing { "Stop" } else { "Play" };
453 453 let hover = if is_playing {
@@ -608,7 +608,7 @@
608 608 }
609 609
610 610 // Drag source for instrument zone assignment (not for cloud-only)
611 - if state.instrument_visible
611 + if state.preview.instrument_visible
612 612 && node.node.node_type == NodeType::Sample
613 613 && !node.cloud_only
614 614 && let Some(hash) = &node.node.sample_hash
@@ -622,7 +622,7 @@
622 622 // Native OS drag-out to Finder/DAW (only when instrument panel is closed)
623 623 #[cfg(any(target_os = "macos", target_os = "windows"))]
624 624 if !os_drag_blocked
625 - && !state.instrument_visible
625 + && !state.preview.instrument_visible
626 626 && node.node.node_type == NodeType::Sample
627 627 && !node.cloud_only
628 628 && resp.dragged()