head_music 17.2.0 → 17.4.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.
Files changed (113) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +15 -0
  3. data/Gemfile.lock +1 -1
  4. data/lib/head_music/analysis/chord_analysis.rb +123 -0
  5. data/lib/head_music/analysis/circle.rb +11 -3
  6. data/lib/head_music/analysis/diatonic_interval.rb +14 -11
  7. data/lib/head_music/analysis/dyad/chord_implication.rb +101 -0
  8. data/lib/head_music/analysis/dyad.rb +24 -104
  9. data/lib/head_music/analysis/interval_cycle.rb +9 -3
  10. data/lib/head_music/analysis/pitch_collection.rb +33 -107
  11. data/lib/head_music/analysis/sonority.rb +11 -21
  12. data/lib/head_music/content/composition/hash_deserializer.rb +110 -0
  13. data/lib/head_music/content/composition/schema_values.rb +127 -0
  14. data/lib/head_music/content/composition.rb +0 -215
  15. data/lib/head_music/content/placement.rb +11 -42
  16. data/lib/head_music/content/sound_resolver.rb +54 -0
  17. data/lib/head_music/content/voice/melodic_line.rb +32 -0
  18. data/lib/head_music/content/voice/melodic_note_pair.rb +33 -0
  19. data/lib/head_music/content/voice.rb +7 -58
  20. data/lib/head_music/instruments/alternate_tuning.rb +4 -6
  21. data/lib/head_music/instruments/catalog_lookup.rb +18 -0
  22. data/lib/head_music/instruments/instrument.rb +17 -117
  23. data/lib/head_music/instruments/instrument_catalog.rb +48 -0
  24. data/lib/head_music/instruments/instrument_configuration.rb +4 -6
  25. data/lib/head_music/instruments/instrument_family.rb +3 -8
  26. data/lib/head_music/instruments/instrument_name.rb +49 -0
  27. data/lib/head_music/instruments/score_order.rb +52 -42
  28. data/lib/head_music/instruments/staff_profile.rb +71 -0
  29. data/lib/head_music/instruments/stringing.rb +4 -6
  30. data/lib/head_music/instruments/stringing_course.rb +4 -6
  31. data/lib/head_music/instruments/variant.rb +4 -6
  32. data/lib/head_music/named/locale.rb +32 -0
  33. data/lib/head_music/named/localized_name.rb +15 -0
  34. data/lib/head_music/named.rb +1 -45
  35. data/lib/head_music/notation/abc/body_lexer.rb +65 -24
  36. data/lib/head_music/notation/abc/duration_resolver.rb +11 -1
  37. data/lib/head_music/notation/abc/duration_writer.rb +1 -8
  38. data/lib/head_music/notation/abc/header.rb +27 -16
  39. data/lib/head_music/notation/abc/parser.rb +48 -159
  40. data/lib/head_music/notation/abc/preflight.rb +48 -0
  41. data/lib/head_music/notation/abc/voice_state.rb +161 -0
  42. data/lib/head_music/notation/abc/writer.rb +47 -16
  43. data/lib/head_music/notation/dotted_duration.rb +19 -0
  44. data/lib/head_music/notation/instrument_notation.rb +5 -7
  45. data/lib/head_music/notation/music_xml/beam_grouper.rb +108 -0
  46. data/lib/head_music/notation/music_xml/duration_writer.rb +3 -8
  47. data/lib/head_music/notation/music_xml/preflight.rb +90 -0
  48. data/lib/head_music/notation/music_xml/render_plan.rb +161 -0
  49. data/lib/head_music/notation/music_xml/writer.rb +31 -146
  50. data/lib/head_music/notation/placement_validation.rb +19 -0
  51. data/lib/head_music/rudiment/enharmonic_equivalence.rb +33 -0
  52. data/lib/head_music/rudiment/key.rb +8 -36
  53. data/lib/head_music/rudiment/key_signature/enharmonic_equivalence.rb +21 -15
  54. data/lib/head_music/rudiment/key_signature.rb +18 -19
  55. data/lib/head_music/rudiment/meter.rb +39 -6
  56. data/lib/head_music/rudiment/mode.rb +33 -65
  57. data/lib/head_music/rudiment/note.rb +18 -21
  58. data/lib/head_music/rudiment/pitch/enharmonic_equivalence.rb +4 -15
  59. data/lib/head_music/rudiment/pitch/natural_step.rb +36 -0
  60. data/lib/head_music/rudiment/pitch/parser.rb +16 -5
  61. data/lib/head_music/rudiment/pitch.rb +11 -33
  62. data/lib/head_music/rudiment/qualified_diatonic_context.rb +61 -0
  63. data/lib/head_music/rudiment/rhythmic_unit/parser.rb +16 -12
  64. data/lib/head_music/rudiment/rhythmic_unit.rb +21 -37
  65. data/lib/head_music/rudiment/rhythmic_value/parser.rb +53 -46
  66. data/lib/head_music/rudiment/spelling/enharmonic_equivalence.rb +13 -0
  67. data/lib/head_music/rudiment/spelling.rb +0 -25
  68. data/lib/head_music/rudiment/tuning/just_intonation.rb +2 -4
  69. data/lib/head_music/rudiment/tuning/meantone.rb +2 -4
  70. data/lib/head_music/rudiment/tuning/pythagorean.rb +2 -4
  71. data/lib/head_music/rudiment/tuning/tonal_centered.rb +10 -0
  72. data/lib/head_music/rudiment/unpitched_note.rb +3 -5
  73. data/lib/head_music/style/annotation/configured.rb +35 -0
  74. data/lib/head_music/style/annotation.rb +32 -44
  75. data/lib/head_music/style/guidelines/allowed_rhythmic_values_for_fifth_species.rb +0 -10
  76. data/lib/head_music/style/guidelines/contoured.rb +65 -53
  77. data/lib/head_music/style/guidelines/dissonance_figure_detection.rb +55 -16
  78. data/lib/head_music/style/guidelines/florid_dissonance_treatment.rb +0 -37
  79. data/lib/head_music/style/guidelines/large_leaps.rb +10 -2
  80. data/lib/head_music/style/guidelines/minimum_melodic_intervals.rb +3 -25
  81. data/lib/head_music/style/guidelines/minimum_notes.rb +3 -25
  82. data/lib/head_music/style/guidelines/minimum_threshold.rb +38 -0
  83. data/lib/head_music/style/guidelines/no_parallel_perfect.rb +31 -0
  84. data/lib/head_music/style/guidelines/no_parallel_perfect_on_downbeats.rb +3 -17
  85. data/lib/head_music/style/guidelines/no_parallel_perfect_with_syncopation.rb +3 -17
  86. data/lib/head_music/style/guidelines/prepare_octave_leaps.rb +10 -7
  87. data/lib/head_music/style/guidelines/singable_intervals.rb +22 -8
  88. data/lib/head_music/style/guidelines/suspension_treatment.rb +30 -12
  89. data/lib/head_music/style/guidelines/third_species_dissonance_treatment.rb +0 -7
  90. data/lib/head_music/style/guidelines/triple_meter_dissonance_treatment.rb +0 -12
  91. data/lib/head_music/style/guidelines/weak_beat_dissonance_treatment.rb +2 -42
  92. data/lib/head_music/style/guides/fifth_species_melody.rb +3 -13
  93. data/lib/head_music/style/guides/fourth_species_melody.rb +3 -13
  94. data/lib/head_music/style/guides/second_species_harmony.rb +2 -5
  95. data/lib/head_music/style/guides/second_species_melody.rb +2 -12
  96. data/lib/head_music/style/guides/species_harmony.rb +14 -0
  97. data/lib/head_music/style/guides/species_melody.rb +21 -0
  98. data/lib/head_music/style/guides/third_species_harmony.rb +2 -5
  99. data/lib/head_music/style/guides/third_species_melody.rb +3 -13
  100. data/lib/head_music/style/guides/third_species_triple_meter_harmony.rb +2 -5
  101. data/lib/head_music/style/guides/third_species_triple_meter_melody.rb +2 -12
  102. data/lib/head_music/time/conductor.rb +19 -125
  103. data/lib/head_music/time/musical_position.rb +26 -26
  104. data/lib/head_music/time/musical_time_converter.rb +104 -0
  105. data/lib/head_music/time/smpte_converter.rb +51 -0
  106. data/lib/head_music/time/smpte_timecode.rb +25 -23
  107. data/lib/head_music/time.rb +2 -0
  108. data/lib/head_music/value_equality.rb +37 -0
  109. data/lib/head_music/version.rb +1 -1
  110. data/lib/head_music.rb +25 -1
  111. data/user-stories/done/abc-notation-beaming.md +235 -0
  112. data/user-stories/index.html +7 -3
  113. metadata +34 -2
