head_music 17.3.0 → 17.4.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.
Files changed (110) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +8 -0
  3. data/Gemfile.lock +1 -1
  4. data/lib/head_music/analysis/chord_analysis.rb +123 -0
  5. data/lib/head_music/analysis/circle.rb +11 -3
  6. data/lib/head_music/analysis/diatonic_interval.rb +14 -11
  7. data/lib/head_music/analysis/dyad/chord_implication.rb +101 -0
  8. data/lib/head_music/analysis/dyad.rb +24 -104
  9. data/lib/head_music/analysis/interval_cycle.rb +9 -3
  10. data/lib/head_music/analysis/pitch_collection.rb +33 -107
  11. data/lib/head_music/analysis/sonority.rb +11 -21
  12. data/lib/head_music/content/composition/hash_deserializer.rb +110 -0
  13. data/lib/head_music/content/composition/schema_values.rb +127 -0
  14. data/lib/head_music/content/composition.rb +0 -216
  15. data/lib/head_music/content/placement.rb +1 -41
  16. data/lib/head_music/content/sound_resolver.rb +54 -0
  17. data/lib/head_music/content/voice/melodic_line.rb +32 -0
  18. data/lib/head_music/content/voice/melodic_note_pair.rb +33 -0
  19. data/lib/head_music/content/voice.rb +7 -58
  20. data/lib/head_music/instruments/alternate_tuning.rb +4 -6
  21. data/lib/head_music/instruments/catalog_lookup.rb +18 -0
  22. data/lib/head_music/instruments/instrument.rb +17 -117
  23. data/lib/head_music/instruments/instrument_catalog.rb +48 -0
  24. data/lib/head_music/instruments/instrument_configuration.rb +4 -6
  25. data/lib/head_music/instruments/instrument_family.rb +3 -8
  26. data/lib/head_music/instruments/instrument_name.rb +49 -0
  27. data/lib/head_music/instruments/score_order.rb +52 -42
  28. data/lib/head_music/instruments/staff_profile.rb +71 -0
  29. data/lib/head_music/instruments/stringing.rb +4 -6
  30. data/lib/head_music/instruments/stringing_course.rb +4 -6
  31. data/lib/head_music/instruments/variant.rb +4 -6
  32. data/lib/head_music/named/locale.rb +32 -0
  33. data/lib/head_music/named/localized_name.rb +15 -0
  34. data/lib/head_music/named.rb +1 -45
  35. data/lib/head_music/notation/abc/body_lexer.rb +48 -23
  36. data/lib/head_music/notation/abc/duration_resolver.rb +11 -1
  37. data/lib/head_music/notation/abc/duration_writer.rb +1 -8
  38. data/lib/head_music/notation/abc/header.rb +27 -16
  39. data/lib/head_music/notation/abc/parser.rb +44 -183
  40. data/lib/head_music/notation/abc/preflight.rb +48 -0
  41. data/lib/head_music/notation/abc/voice_state.rb +161 -0
  42. data/lib/head_music/notation/abc/writer.rb +34 -17
  43. data/lib/head_music/notation/dotted_duration.rb +19 -0
  44. data/lib/head_music/notation/instrument_notation.rb +5 -7
  45. data/lib/head_music/notation/music_xml/duration_writer.rb +3 -8
  46. data/lib/head_music/notation/music_xml/preflight.rb +90 -0
  47. data/lib/head_music/notation/music_xml/render_plan.rb +161 -0
  48. data/lib/head_music/notation/music_xml/writer.rb +20 -209
  49. data/lib/head_music/notation/placement_validation.rb +19 -0
  50. data/lib/head_music/rudiment/enharmonic_equivalence.rb +33 -0
  51. data/lib/head_music/rudiment/key.rb +8 -36
  52. data/lib/head_music/rudiment/key_signature/enharmonic_equivalence.rb +21 -15
  53. data/lib/head_music/rudiment/key_signature.rb +18 -19
  54. data/lib/head_music/rudiment/meter.rb +17 -13
  55. data/lib/head_music/rudiment/mode.rb +33 -65
  56. data/lib/head_music/rudiment/note.rb +18 -21
  57. data/lib/head_music/rudiment/pitch/enharmonic_equivalence.rb +4 -15
  58. data/lib/head_music/rudiment/pitch/natural_step.rb +36 -0
  59. data/lib/head_music/rudiment/pitch/parser.rb +16 -5
  60. data/lib/head_music/rudiment/pitch.rb +11 -33
  61. data/lib/head_music/rudiment/qualified_diatonic_context.rb +61 -0
  62. data/lib/head_music/rudiment/rhythmic_unit/parser.rb +16 -12
  63. data/lib/head_music/rudiment/rhythmic_unit.rb +21 -37
  64. data/lib/head_music/rudiment/rhythmic_value/parser.rb +53 -46
  65. data/lib/head_music/rudiment/spelling/enharmonic_equivalence.rb +13 -0
  66. data/lib/head_music/rudiment/spelling.rb +0 -25
  67. data/lib/head_music/rudiment/tuning/just_intonation.rb +2 -4
  68. data/lib/head_music/rudiment/tuning/meantone.rb +2 -4
  69. data/lib/head_music/rudiment/tuning/pythagorean.rb +2 -4
  70. data/lib/head_music/rudiment/tuning/tonal_centered.rb +10 -0
  71. data/lib/head_music/rudiment/unpitched_note.rb +3 -5
  72. data/lib/head_music/style/annotation/configured.rb +35 -0
  73. data/lib/head_music/style/annotation.rb +32 -44
  74. data/lib/head_music/style/guidelines/allowed_rhythmic_values_for_fifth_species.rb +0 -10
  75. data/lib/head_music/style/guidelines/contoured.rb +65 -53
  76. data/lib/head_music/style/guidelines/dissonance_figure_detection.rb +55 -16
  77. data/lib/head_music/style/guidelines/florid_dissonance_treatment.rb +0 -37
  78. data/lib/head_music/style/guidelines/large_leaps.rb +10 -2
  79. data/lib/head_music/style/guidelines/minimum_melodic_intervals.rb +3 -25
  80. data/lib/head_music/style/guidelines/minimum_notes.rb +3 -25
  81. data/lib/head_music/style/guidelines/minimum_threshold.rb +38 -0
  82. data/lib/head_music/style/guidelines/no_parallel_perfect.rb +31 -0
  83. data/lib/head_music/style/guidelines/no_parallel_perfect_on_downbeats.rb +3 -17
  84. data/lib/head_music/style/guidelines/no_parallel_perfect_with_syncopation.rb +3 -17
  85. data/lib/head_music/style/guidelines/prepare_octave_leaps.rb +10 -7
  86. data/lib/head_music/style/guidelines/singable_intervals.rb +22 -8
  87. data/lib/head_music/style/guidelines/suspension_treatment.rb +30 -12
  88. data/lib/head_music/style/guidelines/third_species_dissonance_treatment.rb +0 -7
  89. data/lib/head_music/style/guidelines/triple_meter_dissonance_treatment.rb +0 -12
  90. data/lib/head_music/style/guidelines/weak_beat_dissonance_treatment.rb +2 -42
  91. data/lib/head_music/style/guides/fifth_species_melody.rb +3 -13
  92. data/lib/head_music/style/guides/fourth_species_melody.rb +3 -13
  93. data/lib/head_music/style/guides/second_species_harmony.rb +2 -5
  94. data/lib/head_music/style/guides/second_species_melody.rb +2 -12
  95. data/lib/head_music/style/guides/species_harmony.rb +14 -0
  96. data/lib/head_music/style/guides/species_melody.rb +21 -0
  97. data/lib/head_music/style/guides/third_species_harmony.rb +2 -5
  98. data/lib/head_music/style/guides/third_species_melody.rb +3 -13
  99. data/lib/head_music/style/guides/third_species_triple_meter_harmony.rb +2 -5
  100. data/lib/head_music/style/guides/third_species_triple_meter_melody.rb +2 -12
  101. data/lib/head_music/time/conductor.rb +19 -125
  102. data/lib/head_music/time/musical_position.rb +26 -26
  103. data/lib/head_music/time/musical_time_converter.rb +104 -0
  104. data/lib/head_music/time/smpte_converter.rb +51 -0
  105. data/lib/head_music/time/smpte_timecode.rb +25 -23
  106. data/lib/head_music/time.rb +2 -0
  107. data/lib/head_music/value_equality.rb +37 -0
  108. data/lib/head_music/version.rb +1 -1
  109. data/lib/head_music.rb +25 -1
  110. metadata +31 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d5c0dadcdb3b7bba85e7de810d0d52d960e6bf2b9386b62db9ec201336e084bb
