head_music 0.20.0 → 0.22.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 (59) hide show
  1. checksums.yaml +5 -5
  2. data/.circleci/config.yml +22 -0
  3. data/.circleci/setup-rubygems.sh +3 -0
  4. data/.gitignore +1 -0
  5. data/.pairs +8 -0
  6. data/.rubocop.yml +11 -2
  7. data/.ruby-version +1 -0
  8. data/CODE_OF_CONDUCT.md +3 -6
  9. data/Gemfile +2 -2
  10. data/README.md +0 -1
  11. data/TODO.md +18 -0
  12. data/head_music.gemspec +1 -2
  13. data/lib/head_music/chord.rb +9 -2
  14. data/lib/head_music/circle.rb +1 -0
  15. data/lib/head_music/clef.rb +10 -5
  16. data/lib/head_music/consonance.rb +1 -1
  17. data/lib/head_music/content/composition.rb +1 -2
  18. data/lib/head_music/content/note.rb +2 -0
  19. data/lib/head_music/content/placement.rb +1 -1
  20. data/lib/head_music/content/rhythmic_value.rb +1 -0
  21. data/lib/head_music/content/voice.rb +4 -2
  22. data/lib/head_music/functional_interval.rb +43 -17
  23. data/lib/head_music/grand_staff.rb +2 -1
  24. data/lib/head_music/harmonic_interval.rb +1 -0
  25. data/lib/head_music/key_signature.rb +1 -0
  26. data/lib/head_music/letter_name.rb +20 -7
  27. data/lib/head_music/melodic_interval.rb +4 -2
  28. data/lib/head_music/meter.rb +1 -0
  29. data/lib/head_music/octave.rb +2 -0
  30. data/lib/head_music/pitch/enharmonic_equivalence.rb +28 -0
  31. data/lib/head_music/pitch/octave_equivalence.rb +24 -0
  32. data/lib/head_music/pitch.rb +41 -57
  33. data/lib/head_music/pitch_class.rb +5 -0
  34. data/lib/head_music/rhythmic_unit.rb +1 -0
  35. data/lib/head_music/scale.rb +7 -3
  36. data/lib/head_music/scale_degree.rb +2 -1
  37. data/lib/head_music/sign.rb +2 -1
  38. data/lib/head_music/spelling.rb +6 -2
  39. data/lib/head_music/style/analysis.rb +1 -0
  40. data/lib/head_music/style/annotation.rb +2 -2
  41. data/lib/head_music/style/guidelines/at_least_eight_notes.rb +1 -0
  42. data/lib/head_music/style/guidelines/direction_changes.rb +2 -0
  43. data/lib/head_music/style/guidelines/limit_octave_leaps.rb +1 -0
  44. data/lib/head_music/style/guidelines/mostly_conjunct.rb +1 -0
  45. data/lib/head_music/style/guidelines/notes_same_length.rb +2 -1
  46. data/lib/head_music/style/guidelines/one_to_one.rb +1 -0
  47. data/lib/head_music/style/guidelines/prefer_contrary_motion.rb +2 -0
  48. data/lib/head_music/style/guidelines/prefer_imperfect.rb +1 -0
  49. data/lib/head_music/style/guidelines/single_large_leaps.rb +1 -0
  50. data/lib/head_music/style/guidelines/start_on_perfect_consonance.rb +1 -0
  51. data/lib/head_music/style/guidelines/step_down_to_final_note.rb +1 -0
  52. data/lib/head_music/style/guidelines/step_out_of_unison.rb +1 -1
  53. data/lib/head_music/style/guidelines/step_up_to_final_note.rb +1 -0
  54. data/lib/head_music/style/mark.rb +3 -2
  55. data/lib/head_music/tuning.rb +6 -1
  56. data/lib/head_music/version.rb +1 -1
  57. data/lib/head_music.rb +2 -0
  58. metadata +10 -4
  59. data/circle.yml +0 -9
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
- SHA1:
3
- metadata.gz: 84558801b5df822669de2d7be0b1316dc711511a
4
- data.tar.gz: c246dbb35d11e8b7815d56a6535420e00096c791
2
+ SHA256:
3
+ metadata.gz: c5e3afc9754608ddd1d1c10b191f356d4c200733ebfd9e9294693309d7ab53d9
4
+ data.tar.gz: f92fbe212261872b48e41d1189289f7a0b9aea7643e060a3e96ed223746f1089
5
5
  SHA512:
