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,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Clef
|
|
4
|
+
module Ir
|
|
5
|
+
class Timeline
|
|
6
|
+
include Enumerable
|
|
7
|
+
|
|
8
|
+
attr_reader :events
|
|
9
|
+
|
|
10
|
+
def initialize
|
|
11
|
+
@events = []
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# @param event [Event]
|
|
15
|
+
# @return [Timeline]
|
|
16
|
+
def add(event)
|
|
17
|
+
raise ArgumentError, "event must be a Clef::Ir::Event" unless event.is_a?(Event)
|
|
18
|
+
|
|
19
|
+
events << event
|
|
20
|
+
events.sort_by! { |item| item.moment.value }
|
|
21
|
+
self
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# @param moment [Moment, Rational, Integer]
|
|
25
|
+
# @return [Array<Event>]
|
|
26
|
+
def events_at(moment)
|
|
27
|
+
target = moment_value(moment)
|
|
28
|
+
events.select { |event| event.moment.value == target }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# @param from [Moment, Rational, Integer]
|
|
32
|
+
# @param to [Moment, Rational, Integer]
|
|
33
|
+
# @return [Array<Event>]
|
|
34
|
+
def events_between(from, to)
|
|
35
|
+
lower = moment_value(from)
|
|
36
|
+
upper = moment_value(to)
|
|
37
|
+
events.select { |event| (lower..upper).cover?(event.moment.value) }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# @yield [Moment]
|
|
41
|
+
# @return [Enumerator]
|
|
42
|
+
def each_moment
|
|
43
|
+
return enum_for(:each_moment) unless block_given?
|
|
44
|
+
|
|
45
|
+
unique_moments.each { |moment| yield(moment) }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def each(&block)
|
|
49
|
+
events.each(&block)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# @return [Rational]
|
|
53
|
+
def shortest_duration
|
|
54
|
+
durations = events.filter_map { |event| event.element.length if event.element.respond_to?(:length) }
|
|
55
|
+
durations.min || Rational(1, 4)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def unique_moments
|
|
61
|
+
events.map(&:moment).uniq.sort
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def moment_value(value)
|
|
65
|
+
value.is_a?(Moment) ? value.value : Rational(value)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Clef
|
|
4
|
+
module Layout
|
|
5
|
+
class BeamLayout
|
|
6
|
+
class << self
|
|
7
|
+
# @param notes [Array<Clef::Core::Note>]
|
|
8
|
+
# @param time_signature [Clef::Core::TimeSignature]
|
|
9
|
+
# @return [Array<Array<Clef::Core::Note>>]
|
|
10
|
+
def auto_beam(notes, time_signature)
|
|
11
|
+
return [] if notes.empty?
|
|
12
|
+
|
|
13
|
+
notes.each_slice(group_size(time_signature)).to_a
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# @param beam_group [Array<Clef::Core::Note>]
|
|
17
|
+
# @param _clef [Clef::Core::Clef]
|
|
18
|
+
# @param _spacing [Hash]
|
|
19
|
+
# @return [Hash]
|
|
20
|
+
def compute(beam_group, _clef, _spacing)
|
|
21
|
+
first_y = pitch_y(beam_group.first.pitch)
|
|
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 }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def group_size(time_signature)
|
|
30
|
+
return 3 if time_signature.denominator == 8 && (time_signature.numerator % 3).zero?
|
|
31
|
+
return 4 if time_signature.denominator == 4 && time_signature.numerator == 4
|
|
32
|
+
|
|
33
|
+
[time_signature.numerator, 2].max
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def pitch_y(pitch)
|
|
37
|
+
pitch.to_midi.to_f / 12.0
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Clef
|
|
4
|
+
module Layout
|
|
5
|
+
class LineBreaker
|
|
6
|
+
# @param columns [Array<Hash>]
|
|
7
|
+
# @param line_width [Float]
|
|
8
|
+
# @return [Array<Array<Hash>>]
|
|
9
|
+
def break_into_lines(columns, line_width)
|
|
10
|
+
costs, breaks = initialize_dp(columns.length)
|
|
11
|
+
1.upto(columns.length) { |idx| update_dp_for_index(columns, line_width, idx, costs, breaks) }
|
|
12
|
+
build_lines(columns, breaks)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
private
|
|
16
|
+
|
|
17
|
+
def initialize_dp(size)
|
|
18
|
+
costs = Array.new(size + 1, Float::INFINITY)
|
|
19
|
+
breaks = Array.new(size + 1)
|
|
20
|
+
costs[0] = 0.0
|
|
21
|
+
[costs, breaks]
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def update_dp_for_index(columns, line_width, idx, costs, breaks)
|
|
25
|
+
width = 0.0
|
|
26
|
+
idx.downto(1) do |start_idx|
|
|
27
|
+
width += column_width(columns[start_idx - 1])
|
|
28
|
+
break if width > line_width && start_idx != idx
|
|
29
|
+
|
|
30
|
+
relax_edge(columns, idx, start_idx, width, line_width, costs, breaks)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def relax_edge(columns, idx, start_idx, width, line_width, costs, breaks)
|
|
35
|
+
badness = (line_width - width).abs**2
|
|
36
|
+
penalty = (columns[idx - 1][:break_penalty] || 0).to_f
|
|
37
|
+
next_cost = costs[start_idx - 1] + badness + penalty
|
|
38
|
+
return unless next_cost < costs[idx]
|
|
39
|
+
|
|
40
|
+
costs[idx] = next_cost
|
|
41
|
+
breaks[idx] = start_idx - 1
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def build_lines(columns, breaks)
|
|
45
|
+
slices = []
|
|
46
|
+
index = columns.length
|
|
47
|
+
while index.positive?
|
|
48
|
+
start_index = breaks[index] || 0
|
|
49
|
+
slices.unshift(columns[start_index...index])
|
|
50
|
+
index = start_index
|
|
51
|
+
end
|
|
52
|
+
slices
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def column_width(column)
|
|
56
|
+
column.fetch(:width).to_f
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Clef
|
|
4
|
+
module Layout
|
|
5
|
+
class PageBreaker
|
|
6
|
+
# @param lines [Array<Array<Hash>>]
|
|
7
|
+
# @param page_height [Float]
|
|
8
|
+
# @param line_height [Float]
|
|
9
|
+
# @return [Array<Array<Array<Hash>>>]
|
|
10
|
+
def break_into_pages(lines, page_height:, line_height:)
|
|
11
|
+
max_lines = [(page_height / line_height).floor, 1].max
|
|
12
|
+
lines.each_slice(max_lines).to_a
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Clef
|
|
4
|
+
module Layout
|
|
5
|
+
class Spacing
|
|
6
|
+
attr_reader :timeline, :style, :stretch_factor
|
|
7
|
+
|
|
8
|
+
# @param timeline [Clef::Ir::Timeline]
|
|
9
|
+
# @param style [Clef::Engraving::Style]
|
|
10
|
+
def initialize(timeline, style)
|
|
11
|
+
@timeline = timeline
|
|
12
|
+
@style = style
|
|
13
|
+
@stretch_factor = 1.0
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# @return [Hash{Clef::Ir::Moment=>Float}]
|
|
17
|
+
def compute
|
|
18
|
+
moments = timeline.each_moment.to_a.sort
|
|
19
|
+
return {} if moments.empty?
|
|
20
|
+
|
|
21
|
+
build_positions(moments)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# @param target_width [Numeric]
|
|
25
|
+
# @return [Float]
|
|
26
|
+
def stretch_to_fit(target_width)
|
|
27
|
+
positions = compute
|
|
28
|
+
return stretch_factor if positions.empty?
|
|
29
|
+
|
|
30
|
+
current_width = positions.values.max
|
|
31
|
+
return stretch_factor unless current_width.positive?
|
|
32
|
+
|
|
33
|
+
@stretch_factor *= target_width.to_f / current_width
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def build_positions(moments)
|
|
39
|
+
positions = { moments.first => 0.0 }
|
|
40
|
+
total = 0.0
|
|
41
|
+
moments.each_cons(2) do |left, right|
|
|
42
|
+
total += interval_width(right.value - left.value)
|
|
43
|
+
positions[right] = total
|
|
44
|
+
end
|
|
45
|
+
positions
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def interval_width(duration)
|
|
49
|
+
shortest = timeline.shortest_duration
|
|
50
|
+
ratio = duration.to_f / shortest.to_f
|
|
51
|
+
base = style.min_note_spacing
|
|
52
|
+
base * Math.log2(ratio + 1.0) * stretch_factor
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Clef
|
|
4
|
+
module Layout
|
|
5
|
+
class Stem
|
|
6
|
+
B4_MIDI = 71
|
|
7
|
+
|
|
8
|
+
class << self
|
|
9
|
+
# @param note_or_notes [Clef::Core::Note, Array<Clef::Core::Note>]
|
|
10
|
+
# @param _clef [Clef::Core::Clef]
|
|
11
|
+
# @return [Symbol]
|
|
12
|
+
def direction(note_or_notes, _clef)
|
|
13
|
+
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
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# @param note [Clef::Core::Note]
|
|
19
|
+
# @param _clef [Clef::Core::Clef]
|
|
20
|
+
# @param _direction [Symbol]
|
|
21
|
+
# @return [Float]
|
|
22
|
+
def length(note, _clef, _direction)
|
|
23
|
+
extra = ledger_extension(note.pitch)
|
|
24
|
+
Clef::Engraving::Rules::STEM_LENGTH + extra
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def ledger_extension(pitch)
|
|
30
|
+
return 1.5 if pitch.to_midi > 84
|
|
31
|
+
return 1.5 if pitch.to_midi < 48
|
|
32
|
+
|
|
33
|
+
0.0
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "midilib/sequence"
|
|
4
|
+
require "midilib/consts"
|
|
5
|
+
|
|
6
|
+
module Clef
|
|
7
|
+
module Midi
|
|
8
|
+
class Exporter
|
|
9
|
+
include MIDI
|
|
10
|
+
|
|
11
|
+
# @param score [Clef::Core::Score]
|
|
12
|
+
# @param channel_map [ChannelMap]
|
|
13
|
+
def initialize(score, channel_map: ChannelMap.new)
|
|
14
|
+
@score = score
|
|
15
|
+
@channel_map = channel_map
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# @param path [String]
|
|
19
|
+
# @return [String]
|
|
20
|
+
def export(path)
|
|
21
|
+
sequence = Sequence.new
|
|
22
|
+
track = Track.new(sequence)
|
|
23
|
+
sequence.tracks << track
|
|
24
|
+
track.events << Tempo.new(Tempo.bpm_to_mpq(tempo_bpm))
|
|
25
|
+
|
|
26
|
+
append_score(track, sequence.ppqn)
|
|
27
|
+
File.open(path, "wb") { |file| sequence.write(file) }
|
|
28
|
+
path
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
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)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
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
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def append_voice(track, voice, channel, ppqn, delta)
|
|
50
|
+
voice.elements.each do |element|
|
|
51
|
+
delta = append_element(track, element, channel, ppqn, delta)
|
|
52
|
+
end
|
|
53
|
+
delta
|
|
54
|
+
end
|
|
55
|
+
|
|
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)
|
|
59
|
+
|
|
60
|
+
pitches = element.is_a?(Clef::Core::Chord) ? element.pitches : [element.pitch]
|
|
61
|
+
write_note_events(track, channel, pitches, ticks, delta)
|
|
62
|
+
0
|
|
63
|
+
end
|
|
64
|
+
|
|
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)
|
|
69
|
+
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)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def tempo_bpm
|
|
77
|
+
@score.tempo&.bpm || 120
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Clef
|
|
4
|
+
module Notation
|
|
5
|
+
class Articulation
|
|
6
|
+
TYPES = %i[staccato tenuto accent marcato fermata].freeze
|
|
7
|
+
|
|
8
|
+
attr_reader :type
|
|
9
|
+
|
|
10
|
+
# @param type [Symbol]
|
|
11
|
+
def initialize(type)
|
|
12
|
+
raise ArgumentError, "unsupported articulation type" unless TYPES.include?(type)
|
|
13
|
+
|
|
14
|
+
@type = type
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Clef
|
|
4
|
+
module Notation
|
|
5
|
+
class Barline
|
|
6
|
+
TYPES = %i[single double final repeat_start repeat_end repeat_both].freeze
|
|
7
|
+
|
|
8
|
+
attr_reader :type
|
|
9
|
+
|
|
10
|
+
# @param type [Symbol]
|
|
11
|
+
def initialize(type = :single)
|
|
12
|
+
raise ArgumentError, "unsupported barline type" unless TYPES.include?(type)
|
|
13
|
+
|
|
14
|
+
@type = type
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# @return [String]
|
|
18
|
+
def to_symbol
|
|
19
|
+
{
|
|
20
|
+
single: "|",
|
|
21
|
+
double: "||",
|
|
22
|
+
final: "|.",
|
|
23
|
+
repeat_start: ".|:",
|
|
24
|
+
repeat_end: ":|.",
|
|
25
|
+
repeat_both: ":|.|:"
|
|
26
|
+
}.fetch(type)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Clef
|
|
4
|
+
module Notation
|
|
5
|
+
class Beam
|
|
6
|
+
attr_reader :notes
|
|
7
|
+
|
|
8
|
+
# @param notes [Array<Clef::Core::Note>]
|
|
9
|
+
def initialize(notes)
|
|
10
|
+
raise ArgumentError, "beam requires at least two notes" if notes.length < 2
|
|
11
|
+
|
|
12
|
+
@notes = notes
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# @return [Integer]
|
|
16
|
+
def level
|
|
17
|
+
shortest = notes.map { |note| note.duration.base_value }.min
|
|
18
|
+
return 1 if shortest >= Rational(1, 8)
|
|
19
|
+
return 2 if shortest >= Rational(1, 16)
|
|
20
|
+
|
|
21
|
+
3
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Clef
|
|
4
|
+
module Notation
|
|
5
|
+
class Dynamic
|
|
6
|
+
TYPES = %i[pp p mp mf f ff fff sfz fp cresc dim].freeze
|
|
7
|
+
|
|
8
|
+
attr_reader :type
|
|
9
|
+
|
|
10
|
+
# @param type [Symbol]
|
|
11
|
+
def initialize(type)
|
|
12
|
+
raise ArgumentError, "unsupported dynamic type" unless TYPES.include?(type)
|
|
13
|
+
|
|
14
|
+
@type = type
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Clef
|
|
4
|
+
module Notation
|
|
5
|
+
class Lyric
|
|
6
|
+
attr_reader :voice_id, :text, :syllables
|
|
7
|
+
|
|
8
|
+
# @param voice_id [Symbol]
|
|
9
|
+
# @param text [String]
|
|
10
|
+
def initialize(voice_id, text)
|
|
11
|
+
raise ArgumentError, "text must be String" unless text.is_a?(String)
|
|
12
|
+
|
|
13
|
+
@voice_id = voice_id
|
|
14
|
+
@text = text
|
|
15
|
+
@syllables = parse_syllables(text)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
def parse_syllables(input)
|
|
21
|
+
input
|
|
22
|
+
.split(/\s+/)
|
|
23
|
+
.flat_map { |token| token.split("-") }
|
|
24
|
+
.reject(&:empty?)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Clef
|
|
4
|
+
module Notation
|
|
5
|
+
class Slur
|
|
6
|
+
attr_reader :start_note, :end_note
|
|
7
|
+
|
|
8
|
+
# @param start_note [Clef::Core::Note]
|
|
9
|
+
# @param end_note [Clef::Core::Note]
|
|
10
|
+
def initialize(start_note, end_note)
|
|
11
|
+
@start_note = start_note
|
|
12
|
+
@end_note = end_note
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# @param start_point [Array<Float>]
|
|
16
|
+
# @param end_point [Array<Float>]
|
|
17
|
+
# @return [Array<Array<Float>>]
|
|
18
|
+
def control_points(start_point, end_point)
|
|
19
|
+
midpoint_x = (start_point[0] + end_point[0]) / 2.0
|
|
20
|
+
lift = [((end_point[0] - start_point[0]).abs / 4.0), 6.0].max
|
|
21
|
+
[
|
|
22
|
+
[midpoint_x - 8, start_point[1] - lift],
|
|
23
|
+
[midpoint_x + 8, end_point[1] - lift]
|
|
24
|
+
]
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Clef
|
|
4
|
+
module Notation
|
|
5
|
+
class Tie
|
|
6
|
+
attr_reader :start_note, :end_note
|
|
7
|
+
|
|
8
|
+
# @param start_note [Clef::Core::Note]
|
|
9
|
+
# @param end_note [Clef::Core::Note]
|
|
10
|
+
def initialize(start_note, end_note)
|
|
11
|
+
raise ArgumentError, "tie requires notes with same pitch" unless start_note.pitch.enharmonic?(end_note.pitch)
|
|
12
|
+
|
|
13
|
+
@start_note = start_note
|
|
14
|
+
@end_note = end_note
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# @param start_point [Array<Float>]
|
|
18
|
+
# @param end_point [Array<Float>]
|
|
19
|
+
# @return [Array<Array<Float>>]
|
|
20
|
+
def control_points(start_point, end_point)
|
|
21
|
+
midpoint_x = (start_point[0] + end_point[0]) / 2.0
|
|
22
|
+
lift = [((end_point[0] - start_point[0]).abs / 8.0), 3.0].max
|
|
23
|
+
[
|
|
24
|
+
[midpoint_x - 6, start_point[1] - lift],
|
|
25
|
+
[midpoint_x + 6, end_point[1] - lift]
|
|
26
|
+
]
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|