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.
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fet
4
+ module Generator
5
+ # Class that generates MIDI files for the singing exercises
6
+ class Singing
7
+ def initialize(tempo:, pause:, directory_prefix: "")
8
+ self.tempo = tempo
9
+ self.pause = pause
10
+ self.midi_range = HIGH_SINGING_OCTAVE_RANGE
11
+ self.directory_prefix = directory_prefix
12
+ end
13
+
14
+ def generate
15
+ generate_major
16
+ generate_minor
17
+ end
18
+
19
+ private
20
+
21
+ attr_accessor :tempo, :pause, :midi_range, :directory_prefix
22
+
23
+ def generate_major
24
+ MusicTheory::MAJOR_KEYS.each do |root_note_name|
25
+ root_midi_value = MAJOR_ROOT_MIDI_VALUES[root_note_name]
26
+ midi_range.each do |note_midi_value|
27
+ create_midi_file("major", root_note_name, root_midi_value, note_midi_value)
28
+ end
29
+ end
30
+ end
31
+
32
+ def generate_minor
33
+ MusicTheory::MINOR_KEYS.each do |root_note_name|
34
+ root_midi_value = MINOR_ROOT_MIDI_VALUES[root_note_name]
35
+ midi_range.each do |note_midi_value|
36
+ create_midi_file("minor", root_note_name, root_midi_value, note_midi_value)
37
+ end
38
+ end
39
+ end
40
+
41
+ def create_midi_file(key_type, root_note_name, root_midi_value, note_midi_value)
42
+ progression = Fet::ChordProgression.new(offset: root_midi_value, template_type: key_type).with_offset
43
+ Fet::MidilibInterface.new(
44
+ tempo: tempo, progression: progression, notes: [note_midi_value],
45
+ info: generate_midi_info(key_type, root_note_name, root_midi_value, note_midi_value),
46
+ filename: full_filename(key_type, root_note_name, root_midi_value, note_midi_value),
47
+ ).create_singing_midi_file(pause)
48
+ end
49
+
50
+ def generate_midi_info(key_type, root_note_name, root_midi_value, note_midi_value)
51
+ note_name = note_name(root_note_name, root_midi_value, note_midi_value)
52
+ note_octave_value = Fet::MidiNote.new(note_midi_value).octave_number
53
+ result = [
54
+ "Key: [#{root_note_name} #{key_type}]",
55
+ "Degree: [#{degree_name(root_note_name, root_midi_value, note_midi_value)}]",
56
+ "Note: [#{note_name}#{note_octave_value}]",
57
+ ]
58
+ return result.join(" ")
59
+ end
60
+
61
+ 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?))
63
+ filename = root_note_name # note, e.g. Db
64
+ filename += key_type == "major" ? "M" : "m" # type of note, M or m
65
+ filename += "_" # delimiter
66
+ filename += degree_name(root_note_name, root_midi_value, note_midi_value) # degree, e.g. b7
67
+ filename += ".mid" # extension
68
+ return File.join(result, filename)
69
+ end
70
+
71
+ def root_degrees_instance(root_note_name, root_midi_value)
72
+ root_octave_value = Fet::MidiNote.new(root_midi_value).octave_number
73
+ return Fet::Degrees.new(root_name: root_note_name, octave_value: root_octave_value)
74
+ end
75
+
76
+ def degree_name(root_note_name, root_midi_value, note_midi_value)
77
+ degrees_instance = root_degrees_instance(root_note_name, root_midi_value)
78
+ return degrees_instance.degree_names_of_midi_value(note_midi_value).last
79
+ end
80
+
81
+ def note_name(root_note_name, root_midi_value, note_midi_value)
82
+ degrees_instance = root_degrees_instance(root_note_name, root_midi_value)
83
+ return degrees_instance.note_name_of_degree(
84
+ degree_name(root_note_name, root_midi_value, note_midi_value),
85
+ )
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fet
4
+ module Generator
5
+ # Class that generates MIDI files for the single note listening exercises
6
+ class SingleNoteListening
7
+ def initialize(tempo:, directory_prefix: "")
8
+ self.tempo = tempo
9
+ self.note = Fet::Note.new("C")
10
+ self.octave_value = 4
11
+ self.midi_value = MidiNote.from_note(note.full_note, octave_value).midi_value
12
+ self.directory_prefix = directory_prefix
13
+ end
14
+
15
+ def generate
16
+ MusicTheory::MAJOR_KEYS.each do |root_note_name|
17
+ root_midi_value = MAJOR_ROOT_MIDI_VALUES[root_note_name]
18
+ create_midi_file("major", root_note_name, root_midi_value)
19
+ end
20
+
21
+ MusicTheory::MINOR_KEYS.each do |root_note_name|
22
+ root_midi_value = MINOR_ROOT_MIDI_VALUES[root_note_name]
23
+ create_midi_file("minor", root_note_name, root_midi_value)
24
+ end
25
+ end
26
+
27
+ private
28
+
29
+ attr_accessor :tempo, :note, :octave_value, :midi_value, :directory_prefix
30
+
31
+ def create_midi_file(key_type, root_note_name, root_midi_value)
32
+ progression = Fet::ChordProgression.new(offset: root_midi_value, template_type: key_type).with_offset
33
+ Fet::MidilibInterface.new(
34
+ tempo: tempo, progression: progression, notes: [midi_value],
35
+ info: generate_midi_info(key_type, root_note_name, root_midi_value),
36
+ filename: full_filename(key_type, root_note_name, root_midi_value),
37
+ ).create_listening_midi_file
38
+ end
39
+
40
+ def generate_midi_info(key_type, root_note_name, root_midi_value)
41
+ result = [
42
+ "Key: [#{root_note_name} #{key_type}]",
43
+ "Degree: [#{degree_name(root_note_name, root_midi_value)}]",
44
+ "Note: [#{note.full_note}#{octave_value}]",
45
+ ]
46
+ return result.join(" ")
47
+ end
48
+
49
+ def full_filename(key_type, root_note_name, root_midi_value)
50
+ result = File.join(*[directory_prefix, "listening_single_note", key_type].reject(&:empty?))
51
+ filename = root_note_name # note, e.g. Db
52
+ filename += key_type == "major" ? "M" : "m" # type of note, M or m
53
+ filename += "_" # delimiter
54
+ filename += degree_name(root_note_name, root_midi_value) # degree, e.g. b7
55
+ filename += ".mid" # extension
56
+ return File.join(result, filename)
57
+ end
58
+
59
+ def degree_name(root_note_name, root_midi_value)
60
+ root_octave_value = Fet::MidiNote.new(root_midi_value).octave_number
61
+ degrees_instance = Fet::Degrees.new(root_name: root_note_name, octave_value: root_octave_value)
62
+ degree_name = degrees_instance.degree_names_of_midi_value(midi_value).last
63
+ return degree_name
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "midi_note"
4
+
5
+ module Fet
6
+ MAJOR_ROOT_MIDI_VALUES = {
7
+ "C#" => MidiNote.from_note("C#", 4).midi_value,
8
+ "F#" => MidiNote.from_note("F#", 3).midi_value,
9
+ "B" => MidiNote.from_note("B", 3).midi_value,
10
+ "E" => MidiNote.from_note("E", 3).midi_value,
11
+ "A" => MidiNote.from_note("A", 3).midi_value,
12
+ "D" => MidiNote.from_note("D", 4).midi_value,
13
+ "G" => MidiNote.from_note("G", 3).midi_value,
14
+ "C" => MidiNote.from_note("C", 4).midi_value,
15
+ "F" => MidiNote.from_note("F", 3).midi_value,
16
+ "Bb" => MidiNote.from_note("Bb", 3).midi_value,
17
+ "Eb" => MidiNote.from_note("Eb", 4).midi_value,
18
+ "Ab" => MidiNote.from_note("Ab", 3).midi_value,
19
+ "Db" => MidiNote.from_note("Db", 4).midi_value,
20
+ "Gb" => MidiNote.from_note("Gb", 3).midi_value,
21
+ "Cb" => MidiNote.from_note("Cb", 3).midi_value,
22
+ }.deep_freeze
23
+ MINOR_ROOT_MIDI_VALUES = MusicTheory::MINOR_KEYS.zip(MAJOR_ROOT_MIDI_VALUES.values.map { |i| i - 3 }).to_h.deep_freeze
24
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "midi_note"
4
+
5
+ module Fet
6
+ PIANO_RANGE = (MidiNote.from_note("A", 0).midi_value..MidiNote.from_note("C", 8).midi_value).to_a.deep_freeze
7
+ REDUCED_BY_OCTAVE_PIANO_RANGE = (MidiNote.from_note("A", 1).midi_value..MidiNote.from_note("C", 7).midi_value).to_a.deep_freeze
8
+ GUITAR_RANGE = (MidiNote.from_note("E", 2).midi_value..MidiNote.from_note("E", 6).midi_value).to_a.deep_freeze
9
+ # Ranges according to Wikipedia:
10
+ # Bass: E2 - E4
11
+ # Baritone: G2 - G4
12
+ # Tenor: C3 - C5
13
+ # Countertenor: E3 - E5
14
+ # Contralto: F3 - F5
15
+ # Mezzo-soprano: A3 - A5
16
+ # Soprano: C4 - C6
17
+ LOW_SINGING_OCTAVE_RANGE = (MidiNote.from_note("D", 3).midi_value...MidiNote.from_note("D", 4).midi_value).to_a.deep_freeze
18
+ HIGH_SINGING_OCTAVE_RANGE = (MidiNote.from_note("D", 4).midi_value...MidiNote.from_note("D", 5).midi_value).to_a.deep_freeze
19
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "music_theory"
4
+
5
+ module Fet
6
+ # Class that handles MIDI values that represent notes
7
+ class MidiNote
8
+ attr_reader :midi_value
9
+
10
+ def initialize(midi_value)
11
+ self.midi_value = midi_value
12
+ validate_midi_value!
13
+ end
14
+
15
+ def self.from_note(note_name, octave_number)
16
+ midi_value_for_c = 12 * (1 + octave_number)
17
+ midi_value = midi_value_for_c + MusicTheory.semitones_from_c(note_name)
18
+ return new(midi_value)
19
+ end
20
+
21
+ def octave_number
22
+ return (midi_value - 12) / 12
23
+ end
24
+
25
+ # NOTE: This is not strictly correct because e.g. the midi value of 63 can be D#4 or Eb4, which affects
26
+ # what degree it actually is. However, without additional information, this is good enough.
27
+ def degree(root_midi_value)
28
+ return (midi_value - root_midi_value) % 12
29
+ end
30
+
31
+ private
32
+
33
+ attr_writer :midi_value
34
+
35
+ def validate_midi_value!
36
+ # In MIDI, the minimum note value is 0 (which is C(-1)) and the maximum note value is 127 (which is G(9))
37
+ raise InvalidMidiNote.new(midi_value) if midi_value.negative? || midi_value > 127
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "midilib/sequence"
4
+ require "midilib/consts"
5
+
6
+ module Fet
7
+ # Interface with the midilib library to generate MIDI files
8
+ class MidilibInterface
9
+ def initialize(tempo:, progression:, notes:, info:, filename:)
10
+ self.tempo = tempo
11
+ self.progression = progression
12
+ self.notes = notes
13
+ self.info = info
14
+ self.filename = filename
15
+ self.sequence = MIDI::Sequence.new
16
+ self.track = generate_instrument_track
17
+ end
18
+
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
+ private
45
+
46
+ attr_accessor :tempo, :progression, :notes, :info, :filename, :sequence, :track
47
+
48
+ def write_sequence_to_file
49
+ directory_name = File.dirname(filename)
50
+ FileUtils.mkdir_p(directory_name)
51
+ File.open(filename, "wb") { |file| sequence.write(file) }
52
+ end
53
+
54
+ def add_seconds_of_rest(seconds)
55
+ # Change tempo to 60 so that sleep_duration quarter notes corresponds to number of seconds
56
+ with_temporary_tempo_change(60) do
57
+ # Sleep for the requested duration
58
+ add_rest(seconds * quarter_note_length)
59
+ yield
60
+ end
61
+ end
62
+
63
+ def play_notes_as_chord(the_notes, rest_after_chord)
64
+ the_notes.each do |note|
65
+ track.events << MIDI::NoteOn.new(0, note, 127, 0) # track number, note, volume, time to add
66
+ end
67
+
68
+ the_notes.each.with_index do |note, index|
69
+ time_interval = index.zero? ? rest_after_chord : 0
70
+ track.events << MIDI::NoteOff.new(0, note, 127, time_interval)
71
+ end
72
+ end
73
+
74
+ def play_notes_sequentially(the_notes, rest_between_notes)
75
+ the_notes.each do |note|
76
+ track.events << MIDI::NoteOn.new(0, note, 127, 0) # track number, note, volume, time to add
77
+ track.events << MIDI::NoteOff.new(0, note, 127, rest_between_notes)
78
+ end
79
+ end
80
+
81
+ def add_rest(duration)
82
+ track.events << MIDI::NoteOff.new(0, 0, 0, duration)
83
+ end
84
+
85
+ def with_temporary_tempo_change(new_tempo)
86
+ change_tempo(new_tempo)
87
+ yield
88
+ change_tempo(tempo)
89
+ end
90
+
91
+ def change_tempo(new_tempo)
92
+ track.events << MIDI::Tempo.new(MIDI::Tempo.bpm_to_mpq(new_tempo))
93
+ end
94
+
95
+ def generate_instrument_track
96
+ create_info_track
97
+ return create_instrument_track
98
+ end
99
+
100
+ def set_progression_on_track
101
+ # Create the progression
102
+ progression.each do |chord|
103
+ play_notes_as_chord(chord, quarter_note_length)
104
+ end
105
+
106
+ return track
107
+ end
108
+
109
+ def create_info_track
110
+ # Create a first track for the sequence. This holds tempo events and meta info.
111
+ track = MIDI::Track.new(sequence)
112
+ sequence.tracks << track
113
+ track.events << MIDI::Tempo.new(MIDI::Tempo.bpm_to_mpq(tempo))
114
+ track.events << MIDI::MetaEvent.new(MIDI::META_SEQ_NAME, info)
115
+ return track
116
+ end
117
+
118
+ def create_instrument_track
119
+ # Create a track to hold the notes. Add it to the sequence.
120
+ track = MIDI::Track.new(sequence)
121
+ sequence.tracks << track
122
+ track.name = info
123
+ track.instrument = MIDI::GM_PATCH_NAMES[0] # This is the piano patch
124
+ track.events << MIDI::ProgramChange.new(0, 1, 0) # Specify instrument as 2nd argument - see consts in midilib
125
+ return track
126
+ end
127
+
128
+ def quarter_note_length
129
+ # NOTE: magic constant will freeze strings, but apparently midilib needs "quarter" to NOT be frozen,
130
+ # so calling dup on it will generate a non-frozen version
131
+ return sequence.note_to_delta("quarter".dup)
132
+ end
133
+ end
134
+ end
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "note"
4
+
5
+ module Fet
6
+ # Module in charge of handling music theory concepts
7
+ module MusicTheory
8
+ # All 12 degrees
9
+ DEGREES = ["1", "b2", "2", "b3", "3", "4", "b5", "5", "b6", "6", "b7", "7"].deep_freeze
10
+
11
+ # All 15 keys - 7 sharps...0 accidentals...7 flats.
12
+ MAJOR_KEYS = ["C#", "F#", "B", "E", "A", "D", "G", "C", "F", "Bb", "Eb", "Ab", "Db", "Gb", "Cb"].deep_freeze
13
+ MINOR_KEYS = ["A#", "D#", "G#", "C#", "F#", "B", "E", "A", "D", "G", "C", "F", "Bb", "Eb", "Ab"].deep_freeze
14
+
15
+ MODES_IN_ORDER_OF_DARKNESS = [
16
+ ["lydian"],
17
+ ["major", "ionian"],
18
+ ["mixolydian"],
19
+ ["dorian"],
20
+ ["minor", "aeolian"],
21
+ ["phrygian"],
22
+ ["locrian"],
23
+ ].deep_freeze
24
+
25
+ DEGREES_OF_MODE = {
26
+ ["lydian"] => ["1", "2", "3", "#4", "5", "6", "7"],
27
+ ["major", "ionian"] => ["1", "2", "3", "4", "5", "6", "7"],
28
+ ["mixolydian"] => ["1", "2", "3", "4", "5", "6", "b7"],
29
+ ["dorian"] => ["1", "2", "b3", "4", "5", "6", "b7"],
30
+ ["minor", "aeolian"] => ["1", "2", "b3", "4", "5", "b6", "b7"],
31
+ ["phrygian"] => ["1", "b2", "b3", "4", "5", "b6", "b7"],
32
+ ["locrian"] => ["1", "b2", "b3", "4", "b5", "b6", "b7"],
33
+ }.deep_freeze
34
+
35
+ SEMITONES_FROM_C = {
36
+ "C" => 0,
37
+ "D" => 2,
38
+ "E" => 4,
39
+ "F" => 5,
40
+ "G" => 7,
41
+ "A" => 9,
42
+ "B" => 11,
43
+ }.deep_freeze
44
+
45
+ # NOTE: returns value from 0 to 11
46
+ def self.semitones_from_c(note_name)
47
+ note = Note.new(note_name)
48
+ return (SEMITONES_FROM_C[note.natural_note] + note.accidental_to_semitone_offset) % 12
49
+ end
50
+
51
+ def self.degrees_of_mode(mode_name)
52
+ mode_info = DEGREES_OF_MODE.detect { |mode_names, _| mode_names.include?(mode_name) }
53
+ raise InvalidModeName.new(mode_name) if mode_info.nil?
54
+
55
+ return mode_info[1]
56
+ end
57
+
58
+ CIRCLE_OF_FIFTHS_WITHOUT_ACCIDENTALS = ["F", "C", "G", "D", "A", "E", "B"].deep_freeze
59
+ CIRCLE_OF_FIFTHS = [
60
+ *CIRCLE_OF_FIFTHS_WITHOUT_ACCIDENTALS.map { |note| Note.new(note).flattened_note.flattened_note.flattened_note.full_note },
61
+ *CIRCLE_OF_FIFTHS_WITHOUT_ACCIDENTALS.map { |note| Note.new(note).flattened_note.flattened_note.full_note },
62
+ *CIRCLE_OF_FIFTHS_WITHOUT_ACCIDENTALS.map { |note| Note.new(note).flattened_note.full_note },
63
+ *CIRCLE_OF_FIFTHS_WITHOUT_ACCIDENTALS,
64
+ *CIRCLE_OF_FIFTHS_WITHOUT_ACCIDENTALS.map { |note| Note.new(note).sharpened_note.full_note },
65
+ *CIRCLE_OF_FIFTHS_WITHOUT_ACCIDENTALS.map { |note| Note.new(note).sharpened_note.sharpened_note.full_note },
66
+ *CIRCLE_OF_FIFTHS_WITHOUT_ACCIDENTALS.map { |note| Note.new(note).sharpened_note.sharpened_note.sharpened_note.full_note },
67
+ ].deep_freeze
68
+
69
+ ORDERED_NATURAL_NOTES = CIRCLE_OF_FIFTHS_WITHOUT_ACCIDENTALS.sort.deep_freeze
70
+ SEMITONES_TO_NEXT_NATURAL_NOTE = {
71
+ "A" => 2,
72
+ "B" => 1,
73
+ "C" => 2,
74
+ "D" => 2,
75
+ "E" => 1,
76
+ "F" => 2,
77
+ "G" => 2,
78
+ }.deep_freeze
79
+
80
+ # A aeolian -> ["A", "B", "C", "D", "E", "F", "G"]
81
+ def self.notes_of_mode(note_name, mode_name)
82
+ relative_major_note_name = relative_major(note_name, mode_name)
83
+
84
+ index = CIRCLE_OF_FIFTHS.index(relative_major_note_name)
85
+ result = CIRCLE_OF_FIFTHS[(index - 1)..(index + 5)]
86
+ raise UnsupportedRootName.new(note_name) unless result.size == 7
87
+
88
+ result = result.sort
89
+ return result.rotate(result.index(note_name))
90
+ end
91
+
92
+ # A aeolian -> C
93
+ def self.relative_major(note_name, mode_name)
94
+ note = Note.new(note_name)
95
+ index = CIRCLE_OF_FIFTHS.index(note.full_note)
96
+ raise UnsupportedRootName.new(note_name) if index.nil?
97
+
98
+ result_index = index - mode_offset_from_major(mode_name)
99
+ raise UnsupportedRootName.new(note_name) if result_index.negative?
100
+
101
+ result = CIRCLE_OF_FIFTHS[index - mode_offset_from_major(mode_name)]
102
+ return result
103
+ end
104
+
105
+ class << self
106
+ private
107
+
108
+ def mode_offset_from_major(mode_name)
109
+ MODES_IN_ORDER_OF_DARKNESS.each.with_index do |mode_names, index|
110
+ return (index - 1) if mode_names.include?(mode_name)
111
+ end
112
+ raise InvalidModeName.new(mode_name)
113
+ end
114
+ end
115
+ end
116
+ end