4
- data.tar.gz: 33c3b42e6b07ed88649b6fb0c7b42476c9d45edbc18ef0cc458ff8978b876c4c
3
+ metadata.gz: 4d075fb623ea50351c7ac422f44be04c265867253312b6b27faffaf76bb3b397
4
+ data.tar.gz: 5649553e987c0a98cd9388d24d6e15155f88da4e59eaf62f7a2d9f988e656b36
5
5
  SHA512:
6
- metadata.gz: eb3e9c1f84f2efbf87947a2037b9804cf9e16247be5c180509cb06a9c3c66229513b8cdcc2e10fa1d8f2f7ed952b91de99375e5939de33665aec909331f8f115
7
- data.tar.gz: f871c26bf5da1052474f5317d72788df760f994f3264dbebc6ad35e91b91124088378524b46f4fc50721f6b9442453978018f6c012dd40f55926a66dd516539c
6
+ metadata.gz: a89a0dfc44c68e823e5c1eff6c48b950b759cdd059c504adacd8bd6b3d06dc0b23bc1a13b690e68086526a33ab3bebc1617825abecfc3ab108c89425a44d6f55
7
+ data.tar.gz: 921ce718ccc4b728ed57dce347fbce73ae7c120960931b32933a5893f52992b4aa40ec115e248421a3f53d3a66defff0b4d7eac15efaf34ae51fae23e5e2a328
data/CHANGELOG.md CHANGED
@@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [17.4.0] - 2026-07-20
11
+
12
+ ### Changed
13
+
14
+ - Internal refactoring for clarity and maintainability, with no changes to public behavior or output. Complex classes were split along their natural seams by extracting focused collaborators and value objects, each with its own spec: `MusicXML::Preflight` and `MusicXML::RenderPlan` from the MusicXML `Writer`; `Instruments::InstrumentName`, `InstrumentCatalog`, and `StaffProfile` from `Instrument`; `Analysis::ChordAnalysis` from `PitchCollection`; `Content::SoundResolver` from `Placement`; `Time::SmpteConverter` and `MusicalTimeConverter` from `Conductor`; `Composition::SchemaValues` from `HashDeserializer`; `Pitch::NaturalStep` from `Pitch`; `Voice::MelodicLine` from `Voice`; `Dyad::ChordImplication` from `Dyad`; and `ABC::Preflight` plus per-voice note-assembly moved onto `ABC::VoiceState` in the ABC parser.
15
+ - Shared value-object equality was consolidated into a `ValueEquality` mixin, and duplication was reduced across the style guides, notation writers, and rudiments.
16
+ - Nested classes that had outgrown their host files were given their own files without changing their constant paths: `Named::Locale` and `Named::LocalizedName`, `Voice::MelodicNotePair`, `Spelling::EnharmonicEquivalence`, and `Style::Annotation::Configured`.
17
+
10
18
  ## [17.3.0] - 2026-07-19
