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
@@ -45,43 +45,4 @@ class HeadMusic::Rudiment::Tuning::Meantone < HeadMusic::Rudiment::Tuning
45
45
  # Calculate the frequency
46
46
  tonal_center_frequency * ratio
47
47
  end
48
-
49
- private
50
-
51
- def calculate_tonal_center_frequency
52
- # Use equal temperament to get the tonal center frequency from the reference pitch
53
- interval_to_tonal_center = (tonal_center - reference_pitch.pitch).semitones
54
- reference_pitch_frequency * (2**(interval_to_tonal_center / 12.0))
55
- end
56
-
57
- def ratio_for_interval(semitones)
58
- # Handle octaves
59
- octaves = semitones / 12
60
- interval_within_octave = semitones % 12
61
-
62
- # Make sure we handle negative intervals
63
- if interval_within_octave < 0
64
- interval_within_octave += 12
65
- octaves -= 1
66
- end
67
-
68
- # Get the base ratio
69
- base_ratio = case interval_within_octave
70
- when 0 then INTERVAL_RATIOS[:unison]
71
- when 1 then INTERVAL_RATIOS[:minor_second]
72
- when 2 then INTERVAL_RATIOS[:major_second]
73
- when 3 then INTERVAL_RATIOS[:minor_third]
74
- when 4 then INTERVAL_RATIOS[:major_third]
75
- when 5 then INTERVAL_RATIOS[:perfect_fourth]
76
- when 6 then INTERVAL_RATIOS[:tritone]
77
- when 7 then INTERVAL_RATIOS[:perfect_fifth]
78
- when 8 then INTERVAL_RATIOS[:minor_sixth]
79
- when 9 then INTERVAL_RATIOS[:major_sixth]
80
- when 10 then INTERVAL_RATIOS[:minor_seventh]
81
- when 11 then INTERVAL_RATIOS[:major_seventh]
82
- end
83
-
84
- # Apply octave adjustments
85
- base_ratio * (2**octaves)
86
- end
87
48
  end
@@ -49,43 +49,4 @@ class HeadMusic::Rudiment::Tuning::Pythagorean < HeadMusic::Rudiment::Tuning
49
49
  # Calculate the frequency
50
50
  tonal_center_frequency * ratio
51
51
  end
52
-
53
- private
54
-
55
- def calculate_tonal_center_frequency
56
- # Use equal temperament to get the tonal center frequency from the reference pitch
57
- interval_to_tonal_center = (tonal_center - reference_pitch.pitch).semitones
58
- reference_pitch_frequency * (2**(interval_to_tonal_center / 12.0))
59
- end
60
-
61
- def ratio_for_interval(semitones)
62
- # Handle octaves
63
- octaves = semitones / 12
64
- interval_within_octave = semitones % 12
65
-
66
- # Make sure we handle negative intervals
67
- if interval_within_octave < 0
68
- interval_within_octave += 12
69
- octaves -= 1
70
- end
71
-
72
- # Get the base ratio
73
- base_ratio = case interval_within_octave
74
- when 0 then INTERVAL_RATIOS[:unison]
75
- when 1 then INTERVAL_RATIOS[:minor_second]
76
- when 2 then INTERVAL_RATIOS[:major_second]
77
- when 3 then INTERVAL_RATIOS[:minor_third]
78
- when 4 then INTERVAL_RATIOS[:major_third]
79
- when 5 then INTERVAL_RATIOS[:perfect_fourth]
80
- when 6 then INTERVAL_RATIOS[:tritone]
81
- when 7 then INTERVAL_RATIOS[:perfect_fifth]
82
- when 8 then INTERVAL_RATIOS[:minor_sixth]
83
- when 9 then INTERVAL_RATIOS[:major_sixth]
84
- when 10 then INTERVAL_RATIOS[:minor_seventh]
85
- when 11 then INTERVAL_RATIOS[:major_seventh]
86
- end
87
-
88
- # Apply octave adjustments
89
- base_ratio * (2**octaves)
90
- end
91
52
  end
