head_music 8.3.0 → 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 (138) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +9 -3
  3. data/CHANGELOG.md +71 -0
  4. data/CLAUDE.md +62 -25
  5. data/Gemfile +7 -1
  6. data/Gemfile.lock +91 -3
  7. data/MUSIC_THEORY.md +120 -0
  8. data/README.md +18 -0
  9. data/Rakefile +7 -2
  10. data/head_music.gemspec +1 -1
  11. data/lib/head_music/analysis/diatonic_interval.rb +29 -27
  12. data/lib/head_music/analysis/dyad.rb +229 -0
  13. data/lib/head_music/analysis/interval_consonance.rb +51 -0
  14. data/lib/head_music/analysis/melodic_interval.rb +1 -1
  15. data/lib/head_music/analysis/pitch_class_set.rb +111 -14
  16. data/lib/head_music/analysis/{pitch_set.rb → pitch_collection.rb} +11 -5
  17. data/lib/head_music/analysis/sonority.rb +50 -12
  18. data/lib/head_music/content/note.rb +1 -1
  19. data/lib/head_music/content/placement.rb +1 -1
  20. data/lib/head_music/content/position.rb +1 -1
  21. data/lib/head_music/content/voice.rb +1 -1
  22. data/lib/head_music/instruments/alternate_tuning.rb +102 -0
  23. data/lib/head_music/instruments/alternate_tunings.yml +78 -0
  24. data/lib/head_music/instruments/instrument.rb +231 -72
  25. data/lib/head_music/instruments/instrument_configuration.rb +66 -0
  26. data/lib/head_music/instruments/instrument_configuration_option.rb +38 -0
  27. data/lib/head_music/instruments/instrument_configurations.yml +288 -0
  28. data/lib/head_music/instruments/instrument_families.yml +77 -0
  29. data/lib/head_music/instruments/instrument_family.rb +15 -5
  30. data/lib/head_music/instruments/instruments.yml +795 -965
  31. data/lib/head_music/instruments/playing_technique.rb +75 -0
  32. data/lib/head_music/instruments/playing_techniques.yml +826 -0
  33. data/lib/head_music/instruments/score_order.rb +136 -0
  34. data/lib/head_music/instruments/score_orders.yml +130 -0
  35. data/lib/head_music/instruments/staff.rb +61 -1
  36. data/lib/head_music/instruments/staff_scheme.rb +6 -4
  37. data/lib/head_music/instruments/stringing.rb +115 -0
  38. data/lib/head_music/instruments/stringing_course.rb +58 -0
  39. data/lib/head_music/instruments/stringings.yml +168 -0
  40. data/lib/head_music/instruments/variant.rb +6 -1
  41. data/lib/head_music/locales/de.yml +29 -0
  42. data/lib/head_music/locales/en.yml +106 -0
  43. data/lib/head_music/locales/es.yml +29 -0
  44. data/lib/head_music/locales/fr.yml +29 -0
  45. data/lib/head_music/locales/it.yml +29 -0
  46. data/lib/head_music/locales/ru.yml +29 -0
  47. data/lib/head_music/{rudiment → notation}/musical_symbol.rb +3 -3
  48. data/lib/head_music/notation/staff_mapping.rb +70 -0
  49. data/lib/head_music/notation/staff_position.rb +62 -0
  50. data/lib/head_music/notation.rb +7 -0
  51. data/lib/head_music/rudiment/alteration.rb +34 -49
  52. data/lib/head_music/rudiment/alterations.yml +32 -0
  53. data/lib/head_music/rudiment/base.rb +9 -0
  54. data/lib/head_music/rudiment/chromatic_interval.rb +4 -7
  55. data/lib/head_music/rudiment/clef.rb +2 -2
  56. data/lib/head_music/rudiment/consonance.rb +39 -5
  57. data/lib/head_music/rudiment/diatonic_context.rb +25 -0
  58. data/lib/head_music/rudiment/key.rb +77 -0
  59. data/lib/head_music/rudiment/key_signature/enharmonic_equivalence.rb +1 -1
  60. data/lib/head_music/rudiment/key_signature.rb +21 -8
  61. data/lib/head_music/rudiment/letter_name.rb +3 -3
  62. data/lib/head_music/rudiment/meter.rb +19 -9
  63. data/lib/head_music/rudiment/mode.rb +92 -0
  64. data/lib/head_music/rudiment/note.rb +112 -0
  65. data/lib/head_music/rudiment/pitch/parser.rb +52 -0
  66. data/lib/head_music/rudiment/pitch.rb +5 -6
  67. data/lib/head_music/rudiment/pitch_class.rb +1 -1
  68. data/lib/head_music/rudiment/quality.rb +1 -1
  69. data/lib/head_music/rudiment/reference_pitch.rb +1 -1
  70. data/lib/head_music/rudiment/register.rb +4 -1
  71. data/lib/head_music/rudiment/rest.rb +36 -0
  72. data/lib/head_music/rudiment/rhythmic_element.rb +53 -0
  73. data/lib/head_music/rudiment/rhythmic_unit/parser.rb +86 -0
  74. data/lib/head_music/rudiment/rhythmic_unit.rb +13 -5
  75. data/lib/head_music/rudiment/rhythmic_units.yml +80 -0
  76. data/lib/head_music/rudiment/rhythmic_value/parser.rb +77 -0
  77. data/lib/head_music/{content → rudiment}/rhythmic_value.rb +23 -5
  78. data/lib/head_music/rudiment/scale.rb +4 -5
  79. data/lib/head_music/rudiment/scale_degree.rb +1 -1
  80. data/lib/head_music/rudiment/scale_type.rb +9 -3
  81. data/lib/head_music/rudiment/solmization.rb +1 -1
  82. data/lib/head_music/rudiment/spelling.rb +8 -4
  83. data/lib/head_music/rudiment/tempo.rb +85 -0
  84. data/lib/head_music/rudiment/tonal_context.rb +35 -0
  85. data/lib/head_music/rudiment/tuning/just_intonation.rb +0 -39
  86. data/lib/head_music/rudiment/tuning/meantone.rb +0 -39
  87. data/lib/head_music/rudiment/tuning/pythagorean.rb +0 -39
  88. data/lib/head_music/rudiment/tuning.rb +21 -1
  89. data/lib/head_music/rudiment/unpitched_note.rb +62 -0
  90. data/lib/head_music/style/guidelines/consonant_climax.rb +2 -2
  91. data/lib/head_music/style/medieval_tradition.rb +26 -0
  92. data/lib/head_music/style/modern_tradition.rb +31 -0
  93. data/lib/head_music/style/renaissance_tradition.rb +26 -0
  94. data/lib/head_music/style/tradition.rb +21 -0
  95. data/lib/head_music/time/clock_position.rb +84 -0
  96. data/lib/head_music/time/conductor.rb +264 -0
  97. data/lib/head_music/time/meter_event.rb +37 -0
  98. data/lib/head_music/time/meter_map.rb +173 -0
  99. data/lib/head_music/time/musical_position.rb +188 -0
  100. data/lib/head_music/time/smpte_timecode.rb +164 -0
  101. data/lib/head_music/time/tempo_event.rb +40 -0
  102. data/lib/head_music/time/tempo_map.rb +187 -0
  103. data/lib/head_music/time.rb +32 -0
  104. data/lib/head_music/utilities/case.rb +27 -0
  105. data/lib/head_music/utilities/hash_key.rb +34 -2
  106. data/lib/head_music/version.rb +1 -1
  107. data/lib/head_music.rb +71 -22
  108. data/user_stories/active/string-pitches.md +41 -0
  109. data/user_stories/backlog/notation-style.md +183 -0
  110. data/user_stories/backlog/organizing-content.md +80 -0
  111. data/user_stories/done/consonance-dissonance-classification.md +117 -0
  112. data/user_stories/{backlog → done}/dyad-analysis.md +6 -16
  113. data/user_stories/done/epic--score-order/PLAN.md +244 -0
  114. data/user_stories/done/expand-playing-techniques.md +38 -0
  115. data/user_stories/done/handle-time.md +7 -0
  116. data/user_stories/done/handle-time.rb +163 -0
  117. data/user_stories/done/instrument-architecture.md +238 -0
  118. data/user_stories/done/instrument-variant.md +65 -0
  119. data/user_stories/done/move-musical-symbol-to-notation.md +161 -0
  120. data/user_stories/done/move-staff-mapping-to-notation.md +158 -0
  121. data/user_stories/done/move-staff-position-to-notation.md +141 -0
  122. data/user_stories/done/notation-module-foundation.md +102 -0
  123. data/user_stories/done/percussion_set.md +260 -0
  124. data/user_stories/done/sonority-identification.md +37 -0
  125. data/user_stories/done/superclass-for-note.md +30 -0
  126. data/user_stories/epics/notation-module.md +135 -0
  127. data/user_stories/visioning/agentic-daw.md +2 -0
  128. metadata +84 -18
  129. data/TODO.md +0 -109
  130. data/check_instrument_consistency.rb +0 -0
  131. data/test_translations.rb +0 -15
  132. data/user_stories/backlog/consonance-dissonance-classification.md +0 -57
  133. data/user_stories/backlog/pitch-set-classification.md +0 -62
  134. data/user_stories/backlog/sonority-identification.md +0 -47
  135. /data/user_stories/{backlog → done/epic--score-order}/band-score-order.md +0 -0
  136. /data/user_stories/{backlog → done/epic--score-order}/chamber-ensemble-score-order.md +0 -0
  137. /data/user_stories/{backlog → done/epic--score-order}/orchestral-score-order.md +0 -0
  138. /data/user_stories/{backlog → done}/pitch-class-set-analysis.md +0 -0
