1use crate::decoder::{DecodeCancelToken, HttpCredentials, StreamingDecoder};
8use crate::processor::LoudnessMeter;
9use rustfft::{num_complex::Complex32, FftPlanner};
10use serde::{Deserialize, Serialize};
11
12const ANALYSIS_VERSION: u32 = 1;
13const DEFAULT_MAX_ANALYZE_TIME_SEC: f64 = 60.0;
14const MIN_ANALYZE_TIME_SEC: f64 = 5.0;
15const MAX_ANALYZE_TIME_SEC: f64 = 300.0;
16const ENVELOPE_RATE: f64 = 50.0;
17const WINDOW_SIZE_MS: usize = 20;
18const SILENCE_THRESHOLD_DB: f32 = -48.0;
19const BPM_MIN_LAG: usize = 15;
20const BPM_MAX_LAG: usize = 55;
21const FFT_SIZE: usize = 1024;
22
23#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)]
24#[serde(rename_all = "snake_case")]
25#[derive(Default)]
26pub enum AutomixAnalysisMode {
27 Head,
28 #[default]
29 Full,
30}
31
32impl AutomixAnalysisMode {
33 pub fn includes_tail(self) -> bool {
34 matches!(self, Self::Full)
35 }
36}
37
38#[derive(Clone, Debug, Serialize)]
39pub struct AutomixAnalysis {
40 pub version: u32,
41 pub mode: AutomixAnalysisMode,
42 pub duration: f64,
43 pub analyze_window: f64,
44 pub bpm: Option<f64>,
45 pub bpm_confidence: Option<f64>,
46 pub first_beat_pos: Option<f64>,
47 pub loudness: Option<f64>,
48 pub true_peak_dbtp: Option<f64>,
49 pub fade_in_pos: f64,
50 pub fade_out_pos: f64,
51 pub cut_in_pos: Option<f64>,
52 pub cut_out_pos: Option<f64>,
53 pub mix_center_pos: f64,
54 pub mix_start_pos: f64,
55 pub mix_end_pos: f64,
56 pub energy_profile: Vec<f64>,
57 pub drop_pos: Option<f64>,
58 pub vocal_in_pos: Option<f64>,
59 pub vocal_out_pos: Option<f64>,
60 pub vocal_last_in_pos: Option<f64>,
61 pub outro_energy_level: Option<f64>,
62 pub key_root: Option<i32>,
63 pub key_mode: Option<i32>,
64 pub key_confidence: Option<f64>,
65 pub camelot_key: Option<String>,
66}
67
68#[derive(Clone, Debug)]
69pub struct AutomixAnalysisOptions {
70 pub mode: AutomixAnalysisMode,
71 pub max_analyze_time_sec: f64,
72}
73
74impl Default for AutomixAnalysisOptions {
75 fn default() -> Self {
76 Self {
77 mode: AutomixAnalysisMode::Full,
78 max_analyze_time_sec: DEFAULT_MAX_ANALYZE_TIME_SEC,
79 }
80 }
81}
82
83impl AutomixAnalysisOptions {
84 pub fn normalized(mut self) -> Self {
85 if !self.max_analyze_time_sec.is_finite() {
86 self.max_analyze_time_sec = DEFAULT_MAX_ANALYZE_TIME_SEC;
87 }
88 self.max_analyze_time_sec = self
89 .max_analyze_time_sec
90 .clamp(MIN_ANALYZE_TIME_SEC, MAX_ANALYZE_TIME_SEC);
91 self
92 }
93}
94
95#[derive(Default)]
96struct AnalysisSegment {
97 envelope: Vec<f32>,
98 low_envelope: Vec<f32>,
99 vocal_ratio: Vec<f32>,
100 spectral_flux: Vec<f32>,
101}
102
103struct EnvelopeAccumulator {
104 sum_sq: f32,
105 count: usize,
106 window_size: usize,
107}
108
109impl EnvelopeAccumulator {
110 fn new(window_size: usize) -> Self {
111 Self {
112 sum_sq: 0.0,
113 count: 0,
114 window_size: window_size.max(1),
115 }
116 }
117
118 fn process(&mut self, sample: f32) -> Option<f32> {
119 self.sum_sq += sample * sample;
120 self.count += 1;
121 if self.count >= self.window_size {
122 let rms = (self.sum_sq / self.window_size as f32).sqrt();
123 self.sum_sq = 0.0;
124 self.count = 0;
125 Some(rms)
126 } else {
127 None
128 }
129 }
130}
131
132struct FirstOrderFilter {
133 prev_x: f32,
134 prev_y: f32,
135 alpha: f32,
136 high_pass: bool,
137}
138
139impl FirstOrderFilter {
140 fn new(sample_rate: u32, cutoff_hz: f32, high_pass: bool) -> Self {
141 let dt = 1.0 / sample_rate.max(1) as f32;
142 let rc = 1.0 / (2.0 * std::f32::consts::PI * cutoff_hz);
143 let alpha = if high_pass {
144 rc / (rc + dt)
145 } else {
146 dt / (rc + dt)
147 };
148 Self {
149 prev_x: 0.0,
150 prev_y: 0.0,
151 alpha,
152 high_pass,
153 }
154 }
155
156 fn process(&mut self, x: f32) -> f32 {
157 let y = if self.high_pass {
158 self.alpha * (self.prev_y + x - self.prev_x)
159 } else {
160 self.prev_y + self.alpha * (x - self.prev_y)
161 };
162 self.prev_x = x;
163 self.prev_y = y;
164 y
165 }
166}
167
168struct SpectralFluxAccumulator {
169 frame: Vec<Complex32>,
170 previous_magnitudes: Vec<f32>,
171 scratch: Vec<f32>,
172 pos: usize,
173 fft: std::sync::Arc<dyn rustfft::Fft<f32>>,
174}
175
176impl SpectralFluxAccumulator {
177 fn new() -> Self {
178 let mut planner = FftPlanner::<f32>::new();
179 let fft = planner.plan_fft_forward(FFT_SIZE);
180 Self {
181 frame: vec![Complex32::new(0.0, 0.0); FFT_SIZE],
182 previous_magnitudes: vec![0.0; FFT_SIZE / 2],
183 scratch: vec![0.0; FFT_SIZE],
184 pos: 0,
185 fft,
186 }
187 }
188
189 fn process(&mut self, sample: f32) -> Option<f32> {
190 self.scratch[self.pos] = sample;
191 self.pos += 1;
192 if self.pos < FFT_SIZE {
193 return None;
194 }
195
196 for i in 0..FFT_SIZE {
197 let window =
198 0.5 - 0.5 * (2.0 * std::f32::consts::PI * i as f32 / (FFT_SIZE - 1) as f32).cos();
199 self.frame[i] = Complex32::new(self.scratch[i] * window, 0.0);
200 }
201 self.fft.process(&mut self.frame);
202
203 let mut flux = 0.0;
204 for i in 0..FFT_SIZE / 2 {
205 let mag = self.frame[i].norm();
206 flux += (mag - self.previous_magnitudes[i]).max(0.0);
207 self.previous_magnitudes[i] = mag;
208 }
209
210 self.scratch.copy_within(FFT_SIZE / 2..FFT_SIZE, 0);
211 self.pos = FFT_SIZE / 2;
212 Some(flux / (FFT_SIZE / 2) as f32)
213 }
214}
215
216pub fn analyze_automix(
217 path: String,
218 credentials: Option<HttpCredentials>,
219 options: AutomixAnalysisOptions,
220) -> Result<AutomixAnalysis, String> {
221 analyze_automix_with_cancel(path, credentials, options, None)
222}
223
224pub fn analyze_automix_with_cancel(
225 path: String,
226 credentials: Option<HttpCredentials>,
227 options: AutomixAnalysisOptions,
228 cancel_token: Option<DecodeCancelToken>,
229) -> Result<AutomixAnalysis, String> {
230 let options = options.normalized();
231 check_cancel(cancel_token.as_ref())?;
232 let mut decoder = StreamingDecoder::open_with_credentials_and_cancel(
233 &path,
234 credentials.as_ref(),
235 cancel_token.clone(),
236 )
237 .map_err(|e| format!("Failed to open file for AutoMix analysis: {}", e))?;
238
239 let sample_rate = decoder.info.sample_rate;
240 let channels = decoder.info.channels.max(1);
241 let duration = decoder.info.duration_secs.unwrap_or(0.0);
242 let mut meter = LoudnessMeter::new(channels, sample_rate);
243 let mut head = AnalysisSegment::default();
244 let mut tail = AnalysisSegment::default();
245
246 decode_segment(
247 &mut decoder,
248 &mut meter,
249 &mut head,
250 options.max_analyze_time_sec,
251 cancel_token.as_ref(),
252 )?;
253
254 if options.mode.includes_tail() && duration > options.max_analyze_time_sec * 2.0 {
255 check_cancel(cancel_token.as_ref())?;
256 decoder
257 .seek((duration - options.max_analyze_time_sec).max(0.0))
258 .map_err(|e| format!("Failed to seek tail for AutoMix analysis: {}", e))?;
259 decode_segment(
260 &mut decoder,
261 &mut meter,
262 &mut tail,
263 options.max_analyze_time_sec,
264 cancel_token.as_ref(),
265 )?;
266 }
267
268 Ok(finalize_analysis(
269 options.mode,
270 options.max_analyze_time_sec,
271 duration,
272 &meter,
273 &head,
274 &tail,
275 ))
276}
277
278fn decode_segment(
279 decoder: &mut StreamingDecoder,
280 meter: &mut LoudnessMeter,
281 segment: &mut AnalysisSegment,
282 max_time_sec: f64,
283 cancel_token: Option<&DecodeCancelToken>,
284) -> Result<(), String> {
285 let sample_rate = decoder.info.sample_rate;
286 let channels = decoder.info.channels.max(1);
287 let max_frames = (sample_rate as f64 * max_time_sec).ceil() as usize;
288 let window_size = (sample_rate as usize * WINDOW_SIZE_MS / 1000).max(1);
289 let mut frames_processed = 0usize;
290 let mut chunk = Vec::with_capacity(window_size * channels);
291 let mut env_acc = EnvelopeAccumulator::new(window_size);
292 let mut low_acc = EnvelopeAccumulator::new(window_size);
293 let mut vocal_acc = EnvelopeAccumulator::new(window_size);
294 let mut low_filter = FirstOrderFilter::new(sample_rate, 150.0, false);
295 let mut vocal_lowpass = FirstOrderFilter::new(sample_rate, 3_000.0, false);
296 let mut vocal_highpass = FirstOrderFilter::new(sample_rate, 200.0, true);
297 let mut spectral = SpectralFluxAccumulator::new();
298
299 while frames_processed < max_frames {
300 check_cancel(cancel_token)?;
301 chunk.clear();
302 let Some(sample_count) = decoder
303 .decode_next_into(&mut chunk)
304 .map_err(|e| e.to_string())?
305 else {
306 break;
307 };
308 if sample_count == 0 {
309 continue;
310 }
311
312 meter.process(&chunk);
313
314 for frame in chunk.chunks_exact(channels) {
315 if frames_processed >= max_frames {
316 break;
317 }
318 let mono = (frame.iter().sum::<f64>() / channels as f64) as f32;
319 let low = low_filter.process(mono);
320 let vocal = vocal_lowpass.process(vocal_highpass.process(mono));
321
322 if let Some(rms) = env_acc.process(mono) {
323 segment.envelope.push(rms);
324 }
325 if let Some(rms) = low_acc.process(low) {
326 segment.low_envelope.push(rms);
327 }
328 if let Some(rms) = vocal_acc.process(vocal) {
329 let base = segment.envelope.last().copied().unwrap_or(1.0);
330 segment
331 .vocal_ratio
332 .push(if base > 0.0001 { rms / base } else { 0.0 });
333 }
334 if let Some(flux) = spectral.process(mono) {
335 segment.spectral_flux.push(flux);
336 }
337 frames_processed += 1;
338 }
339 }
340
341 Ok(())
342}
343
344fn check_cancel(cancel_token: Option<&DecodeCancelToken>) -> Result<(), String> {
345 if cancel_token.is_some_and(DecodeCancelToken::is_cancelled) {
346 Err("Analysis task canceled".to_string())
347 } else {
348 Ok(())
349 }
350}
351
352fn finalize_analysis(
353 mode: AutomixAnalysisMode,
354 analyze_window: f64,
355 duration: f64,
356 meter: &LoudnessMeter,
357 head: &AnalysisSegment,
358 tail: &AnalysisSegment,
359) -> AutomixAnalysis {
360 let effective_duration = if duration.is_finite() && duration > 0.0 {
361 duration
362 } else {
363 head.envelope.len() as f64 / ENVELOPE_RATE
364 };
365 let (fade_in, fade_out) = detect_silence(
366 &head.envelope,
367 if mode.includes_tail() {
368 &tail.envelope
369 } else {
370 &[]
371 },
372 effective_duration,
373 ENVELOPE_RATE,
374 SILENCE_THRESHOLD_DB,
375 );
376 let (bpm, bpm_confidence, first_beat) = detect_bpm(
377 if head.spectral_flux.len() >= 100 {
378 &head.spectral_flux
379 } else {
380 &head.envelope
381 },
382 ENVELOPE_RATE,
383 );
384 let drop_pos = detect_drop(&head.envelope, ENVELOPE_RATE);
385 let (vocal_in, vocal_out, vocal_last_in) = detect_vocals(
386 &head.envelope,
387 &head.vocal_ratio,
388 if mode.includes_tail() {
389 &tail.envelope
390 } else {
391 &[]
392 },
393 if mode.includes_tail() {
394 &tail.vocal_ratio
395 } else {
396 &[]
397 },
398 effective_duration,
399 ENVELOPE_RATE,
400 fade_in,
401 fade_out,
402 );
403 let cut_in = calculate_smart_cut_in(
404 bpm,
405 first_beat,
406 bpm_confidence,
407 vocal_in.or(drop_pos),
408 fade_in,
409 );
410 let cut_out = if mode.includes_tail() {
411 Some(calculate_smart_cut_out(
412 bpm,
413 first_beat,
414 bpm_confidence,
415 vocal_out,
416 fade_out,
417 effective_duration,
418 ))
419 } else {
420 None
421 };
422 let mix_center = cut_out.unwrap_or(fade_out).min(effective_duration);
423 let mix_duration = bpm.map_or(20.0, |b| (240.0 / b * 8.0).clamp(15.0, 30.0));
424 let mix_start = (mix_center - mix_duration / 2.0).max(0.0);
425 let mix_end = (mix_center + mix_duration / 2.0).min(effective_duration);
426 let energy_profile = build_energy_profile(
427 &head.envelope,
428 if mode.includes_tail() {
429 &tail.envelope
430 } else {
431 &[]
432 },
433 effective_duration,
434 );
435 let loudness = finite_measurement(meter.integrated_loudness());
436 let true_peak_dbtp = finite_measurement(meter.true_peak());
437
438 AutomixAnalysis {
439 version: ANALYSIS_VERSION,
440 mode,
441 duration: effective_duration,
442 analyze_window,
443 bpm,
444 bpm_confidence,
445 first_beat_pos: first_beat,
446 loudness,
447 true_peak_dbtp,
448 fade_in_pos: fade_in,
449 fade_out_pos: if mode.includes_tail() {
450 fade_out
451 } else {
452 effective_duration
453 },
454 cut_in_pos: Some(cut_in),
455 cut_out_pos: cut_out,
456 mix_center_pos: mix_center,
457 mix_start_pos: mix_start,
458 mix_end_pos: mix_end,
459 energy_profile,
460 drop_pos,
461 vocal_in_pos: vocal_in,
462 vocal_out_pos: if mode.includes_tail() {
463 vocal_out
464 } else {
465 None
466 },
467 vocal_last_in_pos: if mode.includes_tail() {
468 vocal_last_in
469 } else {
470 None
471 },
472 outro_energy_level: if mode.includes_tail() {
473 calculate_outro_energy(&tail.envelope, ENVELOPE_RATE)
474 } else {
475 None
476 },
477 key_root: None,
478 key_mode: None,
479 key_confidence: None,
480 camelot_key: None,
481 }
482}
483
484pub fn detect_silence(
485 head: &[f32],
486 tail: &[f32],
487 duration: f64,
488 rate: f64,
489 db_thresh: f32,
490) -> (f64, f64) {
491 let threshold = 10.0_f32.powf(db_thresh / 20.0);
492 let fade_in = head
493 .iter()
494 .position(|value| *value > threshold)
495 .map_or(0.0, |idx| idx as f64 / rate);
496
497 let fade_out = if tail.is_empty() {
498 head.iter()
499 .rposition(|value| *value > threshold)
500 .map_or(duration, |idx| (idx + 1) as f64 / rate)
501 .min(duration)
502 } else {
503 let tail_duration = tail.len() as f64 / rate;
504 let tail_start = (duration - tail_duration).max(0.0);
505 tail.iter()
506 .rposition(|value| *value > threshold)
507 .map_or(duration, |idx| tail_start + (idx + 1) as f64 / rate)
508 .min(duration)
509 };
510
511 (fade_in, fade_out)
512}
513
514pub fn detect_bpm(values: &[f32], rate: f64) -> (Option<f64>, Option<f64>, Option<f64>) {
515 if values.len() < 110 || !rate.is_finite() || rate <= 0.0 {
516 return (None, None, None);
517 }
518
519 let flux: Vec<f32> = values
520 .windows(2)
521 .map(|window| (window[1] - window[0]).max(0.0))
522 .collect();
523 let flux_energy = flux.iter().map(|value| value * value).sum::<f32>();
524 if flux_energy <= 1.0e-6 {
525 return (None, None, None);
526 }
527
528 let max_lag = BPM_MAX_LAG.min(flux.len().saturating_sub(1));
529 let mut best_corr = 0.0_f32;
530 let mut best_lag = 0usize;
531 let mut corr_sum = 0.0_f32;
532 let mut corr_count = 0usize;
533
534 for lag in BPM_MIN_LAG..=max_lag {
535 let mut sum = 0.0;
536 for idx in 0..flux.len() - lag {
537 sum += flux[idx] * flux[idx + lag];
538 }
539 let normalized = sum / (flux.len() - lag) as f32;
540 corr_sum += normalized;
541 corr_count += 1;
542 if normalized > best_corr {
543 best_corr = normalized;
544 best_lag = lag;
545 }
546 }
547
548 if best_lag == 0 || best_corr <= 1.0e-5 {
549 return (None, None, None);
550 }
551
552 let average_corr = if corr_count == 0 {
553 0.0
554 } else {
555 corr_sum / corr_count as f32
556 };
557 let confidence = ((best_corr - average_corr).max(0.0) / best_corr.max(1.0e-6)).clamp(0.0, 1.0);
558 if confidence < 0.12 {
559 return (None, Some(confidence as f64), None);
560 }
561
562 let first_beat = (0..best_lag)
563 .max_by(|a, b| {
564 phase_energy(&flux, *a, best_lag)
565 .partial_cmp(&phase_energy(&flux, *b, best_lag))
566 .unwrap_or(std::cmp::Ordering::Equal)
567 })
568 .map(|phase| phase as f64 / rate);
569
570 (
571 Some(60.0 / (best_lag as f64 / rate)),
572 Some(confidence as f64),
573 first_beat,
574 )
575}
576
577fn phase_energy(flux: &[f32], phase: usize, lag: usize) -> f32 {
578 let mut energy = 0.0;
579 let mut idx = phase;
580 while idx < flux.len() {
581 energy += flux[idx];
582 idx += lag;
583 }
584 energy
585}
586
587fn detect_drop(envelope: &[f32], rate: f64) -> Option<f64> {
588 let window_len = (2.0 * rate) as usize;
589 let prev_len = (4.0 * rate) as usize;
590 if envelope.len() < window_len + prev_len {
591 return None;
592 }
593
594 let mut best_ratio = 0.0;
595 let mut best_idx = 0usize;
596 for idx in prev_len..envelope.len().saturating_sub(window_len) {
597 let prev_avg = mean(&envelope[idx - prev_len..idx]);
598 let next_avg = mean(&envelope[idx..idx + window_len]);
599 if prev_avg > 0.001 {
600 let ratio = next_avg / prev_avg;
601 if ratio > best_ratio {
602 best_ratio = ratio;
603 best_idx = idx;
604 }
605 }
606 }
607
608 (best_ratio > 1.5).then_some(best_idx as f64 / rate)
609}
610
611#[allow(clippy::too_many_arguments)]
612fn detect_vocals(
613 head_env: &[f32],
614 head_ratio: &[f32],
615 tail_env: &[f32],
616 tail_ratio: &[f32],
617 duration: f64,
618 rate: f64,
619 fade_in: f64,
620 fade_out: f64,
621) -> (Option<f64>, Option<f64>, Option<f64>) {
622 let is_vocal = |ratio: f32, env: f32| ratio > 0.4 && env > 0.02;
623 let vocal_in = head_ratio
624 .iter()
625 .zip(head_env.iter())
626 .enumerate()
627 .skip((fade_in * rate) as usize)
628 .find(|(_, (ratio, env))| is_vocal(**ratio, **env))
629 .map(|(idx, _)| idx as f64 / rate);
630
631 let (scan_env, scan_ratio, base_time) = if tail_env.is_empty() {
632 (head_env, head_ratio, 0.0)
633 } else {
634 (
635 tail_env,
636 tail_ratio,
637 (duration - tail_env.len() as f64 / rate).max(0.0),
638 )
639 };
640 let limit = ((fade_out - base_time) * rate).max(0.0) as usize;
641 let vocal_out = scan_ratio
642 .iter()
643 .zip(scan_env.iter())
644 .take(limit.min(scan_env.len()))
645 .enumerate()
646 .rfind(|(_, (ratio, env))| is_vocal(**ratio, **env))
647 .map(|(idx, _)| base_time + idx as f64 / rate);
648
649 let vocal_last_in = vocal_out.map(|value| (value - 5.0).max(fade_in));
650 (vocal_in, vocal_out, vocal_last_in)
651}
652
653fn calculate_smart_cut_in(
654 bpm: Option<f64>,
655 first_beat: Option<f64>,
656 confidence: Option<f64>,
657 anchor: Option<f64>,
658 fade_in: f64,
659) -> f64 {
660 let anchor = anchor.unwrap_or(fade_in);
661 if let (Some(bpm), Some(first_beat)) = (bpm, first_beat) {
662 if confidence.unwrap_or(0.0) > 0.4 {
663 let sec_per_bar = 240.0 / bpm;
664 for bars in [32.0_f64, 16.0, 8.0] {
665 let time = anchor - bars * sec_per_bar;
666 if time > fade_in {
667 return snap_time(time, bpm, first_beat, 4.0);
668 }
669 }
670 }
671 }
672 fade_in
673}
674
675fn calculate_smart_cut_out(
676 bpm: Option<f64>,
677 first_beat: Option<f64>,
678 confidence: Option<f64>,
679 vocal_out: Option<f64>,
680 fade_out: f64,
681 duration: f64,
682) -> f64 {
683 let search_end = vocal_out.map_or(fade_out, |value| (value + 40.0).min(fade_out));
684 if let (Some(bpm), Some(first_beat)) = (bpm, first_beat) {
685 if confidence.unwrap_or(0.0) > 0.4 {
686 let snapped = snap_time(search_end, bpm, first_beat, 4.0);
687 if let Some(vocal_out) = vocal_out {
688 if snapped < vocal_out + 2.0 {
689 return snap_time(vocal_out + 4.0, bpm, first_beat, 4.0).min(duration);
690 }
691 }
692 return snapped.min(duration);
693 }
694 }
695 search_end
696}
697
698fn snap_time(time: f64, bpm: f64, first_beat: f64, grid: f64) -> f64 {
699 let grid_sec = 60.0 / bpm * grid;
700 if grid_sec <= 0.0 {
701 return time;
702 }
703 let units = ((time - first_beat) / grid_sec).round();
704 (first_beat + units * grid_sec).max(0.0)
705}
706
707fn build_energy_profile(head: &[f32], tail: &[f32], duration: f64) -> Vec<f64> {
708 let profile_rate = 10.0;
709 let len = ((duration * profile_rate).ceil() as usize).max(1);
710 let mut profile = vec![0.0; len];
711 fill_energy_profile(&mut profile, head, 0.0, ENVELOPE_RATE, profile_rate);
712 if !tail.is_empty() {
713 let tail_start = (duration - tail.len() as f64 / ENVELOPE_RATE).max(0.0);
714 fill_energy_profile(&mut profile, tail, tail_start, ENVELOPE_RATE, profile_rate);
715 }
716 profile
717}
718
719fn fill_energy_profile(
720 profile: &mut [f64],
721 envelope: &[f32],
722 start_time: f64,
723 env_rate: f64,
724 profile_rate: f64,
725) {
726 for (idx, value) in envelope.iter().enumerate() {
727 let profile_idx = ((start_time + idx as f64 / env_rate) * profile_rate) as usize;
728 if let Some(slot) = profile.get_mut(profile_idx) {
729 *slot = slot.max(f64::from(*value));
730 }
731 }
732}
733
734fn calculate_outro_energy(tail: &[f32], rate: f64) -> Option<f64> {
735 if tail.is_empty() {
736 return None;
737 }
738 let (_, local_out) = detect_silence(
739 tail,
740 &[],
741 tail.len() as f64 / rate,
742 rate,
743 SILENCE_THRESHOLD_DB,
744 );
745 let end = (local_out * rate) as usize;
746 let start = end.saturating_sub((10.0 * rate) as usize);
747 if end <= start || end > tail.len() {
748 return None;
749 }
750 let rms = mean_square(&tail[start..end]).sqrt();
751 Some(if rms > 0.0 {
752 f64::from(20.0 * rms.log10())
753 } else {
754 -70.0
755 })
756}
757
758fn finite_measurement(value: f64) -> Option<f64> {
759 value.is_finite().then_some(value)
760}
761
762fn mean(values: &[f32]) -> f32 {
763 if values.is_empty() {
764 0.0
765 } else {
766 values.iter().sum::<f32>() / values.len() as f32
767 }
768}
769
770fn mean_square(values: &[f32]) -> f32 {
771 if values.is_empty() {
772 0.0
773 } else {
774 values.iter().map(|value| value * value).sum::<f32>() / values.len() as f32
775 }
776}
777
778#[cfg(test)]
779mod tests {
780 use super::*;
781
782 #[test]
783 fn silence_detection_uses_head_and_tail_windows() {
784 let mut head = vec![0.0; 50];
785 head.extend(vec![0.02; 100]);
786 let mut tail = vec![0.02; 100];
787 tail.extend(vec![0.0; 50]);
788
789 let (fade_in, fade_out) = detect_silence(&head, &tail, 20.0, 50.0, -48.0);
790
791 assert!((fade_in - 1.0).abs() < 0.001);
792 assert!((fade_out - 19.0).abs() < 0.001);
793 }
794
795 #[test]
796 fn bpm_detection_returns_structured_low_confidence_for_flat_signal() {
797 let values = vec![0.01; 160];
798 let (bpm, confidence, first_beat) = detect_bpm(&values, 50.0);
799
800 assert!(bpm.is_none());
801 assert!(confidence.is_none());
802 assert!(first_beat.is_none());
803 }
804
805 #[test]
806 fn bpm_detection_finds_regular_pulse_train() {
807 let mut values = vec![0.0; 300];
808 for idx in (0..values.len()).step_by(25) {
809 values[idx] = 1.0;
810 }
811
812 let (bpm, confidence, first_beat) = detect_bpm(&values, 50.0);
813
814 assert!(bpm.is_some_and(|value| (value - 120.0).abs() < 0.1));
815 assert!(confidence.is_some_and(|value| value > 0.12));
816 assert!(first_beat.is_some());
817 }
818}