Skip to content

Commit acb9576

Browse files
authored
Transpose (#51)
* 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
1 parent 169bd12 commit acb9576

File tree

6 files changed

+306
-16
lines changed

6 files changed

+306
-16
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
# Changelog
22

3+
## [Unreleased]
4+
5+
* 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)).
6+
37
## [0.5.0] - 2020-01-02
48

59
* Add subcommand `name` for looking up the chord name(s) corresponding to a given chord fingering ([#18](https://github.com/noeddl/ukebox/issues/18)).

README.md

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@
1313
* shows you how to play a given chord on a ukulele by printing a **chord chart** in ASCII art
1414
* presents the **chord name(s)** corresponding to a chord fingering given in [numeric chord notation](https://ukenut.com/compact-fretted-chord-notation/)
1515
* supports **different ukulele tunings** (C, D and G)
16-
* can present **different fingerings** of the same chord along the fretbord
16+
* can present each chord in **different positions** along the fretbord
17+
* allows you to **transpose** a chord by any number of semitones
1718

1819
## Installation
1920

@@ -47,7 +48,7 @@ SUBCOMMANDS:
4748
name Chord name lookup
4849
```
4950

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

5253
### Chord chart lookup
5354

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

118+
```
119+
$ ukebox chart --transpose 1 C
120+
[C# - C# major]
121+
122+
A ||---|---|---|-o-|- C#
123+
E ||-o-|---|---|---|- F
124+
C ||-o-|---|---|---|- C#
125+
G ||-o-|---|---|---|- G#
126+
```
127+
128+
```
129+
$ ukebox chart --transpose -2 C
130+
[Bb - Bb major]
131+
132+
A ||-o-|---|---|---|- Bb
133+
E ||-o-|---|---|---|- F
134+
C ||---|-o-|---|---|- D
135+
G ||---|---|-o-|---|- Bb
136+
```
137+
116138
### Chord name lookup
117139

118140
Use the subcommand `name` to look up the chord name(s) corresponding to a given chord fingering.

src/chord/chord.rs

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,13 @@ use crate::chord::Tuning;
55
use crate::diagram::ChordDiagram;
66
use crate::note::Note;
77
use crate::note::PitchClass;
8+
use crate::note::Semitones;
89
use regex::Regex;
910
use std::convert::TryFrom;
1011
use std::error::Error;
1112
use std::fmt;
13+
use std::ops::Add;
14+
use std::ops::Sub;
1215
use std::str::FromStr;
1316

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

127+
impl Add<Semitones> for Chord {
128+
type Output = Self;
129+
130+
fn add(self, n: Semitones) -> Self {
131+
Self::new(self.root + n, self.chord_type)
132+
}
133+
}
134+
135+
impl Sub<Semitones> for Chord {
136+
type Output = Self;
137+
138+
fn sub(self, n: Semitones) -> Self {
139+
Self::new(self.root - n, self.chord_type)
140+
}
141+
}
142+
124143
#[cfg(test)]
125144
mod tests {
126145
#![allow(clippy::many_single_char_names)]
@@ -716,4 +735,44 @@ mod tests {
716735
let n = Note::from_str(note).unwrap();
717736
assert_eq!(c.contains(n), contains);
718737
}
738+
739+
#[rstest(
740+
chord,
741+
n,
742+
result,
743+
case("C", 0, "C"),
744+
case("C#", 0, "C#"),
745+
case("Db", 0, "Db"),
746+
case("Cm", 1, "C#m"),
747+
case("Cmaj7", 2, "Dmaj7"),
748+
case("Cdim", 4, "Edim"),
749+
case("C#", 2, "D#"),
750+
case("A#m", 3, "C#m"),
751+
case("A", 12, "A"),
752+
case("A#", 12, "A#"),
753+
case("Ab", 12, "Ab")
754+
)]
755+
fn test_add_semitones(chord: &str, n: Semitones, result: &str) {
756+
let c = Chord::from_str(chord).unwrap();
757+
assert_eq!(c + n, Chord::from_str(result).unwrap());
758+
}
759+
760+
#[rstest(
761+
chord,
762+
n,
763+
result,
764+
case("C", 0, "C"),
765+
case("C#", 0, "C#"),
766+
case("Db", 0, "Db"),
767+
case("Cm", 1, "Bm"),
768+
case("Cmaj7", 2, "Bbmaj7"),
769+
case("Adim", 3, "Gbdim"),
770+
case("A", 12, "A"),
771+
case("A#", 12, "A#"),
772+
case("Ab", 12, "Ab")
773+
)]
774+
fn test_subtract_semitones(chord: &str, n: Semitones, result: &str) {
775+
let c = Chord::from_str(chord).unwrap();
776+
assert_eq!(c - n, Chord::from_str(result).unwrap());
777+
}
719778
}

src/main.rs

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@ enum Subcommand {
2020
/// Minimal fret (= minimal position) from which to play <chord>
2121
#[structopt(short = "f", long, default_value = "0")]
2222
min_fret: FretID,
23+
/// Number of semitones to add (e.g. 1, +1) or to subtract (e.g. -1)
24+
#[structopt(long, allow_hyphen_values = true, default_value = "0")]
25+
transpose: i8,
2326
/// Name of the chord to be shown
2427
chord: Chord,
2528
},
@@ -35,7 +38,18 @@ fn main() {
3538
let tuning = args.tuning;
3639

3740
match args.cmd {
38-
Subcommand::Chart { min_fret, chord } => {
41+
Subcommand::Chart {
42+
min_fret,
43+
transpose,
44+
chord,
45+
} => {
46+
// Transpose chord.
47+
let chord = match transpose {
48+
// Subtract semitones (e.g. -1).
49+
t if t < 0 => chord - transpose.abs() as u8,
50+
// Add semitones (e.g. 1, +1).
51+
_ => chord + transpose as u8,
52+
};
3953
let diagram = chord.get_diagram(min_fret, tuning);
4054
println!("{}", diagram);
4155
}

src/note/note.rs

Lines changed: 130 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
use crate::note::Interval;
22
use crate::note::PitchClass;
3+
use crate::note::Semitones;
34
use crate::note::StaffPosition;
45
use std::fmt;
56
use std::ops::Add;
7+
use std::ops::Sub;
68
use std::str::FromStr;
79

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

29+
impl Note {
30+
pub fn new(pitch_class: PitchClass, staff_position: StaffPosition) -> Self {
31+
Self {
32+
pitch_class,
33+
staff_position,
34+
}
35+
}
36+
37+
/// Return `true` if this note is a "white note", i.e. a note represented
38+
/// by a white key on the piano (i.e. the note is part of the C major scale).
39+
pub fn is_white_note(&self) -> bool {
40+
use PitchClass::*;
41+
42+
matches!(self.pitch_class, C | D | E | F | G | A | B)
43+
}
44+
}
45+
2746
impl PartialEq for Note {
2847
/// Treat two notes as equal if they are represented by the same symbol.
2948
/// For example, `B sharp`, `C` and `D double flat` are all casually
@@ -118,10 +137,7 @@ impl FromStr for Note {
118137
_ => return Err(ParseNoteError { name }),
119138
};
120139

121-
Ok(Self {
122-
pitch_class,
123-
staff_position,
124-
})
140+
Ok(Self::new(pitch_class, staff_position))
125141
}
126142
}
127143

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

145-
Self {
146-
pitch_class,
147-
staff_position,
148-
}
161+
Self::new(pitch_class, staff_position)
149162
}
150163
}
151164

@@ -156,10 +169,48 @@ impl Add<Interval> for Note {
156169
fn add(self, interval: Interval) -> Self {
157170
let pitch_class = self.pitch_class + interval.to_semitones();
158171
let staff_position = self.staff_position + (interval.to_number() - 1);
159-
Self {
160-
pitch_class,
161-
staff_position,
172+
Self::new(pitch_class, staff_position)
173+
}
174+
}
175+
176+
impl Add<Semitones> for Note {
177+
type Output = Self;
178+
179+
fn add(self, n: Semitones) -> Self {
180+
let note = Self::from(self.pitch_class + n);
181+
182+
// Make sure the staff position stays the same if the pitch class
183+
// stays the same (e.g. when adding 0 or 12 semitones).
184+
if note.pitch_class == self.pitch_class {
185+
return Self::new(self.pitch_class, self.staff_position);
162186
}
187+
188+
// Otherwise, the staff position will by default be chosen so that
189+
// sharp/flat notes turn out sharp (e.g. C + 1 = C#).
190+
note
191+
}
192+
}
193+
194+
impl Sub<Semitones> for Note {
195+
type Output = Self;
196+
197+
fn sub(self, n: Semitones) -> Self {
198+
let note = Self::from(self.pitch_class - n);
199+
200+
// Make sure the staff position stays the same if the pitch class
201+
// stays the same (e.g. when subtracting 0 or 12 semitones).
202+
if note.pitch_class == self.pitch_class {
203+
return Self::new(self.pitch_class, self.staff_position);
204+
}
205+
206+
// Otherwise, make sure that the staff position will be chosen so that
207+
// sharp/flat notes turn out flat (e.g. D - 1 = Db).
208+
let staff_position = match note {
209+
n if n.is_white_note() => note.staff_position,
210+
_ => note.staff_position + 1,
211+
};
212+
213+
Self::new(note.pitch_class, staff_position)
163214
}
164215
}
165216

@@ -195,6 +246,32 @@ mod tests {
195246
assert_eq!(format!("{}", note), s);
196247
}
197248

249+
#[rstest(
250+
s,
251+
is_white_note,
252+
case("C", true),
253+
case("C#", false),
254+
case("Db", false),
255+
case("D", true),
256+
case("D#", false),
257+
case("Eb", false),
258+
case("E", true),
259+
case("F", true),
260+
case("F#", false),
261+
case("Gb", false),
262+
case("G", true),
263+
case("G#", false),
264+
case("Ab", false),
265+
case("A", true),
266+
case("A#", false),
267+
case("Bb", false),
268+
case("B", true)
269+
)]
270+
fn test_is_white_note(s: &str, is_white_note: bool) {
271+
let note = Note::from_str(s).unwrap();
272+
assert_eq!(note.is_white_note(), is_white_note);
273+
}
274+
198275
#[rstest(
199276
pitch_class,
200277
s,
@@ -231,4 +308,46 @@ mod tests {
231308
let note = Note::from_str(note_name).unwrap();
232309
assert_eq!(note + interval, Note::from_str(result_name).unwrap());
233310
}
311+
312+
#[rstest(
313+
note_name,
314+
n,
315+
result_name,
316+
case("C", 0, "C"),
317+
case("C#", 0, "C#"),
318+
case("Db", 0, "Db"),
319+
case("C", 1, "C#"),
320+
case("C#", 1, "D"),
321+
case("Db", 1, "D"),
322+
case("C", 3, "D#"),
323+
case("C", 4, "E"),
324+
case("C", 7, "G"),
325+
case("A", 3, "C"),
326+
case("A", 12, "A"),
327+
case("A#", 12, "A#"),
328+
case("Ab", 12, "Ab")
329+
)]
330+
fn test_add_semitones(note_name: &str, n: Semitones, result_name: &str) {
331+
let note = Note::from_str(note_name).unwrap();
332+
assert_eq!(note + n, Note::from_str(result_name).unwrap());
333+
}
334+
335+
#[rstest(
336+
note_name,
337+
n,
338+
result_name,
339+
case("C", 0, "C"),
340+
case("C#", 0, "C#"),
341+
case("Db", 0, "Db"),
342+
case("C", 1, "B"),
343+
case("C", 2, "Bb"),
344+
case("C#", 3, "Bb"),
345+
case("Db", 3, "Bb"),
346+
case("A", 3, "Gb"),
347+
case("A", 12, "A")
348+
)]
349+
fn test_subtract_semitones(note_name: &str, n: Semitones, result_name: &str) {
350+
let note = Note::from_str(note_name).unwrap();
351+
assert_eq!(note - n, Note::from_str(result_name).unwrap());
352+
}
234353
}

0 commit comments

Comments
 (0)