11
19
 
12
20
  ### Added
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- head_music (17.3.0)
4
+ head_music (17.4.0)
5
5
  activesupport (>= 7.0, < 10)
6
6
  humanize (~> 2.0)
7
7
  i18n (~> 1.8)
@@ -0,0 +1,123 @@
1
+ # A module for musical analysis
2
+ module HeadMusic::Analysis; end
3
+
4
+ # Classifies a PitchCollection as a tertian chord: whether it is a triad,
5
+ # seventh, or extended chord; its triad quality (major/minor/diminished/
6
+ # augmented); and its inversion. "Tertian" means the pitches, once reduced to
7
+ # within an octave and rotated, stack in thirds.
8
+ class HeadMusic::Analysis::ChordAnalysis
9
+ # Scale-degree signatures (above the bass) of each tertian chord size, used
10
+ # to recognize a stack of thirds in any rotation.
11
+ TERTIAN_SONORITIES = {
12
+ implied_triad: [3],
13
+ triad: [3, 5],
14
+ seventh_chord: [3, 5, 7],
15
+ ninth_chord: [2, 3, 5, 7],
16
+ eleventh_chord: [2, 3, 4, 5, 7],
17
+ thirteenth_chord: [2, 3, 4, 5, 6, 7] # a.k.a. diatonic scale
18
+ }.freeze
19
+
20
+ # The interval-shorthand pairs (bass-to-third, third-to-fifth) that spell each
21
+ # triad quality, in every inversion.
22
+ TRIAD_PATTERNS = {
23
+ major: [%w[M3 m3], %w[m3 P4], %w[P4 M3]],
24
+ minor: [%w[m3 M3], %w[M3 P4], %w[P4 m3]],
25
+ diminished: [%w[m3 m3], %w[m3 A4], %w[A4 m3]],
26
+ augmented: [%w[M3 M3], %w[M3 d4], %w[d4 M3]]
27
+ }.freeze
28
+
29
+ attr_reader :collection
30
+
31
+ def initialize(collection)
32
+ @collection = collection
33
+ end
34
+
35
+ def triad?
36
+ collection.trichord? && tertian?
37
+ end
38
+
39
+ def consonant_triad?
40
+ major_triad? || minor_triad?
41
+ end
42
+
43
+ def major_triad?
44
+ triad_type?(:major)
45
+ end
46
+
47
+ def minor_triad?
48
+ triad_type?(:minor)
49
+ end
50
+
51
+ def diminished_triad?
52
+ triad_type?(:diminished)
53
+ end
54
+
55
+ def augmented_triad?
56
+ triad_type?(:augmented)
57
+ end
58
+
59
+ def root_position_triad?
60
+ collection.trichord? && stacked_in_thirds?(collection.reduction)
61
+ end
62
+
63
+ def first_inversion_triad?
64
+ collection.trichord? && stacked_in_thirds?(collection.reduction.uninvert)
65
+ end
66
+
67
+ def second_inversion_triad?
68
+ collection.trichord? && stacked_in_thirds?(collection.reduction.invert)
69
+ end
70
+
71
+ def seventh_chord?
72
+ collection.tetrachord? && tertian?
73
+ end
74
+
75
+ def root_position_seventh_chord?
76
+ collection.tetrachord? && stacked_in_thirds?(collection.reduction)
77
+ end
78
+
79
+ def first_inversion_seventh_chord?
80
+ collection.tetrachord? && stacked_in_thirds?(collection.reduction.uninvert)
81
+ end
82
+
83
+ def second_inversion_seventh_chord?
84
+ collection.tetrachord? && stacked_in_thirds?(collection.reduction.uninvert.uninvert)
85
+ end
86
+
87
+ def third_inversion_seventh_chord?
88
+ collection.tetrachord? && stacked_in_thirds?(collection.reduction.invert)
89
+ end
90
+
91
+ def ninth_chord?
92
+ collection.pentachord? && tertian?
93
+ end
94
+
95
+ def eleventh_chord?
96
+ collection.hexachord? && tertian?
97
+ end
98
+
99
+ def thirteenth_chord?
100
+ collection.heptachord? && tertian?
101
+ end
102
+
103
+ def tertian?
104
+ return false unless collection.diatonic_intervals.any?
105
+
106
+ inversion = collection.reduction
107
+ collection.pitches.length.times do
108
+ return true if TERTIAN_SONORITIES.value?(inversion.scale_degrees_above_bass_pitch)
109
+ inversion = inversion.invert
110
+ end
111
+ false
112
+ end
113
+
114
+ private
115
+
116
+ def triad_type?(type)
117
+ TRIAD_PATTERNS[type].include?(collection.reduction_diatonic_intervals.map(&:shorthand))
118
+ end
119
+
120
+ def stacked_in_thirds?(reduced_collection)
121
+ reduced_collection.diatonic_intervals.all?(&:third?)
122
+ end
123
+ end
@@ -46,14 +46,22 @@ class HeadMusic::Analysis::Circle < HeadMusic::Analysis::IntervalCycle
46
46
  def pitches_down
