fet 0.3.0 → 0.3.1

Sign up to get free protection for your applications and to get access to all the features.
data/lib/fet/degrees.rb CHANGED
@@ -31,8 +31,8 @@ module Fet
31
31
  return degree_to_note_name[Degree.new(degree_name).degree_name]
32
32
  end
33
33
 
34
- def select_degrees_from_midi_values(midi_value_range, number_of_degrees)
35
- return select_degrees_from_midi_values_recursive(midi_value_range, [], number_of_degrees)
34
+ def select_degrees_from_midi_values(midi_value_range, number_of_degrees, degrees_filter)
35
+ return select_degrees_from_midi_values_recursive(filtered_midi_values(midi_value_range, degrees_filter), [], number_of_degrees)
36
36
  end
37
37
 
38
38
  private
@@ -42,6 +42,16 @@ module Fet
42
42
 
43
43
  attr_accessor :degree_to_note_name
44
44
 
45
+ def filtered_midi_values(midi_value_range, degrees_filter)
46
+ return midi_value_range if degrees_filter.empty?
47
+
48
+ filter_degree_indices = degrees_filter.map { |degree_name| Degree.new(degree_name).degree_index }
49
+ return midi_value_range.select do |midi_value|
50
+ degree_index = degree_index_of_midi_value(midi_value)
51
+ filter_degree_indices.include?(degree_index)
52
+ end
53
+ end
54
+
45
55
  def note_name_of_degree_internal(degree_name)
46
56
  degree = Degree.new(degree_name)
47
57
 
@@ -9,22 +9,28 @@ module Fet
9
9
  # (i.e. if you chose the b2 degree, you exclude the rest of the b2 degrees too)
10
10
  # 2 degrees => 21592 (left it for a while and it seemed to stop at this value) - comparable to 64C2 * 12 = 24192
11
11
  # 3 degrees => 252398 (before I stopped it, there was more being generated)
12
- def initialize(exercises:, tempo:, degrees:, all_single_degree:, directory_prefix: "")
12
+ def initialize(exercises:, tempo:, degrees:, all_single_degree:)
13
13
  self.number_of_exercises = exercises
14
14
  self.all_single_degree = all_single_degree
15
15
  self.tempo = tempo
16
16
  self.number_of_degrees = degrees
17
17
  self.note_range = Fet::REDUCED_BY_OCTAVE_PIANO_RANGE
18
- self.directory_prefix = directory_prefix
19
18
  end
20
19
 
20
+ # NOTE: this is explicitly changed in tests, so no need to check for coverage
21
+ # :nocov:
22
+ def self.directory_prefix
23
+ return ""
24
+ end
25
+ # :nocov:
26
+
21
27
  def generate
22
28
  all_single_degree ? generate_all_single_degree_exercises : generate_number_of_exercises
23
29
  end
24
30
 
25
31
  private
26
32
 
27
- attr_accessor :number_of_exercises, :tempo, :number_of_degrees, :note_range, :all_single_degree, :directory_prefix
33
+ attr_accessor :number_of_exercises, :tempo, :number_of_degrees, :note_range, :all_single_degree
28
34
 
29
35
  def generate_all_single_degree_exercises
30
36
  Fet::MAJOR_ROOT_MIDI_VALUES.each do |root|
@@ -55,7 +61,7 @@ module Fet
55
61
  def select_notes(all_notes, root_name, root_midi_value, number_degrees, key_type)
56
62
  root_octave_value = Fet::MidiNote.new(root_midi_value).octave_number
57
63
  degrees_instance = Fet::Degrees.new(root_name: root_name, octave_value: root_octave_value)
58
- chosen_notes = degrees_instance.select_degrees_from_midi_values(all_notes, number_degrees)
64
+ chosen_notes = degrees_instance.select_degrees_from_midi_values(all_notes, number_degrees, [])
59
65
  return create_midi_file(chosen_notes, root_name, root_midi_value, key_type)
60
66
  end
61
67
 
@@ -82,7 +88,7 @@ module Fet
82
88
  end
83
89
 
84
90
  def full_filename(key_type, root_name, root_midi_value, chosen_notes)
85
- result = File.join(*[directory_prefix, "listening", key_type].reject(&:empty?))
91
+ result = File.join(*[self.class.directory_prefix, "listening", key_type].reject(&:empty?))
86
92
  filename = root_name # note, e.g. Db
87
93
  filename += key_type == "major" ? "M" : "m" # type of note, M or m
88
94
  filename += "_" # delimiter
