clef 0.1.0 → 1.0.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.
Files changed (49) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +77 -90
  3. data/Rakefile +21 -1
  4. data/exe/clef +21 -0
  5. data/lib/clef/compiler.rb +107 -4
  6. data/lib/clef/core/chord.rb +9 -3
  7. data/lib/clef/core/duration.rb +7 -3
  8. data/lib/clef/core/key_signature.rb +43 -36
  9. data/lib/clef/core/measure.rb +14 -10
  10. data/lib/clef/core/metadata.rb +52 -0
  11. data/lib/clef/core/note.rb +50 -4
  12. data/lib/clef/core/pitch.rb +73 -4
  13. data/lib/clef/core/rest.rb +11 -3
  14. data/lib/clef/core/score.rb +148 -9
  15. data/lib/clef/core/staff.rb +13 -3
  16. data/lib/clef/core/staff_group.rb +8 -2
  17. data/lib/clef/core/tempo.rb +5 -0
  18. data/lib/clef/core/tuplet.rb +48 -0
  19. data/lib/clef/core/validation.rb +39 -0
  20. data/lib/clef/core/voice.rb +21 -5
  21. data/lib/clef/engraving/font_manager.rb +1 -1
  22. data/lib/clef/engraving/glyph_table.rb +18 -3
  23. data/lib/clef/engraving/style.rb +41 -2
  24. data/lib/clef/ir/moment.rb +2 -2
  25. data/lib/clef/ir/music_tree.rb +2 -2
  26. data/lib/clef/ir/timeline.rb +25 -5
  27. data/lib/clef/layout/beam_layout.rb +2 -2
  28. data/lib/clef/layout/item.rb +26 -0
  29. data/lib/clef/layout/spacing.rb +6 -4
  30. data/lib/clef/layout/stem.rb +10 -6
  31. data/lib/clef/layout/system_layout.rb +71 -0
  32. data/lib/clef/midi/channel_map.rb +5 -2
  33. data/lib/clef/midi/exporter.rb +316 -38
  34. data/lib/clef/notation/dynamic.rb +5 -0
  35. data/lib/clef/notation/lyric.rb +33 -1
  36. data/lib/clef/parser/dsl.rb +249 -58
  37. data/lib/clef/parser/lilypond_lexer.rb +43 -3
  38. data/lib/clef/parser/lilypond_parser.rb +231 -17
  39. data/lib/clef/plugins/base.rb +24 -4
  40. data/lib/clef/plugins/registry.rb +80 -10
  41. data/lib/clef/renderer/base.rb +2 -2
  42. data/lib/clef/renderer/drawing_context.rb +26 -0
  43. data/lib/clef/renderer/notation_helpers.rb +92 -1
  44. data/lib/clef/renderer/pdf_renderer.rb +487 -82
  45. data/lib/clef/renderer/svg_renderer.rb +510 -97
  46. data/lib/clef/version.rb +1 -1
  47. data/lib/clef.rb +60 -7
  48. data/sig/clef.rbs +292 -0
  49. metadata +14 -5
@@ -3,41 +3,156 @@
3
3
  module Clef
4
4
  module Parser
5
5
  class LilypondParser
6
+ SUPPORTED_COMMANDS = %w[\\clef \\key \\major \\minor \\time \\tempo \\relative \\new \\with].freeze
7
+ DYNAMIC_COMMANDS = Clef::Notation::Dynamic::TYPES.map { |type| "\\#{type}" }.freeze
8
+ STAFF_COMMAND = /\\new\s+(?:Staff|PianoStaff|StaffGroup)/
9
+
10
+ attr_reader :warnings, :plugins
11
+
12
+ # @param plugins [Clef::Plugins::Registry]
13
+ def initialize(plugins: Clef.plugins)
14
+ @plugins = plugins
15
+ @warnings = []
16
+ end
17
+
6
18
  # @param input [String]
7
19
  # @return [Clef::Core::Score]
8
20
  def parse(input)
