head_music 18.0.0 → 19.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.
Files changed (40) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +4 -4
  3. data/.github/workflows/release.yml +2 -2
  4. data/.github/workflows/security.yml +1 -1
  5. data/CHANGELOG.md +40 -0
  6. data/Gemfile.lock +3 -3
  7. data/README.md +28 -0
  8. data/lib/head_music/instruments/instrument.rb +2 -7
  9. data/lib/head_music/locales/en.yml +5 -0
  10. data/lib/head_music/notation/abc/body_lexer.rb +222 -299
  11. data/lib/head_music/notation/abc/chord_reader.rb +56 -0
  12. data/lib/head_music/notation/abc/chord_scanner.rb +78 -0
  13. data/lib/head_music/notation/abc/parser.rb +19 -101
  14. data/lib/head_music/notation/abc/repeat_tagger.rb +67 -0
  15. data/lib/head_music/notation/abc/token.rb +21 -0
  16. data/lib/head_music/notation/abc/voice_registry.rb +51 -0
  17. data/lib/head_music/notation/abc/volta_passes.rb +28 -0
  18. data/lib/head_music/notation/music_xml/lyric_writer.rb +60 -0
  19. data/lib/head_music/notation/music_xml/note_writer.rb +105 -0
  20. data/lib/head_music/notation/music_xml/writer.rb +13 -142
  21. data/lib/head_music/notation/music_xml/xml_text.rb +21 -0
  22. data/lib/head_music/style/analysis.rb +7 -0
  23. data/lib/head_music/style/guide.rb +100 -0
  24. data/lib/head_music/style/guides/base.rb +38 -2
  25. data/lib/head_music/style/guides/configured.rb +62 -0
  26. data/lib/head_music/style/guides/contour_melody.rb +61 -0
  27. data/lib/head_music/style/guides/diatonic_melody.rb +0 -24
  28. data/lib/head_music/style/guides/species_harmony.rb +5 -0
  29. data/lib/head_music/style/guides/species_melody.rb +5 -0
  30. data/lib/head_music/version.rb +1 -1
  31. data/lib/head_music.rb +5 -6
  32. data/user-stories/done/configurable-guide-registry.md +647 -0
  33. data/user-stories/index.html +4 -0
  34. metadata +15 -8
  35. data/lib/head_music/style/guides/arch_contour_melody.rb +0 -4
  36. data/lib/head_music/style/guides/ascending_contour_melody.rb +0 -4
  37. data/lib/head_music/style/guides/descending_contour_melody.rb +0 -4
  38. data/lib/head_music/style/guides/static_contour_melody.rb +0 -5
  39. data/lib/head_music/style/guides/valley_contour_melody.rb +0 -4
  40. data/lib/head_music/style/guides/wave_contour_melody.rb +0 -4
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8a16868734e2fac408461d2202fcbc84be2a796a68371a81a7d2ad5bbeef516b
4
- data.tar.gz: 02becc93275629796177a72894d81cc10feca0819847496e60aa3ddc6f5aac00
3
+ metadata.gz: 7462797fd33041d3ab9fea4cf10cfaf329213d6f96d3f3b9cbb4f6a73dfd2890
4
+ data.tar.gz: 01c4cff6e9f609ce9543be019c340b3c7a01c21928e7248345d5a50364d85705
5
5
  SHA512:
6
- metadata.gz: e3533678a1a2b58558080cad6c03f04fe4b5b205d4413456b728c5ae2e1a598e3a09ebc0329c9f6fde263e84f857eb4571918087afe23a083278facaea7aa460
7
- data.tar.gz: 3d605bff3a0459e56cddd4911e7699d2b295bf337e7f468094d0f4cc19e55f9b3036c21e59c95263f3bf19c69dfffa99093a8564f036aa625b776199e959e8f4
6
+ metadata.gz: c14082d1dc7e8405227b46be83e803b3037808c817200e8cadf18ead15dda70f17248d92a2c370d52f92eb9ea4241aa7dad07869e016979edc301c9d30f077aa
7
+ data.tar.gz: '06868ca14c8cca41f22c395cb04d84a6291492471cf034a9efd69afc4a822951cd2fc9ad4d2c7b4a94ed7d68b8bd70a8003090092b96291733ad1378f48c3d19'
@@ -16,7 +16,7 @@ jobs:
16
16
  activesupport-version: ['7.2', '8.0']
17
17
 
18
18
  steps:
19
- - uses: actions/checkout@v5
19
+ - uses: actions/checkout@v7
20
20
 
