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
@@ -5,11 +5,33 @@ module Clef
5
5
  module DSL
6
6
  class Error < StandardError; end
7
7
 
8
+ module BlockEvaluation
9
+ private
10
+
11
+ def evaluate_block(target, &block)
12
+ return unless block
13
+ return block.call(target) if block.arity == 1
14
+
15
+ target.instance_eval(&block)
16
+ end
17
+
18
+ def method_missing(name, *_args, &_block)
19
+ raise Error, "invalid DSL method: #{name}"
20
+ end
21
+
22
+ def respond_to_missing?(_name, _include_private = false)
23
+ false
24
+ end
25
+ end
26
+
8
27
  class ScoreBuilder
28
+ include BlockEvaluation
29
+
9
30
  attr_reader :score
10
31
 
11
- def initialize
32
+ def initialize(plugins: Clef.plugins)
12
33
  @score = Clef::Core::Score.new
34
+ @score.plugins = plugins
13
35
  @default_group = Clef::Core::StaffGroup.new([], bracket_type: :none)
14
36
  score.add_staff_group(@default_group)
15
37
  end
@@ -36,38 +58,30 @@ module Clef
36
58
  # @param clef [Symbol]
37
59
  def staff(id, name: nil, clef: :treble, &block)
38
60
  staff = Clef::Core::Staff.new(id, name: name, clef: Clef::Core::Clef.new(clef))
39
- build_staff(staff, &block)
40
- @default_group.add_staff(staff)
61
+ evaluate_block(StaffBuilder.new(staff), &block)
62
+ score.add_staff(staff)
41
63
  staff
42
- rescue NoMethodError => e
43
- raise Error, "invalid DSL method in staff block: #{e.name}"
44
64
  end
45
65
 
46
66
  # @param bracket_type [Symbol]
47
67
  def staff_group(bracket_type, &block)
68
+ raise Error, "staff_group requires a block" unless block
69
+
48
70
  group = Clef::Core::StaffGroup.new([], bracket_type: bracket_type)
49
- GroupBuilder.new(group).instance_eval(&block)
71
+ evaluate_block(GroupBuilder.new(group), &block)
50
72
  score.add_staff_group(group)
51
73
  group
52
- rescue NoMethodError => e
53
- raise Error, "invalid DSL method in staff_group block: #{e.name}"
54
74
  end
55
75
 
56
76
  # @return [Clef::Core::Score]
57
77
  def build
58
78
  score
59
79
  end
60
-
61
- private
62
-
63
- def build_staff(staff, &block)
64
- return unless block
65
-
66
- StaffBuilder.new(staff).instance_eval(&block)
67
- end
68
80
  end
69
81
 
70
82
  class GroupBuilder
83
+ include BlockEvaluation
84
+
71
85
  def initialize(group)
72
86
  @group = group
73
87
  end
@@ -77,13 +91,15 @@ module Clef
77
91
  # @param clef [Symbol]
78
92
  def staff(id, name: nil, clef: :treble, &block)
79
93
  staff = Clef::Core::Staff.new(id, name: name, clef: Clef::Core::Clef.new(clef))
80
- StaffBuilder.new(staff).instance_eval(&block) if block
94
+ evaluate_block(StaffBuilder.new(staff), &block)
81
95
  @group.add_staff(staff)
82
96
  staff
83
97
  end
84
98
  end
85
99
 
86
100
  class StaffBuilder
101
+ include BlockEvaluation
102
+
87
103
  def initialize(staff)
88
104
  @staff = staff
89
105
  @current_measure = nil
@@ -91,7 +107,7 @@ module Clef
91
107
  @lyrics = []
92
108
  end
93
109
 
94
- # @param tonic [Symbol, Clef::Core::Pitch]
110
+ # @param tonic [Symbol, String, Clef::Core::Pitch]
95
111
  # @param mode [Symbol]
96
112
  def key(tonic, mode = :major)
97
113
  @staff.key_signature = Clef::Core::KeySignature.new(tonic, mode)
@@ -103,34 +119,54 @@ module Clef
103
119
  @staff.time_signature = Clef::Core::TimeSignature.new(numerator, denominator)
104
120
  end
