clef 0.1.0 → 1.0.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 (49) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +77 -90
  3. data/Rakefile +21 -1
  4. data/exe/clef +21 -0
  5. data/lib/clef/compiler.rb +107 -4
  6. data/lib/clef/core/chord.rb +9 -3
  7. data/lib/clef/core/duration.rb +7 -3
  8. data/lib/clef/core/key_signature.rb +43 -36
  9. data/lib/clef/core/measure.rb +14 -10
  10. data/lib/clef/core/metadata.rb +52 -0
  11. data/lib/clef/core/note.rb +50 -4
  12. data/lib/clef/core/pitch.rb +73 -4
  13. data/lib/clef/core/rest.rb +11 -3
  14. data/lib/clef/core/score.rb +148 -9
  15. data/lib/clef/core/staff.rb +13 -3
  16. data/lib/clef/core/staff_group.rb +8 -2
  17. data/lib/clef/core/tempo.rb +5 -0
  18. data/lib/clef/core/tuplet.rb +48 -0
  19. data/lib/clef/core/validation.rb +39 -0
  20. data/lib/clef/core/voice.rb +21 -5
  21. data/lib/clef/engraving/font_manager.rb +1 -1
  22. data/lib/clef/engraving/glyph_table.rb +18 -3
  23. data/lib/clef/engraving/style.rb +41 -2
  24. data/lib/clef/ir/moment.rb +2 -2
  25. data/lib/clef/ir/music_tree.rb +2 -2
  26. data/lib/clef/ir/timeline.rb +25 -5
  27. data/lib/clef/layout/beam_layout.rb +2 -2
  28. data/lib/clef/layout/item.rb +26 -0
  29. data/lib/clef/layout/spacing.rb +6 -4
  30. data/lib/clef/layout/stem.rb +10 -6
  31. data/lib/clef/layout/system_layout.rb +71 -0
  32. data/lib/clef/midi/channel_map.rb +5 -2
  33. data/lib/clef/midi/exporter.rb +316 -38
  34. data/lib/clef/notation/dynamic.rb +5 -0
  35. data/lib/clef/notation/lyric.rb +33 -1
  36. data/lib/clef/parser/dsl.rb +249 -58
  37. data/lib/clef/parser/lilypond_lexer.rb +43 -3
  38. data/lib/clef/parser/lilypond_parser.rb +231 -17
  39. data/lib/clef/plugins/base.rb +24 -4
  40. data/lib/clef/plugins/registry.rb +80 -10
  41. data/lib/clef/renderer/base.rb +2 -2
  42. data/lib/clef/renderer/drawing_context.rb +26 -0
  43. data/lib/clef/renderer/notation_helpers.rb +92 -1
  44. data/lib/clef/renderer/pdf_renderer.rb +487 -82
  45. data/lib/clef/renderer/svg_renderer.rb +510 -97
  46. data/lib/clef/version.rb +1 -1
  47. data/lib/clef.rb +60 -7
  48. data/sig/clef.rbs +292 -0
  49. metadata +14 -5
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 54756cf60de8a132c3b38667cd33fdd80f4bb856d87026b916aa4018b1d515e3
4
- data.tar.gz: f78c5b688a8a28952a08d701c737c9b1929cebf6caf86451f9daa0e92ca7f84d
3
+ metadata.gz: e7370681782ef34e1aae62e5fa1c3ef85691ce5b24ef64ec9c7aa4ae7c330729
4
+ data.tar.gz: 93cb4ad4fb0ebfe17ce92d8c237bbf8b142b3ed6b8cf7b2280f5da6f382f2cd0
5
5
  SHA512:
