coltrane 2.1.5 → 2.2.1

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.
@@ -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)