diff --git a/CHANGELOG.md b/CHANGELOG.md index 85111d0..4908dec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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)). diff --git a/README.md b/README.md index f1fae27..58c68cc 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 @@ -62,8 +63,9 @@ FLAGS: -V, --version Prints version information OPTIONS: - -f, --min-fret Minimal fret (= minimal position) from which to play [default: 0] - -t, --tuning Type of tuning to be used [default: C] [possible values: C, D, G] + -f, --min-fret Minimal fret (= minimal position) from which to play [default: 0] + --transpose Number of semitones to add (e.g. 1, +1) or to subtract (e.g. -1) [default: 0] + -t, --tuning Type of tuning to be used [default: C] [possible values: C, D, G] ARGS: Name of the chord to be shown @@ -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. diff --git a/src/chord/chord.rs b/src/chord/chord.rs index 77be025..414ef94 100644 --- a/src/chord/chord.rs +++ b/src/chord/chord.rs @@ -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. @@ -121,6 +124,22 @@ impl TryFrom<&[PitchClass]> for Chord { } } +impl Add for Chord { + type Output = Self; + + fn add(self, n: Semitones) -> Self { + Self::new(self.root + n, self.chord_type) + } +} + +impl Sub 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)] @@ -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()); + } } diff --git a/src/main.rs b/src/main.rs index 23b573d..ad9d33b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -20,6 +20,9 @@ enum Subcommand { /// Minimal fret (= minimal position) from which to play #[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, }, @@ -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); } diff --git a/src/note/note.rs b/src/note/note.rs index 0fa61e9..50d60f3 100644 --- a/src/note/note.rs +++ b/src/note/note.rs @@ -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. @@ -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 @@ -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)) } } @@ -142,10 +158,7 @@ impl From for Note { B => BPos, }; - Self { - pitch_class, - staff_position, - } + Self::new(pitch_class, staff_position) } } @@ -156,10 +169,48 @@ impl Add 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 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 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) } } @@ -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, @@ -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()); + } } diff --git a/tests/ukebox.rs b/tests/ukebox.rs index c526866..7f7dfee 100644 --- a/tests/ukebox.rs +++ b/tests/ukebox.rs @@ -5,6 +5,7 @@ /// using the command line parameters and the actual output is compared to the /// expected output. use assert_cmd::prelude::*; // Add methods on commands +use indoc::indoc; use predicates::prelude::*; // Used for writing assertions use rstest::rstest; use std::collections::HashMap; @@ -817,3 +818,74 @@ fn test_half_diminished_seventh_chords(tuning: Tuning) -> Result<(), Box Result<(), Box> { + let mut cmd = Command::cargo_bin("ukebox")?; + cmd.arg("chart"); + cmd.arg("--transpose").arg(semitones); + cmd.arg(chord); + cmd.assert().success().stdout(chart); + + Ok(()) +}