6
- metadata.gz: 851820181797720920c79212516349316722dac6a4cd7889f917d2659543dbba5a39f7fce51ac8e5ed08302a1d51d7daa820e93f121bc8d3c681f1321441230e
7
- data.tar.gz: ec6881d59cd432e3c392eeafcba2a34edc1c9d51f3842bb0832641546a86c3656c381ac0226c9865152532696e36e16644e59d7b3e54554eec6e9978c25d8c64
6
+ metadata.gz: 30fac39ee9b758610839343fb7e879ec5422485a83240be3b535be261fc5e31d9f6a2f66c8a34523f90bb95c9f6c3c836ba1a404d07a40634bc2963a1cb584c3
7
+ data.tar.gz: ef1240d4dcc7b9d159967a1dcad186867c2dc1f7cac8e33df295d77452bb75d0a1888d9d3a99800aeff60d7a00dca94d7a9ece30d8190769ebcacffbceccedf5
@@ -0,0 +1,22 @@
1
+ version: 2
2
+ jobs:
3
+ build:
4
+ docker:
5
+ - image: circleci/ruby:2.5.1
6
+ steps:
7
+ - checkout
8
+ - restore_cache:
9
+ keys:
10
+ - v1-gems-{{ checksum "Gemfile.lock" }}
11
+ - v1-gems-
12
+ - run:
13
+ name: Bundle Install
14
+ command: bundle check || bundle install
15
+ - save_cache:
16
+ key: v1-gems-{{ checksum "Gemfile.lock" }}
17
+ paths:
18
+ - vendor/bundle
19
+ - run:
20
+ command: bundle exec rspec
21
+ - store_test_results:
22
+ path: test_results
@@ -0,0 +1,3 @@
1
+ mkdir ~/.gem
2
+ echo -e "---\r\n:rubygems_api_key: $RUBYGEMS_API_KEY" > ~/.gem/credentials
3
+ chmod 0600 /home/circleci/.gem/credentials
data/.gitignore CHANGED
@@ -7,3 +7,4 @@
7
7
  /pkg/
8
8
  /spec/reports/
9
9
  /tmp/
10
+ /spec/examples.txt
data/.pairs ADDED
@@ -0,0 +1,8 @@
1
+ # .pairs - configuration for 'git pair'
2
+ # place in project or home directory
3
+ pairs:
4
+ rh: Rob Head; robert.head
5
+ email:
6
+ prefix: pair
7
+ domain: gmail.com
8
+ no_solo_prefix: true
data/.rubocop.yml CHANGED
@@ -8,7 +8,7 @@ Metrics/BlockLength:
8
8
  - 'spec/**/*.rb'
9
9
 
10
10
  Metrics/ClassLength:
11
- Max: 150
11
+ Max: 155
12
12
 
13
13
  Metrics/LineLength:
14
14
  Max: 120
@@ -19,5 +19,14 @@ Style/SymbolArray:
19
19
  Style/ClassAndModuleChildren:
20
20
  EnforcedStyle: compact
21
21
 
22
- Style/TrailingCommaInLiteral:
22
+ Style/TrailingCommaInArrayLiteral:
23
23
  EnforcedStyleForMultiline: consistent_comma
24
+
25
+ Style/TrailingCommaInHashLiteral:
26
+ EnforcedStyleForMultiline: consistent_comma
27
+
28
+ AllCops:
29
+ TargetRubyVersion: 2.4
30
+
31
+ Layout/EmptyLineAfterGuardClause:
32
+ Enabled: false
data/.ruby-version ADDED
@@ -0,0 +1 @@
1
+ 2.5.1
data/CODE_OF_CONDUCT.md CHANGED
@@ -22,14 +22,11 @@ include:
22
22
 
