Class: OutroRails::Theory::NoteName
- Inherits:
-
Object
- Object
- OutroRails::Theory::NoteName
- 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
-
#accidental ⇒ Object
readonly
Returns the value of attribute accidental.
-
#letter ⇒ Object
readonly
Returns the value of attribute letter.
Class Method Summary collapse
-
.for_pitch_class(pitch_class, prefer: :sharps) ⇒ Object
Names a bare pitch class (0..11), preferring sharp or flat spelling.
-
.from_slug(slug) ⇒ Object
Inverse of #path_slug: 'f-sharp' => F#, 'b-flat' => Bb, 'c' => C.
-
.parse(value) ⇒ Object
Accepts a NoteName or a string like 'C', 'f#', 'Ebb'.
-
.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).
Instance Method Summary collapse
- #<=>(other) ⇒ Object
- #==(other) ⇒ Object (also: #eql?)
- #accidental_offset ⇒ Object
-
#at_degree(semitones, letter_steps) ⇒ Object
Spells the note
letter_stepsletters up whose pitch class ispitch_class + semitones. -
#at_interval(semitones) ⇒ Object
The correctly spelled note
semitonesabove 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. - #hash ⇒ Object
-
#initialize(letter, accidental = "") ⇒ NoteName
constructor
A new instance of NoteName.
- #inspect ⇒ Object
- #letter_index ⇒ Object
-
#letter_natural_midi_offset ⇒ Object
Semitones above the octave's C, accidental included and not wrapped: Cb => -1, B# => 12.
-
#path_slug ⇒ Object
Used for URL segments, e.g.
- #pitch_class ⇒ Object
- #to_s ⇒ Object (also: #name)
-
#transpose(semitones, prefer: :sharps) ⇒ Object
Simple chromatic transposition; picks sharp or flat spelling for the result.
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
#accidental ⇒ Object (readonly)
Returns the value of attribute accidental.
52 53 54 |
# File 'lib/outro_rails/theory/note_name.rb', line 52 def accidental @accidental end |
#letter ⇒ Object (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'.
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_offset ⇒ Object
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 |
#hash ⇒ Object
196 197 198 |
# File 'lib/outro_rails/theory/note_name.rb', line 196 def hash [ letter, accidental ].hash end |
#inspect ⇒ Object
180 181 182 |
# File 'lib/outro_rails/theory/note_name.rb', line 180 def inspect "#<#{self.class.name} #{self}>" end |
#letter_index ⇒ Object
118 119 120 |
# File 'lib/outro_rails/theory/note_name.rb', line 118 def letter_index LETTERS.index(letter) end |
#letter_natural_midi_offset ⇒ Object
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_slug ⇒ Object
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_class ⇒ Object
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_s ⇒ Object 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 |