head_music 17.4.0 → 18.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +3 -3
  3. data/.github/workflows/release.yml +1 -1
  4. data/.github/workflows/security.yml +2 -1
  5. data/.rubocop.yml +6 -0
  6. data/CHANGELOG.md +34 -0
  7. data/CLAUDE.md +20 -0
  8. data/Gemfile.lock +68 -68
  9. data/head_music.gemspec +1 -1
  10. data/lib/head_music/analysis/pitch_collection.rb +2 -2
  11. data/lib/head_music/content/composition/hash_deserializer.rb +3 -0
  12. data/lib/head_music/content/composition/schema_values.rb +40 -0
  13. data/lib/head_music/content/placement.rb +30 -1
  14. data/lib/head_music/content/staff.rb +3 -5
  15. data/lib/head_music/content/syllable.rb +38 -0
  16. data/lib/head_music/content/voice.rb +1 -1
  17. data/lib/head_music/instruments/instrument_name.rb +1 -1
  18. data/lib/head_music/notation/abc/key_mapper.rb +24 -4
  19. data/lib/head_music/notation/music_xml/beam_grouper.rb +2 -2
  20. data/lib/head_music/notation/music_xml/duration_writer.rb +1 -1
  21. data/lib/head_music/notation/music_xml/writer.rb +46 -0
  22. data/lib/head_music/rudiment/alteration.rb +20 -5
  23. data/lib/head_music/rudiment/alterations.yml +6 -0
  24. data/lib/head_music/rudiment/key_signature.rb +1 -1
  25. data/lib/head_music/rudiment/note.rb +6 -2
  26. data/lib/head_music/rudiment/scale.rb +3 -5
  27. data/lib/head_music/style/guidelines/notes_same_length.rb +1 -1
  28. data/lib/head_music/time/musical_position.rb +1 -13
  29. data/lib/head_music/time/radix_carry.rb +22 -0
  30. data/lib/head_music/time/smpte_timecode.rb +1 -13
  31. data/lib/head_music/time.rb +1 -0
  32. data/lib/head_music/utilities/accidentals.rb +137 -0
  33. data/lib/head_music/utilities/hash_key.rb +44 -29
  34. data/lib/head_music/version.rb +1 -1
  35. data/lib/head_music.rb +10 -8
  36. data/user-stories/done/accidental-string-normalization.md +552 -0
  37. data/user-stories/done/lyrics.md +162 -0
  38. data/user-stories/index.html +8 -0
  39. metadata +17 -6
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4d075fb623ea50351c7ac422f44be04c265867253312b6b27faffaf76bb3b397
4
- data.tar.gz: 5649553e987c0a98cd9388d24d6e15155f88da4e59eaf62f7a2d9f988e656b36
3
+ metadata.gz: 8a16868734e2fac408461d2202fcbc84be2a796a68371a81a7d2ad5bbeef516b
4
+ data.tar.gz: 02becc93275629796177a72894d81cc10feca0819847496e60aa3ddc6f5aac00
5
5
  SHA512:
6
- metadata.gz: a89a0dfc44c68e823e5c1eff6c48b950b759cdd059c504adacd8bd6b3d06dc0b23bc1a13b690e68086526a33ab3bebc1617825abecfc3ab108c89425a44d6f55
7
- data.tar.gz: 921ce718ccc4b728ed57dce347fbce73ae7c120960931b32933a5893f52992b4aa40ec115e248421a3f53d3a66defff0b4d7eac15efaf34ae51fae23e5e2a328
6
+ metadata.gz: e3533678a1a2b58558080cad6c03f04fe4b5b205d4413456b728c5ae2e1a598e3a09ebc0329c9f6fde263e84f857eb4571918087afe23a083278facaea7aa460
7
+ data.tar.gz: 3d605bff3a0459e56cddd4911e7699d2b295bf337e7f468094d0f4cc19e55f9b3036c21e59c95263f3bf19c69dfffa99093a8564f036aa625b776199e959e8f4
@@ -16,7 +16,7 @@ jobs:
16
16
  activesupport-version: ['7.2', '8.0']
17
17
 
18
18
  steps:
19
- - uses: actions/checkout@v4
19
+ - uses: actions/checkout@v5
20
20
 
