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]/

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(composition) ⇒ Writer

Returns a new instance of Writer.



24
25
26
# File 'lib/head_music/notation/music_xml/writer.rb', line 24

def initialize(composition)
  @composition = composition
end

Instance Attribute Details

#compositionObject (readonly)

Returns the value of attribute composition.



22
23
24
# File 'lib/head_music/notation/music_xml/writer.rb', line 22

def composition
  @composition
end

Instance Method Details

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



231
232
233
234
235
236
237
238
239
240
241
242
243
244
# File 'lib/head_music/notation/music_xml/writer.rb', line 231

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)



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

def bar_numbers
  composition.earliest_bar_number..composition.latest_bar_number
end

#clef_lines(voice) ⇒ Object (private)



275
276
277
278
279
280
281
282
283
# File 'lib/head_music/notation/music_xml/writer.rb', line 275

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

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



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

def component_lines(placement, component)
  [
    "#{INDENT * 3}<note>",
    *(placement.note? ? pitch_lines(placement.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/>" },
    *notation_lines(placement, component),
    "#{INDENT * 3}</note>"
  ]
end

#components_by_placementObject (private)



117
118
119
120
121
# File 'lib/head_music/notation/music_xml/writer.rb', line 117

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)



109
110
111
# File 'lib/head_music/notation/music_xml/writer.rb', line 109

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

#document_linesObject (private)



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

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)



113
114
115
# File 'lib/head_music/notation/music_xml/writer.rb', line 113

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

#effective_meter(bar_number) ⇒ Object (private)



152
153
154
155
# File 'lib/head_music/notation/music_xml/writer.rb', line 152

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)



70
71
72
73
74
75
# File 'lib/head_music/notation/music_xml/writer.rb', line 70

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)



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

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_renderable_textObject (private)



61
62
63
64
65
66
67
68
# File 'lib/head_music/notation/music_xml/writer.rb', line 61

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:



44
45
46
47
48
# File 'lib/head_music/notation/music_xml/writer.rb', line 44

def ensure_voices
  return unless composition.voices.empty?

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

#escape(text) ⇒ Object (private)



365
366
367
# File 'lib/head_music/notation/music_xml/writer.rb', line 365

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

#first_measure_attribute_lines(voice) ⇒ Object (private)



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

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)



143
144
145
146
# File 'lib/head_music/notation/music_xml/writer.rb', line 143

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

#first_measure_meterObject (private)



148
149
150
# File 'lib/head_music/notation/music_xml/writer.rb', line 148

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

#identification_linesObject (private)



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

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)



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

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

#key_lines(key) ⇒ Object (private)



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

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)



285
286
287
288
289
290
# File 'lib/head_music/notation/music_xml/writer.rb', line 285

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)



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

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)



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

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.



226
227
228
229
# File 'lib/head_music/notation/music_xml/writer.rb', line 226

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

#measure_time_changesObject (private)



133
134
135
136
137
# File 'lib/head_music/notation/music_xml/writer.rb', line 133

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.



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

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.



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

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)



354
355
356
357
358
359
360
361
362
363
# File 'lib/head_music/notation/music_xml/writer.rb', line 354

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_lines(placement) ⇒ Object (private)



313
314
315
316
317
# File 'lib/head_music/notation/music_xml/writer.rb', line 313

def note_lines(placement)
  components_by_placement[placement].flat_map do |component|
    component_lines(placement, component)
  end
end

#part_linesObject (private)



204
205
206
207
208
209
210
211
212
# File 'lib/head_music/notation/music_xml/writer.rb', line 204

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)



189
190
191
192
193
194
195
196
197
198
# File 'lib/head_music/notation/music_xml/writer.rb', line 189

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)



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

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

#pitch_lines(pitch) ⇒ Object (private)



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

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)



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

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:



77
78
79
80
81
82
83
84
85
# File 'lib/head_music/notation/music_xml/writer.rb', line 77

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.



345
346
347
348
349
350
351
352
# File 'lib/head_music/notation/music_xml/writer.rb', line 345

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)



266
267
268
269
270
271
272
273
# File 'lib/head_music/notation/music_xml/writer.rb', line 266

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



28
29
30
31
# File 'lib/head_music/notation/music_xml/writer.rb', line 28

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

#validate!Object (private)



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

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.



308
309
310
311
# File 'lib/head_music/notation/music_xml/writer.rb', line 308

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)



297
298
299
300
301
302
303
304
# File 'lib/head_music/notation/music_xml/writer.rb', line 297

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)



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

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