pedrozath/coltrane

View on GitHub
lib/coltrane/theory/interval.rb

Summary

Maintainability
B
5 hrs
Test Coverage
A
95%
# 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