head_music 9.0.1 → 11.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 (102) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +9 -3
  3. data/CHANGELOG.md +18 -0
  4. data/CLAUDE.md +35 -15
  5. data/Gemfile +7 -1
  6. data/Gemfile.lock +91 -3
  7. data/README.md +18 -0
  8. data/Rakefile +7 -2
  9. data/head_music.gemspec +1 -1
  10. data/lib/head_music/analysis/dyad.rb +229 -0
  11. data/lib/head_music/analysis/melodic_interval.rb +1 -1
  12. data/lib/head_music/analysis/pitch_class_set.rb +111 -14
  13. data/lib/head_music/analysis/{pitch_set.rb → pitch_collection.rb} +11 -5
  14. data/lib/head_music/analysis/sonority.rb +50 -12
  15. data/lib/head_music/content/staff.rb +1 -1
  16. data/lib/head_music/content/voice.rb +1 -1
  17. data/lib/head_music/instruments/alternate_tuning.rb +102 -0
  18. data/lib/head_music/instruments/alternate_tunings.yml +78 -0
  19. data/lib/head_music/instruments/instrument.rb +251 -82
  20. data/lib/head_music/instruments/instrument_configuration.rb +66 -0
  21. data/lib/head_music/instruments/instrument_configuration_option.rb +38 -0
  22. data/lib/head_music/instruments/instrument_configurations.yml +288 -0
  23. data/lib/head_music/instruments/instrument_families.yml +77 -0
  24. data/lib/head_music/instruments/instrument_family.rb +3 -4
  25. data/lib/head_music/instruments/instruments.yml +795 -965
  26. data/lib/head_music/instruments/playing_technique.rb +75 -0
  27. data/lib/head_music/instruments/playing_techniques.yml +826 -0
  28. data/lib/head_music/instruments/score_order.rb +2 -5
  29. data/lib/head_music/instruments/staff.rb +61 -1
  30. data/lib/head_music/instruments/staff_scheme.rb +6 -4
  31. data/lib/head_music/instruments/stringing.rb +115 -0
  32. data/lib/head_music/instruments/stringing_course.rb +58 -0
  33. data/lib/head_music/instruments/stringings.yml +168 -0
  34. data/lib/head_music/instruments/variant.rb +0 -1
  35. data/lib/head_music/locales/de.yml +23 -0
  36. data/lib/head_music/locales/en.yml +100 -0
  37. data/lib/head_music/locales/es.yml +23 -0
  38. data/lib/head_music/locales/fr.yml +23 -0
  39. data/lib/head_music/locales/it.yml +23 -0
  40. data/lib/head_music/locales/ru.yml +23 -0
  41. data/lib/head_music/{rudiment → notation}/musical_symbol.rb +3 -3
  42. data/lib/head_music/notation/staff_mapping.rb +70 -0
  43. data/lib/head_music/notation/staff_position.rb +62 -0
  44. data/lib/head_music/notation.rb +7 -0
  45. data/lib/head_music/rudiment/alteration.rb +17 -47
  46. data/lib/head_music/rudiment/alterations.yml +32 -0
  47. data/lib/head_music/rudiment/chromatic_interval.rb +1 -1
  48. data/lib/head_music/rudiment/clef.rb +1 -1
  49. data/lib/head_music/rudiment/consonance.rb +14 -13
  50. data/lib/head_music/rudiment/key_signature.rb +0 -26
  51. data/lib/head_music/rudiment/rhythmic_unit/parser.rb +2 -2
  52. data/lib/head_music/rudiment/rhythmic_value/parser.rb +1 -1
  53. data/lib/head_music/rudiment/rhythmic_value.rb +1 -1
  54. data/lib/head_music/rudiment/spelling.rb +3 -0
  55. data/lib/head_music/rudiment/tempo.rb +1 -1
  56. data/lib/head_music/rudiment/tuning/just_intonation.rb +0 -39
  57. data/lib/head_music/rudiment/tuning/meantone.rb +0 -39
  58. data/lib/head_music/rudiment/tuning/pythagorean.rb +0 -39
  59. data/lib/head_music/rudiment/tuning.rb +20 -0
  60. data/lib/head_music/style/guidelines/consonant_climax.rb +2 -2
  61. data/lib/head_music/style/modern_tradition.rb +8 -11
  62. data/lib/head_music/style/tradition.rb +1 -1
  63. data/lib/head_music/time/clock_position.rb +84 -0
  64. data/lib/head_music/time/conductor.rb +264 -0
  65. data/lib/head_music/time/meter_event.rb +37 -0
  66. data/lib/head_music/time/meter_map.rb +173 -0
  67. data/lib/head_music/time/musical_position.rb +188 -0
  68. data/lib/head_music/time/smpte_timecode.rb +164 -0
  69. data/lib/head_music/time/tempo_event.rb +40 -0
  70. data/lib/head_music/time/tempo_map.rb +187 -0
  71. data/lib/head_music/time.rb +32 -0
  72. data/lib/head_music/utilities/case.rb +27 -0
  73. data/lib/head_music/utilities/hash_key.rb +1 -1
  74. data/lib/head_music/version.rb +1 -1
  75. data/lib/head_music.rb +41 -13
  76. data/user_stories/active/string-pitches.md +41 -0
  77. data/user_stories/backlog/notation-style.md +183 -0
  78. data/user_stories/{todo → backlog}/organizing-content.md +9 -1
  79. data/user_stories/done/consonance-dissonance-classification.md +117 -0
  80. data/user_stories/{todo → done}/dyad-analysis.md +4 -6
  81. data/user_stories/done/expand-playing-techniques.md +38 -0
  82. data/user_stories/{active → done}/handle-time.rb +5 -19
  83. data/user_stories/done/instrument-architecture.md +238 -0
  84. data/user_stories/done/move-musical-symbol-to-notation.md +161 -0
  85. data/user_stories/done/move-staff-mapping-to-notation.md +158 -0
  86. data/user_stories/done/move-staff-position-to-notation.md +141 -0
  87. data/user_stories/done/notation-module-foundation.md +102 -0
  88. data/user_stories/done/percussion_set.md +260 -0
  89. data/user_stories/{todo → done}/pitch-class-set-analysis.md +0 -40
  90. data/user_stories/done/sonority-identification.md +37 -0
  91. data/user_stories/epics/notation-module.md +135 -0
  92. data/user_stories/{todo → visioning}/agentic-daw.md +0 -1
  93. metadata +55 -20
  94. data/check_instrument_consistency.rb +0 -0
  95. data/lib/head_music/instruments/instrument_type.rb +0 -188
  96. data/test_translations.rb +0 -15
  97. data/user_stories/todo/consonance-dissonance-classification.md +0 -57
  98. data/user_stories/todo/material-and-scores.md +0 -10
  99. data/user_stories/todo/percussion_set.md +0 -1
  100. data/user_stories/todo/pitch-set-classification.md +0 -72
  101. data/user_stories/todo/sonority-identification.md +0 -67
  102. /data/user_stories/{active → done}/handle-time.md +0 -0
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 012ce64dd4174cbeec42f1d4825e9fd10af79ed8f4b83a3beedbc14a9aea34fa
4
- data.tar.gz: a31dbb0b6b3d732bd41e1dbfe6a042a8395db05832d58848ec4d53e5ae4a4524
3
+ metadata.gz: c3abf9e12840eb88aee1cb454116cbd092f224eb1961cfda5567444ad9e91d2f
4
+ data.tar.gz: e7680154e971414788fc8bd1a87f53111bc82c9452f4df5a8ad47a74b1c5c16d
5
5
  SHA512:
