head_music 17.3.0 → 17.5.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 (121) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +3 -3
  3. data/.github/workflows/release.yml +1 -1
  4. data/.github/workflows/security.yml +2 -1
  5. data/.rubocop.yml +6 -0
  6. data/CHANGELOG.md +15 -0
  7. data/Gemfile.lock +68 -68
  8. data/head_music.gemspec +1 -1
  9. data/lib/head_music/analysis/chord_analysis.rb +123 -0
  10. data/lib/head_music/analysis/circle.rb +11 -3
  11. data/lib/head_music/analysis/diatonic_interval.rb +14 -11
  12. data/lib/head_music/analysis/dyad/chord_implication.rb +101 -0
  13. data/lib/head_music/analysis/dyad.rb +24 -104
  14. data/lib/head_music/analysis/interval_cycle.rb +9 -3
  15. data/lib/head_music/analysis/pitch_collection.rb +35 -109
  16. data/lib/head_music/analysis/sonority.rb +11 -21
  17. data/lib/head_music/content/composition/hash_deserializer.rb +113 -0
  18. data/lib/head_music/content/composition/schema_values.rb +167 -0
  19. data/lib/head_music/content/composition.rb +0 -216
  20. data/lib/head_music/content/placement.rb +31 -42
  21. data/lib/head_music/content/sound_resolver.rb +54 -0
  22. data/lib/head_music/content/syllable.rb +38 -0
  23. data/lib/head_music/content/voice/melodic_line.rb +32 -0
  24. data/lib/head_music/content/voice/melodic_note_pair.rb +33 -0
  25. data/lib/head_music/content/voice.rb +8 -59
  26. data/lib/head_music/instruments/alternate_tuning.rb +4 -6
  27. data/lib/head_music/instruments/catalog_lookup.rb +18 -0
  28. data/lib/head_music/instruments/instrument.rb +17 -117
  29. data/lib/head_music/instruments/instrument_catalog.rb +48 -0
  30. data/lib/head_music/instruments/instrument_configuration.rb +4 -6
  31. data/lib/head_music/instruments/instrument_family.rb +3 -8
  32. data/lib/head_music/instruments/instrument_name.rb +49 -0
  33. data/lib/head_music/instruments/score_order.rb +52 -42
  34. data/lib/head_music/instruments/staff_profile.rb +71 -0
  35. data/lib/head_music/instruments/stringing.rb +4 -6
  36. data/lib/head_music/instruments/stringing_course.rb +4 -6
  37. data/lib/head_music/instruments/variant.rb +4 -6
  38. data/lib/head_music/named/locale.rb +32 -0
  39. data/lib/head_music/named/localized_name.rb +15 -0
  40. data/lib/head_music/named.rb +1 -45
  41. data/lib/head_music/notation/abc/body_lexer.rb +48 -23
  42. data/lib/head_music/notation/abc/duration_resolver.rb +11 -1
  43. data/lib/head_music/notation/abc/duration_writer.rb +1 -8
  44. data/lib/head_music/notation/abc/header.rb +27 -16
  45. data/lib/head_music/notation/abc/parser.rb +44 -183
  46. data/lib/head_music/notation/abc/preflight.rb +48 -0
  47. data/lib/head_music/notation/abc/voice_state.rb +161 -0
  48. data/lib/head_music/notation/abc/writer.rb +34 -17
  49. data/lib/head_music/notation/dotted_duration.rb +19 -0
  50. data/lib/head_music/notation/instrument_notation.rb +5 -7
  51. data/lib/head_music/notation/music_xml/beam_grouper.rb +2 -2
  52. data/lib/head_music/notation/music_xml/duration_writer.rb +4 -9
  53. data/lib/head_music/notation/music_xml/preflight.rb +90 -0
  54. data/lib/head_music/notation/music_xml/render_plan.rb +161 -0
  55. data/lib/head_music/notation/music_xml/writer.rb +66 -209
  56. data/lib/head_music/notation/placement_validation.rb +19 -0
  57. data/lib/head_music/rudiment/enharmonic_equivalence.rb +33 -0
  58. data/lib/head_music/rudiment/key.rb +8 -36
  59. data/lib/head_music/rudiment/key_signature/enharmonic_equivalence.rb +21 -15
  60. data/lib/head_music/rudiment/key_signature.rb +18 -19
  61. data/lib/head_music/rudiment/meter.rb +17 -13
  62. data/lib/head_music/rudiment/mode.rb +33 -65
  63. data/lib/head_music/rudiment/note.rb +18 -21
  64. data/lib/head_music/rudiment/pitch/enharmonic_equivalence.rb +4 -15
  65. data/lib/head_music/rudiment/pitch/natural_step.rb +36 -0
  66. data/lib/head_music/rudiment/pitch/parser.rb +16 -5
  67. data/lib/head_music/rudiment/pitch.rb +11 -33
  68. data/lib/head_music/rudiment/qualified_diatonic_context.rb +61 -0
  69. data/lib/head_music/rudiment/rhythmic_unit/parser.rb +16 -12
  70. data/lib/head_music/rudiment/rhythmic_unit.rb +21 -37
  71. data/lib/head_music/rudiment/rhythmic_value/parser.rb +53 -46
  72. data/lib/head_music/rudiment/spelling/enharmonic_equivalence.rb +13 -0
  73. data/lib/head_music/rudiment/spelling.rb +0 -25
  74. data/lib/head_music/rudiment/tuning/just_intonation.rb +2 -4
  75. data/lib/head_music/rudiment/tuning/meantone.rb +2 -4
  76. data/lib/head_music/rudiment/tuning/pythagorean.rb +2 -4
  77. data/lib/head_music/rudiment/tuning/tonal_centered.rb +10 -0
  78. data/lib/head_music/rudiment/unpitched_note.rb +3 -5
  79. data/lib/head_music/style/annotation/configured.rb +35 -0
  80. data/lib/head_music/style/annotation.rb +32 -44
  81. data/lib/head_music/style/guidelines/allowed_rhythmic_values_for_fifth_species.rb +0 -10
  82. data/lib/head_music/style/guidelines/contoured.rb +65 -53
  83. data/lib/head_music/style/guidelines/dissonance_figure_detection.rb +55 -16
  84. data/lib/head_music/style/guidelines/florid_dissonance_treatment.rb +0 -37
  85. data/lib/head_music/style/guidelines/large_leaps.rb +10 -2
  86. data/lib/head_music/style/guidelines/minimum_melodic_intervals.rb +3 -25
  87. data/lib/head_music/style/guidelines/minimum_notes.rb +3 -25
  88. data/lib/head_music/style/guidelines/minimum_threshold.rb +38 -0
  89. data/lib/head_music/style/guidelines/no_parallel_perfect.rb +31 -0
  90. data/lib/head_music/style/guidelines/no_parallel_perfect_on_downbeats.rb +3 -17
  91. data/lib/head_music/style/guidelines/no_parallel_perfect_with_syncopation.rb +3 -17
  92. data/lib/head_music/style/guidelines/notes_same_length.rb +1 -1
  93. data/lib/head_music/style/guidelines/prepare_octave_leaps.rb +10 -7
  94. data/lib/head_music/style/guidelines/singable_intervals.rb +22 -8
  95. data/lib/head_music/style/guidelines/suspension_treatment.rb +30 -12
  96. data/lib/head_music/style/guidelines/third_species_dissonance_treatment.rb +0 -7
  97. data/lib/head_music/style/guidelines/triple_meter_dissonance_treatment.rb +0 -12
  98. data/lib/head_music/style/guidelines/weak_beat_dissonance_treatment.rb +2 -42
  99. data/lib/head_music/style/guides/fifth_species_melody.rb +3 -13
  100. data/lib/head_music/style/guides/fourth_species_melody.rb +3 -13
  101. data/lib/head_music/style/guides/second_species_harmony.rb +2 -5
  102. data/lib/head_music/style/guides/second_species_melody.rb +2 -12
  103. data/lib/head_music/style/guides/species_harmony.rb +14 -0
  104. data/lib/head_music/style/guides/species_melody.rb +21 -0
  105. data/lib/head_music/style/guides/third_species_harmony.rb +2 -5
  106. data/lib/head_music/style/guides/third_species_melody.rb +3 -13
  107. data/lib/head_music/style/guides/third_species_triple_meter_harmony.rb +2 -5
  108. data/lib/head_music/style/guides/third_species_triple_meter_melody.rb +2 -12
  109. data/lib/head_music/time/conductor.rb +19 -125
  110. data/lib/head_music/time/musical_position.rb +14 -26
  111. data/lib/head_music/time/musical_time_converter.rb +104 -0
  112. data/lib/head_music/time/radix_carry.rb +22 -0
  113. data/lib/head_music/time/smpte_converter.rb +51 -0
  114. data/lib/head_music/time/smpte_timecode.rb +13 -23
  115. data/lib/head_music/time.rb +3 -0
  116. data/lib/head_music/value_equality.rb +37 -0
  117. data/lib/head_music/version.rb +1 -1
  118. data/lib/head_music.rb +26 -1
  119. data/user-stories/done/lyrics.md +162 -0
  120. data/user-stories/index.html +7 -3
  121. metadata +45 -6
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d5c0dadcdb3b7bba85e7de810d0d52d960e6bf2b9386b62db9ec201336e084bb
4
- data.tar.gz: 33c3b42e6b07ed88649b6fb0c7b42476c9d45edbc18ef0cc458ff8978b876c4c
3
+ metadata.gz: 5a439a4b39c708eb38984b3878e7bc0c02ce92d7c693d34041ed3b8c69acfffe
4
+ data.tar.gz: d0b702f38b7de8826fe7a345c3b17d0a8ea8c963bd922343889ba97a47f3538e
5
5
  SHA512:
