head_music 9.0.1 → 11.1.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/cantus_firmus_examples.rb +58 -0
- 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 +42 -13
- 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/done/string-pitches.md +41 -0
- data/user_stories/epics/notation-module.md +135 -0
- data/user_stories/{todo → visioning}/agentic-daw.md +0 -1
- metadata +56 -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
|
@@ -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
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
module HeadMusic
|
|
2
|
+
module Content
|
|
3
|
+
# Sample cantus firmus examples from various pedagogical sources.
|
|
4
|
+
# These are traditional melodies used for teaching counterpoint.
|
|
5
|
+
module CantusFirmusExamples
|
|
6
|
+
FUX = [
|
|
7
|
+
{ source: "Fux", key: "D dorian", pitches: %w[D F E D G F A G F E D] },
|
|
8
|
+
{ source: "Fux", key: "E phrygian", pitches: %w[E C D C A3 A G E F E] },
|
|
9
|
+
{ source: "Fux", key: "F lydian", pitches: %w[F G A F D E F C5 A F G F] },
|
|
10
|
+
{ source: "Fux", key: "G mixolydian", pitches: %w[G3 C B3 G3 C E D G E C D B3 A3 G3] },
|
|
11
|
+
{ source: "Fux", key: "A aeolian", pitches: %w[A3 C B3 D C E F E D C B3 A3] },
|
|
12
|
+
{ source: "Fux", key: "C ionian", pitches: %w[C E F G E A G E F E D C] },
|
|
13
|
+
{ source: "Fux", key: "C ionian", pitches: %w[C E F E G F E D C] }
|
|
14
|
+
].freeze
|
|
15
|
+
|
|
16
|
+
CLENDINNING = [
|
|
17
|
+
{ source: "Clendinning", name: "CF in F major", key: "F major", pitches: %w[F3 G3 A3 F3 D3 E3 F3 C4 A3 F3 G3 F3] },
|
|
18
|
+
{ source: "Clendinning", name: "CF in D minor", key: "D minor", pitches: %w[D3 A3 G3 F3 E3 D3 F3 E3 D3] },
|
|
19
|
+
{ source: "Clendinning", name: "CF in C major (treble)", key: "C major", pitches: %w[C D F E F G A G E D C] },
|
|
20
|
+
{ source: "Clendinning", name: "CF in C major (bass)", key: "C major", pitches: %w[C3 E3 F3 G3 E3 A3 G3 E3 F3 E3 D3 C3] }
|
|
21
|
+
].freeze
|
|
22
|
+
|
|
23
|
+
DAVIS_AND_LYBBERT = [
|
|
24
|
+
{ source: "Davis & Lybbert", name: "CF 1 in C major", key: "C major", pitches: %w[C3 E3 D3 G3 A3 G3 E3 F3 D3 C3] },
|
|
25
|
+
{ source: "Davis & Lybbert", name: "CF 2 in C major", key: "C major", pitches: %w[C3 D3 E3 G3 A3 F3 E3 D3 C3] },
|
|
26
|
+
{ source: "Davis & Lybbert", name: "CF 3 in G major", key: "G major", pitches: %w[G3 F#3 G3 E3 D3 B2 C3 D3 B2 A2 G2] },
|
|
27
|
+
{ source: "Davis & Lybbert", name: "CF 4 in G major", key: "G major", pitches: %w[G2 B2 C3 D3 E3 D3 B2 C3 A2 G2] },
|
|
28
|
+
{ source: "Davis & Lybbert", name: "CF 5 in F major", key: "F major", pitches: %w[F3 D3 C3 F3 G3 A3 E3 D3 G3 F3] },
|
|
29
|
+
{ source: "Davis & Lybbert", name: "CF 6 in A minor", key: "A minor", pitches: %w[A2 E3 C3 D3 B2 G2 A2 C3 B2 A2] },
|
|
30
|
+
{ source: "Davis & Lybbert", name: "CF 7 in A minor", key: "A minor", pitches: %w[A2 B2 C3 D3 E3 F3 E3 C3 B2 A2] },
|
|
31
|
+
{ source: "Davis & Lybbert", name: "CF 8 in E minor", key: "E minor", pitches: %w[E3 A3 B3 G3 C4 A3 B3 G3 F#3 E3] },
|
|
32
|
+
{ source: "Davis & Lybbert", name: "CF 9 in E minor", key: "E minor", pitches: %w[E3 D3 C3 B2 G2 A2 B2 E3 G3 F#3 E3] },
|
|
33
|
+
{ source: "Davis & Lybbert", name: "CF 10 in D minor", key: "D minor", pitches: %w[D3 F3 E3 G3 F3 D3 A3 G3 F3 E3 D3] }
|
|
34
|
+
].freeze
|
|
35
|
+
|
|
36
|
+
SCHOENBERG = [
|
|
37
|
+
{ source: "Schoenberg", key: "Eb major", pitches: %w[Eb D G3 Ab3 C Ab3 F3 Eb3] },
|
|
38
|
+
{ source: "Schoenberg", key: "A major", pitches: %w[A3 C#4 B3 F#3 A3 F#3 G#3 A3] }
|
|
39
|
+
].freeze
|
|
40
|
+
|
|
41
|
+
EXAMPLES = (FUX + CLENDINNING + DAVIS_AND_LYBBERT + SCHOENBERG).freeze
|
|
42
|
+
|
|
43
|
+
class << self
|
|
44
|
+
def all
|
|
45
|
+
EXAMPLES
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def by_source(source)
|
|
49
|
+
EXAMPLES.select { |ex| ex[:source] == source }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def sources
|
|
53
|
+
EXAMPLES.map { |ex| ex[:source] }.uniq
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -8,7 +8,7 @@ class HeadMusic::Content::Staff
|
|
|
8
8
|
attr_reader :default_clef, :line_count, :instrument
|
|
9
9
|
|
|
10
10
|
def initialize(default_clef_key, instrument: nil, line_count: nil)
|
|
11
|
-
@instrument = HeadMusic::Instruments::
|
|
11
|
+
@instrument = HeadMusic::Instruments::Instrument.get(instrument) if instrument
|
|
12
12
|
begin
|
|
13
13
|
@default_clef = HeadMusic::Rudiment::Clef.get(default_clef_key)
|
|
14
14
|
rescue KeyError, NoMethodError
|
|
@@ -170,7 +170,7 @@ class HeadMusic::Content::Voice
|
|
|
170
170
|
combined_pitches = (pitches + other_note_pair.pitches).uniq
|
|
171
171
|
return false if combined_pitches.length < 3
|
|
172
172
|
|
|
173
|
-
HeadMusic::Analysis::
|
|
173
|
+
HeadMusic::Analysis::PitchCollection.new(combined_pitches).consonant_triad?
|
|
174
174
|
end
|
|
175
175
|
end
|
|
176
176
|
end
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
module HeadMusic::Instruments; end
|
|
2
|
+
|
|
3
|
+
# An alternate tuning for a stringed instrument.
|
|
4
|
+
#
|
|
5
|
+
# Tunings are defined as semitone adjustments from the standard tuning.
|
|
6
|
+
# For example, "Drop D" tuning lowers the low E string by 2 semitones.
|
|
7
|
+
#
|
|
8
|
+
# Examples:
|
|
9
|
+
# drop_d = HeadMusic::Instruments::AlternateTuning.get("guitar", "drop_d")
|
|
10
|
+
# drop_d.semitones # => [-2, 0, 0, 0, 0, 0]
|
|
11
|
+
#
|
|
12
|
+
# When applying a tuning:
|
|
13
|
+
# - First element applies to the lowest course
|
|
14
|
+
# - Missing elements are treated as 0 (no change)
|
|
15
|
+
# - Extra elements are ignored
|
|
16
|
+
class HeadMusic::Instruments::AlternateTuning
|
|
17
|
+
TUNINGS = YAML.load_file(File.expand_path("alternate_tunings.yml", __dir__)).freeze
|
|
18
|
+
|
|
19
|
+
attr_reader :instrument_key, :name_key, :semitones
|
|
20
|
+
|
|
21
|
+
class << self
|
|
22
|
+
# Get an alternate tuning by instrument and name
|
|
23
|
+
# @param instrument [HeadMusic::Instruments::Instrument, String, Symbol] The instrument
|
|
24
|
+
# @param name [String, Symbol] The tuning name (e.g., "drop_d")
|
|
25
|
+
# @return [AlternateTuning, nil]
|
|
26
|
+
def get(instrument, name)
|
|
27
|
+
instrument_key = normalize_instrument_key(instrument)
|
|
28
|
+
name_key = name.to_s
|
|
29
|
+
|
|
30
|
+
data = TUNINGS.dig(instrument_key, name_key)
|
|
31
|
+
return nil unless data
|
|
32
|
+
|
|
33
|
+
new(
|
|
34
|
+
instrument_key: instrument_key,
|
|
35
|
+
name_key: name_key,
|
|
36
|
+
semitones: data["semitones"] || []
|
|
37
|
+
)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Get all alternate tunings for an instrument
|
|
41
|
+
# @param instrument [HeadMusic::Instruments::Instrument, String, Symbol] The instrument
|
|
42
|
+
# @return [Array<AlternateTuning>]
|
|
43
|
+
def for_instrument(instrument)
|
|
44
|
+
instrument_key = normalize_instrument_key(instrument)
|
|
45
|
+
return [] unless TUNINGS.key?(instrument_key)
|
|
46
|
+
|
|
47
|
+
TUNINGS[instrument_key].map do |name_key, data|
|
|
48
|
+
new(
|
|
49
|
+
instrument_key: instrument_key,
|
|
50
|
+
name_key: name_key,
|
|
51
|
+
semitones: data["semitones"] || []
|
|
52
|
+
)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def normalize_instrument_key(instrument)
|
|
59
|
+
case instrument
|
|
60
|
+
when HeadMusic::Instruments::Instrument
|
|
61
|
+
instrument.name_key.to_s
|
|
62
|
+
else
|
|
63
|
+
instrument.to_s
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def initialize(instrument_key:, name_key:, semitones:)
|
|
69
|
+
@instrument_key = instrument_key.to_sym
|
|
70
|
+
@name_key = name_key.to_sym
|
|
71
|
+
@semitones = Array(semitones)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# The instrument this tuning applies to
|
|
75
|
+
# @return [HeadMusic::Instruments::Instrument]
|
|
76
|
+
def instrument
|
|
77
|
+
HeadMusic::Instruments::Instrument.get(instrument_key)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Human-readable name for the tuning
|
|
81
|
+
# @return [String]
|
|
82
|
+
def name
|
|
83
|
+
name_key.to_s.tr("_", " ").split.map(&:capitalize).join(" ")
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Apply this tuning to a stringing's standard pitches
|
|
87
|
+
# @param stringing [Stringing] The stringing to apply to
|
|
88
|
+
# @return [Array<HeadMusic::Rudiment::Pitch>]
|
|
89
|
+
def apply_to(stringing)
|
|
90
|
+
stringing.pitches_with_tuning(self)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def ==(other)
|
|
94
|
+
return false unless other.is_a?(self.class)
|
|
95
|
+
|
|
96
|
+
instrument_key == other.instrument_key && name_key == other.name_key
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def to_s
|
|
100
|
+
"#{name} (#{instrument_key})"
|
|
101
|
+
end
|
|
102
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# Alternate tunings for stringed instruments.
|
|
2
|
+
#
|
|
3
|
+
# Each tuning is defined as semitone adjustments from standard tuning.
|
|
4
|
+
# - First element = lowest course
|
|
5
|
+
# - Missing elements = 0 (no change)
|
|
6
|
+
# - Extra elements = ignored
|
|
7
|
+
|
|
8
|
+
guitar:
|
|
9
|
+
drop_d:
|
|
10
|
+
semitones: [-2, 0, 0, 0, 0, 0]
|
|
11
|
+
double_drop_d:
|
|
12
|
+
semitones: [-2, 0, 0, 0, 0, -2]
|
|
13
|
+
dadgad:
|
|
14
|
+
semitones: [-2, 0, 0, 0, -2, -2]
|
|
15
|
+
open_d:
|
|
16
|
+
semitones: [-2, 0, 0, -1, -2, -2]
|
|
17
|
+
open_g:
|
|
18
|
+
semitones: [-2, -2, 0, 0, 0, -2]
|
|
19
|
+
open_e:
|
|
20
|
+
semitones: [0, 2, 2, 1, 0, 0]
|
|
21
|
+
open_a:
|
|
22
|
+
semitones: [0, 0, 2, 2, 2, 0]
|
|
23
|
+
open_c:
|
|
24
|
+
semitones: [-4, -2, 0, 0, 1, 0]
|
|
25
|
+
half_step_down:
|
|
26
|
+
semitones: [-1, -1, -1, -1, -1, -1]
|
|
27
|
+
whole_step_down:
|
|
28
|
+
semitones: [-2, -2, -2, -2, -2, -2]
|
|
29
|
+
drop_c:
|
|
30
|
+
semitones: [-4, -2, -2, -2, -2, -2]
|
|
31
|
+
nashville:
|
|
32
|
+
semitones: [12, 12, 12, 12, 0, 0]
|
|
33
|
+
|
|
34
|
+
bass_guitar:
|
|
35
|
+
drop_d:
|
|
36
|
+
semitones: [-2, 0, 0, 0]
|
|
37
|
+
half_step_down:
|
|
38
|
+
semitones: [-1, -1, -1, -1]
|
|
39
|
+
whole_step_down:
|
|
40
|
+
semitones: [-2, -2, -2, -2]
|
|
41
|
+
drop_c:
|
|
42
|
+
semitones: [-4, -2, -2, -2]
|
|
43
|
+
|
|
44
|
+
five_string_bass:
|
|
45
|
+
standard:
|
|
46
|
+
semitones: [0, 0, 0, 0, 0]
|
|
47
|
+
drop_a:
|
|
48
|
+
semitones: [-2, 0, 0, 0, 0]
|
|
49
|
+
|
|
50
|
+
banjo:
|
|
51
|
+
open_g:
|
|
52
|
+
semitones: [0, 0, 0, 0, 0]
|
|
53
|
+
double_c:
|
|
54
|
+
semitones: [-2, -2, 0, -2, 0]
|
|
55
|
+
sawmill:
|
|
56
|
+
semitones: [-2, -2, 0, 0, -2]
|
|
57
|
+
open_d:
|
|
58
|
+
semitones: [-5, -2, -2, -1, -2]
|
|
59
|
+
|
|
60
|
+
ukulele:
|
|
61
|
+
low_g:
|
|
62
|
+
semitones: [-12, 0, 0, 0]
|
|
63
|
+
baritone:
|
|
64
|
+
semitones: [-5, -5, -5, -5]
|
|
65
|
+
slack_key:
|
|
66
|
+
semitones: [0, 0, -1, 0]
|
|
67
|
+
|
|
68
|
+
violin:
|
|
69
|
+
solo_tuning:
|
|
70
|
+
semitones: [1, 1, 1, 1]
|
|
71
|
+
cross_tuning:
|
|
72
|
+
semitones: [0, 0, -2, 0]
|
|
73
|
+
|
|
74
|
+
cello:
|
|
75
|
+
solo_tuning:
|
|
76
|
+
semitones: [1, 1, 1, 1]
|
|
77
|
+
drop_c:
|
|
78
|
+
semitones: [-2, 0, 0, 0]
|