fet 0.2.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.github/workflows/main.yml +18 -0
- data/.gitignore +11 -0
- data/.rubocop.yml +47 -0
- data/.ruby_version +1 -0
- data/CHANGELOG.md +5 -0
- data/Gemfile +19 -0
- data/Gemfile.lock +71 -0
- data/LICENSE +21 -0
- data/LICENSE.txt +21 -0
- data/README.md +113 -0
- data/README.rdoc +6 -0
- data/Rakefile +23 -0
- data/bin/bash_scripts/cleanup_listening_exercises.sh +2 -0
- data/bin/bash_scripts/cleanup_singing_exercises.sh +2 -0
- data/bin/bash_scripts/midi_to_mp3.sh +13 -0
- data/bin/console +15 -0
- data/bin/fet +87 -0
- data/bin/setup +8 -0
- data/fet.gemspec +41 -0
- data/fet.rdoc +94 -0
- data/lib/fet/chord_progression.rb +55 -0
- data/lib/fet/cli/generate/listening.rb +20 -0
- data/lib/fet/cli/generate/singing.rb +18 -0
- data/lib/fet/cli/generate/single_note_listening.rb +17 -0
- data/lib/fet/degree.rb +44 -0
- data/lib/fet/degrees.rb +61 -0
- data/lib/fet/exceptions.rb +18 -0
- data/lib/fet/generator/listening.rb +107 -0
- data/lib/fet/generator/singing.rb +89 -0
- data/lib/fet/generator/single_note_listening.rb +67 -0
- data/lib/fet/hardcoded_midi_values.rb +24 -0
- data/lib/fet/instrument_ranges.rb +19 -0
- data/lib/fet/midi_note.rb +40 -0
- data/lib/fet/midilib_interface.rb +134 -0
- data/lib/fet/music_theory.rb +116 -0
- data/lib/fet/note.rb +138 -0
- data/lib/fet/note_validations.rb +58 -0
- data/lib/fet/version.rb +5 -0
- data/lib/fet.rb +10 -0
- metadata +147 -0
data/bin/setup
ADDED
data/fet.gemspec
ADDED
@@ -0,0 +1,41 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "lib/fet/version"
|
4
|
+
|
5
|
+
Gem::Specification.new do |spec|
|
6
|
+
spec.name = "fet"
|
7
|
+
spec.version = Fet::VERSION
|
8
|
+
spec.authors = ["Dimitrios Lisenko"]
|
9
|
+
spec.email = ["dimitrioslisenko@gmail.com"]
|
10
|
+
|
11
|
+
spec.summary = "A functional ear trainer."
|
12
|
+
spec.description = "Teaches your ear to recognize notes based on their function in a key."
|
13
|
+
spec.homepage = "https://github.com/DimitriosLisenko/fet"
|
14
|
+
spec.license = "MIT"
|
15
|
+
spec.required_ruby_version = ">= 3.0.0"
|
16
|
+
|
17
|
+
spec.metadata["homepage_uri"] = spec.homepage
|
18
|
+
spec.metadata["source_code_uri"] = "https://github.com/DimitriosLisenko/fet"
|
19
|
+
spec.metadata["changelog_uri"] = "https://github.com/DimitriosLisenko/fet/blob/master/CHANGELOG.md"
|
20
|
+
|
21
|
+
# Specify which files should be added to the gem when it is released.
|
22
|
+
# The `git ls-files -z` loads the files in the RubyGem that have been added into git.
|
23
|
+
spec.files = Dir.chdir(File.expand_path(__dir__)) do
|
24
|
+
`git ls-files -z`.split("\x0").reject { |f| f.match(%r{\A(?:test|spec|features)/}) }
|
25
|
+
end
|
26
|
+
spec.bindir = "bin"
|
27
|
+
spec.executables = ["fet"]
|
28
|
+
spec.require_paths = ["lib"]
|
29
|
+
|
30
|
+
# Gem runtime dependencies - place development dependencies inside Gemfile
|
31
|
+
spec.add_dependency "gli", "~> 2.20", ">= 2.20.1"
|
32
|
+
spec.add_dependency "ice_nine", "~> 0.11.2"
|
33
|
+
spec.add_dependency "midilib", "~> 2.0", ">= 2.0.5"
|
34
|
+
|
35
|
+
# RDoc configuration
|
36
|
+
spec.extra_rdoc_files = ["README.rdoc", "fet.rdoc"]
|
37
|
+
spec.rdoc_options << "--title" << "fet" << "--main" << "README.rdoc" << "-ri"
|
38
|
+
|
39
|
+
# For more information and examples about making a new gem, checkout our
|
40
|
+
# guide at: https://bundler.io/guides/creating_gem.html
|
41
|
+
end
|
data/fet.rdoc
ADDED
@@ -0,0 +1,94 @@
|
|
1
|
+
== fet - Functional Ear Trainer
|
2
|
+
|
3
|
+
v0.2.0
|
4
|
+
|
5
|
+
=== Global Options
|
6
|
+
=== --help
|
7
|
+
Show this message
|
8
|
+
|
9
|
+
|
10
|
+
|
11
|
+
=== --version
|
12
|
+
Display the program version
|
13
|
+
|
14
|
+
|
15
|
+
|
16
|
+
=== Commands
|
17
|
+
==== Command: <tt>generate </tt>
|
18
|
+
Generate MIDI files for ear training
|
19
|
+
|
20
|
+
|
21
|
+
===== Commands
|
22
|
+
====== Command: <tt>listening </tt>
|
23
|
+
Generate MIDI files for listening
|
24
|
+
|
25
|
+
Each MIDI file will contain a chord progression, followed by the specified number of degrees - first harmonically, then melodically after a pause.
|
26
|
+
======= Options
|
27
|
+
======= -d|--degrees arg
|
28
|
+
|
29
|
+
Number of degrees to play
|
30
|
+
|
31
|
+
[Default Value] 1
|
32
|
+
[Must Match] ["1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11"]
|
33
|
+
|
34
|
+
|
35
|
+
======= -e|--exercises arg
|
36
|
+
|
37
|
+
Number of exercises to generate
|
38
|
+
|
39
|
+
[Default Value] 200
|
40
|
+
|
41
|
+
|
42
|
+
======= -t|--tempo arg
|
43
|
+
|
44
|
+
Tempo at which the chord progression is played at
|
45
|
+
|
46
|
+
[Default Value] 120
|
47
|
+
|
48
|
+
|
49
|
+
======= -a|--[no-]all-single-degree
|
50
|
+
Generate all single degree listening exercises (ignores -e and -d flag)
|
51
|
+
|
52
|
+
|
53
|
+
|
54
|
+
====== Command: <tt>singing </tt>
|
55
|
+
Generate MIDI files for singing
|
56
|
+
|
57
|
+
Each MIDI file will contain a chord progression, followed by a specified pause, during which the degree should be sung. The degree is then played for confirmation.
|
58
|
+
======= Options
|
59
|
+
======= -p|--pause arg
|
60
|
+
|
61
|
+
How many seconds to wait before playing the correct note
|
62
|
+
|
63
|
+
[Default Value] 3
|
64
|
+
|
65
|
+
|
66
|
+
======= -t|--tempo arg
|
67
|
+
|
68
|
+
Tempo at which the chord progression is played at
|
69
|
+
|
70
|
+
[Default Value] 120
|
71
|
+
|
72
|
+
|
73
|
+
====== Command: <tt>single_note_listening </tt>
|
74
|
+
Generate MIDI files for listening (single note)
|
75
|
+
|
76
|
+
Each MIDI file will contain a chord progression, followed the same note across all files.
|
77
|
+
======= Options
|
78
|
+
======= -t|--tempo arg
|
79
|
+
|
80
|
+
Tempo at which the chord progression is played at
|
81
|
+
|
82
|
+
[Default Value] 120
|
83
|
+
|
84
|
+
|
85
|
+
==== Command: <tt>help command</tt>
|
86
|
+
Shows a list of commands or help for one command
|
87
|
+
|
88
|
+
Gets help for the application or its commands. Can also list the commands in a way helpful to creating a bash-style completion function
|
89
|
+
===== Options
|
90
|
+
===== -c
|
91
|
+
List commands one per line, to assist with shell completion
|
92
|
+
|
93
|
+
|
94
|
+
|
@@ -0,0 +1,55 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Fet
|
4
|
+
# Class in charge of generating chord progressions, as well as any associated music theory
|
5
|
+
class ChordProgression
|
6
|
+
TEMPLATE_MAJOR = "major".deep_freeze
|
7
|
+
TEMPLATE_MINOR = "minor".deep_freeze
|
8
|
+
|
9
|
+
DEFAULT_TEMPLATES = {
|
10
|
+
# I-IV-V7-I
|
11
|
+
TEMPLATE_MAJOR => [[0, 4, 7], [0, 5, 9], [-1, 5, 7], [0, 4, 7]],
|
12
|
+
# i-iv-V7-i
|
13
|
+
TEMPLATE_MINOR => [[0, 3, 7], [0, 5, 8], [-1, 5, 7], [0, 3, 7]],
|
14
|
+
}.deep_freeze
|
15
|
+
|
16
|
+
attr_accessor :template, :offset
|
17
|
+
|
18
|
+
def initialize(offset:, template_type: nil, template: nil)
|
19
|
+
self.template = template_type ? DEFAULT_TEMPLATES[template_type] : template
|
20
|
+
validate_template!
|
21
|
+
|
22
|
+
self.offset = offset
|
23
|
+
validate_offset!
|
24
|
+
end
|
25
|
+
|
26
|
+
def with_offset
|
27
|
+
return template.map { |chord| chord.map { |note| note + offset } }
|
28
|
+
end
|
29
|
+
|
30
|
+
private
|
31
|
+
|
32
|
+
def validate_template!
|
33
|
+
validate_nonempty_array!(template, template)
|
34
|
+
template.each do |chord|
|
35
|
+
validate_nonempty_array!(template, chord)
|
36
|
+
chord.each do |offset|
|
37
|
+
validate_integer!(template, offset)
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
def validate_offset!
|
43
|
+
validate_integer!(offset, offset)
|
44
|
+
end
|
45
|
+
|
46
|
+
def validate_nonempty_array!(core, element)
|
47
|
+
raise Fet::InvalidChordProgression.new(core) unless element.is_a?(Array)
|
48
|
+
raise Fet::InvalidChordProgression.new(core) if element.empty?
|
49
|
+
end
|
50
|
+
|
51
|
+
def validate_integer!(core, element)
|
52
|
+
raise Fet::InvalidChordProgression.new(core) unless element.is_a?(Integer)
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
@@ -0,0 +1,20 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Fet
|
4
|
+
module Cli
|
5
|
+
module Generate
|
6
|
+
# CLI implementation for the "generate listening" command
|
7
|
+
module Listening
|
8
|
+
def self.run(_global_options, options, _args)
|
9
|
+
Fet::Generator::Listening.new(
|
10
|
+
exercises: options[:exercises],
|
11
|
+
tempo: options[:tempo],
|
12
|
+
degrees: options[:degrees],
|
13
|
+
all_single_degree: options[:"all-single-degree"],
|
14
|
+
directory_prefix: options[:directory_prefix] || "",
|
15
|
+
).generate
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
@@ -0,0 +1,18 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Fet
|
4
|
+
module Cli
|
5
|
+
module Generate
|
6
|
+
# CLI implementation for the "generate singing" command
|
7
|
+
module Singing
|
8
|
+
def self.run(_global_options, options, _args)
|
9
|
+
Fet::Generator::Singing.new(
|
10
|
+
tempo: options[:tempo],
|
11
|
+
pause: options[:pause],
|
12
|
+
directory_prefix: options[:directory_prefix] || "",
|
13
|
+
).generate
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
@@ -0,0 +1,17 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Fet
|
4
|
+
module Cli
|
5
|
+
module Generate
|
6
|
+
# CLI implementation for the "generate single_note_listening" command
|
7
|
+
module SingleNoteListening
|
8
|
+
def self.run(_global_options, options, _args)
|
9
|
+
Fet::Generator::SingleNoteListening.new(
|
10
|
+
tempo: options[:tempo],
|
11
|
+
directory_prefix: options[:directory_prefix] || "",
|
12
|
+
).generate
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
data/lib/fet/degree.rb
ADDED
@@ -0,0 +1,44 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Fet
|
4
|
+
# This class handles validation of degree names and extraction of values from it
|
5
|
+
class Degree
|
6
|
+
DEGREE_NAMES = [
|
7
|
+
["1"],
|
8
|
+
["#1", "b2"],
|
9
|
+
["2"],
|
10
|
+
["#2", "b3"],
|
11
|
+
["3"],
|
12
|
+
["4"],
|
13
|
+
["#4", "b5"],
|
14
|
+
["5"],
|
15
|
+
["#5", "b6"],
|
16
|
+
["6"],
|
17
|
+
["#6", "b7"],
|
18
|
+
["7"],
|
19
|
+
].deep_freeze
|
20
|
+
|
21
|
+
attr_reader :degree_name
|
22
|
+
|
23
|
+
def initialize(degree_name)
|
24
|
+
self.degree_name = degree_name
|
25
|
+
validate_degree_name!
|
26
|
+
end
|
27
|
+
|
28
|
+
def degree_accidental
|
29
|
+
return degree_name.size == 2 ? degree_name[0] : nil
|
30
|
+
end
|
31
|
+
|
32
|
+
def degree_value
|
33
|
+
return degree_name.size == 2 ? degree_name[1].to_i : degree_name[0].to_i
|
34
|
+
end
|
35
|
+
|
36
|
+
private
|
37
|
+
|
38
|
+
attr_writer :degree_name
|
39
|
+
|
40
|
+
def validate_degree_name!
|
41
|
+
raise InvalidDegreeName.new(degree_name) unless DEGREE_NAMES.flatten.include?(degree_name)
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
data/lib/fet/degrees.rb
ADDED
@@ -0,0 +1,61 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "degree"
|
4
|
+
require_relative "midi_note"
|
5
|
+
|
6
|
+
module Fet
|
7
|
+
# This class handles determining the degrees for a given root
|
8
|
+
class Degrees
|
9
|
+
attr_reader :root_name,
|
10
|
+
:octave_value
|
11
|
+
|
12
|
+
def initialize(root_name:, octave_value:)
|
13
|
+
self.root_name = root_name
|
14
|
+
self.octave_value = octave_value
|
15
|
+
self.degree_to_note_name = generate_degree_to_note_name
|
16
|
+
end
|
17
|
+
|
18
|
+
def root_midi_value
|
19
|
+
return MidiNote.from_note(root_name, octave_value).midi_value
|
20
|
+
end
|
21
|
+
|
22
|
+
def degree_names_of_midi_value(midi_value)
|
23
|
+
return Degree::DEGREE_NAMES[degree_index_of_midi_value(midi_value)]
|
24
|
+
end
|
25
|
+
|
26
|
+
def note_name_of_degree(degree_name)
|
27
|
+
return degree_to_note_name[Degree.new(degree_name).degree_name]
|
28
|
+
end
|
29
|
+
|
30
|
+
private
|
31
|
+
|
32
|
+
attr_writer :root_name,
|
33
|
+
:octave_value
|
34
|
+
|
35
|
+
attr_accessor :degree_to_note_name
|
36
|
+
|
37
|
+
def note_name_of_degree_internal(degree_name)
|
38
|
+
degree = Degree.new(degree_name)
|
39
|
+
|
40
|
+
notes_array = Fet::MusicTheory.notes_of_mode(root_name, "major")
|
41
|
+
note_name = notes_array[degree.degree_value - 1]
|
42
|
+
|
43
|
+
return Note.new(note_name).flattened_note.full_note if degree.degree_accidental == "b"
|
44
|
+
return Note.new(note_name).sharpened_note.full_note if degree.degree_accidental == "#"
|
45
|
+
|
46
|
+
return Note.new(note_name).full_note
|
47
|
+
end
|
48
|
+
|
49
|
+
def generate_degree_to_note_name
|
50
|
+
result = {}
|
51
|
+
Degree::DEGREE_NAMES.flatten.each do |degree_name|
|
52
|
+
result[degree_name] = note_name_of_degree_internal(degree_name)
|
53
|
+
end
|
54
|
+
return result
|
55
|
+
end
|
56
|
+
|
57
|
+
def degree_index_of_midi_value(midi_value)
|
58
|
+
return MidiNote.new(midi_value).degree(root_midi_value)
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
@@ -0,0 +1,18 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Fet
|
4
|
+
class Error < StandardError; end
|
5
|
+
|
6
|
+
class InvalidNote < Error; end
|
7
|
+
|
8
|
+
class InvalidChordProgression < Error; end
|
9
|
+
|
10
|
+
class InvalidModeName < Error; end
|
11
|
+
|
12
|
+
class InvalidMidiNote < Error; end
|
13
|
+
|
14
|
+
class InvalidDegreeName < Error; end
|
15
|
+
|
16
|
+
# TODO: this can be removed if the circle of fifths is generated dynamically
|
17
|
+
class UnsupportedRootName < Error; end
|
18
|
+
end
|
@@ -0,0 +1,107 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Fet
|
4
|
+
module Generator
|
5
|
+
# Class that generates MIDI files for the listening exercises
|
6
|
+
class Listening
|
7
|
+
# The reason number of exercises is required is because the actual number generated is quite large
|
8
|
+
# For three degrees, it's something like 88C3, but a bit smaller because one you choose 1, you actually exclude some of the 88 other than just itself
|
9
|
+
# (i.e. if you chose the b2 degree, you exclude the rest of the b2 degrees too)
|
10
|
+
# 2 degrees => 21592 (left it for a while and it seemed to stop at this value) - comparable to 64C2 * 12 = 24192
|
11
|
+
# 3 degrees => 252398 (before I stopped it, there was more being generated)
|
12
|
+
def initialize(exercises:, tempo:, degrees:, all_single_degree:, directory_prefix: "")
|
13
|
+
self.number_of_exercises = exercises
|
14
|
+
self.all_single_degree = all_single_degree
|
15
|
+
self.tempo = tempo
|
16
|
+
self.number_of_degrees = degrees
|
17
|
+
self.note_range = Fet::REDUCED_BY_OCTAVE_PIANO_RANGE
|
18
|
+
self.directory_prefix = directory_prefix
|
19
|
+
end
|
20
|
+
|
21
|
+
def generate
|
22
|
+
all_single_degree ? generate_all_single_degree_exercises : generate_number_of_exercises
|
23
|
+
end
|
24
|
+
|
25
|
+
private
|
26
|
+
|
27
|
+
attr_accessor :number_of_exercises, :tempo, :number_of_degrees, :note_range, :all_single_degree, :directory_prefix
|
28
|
+
|
29
|
+
def generate_all_single_degree_exercises
|
30
|
+
Fet::MAJOR_ROOT_MIDI_VALUES.each do |root|
|
31
|
+
note_range.each do |note|
|
32
|
+
select_notes_recursive([note], [], root, 1, "major")
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
Fet::MINOR_ROOT_MIDI_VALUES.each do |root|
|
37
|
+
note_range.each do |note|
|
38
|
+
select_notes_recursive([note], [], root, 1, "minor")
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
def generate_number_of_exercises
|
44
|
+
number_of_exercises.times do
|
45
|
+
# Create major key exercises
|
46
|
+
root = Fet::MAJOR_ROOT_MIDI_VALUES.to_a.sample
|
47
|
+
until select_notes_recursive(note_range, [], root, number_of_degrees, "major"); end
|
48
|
+
|
49
|
+
# Create minor key exercises
|
50
|
+
root = Fet::MINOR_ROOT_MIDI_VALUES.to_a.sample
|
51
|
+
until select_notes_recursive(note_range, [], root, number_of_degrees, "minor"); end
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
def select_notes_recursive(all_notes, chosen_notes, root, number_degrees, key_type)
|
56
|
+
return create_midi_file(chosen_notes, root, key_type) if number_degrees.zero?
|
57
|
+
|
58
|
+
selected_note = all_notes.sample
|
59
|
+
chosen_notes << selected_note
|
60
|
+
|
61
|
+
all_notes_without_note_degree = all_notes.reject { |note| Fet::MidiNote.new(note).degree(root[1]) == Fet::MidiNote.new(selected_note).degree(root[1]) }
|
62
|
+
select_notes_recursive(all_notes_without_note_degree, chosen_notes, root, number_degrees - 1, key_type)
|
63
|
+
end
|
64
|
+
|
65
|
+
def create_midi_file(chosen_notes, root, key_type)
|
66
|
+
# Sort so that the file name corresponds to degree of lowest to highest
|
67
|
+
chosen_notes = chosen_notes.sort
|
68
|
+
|
69
|
+
filename = full_filename(key_type, root, chosen_notes)
|
70
|
+
return false if File.exist?(filename)
|
71
|
+
|
72
|
+
progression = Fet::ChordProgression.new(offset: root[1], template_type: key_type).with_offset
|
73
|
+
Fet::MidilibInterface.new(
|
74
|
+
tempo: tempo, progression: progression, notes: chosen_notes, info: generate_midi_info(key_type, root, chosen_notes), filename: filename,
|
75
|
+
).create_listening_midi_file
|
76
|
+
return true
|
77
|
+
end
|
78
|
+
|
79
|
+
def generate_midi_info(key_type, root, chosen_notes)
|
80
|
+
return [
|
81
|
+
"Key: [#{root[0]} #{key_type}]",
|
82
|
+
"Degrees: [#{chosen_notes.map { |i| Fet::MusicTheory::DEGREES[(i - root[1]) % 12] }}]",
|
83
|
+
"Notes: [#{chosen_notes}]",
|
84
|
+
].join(" ")
|
85
|
+
end
|
86
|
+
|
87
|
+
def full_filename(key_type, root, chosen_notes)
|
88
|
+
result = File.join(*[directory_prefix, "listening", key_type].reject(&:empty?))
|
89
|
+
filename = root[0].to_s # note, e.g. Db
|
90
|
+
filename += key_type == "major" ? "M" : "m" # type of note, M or m
|
91
|
+
filename += "_" # delimiter
|
92
|
+
filename += chosen_notes.map { |i| note_filename_part(root[0], root[1], i) }.join("_") # chosen notes description, e.g. b7(Cb4)
|
93
|
+
filename += ".mid" # extension
|
94
|
+
return File.join(result, filename)
|
95
|
+
end
|
96
|
+
|
97
|
+
def note_filename_part(root_name, root_midi_value, note_midi_value)
|
98
|
+
root_octave_value = Fet::MidiNote.new(root_midi_value).octave_number
|
99
|
+
degrees_instance = Fet::Degrees.new(root_name: root_name, octave_value: root_octave_value)
|
100
|
+
degree_name = degrees_instance.degree_names_of_midi_value(note_midi_value).last
|
101
|
+
note_name = degrees_instance.note_name_of_degree(degree_name)
|
102
|
+
note_octave_value = Fet::MidiNote.new(note_midi_value).octave_number
|
103
|
+
return "#{degree_name}(#{note_name}#{note_octave_value})" # e.g. b7(Cb4)
|
104
|
+
end
|
105
|
+
end
|
106
|
+
end
|
107
|
+
end
|