pedrozath/coltrane

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

Summary

Maintainability
A
3 hrs
Test Coverage
B
89%
# frozen_string_literal: true

module Coltrane
  module Theory
    # Interval class here is not related to the Object Oriented Programming context
    # but to the fact that there is a class of intervals that can all be categorized
    # as having the same quality.
    #
    # This class in specific still takes into account the order of intervals.
    # C to D is a major second, but D to C is a minor seventh.
    class IntervalClass < FrequencyInterval
      QUALITY_SEQUENCE = [
        %w[P],
        %w[m M],
        %w[m M],
        %w[P A],
        %w[P],
        %w[m M],
        %w[m M]
      ].freeze

      ALTERATIONS = {
        'A' => +1,
        'd' => -1
      }.freeze

      SINGLE_DISTANCES_NAMES = %w[
        Unison
        Second
        Third
        Fourth
        Fifth
        Sixth
        Seventh
      ].freeze

      COMPOUND_DISTANCES_NAMES = [
        'Octave',
        'Ninth',
        'Tenth',
        'Eleventh',
        'Twelfth',
        'Thirteenth',
        'Fourteenth',
        'Double Octave'
      ].freeze

      DISTANCES_NAMES = (SINGLE_DISTANCES_NAMES + COMPOUND_DISTANCES_NAMES).freeze

      QUALITY_NAMES = {
        'P' => 'Perfect',
        'm' => 'Minor',
        'M' => 'Major',
        'A' => 'Augmented',
        'd' => 'Diminished'
      }.freeze

      class << self
        def distances_names
          DISTANCES_NAMES
        end

        def distance_name(n)
          DISTANCES_NAMES[n - 1]
        end

        def quality_name(q)
          QUALITY_NAMES[q]
        end

        def names
          @names ||= begin
            SINGLE_DISTANCES_NAMES.each_with_index.reduce([]) do |i_names, (_d, i)|
              i_names + QUALITY_SEQUENCE[i % 7].reduce([]) do |qs, q|
                qs + ["#{q}#{i + 1}"]
              end
            end
          end
        end

        def compound_names
          @compound_names ||= all.map(&:compound_name)
        end

        def all_names_including_compound
          @all_names_including_compound ||= names + compound_names
        end

        def full_names
          @full_names ||= names.map { |n| expand_name(n) }
        end

        def all
          @all ||= names.map { |n| IntervalClass[n] }
        end

        def full_names_including_compound
          @full_names_including_compound ||=
            all_names_including_compound.map { |n| expand_name(n) }
        end

        def split(interval)
          interval.scan(/(\w)(\d\d?)/)[0]
        end

        def expand_name(name)
          q, n = split(name)
          (
            case name
            when /AA|dd/ then 'Double '
            when /AAA|ddd/ then 'Triple '
            else ''
            end
          ) + "#{quality_name(q)} #{distance_name(n.to_i)}"
        end
      end

      def initialize(arg)
        super case arg
              when FrequencyInterval then arg.semitones
              when String
                self.class.names.index(arg) ||
                  self.class.full_names.index(arg) ||
                  self.class.all_names_including_compound.index(arg) ||
                  self.class.full_names_including_compound.index(arg)
              when Numeric then arg
              else
                raise WrongArgumentsError,
                      'Provide: [interval] || [name] || [number of semitones]'
              end % 12 * 100
      end

      instance_eval { alias [] new }

      def interval
        Interval.new(letter_distance: distance, semitones: semitones)
      end

      def compound_interval
        Interval.new(
          letter_distance: distance,
          semitones: semitones,
          compound: true
        )
      end

      alias compound compound_interval

      def ==(other)
        return false unless other.is_a? FrequencyInterval
        (semitones % 12) == (other.semitones % 12)
      end

      def alteration
        name.chars.reduce(0) { |a, s| a + (ALTERATIONS[s] || 0) }
      end

      def ascending
        self.class[semitones.abs]
      end

      def descending
        self.class[-semitones.abs]
      end

      def inversion
        self.class[-semitones % 12]
      end

      def full_name
        self.class.expand_name(name)
      end

      def name
        self.class.names[semitones % 12]
      end

      def compound_name
        "#{quality}#{distance + 7}"
      end

      def distance
        self.class.split(name)[1].to_i
      end

      def quality
        self.class.split(name)[0]
      end

      def +(other)
        IntervalClass[semitones + other]
      end

      def -(other)
        IntervalClass[semitones - other]
      end

      def -@
        IntervalClass[-semitones]
      end

      private

      def self.interval_by_full_name(arg)
        NAMES.invert.each do |full_names, interval_name|
          return INTERVALS.index(interval_name) if full_names.include?(arg)
        end
        raise IntervalNotFoundError, arg
      end
    end
  end
end