Skip to main content

klib/ml/base/
helpers.rs

1//! Module for generic training and inference helpers.
2
3use std::{
4    collections::hash_map::DefaultHasher,
5    fs::File,
6    hash::{Hash, Hasher},
7    io::{BufReader, Cursor, Write},
8    path::{Path, PathBuf},
9};
10
11use anyhow::Context;
12use byteorder::{BigEndian, ReadBytesExt, WriteBytesExt};
13
14use crate::core::{
15    base::Res,
16    helpers::{inv_mel, mel},
17    note::{HasNoteId, Note, ALL_PITCH_NOTES_WITH_FREQUENCY},
18    pitch::HasFrequency,
19};
20
21use super::{KordItem, FREQUENCY_SPACE_SIZE, MEL_SPACE_SIZE, NOTE_SIGNATURE_SIZE, PITCH_CLASS_COUNT};
22#[cfg(feature = "ml_loader_frequency_pooled")]
23use super::{FREQUENCY_POOL_FACTOR, FREQUENCY_SPACE_POOLED_SIZE};
24
25// Operations for working with kord samples.
26
27/// Load the kord sample from the binary file into a new [`KordItem`].
28pub fn load_kord_item(path: impl AsRef<Path>) -> Res<KordItem> {
29    let path = path.as_ref();
30    let file = std::fs::File::open(path).with_context(|| format!("File: `{path:?}`."))?;
31    let mut reader = BufReader::new(file);
32
33    // Read 8192 f32s in big endian from the file.
34    let mut frequency_space = [0f32; 8192];
35
36    for k in 0..FREQUENCY_SPACE_SIZE {
37        frequency_space[k] = reader.read_f32::<BigEndian>()?;
38    }
39
40    let label = reader.read_u128::<BigEndian>()?;
41
42    Ok(KordItem {
43        path: path.to_owned(),
44        frequency_space,
45        label,
46    })
47}
48
49/// Save the kord sample into a binary file.
50pub fn save_kord_item(destination: impl AsRef<Path>, prefix: &str, note_names: &str, item: &KordItem) -> Res<PathBuf> {
51    let mut output_data: Vec<u8> = Vec::with_capacity(FREQUENCY_SPACE_SIZE);
52    let mut cursor = Cursor::new(&mut output_data);
53
54    // Write frequency space.
55    for value in item.frequency_space {
56        cursor.write_f32::<BigEndian>(value)?;
57    }
58
59    // Write result.
60    cursor.write_u128::<BigEndian>(item.label)?;
61
62    // Get the hash.
63    let mut hasher = DefaultHasher::new();
64    output_data.hash(&mut hasher);
65    let hash = hasher.finish();
66
67    // Write the file.
68    let path = destination.as_ref().join(format!("{prefix}{note_names}_{hash}.bin"));
69    let mut f = File::create(&path)?;
70    f.write_all(&output_data)?;
71
72    Ok(path)
73}
74
75// Operations for working with mels.
76
77/// Convert the [`FREQUENCY_SPACE_SIZE`] f32s in frequency space into [`MEL_SPACE_SIZE`] mel filter bands.
78pub fn mel_filter_banks_from(spectrum: &[f32]) -> [f32; MEL_SPACE_SIZE] {
79    let num_frequencies = spectrum.len();
80    let num_mels = MEL_SPACE_SIZE;
81
82    let f_min = 0f32;
83    let f_max = FREQUENCY_SPACE_SIZE as f32;
84
85    let mel_points = linspace(mel(f_min), mel(f_max), num_mels + 2);
86    let f_points = mel_points.iter().map(|m| inv_mel(*m)).collect::<Vec<_>>();
87
88    let mut filter_banks = [0f32; MEL_SPACE_SIZE];
89
90    for i in 0..num_mels {
91        let f_m_minus = f_points[i];
92        let f_m = f_points[i + 1];
93        let f_m_plus = f_points[i + 2];
94
95        let k_minus = (num_frequencies as f32 * f_m_minus / 8192f32).floor() as usize;
96        let k = (num_frequencies as f32 * f_m / 8192f32).floor() as usize;
97        let k_plus = (num_frequencies as f32 * f_m_plus / 8192f32).floor() as usize;
98
99        for j in k_minus..k {
100            filter_banks[i] += spectrum[j] * (j - k_minus) as f32 / (k - k_minus) as f32;
101        }
102
103        for j in k..k_plus {
104            filter_banks[i] += spectrum[j] * (k_plus - j) as f32 / (k_plus - k) as f32;
105        }
106    }
107
108    filter_banks
109}
110
111/// Run a note-binned "harmonic convolution" over the frequency space data.
112pub fn note_binned_convolution(spectrum: &[f32]) -> [f32; NOTE_SIGNATURE_SIZE] {
113    let mut convolution = [0f32; NOTE_SIGNATURE_SIZE];
114
115    for (note, _) in ALL_PITCH_NOTES_WITH_FREQUENCY.iter().skip(7).take(90) {
116        let id_index = note.id_index();
117
118        let (low, high) = note.tight_frequency_range();
119        let low = low.round() as usize;
120        let high = high.round() as usize;
121
122        if high >= FREQUENCY_SPACE_SIZE {
123            continue;
124        }
125
126        let mut sum = 0f32;
127        for k in low..high {
128            sum += spectrum[k];
129        }
130
131        convolution[id_index as usize] = sum;
132    }
133
134    convolution
135}
136
137/// Run a "harmonic convolution" over the frequency space data.
138pub fn harmonic_convolution(spectrum: &[f32]) -> [f32; FREQUENCY_SPACE_SIZE] {
139    let mut harmonic_convolution = [0f32; FREQUENCY_SPACE_SIZE];
140
141    let (peak, _) = spectrum.iter().enumerate().fold((0usize, 0f32), |(k, max), (j, x)| if *x > max { (j, *x) } else { (k, max) });
142
143    for center in (peak / 2)..4000 {
144        let mut sum = spectrum[center];
145
146        for k in 2..16 {
147            let index = center * k;
148            if index < FREQUENCY_SPACE_SIZE {
149                sum += spectrum[index];
150            }
151        }
152
153        for k in 2..16 {
154            let index = center / k;
155            if index < FREQUENCY_SPACE_SIZE {
156                sum -= spectrum[index];
157            }
158        }
159
160        harmonic_convolution[center] = sum.clamp(0.0, f32::MAX);
161    }
162
163    harmonic_convolution
164}
165
166/// Downsamples the full frequency space by averaging contiguous windows.
167#[cfg(feature = "ml_loader_frequency_pooled")]
168pub fn average_pool_frequency_space(spectrum: &[f32; FREQUENCY_SPACE_SIZE]) -> [f32; FREQUENCY_SPACE_POOLED_SIZE] {
169    let mut pooled = [0f32; FREQUENCY_SPACE_POOLED_SIZE];
170
171    for (index, chunk) in spectrum.chunks_exact(FREQUENCY_POOL_FACTOR).enumerate() {
172        let sum: f32 = chunk.iter().sum();
173        pooled[index] = sum / FREQUENCY_POOL_FACTOR as f32;
174    }
175
176    pooled
177}
178
179/// Create a linearly spaced vector.
180pub fn linspace(start: f32, end: f32, num_points: usize) -> Vec<f32> {
181    let step = (end - start) / (num_points - 1) as f32;
182    (0..num_points).map(|i| start + i as f32 * step).collect()
183}
184
185/// Gets the "deterministic guess" for a given kord item.
186#[cfg(feature = "analyze_base")]
187pub fn get_deterministic_guess(kord_item: &KordItem) -> u128 {
188    use crate::analyze::base::get_notes_from_smoothed_frequency_space;
189
190    let smoothed_frequency_space = kord_item.frequency_space.into_iter().enumerate().map(|(k, v)| (k as f32, v)).collect::<Vec<_>>();
191
192    let notes = get_notes_from_smoothed_frequency_space(&smoothed_frequency_space);
193
194    Note::id_mask(&notes)
195}
196
197/// Produces a 128 element array of 0s and 1s from a u128.
198pub fn u128_to_binary(num: u128) -> [f32; 128] {
199    let mut binary = [0f32; 128];
200    for i in 0..128 {
201        binary[128 - 1 - i] = (num >> i & 1) as f32;
202    }
203
204    binary
205}
206
207/// Produces a u128 from a 128 element array of 0s and 1s.
208pub fn binary_to_u128(binary: &[f32]) -> u128 {
209    let mut num = 0u128;
210    for i in 0..128 {
211        num += (binary[i] as u128) << (128 - 1 - i);
212    }
213
214    num
215}
216
217/// Folds the 128-bit binary signature of the the notes into a 12-bit signature (which represent one octave)
218#[allow(dead_code)]
219pub fn fold_binary(binary: &[f32; NOTE_SIGNATURE_SIZE]) -> [f32; PITCH_CLASS_COUNT] {
220    let mut folded = [0f32; PITCH_CLASS_COUNT];
221
222    // binary is MSB-first from u128_to_binary, so we need to map array indices back to bit positions
223    for array_idx in 0..NOTE_SIGNATURE_SIZE {
224        if binary[array_idx] == 1.0 {
225            // Convert array index back to bit position: array_idx corresponds to bit (127 - array_idx)
226            let bit_position = NOTE_SIGNATURE_SIZE - 1 - array_idx;
227            let pitch_class = bit_position % PITCH_CLASS_COUNT;
228            folded[pitch_class] = 1.0;
229        }
230    }
231
232    folded
233}
234
235/// Applies sigmoid activation to convert logits to probabilities in `[0, 1]`.
236#[cfg(any(feature = "ml_target_full", feature = "ml_target_folded"))]
237pub fn logits_to_probabilities(logits: &[f32]) -> Vec<f32> {
238    logits.iter().map(|&logit| 1.0 / (1.0 + (-logit).exp())).collect()
239}
240
241/// Applies sigmoid activation and bass softmax to convert logits to probabilities in `[0, 1]`.
242#[cfg(feature = "ml_target_folded_bass")]
243pub fn logits_to_probabilities(logits: &[f32]) -> Vec<f32> {
244    let mut probabilities: Vec<f32> = logits.iter().map(|&logit| 1.0 / (1.0 + (-logit).exp())).collect();
245
246    if logits.len() >= PITCH_CLASS_COUNT {
247        let slice = &logits[..PITCH_CLASS_COUNT];
248        let max_logit = slice.iter().copied().fold(f32::NEG_INFINITY, f32::max);
249        let exp_values: Vec<f32> = slice.iter().map(|&value| (value - max_logit).exp()).collect();
250        let sum: f32 = exp_values.iter().sum();
251
252        if sum > 0.0 {
253            for (offset, exp_value) in exp_values.iter().enumerate() {
254                probabilities[offset] = exp_value / sum;
255            }
256        }
257    }
258
259    probabilities
260}
261
262/// Converts probabilities to binary predictions using per-class thresholds.
263#[cfg(any(feature = "ml_target_full", feature = "ml_target_folded"))]
264pub fn logits_to_predictions(probabilities: &[f32], thresholds: &[f32]) -> Vec<f32> {
265    probabilities
266        .iter()
267        .enumerate()
268        .map(|(idx, probability)| {
269            let threshold = thresholds.get(idx).copied().unwrap_or(0.5);
270            if *probability > threshold {
271                1.0
272            } else {
273                0.0
274            }
275        })
276        .collect()
277}
278
279/// Converts probabilities to binary predictions using per-class thresholds.
280#[cfg(feature = "ml_target_folded_bass")]
281pub fn logits_to_predictions(probabilities: &[f32], thresholds: &[f32]) -> Vec<f32> {
282    let mut predictions = vec![0.0; probabilities.len()];
283
284    if probabilities.len() >= PITCH_CLASS_COUNT {
285        let bass_slice = &probabilities[..PITCH_CLASS_COUNT];
286        if let Some((best_idx, _)) = bass_slice.iter().enumerate().max_by(|a, b| a.1.partial_cmp(b.1).unwrap_or(std::cmp::Ordering::Equal)) {
287            predictions[best_idx] = 1.0;
288        }
289
290        for offset in 0..PITCH_CLASS_COUNT {
291            let idx = PITCH_CLASS_COUNT + offset;
292            if idx < probabilities.len() {
293                let threshold = thresholds.get(idx).copied().unwrap_or(0.5);
294                let probability = probabilities[idx];
295                predictions[idx] = if probability > threshold { 1.0 } else { 0.0 };
296            }
297        }
298    }
299
300    predictions
301}
302