21
21
  - name: Set up Ruby ${{ matrix.ruby-version }}
22
22
  uses: ruby/setup-ruby@v1
@@ -43,7 +43,7 @@ jobs:
43
43
  lint:
44
44
  runs-on: ubuntu-latest
45
45
  steps:
46
- - uses: actions/checkout@v4
46
+ - uses: actions/checkout@v5
47
47
 
48
48
  - name: Set up Ruby
49
49
  uses: ruby/setup-ruby@v1
@@ -57,7 +57,7 @@ jobs:
57
57
  build:
58
58
  runs-on: ubuntu-latest
59
59
  steps:
60
- - uses: actions/checkout@v4
60
+ - uses: actions/checkout@v5
61
61
 
62
62
  - name: Set up Ruby
63
63
  uses: ruby/setup-ruby@v1
@@ -13,7 +13,7 @@ jobs:
13
13
  id-token: write
14
14
 
15
15
  steps:
16
- - uses: actions/checkout@v4
16
+ - uses: actions/checkout@v5
17
17
 
18
18
  - name: Set up Ruby
19
19
  uses: ruby/setup-ruby@v1
@@ -8,13 +8,14 @@ on:
8
8
  schedule:
9
9
  # Run security checks daily at 9 AM UTC
10
10
  - cron: '0 9 * * *'
11
+ workflow_dispatch:
11
12
 
12
13
  jobs:
13
14
  security:
14
15
  runs-on: ubuntu-latest
15
16
 
16
17
  steps:
17
- - uses: actions/checkout@v4
18
+ - uses: actions/checkout@v5
18
19
 
19
20
  - name: Set up Ruby
20
21
  uses: ruby/setup-ruby@v1
data/.rubocop.yml CHANGED
@@ -31,6 +31,12 @@ RSpec/ContextWording:
31
31
  RSpec/MultipleExpectations:
32
32
  Enabled: false
33
33
 
34
+ # Several data-driven specs build examples from collections resolved at
35
+ # definition time (e.g. `legacy.each`, `melodic_guides.each`), which requires
36
+ # those values to live as locals in the example-group scope.
37
+ RSpec/LeakyLocalVariable:
38
+ Enabled: false
39
+
34
40
  RSpec/NestedGroups:
35
41
  Max: 5
36
42
 
data/CHANGELOG.md CHANGED
@@ -7,6 +7,40 @@ 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
+
37
+ ## [17.5.0] - 2026-07-21
38
+
39
+ ### Added
40
+
41
+ - Sung text (lyrics) can be attached to the notes of a voice. `HeadMusic::Content::Placement#sing(text, verse:, hyphen_after:)` assigns a syllable to a placement, keyed by verse so a note carries at most one syllable per verse and any number of verses (`glo` on verse 1, `peace` on verse 2 of the same note). A new immutable `HeadMusic::Content::Syllable` value object stores only the minimal linguistic fact — `text`, `verse`, and a `hyphen_after` boolean marking that the word continues onto the next sung note; the MusicXML `syllabic` value (`single`/`begin`/`middle`/`end`) is derived at render time rather than stored, and a melisma is represented by the absence of a syllable on the held notes rather than a stored flag. Syllables serialize through `Placement#to_h` and round-trip through the composition hash deserializer, validated at the import boundary by `Composition::SchemaValues` (non-empty text, a positive-integer verse, and no duplicate verse per placement).
42
+ - The MusicXML writer emits a `<lyric number="N">` element as the last child of each `<note>`, on the lead note of a chord only and the attack of a tied chain only, deriving `<syllabic>` from the `hyphen_after` booleans of the syllable and its predecessor in the same verse and XML-escaping the text. Held notes of a melisma carry no `<lyric>`, matching MusicXML's continuation-by-absence. ABC `w:` lyric-line input and the MusicXML `<extend/>` melisma line remain out of scope for a future release.
43
+
10
44
  ## [17.4.0] - 2026-07-20
11
45
 
12
46
  ### Changed
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
@@ -1,9 +1,9 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- head_music (17.4.0)
4
+ head_music (18.0.0)
5
5
  activesupport (>= 7.0, < 10)