6
- metadata.gz: 2e82ac741da2cdf64fc828fe4664bb1d29bd7a118c0a4bd7095c8d189cafb72be12e8fa263500135d193a467c3728cb97a4fcca5f7ef2e459189f294ef8eddb0
7
- data.tar.gz: 56655953b3b5b01289b38a4e08bf80ce07024cfd13208cc15bb57059e07e4e8a6be2ee6c31ebb2879fb4b379381cd8558d4524b673bc0b2ba3451daeb8ca4f8d
6
+ metadata.gz: 190106173db34b9c34d549354ad06b71a9b910cce0a6c39ed33365fb88d757e71517d2cd9135cd8b2b775ed5ed99c8c427f2372651d5762c8143aa7dd4907884
7
+ data.tar.gz: 252cb685567040363ccbf78db7071379ee5ae2c7a6ff46ea0ff4f6d3547687f8265383e8a9f87d036291a6ee59f6847de91ca30db67eefb0daa3ba003e1b7cc5
data/README.md CHANGED
@@ -1,33 +1,36 @@
1
1
  # Clef
2
2
 
3
- Clef is a Ruby toolkit for building small scores with a Ruby DSL and exporting them to PDF, SVG, or MIDI.
3
+ [![Ruby](https://github.com/ydah/clef/actions/workflows/main.yml/badge.svg?branch=main)](https://github.com/ydah/clef/actions/workflows/main.yml)
4
+ [![Gem Version](https://img.shields.io/gem/v/clef.svg)](https://rubygems.org/gems/clef)
5
+ [![Ruby Version](https://img.shields.io/badge/ruby-%3E%3D%203.1-red.svg)](https://www.ruby-lang.org/)
6
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE.txt)
4
7
 
5
- ## Current Feature Set
8
+ Clef is a lightweight Ruby toolkit for building small music scores, parsing a practical LilyPond-style syntax subset, and exporting PDF, SVG, or MIDI.
6
9
 
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
10
+ It is intended for concise notation workflows, examples, and application embedding. It is not a full replacement for LilyPond, MusicXML, or a publishing-grade engraving engine.
11
+
12
+ ## Requirements
13
+
14
+ - Ruby 3.1 or newer
15
+ - Bundler for development
16
+ - Optional: `fonts/bravura/Bravura.otf` for higher quality PDF/SVG music glyphs
15
17
 
16
18
  ## Installation
17
19
 
18
- Clef requires Ruby 3.1 or newer.
20
+ Add Clef to your application's Gemfile:
21
+
22
+ ```ruby
23
+ gem "clef"
24
+ ```
19
25
 
20
26
  For local development:
21
27
 
22
- ```bash
28
+ ```sh
23
29
  git clone https://github.com/ydah/clef.git
24
30
  cd clef
25
31
  bin/setup
26
32
  ```
27
33
 
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
34
  ## Quick Start
32
35
 
33
36
  ```ruby
@@ -36,11 +39,11 @@ require "clef"
36
39
  score = Clef.score do
37
40
  title "Twinkle Twinkle Little Star"
38
41
  composer "Traditional"
39
- tempo beat_unit: :quarter, bpm: 100
40
42
 
41
- staff :melody, clef: :treble do
43
+ staff :piano, clef: :treble do
42
44
  key :c, :major
43
45
  time 4, 4
46
+
44
47
  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
48
  end
46
49
  end
@@ -50,123 +53,107 @@ score.to_svg("twinkle.svg")
50
53
  score.to_midi("twinkle.mid")
51
54
  ```
52
55
 
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.
56
+ `score.to_format("twinkle.svg")` chooses PDF, SVG, or MIDI from the file extension.
54
57
 
55
- ## DSL Overview
58
+ ## DSL Essentials
56
59
 
57
- Clef's main entry point is `Clef.score`.
60
+ Use `Clef.score` with one or more `staff` blocks. Inside a staff you can set notation state and add music with `play`:
58
61
 
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
62
+ ```ruby
63
+ score = Clef.score do
64
+ title "Example"
65
+ tempo beat_unit: :quarter, bpm: 96
65
66
 
66
- Example:
67
+ staff :violin, clef: :treble do
68
+ key :d, :major
69
+ time 3, 4
70
+ instrument 40
71
+
72
+ play "fis'4 g'4 a'4"
73
+ lyrics :default, "one two three"
74
+ end
75
+ end
76
+ ```
77
+
78
+ Common `play` tokens:
79
+
80
+ - Notes: `c'4`, `fis'8`, `bes2`
81
+ - Rests: `r4`, `r8`
82
+ - Chords: `<c' e' g'>2`
83
+ - Durations: `1`, `2`, `4`, `8`, `16`, with dotted values such as `4.`
84
+ - Measure separators: `|`
85
+ - Ties, slurs, articulations, and dynamics are supported in the lightweight DSL subset
86
+
87
+ For multi-part scores, use multiple `staff` blocks or `staff_group`:
67
88
 
68
89
  ```ruby
69
90
  score = Clef.score do
91
+ title "Piano Sketch"
92
+
70
93
  staff_group :brace do
71
- staff :piano_rh, clef: :treble do
94
+ staff :right, clef: :treble do
72
95
  key :c, :major
73
96
  time 4, 4
74
- play "c'4 e'4 g'4 c''4"
97
+ play "c''4 e''4 g''4 c'''4"
75
98
  end
76
99
 
77
- staff :piano_lh, clef: :bass do
100
+ staff :left, clef: :bass do
78
101
  key :c, :major
79
102
  time 4, 4
80
-
81
- voice :main do
82
- chord %w[c3 g3], :half
83
- rest :half
84
- end
103
+ play "c2 g,2"
85
104
  end
86
105
  end
87
106
  end
88
107
  ```
89
108
 
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
109
+ ## LilyPond-Style Input
93
110
 
94
- `Clef::Parser::LilypondParser` supports a small subset of LilyPond and turns it into a `Clef::Core::Score`.
111
+ Clef can parse a practical subset of LilyPond-style input:
95
112
 
96
113
  ```ruby
97
114
  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")
115
+ score = parser.parse(<<~'LILYPOND')
116
+ \tempo 4 = 96
117
+ \new Staff {
118
+ \clef treble
119
+ \key c \major
120
+ \time 4/4
121
+ { c'4 d'4 e'4 f'4 | }
122
+ }
123
+ LILYPOND
124
+
125
+ warn parser.warnings.join("\n") unless parser.warnings.empty?
107
126
  ```
108
127
 
109
- The current parser recognizes:
110
-
111
- - `\clef`
112
- - `\key` with `\major` or `\minor`
113
- - `\time`
114
- - Note, rest, chord, and bar tokens inside `{ ... }`
128
+ The importer supports tempo, staves, staff groups, clefs, key and time signatures, notes, rests, chords, a small relative pitch subset, simultaneous voices, dynamics, articulations, ties, slurs, beams, and measure splitting. Unsupported commands are skipped and reported as warnings with source locations.
115
129
 
116
130
  ## Plugins
117
131
 
118
- Clef exposes plugin hooks through `Clef.plugins`.
132
+ Plugins can inspect or modify scores before rendering, and can observe rendering lifecycle events:
119
133
 
120
134
  ```ruby
121
- class MarkerPlugin < Clef::Plugins::Base
135
+ class PreparedFlagPlugin < Clef::Plugins::Base
122
136
  def on_before_layout(score)
123
- score.metadata[:prepared] = true
137
+ score.set_metadata(:prepared, true)
124
138
  end
125
139
  end
126
140
 
127
- Clef.plugins.register(MarkerPlugin)
141
+ Clef.plugins.register(PreparedFlagPlugin)
128
142
  ```
129
143
 
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.
144
+ Common hooks are `on_before_layout`, `on_after_layout`, `on_before_render`, `on_after_render`, `on_after_parse`, `on_before_midi`, and `register_glyphs`.
142
145
 
143
146
  ## Development
144
147
 
145
- Install dependencies:
146
-
147
- ```bash
148
+ ```sh
148
149
  bin/setup
149
- ```
150
-
151
- Run the test suite:
152
-
153
- ```bash
154
- bundle exec rspec
155
150
  bundle exec rake
156
- ```
157
-
158
- Run an example:
159
-
160
- ```bash
161
151
  bundle exec ruby examples/twinkle.rb
152
+ bundle exec ruby exe/clef examples/twinkle.rb twinkle.svg
162
153
  ```
163
154
 
164
- Open a console:
165
-
166
- ```bash
167
- bin/console
168
- ```
155
+ `bundle exec rake` runs specs, lint, syntax checks, and a gem build smoke test.
169
156
 
170
157
  ## License
171
158
 
172
- MIT License.
159
+ Clef is available as open source under the terms of the MIT License.
data/Rakefile CHANGED
@@ -2,9 +2,29 @@
2
2
 
3
3
  require "bundler/gem_tasks"
4
4
  require "rspec/core/rake_task"
5
+ require "rbconfig"
6
+ require "tmpdir"
5
7
 
6
8
  RSpec::Core::RakeTask.new(:spec)
7
9
 
10
+ desc "Run Standard Ruby lint"
11
+ task :lint do
12
+ sh({"RUBOCOP_CACHE_ROOT" => "tmp/rubocop_cache"}, "bundle", "exec", "standardrb")
13
+ end
14
+
15
+ desc "Run Ruby syntax checks"
16
+ task :syntax do
17
+ files = FileList["lib/**/*.rb", "spec/**/*.rb", "exe/*", "*.gemspec", "Rakefile"]
18
+ files.each { |file| sh RbConfig.ruby, "-c", file }
19
+ end
20
+
21
+ desc "Build the gem into a temporary directory"
22
+ task :build_smoke do
23
+ Dir.mktmpdir do |dir|
24
+ sh "gem", "build", "clef.gemspec", "--output", File.join(dir, "clef.gem")
25
+ end
26
+ end
27
+
8
28
  begin
9
29
  require "yard"
10
30
  YARD::Rake::YardocTask.new(:yard)
@@ -14,4 +34,4 @@ rescue LoadError
14
34
  end
15
35
  end
16
36
 
17
- task default: :spec
37
+ task default: %i[spec lint syntax build_smoke]
data/exe/clef ADDED
@@ -0,0 +1,21 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "clef"
5
+
6
+ input, output = ARGV
7
+ unless input && output
8
+ warn "usage: clef SCORE.rb OUTPUT.{pdf,svg,mid}"
9
+ exit 64
10
+ end
11
+
12
+ before = Clef.last_score
13
+ load input, true
14
+ score = Clef.last_score unless Clef.last_score.equal?(before)
15
+
16
+ unless score.is_a?(Clef::Core::Score)
17
+ warn "input must build a Clef::Core::Score with Clef.score"
18
+ exit 65
19
+ end
20
+
21
+ score.to_format(output)
data/lib/clef/compiler.rb CHANGED
@@ -16,8 +16,10 @@ module Clef
16
16
  def compile_to_pdf(path)
17
17
  layout = build_layout
18
18
  renderer = Clef::Renderer::PdfRenderer.new(style: @style)
19
+ @plugins.run_hook(:register_glyphs, renderer.glyph_table)
19
20
  @plugins.run_hook(:on_before_render, renderer)
20
- renderer.render(@score, path, positions: layout[:positions])
21
+ renderer.render(@score, path, positions: layout[:positions], layout: layout)
22
+ @plugins.run_hook(:on_after_render, path)
21
23
  path
22
24
  end
23
25
 
@@ -26,8 +28,10 @@ module Clef
26
28
  def compile_to_svg(path)
27
29
  layout = build_layout
28
30
  renderer = Clef::Renderer::SvgRenderer.new(style: @style)
31
+ @plugins.run_hook(:register_glyphs, renderer.glyph_table)
29
32
  @plugins.run_hook(:on_before_render, renderer)
30
- renderer.render(@score, path, positions: layout[:positions])
33
+ renderer.render(@score, path, positions: layout[:positions], layout: layout)
34
+ @plugins.run_hook(:on_after_render, path)
31
35
  path
32
36
  end
33
37
 
@@ -36,11 +40,110 @@ module Clef
36
40
  def build_layout
37
41
  @plugins.run_hook(:on_before_layout, @score)
38
42
  timeline = Clef::Ir::MusicTree.build(@score)
39
- spacing = Clef::Layout::Spacing.new(timeline, @style)
43
+ timeline.sort!
44
+ layout_items = build_layout_items
45
+ plugin_items = @plugins.run_hook(:on_layout_items, layout_items).flatten.compact
46
+ layout_items.concat(plugin_items)
47
+ spacing = Clef::Layout::Spacing.new(timeline, @style, extra_moments: layout_items.map(&:moment))
48
+ spacing.stretch_to_fit(@style.line_width)
40
49
  positions = spacing.compute
41
- layout = { timeline: timeline, spacing: spacing, positions: positions }
50
+ columns = build_columns(positions)
51
+ lines = Clef::Layout::LineBreaker.new.break_into_lines(columns, @style.line_width)
52
+ pages = Clef::Layout::PageBreaker.new.break_into_pages(
53
+ lines,
54
+ page_height: page_height,
55
+ line_height: @style.system_gap
56
+ )
57
+ layout = {
58
+ timeline: timeline,
59
+ spacing: spacing,
60
+ positions: positions,
61
+ columns: columns,
62
+ lines: lines,
63
+ pages: pages,
64
+ systems: Clef::Layout::SystemLayout.new(@score, pages: pages, positions: positions, style: @style).build,
65
+ items: layout_items,
66
+ beams: build_beams
67
+ }
42
68
  @plugins.run_hook(:on_after_layout, layout)
43
69
  layout
44
70
  end
71
+
72
+ def build_columns(positions)
73
+ sorted = positions.sort_by { |moment, _x| moment.value }
74
+ sorted.each_cons(2).map do |(moment, x), (_next_moment, next_x)|
75
+ {moment: moment, x: x, width: [next_x - x, @style.min_note_spacing].max, break_penalty: 0}
76
+ end + sorted.last(1).map do |moment, x|
77
+ {moment: moment, x: x, width: 0.0, break_penalty: 0}
78
+ end
79
+ end
80
+
81
+ def build_beams
82
+ @score.staves.to_h do |staff|
83
+ [staff.id, staff.measures.to_h { |measure| [measure.number, measure_beams(measure)] }]
84
+ end
85
+ end
86
+
87
+ def build_layout_items
88
+ @score.staves.flat_map do |staff|
89
+ current = Clef::Ir::Moment.new(0)
90
+ staff.measures.flat_map do |measure|
91
+ items = metadata_items(staff, measure, current)
92
+ current += measure_length_for(measure)
93
+ items << Clef::Layout::Item.new(type: :barline, moment: current, staff_id: staff.id,
94
+ measure_number: measure.number)
95
+ items
96
+ end
97
+ end
98
+ end
99
+
100
+ def metadata_items(staff, measure, moment)
101
+ [
102
+ Clef::Layout::Item.new(type: :clef, moment: moment, staff_id: staff.id,
103
+ measure_number: measure.number, payload: {clef: measure.clef || staff.clef}),
104
+ Clef::Layout::Item.new(type: :key_signature, moment: moment, staff_id: staff.id,
105
+ measure_number: measure.number, payload: {key_signature: measure.key_signature || staff.key_signature}),
106
+ Clef::Layout::Item.new(type: :time_signature, moment: moment, staff_id: staff.id,
107
+ measure_number: measure.number, payload: {time_signature: measure.time_signature || staff.time_signature})
108
+ ].reject { |item| item.payload.values.all?(&:nil?) }
109
+ end
110
+
111
+ def measure_length_for(measure)
112
+ return measure.time_signature.measure_length if measure.time_signature
113
+
114
+ measure.voices.values.map(&:total_length).max || Rational(0, 1)
115
+ end
116
+
117
+ def measure_beams(measure)
118
+ measure.voices.to_h do |voice_id, voice|
119
+ notes = beamable_notes(voice.elements)
120
+ groups = Clef::Layout::BeamLayout.auto_beam(notes, measure.time_signature || Clef::Core::TimeSignature.new(4, 4))
121
+ [voice_id, groups.select { |group| group.length > 1 }]
122
+ end
123
+ end
124
+
125
+ def beamable_notes(elements)
126
+ elements.flat_map do |element|
127
+ case element
128
+ when Clef::Core::Note
129
+ beamable_duration?(element.duration) ? [element] : []
130
+ when Clef::Core::Tuplet
131
+ beamable_notes(element.elements)
132
+ else
133
+ []
134
+ end
135
+ end
136
+ end
137
+
138
+ def beamable_duration?(duration)
139
+ %i[eighth sixteenth thirty_second sixty_fourth one_twenty_eighth two_fifty_sixth].include?(duration.base)
140
+ end
141
+
142
+ def page_height
143
+ case @style.page_size
144
+ when Array then @style.page_size.last.to_f - (@style.margin * 2)
145
+ else 760.0
146
+ end
147
+ end
45
148
  end
46
149
  end
@@ -11,7 +11,7 @@ module Clef
11
11
  validate_pitches!(pitches)
12
12
  raise ArgumentError, "duration must be a Clef::Core::Duration" unless duration.is_a?(Duration)
13
13
 
14
- @pitches = pitches.sort
14
+ @pitches = Array(pitches).freeze
15
15
  @duration = duration
16
16
  end
17
17
 
@@ -20,14 +20,20 @@ module Clef
20
20
  duration.length
21
21
  end
22
22
 
23
+ # @return [Array<Pitch>]
24
+ def sorted_pitches
25
+ pitches.sort
26
+ end
27
+
23
28
  private
24
29
 
25
30
  def validate_pitches!(pitches)
26
31
  list = Array(pitches)
27
32
  raise ArgumentError, "pitches must not be empty" if list.empty?
28
- return if list.all? { |pitch| pitch.is_a?(Pitch) }
33
+ raise ArgumentError, "all chord pitches must be Clef::Core::Pitch" unless list.all? { |pitch| pitch.is_a?(Pitch) }
34
+ return if list.map(&:semitones).uniq.length == list.length
29
35
 
30
- raise ArgumentError, "all chord pitches must be Clef::Core::Pitch"
36
+ raise ArgumentError, "chord pitches must not contain duplicates"
31
37
  end
32
38
  end
33
39
  end
@@ -12,7 +12,9 @@ module Clef
12
12
  eighth: Rational(1, 8),
13
13
  sixteenth: Rational(1, 16),
14
14
  thirty_second: Rational(1, 32),
15
- sixty_fourth: Rational(1, 64)
15
+ sixty_fourth: Rational(1, 64),
16
+ one_twenty_eighth: Rational(1, 128),
17
+ two_fifty_sixth: Rational(1, 256)
16
18
  }.freeze
17
19
  NUMBER_TO_BASE = {
18
20
  1 => :whole,
@@ -21,7 +23,9 @@ module Clef
21
23
  8 => :eighth,
22
24
  16 => :sixteenth,
23
25
  32 => :thirty_second,
24
- 64 => :sixty_fourth
26
+ 64 => :sixty_fourth,
27
+ 128 => :one_twenty_eighth,
28
+ 256 => :two_fifty_sixth
25
29
  }.freeze
26
30
  BASE_TO_NUMBER = NUMBER_TO_BASE.invert.freeze
27
31
 
@@ -58,7 +62,7 @@ module Clef
58
62
 
59
63
  # @return [String]
60
64
  def to_lilypond
61
- "#{BASE_TO_NUMBER.fetch(base)}#{'.' * dots}"
65
+ "#{BASE_TO_NUMBER.fetch(base)}#{"." * dots}"
62
66
  end
63
67
 
64
68
  # @param number [Integer]
@@ -4,43 +4,43 @@ module Clef
4
4
  module Core
5
5
  class KeySignature
6
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 }
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
22
  }.freeze
23
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 }
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
39
  }.freeze
40
40
 
41
41
  attr_reader :tonic, :mode
42
42
 
43
- # @param tonic [Pitch, Symbol]
43
+ # @param tonic [Pitch, Symbol, String]
44
44
  # @param mode [Symbol]
45
45
  def initialize(tonic, mode = :major)
46
46
  @tonic = normalize_tonic(tonic)
@@ -50,23 +50,30 @@ module Clef
50
50
 
51
51
  # @return [Hash]
52
52
  def accidentals
53
- table = mode == :major ? MAJOR_ACCIDENTALS : MINOR_ACCIDENTALS
54
- table.fetch(tonic_key) { { count: 0, type: :natural } }
53
+ table = (mode == :major) ? MAJOR_ACCIDENTALS : MINOR_ACCIDENTALS
54
+ table.fetch(tonic_key)
55
+ rescue KeyError
56
+ raise ArgumentError, "unsupported #{mode} key tonic: #{tonic.to_lilypond}"
57
+ end
58
+
59
+ # @return [Symbol]
60
+ def preferred_transpose_spelling
61
+ (accidentals[:type] == :flat) ? :flat : :sharp
55
62
  end
56
63
 
57
64
  private
58
65
 
59
66
  def normalize_tonic(tonic)
60
67
  return tonic if tonic.is_a?(Pitch)
61
- return Pitch.new(tonic, 4) if tonic.is_a?(Symbol)
68
+ return Pitch.parse_any(tonic) if tonic.is_a?(Symbol) || tonic.is_a?(String)
62
69
 
63
- raise ArgumentError, "tonic must be a Pitch or Symbol"
70
+ raise ArgumentError, "tonic must be a Pitch, Symbol, or String"
64
71
  end
65
72
 
66
73
  def tonic_key
67
74
  base = tonic.note_name.to_s
68
75
  suffix = tonic.alteration.positive? ? "is" * tonic.alteration : "es" * tonic.alteration.abs
69
- "#{base}#{suffix}".to_sym
76
+ :"#{base}#{suffix}"
70
77
  end
71
78
 
72
79
  def validate_mode!(mode)
@@ -3,7 +3,7 @@
3
3
  module Clef
4
4
  module Core
5
5
  class Measure
6
- attr_reader :number, :voices
6
+ attr_reader :number
7
7
  attr_accessor :key_signature, :time_signature, :clef
8
8
 
9
9
  # @param number [Integer]
@@ -20,26 +20,30 @@ module Clef
20
20
  # @yield [Voice]
21
21
  # @return [Voice]
22
22
  def voice(id = :default)
23
- current = voices[id] ||= Voice.new(id: id)
23
+ current = @voices[id] ||= Voice.new(id: id)
24
24
  yield(current) if block_given?
25
- warn_overflow_if_needed(id, current)
26
25
  current
27
26
  end
28
27
 
28
+ # @return [Hash<Symbol, Voice>]
29
+ def voices
30
+ @voices.dup.freeze
31
+ end
32
+
29
33
  # @return [Array<Symbol>]
30
34
  def overflowing_voice_ids
31
35
  return [] unless time_signature
32
36
 
33
- voices.filter_map { |id, voice| id if voice.total_length > time_signature.measure_length }
37
+ @voices.filter_map { |id, voice| id if voice.total_length > time_signature.measure_length }
34
38
  end
35
39
 
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
40
+ # @return [Array<Symbol>]
41
+ def underfull_voice_ids
42
+ return [] unless time_signature
41
43
 
42
- warn("Measure #{number} voice #{id} exceeds time signature length")
44
+ @voices.filter_map do |id, voice|
45
+ id if voice.total_length.positive? && voice.total_length < time_signature.measure_length
46
+ end
43
47
  end
44
48
  end
45
49
  end