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.
- checksums.yaml +4 -4
- data/README.md +77 -90
- data/Rakefile +21 -1
- data/exe/clef +21 -0
- data/lib/clef/compiler.rb +107 -4
- data/lib/clef/core/chord.rb +9 -3
- data/lib/clef/core/duration.rb +7 -3
- data/lib/clef/core/key_signature.rb +43 -36
- data/lib/clef/core/measure.rb +14 -10
- data/lib/clef/core/metadata.rb +52 -0
- data/lib/clef/core/note.rb +50 -4
- data/lib/clef/core/pitch.rb +73 -4
- data/lib/clef/core/rest.rb +11 -3
- data/lib/clef/core/score.rb +148 -9
- data/lib/clef/core/staff.rb +13 -3
- data/lib/clef/core/staff_group.rb +8 -2
- data/lib/clef/core/tempo.rb +5 -0
- data/lib/clef/core/tuplet.rb +48 -0
- data/lib/clef/core/validation.rb +39 -0
- data/lib/clef/core/voice.rb +21 -5
- data/lib/clef/engraving/font_manager.rb +1 -1
- data/lib/clef/engraving/glyph_table.rb +18 -3
- data/lib/clef/engraving/style.rb +41 -2
- data/lib/clef/ir/moment.rb +2 -2
- data/lib/clef/ir/music_tree.rb +2 -2
- data/lib/clef/ir/timeline.rb +25 -5
- data/lib/clef/layout/beam_layout.rb +2 -2
- data/lib/clef/layout/item.rb +26 -0
- data/lib/clef/layout/spacing.rb +6 -4
- data/lib/clef/layout/stem.rb +10 -6
- data/lib/clef/layout/system_layout.rb +71 -0
- data/lib/clef/midi/channel_map.rb +5 -2
- data/lib/clef/midi/exporter.rb +316 -38
- data/lib/clef/notation/dynamic.rb +5 -0
- data/lib/clef/notation/lyric.rb +33 -1
- data/lib/clef/parser/dsl.rb +249 -58
- data/lib/clef/parser/lilypond_lexer.rb +43 -3
- data/lib/clef/parser/lilypond_parser.rb +231 -17
- data/lib/clef/plugins/base.rb +24 -4
- data/lib/clef/plugins/registry.rb +80 -10
- data/lib/clef/renderer/base.rb +2 -2
- data/lib/clef/renderer/drawing_context.rb +26 -0
- data/lib/clef/renderer/notation_helpers.rb +92 -1
- data/lib/clef/renderer/pdf_renderer.rb +487 -82
- data/lib/clef/renderer/svg_renderer.rb +510 -97
- data/lib/clef/version.rb +1 -1
- data/lib/clef.rb +60 -7
- data/sig/clef.rbs +292 -0
- metadata +14 -5
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e7370681782ef34e1aae62e5fa1c3ef85691ce5b24ef64ec9c7aa4ae7c330729
|
|
4
|
+
data.tar.gz: 93cb4ad4fb0ebfe17ce92d8c237bbf8b142b3ed6b8cf7b2280f5da6f382f2cd0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 190106173db34b9c34d549354ad06b71a9b910cce0a6c39ed33365fb88d757e71517d2cd9135cd8b2b775ed5ed99c8c427f2372651d5762c8143aa7dd4907884
|
|
7
|
+
data.tar.gz: 252cb685567040363ccbf78db7071379ee5ae2c7a6ff46ea0ff4f6d3547687f8265383e8a9f87d036291a6ee59f6847de91ca30db67eefb0daa3ba003e1b7cc5
|
data/README.md
CHANGED
|
@@ -1,33 +1,36 @@
|
|
|
1
1
|
# Clef
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://github.com/ydah/clef/actions/workflows/main.yml)
|
|
4
|
+
[](https://rubygems.org/gems/clef)
|
|
5
|
+
[](https://www.ruby-lang.org/)
|
|
6
|
+
[](LICENSE.txt)
|
|
4
7
|
|
|
5
|
-
|
|
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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
-
|
|
12
|
-
-
|
|
13
|
-
-
|
|
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
|
|
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
|
-
```
|
|
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 :
|
|
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
|
-
`
|
|
56
|
+
`score.to_format("twinkle.svg")` chooses PDF, SVG, or MIDI from the file extension.
|
|
54
57
|
|
|
55
|
-
## DSL
|
|
58
|
+
## DSL Essentials
|
|
56
59
|
|
|
57
|
-
Clef
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
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 :
|
|
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 :
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
## LilyPond Input
|
|
109
|
+
## LilyPond-Style Input
|
|
93
110
|
|
|
94
|
-
|
|
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
|
-
|
|
100
|
-
\
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
|
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
|
-
|
|
132
|
+
Plugins can inspect or modify scores before rendering, and can observe rendering lifecycle events:
|
|
119
133
|
|
|
120
134
|
```ruby
|
|
121
|
-
class
|
|
135
|
+
class PreparedFlagPlugin < Clef::Plugins::Base
|
|
122
136
|
def on_before_layout(score)
|
|
123
|
-
score.
|
|
137
|
+
score.set_metadata(:prepared, true)
|
|
124
138
|
end
|
|
125
139
|
end
|
|
126
140
|
|
|
127
|
-
Clef.plugins.register(
|
|
141
|
+
Clef.plugins.register(PreparedFlagPlugin)
|
|
128
142
|
```
|
|
129
143
|
|
|
130
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
data/lib/clef/core/chord.rb
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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, "
|
|
36
|
+
raise ArgumentError, "chord pitches must not contain duplicates"
|
|
31
37
|
end
|
|
32
38
|
end
|
|
33
39
|
end
|
data/lib/clef/core/duration.rb
CHANGED
|
@@ -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)}#{
|
|
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: {
|
|
8
|
-
g: {
|
|
9
|
-
d: {
|
|
10
|
-
a: {
|
|
11
|
-
e: {
|
|
12
|
-
b: {
|
|
13
|
-
fis: {
|
|
14
|
-
cis: {
|
|
15
|
-
f: {
|
|
16
|
-
bes: {
|
|
17
|
-
ees: {
|
|
18
|
-
aes: {
|
|
19
|
-
des: {
|
|
20
|
-
ges: {
|
|
21
|
-
ces: {
|
|
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: {
|
|
25
|
-
e: {
|
|
26
|
-
b: {
|
|
27
|
-
fis: {
|
|
28
|
-
cis: {
|
|
29
|
-
gis: {
|
|
30
|
-
dis: {
|
|
31
|
-
ais: {
|
|
32
|
-
d: {
|
|
33
|
-
g: {
|
|
34
|
-
c: {
|
|
35
|
-
f: {
|
|
36
|
-
bes: {
|
|
37
|
-
ees: {
|
|
38
|
-
aes: {
|
|
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)
|
|
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.
|
|
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
|
|
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}"
|
|
76
|
+
:"#{base}#{suffix}"
|
|
70
77
|
end
|
|
71
78
|
|
|
72
79
|
def validate_mode!(mode)
|
data/lib/clef/core/measure.rb
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
module Clef
|
|
4
4
|
module Core
|
|
5
5
|
class Measure
|
|
6
|
-
attr_reader :number
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
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
|