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.
Files changed (85) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +53 -0
  3. data/CLAUDE.md +32 -15
  4. data/Gemfile.lock +1 -1
  5. data/MUSIC_THEORY.md +120 -0
  6. data/lib/head_music/analysis/diatonic_interval.rb +29 -27
  7. data/lib/head_music/analysis/interval_consonance.rb +51 -0
  8. data/lib/head_music/content/note.rb +1 -1
  9. data/lib/head_music/content/placement.rb +1 -1
  10. data/lib/head_music/content/position.rb +1 -1
  11. data/lib/head_music/content/staff.rb +1 -1
  12. data/lib/head_music/instruments/instrument.rb +103 -113
  13. data/lib/head_music/instruments/instrument_family.rb +13 -2
  14. data/lib/head_music/instruments/instrument_type.rb +188 -0
  15. data/lib/head_music/instruments/score_order.rb +139 -0
  16. data/lib/head_music/instruments/score_orders.yml +130 -0
  17. data/lib/head_music/instruments/variant.rb +6 -0
  18. data/lib/head_music/locales/de.yml +6 -0
  19. data/lib/head_music/locales/en.yml +6 -0
  20. data/lib/head_music/locales/es.yml +6 -0
  21. data/lib/head_music/locales/fr.yml +6 -0
  22. data/lib/head_music/locales/it.yml +6 -0
  23. data/lib/head_music/locales/ru.yml +6 -0
  24. data/lib/head_music/rudiment/alteration.rb +23 -8
  25. data/lib/head_music/rudiment/base.rb +9 -0
  26. data/lib/head_music/rudiment/chromatic_interval.rb +3 -6
  27. data/lib/head_music/rudiment/clef.rb +1 -1
  28. data/lib/head_music/rudiment/consonance.rb +37 -4
  29. data/lib/head_music/rudiment/diatonic_context.rb +25 -0
  30. data/lib/head_music/rudiment/key.rb +77 -0
  31. data/lib/head_music/rudiment/key_signature/enharmonic_equivalence.rb +1 -1
  32. data/lib/head_music/rudiment/key_signature.rb +46 -7
  33. data/lib/head_music/rudiment/letter_name.rb +3 -3
  34. data/lib/head_music/rudiment/meter.rb +19 -9
  35. data/lib/head_music/rudiment/mode.rb +92 -0
  36. data/lib/head_music/rudiment/musical_symbol.rb +1 -1
  37. data/lib/head_music/rudiment/note.rb +112 -0
  38. data/lib/head_music/rudiment/pitch/parser.rb +52 -0
  39. data/lib/head_music/rudiment/pitch.rb +5 -6
  40. data/lib/head_music/rudiment/pitch_class.rb +1 -1
  41. data/lib/head_music/rudiment/quality.rb +1 -1
  42. data/lib/head_music/rudiment/reference_pitch.rb +1 -1
  43. data/lib/head_music/rudiment/register.rb +4 -1
  44. data/lib/head_music/rudiment/rest.rb +36 -0
  45. data/lib/head_music/rudiment/rhythmic_element.rb +53 -0
  46. data/lib/head_music/rudiment/rhythmic_unit/parser.rb +86 -0
  47. data/lib/head_music/rudiment/rhythmic_unit.rb +13 -5
  48. data/lib/head_music/rudiment/rhythmic_units.yml +80 -0
  49. data/lib/head_music/rudiment/rhythmic_value/parser.rb +77 -0
  50. data/lib/head_music/{content → rudiment}/rhythmic_value.rb +23 -5
  51. data/lib/head_music/rudiment/scale.rb +4 -5
  52. data/lib/head_music/rudiment/scale_degree.rb +1 -1
  53. data/lib/head_music/rudiment/scale_type.rb +9 -3
  54. data/lib/head_music/rudiment/solmization.rb +1 -1
  55. data/lib/head_music/rudiment/spelling.rb +5 -4
  56. data/lib/head_music/rudiment/tempo.rb +85 -0
  57. data/lib/head_music/rudiment/tonal_context.rb +35 -0
  58. data/lib/head_music/rudiment/tuning.rb +1 -1
  59. data/lib/head_music/rudiment/unpitched_note.rb +62 -0
  60. data/lib/head_music/style/medieval_tradition.rb +26 -0
  61. data/lib/head_music/style/modern_tradition.rb +34 -0
  62. data/lib/head_music/style/renaissance_tradition.rb +26 -0
  63. data/lib/head_music/style/tradition.rb +21 -0
  64. data/lib/head_music/utilities/hash_key.rb +34 -2
  65. data/lib/head_music/version.rb +1 -1
  66. data/lib/head_music.rb +31 -10
  67. data/user_stories/active/handle-time.md +7 -0
  68. data/user_stories/active/handle-time.rb +177 -0
  69. data/user_stories/done/epic--score-order/PLAN.md +244 -0
  70. data/user_stories/done/instrument-variant.md +65 -0
  71. data/user_stories/done/superclass-for-note.md +30 -0
  72. data/user_stories/todo/agentic-daw.md +3 -0
  73. data/user_stories/{backlog → todo}/dyad-analysis.md +2 -10
  74. data/user_stories/todo/material-and-scores.md +10 -0
  75. data/user_stories/todo/organizing-content.md +72 -0
  76. data/user_stories/todo/percussion_set.md +1 -0
  77. data/user_stories/{backlog → todo}/pitch-class-set-analysis.md +40 -0
  78. data/user_stories/{backlog → todo}/pitch-set-classification.md +10 -0
  79. data/user_stories/{backlog → todo}/sonority-identification.md +20 -0
  80. metadata +43 -12
  81. data/TODO.md +0 -109
  82. /data/user_stories/{backlog → done/epic--score-order}/band-score-order.md +0 -0
  83. /data/user_stories/{backlog → done/epic--score-order}/chamber-ensemble-score-order.md +0 -0
  84. /data/user_stories/{backlog → done/epic--score-order}/orchestral-score-order.md +0 -0
  85. /data/user_stories/{backlog → todo}/consonance-dissonance-classification.md +0 -0
