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.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +9 -3
- data/CHANGELOG.md +71 -0
- data/CLAUDE.md +62 -25
- data/Gemfile +7 -1
- data/Gemfile.lock +91 -3
- data/MUSIC_THEORY.md +120 -0
- data/README.md +18 -0
- data/Rakefile +7 -2
- data/head_music.gemspec +1 -1
- data/lib/head_music/analysis/diatonic_interval.rb +29 -27
- data/lib/head_music/analysis/dyad.rb +229 -0
- data/lib/head_music/analysis/interval_consonance.rb +51 -0
- data/lib/head_music/analysis/melodic_interval.rb +1 -1
- data/lib/head_music/analysis/pitch_class_set.rb +111 -14
- data/lib/head_music/analysis/{pitch_set.rb → pitch_collection.rb} +11 -5
- data/lib/head_music/analysis/sonority.rb +50 -12
- data/lib/head_music/content/note.rb +1 -1
- data/lib/head_music/content/placement.rb +1 -1
- data/lib/head_music/content/position.rb +1 -1
- data/lib/head_music/content/voice.rb +1 -1
- data/lib/head_music/instruments/alternate_tuning.rb +102 -0
- data/lib/head_music/instruments/alternate_tunings.yml +78 -0
- data/lib/head_music/instruments/instrument.rb +231 -72
- data/lib/head_music/instruments/instrument_configuration.rb +66 -0
- data/lib/head_music/instruments/instrument_configuration_option.rb +38 -0
- data/lib/head_music/instruments/instrument_configurations.yml +288 -0
- data/lib/head_music/instruments/instrument_families.yml +77 -0
- data/lib/head_music/instruments/instrument_family.rb +15 -5
- data/lib/head_music/instruments/instruments.yml +795 -965
- data/lib/head_music/instruments/playing_technique.rb +75 -0
- data/lib/head_music/instruments/playing_techniques.yml +826 -0
- data/lib/head_music/instruments/score_order.rb +136 -0
- data/lib/head_music/instruments/score_orders.yml +130 -0
- data/lib/head_music/instruments/staff.rb +61 -1
- data/lib/head_music/instruments/staff_scheme.rb +6 -4
- data/lib/head_music/instruments/stringing.rb +115 -0
- data/lib/head_music/instruments/stringing_course.rb +58 -0
- data/lib/head_music/instruments/stringings.yml +168 -0
- data/lib/head_music/instruments/variant.rb +6 -1
- data/lib/head_music/locales/de.yml +29 -0
- data/lib/head_music/locales/en.yml +106 -0
- data/lib/head_music/locales/es.yml +29 -0
- data/lib/head_music/locales/fr.yml +29 -0
- data/lib/head_music/locales/it.yml +29 -0
- data/lib/head_music/locales/ru.yml +29 -0
- data/lib/head_music/{rudiment → notation}/musical_symbol.rb +3 -3
- data/lib/head_music/notation/staff_mapping.rb +70 -0
- data/lib/head_music/notation/staff_position.rb +62 -0
- data/lib/head_music/notation.rb +7 -0
- data/lib/head_music/rudiment/alteration.rb +34 -49
- data/lib/head_music/rudiment/alterations.yml +32 -0
- data/lib/head_music/rudiment/base.rb +9 -0
- data/lib/head_music/rudiment/chromatic_interval.rb +4 -7
- data/lib/head_music/rudiment/clef.rb +2 -2
- data/lib/head_music/rudiment/consonance.rb +39 -5
- data/lib/head_music/rudiment/diatonic_context.rb +25 -0
- data/lib/head_music/rudiment/key.rb +77 -0
- data/lib/head_music/rudiment/key_signature/enharmonic_equivalence.rb +1 -1
- data/lib/head_music/rudiment/key_signature.rb +21 -8
- data/lib/head_music/rudiment/letter_name.rb +3 -3
- data/lib/head_music/rudiment/meter.rb +19 -9
- data/lib/head_music/rudiment/mode.rb +92 -0
- data/lib/head_music/rudiment/note.rb +112 -0
- data/lib/head_music/rudiment/pitch/parser.rb +52 -0
- data/lib/head_music/rudiment/pitch.rb +5 -6
- data/lib/head_music/rudiment/pitch_class.rb +1 -1
- data/lib/head_music/rudiment/quality.rb +1 -1
- data/lib/head_music/rudiment/reference_pitch.rb +1 -1
- data/lib/head_music/rudiment/register.rb +4 -1
- data/lib/head_music/rudiment/rest.rb +36 -0
- data/lib/head_music/rudiment/rhythmic_element.rb +53 -0
- data/lib/head_music/rudiment/rhythmic_unit/parser.rb +86 -0
- data/lib/head_music/rudiment/rhythmic_unit.rb +13 -5
- data/lib/head_music/rudiment/rhythmic_units.yml +80 -0
- data/lib/head_music/rudiment/rhythmic_value/parser.rb +77 -0
- data/lib/head_music/{content → rudiment}/rhythmic_value.rb +23 -5
- data/lib/head_music/rudiment/scale.rb +4 -5
- data/lib/head_music/rudiment/scale_degree.rb +1 -1
- data/lib/head_music/rudiment/scale_type.rb +9 -3
- data/lib/head_music/rudiment/solmization.rb +1 -1
- data/lib/head_music/rudiment/spelling.rb +8 -4
- data/lib/head_music/rudiment/tempo.rb +85 -0
- data/lib/head_music/rudiment/tonal_context.rb +35 -0
- data/lib/head_music/rudiment/tuning/just_intonation.rb +0 -39
- data/lib/head_music/rudiment/tuning/meantone.rb +0 -39
- data/lib/head_music/rudiment/tuning/pythagorean.rb +0 -39
- data/lib/head_music/rudiment/tuning.rb +21 -1
- data/lib/head_music/rudiment/unpitched_note.rb +62 -0
- data/lib/head_music/style/guidelines/consonant_climax.rb +2 -2
- data/lib/head_music/style/medieval_tradition.rb +26 -0
- data/lib/head_music/style/modern_tradition.rb +31 -0
- data/lib/head_music/style/renaissance_tradition.rb +26 -0
- data/lib/head_music/style/tradition.rb +21 -0
- data/lib/head_music/time/clock_position.rb +84 -0
- data/lib/head_music/time/conductor.rb +264 -0
- data/lib/head_music/time/meter_event.rb +37 -0
- data/lib/head_music/time/meter_map.rb +173 -0
- data/lib/head_music/time/musical_position.rb +188 -0
- data/lib/head_music/time/smpte_timecode.rb +164 -0
- data/lib/head_music/time/tempo_event.rb +40 -0
- data/lib/head_music/time/tempo_map.rb +187 -0
- data/lib/head_music/time.rb +32 -0
- data/lib/head_music/utilities/case.rb +27 -0
- data/lib/head_music/utilities/hash_key.rb +34 -2
- data/lib/head_music/version.rb +1 -1
- data/lib/head_music.rb +71 -22
- data/user_stories/active/string-pitches.md +41 -0
- data/user_stories/backlog/notation-style.md +183 -0
- data/user_stories/backlog/organizing-content.md +80 -0
- data/user_stories/done/consonance-dissonance-classification.md +117 -0
- data/user_stories/{backlog → done}/dyad-analysis.md +6 -16
- data/user_stories/done/epic--score-order/PLAN.md +244 -0
- data/user_stories/done/expand-playing-techniques.md +38 -0
- data/user_stories/done/handle-time.md +7 -0
- data/user_stories/done/handle-time.rb +163 -0
- data/user_stories/done/instrument-architecture.md +238 -0
- data/user_stories/done/instrument-variant.md +65 -0
- data/user_stories/done/move-musical-symbol-to-notation.md +161 -0
- data/user_stories/done/move-staff-mapping-to-notation.md +158 -0
- data/user_stories/done/move-staff-position-to-notation.md +141 -0
- data/user_stories/done/notation-module-foundation.md +102 -0
- data/user_stories/done/percussion_set.md +260 -0
- data/user_stories/done/sonority-identification.md +37 -0
- data/user_stories/done/superclass-for-note.md +30 -0
- data/user_stories/epics/notation-module.md +135 -0
- data/user_stories/visioning/agentic-daw.md +2 -0
- metadata +84 -18
- data/TODO.md +0 -109
- data/check_instrument_consistency.rb +0 -0
- data/test_translations.rb +0 -15
- data/user_stories/backlog/consonance-dissonance-classification.md +0 -57
- data/user_stories/backlog/pitch-set-classification.md +0 -62
- data/user_stories/backlog/sonority-identification.md +0 -47
- /data/user_stories/{backlog → done/epic--score-order}/band-score-order.md +0 -0
- /data/user_stories/{backlog → done/epic--score-order}/chamber-ensemble-score-order.md +0 -0
- /data/user_stories/{backlog → done/epic--score-order}/orchestral-score-order.md +0 -0
- /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
|