head_music 17.4.0 → 18.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 +3 -3
- data/.github/workflows/release.yml +1 -1
- data/.github/workflows/security.yml +2 -1
- data/.rubocop.yml +6 -0
- data/CHANGELOG.md +34 -0
- data/CLAUDE.md +20 -0
- data/Gemfile.lock +68 -68
- data/head_music.gemspec +1 -1
- data/lib/head_music/analysis/pitch_collection.rb +2 -2
- data/lib/head_music/content/composition/hash_deserializer.rb +3 -0
- data/lib/head_music/content/composition/schema_values.rb +40 -0
- data/lib/head_music/content/placement.rb +30 -1
- data/lib/head_music/content/staff.rb +3 -5
- data/lib/head_music/content/syllable.rb +38 -0
- data/lib/head_music/content/voice.rb +1 -1
- data/lib/head_music/instruments/instrument_name.rb +1 -1
- data/lib/head_music/notation/abc/key_mapper.rb +24 -4
- data/lib/head_music/notation/music_xml/beam_grouper.rb +2 -2
- data/lib/head_music/notation/music_xml/duration_writer.rb +1 -1
- data/lib/head_music/notation/music_xml/writer.rb +46 -0
- data/lib/head_music/rudiment/alteration.rb +20 -5
- data/lib/head_music/rudiment/alterations.yml +6 -0
- data/lib/head_music/rudiment/key_signature.rb +1 -1
- data/lib/head_music/rudiment/note.rb +6 -2
- data/lib/head_music/rudiment/scale.rb +3 -5
- data/lib/head_music/style/guidelines/notes_same_length.rb +1 -1
- data/lib/head_music/time/musical_position.rb +1 -13
- data/lib/head_music/time/radix_carry.rb +22 -0
- data/lib/head_music/time/smpte_timecode.rb +1 -13
- data/lib/head_music/time.rb +1 -0
- data/lib/head_music/utilities/accidentals.rb +137 -0
- data/lib/head_music/utilities/hash_key.rb +44 -29
- data/lib/head_music/version.rb +1 -1
- data/lib/head_music.rb +10 -8
- data/user-stories/done/accidental-string-normalization.md +552 -0
- data/user-stories/done/lyrics.md +162 -0
- data/user-stories/index.html +8 -0
- metadata +17 -6
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8a16868734e2fac408461d2202fcbc84be2a796a68371a81a7d2ad5bbeef516b
|
|
4
|
+
data.tar.gz: 02becc93275629796177a72894d81cc10feca0819847496e60aa3ddc6f5aac00
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e3533678a1a2b58558080cad6c03f04fe4b5b205d4413456b728c5ae2e1a598e3a09ebc0329c9f6fde263e84f857eb4571918087afe23a083278facaea7aa460
|
|
7
|
+
data.tar.gz: 3d605bff3a0459e56cddd4911e7699d2b295bf337e7f468094d0f4cc19e55f9b3036c21e59c95263f3bf19c69dfffa99093a8564f036aa625b776199e959e8f4
|
data/.github/workflows/ci.yml
CHANGED
|
@@ -16,7 +16,7 @@ jobs:
|
|
|
16
16
|
activesupport-version: ['7.2', '8.0']
|
|
17
17
|
|
|
18
18
|
steps:
|
|
19
|
-
- uses: actions/checkout@
|
|
19
|
+
- uses: actions/checkout@v5
|
|
20
20
|
|
|
21
21
|
- name: Set up Ruby ${{ matrix.ruby-version }}
|
|
22
22
|
uses: ruby/setup-ruby@v1
|
|
@@ -43,7 +43,7 @@ jobs:
|
|
|
43
43
|
lint:
|
|
44
44
|
runs-on: ubuntu-latest
|
|
45
45
|
steps:
|
|
46
|
-
- uses: actions/checkout@
|
|
46
|
+
- uses: actions/checkout@v5
|
|
47
47
|
|
|
48
48
|
- name: Set up Ruby
|
|
49
49
|
uses: ruby/setup-ruby@v1
|
|
@@ -57,7 +57,7 @@ jobs:
|
|
|
57
57
|
build:
|
|
58
58
|
runs-on: ubuntu-latest
|
|
59
59
|
steps:
|
|
60
|
-
- uses: actions/checkout@
|
|
60
|
+
- uses: actions/checkout@v5
|
|
61
61
|
|
|
62
62
|
- name: Set up Ruby
|
|
63
63
|
uses: ruby/setup-ruby@v1
|
|
@@ -8,13 +8,14 @@ on:
|
|
|
8
8
|
schedule:
|
|
9
9
|
# Run security checks daily at 9 AM UTC
|
|
10
10
|
- cron: '0 9 * * *'
|
|
11
|
+
workflow_dispatch:
|
|
11
12
|
|
|
12
13
|
jobs:
|
|
13
14
|
security:
|
|
14
15
|
runs-on: ubuntu-latest
|
|
15
16
|
|
|
16
17
|
steps:
|
|
17
|
-
- uses: actions/checkout@
|
|
18
|
+
- uses: actions/checkout@v5
|
|
18
19
|
|
|
19
20
|
- name: Set up Ruby
|
|
20
21
|
uses: ruby/setup-ruby@v1
|
data/.rubocop.yml
CHANGED
|
@@ -31,6 +31,12 @@ RSpec/ContextWording:
|
|
|
31
31
|
RSpec/MultipleExpectations:
|
|
32
32
|
Enabled: false
|
|
33
33
|
|
|
34
|
+
# Several data-driven specs build examples from collections resolved at
|
|
35
|
+
# definition time (e.g. `legacy.each`, `melodic_guides.each`), which requires
|
|
36
|
+
# those values to live as locals in the example-group scope.
|
|
37
|
+
RSpec/LeakyLocalVariable:
|
|
38
|
+
Enabled: false
|
|
39
|
+
|
|
34
40
|
RSpec/NestedGroups:
|
|
35
41
|
Max: 5
|
|
36
42
|
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,40 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [18.0.0] - 2026-07-27
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- `HeadMusic::Utilities::Accidentals` converts accidentals in a string between ASCII spellings and canonical unicode glyphs, replacing the hand-rolled `gsub` and `tr` chains that were scattered across the gem and disagreed with each other. `.to_unicode` converts whole chord symbols rather than bare pitch names — `Bbmaj7#11` becomes `B♭maj7♯11` — and `.to_ascii` converts back, emitting the spellings the gem's own parsers accept (`x` for a double sharp, never `##`). Both directions are idempotent, and conversion is all-or-nothing per accidental so a double never half-converts.
|
|
15
|
+
- The forward direction is safe to run over prose. Conversion happens only inside a candidate chord token, an uppercase letter name that does not continue a longer word or number, so `Above`, `Bebop`, `1.0b2`, and `0x1B2F` are untouched while `Bb`, `Ab7`, `Bbm`, and `Ebmaj7` convert. A chord-quality allowlist rescues `Bbm` and `Ebmaj7` from the word-boundary guard that protects `Above`; the allowlist was measured against a full system word list and is pinned by a spec that sweeps it.
|
|
16
|
+
- `##` is now accepted everywhere `bb` already was, ending the asymmetry between the two doubles. `Alteration.get("##")` resolves to `double_sharp` and `Spelling.get("C##")` returns `C𝄪`.
|
|
17
|
+
- The utility is deliberately narrower than `Spelling` and `Pitch` in two ways, both consequences of being safe to run over prose. It is case-sensitive, because a case-insensitive rule reads the `B` of `B7` as a flat sign — so `to_unicode("eb")` is `"eb"` while `Spelling.get("eb")` is `E♭`. And a bare token is read as an accidental rather than a pitch name — `to_unicode("bb")` is `𝄫`, the double-flat sign, while `Spelling.get("bb")` is `B♭`. Parse with `Spelling` first when a pitch name may be lowercase. `to_ascii` preserves `♮` for the same reason: dropping it would turn the valid spelling `C♮` into the different spelling `C`.
|
|
18
|
+
|
|
19
|
+
### Changed
|
|
20
|
+
|
|
21
|
+
- **Breaking.** `Spelling.get("C##")` returns a `Spelling` instead of `nil`, and the same widening applies to `Pitch`, `Note`, `KeySignature`, and ABC `K:` fields. Code that treated that `nil` as a validation signal now silently accepts double sharps.
|
|
22
|
+
- **Breaking.** `Alteration::PATTERN`, `Alteration::MATCHER`, and `Alteration::SYMBOLS` gain `##` and are now sorted longest-first so that a two-character spelling always matches before its one-character prefix. Code embedding these in its own regular expressions will see different matches.
|
|
23
|
+
- **Breaking.** `Alteration.symbols` now returns `SYMBOLS` rather than a separately derived list that disagreed with it.
|
|
24
|
+
- **Breaking.** `Utilities::HashKey` normalizes ASCII accidentals before mapping them to word suffixes, so ASCII and unicode spellings of the same accidental now produce the same key. Previously it handled ASCII `#` but not ASCII `b`, so `"C#"` keyed to `:c_sharp` while `"Bb"` keyed to `:bb`. Now `"Bb"` keys to `:b_flat`, `"Bbb"` to `:b_double_flat`, and `"Cx"` to `:c_double_sharp`. Words containing those letters are unaffected — `:bass_clarinet` and `:blues_major_pentatonic` are unchanged — because normalization goes through the prose-safe `Utilities::Accidentals` rather than a bare substitution. The glyph-to-word table is now derived from `Alteration` rather than hard-coded, leaving `alterations.yml` as the single inventory.
|
|
25
|
+
- `Utilities::HashKey` is class-methods-only, matching `Utilities::Case`. `.new` and `#to_sym` are gone; `.for` was already the only entry point in use. The per-instance memo they wrapped could never be hit twice, since each instance was discarded after one call.
|
|
26
|
+
- **Breaking.** `Analysis::Dyad` gains double sharps in its enharmonic universe, which were previously built as candidates and then silently discarded by the parser. Candidate spellings grow from 28 to 35, and a major third gains three respellings.
|
|
27
|
+
- **Breaking.** `Rudiment::Scale::SCALE_REGEX` is removed. It was unreferenced, and its singles-only `[#b]?` was an instance of the asymmetry this release closes.
|
|
28
|
+
- **Breaking.** `KeySignature` and `Scale` memoization keys change for identifiers containing accidentals. `Fx`, `C##`, and `F𝄪` now unify onto one cache entry, as they name the same key signature.
|
|
29
|
+
- `Rudiment::Note::PITCH_PATTERN` no longer truncates a double sharp. `"C##4"` previously parsed as `["C", "#", nil]`, silently losing the register, because the optional quantifier bound to the pattern's final alternative rather than to the whole alternation.
|
|
30
|
+
- `Alteration#representations` now spans every symbol record rather than only the primary one, so a double sharp reports `"##"` alongside `"x"` and `𝄪`. It also accepts a `locale_code:` keyword and memoizes per locale, since the list includes the localized `#name`.
|
|
31
|
+
- An ABC `K:` field naming a double-altered tonic still raises `ParseError`, but with a message that says so — `Cannot express double-altered tonic Cx in an ABC K: field` rather than the incidental `Unrecognized mode` it produced when the narrower key pattern failed to match the accidental.
|
|
32
|
+
|
|
33
|
+
### Removed
|
|
34
|
+
|
|
35
|
+
- `Content::Staff` no longer prints to standard output when it cannot resolve a clef key. It previously wrote `Warning: Clef '...' not found.` and `Using instrument clef.` to stdout. The fallback behavior is unchanged — an unrecognized key still resolves to the instrument's clef when an instrument is present, and to the treble clef otherwise — it is simply no longer announced.
|
|
36
|
+
|
|
37
|
+
## [17.5.0] - 2026-07-21
|
|
38
|
+
|
|
39
|
+
### Added
|
|
40
|
+
|
|
41
|
+
- Sung text (lyrics) can be attached to the notes of a voice. `HeadMusic::Content::Placement#sing(text, verse:, hyphen_after:)` assigns a syllable to a placement, keyed by verse so a note carries at most one syllable per verse and any number of verses (`glo` on verse 1, `peace` on verse 2 of the same note). A new immutable `HeadMusic::Content::Syllable` value object stores only the minimal linguistic fact — `text`, `verse`, and a `hyphen_after` boolean marking that the word continues onto the next sung note; the MusicXML `syllabic` value (`single`/`begin`/`middle`/`end`) is derived at render time rather than stored, and a melisma is represented by the absence of a syllable on the held notes rather than a stored flag. Syllables serialize through `Placement#to_h` and round-trip through the composition hash deserializer, validated at the import boundary by `Composition::SchemaValues` (non-empty text, a positive-integer verse, and no duplicate verse per placement).
|
|
42
|
+
- The MusicXML writer emits a `<lyric number="N">` element as the last child of each `<note>`, on the lead note of a chord only and the attack of a tied chain only, deriving `<syllabic>` from the `hyphen_after` booleans of the syllable and its predecessor in the same verse and XML-escaping the text. Held notes of a melisma carry no `<lyric>`, matching MusicXML's continuation-by-absence. ABC `w:` lyric-line input and the MusicXML `<extend/>` melisma line remain out of scope for a future release.
|
|
43
|
+
|
|
10
44
|
## [17.4.0] - 2026-07-20
|
|
11
45
|
|
|
12
46
|
### Changed
|
data/CLAUDE.md
CHANGED
|
@@ -150,6 +150,26 @@ The gem supports multiple languages through the HeadMusic::Named mixin:
|
|
|
150
150
|
- Shared examples in `spec/support/`
|
|
151
151
|
- `composition_context.rb` provides test utilities
|
|
152
152
|
|
|
153
|
+
**Never assert on console output.** Do not use `output(...).to_stdout` or `.to_stderr`. A library should not print to the console, so a spec that asserts it printed something is pinning behavior the gem should not have — and it makes the printing hard to remove later, because deleting the `puts` breaks a test that looks unrelated to the change.
|
|
154
|
+
|
|
155
|
+
Test the behavior the message was describing instead. When a fallback or recovery path is worth pinning, assert what it produces and that it does not raise:
|
|
156
|
+
|
|
157
|
+
```ruby
|
|
158
|
+
# Not this
|
|
159
|
+
it "prints a warning" do
|
|
160
|
+
expect { staff }.to output(/Warning: Clef 'foo' not found/).to_stdout
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# This
|
|
164
|
+
it "recovers from the unrecognized key instead of raising" do
|
|
165
|
+
expect { staff }.not_to raise_error
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
it "prefers the instrument's clef over the generic fallback" do
|
|
169
|
+
expect(staff.clef).not_to eq described_class.new("foo").clef
|
|
170
|
+
end
|
|
171
|
+
```
|
|
172
|
+
|
|
153
173
|
### Documentation Philosophy
|
|
154
174
|
|
|
155
175
|
This project deliberately deprioritizes formal documentation in favor of clear, comprehensive tests.
|
data/Gemfile.lock
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
PATH
|
|
2
2
|
remote: .
|
|
3
3
|
specs:
|
|
4
|
-
head_music (
|
|
4
|
+
head_music (18.0.0)
|
|
5
5
|
activesupport (>= 7.0, < 10)
|
|
6
|
-
humanize (
|
|
6
|
+
humanize (>= 2, < 4)
|
|
7
7
|
i18n (~> 1.8)
|
|
8
8
|
|
|
9
9
|
GEM
|
|
@@ -30,98 +30,100 @@ GEM
|
|
|
30
30
|
ice_nine (~> 0.11.0)
|
|
31
31
|
thread_safe (~> 0.3, >= 0.3.1)
|
|
32
32
|
base64 (0.3.0)
|
|
33
|
-
bigdecimal (
|
|
34
|
-
bundler-audit (0.9.
|
|
35
|
-
bundler (>= 1.2.0
|
|
33
|
+
bigdecimal (4.1.2)
|
|
34
|
+
bundler-audit (0.9.3)
|
|
35
|
+
bundler (>= 1.2.0)
|
|
36
36
|
thor (~> 1.0)
|
|
37
37
|
childprocess (5.1.0)
|
|
38
38
|
logger (~> 1.5)
|
|
39
39
|
coercible (1.0.0)
|
|
40
40
|
descendants_tracker (~> 0.0.1)
|
|
41
|
-
concurrent-ruby (1.3.
|
|
42
|
-
connection_pool (
|
|
41
|
+
concurrent-ruby (1.3.8)
|
|
42
|
+
connection_pool (3.0.2)
|
|
43
43
|
descendants_tracker (0.0.4)
|
|
44
44
|
thread_safe (~> 0.3, >= 0.3.1)
|
|
45
45
|
diff-lcs (1.6.2)
|
|
46
|
-
docile (1.4.1)
|
|
47
46
|
drb (2.2.3)
|
|
48
|
-
dry-configurable (1.
|
|
49
|
-
dry-core (~> 1.
|
|
47
|
+
dry-configurable (1.4.0)
|
|
48
|
+
dry-core (~> 1.0)
|
|
50
49
|
zeitwerk (~> 2.6)
|
|
51
|
-
dry-core (1.
|
|
50
|
+
dry-core (1.2.0)
|
|
52
51
|
concurrent-ruby (~> 1.0)
|
|
53
52
|
logger
|
|
54
53
|
zeitwerk (~> 2.6)
|
|
55
|
-
dry-inflector (1.
|
|
54
|
+
dry-inflector (1.3.1)
|
|
56
55
|
dry-initializer (3.2.0)
|
|
57
56
|
dry-logic (1.6.0)
|
|
58
57
|
bigdecimal
|
|
59
58
|
concurrent-ruby (~> 1.0)
|
|
60
59
|
dry-core (~> 1.1)
|
|
61
60
|
zeitwerk (~> 2.6)
|
|
62
|
-
dry-schema (1.
|
|
61
|
+
dry-schema (1.16.0)
|
|
63
62
|
concurrent-ruby (~> 1.0)
|
|
64
63
|
dry-configurable (~> 1.0, >= 1.0.1)
|
|
65
64
|
dry-core (~> 1.1)
|
|
66
65
|
dry-initializer (~> 3.2)
|
|
67
|
-
dry-logic (~> 1.
|
|
68
|
-
dry-types (~> 1.
|
|
66
|
+
dry-logic (~> 1.6)
|
|
67
|
+
dry-types (~> 1.9, >= 1.9.1)
|
|
69
68
|
zeitwerk (~> 2.6)
|
|
70
|
-
dry-types (1.
|
|
71
|
-
bigdecimal (
|
|
69
|
+
dry-types (1.9.1)
|
|
70
|
+
bigdecimal (>= 3.0)
|
|
72
71
|
concurrent-ruby (~> 1.0)
|
|
73
72
|
dry-core (~> 1.0)
|
|
74
73
|
dry-inflector (~> 1.0)
|
|
75
74
|
dry-logic (~> 1.4)
|
|
76
75
|
zeitwerk (~> 2.6)
|
|
77
76
|
erubi (1.13.1)
|
|
78
|
-
flay (2.
|
|
77
|
+
flay (2.14.4)
|
|
79
78
|
erubi (~> 1.10)
|
|
80
|
-
path_expander (~>
|
|
81
|
-
|
|
79
|
+
path_expander (~> 2.0)
|
|
80
|
+
prism (~> 1.7)
|
|
82
81
|
sexp_processor (~> 4.0)
|
|
83
|
-
flog (4.
|
|
84
|
-
path_expander (~>
|
|
85
|
-
|
|
82
|
+
flog (4.9.4)
|
|
83
|
+
path_expander (~> 2.0)
|
|
84
|
+
prism (~> 1.7)
|
|
86
85
|
sexp_processor (~> 4.8)
|
|
87
|
-
humanize (
|
|
88
|
-
i18n (1.
|
|
86
|
+
humanize (3.1.0)
|
|
87
|
+
i18n (1.15.2)
|
|
89
88
|
concurrent-ruby (~> 1.0)
|
|
90
89
|
ice_nine (0.11.2)
|
|
91
90
|
json (2.21.1)
|
|
92
|
-
kramdown (2.5.
|
|
93
|
-
rexml (>= 3.
|
|
94
|
-
language_server-protocol (3.17.0.
|
|
91
|
+
kramdown (2.5.2)
|
|
92
|
+
rexml (>= 3.4.4)
|
|
93
|
+
language_server-protocol (3.17.0.6)
|
|
95
94
|
launchy (3.1.1)
|
|
96
95
|
addressable (~> 2.8)
|
|
97
96
|
childprocess (~> 5.0)
|
|
98
97
|
logger (~> 1.6)
|
|
99
98
|
lint_roller (1.1.0)
|
|
100
99
|
logger (1.7.0)
|
|
101
|
-
minitest (
|
|
102
|
-
|
|
103
|
-
|
|
100
|
+
minitest (6.0.6)
|
|
101
|
+
drb (~> 2.0)
|
|
102
|
+
prism (~> 1.5)
|
|
103
|
+
ostruct (0.6.3)
|
|
104
|
+
parallel (2.1.0)
|
|
105
|
+
parser (3.3.12.0)
|
|
104
106
|
ast (~> 2.4.1)
|
|
105
107
|
racc
|
|
106
|
-
path_expander (
|
|
107
|
-
prism (1.
|
|
108
|
-
public_suffix (
|
|
108
|
+
path_expander (2.0.1)
|
|
109
|
+
prism (1.9.0)
|
|
110
|
+
public_suffix (7.0.5)
|
|
109
111
|
racc (1.8.1)
|
|
110
112
|
rainbow (3.1.1)
|
|
111
|
-
rake (13.
|
|
113
|
+
rake (13.4.2)
|
|
112
114
|
reek (6.5.0)
|
|
113
115
|
dry-schema (~> 1.13)
|
|
114
116
|
logger (~> 1.6)
|
|
115
117
|
parser (~> 3.3.0)
|
|
116
118
|
rainbow (>= 2.0, < 4.0)
|
|
117
119
|
rexml (~> 3.1)
|
|
118
|
-
regexp_parser (2.
|
|
120
|
+
regexp_parser (2.12.0)
|
|
119
121
|
rexml (3.4.4)
|
|
120
|
-
rspec (3.13.
|
|
122
|
+
rspec (3.13.2)
|
|
121
123
|
rspec-core (~> 3.13.0)
|
|
122
124
|
rspec-expectations (~> 3.13.0)
|
|
123
125
|
rspec-mocks (~> 3.13.0)
|
|
124
|
-
rspec-core (3.13.
|
|
126
|
+
rspec-core (3.13.6)
|
|
125
127
|
rspec-support (~> 3.13.0)
|
|
126
128
|
rspec-expectations (3.13.5)
|
|
127
129
|
diff-lcs (>= 1.2.0, < 2.0)
|
|
@@ -129,43 +131,46 @@ GEM
|
|
|
129
131
|
rspec-its (2.0.0)
|
|
130
132
|
rspec-core (>= 3.13.0)
|
|
131
133
|
rspec-expectations (>= 3.13.0)
|
|
132
|
-
rspec-mocks (3.13.
|
|
134
|
+
rspec-mocks (3.13.8)
|
|
133
135
|
diff-lcs (>= 1.2.0, < 2.0)
|
|
134
136
|
rspec-support (~> 3.13.0)
|
|
135
|
-
rspec-support (3.13.
|
|
136
|
-
rubocop (1.
|
|
137
|
+
rspec-support (3.13.7)
|
|
138
|
+
rubocop (1.88.2)
|
|
137
139
|
json (~> 2.3)
|
|
138
140
|
language_server-protocol (~> 3.17.0.2)
|
|
139
141
|
lint_roller (~> 1.1.0)
|
|
140
|
-
parallel (
|
|
142
|
+
parallel (>= 1.10)
|
|
141
143
|
parser (>= 3.3.0.2)
|
|
142
144
|
rainbow (>= 2.2.2, < 4.0)
|
|
143
145
|
regexp_parser (>= 2.9.3, < 3.0)
|
|
144
|
-
rubocop-ast (>= 1.
|
|
146
|
+
rubocop-ast (>= 1.49.0, < 2.0)
|
|
145
147
|
ruby-progressbar (~> 1.7)
|
|
146
148
|
unicode-display_width (>= 2.4.0, < 4.0)
|
|
147
|
-
rubocop-ast (1.
|
|
149
|
+
rubocop-ast (1.50.0)
|
|
148
150
|
parser (>= 3.3.7.2)
|
|
149
|
-
prism (~> 1.
|
|
150
|
-
rubocop-performance (1.
|
|
151
|
+
prism (~> 1.7)
|
|
152
|
+
rubocop-performance (1.26.1)
|
|
151
153
|
lint_roller (~> 1.1)
|
|
152
154
|
rubocop (>= 1.75.0, < 2.0)
|
|
153
|
-
rubocop-ast (>= 1.
|
|
155
|
+
rubocop-ast (>= 1.47.1, < 2.0)
|
|
154
156
|
rubocop-rake (0.7.1)
|
|
155
157
|
lint_roller (~> 1.1)
|
|
156
158
|
rubocop (>= 1.72.1)
|
|
157
|
-
rubocop-rspec (3.
|
|
159
|
+
rubocop-rspec (3.10.2)
|
|
158
160
|
lint_roller (~> 1.1)
|
|
159
|
-
|
|
161
|
+
regexp_parser (>= 2.0)
|
|
162
|
+
rubocop (~> 1.86, >= 1.86.2)
|
|
160
163
|
ruby-progressbar (1.13.0)
|
|
161
|
-
ruby_parser (3.
|
|
164
|
+
ruby_parser (3.22.0)
|
|
162
165
|
racc (~> 1.5)
|
|
163
166
|
sexp_processor (~> 4.16)
|
|
164
|
-
rubycritic (
|
|
167
|
+
rubycritic (5.0.0)
|
|
165
168
|
flay (~> 2.13)
|
|
166
169
|
flog (~> 4.7)
|
|
167
170
|
launchy (>= 2.5.2)
|
|
171
|
+
ostruct
|
|
168
172
|
parser (>= 3.3.0.5)
|
|
173
|
+
prism (>= 1.6.0)
|
|
169
174
|
rainbow (~> 3.1.1)
|
|
170
175
|
reek (~> 6.5.0, < 7.0)
|
|
171
176
|
rexml
|
|
@@ -174,40 +179,35 @@ GEM
|
|
|
174
179
|
tty-which (~> 0.5.0)
|
|
175
180
|
virtus (~> 2.0)
|
|
176
181
|
securerandom (0.4.1)
|
|
177
|
-
sexp_processor (4.17.
|
|
178
|
-
simplecov (0.
|
|
179
|
-
|
|
180
|
-
simplecov-html (~> 0.11)
|
|
181
|
-
simplecov_json_formatter (~> 0.1)
|
|
182
|
-
simplecov-html (0.13.2)
|
|
183
|
-
simplecov_json_formatter (0.1.4)
|
|
184
|
-
standard (1.50.0)
|
|
182
|
+
sexp_processor (4.17.5)
|
|
183
|
+
simplecov (1.0.2)
|
|
184
|
+
standard (1.56.0)
|
|
185
185
|
language_server-protocol (~> 3.17.0.2)
|
|
186
186
|
lint_roller (~> 1.0)
|
|
187
|
-
rubocop (~> 1.
|
|
187
|
+
rubocop (~> 1.88.0)
|
|
188
188
|
standard-custom (~> 1.0.0)
|
|
189
189
|
standard-performance (~> 1.8)
|
|
190
190
|
standard-custom (1.0.2)
|
|
191
191
|
lint_roller (~> 1.0)
|
|
192
192
|
rubocop (~> 1.50)
|
|
193
|
-
standard-performance (1.
|
|
193
|
+
standard-performance (1.9.0)
|
|
194
194
|
lint_roller (~> 1.1)
|
|
195
|
-
rubocop-performance (~> 1.
|
|
196
|
-
thor (1.
|
|
195
|
+
rubocop-performance (~> 1.26.0)
|
|
196
|
+
thor (1.5.0)
|
|
197
197
|
thread_safe (0.3.6)
|
|
198
198
|
tty-which (0.5.0)
|
|
199
199
|
tzinfo (2.0.6)
|
|
200
200
|
concurrent-ruby (~> 1.0)
|
|
201
|
-
unicode-display_width (3.
|
|
202
|
-
unicode-emoji (~> 4.
|
|
203
|
-
unicode-emoji (4.0
|
|
201
|
+
unicode-display_width (3.2.0)
|
|
202
|
+
unicode-emoji (~> 4.1)
|
|
203
|
+
unicode-emoji (4.2.0)
|
|
204
204
|
uri (1.1.1)
|
|
205
205
|
virtus (2.0.0)
|
|
206
206
|
axiom-types (~> 0.1)
|
|
207
207
|
coercible (~> 1.0)
|
|
208
208
|
descendants_tracker (~> 0.0, >= 0.0.3)
|
|
209
|
-
yard (0.9.
|
|
210
|
-
zeitwerk (2.
|
|
209
|
+
yard (0.9.45)
|
|
210
|
+
zeitwerk (2.8.2)
|
|
211
211
|
|
|
212
212
|
PLATFORMS
|
|
213
213
|
arm64-darwin-22
|
data/head_music.gemspec
CHANGED
|
@@ -33,7 +33,7 @@ Gem::Specification.new do |spec|
|
|
|
33
33
|
spec.required_ruby_version = ">= 3.3.0"
|
|
34
34
|
|
|
35
35
|
spec.add_runtime_dependency "activesupport", ">= 7.0", "< 10"
|
|
36
|
-
spec.add_runtime_dependency "humanize", "
|
|
36
|
+
spec.add_runtime_dependency "humanize", ">= 2", "< 4"
|
|
37
37
|
spec.add_runtime_dependency "i18n", "~> 1.8"
|
|
38
38
|
|
|
39
39
|
spec.add_development_dependency "rake", "~> 13.0"
|
|
@@ -78,6 +78,9 @@ class HeadMusic::Content::Composition
|
|
|
78
78
|
sounds = values.placement_sounds(placement_hash, path)
|
|
79
79
|
placement = voice.place(position, rhythmic_value, sounds)
|
|
80
80
|
placement.beam_break_before = placement_hash["beam_break_before"] if placement_hash.key?("beam_break_before")
|
|
81
|
+
values.placement_syllables(placement_hash, path).each do |syllable|
|
|
82
|
+
placement.sing(syllable.text, verse: syllable.verse, hyphen_after: syllable.hyphen_after)
|
|
83
|
+
end
|
|
81
84
|
end
|
|
82
85
|
end
|
|
83
86
|
end
|
|
@@ -71,6 +71,25 @@ class HeadMusic::Content::Composition
|
|
|
71
71
|
end
|
|
72
72
|
end
|
|
73
73
|
|
|
74
|
+
# "syllables" is an optional array of sung-text data, one entry per verse.
|
|
75
|
+
# Each entry is a {"text" => ..., "verse" => ..., "hyphen_after" => ...}
|
|
76
|
+
# hash; verse defaults to 1. Text must be a non-empty string, verse a
|
|
77
|
+
# positive integer, and no two entries may share a verse (a placement holds
|
|
78
|
+
# at most one syllable per verse).
|
|
79
|
+
def placement_syllables(placement_hash, path)
|
|
80
|
+
values = placement_hash["syllables"]
|
|
81
|
+
return [] if values.nil?
|
|
82
|
+
|
|
83
|
+
unless values.is_a?(Array)
|
|
84
|
+
raise ArgumentError, "#{path}: syllables must be an Array, got #{values.inspect}"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
seen_verses = []
|
|
88
|
+
values.each_with_index.map do |value, index|
|
|
89
|
+
syllable(value, seen_verses, "#{path}.syllables[#{index}]")
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
74
93
|
def bar_number(bar_hash, index)
|
|
75
94
|
number = bar_hash["number"]
|
|
76
95
|
unless number.is_a?(Integer) && number >= 0
|
|
@@ -101,6 +120,27 @@ class HeadMusic::Content::Composition
|
|
|
101
120
|
pitch
|
|
102
121
|
end
|
|
103
122
|
|
|
123
|
+
def syllable(value, seen_verses, path)
|
|
124
|
+
unless value.is_a?(Hash)
|
|
125
|
+
raise ArgumentError, "#{path}: syllable must be a Hash, got #{value.inspect}"
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
text = value["text"]
|
|
129
|
+
unless text.is_a?(String) && !text.empty?
|
|
130
|
+
raise ArgumentError, "#{path}: syllable text must be a non-empty String, got #{text.inspect}"
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
verse = value.fetch("verse", 1)
|
|
134
|
+
unless verse.is_a?(Integer) && verse.positive?
|
|
135
|
+
raise ArgumentError, "#{path}: verse must be a positive Integer, got #{verse.inspect}"
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
raise ArgumentError, "#{path}: duplicate verse #{verse}" if seen_verses.include?(verse)
|
|
139
|
+
seen_verses << verse
|
|
140
|
+
|
|
141
|
+
HeadMusic::Content::Syllable.from_h(value)
|
|
142
|
+
end
|
|
143
|
+
|
|
104
144
|
def sound(value, path)
|
|
105
145
|
return pitch(value, path) if value.is_a?(String)
|
|
106
146
|
return unpitched_sound(value, path) if value.is_a?(Hash)
|
|
@@ -25,6 +25,32 @@ class HeadMusic::Content::Placement
|
|
|
25
25
|
sounds.select(&:pitched?)
|
|
26
26
|
end
|
|
27
27
|
|
|
28
|
+
# Authored sung text: at most one Syllable per verse, keyed by verse number.
|
|
29
|
+
# Empty for un-texted placements and rests. Set after construction, like
|
|
30
|
+
# beam_break_before. The MusicXML writer derives <syllabic> from these plus
|
|
31
|
+
# neighboring placements; melisma is the absence of a syllable here.
|
|
32
|
+
def syllables
|
|
33
|
+
@syllables ||= {}
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Assigns the syllable for a verse (default verse 1). Returns self so calls
|
|
37
|
+
# chain across verses. Keys by the Syllable's coerced verse (not the raw
|
|
38
|
+
# argument) so syllable(2) finds what sing(verse: "2") stored and mixed-type
|
|
39
|
+
# keys never make syllables.keys.sort raise.
|
|
40
|
+
def sing(text, verse: 1, hyphen_after: false)
|
|
41
|
+
syllable = HeadMusic::Content::Syllable.new(text, verse: verse, hyphen_after: hyphen_after)
|
|
42
|
+
syllables[syllable.verse] = syllable
|
|
43
|
+
self
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def syllable(verse = 1)
|
|
47
|
+
syllables[verse]
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def sung?
|
|
51
|
+
syllables.any?
|
|
52
|
+
end
|
|
53
|
+
|
|
28
54
|
# The top pitch of a chord (or the only pitch of a note), which melodic
|
|
29
55
|
# analysis treats as the melody note. Returns nil for rests and
|
|
30
56
|
# unpitched-only placements; pitched? is the guard. Enharmonic ties
|
|
@@ -64,7 +90,9 @@ class HeadMusic::Content::Placement
|
|
|
64
90
|
|
|
65
91
|
# Voice#place merges a same-position placement into the existing one, so a
|
|
66
92
|
# position holds at most one placement. The sound union keeps the chord free
|
|
67
|
-
# of duplicates, making repeated placement of a sound idempotent.
|
|
93
|
+
# of duplicates, making repeated placement of a sound idempotent. Syllables
|
|
94
|
+
# are left untouched: a chord sings one syllable per verse, and the receiver
|
|
95
|
+
# (the placement already at this position) keeps its own.
|
|
68
96
|
def merge(other)
|
|
69
97
|
unless rhythmic_value == other.rhythmic_value
|
|
70
98
|
raise ArgumentError,
|
|
@@ -98,6 +126,7 @@ class HeadMusic::Content::Placement
|
|
|
98
126
|
"sounds" => sounds.map { |sound| sound_datum(sound) }
|
|
99
127
|
}
|
|
100
128
|
hash["beam_break_before"] = beam_break_before unless beam_break_before.nil?
|
|
129
|
+
hash["syllables"] = syllables.keys.sort.map { |verse| syllables[verse].to_h } unless syllables.empty?
|
|
101
130
|
hash
|
|
102
131
|
end
|
|
103
132
|
|
|
@@ -12,12 +12,10 @@ class HeadMusic::Content::Staff
|
|
|
12
12
|
begin
|
|
13
13
|
@default_clef = HeadMusic::Rudiment::Clef.get(default_clef_key)
|
|
14
14
|
rescue KeyError, NoMethodError
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
puts("Using instrument clef.")
|
|
18
|
-
@default_clef = @instrument.default_staves.first.clef
|
|
15
|
+
@default_clef = if @instrument
|
|
16
|
+
@instrument.default_staves.first.clef
|
|
19
17
|
else
|
|
20
|
-
|
|
18
|
+
HeadMusic::Rudiment::Clef.get(:treble_clef)
|
|
21
19
|
end
|
|
22
20
|
end
|
|
23
21
|
@line_count = line_count || DEFAULT_LINE_COUNT
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# A module for musical content
|
|
2
|
+
module HeadMusic::Content; end
|
|
3
|
+
|
|
4
|
+
# A sung syllable attached to a Placement for one verse. Only the minimal
|
|
5
|
+
# linguistic fact is stored: the text, its verse number, and whether the word
|
|
6
|
+
# continues onto the next sung note (hyphen_after). The MusicXML `syllabic`
|
|
7
|
+
# value (single/begin/middle/end) is derived from these at render time rather
|
|
8
|
+
# than stored, and melisma is represented by the absence of a syllable on the
|
|
9
|
+
# following placements, so nothing here encodes it.
|
|
10
|
+
class HeadMusic::Content::Syllable
|
|
11
|
+
attr_reader :text, :verse, :hyphen_after
|
|
12
|
+
|
|
13
|
+
def initialize(text, verse: 1, hyphen_after: false)
|
|
14
|
+
@text = text.to_s
|
|
15
|
+
@verse = Integer(verse)
|
|
16
|
+
@hyphen_after = !!hyphen_after
|
|
17
|
+
freeze
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def hyphen_after?
|
|
21
|
+
hyphen_after
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def to_h
|
|
25
|
+
hash = {"text" => text}
|
|
26
|
+
hash["verse"] = verse unless verse == 1
|
|
27
|
+
hash["hyphen_after"] = true if hyphen_after
|
|
28
|
+
hash
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def self.from_h(hash)
|
|
32
|
+
new(hash["text"], verse: hash.fetch("verse", 1), hyphen_after: hash.fetch("hyphen_after", false))
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def ==(other)
|
|
36
|
+
other.is_a?(self.class) && to_h == other.to_h
|
|
37
|
+
end
|
|
38
|
+
end
|