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.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +9 -3
- data/CHANGELOG.md +18 -0
- data/CLAUDE.md +35 -15
- data/Gemfile +7 -1
- data/Gemfile.lock +91 -3
- data/README.md +18 -0
- data/Rakefile +7 -2
- data/head_music.gemspec +1 -1
- data/lib/head_music/analysis/dyad.rb +229 -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/cantus_firmus_examples.rb +58 -0
- data/lib/head_music/content/staff.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 +251 -82
- 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 +3 -4
- 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 +2 -5
- 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 +0 -1
- data/lib/head_music/locales/de.yml +23 -0
- data/lib/head_music/locales/en.yml +100 -0
- data/lib/head_music/locales/es.yml +23 -0
- data/lib/head_music/locales/fr.yml +23 -0
- data/lib/head_music/locales/it.yml +23 -0
- data/lib/head_music/locales/ru.yml +23 -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 +17 -47
- data/lib/head_music/rudiment/alterations.yml +32 -0
- data/lib/head_music/rudiment/chromatic_interval.rb +1 -1
- data/lib/head_music/rudiment/clef.rb +1 -1
- data/lib/head_music/rudiment/consonance.rb +14 -13
- data/lib/head_music/rudiment/key_signature.rb +0 -26
- data/lib/head_music/rudiment/rhythmic_unit/parser.rb +2 -2
- data/lib/head_music/rudiment/rhythmic_value/parser.rb +1 -1
- data/lib/head_music/rudiment/rhythmic_value.rb +1 -1
- data/lib/head_music/rudiment/spelling.rb +3 -0
- data/lib/head_music/rudiment/tempo.rb +1 -1
- 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 +20 -0
- data/lib/head_music/style/guidelines/consonant_climax.rb +2 -2
- data/lib/head_music/style/modern_tradition.rb +8 -11
- data/lib/head_music/style/tradition.rb +1 -1
- 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 +1 -1
- data/lib/head_music/version.rb +1 -1
- data/lib/head_music.rb +42 -13
- data/user_stories/backlog/notation-style.md +183 -0
- data/user_stories/{todo → backlog}/organizing-content.md +9 -1
- data/user_stories/done/consonance-dissonance-classification.md +117 -0
- data/user_stories/{todo → done}/dyad-analysis.md +4 -6
- data/user_stories/done/expand-playing-techniques.md +38 -0
- data/user_stories/{active → done}/handle-time.rb +5 -19
- data/user_stories/done/instrument-architecture.md +238 -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/{todo → done}/pitch-class-set-analysis.md +0 -40
- data/user_stories/done/sonority-identification.md +37 -0
- data/user_stories/done/string-pitches.md +41 -0
- data/user_stories/epics/notation-module.md +135 -0
- data/user_stories/{todo → visioning}/agentic-daw.md +0 -1
- metadata +56 -20
- data/check_instrument_consistency.rb +0 -0
- data/lib/head_music/instruments/instrument_type.rb +0 -188
- data/test_translations.rb +0 -15
- data/user_stories/todo/consonance-dissonance-classification.md +0 -57
- data/user_stories/todo/material-and-scores.md +0 -10
- data/user_stories/todo/percussion_set.md +0 -1
- data/user_stories/todo/pitch-set-classification.md +0 -72
- data/user_stories/todo/sonority-identification.md +0 -67
- /data/user_stories/{active → done}/handle-time.md +0 -0
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module HeadMusic
|
|
4
|
+
module Time
|
|
5
|
+
# Manages meter (time signature) changes along a musical timeline
|
|
6
|
+
#
|
|
7
|
+
# A MeterMap maintains a sorted list of meter changes at specific musical
|
|
8
|
+
# positions, allowing you to determine which meter is active at any point
|
|
9
|
+
# and iterate through meter segments for musical position calculations.
|
|
10
|
+
#
|
|
11
|
+
# This is essential for normalizing musical positions when the meter changes
|
|
12
|
+
# during a composition.
|
|
13
|
+
#
|
|
14
|
+
# @example Basic usage
|
|
15
|
+
# meter_map = HeadMusic::Time::MeterMap.new
|
|
16
|
+
# meter_map.add_change(MusicalPosition.new(5, 1, 0, 0), "3/4")
|
|
17
|
+
# meter_map.add_change(MusicalPosition.new(9, 1, 0, 0), "6/8")
|
|
18
|
+
#
|
|
19
|
+
# meter = meter_map.meter_at(MusicalPosition.new(7, 1, 0, 0))
|
|
20
|
+
# meter.to_s # => "3/4"
|
|
21
|
+
#
|
|
22
|
+
# @example Iterating through segments
|
|
23
|
+
# from = MusicalPosition.new(1, 1, 0, 0)
|
|
24
|
+
# to = MusicalPosition.new(10, 1, 0, 0)
|
|
25
|
+
# meter_map.each_segment(from, to) do |start_pos, end_pos, meter|
|
|
26
|
+
# # Process each meter segment
|
|
27
|
+
# end
|
|
28
|
+
class MeterMap
|
|
29
|
+
# @return [Array<MeterEvent>] all meter events in chronological order
|
|
30
|
+
attr_reader :events
|
|
31
|
+
|
|
32
|
+
# Create a new meter map
|
|
33
|
+
#
|
|
34
|
+
# @param starting_meter [HeadMusic::Rudiment::Meter, String] initial meter (default: 4/4)
|
|
35
|
+
# @param starting_position [MusicalPosition] where the initial meter begins (default: 1:1:0:0)
|
|
36
|
+
def initialize(starting_meter: nil, starting_position: nil)
|
|
37
|
+
starting_meter = HeadMusic::Rudiment::Meter.get(starting_meter || "4/4")
|
|
38
|
+
starting_position ||= MusicalPosition.new
|
|
39
|
+
@events = [MeterEvent.new(starting_position, starting_meter)]
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Add a meter change at the specified position
|
|
43
|
+
#
|
|
44
|
+
# If a meter change already exists at this position, it will be replaced.
|
|
45
|
+
# Events are automatically maintained in sorted order.
|
|
46
|
+
#
|
|
47
|
+
# @param position [MusicalPosition] where the meter change occurs
|
|
48
|
+
# @param meter_or_identifier [String, HeadMusic::Rudiment::Meter] either a meter string like "3/4" or a Meter object
|
|
49
|
+
# @return [MeterEvent] the created event
|
|
50
|
+
def add_change(position, meter_or_identifier)
|
|
51
|
+
# Remove any existing event at this position (except the first)
|
|
52
|
+
remove_change(position)
|
|
53
|
+
|
|
54
|
+
# Create the new event
|
|
55
|
+
meter = meter_or_identifier.is_a?(HeadMusic::Rudiment::Meter) ? meter_or_identifier : HeadMusic::Rudiment::Meter.get(meter_or_identifier)
|
|
56
|
+
event = MeterEvent.new(position, meter)
|
|
57
|
+
|
|
58
|
+
@events << event
|
|
59
|
+
sort_events!
|
|
60
|
+
event
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Remove a meter change at the specified position
|
|
64
|
+
#
|
|
65
|
+
# The starting meter (first event) cannot be removed.
|
|
66
|
+
#
|
|
67
|
+
# @param position [MusicalPosition] the position of the event to remove
|
|
68
|
+
# @return [void]
|
|
69
|
+
def remove_change(position)
|
|
70
|
+
@events.reject! do |event|
|
|
71
|
+
event != @events.first && positions_equal?(event.position, position)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Remove all meter changes except the starting meter
|
|
76
|
+
#
|
|
77
|
+
# @return [void]
|
|
78
|
+
def clear_changes
|
|
79
|
+
@events = [@events.first]
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Find the meter active at a given position
|
|
83
|
+
#
|
|
84
|
+
# Returns the meter from the most recent meter event at or before
|
|
85
|
+
# the specified position.
|
|
86
|
+
#
|
|
87
|
+
# @param position [MusicalPosition] the position to query
|
|
88
|
+
# @return [HeadMusic::Rudiment::Meter] the active meter
|
|
89
|
+
def meter_at(position)
|
|
90
|
+
# Find the last event at or before this position
|
|
91
|
+
# We need to compare positions carefully since they might not be normalized
|
|
92
|
+
active_event = @events.reverse.find do |event|
|
|
93
|
+
compare_positions(event.position, position) <= 0
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
active_event&.meter || @events.first.meter
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Iterate through meter segments between two positions
|
|
100
|
+
#
|
|
101
|
+
# Yields each segment with its start position, end position, and meter.
|
|
102
|
+
# Segments are created wherever a meter change occurs within the range.
|
|
103
|
+
#
|
|
104
|
+
# @param from_position [MusicalPosition] start of the range
|
|
105
|
+
# @param to_position [MusicalPosition] end of the range
|
|
106
|
+
# @yield [start_position, end_position, meter] for each segment
|
|
107
|
+
# @yieldparam start_position [MusicalPosition] segment start
|
|
108
|
+
# @yieldparam end_position [MusicalPosition] segment end
|
|
109
|
+
# @yieldparam meter [HeadMusic::Rudiment::Meter] active meter
|
|
110
|
+
# @return [void]
|
|
111
|
+
def each_segment(from_position, to_position)
|
|
112
|
+
# Find events that affect this range
|
|
113
|
+
relevant_events = @events.select do |event|
|
|
114
|
+
compare_positions(event.position, to_position) < 0
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Start with the meter active at from_position
|
|
118
|
+
current_pos = from_position
|
|
119
|
+
current_meter = meter_at(from_position)
|
|
120
|
+
|
|
121
|
+
# Iterate through relevant events
|
|
122
|
+
relevant_events.each do |event|
|
|
123
|
+
# Skip events before our starting position
|
|
124
|
+
next if compare_positions(event.position, from_position) <= 0
|
|
125
|
+
|
|
126
|
+
# Yield the segment up to this event
|
|
127
|
+
yield current_pos, event.position, current_meter
|
|
128
|
+
|
|
129
|
+
# Move to next segment
|
|
130
|
+
current_pos = event.position
|
|
131
|
+
current_meter = event.meter
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Yield the final segment to the end position
|
|
135
|
+
yield current_pos, to_position, current_meter
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
private
|
|
139
|
+
|
|
140
|
+
# Sort events by position
|
|
141
|
+
#
|
|
142
|
+
# @return [void]
|
|
143
|
+
def sort_events!
|
|
144
|
+
@events.sort_by! do |event|
|
|
145
|
+
pos = event.position
|
|
146
|
+
[pos.bar, pos.beat, pos.tick, pos.subtick]
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Check if two positions are equal
|
|
151
|
+
#
|
|
152
|
+
# @param pos1 [MusicalPosition] first position
|
|
153
|
+
# @param pos2 [MusicalPosition] second position
|
|
154
|
+
# @return [Boolean] true if positions are equal
|
|
155
|
+
def positions_equal?(pos1, pos2)
|
|
156
|
+
pos1.bar == pos2.bar &&
|
|
157
|
+
pos1.beat == pos2.beat &&
|
|
158
|
+
pos1.tick == pos2.tick &&
|
|
159
|
+
pos1.subtick == pos2.subtick
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Compare two positions
|
|
163
|
+
#
|
|
164
|
+
# @param pos1 [MusicalPosition] first position
|
|
165
|
+
# @param pos2 [MusicalPosition] second position
|
|
166
|
+
# @return [Integer] -1, 0, or 1
|
|
167
|
+
def compare_positions(pos1, pos2)
|
|
168
|
+
[pos1.bar, pos1.beat, pos1.tick, pos1.subtick] <=>
|
|
169
|
+
[pos2.bar, pos2.beat, pos2.tick, pos2.subtick]
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module HeadMusic
|
|
4
|
+
module Time
|
|
5
|
+
# Representation of a musical position in bars:beats:ticks:subticks notation
|
|
6
|
+
#
|
|
7
|
+
# A MusicalPosition represents a point in musical time using a hierarchical
|
|
8
|
+
# structure:
|
|
9
|
+
# - bar: the measure number (1-indexed)
|
|
10
|
+
# - beat: the beat within the bar (1-indexed)
|
|
11
|
+
# - tick: subdivision of a beat (0-indexed, 960 ticks per quarter note)
|
|
12
|
+
# - subtick: finest resolution (0-indexed, 240 subticks per tick)
|
|
13
|
+
#
|
|
14
|
+
# The position can be normalized according to a meter, which handles
|
|
15
|
+
# overflow by carrying excess values to higher levels (e.g., excess ticks
|
|
16
|
+
# become beats, excess beats become bars).
|
|
17
|
+
#
|
|
18
|
+
# @example Creating a position at bar 1, beat 1
|
|
19
|
+
# position = HeadMusic::Time::MusicalPosition.new
|
|
20
|
+
# position.to_s # => "1:1:0:0"
|
|
21
|
+
#
|
|
22
|
+
# @example Parsing from a string
|
|
23
|
+
# position = HeadMusic::Time::MusicalPosition.parse("2:3:480:0")
|
|
24
|
+
# position.bar # => 2
|
|
25
|
+
# position.beat # => 3
|
|
26
|
+
#
|
|
27
|
+
# @example Normalizing with overflow
|
|
28
|
+
# meter = HeadMusic::Rudiment::Meter.get("4/4")
|
|
29
|
+
# position = HeadMusic::Time::MusicalPosition.new(1, 1, 960, 0)
|
|
30
|
+
# position.normalize!(meter)
|
|
31
|
+
# position.to_s # => "1:2:0:0" (ticks carried into beats)
|
|
32
|
+
#
|
|
33
|
+
# @example Comparing positions
|
|
34
|
+
# pos1 = HeadMusic::Time::MusicalPosition.new(1, 1, 0, 0)
|
|
35
|
+
# pos2 = HeadMusic::Time::MusicalPosition.new(1, 2, 0, 0)
|
|
36
|
+
# meter = HeadMusic::Rudiment::Meter.get("4/4")
|
|
37
|
+
# pos1.normalize!(meter)
|
|
38
|
+
# pos2.normalize!(meter)
|
|
39
|
+
# pos1 < pos2 # => true
|
|
40
|
+
class MusicalPosition
|
|
41
|
+
include Comparable
|
|
42
|
+
|
|
43
|
+
# @return [Integer] the bar (measure) number (1-indexed)
|
|
44
|
+
attr_reader :bar
|
|
45
|
+
|
|
46
|
+
# @return [Integer] the beat within the bar (1-indexed)
|
|
47
|
+
attr_reader :beat
|
|
48
|
+
|
|
49
|
+
# @return [Integer] the tick within the beat (0-indexed)
|
|
50
|
+
attr_reader :tick
|
|
51
|
+
|
|
52
|
+
# @return [Integer] the subtick within the tick (0-indexed)
|
|
53
|
+
attr_reader :subtick
|
|
54
|
+
|
|
55
|
+
# Default starting bar number
|
|
56
|
+
DEFAULT_FIRST_BAR = 1
|
|
57
|
+
|
|
58
|
+
# First beat in a bar
|
|
59
|
+
FIRST_BEAT = 1
|
|
60
|
+
|
|
61
|
+
# First tick in a beat
|
|
62
|
+
FIRST_TICK = 0
|
|
63
|
+
|
|
64
|
+
# First subtick in a tick
|
|
65
|
+
FIRST_SUBTICK = 0
|
|
66
|
+
|
|
67
|
+
# Parse a position from a string representation
|
|
68
|
+
#
|
|
69
|
+
# @param identifier [String] position in "bar:beat:tick:subtick" format
|
|
70
|
+
# @return [MusicalPosition] the parsed position
|
|
71
|
+
# @example
|
|
72
|
+
# MusicalPosition.parse("2:3:480:120")
|
|
73
|
+
def self.parse(identifier)
|
|
74
|
+
new(*identifier.scan(/\d+/)[0..3])
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Create a new musical position
|
|
78
|
+
#
|
|
79
|
+
# @param bar [Integer, String] the bar number (default: 1)
|
|
80
|
+
# @param beat [Integer, String] the beat number (default: 1)
|
|
81
|
+
# @param tick [Integer, String] the tick number (default: 0)
|
|
82
|
+
# @param subtick [Integer, String] the subtick number (default: 0)
|
|
83
|
+
def initialize(
|
|
84
|
+
bar = DEFAULT_FIRST_BAR,
|
|
85
|
+
beat = FIRST_BEAT,
|
|
86
|
+
tick = FIRST_TICK,
|
|
87
|
+
subtick = FIRST_SUBTICK
|
|
88
|
+
)
|
|
89
|
+
@bar = bar.to_i
|
|
90
|
+
@beat = beat.to_i
|
|
91
|
+
@tick = tick.to_i
|
|
92
|
+
@subtick = subtick.to_i
|
|
93
|
+
@meter = nil
|
|
94
|
+
@total_subticks = nil
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Convert position to array format
|
|
98
|
+
#
|
|
99
|
+
# @return [Array<Integer>] [bar, beat, tick, subtick]
|
|
100
|
+
def to_a
|
|
101
|
+
[bar, beat, tick, subtick]
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Convert position to string format
|
|
105
|
+
#
|
|
106
|
+
# @return [String] position in "bar:beat:tick:subtick" format
|
|
107
|
+
def to_s
|
|
108
|
+
"#{bar}:#{beat}:#{tick}:#{subtick}"
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Normalize the position according to a meter, handling overflow
|
|
112
|
+
#
|
|
113
|
+
# This method modifies the position in place, carrying excess values
|
|
114
|
+
# from lower levels to higher levels (subticks → ticks → beats → bars).
|
|
115
|
+
# Also handles negative values by borrowing from higher levels.
|
|
116
|
+
#
|
|
117
|
+
# @param meter [HeadMusic::Rudiment::Meter, nil] the meter to use for normalization
|
|
118
|
+
# @return [self] returns self for method chaining
|
|
119
|
+
# @example
|
|
120
|
+
# meter = HeadMusic::Rudiment::Meter.get("4/4")
|
|
121
|
+
# position = MusicalPosition.new(1, 1, 960, 240)
|
|
122
|
+
# position.normalize!(meter) # => "1:2:1:0"
|
|
123
|
+
def normalize!(meter)
|
|
124
|
+
return self unless meter
|
|
125
|
+
|
|
126
|
+
@meter = meter
|
|
127
|
+
@total_subticks = nil # Invalidate cached value
|
|
128
|
+
|
|
129
|
+
# Carry subticks into ticks
|
|
130
|
+
if subtick >= HeadMusic::Time::SUBTICKS_PER_TICK || subtick.negative?
|
|
131
|
+
tick_delta, @subtick = subtick.divmod(HeadMusic::Time::SUBTICKS_PER_TICK)
|
|
132
|
+
@tick += tick_delta
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Carry ticks into beats
|
|
136
|
+
if tick >= meter.ticks_per_count || tick.negative?
|
|
137
|
+
beat_delta, @tick = tick.divmod(meter.ticks_per_count)
|
|
138
|
+
@beat += beat_delta
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Carry beats into bars
|
|
142
|
+
if beat >= meter.counts_per_bar || beat.negative?
|
|
143
|
+
bar_delta, @beat = beat.divmod(meter.counts_per_bar)
|
|
144
|
+
@bar += bar_delta
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
self
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Compare this position to another
|
|
151
|
+
#
|
|
152
|
+
# Note: For accurate comparison, both positions should be normalized
|
|
153
|
+
# with the same meter first.
|
|
154
|
+
#
|
|
155
|
+
# @param other [MusicalPosition] another position to compare
|
|
156
|
+
# @return [Integer] -1 if less than, 0 if equal, 1 if greater than
|
|
157
|
+
def <=>(other)
|
|
158
|
+
to_total_subticks <=> other.to_total_subticks
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Convert position to total subticks for comparison and calculation
|
|
162
|
+
#
|
|
163
|
+
# @return [Integer] total subticks from the beginning
|
|
164
|
+
# @note This calculation assumes the position has been normalized
|
|
165
|
+
def to_total_subticks
|
|
166
|
+
return @total_subticks if @total_subticks
|
|
167
|
+
|
|
168
|
+
# Calculate based on the structure
|
|
169
|
+
# Note: This is a simplified calculation that assumes consistent meter
|
|
170
|
+
ticks_per_count = @meter&.ticks_per_count || HeadMusic::Time::PPQN
|
|
171
|
+
counts_per_bar = @meter&.counts_per_bar || 4
|
|
172
|
+
|
|
173
|
+
total = 0
|
|
174
|
+
total += (bar - 1) * counts_per_bar * ticks_per_count * HeadMusic::Time::SUBTICKS_PER_TICK
|
|
175
|
+
total += (beat - 1) * ticks_per_count * HeadMusic::Time::SUBTICKS_PER_TICK
|
|
176
|
+
total += tick * HeadMusic::Time::SUBTICKS_PER_TICK
|
|
177
|
+
total += subtick
|
|
178
|
+
|
|
179
|
+
@total_subticks = total
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
protected
|
|
183
|
+
|
|
184
|
+
# Allow other MusicalPosition instances to access this method for comparison
|
|
185
|
+
alias_method :to_i, :to_total_subticks
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
end
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module HeadMusic
|
|
4
|
+
module Time
|
|
5
|
+
# Represents a SMPTE (Society of Motion Picture and Television Engineers) timecode
|
|
6
|
+
#
|
|
7
|
+
# SMPTE timecode is used for synchronizing audio and video in professional
|
|
8
|
+
# production. It represents time as HH:MM:SS:FF (hours:minutes:seconds:frames).
|
|
9
|
+
#
|
|
10
|
+
# The framerate determines how many frames occur per second. Common framerates:
|
|
11
|
+
# - 24 fps: Film standard
|
|
12
|
+
# - 25 fps: PAL video standard (Europe)
|
|
13
|
+
# - 30 fps: NTSC video standard (North America)
|
|
14
|
+
# - 29.97 fps: NTSC drop-frame
|
|
15
|
+
#
|
|
16
|
+
# @example Creating a timecode at 1 hour
|
|
17
|
+
# timecode = HeadMusic::Time::SmpteTimecode.new(1, 0, 0, 0)
|
|
18
|
+
# timecode.to_s # => "01:00:00:00"
|
|
19
|
+
#
|
|
20
|
+
# @example Parsing from a string
|
|
21
|
+
# timecode = HeadMusic::Time::SmpteTimecode.parse("02:30:45:15")
|
|
22
|
+
# timecode.hour # => 2
|
|
23
|
+
# timecode.minute # => 30
|
|
24
|
+
#
|
|
25
|
+
# @example Normalizing with overflow
|
|
26
|
+
# timecode = HeadMusic::Time::SmpteTimecode.new(0, 0, 0, 60, framerate: 30)
|
|
27
|
+
# timecode.normalize!
|
|
28
|
+
# timecode.to_s # => "00:00:02:00" (frames carried into seconds)
|
|
29
|
+
#
|
|
30
|
+
# @example Comparing timecodes
|
|
31
|
+
# tc1 = HeadMusic::Time::SmpteTimecode.new(1, 0, 0, 0)
|
|
32
|
+
# tc2 = HeadMusic::Time::SmpteTimecode.new(1, 30, 0, 0)
|
|
33
|
+
# tc1 < tc2 # => true
|
|
34
|
+
class SmpteTimecode
|
|
35
|
+
include Comparable
|
|
36
|
+
|
|
37
|
+
# @return [Integer] the hour component
|
|
38
|
+
attr_reader :hour
|
|
39
|
+
|
|
40
|
+
# @return [Integer] the minute component
|
|
41
|
+
attr_reader :minute
|
|
42
|
+
|
|
43
|
+
# @return [Integer] the second component
|
|
44
|
+
attr_reader :second
|
|
45
|
+
|
|
46
|
+
# @return [Integer] the frame component
|
|
47
|
+
attr_reader :frame
|
|
48
|
+
|
|
49
|
+
# @return [Integer] frames per second (default: 30 for NTSC)
|
|
50
|
+
attr_reader :framerate
|
|
51
|
+
|
|
52
|
+
# Default framerate (30 fps NTSC)
|
|
53
|
+
DEFAULT_FRAMERATE = 30
|
|
54
|
+
|
|
55
|
+
# Seconds per minute
|
|
56
|
+
SECONDS_PER_MINUTE = 60
|
|
57
|
+
|
|
58
|
+
# Minutes per hour
|
|
59
|
+
MINUTES_PER_HOUR = 60
|
|
60
|
+
|
|
61
|
+
# Parse a timecode from a string representation
|
|
62
|
+
#
|
|
63
|
+
# @param identifier [String] timecode in "HH:MM:SS:FF" format
|
|
64
|
+
# @return [SmpteTimecode] the parsed timecode
|
|
65
|
+
# @example
|
|
66
|
+
# SmpteTimecode.parse("02:30:45:15")
|
|
67
|
+
def self.parse(identifier)
|
|
68
|
+
new(*identifier.scan(/\d+/)[0..3])
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Create a new SMPTE timecode
|
|
72
|
+
#
|
|
73
|
+
# @param hour [Integer, String] the hour component (default: 0)
|
|
74
|
+
# @param minute [Integer, String] the minute component (default: 0)
|
|
75
|
+
# @param second [Integer, String] the second component (default: 0)
|
|
76
|
+
# @param frame [Integer, String] the frame component (default: 0)
|
|
77
|
+
# @param framerate [Integer] frames per second (default: 30)
|
|
78
|
+
def initialize(hour = 0, minute = 0, second = 0, frame = 0, framerate: DEFAULT_FRAMERATE)
|
|
79
|
+
@hour = hour.to_i
|
|
80
|
+
@minute = minute.to_i
|
|
81
|
+
@second = second.to_i
|
|
82
|
+
@frame = frame.to_i
|
|
83
|
+
@framerate = framerate
|
|
84
|
+
@total_frames = nil
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Convert timecode to array format
|
|
88
|
+
#
|
|
89
|
+
# @return [Array<Integer>] [hour, minute, second, frame]
|
|
90
|
+
def to_a
|
|
91
|
+
[hour, minute, second, frame]
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Convert timecode to string format with zero padding
|
|
95
|
+
#
|
|
96
|
+
# @return [String] timecode in "HH:MM:SS:FF" format
|
|
97
|
+
def to_s
|
|
98
|
+
format("%02d:%02d:%02d:%02d", hour, minute, second, frame)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Normalize the timecode, handling overflow and underflow
|
|
102
|
+
#
|
|
103
|
+
# This method modifies the timecode in place, carrying excess values
|
|
104
|
+
# from lower levels to higher levels (frames → seconds → minutes → hours).
|
|
105
|
+
# Also handles negative values by borrowing from higher levels.
|
|
106
|
+
#
|
|
107
|
+
# @return [self] returns self for method chaining
|
|
108
|
+
# @example
|
|
109
|
+
# timecode = SmpteTimecode.new(0, 0, 0, 60, framerate: 30)
|
|
110
|
+
# timecode.normalize! # => "00:00:02:00"
|
|
111
|
+
def normalize!
|
|
112
|
+
@total_frames = nil # Invalidate cached value
|
|
113
|
+
|
|
114
|
+
# Carry frames into seconds
|
|
115
|
+
if frame >= framerate || frame.negative?
|
|
116
|
+
second_delta, @frame = frame.divmod(framerate)
|
|
117
|
+
@second += second_delta
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Carry seconds into minutes
|
|
121
|
+
if second >= SECONDS_PER_MINUTE || second.negative?
|
|
122
|
+
minute_delta, @second = second.divmod(SECONDS_PER_MINUTE)
|
|
123
|
+
@minute += minute_delta
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Carry minutes into hours
|
|
127
|
+
if minute >= MINUTES_PER_HOUR || minute.negative?
|
|
128
|
+
hour_delta, @minute = minute.divmod(MINUTES_PER_HOUR)
|
|
129
|
+
@hour += hour_delta
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
self
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Compare this timecode to another
|
|
136
|
+
#
|
|
137
|
+
# @param other [SmpteTimecode] another timecode to compare
|
|
138
|
+
# @return [Integer] -1 if less than, 0 if equal, 1 if greater than
|
|
139
|
+
def <=>(other)
|
|
140
|
+
to_total_frames <=> other.to_total_frames
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Convert timecode to total frames from the beginning
|
|
144
|
+
#
|
|
145
|
+
# @return [Integer] total frames
|
|
146
|
+
def to_total_frames
|
|
147
|
+
return @total_frames if @total_frames
|
|
148
|
+
|
|
149
|
+
total = 0
|
|
150
|
+
total += hour * MINUTES_PER_HOUR * SECONDS_PER_MINUTE * framerate
|
|
151
|
+
total += minute * SECONDS_PER_MINUTE * framerate
|
|
152
|
+
total += second * framerate
|
|
153
|
+
total += frame
|
|
154
|
+
|
|
155
|
+
@total_frames = total
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
protected
|
|
159
|
+
|
|
160
|
+
# Allow other SmpteTimecode instances to access this method for comparison
|
|
161
|
+
alias_method :to_i, :to_total_frames
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module HeadMusic
|
|
4
|
+
module Time
|
|
5
|
+
# Represents a tempo change at a specific musical position
|
|
6
|
+
#
|
|
7
|
+
# TempoEvent marks a point in a musical timeline where the tempo changes.
|
|
8
|
+
# This is essential for converting between clock time and musical position,
|
|
9
|
+
# as different tempos affect how long each beat takes in real time.
|
|
10
|
+
#
|
|
11
|
+
# @example Creating a tempo change to quarter = 120 at bar 1
|
|
12
|
+
# position = HeadMusic::Time::MusicalPosition.new(1, 1, 0, 0)
|
|
13
|
+
# event = HeadMusic::Time::TempoEvent.new(position, "quarter", 120)
|
|
14
|
+
#
|
|
15
|
+
# @example With a dotted quarter note tempo
|
|
16
|
+
# position = HeadMusic::Time::MusicalPosition.new(5, 1, 0, 0)
|
|
17
|
+
# event = HeadMusic::Time::TempoEvent.new(position, "dotted quarter", 92)
|
|
18
|
+
#
|
|
19
|
+
# @example With an eighth note tempo
|
|
20
|
+
# position = HeadMusic::Time::MusicalPosition.new(10, 1, 0, 0)
|
|
21
|
+
# event = HeadMusic::Time::TempoEvent.new(position, "eighth", 140)
|
|
22
|
+
class TempoEvent
|
|
23
|
+
# @return [MusicalPosition] the position where this tempo change occurs
|
|
24
|
+
attr_accessor :position
|
|
25
|
+
|
|
26
|
+
# @return [HeadMusic::Rudiment::Tempo] the tempo
|
|
27
|
+
attr_accessor :tempo
|
|
28
|
+
|
|
29
|
+
# Create a new tempo change event
|
|
30
|
+
#
|
|
31
|
+
# @param position [MusicalPosition] where the tempo change occurs
|
|
32
|
+
# @param beat_value [String] the rhythmic value that gets the beat (e.g., "quarter", "eighth")
|
|
33
|
+
# @param beats_per_minute [Numeric] the tempo in beats per minute
|
|
34
|
+
def initialize(position, beat_value, beats_per_minute)
|
|
35
|
+
@position = position
|
|
36
|
+
@tempo = HeadMusic::Rudiment::Tempo.new(beat_value, beats_per_minute)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|