First at all, falsehoods programmers believe about musical tuning:
- C♯ and D♭ are the same notes
- C𝄪 and D are the same notes
- Semitone is 100 cents wide
- There's only 12 notes in the octave
- There's only 24 notes
- Okay, 31 notes
- Come on, 72 notes per octave is enough for everybody
- You're kidding, right?
- A4 = 440 Hz
- A4 is either 440 Hz or 432 Hz
- A4 is a constant
- Octave ratio is 2:1
- There's always an octave
- The size of a step is always the same
- At least there's an interval of equivalence
- Note 69 in MIDI is A4
- There's a linear map from MIDI to tuning steps
- A musical piece uses only one tuning
- Only crazy microtonalists care about all these nuances
- Non-microtonal Western music is in 12 tone equal temperament
- But if you play it in 12ET everything will be fine, right?
- Letters are not ambiguous
After listing everything that is wrong with pitch_calc, let's try to build a better library for pitch calculations.
Obviously, we need a data type for a pitch:
/// Hertz is the standard unit of frequency.
///
/// It is also the standard unit of pitch as well.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Hz(pub f32);
There probably should be F: Float
(from num-traits) instead of f32
, but after some fight with rust borrow checker generics I decided it is not that important. Maybe I'll add F: Float
later.
Also we need a data type for musical intervals:
/// Cent is the standard unit of musical interval.
///
/// 12 EDO semitone is 100 cents large while an octave is 1200 cents large.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Cents(pub f32);
impl Cents {
pub fn from_ratio(ratio: f32) -> Cents {
Cents(1200.0 * ratio.log2())
}
}
Musical intervals are ratios, and cents are logarithms of ratios, allowing you just sum numbers instead of multiplying them.
We also need a few operations on these types:
/// You can add an interval to Hz. For example:
///
/// ```rust
/// # use monochord::*;
/// Hz(440.0) + Cents(702.0); // Hz(660.0)
/// ```
impl Add<Cents> for Hz {
type Output = Hz;
fn add(self, rhs: Cents) -> Self::Output {
let freq = self.0 * (rhs.0 / 1200.0).exp2();
Hz(freq)
}
}
impl Sub<Cents> for Hz {
type Output = Hz;
fn sub(self, rhs: Cents) -> Self::Output {
let freq = self.0 * (-rhs.0 / 1200.0).exp2();
Hz(freq)
}
}
impl Mul<f32> for Hz {
type Output = Hz;
fn mul(self, rhs: f32) -> Self::Output {
Hz(self.0 * rhs)
}
}
impl Div<Hz> for Hz {
/// `Hz(b) / Hz(a)` is equivalent to `Cents::from_ratio(b / a)`.
type Output = Cents;
fn div(self, rhs: Hz) -> Self::Output {
Cents::from_ratio(self.0 / rhs.0)
}
}
This operations are pretty obvious. We can add an interval to a pitch to get an another pitch, we can multiply pitch by a ratio, and we can get a ratio of two pitches.
Operations like hz1 + hz2
or hz1 * hz2
are rather meaningless, so we don't implement them. But you can use Hz(hz1.0 + hz2.0)
instead. Adding cents to cents is meaningful, but the implementation is obvious.
So, in a very few lines of code we implemented something that is more useful than the whole pitch_calc (for example, you can easily do something like Hz(440.0) + Cents(386.0)
or Hz(415.0) * 7.0/4.0
). But a good library needs something more than that...
This is the trait for tunings:
/// A general trait for tunings.
/// `tun.pitch(0)` should be the same as `Some(tun.reference_pitch())`
pub trait Tuning {
fn reference_pitch(&self) -> Hz;
fn pitch(&self, step: i32) -> Option<Hz>;
fn interval(&self, from: i32, to: i32) -> Option<Cents> {
match (self.pitch(from), self.pitch(to)) {
(Some(to), Some(from)) => Some(to / from),
_ => None,
}
}
}
Here we assume as litte as possible. A tuning is a numerated collection of pitches and there's the reference pitch with number 0. That's all.
Let's write some implementation of this trait.
/// Equal division of 2:1
#[derive(Debug, Clone)]
pub struct Edo {
cardinality: u16,
reference: Hz,
}
impl Edo {
pub fn new(cardinality: u16, reference: Hz) -> Self {
Edo {
cardinality, reference
}
}
pub fn new_a440(cardinality: u16) -> Self {
Self::new(cardinality, A440)
}
}
impl Tuning for Edo {
fn reference_pitch(&self) -> Hz {
self.reference
}
fn pitch(&self, step: i32) -> Option<Hz> {
let int = Cents(1200.0 / self.cardinality as f32) * step as f32;
Some(self.reference + int)
}
fn interval(&self, from: i32, to: i32) -> Option<Cents> {
let delta = (to - from) as f32;
Some(Cents(1200.0 / self.cardinality as f32 * delta))
}
}
Good old equal temperament. Here you can specify both cardinality and the reference, so you can get any EDO you want.
But what if you a stretched tuning or even something completely non-octave? Well, this may be helpful:
#[derive(Debug, Clone)]
/// Tuning with equal steps
pub struct EqualSteps {
step: Cents,
reference: Hz,
}
impl EqualSteps {
pub fn new(step: Cents, reference: Hz) -> Self {
EqualSteps {
step, reference
}
}
pub fn new_a440(step: Cents) -> Self {
Self::new(step, A440)
}
}
impl Tuning for EqualSteps {
fn reference_pitch(&self) -> Hz {
self.reference
}
fn pitch(&self, step: i32) -> Option<Hz> {
let int = self.step * step as f32;
Some(self.reference + int)
}
}
Anyway, since Tuning
trait is public, you can easily implement any tuning you want.
Things get more complicated when you add a keyboard. Linear maps are good, but they're not the only possible one. Also there's only 127 notes in midi but there can be much more notes in the tuning.
The best idea is, as usual, to keep it simple:
#[derive(Debug, Clone)]
/// This is what your synth should actually use
pub struct MidiTuning {
pitches: Vec<Hz>,
}
impl MidiTuning {
pub fn from_tuning<T: Tuning>(tuning: T, refkey: u8) -> Option<Self> {
assert!(refkey < 127);
let mut pitches = Vec::with_capacity(127);
for i in 0..127 {
if let Some(hz) = tuning.pitch(i - refkey as i32) {
pitches.push(hz)
}
else {
return None
}
}
Some(MidiTuning {
pitches
})
}
pub fn from_pitches(hzs: &[Hz]) -> Option<Self> {
if hzs.len() < 127 { return None }
let pitches = hzs[0..127].to_owned();
Some(MidiTuning {
pitches
})
}
}
MidiTuning
is basically a map from MIDI notes to pitches. You can make it from a tuning by mapping it lineary, or you can make from a slice of pitches.
Ideally, your synth should have a separate MidiTuning
for each channel and allow to switch between them via the MIDI Tuning Standard.
Because I know people are too lazy to write MidiTuning::from_tuning(Edo::new_a440(12), 69)
, MidiTuning also implements Default
:
impl ::std::default::Default for MidiTuning {
fn default() -> Self {
let mut pitches = Vec::with_capacity(127);
pitches.extend((0..127).map(|i| A440 + Cents((i - 69) as f32 * 100.0)));
MidiTuning {
pitches
}
}
}
And, of course, it implements Tuning
and Index<usize>
as well.
There're three planes: practical, historical and microtonal.
From the practical side, let you have a piano. Like any piano, it has a feature: the sound it produces is slighly inharmonious. That means, that harmonics differ from “f, 2f, 3f, ...” pattern, intervals are a little bit stretched. But if you want to perfectly blend together sounds of different keys, you should align harmonics to reduce beatings between them.
That's why pianos often have a stretched tuning. And octave size a stretched tuning is slightly bigger than 2:1.
Now let your piano be a virtual physical-modeling piano. To sound like a real piano, it should model inharmonicity. But then it also should have a stretched tuning as well.
Physical modeling is not the only way to get inharmonicity. FM synthesis can produce inharmonic sounds too, that's why it imitates bells and electical pianos so well. But most FM synths don't allow you to choose a tuning.
So why your synth doesn't support different tunings, so musicians could achieve the best sound?
From the historical side, people in the past used tunings different from 12EDO. You may probably think that because their music was not microtonal, it is perfectly fine to play it in 12EDO and nothing bad will happen. Wrong. And wrong in two ways: there actually was some microtonal music in Renaissance, but even non-microtonal music suffers from a wrong tuning.
The choice of tuning affects musical preferences and vice versa. People in Renaissance really liked pure thirds and sixths, and their tuning (meantone) approximates these intervals extremely well, making them expressive. And their music favored thirds over other intervals. In 12EDO everything is different: thirds are approximated poorly (15 cents error is a lot), while fifths and seconds are very good. This makes you to prefer suspended and seventh chords, quartal and quintal harmony. And music is indeed full of them.
There's an example in meantone and in 12EDO: Ascanio Mayone: Toccata quinta (excerpt). You can hear the clarity of thirds and sixths in meantone tuning as well as the lack of purity in 12EDO.
Also the fact that music is not microtonal doesn't mean there can't be more than 12 notes in the octave. Meantone tuning doesn't temper out the diesis. That means that sharps are different from flats. Because of that, you may need more than 12 keys to avoid wolfs. And indeed, some old instrument had split keys.
People didn't immediately switch from meantone to 12EDO. There were transitional tunings like well temperaments. These tunings had a feature: while they temper out diesis and eliminate wolfs, they sound different in different keys. Did Chopin or Bach take it in account? Yes and yes.
So why your synth doesn't support different tunings, so people could know the history of music?
From the microtonal side, suppose you're Turkish. You know two kinds of music, the first one is just an ordinary music in equal temperament. The other kind of music sounds like this. Makam. This is not only one microtonal tradition, there're other like the Indian one.
So why your synth doesn't support different tunings, so people could play their traditional music?
Now suppose you're not Turkish. There's still two kinds of music: the first one is just ordinary music you know. The other one is so called “microtonal” music. You may probably think it's some kind of crazy out-of-tune quarter tone music nobody listens to. Nope. Well, I mean, not quite: it doesn't have to be quarter tone, neither it has to be out of tune like quarter tones, nor it has to be crazy. Everything depends on a composer.
There's probably as many ways to do microtonality as there're microtonalists. Some of them want to achieve at much crazyness and out-of-tuneness as possible (it's called “xenharmonic”). Some want the exactly opposite, so they only make music in just intonation. Some people seek for new harmonic possibilities. Some explore possibilities in a way so other people could understand their music too. Some people understand microtonal intervals, some people don't. Some digg into the history. And so on.
Is microtonal music crazy and out of tune? Well, listen to a barbershop quartet: https://www.youtube.com/watch?v=6SSZqzZKT1g
Did you notice that awesome ringing sound? This famous 4:5:6:7 chord is called a harmonic seventh chord. As you can guess, this is actually a microtonal chord. Like other septimal intervals, the harmonic seventh cannot be played on a 12EDO piano, it falls somewhere between the keys.
And what about Renaissance microtonal music? Derelinquat impius viam suam, for example.
Anyway, think about a yet another thing: 12 tone space is small. That's why academic composers tend to make a really complicated music. But microtonality gives you more variety and freedom, also more expressiveness. And that allows you to write something simple but yet powerful and new.
So why your synth doesn't support different tunings, so composers could write something like you never heard before?
Programming is the art of making right assumptions. If you guess them right, your abstraction will be powerful but flexible and easy to use. If your assumptions are wrong, your abstraction will be rigid and ugly, and maybe even unusable.
Let's try to make it right.
This little library is available on Github and on crates.io.