coltrane 2.1.5 → 2.2.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -38,7 +38,7 @@ module Coltrane
38
38
 
39
39
  def /(other)
40
40
  case other
41
- when Frequency then Interval[1200 * Math.log2(frequency / other.frequency)]
41
+ when Frequency then FrequencyInterval[1200 * Math.log2(other.frequency / frequency)]
42
42
  when Numeric then Frequency[frequency / other]
43
43
  end
44
44
  end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Coltrane
4
+ # Interval describe the logarithmic distance between 2 frequencies.
5
+ # It's measured in cents.
6
+ class FrequencyInterval
7
+ include Comparable
8
+
9
+ attr_reader :cents
10
+
11
+ class << self
12
+ alias [] new
13
+ end
14
+
15
+ def initialize(cents)
16
+ @cents = cents.round
17
+ end
18
+
19
+ def semitones
20
+ (cents.to_f / 100).round
21
+ end
22
+
23
+ def ascending
24
+ self.class[cents.abs]
25
+ end
26
+
27
+ def descending
28
+ self.class[-cents.abs]
29
+ end
30
+
31
+ def ascending?
32
+ cents > 0
33
+ end
34
+
35
+ def descending?
36
+ cents < 0
37
+ end
38
+
39
+ def inversion
40
+ self.class[(-cents.abs % 1200) * (ascending? ? +1 : -1)]
41
+ end
42
+
43
+ def opposite
44
+ self.class.new(-cents)
45
+ end
46
+
47
+ def interval_class
48
+ IntervalClass.new(semitones)
49
+ end
50
+
51
+ def ==(other)
52
+ return false unless other.is_a? FrequencyInterval
53
+ cents == other.cents
54
+ end
55
+
56
+ alias eql? ==
57
+ alias hash cents
58
+
59
+ def +(other)
60
+ case other
61
+ when Numeric then FrequencyInterval[cents + other]
62
+ when Interval then FrequencyInterval[cents + other.cents]
63
+ end
64
+ end
65
+
66
+ def -(other)
67
+ case other
68
+ when Numeric then FrequencyInterval[cents - other]
69
+ when Interval then FrequencyInterval[cents - other.cents]
70
+ end
71
+ end
72
+
73
+ def -@
74
+ FrequencyInterval[-cents]
75
+ end
76
+
77
+ def <=>(other)
78
+ cents <=> other.cents
79
+ end
80
+ end
81
+ end
@@ -1,64 +1,208 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Coltrane
4
- # Interval describe the logarithmic distance between 2 frequencies.
5
- # It's measured in cents.
6
- class Interval
7
- include Comparable
8
-
9
- attr_reader :cents
4
+ class Interval < IntervalClass
5
+ attr_reader :letter_distance, :cents
6
+ alias compound? compound
10
7
 
11
8
  class << self
12
- alias [] new
9
+ def all
10
+ @all ||= super.map(&:interval)
11
+ end
12
+
13
+ def all_compound
14
+ @all_compound ||= all.map(&:compound)
15
+ end
16
+
17
+ def all_including_compound
18
+ @all_including_compound ||= all + all_compound
19
+ end
13
20
 
14
- def method_missing(method, *args)
15
- IntervalClass.send(method, *args)
21
+ def all_augmented
22
+ @all_augmented ||= all_including_compound.select(&:has_augmented?)
23
+ .map(&:augmented)
24
+ end
25
+
26
+ def all_diminished
27
+ @all_diminished ||= all_including_compound.select(&:has_diminished?)
28
+ .map(&:diminished)
29
+ end
30
+
31
+ def all_including_compound_and_altered
32
+ @all_including_compound_and_altered ||=
33
+ all_including_compound +
34
+ all_diminished +
35
+ all_augmented
16
36
  end
17
37
  end
18
38
 
