Skip to main content

max / audiofiles

44.6 KB · 1255 lines History Blame Raw
1 //! Tag-classifier UI: the deterministic rules builder (Layer A), drawn as a section
2 //! inside the Settings window. Reads/mutates `state.classifier`; persistence and
3 //! reconciliation go through the `classifier_*` methods on `BrowserState`.
4
5 use egui;
6
7 use audiofiles_core::rules::{MatchMode, RuleAction, RuleCondition, RuleField, RuleOp};
8
9 use super::theme;
10 use super::widgets;
11 use crate::state::BrowserState;
12
13 /// Fields offered in the condition builder (path/name + tags first, then DSP).
14 const FIELDS: &[RuleField] = &[
15 RuleField::Name,
16 RuleField::SourcePath,
17 RuleField::VfsPath,
18 RuleField::FileExtension,
19 RuleField::Tag,
20 RuleField::Duration,
21 RuleField::Bpm,
22 RuleField::MusicalKey,
23 RuleField::IsLoop,
24 RuleField::SpectralCentroid,
25 RuleField::SpectralFlatness,
26 RuleField::SpectralRolloff,
27 RuleField::Zcr,
28 RuleField::SpectralBandwidth,
29 RuleField::CentroidVariance,
30 RuleField::CrestFactor,
31 RuleField::AttackTime,
32 RuleField::PeakDb,
33 RuleField::RmsDb,
34 RuleField::Lufs,
35 RuleField::SampleRate,
36 RuleField::Channels,
37 RuleField::FileSize,
38 ];
39
40 fn field_label(f: RuleField) -> &'static str {
41 use RuleField::{
42 AttackTime, Bpm, CentroidVariance, Channels, CrestFactor, Duration, FileExtension,
43 FileSize, IsLoop, Lufs, MusicalKey, Name, PeakDb, RmsDb, SampleRate, SourcePath,
44 SpectralBandwidth, SpectralCentroid, SpectralFlatness, SpectralRolloff, Tag, VfsPath, Zcr,
45 };
46 match f {
47 Name => "File name",
48 SourcePath => "Source path",
49 VfsPath => "Folder path",
50 FileExtension => "Extension",
51 Tag => "Tag",
52 Duration => "Duration (s)",
53 Bpm => "BPM",
54 MusicalKey => "Key",
55 IsLoop => "Is loop",
56 SpectralCentroid => "Brightness",
57 SpectralFlatness => "Noisiness",
58 SpectralRolloff => "Rolloff",
59 Zcr => "Zero-crossing rate",
60 SpectralBandwidth => "Bandwidth",
61 CentroidVariance => "Spectral motion",
62 CrestFactor => "Crest factor",
63 AttackTime => "Attack (s)",
64 PeakDb => "Peak dB",
65 RmsDb => "RMS dB",
66 Lufs => "LUFS",
67 SampleRate => "Sample rate",
68 Channels => "Channels",
69 FileSize => "File size",
70 }
71 }
72
73 enum Kind {
74 Text,
75 Number,
76 Bool,
77 }
78
79 fn field_kind(f: RuleField) -> Kind {
80 use RuleField::{FileExtension, IsLoop, MusicalKey, Name, SourcePath, Tag, VfsPath};
81 match f {
82 Name | SourcePath | VfsPath | FileExtension | Tag | MusicalKey => Kind::Text,
83 IsLoop => Kind::Bool,
84 _ => Kind::Number,
85 }
86 }
87
88 const STRING_OPS: &[RuleOp] = &[
89 RuleOp::Contains,
90 RuleOp::NotContains,
91 RuleOp::Equals,
92 RuleOp::NotEquals,
93 RuleOp::StartsWith,
94 RuleOp::EndsWith,
95 RuleOp::Exists,
96 RuleOp::NotExists,
97 ];
98 const NUM_OPS: &[RuleOp] = &[
99 RuleOp::Lt,
100 RuleOp::Le,
101 RuleOp::Gt,
102 RuleOp::Ge,
103 RuleOp::Equals,
104 RuleOp::NotEquals,
105 RuleOp::Exists,
106 RuleOp::NotExists,
107 ];
108 const BOOL_OPS: &[RuleOp] = &[
109 RuleOp::IsTrue,
110 RuleOp::IsFalse,
111 RuleOp::Exists,
112 RuleOp::NotExists,
113 ];
114
115 fn ops_for(f: RuleField) -> &'static [RuleOp] {
116 match field_kind(f) {
117 Kind::Text => STRING_OPS,
118 Kind::Number => NUM_OPS,
119 Kind::Bool => BOOL_OPS,
120 }
121 }
122
123 fn op_label(op: RuleOp) -> &'static str {
124 use RuleOp::{
125 Contains, EndsWith, Equals, Exists, Ge, Gt, IsFalse, IsTrue, Le, Lt, NotContains,
126 NotEquals, NotExists, StartsWith,
127 };
128 match op {
129 Contains => "contains",
130 NotContains => "doesn't contain",
131 Equals => "equals",
132 NotEquals => "not equals",
133 StartsWith => "starts with",
134 EndsWith => "ends with",
135 Lt => "<",
136 Le => "\u{2264}",
137 Gt => ">",
138 Ge => "\u{2265}",
139 IsTrue => "is true",
140 IsFalse => "is false",
141 Exists => "exists",
142 NotExists => "missing",
143 }
144 }
145
146 fn op_needs_value(op: RuleOp) -> bool {
147 !matches!(
148 op,
149 RuleOp::Exists | RuleOp::NotExists | RuleOp::IsTrue | RuleOp::IsFalse
150 )
151 }
152
153 /// Keep a condition's operator valid for its field's kind: if `op` isn't one the
154 /// field offers, fall back to the field's first offered op. `ops_for` is never empty,
155 /// so `ops[0]` always exists.
156 fn coerce_op(field: RuleField, op: RuleOp) -> RuleOp {
157 let ops = ops_for(field);
158 if ops.contains(&op) { op } else { ops[0] }
159 }
160
161 /// Draw all tag-classifier sections in Settings.
162 pub fn draw_classifier_section(ui: &mut egui::Ui, state: &mut BrowserState) {
163 // A background job (train/auto-tag/cluster/export) is running: show progress and let
164 // the disabled buttons below explain why they're inert. Footer status is hidden behind
165 // the Settings modal, so feedback lives here.
166 if let Some(label) = state.classifier.busy.clone() {
167 ui.horizontal(|ui| {
168 ui.add(egui::Spinner::new().size(14.0));
169 ui.label(
170 egui::RichText::new(label)
171 .small()
172 .color(theme::content_secondary()),
173 );
174 });
175 ui.add_space(theme::space::SM);
176 } else if let Some(err) = state.classifier.last_error.clone() {
177 widgets::warning_banner(ui, &err);
178 ui.add_space(theme::space::SM);
179 }
180
181 draw_rules_header(ui, state);
182 ui.add_space(theme::space::SM);
183 draw_autotag_header(ui, state);
184 ui.add_space(theme::space::SM);
185 draw_cluster_header(ui, state);
186 ui.add_space(theme::space::SM);
187 draw_folder_header(ui, state);
188 ui.add_space(theme::space::SM);
189 draw_sharing_header(ui, state);
190 }
191
192 /// "Tag Rules" section (Layer A).
193 fn draw_rules_header(ui: &mut egui::Ui, state: &mut BrowserState) {
194 egui::CollapsingHeader::new(egui::RichText::new("Tag Rules").strong())
195 .default_open(false)
196 .show(ui, |ui| {
197 state.ensure_rules_loaded();
198 ui.label(
199 egui::RichText::new(
200 "Deterministic rules that auto-apply tags by sample metadata and audio \
201 features. Rules never remove tags you added by hand.",
202 )
203 .small()
204 .color(theme::content_muted()),
205 );
206 ui.add_space(theme::space::SM);
207
208 if state.classifier.editing.is_some() {
209 draw_editor(ui, state);
210 } else {
211 draw_list(ui, state);
212 }
213 });
214 }
215
216 fn draw_list(ui: &mut egui::Ui, state: &mut BrowserState) {
217 ui.horizontal(|ui| {
218 if ui.button("New rule").clicked() {
219 state.classifier_new_draft();
220 }
221 if ui
222 .button("Apply rules now")
223 .on_hover_text("Re-evaluate every rule across the whole library")
224 .clicked()
225 {
226 state.classifier_apply_all();
227 }
228 });
229
230 if let Some(n) = state.classifier.last_apply {
231 ui.label(
232 egui::RichText::new(format!(
233 "Last apply: {n} sample{} updated",
234 if n == 1 { "" } else { "s" }
235 ))
236 .small()
237 .color(theme::content_muted()),
238 );
239 }
240 ui.add_space(theme::space::SM);
241
242 if state.classifier.rules.is_empty() {
243 ui.label(
244 egui::RichText::new(
245 "No rules yet \u{2014} new rules start empty; you decide what gets tagged.",
246 )
247 .small()
248 .color(theme::content_muted()),
249 );
250 return;
251 }
252
253 // Move the list out to iterate it, then move it back before applying the
254 // deferred actions (a zero-copy swap replacing a per-frame deep clone of the
255 // whole rules Vec). The loop body reads only the local list + `ui`, never
256 // `state.classifier.rules`, so the temporary empty field is never observed.
257 let rules = std::mem::take(&mut state.classifier.rules);
258 let count = rules.len();
259 let mut toggle: Option<(String, bool)> = None;
260 let mut edit: Option<usize> = None;
261 let mut delete: Option<String> = None;
262 let mut move_rule: Option<(usize, bool)> = None;
263
264 for (i, rule) in rules.iter().enumerate() {
265 ui.horizontal(|ui| {
266 let mut enabled = rule.enabled;
267 if ui.checkbox(&mut enabled, "").changed() {
268 toggle = Some((rule.id.clone(), enabled));
269 }
270 let name = if rule.name.trim().is_empty() {
271 "(unnamed)"
272 } else {
273 rule.name.as_str()
274 };
275 ui.label(name);
276 ui.label(
277 egui::RichText::new(format!(
278 "{} cond \u{2192} {} act",
279 rule.conditions.len(),
280 rule.actions.len()
281 ))
282 .small()
283 .color(theme::content_muted()),
284 );
285
286 ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
287 if widgets::danger_small_button(ui, "Delete").clicked() {
288 delete = Some(rule.id.clone());
289 }
290 if ui.small_button("Edit").clicked() {
291 edit = Some(i);
292 }
293 if ui
294 .add_enabled(i + 1 < count, egui::Button::new("\u{25bc}").small())
295 .clicked()
296 {
297 move_rule = Some((i, false));
298 }
299 if ui
300 .add_enabled(i > 0, egui::Button::new("\u{25b2}").small())
301 .clicked()
302 {
303 move_rule = Some((i, true));
304 }
305 });
306 });
307 }
308
309 // Restore the list before applying deferred actions (which mutate it).
310 state.classifier.rules = rules;
311
312 if let Some((id, enabled)) = toggle {
313 state.classifier_toggle_rule(&id, enabled);
314 }
315 if let Some(i) = edit {
316 let rule = state.classifier.rules[i].clone();
317 state.classifier_edit_rule(&rule);
318 }
319 if let Some(id) = delete {
320 state.classifier_delete_rule(&id);
321 }
322 if let Some((i, up)) = move_rule {
323 state.classifier_move_rule(i, up);
324 }
325 }
326
327 fn draw_editor(ui: &mut egui::Ui, state: &mut BrowserState) {
328 let mut do_save = false;
329 let mut do_cancel = false;
330 let mut do_test = false;
331
332 {
333 let Some(draft) = state.classifier.editing.as_mut() else {
334 return;
335 };
336
337 ui.horizontal(|ui| {
338 ui.label("Name");
339 ui.text_edit_singleline(&mut draft.name);
340 });
341 ui.checkbox(&mut draft.enabled, "Enabled");
342
343 ui.horizontal(|ui| {
344 ui.label("Match");
345 ui.selectable_value(&mut draft.match_mode, MatchMode::All, "All");
346 ui.selectable_value(&mut draft.match_mode, MatchMode::Any, "Any");
347 ui.label(
348 egui::RichText::new("of these conditions")
349 .small()
350 .color(theme::content_muted()),
351 );
352 });
353
354 // --- Conditions ---
355 widgets::subsection_label(ui, "When");
356 let mut remove_cond: Option<usize> = None;
357 let cond_count = draft.conditions.len();
358 for (i, cond) in draft.conditions.iter_mut().enumerate() {
359 ui.horizontal(|ui| {
360 egui::ComboBox::from_id_salt(("rule_field", i))
361 .selected_text(field_label(cond.field))
362 .width(120.0)
363 .show_ui(ui, |ui| {
364 for f in FIELDS {
365 ui.selectable_value(&mut cond.field, *f, field_label(*f));
366 }
367 });
368 // Keep the operator valid for the chosen field's kind.
369 cond.op = coerce_op(cond.field, cond.op);
370 let ops = ops_for(cond.field);
371 egui::ComboBox::from_id_salt(("rule_op", i))
372 .selected_text(op_label(cond.op))
373 .width(110.0)
374 .show_ui(ui, |ui| {
375 for op in ops {
376 ui.selectable_value(&mut cond.op, *op, op_label(*op));
377 }
378 });
379 if op_needs_value(cond.op) {
380 ui.add(
381 egui::TextEdit::singleline(&mut cond.value)
382 .desired_width(70.0)
383 .hint_text("value"),
384 );
385 }
386 if ui
387 .add_enabled(cond_count > 1, egui::Button::new("\u{2715}").small())
388 .clicked()
389 {
390 remove_cond = Some(i);
391 }
392 });
393 }
394 if let Some(i) = remove_cond {
395 draft.conditions.remove(i);
396 draft.match_count = None;
397 }
398 if ui.small_button("Add condition").clicked() {
399 draft.conditions.push(RuleCondition {
400 field: RuleField::Name,
401 op: RuleOp::Contains,
402 value: String::new(),
403 });
404 draft.match_count = None;
405 }
406
407 // --- Actions ---
408 ui.add_space(theme::space::SM);
409 widgets::subsection_label(ui, "Then");
410 let mut remove_act: Option<usize> = None;
411 for (i, action) in draft.actions.iter_mut().enumerate() {
412 ui.horizontal(|ui| {
413 // 0 = add tag, 1 = remove tag, 2 = stop.
414 let mut kind = match action {
415 RuleAction::AddTag(_) => 0u8,
416 RuleAction::RemoveTag(_) => 1,
417 RuleAction::Stop => 2,
418 };
419 let kind_before = kind;
420 egui::ComboBox::from_id_salt(("rule_action", i))
421 .selected_text(match kind {
422 0 => "Add tag",
423 1 => "Remove tag",
424 _ => "Stop",
425 })
426 .width(100.0)
427 .show_ui(ui, |ui| {
428 ui.selectable_value(&mut kind, 0, "Add tag");
429 ui.selectable_value(&mut kind, 1, "Remove tag");
430 ui.selectable_value(&mut kind, 2, "Stop");
431 });
432 if kind != kind_before {
433 let tag = match action {
434 RuleAction::AddTag(s) | RuleAction::RemoveTag(s) => s.clone(),
435 RuleAction::Stop => String::new(),
436 };
437 *action = match kind {
438 0 => RuleAction::AddTag(tag),
439 1 => RuleAction::RemoveTag(tag),
440 _ => RuleAction::Stop,
441 };
442 }
443 if let RuleAction::AddTag(s) | RuleAction::RemoveTag(s) = action {
444 ui.add(
445 egui::TextEdit::singleline(s)
446 .desired_width(150.0)
447 .hint_text("instrument.drum.kick"),
448 );
449 }
450 if ui.small_button("\u{2715}").clicked() {
451 remove_act = Some(i);
452 }
453 });
454 }
455 if let Some(i) = remove_act {
456 draft.actions.remove(i);
457 }
458 if ui.small_button("Add action").clicked() {
459 draft.actions.push(RuleAction::AddTag(String::new()));
460 }
461
462 // --- Dry run + footer ---
463 ui.add_space(theme::space::SM);
464 ui.horizontal(|ui| {
465 if ui
466 .button("Test")
467 .on_hover_text("Count how many samples match")
468 .clicked()
469 {
470 do_test = true;
471 }
472 if let Some(n) = draft.match_count {
473 ui.label(
474 egui::RichText::new(format!(
475 "Matches {n} sample{}",
476 if n == 1 { "" } else { "s" }
477 ))
478 .small()
479 .color(theme::content_secondary()),
480 );
481 }
482 });
483
484 ui.add_space(theme::space::SM);
485 ui.horizontal(|ui| {
486 if widgets::primary_button(ui, "Save").clicked() {
487 do_save = true;
488 }
489 if ui.button("Cancel").clicked() {
490 do_cancel = true;
491 }
492 });
493 }
494
495 if do_test {
496 state.classifier_test_draft();
497 }
498 if do_save {
499 state.classifier_save_draft();
500 }
501 if do_cancel {
502 state.classifier_cancel_draft();
503 }
504 }
505
506 /// "Auto-Tagging" section (Layer B): library-wide suggest + per-tag thresholds.
507 fn draw_autotag_header(ui: &mut egui::Ui, state: &mut BrowserState) {
508 egui::CollapsingHeader::new(egui::RichText::new("Auto-Tagging").strong())
509 .default_open(false)
510 .show(ui, |ui| {
511 ui.label(
512 egui::RichText::new(
513 "Suggests tags from samples that look like ones you've already tagged. \
514 Tags above a per-tag threshold are applied automatically.",
515 )
516 .small()
517 .color(theme::content_muted()),
518 );
519 ui.add_space(theme::space::SM);
520 let busy = state.classifier.busy.is_some();
521 ui.horizontal(|ui| {
522 if ui
523 .add_enabled(!busy, egui::Button::new("Suggest tags across library"))
524 .on_hover_text(
525 "Build the model from your tagged samples and auto-apply confident tags",
526 )
527 .clicked()
528 {
529 state.classifier_auto_apply_ml();
530 }
531 if ui
532 .add_enabled(!busy, egui::Button::new("Undo"))
533 .on_hover_text("Remove every tag applied by auto-tagging")
534 .clicked()
535 {
536 state.undo_tag_source("ml");
537 }
538 });
539 if let Some(n) = state.classifier.last_ml_apply {
540 ui.label(
541 egui::RichText::new(format!(
542 "Last run: {n} tag{} applied",
543 if n == 1 { "" } else { "s" }
544 ))
545 .small()
546 .color(theme::content_muted()),
547 );
548 }
549
550 ui.add_space(theme::space::MD);
551 draw_trained_head(ui, state);
552
553 ui.add_space(theme::space::MD);
554 widgets::subsection_label(ui, "Per-tag thresholds");
555 ui.label(
556 egui::RichText::new(
557 "review = surface for review \u{00b7} auto = apply automatically",
558 )
559 .small()
560 .color(theme::content_muted()),
561 );
562 state.ensure_policies_loaded();
563
564 let policies = std::mem::take(&mut state.classifier.policies);
565 let mut changed: Option<(String, f64, f64)> = None;
566 for (tag, policy) in &policies {
567 let mut review = policy.review_threshold as f32;
568 let mut auto = policy.auto_threshold as f32;
569 ui.horizontal(|ui| {
570 ui.label(egui::RichText::new(tag).small());
571 });
572 ui.horizontal(|ui| {
573 ui.label(
574 egui::RichText::new("review")
575 .small()
576 .color(theme::content_muted()),
577 );
578 let r = ui.add(egui::Slider::new(&mut review, 0.0..=1.0).show_value(true));
579 ui.label(
580 egui::RichText::new("auto")
581 .small()
582 .color(theme::content_muted()),
583 );
584 let a = ui.add(egui::Slider::new(&mut auto, 0.0..=1.0).show_value(true));
585 if r.drag_stopped()
586 || a.drag_stopped()
587 || (r.changed() && !r.dragged())
588 || (a.changed() && !a.dragged())
589 {
590 changed = Some((tag.clone(), review as f64, auto as f64));
591 }
592 });
593 }
594 state.classifier.policies = policies;
595 if let Some((tag, review, auto)) = changed {
596 state.classifier_set_policy(&tag, review, auto);
597 state.refresh_policies();
598 }
599
600 ui.add_space(theme::space::SM);
601 ui.horizontal(|ui| {
602 ui.add(
603 egui::TextEdit::singleline(&mut state.classifier.new_policy_tag)
604 .desired_width(170.0)
605 .hint_text("tag to configure"),
606 );
607 if ui.button("Add").clicked() {
608 state.classifier_add_policy();
609 }
610 });
611 });
612 }
613
614 /// "Trained model" subsection: the optional distilled head that speeds up the
615 /// library-wide auto-tagging pass on large libraries. Optional, without it, auto-tagging
616 /// uses nearest-neighbor matching directly.
617 fn draw_trained_head(ui: &mut egui::Ui, state: &mut BrowserState) {
618 state.ensure_head_loaded();
619 widgets::subsection_label(ui, "Trained model (optional)");
620 ui.label(
621 egui::RichText::new(
622 "For large libraries, distil your tagged samples into a compact model so \
623 auto-tagging runs much faster. Optional: auto-tagging works without it.",
624 )
625 .small()
626 .color(theme::content_muted()),
627 );
628
629 match state.classifier.head_info {
630 Some(info) => {
631 ui.label(
632 egui::RichText::new(format!(
633 "Active: {} tag{} from {} sample{}.",
634 info.class_count,
635 if info.class_count == 1 { "" } else { "s" },
636 info.exemplar_count,
637 if info.exemplar_count == 1 { "" } else { "s" },
638 ))
639 .small(),
640 );
641 }
642 None => {
643 // Surface the size hint only when there's no model yet.
644 if let Some((n, worthwhile)) = state.classifier.head_readiness {
645 let hint = if worthwhile {
646 format!("No model yet. With {n} tagged samples, training is recommended.")
647 } else {
648 format!(
649 "No model: nearest-neighbor matching is fast enough at {n} tagged \
650 sample{}.",
651 if n == 1 { "" } else { "s" }
652 )
653 };
654 ui.label(
655 egui::RichText::new(hint)
656 .small()
657 .color(theme::content_muted()),
658 );
659 }
660 }
661 }
662
663 ui.add_space(theme::space::SM);
664 let busy = state.classifier.busy.is_some();
665 let has_head = state.classifier.head_info.is_some();
666 ui.horizontal(|ui| {
667 let train_label = if has_head {
668 "Retrain model"
669 } else {
670 "Train model"
671 };
672 // Fixed width so the "Train model" ↔ "Retrain model" swap doesn't shift the row.
673 if ui
674 .add_enabled(
675 !busy,
676 egui::Button::new(train_label).min_size(egui::vec2(96.0, 0.0)),
677 )
678 .on_hover_text("Distil your tagged library into a compact per-library classifier")
679 .clicked()
680 {
681 state.classifier_train_head();
682 }
683 if has_head {
684 let clear = ui
685 .add_enabled(!busy, egui::Button::new("Clear"))
686 .on_hover_text(
687 "Remove the model; auto-tagging reverts to nearest-neighbor matching",
688 );
689 if clear.clicked() {
690 state.classifier_clear_head();
691 }
692 }
693 });
694 }
695
696 /// "Clustering" section: unsupervised cold-start grouping.
697 fn draw_cluster_header(ui: &mut egui::Ui, state: &mut BrowserState) {
698 egui::CollapsingHeader::new(egui::RichText::new("Clustering").strong())
699 .default_open(false)
700 .show(ui, |ui| {
701 ui.label(
702 egui::RichText::new(
703 "Groups similar samples so you can name them and seed your first tags \
704 \u{2014} useful when nothing is tagged yet.",
705 )
706 .small()
707 .color(theme::content_muted()),
708 );
709 ui.add_space(theme::space::SM);
710 let busy = state.classifier.busy.is_some();
711 ui.horizontal(|ui| {
712 let mut k = state.classifier.cluster_k.max(2);
713 ui.label("Groups");
714 if ui.add(egui::Slider::new(&mut k, 2..=24)).changed() {
715 state.classifier.cluster_k = k;
716 }
717 if ui
718 .add_enabled(!busy, egui::Button::new("Find clusters"))
719 .clicked()
720 {
721 state.classifier.cluster_k = k;
722 state.run_clustering();
723 }
724 });
725
726 if state.classifier.clusters.is_empty() {
727 return;
728 }
729 ui.add_space(theme::space::SM);
730
731 let clusters = std::mem::take(&mut state.classifier.clusters);
732 let mut play: Option<String> = None;
733 let mut apply: Option<usize> = None;
734 for (i, cluster) in clusters.iter().enumerate() {
735 ui.horizontal(|ui| {
736 ui.label(
737 egui::RichText::new(format!("{} samples", cluster.member_hashes.len()))
738 .small()
739 .color(theme::content_secondary()),
740 );
741 if ui
742 .small_button("Play")
743 .on_hover_text("Preview a representative sample")
744 .clicked()
745 {
746 play = Some(cluster.medoid_hash.clone());
747 }
748 if let Some(name) = state.classifier.cluster_names.get_mut(i) {
749 ui.add(
750 egui::TextEdit::singleline(name)
751 .desired_width(150.0)
752 .hint_text("tag for this group"),
753 );
754 }
755 if ui.small_button("Tag").clicked() {
756 apply = Some(i);
757 }
758 });
759 }
760 state.classifier.clusters = clusters;
761 if let Some(hash) = play {
762 state.trigger_preview(&hash);
763 }
764 if let Some(i) = apply {
765 state.apply_cluster(i);
766 }
767
768 ui.add_space(theme::space::SM);
769 if ui
770 .button("Remove all cluster tags")
771 .on_hover_text("Undo every tag applied from clustering")
772 .clicked()
773 {
774 state.undo_tag_source("cluster");
775 }
776 });
777 }
778
779 /// "Folder Tags" section: derive tags from the library's folder structure.
780 fn draw_folder_header(ui: &mut egui::Ui, state: &mut BrowserState) {
781 egui::CollapsingHeader::new(egui::RichText::new("Folder Tags").strong())
782 .default_open(false)
783 .show(ui, |ui| {
784 ui.label(
785 egui::RichText::new(
786 "Turns folders that directly contain samples into tags \u{2014} useful when \
787 your library is already organized into folders.",
788 )
789 .small()
790 .color(theme::content_muted()),
791 );
792 ui.add_space(theme::space::SM);
793 ui.horizontal(|ui| {
794 if ui.button("Scan folders").clicked() {
795 state.refresh_folder_labels();
796 }
797 if ui
798 .button("Undo")
799 .on_hover_text("Remove every tag applied from folders")
800 .clicked()
801 {
802 state.undo_tag_source("harvest");
803 }
804 });
805
806 if !state.classifier.folder_loaded {
807 return;
808 }
809 if state.classifier.folder_labels.is_empty() {
810 ui.label(
811 egui::RichText::new("No folders with samples found.")
812 .small()
813 .color(theme::content_muted()),
814 );
815 return;
816 }
817 ui.add_space(theme::space::SM);
818
819 let labels = std::mem::take(&mut state.classifier.folder_labels);
820 let mut apply: Option<usize> = None;
821 for (i, label) in labels.iter().enumerate() {
822 ui.horizontal(|ui| {
823 ui.label(egui::RichText::new(&label.folder_name).small());
824 ui.label(
825 egui::RichText::new(format!("({})", label.sample_hashes.len()))
826 .small()
827 .color(theme::content_muted()),
828 );
829 });
830 ui.horizontal(|ui| {
831 if let Some(tag) = state.classifier.folder_tags.get_mut(i) {
832 ui.add(egui::TextEdit::singleline(tag).desired_width(180.0));
833 }
834 if ui.small_button("Apply").clicked() {
835 apply = Some(i);
836 }
837 });
838 }
839 state.classifier.folder_labels = labels;
840 if let Some(i) = apply {
841 state.apply_folder_label(i);
842 }
843 });
844 }
845
846 /// "Shared Classifiers (.afcl)" section: export your own data to a portable file, import
847 /// someone else's as a removable layer, and manage imported layers.
848 fn draw_sharing_header(ui: &mut egui::Ui, state: &mut BrowserState) {
849 egui::CollapsingHeader::new(egui::RichText::new("Shared Classifiers (.afcl)").strong())
850 .default_open(false)
851 .show(ui, |ui| {
852 state.ensure_layers_loaded();
853 ui.label(
854 egui::RichText::new(
855 "Share your tagging as a portable .afcl file \u{2014} it carries your feature \
856 vectors, tags, rules, and thresholds, but never any audio. Imported files \
857 become removable layers that sit below your own tagging.",
858 )
859 .small()
860 .color(theme::content_muted()),
861 );
862
863 let busy = state.classifier.busy.is_some();
864
865 // ── Export ──
866 ui.add_space(theme::space::MD);
867 widgets::subsection_label(ui, "Export");
868 // Load what's exportable so the button can disable when there's nothing to share.
869 state.ensure_head_loaded();
870 state.ensure_rules_loaded();
871 state.ensure_policies_loaded();
872 ui.horizontal(|ui| {
873 ui.label(
874 egui::RichText::new("Name")
875 .small()
876 .color(theme::content_muted()),
877 );
878 ui.add(
879 egui::TextEdit::singleline(&mut state.classifier.export_name)
880 .desired_width(180.0)
881 .hint_text("My classifier"),
882 );
883 });
884 ui.horizontal(|ui| {
885 ui.checkbox(&mut state.classifier.export_include_exemplars, "Exemplars");
886 ui.checkbox(&mut state.classifier.export_include_rules, "Rules");
887 ui.checkbox(&mut state.classifier.export_include_policy, "Thresholds");
888 });
889 let exemplars_avail = state.classifier.head_readiness.is_some_and(|(n, _)| n > 0);
890 let rules_avail = !state.classifier.rules.is_empty();
891 let policy_avail = !state.classifier.policies.is_empty();
892 let has_content = export_has_content(
893 (state.classifier.export_include_exemplars, exemplars_avail),
894 (state.classifier.export_include_rules, rules_avail),
895 (state.classifier.export_include_policy, policy_avail),
896 );
897 ui.add_enabled_ui(!busy && has_content, |ui| {
898 if widgets::primary_button(ui, "Export to file...")
899 .on_hover_text("Save a .afcl file you can share \u{2014} no audio is included")
900 .clicked()
901 {
902 let file_name =
903 format!("{}.afcl", sanitize_filename(&state.classifier.export_name));
904 state.dialogs.save_file(
905 "Export classifier",
906 file_name,
907 &[("AF classifier", &["afcl"])],
908 |s, p| s.classifier_export_afcl(&p),
909 );
910 }
911 });
912 if !has_content {
913 ui.label(
914 egui::RichText::new(
915 "Nothing to export yet \u{2014} tag samples or add rules first.",
916 )
917 .small()
918 .color(theme::content_muted()),
919 );
920 }
921 if let Some(msg) = &state.classifier.last_export_msg {
922 ui.label(
923 egui::RichText::new(msg)
924 .small()
925 .color(theme::content_secondary()),
926 );
927 }
928
929 // ── Import ──
930 ui.add_space(theme::space::MD);
931 widgets::subsection_label(ui, "Import");
932 ui.add_enabled_ui(!busy, |ui| {
933 if widgets::primary_button(ui, "Import .afcl file...")
934 .on_hover_text("Add someone's shared classifier as a removable layer")
935 .clicked()
936 {
937 state.dialogs.pick_file(
938 "Import classifier",
939 &[("AF classifier", &["afcl"])],
940 |s, p| s.classifier_import_afcl(&p),
941 );
942 }
943 });
944 if let Some(msg) = &state.classifier.last_import_msg {
945 ui.label(
946 egui::RichText::new(msg)
947 .small()
948 .color(theme::content_secondary()),
949 );
950 }
951
952 // ── Imported layers ──
953 let layers = std::mem::take(&mut state.classifier.layers);
954 if layers.is_empty() {
955 state.classifier.layers = layers; // restore (empty), keep field consistent
956 return;
957 }
958 ui.add_space(theme::space::MD);
959 widgets::subsection_label(ui, "Imported layers");
960 ui.label(
961 egui::RichText::new(
962 "Imported rules arrive disabled \u{2014} review them in Tag Rules above before \
963 enabling. Weight sets how much a layer counts next to your own tagging.",
964 )
965 .small()
966 .color(theme::content_muted()),
967 );
968
969 let pending = state.classifier.pending_layer_remove.clone();
970 let mut toggle: Option<(String, bool)> = None;
971 let mut weight_change: Option<(String, f64)> = None;
972 let mut remove: Option<String> = None;
973 let mut set_pending: Option<Option<String>> = None;
974 for layer in &layers {
975 ui.add_space(theme::space::SM);
976 let confirming = pending.as_deref() == Some(layer.id.as_str());
977 // Row A: enable + name (truncated, dimmed when disabled) + counts.
978 ui.horizontal(|ui| {
979 let mut enabled = layer.enabled;
980 if ui
981 .checkbox(&mut enabled, "")
982 .on_hover_text("Use this layer when auto-tagging")
983 .changed()
984 {
985 toggle = Some((layer.id.clone(), enabled));
986 }
987 let name_color = if layer.enabled {
988 theme::content()
989 } else {
990 theme::content_muted()
991 };
992 ui.add(
993 egui::Label::new(
994 egui::RichText::new(&layer.name)
995 .small()
996 .strong()
997 .color(name_color),
998 )
999 .truncate(),
1000 )
1001 .on_hover_text(&layer.name);
1002 ui.label(
1003 egui::RichText::new(format!(
1004 "{} ex \u{00b7} {} rule{}",
1005 layer.exemplar_count,
1006 layer.rule_count,
1007 if layer.rule_count == 1 { "" } else { "s" },
1008 ))
1009 .small()
1010 .color(theme::content_muted()),
1011 );
1012 });
1013 // Row B: weight + remove (with inline two-step confirm, destructive, no undo).
1014 ui.horizontal(|ui| {
1015 ui.label(
1016 egui::RichText::new("weight")
1017 .small()
1018 .color(theme::content_muted()),
1019 );
1020 let mut w = layer.weight as f32;
1021 let r = ui
1022 .add(egui::Slider::new(&mut w, 0.0..=1.0).show_value(true))
1023 .on_hover_text(
1024 "How much this layer counts when matching. Your own tags are always \
1025 1.0; lower means weaker.",
1026 );
1027 if r.drag_stopped() || (r.changed() && !r.dragged()) {
1028 weight_change = Some((layer.id.clone(), w as f64));
1029 }
1030 if confirming {
1031 if widgets::danger_small_button(ui, "Remove")
1032 .on_hover_text("Permanently delete this layer and its imported rules")
1033 .clicked()
1034 {
1035 remove = Some(layer.id.clone());
1036 set_pending = Some(None);
1037 }
1038 if ui.small_button("Cancel").clicked() {
1039 set_pending = Some(None);
1040 }
1041 } else if widgets::danger_small_button(ui, "Remove").clicked() {
1042 set_pending = Some(Some(layer.id.clone()));
1043 }
1044 });
1045 if confirming {
1046 ui.label(
1047 egui::RichText::new(
1048 "Removing deletes this layer's exemplars and imported rules. You'll \
1049 need the .afcl file to add it again.",
1050 )
1051 .small()
1052 .color(theme::danger()),
1053 );
1054 }
1055 }
1056 state.classifier.layers = layers;
1057 if let Some(p) = set_pending {
1058 state.classifier.pending_layer_remove = p;
1059 }
1060 if let Some((id, enabled)) = toggle {
1061 state.classifier_set_layer_enabled(&id, enabled);
1062 }
1063 if let Some((id, w)) = weight_change {
1064 state.classifier_set_layer_weight(&id, w);
1065 state.refresh_layers();
1066 }
1067 if let Some(id) = remove {
1068 state.classifier_remove_layer(&id);
1069 }
1070 });
1071 }
1072
1073 /// Whether the current export selection covers anything actually present. Each toggle
1074 /// only counts when its data exists, so "export everything" with nothing tagged still
1075 /// reads as empty and the button stays disabled.
1076 fn export_has_content(exemplars: (bool, bool), rules: (bool, bool), policy: (bool, bool)) -> bool {
1077 (exemplars.0 && exemplars.1) || (rules.0 && rules.1) || (policy.0 && policy.1)
1078 }
1079
1080 /// Reduce a user-entered name to a safe-ish default filename stem.
1081 fn sanitize_filename(name: &str) -> String {
1082 let cleaned: String = name
1083 .trim()
1084 .chars()
1085 .map(|c| {
1086 if c.is_alphanumeric() || c == '-' || c == '_' {
1087 c
1088 } else {
1089 '_'
1090 }
1091 })
1092 .collect();
1093 if cleaned.is_empty() {
1094 "classifier".to_string()
1095 } else {
1096 cleaned
1097 }
1098 }
1099
1100 #[cfg(test)]
1101 mod tests {
1102 use super::*;
1103
1104 // field kind / operator sets
1105
1106 #[test]
1107 fn field_kind_classifies_text_number_bool() {
1108 assert!(matches!(field_kind(RuleField::Name), Kind::Text));
1109 assert!(matches!(field_kind(RuleField::MusicalKey), Kind::Text));
1110 assert!(matches!(field_kind(RuleField::IsLoop), Kind::Bool));
1111 assert!(matches!(field_kind(RuleField::Bpm), Kind::Number));
1112 assert!(matches!(field_kind(RuleField::Lufs), Kind::Number));
1113 }
1114
1115 #[test]
1116 fn ops_for_matches_the_field_kind() {
1117 assert_eq!(ops_for(RuleField::Name), STRING_OPS);
1118 assert_eq!(ops_for(RuleField::Bpm), NUM_OPS);
1119 assert_eq!(ops_for(RuleField::IsLoop), BOOL_OPS);
1120 }
1121
1122 #[test]
1123 fn every_offered_field_has_a_nonempty_op_set() {
1124 // draw_editor relies on ops[0] as a fallback, so no field may offer zero ops.
1125 for f in FIELDS {
1126 assert!(!ops_for(*f).is_empty(), "{f:?} offered no operators");
1127 }
1128 }
1129
1130 // coerce_op
1131
1132 #[test]
1133 fn coerce_op_keeps_a_valid_operator() {
1134 // Equals is valid for a text field, so it survives unchanged.
1135 assert_eq!(coerce_op(RuleField::Name, RuleOp::Equals), RuleOp::Equals);
1136 }
1137
1138 #[test]
1139 fn coerce_op_replaces_an_invalid_operator_with_the_first_offered() {
1140 // Lt is number-only; on a text field it falls back to STRING_OPS[0].
1141 assert_eq!(coerce_op(RuleField::Name, RuleOp::Lt), STRING_OPS[0]);
1142 // Contains is text-only; on a number field it falls back to NUM_OPS[0].
1143 assert_eq!(coerce_op(RuleField::Bpm, RuleOp::Contains), NUM_OPS[0]);
1144 // ...and on a bool field to BOOL_OPS[0].
1145 assert_eq!(coerce_op(RuleField::IsLoop, RuleOp::Contains), BOOL_OPS[0]);
1146 }
1147
1148 #[test]
1149 fn coerce_op_output_is_always_valid_for_the_field() {
1150 // For every field, coercing any operator yields one the field actually offers.
1151 for f in FIELDS {
1152 for op in ops_for(RuleField::Bpm)
1153 .iter()
1154 .chain(STRING_OPS)
1155 .chain(BOOL_OPS)
1156 {
1157 let coerced = coerce_op(*f, *op);
1158 assert!(
1159 ops_for(*f).contains(&coerced),
1160 "coerce_op({f:?}, {op:?}) = {coerced:?} not in the field's op set",
1161 );
1162 }
1163 }
1164 }
1165
1166 // op_needs_value
1167
1168 #[test]
1169 fn presence_and_bool_operators_need_no_value() {
1170 for op in [
1171 RuleOp::Exists,
1172 RuleOp::NotExists,
1173 RuleOp::IsTrue,
1174 RuleOp::IsFalse,
1175 ] {
1176 assert!(!op_needs_value(op), "{op:?} should not require a value");
1177 }
1178 }
1179
1180 #[test]
1181 fn comparison_operators_need_a_value() {
1182 for op in [
1183 RuleOp::Contains,
1184 RuleOp::Equals,
1185 RuleOp::Lt,
1186 RuleOp::StartsWith,
1187 ] {
1188 assert!(op_needs_value(op), "{op:?} should require a value");
1189 }
1190 }
1191
1192 // export_has_content
1193
1194 #[test]
1195 fn export_is_empty_when_nothing_is_selected() {
1196 assert!(!export_has_content(
1197 (false, true),
1198 (false, true),
1199 (false, true)
1200 ));
1201 }
1202
1203 #[test]
1204 fn export_is_empty_when_selected_data_is_absent() {
1205 // Everything checked, but none of the underlying data exists.
1206 assert!(!export_has_content(
1207 (true, false),
1208 (true, false),
1209 (true, false)
1210 ));
1211 }
1212
1213 #[test]
1214 fn export_has_content_when_a_selected_category_has_data() {
1215 assert!(export_has_content(
1216 (true, true),
1217 (false, false),
1218 (false, false)
1219 ));
1220 assert!(export_has_content(
1221 (false, false),
1222 (true, true),
1223 (false, false)
1224 ));
1225 assert!(export_has_content(
1226 (false, false),
1227 (false, false),
1228 (true, true)
1229 ));
1230 }
1231
1232 // sanitize_filename
1233
1234 #[test]
1235 fn sanitize_replaces_unsafe_chars_with_underscore() {
1236 assert_eq!(sanitize_filename("My Classifier!"), "My_Classifier_");
1237 }
1238
1239 #[test]
1240 fn sanitize_preserves_alphanumeric_dash_underscore() {
1241 assert_eq!(sanitize_filename("kick-drums_v2"), "kick-drums_v2");
1242 }
1243
1244 #[test]
1245 fn sanitize_trims_surrounding_whitespace() {
1246 assert_eq!(sanitize_filename(" spacey "), "spacey");
1247 }
1248
1249 #[test]
1250 fn sanitize_falls_back_when_empty_or_all_unsafe() {
1251 assert_eq!(sanitize_filename(""), "classifier");
1252 assert_eq!(sanitize_filename(" "), "classifier");
1253 }
1254 }
1255