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
@@ -0,0 +1,264 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HeadMusic
4
+ module Time
5
+ # Representation of a conductor track for musical material
6
+ #
7
+ # The Conductor class synchronizes three different 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
11
+ #
12
+ # Each moment in a track corresponds to all three positions simultaneously.
13
+ # The conductor handles conversions between these representations based on
14
+ # the current tempo, meter, and framerate.
15
+ #
16
+ # @example Basic usage
17
+ # conductor = HeadMusic::Time::Conductor.new
18
+ # clock_pos = HeadMusic::Time::ClockPosition.new(1_000_000_000) # 1 second
19
+ # musical_pos = conductor.clock_to_musical(clock_pos)
20
+ # smpte = conductor.clock_to_smpte(clock_pos)
21
+ #
22
+ # @example With custom tempo and meter
23
+ # conductor = HeadMusic::Time::Conductor.new(
24
+ # starting_tempo: HeadMusic::Rudiment::Tempo.new("quarter", 96),
25
+ # starting_meter: HeadMusic::Rudiment::Meter.get("3/4")
26
+ # )
27
+ #
28
+ # @example Converting between representations
29
+ # conductor = HeadMusic::Time::Conductor.new
30
+ # musical = HeadMusic::Time::MusicalPosition.new(2, 1, 0, 0)
31
+ # clock = conductor.musical_to_clock(musical)
32
+ # smpte = conductor.clock_to_smpte(clock)
33
+ class Conductor
34
+ # @return [MusicalPosition] the musical position at clock time 0
35
+ attr_accessor :starting_musical_position
36
+
37
+ # @return [SmpteTimecode] the SMPTE timecode at clock time 0
38
+ attr_accessor :starting_smpte_timecode
39
+
40
+ # @return [Integer] frames per second for SMPTE conversions
41
+ attr_accessor :framerate
42
+
43
+ # @return [TempoMap] the tempo map for this conductor
44
+ attr_reader :tempo_map
45
+
46
+ # @return [MeterMap] the meter map for this conductor
47
+ attr_reader :meter_map
48
+
49
+ # @return [HeadMusic::Rudiment::Tempo] the initial tempo (delegates to tempo_map)
50
+ def starting_tempo
51
+ tempo_map.events.first.tempo
52
+ end
53
+
54
+ # @return [HeadMusic::Rudiment::Meter] the initial meter (delegates to meter_map)
55
+ def starting_meter
56
+ meter_map.events.first.meter
57
+ end
58
+
59
+ # Create a new conductor
60
+ #
61
+ # @param starting_musical_position [MusicalPosition] initial musical position (default: 1:1:0:0)
62
+ # @param starting_smpte_timecode [SmpteTimecode] initial SMPTE timecode (default: 00:00:00:00)
63
+ # @param framerate [Integer] frames per second (default: 30)
64
+ # @param starting_tempo [HeadMusic::Rudiment::Tempo] initial tempo (default: quarter = 120)
65
+ # @param starting_meter [HeadMusic::Rudiment::Meter, String] initial meter (default: 4/4)
66
+ # @param tempo_map [TempoMap] custom tempo map (optional, creates one from starting_tempo if not provided)
67
+ # @param meter_map [MeterMap] custom meter map (optional, creates one from starting_meter if not provided)
68
+ def initialize(
69
+ starting_musical_position: nil,
70
+ starting_smpte_timecode: nil,
71
+ framerate: SmpteTimecode::DEFAULT_FRAMERATE,
72
+ starting_tempo: nil,
73
+ starting_meter: nil,
74
+ tempo_map: nil,
75
+ meter_map: nil
76
+ )
77
+ @starting_musical_position = starting_musical_position || MusicalPosition.new
78
+ @starting_smpte_timecode = starting_smpte_timecode || SmpteTimecode.new(0, 0, 0, 0, framerate: framerate)
79
+ @framerate = framerate
80
+
81
+ # Create or use provided maps
82
+ @tempo_map = tempo_map || TempoMap.new(
83
+ starting_tempo: starting_tempo || HeadMusic::Rudiment::Tempo.new("quarter", 120),
84
+ starting_position: @starting_musical_position
85
+ )
86
+ @meter_map = meter_map || MeterMap.new(
87
+ starting_meter: starting_meter || "4/4",
88
+ starting_position: @starting_musical_position
89
+ )
90
+
91
+ # Link maps together for position normalization
92
+ @tempo_map.meter = @meter_map.meter_at(@starting_musical_position)
93
+ end
94
+
95
+ # Convert clock position to musical position
96
+ #
97
+ # Uses the tempo map to determine how many beats have elapsed,
98
+ # accounting for tempo changes along the timeline.
99
+ #
100
+ # @param clock_position [ClockPosition] the clock time to convert
101
+ # @return [MusicalPosition] the corresponding musical position
102
+ def clock_to_musical(clock_position)
103
+ target_nanoseconds = clock_position.nanoseconds
104
+ accumulated_nanoseconds = 0
105
+ current_position = starting_musical_position
106
+
107
+ # We need an end position far enough to contain our target clock time
108
+ # Start with a reasonable guess and extend if needed
109
+ estimated_end_bar = starting_musical_position.bar + 1000
110
+ estimated_end = MusicalPosition.new(estimated_end_bar, 1, 0, 0)
111
+
112
+ tempo_map.each_segment(starting_musical_position, estimated_end) do |start_pos, end_pos, tempo|
113
+ meter = meter_map.meter_at(start_pos)
114
+
115
+ # Calculate clock duration of this segment
116
+ start_subticks = musical_position_to_subticks(start_pos, meter)
117
+ end_subticks = musical_position_to_subticks(end_pos, meter)
118
+ segment_subticks = end_subticks - start_subticks
119
+ segment_ticks = segment_subticks / HeadMusic::Time::SUBTICKS_PER_TICK.to_f
120
+ segment_nanoseconds = (segment_ticks * tempo.tick_duration_in_nanoseconds).round
121
+
122
+ # Check if our target falls within this segment
123
+ if accumulated_nanoseconds + segment_nanoseconds >= target_nanoseconds
124
+ # Target is in this segment - calculate exact position
125
+ remaining_nanoseconds = target_nanoseconds - accumulated_nanoseconds
126
+ remaining_ticks = remaining_nanoseconds / tempo.tick_duration_in_nanoseconds.to_f
127
+ remaining_subticks = (remaining_ticks * HeadMusic::Time::SUBTICKS_PER_TICK).round
128
+
129
+ # Add to start position of this segment
130
+ total_subticks = start_subticks + remaining_subticks
131
+
132
+ # Convert to bar:beat:tick:subtick
133
+ ticks_per_count = meter.ticks_per_count
134
+ counts_per_bar = meter.counts_per_bar
135
+ subticks_per_count = ticks_per_count * HeadMusic::Time::SUBTICKS_PER_TICK
136
+ subticks_per_bar = counts_per_bar * subticks_per_count
137
+
138
+ bars = (total_subticks / subticks_per_bar).floor
139
+ remaining = total_subticks % subticks_per_bar
140
+
141
+ beats = (remaining / subticks_per_count).floor
142
+ remaining %= subticks_per_count
143
+
144
+ ticks = (remaining / HeadMusic::Time::SUBTICKS_PER_TICK).floor
145
+ subticks = remaining % HeadMusic::Time::SUBTICKS_PER_TICK
146
+
147
+ position = MusicalPosition.new(bars + 1, beats + 1, ticks, subticks)
148
+ return position.normalize!(meter)
149
+ end
150
+
151
+ accumulated_nanoseconds += segment_nanoseconds
152
+ current_position = end_pos
153
+ end
154
+
155
+ # If we get here, return the last position (shouldn't normally happen)
156
+ current_position
157
+ end
158
+
159
+ # Convert musical position to clock position
160
+ #
161
+ # Uses the tempo map to determine how much clock time has elapsed
162
+ # based on the musical position, accounting for tempo changes.
163
+ #
164
+ # @param musical_position [MusicalPosition] the musical position to convert
165
+ # @return [ClockPosition] the corresponding clock time
166
+ def musical_to_clock(musical_position)
167
+ total_nanoseconds = 0
168
+
169
+ # Iterate through each tempo segment from start to target position
170
+ tempo_map.each_segment(starting_musical_position, musical_position) do |start_pos, end_pos, tempo|
171
+ # Get the meter for this segment to calculate subticks correctly
172
+ meter = meter_map.meter_at(start_pos)
173
+
174
+ # Calculate subticks in this segment
175
+ start_subticks = musical_position_to_subticks(start_pos, meter)
176
+ end_subticks = musical_position_to_subticks(end_pos, meter)
177
+ segment_subticks = end_subticks - start_subticks
178
+
179
+ # Convert subticks to ticks
180
+ segment_ticks = segment_subticks / HeadMusic::Time::SUBTICKS_PER_TICK.to_f
181
+
182
+ # Convert ticks to nanoseconds using this segment's tempo
183
+ nanoseconds_per_tick = tempo.tick_duration_in_nanoseconds
184
+ segment_nanoseconds = (segment_ticks * nanoseconds_per_tick).round
185
+
186
+ total_nanoseconds += segment_nanoseconds
187
+ end
188
+
189
+ ClockPosition.new(total_nanoseconds)
190
+ end
191
+
192
+ # Convert clock position to SMPTE timecode
193
+ #
194
+ # Uses the framerate to determine the timecode.
195
+ #
196
+ # @param clock_position [ClockPosition] the clock time to convert
197
+ # @return [SmpteTimecode] the corresponding SMPTE timecode
198
+ def clock_to_smpte(clock_position)
199
+ # Calculate total frames from nanoseconds
200
+ nanoseconds_per_second = 1_000_000_000.0
201
+ elapsed_seconds = clock_position.nanoseconds / nanoseconds_per_second
202
+ total_frames = (elapsed_seconds * framerate).round
203
+
204
+ # Add starting timecode frames
205
+ starting_frames = starting_smpte_timecode.to_total_frames
206
+ total_frames += starting_frames
207
+
208
+ # Convert frames to HH:MM:SS:FF
209
+ hours = total_frames / (framerate * 60 * 60)
210
+ remaining = total_frames % (framerate * 60 * 60)
211
+
212
+ minutes = remaining / (framerate * 60)
213
+ remaining %= (framerate * 60)
214
+
215
+ seconds = remaining / framerate
216
+ frames = remaining % framerate
217
+
218
+ timecode = SmpteTimecode.new(hours, minutes, seconds, frames, framerate: framerate)
219
+ timecode.normalize!
220
+ end
221
+
222
+ # Convert SMPTE timecode to clock position
223
+ #
224
+ # Uses the framerate to determine the clock time.
225
+ #
226
+ # @param smpte_timecode [SmpteTimecode] the SMPTE timecode to convert
227
+ # @return [ClockPosition] the corresponding clock time
228
+ def smpte_to_clock(smpte_timecode)
229
+ # Calculate total frames
230
+ total_frames = smpte_timecode.to_total_frames
231
+ starting_frames = starting_smpte_timecode.to_total_frames
232
+ elapsed_frames = total_frames - starting_frames
233
+
234
+ # Convert frames to seconds, then to nanoseconds
235
+ nanoseconds_per_second = 1_000_000_000.0
236
+ elapsed_seconds = elapsed_frames / framerate.to_f
237
+ elapsed_nanoseconds = (elapsed_seconds * nanoseconds_per_second).round
238
+
239
+ ClockPosition.new(elapsed_nanoseconds)
240
+ end
241
+
242
+ private
243
+
244
+ # Convert a musical position to total subticks for calculation
245
+ #
246
+ # @param position [MusicalPosition] the position to convert
247
+ # @param meter [HeadMusic::Rudiment::Meter] the meter to use for calculation
248
+ # @return [Integer] total subticks from the beginning
249
+ def musical_position_to_subticks(position, meter = nil)
250
+ meter ||= meter_map.meter_at(position)
251
+ ticks_per_count = meter.ticks_per_count
252
+ counts_per_bar = meter.counts_per_bar
253
+
254
+ total = 0
255
+ total += (position.bar - 1) * counts_per_bar * ticks_per_count * HeadMusic::Time::SUBTICKS_PER_TICK
256
+ total += (position.beat - 1) * ticks_per_count * HeadMusic::Time::SUBTICKS_PER_TICK
257
+ total += position.tick * HeadMusic::Time::SUBTICKS_PER_TICK
258
+ total += position.subtick
259
+
260
+ total
261
+ end
262
+ end
263
+ end
264
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HeadMusic
4
+ module Time
5
+ # Represents a meter change at a specific musical position
6
+ #
7
+ # MeterEvent marks a point in a musical timeline where the meter
8
+ # (time signature) changes. This is essential for properly calculating
9
+ # musical positions and normalizing bar:beat:tick:subtick values.
10
+ #
11
+ # @example Creating a meter change to 3/4 at bar 5
12
+ # position = HeadMusic::Time::MusicalPosition.new(5, 1, 0, 0)
13
+ # meter = HeadMusic::Rudiment::Meter.get("3/4")
14
+ # event = HeadMusic::Time::MeterEvent.new(position, meter)
15
+ #
16
+ # @example With common time
17
+ # position = HeadMusic::Time::MusicalPosition.new(1, 1, 0, 0)
18
+ # meter = HeadMusic::Rudiment::Meter.common_time
19
+ # event = HeadMusic::Time::MeterEvent.new(position, meter)
20
+ class MeterEvent
21
+ # @return [MusicalPosition] the position where this meter change occurs
22
+ attr_accessor :position
23
+
24
+ # @return [HeadMusic::Rudiment::Meter, String] the meter (time signature)
25
+ attr_accessor :meter
26
+
27
+ # Create a new meter change event
28
+ #
29
+ # @param position [MusicalPosition] where the meter change occurs
30
+ # @param meter [HeadMusic::Rudiment::Meter, String] the new meter
31
+ def initialize(position, meter)
32
+ @position = position
33
+ @meter = meter
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,173 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HeadMusic
4
+ module Time
5
+ # Manages meter (time signature) changes along a musical timeline
6
+ #
7
+ # A MeterMap maintains a sorted list of meter changes at specific musical
8
+ # positions, allowing you to determine which meter is active at any point
9
+ # and iterate through meter segments for musical position calculations.
10
+ #
11
+ # This is essential for normalizing musical positions when the meter changes
12
+ # during a composition.
13
+ #
14
+ # @example Basic usage
15
+ # meter_map = HeadMusic::Time::MeterMap.new
16
+ # meter_map.add_change(MusicalPosition.new(5, 1, 0, 0), "3/4")
17
+ # meter_map.add_change(MusicalPosition.new(9, 1, 0, 0), "6/8")
18
+ #
19
+ # meter = meter_map.meter_at(MusicalPosition.new(7, 1, 0, 0))
20
+ # meter.to_s # => "3/4"
21
+ #
22
+ # @example Iterating through segments
23
+ # from = MusicalPosition.new(1, 1, 0, 0)
24
+ # to = MusicalPosition.new(10, 1, 0, 0)
25
+ # meter_map.each_segment(from, to) do |start_pos, end_pos, meter|
26
+ # # Process each meter segment
27
+ # end
28
+ class MeterMap
29
+ # @return [Array<MeterEvent>] all meter events in chronological order
30
+ attr_reader :events
31
+
32
+ # Create a new meter map
33
+ #
34
+ # @param starting_meter [HeadMusic::Rudiment::Meter, String] initial meter (default: 4/4)
35
+ # @param starting_position [MusicalPosition] where the initial meter begins (default: 1:1:0:0)
36
+ def initialize(starting_meter: nil, starting_position: nil)
37
+ starting_meter = HeadMusic::Rudiment::Meter.get(starting_meter || "4/4")
38
+ starting_position ||= MusicalPosition.new
39
+ @events = [MeterEvent.new(starting_position, starting_meter)]
40
+ end
41
+
42
+ # Add a meter change at the specified position
43
+ #
44
+ # If a meter change already exists at this position, it will be replaced.
45
+ # Events are automatically maintained in sorted order.
46
+ #
47
+ # @param position [MusicalPosition] where the meter change occurs
48
+ # @param meter_or_identifier [String, HeadMusic::Rudiment::Meter] either a meter string like "3/4" or a Meter object
49
+ # @return [MeterEvent] the created event
50
+ def add_change(position, meter_or_identifier)
51
+ # Remove any existing event at this position (except the first)
52
+ remove_change(position)
53
+
54
+ # Create the new event
55
+ meter = meter_or_identifier.is_a?(HeadMusic::Rudiment::Meter) ? meter_or_identifier : HeadMusic::Rudiment::Meter.get(meter_or_identifier)
56
+ event = MeterEvent.new(position, meter)
57
+
58
+ @events << event
59
+ sort_events!
60
+ event
61
+ end
62
+
63
+ # Remove a meter change at the specified position
64
+ #
65
+ # The starting meter (first event) cannot be removed.
66
+ #
67
+ # @param position [MusicalPosition] the position of the event to remove
68
+ # @return [void]
69
+ def remove_change(position)
70
+ @events.reject! do |event|
71
+ event != @events.first && positions_equal?(event.position, position)
72
+ end
73
+ end
74
+
75
+ # Remove all meter changes except the starting meter
76
+ #
77
+ # @return [void]
78
+ def clear_changes
79
+ @events = [@events.first]
80
+ end
81
+
82
+ # Find the meter active at a given position
83
+ #
84
+ # Returns the meter from the most recent meter event at or before
85
+ # the specified position.
86
+ #
87
+ # @param position [MusicalPosition] the position to query
88
+ # @return [HeadMusic::Rudiment::Meter] the active meter
89
+ def meter_at(position)
90
+ # Find the last event at or before this position
91
+ # We need to compare positions carefully since they might not be normalized
92
+ active_event = @events.reverse.find do |event|
93
+ compare_positions(event.position, position) <= 0
94
+ end
95
+
96
+ active_event&.meter || @events.first.meter
97
+ end
98
+
99
+ # Iterate through meter segments between two positions
100
+ #
101
+ # Yields each segment with its start position, end position, and meter.
102
+ # Segments are created wherever a meter change occurs within the range.
103
+ #
104
+ # @param from_position [MusicalPosition] start of the range
105
+ # @param to_position [MusicalPosition] end of the range
106
+ # @yield [start_position, end_position, meter] for each segment
107
+ # @yieldparam start_position [MusicalPosition] segment start
108
+ # @yieldparam end_position [MusicalPosition] segment end
109
+ # @yieldparam meter [HeadMusic::Rudiment::Meter] active meter
110
+ # @return [void]
111
+ def each_segment(from_position, to_position)
112
+ # Find events that affect this range
113
+ relevant_events = @events.select do |event|
114
+ compare_positions(event.position, to_position) < 0
115
+ end
116
+
117
+ # Start with the meter active at from_position
118
+ current_pos = from_position
119
+ current_meter = meter_at(from_position)
120
+
121
+ # Iterate through relevant events
122
+ relevant_events.each do |event|
123
+ # Skip events before our starting position
124
+ next if compare_positions(event.position, from_position) <= 0
125
+
126
+ # Yield the segment up to this event
127
+ yield current_pos, event.position, current_meter
128
+
129
+ # Move to next segment
130
+ current_pos = event.position
131
+ current_meter = event.meter
132
+ end
133
+
134
+ # Yield the final segment to the end position
135
+ yield current_pos, to_position, current_meter
136
+ end
137
+
138
+ private
139
+
140
+ # Sort events by position
141
+ #
142
+ # @return [void]
143
+ def sort_events!
144
+ @events.sort_by! do |event|
145
+ pos = event.position
146
+ [pos.bar, pos.beat, pos.tick, pos.subtick]
147
+ end
148
+ end
149
+
150
+ # Check if two positions are equal
151
+ #
152
+ # @param pos1 [MusicalPosition] first position
153
+ # @param pos2 [MusicalPosition] second position
154
+ # @return [Boolean] true if positions are equal
155
+ def positions_equal?(pos1, pos2)
156
+ pos1.bar == pos2.bar &&
157
+ pos1.beat == pos2.beat &&
158
+ pos1.tick == pos2.tick &&
159
+ pos1.subtick == pos2.subtick
160
+ end
161
+
162
+ # Compare two positions
163
+ #
164
+ # @param pos1 [MusicalPosition] first position
165
+ # @param pos2 [MusicalPosition] second position
166
+ # @return [Integer] -1, 0, or 1
167
+ def compare_positions(pos1, pos2)
168
+ [pos1.bar, pos1.beat, pos1.tick, pos1.subtick] <=>
169
+ [pos2.bar, pos2.beat, pos2.tick, pos2.subtick]
170
+ end
171
+ end
172
+ end
173
+ end
@@ -0,0 +1,188 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HeadMusic
4
+ module Time
5
+ # Representation of a musical position in bars:beats:ticks:subticks notation
6
+ #
7
+ # A MusicalPosition represents a point in musical time using a hierarchical
8
+ # structure:
9
+ # - bar: the measure number (1-indexed)
10
+ # - beat: the beat within the bar (1-indexed)
11
+ # - tick: subdivision of a beat (0-indexed, 960 ticks per quarter note)
12
+ # - subtick: finest resolution (0-indexed, 240 subticks per tick)
13
+ #
14
+ # The position can be normalized according to a meter, which handles
15
+ # overflow by carrying excess values to higher levels (e.g., excess ticks
16
+ # become beats, excess beats become bars).
17
+ #
18
+ # @example Creating a position at bar 1, beat 1
19
+ # position = HeadMusic::Time::MusicalPosition.new
20
+ # position.to_s # => "1:1:0:0"
21
+ #
22
+ # @example Parsing from a string
23
+ # position = HeadMusic::Time::MusicalPosition.parse("2:3:480:0")
24
+ # position.bar # => 2
25
+ # position.beat # => 3
26
+ #
27
+ # @example Normalizing with overflow
28
+ # meter = HeadMusic::Rudiment::Meter.get("4/4")
29
+ # position = HeadMusic::Time::MusicalPosition.new(1, 1, 960, 0)
30
+ # position.normalize!(meter)
31
+ # position.to_s # => "1:2:0:0" (ticks carried into beats)
32
+ #
33
+ # @example Comparing positions
34
+ # pos1 = HeadMusic::Time::MusicalPosition.new(1, 1, 0, 0)
35
+ # pos2 = HeadMusic::Time::MusicalPosition.new(1, 2, 0, 0)
36
+ # meter = HeadMusic::Rudiment::Meter.get("4/4")
37
+ # pos1.normalize!(meter)
38
+ # pos2.normalize!(meter)
39
+ # pos1 < pos2 # => true
40
+ class MusicalPosition
41
+ include Comparable
42
+
43
+ # @return [Integer] the bar (measure) number (1-indexed)
44
+ attr_reader :bar
45
+
46
+ # @return [Integer] the beat within the bar (1-indexed)
47
+ attr_reader :beat
48
+
49
+ # @return [Integer] the tick within the beat (0-indexed)
50
+ attr_reader :tick
51
+
52
+ # @return [Integer] the subtick within the tick (0-indexed)
53
+ attr_reader :subtick
54
+
55
+ # Default starting bar number
56
+ DEFAULT_FIRST_BAR = 1
57
+
58
+ # First beat in a bar
59
+ FIRST_BEAT = 1
60
+
61
+ # First tick in a beat
62
+ FIRST_TICK = 0
63
+
64
+ # First subtick in a tick
65
+ FIRST_SUBTICK = 0
66
+
67
+ # Parse a position from a string representation
68
+ #
69
+ # @param identifier [String] position in "bar:beat:tick:subtick" format
70
+ # @return [MusicalPosition] the parsed position
71
+ # @example
72
+ # MusicalPosition.parse("2:3:480:120")
73
+ def self.parse(identifier)
74
+ new(*identifier.scan(/\d+/)[0..3])
75
+ end
76
+
77
+ # Create a new musical position
78
+ #
79
+ # @param bar [Integer, String] the bar number (default: 1)
80
+ # @param beat [Integer, String] the beat number (default: 1)
81
+ # @param tick [Integer, String] the tick number (default: 0)
82
+ # @param subtick [Integer, String] the subtick number (default: 0)
83
+ def initialize(
84
+ bar = DEFAULT_FIRST_BAR,
85
+ beat = FIRST_BEAT,
86
+ tick = FIRST_TICK,
87
+ subtick = FIRST_SUBTICK
88
+ )
89
+ @bar = bar.to_i
90
+ @beat = beat.to_i
91
+ @tick = tick.to_i
92
+ @subtick = subtick.to_i
93
+ @meter = nil
94
+ @total_subticks = nil
95
+ end
96
+
97
+ # Convert position to array format
98
+ #
99
+ # @return [Array<Integer>] [bar, beat, tick, subtick]
100
+ def to_a
101
+ [bar, beat, tick, subtick]
102
+ end
103
+
104
+ # Convert position to string format
105
+ #
106
+ # @return [String] position in "bar:beat:tick:subtick" format
107
+ def to_s
108
+ "#{bar}:#{beat}:#{tick}:#{subtick}"
109
+ end
110
+
111
+ # Normalize the position according to a meter, handling overflow
112
+ #
113
+ # This method modifies the position in place, carrying excess values
114
+ # from lower levels to higher levels (subticks → ticks → beats → bars).
115
+ # Also handles negative values by borrowing from higher levels.
116
+ #
117
+ # @param meter [HeadMusic::Rudiment::Meter, nil] the meter to use for normalization
118
+ # @return [self] returns self for method chaining
119
+ # @example
120
+ # meter = HeadMusic::Rudiment::Meter.get("4/4")
121
+ # position = MusicalPosition.new(1, 1, 960, 240)
122
+ # position.normalize!(meter) # => "1:2:1:0"
123
+ def normalize!(meter)
124
+ return self unless meter
125
+
126
+ @meter = meter
127
+ @total_subticks = nil # Invalidate cached value
128
+
129
+ # Carry subticks into ticks
130
+ if subtick >= HeadMusic::Time::SUBTICKS_PER_TICK || subtick.negative?
131
+ tick_delta, @subtick = subtick.divmod(HeadMusic::Time::SUBTICKS_PER_TICK)
132
+ @tick += tick_delta
133
+ end
134
+
135
+ # Carry ticks into beats
136
+ if tick >= meter.ticks_per_count || tick.negative?
137
+ beat_delta, @tick = tick.divmod(meter.ticks_per_count)
138
+ @beat += beat_delta
139
+ end
140
+
141
+ # Carry beats into bars
142
+ if beat >= meter.counts_per_bar || beat.negative?
143
+ bar_delta, @beat = beat.divmod(meter.counts_per_bar)
144
+ @bar += bar_delta
145
+ end
146
+
147
+ self
148
+ end
149
+
150
+ # Compare this position to another
151
+ #
152
+ # Note: For accurate comparison, both positions should be normalized
153
+ # with the same meter first.
154
+ #
155
+ # @param other [MusicalPosition] another position to compare
156
+ # @return [Integer] -1 if less than, 0 if equal, 1 if greater than
157
+ def <=>(other)
158
+ to_total_subticks <=> other.to_total_subticks
159
+ end
160
+
161
+ # Convert position to total subticks for comparison and calculation
162
+ #
163
+ # @return [Integer] total subticks from the beginning
164
+ # @note This calculation assumes the position has been normalized
165
+ def to_total_subticks
166
+ return @total_subticks if @total_subticks
167
+
168
+ # Calculate based on the structure
169
+ # Note: This is a simplified calculation that assumes consistent meter
170
+ ticks_per_count = @meter&.ticks_per_count || HeadMusic::Time::PPQN
171
+ counts_per_bar = @meter&.counts_per_bar || 4
172
+
173
+ total = 0
174
+ total += (bar - 1) * counts_per_bar * ticks_per_count * HeadMusic::Time::SUBTICKS_PER_TICK
175
+ total += (beat - 1) * ticks_per_count * HeadMusic::Time::SUBTICKS_PER_TICK
176
+ total += tick * HeadMusic::Time::SUBTICKS_PER_TICK
177
+ total += subtick
178
+
179
+ @total_subticks = total
180
+ end
181
+
182
+ protected
183
+
184
+ # Allow other MusicalPosition instances to access this method for comparison
185
+ alias_method :to_i, :to_total_subticks
186
+ end
187
+ end
188
+ end