6
- metadata.gz: a98367425dcfb4899b24ef2e7986a7fbc594202c960b553ab35d3cecd5ee0b5ecb10f65e1e458fe2d1797b70eded378fd319b2cbfc7b9a5a2e393773ce75f06b
7
- data.tar.gz: d3baf95b28de6c2df0e3d14250ed9c335cb3793d598eb7cf1c417dd40b6463bb57ddcda19be309a1045e835809ad3b9ad45dc0fd21797563319069da35db76bc
6
+ metadata.gz: 199c8cdba49e5d6e15e7047a8bb8ee28eaa5c8087c9f427916d087d4ce8986f04cf7dd354666247b389836256230d5c3535ff5fd79964443626e397c0cdb3288
7
+ data.tar.gz: d793d6730a790b088fcc7906bc6cb02b865364852767534c15c1b76b40622ae6a12b23a2bde5f651687e9ce124941501ae8d1f17f9a63a7c8c473a2112914024
@@ -12,7 +12,8 @@ jobs:
12
12
  strategy:
13
13
  fail-fast: false
14
14
  matrix:
15
- ruby-version: ['3.3.0', '3.3', '3.4']
15
+ ruby-version: ['3.3', '3.4']
16
+ activesupport-version: ['7.2', '8.0']
16
17
 