23
23
  Examples of unacceptable behavior by participants include:
24
24
 
25
- * The use of sexualized language or imagery and unwelcome sexual attention or
26
- advances
25
+ * The use of sexualized language or imagery and unwelcome sexual attention or advances
27
26
  * Trolling, insulting/derogatory comments, and personal or political attacks
28
27
  * Public or private harassment
29
- * Publishing others' private information, such as a physical or electronic
30
- address, without explicit permission
31
- * Other conduct which could reasonably be considered inappropriate in a
32
- professional setting
28
+ * Publishing others' private information, such as a physical or electronic address, without explicit permission
29
+ * Other conduct which could reasonably be considered inappropriate in a professional setting
33
30
 
34
31
  ## Our Responsibilities
35
32
 
data/Gemfile CHANGED
@@ -2,14 +2,14 @@
2
2
 
3
3
  source 'https://rubygems.org'
4
4
 
5
- ruby '2.4.0'
5
+ ruby '2.5.1'
6
6
 
7
7
  # Specify your gem's dependencies in head_music.gemspec
8
8
  gemspec
9
9
 
10
10
  group :test do
11
11
  gem 'codeclimate-test-reporter', '~> 1.0.0'
12
- gem 'rubocop', require: false
12
+ gem 'rubocop', '>= 0.56.0', require: false
13
13
  gem 'rubocop-rspec', require: false
14
14
  gem 'simplecov'
15
15
  end
data/README.md CHANGED
@@ -40,4 +40,3 @@ Bug reports and pull requests are welcome on GitHub at https://github.com/[USERN
40
40
  ## License
41
41
 
42
42
  The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT).
43
-
data/TODO.md ADDED
@@ -0,0 +1,18 @@
1
+ # TODO
2
+
3
+ ## User stories
4
+
5
+ ### Done
6
+
7
+ As a developer
8
+ Given a pitch
9
+ I want to be able to add a functional interval to get another pitch.
10
+
11
+ FunctionalInterval
12
+ - def above(pitch) -> pitch
13
+ FunctionalInterval
14
+ - def below(pitch) -> pitch
15
+
16
+ Pitch addition and subtraction
17
+ - define `Pitch#+`, `Pitch#-`
18
+ - use FunctionalInterval methods
data/head_music.gemspec CHANGED
@@ -1,7 +1,6 @@
1
-
2
1
  # frozen_string_literal: true
3
2
 
4
- lib = File.expand_path('../lib', __FILE__)
3
+ lib = File.expand_path('lib', __dir__)
5
4
  $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
6
5
  require 'head_music/version'
7
6
 
@@ -6,23 +6,30 @@ class HeadMusic::Chord
6
6
 
7
7
  def initialize(pitches)
8
8
  raise ArgumentError if pitches.length < 3
9
+
9
10
  @pitches = pitches.map { |pitch| HeadMusic::Pitch.get(pitch) }.sort
10
11
  end
11
12
 
12
13
  def consonant_triad?
13
- return false unless three_pitches?
14
14
  reduction.root_triad? || reduction.first_inversion_triad? || reduction.second_inversion_triad?
15
15
  end
16
16
 
17
17
  def root_triad?
18
+ return false unless triad?
19
+
18
20
  intervals.map(&:shorthand).sort == %w[M3 m3]
19
21
  end
20
22
 
23
+ # TODO: look up definition of first and second inversion triads. Can they be spread?
21
24
  def first_inversion_triad?
25
+ return false unless triad?
26
+
22
27
  invert.invert.intervals.map(&:shorthand).sort == %w[M3 m3]
23
28
  end
24
29
 
25
30
  def second_inversion_triad?
31
+ return false unless triad?
32
+
26
33
  invert.intervals.map(&:shorthand).sort == %w[M3 m3]
