max / audiofiles
11 files changed,
+945 insertions,
-18 deletions
| @@ -681,6 +681,45 @@ impl Backend for DirectBackend { | |||
| 681 | 681 | Ok((n, n >= trained_head::RECOMMENDED_MIN_EXEMPLARS)) | |
| 682 | 682 | } | |
| 683 | 683 | ||
| 684 | + | fn export_afcl( | |
| 685 | + | &self, | |
| 686 | + | path: &Path, | |
| 687 | + | opts: &audiofiles_core::analysis::afcl::ExportOptions, | |
| 688 | + | ) -> BackendResult<()> { | |
| 689 | + | let db = self.db.lock(); | |
| 690 | + | Ok(audiofiles_core::analysis::afcl::export_to_path(&db, path, opts)?) | |
| 691 | + | } | |
| 692 | + | ||
| 693 | + | fn import_afcl( | |
| 694 | + | &self, | |
| 695 | + | path: &Path, | |
| 696 | + | ) -> BackendResult<audiofiles_core::analysis::afcl::ImportSummary> { | |
| 697 | + | let db = self.db.lock(); | |
| 698 | + | Ok(audiofiles_core::analysis::afcl::import_from_path(&db, path)?) | |
| 699 | + | } | |
| 700 | + | ||
| 701 | + | fn list_classifier_layers( | |
| 702 | + | &self, | |
| 703 | + | ) -> BackendResult<Vec<audiofiles_core::analysis::afcl::ClassifierLayer>> { | |
| 704 | + | let db = self.db.lock(); | |
| 705 | + | Ok(audiofiles_core::analysis::afcl::list_layers(&db)?) | |
| 706 | + | } | |
| 707 | + | ||
| 708 | + | fn remove_classifier_layer(&self, id: &str) -> BackendResult<()> { | |
| 709 | + | let db = self.db.lock(); | |
| 710 | + | Ok(audiofiles_core::analysis::afcl::remove_layer(&db, id)?) | |
| 711 | + | } | |
| 712 | + | ||
| 713 | + | fn set_classifier_layer_enabled(&self, id: &str, enabled: bool) -> BackendResult<()> { | |
| 714 | + | let db = self.db.lock(); | |
| 715 | + | Ok(audiofiles_core::analysis::afcl::set_layer_enabled(&db, id, enabled)?) | |
| 716 | + | } | |
| 717 | + | ||
| 718 | + | fn set_classifier_layer_weight(&self, id: &str, weight: f64) -> BackendResult<()> { | |
| 719 | + | let db = self.db.lock(); | |
| 720 | + | Ok(audiofiles_core::analysis::afcl::set_layer_weight(&db, id, weight)?) | |
| 721 | + | } | |
| 722 | + | ||
| 684 | 723 | fn accept_ml_tag(&self, hash: &str, tag: &str) -> BackendResult<()> { | |
| 685 | 724 | let db = self.db.lock(); | |
| 686 | 725 | audiofiles_core::rules::apply_tag_sourced(&db, hash, tag, "ml")?; |
| @@ -444,6 +444,35 @@ pub trait Backend: Send + Sync { | |||
| 444 | 444 | /// advisory hint in the UI. | |
| 445 | 445 | fn classifier_head_readiness(&self) -> BackendResult<(usize, bool)>; | |
| 446 | 446 | ||
| 447 | + | // --- .afcl file sharing (classifier layers) --- | |
| 448 | + | ||
| 449 | + | /// Write the user's selected classifier data to a `.afcl` file. | |
| 450 | + | fn export_afcl( | |
| 451 | + | &self, | |
| 452 | + | path: &Path, | |
| 453 | + | opts: &audiofiles_core::analysis::afcl::ExportOptions, | |
| 454 | + | ) -> BackendResult<()>; | |
| 455 | + | ||
| 456 | + | /// Import a `.afcl` file as a new removable layer. | |
| 457 | + | fn import_afcl( | |
| 458 | + | &self, | |
| 459 | + | path: &Path, | |
| 460 | + | ) -> BackendResult<audiofiles_core::analysis::afcl::ImportSummary>; | |
| 461 | + | ||
| 462 | + | /// All imported/official classifier layers with live counts. | |
| 463 | + | fn list_classifier_layers( | |
| 464 | + | &self, | |
| 465 | + | ) -> BackendResult<Vec<audiofiles_core::analysis::afcl::ClassifierLayer>>; | |
| 466 | + | ||
| 467 | + | /// Remove a layer and everything it brought in. | |
| 468 | + | fn remove_classifier_layer(&self, id: &str) -> BackendResult<()>; | |
| 469 | + | ||
| 470 | + | /// Enable/disable a layer (disabled = excluded from the k-NN index). | |
| 471 | + | fn set_classifier_layer_enabled(&self, id: &str, enabled: bool) -> BackendResult<()>; | |
| 472 | + | ||
| 473 | + | /// Set a layer's k-NN weight (clamped to `[0, 1]`). | |
| 474 | + | fn set_classifier_layer_weight(&self, id: &str, weight: f64) -> BackendResult<()>; | |
| 475 | + | ||
| 447 | 476 | /// Cluster the library's feature vectors into `k` groups (for naming/seeding). | |
| 448 | 477 | fn cluster_library( | |
| 449 | 478 | &self, |
| @@ -195,6 +195,93 @@ impl BrowserState { | |||
| 195 | 195 | } | |
| 196 | 196 | } | |
| 197 | 197 | ||
| 198 | + | // ── .afcl file sharing (classifier layers) ── | |
| 199 | + | ||
| 200 | + | /// Load the layer list (and seed export-form defaults) when the section first opens. | |
| 201 | + | pub fn ensure_layers_loaded(&mut self) { | |
| 202 | + | if !self.classifier.layers_loaded { | |
| 203 | + | // One-time export-form defaults (derive(Default) leaves bools false). | |
| 204 | + | if self.classifier.export_name.is_empty() { | |
| 205 | + | self.classifier.export_name = "My classifier".to_string(); | |
| 206 | + | self.classifier.export_include_exemplars = true; | |
| 207 | + | self.classifier.export_include_rules = true; | |
| 208 | + | self.classifier.export_include_policy = true; | |
| 209 | + | } | |
| 210 | + | self.refresh_layers(); | |
| 211 | + | } | |
| 212 | + | } | |
| 213 | + | ||
| 214 | + | pub fn refresh_layers(&mut self) { | |
| 215 | + | self.classifier.layers = self.backend.list_classifier_layers().unwrap_or_default(); | |
| 216 | + | self.classifier.layers_loaded = true; | |
| 217 | + | } | |
| 218 | + | ||
| 219 | + | /// Export the user's classifier data to `path` using the export-form selections. | |
| 220 | + | pub fn classifier_export_afcl(&mut self, path: &std::path::Path) { | |
| 221 | + | use audiofiles_core::analysis::afcl::ExportOptions; | |
| 222 | + | let opts = ExportOptions { | |
| 223 | + | name: { | |
| 224 | + | let n = self.classifier.export_name.trim(); | |
| 225 | + | if n.is_empty() { "My classifier".to_string() } else { n.to_string() } | |
| 226 | + | }, | |
| 227 | + | description: String::new(), | |
| 228 | + | license_note: String::new(), | |
| 229 | + | include_exemplars: self.classifier.export_include_exemplars, | |
| 230 | + | include_rules: self.classifier.export_include_rules, | |
| 231 | + | include_policy: self.classifier.export_include_policy, | |
| 232 | + | }; | |
| 233 | + | match self.backend.export_afcl(path, &opts) { | |
| 234 | + | Ok(()) => self.status = format!("Exported classifier to {}", path.display()), | |
| 235 | + | Err(e) => self.status = format!("Export failed: {e}"), | |
| 236 | + | } | |
| 237 | + | } | |
| 238 | + | ||
| 239 | + | /// Import a `.afcl` file as a new removable layer, refreshing dependent views. | |
| 240 | + | pub fn classifier_import_afcl(&mut self, path: &std::path::Path) { | |
| 241 | + | match self.backend.import_afcl(path) { | |
| 242 | + | Ok(s) => { | |
| 243 | + | self.status = format!( | |
| 244 | + | "Imported \"{}\" — {} exemplar{}, {} rule{} (disabled), {} threshold{}", | |
| 245 | + | s.name, | |
| 246 | + | s.exemplars, if s.exemplars == 1 { "" } else { "s" }, | |
| 247 | + | s.rules, if s.rules == 1 { "" } else { "s" }, | |
| 248 | + | s.policies, if s.policies == 1 { "" } else { "s" }, | |
| 249 | + | ); | |
| 250 | + | // Import adds disabled rules + policies; refresh those views too. | |
| 251 | + | self.refresh_layers(); | |
| 252 | + | self.refresh_rules(); | |
| 253 | + | self.refresh_policies(); | |
| 254 | + | } | |
| 255 | + | Err(e) => self.status = format!("Import failed: {e}"), | |
| 256 | + | } | |
| 257 | + | } | |
| 258 | + | ||
| 259 | + | /// Remove a layer and everything it brought in. | |
| 260 | + | pub fn classifier_remove_layer(&mut self, id: &str) { | |
| 261 | + | match self.backend.remove_classifier_layer(id) { | |
| 262 | + | Ok(()) => { | |
| 263 | + | self.status = "Removed classifier layer.".to_string(); | |
| 264 | + | self.refresh_layers(); | |
| 265 | + | self.refresh_rules(); | |
| 266 | + | self.refresh_selected_tags(); | |
| 267 | + | } | |
| 268 | + | Err(e) => self.status = format!("Could not remove layer: {e}"), | |
| 269 | + | } | |
| 270 | + | } | |
| 271 | + | ||
| 272 | + | pub fn classifier_set_layer_enabled(&mut self, id: &str, enabled: bool) { | |
| 273 | + | if let Err(e) = self.backend.set_classifier_layer_enabled(id, enabled) { | |
| 274 | + | self.status = format!("Could not update layer: {e}"); | |
| 275 | + | } | |
| 276 | + | self.refresh_layers(); | |
| 277 | + | } | |
| 278 | + | ||
| 279 | + | pub fn classifier_set_layer_weight(&mut self, id: &str, weight: f64) { | |
| 280 | + | if let Err(e) = self.backend.set_classifier_layer_weight(id, weight) { | |
| 281 | + | self.status = format!("Could not set layer weight: {e}"); | |
| 282 | + | } | |
| 283 | + | } | |
| 284 | + | ||
| 198 | 285 | // ── Optional trained head (Layer B speed-up) ── | |
| 199 | 286 | ||
| 200 | 287 | /// Load head summary + readiness once when the section opens. |
| @@ -1939,6 +1939,35 @@ mod misc { | |||
| 1939 | 1939 | } | |
| 1940 | 1940 | ||
| 1941 | 1941 | #[test] | |
| 1942 | + | fn classifier_afcl_export_import_round_trip() { | |
| 1943 | + | // Source library: tag two samples, then export to a .afcl file. | |
| 1944 | + | let (mut src, src_dir) = make_state(); | |
| 1945 | + | insert_featured_sample(&src, "k0", 0.0, Some("instrument.drum.kick")); | |
| 1946 | + | insert_featured_sample(&src, "k1", 0.1, Some("instrument.drum.kick")); | |
| 1947 | + | let path = src_dir.path().join("share.afcl"); | |
| 1948 | + | src.classifier.export_include_exemplars = true; | |
| 1949 | + | src.classifier.export_include_rules = true; | |
| 1950 | + | src.classifier.export_include_policy = true; | |
| 1951 | + | src.classifier_export_afcl(&path); | |
| 1952 | + | assert!(path.exists(), "export wrote the file"); | |
| 1953 | + | ||
| 1954 | + | // Destination library: import it as a layer. | |
| 1955 | + | let (mut dst, _dst_dir) = make_state(); | |
| 1956 | + | dst.classifier_import_afcl(&path); | |
| 1957 | + | dst.refresh_layers(); | |
| 1958 | + | assert_eq!(dst.classifier.layers.len(), 1); | |
| 1959 | + | let layer = dst.classifier.layers[0].clone(); | |
| 1960 | + | assert_eq!(layer.exemplar_count, 2); | |
| 1961 | + | ||
| 1962 | + | // Toggle + weight + remove all route through the backend. | |
| 1963 | + | dst.classifier_set_layer_enabled(&layer.id, false); | |
| 1964 | + | assert!(!dst.classifier.layers[0].enabled); | |
| 1965 | + | dst.classifier_set_layer_weight(&layer.id, 0.3); | |
| 1966 | + | dst.classifier_remove_layer(&layer.id); | |
| 1967 | + | assert!(dst.classifier.layers.is_empty()); | |
| 1968 | + | } | |
| 1969 | + | ||
| 1970 | + | #[test] | |
| 1942 | 1971 | fn classifier_train_head_needs_examples() { | |
| 1943 | 1972 | let (mut state, _dir) = make_state(); | |
| 1944 | 1973 | // Only two of a tag — under MIN_POSITIVE_EXAMPLES. |
| @@ -321,6 +321,16 @@ pub struct ClassifierUiState { | |||
| 321 | 321 | /// Editable tag per folder label, parallel to `folder_labels`. | |
| 322 | 322 | pub folder_tags: Vec<String>, | |
| 323 | 323 | pub folder_loaded: bool, | |
| 324 | + | ||
| 325 | + | // --- .afcl file sharing (Phase 7) --- | |
| 326 | + | /// Imported/official classifier layers, loaded on demand. | |
| 327 | + | pub layers: Vec<audiofiles_core::analysis::afcl::ClassifierLayer>, | |
| 328 | + | pub layers_loaded: bool, | |
| 329 | + | /// Export form: name + which components to include. | |
| 330 | + | pub export_name: String, | |
| 331 | + | pub export_include_exemplars: bool, | |
| 332 | + | pub export_include_rules: bool, | |
| 333 | + | pub export_include_policy: bool, | |
| 324 | 334 | } | |
| 325 | 335 | ||
| 326 | 336 | /// A rule being authored in the builder. Mirrors `audiofiles_core::rules::Rule` |
| @@ -144,6 +144,8 @@ pub fn draw_classifier_section(ui: &mut egui::Ui, state: &mut BrowserState) { | |||
| 144 | 144 | draw_cluster_header(ui, state); | |
| 145 | 145 | ui.add_space(theme::space::SM); | |
| 146 | 146 | draw_folder_header(ui, state); | |
| 147 | + | ui.add_space(theme::space::SM); | |
| 148 | + | draw_sharing_header(ui, state); | |
| 147 | 149 | } | |
| 148 | 150 | ||
| 149 | 151 | /// "Tag Rules" section (Layer A). | |
| @@ -728,3 +730,141 @@ fn draw_folder_header(ui: &mut egui::Ui, state: &mut BrowserState) { | |||
| 728 | 730 | } | |
| 729 | 731 | }); | |
| 730 | 732 | } | |
| 733 | + | ||
| 734 | + | /// "Shared Classifiers (.afcl)" section: export your own data to a portable file, import | |
| 735 | + | /// someone else's as a removable layer, and manage imported layers. | |
| 736 | + | fn draw_sharing_header(ui: &mut egui::Ui, state: &mut BrowserState) { | |
| 737 | + | egui::CollapsingHeader::new(egui::RichText::new("Shared Classifiers (.afcl)").strong()) | |
| 738 | + | .default_open(false) | |
| 739 | + | .show(ui, |ui| { | |
| 740 | + | state.ensure_layers_loaded(); | |
| 741 | + | ui.label( | |
| 742 | + | egui::RichText::new( | |
| 743 | + | "Share your tagging as a portable .afcl file \u{2014} it carries your feature \ | |
| 744 | + | vectors, tags, rules, and thresholds, but never any audio. Imported files \ | |
| 745 | + | become removable layers that sit below your own tagging.", | |
| 746 | + | ) | |
| 747 | + | .small() | |
| 748 | + | .color(theme::content_muted()), | |
| 749 | + | ); | |
| 750 | + | ||
| 751 | + | // ── Export ── | |
| 752 | + | ui.add_space(theme::space::MD); | |
| 753 | + | widgets::subsection_label(ui, "Export"); | |
| 754 | + | ui.horizontal(|ui| { | |
| 755 | + | ui.label(egui::RichText::new("Name").small().color(theme::content_muted())); | |
| 756 | + | ui.add( | |
| 757 | + | egui::TextEdit::singleline(&mut state.classifier.export_name) | |
| 758 | + | .desired_width(180.0) | |
| 759 | + | .hint_text("My classifier"), | |
| 760 | + | ); | |
| 761 | + | }); | |
| 762 | + | ui.horizontal(|ui| { | |
| 763 | + | ui.checkbox(&mut state.classifier.export_include_exemplars, "Exemplars"); | |
| 764 | + | ui.checkbox(&mut state.classifier.export_include_rules, "Rules"); | |
| 765 | + | ui.checkbox(&mut state.classifier.export_include_policy, "Thresholds"); | |
| 766 | + | }); | |
| 767 | + | let nothing_selected = !state.classifier.export_include_exemplars | |
| 768 | + | && !state.classifier.export_include_rules | |
| 769 | + | && !state.classifier.export_include_policy; | |
| 770 | + | ui.add_enabled_ui(!nothing_selected, |ui| { | |
| 771 | + | if ui | |
| 772 | + | .button("Export to file...") | |
| 773 | + | .on_hover_text("Save a .afcl file you can share \u{2014} no audio is included") | |
| 774 | + | .clicked() | |
| 775 | + | && let Some(path) = rfd::FileDialog::new() | |
| 776 | + | .set_file_name(format!("{}.afcl", sanitize_filename(&state.classifier.export_name))) | |
| 777 | + | .add_filter("AF classifier", &["afcl"]) | |
| 778 | + | .save_file() | |
| 779 | + | { | |
| 780 | + | state.classifier_export_afcl(&path); | |
| 781 | + | } | |
| 782 | + | }); | |
| 783 | + | ||
| 784 | + | // ── Import ── | |
| 785 | + | ui.add_space(theme::space::MD); | |
| 786 | + | widgets::subsection_label(ui, "Import"); | |
| 787 | + | if ui | |
| 788 | + | .button("Import .afcl file...") | |
| 789 | + | .on_hover_text("Add someone's shared classifier as a removable layer") | |
| 790 | + | .clicked() | |
| 791 | + | && let Some(path) = rfd::FileDialog::new() | |
| 792 | + | .add_filter("AF classifier", &["afcl"]) | |
| 793 | + | .pick_file() | |
| 794 | + | { | |
| 795 | + | state.classifier_import_afcl(&path); | |
| 796 | + | } | |
| 797 | + | ||
| 798 | + | // ── Imported layers ── | |
| 799 | + | let layers = state.classifier.layers.clone(); | |
| 800 | + | if layers.is_empty() { | |
| 801 | + | return; | |
| 802 | + | } | |
| 803 | + | ui.add_space(theme::space::MD); | |
| 804 | + | widgets::subsection_label(ui, "Imported layers"); | |
| 805 | + | ui.label( | |
| 806 | + | egui::RichText::new( | |
| 807 | + | "Imported rules arrive disabled \u{2014} review them in Tag Rules above before \ | |
| 808 | + | enabling. Weight sets how much a layer counts next to your own tagging.", | |
| 809 | + | ) | |
| 810 | + | .small() | |
| 811 | + | .color(theme::content_muted()), | |
| 812 | + | ); | |
| 813 | + | ||
| 814 | + | let mut toggle: Option<(String, bool)> = None; | |
| 815 | + | let mut weight_change: Option<(String, f64)> = None; | |
| 816 | + | let mut remove: Option<String> = None; | |
| 817 | + | for layer in &layers { | |
| 818 | + | ui.add_space(theme::space::SM); | |
| 819 | + | ui.horizontal(|ui| { | |
| 820 | + | let mut enabled = layer.enabled; | |
| 821 | + | if ui.checkbox(&mut enabled, "").changed() { | |
| 822 | + | toggle = Some((layer.id.clone(), enabled)); | |
| 823 | + | } | |
| 824 | + | ui.label(egui::RichText::new(&layer.name).small().strong()); | |
| 825 | + | ui.label( | |
| 826 | + | egui::RichText::new(format!( | |
| 827 | + | "{} exemplar{} \u{00b7} {} rule{}", | |
| 828 | + | layer.exemplar_count, | |
| 829 | + | if layer.exemplar_count == 1 { "" } else { "s" }, | |
| 830 | + | layer.rule_count, | |
| 831 | + | if layer.rule_count == 1 { "" } else { "s" }, | |
| 832 | + | )) | |
| 833 | + | .small() | |
| 834 | + | .color(theme::content_muted()), | |
| 835 | + | ); | |
| 836 | + | if ui.small_button("Remove").clicked() { | |
| 837 | + | remove = Some(layer.id.clone()); | |
| 838 | + | } | |
| 839 | + | }); | |
| 840 | + | ui.horizontal(|ui| { | |
| 841 | + | ui.label(egui::RichText::new("weight").small().color(theme::content_muted())); | |
| 842 | + | let mut w = layer.weight as f32; | |
| 843 | + | let r = ui.add(egui::Slider::new(&mut w, 0.0..=1.0).show_value(true)); | |
| 844 | + | if r.drag_stopped() || (r.changed() && !r.dragged()) { | |
| 845 | + | weight_change = Some((layer.id.clone(), w as f64)); | |
| 846 | + | } | |
| 847 | + | }); | |
| 848 | + | } | |
| 849 | + | if let Some((id, enabled)) = toggle { | |
| 850 | + | state.classifier_set_layer_enabled(&id, enabled); | |
| 851 | + | } | |
| 852 | + | if let Some((id, w)) = weight_change { | |
| 853 | + | state.classifier_set_layer_weight(&id, w); | |
| 854 | + | state.refresh_layers(); | |
| 855 | + | } | |
| 856 | + | if let Some(id) = remove { | |
| 857 | + | state.classifier_remove_layer(&id); | |
| 858 | + | } | |
| 859 | + | }); | |
| 860 | + | } | |
| 861 | + | ||
| 862 | + | /// Reduce a user-entered name to a safe-ish default filename stem. | |
| 863 | + | fn sanitize_filename(name: &str) -> String { | |
| 864 | + | let cleaned: String = name | |
| 865 | + | .trim() | |
| 866 | + | .chars() | |
| 867 | + | .map(|c| if c.is_alphanumeric() || c == '-' || c == '_' { c } else { '_' }) | |
| 868 | + | .collect(); | |
| 869 | + | if cleaned.is_empty() { "classifier".to_string() } else { cleaned } | |
| 870 | + | } |
| @@ -0,0 +1,617 @@ | |||
| 1 | + | //! `.afcl` classifier-layer file sharing (Phase 7 of the hybrid classifier). | |
| 2 | + | //! | |
| 3 | + | //! A `.afcl` ("AF classifier") is a portable, self-describing classifier-layer artifact. | |
| 4 | + | //! It carries **no audio** — only the non-reversible 35-d feature vectors plus labels and | |
| 5 | + | //! deterministic rules — so sharing one exposes nothing of the source library's audio. The | |
| 6 | + | //! same mechanism serves user-to-user sharing and the eventual bundled official classifier | |
| 7 | + | //! (which ships as a `kind = "official"` `.afcl`). | |
| 8 | + | //! | |
| 9 | + | //! The container is a single self-describing **JSON document** (dep-free; the design's | |
| 10 | + | //! optional zip/gzip is skipped — these files are small and a plain JSON `.afcl` stays | |
| 11 | + | //! diffable). An import creates a removable [`ClassifierLayer`]: | |
| 12 | + | //! | |
| 13 | + | //! - **Exemplars** land in `classifier_exemplars` (vector + tags only — no sample row), | |
| 14 | + | //! join the k-NN index weighted *below* the user's own `local` data, and only fill gaps | |
| 15 | + | //! or break ties (see [`super::exemplar::build_index`]). | |
| 16 | + | //! - **Rules** are added as ordinary `tag_rules` rows, **disabled**, grouped under the | |
| 17 | + | //! layer via `classifier_layer_rules` so the user reviews before enabling. | |
| 18 | + | //! - **Policy** thresholds are applied only for tags the user hasn't already configured | |
| 19 | + | //! (never clobbers the user's own thresholds). | |
| 20 | + | //! | |
| 21 | + | //! `feat_version` is gated at import: a `.afcl` built under a different feature layout is | |
| 22 | + | //! rejected. The whole layer is removable in one action. | |
| 23 | + | ||
| 24 | + | use std::collections::HashSet; | |
| 25 | + | use std::path::Path; | |
| 26 | + | use std::sync::atomic::{AtomicU64, Ordering}; | |
| 27 | + | ||
| 28 | + | use serde::{Deserialize, Serialize}; | |
| 29 | + | ||
| 30 | + | use crate::db::Database; | |
| 31 | + | use crate::error::{io_err, unix_now, CoreError, Result}; | |
| 32 | + | use crate::rules::{self, NewRule, Rule}; | |
| 33 | + | use tracing::instrument; | |
| 34 | + | ||
| 35 | + | use super::classify::{FEATURE_VERSION, NUM_FEATURES}; | |
| 36 | + | ||
| 37 | + | /// On-disk `.afcl` format version (independent of `FEATURE_VERSION`). | |
| 38 | + | pub const AFCL_VERSION: u32 = 1; | |
| 39 | + | ||
| 40 | + | /// Default k-NN weight for an imported layer — below the user's own `local` (1.0), so | |
| 41 | + | /// imported exemplars fill gaps and break ties without overriding the user's labels. | |
| 42 | + | pub const DEFAULT_IMPORT_WEIGHT: f64 = 0.5; | |
| 43 | + | ||
| 44 | + | // ── File format ── | |
| 45 | + | ||
| 46 | + | /// `.afcl` manifest. `feat_version` MUST match the importing app's [`FEATURE_VERSION`]. | |
| 47 | + | #[derive(Debug, Clone, Serialize, Deserialize)] | |
| 48 | + | pub struct AfclManifest { | |
| 49 | + | pub afcl_version: u32, | |
| 50 | + | pub feat_version: u32, | |
| 51 | + | pub name: String, | |
| 52 | + | pub description: String, | |
| 53 | + | /// `"imported"` (user share) or `"official"` (bundled default classifier). | |
| 54 | + | pub kind: String, | |
| 55 | + | pub created_at: i64, | |
| 56 | + | /// Free-text note about the rights to the shared labels (UI surfaces it on export). | |
| 57 | + | pub license_note: String, | |
| 58 | + | pub exemplar_count: usize, | |
| 59 | + | pub rule_count: usize, | |
| 60 | + | pub policy_count: usize, | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | /// One shared exemplar: a feature vector and its labels (sample hash stripped). | |
| 64 | + | #[derive(Debug, Clone, Serialize, Deserialize)] | |
| 65 | + | pub struct AfclExemplar { | |
| 66 | + | pub vector: Vec<f64>, | |
| 67 | + | pub tags: Vec<String>, | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | /// One shared per-tag policy. | |
| 71 | + | #[derive(Debug, Clone, Serialize, Deserialize)] | |
| 72 | + | pub struct AfclPolicy { | |
| 73 | + | pub tag: String, | |
| 74 | + | pub review_threshold: f64, | |
| 75 | + | pub auto_threshold: f64, | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | /// A complete `.afcl` document. | |
| 79 | + | #[derive(Debug, Clone, Serialize, Deserialize)] | |
| 80 | + | pub struct Afcl { | |
| 81 | + | pub manifest: AfclManifest, | |
| 82 | + | #[serde(default)] | |
| 83 | + | pub exemplars: Vec<AfclExemplar>, | |
| 84 | + | #[serde(default)] | |
| 85 | + | pub rules: Vec<Rule>, | |
| 86 | + | #[serde(default)] | |
| 87 | + | pub policy: Vec<AfclPolicy>, | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | /// What to include when exporting. | |
| 91 | + | #[derive(Debug, Clone)] | |
| 92 | + | pub struct ExportOptions { | |
| 93 | + | pub name: String, | |
| 94 | + | pub description: String, | |
| 95 | + | pub license_note: String, | |
| 96 | + | pub include_exemplars: bool, | |
| 97 | + | pub include_rules: bool, | |
| 98 | + | pub include_policy: bool, | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | impl Default for ExportOptions { | |
| 102 | + | fn default() -> Self { | |
| 103 | + | Self { | |
| 104 | + | name: "My classifier".to_string(), | |
| 105 | + | description: String::new(), | |
| 106 | + | license_note: String::new(), | |
| 107 | + | include_exemplars: true, | |
| 108 | + | include_rules: true, | |
| 109 | + | include_policy: true, | |
| 110 | + | } | |
| 111 | + | } | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | /// A persisted classifier layer (a group of imported exemplars/rules). | |
| 115 | + | #[derive(Debug, Clone)] | |
| 116 | + | pub struct ClassifierLayer { | |
| 117 | + | pub id: String, | |
| 118 | + | pub name: String, | |
| 119 | + | pub kind: String, | |
| 120 | + | pub weight: f64, | |
| 121 | + | pub enabled: bool, | |
| 122 | + | pub source: Option<String>, | |
| 123 | + | pub imported_at: i64, | |
| 124 | + | /// Live counts (joined at list time). | |
| 125 | + | pub exemplar_count: usize, | |
| 126 | + | pub rule_count: usize, | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | /// Outcome of an import. | |
| 130 | + | #[derive(Debug, Clone)] | |
| 131 | + | pub struct ImportSummary { | |
| 132 | + | pub layer_id: String, | |
| 133 | + | pub name: String, | |
| 134 | + | pub exemplars: usize, | |
| 135 | + | pub rules: usize, | |
| 136 | + | pub policies: usize, | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | // ── Layer ids ── | |
| 140 | + | ||
| 141 | + | static LAYER_COUNTER: AtomicU64 = AtomicU64::new(0); | |
| 142 | + | ||
| 143 | + | fn new_layer_id() -> String { | |
| 144 | + | let n = LAYER_COUNTER.fetch_add(1, Ordering::Relaxed); | |
| 145 | + | format!("layer-{:x}{:x}", unix_now(), n) | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | // ── Export ── | |
| 149 | + | ||
| 150 | + | /// Build an [`Afcl`] from the user's own (`local`) classifier data per `opts`. | |
| 151 | + | #[instrument(skip_all)] | |
| 152 | + | pub fn build_export(db: &Database, opts: &ExportOptions) -> Result<Afcl> { | |
| 153 | + | let exemplars = if opts.include_exemplars { local_exemplars(db)? } else { Vec::new() }; | |
| 154 | + | let rules = if opts.include_rules { local_rules(db)? } else { Vec::new() }; | |
| 155 | + | let policy = if opts.include_policy { local_policy(db)? } else { Vec::new() }; | |
| 156 | + | ||
| 157 | + | let manifest = AfclManifest { | |
| 158 | + | afcl_version: AFCL_VERSION, | |
| 159 | + | feat_version: FEATURE_VERSION, | |
| 160 | + | name: opts.name.clone(), | |
| 161 | + | description: opts.description.clone(), | |
| 162 | + | kind: "imported".to_string(), | |
| 163 | + | created_at: unix_now(), | |
| 164 | + | license_note: opts.license_note.clone(), | |
| 165 | + | exemplar_count: exemplars.len(), | |
| 166 | + | rule_count: rules.len(), | |
| 167 | + | policy_count: policy.len(), | |
| 168 | + | }; | |
| 169 | + | Ok(Afcl { manifest, exemplars, rules, policy }) | |
| 170 | + | } | |
| 171 | + | ||
| 172 | + | /// Serialize the user's classifier data to a `.afcl` JSON string. | |
| 173 | + | pub fn export_to_string(db: &Database, opts: &ExportOptions) -> Result<String> { | |
| 174 | + | let afcl = build_export(db, opts)?; | |
| 175 | + | serde_json::to_string_pretty(&afcl).map_err(|e| CoreError::Serialization(e.to_string())) | |
| 176 | + | } | |
| 177 | + | ||
| 178 | + | /// Write the user's classifier data to a `.afcl` file. | |
| 179 | + | pub fn export_to_path(db: &Database, path: &Path, opts: &ExportOptions) -> Result<()> { | |
| 180 | + | let json = export_to_string(db, opts)?; | |
| 181 | + | std::fs::write(path, json).map_err(|e| io_err(path, e))?; | |
| 182 | + | Ok(()) | |
| 183 | + | } | |
| 184 | + | ||
| 185 | + | /// The user's own (`local`) exemplars: current-version feature vectors carrying >=1 non-ML | |
| 186 | + | /// tag. Sample hashes are intentionally dropped — only vector + tags travel. | |
| 187 | + | fn local_exemplars(db: &Database) -> Result<Vec<AfclExemplar>> { | |
| 188 | + | let conn = db.conn(); | |
| 189 | + | // Non-ML tags per local sample. | |
| 190 | + | let mut tags_by_hash: std::collections::HashMap<String, Vec<String>> = | |
| 191 | + | std::collections::HashMap::new(); | |
| 192 | + | { | |
| 193 | + | let mut stmt = conn.prepare( | |
| 194 | + | "SELECT t.sample_hash, t.tag FROM tags t | |
| 195 | + | WHERE NOT EXISTS ( | |
| 196 | + | SELECT 1 FROM tag_provenance p | |
| 197 | + | WHERE p.sample_hash = t.sample_hash AND p.tag = t.tag AND p.source = 'ml')", | |
| 198 | + | )?; | |
| 199 | + | let rows = stmt | |
| 200 | + | .query_map([], |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)))?; | |
| 201 | + | for r in rows { | |
| 202 | + | let (hash, tag) = r?; | |
| 203 | + | tags_by_hash.entry(hash).or_default().push(tag); | |
| 204 | + | } | |
| 205 | + | } | |
| 206 | + | let mut out = Vec::new(); | |
| 207 | + | let mut stmt = | |
| 208 | + | conn.prepare("SELECT hash, vector FROM sample_features WHERE feat_version = ?1")?; | |
| 209 | + | let rows = stmt.query_map([FEATURE_VERSION], |row| { | |
| 210 | + | Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) | |
| 211 | + | })?; | |
| 212 | + | for r in rows { | |
| 213 | + | let (hash, json) = r?; | |
| 214 | + | let Some(tags) = tags_by_hash.remove(&hash) else { continue }; | |
| 215 | + | let vector: Vec<f64> = | |
| 216 | + | serde_json::from_str(&json).map_err(|e| CoreError::Serialization(e.to_string()))?; | |
| 217 | + | if vector.len() == NUM_FEATURES { | |
| 218 | + | out.push(AfclExemplar { vector, tags }); | |
| 219 | + | } | |
| 220 | + | } | |
| 221 | + | Ok(out) | |
| 222 | + | } | |
| 223 | + | ||
| 224 | + | /// The user's own rules (those not belonging to any imported layer). | |
| 225 | + | fn local_rules(db: &Database) -> Result<Vec<Rule>> { | |
| 226 | + | let imported: HashSet<String> = imported_rule_ids(db)?; | |
| 227 | + | Ok(rules::list_rules(db)?.into_iter().filter(|r| !imported.contains(&r.id)).collect()) | |
| 228 | + | } | |
| 229 | + | ||
| 230 | + | fn local_policy(db: &Database) -> Result<Vec<AfclPolicy>> { | |
| 231 | + | Ok(super::exemplar::list_policies(db)? | |
| 232 | + | .into_iter() | |
| 233 | + | .map(|(tag, p)| AfclPolicy { | |
| 234 | + | tag, | |
| 235 | + | review_threshold: p.review_threshold, | |
| 236 | + | auto_threshold: p.auto_threshold, | |
| 237 | + | }) | |
| 238 | + | .collect()) | |
| 239 | + | } | |
| 240 | + | ||
| 241 | + | fn imported_rule_ids(db: &Database) -> Result<HashSet<String>> { | |
| 242 | + | let mut stmt = db.conn().prepare("SELECT rule_id FROM classifier_layer_rules")?; | |
| 243 | + | let rows = stmt.query_map([], |row| row.get::<_, String>(0))?; | |
| 244 | + | Ok(rows.collect::<std::result::Result<HashSet<_>, _>>()?) | |
| 245 | + | } | |
| 246 | + | ||
| 247 | + | // ── Import ── | |
| 248 | + | ||
| 249 | + | /// Parse and import a `.afcl` JSON string as a new removable layer. | |
| 250 | + | #[instrument(skip_all)] | |
| 251 | + | pub fn import_from_string(db: &Database, json: &str, source: Option<&str>) -> Result<ImportSummary> { | |
| 252 | + | let afcl: Afcl = | |
| 253 | + | serde_json::from_str(json).map_err(|e| CoreError::Serialization(e.to_string()))?; | |
| 254 | + | import(db, &afcl, source) | |
| 255 | + | } | |
| 256 | + | ||
| 257 | + | /// Read and import a `.afcl` file as a new removable layer. | |
| 258 | + | pub fn import_from_path(db: &Database, path: &Path) -> Result<ImportSummary> { | |
| 259 | + | let json = std::fs::read_to_string(path).map_err(|e| io_err(path, e))?; | |
| 260 | + | let source = path.file_name().and_then(|s| s.to_str()); | |
| 261 | + | import_from_string(db, &json, source) | |
| 262 | + | } | |
| 263 | + | ||
| 264 | + | /// Import a parsed `.afcl`, creating a new layer. Gated on `afcl_version`/`feat_version`. | |
| 265 | + | pub fn import(db: &Database, afcl: &Afcl, source: Option<&str>) -> Result<ImportSummary> { | |
| 266 | + | if afcl.manifest.afcl_version > AFCL_VERSION { | |
| 267 | + | return Err(CoreError::Incompatible(format!( | |
| 268 | + | "this .afcl needs a newer version of audiofiles (format v{}, supported v{})", | |
| 269 | + | afcl.manifest.afcl_version, AFCL_VERSION | |
| 270 | + | ))); | |
| 271 | + | } | |
| 272 | + | if afcl.manifest.feat_version != FEATURE_VERSION { | |
| 273 | + | return Err(CoreError::Incompatible(format!( | |
| 274 | + | "this .afcl was built for a different analysis version (features v{}, this app v{})", | |
| 275 | + | afcl.manifest.feat_version, FEATURE_VERSION | |
| 276 | + | ))); | |
| 277 | + | } | |
| 278 | + | ||
| 279 | + | let layer_id = new_layer_id(); | |
| 280 | + | let kind = if afcl.manifest.kind == "official" { "official" } else { "imported" }; | |
| 281 | + | db.conn().execute( | |
| 282 | + | "INSERT INTO classifier_layers (id, name, kind, weight, enabled, source, imported_at) | |
| 283 | + | VALUES (?1, ?2, ?3, ?4, 1, ?5, ?6)", | |
| 284 | + | rusqlite::params![ | |
| 285 | + | layer_id, | |
| 286 | + | afcl.manifest.name, | |
| 287 | + | kind, | |
| 288 | + | DEFAULT_IMPORT_WEIGHT, | |
| 289 | + | source, | |
| 290 | + | unix_now(), | |
| 291 | + | ], | |
| 292 | + | )?; | |
| 293 | + | ||
| 294 | + | // Exemplars (vector + tags only). | |
| 295 | + | let mut exemplars = 0; | |
| 296 | + | for ex in &afcl.exemplars { | |
| 297 | + | if ex.vector.len() != NUM_FEATURES { | |
| 298 | + | continue; | |
| 299 | + | } | |
| 300 | + | let vector = serde_json::to_string(&ex.vector) | |
| 301 | + | .map_err(|e| CoreError::Serialization(e.to_string()))?; | |
| 302 | + | let tags = serde_json::to_string(&ex.tags) | |
| 303 | + | .map_err(|e| CoreError::Serialization(e.to_string()))?; | |
| 304 | + | db.conn().execute( | |
| 305 | + | "INSERT INTO classifier_exemplars (layer_id, feat_version, vector, tags) | |
| 306 | + | VALUES (?1, ?2, ?3, ?4)", | |
| 307 | + | rusqlite::params![layer_id, FEATURE_VERSION, vector, tags], | |
| 308 | + | )?; | |
| 309 | + | exemplars += 1; | |
| 310 | + | } | |
| 311 | + | ||
| 312 | + | // Rules: created disabled, grouped under the layer for the user to review. | |
| 313 | + | let mut rule_count = 0; | |
| 314 | + | for r in &afcl.rules { | |
| 315 | + | let created = rules::create_rule( | |
| 316 | + | db, | |
| 317 | + | NewRule { | |
| 318 | + | name: r.name.clone(), | |
| 319 | + | enabled: false, | |
| 320 | + | priority: None, | |
| 321 | + | match_mode: r.match_mode, | |
| 322 | + | conditions: r.conditions.clone(), | |
| 323 | + | actions: r.actions.clone(), | |
| 324 | + | }, | |
| 325 | + | )?; | |
| 326 | + | db.conn().execute( | |
| 327 | + | "INSERT INTO classifier_layer_rules (layer_id, rule_id) VALUES (?1, ?2)", | |
| 328 | + | rusqlite::params![layer_id, created.id], | |
| 329 | + | )?; | |
| 330 | + | rule_count += 1; | |
| 331 | + | } | |
| 332 | + | ||
| 333 | + | // Policy: only for tags the user hasn't configured (never clobber the user's own). | |
| 334 | + | let mut policies = 0; | |
| 335 | + | let configured: HashSet<String> = | |
| 336 | + | super::exemplar::list_policies(db)?.into_iter().map(|(t, _)| t).collect(); | |
| 337 | + | for p in &afcl.policy { | |
| 338 | + | if configured.contains(&p.tag) { | |
| 339 | + | continue; | |
| 340 | + | } | |
| 341 | + | super::exemplar::set_policy(db, &p.tag, p.review_threshold, p.auto_threshold)?; | |
| 342 | + | policies += 1; | |
| 343 | + | } | |
| 344 | + | ||
| 345 | + | Ok(ImportSummary { | |
| 346 | + | layer_id, | |
| 347 | + | name: afcl.manifest.name.clone(), | |
| 348 | + | exemplars, | |
| 349 | + | rules: rule_count, | |
| 350 | + | policies, | |
| 351 | + | }) | |
| 352 | + | } | |
| 353 | + | ||
| 354 | + | // ── Layer management ── | |
| 355 | + | ||
| 356 | + | /// All imported/official layers with live exemplar + rule counts, newest first. | |
| 357 | + | #[instrument(skip_all)] | |
| 358 | + | pub fn list_layers(db: &Database) -> Result<Vec<ClassifierLayer>> { | |
| 359 | + | let conn = db.conn(); | |
| 360 | + | let mut stmt = conn.prepare( | |
| 361 | + | "SELECT l.id, l.name, l.kind, l.weight, l.enabled, l.source, l.imported_at, | |
| 362 | + | (SELECT COUNT(*) FROM classifier_exemplars e WHERE e.layer_id = l.id), | |
| 363 | + | (SELECT COUNT(*) FROM classifier_layer_rules r WHERE r.layer_id = l.id) | |
| 364 | + | FROM classifier_layers l ORDER BY l.imported_at DESC, l.id DESC", | |
| 365 | + | )?; | |
| 366 | + | let rows = stmt.query_map([], |row| { | |
| 367 | + | Ok(ClassifierLayer { | |
| 368 | + | id: row.get(0)?, | |
| 369 | + | name: row.get(1)?, | |
| 370 | + | kind: row.get(2)?, | |
| 371 | + | weight: row.get(3)?, | |
| 372 | + | enabled: row.get::<_, i64>(4)? != 0, | |
| 373 | + | source: row.get(5)?, | |
| 374 | + | imported_at: row.get(6)?, | |
| 375 | + | exemplar_count: row.get::<_, i64>(7)? as usize, | |
| 376 | + | rule_count: row.get::<_, i64>(8)? as usize, | |
| 377 | + | }) | |
| 378 | + | })?; | |
| 379 | + | Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?) | |
| 380 | + | } | |
| 381 | + | ||
| 382 | + | /// Remove a layer and everything it brought in: its imported rules (from `tag_rules`, | |
| 383 | + | /// reconciling their tags away), then the layer row (cascading exemplars + membership). | |
| 384 | + | #[instrument(skip_all)] | |
| 385 | + | pub fn remove_layer(db: &Database, layer_id: &str) -> Result<()> { | |
| 386 | + | let rule_ids: Vec<String> = { | |
| 387 | + | let mut stmt = | |
| 388 | + | db.conn().prepare("SELECT rule_id FROM classifier_layer_rules WHERE layer_id = ?1")?; | |
| 389 | + | let rows = stmt.query_map([layer_id], |row| row.get::<_, String>(0))?; | |
| 390 | + | rows.collect::<std::result::Result<Vec<_>, _>>()? | |
| 391 | + | }; | |
| 392 | + | // delete_rule reconciles each rule's tags away (manual/local tags stay sticky). | |
| 393 | + | for id in &rule_ids { | |
| 394 | + | rules::delete_rule(db, id)?; | |
| 395 | + | } | |
| 396 | + | // Cascades classifier_exemplars + classifier_layer_rules. | |
| 397 | + | db.conn().execute("DELETE FROM classifier_layers WHERE id = ?1", [layer_id])?; | |
| 398 | + | Ok(()) | |
| 399 | + | } | |
| 400 | + | ||
| 401 | + | /// Enable or disable a layer (disabled layers contribute nothing to the k-NN index). | |
| 402 | + | pub fn set_layer_enabled(db: &Database, layer_id: &str, enabled: bool) -> Result<()> { | |
| 403 | + | db.conn().execute( | |
| 404 | + | "UPDATE classifier_layers SET enabled = ?2 WHERE id = ?1", | |
| 405 | + | rusqlite::params![layer_id, enabled as i64], | |
| 406 | + | )?; | |
| 407 | + | Ok(()) | |
| 408 | + | } | |
| 409 | + | ||
| 410 | + | /// Set a layer's k-NN weight (clamped to `[0, 1]` — at or below the user's own data). | |
| 411 | + | pub fn set_layer_weight(db: &Database, layer_id: &str, weight: f64) -> Result<()> { | |
| 412 | + | let weight = weight.clamp(0.0, 1.0); | |
| 413 | + | db.conn().execute( | |
| 414 | + | "UPDATE classifier_layers SET weight = ?2 WHERE id = ?1", | |
| 415 | + | rusqlite::params![layer_id, weight], | |
| 416 | + | )?; | |
| 417 | + | Ok(()) | |
| 418 | + | } | |
| 419 | + | ||
| 420 | + | /// An imported exemplar joining the k-NN index: a synthetic id (`layer:<id>#<rowid>`, no | |
| 421 | + | /// audio), its raw feature vector, tags, and the owning layer's weight. | |
| 422 | + | pub struct ImportedExemplar { | |
| 423 | + | pub id: String, | |
| 424 | + | pub vector: Vec<f64>, | |
| 425 | + | pub tags: Vec<String>, | |
| 426 | + | pub weight: f64, | |
| 427 | + | } | |
| 428 | + | ||
| 429 | + | /// Every current-version exemplar from **enabled** layers — unioned into the k-NN index by | |
| 430 | + | /// [`super::exemplar::build_index`], weighted below the user's own `local` data. | |
| 431 | + | pub fn enabled_imported_exemplars(db: &Database) -> Result<Vec<ImportedExemplar>> { | |
| 432 | + | let conn = db.conn(); | |
| 433 | + | let mut stmt = conn.prepare( | |
| 434 | + | "SELECT e.id, e.vector, e.tags, l.weight | |
| 435 | + | FROM classifier_exemplars e | |
| 436 | + | JOIN classifier_layers l ON l.id = e.layer_id | |
| 437 | + | WHERE l.enabled = 1 AND e.feat_version = ?1", | |
| 438 | + | )?; | |
| 439 | + | let rows = stmt.query_map([FEATURE_VERSION], |row| { | |
| 440 | + | Ok(( | |
| 441 | + | row.get::<_, i64>(0)?, | |
| 442 | + | row.get::<_, String>(1)?, | |
| 443 | + | row.get::<_, String>(2)?, | |
| 444 | + | row.get::<_, f64>(3)?, | |
| 445 | + | )) | |
| 446 | + | })?; | |
| 447 | + | let mut out = Vec::new(); | |
| 448 | + | for r in rows { | |
| 449 | + | let (rowid, vec_json, tags_json, weight) = r?; | |
| 450 | + | let vector: Vec<f64> = serde_json::from_str(&vec_json) | |
| 451 | + | .map_err(|e| CoreError::Serialization(e.to_string()))?; | |
| 452 | + | let tags: Vec<String> = serde_json::from_str(&tags_json) | |
| 453 | + | .map_err(|e| CoreError::Serialization(e.to_string()))?; | |
| 454 | + | if vector.len() == NUM_FEATURES { | |
| 455 | + | out.push(ImportedExemplar { id: format!("layer#{rowid}"), vector, tags, weight }); | |
| 456 | + | } | |
| 457 | + | } | |
| 458 | + | Ok(out) | |
| 459 | + | } | |
| 460 | + | ||
| 461 | + | #[cfg(test)] | |
| 462 | + | mod tests { | |
| 463 | + | use super::*; | |
| 464 | + | use crate::analysis::classify::NUM_FEATURES; | |
| 465 | + | ||
| 466 | + | fn insert(db: &Database, hash: &str, fill: f64, tags: &[&str]) { | |
| 467 | + | db.conn() | |
| 468 | + | .execute( | |
| 469 | + | "INSERT INTO samples (hash, original_name, file_extension, file_size, import_date, last_modified) \ | |
| 470 | + | VALUES (?1, ?1, 'wav', 1, 0, 0)", | |
| 471 | + | [hash], | |
| 472 | + | ) | |
| 473 | + | .unwrap(); | |
| 474 | + | let v = vec![fill; NUM_FEATURES]; | |
| 475 | + | let json = serde_json::to_string(&v).unwrap(); | |
| 476 | + | db.conn() | |
| 477 | + | .execute( | |
| 478 | + | "INSERT INTO sample_features (hash, feat_version, vector, computed_at) VALUES (?1, ?2, ?3, 0)", | |
| 479 | + | rusqlite::params![hash, FEATURE_VERSION, json], | |
| 480 | + | ) | |
| 481 | + | .unwrap(); | |
| 482 | + | for t in tags { | |
| 483 | + | crate::tags::add_tag(db, hash, t).unwrap(); | |
| 484 | + | } | |
| 485 | + | } | |
| 486 | + | ||
| 487 | + | fn seed_library(db: &Database) { | |
| 488 | + | insert(db, "k1", 0.0, &["instrument.drum.kick"]); | |
| 489 | + | insert(db, "k2", 0.1, &["instrument.drum.kick"]); | |
| 490 | + | insert(db, "s1", 9.0, &["instrument.drum.snare"]); | |
| 491 | + | super::super::exemplar::set_policy(db, "instrument.drum.kick", 0.4, 0.8).unwrap(); | |
| 492 | + | rules::create_rule( | |
| 493 | + | db, | |
| 494 | + | NewRule { | |
| 495 | + | name: "kicks folder".to_string(), | |
| 496 | + | enabled: true, | |
| 497 | + | priority: None, | |
| 498 | + | match_mode: crate::rules::MatchMode::All, | |
| 499 | + | conditions: vec![], | |
| 500 | + | actions: vec![crate::rules::RuleAction::AddTag("instrument.drum.kick".to_string())], |
Lines truncated
| @@ -94,6 +94,9 @@ struct Exemplar { | |||
| 94 | 94 | /// Standardized feature vector. | |
| 95 | 95 | vector: Vec<f64>, | |
| 96 | 96 | tags: Vec<String>, | |
| 97 | + | /// Layer weight multiplier on this exemplar's k-NN contribution. `1.0` for the user's | |
| 98 | + | /// own (`local`) data; imported `.afcl` layers sit below it (see [`super::afcl`]). | |
| 99 | + | weight: f64, | |
| 97 | 100 | } | |
| 98 | 101 | ||
| 99 | 102 | /// In-memory k-NN index over the labeled library. Rebuild after tag changes. | |
| @@ -165,8 +168,8 @@ pub fn build_index(db: &Database) -> Result<ExemplarIndex> { | |||
| 165 | 168 | } | |
| 166 | 169 | } | |
| 167 | 170 | ||
| 168 | - | // Raw vectors for those samples. | |
| 169 | - | let mut raw: Vec<(String, Vec<f64>)> = Vec::new(); | |
| 171 | + | // Raw vectors for those samples (the user's own `local` exemplars; weight 1.0). | |
| 172 | + | let mut raw: Vec<(String, Vec<f64>, Vec<String>, f64)> = Vec::new(); | |
| 170 | 173 | { | |
| 171 | 174 | let mut stmt = | |
| 172 | 175 | conn.prepare("SELECT hash, vector FROM sample_features WHERE feat_version = ?1")?; | |
| @@ -175,25 +178,29 @@ pub fn build_index(db: &Database) -> Result<ExemplarIndex> { | |||
| 175 | 178 | })?; | |
| 176 | 179 | for r in rows { | |
| 177 | 180 | let (hash, json) = r?; | |
| 178 | - | if !tags_by_hash.contains_key(&hash) { | |
| 179 | - | continue; | |
| 180 | - | } | |
| 181 | + | let Some(tags) = tags_by_hash.remove(&hash) else { continue }; | |
| 181 | 182 | let v: Vec<f64> = serde_json::from_str(&json) | |
| 182 | 183 | .map_err(|e| CoreError::Serialization(e.to_string()))?; | |
| 183 | 184 | if v.len() == NUM_FEATURES { | |
| 184 | - | raw.push((hash, v)); | |
| 185 | + | raw.push((hash, v, tags, 1.0)); | |
| 185 | 186 | } | |
| 186 | 187 | } | |
| 187 | 188 | } | |
| 188 | 189 | ||
| 189 | - | // Standardization params over the exemplar set. | |
| 190 | - | let (means, stds) = standardization_params(&raw); | |
| 190 | + | // Imported `.afcl` layer exemplars (enabled layers), weighted below `local`. | |
| 191 | + | for imp in super::afcl::enabled_imported_exemplars(db)? { | |
| 192 | + | raw.push((imp.id, imp.vector, imp.tags, imp.weight)); | |
| 193 | + | } | |
| 194 | + | ||
| 195 | + | // Standardization params over the full exemplar set (local + imported). | |
| 196 | + | let raw_vecs: Vec<(String, Vec<f64>)> = | |
| 197 | + | raw.iter().map(|(h, v, _, _)| (h.clone(), v.clone())).collect(); | |
| 198 | + | let (means, stds) = standardization_params(&raw_vecs); | |
| 191 | 199 | let exemplars = raw | |
| 192 | 200 | .into_iter() | |
| 193 | - | .map(|(hash, v)| { | |
| 201 | + | .map(|(hash, v, tags, weight)| { | |
| 194 | 202 | let vector = standardize_vec(&v, &means, &stds); | |
| 195 | - | let tags = tags_by_hash.remove(&hash).unwrap_or_default(); | |
| 196 | - | Exemplar { hash, vector, tags } | |
| 203 | + | Exemplar { hash, vector, tags, weight } | |
| 197 | 204 | }) | |
| 198 | 205 | .collect(); | |
| 199 | 206 | ||
| @@ -286,7 +293,8 @@ impl ExemplarIndex { | |||
| 286 | 293 | let weighted: Vec<(usize, f64)> = dists | |
| 287 | 294 | .iter() | |
| 288 | 295 | .map(|&(i, d2)| { | |
| 289 | - | let w = (-d2 / denom).exp(); | |
| 296 | + | // Gaussian kernel scaled by the exemplar's layer weight (imported < local). | |
| 297 | + | let w = (-d2 / denom).exp() * self.exemplars[i].weight; | |
| 290 | 298 | total_weight += w; | |
| 291 | 299 | (i, w) | |
| 292 | 300 | }) | |
| @@ -532,6 +540,46 @@ mod tests { | |||
| 532 | 540 | } | |
| 533 | 541 | ||
| 534 | 542 | #[test] | |
| 543 | + | fn imported_layer_exemplars_join_the_index() { | |
| 544 | + | let db = Database::open_in_memory().unwrap(); | |
| 545 | + | // The user has no labels at all; an imported layer carries the only kicks. | |
| 546 | + | let afcl = super::super::afcl::Afcl { | |
| 547 | + | manifest: super::super::afcl::AfclManifest { | |
| 548 | + | afcl_version: super::super::afcl::AFCL_VERSION, | |
| 549 | + | feat_version: FEATURE_VERSION, | |
| 550 | + | name: "kicks".into(), | |
| 551 | + | description: String::new(), | |
| 552 | + | kind: "imported".into(), | |
| 553 | + | created_at: 0, | |
| 554 | + | license_note: String::new(), | |
| 555 | + | exemplar_count: 2, | |
| 556 | + | rule_count: 0, | |
| 557 | + | policy_count: 0, | |
| 558 | + | }, | |
| 559 | + | exemplars: vec![ | |
| 560 | + | super::super::afcl::AfclExemplar { | |
| 561 | + | vector: vec![0.0; NUM_FEATURES], | |
| 562 | + | tags: vec!["instrument.drum.kick".into()], | |
| 563 | + | }, | |
| 564 | + | super::super::afcl::AfclExemplar { | |
| 565 | + | vector: vec![0.05; NUM_FEATURES], | |
| 566 | + | tags: vec!["instrument.drum.kick".into()], | |
| 567 | + | }, | |
| 568 | + | ], | |
| 569 | + | rules: vec![], | |
| 570 | + | policy: vec![], | |
| 571 | + | }; | |
| 572 | + | super::super::afcl::import(&db, &afcl, None).unwrap(); | |
| 573 | + | ||
| 574 | + | // A query near the imported kicks gets the kick tag from the imported layer alone. | |
| 575 | + | insert(&db, "q", vec_at(0.02), &[]); | |
| 576 | + | let index = build_index(&db).unwrap(); | |
| 577 | + | assert_eq!(index.len(), 2, "imported exemplars are in the index"); | |
| 578 | + | let scores = index.score(&vec_at(0.02), DEFAULT_K, Some("q")); | |
| 579 | + | assert!(scores.iter().any(|s| s.tag == "instrument.drum.kick" && s.score > 0.5)); | |
| 580 | + | } | |
| 581 | + | ||
| 582 | + | #[test] | |
| 535 | 583 | fn review_band_between_thresholds() { | |
| 536 | 584 | let db = Database::open_in_memory().unwrap(); | |
| 537 | 585 | // Two equidistant neighbors with different tags -> each ~0.5 score. |
| @@ -4,6 +4,7 @@ | |||
| 4 | 4 | //! stages (loudness, spectral features, BPM/key detection, loop detection, | |
| 5 | 5 | //! classification) and persists results to the `audio_analysis` table. | |
| 6 | 6 | ||
| 7 | + | pub mod afcl; | |
| 7 | 8 | pub mod basic; | |
| 8 | 9 | pub mod bpm; | |
| 9 | 10 | pub mod classify; |
| @@ -1310,6 +1310,41 @@ CREATE TABLE IF NOT EXISTS trained_head ( | |||
| 1310 | 1310 | ); | |
| 1311 | 1311 | "#; | |
| 1312 | 1312 | ||
| 1313 | + | const MIGRATION_025: &str = r#" | |
| 1314 | + | -- Phase 7: classifier layers (.afcl file sharing). Imported exemplars/rules are grouped | |
| 1315 | + | -- into removable, weightable layers; the user's own data is the implicit 'local' layer | |
| 1316 | + | -- (not a row here). Local-only for now (no sync triggers): the .afcl file is the portable | |
| 1317 | + | -- artifact and re-imports per device — cross-device sync of imported layers is a follow-up. | |
| 1318 | + | CREATE TABLE IF NOT EXISTS classifier_layers ( | |
| 1319 | + | id TEXT PRIMARY KEY, | |
| 1320 | + | name TEXT NOT NULL, | |
| 1321 | + | kind TEXT NOT NULL, -- 'imported' | 'official' | |
| 1322 | + | weight REAL NOT NULL DEFAULT 1.0, | |
| 1323 | + | enabled INTEGER NOT NULL DEFAULT 1, | |
| 1324 | + | source TEXT, -- .afcl filename / provenance | |
| 1325 | + | imported_at INTEGER NOT NULL | |
| 1326 | + | ); | |
| 1327 | + | ||
| 1328 | + | -- Imported exemplars: feature vector + tags only (no audio, no sample row), so they live | |
| 1329 | + | -- here rather than in sample_features (which FKs to samples). | |
| 1330 | + | CREATE TABLE IF NOT EXISTS classifier_exemplars ( | |
| 1331 | + | id INTEGER PRIMARY KEY, | |
| 1332 | + | layer_id TEXT NOT NULL REFERENCES classifier_layers(id) ON DELETE CASCADE, | |
| 1333 | + | feat_version INTEGER NOT NULL, | |
| 1334 | + | vector TEXT NOT NULL, -- JSON array of 35 f64 | |
| 1335 | + | tags TEXT NOT NULL -- JSON array of String | |
| 1336 | + | ); | |
| 1337 | + | CREATE INDEX IF NOT EXISTS idx_classifier_exemplars_layer ON classifier_exemplars(layer_id); | |
| 1338 | + | ||
| 1339 | + | -- Membership of imported rules in a layer. The rules themselves are ordinary tag_rules | |
| 1340 | + | -- rows (added disabled); this join lets a layer be removed in one action. | |
| 1341 | + | CREATE TABLE IF NOT EXISTS classifier_layer_rules ( | |
| 1342 | + | layer_id TEXT NOT NULL REFERENCES classifier_layers(id) ON DELETE CASCADE, | |
| 1343 | + | rule_id TEXT NOT NULL REFERENCES tag_rules(id) ON DELETE CASCADE, | |
| 1344 | + | PRIMARY KEY (layer_id, rule_id) | |
| 1345 | + | ); | |
| 1346 | + | "#; | |
| 1347 | + | ||
| 1313 | 1348 | /// Register `hash_row_id(salt, key) -> TEXT` as a deterministic SQLite | |
| 1314 | 1349 | /// function on the given connection. Used by the M018 sync triggers so the | |
| 1315 | 1350 | /// `sync_changelog.row_id` field never carries cleartext content (tag strings, | |
| @@ -1414,6 +1449,7 @@ impl Database { | |||
| 1414 | 1449 | MIGRATION_022, | |
| 1415 | 1450 | MIGRATION_023, | |
| 1416 | 1451 | MIGRATION_024, | |
| 1452 | + | MIGRATION_025, | |
| 1417 | 1453 | ]; | |
| 1418 | 1454 | ||
| 1419 | 1455 | for (i, sql) in MIGRATIONS.iter().enumerate() { | |
| @@ -1567,6 +1603,9 @@ mod tests { | |||
| 1567 | 1603 | ||
| 1568 | 1604 | let expected = vec![ | |
| 1569 | 1605 | "audio_analysis", | |
| 1606 | + | "classifier_exemplars", | |
| 1607 | + | "classifier_layer_rules", | |
| 1608 | + | "classifier_layers", | |
| 1570 | 1609 | "collection_members", | |
| 1571 | 1610 | "collections", | |
| 1572 | 1611 | "edit_history", | |
| @@ -1595,7 +1634,7 @@ mod tests { | |||
| 1595 | 1634 | .conn() | |
| 1596 | 1635 | .query_row("PRAGMA user_version", [], |row| row.get(0)) | |
| 1597 | 1636 | .unwrap(); | |
| 1598 | - | assert_eq!(version, 24); | |
| 1637 | + | assert_eq!(version, 25); | |
| 1599 | 1638 | } | |
| 1600 | 1639 | ||
| 1601 | 1640 | #[test] | |
| @@ -1606,7 +1645,7 @@ mod tests { | |||
| 1606 | 1645 | .conn() | |
| 1607 | 1646 | .query_row("PRAGMA user_version", [], |row| row.get(0)) | |
| 1608 | 1647 | .unwrap(); | |
| 1609 | - | assert_eq!(version, 24); | |
| 1648 | + | assert_eq!(version, 25); | |
| 1610 | 1649 | } | |
| 1611 | 1650 | ||
| 1612 | 1651 | /// Open a fresh file-backed DB, close, reopen. The second open re-enters | |
| @@ -1625,7 +1664,7 @@ mod tests { | |||
| 1625 | 1664 | .conn() | |
| 1626 | 1665 | .query_row("PRAGMA user_version", [], |row| row.get(0)) | |
| 1627 | 1666 | .unwrap(); | |
| 1628 | - | assert_eq!(version, 24); | |
| 1667 | + | assert_eq!(version, 25); | |
| 1629 | 1668 | } | |
| 1630 | 1669 | ||
| 1631 | 1670 | /// Simulates the worst-case recovery path: a prior partial migration left | |
| @@ -1669,7 +1708,7 @@ mod tests { | |||
| 1669 | 1708 | .conn() | |
| 1670 | 1709 | .query_row("PRAGMA user_version", [], |row| row.get(0)) | |
| 1671 | 1710 | .unwrap(); | |
| 1672 | - | assert_eq!(version, 24); | |
| 1711 | + | assert_eq!(version, 25); | |
| 1673 | 1712 | } | |
| 1674 | 1713 | ||
| 1675 | 1714 | /// M018 contract: the `sync_changelog.row_id` for sensitive tables must | |
| @@ -1891,7 +1930,7 @@ mod tests { | |||
| 1891 | 1930 | let initial_version: i32 = conn | |
| 1892 | 1931 | .query_row("PRAGMA user_version", [], |row| row.get(0)) | |
| 1893 | 1932 | .unwrap(); | |
| 1894 | - | assert_eq!(initial_version, 24); | |
| 1933 | + | assert_eq!(initial_version, 25); | |
| 1895 | 1934 | ||
| 1896 | 1935 | let batch = format!( | |
| 1897 | 1936 | "BEGIN;\n{}\nPRAGMA user_version = 999;\nCOMMIT;", | |
| @@ -1954,7 +1993,7 @@ mod tests { | |||
| 1954 | 1993 | .conn() | |
| 1955 | 1994 | .query_row("PRAGMA user_version", [], |row| row.get(0)) | |
| 1956 | 1995 | .unwrap(); | |
| 1957 | - | assert_eq!(version, 24); | |
| 1996 | + | assert_eq!(version, 25); | |
| 1958 | 1997 | } | |
| 1959 | 1998 | ||
| 1960 | 1999 | #[test] |
| @@ -78,6 +78,11 @@ pub enum CoreError { | |||
| 78 | 78 | /// Internal logic error (e.g. invalid arguments to an internal function). | |
| 79 | 79 | #[error("internal error: {0}")] | |
| 80 | 80 | Internal(String), | |
| 81 | + | ||
| 82 | + | /// A user-supplied artifact (e.g. an `.afcl` file) is incompatible with this build. | |
| 83 | + | /// Message is shown to the user verbatim (no prefix). | |
| 84 | + | #[error("{0}")] | |
| 85 | + | Incompatible(String), | |
| 81 | 86 | } | |
| 82 | 87 | ||
| 83 | 88 | /// Typed errors for the audio analysis/decode pipeline. |