@@ -32,4 +32,24 @@ class HeadMusic::Rudiment::Tuning < HeadMusic::Rudiment::Base
32
32
  pitch = HeadMusic::Rudiment::Pitch.get(pitch)
33
33
  reference_pitch_frequency * (2**(1.0 / 12))**(pitch - reference_pitch.pitch).semitones
34
34
  end
35
+
36
+ private
37
+
38
+ def calculate_tonal_center_frequency
39
+ # Use equal temperament to get the tonal center frequency from the reference pitch
40
+ interval_to_tonal_center = (tonal_center - reference_pitch.pitch).semitones
41
+ reference_pitch_frequency * (2**(interval_to_tonal_center / 12.0))
42
+ end
43
+
44
+ def ratio_for_interval(semitones)
45
+ # Handle octaves
46
+ octaves = semitones / 12
47
+ interval_within_octave = semitones % 12
48
+
49
+ # Get the base ratio from the subclass's INTERVAL_RATIOS constant
50
+ base_ratio = self.class::INTERVAL_RATIOS[self.class::INTERVAL_RATIOS.keys[interval_within_octave]]
51
+
52
+ # Apply octave adjustments
53
+ base_ratio * (2**octaves)
54
+ end
35
55
  end
@@ -27,11 +27,11 @@ class HeadMusic::Style::Guidelines::ConsonantClimax < HeadMusic::Style::Annotati
27
27
  end
28
28
 
29
29
  def highest_pitch_consonant_with_tonic?
30
- diatonic_interval_to_highest_pitch.consonance?(:melodic)
30
+ !diatonic_interval_to_highest_pitch.dissonant?(:melodic)
31
31
  end
32
32
 
33
33
  def lowest_pitch_consonant_with_tonic?
34
- diatonic_interval_to_lowest_pitch.consonance?(:melodic)
34
+ !diatonic_interval_to_lowest_pitch.dissonant?(:melodic)
35
35
  end
36
36
 
37
37
  def diatonic_interval_to_highest_pitch
@@ -9,25 +9,22 @@ class HeadMusic::Style::ModernTradition < HeadMusic::Style::Tradition
9
9
  end
10
10
 
11
11
  case interval_mod
12
- when 0, 12 # Unison, Octave
12
+ when 0 # Unison, Octave
13
13
  HeadMusic::Rudiment::Consonance::PERFECT_CONSONANCE
14
- when 7 # Perfect Fifth
14
+ when 7 # Perfect Fifth
15
15
  HeadMusic::Rudiment::Consonance::PERFECT_CONSONANCE
16
- when 3, 4 # Minor Third, Major Third
16
+ when 3, 4 # Minor Third, Major Third
17
17
  HeadMusic::Rudiment::Consonance::IMPERFECT_CONSONANCE
18
- when 8, 9 # Minor Sixth, Major Sixth
18
+ when 8, 9 # Minor Sixth, Major Sixth
19
19
  HeadMusic::Rudiment::Consonance::IMPERFECT_CONSONANCE
20
- when 5 # Perfect Fourth
21
- # In standard practice, perfect fourth is considered consonant
22
- # but contextual would be more accurate
23
- HeadMusic::Rudiment::Consonance::PERFECT_CONSONANCE
20
+ when 5 # Perfect Fourth
21
+ # Perfect fourth is contextual: consonant in upper voices, dissonant against bass
22
+ HeadMusic::Rudiment::Consonance::CONTEXTUAL
24
23
  when 2, 10 # Major Second, Minor Seventh
25
24
  HeadMusic::Rudiment::Consonance::MILD_DISSONANCE
26
25
  when 1, 11 # Minor Second, Major Seventh
27
26
  HeadMusic::Rudiment::Consonance::HARSH_DISSONANCE
