Class: OutroRails::Theory::NoteName

Inherits:
Object
  • Object
show all
Includes:
Comparable
Defined in:
lib/outro_rails/theory/note_name.rb

Overview

A spelled note name without an octave: a letter plus an accidental ('C', 'F#', 'Bbb'). Two NoteNames can share a pitch class but still be different notes (C# vs Db), which matters for keys, transposition and correct chord spelling.

Constant Summary collapse

LETTERS =
%w[C D E F G A B].freeze
LETTERS_PER_OCTAVE =
LETTERS.size
NATURAL_PITCH_CLASSES =
{
  "C" => 0,
  "D" => 2,
  "E" => 4,
  "F" => 5,
  "G" => 7,
  "A" => 9,
  "B" => 11
}.freeze
ACCIDENTAL_OFFSETS =

Offset in semitones contributed by each accidental spelling.

{
  "bb" => -2,
  "b"  => -1,
  ""   =>  0,
  "#"  =>  1,
  "##" =>  2
}.freeze
ACCIDENTALS_BY_OFFSET =
ACCIDENTAL_OFFSETS.invert.freeze
CANONICAL_ROOTS =

The root names the app generates chords for - every pitch class, with both spellings where two are in common use.

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

Preferred spelling of each pitch class in sharp and flat contexts.

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

Shared regex fragments - Pitch and Transposer build their note patterns from these so the spelling grammar lives in one place.

/##|bb|[#b]/
NAME_PATTERN =
/[A-G](?:#{ACCIDENTAL_PATTERN})?/
PARSE_PATTERN =
/\A([A-Ga-g])(#{ACCIDENTAL_PATTERN})?\z/

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(letter, accidental = "") ⇒ NoteName

Returns a new instance of NoteName.



88
89
90
91
92
93
94
95
96
97
98
99
100
# File 'lib/outro_rails/theory/note_name.rb', line 88

def initialize(letter, accidental = "")
  @letter = letter
  @accidental = accidental

  NATURAL_PITCH_CLASSES.fetch(letter) do
    raise ArgumentError, "Unknown letter #{letter.inspect}"
  end
  ACCIDENTAL_OFFSETS.fetch(accidental) do
    raise ArgumentError, "Unknown accidental #{accidental.inspect}"
  end

  freeze
end

Instance Attribute Details

#accidentalObject (readonly)

Returns the value of attribute accidental.



52
53
54
# File 'lib/outro_rails/theory/note_name.rb', line 52

def accidental
  @accidental
end

#letterObject (readonly)

Returns the value of attribute letter.



52
53
54
# File 'lib/outro_rails/theory/note_name.rb', line 52

def letter
  @letter
end

Class Method Details

.for_pitch_class(pitch_class, prefer: :sharps) ⇒ Object

Names a bare pitch class (0..11), preferring sharp or flat spelling.



83
84
85
86
# File 'lib/outro_rails/theory/note_name.rb', line 83

def self.for_pitch_class(pitch_class, prefer: :sharps)
  table = prefer == :flats ? FLAT_NAMES : SHARP_NAMES
  parse(table[pitch_class % Interval::SEMITONES_PER_OCTAVE])
end

.from_slug(slug) ⇒ Object

Inverse of #path_slug: 'f-sharp' => F#, 'b-flat' => Bb, 'c' => C. Legacy underscore slugs ('f_sharp') are still accepted.



66
67
68
69
70
71
72
73
# File 'lib/outro_rails/theory/note_name.rb', line 66

def self.from_slug(slug)
  parse(slug.to_s
            .strip
            .downcase
            .tr("_", "-")
            .gsub("-sharp", "#")
            .gsub("-flat", "b"))
end

.parse(value) ⇒ Object

Accepts a NoteName or a string like 'C', 'f#', 'Ebb'.

Raises:

  • (ArgumentError)


55
56
57
58
59
60
61
62
# File 'lib/outro_rails/theory/note_name.rb', line 55

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

  match = value.to_s.strip.match(PARSE_PATTERN)
  raise ArgumentError, "Invalid note name: #{value.inspect}" unless match

  new(match[1].upcase, match[2].to_s)
end

.preferred_accidentals(names) ⇒ Object

:sharps when any of the spelled names uses a sharp, else :flats - the spelling that matches a set of notes, for naming pitches the set itself doesn't spell (keyboard ranges, fretboard labels).



78
79
80
# File 'lib/outro_rails/theory/note_name.rb', line 78

def self.preferred_accidentals(names)
  names.any? { |name| name.to_s.include?("#") } ? :sharps : :flats
end

Instance Method Details

#<=>(other) ⇒ Object



200
201
202
203
204
205
206
207
208
# File 'lib/outro_rails/theory/note_name.rb', line 200

def <=>(other)
  [
    letter_index,
    accidental_offset
  ] <=> [
    other.letter_index,
    other.accidental_offset
  ]
end

#==(other) ⇒ Object Also known as: eql?



189
190
191
192
193
# File 'lib/outro_rails/theory/note_name.rb', line 189

def ==(other)
  other.is_a?(self.class) &&
    letter == other.letter &&
    accidental == other.accidental
end

#accidental_offsetObject



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

def accidental_offset
  ACCIDENTAL_OFFSETS.fetch(accidental)
end

#at_degree(semitones, letter_steps) ⇒ Object

Spells the note letter_steps letters up whose pitch class is pitch_class + semitones. Unlike #at_interval this keeps double accidentals, which scale spelling needs (G# major contains F##).



155
156
157
158
159
160
161
162
163
164
165
166
167
# File 'lib/outro_rails/theory/note_name.rb', line 155

def at_degree(semitones, letter_steps)
  index = (letter_index + letter_steps) % LETTERS.size
  target = (pitch_class + semitones) % Interval::SEMITONES_PER_OCTAVE
  diff = accidental_diff(target, index)

  accidental = ACCIDENTALS_BY_OFFSET.fetch(diff) do
    raise ArgumentError,
          "cannot spell #{semitones} semitones above #{self} on" \
          "letter #{LETTERS[index]}"
  end

  self.class.new(LETTERS[index], accidental)
end

#at_interval(semitones) ⇒ Object

The correctly spelled note semitones above this one: the letter moves the number of scale-letter steps above the note that Interval defines for that many semitones, and the accidental is whatever makes the pitch come out right, e.g. E.at_interval(4) => G#, Db.at_interval(3) => Fb (not E). Double accidentals are respelled enharmonically (G## => A) to match how chord tones are conventionally written.

Raises ArgumentError if Interval defines no letter step for semitones (an interval the app never spells, e.g. 1 or 12), or if the result would still need more than one accidental.



133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
# File 'lib/outro_rails/theory/note_name.rb', line 133

def at_interval(semitones)
  step = Interval.letter_steps!(semitones)

  index = (letter_index + step) % LETTERS.size
  target = (pitch_class + semitones) % Interval::SEMITONES_PER_OCTAVE
  diff = accidental_diff(target, index)

  if diff.abs == 2
    index = (index + (diff / 2)) % LETTERS.size
    diff = accidental_diff(target, index)
  end
  if diff.abs > 1
    raise ArgumentError,
          "cannot spell interval #{semitones} above #{self}"
  end

  self.class.new(LETTERS[index], ACCIDENTALS_BY_OFFSET.fetch(diff))
end

#hashObject



196
197
198
# File 'lib/outro_rails/theory/note_name.rb', line 196

def hash
  [ letter, accidental ].hash
end

#inspectObject



180
181
182
# File 'lib/outro_rails/theory/note_name.rb', line 180

def inspect
  "#<#{self.class.name} #{self}>"
end

#letter_indexObject



118
119
120
# File 'lib/outro_rails/theory/note_name.rb', line 118

def letter_index
  LETTERS.index(letter)
end

#letter_natural_midi_offsetObject

Semitones above the octave's C, accidental included and not wrapped: Cb => -1, B# => 12. Pitch relies on this so that the written octave follows the letter.



114
115
116
# File 'lib/outro_rails/theory/note_name.rb', line 114

def letter_natural_midi_offset
  NATURAL_PITCH_CLASSES.fetch(letter) + accidental_offset
end

#path_slugObject

Used for URL segments, e.g. F# => 'f-sharp', Bb => 'b-flat'.



185
186
187
# File 'lib/outro_rails/theory/note_name.rb', line 185

def path_slug
  to_s.gsub("b", "-flat").gsub("#", "-sharp").downcase
end

#pitch_classObject



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

def pitch_class
  (NATURAL_PITCH_CLASSES.fetch(letter) +
    ACCIDENTAL_OFFSETS.fetch(accidental)) % Interval::SEMITONES_PER_OCTAVE
end

#to_sObject Also known as: name



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

def to_s
  "#{letter}#{accidental}"
end

#transpose(semitones, prefer: :sharps) ⇒ Object

Simple chromatic transposition; picks sharp or flat spelling for the result. Spelling-aware transposition should go through Key instead.



171
172
173
# File 'lib/outro_rails/theory/note_name.rb', line 171

def transpose(semitones, prefer: :sharps)
  self.class.for_pitch_class(pitch_class + semitones, prefer: prefer)
end