@@ -57,16 +57,17 @@ class HeadMusic::Analysis::DiatonicInterval
57
57
 
58
58
  alias_method :to_i, :semitones
59
59
 
60
- # Override Named module methods to use computed name from naming
60
+ # Override Named module method to try I18n and fall back to computed name
61
61
  def name(locale_code: nil)
62
62
  if locale_code
63
- # Try to get translation from locale files
64
- name_key = naming.name.downcase.gsub(" ", "_").to_sym
65
- translation = I18n.translate(name_key, scope: "head_music.diatonic_intervals", locale: locale_code, default: nil)
66
- translation || naming.name
67
- else
68
- naming.name
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
69
  end
70
+ naming.name
70
71
  end
71
72
 
72
73
  def to_s
@@ -111,26 +112,39 @@ class HeadMusic::Analysis::DiatonicInterval
111
112
  alias_method :invert, :inversion
112
113
 
113
114
  def consonance(style = :standard_practice)
114
- consonance_for_perfect(style) ||
115
- consonance_for_major_and_minor ||
116
- HeadMusic::Rudiment::Consonance.get(:dissonant)
115
+ consonance_analysis(style).consonance
117
116
  end
118
117
 
119
118
  def consonance?(style = :standard_practice)
120
- consonance(style).perfect? || consonance(style).imperfect?
119
+ consonance(style).consonant?
120
+ end
121
+
122
+ def consonant?(style = :standard_practice)
123
+ consonance_analysis(style).consonant?
121
124
  end