6
- metadata.gz: eb3e9c1f84f2efbf87947a2037b9804cf9e16247be5c180509cb06a9c3c66229513b8cdcc2e10fa1d8f2f7ed952b91de99375e5939de33665aec909331f8f115
7
- data.tar.gz: f871c26bf5da1052474f5317d72788df760f994f3264dbebc6ad35e91b91124088378524b46f4fc50721f6b9442453978018f6c012dd40f55926a66dd516539c
6
+ metadata.gz: 266406dca2449e15a0d72e4ac315190929aa048d18c6236f07d5eea23d5b220dcc037408edb7f5a2a508e677dee5bbf70bb96b620e1bcdcf75df145623efff9e
7
+ data.tar.gz: 81fcf78c8ec6234b1478d0c6ca92acdc096bb714de76e33d1bd19bf21e52fce8c0b3831fa72b2e8ec2cbe4dad21d399886cd0e62c416934fd6778ab08d03308d
@@ -16,7 +16,7 @@ jobs:
16
16
  activesupport-version: ['7.2', '8.0']
17
17
 
18
18
  steps:
19
- - uses: actions/checkout@v4
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@v4
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@v4
60
+ - uses: actions/checkout@v5
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@v4
16
+ - uses: actions/checkout@v5
17
17
 
18
18
  - name: Set up Ruby
