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
|
@@ -45,43 +45,4 @@ class HeadMusic::Rudiment::Tuning::Meantone < HeadMusic::Rudiment::Tuning
|
|
|
45
45
|
# Calculate the frequency
|
|
46
46
|
tonal_center_frequency * ratio
|
|
47
47
|
end
|
|
48
|
-
|
|
49
|
-
private
|
|
50
|
-
|
|
51
|
-
def calculate_tonal_center_frequency
|
|
52
|
-
# Use equal temperament to get the tonal center frequency from the reference pitch
|
|
53
|
-
interval_to_tonal_center = (tonal_center - reference_pitch.pitch).semitones
|
|
54
|
-
reference_pitch_frequency * (2**(interval_to_tonal_center / 12.0))
|
|
55
|
-
end
|
|
56
|
-
|
|
57
|
-
def ratio_for_interval(semitones)
|
|
58
|
-
# Handle octaves
|
|
59
|
-
octaves = semitones / 12
|
|
60
|
-
interval_within_octave = semitones % 12
|
|
61
|
-
|
|
62
|
-
# Make sure we handle negative intervals
|
|
63
|
-
if interval_within_octave < 0
|
|
64
|
-
interval_within_octave += 12
|
|
65
|
-
octaves -= 1
|
|
66
|
-
end
|
|
67
|
-
|
|
68
|
-
# Get the base ratio
|
|
69
|
-
base_ratio = case interval_within_octave
|
|
70
|
-
when 0 then INTERVAL_RATIOS[:unison]
|
|
71
|
-
when 1 then INTERVAL_RATIOS[:minor_second]
|
|
72
|
-
when 2 then INTERVAL_RATIOS[:major_second]
|
|
73
|
-
when 3 then INTERVAL_RATIOS[:minor_third]
|
|
74
|
-
when 4 then INTERVAL_RATIOS[:major_third]
|
|
75
|
-
when 5 then INTERVAL_RATIOS[:perfect_fourth]
|
|
76
|
-
when 6 then INTERVAL_RATIOS[:tritone]
|
|
77
|
-
when 7 then INTERVAL_RATIOS[:perfect_fifth]
|
|
78
|
-
when 8 then INTERVAL_RATIOS[:minor_sixth]
|
|
79
|
-
when 9 then INTERVAL_RATIOS[:major_sixth]
|
|
80
|
-
when 10 then INTERVAL_RATIOS[:minor_seventh]
|
|
81
|
-
when 11 then INTERVAL_RATIOS[:major_seventh]
|
|
82
|
-
end
|
|
83
|
-
|
|
84
|
-
# Apply octave adjustments
|
|
85
|
-
base_ratio * (2**octaves)
|
|
86
|
-
end
|
|
87
48
|
end
|
|
@@ -49,43 +49,4 @@ class HeadMusic::Rudiment::Tuning::Pythagorean < HeadMusic::Rudiment::Tuning
|
|
|
49
49
|
# Calculate the frequency
|
|
50
50
|
tonal_center_frequency * ratio
|
|
51
51
|
end
|
|
52
|
-
|
|
53
|
-
private
|
|
54
|
-
|
|
55
|
-
def calculate_tonal_center_frequency
|
|
56
|
-
# Use equal temperament to get the tonal center frequency from the reference pitch
|
|
57
|
-
interval_to_tonal_center = (tonal_center - reference_pitch.pitch).semitones
|
|
58
|
-
reference_pitch_frequency * (2**(interval_to_tonal_center / 12.0))
|
|
59
|
-
end
|
|
60
|
-
|
|
61
|
-
def ratio_for_interval(semitones)
|
|
62
|
-
# Handle octaves
|
|
63
|
-
octaves = semitones / 12
|
|
64
|
-
interval_within_octave = semitones % 12
|
|
65
|
-
|
|
66
|
-
# Make sure we handle negative intervals
|
|
67
|
-
if interval_within_octave < 0
|
|
68
|
-
interval_within_octave += 12
|
|
69
|
-
octaves -= 1
|
|
70
|
-
end
|
|
71
|
-
|
|
72
|
-
# Get the base ratio
|
|
73
|
-
base_ratio = case interval_within_octave
|
|
74
|
-
when 0 then INTERVAL_RATIOS[:unison]
|
|
75
|
-
when 1 then INTERVAL_RATIOS[:minor_second]
|
|
76
|
-
when 2 then INTERVAL_RATIOS[:major_second]
|
|
77
|
-
when 3 then INTERVAL_RATIOS[:minor_third]
|
|
78
|
-
when 4 then INTERVAL_RATIOS[:major_third]
|
|
79
|
-
when 5 then INTERVAL_RATIOS[:perfect_fourth]
|
|
80
|
-
when 6 then INTERVAL_RATIOS[:tritone]
|
|
81
|
-
when 7 then INTERVAL_RATIOS[:perfect_fifth]
|
|
82
|
-
when 8 then INTERVAL_RATIOS[:minor_sixth]
|
|
83
|
-
when 9 then INTERVAL_RATIOS[:major_sixth]
|
|
84
|
-
when 10 then INTERVAL_RATIOS[:minor_seventh]
|
|
85
|
-
when 11 then INTERVAL_RATIOS[:major_seventh]
|
|
86
|
-
end
|
|
87
|
-
|
|
88
|
-
# Apply octave adjustments
|
|
89
|
-
base_ratio * (2**octaves)
|
|
90
|
-
end
|
|
91
52
|
end
|
|
@@ -32,4 +32,24 @@ class HeadMusic::Rudiment::Tuning < HeadMusic::Rudiment::Base
|
|
|
32
32
|
pitch = HeadMusic::Rudiment::Pitch.get(pitch)
|
|
33
33
|
reference_pitch_frequency * (2**(1.0 / 12))**(pitch - reference_pitch.pitch).semitones
|
|
34
34
|
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def calculate_tonal_center_frequency
|
|
39
|
+
# Use equal temperament to get the tonal center frequency from the reference pitch
|
|
40
|
+
interval_to_tonal_center = (tonal_center - reference_pitch.pitch).semitones
|
|
41
|
+
reference_pitch_frequency * (2**(interval_to_tonal_center / 12.0))
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def ratio_for_interval(semitones)
|
|
45
|
+
# Handle octaves
|
|
46
|
+
octaves = semitones / 12
|
|
47
|
+
interval_within_octave = semitones % 12
|
|
48
|
+
|
|
49
|
+
# Get the base ratio from the subclass's INTERVAL_RATIOS constant
|
|
50
|
+
base_ratio = self.class::INTERVAL_RATIOS[self.class::INTERVAL_RATIOS.keys[interval_within_octave]]
|
|
51
|
+
|
|
52
|
+
# Apply octave adjustments
|
|
53
|
+
base_ratio * (2**octaves)
|
|
54
|
+
end
|
|
35
55
|
end
|
|
@@ -27,11 +27,11 @@ class HeadMusic::Style::Guidelines::ConsonantClimax < HeadMusic::Style::Annotati
|
|
|
27
27
|
end
|
|
28
28
|
|
|
29
29
|
def highest_pitch_consonant_with_tonic?
|
|
30
|
-
diatonic_interval_to_highest_pitch.
|
|
30
|
+
!diatonic_interval_to_highest_pitch.dissonant?(:melodic)
|
|
31
31
|
end
|
|
32
32
|
|
|
33
33
|
def lowest_pitch_consonant_with_tonic?
|
|
34
|
-
diatonic_interval_to_lowest_pitch.
|
|
34
|
+
!diatonic_interval_to_lowest_pitch.dissonant?(:melodic)
|
|
35
35
|
end
|
|
36
36
|
|
|
37
37
|
def diatonic_interval_to_highest_pitch
|
|
@@ -9,25 +9,22 @@ class HeadMusic::Style::ModernTradition < HeadMusic::Style::Tradition
|
|
|
9
9
|
end
|
|
10
10
|
|
|
11
11
|
case interval_mod
|
|
12
|
-
when 0
|
|
12
|
+
when 0 # Unison, Octave
|
|
13
13
|
HeadMusic::Rudiment::Consonance::PERFECT_CONSONANCE
|
|
14
|
-
when 7
|
|
14
|
+
when 7 # Perfect Fifth
|
|
15
15
|
HeadMusic::Rudiment::Consonance::PERFECT_CONSONANCE
|
|
16
|
-
when 3, 4
|
|
16
|
+
when 3, 4 # Minor Third, Major Third
|
|
17
17
|
HeadMusic::Rudiment::Consonance::IMPERFECT_CONSONANCE
|
|
18
|
-
when 8, 9
|
|
18
|
+
when 8, 9 # Minor Sixth, Major Sixth
|
|
19
19
|
HeadMusic::Rudiment::Consonance::IMPERFECT_CONSONANCE
|
|
20
|
-
when 5
|
|
21
|
-
#
|
|
22
|
-
|
|
23
|
-
HeadMusic::Rudiment::Consonance::PERFECT_CONSONANCE
|
|
20
|
+
when 5 # Perfect Fourth
|
|
21
|
+
# Perfect fourth is contextual: consonant in upper voices, dissonant against bass
|
|
22
|
+
HeadMusic::Rudiment::Consonance::CONTEXTUAL
|
|
24
23
|
when 2, 10 # Major Second, Minor Seventh
|
|
25
24
|
HeadMusic::Rudiment::Consonance::MILD_DISSONANCE
|
|
26
25
|
when 1, 11 # Minor Second, Major Seventh
|
|
27
26
|
HeadMusic::Rudiment::Consonance::HARSH_DISSONANCE
|
|
28
|
-
when 6
|
|
29
|
-
HeadMusic::Rudiment::Consonance::DISSONANCE
|
|
30
|
-
else
|
|
27
|
+
when 6 # Tritone (Aug 4th/Dim 5th)
|
|
31
28
|
HeadMusic::Rudiment::Consonance::DISSONANCE
|
|
32
29
|
end
|
|
33
30
|
end
|
|
@@ -15,7 +15,7 @@ module HeadMusic::Style
|
|
|
15
15
|
end
|
|
16
16
|
|
|
17
17
|
def name
|
|
18
|
-
self.class.name.split("::").last.sub(/Tradition$/, "")
|
|
18
|
+
HeadMusic::Utilities::Case.to_snake_case(self.class.name.split("::").last.sub(/Tradition$/, "")).to_sym
|
|
19
19
|
end
|
|
20
20
|
end
|
|
21
21
|
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module HeadMusic
|
|
4
|
+
module Time
|
|
5
|
+
# A value object representing elapsed nanoseconds of clock time
|
|
6
|
+
#
|
|
7
|
+
# ClockPosition provides a high-precision representation of time elapsed
|
|
8
|
+
# from a reference point, stored as nanoseconds. This allows for precise
|
|
9
|
+
# temporal calculations in musical contexts where millisecond-level accuracy
|
|
10
|
+
# is required for MIDI timing, audio synchronization, and SMPTE timecode.
|
|
11
|
+
#
|
|
12
|
+
# @example Creating a position at one second
|
|
13
|
+
# position = HeadMusic::Time::ClockPosition.new(1_000_000_000)
|
|
14
|
+
# position.to_seconds # => 1.0
|
|
15
|
+
#
|
|
16
|
+
# @example Adding two positions together
|
|
17
|
+
# pos1 = HeadMusic::Time::ClockPosition.new(500_000_000)
|
|
18
|
+
# pos2 = HeadMusic::Time::ClockPosition.new(300_000_000)
|
|
19
|
+
# result = pos1 + pos2
|
|
20
|
+
# result.to_milliseconds # => 800.0
|
|
21
|
+
#
|
|
22
|
+
# @example Comparing positions
|
|
23
|
+
# early = HeadMusic::Time::ClockPosition.new(1_000_000_000)
|
|
24
|
+
# late = HeadMusic::Time::ClockPosition.new(2_000_000_000)
|
|
25
|
+
# early < late # => true
|
|
26
|
+
class ClockPosition
|
|
27
|
+
include Comparable
|
|
28
|
+
|
|
29
|
+
# @return [Integer] the number of nanoseconds since the reference point
|
|
30
|
+
attr_reader :nanoseconds
|
|
31
|
+
|
|
32
|
+
# Create a new clock position
|
|
33
|
+
#
|
|
34
|
+
# @param nanoseconds [Integer] the number of nanoseconds elapsed
|
|
35
|
+
def initialize(nanoseconds)
|
|
36
|
+
@nanoseconds = nanoseconds
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Convert to integer representation (nanoseconds)
|
|
40
|
+
#
|
|
41
|
+
# @return [Integer] nanoseconds
|
|
42
|
+
def to_i
|
|
43
|
+
nanoseconds
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Convert nanoseconds to microseconds
|
|
47
|
+
#
|
|
48
|
+
# @return [Float] elapsed microseconds
|
|
49
|
+
def to_microseconds
|
|
50
|
+
nanoseconds / 1_000.0
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Convert nanoseconds to milliseconds
|
|
54
|
+
#
|
|
55
|
+
# @return [Float] elapsed milliseconds
|
|
56
|
+
def to_milliseconds
|
|
57
|
+
nanoseconds / 1_000_000.0
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Convert nanoseconds to seconds
|
|
61
|
+
#
|
|
62
|
+
# @return [Float] elapsed seconds
|
|
63
|
+
def to_seconds
|
|
64
|
+
nanoseconds / 1_000_000_000.0
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Add another clock position to this one
|
|
68
|
+
#
|
|
69
|
+
# @param other [ClockPosition, #to_i] another position or value with nanoseconds
|
|
70
|
+
# @return [ClockPosition] a new position with the combined duration
|
|
71
|
+
def +(other)
|
|
72
|
+
self.class.new(nanoseconds + other.to_i)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Compare this position to another
|
|
76
|
+
#
|
|
77
|
+
# @param other [ClockPosition, #to_i] another position to compare
|
|
78
|
+
# @return [Integer] -1 if less than, 0 if equal, 1 if greater than
|
|
79
|
+
def <=>(other)
|
|
80
|
+
nanoseconds <=> other.to_i
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module HeadMusic
|
|
4
|
+
module Time
|
|
5
|
+
# Representation of a conductor track for musical material
|
|
6
|
+
#
|
|
7
|
+
# The Conductor class synchronizes three different time representations:
|
|
8
|
+
# - Clock time: elapsed nanoseconds (source of truth)
|
|
9
|
+
# - Musical position: bars:beats:ticks:subticks notation
|
|
10
|
+
# - SMPTE timecode: hours:minutes:seconds:frames
|
|
11
|
+
#
|
|
12
|
+
# Each moment in a track corresponds to all three positions simultaneously.
|
|
13
|
+
# The conductor handles conversions between these representations based on
|
|
14
|
+
# the current tempo, meter, and framerate.
|
|
15
|
+
#
|
|
16
|
+
# @example Basic usage
|
|
17
|
+
# conductor = HeadMusic::Time::Conductor.new
|
|
18
|
+
# clock_pos = HeadMusic::Time::ClockPosition.new(1_000_000_000) # 1 second
|
|
19
|
+
# musical_pos = conductor.clock_to_musical(clock_pos)
|
|
20
|
+
# smpte = conductor.clock_to_smpte(clock_pos)
|
|
21
|
+
#
|
|
22
|
+
# @example With custom tempo and meter
|
|
23
|
+
# conductor = HeadMusic::Time::Conductor.new(
|
|
24
|
+
# starting_tempo: HeadMusic::Rudiment::Tempo.new("quarter", 96),
|
|
25
|
+
# starting_meter: HeadMusic::Rudiment::Meter.get("3/4")
|
|
26
|
+
# )
|
|
27
|
+
#
|
|
28
|
+
# @example Converting between representations
|
|
29
|
+
# conductor = HeadMusic::Time::Conductor.new
|
|
30
|
+
# musical = HeadMusic::Time::MusicalPosition.new(2, 1, 0, 0)
|
|
31
|
+
# clock = conductor.musical_to_clock(musical)
|
|
32
|
+
# smpte = conductor.clock_to_smpte(clock)
|
|
33
|
+
class Conductor
|
|
34
|
+
# @return [MusicalPosition] the musical position at clock time 0
|
|
35
|
+
attr_accessor :starting_musical_position
|
|
36
|
+
|
|
37
|
+
# @return [SmpteTimecode] the SMPTE timecode at clock time 0
|
|
38
|
+
attr_accessor :starting_smpte_timecode
|
|
39
|
+
|
|
40
|
+
# @return [Integer] frames per second for SMPTE conversions
|
|
41
|
+
attr_accessor :framerate
|
|
42
|
+
|
|
43
|
+
# @return [TempoMap] the tempo map for this conductor
|
|
44
|
+
attr_reader :tempo_map
|
|
45
|
+
|
|
46
|
+
# @return [MeterMap] the meter map for this conductor
|
|
47
|
+
attr_reader :meter_map
|
|
48
|
+
|
|
49
|
+
# @return [HeadMusic::Rudiment::Tempo] the initial tempo (delegates to tempo_map)
|
|
50
|
+
def starting_tempo
|
|
51
|
+
tempo_map.events.first.tempo
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# @return [HeadMusic::Rudiment::Meter] the initial meter (delegates to meter_map)
|
|
55
|
+
def starting_meter
|
|
56
|
+
meter_map.events.first.meter
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Create a new conductor
|
|
60
|
+
#
|
|
61
|
+
# @param starting_musical_position [MusicalPosition] initial musical position (default: 1:1:0:0)
|
|
62
|
+
# @param starting_smpte_timecode [SmpteTimecode] initial SMPTE timecode (default: 00:00:00:00)
|
|
63
|
+
# @param framerate [Integer] frames per second (default: 30)
|
|
64
|
+
# @param starting_tempo [HeadMusic::Rudiment::Tempo] initial tempo (default: quarter = 120)
|
|
65
|
+
# @param starting_meter [HeadMusic::Rudiment::Meter, String] initial meter (default: 4/4)
|
|
66
|
+
# @param tempo_map [TempoMap] custom tempo map (optional, creates one from starting_tempo if not provided)
|
|
67
|
+
# @param meter_map [MeterMap] custom meter map (optional, creates one from starting_meter if not provided)
|
|
68
|
+
def initialize(
|
|
69
|
+
starting_musical_position: nil,
|
|
70
|
+
starting_smpte_timecode: nil,
|
|
71
|
+
framerate: SmpteTimecode::DEFAULT_FRAMERATE,
|
|
72
|
+
starting_tempo: nil,
|
|
73
|
+
starting_meter: nil,
|
|
74
|
+
tempo_map: nil,
|
|
75
|
+
meter_map: nil
|
|
76
|
+
)
|
|
77
|
+
@starting_musical_position = starting_musical_position || MusicalPosition.new
|
|
78
|
+
@starting_smpte_timecode = starting_smpte_timecode || SmpteTimecode.new(0, 0, 0, 0, framerate: framerate)
|
|
79
|
+
@framerate = framerate
|
|
80
|
+
|
|
81
|
+
# Create or use provided maps
|
|
82
|
+
@tempo_map = tempo_map || TempoMap.new(
|
|
83
|
+
starting_tempo: starting_tempo || HeadMusic::Rudiment::Tempo.new("quarter", 120),
|
|
84
|
+
starting_position: @starting_musical_position
|
|
85
|
+
)
|
|
86
|
+
@meter_map = meter_map || MeterMap.new(
|
|
87
|
+
starting_meter: starting_meter || "4/4",
|
|
88
|
+
starting_position: @starting_musical_position
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
# Link maps together for position normalization
|
|
92
|
+
@tempo_map.meter = @meter_map.meter_at(@starting_musical_position)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Convert clock position to musical position
|
|
96
|
+
#
|
|
97
|
+
# Uses the tempo map to determine how many beats have elapsed,
|
|
98
|
+
# accounting for tempo changes along the timeline.
|
|
99
|
+
#
|
|
100
|
+
# @param clock_position [ClockPosition] the clock time to convert
|
|
101
|
+
# @return [MusicalPosition] the corresponding musical position
|
|
102
|
+
def clock_to_musical(clock_position)
|
|
103
|
+
target_nanoseconds = clock_position.nanoseconds
|
|
104
|
+
accumulated_nanoseconds = 0
|
|
105
|
+
current_position = starting_musical_position
|
|
106
|
+
|
|
107
|
+
# We need an end position far enough to contain our target clock time
|
|
108
|
+
# Start with a reasonable guess and extend if needed
|
|
109
|
+
estimated_end_bar = starting_musical_position.bar + 1000
|
|
110
|
+
estimated_end = MusicalPosition.new(estimated_end_bar, 1, 0, 0)
|
|
111
|
+
|
|
112
|
+
tempo_map.each_segment(starting_musical_position, estimated_end) do |start_pos, end_pos, tempo|
|
|
113
|
+
meter = meter_map.meter_at(start_pos)
|
|
114
|
+
|
|
115
|
+
# Calculate clock duration of this segment
|
|
116
|
+
start_subticks = musical_position_to_subticks(start_pos, meter)
|
|
117
|
+
end_subticks = musical_position_to_subticks(end_pos, meter)
|
|
118
|
+
segment_subticks = end_subticks - start_subticks
|
|
119
|
+
segment_ticks = segment_subticks / HeadMusic::Time::SUBTICKS_PER_TICK.to_f
|
|
120
|
+
segment_nanoseconds = (segment_ticks * tempo.tick_duration_in_nanoseconds).round
|
|
121
|
+
|
|
122
|
+
# Check if our target falls within this segment
|
|
123
|
+
if accumulated_nanoseconds + segment_nanoseconds >= target_nanoseconds
|
|
124
|
+
# Target is in this segment - calculate exact position
|
|
125
|
+
remaining_nanoseconds = target_nanoseconds - accumulated_nanoseconds
|
|
126
|
+
remaining_ticks = remaining_nanoseconds / tempo.tick_duration_in_nanoseconds.to_f
|
|
127
|
+
remaining_subticks = (remaining_ticks * HeadMusic::Time::SUBTICKS_PER_TICK).round
|
|
128
|
+
|
|
129
|
+
# Add to start position of this segment
|
|
130
|
+
total_subticks = start_subticks + remaining_subticks
|
|
131
|
+
|
|
132
|
+
# Convert to bar:beat:tick:subtick
|
|
133
|
+
ticks_per_count = meter.ticks_per_count
|
|
134
|
+
counts_per_bar = meter.counts_per_bar
|
|
135
|
+
subticks_per_count = ticks_per_count * HeadMusic::Time::SUBTICKS_PER_TICK
|
|
136
|
+
subticks_per_bar = counts_per_bar * subticks_per_count
|
|
137
|
+
|
|
138
|
+
bars = (total_subticks / subticks_per_bar).floor
|
|
139
|
+
remaining = total_subticks % subticks_per_bar
|
|
140
|
+
|
|
141
|
+
beats = (remaining / subticks_per_count).floor
|
|
142
|
+
remaining %= subticks_per_count
|
|
143
|
+
|
|
144
|
+
ticks = (remaining / HeadMusic::Time::SUBTICKS_PER_TICK).floor
|
|
145
|
+
subticks = remaining % HeadMusic::Time::SUBTICKS_PER_TICK
|
|
146
|
+
|
|
147
|
+
position = MusicalPosition.new(bars + 1, beats + 1, ticks, subticks)
|
|
148
|
+
return position.normalize!(meter)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
accumulated_nanoseconds += segment_nanoseconds
|
|
152
|
+
current_position = end_pos
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# If we get here, return the last position (shouldn't normally happen)
|
|
156
|
+
current_position
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Convert musical position to clock position
|
|
160
|
+
#
|
|
161
|
+
# Uses the tempo map to determine how much clock time has elapsed
|
|
162
|
+
# based on the musical position, accounting for tempo changes.
|
|
163
|
+
#
|
|
164
|
+
# @param musical_position [MusicalPosition] the musical position to convert
|
|
165
|
+
# @return [ClockPosition] the corresponding clock time
|
|
166
|
+
def musical_to_clock(musical_position)
|
|
167
|
+
total_nanoseconds = 0
|
|
168
|
+
|
|
169
|
+
# Iterate through each tempo segment from start to target position
|
|
170
|
+
tempo_map.each_segment(starting_musical_position, musical_position) do |start_pos, end_pos, tempo|
|
|
171
|
+
# Get the meter for this segment to calculate subticks correctly
|
|
172
|
+
meter = meter_map.meter_at(start_pos)
|
|
173
|
+
|
|
174
|
+
# Calculate subticks in this segment
|
|
175
|
+
start_subticks = musical_position_to_subticks(start_pos, meter)
|
|
176
|
+
end_subticks = musical_position_to_subticks(end_pos, meter)
|
|
177
|
+
segment_subticks = end_subticks - start_subticks
|
|
178
|
+
|
|
179
|
+
# Convert subticks to ticks
|
|
180
|
+
segment_ticks = segment_subticks / HeadMusic::Time::SUBTICKS_PER_TICK.to_f
|
|
181
|
+
|
|
182
|
+
# Convert ticks to nanoseconds using this segment's tempo
|
|
183
|
+
nanoseconds_per_tick = tempo.tick_duration_in_nanoseconds
|
|
184
|
+
segment_nanoseconds = (segment_ticks * nanoseconds_per_tick).round
|
|
185
|
+
|
|
186
|
+
total_nanoseconds += segment_nanoseconds
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
ClockPosition.new(total_nanoseconds)
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# Convert clock position to SMPTE timecode
|
|
193
|
+
#
|
|
194
|
+
# Uses the framerate to determine the timecode.
|
|
195
|
+
#
|
|
196
|
+
# @param clock_position [ClockPosition] the clock time to convert
|
|
197
|
+
# @return [SmpteTimecode] the corresponding SMPTE timecode
|
|
198
|
+
def clock_to_smpte(clock_position)
|
|
199
|
+
# Calculate total frames from nanoseconds
|
|
200
|
+
nanoseconds_per_second = 1_000_000_000.0
|
|
201
|
+
elapsed_seconds = clock_position.nanoseconds / nanoseconds_per_second
|
|
202
|
+
total_frames = (elapsed_seconds * framerate).round
|
|
203
|
+
|
|
204
|
+
# Add starting timecode frames
|
|
205
|
+
starting_frames = starting_smpte_timecode.to_total_frames
|
|
206
|
+
total_frames += starting_frames
|
|
207
|
+
|
|
208
|
+
# Convert frames to HH:MM:SS:FF
|
|
209
|
+
hours = total_frames / (framerate * 60 * 60)
|
|
210
|
+
remaining = total_frames % (framerate * 60 * 60)
|
|
211
|
+
|
|
212
|
+
minutes = remaining / (framerate * 60)
|
|
213
|
+
remaining %= (framerate * 60)
|
|
214
|
+
|
|
215
|
+
seconds = remaining / framerate
|
|
216
|
+
frames = remaining % framerate
|
|
217
|
+
|
|
218
|
+
timecode = SmpteTimecode.new(hours, minutes, seconds, frames, framerate: framerate)
|
|
219
|
+
timecode.normalize!
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# Convert SMPTE timecode to clock position
|
|
223
|
+
#
|
|
224
|
+
# Uses the framerate to determine the clock time.
|
|
225
|
+
#
|
|
226
|
+
# @param smpte_timecode [SmpteTimecode] the SMPTE timecode to convert
|
|
227
|
+
# @return [ClockPosition] the corresponding clock time
|
|
228
|
+
def smpte_to_clock(smpte_timecode)
|
|
229
|
+
# Calculate total frames
|
|
230
|
+
total_frames = smpte_timecode.to_total_frames
|
|
231
|
+
starting_frames = starting_smpte_timecode.to_total_frames
|
|
232
|
+
elapsed_frames = total_frames - starting_frames
|
|
233
|
+
|
|
234
|
+
# Convert frames to seconds, then to nanoseconds
|
|
235
|
+
nanoseconds_per_second = 1_000_000_000.0
|
|
236
|
+
elapsed_seconds = elapsed_frames / framerate.to_f
|
|
237
|
+
elapsed_nanoseconds = (elapsed_seconds * nanoseconds_per_second).round
|
|
238
|
+
|
|
239
|
+
ClockPosition.new(elapsed_nanoseconds)
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
private
|
|
243
|
+
|
|
244
|
+
# Convert a musical position to total subticks for calculation
|
|
245
|
+
#
|
|
246
|
+
# @param position [MusicalPosition] the position to convert
|
|
247
|
+
# @param meter [HeadMusic::Rudiment::Meter] the meter to use for calculation
|
|
248
|
+
# @return [Integer] total subticks from the beginning
|
|
249
|
+
def musical_position_to_subticks(position, meter = nil)
|
|
250
|
+
meter ||= meter_map.meter_at(position)
|
|
251
|
+
ticks_per_count = meter.ticks_per_count
|
|
252
|
+
counts_per_bar = meter.counts_per_bar
|
|
253
|
+
|
|
254
|
+
total = 0
|
|
255
|
+
total += (position.bar - 1) * counts_per_bar * ticks_per_count * HeadMusic::Time::SUBTICKS_PER_TICK
|
|
256
|
+
total += (position.beat - 1) * ticks_per_count * HeadMusic::Time::SUBTICKS_PER_TICK
|
|
257
|
+
total += position.tick * HeadMusic::Time::SUBTICKS_PER_TICK
|
|
258
|
+
total += position.subtick
|
|
259
|
+
|
|
260
|
+
total
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module HeadMusic
|
|
4
|
+
module Time
|
|
5
|
+
# Represents a meter change at a specific musical position
|
|
6
|
+
#
|
|
7
|
+
# MeterEvent marks a point in a musical timeline where the meter
|
|
8
|
+
# (time signature) changes. This is essential for properly calculating
|
|
9
|
+
# musical positions and normalizing bar:beat:tick:subtick values.
|
|
10
|
+
#
|
|
11
|
+
# @example Creating a meter change to 3/4 at bar 5
|
|
12
|
+
# position = HeadMusic::Time::MusicalPosition.new(5, 1, 0, 0)
|
|
13
|
+
# meter = HeadMusic::Rudiment::Meter.get("3/4")
|
|
14
|
+
# event = HeadMusic::Time::MeterEvent.new(position, meter)
|
|
15
|
+
#
|
|
16
|
+
# @example With common time
|
|
17
|
+
# position = HeadMusic::Time::MusicalPosition.new(1, 1, 0, 0)
|
|
18
|
+
# meter = HeadMusic::Rudiment::Meter.common_time
|
|
19
|
+
# event = HeadMusic::Time::MeterEvent.new(position, meter)
|
|
20
|
+
class MeterEvent
|
|
21
|
+
# @return [MusicalPosition] the position where this meter change occurs
|
|
22
|
+
attr_accessor :position
|
|
23
|
+
|
|
24
|
+
# @return [HeadMusic::Rudiment::Meter, String] the meter (time signature)
|
|
25
|
+
attr_accessor :meter
|
|
26
|
+
|
|
27
|
+
# Create a new meter change event
|
|
28
|
+
#
|
|
29
|
+
# @param position [MusicalPosition] where the meter change occurs
|
|
30
|
+
# @param meter [HeadMusic::Rudiment::Meter, String] the new meter
|
|
31
|
+
def initialize(position, meter)
|
|
32
|
+
@position = position
|
|
33
|
+
@meter = meter
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|