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
@@ -8,73 +8,351 @@ module Clef
8
8
  class Exporter
9
9
  include MIDI
10
10
 
11
+ DEFAULT_VELOCITY = 90
12
+ DEFAULT_PROGRAM = 1
13
+
14
+ attr_reader :score, :channel_map, :ppqn, :instrument_map, :plugins
15
+
11
16
  # @param score [Clef::Core::Score]
12
17
  # @param channel_map [ChannelMap]
13
- def initialize(score, channel_map: ChannelMap.new)
18
+ # @param ppqn [Integer]
19
+ # @param instrument_map [Hash]
20
+ # @param plugins [Clef::Plugins::Registry]
21
+ def initialize(score, channel_map: ChannelMap.new, ppqn: 480, instrument_map: {}, plugins: score.plugins || Clef.plugins)
22
+ raise ArgumentError, "ppqn must be a positive Integer" unless ppqn.is_a?(Integer) && ppqn.positive?
23
+
14
24
  @score = score
15
25
  @channel_map = channel_map
26
+ @ppqn = ppqn
27
+ @instrument_map = instrument_map
28
+ @plugins = plugins
29
+ end
30
+
31
+ # @param target [String, #write]
32
+ # @return [String, #write]
33
+ def export(target)
34
+ plugins.run_hook(:on_before_midi, self)
35
+ sequence = build_sequence
36
+ write_sequence(sequence, target)
37
+ target
16
38
  end
17
39
 
18
- # @param path [String]
19
- # @return [String]
20
- def export(path)
40
+ private
41
+
42
+ def build_sequence
21
43
  sequence = Sequence.new
44
+ sequence.ppqn = ppqn
45
+ append_tempo_track(sequence)
46
+ append_staff_tracks(sequence)
47
+ sequence
48
+ end
49
+
50
+ def append_tempo_track(sequence)
22
51
  track = Track.new(sequence)
23
52
  sequence.tracks << track
24
- track.events << Tempo.new(Tempo.bpm_to_mpq(tempo_bpm))
53
+ last_tick = write_tempo_events(track)
54
+ append_track_end(track, score_length, last_tick)
55
+ end
25
56
 
26
- append_score(track, sequence.ppqn)
27
- File.open(path, "wb") { |file| sequence.write(file) }
28
- path
57
+ def append_staff_tracks(sequence)
58
+ score.staves.each_with_index do |staff, index|
59
+ track = Track.new(sequence)
60
+ sequence.tracks << track
61
+ channel = channel_for(staff, index)
62
+ track.events << ProgramChange.new(channel, instrument_for(staff), 0)
63
+ append_staff(track, staff, channel)
64
+ end
29
65
  end
30
66
 
31
- private
67
+ def append_staff(track, staff, channel)
68
+ note_events = collect_staff_note_events(staff, channel)
69
+ last_tick = write_delta_events(track, note_events)
70
+ append_track_end(track, staff_length(staff), last_tick)
71
+ end
32
72
 
33
- def append_score(track, ppqn)
34
- @score.staves.each_with_index do |staff, index|
35
- channel = @channel_map.channel_for(index)
36
- track.events << ProgramChange.new(channel, 1, 0)
37
- append_staff(track, staff, channel, ppqn)
73
+ def collect_staff_note_events(staff, channel)
74
+ starts = measure_starts(staff)
75
+ staff.measures.flat_map { |measure| measure.voices.keys }.uniq.flat_map do |voice_id|
76
+ pending_ties = {}
77
+ playback_state = {velocity: DEFAULT_VELOCITY, slur_depth: 0}
78
+ events = starts.flat_map do |measure, measure_start|
79
+ voice = measure.voices[voice_id]
80
+ next [] unless voice
81
+
82
+ collect_voice_events(voice, measure_start, channel,
83
+ pending_ties: pending_ties,
84
+ playback_state: playback_state,
85
+ flush_ties: false)
86
+ end
87
+ events + flush_pending_ties(pending_ties, channel)
38
88
  end
39
89
  end
40
90
 
