head_music 15.1.0 → 15.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +17 -0
- data/Gemfile.lock +1 -1
- data/lib/head_music/content/bar.rb +24 -4
- data/lib/head_music/content/comment.rb +4 -0
- data/lib/head_music/content/composition.rb +225 -3
- data/lib/head_music/content/placement.rb +8 -0
- data/lib/head_music/content/voice.rb +11 -2
- data/lib/head_music/rudiment/rhythmic_value.rb +18 -1
- data/lib/head_music/version.rb +1 -1
- data/lib/head_music.rb +2 -0
- data/user-stories/done/composition-serialization.md +335 -0
- data/user-stories/index.html +19 -16
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d2446af0205d6b751566da4a2cc132032151d5c9b41732938552d12d3f07d05e
|
|
4
|
+
data.tar.gz: ed75fb7cea9e3a2ea50543bad21d28d0c66b8996cb5d57b4757aa9ef211eb67b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: fda48134cdaea328ddcdc1e2e072ea9c94d7a354e99178428969834cab6db36882667f21f7860ba427561d7a70e40eace071a1d4e9a7228e42c6cb92f2749e01
|
|
7
|
+
data.tar.gz: d2d1bfd02a1b1101932acd435836006e1deaa6e81102f233b9b339dfb56c01effc94691ce30063334e7c61eafffee214075c516ff0d831395a8c91b861cfffb0
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [15.2.0] - 2026-07-16
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- `HeadMusic::Content::Composition#to_h` / `.from_h` — lossless, JSON-safe hash serialization of a composition (schema_version 1). The hash captures name, key signature, meter, composer, origin, voices with roles and ordered placements (tick-precise positions, rhythmic values including ties, exact pitch spellings, rests as `null`), sparse per-bar state (mid-piece key and meter changes, repeat and volta structure), and comments. `from_h` rebuilds through the public builder API and raises `ArgumentError` with path context on malformed input; unknown keys are ignored so the format can evolve additively.
|
|
15
|
+
- `HeadMusic::Content::Composition#to_json` / `.from_json` — thin delegates over `to_h`/`from_h`.
|
|
16
|
+
- `#to_h` on `Content::Voice`, `Content::Placement`, `Content::Bar`, and `Content::Comment`.
|
|
17
|
+
|
|
18
|
+
**Schema v1 is a compatibility surface**: hashes persisted by downstream apps (e.g. in a jsonb column) must keep loading. Additive optional keys are fine within version 1; any change to existing keys' shape or meaning requires a `schema_version` bump.
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
|
|
22
|
+
- `RhythmicValue.get` now parses tied value strings ("half tied to eighth", including chained ties), so tied durations round-trip through `#to_s`.
|
|
23
|
+
- `Bar#key_signature=` and `Bar#meter=` coerce strings via `KeySignature.get` / `Meter.get`, so `change_meter(4, "6/8")` no longer stores a raw String.
|
|
24
|
+
- `Voice` placement ordering is now stable: notes placed at the same position (chords) keep their insertion order.
|
|
25
|
+
- `Composition#change_key_signature` / `#change_meter` no longer raise for a bar earlier than the first placement (e.g. a pickup bar).
|
|
26
|
+
|
|
10
27
|
## [15.1.0] - 2026-07-07
|
|
11
28
|
|
|
12
29
|
### Added
|
data/Gemfile.lock
CHANGED
|
@@ -5,19 +5,26 @@ module HeadMusic::Content; end
|
|
|
5
5
|
# Encapsulates meter and key signature changes
|
|
6
6
|
# and repeat structure (repeat barlines and volta brackets) as content semantics
|
|
7
7
|
class HeadMusic::Content::Bar
|
|
8
|
-
attr_reader :composition, :ends_repeat_after_num_plays, :plays_on_passes
|
|
9
|
-
attr_accessor :key_signature, :meter
|
|
8
|
+
attr_reader :composition, :ends_repeat_after_num_plays, :plays_on_passes, :key_signature, :meter
|
|
10
9
|
attr_writer :starts_repeat
|
|
11
10
|
|
|
12
11
|
def initialize(composition, key_signature: nil, meter: nil)
|
|
13
12
|
@composition = composition
|
|
14
|
-
|
|
15
|
-
|
|
13
|
+
self.key_signature = key_signature
|
|
14
|
+
self.meter = meter
|
|
16
15
|
@starts_repeat = false
|
|
17
16
|
@ends_repeat_after_num_plays = nil
|
|
18
17
|
@plays_on_passes = nil
|
|
19
18
|
end
|
|
20
19
|
|
|
20
|
+
def key_signature=(value)
|
|
21
|
+
@key_signature = value ? HeadMusic::Rudiment::KeySignature.get(value) : nil
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def meter=(value)
|
|
25
|
+
@meter = value ? HeadMusic::Rudiment::Meter.get(value) : nil
|
|
26
|
+
end
|
|
27
|
+
|
|
21
28
|
def starts_repeat?
|
|
22
29
|
@starts_repeat
|
|
23
30
|
end
|
|
@@ -48,6 +55,19 @@ class HeadMusic::Content::Bar
|
|
|
48
55
|
["Bar", key_signature, meter, repeat_summary].compact.join(" ")
|
|
49
56
|
end
|
|
50
57
|
|
|
58
|
+
# Sparse serialization: only non-default state, so a default bar is {}.
|
|
59
|
+
# KeySignature serializes via #name ("F♯ minor") because #to_s ("3 sharps")
|
|
60
|
+
# cannot be parsed back by KeySignature.get.
|
|
61
|
+
def to_h
|
|
62
|
+
hash = {}
|
|
63
|
+
hash["key_signature"] = key_signature.name if key_signature
|
|
64
|
+
hash["meter"] = meter.to_s if meter
|
|
65
|
+
hash["starts_repeat"] = true if starts_repeat?
|
|
66
|
+
hash["ends_repeat_after_num_plays"] = ends_repeat_after_num_plays if ends_repeat?
|
|
67
|
+
hash["plays_on_passes"] = plays_on_passes.dup if plays_on_passes
|
|
68
|
+
hash
|
|
69
|
+
end
|
|
70
|
+
|
|
51
71
|
private
|
|
52
72
|
|
|
53
73
|
def valid_ends_repeat_after_num_plays?(value)
|
|
@@ -3,8 +3,18 @@ module HeadMusic::Content; end
|
|
|
3
3
|
|
|
4
4
|
# A composition is musical content.
|
|
5
5
|
class HeadMusic::Content::Composition
|
|
6
|
+
SCHEMA_VERSION = 1
|
|
7
|
+
|
|
6
8
|
attr_reader :name, :key_signature, :meter, :voices, :composer, :origin, :comments
|
|
7
9
|
|
|
10
|
+
def self.from_h(hash)
|
|
11
|
+
HashDeserializer.new(hash).composition
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def self.from_json(json)
|
|
15
|
+
from_h(JSON.parse(json))
|
|
16
|
+
end
|
|
17
|
+
|
|
8
18
|
def initialize(name: nil, key_signature: nil, meter: nil, composer: nil, origin: nil, comments: nil)
|
|
9
19
|
ensure_attributes(name, key_signature, meter)
|
|
10
20
|
@composer = composer
|
|
@@ -35,10 +45,11 @@ class HeadMusic::Content::Composition
|
|
|
35
45
|
|
|
36
46
|
def bars(last = latest_bar_number)
|
|
37
47
|
@bars ||= []
|
|
38
|
-
|
|
48
|
+
first = [earliest_bar_number, last].min
|
|
49
|
+
(first..last).each do |bar_number|
|
|
39
50
|
@bars[bar_number] ||= HeadMusic::Content::Bar.new(self)
|
|
40
51
|
end
|
|
41
|
-
@bars[
|
|
52
|
+
@bars[first..last]
|
|
42
53
|
end
|
|
43
54
|
|
|
44
55
|
def change_key_signature(bar_number, key_signature)
|
|
@@ -50,7 +61,7 @@ class HeadMusic::Content::Composition
|
|
|
50
61
|
end
|
|
51
62
|
|
|
52
63
|
def earliest_bar_number
|
|
53
|
-
[voices.map(&:earliest_bar_number), 1].flatten.min
|
|
64
|
+
[voices.map(&:earliest_bar_number), first_allocated_bar_number, 1].flatten.compact.min
|
|
54
65
|
end
|
|
55
66
|
|
|
56
67
|
def latest_bar_number
|
|
@@ -77,8 +88,32 @@ class HeadMusic::Content::Composition
|
|
|
77
88
|
HeadMusic::Notation::MusicXML.render(self)
|
|
78
89
|
end
|
|
79
90
|
|
|
91
|
+
def to_h
|
|
92
|
+
{
|
|
93
|
+
"schema_version" => SCHEMA_VERSION,
|
|
94
|
+
"name" => name,
|
|
95
|
+
"key_signature" => key_signature.name,
|
|
96
|
+
"meter" => meter.to_s,
|
|
97
|
+
"composer" => composer&.to_s,
|
|
98
|
+
"origin" => origin&.to_s,
|
|
99
|
+
"voices" => voices.map(&:to_h),
|
|
100
|
+
"bars" => bars_to_h,
|
|
101
|
+
"comments" => comments.map(&:to_h)
|
|
102
|
+
}
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def to_json(*_args)
|
|
106
|
+
to_h.to_json
|
|
107
|
+
end
|
|
108
|
+
|
|
80
109
|
private
|
|
81
110
|
|
|
111
|
+
# Bars can be allocated below the voices' earliest bar (e.g. a key or meter
|
|
112
|
+
# change in a pickup bar), so the earliest bar reflects those allocations too.
|
|
113
|
+
def first_allocated_bar_number
|
|
114
|
+
(@bars || []).index { |bar| !bar.nil? }
|
|
115
|
+
end
|
|
116
|
+
|
|
82
117
|
def ensure_attributes(name, key_signature, meter)
|
|
83
118
|
@name = name || "Composition"
|
|
84
119
|
@key_signature = HeadMusic::Rudiment::KeySignature.get(key_signature) if key_signature
|
|
@@ -94,4 +129,191 @@ class HeadMusic::Content::Composition
|
|
|
94
129
|
def last_key_signature_change(bar_number)
|
|
95
130
|
bars(bar_number)[earliest_bar_number..bar_number].reverse.detect(&:key_signature)
|
|
96
131
|
end
|
|
132
|
+
|
|
133
|
+
# Iterates the raw sparse array (not the public #bars slice, which loses the
|
|
134
|
+
# number offset), pairing each non-default bar with its number.
|
|
135
|
+
def bars_to_h
|
|
136
|
+
(@bars || []).each_with_index.filter_map do |bar, number|
|
|
137
|
+
next if bar.nil?
|
|
138
|
+
|
|
139
|
+
bar_hash = bar.to_h
|
|
140
|
+
next if bar_hash.empty?
|
|
141
|
+
|
|
142
|
+
{"number" => number}.merge(bar_hash)
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Rebuilds a composition from a schema v1 hash by replaying the public
|
|
147
|
+
# builder API in dependency order: meter and key changes first (position
|
|
148
|
+
# strings roll counts and ticks over via the meter map), then placements,
|
|
149
|
+
# then repeat flags (a pickup-bar flag needs its bar allocated), then
|
|
150
|
+
# comments. Validates values at the boundary so corrupted input raises
|
|
151
|
+
# ArgumentError with path context instead of silently deserializing wrong.
|
|
152
|
+
class HashDeserializer
|
|
153
|
+
def initialize(hash)
|
|
154
|
+
raise ArgumentError, "expected a Hash, got #{hash.class}" unless hash.is_a?(Hash)
|
|
155
|
+
|
|
156
|
+
@hash = hash.deep_transform_keys(&:to_s)
|
|
157
|
+
validate_schema_version
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def composition
|
|
161
|
+
@composition ||= build_base_composition.tap do |composition|
|
|
162
|
+
apply_bar_changes(composition)
|
|
163
|
+
build_voices(composition)
|
|
164
|
+
apply_repeat_flags(composition)
|
|
165
|
+
add_comments(composition)
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
private
|
|
170
|
+
|
|
171
|
+
attr_reader :hash
|
|
172
|
+
|
|
173
|
+
def validate_schema_version
|
|
174
|
+
version = hash["schema_version"]
|
|
175
|
+
return if version.is_a?(Integer) && version == SCHEMA_VERSION
|
|
176
|
+
|
|
177
|
+
raise ArgumentError, "unsupported schema_version: #{version.inspect} (supported: #{SCHEMA_VERSION})"
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def build_base_composition
|
|
181
|
+
HeadMusic::Content::Composition.new(
|
|
182
|
+
name: hash["name"],
|
|
183
|
+
key_signature: parsed_key_signature(hash["key_signature"], "key_signature"),
|
|
184
|
+
meter: parsed_meter(hash["meter"], "meter"),
|
|
185
|
+
composer: hash["composer"],
|
|
186
|
+
origin: hash["origin"]
|
|
187
|
+
)
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def bar_hashes
|
|
191
|
+
@bar_hashes ||= Array(hash["bars"])
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def apply_bar_changes(composition)
|
|
195
|
+
bar_hashes.each_with_index do |bar_hash, index|
|
|
196
|
+
number = validated_bar_number(bar_hash, index)
|
|
197
|
+
path = "bars[#{index}]"
|
|
198
|
+
key_signature = parsed_key_signature(bar_hash["key_signature"], path)
|
|
199
|
+
meter = parsed_meter(bar_hash["meter"], path)
|
|
200
|
+
composition.change_key_signature(number, key_signature) if key_signature
|
|
201
|
+
composition.change_meter(number, meter) if meter
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def build_voices(composition)
|
|
206
|
+
Array(hash["voices"]).each_with_index do |voice_hash, voice_index|
|
|
207
|
+
voice = composition.add_voice(role: voice_hash["role"])
|
|
208
|
+
Array(voice_hash["placements"]).each_with_index do |placement_hash, placement_index|
|
|
209
|
+
path = "voices[#{voice_index}].placements[#{placement_index}]"
|
|
210
|
+
position = parsed_position(placement_hash["position"], path)
|
|
211
|
+
rhythmic_value = parsed_rhythmic_value(placement_hash["rhythmic_value"], path)
|
|
212
|
+
pitch = parsed_pitch(placement_hash["pitch"], path)
|
|
213
|
+
voice.place(position, rhythmic_value, pitch)
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def apply_repeat_flags(composition)
|
|
219
|
+
bar_hashes.each_with_index do |bar_hash, index|
|
|
220
|
+
next unless repeat_state?(bar_hash)
|
|
221
|
+
|
|
222
|
+
bar = composition.bars(validated_bar_number(bar_hash, index)).last
|
|
223
|
+
bar.starts_repeat = true if bar_hash["starts_repeat"]
|
|
224
|
+
bar.ends_repeat_after_num_plays = bar_hash["ends_repeat_after_num_plays"] if bar_hash["ends_repeat_after_num_plays"]
|
|
225
|
+
bar.plays_on_passes = bar_hash["plays_on_passes"] if bar_hash["plays_on_passes"]
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def add_comments(composition)
|
|
230
|
+
Array(hash["comments"]).each_with_index do |comment_hash, index|
|
|
231
|
+
position = parsed_position(comment_hash["position"], "comments[#{index}]") if comment_hash["position"]
|
|
232
|
+
composition.add_comment(comment_hash["text"], position)
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def repeat_state?(bar_hash)
|
|
237
|
+
bar_hash["starts_repeat"] || bar_hash["ends_repeat_after_num_plays"] || bar_hash["plays_on_passes"]
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def validated_bar_number(bar_hash, index)
|
|
241
|
+
number = bar_hash["number"]
|
|
242
|
+
unless number.is_a?(Integer) && number >= 0
|
|
243
|
+
raise ArgumentError, "bars[#{index}]: bar number must be an Integer of at least 0, got #{number.inspect}"
|
|
244
|
+
end
|
|
245
|
+
number
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# Position silently coerces garbage strings to "0:1:000", which would
|
|
249
|
+
# mislocate content with no error, so the shape is validated up front.
|
|
250
|
+
# Accepts "bar", "bar:count", or "bar:count:tick" with non-negative parts.
|
|
251
|
+
def parsed_position(value, path)
|
|
252
|
+
return nil if value.nil?
|
|
253
|
+
|
|
254
|
+
unless value.is_a?(String) && value.match?(/\A\d+(:\d+){0,2}\z/)
|
|
255
|
+
raise ArgumentError, "#{path}: unknown position #{value.inspect}"
|
|
256
|
+
end
|
|
257
|
+
value
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
# KeySignature.get returns a hollow object (nil tonic_spelling) for
|
|
261
|
+
# garbage rather than nil, so presence of the tonic is the real check.
|
|
262
|
+
def parsed_key_signature(value, path)
|
|
263
|
+
return nil if value.nil?
|
|
264
|
+
|
|
265
|
+
key_signature = begin
|
|
266
|
+
HeadMusic::Rudiment::KeySignature.get(value)
|
|
267
|
+
rescue
|
|
268
|
+
nil
|
|
269
|
+
end
|
|
270
|
+
unless key_signature&.tonic_spelling
|
|
271
|
+
raise ArgumentError, "#{path}: unknown key signature #{value.inspect}"
|
|
272
|
+
end
|
|
273
|
+
key_signature
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def parsed_meter(value, path)
|
|
277
|
+
return nil if value.nil?
|
|
278
|
+
|
|
279
|
+
meter = begin
|
|
280
|
+
HeadMusic::Rudiment::Meter.get(value)
|
|
281
|
+
rescue
|
|
282
|
+
nil
|
|
283
|
+
end
|
|
284
|
+
unless meter&.top_number&.positive? && meter.bottom_number.positive?
|
|
285
|
+
raise ArgumentError, "#{path}: unknown meter #{value.inspect}"
|
|
286
|
+
end
|
|
287
|
+
meter
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
def parsed_rhythmic_value(value, path)
|
|
291
|
+
rhythmic_value = HeadMusic::Rudiment::RhythmicValue.get(value)
|
|
292
|
+
unless valid_rhythmic_value?(rhythmic_value)
|
|
293
|
+
raise ArgumentError, "#{path}: unknown rhythmic value #{value.inspect}"
|
|
294
|
+
end
|
|
295
|
+
rhythmic_value
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
# RhythmicValue.get returns a hollow object (nil unit) for garbage rather
|
|
299
|
+
# than nil, and a tied tail can be hollow while the head parses, so the
|
|
300
|
+
# whole tie chain is checked.
|
|
301
|
+
def valid_rhythmic_value?(rhythmic_value)
|
|
302
|
+
return false unless rhythmic_value.is_a?(HeadMusic::Rudiment::RhythmicValue)
|
|
303
|
+
return false unless rhythmic_value.unit
|
|
304
|
+
|
|
305
|
+
rhythmic_value.tied_value.nil? || valid_rhythmic_value?(rhythmic_value.tied_value)
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
# A nil pitch is a rest; a non-nil string that fails to parse would
|
|
309
|
+
# otherwise silently deserialize as a rest.
|
|
310
|
+
def parsed_pitch(value, path)
|
|
311
|
+
return nil if value.nil?
|
|
312
|
+
|
|
313
|
+
pitch = HeadMusic::Rudiment::Pitch.get(value)
|
|
314
|
+
raise ArgumentError, "#{path}: unknown pitch #{value.inspect}" unless pitch
|
|
315
|
+
|
|
316
|
+
pitch
|
|
317
|
+
end
|
|
318
|
+
end
|
|
97
319
|
end
|
|
@@ -38,6 +38,14 @@ class HeadMusic::Content::Placement
|
|
|
38
38
|
"#{rhythmic_value} #{pitch || "rest"} at #{position}"
|
|
39
39
|
end
|
|
40
40
|
|
|
41
|
+
def to_h
|
|
42
|
+
{
|
|
43
|
+
"position" => position.to_s,
|
|
44
|
+
"rhythmic_value" => rhythmic_value.to_s,
|
|
45
|
+
"pitch" => pitch&.to_s
|
|
46
|
+
}
|
|
47
|
+
end
|
|
48
|
+
|
|
41
49
|
private
|
|
42
50
|
|
|
43
51
|
def starts_during?(other_placement)
|
|
@@ -140,15 +140,24 @@ class HeadMusic::Content::Voice
|
|
|
140
140
|
[role, pitches_string].join(": ")
|
|
141
141
|
end
|
|
142
142
|
|
|
143
|
+
def to_h
|
|
144
|
+
{
|
|
145
|
+
"role" => role&.to_s,
|
|
146
|
+
"placements" => placements.map(&:to_h)
|
|
147
|
+
}
|
|
148
|
+
end
|
|
149
|
+
|
|
143
150
|
private
|
|
144
151
|
|
|
145
152
|
def bar_start_position(placement)
|
|
146
153
|
HeadMusic::Content::Position.new(composition, placement.position.bar_number, 1, 0)
|
|
147
154
|
end
|
|
148
155
|
|
|
156
|
+
# Stable insertion: co-positioned placements (chords) keep their insertion order,
|
|
157
|
+
# which serialization round-trips depend on.
|
|
149
158
|
def insert_into_placements(placement)
|
|
150
|
-
@placements
|
|
151
|
-
@placements
|
|
159
|
+
index = @placements.index { |existing| existing > placement } || @placements.length
|
|
160
|
+
@placements.insert(index, placement)
|
|
152
161
|
end
|
|
153
162
|
|
|
154
163
|
def pitches_string
|
|
@@ -20,7 +20,11 @@ class HeadMusic::Rudiment::RhythmicValue
|
|
|
20
20
|
new(identifier)
|
|
21
21
|
when Symbol, String
|
|
22
22
|
original_identifier = identifier.to_s.strip
|
|
23
|
-
#
|
|
23
|
+
# Handle tied values first, so "half tied to eighth" round-trips through #to_s
|
|
24
|
+
tied = from_tied_words(original_identifier)
|
|
25
|
+
return tied if tied
|
|
26
|
+
|
|
27
|
+
# Then try the new parser which handles all formats
|
|
24
28
|
parsed = Parser.parse(original_identifier)
|
|
25
29
|
return parsed if parsed
|
|
26
30
|
|
|
@@ -34,6 +38,19 @@ class HeadMusic::Rudiment::RhythmicValue
|
|
|
34
38
|
end
|
|
35
39
|
end
|
|
36
40
|
|
|
41
|
+
# Splits on the first " tied to " and recurses on the remainder,
|
|
42
|
+
# so chained ties ("half tied to eighth tied to sixteenth") nest naturally.
|
|
43
|
+
def self.from_tied_words(identifier)
|
|
44
|
+
head, tail = identifier.split(/\s+tied\s+to\s+/, 2)
|
|
45
|
+
return nil unless tail
|
|
46
|
+
|
|
47
|
+
head_value = get(head)
|
|
48
|
+
tied_value = get(tail)
|
|
49
|
+
return nil unless head_value && tied_value
|
|
50
|
+
|
|
51
|
+
new(head_value.unit, dots: head_value.dots, tied_value: tied_value)
|
|
52
|
+
end
|
|
53
|
+
|
|
37
54
|
def self.from_words(identifier)
|
|
38
55
|
new(unit_from_words(identifier), dots: dots_from_words(identifier))
|
|
39
56
|
end
|
data/lib/head_music/version.rb
CHANGED
data/lib/head_music.rb
CHANGED
|
@@ -7,10 +7,12 @@ end
|
|
|
7
7
|
|
|
8
8
|
require "head_music/version"
|
|
9
9
|
|
|
10
|
+
require "active_support/core_ext/hash/keys"
|
|
10
11
|
require "active_support/core_ext/module/delegation"
|
|
11
12
|
require "active_support/core_ext/string/access"
|
|
12
13
|
require "active_support/core_ext/integer/inflections"
|
|
13
14
|
require "humanize"
|
|
15
|
+
require "json"
|
|
14
16
|
require "i18n"
|
|
15
17
|
require "i18n/backend/fallbacks"
|
|
16
18
|
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
metadata:
|
|
3
|
+
created_at: 2026-07-16T12:00:00-07:00
|
|
4
|
+
activated_at: 2026-07-16T18:59:42-07:00
|
|
5
|
+
planned_at: 2026-07-16T19:25:49-07:00
|
|
6
|
+
finished_at: 2026-07-16T21:46:48-07:00
|
|
7
|
+
updated_at: 2026-07-16T21:46:48-07:00
|
|
8
|
+
-->
|
|
9
|
+
|
|
10
|
+
# Story: Composition Serialization (to_h / from_h)
|
|
11
|
+
|
|
12
|
+
## Summary
|
|
13
|
+
|
|
14
|
+
AS a developer using HeadMusic
|
|
15
|
+
|
|
16
|
+
I WANT to serialize a `HeadMusic::Content::Composition` to a plain hash and reconstruct it losslessly
|
|
17
|
+
|
|
18
|
+
SO THAT downstream apps can persist compositions in a database and reload them with full fidelity
|
|
19
|
+
|
|
20
|
+
## Background
|
|
21
|
+
|
|
22
|
+
HeadMusic can render a `Composition` outward to notation formats (`#to_abc`, `#to_musicxml`, `#to_s`) and read *inward* from ABC (`HeadMusic::Notation::ABC.parse`). What's missing is a canonical, round-trippable **serialization of the object itself** — a structured form that captures everything the model holds and rebuilds an equivalent object.
|
|
23
|
+
|
|
24
|
+
This is distinct from the notation-format work under the [Notation Module epic](../epics/notation-module.md). ABC, MusicXML, and LilyPond are engraving/interchange formats — each lossy relative to the in-memory object (tick-precise positions, voice roles, comments, and mid-piece changes don't all survive an ABC round trip). This story is about **persistence fidelity**, not notation: a JSON-able hash that is the source of truth from which notation formats can be regenerated.
|
|
25
|
+
|
|
26
|
+
**Driving consumer.** The bardtheory app is adding a Staff Notation View. It will store `Composition` records in its database — a JSON `definition` column as the source of truth, plus a cached MusicXML render — and reference them by slug in lesson content. It needs `#to_h` / `.from_h` to persist and reload compositions. This serialization is a hard prerequisite for that story; bardtheory pins `head_music` from RubyGems (currently `~> 15.1`), so a released version carrying these methods is the deliverable.
|
|
27
|
+
|
|
28
|
+
## Example
|
|
29
|
+
|
|
30
|
+
```ruby
|
|
31
|
+
composition = HeadMusic::Notation::ABC.parse(abc) # or built any other way
|
|
32
|
+
hash = composition.to_h # plain, JSON-safe Hash
|
|
33
|
+
|
|
34
|
+
restored = HeadMusic::Content::Composition.from_h(hash)
|
|
35
|
+
restored.to_h == composition.to_h # => true
|
|
36
|
+
restored.to_musicxml == composition.to_musicxml # => true
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Acceptance Criteria
|
|
40
|
+
|
|
41
|
+
- `HeadMusic::Content::Composition#to_h` returns a plain, JSON-serializable Hash — only `Hash`/`Array`/`String`/`Integer`/`Float`/`true`/`false`/`nil`; no symbols as values and no custom objects.
|
|
42
|
+
- `HeadMusic::Content::Composition.from_h(hash)` reconstructs an equivalent Composition using the existing public construction API (`new`, `add_voice`, `Voice#place`, `change_key_signature`, `change_meter`, `add_comment`) — not private state, where the public builders suffice.
|
|
43
|
+
- **Round-trip identity:** for any Composition `c`, `Composition.from_h(c.to_h).to_h == c.to_h`, and both `#to_musicxml` and `#to_abc` output match the original.
|
|
44
|
+
- **JSON-safety:** `Composition.from_h(JSON.parse(c.to_h.to_json))` round-trips unchanged (guards against non-JSON values leaking into the hash).
|
|
45
|
+
- The hash captures full model fidelity, at minimum:
|
|
46
|
+
- Composition attributes: `name`, `key_signature`, `meter`, `composer`, `origin`, `comments`.
|
|
47
|
+
- Voices with their `role` and ordered placements.
|
|
48
|
+
- Placements: `position` at full precision including tick offsets (`"1:1:480"`, not rounded to the beat grid), `rhythmic_value`, and `pitch` as a pitch-name string — with `nil` preserved as a rest.
|
|
49
|
+
- Chords (simultaneous pitches at a position).
|
|
50
|
+
- Mid-piece `change_key_signature(bar, ...)` and `change_meter(bar, ...)`.
|
|
51
|
+
- Comments (`add_comment(text, position = nil)`, and the constructor's `comments:` string-or-array form).
|
|
52
|
+
- Pitch spellings/accidentals round-trip faithfully (preserve the object's exact pitch string; don't normalize enharmonics).
|
|
53
|
+
- The hash carries a `schema_version` so the format can evolve.
|
|
54
|
+
- Specs cover: single-voice diatonic, accidentals, rests, chords, multi-voice with roles, tick-precise positions, a mid-piece key change, a mid-piece meter change, comments, and a round trip seeded from existing ABC fixtures (e.g. `ABCFixtures::SPEED_THE_PLOUGH`).
|
|
55
|
+
|
|
56
|
+
## Notes
|
|
57
|
+
|
|
58
|
+
**Existing construction/serialization surface** (verified in the current codebase):
|
|
59
|
+
|
|
60
|
+
- Constructor: `Composition#initialize(name:, key_signature:, meter:, composer:, origin:, comments:)` (`lib/head_music/content/composition.rb:8`); `key_signature`/`meter` coerce from strings via `KeySignature.get` / `Meter.get`.
|
|
61
|
+
- Builders: `#add_voice(role:)` (`composition.rb:16`), `Voice#place(position, rhythmic_value, pitch = nil)` (`lib/head_music/content/voice.rb:19`), `#add_comment` (`composition.rb:21`), `#change_key_signature(bar_number, key_signature)` (`composition.rb:44`), `#change_meter(bar_number, meter)` (`composition.rb:48`).
|
|
62
|
+
- Existing serializers: `#to_abc` (`composition.rb:72`), `#to_musicxml` (`composition.rb:76`), `#to_s` (`composition.rb:68`). No `to_h`/`as_json`/`deconstruct` exists today; the only importer is ABC.
|
|
63
|
+
|
|
64
|
+
**Suggested hash shape** (illustrative — adjust to the real model):
|
|
65
|
+
|
|
66
|
+
```ruby
|
|
67
|
+
{
|
|
68
|
+
"schema_version" => 1,
|
|
69
|
+
"name" => "…",
|
|
70
|
+
"key_signature" => "C major",
|
|
71
|
+
"meter" => "4/4",
|
|
72
|
+
"composer" => nil,
|
|
73
|
+
"origin" => nil,
|
|
74
|
+
"comments" => [{ "text" => "…", "position" => "1:1" }],
|
|
75
|
+
"key_changes" => [{ "bar" => 5, "key_signature" => "G major" }],
|
|
76
|
+
"meter_changes" => [{ "bar" => 9, "meter" => "3/4" }],
|
|
77
|
+
"voices" => [
|
|
78
|
+
{
|
|
79
|
+
"role" => "melody",
|
|
80
|
+
"placements" => [
|
|
81
|
+
{ "position" => "1:1", "rhythmic_value" => "quarter", "pitch" => "C4" },
|
|
82
|
+
{ "position" => "1:1:480", "rhythmic_value" => "eighth", "pitch" => "E4" },
|
|
83
|
+
{ "position" => "1:2", "rhythmic_value" => "quarter", "pitch" => nil }
|
|
84
|
+
]
|
|
85
|
+
}
|
|
86
|
+
]
|
|
87
|
+
}
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
**Design preferences.**
|
|
91
|
+
|
|
92
|
+
- Prefer composing `#to_h` from smaller `#to_h` methods on `Voice`, `Placement`, etc. if that fits the codebase style, rather than one monolithic method.
|
|
93
|
+
- Round-trip via strings where constructors already coerce them (key signature, meter, pitch) to avoid instantiating value objects in the hash.
|
|
94
|
+
- No new runtime dependencies; pure Ruby / stdlib.
|
|
95
|
+
|
|
96
|
+
**Serialization layering — `to_h`/`from_h` is the primitive; JSON is thin sugar.**
|
|
97
|
+
|
|
98
|
+
The expensive work is Composition↔hash (walking voices/placements/positions/changes/comments and rebuilding via the builder API). Hash↔JSON-string is trivial (`JSON.generate`/`JSON.parse`), so a JSON API is not a lighter lift — it's the same work plus a string layer. Keep `to_h`/`from_h` as the source of truth and, if convenient, add `to_json`/`from_json` as one-line delegates:
|
|
99
|
+
|
|
100
|
+
```ruby
|
|
101
|
+
def to_h; ...; end # the real primitive
|
|
102
|
+
def to_json(*args) = to_h.to_json(*args) # optional string/API-boundary sugar
|
|
103
|
+
def self.from_h(hash); ...; end # the real primitive
|
|
104
|
+
def self.from_json(str) = from_h(JSON.parse(str)) # optional one-liner
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Rationale for hash-first, not JSON-string-first:
|
|
108
|
+
|
|
109
|
+
- **Postgres json/jsonb wants the hash, not a string.** The driving consumer stores `definition` in a jsonb column; ActiveRecord serializes/deserializes a Ruby `Hash` to/from jsonb automatically (`record.definition = comp.to_h`; `Composition.from_h(record.definition)`). It never handles a JSON string. Returning a JSON string instead would force AR to re-parse it, or push the caller to a plain `text` column and forfeit jsonb indexing/query — strictly worse for this path.
|
|
110
|
+
- **Ruby convention.** Idiomatic `to_json` is built on top of `to_h`/`as_json`; there is no standard `from_json` (the convention is `JSON.parse` → build from hash). The delegates above follow that.
|
|
111
|
+
- **Keep the primitive named `to_h`, not `as_json`.** `as_json` is the ActiveSupport hook; `to_h` stays framework-neutral, which suits a plain gem, and jsonb columns consume it just as happily.
|
|
112
|
+
|
|
113
|
+
So: add `to_json`/`from_json` as optional conveniences for string/HTTP boundaries, but they carry no weight for the DB storage path — that path is pure hash.
|
|
114
|
+
|
|
115
|
+
**Open questions for planning.**
|
|
116
|
+
|
|
117
|
+
- Exact key names and nesting (e.g. inline `key_changes`/`meter_changes` vs. a per-bar structure).
|
|
118
|
+
- How chords are represented in the placement list (grouped array vs. repeated positions) — follow whatever the model already does internally.
|
|
119
|
+
- Whether `from_h` should tolerate/upgrade older `schema_version`s, or hard-fail on mismatch for v1.
|
|
120
|
+
|
|
121
|
+
## Implementation Plan
|
|
122
|
+
|
|
123
|
+
### Overview
|
|
124
|
+
|
|
125
|
+
Add `#to_h` methods to `Placement`, `Comment`, `Bar`, and `Voice`, composed by `Composition#to_h`, plus `Composition.from_h` that replays the hash through the existing public builder API in dependency order (key/meter changes → voices/placements → repeat flags → comments) — the same access path the ABC parser uses (`lib/head_music/notation/abc/parser.rb:250-265`). Three small prerequisite fixes make the round trip actually lossless: tied `RhythmicValue` string parsing (confirmed silent-loss bug), coercing `Bar` writers, and stable placement ordering. Ships as gem 15.2.0 for the bardtheory `~> 15.1` pin.
|
|
126
|
+
|
|
127
|
+
### Finalized hash schema (schema_version 1)
|
|
128
|
+
|
|
129
|
+
All keys are strings; all values are JSON primitives. `voices`, `bars`, and `comments` are always present (possibly `[]`). Placement and comment hashes always include all their keys (`pitch`/`position` may be `null`); top-level `composer`/`origin` emit `null` explicitly — schema stability over compactness. `bars` is sparse: only bars with non-default state appear, each carrying `number` plus only the keys that are set.
|
|
130
|
+
|
|
131
|
+
```json
|
|
132
|
+
{
|
|
133
|
+
"schema_version": 1,
|
|
134
|
+
"name": "Speed the Plough",
|
|
135
|
+
"key_signature": "G major",
|
|
136
|
+
"meter": "4/4",
|
|
137
|
+
"composer": null,
|
|
138
|
+
"origin": null,
|
|
139
|
+
"voices": [
|
|
140
|
+
{
|
|
141
|
+
"role": "melody",
|
|
142
|
+
"placements": [
|
|
143
|
+
{"position": "1:1:000", "rhythmic_value": "eighth", "pitch": "G4"},
|
|
144
|
+
{"position": "1:1:480", "rhythmic_value": "eighth", "pitch": null},
|
|
145
|
+
{"position": "3:1:000", "rhythmic_value": "quarter", "pitch": "C5"},
|
|
146
|
+
{"position": "3:1:000", "rhythmic_value": "quarter", "pitch": "E5"}
|
|
147
|
+
]
|
|
148
|
+
}
|
|
149
|
+
],
|
|
150
|
+
"bars": [
|
|
151
|
+
{"number": 1, "starts_repeat": true},
|
|
152
|
+
{"number": 5, "key_signature": "D major", "meter": "6/8"},
|
|
153
|
+
{"number": 8, "ends_repeat_after_num_plays": 2, "plays_on_passes": [1, 2]}
|
|
154
|
+
],
|
|
155
|
+
"comments": [
|
|
156
|
+
{"text": "First strain", "position": "1:1:000"},
|
|
157
|
+
{"text": "Traditional", "position": null}
|
|
158
|
+
]
|
|
159
|
+
}
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
Value formats — each chosen because the corresponding constructor verifiably coerces the string back:
|
|
163
|
+
|
|
164
|
+
| Field | Format | Round-trip vehicle |
|
|
165
|
+
|---|---|---|
|
|
166
|
+
| `key_signature` (top-level and per-bar) | `KeySignature#name`, e.g. `"F♯ minor"` — **never** `#to_s`, which returns the unparseable `"3 flats"` | `KeySignature.get` (splits tonic + scale type, `key_signature.rb:14-22`); `#name` is always `"<tonic> <parent-scale-type>"` because construction normalizes to the parent type |
|
|
167
|
+
| `meter` | `Meter#to_s`, e.g. `"6/8"` | `Meter.get` (verified) |
|
|
168
|
+
| `position` | `Position#to_s`, e.g. `"1:1:480"` (tick zero-padded to 3) | `Position.new(composition, string)` (verified) |
|
|
169
|
+
| `rhythmic_value` | `RhythmicValue#to_s`, e.g. `"dotted quarter"`, `"half tied to eighth"` | `RhythmicValue.get` (after Step 1 fix) |
|
|
170
|
+
| `pitch` | `Pitch#to_s` with unicode accidentals (`"B♭3"`, `"F𝄪5"`); `null` = rest | `Pitch.get` (verified for unicode; note `"F##5"` returns nil — always emit `#to_s`) |
|
|
171
|
+
| `role` | `role&.to_s` or `null` | `add_voice(role:)`; `cantus_firmus?` already matches on `role.to_s`, so symbol→string is behavior-preserving |
|
|
172
|
+
|
|
173
|
+
### Resolution of the story's open questions (with evidence)
|
|
174
|
+
|
|
175
|
+
1. **Key names/nesting for changes:** a single sparse `"bars"` array, not separate `key_changes`/`meter_changes`. Evidence: `Bar` (`lib/head_music/content/bar.rb`) holds key signature, meter, *and* repeat structure (`starts_repeat`, `ends_repeat_after_num_plays`, `plays_on_passes`) together — and repeats, though absent from the story's fidelity list, are **forced into scope**: `ABC.parse(ABCFixtures::SPEED_THE_PLOUGH)` sets `starts_repeat` on bar 1 and `ends_repeat_after_num_plays = 2` on bar 8 (verified), so the required `to_abc`-equality criterion fails without them.
|
|
176
|
+
2. **Chords:** the model's only chord representation is multiple co-positioned placements in one voice (the ABC lexer treats `[...]` chords as unsupported tokens, `body_lexer.rb:175-176`). No special schema structure needed — the ordered placement list with duplicate `position` values covers it. Caveat: `to_musicxml` currently raises `RenderError` on exactly this structure (verified), so the MusicXML-equality criterion is scoped to compositions MusicXML can render (see Risks).
|
|
177
|
+
3. **schema_version policy:** hard-fail. `raise ArgumentError, "unsupported schema_version: #{version.inspect} (supported: 1)"` unless strictly `Integer` `1` (reject `"1"`; `.inspect` disambiguates). Unknown *keys* are ignored, so additive v1.x evolution stays possible. No migration machinery until a v2 exists.
|
|
178
|
+
4. **Position string parsing:** exists and round-trips — `Position#to_s` emits `"bar:count:tick"` and the constructor parses it by splitting on `/\D+/` (`position.rb:12-19`). Two verified constraints shape `from_h`: (a) parsing rolls counts/ticks over via `composition.meter_at`, so **meter changes must be applied before any position string parses**; (b) negative bars don't survive (`"-1:1:000"` reparses as `[0,1,1]`) — bar 0 does — so `from_h` rejects bar numbers below 0. (Integer `{bar, count, tick}` components were considered for jsonb queryability; strings win on story alignment and compactness given (a) and (b) are handled.)
|
|
179
|
+
5. **Comments:** `Comment` stores text plus an optional `Position` (accepts strings; raises on a foreign composition's Position). The constructor's `comments:` param is **text-only** (`composition.rb:13`), so `from_h` must use `add_comment(text, position)` for every comment; `to_h` always converges both input shapes to `[{"text" => ..., "position" => ... | null}]`.
|
|
180
|
+
|
|
181
|
+
### Steps
|
|
182
|
+
|
|
183
|
+
1. **Fix tied `RhythmicValue` string parsing (prerequisite — confirmed silent-loss bug)**
|
|
184
|
+
- `RhythmicValue.get("half tied to eighth")` currently drops the tie and returns plain `half`, while the ABC duration resolver produces tied values (`abc/duration_resolver.rb:67-73`) — any ABC-seeded composition with a cross-unit duration would serialize lossily and *pass* naive specs. In `.get`'s String branch, split on `" tied to "` and rebuild recursively (`new(head.unit, dots: head.dots, tied_value: tail)`).
|
|
185
|
+
- Spec: `get(rv.to_s) == rv` for single and chained ties.
|
|
186
|
+
- Files: `lib/head_music/rudiment/rhythmic_value.rb`, `spec/head_music/rudiment/rhythmic_value_spec.rb`
|
|
187
|
+
|
|
188
|
+
2. **Coerce `Bar#key_signature=` / `#meter=` writers**
|
|
189
|
+
- They are plain `attr_accessor`s (`bar.rb:9`), so `change_meter(4, "6/8")` stores a raw String, which then breaks `Position` rollover (`meter.ticks_per_count`) and `Bar#to_h`. Replace with writers that call `KeySignature.get` / `Meter.get` when non-nil, mirroring `Bar#initialize` — backward compatible since objects pass through `.get` unchanged.
|
|
190
|
+
- Files: `lib/head_music/content/bar.rb`, `spec/head_music/content/bar_spec.rb`
|
|
191
|
+
|
|
192
|
+
3. **Make `Voice` placement ordering stable**
|
|
193
|
+
- `insert_into_placements` re-sorts with `Placement#<=>` comparing position only; Ruby's sort is unstable, so chord-note order is officially nondeterministic — and the chord round-trip criterion makes order load-bearing. Replace the sort with stable insertion: `index = @placements.index { |existing| existing > placement } || @placements.length; @placements.insert(index, placement)`. Do **not** add a tie-breaker to `Placement#<=>` (position-only comparison is used semantically elsewhere).
|
|
194
|
+
- Files: `lib/head_music/content/voice.rb` (lines 149-152), `spec/head_music/content/voice_spec.rb` (co-positioned chord ordering example)
|
|
195
|
+
|
|
196
|
+
4. **Harden `Composition#bars` for bar numbers below `earliest_bar_number`**
|
|
197
|
+
- Verified: `change_key_signature(0, ...)` on a composition without bar-0 placements raises `NoMethodError` on `nil`. One-line fix: iterate/slice from `[earliest_bar_number, last].min`.
|
|
198
|
+
- Files: `lib/head_music/content/composition.rb` (lines 36-42), `spec/head_music/content/composition_spec.rb`
|
|
199
|
+
|
|
200
|
+
5. **Leaf `#to_h` methods**
|
|
201
|
+
- `Placement#to_h` → `{"position" => position.to_s, "rhythmic_value" => rhythmic_value.to_s, "pitch" => pitch&.to_s}`
|
|
202
|
+
- `Comment#to_h` → `{"text" => text, "position" => position&.to_s}`
|
|
203
|
+
- `Bar#to_h` → sparse hash without `number` (Bar doesn't know its own number): each of key_signature (via `#name`), meter, `starts_repeat`, `ends_repeat_after_num_plays`, `plays_on_passes` only when set; `{}` for a default bar
|
|
204
|
+
- `Voice#to_h` → `{"role" => role&.to_s, "placements" => placements.map(&:to_h)}`
|
|
205
|
+
- Files: `lib/head_music/content/placement.rb`, `comment.rb`, `bar.rb`, `voice.rb`; unit examples in the four existing spec files under `spec/head_music/content/`
|
|
206
|
+
|
|
207
|
+
6. **`Composition#to_h` + `SCHEMA_VERSION = 1`**
|
|
208
|
+
- Assemble the schema; `key_signature.name`, `meter.to_s`. For bars, do **not** use the public `bars` method (it returns a slice, losing the number offset, and stops at `latest_bar_number`); use a private helper over the raw `@bars` array: `each_with_index.filter_map { |bar, number| ... {"number" => number}.merge(bar.to_h) unless bar.nil? || bar.to_h.empty? }`.
|
|
209
|
+
- Files: `lib/head_music/content/composition.rb`, `spec/head_music/content/composition_spec.rb`
|
|
210
|
+
|
|
211
|
+
7. **`Composition.from_h` + `to_json`/`from_json` delegates**
|
|
212
|
+
- Entry: raise `ArgumentError` on non-Hash; `deep_transform_keys(&:to_s)` (ActiveSupport is already a runtime dependency) so symbol-keyed Ruby hashes also load; strict `schema_version` check per Resolution 3.
|
|
213
|
+
- Four phases, each load-bearing:
|
|
214
|
+
1. `new(name:, key_signature:, meter:, composer:, origin:)` then `change_key_signature`/`change_meter` per bar entry — **before any position parses** (meter map drives count/tick rollover).
|
|
215
|
+
2. `add_voice(role:)` + `voice.place(position, rhythmic_value, pitch)` in serialized order (stable insertion from Step 3 preserves chord order).
|
|
216
|
+
3. Repeat flags via `composition.bars(number).last` + Bar's validating public setters — **after placements**, so a pickup-bar-0 flag finds its bar.
|
|
217
|
+
4. `add_comment(text, position)` last (positions parse under the final meter map).
|
|
218
|
+
- **Boundary validation (verified failure modes):** `Pitch.get("garbage")` returns nil — a corrupted pitch would silently deserialize as a *rest*; `RhythmicValue.get("garbage")` returns a broken object that explodes later; `Meter.get("garbage")` raises a useless internal arity error. `from_h` must validate factory results and raise `ArgumentError` with path context (e.g. `voices[0].placements[3]: unknown pitch "H#4"`), and reject bar numbers `< 0`. `ArgumentError` matches the content module's existing convention (`bar.rb`, `comment.rb`).
|
|
219
|
+
- Delegates: `def to_json(*_args) = to_h.to_json`; `def self.from_json(json) = from_h(JSON.parse(json))`.
|
|
220
|
+
- Files: `lib/head_music/content/composition.rb`
|
|
221
|
+
|
|
222
|
+
8. **Round-trip spec suite**
|
|
223
|
+
- New file: `spec/head_music/content/composition_serialization_spec.rb` with a shared helper `expect_lossless_round_trip(composition)` asserting `from_h(to_h).to_h == to_h` plus `to_abc` equality (and `to_musicxml` equality where renderable). Scenario matrix in Testing Strategy below.
|
|
224
|
+
- Files: `spec/head_music/content/composition_serialization_spec.rb`
|
|
225
|
+
|
|
226
|
+
9. **Finish: lint, coverage, release prep**
|
|
227
|
+
- `bundle exec rubocop -a`, `bundle exec rake` (90% coverage gate). Bump to 15.2.0 (minor — bardtheory pins `~> 15.1`). CHANGELOG entry documenting the schema v1 hash as public API: once bardtheory persists it in jsonb, the schema is a compatibility surface independent of gem semver — additive optional keys are fine within version 1 (because `from_h` ignores unknown keys); any change to existing keys' shape or meaning requires a `schema_version` bump.
|
|
228
|
+
- Files: `lib/head_music/version.rb` (or equivalent), `CHANGELOG.md`
|
|
229
|
+
|
|
230
|
+
### Design Considerations
|
|
231
|
+
|
|
232
|
+
(No UI surface — this is a pure-Ruby library story; the ui-ux and accessibility perspectives were deliberately skipped.)
|
|
233
|
+
|
|
234
|
+
- `#to_h` on each content class (alongside existing `#to_s`) matches the gem's value-object style; the Notation precedent of delegating to renderer classes doesn't apply — those translate to foreign formats, while `to_h` is the object's own intrinsic representation. Standard Ruby disables Metrics cops (confirmed), so no cop pressure forces an extracted serializer; extract a builder class from `from_h` only if it exceeds ~5 private helpers.
|
|
235
|
+
- Exporter limitations must never leak into the serialization contract: the hash is the source of truth; for compositions MusicXML rejects, `to_h`/`from_h` still round-trips losslessly and `from_h(c.to_h).to_musicxml` raises the same `RenderError`.
|
|
236
|
+
- `to_h` is pure aside from idempotently materializing the already-memoized `@bars` (which `meter_at` does anyway); `Hash#==` ignores key order, so specs compare hashes, never `to_json` strings.
|
|
237
|
+
|
|
238
|
+
### Testing Strategy
|
|
239
|
+
|
|
240
|
+
| Acceptance criterion | Test |
|
|
241
|
+
|---|---|
|
|
242
|
+
| JSON-safe hash | Recursive walk asserting only Hash/Array/String/Integer/Float/booleans/nil; string keys only |
|
|
243
|
+
| Round-trip + `to_abc`/`to_musicxml` equality | `expect_lossless_round_trip` per scenario below |
|
|
244
|
+
| JSON safety | `from_h(JSON.parse(c.to_h.to_json)).to_h == c.to_h`; also covers the `to_json`/`from_json` delegates |
|
|
245
|
+
| Single-voice diatonic | `ABC.parse` of a simple tune |
|
|
246
|
+
| Accidentals / exact spellings | `ABCFixtures::CHROMATIC_AIR` (also covers composer + origin + minor key); one manual double-sharp placement to prove `𝄪` survives |
|
|
247
|
+
| Rests | `voice.place("1:2:000", :quarter)` → `"pitch" => nil` |
|
|
248
|
+
| Chords | Two placements at `"1:1:000"` in one voice; order preserved; `to_h` + `to_abc` only (MusicXML raises — documented) |
|
|
249
|
+
| Multi-voice with roles | Two `add_voice(role:)` voices, including two voices with the *same* role (voices are an ordered array, never a role-keyed map) |
|
|
250
|
+
| Tick-precise positions | Placement at `"1:1:480"`; exact string in hash |
|
|
251
|
+
| Mid-piece key change | `change_key_signature(5, "D major")` with a string arg (exercises Step 2 coercion) |
|
|
252
|
+
| Mid-piece meter change | `change_meter(3, "6/8")` **with a later placement whose count only parses correctly under 6/8** — locks in the phase-1-before-phase-2 ordering |
|
|
253
|
+
| Comments | With position, without position, and constructor `comments:` string/array forms converging |
|
|
254
|
+
| ABC fixture seed + repeats | `ABC.parse(ABCFixtures::SPEED_THE_PLOUGH)`: `from_h(to_h).to_abc == original.to_abc` (repeats at bars 1 and 8 are load-bearing) and `to_musicxml` equality |
|
|
255
|
+
| Tied durations | ABC note spanning e.g. 5 eighths round-trips (Step 1 fix) |
|
|
256
|
+
| schema_version | Present, `== 1`; `from_h` raises ArgumentError on missing / `2` / `"1"` |
|
|
257
|
+
| Malformed input | Non-Hash, unknown pitch string, unknown rhythmic value, negative bar number → `ArgumentError` with path context, never `NoMethodError` |
|
|
258
|
+
| Edge cases | Empty composition (`voices: []`, compare `to_h` only — `to_musicxml` raises "no voices" on both sides); voice with role but zero placements; `plays_on_passes: [1, 2]` volta bars; unknown top-level key ignored; `"name" => nil` vs `"name" => "Composition"` produce `to_h`-identical results (pinned as documented equivalence) |
|
|
259
|
+
|
|
260
|
+
Unit-level `#to_h` examples extend the existing mirrored specs: `composition_spec.rb`, `voice_spec.rb`, `placement_spec.rb`, `bar_spec.rb`, `comment_spec.rb`, `rhythmic_value_spec.rb`.
|
|
261
|
+
|
|
262
|
+
### Risks & Open Questions
|
|
263
|
+
|
|
264
|
+
**Risks / edge cases**
|
|
265
|
+
|
|
266
|
+
- **Tied-duration bug is the stealthiest failure**: without Step 1, ABC-seeded hashes are silently lossy and round-trip specs that avoid tied durations still pass. It's first in the step order deliberately.
|
|
267
|
+
- **Chords vs `to_musicxml`**: the MusicXML-equality criterion is scoped to renderable compositions (same-voice chords raise `RenderError` today). Fixing MusicXML chord rendering is a separate story — resist coupling it here; bardtheory's cached-render path works today for what it renders, and this story blocks their release.
|
|
268
|
+
- **Phase ordering in `from_h` is load-bearing** (meters before placements; placements before repeat flags; comments last). The 6/8 meter-change spec pins it against regression.
|
|
269
|
+
- **Negative bar numbers** don't survive position strings (`"-1:1:000"` → bar 0) and would index `@bars` from the tail; v1 declares bar ≥ 0 as the supported domain and `from_h` rejects below that. Nothing in the ABC parser produces negatives today.
|
|
270
|
+
- **Silent factory nils**: `Pitch.get` returning nil turns corrupted pitches into rests; only the Step 7 boundary validation prevents wrong-music-no-error outcomes on drifted jsonb rows.
|
|
271
|
+
- **Schema-as-API discipline**: after bardtheory persists hashes, schema v1 is a public contract. `from_h` ignoring unknown keys is what keeps additive evolution possible; hold that line in review.
|
|
272
|
+
|
|
273
|
+
**Open questions — resolved by the user (2026-07-16)**
|
|
274
|
+
|
|
275
|
+
1. **Unicode vs ASCII key/pitch strings**: **Unicode verbatim.** Serialize `KeySignature#name` (`"F♯ minor"`) and `Pitch#to_s` (`"B♭3"`) exactly as emitted; pin with a spec.
|
|
276
|
+
2. **Repeats formally in scope**: **In scope.** Bar-level repeat structure (`starts_repeat`, `ends_repeat_after_num_plays`, `plays_on_passes`) serializes in the sparse `"bars"` array; the SPEED_THE_PLOUGH `to_abc` criterion stands as written.
|
|
277
|
+
3. **`schema_version` strictness**: **Strict Integer.** `from_h` raises `ArgumentError` on `"1"`, `2`, or missing.
|
|
278
|
+
|
|
279
|
+
## Review
|
|
280
|
+
|
|
281
|
+
**Date:** 2026-07-16 · **Commit reviewed:** 5b0fd0a · Full suite: 5640 examples, 0 failures, 99.73% line coverage.
|
|
282
|
+
|
|
283
|
+
### Acceptance criteria
|
|
284
|
+
|
|
285
|
+
- ✅ **`#to_h` returns a plain JSON-serializable Hash** — `lib/head_music/content/composition.rb:91-103`; recursive-walk assertion in `composition_serialization_spec.rb:20-34`.
|
|
286
|
+
- ✅ **`.from_h` rebuilds via the public construction API** — `HashDeserializer` (`composition.rb:152-301`) replays `new` → `change_key_signature`/`change_meter` → `add_voice` + `Voice#place` → public `Bar` repeat setters → `add_comment`; no private state touched.
|
|
287
|
+
- ⚠️ **Round-trip identity incl. `to_musicxml`/`to_abc` equality** — *scoped-met*: `from_h(c.to_h).to_h == c.to_h` holds universally; renderer-output equality is asserted wherever the renderers can render. ABC comparison skipped for multi-voice, mid-piece changes, and chords; MusicXML for same-voice chords and empty compositions — pre-existing renderer limitations, not serialization loss. Compensating specs pin that the restored composition raises the identical `RenderError`. The literal "for any Composition" is unsatisfiable without separate renderer fixes.
|
|
288
|
+
- ✅ **JSON-safety round trip** — `from_h(JSON.parse(c.to_h.to_json)).to_h == c.to_h` plus `to_json`/`from_json` delegates (`composition_serialization_spec.rb:73-82`).
|
|
289
|
+
- ✅ **Full model fidelity** — attributes, voices with roles + ordered placements, tick-precise positions, tied rhythmic values, nil-pitch rests, chords as co-positioned placements, mid-piece key/meter changes and repeat/volta state in sparse `"bars"`, comments in both builder and constructor forms.
|
|
290
|
+
- ✅ **Pitch spellings round-trip faithfully** — enharmonics not normalized (`B♭4` vs `A♯4`); double sharp `F𝄪5` and `"F♯ minor"` verbatim unicode.
|
|
291
|
+
- ✅ **`schema_version` carried** — `SCHEMA_VERSION = 1`; strict-Integer validation raising `ArgumentError` on missing/`2`/`"1"`.
|
|
292
|
+
- ✅ **Spec coverage of all listed scenarios** — all ten present in `composition_serialization_spec.rb` (single-voice diatonic L85, accidentals L102 via `CHROMATIC_AIR`, rests L168, chords L190, multi-voice roles L216, tick positions L238, key change L259, meter change with phase-ordering pin L279, comments L314, `SPEED_THE_PLOUGH` round trip with repeats L349), plus tied durations, malformed input, and edge cases beyond the list.
|
|
293
|
+
|
|
294
|
+
**Deliverable note:** version bumped to 15.2.0 with CHANGELOG documenting schema v1 as a compatibility surface, but publishing to RubyGems is a manual step outside this branch.
|
|
295
|
+
|
|
296
|
+
### Code review findings
|
|
297
|
+
|
|
298
|
+
**Critical:** none.
|
|
299
|
+
|
|
300
|
+
**Important:**
|
|
301
|
+
|
|
302
|
+
1. ✅ **FIXED** — `composition.rb` — **invalid key signature slips through validation.** `KeySignature.get("Q major")` returns a hollow object (`tonic_spelling=nil`) rather than nil, passing the truthiness guard. Fixed: `parsed_key_signature` now requires `tonic_spelling`; corrupted-input specs added (top-level and per-bar).
|
|
303
|
+
2. ✅ **FIXED** — `composition.rb` — **position strings never validated.** `Position.new` silently coerces garbage to `"0:1:000"`. Fixed: new `parsed_position` helper validates the `bar[:count[:tick]]` shape (non-negative parts) for placements and comments; specs added for garbage, negative-bar, and comment positions.
|
|
304
|
+
|
|
305
|
+
**Minor:**
|
|
306
|
+
|
|
307
|
+
3. `composition.rb` — repeat flags accept any truthy value (string `"yes"` acts as `true`); harmless for real JSON (booleans survive), inconsistent with otherwise-strict validation. Accepted as-is.
|
|
308
|
+
4. ✅ **FIXED** — corrupted-input spec block lacked invalid-key-signature and invalid-position cases; five specs added alongside findings 1–2.
|
|
309
|
+
|
|
310
|
+
**Verified non-issues:** `to_json(*_args)` nests correctly in `{a: composition}.to_json`; stable chord insertion correct; `from_tied_words` recursion terminates; `to_h` leaks no mutable state; pickup-bar reindexing consistent.
|
|
311
|
+
|
|
312
|
+
**Verdict:** solid, well-tested feature. Findings 1–2 (and the spec gap, finding 4) fixed post-review; full suite 5645 examples, 0 failures, 99.73% line coverage. Nothing blocks `finish` except the manual RubyGems release.
|
|
313
|
+
|
|
314
|
+
## Learnings
|
|
315
|
+
|
|
316
|
+
**What went well**
|
|
317
|
+
|
|
318
|
+
- **Planning that verifies code claims pays off.** The planner ran live probes instead of trusting the story's assumptions and surfaced three real pre-existing bugs (tied `RhythmicValue.get` silently dropping ties, uncoerced `Bar` writers, unstable chord ordering) before implementation. Sequencing those as prerequisite steps meant the round-trip specs were trustworthy from the start — a naive plan would have shipped lossy serialization with passing tests.
|
|
319
|
+
- **Independent spec-writer as verifier.** A separate agent wrote the round-trip suite against the finished implementation, instructed to fix any bugs its specs exposed. It found zero — meaningful evidence rather than self-confirmation.
|
|
320
|
+
- **File-conflict-based wave planning** allowed safe parallelism (tied-value fix ∥ content-model fixes) with no conflicts between agents.
|
|
321
|
+
- **Resolving open questions before implementation** (unicode verbatim, repeats in scope, strict Integer schema_version) meant zero mid-implementation scope churn.
|
|
322
|
+
|
|
323
|
+
**What was surprising**
|
|
324
|
+
|
|
325
|
+
- **Hollow objects are the gem's dominant boundary failure mode.** `RhythmicValue.get("garbage")`, `KeySignature.get("Q major")`, and `Meter.get("0/4")` all return broken-but-truthy objects instead of nil/raising, while `Pitch.get` returns nil and `Position` silently coerces garbage to `"0:1:000"`. Every factory needed a bespoke validity check; code review caught the two that slipped (key signature, position).
|
|
326
|
+
- **Repeats were forced into scope by an acceptance criterion**, not by the fidelity list — the `SPEED_THE_PLOUGH` `to_abc` equality was unimplementable without serializing repeat structure. Criteria examples can carry hidden scope.
|
|
327
|
+
- **The renderers are lossier than expected**: ABC raises on multi-voice, mid-piece changes, and chords; MusicXML raises on same-voice chords. The "for any Composition" renderer-equality criterion was unsatisfiable as written and had to be scoped to renderable material.
|
|
328
|
+
|
|
329
|
+
**What to do differently next time**
|
|
330
|
+
|
|
331
|
+
- When planning a `from_h`-style boundary, enumerate every factory's garbage behavior up front (a quick probe table) instead of discovering hollow-object cases one review at a time.
|
|
332
|
+
- Write renderer-equality acceptance criteria as "where the renderer supports the material" — or spike renderer limits before pinning criteria to fixtures.
|
|
333
|
+
- Follow-up stories worth filing: MusicXML same-voice chord rendering; the bar-1 change visibility off-by-one in `last_meter_change`/`last_key_signature_change`; ASCII key/pitch aliases for jsonb querying if bardtheory wants them.
|
|
334
|
+
|
|
335
|
+
**Reminder:** the RubyGems release of 15.2.0 is the actual deliverable for bardtheory — this branch only stages it.
|
data/user-stories/index.html
CHANGED
|
@@ -262,6 +262,10 @@
|
|
|
262
262
|
"title": "LilyPond Interpreter",
|
|
263
263
|
"path": "backlog/lilypond-interpreter.md"
|
|
264
264
|
},
|
|
265
|
+
{
|
|
266
|
+
"title": "Organizing Content",
|
|
267
|
+
"path": "backlog/organizing-content.md"
|
|
268
|
+
},
|
|
265
269
|
{
|
|
266
270
|
"title": "Sixteenth-Century (Renaissance) Style Guides",
|
|
267
271
|
"path": "backlog/sixteenth-century-style.md"
|
|
@@ -269,18 +273,13 @@
|
|
|
269
273
|
{
|
|
270
274
|
"title": "Split Counterpoint Species Guides by Pedagogical Author",
|
|
271
275
|
"path": "backlog/split-counterpoint-species-by-author.md"
|
|
272
|
-
},
|
|
273
|
-
{
|
|
274
|
-
"title": "Organizing Content",
|
|
275
|
-
"path": "backlog/organizing-content.md"
|
|
276
276
|
}
|
|
277
277
|
],
|
|
278
|
-
"active": [
|
|
279
|
-
],
|
|
278
|
+
"active": [],
|
|
280
279
|
"done": [
|
|
281
280
|
{
|
|
282
|
-
"title": "
|
|
283
|
-
"path": "
|
|
281
|
+
"title": "Composition Serialization (to_h / from_h)",
|
|
282
|
+
"path": "done/composition-serialization.md"
|
|
284
283
|
},
|
|
285
284
|
{
|
|
286
285
|
"title": "ABC Notation Export",
|
|
@@ -322,6 +321,10 @@
|
|
|
322
321
|
"title": "Dyad Analysis",
|
|
323
322
|
"path": "done/dyad-analysis.md"
|
|
324
323
|
},
|
|
324
|
+
{
|
|
325
|
+
"title": "expand-playing-techniques",
|
|
326
|
+
"path": "done/expand-playing-techniques.md"
|
|
327
|
+
},
|
|
325
328
|
{
|
|
326
329
|
"title": "Handling Time and Tempo",
|
|
327
330
|
"path": "done/handle-time.md"
|
|
@@ -330,6 +333,10 @@
|
|
|
330
333
|
"title": "Instrument Inheritance Architecture",
|
|
331
334
|
"path": "done/instrument-architecture.md"
|
|
332
335
|
},
|
|
336
|
+
{
|
|
337
|
+
"title": "instrument-variant",
|
|
338
|
+
"path": "done/instrument-variant.md"
|
|
339
|
+
},
|
|
333
340
|
{
|
|
334
341
|
"title": "Instruments can have strings with pitches",
|
|
335
342
|
"path": "done/string-pitches.md"
|
|
@@ -346,6 +353,10 @@
|
|
|
346
353
|
"title": "Move StaffPosition to Notation Module",
|
|
347
354
|
"path": "done/move-staff-position-to-notation.md"
|
|
348
355
|
},
|
|
356
|
+
{
|
|
357
|
+
"title": "MusicXML Export",
|
|
358
|
+
"path": "done/music-xml-export.md"
|
|
359
|
+
},
|
|
349
360
|
{
|
|
350
361
|
"title": "Notation Module Foundation",
|
|
351
362
|
"path": "done/notation-module-foundation.md"
|
|
@@ -370,14 +381,6 @@
|
|
|
370
381
|
"title": "Sonority Identification",
|
|
371
382
|
"path": "done/sonority-identification.md"
|
|
372
383
|
},
|
|
373
|
-
{
|
|
374
|
-
"title": "expand-playing-techniques",
|
|
375
|
-
"path": "done/expand-playing-techniques.md"
|
|
376
|
-
},
|
|
377
|
-
{
|
|
378
|
-
"title": "instrument-variant",
|
|
379
|
-
"path": "done/instrument-variant.md"
|
|
380
|
-
},
|
|
381
384
|
{
|
|
382
385
|
"title": "superclass-for-note",
|
|
383
386
|
"path": "done/superclass-for-note.md"
|
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: 15.
|
|
4
|
+
version: 15.2.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-17 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: activesupport
|
|
@@ -421,6 +421,7 @@ files:
|
|
|
421
421
|
- user-stories/backlog/split-counterpoint-species-by-author.md
|
|
422
422
|
- user-stories/done/abc-notation-export.md
|
|
423
423
|
- user-stories/done/abc-notation-interpreter.md
|
|
424
|
+
- user-stories/done/composition-serialization.md
|
|
424
425
|
- user-stories/done/configurable-large-leap-recovery.md
|
|
425
426
|
- user-stories/done/consonance-dissonance-classification.md
|
|
426
427
|
- user-stories/done/dyad-analysis.md
|