Class: OutroRails::Theory::Scale

Inherits:
Object
  • Object
show all
Defined in:
lib/outro_rails/theory/scale.rb

Overview

A scale: a root NoteName plus a named pattern. Not persisted - scales are generated on demand from the pattern table.

Theory::Scale.new('A', :natural_minor).note_names.map(&:to_s)
# => ['A', 'B', 'C', 'D', 'E', 'F', 'G']

Defined Under Namespace

Classes: DiatonicChord

Constant Summary collapse

PATTERNS =

Each degree is [semitones above root, letter steps above root]. Carrying the letter step makes spelling unambiguous (F# major gets E#, not F; G# major gets F#).

{
  major:            [ [ 0, 0 ], [ 2, 1 ], [ 4, 2 ], [ 5, 3 ], [ 7, 4 ], [ 9, 5 ], [ 11, 6 ] ],
  natural_minor:    [ [ 0, 0 ], [ 2, 1 ], [ 3, 2 ], [ 5, 3 ], [ 7, 4 ], [ 8, 5 ], [ 10, 6 ] ],
  harmonic_minor:   [ [ 0, 0 ], [ 2, 1 ], [ 3, 2 ], [ 5, 3 ], [ 7, 4 ], [ 8, 5 ], [ 11, 6 ] ],
  melodic_minor:    [ [ 0, 0 ], [ 2, 1 ], [ 3, 2 ], [ 5, 3 ], [ 7, 4 ], [ 9, 5 ], [ 11, 6 ] ],
  major_pentatonic: [ [ 0, 0 ], [ 2, 1 ], [ 4, 2 ], [ 7, 4 ], [ 9, 5 ] ],
  minor_pentatonic: [ [ 0, 0 ], [ 3, 2 ], [ 5, 3 ], [ 7, 4 ], [ 10, 6 ] ],
  major_blues:      [ [ 0, 0 ], [ 2, 1 ], [ 3, 2 ], [ 4, 2 ], [ 7, 4 ], [ 9, 5 ] ],
  minor_blues:      [ [ 0, 0 ], [ 3, 2 ], [ 5, 3 ], [ 6, 4 ], [ 7, 4 ], [ 10, 6 ] ],
  # Modes of the major scale (ionian == major, aeolian == natural_minor).
  dorian:           [ [ 0, 0 ], [ 2, 1 ], [ 3, 2 ], [ 5, 3 ], [ 7, 4 ], [ 9, 5 ], [ 10, 6 ] ],
  phrygian:         [ [ 0, 0 ], [ 1, 1 ], [ 3, 2 ], [ 5, 3 ], [ 7, 4 ], [ 8, 5 ], [ 10, 6 ] ],
  lydian:           [ [ 0, 0 ], [ 2, 1 ], [ 4, 2 ], [ 6, 3 ], [ 7, 4 ], [ 9, 5 ], [ 11, 6 ] ],
  mixolydian:       [ [ 0, 0 ], [ 2, 1 ], [ 4, 2 ], [ 5, 3 ], [ 7, 4 ], [ 9, 5 ], [ 10, 6 ] ],
  locrian:          [ [ 0, 0 ], [ 1, 1 ], [ 3, 2 ], [ 5, 3 ], [ 6, 4 ], [ 8, 5 ], [ 10, 6 ] ]
}.freeze
MINOR_PATTERNS =

The minor-family patterns; all share the same relative major.

%i[natural_minor harmonic_minor melodic_minor].freeze
MODE_PARENT_DEGREES =

Degree of the parent major scale each mode starts on (dorian is built on the 2nd degree, and so on).

{
  dorian:     2,
  phrygian:   3,
  lydian:     4,
  mixolydian: 5,
  locrian:    7
}.freeze

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(root, pattern_name = :major) ⇒ Scale

Parses the root and validates the pattern name up front, so an unknown scale raises here rather than on first use.



88
89
90
91
92
93
94
# File 'lib/outro_rails/theory/scale.rb', line 88

def initialize(root, pattern_name = :major)
  @root = NoteName.parse(root)
  @pattern_name = pattern_name.to_sym
  PATTERNS.fetch(@pattern_name) do
    raise ArgumentError, "unknown scale #{pattern_name.inspect}"
  end
end

Instance Attribute Details

#pattern_nameObject (readonly)

The tonic NoteName and the pattern key it was built with



79
80
81
# File 'lib/outro_rails/theory/scale.rb', line 79

def pattern_name
  @pattern_name
end

#rootObject (readonly)

The tonic NoteName and the pattern key it was built with



79
80
81
# File 'lib/outro_rails/theory/scale.rb', line 79

def root
  @root
end

Class Method Details

.pattern?(name) ⇒ Boolean

Whether a pattern by this name exists, for validating user input

Returns:

  • (Boolean)


82
83
84
# File 'lib/outro_rails/theory/scale.rb', line 82

def self.pattern?(name)
  PATTERNS.key?(name.to_s.to_sym)
end

Instance Method Details

#church_mode?Boolean Also known as: mode?

True for the named modes of the major scale (excluding major itself and natural minor, which the app treats as keys in their own right). Named to avoid colliding with Key#mode, which means major/minor tonality, not a church mode.

Returns:

  • (Boolean)


175
176
177
# File 'lib/outro_rails/theory/scale.rb', line 175

def church_mode?
  MODE_PARENT_DEGREES.key?(pattern_name)
end

#degree(number) ⇒ Object

1-based scale degree => NoteName ("what's the 5th of D major?").



135
136
137
138
139
140
141
142
143
# File 'lib/outro_rails/theory/scale.rb', line 135

def degree(number)
  note_names.fetch(number - 1) do
    message =
      "degree #{number} out of range for " \
      "#{pattern_name} (1..#{size})"

    raise ArgumentError, message
  end
end

#diatonic_chordsObject

The triad on each degree, built by stacking alternate scale notes (1-3-5 within the scale). Works for any seven-note pattern, so modes and the altered minors get correct qualities (harmonic minor yields a major V and an augmented III):

Scale.new('C').diatonic_chords.map(&:symbol)
# => ['C', 'Dm', 'Em', 'F', 'G', 'Am', 'Bdim']


265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
# File 'lib/outro_rails/theory/scale.rb', line 265

def diatonic_chords
  unless diatonic_chords?
    raise ArgumentError,
          "no diatonic chords for #{pattern_name} " \
          "(needs a seven-note scale)"
  end

  names = note_names

  (1..size).map do |degree|
    chord_root = names[degree - 1]
    third = names[(degree + 1) % size]
    fifth = names[(degree + 3) % size]

    third_interval =
      (third.pitch_class - chord_root.pitch_class) %
      Interval::SEMITONES_PER_OCTAVE

    fifth_interval =
      (fifth.pitch_class - chord_root.pitch_class) %
      Interval::SEMITONES_PER_OCTAVE

    intervals = [
      0,
      third_interval,
      fifth_interval
    ]

    quality = ChordVocabulary::QUALITIES.key(intervals)
    unless quality
      raise ArgumentError,
            "no triad quality for intervals #{intervals.inspect} on " \
            "degree #{degree} of #{self}"
    end

    DiatonicChord.new(degree: degree, root: chord_root, quality: quality)
  end
end

#diatonic_chords?Boolean

True when the scale supports stacked-third diatonic triads (any seven-note pattern; pentatonic and blues scales don't).

Returns:

  • (Boolean)


182
183
184
# File 'lib/outro_rails/theory/scale.rb', line 182

def diatonic_chords?
  size == 7
end

#diatonic_quality(number) ⇒ Object

Diatonic triad quality for a 1-based degree, derived from the stacked-third triads (any seven-note pattern).



306
307
308
309
310
311
# File 'lib/outro_rails/theory/scale.rb', line 306

def diatonic_quality(number)
  diatonic_chords.fetch(number - 1) do
    raise ArgumentError,
          "degree #{number} out of range for #{pattern_name} (1..#{size})"
  end.quality
end

#include?(note) ⇒ Boolean

Whether a note belongs to the scale, compared by sound so enharmonics match (Gb counts in a scale spelling F#).

Returns:

  • (Boolean)


130
131
132
# File 'lib/outro_rails/theory/scale.rb', line 130

def include?(note)
  pitch_classes.include?(NoteName.parse(note).pitch_class)
end

#intervalsObject

Semitones above the root, one per degree



107
108
109
# File 'lib/outro_rails/theory/scale.rb', line 107

def intervals
  pattern.map(&:first)
end

#major?Boolean

True only for the major pattern itself, not its modes.

Returns:

  • (Boolean)


162
163
164
# File 'lib/outro_rails/theory/scale.rb', line 162

def major?
  pattern_name == :major
end

#minor?Boolean

True for the minor-family patterns (natural, harmonic, melodic).

Returns:

  • (Boolean)


167
168
169
# File 'lib/outro_rails/theory/scale.rb', line 167

def minor?
  MINOR_PATTERNS.include?(pattern_name)
end

#modesObject

The named modes built on this major scale's degrees, in degree order (dorian on the 2nd, and so on) - the inverse of #parent_major:

Scale.new('C').modes.map(&:to_s)
# => ['D dorian', 'E phrygian', 'F lydian', 'G mixolydian',
#     'B locrian']


249
250
251
252
253
254
255
256
257
# File 'lib/outro_rails/theory/scale.rb', line 249

def modes
  unless major?
    raise ArgumentError, "modes are derived from a major scale"
  end

  MODE_PARENT_DEGREES.map do |pattern, degree_number|
    self.class.new(degree(degree_number), pattern)
  end
end

#net_accidentalsObject Also known as: signature

Net accidentals across the spelled scale; negative leans flat. (Not a key signature: A harmonic minor scores +1 for its G#, and pentatonic/blues scales have no key signature at all - Key#signature is the key-signature reading of this number.)



149
150
151
# File 'lib/outro_rails/theory/scale.rb', line 149

def net_accidentals
  note_names.sum(&:accidental_offset)
end

#note_namesObject

Spelled notes, one per degree.



117
118
119
# File 'lib/outro_rails/theory/scale.rb', line 117

def note_names
  pattern.map { |semitones, steps| root.at_degree(semitones, steps) }
end

#parent_majorObject

The major scale a mode is drawn from, correctly spelled:

Scale.new('C', :dorian).parent_major   # => Bb major
Scale.new('F#', :lydian).parent_major  # => C# major


220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
# File 'lib/outro_rails/theory/scale.rb', line 220

def parent_major
  degree = MODE_PARENT_DEGREES.fetch(pattern_name) do
    raise ArgumentError,
          "#{pattern_name} is not a mode of the major scale"
  end

  # The mode root sits `semitones`/`steps` above its parent tonic, so
  # the tonic is the complementary degree *up* from the root.
  semitones, steps = PATTERNS.fetch(:major)[degree - 1]

  semitone_offset =
    (Interval::SEMITONES_PER_OCTAVE - semitones) %
    Interval::SEMITONES_PER_OCTAVE

  step_offset =
    (NoteName::LETTERS_PER_OCTAVE - steps) %
    NoteName::LETTERS_PER_OCTAVE

  tonic = root.at_degree(semitone_offset, step_offset)

  self.class.new(tonic, :major)
end

#patternObject

The [semitones, letter steps] pairs for this scale



97
98
99
# File 'lib/outro_rails/theory/scale.rb', line 97

def pattern
  PATTERNS.fetch(pattern_name)
end

#pitch_classesObject

Sounding pitch classes, 0-11, ignoring spelling



122
123
124
125
126
# File 'lib/outro_rails/theory/scale.rb', line 122

def pitch_classes
  intervals.map do |i|
    (root.pitch_class + i) % Interval::SEMITONES_PER_OCTAVE
  end
end

#pitched_notes(start_octave: 4, octaves: 1) ⇒ Object

Pitches for rendering on a keyboard: the scale laid out ascending from the root, spanning octaves octaves plus the top root. Written octaves follow the letter (a Cb is written in the octave above the B it sounds as).



317
318
319
320
321
322
323
324
325
326
327
328
329
330
# File 'lib/outro_rails/theory/scale.rb', line 317

def pitched_notes(start_octave: 4, octaves: 1)
  root_midi = Pitch.new(root, start_octave).midi
  names = note_names

  pitches = octaves.times.flat_map do |octave|
    pattern.each_with_index.map do |(semitones, _), index|
      Pitch.sounding(names[index],
                     root_midi + (octave * Interval::OCTAVE) + semitones)
    end
  end

  pitches << Pitch.sounding(root,
                            root_midi + (octaves * Interval::OCTAVE))
end

#preferred_accidentalsObject

:flats or :sharps - the spelling that matches this scale, for naming pitches the scale itself doesn't spell (display padding, fretboard labels).



157
158
159
# File 'lib/outro_rails/theory/scale.rb', line 157

def preferred_accidentals
  net_accidentals.negative? ? :flats : :sharps
end

#relative_majorObject

The major key sharing this minor's tonality - a minor third above the root, correctly spelled:

Scale.new('A', :natural_minor).relative_major   # => C major
Scale.new('Eb', :harmonic_minor).relative_major # => Gb major


209
210
211
212
213
214
215
# File 'lib/outro_rails/theory/scale.rb', line 209

def relative_major
  unless minor?
    raise ArgumentError, "#{pattern_name} has no relative major"
  end

  self.class.new(root.at_degree(Interval::MINOR_THIRD, 2), :major)
end

#relative_minorObject

The natural minor sharing this major's notes - built on the 6th degree, so the spelling is inherited from the scale itself:

Scale.new('C').relative_minor  # => A natural minor
Scale.new('Gb').relative_minor # => Eb natural minor


190
191
192
193
194
195
196
# File 'lib/outro_rails/theory/scale.rb', line 190

def relative_minor
  unless major?
    raise ArgumentError, "#{pattern_name} has no relative minor"
  end

  self.class.new(degree(6), :natural_minor)
end

#relative_minorsObject

All three minor forms on the relative tonic (natural, harmonic, melodic) - the inverse of #relative_major.



200
201
202
203
# File 'lib/outro_rails/theory/scale.rb', line 200

def relative_minors
  tonic = relative_minor.root
  MINOR_PATTERNS.map { |pattern| self.class.new(tonic, pattern) }
end

#sizeObject

Number of degrees - 7 for the diatonic patterns, fewer otherwise



112
113
114
# File 'lib/outro_rails/theory/scale.rb', line 112

def size
  pattern.size
end

#slugObject

URL segment for the pattern, e.g. :major_blues => 'major-blues'



102
103
104
# File 'lib/outro_rails/theory/scale.rb', line 102

def slug
  pattern_name.to_s.tr("_", "-")
end

#to_sObject

Display name, e.g. "A natural minor"



333
334
335
# File 'lib/outro_rails/theory/scale.rb', line 333

def to_s
  "#{root} #{pattern_name.to_s.tr('_', ' ')}"
end