@@ -34,85 +34,18 @@ module HeadMusic::Notation::ABC
34
34
 
35
35
  attr_reader :header, :duration_resolver
36
36
 
37
- # A note or chord whose placement is deferred until we know whether
38
- # a broken-rhythm mark follows it. The pitches are computed eagerly
39
- # so bar-line accidental resets cannot corrupt them. `tied_prefix`,
40
- # when present, is the already-built rhythmic value of everything
41
- # tied ahead of this note; its own value is appended at flush time.
42
- PendingNote = Data.define(:pitches, :length, :scale, :tied_prefix) do
43
- def initialize(pitches:, length:, scale:, tied_prefix: nil)
44
- super
45
- end
46
- end
47
-
48
- # Per-voice interpretation state. Accidentals, the deferred note,
49
- # and volta tracking are all independent between voices.
50
- class VoiceState
51
- attr_reader :voice, :pitch_builder
52
- attr_accessor :pending_note, :awaiting_scale, :broken_line,
53
- :active_passes, :volta_start_bar, :tie_open, :tie_line
54
-
55
- def initialize(voice, pitch_builder)
56
- @voice = voice
57
- @pitch_builder = pitch_builder
58
- end
59
-
60
- def completed_bar_number
61
- voice.last_placement&.position&.bar_number
62
- end
63
-
64
- def entered_bar_number
65
- voice.next_position.bar_number
66
- end
67
- end
37
+ # Per-voice interpretation state and note-assembly live in VoiceState.
68
38
 
