head_music 17.4.0 → 18.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +3 -3
  3. data/.github/workflows/release.yml +1 -1
  4. data/.github/workflows/security.yml +2 -1
  5. data/.rubocop.yml +6 -0
  6. data/CHANGELOG.md +34 -0
  7. data/CLAUDE.md +20 -0
  8. data/Gemfile.lock +68 -68
  9. data/head_music.gemspec +1 -1
  10. data/lib/head_music/analysis/pitch_collection.rb +2 -2
  11. data/lib/head_music/content/composition/hash_deserializer.rb +3 -0
  12. data/lib/head_music/content/composition/schema_values.rb +40 -0
  13. data/lib/head_music/content/placement.rb +30 -1
  14. data/lib/head_music/content/staff.rb +3 -5
  15. data/lib/head_music/content/syllable.rb +38 -0
  16. data/lib/head_music/content/voice.rb +1 -1
  17. data/lib/head_music/instruments/instrument_name.rb +1 -1
  18. data/lib/head_music/notation/abc/key_mapper.rb +24 -4
  19. data/lib/head_music/notation/music_xml/beam_grouper.rb +2 -2
  20. data/lib/head_music/notation/music_xml/duration_writer.rb +1 -1
  21. data/lib/head_music/notation/music_xml/writer.rb +46 -0
  22. data/lib/head_music/rudiment/alteration.rb +20 -5
  23. data/lib/head_music/rudiment/alterations.yml +6 -0
  24. data/lib/head_music/rudiment/key_signature.rb +1 -1
  25. data/lib/head_music/rudiment/note.rb +6 -2
  26. data/lib/head_music/rudiment/scale.rb +3 -5
  27. data/lib/head_music/style/guidelines/notes_same_length.rb +1 -1
  28. data/lib/head_music/time/musical_position.rb +1 -13
  29. data/lib/head_music/time/radix_carry.rb +22 -0
  30. data/lib/head_music/time/smpte_timecode.rb +1 -13
  31. data/lib/head_music/time.rb +1 -0
  32. data/lib/head_music/utilities/accidentals.rb +137 -0
  33. data/lib/head_music/utilities/hash_key.rb +44 -29
  34. data/lib/head_music/version.rb +1 -1
  35. data/lib/head_music.rb +10 -8
  36. data/user-stories/done/accidental-string-normalization.md +552 -0
  37. data/user-stories/done/lyrics.md +162 -0
  38. data/user-stories/index.html +8 -0
  39. metadata +17 -6
@@ -4,7 +4,10 @@
4
4
  # KeySignature.get, which splits tonic and scale type on whitespace,
5
5
  # so the mode word is normalized into a "tonic mode" string first.
6
6
  class HeadMusic::Notation::ABC::KeyMapper