105
121
 
122
+ # @param program [Integer]
123
+ def instrument(program)
124
+ @staff.set_metadata(:midi_program, program)
125
+ end
126
+
106
127
  # @param id [Symbol]
107
128
  def voice(id = :default, &block)
108
129
  measure = ensure_measure
109
130
  voice = measure.voice(id)
110
131
  return voice unless block
111
132
 
112
- VoiceBuilder.new(voice).instance_eval(&block)
133
+ evaluate_block(VoiceBuilder.new(voice), &block)
134
+ validate_measure_overflow!(measure)
113
135
  voice
114
- rescue NoMethodError => e
115
- raise Error, "invalid DSL method in voice block: #{e.name}"
116
136
  end
117
137
 
118
138
  # @param lilypond_string [String]
119
139
  def play(lilypond_string)
120
140
  segments = split_measures(lilypond_string)
141
+ voice_builder = nil
121
142
  segments.each_with_index do |segment, idx|
122
143
  measure = ensure_measure
123
- VoiceBuilder.new(measure.voice(:default)).notes(segment)
144
+ voice_builder ||= VoiceBuilder.new(measure.voice(:default))
145
+ voice_builder.use_voice(measure.voice(:default)).notes(segment)
146
+ validate_measure_overflow!(measure)
124
147
  advance_measure if idx < segments.length - 1
125
148
  end
126
149
  end
127
150
 
151
+ def bar
152
+ advance_measure
153
+ end
154
+
155
+ # @param number [Integer, nil]
156
+ def measure(number = nil, &block)
157
+ target_number = number || @next_measure_number
158
+ @current_measure = existing_measure(target_number) || new_measure(target_number)
159
+ evaluate_block(self, &block)
160
+ @current_measure
161
+ ensure
162
+ advance_measure
163
+ end
164
+
128
165
  # @param voice_id [Symbol]
129
166
  # @param text [String]
130
167
  def lyrics(voice_id, text)
131
168
  @lyrics << Clef::Notation::Lyric.new(voice_id, text)
132
- @staff.metadata ||= {}
133
- @staff.metadata[:lyrics] = @lyrics
169
+ @staff.set_metadata(:lyrics, @lyrics)
134
170
  end
135
171
 
136
172
  private
@@ -138,12 +174,20 @@ module Clef
138
174
  def ensure_measure
139
175
  return @current_measure if @current_measure
140
176
 
141
- @current_measure = Clef::Core::Measure.new(@next_measure_number, time_signature: @staff.time_signature)
142
- @current_measure.key_signature = @staff.key_signature
143
- @current_measure.clef = @staff.clef
144
- @staff.add_measure(@current_measure)
145
- @next_measure_number += 1
146
- @current_measure
177
+ @current_measure = new_measure(@next_measure_number)
178
+ end
179
+
180
+ def existing_measure(number)
181
+ @staff.measures.find { |measure| measure.number == number }
182
+ end
183
+
184
+ def new_measure(number)
185
+ measure = Clef::Core::Measure.new(number, time_signature: @staff.time_signature)
186
+ measure.key_signature = @staff.key_signature
187
+ measure.clef = @staff.clef
188
+ @staff.add_measure(measure)
189
+ @next_measure_number = measure.number + 1
190
+ measure
147
191
  end
148
192
 
149
193
  def advance_measure
@@ -153,11 +197,33 @@ module Clef
153
197
  def split_measures(input)
154
198
  input.to_s.split("|").map(&:strip).reject(&:empty?)
155
199
  end
200
+
201
+ def validate_measure_overflow!(measure)
202
+ ids = measure.overflowing_voice_ids
203
+ return if ids.empty?
204
+
205
+ raise Error, "measure #{measure.number} voice #{ids.join(", ")} exceeds time signature length"
206
+ end
156
207
  end
157
208
 
158
209
  class VoiceBuilder
210
+ include BlockEvaluation
211
+
159
212
  def initialize(voice)
160
213
  @voice = voice
