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
@@ -0,0 +1,136 @@
1
+ module HeadMusic::Instruments; end
2
+
3
+ class HeadMusic::Instruments::ScoreOrder
4
+ include HeadMusic::Named
5
+
6
+ SCORE_ORDERS = YAML.load_file(File.expand_path("score_orders.yml", __dir__)).freeze
7
+
8
+ DEFAULT_ENSEMBLE_TYPE_KEY = :orchestral
9
+
10
+ attr_reader :ensemble_type_key, :sections
11
+
12
+ # Factory method to get a ScoreOrder instance for a specific ensemble type
13
+ def self.get(ensemble_type)
14
+ @instances ||= {}
15
+ key = HeadMusic::Utilities::HashKey.for(ensemble_type)
16
+ return unless SCORE_ORDERS.key?(key.to_s)
17
+
18
+ @instances[key] ||= new(key)
19
+ end
20
+
21
+ # Convenience method to order instruments in orchestral order
22
+ def self.in_orchestral_order(instruments)
23
+ get(:orchestral).order(instruments)
24
+ end
25
+
26
+ # Convenience method to order instruments in concert band order
27
+ def self.in_band_order(instruments)
28
+ get(:band).order(instruments)
29
+ end
30
+
31
+ # Accepts a list of instruments and orders them according to this ensemble type's conventions
32
+ def order(instruments)
33
+ valid_inputs = instruments.compact.reject { |i| i.respond_to?(:empty?) && i.empty? }
34
+ instrument_objects = valid_inputs.map { |i| normalize_to_instrument(i) }.compact
35
+
36
+ # Build ordering index
37
+ ordering_index = build_ordering_index
38
+
39
+ # Separate known and unknown instruments
40
+ known_instruments = []
41
+ unknown_instruments = []
42
+
43
+ instrument_objects.each do |instrument|
44
+ position_info = find_position_with_transposition(instrument, ordering_index)
45
+ if position_info
46
+ known_instruments << [instrument, position_info]
47
+ else
48
+ unknown_instruments << instrument
49
+ end
50
+ end
51
+
52
+ # Sort known instruments by position (primary) and transposition (secondary)
53
+ sorted_known = known_instruments.sort_by { |_, pos_info|
54
+ [pos_info[:position], -pos_info[:transposition]]
55
+ }.map(&:first)
56
+ sorted_known + unknown_instruments.sort_by(&:to_s)
57
+ end
58
+
59
+ private_class_method :new
60
+
61
+ private
62
+
63
+ def initialize(ensemble_type_key = DEFAULT_ENSEMBLE_TYPE_KEY)
64
+ @ensemble_type_key = ensemble_type_key.to_sym
65
+ data = SCORE_ORDERS[ensemble_type_key.to_s]
66
+
67
+ @sections = data["sections"] || []
68
+ self.name = data["name"] || ensemble_type_key.to_s.tr("_", " ").capitalize
69
+ end
70
+
71
+ def normalize_to_instrument(input)
72
+ # Return if already an Instrument instance
73
+ return input if input.is_a?(HeadMusic::Instruments::Instrument)
74
+
75
+ # Return other objects that respond to required methods (mock objects, etc.)
76
+ return input if input.respond_to?(:name_key) && input.respond_to?(:family_key)
77
+
78
+ # Create an Instrument instance for string inputs
79
+ HeadMusic::Instruments::Instrument.get(input)
80
+ end
81
+
82
+ # Builds an index mapping instrument names to their position in the order
83
+ def build_ordering_index
84
+ index = {}
85
+ position = 0
86
+
87
+ sections.each do |section|
88
+ instruments = section["instruments"] || []
89
+ instruments.each do |instrument_key|
90
+ # Store position for this instrument key
91
+ index[instrument_key.to_s] = position
92
+ position += 1
93
+ end
94
+ end
95
+
96
+ index
97
+ end
98
+
99
+ # Finds the position of an instrument in the ordering
100
+ def find_position(instrument, ordering_index)
101
+ # Try exact match with name_key
102
+ return ordering_index[instrument.name_key.to_s] if instrument.name_key && ordering_index.key?(instrument.name_key.to_s)
103
+
104
+ # Try matching by family + range category (e.g., alto_saxophone -> saxophone family)
105
+ if instrument.family_key
106
+ family_base = instrument.family_key.to_s
107
+ instrument_key = instrument.name_key.to_s
108
+
109
+ # Check if this is a variant of a family (e.g., alto_saxophone)
110
+ if instrument_key.include?(family_base)
111
+ # Look for the specific variant first
112
+ return ordering_index[instrument_key] if ordering_index.key?(instrument_key)
113
+
114
+ # Fall back to generic family instrument if listed
115
+ return ordering_index[family_base] if ordering_index.key?(family_base)
116
+ end
117
+ end
118
+
119
+ # Try normalized name (lowercase, underscored)
120
+ normalized = HeadMusic::Utilities::Case.to_snake_case(instrument.name)
121
+ return ordering_index[normalized] if ordering_index.key?(normalized)
122
+
123
+ nil
124
+ end
125
+
126
+ # Finds the position and transposition information for an instrument
127
+ def find_position_with_transposition(instrument, ordering_index)
128
+ position = find_position(instrument, ordering_index)
129
+ return nil unless position
130
+
131
+ # Get the sounding transposition for secondary sorting
132
+ transposition = instrument.default_sounding_transposition || 0
133
+
134
+ {position: position, transposition: transposition}
135
+ end
136
+ end
@@ -0,0 +1,130 @@
1
+ ---
2
+ orchestral:
3
+ name: "Orchestral"
4
+ sections:
5
+ - section_key: woodwind
6
+ instruments:
7
+ - piccolo_flute
8
+ - flute
9
+ - alto_flute
10
+ - alto_recorder
11
+ - soprano_recorder
12
+ - tenor_recorder
13
+ - oboe
14
+ - english_horn
15
+ - clarinet
16
+ - alto_clarinet
17
+ - bass_clarinet
18
+ - soprano_saxophone
19
+ - alto_saxophone
20
+ - tenor_saxophone
21
+ - baritone_saxophone
22
+ - bassoon
23
+ - contrabassoon
24
+ - section_key: brass
25
+ instruments:
26
+ - french_horn
27
+ - trumpet
28
+ - cornet
29
+ - trombone
30
+ - bass_trombone
31
+ - tuba
32
+ - section_key: percussion
33
+ instruments:
34
+ - timpani
35
+ - snare_drum
36
+ - bass_drum
37
+ - cymbal
38
+ - gong
39
+ - xylophone
40
+ - glockenspiel
41
+ - marimba
42
+ - vibraphone
43
+ - percussion
44
+ - section_key: keyboard
45
+ instruments:
46
+ - harp
47
+ - piano
48
+ - celesta
49
+ - harpsichord
50
+ - organ
51
+ - section_key: voice
52
+ instruments:
53
+ - soprano_voice
54
+ - alto_voice
55
+ - tenor_voice
56
+ - bass_voice
57
+ - section_key: string
58
+ instruments:
59
+ - violin
60
+ - viola
61
+ - cello
62
+ - double_bass
63
+
64
+ band:
65
+ name: "Concert Band"
66
+ sections:
67
+ - section_key: woodwind
68
+ instruments:
69
+ - piccolo
70
+ - flute
71
+ - oboe
72
+ - english_horn
73
+ - bassoon
74
+ - contrabassoon
75
+ - clarinet
76
+ - alto_clarinet
77
+ - bass_clarinet
78
+ - soprano_saxophone
79
+ - alto_saxophone
80
+ - tenor_saxophone
81
+ - baritone_saxophone
82
+ - section_key: brass
83
+ instruments:
84
+ - cornet
85
+ - trumpet
86
+ - french_horn
87
+ - trombone
88
+ - bass_trombone
89
+ - euphonium
90
+ - baritone_horn
91
+ - tuba
92
+ - section_key: percussion
93
+ instruments:
94
+ - timpani
95
+ - snare_drum
96
+ - bass_drum
97
+ - cymbal
98
+ - percussion
99
+
100
+ brass_quintet:
101
+ name: "Brass Quintet"
102
+ sections:
103
+ - section_key: brass
104
+ instruments:
105
+ - trumpet # First trumpet
106
+ - trumpet # Second trumpet
107
+ - french_horn
108
+ - trombone
109
+ - tuba
110
+
111
+ woodwind_quintet:
112
+ name: "Woodwind Quintet"
113
+ sections:
114
+ - section_key: mixed
115
+ instruments:
116
+ - flute
117
+ - oboe
118
+ - clarinet
119
+ - french_horn # Horn is traditional in woodwind quintets
120
+ - bassoon
121
+
122
+ string_quartet:
123
+ name: "String Quartet"
124
+ sections:
125
+ - section_key: string
126
+ instruments:
127
+ - violin # First violin
128
+ - violin # Second violin
129
+ - viola
130
+ - cello
@@ -1,4 +1,3 @@
1
- # Namespace for instrument definitions, categorization, and configuration
2
1
  module HeadMusic::Instruments; end
