Skip to content

Commit

Permalink
Transpose (#51)
Browse files Browse the repository at this point in the history
* Implement addition and subtraction of Semitones to Notes

* Implement addition and subtraction of Semitones to Chords

* Add --transpose flag

* Add Note::new() function

* Update tests

* Fix addition of semitones to notes

* Fix subtraction of semitones from notes

* Refactor subtraction of semitones from notes

* Refactor addition of semitones to notes

* Add method Note.is_white_note()

* Add integration tests

* Fix comments

* Add tests

* Fix comment

* Update README

* Update changelog

* Fix comment

* Update README

* Add tests
  • Loading branch information
noeddl committed Feb 6, 2021
1 parent 169bd12 commit acb9576
Show file tree
Hide file tree
Showing 6 changed files with 306 additions and 16 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# Changelog

## [Unreleased]

* Add command line option `--transpose` to specify a number of semitones to be added or subtracted before printing the chord chart ([#24](https://github.com/noeddl/ukebox/issues/24)).

## [0.5.0] - 2020-01-02

* Add subcommand `name` for looking up the chord name(s) corresponding to a given chord fingering ([#18](https://github.com/noeddl/ukebox/issues/18)).
Expand Down
30 changes: 26 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@
* shows you how to play a given chord on a ukulele by printing a **chord chart** in ASCII art
* presents the **chord name(s)** corresponding to a chord fingering given in [numeric chord notation](https://ukenut.com/compact-fretted-chord-notation/)
* supports **different ukulele tunings** (C, D and G)
* can present **different fingerings** of the same chord along the fretbord
* can present each chord in **different positions** along the fretbord
* allows you to **transpose** a chord by any number of semitones

## Installation

Expand Down Expand Up @@ -47,7 +48,7 @@ SUBCOMMANDS:
name Chord name lookup
```

When running the program with Rust, replace the command `ukebox` with `cargo run --release`, e.g. `cargo run --release chart G`.
When running the program with Rust, replace the command `ukebox` with `cargo run --release`, e.g. `cargo run --release -- chart G`.

### Chord chart lookup

Expand All @@ -62,8 +63,9 @@ FLAGS:
-V, --version Prints version information
OPTIONS:
-f, --min-fret <min-fret> Minimal fret (= minimal position) from which to play <chord> [default: 0]
-t, --tuning <tuning> Type of tuning to be used [default: C] [possible values: C, D, G]
-f, --min-fret <min-fret> Minimal fret (= minimal position) from which to play <chord> [default: 0]
--transpose <transpose> Number of semitones to add (e.g. 1, +1) or to subtract (e.g. -1) [default: 0]
-t, --tuning <tuning> Type of tuning to be used [default: C] [possible values: C, D, G]
ARGS:
<chord> Name of the chord to be shown
Expand Down Expand Up @@ -113,6 +115,26 @@ A -|---|---|-o-|---|- D
3
```

```
$ ukebox chart --transpose 1 C
[C# - C# major]
A ||---|---|---|-o-|- C#
E ||-o-|---|---|---|- F
C ||-o-|---|---|---|- C#
G ||-o-|---|---|---|- G#
```

```
$ ukebox chart --transpose -2 C
[Bb - Bb major]
A ||-o-|---|---|---|- Bb
E ||-o-|---|---|---|- F
C ||---|-o-|---|---|- D
G ||---|---|-o-|---|- Bb
```

### Chord name lookup

Use the subcommand `name` to look up the chord name(s) corresponding to a given chord fingering.
Expand Down
59 changes: 59 additions & 0 deletions src/chord/chord.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,13 @@ use crate::chord::Tuning;
use crate::diagram::ChordDiagram;
use crate::note::Note;
use crate::note::PitchClass;
use crate::note::Semitones;
use regex::Regex;
use std::convert::TryFrom;
use std::error::Error;
use std::fmt;
use std::ops::Add;
use std::ops::Sub;
use std::str::FromStr;

/// Custom error for strings that cannot be parsed into chords.
Expand Down Expand Up @@ -121,6 +124,22 @@ impl TryFrom<&[PitchClass]> for Chord {
}
}

impl Add<Semitones> for Chord {
type Output = Self;

fn add(self, n: Semitones) -> Self {
Self::new(self.root + n, self.chord_type)
}
}

impl Sub<Semitones> for Chord {
type Output = Self;

fn sub(self, n: Semitones) -> Self {
Self::new(self.root - n, self.chord_type)
}
}

#[cfg(test)]
mod tests {
#![allow(clippy::many_single_char_names)]
Expand Down Expand Up @@ -716,4 +735,44 @@ mod tests {
let n = Note::from_str(note).unwrap();
assert_eq!(c.contains(n), contains);
}

#[rstest(
chord,
n,
result,
case("C", 0, "C"),
case("C#", 0, "C#"),
case("Db", 0, "Db"),
case("Cm", 1, "C#m"),
case("Cmaj7", 2, "Dmaj7"),
case("Cdim", 4, "Edim"),
case("C#", 2, "D#"),
case("A#m", 3, "C#m"),
case("A", 12, "A"),
case("A#", 12, "A#"),
case("Ab", 12, "Ab")
)]
fn test_add_semitones(chord: &str, n: Semitones, result: &str) {
let c = Chord::from_str(chord).unwrap();
assert_eq!(c + n, Chord::from_str(result).unwrap());
}

#[rstest(
chord,
n,
result,
case("C", 0, "C"),
case("C#", 0, "C#"),
case("Db", 0, "Db"),
case("Cm", 1, "Bm"),
case("Cmaj7", 2, "Bbmaj7"),
case("Adim", 3, "Gbdim"),
case("A", 12, "A"),
case("A#", 12, "A#"),
case("Ab", 12, "Ab")
)]
fn test_subtract_semitones(chord: &str, n: Semitones, result: &str) {
let c = Chord::from_str(chord).unwrap();
assert_eq!(c - n, Chord::from_str(result).unwrap());
}
}
16 changes: 15 additions & 1 deletion src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ enum Subcommand {
/// Minimal fret (= minimal position) from which to play <chord>
#[structopt(short = "f", long, default_value = "0")]
min_fret: FretID,
/// Number of semitones to add (e.g. 1, +1) or to subtract (e.g. -1)
#[structopt(long, allow_hyphen_values = true, default_value = "0")]
transpose: i8,
/// Name of the chord to be shown
chord: Chord,
},
Expand All @@ -35,7 +38,18 @@ fn main() {
let tuning = args.tuning;

match args.cmd {
Subcommand::Chart { min_fret, chord } => {
Subcommand::Chart {
min_fret,
transpose,
chord,
} => {
// Transpose chord.
let chord = match transpose {
// Subtract semitones (e.g. -1).
t if t < 0 => chord - transpose.abs() as u8,
// Add semitones (e.g. 1, +1).
_ => chord + transpose as u8,
};
let diagram = chord.get_diagram(min_fret, tuning);
println!("{}", diagram);
}
Expand Down
141 changes: 130 additions & 11 deletions src/note/note.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
use crate::note::Interval;
use crate::note::PitchClass;
use crate::note::Semitones;
use crate::note::StaffPosition;
use std::fmt;
use std::ops::Add;
use std::ops::Sub;
use std::str::FromStr;

/// Custom error for strings that cannot be parsed into notes.
Expand All @@ -24,6 +26,23 @@ pub struct Note {
staff_position: StaffPosition,
}

impl Note {
pub fn new(pitch_class: PitchClass, staff_position: StaffPosition) -> Self {
Self {
pitch_class,
staff_position,
}
}

/// Return `true` if this note is a "white note", i.e. a note represented
/// by a white key on the piano (i.e. the note is part of the C major scale).
pub fn is_white_note(&self) -> bool {
use PitchClass::*;

matches!(self.pitch_class, C | D | E | F | G | A | B)
}
}

impl PartialEq for Note {
/// Treat two notes as equal if they are represented by the same symbol.
/// For example, `B sharp`, `C` and `D double flat` are all casually
Expand Down Expand Up @@ -118,10 +137,7 @@ impl FromStr for Note {
_ => return Err(ParseNoteError { name }),
};

Ok(Self {
pitch_class,
staff_position,
})
Ok(Self::new(pitch_class, staff_position))
}
}

Expand All @@ -142,10 +158,7 @@ impl From<PitchClass> for Note {
B => BPos,
};

Self {
pitch_class,
staff_position,
}
Self::new(pitch_class, staff_position)
}
}

Expand All @@ -156,10 +169,48 @@ impl Add<Interval> for Note {
fn add(self, interval: Interval) -> Self {
let pitch_class = self.pitch_class + interval.to_semitones();
let staff_position = self.staff_position + (interval.to_number() - 1);
Self {
pitch_class,
staff_position,
Self::new(pitch_class, staff_position)
}
}

impl Add<Semitones> for Note {
type Output = Self;

fn add(self, n: Semitones) -> Self {
let note = Self::from(self.pitch_class + n);

// Make sure the staff position stays the same if the pitch class
// stays the same (e.g. when adding 0 or 12 semitones).
if note.pitch_class == self.pitch_class {
return Self::new(self.pitch_class, self.staff_position);
}

// Otherwise, the staff position will by default be chosen so that
// sharp/flat notes turn out sharp (e.g. C + 1 = C#).
note
}
}

impl Sub<Semitones> for Note {
type Output = Self;

fn sub(self, n: Semitones) -> Self {
let note = Self::from(self.pitch_class - n);

// Make sure the staff position stays the same if the pitch class
// stays the same (e.g. when subtracting 0 or 12 semitones).
if note.pitch_class == self.pitch_class {
return Self::new(self.pitch_class, self.staff_position);
}

// Otherwise, make sure that the staff position will be chosen so that
// sharp/flat notes turn out flat (e.g. D - 1 = Db).
let staff_position = match note {
n if n.is_white_note() => note.staff_position,
_ => note.staff_position + 1,
};

Self::new(note.pitch_class, staff_position)
}
}

Expand Down Expand Up @@ -195,6 +246,32 @@ mod tests {
assert_eq!(format!("{}", note), s);
}

#[rstest(
s,
is_white_note,
case("C", true),
case("C#", false),
case("Db", false),
case("D", true),
case("D#", false),
case("Eb", false),
case("E", true),
case("F", true),
case("F#", false),
case("Gb", false),
case("G", true),
case("G#", false),
case("Ab", false),
case("A", true),
case("A#", false),
case("Bb", false),
case("B", true)
)]
fn test_is_white_note(s: &str, is_white_note: bool) {
let note = Note::from_str(s).unwrap();
assert_eq!(note.is_white_note(), is_white_note);
}

