//! Tag suggestion engine: generates dot-notation tag proposals from analysis results. use super::AnalysisResult; use tracing::instrument; /// Source of a tag suggestion, for transparency. #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub enum SuggestionSource { Classification, BpmRange, KeyDetection, LoopDetection, DurationHeuristic, LoudnessRange, } impl std::fmt::Display for SuggestionSource { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::Classification => write!(f, "Classification"), Self::BpmRange => write!(f, "BPM"), Self::KeyDetection => write!(f, "Key"), Self::LoopDetection => write!(f, "Loop"), Self::DurationHeuristic => write!(f, "Duration"), Self::LoudnessRange => write!(f, "Loudness"), } } } /// A suggested tag with explanation. #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct TagSuggestion { pub tag: String, pub reason: String, pub confidence: f32, pub source: SuggestionSource, } /// Generate tag suggestions from analysis results. Pure function — no DB access. #[instrument(skip_all)] pub fn suggest_tags(result: &AnalysisResult) -> Vec { let mut suggestions = Vec::new(); // 1. Classification tag — use ML confidence when available if let Some(ref class) = result.classification { let ml_conf = result.classification_confidence.unwrap_or(0.0); // Only suggest if ML confidence >= 0.5 (or rule-based fallback with 0.0 confidence) let suggest_conf = if ml_conf > 0.0 { ml_conf as f32 } else { 0.7 }; if ml_conf == 0.0 || ml_conf >= 0.5 { let reason = if ml_conf > 0.0 { format!("ML classifier: {:.0}% confidence", ml_conf * 100.0) } else { format!( "Spectral: centroid {:.0} Hz, flatness {:.2}, crest {:.1}", result.spectral_centroid.unwrap_or(0.0), result.spectral_flatness.unwrap_or(0.0), result.crest_factor.unwrap_or(0.0), ) }; suggestions.push(TagSuggestion { tag: class.tag().to_string(), reason, confidence: suggest_conf, source: SuggestionSource::Classification, }); } } // 2. BPM tag — high confidence (0.8) because BPM detection is fairly reliable // for rhythmic material. Genre hint is low confidence (0.3) because BPM ranges // overlap heavily between genres — it's a rough starting point, not a classification. if let Some(bpm) = result.bpm { let rounded = bpm.round() as u32; suggestions.push(TagSuggestion { tag: format!("bpm.{rounded}"), reason: format!("Detected BPM: {bpm:.1}"), confidence: 0.8, source: SuggestionSource::BpmRange, }); if let Some((genre_tag, genre_name)) = genre_from_bpm(bpm) { suggestions.push(TagSuggestion { tag: genre_tag.to_string(), reason: format!("BPM {bpm:.0} typical for {genre_name}"), confidence: 0.3, source: SuggestionSource::BpmRange, }); } } // 3. Key tag if let Some(ref key) = result.musical_key { let tag_key = key .to_lowercase() .replace(' ', "-") .replace('#', "-sharp"); suggestions.push(TagSuggestion { tag: format!("key.{tag_key}"), reason: format!("Detected key: {key}"), confidence: 0.75, source: SuggestionSource::KeyDetection, }); } // 4. Loop/oneshot from detection if let Some(is_loop) = result.is_loop { if is_loop { suggestions.push(TagSuggestion { tag: "type.loop".to_string(), reason: "Loop boundaries match, beat-aligned".to_string(), confidence: 0.8, source: SuggestionSource::LoopDetection, }); } else { suggestions.push(TagSuggestion { tag: "type.oneshot".to_string(), reason: "No loop correlation detected".to_string(), confidence: 0.6, source: SuggestionSource::LoopDetection, }); } } // 5. Duration heuristic: anything under 0.3s is almost certainly a one-shot // (single hit, click, transient). Most drum hits and short percussion fall here. if result.duration < 0.3 { // Only add if not already suggested by loop detection if !suggestions.iter().any(|s| s.tag == "type.oneshot") { suggestions.push(TagSuggestion { tag: "type.oneshot".to_string(), reason: format!("Very short: {:.2}s", result.duration), confidence: 0.9, source: SuggestionSource::DurationHeuristic, }); } } // 6. Loudness tags based on RMS level. // -6 dBFS RMS is very loud (heavily compressed/limited signal). // -30 dBFS RMS is very quiet (ambient, room tone, quiet textures). // The gap between -6 and -30 is "normal" and gets no tag. if let Some(rms) = result.rms_db { if rms > -6.0 { suggestions.push(TagSuggestion { tag: "character.loud".to_string(), reason: format!("RMS: {rms:.1} dBFS"), confidence: 0.7, source: SuggestionSource::LoudnessRange, }); } else if rms < -30.0 { suggestions.push(TagSuggestion { tag: "character.quiet".to_string(), reason: format!("RMS: {rms:.1} dBFS"), confidence: 0.7, source: SuggestionSource::LoudnessRange, }); } } // Sort by confidence descending so the most reliable suggestions appear first. suggestions.sort_by(|a, b| b.confidence.total_cmp(&a.confidence)); // Deduplicate by tag string — if the same tag was suggested by two sources // (e.g., "type.oneshot" from both loop detection and duration), keep the higher-confidence one. let mut seen = std::collections::HashSet::new(); suggestions.retain(|s| seen.insert(s.tag.clone())); suggestions } /// Low-confidence genre hint from BPM range. /// /// Ranges are approximate and based on common tempo conventions in electronic music. /// Many genres overlap (e.g., house and tech-house share 115-126 BPM), so these /// are suggestions, not classifications. BPMs outside 60-190 are too ambiguous. fn genre_from_bpm(bpm: f64) -> Option<(&'static str, &'static str)> { match bpm as u32 { 60..=89 => Some(("genre.hip-hop", "hip-hop")), 90..=99 => Some(("genre.trip-hop", "trip-hop")), 100..=115 => Some(("genre.house", "house")), 116..=125 => Some(("genre.tech-house", "tech house")), 126..=135 => Some(("genre.techno", "techno")), 136..=149 => Some(("genre.trance", "trance")), 150..=169 => Some(("genre.jungle", "jungle")), 170..=190 => Some(("genre.drum-and-bass", "drum & bass")), _ => None, } } #[cfg(test)] mod tests { use super::*; use crate::analysis::classify::SampleClass; fn make_result() -> AnalysisResult { AnalysisResult { hash: "test".to_string(), duration: 0.12, sample_rate: 44100, channels: 1, peak_db: Some(-3.2), rms_db: Some(-8.0), lufs: Some(-10.0), bpm: Some(127.5), musical_key: Some("C minor".to_string()), is_loop: Some(false), spectral_centroid: Some(150.0), spectral_flatness: Some(0.05), spectral_rolloff: Some(300.0), zero_crossing_rate: Some(0.02), onset_strength: Some(50.0), classification: Some(SampleClass::Kick), fingerprint: None, spectral_bandwidth: Some(500.0), centroid_variance: Some(10000.0), crest_factor: Some(4.5), attack_time: Some(0.003), classification_confidence: None, } } #[test] fn suggests_classification_tag() { let result = make_result(); let tags = suggest_tags(&result); assert!(tags.iter().any(|t| t.tag == "instrument.drum.kick")); } #[test] fn suggests_bpm_tag() { let result = make_result(); let tags = suggest_tags(&result); assert!(tags.iter().any(|t| t.tag == "bpm.128")); // 127.5 rounds to 128 } #[test] fn suggests_key_tag() { let result = make_result(); let tags = suggest_tags(&result); assert!(tags.iter().any(|t| t.tag == "key.c-minor")); } #[test] fn suggests_oneshot_for_short() { let result = make_result(); let tags = suggest_tags(&result); assert!(tags.iter().any(|t| t.tag == "type.oneshot")); } #[test] fn suggests_genre_from_bpm() { let result = make_result(); let tags = suggest_tags(&result); // 127.5 BPM → techno range (126-135) assert!(tags.iter().any(|t| t.tag == "genre.techno")); } #[test] fn sorted_by_confidence() { let result = make_result(); let tags = suggest_tags(&result); for pair in tags.windows(2) { assert!(pair[0].confidence >= pair[1].confidence); } } #[test] fn no_duplicate_tags() { let result = make_result(); let tags = suggest_tags(&result); let mut seen = std::collections::HashSet::new(); for t in &tags { assert!(seen.insert(&t.tag), "duplicate tag: {}", t.tag); } } }