9
- cleaned = input.to_s
10
- clef = extract_clef(cleaned)
11
- key_tonic = extract_key_tonic(cleaned)
12
- mode = extract_mode(cleaned)
13
- time_numerator, time_denominator = extract_time(cleaned)
14
- note_stream = extract_note_stream(cleaned)
15
-
16
- Clef.score do
21
+ cleaned = strip_comments(input)
22
+ @warnings = unsupported_command_warnings(cleaned)
23
+ score = build_score(cleaned)
24
+ plugins.run_hook(:on_after_parse, score)
25
+ score
26
+ end
27
+
28
+ private
29
+
30
+ def build_score(input)
31
+ staff_blocks = extract_staff_blocks(input)
32
+ return build_single_staff_score(input) if staff_blocks.empty?
33
+
34
+ global_tempo_unit, global_tempo_bpm = extract_tempo(input)
35
+ use_staff_group = staff_group_input?(input)
36
+ parser = self
37
+ Clef.score(plugins: plugins) do
38
+ tempo beat_unit: global_tempo_unit, bpm: global_tempo_bpm if global_tempo_bpm
39
+ if use_staff_group
40
+ staff_group :bracket do
41
+ staff_blocks.each_with_index { |block, index| parser.send(:build_staff_from_block, self, block, index) }
42
+ end
43
+ else
44
+ staff_blocks.each_with_index { |block, index| parser.send(:build_staff_from_block, self, block, index) }
45
+ end
46
+ end
47
+ end
48
+
49
+ def build_single_staff_score(input)
50
+ clef = extract_clef(input)
51
+ key_tonic = extract_key_tonic(input)
52
+ mode = extract_mode(input)
53
+ time_numerator, time_denominator = extract_time(input)
54
+ tempo_unit, tempo_bpm = extract_tempo(input)
55
+ voice_streams = extract_voice_streams(first_braced_body(input), context: input)
56
+ parser = self
57
+
58
+ Clef.score(plugins: plugins) do
59
+ tempo beat_unit: tempo_unit, bpm: tempo_bpm if tempo_bpm
17
60
  staff :staff1, clef: clef do
18
61
  key key_tonic, mode
19
62
  time time_numerator, time_denominator
20
- play note_stream
63
+ parser.send(:add_voice_streams, self, voice_streams)
21
64
  end
22
65
  end
23
66
  end
24
67
 
25
- private
68
+ def build_staff_from_block(builder, block, index)
69
+ context = "#{block[:header]}\n#{block[:body]}"
70
+ clef = extract_clef(context)
71
+ key_tonic = extract_key_tonic(context)
72
+ mode = extract_mode(context)
73
+ time_numerator, time_denominator = extract_time(context)
74
+ voice_streams = extract_voice_streams(block[:body], context: context)
75
+ staff_id = :"staff#{index + 1}"
76
+ parser = self
77
+
78
+ builder.staff staff_id, clef: clef do
79
+ key key_tonic, mode
80
+ time time_numerator, time_denominator
81
+ parser.send(:add_voice_streams, self, voice_streams)
82
+ end
83
+ end
84
+
85
+ def add_voice_streams(builder, voice_streams)
86
+ streams = voice_streams.map { |stream| measure_segments(stream) }
87
+ measure_count = streams.map(&:length).max.to_i
88
+ measure_count.times do |measure_index|
89
+ builder.measure(measure_index + 1) do |staff_builder|
90
+ streams.each_with_index do |segments, voice_index|
91
+ segment = segments[measure_index]
92
+ next if segment.to_s.empty?
93
+
94
+ staff_builder.voice(voice_id_for(voice_index)) { notes segment }
95
+ end
96
+ end
97
+ end
98
+ end
99
+
100
+ def measure_segments(stream)
101
+ stream.to_s.split("|").map(&:strip).reject(&:empty?)
102
+ end
103
+
104
+ def voice_id_for(index)
105
+ index.zero? ? :default : :"voice#{index + 1}"
106
+ end
107
+
108
+ def extract_staff_blocks(input)
109
+ blocks = []
110
+ scanner_index = 0
111
+ while (match = input.match(STAFF_COMMAND, scanner_index))
112
+ command_start = match.begin(0)
113
+ brace_start = input.index("{", match.end(0))
114
+ break unless brace_start
115
+
116
+ body, body_end = braced_body_at(input, brace_start)
117
+ header = input[command_start...brace_start]
118
+ blocks << {header: header, body: body}
119
+ scanner_index = body_end + 1
120
+ end
121
+ blocks
122
+ end
123
+
124
+ def staff_group_input?(input)
125
+ input.match?(/\\new\s+(?:StaffGroup|PianoStaff)\b/) || input.include?("<<")
126
+ end
127
+
128
+ def extract_voice_streams(body, context: body)
129
+ simultaneous = first_simultaneous_body(body)
130
+ streams = if simultaneous
131
+ braced_bodies(simultaneous)
132
+ elsif (inner = braced_bodies(body).first)
133
+ [inner]
134
+ else
135
+ [body]
136
+ end
137
+ streams.map { |stream| note_stream(stream, context: context) }.reject(&:empty?)
138
+ end
26
139
 
