fet 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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
|