Class: HeadMusic::Notation::ABC::Parser

Inherits:
Object
  • Object
show all
Defined in:
lib/head_music/notation/abc/parser.rb

Overview

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

Everything that can be validated up front (blank input, header problems, lexing errors, unsupported features) raises before the composition is constructed, so callers never receive a reference to a partially built composition.

Constant Summary collapse

BROKEN_RHYTHM_SCALES =

Broken-rhythm scales: the mark’s side gets the dot (x 3/2) and the other side is halved.

{
  :> => [Rational(3, 2), Rational(1, 2)],
  :< => [Rational(1, 2), Rational(3, 2)]
}.freeze

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(abc_string, start_line: 1) ⇒ Parser

start_line offsets reported line numbers, so a tune parsed out of a larger book raises errors with book-relative line numbers.



19
20
21
22
# File 'lib/head_music/notation/abc/parser.rb', line 19

def initialize(abc_string, start_line: 1)
  @abc_string = abc_string
  @start_line = start_line
end

Instance Attribute Details

#duration_resolverObject (readonly, private)

Returns the value of attribute duration_resolver.



30
31
32
# File 'lib/head_music/notation/abc/parser.rb', line 30

def duration_resolver
  @duration_resolver
end

#headerObject (readonly, private)

Returns the value of attribute header.



30
31
32
# File 'lib/head_music/notation/abc/parser.rb', line 30

def header
  @header
end

Instance Method Details

#build_compositionObject (private)

Per-voice interpretation state and note-assembly live in VoiceState.



34
35
36
37
38
39
40
41
42
# File 'lib/head_music/notation/abc/parser.rb', line 34

def build_composition
  Preflight.ensure_input_present(@abc_string)
  @header = Header.new(@abc_string, start_line: @start_line)
  Preflight.reject_content_after_tune(header)
  tokens = BodyLexer.new(header.body, start_line: header.body_start_line).tokens
  Preflight.reject_unsupported_tokens(tokens)
  @duration_resolver = DurationResolver.new(header.unit_note_length)
  interpret(tokens)
end

#chord_readerObject (private)



202
203
204
# File 'lib/head_music/notation/abc/parser.rb', line 202

def chord_reader
  @chord_reader ||= ChordReader.new(duration_resolver)
end

#compositionObject



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

def composition
  @composition ||= build_composition
end

#current_stateObject (private)



64
65
66
# File 'lib/head_music/notation/abc/parser.rb', line 64

def current_state
  @voices.current
end

#ensure_not_awaiting_note(token, state: current_state) ⇒ Object (private)

Raises:



193
194
195
196
197
198
199
200
# File 'lib/head_music/notation/abc/parser.rb', line 193

def ensure_not_awaiting_note(token, state: current_state)
  return unless state.awaiting_scale

  raise ParseError.new(
    "Broken rhythm must be followed by a note",
    line_number: token&.line || state.broken_line
  )
end

#finishObject (private)



184
185
186
187
188
189
190
191
# File 'lib/head_music/notation/abc/parser.rb', line 184

def finish
  @voices.each do |state|
    ensure_not_awaiting_note(nil, state: state)
    reject_open_tie(state, nil, "A tie must be followed by a note")
    state.flush_pending_note
    repeat_tagger.tag_completed_bar(state)
  end
end

#handle(token) ⇒ Object (private)



68
69
70
71
72
73
74
75
76
77
78
79
80
# File 'lib/head_music/notation/abc/parser.rb', line 68

def handle(token)
  case token.type
  when :note then handle_note(token)
  when :chord then handle_chord(token)
  when :rest then handle_rest(token)
  when :tie then handle_tie(token)
  when :broken_rhythm then handle_broken_rhythm(token)
  when :bar_line then handle_bar_line(token)
  when :volta then handle_volta(token)
  when :voice_change then handle_voice_change(token)
  when :beam_break then handle_beam_break(token)
  end
end

#handle_bar_line(token) ⇒ Object (private)



148
149
150
151
152
153
154
155
156
157
# File 'lib/head_music/notation/abc/parser.rb', line 148

def handle_bar_line(token)
  ensure_not_awaiting_note(token)
  state = current_state
  style = token.style
  reject_open_tie(state, token.line, "Ties across barlines are not yet supported")
  state.flush_pending_note
  state.reset_beam_adjacency
  repeat_tagger.bar_line(state, style)
  state.pitch_builder.start_new_bar
end

#handle_beam_break(_token) ⇒ Object (private)

The lexer only emits :beam_break after a music token, so a voice state already exists; the flag is consumed by the next deferred note.



96
97
98
# File 'lib/head_music/notation/abc/parser.rb', line 96

def handle_beam_break(_token)
  current_state.mark_beam_break
end

