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
@@ -1,53 +1,136 @@
1
- # Namespace for instrument definitions, categorization, and configuration
2
1
  module HeadMusic::Instruments; end
3
2
 
4
- # A specific musical instrument instance with a selected variant.
5
- # Represents an actual playable instrument with its transposition and configuration.
3
+ # A musical instrument with parent-based inheritance.
4
+ #
5
+ # Instruments can inherit from parent instruments, allowing for a clean
6
+ # hierarchy where child instruments override specific attributes while
7
+ # inheriting others from their parents.
6
8
  #
7
9
  # 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
10
+ # trumpet = HeadMusic::Instruments::Instrument.get("trumpet")
11
+ # clarinet_in_a = HeadMusic::Instruments::Instrument.get("clarinet_in_a")
12
+ # clarinet_in_a.parent # => clarinet
13
+ # clarinet_in_a.pitch_key # => "a" (own attribute)
14
+ # clarinet_in_a.family_key # => "clarinet" (inherited from parent)
11
15
  #
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
16
+ # Attributes:
17
+ # name_key: the primary identifier for the instrument
18
+ # parent_key: optional key referencing the parent instrument
19
+ # family_key: the instrument family (e.g., "clarinet", "trumpet")
20
+ # pitch_key: the pitch designation (e.g., "b_flat", "a", "c")
21
+ # alias_name_keys: alternative names for the instrument
22
+ # range_categories: size/range classifications
23
+ # staff_schemes: notation schemes (to be moved to NotationStyle later)
17
24
  class HeadMusic::Instruments::Instrument
18
25
  include HeadMusic::Named
19
26
 
20
- attr_reader :instrument_type, :variant
27
+ INSTRUMENTS = YAML.load_file(File.expand_path("instruments.yml", __dir__)).freeze
28
+
29
+ attr_reader :name_key, :parent_key, :alias_name_keys, :range_categories, :staff_schemes_data
30
+
31
+ class << self
32
+ # Factory method to get an Instrument instance
33
+ # @param name [String, Symbol] instrument name (e.g., "clarinet", "clarinet_in_a")
34
+ # @param variant_key [String, Symbol, nil] DEPRECATED: variant key (for backward compatibility)
35
+ # @return [Instrument, nil] instrument instance or nil if not found
36
+ def get(name, variant_key = nil)
37
+ return name if name.is_a?(self)
38
+
39
+ # Handle two-argument form for backward compatibility
40
+ if variant_key
41
+ combined_name = "#{name}_#{variant_key}"
42
+ result = find_valid_instrument(combined_name) || find_valid_instrument(name.to_s)
43
+ else
44
+ result = find_valid_instrument(name.to_s) || find_valid_instrument(normalize_variant_name(name))
45
+ end
21
46
 
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)
47
+ result
48
+ end
49
+
50
+ def find_valid_instrument(name)
51
+ instrument = get_by_name(name)
52
+ instrument&.name_key ? instrument : nil
53
+ end
54
+
55
+ def all
56
+ HeadMusic::Instruments::InstrumentFamily.all # Ensure families are loaded first
57
+ INSTRUMENTS.map { |key, _data| get(key) }
58
+ @all ||= @instances.values.compact.sort_by { |instrument| instrument.name.downcase }
59
+ end
28
60
 
29
- type_name, parsed_variant_key = parse_instrument_name(type_or_name)
30
- variant_key ||= parsed_variant_key
61
+ private
31
62
 
32
- instrument_type = HeadMusic::Instruments::InstrumentType.get(type_name)
33
- return nil unless instrument_type&.name_key
63
+ # Convert shorthand variant names to full form
64
+ # e.g., "trumpet_in_eb" -> "trumpet_in_e_flat"
65
+ # e.g., "clarinet_in_bb" -> "clarinet_in_b_flat"
66
+ def normalize_variant_name(name)
67
+ name_str = name.to_s
34
68
 
