head_music 20.0.0 → 20.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 +4 -4
- data/.github/workflows/ci.yml +93 -8
- data/.github/workflows/release.yml +10 -9
- data/.github/workflows/security.yml +10 -8
- data/.rubocop.yml +8 -0
- data/CHANGELOG.md +18 -2
- data/Gemfile +5 -4
- data/Gemfile.lock +5 -5
- data/README.md +14 -0
- data/lib/head_music/content/voice.rb +11 -4
- data/lib/head_music/notation/abc/duration_resolver.rb +4 -64
- data/lib/head_music/notation/abc/voice_state.rb +1 -10
- data/lib/head_music/notation/dotted_duration.rb +81 -2
- data/lib/head_music/notation/lily_pond/assignment_reader.rb +65 -0
- data/lib/head_music/notation/lily_pond/composition_builder.rb +159 -0
- data/lib/head_music/notation/lily_pond/context_reader.rb +85 -0
- data/lib/head_music/notation/lily_pond/document.rb +46 -0
- data/lib/head_music/notation/lily_pond/document_reader.rb +96 -0
- data/lib/head_music/notation/lily_pond/duration_reader.rb +67 -0
- data/lib/head_music/notation/lily_pond/key_reader.rb +63 -0
- data/lib/head_music/notation/lily_pond/lexer.rb +198 -0
- data/lib/head_music/notation/lily_pond/meter_reader.rb +31 -0
- data/lib/head_music/notation/lily_pond/music_item_reader.rb +107 -0
- data/lib/head_music/notation/lily_pond/music_reader.rb +127 -0
- data/lib/head_music/notation/lily_pond/parse_preflight.rb +48 -0
- data/lib/head_music/notation/lily_pond/parser.rb +30 -0
- data/lib/head_music/notation/lily_pond/pitch_reader.rb +100 -0
- data/lib/head_music/notation/lily_pond/pitch_reader_stack.rb +31 -0
- data/lib/head_music/notation/lily_pond/render_plan.rb +8 -52
- data/lib/head_music/notation/lily_pond/string_text.rb +10 -4
- data/lib/head_music/notation/lily_pond/token.rb +20 -0
- data/lib/head_music/notation/lily_pond/token_cursor.rb +68 -0
- data/lib/head_music/notation/lily_pond/voice_context.rb +42 -0
- data/lib/head_music/notation/lily_pond/voice_stream.rb +110 -0
- data/lib/head_music/notation/lily_pond.rb +28 -1
- data/lib/head_music/notation/music_xml/render_plan.rb +12 -58
- data/lib/head_music/notation/render_plan.rb +71 -0
- data/lib/head_music/notation.rb +1 -0
- data/lib/head_music/rudiment/rhythmic_value.rb +8 -0
- data/lib/head_music/version.rb +1 -1
- data/user-stories/done/lilypond-interpreter.md +395 -0
- data/user-stories/index.html +32 -40
- metadata +23 -3
- data/user-stories/backlog/lilypond-interpreter.md +0 -77
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b3714d51858bd368308d14a5f12544e6f4fb9dbecf0f0eacc1ad795c5e4b3d1e
|
|
4
|
+
data.tar.gz: 99345ffa2b44dfbdc27836b7b7350a38af1665770a74490f52e22bd1a0aa3602
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d95aa74479fe498564dc7e1bc5427660b9687e286a31a4b5cbbb1c0a274bebe9413ae18e2176dbbf38c351e4b1517275d5bf8dce590642c88c2301ea83f038b6
|
|
7
|
+
data.tar.gz: 4446808775faf30ef42dceca7de10248a88d8c8265ab4faa3c11589bd6ddb7dd60ff9c10ce2cb34a68f9e039061bc3633484e01b2002f649bb3f885bd5d7e968
|
data/.github/workflows/ci.yml
CHANGED
|
@@ -6,14 +6,39 @@ on:
|
|
|
6
6
|
pull_request:
|
|
7
7
|
branches: [ main ]
|
|
8
8
|
|
|
9
|
+
permissions:
|
|
10
|
+
contents: read
|
|
11
|
+
|
|
12
|
+
# A rebase flow means force-pushes are routine; without this, each one leaves
|
|
13
|
+
# the superseded run burning minutes alongside its replacement.
|
|
14
|
+
concurrency:
|
|
15
|
+
group: ${{ github.workflow }}-${{ github.ref }}
|
|
16
|
+
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
|
17
|
+
|
|
9
18
|
jobs:
|
|
10
19
|
test:
|
|
20
|
+
name: test (ruby ${{ matrix.ruby-version }}, activesupport ${{ matrix.activesupport-version || 'latest' }})
|
|
11
21
|
runs-on: ubuntu-latest
|
|
22
|
+
timeout-minutes: 10
|
|
12
23
|
strategy:
|
|
13
24
|
fail-fast: false
|
|
14
25
|
matrix:
|
|
15
|
-
|
|
16
|
-
activesupport-version
|
|
26
|
+
# The corners of the supported range rather than a full cross product.
|
|
27
|
+
# An empty activesupport-version installs from the lockfile, covering
|
|
28
|
+
# the newest release the gemspec allows; ActiveSupport 7.2 is not
|
|
29
|
+
# paired with Ruby 4.0, which it predates.
|
|
30
|
+
include:
|
|
31
|
+
- ruby-version: '3.3'
|
|
32
|
+
activesupport-version: '7.2'
|
|
33
|
+
- ruby-version: '3.3'
|
|
34
|
+
activesupport-version: ''
|
|
35
|
+
coverage: true
|
|
36
|
+
- ruby-version: '3.4'
|
|
37
|
+
activesupport-version: '7.2'
|
|
38
|
+
- ruby-version: '3.4'
|
|
39
|
+
activesupport-version: '8.0'
|
|
40
|
+
- ruby-version: '4.0'
|
|
41
|
+
activesupport-version: ''
|
|
17
42
|
|
|
18
43
|
steps:
|
|
19
44
|
- uses: actions/checkout@v7
|
|
@@ -24,31 +49,90 @@ jobs:
|
|
|
24
49
|
ruby-version: ${{ matrix.ruby-version }}
|
|
25
50
|
bundler-cache: false
|
|
26
51
|
|
|
27
|
-
- name: Install dependencies with ActiveSupport ${{ matrix.activesupport-version }}
|
|
52
|
+
- name: Install dependencies with ActiveSupport ${{ matrix.activesupport-version || 'latest' }}
|
|
53
|
+
env:
|
|
54
|
+
ACTIVESUPPORT_VERSION: ${{ matrix.activesupport-version }}
|
|
28
55
|
run: |
|
|
29
56
|
bundle config set --local path vendor/bundle
|
|
30
|
-
|
|
57
|
+
bundle install
|
|
31
58
|
|
|
32
59
|
- name: Run tests
|
|
33
60
|
run: bundle exec rspec
|
|
34
61
|
|
|
35
62
|
- name: Upload coverage to Codecov
|
|
36
|
-
if: matrix.
|
|
63
|
+
if: matrix.coverage
|
|
37
64
|
uses: codecov/codecov-action@v7
|
|
38
65
|
with:
|
|
39
66
|
token: ${{ secrets.CODECOV_TOKEN }}
|
|
40
67
|
fail_ci_if_error: false
|
|
41
68
|
verbose: true
|
|
42
69
|
|
|
70
|
+
# Months of warning about upcoming Ruby releases, without a red check that
|
|
71
|
+
# blocks a merge.
|
|
72
|
+
test-ruby-head:
|
|
73
|
+
name: test (ruby-head, advisory)
|
|
74
|
+
runs-on: ubuntu-latest
|
|
75
|
+
timeout-minutes: 10
|
|
76
|
+
continue-on-error: true
|
|
77
|
+
env:
|
|
78
|
+
# Bundler otherwise switches to the lockfile's BUNDLED WITH, and 2.7.2
|
|
79
|
+
# raises "uninitialized constant Pathname::SEPARATOR_PAT" on a Ruby this
|
|
80
|
+
# new. A bundler bug is not the breakage this job exists to find.
|
|
81
|
+
BUNDLE_VERSION: system
|
|
82
|
+
steps:
|
|
83
|
+
- uses: actions/checkout@v7
|
|
84
|
+
|
|
85
|
+
- name: Set up Ruby head
|
|
86
|
+
uses: ruby/setup-ruby@v1
|
|
87
|
+
with:
|
|
88
|
+
ruby-version: head
|
|
89
|
+
bundler: latest
|
|
90
|
+
bundler-cache: false
|
|
91
|
+
|
|
92
|
+
- name: Install dependencies
|
|
93
|
+
run: |
|
|
94
|
+
bundle config set --local path vendor/bundle
|
|
95
|
+
bundle install
|
|
96
|
+
|
|
97
|
+
- name: Run tests
|
|
98
|
+
run: bundle exec rspec
|
|
99
|
+
|
|
100
|
+
# The LilyPond specs treat the real binary as an oracle and skip when none is
|
|
101
|
+
# installed, so without this job nothing proves the rendered documents
|
|
102
|
+
# actually compile. Runs the whole suite because SimpleCov's minimum applies
|
|
103
|
+
# to any rspec invocation.
|
|
104
|
+
test-lilypond:
|
|
105
|
+
name: test (with the lilypond toolchain)
|
|
106
|
+
runs-on: ubuntu-latest
|
|
107
|
+
timeout-minutes: 15
|
|
108
|
+
steps:
|
|
109
|
+
- uses: actions/checkout@v7
|
|
110
|
+
|
|
111
|
+
- name: Install LilyPond
|
|
112
|
+
run: |
|
|
113
|
+
sudo apt-get update
|
|
114
|
+
sudo apt-get install -y lilypond
|
|
115
|
+
lilypond --version
|
|
116
|
+
|
|
117
|
+
- name: Set up Ruby
|
|
118
|
+
uses: ruby/setup-ruby@v1
|
|
119
|
+
with:
|
|
120
|
+
ruby-version: .ruby-version
|
|
121
|
+
bundler-cache: true
|
|
122
|
+
|
|
123
|
+
- name: Run tests
|
|
124
|
+
run: bundle exec rspec
|
|
125
|
+
|
|
43
126
|
lint:
|
|
44
127
|
runs-on: ubuntu-latest
|
|
128
|
+
timeout-minutes: 5
|
|
45
129
|
steps:
|
|
46
130
|
- uses: actions/checkout@v7
|
|
47
131
|
|
|
48
132
|
- name: Set up Ruby
|
|
49
133
|
uses: ruby/setup-ruby@v1
|
|
50
134
|
with:
|
|
51
|
-
ruby-version:
|
|
135
|
+
ruby-version: .ruby-version
|
|
52
136
|
bundler-cache: true
|
|
53
137
|
|
|
54
138
|
- name: Run RuboCop
|
|
@@ -56,13 +140,14 @@ jobs:
|
|
|
56
140
|
|
|
57
141
|
build:
|
|
58
142
|
runs-on: ubuntu-latest
|
|
143
|
+
timeout-minutes: 5
|
|
59
144
|
steps:
|
|
60
145
|
- uses: actions/checkout@v7
|
|
61
146
|
|
|
62
147
|
- name: Set up Ruby
|
|
63
148
|
uses: ruby/setup-ruby@v1
|
|
64
149
|
with:
|
|
65
|
-
ruby-version:
|
|
150
|
+
ruby-version: .ruby-version
|
|
66
151
|
bundler-cache: true
|
|
67
152
|
|
|
68
153
|
- name: Build gem
|
|
@@ -71,4 +156,4 @@ jobs:
|
|
|
71
156
|
- name: Check gem
|
|
72
157
|
run: |
|
|
73
158
|
gem install *.gem
|
|
74
|
-
gem specification *.gem
|
|
159
|
+
gem specification *.gem
|
|
@@ -8,6 +8,7 @@ on:
|
|
|
8
8
|
jobs:
|
|
9
9
|
release:
|
|
10
10
|
runs-on: ubuntu-latest
|
|
11
|
+
timeout-minutes: 15
|
|
11
12
|
permissions:
|
|
12
13
|
contents: write
|
|
13
14
|
id-token: write
|
|
@@ -18,7 +19,7 @@ jobs:
|
|
|
18
19
|
- name: Set up Ruby
|
|
19
20
|
uses: ruby/setup-ruby@v1
|
|
20
21
|
with:
|
|
21
|
-
ruby-version:
|
|
22
|
+
ruby-version: .ruby-version
|
|
22
23
|
bundler-cache: true
|
|
23
24
|
|
|
24
25
|
- name: Run tests
|
|
@@ -38,12 +39,12 @@ jobs:
|
|
|
38
39
|
env:
|
|
39
40
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
40
41
|
|
|
42
|
+
# Trusted publishing: RubyGems exchanges this job's OIDC token for a
|
|
43
|
+
# short-lived credential, so there is no API key to store or rotate.
|
|
44
|
+
# Requires head_music to have this workflow registered as a trusted
|
|
45
|
+
# publisher at rubygems.org/gems/head_music/trusted_publishers.
|
|
46
|
+
- name: Configure RubyGems credentials
|
|
47
|
+
uses: rubygems/configure-rubygems-credentials@v2
|
|
48
|
+
|
|
41
49
|
- name: Publish to RubyGems
|
|
42
|
-
run:
|
|
43
|
-
mkdir -p $HOME/.gem
|
|
44
|
-
touch $HOME/.gem/credentials
|
|
45
|
-
chmod 0600 $HOME/.gem/credentials
|
|
46
|
-
printf -- "---\n:rubygems_api_key: ${GEM_HOST_API_KEY}\n" > $HOME/.gem/credentials
|
|
47
|
-
gem push *.gem
|
|
48
|
-
env:
|
|
49
|
-
GEM_HOST_API_KEY: ${{ secrets.RUBYGEMS_API_KEY }}
|
|
50
|
+
run: gem push *.gem
|
|
@@ -10,9 +10,17 @@ on:
|
|
|
10
10
|
- cron: '0 9 * * *'
|
|
11
11
|
workflow_dispatch:
|
|
12
12
|
|
|
13
|
+
permissions:
|
|
14
|
+
contents: read
|
|
15
|
+
|
|
16
|
+
concurrency:
|
|
17
|
+
group: ${{ github.workflow }}-${{ github.ref }}
|
|
18
|
+
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
|
19
|
+
|
|
13
20
|
jobs:
|
|
14
21
|
security:
|
|
15
22
|
runs-on: ubuntu-latest
|
|
23
|
+
timeout-minutes: 5
|
|
16
24
|
|
|
17
25
|
steps:
|
|
18
26
|
- uses: actions/checkout@v7
|
|
@@ -20,14 +28,8 @@ jobs:
|
|
|
20
28
|
- name: Set up Ruby
|
|
21
29
|
uses: ruby/setup-ruby@v1
|
|
22
30
|
with:
|
|
23
|
-
ruby-version:
|
|
31
|
+
ruby-version: .ruby-version
|
|
24
32
|
bundler-cache: true
|
|
25
33
|
|
|
26
|
-
- name: Install bundler-audit
|
|
27
|
-
run: gem install bundler-audit
|
|
28
|
-
|
|
29
34
|
- name: Run bundler-audit
|
|
30
|
-
run: bundle-audit check --update
|
|
31
|
-
|
|
32
|
-
- name: Run RuboCop Security
|
|
33
|
-
run: bundle exec rubocop --only Security
|
|
35
|
+
run: bundle exec bundle-audit check --update
|
data/.rubocop.yml
CHANGED
|
@@ -15,6 +15,14 @@ AllCops:
|
|
|
15
15
|
- vendor/**/*
|
|
16
16
|
TargetRubyVersion: 3.3.0
|
|
17
17
|
|
|
18
|
+
# Off in standard's config, so the lint job missed them; the security
|
|
19
|
+
# workflow's "--only Security" pass was the only thing covering them.
|
|
20
|
+
Security/IoMethods:
|
|
21
|
+
Enabled: true
|
|
22
|
+
|
|
23
|
+
Security/MarshalLoad:
|
|
24
|
+
Enabled: true
|
|
25
|
+
|
|
18
26
|
RSpec:
|
|
19
27
|
Enabled: true
|
|
20
28
|
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,21 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
## [20.1.0] - 2026-09-05
|
|
11
|
+
|
|
12
|
+
The other half of the LilyPond export released in 20.0.0. A document written by the writer, or by hand, now reads back into a composition, so `parse(render(composition))` reproduces the music. Nothing in 20.0.0 changed shape; a consumer upgrades by upgrading.
|
|
13
|
+
|
|
14
|
+
### Added
|
|
15
|
+
|
|
16
|
+
- **LilyPond import.** `HeadMusic::Notation::LilyPond.parse(string)` reads a LilyPond document, or a bare music expression, into a `Content::Composition`: absolute and `\relative` pitches with `is`/`es` accidentals and Dutch contractions, durations with dots and carry-over, rests and whole-bar rests, chords, intra-bar ties, `\key`, `\time` (including mid-piece changes), `\clef`, bar checks, `\header` title and composer, and `\new Staff` / `\new Voice` contexts with `instrumentName` as the voice role. Everything the LilyPond writer emits reads back, so `parse(render(composition))` reproduces the music. `\version`, `\layout`, and `\midi` blocks are skipped whole, as are the header fields the reader does not use, so the Scheme in an everyday engraving preamble costs nothing. A bar-check mismatch raises `LilyPond::ParseError`; constructs outside the subset raise `LilyPond::UnsupportedFeatureError` rather than being skipped.
|
|
17
|
+
- `HeadMusic::Notation::DottedDuration.rhythmic_value_for(fraction)`, the inverse of `dotted_unit_fraction`, shared by the ABC and LilyPond readers.
|
|
18
|
+
|
|
19
|
+
### Changed
|
|
20
|
+
|
|
21
|
+
- **Placing a note no longer costs time linear in the voice's length.** `Content::Voice#place` scanned its placements from the front twice — once for a placement already at the position, once for the insertion point — so filling a long voice was quadratic in its length. Both are now binary searches over the position order the list already keeps. Reading a 28KB LilyPond score went from 76 seconds to under 9; the remaining time is in `Content::Position` arithmetic rather than here.
|
|
22
|
+
|
|
8
23
|
## [20.0.0] - 2026-08-30
|
|
9
24
|
|
|
10
25
|
The [style assessment model](https://github.com/roberthead/head_music/tree/main/user-stories/epics/style-assessment-model.md) epic, released together. Five stories reshaped how a guide is declared, how it grades, what it says, and what a consumer asks for — so the breaking changes below are one migration rather than five. Two notation stories ride along: LilyPond export, and note values named in each reader's own language.
|
|
@@ -716,7 +731,8 @@ note = HeadMusic::Rudiment::Note.get("F#4 dotted-quarter")
|
|
|
716
731
|
|
|
717
732
|
For changes in versions prior to 0.28.0, please refer to the git history.
|
|
718
733
|
|
|
719
|
-
[Unreleased]: https://github.com/roberthead/head_music/compare/v20.
|
|
734
|
+
[Unreleased]: https://github.com/roberthead/head_music/compare/v20.1.0...HEAD
|
|
735
|
+
[20.1.0]: https://github.com/roberthead/head_music/compare/v20.0.0...v20.1.0
|
|
720
736
|
[20.0.0]: https://github.com/roberthead/head_music/compare/v19.0.0...v20.0.0
|
|
721
737
|
[19.0.0]: https://github.com/roberthead/head_music/compare/v18.0.0...v19.0.0
|
|
722
738
|
[18.0.0]: https://github.com/roberthead/head_music/compare/v17.5.0...v18.0.0
|
|
@@ -764,4 +780,4 @@ For changes in versions prior to 0.28.0, please refer to the git history.
|
|
|
764
780
|
[2.0.0]: https://github.com/roberthead/head_music/compare/v1.0.0...v2.0.0
|
|
765
781
|
[1.0.0]: https://github.com/roberthead/head_music/compare/v0.29.0...v1.0.0
|
|
766
782
|
[0.29.0]: https://github.com/roberthead/head_music/compare/v0.28.0...v0.29.0
|
|
767
|
-
[0.28.0]: https://github.com/roberthead/head_music/releases/tag/v0.28.0
|
|
783
|
+
[0.28.0]: https://github.com/roberthead/head_music/releases/tag/v0.28.0
|
data/Gemfile
CHANGED
|
@@ -5,10 +5,11 @@ ruby ">= 3.3.0"
|
|
|
5
5
|
# Specify your gem's dependencies in head_music.gemspec
|
|
6
6
|
gemspec
|
|
7
7
|
|
|
8
|
-
# Allow CI to test against specific ActiveSupport versions
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
8
|
+
# Allow CI to test against specific ActiveSupport versions. An unset or empty
|
|
9
|
+
# value leaves the gemspec range alone -- empty is truthy in Ruby, so a matrix
|
|
10
|
+
# cell that sets the key to "" would otherwise resolve against "~> .0".
|
|
11
|
+
activesupport_version = ENV["ACTIVESUPPORT_VERSION"].to_s
|
|
12
|
+
gem "activesupport", "~> #{activesupport_version}.0" unless activesupport_version.empty?
|
|
12
13
|
|
|
13
14
|
gem "standard", require: false
|
|
14
15
|
|
data/Gemfile.lock
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
PATH
|
|
2
2
|
remote: .
|
|
3
3
|
specs:
|
|
4
|
-
head_music (20.
|
|
4
|
+
head_music (20.1.0)
|
|
5
5
|
activesupport (>= 7.0, < 10)
|
|
6
6
|
humanize (>= 2, < 4)
|
|
7
7
|
i18n (~> 1.8)
|
|
@@ -216,7 +216,7 @@ PLATFORMS
|
|
|
216
216
|
x86_64-linux
|
|
217
217
|
|
|
218
218
|
DEPENDENCIES
|
|
219
|
-
bundler-audit
|
|
219
|
+
bundler-audit (~> 0.9, >= 0)
|
|
220
220
|
head_music!
|
|
221
221
|
kramdown
|
|
222
222
|
rake (~> 13.0)
|
|
@@ -228,10 +228,10 @@ DEPENDENCIES
|
|
|
228
228
|
rubycritic
|
|
229
229
|
simplecov (< 1.1)
|
|
230
230
|
standard
|
|
231
|
-
yard
|
|
231
|
+
yard (~> 0.9, >= 0)
|
|
232
232
|
|
|
233
233
|
RUBY VERSION
|
|
234
|
-
|
|
234
|
+
ruby 3.3.0p0
|
|
235
235
|
|
|
236
236
|
BUNDLED WITH
|
|
237
|
-
|
|
237
|
+
4.0.20
|
data/README.md
CHANGED
|
@@ -14,6 +14,7 @@ The **head_music** Ruby gem provides a toolkit for working with Western music th
|
|
|
14
14
|
- **Style Analysis**: Rules for species counterpoint and voice leading
|
|
15
15
|
- **Internationalization**: Support for multiple languages (English, French, German, Italian, Russian, Spanish)
|
|
16
16
|
- **Instrument Modeling**: Extensive database of musical instruments with ranges and properties
|
|
17
|
+
- **Notation Formats**: Read ABC and LilyPond into compositions; write compositions as ABC, LilyPond, and MusicXML
|
|
17
18
|
|
|
18
19
|
## Installation
|
|
19
20
|
|
|
@@ -50,8 +51,21 @@ puts scale.pitches.map(&:to_s) # => ["C4", "D4", "E4", "F4", "G4", "A4", "B4"]
|
|
|
50
51
|
pitches = %w[C4 E4 G4].map { |p| HeadMusic::Rudiment::Pitch.get(p) }
|
|
51
52
|
chord = HeadMusic::Analysis::PitchSet.new(pitches)
|
|
52
53
|
puts chord.major_triad? # => true
|
|
54
|
+
|
|
55
|
+
# Import a LilyPond excerpt as a composition
|
|
56
|
+
composition = HeadMusic::Notation::LilyPond.parse(<<~'LILY')
|
|
57
|
+
\relative c' {
|
|
58
|
+
\key g \major
|
|
59
|
+
\time 4/4
|
|
60
|
+
g8 a b c d c b g |
|
|
61
|
+
}
|
|
62
|
+
LILY
|
|
63
|
+
puts composition.voices.first.pitches.map(&:to_s) # => ["G3", "A3", "B3", "C4", "D4", "C4", "B3", "G3"]
|
|
64
|
+
puts composition.to_lilypond # => a complete LilyPond document
|
|
53
65
|
```
|
|
54
66
|
|
|
67
|
+
The LilyPond reader covers absolute and `\relative` pitches, durations and dots, rests and whole-bar rests, chords, intra-bar ties, `\key`, `\time`, `\clef`, bar checks, `\header` title and composer, and `\new Staff` / `\new Voice` contexts. Constructs outside that subset (tuplets, lyrics, variables, articulations, and so on) raise an `UnsupportedFeatureError` rather than being skipped.
|
|
68
|
+
|
|
55
69
|
## Style Analysis
|
|
56
70
|
|
|
57
71
|
Look up a style guide by key and analyze a voice against it. `Style::Guide.get` returns `nil` for an
|
|
@@ -95,14 +95,21 @@ class HeadMusic::Content::Voice
|
|
|
95
95
|
end
|
|
96
96
|
|
|
97
97
|
def placement_at(position)
|
|
98
|
-
placements.
|
|
98
|
+
candidate = placements.bsearch { |placement| placement.position >= position }
|
|
99
|
+
candidate if candidate&.position == position
|
|
99
100
|
end
|
|
100
101
|
|
|
101
102
|
# Positions are unique within a voice (place merges same-position
|
|
102
|
-
# placements), so insertion order is simply position order.
|
|
103
|
+
# placements), so insertion order is simply position order. Both the
|
|
104
|
+
# lookup and the insertion point are binary searches over that order,
|
|
105
|
+
# which keeps placing a long voice linear in its length rather than
|
|
106
|
+
# quadratic.
|
|
107
|
+
def insertion_index(placement)
|
|
108
|
+
placements.bsearch_index { |existing| existing > placement } || placements.length
|
|
109
|
+
end
|
|
110
|
+
|
|
103
111
|
def insert_into_placements(placement)
|
|
104
|
-
|
|
105
|
-
placements.insert(index, placement)
|
|
112
|
+
placements.insert(insertion_index(placement), placement)
|
|
106
113
|
end
|
|
107
114
|
|
|
108
115
|
def pitches_string
|
|
@@ -3,14 +3,7 @@ module HeadMusic::Notation::ABC
|
|
|
3
3
|
# Converts the tune's unit note length and a per-note multiplier string
|
|
4
4
|
# (e.g. "2", "3/2", "/", "//") into a HeadMusic::Rudiment::RhythmicValue.
|
|
5
5
|
class DurationResolver
|
|
6
|
-
|
|
7
|
-
MAX_FRACTION = Rational(8)
|
|
8
|
-
|
|
9
|
-
# A reduced binary fraction's odd factor determines the dot count:
|
|
10
|
-
# 1 -> plain, 3 -> dotted, 7 -> double-dotted, 15 -> triple-dotted.
|
|
11
|
-
DOTS_BY_ODD_FACTOR = {1 => 0, 3 => 1, 7 => 2, 15 => 3}.freeze
|
|
12
|
-
|
|
13
|
-
UNIT_NAMES_BY_MULTIPLE = {1 => "whole", 2 => "double whole", 4 => "longa", 8 => "maxima"}.freeze
|
|
6
|
+
MAX_FRACTION = HeadMusic::Notation::DottedDuration::MAX_FRACTION
|
|
14
7
|
|
|
15
8
|
MULTIPLIER_PATTERN = %r{\A(\d+)?(?:(/+)(\d+)?)?\z}
|
|
16
9
|
|
|
@@ -25,7 +18,8 @@ module HeadMusic::Notation::ABC
|
|
|
25
18
|
def rhythmic_value(multiplier_string, scale: Rational(1))
|
|
26
19
|
fraction = unit_note_length * multiplier(multiplier_string) * scale
|
|
27
20
|
validate_fraction!(fraction, multiplier_string)
|
|
28
|
-
|
|
21
|
+
HeadMusic::Notation::DottedDuration.rhythmic_value_for(fraction) ||
|
|
22
|
+
raise_error("no rhythmic unit for a note length of #{fraction}", multiplier_string)
|
|
29
23
|
end
|
|
30
24
|
|
|
31
25
|
# The bare fraction a length string denotes (e.g. "2" -> 2, "/2" -> 1/2,
|
|
@@ -61,65 +55,11 @@ module HeadMusic::Notation::ABC
|
|
|
61
55
|
def validate_fraction!(fraction, source)
|
|
62
56
|
raise_error("note length must be positive", source) if fraction <= 0
|
|
63
57
|
raise_error("note length exceeds #{MAX_FRACTION.to_i} whole notes", source) if fraction > MAX_FRACTION
|
|
64
|
-
return if power_of_two?(fraction.denominator)
|
|
58
|
+
return if HeadMusic::Notation::DottedDuration.power_of_two?(fraction.denominator)
|
|
65
59
|
|
|
66
60
|
raise_error("note length #{fraction} is not expressible in binary note values", source)
|
|
67
61
|
end
|
|
68
62
|
|
|
69
|
-
# Fractions whose odd factor is one less than a power of two map onto a
|
|
70
|
-
# single (possibly dotted) note; anything else becomes a chain of tied notes,
|
|
71
|
-
# peeling off the largest dotted-expressible head each pass.
|
|
72
|
-
def build_rhythmic_value(fraction, source)
|
|
73
|
-
dots = DOTS_BY_ODD_FACTOR[odd_factor(fraction.numerator)]
|
|
74
|
-
return single_value(fraction, dots, source) if dots
|
|
75
|
-
|
|
76
|
-
head = greedy_head(fraction)
|
|
77
|
-
tail = build_rhythmic_value(fraction - head, source)
|
|
78
|
-
single_value(head, DOTS_BY_ODD_FACTOR.fetch(odd_factor(head.numerator)), source, tied_value: tail)
|
|
79
|
-
end
|
|
80
|
-
|
|
81
|
-
def single_value(fraction, dots, source, tied_value: nil)
|
|
82
|
-
# A value with d dots spans (2^(d+1) - 1) / 2^d of its unit.
|
|
83
|
-
unit_fraction = fraction * Rational(2**dots, (2**(dots + 1)) - 1)
|
|
84
|
-
HeadMusic::Rudiment::RhythmicValue.new(unit_for(unit_fraction, source), dots: dots, tied_value: tied_value)
|
|
85
|
-
end
|
|
86
|
-
|
|
87
|
-
# The largest leading run of set bits (capped at four, i.e. triple-dotted)
|
|
88
|
-
# forms a dotted-expressible head for the tied-value decomposition.
|
|
89
|
-
def greedy_head(fraction)
|
|
90
|
-
numerator = fraction.numerator
|
|
91
|
-
bits = numerator.bit_length
|
|
92
|
-
run = leading_set_bits(numerator, bits)
|
|
93
|
-
Rational(((1 << run) - 1) << (bits - run), fraction.denominator)
|
|
94
|
-
end
|
|
95
|
-
|
|
96
|
-
# Length of the leading run of set bits, capped at four (triple-dotted).
|
|
97
|
-
def leading_set_bits(numerator, bits)
|
|
98
|
-
run = 0
|
|
99
|
-
run += 1 while run < 4 && run < bits && numerator[bits - 1 - run] == 1
|
|
100
|
-
run
|
|
101
|
-
end
|
|
102
|
-
|
|
103
|
-
def unit_for(unit_fraction, source)
|
|
104
|
-
unit = if unit_fraction >= 1
|
|
105
|
-
name = UNIT_NAMES_BY_MULTIPLE[unit_fraction.numerator]
|
|
106
|
-
name && HeadMusic::Rudiment::RhythmicUnit.get(name)
|
|
107
|
-
else
|
|
108
|
-
HeadMusic::Rudiment::RhythmicUnit.for_denominator_value(unit_fraction.denominator)
|
|
109
|
-
end
|
|
110
|
-
raise_error("no rhythmic unit for a note length of #{unit_fraction}", source) unless unit
|
|
111
|
-
unit
|
|
112
|
-
end
|
|
113
|
-
|
|
114
|
-
def odd_factor(integer)
|
|
115
|
-
integer >>= 1 while integer.even?
|
|
116
|
-
integer
|
|
117
|
-
end
|
|
118
|
-
|
|
119
|
-
def power_of_two?(integer)
|
|
120
|
-
(integer & (integer - 1)).zero?
|
|
121
|
-
end
|
|
122
|
-
|
|
123
63
|
def raise_error(message, source)
|
|
124
64
|
raise HeadMusic::Notation::ABC::ParseError.new(message, snippet: source)
|
|
125
65
|
end
|
|
@@ -146,16 +146,7 @@ module HeadMusic::Notation::ABC
|
|
|
146
146
|
def pending_rhythmic_value(pending)
|
|
147
147
|
own = @duration_resolver.rhythmic_value(pending.length, scale: pending.scale)
|
|
148
148
|
prefix = pending.tied_prefix
|
|
149
|
-
prefix ? append_tied(
|
|
150
|
-
end
|
|
151
|
-
|
|
152
|
-
# Attaches `tail` at the deep end of `head`'s tied chain, rebuilding
|
|
153
|
-
# each link (RhythmicValue exposes no setter) so a chain like
|
|
154
|
-
# "half tied to eighth" gains a further "tied to quarter".
|
|
155
|
-
def append_tied(head, tail)
|
|
156
|
-
tied = head.tied_value
|
|
157
|
-
inner = tied ? append_tied(tied, tail) : tail
|
|
158
|
-
HeadMusic::Rudiment::RhythmicValue.new(head.unit, dots: head.dots, tied_value: inner)
|
|
149
|
+
prefix ? prefix.append_tied(own) : own
|
|
159
150
|
end
|
|
160
151
|
end
|
|
161
152
|
end
|
|
@@ -1,11 +1,21 @@
|
|
|
1
1
|
module HeadMusic
|
|
2
2
|
module Notation
|
|
3
3
|
# The exact fractional length of a (possibly dotted) rhythmic value in
|
|
4
|
-
# terms of its own unit
|
|
4
|
+
# terms of its own unit, and the inverse: the rhythmic value a fraction of
|
|
5
|
+
# a whole note denotes. RhythmicValue's own value methods return Floats,
|
|
5
6
|
# so the fraction is rebuilt here from the unit's integer numerator and
|
|
6
7
|
# denominator to keep downstream arithmetic (ABC multipliers, MusicXML
|
|
7
|
-
# divisions) exact.
|
|
8
|
+
# divisions, LilyPond bar sums) exact.
|
|
8
9
|
module DottedDuration
|
|
10
|
+
# Longest supported duration: a maxima (8 whole notes).
|
|
11
|
+
MAX_FRACTION = Rational(8)
|
|
12
|
+
|
|
13
|
+
# A reduced binary fraction's odd factor determines the dot count:
|
|
14
|
+
# 1 -> plain, 3 -> dotted, 7 -> double-dotted, 15 -> triple-dotted.
|
|
15
|
+
DOTS_BY_ODD_FACTOR = {1 => 0, 3 => 1, 7 => 2, 15 => 3}.freeze
|
|
16
|
+
|
|
17
|
+
UNIT_NAMES_BY_MULTIPLE = {1 => "whole", 2 => "double whole", 4 => "longa", 8 => "maxima"}.freeze
|
|
18
|
+
|
|
9
19
|
module_function
|
|
10
20
|
|
|
11
21
|
def dotted_unit_fraction(rhythmic_value)
|
|
@@ -14,6 +24,75 @@ module HeadMusic
|
|
|
14
24
|
# A value with d dots spans (2^(d+1) - 1) / 2^d of its own unit.
|
|
15
25
|
Rational(unit.numerator, unit.denominator) * Rational((2**(dots + 1)) - 1, 2**dots)
|
|
16
26
|
end
|
|
27
|
+
|
|
28
|
+
# The rhythmic value spanning a fraction of a whole note, or nil when no
|
|
29
|
+
# binary note value (or tied chain of them) can express it.
|
|
30
|
+
def rhythmic_value_for(fraction)
|
|
31
|
+
fraction = Rational(fraction)
|
|
32
|
+
return unless expressible?(fraction)
|
|
33
|
+
|
|
34
|
+
build_rhythmic_value(fraction)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def expressible?(fraction)
|
|
38
|
+
fraction.positive? && fraction <= MAX_FRACTION && power_of_two?(fraction.denominator)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Fractions whose odd factor is one less than a power of two map onto a
|
|
42
|
+
# single (possibly dotted) note; anything else becomes a chain of tied notes,
|
|
43
|
+
# peeling off the largest dotted-expressible head each pass.
|
|
44
|
+
def build_rhythmic_value(fraction)
|
|
45
|
+
dots = DOTS_BY_ODD_FACTOR[odd_factor(fraction.numerator)]
|
|
46
|
+
return single_value(fraction, dots) if dots
|
|
47
|
+
|
|
48
|
+
head = greedy_head(fraction)
|
|
49
|
+
tail = build_rhythmic_value(fraction - head)
|
|
50
|
+
return unless tail
|
|
51
|
+
|
|
52
|
+
single_value(head, DOTS_BY_ODD_FACTOR.fetch(odd_factor(head.numerator)), tied_value: tail)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def single_value(fraction, dots, tied_value: nil)
|
|
56
|
+
# A value with d dots spans (2^(d+1) - 1) / 2^d of its unit.
|
|
57
|
+
unit = unit_for(fraction * Rational(2**dots, (2**(dots + 1)) - 1))
|
|
58
|
+
return unless unit
|
|
59
|
+
|
|
60
|
+
HeadMusic::Rudiment::RhythmicValue.new(unit, dots: dots, tied_value: tied_value)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# The largest leading run of set bits (capped at four, i.e. triple-dotted)
|
|
64
|
+
# forms a dotted-expressible head for the tied-value decomposition.
|
|
65
|
+
def greedy_head(fraction)
|
|
66
|
+
numerator = fraction.numerator
|
|
67
|
+
bits = numerator.bit_length
|
|
68
|
+
run = leading_set_bits(numerator, bits)
|
|
69
|
+
Rational(((1 << run) - 1) << (bits - run), fraction.denominator)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Length of the leading run of set bits, capped at four (triple-dotted).
|
|
73
|
+
def leading_set_bits(numerator, bits)
|
|
74
|
+
run = 0
|
|
75
|
+
run += 1 while run < 4 && run < bits && numerator[bits - 1 - run] == 1
|
|
76
|
+
run
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def unit_for(unit_fraction)
|
|
80
|
+
if unit_fraction >= 1
|
|
81
|
+
name = UNIT_NAMES_BY_MULTIPLE[unit_fraction.numerator]
|
|
82
|
+
name && HeadMusic::Rudiment::RhythmicUnit.get(name)
|
|
83
|
+
else
|
|
84
|
+
HeadMusic::Rudiment::RhythmicUnit.for_denominator_value(unit_fraction.denominator)
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def odd_factor(integer)
|
|
89
|
+
integer >>= 1 while integer.even?
|
|
90
|
+
integer
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def power_of_two?(integer)
|
|
94
|
+
(integer & (integer - 1)).zero?
|
|
95
|
+
end
|
|
17
96
|
end
|
|
18
97
|
end
|
|
19
98
|
end
|