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,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
@@ -0,0 +1,164 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HeadMusic
4
+ module Time
5
+ # Represents a SMPTE (Society of Motion Picture and Television Engineers) timecode
6
+ #
7
+ # SMPTE timecode is used for synchronizing audio and video in professional
8
+ # production. It represents time as HH:MM:SS:FF (hours:minutes:seconds:frames).
9
+ #
10
+ # The framerate determines how many frames occur per second. Common framerates:
11
+ # - 24 fps: Film standard
12
+ # - 25 fps: PAL video standard (Europe)
13
+ # - 30 fps: NTSC video standard (North America)
14
+ # - 29.97 fps: NTSC drop-frame
15
+ #
16
+ # @example Creating a timecode at 1 hour
17
+ # timecode = HeadMusic::Time::SmpteTimecode.new(1, 0, 0, 0)
18
+ # timecode.to_s # => "01:00:00:00"
19
+ #
20
+ # @example Parsing from a string
21
+ # timecode = HeadMusic::Time::SmpteTimecode.parse("02:30:45:15")
22
+ # timecode.hour # => 2
23
+ # timecode.minute # => 30
24
+ #
25
+ # @example Normalizing with overflow
26
+ # timecode = HeadMusic::Time::SmpteTimecode.new(0, 0, 0, 60, framerate: 30)
27
+ # timecode.normalize!
28
+ # timecode.to_s # => "00:00:02:00" (frames carried into seconds)
29
+ #
30
+ # @example Comparing timecodes
31
+ # tc1 = HeadMusic::Time::SmpteTimecode.new(1, 0, 0, 0)
32
+ # tc2 = HeadMusic::Time::SmpteTimecode.new(1, 30, 0, 0)
33
+ # tc1 < tc2 # => true
34
+ class SmpteTimecode
35
+ include Comparable
36
+
37
+ # @return [Integer] the hour component
38
+ attr_reader :hour
39
+
40
+ # @return [Integer] the minute component
41
+ attr_reader :minute
42
+
43
+ # @return [Integer] the second component
44
+ attr_reader :second
45
+
46
+ # @return [Integer] the frame component
47
+ attr_reader :frame
48
+
49
+ # @return [Integer] frames per second (default: 30 for NTSC)
50
+ attr_reader :framerate
51
+
52
+ # Default framerate (30 fps NTSC)
53
+ DEFAULT_FRAMERATE = 30
54
+
55
+ # Seconds per minute
56
+ SECONDS_PER_MINUTE = 60
57
+
58
+ # Minutes per hour
59
+ MINUTES_PER_HOUR = 60
60
+
61
+ # Parse a timecode from a string representation
62
+ #
63
+ # @param identifier [String] timecode in "HH:MM:SS:FF" format
64
+ # @return [SmpteTimecode] the parsed timecode
65
+ # @example
66
+ # SmpteTimecode.parse("02:30:45:15")
67
+ def self.parse(identifier)
68
+ new(*identifier.scan(/\d+/)[0..3])
69
+ end
70
+
71
+ # Create a new SMPTE timecode
72
+ #
73
+ # @param hour [Integer, String] the hour component (default: 0)
74
+ # @param minute [Integer, String] the minute component (default: 0)
75
+ # @param second [Integer, String] the second component (default: 0)
76
+ # @param frame [Integer, String] the frame component (default: 0)
77
+ # @param framerate [Integer] frames per second (default: 30)
78
+ def initialize(hour = 0, minute = 0, second = 0, frame = 0, framerate: DEFAULT_FRAMERATE)
79
+ @hour = hour.to_i
80
+ @minute = minute.to_i
81
+ @second = second.to_i
82
+ @frame = frame.to_i
83
+ @framerate = framerate
84
+ @total_frames = nil
85
+ end
86
+
87
+ # Convert timecode to array format
88
+ #
89
+ # @return [Array<Integer>] [hour, minute, second, frame]
90
+ def to_a
91
+ [hour, minute, second, frame]
92
+ end
93
+
94
+ # Convert timecode to string format with zero padding
95
+ #
96
+ # @return [String] timecode in "HH:MM:SS:FF" format
97
+ def to_s
98
+ format("%02d:%02d:%02d:%02d", hour, minute, second, frame)
99
+ end
100
+
101
+ # Normalize the timecode, handling overflow and underflow
102
+ #
103
+ # This method modifies the timecode in place, carrying excess values
104
+ # from lower levels to higher levels (frames → seconds → minutes → hours).
105
+ # Also handles negative values by borrowing from higher levels.
106
+ #
107
+ # @return [self] returns self for method chaining
108
+ # @example
109
+ # timecode = SmpteTimecode.new(0, 0, 0, 60, framerate: 30)
110
+ # timecode.normalize! # => "00:00:02:00"
111
+ def normalize!
112
+ @total_frames = nil # Invalidate cached value
113
+
114
+ # Carry frames into seconds
115
+ if frame >= framerate || frame.negative?
116
+ second_delta, @frame = frame.divmod(framerate)
117
+ @second += second_delta
118
+ end
119
+
120
+ # Carry seconds into minutes
121
+ if second >= SECONDS_PER_MINUTE || second.negative?
122
+ minute_delta, @second = second.divmod(SECONDS_PER_MINUTE)
123
+ @minute += minute_delta
124
+ end
125
+
126
+ # Carry minutes into hours
127
+ if minute >= MINUTES_PER_HOUR || minute.negative?
128
+ hour_delta, @minute = minute.divmod(MINUTES_PER_HOUR)
129
+ @hour += hour_delta
130
+ end
131
+
132
+ self
133
+ end
134
+
135
+ # Compare this timecode to another
136
+ #
137
+ # @param other [SmpteTimecode] another timecode to compare
138
+ # @return [Integer] -1 if less than, 0 if equal, 1 if greater than
139
+ def <=>(other)
140
+ to_total_frames <=> other.to_total_frames
141
+ end
142
+
143
+ # Convert timecode to total frames from the beginning
144
+ #
145
+ # @return [Integer] total frames
146
+ def to_total_frames
147
+ return @total_frames if @total_frames
148
+
149
+ total = 0
150
+ total += hour * MINUTES_PER_HOUR * SECONDS_PER_MINUTE * framerate
151
+ total += minute * SECONDS_PER_MINUTE * framerate
152
+ total += second * framerate
153
+ total += frame
154
+
155
+ @total_frames = total
156
+ end
157
+
158
+ protected
159
+
160
+ # Allow other SmpteTimecode instances to access this method for comparison
161
+ alias_method :to_i, :to_total_frames
162
+ end
163
+ end
164
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HeadMusic
4
+ module Time
5
+ # Represents a tempo change at a specific musical position
6
+ #
7
+ # TempoEvent marks a point in a musical timeline where the tempo changes.
8
+ # This is essential for converting between clock time and musical position,
9
+ # as different tempos affect how long each beat takes in real time.
10
+ #
11
+ # @example Creating a tempo change to quarter = 120 at bar 1
12
+ # position = HeadMusic::Time::MusicalPosition.new(1, 1, 0, 0)
13
+ # event = HeadMusic::Time::TempoEvent.new(position, "quarter", 120)
14
+ #
15
+ # @example With a dotted quarter note tempo
16
+ # position = HeadMusic::Time::MusicalPosition.new(5, 1, 0, 0)
17
+ # event = HeadMusic::Time::TempoEvent.new(position, "dotted quarter", 92)
18
+ #
19
+ # @example With an eighth note tempo
20
+ # position = HeadMusic::Time::MusicalPosition.new(10, 1, 0, 0)
21
+ # event = HeadMusic::Time::TempoEvent.new(position, "eighth", 140)
22
+ class TempoEvent
23
+ # @return [MusicalPosition] the position where this tempo change occurs
24
+ attr_accessor :position
25
+
26
+ # @return [HeadMusic::Rudiment::Tempo] the tempo
27
+ attr_accessor :tempo
28
+
29
+ # Create a new tempo change event
30
+ #
31
+ # @param position [MusicalPosition] where the tempo change occurs
32
+ # @param beat_value [String] the rhythmic value that gets the beat (e.g., "quarter", "eighth")
33
+ # @param beats_per_minute [Numeric] the tempo in beats per minute
34
+ def initialize(position, beat_value, beats_per_minute)
35
+ @position = position
36
+ @tempo = HeadMusic::Rudiment::Tempo.new(beat_value, beats_per_minute)
37
+ end
38
+ end
39
+ end
40
+ end