69
39
  def build_composition
70
- ensure_input_present
40
+ Preflight.ensure_input_present(@abc_string)
71
41
  @header = Header.new(@abc_string, start_line: @start_line)
72
- reject_content_after_tune
42
+ Preflight.reject_content_after_tune(header)
73
43
  tokens = BodyLexer.new(header.body, start_line: header.body_start_line).tokens
74
- reject_unsupported_tokens(tokens)
44
+ Preflight.reject_unsupported_tokens(tokens)
75
45
  @duration_resolver = DurationResolver.new(header.unit_note_length)
76
46
  interpret(tokens)
77
47
  end
78
48
 
79
- def ensure_input_present
80
- return unless @abc_string.to_s.strip.empty?
81
-
82
- raise ParseError, "ABC input is blank"
83
- end
84
-
85
- # The lexer treats a blank line as the end of the tune, so anything
86
- # after it would be silently dropped — most likely another tune.
87
- def reject_content_after_tune
88
- lines = header.body.lines
89
- blank_index = lines.find_index { |line| line.strip.empty? }
90
- return unless blank_index
91
-
92
- extra_lines = lines[(blank_index + 1)..]
93
- extra_index = extra_lines.find_index do |line|
94
- stripped = line.strip
95
- !stripped.empty? && !stripped.start_with?("%")
96
- end
97
- return unless extra_index
98
-
99
- raise ParseError.new(
100
- "Content after the tune body; parse a book of tunes with ABC.parse_book",
101
- line_number: header.body_start_line + blank_index + 1 + extra_index,
102
- snippet: extra_lines[extra_index].strip[0, BodyLexer::SNIPPET_LENGTH]
103
- )
104
- end
105
-
106
- def reject_unsupported_tokens(tokens)
107
- token = tokens.find { |candidate| candidate.type == :unsupported }
108
- return unless token
109
-
110
- raise UnsupportedFeatureError.new(
111
- "Unsupported ABC feature #{token.lexeme.inspect}",
112
- line_number: token.line, snippet: token.lexeme
113
- )
114
- end
115
-
116
49
  def interpret(tokens)
