fet 0.2.0 → 0.3.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.
@@ -13,6 +13,10 @@ module Fet
13
13
 
14
14
  class InvalidDegreeName < Error; end
15
15
 
16
+ class InvalidCustomEventType < Error; end
17
+
18
+ class ImplementationError < Error; end
19
+
16
20
  # TODO: this can be removed if the circle of fifths is generated dynamically
17
21
  class UnsupportedRootName < Error; end
18
22
  end
@@ -29,13 +29,13 @@ module Fet
29
29
  def generate_all_single_degree_exercises
30
30
  Fet::MAJOR_ROOT_MIDI_VALUES.each do |root|
31
31
  note_range.each do |note|
32
- select_notes_recursive([note], [], root, 1, "major")
32
+ select_notes([note], root[0], root[1], 1, "major")
33
33
  end
34
34
  end
35
35
 
36
36
  Fet::MINOR_ROOT_MIDI_VALUES.each do |root|
37
37
  note_range.each do |note|
38
- select_notes_recursive([note], [], root, 1, "minor")
38
+ select_notes([note], root[0], root[1], 1, "minor")
39
39
  end
40
40
  end
41
41
  end
@@ -43,53 +43,50 @@ module Fet
43
43
  def generate_number_of_exercises
44
44
  number_of_exercises.times do
45
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
46
+ root_name, root_midi_value = Fet::MAJOR_ROOT_MIDI_VALUES.to_a.sample
47
+ until select_notes(note_range, root_name, root_midi_value, number_of_degrees, "major"); end
48
48
 
49
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
50
+ root_name, root_midi_value = Fet::MINOR_ROOT_MIDI_VALUES.to_a.sample
51
+ until select_notes(note_range, root_name, root_midi_value, number_of_degrees, "minor"); end
52
52
  end
53
53
  end
54
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)
55
+ def select_notes(all_notes, root_name, root_midi_value, number_degrees, key_type)
56
+ root_octave_value = Fet::MidiNote.new(root_midi_value).octave_number
57
+ 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)
59
+ return create_midi_file(chosen_notes, root_name, root_midi_value, key_type)
63
60
  end
64
61
 
65
- def create_midi_file(chosen_notes, root, key_type)
62
+ def create_midi_file(chosen_notes, root_name, root_midi_value, key_type)
66
63
  # Sort so that the file name corresponds to degree of lowest to highest
67
64
  chosen_notes = chosen_notes.sort
68
65
 
69
- filename = full_filename(key_type, root, chosen_notes)
66
+ filename = full_filename(key_type, root_name, root_midi_value, chosen_notes)
70
67
  return false if File.exist?(filename)
71
68
 
72
- progression = Fet::ChordProgression.new(offset: root[1], template_type: key_type).with_offset
69
+ progression = Fet::ChordProgression.new(offset: root_midi_value, template_type: key_type).with_offset
73
70
  Fet::MidilibInterface.new(
74
- tempo: tempo, progression: progression, notes: chosen_notes, info: generate_midi_info(key_type, root, chosen_notes), filename: filename,
71
+ tempo: tempo, progression: progression, notes: chosen_notes, info: generate_midi_info(key_type, root_name, root_midi_value, chosen_notes), filename: filename,
75
72
  ).create_listening_midi_file
76
73
  return true
77
74
  end
78
75
 
79
- def generate_midi_info(key_type, root, chosen_notes)
76
+ def generate_midi_info(key_type, root_name, root_midi_value, chosen_notes)
80
77
  return [
81
- "Key: [#{root[0]} #{key_type}]",
82
- "Degrees: [#{chosen_notes.map { |i| Fet::MusicTheory::DEGREES[(i - root[1]) % 12] }}]",
78
+ "Key: [#{root_name} #{key_type}]",
79
+ "Degrees: [#{chosen_notes.map { |i| Fet::MusicTheory::DEGREES[(i - root_midi_value) % 12] }}]",
83
80
  "Notes: [#{chosen_notes}]",
84
81
  ].join(" ")