21
21
  - name: Set up Ruby ${{ matrix.ruby-version }}
22
22
  uses: ruby/setup-ruby@v1
@@ -34,7 +34,7 @@ jobs:
34
34
 
35
35
  - name: Upload coverage to Codecov
36
36
  if: matrix.ruby-version == '3.3' && matrix.activesupport-version == '8.0'
37
- uses: codecov/codecov-action@v5
37
+ uses: codecov/codecov-action@v7
38
38
  with:
39
39
  token: ${{ secrets.CODECOV_TOKEN }}
40
40
  fail_ci_if_error: false
@@ -43,7 +43,7 @@ jobs:
43
43
  lint:
44
44
  runs-on: ubuntu-latest
45
45
  steps:
46
- - uses: actions/checkout@v5
46
+ - uses: actions/checkout@v7
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@v5
60
+ - uses: actions/checkout@v7
61
61
 
62
62
  - name: Set up Ruby
63
63
  uses: ruby/setup-ruby@v1
@@ -13,7 +13,7 @@ jobs:
13
13
  id-token: write
14
14
 
15
15
  steps:
16
- - uses: actions/checkout@v5
16
+ - uses: actions/checkout@v7
17
17
 
18
18
  - name: Set up Ruby
19
19
  uses: ruby/setup-ruby@v1
@@ -31,7 +31,7 @@ jobs:
31
31
  run: gem build *.gemspec
32
32
 
33
33
  - name: Create GitHub Release
34
- uses: softprops/action-gh-release@v2
34
+ uses: softprops/action-gh-release@v3
35
35
  with:
36
36
  files: '*.gem'
37
37
  generate_release_notes: true
@@ -15,7 +15,7 @@ jobs:
15
15
  runs-on: ubuntu-latest
16
16
 
17
17
  steps:
18
- - uses: actions/checkout@v5
18
+ - uses: actions/checkout@v7
19
19
 
20
20
  - name: Set up Ruby
21
21
  uses: ruby/setup-ruby@v1
