diff --git a/plugins/common/dsp/src/lib.rs b/plugins/common/dsp/src/lib.rs index c5e909d..6c04517 100644 --- a/plugins/common/dsp/src/lib.rs +++ b/plugins/common/dsp/src/lib.rs @@ -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 { @@ -231,7 +239,14 @@ pub fn parse_note(text: &str) -> Option { 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() diff --git a/plugins/common/dsp/tests/blocks.rs b/plugins/common/dsp/tests/blocks.rs index 7b28d8f..07d8a56 100644 --- a/plugins/common/dsp/tests/blocks.rs +++ b/plugins/common/dsp/tests/blocks.rs @@ -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); } diff --git a/plugins/crucible/core/src/export.rs b/plugins/crucible/core/src/export.rs index bc446cd..21f2cc1 100644 --- a/plugins/crucible/core/src/export.rs +++ b/plugins/crucible/core/src/export.rs @@ -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( diff --git a/plugins/crucible/core/src/lib.rs b/plugins/crucible/core/src/lib.rs index c8f3bd4..c4b414a 100644 --- a/plugins/crucible/core/src/lib.rs +++ b/plugins/crucible/core/src/lib.rs @@ -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, diff --git a/plugins/crucible/core/src/settings.rs b/plugins/crucible/core/src/settings.rs index 878e4f6..3042f84 100644 --- a/plugins/crucible/core/src/settings.rs +++ b/plugins/crucible/core/src/settings.rs @@ -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, } 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() { diff --git a/plugins/crucible/core/src/take.rs b/plugins/crucible/core/src/take.rs index 052a56d..3413a38 100644 --- a/plugins/crucible/core/src/take.rs +++ b/plugins/crucible/core/src/take.rs @@ -245,31 +245,35 @@ pub fn transients(x: &[f32], rate: f32, sensitivity: f32) -> Vec { 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 { 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 = 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 = 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. diff --git a/plugins/crucible/core/tests/core.rs b/plugins/crucible/core/tests/core.rs index efeb569..76713ac 100644 --- a/plugins/crucible/core/tests/core.rs +++ b/plugins/crucible/core/tests/core.rs @@ -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 = (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.); diff --git a/plugins/crucible/ui/src/lib.rs b/plugins/crucible/ui/src/lib.rs index 0297e35..1a14b9d 100644 --- a/plugins/crucible/ui/src/lib.rs +++ b/plugins/crucible/ui/src/lib.rs @@ -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.)