27
- def extract_note_stream(input)
28
- body = input[/\{(.+)\}/m, 1] || ""
140
+ def note_stream(input, context:)
141
+ body = context.match?(/\\relative\b/) ? relativize_body(context, input) : input
29
142
  tokens = LilypondLexer.new.tokenize(body)
30
- tokens.select { |token| token.match?(/\A<|\A[a-g]|\Ar|\A\|/) }.join(" ")
143
+ tokens.select do |token|
144
+ token.match?(/\A<|\A[a-g]|\Ar|\A\||\A[()~\[\]]|\A(?:--|->|-\.)\z/) || DYNAMIC_COMMANDS.include?(token)
145
+ end.join(" ")
31
146
  end
32
147
 
33
148
  def extract_key_tonic(input)
34
- match = /\\key\s+([a-g](?:is|es)?)/.match(input)
149
+ match = /\\key\s+([a-g](?:isis|eses|is|es)?)/.match(input)
35
150
  tonic = (match && match[1]) || "c"
36
- Clef::Core::Pitch.parse(tonic)
151
+ Clef::Core::Pitch.parse_any(tonic)
37
152
  end
38
153
 
39
154
  def extract_mode(input)
40
- match = /\\key\s+[a-g](?:is|es)?\s+\\(major|minor)/.match(input)
155
+ match = /\\key\s+[a-g](?:isis|eses|is|es)?\s+\\(major|minor)/.match(input)
41
156
  (match && match[1]&.to_sym) || :major
42
157
  end
43
158
 
@@ -50,7 +165,106 @@ module Clef
50
165
 
51
166
  def extract_clef(input)
52
167
  match = /\\clef\s+"?([a-z_]+)"?/.match(input)