85
82
  end
86
83
 
87
- def full_filename(key_type, root, chosen_notes)
84
+ def full_filename(key_type, root_name, root_midi_value, chosen_notes)
88
85
  result = File.join(*[directory_prefix, "listening", key_type].reject(&:empty?))
89
- filename = root[0].to_s # note, e.g. Db
86
+ filename = root_name # note, e.g. Db
90
87
  filename += key_type == "major" ? "M" : "m" # type of note, M or m
91
88
  filename += "_" # delimiter
92
- filename += chosen_notes.map { |i| note_filename_part(root[0], root[1], i) }.join("_") # chosen notes description, e.g. b7(Cb4)
89
+ filename += chosen_notes.map { |i| note_filename_part(root_name, root_midi_value, i) }.join("_") # chosen notes description, e.g. b7(Cb4)
93
90
  filename += ".mid" # extension
94
91
  return File.join(result, filename)
95
92
  end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fet
4
+ # Use MidilibInterface to create concrete MIDI files
5
+ module MidiFileGenerator
6
+ def create_full_question
7
+ set_progression_on_track
8
+ add_rest(2 * quarter_note_length)
9
+ play_notes_as_chord(notes, quarter_note_length)
10
+ add_rest(3 * quarter_note_length)
11
+ write_sequence_to_file
12
+ end
13
+
14
+ def create_chord_progression_of_question
15
+ set_progression_on_track
16
+ add_rest(1 * quarter_note_length)
17
+ write_sequence_to_file
18
+ end
19
+
20
+ def create_notes_only
21
+ play_notes_as_chord(notes, quarter_note_length)
22
+ add_rest(1 * quarter_note_length)
23
+ write_sequence_to_file
24
+ end
25
+
26
+ def create_listening_midi_file
27
+ set_progression_on_track
28
+
29
+ add_rest(2 * quarter_note_length)
30
+ play_notes_as_chord(notes, quarter_note_length)
31
+
32
+ add_rest(6 * quarter_note_length)
33
+ play_notes_sequentially(notes, quarter_note_length)
34
+
35
+ write_sequence_to_file
36
+ end
37
+
38
+ def create_singing_midi_file(sleep_duration)
39
+ set_progression_on_track
40
+
41
+ add_seconds_of_rest(sleep_duration)
42
+ play_notes_sequentially(notes, quarter_note_length)
43
+ add_rest(3 * quarter_note_length)
44
+
45
+ write_sequence_to_file
46
+ end
47
+ end
48
+ end
@@ -6,6 +6,8 @@ require "midilib/consts"
6
6
  module Fet
7
7
  # Interface with the midilib library to generate MIDI files
8
8
  class MidilibInterface
9
+ include MidiFileGenerator
10
+
9
11
  def initialize(tempo:, progression:, notes:, info:, filename:)
10
12
  self.tempo = tempo
11
13
  self.progression = progression
@@ -16,31 +18,6 @@ module Fet
16
18
  self.track = generate_instrument_track
17
19
  end
18
20
 
19
- def create_listening_midi_file
20
- # Play the chord progression
21
- set_progression_on_track
22
-
23
- add_rest(2 * quarter_note_length)
24
- play_notes_as_chord(notes, quarter_note_length)
25
-
26
- add_rest(6 * quarter_note_length)
27
- play_notes_sequentially(notes, quarter_note_length)
28
-
29
- write_sequence_to_file
30
- end
31
-
32
- def create_singing_midi_file(sleep_duration)
33
- # Play the chord progression
34
- set_progression_on_track
35
-
36
- # Play the note after waiting for a specified amount of time
37
- add_seconds_of_rest(sleep_duration) do
38
- play_notes_sequentially(notes, quarter_note_length)
39
- end
40
-
41
- write_sequence_to_file
42
- end
43
-
44
21
  private
45
22
 
46
23
  attr_accessor :tempo, :progression, :notes, :info, :filename, :sequence, :track