27
34
  end
28
35
 
@@ -30,7 +37,7 @@ class HeadMusic::Chord
30
37
  @reduction ||= HeadMusic::Chord.new(reduction_pitches)
31
38
  end
32
39
 
33
- def three_pitches?
40
+ def triad?
34
41
  pitches.length == 3
35
42
  end
36
43
 
@@ -37,6 +37,7 @@ class HeadMusic::Circle
37
37
  loop do
38
38
  next_pitch_class = list.last + interval
39
39
  break if next_pitch_class == list.first
40
+
40
41
  list << next_pitch_class
41
42
  end
42
43
  end
@@ -7,7 +7,7 @@ class HeadMusic::Clef
7
7
  CLEFS = [
8
8
  { pitch: 'G4', line: 2, names: ['treble', 'G-clef'], modern: true },
9
9
  { pitch: 'G4', line: 1, names: ['French', 'French violin'] },
10
- { pitch: 'G3', line: 2, names: ['choral tenor', 'tenor'], modern: true },
10
+ { pitch: 'G3', line: 2, names: ['choral tenor', 'tenor', 'tenor G-clef'], modern: true },
11
11
 
12
12
  { pitch: 'F3', line: 3, names: ['baritone'] },
13
13
  { pitch: 'F3', line: 4, names: ['bass', 'F-clef'], modern: true },
@@ -16,8 +16,8 @@ class HeadMusic::Clef
16
16
  { pitch: 'C4', line: 1, names: ['soprano'] },
17
17
  { pitch: 'C4', line: 2, names: ['mezzo-soprano'] },
18
18
  { pitch: 'C4', line: 3, names: ['alto', 'viola', 'counter-tenor', 'countertenor', 'C-clef'], modern: true },
19
- { pitch: 'C4', line: 4, names: ['tenor'], modern: true },
20
- { pitch: 'C4', line: 5, names: ['baritone'] },
19
+ { pitch: 'C4', line: 4, names: ['tenor', 'tenor C-clef'], modern: true },
20
+ { pitch: 'C4', line: 5, names: ['baritone', 'baritone C-clef'] },
21
21
 
22
22
  { pitch: nil, line: 3, names: %w[neutral percussion] },
23
23
  ].freeze
@@ -33,13 +33,14 @@ class HeadMusic::Clef
33
33
  clef_data = CLEFS.detect { |clef| clef[:names].map(&:downcase).include?(name.downcase) }
34
34
  @pitch = HeadMusic::Pitch.get(clef_data[:pitch])
35
35
  @line = clef_data[:line]
36
+ @modern = clef_data[:modern]
36
37
  end
37
38
 
38
39
  def clef_type
39
40
  "#{pitch.letter_name}-clef"
40
41
  end
41
42
 
42
- def line_pitch(line_number)
43
+ def pitch_for_line(line_number)
43
44
  @line_pitches ||= {}
44
45
  @line_pitches[line_number] ||= begin
45
46
  steps = (line_number - line) * 2
@@ -47,7 +48,7 @@ class HeadMusic::Clef
47
48
  end
48
49
  end
49
50
 
50
- def space_pitch(space_number)
51
+ def pitch_for_space(space_number)
51
52
  @space_pitches ||= {}
52
53
  @space_pitches[space_number] ||= begin
53
54
  steps = (space_number - line) * 2 + 1
@@ -55,6 +56,10 @@ class HeadMusic::Clef
55
56
  end
56
57
  end
57
58
 
59
+ def modern?
60
+ @modern
61
+ end
62
+
58
63
  def ==(other)
59
64
  to_s == other.to_s
60
65
  end
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # Consonance is a description of intervals that sound more pleasing.
3
+ # Consonance describes a category or degree of harmonic pleasantness: perfect, imperfect, or dissonant
4
4
  class HeadMusic::Consonance
5
5
  LEVELS = %w[perfect imperfect dissonant].freeze
6
6
 