117
50
  @building = HeadMusic::Content::Composition.new(
118
51
  name: header.title,
@@ -140,7 +73,8 @@ module HeadMusic::Notation::ABC
140
73
  def voice_state(role)
141
74
  @voice_states[role] ||= VoiceState.new(
142
75
  @building.add_voice(role: role),
143
- PitchBuilder.new(header.key_signature)
76
+ PitchBuilder.new(header.key_signature),
77
+ duration_resolver
144
78
  )
145
79
  end
146
80
 
@@ -159,13 +93,14 @@ module HeadMusic::Notation::ABC
159
93
  when :bar_line then handle_bar_line(token)
160
94
  when :volta then handle_volta(token)
161
95
  when :voice_change then handle_voice_change(token)
96
+ when :beam_break then handle_beam_break(token)
162
97
  end
163
98
  end
164
99
 
165
100
  def handle_note(token)
166
101
  state = current_state
167
102
  pitch = state.pitch_builder.pitch(token.letter, token.octave_marks, token.accidental)
168
- defer_placement(state, [pitch], token.length)
103
+ state.defer_placement([pitch], token.length)
169
104
  end
170
105
 
171
106
  # Chord pitches resolve in bracket order, so an explicit accidental
@@ -177,7 +112,7 @@ module HeadMusic::Notation::ABC
177
112
  end
178
113
  ensure_unique_chord_pitches(pitches, token)
179
114
  inner_length = uniform_chord_length(token)
180
- defer_placement(state, pitches, token.length, inner_length)
115
+ state.defer_placement(pitches, token.length, inner_length)
181
116
  end
182
117
 
183
118
  def ensure_unique_chord_pitches(pitches, token)
@@ -211,13 +146,10 @@ module HeadMusic::Notation::ABC
211
146
  "[#{inner}]"
212
147
  end
213
148
 
214
- def defer_placement(state, pitches, length, inner_scale = Rational(1))
215
- scale = (state.awaiting_scale || Rational(1)) * inner_scale
216
- state.awaiting_scale = nil
217
- return tie_onto_pending(state, pitches, length, scale) if state.tie_open
218
-
219
- flush_pending_note(state)
220
- state.pending_note = PendingNote.new(pitches: pitches, length: length, scale: scale)
149
+ # The lexer only emits :beam_break after a music token, so a voice
150
+ # state already exists; the flag is consumed by the next deferred note.
151
+ def handle_beam_break(_token)
152
+ current_state.mark_beam_break
221
153
  end
222
154
 
223
155
  # A tie (`-`) after a note or chord fuses it to the next note of the
@@ -225,90 +157,73 @@ module HeadMusic::Notation::ABC
225
157
  # its right note arrives.
226
158
  def handle_tie(token)
227
159
  state = current_state
160
+ line = token.line
228
161
  if state.awaiting_scale || state.pending_note.nil?
229
- raise ParseError.new(
230
- "A tie must follow a note", line_number: token.line, snippet: "-"
231
- )
162
+ raise ParseError.new("A tie must follow a note", line_number: line, snippet: "-")
232
163
  end
233
- state.tie_open = true
234
- state.tie_line = token.line
235
- end
236
-
237
- # Closes an open tie: the pending note becomes the new note's tied
238
- # prefix, so the pair (and any longer chain) resolves to a single
239
- # placement whose rhythmic value carries the author's chosen split.
240
- def tie_onto_pending(state, pitches, length, scale)
241
- pending = state.pending_note
242
- ensure_tie_pitches_match(state, pending, pitches)
243
- prefix = pending_rhythmic_value(pending)
244
- state.tie_open = false
245
- state.tie_line = nil
246
- state.pending_note = PendingNote.new(
247
- pitches: pitches, length: length, scale: scale, tied_prefix: prefix
248
- )
249
- end
250
-
251
- def ensure_tie_pitches_match(state, pending, pitches)
252
- return if pending.pitches.sort == pitches.sort
253
-
254
- raise ParseError.new(
255
- "A tie must connect two notes of the same pitch",
256
- line_number: state.tie_line, snippet: "-"
257
- )
164
+ state.open_tie(line)
258
165
  end
259
166
 
260
167
  def handle_rest(token)
261
168
  ensure_not_awaiting_note(token)
262
169
  state = current_state
263
170
  reject_open_tie(state, token.line, "A tie must be followed by a note")
264
- flush_pending_note(state)
265
- place(state, token.length, nil)
171
+ state.flush_pending_note
172
+ state.reset_beam_adjacency
173
+ state.place(token.length, nil)
266
174
  end
267
175
 
268
176
  # A tie left open by a non-note terminator can never close, so each
269
177
  # terminator rejects it. A bar line gets its own message: an author
270
178
  # tie across a barline is a real, but not-yet-supported, request.
271
179
  def reject_open_tie(state, line, message)
272
- return unless state&.tie_open
180
+ return unless state&.tie_open?
273
181
 
274
182
  raise ParseError.new(message, line_number: line || state.tie_line, snippet: "-")
275
183
  end
276
184
 
277
185
  def handle_broken_rhythm(token)
278
186
  state = current_state
279
- reject_open_tie(state, token.line, "A tie must be followed by a note")
280
- if state.awaiting_scale || state.pending_note.nil?
187
+ line = token.line
188
+ direction = token.direction
189
+ reject_open_tie(state, line, "A tie must be followed by a note")
190
+ pending = state.pending_note
191
+ if state.awaiting_scale || pending.nil?
281
192
  raise ParseError.new(
282
193
  "Broken rhythm must appear between two notes",
283
- line_number: token.line, snippet: token.direction.to_s
194
+ line_number: line, snippet: direction.to_s
284
195
  )
285
196
  end
286
- left_scale, right_scale = BROKEN_RHYTHM_SCALES.fetch(token.direction)
287
- state.pending_note = state.pending_note.with(scale: state.pending_note.scale * left_scale)
197
+ left_scale, right_scale = BROKEN_RHYTHM_SCALES.fetch(direction)
198
+ state.pending_note = pending.with(scale: pending.scale * left_scale)
288
199
  state.awaiting_scale = right_scale
289
- state.broken_line = token.line
200
+ state.broken_line = line
290
201
  end
291
202
 
292
203
  def handle_bar_line(token)
293
204
  ensure_not_awaiting_note(token)
294
205
  state = current_state
206
+ style = token.style
295
207
  reject_open_tie(state, token.line, "Ties across barlines are not yet supported")
296
- flush_pending_note(state)
208
+ state.flush_pending_note
209
+ state.reset_beam_adjacency
297
210
  tag_completed_bar(state)
298
- apply_repeat_flags(state, token.style)
299
- clear_passes_if_over(state, token.style)
211
+ apply_repeat_flags(state, style)
212
+ clear_passes_if_over(state, style)
300
213
  state.pitch_builder.start_new_bar
301
214
  end
302
215
 
303
216
  def handle_volta(token)
304
217
  ensure_not_awaiting_note(token)
305
- if token.passes.empty?
306
- raise ParseError.new("Volta has no passes", line_number: token.line)
307
- end
218
+ passes = token.passes
219
+ line = token.line
220
+ raise ParseError.new("Volta has no passes", line_number: line) if passes.empty?
221
+
308
222
  state = current_state
309
- reject_open_tie(state, token.line, "A tie must be followed by a note")
310
- flush_pending_note(state)
311
- state.active_passes = token.passes
223
+ reject_open_tie(state, line, "A tie must be followed by a note")
224
+ state.flush_pending_note
225
+ state.reset_beam_adjacency
226
+ state.active_passes = passes
312
227
  state.volta_start_bar = state.entered_bar_number
313
228
  end
314
229
 
@@ -317,7 +232,8 @@ module HeadMusic::Notation::ABC
317
232
  if @current_state
318
233
  ensure_not_awaiting_note(token, state: @current_state)
319
234
  reject_open_tie(@current_state, token.line, "A tie must be followed by a note")
320
- flush_pending_note(@current_state)
235
+ @current_state.flush_pending_note
236
+ @current_state.reset_beam_adjacency
321
237
  end
322
238
  @current_state = voice_state(token.voice_id)
323
239
  end
@@ -326,7 +242,7 @@ module HeadMusic::Notation::ABC
326
242
  @voice_states.each_value do |state|
327
243
  ensure_not_awaiting_note(nil, state: state)
328
244
  reject_open_tie(state, nil, "A tie must be followed by a note")
329
- flush_pending_note(state)
245
+ state.flush_pending_note
330
246
  tag_completed_bar(state)
331
247
  end
332
248
  end
@@ -340,34 +256,6 @@ module HeadMusic::Notation::ABC
340
256
  )
