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.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +9 -3
- data/CHANGELOG.md +18 -0
- data/CLAUDE.md +35 -15
- data/Gemfile +7 -1
- data/Gemfile.lock +91 -3
- data/README.md +18 -0
- data/Rakefile +7 -2
- data/head_music.gemspec +1 -1
- data/lib/head_music/analysis/dyad.rb +229 -0
- data/lib/head_music/analysis/melodic_interval.rb +1 -1
- data/lib/head_music/analysis/pitch_class_set.rb +111 -14
- data/lib/head_music/analysis/{pitch_set.rb → pitch_collection.rb} +11 -5
- data/lib/head_music/analysis/sonority.rb +50 -12
- data/lib/head_music/content/staff.rb +1 -1
- data/lib/head_music/content/voice.rb +1 -1
- data/lib/head_music/instruments/alternate_tuning.rb +102 -0
- data/lib/head_music/instruments/alternate_tunings.yml +78 -0
- data/lib/head_music/instruments/instrument.rb +251 -82
- data/lib/head_music/instruments/instrument_configuration.rb +66 -0
- data/lib/head_music/instruments/instrument_configuration_option.rb +38 -0
- data/lib/head_music/instruments/instrument_configurations.yml +288 -0
- data/lib/head_music/instruments/instrument_families.yml +77 -0
- data/lib/head_music/instruments/instrument_family.rb +3 -4
- data/lib/head_music/instruments/instruments.yml +795 -965
- data/lib/head_music/instruments/playing_technique.rb +75 -0
- data/lib/head_music/instruments/playing_techniques.yml +826 -0
- data/lib/head_music/instruments/score_order.rb +2 -5
- data/lib/head_music/instruments/staff.rb +61 -1
- data/lib/head_music/instruments/staff_scheme.rb +6 -4
- data/lib/head_music/instruments/stringing.rb +115 -0
- data/lib/head_music/instruments/stringing_course.rb +58 -0
- data/lib/head_music/instruments/stringings.yml +168 -0
- data/lib/head_music/instruments/variant.rb +0 -1
- data/lib/head_music/locales/de.yml +23 -0
- data/lib/head_music/locales/en.yml +100 -0
- data/lib/head_music/locales/es.yml +23 -0
- data/lib/head_music/locales/fr.yml +23 -0
- data/lib/head_music/locales/it.yml +23 -0
- data/lib/head_music/locales/ru.yml +23 -0
- data/lib/head_music/{rudiment → notation}/musical_symbol.rb +3 -3
- data/lib/head_music/notation/staff_mapping.rb +70 -0
- data/lib/head_music/notation/staff_position.rb +62 -0
- data/lib/head_music/notation.rb +7 -0
- data/lib/head_music/rudiment/alteration.rb +17 -47
- data/lib/head_music/rudiment/alterations.yml +32 -0
- data/lib/head_music/rudiment/chromatic_interval.rb +1 -1
- data/lib/head_music/rudiment/clef.rb +1 -1
- data/lib/head_music/rudiment/consonance.rb +14 -13
- data/lib/head_music/rudiment/key_signature.rb +0 -26
- data/lib/head_music/rudiment/rhythmic_unit/parser.rb +2 -2
- data/lib/head_music/rudiment/rhythmic_value/parser.rb +1 -1
- data/lib/head_music/rudiment/rhythmic_value.rb +1 -1
- data/lib/head_music/rudiment/spelling.rb +3 -0
- data/lib/head_music/rudiment/tempo.rb +1 -1
- data/lib/head_music/rudiment/tuning/just_intonation.rb +0 -39
- data/lib/head_music/rudiment/tuning/meantone.rb +0 -39
- data/lib/head_music/rudiment/tuning/pythagorean.rb +0 -39
- data/lib/head_music/rudiment/tuning.rb +20 -0
- data/lib/head_music/style/guidelines/consonant_climax.rb +2 -2
- data/lib/head_music/style/modern_tradition.rb +8 -11
- data/lib/head_music/style/tradition.rb +1 -1
- data/lib/head_music/time/clock_position.rb +84 -0
- data/lib/head_music/time/conductor.rb +264 -0
- data/lib/head_music/time/meter_event.rb +37 -0
- data/lib/head_music/time/meter_map.rb +173 -0
- data/lib/head_music/time/musical_position.rb +188 -0
- data/lib/head_music/time/smpte_timecode.rb +164 -0
- data/lib/head_music/time/tempo_event.rb +40 -0
- data/lib/head_music/time/tempo_map.rb +187 -0
- data/lib/head_music/time.rb +32 -0
- data/lib/head_music/utilities/case.rb +27 -0
- data/lib/head_music/utilities/hash_key.rb +1 -1
- data/lib/head_music/version.rb +1 -1
- data/lib/head_music.rb +41 -13
- data/user_stories/active/string-pitches.md +41 -0
- data/user_stories/backlog/notation-style.md +183 -0
- data/user_stories/{todo → backlog}/organizing-content.md +9 -1
- data/user_stories/done/consonance-dissonance-classification.md +117 -0
- data/user_stories/{todo → done}/dyad-analysis.md +4 -6
- data/user_stories/done/expand-playing-techniques.md +38 -0
- data/user_stories/{active → done}/handle-time.rb +5 -19
- data/user_stories/done/instrument-architecture.md +238 -0
- data/user_stories/done/move-musical-symbol-to-notation.md +161 -0
- data/user_stories/done/move-staff-mapping-to-notation.md +158 -0
- data/user_stories/done/move-staff-position-to-notation.md +141 -0
- data/user_stories/done/notation-module-foundation.md +102 -0
- data/user_stories/done/percussion_set.md +260 -0
- data/user_stories/{todo → done}/pitch-class-set-analysis.md +0 -40
- data/user_stories/done/sonority-identification.md +37 -0
- data/user_stories/epics/notation-module.md +135 -0
- data/user_stories/{todo → visioning}/agentic-daw.md +0 -1
- metadata +55 -20
- data/check_instrument_consistency.rb +0 -0
- data/lib/head_music/instruments/instrument_type.rb +0 -188
- data/test_translations.rb +0 -15
- data/user_stories/todo/consonance-dissonance-classification.md +0 -57
- data/user_stories/todo/material-and-scores.md +0 -10
- data/user_stories/todo/percussion_set.md +0 -1
- data/user_stories/todo/pitch-set-classification.md +0 -72
- data/user_stories/todo/sonority-identification.md +0 -67
- /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:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c3abf9e12840eb88aee1cb454116cbd092f224eb1961cfda5567444ad9e91d2f
|
|
4
|
+
data.tar.gz: e7680154e971414788fc8bd1a87f53111bc82c9452f4df5a8ad47a74b1c5c16d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 199c8cdba49e5d6e15e7047a8bb8ee28eaa5c8087c9f427916d087d4ce8986f04cf7dd354666247b389836256230d5c3535ff5fd79964443626e397c0cdb3288
|
|
7
|
+
data.tar.gz: d793d6730a790b088fcc7906bc6cb02b865364852767534c15c1b76b40622ae6a12b23a2bde5f651687e9ce124941501ae8d1f17f9a63a7c8c473a2112914024
|
data/.github/workflows/ci.yml
CHANGED
|
@@ -12,7 +12,8 @@ jobs:
|
|
|
12
12
|
strategy:
|
|
13
13
|
fail-fast: false
|
|
14
14
|
matrix:
|
|
15
|
-
ruby-version: ['3.3
|
|
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:
|
|
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
|
-
-
|
|
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::
|
|
79
|
-
-
|
|
80
|
-
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
-
|
|
84
|
-
-
|
|
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
|
-
|
|
87
|
-
- Instrument families and
|
|
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
|
-
|
|
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.
|
|
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 (
|
|
5
|
-
activesupport (
|
|
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.
|
|
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", "
|
|
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::
|
|
65
|
+
HeadMusic::Analysis::PitchCollection.new(combined_pitches).consonant_triad?
|
|
66
66
|
end
|
|
67
67
|
|
|
68
68
|
def method_missing(method_name, *args, &block)
|