data/CHANGELOG.md CHANGED
@@ -7,6 +7,46 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ### Added
11
+
12
+ - `HeadMusic::Style::Guide` — a lookup facade over every style guide in the gem. `Guide.get("first_species_harmony")` resolves a key to a guide; an unknown key returns `nil` rather than raising, so a consumer can ask about a guide the gem does not have. `Guide.get!` raises `KeyError` instead, and `Guide.known?`, `.all`, `.keys`, and `.key_for` round out the surface. Keys are stable strings suitable for storing in a database. The registry holds twenty-three:
13
+
14
+ | Category | Keys |
15
+ | --- | --- |
16
+ | `:melody` | `fux_cantus_firmus`, `salzer_schachter_cantus_firmus`, `diatonic_melody`, `first_species_melody`, `second_species_melody`, `third_species_melody`, `third_species_triple_meter_melody`, `fourth_species_melody`, `combined_first_second_third_species_melody`, `fifth_species_melody`, `arch_contour_melody`, `ascending_contour_melody`, `descending_contour_melody`, `static_contour_melody`, `valley_contour_melody`, `wave_contour_melody` |
17
+ | `:harmony` | `first_species_harmony`, `second_species_harmony`, `third_species_harmony`, `third_species_triple_meter_harmony`, `fourth_species_harmony`, `combined_first_second_third_species_harmony`, `fifth_species_harmony` |
18
+
19
+ - Guides now carry identity: `.key` (snake_case of the class name), `.category` (`:melody` or `:harmony`, derived from the `SpeciesMelody`/`SpeciesHarmony` ancestry), and `.display_name` (localizable, with a computed English default). Consumers no longer need to hand-maintain a map of guide constants to categories.
20
+ - `HeadMusic::Style::Guides::Configured` — the guide-layer twin of `Annotation::Configured`. It pairs a guide class with options and answers `analyze(voice)`, so it drops into `Style::Analysis` wherever a guide class was expected. `Guides::Base.with(**options)` returns one, and `#with` layers further options without dropping earlier ones.
21
+ - `HeadMusic::Style::Guides::ContourMelody` — one configurable guide replacing the six contour subclasses: `ContourMelody.with(contour: :arch, minimum_melodic_intervals: 2)`. An invalid contour raises `ArgumentError` at configuration time, not at analysis.
22
+
23
+ ### Changed
24
+
25
+ - **Breaking.** The six contour guide classes are removed: `ArchContourMelody`, `AscendingContourMelody`, `DescendingContourMelody`, `StaticContourMelody`, `ValleyContourMelody`, and `WaveContourMelody`. Their rulesets are unchanged and still reachable — by key, through the registry. Migrate constant references to `Guide.get`:
26
+
27
+ | Removed constant | Replacement |
28
+ | --- | --- |
29
+ | `Guides::ArchContourMelody` | `Style::Guide.get("arch_contour_melody")` |
30
+ | `Guides::AscendingContourMelody` | `Style::Guide.get("ascending_contour_melody")` |
31
+ | `Guides::DescendingContourMelody` | `Style::Guide.get("descending_contour_melody")` |
32
+ | `Guides::StaticContourMelody` | `Style::Guide.get("static_contour_melody")` |
33
+ | `Guides::ValleyContourMelody` | `Style::Guide.get("valley_contour_melody")` |
34
+ | `Guides::WaveContourMelody` | `Style::Guide.get("wave_contour_melody")` |
35
+
36
+ - **Breaking.** `Guides::DiatonicMelody.contour_ruleset` and `Guides::DiatonicMelody::CONTOUR_PEER_WEIGHT_BUDGET` are removed. The budget constant now lives on the guide that uses it, as `Guides::ContourMelody::PEER_WEIGHT_BUDGET`.
37
+ - **Breaking.** Neither a configured guide nor `Guides::ContourMelody` has a `::RULESET` constant — read `#ruleset` or `.ruleset(**options)` instead. `Guides::Base.ruleset` is now a method rather than a bare constant read, and `ContourMelody` deliberately does not descend from `DiatonicMelody`, so a guide whose ruleset depends on configuration raises rather than silently resolving an ancestor's ruleset through Ruby's constant lookup.
38
+ - **Breaking.** `guide.name` on a configured guide returns the underlying class name, which is shared across all six contour configurations. Persist `guide.key`, not `guide.name`; use `guide.display_name` for presentation.
39
+ - `Style::Analysis.new` now raises `ArgumentError` when given something that cannot answer `analyze`. Previously a `nil` guide — the result of an unrecognized key — surfaced much later as a `NoMethodError` on `nil`.
40
+ - **Breaking.** `Guides::Base.with` raises `ArgumentError` when given options for a guide that takes none, rather than accepting them and failing at the first `analyze` with Ruby's bare `wrong number of arguments (given 1, expected 0)`. A guide accepts configuration by declaring keywords on `.ruleset`, so `FuxCantusFirmus.with(contour: :arch)` now names the guide and the options it cannot take. `Guides::Configured` resolves its ruleset in its constructor for the same reason: a configuration error belongs at `.with`, not mid-grading.
41
+ - **Breaking.** `Instruments::Instrument.get` no longer accepts a second positional argument. The deprecated two-argument form joined its arguments with an underscore, so `Instrument.get("trumpet", "in_c")` becomes `Instrument.get("trumpet_in_c")`. It never emitted a runtime deprecation warning, so check call sites rather than logs. The two-argument form also fell back to the base instrument when the combined name did not exist — `get("trumpet", "in_q")` returned the trumpet — where `get("trumpet_in_q")` returns `nil`.
42
+ - The six contour rulesets are byte-for-byte what they were: the same ten rubric peers sharing φ⁻² evenly, `Contoured` at φ⁻¹, and the same per-contour motion gate (omitted for `static`). Fitness values are unchanged.
43
+
44
+ - **Breaking.** The ABC parser and lexer and the MusicXML writer each shed a responsibility to a collaborator, leaving no file in the gem above a RubyCritic C. Behavior is unchanged and no public method signature moved, but four constants did, and the old names no longer resolve:
45
+ - `Notation::ABC::BodyLexer::Token` is now `Notation::ABC::Token`. The token type is consumed by `Parser` and `Preflight`, not just the lexer.
46
+ - `Notation::ABC::BodyLexer::ChordNote` is now `Notation::ABC::ChordScanner::ChordNote`, alongside the bracket-chord scanning that produces it.
47
+ - `Notation::ABC::Parser::REPEAT_ENDING_STYLES`, `REPEAT_STARTING_STYLES`, and `SECTION_ENDING_STYLES` are now on `Notation::ABC::RepeatTagger`, which applies the repeat and volta marks they describe.
48
+ - `Notation::MusicXML::Writer::XML_ESCAPES` is now `Notation::MusicXML::XmlText::ESCAPES`, shared with the note and lyric writers. `Writer::INDENT` still resolves, through the same module.
49
+
10
50
  ## [18.0.0] - 2026-07-27