@@ -66,8 +66,7 @@ class HeadMusic::Composition
66
66
  @name = name || 'Composition'
67
67
  @key_signature = HeadMusic::KeySignature.get(key_signature) if key_signature
68
68
  @key_signature ||= HeadMusic::KeySignature.default
69
- @meter = HeadMusic::Meter.get(meter) if meter
70
- @meter ||= HeadMusic::Meter.default
69
+ @meter = meter ? HeadMusic::Meter.get(meter) : HeadMusic::Meter.default
71
70
  end
72
71
 
73
72
  def last_meter_change(bar_number)
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # A note is a pitch with a duration.
4
+ #
3
5
  # Note quacks like a placement, but requires a different set of construction arguments
4
6
  # - always has a pitch
5
7
  # - receives a voice and position if unspecified
@@ -33,7 +33,7 @@ class HeadMusic::Placement
33
33
  end
34
34
 
35
35
  def to_s
36
- "#{rhythmic_value} #{pitch ? pitch : 'rest'} at #{position}"
36
+ "#{rhythmic_value} #{pitch.presence || 'rest'} at #{position}"
37
37
  end
38
38
 
39
39
  private
@@ -29,6 +29,7 @@ class HeadMusic::RhythmicValue
29
29
 
30
30
  def self.dots_from_words(identifier)
31
31
  return 0 unless identifier.match?(/dotted/)
32
+
32
33
  modifier, = identifier.split(/_*dotted_*/)
33
34
  case modifier
34
35
  when /tripl\w/
@@ -38,11 +38,11 @@ class HeadMusic::Voice
38
38
  end
39
39
 
40
40
  def highest_pitch
41
- pitches.sort.last
41
+ pitches.max
42
42
  end
43
43
 
44
44
  def lowest_pitch
45
- pitches.sort.first
45
+ pitches.min
46
46
  end
47
47
 
48
48
  def highest_notes
@@ -92,11 +92,13 @@ class HeadMusic::Voice
92
92
 
93
93
  def earliest_bar_number
94
94
  return 1 if notes.empty?
95
+
95
96
  placements.first.position.bar_number
96
97
  end
97
98
 
98
99
  def latest_bar_number
99
100
  return 1 if notes.empty?
101
+
100
102
  placements.last.position.bar_number
101
103
  end
102
104
 
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # Represents a functional interval.
3
+ # A functional interval is the distance between two spelled pitches.
4
4
  class HeadMusic::FunctionalInterval
5
5
  include Comparable
6
6
 
@@ -61,7 +61,7 @@ class HeadMusic::FunctionalInterval
61
61
  end
62
62
 
63
63
  def higher_letter
64
- HeadMusic::Pitch.middle_c.letter_name.steps(steps)
64
+ HeadMusic::Pitch.middle_c.letter_name.steps_up(steps)
65
65
  end
66
66
  end
67
67
 
@@ -74,7 +74,7 @@ class HeadMusic::FunctionalInterval
74
74
  end
75
75
 
76
76
  def self.degree_quality_semitones
77
- @_degree_quality_semitones ||= begin
77
+ @degree_quality_semitones ||= begin
78
78
  {}.tap do |degree_quality_semitones|
79
79
  QUALITY_SEMITONES.each do |degree_name, qualities|
80
80
  default_quality = qualities.keys.first
@@ -135,21 +135,20 @@ class HeadMusic::FunctionalInterval
135
135
  @low_pitch, @high_pitch = *[pitch1, pitch2].sort
136
136
  end
137
137
 
138
- def simple_number
139
- @simple_number ||= @low_pitch.letter_name.steps_to(@high_pitch.letter_name) + 1
138
+ def number
139
+ @number ||= @low_pitch.steps_to(@high_pitch) + 1
140
140
  end
141
141
 
142
- def octaves
143
- @octaves ||= (high_pitch.number - low_pitch.number) / 12
142
+ def simple_number
143
+ @simple_number ||= octave_equivalent? ? 8 : (number - 1) % 7 + 1
144
144
  end