341
257
  end
342
258
 
343
- def flush_pending_note(state)
344
- pending = state.pending_note
345
- return unless pending
346
-
347
- state.pending_note = nil
348
- state.voice.place(state.voice.next_position, pending_rhythmic_value(pending), pending.pitches)
349
- end
350
-
351
- # A pending note's own value, with any tied prefix appended ahead of
352
- # it so the whole tie chain renders as one sounding note.
353
- def pending_rhythmic_value(pending)
354
- own = duration_resolver.rhythmic_value(pending.length, scale: pending.scale)
355
- pending.tied_prefix ? append_tied(pending.tied_prefix, own) : own
356
- end
357
-
358
- # Attaches `tail` at the deep end of `head`'s tied chain, rebuilding
359
- # each link (RhythmicValue exposes no setter) so a chain like
360
- # "half tied to eighth" gains a further "tied to quarter".
361
- def append_tied(head, tail)
362
- inner = head.tied_value ? append_tied(head.tied_value, tail) : tail
363
- HeadMusic::Rudiment::RhythmicValue.new(head.unit, dots: head.dots, tied_value: inner)
364
- end
365
-
366
- def place(state, length, pitches, scale: Rational(1))
367
- rhythmic_value = duration_resolver.rhythmic_value(length, scale: scale)
368
- state.voice.place(state.voice.next_position, rhythmic_value, pitches)
369
- end
370
-
371
259
  def apply_repeat_flags(state, style)
372
260
  if REPEAT_ENDING_STYLES.include?(style)
373
261
  completed = state.completed_bar_number
@@ -381,12 +269,13 @@ module HeadMusic::Notation::ABC
381
269
  # A volta covers every bar from its opening bracket through the bar
382
270
  # line that ends it, so each completed bar in that span gets tagged.
383
271
  def tag_completed_bar(state)
384
- return unless state.active_passes
272
+ passes = state.active_passes
273
+ return unless passes
385
274
 
386
275
  completed = state.completed_bar_number
387
276
  return unless completed && completed >= state.volta_start_bar
388
277
 
389
- bar(completed).plays_on_passes = state.active_passes
278
+ bar(completed).plays_on_passes = passes
390
279
  end
391
280
 
392
281
  def clear_passes_if_over(state, style)