214
+ @last_duration = Clef::Core::Duration.quarter
215
+ @pending_tie = false
216
+ @pending_articulations = []
217
+ @open_slur = false
218
+ @open_beam = false
219
+ @last_note = nil
220
+ end
221
+
222
+ # @param voice [Clef::Core::Voice]
223
+ # @return [VoiceBuilder]
224
+ def use_voice(voice)
225
+ @voice = voice
226
+ self
161
227
  end
162
228
 
163
229
  # @param pitch_str [String]
@@ -174,7 +240,7 @@ module Clef
174
240
  # @param opts [Hash]
175
241
  def rest(duration_sym, **opts)
176
242
  duration = Clef::Core::Duration.new(duration_sym, dots: opts.fetch(:dots, 0))
177
- @voice.add(Clef::Core::Rest.new(duration))
243
+ @voice.add(Clef::Core::Rest.new(duration, kind: opts.fetch(:kind, :visible), measures: opts.fetch(:measures, 1)))
178
244
  end
179
245
 
180
246
  # @param pitch_strs [Array<String>]
@@ -186,6 +252,18 @@ module Clef
186
252
  @voice.add(Clef::Core::Chord.new(pitches, duration))
187
253
  end
188
254
 
255
+ # @param type [Symbol]
256
+ def dynamic(type)
257
+ @voice.add(Clef::Notation::Dynamic.new(type))
258
+ end
259
+
260
+ # @param beat_unit [Symbol, Clef::Core::Duration]
261
+ # @param bpm [Integer]
262
+ def tempo(beat_unit:, bpm:)
263
+ duration = beat_unit.is_a?(Clef::Core::Duration) ? beat_unit : Clef::Core::Duration.new(beat_unit)
264
+ @voice.add(Clef::Core::Tempo.new(beat_unit: duration, bpm: bpm))
265
+ end
266
+
189
267
  # @param lilypond_string [String]
190
268
  def notes(lilypond_string)
191
269
  parse_tokens(lilypond_string).each { |token| add_token(token) }
@@ -193,71 +271,184 @@ module Clef
193
271
 
194
272
  # @param actual [Integer]
195
273
  # @param normal [Integer]
196
- def tuplet(actual, normal)
274
+ def tuplet(actual, normal, &block)
197
275
  raise ArgumentError, "tuplet values must be positive" unless actual.positive? && normal.positive?
198
-
199
276
  return unless block_given?
200
277
 
201
- yield
278
+ voice = Clef::Core::Voice.new(id: @voice.id)
279
+ evaluate_block(self.class.new(voice), &block)
280
+ @voice.add(Clef::Core::Tuplet.new(actual, normal, voice.elements))
202
281
  end
203
282
 
204
283
  private
205
284
 
206
285
  def parse_tokens(input)
207
- input.to_s.split(/\s+/).reject(&:empty?)
286
+ Clef::Parser::LilypondLexer.new.tokenize_with_locations(input)
208
287
  end
209
288
 
210
289
  def add_token(token)
211
- return if token == "|"
212
-
213
- if token.start_with?("r")
214
- add_rest_token(token)
215
- elsif token.start_with?("<")
216
- add_chord_token(token)
290
+ value = token.to_s
291
+ return if value == "|"
292
+ return start_slur if value == "("
293
+ return end_slur if value == ")"
294
+ return start_beam if value == "["
295
+ return end_beam if value == "]"
296
+ return tie_next if value == "~"
297
+ return add_pending_articulation(value) if articulation_token?(value)
298
+ return add_command_token(value) if value.start_with?("\\")
299
+
300
+ if value.start_with?("r")
301
+ add_rest_token(value)
302
+ elsif value.start_with?("<")
303
+ add_chord_token(value)
217
304
  else
218
- add_note_token(token)
305
+ add_note_token(value)
219
306
  end
220
- rescue StandardError => e
221
- raise Error, "failed to parse token '#{token}': #{e.message}"
307
+ rescue => e
308
+ raise Error, "failed to parse token '#{value}'#{token_location(token)}: #{e.message}"
309
+ end
310
+
311
+ def token_location(token)
312
+ return "" unless token.respond_to?(:line) && token.respond_to?(:column)
313
+
314
+ " at line #{token.line}, column #{token.column}"
222
315
  end
223
316
 
224
317
  def add_rest_token(token)
