fet 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
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