Skip to main content

max / audiofiles

performance: debounce search, add library-scaling bench (ultra-fuzz) Global search ran a blocking DB query + in-frame re-sort on the GUI thread on every keystroke. Debounce it: re-arm a 150ms timer on change and repaint at the deadline so the query fires once typing pauses. Explicit Clear still applies immediately. Add bench Section 7 "Library Scaling": times the similarity VP-tree index build and indexed-vs-linear query latency at 1k/10k/50k/100k synthetic samples — the operation that previously froze the GUI and motivated the off-thread search worker, now tracked so build/query regressions surface before users feel them. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-06-19 20:54 UTC
Commit: d061494f99d7902ba9afd94b64644a37ce9a3f65
Parent: a954d9f
3 files changed, +112 insertions, -0 deletions
@@ -679,7 +679,100 @@ fn main() {
679 679 }
680 680 println!();
681 681
682 + // ─────────────────────────────────────────────────────────────
683 + // Section 7: Library Scaling (similarity index build + query)
684 + // ─────────────────────────────────────────────────────────────
685 + println!("━━━ 7. LIBRARY SCALING ━━━");
686 + println!();
687 + println!(" Similarity index (VP-tree) build + query vs library size.");
688 + println!(" This is the operation that previously froze the GUI on large");
689 + println!(" libraries and motivated the off-thread search worker; tracking it");
690 + println!(" here catches build/query regressions before users feel a stall.");
691 + println!();
692 + bench_similarity_scaling();
693 + println!();
694 +
682 695 println!("═══════════════════════════════════════════════════════════════");
683 696 println!(" Benchmark complete. {} files analyzed.", n + total_classified);
684 697 println!("═══════════════════════════════════════════════════════════════");
685 698 }
699 +
700 + // ── Library Scaling ──
701 +
702 + /// Deterministic synthetic feature vector generator. Uses a small LCG so the
703 + /// bench needs no RNG dependency and is reproducible across runs.
704 + fn synth_features(count: usize) -> Vec<(String, audiofiles_core::similarity::FeatureVector)> {
705 + use audiofiles_core::similarity::FeatureVector;
706 + let mut state: u64 = 0x2545F4914F6CDD1D;
707 + let mut next = || {
708 + // xorshift64 → unit f64
709 + state ^= state << 13;
710 + state ^= state >> 7;
711 + state ^= state << 17;
712 + (state >> 11) as f64 / (1u64 << 53) as f64
713 + };
714 + (0..count)
715 + .map(|i| {
716 + let fv = FeatureVector {
717 + bpm: Some(60.0 + next() * 140.0),
718 + duration: Some(next() * 10.0),
719 + lufs: Some(-40.0 + next() * 40.0),
720 + spectral_centroid: Some(next() * 8000.0),
721 + spectral_flatness: Some(next()),
722 + spectral_rolloff: Some(next() * 12000.0),
723 + zero_crossing_rate: Some(next()),
724 + onset_strength: Some(next()),
725 + spectral_bandwidth: Some(next() * 4000.0),
726 + centroid_variance: Some(next()),
727 + crest_factor: Some(next() * 20.0),
728 + attack_time: Some(next() * 0.5),
729 + };
730 + (format!("{i:064x}"), fv)
731 + })
732 + .collect()
733 + }
734 +
735 + fn bench_similarity_scaling() {
736 + use audiofiles_core::similarity::SimilarityIndex;
737 +
738 + println!(
739 + " {:>9} {:>12} {:>14} {:>14}",
740 + "Library", "Build(ms)", "Indexed q(µs)", "Linear q(µs)"
741 + );
742 + println!(" {}", "─".repeat(53));
743 +
744 + for &size in &[1_000usize, 10_000, 50_000, 100_000] {
745 + let data = synth_features(size);
746 +
747 + let t = Instant::now();
748 + let index = SimilarityIndex::build_from_data(data.clone());
749 + let build_ms = t.elapsed().as_secs_f64() * 1000.0;
750 +
751 + // Query the index with the first sample's features, averaged over runs.
752 + let (probe_hash, probe_fv) = &data[0];
753 + let runs = 200;
754 + let t = Instant::now();
755 + for _ in 0..runs {
756 + let _ = index.find_similar(probe_hash, probe_fv, 20);
757 + }
758 + let indexed_us = t.elapsed().as_secs_f64() * 1e6 / runs as f64;
759 +
760 + // Linear scan baseline over the same data for the same query count.
761 + let weights = audiofiles_core::similarity::FeatureWeights::default();
762 + let t = Instant::now();
763 + for _ in 0..runs {
764 + let mut best: Vec<f64> = data
765 + .iter()
766 + .map(|(_, fv)| audiofiles_core::similarity::feature_distance(probe_fv, fv, &weights))
767 + .collect();
768 + best.sort_by(|a, b| a.total_cmp(b));
769 + let _ = &best[..best.len().min(20)];
770 + }
771 + let linear_us = t.elapsed().as_secs_f64() * 1e6 / runs as f64;
772 +
773 + println!(
774 + " {:>9} {:>12.1} {:>14.1} {:>14.1}",
775 + size, build_ms, indexed_us, linear_us
776 + );
777 + }
778 + }
@@ -128,6 +128,10 @@ pub struct BrowserState {
128 128 pub search_query: String,
129 129 pub search_filter: SearchFilter,
130 130 pub filter_panel_open: bool,
131 + /// Set when the search text changes; the search is applied only once this
132 + /// is older than the debounce window, so each keystroke does not run a
133 + /// blocking DB query + re-sort on the GUI thread.
134 + pub search_debounce_at: Option<std::time::Instant>,
131 135
132 136 // Dynamic collection (saved search) name input
133 137 pub collection_filter_name_input: String,
@@ -490,6 +494,7 @@ impl BrowserState {
490 494 search_query: String::new(),
491 495 search_filter: SearchFilter::default(),
492 496 filter_panel_open,
497 + search_debounce_at: None,
493 498 collection_filter_name_input: String::new(),
494 499 filter_tag_input: String::new(),
495 500 similarity_search_hash: None,
@@ -35,7 +35,19 @@ pub fn draw_toolbar(
35 35 state.focus_search = false;
36 36 }
37 37
38 + // Debounce typing: each keystroke would otherwise run a blocking DB
39 + // query + re-sort on the GUI thread. Re-arm a short timer on change and
40 + // request a repaint at its deadline so the search fires once typing
41 + // pauses, even without further input.
42 + const SEARCH_DEBOUNCE: std::time::Duration = std::time::Duration::from_millis(150);
38 43 if resp.changed() {
44 + state.search_debounce_at = Some(std::time::Instant::now());
45 + ui.ctx().request_repaint_after(SEARCH_DEBOUNCE);
46 + }
47 + if let Some(t) = state.search_debounce_at
48 + && t.elapsed() >= SEARCH_DEBOUNCE
49 + {
50 + state.search_debounce_at = None;
39 51 state.apply_search();
40 52 }
41 53
@@ -43,6 +55,8 @@ pub fn draw_toolbar(
43 55 && ui.button("Clear").on_hover_text("Clear search").clicked()
44 56 {
45 57 state.search_query.clear();
58 + // Explicit action: apply immediately and cancel any pending debounce.
59 + state.search_debounce_at = None;
46 60 state.apply_search();
47 61 }
48 62