3
2
 
4
3
  class HeadMusic::Instruments::Staff
@@ -30,4 +29,65 @@ class HeadMusic::Instruments::Staff
30
29
  def name
31
30
  name_key.to_s.tr("_", " ")
32
31
  end
32
+
33
+ # Get all staff mappings for composite instruments
34
+ #
35
+ # @return [Array<Notation::StaffMapping>] array of staff mappings
36
+ # @example
37
+ # drum_kit_staff.mappings #=> [#<Notation::StaffMapping...>, #<Notation::StaffMapping...>]
38
+ def mappings
39
+ @mappings ||= parse_mappings
40
+ end
41
+
42
+ # Find the staff mapping at a specific position
43
+ #
44
+ # @param position_index [Integer] the staff position index
45
+ # @return [Notation::StaffMapping, nil] the mapping at that position or nil
46
+ # @example
47
+ # staff.mapping_for_position(4) #=> #<Notation::StaffMapping instrument: snare_drum...>
48
+ def mapping_for_position(position_index)
49
+ mappings.find { |mapping| mapping.position_index == position_index }
50
+ end
51
+
52
+ # Get the instrument at a specific staff position
53
+ #
54
+ # @param position_index [Integer] the staff position index
55
+ # @return [Instrument, nil] the instrument at that position or nil
56
+ # @example
57
+ # staff.instrument_for_position(4) #=> #<Instrument name: "snare drum">
58
+ def instrument_for_position(position_index)
59
+ mapping = mapping_for_position(position_index)
60
+ mapping&.instrument
61
+ end
62
+
63
+ # Get all staff positions for a given instrument
64
+ #
65
+ # This is useful for instruments that appear at multiple positions
66
+ # (e.g., hi-hat with stick and pedal techniques)
67
+ #
68
+ # @param instrument_key [String, Symbol] the instrument key
69
+ # @return [Array<Integer>] array of position indices
70
+ # @example
71
+ # staff.positions_for_instrument("hi_hat") #=> [-1, 9]
72
+ def positions_for_instrument(instrument_key)
73
+ mappings.select { |mapping| mapping.instrument_key.to_s == instrument_key.to_s }
74
+ .map(&:position_index)
75
+ end
76
+
77
+ # Get all unique instruments used in this staff's mappings
78
+ #
79
+ # @return [Array<Instrument>] array of unique instruments
80
+ # @example
81
+ # drum_kit_staff.components #=> [#<Instrument: bass_drum>, #<Instrument: snare_drum>, ...]
82
+ def components
83
+ mappings.map(&:instrument).compact.uniq
84
+ end
85
+
86
+ private
87
+
88
+ def parse_mappings
89
+ mappings_data = attributes["mappings"] || []
90
+
91
+ mappings_data.map { |mapping_attrs| HeadMusic::Notation::StaffMapping.new(mapping_attrs) }
92
+ end
33
93
  end
