Class: HeadMusic::Utilities::Accidentals
- Inherits:
-
Object
- Object
- HeadMusic::Utilities::Accidentals
- 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:
- 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♭”.
- 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
-
.altering_alterations ⇒ Object
private
-
.ascii_for ⇒ Object
One entry per alteration, keyed on its primary #ascii, so “##” is never emitted.
-
.ascii_matcher ⇒ Object
private
Applied only inside a CHORD_TOKEN.
-
.double_sharp_branch ⇒ Object
private
-
.extension_branch ⇒ Object
private
-
.flat_branch ⇒ Object
private
-
.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.
-
.sharp_branch ⇒ Object
private
-
.spellings_matching(pattern) ⇒ Object
private
-
.to_ascii(text) ⇒ Object
“B♭” => “Bb”, “C𝄪” => “Cx”.
-
.to_unicode(text) ⇒ Object
“Bb” => “B♭”, “C##” => “C𝄪”, “Cx” => “C𝄪”, “Bbmaj7#11” => “B♭maj7♯11”.
-
.unicode_for ⇒ Object
-
.unicode_matcher ⇒ Object
private
Class Method Details
.altering_alterations ⇒ Object (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_for ⇒ Object
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_matcher ⇒ Object (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_branch ⇒ Object (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_branch ⇒ Object (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_branch ⇒ Object (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_branch ⇒ Object (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_for ⇒ Object
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_matcher ⇒ Object (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 |