head_music 17.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: afa3afecdb9f3baef8bc34bb645805cd7defbd206e388a2a156d1f4f80516656
4
- data.tar.gz: b858239c696ef22b759d4a96f033588cf871b83254538c991c8fdff67565a179
3
+ metadata.gz: d5c0dadcdb3b7bba85e7de810d0d52d960e6bf2b9386b62db9ec201336e084bb
4
+ data.tar.gz: 33c3b42e6b07ed88649b6fb0c7b42476c9d45edbc18ef0cc458ff8978b876c4c
5
5
  SHA512:
6
- metadata.gz: b3c54b4663df784f5382fd992500df795cf69e985501596d592bf7ffe5c0a7887ecc81ad36e76cbebf9a0e30597607ef2db629c3e8946e128043f9f5aa012976
7
- data.tar.gz: d5f6c002b3f4522dcdf2f3741ecacd933ea3bb2c33ee7a438ca8a649eb57b2e8d68053a6a8a0a616f17d1e3087b733dccf1d384a0ae6c27084854474c528deea
6
+ metadata.gz: eb3e9c1f84f2efbf87947a2037b9804cf9e16247be5c180509cb06a9c3c66229513b8cdcc2e10fa1d8f2f7ed952b91de99375e5939de33665aec909331f8f115
7
+ data.tar.gz: f871c26bf5da1052474f5317d72788df760f994f3264dbebc6ad35e91b91124088378524b46f4fc50721f6b9442453978018f6c012dd40f55926a66dd516539c
data/CHANGELOG.md CHANGED
@@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [17.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
+
10
17
  ## [17.2.0] - 2026-07-19
11
18
 
12
19
  ### Added
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- head_music (17.2.0)
4
+ head_music (17.3.0)
5
5
  activesupport (>= 7.0, < 10)
6
6
  humanize (~> 2.0)
7
7
  i18n (~> 1.8)
@@ -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
- next if scanner.skip(/[ \t]+/)
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
 
@@ -39,8 +39,8 @@ module HeadMusic::Notation::ABC
39
39
  # so bar-line accidental resets cannot corrupt them. `tied_prefix`,
40
40
  # when present, is the already-built rhythmic value of everything
41
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)
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
44
  super
45
45
  end
46
46
  end
@@ -50,7 +50,8 @@ module HeadMusic::Notation::ABC
50
50
  class VoiceState
51
51
  attr_reader :voice, :pitch_builder
52
52
  attr_accessor :pending_note, :awaiting_scale, :broken_line,
53
- :active_passes, :volta_start_bar, :tie_open, :tie_line
53
+ :active_passes, :volta_start_bar, :tie_open, :tie_line,
54
+ :beam_break_pending, :beam_last_was_note
54
55
 
55
56
  def initialize(voice, pitch_builder)
56
57
  @voice = voice
@@ -159,6 +160,7 @@ module HeadMusic::Notation::ABC
159
160
  when :bar_line then handle_bar_line(token)
160
161
  when :volta then handle_volta(token)
161
162
  when :voice_change then handle_voice_change(token)
163
+ when :beam_break then handle_beam_break(token)
162
164
  end
163
165
  end
164
166
 
@@ -217,7 +219,20 @@ module HeadMusic::Notation::ABC
217
219
  return tie_onto_pending(state, pitches, length, scale) if state.tie_open
218
220
 
219
221
  flush_pending_note(state)
220
- state.pending_note = PendingNote.new(pitches: pitches, length: length, scale: scale)
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
221
236
  end
222
237
 
223
238
  # A tie (`-`) after a note or chord fuses it to the next note of the
@@ -244,7 +259,8 @@ module HeadMusic::Notation::ABC
244
259
  state.tie_open = false
245
260
  state.tie_line = nil
246
261
  state.pending_note = PendingNote.new(
247
- pitches: pitches, length: length, scale: scale, tied_prefix: prefix
262
+ pitches: pitches, length: length, scale: scale, tied_prefix: prefix,
263
+ beam_break: pending.beam_break
248
264
  )
