head_music 17.0.0 → 17.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 +12 -0
- data/Gemfile.lock +2 -2
- data/lib/head_music/notation/abc/body_lexer.rb +14 -2
- data/lib/head_music/notation/abc/parser.rb +80 -4
- data/lib/head_music/notation/music_xml/writer.rb +16 -4
- data/lib/head_music/version.rb +1 -1
- data/user-stories/done/abc-tie-input.md +195 -0
- data/user-stories/done/musicxml-chord-rendering.md +191 -0
- data/user-stories/index.html +18 -14
- metadata +3 -2
- data/user-stories/backlog/musicxml-chord-rendering.md +0 -57
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: afa3afecdb9f3baef8bc34bb645805cd7defbd206e388a2a156d1f4f80516656
|
|
4
|
+
data.tar.gz: b858239c696ef22b759d4a96f033588cf871b83254538c991c8fdff67565a179
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b3c54b4663df784f5382fd992500df795cf69e985501596d592bf7ffe5c0a7887ecc81ad36e76cbebf9a0e30597607ef2db629c3e8946e128043f9f5aa012976
|
|
7
|
+
data.tar.gz: d5f6c002b3f4522dcdf2f3741ecacd933ea3bb2c33ee7a438ca8a649eb57b2e8d68053a6a8a0a616f17d1e3087b733dccf1d384a0ae6c27084854474c528deea
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [17.2.0] - 2026-07-19
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- The ABC interpreter reads explicit ties (`-`) between notes. `E3-E2` fuses into one sounding note whose rhythmic value carries the authored split (`dotted quarter tied to quarter`), overriding the resolver's greedy decomposition, and tie chains (`C2-C2-C2`) nest into a single value. A tie between notes of different pitches, a dangling tie, or a tie to a rest raises `ParseError`; a tie across a barline raises `ParseError` ("Ties across barlines are not yet supported") pending a future release.
|
|
15
|
+
|
|
16
|
+
## [17.1.0] - 2026-07-18
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
|
|
20
|
+
- The MusicXML writer renders chord placements as stacked notes rather than raising `RenderError`: a chord emits one `<note>` per pitched sound, ordered low to high, with `<chord/>` on all but the lowest note, all sharing the placement's rhythmic value. Tied chords emit a full chord stack per tie-link. The writer still raises `RenderError` for placements containing unpitched sounds (percussion rendering lands in a future release).
|
|
21
|
+
|
|
10
22
|
## [17.0.0] - 2026-07-18
|
|
11
23
|
|
|
12
24
|
### Added
|
data/Gemfile.lock
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
PATH
|
|
2
2
|
remote: .
|
|
3
3
|
specs:
|
|
4
|
-
head_music (17.
|
|
4
|
+
head_music (17.2.0)
|
|
5
5
|
activesupport (>= 7.0, < 10)
|
|
6
6
|
humanize (~> 2.0)
|
|
7
7
|
i18n (~> 1.8)
|
|
@@ -88,7 +88,7 @@ GEM
|
|
|
88
88
|
i18n (1.14.7)
|
|
89
89
|
concurrent-ruby (~> 1.0)
|
|
90
90
|
ice_nine (0.11.2)
|
|
91
|
-
json (2.
|
|
91
|
+
json (2.21.1)
|
|
92
92
|
kramdown (2.5.1)
|
|
93
93
|
rexml (>= 3.3.9)
|
|
94
94
|
language_server-protocol (3.17.0.5)
|
|
@@ -128,6 +128,7 @@ class HeadMusic::Notation::ABC::BodyLexer
|
|
|
128
128
|
return if scan_bracket(scanner, line_number, column, tokens)
|
|
129
129
|
return if scan_note(scanner, line_number, column, tokens)
|
|
130
130
|
return if scan_rest(scanner, line_number, column, tokens)
|
|
131
|
+
return if scan_tie(scanner, line_number, column, tokens)
|
|
131
132
|
return if scan_broken_rhythm(scanner, line_number, column, tokens)
|
|
132
133
|
return if scan_unsupported(scanner, line_number, column, tokens)
|
|
133
134
|
|
|
@@ -277,13 +278,24 @@ class HeadMusic::Notation::ABC::BodyLexer
|
|
|
277
278
|
true
|
|
278
279
|
end
|
|
279
280
|
|
|
281
|
+
# A tie (a hyphen following a note or chord) joins it to the next
|
|
282
|
+
# note of the same pitch; the parser fuses the two into one sounding
|
|
283
|
+
# value. Ties inside a bracket chord still surface as unsupported via
|
|
284
|
+
# the chord fallback.
|
|
285
|
+
def scan_tie(scanner, line_number, column, tokens)
|
|
286
|
+
return false unless scanner.scan("-")
|
|
287
|
+
|
|
288
|
+
tokens << Token.new(type: :tie, line: line_number, column: column)
|
|
289
|
+
true
|
|
290
|
+
end
|
|
291
|
+
|
|
280
292
|
# Recognizable ABC we deliberately don't handle: grace notes,
|
|
281
|
-
# decorations, tuplets, slurs,
|
|
293
|
+
# decorations, tuplets, slurs, and special rests.
|
|
282
294
|
def scan_unsupported(scanner, line_number, column, tokens)
|
|
283
295
|
lexeme =
|
|
284
296
|
scanner.scan(/\{[^}]*\}/) || scanner.scan(/\{[^}]*/) ||
|
|
285
297
|
scanner.scan(/![^!]*!/) || scanner.scan(/![^!]*/) ||
|
|
286
|
-
scanner.scan(/\(\d/) || scanner.scan(/[()
|
|
298
|
+
scanner.scan(/\(\d/) || scanner.scan(/[()~.]/) ||
|
|
287
299
|
scanner.scan(/Z\d*/) || scanner.scan(%r{x[\d/]*})
|
|
288
300
|
return false unless lexeme
|
|
289
301
|
|
|
@@ -36,15 +36,21 @@ module HeadMusic::Notation::ABC
|
|
|
36
36
|
|
|
37
37
|
# A note or chord whose placement is deferred until we know whether
|
|
38
38
|
# a broken-rhythm mark follows it. The pitches are computed eagerly
|
|
39
|
-
# so bar-line accidental resets cannot corrupt them.
|
|
40
|
-
|
|
39
|
+
# so bar-line accidental resets cannot corrupt them. `tied_prefix`,
|
|
40
|
+
# when present, is the already-built rhythmic value of everything
|
|
41
|
+
# tied ahead of this note; its own value is appended at flush time.
|
|
42
|
+
PendingNote = Data.define(:pitches, :length, :scale, :tied_prefix) do
|
|
43
|
+
def initialize(pitches:, length:, scale:, tied_prefix: nil)
|
|
44
|
+
super
|
|
45
|
+
end
|
|
46
|
+
end
|
|
41
47
|
|
|
42
48
|
# Per-voice interpretation state. Accidentals, the deferred note,
|
|
43
49
|
# and volta tracking are all independent between voices.
|
|
44
50
|
class VoiceState
|
|
45
51
|
attr_reader :voice, :pitch_builder
|
|
46
52
|
attr_accessor :pending_note, :awaiting_scale, :broken_line,
|
|
47
|
-
:active_passes, :volta_start_bar
|
|
53
|
+
:active_passes, :volta_start_bar, :tie_open, :tie_line
|
|
48
54
|
|
|
49
55
|
def initialize(voice, pitch_builder)
|
|
50
56
|
@voice = voice
|
|
@@ -148,6 +154,7 @@ module HeadMusic::Notation::ABC
|
|
|
148
154
|
when :note then handle_note(token)
|
|
149
155
|
when :chord then handle_chord(token)
|
|
150
156
|
when :rest then handle_rest(token)
|
|
157
|
+
when :tie then handle_tie(token)
|
|
151
158
|
when :broken_rhythm then handle_broken_rhythm(token)
|
|
152
159
|
when :bar_line then handle_bar_line(token)
|
|
153
160
|
when :volta then handle_volta(token)
|
|
@@ -207,19 +214,69 @@ module HeadMusic::Notation::ABC
|
|
|
207
214
|
def defer_placement(state, pitches, length, inner_scale = Rational(1))
|
|
208
215
|
scale = (state.awaiting_scale || Rational(1)) * inner_scale
|
|
209
216
|
state.awaiting_scale = nil
|
|
217
|
+
return tie_onto_pending(state, pitches, length, scale) if state.tie_open
|
|
218
|
+
|
|
210
219
|
flush_pending_note(state)
|
|
211
220
|
state.pending_note = PendingNote.new(pitches: pitches, length: length, scale: scale)
|
|
212
221
|
end
|
|
213
222
|
|
|
223
|
+
# A tie (`-`) after a note or chord fuses it to the next note of the
|
|
224
|
+
# same pitch. The left note stays pending; the tie is only closed once
|
|
225
|
+
# its right note arrives.
|
|
226
|
+
def handle_tie(token)
|
|
227
|
+
state = current_state
|
|
228
|
+
if state.awaiting_scale || state.pending_note.nil?
|
|
229
|
+
raise ParseError.new(
|
|
230
|
+
"A tie must follow a note", line_number: token.line, snippet: "-"
|
|
231
|
+
)
|
|
232
|
+
end
|
|
233
|
+
state.tie_open = true
|
|
234
|
+
state.tie_line = token.line
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# Closes an open tie: the pending note becomes the new note's tied
|
|
238
|
+
# prefix, so the pair (and any longer chain) resolves to a single
|
|
239
|
+
# placement whose rhythmic value carries the author's chosen split.
|
|
240
|
+
def tie_onto_pending(state, pitches, length, scale)
|
|
241
|
+
pending = state.pending_note
|
|
242
|
+
ensure_tie_pitches_match(state, pending, pitches)
|
|
243
|
+
prefix = pending_rhythmic_value(pending)
|
|
244
|
+
state.tie_open = false
|
|
245
|
+
state.tie_line = nil
|
|
246
|
+
state.pending_note = PendingNote.new(
|
|
247
|
+
pitches: pitches, length: length, scale: scale, tied_prefix: prefix
|
|
248
|
+
)
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def ensure_tie_pitches_match(state, pending, pitches)
|
|
252
|
+
return if pending.pitches.sort == pitches.sort
|
|
253
|
+
|
|
254
|
+
raise ParseError.new(
|
|
255
|
+
"A tie must connect two notes of the same pitch",
|
|
256
|
+
line_number: state.tie_line, snippet: "-"
|
|
257
|
+
)
|
|
258
|
+
end
|
|
259
|
+
|
|
214
260
|
def handle_rest(token)
|
|
215
261
|
ensure_not_awaiting_note(token)
|
|
216
262
|
state = current_state
|
|
263
|
+
reject_open_tie(state, token.line, "A tie must be followed by a note")
|
|
217
264
|
flush_pending_note(state)
|
|
218
265
|
place(state, token.length, nil)
|
|
219
266
|
end
|
|
220
267
|
|
|
268
|
+
# A tie left open by a non-note terminator can never close, so each
|
|
269
|
+
# terminator rejects it. A bar line gets its own message: an author
|
|
270
|
+
# tie across a barline is a real, but not-yet-supported, request.
|
|
271
|
+
def reject_open_tie(state, line, message)
|
|
272
|
+
return unless state&.tie_open
|
|
273
|
+
|
|
274
|
+
raise ParseError.new(message, line_number: line || state.tie_line, snippet: "-")
|
|
275
|
+
end
|
|
276
|
+
|
|
221
277
|
def handle_broken_rhythm(token)
|
|
222
278
|
state = current_state
|
|
279
|
+
reject_open_tie(state, token.line, "A tie must be followed by a note")
|
|
223
280
|
if state.awaiting_scale || state.pending_note.nil?
|
|
224
281
|
raise ParseError.new(
|
|
225
282
|
"Broken rhythm must appear between two notes",
|
|
@@ -235,6 +292,7 @@ module HeadMusic::Notation::ABC
|
|
|
235
292
|
def handle_bar_line(token)
|
|
236
293
|
ensure_not_awaiting_note(token)
|
|
237
294
|
state = current_state
|
|
295
|
+
reject_open_tie(state, token.line, "Ties across barlines are not yet supported")
|
|
238
296
|
flush_pending_note(state)
|
|
239
297
|
tag_completed_bar(state)
|
|
240
298
|
apply_repeat_flags(state, token.style)
|
|
@@ -248,6 +306,7 @@ module HeadMusic::Notation::ABC
|
|
|
248
306
|
raise ParseError.new("Volta has no passes", line_number: token.line)
|
|
249
307
|
end
|
|
250
308
|
state = current_state
|
|
309
|
+
reject_open_tie(state, token.line, "A tie must be followed by a note")
|
|
251
310
|
flush_pending_note(state)
|
|
252
311
|
state.active_passes = token.passes
|
|
253
312
|
state.volta_start_bar = state.entered_bar_number
|
|
@@ -257,6 +316,7 @@ module HeadMusic::Notation::ABC
|
|
|
257
316
|
# Guarded so a leading V: line doesn't force a default voice into existence.
|
|
258
317
|
if @current_state
|
|
259
318
|
ensure_not_awaiting_note(token, state: @current_state)
|
|
319
|
+
reject_open_tie(@current_state, token.line, "A tie must be followed by a note")
|
|
260
320
|
flush_pending_note(@current_state)
|
|
261
321
|
end
|
|
262
322
|
@current_state = voice_state(token.voice_id)
|
|
@@ -265,6 +325,7 @@ module HeadMusic::Notation::ABC
|
|
|
265
325
|
def finish
|
|
266
326
|
@voice_states.each_value do |state|
|
|
267
327
|
ensure_not_awaiting_note(nil, state: state)
|
|
328
|
+
reject_open_tie(state, nil, "A tie must be followed by a note")
|
|
268
329
|
flush_pending_note(state)
|
|
269
330
|
tag_completed_bar(state)
|
|
270
331
|
end
|
|
@@ -284,7 +345,22 @@ module HeadMusic::Notation::ABC
|
|
|
284
345
|
return unless pending
|
|
285
346
|
|
|
286
347
|
state.pending_note = nil
|
|
287
|
-
place(state, pending
|
|
348
|
+
state.voice.place(state.voice.next_position, pending_rhythmic_value(pending), pending.pitches)
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
# A pending note's own value, with any tied prefix appended ahead of
|
|
352
|
+
# it so the whole tie chain renders as one sounding note.
|
|
353
|
+
def pending_rhythmic_value(pending)
|
|
354
|
+
own = duration_resolver.rhythmic_value(pending.length, scale: pending.scale)
|
|
355
|
+
pending.tied_prefix ? append_tied(pending.tied_prefix, own) : own
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
# Attaches `tail` at the deep end of `head`'s tied chain, rebuilding
|
|
359
|
+
# each link (RhythmicValue exposes no setter) so a chain like
|
|
360
|
+
# "half tied to eighth" gains a further "tied to quarter".
|
|
361
|
+
def append_tied(head, tail)
|
|
362
|
+
inner = head.tied_value ? append_tied(head.tied_value, tail) : tail
|
|
363
|
+
HeadMusic::Rudiment::RhythmicValue.new(head.unit, dots: head.dots, tied_value: inner)
|
|
288
364
|
end
|
|
289
365
|
|
|
290
366
|
def place(state, length, pitches, scale: Rational(1))
|
|
@@ -312,13 +312,21 @@ module HeadMusic::Notation::MusicXML
|
|
|
312
312
|
|
|
313
313
|
def note_lines(placement)
|
|
314
314
|
ensure_pitched_sounds(placement)
|
|
315
|
-
raise RenderError, "chords are not yet supported by the MusicXML writer" if placement.chord?
|
|
316
315
|
|
|
317
316
|
components_by_placement[placement].flat_map do |component|
|
|
318
|
-
|
|
317
|
+
note_slots(placement).each_with_index.flat_map do |pitch, index|
|
|
318
|
+
note_element_lines(placement, component, pitch: pitch, chord: index.positive?)
|
|
319
|
+
end
|
|
319
320
|
end
|
|
320
321
|
end
|
|
321
322
|
|
|
323
|
+
# A rest emits one empty slot; a sounded placement emits its pitches low to
|
|
324
|
+
# high, so the lowest note leads and the rest carry <chord/>. ensure_pitched_sounds
|
|
325
|
+
# has already rejected any unpitched sound, so pitches covers every sound here.
|
|
326
|
+
def note_slots(placement)
|
|
327
|
+
placement.rest? ? [nil] : placement.pitches.sort
|
|
328
|
+
end
|
|
329
|
+
|
|
322
330
|
def ensure_pitched_sounds(placement)
|
|
323
331
|
unpitched = placement.sounds.find { |sound| !sound.pitched? }
|
|
324
332
|
return unless unpitched
|
|
@@ -327,10 +335,14 @@ module HeadMusic::Notation::MusicXML
|
|
|
327
335
|
"percussion rendering is not yet supported"
|
|
328
336
|
end
|
|
329
337
|
|
|
330
|
-
|
|
338
|
+
# A chord note carries <chord/> as its first child, before <pitch>, marking
|
|
339
|
+
# it as sounding with the preceding note; the lead note (and every single
|
|
340
|
+
# note and rest) omits it, so this path stays byte-identical for those.
|
|
341
|
+
def note_element_lines(placement, component, pitch: nil, chord: false)
|
|
331
342
|
[
|
|
332
343
|
"#{INDENT * 3}<note>",
|
|
333
|
-
*(
|
|
344
|
+
*(chord ? ["#{INDENT * 4}<chord/>"] : []),
|
|
345
|
+
*(pitch ? pitch_lines(pitch) : ["#{INDENT * 4}<rest/>"]),
|
|
334
346
|
"#{INDENT * 4}<duration>#{component.duration}</duration>",
|
|
335
347
|
*tie_lines(placement, component),
|
|
336
348
|
"#{INDENT * 4}<type>#{component.type}</type>",
|
data/lib/head_music/version.rb
CHANGED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
metadata:
|
|
3
|
+
created_at: 2026-07-19T11:46:34-07:00
|
|
4
|
+
activated_at: 2026-07-19T11:52:42-07:00
|
|
5
|
+
planned_at: 2026-07-19T11:57:00-07:00
|
|
6
|
+
finished_at: 2026-07-19T15:56:20-07:00
|
|
7
|
+
updated_at: 2026-07-19T15:56:20-07:00
|
|
8
|
+
-->
|
|
9
|
+
|
|
10
|
+
# Story: ABC Tie Input
|
|
11
|
+
|
|
12
|
+
## Summary
|
|
13
|
+
|
|
14
|
+
AS a developer authoring music with the ABC interpreter
|
|
15
|
+
|
|
16
|
+
I WANT to write explicit ties (`-`) between notes
|
|
17
|
+
|
|
18
|
+
SO THAT I can control how a sustained duration is engraved — including durations that span a barline — instead of accepting the interpreter's automatic decomposition
|
|
19
|
+
|
|
20
|
+
## Background
|
|
21
|
+
|
|
22
|
+
The [ABC Notation interpreter](../done/abc-notation-interpreter.md) reads ABC text into a `HeadMusic::Content::Composition` via `HeadMusic::Notation::ABC.parse`. It handles pitches, durations, chords, broken rhythm, and voltas — but it **deliberately rejects the tie character `-`**. `BodyLexer#scan_unsupported` folds `-` into an `:unsupported` token (character class `/[()\-~.]/`, whose comment reads "Recognizable ABC we deliberately don't handle: grace notes, decorations, tuplets, slurs, **ties**, and special rests"), and `Parser#reject_unsupported_tokens` then raises `UnsupportedFeatureError`.
|
|
23
|
+
|
|
24
|
+
Ties are already *modeled* everywhere except at the input boundary:
|
|
25
|
+
|
|
26
|
+
- `HeadMusic::Rudiment::RhythmicValue` carries a `tied_value`, and its string form round-trips "half tied to eighth".
|
|
27
|
+
- `ABC::DurationResolver#build_rhythmic_value` already emits tied chains, decomposing any non-dotted-expressible duration by **greedily peeling the largest dotted head each pass**. So `E5` (five eighths under `L:1/8`) always becomes **half tied to eighth** (4 + 1) — the resolver never chooses 3 + 2.
|
|
28
|
+
- The ABC exporter's `DurationWriter` already **collapses** a tied chain back into a single multiplier ("A tied chain collapses to one multiplier, round-tripping tokens like `A5`").
|
|
29
|
+
|
|
30
|
+
The gap is authoring. Because the split is derived greedily and can't be overridden, an author cannot ask for a **dotted quarter tied to a quarter** (3 + 2) rather than **half tied to an eighth** (4 + 1), and — more fundamentally — cannot tie a note *across a barline*, which is the primary reason ties exist in ABC.
|
|
31
|
+
|
|
32
|
+
This surfaced concretely in bardtheory's "Three Blind Mice" seed. In `6/8` with `L:1/8`, measure 8 is `E5 G` — a sustained E leading to a pickup G. The author wants that E engraved as a dotted quarter tied to a quarter; the interpreter can only produce half-tied-to-eighth, and `E3-E2 G` fails to parse.
|
|
33
|
+
|
|
34
|
+
The [Notation Module epic](../epics/notation-module.md) already lists this as planned work: "Future: … ties …" and "Ties (connecting same pitches across bars)."
|
|
35
|
+
|
|
36
|
+
## Example
|
|
37
|
+
|
|
38
|
+
```
|
|
39
|
+
X:1
|
|
40
|
+
T:Tie examples
|
|
41
|
+
M:6/8
|
|
42
|
+
L:1/8
|
|
43
|
+
K:C
|
|
44
|
+
% Author-chosen split: dotted quarter tied to a quarter, then a pickup G
|
|
45
|
+
E3-E2 G |
|
|
46
|
+
% Tie across a barline: the C sustains from bar 1 into bar 2
|
|
47
|
+
C6 | C3-C3 |]
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
```ruby
|
|
51
|
+
composition = HeadMusic::Notation::ABC.parse(abc)
|
|
52
|
+
# The E in bar 1 is a single sounding note whose RhythmicValue is
|
|
53
|
+
# "dotted quarter tied to quarter" — the authored split, not 4 + 1.
|
|
54
|
+
# The tied C sustains across the barline as one note.
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Acceptance Criteria
|
|
58
|
+
|
|
59
|
+
- The lexer recognizes `-` immediately following a note (or chord) as a **tie token** rather than an unsupported feature; `-` in any other position still surfaces the existing clear error.
|
|
60
|
+
- A tie between two **same-pitch** notes parses into a single sounding note whose `RhythmicValue` is the authored head tied to the authored tail: `E3-E2` → *dotted quarter tied to quarter* (3 + 2), overriding the greedy resolver rather than re-decomposing to 4 + 1.
|
|
61
|
+
- Tie **chains** parse: `E2-E2-E2` yields a single note with a nested tied value spanning the whole duration.
|
|
62
|
+
- A tie **across a barline** joins the last note of a measure to the first note of the next measure of the same pitch, producing one sustained note that spans the bar line. **(Deferred to a follow-up — see the Implementation Plan's scope decision. v1 detects an author tie that would cross a barline and raises a clear `ParseError`, "Ties across barlines are not yet supported," rather than joining the notes.)**
|
|
63
|
+
- A tie between **different pitches** is not a tie in ABC (that notation is a slur). It raises a `ParseError` with line number and snippet — or is classified as an unsupported slur — but never silently produces a wrong pitch. (Pick one behavior and document it.)
|
|
64
|
+
- The resulting model renders correctly through `to_musicxml`: tied notes emit MusicXML `<tie>` / `<tied>` so playback and engraving treat them as one sustained pitch, not two re-articulated notes.
|
|
65
|
+
- Decide and document the **ABC export** behavior for authored ties: whether `DurationWriter` preserves the authored split on round-trip or continues collapsing tied chains to a single multiplier (today it collapses, so `E3-E2` would re-export as `E5`). Round-trip specs assert whichever behavior is chosen.
|
|
66
|
+
- Specs cover: an author-chosen intra-measure split, a tie across a barline, a multi-note tie chain, a mismatched-pitch tie (error/slur path), and a MusicXML render asserting the `<tied>` elements.
|
|
67
|
+
|
|
68
|
+
## Notes
|
|
69
|
+
|
|
70
|
+
**Touch points (for planning, not prescriptive):**
|
|
71
|
+
|
|
72
|
+
- `ABC::BodyLexer` — remove `-` from `scan_unsupported`; add a `scan_tie` producing a `:tie` token. Keep the "unterminated / dangling tie" cases as clear `ParseError`s.
|
|
73
|
+
- `ABC::Parser` — when a `:tie` token joins two adjacent note placements, merge them into one placement whose `RhythmicValue` uses the authored head with the tail as its `tied_value`, bypassing `DurationResolver`'s greedy split for the tied span. Cross-barline ties are the hard part: a single note must span two measures' placement bookkeeping.
|
|
74
|
+
- `ABC::DurationResolver` — unchanged for un-tied durations; explicit ties should *supply* the split rather than derive it.
|
|
75
|
+
- `Notation::MusicXML::Writer` / `DurationWriter` — verify tied `RhythmicValue`s already emit `<tie type="start"/stop">` and `<tied>`; the greedy resolver produces tied chains today, so this path may already work and just need coverage.
|
|
76
|
+
- `ABC::DurationWriter` (export) — the round-trip decision above lives here.
|
|
77
|
+
|
|
78
|
+
**Scope boundaries (candidates for follow-up, not v1):**
|
|
79
|
+
|
|
80
|
+
- Ties inside or between chords (`[CEG]-[CEG]`).
|
|
81
|
+
- Slurs `( … )`, which share the `-`-adjacent lexer neighborhood but are a distinct musical construct.
|
|
82
|
+
- Preserving an authored split through ABC *export* if that proves to conflict with the exporter's canonical-form goal.
|
|
83
|
+
|
|
84
|
+
Related: [ABC Notation interpreter](../done/abc-notation-interpreter.md), [ABC Chord Input](../done/abc-chord-input.md), [ABC Notation Export](../done/abc-notation-export.md), [Notation Module epic](../epics/notation-module.md).
|
|
85
|
+
|
|
86
|
+
## Implementation Plan
|
|
87
|
+
|
|
88
|
+
### Scope decision
|
|
89
|
+
|
|
90
|
+
This story ships **intra-note (within-bar) author-controlled ties** and reuses the existing tie-rendering pipeline end to end. The MusicXML writer already turns a `RhythmicValue` with a `tied_value` chain into multiple `<note>` elements carrying `<tie>`/`<tied>` (`MusicXML::DurationWriter#components`, `Writer#tie_lines`/`#notation_lines`). So the only gap is the ABC **input** boundary: letting the parser build a placement whose `RhythmicValue` is the author's chosen head-plus-tail instead of the resolver's greedy split.
|
|
91
|
+
|
|
92
|
+
**Cross-barline ties are deferred.** A single placement cannot span a bar — the MusicXML writer's `ensure_notes_within_barlines` rejects that outright, and representing a tie *between two placements* would require a new concept in the Voice/Placement model plus writer changes. This story instead detects an author tie that would cross a barline (a `-` immediately before a bar line) and raises a clear, specific `ParseError` at parse time. Tracked as a follow-up in Scope Boundaries.
|
|
93
|
+
|
|
94
|
+
### Step 1 — Lexer: emit a `:tie` token (`abc/body_lexer.rb`)
|
|
95
|
+
|
|
96
|
+
- Add `scan_tie` to the `scan_token` chain, placed **before** `scan_unsupported`: match a single `-` and push `Token.new(type: :tie, line:, column:)`.
|
|
97
|
+
- Remove `-` from `scan_unsupported`'s character class (`/[()\-~.]/` → `/[()~.]/`) so slurs `()`, staccato `.`, and roll `~` stay unsupported while `-` no longer does.
|
|
98
|
+
- `-` inside volta ranges (`VOLTA_DIGITS_PATTERN`, `[…]`/trailing-volta paths) is unaffected — those are scanned in their own branches before the note stream reaches `scan_tie`.
|
|
99
|
+
|
|
100
|
+
### Step 2 — Parser: combine tied notes into one placement (`abc/parser.rb`)
|
|
101
|
+
|
|
102
|
+
- Extend `VoiceState` with a `tie_open` flag (+ a `tie_line` for error reporting).
|
|
103
|
+
- Extend `PendingNote` with a `tied_prefix:` field (default `nil`) — a pre-built `RhythmicValue` holding everything tied *before* this note's own `(length, scale)`.
|
|
104
|
+
- `handle_tie(token)`: require a `pending_note` and not `awaiting_scale`; otherwise raise `ParseError` ("A tie must follow a note" / broken-rhythm conflict). Set `state.tie_open = true`, record the line. Do **not** flush the pending note.
|
|
105
|
+
- In `handle_note`/`handle_chord`: when `state.tie_open` is set, don't defer normally. Instead:
|
|
106
|
+
- Validate the new pitches equal the pending pitches (set equality). Mismatch → `ParseError` "A tie must connect two notes of the same pitch" with line/snippet (this is the ABC slur case).
|
|
107
|
+
- Build the head chain: `head = append_tied(pending.tied_prefix, resolve(pending))` where `resolve` = `duration_resolver.rhythmic_value(pending.length, scale: pending.scale)` and `append_tied(prefix, tail)` returns `tail` when `prefix` is nil, else a copy of `prefix` whose deepest `tied_value` is `tail` (recursive rebuild via `RhythmicValue.new(unit, dots:, tied_value:)`, since `RhythmicValue` has no setters).
|
|
108
|
+
- Set the new `pending_note` to the incoming note with `tied_prefix: head`; clear `tie_open`. Keeping it pending preserves broken-rhythm deferral and lets `E2-E2-E2` chains accumulate.
|
|
109
|
+
- `flush_pending_note`/`place`: when `pending.tied_prefix` is present, place `append_tied(pending.tied_prefix, resolve(pending))` as the single placement's value; otherwise unchanged.
|
|
110
|
+
- Guard the non-note terminators so an open tie fails fast and specifically:
|
|
111
|
+
- `handle_bar_line` while `tie_open` → `ParseError` "Ties across barlines are not yet supported" (the deferred case).
|
|
112
|
+
- `handle_rest` / `handle_volta` / `handle_voice_change` while `tie_open` → `ParseError` "A tie must connect two notes of the same pitch" (a tie to a non-note).
|
|
113
|
+
- `finish` while `tie_open` → `ParseError` "Tie has no following note" (dangling tie at end of tune).
|
|
114
|
+
- `handle` gains a `when :tie then handle_tie(token)` branch.
|
|
115
|
+
|
|
116
|
+
### Step 3 — Verify the render path is already correct
|
|
117
|
+
|
|
118
|
+
No writer changes expected. Confirm by test that a parsed `E3-E2` produces a placement whose `rhythmic_value.to_s == "dotted quarter tied to quarter"` and that `to_musicxml` emits `<tie type="start"/>` + `<tied type="start"/>` on the first note and the `stop` pair on the second. If a gap surfaces, fix it in `MusicXML::Writer`, but the existing greedy-resolver output already exercises this path.
|
|
119
|
+
|
|
120
|
+
### Step 4 — Specs
|
|
121
|
+
|
|
122
|
+
- **Lexer** (`spec/.../abc/body_lexer_spec.rb`): `-` lexes as a `:tie` token; `()`, `.`, `~` still lex as `:unsupported`.
|
|
123
|
+
- **Parser** (`spec/.../abc/parser_spec.rb` or the interpreter spec): author split `E3-E2` → one note, `RhythmicValue` "dotted quarter tied to quarter" (assert it differs from the greedy `E5` → "half tied to eighth"); a chain `C2-C2-C2`; mismatched pitch `E3-D2` raises the same-pitch `ParseError`; cross-barline `E3-|E3` raises "not yet supported"; dangling `E3-` at end raises; tie with no preceding note (`-E3`) raises.
|
|
124
|
+
- **Round-trip / MusicXML** (`spec/.../music_xml_spec.rb` or the ABC interpreter → MusicXML spec): parsing the Three-Blind-Mice-style measure and rendering asserts the `<tied>` start/stop elements.
|
|
125
|
+
- Follow existing spec structure/naming in `spec/head_music/notation/abc/`.
|
|
126
|
+
|
|
127
|
+
### Step 5 — Docs & housekeeping
|
|
128
|
+
|
|
129
|
+
- Update the `scan_unsupported` comment (currently lists "ties" among deliberately-unhandled constructs) to reflect that ties are now handled on input.
|
|
130
|
+
- If a README/CHANGELOG enumerates supported ABC features, add ties (input, within-bar).
|
|
131
|
+
- Run `bundle exec rspec` and `bundle exec standardrb`/`rubocop` (whichever the repo uses) to green before finishing.
|
|
132
|
+
|
|
133
|
+
### Files touched
|
|
134
|
+
|
|
135
|
+
- `lib/head_music/notation/abc/body_lexer.rb` — `scan_tie`, unsupported char-class edit, comment.
|
|
136
|
+
- `lib/head_music/notation/abc/parser.rb` — `:tie` handling, `PendingNote`/`VoiceState` extension, `append_tied`, terminator guards.
|
|
137
|
+
- `spec/head_music/notation/abc/*` and the MusicXML spec — new coverage.
|
|
138
|
+
- Possibly `CHANGELOG`/README if they enumerate ABC feature support.
|
|
139
|
+
|
|
140
|
+
### Scope Boundaries (Not in v1)
|
|
141
|
+
|
|
142
|
+
- **Cross-barline ties** (`E3-|E3`) — needs a tie-between-placements concept in the Voice/Placement model and MusicXML writer; raises a clear "not yet supported" error for now.
|
|
143
|
+
- **Ties on/between chords** (`[CEG]-[CEG]`) — the pitch-set-equality guard would admit identical chords, but this path is not a v1 target and gets at most light coverage; document as follow-up.
|
|
144
|
+
- **Slurs** (`( … )`) — distinct construct, remains unsupported.
|
|
145
|
+
- **ABC export of authored ties** — the exporter still collapses tied chains to a single multiplier (`E3-E2` → `E5`); preserving an authored split on export is out of scope. Export round-trip specs assert the current collapsing behavior.
|
|
146
|
+
|
|
147
|
+
## Review
|
|
148
|
+
|
|
149
|
+
_Reviewed 2026-07-19 at commit `8323de5` (implementation in `93023bb`, committed to `main` — no `story/abc-tie-input` branch was cut). All 236 examples in the ABC lexer/parser and MusicXML writer specs pass; the full suite still needs to be run to confirm nothing else regressed._
|
|
150
|
+
|
|
151
|
+
### Acceptance criteria
|
|
152
|
+
|
|
153
|
+
- ✅ **Lexer tie token; `-` elsewhere still errors.** `scan_tie` is inserted before `scan_unsupported` and emits a `:tie` token (`body_lexer.rb:285-290`); `-` was removed from the unsupported char class (`body_lexer.rb:298`). A leading tie is rejected in the parser ("A tie must follow a note", `parser.rb:226-235`). Note: a stray `-` with nothing else to match now yields the generic "Unexpected character" error rather than the old `UnsupportedFeatureError "-"` — arguably clearer, but a behavior change.
|
|
154
|
+
- ✅ **Authored split preserved, overrides greedy resolver.** `tie_onto_pending` + `append_tied` build the nested `RhythmicValue` from the authored lengths (`parser.rb:240-363`). `parser_spec.rb:295-306` asserts `E3-E2` → `"dotted quarter tied to quarter"` and contrasts it against the greedy `"half tied to eighth"`.
|
|
155
|
+
- ✅ **Tie chains nest into one value.** `append_tied` recurses; `C2-C2-C2` → `"quarter tied to quarter tied to quarter"` (`parser_spec.rb:308-311`).
|
|
156
|
+
- ❌ **Cross-barline spanning tie — consciously deferred.** Not implemented; `handle_bar_line` raises "Ties across barlines are not yet supported" (`parser.rb:294`). The deferral is clean, documented in code/CHANGELOG/commit, and tested (`parser_spec.rb:323-326`). The original criterion is not met by design — this was scoped out in the plan. _Follow-up: the story's AC text has now been annotated to record the deferral._
|
|
157
|
+
- ✅ **Different-pitch tie raises, never silent.** `ensure_tie_pitches_match` raises "A tie must connect two notes of the same pitch" (`parser.rb:251-258`); `E3-D2` tested (`parser_spec.rb:318-321`). Behavior (raise, not slur) is documented in the CHANGELOG.
|
|
158
|
+
- ✅ **MusicXML `<tie>`/`<tied>` render.** No writer change needed — reuses the existing tied-`RhythmicValue` path. `writer_spec.rb:363-380` asserts note types `quarter quarter eighth`, a dot on note 1, and `tied` start/stop counts `[1,0,0]`/`[0,1,0]`.
|
|
159
|
+
- ✅ **ABC export round-trip decided/documented/asserted.** Behavior is decided (collapse to one multiplier: `E3-E2` → `E5`) and documented in the story's Scope Boundaries. _Follow-up: a round-trip spec was added (`writer_spec.rb`, "with an authored tie collapsing to a single multiplier") asserting that parsing `E3-E2 G` and re-exporting yields `E5 G` — pinning the intentional asymmetry between input (preserves the authored split) and export (collapses it)._
|
|
160
|
+
- ✅ **Five required spec cases present** (intra-measure split, cross-barline [asserts the deferral error], chain, mismatched pitch, MusicXML `<tied>`), plus bonus error-path coverage (dangling tie, no-preceding-note, tie-to-rest, whole-bar simple-meter tie).
|
|
161
|
+
|
|
162
|
+
### Code review findings
|
|
163
|
+
|
|
164
|
+
1. **(Should-fix, confirmed bug) `handle_broken_rhythm` does not reject an open tie — silent wrong output.** `parser.rb:277-289`. Every other terminator calls `reject_open_tie`, but this one was overlooked; its guard checks only `awaiting_scale`/`pending_note.nil?`. Verified: `A->A` parses **without error** into a bogus "dotted eighth tied to sixteenth" (both the tie and the broken-rhythm ×3/2·×1/2 scaling applied at once) instead of raising like `A-z` or `A-|` do. Fix: add `reject_open_tie(state, token.line, "A tie must be followed by a note")` at the top of `handle_broken_rhythm`, with a spec for `A->A` / `A-<A`.
|
|
165
|
+
2. **(Nice-to-have) Pitch match mixes two comparison semantics.** `parser.rb:252` — `pending.pitches.sort == pitches.sort`. `sort` orders by `Pitch#<=>` (MIDI number; enharmonics tie), then array `==` compares by `Pitch#==` (spelling). They disagree on enharmonics, so `^C-_D` is rejected as "same pitch" mismatch — defensible, but worth a one-line "why" comment.
|
|
166
|
+
3. **(Nice-to-have) Bar-line tie message can mislead.** `parser.rb:294` — `E3-|]` (dangling tie at a section end) reports "Ties across barlines are not yet supported" though nothing crosses a barline. Minor; the terminator can't distinguish the two cases and the message is right for the tested `E3-|E3`.
|
|
167
|
+
|
|
168
|
+
### Test coverage gaps
|
|
169
|
+
|
|
170
|
+
- **Tie + broken rhythm** (`A->A`) — the untested path behind finding #1.
|
|
171
|
+
- **Successful chord-to-chord tie** — the lexer tokenizes `[CEG]-c`, but no parser spec fuses a chord tie into one placement.
|
|
172
|
+
- **Open tie across a voice change** (`parser.rb:318`) and **across a volta** (`parser.rb:308`) — both `reject_open_tie` branches are unexercised.
|
|
173
|
+
|
|
174
|
+
### Blocks `finish`?
|
|
175
|
+
|
|
176
|
+
Nothing outstanding. **Finding #1** (the `A->A` silent-miscompute) has been fixed: `handle_broken_rhythm` now calls `reject_open_tie` and `A->A`/`A-<A` raise a clear `ParseError`, with a covering spec. The two follow-up items called out under criteria 4 and 7 have also been addressed — the export round-trip spec was added and the deferred cross-barline AC was annotated. The remaining nice-to-haves (pitch-comparison comment, bar-line message wording, chord-to-chord / voice-change / volta coverage) are non-blocking.
|
|
177
|
+
|
|
178
|
+
## Learnings
|
|
179
|
+
|
|
180
|
+
**What went well**
|
|
181
|
+
|
|
182
|
+
- The plan's central bet held: because the greedy resolver already emits tied chains, the entire tie *rendering* path (`RhythmicValue.tied_value` → MusicXML `<tie>`/`<tied>`) already existed. No writer changes were needed, so the work collapsed to the input boundary (lexer + parser) and stayed small.
|
|
183
|
+
- The "keep the left note pending, close the tie when its right note arrives" state machine composed cleanly with the existing broken-rhythm deferral and gave tie chains (`E2-E2-E2`) for free.
|
|
184
|
+
|
|
185
|
+
**What was surprising**
|
|
186
|
+
|
|
187
|
+
- The `reject_open_tie` guard had to be called by *every* non-note terminator, and `handle_broken_rhythm` was the one that got missed — the review caught a genuine silent miscompute (`A->A` applied both the tie and the broken-rhythm scaling with no error).
|
|
188
|
+
- Cross-barline ties — the primary real-world reason ties exist in ABC — had to be deferred, because a single `Placement` cannot span a bar (`ensure_notes_within_barlines`). The feature the story most wanted is the one v1 does not ship.
|
|
189
|
+
- The input/export asymmetry (input preserves an authored split; export collapses it to one multiplier) is intentional but was easy to overlook until a spec pinned it.
|
|
190
|
+
|
|
191
|
+
**What to do differently next time**
|
|
192
|
+
|
|
193
|
+
- When introducing a cross-cutting guard that "every terminator must call," write one spec per guard branch — the missing terminator failed silently precisely because it had no test.
|
|
194
|
+
- Annotate a deferred acceptance criterion in the AC text at *plan* time, not review time, so the backlog never overstates what shipped.
|
|
195
|
+
- Cut a `story/<name>` branch. Work went straight onto `main` (acceptable for this rebase-flow repo, but the review/finish tooling assumes a story branch and had to diff commit ranges instead).
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
metadata:
|
|
3
|
+
created_at: 2026-07-17T12:54:19-07:00
|
|
4
|
+
activated_at: 2026-07-18T20:22:35-07:00
|
|
5
|
+
planned_at: 2026-07-18T20:30:54-07:00
|
|
6
|
+
finished_at: 2026-07-18T20:44:44-07:00
|
|
7
|
+
updated_at: 2026-07-18T20:44:44-07:00
|
|
8
|
+
-->
|
|
9
|
+
|
|
10
|
+
# Story: MusicXML Chord Rendering
|
|
11
|
+
|
|
12
|
+
## Summary
|
|
13
|
+
|
|
14
|
+
AS a developer using HeadMusic
|
|
15
|
+
|
|
16
|
+
I WANT `Composition#to_musicxml` to render chord placements as stacked notes
|
|
17
|
+
|
|
18
|
+
SO THAT block chords display on one staff in MusicXML-consuming renderers (OSMD, MuseScore, etc.)
|
|
19
|
+
|
|
20
|
+
## Background
|
|
21
|
+
|
|
22
|
+
The content model represents a chord as a single `Placement` holding two or more pitched sounds (see the Chord Placement Model and Sound Model stories): one position, one rhythmic value, many sounds. `Placement#sounds` (a frozen array) is the source of truth — each sound is a `Rudiment::Pitch` or a `Rudiment::UnpitchedSound` — with `Placement#pitches` as the derived pitched subset and `chord?` true when there are two or more pitched sounds. Because a chord is one placement, `Voice#first_gap` contiguity works by construction — no special grouping is needed. The MusicXML writer, however, currently guards against chords: it raises when it encounters a placement where `chord?` is true, because it only knows how to emit the single derived `placement.pitch` (the highest pitch) and would otherwise silently render the top note alone. It never emits the `<chord/>` element, which is how MusicXML marks a note as sounding simultaneously with the previous note. (The writer's separate `RenderError` guard for placements containing unpitched sounds is out of scope here — it remains until a dedicated percussion-rendering story.)
|
|
23
|
+
|
|
24
|
+
This story is a prerequisite for BardTheory's staff-notation-view story, which renders compositions via `to_musicxml` → OSMD and requires block chords on the treble staff. The complementary input half is [ABC Chord Input](abc-chord-input.md) — that story gets chords *into* the model; this one gets them *out* to notation.
|
|
25
|
+
|
|
26
|
+
## Scope
|
|
27
|
+
|
|
28
|
+
- Replace the writer's chord guard with real rendering: a chord placement emits one `<note>` element per *pitched* sound, first note plain, each subsequent note carrying `<chord/>`, per the MusicXML 4.0 convention. All notes share the placement's rhythmic value.
|
|
29
|
+
- Emit chord notes in a deterministic order (low to high) regardless of the insertion order of the placement's `sounds`.
|
|
30
|
+
- Leave the writer's `RenderError` guard for unpitched sounds in place — any placement containing an unpitched sound still raises until a percussion-rendering story lands.
|
|
31
|
+
- Chords spanning voices are *not* merged — each voice remains its own part; only the pitched sounds of a single placement form a chord.
|
|
32
|
+
|
|
33
|
+
## Example
|
|
34
|
+
|
|
35
|
+
```ruby
|
|
36
|
+
composition = HeadMusic::Content::Composition.new(name: "Chorale", key_signature: "C major", meter: "4/4")
|
|
37
|
+
voice = composition.add_voice(role: "Treble")
|
|
38
|
+
voice.place("1:1", :half, %w[C4 E4 G4])
|
|
39
|
+
voice.place("1:3", :half, %w[D4 F4 A4])
|
|
40
|
+
|
|
41
|
+
xml = composition.to_musicxml
|
|
42
|
+
# E4 and G4 <note> elements at beat 1 each contain <chord/>; OSMD renders two stacked triads
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Acceptance Criteria
|
|
46
|
+
|
|
47
|
+
- A chord placement emits one `<note>` per pitched sound as a MusicXML chord (`<chord/>` on all but the first note), replacing the writer's raise-on-chord guard
|
|
48
|
+
- A composition mixing chords and single notes renders with correct measure durations
|
|
49
|
+
- Chord notes emit low to high
|
|
50
|
+
- The writer's `RenderError` guard for unpitched sounds is unchanged
|
|
51
|
+
- Existing single-line compositions render byte-identically to before (no regression)
|
|
52
|
+
- Output validates against the MusicXML 4.0 schema (matching the writer's existing spec approach)
|
|
53
|
+
- Rubocop and all specs pass
|
|
54
|
+
|
|
55
|
+
## Implementation Plan
|
|
56
|
+
|
|
57
|
+
### Overview
|
|
58
|
+
|
|
59
|
+
Replace the writer's raise-on-chord guard with a single parametrized note-builder that emits one `<note>` per pitched sound of a placement — the lowest note plain, each higher note carrying `<chord/>` — sorted low→high, sharing the placement's rhythmic value. The single-note and rest paths collapse to the same builder so existing output stays byte-identical. The change is confined to `lib/head_music/notation/music_xml/writer.rb` and its spec.
|
|
60
|
+
|
|
61
|
+
### Steps
|
|
62
|
+
|
|
63
|
+
1. **Remove the chord guard and restructure `note_lines`** — `lib/head_music/notation/music_xml/writer.rb:313`
|
|
64
|
+
- Delete the `raise RenderError ... "chords are not yet supported"` line (`:315`). Keep `ensure_pitched_sounds` first — it still raises for any unpitched sound, so every non-rest placement reaching the builder is fully pitched (this is what makes the unpitched-sound guard AC hold unchanged).
|
|
65
|
+
- Nest the loops **components outer, pitches inner** so each tied rhythmic segment emits a complete, contiguous chord block. This ordering is load-bearing: pitches-outer would break `<chord/>` adjacency and produce invalid MusicXML.
|
|
66
|
+
|
|
67
|
+
```ruby
|
|
68
|
+
def note_lines(placement)
|
|
69
|
+
ensure_pitched_sounds(placement)
|
|
70
|
+
|
|
71
|
+
components_by_placement[placement].flat_map do |component|
|
|
72
|
+
note_slots(placement).each_with_index.flat_map do |pitch, index|
|
|
73
|
+
note_element_lines(placement, component, pitch: pitch, chord: index.positive?)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# ensure_pitched_sounds has rejected any unpitched sound, so a sounded
|
|
79
|
+
# placement's pitches are all its sounds; a rest emits one empty slot.
|
|
80
|
+
def note_slots(placement)
|
|
81
|
+
placement.rest? ? [nil] : placement.pitches.sort
|
|
82
|
+
end
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
- `placement.pitches.sort` yields low→high (Pitch is `Comparable`); this is the explicit sort the ordering AC requires — `pitches` is otherwise insertion-ordered. `index.positive?` puts `<chord/>` on all but the lowest note. For a single-pitch placement this is a one-element array at index 0 (`chord: false`), and `pitches.sort.first == placement.pitch` for length 1, preserving today's output exactly.
|
|
86
|
+
|
|
87
|
+
2. **Parametrize the note builder (rename `component_lines` → `note_element_lines`)** — `writer.rb:330`
|
|
88
|
+
- One builder serves single note, rest, and chord — no second code path to drift.
|
|
89
|
+
|
|
90
|
+
```ruby
|
|
91
|
+
def note_element_lines(placement, component, pitch: nil, chord: false)
|
|
92
|
+
[
|
|
93
|
+
"#{INDENT * 3}<note>",
|
|
94
|
+
*(chord ? ["#{INDENT * 4}<chord/>"] : []),
|
|
95
|
+
*(pitch ? pitch_lines(pitch) : ["#{INDENT * 4}<rest/>"]),
|
|
96
|
+
"#{INDENT * 4}<duration>#{component.duration}</duration>",
|
|
97
|
+
*tie_lines(placement, component),
|
|
98
|
+
"#{INDENT * 4}<type>#{component.type}</type>",
|
|
99
|
+
*Array.new(component.dots) { "#{INDENT * 4}<dot/>" },
|
|
100
|
+
*notation_lines(placement, component),
|
|
101
|
+
"#{INDENT * 3}</note>"
|
|
102
|
+
]
|
|
103
|
+
end
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
- Byte-identical guarantees: `<chord/>` sits at `INDENT * 4`, first child after `<note>` and **before** `<pitch>` (MusicXML schema requires this order — the one correctness detail that must be right); the `chord ? [...] : []` splat contributes zero lines when false. The rest branch now keys off the passed `pitch:` arg (nil → `<rest/>`) instead of `placement.pitched?`; equivalent because `note_slots` only yields nil for a rest.
|
|
107
|
+
- `tie_lines`/`notation_lines` are unchanged and already per-component + rest-guarded, so each note in a chord (and each tied link) carries its own correctly-matched tie — no chord-specific tie work needed.
|
|
108
|
+
|
|
109
|
+
3. **Rewrite the two chord specs; add coverage** — `spec/head_music/notation/music_xml/writer_spec.rb`
|
|
110
|
+
- **Flip** the two "raises the chord render error" contexts (`:465-479` three-pitch, `:481-495` two-pitch) from raise-assertions to rendering assertions — they currently pin the removed behavior and will otherwise fail.
|
|
111
|
+
- See Testing Strategy for the exact cases.
|
|
112
|
+
|
|
113
|
+
4. **Run the checks**
|
|
114
|
+
|
|
115
|
+
```
|
|
116
|
+
bundle exec rspec spec/head_music/notation/music_xml/writer_spec.rb
|
|
117
|
+
bundle exec rubocop -a
|
|
118
|
+
bundle exec rake
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
### Design & Notation-Output Considerations
|
|
122
|
+
|
|
123
|
+
- **`<chord/>` before `<pitch>`, per subsequent note** is the single highest-risk detail — both OSMD and MuseScore mis-parse a `<chord/>` that follows `<pitch>`. The builder above places it correctly.
|
|
124
|
+
- **Iterate `placement.pitches`, never reuse `placement.pitch`** (which is `pitches.max`, the top note only). Reusing it would print the top pitch on every chord note — the most likely refactor bug.
|
|
125
|
+
- **Low→high order is cosmetic to renderers** (both sort internally) but is the right choice: deterministic, diff-stable, and the conventional bass-up reading order. It must come from the explicit `.sort`, not insertion order.
|
|
126
|
+
- **`<stem>`/`<voice>`/`<staff>` omission is fine for v1** single-voice, single-staff block chords, consistent with how single notes already render (the writer emits none today). Consumers default absent voice/staff to 1, and `<chord/>` alone binds the stack. The boundary where `<voice>` stops being optional is multi-voice-per-staff — out of scope, tracked below.
|
|
127
|
+
- **Clusters/seconds (C4+D4) and shared-letter accidentals (C4+C#4) are the renderer's job** — each note carries its own `<alter>`; OSMD/MuseScore handle notehead offset and accidental columns automatically. No writer work for v1.
|
|
128
|
+
|
|
129
|
+
### Accessibility / Semantic Fidelity
|
|
130
|
+
|
|
131
|
+
Traditional WCAG/keyboard/ARIA do not apply (this emits a string, not a UI). The surface is MusicXML semantic fidelity for Braille-music and MusicXML-reading assistive tools:
|
|
132
|
+
|
|
133
|
+
- **`<chord/>` on notes sharing a rhythmic value IS the canonical encoding** these tools recognize — a lead `<note>` followed by `<chord/>` siblings with no intervening `<backup>`/`<forward>`. No richer encoding needed.
|
|
134
|
+
- **Low→high ordering gives a predictable reading order**, matching Braille music's bass-relative interval convention. Reinforces the explicit-sort requirement.
|
|
135
|
+
- **No semantic loss**: each tone keeps full absolute `<pitch>` (step/alter/octave); sharing one `<duration>`/`<type>` is spec-required, not a loss.
|
|
136
|
+
|
|
137
|
+
### Testing Strategy
|
|
138
|
+
|
|
139
|
+
"Validates against the MusicXML 4.0 schema" is satisfied here by the repo's actual approach — there is **no** XSD/DTD tooling; do not add a Nokogiri XSD dependency. Verification is: REXML well-formedness via `parse_musicxml` (`spec/support/music_xml_helpers.rb`), XPath structural assertions, and the byte-for-byte golden-document test. (See the AC note: treat "schema validation" as these concrete facts.)
|
|
140
|
+
|
|
141
|
+
In `spec/head_music/notation/music_xml/writer_spec.rb`, using the existing helpers (`parse_musicxml`, `xpath_count`, `xpath_texts`):
|
|
142
|
+
|
|
143
|
+
- **Three-pitch chord** `voice.place("1:1", :half, %w[C4 E4 G4])`: `//note` count == 3 for the measure; `xpath_count(document, "//note/chord")` == 2; the lowest note (C4) has no `<chord/>`; all three share `<duration>`.
|
|
144
|
+
- **Insertion-order independence**: place `%w[G4 C4 E4]` and assert `xpath_texts(document, "//note/pitch/step")` == `%w[C E G]` (low→high regardless of input order).
|
|
145
|
+
- **`<chord/>` element ordering** (child-order, invisible to count-only XPath): assert against the rendered `to_s` that `<chord/>` precedes `<pitch>` — cleanest via a **golden-document fixture** with a chord measure (extend the existing golden test at `:173`, the only mechanism in the suite that catches child-ordering regressions), or a targeted string `include`.
|
|
146
|
+
- **Two-pitch boundary**: rewrite the `:481` context to assert emission, pinning `chord?`-true-at-exactly-2.
|
|
147
|
+
- **Mixed chords + single notes measure**: `place("1:1", :half, %w[C4 E4 G4]); place("1:3", :half, "D5")` — assert per-note durations and correct note count (measure-duration AC; confirms only the lead note advances the cursor).
|
|
148
|
+
- **Tied chord** (if kept in v1 — see Open Questions): a chord with a tied rhythmic value → each tie-link emits a full chord stack with `<chord/>` on the upper notes and ties on every note.
|
|
149
|
+
- **Keep green** the unpitched (`:497`) and mixed pitched+unpitched (`:513`) percussion-raise contexts, and the single-line regression contexts (`SPEED_THE_PLOUGH` at `:22`, the golden document at `:173`) — these are the no-regression / byte-identical guard.
|
|
150
|
+
|
|
151
|
+
### Risks & Open Questions
|
|
152
|
+
|
|
153
|
+
- **Tied-chord handling in v1 — recommended IN, needs sign-off.** A chord with a tied rhythmic value is reachable through the public `Voice#place` API (`components_by_placement` expands it into multiple tie-link components). The components-outer/pitches-inner nesting handles it correctly with **zero extra code**, and every pitch legitimately shares the link's tie flags. Recommendation: ship the natural handling. The alternative is an explicit `RenderError` guarded by `components_by_placement[placement].length > 1 && placement.chord?` plus a deferral note — but do **not** leave it to silently drop ties. This plan assumes full tied-chord rendering.
|
|
154
|
+
- **Byte-identical regression** is the primary technical risk; mitigated by the empty-splat for `chord: false` and by `pitches.sort.first == placement.pitch` for length-1 placements. The existing exact-structure and golden-document specs catch any drift.
|
|
155
|
+
- **Duplicate/enharmonic pitches**: `Placement` already dedups exact duplicates via `sounds.uniq`, so an exact unison cannot reach the writer — no writer-side dedup needed. Two distinct spellings at the same height (C#4 + Db4) are a legitimate diminished-second cluster and correctly emit as two notes; order between them is undefined but harmless.
|
|
156
|
+
- **Multi-voice-per-staff** is the concrete future trigger where omitting `<voice>`/`<staff>` stops being acceptable. Out of scope for v1; track for the multi-voice story.
|
|
157
|
+
|
|
158
|
+
## Review
|
|
159
|
+
|
|
160
|
+
_Reviewed 2026-07-18 at commit `0f1f090` (product-manager + code-reviewer agents). Full suite: 5842 examples, 0 failures, 99.76% line coverage; rubocop 0 offenses._
|
|
161
|
+
|
|
162
|
+
### Acceptance criteria
|
|
163
|
+
|
|
164
|
+
- ✅ **Chord emits one `<note>` per pitched sound, `<chord/>` on all but the first, guard removed** — `writer.rb` `note_lines`/`note_element_lines`; specs assert 3 notes, 2 `<chord/>`, none on `note[1]`. No raise-on-chord path remains.
|
|
165
|
+
- ✅ **Mixing chords and single notes → correct measure durations** — "measure mixing a chord and a single note" spec: steps `C E G D`, 2 `<chord/>`, durations `2 2 2 2`. Only the lead C4 and D5 advance the cursor → full 4/4 bar.
|
|
166
|
+
- ✅ **Chord notes emit low to high** — `note_slots` returns `placement.pitches.sort`; the "placed high to low" spec (`%w[G4 C4 E4]` → `%w[C E G]`) proves the sort, not coincidental input order.
|
|
167
|
+
- ✅ **Unpitched-sound guard unchanged** — `ensure_pitched_sounds` runs first and raises on the first non-pitched sound; the mixed pitched+unpitched spec confirms it still fires with the same message.
|
|
168
|
+
- ✅ **Single-line output byte-identical** — single note → one slot, `chord: false`, `<chord/>` line omitted; the golden-document test pins the full single-line document byte-for-byte.
|
|
169
|
+
- ✅ **Validates against MusicXML 4.0 schema (repo's approach)** — every chord spec parses via REXML + XPath; the golden-string "exact note markup" spec pins `<chord/>`-before-`<pitch>` child order (which count-based XPath cannot see). Caveat, story-acknowledged: no XSD/DTD validator exists in the repo; "schema-valid" is structural, not tool-verified.
|
|
170
|
+
- ✅ **Rubocop and all specs pass** — verified; tied-chord case also covered (6 notes / 4 `<chord/>`, 3 tie-starts / 3 tie-stops).
|
|
171
|
+
|
|
172
|
+
### Code review findings
|
|
173
|
+
|
|
174
|
+
**Verdict: clean — no blocking or should-fix issues.** Correctness verified on `<chord/>` placement/order, low→high sort, byte-identical single-note/rest paths, tied-chord loop nesting (index resets per tie-link), and guard interaction (no silent empty-slot path). Both new comments explain "why" and earn their place.
|
|
175
|
+
|
|
176
|
+
Nits (non-blocking, no action taken):
|
|
177
|
+
|
|
178
|
+
- `note_slots` re-sorts pitches once per tie-link (N times for an N-link tied chord). Negligible; hoisting would churn the diff.
|
|
179
|
+
- Enharmonic/duplicate pitches at the same height (C4 + B♯3) render as two chord notes — arguably correct MusicXML, untested. Add a spec only if the domain cares.
|
|
180
|
+
- Minor coverage: "correct measure durations" is proven indirectly (duration list + `<chord/>` count) rather than by an explicit sum-to-bar-length assertion. Low risk.
|
|
181
|
+
- `note_slots` naming is slightly abstract; the comment carries the meaning.
|
|
182
|
+
|
|
183
|
+
**Nothing blocks `finish`.**
|
|
184
|
+
|
|
185
|
+
## Learnings
|
|
186
|
+
|
|
187
|
+
- **Planning earned its keep on two judgment calls.** It surfaced the tied-chord decision as an explicit sign-off point rather than a silent implementation choice, and it reconciled the "validates against the MusicXML 4.0 schema" AC against the repo's *actual* verification approach (REXML well-formedness + XPath + a byte-for-byte golden document). That stopped us from adding an unneeded XSD/Nokogiri dependency to satisfy a criterion the existing tooling already covers.
|
|
188
|
+
- **The one surprise was a stale spec outside the writer's own file.** `composition_serialization_spec.rb` still pinned the old raise-on-chord behavior. The single-file spec run was green; only the full `bundle exec rake` suite caught it. Takeaway: when removing a guard or changing an error contract, grep the whole codebase for tests asserting the old behavior before assuming the change is done — don't trust a scoped run.
|
|
189
|
+
- **Tied chords cost zero extra code.** The components-outer / pitches-inner loop nesting composed cleanly with the model's existing expansion of a tied rhythmic value into per-link components; each link just emits a fresh full chord stack. Shipping the natural handling (rather than a guard-and-defer) was the low-risk call.
|
|
190
|
+
- **Child order is invisible to count-based XPath.** Asserting `<chord/>` precedes `<pitch>` needed a golden-string test; the structural XPath count/text assertions could confirm *how many* chord elements existed but never *where* they sat in the note. Reach for a string/golden assertion whenever element ordering is the property under test.
|
|
191
|
+
- **Byte-identical regression was the real risk, and it was cheap to neutralize.** The empty-when-false splat for the `<chord/>` line plus `pitches.sort.first == placement.pitch` for length-1 placements kept single-note output identical, and the pre-existing golden document guarded it — no new machinery required.
|
data/user-stories/index.html
CHANGED
|
@@ -254,10 +254,6 @@
|
|
|
254
254
|
// one-liner preserved across regenerations (see /stories board).
|
|
255
255
|
const stories = {
|
|
256
256
|
"backlog": [
|
|
257
|
-
{
|
|
258
|
-
"title": "MusicXML Chord Rendering",
|
|
259
|
-
"path": "backlog/musicxml-chord-rendering.md"
|
|
260
|
-
},
|
|
261
257
|
{
|
|
262
258
|
"title": "LilyPond Export",
|
|
263
259
|
"path": "backlog/lilypond-export.md"
|
|
@@ -266,10 +262,6 @@
|
|
|
266
262
|
"title": "LilyPond Interpreter",
|
|
267
263
|
"path": "backlog/lilypond-interpreter.md"
|
|
268
264
|
},
|
|
269
|
-
{
|
|
270
|
-
"title": "Organizing Content",
|
|
271
|
-
"path": "backlog/organizing-content.md"
|
|
272
|
-
},
|
|
273
265
|
{
|
|
274
266
|
"title": "Sixteenth-Century (Renaissance) Style Guides",
|
|
275
267
|
"path": "backlog/sixteenth-century-style.md"
|
|
@@ -277,10 +269,22 @@
|
|
|
277
269
|
{
|
|
278
270
|
"title": "Split Counterpoint Species Guides by Pedagogical Author",
|
|
279
271
|
"path": "backlog/split-counterpoint-species-by-author.md"
|
|
272
|
+
},
|
|
273
|
+
{
|
|
274
|
+
"title": "Untitled",
|
|
275
|
+
"path": "backlog/organizing-content.md"
|
|
280
276
|
}
|
|
281
277
|
],
|
|
282
278
|
"active": [],
|
|
283
279
|
"done": [
|
|
280
|
+
{
|
|
281
|
+
"title": "ABC Tie Input",
|
|
282
|
+
"path": "done/abc-tie-input.md"
|
|
283
|
+
},
|
|
284
|
+
{
|
|
285
|
+
"title": "MusicXML Chord Rendering",
|
|
286
|
+
"path": "done/musicxml-chord-rendering.md"
|
|
287
|
+
},
|
|
284
288
|
{
|
|
285
289
|
"title": "ABC Chord Input",
|
|
286
290
|
"path": "done/abc-chord-input.md"
|
|
@@ -390,16 +394,16 @@
|
|
|
390
394
|
"path": "done/sonority-identification.md"
|
|
391
395
|
},
|
|
392
396
|
{
|
|
393
|
-
"title": "
|
|
394
|
-
"path": "done/
|
|
397
|
+
"title": "Untitled",
|
|
398
|
+
"path": "done/superclass-for-note.md"
|
|
395
399
|
},
|
|
396
400
|
{
|
|
397
|
-
"title": "
|
|
398
|
-
"path": "done/
|
|
401
|
+
"title": "Untitled",
|
|
402
|
+
"path": "done/expand-playing-techniques.md"
|
|
399
403
|
},
|
|
400
404
|
{
|
|
401
|
-
"title": "
|
|
402
|
-
"path": "done/
|
|
405
|
+
"title": "Untitled",
|
|
406
|
+
"path": "done/instrument-variant.md"
|
|
403
407
|
}
|
|
404
408
|
]
|
|
405
409
|
};
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: head_music
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 17.
|
|
4
|
+
version: 17.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Rob Head
|
|
@@ -417,13 +417,13 @@ files:
|
|
|
417
417
|
- user-stories/_template.md
|
|
418
418
|
- user-stories/backlog/lilypond-export.md
|
|
419
419
|
- user-stories/backlog/lilypond-interpreter.md
|
|
420
|
-
- user-stories/backlog/musicxml-chord-rendering.md
|
|
421
420
|
- user-stories/backlog/organizing-content.md
|
|
422
421
|
- user-stories/backlog/sixteenth-century-style.md
|
|
423
422
|
- user-stories/backlog/split-counterpoint-species-by-author.md
|
|
424
423
|
- user-stories/done/abc-chord-input.md
|
|
425
424
|
- user-stories/done/abc-notation-export.md
|
|
426
425
|
- user-stories/done/abc-notation-interpreter.md
|
|
426
|
+
- user-stories/done/abc-tie-input.md
|
|
427
427
|
- user-stories/done/chord-placement-model.md
|
|
428
428
|
- user-stories/done/composition-serialization.md
|
|
429
429
|
- user-stories/done/configurable-large-leap-recovery.md
|
|
@@ -443,6 +443,7 @@ files:
|
|
|
443
443
|
- user-stories/done/move-staff-mapping-to-notation.md
|
|
444
444
|
- user-stories/done/move-staff-position-to-notation.md
|
|
445
445
|
- user-stories/done/music-xml-export.md
|
|
446
|
+
- user-stories/done/musicxml-chord-rendering.md
|
|
446
447
|
- user-stories/done/notation-module-foundation.md
|
|
447
448
|
- user-stories/done/notation-style.md
|
|
448
449
|
- user-stories/done/percussion_set.md
|
|
@@ -1,57 +0,0 @@
|
|
|
1
|
-
<!--
|
|
2
|
-
metadata:
|
|
3
|
-
created_at: 2026-07-17T12:54:19-07:00
|
|
4
|
-
activated_at:
|
|
5
|
-
planned_at:
|
|
6
|
-
finished_at:
|
|
7
|
-
updated_at: 2026-07-18T16:03:28-07:00
|
|
8
|
-
-->
|
|
9
|
-
|
|
10
|
-
# Story: MusicXML Chord Rendering
|
|
11
|
-
|
|
12
|
-
## Summary
|
|
13
|
-
|
|
14
|
-
AS a developer using HeadMusic
|
|
15
|
-
|
|
16
|
-
I WANT `Composition#to_musicxml` to render chord placements as stacked notes
|
|
17
|
-
|
|
18
|
-
SO THAT block chords display on one staff in MusicXML-consuming renderers (OSMD, MuseScore, etc.)
|
|
19
|
-
|
|
20
|
-
## Background
|
|
21
|
-
|
|
22
|
-
The content model represents a chord as a single `Placement` holding two or more pitched sounds (see the Chord Placement Model and Sound Model stories): one position, one rhythmic value, many sounds. `Placement#sounds` (a frozen array) is the source of truth — each sound is a `Rudiment::Pitch` or a `Rudiment::UnpitchedSound` — with `Placement#pitches` as the derived pitched subset and `chord?` true when there are two or more pitched sounds. Because a chord is one placement, `Voice#first_gap` contiguity works by construction — no special grouping is needed. The MusicXML writer, however, currently guards against chords: it raises when it encounters a placement where `chord?` is true, because it only knows how to emit the single derived `placement.pitch` (the highest pitch) and would otherwise silently render the top note alone. It never emits the `<chord/>` element, which is how MusicXML marks a note as sounding simultaneously with the previous note. (The writer's separate `RenderError` guard for placements containing unpitched sounds is out of scope here — it remains until a dedicated percussion-rendering story.)
|
|
23
|
-
|
|
24
|
-
This story is a prerequisite for BardTheory's staff-notation-view story, which renders compositions via `to_musicxml` → OSMD and requires block chords on the treble staff. The complementary input half is [ABC Chord Input](abc-chord-input.md) — that story gets chords *into* the model; this one gets them *out* to notation.
|
|
25
|
-
|
|
26
|
-
## Scope
|
|
27
|
-
|
|
28
|
-
- Replace the writer's chord guard with real rendering: a chord placement emits one `<note>` element per *pitched* sound, first note plain, each subsequent note carrying `<chord/>`, per the MusicXML 4.0 convention. All notes share the placement's rhythmic value.
|
|
29
|
-
- Emit chord notes in a deterministic order (low to high) regardless of the insertion order of the placement's `sounds`.
|
|
30
|
-
- Leave the writer's `RenderError` guard for unpitched sounds in place — any placement containing an unpitched sound still raises until a percussion-rendering story lands.
|
|
31
|
-
- Chords spanning voices are *not* merged — each voice remains its own part; only the pitched sounds of a single placement form a chord.
|
|
32
|
-
|
|
33
|
-
## Example
|
|
34
|
-
|
|
35
|
-
```ruby
|
|
36
|
-
composition = HeadMusic::Content::Composition.new(name: "Chorale", key_signature: "C major", meter: "4/4")
|
|
37
|
-
voice = composition.add_voice(role: "Treble")
|
|
38
|
-
voice.place("1:1", :half, %w[C4 E4 G4])
|
|
39
|
-
voice.place("1:3", :half, %w[D4 F4 A4])
|
|
40
|
-
|
|
41
|
-
xml = composition.to_musicxml
|
|
42
|
-
# E4 and G4 <note> elements at beat 1 each contain <chord/>; OSMD renders two stacked triads
|
|
43
|
-
```
|
|
44
|
-
|
|
45
|
-
## Acceptance Criteria
|
|
46
|
-
|
|
47
|
-
- A chord placement emits one `<note>` per pitched sound as a MusicXML chord (`<chord/>` on all but the first note), replacing the writer's raise-on-chord guard
|
|
48
|
-
- A composition mixing chords and single notes renders with correct measure durations
|
|
49
|
-
- Chord notes emit low to high
|
|
50
|
-
- The writer's `RenderError` guard for unpitched sounds is unchanged
|
|
51
|
-
- Existing single-line compositions render byte-identically to before (no regression)
|
|
52
|
-
- Output validates against the MusicXML 4.0 schema (matching the writer's existing spec approach)
|
|
53
|
-
- Rubocop and all specs pass
|
|
54
|
-
|
|
55
|
-
## Implementation Plan
|
|
56
|
-
|
|
57
|
-
[to be filled in by /stories plan]
|