@@ -1,13 +1,15 @@
1
1
  require_relative "staff"
2
2
 
3
- # Namespace for instrument definitions, categorization, and configuration
4
3
  module HeadMusic::Instruments; end
5
4
 
6
5
  class HeadMusic::Instruments::StaffScheme
7
- attr_reader :variant, :key, :list
6
+ attr_reader :instrument, :key, :list
8
7
 
9
- def initialize(variant:, key:, list:)
10
- @variant = variant
8
+ # For backward compatibility, also alias as variant
9
+ alias_method :variant, :instrument
10
+
11
+ def initialize(key:, list:, instrument: nil, variant: nil)
12
+ @instrument = instrument || variant
11
13
  @key = key || "default"
12
14
  @list = list
13
15
  end
@@ -0,0 +1,115 @@
1
+ module HeadMusic::Instruments; end
2
+
3
+ # The string configuration for a stringed instrument.
4
+ #
5
+ # A Stringing defines the courses (strings) of an instrument and their
6
+ # standard tuning pitches. Each course can have one or more strings.
7
+ #
8
+ # Examples:
9
+ # guitar = HeadMusic::Instruments::Instrument.get("guitar")
10
+ # stringing = HeadMusic::Instruments::Stringing.for_instrument(guitar)
11
+ # stringing.courses.map(&:standard_pitch) # => [E2, A2, D3, G3, B3, E4]
12
+ class HeadMusic::Instruments::Stringing
13
+ STRINGINGS = YAML.load_file(File.expand_path("stringings.yml", __dir__)).freeze
14
+
15
+ attr_reader :instrument_key, :courses
16
+
17
+ class << self
18
+ # Find the stringing for an instrument
19
+ # @param instrument [HeadMusic::Instruments::Instrument, String, Symbol] The instrument
20
+ # @return [Stringing, nil]
21
+ def for_instrument(instrument)
22
+ instrument_key = normalize_instrument_key(instrument)
23
+ return nil unless instrument_key
24
+
25
+ data = find_stringing_data(instrument_key, instrument)
26
+ return nil unless data
27
+
28
+ new(instrument_key: instrument_key, courses_data: data["courses"])
29
+ end
30
+
31
+ private
32
+
33
+ def normalize_instrument_key(instrument)
34
+ case instrument
35
+ when HeadMusic::Instruments::Instrument
36
+ instrument.name_key.to_s
37
+ else
38
+ instrument.to_s
39
+ end
40
+ end
41
+
42
+ def find_stringing_data(instrument_key, instrument)
43
+ # Direct match
44
+ return STRINGINGS[instrument_key] if STRINGINGS.key?(instrument_key)
45
+
46
+ # Try parent instrument if this is an Instrument object
47
+ if instrument.is_a?(HeadMusic::Instruments::Instrument) && instrument.parent
48
+ parent_key = instrument.parent.name_key.to_s
49
+ return STRINGINGS[parent_key] if STRINGINGS.key?(parent_key)
50
+ end
51
+
52
+ nil
53
+ end
54
+ end
55
+
56
+ def initialize(instrument_key:, courses_data:)
57
+ @instrument_key = instrument_key.to_sym
58
+ @courses = build_courses(courses_data)
59
+ end
60
+
61
+ # The instrument this stringing belongs to
62
+ # @return [HeadMusic::Instruments::Instrument]
63
+ def instrument
64
+ HeadMusic::Instruments::Instrument.get(instrument_key)
65
+ end
66
+
67
+ # Number of courses
68
+ # @return [Integer]
69
+ def course_count
70
+ courses.length
71
+ end
72
+
73
+ # Total number of physical strings across all courses
74
+ # @return [Integer]
75
+ def string_count
76
+ courses.sum(&:string_count)
77
+ end
78
+
79
+ # Standard pitches for each course (primary string only)
80
+ # @return [Array<HeadMusic::Rudiment::Pitch>]
81
+ def standard_pitches
82
+ courses.map(&:standard_pitch)
83
+ end
84
+
85
+ # Apply an alternate tuning to get adjusted pitches
86
+ # @param tuning [AlternateTuning] The alternate tuning to apply
87
+ # @return [Array<HeadMusic::Rudiment::Pitch>]
88
+ def pitches_with_tuning(tuning)
89
+ courses.each_with_index.map do |course, index|
90
+ semitone_adjustment = tuning.semitones[index] || 0
91
+ HeadMusic::Rudiment::Pitch.from_number(course.standard_pitch.to_i + semitone_adjustment)
92
+ end
93
+ end
94
+
95
+ def ==(other)
96
+ return false unless other.is_a?(self.class)
97
+
98
+ instrument_key == other.instrument_key && courses == other.courses
99
+ end
100
+
101
+ def to_s
102
+ "#{course_count}-course stringing for #{instrument_key}"
103
+ end
104
+
105
+ private
106
+
107
+ def build_courses(courses_data)
108
+ courses_data.map do |course_data|
109
+ HeadMusic::Instruments::StringingCourse.new(
110
+ standard_pitch: course_data["pitch"],
111
+ course_semitones: course_data["course_semitones"] || []
112
+ )
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,58 @@
1
+ module HeadMusic::Instruments; end
2
+
3
+ # A single course (string or set of strings) on a stringed instrument.
4
+ #
5
+ # A "course" is a set of strings that are played together. On most guitars,
6
+ # each course has a single string. On a 12-string guitar or mandolin,
7
+ # courses have multiple strings tuned in unison or octaves.
8
+ #
9
+ # Examples:
10
+ # - 6-string guitar: 6 courses, each with 1 string
11
+ # - 12-string guitar: 6 courses, each with 2 strings (octave or unison)
12
+ # - Mandolin: 4 courses, each with 2 strings in unison
13
+ class HeadMusic::Instruments::StringingCourse
14
+ attr_reader :standard_pitch, :course_semitones
15
+
16
+ # @param standard_pitch [HeadMusic::Rudiment::Pitch, String] The pitch of the primary string
17
+ # @param course_semitones [Array<Integer>] Semitone offsets for additional strings in the course
18
+ def initialize(standard_pitch:, course_semitones: [])
19
+ @standard_pitch = HeadMusic::Rudiment::Pitch.get(standard_pitch)
20
+ @course_semitones = Array(course_semitones)
21
+ end
22
+
23
+ # Returns all pitches in this course (primary + additional strings)
24
+ # @return [Array<HeadMusic::Rudiment::Pitch>]
25
+ def pitches
26
+ [standard_pitch] + additional_pitches
27
+ end
28
+
29
+ # Returns the number of physical strings in this course
30
+ # @return [Integer]
31
+ def string_count
32
+ 1 + course_semitones.length
33
+ end
34
+
35
+ # Whether this course has multiple strings
36
+ # @return [Boolean]
37
+ def doubled?
38
+ course_semitones.any?
39
+ end
40
+
41
+ def ==(other)
42
+ return false unless other.is_a?(self.class)
43
+
44
+ standard_pitch == other.standard_pitch && course_semitones == other.course_semitones
45
+ end
46
+
47
+ def to_s
48
+ standard_pitch.to_s
49
+ end
50
+
51
+ private
52
+
53
+ def additional_pitches
54
+ course_semitones.map do |semitones|
55
+ HeadMusic::Rudiment::Pitch.from_number(standard_pitch.to_i + semitones)
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,168 @@
1
+ # Stringing configurations for stringed instruments.
2
+ #
3
+ # Each instrument has courses (strings played together).
4
+ # course_semitones defines additional strings in a course relative to the pitch.
5
+ #
6
+ # Examples:
7
+ # - [] = single string
8
+ # - [12] = doubled an octave higher
9
+ # - [0] = doubled in unison
10
+
11
+ guitar:
12
+ courses:
13
+ - pitch: E2
14
+ - pitch: A2
15
+ - pitch: D3
16
+ - pitch: G3
17
+ - pitch: B3
18
+ - pitch: E4
19
+
20
+ twelve_string_guitar:
21
+ courses:
22
+ - pitch: E2
23
+ course_semitones: [12]
24
+ - pitch: A2
25
+ course_semitones: [12]
26
+ - pitch: D3
27
+ course_semitones: [12]
28
+ - pitch: G3
29
+ course_semitones: [12]
30
+ - pitch: B3
31
+ course_semitones: [0]
32
+ - pitch: E4
33
+ course_semitones: [0]
34
+
35
+ bass_guitar:
36
+ courses:
37
+ - pitch: E1
38
+ - pitch: A1
39
+ - pitch: D2
40
+ - pitch: G2
41
+
42
+ five_string_bass:
43
+ courses:
44
+ - pitch: B0
45
+ - pitch: E1
46
+ - pitch: A1
47
+ - pitch: D2
48
+ - pitch: G2
49
+
50
+ six_string_bass:
51
+ courses:
52
+ - pitch: B0
53
+ - pitch: E1
54
+ - pitch: A1
55
+ - pitch: D2
56
+ - pitch: G2
57
+ - pitch: C3
58
+
59
+ ukulele:
60
+ courses:
61
+ - pitch: G4
62
+ - pitch: C4
63
+ - pitch: E4
64
+ - pitch: A4
65
+
66
+ baritone_ukulele:
67
+ courses:
68
+ - pitch: D3
69
+ - pitch: G3
70
+ - pitch: B3
71
+ - pitch: E4
72
+
73
+ mandolin:
74
+ courses:
75
+ - pitch: G3
76
+ course_semitones: [0]
77
+ - pitch: D4
78
+ course_semitones: [0]
79
+ - pitch: A4
80
+ course_semitones: [0]
81
+ - pitch: E5
82
+ course_semitones: [0]
83
+
84
+ banjo:
85
+ courses:
86
+ - pitch: G4
87
+ - pitch: D3
88
+ - pitch: G3
89
+ - pitch: B3
90
+ - pitch: D4
91
+
92
+ violin:
93
+ courses:
94
+ - pitch: G3
95
+ - pitch: D4
96
+ - pitch: A4
97
+ - pitch: E5
98
+
99
+ viola:
100
+ courses:
101
+ - pitch: C3
102
+ - pitch: G3
103
+ - pitch: D4
104
+ - pitch: A4
105
+
106
+ cello:
107
+ courses:
108
+ - pitch: C2
109
+ - pitch: G2
110
+ - pitch: D3
111
+ - pitch: A3
112
+
113
+ double_bass:
114
+ courses:
115
+ - pitch: E1
116
+ - pitch: A1
117
+ - pitch: D2
118
+ - pitch: G2
119
+
120
+ harp:
121
+ courses:
122
+ - pitch: C1
123
+ - pitch: D1
124
+ - pitch: E1
125
+ - pitch: F1
126
+ - pitch: G1
127
+ - pitch: A1
128
+ - pitch: B1
129
+ - pitch: C2
130
+ - pitch: D2
131
+ - pitch: E2
132
+ - pitch: F2
133
+ - pitch: G2
134
+ - pitch: A2
135
+ - pitch: B2
136
+ - pitch: C3
137
+ - pitch: D3
138
+ - pitch: E3
139
+ - pitch: F3
140
+ - pitch: G3
141
+ - pitch: A3
142
+ - pitch: B3
143
+ - pitch: C4
144
+ - pitch: D4
145
+ - pitch: E4
146
+ - pitch: F4
147
+ - pitch: G4
148
+ - pitch: A4
149
+ - pitch: B4
150
+ - pitch: C5
151
+ - pitch: D5
152
+ - pitch: E5
153
+ - pitch: F5
154
+ - pitch: G5
155
+ - pitch: A5
156
+ - pitch: B5
157
+ - pitch: C6
158
+ - pitch: D6
159
+ - pitch: E6
160
+ - pitch: F6
161
+ - pitch: G6
162
+ - pitch: A6
163
+ - pitch: B6
164
+ - pitch: C7
165
+ - pitch: D7
166
+ - pitch: E7
167
+ - pitch: F7
168
+ - pitch: G7
@@ -1,4 +1,3 @@
1
- # Namespace for instrument definitions, categorization, and configuration
2
1
  module HeadMusic::Instruments; end
3
2
 
4
3
  class HeadMusic::Instruments::Variant
@@ -35,4 +34,10 @@ class HeadMusic::Instruments::Variant
35
34
  @default_staff_scheme ||=
36
35
  staff_schemes.find(&:default?) || staff_schemes.first
37
36
  end
37
+
38
+ def ==(other)
39
+ return false unless other.is_a?(self.class)
40
+
41
+ key == other.key && attributes == other.attributes
42
+ end
38
43
  end