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
@@ -11,7 +11,7 @@ class HeadMusic::Content::Note
11
11
 
12
12
  def initialize(pitch, rhythmic_value, voice = nil, position = nil)
13
13
  @pitch = HeadMusic::Rudiment::Pitch.get(pitch)
14
- @rhythmic_value = HeadMusic::Content::RhythmicValue.get(rhythmic_value)
14
+ @rhythmic_value = HeadMusic::Rudiment::RhythmicValue.get(rhythmic_value)
15
15
  @voice = voice || HeadMusic::Content::Voice.new
16
16
  @position = position || HeadMusic::Content::Position.new(@voice.composition, "1:1")
17
17
  end
@@ -55,7 +55,7 @@ class HeadMusic::Content::Placement
55
55
  def ensure_attributes(voice, position, rhythmic_value, pitch)
56
56
  @voice = voice
57
57
  ensure_position(position)
58
- @rhythmic_value = HeadMusic::Content::RhythmicValue.get(rhythmic_value)
58
+ @rhythmic_value = HeadMusic::Rudiment::RhythmicValue.get(rhythmic_value)
59
59
  @pitch = HeadMusic::Rudiment::Pitch.get(pitch)
60
60
  end
61
61
 
@@ -53,7 +53,7 @@ class HeadMusic::Content::Position
53
53
  end
54
54
 
55
55
  def +(other)
56
- other = HeadMusic::Content::RhythmicValue.new(other) if [HeadMusic::Rudiment::RhythmicUnit, Symbol, String].include?(other.class)
56
+ other = HeadMusic::Rudiment::RhythmicValue.new(other) if [HeadMusic::Rudiment::RhythmicUnit, Symbol, String].include?(other.class)
57
57
  self.class.new(composition, bar_number, count, tick + other.ticks)
58
58
  end
59
59
 
@@ -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]
@@ -1,51 +1,104 @@
1
- # Namespace for instrument definitions, categorization, and configuration
2
1
  module HeadMusic::Instruments; end
3
2
 
4
- # A musical instrument.
5
- # An instrument object can be assigned to a staff object.
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.
8
+ #
9
+ # Examples:
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)
15
+ #
6
16
  # 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")
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)
20
24
  class HeadMusic::Instruments::Instrument
21
25
  include HeadMusic::Named
22
26
 
23
27
  INSTRUMENTS = YAML.load_file(File.expand_path("instruments.yml", __dir__)).freeze
24
28
 
25
- def self.get(name)
26
- get_by_name(name)
27
- end
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
46
+
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
28
54
 
29
- def self.all
30
- HeadMusic::Instruments::InstrumentFamily.all
31
- @all ||=
32
- INSTRUMENTS.map { |key, _data| get(key) }.sort_by { |instrument| instrument.name.downcase }
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
60
+
61
+ private
62
+
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
68
+
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
33
85
  end
34
86
 
35
- attr_reader(
36
- :name_key, :alias_name_keys,
37
- :family_key, :orchestra_section_key,
38
- :variants, :classification_keys
39
- )
87
+ # Parent instrument (for inheritance)
88
+ def parent
89
+ return nil unless parent_key
40
90
 
41
- def ==(other)
42
- to_s == other.to_s
91
+ @parent ||= self.class.get(parent_key)
43
92
  end
44
93
 
45
- def translation(locale = :en)
46
- return name unless name_key
94
+ # Attributes with parent chain resolution
47
95
 
48
- I18n.translate(name_key, scope: %i[head_music instruments], locale: locale, default: name)
96
+ def family_key
97
+ @family_key || parent&.family_key
98
+ end
99
+
100
+ def pitch_key
101
+ @pitch_key || parent&.pitch_key
49
102
  end
50
103
 
51
104
  def family
@@ -54,14 +107,51 @@ class HeadMusic::Instruments::Instrument
54
107
  HeadMusic::Instruments::InstrumentFamily.get(family_key)
55
108
  end
56
109
 
