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,265 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clef
4
+ module Parser
5
+ module DSL
6
+ class Error < StandardError; end
7
+
8
+ class ScoreBuilder
9
+ attr_reader :score
10
+
11
+ def initialize
12
+ @score = Clef::Core::Score.new
13
+ @default_group = Clef::Core::StaffGroup.new([], bracket_type: :none)
14
+ score.add_staff_group(@default_group)
15
+ end
16
+
17
+ # @param value [String]
18
+ def title(value)
19
+ score.title = value.to_s
20
+ end
21
+
22
+ # @param value [String]
23
+ def composer(value)
24
+ score.composer = value.to_s
25
+ end
26
+
27
+ # @param beat_unit [Symbol, Clef::Core::Duration]
28
+ # @param bpm [Integer]
29
+ def tempo(beat_unit:, bpm:)
30
+ duration = beat_unit.is_a?(Clef::Core::Duration) ? beat_unit : Clef::Core::Duration.new(beat_unit)
31
+ score.tempo = Clef::Core::Tempo.new(beat_unit: duration, bpm: bpm)
32
+ end
33
+
34
+ # @param id [Symbol]
35
+ # @param name [String, nil]
36
+ # @param clef [Symbol]
37
+ def staff(id, name: nil, clef: :treble, &block)
38
+ staff = Clef::Core::Staff.new(id, name: name, clef: Clef::Core::Clef.new(clef))
39
+ build_staff(staff, &block)
40
+ @default_group.add_staff(staff)
41
+ staff
42
+ rescue NoMethodError => e
43
+ raise Error, "invalid DSL method in staff block: #{e.name}"
44
+ end
45
+
46
+ # @param bracket_type [Symbol]
47
+ def staff_group(bracket_type, &block)
48
+ group = Clef::Core::StaffGroup.new([], bracket_type: bracket_type)
49
+ GroupBuilder.new(group).instance_eval(&block)
50
+ score.add_staff_group(group)
51
+ group
52
+ rescue NoMethodError => e
53
+ raise Error, "invalid DSL method in staff_group block: #{e.name}"
54
+ end
55
+
56
+ # @return [Clef::Core::Score]
57
+ def build
58
+ score
59
+ end
60
+
61
+ private
62
+
63
+ def build_staff(staff, &block)
64
+ return unless block
65
+
66
+ StaffBuilder.new(staff).instance_eval(&block)
67
+ end
68
+ end
69
+
70
+ class GroupBuilder
71
+ def initialize(group)
72
+ @group = group
73
+ end
74
+
75
+ # @param id [Symbol]
76
+ # @param name [String, nil]
77
+ # @param clef [Symbol]
78
+ def staff(id, name: nil, clef: :treble, &block)
79
+ staff = Clef::Core::Staff.new(id, name: name, clef: Clef::Core::Clef.new(clef))
80
+ StaffBuilder.new(staff).instance_eval(&block) if block
81
+ @group.add_staff(staff)
82
+ staff
83
+ end
84
+ end
85
+
86
+ class StaffBuilder
87
+ def initialize(staff)
88
+ @staff = staff
89
+ @current_measure = nil
90
+ @next_measure_number = 1
91
+ @lyrics = []
92
+ end
93
+
94
+ # @param tonic [Symbol, Clef::Core::Pitch]
95
+ # @param mode [Symbol]
96
+ def key(tonic, mode = :major)
97
+ @staff.key_signature = Clef::Core::KeySignature.new(tonic, mode)
98
+ end
99
+
100
+ # @param numerator [Integer]
101
+ # @param denominator [Integer]
102
+ def time(numerator, denominator)
103
+ @staff.time_signature = Clef::Core::TimeSignature.new(numerator, denominator)
104
+ end
105
+
106
+ # @param id [Symbol]
107
+ def voice(id = :default, &block)
108
+ measure = ensure_measure
109
+ voice = measure.voice(id)
110
+ return voice unless block
111
+
112
+ VoiceBuilder.new(voice).instance_eval(&block)
113
+ voice
114
+ rescue NoMethodError => e
115
+ raise Error, "invalid DSL method in voice block: #{e.name}"
116
+ end
117
+
118
+ # @param lilypond_string [String]
119
+ def play(lilypond_string)
120
+ segments = split_measures(lilypond_string)
121
+ segments.each_with_index do |segment, idx|
122
+ measure = ensure_measure
123
+ VoiceBuilder.new(measure.voice(:default)).notes(segment)
124
+ advance_measure if idx < segments.length - 1
125
+ end
126
+ end
127
+
128
+ # @param voice_id [Symbol]
129
+ # @param text [String]
130
+ def lyrics(voice_id, text)
131
+ @lyrics << Clef::Notation::Lyric.new(voice_id, text)
132
+ @staff.metadata ||= {}
133
+ @staff.metadata[:lyrics] = @lyrics
134
+ end
135
+
136
+ private
137
+
138
+ def ensure_measure
139
+ return @current_measure if @current_measure
140
+
141
+ @current_measure = Clef::Core::Measure.new(@next_measure_number, time_signature: @staff.time_signature)
142
+ @current_measure.key_signature = @staff.key_signature
143
+ @current_measure.clef = @staff.clef
144
+ @staff.add_measure(@current_measure)
145
+ @next_measure_number += 1
146
+ @current_measure
147
+ end
148
+
149
+ def advance_measure
150
+ @current_measure = nil
151
+ end
152
+
153
+ def split_measures(input)
154
+ input.to_s.split("|").map(&:strip).reject(&:empty?)
155
+ end
156
+ end
157
+
158
+ class VoiceBuilder
159
+ def initialize(voice)
160
+ @voice = voice
161
+ end
162
+
163
+ # @param pitch_str [String]
164
+ # @param duration_sym [Symbol]
165
+ # @param opts [Hash]
166
+ def note(pitch_str, duration_sym, **opts)
167
+ pitch = parse_pitch(pitch_str)
168
+ duration = Clef::Core::Duration.new(duration_sym, dots: opts.fetch(:dots, 0))
169
+ articulations = Array(opts.fetch(:articulations, []))
170
+ @voice.add(Clef::Core::Note.new(pitch, duration, articulations: articulations, tied: opts[:tied]))
171
+ end
172
+
173
+ # @param duration_sym [Symbol]
174
+ # @param opts [Hash]
175
+ def rest(duration_sym, **opts)
176
+ duration = Clef::Core::Duration.new(duration_sym, dots: opts.fetch(:dots, 0))
177
+ @voice.add(Clef::Core::Rest.new(duration))
178
+ end
179
+
180
+ # @param pitch_strs [Array<String>]
181
+ # @param duration_sym [Symbol]
182
+ # @param opts [Hash]
183
+ def chord(pitch_strs, duration_sym, **opts)
184
+ duration = Clef::Core::Duration.new(duration_sym, dots: opts.fetch(:dots, 0))
185
+ pitches = pitch_strs.map { |pitch| parse_pitch(pitch) }
186
+ @voice.add(Clef::Core::Chord.new(pitches, duration))
187
+ end
188
+
189
+ # @param lilypond_string [String]
190
+ def notes(lilypond_string)
191
+ parse_tokens(lilypond_string).each { |token| add_token(token) }
192
+ end
193
+
194
+ # @param actual [Integer]
195
+ # @param normal [Integer]
196
+ def tuplet(actual, normal)
197
+ raise ArgumentError, "tuplet values must be positive" unless actual.positive? && normal.positive?
198
+
199
+ return unless block_given?
200
+
201
+ yield
202
+ end
203
+
204
+ private
205
+
206
+ def parse_tokens(input)
207
+ input.to_s.split(/\s+/).reject(&:empty?)
208
+ end
209
+
210
+ def add_token(token)
211
+ return if token == "|"
212
+
213
+ if token.start_with?("r")
214
+ add_rest_token(token)
215
+ elsif token.start_with?("<")
216
+ add_chord_token(token)
217
+ else
218
+ add_note_token(token)
219
+ end
220
+ rescue StandardError => e
221
+ raise Error, "failed to parse token '#{token}': #{e.message}"
222
+ end
223
+
224
+ def add_rest_token(token)
225
+ match = /\Ar(\d+)(\.*)\z/.match(token)
226
+ raise ArgumentError, "invalid rest token" unless match
227
+
228
+ duration = Clef::Core::Duration.from_lilypond(match[1].to_i, match[2].length)
229
+ @voice.add(Clef::Core::Rest.new(duration))
230
+ end
231
+
232
+ def add_chord_token(token)
233
+ match = /\A<([^>]+)>(\d+)(\.*)\z/.match(token)
234
+ raise ArgumentError, "invalid chord token" unless match
235
+
236
+ pitches = match[1].split(/\s+/).map { |value| parse_pitch(value) }
237
+ duration = Clef::Core::Duration.from_lilypond(match[2].to_i, match[3].length)
238
+ @voice.add(Clef::Core::Chord.new(pitches, duration))
239
+ end
240
+
241
+ def add_note_token(token)
242
+ match = /\A([a-g](?:isis|eses|is|es)?[',]*)(\d+)(\.*)\z/.match(token)
243
+ raise ArgumentError, "invalid note token" unless match
244
+
245
+ pitch = parse_pitch(match[1])
246
+ duration = Clef::Core::Duration.from_lilypond(match[2].to_i, match[3].length)
247
+ @voice.add(Clef::Core::Note.new(pitch, duration))
248
+ end
249
+
250
+ def parse_pitch(value)
251
+ parse_scientific_pitch(value) || Clef::Core::Pitch.parse(value.downcase)
252
+ end
253
+
254
+ def parse_scientific_pitch(value)
255
+ match = /\A([A-Ga-g])([#b]{0,2})(-?\d+)\z/.match(value)
256
+ return nil unless match
257
+
258
+ note_name = match[1].downcase.to_sym
259
+ alteration = { "" => 0, "#" => 1, "##" => 2, "b" => -1, "bb" => -2 }.fetch(match[2])
260
+ Clef::Core::Pitch.new(note_name, match[3].to_i, alteration: alteration)
261
+ end
262
+ end
263
+ end
264
+ end
265
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clef
4
+ module Parser
5
+ class LilypondLexer
6
+ TOKEN_REGEX = /\\[a-zA-Z]+|\{\}|\{|\}|<[^>]+>\d+\.*|[a-g](?:isis|eses|is|es)?[',]*\d+\.*|r\d+\.*|\|/m
7
+
8
+ # @param input [String]
9
+ # @return [Array<String>]
10
+ def tokenize(input)
11
+ sanitized = strip_comments(input)
12
+ sanitized.scan(TOKEN_REGEX)
13
+ end
14
+
15
+ private
16
+
17
+ def strip_comments(input)
18
+ input.to_s.each_line.map { |line| line.sub(/%.*/, "") }.join
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clef
4
+ module Parser
5
+ class LilypondParser
6
+ # @param input [String]
7
+ # @return [Clef::Core::Score]
8
+ def parse(input)
9
+ cleaned = input.to_s
10
+ clef = extract_clef(cleaned)
11
+ key_tonic = extract_key_tonic(cleaned)
12
+ mode = extract_mode(cleaned)
13
+ time_numerator, time_denominator = extract_time(cleaned)
14
+ note_stream = extract_note_stream(cleaned)
15
+
16
+ Clef.score do
17
+ staff :staff1, clef: clef do
18
+ key key_tonic, mode
19
+ time time_numerator, time_denominator
20
+ play note_stream
21
+ end
22
+ end
23
+ end
24
+
25
+ private
26
+
27
+ def extract_note_stream(input)
28
+ body = input[/\{(.+)\}/m, 1] || ""
29
+ tokens = LilypondLexer.new.tokenize(body)
30
+ tokens.select { |token| token.match?(/\A<|\A[a-g]|\Ar|\A\|/) }.join(" ")
31
+ end
32
+
33
+ def extract_key_tonic(input)
34
+ match = /\\key\s+([a-g](?:is|es)?)/.match(input)
35
+ tonic = (match && match[1]) || "c"
36
+ Clef::Core::Pitch.parse(tonic)
37
+ end
38
+
39
+ def extract_mode(input)
40
+ match = /\\key\s+[a-g](?:is|es)?\s+\\(major|minor)/.match(input)
41
+ (match && match[1]&.to_sym) || :major
42
+ end
43
+
44
+ def extract_time(input)
45
+ match = /\\time\s+(\d+)\/(\d+)/.match(input)
46
+ return [4, 4] unless match
47
+
48
+ [match[1].to_i, match[2].to_i]
49
+ end
50
+
51
+ def extract_clef(input)
52
+ match = /\\clef\s+"?([a-z_]+)"?/.match(input)
53
+ (match && match[1]&.to_sym) || :treble
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clef
4
+ module Plugins
5
+ class Base
6
+ class << self
7
+ # @return [String]
8
+ def plugin_name
9
+ name.split("::").last.downcase
10
+ end
11
+ end
12
+
13
+ # @param _score [Clef::Core::Score]
14
+ def on_before_layout(_score); end
15
+
16
+ # @param _layout_result [Hash]
17
+ def on_after_layout(_layout_result); end
18
+
19
+ # @param _renderer [Object]
20
+ def on_before_render(_renderer); end
21
+
22
+ # @param _glyph_table [Clef::Engraving::GlyphTable]
23
+ def register_glyphs(_glyph_table); end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clef
4
+ module Plugins
5
+ class Registry
6
+ attr_reader :plugins
7
+
8
+ def initialize
9
+ @plugins = []
10
+ end
11
+
12
+ # @param plugin_class [Class]
13
+ # @return [Plugins::Base]
14
+ def register(plugin_class)
15
+ raise ArgumentError, "plugin must inherit Clef::Plugins::Base" unless plugin_class < Base
16
+
17
+ plugin = plugin_class.new
18
+ plugins << plugin
19
+ plugin
20
+ end
21
+
22
+ # @param hook_name [Symbol]
23
+ # @param args [Array<Object>]
24
+ # @return [Array<Object>]
25
+ def run_hook(hook_name, *args)
26
+ plugins.each_with_object([]) do |plugin, results|
27
+ next unless plugin.respond_to?(hook_name)
28
+
29
+ results << plugin.public_send(hook_name, *args)
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clef
4
+ module Renderer
5
+ class Base
6
+ attr_reader :style, :glyph_table, :font_manager
7
+
8
+ # @param style [Clef::Engraving::Style]
9
+ # @param glyph_table [Clef::Engraving::GlyphTable]
10
+ # @param font_manager [Clef::Engraving::FontManager]
11
+ def initialize(style: Clef::Engraving::Style.default,
12
+ glyph_table: Clef::Engraving::GlyphTable.new,
13
+ font_manager: Clef::Engraving::FontManager.new)
14
+ @style = style
15
+ @glyph_table = glyph_table
16
+ @font_manager = font_manager
17
+ end
18
+
19
+ # @param _score [Clef::Core::Score]
20
+ # @param _path [String]
21
+ def render(_score, _path, **_options)
22
+ raise NotImplementedError
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clef
4
+ module Renderer
5
+ module NotationHelpers
6
+ ACCIDENTAL_GLYPH_KEYS = {
7
+ -2 => :accidental_double_flat,
8
+ -1 => :accidental_flat,
9
+ 1 => :accidental_sharp,
10
+ 2 => :accidental_double_sharp
11
+ }.freeze
12
+ ACCIDENTAL_FALLBACK = {
13
+ -2 => "bb",
14
+ -1 => "b",
15
+ 1 => "#",
16
+ 2 => "##"
17
+ }.freeze
18
+
19
+ private
20
+
21
+ def filled_notehead?(duration)
22
+ !%i[whole half].include?(duration.base)
23
+ end
24
+
25
+ def stem_required?(duration)
26
+ duration.base != :whole
27
+ end
28
+
29
+ def duration_spacing(element)
30
+ style.min_note_spacing * (element.length.to_f / Rational(1, 4).to_f)
31
+ end
32
+
33
+ def diatonic_step(pitch)
34
+ note_index = Clef::Core::Pitch::VALID_NOTE_NAMES.index(pitch.note_name)
35
+ (pitch.octave * 7) + note_index
36
+ end
37
+
38
+ # The staff baseline is the top line.
39
+ # vertical_axis: +1 for PDF coordinates, -1 for SVG coordinates.
40
+ def calculate_pitch_y(pitch, baseline, clef, vertical_axis:)
41
+ reference = clef.reference_pitch
42
+ diatonic = diatonic_step(pitch) - diatonic_step(reference)
43
+ bottom_line = baseline - (vertical_axis * style.staff_space * 4)
44
+ reference_y = bottom_line + (clef.reference_line * vertical_axis * style.staff_space)
45
+ reference_y - (diatonic * (style.staff_space / 2.0))
46
+ end
47
+
48
+ def accidental_glyph_key(alteration)
49
+ ACCIDENTAL_GLYPH_KEYS[alteration]
50
+ end
51
+
52
+ def accidental_text(alteration)
53
+ ACCIDENTAL_FALLBACK.fetch(alteration)
54
+ end
55
+
56
+ def chord_notes(chord)
57
+ chord.pitches.map { |pitch| Clef::Core::Note.new(pitch, chord.duration) }
58
+ end
59
+
60
+ def chord_stem_anchor_note(notes, direction)
61
+ return notes.min_by { |note| note.pitch.to_midi } if direction == :up
62
+
63
+ notes.max_by { |note| note.pitch.to_midi }
64
+ end
65
+
66
+ def chord_stem_length(notes, clef, direction)
67
+ notes.map { |note| Clef::Layout::Stem.length(note, clef, direction) }.max
68
+ end
69
+ end
70
+ end
71
+ end