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
@@ -0,0 +1,187 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HeadMusic
4
+ module Time
5
+ # Manages tempo changes along a musical timeline
6
+ #
7
+ # A TempoMap maintains a sorted list of tempo changes at specific musical
8
+ # positions, allowing you to determine which tempo is active at any point
9
+ # and iterate through tempo segments for time calculations.
10
+ #
11
+ # This is essential for converting between clock time and musical position
12
+ # when the tempo changes during a composition.
13
+ #
14
+ # @example Basic usage
15
+ # tempo_map = HeadMusic::Time::TempoMap.new
16
+ # tempo_map.add_change(MusicalPosition.new(5, 1, 0, 0), "quarter", 96)
17
+ # tempo_map.add_change(MusicalPosition.new(9, 1, 0, 0), "quarter", 140)
18
+ #
19
+ # tempo = tempo_map.tempo_at(MusicalPosition.new(7, 1, 0, 0))
20
+ # tempo.beats_per_minute # => 96.0
21
+ #
22
+ # @example Iterating through segments
23
+ # from = MusicalPosition.new(1, 1, 0, 0)
24
+ # to = MusicalPosition.new(10, 1, 0, 0)
25
+ # tempo_map.each_segment(from, to) do |start_pos, end_pos, tempo|
26
+ # # Calculate clock time for this segment
27
+ # end
28
+ class TempoMap
29
+ # @return [Array<TempoEvent>] all tempo events in chronological order
30
+ attr_reader :events
31
+
32
+ # Create a new tempo map
33
+ #
34
+ # @param starting_tempo [HeadMusic::Rudiment::Tempo] initial tempo (default: quarter = 120)
35
+ # @param starting_position [MusicalPosition] where the initial tempo begins (default: 1:1:0:0)
36
+ def initialize(starting_tempo: nil, starting_position: nil)
37
+ starting_tempo ||= HeadMusic::Rudiment::Tempo.new("quarter", 120)
38
+ starting_position ||= MusicalPosition.new
39
+ @events = [TempoEvent.new(starting_position, starting_tempo.beat_value.to_s, starting_tempo.beats_per_minute)]
40
+ @meter = nil # Will be set when used with a MeterMap
41
+ end
42
+
43
+ # Add a tempo change at the specified position
44
+ #
45
+ # If a tempo change already exists at this position, it will be replaced.
46
+ # Events are automatically maintained in sorted order.
47
+ #
48
+ # @param position [MusicalPosition] where the tempo change occurs
49
+ # @param beat_value_or_tempo [String, HeadMusic::Rudiment::Tempo] either a beat value like "quarter" or a Tempo object
50
+ # @param beats_per_minute [Numeric, nil] BPM (required if beat_value_or_tempo is a string)
51
+ # @return [TempoEvent] the created event
52
+ def add_change(position, beat_value_or_tempo, beats_per_minute = nil)
53
+ # Remove any existing event at this position (except the first)
54
+ remove_change(position)
55
+
56
+ # Create the new event
57
+ event = if beat_value_or_tempo.is_a?(HeadMusic::Rudiment::Tempo)
58
+ TempoEvent.new(position, beat_value_or_tempo.beat_value.to_s, beat_value_or_tempo.beats_per_minute).tap do |e|
59
+ e.tempo = beat_value_or_tempo
60
+ end
61
+ else
62
+ TempoEvent.new(position, beat_value_or_tempo, beats_per_minute)
63
+ end
64
+
65
+ @events << event
66
+ sort_events!
67
+ event
68
+ end
69
+
70
+ # Remove a tempo change at the specified position
71
+ #
72
+ # The starting tempo (first event) cannot be removed.
73
+ #
74
+ # @param position [MusicalPosition] the position of the event to remove
75
+ # @return [void]
76
+ def remove_change(position)
77
+ @events.reject! do |event|
78
+ event != @events.first && positions_equal?(event.position, position)
79
+ end
80
+ end
81
+
82
+ # Remove all tempo changes except the starting tempo
83
+ #
84
+ # @return [void]
85
+ def clear_changes
86
+ @events = [@events.first]
87
+ end
88
+
89
+ # Find the tempo active at a given position
90
+ #
91
+ # Returns the tempo from the most recent tempo event at or before
92
+ # the specified position.
93
+ #
94
+ # @param position [MusicalPosition] the position to query
95
+ # @return [HeadMusic::Rudiment::Tempo] the active tempo
96
+ def tempo_at(position)
97
+ # Normalize positions for comparison if we have a meter
98
+ normalized_pos = @meter ? position.dup.tap { |p| p.normalize!(@meter) } : position
99
+
100
+ # Find the last event at or before this position
101
+ active_event = @events.reverse.find do |event|
102
+ normalized_event_pos = @meter ? event.position.dup.tap { |p| p.normalize!(@meter) } : event.position
103
+ normalized_event_pos <= normalized_pos
104
+ end
105
+
106
+ active_event&.tempo || @events.first.tempo
107
+ end
108
+
109
+ # Iterate through tempo segments between two positions
110
+ #
111
+ # Yields each segment with its start position, end position, and tempo.
112
+ # Segments are created wherever a tempo change occurs within the range.
113
+ #
114
+ # @param from_position [MusicalPosition] start of the range
115
+ # @param to_position [MusicalPosition] end of the range
116
+ # @yield [start_position, end_position, tempo] for each segment
117
+ # @yieldparam start_position [MusicalPosition] segment start
118
+ # @yieldparam end_position [MusicalPosition] segment end
119
+ # @yieldparam tempo [HeadMusic::Rudiment::Tempo] active tempo
120
+ # @return [void]
121
+ def each_segment(from_position, to_position)
122
+ # Normalize positions if we have a meter
123
+ from_pos = @meter ? from_position.dup.tap { |p| p.normalize!(@meter) } : from_position
124
+ to_pos = @meter ? to_position.dup.tap { |p| p.normalize!(@meter) } : to_position
125
+
126
+ # Find events that affect this range
127
+ relevant_events = @events.select do |event|
128
+ normalized_event_pos = @meter ? event.position.dup.tap { |p| p.normalize!(@meter) } : event.position
129
+ normalized_event_pos < to_pos
130
+ end
131
+
132
+ # Start with the tempo active at from_position
133
+ current_pos = from_pos
134
+ current_tempo = tempo_at(from_pos)
135
+
136
+ # Iterate through relevant events
137
+ relevant_events.each do |event|
138
+ normalized_event_pos = @meter ? event.position.dup.tap { |p| p.normalize!(@meter) } : event.position
139
+
140
+ # Skip events before our starting position
141
+ next if normalized_event_pos <= from_pos
142
+
143
+ # Yield the segment up to this event
144
+ yield current_pos, normalized_event_pos, current_tempo
145
+
146
+ # Move to next segment
147
+ current_pos = normalized_event_pos
148
+ current_tempo = event.tempo
149
+ end
150
+
151
+ # Yield the final segment to the end position
152
+ yield current_pos, to_pos, current_tempo
153
+ end
154
+
155
+ # Set the meter for position normalization
156
+ #
157
+ # @param meter [HeadMusic::Rudiment::Meter] the meter to use
158
+ # @return [void]
159
+ # @api private
160
+ attr_writer :meter
161
+
162
+ private
163
+
164
+ # Sort events by position
165
+ #
166
+ # @return [void]
167
+ def sort_events!
168
+ @events.sort_by! do |event|
169
+ pos = event.position
170
+ [pos.bar, pos.beat, pos.tick, pos.subtick]
171
+ end
172
+ end
173
+
174
+ # Check if two positions are equal
175
+ #
176
+ # @param pos1 [MusicalPosition] first position
177
+ # @param pos2 [MusicalPosition] second position
178
+ # @return [Boolean] true if positions are equal
179
+ def positions_equal?(pos1, pos2)
180
+ pos1.bar == pos2.bar &&
181
+ pos1.beat == pos2.beat &&
182
+ pos1.tick == pos2.tick &&
183
+ pos1.subtick == pos2.subtick
184
+ end
185
+ end
186
+ end
187
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HeadMusic
4
+ # The Time module provides classes and methods to handle representations
5
+ # of musical time and its relationship to clock time and SMPTE Time Code.
6
+ #
7
+ # This module enables synchronization between three time representations:
8
+ # - Clock time: elapsed nanoseconds (source of truth)
9
+ # - Musical position: bars:beats:ticks:subticks notation
10
+ # - SMPTE timecode: hours:minutes:seconds:frames for video/audio sync
11
+ #
12
+ # @example Converting between time representations
13
+ # conductor = HeadMusic::Time::Conductor.new
14
+ # clock_pos = HeadMusic::Time::ClockPosition.new(1_000_000_000) # 1 second
15
+ # musical_pos = conductor.clock_to_musical(clock_pos)
16
+ module Time
17
+ # Ticks per quarter note value (MIDI standard)
18
+ PPQN = PULSES_PER_QUARTER_NOTE = 960
19
+
20
+ # Subticks provide finer resolution than ticks for precise timing
21
+ SUBTICKS_PER_TICK = 240
22
+ end
23
+ end
24
+
25
+ require_relative "time/clock_position"
26
+ require_relative "time/musical_position"
27
+ require_relative "time/smpte_timecode"
28
+ require_relative "time/meter_event"
29
+ require_relative "time/tempo_event"
30
+ require_relative "time/tempo_map"
31
+ require_relative "time/meter_map"
32
+ require_relative "time/conductor"
@@ -0,0 +1,27 @@
1
+ # A namespace for utilities classes and modules
2
+ module HeadMusic::Utilities; end
3
+
4
+ # Util for converting an object to a particular case
5
+ class HeadMusic::Utilities::Case
6
+ def self.to_snake_case(text)
7
+ text.to_s
8
+ .gsub("::", "/")
9
+ .gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
10
+ .gsub(/([a-z\d])([A-Z])/, '\1_\2')
11
+ .tr("-", "_")
12
+ .tr(" ", "_")
13
+ .gsub(/[^\w\/]+/, "_")
14
+ .squeeze("_")
15
+ .gsub(/^_|_$/, "")
16
+ .downcase
17
+ end
18
+
19
+ def self.to_kebab_case(text)
20
+ to_snake_case(text).tr("_", "-")
21
+ end
22
+
23
+ def self.to_camel_case(text)
24
+ str = to_snake_case(text)
25
+ str.split("_").map.with_index { |word, index| index.zero? ? word : word.capitalize }.join
26
+ end
27
+ end
@@ -22,7 +22,7 @@ class HeadMusic::Utilities::HashKey
22
22
 
