head_music 17.2.0 → 17.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +15 -0
- data/Gemfile.lock +1 -1
- data/lib/head_music/analysis/chord_analysis.rb +123 -0
- data/lib/head_music/analysis/circle.rb +11 -3
- data/lib/head_music/analysis/diatonic_interval.rb +14 -11
- data/lib/head_music/analysis/dyad/chord_implication.rb +101 -0
- data/lib/head_music/analysis/dyad.rb +24 -104
- data/lib/head_music/analysis/interval_cycle.rb +9 -3
- data/lib/head_music/analysis/pitch_collection.rb +33 -107
- data/lib/head_music/analysis/sonority.rb +11 -21
- data/lib/head_music/content/composition/hash_deserializer.rb +110 -0
- data/lib/head_music/content/composition/schema_values.rb +127 -0
- data/lib/head_music/content/composition.rb +0 -215
- data/lib/head_music/content/placement.rb +11 -42
- data/lib/head_music/content/sound_resolver.rb +54 -0
- data/lib/head_music/content/voice/melodic_line.rb +32 -0
- data/lib/head_music/content/voice/melodic_note_pair.rb +33 -0
- data/lib/head_music/content/voice.rb +7 -58
- data/lib/head_music/instruments/alternate_tuning.rb +4 -6
- data/lib/head_music/instruments/catalog_lookup.rb +18 -0
- data/lib/head_music/instruments/instrument.rb +17 -117
- data/lib/head_music/instruments/instrument_catalog.rb +48 -0
- data/lib/head_music/instruments/instrument_configuration.rb +4 -6
- data/lib/head_music/instruments/instrument_family.rb +3 -8
- data/lib/head_music/instruments/instrument_name.rb +49 -0
- data/lib/head_music/instruments/score_order.rb +52 -42
- data/lib/head_music/instruments/staff_profile.rb +71 -0
- data/lib/head_music/instruments/stringing.rb +4 -6
- data/lib/head_music/instruments/stringing_course.rb +4 -6
- data/lib/head_music/instruments/variant.rb +4 -6
- data/lib/head_music/named/locale.rb +32 -0
- data/lib/head_music/named/localized_name.rb +15 -0
- data/lib/head_music/named.rb +1 -45
- data/lib/head_music/notation/abc/body_lexer.rb +65 -24
- data/lib/head_music/notation/abc/duration_resolver.rb +11 -1
- data/lib/head_music/notation/abc/duration_writer.rb +1 -8
- data/lib/head_music/notation/abc/header.rb +27 -16
- data/lib/head_music/notation/abc/parser.rb +48 -159
- data/lib/head_music/notation/abc/preflight.rb +48 -0
- data/lib/head_music/notation/abc/voice_state.rb +161 -0
- data/lib/head_music/notation/abc/writer.rb +47 -16
- data/lib/head_music/notation/dotted_duration.rb +19 -0
- data/lib/head_music/notation/instrument_notation.rb +5 -7
- data/lib/head_music/notation/music_xml/beam_grouper.rb +108 -0
- data/lib/head_music/notation/music_xml/duration_writer.rb +3 -8
- data/lib/head_music/notation/music_xml/preflight.rb +90 -0
- data/lib/head_music/notation/music_xml/render_plan.rb +161 -0
- data/lib/head_music/notation/music_xml/writer.rb +31 -146
- data/lib/head_music/notation/placement_validation.rb +19 -0
- data/lib/head_music/rudiment/enharmonic_equivalence.rb +33 -0
- data/lib/head_music/rudiment/key.rb +8 -36
- data/lib/head_music/rudiment/key_signature/enharmonic_equivalence.rb +21 -15
- data/lib/head_music/rudiment/key_signature.rb +18 -19
- data/lib/head_music/rudiment/meter.rb +39 -6
- data/lib/head_music/rudiment/mode.rb +33 -65
- data/lib/head_music/rudiment/note.rb +18 -21
- data/lib/head_music/rudiment/pitch/enharmonic_equivalence.rb +4 -15
- data/lib/head_music/rudiment/pitch/natural_step.rb +36 -0
- data/lib/head_music/rudiment/pitch/parser.rb +16 -5
- data/lib/head_music/rudiment/pitch.rb +11 -33
- data/lib/head_music/rudiment/qualified_diatonic_context.rb +61 -0
- data/lib/head_music/rudiment/rhythmic_unit/parser.rb +16 -12
- data/lib/head_music/rudiment/rhythmic_unit.rb +21 -37
- data/lib/head_music/rudiment/rhythmic_value/parser.rb +53 -46
- data/lib/head_music/rudiment/spelling/enharmonic_equivalence.rb +13 -0
- data/lib/head_music/rudiment/spelling.rb +0 -25
- data/lib/head_music/rudiment/tuning/just_intonation.rb +2 -4
- data/lib/head_music/rudiment/tuning/meantone.rb +2 -4
- data/lib/head_music/rudiment/tuning/pythagorean.rb +2 -4
- data/lib/head_music/rudiment/tuning/tonal_centered.rb +10 -0
- data/lib/head_music/rudiment/unpitched_note.rb +3 -5
- data/lib/head_music/style/annotation/configured.rb +35 -0
- data/lib/head_music/style/annotation.rb +32 -44
- data/lib/head_music/style/guidelines/allowed_rhythmic_values_for_fifth_species.rb +0 -10
- data/lib/head_music/style/guidelines/contoured.rb +65 -53
- data/lib/head_music/style/guidelines/dissonance_figure_detection.rb +55 -16
- data/lib/head_music/style/guidelines/florid_dissonance_treatment.rb +0 -37
- data/lib/head_music/style/guidelines/large_leaps.rb +10 -2
- data/lib/head_music/style/guidelines/minimum_melodic_intervals.rb +3 -25
- data/lib/head_music/style/guidelines/minimum_notes.rb +3 -25
- data/lib/head_music/style/guidelines/minimum_threshold.rb +38 -0
- data/lib/head_music/style/guidelines/no_parallel_perfect.rb +31 -0
- data/lib/head_music/style/guidelines/no_parallel_perfect_on_downbeats.rb +3 -17
- data/lib/head_music/style/guidelines/no_parallel_perfect_with_syncopation.rb +3 -17
- data/lib/head_music/style/guidelines/prepare_octave_leaps.rb +10 -7
- data/lib/head_music/style/guidelines/singable_intervals.rb +22 -8
- data/lib/head_music/style/guidelines/suspension_treatment.rb +30 -12
- data/lib/head_music/style/guidelines/third_species_dissonance_treatment.rb +0 -7
- data/lib/head_music/style/guidelines/triple_meter_dissonance_treatment.rb +0 -12
- data/lib/head_music/style/guidelines/weak_beat_dissonance_treatment.rb +2 -42
- data/lib/head_music/style/guides/fifth_species_melody.rb +3 -13
- data/lib/head_music/style/guides/fourth_species_melody.rb +3 -13
- data/lib/head_music/style/guides/second_species_harmony.rb +2 -5
- data/lib/head_music/style/guides/second_species_melody.rb +2 -12
- data/lib/head_music/style/guides/species_harmony.rb +14 -0
- data/lib/head_music/style/guides/species_melody.rb +21 -0
- data/lib/head_music/style/guides/third_species_harmony.rb +2 -5
- data/lib/head_music/style/guides/third_species_melody.rb +3 -13
- data/lib/head_music/style/guides/third_species_triple_meter_harmony.rb +2 -5
- data/lib/head_music/style/guides/third_species_triple_meter_melody.rb +2 -12
- data/lib/head_music/time/conductor.rb +19 -125
- data/lib/head_music/time/musical_position.rb +26 -26
- data/lib/head_music/time/musical_time_converter.rb +104 -0
- data/lib/head_music/time/smpte_converter.rb +51 -0
- data/lib/head_music/time/smpte_timecode.rb +25 -23
- data/lib/head_music/time.rb +2 -0
- data/lib/head_music/value_equality.rb +37 -0
- data/lib/head_music/version.rb +1 -1
- data/lib/head_music.rb +25 -1
- data/user-stories/done/abc-notation-beaming.md +235 -0
- data/user-stories/index.html +7 -3
- metadata +34 -2
|
@@ -34,85 +34,18 @@ module HeadMusic::Notation::ABC
|
|
|
34
34
|
|
|
35
35
|
attr_reader :header, :duration_resolver
|
|
36
36
|
|
|
37
|
-
#
|
|
38
|
-
# a broken-rhythm mark follows it. The pitches are computed eagerly
|
|
39
|
-
# so bar-line accidental resets cannot corrupt them. `tied_prefix`,
|
|
40
|
-
# when present, is the already-built rhythmic value of everything
|
|
41
|
-
# tied ahead of this note; its own value is appended at flush time.
|
|
42
|
-
PendingNote = Data.define(:pitches, :length, :scale, :tied_prefix) do
|
|
43
|
-
def initialize(pitches:, length:, scale:, tied_prefix: nil)
|
|
44
|
-
super
|
|
45
|
-
end
|
|
46
|
-
end
|
|
47
|
-
|
|
48
|
-
# Per-voice interpretation state. Accidentals, the deferred note,
|
|
49
|
-
# and volta tracking are all independent between voices.
|
|
50
|
-
class VoiceState
|
|
51
|
-
attr_reader :voice, :pitch_builder
|
|
52
|
-
attr_accessor :pending_note, :awaiting_scale, :broken_line,
|
|
53
|
-
:active_passes, :volta_start_bar, :tie_open, :tie_line
|
|
54
|
-
|
|
55
|
-
def initialize(voice, pitch_builder)
|
|
56
|
-
@voice = voice
|
|
57
|
-
@pitch_builder = pitch_builder
|
|
58
|
-
end
|
|
59
|
-
|
|
60
|
-
def completed_bar_number
|
|
61
|
-
voice.last_placement&.position&.bar_number
|
|
62
|
-
end
|
|
63
|
-
|
|
64
|
-
def entered_bar_number
|
|
65
|
-
voice.next_position.bar_number
|
|
66
|
-
end
|
|
67
|
-
end
|
|
37
|
+
# Per-voice interpretation state and note-assembly live in VoiceState.
|
|
68
38
|
|
|
69
39
|
def build_composition
|
|
70
|
-
ensure_input_present
|
|
40
|
+
Preflight.ensure_input_present(@abc_string)
|
|
71
41
|
@header = Header.new(@abc_string, start_line: @start_line)
|
|
72
|
-
reject_content_after_tune
|
|
42
|
+
Preflight.reject_content_after_tune(header)
|
|
73
43
|
tokens = BodyLexer.new(header.body, start_line: header.body_start_line).tokens
|
|
74
|
-
reject_unsupported_tokens(tokens)
|
|
44
|
+
Preflight.reject_unsupported_tokens(tokens)
|
|
75
45
|
@duration_resolver = DurationResolver.new(header.unit_note_length)
|
|
76
46
|
interpret(tokens)
|
|
77
47
|
end
|
|
78
48
|
|
|
79
|
-
def ensure_input_present
|
|
80
|
-
return unless @abc_string.to_s.strip.empty?
|
|
81
|
-
|
|
82
|
-
raise ParseError, "ABC input is blank"
|
|
83
|
-
end
|
|
84
|
-
|
|
85
|
-
# The lexer treats a blank line as the end of the tune, so anything
|
|
86
|
-
# after it would be silently dropped — most likely another tune.
|
|
87
|
-
def reject_content_after_tune
|
|
88
|
-
lines = header.body.lines
|
|
89
|
-
blank_index = lines.find_index { |line| line.strip.empty? }
|
|
90
|
-
return unless blank_index
|
|
91
|
-
|
|
92
|
-
extra_lines = lines[(blank_index + 1)..]
|
|
93
|
-
extra_index = extra_lines.find_index do |line|
|
|
94
|
-
stripped = line.strip
|
|
95
|
-
!stripped.empty? && !stripped.start_with?("%")
|
|
96
|
-
end
|
|
97
|
-
return unless extra_index
|
|
98
|
-
|
|
99
|
-
raise ParseError.new(
|
|
100
|
-
"Content after the tune body; parse a book of tunes with ABC.parse_book",
|
|
101
|
-
line_number: header.body_start_line + blank_index + 1 + extra_index,
|
|
102
|
-
snippet: extra_lines[extra_index].strip[0, BodyLexer::SNIPPET_LENGTH]
|
|
103
|
-
)
|
|
104
|
-
end
|
|
105
|
-
|
|
106
|
-
def reject_unsupported_tokens(tokens)
|
|
107
|
-
token = tokens.find { |candidate| candidate.type == :unsupported }
|
|
108
|
-
return unless token
|
|
109
|
-
|
|
110
|
-
raise UnsupportedFeatureError.new(
|
|
111
|
-
"Unsupported ABC feature #{token.lexeme.inspect}",
|
|
112
|
-
line_number: token.line, snippet: token.lexeme
|
|
113
|
-
)
|
|
114
|
-
end
|
|
115
|
-
|
|
116
49
|
def interpret(tokens)
|
|
117
50
|
@building = HeadMusic::Content::Composition.new(
|
|
118
51
|
name: header.title,
|
|
@@ -140,7 +73,8 @@ module HeadMusic::Notation::ABC
|
|
|
140
73
|
def voice_state(role)
|
|
141
74
|
@voice_states[role] ||= VoiceState.new(
|
|
142
75
|
@building.add_voice(role: role),
|
|
143
|
-
PitchBuilder.new(header.key_signature)
|
|
76
|
+
PitchBuilder.new(header.key_signature),
|
|
77
|
+
duration_resolver
|
|
144
78
|
)
|
|
145
79
|
end
|
|
146
80
|
|
|
@@ -159,13 +93,14 @@ module HeadMusic::Notation::ABC
|
|
|
159
93
|
when :bar_line then handle_bar_line(token)
|
|
160
94
|
when :volta then handle_volta(token)
|
|
161
95
|
when :voice_change then handle_voice_change(token)
|
|
96
|
+
when :beam_break then handle_beam_break(token)
|
|
162
97
|
end
|
|
163
98
|
end
|
|
164
99
|
|
|
165
100
|
def handle_note(token)
|
|
166
101
|
state = current_state
|
|
167
102
|
pitch = state.pitch_builder.pitch(token.letter, token.octave_marks, token.accidental)
|
|
168
|
-
defer_placement(
|
|
103
|
+
state.defer_placement([pitch], token.length)
|
|
169
104
|
end
|
|
170
105
|
|
|
171
106
|
# Chord pitches resolve in bracket order, so an explicit accidental
|
|
@@ -177,7 +112,7 @@ module HeadMusic::Notation::ABC
|
|
|
177
112
|
end
|
|
178
113
|
ensure_unique_chord_pitches(pitches, token)
|
|
179
114
|
inner_length = uniform_chord_length(token)
|
|
180
|
-
defer_placement(
|
|
115
|
+
state.defer_placement(pitches, token.length, inner_length)
|
|
181
116
|
end
|
|
182
117
|
|
|
183
118
|
def ensure_unique_chord_pitches(pitches, token)
|
|
@@ -211,13 +146,10 @@ module HeadMusic::Notation::ABC
|
|
|
211
146
|
"[#{inner}]"
|
|
212
147
|
end
|
|
213
148
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
flush_pending_note(state)
|
|
220
|
-
state.pending_note = PendingNote.new(pitches: pitches, length: length, scale: scale)
|
|
149
|
+
# The lexer only emits :beam_break after a music token, so a voice
|
|
150
|
+
# state already exists; the flag is consumed by the next deferred note.
|
|
151
|
+
def handle_beam_break(_token)
|
|
152
|
+
current_state.mark_beam_break
|
|
221
153
|
end
|
|
222
154
|
|
|
223
155
|
# A tie (`-`) after a note or chord fuses it to the next note of the
|
|
@@ -225,90 +157,73 @@ module HeadMusic::Notation::ABC
|
|
|
225
157
|
# its right note arrives.
|
|
226
158
|
def handle_tie(token)
|
|
227
159
|
state = current_state
|
|
160
|
+
line = token.line
|
|
228
161
|
if state.awaiting_scale || state.pending_note.nil?
|
|
229
|
-
raise ParseError.new(
|
|
230
|
-
"A tie must follow a note", line_number: token.line, snippet: "-"
|
|
231
|
-
)
|
|
162
|
+
raise ParseError.new("A tie must follow a note", line_number: line, snippet: "-")
|
|
232
163
|
end
|
|
233
|
-
state.
|
|
234
|
-
state.tie_line = token.line
|
|
235
|
-
end
|
|
236
|
-
|
|
237
|
-
# Closes an open tie: the pending note becomes the new note's tied
|
|
238
|
-
# prefix, so the pair (and any longer chain) resolves to a single
|
|
239
|
-
# placement whose rhythmic value carries the author's chosen split.
|
|
240
|
-
def tie_onto_pending(state, pitches, length, scale)
|
|
241
|
-
pending = state.pending_note
|
|
242
|
-
ensure_tie_pitches_match(state, pending, pitches)
|
|
243
|
-
prefix = pending_rhythmic_value(pending)
|
|
244
|
-
state.tie_open = false
|
|
245
|
-
state.tie_line = nil
|
|
246
|
-
state.pending_note = PendingNote.new(
|
|
247
|
-
pitches: pitches, length: length, scale: scale, tied_prefix: prefix
|
|
248
|
-
)
|
|
249
|
-
end
|
|
250
|
-
|
|
251
|
-
def ensure_tie_pitches_match(state, pending, pitches)
|
|
252
|
-
return if pending.pitches.sort == pitches.sort
|
|
253
|
-
|
|
254
|
-
raise ParseError.new(
|
|
255
|
-
"A tie must connect two notes of the same pitch",
|
|
256
|
-
line_number: state.tie_line, snippet: "-"
|
|
257
|
-
)
|
|
164
|
+
state.open_tie(line)
|
|
258
165
|
end
|
|
259
166
|
|
|
260
167
|
def handle_rest(token)
|
|
261
168
|
ensure_not_awaiting_note(token)
|
|
262
169
|
state = current_state
|
|
263
170
|
reject_open_tie(state, token.line, "A tie must be followed by a note")
|
|
264
|
-
flush_pending_note
|
|
265
|
-
|
|
171
|
+
state.flush_pending_note
|
|
172
|
+
state.reset_beam_adjacency
|
|
173
|
+
state.place(token.length, nil)
|
|
266
174
|
end
|
|
267
175
|
|
|
268
176
|
# A tie left open by a non-note terminator can never close, so each
|
|
269
177
|
# terminator rejects it. A bar line gets its own message: an author
|
|
270
178
|
# tie across a barline is a real, but not-yet-supported, request.
|
|
271
179
|
def reject_open_tie(state, line, message)
|
|
272
|
-
return unless state&.tie_open
|
|
180
|
+
return unless state&.tie_open?
|
|
273
181
|
|
|
274
182
|
raise ParseError.new(message, line_number: line || state.tie_line, snippet: "-")
|
|
275
183
|
end
|
|
276
184
|
|
|
277
185
|
def handle_broken_rhythm(token)
|
|
278
186
|
state = current_state
|
|
279
|
-
|
|
280
|
-
|
|
187
|
+
line = token.line
|
|
188
|
+
direction = token.direction
|
|
189
|
+
reject_open_tie(state, line, "A tie must be followed by a note")
|
|
190
|
+
pending = state.pending_note
|
|
191
|
+
if state.awaiting_scale || pending.nil?
|
|
281
192
|
raise ParseError.new(
|
|
282
193
|
"Broken rhythm must appear between two notes",
|
|
283
|
-
line_number:
|
|
194
|
+
line_number: line, snippet: direction.to_s
|
|
284
195
|
)
|
|
285
196
|
end
|
|
286
|
-
left_scale, right_scale = BROKEN_RHYTHM_SCALES.fetch(
|
|
287
|
-
state.pending_note =
|
|
197
|
+
left_scale, right_scale = BROKEN_RHYTHM_SCALES.fetch(direction)
|
|
198
|
+
state.pending_note = pending.with(scale: pending.scale * left_scale)
|
|
288
199
|
state.awaiting_scale = right_scale
|
|
289
|
-
state.broken_line =
|
|
200
|
+
state.broken_line = line
|
|
290
201
|
end
|
|
291
202
|
|
|
292
203
|
def handle_bar_line(token)
|
|
293
204
|
ensure_not_awaiting_note(token)
|
|
294
205
|
state = current_state
|
|
206
|
+
style = token.style
|
|
295
207
|
reject_open_tie(state, token.line, "Ties across barlines are not yet supported")
|
|
296
|
-
flush_pending_note
|
|
208
|
+
state.flush_pending_note
|
|
209
|
+
state.reset_beam_adjacency
|
|
297
210
|
tag_completed_bar(state)
|
|
298
|
-
apply_repeat_flags(state,
|
|
299
|
-
clear_passes_if_over(state,
|
|
211
|
+
apply_repeat_flags(state, style)
|
|
212
|
+
clear_passes_if_over(state, style)
|
|
300
213
|
state.pitch_builder.start_new_bar
|
|
301
214
|
end
|
|
302
215
|
|
|
303
216
|
def handle_volta(token)
|
|
304
217
|
ensure_not_awaiting_note(token)
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
218
|
+
passes = token.passes
|
|
219
|
+
line = token.line
|
|
220
|
+
raise ParseError.new("Volta has no passes", line_number: line) if passes.empty?
|
|
221
|
+
|
|
308
222
|
state = current_state
|
|
309
|
-
reject_open_tie(state,
|
|
310
|
-
flush_pending_note
|
|
311
|
-
state.
|
|
223
|
+
reject_open_tie(state, line, "A tie must be followed by a note")
|
|
224
|
+
state.flush_pending_note
|
|
225
|
+
state.reset_beam_adjacency
|
|
226
|
+
state.active_passes = passes
|
|
312
227
|
state.volta_start_bar = state.entered_bar_number
|
|
313
228
|
end
|
|
314
229
|
|
|
@@ -317,7 +232,8 @@ module HeadMusic::Notation::ABC
|
|
|
317
232
|
if @current_state
|
|
318
233
|
ensure_not_awaiting_note(token, state: @current_state)
|
|
319
234
|
reject_open_tie(@current_state, token.line, "A tie must be followed by a note")
|
|
320
|
-
|
|
235
|
+
@current_state.flush_pending_note
|
|
236
|
+
@current_state.reset_beam_adjacency
|
|
321
237
|
end
|
|
322
238
|
@current_state = voice_state(token.voice_id)
|
|
323
239
|
end
|
|
@@ -326,7 +242,7 @@ module HeadMusic::Notation::ABC
|
|
|
326
242
|
@voice_states.each_value do |state|
|
|
327
243
|
ensure_not_awaiting_note(nil, state: state)
|
|
328
244
|
reject_open_tie(state, nil, "A tie must be followed by a note")
|
|
329
|
-
flush_pending_note
|
|
245
|
+
state.flush_pending_note
|
|
330
246
|
tag_completed_bar(state)
|
|
331
247
|
end
|
|
332
248
|
end
|
|
@@ -340,34 +256,6 @@ module HeadMusic::Notation::ABC
|
|
|
340
256
|
)
|
|
341
257
|
end
|
|
342
258
|
|
|
343
|
-
def flush_pending_note(state)
|
|
344
|
-
pending = state.pending_note
|
|
345
|
-
return unless pending
|
|
346
|
-
|
|
347
|
-
state.pending_note = nil
|
|
348
|
-
state.voice.place(state.voice.next_position, pending_rhythmic_value(pending), pending.pitches)
|
|
349
|
-
end
|
|
350
|
-
|
|
351
|
-
# A pending note's own value, with any tied prefix appended ahead of
|
|
352
|
-
# it so the whole tie chain renders as one sounding note.
|
|
353
|
-
def pending_rhythmic_value(pending)
|
|
354
|
-
own = duration_resolver.rhythmic_value(pending.length, scale: pending.scale)
|
|
355
|
-
pending.tied_prefix ? append_tied(pending.tied_prefix, own) : own
|
|
356
|
-
end
|
|
357
|
-
|
|
358
|
-
# Attaches `tail` at the deep end of `head`'s tied chain, rebuilding
|
|
359
|
-
# each link (RhythmicValue exposes no setter) so a chain like
|
|
360
|
-
# "half tied to eighth" gains a further "tied to quarter".
|
|
361
|
-
def append_tied(head, tail)
|
|
362
|
-
inner = head.tied_value ? append_tied(head.tied_value, tail) : tail
|
|
363
|
-
HeadMusic::Rudiment::RhythmicValue.new(head.unit, dots: head.dots, tied_value: inner)
|
|
364
|
-
end
|
|
365
|
-
|
|
366
|
-
def place(state, length, pitches, scale: Rational(1))
|
|
367
|
-
rhythmic_value = duration_resolver.rhythmic_value(length, scale: scale)
|
|
368
|
-
state.voice.place(state.voice.next_position, rhythmic_value, pitches)
|
|
369
|
-
end
|
|
370
|
-
|
|
371
259
|
def apply_repeat_flags(state, style)
|
|
372
260
|
if REPEAT_ENDING_STYLES.include?(style)
|
|
373
261
|
completed = state.completed_bar_number
|
|
@@ -381,12 +269,13 @@ module HeadMusic::Notation::ABC
|
|
|
381
269
|
# A volta covers every bar from its opening bracket through the bar
|
|
382
270
|
# line that ends it, so each completed bar in that span gets tagged.
|
|
383
271
|
def tag_completed_bar(state)
|
|
384
|
-
|
|
272
|
+
passes = state.active_passes
|
|
273
|
+
return unless passes
|
|
385
274
|
|
|
386
275
|
completed = state.completed_bar_number
|
|
387
276
|
return unless completed && completed >= state.volta_start_bar
|
|
388
277
|
|
|
389
|
-
bar(completed).plays_on_passes =
|
|
278
|
+
bar(completed).plays_on_passes = passes
|
|
390
279
|
end
|
|
391
280
|
|
|
392
281
|
def clear_passes_if_over(state, style)
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# A namespace for ABC-notation parsing helpers
|
|
2
|
+
module HeadMusic::Notation::ABC
|
|
3
|
+
# Boundary checks the Parser runs before it interprets a tune, so a caller
|
|
4
|
+
# never receives a reference to a partially built composition. Each check
|
|
5
|
+
# raises on the first problem it finds and is otherwise a no-op. They run at
|
|
6
|
+
# distinct points of construction — blank input before the header is read,
|
|
7
|
+
# trailing content once the header exists, unsupported tokens after lexing —
|
|
8
|
+
# so each takes exactly the input it inspects rather than sharing state.
|
|
9
|
+
class Preflight
|
|
10
|
+
def self.ensure_input_present(abc_string)
|
|
11
|
+
return unless abc_string.to_s.strip.empty?
|
|
12
|
+
|
|
13
|
+
raise ParseError, "ABC input is blank"
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# The lexer treats a blank line as the end of the tune, so anything
|
|
17
|
+
# after it would be silently dropped — most likely another tune.
|
|
18
|
+
def self.reject_content_after_tune(header)
|
|
19
|
+
lines = header.body.lines
|
|
20
|
+
blank_index = lines.find_index { |line| line.strip.empty? }
|
|
21
|
+
return unless blank_index
|
|
22
|
+
|
|
23
|
+
extra_lines = lines[(blank_index + 1)..]
|
|
24
|
+
extra_index = extra_lines.find_index do |line|
|
|
25
|
+
stripped = line.strip
|
|
26
|
+
!stripped.empty? && !stripped.start_with?("%")
|
|
27
|
+
end
|
|
28
|
+
return unless extra_index
|
|
29
|
+
|
|
30
|
+
raise ParseError.new(
|
|
31
|
+
"Content after the tune body; parse a book of tunes with ABC.parse_book",
|
|
32
|
+
line_number: header.body_start_line + blank_index + 1 + extra_index,
|
|
33
|
+
snippet: extra_lines[extra_index].strip[0, BodyLexer::SNIPPET_LENGTH]
|
|
34
|
+
)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def self.reject_unsupported_tokens(tokens)
|
|
38
|
+
token = tokens.find { |candidate| candidate.type == :unsupported }
|
|
39
|
+
return unless token
|
|
40
|
+
|
|
41
|
+
lexeme = token.lexeme
|
|
42
|
+
raise UnsupportedFeatureError.new(
|
|
43
|
+
"Unsupported ABC feature #{lexeme.inspect}",
|
|
44
|
+
line_number: token.line, snippet: lexeme
|
|
45
|
+
)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# A namespace for ABC-notation parsing helpers
|
|
2
|
+
module HeadMusic::Notation::ABC
|
|
3
|
+
# Per-voice interpretation state and the note-assembly it drives. Accidentals,
|
|
4
|
+
# the deferred note, ties, and volta tracking are all independent between
|
|
5
|
+
# voices, so each voice owns one of these. Beam adjacency and tie bookkeeping
|
|
6
|
+
# are exposed as transitions rather than raw setters, and the parser hands
|
|
7
|
+
# notes and chords here to be buffered, tied, and flushed onto the voice — the
|
|
8
|
+
# behavior that reads and mutates this state lives with the state itself.
|
|
9
|
+
class VoiceState
|
|
10
|
+
# The identity length scale, used wherever no stretching applies.
|
|
11
|
+
ONE = Rational(1)
|
|
12
|
+
|
|
13
|
+
# A note or chord whose placement is deferred until we know whether a
|
|
14
|
+
# broken-rhythm mark follows it. The pitches are computed eagerly so
|
|
15
|
+
# bar-line accidental resets cannot corrupt them. `tied_prefix`, when
|
|
16
|
+
# present, is the already-built rhythmic value of everything tied ahead of
|
|
17
|
+
# this note; its own value is appended at flush time.
|
|
18
|
+
PendingNote = Data.define(:pitches, :length, :scale, :tied_prefix, :beam_break) do
|
|
19
|
+
def initialize(pitches:, length:, scale:, tied_prefix: nil, beam_break: nil)
|
|
20
|
+
super
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
attr_reader :voice, :pitch_builder, :tie_line
|
|
25
|
+
attr_accessor :pending_note, :awaiting_scale, :broken_line,
|
|
26
|
+
:active_passes, :volta_start_bar
|
|
27
|
+
|
|
28
|
+
def initialize(voice, pitch_builder, duration_resolver = nil)
|
|
29
|
+
@voice = voice
|
|
30
|
+
@pitch_builder = pitch_builder
|
|
31
|
+
@duration_resolver = duration_resolver
|
|
32
|
+
@beam_break_pending = false
|
|
33
|
+
@beam_last_was_note = false
|
|
34
|
+
@tie_open = false
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def completed_bar_number
|
|
38
|
+
voice.last_placement&.position&.bar_number
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def entered_bar_number
|
|
42
|
+
voice.next_position.bar_number
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Records an explicit beam break; the next note consumes it.
|
|
46
|
+
def mark_beam_break
|
|
47
|
+
@beam_break_pending = true
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# The beam-break flag for the note now beginning: true if a break was
|
|
51
|
+
# marked, false if the previous token was also a note (so they beam
|
|
52
|
+
# together), or nil when there is no prior note to beam against.
|
|
53
|
+
# Consumes the pending break and records that a note is now current.
|
|
54
|
+
def next_beam_break
|
|
55
|
+
flag = if @beam_break_pending
|
|
56
|
+
true
|
|
57
|
+
elsif @beam_last_was_note
|
|
58
|
+
false
|
|
59
|
+
end
|
|
60
|
+
@beam_break_pending = false
|
|
61
|
+
@beam_last_was_note = true
|
|
62
|
+
flag
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# After a rest, bar line, volta, or voice change, the next note must
|
|
66
|
+
# not beam to whatever preceded the boundary.
|
|
67
|
+
def reset_beam_adjacency
|
|
68
|
+
@beam_last_was_note = false
|
|
69
|
+
@beam_break_pending = false
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def tie_open?
|
|
73
|
+
@tie_open
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def open_tie(line)
|
|
77
|
+
@tie_open = true
|
|
78
|
+
@tie_line = line
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def close_tie
|
|
82
|
+
@tie_open = false
|
|
83
|
+
@tie_line = nil
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Buffers a note or chord as the pending note, flushing whatever was
|
|
87
|
+
# pending first. A broken-rhythm scale awaiting its right note, and any
|
|
88
|
+
# per-chord inner scale, fold into the buffered length. When a tie is
|
|
89
|
+
# open the arriving note instead extends the pending tie chain.
|
|
90
|
+
def defer_placement(pitches, length, inner_scale = ONE)
|
|
91
|
+
scale = (awaiting_scale || ONE) * inner_scale
|
|
92
|
+
self.awaiting_scale = nil
|
|
93
|
+
return tie_onto_pending(pitches, length, scale) if tie_open?
|
|
94
|
+
|
|
95
|
+
flush_pending_note
|
|
96
|
+
self.pending_note = PendingNote.new(
|
|
97
|
+
pitches: pitches, length: length, scale: scale, beam_break: next_beam_break
|
|
98
|
+
)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Places the pending note onto the voice, if any, carrying its authored
|
|
102
|
+
# beam break onto the placement.
|
|
103
|
+
def flush_pending_note
|
|
104
|
+
pending = pending_note
|
|
105
|
+
return unless pending
|
|
106
|
+
|
|
107
|
+
self.pending_note = nil
|
|
108
|
+
placement = voice.place(voice.next_position, pending_rhythmic_value(pending), pending.pitches)
|
|
109
|
+
placement.beam_break_before = pending.beam_break
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Places a note, chord, or rest (nil pitches) directly onto the voice,
|
|
113
|
+
# bypassing the pending-note buffer.
|
|
114
|
+
def place(length, pitches, scale: ONE)
|
|
115
|
+
rhythmic_value = @duration_resolver.rhythmic_value(length, scale: scale)
|
|
116
|
+
voice.place(voice.next_position, rhythmic_value, pitches)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
private
|
|
120
|
+
|
|
121
|
+
# Closes an open tie: the pending note becomes the new note's tied prefix,
|
|
122
|
+
# so the pair (and any longer chain) resolves to a single placement whose
|
|
123
|
+
# rhythmic value carries the author's chosen split.
|
|
124
|
+
def tie_onto_pending(pitches, length, scale)
|
|
125
|
+
pending = pending_note
|
|
126
|
+
ensure_tie_pitches_match(pending, pitches)
|
|
127
|
+
prefix = pending_rhythmic_value(pending)
|
|
128
|
+
close_tie
|
|
129
|
+
self.pending_note = PendingNote.new(
|
|
130
|
+
pitches: pitches, length: length, scale: scale, tied_prefix: prefix,
|
|
131
|
+
beam_break: pending.beam_break
|
|
132
|
+
)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def ensure_tie_pitches_match(pending, pitches)
|
|
136
|
+
return if pending.pitches.sort == pitches.sort
|
|
137
|
+
|
|
138
|
+
raise ParseError.new(
|
|
139
|
+
"A tie must connect two notes of the same pitch",
|
|
140
|
+
line_number: tie_line, snippet: "-"
|
|
141
|
+
)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# A pending note's own value, with any tied prefix appended ahead of
|
|
145
|
+
# it so the whole tie chain renders as one sounding note.
|
|
146
|
+
def pending_rhythmic_value(pending)
|
|
147
|
+
own = @duration_resolver.rhythmic_value(pending.length, scale: pending.scale)
|
|
148
|
+
prefix = pending.tied_prefix
|
|
149
|
+
prefix ? append_tied(prefix, own) : own
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Attaches `tail` at the deep end of `head`'s tied chain, rebuilding
|
|
153
|
+
# each link (RhythmicValue exposes no setter) so a chain like
|
|
154
|
+
# "half tied to eighth" gains a further "tied to quarter".
|
|
155
|
+
def append_tied(head, tail)
|
|
156
|
+
tied = head.tied_value
|
|
157
|
+
inner = tied ? append_tied(tied, tail) : tail
|
|
158
|
+
HeadMusic::Rudiment::RhythmicValue.new(head.unit, dots: head.dots, tied_value: inner)
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
@@ -15,6 +15,8 @@ module HeadMusic::Notation::ABC
|
|
|
15
15
|
UNIT_NOTE_LENGTH = Rational(1, 8)
|
|
16
16
|
BARS_PER_LINE = 4
|
|
17
17
|
|
|
18
|
+
include HeadMusic::Notation::PlacementValidation
|
|
19
|
+
|
|
18
20
|
attr_reader :composition, :reference_number
|
|
19
21
|
|
|
20
22
|
def initialize(composition, reference_number: 1)
|
|
@@ -83,15 +85,27 @@ module HeadMusic::Notation::ABC
|
|
|
83
85
|
[
|
|
84
86
|
"X:#{reference_number}",
|
|
85
87
|
"T:#{composition.name}",
|
|
86
|
-
|
|
87
|
-
|
|
88
|
+
optional_field("C", composition.composer),
|
|
89
|
+
optional_field("O", composition.origin),
|
|
88
90
|
"M:#{composition.meter}",
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
"K:#{KeyMapper.abc_value(composition.key_signature)}"
|
|
91
|
+
unit_note_length_field,
|
|
92
|
+
key_field
|
|
92
93
|
].compact
|
|
93
94
|
end
|
|
94
95
|
|
|
96
|
+
def optional_field(letter, value)
|
|
97
|
+
"#{letter}:#{value}" if value
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def unit_note_length_field
|
|
101
|
+
"L:#{UNIT_NOTE_LENGTH.numerator}/#{UNIT_NOTE_LENGTH.denominator}"
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def key_field
|
|
105
|
+
# The parser requires K: to terminate the header.
|
|
106
|
+
"K:#{KeyMapper.abc_value(composition.key_signature)}"
|
|
107
|
+
end
|
|
108
|
+
|
|
95
109
|
def body_lines
|
|
96
110
|
return [] if bar_strings.empty?
|
|
97
111
|
|
|
@@ -109,13 +123,34 @@ module HeadMusic::Notation::ABC
|
|
|
109
123
|
def build_bar_strings
|
|
110
124
|
pitch_writer = PitchWriter.new(composition.key_signature)
|
|
111
125
|
duration_writer = DurationWriter.new(UNIT_NOTE_LENGTH)
|
|
112
|
-
|
|
113
|
-
previous.position.bar_number == current.position.bar_number
|
|
114
|
-
end
|
|
115
|
-
bar_groups.each_with_index.map do |bar_placements, index|
|
|
126
|
+
placements_by_bar.each_with_index.map do |bar_placements, index|
|
|
116
127
|
# Accidental state must mirror what a re-parse accumulates bar by bar.
|
|
117
128
|
pitch_writer.start_new_bar if index.positive?
|
|
118
|
-
bar_placements
|
|
129
|
+
render_bar(bar_placements, pitch_writer, duration_writer)
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def placements_by_bar
|
|
134
|
+
placements.chunk_while do |previous, current|
|
|
135
|
+
previous.position.bar_number == current.position.bar_number
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def render_bar(bar_placements, pitch_writer, duration_writer)
|
|
140
|
+
tokens = bar_placements.map { |placement| token(placement, pitch_writer, duration_writer) }
|
|
141
|
+
join_bar_tokens(bar_placements, tokens)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Suppresses the inter-token space only where the following placement was
|
|
145
|
+
# authored as beamed to its predecessor (beam_break_before == false).
|
|
146
|
+
# A true or nil flag keeps the space, so programmatic (nil-flag)
|
|
147
|
+
# compositions render with today's every-token spacing. Every bar token
|
|
148
|
+
# (note, rest, or [..] chord) re-lexes unambiguously with no separator, so
|
|
149
|
+
# dropping the space is safe.
|
|
150
|
+
def join_bar_tokens(placements, tokens)
|
|
151
|
+
tokens.each_with_index.reduce(+"") do |line, (token, index)|
|
|
152
|
+
separator = (index.zero? || placements[index].beam_break_before == false) ? "" : " "
|
|
153
|
+
line << separator << token
|
|
119
154
|
end
|
|
120
155
|
end
|
|
121
156
|
|
|
@@ -137,12 +172,8 @@ module HeadMusic::Notation::ABC
|
|
|
137
172
|
"[#{pitch_tokens.join}]#{multiplier}"
|
|
138
173
|
end
|
|
139
174
|
|
|
140
|
-
def
|
|
141
|
-
|
|
142
|
-
return unless unpitched
|
|
143
|
-
|
|
144
|
-
raise RenderError, "cannot render unpitched sound \"#{unpitched}\" at #{placement.position}: " \
|
|
145
|
-
"percussion rendering is not yet supported"
|
|
175
|
+
def render_error_class
|
|
176
|
+
RenderError
|
|
146
177
|
end
|
|
147
178
|
end
|
|
148
179
|
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
module HeadMusic
|
|
2
|
+
module Notation
|
|
3
|
+
# The exact fractional length of a (possibly dotted) rhythmic value in
|
|
4
|
+
# terms of its own unit. RhythmicValue's own value methods return Floats,
|
|
5
|
+
# so the fraction is rebuilt here from the unit's integer numerator and
|
|
6
|
+
# denominator to keep downstream arithmetic (ABC multipliers, MusicXML
|
|
7
|
+
# divisions) exact.
|
|
8
|
+
module DottedDuration
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def dotted_unit_fraction(rhythmic_value)
|
|
12
|
+
unit = rhythmic_value.unit
|
|
13
|
+
dots = rhythmic_value.dots
|
|
14
|
+
# A value with d dots spans (2^(d+1) - 1) / 2^d of its own unit.
|
|
15
|
+
Rational(unit.numerator, unit.denominator) * Rational((2**(dots + 1)) - 1, 2**dots)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -7,8 +7,13 @@ module HeadMusic::Notation; end
|
|
|
7
7
|
# catalog entry with its own factory. Alternatives are recorded data only —
|
|
8
8
|
# no selection behavior is implemented here (deferred to the Content layer).
|
|
9
9
|
class HeadMusic::Notation::InstrumentNotation
|
|
10
|
+
include HeadMusic::ValueEquality
|
|
11
|
+
|
|
10
12
|
attr_reader :instrument, :staves, :alternatives
|
|
11
13
|
|
|
14
|
+
value_equality :instrument, :staves_attributes
|
|
15
|
+
alias_method :eql?, :==
|
|
16
|
+
|
|
12
17
|
def initialize(instrument:, data:)
|
|
13
18
|
@instrument = instrument
|
|
14
19
|
@staves = build_staves(data["staves"])
|
|
@@ -32,13 +37,6 @@ class HeadMusic::Notation::InstrumentNotation
|
|
|
32
37
|
staves.length > 1
|
|
33
38
|
end
|
|
34
39
|
|
|
35
|
-
def ==(other)
|
|
36
|
-
return false unless other.is_a?(self.class)
|
|
37
|
-
|
|
38
|
-
instrument == other.instrument && staves_attributes == other.staves_attributes
|
|
39
|
-
end
|
|
40
|
-
alias_method :eql?, :==
|
|
41
|
-
|
|
42
40
|
def hash
|
|
43
41
|
[instrument.name_key, staves_attributes].hash
|
|
44
42
|
end
|