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
@@ -21,7 +21,7 @@ module Clef
21
21
  def register_with(pdf, family_name: "Bravura")
22
22
  return "Helvetica" unless font_available?
23
23
 
24
- pdf.font_families.update(family_name => { normal: font_path })
24
+ pdf.font_families.update(family_name => {normal: font_path})
25
25
  family_name
26
26
  end
27
27
 
@@ -30,22 +30,37 @@ module Clef
30
30
  fermata: "\uE4C0"
31
31
  }.merge((0..9).to_h { |n| [:"time_#{n}", (0xE080 + n).chr(Encoding::UTF_8)] }).freeze
32
32
 
33
+ # @param glyphs [Hash]
34
+ def initialize(glyphs: GLYPHS)
35
+ @glyphs = glyphs.dup
36
+ end
37
+
33
38
  # @param name [Symbol]
34
39
  # @return [String]
35
40
  def fetch(name)
36
- GLYPHS.fetch(name)
41
+ @glyphs.fetch(name)
37
42
  end
38
43
 
39
44
  # @param name [Symbol]
40
45
  # @return [String, nil]
41
46
  def [](name)
42
- GLYPHS[name]
47
+ @glyphs[name]
43
48
  end
44
49
 
45
50
  # @param name [Symbol]
46
51
  # @return [Boolean]
47
52
  def key?(name)
48
- GLYPHS.key?(name)
53
+ @glyphs.key?(name)
54
+ end
55
+
56
+ # @param name [Symbol]
57
+ # @param glyph [String]
58
+ # @return [String]
59
+ def register(name, glyph)
60
+ raise ArgumentError, "glyph name must be a Symbol" unless name.is_a?(Symbol)
61
+ raise ArgumentError, "glyph must be a String" unless glyph.is_a?(String)
62
+
63
+ @glyphs[name] = glyph
49
64
  end
50
65
  end
51
66
  end
@@ -3,25 +3,64 @@
3
3
  module Clef
4
4
  module Engraving
5
5
  class Style
6
- attr_reader :page_size, :margin, :staff_size, :staff_space, :min_note_spacing
6
+ attr_reader :page_size, :margin, :staff_size, :staff_space, :min_note_spacing,
7
+ :line_width, :system_gap, :staff_gap, :measure_padding,
8
+ :notehead_width, :beam_thickness
7
9
 
8
10
  # @param page_size [String, Array]
9
11
  # @param margin [Numeric]
10
12
  # @param staff_size [Numeric]
11
13
  # @param staff_space [Numeric]
12
14
  # @param min_note_spacing [Numeric]
13
- def initialize(page_size: "A4", margin: 36, staff_size: 20, staff_space: 10, min_note_spacing: 24)
15
+ # @param line_width [Numeric]
16
+ # @param system_gap [Numeric]
17
+ # @param staff_gap [Numeric]
18
+ # @param measure_padding [Numeric]
19
+ # @param notehead_width [Numeric]
20
+ # @param beam_thickness [Numeric]
21
+ def initialize(page_size: "A4", margin: 36, staff_size: 20, staff_space: 10, min_note_spacing: nil,
22
+ line_width: 820, system_gap: 72, staff_gap: 90, measure_padding: 14,
23
+ notehead_width: 7, beam_thickness: nil)
24
+ min_note_spacing ||= Rules::MIN_NOTE_SPACING * staff_space
25
+ beam_thickness ||= Rules::BEAM_THICKNESS * staff_space
26
+
27
+ validate_positive!(
28
+ margin: margin,
29
+ staff_size: staff_size,
30
+ staff_space: staff_space,
31
+ min_note_spacing: min_note_spacing,
32
+ line_width: line_width,
33
+ system_gap: system_gap,
34
+ staff_gap: staff_gap,
35
+ measure_padding: measure_padding,
36
+ notehead_width: notehead_width,
37
+ beam_thickness: beam_thickness
38
+ )
39
+
14
40
  @page_size = page_size
15
41
  @margin = margin
16
42
  @staff_size = staff_size
17
43
  @staff_space = staff_space
18
44
  @min_note_spacing = min_note_spacing