6
- humanize (~> 2.0)
6
+ humanize (>= 2, < 4)
7
7
  i18n (~> 1.8)
8
8
 
9
9
  GEM
@@ -30,98 +30,100 @@ GEM
30
30
  ice_nine (~> 0.11.0)
31
31
  thread_safe (~> 0.3, >= 0.3.1)
32
32
  base64 (0.3.0)
33
- bigdecimal (3.2.2)
34
- bundler-audit (0.9.2)
35
- bundler (>= 1.2.0, < 3)
33
+ bigdecimal (4.1.2)
34
+ bundler-audit (0.9.3)
35
+ bundler (>= 1.2.0)
36
36
  thor (~> 1.0)
37
37
  childprocess (5.1.0)
38
38
  logger (~> 1.5)
39
39
  coercible (1.0.0)
40
40
  descendants_tracker (~> 0.0.1)
41
- concurrent-ruby (1.3.7)
42
- connection_pool (2.5.3)
41
+ concurrent-ruby (1.3.8)
42
+ connection_pool (3.0.2)
43
43
  descendants_tracker (0.0.4)
44
44
  thread_safe (~> 0.3, >= 0.3.1)
45
45
  diff-lcs (1.6.2)
46
- docile (1.4.1)
47
46
  drb (2.2.3)
48
- dry-configurable (1.3.0)
49
- dry-core (~> 1.1)
47
+ dry-configurable (1.4.0)
48
+ dry-core (~> 1.0)
50
49
  zeitwerk (~> 2.6)
51
- dry-core (1.1.0)
50
+ dry-core (1.2.0)
52
51
  concurrent-ruby (~> 1.0)
53
52
  logger
54
53
  zeitwerk (~> 2.6)
55
- dry-inflector (1.2.0)
54
+ dry-inflector (1.3.1)
56
55
  dry-initializer (3.2.0)
57
56
  dry-logic (1.6.0)
58
57
  bigdecimal
59
58
  concurrent-ruby (~> 1.0)
60
59
  dry-core (~> 1.1)
61
60
  zeitwerk (~> 2.6)
62
- dry-schema (1.14.1)
61
+ dry-schema (1.16.0)
63
62
  concurrent-ruby (~> 1.0)
64
63
  dry-configurable (~> 1.0, >= 1.0.1)
65
64
  dry-core (~> 1.1)
66
65
  dry-initializer (~> 3.2)
67
- dry-logic (~> 1.5)
68
- dry-types (~> 1.8)
66
+ dry-logic (~> 1.6)
67
+ dry-types (~> 1.9, >= 1.9.1)
69
68
  zeitwerk (~> 2.6)
70
- dry-types (1.8.3)
71
- bigdecimal (~> 3.0)
69
+ dry-types (1.9.1)
70
+ bigdecimal (>= 3.0)
72
71
  concurrent-ruby (~> 1.0)
73
72
  dry-core (~> 1.0)
74
73
  dry-inflector (~> 1.0)
75
74
  dry-logic (~> 1.4)
76
75
  zeitwerk (~> 2.6)
77
76
  erubi (1.13.1)
78
- flay (2.13.3)
77
+ flay (2.14.4)
79
78
  erubi (~> 1.10)
80
- path_expander (~> 1.0)
81
- ruby_parser (~> 3.0)
79
+ path_expander (~> 2.0)
80
+ prism (~> 1.7)
82
81
  sexp_processor (~> 4.0)
83
- flog (4.8.0)
84
- path_expander (~> 1.0)
85
- ruby_parser (~> 3.1, > 3.1.0)
82
+ flog (4.9.4)
83
+ path_expander (~> 2.0)
84
+ prism (~> 1.7)
86
85
  sexp_processor (~> 4.8)
87
- humanize (2.5.1)
88
- i18n (1.14.7)
86
+ humanize (3.1.0)
87
+ i18n (1.15.2)
89
88
  concurrent-ruby (~> 1.0)
90
89
  ice_nine (0.11.2)
91
90
  json (2.21.1)