35
- variant = find_variant(instrument_type, variant_key)
36
- new(instrument_type, variant)
69
+ # Match patterns like "_in_eb" or "_in_bb" at the end (flat)
70
+ flat_pattern = /^(.+)_in_([a-g])b$/i
71
+ sharp_pattern = %r{^(.+)_in_([a-g])\#$}i
72
+
73
+ if name_str =~ flat_pattern
74
+ instrument = Regexp.last_match(1)
75
+ note = Regexp.last_match(2).downcase
76
+ "#{instrument}_in_#{note}_flat"
77
+ elsif name_str =~ sharp_pattern
78
+ instrument = Regexp.last_match(1)
79
+ note = Regexp.last_match(2).downcase
80
+ "#{instrument}_in_#{note}_sharp"
81
+ else
82
+ name_str
83
+ end
84
+ end
37
85
  end
38
86
 
39
- def initialize(instrument_type, variant)
40
- @instrument_type = instrument_type
41
- @variant = variant
42
- initialize_name
87
+ # Parent instrument (for inheritance)
88
+ def parent
89
+ return nil unless parent_key
90
+
91
+ @parent ||= self.class.get(parent_key)
43
92
  end
44
93
 
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
94
+ # Attributes with parent chain resolution
95
+
96
+ def family_key
97
+ @family_key || parent&.family_key
98
+ end
99
+
100
+ def pitch_key
101
+ @pitch_key || parent&.pitch_key
102
+ end
48
103
 
49
- # Delegations to variant
50
- delegate :pitch_designation, :staff_schemes, :default_staff_scheme, to: :variant
104
+ def family
105
+ return unless family_key
106
+
107
+ HeadMusic::Instruments::InstrumentFamily.get(family_key)
108
+ end
109
+
110
+ def orchestra_section_key
111
+ family&.orchestra_section_key
112
+ end
113
+
114
+ def classification_keys
115
+ family&.classification_keys || []
116
+ end
117
+
118
+ # Pitch designation as a Spelling object (for backward compatibility)
119
+ def pitch_designation
120
+ return nil unless pitch_key
121
+
122
+ @pitch_designation ||= HeadMusic::Rudiment::Spelling.get(pitch_key_to_designation)
123
+ end
124
+
125
+ # Staff schemes (notation concern - kept for backward compatibility)
126
+ def staff_schemes
127
+ @staff_schemes ||= build_staff_schemes
128
+ end
129
+
130
+ def default_staff_scheme
131
+ @default_staff_scheme ||=
132
+ staff_schemes.find(&:default?) || staff_schemes.first
133
+ end
51
134
 
52
135
  def default_staves
53
136
  default_staff_scheme&.staves || []
@@ -85,77 +168,163 @@ class HeadMusic::Instruments::Instrument
85
168
  default_clefs.any?
86
169
  end
87
170
 
171
+ def translation(locale = :en)
172
+ return name unless name_key
173
+
174
+ I18n.translate(name_key, scope: %i[head_music instruments], locale: locale, default: name)
175
+ end
176
+
88
177
  def ==(other)
89
178
  return false unless other.is_a?(self.class)
90
179
 
91
- instrument_type == other.instrument_type && variant == other.variant
180
+ name_key == other.name_key
92
181
  end
93
182
 
94
183
  def to_s
95
184
  name
96
185
  end
97
186
 
187
+ # For backward compatibility with code that expects variants
188
+ def variants
189
+ []
190
+ end
191
+
192
+ def default_variant
193
+ nil
194
+ end
195
+
196
+ # Collect all instrument_configurations from self and ancestors
197
+ def instrument_configurations
198
+ own_configs = HeadMusic::Instruments::InstrumentConfiguration.for_instrument(name_key)
199
+ parent_configs = parent&.instrument_configurations || []
200
+ own_configs + parent_configs
201
+ end
202
+
203
+ def stringing
204
+ @stringing ||= HeadMusic::Instruments::Stringing.for_instrument(self) || parent&.stringing
205
+ end
206
+
207
+ def alternate_tunings
208
+ own_tunings = HeadMusic::Instruments::AlternateTuning.for_instrument(name_key)
209
+ return own_tunings if own_tunings.any?
210
+
211
+ parent&.alternate_tunings || []
212
+ end
213
+
214
+ private_class_method :new
215
+
98
216
  private
99
217
 
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}"
218
+ def initialize(name)
219
+ record = record_for_name(name)
220
+ if record
221
+ initialize_data_from_record(record)
106
222
  else
