Class: HeadMusic::Notation::MusicXML::Writer

Inherits:
Object
  • Object
show all
Defined in:
lib/head_music/notation/music_xml/writer.rb

Overview

Renders a HeadMusic::Content::Composition as a score-partwise MusicXML 4.0 document string.

Whole-composition problems (no voices, positional gaps, barline-crossing notes, unmappable keys or durations, forbidden control characters) raise before any assembly, so #to_s only ever returns a complete document.

Constant Summary collapse

INDENT =
"  "
XML_ESCAPES =
{
  "&" => "&",
  "<" => "&lt;",
  ">" => "&gt;",
  '"' => "&quot;",
  "'" => "&apos;"
}.freeze
FORBIDDEN_TEXT_CHARACTERS =

XML 1.0 forbids the C0 control characters other than tab, newline, and carriage return, even as character references.

/[\u0000-\u0008\u000B\u000C\u000E-\u001F]/
BEAM_LEVELS_BY_TYPE =

The number of beams a notehead of each MusicXML carries alone. Every other type (quarter and longer) and every rest carries none.

{
  "eighth" => 1,
  "16th" => 2,
  "32nd" => 3,
  "64th" => 4,
  "128th" => 5,
  "256th" => 6
}.freeze

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(composition) ⇒ Writer

Returns a new instance of Writer.



35
36
37
# File 'lib/head_music/notation/music_xml/writer.rb', line 35

def initialize(composition)
  @composition = composition
end

Instance Attribute Details

#compositionObject (readonly)

Returns the value of attribute composition.



33
34
35
# File 'lib/head_music/notation/music_xml/writer.rb', line 33

def composition
  @composition
end

Instance Method Details

#annotate_bar(voice, bar_number, annotations) ⇒ Object (private)



145
146
147
148
149
150
151
152
153
# File 'lib/head_music/notation/music_xml/writer.rb', line 145

def annotate_bar(voice, bar_number, annotations)
  placements = placements_by_bar(voice)[bar_number]
  return unless placements

  keys = []
  events = build_bar_events(placements, keys)
  beams = BeamGrouper.annotate(events, group_unit_divisions(bar_number))
  keys.each_with_index { |key, index| annotations[key] = beams[index] }
end

#attribute_lines(voice, bar_number) ⇒ Object (private)



297
298
299
300
301
302
303
304
305
306
307
308
309
310
# File 'lib/head_music/notation/music_xml/writer.rb', line 297

def attribute_lines(voice, bar_number)
  return first_measure_attribute_lines(voice) if bar_number == bar_numbers.first

  key = measure_key_changes[bar_number]
  meter = measure_time_changes[bar_number]
  return [] unless key || meter

  [
    "#{INDENT * 3}<attributes>",
    *(key ? key_lines(key) : []),
    *(meter ? time_lines(meter) : []),
    "#{INDENT * 3}</attributes>"
  ]
end

#bar_numbersObject (private)



189
190
191
# File 'lib/head_music/notation/music_xml/writer.rb', line 189

def bar_numbers
  composition.earliest_bar_number..composition.latest_bar_number
end

#beam_annotationsObject (private)

A Hash keyed by [placement, component_index] holding the Array that BeamGrouper computed for that notehead. Built one bar at a time so a notehead's onset is its exact integer offset from the bar start.



137
138
139
140
141
142
143
# File 'lib/head_music/notation/music_xml/writer.rb', line 137

def beam_annotations
  @beam_annotations ||= {}.tap do |annotations|
    composition.voices.each do |voice|
      bar_numbers.each { |bar_number| annotate_bar(voice, bar_number, annotations) }
    end
  end
end

#beam_levels(placement, component) ⇒ Object (private)



171
172
173
174
175
# File 'lib/head_music/notation/music_xml/writer.rb', line 171

def beam_levels(placement, component)
  return 0 if placement.rest?

  BEAM_LEVELS_BY_TYPE.fetch(component.type, 0)
end

