lib/coltrane/theory/interval.rb
# frozen_string_literal: true
module Coltrane
module Theory
class Interval < IntervalClass
attr_reader :letter_distance, :cents
alias compound? compound
class << self
def all
@all ||= super.map(&:interval)
end
def all_compound
@all_compound ||= all.map(&:compound)
end
def all_including_compound
@all_including_compound ||= all + all_compound
end
def all_augmented
@all_augmented ||= all_including_compound.select(&:has_augmented?)
.map(&:augmented)
end
def all_diminished
@all_diminished ||= all_including_compound.select(&:has_diminished?)
.map(&:diminished)
end
def all_including_compound_and_altered
@all_including_compound_and_altered ||=
all_including_compound +
all_diminished +
all_augmented
end
end
def initialize(arg_1 = nil, arg_2 = nil, ascending: true,
letter_distance: nil,
semitones: nil,
compound: false)
if arg_1 && !arg_2 # assumes arg_1 is a letter
@compound = compound
IntervalClass[arg_1].interval.yield_self do |interval|
@letter_distance = interval.letter_distance
@cents = interval.cents
end
elsif arg_1 && arg_2 # assumes those are notes
if ascending
@compound = compound
@cents =
(arg_1.frequency / arg_2.frequency)
.interval_class
.cents
@letter_distance = calculate_letter_distance arg_1.letter,
arg_2.letter,
ascending
else
self.class.new(arg_1, arg_2).descending.yield_self do |base_interval|
@compound = base_interval.compound?
@cents = base_interval.cents
@letter_distance = base_interval.letter_distance
end
end
elsif letter_distance && semitones
@compound = compound || letter_distance > 8
@cents = semitones * 100
@letter_distance = letter_distance
else
raise WrongKeywordsError,
'[interval_class_name]' \
'Provide: [first_note, second_note] || ' \
'[letter_distance:, semitones:]'
end
end
def self.[](arg)
new(arg)
end
def interval_class
FrequencyInterval[cents].interval_class
end
def compound?
@compound
end
def has_augmented?
name.match? /M|P|A/
end
def has_diminished?
name.match? /m|P|d/
end
def accidentals
if distance_to_starting.positive? then 'A' * distance_to_starting.abs
elsif distance_to_starting.negative? then 'd' * distance_to_starting.abs
else ''
end
end
def name
@name ||= begin
if distance_to_starting.zero? || distance_to_starting.abs > 2
compound? ? interval_class.compound_name : interval_class.name
else
"#{accidentals}#{starting_interval.distance + (compound? ? 7 : 0)}"
end
end
end
def as(n)
i = clone(letter_distance: n)
i if i.name.match?(n.to_s)
end
def as!(n)
i = as(n)
i unless i&.name&.match? /d|A/
end
def as_diminished(n = 1)
as(letter_distance + n)
end
def as_augmented(n = 1)
as(letter_distance - n)
end
def clone(override_args = {})
self.class.new(**{
semitones: semitones,
letter_distance: letter_distance,
compound: compound?
}.merge(override_args))
end
def diminish(n = 1)
clone(semitones: semitones - n)
end
alias diminished diminish
def augment(n = 1)
clone(semitones: semitones + n)
end
alias augmented augment
def opposite
clone(semitones: -semitones, letter_distance: (-letter_distance % 8) + 1)
end
def ascending
ascending? ? self : opposite
end
def descending
descending? ? self : opposite
end
private
def starting_interval # select the closest interval possible to start from
@starting_interval ||= begin
IntervalClass.all
.select { |i| i.distance == normalized_letter_distance }
.sort_by do |i|
(cents - i.cents)
.yield_self { |d| [(d % 1200), (d % -1200)].min_by(&:abs) }
.abs
end
.first
end
end
def normalized_letter_distance
return letter_distance if letter_distance < 8
(letter_distance % 8) + 1
end
def distance_to_starting # calculate the closts distance to it
d = semitones - starting_interval.semitones
[(d % 12), (d % -12)].min_by(&:abs)
end
def all_letters
PitchClass.all_letters
end
def calculate_letter_distance(a, b, asc)
all_letters
.rotate(all_letters.index(asc ? a : b))
.index(b) + 1
end
public
all_including_compound_and_altered.each do |interval|
self.class.define_method(interval.full_name.underscore) { interval.clone }
end
end
end
end