145
145
 
146
- # returns the ordinality of the interval
147
- def number
148
- simple_number + octaves * 7
146
+ def octaves
147
+ @octaves ||= number / 8
149
148
  end
150
149
 
151
150
  def simple?
152
- octaves.zero?
151
+ number <= 8
153
152
  end
154
153
 
155
154
  def compound?
@@ -167,6 +166,12 @@ class HeadMusic::FunctionalInterval
167
166
  def steps
168
167
  number - 1
169
168
  end
169
+
170
+ private
171
+
172
+ def octave_equivalent?
173
+ number > 1 && ((number - 1) % 7).zero?
174
+ end
170
175
  end
171
176
 
172
177
  # Accepts a number and number of semitones and privides the naming methods.
@@ -183,7 +188,7 @@ class HeadMusic::FunctionalInterval
183
188
  end
184
189
 
185
190
  def simple_number
186
- @simple_number ||= (number - 1) % 7 + 1
191
+ @simple_number ||= octave_equivalent? ? 8 : (number - 1) % 7 + 1
187
192
  end
188
193
 
189
194
  def simple_name
@@ -192,7 +197,8 @@ class HeadMusic::FunctionalInterval
192
197
 
193
198
  def quality_name
194
199
  starting_quality = QUALITY_SEMITONES[simple_number_name.to_sym].keys.first
195
- delta = simple_semitones - QUALITY_SEMITONES[simple_number_name.to_sym][starting_quality]
200
+ delta = simple_semitones - (QUALITY_SEMITONES[simple_number_name.to_sym][starting_quality] % 12)
201
+ delta -= 12 while delta >= 6
196
202
  HeadMusic::Quality.from(starting_quality, delta)
197
203
  end
198
204
 
@@ -207,7 +213,7 @@ class HeadMusic::FunctionalInterval
207
213
  def name
208
214
  if named_number?
209
215
  [quality_name, number_name].join(' ')
210
- elsif simple_name == 'perfect unison'
216
+ elsif simple_name == 'perfect octave'
211
217
  "#{octaves.humanize} octaves"
212
218
  else
213
219
  "#{octaves.humanize} octaves and #{quality.article} #{simple_name}"
@@ -232,12 +238,20 @@ class HeadMusic::FunctionalInterval
232
238
  def octaves
233
239
  @octaves ||= semitones / 12
234
240
  end
241
+
242
+ def octave_equivalent?
243
+ number > 1 && ((number - 1) % 7).zero?
244
+ end
235
245
  end
236
246
 
237
247
  delegate :step?, :skip?, :leap?, :large_leap?, to: :category
238
- delegate :simple_number, :octaves, :number, :simple?, :compound?, :semitones, :simple_semitones, :steps, to: :size
239
248
  delegate(
240
- :simple_semitones, :simple_name, :quality_name, :simple_number_name, :number_name, :name, :shorthand, to: :naming
249
+ :simple_number, :octaves, :number, :simple?, :compound?, :semitones, :simple_semitones, :steps,
250
+ to: :size
251
+ )
252
+ delegate(
253
+ :simple_semitones, :simple_name, :quality_name, :simple_number_name, :number_name, :name, :shorthand,
254
+ to: :naming
241
255
  )
242
256
 
243
257
  # Accepts a name and returns the interval with middle c on the bottom
@@ -260,7 +274,9 @@ class HeadMusic::FunctionalInterval
260
274
 
261
275
  def inversion
262
276
  inverted_low_pitch = lower_pitch
263
- inverted_low_pitch += 12 while inverted_low_pitch < higher_pitch
277
+ while inverted_low_pitch < higher_pitch
278
+ inverted_low_pitch = HeadMusic::Pitch.fetch_or_create(lower_pitch.spelling, inverted_low_pitch.octave + 1)
279
+ end
264
280
  HeadMusic::FunctionalInterval.new(higher_pitch, inverted_low_pitch)