122
- alias_method :consonant?, :consonance?
123
125
 
124
126
  def perfect_consonance?(style = :standard_practice)
125
- consonance(style).perfect?
127
+ consonance_analysis(style).perfect_consonance?
126
128
  end
127
129
 
128
130
  def imperfect_consonance?(style = :standard_practice)
129
- consonance(style).imperfect?
131
+ consonance_analysis(style).imperfect_consonance?
130
132
  end
131
133
 
132
134
  def dissonance?(style = :standard_practice)
133
- consonance(style).dissonant?
135
+ consonance_analysis(style).dissonant?
136
+ end
137
+
138
+ def dissonant?(style = :standard_practice)
139
+ consonance_analysis(style).dissonant?
140
+ end
141
+
142
+ def consonance_analysis(style = :standard_practice)
143
+ HeadMusic::Analysis::IntervalConsonance.new(self, style)
144
+ end
145
+
146
+ def consonance_classification(style: :standard_practice)
147
+ consonance_analysis(style).classification
134
148
  end
135
149
 
136
150
  def above(pitch)
@@ -181,16 +195,4 @@ class HeadMusic::Analysis::DiatonicInterval
181
195
  def naming
182
196
  @naming ||= Naming.new(number: number, semitones: semitones)
183
197
  end
184
-
185
- def consonance_for_perfect(style = :standard_practice)
186
- HeadMusic::Rudiment::Consonance.get(dissonant_fourth?(style) ? :dissonant : :perfect) if perfect?
187
- end
188
-
189
- def consonance_for_major_and_minor
190
- HeadMusic::Rudiment::Consonance.get((third_or_compound? || sixth_or_compound?) ? :imperfect : :dissonant) if major? || minor?
191
- end
192
-
193
- def dissonant_fourth?(style = :standard_practice)
194
- fourth_or_compound? && style == :two_part_harmony
195
- end
196
198
  end