28
- when 6 # Tritone (Aug 4th/Dim 5th)
29
- HeadMusic::Rudiment::Consonance::DISSONANCE
30
- else
27
+ when 6 # Tritone (Aug 4th/Dim 5th)
31
28
  HeadMusic::Rudiment::Consonance::DISSONANCE
32
29
  end
33
30
  end
@@ -15,7 +15,7 @@ module HeadMusic::Style
15
15
  end
16
16
 
17
17
  def name
18
- self.class.name.split("::").last.sub(/Tradition$/, "").downcase.gsub(" ", "_").to_sym
18
+ HeadMusic::Utilities::Case.to_snake_case(self.class.name.split("::").last.sub(/Tradition$/, "")).to_sym
19
19
  end
20
20
  end
21
21
  end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HeadMusic
4
+ module Time
5
+ # A value object representing elapsed nanoseconds of clock time
6
+ #
7
+ # ClockPosition provides a high-precision representation of time elapsed
8
+ # from a reference point, stored as nanoseconds. This allows for precise
9
+ # temporal calculations in musical contexts where millisecond-level accuracy
10
+ # is required for MIDI timing, audio synchronization, and SMPTE timecode.
11
+ #
12
+ # @example Creating a position at one second
13
+ # position = HeadMusic::Time::ClockPosition.new(1_000_000_000)
14
+ # position.to_seconds # => 1.0
15
+ #
16
+ # @example Adding two positions together
17
+ # pos1 = HeadMusic::Time::ClockPosition.new(500_000_000)
18
+ # pos2 = HeadMusic::Time::ClockPosition.new(300_000_000)
19
+ # result = pos1 + pos2
20
+ # result.to_milliseconds # => 800.0
21
+ #
22
+ # @example Comparing positions
23
+ # early = HeadMusic::Time::ClockPosition.new(1_000_000_000)
24
+ # late = HeadMusic::Time::ClockPosition.new(2_000_000_000)
25
+ # early < late # => true
26
+ class ClockPosition
27
+ include Comparable
28
+
29
+ # @return [Integer] the number of nanoseconds since the reference point
30
+ attr_reader :nanoseconds
31
+
32
+ # Create a new clock position
33
+ #
34
+ # @param nanoseconds [Integer] the number of nanoseconds elapsed
35
+ def initialize(nanoseconds)
36
+ @nanoseconds = nanoseconds
37
+ end
38
+
39
+ # Convert to integer representation (nanoseconds)
40
+ #
41
+ # @return [Integer] nanoseconds
42
+ def to_i
43
+ nanoseconds
44
+ end
45
+
46
+ # Convert nanoseconds to microseconds
47
+ #
48
+ # @return [Float] elapsed microseconds
49
+ def to_microseconds
50
+ nanoseconds / 1_000.0
51
+ end
52
+
53
+ # Convert nanoseconds to milliseconds
54
+ #
55
+ # @return [Float] elapsed milliseconds
56
+ def to_milliseconds
57
+ nanoseconds / 1_000_000.0
58
+ end
59
+
60
+ # Convert nanoseconds to seconds
61
+ #
62
+ # @return [Float] elapsed seconds
63
+ def to_seconds
64
+ nanoseconds / 1_000_000_000.0
65
+ end
66
+
67
+ # Add another clock position to this one
68
+ #
69
+ # @param other [ClockPosition, #to_i] another position or value with nanoseconds
70
+ # @return [ClockPosition] a new position with the combined duration
71
+ def +(other)
72
+ self.class.new(nanoseconds + other.to_i)
73
+ end
74
+
75
+ # Compare this position to another
76
+ #
77
+ # @param other [ClockPosition, #to_i] another position to compare
78
+ # @return [Integer] -1 if less than, 0 if equal, 1 if greater than
79
+ def <=>(other)
80
+ nanoseconds <=> other.to_i
81
+ end
82
+ end
83
+ end
84
+ end
@@ -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