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.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +3 -3
- data/.github/workflows/release.yml +1 -1
- data/.github/workflows/security.yml +2 -1
- data/.rubocop.yml +6 -0
- data/CHANGELOG.md +34 -0
- data/CLAUDE.md +20 -0
- data/Gemfile.lock +68 -68
- data/head_music.gemspec +1 -1
- data/lib/head_music/analysis/pitch_collection.rb +2 -2
- data/lib/head_music/content/composition/hash_deserializer.rb +3 -0
- data/lib/head_music/content/composition/schema_values.rb +40 -0
- data/lib/head_music/content/placement.rb +30 -1
- data/lib/head_music/content/staff.rb +3 -5
- data/lib/head_music/content/syllable.rb +38 -0
- data/lib/head_music/content/voice.rb +1 -1
- data/lib/head_music/instruments/instrument_name.rb +1 -1
- data/lib/head_music/notation/abc/key_mapper.rb +24 -4
- data/lib/head_music/notation/music_xml/beam_grouper.rb +2 -2
- data/lib/head_music/notation/music_xml/duration_writer.rb +1 -1
- data/lib/head_music/notation/music_xml/writer.rb +46 -0
- data/lib/head_music/rudiment/alteration.rb +20 -5
- data/lib/head_music/rudiment/alterations.yml +6 -0
- data/lib/head_music/rudiment/key_signature.rb +1 -1
- data/lib/head_music/rudiment/note.rb +6 -2
- data/lib/head_music/rudiment/scale.rb +3 -5
- data/lib/head_music/style/guidelines/notes_same_length.rb +1 -1
- data/lib/head_music/time/musical_position.rb +1 -13
- data/lib/head_music/time/radix_carry.rb +22 -0
- data/lib/head_music/time/smpte_timecode.rb +1 -13
- data/lib/head_music/time.rb +1 -0
- data/lib/head_music/utilities/accidentals.rb +137 -0
- data/lib/head_music/utilities/hash_key.rb +44 -29
- data/lib/head_music/version.rb +1 -1
- data/lib/head_music.rb +10 -8
- data/user-stories/done/accidental-string-normalization.md +552 -0
- data/user-stories/done/lyrics.md +162 -0
- data/user-stories/index.html +8 -0
- 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
|
-
|
|
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
|
-
#
|
|
53
|
-
"
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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: "𝄪"
|
|
36
|
+
- ascii: "##"
|
|
37
|
+
unicode: "𝄪"
|
|
38
|
+
html_entity: "𝄪"
|
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
13
|
-
|
|
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.
|
|
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
|
data/lib/head_music/time.rb
CHANGED
|
@@ -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] ||=
|
|
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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
data/lib/head_music/version.rb
CHANGED
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
|
-
# :
|
|
22
|
-
# irreversible, so the fresh-install arm cannot be re-exercised
|
|
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
|
-
# :
|
|
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
|
-
# :
|
|
49
|
-
# arm runs depends on global I18n state at first require. The
|
|
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
|
-
# :
|
|
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"
|