Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Pitch classes, notes and spelling

Files: crates/lineup-core/src/pitch.rs and crates/lineup-core/src/harmony/pitch_set.rs

This part of the book is about the music theory inside the program: scales, chords, numerals, sets, rows and the rules of voice leading, each one a few small Rust types with methods, tested like any other code. The theory itself is taught in the user manual’s Learning harmony chapters; each chapter here says the musical idea in a few lines and then shows how the code holds it.

Everything else is built on the types of this chapter: a note, the twelve keys of an octave, the name a key is written with, and a set of keys.

The music, briefly

A piano has 88 keys but only twelve different notes, which repeat in every octave. The distance from one key to the next, white or black, is a semitone:

 C  C♯ D  D♯ E  F  F♯ G  G♯ A  A♯ B  C
 0  1  2  3  4  5  6  7  8  9  10 11 12

A note’s name without its octave is its pitch class: every C on the piano has pitch class C. Harmony mostly works with pitch classes; a C major chord is C, E and G, in any octave and any order. The octave is written after the name: C4 is middle C and A4 the A an orchestra tunes to, 440 Hz.

Each black key has two names: the key between C and D is C♯ and also D♭. Which name is right depends on the music around it, and the rule is that a scale uses each of the seven letters once. F major has a B♭, never an A♯, because it already has an A. F♯ major’s seventh note is E♯, the key usually called F, because F♯ already has the letter F. Now and then a note needs two signs: the seventh of G♯ harmonic minor is F𝄪 (F double sharp, the key usually called G), and the seventh of a C diminished seventh chord is B𝄫 (B double flat, which sounds as A). The manual’s Notes, intervals and scales tells this more slowly.

So the code needs two different things: which key of the piano a note is, and what it is called. They are different types.

Twelve pitch classes: an enum

#![allow(unused)]
fn main() {
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub enum PitchClass {
    C,
    CSharp,
    D,
    DSharp,
    E,
    F,
    FSharp,
    G,
    GSharp,
    A,
    ASharp,
    B,
}
}

There are exactly twelve pitch classes, so an enum fits: a PitchClass can never be 13 or -1. The variants carry no data (a fieldless enum), and Rust numbers them from 0 in the order they are written. The as keyword turns such an enum into its number, which is the number of semitones above C:

#![allow(unused)]
fn main() {
pub fn semitone(self) -> u8 {
    self as u8
}
}

Going the other way needs care, because not every number is a pitch class. from_semitone wraps first, then looks the answer up in the ALL array, so it cannot fail:

#![allow(unused)]
fn main() {
pub fn from_semitone(semitone: u8) -> Self {
    Self::ALL[(semitone % 12) as usize]
}
}

transpose adds semitones and wraps around the octave. It takes an i32 so it can also go down. For a negative number % gives a negative remainder (-1 % 12 is -1 in Rust), so it uses rem_euclid, which always gives 0 to 11:

#![allow(unused)]
fn main() {
pub fn transpose(self, semitones: i32) -> Self {
    let wrapped = (self.semitone() as i32 + semitones).rem_euclid(12);
    Self::from_semitone(wrapped as u8)
}
}

semitones_up_to answers the opposite question, how far up one pitch class is from another. From A up to C is 3 semitones (A♯, B, C), and from C up to A is 9; adding 12 before subtracting keeps the u8 from going below zero:

#![allow(unused)]
fn main() {
pub fn semitones_up_to(self, other: PitchClass) -> u8 {
    (other.semitone() + 12 - self.semitone()) % 12
}
}

Matching on two values at once

The everyday name of a pitch class depends on two things: which one it is, and whether the black keys are written as sharps or as flats. match can look at both at once when they are put in a tuple:

#![allow(unused)]
fn main() {
pub fn name(self, spelling: Spelling) -> &'static str {
    use PitchClass::*;
    use Spelling::*;
    match (self, spelling) {
        (C, _) => "C",
        (CSharp, Sharps) => "C#",
        (CSharp, Flats) => "Db",
        (D, _) => "D",
        // ...
        (B, _) => "B",
    }
}
}
  • use PitchClass::*; inside the function lets it write C instead of PitchClass::C, without spreading those short names over the whole file.
  • _ matches anything: a white key has the same name either way.
  • The compiler checks that the arms cover all 24 combinations. Leave out (GSharp, Flats) and it refuses to build, naming the missing case.
  • &'static str is a borrowed string that lives as long as the program: string literals are stored in the program itself.

