Skip to main content

sva_analysis/
lib.rs

1// Concern: dispatches a name to one post-hoc analysis over already-rendered buffers | Non-concern: rendering them (sva-engine), argv (sva-cli) | IO: (name, Request) -> a JSON fragment or AnalysisError
2
3mod decibels;
4pub mod experimental;
5mod frame;
6mod json;
7pub mod stable;
8
9use sva_samples::{EnvelopeFrame, StereoImage};
10
11pub use decibels::to_db;
12
13/// Every name this crate answers `--as` for, once `sva-core::REPRESENTATIONS` has said no.
14pub const ANALYSES: [&str; 4] = ["onsets", "trajectory", "masking", "gain-reduction"];
15
16#[derive(Debug, PartialEq)]
17pub struct AnalysisError(pub String);
18
19#[derive(Clone, Copy, Debug, PartialEq)]
20pub struct TempoGrid {
21    pub seconds_per_bar: f64,
22    pub beats_per_bar: f64,
23}
24
25/// Every field an analysis might read; which ones a given `name` actually uses is that
26/// analysis's own concern, not this struct's.
27pub struct Request<'a> {
28    pub samples: &'a [f32],
29    pub sample_rate: f64,
30    pub start_secs: f64,
31    pub frame_secs: f64,
32    pub tempo: Option<TempoGrid>,
33    pub envelope: &'a [EnvelopeFrame],
34    pub stereo: Option<&'a StereoImage>,
35    pub against: Option<&'a [f32]>,
36    pub gated: bool,
37    pub input_envelope: Option<&'a [EnvelopeFrame]>,
38}
39
40/// One flat match, no registration: a name past `sva-core::REPRESENTATIONS` lands here.
41pub fn run(name: &str, req: &Request) -> Result<String, AnalysisError> {
42    match name {
43        "onsets" => {
44            let o =
45                stable::onsets::detect(req.samples, req.sample_rate, req.start_secs, req.tempo)?;
46            Ok(json::onsets(&o))
47        }
48        "trajectory" => {
49            let t = stable::trajectory::analyze(
50                req.envelope,
51                req.stereo,
52                req.samples,
53                req.sample_rate,
54                req.frame_secs,
55            );
56            Ok(json::trajectory(&t))
57        }
58        "masking" => {
59            let against = req.against.ok_or_else(|| {
60                AnalysisError("`masking` needs a second, `--against` node".to_string())
61            })?;
62            let gate = req
63                .gated
64                .then(|| {
65                    stable::onsets::detect(req.samples, req.sample_rate, req.start_secs, req.tempo)
66                })
67                .transpose()?;
68            experimental::masking::analyze(
69                req.samples,
70                against,
71                req.sample_rate,
72                req.start_secs,
73                gate.as_ref().map(|o| o.onsets.as_slice()),
74            )
75            .map(|m| json::masking(&m))
76        }
77        "gain-reduction" => {
78            let g = experimental::gain_reduction::analyze(req.envelope, req.input_envelope);
79            Ok(json::gain_reduction(&g))
80        }
81        other => Err(AnalysisError(format!("unknown representation `{other}`"))),
82    }
83}
84
85#[cfg(test)]
86mod tests {
87    use super::*;
88
89    fn req(samples: &[f32]) -> Request<'_> {
90        Request {
91            samples,
92            sample_rate: 44100.0,
93            start_secs: 0.0,
94            frame_secs: 0.01,
95            tempo: None,
96            envelope: &[],
97            stereo: None,
98            against: None,
99            gated: false,
100            input_envelope: None,
101        }
102    }
103
104    #[test]
105    fn every_listed_analysis_is_reachable_by_name() {
106        assert!(run("onsets", &req(&[0.0; 100])).is_ok());
107        assert!(run("trajectory", &req(&[0.0; 100])).is_ok());
108        assert!(run("gain-reduction", &req(&[])).is_ok());
109    }
110
111    #[test]
112    fn masking_without_against_refuses_with_a_named_flag() {
113        let err = run("masking", &req(&[0.0; 100])).unwrap_err();
114        assert!(err.0.contains("--against"));
115    }
116
117    #[test]
118    fn an_unknown_name_refuses() {
119        assert!(run("vibes", &req(&[])).is_err());
120    }
121}