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.
Files changed (103) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +9 -3
  3. data/CHANGELOG.md +18 -0
  4. data/CLAUDE.md +35 -15
  5. data/Gemfile +7 -1
  6. data/Gemfile.lock +91 -3
  7. data/README.md +18 -0
  8. data/Rakefile +7 -2
  9. data/head_music.gemspec +1 -1
  10. data/lib/head_music/analysis/dyad.rb +229 -0
  11. data/lib/head_music/analysis/melodic_interval.rb +1 -1
  12. data/lib/head_music/analysis/pitch_class_set.rb +111 -14
  13. data/lib/head_music/analysis/{pitch_set.rb → pitch_collection.rb} +11 -5
  14. data/lib/head_music/analysis/sonority.rb +50 -12
  15. data/lib/head_music/content/cantus_firmus_examples.rb +58 -0
  16. data/lib/head_music/content/staff.rb +1 -1
  17. data/lib/head_music/content/voice.rb +1 -1
  18. data/lib/head_music/instruments/alternate_tuning.rb +102 -0
  19. data/lib/head_music/instruments/alternate_tunings.yml +78 -0
  20. data/lib/head_music/instruments/instrument.rb +251 -82
  21. data/lib/head_music/instruments/instrument_configuration.rb +66 -0
  22. data/lib/head_music/instruments/instrument_configuration_option.rb +38 -0
  23. data/lib/head_music/instruments/instrument_configurations.yml +288 -0
  24. data/lib/head_music/instruments/instrument_families.yml +77 -0
  25. data/lib/head_music/instruments/instrument_family.rb +3 -4
  26. data/lib/head_music/instruments/instruments.yml +795 -965
  27. data/lib/head_music/instruments/playing_technique.rb +75 -0
  28. data/lib/head_music/instruments/playing_techniques.yml +826 -0
  29. data/lib/head_music/instruments/score_order.rb +2 -5
  30. data/lib/head_music/instruments/staff.rb +61 -1
  31. data/lib/head_music/instruments/staff_scheme.rb +6 -4
  32. data/lib/head_music/instruments/stringing.rb +115 -0
  33. data/lib/head_music/instruments/stringing_course.rb +58 -0
  34. data/lib/head_music/instruments/stringings.yml +168 -0
  35. data/lib/head_music/instruments/variant.rb +0 -1
  36. data/lib/head_music/locales/de.yml +23 -0
  37. data/lib/head_music/locales/en.yml +100 -0
  38. data/lib/head_music/locales/es.yml +23 -0
  39. data/lib/head_music/locales/fr.yml +23 -0
  40. data/lib/head_music/locales/it.yml +23 -0
  41. data/lib/head_music/locales/ru.yml +23 -0
  42. data/lib/head_music/{rudiment → notation}/musical_symbol.rb +3 -3
  43. data/lib/head_music/notation/staff_mapping.rb +70 -0
  44. data/lib/head_music/notation/staff_position.rb +62 -0
  45. data/lib/head_music/notation.rb +7 -0
  46. data/lib/head_music/rudiment/alteration.rb +17 -47
  47. data/lib/head_music/rudiment/alterations.yml +32 -0
  48. data/lib/head_music/rudiment/chromatic_interval.rb +1 -1
  49. data/lib/head_music/rudiment/clef.rb +1 -1
  50. data/lib/head_music/rudiment/consonance.rb +14 -13
  51. data/lib/head_music/rudiment/key_signature.rb +0 -26
  52. data/lib/head_music/rudiment/rhythmic_unit/parser.rb +2 -2
  53. data/lib/head_music/rudiment/rhythmic_value/parser.rb +1 -1
  54. data/lib/head_music/rudiment/rhythmic_value.rb +1 -1
  55. data/lib/head_music/rudiment/spelling.rb +3 -0
  56. data/lib/head_music/rudiment/tempo.rb +1 -1
  57. data/lib/head_music/rudiment/tuning/just_intonation.rb +0 -39
  58. data/lib/head_music/rudiment/tuning/meantone.rb +0 -39
  59. data/lib/head_music/rudiment/tuning/pythagorean.rb +0 -39
  60. data/lib/head_music/rudiment/tuning.rb +20 -0
  61. data/lib/head_music/style/guidelines/consonant_climax.rb +2 -2
  62. data/lib/head_music/style/modern_tradition.rb +8 -11
  63. data/lib/head_music/style/tradition.rb +1 -1
  64. data/lib/head_music/time/clock_position.rb +84 -0
  65. data/lib/head_music/time/conductor.rb +264 -0
  66. data/lib/head_music/time/meter_event.rb +37 -0
  67. data/lib/head_music/time/meter_map.rb +173 -0
  68. data/lib/head_music/time/musical_position.rb +188 -0
  69. data/lib/head_music/time/smpte_timecode.rb +164 -0
  70. data/lib/head_music/time/tempo_event.rb +40 -0
  71. data/lib/head_music/time/tempo_map.rb +187 -0
  72. data/lib/head_music/time.rb +32 -0
  73. data/lib/head_music/utilities/case.rb +27 -0
  74. data/lib/head_music/utilities/hash_key.rb +1 -1
  75. data/lib/head_music/version.rb +1 -1
  76. data/lib/head_music.rb +42 -13
  77. data/user_stories/backlog/notation-style.md +183 -0
  78. data/user_stories/{todo → backlog}/organizing-content.md +9 -1
  79. data/user_stories/done/consonance-dissonance-classification.md +117 -0
  80. data/user_stories/{todo → done}/dyad-analysis.md +4 -6
  81. data/user_stories/done/expand-playing-techniques.md +38 -0
  82. data/user_stories/{active → done}/handle-time.rb +5 -19
  83. data/user_stories/done/instrument-architecture.md +238 -0
  84. data/user_stories/done/move-musical-symbol-to-notation.md +161 -0
  85. data/user_stories/done/move-staff-mapping-to-notation.md +158 -0
  86. data/user_stories/done/move-staff-position-to-notation.md +141 -0
  87. data/user_stories/done/notation-module-foundation.md +102 -0
  88. data/user_stories/done/percussion_set.md +260 -0
  89. data/user_stories/{todo → done}/pitch-class-set-analysis.md +0 -40
  90. data/user_stories/done/sonority-identification.md +37 -0
  91. data/user_stories/done/string-pitches.md +41 -0
  92. data/user_stories/epics/notation-module.md +135 -0
  93. data/user_stories/{todo → visioning}/agentic-daw.md +0 -1
  94. metadata +56 -20
  95. data/check_instrument_consistency.rb +0 -0
  96. data/lib/head_music/instruments/instrument_type.rb +0 -188
  97. data/test_translations.rb +0 -15
  98. data/user_stories/todo/consonance-dissonance-classification.md +0 -57
  99. data/user_stories/todo/material-and-scores.md +0 -10
  100. data/user_stories/todo/percussion_set.md +0 -1
  101. data/user_stories/todo/pitch-set-classification.md +0 -72
  102. data/user_stories/todo/sonority-identification.md +0 -67
  103. /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: 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
@@ -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::InstrumentType.get(instrument) if instrument
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::PitchSet.new(combined_pitches).consonant_triad?
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]