47
47
  @pitches_down ||= [starting_pitch].tap do |list|
48
48
  loop do
49
- next_pitch = list.last - interval
50
- next_pitch += octave while starting_pitch - next_pitch > 12
51
- break if next_pitch.pitch_class == list.first.pitch_class
49
+ next_pitch = folded_down(list.last)
50
+ break if next_pitch.pitch_class == starting_pitch.pitch_class
52
51
 
53
52
  list << next_pitch
54
53
  end
55
54
  end
56
55
  end
57
56
 
57
+ private
58
+
59
+ # The next pitch a cycle-interval below, folded up by octaves to stay within an octave of the start.
60
+ def folded_down(pitch)
61
+ next_pitch = pitch - interval
62
+ next_pitch += octave while starting_pitch - next_pitch > 12
63
+ next_pitch
64
+ end
65
+
58
66
  private_class_method :new
59
67
  end
@@ -59,15 +59,17 @@ class HeadMusic::Analysis::DiatonicInterval
59
59
 
60
60
  # Override Named module method to try I18n and fall back to computed name
61
61
  def name(locale_code: nil)
62
- if locale_code
63
- name_key = HeadMusic::Utilities::HashKey.for(naming.name)
64
- if I18n.backend.translations[locale_code]
65
- locale_data = I18n.backend.translations[locale_code][:head_music] || {}
66
- return locale_data[:diatonic_intervals][name_key] if locale_data.dig(:diatonic_intervals, name_key)
67
- return locale_data[:chromatic_intervals][name_key] if locale_data.dig(:chromatic_intervals, name_key)
68
- end
69
- end
70
- naming.name
62
+ computed_name = naming.name
63
+ return computed_name unless locale_code
64
+
65
+ translations = I18n.backend.translations[locale_code]
66
+ return computed_name unless translations
67
+
68
+ name_key = HeadMusic::Utilities::HashKey.for(computed_name)
69
+ locale_data = translations[:head_music] || {}
70
+ locale_data.dig(:diatonic_intervals, name_key) ||
71
+ locale_data.dig(:chromatic_intervals, name_key) ||
72
+ computed_name
71
73
  end