19
19
  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@v4
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,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [17.5.0] - 2026-07-21
11
+
12
+ ### Added
13
+
14
+ - 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).
15
+ - 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.
16
+
17
+ ## [17.4.0] - 2026-07-20
18
+
19
+ ### Changed
20
+
21
+ - Internal refactoring for clarity and maintainability, with no changes to public behavior or output. Complex classes were split along their natural seams by extracting focused collaborators and value objects, each with its own spec: `MusicXML::Preflight` and `MusicXML::RenderPlan` from the MusicXML `Writer`; `Instruments::InstrumentName`, `InstrumentCatalog`, and `StaffProfile` from `Instrument`; `Analysis::ChordAnalysis` from `PitchCollection`; `Content::SoundResolver` from `Placement`; `Time::SmpteConverter` and `MusicalTimeConverter` from `Conductor`; `Composition::SchemaValues` from `HashDeserializer`; `Pitch::NaturalStep` from `Pitch`; `Voice::MelodicLine` from `Voice`; `Dyad::ChordImplication` from `Dyad`; and `ABC::Preflight` plus per-voice note-assembly moved onto `ABC::VoiceState` in the ABC parser.
22
+ - Shared value-object equality was consolidated into a `ValueEquality` mixin, and duplication was reduced across the style guides, notation writers, and rudiments.
23
+ - Nested classes that had outgrown their host files were given their own files without changing their constant paths: `Named::Locale` and `Named::LocalizedName`, `Voice::MelodicNotePair`, `Spelling::EnharmonicEquivalence`, and `Style::Annotation::Configured`.
24
+
10
25
  ## [17.3.0] - 2026-07-19
