head_music 20.0.0 → 21.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/.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 +117 -2
- data/CLAUDE.md +6 -48
- data/Gemfile +5 -4
- data/Gemfile.lock +5 -5
- data/README.md +14 -0
- data/Rakefile +7 -0
- data/bin/guide_grade_corpus.rb +14 -92
- data/bin/guide_grade_table.rb +1 -1
- data/lib/head_music/analysis/harmonic_interval.rb +1 -1
- data/lib/head_music/content/bar.rb +15 -14
- data/lib/head_music/content/cantus_firmus/example.rb +25 -0
- data/lib/head_music/content/comment.rb +7 -7
- data/lib/head_music/content/flow/deserializer.rb +98 -0
- data/lib/head_music/content/flow/hash_deserializer.rb +103 -0
- data/lib/head_music/content/{composition → flow}/schema_values.rb +68 -15
- data/lib/head_music/content/flow/staff_system_values.rb +69 -0
- data/lib/head_music/content/flow/timeline.rb +234 -0
- data/lib/head_music/content/flow/v3_hash_deserializer.rb +52 -0
- data/lib/head_music/content/flow.rb +223 -0
- data/lib/head_music/content/note.rb +1 -1
- data/lib/head_music/content/part.rb +122 -0
- data/lib/head_music/content/placement.rb +3 -3
- data/lib/head_music/content/player.rb +44 -0
- data/lib/head_music/content/position.rb +81 -39
- data/lib/head_music/content/project.rb +110 -0
- data/lib/head_music/content/staff.rb +59 -15
- data/lib/head_music/content/staff_system.rb +49 -0
- data/lib/head_music/content/voice/continuity.rb +4 -4
- data/lib/head_music/content/voice.rb +95 -14
- data/lib/head_music/notation/abc/book_parser.rb +4 -4
- data/lib/head_music/notation/abc/duration_resolver.rb +5 -65
- data/lib/head_music/notation/abc/duration_writer.rb +1 -1
- data/lib/head_music/notation/abc/header.rb +1 -1
- data/lib/head_music/notation/abc/parser.rb +7 -7
- data/lib/head_music/notation/abc/preflight.rb +1 -1
- data/lib/head_music/notation/abc/repeat_tagger.rb +6 -6
- data/lib/head_music/notation/abc/voice_registry.rb +4 -4
- data/lib/head_music/notation/abc/voice_state.rb +1 -10
- data/lib/head_music/notation/abc/writer.rb +23 -25
- data/lib/head_music/notation/abc.rb +7 -7
- data/lib/head_music/notation/clef_selector.rb +5 -5
- 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/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/flow_builder.rb +159 -0
- data/lib/head_music/notation/lily_pond/key_reader.rb +63 -0
- data/lib/head_music/notation/lily_pond/lexer.rb +135 -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/preflight.rb +13 -13
- data/lib/head_music/notation/lily_pond/render_plan.rb +12 -54
- data/lib/head_music/notation/lily_pond/source_scanner.rb +90 -0
- 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/voice_writer.rb +120 -0
- data/lib/head_music/notation/lily_pond/writer.rb +72 -57
- data/lib/head_music/notation/lily_pond.rb +32 -5
- data/lib/head_music/notation/music_xml/attributes_writer.rb +103 -0
- data/lib/head_music/notation/music_xml/divisions.rb +9 -12
- data/lib/head_music/notation/music_xml/key_mapper.rb +12 -5
- data/lib/head_music/notation/music_xml/note_writer.rb +29 -4
- data/lib/head_music/notation/music_xml/preflight.rb +12 -12
- data/lib/head_music/notation/music_xml/render_plan.rb +28 -62
- data/lib/head_music/notation/music_xml/writer.rb +75 -90
- data/lib/head_music/notation/music_xml.rb +5 -5
- data/lib/head_music/notation/preflight_checks.rb +5 -5
- data/lib/head_music/notation/render_plan.rb +74 -0
- data/lib/head_music/notation.rb +1 -0
- data/lib/head_music/rudiment/clefs.yml +0 -1
- data/lib/head_music/rudiment/key.rb +28 -0
- data/lib/head_music/rudiment/note.rb +2 -2
- data/lib/head_music/rudiment/rhythmic_value.rb +8 -0
- data/lib/head_music/rudiment/unpitched_note.rb +1 -1
- data/lib/head_music/style/guideline/voice_context.rb +2 -2
- data/lib/head_music/style/guideline.rb +4 -4
- data/lib/head_music/style/guidelines/first_bar_entry.rb +1 -1
- data/lib/head_music/style/guidelines/note_fills_final_bar.rb +1 -1
- data/lib/head_music/time/conductor.rb +0 -3
- data/lib/head_music/time/event_map.rb +172 -0
- data/lib/head_music/time/key_signature_event.rb +111 -0
- data/lib/head_music/time/meter_event.rb +1 -1
- data/lib/head_music/time/meter_map.rb +20 -34
- data/lib/head_music/time/musical_position.rb +33 -46
- data/lib/head_music/time/musical_time_converter.rb +4 -4
- data/lib/head_music/time/radix_carry.rb +8 -3
- data/lib/head_music/time/tempo_map.rb +20 -49
- data/lib/head_music/time.rb +7 -3
- data/lib/head_music/version.rb +1 -1
- data/lib/head_music.rb +12 -4
- data/references/wemi.md +102 -0
- data/user-stories/backlog/ensemble-sessions.md +82 -0
- data/user-stories/backlog/identity-and-presentation.md +148 -0
- data/user-stories/done/content-architecture.md +834 -0
- data/user-stories/done/lilypond-interpreter.md +395 -0
- data/user-stories/epics/organizing-content.md +137 -0
- data/user-stories/index.html +34 -34
- metadata +44 -8
- data/lib/head_music/content/composition/hash_deserializer.rb +0 -113
- data/lib/head_music/content/composition.rb +0 -149
- data/lib/head_music/time/event_map_support.rb +0 -29
- data/user-stories/backlog/lilypond-interpreter.md +0 -77
- data/user-stories/backlog/organizing-content.md +0 -83
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3adeb7e3fa4f5422977916729f285e9ebb50309409927b25b284dbbb35d2932d
|
|
4
|
+
data.tar.gz: 78f4b8b6922142b56e185cc23da551576ba3ffd375d51eb6aa34f3382e30530f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 444f66354a57746f2c9acf747462bf3f2396f76b8b9c7fe16d4c2bc93020bbe178349f437d7135141e587ffb9fef09c57bdf6a9145159b47a88af908960b608b
|
|
7
|
+
data.tar.gz: 4c38611fa5aea3b2530310dc33644ab29af9c81c523eb37e39fe94298d79c71ee982791e1278c449cd4d91c99623e21f1a2f3c9f4882e648c8b1783f6ff94a7c
|
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,119 @@ 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
|
+
## [21.0.0] - 2026-09-06
|
|
11
|
+
|
|
12
|
+
The [organizing content](https://github.com/roberthead/head_music/tree/main/user-stories/epics/organizing-content.md) epic's first story. `Content::Composition` was the document, the movement, the timeline, and the credits at once, and its `Voice` was a bare melodic line with no instrument, no staff, and no performer — a shape adequate for two-voice species counterpoint and for almost nothing else. Content is now `Project` → `Flow` → `Part` → `Voice` → `Placement`, and a voice can cross between the staves of its part.
|
|
13
|
+
|
|
14
|
+
This is a breaking release. `Composition` is removed rather than deprecated, and the serialization schema goes to 4.
|
|
15
|
+
|
|
16
|
+
**Migrating from 20.1.0**, in the order a consumer will hit them:
|
|
17
|
+
|
|
18
|
+
1. **`HeadMusic::Content::Composition` is `HeadMusic::Content::Flow`.** The constant is gone, not aliased. `Flow.new` takes the same keyword arguments, and `#add_voice`, `#voices`, `#bars`, `#to_h`, `#to_abc`, `#to_lilypond`, and `#to_musicxml` all behave as they did, so most call sites need only the constant renamed. `#add_voice` now mints a `Part` per voice behind the scenes; every document this gem produced before still renders byte-identically.
|
|
19
|
+
|
|
20
|
+
2. **`LilyPond.parse` and `ABC.parse` return a `Flow`.** So do `ABC::BookParser#flows` (was `#compositions`) and `LilyPond::FlowBuilder` (was `CompositionBuilder`). These changed return type five days after the LilyPond reader shipped in 20.1.0; they are named individually here because a reader scanning for "Composition" will not otherwise notice that `parse` moved.
|
|
21
|
+
|
|
22
|
+
3. **Persisted schema-3 documents need one read-and-re-save.** `Flow.from_h` rejects a v3 hash with an error naming `Flow.from_v3_h`, which is retained read-only for this reason and removed in 22.0.0. The previous bumps shipped a key-rename recipe — v2 to v3 was "rename each placement's `pitches` key to `sounds`", doable in SQL against a jsonb column — but v3 to v4 restructures the container, so no equivalent recipe can be written. **20.1.0 is the last version that reads v3 directly.**
|
|
23
|
+
|
|
24
|
+
4. **`HeadMusic::Content::Staff` is a different class under the same name.** The 20.x `Content::Staff` was dead code, referenced by nothing but its own spec, and has been deleted; the constant is now the instance-layer staff described below. The new one takes only keyword arguments, so `Content::Staff.new(:bass_clef)` raises `ArgumentError` rather than quietly reading the clef as something else — but the readers changed too: `#default_clef` is `#clef` (and answers `nil` where none was authored, rather than falling back to treble), and `#instrument` is `#instruments_staff`, which references the catalog staff instead of an instrument.
|
|
25
|
+
|
|
26
|
+
5. **`Time::MusicalPosition#beat` is `#count`**, and `FIRST_BEAT` is `FIRST_COUNT`. `Meter` already distinguished the two — 6/8 has two beats and six counts — so a position was misnamed against the gem's own vocabulary.
|
|
27
|
+
|
|
28
|
+
6. **Grades do not move.** Every guide assesses every voice of the pinned corpus to the fitness it produced before the refactor began, which is asserted rather than assumed. Stored fitness from 20.x remains comparable.
|
|
29
|
+
|
|
30
|
+
### Added
|
|
31
|
+
|
|
32
|
+
- **`HeadMusic::Content::Project`, `Flow`, `Part`, and `Player`.** A `Project` is the document: players and flows. A `Flow` is a continuous span of music owning its own timeline — a movement, a song, a cue, an exercise. A `Player` is a chair in the project ("Flute 1", "Piano"); a `Part` is that chair's music within one flow. Pairing them that way is what makes "the flute plays in movements 1 and 3" expressible without a nullable join — there is simply no `Part` for that player in movement 2 — and what makes an instrument change *within* a part honest, since conceptually it is still the same player.
|
|
33
|
+
|
|
34
|
+
**Containment is total; context is optional.** A voice is always in a part, always in a flow, so `voice.part.staff_system_at(bar)` never needs a nil check. What is optional is the upward reference: `Flow#project` and `Part#player` may be absent. A flow with no project is how a chunk of music lives outside a document — a cantus firmus, a scale, a parsed snippet — and it renders to ABC, LilyPond, and MusicXML with no project at all. `Project#add_flow` adopts such a flow, minting a player for each part that has none.
|
|
35
|
+
|
|
36
|
+
- **Voices orthogonal to staves.** A voice belongs to a part and *has* a staff at any given moment, so a piano voice can start in the bass staff and cross into the treble without leaving its part:
|
|
37
|
+
|
|
38
|
+
```ruby
|
|
39
|
+
left_hand.cross_to(treble_staff, from: 5)
|
|
40
|
+
left_hand.cross_to(bass_staff, from: 9)
|
|
41
|
+
left_hand.staff_at(6) # => the treble staff
|
|
42
|
+
left_hand.staff_at(9) # => back to the bass staff
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
A crossing is one event, not a span: a left hand that rises for four bars and comes back down is two crossings, each authored where it happens, and a single cross-staff note is a crossing and, a bar later, another. There is no note-level special case, and nothing to overlap.
|
|
46
|
+
|
|
47
|
+
MusicXML renders the part as one `<score-part>` with `<staves>`, a numbered `<clef>` per staff, `<voice>` per voice separated by `<backup>`, and `<staff>` per note. LilyPond renders a `\new PianoStaff` (or `\new StaffGroup` for a bracket) with one named `\new Staff` per staff and `\change Staff` at the span boundaries. Both elements are omitted for a one-voice, one-staff part, so existing output is unchanged.
|
|
48
|
+
|
|
49
|
+
- **`HeadMusic::Content::Staff` and `StaffSystem`** — the instance layer. A content staff has a line count, a clef map, and an optional reference to an `Instruments::Staff` for percussion mapping; the catalog class already owns the position-to-instrument mappings, so the instance layer references rather than re-implements it. A `StaffSystem` is an ordered set of staves with a brace, a bracket, or neither; `StaffSystem.grand_staff` and `.single_staff` are the two shapes almost everything uses.
|
|
50
|
+
|
|
51
|
+
- **`HeadMusic::Time::EventMap`** — an ordered list of `(position, value)` events answering what is in force at a position. Everything that changes partway through a flow is this shape, and it is now written once: meter, tempo, and key signature on `Flow::Timeline`; instrument and staff system on `Part`; clef on `Staff`; staff assignment on `Voice`. Lookup is a binary search over tuples computed at insert. `#change_at` answers whether a change *starts* at a position, as distinct from what is in force there, which is what a writer needs in order to decide whether to print a signature.
|
|
52
|
+
|
|
53
|
+
- **`HeadMusic::Time::KeySignatureEvent`**, carrying a signature as fifths and, optionally, the interpretation of it. Neither derives the other. A signature underdetermines its interpretation — two sharps is D major, B minor, E dorian, or A mixolydian — and an interpretation does not fix its signature either, because the two legitimately diverge: C dorian written in cantus mollis takes the parallel minor's three flats and naturalizes the sixth.
|
|
54
|
+
|
|
55
|
+
Fifths rather than a `KeySignature` because a `KeySignature` cannot be built from a bare signature: `KeySignature.get("3 flats")` raises, so naming three flats means naming an interpretation of it, after which the stored tonic and quality are wrong whenever the interpretation disagrees. Fifths is also exactly what MusicXML stores.
|
|
56
|
+
|
|
57
|
+
- **`HeadMusic::Rudiment::Key.for_fifths(n)`** — the conventional reading of a signature that carries no interpretation of its own, for the two consumers that cannot proceed without a tonic: LilyPond's `\key`, and the `Diatonic` guideline. Signatures themselves are unbounded, since a theoretical key such as G♯ major counts each double accidental twice and reaches eight; the table stops at ±7, because past that there is no conventional major key to name. So past ±7 a `tonal_context` is required, and `change_key_signature` raises at authoring time rather than leaving every reader that needs a tonic to raise later.
|
|
58
|
+
|
|
59
|
+
- **`HeadMusic::Content::CantusFirmus::Example#to_flow(rhythmic_value:, meter:)`.** An example was a catalog datum — a pitch list with a mode and a citation — that nothing in the gem turned into music. It now realizes as a standalone flow with one part, no player, and one note per bar. Rhythm and meter are the realization's choice rather than the datum's, so they are parameters.
|
|
60
|
+
|
|
61
|
+
- **`Project#to_h` / `.from_h` / `#to_json` / `.from_json`**, and `Flow.from_v3_h`. Schema 4 round-trips players, flows, parts, voices, staff assignments, instrument changes, staff systems and their changes, clef changes, tempo and tempo changes, subtick-precise positions, and repeat structure, and round-trips a standalone flow as its own document. A tempo serializes as `{"beat_value", "beats_per_minute"}` rather than as a `"quarter = 72"` string, because `Tempo.get` reads the number by stripping non-digits and would turn 72.5 into 725.
|
|
62
|
+
- **`Flow.new(tempo:)`, `Flow#tempo`, and `Flow#change_tempo`**, alongside the meter and key signature equivalents. A tempo change allocates its bar the way a meter change does, and accepts a `Tempo`, a tempo name, or a `"quarter = 96"` string.
|
|
63
|
+
- **`Flow#remove_meter_change`, `#remove_key_signature_change`, and `#remove_tempo_change`** un-author a change, answering the removed value. 20.x cleared a change by passing `nil` to `change_*`; that now raises an `ArgumentError` naming the remover, rather than reaching a rudiment getter that would raise something unrelated.
|
|
64
|
+
|
|
65
|
+
- `Flow#position`, `Content::Position#subtick`, `Voice#assign_staff`, `Part#instrument_at` / `#staff_system_at` / `#instruments`, `Player#instruments` / `#primary_instrument` (derived from the parts, so they cannot drift from the instrument changes authored on them).
|
|
66
|
+
|
|
67
|
+
### Changed
|
|
68
|
+
|
|
69
|
+
- **Schema version 4.** `Flow#to_h` carries a `timeline` (opening meter and key signature, plus the changes to each) and `parts` (each with its instrument, instrument changes, staff system, and voices). Key and meter changes are no longer serialized per bar; a bar's own state is its repeat structure.
|
|
70
|
+
|
|
71
|
+
- **`Content::Bar` keeps only what is bar-shaped** — barlines, repeat structure, volta brackets. Its `#key_signature` and `#meter` are now derived reads of the change *authored in that bar*, and the writers `#key_signature=` and `#meter=` are gone; use `Flow#change_key_signature` and `#change_meter`. The bar had been carrying these in parallel with `Time::MeterMap` doing the same job properly and unused by `Content`.
|
|
72
|
+
|
|
73
|
+
- **`Clef.get(:tenor_clef)` is the C clef on the fourth line**, as in every theory text. It had been an alias of `vocal_tenor_clef`, the octave-down G clef of vocal scores, which won the lookup by appearing first in the catalog; that clef keeps its own name and its `tenor_g_clef` alias. `tenor_c_clef` still names the C clef too.
|
|
74
|
+
- **Meter and key signature changes take a bar number, and reject anything else.** Both are bar-aligned by definition, so `flow.change_meter("2:3", "3/4")` raises rather than rounding into a bar.
|
|
75
|
+
|
|
76
|
+
- **`Content::Position` wraps a `Time::MusicalPosition`.** It gains a subtick, and is normalized once at construction and then frozen — it is a sort key that `Voice#place` binary-searches over, and a mutable sort key is a wrong-note bug that raises nothing. `#eql?` and `#hash` are defined on the component tuple alongside `#<=>`; the flow deliberately takes no part in comparison, preserving the behavior `Voice#placement_at` has always guarded with. `#values` is removed in favor of `#to_a`, which now has four components. `#code` emits a subtick only when there is one, so the everyday `"bar:count:tick"` form is unchanged.
|
|
77
|
+
|
|
78
|
+
- **`Notation::RenderPlan#key_value` receives a key signature event** rather than a key signature, because the two formats want different things from it. `MusicXML::KeyMapper.mode` now takes a tonal context and returns `nil` where there is none, and the writer omits `<mode>` rather than inventing a major. LilyPond renders what is printed at the clef: the interpretation where it agrees with the signature, so a D dorian flow still prints `\key d \dorian`, and the signature where they diverge, so cantus mollis prints `\key c \minor`.
|
|
79
|
+
|
|
80
|
+
- **`Notation::ClefSelector` is demoted to a fallback.** When a staff has an authored clef the writers use it; the selector infers one from a voice's pitch range only for a part whose staves were never authored — an ABC import, a bare counterpoint exercise. The fallback stays in the writers rather than moving onto `Staff`, because it reads a *voice's* range and a staff has no back-reference to one.
|
|
81
|
+
|
|
82
|
+
- `Time::PPQN` is an alias of `Rudiment::Rhythm::PPQN` rather than a second literal 960.
|
|
83
|
+
|
|
84
|
+
### Removed
|
|
85
|
+
|
|
86
|
+
- `HeadMusic::Content::Composition`, and the dead `HeadMusic::Content::Staff` (see migration note 4).
|
|
87
|
+
- `HeadMusic::Time::EventMapSupport`, superseded by `Time::EventMap`.
|
|
88
|
+
- `Time::MusicalPosition#to_total_subticks` and its `#to_i` alias, `TempoMap#normalize_position`, and `TempoMap#meter=` — all of which existed to support the comparator fixed below.
|
|
89
|
+
|
|
90
|
+
### Fixed
|
|
91
|
+
|
|
92
|
+
- **`Time::MusicalPosition#normalize!` destroyed the last count of every bar.** `RadixCarry#carry` used `divmod`, which is correct for the 0-indexed tick and subtick and wrong for the 1-indexed count: in 3/4, `1:3:0:0` normalized to the invalid `2:0:0:0`, and so did `1:4:0:0` in 4/4 and `1:6:0:0` in 6/8. A 1-indexed component is now shifted into 0-indexed space and back.
|
|
93
|
+
|
|
94
|
+
- **`Time::MusicalPosition#<=>` was not a total order across a meter change.** It converted both positions to elapsed subticks through a single stored meter, assuming every prior bar had it, so a position in bar 4 of 4/4 compared *greater* than one in bar 5 of 7/8. Positions now compare their component tuple lexically, which needs no meter at all.
|
|
95
|
+
|
|
96
|
+
Both bugs were latent in 20.x only because `Time` was unused by `Content`. They are on the path every note travels now.
|
|
97
|
+
|
|
98
|
+
- **A `Content::Position` rolled across a meter change kept the origin bar's count unit.** The tick carry ran under the meter of the bar the position started in, so a quarter after `1:4:480` in 4/4 landed in a 6/8 bar spelled `2:1:480`, where the same instant is `2:2:000`; the two compared unequal, so a placement rolled into the bar and one authored there did not merge. A position is now carried again under the destination bar's meter until it lands in a bar it was carried under.
|
|
99
|
+
|
|
100
|
+
- **MusicXML `<backup>` rewound a whole measure regardless of what the preceding voice wrote.** In a multi-voice part whose earlier voice ended mid-bar, the cursor went negative -- invalid MusicXML, with no error. It now rewinds by the duration actually written. Whole-measure filler rests also carry `<voice>` and `<staff>`, where before a reader stacked them onto voice 1 of staff 1 and left a grand staff's bass staff empty.
|
|
101
|
+
|
|
102
|
+
- **Every clef the gem knows renders to LilyPond** by its LilyPond name -- alto, tenor, soprano, mezzosoprano, baritone, varbaritone, french, subbass, percussion, and the quoted octave clefs `"treble_8"` and `"treble^8"` -- where an authored clef other than bass had rendered as treble.
|
|
103
|
+
|
|
104
|
+
- **A part with no voices renders**, in both writers, as a staff of whole-measure rests under its clef, key, and time, so a tacet chair keeps its line in the score. LilyPond had dropped the part; MusicXML had raised `NoMethodError`.
|
|
105
|
+
|
|
106
|
+
- **A one-staff part holding several voices renders in LilyPond as one staff** with a `\new Voice` per voice in parallel, named for its part, as MusicXML already rendered it. It had rendered as one staff per voice.
|
|
107
|
+
|
|
108
|
+
## [20.1.0] - 2026-09-05
|
|
109
|
+
|
|
110
|
+
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.
|
|
111
|
+
|
|
112
|
+
### Added
|
|
113
|
+
|
|
114
|
+
- **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.
|
|
115
|
+
- `HeadMusic::Notation::DottedDuration.rhythmic_value_for(fraction)`, the inverse of `dotted_unit_fraction`, shared by the ABC and LilyPond readers.
|
|
116
|
+
|
|
117
|
+
### Changed
|
|
118
|
+
|
|
119
|
+
- **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.
|
|
120
|
+
|
|
8
121
|
## [20.0.0] - 2026-08-30
|
|
9
122
|
|
|
10
123
|
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 +829,9 @@ note = HeadMusic::Rudiment::Note.get("F#4 dotted-quarter")
|
|
|
716
829
|
|
|
717
830
|
For changes in versions prior to 0.28.0, please refer to the git history.
|
|
718
831
|
|
|
719
|
-
[Unreleased]: https://github.com/roberthead/head_music/compare/
|
|
832
|
+
[Unreleased]: https://github.com/roberthead/head_music/compare/v21.0.0...HEAD
|
|
833
|
+
[21.0.0]: https://github.com/roberthead/head_music/compare/v20.1.0...v21.0.0
|
|
834
|
+
[20.1.0]: https://github.com/roberthead/head_music/compare/v20.0.0...v20.1.0
|
|
720
835
|
[20.0.0]: https://github.com/roberthead/head_music/compare/v19.0.0...v20.0.0
|
|
721
836
|
[19.0.0]: https://github.com/roberthead/head_music/compare/v18.0.0...v19.0.0
|
|
722
837
|
[18.0.0]: https://github.com/roberthead/head_music/compare/v17.5.0...v18.0.0
|
|
@@ -764,4 +879,4 @@ For changes in versions prior to 0.28.0, please refer to the git history.
|
|
|
764
879
|
[2.0.0]: https://github.com/roberthead/head_music/compare/v1.0.0...v2.0.0
|
|
765
880
|
[1.0.0]: https://github.com/roberthead/head_music/compare/v0.29.0...v1.0.0
|
|
766
881
|
[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
|
|
882
|
+
[0.28.0]: https://github.com/roberthead/head_music/releases/tag/v0.28.0
|
data/CLAUDE.md
CHANGED
|
@@ -10,42 +10,18 @@ HeadMusic is a Ruby gem for Western music theory. It provides a comprehensive to
|
|
|
10
10
|
|
|
11
11
|
### Essential Commands
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
# Install dependencies
|
|
15
|
-
bin/setup
|
|
16
|
-
|
|
17
|
-
# Run tests with coverage
|
|
18
|
-
bundle exec rake
|
|
19
|
-
|
|
20
|
-
# Run tests without coverage
|
|
21
|
-
bundle exec rspec
|
|
22
|
-
|
|
23
|
-
# Run a specific test file
|
|
24
|
-
bundle exec rspec spec/head_music/rudiments/pitch_spec.rb
|
|
25
|
-
|
|
26
|
-
# Run linting
|
|
27
|
-
bundle exec rubocop
|
|
28
|
-
|
|
29
|
-
# Run all validation checks (tests, linting, security)
|
|
30
|
-
bundle exec rake validate
|
|
31
|
-
|
|
32
|
-
# Open interactive console with gem loaded
|
|
33
|
-
bin/console
|
|
34
|
-
# or
|
|
35
|
-
bundle exec rake console
|
|
13
|
+
`rake -T` lists the custom tasks. Two distinctions it does not make obvious:
|
|
36
14
|
|
|
37
|
-
|
|
38
|
-
bundle exec rake
|
|
39
|
-
|
|
40
|
-
# Check documentation coverage
|
|
41
|
-
bundle exec rake doc_stats
|
|
15
|
+
```bash
|
|
16
|
+
bundle exec rake # tests WITH coverage (the default task)
|
|
17
|
+
bundle exec rspec # tests WITHOUT coverage
|
|
42
18
|
```
|
|
43
19
|
|
|
44
20
|
### Git Etiquette
|
|
45
21
|
|
|
46
22
|
**IMPORTANT: Do not make a commit unless I explicitly ask you to.** Wait for explicit instruction before running `git commit`.
|
|
47
23
|
|
|
48
|
-
When composing git commit messages, follow best-practices.
|
|
24
|
+
When composing git commit messages, follow best-practices. Describe the change itself — do not narrate the assistant's process in the message body. Attribution trailers (`Co-Authored-By`, `Claude-Session`) are added when the session's attribution setting asks for them.
|
|
49
25
|
|
|
50
26
|
This project uses a rebase flow and `main` as the mainline branch.
|
|
51
27
|
|
|
@@ -130,9 +106,7 @@ The codebase follows a domain-driven design with clear module boundaries:
|
|
|
130
106
|
|
|
131
107
|
### Entry Points
|
|
132
108
|
|
|
133
|
-
|
|
134
|
-
- Module loading order is important and defined in the main file
|
|
135
|
-
- Constants like GOLDEN_RATIO are defined at the top level
|
|
109
|
+
Module loading order matters and is defined in `lib/head_music.rb`.
|
|
136
110
|
|
|
137
111
|
## Important Implementation Details
|
|
138
112
|
|
|
@@ -186,22 +160,6 @@ This project deliberately deprioritizes formal documentation in favor of clear,
|
|
|
186
160
|
- Prefer delegation over inheritance
|
|
187
161
|
- Always run `bundle exec rubocop -a` after editing ruby code
|
|
188
162
|
|
|
189
|
-
## Common Development Tasks
|
|
190
|
-
|
|
191
|
-
### Adding a New Musical Concept
|
|
192
|
-
|
|
193
|
-
1. Create the class in the appropriate module
|
|
194
|
-
2. Include `HeadMusic::Named` if it needs internationalization
|
|
195
|
-
3. Add factory method `.get()` if appropriate
|
|
196
|
-
4. Create corresponding spec file
|
|
197
|
-
5. Add translations to locale files if using Named
|
|
198
|
-
|
|
199
|
-
### Modifying Existing Classes
|
|
200
|
-
|
|
201
|
-
1. Check for dependent classes that might be affected
|
|
202
|
-
2. Run tests for the specific module: `bundle exec rspec spec/head_music/[module_name]`
|
|
203
|
-
3. Ensure translations are updated if names change
|
|
204
|
-
|
|
205
163
|
## Reference Documents
|
|
206
164
|
|
|
207
165
|
Domain reference materials live in `references/`.
|
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 (
|
|
4
|
+
head_music (21.0.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
|
data/Rakefile
CHANGED
|
@@ -67,4 +67,11 @@ namespace :style do
|
|
|
67
67
|
File.write(path, snapshot.to_yaml)
|
|
68
68
|
puts "Wrote #{snapshot.values.sum(&:size)} strings to #{path}"
|
|
69
69
|
end
|
|
70
|
+
|
|
71
|
+
desc "Regenerate the pinned corpus grading (spec/fixtures/style/corpus_fitness.json)"
|
|
72
|
+
task :snapshot_corpus_fitness do
|
|
73
|
+
path = File.expand_path("spec/fixtures/style/corpus_fitness.json", __dir__)
|
|
74
|
+
sh "bundle exec ruby bin/guide_grade_corpus.rb #{path}"
|
|
75
|
+
puts "Wrote #{path}"
|
|
76
|
+
end
|
|
70
77
|
end
|