Class: HeadMusic::Notation::ABC::Parser
- Inherits:
-
Object
- Object
- HeadMusic::Notation::ABC::Parser
- 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
-
#duration_resolver ⇒ Object
readonly
private
Returns the value of attribute duration_resolver.
-
#header ⇒ Object
readonly
private
Returns the value of attribute header.
Instance Method Summary collapse
-
#build_composition ⇒ Object
private
Per-voice interpretation state and note-assembly live in VoiceState.
-
#chord_reader ⇒ Object
private
-
#composition ⇒ Object
-
#current_state ⇒ Object
private
-
#ensure_not_awaiting_note(token, state: current_state) ⇒ Object
private
-
#finish ⇒ Object
private
-
#handle(token) ⇒ Object
private
-
#handle_bar_line(token) ⇒ Object
private
-
#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.
-
#handle_broken_rhythm(token) ⇒ Object
private
-
#handle_chord(token) ⇒ Object
private
-
#handle_note(token) ⇒ Object
private
-
#handle_rest(token) ⇒ Object
private
-
#handle_tie(token) ⇒ Object
private
A tie (
-) after a note or chord fuses it to the next note of the same pitch. -
#handle_voice_change(token) ⇒ Object
private
-
#handle_volta(token) ⇒ Object
private
-
#initialize(abc_string, start_line: 1) ⇒ Parser
constructor
start_line offsets reported line numbers, so a tune parsed out of a larger book raises errors with book-relative line numbers.
-
#interpret(tokens) ⇒ Object
private
-
#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.
-
#repeat_tagger ⇒ Object
private
-
#setup_voices(tokens) ⇒ Object
private
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_composition ⇒ 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_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_reader ⇒ Object (private)
202 203 204 |
# File 'lib/head_music/notation/abc/parser.rb', line 202 def chord_reader @chord_reader ||= ChordReader.new(duration_resolver) end |
#composition ⇒ Object
24 25 26 |
# File 'lib/head_music/notation/abc/parser.rb', line 24 def composition @composition ||= build_composition 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)
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 |
#finish ⇒ Object (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.(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 (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 (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.(state, style) state.pitch_builder. 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)
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.
124 125 126 127 128 |
# File 'lib/head_music/notation/abc/parser.rb', line 124 def reject_open_tie(state, line, ) return unless state&.tie_open? raise ParseError.new(, line_number: line || state.tie_line, snippet: "-") end |
#repeat_tagger ⇒ Object (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 |