Class: HeadMusic::Notation::ABC::Writer

Inherits:
Object
  • Object
show all
Includes:
PlacementValidation, PreflightChecks
Defined in:
lib/head_music/notation/abc/writer.rb

Overview

Renders a HeadMusic::Content::Composition as an ABC tune string.

Whole-composition problems (multiple voices, mid-piece meter or key changes, positional gaps) raise before any string assembly, and #to_s only returns a fully assembled document, so callers never receive a truncated tune.

Repeat barlines and voltas are deliberately not rendered; bars carrying repeat flags degrade to plain bar lines.

Constant Summary collapse

UNIT_NOTE_LENGTH =

A fixed unit note length keeps the L: field and the duration multiplier arithmetic in sync.

Rational(1, 8)
BARS_PER_LINE =
4

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(composition, reference_number: 1) ⇒ Writer

Returns a new instance of Writer.



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

def initialize(composition, reference_number: 1)
  @composition = composition
  @reference_number = reference_number
end

Instance Attribute Details

#compositionObject (readonly)

Returns the value of attribute composition.



21
22
23
# File 'lib/head_music/notation/abc/writer.rb', line 21

def composition
  @composition
end

#reference_numberObject (readonly)

Returns the value of attribute reference_number.



21
22
23
# File 'lib/head_music/notation/abc/writer.rb', line 21

def reference_number
  @reference_number
end

Instance Method Details

#bar_stringsObject (private)



99
100
101
# File 'lib/head_music/notation/abc/writer.rb', line 99

def bar_strings
  @bar_strings ||= build_bar_strings
end

#body_linesObject (private)



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

def body_lines
  return [] if bar_strings.empty?

  lines = bar_strings.each_slice(BARS_PER_LINE).map do |line_bars|
    line_bars.join("|") + "|"
  end
  lines[-1] = lines[-1].sub(/\|\z/, "|]")
  lines
end

#build_bar_stringsObject (private)



103
104
105
106
107
108
109
110
111
# File 'lib/head_music/notation/abc/writer.rb', line 103

def build_bar_strings
  pitch_writer = PitchWriter.new(composition.key_signature)
  duration_writer = DurationWriter.new(UNIT_NOTE_LENGTH)
  placements_by_bar.each_with_index.map do |bar_placements, index|
    # Accidental state must mirror what a re-parse accumulates bar by bar.
    pitch_writer.start_new_bar if index.positive?
    render_bar(bar_placements, pitch_writer, duration_writer)
  end
end

#chord_token(placement, pitch_writer, multiplier) ⇒ Object (private)



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

def chord_token(placement, pitch_writer, multiplier)
  # Pitches are emitted low-to-high, and the oracle sees them in that
  # same order, so the writer's bar-accidental state cannot diverge from
  # what a re-parse of the emitted brackets accumulates.
  pitch_tokens = placement.pitches.sort.map { |pitch| pitch_writer.token(pitch) }
  "[#{pitch_tokens.join}]#{multiplier}"
end

#ensure_contiguous_voices(composition) ⇒ Object (private) Originally defined in module PreflightChecks

#ensure_no_mid_piece_changesObject (private)



47
48
49
50
51
52
53
54
55
56
57
# File 'lib/head_music/notation/abc/writer.rb', line 47

def ensure_no_mid_piece_changes
  composition.bars.each_with_index do |bar, index|
    bar_number = composition.earliest_bar_number + index
    if bar.meter
      raise RenderError, "cannot render the meter change at bar #{bar_number} in ABC output"
    end
    next unless bar.key_signature

    raise RenderError, "cannot render the key signature change at bar #{bar_number} in ABC output"
  end
end

#ensure_notes_within_barlines(composition) ⇒ Object (private) Originally defined in module PreflightChecks

#ensure_pitched_sounds(placement) ⇒ Object (private) Originally defined in module PlacementValidation

Raises:

  • (render_error_class)

#ensure_single_voiceObject (private)

