Class: HeadMusic::Utilities::Accidentals

Inherits:
Object
  • Object
show all
Defined in:
lib/head_music/utilities/accidentals.rb

Overview

Util for converting accidentals in a string between ASCII spellings and canonical unicode glyphs.

Safe to run over prose. Conversion happens only inside a candidate chord token — an uppercase letter name that does not continue a longer word or number — so “Above”, “Bebop”, “1.0b2”, and “0x1B2F” are untouched while “Bb”, “Ab7”, “Bbm”, and “Bbmaj7#11” convert.

Deliberately narrower than the gem’s parsers in two ways, both of which follow from being safe over prose:

  1. Case-sensitive. Spelling and Pitch accept a lowercase letter name; this does not, because a case-insensitive rule reads the “B” of “B7” as a flat sign and every lowercase word in a sentence becomes a candidate. So to_unicode(“eb”) is “eb” while Spelling.get(“eb”) is “E♭”.
  2. A bare accidental token is read as an accidental, not as a pitch name. to_unicode (“bb”) is “𝄫” — the double-flat sign — while Spelling.get(“bb”) is “B♭”. Callers holding a pitch name that may be lowercase should parse it with Spelling first.

The maps are derived from Alteration lazily rather than at load time. Eager derivation is not an option anywhere in the load sequence: Alteration.all instantiates Notation::MusicalSymbol, which is not required until much later in head_music.rb.

Constant Summary collapse

CHORD_QUALITIES =

Chord qualities that may follow a flat without a word boundary, so that “Bbm” and “Ebmaj7” convert while “Above” does not. “m” subsumes “maj”, “min”, and “m7”; uppercase forms like “BbM7” already clear the (?![a-z]) guard unaided.

Measured against /usr/share/dict/words: this list costs exactly two false positives (“Abmho”, “Ebbman”) across 235,976 capitalized words. Each entry clears a real word by one letter — “sus” vs “Absurd”, “dim” vs “Abdomen” and “Abdicate” — so any addition here needs re-measuring rather than eyeballing.

%w[m dim aug sus].freeze
EXTENSION_DEGREES =

The scale degrees a chord extension can alter. Requiring the altered degree to be one of these is what separates “C7b9” from “Figure C2b3”: both are an accidental flanked by digits inside a token that opens with a letter name, and only the degree tells them apart.

%w[13 11 9 6 5 4].freeze
CHORD_TOKEN =

A candidate chord symbol. One quantifier over a character class, so there is no nesting and no ReDoS surface. This is what scopes the altered-extension rule below: “1.0b2” and “0x1B2F” never open a token, so they can never reach it.