11
26
 
12
27
  ### Added
data/Gemfile.lock CHANGED
@@ -1,9 +1,9 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- head_music (17.3.0)
4
+ head_music (17.5.0)
5
5
  activesupport (>= 7.0, < 10)
6
- humanize (~> 2.0)
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 (3.2.2)
34
- bundler-audit (0.9.2)
35
- bundler (>= 1.2.0, < 3)
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.7)
42
- connection_pool (2.5.3)
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.3.0)
49
- dry-core (~> 1.1)
47
+ dry-configurable (1.4.0)
48
+ dry-core (~> 1.0)
50
49
  zeitwerk (~> 2.6)
51
- dry-core (1.1.0)
50
+ dry-core (1.2.0)
52
51
  concurrent-ruby (~> 1.0)
53
52
  logger
54
53
  zeitwerk (~> 2.6)
55
- dry-inflector (1.2.0)
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.14.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.5)
68
- dry-types (~> 1.8)
66
+ dry-logic (~> 1.6)
67
+ dry-types (~> 1.9, >= 1.9.1)
69
68
  zeitwerk (~> 2.6)
70
- dry-types (1.8.3)
71
- bigdecimal (~> 3.0)
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.13.3)
77
+ flay (2.14.4)
79
78
  erubi (~> 1.10)
80
- path_expander (~> 1.0)
81
- ruby_parser (~> 3.0)
79
+ path_expander (~> 2.0)
80
+ prism (~> 1.7)
82
81
  sexp_processor (~> 4.0)
83
- flog (4.8.0)
84
- path_expander (~> 1.0)
85
- ruby_parser (~> 3.1, > 3.1.0)
82
+ flog (4.9.4)
83
+ path_expander (~> 2.0)
84
+ prism (~> 1.7)
86
85
  sexp_processor (~> 4.8)
87
- humanize (2.5.1)
88
- i18n (1.14.7)
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.1)
93
- rexml (>= 3.3.9)
94
- language_server-protocol (3.17.0.5)
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 (5.25.5)
102
- parallel (1.27.0)
103
- parser (3.3.9.0)
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 (1.1.3)
107
- prism (1.4.0)
108
- public_suffix (6.0.2)
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.3.0)
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.10.0)
120
+ regexp_parser (2.12.0)
119
121
  rexml (3.4.4)
120
- rspec (3.13.1)
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.5)
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.5)
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.4)
136
- rubocop (1.75.8)
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 (~> 1.10)
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.44.0, < 2.0)
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.46.0)
149
+ rubocop-ast (1.50.0)
148
150
  parser (>= 3.3.7.2)
149
- prism (~> 1.4)
150
- rubocop-performance (1.25.0)
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.38.0, < 2.0)
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.6.0)
159
+ rubocop-rspec (3.10.2)
158
160
  lint_roller (~> 1.1)
159
- rubocop (~> 1.72, >= 1.72.1)
161
+ regexp_parser (>= 2.0)
162
+ rubocop (~> 1.86, >= 1.86.2)
160
163
  ruby-progressbar (1.13.0)
161
- ruby_parser (3.21.1)
164
+ ruby_parser (3.22.0)
162
165
  racc (~> 1.5)
163
166
  sexp_processor (~> 4.16)
164
- rubycritic (4.11.0)
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.4)
178
- simplecov (0.22.0)
179
- docile (~> 1.1)
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.75.5)
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.8.0)
193
+ standard-performance (1.9.0)
194
194
  lint_roller (~> 1.1)
