head_music 17.1.0 → 17.3.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 +13 -0
- data/Gemfile.lock +2 -2
- data/lib/head_music/content/composition.rb +2 -1
- data/lib/head_music/content/placement.rb +10 -1
- data/lib/head_music/notation/abc/body_lexer.rb +31 -3
- data/lib/head_music/notation/abc/parser.rb +109 -5
- data/lib/head_music/notation/abc/writer.rb +15 -1
- data/lib/head_music/notation/music_xml/beam_grouper.rb +108 -0
- data/lib/head_music/notation/music_xml/writer.rb +77 -3
- data/lib/head_music/rudiment/meter.rb +29 -0
- data/lib/head_music/version.rb +1 -1
- data/user-stories/done/abc-notation-beaming.md +235 -0
- data/user-stories/done/abc-tie-input.md +195 -0
- data/user-stories/index.html +11 -3
- metadata +5 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d5c0dadcdb3b7bba85e7de810d0d52d960e6bf2b9386b62db9ec201336e084bb
|
|
4
|
+
data.tar.gz: 33c3b42e6b07ed88649b6fb0c7b42476c9d45edbc18ef0cc458ff8978b876c4c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: eb3e9c1f84f2efbf87947a2037b9804cf9e16247be5c180509cb06a9c3c66229513b8cdcc2e10fa1d8f2f7ed952b91de99375e5939de33665aec909331f8f115
|
|
7
|
+
data.tar.gz: f871c26bf5da1052474f5317d72788df760f994f3264dbebc6ad35e91b91124088378524b46f4fc50721f6b9442453978018f6c012dd40f55926a66dd516539c
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [17.3.0] - 2026-07-19
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- The MusicXML writer emits `<beam>` elements so exported notation renders with correct beaming rather than relying on renderer-side auto-beaming (which mis-groups compound meters). Default beam groups are derived from the meter at render time — the dotted-quarter pulse for compound meters (6/8, 9/8, 12/8), the beat for simple quarter meters, and the whole bar for 3/8 — via a new `HeadMusic::Rudiment::Meter#beam_group_unit`. Grouping resolves at the notated-note level, so a tied chain beams correctly across its own noteheads, and secondary beams (sixteenths and finer) render with begin/continue/end runs plus forward/backward partial-beam hooks.
|
|
15
|
+
- The ABC interpreter captures authored beam grouping from inter-note spacing: adjacent notes beam together and a space breaks the beam, honored verbatim (even across a beat). This is carried on a new tri-state `HeadMusic::Content::Placement#beam_break_before` flag (`nil` = meter default, `true` = break, `false` = join) that overrides the default on output, serializes through `to_h`/`from_h`, and survives an ABC parse → render → parse round trip (the writer suppresses the space within an authored group).
|
|
16
|
+
|
|
17
|
+
## [17.2.0] - 2026-07-19
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
|
|
21
|
+
- 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.
|
|
22
|
+
|
|
10
23
|
## [17.1.0] - 2026-07-18
|
|
11
24
|
|
|
12
25
|
### Changed
|
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.3.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)
|
|
@@ -215,7 +215,8 @@ class HeadMusic::Content::Composition
|
|
|
215
215
|
position = parsed_position(placement_hash["position"], path)
|
|
216
216
|
rhythmic_value = parsed_rhythmic_value(placement_hash["rhythmic_value"], path)
|
|
217
217
|
sounds = parsed_placement_sounds(placement_hash, path)
|
|
218
|
-
voice.place(position, rhythmic_value, sounds)
|
|
218
|
+
placement = voice.place(position, rhythmic_value, sounds)
|
|
219
|
+
placement.beam_break_before = placement_hash["beam_break_before"] if placement_hash.key?("beam_break_before")
|
|
219
220
|
end
|
|
220
221
|
end
|
|
221
222
|
end
|
|
@@ -7,6 +7,13 @@ class HeadMusic::Content::Placement
|
|
|
7
7
|
|
|
8
8
|
attr_reader :voice, :position, :rhythmic_value, :sounds
|
|
9
9
|
|
|
10
|
+
# Authored beam grouping relative to the previous placement, set after
|
|
11
|
+
# construction (the Bar-style side-metadata pattern). Tri-state: nil = use
|
|
12
|
+
# the meter-derived default, true = force a beam break before this placement,
|
|
13
|
+
# false = force a beam join to the previous placement. Consumed by the
|
|
14
|
+
# MusicXML writer, which prefers it over the default grouping.
|
|
15
|
+
attr_accessor :beam_break_before
|
|
16
|
+
|
|
10
17
|
delegate :composition, to: :voice
|
|
11
18
|
delegate :spelling, to: :pitch, allow_nil: true
|
|
12
19
|
|
|
@@ -85,11 +92,13 @@ class HeadMusic::Content::Placement
|
|
|
85
92
|
end
|
|
86
93
|
|
|
87
94
|
def to_h
|
|
88
|
-
{
|
|
95
|
+
hash = {
|
|
89
96
|
"position" => position.to_s,
|
|
90
97
|
"rhythmic_value" => rhythmic_value.to_s,
|
|
91
98
|
"sounds" => sounds.map { |sound| sound_datum(sound) }
|
|
92
99
|
}
|
|
100
|
+
hash["beam_break_before"] = beam_break_before unless beam_break_before.nil?
|
|
101
|
+
hash
|
|
93
102
|
end
|
|
94
103
|
|
|
95
104
|
private
|
|
@@ -107,19 +107,35 @@ class HeadMusic::Notation::ABC::BodyLexer
|
|
|
107
107
|
Token.new(type: :voice_change, line: line_number, column: 1, voice_id: voice_id)
|
|
108
108
|
end
|
|
109
109
|
|
|
110
|
+
# Music tokens whose whitespace successor breaks a beam group; other
|
|
111
|
+
# tokens (bar lines, ties, etc.) already interrupt beaming on their own.
|
|
112
|
+
BEAMABLE_TOKEN_TYPES = [:note, :rest, :chord].freeze
|
|
113
|
+
|
|
110
114
|
# Returns true when the line ends with a continuation backslash.
|
|
111
115
|
def scan_line(line_text, line_number, tokens)
|
|
112
116
|
scanner = StringScanner.new(line_text)
|
|
113
117
|
until scanner.eos?
|
|
114
|
-
|
|
118
|
+
spaced = scanner.skip(/[ \t]+/)
|
|
115
119
|
break if scanner.skip(/%/)
|
|
116
120
|
return true if scanner.skip(/\\[ \t]*\z/)
|
|
121
|
+
break if scanner.eos?
|
|
117
122
|
|
|
123
|
+
tokens << beam_break_token(scanner, line_number) if spaced && beamable_predecessor?(tokens.last)
|
|
118
124
|
scan_token(scanner, line_number, tokens)
|
|
119
125
|
end
|
|
120
126
|
false
|
|
121
127
|
end
|
|
122
128
|
|
|
129
|
+
# A beam break only matters after a music token; whitespace elsewhere
|
|
130
|
+
# (leading, after a bar line) carries no beaming signal.
|
|
131
|
+
def beamable_predecessor?(token)
|
|
132
|
+
!token.nil? && BEAMABLE_TOKEN_TYPES.include?(token.type)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def beam_break_token(scanner, line_number)
|
|
136
|
+
Token.new(type: :beam_break, line: line_number, column: scanner.pos + 1)
|
|
137
|
+
end
|
|
138
|
+
|
|
123
139
|
def scan_token(scanner, line_number, tokens)
|
|
124
140
|
column = scanner.pos + 1
|
|
125
141
|
|
|
@@ -128,6 +144,7 @@ class HeadMusic::Notation::ABC::BodyLexer
|
|
|
128
144
|
return if scan_bracket(scanner, line_number, column, tokens)
|
|
129
145
|
return if scan_note(scanner, line_number, column, tokens)
|
|
130
146
|
return if scan_rest(scanner, line_number, column, tokens)
|
|
147
|
+
return if scan_tie(scanner, line_number, column, tokens)
|
|
131
148
|
return if scan_broken_rhythm(scanner, line_number, column, tokens)
|
|
132
149
|
return if scan_unsupported(scanner, line_number, column, tokens)
|
|
133
150
|
|
|
@@ -277,13 +294,24 @@ class HeadMusic::Notation::ABC::BodyLexer
|
|
|
277
294
|
true
|
|
278
295
|
end
|
|
279
296
|
|
|
297
|
+
# A tie (a hyphen following a note or chord) joins it to the next
|
|
298
|
+
# note of the same pitch; the parser fuses the two into one sounding
|
|
299
|
+
# value. Ties inside a bracket chord still surface as unsupported via
|
|
300
|
+
# the chord fallback.
|
|
301
|
+
def scan_tie(scanner, line_number, column, tokens)
|
|
302
|
+
return false unless scanner.scan("-")
|
|
303
|
+
|
|
304
|
+
tokens << Token.new(type: :tie, line: line_number, column: column)
|
|
305
|
+
true
|
|
306
|
+
end
|
|
307
|
+
|
|
280
308
|
# Recognizable ABC we deliberately don't handle: grace notes,
|
|
281
|
-
# decorations, tuplets, slurs,
|
|
309
|
+
# decorations, tuplets, slurs, and special rests.
|
|
282
310
|
def scan_unsupported(scanner, line_number, column, tokens)
|
|
283
311
|
lexeme =
|
|
284
312
|
scanner.scan(/\{[^}]*\}/) || scanner.scan(/\{[^}]*/) ||
|
|
285
313
|
scanner.scan(/![^!]*!/) || scanner.scan(/![^!]*/) ||
|
|
286
|
-
scanner.scan(/\(\d/) || scanner.scan(/[()
|
|
314
|
+
scanner.scan(/\(\d/) || scanner.scan(/[()~.]/) ||
|
|
287
315
|
scanner.scan(/Z\d*/) || scanner.scan(%r{x[\d/]*})
|
|
288
316
|
return false unless lexeme
|
|
289
317
|
|
|
@@ -36,15 +36,22 @@ 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, :beam_break) do
|
|
43
|
+
def initialize(pitches:, length:, scale:, tied_prefix: nil, beam_break: 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,
|
|
54
|
+
:beam_break_pending, :beam_last_was_note
|
|
48
55
|
|
|
49
56
|
def initialize(voice, pitch_builder)
|
|
50
57
|
@voice = voice
|
|
@@ -148,10 +155,12 @@ module HeadMusic::Notation::ABC
|
|
|
148
155
|
when :note then handle_note(token)
|
|
149
156
|
when :chord then handle_chord(token)
|
|
150
157
|
when :rest then handle_rest(token)
|
|
158
|
+
when :tie then handle_tie(token)
|
|
151
159
|
when :broken_rhythm then handle_broken_rhythm(token)
|
|
152
160
|
when :bar_line then handle_bar_line(token)
|
|
153
161
|
when :volta then handle_volta(token)
|
|
154
162
|
when :voice_change then handle_voice_change(token)
|
|
163
|
+
when :beam_break then handle_beam_break(token)
|
|
155
164
|
end
|
|
156
165
|
end
|
|
157
166
|
|
|
@@ -207,19 +216,91 @@ module HeadMusic::Notation::ABC
|
|
|
207
216
|
def defer_placement(state, pitches, length, inner_scale = Rational(1))
|
|
208
217
|
scale = (state.awaiting_scale || Rational(1)) * inner_scale
|
|
209
218
|
state.awaiting_scale = nil
|
|
219
|
+
return tie_onto_pending(state, pitches, length, scale) if state.tie_open
|
|
220
|
+
|
|
210
221
|
flush_pending_note(state)
|
|
211
|
-
|
|
222
|
+
flag = if state.beam_break_pending
|
|
223
|
+
true
|
|
224
|
+
else
|
|
225
|
+
(state.beam_last_was_note ? false : nil)
|
|
226
|
+
end
|
|
227
|
+
state.beam_break_pending = false
|
|
228
|
+
state.beam_last_was_note = true
|
|
229
|
+
state.pending_note = PendingNote.new(pitches: pitches, length: length, scale: scale, beam_break: flag)
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# The lexer only emits :beam_break after a music token, so a voice
|
|
233
|
+
# state already exists; the flag is consumed by the next deferred note.
|
|
234
|
+
def handle_beam_break(_token)
|
|
235
|
+
current_state.beam_break_pending = true
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
# A tie (`-`) after a note or chord fuses it to the next note of the
|
|
239
|
+
# same pitch. The left note stays pending; the tie is only closed once
|
|
240
|
+
# its right note arrives.
|
|
241
|
+
def handle_tie(token)
|
|
242
|
+
state = current_state
|
|
243
|
+
if state.awaiting_scale || state.pending_note.nil?
|
|
244
|
+
raise ParseError.new(
|
|
245
|
+
"A tie must follow a note", line_number: token.line, snippet: "-"
|
|
246
|
+
)
|
|
247
|
+
end
|
|
248
|
+
state.tie_open = true
|
|
249
|
+
state.tie_line = token.line
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
# Closes an open tie: the pending note becomes the new note's tied
|
|
253
|
+
# prefix, so the pair (and any longer chain) resolves to a single
|
|
254
|
+
# placement whose rhythmic value carries the author's chosen split.
|
|
255
|
+
def tie_onto_pending(state, pitches, length, scale)
|
|
256
|
+
pending = state.pending_note
|
|
257
|
+
ensure_tie_pitches_match(state, pending, pitches)
|
|
258
|
+
prefix = pending_rhythmic_value(pending)
|
|
259
|
+
state.tie_open = false
|
|
260
|
+
state.tie_line = nil
|
|
261
|
+
state.pending_note = PendingNote.new(
|
|
262
|
+
pitches: pitches, length: length, scale: scale, tied_prefix: prefix,
|
|
263
|
+
beam_break: pending.beam_break
|
|
264
|
+
)
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def ensure_tie_pitches_match(state, pending, pitches)
|
|
268
|
+
return if pending.pitches.sort == pitches.sort
|
|
269
|
+
|
|
270
|
+
raise ParseError.new(
|
|
271
|
+
"A tie must connect two notes of the same pitch",
|
|
272
|
+
line_number: state.tie_line, snippet: "-"
|
|
273
|
+
)
|
|
212
274
|
end
|
|
213
275
|
|
|
214
276
|
def handle_rest(token)
|
|
215
277
|
ensure_not_awaiting_note(token)
|
|
216
278
|
state = current_state
|
|
279
|
+
reject_open_tie(state, token.line, "A tie must be followed by a note")
|
|
217
280
|
flush_pending_note(state)
|
|
281
|
+
reset_beam_adjacency(state)
|
|
218
282
|
place(state, token.length, nil)
|
|
219
283
|
end
|
|
220
284
|
|
|
285
|
+
# After a rest, bar line, volta, or voice change, a following note
|
|
286
|
+
# must not be marked as beamed to whatever preceded the boundary.
|
|
287
|
+
def reset_beam_adjacency(state)
|
|
288
|
+
state.beam_last_was_note = false
|
|
289
|
+
state.beam_break_pending = false
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
# A tie left open by a non-note terminator can never close, so each
|
|
293
|
+
# terminator rejects it. A bar line gets its own message: an author
|
|
294
|
+
# tie across a barline is a real, but not-yet-supported, request.
|
|
295
|
+
def reject_open_tie(state, line, message)
|
|
296
|
+
return unless state&.tie_open
|
|
297
|
+
|
|
298
|
+
raise ParseError.new(message, line_number: line || state.tie_line, snippet: "-")
|
|
299
|
+
end
|
|
300
|
+
|
|
221
301
|
def handle_broken_rhythm(token)
|
|
222
302
|
state = current_state
|
|
303
|
+
reject_open_tie(state, token.line, "A tie must be followed by a note")
|
|
223
304
|
if state.awaiting_scale || state.pending_note.nil?
|
|
224
305
|
raise ParseError.new(
|
|
225
306
|
"Broken rhythm must appear between two notes",
|
|
@@ -235,7 +316,9 @@ module HeadMusic::Notation::ABC
|
|
|
235
316
|
def handle_bar_line(token)
|
|
236
317
|
ensure_not_awaiting_note(token)
|
|
237
318
|
state = current_state
|
|
319
|
+
reject_open_tie(state, token.line, "Ties across barlines are not yet supported")
|
|
238
320
|
flush_pending_note(state)
|
|
321
|
+
reset_beam_adjacency(state)
|
|
239
322
|
tag_completed_bar(state)
|
|
240
323
|
apply_repeat_flags(state, token.style)
|
|
241
324
|
clear_passes_if_over(state, token.style)
|
|
@@ -248,7 +331,9 @@ module HeadMusic::Notation::ABC
|
|
|
248
331
|
raise ParseError.new("Volta has no passes", line_number: token.line)
|
|
249
332
|
end
|
|
250
333
|
state = current_state
|
|
334
|
+
reject_open_tie(state, token.line, "A tie must be followed by a note")
|
|
251
335
|
flush_pending_note(state)
|
|
336
|
+
reset_beam_adjacency(state)
|
|
252
337
|
state.active_passes = token.passes
|
|
253
338
|
state.volta_start_bar = state.entered_bar_number
|
|
254
339
|
end
|
|
@@ -257,7 +342,9 @@ module HeadMusic::Notation::ABC
|
|
|
257
342
|
# Guarded so a leading V: line doesn't force a default voice into existence.
|
|
258
343
|
if @current_state
|
|
259
344
|
ensure_not_awaiting_note(token, state: @current_state)
|
|
345
|
+
reject_open_tie(@current_state, token.line, "A tie must be followed by a note")
|
|
260
346
|
flush_pending_note(@current_state)
|
|
347
|
+
reset_beam_adjacency(@current_state)
|
|
261
348
|
end
|
|
262
349
|
@current_state = voice_state(token.voice_id)
|
|
263
350
|
end
|
|
@@ -265,6 +352,7 @@ module HeadMusic::Notation::ABC
|
|
|
265
352
|
def finish
|
|
266
353
|
@voice_states.each_value do |state|
|
|
267
354
|
ensure_not_awaiting_note(nil, state: state)
|
|
355
|
+
reject_open_tie(state, nil, "A tie must be followed by a note")
|
|
268
356
|
flush_pending_note(state)
|
|
269
357
|
tag_completed_bar(state)
|
|
270
358
|
end
|
|
@@ -284,7 +372,23 @@ module HeadMusic::Notation::ABC
|
|
|
284
372
|
return unless pending
|
|
285
373
|
|
|
286
374
|
state.pending_note = nil
|
|
287
|
-
place(state, pending
|
|
375
|
+
placement = state.voice.place(state.voice.next_position, pending_rhythmic_value(pending), pending.pitches)
|
|
376
|
+
placement.beam_break_before = pending.beam_break
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
# A pending note's own value, with any tied prefix appended ahead of
|
|
380
|
+
# it so the whole tie chain renders as one sounding note.
|
|
381
|
+
def pending_rhythmic_value(pending)
|
|
382
|
+
own = duration_resolver.rhythmic_value(pending.length, scale: pending.scale)
|
|
383
|
+
pending.tied_prefix ? append_tied(pending.tied_prefix, own) : own
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
# Attaches `tail` at the deep end of `head`'s tied chain, rebuilding
|
|
387
|
+
# each link (RhythmicValue exposes no setter) so a chain like
|
|
388
|
+
# "half tied to eighth" gains a further "tied to quarter".
|
|
389
|
+
def append_tied(head, tail)
|
|
390
|
+
inner = head.tied_value ? append_tied(head.tied_value, tail) : tail
|
|
391
|
+
HeadMusic::Rudiment::RhythmicValue.new(head.unit, dots: head.dots, tied_value: inner)
|
|
288
392
|
end
|
|
289
393
|
|
|
290
394
|
def place(state, length, pitches, scale: Rational(1))
|
|
@@ -115,7 +115,21 @@ module HeadMusic::Notation::ABC
|
|
|
115
115
|
bar_groups.each_with_index.map do |bar_placements, index|
|
|
116
116
|
# Accidental state must mirror what a re-parse accumulates bar by bar.
|
|
117
117
|
pitch_writer.start_new_bar if index.positive?
|
|
118
|
-
bar_placements.map { |placement| token(placement, pitch_writer, duration_writer) }
|
|
118
|
+
tokens = bar_placements.map { |placement| token(placement, pitch_writer, duration_writer) }
|
|
119
|
+
join_bar_tokens(bar_placements, tokens)
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Suppresses the inter-token space only where the following placement was
|
|
124
|
+
# authored as beamed to its predecessor (beam_break_before == false).
|
|
125
|
+
# A true or nil flag keeps the space, so programmatic (nil-flag)
|
|
126
|
+
# compositions render with today's every-token spacing. Every bar token
|
|
127
|
+
# (note, rest, or [..] chord) re-lexes unambiguously with no separator, so
|
|
128
|
+
# dropping the space is safe.
|
|
129
|
+
def join_bar_tokens(placements, tokens)
|
|
130
|
+
tokens.each_with_index.reduce(+"") do |line, (token, index)|
|
|
131
|
+
separator = (index.zero? || placements[index].beam_break_before == false) ? "" : " "
|
|
132
|
+
line << separator << token
|
|
119
133
|
end
|
|
120
134
|
end
|
|
121
135
|
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# A namespace for MusicXML-notation rendering helpers
|
|
2
|
+
module HeadMusic::Notation::MusicXML
|
|
3
|
+
# Computes MusicXML <beam> annotations for the ordered noteheads of one
|
|
4
|
+
# measure. Pure and side-effect-free: data in, data out. The Writer builds
|
|
5
|
+
# the Event list and turns the returned Beams into XML.
|
|
6
|
+
class BeamGrouper
|
|
7
|
+
# A single <beam number="N">type</beam> annotation.
|
|
8
|
+
Beam = Struct.new(:number, :type, keyword_init: true)
|
|
9
|
+
|
|
10
|
+
# One notehead's beaming inputs.
|
|
11
|
+
# - levels: beams this notehead carries alone (eighth=1, sixteenth=2, ...);
|
|
12
|
+
# a rest or quarter-or-longer note is 0 (unbeamable).
|
|
13
|
+
# - onset: integer offset from the start of the bar, in MusicXML divisions.
|
|
14
|
+
# - beam_break_before: tri-state override vs. the previous event
|
|
15
|
+
# (nil = meter default, true = force break, false = force join).
|
|
16
|
+
Event = Struct.new(:levels, :onset, :beam_break_before, keyword_init: true)
|
|
17
|
+
|
|
18
|
+
def self.annotate(events, group_unit_divisions)
|
|
19
|
+
new(events, group_unit_divisions).annotate
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def initialize(events, group_unit_divisions)
|
|
23
|
+
@events = events
|
|
24
|
+
@group_unit_divisions = group_unit_divisions
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def annotate
|
|
28
|
+
groups.flat_map { |group| beams_for_group(group) }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
attr_reader :events, :group_unit_divisions
|
|
34
|
+
|
|
35
|
+
# Phase A: segment event indices into groups of consecutive indices.
|
|
36
|
+
def groups
|
|
37
|
+
result = []
|
|
38
|
+
events.each_index do |i|
|
|
39
|
+
if i.zero? || break_before?(i)
|
|
40
|
+
result << [i]
|
|
41
|
+
else
|
|
42
|
+
result.last << i
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
result
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def break_before?(index)
|
|
49
|
+
event = events[index]
|
|
50
|
+
return true if event.levels.zero?
|
|
51
|
+
return true if events[index - 1].levels.zero?
|
|
52
|
+
return true if event.beam_break_before == true
|
|
53
|
+
return false if event.beam_break_before == false
|
|
54
|
+
|
|
55
|
+
(event.onset % group_unit_divisions).zero?
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Phase B: emit one Array<Beam> per member, parallel to the group's events.
|
|
59
|
+
def beams_for_group(group)
|
|
60
|
+
return [[]] if group.size == 1
|
|
61
|
+
|
|
62
|
+
member_beams = Array.new(group.size) { [] }
|
|
63
|
+
max_level = group.map { |i| events[i].levels }.max
|
|
64
|
+
(1..max_level).each { |level| add_level_beams(group, level, member_beams) }
|
|
65
|
+
member_beams
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def add_level_beams(group, level, member_beams)
|
|
69
|
+
participating(group, level).each do |run|
|
|
70
|
+
emit_run(run, level, member_beams)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Maximal runs of consecutive-within-the-group positions whose event has
|
|
75
|
+
# levels >= level. Positions are indices into `group` (and member_beams).
|
|
76
|
+
def participating(group, level)
|
|
77
|
+
runs = []
|
|
78
|
+
group.each_with_index do |event_index, position|
|
|
79
|
+
if events[event_index].levels >= level
|
|
80
|
+
(runs.last && runs.last.last == position - 1) ? runs.last << position : runs << [position]
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
runs
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def emit_run(run, level, member_beams)
|
|
87
|
+
if run.size == 1
|
|
88
|
+
position = run.first
|
|
89
|
+
# A lone member at this level gets a partial beam (hook). It points
|
|
90
|
+
# backward unless it is the group's first member, which has nothing
|
|
91
|
+
# to hook back to.
|
|
92
|
+
member_beams[position] << Beam.new(number: level, type: position.zero? ? "forward hook" : "backward hook")
|
|
93
|
+
return
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
run.each_with_index do |position, offset|
|
|
97
|
+
member_beams[position] << Beam.new(number: level, type: run_type(offset, run.size))
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def run_type(offset, run_size)
|
|
102
|
+
return "begin" if offset.zero?
|
|
103
|
+
return "end" if offset == run_size - 1
|
|
104
|
+
|
|
105
|
+
"continue"
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
@@ -19,6 +19,17 @@ module HeadMusic::Notation::MusicXML
|
|
|
19
19
|
# carriage return, even as character references.
|
|
20
20
|
FORBIDDEN_TEXT_CHARACTERS = /[\u0000-\u0008\u000B\u000C\u000E-\u001F]/
|
|
21
21
|
|
|
22
|
+
# The number of beams a notehead of each MusicXML <type> carries alone.
|
|
23
|
+
# Every other type (quarter and longer) and every rest carries none.
|
|
24
|
+
BEAM_LEVELS_BY_TYPE = {
|
|
25
|
+
"eighth" => 1,
|
|
26
|
+
"16th" => 2,
|
|
27
|
+
"32nd" => 3,
|
|
28
|
+
"64th" => 4,
|
|
29
|
+
"128th" => 5,
|
|
30
|
+
"256th" => 6
|
|
31
|
+
}.freeze
|
|
32
|
+
|
|
22
33
|
attr_reader :composition
|
|
23
34
|
|
|
24
35
|
def initialize(composition)
|
|
@@ -120,6 +131,61 @@ module HeadMusic::Notation::MusicXML
|
|
|
120
131
|
end
|
|
121
132
|
end
|
|
122
133
|
|
|
134
|
+
# A Hash keyed by [placement, component_index] holding the Array<Beam>
|
|
135
|
+
# that BeamGrouper computed for that notehead. Built one bar at a time so
|
|
136
|
+
# a notehead's onset is its exact integer offset from the bar start.
|
|
137
|
+
def beam_annotations
|
|
138
|
+
@beam_annotations ||= {}.tap do |annotations|
|
|
139
|
+
composition.voices.each do |voice|
|
|
140
|
+
bar_numbers.each { |bar_number| annotate_bar(voice, bar_number, annotations) }
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def annotate_bar(voice, bar_number, annotations)
|
|
146
|
+
placements = placements_by_bar(voice)[bar_number]
|
|
147
|
+
return unless placements
|
|
148
|
+
|
|
149
|
+
keys = []
|
|
150
|
+
events = build_bar_events(placements, keys)
|
|
151
|
+
beams = BeamGrouper.annotate(events, group_unit_divisions(bar_number))
|
|
152
|
+
keys.each_with_index { |key, index| annotations[key] = beams[index] }
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def build_bar_events(placements, keys)
|
|
156
|
+
onset = 0
|
|
157
|
+
placements.flat_map do |placement|
|
|
158
|
+
components_by_placement[placement].each_with_index.map do |component, component_index|
|
|
159
|
+
event = BeamGrouper::Event.new(
|
|
160
|
+
levels: beam_levels(placement, component),
|
|
161
|
+
onset: onset,
|
|
162
|
+
beam_break_before: component_index.zero? ? placement.beam_break_before : nil
|
|
163
|
+
)
|
|
164
|
+
keys << [placement, component_index]
|
|
165
|
+
onset += component.duration
|
|
166
|
+
event
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def beam_levels(placement, component)
|
|
172
|
+
return 0 if placement.rest?
|
|
173
|
+
|
|
174
|
+
BEAM_LEVELS_BY_TYPE.fetch(component.type, 0)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# The beam-group span in integer divisions for a bar's effective meter.
|
|
178
|
+
# DurationWriter.single_quarter_fraction returns an exact Rational, so the
|
|
179
|
+
# product with the integer divisions is integral for every supported meter.
|
|
180
|
+
def group_unit_divisions(bar_number)
|
|
181
|
+
fraction = DurationWriter.single_quarter_fraction(effective_meter(bar_number).beam_group_unit) * divisions
|
|
182
|
+
unless fraction.denominator == 1
|
|
183
|
+
raise RenderError,
|
|
184
|
+
"cannot express the beam group unit as an integer duration at #{divisions} divisions per quarter note"
|
|
185
|
+
end
|
|
186
|
+
fraction.to_i
|
|
187
|
+
end
|
|
188
|
+
|
|
123
189
|
def bar_numbers
|
|
124
190
|
composition.earliest_bar_number..composition.latest_bar_number
|
|
125
191
|
end
|
|
@@ -313,9 +379,12 @@ module HeadMusic::Notation::MusicXML
|
|
|
313
379
|
def note_lines(placement)
|
|
314
380
|
ensure_pitched_sounds(placement)
|
|
315
381
|
|
|
316
|
-
components_by_placement[placement].flat_map do |component|
|
|
382
|
+
components_by_placement[placement].each_with_index.flat_map do |component, component_index|
|
|
383
|
+
beams = beam_annotations[[placement, component_index]] || []
|
|
317
384
|
note_slots(placement).each_with_index.flat_map do |pitch, index|
|
|
318
|
-
note_element_lines(
|
|
385
|
+
note_element_lines(
|
|
386
|
+
placement, component, pitch: pitch, chord: index.positive?, beams: index.zero? ? beams : []
|
|
387
|
+
)
|
|
319
388
|
end
|
|
320
389
|
end
|
|
321
390
|
end
|
|
@@ -338,7 +407,7 @@ module HeadMusic::Notation::MusicXML
|
|
|
338
407
|
# A chord note carries <chord/> as its first child, before <pitch>, marking
|
|
339
408
|
# it as sounding with the preceding note; the lead note (and every single
|
|
340
409
|
# note and rest) omits it, so this path stays byte-identical for those.
|
|
341
|
-
def note_element_lines(placement, component, pitch: nil, chord: false)
|
|
410
|
+
def note_element_lines(placement, component, pitch: nil, chord: false, beams: [])
|
|
342
411
|
[
|
|
343
412
|
"#{INDENT * 3}<note>",
|
|
344
413
|
*(chord ? ["#{INDENT * 4}<chord/>"] : []),
|
|
@@ -347,11 +416,16 @@ module HeadMusic::Notation::MusicXML
|
|
|
347
416
|
*tie_lines(placement, component),
|
|
348
417
|
"#{INDENT * 4}<type>#{component.type}</type>",
|
|
349
418
|
*Array.new(component.dots) { "#{INDENT * 4}<dot/>" },
|
|
419
|
+
*beam_lines(beams),
|
|
350
420
|
*notation_lines(placement, component),
|
|
351
421
|
"#{INDENT * 3}</note>"
|
|
352
422
|
]
|
|
353
423
|
end
|
|
354
424
|
|
|
425
|
+
def beam_lines(beams)
|
|
426
|
+
beams.map { |beam| %(#{INDENT * 4}<beam number="#{beam.number}">#{beam.type}</beam>) }
|
|
427
|
+
end
|
|
428
|
+
|
|
355
429
|
def pitch_lines(pitch)
|
|
356
430
|
attributes = PitchWriter.attributes(pitch)
|
|
357
431
|
[
|
|
@@ -99,6 +99,20 @@ class HeadMusic::Rudiment::Meter < HeadMusic::Rudiment::Base
|
|
|
99
99
|
# for consistency with conversational usage
|
|
100
100
|
alias_method :beat_unit, :beat_value
|
|
101
101
|
|
|
102
|
+
# The span of one default beam group for this meter, as a RhythmicValue.
|
|
103
|
+
# This is a distinct concern from #beat_value: it is the boundary unit used
|
|
104
|
+
# to decide where beams break.
|
|
105
|
+
def beam_group_unit
|
|
106
|
+
@beam_group_unit ||=
|
|
107
|
+
if compound?
|
|
108
|
+
beat_value
|
|
109
|
+
elsif bottom_number >= 8
|
|
110
|
+
whole_bar_beam_group_unit
|
|
111
|
+
else
|
|
112
|
+
HeadMusic::Rudiment::RhythmicValue.new(count_unit)
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
102
116
|
def to_s
|
|
103
117
|
[top_number, bottom_number].join("/")
|
|
104
118
|
end
|
|
@@ -119,6 +133,21 @@ class HeadMusic::Rudiment::Meter < HeadMusic::Rudiment::Base
|
|
|
119
133
|
|
|
120
134
|
private
|
|
121
135
|
|
|
136
|
+
# For simple meters with an eighth-or-shorter denominator (e.g. 2/8, 3/8),
|
|
137
|
+
# the whole bar is a single beam group. A triple grouping (3/8) is a dotted
|
|
138
|
+
# value of the half-denominator unit; a duple grouping (2/8) spans the bar.
|
|
139
|
+
# Asymmetric meters (5/8, 7/16, ...) are out of scope: their bar has no
|
|
140
|
+
# single power-of-two unit, so the group unit falls back to the count unit
|
|
141
|
+
# (each count its own group) rather than raising on a nil unit.
|
|
142
|
+
def whole_bar_beam_group_unit
|
|
143
|
+
if (top_number % 3).zero?
|
|
144
|
+
HeadMusic::Rudiment::RhythmicValue.new(HeadMusic::Rudiment::RhythmicUnit.for_denominator_value(bottom_number / 2), dots: 1)
|
|
145
|
+
else
|
|
146
|
+
unit = HeadMusic::Rudiment::RhythmicUnit.for_denominator_value(bottom_number / top_number)
|
|
147
|
+
HeadMusic::Rudiment::RhythmicValue.new(unit || count_unit)
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
122
151
|
def downbeat?(count, tick = 0)
|
|
123
152
|
beat?(tick) && count == 1
|
|
124
153
|
end
|
data/lib/head_music/version.rb
CHANGED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
metadata:
|
|
3
|
+
created_at: 2026-07-19T16:21:53-07:00
|
|
4
|
+
activated_at: 2026-07-19T18:37:53-07:00
|
|
5
|
+
planned_at: 2026-07-19T18:09:39-07:00
|
|
6
|
+
finished_at: 2026-07-19T20:10:12-07:00
|
|
7
|
+
updated_at: 2026-07-19T20:10:12-07:00
|
|
8
|
+
-->
|
|
9
|
+
|
|
10
|
+
# Story: ABC Notation Beaming
|
|
11
|
+
|
|
12
|
+
## Summary
|
|
13
|
+
|
|
14
|
+
AS a dev
|
|
15
|
+
I WANT to be able to represent beaming in an ABC import
|
|
16
|
+
SO THAT notation I generate from the resulting Composition has beaming.
|
|
17
|
+
|
|
18
|
+
## Acceptance Criteria
|
|
19
|
+
|
|
20
|
+
### Default meter-derived beaming (MusicXML output)
|
|
21
|
+
|
|
22
|
+
- In a simple meter (2/4, 3/4, 4/4), `MusicXML::Writer` emits `<beam>` elements that group consecutive sub-quarter notes by beat (the count unit).
|
|
23
|
+
- In a compound meter (6/8, 9/8, 12/8), beam groups follow the dotted-quarter pulse — three eighths per group — not the individual eighth. (This is the case OSMD's renderer default gets wrong.)
|
|
24
|
+
- Beam elements read `begin` on the first note of a group, `continue` in the middle, and `end` on the last; a lone flagged note in its own group emits no beam.
|
|
25
|
+
- The `number` attribute reflects the beam level (eighth = 1, sixteenth = 2, …), and mixed values within a group (e.g. an eighth followed by two sixteenths) nest the levels correctly.
|
|
26
|
+
- Quarter-or-longer notes and rests terminate the current beam group — no beam spans them.
|
|
27
|
+
- Beam grouping resolves at the notated-note (`Component`) level, so a note that expands into a tied chain still beams correctly across the tie.
|
|
28
|
+
- Beam groups never cross a barline or a beat/pulse-group boundary.
|
|
29
|
+
|
|
30
|
+
### Authored-group capture from ABC (override)
|
|
31
|
+
|
|
32
|
+
- The ABC body lexer preserves inter-note spacing as a beam signal instead of silently discarding it (today: `body_lexer.rb:114` skips `/[ \t]+/` with no token).
|
|
33
|
+
- Adjacent note tokens with no separating space import as one beam group; a space breaks the group — matching ABC's beaming convention.
|
|
34
|
+
- When ABC source expresses an explicit grouping, that authored grouping overrides the meter-derived default on output (the "deliberate deviation" case, same default-vs-override shape as ties).
|
|
35
|
+
- A placement with no authored beam signal falls back to the meter-derived default; an explicit signal is honored verbatim.
|
|
36
|
+
|
|
37
|
+
### ABC export round-trip
|
|
38
|
+
|
|
39
|
+
- The ABC writer suppresses the inter-placement space *within* an authored beam group and emits a space at group boundaries, so an authored grouping survives parse → write → parse without loss (today `writer.rb:118` joins every placement with a space, breaking every beam).
|
|
40
|
+
|
|
41
|
+
## Notes
|
|
42
|
+
|
|
43
|
+
### Why this feature exists
|
|
44
|
+
|
|
45
|
+
OSMD's renderer-side default beaming can't handle compound meters, which are central to the material here (6/8 now; counterpoint lives in 3/4, 6/4, cut time…). So head_music must emit beams explicitly rather than lean on the renderer. Consumers render with **autoBeam off** — explicit beams are then authoritative and portable (MuseScore and Dorico honor them too).
|
|
46
|
+
|
|
47
|
+
### How beaming is represented (design recommendation)
|
|
48
|
+
|
|
49
|
+
Beaming is **not** modeled like ties. Ties fold into a single `RhythmicValue.tied_value` chain (contained within one note); beaming is a *grouping across multiple notes*, so it cannot live inside one `RhythmicValue`. Two representations, matching the default-vs-override shape:
|
|
50
|
+
|
|
51
|
+
1. **Default beaming — no model state.** `MusicXML::Writer` derives groups on the fly from `Meter#compound?` / `Meter#beat_value` (`meter.rb:90-100`) plus each note's position and type. A programmatically-built Composition, or ABC with no grouping intent, carries nothing extra.
|
|
52
|
+
2. **Authored override — a lightweight per-`Placement` flag.** In ABC a beam break can only fall *between* note tokens (a space), never inside a single note, so a placement-level signal (e.g. `beam_break_before` / "beamed-to-previous", `nil` by default) is sufficient. This mirrors the side-metadata flags `Bar` already carries (`starts_repeat`, `plays_on_passes`) rather than baking state into `RhythmicValue`. Concrete attribute name is a planning decision.
|
|
53
|
+
|
|
54
|
+
At render: an explicit placement flag is honored (override); `nil` falls back to the meter default. Within a placement that expands to a tied chain, the internal component-to-component beams still follow type-based rules — the flag only governs the boundary *between* placements.
|
|
55
|
+
|
|
56
|
+
### Code map (from exploration)
|
|
57
|
+
|
|
58
|
+
- **Meter grouping primitives** — `Meter#compound?` (`top > 3 && top % 3 == 0`), `Meter#beat_value`/`beat_unit` (dotted-quarter for compound, count unit for simple), `Meter#beats_per_bar`: `lib/head_music/rudiment/meter.rb:38-44, 58-64, 90-100`. Edge case: **3/8 reports `simple?`** (fails `top > 3`) — confirm desired grouping.
|
|
59
|
+
- **MusicXML `<beam>` emission** — insert into `note_element_lines` after `<dot>`/`<stem>` and before `<notations>`: `lib/head_music/notation/music_xml/writer.rb:341-353`. A `Component` is one notated notehead (`duration_writer.rb:7, 44-65`); a placement can expand into several via a tied chain, so beams resolve at the Component level in `note_lines` (`writer.rb:313-321`). No `beam` string exists in `lib` today.
|
|
60
|
+
- **ABC lexer capture** — change the discard at `lib/head_music/notation/abc/body_lexer.rb:114`; add token field(s) near `body_lexer.rb:12-25`.
|
|
61
|
+
- **ABC parser** — placement creation at `parser.rb:165-169, 214-221, 343-349` needs to carry the beam signal onto the Placement (compare with how `tie` state threads through `VoiceState`).
|
|
62
|
+
- **Placement model** — no beam attribute today: `lib/head_music/content/placement.rb:5-8`.
|
|
63
|
+
- **ABC export** — space-joining to make conditional: `lib/head_music/notation/abc/writer.rb:112-120`.
|
|
64
|
+
|
|
65
|
+
### Reference
|
|
66
|
+
|
|
67
|
+
Follow the tie feature as the architectural template: `user-stories/done/abc-tie-input.md`. Note the key difference — ties collapsed to the input boundary because the render path already existed; beaming has **no existing data path at all** (new model state + MusicXML emission + ABC space-suppression), so it is a larger change.
|
|
68
|
+
|
|
69
|
+
## Review
|
|
70
|
+
|
|
71
|
+
_Reviewed 2026-07-19 at commit `cd35e95` (feature), by product-manager (acceptance criteria) and code-reviewer (code quality). Two follow-up fixes were applied after the review and are described below._
|
|
72
|
+
|
|
73
|
+
### Acceptance criteria
|
|
74
|
+
|
|
75
|
+
All 12 criteria **✅ met**, each backed by code and a passing test (full suite green, 5938 examples).
|
|
76
|
+
|
|
77
|
+
**Default meter beaming (MusicXML)**
|
|
78
|
+
|
|
79
|
+
- ✅ Simple meter groups sub-quarter notes by beat — `meter.rb` `beam_group_unit` + `writer_spec` "eight default-beamed eighths in 4/4".
|
|
80
|
+
- ✅ Compound meter groups by the dotted-quarter pulse (the renderer-fails case) — `writer_spec` "six default-beamed eighths in 6/8" → two 3-groups.
|
|
81
|
+
- ✅ begin/continue/end; lone note emits no beam — `beam_grouper_spec` size-1 group → `[]`.
|
|
82
|
+
- ✅ `number` = beam level; mixed values nest — `writer_spec` dotted-eighth + sixteenth (level-1 end + level-2 backward hook).
|
|
83
|
+
- ✅ Quarter-or-longer notes and rests terminate a group — `writer_spec` quarter/rest break cases.
|
|
84
|
+
- ✅ Resolves at the Component level; beams across a tied chain — `writer_spec` "tied chain of two eighths inside one beat group".
|
|
85
|
+
- ✅ Never crosses a barline or beat/pulse boundary — per-bar annotation + modulo check; pickup-bar test.
|
|
86
|
+
|
|
87
|
+
**Authored ABC capture (override)**
|
|
88
|
+
|
|
89
|
+
- ✅ Lexer preserves inter-note spacing as `:beam_break` — `body_lexer_spec`.
|
|
90
|
+
- ✅ Adjacent → one group, space → break — `parser_spec` flag-sequence tests.
|
|
91
|
+
- ✅ Authored grouping overrides the default — `writer_spec` "authored ABC beam grouping in 4/4".
|
|
92
|
+
- ✅ No signal → meter default; explicit honored — `beam_grouper_spec` tri-state tests.
|
|
93
|
+
|
|
94
|
+
**ABC export round-trip**
|
|
95
|
+
|
|
96
|
+
- ✅ Space suppressed within a group, kept at boundaries; survives parse→write→parse — `abc/writer_spec` idempotence + `composition_serialization_spec` JSON round-trip.
|
|
97
|
+
|
|
98
|
+
### Code review findings
|
|
99
|
+
|
|
100
|
+
- **Medium — asymmetric simple meters crashed `beam_group_unit`** (`meter.rb`): 5/16, 7/16, etc. produced a non-power-of-two unit → `nil` → `DelegationError` instead of a graceful result, violating the "out of scope but must not crash" constraint. **Fixed:** the duple whole-bar branch now falls back to the count unit when `for_denominator_value` returns `nil`; added `meter_spec` rows for 5/16 and 7/8.
|
|
101
|
+
- **Nit — local `groups` shadowed the method `#groups`** (`beam_grouper.rb`). **Fixed:** renamed the accumulator to `result`.
|
|
102
|
+
- Positives noted: `BeamGrouper` is genuinely pure; integer-only onset arithmetic (no Float drift); the component-vs-placement granularity risk is handled correctly (flag only on component 0, onset accumulates per component); `false`-join correctly cannot bridge a rest/quarter; high-quality XML-output assertions.
|
|
103
|
+
|
|
104
|
+
### Manual verification (not blockers)
|
|
105
|
+
|
|
106
|
+
- The MusicXML `<beam>` output has **not** been opened in a real notation reader (MuseScore/Finale/Sibelius). The tests assert correct XML structure; a one-time visual eyeball — especially the 6/8 case and hook direction — is worth doing.
|
|
107
|
+
|
|
108
|
+
### Verdict
|
|
109
|
+
|
|
110
|
+
Shippable. Both review findings have been fixed; nothing blocks `finish`. Recommend committing the two fixes before finishing.
|
|
111
|
+
|
|
112
|
+
## Implementation Plan
|
|
113
|
+
|
|
114
|
+
### Overview
|
|
115
|
+
|
|
116
|
+
Beaming is emitted at MusicXML render time from a new stateless helper `HeadMusic::Notation::MusicXML::BeamGrouper`, fed a measure's ordered noteheads and the meter's beam-group unit. Default grouping needs zero model state; ABC-authored grouping is captured as a tri-state `beam_break_before` flag on each `Placement` (the `Bar`-style side-metadata pattern) that overrides the default at placement boundaries. Build in two internally-ordered layers that ship together: default render (Steps 1-3), then authored override (Steps 4-7).
|
|
117
|
+
|
|
118
|
+
**One "confirmed" assumption is corrected here.** The brief said "simple meter → group by beat (count unit)." Grouping 3/8 by its count unit (the eighth) makes every eighth its own group and emits **no** beam — contradicting the confirmed "3/8 → one group of three eighths." The grouping unit is therefore **not** `Meter#beat_value` for eighth/sixteenth-denominator simple meters. Step 1 introduces a distinct `Meter#beam_group_unit` (whole bar for 3/8 and 2/8, the beat for quarter-or-larger simple meters, the dotted-quarter pulse for compound). 3/8 stays `simple?`; only the beam-grouping unit differs.
|
|
119
|
+
|
|
120
|
+
### Scope
|
|
121
|
+
|
|
122
|
+
- **v1 (ships together):** default beaming for simple (2/4, 3/4, 4/4, 3/8) and compound (6/8, 9/8, 12/8) meters; eighths and sixteenths with correct primary (level 1) and secondary (level 2) beams **including forward/backward hooks** (dotted-eighth + sixteenth is ubiquitous and cannot be omitted without visibly wrong output); authored override via the per-Placement flag, honored on render and preserved through ABC export round-trip. Layers 1 and 2 must land in one story — the override is only observable as a deviation from the default, so the default must exist to test it.
|
|
123
|
+
- **Deferred, each with a defined behavior:** 32nd-and-finer levels (logic generalizes but assert only to level 2); tuplet beaming (tuplets already unsupported at the ABC boundary); beaming across a barline (MusicXML forbids it; `ensure_notes_within_barlines` enforces it — beam simply breaks at the barline, silently, matching the tie feature's barline behavior); beam-over-rest (rests always break a group in v1); feathered/cross-staff/slope beams (not expressible in ABC).
|
|
124
|
+
|
|
125
|
+
### Steps
|
|
126
|
+
|
|
127
|
+
**1. `Meter#beam_group_unit` — the grouping-boundary source (domain rudiment)**
|
|
128
|
+
|
|
129
|
+
- Add a method returning the beam-group span as a `RhythmicValue`: dotted-quarter for `compound?`; the whole-bar value for eighth/sixteenth-denominator simple meters (3/8, 2/8); the beat (`count_unit`) for quarter-or-larger simple meters. Keep `beat_value`/`beat_unit` untouched — this is a separate concern (where beams break vs. where beats fall).
|
|
130
|
+
- Files: `lib/head_music/rudiment/meter.rb` (new method near `beat_value` :90-100; reuses `compound?` :42-44, `count_unit` :86-88, `top_number`/`bottom_number`).
|
|
131
|
+
- Tests: `spec/head_music/rudiment/meter_spec.rb` — table-driven across 4/4, 3/4, 2/2, **3/8**, 2/8, 6/8, 9/8, 12/8, asserting the group unit (and its tick/division span). This single spec regression-locks the 3/8 decision.
|
|
132
|
+
|
|
133
|
+
**2. `BeamGrouper` helper — pure, fully unit-testable (default beaming logic)**
|
|
134
|
+
|
|
135
|
+
- New `HeadMusic::Notation::MusicXML::BeamGrouper`, same shape as `Divisions`/`DurationWriter`/`ClefSelector`. Consumes an ordered list of per-notehead events for one measure plus the group-unit span; returns per-event `<beam>` annotations. No XML strings — return plain structs (mirror `DurationWriter#components` :44-47) so grouping is testable in isolation.
|
|
136
|
+
- Suggested shape:
|
|
137
|
+
- `Beam = Struct.new(:number, :type, keyword_init: true)`, `type ∈ "begin"|"continue"|"end"|"forward hook"|"backward hook"`.
|
|
138
|
+
- `Event = Struct.new(:levels, :onset, :beam_break_before, keyword_init: true)` — `levels` = beam count the notehead carries alone (eighth=1, 16th=2, 32nd=3 via a table keyed on `DurationWriter::TYPES_BY_UNIT_NAME` :11-24; `0` for a rest or quarter-or-longer); `onset` = integer offset from bar start **in divisions** (integer, not ticks — avoids Float; see risk note); `beam_break_before` meaningful only on a placement's first component.
|
|
139
|
+
- `BeamGrouper.for(events, group_unit_divisions) → Array<Array<Beam>>` parallel to `events`.
|
|
140
|
+
- Algorithm — segment then emit:
|
|
141
|
+
1. Boundary before event `i` when any of: `events[i].levels.zero?` or `events[i-1].levels.zero?` (rests / quarter+ are hard breaks and are singletons); `beam_break_before == true` (force break); `beam_break_before == false` (force join, overrides the default boundary); `nil` → default boundary iff `onset % group_unit_divisions == 0`.
|
|
142
|
+
2. Per group: size 1 → `[]` (lone-note no-beam case). Else level 1 spans the group (begin/continue/end). For each level `k ≥ 2`: runs of consecutive members with `levels ≥ k` emit begin/continue/end; a level-`k` member isolated between lower-level neighbours is a **hook** — backward if its predecessor is lower (dotted-eighth + 16th), forward if its successor is.
|
|
143
|
+
- Per-level nesting requires independent open/close tracking per level, not one group cursor.
|
|
144
|
+
- Tests: `spec/head_music/notation/music_xml/beam_grouper_spec.rb` — two eighths → begin/end L1; four eighths across two beats (all-nil) → two pairs; eighth + two 16ths → L1 begin/continue/end + L2 begin/end; dotted-eighth + 16th → L2 backward hook; level-0 mid-run splits and gets `[]`; size-1 group → `[]`; `beam_break_before:false` overriding a boundary → single group; `beam_break_before:true` mid-beat → split.
|
|
145
|
+
|
|
146
|
+
**3. Emit `<beam>` from the Writer, driven by `BeamGrouper` (completes Layer 1)**
|
|
147
|
+
|
|
148
|
+
- Build a memoized `beam_annotations` hash keyed by `[placement, component_index] → Array<Beam>`. For each voice/bar, iterate `placements_by_bar` (:292-295) in position order and construct the ordered `Event` list:
|
|
149
|
+
- **Onset must derive from `placement.position` (count/tick), not from the Component** — the Component carries no position. This is what makes anacrusis/pickup bars land on the correct metric grid: a 6/8 pickup eighth on count 6 groups as the bar's last pulse, not as offset 0. Compute the placement's bar-start offset in **divisions** (mirror the exact-integer arithmetic of `whole_measure_duration` :308-310), then accumulate each subsequent tied-chain component's `component.duration` (already integer divisions) onto it.
|
|
150
|
+
- Walk `components_by_placement[placement]` (:117-121) parallel to the `tied_value` chain. Per component: `levels` from its own `type` (0 if `placement.rest?`); `beam_break_before` = `placement.beam_break_before` on `component_index == 0`, else nil. Internal tied-chain beams thus follow type rules; the flag only governs the boundary between placements.
|
|
151
|
+
- `group_unit_divisions` from `effective_meter(bar_number).beam_group_unit` (Step 1) converted to divisions.
|
|
152
|
+
- Call `BeamGrouper.for(...)`; store under `[placement, i]`.
|
|
153
|
+
- `note_lines` (:313-321): change the outer `flat_map` to `each_with_index.flat_map` for `component_index`; look up the annotation; pass `beams:` into `note_element_lines` **only on the lead slot** — `beams: index.zero? ? beams : []` (MusicXML puts `<beam>` on the chord's primary note, never on `<chord/>` members).
|
|
154
|
+
- `note_element_lines` (:341-353): add `beams: []` kwarg; insert `*beam_lines(beams)` **between** the dots line (:349) and `*notation_lines` (:350) — after `<dot>`, before `<notations>`, per the DTD. `beam_lines` maps each `Beam` to `<beam number="N">type</beam>`.
|
|
155
|
+
- Tests: `spec/head_music/notation/music_xml/writer_spec.rb` — 4/4 eight eighths → four two-note groups (assert exact begin/end and that no beam crosses a beat); 6/8 six eighths → exactly two three-note groups (the OSMD-failure case, asserted directly); a quarter/rest between eighths breaks the group and emits no `<beam>`; a chord of eighths carries beams only on the non-`<chord/>` note; a lone eighth emits no `<beam>`; assert exact line placement relative to `<dot>`/`<notations>`.
|
|
156
|
+
|
|
157
|
+
**4. Per-`Placement` `beam_break_before` flag + override wiring (starts Layer 2)**
|
|
158
|
+
|
|
159
|
+
- Recommended name/semantics: **`beam_break_before`**, tri-state — `nil` = default/meter-derived (**nil ≠ false**), `true` = force a break here, `false` = force beamed-to-previous. Reserve all three states now: a nil/true-only boolean can add a break but can never join across a default boundary, which is exactly what fully-authoritative ABC needs.
|
|
160
|
+
- Follow the `Bar` side-metadata precedent (`starts_repeat`, `plays_on_passes`, `bar.rb`): add a writable attribute (`attr_accessor :beam_break_before`, default nil) set on the placement **after** construction, rather than threading a new positional/keyword arg through `Voice#place` and `Placement.new`. Placement already mutates via `merge` (:61-69) and `Voice#place` returns the instance, so the parser can set the flag on the returned object — this leaves the `place`/constructor/`merge` contract untouched.
|
|
161
|
+
- In `merge`, the receiver's flag wins (chord members at one position share one beam edge) — pin with a spec.
|
|
162
|
+
- The writer already reads `placement.beam_break_before` in Step 3's event construction; with the attribute defaulting to nil, Steps 1-3 render unchanged until the parser populates it.
|
|
163
|
+
- Files: `lib/head_music/content/placement.rb` (:5-15 attr, `merge` :61-69). Tests: `spec/head_music/content/placement_spec.rb` — default nil; `merge` keeps the first flag; a non-nil flag flows into the Step-3 event and changes the emitted grouping.
|
|
164
|
+
|
|
165
|
+
**5. ABC lexer — capture inter-note spacing as a `:beam_break` token**
|
|
166
|
+
|
|
167
|
+
- Emit a standalone `:beam_break` token (mirroring the separate `:tie` token, `scan_tie` :285-290) rather than adding a field to every note/chord/rest `Token` — keeps the `Token` struct (:12-25) and localizes the change. At :114, replace the silent `scanner.skip(/[ \t]+/)` discard with: skip the whitespace, and if it separated two music tokens (`tokens.last` is a note/chord/rest) push `Token.new(type: :beam_break, ...)`. Emitting on any skipped run is harmless; the parser consults it only when a note follows.
|
|
168
|
+
- Files: `lib/head_music/notation/abc/body_lexer.rb:114`. Tests: `spec/head_music/notation/abc/body_lexer_spec.rb` — `"CC"` → two `:note`, no `:beam_break`; `"C C"` → `:note`, `:beam_break`, `:note`; comment/continuation handling (:115-116) unaffected.
|
|
169
|
+
|
|
170
|
+
**6. ABC parser — thread the signal onto placements (mirror the tie state machine)**
|
|
171
|
+
|
|
172
|
+
- Extend `VoiceState` (:50-58) with `beam_break_pending` and `beam_last_was_note` accessors (parallels `tie_open`/`tie_line` :52-53). Add `when :beam_break then handle_beam_break(token)` to the dispatch (:152-163); `handle_beam_break` sets `beam_break_pending = true`.
|
|
173
|
+
- In `defer_placement` (:214-221), compute the flag as the note is deferred: `state.beam_break_pending ? true : (state.beam_last_was_note ? false : nil)`; carry it to `flush_pending_note` (:343-349) and set it on the placement returned by `state.voice.place(...)` (the Step-4 setter). Then reset `beam_break_pending = false; beam_last_was_note = true`. For a tie continuation (`tie_onto_pending` :240-249) keep the flag on the head of the fused chain only.
|
|
174
|
+
- Reset adjacency at hard boundaries so a following note is not marked "beamed to previous": `handle_rest` (:260-266), `handle_bar_line` (:292-301), `handle_voice_change` (:315-323) set both `beam_last_was_note = false` and `beam_break_pending = false`. First-of-bar/post-rest notes thus stay nil → meter default.
|
|
175
|
+
- Net: no-space adjacency → `false` (join), space-separated → `true` (break), first-of-run → `nil` (default). This makes ABC input fully authoritative (matching the OSMD-autoBeam-OFF contract), which is the recommended direction for the beat-crossing question below.
|
|
176
|
+
- Files: `lib/head_music/notation/abc/parser.rb`. Tests: `spec/head_music/notation/abc/parser_spec.rb` — `"CCCC"` → flags `[nil,false,false,false]` → one four-note group; `"CC CC"` → `[nil,false,true,false]` → two groups; a rest resets (`"CC z CC"`); tie interaction (`"C-C DD"`).
|
|
177
|
+
|
|
178
|
+
**7. ABC export — space-suppression for round-trip**
|
|
179
|
+
|
|
180
|
+
- In `build_bar_strings` (:112-120), replace the literal `" "` join (:118) with a per-adjacency separator: emit `""` before a placement whose `beam_break_before == false` (join), otherwise `" "` (covers `true` and `nil`, preserving today's behavior for unflagged programmatic compositions). Only valid between self-delimiting tokens (notes/chords/rests all re-lex unambiguously).
|
|
181
|
+
- Also add the non-nil flag to `Placement#to_h` (sparsely, mirroring how `Bar` serializes only non-default state) so an ABC → Composition → serialize cycle doesn't silently drop authored beaming.
|
|
182
|
+
- Files: `lib/head_music/notation/abc/writer.rb:118`, `lib/head_music/content/placement.rb` (`to_h`). Tests: `spec/head_music/notation/abc/writer_spec.rb` — `"CCCC"` re-emits without internal spaces; `"CC CC"` re-emits with the group-boundary space; unflagged programmatic composition still emits spaces (no regression); `ABC.parse(writer.to_s)` reproduces the flags (full round-trip).
|
|
183
|
+
|
|
184
|
+
**8. Edge-case coverage (cross-cutting)**
|
|
185
|
+
|
|
186
|
+
- Add to `beam_grouper_spec.rb` (pure) and `writer_spec.rb` / ABC `parser_spec.rb` (end-to-end):
|
|
187
|
+
- **Simple vs compound:** 6/8 → 2 groups of 3; 3/4 → 3 groups of 2; **3/8 → one group of 3** (do not special-case; Step 1 handles it).
|
|
188
|
+
- **Level 2:** eighth + two 16ths → L2 begin/end; **dotted-eighth + 16th → L2 backward hook**; a lone 16th → no beam; a pair of 16ths → both L1 and L2 begin/end.
|
|
189
|
+
- **Quarter/rest breaks:** `"CC A2 CC"` and `"CC z CC"` → separate groups; the quarter/rest emit no `<beam>`.
|
|
190
|
+
- **Tied note beaming across its own components:** a placement whose `tied_value` chain expands to multiple sub-quarter components beams internally by type; the `beam_break_before` flag applies only to `component_index 0`.
|
|
191
|
+
- **Pickup/incomplete bar:** onset from `position` → a partial bar's notes land on correct pulse boundaries; no beam crosses into the next bar.
|
|
192
|
+
- **Back-to-back groups:** `"CCCC CCCC"` → adjacent self-contained groups, no `continue` spanning the boundary.
|
|
193
|
+
- **Authored split below the pulse:** 6/8 `"ab c def"` → `ab` beams, lone `c` no beam, `def` beams — an authored split subdivides inside one dotted-quarter pulse.
|
|
194
|
+
|
|
195
|
+
### Testing Strategy
|
|
196
|
+
|
|
197
|
+
Unit-test `BeamGrouper` exhaustively in isolation (data in / data out — the bulk of the correctness lives here, and it mirrors `DurationWriter`'s testable-struct seam). Table-test `Meter#beam_group_unit` separately. Integration-test the `Writer` for exact `<beam>` element placement and levels, and the ABC parse→export round-trip for flag capture and space suppression. Extend existing specs where they exist (`writer_spec.rb`, `body_lexer_spec.rb`, `parser_spec.rb`, ABC `writer_spec.rb`, `placement_spec.rb`, `meter_spec.rb`); add one new `beam_grouper_spec.rb`. Highest-value cases to pin: the 3/8-simple group, level-2 hooks, the rest/quarter break, and the `false`-flag join overriding a default boundary. Prefer building fixtures via `HeadMusic::Notation::ABC.parse` (per project memory). Maintain 90% coverage; run `bundle exec rspec` and `bundle exec rubocop -a` green before finishing.
|
|
198
|
+
|
|
199
|
+
### Risks & Open Questions
|
|
200
|
+
|
|
201
|
+
**Ordering / technical risks**
|
|
202
|
+
|
|
203
|
+
- **Component-vs-Placement granularity (the crux):** the grouper consumes the flattened Component stream, but onsets come from `Placement.position` and only the first component of a placement carries the flag. Reading position off a Component, or letting the `components_by_placement` walk and the `tied_value` chain fall out of sync, misplaces beams — pin with a tied-chain spec.
|
|
204
|
+
- **Integer arithmetic:** do onset/boundary math in **divisions** (integer, via `component.duration` and the divisions form of `beam_group_unit`), not ticks. `RhythmicValue#ticks` returns Floats; staying in divisions avoids `.round` drift and matches `whole_measure_duration` (:308-310). If tuplets are ever supported at the ABC boundary, revisit for Rational arithmetic.
|
|
205
|
+
- **`<beam>` element order** must be after `<dot>` and before `<notations>` (:349-350); misordering yields invalid MusicXML that some readers silently tolerate — assert exact line order.
|
|
206
|
+
- **Chord double-emit:** the `index.zero?` lead-slot guard is essential; a `<beam>` on a `<chord/>` member is malformed.
|
|
207
|
+
- **ABC re-lex ambiguity:** space suppression is safe only between self-delimiting tokens (notes/chords/rests); any future token type inside a beam group needs re-checking.
|
|
208
|
+
|
|
209
|
+
**Resolved decisions** (confirmed by the human, 2026-07-19)
|
|
210
|
+
|
|
211
|
+
1. **Authored beam crossing a beat/pulse boundary** — **RESOLVED: honor verbatim.** A no-space ABC run beams as one group even across beats (e.g. `cdef` in 4/4 → a single group spanning beats 1-2). ABC spacing is authoritative, matching the "explicit beams win / OSMD autoBeam OFF" purpose. The Step 6 fully-authoritative parser produces exactly this; the grouper honors `beam_break_before == false` as a join across the default boundary.
|
|
212
|
+
2. **Partial-beam hooks in v1** — **RESOLVED: yes, include hooks.** Dotted-eighth + sixteenth emits an L2 backward hook. The grouper's `type` vocabulary includes `forward hook`/`backward hook` from the start.
|
|
213
|
+
3. **Flag name/polarity** — **RESOLVED: `beam_break_before`**, tri-state (`nil` = meter default, `true` = force break, `false` = force join), a `Bar`-style writable `attr_accessor` on `Placement`.
|
|
214
|
+
4. **Export symmetry** — **RESOLVED: asymmetric, like ties.** ABC export only suppresses/emits spaces where an authored flag is present; nil-flag (programmatic) compositions keep today's every-placement spacing. Pin with a round-trip spec.
|
|
215
|
+
5. **Authored beam across a barline** — **RESOLVED: silent break at the barline.** The barline terminates any beam (MusicXML forbids cross-barline beams; `ensure_notes_within_barlines` enforces it). This intentionally differs from the tie feature's explicit `ParseError` across a barline.
|
|
216
|
+
|
|
217
|
+
## Learnings
|
|
218
|
+
|
|
219
|
+
### What went well
|
|
220
|
+
|
|
221
|
+
- The core design from planning — **default beaming = no model state (computed at render), authored override = one tri-state `Placement` flag** — held through implementation without rework. Deliberately **not** modeling beaming inside `RhythmicValue` (unlike ties) was correct, since beaming spans notes rather than living within one.
|
|
222
|
+
- Making `BeamGrouper` a **pure data-in/data-out helper** put the hardest logic (segmentation, multi-level beams, forward/backward hooks) behind an exhaustive unit spec, isolated from the writer. Most correctness lived there, and testing it in isolation paid off.
|
|
223
|
+
- Implementing one step at a time with a review + spec run between each caught the major design tension the moment it appeared, not at the end.
|
|
224
|
+
|
|
225
|
+
### What was surprising
|
|
226
|
+
|
|
227
|
+
- The **"honor verbatim" decision has a non-obvious consequence**: ABC adjacency is always authoritative (implies a join), so pure meter-**default** beaming is only observable on programmatically-built compositions. This surfaced mid-implementation as 5 failing writer tests and forced splitting the tests into a default (programmatic) bucket and an authored (ABC-spacing) bucket. Lesson: when an input format is fully authoritative, you cannot exercise the *default* path through that format.
|
|
228
|
+
- A **"confirmed" assumption was wrong at an edge**: "simple → group by beat" would make 3/8 emit no beams at all. Planning caught it and introduced `Meter#beam_group_unit` distinct from `beat_value`. Validate confirmed rules against odd cases (3/8) during planning, not after.
|
|
229
|
+
- **Lexer/parser signals compose**: a rest is a "beamable predecessor," so `z CC` emits a beam-break and the post-rest note gets `true` rather than `nil`. Harmless (a rest forces a break anyway), but a reminder these signals interact.
|
|
230
|
+
|
|
231
|
+
### What to do differently
|
|
232
|
+
|
|
233
|
+
- **Front-load "how is this represented in the model?"** — that question (asked explicitly during the story) is the crux, and answering it early shaped the whole plan. Make it step one.
|
|
234
|
+
- The **asymmetric-meter crash (5/16)** slipped past because "don't crash out of scope" was an instruction with no test; code review caught it. When a method becomes public, add a defensive test for out-of-scope inputs.
|
|
235
|
+
- **Visual verification in a real notation reader (MuseScore/Finale) is still outstanding** — the suite asserts XML structure, not rendered appearance. Worth a one-time eyeball of the 6/8 case and hook direction.
|
|
@@ -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).
|
data/user-stories/index.html
CHANGED
|
@@ -277,6 +277,14 @@
|
|
|
277
277
|
],
|
|
278
278
|
"active": [],
|
|
279
279
|
"done": [
|
|
280
|
+
{
|
|
281
|
+
"title": "ABC Notation Beaming",
|
|
282
|
+
"path": "done/abc-notation-beaming.md"
|
|
283
|
+
},
|
|
284
|
+
{
|
|
285
|
+
"title": "ABC Tie Input",
|
|
286
|
+
"path": "done/abc-tie-input.md"
|
|
287
|
+
},
|
|
280
288
|
{
|
|
281
289
|
"title": "MusicXML Chord Rendering",
|
|
282
290
|
"path": "done/musicxml-chord-rendering.md"
|
|
@@ -391,15 +399,15 @@
|
|
|
391
399
|
},
|
|
392
400
|
{
|
|
393
401
|
"title": "Untitled",
|
|
394
|
-
"path": "done/
|
|
402
|
+
"path": "done/expand-playing-techniques.md"
|
|
395
403
|
},
|
|
396
404
|
{
|
|
397
405
|
"title": "Untitled",
|
|
398
|
-
"path": "done/
|
|
406
|
+
"path": "done/instrument-variant.md"
|
|
399
407
|
},
|
|
400
408
|
{
|
|
401
409
|
"title": "Untitled",
|
|
402
|
-
"path": "done/
|
|
410
|
+
"path": "done/superclass-for-note.md"
|
|
403
411
|
}
|
|
404
412
|
]
|
|
405
413
|
};
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: head_music
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 17.
|
|
4
|
+
version: 17.3.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-20 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: activesupport
|
|
@@ -247,6 +247,7 @@ files:
|
|
|
247
247
|
- lib/head_music/notation/abc/writer.rb
|
|
248
248
|
- lib/head_music/notation/instrument_notation.rb
|
|
249
249
|
- lib/head_music/notation/music_xml.rb
|
|
250
|
+
- lib/head_music/notation/music_xml/beam_grouper.rb
|
|
250
251
|
- lib/head_music/notation/music_xml/clef_selector.rb
|
|
251
252
|
- lib/head_music/notation/music_xml/divisions.rb
|
|
252
253
|
- lib/head_music/notation/music_xml/duration_writer.rb
|
|
@@ -421,8 +422,10 @@ files:
|
|
|
421
422
|
- user-stories/backlog/sixteenth-century-style.md
|
|
422
423
|
- user-stories/backlog/split-counterpoint-species-by-author.md
|
|
423
424
|
- user-stories/done/abc-chord-input.md
|
|
425
|
+
- user-stories/done/abc-notation-beaming.md
|
|
424
426
|
- user-stories/done/abc-notation-export.md
|
|
425
427
|
- user-stories/done/abc-notation-interpreter.md
|
|
428
|
+
- user-stories/done/abc-tie-input.md
|
|
426
429
|
- user-stories/done/chord-placement-model.md
|
|
427
430
|
- user-stories/done/composition-serialization.md
|
|
428
431
|
- user-stories/done/configurable-large-leap-recovery.md
|