@@ -0,0 +1,48 @@
1
+ # A namespace for ABC-notation parsing helpers
2
+ module HeadMusic::Notation::ABC
3
+ # Boundary checks the Parser runs before it interprets a tune, so a caller
4
+ # never receives a reference to a partially built composition. Each check
5
+ # raises on the first problem it finds and is otherwise a no-op. They run at
6
+ # distinct points of construction — blank input before the header is read,
7
+ # trailing content once the header exists, unsupported tokens after lexing —
8
+ # so each takes exactly the input it inspects rather than sharing state.
9
+ class Preflight
10
+ def self.ensure_input_present(abc_string)
11
+ return unless abc_string.to_s.strip.empty?
12
+
13
+ raise ParseError, "ABC input is blank"
14
+ end
15
+
16
+ # The lexer treats a blank line as the end of the tune, so anything
17
+ # after it would be silently dropped — most likely another tune.
18
+ def self.reject_content_after_tune(header)
19
+ lines = header.body.lines
20
+ blank_index = lines.find_index { |line| line.strip.empty? }
21
+ return unless blank_index
22
+
23
+ extra_lines = lines[(blank_index + 1)..]
24
+ extra_index = extra_lines.find_index do |line|
25
+ stripped = line.strip
26
+ !stripped.empty? && !stripped.start_with?("%")
27
+ end
28
+ return unless extra_index
29
+
30
+ raise ParseError.new(
31
+ "Content after the tune body; parse a book of tunes with ABC.parse_book",
32
+ line_number: header.body_start_line + blank_index + 1 + extra_index,
33
+ snippet: extra_lines[extra_index].strip[0, BodyLexer::SNIPPET_LENGTH]
34
+ )
35
+ end
36
+
37
+ def self.reject_unsupported_tokens(tokens)
38
+ token = tokens.find { |candidate| candidate.type == :unsupported }
39
+ return unless token
40
+
41
+ lexeme = token.lexeme
42
+ raise UnsupportedFeatureError.new(
43
+ "Unsupported ABC feature #{lexeme.inspect}",
44
+ line_number: token.line, snippet: lexeme
45
+ )
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,161 @@
1
+ # A namespace for ABC-notation parsing helpers
2
+ module HeadMusic::Notation::ABC
3
+ # Per-voice interpretation state and the note-assembly it drives. Accidentals,
4
+ # the deferred note, ties, and volta tracking are all independent between
5
+ # voices, so each voice owns one of these. Beam adjacency and tie bookkeeping
6
+ # are exposed as transitions rather than raw setters, and the parser hands
7
+ # notes and chords here to be buffered, tied, and flushed onto the voice — the
8
+ # behavior that reads and mutates this state lives with the state itself.
9
+ class VoiceState
10
+ # The identity length scale, used wherever no stretching applies.
11
+ ONE = Rational(1)
12
+
13
+ # A note or chord whose placement is deferred until we know whether a
14
+ # broken-rhythm mark follows it. The pitches are computed eagerly so
15
+ # bar-line accidental resets cannot corrupt them. `tied_prefix`, when
16
+ # present, is the already-built rhythmic value of everything tied ahead of
17
+ # this note; its own value is appended at flush time.
18
+ PendingNote = Data.define(:pitches, :length, :scale, :tied_prefix, :beam_break) do
19
+ def initialize(pitches:, length:, scale:, tied_prefix: nil, beam_break: nil)
20
+ super
21
+ end
22
+ end
23
+
24
+ attr_reader :voice, :pitch_builder, :tie_line
25
+ attr_accessor :pending_note, :awaiting_scale, :broken_line,
26
+ :active_passes, :volta_start_bar
27
+
28
+ def initialize(voice, pitch_builder, duration_resolver = nil)
29
+ @voice = voice
30
+ @pitch_builder = pitch_builder
31
+ @duration_resolver = duration_resolver
32
+ @beam_break_pending = false
33
+ @beam_last_was_note = false
34
+ @tie_open = false
35
+ end
36
+
37
+ def completed_bar_number
38
+ voice.last_placement&.position&.bar_number
39
+ end
40
+
41
+ def entered_bar_number
42
+ voice.next_position.bar_number
43
+ end
44
+
45
+ # Records an explicit beam break; the next note consumes it.
46
+ def mark_beam_break
47
+ @beam_break_pending = true
48
+ end
49
+
50
+ # The beam-break flag for the note now beginning: true if a break was
51
+ # marked, false if the previous token was also a note (so they beam
52
+ # together), or nil when there is no prior note to beam against.
53
+ # Consumes the pending break and records that a note is now current.
54
+ def next_beam_break
55
+ flag = if @beam_break_pending
56
+ true
57
+ elsif @beam_last_was_note
58
+ false
59
+ end
60
+ @beam_break_pending = false
61
+ @beam_last_was_note = true
62
+ flag
63
+ end
64
+
65
+ # After a rest, bar line, volta, or voice change, the next note must
66
+ # not beam to whatever preceded the boundary.
67
+ def reset_beam_adjacency
68
+ @beam_last_was_note = false
69
+ @beam_break_pending = false
70
+ end
71
+
72
+ def tie_open?
73
+ @tie_open
74
+ end
75
+
76
+ def open_tie(line)
77
+ @tie_open = true
78
+ @tie_line = line
79
+ end
80
+
81
+ def close_tie
82
+ @tie_open = false
83
+ @tie_line = nil
84
+ end
85
+
86
+ # Buffers a note or chord as the pending note, flushing whatever was
87
+ # pending first. A broken-rhythm scale awaiting its right note, and any
88
+ # per-chord inner scale, fold into the buffered length. When a tie is
89
+ # open the arriving note instead extends the pending tie chain.
90
+ def defer_placement(pitches, length, inner_scale = ONE)
91
+ scale = (awaiting_scale || ONE) * inner_scale
92
+ self.awaiting_scale = nil
93
+ return tie_onto_pending(pitches, length, scale) if tie_open?
94
+
95
+ flush_pending_note
96
+ self.pending_note = PendingNote.new(
97
+ pitches: pitches, length: length, scale: scale, beam_break: next_beam_break
98
+ )
99
+ end
100
+
101
+ # Places the pending note onto the voice, if any, carrying its authored
102
+ # beam break onto the placement.
103
+ def flush_pending_note
104
+ pending = pending_note
105
+ return unless pending
106
+
107
+ self.pending_note = nil
108
+ placement = voice.place(voice.next_position, pending_rhythmic_value(pending), pending.pitches)
109
+ placement.beam_break_before = pending.beam_break
110
+ end
111
+
112
+ # Places a note, chord, or rest (nil pitches) directly onto the voice,
113
+ # bypassing the pending-note buffer.
114
+ def place(length, pitches, scale: ONE)
115
+ rhythmic_value = @duration_resolver.rhythmic_value(length, scale: scale)
116
+ voice.place(voice.next_position, rhythmic_value, pitches)
117
+ end
118
+
119
+ private
120
+
121
+ # Closes an open tie: the pending note becomes the new note's tied prefix,
122
+ # so the pair (and any longer chain) resolves to a single placement whose
123
+ # rhythmic value carries the author's chosen split.
124
+ def tie_onto_pending(pitches, length, scale)
125
+ pending = pending_note
126
+ ensure_tie_pitches_match(pending, pitches)
127
+ prefix = pending_rhythmic_value(pending)
128
+ close_tie
129
+ self.pending_note = PendingNote.new(
130
+ pitches: pitches, length: length, scale: scale, tied_prefix: prefix,
131
+ beam_break: pending.beam_break
132
+ )
133
+ end
134
+
135
+ def ensure_tie_pitches_match(pending, pitches)
136
+ return if pending.pitches.sort == pitches.sort
137
+
138
+ raise ParseError.new(
139
+ "A tie must connect two notes of the same pitch",
140
+ line_number: tie_line, snippet: "-"
141
+ )
142
+ end
143
+
144
+ # A pending note's own value, with any tied prefix appended ahead of
145
+ # it so the whole tie chain renders as one sounding note.
146
+ def pending_rhythmic_value(pending)
147
+ own = @duration_resolver.rhythmic_value(pending.length, scale: pending.scale)
148
+ prefix = pending.tied_prefix
149
+ prefix ? append_tied(prefix, own) : own
150
+ end
151
+
152
+ # Attaches `tail` at the deep end of `head`'s tied chain, rebuilding
153
+ # each link (RhythmicValue exposes no setter) so a chain like
154
+ # "half tied to eighth" gains a further "tied to quarter".
155
+ def append_tied(head, tail)
156
+ tied = head.tied_value
157
+ inner = tied ? append_tied(tied, tail) : tail
158
+ HeadMusic::Rudiment::RhythmicValue.new(head.unit, dots: head.dots, tied_value: inner)
159
+ end
160
+ end
161
+ end
@@ -15,6 +15,8 @@ module HeadMusic::Notation::ABC
15
15
  UNIT_NOTE_LENGTH = Rational(1, 8)
