1#![warn(missing_docs)]
41#![warn(clippy::all)]
42
43pub mod analysis;
44pub mod config;
45pub mod error;
46pub mod features;
47pub mod preprocessing;
48
49#[cfg(feature = "ml")]
50pub mod ml;
51
52pub use analysis::result::{AnalysisResult, AnalysisMetadata, BeatGrid, Key, KeyType};
54pub use analysis::confidence::{AnalysisConfidence, compute_confidence};
55pub use config::AnalysisConfig;
56pub use error::AnalysisError;
57
58pub fn analyze_audio(
87 samples: &[f32],
88 sample_rate: u32,
89 config: AnalysisConfig,
90) -> Result<AnalysisResult, AnalysisError> {
91 use std::time::Instant;
92 let start_time = Instant::now();
93
94 log::debug!("Starting audio analysis: {} samples at {} Hz", samples.len(), sample_rate);
95
96 if samples.is_empty() {
97 return Err(AnalysisError::InvalidInput("Empty audio samples".to_string()));
98 }
99
100 if sample_rate == 0 {
101 return Err(AnalysisError::InvalidInput("Invalid sample rate".to_string()));
102 }
103
104 let mut processed_samples = samples.to_vec();
106
107 use preprocessing::normalization::{normalize, NormalizationConfig};
109 if config.enable_normalization {
110 let norm_config = NormalizationConfig {
111 method: config.normalization,
112 target_loudness_lufs: -14.0, max_headroom_db: 1.0,
114 };
115 let _loudness_metadata = normalize(&mut processed_samples, norm_config, sample_rate as f32)?;
116 } else {
117 log::debug!("Skipping normalization (enable_normalization=false)");
118 }
119
120 use preprocessing::silence::{detect_and_trim, SilenceDetector};
122 let (trimmed_samples, _silence_regions) = if config.enable_silence_trimming {
123 let silence_detector = SilenceDetector {
124 threshold_db: config.min_amplitude_db,
125 min_duration_ms: 500,
126 frame_size: config.frame_size,
127 };
128 detect_and_trim(&processed_samples, sample_rate, silence_detector)?
129 } else {
130 log::debug!("Skipping silence trimming (enable_silence_trimming=false)");
131 (processed_samples.clone(), Vec::new())
132 };
133
134 if trimmed_samples.is_empty() {
135 return Err(AnalysisError::ProcessingError("Audio is entirely silent after trimming".to_string()));
136 }
137
138 use features::onset::energy_flux::detect_energy_flux_onsets;
142
143 let energy_onsets = detect_energy_flux_onsets(
144 &trimmed_samples,
145 config.frame_size,
146 config.hop_size,
147 -20.0, )?;
149
150 log::debug!("Detected {} onsets using energy flux", energy_onsets.len());
151
152 use features::chroma::extractor::compute_stft;
155 let magnitude_spec_frames = compute_stft(
156 &trimmed_samples,
157 config.frame_size,
158 config.hop_size,
159 )?;
160
161 let mut onsets_for_legacy: Vec<usize> = energy_onsets.clone();
167 let mut onsets_for_beat_tracking: Vec<usize> = energy_onsets.clone();
168
169 if config.enable_onset_consensus && !magnitude_spec_frames.is_empty() {
170 use features::onset::consensus::{vote_onsets, OnsetConsensus};
171 use features::onset::hfc::detect_hfc_onsets;
172 use features::onset::spectral_flux::detect_spectral_flux_onsets;
173
174 let to_samples = |frames: Vec<usize>, hop_size: usize, n_samples: usize| -> Vec<usize> {
175 let mut out: Vec<usize> = frames
176 .into_iter()
177 .map(|f| f.saturating_mul(hop_size))
178 .filter(|&s| s < n_samples)
179 .collect();
180 out.sort_unstable();
181 out.dedup();
182 out
183 };
184
185 let spectral_onsets_frames = match detect_spectral_flux_onsets(
186 &magnitude_spec_frames,
187 config.onset_threshold_percentile,
188 ) {
189 Ok(v) => v,
190 Err(e) => {
191 log::warn!("Spectral flux onset detection failed: {}", e);
192 Vec::new()
193 }
194 };
195 let spectral_onsets_samples = to_samples(
196 spectral_onsets_frames,
197 config.hop_size,
198 trimmed_samples.len(),
199 );
200
201 let hfc_onsets_frames = match detect_hfc_onsets(
202 &magnitude_spec_frames,
203 sample_rate,
204 config.onset_threshold_percentile,
205 ) {
206 Ok(v) => v,
207 Err(e) => {
208 log::warn!("HFC onset detection failed: {}", e);
209 Vec::new()
210 }
211 };
212 let hfc_onsets_samples = to_samples(hfc_onsets_frames, config.hop_size, trimmed_samples.len());
213
214 let hpss_onsets_samples = if config.enable_hpss_onsets {
215 use features::onset::hpss::{detect_hpss_onsets, hpss_decompose};
216 match hpss_decompose(&magnitude_spec_frames, config.hpss_margin)
217 .and_then(|(_, p)| detect_hpss_onsets(&p, config.onset_threshold_percentile))
218 {
219 Ok(hpss_frames) => to_samples(hpss_frames, config.hop_size, trimmed_samples.len()),
220 Err(e) => {
221 log::warn!("HPSS onset detection failed: {}", e);
222 Vec::new()
223 }
224 }
225 } else {
226 Vec::new()
227 };
228
229 log::debug!(
230 "Onset detectors: energy_flux(samples)={}, spectral_flux(samples)={}, hfc(samples)={}, hpss(samples)={}",
231 energy_onsets.len(),
232 spectral_onsets_samples.len(),
233 hfc_onsets_samples.len(),
234 hpss_onsets_samples.len()
235 );
236
237 let consensus = OnsetConsensus {
238 energy_flux: energy_onsets.clone(),
239 spectral_flux: spectral_onsets_samples,
240 hfc: hfc_onsets_samples,
241 hpss: hpss_onsets_samples,
242 };
243
244 match vote_onsets(
245 consensus,
246 config.onset_consensus_weights,
247 config.onset_consensus_tolerance_ms,
248 sample_rate,
249 ) {
250 Ok(candidates) => {
251 let mut strong: Vec<usize> = candidates
254 .iter()
255 .filter(|c| c.voted_by >= 2)
256 .map(|c| c.time_samples)
257 .collect();
258 strong.sort_unstable();
259 strong.dedup();
260
261 let mut any: Vec<usize> = candidates.iter().map(|c| c.time_samples).collect();
262 any.sort_unstable();
263 any.dedup();
264
265 let chosen = if !strong.is_empty() { strong } else { any };
266 if !chosen.is_empty() {
267 log::debug!(
268 "Onset consensus: chosen {} onsets (strong>=2 methods: {}, total_clusters: {})",
269 chosen.len(),
270 candidates.iter().filter(|c| c.voted_by >= 2).count(),
271 candidates.len()
272 );
273 onsets_for_legacy = chosen.clone();
274 onsets_for_beat_tracking = chosen;
275 } else {
276 log::debug!("Onset consensus produced no candidates; using energy-flux onsets");
277 }
278 }
279 Err(e) => {
280 log::warn!("Onset consensus voting failed: {}", e);
281 }
282 }
283 }
284
285 let legacy_estimate = {
287 use features::period::{estimate_bpm, estimate_bpm_with_guardrails, LegacyBpmGuardrails};
288 if onsets_for_legacy.len() >= 2 {
289 if config.enable_legacy_bpm_guardrails {
290 let guardrails = LegacyBpmGuardrails {
291 preferred_min: config.legacy_bpm_preferred_min,
292 preferred_max: config.legacy_bpm_preferred_max,
293 soft_min: config.legacy_bpm_soft_min,
294 soft_max: config.legacy_bpm_soft_max,
295 mul_preferred: config.legacy_bpm_conf_mul_preferred,
296 mul_soft: config.legacy_bpm_conf_mul_soft,
297 mul_extreme: config.legacy_bpm_conf_mul_extreme,
298 };
299 estimate_bpm_with_guardrails(
300 &onsets_for_legacy,
301 sample_rate,
302 config.hop_size,
303 config.min_bpm,
304 config.max_bpm,
305 config.bpm_resolution,
306 guardrails,
307 )?
308 } else {
309 estimate_bpm(
310 &onsets_for_legacy,
311 sample_rate,
312 config.hop_size,
313 config.min_bpm,
314 config.max_bpm,
315 config.bpm_resolution,
316 )?
317 }
318 } else {
319 None
320 }
321 };
322
323 let mut tempogram_candidates: Option<Vec<crate::analysis::result::TempoCandidateDebug>> = None;
324 let mut tempogram_multi_res_triggered: Option<bool> = None;
325 let mut tempogram_multi_res_used: Option<bool> = None;
326 let mut tempogram_percussive_triggered: Option<bool> = None;
327 let mut tempogram_percussive_used: Option<bool> = None;
328
329 let tempogram_estimate = if !config.force_legacy_bpm && !magnitude_spec_frames.is_empty() {
330 use crate::analysis::result::TempoCandidateDebug;
331 use features::period::multi_resolution::multi_resolution_tempogram_from_samples;
332 use features::period::tempogram::{
333 estimate_bpm_tempogram,
334 estimate_bpm_tempogram_band_fusion,
335 estimate_bpm_tempogram_with_candidates,
336 estimate_bpm_tempogram_with_candidates_band_fusion,
337 TempogramBandFusionConfig,
338 };
339
340 let band_cfg = TempogramBandFusionConfig {
341 enabled: config.enable_tempogram_band_fusion,
342 low_max_hz: config.tempogram_band_low_max_hz,
343 mid_max_hz: config.tempogram_band_mid_max_hz,
344 high_max_hz: config.tempogram_band_high_max_hz,
345 w_full: config.tempogram_band_w_full,
346 w_low: config.tempogram_band_w_low,
347 w_mid: config.tempogram_band_w_mid,
348 w_high: config.tempogram_band_w_high,
349 seed_only: config.tempogram_band_seed_only,
350 support_threshold: config.tempogram_band_support_threshold,
351 consensus_bonus: config.tempogram_band_consensus_bonus,
352 enable_mel: config.enable_tempogram_mel_novelty,
353 mel_n_mels: config.tempogram_mel_n_mels,
354 mel_fmin_hz: config.tempogram_mel_fmin_hz,
355 mel_fmax_hz: config.tempogram_mel_fmax_hz,
356 mel_max_filter_bins: config.tempogram_mel_max_filter_bins,
357 w_mel: config.tempogram_mel_weight,
358 novelty_w_spectral: config.tempogram_novelty_w_spectral,
359 novelty_w_energy: config.tempogram_novelty_w_energy,
360 novelty_w_hfc: config.tempogram_novelty_w_hfc,
361 novelty_local_mean_window: config.tempogram_novelty_local_mean_window,
362 novelty_smooth_window: config.tempogram_novelty_smooth_window,
363 debug_track_id: config.debug_track_id,
364 debug_gt_bpm: config.debug_gt_bpm,
365 debug_top_n: config.debug_top_n,
366 superflux_max_filter_bins: config.tempogram_superflux_max_filter_bins,
367 };
368
369 let use_aux_variants = config.enable_tempogram_band_fusion
370 || config.enable_tempogram_mel_novelty
371 || config.tempogram_band_consensus_bonus > 0.0;
372
373 if config.enable_tempogram_multi_resolution {
374 let base_top_n = config
377 .tempogram_candidates_top_n
378 .max(config.tempogram_multi_res_top_k)
379 .max(10);
380
381 let base_call = if use_aux_variants {
382 estimate_bpm_tempogram_with_candidates_band_fusion(
383 &magnitude_spec_frames,
384 sample_rate,
385 config.hop_size as u32,
386 config.min_bpm,
387 config.max_bpm,
388 config.bpm_resolution,
389 base_top_n,
390 band_cfg.clone(),
391 )
392 } else {
393 estimate_bpm_tempogram_with_candidates(
394 &magnitude_spec_frames,
395 sample_rate,
396 config.hop_size as u32,
397 config.min_bpm,
398 config.max_bpm,
399 config.bpm_resolution,
400 base_top_n,
401 )
402 };
403
404 match base_call {
405 Ok((base_est, base_cands)) => {
406 let trap_low = base_est.bpm >= 55.0 && base_est.bpm <= 80.0;
407 let trap_high = base_est.bpm >= 170.0 && base_est.bpm <= 200.0;
408
409 fn cand_support(
415 cands: &[features::period::tempogram::TempogramCandidateDebug],
416 bpm: f32,
417 tol: f32,
418 ) -> f32 {
419 let mut best = 0.0f32;
420 for c in cands {
421 if (c.bpm - bpm).abs() <= tol {
422 best = best.max(c.score);
423 }
424 }
425 best
426 }
427
428 let tol = 2.0f32.max(config.bpm_resolution);
429 let s_base = cand_support(&base_cands, base_est.bpm, tol);
430 let s_2x = cand_support(&base_cands, base_est.bpm * 2.0, tol);
431 let s_half = cand_support(&base_cands, base_est.bpm * 0.5, tol);
432 let family_competes = (s_2x > 0.0 && s_2x >= s_base * 0.90)
433 || (s_half > 0.0 && s_half >= s_base * 0.90);
434
435 let fold_into_trap = base_est.bpm * 2.0 >= 170.0 && base_est.bpm * 2.0 <= 200.0;
448 let weak_base = base_est.method_agreement == 0 || base_est.confidence < 0.06;
449
450 let ambiguous = trap_low || trap_high || family_competes || (weak_base && fold_into_trap);
451 tempogram_multi_res_triggered = Some(ambiguous);
453
454 if let Some(track_id) = config.debug_track_id {
455 eprintln!("\n=== DEBUG base tempogram (track_id={}) ===", track_id);
456 if let Some(gt) = config.debug_gt_bpm {
457 eprintln!("GT bpm: {:.3}", gt);
458 }
459 eprintln!(
460 "base_est: bpm={:.2} conf={:.4} agree={} (trap_low={} trap_high={} ambiguous={})",
461 base_est.bpm,
462 base_est.confidence,
463 base_est.method_agreement,
464 trap_low,
465 trap_high,
466 ambiguous
467 );
468 eprintln!(
469 "ambiguity signals: family_competes={} (s_base={:.4} s_2x={:.4} s_half={:.4}) weak_base={} fold_into_trap={}",
470 family_competes,
471 s_base,
472 s_2x,
473 s_half,
474 weak_base,
475 fold_into_trap
476 );
477 if !ambiguous {
478 eprintln!("NOTE: multi-res not run (outside trap zones).");
479 }
480 }
481
482 let mut chosen_est = base_est.clone();
483 let mut chosen_cands = base_cands;
484 let mut used_mr = false;
485
486 if ambiguous {
487 match multi_resolution_tempogram_from_samples(
488 &trimmed_samples,
489 sample_rate,
490 config.frame_size,
491 config.min_bpm,
492 config.max_bpm,
493 config.bpm_resolution,
494 config.tempogram_multi_res_top_k,
495 config.tempogram_multi_res_w512,
496 config.tempogram_multi_res_w256,
497 config.tempogram_multi_res_w1024,
498 config.tempogram_multi_res_structural_discount,
499 config.tempogram_multi_res_double_time_512_factor,
500 config.tempogram_multi_res_margin_threshold,
501 config.tempogram_multi_res_use_human_prior,
502 Some(band_cfg.clone()),
503 ) {
504 Ok((mr_est, mr_cands_512)) => {
505 let mr_est_log = mr_est.clone();
506 let rel = if base_est.bpm > 1e-6 {
509 (mr_est.bpm / base_est.bpm).max(base_est.bpm / mr_est.bpm)
510 } else {
511 1.0
512 };
513 let family_related =
514 (rel - 2.0).abs() < 0.05 || (rel - 1.5).abs() < 0.05 || (rel - (4.0 / 3.0)).abs() < 0.05;
515
516 let forbid_promote_high = base_est.bpm <= 180.0 && mr_est.bpm > 180.0;
520
521 let mr_better = !forbid_promote_high
522 && (mr_est.confidence >= (base_est.confidence + 0.05)
523 || (mr_est.method_agreement > base_est.method_agreement
524 && mr_est.confidence >= base_est.confidence * 0.90)
525 || ((trap_low || trap_high)
526 && family_related
527 && mr_est.confidence >= base_est.confidence * 0.88
528 && ((mr_est.bpm >= 70.0 && mr_est.bpm <= 180.0) || base_est.bpm > 180.0)));
531
532 if mr_better {
533 chosen_est = mr_est;
534 chosen_cands = mr_cands_512;
535 used_mr = true;
536 }
537
538 if let Some(track_id) = config.debug_track_id {
539 eprintln!("\n=== DEBUG multi-res decision (track_id={}) ===", track_id);
540 if let Some(gt) = config.debug_gt_bpm {
541 eprintln!("GT bpm: {:.3}", gt);
542 }
543 eprintln!("base_est: bpm={:.2} conf={:.4} agree={}", base_est.bpm, base_est.confidence, base_est.method_agreement);
544 eprintln!("mr_est: bpm={:.2} conf={:.4} agree={}", mr_est_log.bpm, mr_est_log.confidence, mr_est_log.method_agreement);
545 eprintln!("ambiguous(trap_low||trap_high)={}", ambiguous);
546 eprintln!("rel={:.3} family_related={} forbid_promote_high={}", rel, family_related, forbid_promote_high);
547 eprintln!("mr_better={} used_mr={}", mr_better, used_mr);
548 }
549 }
550 Err(e) => {
551 log::debug!("Multi-resolution escalation skipped (failed): {}", e);
552 }
553 }
554 }
555 tempogram_multi_res_used = Some(used_mr);
556
557 let percussive_needed = ambiguous && trap_low;
563 tempogram_percussive_triggered = Some(percussive_needed);
564
565 if config.enable_tempogram_percussive_fallback && percussive_needed {
566 use features::onset::hpss::hpss_decompose;
567
568 match hpss_decompose(&magnitude_spec_frames, config.hpss_margin) {
570 Ok((_h, p)) => {
571 let p_call = if use_aux_variants {
573 estimate_bpm_tempogram_with_candidates_band_fusion(
574 &p,
575 sample_rate,
576 config.hop_size as u32,
577 config.min_bpm,
578 config.max_bpm,
579 config.bpm_resolution,
580 base_top_n,
581 band_cfg.clone(),
582 )
583 } else {
584 estimate_bpm_tempogram_with_candidates(
585 &p,
586 sample_rate,
587 config.hop_size as u32,
588 config.min_bpm,
589 config.max_bpm,
590 config.bpm_resolution,
591 base_top_n,
592 )
593 };
594
595 match p_call {
596 Ok((p_est, p_cands)) => {
597 let rel = if chosen_est.bpm > 1e-6 {
600 (p_est.bpm / chosen_est.bpm).max(chosen_est.bpm / p_est.bpm)
601 } else {
602 1.0
603 };
604 let family_related = (rel - 2.0).abs() < 0.05
605 || (rel - 1.5).abs() < 0.05
606 || (rel - (4.0 / 3.0)).abs() < 0.05
607 || (rel - (3.0 / 2.0)).abs() < 0.05
608 || (rel - (2.0 / 3.0)).abs() < 0.05
609 || (rel - (3.0 / 4.0)).abs() < 0.05;
610
611 let forbid_promote_high = chosen_est.bpm <= 180.0 && p_est.bpm > 180.0;
612
613 let base_low_trap = trap_low || base_est.bpm < 95.0;
617 let percussive_in_common = p_est.bpm >= 70.0 && p_est.bpm <= 180.0;
618
619 let p_better = !forbid_promote_high
620 && family_related
621 && percussive_in_common
622 && (p_est.confidence >= chosen_est.confidence + 0.04
623 || (base_low_trap && p_est.confidence >= chosen_est.confidence * 0.85)
624 || (p_est.method_agreement > chosen_est.method_agreement
625 && p_est.confidence >= chosen_est.confidence * 0.92));
626
627 if p_better {
628 chosen_est = p_est;
629 chosen_cands = p_cands;
630 tempogram_percussive_used = Some(true);
631 } else {
632 tempogram_percussive_used = Some(false);
633 }
634 }
635 Err(e) => {
636 log::debug!("Percussive tempogram fallback failed: {}", e);
637 tempogram_percussive_used = Some(false);
638 }
639 }
640 }
641 Err(e) => {
642 log::debug!("HPSS decomposition for percussive tempogram failed: {}", e);
643 tempogram_percussive_used = Some(false);
644 }
645 }
646 } else if config.enable_tempogram_percussive_fallback {
647 tempogram_percussive_used = Some(false);
648 }
649
650 if config.emit_tempogram_candidates {
651 tempogram_candidates = Some(
652 chosen_cands
653 .into_iter()
654 .map(|c| TempoCandidateDebug {
655 bpm: c.bpm,
656 score: c.score,
657 fft_norm: c.fft_norm,
658 autocorr_norm: c.autocorr_norm,
659 selected: c.selected,
660 })
661 .collect(),
662 );
663 }
664
665 log::debug!(
666 "Tempogram BPM estimate: {:.2} (confidence: {:.3}, method_agreement: {}, multi_res={})",
667 chosen_est.bpm,
668 chosen_est.confidence,
669 chosen_est.method_agreement,
670 ambiguous
671 );
672
673 Some(chosen_est)
674 }
675 Err(e) => {
676 log::warn!("Tempogram BPM detection failed: {}", e);
677 None
678 }
679 }
680 } else if config.emit_tempogram_candidates {
681 let call = if use_aux_variants {
682 estimate_bpm_tempogram_with_candidates_band_fusion(
683 &magnitude_spec_frames,
684 sample_rate,
685 config.hop_size as u32,
686 config.min_bpm,
687 config.max_bpm,
688 config.bpm_resolution,
689 config.tempogram_candidates_top_n,
690 band_cfg.clone(),
691 )
692 } else {
693 estimate_bpm_tempogram_with_candidates(
694 &magnitude_spec_frames,
695 sample_rate,
696 config.hop_size as u32,
697 config.min_bpm,
698 config.max_bpm,
699 config.bpm_resolution,
700 config.tempogram_candidates_top_n,
701 )
702 };
703
704 match call {
705 Ok((estimate, cands)) => {
706 tempogram_candidates = Some(
707 cands.into_iter()
708 .map(|c| TempoCandidateDebug {
709 bpm: c.bpm,
710 score: c.score,
711 fft_norm: c.fft_norm,
712 autocorr_norm: c.autocorr_norm,
713 selected: c.selected,
714 })
715 .collect(),
716 );
717 log::debug!(
718 "Tempogram BPM estimate: {:.2} (confidence: {:.3}, method_agreement: {}, candidates_emitted={})",
719 estimate.bpm,
720 estimate.confidence,
721 estimate.method_agreement,
722 tempogram_candidates.as_ref().map(|v| v.len()).unwrap_or(0)
723 );
724 Some(estimate)
725 }
726 Err(e) => {
727 log::warn!("Tempogram BPM detection failed: {}", e);
728 None
729 }
730 }
731 } else {
732 let call = if use_aux_variants {
733 estimate_bpm_tempogram_band_fusion(
734 &magnitude_spec_frames,
735 sample_rate,
736 config.hop_size as u32,
737 config.min_bpm,
738 config.max_bpm,
739 config.bpm_resolution,
740 band_cfg.clone(),
741 )
742 } else {
743 estimate_bpm_tempogram(
744 &magnitude_spec_frames,
745 sample_rate,
746 config.hop_size as u32,
747 config.min_bpm,
748 config.max_bpm,
749 config.bpm_resolution,
750 )
751 };
752
753 match call {
754 Ok(estimate) => {
755 log::debug!(
756 "Tempogram BPM estimate: {:.2} (confidence: {:.3}, method_agreement: {})",
757 estimate.bpm,
758 estimate.confidence,
759 estimate.method_agreement
760 );
761 Some(estimate)
762 }
763 Err(e) => {
764 log::warn!("Tempogram BPM detection failed: {}", e);
765 None
766 }
767 }
768 }
769 } else {
770 if config.force_legacy_bpm {
771 log::debug!("Forcing legacy BPM estimation (force_legacy_bpm=true)");
772 } else if magnitude_spec_frames.is_empty() {
773 log::warn!("Could not compute STFT for tempogram");
774 }
775 None
776 };
777
778 let (bpm, bpm_confidence) = if config.force_legacy_bpm {
779 legacy_estimate
780 .as_ref()
781 .map(|e| (e.bpm, e.confidence))
782 .unwrap_or((0.0, 0.0))
783 } else if config.enable_bpm_fusion {
784 let (t_bpm, t_conf, t_agree) = tempogram_estimate
788 .as_ref()
789 .map(|e| (e.bpm, e.confidence, e.method_agreement))
790 .unwrap_or((0.0, 0.0, 0));
791 let (l_bpm, l_conf_raw) = legacy_estimate
792 .as_ref()
793 .map(|e| (e.bpm, e.confidence))
794 .unwrap_or((0.0, 0.0));
795 let l_conf = l_conf_raw.clamp(0.0, 1.0);
796
797 if t_bpm <= 0.0 {
799 legacy_estimate
800 .as_ref()
801 .map(|e| (e.bpm, e.confidence))
802 .unwrap_or((0.0, 0.0))
803 } else {
804 let tol = 2.0f32;
805 let mut conf = t_conf.clamp(0.0, 1.0);
806
807 let agreement = if l_bpm > 0.0 {
809 let diffs = [
811 (l_bpm - t_bpm).abs(),
812 (l_bpm - (t_bpm * 0.5)).abs(),
813 (l_bpm - (t_bpm * 2.0)).abs(),
814 (l_bpm - (t_bpm * (2.0 / 3.0))).abs(),
815 (l_bpm - (t_bpm * (3.0 / 2.0))).abs(),
816 ];
817 diffs.into_iter().any(|d| d <= tol)
818 } else {
819 false
820 };
821
822 if agreement {
823 let boost = 0.12 * l_conf;
825 conf = (conf + boost).clamp(0.0, 1.0);
826 log::debug!(
827 "BPM fusion (validator): tempogram {:.2} kept; legacy {:.2} validates (agree≈true); conf {:.3}->{:.3}; temp_agree={}",
828 t_bpm,
829 l_bpm,
830 t_conf,
831 conf,
832 t_agree
833 );
834 } else if l_bpm > 0.0 {
835 conf = (conf * 0.90).clamp(0.0, 1.0);
839 log::debug!(
840 "BPM fusion (validator): tempogram {:.2} kept; legacy {:.2} disagrees; conf {:.3}->{:.3}; temp_agree={}",
841 t_bpm,
842 l_bpm,
843 t_conf,
844 conf,
845 t_agree
846 );
847 } else {
848 log::debug!(
849 "BPM fusion (validator): tempogram {:.2} kept; no legacy estimate available; temp_agree={}",
850 t_bpm,
851 t_agree
852 );
853 }
854
855 (t_bpm, conf)
856 }
857 } else {
858 tempogram_estimate
860 .as_ref()
861 .map(|e| (e.bpm, e.confidence))
862 .or_else(|| legacy_estimate.as_ref().map(|e| (e.bpm, e.confidence)))
863 .unwrap_or((0.0, 0.0))
864 };
865
866 if bpm == 0.0 {
867 log::warn!("Could not estimate BPM: tempogram and legacy methods both failed");
868 } else {
869 log::debug!("Estimated BPM: {:.2} (confidence: {:.3})", bpm, bpm_confidence);
870 }
871
872 let (beat_grid, grid_stability) = if bpm > 0.0 && onsets_for_beat_tracking.len() >= 2 {
874 let onsets_seconds: Vec<f32> = onsets_for_beat_tracking
876 .iter()
877 .map(|&sample_idx| sample_idx as f32 / sample_rate as f32)
878 .collect();
879
880 use features::beat_tracking::generate_beat_grid;
882 match generate_beat_grid(bpm, bpm_confidence, &onsets_seconds, sample_rate) {
883 Ok((grid, stability)) => {
884 log::debug!(
885 "Beat grid generated: {} beats, {} downbeats, stability={:.3}",
886 grid.beats.len(),
887 grid.downbeats.len(),
888 stability
889 );
890 (grid, stability)
891 }
892 Err(e) => {
893 log::warn!("Beat tracking failed: {}, using empty grid", e);
894 (
895 BeatGrid {
896 downbeats: vec![],
897 beats: vec![],
898 bars: vec![],
899 },
900 0.0,
901 )
902 }
903 }
904 } else {
905 log::debug!("Skipping beat tracking: BPM={:.2}, onsets={}", bpm, energy_onsets.len());
906 (
907 BeatGrid {
908 downbeats: vec![],
909 beats: vec![],
910 bars: vec![],
911 },
912 0.0,
913 )
914 };
915
916 let (key, key_confidence, key_clarity) = if trimmed_samples.len() >= config.frame_size {
918 use features::chroma::extractor::{
923 convert_linear_to_log_frequency_spectrogram,
924 extract_beat_synchronous_chroma,
925 extract_chroma_from_log_frequency_spectrogram,
926 extract_chroma_from_spectrogram_with_options_and_energy,
927 extract_chroma_from_spectrogram_with_options_and_energy_tuned,
928 extract_hpcp_from_spectrogram_with_options_and_energy_tuned,
929 extract_hpcp_bass_blend_from_spectrogram_with_options_and_energy_tuned,
930 estimate_tuning_offset_semitones_from_spectrogram,
931 harmonic_spectrogram_hpss_median_mask,
932 harmonic_spectrogram_time_mask,
933 smooth_spectrogram_time,
934 };
935 use features::chroma::normalization::sharpen_chroma;
936 use features::chroma::smoothing::smooth_chroma;
937 use features::key::{
938 compute_key_clarity, detect_key_ensemble, detect_key_multi_scale, detect_key_weighted, detect_key_weighted_mode_heuristic,
939 KeyDetectionResult, KeyTemplates,
940 };
941
942 let key_fft_size = if config.enable_key_stft_override {
944 config.key_stft_frame_size.max(256)
945 } else {
946 config.frame_size
947 };
948 let key_hop_size = if config.enable_key_stft_override {
949 config.key_stft_hop_size.max(1)
950 } else {
951 config.hop_size
952 };
953
954 let key_spec_frames = if config.enable_key_stft_override {
955 match compute_stft(&trimmed_samples, key_fft_size, key_hop_size) {
956 Ok(s) => s,
957 Err(e) => {
958 log::warn!("Key-only STFT override failed: {}, falling back to shared STFT", e);
959 magnitude_spec_frames.clone()
960 }
961 }
962 } else {
963 magnitude_spec_frames.clone()
964 };
965
966 let spec_for_key = if !key_spec_frames.is_empty() {
968 if config.enable_key_hpss_harmonic {
969 match harmonic_spectrogram_hpss_median_mask(
970 &key_spec_frames,
971 sample_rate,
972 key_fft_size,
973 100.0,
974 5000.0,
975 config.key_hpss_frame_step,
976 config.key_hpss_time_margin,
977 config.key_hpss_freq_margin,
978 config.key_hpss_mask_power,
979 ) {
980 Ok(s) => s,
981 Err(e) => {
982 log::warn!("Key HPSS harmonic mask failed: {}, falling back", e);
983 key_spec_frames.clone()
984 }
985 }
986 } else if config.enable_key_harmonic_mask {
987 match harmonic_spectrogram_time_mask(
988 &key_spec_frames,
989 config.key_spectrogram_smooth_margin,
990 config.key_harmonic_mask_power,
991 ) {
992 Ok(s) => s,
993 Err(e) => {
994 log::warn!("Key harmonic mask failed: {}, falling back", e);
995 key_spec_frames.clone()
996 }
997 }
998 } else if config.enable_key_spectrogram_time_smoothing {
999 match smooth_spectrogram_time(&key_spec_frames, config.key_spectrogram_smooth_margin) {
1000 Ok(s) => s,
1001 Err(e) => {
1002 log::warn!("Key spectrogram time smoothing failed: {}, using raw spectrogram", e);
1003 key_spec_frames.clone()
1004 }
1005 }
1006 } else {
1007 key_spec_frames.clone()
1008 }
1009 } else {
1010 key_spec_frames.clone()
1011 };
1012
1013 let (use_log_freq, log_freq_spec, semitone_offset) = if config.enable_key_log_frequency && !spec_for_key.is_empty() {
1016 match convert_linear_to_log_frequency_spectrogram(
1017 &spec_for_key,
1018 sample_rate,
1019 key_fft_size,
1020 100.0,
1021 5000.0,
1022 ) {
1023 Ok(log_spec) => {
1024 let fmin: f32 = 100.0;
1026 let semitone_min: f32 = 12.0 * (fmin / 440.0).log2() + 57.0;
1027 let semitone_bin_min = semitone_min.floor() as i32;
1028 (true, log_spec, semitone_bin_min)
1029 }
1030 Err(e) => {
1031 log::warn!("Key log-frequency conversion failed: {}, falling back to linear", e);
1032 (false, vec![], 0)
1033 }
1034 }
1035 } else {
1036 (false, vec![], 0)
1037 };
1038
1039 let tuning_offset = if config.enable_key_tuning_compensation && !spec_for_key.is_empty() && !use_log_freq {
1042 match estimate_tuning_offset_semitones_from_spectrogram(
1043 &spec_for_key,
1044 sample_rate,
1045 key_fft_size,
1046 80.0,
1047 2000.0,
1048 config.key_tuning_frame_step,
1049 config.key_tuning_peak_rel_threshold,
1050 ) {
1051 Ok(d) => d.clamp(
1052 -config.key_tuning_max_abs_semitones.abs(),
1053 config.key_tuning_max_abs_semitones.abs(),
1054 ),
1055 Err(e) => {
1056 log::warn!("Key tuning estimation failed: {}", e);
1057 0.0
1058 }
1059 }
1060 } else {
1061 0.0
1062 };
1063
1064 let chroma_call = if config.enable_key_beat_synchronous && !beat_grid.beats.is_empty() && !use_log_freq {
1065 extract_beat_synchronous_chroma(
1067 &spec_for_key,
1068 sample_rate,
1069 key_fft_size,
1070 key_hop_size,
1071 &beat_grid.beats,
1072 config.soft_chroma_mapping,
1073 config.soft_mapping_sigma,
1074 tuning_offset,
1075 )
1076 } else if use_log_freq {
1077 extract_chroma_from_log_frequency_spectrogram(&log_freq_spec, semitone_offset)
1079 .map(|chroma_vecs| {
1080 let energies: Vec<f32> = log_freq_spec
1082 .iter()
1083 .map(|frame| frame.iter().map(|&x| x * x).sum())
1084 .collect();
1085 (chroma_vecs, energies)
1086 })
1087 } else if config.enable_key_hpcp {
1088 if config.enable_key_hpcp_bass_blend {
1089 extract_hpcp_bass_blend_from_spectrogram_with_options_and_energy_tuned(
1090 &spec_for_key,
1091 sample_rate,
1092 key_fft_size,
1093 config.soft_mapping_sigma,
1094 tuning_offset,
1095 config.key_hpcp_peaks_per_frame,
1096 config.key_hpcp_num_harmonics,
1097 config.key_hpcp_harmonic_decay,
1098 config.key_hpcp_mag_power,
1099 config.enable_key_hpcp_whitening,
1100 config.key_hpcp_whitening_smooth_bins,
1101 config.key_hpcp_bass_fmin_hz,
1102 config.key_hpcp_bass_fmax_hz,
1103 config.key_hpcp_bass_weight,
1104 )
1105 } else {
1106 extract_hpcp_from_spectrogram_with_options_and_energy_tuned(
1107 &spec_for_key,
1108 sample_rate,
1109 key_fft_size,
1110 config.soft_mapping_sigma,
1111 tuning_offset,
1112 config.key_hpcp_peaks_per_frame,
1113 config.key_hpcp_num_harmonics,
1114 config.key_hpcp_harmonic_decay,
1115 config.key_hpcp_mag_power,
1116 config.enable_key_hpcp_whitening,
1117 config.key_hpcp_whitening_smooth_bins,
1118 )
1119 }
1120 } else if config.enable_key_tuning_compensation && tuning_offset.abs() > 1e-6 {
1121 extract_chroma_from_spectrogram_with_options_and_energy_tuned(
1122 &spec_for_key,
1123 sample_rate,
1124 key_fft_size,
1125 config.soft_chroma_mapping,
1126 config.soft_mapping_sigma,
1127 tuning_offset,
1128 )
1129 } else {
1130 extract_chroma_from_spectrogram_with_options_and_energy(
1131 &spec_for_key,
1132 sample_rate,
1133 key_fft_size,
1134 config.soft_chroma_mapping,
1135 config.soft_mapping_sigma,
1136 )
1137 };
1138
1139 match chroma_call {
1140 Ok((mut chroma_vectors, frame_energies)) => {
1141 if config.chroma_sharpening_power > 1.0 {
1143 for chroma in &mut chroma_vectors {
1144 *chroma = sharpen_chroma(chroma, config.chroma_sharpening_power);
1145 }
1146 log::debug!("Applied chroma sharpening with power {:.2}", config.chroma_sharpening_power);
1147 }
1148
1149 if chroma_vectors.len() > 5 {
1151 chroma_vectors = smooth_chroma(&chroma_vectors, 5);
1152 }
1153
1154 let (chroma_slice, energy_slice): (&[Vec<f32>], &[f32]) = if config.enable_key_edge_trim
1157 && chroma_vectors.len() == frame_energies.len()
1158 && chroma_vectors.len() >= 200
1159 {
1160 let frac = config.key_edge_trim_fraction.clamp(0.0, 0.49);
1161 let n = chroma_vectors.len();
1162 let start = ((n as f32) * frac).round() as usize;
1163 let end = ((n as f32) * (1.0 - frac)).round() as usize;
1164 if end > start + 50 && end <= n {
1165 (&chroma_vectors[start..end], &frame_energies[start..end])
1166 } else {
1167 (&chroma_vectors[..], &frame_energies[..])
1168 }
1169 } else {
1170 (&chroma_vectors[..], &frame_energies[..])
1171 };
1172
1173 fn chroma_tonalness(chroma: &[f32]) -> f32 {
1175 let sum: f32 = chroma.iter().sum();
1176 if sum <= 1e-12 {
1177 return 0.0;
1178 }
1179 let mut entropy = 0.0f32;
1180 for &x in chroma {
1181 let p = x / sum;
1182 if p > 1e-12 {
1183 entropy -= p * p.ln();
1184 }
1185 }
1186 let max_entropy = (12.0f32).ln();
1187 let t = 1.0 - (entropy / max_entropy);
1188 t.clamp(0.0, 1.0)
1189 }
1190
1191 let mut frame_weights: Option<Vec<f32>> = if config.enable_key_frame_weighting
1192 && !energy_slice.is_empty()
1193 && energy_slice.len() == chroma_slice.len()
1194 {
1195 let mut sorted = energy_slice.to_vec();
1197 sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
1198 let median = sorted[sorted.len() / 2].max(1e-12);
1199
1200 let mut weights = Vec::with_capacity(chroma_slice.len());
1201 for (ch, &e) in chroma_slice.iter().zip(energy_slice.iter()) {
1202 let tonal = chroma_tonalness(ch);
1203 let tonal = if tonal < config.key_min_tonalness { 0.0 } else { tonal };
1204 let e_norm = (e / median).max(0.0);
1205 let w_t = tonal.powf(config.key_tonalness_power.max(0.0));
1206 let w_e = e_norm.powf(config.key_energy_power.max(0.0));
1207 weights.push((w_t * w_e).max(0.0));
1208 }
1209 Some(weights)
1210 } else {
1211 None
1212 };
1213
1214 if let Some(w) = frame_weights.as_ref() {
1216 let sum_w: f32 = w.iter().sum();
1217 let used = w.iter().filter(|&&x| x > 0.0).count();
1218 if sum_w <= 1e-12 || used < 10 {
1219 frame_weights = None;
1220 }
1221 }
1222
1223 let key_call: Result<KeyDetectionResult, AnalysisError> = if config.enable_key_ensemble {
1225 detect_key_ensemble(
1226 chroma_slice,
1227 frame_weights.as_deref(),
1228 config.key_ensemble_kk_weight,
1229 config.key_ensemble_temperley_weight,
1230 )
1231 } else {
1233 let templates = KeyTemplates::new_with_template_set(config.key_template_set);
1235
1236 if config.enable_key_multi_scale
1237 && !config.key_multi_scale_lengths.is_empty()
1238 && chroma_slice.len() >= *config.key_multi_scale_lengths.iter().min().unwrap_or(&1)
1239 {
1240 detect_key_multi_scale(
1241 chroma_slice,
1242 &templates,
1243 frame_weights.as_deref(),
1244 &config.key_multi_scale_lengths,
1245 config.key_multi_scale_hop.max(1),
1246 config.key_multi_scale_min_clarity.clamp(0.0, 1.0),
1247 if config.key_multi_scale_weights.is_empty() {
1248 None
1249 } else {
1250 Some(&config.key_multi_scale_weights)
1251 },
1252 config.enable_key_mode_heuristic,
1253 config.key_mode_third_ratio_margin,
1254 if config.enable_key_mode_heuristic {
1255 config.key_mode_flip_min_score_ratio
1256 } else {
1257 0.0
1258 },
1259 config.enable_key_minor_harmonic_bonus,
1260 config.key_minor_leading_tone_bonus_weight,
1261 )
1262 } else if config.enable_key_segment_voting
1264 && chroma_slice.len() >= config.key_segment_len_frames.max(1)
1265 && config.key_segment_len_frames >= 120
1266 && config.key_segment_hop_frames >= 1
1267 {
1268 let seg_len = config.key_segment_len_frames.min(chroma_slice.len());
1269 let hop = config.key_segment_hop_frames.min(seg_len).max(1);
1270 let min_clarity = config.key_segment_min_clarity.clamp(0.0, 1.0);
1271
1272 let mut acc_scores: Vec<(Key, f32)> = Vec::with_capacity(24);
1273 for k in 0..12 {
1275 acc_scores.push((Key::Major(k as u32), 0.0));
1276 }
1277 for k in 0..12 {
1278 acc_scores.push((Key::Minor(k as u32), 0.0));
1279 }
1280
1281 let mut used_segments = 0usize;
1282 let mut start = 0usize;
1283 while start + seg_len <= chroma_slice.len() {
1284 let seg = &chroma_slice[start..start + seg_len];
1285 let wseg = frame_weights
1286 .as_ref()
1287 .map(|w| &w[start..start + seg_len]);
1288 let seg_res = if config.enable_key_mode_heuristic || config.enable_key_minor_harmonic_bonus {
1289 detect_key_weighted_mode_heuristic(
1290 seg,
1291 &templates,
1292 wseg,
1293 config.key_mode_third_ratio_margin,
1294 if config.enable_key_mode_heuristic {
1295 config.key_mode_flip_min_score_ratio
1296 } else {
1297 0.0
1298 },
1299 config.enable_key_minor_harmonic_bonus,
1300 config.key_minor_leading_tone_bonus_weight,
1301 )?
1302 } else {
1303 detect_key_weighted(seg, &templates, wseg)?
1304 };
1305 let seg_clarity = compute_key_clarity(&seg_res.all_scores);
1306 if seg_clarity >= min_clarity {
1307 used_segments += 1;
1308 for (k, s) in seg_res.all_scores.iter() {
1310 if let Some((_kk, dst)) = acc_scores.iter_mut().find(|(kk, _)| kk == k) {
1311 *dst += *s * seg_clarity;
1312 }
1313 }
1314 }
1315 start += hop;
1316 }
1317
1318 if used_segments == 0 {
1319 if config.enable_key_mode_heuristic || config.enable_key_minor_harmonic_bonus {
1320 detect_key_weighted_mode_heuristic(
1321 chroma_slice,
1322 &templates,
1323 frame_weights.as_deref(),
1324 config.key_mode_third_ratio_margin,
1325 if config.enable_key_mode_heuristic {
1326 config.key_mode_flip_min_score_ratio
1327 } else {
1328 0.0
1329 },
1330 config.enable_key_minor_harmonic_bonus,
1331 config.key_minor_leading_tone_bonus_weight,
1332 )
1333 } else {
1334 detect_key_weighted(chroma_slice, &templates, frame_weights.as_deref())
1335 }
1336 } else {
1337 acc_scores.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap());
1339 let (best_key, best_score) = acc_scores[0];
1340 let second_score = if acc_scores.len() > 1 { acc_scores[1].1 } else { 0.0 };
1341 let confidence = if best_score > 0.0 {
1342 ((best_score - second_score) / best_score).max(0.0).min(1.0)
1343 } else {
1344 0.0
1345 };
1346 let top_n = 3usize.min(acc_scores.len());
1347 let top_keys = acc_scores.iter().take(top_n).cloned().collect::<Vec<_>>();
1348 Ok(KeyDetectionResult {
1349 key: best_key,
1350 confidence,
1351 all_scores: acc_scores,
1352 top_keys,
1353 })
1354 }
1355 } else if config.enable_key_mode_heuristic || config.enable_key_minor_harmonic_bonus {
1356 detect_key_weighted_mode_heuristic(
1357 chroma_slice,
1358 &templates,
1359 frame_weights.as_deref(),
1360 config.key_mode_third_ratio_margin,
1361 if config.enable_key_mode_heuristic {
1362 config.key_mode_flip_min_score_ratio
1363 } else {
1364 0.0
1365 },
1366 config.enable_key_minor_harmonic_bonus,
1367 config.key_minor_leading_tone_bonus_weight,
1368 )
1369 } else {
1370 detect_key_weighted(chroma_slice, &templates, frame_weights.as_deref())
1371 }
1372 };
1373
1374 match key_call {
1375 Ok(key_result) => {
1376 let clarity = compute_key_clarity(&key_result.all_scores);
1378
1379 log::debug!("Detected key: {:?}, confidence: {:.3}, clarity: {:.3}",
1380 key_result.key, key_result.confidence, clarity);
1381
1382 if let Some(track_id) = config.debug_track_id {
1384 let mut agg = vec![0.0f32; 12];
1386 let mut used = 0usize;
1387 for (idx, ch) in chroma_slice.iter().enumerate() {
1388 let w = frame_weights
1389 .as_ref()
1390 .and_then(|v| v.get(idx).copied())
1391 .unwrap_or(1.0);
1392 if w <= 0.0 {
1393 continue;
1394 }
1395 used += 1;
1396 for i in 0..12 {
1397 agg[i] += w * ch[i];
1398 }
1399 }
1400 let sum_agg: f32 = agg.iter().sum();
1401 if sum_agg > 1e-12 {
1402 for x in agg.iter_mut() {
1403 *x /= sum_agg;
1404 }
1405 }
1406 let mut pcs: Vec<(usize, f32)> = agg.iter().cloned().enumerate().collect();
1407 pcs.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
1408
1409 eprintln!("\n=== DEBUG key (track_id={}) ===", track_id);
1410 eprintln!(
1411 "key={} conf={:.4} clarity={:.4} frames={} used_frames={} soft_mapping={} sigma={:.3} harmonic_mask={} mask_p={:.2} tuning={:.4} time_smooth={} margin={} edge_trim={} trim_frac={:.2}",
1412 key_result.key.name(),
1413 key_result.confidence,
1414 clarity,
1415 chroma_slice.len(),
1416 used,
1417 config.soft_chroma_mapping,
1418 config.soft_mapping_sigma,
1419 config.enable_key_harmonic_mask,
1420 config.key_harmonic_mask_power,
1421 tuning_offset,
1422 config.enable_key_spectrogram_time_smoothing,
1423 config.key_spectrogram_smooth_margin,
1424 config.enable_key_edge_trim,
1425 config.key_edge_trim_fraction
1426 );
1427 eprintln!(
1428 "top_keys: {}",
1429 key_result
1430 .top_keys
1431 .iter()
1432 .map(|(k, s)| format!("{}:{:.4}", k.name(), s))
1433 .collect::<Vec<_>>()
1434 .join(", ")
1435 );
1436 let note_names = ["C","C#","D","D#","E","F","F#","G","G#","A","A#","B"];
1437 eprintln!(
1438 "top_pitch_classes(weighted): {}",
1439 pcs.iter()
1440 .take(6)
1441 .map(|(i, v)| format!("{}:{:.3}", note_names[*i], v))
1442 .collect::<Vec<_>>()
1443 .join(", ")
1444 );
1445 }
1446
1447 (key_result.key, key_result.confidence, clarity)
1448 }
1449 Err(e) => {
1450 log::warn!("Key detection failed: {}, using default", e);
1451 (Key::Major(0), 0.0, 0.0)
1452 }
1453 }
1454 }
1455 Err(e) => {
1456 log::warn!("Chroma extraction failed: {}, using default key", e);
1457 (Key::Major(0), 0.0, 0.0)
1458 }
1459 }
1460 } else {
1461 log::debug!("Skipping key detection: insufficient samples (need at least {} samples)",
1462 config.frame_size);
1463 (Key::Major(0), 0.0, 0.0)
1464 };
1465
1466 let processing_time_ms = start_time.elapsed().as_secs_f32() * 1000.0;
1467
1468 let mut confidence_warnings = Vec::new();
1470 let mut flags = Vec::new();
1471
1472 if bpm == 0.0 {
1473 confidence_warnings.push("BPM detection failed: insufficient onsets or estimation error".to_string());
1474 }
1475 if grid_stability < 0.5 {
1476 confidence_warnings.push(format!("Low beat grid stability: {:.2} (may indicate tempo variation)", grid_stability));
1477 }
1478 if key_confidence < 0.3 {
1479 confidence_warnings.push(format!("Low key detection confidence: {:.2} (may indicate ambiguous or atonal music)", key_confidence));
1480 }
1481 if key_clarity < 0.2 {
1482 confidence_warnings.push(format!("Low key clarity: {:.2} (track may be atonal or have weak tonality)", key_clarity));
1483 flags.push(crate::analysis::result::AnalysisFlag::WeakTonality);
1484 }
1485
1486 let result = AnalysisResult {
1488 bpm,
1489 bpm_confidence,
1490 key,
1491 key_confidence,
1492 key_clarity,
1493 beat_grid,
1494 grid_stability,
1495 metadata: AnalysisMetadata {
1496 duration_seconds: trimmed_samples.len() as f32 / sample_rate as f32,
1497 sample_rate,
1498 processing_time_ms,
1499 algorithm_version: "0.1.0-alpha".to_string(),
1500 onset_method_consensus: if energy_onsets.is_empty() { 0.0 } else { 1.0 },
1501 methods_used: vec!["energy_flux".to_string(), "chroma_extraction".to_string(), "key_detection".to_string()],
1502 flags,
1503 confidence_warnings,
1504 tempogram_candidates,
1505 tempogram_multi_res_triggered,
1506 tempogram_multi_res_used,
1507 tempogram_percussive_triggered,
1508 tempogram_percussive_used,
1509 },
1510 };
1511
1512 use analysis::confidence::compute_confidence;
1514 let confidence = compute_confidence(&result);
1515 log::debug!(
1516 "Analysis complete: BPM={:.2} (conf={:.3}), Key={:?} (conf={:.3}), Overall confidence={:.3}",
1517 result.bpm,
1518 confidence.bpm_confidence,
1519 result.key,
1520 confidence.key_confidence,
1521 confidence.overall_confidence
1522 );
1523
1524 Ok(result)
1526}
1527