Skip to main content

klib/ml/infer/
execute.rs

1//! Module for executing inference.
2
3use std::sync::{LazyLock, Mutex};
4
5use burn::{
6    backend::{ndarray::NdArrayDevice, NdArray},
7    config::Config,
8    module::Module,
9    record::{BinBytesRecorder, Recorder},
10};
11
12use crate::{
13    analyze::base::{get_frequency_space, get_smoothed_frequency_space},
14    core::{base::Res, chord::Chord, pitch::Pitch},
15    ml::base::{
16        data::kord_item_to_sample_tensor,
17        helpers::{logits_to_predictions, logits_to_probabilities},
18        model::KordModel,
19        KordItem, StorePrecisionSettings, TrainConfig, FREQUENCY_SPACE_SIZE, PITCH_CLASS_COUNT,
20    },
21};
22
23#[cfg(feature = "ml_target_folded_bass")]
24use crate::ml::base::TARGET_FOLDED_BASS_NOTE_OFFSET;
25
26/// Result of ML inference containing all pitch classes and chord candidates.
27#[derive(Debug, Clone)]
28pub struct InferenceResult {
29    /// All detected pitch classes.
30    pub pitches: Vec<Pitch>,
31    /// Chord candidates ranked by likelihood.
32    pub chords: Vec<Chord>,
33    /// Delta (probability - threshold) for each of the 12 pitch classes (C through B).
34    /// Positive values indicate detected pitches, negative values indicate below threshold.
35    pub pitch_deltas: [f32; 12],
36}
37
38/// Cached inference state for the `NdArray<f32>` backend.
39///
40/// Deserializing config, loading model weights, and parsing thresholds is expensive.
41/// This struct is initialized once via [`INFERENCE_STATE`] and reused across calls.
42struct InferenceState {
43    model: KordModel<NdArray<f32>>,
44    thresholds: Vec<f32>,
45}
46
47static INFERENCE_STATE: LazyLock<Mutex<InferenceState>> = LazyLock::new(|| Mutex::new({
48    let device = NdArrayDevice::Cpu;
49
50    let config = TrainConfig::load_binary(CONFIG).expect("Could not load the config from within the binary");
51
52    let recorder = BinBytesRecorder::<StorePrecisionSettings>::new()
53        .load(Vec::from_iter(STATE_BINCODE.iter().cloned()), &device)
54        .expect("Could not load the state from within the binary");
55
56    let model = KordModel::<NdArray<f32>>::new(&device, config.mha_heads, config.dropout, config.trunk_hidden_size).load_record(recorder);
57
58    let thresholds: Vec<f32> = serde_json::from_slice(THRESHOLDS_JSON).expect("failed to deserialize thresholds");
59
60    InferenceState { model, thresholds }
61}));
62
63/// Run ML inference on audio data and return detected pitches and chord candidates.
64///
65/// This is the main entry point for inference. It processes audio data through the ML model
66/// and returns a structured result containing all detected pitch classes and ranked chord
67/// candidates.
68pub fn infer(audio_data: &[f32], length_in_seconds: u8) -> Res<InferenceResult> {
69    let frequency_space = get_frequency_space(audio_data, length_in_seconds);
70    let smoothed_frequency_space: [_; FREQUENCY_SPACE_SIZE] = get_smoothed_frequency_space(&frequency_space, length_in_seconds)
71        .into_iter()
72        .take(FREQUENCY_SPACE_SIZE)
73        .map(|(_, v)| v)
74        .collect::<Vec<_>>()
75        .try_into()
76        .map_err(|_| anyhow::Error::msg("Failed to convert smoothed frequency space into array"))?;
77
78    let kord_item = KordItem {
79        frequency_space: smoothed_frequency_space,
80        ..Default::default()
81    };
82
83    let state = INFERENCE_STATE.lock().map_err(|e| anyhow::anyhow!("inference state lock poisoned: {e}"))?;
84    let device = NdArrayDevice::Cpu;
85
86    // Prepare the sample.
87    let sample = kord_item_to_sample_tensor(&device, &kord_item).detach();
88
89    // Run the inference.
90    let logits = state.model.forward(sample).detach();
91    let logits_vec: Vec<f32> = logits
92        .into_data()
93        .convert::<f32>()
94        .to_vec()
95        .map_err(|e| anyhow::anyhow!("failed to convert logits tensor to vec: {e:?}"))?;
96    let probabilities = logits_to_probabilities(&logits_vec);
97    let inferred = logits_to_predictions(&probabilities, &state.thresholds);
98
99    // Decode pitch classes from the prediction vector.
100    //
101    // For folded_bass the first 12 elements are the bass one-hot; the pitch-class
102    // mask lives at indices 12..24. For plain folded, the mask starts at 0.
103    // Both are indexed by true pitch class (C=0, Db=1, ..., B=11).
104    #[cfg(feature = "ml_target_full")]
105    compile_error!("Inference with ml_target_full is not supported; use ml_target_folded or ml_target_folded_bass.");
106    #[cfg(feature = "ml_target_folded_bass")]
107    let note_offset = TARGET_FOLDED_BASS_NOTE_OFFSET;
108    #[cfg(feature = "ml_target_folded")]
109    let note_offset = 0;
110
111    let mut pitches = Vec::new();
112    let mut pitch_deltas = [0.0f32; 12];
113
114    for pitch_class_index in 0..PITCH_CLASS_COUNT {
115        let idx = note_offset + pitch_class_index;
116        let is_present = inferred.get(idx).copied().unwrap_or(0.0);
117
118        // Calculate delta (probability - threshold) for debugging.
119        let probability = probabilities.get(idx).copied().unwrap_or(0.0);
120        let threshold = state.thresholds.get(idx).copied().unwrap_or(0.5);
121        pitch_deltas[pitch_class_index] = probability - threshold;
122
123        if is_present == 1.0 {
124            let pitch = Pitch::try_from(pitch_class_index as u8).map_err(|e| anyhow::Error::msg(format!("Invalid pitch class {}: {}", pitch_class_index, e)))?;
125            pitches.push(pitch);
126        }
127    }
128
129    // Generate chord candidates using smart octave permutations.
130    // If there are no pitches detected, return empty chord list.
131    // If chord detection fails, log it but still return the pitches.
132    let chords = if pitches.is_empty() {
133        vec![]
134    } else {
135        #[allow(unused_variables)]
136        Chord::try_from_pitches(&pitches).unwrap_or_else(|e| {
137            #[cfg(feature = "cli")]
138            eprintln!("Could not determine chords from pitches: {}", e);
139            vec![]
140        })
141    };
142
143    Ok(InferenceResult { pitches, chords, pitch_deltas })
144}
145
146// Statics - forward slashes work on both Unix and Windows in include_bytes!
147static CONFIG: &[u8] = include_bytes!("../../../model/model_config.json");
148static STATE_BINCODE: &[u8] = include_bytes!("../../../model/state.json.bin");
149static THRESHOLDS_JSON: &[u8] = include_bytes!("../../../model/thresholds.json");
150
151// Tests.
152
153#[cfg(test)]
154#[cfg(feature = "ml_infer")]
155mod tests {
156    use std::{fs::File, io::Read};
157
158    use crate::core::base::HasName;
159
160    use super::*;
161
162    #[test]
163    fn test_inference() {
164        let mut file = File::open("tests/vec.bin").unwrap();
165        let file_size = file.metadata().unwrap().len() as usize;
166        let float_size = std::mem::size_of::<f32>();
167        let element_count = file_size / float_size;
168        let mut buffer = vec![0u8; file_size];
169
170        // Read the contents of the file into the buffer
171        file.read_exact(&mut buffer).unwrap();
172
173        // Convert the buffer to a vector of f32
174        let audio_data: Vec<f32> = unsafe { std::slice::from_raw_parts(buffer.as_ptr() as *const f32, element_count).to_vec() };
175
176        // The model always predicts a bass pitch. Pitch classes and chords may be empty for simple audio.
177        let inference_result = infer(&audio_data, 5).unwrap();
178
179        // The folded model predicts pitch classes directly (no octave information).
180        // We expect a C7-family chord from the test audio.
181        assert!(!inference_result.pitches.is_empty(), "expected at least one pitch class");
182        assert!(!inference_result.chords.is_empty(), "expected at least one chord candidate");
183
184        let name = inference_result.chords[0].name_ascii();
185        assert!(name.starts_with("C7") || name.starts_with("C/C 7"), "expected a C7 chord variant, got: {name}");
186    }
187}