41
- def append_staff(track, staff, channel, ppqn)
42
- delta = 0
43
- staff.measures.each do |measure|
44
- voice = measure.voices.values.first
45
- delta = append_voice(track, voice, channel, ppqn, delta) if voice
91
+ def collect_voice_events(voice, start_time, channel, pending_ties: {}, playback_state: {velocity: DEFAULT_VELOCITY}, flush_ties: true)
92
+ events, = collect_elements(voice.elements, start_time, channel, Rational(1, 1), pending_ties, playback_state,
93
+ flush_ties: flush_ties)
94
+ events
95
+ end
96
+
97
+ def collect_elements(elements, start_time, channel, ratio, pending_ties, playback_state, flush_ties:)
98
+ cursor = start_time
99
+ events = []
100
+ elements.each do |element|
101
+ case element
102
+ when Clef::Core::Rest
103
+ cursor += element.length * ratio
104
+ when Clef::Core::Note
105
+ events.concat(schedule_note(element, cursor, channel, ratio, pending_ties, playback_state))
106
+ advance_slur_state(element, playback_state)
107
+ cursor += element.length * ratio
108
+ when Clef::Core::Chord
109
+ events.concat(schedule_chord(element, cursor, channel, ratio, playback_state))
110
+ cursor += element.length * ratio
111
+ when Clef::Core::Tuplet
112
+ nested_events, = collect_elements(element.elements, cursor, channel, ratio * element.ratio,
113
+ pending_ties, playback_state, flush_ties: false)
114
+ events.concat(nested_events)
115
+ cursor += element.length * ratio
116
+ when Clef::Notation::Dynamic
117
+ playback_state[:velocity] = velocity_for_dynamic(element)
118
+ when Clef::Core::Tempo
119
+ cursor += element.length
120
+ end
46
121
  end
122
+ events.concat(flush_pending_ties(pending_ties, channel)) if flush_ties
123
+ [events, cursor]
47
124
  end
48
125
 
49
- def append_voice(track, voice, channel, ppqn, delta)
50
- voice.elements.each do |element|
51
- delta = append_element(track, element, channel, ppqn, delta)
126
+ def schedule_note(note, start_time, channel, ratio, pending_ties, playback_state)
127
+ duration = note.length * ratio
128
+ midi = note.pitch.to_midi
129
+ if note.tie_state == :start || note.tie_state == :continue
130
+ pending = pending_ties[midi] ||= {
131
+ start_time: start_time,
132
+ duration: Rational(0, 1),
133
+ note: note,
134
+ velocity: playback_state[:velocity]
135
+ }
136
+ pending[:duration] += duration
137
+ return []
138
+ end
139
+
140
+ if note.tie_state == :stop && pending_ties.key?(midi)
141
+ pending = pending_ties.delete(midi)
142
+ pending[:duration] += duration
143
+ return [note_event(pending[:note], pending[:start_time], pending[:duration], channel, pending[:velocity])]
52
144
  end
53
- delta
145
+
146
+ [note_event(note, start_time, effective_note_length(note, duration,
147
+ slurred: legato_slur_note?(note, playback_state)), channel, playback_state[:velocity])]
54
148
  end
55
149
 
56
- def append_element(track, element, channel, ppqn, delta)
57
- ticks = (element.length * ppqn * 4).to_i
58
- return delta + ticks if element.is_a?(Clef::Core::Rest)
150
+ def schedule_chord(chord, start_time, channel, ratio, playback_state)
151
+ duration = chord.length * ratio
152
+ chord.pitches.map do |pitch|
153
+ {start_time: start_time, duration: duration, pitch: pitch.to_midi,
154
+ velocity: playback_state[:velocity], channel: channel}
155
+ end
156
+ end
157
+
158
+ def flush_pending_ties(pending_ties, channel)
159
+ pending_ties.values.map do |pending|
160
+ note_event(pending[:note], pending[:start_time], pending[:duration], channel, pending[:velocity])
161
+ end.tap { pending_ties.clear }
162
+ end
59
163
 
60
- pitches = element.is_a?(Clef::Core::Chord) ? element.pitches : [element.pitch]
61
- write_note_events(track, channel, pitches, ticks, delta)
62
- 0
164
+ def note_event(note, start_time, duration, channel, base_velocity)
165
+ {
166
+ start_time: start_time,
167
+ duration: duration,
168
+ pitch: note.pitch.to_midi,
169
+ velocity: velocity_for(note, base_velocity),
170
+ channel: channel
171
+ }
63
172
  end
64
173
 