107
- variant_name = variant.key.to_s.tr("_", " ")
108
- self.name = "#{instrument_type.name} (#{variant_name})"
223
+ # Mark as invalid - will be filtered out by get_by_name
224
+ @name_key = nil
225
+ self.name = name.to_s
109
226
  end
110
227
  end
111
228
 
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}"
229
+ def record_for_name(name)
230
+ record_for_key(HeadMusic::Utilities::HashKey.for(name)) ||
231
+ record_for_key(key_for_name(name)) ||
232
+ record_for_alias(name)
233
+ end
234
+
235
+ def key_for_name(name)
236
+ INSTRUMENTS.each do |key, _data|
237
+ I18n.config.available_locales.each do |locale|
238
+ translation = I18n.t("head_music.instruments.#{key}", locale: locale)
239
+ return key if translation.downcase == name.downcase
240
+ end
241
+ end
242
+ nil
243
+ end
244
+
245
+ def record_for_key(key)
246
+ INSTRUMENTS.each do |name_key, data|
247
+ return data.merge("name_key" => name_key) if name_key.to_s == key.to_s
248
+ end
249
+ nil
250
+ end
251
+
252
+ def record_for_alias(name)
253
+ normalized_name = HeadMusic::Utilities::HashKey.for(name).to_s
254
+ INSTRUMENTS.each do |name_key, data|
255
+ data["alias_name_keys"]&.each do |alias_key|
256
+ return data.merge("name_key" => name_key) if HeadMusic::Utilities::HashKey.for(alias_key).to_s == normalized_name
141
257
  end
142
- [type_name, variant_key]
258
+ end
259
+ nil
260
+ end
261
+
262
+ def initialize_data_from_record(record)
263
+ @name_key = record["name_key"].to_sym
264
+ @parent_key = record["parent_key"]&.to_sym
265
+ @family_key = record["family_key"]
266
+ @pitch_key = record["pitch_key"]
267
+ @alias_name_keys = record["alias_name_keys"] || []
268
+ @range_categories = record["range_categories"] || []
269
+ @staff_schemes_data = record["staff_schemes"] || {}
270
+
271
+ initialize_name
272
+ end
273
+
274
+ def initialize_name
275
+ # Try to get a translation first
276
+ base_name = I18n.translate(name_key, scope: "head_music.instruments", locale: "en", default: nil)
277
+
278
+ if base_name
279
+ # Use the translation as-is
280
+ self.name = base_name
281
+ elsif parent_key && pitch_key
282
+ # Build name from parent + pitch for child instruments
283
+ pitch_name = format_pitch_name(pitch_key_to_designation)
284
+ self.name = "#{parent_translation} in #{pitch_name}"
143
285
  else
144
- [name_str, nil]
286
+ # Fall back to inferred name
287
+ self.name = inferred_name
145
288
  end
146
289
  end
147
290
 
148
- def self.find_variant(instrument_type, variant_key)
149
- return instrument_type.default_variant unless variant_key
291
+ def parent_translation
292
+ return nil unless parent_key
293
+
294
+ I18n.translate(parent_key, scope: "head_music.instruments", locale: "en", default: parent_key.to_s.tr("_", " "))
295
+ end
296
+
297
+ def inferred_name
298
+ name_key.to_s.tr("_", " ")
299
+ end
300
+
301
+ def format_pitch_name(pitch_designation)
302
+ pitch_designation.to_s.tr("b", "♭").tr("#", "♯")
303
+ end
150
304
 
151
- # Convert to symbol for comparison
152
- variant_sym = variant_key.to_sym
305
+ # Convert pitch_key (e.g., "b_flat") to designation format (e.g., "Bb")
306
+ def pitch_key_to_designation
307
+ return nil unless pitch_key
153
308
 
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
309
+ key = pitch_key.to_s
310
+ if key.end_with?("_flat")
311
+ "#{key[0].upcase}b"
312
+ elsif key.end_with?("_sharp")
313
+ "#{key[0].upcase}#"
314
+ else
315
+ key.upcase
316
+ end
158
317
  end
159
318
 
