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:
1 parent
adfb2c0287
commit
d6939a753a
8 files changed
+143
-19
No files matched your search
@@ -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()
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.);
|
||||
|
||||
@@ -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.)
|
||||
|
||||
Reference in new issue
Block a user