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
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user