/(?<![A-Za-z0-9])[A-G][A-Za-z0-9#]*/

Class Method Summary collapse

Class Method Details

.altering_alterationsObject (private)



131
132
133
# File 'lib/head_music/utilities/accidentals.rb', line 131

def self.altering_alterations
  HeadMusic::Rudiment::Alteration.all.reject(&:natural?)
end

.ascii_forObject

One entry per alteration, keyed on its primary #ascii, so “##” is never emitted.



79
80
81
# File 'lib/head_music/utilities/accidentals.rb', line 79

def self.ascii_for
  @ascii_for ||= altering_alterations.to_h { |alteration| [alteration.unicode, alteration.ascii] }
end

.ascii_matcherObject (private)

Applied only inside a CHORD_TOKEN. No /i flag — case-insensitivity would read the “B” of “B7” as a flat sign.

Two anchor contexts. The first is the pitch name’s own accidental, anchored to the letter name, with a per-family guard: “#” cannot occur in an English word at all, “b” can but is rescued by CHORD_QUALITIES, and “x” takes the bare guard because an allowlist there would buy “Cxm7” and cost “Axminster” and “Exmoor”. The second is an altered extension (“7#11”, “7b9”), which has no letter to anchor to and is instead flanked by digits. Being inside a CHORD_TOKEN is not sufficient on its own — “Figure C2b3” would qualify — so the altered degree must also be a real chord extension.



94
95
96
97
# File 'lib/head_music/utilities/accidentals.rb', line 94

def self.ascii_matcher
  @ascii_matcher ||=
    /(?<=[A-G])(?:#{sharp_branch}|#{flat_branch}|#{double_sharp_branch})|#{extension_branch}/
end

.double_sharp_branchObject (private)



112
113
114
# File 'lib/head_music/utilities/accidentals.rb', line 112

def self.double_sharp_branch
  "(?:#{longest_first(spellings_matching(/x/))})(?![a-z])"
end

.extension_branchObject (private)



116
117
118
# File 'lib/head_music/utilities/accidentals.rb', line 116

def self.extension_branch
  "(?<=\\d)(?:#{longest_first(spellings_matching(/[#b]/))})(?=(?:#{longest_first(EXTENSION_DEGREES)})(?![0-9]))"
end

.flat_branchObject (private)



108
109
110
# File 'lib/head_music/utilities/accidentals.rb', line 108

def self.flat_branch
  "(?:#{longest_first(spellings_matching(/b/))})(?:(?![a-z])|(?=#{longest_first(CHORD_QUALITIES)}))"
end

.longest_first(spellings) ⇒ Object (private)

Regexp.union alternates in array order rather than by longest match, so every two-character spelling must precede its one-character prefix or a double half-converts.



127
128
129
# File 'lib/head_music/utilities/accidentals.rb', line 127

def self.longest_first(spellings)
  Regexp.union(spellings.sort_by { |spelling| -spelling.length }).source
end

.sharp_branchObject (private)



104
105
106
# File 'lib/head_music/utilities/accidentals.rb', line 104

def self.sharp_branch
  longest_first(spellings_matching(/#/))
end

.spellings_matching(pattern) ⇒ Object (private)



120
121
122
# File 'lib/head_music/utilities/accidentals.rb', line 120

def self.spellings_matching(pattern)
  unicode_for.keys.grep(pattern)
end

.to_ascii(text) ⇒ Object

“B♭” => “Bb”, “C𝄪” => “Cx”. Emits the spellings the gem’s own parsers accept, so “x” rather than “##” for a double sharp.

Deliberately not letter-anchored: unicode glyphs are unambiguous, and consumers feed this scale degrees and roman numerals (“♭7”, “♭VI”) that have no letter name. A natural sign is left as “♮” — mapping it to “” would turn the valid spelling “C♮” into the different spelling “C”. Idempotent.



67
68
69
# File 'lib/head_music/utilities/accidentals.rb', line 67

def self.to_ascii(text)
  text.to_s.gsub(unicode_matcher) { |match| ascii_for.fetch(match) }
end

.to_unicode(text) ⇒ Object

“Bb” => “B♭”, “C##” => “C𝄪”, “Cx” => “C𝄪”, “Bbmaj7#11” => “B♭maj7♯11”. Idempotent, and all-or-nothing per accidental: a double never half-converts.



51
52
53
54
55
56
57
58
# File 'lib/head_music/utilities/accidentals.rb', line 51

def self.to_unicode(text)
  string = text.to_s
  return unicode_for.fetch(string) if unicode_for.key?(string)

  string.gsub(CHORD_TOKEN) do |token|
    token.gsub(ascii_matcher) { |match| unicode_for.fetch(match) }
  end
end

.unicode_forObject



71
72
73
74
75
76
# File 'lib/head_music/utilities/accidentals.rb', line 71

def self.unicode_for
  @unicode_for ||=
    altering_alterations.flat_map(&:musical_symbols)
      .reject { |symbol| symbol.ascii.to_s.empty? }
      .to_h { |symbol| [symbol.ascii, symbol.unicode] }
end

.unicode_matcherObject (private)



99
100
101
# File 'lib/head_music/utilities/accidentals.rb', line 99

def self.unicode_matcher
  @unicode_matcher ||= Regexp.union(ascii_for.keys)
end