@@ -4,13 +4,19 @@ module Fet
4
4
  module Generator
5
5
  # Class that generates MIDI files for the singing exercises
6
6
  class Singing
7
- def initialize(tempo:, pause:, directory_prefix: "")
7
+ def initialize(tempo:, pause:)
8
8
  self.tempo = tempo
9
9
  self.pause = pause
10
10
  self.midi_range = HIGH_SINGING_OCTAVE_RANGE
11
- self.directory_prefix = directory_prefix
12
11
  end
13
12
 
13
+ # NOTE: this is explicitly changed in tests, so no need to check for coverage
14
+ # :nocov:
15
+ def self.directory_prefix
16
+ return ""
17
+ end
18
+ # :nocov:
19
+
14
20
  def generate
15
21
  generate_major
16
22
  generate_minor
@@ -18,7 +24,7 @@ module Fet
18
24
 
19
25
  private
20
26
 
21
- attr_accessor :tempo, :pause, :midi_range, :directory_prefix
27
+ attr_accessor :tempo, :pause, :midi_range
22
28
 
23
29
  def generate_major
24
30
  MusicTheory::MAJOR_KEYS.each do |root_note_name|
@@ -59,7 +65,7 @@ module Fet
59
65
  end
60
66
 
61
67
  def full_filename(key_type, root_note_name, root_midi_value, note_midi_value)
62
- result = File.join(*[directory_prefix, "singing", key_type].reject(&:empty?))
68
+ result = File.join(*[self.class.directory_prefix, "singing", key_type].reject(&:empty?))
63
69
  filename = root_note_name # note, e.g. Db
64
70
  filename += key_type == "major" ? "M" : "m" # type of note, M or m
65
71
  filename += "_" # delimiter
@@ -4,14 +4,20 @@ module Fet
4
4
  module Generator
5
5
  # Class that generates MIDI files for the single note listening exercises
6
6
  class SingleNoteListening
7
- def initialize(tempo:, directory_prefix: "")
7
+ def initialize(tempo:)
8
8
  self.tempo = tempo
9
9
  self.note = Fet::Note.new("C")
10
10
  self.octave_value = 4
11
11
  self.midi_value = MidiNote.from_note(note.full_note, octave_value).midi_value
12
- self.directory_prefix = directory_prefix
13
12
  end
14
13
 
14
+ # NOTE: this is explicitly changed in tests, so no need to check for coverage
15
+ # :nocov:
16
+ def self.directory_prefix
17
+ return ""
18
+ end
19
+ # :nocov:
20
+
15
21
  def generate
16
22
  MusicTheory::MAJOR_KEYS.each do |root_note_name|
17
23
  root_midi_value = MAJOR_ROOT_MIDI_VALUES[root_note_name]
@@ -26,7 +32,7 @@ module Fet
26
32
 
27
33
  private
28
34
 
29
- attr_accessor :tempo, :note, :octave_value, :midi_value, :directory_prefix
35
+ attr_accessor :tempo, :note, :octave_value, :midi_value
30
36
 
31
37
  def create_midi_file(key_type, root_note_name, root_midi_value)
32
38
  progression = Fet::ChordProgression.new(offset: root_midi_value, template_type: key_type).with_offset
@@ -47,7 +53,7 @@ module Fet
47
53
  end
48
54
 
49
55
  def full_filename(key_type, root_note_name, root_midi_value)
50
- result = File.join(*[directory_prefix, "listening_single_note", key_type].reject(&:empty?))
56
+ result = File.join(*[self.class.directory_prefix, "listening_single_note", key_type].reject(&:empty?))
51
57
  filename = root_note_name # note, e.g. Db
52
58
  filename += key_type == "major" ? "M" : "m" # type of note, M or m
53
59
  filename += "_" # delimiter
data/lib/fet/score.rb CHANGED
@@ -5,8 +5,16 @@ require "json"
5
5
  module Fet
6
6
  # Holds the correct/incorrect answers to questions
7
7
  class Score
8
- def initialize
9
- self.score = initialize_score
8
+ def initialize(score: nil)
9
+ score_hash = score || initialize_score
10
+ score_hash = score_hash.map do |k, v|
11
+ [k.to_i, v.transform_keys(&:to_sym)]
12
+ end.to_h
13
+ self.score = score_hash
14
+ end
15
+
16
+ def self.merge(*scores)
17
+ scores.each_with_object(Fet::Score.new) { |x, res| res.merge(x) }
10
18
  end
11
19
 
12
20
  def answer_correctly(*degree_indices)
