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,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
|
-
|
|
5
|
+
class HeadMusic::Utilities::HashKey
|
|
6
6
|
def self.for(identifier)
|
|
7
|
-
|
|
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
|
data/lib/head_music/version.rb
CHANGED
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
|
|
17
|
-
|
|
18
|
-
I18n.
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
I18n.
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
I18n.
|
|
26
|
-
I18n.
|
|
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/
|
|
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/
|
|
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/
|
|
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/
|
|
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"
|