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,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
@@ -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
@@ -2,8 +2,40 @@
2
2
  module HeadMusic::Utilities; end
3
3
 
4
4
  # Util for converting an object to a consistent hash key
5
- module HeadMusic::Utilities::HashKey
5
+ class HeadMusic::Utilities::HashKey
6
6
  def self.for(identifier)
7
- I18n.transliterate(identifier.to_s).downcase.gsub(/\W+/, "_").to_sym
7
+ @hash_keys ||= {}
8
+ @hash_keys[identifier] ||= new(identifier).to_sym
9
+ end
10
+
11
+ attr_reader :original
12
+
13
+ def initialize(identifier)
14
+ @original = identifier
15
+ end
16
+
17
+ def to_sym
18
+ normalized_string.to_sym
19
+ end
20
+
21
+ private
22
+
23
+ def normalized_string
24
+ @normalized_string ||=
25
+ HeadMusic::Utilities::Case.to_snake_case(transliterated_string)
26
+ end
27
+
28
+ def transliterated_string
29
+ I18n.transliterate(desymbolized_string)
30
+ end
31
+
32
+ def desymbolized_string
33
+ original.to_s
34
+ .gsub("𝄫", "_double_flat")
35
+ .gsub("♭", "_flat")
36
+ .gsub("♮", "_natural")
37
+ .gsub("♯", "_sharp")
38
+ .gsub("#", "_sharp")
39
+ .gsub("𝄪", "_double_sharp")
8
40
  end
9
41
  end
@@ -1,3 +1,3 @@
1
1
  module HeadMusic
2
- VERSION = "8.3.0"
2
+ VERSION = "11.0.0"
3
3
  end
data/lib/head_music.rb CHANGED
@@ -13,59 +13,100 @@ require "humanize"
13
13
  require "i18n"
14
14
  require "i18n/backend/fallbacks"
15
15
 
16
- I18n::Backend::Simple.include I18n::Backend::Fallbacks
17
- 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]
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)
21
+ I18n.load_path += Dir[File.join(File.dirname(__dir__), "lib", "head_music", "locales", "*.yml")]
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
32
49
  require "head_music/named"
33
50
 
34
51
  # rudiments
52
+ require "head_music/rudiment/base"
53
+ require "head_music/rudiment/letter_name"
35
54
  require "head_music/rudiment/alteration"
55
+ require "head_music/rudiment/spelling"
56
+ require "head_music/rudiment/rhythmic_unit"
57
+ require "head_music/rudiment/rhythmic_unit/parser"
58
+ require "head_music/rudiment/rhythmic_value"
59
+ require "head_music/rudiment/rhythmic_value/parser"
60
+ require "head_music/rudiment/register"
61
+ require "head_music/rudiment/pitch"
62
+ require "head_music/rudiment/pitch/parser"
63
+ require "head_music/rudiment/rhythmic_element"
64
+ require "head_music/rudiment/note"
65
+ require "head_music/rudiment/unpitched_note"
66
+ require "head_music/rudiment/rest"
67
+
36
68
  require "head_music/rudiment/chromatic_interval"
37
69
  require "head_music/rudiment/clef"
38
70
  require "head_music/rudiment/consonance"
71
+ require "head_music/rudiment/tonal_context"
72
+ require "head_music/rudiment/diatonic_context"
73
+ require "head_music/rudiment/key"
74
+ require "head_music/rudiment/mode"
39
75
  require "head_music/rudiment/key_signature"
40
76
  require "head_music/rudiment/key_signature/enharmonic_equivalence"
41
- require "head_music/rudiment/letter_name"
42
77
  require "head_music/rudiment/meter"
43
- require "head_music/rudiment/musical_symbol"
44
- require "head_music/rudiment/pitch"
45
78
  require "head_music/rudiment/pitch/enharmonic_equivalence"
46
79
  require "head_music/rudiment/pitch/octave_equivalence"
47
80
  require "head_music/rudiment/pitch_class"
48
81
  require "head_music/rudiment/quality"
49
82
  require "head_music/rudiment/reference_pitch"
50
- require "head_music/rudiment/register"
51
83
  require "head_music/rudiment/rhythm"
52
- require "head_music/rudiment/rhythmic_unit"
53
84
  require "head_music/rudiment/scale"
54
85
  require "head_music/rudiment/scale_degree"
55
86
  require "head_music/rudiment/scale_type"
56
87
  require "head_music/rudiment/solmization"
57
- require "head_music/rudiment/spelling"
88
+ require "head_music/rudiment/tempo"
58
89
  require "head_music/rudiment/tuning"
59
90
  require "head_music/rudiment/tuning/just_intonation"
60
- require "head_music/rudiment/tuning/pythagorean"
61
91
  require "head_music/rudiment/tuning/meantone"
92
+ require "head_music/rudiment/tuning/pythagorean"
93
+
94
+ # time
95
+ require "head_music/time"
62
96
 
63
97
  # instruments
64
98
  require "head_music/instruments/instrument_family"
65
- require "head_music/instruments/instrument"
99
+ require "head_music/instruments/variant"
100
+ require "head_music/instruments/playing_technique"
66
101
  require "head_music/instruments/staff_scheme"
67
102
  require "head_music/instruments/staff"
68
- require "head_music/instruments/variant"
103
+ require "head_music/instruments/instrument_configuration_option"
104
+ require "head_music/instruments/instrument_configuration"
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"
109
+ require "head_music/instruments/score_order"
69
110
 
70
111
  # content
71
112
  require "head_music/content/bar"
@@ -73,10 +114,12 @@ require "head_music/content/composition"
73
114
  require "head_music/content/note"
74
115
  require "head_music/content/placement"
75
116
  require "head_music/content/position"
76
- require "head_music/content/rhythmic_value"
77
117
  require "head_music/content/staff"
78
118
  require "head_music/content/voice"
79
119
 
120
+ # notation
121
+ require "head_music/notation"
122
+
80
123
  # analysis
81
124
  require "head_music/analysis/circle"
82
125
  require "head_music/analysis/diatonic_interval"
@@ -85,15 +128,21 @@ require "head_music/analysis/diatonic_interval/naming"
85
128
  require "head_music/analysis/diatonic_interval/parser"
86
129
  require "head_music/analysis/diatonic_interval/semitones"
87
130
  require "head_music/analysis/diatonic_interval/size"
131
+ require "head_music/analysis/dyad"
88
132
  require "head_music/analysis/harmonic_interval"
133
+ require "head_music/analysis/interval_consonance"
89
134
  require "head_music/analysis/interval_cycle"
90
135
  require "head_music/analysis/melodic_interval"
91
136
  require "head_music/analysis/motion"
92
137
  require "head_music/analysis/pitch_class_set"
93
- require "head_music/analysis/pitch_set"
138
+ require "head_music/analysis/pitch_collection"
94
139
  require "head_music/analysis/sonority"
95
140
 
96
141
  # style analysis
142
+ require "head_music/style/tradition"
143
+ require "head_music/style/modern_tradition"
144
+ require "head_music/style/renaissance_tradition"
145
+ require "head_music/style/medieval_tradition"
97
146
  require "head_music/style/analysis"
98
147
  require "head_music/style/annotation"
99
148
  require "head_music/style/mark"