Spelling is a two-variant enum, Sharps (the #[default]) and Flats. Which one a key uses, and the finer choice of a letter for each note, is in Scales, modes and keys.

is_black_key uses the matches! macro, a short way of asking “does this value match this pattern?” as a bool. The | means “or”:

#![allow(unused)]
fn main() {
pub fn is_black_key(self) -> bool {
    use PitchClass::*;
    matches!(self, CSharp | DSharp | FSharp | GSharp | ASharp)
}
}

PitchClass also has a Display implementation, so {} in format! writes it with sharps, in letters or in solfège as the settings say (Two languages: translations, and letters or solfège).

A note: a MIDI number in a type of its own

A note with its octave is stored as its MIDI note number, 0 to 127, wrapped in a struct of its own:

#![allow(unused)]
fn main() {
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(from = "u8", into = "u8")]
pub struct Note(u8);
}

A struct with one field used to give a value a type of its own is a newtype. A Note is a u8 in memory, but the compiler will not let a note be passed where a velocity (also a u8) is expected, or the other way round. The field is private, so the only ways to make a note are the ones Note offers, and they keep it in range:

#![allow(unused)]
fn main() {
pub fn new(midi: u8) -> Self {
    Note(midi.min(Self::MAX.0))
}
}

The same goes for files. #[serde(from = "u8", into = "u8")] tells serde to save a note as its number and to read it back through From<u8>, which calls Note::new, so not even a hand-edited project file can hold a note above 127:

#![allow(unused)]
fn main() {
impl From<u8> for Note {
    fn from(midi: u8) -> Self {
        Note::new(midi)
    }
}
}

Some notes have names in the code: Note::C4 is middle C (60), Note::A4 the tuning A (69), Note::MIN and Note::MAX the ends of the range.

Octaves

The pitch class is the number modulo 12, and the octave is the number divided by 12, minus one, because MIDI note 60 is middle C and middle C is called C4:

#![allow(unused)]
fn main() {
pub fn octave(self) -> i8 {
    (self.0 / 12) as i8 - 1
}
}

So the lowest MIDI note is C-1 and the highest G9. The octave goes with the letter, not with the key: it changes between B and C, so the key a semitone above B3 is C4.

Moving a note: Option for “it might not exist”

A pitch class always has a pitch class seven semitones above it; a note does not. Seven semitones above G9 is past the top of MIDI. transpose returns an Option<Note>, and None when the new note would be out of range:

#![allow(unused)]
fn main() {
pub fn transpose(self, semitones: i32) -> Option<Note> {
    let midi = self.0 as i32 + semitones;
    (0..=Self::MAX.0 as i32)
        .contains(&midi)
        .then_some(Note(midi as u8))
}
}

contains asks whether a range holds a number, and then_some turns a bool into an Option: Some of the value if true, None if false. The callers decide what an impossible note means: a chord built near the top of the keyboard leaves its highest notes out, and the transpose tool refuses to move a selection at all if one of its notes would fall off the keyboard, so no note is lost (Editing tools as plain functions).

The frequency

Ferrous Lineup tunes the way pianos are tuned, in twelve-tone equal temperament: every semitone multiplies the frequency by the same number, the twelfth root of two (about 1.0595), so twelve semitones exactly double it. A4 is 440 Hz:

#![allow(unused)]
fn main() {
pub fn frequency(self) -> f32 {
    let semitones_from_a4 = self.0 as f32 - Self::A4.0 as f32;
    440.0 * 2.0_f32.powf(semitones_from_a4 / 12.0)
}
}

Middle C is nine semitones below A4, so its frequency is 440 · 2^(-9/12), about 261.63 Hz. The synth plays this frequency, and a sampler plays a recording faster or slower by the same rule (Instruments: drums, sampler and Lineup Osc).

Letters and names

What a note is called is a letter and some sharps or flats. The letters are an enum of their own, the seven white keys:

#![allow(unused)]
fn main() {
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum Letter {
    C,
    D,
    E,
    F,
    G,
    A,
    B,
}
}

Letters go round in sevens as pitch classes go round in twelves, so they have the same kind of methods: the white key’s semitone above C, read from a table, and moving up or down with rem_euclid:

#![allow(unused)]
fn main() {
pub fn semitone(self) -> u8 {
    [0, 2, 4, 5, 7, 9, 11][self as usize]
}

pub fn up(self, steps: i32) -> Letter {
    Self::ALL[(self as i32 + steps).rem_euclid(7) as usize]
}
}

Two letters above A is C (A, B, C), and steps_up_to counts the other way: from A to C is 2.