45
+ @line_width = line_width
46
+ @system_gap = system_gap
47
+ @staff_gap = staff_gap
48
+ @measure_padding = measure_padding
49
+ @notehead_width = notehead_width
50
+ @beam_thickness = beam_thickness
19
51
  end
20
52
 
21
53
  # @return [Style]
22
54
  def self.default
23
55
  new
24
56
  end
57
+
58
+ private
59
+
60
+ def validate_positive!(values)
61
+ invalid = values.filter_map { |name, value| name unless value.is_a?(Numeric) && value.positive? }
62
+ raise ArgumentError, "style values must be positive: #{invalid.join(", ")}" unless invalid.empty?
63
+ end
25
64
  end
26
65
  end
27
66
  end
@@ -16,8 +16,8 @@ module Clef
16
16
 
17
17
  # @param duration [Rational, #length]
18
18
  # @return [Moment]
19
- def +(duration)
20
- self.class.new(value + normalize_duration(duration))
19
+ def +(other)
20
+ self.class.new(value + normalize_duration(other))
21
21
  end
22
22
 
23
23
  # @param other [Moment, Rational, Integer]
@@ -19,7 +19,7 @@ module Clef
19
19
  staff.measures.each do |measure|
20
20
  append_measure_metadata(timeline, measure, staff, current)
21
21
  append_measure_voices(timeline, measure, staff, current)
22
- current = current + measure_length_for(measure)
22
+ current += measure_length_for(measure)
23
23
  end
24
24
  end
25
25
 
@@ -39,7 +39,7 @@ module Clef
39
39
  cursor = Moment.new(start_moment.value)
40
40
  voice.elements.each do |element|
41
41
  timeline.add(Event.new(moment: cursor, element: element, staff_id: staff_id, voice_id: voice_id))
42
- cursor = cursor + element.length
42
+ cursor += element.length
43
43
  end
44
44
  end
45
45
 
@@ -17,6 +17,11 @@ module Clef
17
17
  raise ArgumentError, "event must be a Clef::Ir::Event" unless event.is_a?(Event)
18
18
 
19
19
  events << event
20
+ self
21
+ end
22
+
23
+ # @return [Timeline]
24
+ def sort!
20
25
  events.sort_by! { |item| item.moment.value }
21
26
  self
22
27
  end
@@ -25,7 +30,7 @@ module Clef
25
30
  # @return [Array<Event>]
26
31
  def events_at(moment)
27
32
  target = moment_value(moment)
28
- events.select { |event| event.moment.value == target }
33
+ sorted_events.select { |event| event.moment.value == target }
29
34
  end
30
35
 
31
36
  # @param from [Moment, Rational, Integer]
@@ -34,7 +39,16 @@ module Clef
34
39
  def events_between(from, to)
35
40
  lower = moment_value(from)
36
41
  upper = moment_value(to)
37
- events.select { |event| (lower..upper).cover?(event.moment.value) }
42
+ sorted_events.select { |event| (lower..upper).cover?(event.moment.value) }
43
+ end
44
+
45
+ # @param from [Moment, Rational, Integer]
46
+ # @param to [Moment, Rational, Integer]
47
+ # @return [Array<Event>]
48
+ def events_in_range(from, to)
49
+ lower = moment_value(from)
50
+ upper = moment_value(to)
51
+ sorted_events.select { |event| event.moment.value >= lower && event.moment.value < upper }
38
52
  end
39
53
 
40
54
  # @yield [Moment]
@@ -46,19 +60,25 @@ module Clef
46
60
  end
47
61
 
48
62
  def each(&block)
49
- events.each(&block)
63
+ sorted_events.each(&block)
50
64
  end
51
65
 
52
66
  # @return [Rational]
53
67
  def shortest_duration
54
- durations = events.filter_map { |event| event.element.length if event.element.respond_to?(:length) }
68
+ durations = events.filter_map do |event|
69
+ event.element.length if event.element.respond_to?(:length) && event.element.length.positive?
70
+ end
55
71
  durations.min || Rational(1, 4)
56
72
  end
57
73
 