265
281
  end
266
282
  alias invert inversion
@@ -288,6 +304,16 @@ class HeadMusic::FunctionalInterval
288
304
  consonance(style).dissonant?
289
305
  end
290
306
 
307
+ def above(pitch)
308
+ pitch = HeadMusic::Pitch.get(pitch)
309
+ HeadMusic::Pitch.from_number_and_letter(pitch + semitones, pitch.letter_name.steps_up(number - 1))
310
+ end
311
+
312
+ def below(pitch)
313
+ pitch = HeadMusic::Pitch.get(pitch)
314
+ HeadMusic::Pitch.from_number_and_letter(pitch - semitones, pitch.letter_name.steps_down(number - 1))
315
+ end
316
+
291
317
  def <=>(other)
292
318
  other = self.class.get(other) unless other.is_a?(HeadMusic::FunctionalInterval)
293
319
  semitones <=> other.semitones
@@ -23,7 +23,8 @@ class HeadMusic::GrandStaff
23
23
  def self.get(name)
24
24
  @grand_staves ||= {}
25
25
  hash_key = HeadMusic::Utilities::HashKey.for(name)
26
- return nil unless GRAND_STAVES.keys.include?(hash_key)
26
+ return nil unless GRAND_STAVES.key?(hash_key)
27
+
27
28
  @grand_staves[hash_key] ||= new(hash_key)
28
29
  end
29
30
 
@@ -44,6 +44,7 @@ class HeadMusic::HarmonicInterval
44
44
 
45
45
  def pitch_orientation
46
46
  return if lower_pitch == upper_pitch
47
+
47
48
  if lower_note.voice == voice1
48
49
  :up
49
50
  elsif lower_note.voice == voice2
@@ -15,6 +15,7 @@ class HeadMusic::KeySignature
15
15
 
16
16
  def self.get(identifier)
17
17
  return identifier if identifier.is_a?(HeadMusic::KeySignature)
18
+
18
19
  @key_signatures ||= {}
19
20
  tonic_spelling, scale_type_name = identifier.strip.split(/\s/)