16
16
  BARS_PER_LINE = 4
17
17
 
18
+ include HeadMusic::Notation::PlacementValidation
19
+
18
20
  attr_reader :composition, :reference_number
19
21
 
20
22
  def initialize(composition, reference_number: 1)
@@ -83,15 +85,27 @@ module HeadMusic::Notation::ABC
83
85
  [
84
86
  "X:#{reference_number}",
85
87
  "T:#{composition.name}",
86
- composition.composer && "C:#{composition.composer}",
87
- composition.origin && "O:#{composition.origin}",
88
+ optional_field("C", composition.composer),
89
+ optional_field("O", composition.origin),
88
90
  "M:#{composition.meter}",
89
- "L:#{UNIT_NOTE_LENGTH.numerator}/#{UNIT_NOTE_LENGTH.denominator}",
90
- # The parser requires K: to terminate the header.
91
- "K:#{KeyMapper.abc_value(composition.key_signature)}"
91
+ unit_note_length_field,
92
+ key_field
92
93
  ].compact
93
94
  end
94
95
 
96
+ def optional_field(letter, value)
97
+ "#{letter}:#{value}" if value
98
+ end
99
+
100
+ def unit_note_length_field
101
+ "L:#{UNIT_NOTE_LENGTH.numerator}/#{UNIT_NOTE_LENGTH.denominator}"
102
+ end
103
+
104
+ def key_field
105
+ # The parser requires K: to terminate the header.
106
+ "K:#{KeyMapper.abc_value(composition.key_signature)}"
107
+ end
108
+
95
109
  def body_lines
