feat(crucible): name slices only by a sure note, spelled for the song key

A slice gets a note name only when at least 60 % of up to twelve reads
across its body sit on one note; growls, pitch drops and noise get no name
rather than a wrong one. An optional song key spells names with flats in
flat keys (Eb2, not D#2).
This commit is contained in:
nvrl committed 2026-11-06 21:05:31 +01:00
1 parent adfb2c0287
commit d6939a753a
8 files changed
+143 -19

No files matched your search

+2 -2
View File
@@ -1,7 +1,7 @@
//! Writes a take as ready-to-use files: the full loop, one file per slice and a wavetable.
use crate::settings::Settings;
use crate::take::{render, wavetable, Take, TABLE};
use common_dsp::{note_name, wav};
use common_dsp::wav;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, PartialEq)]
@@ -101,7 +101,7 @@ pub fn export(
let mut files = 1;
for (i, s) in take.slices.iter().enumerate().filter(|(_, s)| s.enabled) {
let name = match s.root {
Some(n) => format!("s{:02}_{}.wav", i + 1, note_name(n)),
Some(n) => format!("s{:02}_{}.wav", i + 1, settings.note_name(n)),
None => format!("s{:02}.wav", i + 1),
};
wav::write(
+1 -1
View File
@@ -8,7 +8,7 @@ mod take;
pub use export::{export, save_take, stamp, Exported};
pub use player::{Clip, Player, Queues};
pub use ring::{Marker, Recorder, Ring, Transport, MARKERS};
pub use settings::{default_folder, Division, Mode, Settings};
pub use settings::{default_folder, Division, Mode, Settings, SongKey};
pub use take::{
capture, render, root_key, transients, wavetable, Segment, Slice, Take, FALLBACK_TEMPO,
OVERVIEW, TABLE, TABLE_FRAMES,
+38
View File
@@ -48,6 +48,34 @@ impl Division {
}
}
/// The song's key, used to spell slice names.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
pub struct SongKey {
/// Pitch class of the tonic, 0 = C.
pub root: u8,
pub minor: bool,
}
impl SongKey {
/// True for keys written with flats: F, Bb, Eb, Ab, Db, Gb major and D, G, C, F, Bb, Eb
/// minor.
pub fn flats(self) -> bool {
let major = if self.minor {
(self.root + 3) % 12
} else {
self.root % 12
};
matches!(major, 5 | 10 | 3 | 8 | 1 | 6)
}
pub fn name(self) -> String {
let n = common_dsp::note_name_spelled(self.root % 12 + 12, self.flats());
format!(
"{} {}",
n.trim_end_matches(|c: char| c.is_ascii_digit()),
if self.minor { "minor" } else { "major" }
)
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(default)]
pub struct Settings {
@@ -68,6 +96,8 @@ pub struct Settings {
pub keep_takes: bool,
/// Export folder; empty selects the default.
pub folder: String,
/// Song key for slice names: flats in flat keys. None spells with sharps.
pub key: Option<SongKey>,
}
impl Default for Settings {
fn default() -> Self {
@@ -82,6 +112,7 @@ impl Default for Settings {
wavetable: false,
keep_takes: true,
folder: String::new(),
key: None,
}
}
}
@@ -102,6 +133,13 @@ impl Settings {
} else {
2.
};
if let Some(k) = &mut self.key {
k.root %= 12;
}
}
/// Name of a slice's note, spelled for the song key.
pub fn note_name(&self, note: u8) -> String {
common_dsp::note_name_spelled(note, self.key.is_some_and(SongKey::flats))
}
pub fn folder(&self) -> PathBuf {
if !self.folder.trim().is_empty() {
+13 -9
View File
@@ -245,31 +245,35 @@ pub fn transients(x: &[f32], rate: f32, sensitivity: f32) -> Vec<usize> {
cuts
}
/// Root note of a slice, if it is clearly pitched.
/// Root note of a slice, only when one note is sure over most of it. A growl, a pitch
/// drop or noise gets no name rather than a wrong one.
pub fn root_key(yin: &mut Yin, mono: &[f32], rate: f32) -> Option<u8> {
let window = (2 * yin.max_lag() + 2).min(mono.len());
if window < (rate / 1000.) as usize * 4 || mono.len() < window {
return None;
}
// Skip the attack: the body of a hit carries its pitch.
let mut notes: Vec<f32> = Vec::new();
// Skip the attack: the body of a hit carries its pitch. Spread up to 12 reads over it.
let start = (mono.len() / 5).min(mono.len() - window);
let step = (window / 2).max(1);
let mut at = start;
while at + window <= mono.len() && notes.len() < 6 {
let span = mono.len() - window - start;
let count = (span / (window / 2).max(1) + 1).min(12);
let mut notes: Vec<f32> = Vec::with_capacity(count);
for k in 0..count {
let at = start + if count > 1 { span * k / (count - 1) } else { 0 };
if let Some((hz, clarity)) = yin.estimate(&mono[at..at + window], rate, 35., 1000.) {
if clarity > 0.7 {
notes.push(hz_to_midi(hz));
}
}
at += step;
}
if notes.is_empty() {
return None;
}
notes.sort_by(f32::total_cmp);
let n = notes[notes.len() / 2].round();
(0. ..=127.).contains(&n).then_some(n as u8)
let median = notes[notes.len() / 2];
let n = median.round();
// At least 60 % of all reads, unclear ones included, must sit on that note.
let agree = notes.iter().filter(|p| (*p - n).abs() < 0.5).count();
(agree * 5 >= count * 3 && (0. ..=127.).contains(&n)).then_some(n as u8)
}
/// Builds a take from the newest `bars` complete bars, or from the newest audio when stopped.