17
18
  steps:
18
19
  - uses: actions/checkout@v4
@@ -21,13 +22,18 @@ jobs:
21
22
  uses: ruby/setup-ruby@v1
22
23
  with:
23
24
  ruby-version: ${{ matrix.ruby-version }}
24
- bundler-cache: true # runs 'bundle install' and caches installed gems
25
+ bundler-cache: false
26
+
27
+ - name: Install dependencies with ActiveSupport ${{ matrix.activesupport-version }}
28
+ run: |
29
+ bundle config set --local path vendor/bundle
30
+ ACTIVESUPPORT_VERSION="${{ matrix.activesupport-version }}" bundle install
25
31
 
26
32
  - name: Run tests
27
33
  run: bundle exec rspec
28
34
 
29
35
  - name: Upload coverage to Codecov
30
- if: matrix.ruby-version == '3.3.0'
36
+ if: matrix.ruby-version == '3.3' && matrix.activesupport-version == '8.0'
31
37
  uses: codecov/codecov-action@v5
32
38
  with:
33
39
  token: ${{ secrets.CODECOV_TOKEN }}
data/CHANGELOG.md CHANGED
@@ -7,6 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [11.0.0] - 2026-01-05
11
+
12
+ ### Changed
13
+ - **BREAKING**: Widened ActiveSupport dependency from `~> 7.0` to `>= 7.0, < 10` to support Rails 8.x
14
+ - Improved I18n initialization to be non-destructive:
15
+ - No longer overwrites `I18n.default_locale` (allows Rails apps to control their default)
16
+ - Adds HeadMusic locales to `available_locales` instead of replacing them
17
+ - Only sets fallbacks if not already configured by the application
18
+ - Updated CI workflow to test against both ActiveSupport 7.x and 8.x
19
+
20
+ ### Fixed
21
+ - Fixed compatibility issue with Rails 8.1.x applications
22
+
23
+ ## [10.0.0] - 2025-12-01
24
+
25
+ ### Changed
26
+ - Internal release for testing
27
+
10
28
  ## [9.0.0] - 2025-10-24
11
29
 
12
30
  ### Added
data/CLAUDE.md CHANGED
@@ -43,7 +43,7 @@ bundle exec rake doc_stats
43
43
 
44
44
  ### Git Etiquette
45
45
 
46
- Do not make a commit unless I ask you to.
46
+ **IMPORTANT: Do not make a commit unless I explicitly ask you to.** Wait for explicit instruction before running `git commit`.
47
47
 
48
48
  When composing git commit messages, follow best-practices. However, do not mention yourself (claude) or list yourself as a co-author.
49
49
 
