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.
- checksums.yaml +4 -4
- data/.github/workflows/main.yml +2 -0
- data/.rubocop.yml +3 -0
- data/CHANGELOG.md +9 -3
- data/Gemfile +2 -0
- data/Gemfile.lock +7 -1
- data/README.md +43 -6
- data/assets/fonts/PTSans/OFL.txt +94 -0
- data/assets/fonts/PTSans/PTSans-Bold.ttf +0 -0
- data/assets/fonts/PTSans/PTSans-BoldItalic.ttf +0 -0
- data/assets/fonts/PTSans/PTSans-Italic.ttf +0 -0
- data/assets/fonts/PTSans/PTSans-Regular.ttf +0 -0
- data/assets/readme/demo.gif +0 -0
- data/bin/fet +32 -5
- data/fet.gemspec +1 -0
- data/fet.rdoc +38 -0
- data/lib/fet/cli/play/listening.rb +19 -0
- data/lib/fet/degree.rb +4 -0
- data/lib/fet/degrees.rb +19 -2
- data/lib/fet/exceptions.rb +4 -0
- data/lib/fet/generator/listening.rb +21 -24
- data/lib/fet/midi_file_generator.rb +48 -0
- data/lib/fet/midilib_interface.rb +2 -26
- data/lib/fet/score.rb +52 -0
- data/lib/fet/ui/color_scheme.rb +13 -0
- data/lib/fet/ui/custom_event.rb +45 -0
- data/lib/fet/ui/game.rb +87 -0
- data/lib/fet/ui/game_loop_handler.rb +74 -0
- data/lib/fet/ui/game_setup_helper.rb +49 -0
- data/lib/fet/ui/key.rb +44 -0
- data/lib/fet/ui/level.rb +108 -0
- data/lib/fet/ui/level_loop_handler.rb +57 -0
- data/lib/fet/ui/note_box.rb +117 -0
- data/lib/fet/ui/note_box_loop_handler.rb +107 -0
- data/lib/fet/ui/note_boxes.rb +81 -0
- data/lib/fet/ui/score.rb +85 -0
- data/lib/fet/ui/timer.rb +81 -0
- data/lib/fet/version.rb +1 -1
- data/lib/fet.rb +3 -0
- metadata +38 -3
- data/LICENSE.txt +0 -21
data/lib/fet/exceptions.rb
CHANGED
@@ -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
|
-
|
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
|
-
|
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
|
-
|
47
|
-
until
|
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
|
-
|
51
|
-
until
|
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
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
chosen_notes
|
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,
|
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,
|
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:
|
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,
|
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,
|
76
|
+
def generate_midi_info(key_type, root_name, root_midi_value, chosen_notes)
|
80
77
|
return [
|
81
|
-
"Key: [#{
|
82
|
-
"Degrees: [#{chosen_notes.map { |i| Fet::MusicTheory::DEGREES[(i -
|
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,
|
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 =
|
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(
|
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
|
data/lib/fet/ui/game.rb
ADDED
@@ -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
|