92
- kramdown (2.5.1)
93
- rexml (>= 3.3.9)
94
- language_server-protocol (3.17.0.5)
91
+ kramdown (2.5.2)
92
+ rexml (>= 3.4.4)
93
+ language_server-protocol (3.17.0.6)
95
94
  launchy (3.1.1)
96
95
  addressable (~> 2.8)
97
96
  childprocess (~> 5.0)
98
97
  logger (~> 1.6)
99
98
  lint_roller (1.1.0)
100
99
  logger (1.7.0)
101
- minitest (5.25.5)
102
- parallel (1.27.0)
103
- parser (3.3.9.0)
100
+ minitest (6.0.6)
101
+ drb (~> 2.0)
102
+ prism (~> 1.5)
103
+ ostruct (0.6.3)
104
+ parallel (2.1.0)
105
+ parser (3.3.12.0)
104
106
  ast (~> 2.4.1)
105
107
  racc
106
- path_expander (1.1.3)
107
- prism (1.4.0)
108
- public_suffix (6.0.2)
108
+ path_expander (2.0.1)
109
+ prism (1.9.0)
110
+ public_suffix (7.0.5)
109
111
  racc (1.8.1)
110
112
  rainbow (3.1.1)
111
- rake (13.3.0)
113
+ rake (13.4.2)
112
114
  reek (6.5.0)
113
115
  dry-schema (~> 1.13)
114
116
  logger (~> 1.6)
115
117
  parser (~> 3.3.0)
116
118
  rainbow (>= 2.0, < 4.0)
117
119
  rexml (~> 3.1)
118
- regexp_parser (2.10.0)
120
+ regexp_parser (2.12.0)
119
121
  rexml (3.4.4)
120
- rspec (3.13.1)
122
+ rspec (3.13.2)
121
123
  rspec-core (~> 3.13.0)
122
124
  rspec-expectations (~> 3.13.0)
123
125
  rspec-mocks (~> 3.13.0)
124
- rspec-core (3.13.5)
126
+ rspec-core (3.13.6)
125
127
  rspec-support (~> 3.13.0)
126
128
  rspec-expectations (3.13.5)
127
129
  diff-lcs (>= 1.2.0, < 2.0)
@@ -129,43 +131,46 @@ GEM
129
131
  rspec-its (2.0.0)
130
132
  rspec-core (>= 3.13.0)
131
133
  rspec-expectations (>= 3.13.0)
132
- rspec-mocks (3.13.5)
134
+ rspec-mocks (3.13.8)
133
135
  diff-lcs (>= 1.2.0, < 2.0)
134
136
  rspec-support (~> 3.13.0)
135
- rspec-support (3.13.4)
136
- rubocop (1.75.8)
137
+ rspec-support (3.13.7)
138
+ rubocop (1.88.2)
137
139
  json (~> 2.3)
138
140
  language_server-protocol (~> 3.17.0.2)
139
141
  lint_roller (~> 1.1.0)
140
- parallel (~> 1.10)
142
+ parallel (>= 1.10)
141
143
  parser (>= 3.3.0.2)
142
144
  rainbow (>= 2.2.2, < 4.0)
143
145
  regexp_parser (>= 2.9.3, < 3.0)
144
- rubocop-ast (>= 1.44.0, < 2.0)
146
+ rubocop-ast (>= 1.49.0, < 2.0)
145
147
  ruby-progressbar (~> 1.7)
146
148
  unicode-display_width (>= 2.4.0, < 4.0)
147
- rubocop-ast (1.46.0)
149
+ rubocop-ast (1.50.0)
148
150
  parser (>= 3.3.7.2)
149
- prism (~> 1.4)
150
- rubocop-performance (1.25.0)
151
+ prism (~> 1.7)
152
+ rubocop-performance (1.26.1)
151
153
  lint_roller (~> 1.1)
152
154
  rubocop (>= 1.75.0, < 2.0)
153
- rubocop-ast (>= 1.38.0, < 2.0)
155
+ rubocop-ast (>= 1.47.1, < 2.0)
154
156
  rubocop-rake (0.7.1)
155
157
  lint_roller (~> 1.1)
156
158
  rubocop (>= 1.72.1)
157
- rubocop-rspec (3.6.0)
159
+ rubocop-rspec (3.10.2)
158
160
  lint_roller (~> 1.1)