53
- (match && match[1]&.to_sym) || :treble
168
+ clef = (match && match[1]&.to_sym) || :treble
169
+ return clef if Clef::Core::Clef::TYPES.include?(clef)
170
+
171
+ warnings << "unsupported clef ignored: #{clef}"
172
+ :treble
173
+ end
174
+
175
+ def extract_tempo(input)
176
+ match = /\\tempo\s+(\d+)\s*=\s*(\d+)/.match(input)
177
+ return [nil, nil] unless match
178
+
179
+ [Clef::Core::Duration.from_lilypond(match[1].to_i), match[2].to_i]
180
+ end
181
+
182
+ def unsupported_command_warnings(input)
183
+ seen = {}
184
+ LilypondLexer.new.tokenize_with_locations(input).select { |token| token.value.start_with?("\\") }.filter_map do |token|
185
+ command = token.value
186
+ next if SUPPORTED_COMMANDS.include?(command) || DYNAMIC_COMMANDS.include?(command)
187
+ next if seen[command]
188
+
189
+ seen[command] = true
190
+ "unsupported LilyPond command ignored at line #{token.line}, column #{token.column}: #{command}"
191
+ end
192
+ end
193
+
194
+ def strip_comments(input)
195
+ input.to_s.gsub(/%\{.*?%\}/m, "").each_line.map { |line| line.sub(/%.*/, "") }.join
196
+ end
197
+
198
+ def first_braced_body(input)
199
+ start_index = input.index("{")
200
+ return "" unless start_index
201
+
202
+ braced_body_at(input, start_index).first
203
+ end
204
+
205
+ def braced_body_at(input, start_index)
206
+ depth = 0
207
+ index = start_index
208
+ while index < input.length
209
+ char = input[index]
210
+ depth += 1 if char == "{"
211
+ depth -= 1 if char == "}"
212
+ return [input[(start_index + 1)...index], index] if depth.zero?
213
+
214
+ index += 1
215
+ end
216
+ ["", input.length]
217
+ end
218
+
219
+ def first_simultaneous_body(input)
220
+ start_index = input.index("<<")
221
+ return nil unless start_index
222
+
223
+ depth = 0
224
+ index = start_index
225
+ while index < input.length - 1
226
+ token = input[index, 2]
227
+ depth += 1 if token == "<<"
228
+ depth -= 1 if token == ">>"
229
+ return input[(start_index + 2)...index] if depth.zero?
230
+
231
+ index += 1
232
+ end
233
+ nil
234
+ end
235
+
236
+ def braced_bodies(input)
237
+ bodies = []
238
+ scanner_index = 0
239
+ while (brace_start = input.index("{", scanner_index))
240
+ body, body_end = braced_body_at(input, brace_start)
241
+ bodies << body
242
+ scanner_index = body_end + 1
243
+ end
244
+ bodies
245
+ end
246
+
247
+ def relativize_body(input, body)
248
+ match = /\\relative\s+([a-g](?:isis|eses|is|es)?[',]*)/.match(input)
249
+ return body unless match
250
+
251
+ previous = Clef::Core::Pitch.parse(match[1])
252
+ LilypondLexer.new.tokenize(body).map do |token|
253
+ if (note_match = /\A([a-g](?:isis|eses|is|es)?)(\d*\.*~?)\z/.match(token))
254
+ previous = closest_relative_pitch(note_match[1], previous)
255
+ "#{previous.to_lilypond}#{note_match[2]}"
256
+ else
257
+ token
258
+ end
259
+ end.join(" ")
260
+ end
261
+
262
+ def closest_relative_pitch(note_name, previous)
263
+ base = Clef::Core::Pitch.parse(note_name)
264
+ candidates = ((previous.octave - 2)..(previous.octave + 2)).map do |octave|
265
+ Clef::Core::Pitch.new(base.note_name, octave, alteration: base.alteration)
266
+ end
267
+ candidates.min_by { |candidate| (candidate.semitones - previous.semitones).abs }
54
268
  end
55
269
  end
56
270
  end
@@ -11,16 +11,36 @@ module Clef
11
11
  end
12
12
 
13
13
  # @param _score [Clef::Core::Score]
14
- def on_before_layout(_score); end
14
+ def on_before_layout(_score)
15
+ end
15
16
 
16
17
  # @param _layout_result [Hash]
17
- def on_after_layout(_layout_result); end
18
+ def on_after_layout(_layout_result)
19
+ end
20
+
21
+ # @param _items [Array<Clef::Layout::Item>]
22
+ def on_layout_items(_items)
23
+ end
18
24
 
19
25
  # @param _renderer [Object]
20
- def on_before_render(_renderer); end
26
+ def on_before_render(_renderer)
27
+ end
28
+
29
+ # @param _path [String, #write]
30
+ def on_after_render(_path)
31
+ end
32
+
33
+ # @param _score [Clef::Core::Score]
34
+ def on_after_parse(_score)
35
+ end
36
+
37
+ # @param _exporter [Clef::Midi::Exporter]
38
+ def on_before_midi(_exporter)
39
+ end
21
40
 
22
41
  # @param _glyph_table [Clef::Engraving::GlyphTable]
23
- def register_glyphs(_glyph_table); end
42
+ def register_glyphs(_glyph_table)
43
+ end
24
44
  end
25
45
  end
26
46
  end
@@ -3,22 +3,53 @@
3
3
  module Clef
4
4
  module Plugins
5
5
  class Registry
6
- attr_reader :plugins
6
+ ERROR_POLICIES = %i[raise warn collect].freeze
7
7
 
8
- def initialize
9
- @plugins = []
8
+ attr_reader :errors
9
+
10
+ # @param error_policy [Symbol]
11
+ def initialize(error_policy: :raise)
12
+ raise ArgumentError, "unsupported hook error policy" unless ERROR_POLICIES.include?(error_policy)
13
+
14
+ @entries = []
15
+ @next_order = 0
16
+ @error_policy = error_policy
17
+ @errors = []
10
18
  end
11
19
 
12
- # @param plugin_class [Class]
13
- # @return [Plugins::Base]
14
- def register(plugin_class)
15
- raise ArgumentError, "plugin must inherit Clef::Plugins::Base" unless plugin_class < Base
20
+ # @return [Array<Plugins::Base>]
21
+ def plugins
22
+ @entries.map { |entry| entry[:plugin] }
23
+ end
16
24
 
17
- plugin = plugin_class.new
18
- plugins << plugin
25
+ # @param plugin_or_class [Class, Plugins::Base]
26
+ # @param args [Array]
27
+ # @param priority [Integer]
28
+ # @param kwargs [Hash]
29
+ # @return [Plugins::Base]
30
+ def register(plugin_or_class, *args, priority: 0, **kwargs)
31
+ plugin = build_plugin(plugin_or_class, *args, **kwargs)
32
+ @entries << {plugin: plugin, priority: priority, order: @next_order}
33
+ @next_order += 1
34
+ @entries.sort_by! { |entry| [entry[:priority], entry[:order]] }
19
35
  plugin
20
36
  end
21
37
 
38
+ # @param plugin_or_class [Class, Plugins::Base, String, Symbol]
39
+ # @return [Plugins::Base, nil]
40
+ def unregister(plugin_or_class)
41
+ entry = @entries.find { |candidate| plugin_match?(candidate[:plugin], plugin_or_class) }
42
+ return unless entry
43
+
44
+ @entries.delete(entry)
45
+ entry[:plugin]
46
+ end
47
+
48
+ # @return [Array]
49
+ def clear
50
+ @entries.clear
51
+ end
52
+
22
53
  # @param hook_name [Symbol]
23
54
  # @param args [Array<Object>]
24
55
  # @return [Array<Object>]
@@ -26,7 +57,46 @@ module Clef
26
57
  plugins.each_with_object([]) do |plugin, results|
27
58
  next unless plugin.respond_to?(hook_name)
28
59
 
29
- results << plugin.public_send(hook_name, *args)
60
+ results << run_plugin_hook(plugin, hook_name, args)
61
+ end
62
+ end
63
+
64
+ private
65
+
66
+ def build_plugin(plugin_or_class, *args, **kwargs)
67
+ if plugin_or_class.is_a?(Class)
68
+ raise ArgumentError, "plugin must inherit Clef::Plugins::Base" unless plugin_or_class < Base
69
+
70
+ return plugin_or_class.new(*args, **kwargs)
71
+ end
72
+ return plugin_or_class if plugin_or_class.is_a?(Base)
73
+
74
+ raise ArgumentError, "plugin must inherit Clef::Plugins::Base"
75
+ end
76
+
77
+ def plugin_match?(plugin, candidate)
78
+ return plugin.equal?(candidate) if candidate.is_a?(Base)
79
+ return plugin.is_a?(candidate) if candidate.is_a?(Class)
80
+
81
+ plugin.class.plugin_name.to_s == candidate.to_s
82
+ end
83
+
84
+ def run_plugin_hook(plugin, hook_name, args)
85
+ plugin.public_send(hook_name, *args)
86
+ rescue => e
87
+ handle_hook_error(plugin, hook_name, e)
88
+ end
89
+
90
+ def handle_hook_error(plugin, hook_name, error)
91
+ case @error_policy
92
+ when :raise
93
+ raise error
94
+ when :warn
95
+ warn("Clef plugin #{plugin.class.plugin_name} #{hook_name} failed: #{error.message}")
96
+ nil
97
+ when :collect
98
+ errors << {plugin: plugin, hook: hook_name, error: error}
99
+ nil
30
100
  end
31
101
  end
32
102
  end
@@ -9,8 +9,8 @@ module Clef
9
9
  # @param glyph_table [Clef::Engraving::GlyphTable]
10
10
  # @param font_manager [Clef::Engraving::FontManager]
11
11
  def initialize(style: Clef::Engraving::Style.default,
12
- glyph_table: Clef::Engraving::GlyphTable.new,
13
- font_manager: Clef::Engraving::FontManager.new)
12
+ glyph_table: Clef::Engraving::GlyphTable.new,
13
+ font_manager: Clef::Engraving::FontManager.new)
14
14
  @style = style
15
15
  @glyph_table = glyph_table
16
16
  @font_manager = font_manager
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clef
4
+ module Renderer
5
+ class DrawingContext
6
+ attr_reader :vertical_axis
7
+
8
+ # @param vertical_axis [Integer]
9
+ def initialize(vertical_axis:)
10
+ raise ArgumentError, "vertical_axis must be 1 or -1" unless [1, -1].include?(vertical_axis)
11
+
12
+ @vertical_axis = vertical_axis
13
+ end
14
+
15
+ # @return [DrawingContext]
16
+ def self.pdf
17
+ new(vertical_axis: 1)
18
+ end
19
+
20
+ # @return [DrawingContext]
21
+ def self.svg
22
+ new(vertical_axis: -1)
23
+ end
24
+ end
25
+ end
26
+ end
@@ -6,15 +6,30 @@ module Clef
6
6
  ACCIDENTAL_GLYPH_KEYS = {
7
7
  -2 => :accidental_double_flat,
8
8
  -1 => :accidental_flat,
9
+ 0 => :accidental_natural,
9
10
  1 => :accidental_sharp,
10
11
  2 => :accidental_double_sharp
11
12
  }.freeze
12
13
  ACCIDENTAL_FALLBACK = {
13
14
  -2 => "bb",
14
15
  -1 => "b",
16
+ 0 => "n",
15
17
  1 => "#",
16
18
  2 => "##"
17
19
  }.freeze
20
+ SHARP_ORDER = %i[f c g d a e b].freeze
21
+ FLAT_ORDER = %i[b e a d g c f].freeze
22
+ REST_LABELS = {
23
+ whole: "W",
24
+ half: "H",
25
+ quarter: "r",
26
+ eighth: "8",
27
+ sixteenth: "16",
28
+ thirty_second: "32",
29
+ sixty_fourth: "64",
30
+ one_twenty_eighth: "128",
31
+ two_fifty_sixth: "256"
32
+ }.freeze
18
33
 
19
34
  private
20
35
 
@@ -26,6 +41,14 @@ module Clef
26
41
  duration.base != :whole
27
42
  end
28
43
 
44
+ def flag_required?(duration)
45
+ %i[eighth sixteenth thirty_second sixty_fourth one_twenty_eighth two_fifty_sixth].include?(duration.base)
46
+ end
47
+
48
+ def beamable?(element)
49
+ element.is_a?(Clef::Core::Note) && flag_required?(element.duration)
50
+ end
51
+
29
52
  def duration_spacing(element)
30
53
  style.min_note_spacing * (element.length.to_f / Rational(1, 4).to_f)
31
54
  end
@@ -54,7 +77,7 @@ module Clef
54
77
  end
55
78
 
56
79
  def chord_notes(chord)
57
- chord.pitches.map { |pitch| Clef::Core::Note.new(pitch, chord.duration) }
80
+ chord.sorted_pitches.map { |pitch| Clef::Core::Note.new(pitch, chord.duration) }
58
81
  end
59
82
 
60
83
  def chord_stem_anchor_note(notes, direction)
@@ -66,6 +89,74 @@ module Clef
66
89
  def chord_stem_length(notes, clef, direction)
67
90
  notes.map { |note| Clef::Layout::Stem.length(note, clef, direction) }.max
68
91
  end
92
+
93
+ def key_signature_alterations(key_signature)
94
+ return {} unless key_signature
95
+
96
+ accidentals = key_signature.accidentals
97
+ order = (accidentals[:type] == :sharp) ? SHARP_ORDER : FLAT_ORDER
98
+ alteration = (accidentals[:type] == :sharp) ? 1 : -1
99
+ order.first(accidentals[:count].to_i).to_h { |note_name| [note_name, alteration] }
100
+ end
101
+
102
+ def accidental_for_pitch(pitch, key_signature, state)
103
+ expected = key_signature_alterations(key_signature)[pitch.note_name] || 0
104
+ current = state.fetch(pitch.note_name, expected)
105
+ return nil if current == pitch.alteration
106
+
107
+ state[pitch.note_name] = pitch.alteration
108
+ pitch.alteration
109
+ end
110
+
111
+ def rest_label(duration)
112
+ REST_LABELS.fetch(duration.base)
113
+ end
114
+
115
+ def element_notes(element)
116
+ case element
117
+ when Clef::Core::Note then [element]
118
+ when Clef::Core::Chord then chord_notes(element)
119
+ when Clef::Core::Tuplet then element.elements.flat_map { |child| element_notes(child) }
120
+ else []
121
+ end
122
+ end
123
+
124
+ def dynamic_text(dynamic)
125
+ dynamic.type.to_s
126
+ end
127
+
128
+ def flatten_elements(elements)
129
+ elements.flat_map do |element|
130
+ element.is_a?(Clef::Core::Tuplet) ? flatten_elements(element.elements) : element
131
+ end
132
+ end
133
+
134
+ def lyric_elements(elements)
135
+ flatten_elements(elements).select do |element|
136
+ element.is_a?(Clef::Core::Note) || element.is_a?(Clef::Core::Chord)
137
+ end
138
+ end
139
+
140
+ def lyric_events(lyric, elements)
141
+ note_index = 0
142
+ previous_element = nil
143
+ lyric.syllables.filter_map do |syllable|
144
+ if Clef::Notation::Lyric.hyphen?(syllable)
145
+ next {type: :hyphen, from: previous_element, to: elements[note_index]}
146
+ end
147
+
148
+ element = elements[note_index]
149
+ note_index += 1
150
+ if Clef::Notation::Lyric.extender?(syllable)
151
+ event = {type: :extender, from: previous_element, to: element}
152
+ previous_element = element if element
153
+ next event
154
+ end
155
+
156
+ previous_element = element if element
157
+ {type: :text, syllable: syllable, element: element}
158
+ end
159
+ end
69
160
  end
70
161
  end
71
162
  end