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.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +172 -0
- data/Rakefile +17 -0
- data/examples/bach_cello_suite.rb +16 -0
- data/examples/piano_score.rb +24 -0
- data/examples/twinkle.rb +18 -0
- data/examples/vocal_score.rb +21 -0
- data/fonts/bravura/README.md +8 -0
- data/lib/clef/compiler.rb +46 -0
- data/lib/clef/core/chord.rb +34 -0
- data/lib/clef/core/clef.rb +40 -0
- data/lib/clef/core/duration.rb +96 -0
- data/lib/clef/core/key_signature.rb +79 -0
- data/lib/clef/core/measure.rb +46 -0
- data/lib/clef/core/note.rb +43 -0
- data/lib/clef/core/pitch.rb +151 -0
- data/lib/clef/core/rest.rb +21 -0
- data/lib/clef/core/score.rb +61 -0
- data/lib/clef/core/staff.rb +34 -0
- data/lib/clef/core/staff_group.rb +30 -0
- data/lib/clef/core/tempo.rb +19 -0
- data/lib/clef/core/time_signature.rb +38 -0
- data/lib/clef/core/voice.rb +29 -0
- data/lib/clef/engraving/font_manager.rb +35 -0
- data/lib/clef/engraving/glyph_table.rb +52 -0
- data/lib/clef/engraving/rules.rb +14 -0
- data/lib/clef/engraving/style.rb +27 -0
- data/lib/clef/ir/event.rb +22 -0
- data/lib/clef/ir/moment.rb +64 -0
- data/lib/clef/ir/music_tree.rb +54 -0
- data/lib/clef/ir/timeline.rb +69 -0
- data/lib/clef/layout/beam_layout.rb +42 -0
- data/lib/clef/layout/line_breaker.rb +60 -0
- data/lib/clef/layout/page_breaker.rb +16 -0
- data/lib/clef/layout/spacing.rb +56 -0
- data/lib/clef/layout/stem.rb +38 -0
- data/lib/clef/midi/channel_map.rb +14 -0
- data/lib/clef/midi/exporter.rb +81 -0
- data/lib/clef/notation/articulation.rb +18 -0
- data/lib/clef/notation/barline.rb +30 -0
- data/lib/clef/notation/beam.rb +25 -0
- data/lib/clef/notation/dynamic.rb +18 -0
- data/lib/clef/notation/lyric.rb +28 -0
- data/lib/clef/notation/slur.rb +28 -0
- data/lib/clef/notation/tie.rb +30 -0
- data/lib/clef/parser/dsl.rb +265 -0
- data/lib/clef/parser/lilypond_lexer.rb +22 -0
- data/lib/clef/parser/lilypond_parser.rb +57 -0
- data/lib/clef/plugins/base.rb +26 -0
- data/lib/clef/plugins/registry.rb +34 -0
- data/lib/clef/renderer/base.rb +26 -0
- data/lib/clef/renderer/notation_helpers.rb +71 -0
- data/lib/clef/renderer/pdf_renderer.rb +341 -0
- data/lib/clef/renderer/svg_renderer.rb +206 -0
- data/lib/clef/version.rb +5 -0
- data/lib/clef.rb +25 -0
- 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")
|
data/examples/twinkle.rb
ADDED
|
@@ -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,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
|