#beam_lines(beams) ⇒ Object (private)



425
426
427
# File 'lib/head_music/notation/music_xml/writer.rb', line 425

def beam_lines(beams)
  beams.map { |beam| %(#{INDENT * 4}<beam number="#{beam.number}">#{beam.type}</beam>) }
end

#build_bar_events(placements, keys) ⇒ Object (private)



155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
# File 'lib/head_music/notation/music_xml/writer.rb', line 155

def build_bar_events(placements, keys)
  onset = 0
  placements.flat_map do |placement|
    components_by_placement[placement].each_with_index.map do |component, component_index|
      event = BeamGrouper::Event.new(
        levels: beam_levels(placement, component),
        onset: onset,
        beam_break_before: component_index.zero? ? placement.beam_break_before : nil
      )
      keys << [placement, component_index]
      onset += component.duration
      event
    end
  end
end

#clef_lines(voice) ⇒ Object (private)



341
342
343
344
345
346
347
348
349
# File 'lib/head_music/notation/music_xml/writer.rb', line 341

def clef_lines(voice)
  clef = ClefSelector.for(voice)
  [
    "#{INDENT * 4}<clef>",
    "#{INDENT * 5}<sign>#{clef.pitch.letter_name}</sign>",
    "#{INDENT * 5}<line>#{clef.line}</line>",
    "#{INDENT * 4}</clef>"
  ]
end

#components_by_placementObject (private)



128
129
130
131
132
# File 'lib/head_music/notation/music_xml/writer.rb', line 128

def components_by_placement
  @components_by_placement ||= composition.voices.flat_map(&:placements).to_h do |placement|
    [placement, duration_writer.components(placement.rhythmic_value)]
  end
end

#divisionsObject (private)



120
121
122
# File 'lib/head_music/notation/music_xml/writer.rb', line 120

def divisions
  @divisions ||= Divisions.for(composition)
end

#document_linesObject (private)



223
224
225
226
227
228
229
230
231
232
233
234
# File 'lib/head_music/notation/music_xml/writer.rb', line 223

def document_lines
  [
    %(<?xml version="1.0" encoding="UTF-8"?>),
    %(<!DOCTYPE score-partwise PUBLIC "-//Recordare//DTD MusicXML 4.0 Partwise//EN" "http://www.musicxml.org/dtds/partwise.dtd">),
    %(<score-partwise version="4.0">),
    *work_lines,
    *identification_lines,
    *part_list_lines,
    *part_lines,
    "</score-partwise>"
  ]
end

#duration_writerObject (private)



124
125
126
# File 'lib/head_music/notation/music_xml/writer.rb', line 124

def duration_writer
  @duration_writer ||= DurationWriter.new(divisions)
end

#effective_meter(bar_number) ⇒ Object (private)



218
219
220
221
# File 'lib/head_music/notation/music_xml/writer.rb', line 218

def effective_meter(bar_number)
  change_bar = measure_time_changes.keys.select { |number| number <= bar_number }.max
  change_bar ? measure_time_changes[change_bar] : composition.meter
end

#ensure_contiguous_voicesObject (private)



81
82
83
84
85
86
# File 'lib/head_music/notation/music_xml/writer.rb', line 81

def ensure_contiguous_voices
  composition.voices.each do |voice|
    gap = voice.first_gap
    raise_gap_error(voice, *gap) if gap
  end
end

#ensure_notes_within_barlinesObject (private)



98
99
100
101
102
103
104
105
106
107
# File 'lib/head_music/notation/music_xml/writer.rb', line 98

def ensure_notes_within_barlines
  composition.voices.each do |voice|
    voice.placements.each do |placement|
      next unless placement.next_position > placement.position.start_of_next_bar

      raise RenderError, "the note at #{placement.position} crosses its barline; " \
        "splitting notes across barlines is not supported"
    end
  end
end

#ensure_pitched_sounds(placement) ⇒ Object (private)

Raises:



399
400
401
402
403
404
405
# File 'lib/head_music/notation/music_xml/writer.rb', line 399

def ensure_pitched_sounds(placement)
  unpitched = placement.sounds.find { |sound| !sound.pitched? }
  return unless unpitched

  raise RenderError, "cannot render unpitched sound \"#{unpitched}\" at #{placement.position}: " \
    "percussion rendering is not yet supported"
end

#ensure_renderable_textObject (private)



72
73
74
75
76
77
78
79
# File 'lib/head_music/notation/music_xml/writer.rb', line 72

def ensure_renderable_text
  texts = [composition.name, composition.composer] + composition.voices.map(&:role)
  texts.compact.each do |text|
    next unless text.to_s.match?(FORBIDDEN_TEXT_CHARACTERS)

    raise RenderError, "cannot render control characters in #{text.to_s.inspect} as XML text"
  end
end

#ensure_voicesObject (private)

Raises:



55
56
57
58
59
# File 'lib/head_music/notation/music_xml/writer.rb', line 55

def ensure_voices
  return unless composition.voices.empty?

  raise RenderError, "cannot render a composition with no voices as MusicXML"
end

#escape(text) ⇒ Object (private)



462
463
464
# File 'lib/head_music/notation/music_xml/writer.rb', line 462

def escape(text)
  text.to_s.gsub(/[&<>"']/) { |character| XML_ESCAPES[character] }
end

#first_measure_attribute_lines(voice) ⇒ Object (private)



312
313
314
315
316
317
318
319
320
321
# File 'lib/head_music/notation/music_xml/writer.rb', line 312

def first_measure_attribute_lines(voice)
  [
    "#{INDENT * 3}<attributes>",
    "#{INDENT * 4}<divisions>#{divisions}</divisions>",
    *key_lines(first_measure_key),
    *time_lines(first_measure_meter),
    *clef_lines(voice),
    "#{INDENT * 3}</attributes>"
  ]
end

#first_measure_keyObject (private)



209
210
211
212
# File 'lib/head_music/notation/music_xml/writer.rb', line 209

def first_measure_key
  @first_measure_key ||=
    measure_key_changes[bar_numbers.first] || key_element_values(composition.key_signature)
end

#first_measure_meterObject (private)



214
215
216
# File 'lib/head_music/notation/music_xml/writer.rb', line 214

def first_measure_meter
  @first_measure_meter ||= effective_meter(bar_numbers.first)
end

#group_unit_divisions(bar_number) ⇒ Object (private)

The beam-group span in integer divisions for a bar’s effective meter. DurationWriter.single_quarter_fraction returns an exact Rational, so the product with the integer divisions is integral for every supported meter.



180
181
182
183
184
185
186
187
# File 'lib/head_music/notation/music_xml/writer.rb', line 180

def group_unit_divisions(bar_number)
  fraction = DurationWriter.single_quarter_fraction(effective_meter(bar_number).beam_group_unit) * divisions
  unless fraction.denominator == 1
    raise RenderError,
      "cannot express the beam group unit as an integer duration at #{divisions} divisions per quarter note"
  end
  fraction.to_i
end

#identification_linesObject (private)



244
245
246
247
248
249
250
251
252
253
# File 'lib/head_music/notation/music_xml/writer.rb', line 244

def identification_lines
  [
    "#{INDENT}<identification>",
    composition.composer && %(#{INDENT * 2}<creator type="composer">#{escape(composition.composer)}</creator>),
    "#{INDENT * 2}<encoding>",
    "#{INDENT * 3}<software>head_music #{HeadMusic::VERSION}</software>",
    "#{INDENT * 2}</encoding>",
    "#{INDENT}</identification>"
  ].compact
end

#key_element_values(key_signature) ⇒ Object (private)



205
206
207
# File 'lib/head_music/notation/music_xml/writer.rb', line 205

def key_element_values(key_signature)
  {fifths: KeyMapper.fifths(key_signature), mode: KeyMapper.mode(key_signature)}
end

#key_lines(key) ⇒ Object (private)



323
324
325
326
327
328
329
330
# File 'lib/head_music/notation/music_xml/writer.rb', line 323

def key_lines(key)
  [
    "#{INDENT * 4}<key>",
    "#{INDENT * 5}<fifths>#{key[:fifths]}</fifths>",
    "#{INDENT * 5}<mode>#{key[:mode]}</mode>",
    "#{INDENT * 4}</key>"
  ]
end

#measure_content_lines(voice, bar_number) ⇒ Object (private)



351
352
353
354
355
356
# File 'lib/head_music/notation/music_xml/writer.rb', line 351

def measure_content_lines(voice, bar_number)
  placements = placements_by_bar(voice)[bar_number]
  return whole_measure_rest_lines(bar_number) unless placements

  placements.flat_map { |placement| note_lines(placement) }
end

#measure_key_changesObject (private)



193
194
195
196
197
# File 'lib/head_music/notation/music_xml/writer.rb', line 193

def measure_key_changes
  @measure_key_changes ||= bar_numbers.zip(composition.bars).filter_map { |bar_number, bar|
    [bar_number, key_element_values(bar.key_signature)] if bar.key_signature
  }.to_h
end

#measure_lines(voice, bar_number) ⇒ Object (private)



280
281
282
283
284
285
286
287
# File 'lib/head_music/notation/music_xml/writer.rb', line 280

def measure_lines(voice, bar_number)
  [
    measure_open_tag(bar_number),
    *attribute_lines(voice, bar_number),
    *measure_content_lines(voice, bar_number),
    "#{INDENT * 2}</measure>"
  ]
end

#measure_open_tag(bar_number) ⇒ Object (private)

A bar before bar 1 — a pickup written out in full with leading rests — is marked implicit by convention. A partially filled first bar is rejected as a gap in validate!, so only complete pickup bars reach here.



292
293
294
295
# File 'lib/head_music/notation/music_xml/writer.rb', line 292

def measure_open_tag(bar_number)
  implicit = (bar_number < 1) ? %( implicit="yes") : ""
  %(#{INDENT * 2}<measure number="#{bar_number}"#{implicit}>)
end

#measure_time_changesObject (private)



199
200
201
202
203
# File 'lib/head_music/notation/music_xml/writer.rb', line 199

def measure_time_changes
  @measure_time_changes ||= bar_numbers.zip(composition.bars).filter_map { |bar_number, bar|
    [bar_number, bar.meter] if bar.meter
  }.to_h
end

#memoize_render_dataObject (private)

Everything else that can raise is computed here, before assembly starts, and memoized for reuse during assembly.



111
112
113
114
115
116
117
118
# File 'lib/head_music/notation/music_xml/writer.rb', line 111

def memoize_render_data
  key_element_values(composition.key_signature)
  first_measure_key
  first_measure_meter
  measure_key_changes
  measure_time_changes
  components_by_placement
end

#normalize_bar_markersObject (private)

change_meter and change_key_signature store the caller’s raw value (Bar’s accessors are bare attr_accessors), and Position arithmetic breaks on an un-coerced meter string, so markers are normalized in place before any placement’s next_position is computed.



65
66
67
68
69
70
# File 'lib/head_music/notation/music_xml/writer.rb', line 65

def normalize_bar_markers
  composition.bars.each do |bar|
    bar.meter = HeadMusic::Rudiment::Meter.get(bar.meter) if bar.meter
    bar.key_signature = HeadMusic::Rudiment::KeySignature.get(bar.key_signature) if bar.key_signature
  end
end

#notation_lines(placement, component) ⇒ Object (private)



451
452
453
454
455
456
457
458
459
460
# File 'lib/head_music/notation/music_xml/writer.rb', line 451

def notation_lines(placement, component)
  return [] if placement.rest? || (!component.tie_start && !component.tie_stop)

  [
    "#{INDENT * 4}<notations>",
    component.tie_stop ? %(#{INDENT * 5}<tied type="stop"/>) : nil,
    component.tie_start ? %(#{INDENT * 5}<tied type="start"/>) : nil,
    "#{INDENT * 4}</notations>"
  ].compact
end

#note_element_lines(placement, component, pitch: nil, chord: false, beams: []) ⇒ Object (private)

A chord note carries as its first child, before , marking it as sounding with the preceding note; the lead note (and every single note and rest) omits it, so this path stays byte-identical for those.



410
411
412
413
414
415
416
417
418
419
420
421
422
423
# File 'lib/head_music/notation/music_xml/writer.rb', line 410

def note_element_lines(placement, component, pitch: nil, chord: false, beams: [])
  [
    "#{INDENT * 3}<note>",
    *(chord ? ["#{INDENT * 4}<chord/>"] : []),
    *(pitch ? pitch_lines(pitch) : ["#{INDENT * 4}<rest/>"]),
    "#{INDENT * 4}<duration>#{component.duration}</duration>",
    *tie_lines(placement, component),
    "#{INDENT * 4}<type>#{component.type}</type>",
    *Array.new(component.dots) { "#{INDENT * 4}<dot/>" },
    *beam_lines(beams),
    *notation_lines(placement, component),
    "#{INDENT * 3}</note>"
  ]
end

#note_lines(placement) ⇒ Object (private)



379
380
381
382
383
384
385
386
387
388
389
390
# File 'lib/head_music/notation/music_xml/writer.rb', line 379

def note_lines(placement)
  ensure_pitched_sounds(placement)

  components_by_placement[placement].each_with_index.flat_map do |component, component_index|
    beams = beam_annotations[[placement, component_index]] || []
    note_slots(placement).each_with_index.flat_map do |pitch, index|
      note_element_lines(
        placement, component, pitch: pitch, chord: index.positive?, beams: index.zero? ? beams : []
      )
    end
  end
end

#note_slots(placement) ⇒ Object (private)

A rest emits one empty slot; a sounded placement emits its pitches low to high, so the lowest note leads and the rest carry . ensure_pitched_sounds has already rejected any unpitched sound, so pitches covers every sound here.



395
396
397
# File 'lib/head_music/notation/music_xml/writer.rb', line 395

def note_slots(placement)
  placement.rest? ? [nil] : placement.pitches.sort
end

#part_linesObject (private)



270
271
272
273
274
275
276
277
278
# File 'lib/head_music/notation/music_xml/writer.rb', line 270

def part_lines
  composition.voices.each_with_index.flat_map do |voice, index|
    [
      %(#{INDENT}<part id="P#{index + 1}">),
      *bar_numbers.flat_map { |bar_number| measure_lines(voice, bar_number) },
      "#{INDENT}</part>"
    ]
  end
end

#part_list_linesObject (private)



255
256
257
258
259
260
261
262
263
264
# File 'lib/head_music/notation/music_xml/writer.rb', line 255

def part_list_lines
  score_part_lines = composition.voices.each_with_index.flat_map do |voice, index|
    [
      %(#{INDENT * 2}<score-part id="P#{index + 1}">),
      "#{INDENT * 3}<part-name>#{escape(part_name(voice, index))}</part-name>",
      "#{INDENT * 2}</score-part>"
    ]
  end
  ["#{INDENT}<part-list>", *score_part_lines, "#{INDENT}</part-list>"]
end

#part_name(voice, index) ⇒ Object (private)



266
267
268
# File 'lib/head_music/notation/music_xml/writer.rb', line 266

def part_name(voice, index)
  voice.role || "Voice #{index + 1}"
end

#pitch_lines(pitch) ⇒ Object (private)



429
430
431
432
433
434
435
436
437
438
# File 'lib/head_music/notation/music_xml/writer.rb', line 429

def pitch_lines(pitch)
  attributes = PitchWriter.attributes(pitch)
  [
    "#{INDENT * 4}<pitch>",
    "#{INDENT * 5}<step>#{attributes[:step]}</step>",
    attributes[:alter] && "#{INDENT * 5}<alter>#{attributes[:alter]}</alter>",
    "#{INDENT * 5}<octave>#{attributes[:octave]}</octave>",
    "#{INDENT * 4}</pitch>"
  ].compact
end

#placements_by_bar(voice) ⇒ Object (private)



358
359
360
361
# File 'lib/head_music/notation/music_xml/writer.rb', line 358

def placements_by_bar(voice)
  @placements_by_bar ||= {}
  @placements_by_bar[voice] ||= voice.placements.group_by { |placement| placement.position.bar_number }
end

#raise_gap_error(voice, expected_position, found_placement) ⇒ Object (private)

Raises:



88
89
90
91
92
93
94
95
96
# File 'lib/head_music/notation/music_xml/writer.rb', line 88

def raise_gap_error(voice, expected_position, found_placement)
  if found_placement.equal?(voice.placements.first)
    raise RenderError, "the first placement must start its bar " \
      "(found #{found_placement.position}); insert explicit rests to fill the gap"
  end

  raise RenderError, "expected a placement at #{expected_position}, " \
    "found one at #{found_placement.position}; insert explicit rests to fill gaps"
end

#tie_lines(placement, component) ⇒ Object (private)

Rests take no tie elements; the links of a rest’s tied chain render as consecutive independent rests.



442
443
444
445
446
447
448
449
# File 'lib/head_music/notation/music_xml/writer.rb', line 442

def tie_lines(placement, component)
  return [] if placement.rest?

  [
    component.tie_stop ? %(#{INDENT * 4}<tie type="stop"/>) : nil,
    component.tie_start ? %(#{INDENT * 4}<tie type="start"/>) : nil
  ].compact
end

#time_lines(meter) ⇒ Object (private)



332
333
334
335
336
337
338
339
# File 'lib/head_music/notation/music_xml/writer.rb', line 332

def time_lines(meter)
  [
    "#{INDENT * 4}<time>",
    "#{INDENT * 5}<beats>#{meter.top_number}</beats>",
    "#{INDENT * 5}<beat-type>#{meter.bottom_number}</beat-type>",
    "#{INDENT * 4}</time>"
  ]
end

#to_sObject



39
40
41
42
# File 'lib/head_music/notation/music_xml/writer.rb', line 39

def to_s
  validate!
  document_lines.join("\n") + "\n"
end

#validate!Object (private)



46
47
48
49
50
51
52
53
# File 'lib/head_music/notation/music_xml/writer.rb', line 46

def validate!
  ensure_voices
  normalize_bar_markers
  ensure_renderable_text
  ensure_contiguous_voices
  ensure_notes_within_barlines
  memoize_render_data
end

#whole_measure_duration(bar_number) ⇒ Object (private)

Divisions.for guarantees a whole measure of any effective meter is an integer number of divisions, so the Rational’s numerator is the value.



374
375
376
377
# File 'lib/head_music/notation/music_xml/writer.rb', line 374

def whole_measure_duration(bar_number)
  meter = effective_meter(bar_number)
  (Rational(4 * meter.top_number, meter.bottom_number) * divisions).numerator
end

#whole_measure_rest_lines(bar_number) ⇒ Object (private)



363
364
365
366
367
368
369
370
# File 'lib/head_music/notation/music_xml/writer.rb', line 363

def whole_measure_rest_lines(bar_number)
  [
    "#{INDENT * 3}<note>",
    %(#{INDENT * 4}<rest measure="yes"/>),
    "#{INDENT * 4}<duration>#{whole_measure_duration(bar_number)}</duration>",
    "#{INDENT * 3}</note>"
  ]
end

#work_linesObject (private)



236
237
238
239
240
241
242
# File 'lib/head_music/notation/music_xml/writer.rb', line 236

def work_lines
  [
    "#{INDENT}<work>",
    "#{INDENT * 2}<work-title>#{escape(composition.name)}</work-title>",
    "#{INDENT}</work>"
  ]
end