head_music 0.20.0 → 0.22.0

Sign up to get free protection for your applications and to get access to all the features.
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