#[rstest(
pitch_class,
s,
Expand Down Expand Up @@ -231,4 +308,46 @@ mod tests {
let note = Note::from_str(note_name).unwrap();
assert_eq!(note + interval, Note::from_str(result_name).unwrap());
}

#[rstest(
note_name,
n,
result_name,
case("C", 0, "C"),
case("C#", 0, "C#"),
case("Db", 0, "Db"),
case("C", 1, "C#"),
case("C#", 1, "D"),
case("Db", 1, "D"),
case("C", 3, "D#"),
case("C", 4, "E"),
case("C", 7, "G"),
case("A", 3, "C"),
case("A", 12, "A"),
case("A#", 12, "A#"),
case("Ab", 12, "Ab")
)]
fn test_add_semitones(note_name: &str, n: Semitones, result_name: &str) {
let note = Note::from_str(note_name).unwrap();
assert_eq!(note + n, Note::from_str(result_name).unwrap());
}

#[rstest(
note_name,
n,
result_name,
case("C", 0, "C"),
case("C#", 0, "C#"),
case("Db", 0, "Db"),
case("C", 1, "B"),
case("C", 2, "Bb"),
case("C#", 3, "Bb"),
case("Db", 3, "Bb"),
case("A", 3, "Gb"),
case("A", 12, "A")
)]
fn test_subtract_semitones(note_name: &str, n: Semitones, result_name: &str) {
let note = Note::from_str(note_name).unwrap();
assert_eq!(note - n, Note::from_str(result_name).unwrap());
}
}
Loading

0 comments on commit acb9576

Please sign in to comment.