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.
Files changed (44) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +93 -8
  3. data/.github/workflows/release.yml +10 -9
  4. data/.github/workflows/security.yml +10 -8
  5. data/.rubocop.yml +8 -0
  6. data/CHANGELOG.md +18 -2
  7. data/Gemfile +5 -4
  8. data/Gemfile.lock +5 -5
  9. data/README.md +14 -0
  10. data/lib/head_music/content/voice.rb +11 -4
  11. data/lib/head_music/notation/abc/duration_resolver.rb +4 -64
  12. data/lib/head_music/notation/abc/voice_state.rb +1 -10
  13. data/lib/head_music/notation/dotted_duration.rb +81 -2
  14. data/lib/head_music/notation/lily_pond/assignment_reader.rb +65 -0
  15. data/lib/head_music/notation/lily_pond/composition_builder.rb +159 -0
  16. data/lib/head_music/notation/lily_pond/context_reader.rb +85 -0
  17. data/lib/head_music/notation/lily_pond/document.rb +46 -0
  18. data/lib/head_music/notation/lily_pond/document_reader.rb +96 -0
  19. data/lib/head_music/notation/lily_pond/duration_reader.rb +67 -0
  20. data/lib/head_music/notation/lily_pond/key_reader.rb +63 -0
  21. data/lib/head_music/notation/lily_pond/lexer.rb +198 -0
  22. data/lib/head_music/notation/lily_pond/meter_reader.rb +31 -0
  23. data/lib/head_music/notation/lily_pond/music_item_reader.rb +107 -0
  24. data/lib/head_music/notation/lily_pond/music_reader.rb +127 -0
  25. data/lib/head_music/notation/lily_pond/parse_preflight.rb +48 -0
  26. data/lib/head_music/notation/lily_pond/parser.rb +30 -0
  27. data/lib/head_music/notation/lily_pond/pitch_reader.rb +100 -0
  28. data/lib/head_music/notation/lily_pond/pitch_reader_stack.rb +31 -0
  29. data/lib/head_music/notation/lily_pond/render_plan.rb +8 -52
  30. data/lib/head_music/notation/lily_pond/string_text.rb +10 -4
  31. data/lib/head_music/notation/lily_pond/token.rb +20 -0
  32. data/lib/head_music/notation/lily_pond/token_cursor.rb +68 -0
  33. data/lib/head_music/notation/lily_pond/voice_context.rb +42 -0
  34. data/lib/head_music/notation/lily_pond/voice_stream.rb +110 -0
  35. data/lib/head_music/notation/lily_pond.rb +28 -1
  36. data/lib/head_music/notation/music_xml/render_plan.rb +12 -58
  37. data/lib/head_music/notation/render_plan.rb +71 -0
  38. data/lib/head_music/notation.rb +1 -0
  39. data/lib/head_music/rudiment/rhythmic_value.rb +8 -0
  40. data/lib/head_music/version.rb +1 -1
  41. data/user-stories/done/lilypond-interpreter.md +395 -0
  42. data/user-stories/index.html +32 -40
  43. metadata +23 -3
  44. data/user-stories/backlog/lilypond-interpreter.md +0 -77
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e8a5d592c5994e59585e09db272d554d2aefcef0f337287ee0fd0206cbab498c
4
- data.tar.gz: '0964c0d8e7ec6a753886ab34131705daacb51eefffc79f48d281ca6663099a4d'
3
+ metadata.gz: b3714d51858bd368308d14a5f12544e6f4fb9dbecf0f0eacc1ad795c5e4b3d1e
4
+ data.tar.gz: 99345ffa2b44dfbdc27836b7b7350a38af1665770a74490f52e22bd1a0aa3602
5
5
  SHA512:
6
- metadata.gz: dafb4e6534d8985a4563a650e141f61d652b40a76e7adf2d3ce7d8820ce5f41038dc1754f893b18756a2f30192813b8e752899656b6abab5bc5dd073a732abad
7
- data.tar.gz: cdd300bd23ca8bbe011f7712385d7b7d80cebb99eae61281bc24049c01f535069116eff46a46fe296acf0c846f3a7e4a36d59562d5c03cb29a4ed4c7d5930ede
6
+ metadata.gz: d95aa74479fe498564dc7e1bc5427660b9687e286a31a4b5cbbb1c0a274bebe9413ae18e2176dbbf38c351e4b1517275d5bf8dce590642c88c2301ea83f038b6
7
+ data.tar.gz: 4446808775faf30ef42dceca7de10248a88d8c8265ab4faa3c11589bd6ddb7dd60ff9c10ce2cb34a68f9e039061bc3633484e01b2002f649bb3f885bd5d7e968
@@ -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
- ruby-version: ['3.3', '3.4']
16
- activesupport-version: ['7.2', '8.0']
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
- ACTIVESUPPORT_VERSION="${{ matrix.activesupport-version }}" bundle install
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.ruby-version == '3.3' && matrix.activesupport-version == '8.0'
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: '3.3.0'
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: '3.3.0'
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: '3.3.0'
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: '3.3.0'
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.0.0...HEAD
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
- if ENV["ACTIVESUPPORT_VERSION"]
10
- gem "activesupport", "~> #{ENV["ACTIVESUPPORT_VERSION"]}.0"
11
- end
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.0.0)
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
- ruby 3.3.0p0
234
+ ruby 3.3.0p0
235
235
 
236
236
  BUNDLED WITH
237
- 2.4.12
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.find { |placement| placement.position == position }
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
- index = placements.index { |existing| existing > placement } || placements.length
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
- # Longest supported duration: a maxima (8 whole notes).
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
- build_rhythmic_value(fraction, multiplier_string)
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(prefix, own) : own
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. RhythmicValue's own value methods return Floats,
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