72
74
 
73
75
  def to_s
@@ -79,8 +81,9 @@ class HeadMusic::Analysis::DiatonicInterval
79
81
  if identifier.is_a?(String) || identifier.is_a?(Symbol)
80
82
  name = Parser.new(identifier)
81
83
  semitones = Semitones.new(name.degree_name.to_sym, name.quality_name).count
82
- higher_pitch = HeadMusic::Rudiment::Pitch.from_number_and_letter(HeadMusic::Rudiment::Pitch.middle_c + semitones, name.higher_letter)
83
- interval = new(HeadMusic::Rudiment::Pitch.middle_c, higher_pitch)
84
+ middle_c = HeadMusic::Rudiment::Pitch.middle_c
85
+ higher_pitch = HeadMusic::Rudiment::Pitch.from_number_and_letter(middle_c + semitones, name.higher_letter)
86
+ interval = new(middle_c, higher_pitch)
84
87
  interval.ensure_localized_name(name: identifier.to_s)
85
88
  interval
86
89
  else
@@ -0,0 +1,101 @@
1
+ class HeadMusic::Analysis::Dyad
2
+ # Enumerates the chords (as PitchCollections) that contain a given dyad.
3
+ # Trichords and seventh chords are built at every chromatic root from a fixed
4
+ # table of interval stacks, then kept only when they actually span the dyad.
5
+ # An optional key narrows the results to diatonic chords and ranks the
6
+ # survivors by how many of their pitches the key contains.
7
+ class ChordImplication
8
+ TRICHORD_INTERVALS = [
9
+ %w[M3 P5], # major triad
10
+ %w[m3 P5], # minor triad
11
+ %w[m3 d5], # diminished triad
12
+ %w[M3 A5], # augmented triad
13
+ %w[P4 P5], # sus4 (not a triad)
14
+ %w[M2 P5] # sus2 (not a triad)
15
+ ].freeze
16
+
17
+ SEVENTH_CHORD_INTERVALS = [
18
+ %w[M3 P5 M7], # major seventh
19
+ %w[M3 P5 m7], # dominant seventh (major-minor)
20
+ %w[m3 P5 m7], # minor seventh
21
+ %w[m3 P5 M7], # minor-major seventh
22
+ %w[m3 d5 m7], # half-diminished seventh
23
+ %w[m3 d5 d7], # diminished seventh
24
+ %w[M2 M3 P5 m7], # dominant ninth
25
+ %w[m2 M3 P5 m7], # dominant minor ninth
26
+ %w[M2 m3 P5 m7], # minor ninth
27
+ %w[M2 M3 P5 M7] # major ninth
28
+ ].freeze
29
+
30
+ attr_reader :dyad_pitch_classes, :key
31
+
32
+ def initialize(dyad_pitch_classes, key)
33
+ @dyad_pitch_classes = dyad_pitch_classes
34
+ @key = key
35
+ end
36
+
37
+ def trichords
38
+ @trichords ||= ranked(chords_from(TRICHORD_INTERVALS))
39
+ end
40
+
41
+ def seventh_chords
42
+ @seventh_chords ||= ranked(chords_from(SEVENTH_CHORD_INTERVALS))
43
+ end
44
+
45
+ private
46
+
47
+ # Without a key every chord stands; with one, keep the diatonic chords and
48
+ # order them by how much they agree with the key. Concentrating the key
49
+ # check here keeps filter and sort free of their own guards.
50
+ def ranked(chords)
51
+ return chords unless key
52
+
53
+ sort_by_diatonic_agreement(filter_by_key(chords))
54
+ end
55
+
56
+ def chords_from(interval_sets)
57
+ candidate_chords(interval_sets)
58
+ .select { |chord| includes_dyad?(chord) }
59
+ .uniq { |chord| chord.pitch_classes.sort.map(&:to_i) }
60
+ end
61
+
62
+ def candidate_chords(interval_sets)
63
+ candidate_roots.flat_map do |root_pitch|
64
+ interval_sets.map { |intervals| build_chord(root_pitch, intervals) }
65
+ end
66
+ end
67
+
68
+ def candidate_roots
69
+ HeadMusic::Rudiment::Spelling::CHROMATIC_SPELLINGS.map do |root_spelling|
70
+ HeadMusic::Rudiment::Pitch.get("#{root_spelling}4")
71
+ end
72
+ end
73
+
74
+ def build_chord(root_pitch, intervals)
75
+ chord_pitches = [root_pitch] + intervals.map do |name|
76
+ HeadMusic::Analysis::DiatonicInterval.get(name).above(root_pitch)
77
+ end
78
+ HeadMusic::Analysis::PitchCollection.new(chord_pitches)
79
+ end
80
+
81
+ def includes_dyad?(pitch_collection)
82
+ dyad_pitch_classes.all? { |pitch_class| pitch_collection.pitch_classes.include?(pitch_class) }
83
+ end
84
+
85
+ def filter_by_key(pitch_collections)
86
+ pitch_collections.select do |pitch_collection|
87
+ pitch_collection.pitches.all? { |pitch| diatonic_spellings.include?(pitch.spelling) }
88
+ end
89
+ end
90
+
91
+ def sort_by_diatonic_agreement(pitch_collections)
92
+ pitch_collections.sort_by do |pitch_collection|
93
+ -pitch_collection.pitches.count { |pitch| diatonic_spellings.include?(pitch.spelling) }
94
+ end
95
+ end
96
+
97
+ def diatonic_spellings
98
+ @diatonic_spellings ||= key.scale.spellings
99
+ end
100
+ end
101
+ end
@@ -31,11 +31,7 @@ class HeadMusic::Analysis::Dyad
31
31
  end