@@ -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
@@ -0,0 +1,51 @@
1
+ # Analysis class that combines an interval with a style tradition to determine consonance
2
+ class HeadMusic::Analysis::IntervalConsonance
3
+ attr_reader :interval, :style_tradition
4
+
5
+ def initialize(interval, style_tradition = HeadMusic::Style::ModernTradition.new)
6
+ @interval = interval
7
+ @style_tradition = style_tradition.is_a?(HeadMusic::Style::Tradition) ?
8
+ style_tradition :
9
+ HeadMusic::Style::Tradition.get(style_tradition)
10
+ end
11
+
12
+ def classification
13
+ @classification ||= style_tradition.consonance_classification(interval)
14
+ end
15
+
16
+ def consonance
17
+ @consonance ||= HeadMusic::Rudiment::Consonance.get(classification)
18
+ end
19
+
20
+ def consonant?
21
+ [HeadMusic::Rudiment::Consonance::PERFECT_CONSONANCE, HeadMusic::Rudiment::Consonance::IMPERFECT_CONSONANCE].include?(classification)
22
+ end
23
+
24
+ def dissonant?
25
+ [HeadMusic::Rudiment::Consonance::MILD_DISSONANCE, HeadMusic::Rudiment::Consonance::HARSH_DISSONANCE, HeadMusic::Rudiment::Consonance::DISSONANCE].include?(classification)
26
+ end
27
+
28
+ def contextual?
29
+ classification == HeadMusic::Rudiment::Consonance::CONTEXTUAL
30
+ end
31
+
32
+ def perfect_consonance?
33
+ classification == HeadMusic::Rudiment::Consonance::PERFECT_CONSONANCE
34
+ end
35
+
36
+ def imperfect_consonance?
37
+ classification == HeadMusic::Rudiment::Consonance::IMPERFECT_CONSONANCE
38
+ end
39
+
40
+ def mild_dissonance?
41
+ classification == HeadMusic::Rudiment::Consonance::MILD_DISSONANCE
42
+ end
43
+
44
+ def harsh_dissonance?
45
+ classification == HeadMusic::Rudiment::Consonance::HARSH_DISSONANCE
46
+ end
47
+
48
+ def dissonance?
49
+ classification == HeadMusic::Rudiment::Consonance::DISSONANCE
50
+ end
51
+ 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)
@@ -2,7 +2,7 @@
2
2
  module HeadMusic::Analysis; end
3
3
 
4
4
  # A PitchClassSet represents a pitch-class set or pitch collection.
5
- # See also: PitchSet, PitchClass
5
+ # See also: PitchCollection, PitchClass
6
6
  class HeadMusic::Analysis::PitchClassSet
7
7
  attr_reader :pitch_classes
8
8
 
@@ -23,7 +23,7 @@ class HeadMusic::Analysis::PitchClassSet
23
23
  end
24
24
 
25
25
  def equivalent?(other)
26
- pitch_classes.sort == other.pitch_classes.sort
26
+ pitch_classes == other.pitch_classes
27
27
  end
28
28
 
29
29
  def size
@@ -31,52 +31,149 @@ class HeadMusic::Analysis::PitchClassSet
31
31
  end
32
32
 
33
33
  def monochord?
