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.
- checksums.yaml +4 -4
- data/README.md +77 -90
- data/Rakefile +21 -1
- data/exe/clef +21 -0
- data/lib/clef/compiler.rb +107 -4
- data/lib/clef/core/chord.rb +9 -3
- data/lib/clef/core/duration.rb +7 -3
- data/lib/clef/core/key_signature.rb +43 -36
- data/lib/clef/core/measure.rb +14 -10
- data/lib/clef/core/metadata.rb +52 -0
- data/lib/clef/core/note.rb +50 -4
- data/lib/clef/core/pitch.rb +73 -4
- data/lib/clef/core/rest.rb +11 -3
- data/lib/clef/core/score.rb +148 -9
- data/lib/clef/core/staff.rb +13 -3
- data/lib/clef/core/staff_group.rb +8 -2
- data/lib/clef/core/tempo.rb +5 -0
- data/lib/clef/core/tuplet.rb +48 -0
- data/lib/clef/core/validation.rb +39 -0
- data/lib/clef/core/voice.rb +21 -5
- data/lib/clef/engraving/font_manager.rb +1 -1
- data/lib/clef/engraving/glyph_table.rb +18 -3
- data/lib/clef/engraving/style.rb +41 -2
- data/lib/clef/ir/moment.rb +2 -2
- data/lib/clef/ir/music_tree.rb +2 -2
- data/lib/clef/ir/timeline.rb +25 -5
- data/lib/clef/layout/beam_layout.rb +2 -2
- data/lib/clef/layout/item.rb +26 -0
- data/lib/clef/layout/spacing.rb +6 -4
- data/lib/clef/layout/stem.rb +10 -6
- data/lib/clef/layout/system_layout.rb +71 -0
- data/lib/clef/midi/channel_map.rb +5 -2
- data/lib/clef/midi/exporter.rb +316 -38
- data/lib/clef/notation/dynamic.rb +5 -0
- data/lib/clef/notation/lyric.rb +33 -1
- data/lib/clef/parser/dsl.rb +249 -58
- data/lib/clef/parser/lilypond_lexer.rb +43 -3
- data/lib/clef/parser/lilypond_parser.rb +231 -17
- data/lib/clef/plugins/base.rb +24 -4
- data/lib/clef/plugins/registry.rb +80 -10
- data/lib/clef/renderer/base.rb +2 -2
- data/lib/clef/renderer/drawing_context.rb +26 -0
- data/lib/clef/renderer/notation_helpers.rb +92 -1
- data/lib/clef/renderer/pdf_renderer.rb +487 -82
- data/lib/clef/renderer/svg_renderer.rb +510 -97
- data/lib/clef/version.rb +1 -1
- data/lib/clef.rb +60 -7
- data/sig/clef.rbs +292 -0
- 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
|
data/lib/clef/core/note.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
20
|
-
@
|
|
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
|
data/lib/clef/core/pitch.rb
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
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
|
data/lib/clef/core/rest.rb
CHANGED
|
@@ -3,18 +3,26 @@
|
|
|
3
3
|
module Clef
|
|
4
4
|
module Core
|
|
5
5
|
class Rest
|
|
6
|
-
|
|
6
|
+
KINDS = %i[visible invisible spacer multi_measure].freeze
|
|
7
|
+
|
|
8
|
+
attr_reader :duration, :kind, :measures
|
|
7
9
|
|
|
8
10
|
# @param duration [Duration]
|
|
9
|
-
|
|
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
|
data/lib/clef/core/score.rb
CHANGED
|
@@ -3,34 +3,45 @@
|
|
|
3
3
|
module Clef
|
|
4
4
|
module Core
|
|
5
5
|
class Score
|
|
6
|
-
|
|
7
|
-
|
|
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
|
-
|
|
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
|
-
|
|
50
|
-
|
|
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
|
data/lib/clef/core/staff.rb
CHANGED
|
@@ -3,9 +3,10 @@
|
|
|
3
3
|
module Clef
|
|
4
4
|
module Core
|
|
5
5
|
class Staff
|
|
6
|
-
|
|
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 :
|
|
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
|
data/lib/clef/core/tempo.rb
CHANGED
|
@@ -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
|
data/lib/clef/core/voice.rb
CHANGED
|
@@ -3,7 +3,9 @@
|
|
|
3
3
|
module Clef
|
|
4
4
|
module Core
|
|
5
5
|
class Voice
|
|
6
|
-
|
|
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 [
|
|
16
|
+
# @param element [Note, Rest, Chord, Tuplet]
|
|
15
17
|
# @return [Voice]
|
|
16
18
|
def add(element)
|
|
17
|
-
raise ArgumentError, "element must
|
|
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
|