195
- rubocop-performance (~> 1.25.0)
196
- thor (1.4.0)
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.1.4)
202
- unicode-emoji (~> 4.0, >= 4.0.4)
203
- unicode-emoji (4.0.4)
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.44)
210
- zeitwerk (2.7.3)
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", "~> 2.0"
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"
@@ -0,0 +1,123 @@
1
+ # A module for musical analysis
2
+ module HeadMusic::Analysis; end
3
+
4
+ # Classifies a PitchCollection as a tertian chord: whether it is a triad,
5
+ # seventh, or extended chord; its triad quality (major/minor/diminished/
6
+ # augmented); and its inversion. "Tertian" means the pitches, once reduced to
7
+ # within an octave and rotated, stack in thirds.
8
+ class HeadMusic::Analysis::ChordAnalysis
9
+ # Scale-degree signatures (above the bass) of each tertian chord size, used
10
+ # to recognize a stack of thirds in any rotation.
11
+ TERTIAN_SONORITIES = {
12
+ implied_triad: [3],
13
+ triad: [3, 5],
14
+ seventh_chord: [3, 5, 7],
15
+ ninth_chord: [2, 3, 5, 7],
16
+ eleventh_chord: [2, 3, 4, 5, 7],
17
+ thirteenth_chord: [2, 3, 4, 5, 6, 7] # a.k.a. diatonic scale
18
+ }.freeze
19
+
20
+ # The interval-shorthand pairs (bass-to-third, third-to-fifth) that spell each
21
+ # triad quality, in every inversion.
22
+ TRIAD_PATTERNS = {
23
+ major: [%w[M3 m3], %w[m3 P4], %w[P4 M3]],
24
+ minor: [%w[m3 M3], %w[M3 P4], %w[P4 m3]],
25
+ diminished: [%w[m3 m3], %w[m3 A4], %w[A4 m3]],
26
+ augmented: [%w[M3 M3], %w[M3 d4], %w[d4 M3]]
27
+ }.freeze
28
+
29
+ attr_reader :collection
30
+
31
+ def initialize(collection)
32
+ @collection = collection
33
+ end
34
+
35
+ def triad?
36
+ collection.trichord? && tertian?
37
+ end
38
+
39
+ def consonant_triad?
40
+ major_triad? || minor_triad?
41
+ end
42
+
43
+ def major_triad?
44
+ triad_type?(:major)
45
+ end
46
+
47
+ def minor_triad?
48
+ triad_type?(:minor)
49
+ end
50
+
51
+ def diminished_triad?
52
+ triad_type?(:diminished)
53
+ end
54
+
55
+ def augmented_triad?
56
+ triad_type?(:augmented)
57
+ end
58
+
59
+ def root_position_triad?
60
+ collection.trichord? && stacked_in_thirds?(collection.reduction)
61
+ end
62
+
63
+ def first_inversion_triad?
64
+ collection.trichord? && stacked_in_thirds?(collection.reduction.uninvert)
65
+ end
66
+
67
+ def second_inversion_triad?
68
+ collection.trichord? && stacked_in_thirds?(collection.reduction.invert)
69
+ end
70
+
71
+ def seventh_chord?
72
+ collection.tetrachord? && tertian?
73
+ end
74
+
75
+ def root_position_seventh_chord?
76
+ collection.tetrachord? && stacked_in_thirds?(collection.reduction)
77
+ end
78
+
79
+ def first_inversion_seventh_chord?
80
+ collection.tetrachord? && stacked_in_thirds?(collection.reduction.uninvert)
81
+ end
82
+
83
+ def second_inversion_seventh_chord?
84
+ collection.tetrachord? && stacked_in_thirds?(collection.reduction.uninvert.uninvert)
85
+ end
86
+
87
+ def third_inversion_seventh_chord?
88
+ collection.tetrachord? && stacked_in_thirds?(collection.reduction.invert)
89
+ end
90
+
91
+ def ninth_chord?
92
+ collection.pentachord? && tertian?
93
+ end
94
+
95
+ def eleventh_chord?
96
+ collection.hexachord? && tertian?
97
+ end
98
+
99
+ def thirteenth_chord?
100
+ collection.heptachord? && tertian?
101
+ end
102
+
103
+ def tertian?
104
+ return false unless collection.diatonic_intervals.any?
105
+
106
+ inversion = collection.reduction
107
+ collection.pitches.length.times do
108
+ return true if TERTIAN_SONORITIES.value?(inversion.scale_degrees_above_bass_pitch)
109
+ inversion = inversion.invert
110
+ end
111
+ false
112
+ end
113
+
114
+ private
115
+
116
+ def triad_type?(type)
117
+ TRIAD_PATTERNS[type].include?(collection.reduction_diatonic_intervals.map(&:shorthand))
118
+ end
119
+
120
+ def stacked_in_thirds?(reduced_collection)
121
+ reduced_collection.diatonic_intervals.all?(&:third?)
122
+ end
123
+ end
@@ -46,14 +46,22 @@ class HeadMusic::Analysis::Circle < HeadMusic::Analysis::IntervalCycle
46
46
  def pitches_down