@@ -56,7 +33,6 @@ module Fet
56
33
  with_temporary_tempo_change(60) do
57
34
  # Sleep for the requested duration
58
35
  add_rest(seconds * quarter_note_length)
59
- yield
60
36
  end
61
37
  end
62
38
 
data/lib/fet/score.rb ADDED
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Fet
6
+ # Holds the correct/incorrect answers to questions
7
+ class Score
8
+ def initialize
9
+ self.score = initialize_score
10
+ end
11
+
12
+ def answer_correctly(*degree_indices)
13
+ degree_indices.each { |degree_index| score[degree_index][:correct] += 1 }
14
+ end
15
+
16
+ def answer_incorrectly(*degree_indices)
17
+ degree_indices.each { |degree_index| score[degree_index][:incorrect] += 1 }
18
+ end
19
+
20
+ def answered_correctly(degree_index = nil)
21
+ return score.reduce(0) do |result, (score_degree_index, score_hash)|
22
+ degree_index.nil? || score_degree_index == degree_index ? result + score_hash[:correct] : result
23
+ end
24
+ end
25
+
26
+ def answered_incorrectly(degree_index = nil)
27
+ return score.reduce(0) do |result, (score_degree_index, score_hash)|
28
+ degree_index.nil? || score_degree_index == degree_index ? result + score_hash[:incorrect] : result
29
+ end
30
+ end
31
+
32
+ def questions_asked(degree_index = nil)
33
+ return answered_correctly(degree_index) + answered_incorrectly(degree_index)
34
+ end
35
+
36
+ def as_json(_options = {})
37
+ score
38
+ end
39
+
40
+ def to_json(*options)
41
+ as_json(*options).to_json(*options)
42
+ end
43
+
44
+ private
45
+
46
+ attr_accessor :score
47
+
48
+ def initialize_score
49
+ Fet::Degree::DEGREE_NAMES.map.with_index { |_, degree_index| [degree_index, { correct: 0, incorrect: 0 }] }.to_h
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fet
4
+ module Ui
5
+ module ColorScheme
6
+ BLACK = "#151727".deep_freeze
7
+ GREY = "#393D56".deep_freeze
8
+ WHITE = "#FAF9F0".deep_freeze
9
+ RED = "#931621".deep_freeze
10
+ GREEN = "#2F754E".deep_freeze
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fet
4
+ module Ui
5
+ # Custom events for the event loop
6
+ class CustomEvent
7
+ attr_accessor :type
8
+
9
+ EVENT_TYPE_NOTE_SELECTED = :note_selected
10
+ EVENT_TYPE_LEVEL_STARTED = :level_started
11
+ EVENT_TYPE_LEVEL_COMPLETE = :level_complete
12
+ EVENT_TYPES = [
13
+ EVENT_TYPE_NOTE_SELECTED,
14
+ EVENT_TYPE_LEVEL_STARTED,
15
+ EVENT_TYPE_LEVEL_COMPLETE,
16
+ ].deep_freeze
17
+
18
+ def initialize(type)
19
+ self.type = type
20
+ validate_type!
21
+ end
22
+
23
+ private
24
+
25
+ def validate_type!
26
+ raise InvalidCustomEventType unless EVENT_TYPES.include?(type)
27
+ end
28
+ end
29
+ end
30
+ end
31
+
32
+ # NOTE: alternatively I started monkey patching the Ruby2D::Window class, though it's probably not the best idea
33
+ # but it WOULD let us call events outside of the standard Ruby2D events.
34
+ # module Ruby2D
35
+ # class Window
36
+ # CustomEvent = Struct.new(:type)
37
+ # @events[:custom] = {}
38
+ #
39
+ # def custom_callback(type)
40
+ # @events[:custom].each do |_id, e|
41
+ # e.call(customEvent.new(type))
42
+ # end
43
+ # end
44
+ # end
45
+ # end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ruby2d"
4
+ require_relative "game_loop_handler"
5
+ require_relative "game_setup_helper"
6
+ require_relative "level"
7
+ require_relative "score"
8
+ require_relative "timer"
9
+
10
+ module Fet
11
+ module Ui
12
+ # Holds the state of the current game
13
+ class Game
14
+ include GameSetupHelper
15
+ include GameLoopHandler
16
+
17
+ SCORES_FILENAME = "#{ENV["HOME"]}/.config/fet/scores".deep_freeze
18
+
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:)
30
+ self.note_range = Fet::REDUCED_BY_OCTAVE_PIANO_RANGE
31
+ self.tempo = tempo
32
+ self.key_type = key_type
33
+ self.number_of_degrees = degrees
34
+ 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)
38
+ setup_window
39
+ end
40
+
41
+ def start
42
+ score.start
43
+ level.start
44
+ timer.start
45
+ show_window
46
+ write_score_to_file
47
+ end
48
+
49
+ def stop
50
+ close_window
51
+ end
52
+
53
+ private
54
+
55
+ def show_window
56
+ Ruby2D::Window.show
57
+ end
58
+
59
+ def close_window
60
+ Ruby2D::Window.close
61
+ end
62
+
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) }
69
+ end
70
+
71
+ def historic_score_entries
72
+ result = File.read(self.class.scores_filename)
73
+ return JSON.parse(result)
74
+ rescue Errno::ENOENT
75
+ return []
76
+ end
77
+
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
+ }
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "custom_event"
4
+
5
+ module Fet
6
+ module Ui
7
+ # Handles various events and updates for the Game object
8
+ module GameLoopHandler
9
+ def handle_update_loop
10
+ score.handle_update_loop
11
+ level.handle_update_loop
12
+ timer.handle_update_loop
13
+ end
14
+
15
+ def handle_event_loop(event)
16
+ handle_keyboard_event(event)
17
+
18
+ # NOTE: score must handle event before level because level event could recreate the whole level
19
+ score.handle_event_loop(event)
20
+ level.handle_event_loop(event)
21
+ timer.handle_event_loop(event)
22
+
23
+ handle_custom_events
24
+ end
25
+
26
+ def set_note_selected_event_flag
27
+ self.note_selected_event_flag = true
28
+ end
29
+
30
+ def set_level_started_event_flag
31
+ self.level_started_event_flag = true
32
+ end
33
+
34
+ def set_level_complete_event_flag
35
+ self.level_complete_event_flag = true
36
+ end
37
+
38
+ private
39
+
40
+ attr_accessor :note_selected_event_flag, :level_started_event_flag, :level_complete_event_flag
41
+
42
+ def handle_keyboard_event(event)
43
+ return unless event.is_a?(Ruby2D::Window::KeyEvent)
44
+ return unless event.type == :down
45
+
46
+ stop if event.key == "q"
47
+ end
48
+
49
+ def handle_custom_events
50
+ handle_note_selected_event
51
+ handle_level_started_event
52
+ handle_level_complete_event
53
+ end
54
+
55
+ def handle_note_selected_event
56
+ handle_event = note_selected_event_flag
57
+ self.note_selected_event_flag = false
58
+ handle_event_loop(CustomEvent.new(CustomEvent::EVENT_TYPE_NOTE_SELECTED)) if handle_event
59
+ end
60
+
61
+ def handle_level_started_event
62
+ handle_event = level_started_event_flag
63
+ self.level_started_event_flag = false
64
+ handle_event_loop(CustomEvent.new(CustomEvent::EVENT_TYPE_LEVEL_STARTED)) if handle_event
65
+ end
66
+
67
+ def handle_level_complete_event
68
+ handle_event = level_complete_event_flag
69
+ self.level_complete_event_flag = false
70
+ handle_event_loop(CustomEvent.new(CustomEvent::EVENT_TYPE_LEVEL_COMPLETE)) if handle_event
71
+ end
72
+ end
73
+ end
74
+ end