34
- pitch_classes.length == 1
34
+ size == 1
35
35
  end
36
36
  alias_method :monad?, :monochord?
37
37
 
38
38
  def dichord?
39
- pitch_classes.length == 2
39
+ size == 2
40
40
  end
41
41
  alias_method :dyad?, :dichord?
42
42
 
43
43
  def trichord?
44
- pitch_classes.length == 3
44
+ size == 3
45
45
  end
46
46
 
47
47
  def tetrachord?
48
- pitch_classes.length == 4
48
+ size == 4
49
49
  end
50
50
 
51
51
  def pentachord?
52
- pitch_classes.length == 5
52
+ size == 5
53
53
  end
54
54
 
55
55
  def hexachord?
56
- pitch_classes.length == 6
56
+ size == 6
57
57
  end
58
58
 
59
59
  def heptachord?
60
- pitch_classes.length == 7
60
+ size == 7
61
61
  end
62
62
 
63
63
  def octachord?
64
- pitch_classes.length == 8
64
+ size == 8
65
65
  end
66
66
 
67
67
  def nonachord?
68
- pitch_classes.length == 9
68
+ size == 9
69
69
  end
70
70
 
71
71
  def decachord?
72
- pitch_classes.length == 10
72
+ size == 10
73
73
  end
74
74
 
75
75
  def undecachord?
76
- pitch_classes.length == 11
76
+ size == 11
77
77
  end
78
78
 
79
79
  def dodecachord?
80
- pitch_classes.length == 12
80
+ size == 12
81
+ end
82
+
83
+ # Returns the inversion of the pitch class set
84
+ # Inversion maps each pitch class to its inverse around 0
85
+ # For pitch class n, the inversion is (12 - n) mod 12
86
+ def inversion
87
+ @inversion ||=
88
+ self.class.new(
89
+ pitch_classes.map { |pc| (12 - pc.to_i) % 12 }
90
+ )
91
+ end
92
+
93
+ # Returns the normal form of the pitch class set
94
+ # The normal form is the most compact rotation of the set
95
+ # Algorithm:
96
+ # 1. Generate all rotations of the pitch class set
97
+ # 2. For each rotation, calculate the span (difference between first and last)
98
+ # 3. Choose the rotation with the smallest span
99
+ # 4. If there's a tie, choose the one with the smallest intervals from the left
100
+ def normal_form
101
+ return self if size <= 1
102
+
103
+ @rotations ||= generate_rotations
104
+ @most_compact_rotation ||= find_most_compact_rotation(@rotations)
105
+
106
+ self.class.new(@most_compact_rotation)
107
+ end
108
+
109
+ # Returns the prime form of the pitch class set
110
+ # The prime form is the most compact form among the normal form and its inversion
111
+ # Algorithm:
112
+ # 1. Find the normal form of the original set
113
+ # 2. Find the normal form of the inverted set
114
+ # 3. Compare and choose the most compact one
115
+ # 4. Transpose to start at 0
116
+ def prime_form
117
+ @prime_form ||= begin
118
+ # Handle edge cases
119
+ return self if size.zero?
120
+ return self.class.new([0]) if size == 1
121
+
122
+ normal = normal_form.pitch_classes
123
+ inverted_normal = inversion.normal_form.pitch_classes
124
+
125
+ # Compare which is more compact
126
+ chosen = compare_forms(normal, inverted_normal)
127
+
128
+ # Transpose to start at 0
129
+ transposed = transpose_to_zero(chosen)
130
+
131
+ self.class.new(transposed)
132
+ end
133
+ end
134
+
135
+ private
136
+
137
+ # Generate all rotations of the pitch class set
138
+ def generate_rotations
139
+ return [pitch_classes] if size <= 1
140
+
141
+ numbers = pitch_classes.map(&:to_i)
142
+ (0...size).map do |i|
143
+ rotation = numbers.rotate(i)
144
+ # Normalize each rotation to start from the first element
145
+ first = rotation.first
146
+ rotation.map { |n| (n - first) % 12 }
147
+ end
148
+ end
149
+
150
+ # Find the most compact rotation
151
+ # Returns the rotation with the smallest span
152
+ # In case of tie, prefer the one with smaller intervals from the left
153
+ def find_most_compact_rotation(rotations)
154
+ rotations.min_by do |rotation|
155
+ # Create a comparison key: [span, intervals from left]
156
+ [rotation.last] + rotation
157
+ end
158
+ end
159
+
160
+ # Compare two normal forms and return the more compact one
161
+ # If equal, return the first one
162
+ def compare_forms(form1, form2)
163
+ normalized1 = transpose_to_zero(form1).map(&:to_i)
164
+ normalized2 = transpose_to_zero(form2).map(&:to_i)
165
+
166
+ # Compare lexicographically element by element
167
+ comparison = normalized1 <=> normalized2
168
+ (comparison && comparison <= 0) ? form1 : form2
169
+ end
170
+
171
+ # Transpose a set of pitch classes to start at 0
172
+ def transpose_to_zero(pcs)
173
+ return pcs if pcs.empty?
174
+
175
+ numbers = pcs.map(&:to_i)
176
+ first = numbers.first
177
+ numbers.map { |n| HeadMusic::Rudiment::PitchClass.get((n - first) % 12) }
81
178
  end
