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.
Files changed (58) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +172 -0
  4. data/Rakefile +17 -0
  5. data/examples/bach_cello_suite.rb +16 -0
  6. data/examples/piano_score.rb +24 -0
  7. data/examples/twinkle.rb +18 -0
  8. data/examples/vocal_score.rb +21 -0
  9. data/fonts/bravura/README.md +8 -0
  10. data/lib/clef/compiler.rb +46 -0
  11. data/lib/clef/core/chord.rb +34 -0
  12. data/lib/clef/core/clef.rb +40 -0
  13. data/lib/clef/core/duration.rb +96 -0
  14. data/lib/clef/core/key_signature.rb +79 -0
  15. data/lib/clef/core/measure.rb +46 -0
  16. data/lib/clef/core/note.rb +43 -0
  17. data/lib/clef/core/pitch.rb +151 -0
  18. data/lib/clef/core/rest.rb +21 -0
  19. data/lib/clef/core/score.rb +61 -0
  20. data/lib/clef/core/staff.rb +34 -0
  21. data/lib/clef/core/staff_group.rb +30 -0
  22. data/lib/clef/core/tempo.rb +19 -0
  23. data/lib/clef/core/time_signature.rb +38 -0
  24. data/lib/clef/core/voice.rb +29 -0
  25. data/lib/clef/engraving/font_manager.rb +35 -0
  26. data/lib/clef/engraving/glyph_table.rb +52 -0
  27. data/lib/clef/engraving/rules.rb +14 -0
  28. data/lib/clef/engraving/style.rb +27 -0
  29. data/lib/clef/ir/event.rb +22 -0
  30. data/lib/clef/ir/moment.rb +64 -0
  31. data/lib/clef/ir/music_tree.rb +54 -0
  32. data/lib/clef/ir/timeline.rb +69 -0
  33. data/lib/clef/layout/beam_layout.rb +42 -0
  34. data/lib/clef/layout/line_breaker.rb +60 -0
  35. data/lib/clef/layout/page_breaker.rb +16 -0
  36. data/lib/clef/layout/spacing.rb +56 -0
  37. data/lib/clef/layout/stem.rb +38 -0
  38. data/lib/clef/midi/channel_map.rb +14 -0
  39. data/lib/clef/midi/exporter.rb +81 -0
  40. data/lib/clef/notation/articulation.rb +18 -0
  41. data/lib/clef/notation/barline.rb +30 -0
  42. data/lib/clef/notation/beam.rb +25 -0
  43. data/lib/clef/notation/dynamic.rb +18 -0
  44. data/lib/clef/notation/lyric.rb +28 -0
  45. data/lib/clef/notation/slur.rb +28 -0
  46. data/lib/clef/notation/tie.rb +30 -0
  47. data/lib/clef/parser/dsl.rb +265 -0
  48. data/lib/clef/parser/lilypond_lexer.rb +22 -0
  49. data/lib/clef/parser/lilypond_parser.rb +57 -0
  50. data/lib/clef/plugins/base.rb +26 -0
  51. data/lib/clef/plugins/registry.rb +34 -0
  52. data/lib/clef/renderer/base.rb +26 -0
  53. data/lib/clef/renderer/notation_helpers.rb +71 -0
  54. data/lib/clef/renderer/pdf_renderer.rb +341 -0
  55. data/lib/clef/renderer/svg_renderer.rb +206 -0
  56. data/lib/clef/version.rb +5 -0
  57. data/lib/clef.rb +25 -0
  58. 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,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clef
4
+ module Midi
5
+ class ChannelMap
6
+ # @param staff_index [Integer]
7
+ # @return [Integer]
8
+ def channel_for(staff_index)
9
+ base = staff_index % 15
10
+ base >= 9 ? base + 1 : base
11
+ end
12
+ end
13
+ end
14
+ 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