96
110
  return [] if bar_strings.empty?
97
111
 
@@ -109,13 +123,34 @@ module HeadMusic::Notation::ABC
109
123
  def build_bar_strings
110
124
  pitch_writer = PitchWriter.new(composition.key_signature)
111
125
  duration_writer = DurationWriter.new(UNIT_NOTE_LENGTH)
112
- bar_groups = placements.chunk_while do |previous, current|
113
- previous.position.bar_number == current.position.bar_number
114
- end
115
- bar_groups.each_with_index.map do |bar_placements, index|
126
+ placements_by_bar.each_with_index.map do |bar_placements, index|
116
127
  # Accidental state must mirror what a re-parse accumulates bar by bar.
117
128
  pitch_writer.start_new_bar if index.positive?
118
- bar_placements.map { |placement| token(placement, pitch_writer, duration_writer) }.join(" ")
129
+ render_bar(bar_placements, pitch_writer, duration_writer)
130
+ end
131
+ end
132
+
133
+ def placements_by_bar
134
+ placements.chunk_while do |previous, current|
135
+ previous.position.bar_number == current.position.bar_number
136
+ end
137
+ end
138
+
139
+ def render_bar(bar_placements, pitch_writer, duration_writer)
140
+ tokens = bar_placements.map { |placement| token(placement, pitch_writer, duration_writer) }
141
+ join_bar_tokens(bar_placements, tokens)
142
+ end
143
+
144
+ # Suppresses the inter-token space only where the following placement was
145
+ # authored as beamed to its predecessor (beam_break_before == false).
146
+ # A true or nil flag keeps the space, so programmatic (nil-flag)
147
+ # compositions render with today's every-token spacing. Every bar token
148
+ # (note, rest, or [..] chord) re-lexes unambiguously with no separator, so
149
+ # dropping the space is safe.
150
+ def join_bar_tokens(placements, tokens)
151
+ tokens.each_with_index.reduce(+"") do |line, (token, index)|
152
+ separator = (index.zero? || placements[index].beam_break_before == false) ? "" : " "
153
+ line << separator << token
119
154
  end
120
155
  end
121
156
 
@@ -137,12 +172,8 @@ module HeadMusic::Notation::ABC
137
172
  "[#{pitch_tokens.join}]#{multiplier}"
138
173
  end
139
174
 
140
- def ensure_pitched_sounds(placement)
141
- unpitched = placement.sounds.find { |sound| !sound.pitched? }
142
- return unless unpitched
143
-
144
- raise RenderError, "cannot render unpitched sound \"#{unpitched}\" at #{placement.position}: " \
145
- "percussion rendering is not yet supported"
175
+ def render_error_class
176
+ RenderError
146
177
  end
147
178
  end
148
179
  end
@@ -0,0 +1,19 @@
1
+ module HeadMusic
2
+ module Notation
3
+ # The exact fractional length of a (possibly dotted) rhythmic value in
4
+ # terms of its own unit. RhythmicValue's own value methods return Floats,
5
+ # so the fraction is rebuilt here from the unit's integer numerator and
6
+ # denominator to keep downstream arithmetic (ABC multipliers, MusicXML
7
+ # divisions) exact.
8
+ module DottedDuration
9
+ module_function
10
+
11
+ def dotted_unit_fraction(rhythmic_value)
12
+ unit = rhythmic_value.unit
13
+ dots = rhythmic_value.dots
14
+ # A value with d dots spans (2^(d+1) - 1) / 2^d of its own unit.
15
+ Rational(unit.numerator, unit.denominator) * Rational((2**(dots + 1)) - 1, 2**dots)
16
+ end
17
+ end
18
+ end
19
+ end
@@ -7,8 +7,13 @@ module HeadMusic::Notation; end
7
7
  # catalog entry with its own factory. Alternatives are recorded data only —
8
8
  # no selection behavior is implemented here (deferred to the Content layer).
9
9
  class HeadMusic::Notation::InstrumentNotation
10
+ include HeadMusic::ValueEquality
11
+
10
12
  attr_reader :instrument, :staves, :alternatives
11
13
 
14
+ value_equality :instrument, :staves_attributes
15
+ alias_method :eql?, :==
16
+
12
17
  def initialize(instrument:, data:)
13
18
  @instrument = instrument
14
19
  @staves = build_staves(data["staves"])
@@ -32,13 +37,6 @@ class HeadMusic::Notation::InstrumentNotation
32
37
  staves.length > 1
33
38
  end
34
39
 
35
- def ==(other)
36
- return false unless other.is_a?(self.class)
37
-
38
- instrument == other.instrument && staves_attributes == other.staves_attributes
39
- end
40
- alias_method :eql?, :==
41
-
42
40
  def hash
43
41
  [instrument.name_key, staves_attributes].hash
44
42
  end