@@ -0,0 +1,77 @@
1
+ class HeadMusic::Rudiment::RhythmicValue::Parser
2
+ attr_reader :identifier, :rhythmic_value
3
+
4
+ RhythmicUnit = HeadMusic::Rudiment::RhythmicUnit
5
+ RhythmicValue = HeadMusic::Rudiment::RhythmicValue
6
+
7
+ PATTERN = /((double|triple)\W?)?(dotted)?.?(#{HeadMusic::Rudiment::RhythmicUnit::PATTERN})/
8
+
9
+ # For stuff like the "q." in "q. = 108"
10
+ SHORTHAND_PATTERN = /\A(#{HeadMusic::Rudiment::RhythmicUnit::Parser::TEMPO_SHORTHAND_PATTERN})(\.*)?\z/i
11
+
12
+ # Parse a rhythmic value identifier and return a RhythmicValue object
13
+ # Returns nil if the identifier cannot be parsed
14
+ def self.parse(identifier)
15
+ return nil if identifier.nil?
16
+ new(identifier).rhythmic_value
17
+ end
18
+
19
+ def initialize(identifier)
20
+ @identifier = identifier.to_s.strip
21
+ parse_components
22
+ end
23
+
24
+ private
25
+
26
+ def parse_components
27
+ # First check for shorthand patterns like "q." to avoid infinite recursion
28
+ match = identifier.match(SHORTHAND_PATTERN)
29
+ if match && match[1]
30
+ unit_name = RhythmicUnit::Parser.parse(match[1].to_s.strip)
31
+ dots = match[2] ? match[2].strip.length : 0
32
+ @rhythmic_value = RhythmicValue.new(unit_name, dots: dots) if unit_name
33
+ return
34
+ end
35
+
36
+ # Try RhythmicUnit::Parser directly first (handles fractions, decimals, British names, etc.)
37
+ parser = RhythmicUnit::Parser.new(identifier)
38
+ if parser.american_name
39
+ @rhythmic_value = RhythmicValue.new(parser.american_name, dots: 0)
40
+ return
41
+ end
42
+
43
+ # Then try to parse with dots extracted for formats like "1/4."
44
+ # Count and strip dots (e.g., "1/4." -> "1/4" with 1 dot)
45
+ # But skip this if identifier looks like a decimal number
46
+ unless identifier.match?(/^\d+\.\d+$/)
47
+ dots = identifier.scan(".").count
48
+ base_identifier = identifier.gsub(".", "").strip
49
+
50
+ # Try RhythmicUnit::Parser on the base identifier
51
+ parser = RhythmicUnit::Parser.new(base_identifier)
52
+ if parser.american_name
53
+ @rhythmic_value = RhythmicValue.new(parser.american_name, dots: dots)
54
+ return
55
+ end
56
+ end
57
+
58
+ # Finally check the word pattern for things like "dotted quarter"
59
+ match = identifier.match(PATTERN)
60
+ if match
61
+ matched_string = match[0].to_s.strip
62
+ # Extract unit and dots from the matched string
63
+ unit_part = matched_string.gsub(/^\W*(double|triple)?\W*(dotted)?\W*/, "")
64
+ unit = RhythmicUnit.get(unit_part)
65
+ if unit
66
+ dots = if matched_string.include?("triple")
67
+ 3
68
+ elsif matched_string.include?("double")
69
+ 2
70
+ else
71
+ matched_string.include?("dotted") ? 1 : 0
72
+ end
73
+ @rhythmic_value = RhythmicValue.new(unit, dots: dots)
74
+ end
75
+ end
76
+ end
77
+ end
@@ -1,8 +1,12 @@
1
1
  # A module for musical content
2
- module HeadMusic::Content; end
2
+ module HeadMusic::Rudiment; end
3
3
 
4
4
  # A rhythmic value is a duration composed of a rhythmic unit, any number of dots, and a tied value.
5
- class HeadMusic::Content::RhythmicValue
5
+ class HeadMusic::Rudiment::RhythmicValue
6
+ include Comparable
7
+
8
+ RhythmicUnit = HeadMusic::Rudiment::RhythmicUnit
9
+
6
10
  attr_reader :unit, :dots, :tied_value
7
11
 
8
12
  delegate :name, to: :unit, prefix: true
@@ -10,13 +14,23 @@ class HeadMusic::Content::RhythmicValue
10
14
 
11
15
  def self.get(identifier)
12
16
  case identifier
13
- when HeadMusic::Content::RhythmicValue
17
+ when HeadMusic::Rudiment::RhythmicValue
14
18
  identifier
15
19
  when HeadMusic::Rudiment::RhythmicUnit
16
20
  new(identifier)
17
21
  when Symbol, String
18
- identifier = identifier.to_s.downcase.strip.gsub(/\W+/, "_")
19
- from_words(identifier)
22
+ original_identifier = identifier.to_s.strip
23
+ # First try the new parser which handles all formats
24
+ parsed = Parser.parse(original_identifier)
25
+ return parsed if parsed
26
+
27
+ # Then try the word-based approach as fallback
28
+ identifier = original_identifier.downcase.gsub(/\W+/, "_")
29
+ begin
30
+ from_words(identifier)
31
+ rescue
32
+ nil
33
+ end
20
34
  end
21
35
  end
22
36
 
@@ -102,4 +116,8 @@ class HeadMusic::Content::RhythmicValue
102
116
  def to_s
103
117
  name.tr("_", "-")
104
118
  end
119
+
120
+ def <=>(other)
121
+ total_value <=> other.total_value
122
+ end
105
123
  end
@@ -2,13 +2,12 @@
2
2
  module HeadMusic::Rudiment; end
3
3
 
4
4
  # A scale contains ordered pitches starting at a tonal center.
5
- class HeadMusic::Rudiment::Scale
5
+ class HeadMusic::Rudiment::Scale < HeadMusic::Rudiment::Base
6
6
  SCALE_REGEX = /^[A-G][#b]?\s+\w+$/
7
7
 
8
8
  def self.get(root_pitch, scale_type = nil)
9
- root_pitch, scale_type = root_pitch.split(/\s+/) if root_pitch.is_a?(String) && scale_type =~ SCALE_REGEX
10
9
  root_pitch = HeadMusic::Rudiment::Pitch.get(root_pitch)
11
- scale_type = HeadMusic::Rudiment::ScaleType.get(scale_type || :major)
10
+ scale_type = HeadMusic::Rudiment::ScaleType.get(scale_type || HeadMusic::Rudiment::ScaleType::DEFAULT)
12
11
  @scales ||= {}
13
12
  hash_key = HeadMusic::Utilities::HashKey.for(
14
13
  [root_pitch, scale_type].join(" ").gsub(/#|♯/, "sharp").gsub(/(\w)[b♭]/, '\\1flat')
@@ -36,7 +35,7 @@ class HeadMusic::Rudiment::Scale
36
35
  end
37
36
 
38
37
  def spellings(direction: :ascending, octaves: 1)
39
- pitches(direction: direction, octaves: octaves).map(&:spelling).map(&:to_s)
38
+ pitches(direction: direction, octaves: octaves).map(&:spelling)
40
39
  end
41
40
 
42
41
  def pitch_names(direction: :ascending, octaves: 1)
@@ -83,7 +82,7 @@ class HeadMusic::Rudiment::Scale
83
82
  end
84
83
 
85
84
  def parent_scale_pitches
86
- HeadMusic::Rudiment::Scale.get(root_pitch, scale_type.parent_name).pitches if scale_type.parent
85
+ HeadMusic::Rudiment::Scale.get(root_pitch, scale_type.parent_name).pitches
87
86
  end
88
87
 
89
88
  def parent_scale_pitch_for(semitones_from_root)
@@ -3,7 +3,7 @@ module HeadMusic::Rudiment; end
3
3
 
4
4
  # A scale degree is a number indicating the ordinality of the spelling in the key.
5
5
  # TODO: Rewrite to accept a tonal_center and a scale type.
6
- class HeadMusic::Rudiment::ScaleDegree
6
+ class HeadMusic::Rudiment::ScaleDegree < HeadMusic::Rudiment::Base
7
7
  include Comparable
8
8
 
9
9
  NAME_FOR_DIATONIC_DEGREE = [nil, "tonic", "supertonic", "mediant", "subdominant", "dominant", "submediant"].freeze
@@ -2,9 +2,13 @@
2
2
  module HeadMusic::Rudiment; end
3
3
 
4
4
  # A ScaleType represents a particular scale pattern, such as major, lydian, or minor pentatonic.
5
- class HeadMusic::Rudiment::ScaleType
6
- H = 1 # whole step
7
- W = 2 # half step
5
+ class HeadMusic::Rudiment::ScaleType < HeadMusic::Rudiment::Base
6
+ # TODO: load scale types from yaml configuration file
7
+ # Include a system of aliasing, e.g. :natural_minor, :aeolian
8
+ # Include ascending and descending intervals for scales that differ
9
+
10
+ H = 1 # half step
11
+ W = 2 # whole step
8
12
 
9
13
  # Modal
10
14
  I = [W, W, H, W, W, W, H].freeze
@@ -33,6 +37,8 @@ class HeadMusic::Rudiment::ScaleType
33
37
 
34
38
  MINOR_PENTATONIC = [3, 2, 2, 3, 2].freeze
35
39
 
40
+ DEFAULT = :major
41
+
36
42
  def self._modes
37
43
  {}.tap do |modes|
38
44
  MODE_NAMES.each do |roman_numeral, aliases|
@@ -2,7 +2,7 @@
2
2
  module HeadMusic::Rudiment; end
3
3
 
4
4
  # A solmization is the rendering of scale degrees as syllables.
5
- class HeadMusic::Rudiment::Solmization
5
+ class HeadMusic::Rudiment::Solmization < HeadMusic::Rudiment::Base
6
6
  include HeadMusic::Named
7
7
 
8
8
  DEFAULT_SOLMIZATION = "solfège"
@@ -4,8 +4,11 @@ module HeadMusic::Rudiment; end
4
4
  # Represents the spelling of a pitch, such as C# or Db.
5
5
  # Composite of a LetterName and an optional Alteration.
6
6
  # Does not include the octave. See Pitch for that.
7
- class HeadMusic::Rudiment::Spelling
8
- MATCHER = /^\s*([A-G])(#{HeadMusic::Rudiment::Alteration.matcher}?)(-?\d+)?\s*$/i
7
+ class HeadMusic::Rudiment::Spelling < HeadMusic::Rudiment::Base
8
+ LetterName = HeadMusic::Rudiment::LetterName
9
+ Alteration = HeadMusic::Rudiment::Alteration
10
+
11
+ MATCHER = /^\s*(#{LetterName::PATTERN})(#{Alteration::PATTERN})?(-?\d+)?\s*$/i
9
12
 
10
13
  attr_reader :pitch_class, :letter_name, :alteration
11
14
 
@@ -86,8 +89,6 @@ class HeadMusic::Rudiment::Spelling
86
89
  !alteration || alteration.natural?
87
90
  end
88
91
 
89
- private_class_method :new
90
-
91
92
  private
92
93
 
93
94
  def enharmonic_equivalence
@@ -0,0 +1,85 @@
1
+ module HeadMusic::Rudiment; end
2
+
3
+ # Represents a musical tempo with a beat value and beats per minute
4
+ class HeadMusic::Rudiment::Tempo
5
+ SECONDS_PER_MINUTE = 60
6
+ NANOSECONDS_PER_SECOND = 1_000_000_000
7
+ NANOSECONDS_PER_MINUTE = (NANOSECONDS_PER_SECOND * SECONDS_PER_MINUTE).freeze
8
+
9
+ attr_reader :beat_value, :beats_per_minute
10
+
11
+ delegate :ticks, to: :beat_value, prefix: true
12
+ alias_method :ticks_per_beat, :beat_value_ticks
13
+
14
+ NAMED_TEMPO_DEFAULTS = {
15
+ larghissimo: ["quarter", 24], # 24–40 bpm
16
+ adagissimo: ["quarter", 32], # 24–40 bpm
17
+ grave: ["quarter", 32], # 24–40 bpm
18
+ largo: ["quarter", 54], # 40–66 bpm
19
+ larghetto: ["quarter", 54], # 44–66 bpm
20
+ adagio: ["quarter", 60], # 44–66 bpm
21
+ adagietto: ["quarter", 68], # 46–80 bpm
22
+ lento: ["quarter", 72], # 52–108 bpm
23
+ marcia_moderato: ["quarter", 72], # 66–80 bpm
24
+ andante: ["quarter", 78], # 56–108 bpm
25
+ andante_moderato: ["quarter", 88], # 80–108 bpm
26
+ andantino: ["quarter", 92], # 80–108 bpm
27
+ moderato: ["quarter", 108], # 108–120 bpm
28
+ allegretto: ["quarter", 112], # 112–120 bpm
29
+ allegro_moderato: ["quarter", 116], # 116–120 bpm
30
+ allegro: ["quarter", 120], # 120–156 bpm
31
+ molto_allegro: ["quarter", 132], # 124–156 bpm
32
+ allegro_vivace: ["quarter", 132], # 124–156 bpm
33
+ vivace: ["quarter", 156], # 156–176 bpm
34
+ vivacissimo: ["quarter", 172], # 172–176 bpm
35
+ allegrissimo: ["quarter", 172], # 172–176 bpm
36
+ presto: ["quarter", 180], # 168–200 bpm
37
+ prestissimo: ["quarter", 200] # 200 bpm and over
38
+ }
39
+
40
+ def self.get(identifier)
41
+ @tempos ||= {}
42
+ key = HeadMusic::Utilities::HashKey.for(identifier)
43
+ if NAMED_TEMPO_DEFAULTS.key?(identifier.to_s.to_sym)
44
+ beat_value, beats_per_minute = NAMED_TEMPO_DEFAULTS[identifier.to_s.to_sym]
45
+ @tempos[key] ||= new(beat_value, beats_per_minute)
46
+ elsif identifier.to_s.match?(/=|at/)
47
+ parts = identifier.to_s.split(/\s*(=|at)\s*/)
48
+ unit = parts[0]
49
+ bpm = parts[2] || parts[1] # Handle both "q = 120" and "q at 120bpm"
50
+ bpm_value = bpm.to_s.gsub(/[^0-9]/, "").to_i
51
+ @tempos[key] ||= new(standardized_unit(unit), bpm_value)
52
+ else
53
+ @tempos[key] ||= new("quarter", 120)
54
+ end
55
+ @tempos[key]
56
+ end
57
+
58
+ def initialize(beat_value, beats_per_minute)
59
+ @beat_value = HeadMusic::Rudiment::RhythmicValue.get(beat_value)
60
+ @beats_per_minute = beats_per_minute.to_f
61
+ end
62
+
63
+ def beat_duration_in_seconds
64
+ @beat_duration_in_seconds ||=
65
+ SECONDS_PER_MINUTE / beats_per_minute
66
+ end
67
+
68
+ def beat_duration_in_nanoseconds
69
+ @beat_duration_in_nanoseconds ||=
70
+ NANOSECONDS_PER_MINUTE / beats_per_minute
71
+ end
72
+
73
+ def tick_duration_in_nanoseconds
74
+ @tick_duration_in_nanoseconds ||=
75
+ beat_duration_in_nanoseconds / ticks_per_beat
76
+ end
77
+
78
+ def self.standardized_unit(unit)
79
+ return "quarter" if unit.nil?
80
+
81
+ # Use RhythmicValue parser to handle all formats (shorthand, fractions, British names, dots, etc.)
82
+ rhythmic_value = HeadMusic::Rudiment::RhythmicValue.get(unit)
83
+ rhythmic_value&.unit ? rhythmic_value.to_s : "quarter"
84
+ end
85
+ end
@@ -0,0 +1,35 @@
1
+ # A module for music rudiments
2
+ module HeadMusic::Rudiment; end
3
+
4
+ # Abstract base class representing a tonal context (system of pitches with a tonal center)
5
+ class HeadMusic::Rudiment::TonalContext < HeadMusic::Rudiment::Base
6
+ attr_reader :tonic_spelling
7
+
8
+ def initialize(tonic_spelling)
9
+ @tonic_spelling = HeadMusic::Rudiment::Spelling.get(tonic_spelling)
10
+ end
11
+
12
+ def tonic_pitch(octave = 4)
13
+ HeadMusic::Rudiment::Pitch.get("#{tonic_spelling}#{octave}")
14
+ end
15
+
16
+ def scale
17
+ raise NotImplementedError, "Subclasses must implement #scale"
18
+ end
19
+
20
+ def pitches(octave = nil)
21
+ scale.pitches(direction: :ascending, octaves: 1)
22
+ end
23
+
24
+ def pitch_classes
25
+ scale.pitch_classes
26
+ end
27
+
28
+ def spellings
29
+ scale.spellings
30
+ end
31
+
32
+ def key_signature
33
+ raise NotImplementedError, "Subclasses must implement #key_signature"
34
+ end
35
+ end
@@ -3,7 +3,7 @@ module HeadMusic::Rudiment; end
3
3
 
4
4
  # A tuning has a reference pitch and frequency and provides frequencies for all pitches
5
5
  # The base class assumes equal temperament tuning. By default, A4 = 440.0 Hz
6
- class HeadMusic::Rudiment::Tuning
6
+ class HeadMusic::Rudiment::Tuning < HeadMusic::Rudiment::Base
7
7
  attr_accessor :reference_pitch
8
8
 
9
9
  delegate :pitch, :frequency, to: :reference_pitch, prefix: true
@@ -0,0 +1,62 @@
1
+ # A module for music rudiments
2
+ module HeadMusic::Rudiment; end
3
+
4
+ # An UnpitchedNote represents a percussion note with rhythm but no specific pitch.
5
+ # It inherits from RhythmicElement and can optionally have an instrument name.
6
+ class HeadMusic::Rudiment::UnpitchedNote < HeadMusic::Rudiment::RhythmicElement
7
+ include HeadMusic::Named
8
+
9
+ attr_reader :instrument_name
10
+
11
+ # Make new public for this concrete class
12
+ public_class_method :new
13
+
14
+ def self.get(rhythmic_value, instrument: nil)
15
+ return rhythmic_value if rhythmic_value.is_a?(HeadMusic::Rudiment::UnpitchedNote)
16
+
17
+ rhythmic_value = HeadMusic::Rudiment::RhythmicValue.get(rhythmic_value)
18
+ return nil unless rhythmic_value
19
+
20
+ fetch_or_create(rhythmic_value, instrument)
21
+ end
22
+
23
+ def self.fetch_or_create(rhythmic_value, instrument_name)
24
+ @unpitched_notes ||= {}
25
+ hash_key = [rhythmic_value.to_s, instrument_name].compact.join("_")
26
+ @unpitched_notes[hash_key] ||= new(rhythmic_value, instrument_name)
27
+ end
28
+
29
+ def initialize(rhythmic_value, instrument_name = nil)
30
+ super(rhythmic_value)
31
+ @instrument_name = instrument_name
32
+ end
33
+
34
+ def name
35
+ if instrument_name
36
+ "#{rhythmic_value} #{instrument_name}"
37
+ else
38
+ "#{rhythmic_value} unpitched note"
39
+ end
40
+ end
41
+
42
+ # Override with_rhythmic_value to preserve instrument
43
+ def with_rhythmic_value(new_rhythmic_value)
44
+ self.class.get(new_rhythmic_value, instrument: instrument_name)
45
+ end
46
+
47
+ # Create a new unpitched note with a different instrument
48
+ def with_instrument(new_instrument_name)
49
+ self.class.get(rhythmic_value, instrument: new_instrument_name)
50
+ end
51
+
52
+ def ==(other)
53
+ return false unless other.is_a?(self.class)
54
+ rhythmic_value == other.rhythmic_value && instrument_name == other.instrument_name
55
+ end
56
+
57
+ def sounded?
58
+ true
59
+ end
60
+
61
+ private_class_method :fetch_or_create
62
+ end
@@ -0,0 +1,26 @@
1
+ # Medieval tradition for interval consonance classification
2
+ class HeadMusic::Style::MedievalTradition < HeadMusic::Style::Tradition
3
+ def consonance_classification(interval)
4
+ interval_mod = interval.simple_semitones
5
+
6
+ # Check for augmented or diminished intervals
7
+ if interval.augmented? || interval.diminished?
8
+ return HeadMusic::Rudiment::Consonance::DISSONANCE
9
+ end
10
+
11
+ case interval_mod
12
+ when 0, 12 # Unison, Octave
13
+ HeadMusic::Rudiment::Consonance::PERFECT_CONSONANCE
14
+ when 7 # Perfect Fifth
15
+ HeadMusic::Rudiment::Consonance::PERFECT_CONSONANCE
16
+ when 5 # Perfect Fourth - consonant in medieval music
17
+ HeadMusic::Rudiment::Consonance::PERFECT_CONSONANCE
18
+ when 3, 4 # Minor Third, Major Third
19
+ HeadMusic::Rudiment::Consonance::IMPERFECT_CONSONANCE
20
+ when 8, 9 # Minor Sixth, Major Sixth
21
+ HeadMusic::Rudiment::Consonance::IMPERFECT_CONSONANCE
22
+ else
23
+ HeadMusic::Rudiment::Consonance::DISSONANCE
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,34 @@
1
+ # Modern/standard practice tradition for interval consonance classification
2
+ class HeadMusic::Style::ModernTradition < HeadMusic::Style::Tradition
3
+ def consonance_classification(interval)
4
+ interval_mod = interval.simple_semitones
5
+
6
+ # Check for augmented or diminished intervals (except diminished fifth/augmented fourth)
7
+ if (interval.augmented? || interval.diminished?) && interval_mod != 6
8
+ return HeadMusic::Rudiment::Consonance::DISSONANCE
9
+ end
10
+
11
+ case interval_mod
12
+ when 0, 12 # Unison, Octave
13
+ HeadMusic::Rudiment::Consonance::PERFECT_CONSONANCE
14
+ when 7 # Perfect Fifth
15
+ HeadMusic::Rudiment::Consonance::PERFECT_CONSONANCE
16
+ when 3, 4 # Minor Third, Major Third
17
+ HeadMusic::Rudiment::Consonance::IMPERFECT_CONSONANCE
18
+ when 8, 9 # Minor Sixth, Major Sixth
19
+ HeadMusic::Rudiment::Consonance::IMPERFECT_CONSONANCE
20
+ when 5 # Perfect Fourth
21
+ # In standard practice, perfect fourth is considered consonant
22
+ # but contextual would be more accurate
23
+ HeadMusic::Rudiment::Consonance::PERFECT_CONSONANCE
24
+ when 2, 10 # Major Second, Minor Seventh
25
+ HeadMusic::Rudiment::Consonance::MILD_DISSONANCE
26
+ when 1, 11 # Minor Second, Major Seventh
27
+ HeadMusic::Rudiment::Consonance::HARSH_DISSONANCE
28
+ when 6 # Tritone (Aug 4th/Dim 5th)
29
+ HeadMusic::Rudiment::Consonance::DISSONANCE
30
+ else
31
+ HeadMusic::Rudiment::Consonance::DISSONANCE
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,26 @@
1
+ # Renaissance counterpoint tradition for interval consonance classification
2
+ class HeadMusic::Style::RenaissanceTradition < HeadMusic::Style::Tradition
3
+ def consonance_classification(interval)
4
+ interval_mod = interval.simple_semitones
5
+
6
+ # Check for augmented or diminished intervals
7
+ if interval.augmented? || interval.diminished?
8
+ return HeadMusic::Rudiment::Consonance::DISSONANCE
9
+ end
10
+
11
+ case interval_mod
12
+ when 0, 12 # Unison, Octave
13
+ HeadMusic::Rudiment::Consonance::PERFECT_CONSONANCE
14
+ when 7 # Perfect Fifth
15
+ HeadMusic::Rudiment::Consonance::PERFECT_CONSONANCE
16
+ when 3, 4 # Minor Third, Major Third
17
+ HeadMusic::Rudiment::Consonance::IMPERFECT_CONSONANCE
18
+ when 8, 9 # Minor Sixth, Major Sixth
19
+ HeadMusic::Rudiment::Consonance::IMPERFECT_CONSONANCE
20
+ when 5 # Perfect Fourth - dissonant in Renaissance counterpoint
21
+ HeadMusic::Rudiment::Consonance::DISSONANCE
22
+ else
23
+ HeadMusic::Rudiment::Consonance::DISSONANCE
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,21 @@
1
+ # A style tradition represents a historical or theoretical approach to music
2
+ module HeadMusic::Style
3
+ class Tradition
4
+ def self.get(name)
5
+ case name&.to_sym
6
+ when :modern, :standard_practice then ModernTradition.new
7
+ when :renaissance_counterpoint, :two_part_harmony then RenaissanceTradition.new
8
+ when :medieval then MedievalTradition.new
9
+ else ModernTradition.new
10
+ end
11
+ end
12
+
13
+ def consonance_classification(interval)
14
+ raise NotImplementedError, "#{self.class} must implement consonance_classification"
15
+ end
16
+
17
+ def name
18
+ self.class.name.split("::").last.sub(/Tradition$/, "").downcase.gsub(" ", "_").to_sym
19
+ end
20
+ end
21
+ 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
- module HeadMusic::Utilities::HashKey
5
+ class HeadMusic::Utilities::HashKey
6
6
  def self.for(identifier)
7
- I18n.transliterate(identifier.to_s).downcase.gsub(/\W+/, "_").to_sym
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
+ transliterated_string.downcase.gsub(/\W+/, "_")
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
@@ -1,3 +1,3 @@
1
1
  module HeadMusic
2
- VERSION = "8.3.0"
2
+ VERSION = "9.0.1"
3
3
  end