Raises:



41
42
43
44
45
# File 'lib/head_music/notation/abc/writer.rb', line 41

def ensure_single_voice
  return if composition.voices.length <= 1

  raise RenderError, "multi-voice ABC output is not supported"
end

#header_linesObject (private)



64
65
66
67
68
69
70
71
72
73
74
# File 'lib/head_music/notation/abc/writer.rb', line 64

def header_lines
  [
    "X:#{reference_number}",
    "T:#{composition.name}",
    optional_field("C", composition.composer),
    optional_field("O", composition.origin),
    "M:#{composition.meter}",
    unit_note_length_field,
    key_field
  ].compact
end

#join_bar_tokens(placements, tokens) ⇒ Object (private)

Suppresses the inter-token space only where the following placement was authored as beamed to its predecessor (beam_break_before == false). A true or nil flag keeps the space, so programmatic (nil-flag) compositions render with today’s every-token spacing. Every bar token (note, rest, or [..] chord) re-lexes unambiguously with no separator, so dropping the space is safe.



130
131
132
133
134
135
# File 'lib/head_music/notation/abc/writer.rb', line 130

def join_bar_tokens(placements, tokens)
  tokens.each_with_index.reduce(+"") do |line, (token, index)|
    separator = (index.zero? || placements[index].beam_break_before == false) ? "" : " "
    line << separator << token
  end
end

#key_fieldObject (private)



84
85
86
87
# File 'lib/head_music/notation/abc/writer.rb', line 84

def key_field
  # The parser requires K: to terminate the header.
  "K:#{KeyMapper.abc_value(composition.key_signature)}"
end

#optional_field(letter, value) ⇒ Object (private)



76
77
78
# File 'lib/head_music/notation/abc/writer.rb', line 76

def optional_field(letter, value)
  "#{letter}:#{value}" if value
end

#placementsObject (private)



59
60
61
62
# File 'lib/head_music/notation/abc/writer.rb', line 59

def placements
  voice = composition.voices.first
  voice ? voice.placements : []
end

#placements_by_barObject (private)



113
114
115
116
117
# File 'lib/head_music/notation/abc/writer.rb', line 113

def placements_by_bar
  placements.chunk_while do |previous, current|
    previous.position.bar_number == current.position.bar_number
  end
end

#raise_gap_error(voice, expected_position, found_placement) ⇒ Object (private) Originally defined in module PreflightChecks

Raises:

  • (render_error_class)

#render_bar(bar_placements, pitch_writer, duration_writer) ⇒ Object (private)



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

def render_bar(bar_placements, pitch_writer, duration_writer)
  tokens = bar_placements.map { |placement| token(placement, pitch_writer, duration_writer) }
  join_bar_tokens(bar_placements, tokens)
end

#render_error_classObject (private)



155
156
157
# File 'lib/head_music/notation/abc/writer.rb', line 155

def render_error_class
  RenderError
end

#to_sObject



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

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

#token(placement, pitch_writer, duration_writer) ⇒ Object (private)



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

def token(placement, pitch_writer, duration_writer)
  ensure_pitched_sounds(placement)

  multiplier = duration_writer.multiplier_string(placement.rhythmic_value)
  return "z#{multiplier}" if placement.rest?
  return chord_token(placement, pitch_writer, multiplier) if placement.chord?

  "#{pitch_writer.token(placement.pitch)}#{multiplier}"
end

#unit_note_length_fieldObject (private)



80
81
82
# File 'lib/head_music/notation/abc/writer.rb', line 80

def unit_note_length_field
  "L:#{UNIT_NOTE_LENGTH.numerator}/#{UNIT_NOTE_LENGTH.denominator}"
end

#validate!Object (private)



35
36
37
38
39
# File 'lib/head_music/notation/abc/writer.rb', line 35

def validate!
  ensure_single_voice
  ensure_no_mid_piece_changes
  ensure_contiguous_voices(composition)
end