47
47
  @pitches_down ||= [starting_pitch].tap do |list|
48
48
  loop do
49
- next_pitch = list.last - interval
50
- next_pitch += octave while starting_pitch - next_pitch > 12
51
- break if next_pitch.pitch_class == list.first.pitch_class
49
+ next_pitch = folded_down(list.last)
50
+ break if next_pitch.pitch_class == starting_pitch.pitch_class
52
51
 
53
52
  list << next_pitch
54
53
  end
55
54
  end
56
55
  end
57
56
 
57
+ private
58
+
59
+ # The next pitch a cycle-interval below, folded up by octaves to stay within an octave of the start.
60
+ def folded_down(pitch)
61
+ next_pitch = pitch - interval
62
+ next_pitch += octave while starting_pitch - next_pitch > 12
63
+ next_pitch
64
+ end
65
+
58
66
  private_class_method :new
59
67
  end
@@ -59,15 +59,17 @@ class HeadMusic::Analysis::DiatonicInterval
59
59
 
60
60
  # Override Named module method to try I18n and fall back to computed name
61
61
  def name(locale_code: nil)
62
- if locale_code
63
- name_key = HeadMusic::Utilities::HashKey.for(naming.name)
64
- if I18n.backend.translations[locale_code]
65
- locale_data = I18n.backend.translations[locale_code][:head_music] || {}
66
- return locale_data[:diatonic_intervals][name_key] if locale_data.dig(:diatonic_intervals, name_key)
67
- return locale_data[:chromatic_intervals][name_key] if locale_data.dig(:chromatic_intervals, name_key)
68
- end
69
- end
70
- naming.name
62
+ computed_name = naming.name
63
+ return computed_name unless locale_code
64
+
65
+ translations = I18n.backend.translations[locale_code]
66
+ return computed_name unless translations
67
+
68
+ name_key = HeadMusic::Utilities::HashKey.for(computed_name)
69
+ locale_data = translations[:head_music] || {}
70
+ locale_data.dig(:diatonic_intervals, name_key) ||
71
+ locale_data.dig(:chromatic_intervals, name_key) ||
72
+ computed_name
71
73
  end
72
74
 
73
75
  def to_s
@@ -79,8 +81,9 @@ class HeadMusic::Analysis::DiatonicInterval
79
81
  if identifier.is_a?(String) || identifier.is_a?(Symbol)
80
82
  name = Parser.new(identifier)
81
83
  semitones = Semitones.new(name.degree_name.to_sym, name.quality_name).count
82
- higher_pitch = HeadMusic::Rudiment::Pitch.from_number_and_letter(HeadMusic::Rudiment::Pitch.middle_c + semitones, name.higher_letter)
83
- interval = new(HeadMusic::Rudiment::Pitch.middle_c, higher_pitch)
84
+ middle_c = HeadMusic::Rudiment::Pitch.middle_c
85
+ higher_pitch = HeadMusic::Rudiment::Pitch.from_number_and_letter(middle_c + semitones, name.higher_letter)
86
+ interval = new(middle_c, higher_pitch)
84
87
  interval.ensure_localized_name(name: identifier.to_s)
85
88
  interval
86
89
  else
