Skip to main content

audio_engine_core/processor/
automix_analysis.rs

1//! AutoMix offline audio analysis.
2//!
3//! This module is intentionally pure/backend-side. It decodes bounded head/tail
4//! windows off the realtime callback path and returns a stable DTO for later
5//! transition planning.
6
7use 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}