249
265
  end
250
266
 
@@ -262,9 +278,17 @@ module HeadMusic::Notation::ABC
262
278
  state = current_state
263
279
  reject_open_tie(state, token.line, "A tie must be followed by a note")
264
280
  flush_pending_note(state)
281
+ reset_beam_adjacency(state)
265
282
  place(state, token.length, nil)
266
283
  end
267
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
+
268
292
  # A tie left open by a non-note terminator can never close, so each
269
293
  # terminator rejects it. A bar line gets its own message: an author
270
294
  # tie across a barline is a real, but not-yet-supported, request.
@@ -294,6 +318,7 @@ module HeadMusic::Notation::ABC
294
318
  state = current_state
295
319
  reject_open_tie(state, token.line, "Ties across barlines are not yet supported")
296
320
  flush_pending_note(state)
321
+ reset_beam_adjacency(state)
297
322
  tag_completed_bar(state)
298
323
  apply_repeat_flags(state, token.style)
299
324
  clear_passes_if_over(state, token.style)
@@ -308,6 +333,7 @@ module HeadMusic::Notation::ABC
308
333
  state = current_state
309
334
  reject_open_tie(state, token.line, "A tie must be followed by a note")
310
335
  flush_pending_note(state)
336
+ reset_beam_adjacency(state)
311
337
  state.active_passes = token.passes
312
338
  state.volta_start_bar = state.entered_bar_number
313
339
  end
@@ -318,6 +344,7 @@ module HeadMusic::Notation::ABC
318
344
  ensure_not_awaiting_note(token, state: @current_state)
319
345
  reject_open_tie(@current_state, token.line, "A tie must be followed by a note")
320
346
  flush_pending_note(@current_state)
347
+ reset_beam_adjacency(@current_state)
321
348
  end
322
349
  @current_state = voice_state(token.voice_id)
323
350
  end
@@ -345,7 +372,8 @@ module HeadMusic::Notation::ABC
345
372
  return unless pending
346
373
 
347
374
  state.pending_note = nil
348
- state.voice.place(state.voice.next_position, pending_rhythmic_value(pending), pending.pitches)
375
+ placement = state.voice.place(state.voice.next_position, pending_rhythmic_value(pending), pending.pitches)
376
+ placement.beam_break_before = pending.beam_break
349
377
  end
350
378
 
351
379
  # A pending note's own value, with any tied prefix appended ahead of
@@ -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) }.join(" ")
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(placement, component, pitch: pitch, chord: index.positive?)
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
@@ -1,3 +1,3 @@
1
1
  module HeadMusic
2
- VERSION = "17.2.0"
2
+ VERSION = "17.3.0"
3
3
  end
@@ -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.
@@ -277,6 +277,10 @@
277
277
  ],
278
278
  "active": [],
279
279
  "done": [
280
+ {
281
+ "title": "ABC Notation Beaming",
282
+ "path": "done/abc-notation-beaming.md"
283
+ },
280
284
  {
281
285
  "title": "ABC Tie Input",
282
286
  "path": "done/abc-tie-input.md"
@@ -395,15 +399,15 @@
395
399
  },
396
400
  {
397
401
  "title": "Untitled",
398
- "path": "done/superclass-for-note.md"
402
+ "path": "done/expand-playing-techniques.md"
399
403
  },
400
404
  {
401
405
  "title": "Untitled",
402
- "path": "done/expand-playing-techniques.md"
406
+ "path": "done/instrument-variant.md"
403
407
  },
404
408
  {
405
409
  "title": "Untitled",
406
- "path": "done/instrument-variant.md"
410
+ "path": "done/superclass-for-note.md"
407
411
  }
408
412
  ]
409
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.2.0
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-19 00:00:00.000000000 Z
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,6 +422,7 @@ 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
426
428
  - user-stories/done/abc-tie-input.md