@@ -41,6 +49,23 @@ module Fet
41
49
  as_json(*options).to_json(*options)
42
50
  end
43
51
 
52
+ def merge(other)
53
+ score.each do |k, v|
54
+ v[:correct] += other.answered_correctly(k)
55
+ v[:incorrect] += other.answered_incorrectly(k)
56
+ end
57
+ end
58
+
59
+ def percentages
60
+ score.map do |k, _|
61
+ next([k, percentage(answered_correctly(k), questions_asked(k)).to_i])
62
+ end.to_h
63
+ end
64
+
65
+ def total_percentage
66
+ return percentage(answered_correctly, questions_asked).to_i
67
+ end
68
+
44
69
  private
45
70
 
46
71
  attr_accessor :score
@@ -48,5 +73,11 @@ module Fet
48
73
  def initialize_score
49
74
  Fet::Degree::DEGREE_NAMES.map.with_index { |_, degree_index| [degree_index, { correct: 0, incorrect: 0 }] }.to_h
50
75
  end
76
+
77
+ def percentage(correct, total)
78
+ return 0.0 if total.zero?
79
+
80
+ return correct.fdiv(total) * 100
81
+ end
51
82
  end
52
83
  end
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+ require "json"
5
+ require "terminal-table"
6
+ require_relative "score_summary_writer"
7
+
8
+ module Fet
9
+ # Responsible for writing + showing the score summary to the user
10
+ class ScoreSummary
11
+ extend ScoreSummaryWriter
12
+
13
+ NO_SCORES_MESSAGE = "No scores available yet!".deep_freeze
14
+
15
+ def initialize(minimum_session_length: 0, number_of_degrees: nil, key_type: nil, begin_offset: 0, end_offset: 0)
16
+ self.minimum_session_length = minimum_session_length
17
+ self.number_of_degrees = number_of_degrees
18
+ self.key_type = key_type
19
+ self.begin_offset = begin_offset
20
+ self.end_offset = end_offset
21
+ end
22
+
23
+ def summary
24
+ if file_exists?
25
+ percentage_summary
26
+ else
27
+ puts NO_SCORES_MESSAGE
28
+ end
29
+ end
30
+
31
+ private
32
+
33
+ attr_accessor :minimum_session_length, :number_of_degrees, :key_type, :begin_offset, :end_offset
34
+
35
+ def filename
36
+ return self.class.scores_filename
37
+ end
38
+
39
+ def file_exists?
40
+ return File.exist?(filename)
41
+ end
42
+
43
+ def file_contents
44
+ return File.read(filename)
45
+ end
46
+
47
+ def games_array
48
+ return JSON.parse(file_contents)
49
+ end
50
+
51
+ def games_array_with_constraints
52
+ result = games_array
53
+ result = filter_by_seconds_elapsed(result)
54
+ result = filter_by_number_of_degrees(result)
55
+ result = filter_by_key_type(result)
56
+ result = filter_by_offsets(result)
57
+ return result
58
+ end
59
+
60
+ def filter_by_seconds_elapsed(games_array)
61
+ return games_array.select { |game_details| game_details["seconds_elapsed"] >= minimum_session_length }
62
+ end
63
+
64
+ def filter_by_number_of_degrees(games_array)
65
+ return games_array if number_of_degrees.nil?
66
+
67
+ return games_array.select { |game_details| game_details["number_of_degrees"] == number_of_degrees }
68
+ end
69
+
70
+ def filter_by_key_type(games_array)
71
+ return games_array if key_type.nil?
72
+
73
+ return games_array.select { |game_details| game_details["key_type"] == key_type }
74
+ end
75
+
76
+ def filter_by_offsets(games_array)
77
+ begin_index = 0
78
+ end_index = games_array.size
79
+ return games_array[(begin_index + begin_offset)...(end_index + end_offset)] || []
80
+ end
81
+
82
+ def score_instances
83
+ return games_array_with_constraints.map { |game_details| Fet::Score.new(score: game_details["score"]) }
84
+ end
85
+
86
+ def merged_score_instance
87
+ return Fet::Score.merge(*score_instances)
88
+ end
89
+
90
+ def generate_table(score_instance)
91
+ Terminal::Table.new do |t|
92
+ generate_table_header(t)
93
+ t.add_separator
94
+ generate_table_main(t, score_instance)
95
+ t.add_separator
96
+ generate_table_footer(t, score_instance)
97
+ end
98
+ end
99
+
100
+ def generate_table_header(table)
101
+ table.add_row(["Degree", "Answered Correctly", "Total Answered", "Percentage"])
102
+ end
103
+
104
+ def generate_table_main(table, score_instance)
105
+ score_instance.percentages.each do |degree_index, percentage|
106
+ table.add_row(
107
+ [
108
+ Fet::Degree::DEGREE_NAMES[degree_index].last,
109
+ score_instance.answered_correctly(degree_index),
110
+ score_instance.questions_asked(degree_index),
111
+ "#{percentage}%",
112
+ ],
113
+ )
114
+ end
115
+ end
116
+
117
+ def generate_table_footer(table, score_instance)
118
+ table.add_row(
119
+ [
120
+ "All",
121
+ score_instance.answered_correctly,
122
+ score_instance.questions_asked,
123
+ "#{score_instance.total_percentage}%",
124
+ ],
125
+ )
126
+ end
127
+
128
+ def percentage_summary
129
+ table = generate_table(merged_score_instance)
130
+ puts table
131
+ end
132
+ end
133
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+ require "json"
5
+ require "terminal-table"
6
+
7
+ module Fet
8
+ # Methods for writing game score to file
9
+ module ScoreSummaryWriter
10
+ SCORES_FILENAME = "#{ENV["HOME"]}/.config/fet/scores".deep_freeze
11
+
12
+ # NOTE: this is explicitly changed in tests, so no need to check for coverage
13
+ # :nocov:
14
+ def scores_filename
15
+ return SCORES_FILENAME
16
+ end
17
+ # :nocov:
18
+
19
+ def add_entry(game)
20
+ write_score_to_file(game)
21
+ end
22
+
23
+ private
24
+
25
+ def write_score_to_file(game)
26
+ new_score_entries = historic_score_entries
27
+ new_score_entries << current_score_entry(game)
28
+ directory_name = File.dirname(scores_filename)
29
+ FileUtils.mkdir_p(directory_name)
30
+ File.open(scores_filename, "w") { |file| file.write(new_score_entries.to_json) }
31
+ end
32
+
33
+ def historic_score_entries
34
+ result = File.read(scores_filename)
35
+ return JSON.parse(result)
36
+ rescue Errno::ENOENT
37
+ return []
38
+ end
39
+
40
+ def current_score_entry(game)
41
+ return {
42
+ "started_at" => game.timer.started_at.to_s,
43
+ "seconds_elapsed" => game.timer.seconds_elapsed,
44
+ "number_of_degrees" => game.number_of_degrees,
45
+ "tempo" => game.tempo,
46
+ "key_type" => game.key_type,
47
+ "score" => game.score.score,
48
+ }
49
+ end
50
+ end
51
+ end
data/lib/fet/ui/game.rb CHANGED
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "ruby2d"
4
+ require "tmpdir"
4
5
  require_relative "game_loop_handler"