23
23
  def normalized_string
24
24
  @normalized_string ||=
25
- transliterated_string.downcase.gsub(/\W+/, "_")
25
+ HeadMusic::Utilities::Case.to_snake_case(transliterated_string)
26
26
  end
27
27
 
28
28
  def transliterated_string
@@ -1,3 +1,3 @@
1
1
  module HeadMusic
2
- VERSION = "9.0.1"
2
+ VERSION = "11.1.0"
3
3
  end
data/lib/head_music.rb CHANGED
@@ -13,19 +13,36 @@ require "humanize"
13
13
  require "i18n"
14
14
  require "i18n/backend/fallbacks"
15
15
 
16
- I18n::Backend::Simple.include I18n::Backend::Fallbacks
16
+ # Configure I18n for HeadMusic locales
17
+ # Include fallbacks backend if not already included
18
+ I18n::Backend::Simple.include(I18n::Backend::Fallbacks) unless I18n::Backend::Simple.included_modules.include?(I18n::Backend::Fallbacks)
19
+
20
+ # Add HeadMusic locale files to the load path (additive, doesn't overwrite)
17
21
  I18n.load_path += Dir[File.join(File.dirname(__dir__), "lib", "head_music", "locales", "*.yml")]
18
- I18n.config.available_locales = %i[en fr de it ru es en_US en_GB]
19
- I18n.default_locale = :en
20
- I18n.fallbacks[:de] = %i[de en_GB en]
21
- I18n.fallbacks[:en_US] = %i[en_US en en_GB]
22
- I18n.fallbacks[:en_GB] = %i[en_GB en en_US]
23
- I18n.fallbacks[:es] = %i[es en]
24
- I18n.fallbacks[:fr] = %i[fr en_GB en]
25
- I18n.fallbacks[:it] = %i[it en_GB en]
26
- I18n.fallbacks[:ru] = %i[ru en_GB en]
22
+
23
+ # Add HeadMusic locales to available locales (additive, doesn't overwrite existing)
24
+ HEAD_MUSIC_LOCALES = %i[en fr de it ru es en_US en_GB].freeze
25
+ existing_locales = I18n.config.available_locales || []
26
+ I18n.config.available_locales = (existing_locales + HEAD_MUSIC_LOCALES).uniq
27
+
28
+ # Configure fallbacks for HeadMusic locales (only if not already configured)
29
+ # These provide sensible defaults for music terminology translations
30
+ HEAD_MUSIC_FALLBACKS = {
31
+ de: %i[de en_GB en],
32
+ en_US: %i[en_US en en_GB],
33
+ en_GB: %i[en_GB en en_US],
34
+ es: %i[es en],
35
+ fr: %i[fr en_GB en],
36
+ it: %i[it en_GB en],
37
+ ru: %i[ru en_GB en]
38
+ }.freeze
39
+
40
+ HEAD_MUSIC_FALLBACKS.each do |locale, fallbacks|
41
+ I18n.fallbacks[locale] = fallbacks if I18n.fallbacks[locale].empty? || I18n.fallbacks[locale] == [locale]
42
+ end
27
43
 