32
32
 
33
33
  def possible_trichords
34
- @possible_trichords ||= begin
35
- trichords = generate_possible_trichords
36
- trichords = filter_by_key(trichords) if key
37
- sort_by_diatonic_agreement(trichords)
38
- end
34
+ chord_implication.trichords
39
35
  end
40
36
 
41
37
  def possible_triads
@@ -43,11 +39,7 @@ class HeadMusic::Analysis::Dyad
43
39
  end
44
40
 
45
41
  def possible_seventh_chords
46
- @possible_seventh_chords ||= begin
47
- seventh_chords = generate_possible_seventh_chords
48
- seventh_chords = filter_by_key(seventh_chords) if key
49
- sort_by_diatonic_agreement(seventh_chords)
50
- end
42
+ chord_implication.seventh_chords
51
43
  end
52
44
 
53
45
  def enharmonic_respellings
@@ -68,111 +60,39 @@ class HeadMusic::Analysis::Dyad
68
60
 
69
61
  private
70
62
 
71
- TRICHORD_INTERVALS = [
72
- %w[M3 P5], # major triad
73
- %w[m3 P5], # minor triad
74
- %w[m3 d5], # diminished triad
75
- %w[M3 A5], # augmented triad
76
- %w[P4 P5], # sus4 (not a triad)
77
- %w[M2 P5] # sus2 (not a triad)
78
- ].freeze
79
-
80
- SEVENTH_CHORD_INTERVALS = [
81
- %w[M3 P5 M7], # major seventh
82
- %w[M3 P5 m7], # dominant seventh (major-minor)
83
- %w[m3 P5 m7], # minor seventh
84
- %w[m3 P5 M7], # minor-major seventh
85
- %w[m3 d5 m7], # half-diminished seventh
86
- %w[m3 d5 d7], # diminished seventh
87
- %w[M2 M3 P5 m7], # dominant ninth
88
- %w[m2 M3 P5 m7], # dominant minor ninth
89
- %w[M2 m3 P5 m7], # minor ninth
90
- %w[M2 M3 P5 M7] # major ninth
91
- ].freeze
92
-
93
- def generate_possible_trichords
94
- generate_possible_chords(TRICHORD_INTERVALS)
95
- end
96
-
97
- def generate_possible_seventh_chords
98
- generate_possible_chords(SEVENTH_CHORD_INTERVALS)
99
- end
100
-
101
- def generate_possible_chords(interval_sets)
102
- dyad_pitch_classes = [lower_pitch.pitch_class, upper_pitch.pitch_class]
103
- chords = []
104
-
105
- HeadMusic::Rudiment::Spelling::CHROMATIC_SPELLINGS.each do |root_spelling|
106
- root_pitch = HeadMusic::Rudiment::Pitch.get("#{root_spelling}4")
107
-
108
- interval_sets.each do |intervals|
109
- chord_pitches = [root_pitch] + intervals.map { |name| HeadMusic::Analysis::DiatonicInterval.get(name).above(root_pitch) }
110
- pitch_collection = HeadMusic::Analysis::PitchCollection.new(chord_pitches)
111
-
112
- if dyad_pitch_classes.all? { |pc| pitch_collection.pitch_classes.include?(pc) }
113
- chords << pitch_collection
114
- end
115
- end
116
- end
117
-
118
- chords.uniq { |chord| chord.pitch_classes.sort.map(&:to_i) }
119
- end
120
-
121
- def filter_by_key(pitch_collections)
122
- return pitch_collections unless key
123
-
124
- pitch_collections.select do |pitch_collection|
125
- pitch_collection.pitches.all? { |pitch| diatonic_spellings.include?(pitch.spelling) }
126
- end
127
- end
128
-
129
- def sort_by_diatonic_agreement(pitch_collections)
130
- return pitch_collections unless key
131
-
132
- pitch_collections.sort_by do |pitch_collection|
133
- -pitch_collection.pitches.count { |pitch| diatonic_spellings.include?(pitch.spelling) }
134
- end
135
- end
136
-
137
- def diatonic_spellings
138
- @diatonic_spellings ||= key.scale.spellings
63
+ def chord_implication
64
+ @chord_implication ||= ChordImplication.new([lower_pitch.pitch_class, upper_pitch.pitch_class], key)
139
65
  end
