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 +4 -4
- data/.github/workflows/ci.yml +3 -3
- data/.github/workflows/release.yml +1 -1
- data/.github/workflows/security.yml +2 -1
- data/.rubocop.yml +6 -0
- data/CHANGELOG.md +7 -0
- data/Gemfile.lock +68 -68
- data/head_music.gemspec +1 -1
- data/lib/head_music/analysis/pitch_collection.rb +2 -2
- data/lib/head_music/content/composition/hash_deserializer.rb +3 -0
- data/lib/head_music/content/composition/schema_values.rb +40 -0
- data/lib/head_music/content/placement.rb +30 -1
- data/lib/head_music/content/syllable.rb +38 -0
- data/lib/head_music/content/voice.rb +1 -1
- data/lib/head_music/notation/music_xml/beam_grouper.rb +2 -2
- data/lib/head_music/notation/music_xml/duration_writer.rb +1 -1
- data/lib/head_music/notation/music_xml/writer.rb +46 -0
- data/lib/head_music/style/guidelines/notes_same_length.rb +1 -1
- data/lib/head_music/time/musical_position.rb +1 -13
- data/lib/head_music/time/radix_carry.rb +22 -0
- data/lib/head_music/time/smpte_timecode.rb +1 -13
- data/lib/head_music/time.rb +1 -0
- data/lib/head_music/version.rb +1 -1
- data/lib/head_music.rb +1 -0
- data/user-stories/done/lyrics.md +162 -0
- data/user-stories/index.html +7 -3
- metadata +15 -6
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5a439a4b39c708eb38984b3878e7bc0c02ce92d7c693d34041ed3b8c69acfffe
|
|
4
|
+
data.tar.gz: d0b702f38b7de8826fe7a345c3b17d0a8ea8c963bd922343889ba97a47f3538e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 266406dca2449e15a0d72e4ac315190929aa048d18c6236f07d5eea23d5b220dcc037408edb7f5a2a508e677dee5bbf70bb96b620e1bcdcf75df145623efff9e
|
|
7
|
+
data.tar.gz: 81fcf78c8ec6234b1478d0c6ca92acdc096bb714de76e33d1bd19bf21e52fce8c0b3831fa72b2e8ec2cbe4dad21d399886cd0e62c416934fd6778ab08d03308d
|
data/.github/workflows/ci.yml
CHANGED
|
@@ -16,7 +16,7 @@ jobs:
|
|
|
16
16
|
activesupport-version: ['7.2', '8.0']
|
|
17
17
|
|
|
18
18
|
steps:
|
|
19
|
-
- uses: actions/checkout@
|
|
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@
|
|
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@
|
|
60
|
+
- uses: actions/checkout@v5
|
|
61
61
|
|
|
62
62
|
- name: Set up Ruby
|
|
63
63
|
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@
|
|
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
|
+
head_music (17.5.0)
|
|
5
5
|
activesupport (>= 7.0, < 10)
|
|
6
|
-
humanize (
|
|
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 (
|
|
34
|
-
bundler-audit (0.9.
|
|
35
|
-
bundler (>= 1.2.0
|
|
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.
|
|
42
|
-
connection_pool (
|
|
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.
|
|
49
|
-
dry-core (~> 1.
|
|
47
|
+
dry-configurable (1.4.0)
|
|
48
|
+
dry-core (~> 1.0)
|
|
50
49
|
zeitwerk (~> 2.6)
|
|
51
|
-
dry-core (1.
|
|
50
|
+
dry-core (1.2.0)
|
|
52
51
|
concurrent-ruby (~> 1.0)
|
|
53
52
|
logger
|
|
54
53
|
zeitwerk (~> 2.6)
|
|
55
|
-
dry-inflector (1.
|
|
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.
|
|
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.
|
|
68
|
-
dry-types (~> 1.
|
|
66
|
+
dry-logic (~> 1.6)
|
|
67
|
+
dry-types (~> 1.9, >= 1.9.1)
|
|
69
68
|
zeitwerk (~> 2.6)
|
|
70
|
-
dry-types (1.
|
|
71
|
-
bigdecimal (
|
|
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.
|
|
77
|
+
flay (2.14.4)
|
|
79
78
|
erubi (~> 1.10)
|
|
80
|
-
path_expander (~>
|
|
81
|
-
|
|
79
|
+
path_expander (~> 2.0)
|
|
80
|
+
prism (~> 1.7)
|
|
82
81
|
sexp_processor (~> 4.0)
|
|
83
|
-
flog (4.
|
|
84
|
-
path_expander (~>
|
|
85
|
-
|
|
82
|
+
flog (4.9.4)
|
|
83
|
+
path_expander (~> 2.0)
|
|
84
|
+
prism (~> 1.7)
|
|
86
85
|
sexp_processor (~> 4.8)
|
|
87
|
-
humanize (
|
|
88
|
-
i18n (1.
|
|
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.
|
|
93
|
-
rexml (>= 3.
|
|
94
|
-
language_server-protocol (3.17.0.
|
|
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 (
|
|
102
|
-
|
|
103
|
-
|
|
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 (
|
|
107
|
-
prism (1.
|
|
108
|
-
public_suffix (
|
|
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.
|
|
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.
|
|
120
|
+
regexp_parser (2.12.0)
|
|
119
121
|
rexml (3.4.4)
|
|
120
|
-
rspec (3.13.
|
|
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.
|
|
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.
|
|
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.
|
|
136
|
-
rubocop (1.
|
|
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 (
|
|
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.
|
|
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.
|
|
149
|
+
rubocop-ast (1.50.0)
|
|
148
150
|
parser (>= 3.3.7.2)
|
|
149
|
-
prism (~> 1.
|
|
150
|
-
rubocop-performance (1.
|
|
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.
|
|
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.
|
|
159
|
+
rubocop-rspec (3.10.2)
|
|
158
160
|
lint_roller (~> 1.1)
|
|
159
|
-
|
|
161
|
+
regexp_parser (>= 2.0)
|
|
162
|
+
rubocop (~> 1.86, >= 1.86.2)
|
|
160
163
|
ruby-progressbar (1.13.0)
|
|
161
|
-
ruby_parser (3.
|
|
164
|
+
ruby_parser (3.22.0)
|
|
162
165
|
racc (~> 1.5)
|
|
163
166
|
sexp_processor (~> 4.16)
|
|
164
|
-
rubycritic (
|
|
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.
|
|
178
|
-
simplecov (0.
|
|
179
|
-
|
|
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.
|
|
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.
|
|
193
|
+
standard-performance (1.9.0)
|
|
194
194
|
lint_roller (~> 1.1)
|
|
195
|
-
rubocop-performance (~> 1.
|
|
196
|
-
thor (1.
|
|
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.
|
|
202
|
-
unicode-emoji (~> 4.
|
|
203
|
-
unicode-emoji (4.0
|
|
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.
|
|
210
|
-
zeitwerk (2.
|
|
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", "
|
|
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"
|
|
@@ -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
|
|
@@ -5,7 +5,7 @@ module HeadMusic::Notation::MusicXML
|
|
|
5
5
|
# the Event list and turns the returned Beams into XML.
|
|
6
6
|
class BeamGrouper
|
|
7
7
|
# A single <beam number="N">type</beam> annotation.
|
|
8
|
-
Beam = Struct.new(:number, :type
|
|
8
|
+
Beam = Struct.new(:number, :type)
|
|
9
9
|
|
|
10
10
|
# One notehead's beaming inputs.
|
|
11
11
|
# - levels: beams this notehead carries alone (eighth=1, sixteenth=2, ...);
|
|
@@ -13,7 +13,7 @@ module HeadMusic::Notation::MusicXML
|
|
|
13
13
|
# - onset: integer offset from the start of the bar, in MusicXML divisions.
|
|
14
14
|
# - beam_break_before: tri-state override vs. the previous event
|
|
15
15
|
# (nil = meter default, true = force break, false = force join).
|
|
16
|
-
Event = Struct.new(:levels, :onset, :beam_break_before
|
|
16
|
+
Event = Struct.new(:levels, :onset, :beam_break_before)
|
|
17
17
|
|
|
18
18
|
def self.annotate(events, group_unit_divisions)
|
|
19
19
|
new(events, group_unit_divisions).annotate
|
|
@@ -4,7 +4,7 @@ module HeadMusic::Notation::MusicXML
|
|
|
4
4
|
# <type>, dot count, and tie flags MusicXML needs for each link of a
|
|
5
5
|
# tied chain. A value with no tied chain yields a single component.
|
|
6
6
|
class DurationWriter
|
|
7
|
-
Component = Struct.new(:duration, :type, :dots, :tie_start, :tie_stop
|
|
7
|
+
Component = Struct.new(:duration, :type, :dots, :tie_start, :tie_stop)
|
|
8
8
|
|
|
9
9
|
# MusicXML's <type> element uses these fixed names rather than the
|
|
10
10
|
# gem's American duration names.
|
|
@@ -229,10 +229,56 @@ module HeadMusic::Notation::MusicXML
|
|
|
229
229
|
*Array.new(component.dots) { "#{INDENT * 4}<dot/>" },
|
|
230
230
|
*beam_lines(beams),
|
|
231
231
|
*notation_lines(placement, component),
|
|
232
|
+
*lyric_lines(placement, component, chord: chord),
|
|
232
233
|
"#{INDENT * 3}</note>"
|
|
233
234
|
]
|
|
234
235
|
end
|
|
235
236
|
|
|
237
|
+
# <lyric> is the last child of <note>. Sung text rides only the lead note
|
|
238
|
+
# of a chord and only the attack of a tied chain (a tie_stop component is a
|
|
239
|
+
# continuation, sung once at the start). Held notes of a melisma carry no
|
|
240
|
+
# syllable and so emit nothing, matching MusicXML's continuation-by-absence.
|
|
241
|
+
def lyric_lines(placement, component, chord:)
|
|
242
|
+
return [] if chord || placement.rest? || component.tie_stop
|
|
243
|
+
|
|
244
|
+
placement.syllables.keys.sort.flat_map do |verse|
|
|
245
|
+
syllable = placement.syllables[verse]
|
|
246
|
+
[
|
|
247
|
+
%(#{INDENT * 4}<lyric number="#{verse}">),
|
|
248
|
+
"#{INDENT * 5}<syllabic>#{syllabic(placement, syllable)}</syllabic>",
|
|
249
|
+
"#{INDENT * 5}<text>#{escape(syllable.text)}</text>",
|
|
250
|
+
"#{INDENT * 4}</lyric>"
|
|
251
|
+
]
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# Derives MusicXML's single/begin/middle/end from our stored hyphen_after
|
|
256
|
+
# booleans: this syllable's, and the previous sung note's for the same verse.
|
|
257
|
+
def syllabic(placement, syllable)
|
|
258
|
+
from_previous = previous_syllable(placement, syllable.verse)&.hyphen_after?
|
|
259
|
+
if from_previous
|
|
260
|
+
syllable.hyphen_after? ? "middle" : "end"
|
|
261
|
+
else
|
|
262
|
+
syllable.hyphen_after? ? "begin" : "single"
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
# The syllable on the nearest earlier placement in the same voice carrying
|
|
267
|
+
# text for this verse. Placements are position-sorted, and melisma gaps are
|
|
268
|
+
# skipped because only sung placements are collected. Array#index compares
|
|
269
|
+
# with ==, which on Placement is position-only, but a voice holds at most
|
|
270
|
+
# one placement per position (Voice#insert_into_placements), so that still
|
|
271
|
+
# locates this exact placement.
|
|
272
|
+
def previous_syllable(placement, verse)
|
|
273
|
+
@sung_placements ||= {}
|
|
274
|
+
sung = @sung_placements[[placement.voice, verse]] ||=
|
|
275
|
+
placement.voice.placements.select { |candidate| candidate.syllable(verse) }
|
|
276
|
+
index = sung.index(placement)
|
|
277
|
+
return nil if index.nil? || index.zero?
|
|
278
|
+
|
|
279
|
+
sung[index - 1].syllable(verse)
|
|
280
|
+
end
|
|
281
|
+
|
|
236
282
|
def beam_lines(beams)
|
|
237
283
|
beams.map { |beam| %(#{INDENT * 4}<beam number="#{beam.number}">#{beam.type}</beam>) }
|
|
238
284
|
end
|
|
@@ -54,7 +54,7 @@ class HeadMusic::Style::Guidelines::NotesSameLength < HeadMusic::Style::Annotati
|
|
|
54
54
|
end
|
|
55
55
|
|
|
56
56
|
def occurrences_by_rhythmic_value
|
|
57
|
-
rhythmic_values.
|
|
57
|
+
rhythmic_values.tally
|
|
58
58
|
end
|
|
59
59
|
|
|
60
60
|
def rhythmic_values
|
|
@@ -39,6 +39,7 @@ module HeadMusic
|
|
|
39
39
|
# pos1 < pos2 # => true
|
|
40
40
|
class MusicalPosition
|
|
41
41
|
include Comparable
|
|
42
|
+
include RadixCarry
|
|
42
43
|
|
|
43
44
|
# @return [Integer] the bar (measure) number (1-indexed)
|
|
44
45
|
attr_reader :bar
|
|
@@ -170,19 +171,6 @@ module HeadMusic
|
|
|
170
171
|
|
|
171
172
|
# Allow other MusicalPosition instances to access this method for comparison
|
|
172
173
|
alias_method :to_i, :to_total_subticks
|
|
173
|
-
|
|
174
|
-
private
|
|
175
|
-
|
|
176
|
-
# Divide the named component by its radix, store the remainder back,
|
|
177
|
-
# and return the amount to carry into the next-higher component.
|
|
178
|
-
#
|
|
179
|
-
# @return [Integer] the carry (may be negative when borrowing)
|
|
180
|
-
def carry(component, radix)
|
|
181
|
-
ivar = :"@#{component}"
|
|
182
|
-
delta, remainder = instance_variable_get(ivar).divmod(radix)
|
|
183
|
-
instance_variable_set(ivar, remainder)
|
|
184
|
-
delta
|
|
185
|
-
end
|
|
186
174
|
end
|
|
187
175
|
end
|
|
188
176
|
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module HeadMusic
|
|
4
|
+
module Time
|
|
5
|
+
# Shared helper for positional/timecode components that carry between
|
|
6
|
+
# radix-bounded fields (e.g. bars:beats:ticks, hours:minutes:seconds:frames).
|
|
7
|
+
module RadixCarry
|
|
8
|
+
private
|
|
9
|
+
|
|
10
|
+
# Divide the named component by its radix, store the remainder back,
|
|
11
|
+
# and return the amount to carry into the next-higher component.
|
|
12
|
+
#
|
|
13
|
+
# @return [Integer] the carry (may be negative when borrowing)
|
|
14
|
+
def carry(component, radix)
|
|
15
|
+
ivar = :"@#{component}"
|
|
16
|
+
delta, remainder = instance_variable_get(ivar).divmod(radix)
|
|
17
|
+
instance_variable_set(ivar, remainder)
|
|
18
|
+
delta
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -33,6 +33,7 @@ module HeadMusic
|
|
|
33
33
|
# tc1 < tc2 # => true
|
|
34
34
|
class SmpteTimecode
|
|
35
35
|
include Comparable
|
|
36
|
+
include RadixCarry
|
|
36
37
|
|
|
37
38
|
# @return [Integer] the hour component
|
|
38
39
|
attr_reader :hour
|
|
@@ -148,19 +149,6 @@ module HeadMusic
|
|
|
148
149
|
|
|
149
150
|
# Allow other SmpteTimecode instances to access this method for comparison
|
|
150
151
|
alias_method :to_i, :to_total_frames
|
|
151
|
-
|
|
152
|
-
private
|
|
153
|
-
|
|
154
|
-
# Divide the named component by its radix, store the remainder back,
|
|
155
|
-
# and return the amount to carry into the next-higher component.
|
|
156
|
-
#
|
|
157
|
-
# @return [Integer] the carry (may be negative when borrowing)
|
|
158
|
-
def carry(component, radix)
|
|
159
|
-
ivar = :"@#{component}"
|
|
160
|
-
delta, remainder = instance_variable_get(ivar).divmod(radix)
|
|
161
|
-
instance_variable_set(ivar, remainder)
|
|
162
|
-
delta
|
|
163
|
-
end
|
|
164
152
|
end
|
|
165
153
|
end
|
|
166
154
|
end
|
data/lib/head_music/time.rb
CHANGED
data/lib/head_music/version.rb
CHANGED
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.
|
data/user-stories/index.html
CHANGED
|
@@ -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/
|
|
406
|
+
"path": "done/superclass-for-note.md"
|
|
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
|
};
|
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
|
+
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-
|
|
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
|
|
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
|
|
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
|