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.
data/bin/setup ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
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
@@ -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