Skip to main content

max / audiofiles

9.7 KB · 277 lines History Blame Raw
1 //! Tag suggestion engine: generates dot-notation tag proposals from analysis results.
2
3 use super::AnalysisResult;
4 use tracing::instrument;
5
6 /// Source of a tag suggestion, for transparency.
7 #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
8 pub enum SuggestionSource {
9 Classification,
10 BpmRange,
11 KeyDetection,
12 LoopDetection,
13 DurationHeuristic,
14 LoudnessRange,
15 }
16
17 impl std::fmt::Display for SuggestionSource {
18 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
19 match self {
20 Self::Classification => write!(f, "Classification"),
21 Self::BpmRange => write!(f, "BPM"),
22 Self::KeyDetection => write!(f, "Key"),
23 Self::LoopDetection => write!(f, "Loop"),
24 Self::DurationHeuristic => write!(f, "Duration"),
25 Self::LoudnessRange => write!(f, "Loudness"),
26 }
27 }
28 }
29
30 /// A suggested tag with explanation.
31 #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
32 pub struct TagSuggestion {
33 pub tag: String,
34 pub reason: String,
35 pub confidence: f32,
36 pub source: SuggestionSource,
37 }
38
39 /// Generate tag suggestions from analysis results. Pure function — no DB access.
40 #[instrument(skip_all)]
41 pub fn suggest_tags(result: &AnalysisResult) -> Vec<TagSuggestion> {
42 let mut suggestions = Vec::new();
43
44 // 1. Classification tag — use ML confidence when available
45 if let Some(ref class) = result.classification {
46 let ml_conf = result.classification_confidence.unwrap_or(0.0);
47 // Only suggest if ML confidence >= 0.5 (or rule-based fallback with 0.0 confidence)
48 let suggest_conf = if ml_conf > 0.0 { ml_conf as f32 } else { 0.7 };
49 if ml_conf == 0.0 || ml_conf >= 0.5 {
50 let reason = if ml_conf > 0.0 {
51 format!("ML classifier: {:.0}% confidence", ml_conf * 100.0)
52 } else {
53 format!(
54 "Spectral: centroid {:.0} Hz, flatness {:.2}, crest {:.1}",
55 result.spectral_centroid.unwrap_or(0.0),
56 result.spectral_flatness.unwrap_or(0.0),
57 result.crest_factor.unwrap_or(0.0),
58 )
59 };
60 suggestions.push(TagSuggestion {
61 tag: class.tag().to_string(),
62 reason,
63 confidence: suggest_conf,
64 source: SuggestionSource::Classification,
65 });
66 }
67 }
68
69 // 2. BPM tag — high confidence (0.8) because BPM detection is fairly reliable
70 // for rhythmic material. Genre hint is low confidence (0.3) because BPM ranges
71 // overlap heavily between genres — it's a rough starting point, not a classification.
72 if let Some(bpm) = result.bpm {
73 let rounded = bpm.round() as u32;
74 suggestions.push(TagSuggestion {
75 tag: format!("bpm.{rounded}"),
76 reason: format!("Detected BPM: {bpm:.1}"),
77 confidence: 0.8,
78 source: SuggestionSource::BpmRange,
79 });
80
81 if let Some((genre_tag, genre_name)) = genre_from_bpm(bpm) {
82 suggestions.push(TagSuggestion {
83 tag: genre_tag.to_string(),
84 reason: format!("BPM {bpm:.0} typical for {genre_name}"),
85 confidence: 0.3,
86 source: SuggestionSource::BpmRange,
87 });
88 }
89 }
90
91 // 3. Key tag
92 if let Some(ref key) = result.musical_key {
93 let tag_key = key
94 .to_lowercase()
95 .replace(' ', "-")
96 .replace('#', "-sharp");
97 suggestions.push(TagSuggestion {
98 tag: format!("key.{tag_key}"),
99 reason: format!("Detected key: {key}"),
100 confidence: 0.75,
101 source: SuggestionSource::KeyDetection,
102 });
103 }
104
105 // 4. Loop/oneshot from detection
106 if let Some(is_loop) = result.is_loop {
107 if is_loop {
108 suggestions.push(TagSuggestion {
109 tag: "type.loop".to_string(),
110 reason: "Loop boundaries match, beat-aligned".to_string(),
111 confidence: 0.8,
112 source: SuggestionSource::LoopDetection,
113 });
114 } else {
115 suggestions.push(TagSuggestion {
116 tag: "type.oneshot".to_string(),
117 reason: "No loop correlation detected".to_string(),
118 confidence: 0.6,
119 source: SuggestionSource::LoopDetection,
120 });
121 }
122 }
123
124 // 5. Duration heuristic: anything under 0.3s is almost certainly a one-shot
125 // (single hit, click, transient). Most drum hits and short percussion fall here.
126 if result.duration < 0.3 {
127 // Only add if not already suggested by loop detection
128 if !suggestions.iter().any(|s| s.tag == "type.oneshot") {
129 suggestions.push(TagSuggestion {
130 tag: "type.oneshot".to_string(),
131 reason: format!("Very short: {:.2}s", result.duration),
132 confidence: 0.9,
133 source: SuggestionSource::DurationHeuristic,
134 });
135 }
136 }
137
138 // 6. Loudness tags based on RMS level.
139 // -6 dBFS RMS is very loud (heavily compressed/limited signal).
140 // -30 dBFS RMS is very quiet (ambient, room tone, quiet textures).
141 // The gap between -6 and -30 is "normal" and gets no tag.
142 if let Some(rms) = result.rms_db {
143 if rms > -6.0 {
144 suggestions.push(TagSuggestion {
145 tag: "character.loud".to_string(),
146 reason: format!("RMS: {rms:.1} dBFS"),
147 confidence: 0.7,
148 source: SuggestionSource::LoudnessRange,
149 });
150 } else if rms < -30.0 {
151 suggestions.push(TagSuggestion {
152 tag: "character.quiet".to_string(),
153 reason: format!("RMS: {rms:.1} dBFS"),
154 confidence: 0.7,
155 source: SuggestionSource::LoudnessRange,
156 });
157 }
158 }
159
160 // Sort by confidence descending so the most reliable suggestions appear first.
161 suggestions.sort_by(|a, b| b.confidence.total_cmp(&a.confidence));
162
163 // Deduplicate by tag string — if the same tag was suggested by two sources
164 // (e.g., "type.oneshot" from both loop detection and duration), keep the higher-confidence one.
165 let mut seen = std::collections::HashSet::new();
166 suggestions.retain(|s| seen.insert(s.tag.clone()));
167
168 suggestions
169 }
170
171 /// Low-confidence genre hint from BPM range.
172 ///
173 /// Ranges are approximate and based on common tempo conventions in electronic music.
174 /// Many genres overlap (e.g., house and tech-house share 115-126 BPM), so these
175 /// are suggestions, not classifications. BPMs outside 60-190 are too ambiguous.
176 fn genre_from_bpm(bpm: f64) -> Option<(&'static str, &'static str)> {
177 match bpm as u32 {
178 60..=89 => Some(("genre.hip-hop", "hip-hop")),
179 90..=99 => Some(("genre.trip-hop", "trip-hop")),
180 100..=115 => Some(("genre.house", "house")),
181 116..=125 => Some(("genre.tech-house", "tech house")),
182 126..=135 => Some(("genre.techno", "techno")),
183 136..=149 => Some(("genre.trance", "trance")),
184 150..=169 => Some(("genre.jungle", "jungle")),
185 170..=190 => Some(("genre.drum-and-bass", "drum & bass")),
186 _ => None,
187 }
188 }
189
190 #[cfg(test)]
191 mod tests {
192 use super::*;
193 use crate::analysis::classify::SampleClass;
194
195 fn make_result() -> AnalysisResult {
196 AnalysisResult {
197 hash: "test".to_string(),
198 duration: 0.12,
199 sample_rate: 44100,
200 channels: 1,
201 peak_db: Some(-3.2),
202 rms_db: Some(-8.0),
203 lufs: Some(-10.0),
204 bpm: Some(127.5),
205 musical_key: Some("C minor".to_string()),
206 is_loop: Some(false),
207 spectral_centroid: Some(150.0),
208 spectral_flatness: Some(0.05),
209 spectral_rolloff: Some(300.0),
210 zero_crossing_rate: Some(0.02),
211 onset_strength: Some(50.0),
212 classification: Some(SampleClass::Kick),
213 fingerprint: None,
214 spectral_bandwidth: Some(500.0),
215 centroid_variance: Some(10000.0),
216 crest_factor: Some(4.5),
217 attack_time: Some(0.003),
218 classification_confidence: None,
219 }
220 }
221
222 #[test]
223 fn suggests_classification_tag() {
224 let result = make_result();
225 let tags = suggest_tags(&result);
226 assert!(tags.iter().any(|t| t.tag == "instrument.drum.kick"));
227 }
228
229 #[test]
230 fn suggests_bpm_tag() {
231 let result = make_result();
232 let tags = suggest_tags(&result);
233 assert!(tags.iter().any(|t| t.tag == "bpm.128")); // 127.5 rounds to 128
234 }
235
236 #[test]
237 fn suggests_key_tag() {
238 let result = make_result();
239 let tags = suggest_tags(&result);
240 assert!(tags.iter().any(|t| t.tag == "key.c-minor"));
241 }
242
243 #[test]
244 fn suggests_oneshot_for_short() {
245 let result = make_result();
246 let tags = suggest_tags(&result);
247 assert!(tags.iter().any(|t| t.tag == "type.oneshot"));
248 }
249
250 #[test]
251 fn suggests_genre_from_bpm() {
252 let result = make_result();
253 let tags = suggest_tags(&result);
254 // 127.5 BPM → techno range (126-135)
255 assert!(tags.iter().any(|t| t.tag == "genre.techno"));
256 }
257
258 #[test]
259 fn sorted_by_confidence() {
260 let result = make_result();
261 let tags = suggest_tags(&result);
262 for pair in tags.windows(2) {
263 assert!(pair[0].confidence >= pair[1].confidence);
264 }
265 }
266
267 #[test]
268 fn no_duplicate_tags() {
269 let result = make_result();
270 let tags = suggest_tags(&result);
271 let mut seen = std::collections::HashSet::new();
272 for t in &tags {
273 assert!(seen.insert(&t.tag), "duplicate tag: {}", t.tag);
274 }
275 }
276 }
277