82
179
  end
@@ -1,9 +1,11 @@
1
1
  # A module for musical analysis
2
2
  module HeadMusic::Analysis; end
3
3
 
4
- # A PitchSet is a collection of one or more pitches.
4
+ # A PitchCollection is a collection of one or more pitches with specific registers.
5
+ # In music theory, "pitch collection" refers to an ordered or unordered group of actual pitches,
6
+ # as distinct from a "pitch-class set" which abstracts away register and octave equivalence.
5
7
  # See also: PitchClassSet
6
- class HeadMusic::Analysis::PitchSet
8
+ class HeadMusic::Analysis::PitchCollection
7
9
  TERTIAN_SONORITIES = {
8
10
  implied_triad: [3],
9
11
  triad: [3, 5],
@@ -35,7 +37,7 @@ class HeadMusic::Analysis::PitchSet
35
37
  end
36
38
 
37
39
  def reduction
38
- @reduction ||= HeadMusic::Analysis::PitchSet.new(reduction_pitches)
40
+ @reduction ||= HeadMusic::Analysis::PitchCollection.new(reduction_pitches)
39
41
  end
40
42
 
41
43
  def diatonic_intervals
@@ -67,13 +69,13 @@ class HeadMusic::Analysis::PitchSet
67
69
  def invert
68
70
  inverted_pitch = pitches[0] + HeadMusic::Analysis::DiatonicInterval.get("perfect octave")
69
71
  new_pitches = pitches.drop(1) + [inverted_pitch]
70
- HeadMusic::Analysis::PitchSet.new(new_pitches)
72
+ HeadMusic::Analysis::PitchCollection.new(new_pitches)
71
73
  end
72
74
 
73
75
  def uninvert
74
76
  inverted_pitch = pitches[-1] - HeadMusic::Analysis::DiatonicInterval.get("perfect octave")
75
77
  new_pitches = [inverted_pitch] + pitches[0..-2]
76
- HeadMusic::Analysis::PitchSet.new(new_pitches)
78
+ HeadMusic::Analysis::PitchCollection.new(new_pitches)
77
79
  end
78
80
 
79
81
  def bass_pitch
@@ -187,6 +189,10 @@ class HeadMusic::Analysis::PitchSet
187
189
  @scale_degrees_above_bass_pitch ||= diatonic_intervals_above_bass_pitch.map(&:simple_number).sort - [8]
188
190
  end
189
191
 
192
+ def sonority
193
+ @sonority ||= HeadMusic::Analysis::Sonority.new(self)
194
+ end
195
+
190
196
  private
191
197
 
192
198
  def reduction_pitches
@@ -27,17 +27,55 @@ class HeadMusic::Analysis::Sonority
27
27
  quartal_chord: %w[P4 m7]
28
28
  }.freeze
29
29
 