20
21
  hash_key = HeadMusic::Utilities::HashKey.for(identifier.gsub(/#|♯/, ' sharp').gsub(/(\w)[b♭]/, '\\1 flat'))
@@ -31,6 +31,7 @@ class HeadMusic::LetterName
31
31
  def self.from_pitch_class(pitch_class)
32
32
  @letter_names ||= {}
33
33
  return nil if pitch_class.to_s == pitch_class
34
+
34
35
  pitch_class = pitch_class.to_i % 12
35
36
  name = NAMES.detect { |candidate| pitch_class == NATURAL_PITCH_CLASS_NUMBERS[candidate] }
36
37
  name ||= HeadMusic::PitchClass::SHARP_SPELLINGS[pitch_class].first
@@ -59,8 +60,12 @@ class HeadMusic::LetterName
59
60
  NAMES.index(to_s) + 1
60
61
  end
61
62
 
62
- def steps(num)
63
- HeadMusic::LetterName.get(cycle[num % NAMES.length])
63
+ def steps_up(num)
64
+ HeadMusic::LetterName.get(series_ascending[num % NAMES.length])
65
+ end
66
+
67
+ def steps_down(num)
68
+ HeadMusic::LetterName.get(series_descending[num % NAMES.length])
64
69
  end
65
70
 
66
71
  def steps_to(other, direction = :ascending)
@@ -75,11 +80,19 @@ class HeadMusic::LetterName
75
80
  end
76
81
  end
77
82
 
78
- def cycle
79
- @cycle ||= begin
80
- cycle = NAMES
81
- cycle = cycle.rotate while cycle.first != to_s
82
- cycle
83
+ def series_ascending
84
+ @series_ascending ||= begin
85
+ series = NAMES
86
+ series = series.rotate while series.first != to_s
87
+ series
88
+ end
89
+ end
90
+
91
+ def series_descending
92
+ @series_descending ||= begin
93
+ series = NAMES.reverse
94
+ series = series.rotate while series.first != to_s
95
+ series
83
96
  end
84
97
  end
85
98
 
@@ -62,11 +62,11 @@ class HeadMusic::MelodicInterval
62
62
  end
63
63
 
64
64
  def high_pitch
65
- pitches.sort.last
65
+ pitches.max
66
66
  end
67
67
 
68
68
  def low_pitch
69
- pitches.sort.first
69
+ pitches.min
70
70
  end
71
71
 
72
72
  def direction
@@ -82,8 +82,10 @@ class HeadMusic::MelodicInterval
82
82
 
83
83
  def spells_consonant_triad_with?(other_interval)
84
84
  return false if step? || other_interval.step?
85
+
85
86
  combined_pitches = (pitches + other_interval.pitches).uniq
86
87
  return false if combined_pitches.length < 3
88
+
87
89
  HeadMusic::Chord.new(combined_pitches).consonant_triad?
88
90
  end
89
91
 
@@ -67,6 +67,7 @@ class HeadMusic::Meter
67
67
  return 80 if strong_beat?(count, tick)
68
68
  return 60 if beat?(tick)
69
69
  return 40 if strong_ticks.include?(tick)
70
+
70
71
  20
71
72
  end
72
73
 
@@ -13,12 +13,14 @@ class HeadMusic::Octave
13
13
  def self.from_number(identifier)
14
14
  return nil unless identifier.to_s == identifier.to_i.to_s
15
15
  return nil unless (-2..12).cover?(identifier.to_i)
16
+
16
17
  @octaves ||= {}
17
18
  @octaves[identifier.to_i] ||= new(identifier.to_i)
18
19
  end
19
20
 
20
21
  def self.from_name(string)
21
22
  return unless string.to_s.match?(HeadMusic::Spelling::MATCHER)
23
+
22
24
  _letter, _sign, octave_string = string.to_s.match(HeadMusic::Spelling::MATCHER).captures
23
25
  @octaves ||= {}
24
26
  @octaves[octave_string.to_i] ||= new(octave_string.to_i) if octave_string
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ # An enharmonic equivalent pitch is the same frequency spelled differently, such as D# and Eb.
4
+ class HeadMusic::Pitch::EnharmonicEquivalence
5
+ def self.get(pitch)
6
+ pitch = HeadMusic::Pitch.get(pitch)
7
+ @enharmonic_equivalences ||= {}
8
+ @enharmonic_equivalences[pitch.to_s] ||= new(pitch)
9
+ end
10
+
11
+ attr_reader :pitch
12
+
13
+ delegate :pitch_class, to: :pitch
14
+
15
+ def initialize(pitch)
16
+ @pitch = HeadMusic::Pitch.get(pitch)
17
+ end
18
+
19
+ def enharmonic_equivalent?(other)
20
+ other = HeadMusic::Pitch.get(other)
21
+ pitch.midi_note_number == other.midi_note_number && pitch.spelling != other.spelling
22
+ end
23
+
24
+ alias enharmonic? enharmonic_equivalent?
25
+ alias equivalent? enharmonic_equivalent?
26
+
27
+ private_class_method :new
28
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Octave equivalence is the functional equivalence of pitches with the same spelling separated by one or more octaves.
4
+ class HeadMusic::Pitch::OctaveEquivalence
5
+ def self.get(pitch)
6
+ @octave_equivalences ||= {}
7
+ @octave_equivalences[pitch.to_s] ||= new(pitch)
8
+ end
9
+
10
+ attr_reader :pitch
11
+
12
+ def initialize(pitch)
13
+ @pitch = pitch
14
+ end
15
+
16
+ def octave_equivalent?(other)
17
+ other = HeadMusic::Pitch.get(other)
18
+ pitch.spelling == other.spelling && pitch.octave != other.octave
19
+ end
20
+
21
+ alias equivalent? octave_equivalent?
22
+
23
+ private_class_method :new
24
+ end