A name is a letter and a count of signs, positive for sharps and negative for flats:

#![allow(unused)]
fn main() {
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct NoteName {
    pub letter: Letter,
    /// Sharps if above zero, flats if below: 1 is "#", -2 is "bb".
    pub accidental: i8,
}
}

One i8 holds both directions, so a name can never be “a sharp and a flat” at once, and F𝄪 is just NoteName::new(Letter::F, 2). The key of the piano a name stands for is its letter’s white key moved by the signs:

#![allow(unused)]
fn main() {
pub fn pitch_class(self) -> PitchClass {
    PitchClass::from_semitone(self.letter.semitone()).transpose(i32::from(self.accidental))
}
}

A name for a key, with a given letter

Most of the time the code knows the key and the letter it wants, and needs the signs. B with the letter C is C♭ (one flat); F with the letter E is E♯ (one sharp). with_letter counts how far up from the letter’s white key the pitch class is, and takes the shorter way round: up to five semitones up is that many sharps, six or more is flats below:

#![allow(unused)]
fn main() {
pub fn with_letter(pitch_class: PitchClass, letter: Letter) -> Self {
    let up = (pitch_class.semitone() + 12 - letter.semitone()) % 12;
    // Up to five semitones up is sharps; six or more is flats below.
    let accidental = if up <= 5 { up as i8 } else { up as i8 - 12 };
    NoteName::new(letter, accidental)
}
}

G with the letter F is two up, F𝄪; A with the letter B is ten up, so two below, B𝄫.

Two simpler names are built on it. plain takes the letter from the everyday name, so it gives C♯ or D♭ for the black key and the white keys’ own letters. chromatic is the name used when there is no key to follow, as in twelve-tone music: C, C♯, D, E♭, E, F, F♯, G, A♭, A, B♭, B, with sharps for two black keys and flats for the other three:

#![allow(unused)]
fn main() {
pub fn chromatic(pitch_class: PitchClass) -> Self {
    let spelling = match pitch_class {
        PitchClass::CSharp | PitchClass::FSharp => Spelling::Sharps,
        _ => Spelling::Flats,
    };
    NoteName::plain(pitch_class, spelling)
}
}

Choosing the letter is the key’s job: each note of a key takes the next letter, and a note from outside the key takes the letter musicians most often use for it. That is Scale::spell, in Scales, modes and keys.

Intervals count letters too

An interval has a size in semitones and a size in letters. A major third is four semitones and two letters: above C it is E, above A it is C♯ (not D♭, which is three letters up), and above E♭ it is G. above moves the key by the semitones, the letter by the letters, and lets with_letter find the signs:

#![allow(unused)]
fn main() {
pub fn above(self, semitones: u8, letters: u8) -> NoteName {
    let pitch_class = self.pitch_class().transpose(i32::from(semitones));
    NoteName::with_letter(pitch_class, self.letter.up(i32::from(letters)))
}
}

The diminished seventh is nine semitones and six letters: above B it is A♭, and above C it is B𝄫. That is how every chord is spelled from its root (Chords: building, naming and inversions), and how a chart is moved to another key, letter by letter, the way a musician transposes it.

A transposed chord symbol sometimes lands on a name with two signs. Moving a chart from E minor to G♯ minor moves everything up four semitones and two letters, so the D♯°7 of E minor becomes F𝄪°7. That is right on a score, but a chord symbol is read at a glance, so symbols take the simpler name of the same key:

#![allow(unused)]
fn main() {
pub fn simplified(self) -> NoteName {
    if self.accidental.abs() < 2 {
        return self;
    }
    let spelling = if self.accidental > 0 {
        Spelling::Sharps
    } else {
        Spelling::Flats
    };
    NoteName::plain(self.pitch_class(), spelling)
}
}

E𝄫 becomes D, F𝄪 becomes G, and the D♯°7 above becomes G°7.

The octave of a spelled note

A note’s own name, Note::name, uses the everyday name, so middle C is always “C4”. But when a key spells middle C as B♯, it must be written “B♯3”: the octave number goes with the letter, and B♯3 is the B of the octave below, raised a semitone. In the same way, the B below middle C written as C♭ is “C♭4”. name_as takes away the letter and the signs to find the C at the bottom of the letter’s octave, and numbers the octave from there:

#![allow(unused)]
fn main() {
pub fn name_as(self, name: NoteName) -> String {
    let natural =
        i32::from(self.0) - i32::from(name.letter.semitone()) - i32::from(name.accidental);
    format!("{name}{}", natural.div_euclid(12) - 1)
}
}