30
- attr_reader :pitch_set
30
+ DEFAULT_ROOT = "C4"
31
+
32
+ # Factory method to get a sonority by identifier
33
+ # Returns a Sonority with pitches starting at the default root (C4)
34
+ #
35
+ # @param identifier [Symbol, String] the sonority identifier (e.g., :major_triad)
36
+ # @param root [String] the root pitch (default: "C4")
37
+ # @param inversion [Integer] the inversion number (default: 0 for root position)
38
+ # @return [Sonority, nil] the sonority object, or nil if identifier not found
39
+ def self.get(identifier, root: DEFAULT_ROOT, inversion: 0)
40
+ identifier = identifier.to_sym
41
+ return nil unless SONORITIES.key?(identifier)
42
+
43
+ root_pitch = HeadMusic::Rudiment::Pitch.get(root)
44
+ interval_shorthands = SONORITIES[identifier]
45
+
46
+ # Build pitches: root + intervals above root
47
+ pitches = [root_pitch] + interval_shorthands.map do |shorthand|
48
+ interval = HeadMusic::Analysis::DiatonicInterval.get(shorthand)
49
+ interval.above(root_pitch)
50
+ end
51
+
52
+ pitch_collection = HeadMusic::Analysis::PitchCollection.new(pitches)
53
+
54
+ # Apply inversions if requested
55
+ inversion.times do
56
+ pitch_collection = pitch_collection.invert
57
+ end
58
+
59
+ new(pitch_collection)
60
+ end
61
+
62
+ # Returns all available sonority identifiers
63
+ # @return [Array<Symbol>] array of sonority identifiers
64
+ def self.identifiers
65
+ SONORITIES.keys
66
+ end
67
+
68
+ attr_reader :pitch_collection
31
69
 
32
- delegate :reduction, to: :pitch_set
33
- delegate :empty?, :empty_set?, to: :pitch_set
34
- delegate :monochord?, :monad, :dichord?, :dyad?, :trichord?, :tetrachord?, :pentachord?, :hexachord?, to: :pitch_set
35
- delegate :heptachord?, :octachord?, :nonachord?, :decachord?, :undecachord?, :dodecachord?, to: :pitch_set
36
- delegate :pitch_class_set, :pitch_class_set_size, to: :pitch_set
37
- delegate :scale_degrees_above_bass_pitch, to: :pitch_set
70
+ delegate :reduction, to: :pitch_collection
71
+ delegate :empty?, :empty_set?, to: :pitch_collection
72
+ delegate :monochord?, :monad, :dichord?, :dyad?, :trichord?, :tetrachord?, :pentachord?, :hexachord?, to: :pitch_collection
73
+ delegate :heptachord?, :octachord?, :nonachord?, :decachord?, :undecachord?, :dodecachord?, to: :pitch_collection
74
+ delegate :pitch_class_set, :pitch_class_set_size, to: :pitch_collection
75
+ delegate :scale_degrees_above_bass_pitch, to: :pitch_collection
38
76
 
39
- def initialize(pitch_set)
40
- @pitch_set = pitch_set
77
+ def initialize(pitch_collection)
78
+ @pitch_collection = pitch_collection
41
79
  identifier
42
80
  end
43
81
 
@@ -75,7 +113,7 @@ class HeadMusic::Analysis::Sonority
75
113
 
76
114
  def consonant?
77
115
  @consonant ||=
78
- pitch_set.reduction_diatonic_intervals.all?(&:consonant?) &&
116
+ pitch_collection.reduction_diatonic_intervals.all?(&:consonant?) &&
79
117
  root_position.diatonic_intervals_above_bass_pitch.all?(&:consonant?)
80
118
  end
81
119
 
@@ -117,8 +155,8 @@ class HeadMusic::Analysis::Sonority
117
155
  end
118
156
 
119
157
  def ==(other)
120
- other = HeadMusic::Analysis::PitchSet.new(other) if other.is_a?(Array)
121
- other = self.class.new(other) if other.is_a?(HeadMusic::Analysis::PitchSet)
158
+ other = HeadMusic::Analysis::PitchCollection.new(other) if other.is_a?(Array)
159
+ other = self.class.new(other) if other.is_a?(HeadMusic::Analysis::PitchCollection)
122
160
  identifier == other.identifier
123
161
  end
124
162
  end