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
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clef
4
+ module Core
5
+ module Metadata
6
+ # @return [Hash]
7
+ def metadata
8
+ immutable_metadata(@metadata)
9
+ end
10
+
11
+ # @param value [Hash]
12
+ def metadata=(value)
13
+ raise ArgumentError, "metadata must be a Hash" unless value.is_a?(Hash)
14
+
15
+ @metadata = value.dup
16
+ end
17
+
18
+ # @param key [Object]
19
+ # @param value [Object]
20
+ # @return [self]
21
+ def set_metadata(key, value)
22
+ @metadata[key] = value
23
+ self
24
+ end
25
+
26
+ # @param value [Hash, nil]
27
+ # @param kwargs [Hash]
28
+ # @return [self]
29
+ def update_metadata(value = nil, **kwargs)
30
+ source = value || {}
31
+ raise ArgumentError, "metadata must be a Hash" unless source.is_a?(Hash)
32
+
33
+ @metadata.merge!(source)
34
+ @metadata.merge!(kwargs)
35
+ self
36
+ end
37
+
38
+ private
39
+
40
+ def immutable_metadata(value)
41
+ case value
42
+ when Hash
43
+ value.to_h { |key, item| [key, immutable_metadata(item)] }.freeze
44
+ when Array
45
+ value.map { |item| immutable_metadata(item) }.freeze
46
+ else
47
+ value
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
@@ -3,21 +3,29 @@
3
3
  module Clef
4
4
  module Core
5
5
  class Note
6
+ VALID_ARTICULATIONS = %i[staccato tenuto accent marcato fermata].freeze
7
+ TIE_STATES = %i[start continue stop].freeze
8
+
6
9
  attr_reader :pitch, :duration
7
- attr_accessor :articulations, :tied
10
+ attr_reader :articulations, :tie_state
11
+ attr_accessor :slur_start, :slur_end, :beam_start, :beam_end
8
12
 
9
13
  # @param pitch [Pitch]
10
14
  # @param duration [Duration]
11
15
  # @param articulations [Array<Symbol>]
12
- # @param tied [Boolean]
16
+ # @param tied [Boolean, Symbol]
13
17
  def initialize(pitch, duration, articulations: [], tied: false)
14
18
  validate_pitch!(pitch)
15
19
  validate_duration!(duration)
16
20
 
17
21
  @pitch = pitch
18
22
  @duration = duration
19
- @articulations = Array(articulations).map(&:to_sym)
20
- @tied = !!tied
23
+ self.articulations = articulations
24
+ @tie_state = normalize_tie_state(tied)
25
+ @slur_start = false
26
+ @slur_end = false
27
+ @beam_start = false
28
+ @beam_end = false
21
29
  end
22
30
 
23
31
  # @return [Rational]
@@ -25,6 +33,28 @@ module Clef
25
33
  duration.length
26
34
  end
27
35
 
36
+ # @return [Boolean]
37
+ def tied
38
+ !tie_state.nil?
39
+ end
40
+
41
+ # @param value [Boolean, Symbol]
42
+ def tied=(value)
43
+ @tie_state = normalize_tie_state(value)
44
+ end
45
+
46
+ # @param values [Array<Symbol>]
47
+ def articulations=(values)
48
+ @articulations = normalize_articulations(values).freeze
49
+ end
50
+
51
+ # @param articulation [Symbol]
52
+ # @return [Note]
53
+ def add_articulation(articulation)
54
+ self.articulations = articulations + [articulation]
55
+ self
56
+ end
57
+
28
58
  private
29
59
 
30
60
  def validate_pitch!(pitch)
@@ -38,6 +68,22 @@ module Clef
38
68
 
39
69
  raise ArgumentError, "duration must be a Clef::Core::Duration"
40
70
  end
71
+
72
+ def normalize_articulations(values)
73
+ symbols = Array(values).map(&:to_sym)
74
+ invalid = symbols - VALID_ARTICULATIONS
75
+ raise ArgumentError, "unsupported articulations: #{invalid.join(", ")}" unless invalid.empty?
76
+
77
+ symbols
78
+ end
79
+
80
+ def normalize_tie_state(value)
81
+ return nil if value.nil? || value == false
82
+ return :start if value == true
83
+ return value if TIE_STATES.include?(value)
84
+
85
+ raise ArgumentError, "tied must be true, false, or one of #{TIE_STATES.inspect}"
86
+ end
41
87
  end