140
66
 
141
67
  def generate_enharmonic_respellings
142
- respellings = []
143
-
144
- # Get enharmonic equivalents for each pitch
145
- pitch1_equivalents = enharmonic_equivalents_for(pitch1)
146
- pitch2_equivalents = enharmonic_equivalents_for(pitch2)
68
+ lower_equivalents = enharmonic_equivalents_for(pitch1)
69
+ upper_equivalents = enharmonic_equivalents_for(pitch2)
147
70
 
148
- # Generate all combinations
149
- pitch1_equivalents.each do |lower|
150
- pitch2_equivalents.each do |upper|
151
- next if lower.spelling == pitch1.spelling && upper.spelling == pitch2.spelling
152
-
153
- respellings << self.class.new(lower, upper, key: key)
154
- end
155
- end
71
+ lower_equivalents.product(upper_equivalents)
72
+ .reject { |lower, upper| original_spelling?(lower, upper) }
73
+ .map { |lower, upper| self.class.new(lower, upper, key: key) }
74
+ end
156
75
 
157
- respellings
76
+ def original_spelling?(lower, upper)
77
+ lower.spelling == pitch1.spelling && upper.spelling == pitch2.spelling
158
78
  end
159
79
 
160
80
  ALTERATION_SIGNS = {-2 => "bb", -1 => "b", 0 => "", 1 => "#", 2 => "##"}.freeze