58
74
  private
59
75
 
60
76
  def unique_moments
61
- events.map(&:moment).uniq.sort
77
+ sorted_events.map(&:moment).uniq
78
+ end
79
+
80
+ def sorted_events
81
+ events.sort_by { |item| item.moment.value }
62
82
  end
63
83
 
64
84
  def moment_value(value)
@@ -20,8 +20,8 @@ module Clef
20
20
  def compute(beam_group, _clef, _spacing)
21
21
  first_y = pitch_y(beam_group.first.pitch)
22
22
  last_y = pitch_y(beam_group.last.pitch)
23
- slope = [[(last_y - first_y) / [beam_group.length - 1, 1].max, -0.5].max, 0.5].min
24
- { start_y: first_y, end_y: first_y + slope * (beam_group.length - 1), slope: slope }
23
+ slope = ((last_y - first_y) / [beam_group.length - 1, 1].max).clamp(-0.5, 0.5)
24
+ {start_y: first_y, end_y: first_y + slope * (beam_group.length - 1), slope: slope}
25
25
  end
26
26
 
27
27
  private
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clef
4
+ module Layout
5
+ class Item
6
+ attr_reader :type, :moment, :staff_id, :measure_number, :payload
7
+
8
+ # @param type [Symbol]
9
+ # @param moment [Clef::Ir::Moment]
10
+ # @param staff_id [Symbol, String, nil]
11
+ # @param measure_number [Integer, nil]
12
+ # @param payload [Hash]
13
+ def initialize(type:, moment:, staff_id: nil, measure_number: nil, payload: {})
14
+ raise ArgumentError, "type must be a Symbol" unless type.is_a?(Symbol)
15
+ raise ArgumentError, "moment must be a Clef::Ir::Moment" unless moment.is_a?(Clef::Ir::Moment)
16
+ raise ArgumentError, "payload must be a Hash" unless payload.is_a?(Hash)
17
+
18
+ @type = type
19
+ @moment = moment
20
+ @staff_id = staff_id
21
+ @measure_number = measure_number
22
+ @payload = payload.dup
23
+ end
24
+ end
25
+ end
26
+ end
@@ -3,19 +3,21 @@
3
3
  module Clef
4
4
  module Layout
5
5
  class Spacing
6
- attr_reader :timeline, :style, :stretch_factor
6
+ attr_reader :timeline, :style, :stretch_factor, :extra_moments
7
7
 
8
8
  # @param timeline [Clef::Ir::Timeline]
9
9
  # @param style [Clef::Engraving::Style]
10
- def initialize(timeline, style)
10
+ # @param extra_moments [Array<Clef::Ir::Moment>]
11
+ def initialize(timeline, style, extra_moments: [])
11
12
  @timeline = timeline
12
13
  @style = style
14
+ @extra_moments = extra_moments
13
15
  @stretch_factor = 1.0
14
16
  end
15
17
 
16
18
  # @return [Hash{Clef::Ir::Moment=>Float}]
17
19
  def compute
18
- moments = timeline.each_moment.to_a.sort
20
+ moments = (timeline.each_moment.to_a + extra_moments).uniq.sort
19
21
  return {} if moments.empty?
20
22
 
21
23
  build_positions(moments)
@@ -36,7 +38,7 @@ module Clef
36
38
  private
37
39
 
38
40
  def build_positions(moments)
39
- positions = { moments.first => 0.0 }
41
+ positions = {moments.first => 0.0}
40
42
  total = 0.0
41
43
  moments.each_cons(2) do |left, right|
42
44
  total += interval_width(right.value - left.value)
@@ -3,16 +3,15 @@
3
3
  module Clef
4
4
  module Layout
5
5
  class Stem
6
- B4_MIDI = 71
7
-
8
6
  class << self
9
7
  # @param note_or_notes [Clef::Core::Note, Array<Clef::Core::Note>]
10
- # @param _clef [Clef::Core::Clef]
8
+ # @param clef [Clef::Core::Clef]
11
9
  # @return [Symbol]
12
- def direction(note_or_notes, _clef)
10
+ def direction(note_or_notes, clef)
13
11
  notes = Array(note_or_notes)