28
44
  # utilities
45
+ require "head_music/utilities/case"
29
46
  require "head_music/utilities/hash_key"
30
47
 
31
48
  # modules
@@ -58,7 +75,6 @@ require "head_music/rudiment/mode"
58
75
  require "head_music/rudiment/key_signature"
59
76
  require "head_music/rudiment/key_signature/enharmonic_equivalence"
60
77
  require "head_music/rudiment/meter"
61
- require "head_music/rudiment/musical_symbol"
62
78
  require "head_music/rudiment/pitch/enharmonic_equivalence"
63
79
  require "head_music/rudiment/pitch/octave_equivalence"
64
80
  require "head_music/rudiment/pitch_class"
@@ -75,17 +91,26 @@ require "head_music/rudiment/tuning/just_intonation"
75
91
  require "head_music/rudiment/tuning/meantone"
76
92
  require "head_music/rudiment/tuning/pythagorean"
77
93
 
94
+ # time
95
+ require "head_music/time"
96
+
78
97
  # instruments
79
98
  require "head_music/instruments/instrument_family"
80
- require "head_music/instruments/instrument_type"
81
99
  require "head_music/instruments/variant"
100
+ require "head_music/instruments/playing_technique"
82
101
  require "head_music/instruments/staff_scheme"
83
102
  require "head_music/instruments/staff"