5
6
  require_relative "game_setup_helper"
6
7
  require_relative "level"
@@ -14,27 +15,19 @@ module Fet
14
15
  include GameSetupHelper
15
16
  include GameLoopHandler
16
17
 
17
- SCORES_FILENAME = "#{ENV["HOME"]}/.config/fet/scores".deep_freeze
18
+ attr_accessor :level, :score, :timer, :note_range, :tmp_directory,
19
+ :tempo, :number_of_degrees, :key_type, :next_on_correct, :limit_degrees
18
20
 
19
- attr_accessor :level, :score, :timer, :note_range,
20
- :tempo, :number_of_degrees, :key_type, :next_on_correct
21
-
22
- # NOTE: this is explicitly changed in tests, so no need to check for coverage
23
- # :nocov:
24
- def self.scores_filename
25
- return SCORES_FILENAME
26
- end
27
- # :nocov:
28
-
29
- def initialize(tempo:, degrees:, key_type:, next_on_correct:)
21
+ def initialize(tempo:, degrees:, key_type:, next_on_correct:, limit_degrees: [])
30
22
  self.note_range = Fet::REDUCED_BY_OCTAVE_PIANO_RANGE
31
23
  self.tempo = tempo
32
24
  self.key_type = key_type
33
25
  self.number_of_degrees = degrees
34
26
  self.next_on_correct = next_on_correct
35
- self.score = Score.new(self)
36
- self.level = Level.new(self)
37
- self.timer = Timer.new(self)
27
+ self.limit_degrees = limit_degrees
28
+ self.tmp_directory = Dir.mktmpdir
29
+ initialize_ui_objects
30
+ validate!
38
31
  setup_window
39
32
  end