42
88
  end
43
89
  end
@@ -37,6 +37,24 @@ module Clef
37
37
  10 => [:a, 1],
38
38
  11 => [:b, 0]
39
39
  }.freeze
40
+ MIDI_CLASS_TO_FLAT_PITCH = {
41
+ 0 => [:c, 0],
42
+ 1 => [:d, -1],
43
+ 2 => [:d, 0],
44
+ 3 => [:e, -1],
45
+ 4 => [:e, 0],
46
+ 5 => [:f, 0],
47
+ 6 => [:g, -1],
48
+ 7 => [:g, 0],
49
+ 8 => [:a, -1],
50
+ 9 => [:a, 0],
51
+ 10 => [:b, -1],
52
+ 11 => [:b, 0]
53
+ }.freeze
54
+ SCIENTIFIC_PITCH_REGEX = /\A([A-Ga-g])([#b]{0,2})(-?\d+)\z/
55
+ LILYPOND_PITCH_REGEX = /\A([a-g])(eses|isis|es|is)?([',]*)\z/
56
+ TRANSPOSE_PREFERENCES = %i[sharp flat].freeze
57
+ MIDI_RANGE = (0..127)
40
58
 
41
59
  attr_reader :note_name, :octave, :alteration
42
60
 
@@ -56,7 +74,10 @@ module Clef
56
74
 
57
75
  # @return [Integer]
58
76
  def to_midi
59
- semitones + 12
77
+ midi = semitones + 12
78
+ raise RangeError, "MIDI pitch out of range: #{midi}" unless MIDI_RANGE.cover?(midi)
79
+
80
+ midi
60
81
  end
61
82
 
62
83
  # @return [Integer]
@@ -71,11 +92,18 @@ module Clef
71
92
  end
72
93
 
73
94
  # @param semitones_or_interval [Integer, #semitones]
95
+ # @param prefer [Symbol, nil]
96
+ # @param key_signature [KeySignature, nil]
74
97
  # @return [Pitch]
75
- def transpose(semitones_or_interval)
98
+ def transpose(semitones_or_interval, prefer: nil, key_signature: nil)
99
+ spelling = transpose_preference(prefer, key_signature)
100
+
76
101
  target_midi = to_midi + normalize_semitones(semitones_or_interval)
102
+ raise RangeError, "MIDI pitch out of range: #{target_midi}" unless MIDI_RANGE.cover?(target_midi)
103
+
77
104
  octave = (target_midi / 12) - 1
78
- note_name, alteration = MIDI_CLASS_TO_PITCH.fetch(target_midi % 12)
105
+ pitch_map = (spelling == :flat) ? MIDI_CLASS_TO_FLAT_PITCH : MIDI_CLASS_TO_PITCH
106
+ note_name, alteration = pitch_map.fetch(target_midi % 12)
79
107
  self.class.new(note_name, octave, alteration: alteration)
80
108
  end
81
109
 
@@ -103,7 +131,7 @@ module Clef
103
131
  def self.parse(str)
104
132
  raise ArgumentError, "pitch string must be a String" unless str.is_a?(String)
105
133
 
106
- match = /\A([a-g])(eses|isis|es|is)?([',]*)\z/.match(str)
134
+ match = LILYPOND_PITCH_REGEX.match(str)
107
135
  raise ArgumentError, "invalid lilypond pitch: #{str}" unless match
108
136
 
109
137
  note_name = match[1].to_sym
@@ -112,6 +140,30 @@ module Clef
112
140
  new(note_name, octave, alteration: SUFFIX_ALTERATION.fetch(suffix))
113
141
  end
114
142
 
143
+ # @param str [String]
144
+ # @return [Pitch]
145
+ def self.parse_scientific(str)
146
+ raise ArgumentError, "pitch string must be a String" unless str.is_a?(String)
147
+
148
+ match = SCIENTIFIC_PITCH_REGEX.match(str)
149
+ raise ArgumentError, "invalid scientific pitch: #{str}" unless match
150
+
151
+ note_name = match[1].downcase.to_sym
152
+ alteration = {"" => 0, "#" => 1, "##" => 2, "b" => -1, "bb" => -2}.fetch(match[2])
153
+ new(note_name, match[3].to_i, alteration: alteration)
154
+ end
155
+
156
+ # @param value [String, Symbol, Pitch]
157
+ # @return [Pitch]
158
+ def self.parse_any(value)
159
+ return value if value.is_a?(self)
160
+ return parse(value.to_s.downcase) if value.is_a?(Symbol)
161
+
162
+ parse_scientific(value)
163
+ rescue ArgumentError
164
+ parse(value.to_s.downcase)
165
+ end
166
+
115
167
  private
116
168
 
117
169
  def octave_marks
@@ -146,6 +198,23 @@ module Clef
146
198
 
147
199
  raise ArgumentError, "alteration must be between -2 and 2"
148
200
  end
201
+
202
+ def validate_transpose_preference!(prefer)
203
+ return if TRANSPOSE_PREFERENCES.include?(prefer)
204
+
205
+ raise ArgumentError, "transpose prefer must be :sharp or :flat"
206
+ end
207
+
208
+ def transpose_preference(prefer, key_signature)
209
+ if prefer
210
+ validate_transpose_preference!(prefer)
211
+ return prefer
212
+ end
213
+ return :sharp unless key_signature
214
+ return key_signature.preferred_transpose_spelling if key_signature.is_a?(KeySignature)
215
+
216
+ raise ArgumentError, "key_signature must be a Clef::Core::KeySignature"
217
+ end
149
218
  end
150
219
  end
151
220
  end
@@ -3,18 +3,26 @@
3
3
  module Clef
4
4
  module Core
5
5
  class Rest
6
- attr_reader :duration
6
+ KINDS = %i[visible invisible spacer multi_measure].freeze
7
+
8
+ attr_reader :duration, :kind, :measures
7
9
 
8
10
  # @param duration [Duration]
9
- def initialize(duration)
11
+ # @param kind [Symbol]
12
+ # @param measures [Integer]
13
+ def initialize(duration, kind: :visible, measures: 1)
10
14
  raise ArgumentError, "duration must be a Clef::Core::Duration" unless duration.is_a?(Duration)
15
+ raise ArgumentError, "unsupported rest kind" unless KINDS.include?(kind)
16
+ raise ArgumentError, "measures must be positive" unless measures.is_a?(Integer) && measures.positive?
11
17
 
12
18
  @duration = duration
19
+ @kind = kind
20
+ @measures = measures
13
21
  end
14
22
 
15
23
  # @return [Rational]
16
24
  def length
17
- duration.length
25
+ duration.length * measures
18
26
  end
19
27
  end
20
28
  end
@@ -3,34 +3,45 @@
3
3
  module Clef
4
4
  module Core
5
5
  class Score
6
- attr_reader :staff_groups, :metadata
7
- attr_accessor :title, :composer, :tempo
6
+ include Metadata
7
+
8
+ attr_accessor :title, :composer, :tempo, :plugins
8
9
 
9
10
  # @param metadata [Hash]
10
11
  def initialize(metadata: {})
11
12
  @staff_groups = []
12
- @metadata = metadata
13
+ self.metadata = metadata
13
14
  end
14
15
 
15
16
  # @param staff_group [StaffGroup]
16
17
  # @return [Score]
17
18
  def add_staff_group(staff_group)
18
19
  raise ArgumentError, "staff_group must be a Clef::Core::StaffGroup" unless staff_group.is_a?(StaffGroup)
20
+ duplicate = staff_group.staves.find { |staff| staves.any? { |existing| existing.id == staff.id } }
21
+ raise ArgumentError, "duplicate staff id: #{duplicate.id}" if duplicate
19
22
 
20
- staff_groups << staff_group
23
+ @staff_groups << staff_group
21
24
  self
22
25
  end
23
26
 
24
27
  # @param staff [Staff]
25
28
  # @return [Score]
26
29
  def add_staff(staff)
30
+ raise ArgumentError, "staff must be a Clef::Core::Staff" unless staff.is_a?(Staff)
31
+ raise ArgumentError, "duplicate staff id: #{staff.id}" if staves.any? { |existing| existing.id == staff.id }
32
+
27
33
  default_group.add_staff(staff)
28
34
  self
29
35
  end
30
36
 
31
37
  # @return [Array<Staff>]
32
38
  def staves
33
- staff_groups.flat_map(&:staves)
39
+ @staff_groups.flat_map(&:staves).freeze
40
+ end
41
+
42
+ # @return [Array<StaffGroup>]
43
+ def staff_groups
44
+ @staff_groups.dup.freeze
34
45
  end
35
46
 
36
47
  # @param path [String]
@@ -45,16 +56,144 @@ module Clef
45
56
  ::Clef::Compiler.new(self, **options).compile_to_svg(path)
46
57
  end
47
58
 
59
+ # @param path [String, #write]
60
+ # @param options [Hash]
61
+ def to_midi(path, **options)
62
+ ::Clef::Midi::Exporter.new(self, **options).export(path)
63
+ end
64
+
48
65
  # @param path [String]
49
- def to_midi(path, **_options)
50
- ::Clef::Midi::Exporter.new(self).export(path)
66
+ # @param options [Hash]
67
+ def to_format(path, **options)
68
+ case File.extname(path.to_s).downcase
69
+ when ".pdf" then to_pdf(path, **options)
70
+ when ".svg" then to_svg(path, **options)
71
+ when ".mid", ".midi" then to_midi(path, **options)
72
+ else
73
+ raise ArgumentError, "unsupported output format for #{path.inspect}"
74
+ end
75
+ end
76
+
77
+ # @param strict [Boolean]
78
+ # @return [ValidationResult]
79
+ def validate(strict: false)
80
+ result = ValidationResult.new(validation_issues)
81
+ raise Clef::Error, result.errors.map(&:message).join(", ") if strict && !result.ok?
82
+
83
+ result
84
+ end
85
+
86
+ # @return [Array<ValidationIssue>]
87
+ def validation_warnings
88
+ validate.warnings
51
89
  end
52
90
 
53
91
  private
54
92
 
93
+ def validation_issues
94
+ staff_issues + measure_issues + lyric_issues + pitch_issues
95
+ end
96
+
97
+ def staff_issues
98
+ staves.map(&:id).tally.filter_map do |id, count|
99
+ next unless count > 1
100
+
101
+ ValidationIssue.new(severity: :error, message: "duplicate staff id: #{id}", path: [:score, :staves, id])
102
+ end
103
+ end
104
+
105
+ def measure_issues
106
+ staves.flat_map do |staff|
107
+ staff.measures.flat_map do |measure|
108
+ overflow_issues(staff, measure) + underfull_issues(staff, measure)
109
+ end
110
+ end
111
+ end
112
+
113
+ def overflow_issues(staff, measure)
114
+ measure.overflowing_voice_ids.map do |voice_id|
115
+ ValidationIssue.new(
116
+ severity: :error,
117
+ message: "measure #{measure.number} voice #{voice_id} exceeds time signature length",
118
+ path: [:staff, staff.id, :measure, measure.number, :voice, voice_id]
119
+ )
120
+ end
121
+ end
122
+
123
+ def underfull_issues(staff, measure)
124
+ measure.underfull_voice_ids.map do |voice_id|
125
+ ValidationIssue.new(
126
+ severity: :warning,
127
+ message: "measure #{measure.number} voice #{voice_id} is shorter than time signature length",
128
+ path: [:staff, staff.id, :measure, measure.number, :voice, voice_id]
129
+ )
130
+ end
131
+ end
132
+
133
+ def lyric_issues
134
+ staves.flat_map do |staff|
135
+ Array(staff.metadata[:lyrics]).flat_map do |lyric|
136
+ note_count = staff.measures.sum do |measure|
137
+ lyric_notes(Array(measure.voices[lyric.voice_id]&.elements)).length
138
+ end
139
+ next [] if lyric.note_slot_count == note_count
140
+
141
+ [
142
+ ValidationIssue.new(
143
+ severity: :warning,
144
+ message: "lyrics for voice #{lyric.voice_id} have #{lyric.note_slot_count} syllables for #{note_count} notes",
145
+ path: [:staff, staff.id, :lyrics, lyric.voice_id]
146
+ )
147
+ ]
148
+ end
149
+ end
150
+ end
151
+
152
+ def lyric_notes(elements)
153
+ elements.flat_map do |element|
154
+ case element
155
+ when Note, Chord then [element]
156
+ when Tuplet then lyric_notes(element.elements)
157
+ else []
158
+ end
159
+ end
160
+ end
161
+
162
+ def pitch_issues
163
+ staves.flat_map do |staff|
164
+ staff.measures.flat_map do |measure|
165
+ measure.voices.flat_map do |voice_id, voice|
166
+ voice.elements.flat_map { |element| invalid_pitch_issues(element, staff.id, measure.number, voice_id) }
167
+ end
168
+ end
169
+ end
170
+ end
171
+
172
+ def invalid_pitch_issues(element, staff_id, measure_number, voice_id)
173
+ pitches_for(element).filter_map do |pitch|
174
+ pitch.to_midi
175
+ nil
176
+ rescue RangeError => e
177
+ ValidationIssue.new(
178
+ severity: :error,
179
+ message: e.message,
180
+ path: [:staff, staff_id, :measure, measure_number, :voice, voice_id]
181
+ )
182
+ end
183
+ end
184
+
185
+ def pitches_for(element)
186
+ case element
187
+ when Note then [element.pitch]
188
+ when Chord then element.pitches
189
+ when Tuplet then element.elements.flat_map { |child| pitches_for(child) }
190
+ else []
191
+ end
192
+ end
193
+
55
194
  def default_group
56
- staff_groups.first || add_staff_group(StaffGroup.new)
57
- staff_groups.first
195
+ @staff_groups.first || add_staff_group(StaffGroup.new)
196
+ @staff_groups.first
58
197
  end
59
198
  end
60
199
  end
@@ -3,9 +3,10 @@
3
3
  module Clef
4
4
  module Core
5
5
  class Staff
6
- attr_reader :id, :name, :clef, :measures
6
+ include Metadata
7
+
8
+ attr_reader :id, :name, :clef
7
9
  attr_accessor :key_signature, :time_signature
8
- attr_accessor :metadata
9
10
 
10
11
  # @param id [Symbol]
11
12
  # @param name [String, nil]
@@ -25,10 +26,19 @@ module Clef
25
26
  # @return [Staff]
26
27
  def add_measure(measure)
27
28
  raise ArgumentError, "measure must be a Clef::Core::Measure" unless measure.is_a?(Measure)
29
+ raise ArgumentError, "duplicate measure number: #{measure.number}" if @measures.any? { |item| item.number == measure.number }
30
+ if @measures.any? && measure.number < @measures.last.number
31
+ raise ArgumentError, "measure numbers must be added in ascending order"
32
+ end
28
33
 
29
- measures << measure
34
+ @measures << measure
30
35
  self
31
36
  end
37
+
38
+ # @return [Array<Measure>]
39
+ def measures
40
+ @measures.dup.freeze
41
+ end
32
42
  end
33
43
  end
34
44
  end
@@ -5,7 +5,7 @@ module Clef
5
5
  class StaffGroup
6
6
  BRACKETS = %i[bracket brace none].freeze
7
7
 
8
- attr_reader :staves, :bracket_type
8
+ attr_reader :bracket_type
9
9
 
10
10
  # @param staves [Array<Staff>]
11
11
  # @param bracket_type [Symbol]
@@ -21,10 +21,16 @@ module Clef
21
21
  # @return [StaffGroup]
22
22
  def add_staff(staff)
23
23
  raise ArgumentError, "staff must be a Clef::Core::Staff" unless staff.is_a?(Staff)
24
+ raise ArgumentError, "duplicate staff id: #{staff.id}" if @staves.any? { |item| item.id == staff.id }
24
25
 
25
- staves << staff
26
+ @staves << staff
26
27
  self
27
28
  end
29
+
30
+ # @return [Array<Staff>]
31
+ def staves
32
+ @staves.dup.freeze
33
+ end
28
34
  end
29
35
  end
30
36
  end
@@ -14,6 +14,11 @@ module Clef
14
14
  @beat_unit = beat_unit
15
15
  @bpm = bpm
16
16
  end
17
+
18
+ # @return [Rational]
19
+ def length
20
+ Rational(0, 1)
21
+ end
17
22
  end
18
23
  end
19
24
  end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clef
4
+ module Core
5
+ class Tuplet
6
+ ELEMENT_TYPES = [Note, Rest, Chord, self].freeze
7
+
8
+ attr_reader :actual, :normal, :elements
9
+
10
+ # @param actual [Integer]
11
+ # @param normal [Integer]
12
+ # @param elements [Array<Note, Rest, Chord, Tuplet>]
13
+ def initialize(actual, normal, elements)
14
+ raise ArgumentError, "tuplet values must be positive" unless actual.is_a?(Integer) && actual.positive? &&
15
+ normal.is_a?(Integer) && normal.positive?
16
+
17
+ @actual = actual
18
+ @normal = normal
19
+ @elements = Array(elements)
20
+ validate_elements!
21
+ @elements.freeze
22
+ end
23
+
24
+ # @return [Rational]
25
+ def length
26
+ raw_length * ratio
27
+ end
28
+
29
+ # @return [Rational]
30
+ def ratio
31
+ Rational(normal, actual)
32
+ end
33
+
34
+ private
35
+
36
+ def raw_length
37
+ elements.reduce(Rational(0, 1)) { |memo, element| memo + element.length }
38
+ end
39
+
40
+ def validate_elements!
41
+ raise ArgumentError, "tuplet must contain at least one element" if elements.empty?
42
+ return if elements.all? { |element| ELEMENT_TYPES.any? { |type| element.is_a?(type) } }
43
+
44
+ raise ArgumentError, "tuplet elements must be musical elements"
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clef
4
+ module Core
5
+ ValidationIssue = Struct.new(:severity, :message, :path, keyword_init: true) do
6
+ def warning?
7
+ severity == :warning
8
+ end
9
+
10
+ def error?
11
+ severity == :error
12
+ end
13
+ end
14
+
15
+ class ValidationResult
16
+ attr_reader :issues
17
+
18
+ # @param issues [Array<ValidationIssue>]
19
+ def initialize(issues = [])
20
+ @issues = issues
21
+ end
22
+
23
+ # @return [Boolean]
24
+ def ok?
25
+ errors.empty?
26
+ end
27
+
28
+ # @return [Array<ValidationIssue>]
29
+ def errors
30
+ issues.select(&:error?)
31
+ end
32
+
33
+ # @return [Array<ValidationIssue>]
34
+ def warnings
35
+ issues.select(&:warning?)
36
+ end
37
+ end
38
+ end
39
+ end
@@ -3,7 +3,9 @@
3
3
  module Clef
4
4
  module Core
5
5
  class Voice
6
- attr_reader :id, :elements
6
+ ELEMENT_TYPES = [Note, Rest, Chord, Tuplet, Tempo].freeze
7
+
8
+ attr_reader :id
7
9
 
8
10
  # @param id [Symbol]
9
11
  def initialize(id: :default)
@@ -11,18 +13,32 @@ module Clef
11
13
  @elements = []
12
14
  end
13
15
 
14
- # @param element [#length]
16
+ # @param element [Note, Rest, Chord, Tuplet]
15
17
  # @return [Voice]
16
18
  def add(element)
17
- raise ArgumentError, "element must respond to #length" unless element.respond_to?(:length)
19
+ raise ArgumentError, "element must be a musical element" unless musical_element?(element)
18
20
 
19
- elements << element
21
+ @elements << element
20
22
  self
21
23
  end
22
24
 
25
+ # @return [Array<Note, Rest, Chord, Tuplet, Tempo>]
26
+ def elements
27
+ @elements.dup.freeze
28
+ end
29
+
23
30
  # @return [Rational]
24
31
  def total_length
25
- elements.reduce(Rational(0, 1)) { |memo, element| memo + element.length }
32
+ @elements.reduce(Rational(0, 1)) { |memo, element| memo + element.length }
33
+ end
34
+
35
+ private
36
+
37
+ def musical_element?(element)
38
+ return true if ELEMENT_TYPES.any? { |type| element.is_a?(type) }
39
+ return true if defined?(::Clef::Notation::Dynamic) && element.is_a?(::Clef::Notation::Dynamic)
40
+
41
+ false
26
42
  end
27
43
  end
28
44
  end