@@ -0,0 +1,101 @@
1
+ class HeadMusic::Analysis::Dyad
2
+ # Enumerates the chords (as PitchCollections) that contain a given dyad.
3
+ # Trichords and seventh chords are built at every chromatic root from a fixed
4
+ # table of interval stacks, then kept only when they actually span the dyad.
5
+ # An optional key narrows the results to diatonic chords and ranks the
6
+ # survivors by how many of their pitches the key contains.
7
+ class ChordImplication
8
+ TRICHORD_INTERVALS = [
9
+ %w[M3 P5], # major triad
10
+ %w[m3 P5], # minor triad
11
+ %w[m3 d5], # diminished triad
12
+ %w[M3 A5], # augmented triad
13
+ %w[P4 P5], # sus4 (not a triad)
14
+ %w[M2 P5] # sus2 (not a triad)
15
+ ].freeze
16
+
17
+ SEVENTH_CHORD_INTERVALS = [
18
+ %w[M3 P5 M7], # major seventh
19
+ %w[M3 P5 m7], # dominant seventh (major-minor)
20
+ %w[m3 P5 m7], # minor seventh
21
+ %w[m3 P5 M7], # minor-major seventh
22
+ %w[m3 d5 m7], # half-diminished seventh
23
+ %w[m3 d5 d7], # diminished seventh
24
+ %w[M2 M3 P5 m7], # dominant ninth
25
+ %w[m2 M3 P5 m7], # dominant minor ninth
26
+ %w[M2 m3 P5 m7], # minor ninth
27
+ %w[M2 M3 P5 M7] # major ninth
28
+ ].freeze
29
+
30
+ attr_reader :dyad_pitch_classes, :key
31
+
32
+ def initialize(dyad_pitch_classes, key)
33
+ @dyad_pitch_classes = dyad_pitch_classes
34
+ @key = key
35
+ end
36
+
37
+ def trichords
38
+ @trichords ||= ranked(chords_from(TRICHORD_INTERVALS))
39
+ end
40
+
41
+ def seventh_chords
42
+ @seventh_chords ||= ranked(chords_from(SEVENTH_CHORD_INTERVALS))
43
+ end
44
+
45
+ private
46
+
47
+ # Without a key every chord stands; with one, keep the diatonic chords and
48
+ # order them by how much they agree with the key. Concentrating the key
49
+ # check here keeps filter and sort free of their own guards.
50
+ def ranked(chords)
51
+ return chords unless key
52
+
53
+ sort_by_diatonic_agreement(filter_by_key(chords))
54
+ end
55
+
56
+ def chords_from(interval_sets)
57
+ candidate_chords(interval_sets)
58
+ .select { |chord| includes_dyad?(chord) }
59
+ .uniq { |chord| chord.pitch_classes.sort.map(&:to_i) }
60
+ end
61
+
62
+ def candidate_chords(interval_sets)
63
+ candidate_roots.flat_map do |root_pitch|
64
+ interval_sets.map { |intervals| build_chord(root_pitch, intervals) }
65
+ end
66
+ end
67
+
68
+ def candidate_roots
69
+ HeadMusic::Rudiment::Spelling::CHROMATIC_SPELLINGS.map do |root_spelling|
70
+ HeadMusic::Rudiment::Pitch.get("#{root_spelling}4")
71
+ end
72
+ end
73
+
74
+ def build_chord(root_pitch, intervals)
75
+ chord_pitches = [root_pitch] + intervals.map do |name|
76
+ HeadMusic::Analysis::DiatonicInterval.get(name).above(root_pitch)
77
+ end
78
+ HeadMusic::Analysis::PitchCollection.new(chord_pitches)
79
+ end
80
+
81
+ def includes_dyad?(pitch_collection)
82
+ dyad_pitch_classes.all? { |pitch_class| pitch_collection.pitch_classes.include?(pitch_class) }
83
+ end
84
+
85
+ def filter_by_key(pitch_collections)
86
+ pitch_collections.select do |pitch_collection|
87
+ pitch_collection.pitches.all? { |pitch| diatonic_spellings.include?(pitch.spelling) }
88
+ end
89
+ end
90
+
91
+ def sort_by_diatonic_agreement(pitch_collections)
92
+ pitch_collections.sort_by do |pitch_collection|
93
+ -pitch_collection.pitches.count { |pitch| diatonic_spellings.include?(pitch.spelling) }
94
+ end
95
+ end
96
+
97
+ def diatonic_spellings
98
+ @diatonic_spellings ||= key.scale.spellings
99
+ end
100
+ end
101
+ end