#handle_broken_rhythm(token) ⇒ Object (private)



130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
# File 'lib/head_music/notation/abc/parser.rb', line 130

def handle_broken_rhythm(token)
  state = current_state
  line = token.line
  direction = token.direction
  reject_open_tie(state, line, "A tie must be followed by a note")
  pending = state.pending_note
  if state.awaiting_scale || pending.nil?
    raise ParseError.new(
      "Broken rhythm must appear between two notes",
      line_number: line, snippet: direction.to_s
    )
  end
  left_scale, right_scale = BROKEN_RHYTHM_SCALES.fetch(direction)
  state.pending_note = pending.with(scale: pending.scale * left_scale)
  state.awaiting_scale = right_scale
  state.broken_line = line
end

#handle_chord(token) ⇒ Object (private)



88
89
90
91
92
# File 'lib/head_music/notation/abc/parser.rb', line 88

def handle_chord(token)
  state = current_state
  pitches, inner_length = chord_reader.read(token, state.pitch_builder)
  state.defer_placement(pitches, token.length, inner_length)
end

#handle_note(token) ⇒ Object (private)



82
83
84
85
86
# File 'lib/head_music/notation/abc/parser.rb', line 82

def handle_note(token)
  state = current_state
  pitch = state.pitch_builder.pitch(token.letter, token.octave_marks, token.accidental)
  state.defer_placement([pitch], token.length)
end

#handle_rest(token) ⇒ Object (private)



112
113
114
115
116
117
118
119
# File 'lib/head_music/notation/abc/parser.rb', line 112

def handle_rest(token)
  ensure_not_awaiting_note(token)
  state = current_state
  reject_open_tie(state, token.line, "A tie must be followed by a note")
  state.flush_pending_note
  state.reset_beam_adjacency
  state.place(token.length, nil)
end

#handle_tie(token) ⇒ Object (private)

A tie (-) after a note or chord fuses it to the next note of the same pitch. The left note stays pending; the tie is only closed once its right note arrives.



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

def handle_tie(token)
  state = current_state
  line = token.line
  if state.awaiting_scale || state.pending_note.nil?
    raise ParseError.new("A tie must follow a note", line_number: line, snippet: "-")
  end
  state.open_tie(line)
end

#handle_voice_change(token) ⇒ Object (private)



172
173
174
175
176
177
178
179
180
181
182
# File 'lib/head_music/notation/abc/parser.rb', line 172

def handle_voice_change(token)
  # Guarded so a leading V: line doesn't force a default voice into existence.
  if @voices.any?
    state = current_state
    ensure_not_awaiting_note(token, state: state)
    reject_open_tie(state, token.line, "A tie must be followed by a note")
    state.flush_pending_note
    state.reset_beam_adjacency
  end
  @voices.switch_to(token.voice_id)
end

#handle_volta(token) ⇒ Object (private)

Raises:



159
160
161
162
163
164
165
166
167
168
169
170
# File 'lib/head_music/notation/abc/parser.rb', line 159

def handle_volta(token)
  ensure_not_awaiting_note(token)
  passes = token.passes
  line = token.line
  raise ParseError.new("Volta has no passes", line_number: line) if passes.empty?

  state = current_state
  reject_open_tie(state, line, "A tie must be followed by a note")
  state.flush_pending_note
  state.reset_beam_adjacency
  repeat_tagger.open_volta(state, passes)
end

#interpret(tokens) ⇒ Object (private)



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

def interpret(tokens)
  @building = HeadMusic::Content::Composition.new(
    name: header.title,
    key_signature: header.key_signature,
    meter: header.meter,
    composer: header.composer,
    origin: header.origin,
    comments: header.annotations
  )
  setup_voices(tokens)
  tokens.each { |token| handle(token) }
  finish
  @building
end

#reject_open_tie(state, line, message) ⇒ Object (private)

A tie left open by a non-note terminator can never close, so each terminator rejects it. A bar line gets its own message: an author tie across a barline is a real, but not-yet-supported, request.

Raises:



124
125
126
127
128
# File 'lib/head_music/notation/abc/parser.rb', line 124

def reject_open_tie(state, line, message)
  return unless state&.tie_open?

  raise ParseError.new(message, line_number: line || state.tie_line, snippet: "-")
end

#repeat_taggerObject (private)



206
207
208
# File 'lib/head_music/notation/abc/parser.rb', line 206

def repeat_tagger
  @repeat_tagger ||= RepeatTagger.new(@building)
end

#setup_voices(tokens) ⇒ Object (private)



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

def setup_voices(tokens)
  @voices = VoiceRegistry.new(@building, header.key_signature, duration_resolver)
  @voices.declare(header.voice_ids, default: tokens.none? { |token| token.type == :voice_change })
end