head_music 17.5.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/CHANGELOG.md +27 -0
- data/CLAUDE.md +20 -0
- data/Gemfile.lock +1 -1
- data/lib/head_music/content/staff.rb +3 -5
- 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/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/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 +9 -8
- data/user-stories/done/accidental-string-normalization.md +552 -0
- data/user-stories/index.html +7 -3
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8a16868734e2fac408461d2202fcbc84be2a796a68371a81a7d2ad5bbeef516b
|
|
4
|
+
data.tar.gz: 02becc93275629796177a72894d81cc10feca0819847496e60aa3ddc6f5aac00
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e3533678a1a2b58558080cad6c03f04fe4b5b205d4413456b728c5ae2e1a598e3a09ebc0329c9f6fde263e84f857eb4571918087afe23a083278facaea7aa460
|
|
7
|
+
data.tar.gz: 3d605bff3a0459e56cddd4911e7699d2b295bf337e7f468094d0f4cc19e55f9b3036c21e59c95263f3bf19c69dfffa99093a8564f036aa625b776199e959e8f4
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [18.0.0] - 2026-07-27
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- `HeadMusic::Utilities::Accidentals` converts accidentals in a string between ASCII spellings and canonical unicode glyphs, replacing the hand-rolled `gsub` and `tr` chains that were scattered across the gem and disagreed with each other. `.to_unicode` converts whole chord symbols rather than bare pitch names — `Bbmaj7#11` becomes `B♭maj7♯11` — and `.to_ascii` converts back, emitting the spellings the gem's own parsers accept (`x` for a double sharp, never `##`). Both directions are idempotent, and conversion is all-or-nothing per accidental so a double never half-converts.
|
|
15
|
+
- The forward direction is 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 `Ebmaj7` convert. A chord-quality allowlist rescues `Bbm` and `Ebmaj7` from the word-boundary guard that protects `Above`; the allowlist was measured against a full system word list and is pinned by a spec that sweeps it.
|
|
16
|
+
- `##` is now accepted everywhere `bb` already was, ending the asymmetry between the two doubles. `Alteration.get("##")` resolves to `double_sharp` and `Spelling.get("C##")` returns `C𝄪`.
|
|
17
|
+
- The utility is deliberately narrower than `Spelling` and `Pitch` in two ways, both consequences of being safe to run over prose. It is case-sensitive, because a case-insensitive rule reads the `B` of `B7` as a flat sign — so `to_unicode("eb")` is `"eb"` while `Spelling.get("eb")` is `E♭`. And a bare token is read as an accidental rather than a pitch name — `to_unicode("bb")` is `𝄫`, the double-flat sign, while `Spelling.get("bb")` is `B♭`. Parse with `Spelling` first when a pitch name may be lowercase. `to_ascii` preserves `♮` for the same reason: dropping it would turn the valid spelling `C♮` into the different spelling `C`.
|
|
18
|
+
|
|
19
|
+
### Changed
|
|
20
|
+
|
|
21
|
+
- **Breaking.** `Spelling.get("C##")` returns a `Spelling` instead of `nil`, and the same widening applies to `Pitch`, `Note`, `KeySignature`, and ABC `K:` fields. Code that treated that `nil` as a validation signal now silently accepts double sharps.
|
|
22
|
+
- **Breaking.** `Alteration::PATTERN`, `Alteration::MATCHER`, and `Alteration::SYMBOLS` gain `##` and are now sorted longest-first so that a two-character spelling always matches before its one-character prefix. Code embedding these in its own regular expressions will see different matches.
|
|
23
|
+
- **Breaking.** `Alteration.symbols` now returns `SYMBOLS` rather than a separately derived list that disagreed with it.
|
|
24
|
+
- **Breaking.** `Utilities::HashKey` normalizes ASCII accidentals before mapping them to word suffixes, so ASCII and unicode spellings of the same accidental now produce the same key. Previously it handled ASCII `#` but not ASCII `b`, so `"C#"` keyed to `:c_sharp` while `"Bb"` keyed to `:bb`. Now `"Bb"` keys to `:b_flat`, `"Bbb"` to `:b_double_flat`, and `"Cx"` to `:c_double_sharp`. Words containing those letters are unaffected — `:bass_clarinet` and `:blues_major_pentatonic` are unchanged — because normalization goes through the prose-safe `Utilities::Accidentals` rather than a bare substitution. The glyph-to-word table is now derived from `Alteration` rather than hard-coded, leaving `alterations.yml` as the single inventory.
|
|
25
|
+
- `Utilities::HashKey` is class-methods-only, matching `Utilities::Case`. `.new` and `#to_sym` are gone; `.for` was already the only entry point in use. The per-instance memo they wrapped could never be hit twice, since each instance was discarded after one call.
|
|
26
|
+
- **Breaking.** `Analysis::Dyad` gains double sharps in its enharmonic universe, which were previously built as candidates and then silently discarded by the parser. Candidate spellings grow from 28 to 35, and a major third gains three respellings.
|
|
27
|
+
- **Breaking.** `Rudiment::Scale::SCALE_REGEX` is removed. It was unreferenced, and its singles-only `[#b]?` was an instance of the asymmetry this release closes.
|
|
28
|
+
- **Breaking.** `KeySignature` and `Scale` memoization keys change for identifiers containing accidentals. `Fx`, `C##`, and `F𝄪` now unify onto one cache entry, as they name the same key signature.
|
|
29
|
+
- `Rudiment::Note::PITCH_PATTERN` no longer truncates a double sharp. `"C##4"` previously parsed as `["C", "#", nil]`, silently losing the register, because the optional quantifier bound to the pattern's final alternative rather than to the whole alternation.
|
|
30
|
+
- `Alteration#representations` now spans every symbol record rather than only the primary one, so a double sharp reports `"##"` alongside `"x"` and `𝄪`. It also accepts a `locale_code:` keyword and memoizes per locale, since the list includes the localized `#name`.
|
|
31
|
+
- An ABC `K:` field naming a double-altered tonic still raises `ParseError`, but with a message that says so — `Cannot express double-altered tonic Cx in an ABC K: field` rather than the incidental `Unrecognized mode` it produced when the narrower key pattern failed to match the accidental.
|
|
32
|
+
|
|
33
|
+
### Removed
|
|
34
|
+
|
|
35
|
+
- `Content::Staff` no longer prints to standard output when it cannot resolve a clef key. It previously wrote `Warning: Clef '...' not found.` and `Using instrument clef.` to stdout. The fallback behavior is unchanged — an unrecognized key still resolves to the instrument's clef when an instrument is present, and to the treble clef otherwise — it is simply no longer announced.
|
|
36
|
+
|
|
10
37
|
## [17.5.0] - 2026-07-21
|
|
11
38
|
|
|
12
39
|
### Added
|
data/CLAUDE.md
CHANGED
|
@@ -150,6 +150,26 @@ The gem supports multiple languages through the HeadMusic::Named mixin:
|
|
|
150
150
|
- Shared examples in `spec/support/`
|
|
151
151
|
- `composition_context.rb` provides test utilities
|
|
152
152
|
|
|
153
|
+
**Never assert on console output.** Do not use `output(...).to_stdout` or `.to_stderr`. A library should not print to the console, so a spec that asserts it printed something is pinning behavior the gem should not have — and it makes the printing hard to remove later, because deleting the `puts` breaks a test that looks unrelated to the change.
|
|
154
|
+
|
|
155
|
+
Test the behavior the message was describing instead. When a fallback or recovery path is worth pinning, assert what it produces and that it does not raise:
|
|
156
|
+
|
|
157
|
+
```ruby
|
|
158
|
+
# Not this
|
|
159
|
+
it "prints a warning" do
|
|
160
|
+
expect { staff }.to output(/Warning: Clef 'foo' not found/).to_stdout
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# This
|
|
164
|
+
it "recovers from the unrecognized key instead of raising" do
|
|
165
|
+
expect { staff }.not_to raise_error
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
it "prefers the instrument's clef over the generic fallback" do
|
|
169
|
+
expect(staff.clef).not_to eq described_class.new("foo").clef
|
|
170
|
+
end
|
|
171
|
+
```
|
|
172
|
+
|
|
153
173
|
### Documentation Philosophy
|
|
154
174
|
|
|
155
175
|
This project deliberately deprioritizes formal documentation in favor of clear, comprehensive tests.
|
data/Gemfile.lock
CHANGED
|
@@ -12,12 +12,10 @@ class HeadMusic::Content::Staff
|
|
|
12
12
|
begin
|
|
13
13
|
@default_clef = HeadMusic::Rudiment::Clef.get(default_clef_key)
|
|
14
14
|
rescue KeyError, NoMethodError
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
puts("Using instrument clef.")
|
|
18
|
-
@default_clef = @instrument.default_staves.first.clef
|
|
15
|
+
@default_clef = if @instrument
|
|
16
|
+
@instrument.default_staves.first.clef
|
|
19
17
|
else
|
|
20
|
-
|
|
18
|
+
HeadMusic::Rudiment::Clef.get(:treble_clef)
|
|
21
19
|
end
|
|
22
20
|
end
|
|
23
21
|
@line_count = line_count || DEFAULT_LINE_COUNT
|
|
@@ -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
|
|
|
@@ -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
|
|
|
@@ -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
|
|
|
@@ -0,0 +1,552 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
metadata:
|
|
3
|
+
created_at: 2026-07-26T18:56:39-07:00
|
|
4
|
+
activated_at: 2026-07-26T19:28:59-07:00
|
|
5
|
+
planned_at: 2026-07-26T20:05:31-07:00
|
|
6
|
+
finished_at: 2026-07-27T07:27:30-07:00
|
|
7
|
+
updated_at: 2026-07-27T07:27:30-07:00
|
|
8
|
+
-->
|
|
9
|
+
|
|
10
|
+
# Story: Accidental String Normalization
|
|
11
|
+
|
|
12
|
+
## Summary
|
|
13
|
+
|
|
14
|
+
AS a developer building on head_music
|
|
15
|
+
I WANT a string utility that converts accidentals between ASCII and canonical Unicode
|
|
16
|
+
SO THAT every consumer stops hand-rolling its own `gsub` chain and getting the double accidentals wrong
|
|
17
|
+
|
|
18
|
+
## Acceptance Criteria
|
|
19
|
+
|
|
20
|
+
### The utility
|
|
21
|
+
|
|
22
|
+
- A public utility converts ASCII accidentals in a string to canonical Unicode: `Bb` → `B♭`, `F#` → `F♯`, `Bbb` → `B𝄫`, `Cx` → `C𝄪`.
|
|
23
|
+
- **`##` converts to `𝄪`.** It's the notation most writers reach for, and the gem currently rejects it outright.
|
|
24
|
+
- The reverse direction is public too: Unicode → ASCII, emitting the spellings the gem's own parsers accept (`x` for double sharp, not `##`).
|
|
25
|
+
- Both directions are **idempotent** — running either on already-converted text is a no-op.
|
|
26
|
+
- Conversion is **all-or-nothing per accidental.** A double never half-converts; `C##` yields `C𝄪`, never `C♯#`.
|
|
27
|
+
- The utility handles a bare accidental token, a pitch name, and accidentals embedded in prose.
|
|
28
|
+
- English words containing accidental-shaped substrings are untouched: "Above", "Absence", "Bebop", "ebb", "back".
|
|
29
|
+
|
|
30
|
+
### Consistent behavior across the gem
|
|
31
|
+
|
|
32
|
+
- **`##` is recognized everywhere `bb` is**, removing the asymmetry between the two doubles. `Alteration.get("##")` resolves to `double_sharp` and `Spelling.get("C##")` returns `C𝄪`, matching how `bb` already behaves.
|
|
33
|
+
- **Every hand-rolled conversion in the gem is replaced by the utility**, and the `gsub`/`tr` chain it replaced is deleted. The full list is in the Notes; none is left behind as a second implementation.
|
|
34
|
+
- Any two call sites given the same input produce the same output. Today they don't: `instrument_name.rb` renders `Bbb` as `B♭♭` while `Spelling` renders it `B𝄫`.
|
|
35
|
+
- A characterization spec pins the behavior of each migrated call site so the consolidation is provably lossless — the ABC notation writers in particular have consumers that reject Unicode.
|
|
36
|
+
|
|
37
|
+
## Notes
|
|
38
|
+
|
|
39
|
+
### Why this exists
|
|
40
|
+
|
|
41
|
+
Every consumer of the gem that needs to display or accept accidentals writes its own conversion, and they disagree with each other. This story consolidates that into one tested utility.
|
|
42
|
+
|
|
43
|
+
The immediate trigger: `bardtheory` needed to normalize drafted dictionary definitions and built `app/services/accidental_converter.rb` locally — six anchored regex rules plus a residue detector. It works, but it's the fourth or fifth independent implementation of this idea across the two repos, and it deliberately refuses to handle doubles because the gem gives it nowhere good to land them.
|
|
44
|
+
|
|
45
|
+
### What the gem has today (verified 2026-07-26)
|
|
46
|
+
|
|
47
|
+
**Correct glyph data exists** — `lib/head_music/rudiment/alterations.yml`, loaded at `alteration.rb:14-15`:
|
|
48
|
+
|
|
49
|
+
| identifier | semitones | ascii | unicode | html_entity |
|
|
50
|
+
| --- | --- | --- | --- | --- |
|
|
51
|
+
| `double_flat` | -2 | `bb` | `𝄫` | `𝄫` |
|
|
52
|
+
| `flat` | -1 | `b` | `♭` | `♭` |
|
|
53
|
+
| `natural` | 0 | `` | `♮` | `♮` |
|
|
54
|
+
| `sharp` | 1 | `#` | `♯` | `♯` |
|
|
55
|
+
| `double_sharp` | 2 | `x` | `𝄪` | `𝄪` |
|
|
56
|
+
|
|
57
|
+
The glyphs are the correct single codepoints — 𝄫 U+1D12B and 𝄪 U+1D12A, not the doubled-single fallback `♭♭`/`♯♯`. This is the right foundation; the story is about exposing it, not fixing it.
|
|
58
|
+
|
|
59
|
+
**A pitch-name round-trip works** via parse-then-`to_s`, and it already handles doubles:
|
|
60
|
+
|
|
61
|
+
```ruby
|
|
62
|
+
Spelling.get("Bb") # => "B♭"
|
|
63
|
+
Spelling.get("Bbb") # => "B𝄫"
|
|
64
|
+
Spelling.get("Cx") # => "C𝄪"
|
|
65
|
+
Spelling.get("C##") # => nil <-- the gap
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
**`##` is not recognized.** `Alteration::PATTERN` (`alteration.rb:19-20`) is:
|
|
69
|
+
|
|
70
|
+
```ruby
|
|
71
|
+
/𝄫|bb|♭|b|♮|♯|\#|𝄪|x/
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
`#` appears as a single alternative with no two-character branch, so `Alteration.get("##")` returns `nil` and `Spelling::MATCHER` (`spelling.rb:11`) rejects `"C##"` entirely. Double flat is spelled `bb` and works; double sharp is spelled only `x`. That asymmetry is the surprising part, and it's what makes `##` the specific thing to fix.
|
|
75
|
+
|
|
76
|
+
**No general string utility exists.** There is no `to_unicode`, `normalize`, `canonical`, or `glyph` method in `lib/`. `Spelling`/`Pitch` expose an `Alteration` but have no `ascii`/`unicode` method of their own.
|
|
77
|
+
|
|
78
|
+
### The hand-rolled implementations to replace
|
|
79
|
+
|
|
80
|
+
- `lib/head_music/instruments/instrument_name.rb:47` — `format_pitch_name`, a private `tr("b", "♭").tr("#", "♯")`. **Singles only, and it corrupts doubles**: `"Bbb"` becomes `"B♭♭"`, and `"Above"` would become `"A♭ove"` if it ever saw prose.
|
|
81
|
+
- `lib/head_music/rudiment/key_signature.rb:21` and `lib/head_music/rudiment/scale.rb:13` — inline `gsub(/#|♯/, "sharp").gsub(/(\w)[b♭]/, '\1flat')`, singles only.
|
|
82
|
+
- `lib/head_music/notation/abc/key_mapper.rb:53` and `abc/pitch_writer.rb:53` — manual `alteration&.ascii` concatenation for the Unicode → ASCII direction.
|
|
83
|
+
- `lib/head_music/notation/abc/key_mapper.rb:7` — `KEY_PATTERN = /\A([A-G])([#♯b♭]?)\s*([A-Za-z]*)/`, singles only.
|
|
84
|
+
- `lib/head_music/utilities/hash_key.rb:32-40` — `desymbolized_string` maps accidentals to word suffixes. Handles both doubles, but targets snake_case symbols rather than notation. Note the ordering: `gsub("♯", "_sharp")` runs before `gsub("𝄪", "_double_sharp")`, harmless only because `𝄪` is a distinct codepoint — worth tidying while nearby.
|
|
85
|
+
|
|
86
|
+
Downstream in `bardtheory`:
|
|
87
|
+
|
|
88
|
+
- `app/services/accidental_converter.rb` — six anchored rules for pitches, Roman numerals, and scale degrees, plus a residue detector. Deliberately declines doubles. Should collapse onto this utility once it exists, keeping only its Roman-numeral and scale-degree rules, which are notation the gem doesn't model.
|
|
89
|
+
- `app/services/pivot_chord_service.rb:127` — `gsub("♭", "b").gsub("♯", "#").gsub("\u{1D12B}", "bb")` for the Unicode → ASCII direction. **Omits `\u{1D12A}` (𝄪)**, so a double sharp survives as Unicode and then fails the `Pitch.get` that consumes it.
|
|
90
|
+
|
|
91
|
+
### Design notes for whoever picks this up
|
|
92
|
+
|
|
93
|
+
**Prose is the hard part, not pitch names.** A pitch name has a known shape; prose does not. The rule that makes prose safe is anchoring every pattern to a musical context rather than to a bare `b` or `#`. Two findings from bardtheory's implementation, both measured against a real 65-document corpus, are worth inheriting:
|
|
94
|
+
|
|
95
|
+
1. **No `/i` flag.** Case-insensitivity has a real false positive: `B7` (a B dominant seventh) becomes `♭7`. Every genuine flat-degree is lowercase; every pitch name is uppercase. Case-sensitivity separates them for free.
|
|
96
|
+
2. **`(?![a-z])`, not a trailing `\b`.** A literal `\b` correctly rejects "Above" and "Absence" but also rejects `Ab7`, a genuine flat. The negative lookahead does both correctly.
|
|
97
|
+
|
|
98
|
+
**Half-conversion is the failure mode to design against.** A naive single-accidental rule applied to `C##` converts the first symbol and leaves `C♯#` — worse than not converting, because it looks converted. Match doubles before singles.
|
|
99
|
+
|
|
100
|
+
**`##` is accepted everywhere, not just by the utility** (decided 2026-07-26). Widen `Alteration::PATTERN` so `Spelling.get("C##")` works, rather than having the utility pre-map `##` → `x` and leaving the grammar asymmetric. Widening changes parsing behavior for existing callers, so it wants the characterization specs above — but the alternative leaves the gem permanently accepting `bb` while rejecting `##`, which is the confusing state this story exists to end.
|
|
101
|
+
|
|
102
|
+
Note the ordering constraint this creates: `##` must be matched **before** the single `#` alternative in `PATTERN`, or the regex engine takes the single and leaves a stray `#`. The same applies to `bb` before `b`, which the current pattern already gets right.
|
|
103
|
+
|
|
104
|
+
**Naturals are a live question.** `natural`'s `ascii` is the empty string, so `Alteration.get("")` returns `nil` and `Spelling.get("C").to_s` is `"C"` — naturals never render as `♮`. Whether the utility should ever *emit* `♮` needs deciding; the safe default is no.
|
|
105
|
+
|
|
106
|
+
**The downstream consumer is a real constraint, not hypothetical.** `bardtheory` has a story waiting on this one (`accidental-converter-on-head-music`), and its `AccidentalConverter` is the closest thing to a reference implementation for the prose case — six anchored rules, measured against a 65-document corpus. Worth reading before designing the prose matcher rather than starting cold.
|
|
107
|
+
|
|
108
|
+
## Learnings
|
|
109
|
+
|
|
110
|
+
### Measurement beat intuition every time it came up
|
|
111
|
+
|
|
112
|
+
Three design questions were settled by running candidates against a real corpus rather than reasoning about them, and in each case the measured answer differed from the plausible one:
|
|
113
|
+
|
|
114
|
+
- Extending the chord-quality allowlist to `x` looks symmetric with extending it to `b`. It costs `Axminster`, `Exmoor`, `Axman`, `Axion` and buys only `Cxm7`, which is not real notation.
|
|
115
|
+
- Including mode words (`dor`, `ion`, `lyd`) in the allowlist looks obviously right for a music gem. It costs five `Ebion*` words and buys nothing, because space-free mode syntax is ABC input that reaches `KEY_PATTERN`, not this utility.
|
|
116
|
+
- `sus` clears `Absurd` by one letter; `dim` clears `Abdomen` and `Abdicate` by one. Nothing about that is visible by inspection.
|
|
117
|
+
|
|
118
|
+
The measurements are now pinned as specs, so the next person doesn't have to re-derive them — and can't silently break them.
|
|
119
|
+
|
|
120
|
+
### Characterization specs inherit the blind spots of whoever writes the migration
|
|
121
|
+
|
|
122
|
+
The most useful finding of the whole story. A 21-key ABC no-Unicode guard was written *specifically* to catch unicode leaking into a `K:` field. The leak that actually happened was on a natural-spelled tonic, and every key in the sample had a plain or accidental tonic. Right idea, wrong sample, zero protection.
|
|
123
|
+
|
|
124
|
+
The same shape recurred in the hostile-input corpus for the extension rule: every case started with a digit or lowercase (`1.0b2`, `0x1b2f`, `1b2c3d`), so the letter-initial shape (`Figure C2b3`, `A1b2`) was entirely unpinned. **Collecting examples is not the same as varying the shape systematically.** When building negative test cases, enumerate the structural variants first, then find an instance of each — not the other way round.
|
|
125
|
+
|
|
126
|
+
This is a direct argument for adversarial review by someone who didn't write the code. Both blind spots were found by review, and neither could have been found by the specs as written.
|
|
127
|
+
|
|
128
|
+
### Widening a parser is not symmetric with widening a writer
|
|
129
|
+
|
|
130
|
+
Making `KEY_PATTERN` accept `##` also made it accept `Cx` as an ABC tonic, which produced a confident wrong answer — `C𝄪 major`, "no sharps or flats" — where the narrow pattern had raised a clean `ParseError`. The writer already refused to render such a tonic, so reader and writer silently disagreed.
|
|
131
|
+
|
|
132
|
+
**Replacing an error with a plausible lie is a regression even when no test fails.** Any time a pattern is widened, ask separately what the *other* direction now has to reject.
|
|
133
|
+
|
|
134
|
+
### Check where a test actually runs before trusting it
|
|
135
|
+
|
|
136
|
+
The word-list sweep read the host's `/usr/share/dict/words` and asserted a macOS-specific result. CI runs `ubuntu-latest`, which has no such file, so the spec skipped — the guard it promised never ran where it mattered, and would have failed spuriously on a Linux box with a different word list. Vendoring a fixture made it deterministic and real. A skip is not a pass.
|
|
137
|
+
|
|
138
|
+
### Check whether an "out of scope" is yours or inherited
|
|
139
|
+
|
|
140
|
+
Altered extensions (`Bbmaj7#11`) were deferred because the plan grouped them with Roman numerals and scale degrees. Challenged, the grouping didn't survive: `#11` is part of a chord symbol, a Roman numeral isn't. The work took about ten minutes and closed a real gap — half-converting a chord symbol is the same failure mode as half-converting a double.
|
|
141
|
+
|
|
142
|
+
The lesson is not "be more ambitious." It's that a scope boundary copied from an upstream document should be re-derived before it's defended.
|
|
143
|
+
|
|
144
|
+
### Parallel review with different mandates found disjoint defects
|
|
145
|
+
|
|
146
|
+
The product-manager and code-reviewer agents ran concurrently over the same commit and their findings barely overlapped. The PM found the natural-sign leak and the missing acceptance-criteria pins; the reviewer found the parse-side asymmetry, the CI fragility, and the locale-dependent memoization. Neither would have surfaced the other's set. Two mandates, not two passes.
|
|
147
|
+
|
|
148
|
+
### Verify the plan's own claims
|
|
149
|
+
|
|
150
|
+
The plan asserted `KeySignature.get("C bebop")` hashed to `:c_be_flatop`. True of the `gsub` in isolation, unreachable through the public API — `bebop` is not a scale type this gem defines. One claim in maybe forty, but it had a spec written against it before it was checked. Plans are evidence, not fact.
|
|
151
|
+
|
|
152
|
+
### Accepted debt
|
|
153
|
+
|
|
154
|
+
Criterion 10 ("none is left behind as a second implementation") is **partially met, accepted deliberately**. `lib/head_music/utilities/hash_key.rb` still hard-codes all five glyphs, mapping glyph → word suffix (`♭` → `_flat`). That is a third direction the utility doesn't model, so it is duplication of the glyph *inventory* rather than a competing conversion. The list is small, stable, and now spec-covered. Deriving it from `Alteration` remains a clean follow-up if the inventory ever grows again.
|
|
155
|
+
|
|
156
|
+
## Review
|
|
157
|
+
|
|
158
|
+
Reviewed 2026-07-26 against commit `75e54ee` by a product-manager agent (acceptance criteria) and a code-reviewer agent (quality and correctness), running in parallel. Every finding below was independently reproduced before being acted on. Fixes are uncommitted at the time of writing; the suite is at **6316 examples, 0 failures**, RuboCop clean, no vulnerabilities, 99.70% line coverage, RubyCritic 87.94.
|
|
159
|
+
|
|
160
|
+
### Acceptance criteria
|
|
161
|
+
|
|
162
|
+
| # | Criterion | Verdict |
|
|
163
|
+
| --- | --- | --- |
|
|
164
|
+
| 1 | ASCII → canonical Unicode (`Bb`, `F#`, `Bbb`, `Cx`) | ✅ met |
|
|
165
|
+
| 2 | `##` converts to `𝄪` | ✅ met |
|
|
166
|
+
| 3 | Reverse direction public, emits `x` not `##` | ✅ met |
|
|
167
|
+
| 4 | Both directions idempotent | ✅ met — swept 30,000 words plus the full corpus, zero non-idempotent inputs |
|
|
168
|
+
| 5 | All-or-nothing per accidental | ✅ met for valid notation; `C###` → `C𝄪#` is now pinned as known residue |
|
|
169
|
+
| 6 | Bare token, pitch name, prose | ✅ met, all three tested |
|
|
170
|
+
| 7 | English words untouched | ✅ met — independently re-measured over 235,976 words |
|
|
171
|
+
| 8 | `##` recognized everywhere `bb` is | ✅ met; the two assertions the story names verbatim had **no spec** and now do |
|
|
172
|
+
| 9 | Any two call sites agree | ✅ met for uppercase input; the lowercase divergence is real and now documented |
|
|
173
|
+
| 10 | Every hand-rolled conversion replaced | ⚠️ **partially met** — see below |
|
|
174
|
+
| 11 | Characterization spec per migrated call site | ✅ met after fixes; three sites had no spec at review time |
|
|
175
|
+
|
|
176
|
+
### Findings fixed during review
|
|
177
|
+
|
|
178
|
+
**1. ABC regression — unicode leaking into a `K:` field (blocking).** `KeyMapper.abc_value(KeySignature.get("C♮ major"))` returned `"C♮"`. The migration replaced a concatenation that dropped naturals for free (`Alteration#ascii` is `""` for a natural) with `to_ascii`, which preserves `♮` by design. The double-altered guard above it catches doubles but not naturals. Fixed by dropping the natural explicitly at the ABC boundary. **This is exactly what the ABC guard specs existed to catch and didn't** — all 21 keys in the guard list had a plain or accidental tonic, never a natural one.
|
|
179
|
+
|
|
180
|
+
**2. ABC reader accepted what the writer refuses (blocking).** Widening `KEY_PATTERN` to the full `Alteration::PATTERN` was necessary for the `(?:...)` fix, but it also admitted `bb`, `##`, and `x` as tonics. `K:Cx` parsed to `C𝄪 major` reporting "no sharps or flats" — musically wrong — where the narrower pattern used to raise a clean `ParseError`. A plausible-looking wrong answer is worse than an error. Fixed by mirroring the double-altered guard on the parse side.
|
|
181
|
+
|
|
182
|
+
**3. The extension rule fired on non-chord identifiers.** `Figure C2b3` → `Figure C2♭3`, `A1b2` → `A1♭2`. The chord-token scope blocks digit-initial strings (`1.0b2`, `0x1B2F`) but not letter-initial ones, and every hostile case in the spec started with a digit or lowercase letter, so the whole shape was unpinned. Fixed by requiring the altered degree to be a real chord extension (`EXTENSION_DEGREES = %w[13 11 9 6 5 4]`). Measured: eliminates all seven false positives with zero must-convert regressions and an unchanged dictionary baseline.
|
|
183
|
+
|
|
184
|
+
**4. The dictionary spec was green-by-skip on CI.** It read the host's `/usr/share/dict/words` and asserted exact equality against a macOS `web2`-specific result (`Abmho` is a web2-ism). CI runs `ubuntu-latest`, which has no such file — so the guard the comment promised never ran where it mattered, and would have failed spuriously on a Linux box with `wamerican`. Replaced with a vendored 2,391-word fixture (`spec/support/fixtures/prose_words_at_risk.txt`) containing every capitalized word whose second character could begin an accidental — the exact set the guards protect.
|
|
185
|
+
|
|
186
|
+
**5. Three migrated call sites had no characterization spec** despite being plan steps: `Note::PITCH_PATTERN`, `HashKey`, and the whole-document ABC guard. All three now pinned.
|
|
187
|
+
|
|
188
|
+
**6. `representations` memoization froze a locale-dependent value.** `#name` resolves through `I18n.locale`. Inert today (only `:en` names are registered), but latent: once the German names in `locales/de.yml` are wired up, `Alteration.get("Kreuz")` would depend on which locale was active at first call. Now memoized per locale, with the duplicate entries the widening introduced deduped.
|
|
189
|
+
|
|
190
|
+
**7. Two undocumented narrowings.** The utility is deliberately case-sensitive and reads a bare token as an accidental, so `to_unicode("eb")` is `"eb"` while `Spelling.get("eb")` is `E♭`, and `to_unicode("bb")` is `𝄫` while `Spelling.get("bb")` is `B♭`. Both follow from being safe over prose. Now stated in the class comment, the CHANGELOG, and specs.
|
|
191
|
+
|
|
192
|
+
**8. Minor.** `ascii_matcher`/`unicode_matcher` made private (no external consumer); the equal-length sort-stability caveat noted where it could mislead; a redundant map assertion dropped.
|
|
193
|
+
|
|
194
|
+
### Accepted, not fixed
|
|
195
|
+
|
|
196
|
+
- **`hash_key.rb` remains a second hard-coded glyph inventory** (criterion 10). It maps glyph → word suffix (`♭` → `_flat`), a third direction the utility doesn't model, so `to_unicode`/`to_ascii` genuinely cannot replace it — but the criterion as written says "none is left behind as a second implementation," and by the letter one is. Deriving it from `Alteration` is a reasonable follow-up.
|
|
197
|
+
- **`pitch_writer.rb` not migrated.** Correct: it's a lookup key into `ACCIDENTAL_FRAGMENTS.invert`, not a conversion, and the `""` → `"="` entry means natural must stay in the map. The `#ascii` invariant it depends on is now pinned.
|
|
198
|
+
- **`C###` → `C𝄪#` half-converts** while `Bbbb` is left whole. Neither is valid notation; the asymmetry is recorded as a spec rather than hidden.
|
|
199
|
+
- **`dyad_spec` pins a private method and two magic counts.** Deliberate — the surrounding assertions are `be_an(Array)`, which is what let the 28 → 35 change pass silently in the first place.
|
|
200
|
+
|
|
201
|
+
### Verified sound
|
|
202
|
+
|
|
203
|
+
No reachable `KeyError` in either gsub block (every alternative derives from the same map; `CHORD_QUALITIES` and `EXTENSION_DEGREES` appear only in zero-width lookaheads). Both regex-interpolation fixes are correct with unchanged capture counts, and `pitch/parser.rb` and `spelling.rb` were correctly left alone — they already wrap the interpolation in a group. `nil`, `""`, frozen strings, newlines, and already-converted input all behave. No ReDoS: 50,000 words in 33ms.
|
|
204
|
+
|
|
205
|
+
## Implementation Plan
|
|
206
|
+
|
|
207
|
+
### Overview
|
|
208
|
+
|
|
209
|
+
Add `HeadMusic::Utilities::Accidentals` — a class-method-only utility mirroring `Utilities::Case` — that derives its ASCII↔Unicode maps **lazily** from `Alteration.all`, keeping `alterations.yml` the single source of truth. Add `##` as a second `symbols:` record under `double_sharp`, sort `SYMBOLS` longest-first, and widen `representations` to span all symbol records. Then delete each hand-rolled chain — or, where the input is already Unicode, delete it in favor of nothing at all.
|
|
210
|
+
|
|
211
|
+
The forward direction converts **whole chord symbols**, not just pitch names: `Bbmaj7#11` → `B♭maj7♯11`. It does this by scoping conversion to a candidate chord token, which is what makes the altered-extension rule safe to include at all.
|
|
212
|
+
|
|
213
|
+
The planner reports verifying the change set against patched scratch copies: the full suite passes all 6097 examples with exactly two failures, the hard-coded regex literals at `alteration_spec.rb:101-102`, which should be behavioral regardless. Re-verify during implementation rather than trusting this.
|
|
214
|
+
|
|
215
|
+
### Decisions on the open questions
|
|
216
|
+
|
|
217
|
+
**1. Home: `HeadMusic::Utilities::Accidentals`, required at `lib/head_music.rb:57`.**
|
|
218
|
+
|
|
219
|
+
Load order is a non-issue *because every `Alteration` reference sits inside a memoized class method* — nothing touches it at class-definition time. This matters more than it looks: a file at line 58 referencing `Alteration::PATTERN` eagerly raises `uninitialized constant HeadMusic::Rudiment` (the *module* doesn't exist yet), and `Alteration.all` is poisoned at **every** load point because `initialize_musical_symbols` instantiates `Notation::MusicalSymbol`, unavailable until line 156. Lazy memoization dissolves both. This also rules out a `Notation` home — `Rudiment` would then depend on a module loaded 90 lines later — and rules out `Alteration` itself, a value object with `private_class_method :new`.
|
|
220
|
+
|
|
221
|
+
**2. The `(?![a-z])` guard is per-alternative, and flats additionally take a chord-quality allowlist.** *(refines the story's settled note; decided 2026-07-26)*
|
|
222
|
+
|
|
223
|
+
Applied uniformly the guard is a false-negative machine — `F#m`, `A#m`, `C#maj7`, `F#dim` all silently unchanged, and `key_mapper.rb`'s own doc comment cites `F#m` as its motivating input. `#` cannot occur in an English word, so the guard is pure cost there. That fixes the sharp side but leaves an asymmetry: `F#m` converts while `Bbm` and `Ebmaj7` do not, because the flat side must keep its guard or `Above` breaks.
|
|
224
|
+
|
|
225
|
+
**That asymmetry is not acceptable — all three must convert.** Closed by giving the flat branch an escape hatch: match when followed by a non-lowercase character **or** by a lowercase chord quality. So the three branches are:
|
|
226
|
+
|
|
227
|
+
| branch | rule | why |
|
|
228
|
+
| --- | --- | --- |
|
|
229
|
+
| `##`, `#` | no guard | cannot occur in an English word |
|
|
230
|
+
| `bb`, `b` | `(?![a-z])` **or** `(?=m\|dim\|aug\|sus)` | needs the guard for `Above`; needs the allowlist for `Bbm` |
|
|
231
|
+
| `x` | `(?![a-z])` only | allowlist here costs `Axman`, `Axminster`, `Exmoor` and buys only `Cxm7`, which is not real notation |
|
|
232
|
+
|
|
233
|
+
`m` subsumes `maj`, `min`, `mix`, and `m7`, so the list is four entries, not a chord vocabulary. Uppercase suffixes (`BbM7`) already pass the plain guard for free.
|
|
234
|
+
|
|
235
|
+
Measured against `/usr/share/dict/words` (235,976 words, each capitalized, since the `(?<=[A-G])` lookbehind only fires on uppercase):
|
|
236
|
+
|
|
237
|
+
| design | must-convert failures | dictionary false positives |
|
|
238
|
+
| --- | --- | --- |
|
|
239
|
+
| guard only, uniform | 10 | 5 (pre-existing: `Ab`, `Abb`, `Ax`, `Ebb`, `Ex`) |
|
|
240
|
+
| + allowlist on flats **and** `x`, incl. mode words | 0 | 23 (+18: `Axman`, `Axminster`, `Exmoor`, `Ebionism`…) |
|
|
241
|
+
| + allowlist on flats only, incl. mode words | 0 | 12 (+7) |
|
|
242
|
+
| **+ chord qualities on flats only** ← chosen | **0** | **7 (+2: `Abmho`, `Ebbman`)** |
|
|
243
|
+
|
|
244
|
+
Mode words (`dor`, `phr`, `lyd`, `ion`…) are deliberately excluded: they cost five `Ebion*` false positives and buy nothing, because space-free mode syntax (`Ebdor`) is ABC input, which reaches `KEY_PATTERN` in step 8 rather than this utility. Prose writes "E♭ Dorian" with a space.
|
|
245
|
+
|
|
246
|
+
Residual cost: `Abmho` and `Ebbman`, both capitalized, both absent from any plausible music-theory corpus.
|
|
247
|
+
|
|
248
|
+
**2a. Altered extensions are in scope too** *(decided 2026-07-26)*
|
|
249
|
+
|
|
250
|
+
`Bbmaj7#11`, `C7b9`, `G7#5b9`, `Db7b9#11` must convert fully, not just their pitch-name accidental. These have no letter name to anchor to — the accidental is flanked by digits — so they need a second rule, and applied globally that rule is unsafe (`1.0b2`, `0x1b2f`, `revision 2b1` all convert).
|
|
251
|
+
|
|
252
|
+
The fix is to scope conversion to a **candidate chord token** — an uppercase letter name that doesn't continue a longer word or number — and run both rules only inside it. Beta versions, hex literals, and `Figure 2b` never open such a token, so the digit-flanked rule can't reach them.
|
|
253
|
+
|
|
254
|
+
| rule | must-convert failures | false positives (29-case adversarial list) |
|
|
255
|
+
| --- | --- | --- |
|
|
256
|
+
| letter-anchored only (previous plan) | 9 | 0 |
|
|
257
|
+
| + naive digit-flanked, applied globally | 0 | 5 (`1.0b2`, `0x1b2f`, `1b2c3d`, `version 3b7`, `revision 2b1`) |
|
|
258
|
+
| **+ digit-flanked, scoped to a chord token** ← chosen | **0** | **0** |
|
|
259
|
+
|
|
260
|
+
The token-scoped version leaves the dictionary baseline byte-identical (same 7 words), stays idempotent, preserves all-or-nothing (`C##` → `C𝄪`, no stray `#`), and is ReDoS-free: 2.6ms on `"A" + "b" * 40_000`, 2.7ms on 250KB of prose. Note this also *strengthens* prose safety — the token boundary is a second line of defense behind the per-family guards, not a replacement for them.
|
|
261
|
+
|
|
262
|
+
Scale degrees (`b7`) and Roman numerals (`♭VI`) still stay downstream, and fall out for free: neither opens with an uppercase letter name, so neither enters a token.
|
|
263
|
+
|
|
264
|
+
**3. No `♮` in either direction.**
|
|
265
|
+
|
|
266
|
+
`to_unicode` *cannot* emit it: natural's `ascii` is `""`, filtered out of the map, so no token could trigger it. `to_ascii` leaves a literal `♮` untouched, because mapping `♮` → `""` is **lossy** — `Spelling.get("C♮").to_s == "C♮"` and `Pitch.get("C♮4").to_s == "C♮4"`, so `to_unicode(to_ascii("C♮"))` would yield `"C"` and destroy information the gem models. ABC distinguishes them too (`=C` vs `C`). Consequence to document: `to_ascii` is "ASCII for *altering* accidentals," not "pure ASCII."
|
|
267
|
+
|
|
268
|
+
**4. `##` via YAML second record + longest-first sort + widened `representations`.**
|
|
269
|
+
|
|
270
|
+
All three are required; each alone is insufficient. Sorting rather than reordering the YAML is deliberate — reordering would silently change `Alteration.all` and `ALTERATION_IDENTIFIERS` ordering, whereas the sort touches only the regex.
|
|
271
|
+
|
|
272
|
+
**5. Forward anchored, reverse unanchored.**
|
|
273
|
+
|
|
274
|
+
Unicode glyphs are unambiguous, so the reverse direction must have no letter-anchor, or `♭VI` and `♭7` — exactly what bardtheory feeds it — stop converting and the downstream consolidation fails.
|
|
275
|
+
|
|
276
|
+
### Recommended public API
|
|
277
|
+
|
|
278
|
+
```ruby
|
|
279
|
+
# lib/head_music/utilities/accidentals.rb
|
|
280
|
+
module HeadMusic::Utilities; end
|
|
281
|
+
|
|
282
|
+
# Converts accidentals in a string between ASCII spellings and canonical Unicode
|
|
283
|
+
# glyphs. Safe to run over prose: conversion happens only inside a token that opens
|
|
284
|
+
# with an uppercase letter name, so "Above", "Bebop", and "1.0b2" are untouched
|
|
285
|
+
# while "Bb", "Ab7", and "Bbmaj7#11" convert.
|
|
286
|
+
#
|
|
287
|
+
# Maps are derived from Alteration lazily rather than at load time, so this file can
|
|
288
|
+
# be required with the other utilities, before rudiments. Eager derivation is not an
|
|
289
|
+
# option anywhere in the load sequence: Alteration.all instantiates
|
|
290
|
+
# Notation::MusicalSymbol, which is not required until much later.
|
|
291
|
+
class HeadMusic::Utilities::Accidentals
|
|
292
|
+
# A candidate chord symbol: an uppercase letter name that doesn't continue a longer
|
|
293
|
+
# word or number, plus the chord-ish characters that may follow. One quantifier over
|
|
294
|
+
# a character class -- no nesting, so no ReDoS surface. This is what scopes the
|
|
295
|
+
# extension rule below: "1.0b2" and "0x1B2F" never open a token, so they can't reach it.
|
|
296
|
+
CHORD_TOKEN = /(?<![A-Za-z0-9])[A-G][A-Za-z0-9#]*/
|
|
297
|
+
|
|
298
|
+
# "Bb" => "B♭", "C##" => "C𝄪", "Bbmaj7#11" => "B♭maj7♯11". Idempotent.
|
|
299
|
+
def self.to_unicode(text)
|
|
300
|
+
string = text.to_s
|
|
301
|
+
return unicode_for.fetch(string) if unicode_for.key?(string)
|
|
302
|
+
|
|
303
|
+
string.gsub(CHORD_TOKEN) do |token|
|
|
304
|
+
token.gsub(ascii_matcher) { |match| unicode_for.fetch(match) }
|
|
305
|
+
end
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
# "B♭" => "Bb", "C𝄪" => "Cx". Emits the spellings the gem's parsers accept.
|
|
309
|
+
# Deliberately NOT letter-anchored: unicode glyphs are unambiguous, and "♭VI"
|
|
310
|
+
# must convert. A natural sign is left as "♮" -- mapping it to "" would turn the
|
|
311
|
+
# valid spelling "C♮" into the different spelling "C". Idempotent.
|
|
312
|
+
def self.to_ascii(text)
|
|
313
|
+
text.to_s.gsub(unicode_matcher) { |match| ascii_for.fetch(match) }
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
def self.unicode_for
|
|
317
|
+
@unicode_for ||=
|
|
318
|
+
altering_alterations.flat_map(&:musical_symbols)
|
|
319
|
+
.reject { |symbol| symbol.ascii.to_s.empty? }
|
|
320
|
+
.to_h { |symbol| [symbol.ascii, symbol.unicode] }
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
# One entry per alteration, using its primary #ascii, so "##" is never emitted.
|
|
324
|
+
def self.ascii_for
|
|
325
|
+
@ascii_for ||= altering_alterations.to_h { |alteration| [alteration.unicode, alteration.ascii] }
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
# Chord qualities that may follow a flat without a word boundary, so that "Bbm"
|
|
329
|
+
# and "Ebmaj7" convert while "Above" does not. "m" subsumes "maj", "min", and
|
|
330
|
+
# "m7"; uppercase forms like "BbM7" already clear the (?![a-z]) guard unaided.
|
|
331
|
+
# Mode words are excluded on purpose -- space-free mode syntax ("Ebdor") is ABC
|
|
332
|
+
# input, which KEY_PATTERN handles, and "ion" alone costs five false positives.
|
|
333
|
+
CHORD_QUALITIES = %w[m dim aug sus].freeze
|
|
334
|
+
|
|
335
|
+
# Applied only inside a CHORD_TOKEN. Longest-first so a double never half-converts.
|
|
336
|
+
# No /i flag -- case-insensitivity would read "B7" as "♭7".
|
|
337
|
+
#
|
|
338
|
+
# Two anchor contexts. The first is the pitch name's own accidental, anchored to the
|
|
339
|
+
# letter name, with a per-family guard: "#" cannot occur in an English word at all,
|
|
340
|
+
# "b" can but is rescued by the chord-quality allowlist, and "x" takes the bare guard
|
|
341
|
+
# because an allowlist there buys "Cxm7" and costs "Axminster"/"Exmoor". The second is
|
|
342
|
+
# an altered extension ("7#11", "7b9"), which has no letter to anchor to and is instead
|
|
343
|
+
# flanked by digits -- safe only because CHORD_TOKEN already established we are inside
|
|
344
|
+
# a chord symbol.
|
|
345
|
+
def self.ascii_matcher
|
|
346
|
+
@ascii_matcher ||=
|
|
347
|
+
/(?<=[A-G])(?:#{sharp_branch}|#{flat_branch}|#{double_sharp_branch})|#{extension_branch}/
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
def self.extension_branch
|
|
351
|
+
"(?<=\\d)(?:#{longest_first(unicode_for.keys.grep(/[#b]/))})(?=\\d)"
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
def self.unicode_matcher
|
|
355
|
+
@unicode_matcher ||= Regexp.union(ascii_for.keys)
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
def self.sharp_branch
|
|
359
|
+
longest_first(spellings_matching(/#/))
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
def self.flat_branch
|
|
363
|
+
"(?:#{longest_first(spellings_matching(/b/))})(?:(?![a-z])|(?=#{longest_first(CHORD_QUALITIES)}))"
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
def self.double_sharp_branch
|
|
367
|
+
"(?:#{longest_first(spellings_matching(/x/))})(?![a-z])"
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
def self.spellings_matching(pattern)
|
|
371
|
+
unicode_for.keys.grep(pattern)
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
def self.longest_first(spellings)
|
|
375
|
+
Regexp.union(spellings.sort_by { |spelling| -spelling.length }).source
|
|
376
|
+
end
|
|
377
|
+
private_class_method :sharp_branch, :flat_branch, :double_sharp_branch,
|
|
378
|
+
:extension_branch, :spellings_matching, :longest_first
|
|
379
|
+
|
|
380
|
+
def self.altering_alterations
|
|
381
|
+
HeadMusic::Rudiment::Alteration.all.reject(&:natural?)
|
|
382
|
+
end
|
|
383
|
+
private_class_method :altering_alterations
|
|
384
|
+
end
|
|
385
|
+
```
|
|
386
|
+
|
|
387
|
+
The bare-token fast path (`unicode_for.fetch`) handles `"b"` → `"♭"` and `"##"` → `"𝄪"` without widening the regex, because a context-free `b` in prose is exactly the false positive the lookbehind exists to prevent. `to_ascii(to_unicode("C##")) == "Cx"` — the normalizing round trip the story asks for.
|
|
388
|
+
|
|
389
|
+
**Why two levels of `gsub` rather than one flat pattern.** The altered-extension rule (`7#11`, `7b9`) can't anchor to a letter name — the accidental is flanked by digits. Applied globally that rule is unsafe: it converts `1.0b2` (a beta version), `0x1b2f` (hex), and `revision 2b1`. Scoping it inside `CHORD_TOKEN` removes the entire class of false positive, because none of those strings opens a token with an uppercase letter name. Measured: the naive digit-flanked rule produces 5 false positives on a 29-case adversarial list; the token-scoped version produces 0, and leaves the 236k-word dictionary baseline byte-identical at the same 7 words.
|
|
390
|
+
|
|
391
|
+
### Steps
|
|
392
|
+
|
|
393
|
+
**1. Characterization specs — land first, green on unmodified code**
|
|
394
|
+
|
|
395
|
+
- **ABC no-Unicode guard** (highest value; ABC consumers reject Unicode). Add across the existing round-trip examples in `abc/writer_spec.rb`, `key_mapper_spec.rb`, `pitch_writer_spec.rb`: `expect(described_class.abc_value(key_signature)).not_to match(/[♭♯♮𝄫𝄪]/)` and the equivalent for `writer.token(pitch)` and the whole document. No such guard exists today.
|
|
396
|
+
- **`key_signature_spec.rb`** — the hash key is private, so pin the observable: memoization identity (`.get("Bb major")` equals itself), `.get("Bb major") == .get("B♭ major")`, `.get("Bbb major").name == "B𝄫 major"`, `.get("Fx major").name == "F𝄪 major"`.
|
|
397
|
+
- **`scale_spec.rb`** — same shape, plus `.get("Bb4","major").pitch_names.first == "B♭4"`.
|
|
398
|
+
- **`dyad_spec.rb`** — pin **exact counts**: `all_spellings.size == 28`, `Dyad.new("C4","E4").enharmonic_respellings.size == 5`. Existing assertions are `be > 0` / `be_an(Array)`, so the coming growth passes silently.
|
|
399
|
+
- **`alteration_spec.rb`** — pin `Alteration.get(:double_sharp).ascii == "x"`, the invariant `PitchWriter` silently depends on.
|
|
400
|
+
- **`instrument_name_spec.rb`** — extend the existing `"in B♭"` (line 12) and `"in F♯"` (line 17).
|
|
401
|
+
|
|
402
|
+
**2. Add the utility**
|
|
403
|
+
|
|
404
|
+
`lib/head_music/utilities/accidentals.rb` + `require` at `lib/head_music.rb:57` + `spec/head_music/utilities/accidentals_spec.rb`. No consumers yet; independently mergeable. Include a spec that the utility works on a cold `require "head_music"` with no prior `Alteration` use.
|
|
405
|
+
|
|
406
|
+
**3. Widen `##`**
|
|
407
|
+
|
|
408
|
+
`alterations.yml`: second `symbols:` record under `double_sharp` (`ascii: "##"`, keeping the `x` record **first**). `alteration.rb:18`: append `.uniq.sort_by { |s| -s.length }` (the `.uniq` is load-bearing — the YAML record otherwise duplicates `𝄪`). `alteration.rb:56-59`: memoize and widen `representations` to span all `musical_symbols`, or the new row is inert and `Spelling.get("C##")` returns a bare `C` — worse than today's honest `nil`. The memoization is reported as a 10x win (200k calls: `get(nil)` 3.55s → 0.38s, `get("#")` 2.68s → 0.16s), and `get` runs on every parse. `alteration.rb:26-28`: align `self.symbols` with `SYMBOLS` or delete it — it currently disagrees with `symbol?` and `PATTERN` and has zero callers. Replace `alteration_spec.rb:101-102`'s literal-regex assertions with behavioral ones (`expect("C##"[PATTERN]).to eq "##"`).
|
|
409
|
+
|
|
410
|
+
**4. Fix `rudiment/note.rb:20`**
|
|
411
|
+
|
|
412
|
+
`PITCH_PATTERN = /([A-G])(#{Alteration::PATTERN.source}?)(-?\d+)?/i` binds the `?` to the final `x` branch only; it works today by accident (the group can match empty only via `x?`). Change to `((?:#{...})?)`. Keep exactly 3 capture groups (`note.rb:38` reads `match[5]`); `Regexp.union` adds none (it emits `(?-mix:…)`), so `register.rb:30-34` stays safe.
|
|
413
|
+
|
|
414
|
+
Verified: `"C##4".match(PITCH_PATTERN).captures` is currently `["C", "#", nil]` — it truncates at the first `#` and loses the register entirely. The longest-first sort in step 3 fixes this case on its own, but the `?` binding is wrong regardless and should be corrected while here.
|
|
415
|
+
|
|
416
|
+
**4a. Full list of `Alteration::PATTERN` consumers** (grep-verified; step 3 widens the pattern under all of them)
|
|
417
|
+
|
|
418
|
+
- `rudiment/spelling.rb:11` — `MATCHER`, embeds it with `/i`. The primary target.
|
|
419
|
+
- `rudiment/note.rb:20` — `PITCH_PATTERN`, the `?`-binding bug above.
|
|
420
|
+
- `rudiment/pitch/parser.rb:12` — `(#{Alteration::PATTERN.source})?`. The `?` is correctly *outside* the group here, so this one is already right; no change needed, but it's the counter-example that makes the `note.rb` form recognizable as a mistake.
|
|
421
|
+
- `rudiment/pitch.rb:126` — `Pitch#natural`, `to_s.gsub(PATTERN, "")`. Widening is a strict improvement: it currently strips `C##4` to `C4` only because `#` matches twice; with `##` in the union it strips as one token. Verified `Pitch.get("C#4").natural.to_s == "C4"` today.
|
|
422
|
+
- `rudiment/register.rb:30-34` — consumes `Spelling::MATCHER`'s captures positionally; group count must not change.
|
|
423
|
+
|
|
424
|
+
**5. `instrument_name.rb:46-48`**
|
|
425
|
+
|
|
426
|
+
`format_pitch_name` becomes `Accidentals.to_unicode(pitch_designation)`; delete the `tr` chain. Spec-only impact: no catalog instrument uses a double (`pitch_key` values are `a a_flat b_flat c d e_flat f g`).
|
|
427
|
+
|
|
428
|
+
**6. `key_signature.rb:21`**
|
|
429
|
+
|
|
430
|
+
`HashKey.for(Accidentals.to_unicode(identifier))`; delete both `gsub`s. Differential over 33 inputs — 27 identical, 6 changed, all improvements:
|
|
431
|
+
|
|
432
|
+
| identifier | old | new |
|
|
433
|
+
| --- | --- | --- |
|
|
434
|
+
| `"Bbb major"` | `:b_flatb_major` | `:b_double_flat_major` |
|
|
435
|
+
| `"Fx major"` / `"Cx minor"` | `:fx_major` / `:cx_minor` | `:f_double_sharp_major` / `:c_double_sharp_minor` |
|
|
436
|
+
| `"C## major"` | `:c_sharp_sharp_major` | `:c_double_sharp_major` |
|
|
437
|
+
| **`"C bebop"`** | **`:c_be_flatop`** | `:c_bebop` |
|
|
438
|
+
| `"bb major"` | `:b_flat_major` *(collides)* | `:bb_major` |
|
|
439
|
+
|
|
440
|
+
The first four *unify* `Fx`/`C##`/`F𝄪` onto one cache entry — correct, they are the same key signature.
|
|
441
|
+
|
|
442
|
+
**Correction found during implementation:** the `"C bebop"` → `:c_be_flatop` mangling is real in the gsub but **unreachable through the public API** — `bebop` is not a scale type this gem defines, so `KeySignature.get("C bebop")` raises in `ScaleType` before the hash key matters. The `blues_*` scale types that *do* exist are unaffected, because `(\w)[b♭]` requires a word character before the `b` and theirs is preceded by a space. The spec written for this pins the reachable case (`C blues_minor_pentatonic`) instead.
|
|
443
|
+
|
|
444
|
+
**7. `scale.rb:12-14`**
|
|
445
|
+
|
|
446
|
+
Collapse to `HashKey.for([root_pitch, scale_type].join(" "))`. **No utility call needed:** `Pitch#to_s` already emits Unicode and `HashKey` already desymbolizes it, so the whole chain is dead (`\w` doesn't even match the astral `𝄫`/`𝄪`). Measured: `:bflat4_major` → `:b_flat4_major`, `:fsharp4_minor` → `:f_sharp4_minor`, no collisions. Also **delete `SCALE_REGEX` (line 6)** — unreferenced in `lib/` and `spec/`, and its `[#b]?` is the same singles-only trap this story closes.
|
|
447
|
+
|
|
448
|
+
**8. `key_mapper.rb:7`**
|
|
449
|
+
|
|
450
|
+
`KEY_PATTERN = /\A([A-G])((?:#{Alteration::PATTERN.source})?)\s*([A-Za-z]*)/`. **The inner `(?:...)?` is load-bearing:** writing `(...)?` makes `match[2]` `nil` and raises `TypeError: no implicit conversion of nil into String` at line 91 for every *unaltered* key — it breaks `C`, `Ador`, `Cmaj`, `Dm`. No mode word begins with `b`, `#`, or `x`, so the widened group cannot eat one (`Bloc`/`Blocrian` checked).
|
|
451
|
+
|
|
452
|
+
**9. `key_mapper.rb:53`**
|
|
453
|
+
|
|
454
|
+
`Accidentals.to_ascii(spelling.to_s)`. Keep the double-altered `raise` at lines 47-49 **above** it; ABC `K:` genuinely cannot express doubles, and `to_ascii` would happily emit `Cx`.
|
|
455
|
+
|
|
456
|
+
**10. `hash_key.rb:32-40`**
|
|
457
|
+
|
|
458
|
+
Reorder doubles before singles and add `"##" => "_double_sharp"` *before* `"#" => "_sharp"` (today the line-38 `#` rule means any appended `##` rule can never fire). Do **not** add ASCII `b` → `_flat`; it would mangle `blues_major_pentatonic`, `bass_clarinet`, and every other key containing `b`. Tidying, not behavior — after step 6, `##` no longer reaches `HashKey` from `KeySignature`.
|
|
459
|
+
|
|
460
|
+
**11. Leave `pitch_writer.rb:52-57` alone**
|
|
461
|
+
|
|
462
|
+
Recorded here so it isn't mistaken for an oversight. `pitch.alteration&.ascii.to_s` is not a conversion chain; it is a lookup key into `ACCIDENTAL_FRAGMENTS.invert` (`{"#"=>"^","x"=>"^^","b"=>"_","bb"=>"__",""=>"="}`), and `#ascii` still returns `"x"` after the YAML change. Substituting `to_ascii` produces identical strings with more indirection.
|
|
463
|
+
|
|
464
|
+
**12. Record the `Dyad` expansion**
|
|
465
|
+
|
|
466
|
+
`dyad.rb:80` sets `ALTERATION_SIGNS[2] = "##"`, builds `"C##"` spellings that `Spelling.get` returns `nil` for, and `.compact`s them away, so double sharps are **absent from the gem's enharmonic universe today**. After step 3: `all_spellings` 28 → 35, `enharmonic_respellings` for C4/E4 5 → 8. Spec comments at `dyad_spec.rb:322` and `:420` name `C##` as an expected-but-missing equivalent, suggesting this is the latent intent. Update the step-1 counts; don't suppress it.
|
|
467
|
+
|
|
468
|
+
**13. Ship as a major version**
|
|
469
|
+
|
|
470
|
+
`bundle exec rubocop -a`, `bundle exec rake validate`, bump `lib/head_music/version.rb` from `17.5.0` to **`18.0.0`** (decided 2026-07-26 — this is a breaking release, not a feature release).
|
|
471
|
+
|
|
472
|
+
The `### Added` entry covers the utility and `##` support, but the CHANGELOG needs a **`### Changed` / `### Breaking`** section that names each behavior change a consumer could be pinned to:
|
|
473
|
+
|
|
474
|
+
- `Spelling.get("C##")` returns a `Spelling` instead of `nil`. Anything using that `nil` as a validation signal now silently accepts double sharps. Same for `Pitch.get`, `Note.get`, and ABC `K:` fields.
|
|
475
|
+
- `Alteration::PATTERN` and `SYMBOLS` gain `##` and reorder longest-first. Anything embedding them in its own regex changes behavior.
|
|
476
|
+
- `KeySignature` hash keys change for six inputs (step 6 table), including the `Fx`/`C##`/`F𝄪` unification onto one cache entry.
|
|
477
|
+
- `Scale` hash keys change (`:bflat4_major` → `:b_flat4_major`).
|
|
478
|
+
- `Dyad#all_spellings` grows 28 → 35 and `#enharmonic_respellings` grows correspondingly, as double sharps enter the enharmonic universe for the first time.
|
|
479
|
+
- `Scale::SCALE_REGEX` is removed (unreferenced, but public by Ruby's rules).
|
|
480
|
+
- `Alteration.symbols` is aligned or removed.
|
|
481
|
+
|
|
482
|
+
### Testing Strategy
|
|
483
|
+
|
|
484
|
+
**Prose corpus** — all verified against the three-branch matcher:
|
|
485
|
+
|
|
486
|
+
| Converts | | Left alone | |
|
|
487
|
+
| --- | --- | --- | --- |
|
|
488
|
+
| `F#m` → `F♯m` | `Bbm` → `B♭m` | `Above`, `Absence`, `Abbey` | `B7` (the `/i` trap) |
|
|
489
|
+
| `A#m` → `A♯m` | `Ebmaj7` → `E♭maj7` | `Bebop`, `ebb`, `back` | `DB`, `Text`, `Exit` |
|
|
490
|
+
| `C#maj7` → `C♯maj7` | `Abmin` → `A♭min` | `subbass`, `cabbage` | `## Heading` (Markdown) |
|
|
491
|
+
| `F#dim` → `F♯dim` | `Bbsus4`, `Ebdim7`, `Abaug` | `Bach`, `Debussy` | `C4 quarter`, `Gb` |
|
|
492
|
+
| `Ab7` → `A♭7` | `BbM7` → `B♭M7` | `Absurd`, `Abdomen`, `Abdicate` | `Ebbing`, `Abbot`, `Adagio` |
|
|
493
|
+
| `Bb`/`Bbb`/`Cx`/`C##` | bare `b`/`#`/`bb`/`##`/`x` | `Abstract`, `Aberrant`, `Ebony` | `Fabric`, `Dabble`, `Abundant` |
|
|
494
|
+
| `Bbmaj7#11` → `B♭maj7♯11` | `C7b9` → `C7♭9` | `1.0b2`, `0x1b2f`, `0x1B2F` | `version 3b7`, `revision 2b1` |
|
|
495
|
+
| `G7#5b9` → `G7♯5♭9` | `Db7b9#11` → `D♭7♭9♯11` | `Figure 2b`, `Chapter 7b` | `1b2c3d`, `Deadbeef`, `DEADBEEF` |
|
|
496
|
+
|
|
497
|
+
The `Absurd`/`Abdomen`/`Abdicate` column is not decoration — those are the near-misses the four-entry allowlist survives by one letter each (`sur` vs `sus`, `dom` vs `dim`, `dic` vs `dim`). Any future addition to `CHORD_QUALITIES` must be re-measured against the dictionary, not eyeballed.
|
|
498
|
+
|
|
499
|
+
**Dictionary sweep as a spec.** Guard the allowlist with a test that runs the matcher over `/usr/share/dict/words` (capitalized) and asserts the false-positive set is exactly `%w[Ab Abb Ax Ebb Ex Abmho Ebbman]` — five pre-existing, two added. This turns "we measured it once" into a regression test. Skip gracefully when the file is absent, since it is not present on all CI images.
|
|
500
|
+
|
|
501
|
+
Known limitations to pin honestly: **`Ebb and flow` → `E𝄫 and flow`**, **`Ex` → `E𝄪`**, and the two dictionary words above. `x` cannot leave the ASCII set without breaking round-trip idempotence; all are bounded by the uppercase lookbehind.
|
|
502
|
+
|
|
503
|
+
**Altered extensions are in scope** and must convert whole: `Bbmaj7#11` → `B♭maj7♯11`, not `B♭maj7#11`. Half-converting a chord symbol is the same failure mode as half-converting a double — it looks converted. Pin `G7#5b9` and `Db7b9#11` specifically, since they exercise the extension rule twice in one token.
|
|
504
|
+
|
|
505
|
+
**Hostile-input corpus for the token scope** — these must all survive untouched, and they are the reason the extension rule is token-scoped rather than global: `1.0b2`, `0x1b2f`, `0x1B2F`, `1b2c3d`, `version 3b7`, `revision 2b1`, `Figure 2b`, `Chapter 7b`, `Deadbeef`, `DEADBEEF`.
|
|
506
|
+
|
|
507
|
+
**Real-world symbols, all verified against the design:**
|
|
508
|
+
|
|
509
|
+
| input | output |
|
|
510
|
+
| --- | --- |
|
|
511
|
+
| `Bb/D` | `B♭/D` (slash chords split into two tokens; both sides convert) |
|
|
512
|
+
| `F#m7b5/Eb` | `F♯m7♭5/E♭` |
|
|
513
|
+
| `Bbmaj7#11/D` | `B♭maj7♯11/D` |
|
|
514
|
+
| `Ebmaj9#11` | `E♭maj9♯11` |
|
|
515
|
+
| `Ab13b9` | `A♭13♭9` |
|
|
516
|
+
| `Gb7sus4` | `G♭7sus4` |
|
|
517
|
+
| `C7alt` | `C7alt` (unchanged — nothing to convert) |
|
|
518
|
+
|
|
519
|
+
The single best regression case, because it exercises prose safety and both conversion rules in one line:
|
|
520
|
+
|
|
521
|
+
```ruby
|
|
522
|
+
Accidentals.to_unicode("Above the Bbm7 we play Ebmaj7.")
|
|
523
|
+
# => "Above the B♭m7 we play E♭maj7."
|
|
524
|
+
```
|
|
525
|
+
|
|
526
|
+
`Above` survives, `Bbm7` converts on the chord-quality allowlist, `Ebmaj7` likewise — and the word that breaks the naive implementation sits directly next to the chord that breaks the guard-only one.
|
|
527
|
+
|
|
528
|
+
Also required: idempotency both directions; all-or-nothing (`expect(to_unicode("C##")).not_to include("#")`); `to_ascii` round-trip over all 28 spellings reparsing to the identical `Spelling`; `Accidentals.ascii_for["𝄪"] == "x"` (guards the `musical_symbols.first` dependency); `Note.get("C4 quarter")`/`("C##4 quarter")`/`("Cx4 quarter")`; `Pitch.get("C##4").natural.to_s == "C4"`; `Spelling.get("CX")` still `nil`; a cold-require spec. Run the full suite after each step against the 6097-example baseline; coverage ≥90%.
|
|
529
|
+
|
|
530
|
+
**Checked, not a risk:** ReDoS — flat alternation of literals, no nesting, no quantifier over a group; `"A" + "b"*40_000` in 1.5ms, 245KB prose in 9ms. Astral handling is clean (Ruby strings are codepoint-based), with one trap: `𝄫`/`𝄪` are non-word chars, so `/𝄫\b/` **matches** `"C𝄫x"` — independent confirmation that `\b` was the wrong guard.
|
|
531
|
+
|
|
532
|
+
### Risks / watch out for
|
|
533
|
+
|
|
534
|
+
- **`KEY_PATTERN`'s inner `(?:...)?`** — get this wrong and every *unaltered* ABC key raises `TypeError`. The failure is in the common path, not the new one.
|
|
535
|
+
- **The uniform `(?![a-z])` guard** — stated as settled in the story; applied uniformly it silently refuses `F#m`, and guard-only (without the allowlist) silently refuses `Bbm` and `Ebmaj7`. Both failure modes are silent no-ops, which is exactly the "looks converted" trap the story warns about, one level up.
|
|
536
|
+
- **`CHORD_QUALITIES` is measured, not intuited.** `sus` clears `Absurd` by one letter and `dim` clears `Abdomen`/`Abdicate` by one. Adding an entry without re-running the dictionary sweep is how this regresses.
|
|
537
|
+
- **`CHORD_TOKEN` is load-bearing for safety, not just tidiness.** The extension rule is only safe *because* it runs inside the token. Anyone who later "simplifies" `to_unicode` into a single flat `gsub` reintroduces `1.0b2` → `1.0♭2` and hex corruption. The two-level `gsub` needs a comment saying so — it is written in the API sketch above; keep it.
|
|
538
|
+
- **`Regexp.union` is not longest-match.** It alternates in array order, so every branch builder sorts longest-first. `CHORD_TOKEN`'s trailing `[A-Za-z0-9#]*` deliberately excludes `b`-as-a-terminator concerns by including `b` in the alphanumeric class — verify the token still ends where you expect on `Bb/D` slash chords (it splits into two tokens, which is correct).
|
|
539
|
+
- **`representations` must widen with the YAML** — otherwise `Spelling.get("C##")` returns a bare `C`, silently.
|
|
540
|
+
- **`ascii_for` depends on `musical_symbols.first`.** Reorder the `double_sharp` YAML records and `to_ascii` starts emitting `C##`, which `Pitch.get` accepts but ABC's `ACCIDENTAL_FRAGMENTS` does not — a failure with no visible link to a YAML edit.
|
|
541
|
+
- **The `Dyad` growth is invisible to the suite.** Loose assertions mean it passes silently; it is the largest behavioral change in the story and the story never mentions it.
|
|
542
|
+
- **`##` becomes valid input everywhere** — `Pitch::Parser`, `Note`, `KeySignature`, ABC `K:` fields. Downstream code treating `Spelling.get("C##") == nil` as a validation signal now gets a spelling. This is the breaking-change surface, and the reason step 13 ships `18.0.0` rather than a minor bump.
|
|
543
|
+
- **Pre-existing, out of scope, do not accidentally "fix":** `KeySignature.get("D flat major")` and `("Db major")` share hash key `:d_flat_major` under both old and new schemes; the former raises `NoMethodError` if called *first* and returns D♭ major if called second. The characterization specs will lock this order-dependence in — flag it as known.
|
|
544
|
+
- **Downstream needs a release, not a path gem.** bardtheory's story is blocked on a published `18.0.0`, and must budget for the major-version upgrade rather than a drop-in bump. The unanchored reverse direction is what makes its consolidation possible; its `pivot_chord_service.rb:127` omits `𝄪` and is fixed for free. Roman-numeral (`♭VI`) and scale-degree (`♭7`) rules **must stay downstream** — head_music models neither. The audit found **17** independent implementations across the two repos, not the four or five the story estimates, plus a third direction (accidental → spoken words, 4 sites) that `Alteration#name` already serves with i18n — worth a follow-up story.
|
|
545
|
+
|
|
546
|
+
### Open questions
|
|
547
|
+
|
|
548
|
+
1. ~~**The `F#m` / `Bbm` asymmetry, and altered extensions.**~~ **RESOLVED 2026-07-26 — fully closed, nothing outstanding.** `F#m`, `Bbm`, and `Ebmaj7` convert via the four-entry `CHORD_QUALITIES` allowlist (decision 2); `Bbmaj7#11`, `C7b9`, and friends convert via the token-scoped extension rule (decision 2a). Both measured. Scale degrees and Roman numerals remain downstream, which the token scope enforces structurally rather than by convention.
|
|
549
|
+
2. **Should `KEY_PATTERN` accept doubles at all,** given `abc_value` raises on rendering them? Widening buys symmetric parse/render errors but lets a field other ABC tools reject through the front door. Planned: widen, for the single-source-of-truth goal.
|
|
550
|
+
3. **Residue detection in the gem?** `to_unicode("Bbbb")` returns the input unchanged — correct all-or-nothing, but *silently*, and silence is indistinguishable from "nothing to convert." Planned: keep the utility pure `String -> String`, leave detection downstream; a separate predicate is far cheaper than changing the return type.
|
|
551
|
+
4. **`Dyad`'s 28 → 35 expansion** — intended, or does `all_spellings` want a "practical spellings" filter? The spec comments suggest someone already wondered and settled for a loose assertion.
|
|
552
|
+
5. **`to_ascii` output is not pure ASCII** (retains `♮`). Acceptable, or add a separate affordance for `C♮` → `C`? `Pitch#natural` already does the pitch-object version.
|
data/user-stories/index.html
CHANGED
|
@@ -277,6 +277,10 @@
|
|
|
277
277
|
],
|
|
278
278
|
"active": [],
|
|
279
279
|
"done": [
|
|
280
|
+
{
|
|
281
|
+
"title": "Accidental String Normalization",
|
|
282
|
+
"path": "done/accidental-string-normalization.md"
|
|
283
|
+
},
|
|
280
284
|
{
|
|
281
285
|
"title": "Lyrics",
|
|
282
286
|
"path": "done/lyrics.md"
|
|
@@ -403,15 +407,15 @@
|
|
|
403
407
|
},
|
|
404
408
|
{
|
|
405
409
|
"title": "Untitled",
|
|
406
|
-
"path": "done/
|
|
410
|
+
"path": "done/expand-playing-techniques.md"
|
|
407
411
|
},
|
|
408
412
|
{
|
|
409
413
|
"title": "Untitled",
|
|
410
|
-
"path": "done/
|
|
414
|
+
"path": "done/instrument-variant.md"
|
|
411
415
|
},
|
|
412
416
|
{
|
|
413
417
|
"title": "Untitled",
|
|
414
|
-
"path": "done/
|
|
418
|
+
"path": "done/superclass-for-note.md"
|
|
415
419
|
}
|
|
416
420
|
]
|
|
417
421
|
};
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: head_music
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version:
|
|
4
|
+
version: 18.0.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Rob Head
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-07-
|
|
11
|
+
date: 2026-07-27 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: activesupport
|
|
@@ -445,6 +445,7 @@ files:
|
|
|
445
445
|
- lib/head_music/time/smpte_timecode.rb
|
|
446
446
|
- lib/head_music/time/tempo_event.rb
|
|
447
447
|
- lib/head_music/time/tempo_map.rb
|
|
448
|
+
- lib/head_music/utilities/accidentals.rb
|
|
448
449
|
- lib/head_music/utilities/case.rb
|
|
449
450
|
- lib/head_music/utilities/hash_key.rb
|
|
450
451
|
- lib/head_music/value_equality.rb
|
|
@@ -464,6 +465,7 @@ files:
|
|
|
464
465
|
- user-stories/done/abc-notation-export.md
|
|
465
466
|
- user-stories/done/abc-notation-interpreter.md
|
|
466
467
|
- user-stories/done/abc-tie-input.md
|
|
468
|
+
- user-stories/done/accidental-string-normalization.md
|
|
467
469
|
- user-stories/done/chord-placement-model.md
|
|
468
470
|
- user-stories/done/composition-serialization.md
|
|
469
471
|
- user-stories/done/configurable-large-leap-recovery.md
|