fet 0.2.0

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