161
81
 
162
82
  def enharmonic_equivalents_for(pitch)
163
- target_pitch_class = pitch.pitch_class
164
- equivalents = [pitch]
165
-
166
- HeadMusic::Rudiment::LetterName.all.each do |letter_name|
167
- ALTERATION_SIGNS.each_value do |sign|
168
- spelling = HeadMusic::Rudiment::Spelling.get("#{letter_name}#{sign}")
169
- next unless spelling && spelling.pitch_class == target_pitch_class
170
- next if equivalents.any? { |equiv| equiv.spelling == spelling }
171
-
172
- equivalents << HeadMusic::Rudiment::Pitch.fetch_or_create(spelling, pitch.register)
173
- end
83
+ equivalent_pitches = enharmonic_spellings_for(pitch.pitch_class).map do |spelling|
84
+ HeadMusic::Rudiment::Pitch.fetch_or_create(spelling, pitch.register)
174
85
  end
86
+ [pitch, *equivalent_pitches].uniq(&:spelling)
87
+ end
88
+
89
+ def enharmonic_spellings_for(target_pitch_class)
90
+ all_spellings.select { |spelling| spelling.pitch_class == target_pitch_class }
91
+ end
175
92
 
176
- equivalents
93
+ def all_spellings
94
+ HeadMusic::Rudiment::LetterName.all.flat_map do |letter_name|
95
+ ALTERATION_SIGNS.each_value.map { |sign| HeadMusic::Rudiment::Spelling.get("#{letter_name}#{sign}") }
96
+ end.compact
177
97
  end
178
98
  end
@@ -41,15 +41,21 @@ class HeadMusic::Analysis::IntervalCycle
41
41
  def pitches_up
42
42
  @pitches_up ||= [starting_pitch].tap do |list|
43
43
  loop do
44
- next_pitch = list.last + interval
45
- next_pitch -= octave while next_pitch - starting_pitch > 12
46
- break if next_pitch.pitch_class == list.first.pitch_class
44
+ next_pitch = folded_up(list.last)
45
+ break if next_pitch.pitch_class == starting_pitch.pitch_class
47
46
 
48
47
  list << next_pitch
49
48
  end
50
49
  end
51
50
  end
52
51
 
52
+ # The next pitch a cycle-interval above, folded down by octaves to stay within an octave of the start.
53
+ def folded_up(pitch)
54
+ next_pitch = pitch + interval
55
+ next_pitch -= octave while next_pitch - starting_pitch > 12
56
+ next_pitch
57
+ end
58
+
53
59
  def octave
54
60
  @octave ||= HeadMusic::Analysis::DiatonicInterval.get(:perfect_octave)
55
61
  end