clef 0.1.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 +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +172 -0
- data/Rakefile +17 -0
- data/examples/bach_cello_suite.rb +16 -0
- data/examples/piano_score.rb +24 -0
- data/examples/twinkle.rb +18 -0
- data/examples/vocal_score.rb +21 -0
- data/fonts/bravura/README.md +8 -0
- data/lib/clef/compiler.rb +46 -0
- data/lib/clef/core/chord.rb +34 -0
- data/lib/clef/core/clef.rb +40 -0
- data/lib/clef/core/duration.rb +96 -0
- data/lib/clef/core/key_signature.rb +79 -0
- data/lib/clef/core/measure.rb +46 -0
- data/lib/clef/core/note.rb +43 -0
- data/lib/clef/core/pitch.rb +151 -0
- data/lib/clef/core/rest.rb +21 -0
- data/lib/clef/core/score.rb +61 -0
- data/lib/clef/core/staff.rb +34 -0
- data/lib/clef/core/staff_group.rb +30 -0
- data/lib/clef/core/tempo.rb +19 -0
- data/lib/clef/core/time_signature.rb +38 -0
- data/lib/clef/core/voice.rb +29 -0
- data/lib/clef/engraving/font_manager.rb +35 -0
- data/lib/clef/engraving/glyph_table.rb +52 -0
- data/lib/clef/engraving/rules.rb +14 -0
- data/lib/clef/engraving/style.rb +27 -0
- data/lib/clef/ir/event.rb +22 -0
- data/lib/clef/ir/moment.rb +64 -0
- data/lib/clef/ir/music_tree.rb +54 -0
- data/lib/clef/ir/timeline.rb +69 -0
- data/lib/clef/layout/beam_layout.rb +42 -0
- data/lib/clef/layout/line_breaker.rb +60 -0
- data/lib/clef/layout/page_breaker.rb +16 -0
- data/lib/clef/layout/spacing.rb +56 -0
- data/lib/clef/layout/stem.rb +38 -0
- data/lib/clef/midi/channel_map.rb +14 -0
- data/lib/clef/midi/exporter.rb +81 -0
- data/lib/clef/notation/articulation.rb +18 -0
- data/lib/clef/notation/barline.rb +30 -0
- data/lib/clef/notation/beam.rb +25 -0
- data/lib/clef/notation/dynamic.rb +18 -0
- data/lib/clef/notation/lyric.rb +28 -0
- data/lib/clef/notation/slur.rb +28 -0
- data/lib/clef/notation/tie.rb +30 -0
- data/lib/clef/parser/dsl.rb +265 -0
- data/lib/clef/parser/lilypond_lexer.rb +22 -0
- data/lib/clef/parser/lilypond_parser.rb +57 -0
- data/lib/clef/plugins/base.rb +26 -0
- data/lib/clef/plugins/registry.rb +34 -0
- data/lib/clef/renderer/base.rb +26 -0
- data/lib/clef/renderer/notation_helpers.rb +71 -0
- data/lib/clef/renderer/pdf_renderer.rb +341 -0
- data/lib/clef/renderer/svg_renderer.rb +206 -0
- data/lib/clef/version.rb +5 -0
- data/lib/clef.rb +25 -0
- metadata +141 -0
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Clef
|
|
4
|
+
module Core
|
|
5
|
+
class Pitch
|
|
6
|
+
include Comparable
|
|
7
|
+
|
|
8
|
+
NOTE_VALUES = {
|
|
9
|
+
c: 0,
|
|
10
|
+
d: 2,
|
|
11
|
+
e: 4,
|
|
12
|
+
f: 5,
|
|
13
|
+
g: 7,
|
|
14
|
+
a: 9,
|
|
15
|
+
b: 11
|
|
16
|
+
}.freeze
|
|
17
|
+
VALID_NOTE_NAMES = NOTE_VALUES.keys.freeze
|
|
18
|
+
ALTERATION_SUFFIX = {
|
|
19
|
+
-2 => "eses",
|
|
20
|
+
-1 => "es",
|
|
21
|
+
0 => "",
|
|
22
|
+
1 => "is",
|
|
23
|
+
2 => "isis"
|
|
24
|
+
}.freeze
|
|
25
|
+
SUFFIX_ALTERATION = ALTERATION_SUFFIX.invert.freeze
|
|
26
|
+
MIDI_CLASS_TO_PITCH = {
|
|
27
|
+
0 => [:c, 0],
|
|
28
|
+
1 => [:c, 1],
|
|
29
|
+
2 => [:d, 0],
|
|
30
|
+
3 => [:d, 1],
|
|
31
|
+
4 => [:e, 0],
|
|
32
|
+
5 => [:f, 0],
|
|
33
|
+
6 => [:f, 1],
|
|
34
|
+
7 => [:g, 0],
|
|
35
|
+
8 => [:g, 1],
|
|
36
|
+
9 => [:a, 0],
|
|
37
|
+
10 => [:a, 1],
|
|
38
|
+
11 => [:b, 0]
|
|
39
|
+
}.freeze
|
|
40
|
+
|
|
41
|
+
attr_reader :note_name, :octave, :alteration
|
|
42
|
+
|
|
43
|
+
# @param note_name [Symbol]
|
|
44
|
+
# @param octave [Integer]
|
|
45
|
+
# @param alteration [Integer]
|
|
46
|
+
def initialize(note_name, octave, alteration: 0)
|
|
47
|
+
validate_note_name!(note_name)
|
|
48
|
+
validate_octave!(octave)
|
|
49
|
+
validate_alteration!(alteration)
|
|
50
|
+
|
|
51
|
+
@note_name = note_name
|
|
52
|
+
@octave = octave
|
|
53
|
+
@alteration = alteration
|
|
54
|
+
freeze
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# @return [Integer]
|
|
58
|
+
def to_midi
|
|
59
|
+
semitones + 12
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# @return [Integer]
|
|
63
|
+
def semitones
|
|
64
|
+
octave * 12 + NOTE_VALUES.fetch(note_name) + alteration
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# @param tuning [Float]
|
|
68
|
+
# @return [Float]
|
|
69
|
+
def to_frequency(tuning: 440.0)
|
|
70
|
+
tuning * (2.0**((to_midi - 69) / 12.0))
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# @param semitones_or_interval [Integer, #semitones]
|
|
74
|
+
# @return [Pitch]
|
|
75
|
+
def transpose(semitones_or_interval)
|
|
76
|
+
target_midi = to_midi + normalize_semitones(semitones_or_interval)
|
|
77
|
+
octave = (target_midi / 12) - 1
|
|
78
|
+
note_name, alteration = MIDI_CLASS_TO_PITCH.fetch(target_midi % 12)
|
|
79
|
+
self.class.new(note_name, octave, alteration: alteration)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# @param other [Pitch]
|
|
83
|
+
# @return [Boolean]
|
|
84
|
+
def enharmonic?(other)
|
|
85
|
+
other.is_a?(self.class) && semitones == other.semitones
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# @param other [Pitch]
|
|
89
|
+
# @return [Integer, nil]
|
|
90
|
+
def <=>(other)
|
|
91
|
+
return nil unless other.is_a?(self.class)
|
|
92
|
+
|
|
93
|
+
semitones <=> other.semitones
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# @return [String]
|
|
97
|
+
def to_lilypond
|
|
98
|
+
[note_name, ALTERATION_SUFFIX.fetch(alteration), octave_marks].join
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# @param str [String]
|
|
102
|
+
# @return [Pitch]
|
|
103
|
+
def self.parse(str)
|
|
104
|
+
raise ArgumentError, "pitch string must be a String" unless str.is_a?(String)
|
|
105
|
+
|
|
106
|
+
match = /\A([a-g])(eses|isis|es|is)?([',]*)\z/.match(str)
|
|
107
|
+
raise ArgumentError, "invalid lilypond pitch: #{str}" unless match
|
|
108
|
+
|
|
109
|
+
note_name = match[1].to_sym
|
|
110
|
+
suffix = match[2] || ""
|
|
111
|
+
octave = 3 + match[3].count("'") - match[3].count(",")
|
|
112
|
+
new(note_name, octave, alteration: SUFFIX_ALTERATION.fetch(suffix))
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
private
|
|
116
|
+
|
|
117
|
+
def octave_marks
|
|
118
|
+
delta = octave - 3
|
|
119
|
+
return "'" * delta if delta.positive?
|
|
120
|
+
|
|
121
|
+
"," * delta.abs
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def normalize_semitones(arg)
|
|
125
|
+
return arg.semitones if arg.respond_to?(:semitones)
|
|
126
|
+
|
|
127
|
+
Integer(arg)
|
|
128
|
+
rescue ArgumentError, TypeError
|
|
129
|
+
raise ArgumentError, "transpose expects Integer or object responding to #semitones"
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def validate_note_name!(note_name)
|
|
133
|
+
return if VALID_NOTE_NAMES.include?(note_name)
|
|
134
|
+
|
|
135
|
+
raise ArgumentError, "invalid note name: #{note_name.inspect}"
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def validate_octave!(octave)
|
|
139
|
+
return if octave.is_a?(Integer)
|
|
140
|
+
|
|
141
|
+
raise ArgumentError, "octave must be an Integer"
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def validate_alteration!(alteration)
|
|
145
|
+
return if (-2..2).cover?(alteration)
|
|
146
|
+
|
|
147
|
+
raise ArgumentError, "alteration must be between -2 and 2"
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Clef
|
|
4
|
+
module Core
|
|
5
|
+
class Rest
|
|
6
|
+
attr_reader :duration
|
|
7
|
+
|
|
8
|
+
# @param duration [Duration]
|
|
9
|
+
def initialize(duration)
|
|
10
|
+
raise ArgumentError, "duration must be a Clef::Core::Duration" unless duration.is_a?(Duration)
|
|
11
|
+
|
|
12
|
+
@duration = duration
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# @return [Rational]
|
|
16
|
+
def length
|
|
17
|
+
duration.length
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Clef
|
|
4
|
+
module Core
|
|
5
|
+
class Score
|
|
6
|
+
attr_reader :staff_groups, :metadata
|
|
7
|
+
attr_accessor :title, :composer, :tempo
|
|
8
|
+
|
|
9
|
+
# @param metadata [Hash]
|
|
10
|
+
def initialize(metadata: {})
|
|
11
|
+
@staff_groups = []
|
|
12
|
+
@metadata = metadata
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# @param staff_group [StaffGroup]
|
|
16
|
+
# @return [Score]
|
|
17
|
+
def add_staff_group(staff_group)
|
|
18
|
+
raise ArgumentError, "staff_group must be a Clef::Core::StaffGroup" unless staff_group.is_a?(StaffGroup)
|
|
19
|
+
|
|
20
|
+
staff_groups << staff_group
|
|
21
|
+
self
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# @param staff [Staff]
|
|
25
|
+
# @return [Score]
|
|
26
|
+
def add_staff(staff)
|
|
27
|
+
default_group.add_staff(staff)
|
|
28
|
+
self
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# @return [Array<Staff>]
|
|
32
|
+
def staves
|
|
33
|
+
staff_groups.flat_map(&:staves)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# @param path [String]
|
|
37
|
+
# @param options [Hash]
|
|
38
|
+
def to_pdf(path, **options)
|
|
39
|
+
::Clef::Compiler.new(self, **options).compile_to_pdf(path)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# @param path [String]
|
|
43
|
+
# @param options [Hash]
|
|
44
|
+
def to_svg(path, **options)
|
|
45
|
+
::Clef::Compiler.new(self, **options).compile_to_svg(path)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# @param path [String]
|
|
49
|
+
def to_midi(path, **_options)
|
|
50
|
+
::Clef::Midi::Exporter.new(self).export(path)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def default_group
|
|
56
|
+
staff_groups.first || add_staff_group(StaffGroup.new)
|
|
57
|
+
staff_groups.first
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Clef
|
|
4
|
+
module Core
|
|
5
|
+
class Staff
|
|
6
|
+
attr_reader :id, :name, :clef, :measures
|
|
7
|
+
attr_accessor :key_signature, :time_signature
|
|
8
|
+
attr_accessor :metadata
|
|
9
|
+
|
|
10
|
+
# @param id [Symbol]
|
|
11
|
+
# @param name [String, nil]
|
|
12
|
+
# @param clef [Clef]
|
|
13
|
+
def initialize(id, name: nil, clef: Clef.new(:treble))
|
|
14
|
+
raise ArgumentError, "id must be provided" if id.nil?
|
|
15
|
+
raise ArgumentError, "clef must be a Clef::Core::Clef" unless clef.is_a?(Clef)
|
|
16
|
+
|
|
17
|
+
@id = id
|
|
18
|
+
@name = name || id.to_s
|
|
19
|
+
@clef = clef
|
|
20
|
+
@measures = []
|
|
21
|
+
@metadata = {}
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# @param measure [Measure]
|
|
25
|
+
# @return [Staff]
|
|
26
|
+
def add_measure(measure)
|
|
27
|
+
raise ArgumentError, "measure must be a Clef::Core::Measure" unless measure.is_a?(Measure)
|
|
28
|
+
|
|
29
|
+
measures << measure
|
|
30
|
+
self
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Clef
|
|
4
|
+
module Core
|
|
5
|
+
class StaffGroup
|
|
6
|
+
BRACKETS = %i[bracket brace none].freeze
|
|
7
|
+
|
|
8
|
+
attr_reader :staves, :bracket_type
|
|
9
|
+
|
|
10
|
+
# @param staves [Array<Staff>]
|
|
11
|
+
# @param bracket_type [Symbol]
|
|
12
|
+
def initialize(staves = [], bracket_type: :none)
|
|
13
|
+
raise ArgumentError, "unsupported bracket type" unless BRACKETS.include?(bracket_type)
|
|
14
|
+
|
|
15
|
+
@staves = []
|
|
16
|
+
@bracket_type = bracket_type
|
|
17
|
+
staves.each { |staff| add_staff(staff) }
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# @param staff [Staff]
|
|
21
|
+
# @return [StaffGroup]
|
|
22
|
+
def add_staff(staff)
|
|
23
|
+
raise ArgumentError, "staff must be a Clef::Core::Staff" unless staff.is_a?(Staff)
|
|
24
|
+
|
|
25
|
+
staves << staff
|
|
26
|
+
self
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Clef
|
|
4
|
+
module Core
|
|
5
|
+
class Tempo
|
|
6
|
+
attr_reader :beat_unit, :bpm
|
|
7
|
+
|
|
8
|
+
# @param beat_unit [Duration]
|
|
9
|
+
# @param bpm [Integer]
|
|
10
|
+
def initialize(beat_unit:, bpm:)
|
|
11
|
+
raise ArgumentError, "beat_unit must be a Clef::Core::Duration" unless beat_unit.is_a?(Duration)
|
|
12
|
+
raise ArgumentError, "bpm must be a positive Integer" unless bpm.is_a?(Integer) && bpm.positive?
|
|
13
|
+
|
|
14
|
+
@beat_unit = beat_unit
|
|
15
|
+
@bpm = bpm
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Clef
|
|
4
|
+
module Core
|
|
5
|
+
class TimeSignature
|
|
6
|
+
attr_reader :numerator, :denominator
|
|
7
|
+
|
|
8
|
+
# @param numerator [Integer]
|
|
9
|
+
# @param denominator [Integer]
|
|
10
|
+
def initialize(numerator, denominator)
|
|
11
|
+
validate_numerator!(numerator)
|
|
12
|
+
validate_denominator!(denominator)
|
|
13
|
+
|
|
14
|
+
@numerator = numerator
|
|
15
|
+
@denominator = denominator
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# @return [Rational]
|
|
19
|
+
def measure_length
|
|
20
|
+
Rational(numerator, denominator)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def validate_numerator!(value)
|
|
26
|
+
return if value.is_a?(Integer) && value.positive?
|
|
27
|
+
|
|
28
|
+
raise ArgumentError, "numerator must be a positive Integer"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def validate_denominator!(value)
|
|
32
|
+
return if value.is_a?(Integer) && value.positive? && (value & (value - 1)).zero?
|
|
33
|
+
|
|
34
|
+
raise ArgumentError, "denominator must be a positive power of two"
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Clef
|
|
4
|
+
module Core
|
|
5
|
+
class Voice
|
|
6
|
+
attr_reader :id, :elements
|
|
7
|
+
|
|
8
|
+
# @param id [Symbol]
|
|
9
|
+
def initialize(id: :default)
|
|
10
|
+
@id = id
|
|
11
|
+
@elements = []
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# @param element [#length]
|
|
15
|
+
# @return [Voice]
|
|
16
|
+
def add(element)
|
|
17
|
+
raise ArgumentError, "element must respond to #length" unless element.respond_to?(:length)
|
|
18
|
+
|
|
19
|
+
elements << element
|
|
20
|
+
self
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# @return [Rational]
|
|
24
|
+
def total_length
|
|
25
|
+
elements.reduce(Rational(0, 1)) { |memo, element| memo + element.length }
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Clef
|
|
4
|
+
module Engraving
|
|
5
|
+
class FontManager
|
|
6
|
+
attr_reader :font_path
|
|
7
|
+
|
|
8
|
+
# @param font_path [String]
|
|
9
|
+
def initialize(font_path: default_font_path)
|
|
10
|
+
@font_path = font_path
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# @return [Boolean]
|
|
14
|
+
def font_available?
|
|
15
|
+
File.exist?(font_path)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# @param pdf [Prawn::Document]
|
|
19
|
+
# @param family_name [String]
|
|
20
|
+
# @return [String]
|
|
21
|
+
def register_with(pdf, family_name: "Bravura")
|
|
22
|
+
return "Helvetica" unless font_available?
|
|
23
|
+
|
|
24
|
+
pdf.font_families.update(family_name => { normal: font_path })
|
|
25
|
+
family_name
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def default_font_path
|
|
31
|
+
File.expand_path("../../../fonts/bravura/Bravura.otf", __dir__)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Clef
|
|
4
|
+
module Engraving
|
|
5
|
+
class GlyphTable
|
|
6
|
+
GLYPHS = {
|
|
7
|
+
notehead_black: "\uE0A4",
|
|
8
|
+
notehead_half: "\uE0A3",
|
|
9
|
+
notehead_whole: "\uE0A2",
|
|
10
|
+
stem: "\uE210",
|
|
11
|
+
flag_8th_up: "\uE240",
|
|
12
|
+
flag_8th_down: "\uE241",
|
|
13
|
+
flag_16th_up: "\uE242",
|
|
14
|
+
flag_16th_down: "\uE243",
|
|
15
|
+
rest_whole: "\uE4E3",
|
|
16
|
+
rest_half: "\uE4E4",
|
|
17
|
+
rest_quarter: "\uE4E5",
|
|
18
|
+
rest_8th: "\uE4E6",
|
|
19
|
+
rest_16th: "\uE4E7",
|
|
20
|
+
clef_treble: "\uE050",
|
|
21
|
+
clef_bass: "\uE062",
|
|
22
|
+
clef_alto: "\uE05C",
|
|
23
|
+
accidental_sharp: "\uE262",
|
|
24
|
+
accidental_flat: "\uE260",
|
|
25
|
+
accidental_double_sharp: "\uE263",
|
|
26
|
+
accidental_double_flat: "\uE264",
|
|
27
|
+
accidental_natural: "\uE261",
|
|
28
|
+
dynamic_p: "\uE520",
|
|
29
|
+
dynamic_f: "\uE522",
|
|
30
|
+
fermata: "\uE4C0"
|
|
31
|
+
}.merge((0..9).to_h { |n| [:"time_#{n}", (0xE080 + n).chr(Encoding::UTF_8)] }).freeze
|
|
32
|
+
|
|
33
|
+
# @param name [Symbol]
|
|
34
|
+
# @return [String]
|
|
35
|
+
def fetch(name)
|
|
36
|
+
GLYPHS.fetch(name)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# @param name [Symbol]
|
|
40
|
+
# @return [String, nil]
|
|
41
|
+
def [](name)
|
|
42
|
+
GLYPHS[name]
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# @param name [Symbol]
|
|
46
|
+
# @return [Boolean]
|
|
47
|
+
def key?(name)
|
|
48
|
+
GLYPHS.key?(name)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Clef
|
|
4
|
+
module Engraving
|
|
5
|
+
class Style
|
|
6
|
+
attr_reader :page_size, :margin, :staff_size, :staff_space, :min_note_spacing
|
|
7
|
+
|
|
8
|
+
# @param page_size [String, Array]
|
|
9
|
+
# @param margin [Numeric]
|
|
10
|
+
# @param staff_size [Numeric]
|
|
11
|
+
# @param staff_space [Numeric]
|
|
12
|
+
# @param min_note_spacing [Numeric]
|
|
13
|
+
def initialize(page_size: "A4", margin: 36, staff_size: 20, staff_space: 10, min_note_spacing: 24)
|
|
14
|
+
@page_size = page_size
|
|
15
|
+
@margin = margin
|
|
16
|
+
@staff_size = staff_size
|
|
17
|
+
@staff_space = staff_space
|
|
18
|
+
@min_note_spacing = min_note_spacing
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# @return [Style]
|
|
22
|
+
def self.default
|
|
23
|
+
new
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Clef
|
|
4
|
+
module Ir
|
|
5
|
+
class Event
|
|
6
|
+
attr_reader :moment, :element, :staff_id, :voice_id
|
|
7
|
+
|
|
8
|
+
# @param moment [Moment]
|
|
9
|
+
# @param element [Object]
|
|
10
|
+
# @param staff_id [Symbol, String, nil]
|
|
11
|
+
# @param voice_id [Symbol, String, nil]
|
|
12
|
+
def initialize(moment:, element:, staff_id: nil, voice_id: nil)
|
|
13
|
+
raise ArgumentError, "moment must be a Clef::Ir::Moment" unless moment.is_a?(Moment)
|
|
14
|
+
|
|
15
|
+
@moment = moment
|
|
16
|
+
@element = element
|
|
17
|
+
@staff_id = staff_id
|
|
18
|
+
@voice_id = voice_id
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Clef
|
|
4
|
+
module Ir
|
|
5
|
+
class Moment
|
|
6
|
+
include Comparable
|
|
7
|
+
|
|
8
|
+
attr_reader :value
|
|
9
|
+
|
|
10
|
+
# @param value [Rational, Integer]
|
|
11
|
+
def initialize(value)
|
|
12
|
+
@value = Rational(value)
|
|
13
|
+
rescue TypeError
|
|
14
|
+
raise ArgumentError, "moment value must be Rational-compatible"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# @param duration [Rational, #length]
|
|
18
|
+
# @return [Moment]
|
|
19
|
+
def +(duration)
|
|
20
|
+
self.class.new(value + normalize_duration(duration))
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# @param other [Moment, Rational, Integer]
|
|
24
|
+
# @return [Rational]
|
|
25
|
+
def -(other)
|
|
26
|
+
value - normalize_other(other)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# @param other [Moment]
|
|
30
|
+
# @return [Integer, nil]
|
|
31
|
+
def <=>(other)
|
|
32
|
+
return nil unless other.is_a?(self.class)
|
|
33
|
+
|
|
34
|
+
value <=> other.value
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def hash
|
|
38
|
+
value.hash
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def eql?(other)
|
|
42
|
+
other.is_a?(self.class) && value.eql?(other.value)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def normalize_duration(duration)
|
|
48
|
+
return duration.length if duration.respond_to?(:length)
|
|
49
|
+
|
|
50
|
+
Rational(duration)
|
|
51
|
+
rescue TypeError
|
|
52
|
+
raise ArgumentError, "duration must be Rational-compatible or respond to #length"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def normalize_other(other)
|
|
56
|
+
return other.value if other.is_a?(self.class)
|
|
57
|
+
|
|
58
|
+
Rational(other)
|
|
59
|
+
rescue TypeError
|
|
60
|
+
raise ArgumentError, "other must be a Moment or Rational-compatible"
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Clef
|
|
4
|
+
module Ir
|
|
5
|
+
class MusicTree
|
|
6
|
+
class << self
|
|
7
|
+
# @param score [Clef::Core::Score]
|
|
8
|
+
# @return [Timeline]
|
|
9
|
+
def build(score)
|
|
10
|
+
timeline = Timeline.new
|
|
11
|
+
score.staves.each { |staff| append_staff_events(timeline, staff) }
|
|
12
|
+
timeline
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
private
|
|
16
|
+
|
|
17
|
+
def append_staff_events(timeline, staff)
|
|
18
|
+
current = Moment.new(0)
|
|
19
|
+
staff.measures.each do |measure|
|
|
20
|
+
append_measure_metadata(timeline, measure, staff, current)
|
|
21
|
+
append_measure_voices(timeline, measure, staff, current)
|
|
22
|
+
current = current + measure_length_for(measure)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def append_measure_metadata(timeline, measure, staff, current)
|
|
27
|
+
[measure.clef, measure.key_signature, measure.time_signature].compact.each do |element|
|
|
28
|
+
timeline.add(Event.new(moment: current, element: element, staff_id: staff.id, voice_id: :meta))
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def append_measure_voices(timeline, measure, staff, current)
|
|
33
|
+
measure.voices.each do |voice_id, voice|
|
|
34
|
+
append_voice_events(timeline, voice, current, staff.id, voice_id)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def append_voice_events(timeline, voice, start_moment, staff_id, voice_id)
|
|
39
|
+
cursor = Moment.new(start_moment.value)
|
|
40
|
+
voice.elements.each do |element|
|
|
41
|
+
timeline.add(Event.new(moment: cursor, element: element, staff_id: staff_id, voice_id: voice_id))
|
|
42
|
+
cursor = cursor + element.length
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def measure_length_for(measure)
|
|
47
|
+
return measure.time_signature.measure_length if measure.time_signature
|
|
48
|
+
|
|
49
|
+
measure.voices.values.map(&:total_length).max || Rational(0, 1)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|