103
+ require "head_music/instruments/instrument_configuration_option"
104
+ require "head_music/instruments/instrument_configuration"
84
105
  require "head_music/instruments/instrument"
106
+ require "head_music/instruments/stringing_course"
107
+ require "head_music/instruments/stringing"
108
+ require "head_music/instruments/alternate_tuning"
85
109
  require "head_music/instruments/score_order"
86
110
 
87
111
  # content
88
112
  require "head_music/content/bar"
113
+ require "head_music/content/cantus_firmus_examples"
89
114
  require "head_music/content/composition"
90
115
  require "head_music/content/note"
91
116
  require "head_music/content/placement"
@@ -93,6 +118,9 @@ require "head_music/content/position"
93
118
  require "head_music/content/staff"
94
119
  require "head_music/content/voice"
95
120
 
121
+ # notation
122
+ require "head_music/notation"
123
+
96
124
  # analysis
97
125
  require "head_music/analysis/circle"
98
126
  require "head_music/analysis/diatonic_interval"
@@ -101,13 +129,14 @@ require "head_music/analysis/diatonic_interval/naming"
101
129
  require "head_music/analysis/diatonic_interval/parser"
102
130
  require "head_music/analysis/diatonic_interval/semitones"
103
131
  require "head_music/analysis/diatonic_interval/size"
132
+ require "head_music/analysis/dyad"
104
133
  require "head_music/analysis/harmonic_interval"
105
134
  require "head_music/analysis/interval_consonance"
106
135
  require "head_music/analysis/interval_cycle"
107
136
  require "head_music/analysis/melodic_interval"
108
137
  require "head_music/analysis/motion"
109
138
  require "head_music/analysis/pitch_class_set"
