head_music 17.4.0 → 17.5.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4d075fb623ea50351c7ac422f44be04c265867253312b6b27faffaf76bb3b397
4
- data.tar.gz: 5649553e987c0a98cd9388d24d6e15155f88da4e59eaf62f7a2d9f988e656b36
3
+ metadata.gz: 5a439a4b39c708eb38984b3878e7bc0c02ce92d7c693d34041ed3b8c69acfffe
4
+ data.tar.gz: d0b702f38b7de8826fe7a345c3b17d0a8ea8c963bd922343889ba97a47f3538e
5
5
  SHA512:
6
- metadata.gz: a89a0dfc44c68e823e5c1eff6c48b950b759cdd059c504adacd8bd6b3d06dc0b23bc1a13b690e68086526a33ab3bebc1617825abecfc3ab108c89425a44d6f55
7
- data.tar.gz: 921ce718ccc4b728ed57dce347fbce73ae7c120960931b32933a5893f52992b4aa40ec115e248421a3f53d3a66defff0b4d7eac15efaf34ae51fae23e5e2a328
6
+ metadata.gz: 266406dca2449e15a0d72e4ac315190929aa048d18c6236f07d5eea23d5b220dcc037408edb7f5a2a508e677dee5bbf70bb96b620e1bcdcf75df145623efff9e
7
+ data.tar.gz: 81fcf78c8ec6234b1478d0c6ca92acdc096bb714de76e33d1bd19bf21e52fce8c0b3831fa72b2e8ec2cbe4dad21d399886cd0e62c416934fd6778ab08d03308d
@@ -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,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [17.5.0] - 2026-07-21
11
+
12
+ ### Added
13
+
14
+ - 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).
15
+ - 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.
16
+
10
17
  ## [17.4.0] - 2026-07-20
11
18
 
12
19
  ### Changed
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 (17.5.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
 
@@ -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
@@ -5,7 +5,7 @@ module HeadMusic::Notation::MusicXML
5
5
  # the Event list and turns the returned Beams into XML.
6
6
  class BeamGrouper
7
7
  # A single <beam number="N">type</beam> annotation.
8
- Beam = Struct.new(:number, :type, keyword_init: true)
8
+ Beam = Struct.new(:number, :type)
9
9
 
10
10
  # One notehead's beaming inputs.
11
11
  # - levels: beams this notehead carries alone (eighth=1, sixteenth=2, ...);
@@ -13,7 +13,7 @@ module HeadMusic::Notation::MusicXML
13
13
  # - onset: integer offset from the start of the bar, in MusicXML divisions.
14
14
  # - beam_break_before: tri-state override vs. the previous event
15
15
  # (nil = meter default, true = force break, false = force join).
16
- Event = Struct.new(:levels, :onset, :beam_break_before, keyword_init: true)
16
+ Event = Struct.new(:levels, :onset, :beam_break_before)
17
17
 
18
18
  def self.annotate(events, group_unit_divisions)
19
19
  new(events, group_unit_divisions).annotate
@@ -4,7 +4,7 @@ module HeadMusic::Notation::MusicXML
4
4
  # <type>, dot count, and tie flags MusicXML needs for each link of a
5
5
  # tied chain. A value with no tied chain yields a single component.
6
6
  class DurationWriter
7
- Component = Struct.new(:duration, :type, :dots, :tie_start, :tie_stop, keyword_init: true)
7
+ Component = Struct.new(:duration, :type, :dots, :tie_start, :tie_stop)
8
8
 
9
9
  # MusicXML's <type> element uses these fixed names rather than the
10
10
  # gem's American duration names.
@@ -229,10 +229,56 @@ module HeadMusic::Notation::MusicXML
229
229
  *Array.new(component.dots) { "#{INDENT * 4}<dot/>" },
230
230
  *beam_lines(beams),
231
231
  *notation_lines(placement, component),
232
+ *lyric_lines(placement, component, chord: chord),
232
233
  "#{INDENT * 3}</note>"
233
234
  ]
234
235
  end
235
236
 