65
- def write_note_events(track, channel, pitches, ticks, delta)
66
- pitches.each_with_index do |pitch, index|
67
- note_delta = index.zero? ? delta : 0
68
- track.events << NoteOn.new(channel, pitch.to_midi, 90, note_delta)
174
+ def write_delta_events(track, note_events)
175
+ absolute_events = note_events.flat_map do |event|
176
+ start_tick = ticks_for(event[:start_time])
177
+ end_tick = ticks_for(event[:start_time] + event[:duration])
178
+ [
179
+ [start_tick, 1, NoteOn.new(event[:channel], event[:pitch], event[:velocity], 0)],
180
+ [end_tick, 0, NoteOff.new(event[:channel], event[:pitch], event[:velocity], 0)]
181
+ ]
182
+ end.sort_by { |tick, priority, _event| [tick, priority] }
183
+
184
+ previous_tick = 0
185
+ absolute_events.each do |tick, _priority, event|
186
+ event.delta_time = tick - previous_tick
187
+ track.events << event
188
+ previous_tick = tick
69
189
  end
70
- pitches.each_with_index do |pitch, index|
71
- note_delta = index.zero? ? ticks : 0
72
- track.events << NoteOff.new(channel, pitch.to_midi, 90, note_delta)
190
+ previous_tick
191
+ end
192
+
193
+ def effective_note_length(note, duration, slurred: false)
194
+ return duration * Rational(1, 2) if note.articulations.include?(:staccato)
195
+ return duration * Rational(19, 20) if note.articulations.include?(:tenuto)
196
+ return duration * Rational(101, 100) if slurred
197
+
198
+ duration
199
+ end
200
+
201
+ def legato_slur_note?(note, playback_state)
202
+ (playback_state.fetch(:slur_depth, 0).positive? || note.slur_start) && !note.slur_end
203
+ end
204
+
205
+ def advance_slur_state(note, playback_state)
206
+ depth = playback_state.fetch(:slur_depth, 0)
207
+ depth += 1 if note.slur_start
208
+ depth -= 1 if note.slur_end && depth.positive?
209
+ playback_state[:slur_depth] = depth
210
+ end
211
+
212
+ def velocity_for(note, base_velocity)
213
+ return 112 if note.articulations.include?(:accent) || note.articulations.include?(:marcato)
214
+
215
+ base_velocity
216
+ end
217
+
218
+ def velocity_for_dynamic(dynamic)
219
+ {
220
+ pp: 36,
221
+ p: 48,
222
+ mp: 64,
223
+ mf: 80,
224
+ f: 96,
225
+ ff: 112,
226
+ fff: 120,
227
+ sfz: 120,
228
+ fp: 96,
229
+ cresc: 88,
230
+ dim: 64
231
+ }.fetch(dynamic.type)
232
+ end
233
+
234
+ def write_tempo_events(track)
235
+ events = ([[Rational(0, 1), quarter_note_bpm]] + collect_score_tempo_events)
236
+ .uniq { |time, _bpm| time }
237
+ .sort_by(&:first)
238
+ previous_tick = 0
239
+ events.each do |time, bpm|
240
+ tick = ticks_for(time)
241
+ event = Tempo.new(Tempo.bpm_to_mpq(bpm))
242
+ event.delta_time = tick - previous_tick
243
+ track.events << event
244
+ previous_tick = tick
73
245
  end
246
+ previous_tick
74
247
  end
75
248
 
