Class: OutroRails::Theory::Key

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

Overview

A key: a tonic NoteName plus a mode. Not persisted. This object can transpose and turn Nashville numbers into chords.

key = Theory::Key.new('G')
key.note_names.map(&:to_s)     # => ['G', 'A', 'B', 'C', 'D', 'E', 'F#']
key.transpose_to('Bb')         # semitone/spelling aware
key.nashville('2m')            # => { root: A, quality: :minor, ... }

Defined Under Namespace

Classes: CirclePosition

Constant Summary collapse

MODES =
{ major: :major, minor: :natural_minor }.freeze
MAJOR_TONICS =

Tonics conventionally used for each mode (the circle of fifths, flattened). Used to pick spellings when transposing.

%w[C G D A E B F# Gb Db Ab Eb Bb F].freeze
MINOR_TONICS =
%w[A E B F# C# G# Eb Bb F C G D].freeze

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(tonic, mode = :major) ⇒ Key

Creates an immutable key from a tonic and mode.



83
84
85
86
87
88
89
# File 'lib/outro_rails/theory/key.rb', line 83

def initialize(tonic, mode = :major)
  @tonic = NoteName.parse(tonic)
  @mode = mode.to_sym
  MODES.fetch(@mode) { raise ArgumentError,
                             "unknown mode #{mode.inspect}" }
  freeze
end

Instance Attribute Details

#modeObject (readonly)

Returns the value of attribute mode.



37
38
39
# File 'lib/outro_rails/theory/key.rb', line 37

def mode
  @mode
end

#tonicObject (readonly)

Returns the value of attribute tonic.



37
38
39
# File 'lib/outro_rails/theory/key.rb', line 37

def tonic
  @tonic
end

Class Method Details

.canonical(mode = :major) ⇒ Object

One key of the given mode per canonical root spelling - the keys the UI offers wherever a key is picked (chart forms, transposer).



58
59
60
# File 'lib/outro_rails/theory/key.rb', line 58

def self.canonical(mode = :major)
  NoteName::CANONICAL_ROOTS.map { |root| new(root, mode) }
end

.circle_positionsObject

The 12 wheel positions in circle order starting at C (clockwise: C G D A E B F#/Gb Db Ab Eb Bb F), pairing each major with its relative minor and merging enharmonic spellings into one position.



65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
# File 'lib/outro_rails/theory/key.rb', line 65

def self.circle_positions
  MAJOR_TONICS.each_with_object([]) do |tonic, positions|
    key = new(tonic, :major)

    if positions.any? &&
       positions.last.pitch_class == key.tonic.pitch_class
       positions.last.majors << key
       positions.last.minors << key.relative
    else
      positions << CirclePosition.new(
        majors: [ key ],
        minors: [ key.relative ]
      )
    end
  end
end

.parse(value) ⇒ Object

Parses a key from a string or returns the key unchanged if already a Key. Strings ending in "m", "min", or "minor" become minor keys; everything else is treated as major.



42
43
44
45
46
47
48
49
50
51
52
53
54
# File 'lib/outro_rails/theory/key.rb', line 42

def self.parse(value)
  return value if value.is_a?(self)

  text = value.to_s.strip
  pattern =
    /\A([A-Ga-g](?:#{NoteName::ACCIDENTAL_PATTERN})?)\s*(m|min|minor)\z/i

  if (match = text.match(pattern))
    new(match[1], :minor)
  else
    new(text, :major)
  end
end

Instance Method Details

#circle_of_fifthsObject

Every key of this mode in circle-of-fifths order, starting from this key's position. Major yields 13 keys, not 12 - F# and Gb both appear at the enharmonic seam (see MAJOR_TONICS).



223
224
225
226
227
228
# File 'lib/outro_rails/theory/key.rb', line 223

def circle_of_fifths
  tonics = major? ? MAJOR_TONICS : MINOR_TONICS
  keys = tonics.map { |t| self.class.new(t, mode) }
  start = keys.index { |k| k.tonic.pitch_class == tonic.pitch_class } || 0
  keys.rotate(start)
end

#degree_of(note) ⇒ Object

Inverse of #note_for_degree: [degree, accidental_offset] locating a note relative to this key by letter. Bb in C major => [7, -1]; F# in C major => [4, 1].



157
158
159
160
161
162
163
164
165
# File 'lib/outro_rails/theory/key.rb', line 157

def degree_of(note)
  note = NoteName.parse(note)
  index = note_names.index { |name| name.letter == note.letter }

  [
    index + 1,
    note.accidental_offset - note_names[index].accidental_offset
  ]
end

#flats?Boolean

True if the key signature contains flats.

Returns:

  • (Boolean)


122
123
124
# File 'lib/outro_rails/theory/key.rb', line 122

def flats?
  signature.negative?
end

#interval_to(other) ⇒ Object

Semitones from this key up to another (0..11).



168
169
170
171
172
173
# File 'lib/outro_rails/theory/key.rb', line 168

def interval_to(other)
  other = self.class.parse(other)
  semitones = other.tonic.pitch_class - tonic.pitch_class

  semitones % Interval::SEMITONES_PER_OCTAVE
end

#major?Boolean

True if this is a major key.

Returns:

  • (Boolean)


92
93
94
# File 'lib/outro_rails/theory/key.rb', line 92

def major?
  mode == :major
end

#minor?Boolean

True if this is a minor key.

Returns:

  • (Boolean)


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

def minor?
  mode == :minor
end

#nashville(input) ⇒ Object

Resolves a Nashville number (or roman numeral) to the attributes needed to find or build a chord in this key:

Key.new('G').nashville('4')
=> { root_name: "C", quality: :major, ... }
Key.new('G').nashville('2m7')
=> { root_name: "A", quality: :minor, extension: "7" }
Key.new('C').nashville('bVII')
=> { root_name: "Bb", quality: :major, ... }

The result maps directly onto OutroRails::Chord columns, so the lookup is Chord.find_by(root_name:, quality:, extension:) - and "the musician flipped the minor 2 to a major 2" is just a different quality in the same lookup.



204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
# File 'lib/outro_rails/theory/key.rb', line 204

def nashville(input)
  number = NashvilleNumber.parse(input)
  root = note_for_degree(number.degree, number.accidental_offset)
  quality = number.quality || default_quality_for(number)

  {
    root_name: root.to_s,
    root_pitch_class: root.pitch_class,
    quality: quality,
    extension: number.extension,
    symbol: ChordVocabulary.symbol(
      root_name: root, quality: quality, extension: number.extension
    )
  }
end

#note_for_degree(number, accidental_offset = 0) ⇒ Object

1-based degree with an optional accidental adjustment, spelled the way the key would spell it (b7 in C major => Bb).



142
143
144
145
146
147
148
149
150
151
152
# File 'lib/outro_rails/theory/key.rb', line 142

def note_for_degree(number, accidental_offset = 0)
  note = scale.degree(number)
  return note if accidental_offset.zero?

  NoteName.new(
    note.letter,
    NoteName::ACCIDENTALS_BY_OFFSET.fetch(
      note.accidental_offset + accidental_offset
    )
  )
end

#note_namesObject

The seven note names of the key's diatonic scale.



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

def note_names
  scale.note_names
end

#parallelObject

The parallel major or minor key, sharing the same tonic.



136
137
138
# File 'lib/outro_rails/theory/key.rb', line 136

def parallel
  self.class.new(tonic, major? ? :minor : :major)
end

#relativeObject

The relative major or minor key.



127
128
129
130
131
132
133
# File 'lib/outro_rails/theory/key.rb', line 127

def relative
  if major?
    self.class.new(scale.degree(6), :minor)
  else
    self.class.new(scale.degree(3), :major)
  end
end

#scaleObject

The scale corresponding to this key and mode.



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

def scale
  Scale.new(tonic, MODES.fetch(mode))
end

#sharps?Boolean

True if the key signature contains sharps.

Returns:

  • (Boolean)


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

def sharps?
  signature.positive?
end

#signatureObject

Positive = sharps, negative = flats, 0 = C major / A minor.



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

def signature
  scale.net_accidentals
end

#to_sObject

String representation using conventional notation ("C", "F#", "Am").



231
232
233
# File 'lib/outro_rails/theory/key.rb', line 231

def to_s
  major? ? tonic.to_s : "#{tonic}m"
end

#transpose_note(note, to:) ⇒ Object

Transposes any note, respelled for the destination key: the note's position relative to this key is preserved. Moving F# (the 7th of G major) to the key of Bb yields A, and Bb's b7 yields Ab, not G#.



183
184
185
186
187
188
# File 'lib/outro_rails/theory/key.rb', line 183

def transpose_note(note, to:)
  destination = self.class.parse(to)
  degree, accidental_offset = degree_of(note)

  destination.note_for_degree(degree, accidental_offset)
end

#transpose_to(new_tonic) ⇒ Object

Returns a new key with the same mode and a different tonic.



176
177
178
# File 'lib/outro_rails/theory/key.rb', line 176

def transpose_to(new_tonic)
  self.class.new(new_tonic, mode)
end