11
51
 
12
52
  ### Added
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- head_music (18.0.0)
4
+ head_music (19.0.0)
5
5
  activesupport (>= 7.0, < 10)
6
6
  humanize (>= 2, < 4)
7
7
  i18n (~> 1.8)
@@ -87,7 +87,7 @@ GEM
87
87
  i18n (1.15.2)
88
88
  concurrent-ruby (~> 1.0)
89
89
  ice_nine (0.11.2)
90
- json (2.21.1)
90
+ json (2.21.2)
91
91
  kramdown (2.5.2)
92
92
  rexml (>= 3.4.4)
93
93
  language_server-protocol (3.17.0.6)
@@ -180,7 +180,7 @@ GEM
180
180
  virtus (~> 2.0)
181
181
  securerandom (0.4.1)
182
182
  sexp_processor (4.17.5)
183
- simplecov (1.0.2)
183
+ simplecov (1.0.3)
184
184
  standard (1.56.0)
185
185
  language_server-protocol (~> 3.17.0.2)
186
186
  lint_roller (~> 1.0)
data/README.md CHANGED
@@ -52,6 +52,34 @@ chord = HeadMusic::Analysis::PitchSet.new(pitches)
52
52
  puts chord.major_triad? # => true
53
53
  ```
54
54
 
55
+ ## Style Analysis
56
+
57
+ Look up a style guide by key and analyze a voice against it. `Style::Guide.get` returns `nil` for an
58
+ unknown key, so a stored key can be validated before use.
59
+
60
+ ```ruby
61
+ guide = HeadMusic::Style::Guide.get('first_species_harmony')
62
+ guide.category # => :harmony
63
+ guide.display_name # => "First Species Harmony"
64
+
65
+ analysis = HeadMusic::Style::Analysis.new(guide, voice)
66
+ analysis.fitness # => 0.0 to 1.0
67
+ analysis.messages # => ["Prefer contrary motion. Move voices in different melodic directions."]
68
+ ```
69
+
70
+ Guides whose ruleset varies by configuration are built with `.with`. The six contour melodies are
71
+ registered under their own keys, and each key is exactly one such configuration:
72
+
73
+ ```ruby
74
+ HeadMusic::Style::Guide.get('arch_contour_melody')
75
+ # the same guide, spelled out
76
+ HeadMusic::Style::Guides::ContourMelody.with(contour: :arch, minimum_melodic_intervals: 2)
77
+ ```
78
+
79
+ Configure it differently and you get a different guide — one the registry does not hold, whose `key`
80
+ is `nil` and whose `display_name` falls back to the class. Prefer the key when you mean a registered
81
+ guide, and `.with` when you deliberately want a configuration of your own.
82
+
55
83
  ## Documentation
56
84
 
57
85
  - **API Documentation**: [rubydoc.info/gems/head_music](https://rubydoc.info/gems/head_music)
@@ -31,17 +31,12 @@ class HeadMusic::Instruments::Instrument
31
31
  class << self
32
32
  # Factory method to get an Instrument instance
33
33
  # @param name [String, Symbol] instrument name (e.g., "clarinet", "clarinet_in_a")
34
- # @param variant_key [String, Symbol, nil] DEPRECATED: variant key (for backward compatibility)
35
34
  # @return [Instrument, nil] instrument instance or nil if not found
36
- def get(name, variant_key = nil)
35
+ def get(name)
37
36
  return name if name.is_a?(self)
38
37
 
39
38
  name_str = name.to_s
40
- if variant_key
41
- find_valid_instrument("#{name_str}_#{variant_key}") || find_valid_instrument(name_str)
42
- else
43
- find_valid_instrument(name_str) || find_valid_instrument(normalize_variant_name(name_str))
44
- end
39
+ find_valid_instrument(name_str) || find_valid_instrument(normalize_variant_name(name_str))
45
40
  end
46
41
 
47
42
  def find_valid_instrument(name)
@@ -566,3 +566,8 @@ en:
566
566
  staff: staff
567
567
  tuba: tuba
568
568
  tuning: tuning
569
+ style:
570
+ guides:
571
+ # Only keys whose humanized default is wrong need an entry here;
572
+ # HeadMusic::Style::Guide.display_name_for derives the rest.
573
+ salzer_schachter_cantus_firmus: "Salzer–Schachter Cantus Firmus"