76
- def tempo_bpm
77
- @score.tempo&.bpm || 120
249
+ def append_track_end(track, length, previous_tick)
250
+ tick = ticks_for(length)
251
+ track.events << MetaEvent.new(META_TRACK_END, nil, tick - previous_tick)
252
+ end
253
+
254
+ def collect_score_tempo_events
255
+ score.staves.flat_map do |staff|
256
+ measure_starts(staff).flat_map do |measure, measure_start|
257
+ events = measure.voices.values.flat_map { |voice| collect_tempo_events(voice.elements, measure_start, Rational(1, 1)) }
258
+ events
259
+ end
260
+ end
261
+ end
262
+
263
+ def measure_starts(staff)
264
+ measure_start = Rational(0, 1)
265
+ staff.measures.map do |measure|
266
+ [measure, measure_start].tap do
267
+ measure_start += measure_length_for(measure)
268
+ end
269
+ end
270
+ end
271
+
272
+ def collect_tempo_events(elements, start_time, ratio)
273
+ cursor = start_time
274
+ elements.flat_map do |element|
275
+ case element
276
+ when Clef::Core::Rest, Clef::Core::Note, Clef::Core::Chord
277
+ cursor += element.length * ratio
278
+ []
279
+ when Clef::Core::Tuplet
280
+ nested = collect_tempo_events(element.elements, cursor, ratio * element.ratio)
281
+ cursor += element.length * ratio
282
+ nested
283
+ when Clef::Core::Tempo
284
+ [[cursor, tempo_to_quarter_bpm(element)]]
285
+ else
286
+ []
287
+ end
288
+ end
289
+ end
290
+
291
+ def ticks_for(time)
292
+ (time * ppqn * 4).round
293
+ end
294
+
295
+ def measure_length_for(measure)
296
+ return measure.time_signature.measure_length if measure.time_signature
297
+
298
+ measure.voices.values.map(&:total_length).max || Rational(0, 1)
299
+ end
300
+
301
+ def staff_length(staff)
302
+ staff.measures.sum { |measure| measure_length_for(measure) }
303
+ end
304
+
305
+ def score_length
306
+ score.staves.map { |staff| staff_length(staff) }.max || Rational(0, 1)
307
+ end
308
+
309
+ def quarter_note_bpm
310
+ tempo = score.tempo
311
+ return 120 unless tempo
312
+
313
+ tempo_to_quarter_bpm(tempo)
314
+ end
315
+
316
+ def tempo_to_quarter_bpm(tempo)
317
+ (tempo.bpm * (tempo.beat_unit.length / Clef::Core::Duration.quarter.length)).round
318
+ end
319
+
320
+ def channel_for(staff, index)
321
+ channel_map.channel_for(index, percussion: staff.clef.type == :percussion)
322
+ end
323
+
324
+ def instrument_for(staff)
325
+ program = instrument_map.fetch(staff.id) do
326
+ instrument_map.fetch(index_key(staff)) do
327
+ staff.metadata.fetch(:midi_program) { staff.metadata.fetch(:program, DEFAULT_PROGRAM) }
328
+ end
329
+ end
330
+ validate_program!(program)
331
+ program
332
+ end
333
+
334
+ def index_key(staff)
335
+ score.staves.index(staff)
336
+ end
337
+
338
+ def validate_program!(program)
339
+ return if program.is_a?(Integer) && (0..127).cover?(program)
340
+
341
+ raise ArgumentError, "MIDI program must be an Integer between 0 and 127"
342
+ end
343
+
344
+ def write_sequence(sequence, target)
345
+ return sequence.write(target) if target.respond_to?(:write)
346
+
347
+ ensure_parent_directory!(target)
348
+ File.open(target, "wb") { |file| sequence.write(file) }
349
+ end
350
+
351
+ def ensure_parent_directory!(path)
352
+ parent = File.dirname(path.to_s)
353
+ return if parent.nil? || parent == "." || Dir.exist?(parent)
354
+
355
+ raise ArgumentError, "output directory does not exist: #{parent}"
78
356
  end
79
357
  end
80
358
  end
@@ -13,6 +13,11 @@ module Clef
13
13
 
14
14
  @type = type
15
15
  end
16
+
17
+ # @return [Rational]
18
+ def length
19
+ Rational(0, 1)
20
+ end
16
21
  end
17
22
  end
18
23
  end
@@ -3,6 +3,9 @@
3
3
  module Clef
4
4
  module Notation
5
5
  class Lyric
6
+ HYPHEN = "--"
7
+ EXTENDER = "_"
8
+
6
9
  attr_reader :voice_id, :text, :syllables
7
10
 
8
11
  # @param voice_id [Symbol]
@@ -15,14 +18,43 @@ module Clef
15
18
  @syllables = parse_syllables(text)
16
19
  end
17
20
 
21
+ # @return [Integer]
22
+ def note_slot_count
23
+ syllables.count { |syllable| self.class.note_syllable?(syllable) }
24
+ end
25
+
26
+ # @param syllable [String]
27
+ # @return [Boolean]
28
+ def self.note_syllable?(syllable)
29
+ syllable != HYPHEN
30
+ end
31
+
32
+ # @param syllable [String]
33
+ # @return [Boolean]
34
+ def self.hyphen?(syllable)
35
+ syllable == HYPHEN
36
+ end
37
+
38
+ # @param syllable [String]
39
+ # @return [Boolean]
40
+ def self.extender?(syllable)
41
+ syllable == EXTENDER
42
+ end
43
+
18
44
  private
19
45
 
20
46
  def parse_syllables(input)
21
47
  input
22
48
  .split(/\s+/)
23
- .flat_map { |token| token.split("-") }
49
+ .flat_map { |token| lyric_token_parts(token) }
24
50
  .reject(&:empty?)
25
51
  end
52
+
53
+ def lyric_token_parts(token)
54
+ return [token] if [EXTENDER, HYPHEN].include?(token)
55
+
56
+ token.split("-")
57
+ end
26
58
  end
27
59
  end
28
60
  end