head_music 8.3.0 → 9.0.1

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 (85) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +53 -0
  3. data/CLAUDE.md +32 -15
  4. data/Gemfile.lock +1 -1
  5. data/MUSIC_THEORY.md +120 -0
  6. data/lib/head_music/analysis/diatonic_interval.rb +29 -27
  7. data/lib/head_music/analysis/interval_consonance.rb +51 -0
  8. data/lib/head_music/content/note.rb +1 -1
  9. data/lib/head_music/content/placement.rb +1 -1
  10. data/lib/head_music/content/position.rb +1 -1
  11. data/lib/head_music/content/staff.rb +1 -1
  12. data/lib/head_music/instruments/instrument.rb +103 -113
  13. data/lib/head_music/instruments/instrument_family.rb +13 -2
  14. data/lib/head_music/instruments/instrument_type.rb +188 -0
  15. data/lib/head_music/instruments/score_order.rb +139 -0
  16. data/lib/head_music/instruments/score_orders.yml +130 -0
  17. data/lib/head_music/instruments/variant.rb +6 -0
  18. data/lib/head_music/locales/de.yml +6 -0
  19. data/lib/head_music/locales/en.yml +6 -0
  20. data/lib/head_music/locales/es.yml +6 -0
  21. data/lib/head_music/locales/fr.yml +6 -0
  22. data/lib/head_music/locales/it.yml +6 -0
  23. data/lib/head_music/locales/ru.yml +6 -0
  24. data/lib/head_music/rudiment/alteration.rb +23 -8
  25. data/lib/head_music/rudiment/base.rb +9 -0
  26. data/lib/head_music/rudiment/chromatic_interval.rb +3 -6
  27. data/lib/head_music/rudiment/clef.rb +1 -1
  28. data/lib/head_music/rudiment/consonance.rb +37 -4
  29. data/lib/head_music/rudiment/diatonic_context.rb +25 -0
  30. data/lib/head_music/rudiment/key.rb +77 -0
  31. data/lib/head_music/rudiment/key_signature/enharmonic_equivalence.rb +1 -1
  32. data/lib/head_music/rudiment/key_signature.rb +46 -7
  33. data/lib/head_music/rudiment/letter_name.rb +3 -3
  34. data/lib/head_music/rudiment/meter.rb +19 -9
  35. data/lib/head_music/rudiment/mode.rb +92 -0
  36. data/lib/head_music/rudiment/musical_symbol.rb +1 -1
  37. data/lib/head_music/rudiment/note.rb +112 -0
  38. data/lib/head_music/rudiment/pitch/parser.rb +52 -0
  39. data/lib/head_music/rudiment/pitch.rb +5 -6
  40. data/lib/head_music/rudiment/pitch_class.rb +1 -1
  41. data/lib/head_music/rudiment/quality.rb +1 -1
  42. data/lib/head_music/rudiment/reference_pitch.rb +1 -1
  43. data/lib/head_music/rudiment/register.rb +4 -1
  44. data/lib/head_music/rudiment/rest.rb +36 -0
  45. data/lib/head_music/rudiment/rhythmic_element.rb +53 -0
  46. data/lib/head_music/rudiment/rhythmic_unit/parser.rb +86 -0
  47. data/lib/head_music/rudiment/rhythmic_unit.rb +13 -5
  48. data/lib/head_music/rudiment/rhythmic_units.yml +80 -0
  49. data/lib/head_music/rudiment/rhythmic_value/parser.rb +77 -0
  50. data/lib/head_music/{content → rudiment}/rhythmic_value.rb +23 -5
  51. data/lib/head_music/rudiment/scale.rb +4 -5
  52. data/lib/head_music/rudiment/scale_degree.rb +1 -1
  53. data/lib/head_music/rudiment/scale_type.rb +9 -3
  54. data/lib/head_music/rudiment/solmization.rb +1 -1
  55. data/lib/head_music/rudiment/spelling.rb +5 -4
  56. data/lib/head_music/rudiment/tempo.rb +85 -0
  57. data/lib/head_music/rudiment/tonal_context.rb +35 -0
  58. data/lib/head_music/rudiment/tuning.rb +1 -1
  59. data/lib/head_music/rudiment/unpitched_note.rb +62 -0
  60. data/lib/head_music/style/medieval_tradition.rb +26 -0
  61. data/lib/head_music/style/modern_tradition.rb +34 -0
  62. data/lib/head_music/style/renaissance_tradition.rb +26 -0
  63. data/lib/head_music/style/tradition.rb +21 -0
  64. data/lib/head_music/utilities/hash_key.rb +34 -2
  65. data/lib/head_music/version.rb +1 -1
  66. data/lib/head_music.rb +31 -10
  67. data/user_stories/active/handle-time.md +7 -0
  68. data/user_stories/active/handle-time.rb +177 -0
  69. data/user_stories/done/epic--score-order/PLAN.md +244 -0
  70. data/user_stories/done/instrument-variant.md +65 -0
  71. data/user_stories/done/superclass-for-note.md +30 -0
  72. data/user_stories/todo/agentic-daw.md +3 -0
  73. data/user_stories/{backlog → todo}/dyad-analysis.md +2 -10
  74. data/user_stories/todo/material-and-scores.md +10 -0
  75. data/user_stories/todo/organizing-content.md +72 -0
  76. data/user_stories/todo/percussion_set.md +1 -0
  77. data/user_stories/{backlog → todo}/pitch-class-set-analysis.md +40 -0
  78. data/user_stories/{backlog → todo}/pitch-set-classification.md +10 -0
  79. data/user_stories/{backlog → todo}/sonority-identification.md +20 -0
  80. metadata +43 -12
  81. data/TODO.md +0 -109
  82. /data/user_stories/{backlog → done/epic--score-order}/band-score-order.md +0 -0
  83. /data/user_stories/{backlog → done/epic--score-order}/chamber-ensemble-score-order.md +0 -0
  84. /data/user_stories/{backlog → done/epic--score-order}/orchestral-score-order.md +0 -0
  85. /data/user_stories/{backlog → todo}/consonance-dissonance-classification.md +0 -0
