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

+18 -3
View File
@@ -218,10 +218,18 @@ pub fn hz_to_midi(hz: f32) -> f32 {
}
/// Scientific note name: MIDI 60 = C4, 43.65 Hz = F1. FL Studio labels MIDI 60 as C5.
pub fn note_name(note: u8) -> String {
const NAMES: [&str; 12] = [
note_name_spelled(note, false)
}
/// Note name with sharps, or with flats (Db, Eb, Gb, Ab, Bb) for keys written in flats.
pub fn note_name_spelled(note: u8, flats: bool) -> String {
const SHARPS: [&str; 12] = [
"C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B",
];
format!("{}{}", NAMES[note as usize % 12], note as i32 / 12 - 1)
const FLATS: [&str; 12] = [
"C", "Db", "D", "Eb", "E", "F", "Gb", "G", "Ab", "A", "Bb", "B",
];
let names = if flats { FLATS } else { SHARPS };
format!("{}{}", names[note as usize % 12], note as i32 / 12 - 1)
}
/// Reads a scientific note name such as "F1" or "c#2", the inverse of [`note_name`].
pub fn parse_note(text: &str) -> Option<u8> {
@@ -231,7 +239,14 @@ pub fn parse_note(text: &str) -> Option<u8> {
let text = text.trim();
let split = text.find(|c: char| c.is_ascii_digit() || c == '-')?;
let (pitch, octave) = text.split_at(split);
let class = NAMES.iter().position(|n| n.eq_ignore_ascii_case(pitch))? as i32;
const FLATS: [&str; 12] = [
"C", "Db", "D", "Eb", "E", "F", "Gb", "G", "Ab", "A", "Bb", "B",
];
let class = NAMES
.iter()
.position(|n| n.eq_ignore_ascii_case(pitch))
.or_else(|| FLATS.iter().position(|n| n.eq_ignore_ascii_case(pitch)))?
as i32;
let octave: i32 = octave.parse().ok()?;
u8::try_from((octave + 1) * 12 + class)
.ok()
+2
View File
@@ -374,5 +374,7 @@ fn note_names_parse_back() {
assert_eq!(common_dsp::parse_note(&common_dsp::note_name(n)), Some(n));
}
assert_eq!(common_dsp::parse_note(" f1 "), Some(29));
assert_eq!(common_dsp::note_name_spelled(30, true), "Gb1");
assert_eq!(common_dsp::parse_note("Gb1"), Some(30));
assert_eq!(common_dsp::parse_note("H2"), None);
}
+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.
+39
View File
@@ -189,6 +189,45 @@ fn root_key_is_detected_per_slice() {
assert_eq!(silent.slices[0].root, None);
}
#[test]
fn a_slice_whose_pitch_moves_gets_no_note_name() {
// Half the slice on F1, half on B1: no one note holds over most of it.
let mut a = Saw::new(RATE, 43.65);
let mut b = Saw::new(RATE, 61.74);
let x: Vec<f32> = (0..48000)
.map(|i| 0.5 * if i < 26000 { a.sample() } else { b.sample() })
.collect();
let take = take_of(x, 120., 1);
assert_eq!(take.slices[0].root, None);
}
#[test]
fn song_key_spells_slice_names() {
let mut s = Settings::default();
assert_eq!(s.note_name(39), "D#2");
s.key = Some(SongKey {
root: 5,
minor: true,
});
assert_eq!(s.note_name(39), "Eb2");
assert_eq!(s.key.unwrap().name(), "F minor");
s.key = Some(SongKey {
root: 4,
minor: false,
});
assert_eq!(s.note_name(39), "D#2");
assert!(SongKey {
root: 10,
minor: false
}
.flats());
assert!(!SongKey {
root: 9,
minor: true
}
.flats());
}
#[test]
fn wavetable_frames_start_on_rising_zero_crossings_and_match() {
let mut saw = Saw::new(RATE, 110.);
+30 -4
View File
@@ -1,8 +1,8 @@
//! Crucible editor, shared by the CLAP wrapper and the headless renderer.
use common_ui::*;
use crucible_core::{
capture, export, render, save_take, Division, Exported, Mode, Queues, Ring, Settings, Take,
Transport, OVERVIEW,
capture, export, render, save_take, Division, Exported, Mode, Queues, Ring, Settings, SongKey,
Take, Transport, OVERVIEW,
};
use egui::{self, Color32, FontId, Pos2, Rect, RichText, Sense, Stroke, Vec2};
use std::sync::{Arc, Mutex};
@@ -342,7 +342,7 @@ fn waveform(ui: &mut egui::Ui, settings: &Settings, state: &mut State, link: &Li
};
p.rect_filled(r, 0., fill);
if r.width() > 26. {
let text = s.root.map_or("·".into(), common_dsp::note_name);
let text = s.root.map_or("·".into(), |n| settings.note_name(n));
p.text(
Pos2::new(r.center().x, rect.top() + 3.),
egui::Align2::CENTER_TOP,
@@ -447,6 +447,30 @@ fn waveform(ui: &mut egui::Ui, settings: &Settings, state: &mut State, link: &Li
}
}
/// Song key picker: spells slice names to fit the key (Eb, not D#, in a flat key).
fn song_key(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
let text = settings
.key
.map_or("KEY —".into(), |k| format!("KEY {}", k.name()));
let r = egui::ComboBox::from_id_salt("song-key")
.selected_text(RichText::new(text).size(13.))
.width(130.)
.show_ui(ui, |ui| {
ui.selectable_value(&mut settings.key, None, "No key (sharps)");
for minor in [true, false] {
for root in 0..12 {
let k = SongKey { root, minor };
ui.selectable_value(&mut settings.key, Some(k), k.name());
}
}
});
hint(
&r.response,
state,
"The song's key: slice names use its spelling, such as Eb instead of D#.",
);
}
fn controls(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State, link: &Link) {
let before = settings.clone();
ui.horizontal(|ui| {
@@ -517,6 +541,7 @@ fn controls(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State, link:
state,
"Save every capture's loop to the takes folder at once, so a reload or crash loses nothing.",
);
song_key(ui, settings, state);
ui.separator();
let take = state.take;
let slice = state.slice;
@@ -528,7 +553,8 @@ fn controls(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State, link:
RichText::new(format!(
"SLICE {} · {} · {:.2} s",
i + 1,
s.root.map_or("no pitch".into(), common_dsp::note_name),
s.root
.map_or("no clear note".into(), |n| settings.note_name(n)),
seconds
))
.size(13.)