40
33
 
@@ -43,7 +36,7 @@ module Fet
43
36
  level.start
44
37
  timer.start
45
38
  show_window
46
- write_score_to_file
39
+ Fet::ScoreSummary.add_entry(self)
47
40
  end
48
41
 
49
42
  def stop
@@ -52,35 +45,28 @@ module Fet
52
45
 
53
46
  private
54
47
 
55
- def show_window
56
- Ruby2D::Window.show
48
+ def initialize_ui_objects
49
+ self.score = Score.new(self)
50
+ self.level = Level.new(self)
51
+ self.timer = Timer.new(self)
57
52
  end
58
53
 
59
- def close_window
60
- Ruby2D::Window.close
54
+ def validate!
55
+ validate_degrees!
61
56
  end
62
57
 
63
- def write_score_to_file
64
- new_score_entries = historic_score_entries
65
- new_score_entries << current_score_entry
66
- directory_name = File.dirname(self.class.scores_filename)
67
- FileUtils.mkdir_p(directory_name)
68
- File.open(self.class.scores_filename, "w") { |file| file.write(new_score_entries.to_json) }
58
+ def validate_degrees!
59
+ return unless !limit_degrees.empty? && number_of_degrees > limit_degrees.uniq.size
60
+
61
+ raise ArgumentError.new("Can not select #{number_of_degrees} degrees from set of #{limit_degrees}")
69
62
  end
70
63
 
71
- def historic_score_entries
72
- result = File.read(self.class.scores_filename)
73
- return JSON.parse(result)
74
- rescue Errno::ENOENT
75
- return []
64
+ def show_window
65
+ Ruby2D::Window.show
76
66
  end
77
67
 
78
- def current_score_entry
79
- return {
80
- "started_at" => timer.started_at.to_s,
81
- "seconds_elapsed" => timer.seconds_elapsed,
82
- "score" => score.score,
83
- }
68
+ def close_window
69
+ Ruby2D::Window.close
84
70
  end
85
71
  end
86
72
  end
data/lib/fet/ui/level.rb CHANGED
@@ -52,7 +52,7 @@ module Fet
52
52
  def start_self
53
53
  self.question_number += 1
54
54
  self.degrees = generate_degrees
55
- self.midi_values = degrees.select_degrees_from_midi_values(game.note_range, game.number_of_degrees)
55
+ self.midi_values = degrees.select_degrees_from_midi_values(game.note_range, game.number_of_degrees, game.limit_degrees)
56
56
 
57
57
  update_music_objects
58
58
  play_full_question
@@ -76,19 +76,19 @@ module Fet
76
76
  end
77
77
 
78
78
  def generate_full_question_music
79
- filename = "tmp/chord_progression_and_question.mid"
79
+ filename = File.join(game.tmp_directory, "chord_progression_and_question.mid")
80
80
  create_midilib_object("Chord Progression + Question", filename).create_full_question
81
81
  return Ruby2D::Music.new(filename)
82
82
  end
83
83
 
84
84
  def generate_chord_progression_music
85
- filename = "tmp/chord_progression.mid"
85
+ filename = File.join(game.tmp_directory, "chord_progression.mid")
86
86
  create_midilib_object("Chord Progression", filename).create_chord_progression_of_question
87
87
  return Ruby2D::Music.new(filename)
88
88
  end
89
89
 
90
90
  def generate_notes_music
91
- filename = "tmp/question.mid"
91
+ filename = File.join(game.tmp_directory, "question.mid")
92
92
  create_midilib_object("Question", filename).create_notes_only
93
93
  return Ruby2D::Music.new(filename)
94
94
  end
@@ -47,6 +47,10 @@ module Fet
47
47
  start
48
48
  end
49
49
 
50
+ # NOET: It seems that until the event loop finishes, the window updates don't happen.
51
+ # Specifically here, selecting the correct answer will set color on a note box, but that change
52
+ # does not take effect until we're out of the event loop, but by then we've already started a
53
+ # new level, so the color change never shows.
50
54
  def handle_level_complete_event(event)
51
55
  return unless event.is_a?(CustomEvent) && event.type == CustomEvent::EVENT_TYPE_LEVEL_COMPLETE
52
56
 
@@ -99,18 +99,19 @@ module Fet
99
99
  end
100
100
 
101
101
  def generate_note_music