@@ -1,67 +1,74 @@
1
1
  # Namespace for instrument definitions, categorization, and configuration
2
2
  module HeadMusic::Instruments; end
3
3
 
4
- # A musical instrument.
5
- # An instrument object can be assigned to a staff object.
6
- # Attributes:
7
- # name_key: the name of the instrument
8
- # alias_name_keys: an array of alternative names for the instrument
9
- # orchestra_section_key: the section of the orchestra (e.g. "strings")
10
- # family_key: the key for the family of the instrument (e.g. "saxophone")
11
- # classification_keys: an array of classification_keys
12
- # default_clefs: the default clef or system of clefs for the instrument
13
- # - [treble] for instruments that use the treble clef
14
- # - [treble, bass] for instruments that use the grand staff
15
- # variants:
16
- # a hash of default and alternative pitch designations
17
- # Associations:
18
- # family: the family of the instrument (e.g. "saxophone")
19
- # orchestra_section: the section of the orchestra (e.g. "strings")
4
+ # A specific musical instrument instance with a selected variant.
5
+ # Represents an actual playable instrument with its transposition and configuration.
6
+ #
7
+ # Examples:
8
+ # trumpet_in_c = HeadMusic::Instruments::Instrument.get("trumpet_in_c")
9
+ # trumpet_in_c = HeadMusic::Instruments::Instrument.get("trumpet", "in_c")
10
+ # clarinet = HeadMusic::Instruments::Instrument.get("clarinet") # uses default Bb variant
11
+ #
12
+ # Attributes accessible via delegation to instrument_type and variant:
13
+ # name: display name including variant (e.g. "Trumpet in C")
14
+ # transposition: sounding transposition in semitones
15
+ # clefs: array of clefs for this instrument
16
+ # pitch_designation: the pitch designation for transposing instruments
20
17
  class HeadMusic::Instruments::Instrument