159
- rubocop (~> 1.72, >= 1.72.1)
161
+ regexp_parser (>= 2.0)
162
+ rubocop (~> 1.86, >= 1.86.2)
160
163
  ruby-progressbar (1.13.0)
161
- ruby_parser (3.21.1)
164
+ ruby_parser (3.22.0)
162
165
  racc (~> 1.5)
163
166
  sexp_processor (~> 4.16)
164
- rubycritic (4.11.0)
167
+ rubycritic (5.0.0)
165
168
  flay (~> 2.13)
166
169
  flog (~> 4.7)
167
170
  launchy (>= 2.5.2)
171
+ ostruct
168
172
  parser (>= 3.3.0.5)
173
+ prism (>= 1.6.0)
169
174
  rainbow (~> 3.1.1)
170
175
  reek (~> 6.5.0, < 7.0)
171
176
  rexml
@@ -174,40 +179,35 @@ GEM
174
179
  tty-which (~> 0.5.0)
175
180
  virtus (~> 2.0)
176
181
  securerandom (0.4.1)
177
- sexp_processor (4.17.4)
178
- simplecov (0.22.0)
179
- docile (~> 1.1)
180
- simplecov-html (~> 0.11)
181
- simplecov_json_formatter (~> 0.1)
182
- simplecov-html (0.13.2)
183
- simplecov_json_formatter (0.1.4)
184
- standard (1.50.0)
182
+ sexp_processor (4.17.5)
183
+ simplecov (1.0.2)
184
+ standard (1.56.0)
185
185
  language_server-protocol (~> 3.17.0.2)
186
186
  lint_roller (~> 1.0)
187
- rubocop (~> 1.75.5)
187
+ rubocop (~> 1.88.0)
188
188
  standard-custom (~> 1.0.0)
189
189
  standard-performance (~> 1.8)
190
190
  standard-custom (1.0.2)
191
191
  lint_roller (~> 1.0)
192
192
  rubocop (~> 1.50)
193
- standard-performance (1.8.0)
193
+ standard-performance (1.9.0)
194
194
  lint_roller (~> 1.1)
195
- rubocop-performance (~> 1.25.0)
196
- thor (1.4.0)
195
+ rubocop-performance (~> 1.26.0)
196
+ thor (1.5.0)
197
197
  thread_safe (0.3.6)
198
198
  tty-which (0.5.0)
199
199
  tzinfo (2.0.6)
200
200
  concurrent-ruby (~> 1.0)
201
- unicode-display_width (3.1.4)
202
- unicode-emoji (~> 4.0, >= 4.0.4)
203
- unicode-emoji (4.0.4)
201
+ unicode-display_width (3.2.0)
202
+ unicode-emoji (~> 4.1)
203
+ unicode-emoji (4.2.0)
204
204
  uri (1.1.1)
205
205
  virtus (2.0.0)
206
206
  axiom-types (~> 0.1)
207
207
  coercible (~> 1.0)
208
208
  descendants_tracker (~> 0.0, >= 0.0.3)
209
- yard (0.9.44)
210
- zeitwerk (2.7.3)
209
+ yard (0.9.45)
210
+ zeitwerk (2.8.2)
211
211
 
212
212
  PLATFORMS
213
213
  arm64-darwin-22
data/head_music.gemspec CHANGED
@@ -33,7 +33,7 @@ Gem::Specification.new do |spec|
33
33
  spec.required_ruby_version = ">= 3.3.0"
34
34
 
35
35
  spec.add_runtime_dependency "activesupport", ">= 7.0", "< 10"
36
- spec.add_runtime_dependency "humanize", "~> 2.0"
36
+ spec.add_runtime_dependency "humanize", ">= 2", "< 4"
37
37
  spec.add_runtime_dependency "i18n", "~> 1.8"
38
38
 
39
39
  spec.add_development_dependency "rake", "~> 13.0"
@@ -82,11 +82,11 @@ class HeadMusic::Analysis::PitchCollection
82
82
  end
83
83
 
84
84
  def inspect
85
- pitches.map(&:to_s).join(" ")
85
+ pitches.join(" ")
86
86
  end
87
87
 
88
88
  def to_s