102
- degrees = note_boxes.level.degrees
103
-
104
- filename = "tmp/#{degree_name}.mid"
105
102
  Fet::MidilibInterface.new(
106
103
  tempo: note_boxes.level.game.tempo,
107
104
  progression: nil,
108
- notes: [degrees.root_midi_value + degree_instance.degree_index],
105
+ notes: [note_boxes.level.degrees.root_midi_value + degree_instance.degree_index],
109
106
  info: degree_name,
110
- filename: filename,
107
+ filename: midi_filename,
111
108
  ).create_notes_only
112
109
 
113
- return Ruby2D::Music.new(filename)
110
+ return Ruby2D::Music.new(midi_filename)
111
+ end
112
+
113
+ def midi_filename
114
+ return File.join(note_boxes.level.game.tmp_directory, "#{degree_name}.mid")
114
115
  end
115
116
  end
116
117
  end
data/lib/fet/ui/score.rb CHANGED
@@ -35,6 +35,7 @@ module Fet
35
35
  TEXT_SIZE = 36
36
36
  X_OFFSET = 508
37
37
  Y_OFFSET = 90
38
+ X_CORRECTION = 20
38
39
  private_constant :TEXT_SIZE, :X_OFFSET, :Y_OFFSET
39
40
 
40
41
  def text_value
@@ -79,6 +80,9 @@ module Fet
79
80
 
80
81
  def update_text
81
82
  text.text = text_value
83
+ # NOTE: keep "/" character in the same position as score increases
84
+ characters = text_value.split("/")[0].size
85
+ text.x = X_OFFSET - (characters - 1) * X_CORRECTION
82
86
  end
83
87
  end
84
88
  end
data/lib/fet/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Fet
4
- VERSION = "0.3.0"
4
+ VERSION = "0.3.1"
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: fet
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.3.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dimitrios Lisenko
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2021-09-06 00:00:00.000000000 Z
11
+ date: 2021-09-27 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: gli
@@ -78,6 +78,26 @@ dependencies:
78
78
  - - "~>"
79
79
  - !ruby/object:Gem::Version
80
80
  version: 0.10.0
81
+ - !ruby/object:Gem::Dependency
82
+ name: terminal-table
83
+ requirement: !ruby/object:Gem::Requirement
84
+ requirements:
85
+ - - "~>"
86
+ - !ruby/object:Gem::Version
87
+ version: '3.0'
88
+ - - ">="
89
+ - !ruby/object:Gem::Version
90
+ version: 3.0.1
91
+ type: :runtime
92
+ prerelease: false
93
+ version_requirements: !ruby/object:Gem::Requirement
94
+ requirements:
95
+ - - "~>"
96
+ - !ruby/object:Gem::Version
97
+ version: '3.0'
98
+ - - ">="
99
+ - !ruby/object:Gem::Version
100
+ version: 3.0.1
81
101
  description: Teaches your ear to recognize notes based on their function in a key.
82
102
  email:
83
103
  - dimitrioslisenko@gmail.com
@@ -91,7 +111,7 @@ files:
91
111
  - ".github/workflows/main.yml"
92
112
  - ".gitignore"
93
113
  - ".rubocop.yml"
94
- - ".ruby_version"
114
+ - ".ruby-version"
95
115
  - CHANGELOG.md
96
116
  - Gemfile
97
117
  - Gemfile.lock
@@ -116,9 +136,15 @@ files:
116
136
  - lib/fet.rb
117
137
  - lib/fet/chord_progression.rb
118
138
  - lib/fet/cli/generate/listening.rb
139
+ - lib/fet/cli/generate/listening_command.rb
119
140
  - lib/fet/cli/generate/singing.rb
141
+ - lib/fet/cli/generate/singing_command.rb
120
142
  - lib/fet/cli/generate/single_note_listening.rb
143
+ - lib/fet/cli/generate/single_note_listening_command.rb
121
144
  - lib/fet/cli/play/listening.rb
145
+ - lib/fet/cli/play/listening_command.rb
146
+ - lib/fet/cli/score/summary.rb
147
+ - lib/fet/cli/score/summary_command.rb
122
148
  - lib/fet/degree.rb
123
149
  - lib/fet/degrees.rb
124
150
  - lib/fet/exceptions.rb
@@ -134,6 +160,8 @@ files:
134
160
  - lib/fet/note.rb
135
161
  - lib/fet/note_validations.rb
136
162
  - lib/fet/score.rb
163
+ - lib/fet/score_summary.rb
164
+ - lib/fet/score_summary_writer.rb
137
165
  - lib/fet/ui/color_scheme.rb
138
166
  - lib/fet/ui/custom_event.rb
139
167
  - lib/fet/ui/game.rb