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.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +9 -3
- data/CHANGELOG.md +71 -0
- data/CLAUDE.md +62 -25
- data/Gemfile +7 -1
- data/Gemfile.lock +91 -3
- data/MUSIC_THEORY.md +120 -0
- data/README.md +18 -0
- data/Rakefile +7 -2
- data/head_music.gemspec +1 -1
- data/lib/head_music/analysis/diatonic_interval.rb +29 -27
- data/lib/head_music/analysis/dyad.rb +229 -0
- data/lib/head_music/analysis/interval_consonance.rb +51 -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/note.rb +1 -1
- data/lib/head_music/content/placement.rb +1 -1
- data/lib/head_music/content/position.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 +231 -72
- 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 +15 -5
- 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 +136 -0
- data/lib/head_music/instruments/score_orders.yml +130 -0
- 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 +6 -1
- data/lib/head_music/locales/de.yml +29 -0
- data/lib/head_music/locales/en.yml +106 -0
- data/lib/head_music/locales/es.yml +29 -0
- data/lib/head_music/locales/fr.yml +29 -0
- data/lib/head_music/locales/it.yml +29 -0
- data/lib/head_music/locales/ru.yml +29 -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 +34 -49
- data/lib/head_music/rudiment/alterations.yml +32 -0
- data/lib/head_music/rudiment/base.rb +9 -0
- data/lib/head_music/rudiment/chromatic_interval.rb +4 -7
- data/lib/head_music/rudiment/clef.rb +2 -2
- data/lib/head_music/rudiment/consonance.rb +39 -5
- data/lib/head_music/rudiment/diatonic_context.rb +25 -0
- data/lib/head_music/rudiment/key.rb +77 -0
- data/lib/head_music/rudiment/key_signature/enharmonic_equivalence.rb +1 -1
- data/lib/head_music/rudiment/key_signature.rb +21 -8
- data/lib/head_music/rudiment/letter_name.rb +3 -3
- data/lib/head_music/rudiment/meter.rb +19 -9
- data/lib/head_music/rudiment/mode.rb +92 -0
- data/lib/head_music/rudiment/note.rb +112 -0
- data/lib/head_music/rudiment/pitch/parser.rb +52 -0
- data/lib/head_music/rudiment/pitch.rb +5 -6
- data/lib/head_music/rudiment/pitch_class.rb +1 -1
- data/lib/head_music/rudiment/quality.rb +1 -1
- data/lib/head_music/rudiment/reference_pitch.rb +1 -1
- data/lib/head_music/rudiment/register.rb +4 -1
- data/lib/head_music/rudiment/rest.rb +36 -0
- data/lib/head_music/rudiment/rhythmic_element.rb +53 -0
- data/lib/head_music/rudiment/rhythmic_unit/parser.rb +86 -0
- data/lib/head_music/rudiment/rhythmic_unit.rb +13 -5
- data/lib/head_music/rudiment/rhythmic_units.yml +80 -0
- data/lib/head_music/rudiment/rhythmic_value/parser.rb +77 -0
- data/lib/head_music/{content → rudiment}/rhythmic_value.rb +23 -5
- data/lib/head_music/rudiment/scale.rb +4 -5
- data/lib/head_music/rudiment/scale_degree.rb +1 -1
- data/lib/head_music/rudiment/scale_type.rb +9 -3
- data/lib/head_music/rudiment/solmization.rb +1 -1
- data/lib/head_music/rudiment/spelling.rb +8 -4
- data/lib/head_music/rudiment/tempo.rb +85 -0
- data/lib/head_music/rudiment/tonal_context.rb +35 -0
- 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 +21 -1
- data/lib/head_music/rudiment/unpitched_note.rb +62 -0
- data/lib/head_music/style/guidelines/consonant_climax.rb +2 -2
- data/lib/head_music/style/medieval_tradition.rb +26 -0
- data/lib/head_music/style/modern_tradition.rb +31 -0
- data/lib/head_music/style/renaissance_tradition.rb +26 -0
- data/lib/head_music/style/tradition.rb +21 -0
- 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 +34 -2
- data/lib/head_music/version.rb +1 -1
- data/lib/head_music.rb +71 -22
- data/user_stories/active/string-pitches.md +41 -0
- data/user_stories/backlog/notation-style.md +183 -0
- data/user_stories/backlog/organizing-content.md +80 -0
- data/user_stories/done/consonance-dissonance-classification.md +117 -0
- data/user_stories/{backlog → done}/dyad-analysis.md +6 -16
- data/user_stories/done/epic--score-order/PLAN.md +244 -0
- data/user_stories/done/expand-playing-techniques.md +38 -0
- data/user_stories/done/handle-time.md +7 -0
- data/user_stories/done/handle-time.rb +163 -0
- data/user_stories/done/instrument-architecture.md +238 -0
- data/user_stories/done/instrument-variant.md +65 -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/done/sonority-identification.md +37 -0
- data/user_stories/done/superclass-for-note.md +30 -0
- data/user_stories/epics/notation-module.md +135 -0
- data/user_stories/visioning/agentic-daw.md +2 -0
- metadata +84 -18
- data/TODO.md +0 -109
- data/check_instrument_consistency.rb +0 -0
- data/test_translations.rb +0 -15
- data/user_stories/backlog/consonance-dissonance-classification.md +0 -57
- data/user_stories/backlog/pitch-set-classification.md +0 -62
- data/user_stories/backlog/sonority-identification.md +0 -47
- /data/user_stories/{backlog → done/epic--score-order}/band-score-order.md +0 -0
- /data/user_stories/{backlog → done/epic--score-order}/chamber-ensemble-score-order.md +0 -0
- /data/user_stories/{backlog → done/epic--score-order}/orchestral-score-order.md +0 -0
- /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
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
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).
|
|
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
|
-
|
|
127
|
+
consonance_analysis(style).perfect_consonance?
|
|
126
128
|
end
|
|
127
129
|
|
|
128
130
|
def imperfect_consonance?(style = :standard_practice)
|
|
129
|
-
|
|
131
|
+
consonance_analysis(style).imperfect_consonance?
|
|
130
132
|
end
|
|
131
133
|
|
|
132
134
|
def dissonance?(style = :standard_practice)
|
|
133
|
-
|
|
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::
|
|
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:
|
|
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
|
|
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
|
-
|
|
34
|
+
size == 1
|
|
35
35
|
end
|
|
36
36
|
alias_method :monad?, :monochord?
|
|
37
37
|
|
|
38
38
|
def dichord?
|
|
39
|
-
|
|
39
|
+
size == 2
|
|
40
40
|
end
|
|
41
41
|
alias_method :dyad?, :dichord?
|
|
42
42
|
|
|
43
43
|
def trichord?
|
|
44
|
-
|
|
44
|
+
size == 3
|
|
45
45
|
end
|
|
46
46
|
|
|
47
47
|
def tetrachord?
|
|
48
|
-
|
|
48
|
+
size == 4
|
|
49
49
|
end
|
|
50
50
|
|
|
51
51
|
def pentachord?
|
|
52
|
-
|
|
52
|
+
size == 5
|
|
53
53
|
end
|
|
54
54
|
|
|
55
55
|
def hexachord?
|
|
56
|
-
|
|
56
|
+
size == 6
|
|
57
57
|
end
|
|
58
58
|
|
|
59
59
|
def heptachord?
|
|
60
|
-
|
|
60
|
+
size == 7
|
|
61
61
|
end
|
|
62
62
|
|
|
63
63
|
def octachord?
|
|
64
|
-
|
|
64
|
+
size == 8
|
|
65
65
|
end
|
|
66
66
|
|
|
67
67
|
def nonachord?
|
|
68
|
-
|
|
68
|
+
size == 9
|
|
69
69
|
end
|
|
70
70
|
|
|
71
71
|
def decachord?
|
|
72
|
-
|
|
72
|
+
size == 10
|
|
73
73
|
end
|
|
74
74
|
|
|
75
75
|
def undecachord?
|
|
76
|
-
|
|
76
|
+
size == 11
|
|
77
77
|
end
|
|
78
78
|
|
|
79
79
|
def dodecachord?
|
|
80
|
-
|
|
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
|
|
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::
|
|
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::
|
|
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::
|
|
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::
|
|
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
|
-
|
|
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: :
|
|
33
|
-
delegate :empty?, :empty_set?, to: :
|
|
34
|
-
delegate :monochord?, :monad, :dichord?, :dyad?, :trichord?, :tetrachord?, :pentachord?, :hexachord?, to: :
|
|
35
|
-
delegate :heptachord?, :octachord?, :nonachord?, :decachord?, :undecachord?, :dodecachord?, to: :
|
|
36
|
-
delegate :pitch_class_set, :pitch_class_set_size, to: :
|
|
37
|
-
delegate :scale_degrees_above_bass_pitch, to: :
|
|
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(
|
|
40
|
-
@
|
|
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
|
-
|
|
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::
|
|
121
|
-
other = self.class.new(other) if other.is_a?(HeadMusic::Analysis::
|
|
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
|