89
- pitches.map(&:to_s).join(" ")
89
+ pitches.join(" ")
90
90
  end
91
91
 
92
92
  def ==(other)
@@ -78,6 +78,9 @@ class HeadMusic::Content::Composition
78
78
  sounds = values.placement_sounds(placement_hash, path)
79
79
  placement = voice.place(position, rhythmic_value, sounds)
80
80
  placement.beam_break_before = placement_hash["beam_break_before"] if placement_hash.key?("beam_break_before")
81
+ values.placement_syllables(placement_hash, path).each do |syllable|
82
+ placement.sing(syllable.text, verse: syllable.verse, hyphen_after: syllable.hyphen_after)
83
+ end
81
84
  end
82
85
  end
83
86
  end
@@ -71,6 +71,25 @@ class HeadMusic::Content::Composition
71
71
  end
72
72
  end
73
73
 
74
+ # "syllables" is an optional array of sung-text data, one entry per verse.
75
+ # Each entry is a {"text" => ..., "verse" => ..., "hyphen_after" => ...}
76
+ # hash; verse defaults to 1. Text must be a non-empty string, verse a
77
+ # positive integer, and no two entries may share a verse (a placement holds
78
+ # at most one syllable per verse).
79
+ def placement_syllables(placement_hash, path)
80
+ values = placement_hash["syllables"]
81
+ return [] if values.nil?
82
+
83
+ unless values.is_a?(Array)
84
+ raise ArgumentError, "#{path}: syllables must be an Array, got #{values.inspect}"
85
+ end
86
+
87
+ seen_verses = []
88
+ values.each_with_index.map do |value, index|
89
+ syllable(value, seen_verses, "#{path}.syllables[#{index}]")
90
+ end
91
+ end
92
+
74
93
  def bar_number(bar_hash, index)
75
94
  number = bar_hash["number"]
76
95
  unless number.is_a?(Integer) && number >= 0
@@ -101,6 +120,27 @@ class HeadMusic::Content::Composition
101
120
  pitch
102
121
  end
103
122
 
123
+ def syllable(value, seen_verses, path)
124
+ unless value.is_a?(Hash)
125
+ raise ArgumentError, "#{path}: syllable must be a Hash, got #{value.inspect}"
126
+ end
127
+
128
+ text = value["text"]
129
+ unless text.is_a?(String) && !text.empty?
130
+ raise ArgumentError, "#{path}: syllable text must be a non-empty String, got #{text.inspect}"
131
+ end
132
+
133
+ verse = value.fetch("verse", 1)
134
+ unless verse.is_a?(Integer) && verse.positive?
135
+ raise ArgumentError, "#{path}: verse must be a positive Integer, got #{verse.inspect}"
136
+ end
137
+
138
+ raise ArgumentError, "#{path}: duplicate verse #{verse}" if seen_verses.include?(verse)
139
+ seen_verses << verse
140
+
141
+ HeadMusic::Content::Syllable.from_h(value)
142
+ end
143
+
104
144
  def sound(value, path)
105
145
  return pitch(value, path) if value.is_a?(String)
106
146
  return unpitched_sound(value, path) if value.is_a?(Hash)
@@ -25,6 +25,32 @@ class HeadMusic::Content::Placement
25
25
  sounds.select(&:pitched?)
26
26
  end
27
27
 
28
+ # Authored sung text: at most one Syllable per verse, keyed by verse number.
29
+ # Empty for un-texted placements and rests. Set after construction, like
30
+ # beam_break_before. The MusicXML writer derives <syllabic> from these plus
31
+ # neighboring placements; melisma is the absence of a syllable here.
32
+ def syllables
33
+ @syllables ||= {}
34
+ end
35
+
36
+ # Assigns the syllable for a verse (default verse 1). Returns self so calls
37
+ # chain across verses. Keys by the Syllable's coerced verse (not the raw
38
+ # argument) so syllable(2) finds what sing(verse: "2") stored and mixed-type
39
+ # keys never make syllables.keys.sort raise.
40
+ def sing(text, verse: 1, hyphen_after: false)
41
+ syllable = HeadMusic::Content::Syllable.new(text, verse: verse, hyphen_after: hyphen_after)
42
+ syllables[syllable.verse] = syllable
43
+ self
44
+ end
45
+
46
+ def syllable(verse = 1)
47
+ syllables[verse]
48
+ end
49
+
50
+ def sung?
51
+ syllables.any?
52
+ end
53
+
28
54
  # The top pitch of a chord (or the only pitch of a note), which melodic