14
- down_votes = notes.count { |note| note.pitch.to_midi > B4_MIDI }
15
- down_votes > (notes.length / 2.0) ? :down : :up
12
+ middle = diatonic_step(clef.reference_pitch)
13
+ down_votes = notes.count { |note| diatonic_step(note.pitch) > middle }
14
+ (down_votes > (notes.length / 2.0)) ? :down : :up
16
15
  end
17
16
 
18
17
  # @param note [Clef::Core::Note]
@@ -26,6 +25,11 @@ module Clef
26
25
 
27
26
  private
28
27
 
28
+ def diatonic_step(pitch)
29
+ note_index = Clef::Core::Pitch::VALID_NOTE_NAMES.index(pitch.note_name)
30
+ (pitch.octave * 7) + note_index
31
+ end
32
+
29
33
  def ledger_extension(pitch)
30
34
  return 1.5 if pitch.to_midi > 84
31
35
  return 1.5 if pitch.to_midi < 48
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clef
4
+ module Layout
5
+ class SystemLayout
6
+ System = Struct.new(:page_index, :line_index, :line_top, :start_moment, :end_moment,
7
+ :position_offset, :x_origin, :y_origin, :staff_offsets, keyword_init: true) do
8
+ def staff_offset(staff_id)
9
+ staff_offsets.fetch(staff_id)
10
+ end
11
+
12
+ def staff_origin(staff_id)
13
+ [x_origin, y_origin + staff_offset(staff_id)]
14
+ end
15
+
16
+ def include_moment?(moment)
17
+ value = moment.is_a?(Clef::Ir::Moment) ? moment.value : Rational(moment)
18
+ value.between?(start_moment.value, end_moment.value)
19
+ end
20
+ end
21
+
22
+ attr_reader :score, :pages, :positions, :style
23
+
24
+ # @param score [Clef::Core::Score]
25
+ # @param pages [Array<Array<Array<Hash>>>]
26
+ # @param positions [Hash]
27
+ # @param style [Clef::Engraving::Style]
28
+ def initialize(score, pages:, positions:, style:)
29
+ @score = score
30
+ @pages = pages
31
+ @positions = positions
32
+ @style = style
33
+ end
34
+
35
+ # @return [Array<System>]
36
+ def build
37
+ pages.flat_map.with_index do |page, page_index|
38
+ page.map.with_index do |line, line_index|
39
+ build_system(line, page_index, line_index)
40
+ end
41
+ end
42
+ end
43
+
44
+ private
45
+
46
+ def build_system(line, page_index, line_index)
47
+ start_moment = line.first.fetch(:moment)
48
+ end_moment = line.last.fetch(:moment)
49
+ System.new(
50
+ page_index: page_index,
51
+ line_index: line_index,
52
+ line_top: line_index * system_height,
53
+ start_moment: start_moment,
54
+ end_moment: end_moment,
55
+ position_offset: positions.fetch(start_moment, 0.0),
56
+ x_origin: -positions.fetch(start_moment, 0.0),
57
+ y_origin: line_index * system_height,
58
+ staff_offsets: staff_offsets
59
+ )
60
+ end
61
+
62
+ def staff_offsets
63
+ score.staves.each_with_index.to_h { |staff, index| [staff.id, index * style.staff_gap] }
64
+ end
65
+
66
+ def system_height
67
+ [(score.staves.length - 1), 0].max * style.staff_gap + style.system_gap
68
+ end
69
+ end
70
+ end
71
+ end
@@ -4,10 +4,13 @@ module Clef
4
4
  module Midi
5
5
  class ChannelMap
6
6
  # @param staff_index [Integer]
7
+ # @param percussion [Boolean]
7
8
  # @return [Integer]
8
- def channel_for(staff_index)
9
+ def channel_for(staff_index, percussion: false)
10
+ return 9 if percussion
11
+
9
12
  base = staff_index % 15
10
- base >= 9 ? base + 1 : base
13
+ (base >= 9) ? base + 1 : base
11
14
  end
12
15
  end
13
16
  end