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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +53 -0
- data/CLAUDE.md +32 -15
- data/Gemfile.lock +1 -1
- data/MUSIC_THEORY.md +120 -0
- data/lib/head_music/analysis/diatonic_interval.rb +29 -27
- data/lib/head_music/analysis/interval_consonance.rb +51 -0
- data/lib/head_music/content/note.rb +1 -1
- data/lib/head_music/content/placement.rb +1 -1
- data/lib/head_music/content/position.rb +1 -1
- data/lib/head_music/content/staff.rb +1 -1
- data/lib/head_music/instruments/instrument.rb +103 -113
- data/lib/head_music/instruments/instrument_family.rb +13 -2
- data/lib/head_music/instruments/instrument_type.rb +188 -0
- data/lib/head_music/instruments/score_order.rb +139 -0
- data/lib/head_music/instruments/score_orders.yml +130 -0
- data/lib/head_music/instruments/variant.rb +6 -0
- data/lib/head_music/locales/de.yml +6 -0
- data/lib/head_music/locales/en.yml +6 -0
- data/lib/head_music/locales/es.yml +6 -0
- data/lib/head_music/locales/fr.yml +6 -0
- data/lib/head_music/locales/it.yml +6 -0
- data/lib/head_music/locales/ru.yml +6 -0
- data/lib/head_music/rudiment/alteration.rb +23 -8
- data/lib/head_music/rudiment/base.rb +9 -0
- data/lib/head_music/rudiment/chromatic_interval.rb +3 -6
- data/lib/head_music/rudiment/clef.rb +1 -1
- data/lib/head_music/rudiment/consonance.rb +37 -4
- data/lib/head_music/rudiment/diatonic_context.rb +25 -0
- data/lib/head_music/rudiment/key.rb +77 -0
- data/lib/head_music/rudiment/key_signature/enharmonic_equivalence.rb +1 -1
- data/lib/head_music/rudiment/key_signature.rb +46 -7
- data/lib/head_music/rudiment/letter_name.rb +3 -3
- data/lib/head_music/rudiment/meter.rb +19 -9
- data/lib/head_music/rudiment/mode.rb +92 -0
- data/lib/head_music/rudiment/musical_symbol.rb +1 -1
- data/lib/head_music/rudiment/note.rb +112 -0
- data/lib/head_music/rudiment/pitch/parser.rb +52 -0
- data/lib/head_music/rudiment/pitch.rb +5 -6
- data/lib/head_music/rudiment/pitch_class.rb +1 -1
- data/lib/head_music/rudiment/quality.rb +1 -1
- data/lib/head_music/rudiment/reference_pitch.rb +1 -1
- data/lib/head_music/rudiment/register.rb +4 -1
- data/lib/head_music/rudiment/rest.rb +36 -0
- data/lib/head_music/rudiment/rhythmic_element.rb +53 -0
- data/lib/head_music/rudiment/rhythmic_unit/parser.rb +86 -0
- data/lib/head_music/rudiment/rhythmic_unit.rb +13 -5
- data/lib/head_music/rudiment/rhythmic_units.yml +80 -0
- data/lib/head_music/rudiment/rhythmic_value/parser.rb +77 -0
- data/lib/head_music/{content → rudiment}/rhythmic_value.rb +23 -5
- data/lib/head_music/rudiment/scale.rb +4 -5
- data/lib/head_music/rudiment/scale_degree.rb +1 -1
- data/lib/head_music/rudiment/scale_type.rb +9 -3
- data/lib/head_music/rudiment/solmization.rb +1 -1
- data/lib/head_music/rudiment/spelling.rb +5 -4
- data/lib/head_music/rudiment/tempo.rb +85 -0
- data/lib/head_music/rudiment/tonal_context.rb +35 -0
- data/lib/head_music/rudiment/tuning.rb +1 -1
- data/lib/head_music/rudiment/unpitched_note.rb +62 -0
- data/lib/head_music/style/medieval_tradition.rb +26 -0
- data/lib/head_music/style/modern_tradition.rb +34 -0
- data/lib/head_music/style/renaissance_tradition.rb +26 -0
- data/lib/head_music/style/tradition.rb +21 -0
- data/lib/head_music/utilities/hash_key.rb +34 -2
- data/lib/head_music/version.rb +1 -1
- data/lib/head_music.rb +31 -10
- data/user_stories/active/handle-time.md +7 -0
- data/user_stories/active/handle-time.rb +177 -0
- data/user_stories/done/epic--score-order/PLAN.md +244 -0
- data/user_stories/done/instrument-variant.md +65 -0
- data/user_stories/done/superclass-for-note.md +30 -0
- data/user_stories/todo/agentic-daw.md +3 -0
- data/user_stories/{backlog → todo}/dyad-analysis.md +2 -10
- data/user_stories/todo/material-and-scores.md +10 -0
- data/user_stories/todo/organizing-content.md +72 -0
- data/user_stories/todo/percussion_set.md +1 -0
- data/user_stories/{backlog → todo}/pitch-class-set-analysis.md +40 -0
- data/user_stories/{backlog → todo}/pitch-set-classification.md +10 -0
- data/user_stories/{backlog → todo}/sonority-identification.md +20 -0
- metadata +43 -12
- data/TODO.md +0 -109
- /data/user_stories/{backlog → done/epic--score-order}/band-score-order.md +0 -0
- /data/user_stories/{backlog → done/epic--score-order}/chamber-ensemble-score-order.md +0 -0
- /data/user_stories/{backlog → done/epic--score-order}/orchestral-score-order.md +0 -0
- /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
|
-
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
16
|
-
#
|
|
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
|
-
|
|
20
|
+
attr_reader :instrument_type, :variant
|
|
24
21
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
30
|
-
|
|
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
|
-
|
|
36
|
-
|
|
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
|
-
|
|
42
|
-
|
|
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
|
-
|
|
46
|
-
|
|
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
|
-
|
|
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
|
|
52
|
-
|
|
56
|
+
def default_clefs
|
|
57
|
+
default_staves&.map(&:clef) || []
|
|
58
|
+
end
|
|
53
59
|
|
|
54
|
-
|
|
60
|
+
def sounding_transposition
|
|
61
|
+
default_staves&.first&.sounding_transposition || 0
|
|
55
62
|
end
|
|
56
63
|
|
|
57
|
-
|
|
64
|
+
alias_method :default_sounding_transposition, :sounding_transposition
|
|
65
|
+
|
|
58
66
|
def transposing?
|
|
59
|
-
|
|
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? &&
|
|
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
|
|
82
|
-
|
|
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
|
-
|
|
88
|
-
default_staff_scheme&.staves || []
|
|
91
|
+
instrument_type == other.instrument_type && variant == other.variant
|
|
89
92
|
end
|
|
90
93
|
|
|
91
|
-
def
|
|
92
|
-
|
|
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
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
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
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
|
128
|
-
|
|
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
|
-
|
|
135
|
-
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
|
|
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
|
|
7
|
-
#
|
|
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
|