237
+ # <lyric> is the last child of <note>. Sung text rides only the lead note
238
+ # of a chord and only the attack of a tied chain (a tie_stop component is a
239
+ # continuation, sung once at the start). Held notes of a melisma carry no
240
+ # syllable and so emit nothing, matching MusicXML's continuation-by-absence.
241
+ def lyric_lines(placement, component, chord:)
242
+ return [] if chord || placement.rest? || component.tie_stop
243
+
244
+ placement.syllables.keys.sort.flat_map do |verse|
245
+ syllable = placement.syllables[verse]
246
+ [
247
+ %(#{INDENT * 4}<lyric number="#{verse}">),
248
+ "#{INDENT * 5}<syllabic>#{syllabic(placement, syllable)}</syllabic>",
249
+ "#{INDENT * 5}<text>#{escape(syllable.text)}</text>",
250
+ "#{INDENT * 4}</lyric>"
251
+ ]
252
+ end
253
+ end
254
+
255
+ # Derives MusicXML's single/begin/middle/end from our stored hyphen_after
256
+ # booleans: this syllable's, and the previous sung note's for the same verse.
257
+ def syllabic(placement, syllable)
258
+ from_previous = previous_syllable(placement, syllable.verse)&.hyphen_after?
259
+ if from_previous
260
+ syllable.hyphen_after? ? "middle" : "end"
261
+ else
262
+ syllable.hyphen_after? ? "begin" : "single"
263
+ end
264
+ end
265
+
266
+ # The syllable on the nearest earlier placement in the same voice carrying
267
+ # text for this verse. Placements are position-sorted, and melisma gaps are
268
+ # skipped because only sung placements are collected. Array#index compares
269
+ # with ==, which on Placement is position-only, but a voice holds at most
270
+ # one placement per position (Voice#insert_into_placements), so that still
271
+ # locates this exact placement.
272
+ def previous_syllable(placement, verse)
273
+ @sung_placements ||= {}
274
+ sung = @sung_placements[[placement.voice, verse]] ||=
275
+ placement.voice.placements.select { |candidate| candidate.syllable(verse) }
276
+ index = sung.index(placement)
277
+ return nil if index.nil? || index.zero?
278
+
279
+ sung[index - 1].syllable(verse)
280
+ end
281
+
236
282
  def beam_lines(beams)
237
283
  beams.map { |beam| %(#{INDENT * 4}<beam number="#{beam.number}">#{beam.type}</beam>) }
238
284
  end
@@ -54,7 +54,7 @@ class HeadMusic::Style::Guidelines::NotesSameLength < HeadMusic::Style::Annotati
54
54
  end
55
55
 
56
56
  def occurrences_by_rhythmic_value
57
- rhythmic_values.each_with_object(Hash.new(0)) { |value, hash| hash[value] += 1 }
57
+ rhythmic_values.tally
58
58
  end
59
59
 
60
60
  def rhythmic_values
@@ -39,6 +39,7 @@ module HeadMusic
39
39
  # pos1 < pos2 # => true
40
40
  class MusicalPosition
41
41
  include Comparable
42
+ include RadixCarry
42
43
 
43
44
  # @return [Integer] the bar (measure) number (1-indexed)
44
45
  attr_reader :bar
@@ -170,19 +171,6 @@ module HeadMusic
170
171
 
171
172
  # Allow other MusicalPosition instances to access this method for comparison
172
173
  alias_method :to_i, :to_total_subticks
173
-
174
- private
175
-
176
- # Divide the named component by its radix, store the remainder back,
177
- # and return the amount to carry into the next-higher component.
178
- #
179
- # @return [Integer] the carry (may be negative when borrowing)
180
- def carry(component, radix)
181
- ivar = :"@#{component}"
182
- delta, remainder = instance_variable_get(ivar).divmod(radix)
183
- instance_variable_set(ivar, remainder)
184
- delta
185
- end
186
174
  end
187
175
  end
188
176
  end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HeadMusic
4
+ module Time
5
+ # Shared helper for positional/timecode components that carry between
6
+ # radix-bounded fields (e.g. bars:beats:ticks, hours:minutes:seconds:frames).
7
+ module RadixCarry
8
+ private
9
+
10
+ # Divide the named component by its radix, store the remainder back,
11
+ # and return the amount to carry into the next-higher component.
12
+ #
13
+ # @return [Integer] the carry (may be negative when borrowing)
14
+ def carry(component, radix)
15
+ ivar = :"@#{component}"
16
+ delta, remainder = instance_variable_get(ivar).divmod(radix)
17
+ instance_variable_set(ivar, remainder)
18
+ delta
19
+ end
20
+ end
21
+ end
22
+ end
@@ -33,6 +33,7 @@ module HeadMusic
33
33
  # tc1 < tc2 # => true
34
34
  class SmpteTimecode
35
35
  include Comparable
36
+ include RadixCarry
36
37
 
37
38
  # @return [Integer] the hour component
38
39
  attr_reader :hour
@@ -148,19 +149,6 @@ module HeadMusic
148
149
 
149
150
  # Allow other SmpteTimecode instances to access this method for comparison
150
151
  alias_method :to_i, :to_total_frames
151
-
152
- private
153
-
154
- # Divide the named component by its radix, store the remainder back,
155
- # and return the amount to carry into the next-higher component.
156
- #
157
- # @return [Integer] the carry (may be negative when borrowing)
158
- def carry(component, radix)
159
- ivar = :"@#{component}"
160
- delta, remainder = instance_variable_get(ivar).divmod(radix)
161
- instance_variable_set(ivar, remainder)
162
- delta
163
- end
164
152
  end
165
153
  end
166
154
  end
@@ -22,6 +22,7 @@ module HeadMusic
22
22
  end
23
23
  end
24
24
 
25
+ require_relative "time/radix_carry"
25
26
  require_relative "time/clock_position"
26
27
  require_relative "time/musical_position"
27
28
  require_relative "time/smpte_timecode"
@@ -1,3 +1,3 @@
1
1
  module HeadMusic
2
- VERSION = "17.4.0"
2
+ VERSION = "17.5.0"
3
3
  end
data/lib/head_music.rb CHANGED
@@ -145,6 +145,7 @@ require "head_music/content/placement"
145
145
  require "head_music/content/position"
146
146
  require "head_music/content/sound_resolver"
147
147
  require "head_music/content/staff"
148
+ require "head_music/content/syllable"
148
149
  require "head_music/content/voice"
149
150
  require "head_music/content/voice/melodic_note_pair"
150
151
  require "head_music/content/voice/melodic_line"
@@ -0,0 +1,162 @@
1
+ <!--
2
+ metadata:
3
+ created_at: 2026-07-21T10:29:08-07:00
4
+ activated_at: 2026-07-21T10:36:39-07:00
5
+ planned_at: 2026-07-21T10:39:08-07:00
6
+ finished_at: 2026-07-21T16:42:58-07:00
7
+ updated_at: 2026-07-21T16:42:58-07:00
8
+ -->
9
+
10
+ # Story: Lyrics
11
+
12
+ ## Summary
13
+
14
+ AS a composer working with vocal music in head_music
15
+ I WANT to attach sung text (lyrics) to the notes of a voice
16
+ SO THAT compositions can carry multi-verse, hyphenated lyrics and export them to MusicXML
17
+
18
+ ## Acceptance Criteria
19
+
20
+ - A `Placement` can carry sung text: at most one syllable per verse, keyed by verse number, with rests and un-texted placements carrying none.
21
+ - Multiple verses are supported (e.g. verse 1 "glo-", verse 2 "peace" on the same note).
22
+ - A syllable stores only the minimal linguistic fact — its text, its verse, and a `hyphen_after` boolean indicating the word continues onto the next sung note. The MusicXML `syllabic` value (single/begin/middle/end) is NOT stored.
23
+ - Melisma (one syllable held over several notes) is represented by absence: the held notes simply carry no syllable for that verse. No melisma flag is stored.
24
+ - Syllables round-trip losslessly through `Placement#to_h` and the composition hash deserializer.
25
+ - MusicXML export emits `<lyric number="N">` as the last child of `<note>`, on the lead note of a chord only and the attack of a tied chain only, deriving `<syllabic>` from the stored `hyphen_after` booleans of the syllable and its predecessor in the same verse.
26
+ - Lyric text is XML-escaped on export.
27
+ - Test coverage stays at or above the project's 90% minimum, with the MusicXML spec ladder covering: single-verse single word, hyphenated word (begin/middle/end derivation), melisma (gap emits no `<lyric>`), and multi-verse.
28
+
29
+ ## Notes
30
+
31
+ Design decisions reached in discussion (2026-07-21):
32
+
33
+ **Model: store the linguistic fact, derive the notation.** A note (`Placement`) is associated with at most one syllable per verse, and often none. `syllabic` (single/begin/middle/end) is derivable from a single `hyphen_after` boolean per syllable and is therefore NOT stored — storing it would be a denormalization that can go inconsistent, and it mixes a notation-rendering concern into the `Content` layer, which this codebase deliberately keeps separate from `Notation`. Melisma is likewise not stored; it is the absence of a syllable on a following placement (MusicXML's continuation-by-absence).
34
+
35
+ Lossless MusicXML *import* is explicitly NOT a goal, which is what frees us to derive `syllabic` on write rather than store it.
36
+
37
+ **`Syllable` value object** (`lib/head_music/content/syllable.rb`): immutable, `text` / `verse` / `hyphen_after`, with `to_h` / `from_h` / `==` / `hyphen_after?`.
38
+
39
+ **`Placement` changes** (mirror the existing `beam_break_before` side-metadata pattern):
40
+
41
+ - `syllables` — a hash keyed by verse number, at most one `Syllable` per verse (structural enforcement of the invariant).
42
+ - `sing(text, verse: 1, hyphen_after: false)` — assign; returns self for chaining.
43
+ - `syllable(verse = 1)`, `sung?`.
44
+ - `to_h` serializes syllables in verse order (alongside the `beam_break_before` line).
45
+ - `merge` keeps the existing placement's syllables and ignores the incoming one — a chord sings one syllable per verse; syllables attach to the note event, not to individual chord tones.
46
+
47
+ **Hash round-trip**: `HashDeserializer` restores syllables beside the `beam_break_before` restore; add a `values.placement_syllables` helper that validates (text present, verse a positive integer, no duplicate verse) the same way sounds/rhythmic values are validated on import.
48
+
49
+ **MusicXML output** (`music_xml/writer.rb`): `<lyric>` is the last child of `<note>`, after `<notations>`. Rides the lead note of a chord only and the attack (`!component.tie_stop`) only. Derive `syllabic` by comparing this syllable's `hyphen_after?` with the previous sung note's for the same verse:
50
+
51
+ | from prev? | to next? | syllabic |
52
+ |---|---|---|
53
+ | no | no | single |
54
+ | no | yes | begin |
55
+ | yes | yes | middle |
56
+ | yes | no | end |
57
+
58
+ Prefer precomputing a per-verse ordered list of sung placements once (O(n)) over re-walking the voice for each note. Text goes through the existing `escape` helper.
59
+
60
+ **Deferred (out of scope for this story):**
61
+
62
+ - ABC `w:` lyric-line parsing — stays an `:unsupported` token in the body lexer. The `Syllable` model is designed to receive it later (space / `-` / `_` / `*` alignment grammar → `Syllable`s).
63
+ - MusicXML `<extend/>` melisma extender line rendering.
64
+
65
+ ## Implementation Plan
66
+
67
+ Derived from the design in Notes (verified against current code 2026-07-21). Six steps, built in dependency order with tests at each step so coverage never dips below 90%.
68
+
69
+ ### Step 1 — `Syllable` value object
70
+
71
+ New file `lib/head_music/content/syllable.rb`. Immutable (`freeze` in initialize). Attributes `text`, `verse`, `hyphen_after`. Methods: `hyphen_after?`, `to_h` (omit `verse` when 1 and `hyphen_after` when false), `self.from_h`, `==` (by `to_h`). Require it from `lib/head_music.rb` alongside the other `content/` requires. Spec: `spec/head_music/content/syllable_spec.rb`.
72
+
73
+ ### Step 2 — `Placement` sung-text API
74
+
75
+ Edit `lib/head_music/content/placement.rb` (mirror the `beam_break_before` side-metadata pattern):
76
+
77
+ - `syllables` — lazily-initialized hash keyed by verse number (`@syllables ||= {}`).
78
+ - `sing(text, verse: 1, hyphen_after: false)` — assigns a `Syllable`, returns `self`.
79
+ - `syllable(verse = 1)` — reader; `sung?` — `syllables.any?`.
80
+ - `to_h` — after the `beam_break_before` line, add `hash["syllables"] = syllables.keys.sort.map { |v| syllables[v].to_h }` unless empty.
81
+ - `merge` — keep the receiver's `@syllables`, ignore the incoming placement's (chords sing one syllable per verse); add a one-line comment.
82
+
83
+ Spec additions in `spec/head_music/content/placement_spec.rb`: assignment, multi-verse, `to_h` shape + ordering, rests carry none, merge keeps existing syllables.
84
+
85
+ ### Step 3 — Hash round-trip
86
+
87
+ Edit `lib/head_music/content/composition/schema_values.rb`: add `placement_syllables(placement_hash, path)` — returns `[]` when key absent; else require an Array; validate each entry (Hash, non-empty `text` String, `verse` a positive Integer defaulting to 1, no duplicate verse) and build via `Syllable.from_h`, raising `ArgumentError` with path context like the sibling validators.
88
+
89
+ Edit `lib/head_music/content/composition/hash_deserializer.rb`: beside the `beam_break_before` restore in `build_voices`, add — when `placement_hash.key?("syllables")` — a loop calling `placement.sing(...)` for each validated syllable.
90
+
91
+ Specs: `schema_values_spec.rb` (valid + each rejection path) and a composition round-trip (`to_h` → `HashDeserializer#composition` preserves syllables).
92
+
93
+ ### Step 4 — MusicXML `<lyric>` output
94
+
95
+ Edit `lib/head_music/notation/music_xml/writer.rb`:
96
+
97
+ - In `note_element_lines`, insert `*lyric_lines(placement, component, chord: chord)` after `notation_lines`, before `</note>`.
98
+ - `lyric_lines` — return `[]` when `chord`, `placement.rest?`, or `component.tie_stop`. Else, for each verse in `placement.syllables.keys.sort`, emit `<lyric number="N">` / `<syllabic>` / `<text>` (escaped) / `</lyric>`.
99
+ - `syllabic(placement, syllable)` — derive from `previous_syllable(placement, syllable.verse)&.hyphen_after?` and `syllable.hyphen_after?` per the truth table in Notes.
100
+ - `previous_syllable(placement, verse)` — memoized per `[voice, verse]`: `placement.voice.placements.select { |p| p.syllable(verse) }` (already position-sorted), return the entry before `placement` (nil if first/absent). Melisma gaps are skipped naturally since only sung placements are collected.
101
+
102
+ Spec ladder in `spec/head_music/notation/music_xml/writer_spec.rb` (or a focused lyric spec): single-verse single word (`single`), hyphenated word (`begin`/`middle`/`end`), melisma (held note emits no `<lyric>`), multi-verse (two `<lyric number>` on one note), chord lead-note-only, tie attack-only, XML escaping.
103
+
104
+ ### Step 5 — Validation & coverage
105
+
106
+ Run `bundle exec rubocop -a`, then `bundle exec rake` (tests + coverage). Confirm ≥90% and that every new file/branch is exercised.
107
+
108
+ ### Step 6 — Verify acceptance criteria
109
+
110
+ Walk each criterion against the code and tests; note any that need manual confirmation.
111
+
112
+ Out of scope (deferred, per Notes): ABC `w:` parsing and the MusicXML `<extend/>` melisma line.
113
+
114
+ ## Review
115
+
116
+ Reviewed 2026-07-21 against commit `8a91b69` (plus a follow-up fix applied during review, see below). Full suite: 6097 examples, 0 failures; coverage 99.69% line / 97.74% branch. Reviewed by a product-manager agent (acceptance criteria) and a code-reviewer agent (quality), on a clean story branch.
117
+
118
+ ### Acceptance criteria
119
+
120
+ | # | Criterion | Verdict | Evidence |
121
+ |---|---|---|---|
122
+ | 1 | ≤1 syllable per verse, keyed by verse; rests/un-texted carry none | ✅ met | `placement.rb` `syllables` verse-keyed hash structurally enforces it; default empty (`placement_spec.rb` "carries no syllables by default") |
123
+ | 2 | Multiple verses | ✅ met | `sing(..., verse:)`; `placement_spec.rb` multi-verse + `writer_spec.rb` one `<lyric>` per verse |
124
+ | 3 | Stores only text/verse/hyphen_after; `syllabic` not stored | ✅ met | `syllable.rb` exposes exactly those three; no `syllabic` field anywhere |
125
+ | 4 | Melisma by absence, no flag | ✅ met | no melisma field; `writer_spec.rb` two-note melisma emits one `<lyric>` |
126
+ | 5 | Round-trips through `to_h` + deserializer | ✅ met | `to_h` verse-ordered; `SchemaValues#placement_syllables` validates; `composition_serialization_spec.rb` end-to-end round trip |
127
+ | 6 | `<lyric number>` last child, lead/attack only, derived `syllabic` | ✅ met | `lyric_lines` last in `note_element_lines`, guards `chord \|\| rest? \|\| tie_stop`; derivation table covered by chord/tie/begin-middle-end specs |
128
+ | 7 | Text XML-escaped | ✅ met | `escape(...)` in `lyric_lines`; `writer_spec.rb` `"R&D <x>"` parses back intact |
129
+ | 8 | Coverage ≥90% + MusicXML spec ladder | ✅ met | 99.69% line; ladder (single/hyphenated/melisma/multi-verse) all present |
130
+
131
+ All eight met. Nothing blocks `finish`.
132
+
133
+ ### Code review findings
134
+
135
+ 1. **(Important — found and fixed during review) `sing` keyed `syllables` by the raw `verse` argument while `Syllable` coerces it via `Integer()`.** `sing("la", verse: "2")` stored under key `"2"` while `syllable(2)` looked up the integer and returned `nil`; mixing key types made `syllables.keys.sort` raise in both `to_h` and the writer. No internal caller triggered it (all pass integers), but it was a latent bug in the public API. **Fixed:** `sing` now keys by `syllable.verse` (the coerced value). Added regression tests in `placement_spec.rb` (string verse found by its integer; mixed-type verses sort without raising).
136
+
137
+ 2. **(Minor — addressed) `previous_syllable` relied on an unstated invariant.** `Array#index` compares with `==`, which on `Placement` is position-only; it locates the right placement only because a voice holds one placement per position. Added a "why" comment documenting the dependency.
138
+
139
+ 3. **(Minor — addressed) behavioral-assertion gaps.** Added `writer_spec.rb` tests for a hyphenated word straddling a melisma gap (begin/middle/end skipping an un-sung note) and for per-verse independent `syllabic` derivation.
140
+
141
+ 4. **(Noted, not actioned) `hyphen_after` isn't validated on import** — any truthy JSON value becomes `true` via `Syllable`'s `!!`. Theoretical only (real JSON always sends a boolean); left as-is.
142
+
143
+ 5. **(Noted, not actioned) layer asymmetry** — `Placement#sing` accepts any positive/`Integer()`-coercible verse, while import rejects `verse <= 0`. The public contract expects `verse >= 1`; no criterion requires validation at `sing`. Left as a known minor asymmetry.
144
+
145
+ ## Learnings
146
+
147
+ **What went well**
148
+
149
+ - Front-loading the design debate paid off. Settling "store the linguistic fact, derive the notation" before any code — grounded in the Content/Notation boundary this codebase already maintains — made the implementation plan nearly write itself and kept the model minimal.
150
+ - Mirroring the existing `beam_break_before` side-metadata pattern gave a proven path through placement metadata → `to_h` → validated deserialization, so each layer was low-risk and consistent with existing code.
151
+ - Deriving `syllabic` on write made melisma free: "no syllable on the held note" is the whole implementation, no stored flag needed.
152
+
153
+ **What was surprising**
154
+
155
+ - Adversarial review earned its keep. The verse-key coercion bug (`sing(verse: "2")` stored under `"2"`, `syllable(2)` returned `nil`, mixed keys crashed `keys.sort`) was invisible to every happy-path test because all internal callers pass integers. Only skeptical review surfaced it.
156
+ - The tooling had its own bug — the board generator's `\s*` swallowed newlines and zeroed every sort timestamp. The scaffolding around the work needed the same scrutiny as the work.
157
+ - A public API (`sing`) accepted input the import layer rejected — a layer asymmetry that normal use never hits.
158
+
159
+ **What to do differently**
160
+
161
+ - When adding per-note metadata, write the round-trip and a coercion/edge test up front — coercion mismatches don't show up in happy-path specs.
162
+ - Decide deliberately whether validation belongs at the write boundary (`sing`) as well as the import boundary, so the layers agree rather than drift.
@@ -277,6 +277,10 @@
277
277
  ],
278
278
  "active": [],
279
279
  "done": [
280
+ {
281
+ "title": "Lyrics",
282
+ "path": "done/lyrics.md"
283
+ },
280
284
  {
281
285
  "title": "ABC Notation Beaming",
282
286
  "path": "done/abc-notation-beaming.md"
@@ -399,15 +403,15 @@
399
403
  },
400
404
  {
401
405
  "title": "Untitled",
402
- "path": "done/expand-playing-techniques.md"
406
+ "path": "done/superclass-for-note.md"
403
407
  },
404
408
  {
405
409
  "title": "Untitled",
406
- "path": "done/instrument-variant.md"
410
+ "path": "done/expand-playing-techniques.md"
407
411
  },
408
412
  {
409
413
  "title": "Untitled",
410
- "path": "done/superclass-for-note.md"
414
+ "path": "done/instrument-variant.md"
411
415
  }
412
416
  ]
413
417
  };
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: 17.4.0
4
+ version: 17.5.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-20 00:00:00.000000000 Z
11
+ date: 2026-07-21 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activesupport
@@ -34,16 +34,22 @@ dependencies:
34
34
  name: humanize
35
35
  requirement: !ruby/object:Gem::Requirement
36
36
  requirements:
37
- - - "~>"
37
+ - - ">="
38
38
  - !ruby/object:Gem::Version
39
- version: '2.0'
39
+ version: '2'
40
+ - - "<"
41
+ - !ruby/object:Gem::Version
42
+ version: '4'
40
43
  type: :runtime
41
44
  prerelease: false
42
45
  version_requirements: !ruby/object:Gem::Requirement
43
46
  requirements:
44
- - - "~>"
47
+ - - ">="
45
48
  - !ruby/object:Gem::Version
46
- version: '2.0'
49
+ version: '2'
50
+ - - "<"
51
+ - !ruby/object:Gem::Version
52
+ version: '4'
47
53
  - !ruby/object:Gem::Dependency
48
54
  name: i18n
49
55
  requirement: !ruby/object:Gem::Requirement
@@ -210,6 +216,7 @@ files:
210
216
  - lib/head_music/content/position.rb
211
217
  - lib/head_music/content/sound_resolver.rb
212
218
  - lib/head_music/content/staff.rb
219
+ - lib/head_music/content/syllable.rb
213
220
  - lib/head_music/content/voice.rb
214
221
  - lib/head_music/content/voice/melodic_line.rb
215
222
  - lib/head_music/content/voice/melodic_note_pair.rb
@@ -433,6 +440,7 @@ files:
433
440
  - lib/head_music/time/meter_map.rb
434
441
  - lib/head_music/time/musical_position.rb
435
442
  - lib/head_music/time/musical_time_converter.rb
443
+ - lib/head_music/time/radix_carry.rb
436
444
  - lib/head_music/time/smpte_converter.rb
437
445
  - lib/head_music/time/smpte_timecode.rb
438
446
  - lib/head_music/time/tempo_event.rb
@@ -470,6 +478,7 @@ files:
470
478
  - user-stories/done/handle-time.rb
471
479
  - user-stories/done/instrument-architecture.md
472
480
  - user-stories/done/instrument-variant.md
481
+ - user-stories/done/lyrics.md
473
482
  - user-stories/done/melody-contour-guides.md
474
483
  - user-stories/done/move-musical-symbol-to-notation.md
475
484
  - user-stories/done/move-staff-mapping-to-notation.md