110
- require "head_music/analysis/pitch_set"
139
+ require "head_music/analysis/pitch_collection"
111
140
  require "head_music/analysis/sonority"
112
141
 
113
142
  # style analysis
@@ -0,0 +1,183 @@
1
+ # Extract Staff Schemes to NotationStyle
2
+
3
+ AS a developer
4
+
5
+ I WANT staff schemes and notation conventions to live in a NotationStyle model
6
+
7
+ SO THAT notation concerns are separated from instrument definition and can vary independently
8
+
9
+ ## Prerequisites
10
+
11
+ This story depends on **000-overlay-architecture.md**. The NotationStyle class implements the **notation style layer** in the overlay stack, which sits between configuration and instance layers.
12
+
13
+ ## Background
14
+
15
+ The current architecture embeds staff schemes (clefs, transposition conventions, number of staves) within instrument variants. This conflates two independent concerns:
16
+
17
+ 1. **What the instrument is** - Its pitch, range, family, physical characteristics
18
+ 2. **How it's notated** - Which clefs, transposed or concert pitch, regional conventions
19
+
20
+ These concerns are orthogonal. A French horn is the same instrument whether notated in treble clef (transposed) or bass clef (concert pitch). A euphonium in a British brass band uses treble clef transposed notation, while the same instrument in an orchestra uses bass clef at concert pitch. The notation choice depends on the **tradition or context**, not the instrument itself.
21
+
22
+ ## Current State
23
+
24
+ Staff schemes are nested under variants in `instruments.yml`:
25
+
26
+ ```yaml
27
+ french_horn:
28
+ family_key: horn
29
+ variants:
30
+ default:
31
+ pitch_designation: F
32
+ staff_schemes:
33
+ bass_clef:
34
+ - clef: bass_clef
35
+ sounding_transposition: 5
36
+ default:
37
+ - clef: treble_clef
38
+ sounding_transposition: -7
39
+
40
+ euphonium:
41
+ family_key: tuba
42
+ variants:
43
+ british_band:
44
+ staff_schemes:
45
+ default:
46
+ - clef: treble_clef
47
+ sounding_transposition: -14
48
+ default:
49
+ staff_schemes:
50
+ default:
51
+ - clef: bass_clef
52
+ sounding_transposition: 0
53
+ ```
54
+
55
+ Problems with current approach:
56
+ - Euphonium has two "variants" that are really notation conventions, not different instruments
57
+ - Adding a new notation style requires modifying every instrument's variant data
58
+ - Staff scheme choices are duplicated across variants that share the same options
59
+ - Sounding transposition (a notation concern) is mixed with pitch designation (an instrument property)
60
+
61
+ ## Proposed State
62
+
63
+ Extract notation concerns to a separate NotationStyle model:
64
+
65
+ ```yaml
66
+ # instruments.yml - now purely about the instrument
67
+ euphonium:
68
+ family_key: tuba
69
+ # No variants needed - there's only one euphonium
70
+
71
+ french_horn:
72
+ family_key: horn
73
+ default_pitched_variant: f
74
+ pitched_variants:
75
+ f:
76
+ pitch_designation: F
77
+ ```
78
+
79
+ ```yaml
80
+ # notation_styles.yml - notation conventions by tradition
81
+ orchestral:
82
+ name: "Orchestral"
83
+ instrument_notations:
84
+ french_horn:
85
+ clef: treble
86
+ transposition: written # written pitch, not concert
87
+ euphonium:
88
+ clef: bass
89
+ transposition: concert
90
+ clarinet:
91
+ clef: treble
92
+ transposition: written
93
+
94
+ british_brass_band:
95
+ name: "British Brass Band"
96
+ instrument_notations:
97
+ euphonium:
98
+ clef: treble
99
+ transposition: written
100
+ tuba:
101
+ clef: treble
102
+ transposition: written
103
+
104
+ concert_pitch:
105
+ name: "Concert Pitch Score"
106
+ default_transposition: concert
107
+ instrument_notations:
108
+ french_horn:
109
+ clef: bass
110
+ ```
111
+
112
+ ## User Stories
113
+
114
+ **STORY 1: Create NotationStyle class**
115
+
116
+ AS a developer
117
+ WHEN I need to specify how instruments should be notated
118
+ I WANT to use a NotationStyle object
119
+ SO THAT notation conventions are explicit and reusable
120
+
121
+ **STORY 2: NotationStyle defines instrument notation**
122
+
123
+ AS a developer
124
+ WHEN I have a NotationStyle and an Instrument
125
+ I WANT to query the appropriate clef and transposition convention
126
+ SO THAT I can notate the instrument correctly for that tradition
127
+
128
+ **STORY 3: Remove notation from Variant**
129
+
130
+ AS a developer
131
+ WHEN I define an instrument's pitched variants
132
+ I WANT to specify only pitch-related properties
133
+ SO THAT variants are purely about the instrument, not its notation
134
+
135
+ **STORY 4: Remove euphonium "variants"**
136
+
137
+ AS a developer
138
+ WHEN I look up a euphonium
139
+ I WANT a single instrument definition
140
+ SO THAT the british_band vs orchestral distinction is handled by NotationStyle
141
+
142
+ **STORY 5: Instrument uses NotationStyle**
143
+
144
+ AS a developer
145
+ WHEN I create an Instrument for a score
146
+ I WANT to specify the notation style
147
+ SO THAT the configuration knows how to notate the instrument
148
+
149
+ ## Implementation Notes
150
+
151
+ 1. Create `HeadMusic::Notation::NotationStyle` class that responds to `[]` for layer resolution
152
+ 2. Create `notation_styles.yml` with common traditions (orchestral, british_brass_band, concert_pitch)
153
+ 3. NotationStyle provides instrument-specific overrides for notation attributes:
154
+ - `clef` - Which clef to use
155
+ - `transposition` - Sounding transposition for this notation context
156
+ - `staves` - Staff configuration (for grand staff instruments)
157
+ 4. Applied via `instrument.with_notation_style(style)` fluent builder
158
+ 5. Sounding transposition is calculated from:
159
+ - The instrument's pitch designation (e.g., Bb = -2 semitones from C)
160
+ - The notation style's transposition convention (written vs concert)
161
+ - The clef's octave displacement if any
162
+ 6. Migration path: keep backward compatibility while new system is built
163
+
164
+ ## Acceptance Criteria
165
+
166
+ - [ ] `HeadMusic::Notation::NotationStyle` class exists
167
+ - [ ] `notation_styles.yml` defines common notation traditions
168
+ - [ ] `NotationStyle.get(:orchestral)` returns appropriate style
169
+ - [ ] `notation_style.notation_for(instrument)` returns clef and transposition info
170
+ - [ ] Euphonium no longer has multiple variants
171
+ - [ ] Staff schemes removed from pitched variants
172
+ - [ ] `Instrument` accepts optional notation_style parameter
173
+ - [ ] All existing tests pass (with appropriate updates)
174
+ - [ ] New tests cover notation style functionality
175
+ - [ ] Maintains 90%+ test coverage
176
+
177
+ ## Open Questions
178
+
179
+ 1. **Percussion mappings** - Are drum kit staff mappings (bass drum on space 1, snare on line 3) part of NotationStyle or intrinsic to the instrument? Different publishers use different mappings, suggesting it's a notation concern.
180
+
181
+ 2. **Grand staff instruments** - Piano always uses grand staff. Is this intrinsic to the instrument or still a notation choice? Perhaps instruments can declare a "minimum staff structure" that notation styles must respect.
182
+
183
+ 3. **Default notation style** - What's the default if none is specified? Probably "orchestral" for most use cases.
@@ -5,7 +5,7 @@ Sequence = neutral term for the editing canvas (2D space).
5
5
  Timeline = strictly the axis.
6
6
 
7
7
 
8
- Material?
8
+
9
9
 
10
10
  ScorePart
11
11
  @name
@@ -59,6 +59,8 @@ ScorePartPlayer
59
59
 
60
60
  Player
61
61
  > person
62
+ - identity? // is a person really a person or a particular name
63
+ - distinction between a unique person and a name
62
64
 
63
65
 
64
66
 
@@ -66,7 +68,13 @@ Fragment < MusicContent
66
68
 
67
69
 
68
70
 
71
+ Material?
69
72
 
70
73
 
74
+ Material
71
75
 
76
+ Fragment < Material
72
77
 
78
+ Score < Material
79
+ - name
80
+ -