@@ -72,22 +72,35 @@ Tests are written in RSpec and located in the `/spec` directory, mirroring the `
72
72
  The codebase follows a domain-driven design with clear module boundaries:
73
73
 
74
74
  1. **HeadMusic::Rudiment** - Core music theory elements
75
- - Pitch, Note, Scale, Key, Interval, Chord
75
+ - Abstract concepts: pitch, interval, scale, chord, key
76
+ - Duration concepts (without visual representation)
76
77
  - Factory methods: `.get()` for most rudiments
77
78
 
78
- 2. **HeadMusic::Analysis** - Musical analysis tools
79
- - Intervals, Chords, Motion analysis
80
- - Harmonic and melodic analysis
81
-
82
- 3. **HeadMusic::Content** - Musical composition representation
83
- - Composition, Voice, Note, Rest
84
- - Rhythmic values and time signatures
79
+ 2. **HeadMusic::Notation** - Visual music notation and representation
80
+ - Staff positions, lines, spaces, ledger lines
81
+ - Musical symbols (ASCII, Unicode, HTML entities)
82
+ - Clef placement and rendering
83
+ - Notehead shapes, stems, flags, beams
84
+ - Accidental placement rules
85
+ - Future: ties, slurs, articulations, dynamics
85
86
 
86
- 4. **HeadMusic::Instruments** - Instrument definitions
87
- - Instrument families and properties
87
+ 3. **HeadMusic::Instruments** - Instrument definitions
88
+ - Instrument families and classification
88
89
  - Pitch ranges and transposition
90
+ - Playing techniques
91
+ - Score ordering
92
+
93
+ 4. **HeadMusic::Content** - Musical composition representation
94
+ - Compositions, voices, bars, positions
95
+ - Notes in context (pitch + duration + placement)
96
+ - Temporal organization
97
+
98
+ 5. **HeadMusic::Analysis** - Musical analysis tools
99
+ - Intervals, chords, motion analysis
100
+ - Harmonic and melodic analysis
101
+ - Pitch class sets and collections
89
102
 
90
- 5. **HeadMusic::Style** - Composition rules and guidelines
103
+ 6. **HeadMusic::Style** - Composition rules and guidelines
91
104
  - Counterpoint rules
92
105
  - Voice leading guidelines
93
106
  - Style analysis
@@ -121,11 +134,19 @@ The gem supports multiple languages through the HeadMusic::Named mixin:
121
134
  - Shared examples in `spec/support/`
122
135
  - `composition_context.rb` provides test utilities
123
136
 
137
+ ### Documentation Philosophy
138
+
139
+ This project deliberately deprioritizes formal documentation in favor of clear, comprehensive tests.
140
+
141
+ - **Tests serve as documentation**: RSpec specs demonstrate how to use the code
142
+ - **Comments explain "why", not "what"**: Only add comments when the implementation is surprising or non-obvious
143
+ - **No YARD documentation required**: The code should be self-explanatory through clear naming and test examples
144
+ - Documentation tools like `rake doc` and `rake doc_stats` exist but low coverage is intentional
145
+
124
146
  ### Code Style
125
147
 
126
148
  - Ruby 3.3.0+ features are allowed
127
149
  - Follow Standard Ruby style guide
128
- - Use YARD documentation format for public methods
129
150
  - Prefer delegation over inheritance
130
151
  - Always run `bundle exec rubocop -a` after editing ruby code
131
152
 
@@ -143,8 +164,7 @@ The gem supports multiple languages through the HeadMusic::Named mixin:
143
164
 
144
165
  1. Check for dependent classes that might be affected
145
166
  2. Run tests for the specific module: `bundle exec rspec spec/head_music/[module_name]`
146
- 3. Update documentation with YARD comments
147
- 4. Ensure translations are updated if names change
167
+ 3. Ensure translations are updated if names change
148
168
 
149
169
  ## Music theory and concepts
150
170
 
data/Gemfile CHANGED
@@ -1,10 +1,15 @@
1
1
  source "https://rubygems.org"
2
2
 
3
- ruby "3.3.0"
3
+ ruby ">= 3.3.0"
4
4
 
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
12
+
8
13
  gem "standard", require: false
9
14
 
10
15
  group :test do
@@ -16,6 +21,7 @@ end
16
21
 
17
22
  group :development do
18
23
  gem "bundler-audit", require: false
24
+ gem "rubycritic", require: false
19
25
  gem "yard", require: false
20
26
  gem "kramdown", require: false
21
27
  end
data/Gemfile.lock CHANGED
@@ -1,8 +1,8 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- head_music (9.0.0)
5
- activesupport (~> 7.0)
4
+ head_music (11.0.0)
5
+ activesupport (>= 7.0, < 10)
6
6
  humanize (~> 2.0)
7
7
  i18n (~> 1.8)
8
8
 
@@ -21,25 +21,81 @@ GEM
21
21
  minitest (>= 5.1)
22
22
  securerandom (>= 0.3)
23
23
  tzinfo (~> 2.0, >= 2.0.5)
24
+ addressable (2.8.7)
25
+ public_suffix (>= 2.0.2, < 7.0)
24
26
  ast (2.4.3)
27
+ axiom-types (0.1.1)
28
+ descendants_tracker (~> 0.0.4)
29
+ ice_nine (~> 0.11.0)
30
+ thread_safe (~> 0.3, >= 0.3.1)
25
31
  base64 (0.3.0)
26
32
  benchmark (0.4.1)
27
33
  bigdecimal (3.2.2)
28
34
  bundler-audit (0.9.2)
29
35
  bundler (>= 1.2.0, < 3)
30
36
  thor (~> 1.0)
37
+ childprocess (5.1.0)
38
+ logger (~> 1.5)
39
+ coercible (1.0.0)
40
+ descendants_tracker (~> 0.0.1)
31
41
  concurrent-ruby (1.3.5)
32
42
  connection_pool (2.5.3)
43
+ descendants_tracker (0.0.4)
44
+ thread_safe (~> 0.3, >= 0.3.1)
33
45
  diff-lcs (1.6.2)
34
46
  docile (1.4.1)
35
47
  drb (2.2.3)
48
+ dry-configurable (1.3.0)
49
+ dry-core (~> 1.1)
50
+ zeitwerk (~> 2.6)
51
+ dry-core (1.1.0)
52
+ concurrent-ruby (~> 1.0)
53
+ logger
54
+ zeitwerk (~> 2.6)
55
+ dry-inflector (1.2.0)
56
+ dry-initializer (3.2.0)
57
+ dry-logic (1.6.0)
58
+ bigdecimal
59
+ concurrent-ruby (~> 1.0)
60
+ dry-core (~> 1.1)
61
+ zeitwerk (~> 2.6)
62
+ dry-schema (1.14.1)
63
+ concurrent-ruby (~> 1.0)
64
+ dry-configurable (~> 1.0, >= 1.0.1)
65
+ dry-core (~> 1.1)
66
+ dry-initializer (~> 3.2)
67
+ dry-logic (~> 1.5)
68
+ dry-types (~> 1.8)
69
+ zeitwerk (~> 2.6)
70
+ dry-types (1.8.3)
71
+ bigdecimal (~> 3.0)
72
+ concurrent-ruby (~> 1.0)
73
+ dry-core (~> 1.0)
74
+ dry-inflector (~> 1.0)
75
+ dry-logic (~> 1.4)
76
+ zeitwerk (~> 2.6)
77
+ erubi (1.13.1)
78
+ flay (2.13.3)
79
+ erubi (~> 1.10)
80
+ path_expander (~> 1.0)
81
+ ruby_parser (~> 3.0)
82
+ sexp_processor (~> 4.0)
83
+ flog (4.8.0)
84
+ path_expander (~> 1.0)
85
+ ruby_parser (~> 3.1, > 3.1.0)
86
+ sexp_processor (~> 4.8)
36
87
  humanize (2.5.1)
37
88
  i18n (1.14.7)
38
89
  concurrent-ruby (~> 1.0)
90
+ ice_nine (0.11.2)
39
91
  json (2.13.1)
40
92
  kramdown (2.5.1)
41
93
  rexml (>= 3.3.9)
42
94
  language_server-protocol (3.17.0.5)
95
+ launchy (3.1.1)
96
+ addressable (~> 2.8)
97
+ childprocess (~> 5.0)
98
+ logger (~> 1.6)
43
99
  lint_roller (1.1.0)
44
100
  logger (1.7.0)
45
101
  minitest (5.25.5)
@@ -47,12 +103,20 @@ GEM
47
103
  parser (3.3.9.0)
48
104
  ast (~> 2.4.1)
49
105
  racc
106
+ path_expander (1.1.3)
50
107
  prism (1.4.0)
108
+ public_suffix (6.0.2)
51
109
  racc (1.8.1)
52
110
  rainbow (3.1.1)
53
111
  rake (13.3.0)
112
+ reek (6.5.0)
113
+ dry-schema (~> 1.13)
114
+ logger (~> 1.6)
115
+ parser (~> 3.3.0)
116
+ rainbow (>= 2.0, < 4.0)
117
+ rexml (~> 3.1)
54
118
  regexp_parser (2.10.0)
55
- rexml (3.4.1)
119
+ rexml (3.4.4)
56
120
  rspec (3.13.1)
57
121
  rspec-core (~> 3.13.0)
58
122
  rspec-expectations (~> 3.13.0)
@@ -94,7 +158,23 @@ GEM
94
158
  lint_roller (~> 1.1)
95
159
  rubocop (~> 1.72, >= 1.72.1)
96
160
  ruby-progressbar (1.13.0)
161
+ ruby_parser (3.21.1)
162
+ racc (~> 1.5)
163
+ sexp_processor (~> 4.16)
164
+ rubycritic (4.11.0)
165
+ flay (~> 2.13)
166
+ flog (~> 4.7)
167
+ launchy (>= 2.5.2)
168
+ parser (>= 3.3.0.5)
169
+ rainbow (~> 3.1.1)
170
+ reek (~> 6.5.0, < 7.0)
171
+ rexml
172
+ ruby_parser (~> 3.21)
173
+ simplecov (>= 0.22.0)
174
+ tty-which (~> 0.5.0)
175
+ virtus (~> 2.0)
97
176
  securerandom (0.4.1)
177
+ sexp_processor (4.17.4)
98
178
  simplecov (0.22.0)
99
179
  docile (~> 1.1)
100
180
  simplecov-html (~> 0.11)
@@ -114,12 +194,19 @@ GEM
114
194
  lint_roller (~> 1.1)
115
195
  rubocop-performance (~> 1.25.0)
116
196
  thor (1.4.0)
197
+ thread_safe (0.3.6)
198
+ tty-which (0.5.0)
117
199
  tzinfo (2.0.6)
118
200
  concurrent-ruby (~> 1.0)
119
201
  unicode-display_width (3.1.4)
120
202
  unicode-emoji (~> 4.0, >= 4.0.4)
121
203
  unicode-emoji (4.0.4)
204
+ virtus (2.0.0)
205
+ axiom-types (~> 0.1)
206
+ coercible (~> 1.0)
207
+ descendants_tracker (~> 0.0, >= 0.0.3)
122
208
  yard (0.9.37)
209
+ zeitwerk (2.7.3)
123
210
 
124
211
  PLATFORMS
125
212
  arm64-darwin-22
@@ -136,6 +223,7 @@ DEPENDENCIES
136
223
  rubocop
137
224
  rubocop-rake
138
225
  rubocop-rspec
226
+ rubycritic
139
227
  simplecov
140
228
  standard
141
229
  yard
data/README.md CHANGED
@@ -101,6 +101,24 @@ bundle exec rake doc
101
101
  - `rake doc_stats` - Show documentation coverage statistics
102
102
  - `rake coverage` - Open coverage report in browser
103
103
 
104
+ ### Releasing a New Version
105
+
106
+ 1. Update the version number in `lib/head_music/version.rb`
107
+ 2. Commit the version change: `git commit -am "Bump version to X.Y.Z"`
108
+ 3. Push to main: `git push origin main`
109
+ 4. Release the gem:
110
+
111
+ ```bash
112
+ bundle exec rake release
113
+ ```
114
+
115
+ This will:
116
+ - Build the gem
117
+ - Create and push a git tag (e.g., `vX.Y.Z`)
118
+ - Push the gem to RubyGems
119
+
120
+ The git tag push also triggers a GitHub Actions workflow that creates a GitHub Release with auto-generated release notes.
121
+
104
122
  ## Contributing
105
123
 
106
124
  We welcome contributions! Please see our [Contributing Guide](CONTRIBUTING.md) for details.
data/Rakefile CHANGED
@@ -26,10 +26,15 @@ rescue LoadError
26
26
  # bundler-audit not available
27
27
  end
28
28
 
29
+ desc "Run RubyCritic code quality analysis"
30
+ task :rubycritic do
31
+ sh "rubycritic lib"
32
+ end
33
+
29
34
  task default: :spec
30
35
 
31
- desc "Run all quality checks (tests, linting, security audit)"
32
- task quality: [:spec, :standard, "bundle:audit:check"]
36
+ desc "Run all quality checks (tests, linting, security audit, code quality)"
37
+ task quality: [:spec, :standard, "bundle:audit:check", :rubycritic]
33
38
 
34
39
  desc "Open an irb session preloaded with this library"
35
40
  task :console do
data/head_music.gemspec CHANGED
@@ -32,7 +32,7 @@ Gem::Specification.new do |spec|
32
32
 
33
33
  spec.required_ruby_version = ">= 3.3.0"
34
34
 
35
- spec.add_runtime_dependency "activesupport", "~> 7.0"
35
+ spec.add_runtime_dependency "activesupport", ">= 7.0", "< 10"
36
36
  spec.add_runtime_dependency "humanize", "~> 2.0"
37
37
  spec.add_runtime_dependency "i18n", "~> 1.8"
38
38
 
@@ -0,0 +1,229 @@
1
+ # A module for musical analysis
2
+ module HeadMusic::Analysis; end
3
+
4
+ # A Dyad is a two-pitch combination that can imply various chords.
5
+ # It analyzes the harmonic implications of two pitches sounding together.
6
+ class HeadMusic::Analysis::Dyad
7
+ attr_reader :pitch1, :pitch2, :key
8
+
9
+ def initialize(pitch1, pitch2, key: nil)
10
+ @pitch1, @pitch2 = [
11
+ HeadMusic::Rudiment::Pitch.get(pitch1),
12
+ HeadMusic::Rudiment::Pitch.get(pitch2)
13
+ ].sort
14
+ @key = key ? HeadMusic::Rudiment::Key.get(key) : nil
15
+ end
16
+
17
+ def interval
18
+ @interval ||= HeadMusic::Analysis::DiatonicInterval.new(lower_pitch, upper_pitch)
19
+ end
20
+
21
+ def pitches
22
+ [pitch1, pitch2]
23
+ end
24
+
25
+ def lower_pitch
26
+ @lower_pitch ||= [pitch1, pitch2].min
27
+ end
28
+
29
+ def upper_pitch
30
+ @upper_pitch ||= [pitch1, pitch2].max
31
+ end
32
+
33
+ def possible_trichords
34
+ @possible_trichords ||= begin
35
+ trichords = generate_possible_trichords
36
+ trichords = filter_by_key(trichords) if key
37
+ sort_by_diatonic_agreement(trichords)
38
+ end
39
+ end
40
+
41
+ def possible_triads
42
+ @possible_triads ||= possible_trichords.select(&:triad?)
43
+ end
44
+
45
+ def possible_seventh_chords
46
+ @possible_seventh_chords ||= begin
47
+ seventh_chords = generate_possible_seventh_chords
48
+ seventh_chords = filter_by_key(seventh_chords) if key
49
+ sort_by_diatonic_agreement(seventh_chords)
50
+ end
51
+ end
52
+
53
+ def enharmonic_respellings
54
+ @enharmonic_respellings ||= generate_enharmonic_respellings
55
+ end
56
+
57
+ def to_s
58
+ "#{pitch1} - #{pitch2}"
59
+ end
60
+
61
+ def method_missing(method_name, *args, &block)
62
+ respond_to_missing?(method_name) ? interval.send(method_name, *args, &block) : super
63
+ end
64
+
65
+ def respond_to_missing?(method_name, *_args)
66
+ interval.respond_to?(method_name)
67
+ end
68
+
69
+ private
70
+
71
+ def generate_possible_trichords
72
+ trichords = []
73
+ pitch_classes = [lower_pitch.pitch_class, upper_pitch.pitch_class]
74
+
75
+ HeadMusic::Rudiment::Spelling::CHROMATIC_SPELLINGS.each do |root_spelling|
76
+ root_pitch = HeadMusic::Rudiment::Pitch.get("#{root_spelling}4")
77
+
78
+ # Try all common trichord types from this root
79
+ trichord_intervals = [
80
+ %w[M3 P5], # major triad
81
+ %w[m3 P5], # minor triad
82
+ %w[m3 d5], # diminished triad
83
+ %w[M3 A5], # augmented triad
84
+ %w[P4 P5], # sus4 (not a triad)
85
+ %w[M2 P5] # sus2 (not a triad)
86
+ ]
87
+
88
+ trichord_intervals.each do |intervals|
89
+ trichord_pitches = [root_pitch]
90
+
91
+ # Each interval is FROM THE ROOT, not consecutive
92
+ intervals.each do |interval_name|
93
+ interval = HeadMusic::Analysis::DiatonicInterval.get(interval_name)
94
+ next_pitch = interval.above(root_pitch)
95
+ trichord_pitches << next_pitch
96
+ end
97
+
98
+ pitch_collection = HeadMusic::Analysis::PitchCollection.new(trichord_pitches)
99
+ trichord_pitch_classes = pitch_collection.pitch_classes
100
+
101
+ # Check if this trichord contains both pitches from our dyad
102
+ if pitch_classes.all? { |pc| trichord_pitch_classes.include?(pc) }
103
+ trichords << pitch_collection
104
+ end
105
+ end
106
+ end
107
+
108
+ trichords.uniq { |t| t.pitch_classes.sort.map(&:to_i) }
109
+ end
110
+
111
+ def generate_possible_seventh_chords
112
+ seventh_chords = []
113
+ pitch_classes = [lower_pitch.pitch_class, upper_pitch.pitch_class]
114
+
115
+ HeadMusic::Rudiment::Spelling::CHROMATIC_SPELLINGS.each do |root_spelling|
116
+ root_pitch = HeadMusic::Rudiment::Pitch.get("#{root_spelling}4")
117
+
118
+ # Try all common seventh chord types from this root
119
+ seventh_chord_intervals = [
120
+ %w[M3 P5 M7], # major seventh
121
+ %w[M3 P5 m7], # dominant seventh (major-minor)
122
+ %w[m3 P5 m7], # minor seventh
123
+ %w[m3 P5 M7], # minor-major seventh
124
+ %w[m3 d5 m7], # half-diminished seventh
125
+ %w[m3 d5 d7], # diminished seventh
126
+ %w[M2 M3 P5 m7], # dominant ninth
127
+ %w[m2 M3 P5 m7], # dominant minor ninth
128
+ %w[M2 m3 P5 m7], # minor ninth
129
+ %w[M2 M3 P5 M7] # major ninth
130
+ ]
131
+
132
+ seventh_chord_intervals.each do |intervals|
133
+ chord_pitches = [root_pitch]
134
+
135
+ # Each interval is FROM THE ROOT, not consecutive
136
+ intervals.each do |interval_name|
137
+ interval = HeadMusic::Analysis::DiatonicInterval.get(interval_name)
138
+ next_pitch = interval.above(root_pitch)
139
+ chord_pitches << next_pitch
140
+ end
141
+
142
+ pitch_collection = HeadMusic::Analysis::PitchCollection.new(chord_pitches)
143
+ chord_pitch_classes = pitch_collection.pitch_classes
144
+
145
+ # Check if this chord contains both pitches from our dyad
146
+ if pitch_classes.all? { |pc| chord_pitch_classes.include?(pc) }
147
+ seventh_chords << pitch_collection
148
+ end
149
+ end
150
+ end
151
+
152
+ seventh_chords.uniq { |c| c.pitch_classes.sort.map(&:to_i) }
153
+ end
154
+
155
+ def filter_by_key(pitch_collections)
156
+ return pitch_collections unless key
157
+
158
+ diatonic_spellings = key.scale.spellings
159
+
160
+ pitch_collections.select do |pitch_collection|
161
+ pitch_collection.pitches.all? { |pitch| diatonic_spellings.include?(pitch.spelling) }
162
+ end
163
+ end
164
+
165
+ def sort_by_diatonic_agreement(pitch_collections)
166
+ return pitch_collections unless key
167
+
168
+ diatonic_spellings = key.scale.spellings
169
+
170
+ pitch_collections.sort_by do |pitch_collection|
171
+ # Count how many pitches match diatonic spellings (lower is better for sort)
172
+ diatonic_count = pitch_collection.pitches.count { |pitch| diatonic_spellings.include?(pitch.spelling) }
173
+ -diatonic_count # Negative so higher counts come first
174
+ end
175
+ end
176
+
177
+ def generate_enharmonic_respellings
178
+ respellings = []
179
+
180
+ # Get enharmonic equivalents for each pitch
181
+ pitch1_equivalents = get_enharmonic_equivalents(pitch1)
182
+ pitch2_equivalents = get_enharmonic_equivalents(pitch2)
183
+
184
+ # Generate all combinations
185
+ pitch1_equivalents.each do |p1|
186
+ pitch2_equivalents.each do |p2|
187
+ # Skip the original combination
188
+ next if p1.spelling == pitch1.spelling && p2.spelling == pitch2.spelling
189
+
190
+ # Create new dyad with same key context
191
+ respellings << self.class.new(p1, p2, key: key)
192
+ end
193
+ end
194
+
195
+ respellings
196
+ end
197
+
198
+ def get_enharmonic_equivalents(pitch)
199
+ equivalents = [pitch]
200
+
201
+ # Get common enharmonic spellings
202
+ pitch_class = pitch.pitch_class
203
+ letter_names = HeadMusic::Rudiment::LetterName.all
204
+
205
+ letter_names.each do |letter_name|
206
+ [-2, -1, 0, 1, 2].each do |alteration_semitones|
207
+ spelling = HeadMusic::Rudiment::Spelling.get("#{letter_name}#{alteration_sign(alteration_semitones)}")
208
+ next unless spelling
209
+
210
+ if spelling.pitch_class == pitch_class
211
+ equivalent_pitch = HeadMusic::Rudiment::Pitch.fetch_or_create(spelling, pitch.register)
212
+ equivalents << equivalent_pitch unless equivalents.any? { |p| p.spelling == spelling }
213
+ end
214
+ end
215
+ end
216
+
217
+ equivalents
218
+ end
219
+
220
+ def alteration_sign(semitones)
221
+ case semitones
222
+ when -2 then "bb"
223
+ when -1 then "b"
224
+ when 0 then ""
225
+ when 1 then "#"
226
+ when 2 then "##"
227
+ end
228
+ end
229
+ end
@@ -62,7 +62,7 @@ class HeadMusic::Analysis::MelodicInterval
62
62
  combined_pitches = (pitches + other_interval.pitches).uniq
63
63
  return false if combined_pitches.length < 3
64
64
 
65
- HeadMusic::Analysis::PitchSet.new(combined_pitches).consonant_triad?
65
+ HeadMusic::Analysis::PitchCollection.new(combined_pitches).consonant_triad?
66
66
  end
67
67
 
68
68
  def method_missing(method_name, *args, &block)