head_music 8.3.0 → 9.0.1
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/CHANGELOG.md +53 -0
- data/CLAUDE.md +32 -15
- data/Gemfile.lock +1 -1
- data/MUSIC_THEORY.md +120 -0
- data/lib/head_music/analysis/diatonic_interval.rb +29 -27
- data/lib/head_music/analysis/interval_consonance.rb +51 -0
- 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/staff.rb +1 -1
- data/lib/head_music/instruments/instrument.rb +103 -113
- data/lib/head_music/instruments/instrument_family.rb +13 -2
- data/lib/head_music/instruments/instrument_type.rb +188 -0
- data/lib/head_music/instruments/score_order.rb +139 -0
- data/lib/head_music/instruments/score_orders.yml +130 -0
- data/lib/head_music/instruments/variant.rb +6 -0
- data/lib/head_music/locales/de.yml +6 -0
- data/lib/head_music/locales/en.yml +6 -0
- data/lib/head_music/locales/es.yml +6 -0
- data/lib/head_music/locales/fr.yml +6 -0
- data/lib/head_music/locales/it.yml +6 -0
- data/lib/head_music/locales/ru.yml +6 -0
- data/lib/head_music/rudiment/alteration.rb +23 -8
- data/lib/head_music/rudiment/base.rb +9 -0
- data/lib/head_music/rudiment/chromatic_interval.rb +3 -6
- data/lib/head_music/rudiment/clef.rb +1 -1
- data/lib/head_music/rudiment/consonance.rb +37 -4
- 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 +46 -7
- 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/musical_symbol.rb +1 -1
- 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 +5 -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.rb +1 -1
- data/lib/head_music/rudiment/unpitched_note.rb +62 -0
- data/lib/head_music/style/medieval_tradition.rb +26 -0
- data/lib/head_music/style/modern_tradition.rb +34 -0
- data/lib/head_music/style/renaissance_tradition.rb +26 -0
- data/lib/head_music/style/tradition.rb +21 -0
- data/lib/head_music/utilities/hash_key.rb +34 -2
- data/lib/head_music/version.rb +1 -1
- data/lib/head_music.rb +31 -10
- data/user_stories/active/handle-time.md +7 -0
- data/user_stories/active/handle-time.rb +177 -0
- data/user_stories/done/epic--score-order/PLAN.md +244 -0
- data/user_stories/done/instrument-variant.md +65 -0
- data/user_stories/done/superclass-for-note.md +30 -0
- data/user_stories/todo/agentic-daw.md +3 -0
- data/user_stories/{backlog → todo}/dyad-analysis.md +2 -10
- data/user_stories/todo/material-and-scores.md +10 -0
- data/user_stories/todo/organizing-content.md +72 -0
- data/user_stories/todo/percussion_set.md +1 -0
- data/user_stories/{backlog → todo}/pitch-class-set-analysis.md +40 -0
- data/user_stories/{backlog → todo}/pitch-set-classification.md +10 -0
- data/user_stories/{backlog → todo}/sonority-identification.md +20 -0
- metadata +43 -12
- data/TODO.md +0 -109
- /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 → todo}/consonance-dissonance-classification.md +0 -0
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# A module for music rudiments
|
|
2
|
+
module HeadMusic::Rudiment; end
|
|
3
|
+
|
|
4
|
+
# Represents a musical mode (church modes)
|
|
5
|
+
class HeadMusic::Rudiment::Mode < HeadMusic::Rudiment::DiatonicContext
|
|
6
|
+
include HeadMusic::Named
|
|
7
|
+
|
|
8
|
+
MODES = %i[ionian dorian phrygian lydian mixolydian aeolian locrian].freeze
|
|
9
|
+
|
|
10
|
+
attr_reader :mode_name
|
|
11
|
+
|
|
12
|
+
def self.get(identifier)
|
|
13
|
+
return identifier if identifier.is_a?(HeadMusic::Rudiment::Mode)
|
|
14
|
+
|
|
15
|
+
@modes ||= {}
|
|
16
|
+
tonic_spelling, mode_name = parse_identifier(identifier)
|
|
17
|
+
hash_key = HeadMusic::Utilities::HashKey.for(identifier)
|
|
18
|
+
@modes[hash_key] ||= new(tonic_spelling, mode_name)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def self.parse_identifier(identifier)
|
|
22
|
+
identifier = identifier.to_s.strip
|
|
23
|
+
parts = identifier.split(/\s+/)
|
|
24
|
+
tonic_spelling = parts[0]
|
|
25
|
+
mode_name = parts[1] || "ionian"
|
|
26
|
+
[tonic_spelling, mode_name]
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def initialize(tonic_spelling, mode_name = :ionian)
|
|
30
|
+
super(tonic_spelling)
|
|
31
|
+
@mode_name = mode_name.to_s.downcase.to_sym
|
|
32
|
+
raise ArgumentError, "Mode must be one of: #{MODES.join(", ")}" unless MODES.include?(@mode_name)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def scale_type
|
|
36
|
+
@scale_type ||= HeadMusic::Rudiment::ScaleType.get(mode_name)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def relative_major
|
|
40
|
+
case mode_name
|
|
41
|
+
when :ionian
|
|
42
|
+
return HeadMusic::Rudiment::Key.get("#{tonic_spelling} major")
|
|
43
|
+
when :dorian
|
|
44
|
+
relative_pitch = tonic_pitch + -2
|
|
45
|
+
when :phrygian
|
|
46
|
+
relative_pitch = tonic_pitch + -4
|
|
47
|
+
when :lydian
|
|
48
|
+
relative_pitch = tonic_pitch + -5
|
|
49
|
+
when :mixolydian
|
|
50
|
+
relative_pitch = tonic_pitch + -7
|
|
51
|
+
when :aeolian
|
|
52
|
+
relative_pitch = tonic_pitch + -9
|
|
53
|
+
when :locrian
|
|
54
|
+
relative_pitch = tonic_pitch + -11
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
HeadMusic::Rudiment::Key.get("#{relative_pitch.spelling} major")
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def relative
|
|
61
|
+
relative_major
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def parallel
|
|
65
|
+
# Return the parallel major or minor key
|
|
66
|
+
case mode_name
|
|
67
|
+
when :ionian
|
|
68
|
+
HeadMusic::Rudiment::Key.get("#{tonic_spelling} major")
|
|
69
|
+
when :aeolian
|
|
70
|
+
HeadMusic::Rudiment::Key.get("#{tonic_spelling} minor")
|
|
71
|
+
when :dorian, :phrygian
|
|
72
|
+
HeadMusic::Rudiment::Key.get("#{tonic_spelling} minor")
|
|
73
|
+
when :lydian, :mixolydian
|
|
74
|
+
HeadMusic::Rudiment::Key.get("#{tonic_spelling} major")
|
|
75
|
+
when :locrian
|
|
76
|
+
HeadMusic::Rudiment::Key.get("#{tonic_spelling} minor")
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def name
|
|
81
|
+
"#{tonic_spelling} #{mode_name}"
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def to_s
|
|
85
|
+
name
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def ==(other)
|
|
89
|
+
other = self.class.get(other)
|
|
90
|
+
tonic_spelling == other.tonic_spelling && mode_name == other.mode_name
|
|
91
|
+
end
|
|
92
|
+
end
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
module HeadMusic::Rudiment; end
|
|
3
3
|
|
|
4
4
|
# A symbol is a mark or sign that signifies a particular rudiment of music
|
|
5
|
-
class HeadMusic::Rudiment::MusicalSymbol
|
|
5
|
+
class HeadMusic::Rudiment::MusicalSymbol < HeadMusic::Rudiment::Base
|
|
6
6
|
attr_reader :ascii, :unicode, :html_entity
|
|
7
7
|
|
|
8
8
|
def initialize(ascii: nil, unicode: nil, html_entity: nil)
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# A module for music rudiments
|
|
2
|
+
module HeadMusic::Rudiment; end
|
|
3
|
+
|
|
4
|
+
# A Note is a fundamental musical element consisting of a pitch and a duration.
|
|
5
|
+
# This is the rudiment version, representing the abstract concept of a note
|
|
6
|
+
# independent of its placement in a composition.
|
|
7
|
+
#
|
|
8
|
+
# For notes placed within a composition context, see HeadMusic::Content::Note
|
|
9
|
+
class HeadMusic::Rudiment::Note < HeadMusic::Rudiment::RhythmicElement
|
|
10
|
+
include HeadMusic::Named
|
|
11
|
+
|
|
12
|
+
attr_reader :pitch
|
|
13
|
+
|
|
14
|
+
delegate :spelling, :register, :letter_name, :alteration, to: :pitch
|
|
15
|
+
delegate :sharp?, :flat?, :natural?, to: :pitch
|
|
16
|
+
delegate :pitch_class, :midi_note_number, :frequency, to: :pitch
|
|
17
|
+
|
|
18
|
+
# Regex pattern for parsing note strings like "C#4 quarter" or "Eb3 dotted half"
|
|
19
|
+
# Extract the core pattern from Spelling::MATCHER without anchors
|
|
20
|
+
PITCH_PATTERN = /([A-G])(#{HeadMusic::Rudiment::Alteration::PATTERN.source}?)(-?\d+)?/i
|
|
21
|
+
MATCHER = /^\s*(#{PITCH_PATTERN.source})\s+(.+)$/i
|
|
22
|
+
|
|
23
|
+
def self.get(pitch, rhythmic_value = nil)
|
|
24
|
+
return pitch if pitch.is_a?(HeadMusic::Rudiment::Note)
|
|
25
|
+
|
|
26
|
+
if rhythmic_value.nil? && pitch.is_a?(String)
|
|
27
|
+
# Try to parse as "pitch rhythmic_value" format (e.g., "F#4 dotted-quarter")
|
|
28
|
+
match = pitch.match(MATCHER)
|
|
29
|
+
if match
|
|
30
|
+
pitch_str = match[1] # The full pitch part
|
|
31
|
+
rhythmic_value_str = match[5] # The rhythmic value part
|
|
32
|
+
|
|
33
|
+
pitch_obj = HeadMusic::Rudiment::Pitch.get(pitch_str)
|
|
34
|
+
rhythmic_value_obj = HeadMusic::Rudiment::RhythmicValue.get(rhythmic_value_str)
|
|
35
|
+
|
|
36
|
+
return fetch_or_create(pitch_obj, rhythmic_value_obj) if pitch_obj && rhythmic_value_obj
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# If parsing fails, treat it as just a pitch with default quarter note
|
|
40
|
+
pitch_obj = HeadMusic::Rudiment::Pitch.get(pitch)
|
|
41
|
+
return fetch_or_create(pitch_obj, HeadMusic::Rudiment::RhythmicValue.get(:quarter)) if pitch_obj
|
|
42
|
+
|
|
43
|
+
nil
|
|
44
|
+
else
|
|
45
|
+
pitch = HeadMusic::Rudiment::Pitch.get(pitch)
|
|
46
|
+
rhythmic_value = HeadMusic::Rudiment::RhythmicValue.get(rhythmic_value || :quarter)
|
|
47
|
+
fetch_or_create(pitch, rhythmic_value)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def self.fetch_or_create(pitch, rhythmic_value)
|
|
52
|
+
@notes ||= {}
|
|
53
|
+
hash_key = [pitch.to_s, rhythmic_value.to_s].join("_")
|
|
54
|
+
@notes[hash_key] ||= new(pitch, rhythmic_value)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def initialize(pitch, rhythmic_value)
|
|
58
|
+
super(rhythmic_value)
|
|
59
|
+
@pitch = pitch
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Make new public for this concrete class
|
|
63
|
+
public_class_method :new
|
|
64
|
+
|
|
65
|
+
def name
|
|
66
|
+
"#{pitch} #{rhythmic_value}"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def to_s
|
|
70
|
+
name
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def ==(other)
|
|
74
|
+
other = HeadMusic::Rudiment::Note.get(other)
|
|
75
|
+
super && pitch == other.pitch
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def <=>(other)
|
|
79
|
+
return nil unless other.is_a?(HeadMusic::Rudiment::RhythmicElement)
|
|
80
|
+
return super unless other.is_a?(self.class)
|
|
81
|
+
|
|
82
|
+
[rhythmic_value, pitch] <=> [other.rhythmic_value, other.pitch]
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Transpose the note up by an interval or semitones
|
|
86
|
+
def +(other)
|
|
87
|
+
new_pitch = pitch + other
|
|
88
|
+
self.class.get(new_pitch, rhythmic_value)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Transpose the note down by an interval or semitones
|
|
92
|
+
def -(other)
|
|
93
|
+
new_pitch = pitch - other
|
|
94
|
+
self.class.get(new_pitch, rhythmic_value)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Override to maintain pitch when changing rhythmic value
|
|
98
|
+
def with_rhythmic_value(new_rhythmic_value)
|
|
99
|
+
self.class.get(pitch, new_rhythmic_value)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Change the pitch while keeping the same rhythmic value
|
|
103
|
+
def with_pitch(new_pitch)
|
|
104
|
+
self.class.get(new_pitch, rhythmic_value)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def sounded?
|
|
108
|
+
true
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
private_class_method :fetch_or_create
|
|
112
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
class HeadMusic::Rudiment::Pitch::Parser
|
|
2
|
+
attr_reader :identifier, :letter_name, :alteration, :register
|
|
3
|
+
|
|
4
|
+
LetterName = HeadMusic::Rudiment::LetterName
|
|
5
|
+
Alteration = HeadMusic::Rudiment::Alteration
|
|
6
|
+
Spelling = HeadMusic::Rudiment::Spelling
|
|
7
|
+
Register = HeadMusic::Rudiment::Register
|
|
8
|
+
Pitch = HeadMusic::Rudiment::Pitch
|
|
9
|
+
|
|
10
|
+
# Pattern that handles negative registers (e.g., -1) and positive registers
|
|
11
|
+
# Anchored to match complete pitch strings only
|
|
12
|
+
PATTERN = /\A(#{LetterName::PATTERN})?(#{Alteration::PATTERN.source})?(-?\d+)?\z/
|
|
13
|
+
|
|
14
|
+
# Parse a pitch identifier and return a Pitch object
|
|
15
|
+
# Returns nil if the identifier cannot be parsed into a valid pitch
|
|
16
|
+
def self.parse(identifier)
|
|
17
|
+
return nil if identifier.nil?
|
|
18
|
+
new(identifier).pitch
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def initialize(identifier)
|
|
22
|
+
@identifier = identifier.to_s.strip
|
|
23
|
+
parse_components
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def pitch
|
|
27
|
+
return unless spelling
|
|
28
|
+
# Default to register 4 if not provided (matching old behavior)
|
|
29
|
+
# Convert Register object to integer for fetch_or_create
|
|
30
|
+
reg = register ? register.to_i : Register::DEFAULT
|
|
31
|
+
|
|
32
|
+
@pitch ||= Pitch.fetch_or_create(spelling, reg)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def spelling
|
|
36
|
+
return unless letter_name
|
|
37
|
+
|
|
38
|
+
@spelling ||= Spelling.new(letter_name, alteration)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def parse_components
|
|
44
|
+
match = identifier.match(PATTERN)
|
|
45
|
+
|
|
46
|
+
if match
|
|
47
|
+
@letter_name = LetterName.get(match[1].upcase) unless match[1].to_s.empty?
|
|
48
|
+
@alteration = Alteration.get(match[2] || "") unless match[2].to_s.empty?
|
|
49
|
+
@register = Register.get(match[3]&.to_i) unless match[3].to_s.empty?
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -1,8 +1,7 @@
|
|
|
1
|
-
# A module for music rudiments
|
|
2
1
|
module HeadMusic::Rudiment; end
|
|
3
2
|
|
|
4
3
|
# A pitch is a named frequency represented by a spelling and a register.
|
|
5
|
-
class HeadMusic::Rudiment::Pitch
|
|
4
|
+
class HeadMusic::Rudiment::Pitch < HeadMusic::Rudiment::Base
|
|
6
5
|
include Comparable
|
|
7
6
|
|
|
8
7
|
attr_reader :spelling, :register
|
|
@@ -29,6 +28,8 @@ class HeadMusic::Rudiment::Pitch
|
|
|
29
28
|
# - a name string, such as 'Ab4'
|
|
30
29
|
# - a number corresponding to the midi note number
|
|
31
30
|
def self.get(value)
|
|
31
|
+
return value if value.is_a?(HeadMusic::Rudiment::Pitch)
|
|
32
|
+
|
|
32
33
|
from_pitch_class(value) ||
|
|
33
34
|
from_name(value) ||
|
|
34
35
|
from_number(value)
|
|
@@ -51,7 +52,7 @@ class HeadMusic::Rudiment::Pitch
|
|
|
51
52
|
def self.from_name(name)
|
|
52
53
|
return nil unless name == name.to_s
|
|
53
54
|
|
|
54
|
-
|
|
55
|
+
Parser.parse(name)
|
|
55
56
|
end
|
|
56
57
|
|
|
57
58
|
def self.from_number(number)
|
|
@@ -114,7 +115,7 @@ class HeadMusic::Rudiment::Pitch
|
|
|
114
115
|
end
|
|
115
116
|
|
|
116
117
|
def natural
|
|
117
|
-
HeadMusic::Rudiment::Pitch.get(to_s.gsub(HeadMusic::Rudiment::Alteration
|
|
118
|
+
HeadMusic::Rudiment::Pitch.get(to_s.gsub(HeadMusic::Rudiment::Alteration::PATTERN, ""))
|
|
118
119
|
end
|
|
119
120
|
|
|
120
121
|
def +(other)
|
|
@@ -167,8 +168,6 @@ class HeadMusic::Rudiment::Pitch
|
|
|
167
168
|
letter_name_steps_to(other) + 7 * octave_changes_to(other)
|
|
168
169
|
end
|
|
169
170
|
|
|
170
|
-
private_class_method :new
|
|
171
|
-
|
|
172
171
|
private
|
|
173
172
|
|
|
174
173
|
def octave_changes_to(other)
|
|
@@ -3,7 +3,7 @@ module HeadMusic::Rudiment; end
|
|
|
3
3
|
|
|
4
4
|
# A reference pitch has a pitch and a frequency
|
|
5
5
|
# With no arguments, it assumes that A4 = 440.0 Hz
|
|
6
|
-
class HeadMusic::Rudiment::ReferencePitch
|
|
6
|
+
class HeadMusic::Rudiment::ReferencePitch < HeadMusic::Rudiment::Base
|
|
7
7
|
include HeadMusic::Named
|
|
8
8
|
|
|
9
9
|
DEFAULT_PITCH_NAME = "A4"
|
|
@@ -6,9 +6,12 @@ module HeadMusic::Rudiment; end
|
|
|
6
6
|
# A pitch is a spelling plus a register. For example, C4 is middle C and C5 is the C one octave higher.
|
|
7
7
|
# The number changes between the letter names B and C regardless of sharps and flats,
|
|
8
8
|
# so as an extreme example, Cb5 is actually a semitone below B#4.
|
|
9
|
-
class HeadMusic::Rudiment::Register
|
|
9
|
+
class HeadMusic::Rudiment::Register < HeadMusic::Rudiment::Base
|
|
10
10
|
include Comparable
|
|
11
11
|
|
|
12
|
+
AUDIBLE_REGISTERS = (0..10).map.freeze
|
|
13
|
+
PATTERN = Regexp.union(AUDIBLE_REGISTERS.map(&:to_s))
|
|
14
|
+
|
|
12
15
|
DEFAULT = 4
|
|
13
16
|
|
|
14
17
|
def self.get(identifier)
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# A module for music rudiments
|
|
2
|
+
module HeadMusic::Rudiment; end
|
|
3
|
+
|
|
4
|
+
# A Rest represents a period of silence with a specific rhythmic value.
|
|
5
|
+
# It inherits from RhythmicElement and has a duration but no pitch.
|
|
6
|
+
class HeadMusic::Rudiment::Rest < HeadMusic::Rudiment::RhythmicElement
|
|
7
|
+
include HeadMusic::Named
|
|
8
|
+
|
|
9
|
+
# Make new public for this concrete class
|
|
10
|
+
public_class_method :new
|
|
11
|
+
|
|
12
|
+
def self.get(rhythmic_value)
|
|
13
|
+
return rhythmic_value if rhythmic_value.is_a?(HeadMusic::Rudiment::Rest)
|
|
14
|
+
|
|
15
|
+
rhythmic_value = HeadMusic::Rudiment::RhythmicValue.get(rhythmic_value)
|
|
16
|
+
return nil unless rhythmic_value
|
|
17
|
+
|
|
18
|
+
fetch_or_create(rhythmic_value)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def self.fetch_or_create(rhythmic_value)
|
|
22
|
+
@rests ||= {}
|
|
23
|
+
hash_key = rhythmic_value.to_s
|
|
24
|
+
@rests[hash_key] ||= new(rhythmic_value)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def name
|
|
28
|
+
"#{rhythmic_value} rest"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def sounded?
|
|
32
|
+
false
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private_class_method :fetch_or_create
|
|
36
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# A module for music rudiments
|
|
2
|
+
module HeadMusic::Rudiment; end
|
|
3
|
+
|
|
4
|
+
# Abstract base class for rhythmic elements that have a rhythmic value.
|
|
5
|
+
# This includes notes (pitched), rests (silence), and unpitched notes (percussion).
|
|
6
|
+
class HeadMusic::Rudiment::RhythmicElement < HeadMusic::Rudiment::Base
|
|
7
|
+
include Comparable
|
|
8
|
+
|
|
9
|
+
LetterName = HeadMusic::Rudiment::LetterName
|
|
10
|
+
Alteration = HeadMusic::Rudiment::Alteration
|
|
11
|
+
Register = HeadMusic::Rudiment::Register
|
|
12
|
+
RhythmicValue = HeadMusic::Rudiment::RhythmicValue
|
|
13
|
+
|
|
14
|
+
attr_reader :rhythmic_value
|
|
15
|
+
|
|
16
|
+
delegate :unit, :dots, :tied_value, :ticks, to: :rhythmic_value
|
|
17
|
+
|
|
18
|
+
# Make new private to prevent direct instantiation of abstract class
|
|
19
|
+
private_class_method :new
|
|
20
|
+
|
|
21
|
+
def initialize(rhythmic_value)
|
|
22
|
+
@rhythmic_value = rhythmic_value
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Create a new instance with a different rhythmic value
|
|
26
|
+
def with_rhythmic_value(new_rhythmic_value)
|
|
27
|
+
self.class.get(new_rhythmic_value)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def ==(other)
|
|
31
|
+
return false unless other.is_a?(self.class)
|
|
32
|
+
rhythmic_value == other.rhythmic_value
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def <=>(other)
|
|
36
|
+
return nil unless other.is_a?(HeadMusic::Rudiment::RhythmicElement)
|
|
37
|
+
rhythmic_value <=> other.rhythmic_value
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def to_s
|
|
41
|
+
name
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Abstract method - must be implemented by subclasses
|
|
45
|
+
def name
|
|
46
|
+
raise NotImplementedError, "Subclasses must implement the name method"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Abstract method - must be implemented by subclasses
|
|
50
|
+
def sounded?
|
|
51
|
+
raise NotImplementedError, "Subclasses must implement the sounded? method"
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
class HeadMusic::Rudiment::RhythmicUnit::Parser
|
|
2
|
+
attr_reader :rhythmic_unit, :identifier
|
|
3
|
+
|
|
4
|
+
RHYTHMIC_UNITS_DATA = HeadMusic::Rudiment::RhythmicUnit::RHYTHMIC_UNITS_DATA
|
|
5
|
+
|
|
6
|
+
TEMPO_SHORTHAND_PATTERN = RHYTHMIC_UNITS_DATA.map { |unit| unit["tempo_shorthand"] }.compact.uniq.sort_by { |s| -s.length }.join("|")
|
|
7
|
+
|
|
8
|
+
def self.parse(identifier)
|
|
9
|
+
return nil if identifier.nil? || identifier.to_s.strip.empty?
|
|
10
|
+
new(identifier).parsed_name
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def initialize(identifier)
|
|
14
|
+
@identifier = identifier.to_s.strip
|
|
15
|
+
parse
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def parse
|
|
19
|
+
@unit_data = from_american_name || from_british_name || from_tempo_shorthand || from_duration
|
|
20
|
+
@rhythmic_unit = @unit_data ? HeadMusic::Rudiment::RhythmicUnit.get_by_name(@unit_data["american_name"]) : nil
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def parsed_name
|
|
24
|
+
# Return the name format that was used in input
|
|
25
|
+
return nil unless @unit_data
|
|
26
|
+
|
|
27
|
+
# Check which type matched
|
|
28
|
+
if from_british_name == @unit_data && @unit_data["british_name"]
|
|
29
|
+
@unit_data["british_name"]
|
|
30
|
+
else
|
|
31
|
+
@unit_data["american_name"]
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def american_name
|
|
36
|
+
# Always return the American name if a unit was found
|
|
37
|
+
@unit_data&.fetch("american_name", nil)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def normalized_identifier
|
|
41
|
+
@normalized_identifier ||= identifier.downcase.strip.gsub(/[^a-z0-9]/, "_").gsub(/_+/, "_").gsub(/^_|_$/, "")
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def from_american_name
|
|
45
|
+
RHYTHMIC_UNITS_DATA.find do |unit|
|
|
46
|
+
normalize_name(unit["american_name"]) == normalized_identifier
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def from_british_name
|
|
51
|
+
RHYTHMIC_UNITS_DATA.find do |unit|
|
|
52
|
+
normalize_name(unit["british_name"]) == normalized_identifier
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def from_tempo_shorthand
|
|
57
|
+
# Handle shorthand with dots (e.g., "q." should match "q")
|
|
58
|
+
clean_identifier = identifier.downcase.strip.gsub(/\.*$/, "")
|
|
59
|
+
RHYTHMIC_UNITS_DATA.find do |unit|
|
|
60
|
+
unit["tempo_shorthand"] && unit["tempo_shorthand"].downcase == clean_identifier
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def from_duration
|
|
65
|
+
RHYTHMIC_UNITS_DATA.find do |unit|
|
|
66
|
+
# Match decimal duration (e.g., "0.25")
|
|
67
|
+
return unit if unit["duration"].to_s == identifier.strip
|
|
68
|
+
|
|
69
|
+
# Match fraction notation (e.g., "1/4" = 0.25)
|
|
70
|
+
if identifier.match?(%r{^\d+/\d+$})
|
|
71
|
+
numerator, denominator = identifier.split("/").map(&:to_f)
|
|
72
|
+
calculated_duration = numerator / denominator
|
|
73
|
+
return unit if (calculated_duration - unit["duration"]).abs < 0.0001
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
false
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
def normalize_name(name)
|
|
83
|
+
return nil if name.nil?
|
|
84
|
+
name.to_s.downcase.strip.gsub(/[^a-z0-9]/, "_").gsub(/_+/, "_").gsub(/^_|_$/, "")
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -2,21 +2,25 @@
|
|
|
2
2
|
module HeadMusic::Rudiment; end
|
|
3
3
|
|
|
4
4
|
# A rhythmic unit is a rudiment of duration consisting of doublings and divisions of a whole note.
|
|
5
|
-
class HeadMusic::Rudiment::RhythmicUnit
|
|
5
|
+
class HeadMusic::Rudiment::RhythmicUnit < HeadMusic::Rudiment::Base
|
|
6
6
|
include HeadMusic::Named
|
|
7
7
|
include Comparable
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
RHYTHMIC_UNITS_DATA = YAML.load_file(File.expand_path("rhythmic_units.yml", __dir__)).freeze
|
|
10
|
+
|
|
10
11
|
AMERICAN_MULTIPLES_NAMES = [
|
|
11
12
|
"whole", "double whole", "longa", "maxima"
|
|
12
13
|
].freeze
|
|
13
14
|
|
|
14
|
-
# Note values from whole note down to very short subdivisions
|
|
15
15
|
AMERICAN_DIVISIONS_NAMES = [
|
|
16
16
|
"whole", "half", "quarter", "eighth", "sixteenth", "thirty-second",
|
|
17
17
|
"sixty-fourth", "hundred twenty-eighth", "two hundred fifty-sixth"
|
|
18
18
|
].freeze
|
|
19
19
|
|
|
20
|
+
AMERICAN_DURATIONS = (AMERICAN_MULTIPLES_NAMES + AMERICAN_DIVISIONS_NAMES).freeze
|
|
21
|
+
|
|
22
|
+
PATTERN = /#{Regexp.union(AMERICAN_DURATIONS)}/i
|
|
23
|
+
|
|
20
24
|
# British terminology for note values longer than a whole note
|
|
21
25
|
BRITISH_MULTIPLES_NAMES = %w[semibreve breve longa maxima].freeze
|
|
22
26
|
|
|
@@ -48,7 +52,11 @@ class HeadMusic::Rudiment::RhythmicUnit
|
|
|
48
52
|
attr_reader :numerator, :denominator
|
|
49
53
|
|
|
50
54
|
def self.get(name)
|
|
51
|
-
|
|
55
|
+
# Use the parser to handle tempo shorthand and other formats
|
|
56
|
+
parsed_name = HeadMusic::Rudiment::RhythmicUnit::Parser.parse(name)
|
|
57
|
+
return nil unless parsed_name
|
|
58
|
+
|
|
59
|
+
get_by_name(parsed_name)
|
|
52
60
|
end
|
|
53
61
|
|
|
54
62
|
def self.all
|
|
@@ -83,7 +91,7 @@ class HeadMusic::Rudiment::RhythmicUnit
|
|
|
83
91
|
end
|
|
84
92
|
|
|
85
93
|
def ticks
|
|
86
|
-
HeadMusic::Rudiment::Rhythm::PPQN * 4 * relative_value
|
|
94
|
+
(HeadMusic::Rudiment::Rhythm::PPQN * 4 * relative_value).to_i
|
|
87
95
|
end
|
|
88
96
|
|
|
89
97
|
def notehead
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
- american_name: maxima
|
|
2
|
+
british_name: maxima
|
|
3
|
+
duration: 8.0
|
|
4
|
+
stem: false
|
|
5
|
+
flags: 0
|
|
6
|
+
notehead: maxima
|
|
7
|
+
- american_name: longa
|
|
8
|
+
british_name: longa
|
|
9
|
+
duration: 4.0
|
|
10
|
+
stem: false
|
|
11
|
+
flags: 0
|
|
12
|
+
notehead: longa
|
|
13
|
+
- american_name: double whole
|
|
14
|
+
british_name: breve
|
|
15
|
+
duration: 2.0
|
|
16
|
+
stem: false
|
|
17
|
+
flags: 0
|
|
18
|
+
notehead: breve
|
|
19
|
+
- american_name: whole
|
|
20
|
+
british_name: semibreve
|
|
21
|
+
duration: 1.0
|
|
22
|
+
stem: false
|
|
23
|
+
flags: 0
|
|
24
|
+
notehead: open
|
|
25
|
+
tempo_shorthand: w
|
|
26
|
+
- american_name: half
|
|
27
|
+
british_name: minim
|
|
28
|
+
duration: 0.5
|
|
29
|
+
stem: true
|
|
30
|
+
flags: 0
|
|
31
|
+
notehead: open
|
|
32
|
+
tempo_shorthand: h
|
|
33
|
+
- american_name: quarter
|
|
34
|
+
british_name: crotchet
|
|
35
|
+
duration: 0.25
|
|
36
|
+
stem: true
|
|
37
|
+
flags: 0
|
|
38
|
+
notehead: closed
|
|
39
|
+
tempo_shorthand: q
|
|
40
|
+
- american_name: eighth
|
|
41
|
+
british_name: quaver
|
|
42
|
+
duration: 0.125
|
|
43
|
+
stem: true
|
|
44
|
+
flags: 1
|
|
45
|
+
notehead: closed
|
|
46
|
+
tempo_shorthand: e
|
|
47
|
+
- american_name: sixteenth
|
|
48
|
+
british_name: semiquaver
|
|
49
|
+
duration: 0.0625
|
|
50
|
+
stem: true
|
|
51
|
+
flags: 2
|
|
52
|
+
notehead: closed
|
|
53
|
+
tempo_shorthand: s
|
|
54
|
+
- american_name: thirty-second
|
|
55
|
+
british_name: demisemiquaver
|
|
56
|
+
duration: 0.03125
|
|
57
|
+
stem: true
|
|
58
|
+
flags: 3
|
|
59
|
+
notehead: closed
|
|
60
|
+
tempo_shorthand: t
|
|
61
|
+
- american_name: sixty-fourth
|
|
62
|
+
british_name: hemidemisemiquaver
|
|
63
|
+
duration: 0.015625
|
|
64
|
+
stem: true
|
|
65
|
+
flags: 4
|
|
66
|
+
notehead: closed
|
|
67
|
+
tempo_shorthand: x
|
|
68
|
+
- american_name: hundred twenty-eighth
|
|
69
|
+
british_name: semihemidemisemiquaver
|
|
70
|
+
duration: 0.0078125
|
|
71
|
+
stem: true
|
|
72
|
+
flags: 5
|
|
73
|
+
notehead: closed
|
|
74
|
+
tempo_shorthand: o
|
|
75
|
+
- american_name: two hundred fifty-sixth
|
|
76
|
+
british_name: demisemihemidemisemiquaver
|
|
77
|
+
duration: 0.00390625
|
|
78
|
+
stem: true
|
|
79
|
+
flags: 6
|
|
80
|
+
notehead: closed
|