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
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 54756cf60de8a132c3b38667cd33fdd80f4bb856d87026b916aa4018b1d515e3
4
+ data.tar.gz: f78c5b688a8a28952a08d701c737c9b1929cebf6caf86451f9daa0e92ca7f84d
5
+ SHA512:
6
+ metadata.gz: 2e82ac741da2cdf64fc828fe4664bb1d29bd7a118c0a4bd7095c8d189cafb72be12e8fa263500135d193a467c3728cb97a4fcca5f7ef2e459189f294ef8eddb0
7
+ data.tar.gz: 56655953b3b5b01289b38a4e08bf80ce07024cfd13208cc15bb57059e07e4e8a6be2ee6c31ebb2879fb4b379381cd8558d4524b673bc0b2ba3451daeb8ca4f8d
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Yudai Takada
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,172 @@
1
+ # Clef
2
+
3
+ Clef is a Ruby toolkit for building small scores with a Ruby DSL and exporting them to PDF, SVG, or MIDI.
4
+
5
+ ## Current Feature Set
6
+
7
+ - Core score model for pitches, durations, notes, rests, chords, measures, staves, staff groups, and tempo
8
+ - Ruby DSL via `Clef.score`
9
+ - LilyPond-like shorthand through `play` and `notes`
10
+ - Basic LilyPond import through `Clef::Parser::LilypondParser`
11
+ - PDF rendering with Prawn
12
+ - SVG rendering with Nokogiri
13
+ - MIDI export with midilib
14
+ - Plugin hooks around layout and rendering
15
+
16
+ ## Installation
17
+
18
+ Clef requires Ruby 3.1 or newer.
19
+
20
+ For local development:
21
+
22
+ ```bash
23
+ git clone https://github.com/ydah/clef.git
24
+ cd clef
25
+ bin/setup
26
+ ```
27
+
28
+ For PDF output with SMuFL glyphs, place `Bravura.otf` at `fonts/bravura/Bravura.otf`.
29
+ If the font file is missing, Clef falls back to `Helvetica`.
30
+
31
+ ## Quick Start
32
+
33
+ ```ruby
34
+ require "clef"
35
+
36
+ score = Clef.score do
37
+ title "Twinkle Twinkle Little Star"
38
+ composer "Traditional"
39
+ tempo beat_unit: :quarter, bpm: 100
40
+
41
+ staff :melody, clef: :treble do
42
+ key :c, :major
43
+ time 4, 4
44
+ play "c'4 c'4 g'4 g'4 | a'4 a'4 g'2 | f'4 f'4 e'4 e'4 | d'4 d'4 c'2"
45
+ end
46
+ end
47
+
48
+ score.to_pdf("twinkle.pdf")
49
+ score.to_svg("twinkle.svg")
50
+ score.to_midi("twinkle.mid")
51
+ ```
52
+
53
+ `title`, `composer`, and `tempo` are stored on the score. The current PDF and SVG renderers focus on staff content, while `tempo` is also used by MIDI export.
54
+
55
+ ## DSL Overview
56
+
57
+ Clef's main entry point is `Clef.score`.
58
+
59
+ - `staff` creates a staff
60
+ - `staff_group(:brace)` and `staff_group(:bracket)` group staves in the score model
61
+ - `play` splits measures on `|`
62
+ - `play` and `notes` accept LilyPond-like tokens such as `c'4`, `r8`, and `<c' e' g'>2.`
63
+ - `voice` gives explicit control over notes, rests, and chords
64
+ - `lyrics` attaches lyric data to a named voice
65
+
66
+ Example:
67
+
68
+ ```ruby
69
+ score = Clef.score do
70
+ staff_group :brace do
71
+ staff :piano_rh, clef: :treble do
72
+ key :c, :major
73
+ time 4, 4
74
+ play "c'4 e'4 g'4 c''4"
75
+ end
76
+
77
+ staff :piano_lh, clef: :bass do
78
+ key :c, :major
79
+ time 4, 4
80
+
81
+ voice :main do
82
+ chord %w[c3 g3], :half
83
+ rest :half
84
+ end
85
+ end
86
+ end
87
+ end
88
+ ```
89
+
90
+ Within a `voice` block, manual builders accept scientific pitch strings such as `C4`, `F#3`, and `Bb5`. The shorthand token parser used by `play` and `notes` expects LilyPond-style pitch tokens.
91
+
92
+ ## LilyPond Input
93
+
94
+ `Clef::Parser::LilypondParser` supports a small subset of LilyPond and turns it into a `Clef::Core::Score`.
95
+
96
+ ```ruby
97
+ parser = Clef::Parser::LilypondParser.new
98
+
99
+ score = parser.parse(<<~LY)
100
+ \clef treble
101
+ \key c \major
102
+ \time 4/4
103
+ { c'4 d'4 e'4 f'4 }
104
+ LY
105
+
106
+ score.to_svg("phrase.svg")
107
+ ```
108
+
109
+ The current parser recognizes:
110
+
111
+ - `\clef`
112
+ - `\key` with `\major` or `\minor`
113
+ - `\time`
114
+ - Note, rest, chord, and bar tokens inside `{ ... }`
115
+
116
+ ## Plugins
117
+
118
+ Clef exposes plugin hooks through `Clef.plugins`.
119
+
120
+ ```ruby
121
+ class MarkerPlugin < Clef::Plugins::Base
122
+ def on_before_layout(score)
123
+ score.metadata[:prepared] = true
124
+ end
125
+ end
126
+
127
+ Clef.plugins.register(MarkerPlugin)
128
+ ```
129
+
130
+ Available hooks:
131
+
132
+ - `on_before_layout(score)`
133
+ - `on_after_layout(layout_result)`
134
+ - `on_before_render(renderer)`
135
+
136
+ ## Current Scope
137
+
138
+ - PDF and SVG rendering currently cover clefs, key and time metadata, noteheads, rests, stems, accidentals, dots, chords, and simple articulation text.
139
+ - PDF and SVG do not yet render score headers, lyric lines, ties, slurs, beams, or staff-group braces and brackets.
140
+ - PDF, SVG, and MIDI export currently consume the first voice in each measure. The core model and IR can hold multiple voices, but full polyphonic engraving and playback are not wired into the output pipeline yet.
141
+ - The compiler currently computes `Clef::Ir::MusicTree` and `Clef::Layout::Spacing` before rendering. `Clef::Layout::LineBreaker`, `PageBreaker`, and `BeamLayout` exist in the codebase as standalone building blocks and are covered by specs, but they are not yet integrated into PDF or SVG output.
142
+
143
+ ## Development
144
+
145
+ Install dependencies:
146
+
147
+ ```bash
148
+ bin/setup
149
+ ```
150
+
151
+ Run the test suite:
152
+
153
+ ```bash
154
+ bundle exec rspec
155
+ bundle exec rake
156
+ ```
157
+
158
+ Run an example:
159
+
160
+ ```bash
161
+ bundle exec ruby examples/twinkle.rb
162
+ ```
163
+
164
+ Open a console:
165
+
166
+ ```bash
167
+ bin/console
168
+ ```
169
+
170
+ ## License
171
+
172
+ MIT License.
data/Rakefile ADDED
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ begin
9
+ require "yard"
10
+ YARD::Rake::YardocTask.new(:yard)
11
+ rescue LoadError
12
+ task :yard do
13
+ warn("YARD is not installed. Run: bundle add yard --group development")
14
+ end
15
+ end
16
+
17
+ task default: :spec
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "clef"
4
+
5
+ score = Clef.score do
6
+ title "Cello Suite No.1 (Opening Motif)"
7
+ composer "J. S. Bach"
8
+
9
+ staff :cello, clef: :bass do
10
+ key :g, :major
11
+ time 4, 4
12
+ play "g,8 d8 b8 d8 g8 d8 b8 d8 | g,8 e8 c8 e8 g8 e8 c8 e8"
13
+ end
14
+ end
15
+
16
+ score.to_pdf("bach_cello_suite.pdf")
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "clef"
4
+
5
+ score = Clef.score do
6
+ title "Piano Example"
7
+ composer "Clef Demo"
8
+
9
+ staff_group :brace do
10
+ staff :piano_rh, name: "Piano RH", clef: :treble do
11
+ key :c, :major
12
+ time 4, 4
13
+ play "c'4 e'4 g'4 c''4"
14
+ end
15
+
16
+ staff :piano_lh, name: "Piano LH", clef: :bass do
17
+ key :c, :major
18
+ time 4, 4
19
+ play "c2 g,2"
20
+ end
21
+ end
22
+ end
23
+
24
+ score.to_pdf("piano_score.pdf")
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "clef"
4
+
5
+ score = Clef.score do
6
+ title "Twinkle Twinkle Little Star"
7
+ composer "Traditional"
8
+
9
+ staff :melody, clef: :treble do
10
+ key :c, :major
11
+ time 4, 4
12
+ play "c'4 c'4 g'4 g'4 | a'4 a'4 g'2 | f'4 f'4 e'4 e'4 | d'4 d'4 c'2"
13
+ end
14
+ end
15
+
16
+ score.to_pdf("twinkle.pdf")
17
+ score.to_svg("twinkle.svg")
18
+ score.to_midi("twinkle.mid")
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "clef"
4
+
5
+ score = Clef.score do
6
+ title "Vocal Example"
7
+ composer "Clef Demo"
8
+
9
+ staff :voice, clef: :treble do
10
+ key :f, :major
11
+ time 3, 4
12
+
13
+ voice :singer do
14
+ notes "a'4 bes'4 c''4"
15
+ end
16
+
17
+ lyrics :singer, "la-la la"
18
+ end
19
+ end
20
+
21
+ score.to_pdf("vocal_score.pdf")
@@ -0,0 +1,8 @@
1
+ # Bravura Font Placeholder
2
+
3
+ Place `Bravura.otf` here when you want SMuFL glyph rendering in PDF output.
4
+
5
+ - Project: https://github.com/steinbergmedia/bravura
6
+ - License: SIL Open Font License 1.1
7
+
8
+ Clef will fall back to `Helvetica` if the file is missing.
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clef
4
+ class Compiler
5
+ # @param score [Clef::Core::Score]
6
+ # @param style [Clef::Engraving::Style]
7
+ # @param plugins [Clef::Plugins::Registry]
8
+ def initialize(score, style: Clef::Engraving::Style.default, plugins: Clef.plugins)
9
+ @score = score
10
+ @style = style
11
+ @plugins = plugins
12
+ end
13
+
14
+ # @param path [String]
15
+ # @return [String]
16
+ def compile_to_pdf(path)
17
+ layout = build_layout
18
+ renderer = Clef::Renderer::PdfRenderer.new(style: @style)
19
+ @plugins.run_hook(:on_before_render, renderer)
20
+ renderer.render(@score, path, positions: layout[:positions])
21
+ path
22
+ end
23
+
24
+ # @param path [String]
25
+ # @return [String]
26
+ def compile_to_svg(path)
27
+ layout = build_layout
28
+ renderer = Clef::Renderer::SvgRenderer.new(style: @style)
29
+ @plugins.run_hook(:on_before_render, renderer)
30
+ renderer.render(@score, path, positions: layout[:positions])
31
+ path
32
+ end
33
+
34
+ private
35
+
36
+ def build_layout
37
+ @plugins.run_hook(:on_before_layout, @score)
38
+ timeline = Clef::Ir::MusicTree.build(@score)
39
+ spacing = Clef::Layout::Spacing.new(timeline, @style)
40
+ positions = spacing.compute
41
+ layout = { timeline: timeline, spacing: spacing, positions: positions }
42
+ @plugins.run_hook(:on_after_layout, layout)
43
+ layout
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clef
4
+ module Core
5
+ class Chord
6
+ attr_reader :pitches, :duration
7
+
8
+ # @param pitches [Array<Pitch>]
9
+ # @param duration [Duration]
10
+ def initialize(pitches, duration)
11
+ validate_pitches!(pitches)
12
+ raise ArgumentError, "duration must be a Clef::Core::Duration" unless duration.is_a?(Duration)
13
+
14
+ @pitches = pitches.sort
15
+ @duration = duration
16
+ end
17
+
18
+ # @return [Rational]
19
+ def length
20
+ duration.length
21
+ end
22
+
23
+ private
24
+
25
+ def validate_pitches!(pitches)
26
+ list = Array(pitches)
27
+ raise ArgumentError, "pitches must not be empty" if list.empty?
28
+ return if list.all? { |pitch| pitch.is_a?(Pitch) }
29
+
30
+ raise ArgumentError, "all chord pitches must be Clef::Core::Pitch"
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clef
4
+ module Core
5
+ class Clef
6
+ TYPES = %i[treble bass alto tenor soprano mezzo_soprano baritone percussion tab].freeze
7
+ REFERENCE = {
8
+ treble: [[:b, 4], 2],
9
+ bass: [[:d, 3], 2],
10
+ alto: [[:c, 4], 2],
11
+ tenor: [[:a, 3], 2],
12
+ soprano: [[:g, 4], 2],
13
+ mezzo_soprano: [[:e, 4], 2],
14
+ baritone: [[:f, 3], 2],
15
+ percussion: [[:b, 4], 2],
16
+ tab: [[:b, 4], 2]
17
+ }.freeze
18
+
19
+ attr_reader :type
20
+
21
+ # @param type [Symbol]
22
+ def initialize(type)
23
+ raise ArgumentError, "unsupported clef type: #{type}" unless TYPES.include?(type)
24
+
25
+ @type = type
26
+ end
27
+
28
+ # @return [Pitch]
29
+ def reference_pitch
30
+ note_name, octave = REFERENCE.fetch(type).first
31
+ Pitch.new(note_name, octave)
32
+ end
33
+
34
+ # @return [Integer]
35
+ def reference_line
36
+ REFERENCE.fetch(type).last
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clef
4
+ module Core
5
+ class Duration
6
+ include Comparable
7
+
8
+ BASE_VALUES = {
9
+ whole: Rational(1, 1),
10
+ half: Rational(1, 2),
11
+ quarter: Rational(1, 4),
12
+ eighth: Rational(1, 8),
13
+ sixteenth: Rational(1, 16),
14
+ thirty_second: Rational(1, 32),
15
+ sixty_fourth: Rational(1, 64)
16
+ }.freeze
17
+ NUMBER_TO_BASE = {
18
+ 1 => :whole,
19
+ 2 => :half,
20
+ 4 => :quarter,
21
+ 8 => :eighth,
22
+ 16 => :sixteenth,
23
+ 32 => :thirty_second,
24
+ 64 => :sixty_fourth
25
+ }.freeze
26
+ BASE_TO_NUMBER = NUMBER_TO_BASE.invert.freeze
27
+
28
+ attr_reader :base, :dots
29
+
30
+ # @param base [Symbol]
31
+ # @param dots [Integer]
32
+ def initialize(base, dots: 0)
33
+ validate_base!(base)
34
+ validate_dots!(dots)
35
+
36
+ @base = base
37
+ @dots = dots
38
+ freeze
39
+ end
40
+
41
+ # @return [Rational]
42
+ def base_value
43
+ BASE_VALUES.fetch(base)
44
+ end
45
+
46
+ # @return [Rational]
47
+ def length
48
+ base_value * (Rational(2, 1) - Rational(1, 2**dots))
49
+ end
50
+
51
+ # @param other [Duration]
52
+ # @return [Integer, nil]
53
+ def <=>(other)
54
+ return nil unless other.is_a?(self.class)
55
+
56
+ length <=> other.length
57
+ end
58
+
59
+ # @return [String]
60
+ def to_lilypond
61
+ "#{BASE_TO_NUMBER.fetch(base)}#{'.' * dots}"
62
+ end
63
+
64
+ # @param number [Integer]
65
+ # @param dots [Integer]
66
+ # @return [Duration]
67
+ def self.from_lilypond(number, dots = 0)
68
+ new(NUMBER_TO_BASE.fetch(number), dots: dots)
69
+ rescue KeyError
70
+ raise ArgumentError, "unsupported lilypond duration: #{number}"
71
+ end
72
+
73
+ class << self
74
+ BASE_VALUES.keys.each do |duration_name|
75
+ define_method(duration_name) do
76
+ new(duration_name)
77
+ end
78
+ end
79
+ end
80
+
81
+ private
82
+
83
+ def validate_base!(base)
84
+ return if BASE_VALUES.key?(base)
85
+
86
+ raise ArgumentError, "unknown duration base: #{base.inspect}"
87
+ end
88
+
89
+ def validate_dots!(dots)
90
+ return if dots.is_a?(Integer) && (0..3).cover?(dots)
91
+
92
+ raise ArgumentError, "dots must be an Integer between 0 and 3"
93
+ end
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clef
4
+ module Core
5
+ class KeySignature
6
+ MAJOR_ACCIDENTALS = {
7
+ c: { count: 0, type: :natural },
8
+ g: { count: 1, type: :sharp },
9
+ d: { count: 2, type: :sharp },
10
+ a: { count: 3, type: :sharp },
11
+ e: { count: 4, type: :sharp },
12
+ b: { count: 5, type: :sharp },
13
+ fis: { count: 6, type: :sharp },
14
+ cis: { count: 7, type: :sharp },
15
+ f: { count: 1, type: :flat },
16
+ bes: { count: 2, type: :flat },
17
+ ees: { count: 3, type: :flat },
18
+ aes: { count: 4, type: :flat },
19
+ des: { count: 5, type: :flat },
20
+ ges: { count: 6, type: :flat },
21
+ ces: { count: 7, type: :flat }
22
+ }.freeze
23
+ MINOR_ACCIDENTALS = {
24
+ a: { count: 0, type: :natural },
25
+ e: { count: 1, type: :sharp },
26
+ b: { count: 2, type: :sharp },
27
+ fis: { count: 3, type: :sharp },
28
+ cis: { count: 4, type: :sharp },
29
+ gis: { count: 5, type: :sharp },
30
+ dis: { count: 6, type: :sharp },
31
+ ais: { count: 7, type: :sharp },
32
+ d: { count: 1, type: :flat },
33
+ g: { count: 2, type: :flat },
34
+ c: { count: 3, type: :flat },
35
+ f: { count: 4, type: :flat },
36
+ bes: { count: 5, type: :flat },
37
+ ees: { count: 6, type: :flat },
38
+ aes: { count: 7, type: :flat }
39
+ }.freeze
40
+
41
+ attr_reader :tonic, :mode
42
+
43
+ # @param tonic [Pitch, Symbol]
44
+ # @param mode [Symbol]
45
+ def initialize(tonic, mode = :major)
46
+ @tonic = normalize_tonic(tonic)
47
+ validate_mode!(mode)
48
+ @mode = mode
49
+ end
50
+
51
+ # @return [Hash]
52
+ def accidentals
53
+ table = mode == :major ? MAJOR_ACCIDENTALS : MINOR_ACCIDENTALS
54
+ table.fetch(tonic_key) { { count: 0, type: :natural } }
55
+ end
56
+
57
+ private
58
+
59
+ def normalize_tonic(tonic)
60
+ return tonic if tonic.is_a?(Pitch)
61
+ return Pitch.new(tonic, 4) if tonic.is_a?(Symbol)
62
+
63
+ raise ArgumentError, "tonic must be a Pitch or Symbol"
64
+ end
65
+
66
+ def tonic_key
67
+ base = tonic.note_name.to_s
68
+ suffix = tonic.alteration.positive? ? "is" * tonic.alteration : "es" * tonic.alteration.abs
69
+ "#{base}#{suffix}".to_sym
70
+ end
71
+
72
+ def validate_mode!(mode)
73
+ return if %i[major minor].include?(mode)
74
+
75
+ raise ArgumentError, "mode must be :major or :minor"
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clef
4
+ module Core
5
+ class Measure
6
+ attr_reader :number, :voices
7
+ attr_accessor :key_signature, :time_signature, :clef
8
+
9
+ # @param number [Integer]
10
+ # @param time_signature [TimeSignature, nil]
11
+ def initialize(number, time_signature: nil)
12
+ raise ArgumentError, "measure number must be positive" unless number.is_a?(Integer) && number.positive?
13
+
14
+ @number = number
15
+ @time_signature = time_signature
16
+ @voices = {}
17
+ end
18
+
19
+ # @param id [Symbol]
20
+ # @yield [Voice]
21
+ # @return [Voice]
22
+ def voice(id = :default)
23
+ current = voices[id] ||= Voice.new(id: id)
24
+ yield(current) if block_given?
25
+ warn_overflow_if_needed(id, current)
26
+ current
27
+ end
28
+
29
+ # @return [Array<Symbol>]
30
+ def overflowing_voice_ids
31
+ return [] unless time_signature
32
+
33
+ voices.filter_map { |id, voice| id if voice.total_length > time_signature.measure_length }
34
+ end
35
+
36
+ private
37
+
38
+ def warn_overflow_if_needed(id, voice)
39
+ return unless time_signature
40
+ return unless voice.total_length > time_signature.measure_length
41
+
42
+ warn("Measure #{number} voice #{id} exceeds time signature length")
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clef
4
+ module Core
5
+ class Note
6
+ attr_reader :pitch, :duration
7
+ attr_accessor :articulations, :tied
8
+
9
+ # @param pitch [Pitch]
10
+ # @param duration [Duration]
11
+ # @param articulations [Array<Symbol>]
12
+ # @param tied [Boolean]
13
+ def initialize(pitch, duration, articulations: [], tied: false)
14
+ validate_pitch!(pitch)
15
+ validate_duration!(duration)
16
+
17
+ @pitch = pitch
18
+ @duration = duration
19
+ @articulations = Array(articulations).map(&:to_sym)
20
+ @tied = !!tied
21
+ end
22
+
23
+ # @return [Rational]
24
+ def length
25
+ duration.length
26
+ end
27
+
28
+ private
29
+
30
+ def validate_pitch!(pitch)
31
+ return if pitch.is_a?(Pitch)
32
+
33
+ raise ArgumentError, "pitch must be a Clef::Core::Pitch"
34
+ end
35
+
36
+ def validate_duration!(duration)
37
+ return if duration.is_a?(Duration)
38
+
39
+ raise ArgumentError, "duration must be a Clef::Core::Duration"
40
+ end
41
+ end
42
+ end
43
+ end