19
- def initialize(cents)
20
- @cents = cents.round
39
+ def initialize(arg_1 = nil, arg_2 = nil, ascending: true,
40
+ letter_distance: nil,
41
+ semitones: nil,
42
+ compound: false)
43
+ if arg_1 && !arg_2 # assumes arg_1 is a letter
44
+ @compound = compound
45
+ IntervalClass[arg_1].interval.yield_self do |interval|
46
+ @letter_distance = interval.letter_distance
47
+ @cents = interval.cents
48
+ end
49
+ elsif arg_1 && arg_2 # assumes those are notes
50
+ if ascending
51
+ @compound = compound
52
+ @cents =
53
+ (arg_1.frequency / arg_2.frequency)
54
+ .interval_class
55
+ .cents
56
+
57
+ @letter_distance = calculate_letter_distance arg_1.letter,
58
+ arg_2.letter,
59
+ ascending
60
+ else
61
+ self.class.new(arg_1, arg_2).descending.yield_self do |base_interval|
62
+ @compound = base_interval.compound?
63
+ @cents = base_interval.cents
64
+ @letter_distance = base_interval.letter_distance
65
+ end
66
+ end
67
+ elsif letter_distance && semitones
68
+ @compound = compound || letter_distance > 8
69
+ @cents = semitones * 100
70
+ @letter_distance = letter_distance
71
+ else
72
+ raise WrongKeywordsError,
73
+ '[interval_class_name]' \
74
+ 'Provide: [first_note, second_note] || ' \
75
+ '[letter_distance:, semitones:]'
76
+ end
21
77
  end
22
78
 
23
- def semitones
24
- (cents.to_f / 100).round
79
+ def self.[](arg)
80
+ new(arg)
25
81
  end
26
82
 
27
- def ascending?
28
- cents < 0
83
+ def interval_class
84
+ FrequencyInterval[cents].interval_class
29
85
  end
30
86
 
31
- def descending?
32
- cents > 0
87
+ def compound?
88
+ @compound
33
89
  end
34
90
 
35
- def ==(other)
36
- cents == other.cents
91
+ def has_augmented?
92
+ name.match?(%r{M|P|A})
37
93
  end
38
94
 
39
- alias eql? ==
40
- alias hash cents
95
+ def has_diminished?
96
+ name.match?(%r{m|P|d})
97
+ end
98
+
99
+ def accidentals
100
+ case
101
+ when distance_to_starting.positive? then 'A'
102
+ when distance_to_starting.negative? then 'd'
103
+ else return ''
104
+ end * distance_to_starting.abs
105
+ end
41
106
 
42
- def +(other)
43
- case other
44
- when Numeric then Interval[cents + other]
45
- when Interval then Interval[cents + other.cents]
107
+ def name
108
+ @name ||= begin
109
+ if distance_to_starting.zero? || distance_to_starting.abs > 2
110
+ compound? ? interval_class.compound_name : interval_class.name
111
+ else
112
+ "#{accidentals}#{starting_interval.distance + (compound? ? 7 : 0)}"
113
+ end
46
114
  end
47
115
  end
48
116
 
49
- def -(other)
50
- case other
51
- when Numeric then Interval[cents - other]
52
- when Interval then Interval[cents - other.cents]
117
+ def as(n)
118
+ i = clone(letter_distance: n)
119
+ i if i.name.match?(n.to_s)
120
+ end
121
+
122
+ def as!(n)
123
+ i = as(n)
124
+ i unless i&.name&.match?(/d|A/)
125
+ end
126
+
127
+ def as_diminished(n = 1)
128
+ as(letter_distance + n)
129
+ end
130
+
131
+ def as_augmented(n = 1)
132
+ as(letter_distance - n)
133
+ end
134
+
135
+ def clone(override_args = {})
136
+ self.class.new({
137
+ semitones: semitones,
138
+ letter_distance: letter_distance,
139
+ compound: compound?
140
+ }.merge(override_args))
141
+ end
142
+
143
+ def diminish(n = 1)
144
+ clone(semitones: semitones - n)
145
+ end
146
+
147
+ alias diminished diminish
148
+
149
+ def augment(n = 1)
150
+ clone(semitones: semitones + n)
151
+ end
152
+
153
+ alias augmented augment
154
+
155
+ def opposite
156
+ clone(semitones: -semitones, letter_distance: (-letter_distance % 8) + 1)
157
+ end
158
+
159
+ def ascending
160
+ ascending? ? self : opposite
161
+ end
162
+
163
+ def descending
164
+ descending? ? self : opposite
165
+ end
166
+
167
+ private
168
+
169
+ def starting_interval # select the closest interval possible to start from
170
+ @starting_interval ||= begin
171
+ IntervalClass.all
172
+ .select { |i| i.distance == normalized_letter_distance }
173
+ .sort_by do |i|
174
+ (cents - i.cents)
175
+ .yield_self { |d| [(d % 1200), (d % -1200)].min_by(&:abs) }
176
+ .abs
177
+ end
178
+ .first
53
179
  end
54
180
  end
55
181
 