21
18
  include HeadMusic::Named
22
19
 
23
- INSTRUMENTS = YAML.load_file(File.expand_path("instruments.yml", __dir__)).freeze
20
+ attr_reader :instrument_type, :variant
24
21
 
25
- def self.get(name)
26
- get_by_name(name)
27
- end
22
+ # Factory method to get an Instrument instance
23
+ # @param type_or_name [String, Symbol] instrument type name or full name with variant
24
+ # @param variant_key [String, Symbol, nil] optional variant key if not included in name
25
+ # @return [Instrument] instrument instance with specified or default variant
26
+ def self.get(type_or_name, variant_key = nil)
27
+ return type_or_name if type_or_name.is_a?(self)
28
28
 
29
- def self.all
30
- HeadMusic::Instruments::InstrumentFamily.all
31
- @all ||=
32
- INSTRUMENTS.map { |key, _data| get(key) }.sort_by { |instrument| instrument.name.downcase }
33
- end
29
+ type_name, parsed_variant_key = parse_instrument_name(type_or_name)
30
+ variant_key ||= parsed_variant_key
34
31
 
35
- attr_reader(
36
- :name_key, :alias_name_keys,
37
- :family_key, :orchestra_section_key,
38
- :variants, :classification_keys
39
- )
32
+ instrument_type = HeadMusic::Instruments::InstrumentType.get(type_name)
33
+ return nil unless instrument_type&.name_key
40
34
 
41
- def ==(other)
42
- to_s == other.to_s
35
+ variant = find_variant(instrument_type, variant_key)
36
+ new(instrument_type, variant)
37
+ end
38
+
39
+ def initialize(instrument_type, variant)
40
+ @instrument_type = instrument_type
41
+ @variant = variant
42
+ initialize_name
43
43
  end
44
44
 
45
- def translation(locale = :en)
46
- return name unless name_key
45
+ # Delegations to instrument_type
46
+ delegate :name_key, :family_key, :family, :orchestra_section_key, :classification_keys,
47
+ :alias_name_keys, :variants, :translation, to: :instrument_type
47
48
 
48
- I18n.translate(name_key, scope: %i[head_music instruments], locale: locale, default: name)
49
+ # Delegations to variant
50
+ delegate :pitch_designation, :staff_schemes, :default_staff_scheme, to: :variant
51
+
52
+ def default_staves
53
+ default_staff_scheme&.staves || []
49
54
  end
50
55
 
51
- def family
52
- return unless family_key
56
+ def default_clefs
57
+ default_staves&.map(&:clef) || []
58
+ end
53
59
 
54
- HeadMusic::Instruments::InstrumentFamily.get(family_key)
60
+ def sounding_transposition
61
+ default_staves&.first&.sounding_transposition || 0
55
62
  end
56
63
 
57
- # Returns true if the instrument sounds at a different pitch than written.
64
+ alias_method :default_sounding_transposition, :sounding_transposition
65
+
58
66
  def transposing?
59
- default_sounding_transposition != 0
67
+ sounding_transposition != 0
60
68
  end
61
69
 
62
- # Returns true if the instrument sounds at a different register than written.
63
70
  def transposing_at_the_octave?
64
- transposing? && default_sounding_transposition % 12 == 0
71
+ transposing? && sounding_transposition % 12 == 0
65
72
  end
66
73
 
67
74
  def single_staff?
@@ -78,94 +85,77 @@ class HeadMusic::Instruments::Instrument
78
85
  default_clefs.any?
79
86
  end
80
87
 
81
- def default_variant
82
- variants.find(&:default?) || variants.first
83
- end
84
-
85
- delegate :default_staff_scheme, to: :default_variant
88
+ def ==(other)
89
+ return false unless other.is_a?(self.class)
86
90
 
87
- def default_staves
88
- default_staff_scheme&.staves || []
91
+ instrument_type == other.instrument_type && variant == other.variant
89
92
  end
90
93
 
