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,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,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clef
4
+ module Engraving
5
+ module Rules
6
+ STEM_LENGTH = 3.5
7
+ BEAM_THICKNESS = 0.5
8
+ MIN_NOTE_SPACING = 1.8
9
+ STAFF_LINE_COUNT = 5
10
+ STAFF_LINE_WEIGHT = 0.7
11
+ LEDGER_LINE_WEIGHT = 0.8
12
+ end
13
+ end
14
+ 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