29
55
  # analysis treats as the melody note. Returns nil for rests and
30
56
  # unpitched-only placements; pitched? is the guard. Enharmonic ties
@@ -64,7 +90,9 @@ class HeadMusic::Content::Placement
64
90
 
65
91
  # Voice#place merges a same-position placement into the existing one, so a
66
92
  # position holds at most one placement. The sound union keeps the chord free
67
- # of duplicates, making repeated placement of a sound idempotent.
93
+ # of duplicates, making repeated placement of a sound idempotent. Syllables
94
+ # are left untouched: a chord sings one syllable per verse, and the receiver
95
+ # (the placement already at this position) keeps its own.
68
96
  def merge(other)
69
97
  unless rhythmic_value == other.rhythmic_value
70
98
  raise ArgumentError,
@@ -98,6 +126,7 @@ class HeadMusic::Content::Placement
98
126
  "sounds" => sounds.map { |sound| sound_datum(sound) }
99
127
  }
100
128
  hash["beam_break_before"] = beam_break_before unless beam_break_before.nil?
129
+ hash["syllables"] = syllables.keys.sort.map { |verse| syllables[verse].to_h } unless syllables.empty?
101
130
  hash
102
131
  end
103
132
 
@@ -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
- puts("Warning: Clef '#{default_clef_key}' not found.")
16
- if @instrument
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
- @default_clef = HeadMusic::Rudiment::Clef.get(:treble_clef)
18
+ HeadMusic::Rudiment::Clef.get(:treble_clef)
21
19
  end
22
20
  end
23
21
  @line_count = line_count || DEFAULT_LINE_COUNT
@@ -0,0 +1,38 @@
1
+ # A module for musical content
2
+ module HeadMusic::Content; end
3
+
4
+ # A sung syllable attached to a Placement for one verse. Only the minimal
5
+ # linguistic fact is stored: the text, its verse number, and whether the word
6
+ # continues onto the next sung note (hyphen_after). The MusicXML `syllabic`
7
+ # value (single/begin/middle/end) is derived from these at render time rather
8
+ # than stored, and melisma is represented by the absence of a syllable on the
9
+ # following placements, so nothing here encodes it.
10
+ class HeadMusic::Content::Syllable
11
+ attr_reader :text, :verse, :hyphen_after
12
+
13
+ def initialize(text, verse: 1, hyphen_after: false)
14
+ @text = text.to_s
15
+ @verse = Integer(verse)
16
+ @hyphen_after = !!hyphen_after
17
+ freeze
18
+ end
19
+
20
+ def hyphen_after?
21
+ hyphen_after
22
+ end
23
+
24
+ def to_h
25
+ hash = {"text" => text}
26
+ hash["verse"] = verse unless verse == 1
27
+ hash["hyphen_after"] = true if hyphen_after
28
+ hash
29
+ end
30
+
31
+ def self.from_h(hash)
32
+ new(hash["text"], verse: hash.fetch("verse", 1), hyphen_after: hash.fetch("hyphen_after", false))
33
+ end
34
+
35
+ def ==(other)
36
+ other.is_a?(self.class) && to_h == other.to_h
37
+ end
38
+ end
@@ -157,6 +157,6 @@ class HeadMusic::Content::Voice
157
157
  end
158
158
 
159
159
  def pitches_string
160
- pitches.first(10).map(&:to_s).join(" ")
160
+ pitches.first(10).join(" ")
161
161
  end
162
162
  end
@@ -44,6 +44,6 @@ class HeadMusic::Instruments::InstrumentName
44
44
  end
45
45
 
46
46
  def format_pitch_name(pitch_designation)
47
- pitch_designation.to_s.tr("b", "♭").tr("#", "♯")
47
+ HeadMusic::Utilities::Accidentals.to_unicode(pitch_designation)
48
48
  end
49
49
  end