56
- def -@
57
- Interval[-cents]
182
+ def normalized_letter_distance
183
+ return letter_distance if letter_distance < 8
184
+ (letter_distance % 8) + 1
185
+ end
186
+
187
+ def distance_to_starting # calculate the closts distance to it
188
+ d = semitones - starting_interval.semitones
189
+ [(d % 12), (d % -12)].min_by(&:abs)
58
190
  end
59
191
 
60
- def <=>(other)
61
- cents <=> other.cents
192
+ def all_letters
193
+ PitchClass.all_letters
194
+ end
195
+
196
+ def calculate_letter_distance(a, b, asc)
197
+ all_letters
198
+ .rotate(all_letters.index(asc ? a : b))
199
+ .index(b) + 1
200
+ end
201
+
202
+ public
203
+
204
+ all_including_compound_and_altered.each do |interval|
205
+ self.class.define_method(interval.full_name.underscore) { interval.clone }
62
206
  end
63
207
  end
64
208
  end
@@ -7,85 +7,183 @@ module Coltrane
7
7
  #
8
8
  # This class in specific still takes into account the order of intervals.
9
9
  # C to D is a major second, but D to C is a minor seventh.
10
- class IntervalClass < Interval
11
- INTERVALS = %w[P1 m2 M2 m3 M3 P4 A4 P5 m6 M6 m7 M7].freeze
10
+ class IntervalClass < FrequencyInterval
11
+ QUALITY_SEQUENCE = [
12
+ %w[P],
13
+ %w[m M],
14
+ %w[m M],
15
+ %w[P A],
16
+ %w[P],
17
+ %w[m M],
18
+ %w[m M]
19
+ ].freeze
20
+
21
+ ALTERATIONS = {
22
+ 'A' => +1,
23
+ 'd' => -1
24
+ }.freeze
25
+
26
+ SINGLE_DISTANCES_NAMES = %w[
27
+ Unison
28
+ Second
29
+ Third
30
+ Fourth
31
+ Fifth
32
+ Sixth
33
+ Seventh
34
+ ].freeze
35
+
36
+ COMPOUND_DISTANCES_NAMES = [
37
+ 'Octave',
38
+ 'Ninth',
39
+ 'Tenth',
40
+ 'Eleventh',
41
+ 'Twelfth',
42
+ 'Thirteenth',
43
+ 'Fourteenth',
44
+ 'Double Octave'
45
+ ].freeze
46
+
47
+ DISTANCES_NAMES = (SINGLE_DISTANCES_NAMES + COMPOUND_DISTANCES_NAMES).freeze
48
+
49
+ QUALITY_NAMES = {
50
+ 'P' => 'Perfect',
51
+ 'm' => 'Minor',
52
+ 'M' => 'Major',
53
+ 'A' => 'Augmented',
54
+ 'd' => 'Diminished'
55
+ }.freeze
56
+
57
+ class << self
58
+ def distances_names
59
+ DISTANCES_NAMES
60
+ end
12
61
 
13
- def self.split(interval)
14
- interval.scan(/(\w)(\d\d?)/)[0]
15
- end
62
+ def distance_name(n)
63
+ DISTANCES_NAMES[n - 1]
64
+ end
16
65
 
17
- def self.full_name(interval)
18
- q, n = split(interval)
19
- "#{q.interval_quality} #{n.to_i.interval_name}"
20
- end
66
+ def quality_name(q)
67
+ QUALITY_NAMES[q]
68
+ end
21
69
 
22
- def self.method_missing; end
70
+ def names
71
+ @names ||= begin
72
+ SINGLE_DISTANCES_NAMES.each_with_index.reduce([]) do |i_names, (_d, i)|
73
+ i_names + QUALITY_SEQUENCE[i % 7].reduce([]) do |qs, q|
74
+ qs + ["#{q}#{i + 1}"]
75
+ end
76
+ end
77
+ end
78
+ end
23
79
 
24
- # Create full names and methods such as major_third? minor_seventh?
25
- # TODO: It's a mess and it really needs a refactor someday
26
- NAMES = INTERVALS.each_with_index.each_with_object({}) do |(interval, index), memo|
27
- memo[interval] ||= []
28
- 2.times do |o|
29
- q, i = split(interval)
30
- num = o * 7 + i.to_i
31
- prev_q = split(INTERVALS[(index - 1) % 12])[0]
32
- next_q = split(INTERVALS[(index + 1) % 12])[0]
33
- memo[interval] << full_name("#{q}#{num}")
34
- memo[interval] << full_name("d#{(num - 1 + 1) % 14 + 1}") if next_q.match? /m|P/
35
- next if q == 'A'
36
- memo[interval] << full_name("A#{(num - 1 - 1) % 14 + 1}") if prev_q.match? /M|P/
80
+ def compound_names
81
+ @compound_names ||= all.map(&:compound_name)
82
+ end
83
+
84
+ def all_names_including_compound
85
+ @all_names_including_compound ||= names + compound_names
37
86
  end