7
- KEY_PATTERN = /\A([A-G])([#♯b♭]?)\s*([A-Za-z]*)/
7
+ # The alteration group wraps the pattern in (?:...) before making it optional.
8
+ # Writing (...)? instead leaves match[2] nil for every unaltered key ("C", "Ador"),
9
+ # which raises downstream where the capture is interpolated into a string.
10
+ KEY_PATTERN = /\A([A-G])((?:#{HeadMusic::Rudiment::Alteration::PATTERN.source})?)\s*([A-Za-z]*)/
8
11
 
9
12
  # Mode words are matched case-insensitively on their first three letters,
10
13
  # so abbreviations ("dor") and full names ("Dorian") both resolve.
@@ -48,9 +51,15 @@ class HeadMusic::Notation::ABC::KeyMapper
48
51
  raise_render_error("Cannot render double-altered tonic #{spelling} in an ABC K: field")
49
52
  end
50
53
 
51
- # ABC convention uses ASCII "#"/"b" rather than the unicode signs
52
- # that Spelling#to_s produces.
53
- "#{spelling.letter_name}#{alteration&.ascii}"
54
+ # ABC convention uses ASCII "#"/"b" rather than the unicode signs that
55
+ # Spelling#to_s produces. The double-altered guard above runs first, since this
56
+ # would otherwise happily emit "Cx".
57
+ #
58
+ # A natural sign is dropped rather than converted. Accidentals.to_ascii preserves
59
+ # "♮" on purpose — mapping it away would turn the spelling "C♮" into the different
60
+ # spelling "C" — but an ABC K: field has no way to express a natural tonic, and
61
+ # "C♮ major" names the same key signature as "C major".
62
+ HeadMusic::Utilities::Accidentals.to_ascii(spelling.to_s).delete("♮")
54
63
  end
55
64
  private_class_method :tonic_string
56
65
 
@@ -87,7 +96,18 @@ class HeadMusic::Notation::ABC::KeyMapper
87
96
  raise_parse_error("Unrecognized key")
88
97
  end
89
98
 
99
+ # An ABC K: field cannot express a double-altered tonic, and .abc_value raises
100
+ # rather than rendering one. Reject it on the way in too: KEY_PATTERN interpolates
101
+ # the whole Alteration::PATTERN so that "bb" and "##" bind correctly, which also
102
+ # makes them matchable here. Without this guard "K:Cx" would parse to C𝄪 major and
103
+ # report "no sharps or flats" — a plausible-looking wrong answer where the narrower
104
+ # pattern used to give a clear parse error.
90
105
  def tonic
106
+ alteration = HeadMusic::Rudiment::Alteration.get(match[2])
107
+ if alteration && (alteration.double_sharp? || alteration.double_flat?)
108
+ raise_parse_error("Cannot express double-altered tonic #{match[1]}#{match[2]} in an ABC K: field")
109
+ end
110
+
91
111
  match[1] + match[2]
92
112
  end
93
113
 
@@ -5,7 +5,7 @@ module HeadMusic::Notation::MusicXML
5
5
  # the Event list and turns the returned Beams into XML.
6
6
  class BeamGrouper
7
7
  # A single <beam number="N">type</beam> annotation.
8
- Beam = Struct.new(:number, :type, keyword_init: true)
8
+ Beam = Struct.new(:number, :type)
9
9
 
10
10
  # One notehead's beaming inputs.
11
11
  # - levels: beams this notehead carries alone (eighth=1, sixteenth=2, ...);
@@ -13,7 +13,7 @@ module HeadMusic::Notation::MusicXML
13
13
  # - onset: integer offset from the start of the bar, in MusicXML divisions.
14
14
  # - beam_break_before: tri-state override vs. the previous event
15
15
  # (nil = meter default, true = force break, false = force join).
16
- Event = Struct.new(:levels, :onset, :beam_break_before, keyword_init: true)
16
+ Event = Struct.new(:levels, :onset, :beam_break_before)
17
17
 
18
18
  def self.annotate(events, group_unit_divisions)
19
19
  new(events, group_unit_divisions).annotate
@@ -4,7 +4,7 @@ module HeadMusic::Notation::MusicXML
4
4
  # <type>, dot count, and tie flags MusicXML needs for each link of a
5
5
  # tied chain. A value with no tied chain yields a single component.
6
6
  class DurationWriter
7
- Component = Struct.new(:duration, :type, :dots, :tie_start, :tie_stop, keyword_init: true)
7
+ Component = Struct.new(:duration, :type, :dots, :tie_start, :tie_stop)
8
8
 
9
9
  # MusicXML's <type> element uses these fixed names rather than the
10
10
  # gem's American duration names.
@@ -229,10 +229,56 @@ module HeadMusic::Notation::MusicXML
229
229
  *Array.new(component.dots) { "#{INDENT * 4}<dot/>" },
230
230
  *beam_lines(beams),
231
231
  *notation_lines(placement, component),
232
+ *lyric_lines(placement, component, chord: chord),
232
233
  "#{INDENT * 3}</note>"
233
234
  ]
234
235
  end
235
236
 
237
+ # <lyric> is the last child of <note>. Sung text rides only the lead note
238
+ # of a chord and only the attack of a tied chain (a tie_stop component is a
239
+ # continuation, sung once at the start). Held notes of a melisma carry no
240
+ # syllable and so emit nothing, matching MusicXML's continuation-by-absence.
241
+ def lyric_lines(placement, component, chord:)
242
+ return [] if chord || placement.rest? || component.tie_stop
243
+
244
+ placement.syllables.keys.sort.flat_map do |verse|
245
+ syllable = placement.syllables[verse]
246
+ [
247
+ %(#{INDENT * 4}<lyric number="#{verse}">),
248
+ "#{INDENT * 5}<syllabic>#{syllabic(placement, syllable)}</syllabic>",
249
+ "#{INDENT * 5}<text>#{escape(syllable.text)}</text>",
250
+ "#{INDENT * 4}</lyric>"
251
+ ]
252
+ end
253
+ end
254
+
255
+ # Derives MusicXML's single/begin/middle/end from our stored hyphen_after
256
+ # booleans: this syllable's, and the previous sung note's for the same verse.
257
+ def syllabic(placement, syllable)
258
+ from_previous = previous_syllable(placement, syllable.verse)&.hyphen_after?
259
+ if from_previous
260
+ syllable.hyphen_after? ? "middle" : "end"
261
+ else
262
+ syllable.hyphen_after? ? "begin" : "single"
263
+ end
264
+ end
265
+
266
+ # The syllable on the nearest earlier placement in the same voice carrying
267
+ # text for this verse. Placements are position-sorted, and melisma gaps are
268
+ # skipped because only sung placements are collected. Array#index compares
269
+ # with ==, which on Placement is position-only, but a voice holds at most
270
+ # one placement per position (Voice#insert_into_placements), so that still
271
+ # locates this exact placement.
272
+ def previous_syllable(placement, verse)
273
+ @sung_placements ||= {}
274
+ sung = @sung_placements[[placement.voice, verse]] ||=
275
+ placement.voice.placements.select { |candidate| candidate.syllable(verse) }
276
+ index = sung.index(placement)
277
+ return nil if index.nil? || index.zero?
278
+
279
+ sung[index - 1].syllable(verse)
280
+ end
281
+
236
282
  def beam_lines(beams)
237
283
  beams.map { |beam| %(#{INDENT * 4}<beam number="#{beam.number}">#{beam.type}</beam>) }
238
284
  end
@@ -15,7 +15,15 @@ class HeadMusic::Rudiment::Alteration < HeadMusic::Rudiment::Base
15
15
  YAML.load_file(File.expand_path("alterations.yml", __dir__), symbolize_names: true)[:alterations].freeze
16
16
 
17
17
  ALTERATION_IDENTIFIERS = ALTERATION_RECORDS.keys.freeze
18
- SYMBOLS = ALTERATION_RECORDS.map { |key, attributes| attributes[:symbols].map { |symbol| [symbol[:unicode], symbol[:ascii]] } }.flatten.reject { |s| s.nil? || s.empty? }.freeze
18
+ # Sorted longest-first because Regexp.union alternates in array order rather than by
19
+ # longest match. Without it, "bb" would match the single-flat branch and leave a
20
+ # stray "b" behind, half-converting a double. Ordering among equal-length symbols is
21
+ # unspecified and does not matter: equal-length strings cannot prefix one another.
22
+ SYMBOLS = ALTERATION_RECORDS
23
+ .map { |key, attributes| attributes[:symbols].map { |symbol| [symbol[:unicode], symbol[:ascii]] } }
24
+ .flatten.reject { |s| s.nil? || s.empty? }.uniq
25
+ .sort_by { |symbol| -symbol.length }
26
+ .freeze
19
27
  PATTERN = Regexp.union(SYMBOLS)
20
28
  MATCHER = PATTERN
21
29
 
@@ -24,7 +32,7 @@ class HeadMusic::Rudiment::Alteration < HeadMusic::Rudiment::Base
24
32
  end
25
33
 
26
34
  def self.symbols
27
- @symbols ||= all.map { |alteration| [alteration.ascii, alteration.unicode] }.flatten.reject { |s| s.nil? || s.empty? }
35
+ SYMBOLS
28
36
  end
29
37
 
30
38
  def self.symbol?(candidate)
@@ -53,9 +61,16 @@ class HeadMusic::Rudiment::Alteration < HeadMusic::Rudiment::Base
53
61
  super || identifier.to_s.tr("_", " ")
54
62
  end
55
63
 
56
- def representations
57
- [identifier, identifier.to_s, name, ascii, unicode, html_entity]
58
- .reject { |representation| representation.to_s.strip == "" }
64
+ # Spans every symbol record, not just the primary one, so that an alternate ASCII
65
+ # spelling like "##" resolves through .get. Memoized per locale because .get runs on
66
+ # every parse and walks this list for each alteration, and #name is locale-dependent.
67
+ def representations(locale_code: I18n.locale)
68
+ @representations ||= {}
69
+ @representations[locale_code] ||=
70
+ ([identifier, identifier.to_s, name(locale_code: locale_code)] +
71
+ musical_symbols.flat_map { |symbol| [symbol.ascii, symbol.unicode, symbol.html_entity] })
72
+ .uniq
73
+ .reject { |representation| representation.to_s.strip == "" }
59
74
  end
60
75
 
61
76
  ALTERATION_RECORDS.keys.each do |key|
@@ -27,6 +27,12 @@ alterations:
27
27
  double_sharp:
28
28
  semitones: 2
29
29
  symbols:
30
+ # "x" stays first: #ascii reads the first record, and ABC's accidental table
31
+ # is keyed on "x". "##" is accepted as input for symmetry with "bb", but is
32
+ # never emitted.
30
33
  - ascii: "x"
31
34
  unicode: "𝄪"
32
35
  html_entity: "&#119082;"
36
+ - ascii: "##"
37
+ unicode: "𝄪"
38
+ html_entity: "&#119082;"
@@ -18,7 +18,7 @@ class HeadMusic::Rudiment::KeySignature < HeadMusic::Rudiment::Base
18
18
 
19
19
  if identifier.is_a?(String)
20
20
  tonic_spelling, scale_type_name = identifier.strip.split(/\s/)
21
- hash_key = HeadMusic::Utilities::HashKey.for(identifier.gsub(/#|♯/, " sharp").gsub(/(\w)[b♭]/, '\\1 flat'))
21
+ hash_key = HeadMusic::Utilities::HashKey.for(identifier)
22
22
  @key_signatures[hash_key] ||= new(tonic_spelling, scale_type_name)
23
23
  elsif identifier.is_a?(HeadMusic::Rudiment::DiatonicContext)
24
24
  identifier.key_signature
@@ -16,8 +16,12 @@ class HeadMusic::Rudiment::Note < HeadMusic::Rudiment::RhythmicElement
16
16
  delegate :pitch_class, :midi_note_number, :frequency, to: :pitch
17
17
 
18
18
  # Regex pattern for parsing note strings like "C#4 quarter" or "Eb3 dotted half"
19
- # Extract the core pattern from Spelling::MATCHER without anchors
20
- PITCH_PATTERN = /([A-G])(#{HeadMusic::Rudiment::Alteration::PATTERN.source}?)(-?\d+)?/i
19
+ # Extract the core pattern from Spelling::MATCHER without anchors.
20
+ #
21
+ # The alteration group wraps the whole pattern in (?:...) before making it
22
+ # optional. Interpolating "#{...}?" instead binds the "?" to the pattern's final
23
+ # alternative alone, which truncates "C##4" to a bare "C#" and loses the register.
24
+ PITCH_PATTERN = /([A-G])((?:#{HeadMusic::Rudiment::Alteration::PATTERN.source})?)(-?\d+)?/i
21
25
  MATCHER = /^\s*(#{PITCH_PATTERN.source})\s+(.+)$/i
22
26
 
23
27
  def self.get(pitch, rhythmic_value = nil)
@@ -3,15 +3,13 @@ module HeadMusic::Rudiment; end
3
3
 
4
4
  # A scale contains ordered pitches starting at a tonal center.
5
5
  class HeadMusic::Rudiment::Scale < HeadMusic::Rudiment::Base
6
- SCALE_REGEX = /^[A-G][#b]?\s+\w+$/
7
-
8
6
  def self.get(root_pitch, scale_type = nil)
9
7
  root_pitch = HeadMusic::Rudiment::Pitch.get(root_pitch)
10
8
  scale_type = HeadMusic::Rudiment::ScaleType.get(scale_type || HeadMusic::Rudiment::ScaleType::DEFAULT)
11
9
  @scales ||= {}
12
- hash_key = HeadMusic::Utilities::HashKey.for(
13
- [root_pitch, scale_type].join(" ").gsub(/#|♯/, "sharp").gsub(/(\w)[b♭]/, '\\1flat')
14
- )
10
+ # Pitch#to_s already emits unicode accidentals and HashKey already desymbolizes
11
+ # them, so no accidental normalization is needed here.
12
+ hash_key = HeadMusic::Utilities::HashKey.for([root_pitch, scale_type].join(" "))
15
13
  @scales[hash_key] ||= new(root_pitch, scale_type)
16
14
  end
17
15
 
@@ -54,7 +54,7 @@ class HeadMusic::Style::Guidelines::NotesSameLength < HeadMusic::Style::Annotati
54
54
  end
55
55
 
56
56
  def occurrences_by_rhythmic_value
57
- rhythmic_values.each_with_object(Hash.new(0)) { |value, hash| hash[value] += 1 }
57
+ rhythmic_values.tally
58
58
  end
59
59
 
60
60
  def rhythmic_values
@@ -39,6 +39,7 @@ module HeadMusic
39
39
  # pos1 < pos2 # => true
40
40
  class MusicalPosition
41
41
  include Comparable
42
+ include RadixCarry
42
43
 
43
44
  # @return [Integer] the bar (measure) number (1-indexed)
44
45
  attr_reader :bar
@@ -170,19 +171,6 @@ module HeadMusic
170
171
 
171
172
  # Allow other MusicalPosition instances to access this method for comparison
172
173
  alias_method :to_i, :to_total_subticks
173
-
174
- private
175
-
176
- # Divide the named component by its radix, store the remainder back,
177
- # and return the amount to carry into the next-higher component.
178
- #
179
- # @return [Integer] the carry (may be negative when borrowing)
180
- def carry(component, radix)
181
- ivar = :"@#{component}"
182
- delta, remainder = instance_variable_get(ivar).divmod(radix)
183
- instance_variable_set(ivar, remainder)
184
- delta
185
- end
186
174
  end
187
175
  end
188
176
  end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HeadMusic
4
+ module Time
5
+ # Shared helper for positional/timecode components that carry between
6
+ # radix-bounded fields (e.g. bars:beats:ticks, hours:minutes:seconds:frames).
7
+ module RadixCarry
8
+ private
9
+
10
+ # Divide the named component by its radix, store the remainder back,
11
+ # and return the amount to carry into the next-higher component.
12
+ #
13
+ # @return [Integer] the carry (may be negative when borrowing)
14
+ def carry(component, radix)
15
+ ivar = :"@#{component}"
16
+ delta, remainder = instance_variable_get(ivar).divmod(radix)
17
+ instance_variable_set(ivar, remainder)
18
+ delta
19
+ end
20
+ end
21
+ end
22
+ end
@@ -33,6 +33,7 @@ module HeadMusic
33
33
  # tc1 < tc2 # => true
34
34
  class SmpteTimecode
35
35
  include Comparable
36
+ include RadixCarry
36
37
 
37
38
  # @return [Integer] the hour component
38
39
  attr_reader :hour
@@ -148,19 +149,6 @@ module HeadMusic
148
149
 
149
150
  # Allow other SmpteTimecode instances to access this method for comparison
150
151
  alias_method :to_i, :to_total_frames
151
-
152
- private
153
-
154
- # Divide the named component by its radix, store the remainder back,
155
- # and return the amount to carry into the next-higher component.
156
- #
157
- # @return [Integer] the carry (may be negative when borrowing)
158
- def carry(component, radix)
159
- ivar = :"@#{component}"
160
- delta, remainder = instance_variable_get(ivar).divmod(radix)
161
- instance_variable_set(ivar, remainder)
162
- delta
163
- end
164
152
  end
165
153
  end
166
154
  end
@@ -22,6 +22,7 @@ module HeadMusic
22
22
  end
23
23
  end
24
24
 
25
+ require_relative "time/radix_carry"
25
26
  require_relative "time/clock_position"
26
27
  require_relative "time/musical_position"
27
28
  require_relative "time/smpte_timecode"
@@ -0,0 +1,137 @@
1
+ # A namespace for utilities classes and modules
2
+ module HeadMusic::Utilities; end
3
+
4
+ # Util for converting accidentals in a string between ASCII spellings and canonical
5
+ # unicode glyphs.
6
+ #
7
+ # Safe to run over prose. Conversion happens only inside a candidate chord token — an
8
+ # uppercase letter name that does not continue a longer word or number — so "Above",
9
+ # "Bebop", "1.0b2", and "0x1B2F" are untouched while "Bb", "Ab7", "Bbm", and
10
+ # "Bbmaj7#11" convert.
11
+ #
12
+ # Deliberately narrower than the gem's parsers in two ways, both of which follow from
13
+ # being safe over prose:
14
+ #
15
+ # 1. Case-sensitive. Spelling and Pitch accept a lowercase letter name; this does not,
16
+ # because a case-insensitive rule reads the "B" of "B7" as a flat sign and every
17
+ # lowercase word in a sentence becomes a candidate. So to_unicode("eb") is "eb"
18
+ # while Spelling.get("eb") is "E♭".
19
+ # 2. A bare accidental token is read as an accidental, not as a pitch name. to_unicode
20
+ # ("bb") is "𝄫" — the double-flat sign — while Spelling.get("bb") is "B♭". Callers
21
+ # holding a pitch name that may be lowercase should parse it with Spelling first.
22
+ #
23
+ # The maps are derived from Alteration lazily rather than at load time. Eager
24
+ # derivation is not an option anywhere in the load sequence: Alteration.all
25
+ # instantiates Notation::MusicalSymbol, which is not required until much later in
26
+ # head_music.rb.
27
+ class HeadMusic::Utilities::Accidentals
28
+ # Chord qualities that may follow a flat without a word boundary, so that "Bbm" and
29
+ # "Ebmaj7" convert while "Above" does not. "m" subsumes "maj", "min", and "m7";
30
+ # uppercase forms like "BbM7" already clear the (?![a-z]) guard unaided.
31
+ #
32
+ # Measured against /usr/share/dict/words: this list costs exactly two false
33
+ # positives ("Abmho", "Ebbman") across 235,976 capitalized words. Each entry clears
34
+ # a real word by one letter — "sus" vs "Absurd", "dim" vs "Abdomen" and "Abdicate" —
35
+ # so any addition here needs re-measuring rather than eyeballing.
36
+ CHORD_QUALITIES = %w[m dim aug sus].freeze
37
+
38
+ # The scale degrees a chord extension can alter. Requiring the altered degree to be
39
+ # one of these is what separates "C7b9" from "Figure C2b3": both are an accidental
40
+ # flanked by digits inside a token that opens with a letter name, and only the
41
+ # degree tells them apart.
42
+ EXTENSION_DEGREES = %w[13 11 9 6 5 4].freeze
43
+
44
+ # A candidate chord symbol. One quantifier over a character class, so there is no
45
+ # nesting and no ReDoS surface. This is what scopes the altered-extension rule
46
+ # below: "1.0b2" and "0x1B2F" never open a token, so they can never reach it.
47
+ CHORD_TOKEN = /(?<![A-Za-z0-9])[A-G][A-Za-z0-9#]*/
48
+
49
+ # "Bb" => "B♭", "C##" => "C𝄪", "Cx" => "C𝄪", "Bbmaj7#11" => "B♭maj7♯11".
50
+ # Idempotent, and all-or-nothing per accidental: a double never half-converts.
51
+ def self.to_unicode(text)
52
+ string = text.to_s
53
+ return unicode_for.fetch(string) if unicode_for.key?(string)
54
+
55
+ string.gsub(CHORD_TOKEN) do |token|
56
+ token.gsub(ascii_matcher) { |match| unicode_for.fetch(match) }
57
+ end
58
+ end
59
+
60
+ # "B♭" => "Bb", "C𝄪" => "Cx". Emits the spellings the gem's own parsers accept,
61
+ # so "x" rather than "##" for a double sharp.
62
+ #
63
+ # Deliberately not letter-anchored: unicode glyphs are unambiguous, and consumers
64
+ # feed this scale degrees and roman numerals ("♭7", "♭VI") that have no letter name.
65
+ # A natural sign is left as "♮" — mapping it to "" would turn the valid spelling
66
+ # "C♮" into the different spelling "C". Idempotent.
67
+ def self.to_ascii(text)
68
+ text.to_s.gsub(unicode_matcher) { |match| ascii_for.fetch(match) }
69
+ end
70
+
71
+ def self.unicode_for
72
+ @unicode_for ||=
73
+ altering_alterations.flat_map(&:musical_symbols)
74
+ .reject { |symbol| symbol.ascii.to_s.empty? }
75
+ .to_h { |symbol| [symbol.ascii, symbol.unicode] }
76
+ end
77
+
78
+ # One entry per alteration, keyed on its primary #ascii, so "##" is never emitted.
79
+ def self.ascii_for
80
+ @ascii_for ||= altering_alterations.to_h { |alteration| [alteration.unicode, alteration.ascii] }
81
+ end
82
+
83
+ # Applied only inside a CHORD_TOKEN. No /i flag — case-insensitivity would read the
84
+ # "B" of "B7" as a flat sign.
85
+ #
86
+ # Two anchor contexts. The first is the pitch name's own accidental, anchored to the
87
+ # letter name, with a per-family guard: "#" cannot occur in an English word at all,
88
+ # "b" can but is rescued by CHORD_QUALITIES, and "x" takes the bare guard because an
89
+ # allowlist there would buy "Cxm7" and cost "Axminster" and "Exmoor". The second is
90
+ # an altered extension ("7#11", "7b9"), which has no letter to anchor to and is
91
+ # instead flanked by digits. Being inside a CHORD_TOKEN is not sufficient on its own
92
+ # — "Figure C2b3" would qualify — so the altered degree must also be a real chord
93
+ # extension.
94
+ def self.ascii_matcher
95
+ @ascii_matcher ||=
96
+ /(?<=[A-G])(?:#{sharp_branch}|#{flat_branch}|#{double_sharp_branch})|#{extension_branch}/
97
+ end
98
+
99
+ def self.unicode_matcher
100
+ @unicode_matcher ||= Regexp.union(ascii_for.keys)
101
+ end
102
+ private_class_method :ascii_matcher, :unicode_matcher
103
+
104
+ def self.sharp_branch
105
+ longest_first(spellings_matching(/#/))
106
+ end
107
+
108
+ def self.flat_branch
109
+ "(?:#{longest_first(spellings_matching(/b/))})(?:(?![a-z])|(?=#{longest_first(CHORD_QUALITIES)}))"
110
+ end
111
+
112
+ def self.double_sharp_branch
113
+ "(?:#{longest_first(spellings_matching(/x/))})(?![a-z])"
114
+ end
115
+
116
+ def self.extension_branch
117
+ "(?<=\\d)(?:#{longest_first(spellings_matching(/[#b]/))})(?=(?:#{longest_first(EXTENSION_DEGREES)})(?![0-9]))"
118
+ end
119
+
120
+ def self.spellings_matching(pattern)
121
+ unicode_for.keys.grep(pattern)
122
+ end
123
+
124
+ # Regexp.union alternates in array order rather than by longest match, so every
125
+ # two-character spelling must precede its one-character prefix or a double
126
+ # half-converts.
127
+ def self.longest_first(spellings)
128
+ Regexp.union(spellings.sort_by { |spelling| -spelling.length }).source
129
+ end
130
+
131
+ def self.altering_alterations
132
+ HeadMusic::Rudiment::Alteration.all.reject(&:natural?)
133
+ end
134
+
135
+ private_class_method :sharp_branch, :flat_branch, :double_sharp_branch,
136
+ :extension_branch, :spellings_matching, :longest_first, :altering_alterations
137
+ end
@@ -3,39 +3,54 @@ module HeadMusic::Utilities; end
3
3
 
4
4
  # Util for converting an object to a consistent hash key
5
5
  class HeadMusic::Utilities::HashKey
6
+ # Keyed on the identifier as given, so a symbol and its string spelling occupy
7
+ # separate entries for the same result. Never evicted: the gem's identifiers come
8
+ # from finite catalogs, so the vocabulary is bounded. A caller generating
9
+ # identifiers dynamically would grow this without limit.
6
10
  def self.for(identifier)
7
11
  @hash_keys ||= {}
8
- @hash_keys[identifier] ||= new(identifier).to_sym
12
+ @hash_keys[identifier] ||=
13
+ HeadMusic::Utilities::Case.to_snake_case(
14
+ I18n.transliterate(desymbolized_string(identifier))
15
+ ).to_sym
9
16
  end
10
17
 
11
- attr_reader :original
12
-
13
- def initialize(identifier)
14
- @original = identifier
15
- end
16
-
17
- def to_sym
18
- normalized_string.to_sym
19
- end
20
-
21
- private
22
-
23
- def normalized_string
24
- @normalized_string ||=
25
- HeadMusic::Utilities::Case.to_snake_case(transliterated_string)
26
- end
27
-
28
- def transliterated_string
29
- I18n.transliterate(desymbolized_string)
30
- end
31
-
32
- def desymbolized_string
33
- original.to_s
34
- .gsub("𝄫", "_double_flat")
35
- .gsub("♭", "_flat")
36
- .gsub("♮", "_natural")
37
- .gsub("♯", "_sharp")
18
+ # Accidentals become word suffixes, so that "C♯" and "C sharp" reach the same key.
19
+ #
20
+ # ASCII spellings are normalized to their unicode glyphs first rather than mapped
21
+ # here. Mapping ASCII "b" directly is what a naive implementation gets wrong — it
22
+ # turns :bass_clarinet into :b_flatass_clarinet — and avoiding that by handling
23
+ # ASCII "#" but not ASCII "b" left the two sides asymmetric, with "C#" keying to
24
+ # :c_sharp while "Bb" keyed to :bb. Accidentals.to_unicode is prose-safe, so it
25
+ # closes that gap without the mangling.
26
+ #
27
+ # The two ASCII sharp rules that follow are a fallback, not a second inventory. They
28
+ # catch a "#" that Accidentals.to_unicode leaves alone because it is not attached to
29
+ # a letter name, and they are safe to apply bare because "#" cannot occur in a word —
30
+ # which is exactly why the corresponding ASCII flat rules must not exist here.
31
+ # Doubles first: the single rule would otherwise consume the first "#" of a "##".
32
+ def self.desymbolized_string(identifier)
33
+ normalized = HeadMusic::Utilities::Accidentals.to_unicode(identifier)
34
+ word_suffixes
35
+ .reduce(normalized) { |string, (glyph, suffix)| string.gsub(glyph, suffix) }
36
+ .gsub("##", "_double_sharp")
38
37
  .gsub("#", "_sharp")
39
- .gsub("𝄪", "_double_sharp")
40
38
  end
39
+ private_class_method :desymbolized_string
40
+
41
+ # Derived from Alteration so that alterations.yml stays the only place the glyph
42
+ # inventory lives. Each alteration's identifier is already the word form a key wants
43
+ # (:double_sharp -> "_double_sharp"). Every unicode glyph is a single character, so
44
+ # unlike the ASCII spellings they need no longest-first ordering.
45
+ #
46
+ # Resolved lazily, and it must stay that way: Alteration includes Named, whose
47
+ # .get_by_name calls back into HashKey. Nothing on Alteration's initialization path
48
+ # reaches that method today, which is what keeps the cycle from closing — so if
49
+ # Named ever starts building a hash key while constructing an object, this memo
50
+ # would recurse through a half-built Alteration.all.
51
+ def self.word_suffixes
52
+ @word_suffixes ||=
53
+ HeadMusic::Rudiment::Alteration.all.to_h { |alteration| [alteration.unicode, "_#{alteration.identifier}"] }
54
+ end
55
+ private_class_method :word_suffixes
41
56
  end
@@ -1,3 +1,3 @@
1
1
  module HeadMusic
2
- VERSION = "17.4.0"
2
+ VERSION = "18.0.0"
3
3
  end
data/lib/head_music.rb CHANGED
@@ -18,11 +18,11 @@ require "i18n/backend/fallbacks"
18
18
 
19
19
  # Configure I18n for HeadMusic locales
20
20
  # Include fallbacks backend if not already included.
21
- # :nocov: — one-time, load-order-dependent bootstrap guard; module inclusion is
22
- # irreversible, so the fresh-install arm cannot be re-exercised in-process. The
23
- # already-included arm is asserted by the idempotency spec.
21
+ # simplecov:disable — one-time, load-order-dependent bootstrap guard; module
22
+ # inclusion is irreversible, so the fresh-install arm cannot be re-exercised
23
+ # in-process. The already-included arm is asserted by the idempotency spec.
24
24
  I18n::Backend::Simple.include(I18n::Backend::Fallbacks) unless I18n::Backend::Simple.included_modules.include?(I18n::Backend::Fallbacks)
25
- # :nocov:
25
+ # simplecov:enable
26
26
 
27
27
  # Add HeadMusic locale files to the load path (additive, doesn't overwrite)
28
28
  I18n.load_path += Dir[File.join(File.dirname(__dir__), "lib", "head_music", "locales", "*.yml")]
@@ -45,14 +45,15 @@ HEAD_MUSIC_FALLBACKS = {
45
45
  }.freeze
46
46
 
47
47
  HEAD_MUSIC_FALLBACKS.each do |locale, fallbacks|
48
- # :nocov: — load-order-dependent bootstrap guard; whether the fresh-configure
49
- # arm runs depends on global I18n state at first require. The already-configured
50
- # arm is asserted by the idempotency spec.
48
+ # simplecov:disable — load-order-dependent bootstrap guard; whether the
49
+ # fresh-configure arm runs depends on global I18n state at first require. The
50
+ # already-configured arm is asserted by the idempotency spec.
51
51
  I18n.fallbacks[locale] = fallbacks if I18n.fallbacks[locale].empty? || I18n.fallbacks[locale] == [locale]
52
- # :nocov:
52
+ # simplecov:enable
53
53
  end
54
54
 
55
55
  # utilities
56
+ require "head_music/utilities/accidentals"
56
57
  require "head_music/utilities/case"
57
58
  require "head_music/utilities/hash_key"
58
59
 
@@ -145,6 +146,7 @@ require "head_music/content/placement"
145
146
  require "head_music/content/position"
146
147
  require "head_music/content/sound_resolver"
147
148
  require "head_music/content/staff"
149
+ require "head_music/content/syllable"
148
150
  require "head_music/content/voice"
149
151
  require "head_music/content/voice/melodic_note_pair"
150
152
  require "head_music/content/voice/melodic_line"