For middle C written B♯, that is 60 - 11 - 1 = 48, the MIDI number of C3, so the name is B♯3. The voice-leading check and the ear trainer write notes this way.

Writing and reading a name

Writing a name repeats the sign as many times as the count says:

#![allow(unused)]
fn main() {
pub fn format(self, naming: NoteNaming) -> String {
    let sign = if self.accidental > 0 { "#" } else { "b" };
    let accidentals = sign.repeat(self.accidental.unsigned_abs() as usize);
    match naming {
        NoteNaming::Letters => format!("{}{accidentals}", self.letter.as_char()),
        NoteNaming::Solfege => format!("{}{accidentals}", self.letter.solfege()),
    }
}
}

unsigned_abs turns -2 into 2 as an unsigned number, which can never overflow (abs on an i8 would for -128). With no signs, repeat(0) gives an empty string.

Reading is the other way, and what the app writes it must be able to read back. In G♯ harmonic minor the chord on the seventh note is F𝄪°7, saved as F##dim7, so read takes up to two signs, both in the same direction. It reads a name at the start of a text and returns the rest, so "F#m7" gives F♯ and "m7":

#![allow(unused)]
fn main() {
let sign = |c| match c {
    '#' | '♯' => 1,
    'b' | '♭' => -1,
    _ => 0,
};
let mut accidental = 0;
for _ in 0..2 {
    let mut chars = rest.chars();
    let step = chars.next().map_or(0, sign);
    if step == 0 || (accidental != 0 && step != accidental) {
        break;
    }
    accidental += step;
    rest = chars.as_str();
}
}
  • sign is a closure kept in a variable. Its argument’s type is not written: the compiler works it out from how the closure is used, on characters.
  • chars() walks the text one character at a time, and as_str() gives back what has not been walked yet. That is the safe way to step over “♯”, which takes three bytes in UTF-8, where cutting the string at a byte count could cut a character in half.
  • map_or(0, sign) gives 0 when the text has ended, and the sign of the next character otherwise.
  • The loop stops at a character that is not a sign, at a sign in the other direction, and after two signs. So "Ebbb" reads E𝄫 and leaves "b", and "F#b" reads F♯ and leaves "b".

The letter itself must be a capital. Letter::from_char refuses 'c', because in a chord symbol a small “b” is a flat: “Bb” is B♭, not two B’s. The same reading in solfège, “Sib” for B♭, and how the app reads a name typed either way, are in Two languages: translations, and letters or solfège.

A set of pitch classes in twelve bits

Chords and scales are sets of pitch classes. A set of at most twelve things fits in the bits of one integer: bit 0 means “C is in the set”, bit 1 “C♯ is in the set”, and so on up to bit 11 for B.

#![allow(unused)]
fn main() {
pub struct PitchSet(u16);
}
#![allow(unused)]
fn main() {
pub fn insert(&mut self, pitch_class: PitchClass) {
    self.0 |= 1 << pitch_class.semitone();
}

pub fn contains(self, pitch_class: PitchClass) -> bool {
    self.0 & (1 << pitch_class.semitone()) != 0
}
}

1 << n is the number with only bit n set (1 shifted left by n places). |= sets that bit, and & keeps only the bits two numbers share, so x & (1 << n) != 0 asks whether bit n is set. A C major chord, C, E and G, is bits 0, 4 and 7:

bit:       11 10  9  8  7  6  5  4  3  2  1  0
           B  A♯ A  G♯ G  F♯ F  E  D♯ D  C♯ C
C major:   0  0  0  0  1  0  0  1  0  0  0  1   = 0b0000_1001_0001

Other set questions are one instruction each. count_ones() counts the members, and a & !b == 0 asks whether every member of a is also in b (! flips every bit, so a & !b is what a has and b lacks):

#![allow(unused)]
fn main() {
pub fn is_subset_of(self, other: PitchSet) -> bool {
    self.0 & !other.0 == 0
}
}

That is how the code asks whether a chord belongs to a key: A minor’s A, C and E are all in the C major scale, E major’s G♯ is not. And because a set is one small number, comparing two chords is a single ==, and a set can be a key in a HashMap.

iter gives the members from C upwards by keeping the pitch classes the set contains:

#![allow(unused)]
fn main() {
pub fn iter(self) -> impl Iterator<Item = PitchClass> {
    PitchClass::ALL
        .into_iter()
        .filter(move |&pitch_class| self.contains(pitch_class))
}
}

