head_music 13.0.0 → 14.0.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +8 -0
- data/Gemfile.lock +1 -1
- data/lib/head_music/style/guidelines/contoured.rb +134 -0
- data/lib/head_music/style/guides/arch_contour_melody.rb +7 -0
- data/lib/head_music/style/guides/ascending_contour_melody.rb +7 -0
- data/lib/head_music/style/guides/descending_contour_melody.rb +7 -0
- data/lib/head_music/style/guides/static_contour_melody.rb +7 -0
- data/lib/head_music/style/guides/valley_contour_melody.rb +7 -0
- data/lib/head_music/style/guides/wave_contour_melody.rb +7 -0
- data/lib/head_music/version.rb +1 -1
- data/lib/head_music.rb +7 -0
- data/user-stories/done/melody-contour-guides.md +267 -0
- data/user-stories/index.html +114 -34
- metadata +10 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ed605ba621e60e4222568efe6071c86daace2bbf21c6c577b7ccfda1fb67f769
|
|
4
|
+
data.tar.gz: b4a0f23c074d7d5bca6eb849a4e01ba099631c87d7cef89e3fd130c294a20803
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 888d95ec1fab0517520899150d8caf794f7ec4edc7fa19e19d370d761d4541287712d207beb4b6f6772e0da62615258607c8ce5dbfb93d9311d5e32c692bbbc8
|
|
7
|
+
data.tar.gz: 759c97ee1504589dfbd6f91c2ac2bad6aa685ce70cf413ffc76eea2bdd4864fc6598b56d742d5e34d5f3ac528f88548830ca502ef7f866d034b0a349c5cf7e8f
|
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
|
+
## [14.0.0] - 2026-07-05
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- `HeadMusic::Style::Guidelines::Contoured` — configurable guideline judging a melody against a chosen contour (`Contoured.with(:arch)` and five other keys: `ascending`, `descending`, `valley`, `wave`, `static`). Predicates are trend-based rather than strictly monotonic; a wrong contour receives a single mark spanning the melody. Unknown contour keys raise `ArgumentError` at guide-definition time.
|
|
15
|
+
- Six contour guides subclassing `Guides::DiatonicMelody`, each appending the configured `Contoured` guideline to the inherited ruleset: `ArchContourMelody`, `AscendingContourMelody`, `DescendingContourMelody`, `StaticContourMelody`, `ValleyContourMelody`, `WaveContourMelody`
|
|
16
|
+
- Contour judgments deliberately complement `ConsonantClimax`: an arch requires only an interior climax pitch level, leaving climax uniqueness and consonance to the existing guideline
|
|
17
|
+
|
|
10
18
|
## [13.0.0] - 2026-07-05
|
|
11
19
|
|
|
12
20
|
### Added
|
data/Gemfile.lock
CHANGED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# Module for style guidelines.
|
|
2
|
+
module HeadMusic::Style::Guidelines; end
|
|
3
|
+
|
|
4
|
+
# Flags a melody without the configured contour
|
|
5
|
+
# Configure the contour with the factory, e.g. Contoured.with(:arch).
|
|
6
|
+
class HeadMusic::Style::Guidelines::Contoured < HeadMusic::Style::Annotation
|
|
7
|
+
CONTOURS = %i[ascending descending arch valley wave static].freeze
|
|
8
|
+
|
|
9
|
+
TREND_REVERSAL_SEMITONES = 3 # a trend reversal must exceed a whole step
|
|
10
|
+
|
|
11
|
+
def self.with(contour_key)
|
|
12
|
+
contour = contour_key.to_s.downcase.to_sym
|
|
13
|
+
unless CONTOURS.include?(contour)
|
|
14
|
+
raise ArgumentError, "Contour must be one of: #{CONTOURS.join(", ")} (got #{contour_key.inspect})"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
super(contour: contour)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def marks
|
|
21
|
+
return if notes.empty? || matches_contour?
|
|
22
|
+
|
|
23
|
+
HeadMusic::Style::Mark.for_all(notes)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def message
|
|
27
|
+
"Write a melody with the #{contour} contour."
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
# Validated again here because Contoured.new(voice, contour: :bogus) bypasses .with.
|
|
33
|
+
def contour
|
|
34
|
+
@contour ||= begin
|
|
35
|
+
key = options.fetch(:contour)
|
|
36
|
+
contour = key.to_s.downcase.to_sym
|
|
37
|
+
unless CONTOURS.include?(contour)
|
|
38
|
+
raise ArgumentError, "Contour must be one of: #{CONTOURS.join(", ")} (got #{key.inspect})"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
contour
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def matches_contour?
|
|
46
|
+
send("#{contour}?")
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def ascending?
|
|
50
|
+
first_note.pitch == lowest_pitch && last_note.pitch == highest_pitch
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def descending?
|
|
54
|
+
first_note.pitch == highest_pitch && last_note.pitch == lowest_pitch
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# The climax is by definition the maximum, so "net rise before, net fall after"
|
|
58
|
+
# is equivalent to both endpoints sitting below the climax pitch.
|
|
59
|
+
# Climax uniqueness and consonance remain ConsonantClimax's job.
|
|
60
|
+
def arch?
|
|
61
|
+
notes.length >= 3 && first_note.pitch < highest_pitch && last_note.pitch < highest_pitch
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def valley?
|
|
65
|
+
notes.length >= 3 && first_note.pitch > lowest_pitch && last_note.pitch > lowest_pitch
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def wave?
|
|
69
|
+
trend_directions.length >= 3
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def static?
|
|
73
|
+
range <= HeadMusic::Analysis::DiatonicInterval.get(:major_third) && !directional_endpoints?
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# The highest_pitch > lowest_pitch guard is load-bearing: without it, an
|
|
77
|
+
# all-same-pitch melody (first == lowest and last == highest simultaneously)
|
|
78
|
+
# would absurdly fail static.
|
|
79
|
+
def directional_endpoints?
|
|
80
|
+
highest_pitch > lowest_pitch &&
|
|
81
|
+
((first_note.pitch == lowest_pitch && last_note.pitch == highest_pitch) ||
|
|
82
|
+
(first_note.pitch == highest_pitch && last_note.pitch == lowest_pitch))
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def pitch_numbers
|
|
86
|
+
@pitch_numbers ||= notes.map { |note| note.pitch.midi_note_number }
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Zigzag walk: a trend reversal is confirmed only when the melody retraces at
|
|
90
|
+
# least TREND_REVERSAL_SEMITONES from the running extreme of the current trend,
|
|
91
|
+
# so stepwise neighbor-note undulation never registers as a trend change.
|
|
92
|
+
def trend_directions
|
|
93
|
+
@trend_directions ||= begin
|
|
94
|
+
directions = []
|
|
95
|
+
direction = nil
|
|
96
|
+
high = low = pitch_numbers.first
|
|
97
|
+
extreme = nil
|
|
98
|
+
pitch_numbers.drop(1).each do |number|
|
|
99
|
+
case direction
|
|
100
|
+
when nil # no trend confirmed yet
|
|
101
|
+
if number - low >= TREND_REVERSAL_SEMITONES
|
|
102
|
+
direction = :ascending
|
|
103
|
+
extreme = number
|
|
104
|
+
directions << direction
|
|
105
|
+
elsif high - number >= TREND_REVERSAL_SEMITONES
|
|
106
|
+
direction = :descending
|
|
107
|
+
extreme = number
|
|
108
|
+
directions << direction
|
|
109
|
+
else
|
|
110
|
+
high = [high, number].max
|
|
111
|
+
low = [low, number].min
|
|
112
|
+
end
|
|
113
|
+
when :ascending
|
|
114
|
+
if number > extreme
|
|
115
|
+
extreme = number
|
|
116
|
+
elsif extreme - number >= TREND_REVERSAL_SEMITONES
|
|
117
|
+
direction = :descending
|
|
118
|
+
extreme = number
|
|
119
|
+
directions << direction
|
|
120
|
+
end
|
|
121
|
+
when :descending
|
|
122
|
+
if number < extreme
|
|
123
|
+
extreme = number
|
|
124
|
+
elsif number - extreme >= TREND_REVERSAL_SEMITONES
|
|
125
|
+
direction = :ascending
|
|
126
|
+
extreme = number
|
|
127
|
+
directions << direction
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
directions
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# A free diatonic melody with an arch contour (interior climax).
|
|
2
|
+
class HeadMusic::Style::Guides::ArchContourMelody < HeadMusic::Style::Guides::DiatonicMelody
|
|
3
|
+
RULESET = [
|
|
4
|
+
*HeadMusic::Style::Guides::DiatonicMelody::RULESET,
|
|
5
|
+
HeadMusic::Style::Guidelines::Contoured.with(:arch)
|
|
6
|
+
].freeze
|
|
7
|
+
end
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# A free diatonic melody with an ascending contour (departs its floor, arrives at its ceiling).
|
|
2
|
+
class HeadMusic::Style::Guides::AscendingContourMelody < HeadMusic::Style::Guides::DiatonicMelody
|
|
3
|
+
RULESET = [
|
|
4
|
+
*HeadMusic::Style::Guides::DiatonicMelody::RULESET,
|
|
5
|
+
HeadMusic::Style::Guidelines::Contoured.with(:ascending)
|
|
6
|
+
].freeze
|
|
7
|
+
end
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# A free diatonic melody with a descending contour (departs its ceiling, arrives at its floor).
|
|
2
|
+
class HeadMusic::Style::Guides::DescendingContourMelody < HeadMusic::Style::Guides::DiatonicMelody
|
|
3
|
+
RULESET = [
|
|
4
|
+
*HeadMusic::Style::Guides::DiatonicMelody::RULESET,
|
|
5
|
+
HeadMusic::Style::Guidelines::Contoured.with(:descending)
|
|
6
|
+
].freeze
|
|
7
|
+
end
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# A free diatonic melody with a static contour (narrow range, non-directional endpoints).
|
|
2
|
+
class HeadMusic::Style::Guides::StaticContourMelody < HeadMusic::Style::Guides::DiatonicMelody
|
|
3
|
+
RULESET = [
|
|
4
|
+
*HeadMusic::Style::Guides::DiatonicMelody::RULESET,
|
|
5
|
+
HeadMusic::Style::Guidelines::Contoured.with(:static)
|
|
6
|
+
].freeze
|
|
7
|
+
end
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# A free diatonic melody with a valley contour (interior nadir).
|
|
2
|
+
class HeadMusic::Style::Guides::ValleyContourMelody < HeadMusic::Style::Guides::DiatonicMelody
|
|
3
|
+
RULESET = [
|
|
4
|
+
*HeadMusic::Style::Guides::DiatonicMelody::RULESET,
|
|
5
|
+
HeadMusic::Style::Guidelines::Contoured.with(:valley)
|
|
6
|
+
].freeze
|
|
7
|
+
end
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# A free diatonic melody with a wave contour (repeated undulation at the trend level).
|
|
2
|
+
class HeadMusic::Style::Guides::WaveContourMelody < HeadMusic::Style::Guides::DiatonicMelody
|
|
3
|
+
RULESET = [
|
|
4
|
+
*HeadMusic::Style::Guides::DiatonicMelody::RULESET,
|
|
5
|
+
HeadMusic::Style::Guidelines::Contoured.with(:wave)
|
|
6
|
+
].freeze
|
|
7
|
+
end
|
data/lib/head_music/version.rb
CHANGED
data/lib/head_music.rb
CHANGED
|
@@ -159,6 +159,7 @@ require "head_music/style/guidelines/avoid_crossing_voices"
|
|
|
159
159
|
require "head_music/style/guidelines/avoid_overlapping_voices"
|
|
160
160
|
require "head_music/style/guidelines/consonant_climax"
|
|
161
161
|
require "head_music/style/guidelines/consonant_downbeats"
|
|
162
|
+
require "head_music/style/guidelines/contoured"
|
|
162
163
|
require "head_music/style/guidelines/diatonic"
|
|
163
164
|
require "head_music/style/guidelines/direction_changes"
|
|
164
165
|
require "head_music/style/guidelines/end_on_perfect_consonance"
|
|
@@ -219,6 +220,12 @@ require "head_music/style/guides/fux_cantus_firmus"
|
|
|
219
220
|
require "head_music/style/guides/salzer_schachter_cantus_firmus"
|
|
220
221
|
require "head_music/style/guides/first_species_melody"
|
|
221
222
|
require "head_music/style/guides/diatonic_melody"
|
|
223
|
+
require "head_music/style/guides/arch_contour_melody"
|
|
224
|
+
require "head_music/style/guides/ascending_contour_melody"
|
|
225
|
+
require "head_music/style/guides/descending_contour_melody"
|
|
226
|
+
require "head_music/style/guides/static_contour_melody"
|
|
227
|
+
require "head_music/style/guides/valley_contour_melody"
|
|
228
|
+
require "head_music/style/guides/wave_contour_melody"
|
|
222
229
|
require "head_music/style/guides/species_harmony"
|
|
223
230
|
require "head_music/style/guides/first_species_harmony"
|
|
224
231
|
require "head_music/style/guides/second_species_melody"
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
metadata:
|
|
3
|
+
created_at: 2026-07-05T13:55:34-07:00
|
|
4
|
+
activated_at: 2026-07-05T16:12:05-07:00
|
|
5
|
+
planned_at: 2026-07-05T16:28:11-07:00
|
|
6
|
+
finished_at: 2026-07-05T17:37:37-07:00
|
|
7
|
+
updated_at: 2026-07-05T17:37:37-07:00
|
|
8
|
+
-->
|
|
9
|
+
|
|
10
|
+
# Story: Melody Contour Guides
|
|
11
|
+
|
|
12
|
+
## Summary
|
|
13
|
+
|
|
14
|
+
AS a composer or student using HeadMusic to evaluate melodies
|
|
15
|
+
I WANT style guides that assess whether a diatonic melody follows a specified contour (ascending, descending, arch, valley, wave, or static)
|
|
16
|
+
SO THAT I can shape melodic writing toward an intended overall gesture and get actionable feedback when the melody doesn't match
|
|
17
|
+
|
|
18
|
+
## Acceptance Criteria
|
|
19
|
+
|
|
20
|
+
- A configurable guideline `HeadMusic::Style::Guidelines::Contoured` exists, configured via `Contoured.with(:arch)` (and the other five contour keys)
|
|
21
|
+
- `Contoured.with` raises an error for a key not in `CONTOURS`, so a typo fails at guide-definition time
|
|
22
|
+
- A melody matching the configured contour receives no marks (adherent, fitness 1.0)
|
|
23
|
+
- A melody not matching the configured contour receives a single mark spanning all notes (one violation, one penalty — no per-note compounding) with the message "Write a melody with the {contour} contour."
|
|
24
|
+
- Contour predicates are trend-based, not strictly monotonic — local direction changes are allowed; existing guidelines (e.g., `ModerateDirectionChanges`) already police local motion:
|
|
25
|
+
- **Ascending**: the lowest pitch occurs at (or tied with) the first note and the highest pitch at the last note — the line departs its floor and arrives at its ceiling
|
|
26
|
+
- **Descending**: mirror of ascending — highest pitch at the first note, lowest pitch at the last note
|
|
27
|
+
- **Arch**: a single climax (highest pitch) in the interior (not the first or last note), with a net rise before it and a net fall after it
|
|
28
|
+
- **Valley**: mirror of arch — a single nadir (lowest pitch) in the interior, with a net fall before it and a net rise after it
|
|
29
|
+
- **Wave**: at least 3 direction changes at the trend level (rise–fall–rise or fall–rise–fall), distinguishing repeated undulation from a single-turn arch or valley
|
|
30
|
+
- **Static**: total range no larger than a major third, and the endpoints must not sit at the extremes in a way that implies another contour (must not start at the lowest pitch and end at the highest, nor start at the highest and end at the lowest)
|
|
31
|
+
- Six guides exist as subclasses of `HeadMusic::Style::Guides::DiatonicMelody` — `AscendingContourMelody`, `DescendingContourMelody`, `ArchContourMelody`, `ValleyContourMelody`, `WaveContourMelody`, `StaticContourMelody` — each appending the appropriately configured `Contoured` guideline to the inherited RULESET
|
|
32
|
+
- The arch predicate complements rather than duplicates `ConsonantClimax` (contour judges shape; climax consonance/uniqueness stays with `ConsonantClimax` — no double-flagging of the same defect)
|
|
33
|
+
- Specs cover adherent and non-adherent melodies for each contour (using ABC notation per the existing spec convention), coverage stays ≥ 90%, rubocop clean
|
|
34
|
+
|
|
35
|
+
## Notes
|
|
36
|
+
|
|
37
|
+
- Sketch in progress at `lib/head_music/style/guidelines/contoured.rb` (a previously drafted `arch_contour_melody.rb` stub was deleted; all six guide files are created fresh)
|
|
38
|
+
- Design decision (2026-07-05): configurability lives at the guideline layer (matching the `MinimumNotes.with` / `LargeLeaps.with` pattern); guides remain named classes because guides are the gem's pedagogical vocabulary and contour is a single closed axis with six values — revisit guide-level `.with` only if a second orthogonal configuration axis appears
|
|
39
|
+
- Guide naming follows the existing stub's convention: `{Contour}ContourMelody`
|
|
40
|
+
- Mark semantics: `Annotation#fitness` is the product of mark fitnesses, so one spanning mark applies the penalty once; per-note marks would compound `PENALTY_FACTOR` per note and crush fitness on long melodies
|
|
41
|
+
- `references/third-species-counterpoint.md:310` is the only contour mention in the references ("The line should have an overall arch or wave shape")
|
|
42
|
+
|
|
43
|
+
## Implementation Plan
|
|
44
|
+
|
|
45
|
+
### Overview
|
|
46
|
+
|
|
47
|
+
Complete the existing sketch at `lib/head_music/style/guidelines/contoured.rb` (six empty predicates, validation, guards), register it in `lib/head_music.rb`, then add six two-line guide classes subclassing `HeadMusic::Style::Guides::DiatonicMelody` that append `Contoured.with(:contour)` to the inherited RULESET. All predicate math uses existing Comparable APIs (`Pitch#<=>` via midi note number, `DiatonicInterval#<=>` by semitones); no changes to existing classes, no data model, no locale entries.
|
|
48
|
+
|
|
49
|
+
### Steps
|
|
50
|
+
|
|
51
|
+
1. **Finish the `Contoured` guideline**
|
|
52
|
+
- Replace `contour_key.to_s.underscore.to_sym` with `contour_key.to_s.downcase.to_sym` — `String#underscore` only works here because ActiveSupport inflections load transitively (`lib/head_music.rb` requires only `module/delegation`, `string/access`, `integer/inflections`); do not depend on that.
|
|
53
|
+
- Validate in `self.with` so a typo raises at require time (RULESET constants evaluate in guide class bodies at load), using the gem's enum-validation precedent (`lib/head_music/rudiment/mode.rb:32`):
|
|
54
|
+
|
|
55
|
+
```ruby
|
|
56
|
+
def self.with(contour_key)
|
|
57
|
+
contour = contour_key.to_s.downcase.to_sym
|
|
58
|
+
unless CONTOURS.include?(contour)
|
|
59
|
+
raise ArgumentError, "Contour must be one of: #{CONTOURS.join(", ")} (got #{contour_key.inspect})"
|
|
60
|
+
end
|
|
61
|
+
super(contour: contour)
|
|
62
|
+
end
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
- Also validate in the private `contour` reader (same guard) so `Contoured.new(voice, contour: :bogus)` — which bypasses `.with` — fails with a clear error instead of a `NoMethodError` from `send("#{contour}?")`. After validation, the `send` dispatch is safe and idiomatic; no case/when needed.
|
|
66
|
+
- Guard `marks`: `return if notes.empty? || matches_contour?` — matches the "no notes is adherent" convention (see `spec/head_music/style/guidelines/moderate_direction_changes_spec.rb`) and prevents `DiatonicInterval.new(nil, nil)` via `range`.
|
|
67
|
+
- Implement the six predicates and the shared `trend_directions` helper (next section). Keep the sketched `message` and `Mark.for_all(notes)` — `for_all` returns one spanning mark, so a violation costs exactly one `HeadMusic::PENALTY_FACTOR`.
|
|
68
|
+
- Files: `lib/head_music/style/guidelines/contoured.rb`
|
|
69
|
+
|
|
70
|
+
2. **Require the guideline**
|
|
71
|
+
- Insert `require "head_music/style/guidelines/contoured"` alphabetically between `consonant_downbeats` (line 161) and `diatonic` (line 162).
|
|
72
|
+
- Files: `lib/head_music.rb`
|
|
73
|
+
|
|
74
|
+
3. **Create the six guide classes**
|
|
75
|
+
- Each is a thin subclass using the explicit splat pattern (matching `DiatonicMelody`'s own composition style); avoid the self-shadowing `RULESET = [*RULESET, ...]` form:
|
|
76
|
+
|
|
77
|
+
```ruby
|
|
78
|
+
# A free diatonic melody with an arch contour (interior climax).
|
|
79
|
+
class HeadMusic::Style::Guides::ArchContourMelody < HeadMusic::Style::Guides::DiatonicMelody
|
|
80
|
+
RULESET = [
|
|
81
|
+
*HeadMusic::Style::Guides::DiatonicMelody::RULESET,
|
|
82
|
+
HeadMusic::Style::Guidelines::Contoured.with(:arch)
|
|
83
|
+
].freeze
|
|
84
|
+
end
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
- No intermediate `ContourMelody` base class — six two-line frozen constants are not duplication worth an abstraction.
|
|
88
|
+
- Files: `lib/head_music/style/guides/arch_contour_melody.rb`, `ascending_contour_melody.rb`, `descending_contour_melody.rb`, `static_contour_melody.rb`, `valley_contour_melody.rb`, `wave_contour_melody.rb`
|
|
89
|
+
|
|
90
|
+
4. **Require the guides**
|
|
91
|
+
- Insert the six requires immediately after `require "head_music/style/guides/diatonic_melody"` (line 221), alphabetized among themselves (arch, ascending, descending, static, valley, wave). The guides block is dependency-ordered — they must load after their superclass (and after `guidelines/contoured`), so they cannot be merged into strict whole-block alphabetical order.
|
|
92
|
+
- Files: `lib/head_music.rb`
|
|
93
|
+
|
|
94
|
+
5. **Specs** (detail under Testing Strategy)
|
|
95
|
+
- Files: `spec/head_music/style/guidelines/contoured_spec.rb` plus six specs under `spec/head_music/style/guides/`
|
|
96
|
+
|
|
97
|
+
6. **Polish**
|
|
98
|
+
- `bundle exec rubocop -a`, then `bundle exec rake` (tests + coverage gate at 90%).
|
|
99
|
+
|
|
100
|
+
### Contour Predicate Algorithms
|
|
101
|
+
|
|
102
|
+
All predicates operate on delegated voice methods; `notes.empty?` never reaches them (guarded in `marks`). Shared helpers (private, memoized per the gem's instance-ivar style):
|
|
103
|
+
|
|
104
|
+
```ruby
|
|
105
|
+
TREND_REVERSAL_SEMITONES = 3 # a trend reversal must exceed a whole step
|
|
106
|
+
|
|
107
|
+
def pitch_numbers
|
|
108
|
+
@pitch_numbers ||= notes.map { |note| note.pitch.midi_note_number }
|
|
109
|
+
end
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
- **ascending?** — `first_note.pitch == lowest_pitch && last_note.pitch == highest_pitch`. Comparing pitches (not membership in `highest_notes`) handles tied extremes for free: a later recurrence of the floor pitch still satisfies "at (or tied with) the first note". Degenerate all-same-pitch melodies pass; length policing belongs to `MinimumNotes.with(5)` already in the RULESET.
|
|
113
|
+
|
|
114
|
+
- **descending?** — mirror: `first_note.pitch == highest_pitch && last_note.pitch == lowest_pitch`.
|
|
115
|
+
|
|
116
|
+
- **arch?** — `notes.length >= 3 && first_note.pitch < highest_pitch && last_note.pitch < highest_pitch`. Because the climax is by definition the maximum, "net rise before, net fall after" is exactly equivalent to "both endpoints sit below the climax pitch"; the length guard ensures an interior exists. Deliberately does **not** require a unique climax — see the double-flagging resolution below.
|
|
117
|
+
|
|
118
|
+
- **valley?** — mirror: `notes.length >= 3 && first_note.pitch > lowest_pitch && last_note.pitch > lowest_pitch`.
|
|
119
|
+
|
|
120
|
+
- **wave?** — `trend_directions.length >= 3`, where `trend_directions` is built by a single-pass zigzag walk: a trend reversal is confirmed only when the melody retraces at least `TREND_REVERSAL_SEMITONES` (3, a minor third) from the running extreme of the current trend. Justification: steps (1–2 semitones) are the unit of local motion already policed by `ModerateDirectionChanges`/`MostlyConjunct`, so stepwise neighbor-note undulation (E–F–E) never registers as a trend change.
|
|
121
|
+
|
|
122
|
+
```ruby
|
|
123
|
+
def trend_directions
|
|
124
|
+
@trend_directions ||= begin
|
|
125
|
+
directions = []
|
|
126
|
+
direction = nil
|
|
127
|
+
high = low = pitch_numbers.first
|
|
128
|
+
extreme = nil
|
|
129
|
+
pitch_numbers.drop(1).each do |number|
|
|
130
|
+
case direction
|
|
131
|
+
when nil # no trend confirmed yet
|
|
132
|
+
if number - low >= TREND_REVERSAL_SEMITONES
|
|
133
|
+
direction = :ascending
|
|
134
|
+
extreme = number
|
|
135
|
+
directions << direction
|
|
136
|
+
elsif high - number >= TREND_REVERSAL_SEMITONES
|
|
137
|
+
direction = :descending
|
|
138
|
+
extreme = number
|
|
139
|
+
directions << direction
|
|
140
|
+
else
|
|
141
|
+
high = [high, number].max
|
|
142
|
+
low = [low, number].min
|
|
143
|
+
end
|
|
144
|
+
when :ascending
|
|
145
|
+
if number > extreme
|
|
146
|
+
extreme = number
|
|
147
|
+
elsif extreme - number >= TREND_REVERSAL_SEMITONES
|
|
148
|
+
direction = :descending
|
|
149
|
+
extreme = number
|
|
150
|
+
directions << direction
|
|
151
|
+
end
|
|
152
|
+
when :descending
|
|
153
|
+
if number < extreme
|
|
154
|
+
extreme = number
|
|
155
|
+
elsif number - extreme >= TREND_REVERSAL_SEMITONES
|
|
156
|
+
direction = :ascending
|
|
157
|
+
extreme = number
|
|
158
|
+
directions << direction
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
directions
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
`directions` alternates by construction, so length ≥ 3 means rise–fall–rise or fall–rise–fall at the trend level (three trend legs), distinguishing a wave from a single-turn arch/valley (two legs). Hand-verified: C D E D C D E → `[asc, desc, asc]` (wave); C D E G E D C → `[asc, desc]` (arch, not wave); C D C D C → `[]` (sub-threshold undulation). Repeated pitches are inert.
|
|
168
|
+
|
|
169
|
+
- **static?** — `range <= HeadMusic::Analysis::DiatonicInterval.get(:major_third) && !directional_endpoints?`. `DiatonicInterval#<=>` coerces symbols via `.get` and compares by semitones, so this is inclusive at exactly M3 per the "no larger than" wording. The endpoint exclusion:
|
|
170
|
+
|
|
171
|
+
```ruby
|
|
172
|
+
def directional_endpoints?
|
|
173
|
+
highest_pitch > lowest_pitch &&
|
|
174
|
+
((first_note.pitch == lowest_pitch && last_note.pitch == highest_pitch) ||
|
|
175
|
+
(first_note.pitch == highest_pitch && last_note.pitch == lowest_pitch))
|
|
176
|
+
end
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
The `highest_pitch > lowest_pitch` guard is load-bearing: without it, an all-same-pitch melody (first == lowest and last == highest simultaneously) would absurdly fail static.
|
|
180
|
+
|
|
181
|
+
**Arch vs. ConsonantClimax (no double-flagging).** `ConsonantClimax` already penalizes climax multiplicity (allowing once, or twice with a step between) and consonance. If `arch?` also demanded a unique climax, a melody like C D G E G E D C would collect two penalties for the single defect "repeated climax". Resolution: `arch?` requires an *interior* climax only; climax uniqueness and consonance remain solely ConsonantClimax's job. The AC's "single climax" is satisfied as a single climax *pitch level* located in the interior. A spec proves the split (below). The predicates are intentionally not mutually exclusive across guides (e.g., a wavy line can also satisfy `arch?`); each guide checks only its configured contour, so overlap is leniency, not defect — the endpoint-exclusivity rule applies to `static?` alone, as specified.
|
|
182
|
+
|
|
183
|
+
### Messages & i18n
|
|
184
|
+
|
|
185
|
+
- Style-guideline messages are plain English throughout — `MESSAGE` constants or interpolated `def message` overrides (`MinimumNotes`: "Write at least #{minimum.humanize} notes."); none are translated in `lib/head_music/locales/`. The sketch's interpolated `"Write a melody with the #{contour} contour."` matches both the mechanism and the "Write..." register exactly. Keep it; adding i18n for this one message would break convention — defer as future work across all guideline messages.
|
|
186
|
+
- The `ArgumentError` message must enumerate the valid keys (as in the snippet above) so a typo tells the user what is allowed — matches the `mode.rb` precedent.
|
|
187
|
+
- Optional deferred polish: indefinite-article variation ("an arch contour" vs "the ascending contour"), implementable via the `SingableRange::VOWEL_SOUND_ORDINALS` pattern; the AC pins the current template, so ship as written.
|
|
188
|
+
|
|
189
|
+
### Testing Strategy
|
|
190
|
+
|
|
191
|
+
**`spec/head_music/style/guidelines/contoured_spec.rb`** — construct melodies with `HeadMusic::Notation::ABC.parse` heredocs (headers `X:1 / M:4/4 / L:1/4 / K:C`; ABC `C` = C4, `c` = C5), `voice = composition.voices.first`, subject `described_class.with(:contour).new(voice)`. Test only the public surface (`fitness`, `marks`, `message`, `be_adherent`) — never the private predicates, per gem convention.
|
|
192
|
+
|
|
193
|
+
- `.with`: returns `Annotation::Configured` with `options == {contour: :arch}`; accepts a string key (`"Arch"`); `expect { described_class.with(:zigzag) }.to raise_error(ArgumentError, /zigzag/)`.
|
|
194
|
+
- Per contour, adherent and non-adherent contexts (each melody ≥ 5 notes):
|
|
195
|
+
- ascending: adherent `CDED|EFEF|G4|`; tied-floor adherent `CDCE|FGA2|`; non-adherent `CDEG|EDC2|`
|
|
196
|
+
- descending: adherent `GFEF|EDED|C4|`; non-adherent `CDED|EFG2|`
|
|
197
|
+
- arch: adherent `CDEG|EDC2|`; repeated-interior-climax adherent `CDGE|GEDC|` — in the same context assert `ConsonantClimax.new(voice)` is *not* adherent (the no-double-flagging proof); non-adherent `CDEF|G4|` (climax at last note)
|
|
198
|
+
- valley: adherent `GFEC|DEFG|`; non-adherent `GFED|C4|`
|
|
199
|
+
- wave: adherent `CDED|CDE2|` (m3-sized legs); non-adherent `CDEG|EDC2|` (single turn); non-adherent `CDCD|C4|` (whole-step undulation below threshold)
|
|
200
|
+
- static: adherent `EDEF|EFED|E4|` (range m3, neutral endpoints); adherent all-same-pitch `EEEE|E4|`; non-adherent `CDEF|G4|` (range P5); non-adherent `CDCD|E4|` (range exactly M3 but endpoints imply ascending)
|
|
201
|
+
- Violation assertions: `fitness == HeadMusic::PENALTY_FACTOR` and exactly one mark (single spanning mark, no per-note compounding); `message` equals the exact template output.
|
|
202
|
+
- Edge cases: empty voice adherent for every contour; single-note melody adherent for ascending/descending/static and non-adherent for arch/valley/wave (no interior / no trend legs exist) — flagging both length (`MinimumNotes`) and contour is two distinct defects, not double-flagging; one melody containing a rest to confirm trend computation is rest-transparent (`notes` excludes rests).
|
|
203
|
+
|
|
204
|
+
**Six guide specs** — `spec/head_music/style/guides/{arch,ascending,descending,static,valley,wave}_contour_melody_spec.rb`, mirroring `diatonic_melody_spec.rb` conventions (`configured(...)` helper):
|
|
205
|
+
|
|
206
|
+
- RULESET includes everything in `DiatonicMelody::RULESET` (object identity holds via the splat) plus `configured(HeadMusic::Style::Guidelines::Contoured, contour: :arch)` etc.
|
|
207
|
+
- One integration pair per guide via `HeadMusic::Style::Analysis.new(described_class, voice)`: adherent melody → `analysis.messages` excludes the contour message; non-adherent → includes it. Assert message presence/absence rather than overall adherence so unrelated guidelines (ConsonantClimax, ModerateDirectionChanges) cannot break the test — note a strictly monotonic 6+ note line adherent to AscendingContour will still be flagged by ModerateDirectionChanges at the guide level, which is coherent with "trend-based" but worth one demonstrating spec (undulating-yet-ascending adherent melody).
|
|
208
|
+
|
|
209
|
+
### Risks & Open Questions
|
|
210
|
+
|
|
211
|
+
- **Wave count semantics** (confirmed 2026-07-05): wave = ≥ 3 trend legs (rise–fall–rise), matching the AC's examples.
|
|
212
|
+
- **Trend-reversal threshold** (confirmed 2026-07-05): `TREND_REVERSAL_SEMITONES = 3` (a reversal must exceed a whole step); a hardcoded constant in the style of `ModerateDirectionChanges`, tunable without structural change. Accepted consequence: a melody undulating purely by whole steps registers zero trend legs and fails `wave?` (it will typically satisfy `static?` instead).
|
|
213
|
+
- **Arch "single climax" reading** (confirmed 2026-07-05): "single climax" means a single climax pitch level located in the interior; multiplicity stays with ConsonantClimax (this is what makes the no-double-flagging criterion satisfiable). The spec `CDGE|GEDC|` pins the behavior.
|
|
214
|
+
- **Semitone-based comparisons**: `range <= :major_third` compares by semitones, so a diminished fourth (4 semitones) counts as static-sized; enharmonic edge cases (B#3 vs C4 extremes) are theoretically ambiguous under spelling-based `Pitch#==` but unrealistic for diatonic melodies. No mitigation needed.
|
|
215
|
+
- **Degenerate pass-through**: an all-repeated-pitch melody passes `ascending?`, `descending?`, and `static?`; nothing in DiatonicMelody's RULESET flags it (no `AlwaysMove`). Accepted as out of scope for contour.
|
|
216
|
+
- **Deferred by scope**: contour of subphrases; configurable static-range threshold and wave leg count (constants only); i18n of guideline messages; a contour-*detection* API ("which contour is this melody?"); wiring contour guides into species RULESETs; message article grammar ("an arch contour").
|
|
217
|
+
|
|
218
|
+
## Review
|
|
219
|
+
|
|
220
|
+
Reviewed 2026-07-05 at commit `cd3b20b` (branch `story/melody-contour-guides`, clean tree) by a product-manager agent (acceptance criteria) and a code-reviewer agent (quality) in parallel.
|
|
221
|
+
|
|
222
|
+
### Acceptance Criteria
|
|
223
|
+
|
|
224
|
+
- ✅ **Configurable `Contoured` guideline via `Contoured.with(:arch)` etc.** — `contoured.rb:6-18` (CONTOURS + factory); pinned at `contoured_spec.rb:19-28`, including string-key coercion (`"Arch"`).
|
|
225
|
+
- ✅ **`.with` raises for unknown keys** — guard at `contoured.rb:13-15` enumerating valid keys; pinned at `contoured_spec.rb:30-32`. Exceeds the criterion: the `.new` bypass path is also guarded in the private reader (`contoured.rb:33-43`, pinned at `contoured_spec.rb:42-49`).
|
|
226
|
+
- ✅ **Matching melody: no marks, fitness 1.0** — `contoured.rb:20-24`; adherent contexts for every contour plus empty-voice adherence for all six (`contoured_spec.rb:200-212`).
|
|
227
|
+
- ✅ **Non-matching melody: single spanning mark, one penalty, exact message** — `Mark.for_all(notes)` at `contoured.rb:23`; specs pin one mark, `fitness == HeadMusic::PENALTY_FACTOR`, and the exact message template (`contoured_spec.rb:66-73, 35-40`).
|
|
228
|
+
- ✅ **Trend-based predicates per the six definitions** — ascending/descending endpoint comparisons (`contoured.rb:49-55`, tied-floor case pinned), arch/valley interior-extreme with length guard (`contoured.rb:60-66`), wave ≥ 3 trend legs with the 3-semitone reversal threshold (`contoured.rb:9, 68-70, 92-133`), static M3 range + `directional_endpoints?` with the all-same-pitch guard (`contoured.rb:72-83`). Arch judged via the recorded "climax pitch level" interpretation.
|
|
229
|
+
- ✅ **Six guides subclass `DiatonicMelody` appending configured `Contoured`** — explicit splat pattern in all six files; guide specs assert RULESET superset + configured guideline + Analysis integration pair; requires at `lib/head_music.rb:162, 223-228`; melodic-guide census updated to 16 (`base_spec.rb:9-10`).
|
|
230
|
+
- ✅ **Arch complements `ConsonantClimax` (no double-flagging)** — `CDGE|GEDC|` spec runs both: Contoured(:arch) adherent, ConsonantClimax not adherent (`contoured_spec.rb:103-111`).
|
|
231
|
+
- ✅ **Specs per contour (ABC convention), coverage ≥ 90%, rubocop clean** — all specs use `ABC.parse` heredocs; guideline + guide specs re-run at this commit (455 examples, 0 failures); full suite 5120 examples, 0 failures, 99.57% line coverage, rubocop clean (401 files).
|
|
232
|
+
|
|
233
|
+
### Code Review Findings
|
|
234
|
+
|
|
235
|
+
No critical or important issues. Correctness verified by hand-tracing: the `trend_directions` nil→trend transition invariant holds (the unconfirmed `[low, high]` band can never widen to ≥ 3 semitones without triggering, so a confirmed leg's `extreme` is its true extreme); `>= 3` correctly encodes "must exceed a whole step"; `range <= major_third` is correctly inclusive; the base_spec census bump is the right fix (the count is an intentional registry guard, also pinning harmonic guides at 7).
|
|
236
|
+
|
|
237
|
+
Minor (spec boundary gaps — a mutation would survive the suite). **All five closed 2026-07-05** with eight new examples in `contoured_spec.rb`; the two comparison mutations were verified caught by temporarily applying them (each now produces a failure):
|
|
238
|
+
|
|
239
|
+
1. **Wave threshold at exactly a minor third untested** — adherent wave legs are major thirds, below-threshold case uses whole steps; `>= 3` mutated to `> 3` (contoured.rb:101,105,116,124) would pass. Add an adherent wave with exactly-m3 legs (e.g. D–F–D–F–D).
|
|
240
|
+
2. **Static inclusive-M3 boundary untested as adherent** (also flagged by PM) — the only exactly-M3 melody fails via endpoints, so `<=` mutated to `<` (contoured.rb:73) would pass. Add an adherent melody with exactly-M3 range and neutral endpoints.
|
|
241
|
+
3. **Two-note melodies untested for arch/valley/wave** — the `notes.length >= 3` guard's n == 2 boundary is unexercised (single-note cases exist).
|
|
242
|
+
4. **`directional_endpoints?` descending branch unexercised** (PM) — only the ascending-implying endpoint case has a spec; add e.g. `EDED|C4|`.
|
|
243
|
+
5. **Valley lacks the repeated-nadir mirror** of the arch no-double-flagging spec (PM) — implementation is a strict mirror, so low risk.
|
|
244
|
+
|
|
245
|
+
Nits: the CONTOURS validation block is duplicated between `self.with` and the private reader (could extract a `self.contour_for(key)` normalizer); the local `contour` inside `def contour` shadows the method name (rename to `key`/`normalized` for clarity). Both behaviorally correct as written.
|
|
246
|
+
|
|
247
|
+
Accepted-by-design behaviors (recorded in Risks, not defects): whole-step-only undulation can never satisfy `wave?`; predicates are not mutually exclusive across guides; an all-repeated-pitch melody passes ascending, descending, and static.
|
|
248
|
+
|
|
249
|
+
## Learnings
|
|
250
|
+
|
|
251
|
+
**What went well**
|
|
252
|
+
|
|
253
|
+
- Pinning the six predicate definitions through one-at-a-time clarifying questions before planning paid off: the planner operationalized them without churn, and every planned spec melody produced its expected result on the first run — zero algorithm rework during implementation.
|
|
254
|
+
- Challenging the architecture up front (configurable guide vs. subclass-per-contour) was settled cheaply by reading the existing patterns rather than debating in the abstract; the decision and its revisit condition (a second orthogonal configuration axis) are recorded in Notes.
|
|
255
|
+
- Understanding Mark/fitness semantics early (fitness is the product of mark fitnesses) turned an API-usage question into a real design decision: one spanning mark per violation instead of per-note penalty compounding.
|
|
256
|
+
- The review found zero functional defects but five mutation-survivable spec gaps; hand mutation-testing (flip the operator, confirm a failure appears) was a cheap, high-confidence way to verify the hardening actually pins the boundaries.
|
|
257
|
+
|
|
258
|
+
**What was surprising**
|
|
259
|
+
|
|
260
|
+
- `spec/head_music/style/guides/base_spec.rb` hard-codes a census of melodic guides (10 → 16) — the plan didn't anticipate it, and any future story adding guides will trip the same assertion.
|
|
261
|
+
- The sketch's `String#underscore` only worked because ActiveSupport inflections load transitively; the gem's explicit core_ext requires don't include it.
|
|
262
|
+
- The arch/ConsonantClimax boundary was the subtlest call: the no-double-flagging criterion was only satisfiable by reading "single climax" as a climax pitch level, leaving multiplicity to the existing guideline.
|
|
263
|
+
|
|
264
|
+
**What to do differently**
|
|
265
|
+
|
|
266
|
+
- Close "minor" review findings in the same story when they're cheap (eight examples here) rather than deferring — the review→harden→re-verify loop took minutes.
|
|
267
|
+
- Don't reference untracked scratch files in story notes; the `arch_contour_melody.rb` stub was deleted mid-story and left a stale pointer. Reference files once they're committed.
|
data/user-stories/index.html
CHANGED
|
@@ -253,40 +253,120 @@
|
|
|
253
253
|
// Each entry: { title, path, story? }. `story` is an optional curated
|
|
254
254
|
// one-liner preserved across regenerations (see /stories board).
|
|
255
255
|
const stories = {
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
256
|
+
"backlog": [
|
|
257
|
+
{
|
|
258
|
+
"title": "LilyPond Interpreter",
|
|
259
|
+
"path": "backlog/lilypond-interpreter.md"
|
|
260
|
+
},
|
|
261
|
+
{
|
|
262
|
+
"title": "Organizing Content",
|
|
263
|
+
"path": "backlog/organizing-content.md"
|
|
264
|
+
},
|
|
265
|
+
{
|
|
266
|
+
"title": "Sixteenth-Century (Renaissance) Style Guides",
|
|
267
|
+
"path": "backlog/sixteenth-century-style.md"
|
|
268
|
+
},
|
|
269
|
+
{
|
|
270
|
+
"title": "Split Counterpoint Species Guides by Pedagogical Author",
|
|
271
|
+
"path": "backlog/split-counterpoint-species-by-author.md"
|
|
272
|
+
}
|
|
273
|
+
],
|
|
274
|
+
"active": [],
|
|
275
|
+
"done": [
|
|
276
|
+
{
|
|
277
|
+
"title": "Melody Contour Guides",
|
|
278
|
+
"path": "done/melody-contour-guides.md"
|
|
279
|
+
},
|
|
280
|
+
{
|
|
281
|
+
"title": "Extract Staff Schemes to NotationStyle",
|
|
282
|
+
"path": "done/notation-style.md"
|
|
283
|
+
},
|
|
284
|
+
{
|
|
285
|
+
"title": "ABC Notation Interpreter",
|
|
286
|
+
"path": "done/abc-notation-interpreter.md"
|
|
287
|
+
},
|
|
288
|
+
{
|
|
289
|
+
"title": "Make Large-Leap Recovery Configurable",
|
|
290
|
+
"path": "done/configurable-large-leap-recovery.md"
|
|
291
|
+
},
|
|
292
|
+
{
|
|
293
|
+
"title": "Band Score Order",
|
|
294
|
+
"path": "done/epic--score-order/band-score-order.md"
|
|
295
|
+
},
|
|
296
|
+
{
|
|
297
|
+
"title": "Chamber Ensemble Score Order",
|
|
298
|
+
"path": "done/epic--score-order/chamber-ensemble-score-order.md"
|
|
299
|
+
},
|
|
300
|
+
{
|
|
301
|
+
"title": "Consonance and Dissonance Classification",
|
|
302
|
+
"path": "done/consonance-dissonance-classification.md"
|
|
303
|
+
},
|
|
304
|
+
{
|
|
305
|
+
"title": "Dyad Analysis",
|
|
306
|
+
"path": "done/dyad-analysis.md"
|
|
307
|
+
},
|
|
308
|
+
{
|
|
309
|
+
"title": "Handling Time and Tempo",
|
|
310
|
+
"path": "done/handle-time.md"
|
|
311
|
+
},
|
|
312
|
+
{
|
|
313
|
+
"title": "Instrument Inheritance Architecture",
|
|
314
|
+
"path": "done/instrument-architecture.md"
|
|
315
|
+
},
|
|
316
|
+
{
|
|
317
|
+
"title": "Instrument Variant Refactoring",
|
|
318
|
+
"path": "done/instrument-variant.md"
|
|
319
|
+
},
|
|
320
|
+
{
|
|
321
|
+
"title": "Instruments can have strings with pitches",
|
|
322
|
+
"path": "done/string-pitches.md"
|
|
323
|
+
},
|
|
324
|
+
{
|
|
325
|
+
"title": "Load Playing Techniques from YAML Data File",
|
|
326
|
+
"path": "done/expand-playing-techniques.md"
|
|
327
|
+
},
|
|
328
|
+
{
|
|
329
|
+
"title": "Move MusicalSymbol to Notation Module",
|
|
330
|
+
"path": "done/move-musical-symbol-to-notation.md"
|
|
331
|
+
},
|
|
332
|
+
{
|
|
333
|
+
"title": "Move StaffMapping to Notation Module",
|
|
334
|
+
"path": "done/move-staff-mapping-to-notation.md"
|
|
335
|
+
},
|
|
336
|
+
{
|
|
337
|
+
"title": "Move StaffPosition to Notation Module",
|
|
338
|
+
"path": "done/move-staff-position-to-notation.md"
|
|
339
|
+
},
|
|
340
|
+
{
|
|
341
|
+
"title": "Notation Module Foundation",
|
|
342
|
+
"path": "done/notation-module-foundation.md"
|
|
343
|
+
},
|
|
344
|
+
{
|
|
345
|
+
"title": "Orchestral Score Order",
|
|
346
|
+
"path": "done/epic--score-order/orchestral-score-order.md"
|
|
347
|
+
},
|
|
348
|
+
{
|
|
349
|
+
"title": "Percussion Set Staff",
|
|
350
|
+
"path": "done/percussion_set.md"
|
|
351
|
+
},
|
|
352
|
+
{
|
|
353
|
+
"title": "Pitch Class Set Analysis",
|
|
354
|
+
"path": "done/pitch-class-set-analysis.md"
|
|
355
|
+
},
|
|
356
|
+
{
|
|
357
|
+
"title": "Score-Order Epic: Implementation Plan",
|
|
358
|
+
"path": "done/epic--score-order/PLAN.md"
|
|
359
|
+
},
|
|
360
|
+
{
|
|
361
|
+
"title": "Sonority Identification",
|
|
362
|
+
"path": "done/sonority-identification.md"
|
|
363
|
+
},
|
|
364
|
+
{
|
|
365
|
+
"title": "Superclass for Note",
|
|
366
|
+
"path": "done/superclass-for-note.md"
|
|
367
|
+
}
|
|
368
|
+
]
|
|
369
|
+
};
|
|
290
370
|
|
|
291
371
|
function render() {
|
|
292
372
|
const board = document.getElementById('board');
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: head_music
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version:
|
|
4
|
+
version: 14.0.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Rob Head
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-07-
|
|
11
|
+
date: 2026-07-06 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: activesupport
|
|
@@ -288,6 +288,7 @@ files:
|
|
|
288
288
|
- lib/head_music/style/guidelines/avoid_overlapping_voices.rb
|
|
289
289
|
- lib/head_music/style/guidelines/consonant_climax.rb
|
|
290
290
|
- lib/head_music/style/guidelines/consonant_downbeats.rb
|
|
291
|
+
- lib/head_music/style/guidelines/contoured.rb
|
|
291
292
|
- lib/head_music/style/guidelines/diatonic.rb
|
|
292
293
|
- lib/head_music/style/guidelines/direction_changes.rb
|
|
293
294
|
- lib/head_music/style/guidelines/directional_step_to_final_note.rb
|
|
@@ -339,9 +340,12 @@ files:
|
|
|
339
340
|
- lib/head_music/style/guidelines/triple_meter_dissonance_treatment.rb
|
|
340
341
|
- lib/head_music/style/guidelines/two_per_bar.rb
|
|
341
342
|
- lib/head_music/style/guidelines/weak_beat_dissonance_treatment.rb
|
|
343
|
+
- lib/head_music/style/guides/arch_contour_melody.rb
|
|
344
|
+
- lib/head_music/style/guides/ascending_contour_melody.rb
|
|
342
345
|
- lib/head_music/style/guides/base.rb
|
|
343
346
|
- lib/head_music/style/guides/combined_first_second_third_species_harmony.rb
|
|
344
347
|
- lib/head_music/style/guides/combined_first_second_third_species_melody.rb
|
|
348
|
+
- lib/head_music/style/guides/descending_contour_melody.rb
|
|
345
349
|
- lib/head_music/style/guides/diatonic_melody.rb
|
|
346
350
|
- lib/head_music/style/guides/fifth_species_harmony.rb
|
|
347
351
|
- lib/head_music/style/guides/fifth_species_melody.rb
|
|
@@ -355,10 +359,13 @@ files:
|
|
|
355
359
|
- lib/head_music/style/guides/second_species_melody.rb
|
|
356
360
|
- lib/head_music/style/guides/species_harmony.rb
|
|
357
361
|
- lib/head_music/style/guides/species_melody.rb
|
|
362
|
+
- lib/head_music/style/guides/static_contour_melody.rb
|
|
358
363
|
- lib/head_music/style/guides/third_species_harmony.rb
|
|
359
364
|
- lib/head_music/style/guides/third_species_melody.rb
|
|
360
365
|
- lib/head_music/style/guides/third_species_triple_meter_harmony.rb
|
|
361
366
|
- lib/head_music/style/guides/third_species_triple_meter_melody.rb
|
|
367
|
+
- lib/head_music/style/guides/valley_contour_melody.rb
|
|
368
|
+
- lib/head_music/style/guides/wave_contour_melody.rb
|
|
362
369
|
- lib/head_music/style/mark.rb
|
|
363
370
|
- lib/head_music/style/medieval_tradition.rb
|
|
364
371
|
- lib/head_music/style/modern_tradition.rb
|
|
@@ -399,6 +406,7 @@ files:
|
|
|
399
406
|
- user-stories/done/handle-time.rb
|
|
400
407
|
- user-stories/done/instrument-architecture.md
|
|
401
408
|
- user-stories/done/instrument-variant.md
|
|
409
|
+
- user-stories/done/melody-contour-guides.md
|
|
402
410
|
- user-stories/done/move-musical-symbol-to-notation.md
|
|
403
411
|
- user-stories/done/move-staff-mapping-to-notation.md
|
|
404
412
|
- user-stories/done/move-staff-position-to-notation.md
|