38
- end
39
87
 
40
- ALL_FULL_NAMES = NAMES.values.flatten
88
+ def full_names
89
+ @full_names ||= names.map { |n| expand_name(n) }
90
+ end
41
91
 
42
- NAMES.each do |interval_name, full_names|
43
- full_names.each do |the_full_name|
44
- define_method "#{the_full_name.underscore}?" do
45
- name == interval_name
46
- end
47
- IntervalClass.class.define_method the_full_name.underscore.to_s do
48
- IntervalClass.new(interval_name)
49
- end
92
+ def all
93
+ @all ||= names.map { |n| IntervalClass[n] }
94
+ end
95
+
96
+ def full_names_including_compound
97
+ @full_names_including_compound ||=
98
+ all_names_including_compound.map { |n| expand_name(n) }
99
+ end
100
+
101
+ def split(interval)
102
+ interval.scan(/(\w)(\d\d?)/)[0]
103
+ end
104
+
105
+ def expand_name(name)
106
+ q, n = split(name)
107
+ (
108
+ case name
109
+ when /AA|dd/ then 'Double '
110
+ when /AAA|ddd/ then 'Triple '
111
+ else ''
112
+ end
113
+ ) + "#{quality_name(q)} #{distance_name(n.to_i)}"
50
114
  end
51
115
  end
52
116
 
53
117
  def initialize(arg)
54
118
  super case arg
55
- when Interval then arg.semitones
119
+ when FrequencyInterval then arg.semitones
56
120
  when String
57
- INTERVALS.index(arg) || self.class.interval_by_full_name(arg)
121
+ self.class.names.index(arg) ||
122
+ self.class.full_names.index(arg) ||
123
+ self.class.all_names_including_compound.index(arg) ||
124
+ self.class.full_names_including_compound.index(arg)
58
125
  when Numeric then arg
59
- else raise WrongArgumentsError
126
+ else
127
+ raise WrongArgumentsError,
128
+ 'Provide: [interval] || [name] || [number of semitones]'
60
129
  end % 12 * 100
61
130
  end
62
131
 
63
- def self.[](semis)
64
- new semis
65
- end
132
+ instance_eval { alias [] new }
66
133
 
67
- def all_full_names
68
- self.class.all_full_names
134
+ def interval
135
+ Interval.new(letter_distance: distance, semitones: semitones)
69
136
  end
70
137
 
71
- def self.all_full_names
72
- ALL_FULL_NAMES
138
+ def compound_interval
139
+ Interval.new(
140
+ letter_distance: distance,
141
+ semitones: semitones,
142
+ compound: true
143
+ )
73
144
  end
74
145
 
146
+ alias compound compound_interval
147
+
75
148
  def ==(other)
76
- (cents % 12) == (other.cents % 12)
149
+ return false unless other.is_a? FrequencyInterval
150
+ (semitones % 12) == (other.semitones % 12)
77
151
  end
78
152
 
79
- def name
80
- INTERVALS[semitones % 12]
153
+ def alteration
154
+ name.chars.reduce(0) { |a, s| a + (ALTERATIONS[s] || 0) }
155
+ end
156
+
157
+ def ascending
158
+ self.class[semitones.abs]
159
+ end
160
+
161
+ def descending
162
+ self.class[-semitones.abs]
163
+ end
164
+
165
+ def inversion
166
+ self.class[-semitones % 12]
81
167
  end
82
168
 
83
169
  def full_name
84
- self.class.full_name(name)
170
+ self.class.expand_name(name)
171
+ end
172
+
173
+ def name
174
+ self.class.names[semitones % 12]
175
+ end
176
+
177
+ def compound_name
178
+ "#{quality}#{distance + 7}"
179
+ end
180
+
181
+ def distance
182
+ self.class.split(name)[1].to_i
85
183
  end
86
184
 
87
- def full_names
88
- NAMES[name]
185
+ def quality
186
+ self.class.split(name)[0]
89
187
  end
90
188
 
91
189
  def +(other)