57
- # Returns true if the instrument sounds at a different pitch than written.
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
134
+
135
+ def default_staves
136
+ default_staff_scheme&.staves || []
137
+ end
138
+
139
+ def default_clefs
140
+ default_staves&.map(&:clef) || []
141
+ end
142
+
143
+ def sounding_transposition
144
+ default_staves&.first&.sounding_transposition || 0
145
+ end
146
+
147
+ alias_method :default_sounding_transposition, :sounding_transposition
148
+
58
149
  def transposing?
59
- default_sounding_transposition != 0
150
+ sounding_transposition != 0
60
151
  end
61
152
 
62
- # Returns true if the instrument sounds at a different register than written.
63
153
  def transposing_at_the_octave?
64
- transposing? && default_sounding_transposition % 12 == 0
154
+ transposing? && sounding_transposition % 12 == 0
65
155
  end
66
156
 
67
157
  def single_staff?
@@ -78,22 +168,47 @@ class HeadMusic::Instruments::Instrument
78
168
  default_clefs.any?
79
169
  end
80
170
 
81
- def default_variant
82
- variants.find(&:default?) || variants.first
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)
83
175
  end
84
176
 
85
- delegate :default_staff_scheme, to: :default_variant
177
+ def ==(other)
178
+ return false unless other.is_a?(self.class)
86
179
 
87
- def default_staves
88
- default_staff_scheme&.staves || []
180
+ name_key == other.name_key
89
181
  end
90
182
 
91
- def default_clefs
92
- default_staves&.map(&:clef) || []
183
+ def to_s
184
+ name
93
185
  end
94
186
 
95
- def default_sounding_transposition
96
- default_staves&.first&.sounding_transposition || 0
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 || []
97
212
  end
98
213
 
99
214
  private_class_method :new
@@ -105,13 +220,16 @@ class HeadMusic::Instruments::Instrument
105
220
  if record
106
221
  initialize_data_from_record(record)
107
222
  else
223
+ # Mark as invalid - will be filtered out by get_by_name
224
+ @name_key = nil
108
225
  self.name = name.to_s
109
226
  end
110
227
  end
111
228
 
112
229
  def record_for_name(name)
113
230
  record_for_key(HeadMusic::Utilities::HashKey.for(name)) ||
114
- record_for_key(key_for_name(name))
231
+ record_for_key(key_for_name(name)) ||
232
+ record_for_alias(name)
115
233
  end
116
234
 
117
235
  def key_for_name(name)
@@ -126,46 +244,87 @@ class HeadMusic::Instruments::Instrument
126
244
 
127
245
  def record_for_key(key)
128
246
  INSTRUMENTS.each do |name_key, data|
129
- return data.merge!("name_key" => name_key) if name_key.to_s == key.to_s
247
+ return data.merge("name_key" => name_key) if name_key.to_s == key.to_s
130
248
  end
131
249
  nil
132
250
  end
133
251
 
134
- def initialize_data_from_record(record)
135
- initialize_family(record)
136
- inherit_family_attributes(record)
137
- initialize_names(record)
138
- initialize_attributes(record)
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
257
+ end
258
+ end
259
+ nil
139
260
  end
140
261
 
141
- def initialize_family(record)
262
+ def initialize_data_from_record(record)
263
+ @name_key = record["name_key"].to_sym
264
+ @parent_key = record["parent_key"]&.to_sym
142
265
  @family_key = record["family_key"]
143
- @family = HeadMusic::Instruments::InstrumentFamily.get(family_key)
144
- end
145
-
146
- def inherit_family_attributes(record)
147
- return unless family
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"] || {}
148
270
 
149
- @orchestra_section_key = family.orchestra_section_key
150
- @classification_keys = family.classification_keys || []
271
+ initialize_name
151
272
  end
152
273
 
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"] || []
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}"
285
+ else
286
+ # Fall back to inferred name
287
+ self.name = inferred_name
288
+ end
157
289
  end
158
290
 
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
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("_", " "))
166
295
  end
167
296
 
168
297
  def inferred_name
169
298
  name_key.to_s.tr("_", " ")
170
299
  end
300
+
301
+ def format_pitch_name(pitch_designation)
302
+ pitch_designation.to_s.tr("b", "♭").tr("#", "♯")
303
+ end
304
+
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
308
+
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
317
+ end
318
+
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
171
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