91
- def default_clefs
92
- default_staves&.map(&:clef) || []
94
+ def to_s
95
+ name
93
96
  end
94
97
 
95
- def default_sounding_transposition
96
- default_staves&.first&.sounding_transposition || 0
97
- end
98
-
99
- private_class_method :new
100
-
101
98
  private
102
99
 
103
- def initialize(name)
104
- record = record_for_name(name)
105
- if record
106
- initialize_data_from_record(record)
100
+ def initialize_name
101
+ if variant.default? || !pitch_designation
102
+ self.name = instrument_type.name
103
+ elsif pitch_designation
104
+ pitch_name = format_pitch_name(pitch_designation)
105
+ self.name = "#{instrument_type.name} in #{pitch_name}"
107
106
  else
108
- self.name = name.to_s
107
+ variant_name = variant.key.to_s.tr("_", " ")
108
+ self.name = "#{instrument_type.name} (#{variant_name})"
109
109
  end
110
110
  end
111
111
 
112
- def record_for_name(name)
113
- record_for_key(HeadMusic::Utilities::HashKey.for(name)) ||
114
- record_for_key(key_for_name(name))
115
- end
116
-
117
- def key_for_name(name)
118
- INSTRUMENTS.each do |key, _data|
119
- I18n.config.available_locales.each do |locale|
120
- translation = I18n.t("head_music.instruments.#{key}", locale: locale)
121
- return key if translation.downcase == name.downcase
112
+ def format_pitch_name(pitch_designation)
113
+ # Format the pitch designation for display
114
+ # e.g. "Bb" -> "B♭", "C" -> "C", "Eb" -> "E♭"
115
+ pitch_designation.to_s.gsub("b", "♭").gsub("#", "♯")
116
+ end
117
+
118
+ def self.parse_instrument_name(name)
119
+ name_str = name.to_s
120
+
121
+ # Check for variant patterns like "trumpet_in_e_flat"
122
+ if name_str =~ /(.+)_in_([a-g])_(flat|sharp)$/i
123
+ type_name = Regexp.last_match(1)
124
+ note = Regexp.last_match(2).downcase
125
+ accidental = Regexp.last_match(3)
126
+ variant_key = :"in_#{note}_#{accidental}"
127
+ [type_name, variant_key]
128
+ # Check for variant patterns like "trumpet_in_c" or "clarinet_in_a" or "trumpet_in_eb"
129
+ elsif name_str =~ /(.+)_in_([a-g][b#]?)$/i
130
+ type_name = Regexp.last_match(1)
131
+ variant_note = Regexp.last_match(2).downcase
132
+ # Convert "eb" to "e_flat", "bb" to "b_flat", etc.
133
+ if variant_note.end_with?("b") && variant_note.length == 2
134
+ note_letter = variant_note[0]
135
+ variant_key = :"in_#{note_letter}_flat"
136
+ elsif variant_note.end_with?("#") && variant_note.length == 2
137
+ note_letter = variant_note[0]
138
+ variant_key = :"in_#{note_letter}_sharp"
139
+ else
140
+ variant_key = :"in_#{variant_note}"
122
141
  end
142
+ [type_name, variant_key]
143
+ else
144
+ [name_str, nil]
123
145
  end
124
- nil
125
146
  end
126
147
 
127
- def record_for_key(key)
128
- INSTRUMENTS.each do |name_key, data|
129
- return data.merge!("name_key" => name_key) if name_key.to_s == key.to_s
130
- end
131
- nil
132
- end
148
+ def self.find_variant(instrument_type, variant_key)
149
+ return instrument_type.default_variant unless variant_key
133
150
 
134
- def initialize_data_from_record(record)
135
- initialize_family(record)
136
- inherit_family_attributes(record)
137
- initialize_names(record)
138
- initialize_attributes(record)
139
- end
151
+ # Convert to symbol for comparison
152
+ variant_sym = variant_key.to_sym
140
153
 
141
- def initialize_family(record)
142
- @family_key = record["family_key"]
143
- @family = HeadMusic::Instruments::InstrumentFamily.get(family_key)
154
+ # Find the variant by key
155
+ variants = instrument_type.variants || []
156
+ variant = variants.find { |v| v.key == variant_sym }
157
+ variant || instrument_type.default_variant
144
158
  end
145
159
 
146
- def inherit_family_attributes(record)
147
- return unless family
148
-
149
- @orchestra_section_key = family.orchestra_section_key
150
- @classification_keys = family.classification_keys || []
151
- end
152
-
153
- def initialize_names(record)
154
- @name_key = record["name_key"].to_sym
155
- self.name = I18n.translate(name_key, scope: "head_music.instruments", locale: "en", default: inferred_name)
156
- @alias_name_keys = record["alias_name_keys"] || []
157
- end
158
-
159
- def initialize_attributes(record)
160
- @orchestra_section_key ||= record["orchestra_section_key"]
161
- @classification_keys = [@classification_keys, record["classification_keys"]].flatten.compact.uniq
162
- @variants =
163
- (record["variants"] || {}).map do |key, attributes|
164
- HeadMusic::Instruments::Variant.new(key, attributes)
165
- end
166
- end
167
-
168
- def inferred_name
169
- name_key.to_s.tr("_", " ")
170
- end
160
+ private_class_method :parse_instrument_name, :find_variant
171
161
  end
@@ -3,8 +3,19 @@ module HeadMusic::Instruments; end
3
3
 
4
4
  # An *InstrumentFamily* is a species of instrument
5
5
  # that may exist in a variety of keys or other variations.
6
- # For example, _saxophone_ is an instrument family, while
7
- # _alto saxophone_ and _baritone saxophone_ are specific instruments.
6
+ # For example:
7
+ # - _saxophone_ is an instrument family, while
8
+ # _alto saxophone_ and _baritone saxophone_ are specific instruments.
9
+ # - _oboe_ is an instrument family, while
10
+ # _oboe d'amore_ and _English horn_ are specific instruments.
11
+ #
12
+ # Instrument families are categorized by:
13
+ # - orchestra section (e.g. woodwind, brass, percussion, strings)
14
+ # - classification (e.g. bowed string, plucked string, double reed, single reed, brass, keyboard, electronic, percussion)
15
+ #
16
+ # Instrument families are defined in `lib/head_music/instruments/instrument_families.yml`.
17
+ #
18
+ # @see HeadMusic::Instruments::InstrumentType
8
19
  class HeadMusic::Instruments::InstrumentFamily
9
20
  include HeadMusic::Named
10
21
 
@@ -0,0 +1,188 @@
1
+ # Namespace for instrument definitions, categorization, and configuration
2
+ module HeadMusic::Instruments; end
3
+
4
+ # A musical instrument type representing a catalog entry.
5
+ # An instrument type defines the base characteristics and available variants for an instrument.
6
+ # Attributes:
7
+ # name_key: the name of the instrument type
8
+ # alias_name_keys: an array of alternative names for the instrument type
9
+ # orchestra_section_key: the section of the orchestra (e.g. "strings")
10
+ # family_key: the key for the family of the instrument (e.g. "saxophone")
11
+ # classification_keys: an array of classification_keys
12
+ # default_clefs: the default clef or system of clefs for the instrument type
13
+ # - [treble] for instruments that use the treble clef
14
+ # - [treble, bass] for instruments that use the grand staff
15
+ # variants:
16
+ # a hash of default and alternative pitch designations
17
+ # Associations:
18
+ # family: the family of the instrument (e.g. "saxophone")
19
+ # orchestra_section: the section of the orchestra (e.g. "strings")
20
+ class HeadMusic::Instruments::InstrumentType
21
+ include HeadMusic::Named
22
+
23
+ INSTRUMENTS = YAML.load_file(File.expand_path("instruments.yml", __dir__)).freeze
24
+
25
+ def self.get(name)
26
+ get_by_name(name)
27
+ end
28
+
29
+ def self.all
30
+ HeadMusic::Instruments::InstrumentFamily.all
31
+ @all ||=
32
+ INSTRUMENTS.map { |key, _data| get(key) }.sort_by { |instrument| instrument.name.downcase }
33
+ end
34
+
35
+ attr_reader(
36
+ :name_key, :alias_name_keys,
37
+ :family_key, :orchestra_section_key,
38
+ :variants, :classification_keys
39
+ )
40
+
41
+ def ==(other)
42
+ to_s == other.to_s
43
+ end
44
+
45
+ def translation(locale = :en)
46
+ return name unless name_key
47
+
48
+ I18n.translate(name_key, scope: %i[head_music instruments], locale: locale, default: name)
49
+ end
50
+
51
+ def family
52
+ return unless family_key
53
+
54
+ HeadMusic::Instruments::InstrumentFamily.get(family_key)
55
+ end
56
+
57
+ # Returns true if the instrument sounds at a different pitch than written.
58
+ def transposing?
59
+ default_sounding_transposition != 0
60
+ end
61
+
62
+ # Returns true if the instrument sounds at a different register than written.
63
+ def transposing_at_the_octave?
64
+ transposing? && default_sounding_transposition % 12 == 0
65
+ end
66
+
67
+ def single_staff?
68
+ default_staves.length == 1
69
+ end
70
+
71
+ def multiple_staves?
72
+ default_staves.length > 1
73
+ end
74
+
75
+ def pitched?
76
+ return false if default_clefs.compact.uniq == [HeadMusic::Rudiment::Clef.get("neutral_clef")]
77
+
78
+ default_clefs.any?
79
+ end
80
+
81
+ def default_variant
82
+ variants&.find(&:default?) || variants&.first
83
+ end
84
+
85
+ def default_instrument
86
+ @default_instrument ||= HeadMusic::Instruments::Instrument.new(self, default_variant)
87
+ end
88
+
89
+ def default_staff_scheme
90
+ default_variant&.default_staff_scheme
91
+ end
92
+
93
+ def default_staves
94
+ default_staff_scheme&.staves || []
95
+ end
96
+
97
+ def default_clefs
98
+ default_staves&.map(&:clef) || []
99
+ end
100
+
101
+ def default_sounding_transposition
102
+ default_staves&.first&.sounding_transposition || 0
103
+ end
104
+
105
+ private_class_method :new
106
+
107
+ private
108
+
109
+ def initialize(name)
110
+ record = record_for_name(name)
111
+ if record
112
+ initialize_data_from_record(record)
113
+ else
114
+ self.name = name.to_s
115
+ end
116
+ end
117
+
118
+ def record_for_name(name)
119
+ record_for_key(HeadMusic::Utilities::HashKey.for(name)) ||
120
+ record_for_key(key_for_name(name)) ||
121
+ record_for_alias(name)
122
+ end
123
+
124
+ def key_for_name(name)
125
+ INSTRUMENTS.each do |key, _data|
126
+ I18n.config.available_locales.each do |locale|
127
+ translation = I18n.t("head_music.instruments.#{key}", locale: locale)
128
+ return key if translation.downcase == name.downcase
129
+ end
130
+ end
131
+ nil
132
+ end
133
+
134
+ def record_for_key(key)
135
+ INSTRUMENTS.each do |name_key, data|
136
+ return data.merge!("name_key" => name_key) if name_key.to_s == key.to_s
137
+ end
138
+ nil
139
+ end
140
+
141
+ def record_for_alias(name)
142
+ normalized_name = HeadMusic::Utilities::HashKey.for(name).to_s
143
+ INSTRUMENTS.each do |name_key, data|
144
+ data["alias_name_keys"]&.each do |alias_key|
145
+ return data.merge!("name_key" => name_key) if HeadMusic::Utilities::HashKey.for(alias_key).to_s == normalized_name
146
+ end
147
+ end
148
+ nil
149
+ end
150
+
151
+ def initialize_data_from_record(record)
152
+ initialize_family(record)
153
+ inherit_family_attributes(record)
154
+ initialize_names(record)
155
+ initialize_attributes(record)
156
+ end
157
+
158
+ def initialize_family(record)
159
+ @family_key = record["family_key"]
160
+ @family = HeadMusic::Instruments::InstrumentFamily.get(family_key)
161
+ end
162
+
163
+ def inherit_family_attributes(record)
164
+ return unless family
165
+
166
+ @orchestra_section_key = family.orchestra_section_key
167
+ @classification_keys = family.classification_keys || []
168
+ end
169
+
170
+ def initialize_names(record)
171
+ @name_key = record["name_key"].to_sym
172
+ self.name = I18n.translate(name_key, scope: "head_music.instruments", locale: "en", default: inferred_name)
173
+ @alias_name_keys = record["alias_name_keys"] || []
174
+ end
175
+
176
+ def initialize_attributes(record)
177
+ @orchestra_section_key ||= record["orchestra_section_key"]
178
+ @classification_keys = [@classification_keys, record["classification_keys"]].flatten.compact.uniq
179
+ @variants =
180
+ (record["variants"] || {}).map do |key, attributes|
181
+ HeadMusic::Instruments::Variant.new(key, attributes)
182
+ end
183
+ end
184
+
185
+ def inferred_name
186
+ name_key.to_s.tr("_", " ")
187
+ end
188
+ end
@@ -0,0 +1,139 @@
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 InstrumentType instances as-is for backward compatibility (duck typing)
76
+ return input.default_instrument if input.is_a?(HeadMusic::Instruments::InstrumentType)
77
+
78
+ # Return other objects that respond to required methods (mock objects, etc.)
79
+ return input if input.respond_to?(:name_key) && input.respond_to?(:family_key)
80
+
81
+ # Create an Instrument instance for string inputs
82
+ HeadMusic::Instruments::Instrument.get(input) || HeadMusic::Instruments::InstrumentType.get(input)
83
+ end
84
+
85
+ # Builds an index mapping instrument names to their position in the order
86
+ def build_ordering_index
87
+ index = {}
88
+ position = 0
89
+
90
+ sections.each do |section|
91
+ instruments = section["instruments"] || []
92
+ instruments.each do |instrument_key|
93
+ # Store position for this instrument key
94
+ index[instrument_key.to_s] = position
95
+ position += 1
96
+ end
97
+ end
98
+
99
+ index
100
+ end
101
+
102
+ # Finds the position of an instrument in the ordering
103
+ def find_position(instrument, ordering_index)
104
+ # Try exact match with name_key
105
+ return ordering_index[instrument.name_key.to_s] if instrument.name_key && ordering_index.key?(instrument.name_key.to_s)
106
+
107
+ # Try matching by family + range category (e.g., alto_saxophone -> saxophone family)
108
+ if instrument.family_key
109
+ family_base = instrument.family_key.to_s
110
+ instrument_key = instrument.name_key.to_s
111
+
112
+ # Check if this is a variant of a family (e.g., alto_saxophone)
113
+ if instrument_key.include?(family_base)
114
+ # Look for the specific variant first
115
+ return ordering_index[instrument_key] if ordering_index.key?(instrument_key)
116
+
117
+ # Fall back to generic family instrument if listed
118
+ return ordering_index[family_base] if ordering_index.key?(family_base)
119
+ end
120
+ end
121
+
122
+ # Try normalized name (lowercase, underscored)
123
+ normalized = instrument.name.downcase.tr(" ", "_").tr("-", "_")
124
+ return ordering_index[normalized] if ordering_index.key?(normalized)
125
+
126
+ nil
127
+ end
128
+
129
+ # Finds the position and transposition information for an instrument
130
+ def find_position_with_transposition(instrument, ordering_index)
131
+ position = find_position(instrument, ordering_index)
132
+ return nil unless position
133
+
134
+ # Get the sounding transposition for secondary sorting
135
+ transposition = instrument.default_sounding_transposition || 0
136
+
137
+ {position: position, transposition: transposition}
138
+ end
139
+ end