move gives the closure its own copy of self (a PitchSet is Copy), because the iterator is returned and outlives the call.

Seeing a chord from its root

The trick behind naming chords is moving a whole set up or down. In bits, that is a rotation of the twelve bits: the ones that go past B come back in at C.

#![allow(unused)]
fn main() {
pub fn transpose(self, semitones: u8) -> PitchSet {
    let shift = (semitones % 12) as u32;
    let bits = self.0 as u32;
    // Rotate the 12 bits left by `shift`: bits that go past B come
    // back in at C.
    let rotated = (bits << shift) | (bits >> (12 - shift));
    PitchSet(rotated as u16 & ALL_BITS)
}
}

bits << shift moves every member up; the ones that went past bit 11 are dropped by the mask ALL_BITS (twelve ones). bits >> (12 - shift) brings exactly those back in at the bottom. C major moved up by 9 has A (bit 9), C♯ (bit 13, back in as bit 1) and E (bit 16, back in as bit 4): A major.

relative_to moves a set down until a given root becomes C:

#![allow(unused)]
fn main() {
pub fn relative_to(self, root: PitchClass) -> PitchSet {
    self.transpose(12 - root.semitone())
}
}

E minor, E G B, seen from E is C D♯ G: semitones 0, 3 and 7, the shape of every minor triad. Seen from its root, every chord of one kind is the same number, whatever its root. Chords: building, naming and inversions names a chord by finding the root from which its set matches a known shape.

Making collect build a PitchSet

collect() can build any type that implements the FromIterator trait; Vec, String and HashMap do. Implementing it for PitchSet lets any code turn notes into a set in one line, such as notes.map(|note| note.pitch_class()).collect::<PitchSet>():

#![allow(unused)]
fn main() {
impl FromIterator<PitchClass> for PitchSet {
    fn from_iter<I: IntoIterator<Item = PitchClass>>(iter: I) -> Self {
        let mut set = PitchSet::EMPTY;
        for pitch_class in iter {
            set.insert(pitch_class);
        }
        set
    }
}
}

from_semitones(&[0, 4, 7]) builds a set from numbers the same way, by mapping each one to a pitch class and collecting.

In a file

A project file stores a set as its semitones above C, such as "0 3 6 9", which is easier to read and to write by hand than the number 585. The attribute #[serde(try_from = "String", into = "String")] saves it through a String and reads it back through TryFrom<String>, which refuses anything that is not a number from 0 to 11. The same pair of conversions, and why the reading one is try_from, is in Twelve-tone rows and their rules.

Testing it

The tests in pitch.rs and pitch_set.rs read like the rules of this chapter. Names and octaves, at both ends of the range:

#![allow(unused)]
fn main() {
assert_eq!(Note::new(61).to_string(), "C#4");
assert_eq!(Note::new(61).name(Spelling::Flats), "Db4");
assert_eq!(Note::MIN.to_string(), "C-1");
assert_eq!(Note::MAX.to_string(), "G9");
}

Names with any letter, and the chromatic names:

#![allow(unused)]
fn main() {
assert_eq!(name(F, Letter::E), "E#");
assert_eq!(name(B, Letter::C), "Cb");
assert_eq!(name(G, Letter::F), "F##");
assert_eq!(name(A, Letter::B), "Bbb");
}
#![allow(unused)]
fn main() {
assert_eq!(chromatic.join(" "), "C C# D Eb E F F# G Ab A Bb B");
}

Intervals counted in letters, and the octave of a spelled note:

#![allow(unused)]
fn main() {
// A major third above A is C#, not Db.
assert_eq!(a.above(4, 2).to_string(), "C#");
}
#![allow(unused)]
fn main() {
assert_eq!(c4.name_as(NoteName::new(Letter::B, 1)), "B#3");
assert_eq!(Note::new(59).name_as(NoteName::new(Letter::C, -1)), "Cb4");
}

And reading, including what must not be read:

#![allow(unused)]
fn main() {
assert_eq!(read("F##dim7"), Some(("F##".to_string(), "dim7")));
assert_eq!(read("Ebbb"), Some(("Ebb".to_string(), "b")), "two at most");
assert_eq!(read("F#b"), Some(("F#".to_string(), "b")), "not mixed");
assert_eq!(read("c"), None, "lower case is a flat, not a note");
}

The sets’ tests check that transposing wraps around (C major up 9 is A major, up 12 is itself) and that E minor seen from E is the set 0 3 7.

Exercises on these types are in Exercises: music theory in code.