225
- match = /\Ar(\d+)(\.*)\z/.match(token)
318
+ match = /\Ar(\d*)(\.*)\z/.match(token)
226
319
  raise ArgumentError, "invalid rest token" unless match
227
320
 
228
- duration = Clef::Core::Duration.from_lilypond(match[1].to_i, match[2].length)
321
+ duration = duration_from_match(match[1], match[2])
229
322
  @voice.add(Clef::Core::Rest.new(duration))
230
323
  end
231
324
 
232
325
  def add_chord_token(token)
233
- match = /\A<([^>]+)>(\d+)(\.*)\z/.match(token)
326
+ token, tied = split_tie_suffix(token)
327
+ match = /\A<([^>]+)>(\d*)(\.*)\z/.match(token)
234
328
  raise ArgumentError, "invalid chord token" unless match
235
329
 
236
330
  pitches = match[1].split(/\s+/).map { |value| parse_pitch(value) }
237
- duration = Clef::Core::Duration.from_lilypond(match[2].to_i, match[3].length)
331
+ duration = duration_from_match(match[2], match[3])
238
332
  @voice.add(Clef::Core::Chord.new(pitches, duration))
333
+ tie_next if tied
239
334
  end
240
335
 
241
336
  def add_note_token(token)
242
- match = /\A([a-g](?:isis|eses|is|es)?[',]*)(\d+)(\.*)\z/.match(token)
337
+ token, tied = split_tie_suffix(token)
338
+ token, articulations = split_articulation_suffixes(token)
339
+ @pending_articulations.concat(articulations)
340
+ match = /\A([a-g](?:isis|eses|is|es)?[',]*)(\d*)(\.*)\z/.match(token)
243
341
  raise ArgumentError, "invalid note token" unless match
244
342
 
245
343
  pitch = parse_pitch(match[1])
246
- duration = Clef::Core::Duration.from_lilypond(match[2].to_i, match[3].length)
247
- @voice.add(Clef::Core::Note.new(pitch, duration))
344
+ duration = duration_from_match(match[2], match[3])
345
+ note = Clef::Core::Note.new(pitch, duration,
346
+ articulations: consume_articulations,
347
+ tied: consume_tie_state)
348
+ note.slur_start = consume_slur_start
349
+ note.beam_start = consume_beam_start
350
+ @voice.add(note)
351
+ @last_note = note
352
+ tie_next if tied
248
353
  end
249
354
 
250
355
  def parse_pitch(value)
251
- parse_scientific_pitch(value) || Clef::Core::Pitch.parse(value.downcase)
356
+ Clef::Core::Pitch.parse_any(value)
357
+ end
358
+
359
+ def duration_from_match(number, dots)
360
+ duration = if number.empty?
361
+ @last_duration
362
+ else
363
+ Clef::Core::Duration.from_lilypond(number.to_i, dots.length)
364
+ end
365
+ @last_duration = duration
366
+ duration
367
+ end
368
+
369
+ def split_tie_suffix(token)
370
+ return [token.delete_suffix("~"), true] if token.end_with?("~")
371
+
372
+ [token, false]
373
+ end
374
+
375
+ def split_articulation_suffixes(token)
376
+ articulations = []
377
+ loop do
378
+ suffix = {"-." => :staccato, "->" => :accent, "--" => :tenuto}.find { |marker, _| token.end_with?(marker) }
379
+ break unless suffix
380
+
381
+ marker, articulation = suffix
382
+ token = token.delete_suffix(marker)
383
+ articulations << articulation
384
+ end
385
+ [token, articulations.reverse]
386
+ end
387
+
388
+ def articulation_token?(token)
389
+ %w[-. -> --].include?(token)
390
+ end
391
+
392
+ def add_pending_articulation(token)
393
+ articulation = {"-." => :staccato, "->" => :accent, "--" => :tenuto}.fetch(token)
394
+ if @last_note
395
+ @last_note.add_articulation(articulation)
396
+ else
397
+ @pending_articulations << articulation
398
+ end
399
+ end
400
+
401
+ def add_command_token(token)
402
+ dynamic_name = token.delete_prefix("\\").to_sym
403
+ return dynamic(dynamic_name) if Clef::Notation::Dynamic::TYPES.include?(dynamic_name)
404
+
405
+ raise ArgumentError, "unsupported command token: #{token}"
406
+ end
407
+
408
+ def consume_articulations
409
+ @pending_articulations.tap { @pending_articulations = [] }
410
+ end
411
+
412
+ def tie_next
413
+ @last_note.tied = :start if @last_note
414
+ @pending_tie = true
415
+ end
416
+
417
+ def consume_tie_state
418
+ return false unless @pending_tie
419
+
420
+ @pending_tie = false
421
+ :stop
422
+ end
423
+
424
+ def start_slur
425
+ @open_slur = true
426
+ end
427
+
428
+ def end_slur
429
+ @last_note.slur_end = true if @last_note
430
+ end
431
+
432
+ def start_beam
433
+ @open_beam = true
434
+ end
435
+
436
+ def end_beam
437
+ @last_note.beam_end = true if @last_note
438
+ end
439
+
440
+ def consume_slur_start
441
+ return false unless @open_slur
442
+
443
+ @open_slur = false
444
+ true
252
445
  end
253
446
 
254
- def parse_scientific_pitch(value)
255
- match = /\A([A-Ga-g])([#b]{0,2})(-?\d+)\z/.match(value)
256
- return nil unless match
447
+ def consume_beam_start
448
+ return false unless @open_beam
257
449
 
258
- note_name = match[1].downcase.to_sym
259
- alteration = { "" => 0, "#" => 1, "##" => 2, "b" => -1, "bb" => -2 }.fetch(match[2])
260
- Clef::Core::Pitch.new(note_name, match[3].to_i, alteration: alteration)
450
+ @open_beam = false
451
+ true
261
452
  end
262
453
  end
263
454
  end
@@ -3,19 +3,59 @@
3
3
  module Clef
4
4
  module Parser
5
5
  class LilypondLexer
6
- TOKEN_REGEX = /\\[a-zA-Z]+|\{\}|\{|\}|<[^>]+>\d+\.*|[a-g](?:isis|eses|is|es)?[',]*\d+\.*|r\d+\.*|\|/m
6
+ Token = Struct.new(:value, :line, :column, keyword_init: true) do
7
+ def to_s
8
+ value
9
+ end
10
+
11
+ def match?(*args)
12
+ value.match?(*args)
13
+ end
14
+
15
+ def ==(other)
16
+ value == other || super
17
+ end
18
+ end
19
+
20
+ TOKEN_REGEX = /
21
+ \\[a-zA-Z]+ |
22
+ << | >> |
23
+ \{\} | \{ | \} |
24
+ <[^>]+>\d*\.*~? |
25
+ [a-g](?:isis|eses|is|es)?[',]*\d*\.*~? |
26
+ r\d*\.* |
27
+ -- | -> | -\. |
28
+ [~()\[\]|]
29
+ /mx
7
30
 
8
31
  # @param input [String]
9
32
  # @return [Array<String>]
10
33
  def tokenize(input)
34
+ tokenize_with_locations(input).map(&:value)
35
+ end
36
+
37
+ # @param input [String]
38
+ # @return [Array<Token>]
39
+ def tokenize_with_locations(input)
11
40
  sanitized = strip_comments(input)
12
- sanitized.scan(TOKEN_REGEX)
41
+ sanitized.enum_for(:scan, TOKEN_REGEX).map do
42
+ value = Regexp.last_match[0]
43
+ line, column = location_for(sanitized, Regexp.last_match.begin(0))
44
+ Token.new(value: value, line: line, column: column)
45
+ end
13
46
  end
14
47
 
15
48
  private
16
49
 
17
50
  def strip_comments(input)
18
- input.to_s.each_line.map { |line| line.sub(/%.*/, "") }.join
51
+ input.to_s.gsub(/%\{.*?%\}/m, "").each_line.map { |line| line.sub(/%.*/, "") }.join
52
+ end
53
+
54
+ def location_for(input, offset)
55
+ prefix = input[0...offset]
56
+ line = prefix.count("\n") + 1
57
+ column = offset - (prefix.rindex("\n") || -1)
58
+ [line, column]
19
59
  end
20
60
  end
21
61
  end