160
- private_class_method :parse_instrument_name, :find_variant
319
+ def build_staff_schemes
320
+ return parent&.staff_schemes || [] if staff_schemes_data.empty?
321
+
322
+ staff_schemes_data.map do |key, list|
323
+ HeadMusic::Instruments::StaffScheme.new(
324
+ key: key,
325
+ instrument: self,
326
+ list: list
327
+ )
328
+ end
329
+ end
161
330
  end
@@ -0,0 +1,66 @@
1
+ module HeadMusic::Instruments; end
2
+
3
+ # A configurable aspect of an instrument, such as a leadpipe, mute, or attachment.
4
+ #
5
+ # Examples:
6
+ # - Piccolo trumpet "leadpipe" configuration with options: b_flat (default), a
7
+ # - Trumpet "mute" configuration with options: open (default), straight, cup, harmon
8
+ # - Bass trombone "f_attachment" with options: disengaged (default), engaged
9
+ class HeadMusic::Instruments::InstrumentConfiguration
10
+ CONFIGURATIONS = YAML.load_file(File.expand_path("instrument_configurations.yml", __dir__)).freeze
11
+
12
+ attr_reader :name_key, :instrument_key, :options
13
+
14
+ class << self
15
+ def for_instrument(instrument_key)
16
+ instrument_key = instrument_key.to_s
17
+ return [] unless CONFIGURATIONS.key?(instrument_key)
18
+
19
+ CONFIGURATIONS[instrument_key].map do |config_name, config_data|
20
+ new(
21
+ name_key: config_name,
22
+ instrument_key: instrument_key,
23
+ options_data: config_data["options"] || {}
24
+ )
25
+ end
26
+ end
27
+ end
28
+
29
+ def initialize(name_key:, instrument_key:, options_data: {})
30
+ @name_key = name_key.to_sym
31
+ @instrument_key = instrument_key.to_sym
32
+ @options = build_options(options_data)
33
+ end
34
+
35
+ def default_option
36
+ @default_option ||= options.find(&:default?) || options.first
37
+ end
38
+
39
+ def option(option_key)
40
+ options.find { |opt| opt.name_key == option_key.to_sym }
41
+ end
42
+
43
+ def ==(other)
44
+ return false unless other.is_a?(self.class)
45
+
46
+ name_key == other.name_key && instrument_key == other.instrument_key
47
+ end
48
+
49
+ def to_s
50
+ name_key.to_s
51
+ end
52
+
53
+ private
54
+
55
+ def build_options(options_data)
56
+ options_data.map do |option_name, option_attrs|
57
+ attrs = option_attrs || {}
58
+ HeadMusic::Instruments::InstrumentConfigurationOption.new(
59
+ name_key: option_name,
60
+ default: attrs["default"],
61
+ transposition_semitones: attrs["transposition_semitones"],
62
+ lowest_pitch_semitones: attrs["lowest_pitch_semitones"]
63
+ )
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,38 @@
1
+ module HeadMusic::Instruments; end
2
+
3
+ # An option for an instrument configuration.
4
+ #
5
+ # Examples:
6
+ # - Piccolo trumpet leadpipe: "a" option with transposition_semitones: -1
7
+ # - Bass trombone F attachment: "engaged" option with lowest_pitch_semitones: -6
8
+ # - Trumpet mute: "straight", "cup", "harmon" options (no pitch effects)
9
+ class HeadMusic::Instruments::InstrumentConfigurationOption
10
+ attr_reader :name_key, :default, :transposition_semitones, :lowest_pitch_semitones
11
+
12
+ def initialize(name_key:, default: false, transposition_semitones: nil, lowest_pitch_semitones: nil)
13
+ @name_key = name_key.to_sym
14
+ @default = default
15
+ @transposition_semitones = transposition_semitones
16
+ @lowest_pitch_semitones = lowest_pitch_semitones
17
+ end
18
+
19
+ def default?
20
+ @default == true
21
+ end
22
+
23
+ def affects_transposition?
24
+ !transposition_semitones.nil?
25
+ end
26
+
27
+ def affects_range?
28
+ !lowest_pitch_semitones.nil?
29
+ end
30
+
31
+ def ==(other)
32
+ to_s == other.to_s
33
+ end
34
+
35
+ def to_s
36
+ name_key.to_s
37
+ end
38
+ end