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::Flow.

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

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_resolver ⇒ Object (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

#header ⇒ Object (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_flow ⇒ Object (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_flow
  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_reader ⇒ Object (private)



204
205
206
# File 'lib/head_music/notation/abc/parser.rb', line 204

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

#current_state ⇒ Object (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:



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

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

#finish ⇒ Object (private)



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

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

#flow ⇒ Object



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

def flow
  @flow ||= build_flow
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)

A note tied across the bar line is left pending rather than flushed, so the note after the bar line fuses onto it the way an in-bar tie does, keeping the pending note's pitch: the bar's accidental reset below would otherwise strip the sharp or flat the tie carries.



152
153
154
155
156
157
158
159
160
# File 'lib/head_music/notation/abc/parser.rb', line 152

def handle_bar_line(token)
  ensure_not_awaiting_note(token)
  state = current_state
  style = token.style
  state.flush_pending_note unless state.tie_open?
  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)



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

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:



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

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
  state.flush_pending_note unless state.tie_open?
  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::Flow.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 or volta is not a terminator: the tied note stays pending across it and closes on the next note.

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



208
209
210
# File 'lib/head_music/notation/abc/parser.rb', line 208

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