Class: HeadMusic::Notation::MusicXML::Writer
- Inherits:
-
Object
- Object
- HeadMusic::Notation::MusicXML::Writer
- 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 =
{ "&" => "&", "<" => "<", ">" => ">", '"' => """, "'" => "'" }.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
-
#composition ⇒ Object
readonly
Returns the value of attribute composition.
Instance Method Summary collapse
-
#attribute_lines(voice, bar_number) ⇒ Object
private
-
#bar_numbers ⇒ Object
private
-
#clef_lines(voice) ⇒ Object
private
-
#component_lines(placement, component) ⇒ Object
private
-
#components_by_placement ⇒ Object
private
-
#divisions ⇒ Object
private
-
#document_lines ⇒ Object
private
-
#duration_writer ⇒ Object
private
-
#effective_meter(bar_number) ⇒ Object
private
-
#ensure_contiguous_voices ⇒ Object
private
-
#ensure_notes_within_barlines ⇒ Object
private
-
#ensure_renderable_text ⇒ Object
private
-
#ensure_voices ⇒ Object
private
-
#escape(text) ⇒ Object
private
-
#first_measure_attribute_lines(voice) ⇒ Object
private
-
#first_measure_key ⇒ Object
private
-
#first_measure_meter ⇒ Object
private
-
#identification_lines ⇒ Object
private
-
#initialize(composition) ⇒ Writer
constructor
A new instance of Writer.
-
#key_element_values(key_signature) ⇒ Object
private
-
#key_lines(key) ⇒ Object
private
-
#measure_content_lines(voice, bar_number) ⇒ Object
private
-
#measure_key_changes ⇒ Object
private
-
#measure_lines(voice, bar_number) ⇒ Object
private
-
#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.
-
#measure_time_changes ⇒ Object
private
-
#memoize_render_data ⇒ Object
private
Everything else that can raise is computed here, before assembly starts, and memoized for reuse during assembly.
-
#normalize_bar_markers ⇒ Object
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.
-
#notation_lines(placement, component) ⇒ Object
private
-
#note_lines(placement) ⇒ Object
private
-
#part_lines ⇒ Object
private
-
#part_list_lines ⇒ Object
private
-
#part_name(voice, index) ⇒ Object
private
-
#pitch_lines(pitch) ⇒ Object
private
-
#placements_by_bar(voice) ⇒ Object
private
-
#raise_gap_error(voice, expected_position, found_placement) ⇒ Object
private
-
#tie_lines(placement, component) ⇒ Object
private
Rests take no tie elements; the links of a rest’s tied chain render as consecutive independent rests.
-
#time_lines(meter) ⇒ Object
private
-
#to_s ⇒ Object
-
#validate! ⇒ Object
private
-
#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.
-
#whole_measure_rest_lines(bar_number) ⇒ Object
private
-
#work_lines ⇒ Object
private
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
#composition ⇒ Object (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, ) return first_measure_attribute_lines(voice) if == .first key = measure_key_changes[] meter = measure_time_changes[] return [] unless key || meter [ "#{INDENT * 3}<attributes>", *(key ? key_lines(key) : []), *(meter ? time_lines(meter) : []), "#{INDENT * 3}</attributes>" ] end |
#bar_numbers ⇒ Object (private)
123 124 125 |
# File 'lib/head_music/notation/music_xml/writer.rb', line 123 def composition...composition. 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_placement ⇒ Object (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 |
#divisions ⇒ Object (private)
109 110 111 |
# File 'lib/head_music/notation/music_xml/writer.rb', line 109 def divisions @divisions ||= Divisions.for(composition) end |
#document_lines ⇒ Object (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_writer ⇒ Object (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() = measure_time_changes.keys.select { |number| number <= }.max ? measure_time_changes[] : composition.meter end |
#ensure_contiguous_voices ⇒ Object (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_barlines ⇒ Object (private)
87 88 89 90 91 92 93 94 95 96 |
# File 'lib/head_music/notation/music_xml/writer.rb', line 87 def composition.voices.each do |voice| voice.placements.each do |placement| next unless placement.next_position > placement.position. raise RenderError, "the note at #{placement.position} crosses its barline; " \ "splitting notes across barlines is not supported" end end end |
#ensure_renderable_text ⇒ Object (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_voices ⇒ Object (private)
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_key ⇒ Object (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[.first] || key_element_values(composition.key_signature) end |
#first_measure_meter ⇒ Object (private)
148 149 150 |
# File 'lib/head_music/notation/music_xml/writer.rb', line 148 def first_measure_meter @first_measure_meter ||= effective_meter(.first) end |
#identification_lines ⇒ Object (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, ) placements = (voice)[] return whole_measure_rest_lines() unless placements placements.flat_map { |placement| note_lines(placement) } end |
#measure_key_changes ⇒ Object (private)
127 128 129 130 131 |
# File 'lib/head_music/notation/music_xml/writer.rb', line 127 def measure_key_changes @measure_key_changes ||= .zip(composition.).filter_map { |, | [, key_element_values(.key_signature)] if .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, ) [ measure_open_tag(), *attribute_lines(voice, ), *measure_content_lines(voice, ), "#{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() implicit = ( < 1) ? %( implicit="yes") : "" %(#{INDENT * 2}<measure number="#{}"#{implicit}>) end |
#measure_time_changes ⇒ Object (private)
133 134 135 136 137 |
# File 'lib/head_music/notation/music_xml/writer.rb', line 133 def measure_time_changes @measure_time_changes ||= .zip(composition.).filter_map { |, | [, .meter] if .meter }.to_h end |
#memoize_render_data ⇒ Object (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_markers ⇒ Object (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 composition..each do || .meter = HeadMusic::Rudiment::Meter.get(.meter) if .meter .key_signature = HeadMusic::Rudiment::KeySignature.get(.key_signature) if .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_lines ⇒ Object (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}">), *.flat_map { || measure_lines(voice, ) }, "#{INDENT}</part>" ] end end |
#part_list_lines ⇒ Object (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 (voice) @placements_by_bar ||= {} @placements_by_bar[voice] ||= voice.placements.group_by { |placement| placement.position. } end |
#raise_gap_error(voice, expected_position, found_placement) ⇒ Object (private)
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_s ⇒ Object
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 ensure_renderable_text ensure_contiguous_voices 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() meter = effective_meter() (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() [ "#{INDENT * 3}<note>", %(#{INDENT * 4}<rest measure="yes"/>), "#{INDENT * 4}<duration>#{whole_measure_duration()}</duration>", "#{INDENT * 3}</note>" ] end |