head_music 13.0.0 → 15.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 +23 -0
- data/Gemfile.lock +1 -1
- data/lib/head_music/style/analysis.rb +19 -4
- data/lib/head_music/style/annotation.rb +42 -1
- data/lib/head_music/style/guidelines/contoured.rb +140 -0
- data/lib/head_music/style/guidelines/diatonic.rb +8 -0
- data/lib/head_music/style/guidelines/maximum_notes.rb +10 -2
- data/lib/head_music/style/guidelines/minimum_melodic_intervals.rb +55 -0
- data/lib/head_music/style/guidelines/minimum_notes.rb +6 -2
- data/lib/head_music/style/guides/arch_contour_melody.rb +4 -0
- data/lib/head_music/style/guides/ascending_contour_melody.rb +4 -0
- data/lib/head_music/style/guides/descending_contour_melody.rb +4 -0
- data/lib/head_music/style/guides/diatonic_melody.rb +24 -0
- data/lib/head_music/style/guides/static_contour_melody.rb +5 -0
- data/lib/head_music/style/guides/valley_contour_melody.rb +4 -0
- data/lib/head_music/style/guides/wave_contour_melody.rb +4 -0
- data/lib/head_music/version.rb +1 -1
- data/lib/head_music.rb +8 -0
- data/user-stories/done/melody-contour-guides.md +267 -0
- data/user-stories/done/scoring-architecture.md +352 -0
- data/user-stories/index.html +118 -34
- metadata +12 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7c29fa7f976d16091512b8bdc9eb8befcb4766c4bb70d387a67991e9b9897a5c
|
|
4
|
+
data.tar.gz: b43c0fed22c3ed7c79a7a0195847740ba7b3bea608a24921893a329f0c66ba7f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 47d395cc1709167182e0810c7383120395e927629a2e5e5076e197bdacfd377445546a842a032fecabd9865f62e878f969fec301535758072b45db229c80e8e4
|
|
7
|
+
data.tar.gz: '0950f0d5a6a2b830a1733d8d0a7d19eb9fcb87815e25d3e562dc6aa94bc95c16a2d7710ca31890e23d1dcc07fc64247030565741d2edf38da6cb177f7e091fb9'
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [15.0.0] - 2026-07-06
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- `HeadMusic::Style::Guidelines::MinimumMelodicIntervals` — a sufficiency gate on the number of moving melodic intervals, so a line that never (or barely) moves reads as a non-attempt rather than a flawed melody (`MinimumMelodicIntervals.with(2)`). The contour guides use it; `StaticContourMelody` omits it so a repeated single pitch remains a legitimate static contour.
|
|
15
|
+
- `weight:` and `gate:` options on `Annotation.with` — any ruleset entry can now carry a rubric weight or be marked as a gate, and `Configured#with` layers options so presets compose (e.g. `MinimumNotes.with(5).with(gate: true)`)
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
|
|
19
|
+
- **Breaking:** `Analysis#fitness` is now a gated weighted rubric instead of an unweighted geometric mean: the product of the gate fitnesses multiplies a weighted arithmetic mean of the remaining (rubric) rules. Every fitness value shifts numerically; downstream consumers that compare grades against stored thresholds must recalibrate.
|
|
20
|
+
- Non-attempts now grade zero: sufficiency guidelines (`MinimumNotes`, `MinimumMelodicIntervals`) act as graded gate multipliers, so an empty or insufficient line scales the whole grade down to 0 instead of averaging against the other rules.
|
|
21
|
+
- Contour guides weight `Contoured` at the inverse golden ratio (φ⁻¹ ≈ 0.618) with their ten rubric peers sharing φ⁻² evenly, so a wrong-contour but otherwise perfect line grades exactly ~0.618 (`HeadMusic::GOLDEN_RATIO_INVERSE`)
|
|
22
|
+
- `Diatonic` and `MaximumNotes` are rate-normalized (fitness raised to 1/note-count), so grades are length-invariant: the same violation rate scores the same in an eight-note line as in a sixteen-note line
|
|
23
|
+
- Broken-but-real work now lands on a deliberate soft floor (roughly 0.3–0.55): rate-normalized rules bottom out near φ⁻¹ and the arithmetic mean averages them, so a gate-passing melody that breaks most of the rubric grades substantially below perfect without collapsing toward the gated zero of a non-attempt
|
|
24
|
+
|
|
25
|
+
## [14.0.0] - 2026-07-05
|
|
26
|
+
|
|
27
|
+
### Added
|
|
28
|
+
|
|
29
|
+
- `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.
|
|
30
|
+
- Six contour guides subclassing `Guides::DiatonicMelody`, each appending the configured `Contoured` guideline to the inherited ruleset: `ArchContourMelody`, `AscendingContourMelody`, `DescendingContourMelody`, `StaticContourMelody`, `ValleyContourMelody`, `WaveContourMelody`
|
|
31
|
+
- Contour judgments deliberately complement `ConsonantClimax`: an arch requires only an interior climax pitch level, leaving climax uniqueness and consonance to the existing guideline
|
|
32
|
+
|
|
10
33
|
## [13.0.0] - 2026-07-05
|
|
11
34
|
|
|
12
35
|
### Added
|
data/Gemfile.lock
CHANGED
|
@@ -19,19 +19,34 @@ class HeadMusic::Style::Analysis
|
|
|
19
19
|
@annotations ||= @guide.analyze(voice)
|
|
20
20
|
end
|
|
21
21
|
|
|
22
|
+
# The grade: sufficiency gates multiply against a weighted average of the
|
|
23
|
+
# rubric rules, so an insufficient exercise scales the whole grade down
|
|
24
|
+
# while ordinary rules trade off against each other by weight.
|
|
22
25
|
def fitness
|
|
23
26
|
return 1.0 if annotations.empty?
|
|
24
27
|
|
|
25
|
-
@fitness ||=
|
|
28
|
+
@fitness ||= gate_factor * rubric_fitness
|
|
26
29
|
end
|
|
27
30
|
|
|
28
31
|
def adherent?
|
|
29
|
-
|
|
32
|
+
annotations.all?(&:adherent?)
|
|
30
33
|
end
|
|
31
34
|
|
|
32
35
|
private
|
|
33
36
|
|
|
34
|
-
def
|
|
35
|
-
|
|
37
|
+
def gate_factor
|
|
38
|
+
gates.map(&:fitness).reduce(1, :*)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def rubric_fitness
|
|
42
|
+
rubric = annotations.reject(&:gate?)
|
|
43
|
+
total_weight = rubric.sum(&:weight)
|
|
44
|
+
return 1.0 if rubric.empty? || total_weight.zero?
|
|
45
|
+
|
|
46
|
+
rubric.sum { |annotation| annotation.weight * annotation.fitness } / total_weight
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def gates
|
|
50
|
+
annotations.select(&:gate?)
|
|
36
51
|
end
|
|
37
52
|
end
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
class HeadMusic::Style::Annotation
|
|
3
3
|
MESSAGE = "Write music."
|
|
4
4
|
|
|
5
|
+
DEFAULT_WEIGHT = 1.0
|
|
6
|
+
|
|
5
7
|
attr_reader :voice
|
|
6
8
|
|
|
7
9
|
delegate(
|
|
@@ -34,6 +36,14 @@ class HeadMusic::Style::Annotation
|
|
|
34
36
|
Configured.new(self, options)
|
|
35
37
|
end
|
|
36
38
|
|
|
39
|
+
def self.default_weight
|
|
40
|
+
DEFAULT_WEIGHT
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def self.default_gate?
|
|
44
|
+
false
|
|
45
|
+
end
|
|
46
|
+
|
|
37
47
|
# A RULESET entry pairing a guideline class with configuration. Quacks like a
|
|
38
48
|
# class to the analyze loop by responding to #new(voice).
|
|
39
49
|
class Configured
|
|
@@ -48,6 +58,19 @@ class HeadMusic::Style::Annotation
|
|
|
48
58
|
guideline_class.new(voice, **options)
|
|
49
59
|
end
|
|
50
60
|
|
|
61
|
+
# Layers additional options onto an already-configured entry, e.g.
|
|
62
|
+
# MinimumNotes.with(5).with(gate: true), without dropping prior options.
|
|
63
|
+
def with(**more)
|
|
64
|
+
Configured.new(guideline_class, options.merge(more))
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Mirrors the class-level predicate so build-time RULESET filters can
|
|
68
|
+
# classify any entry (bare class or configured) uniformly. A per-entry
|
|
69
|
+
# gate: option takes precedence over the guideline class's default.
|
|
70
|
+
def default_gate?
|
|
71
|
+
options.fetch(:gate, guideline_class.default_gate?)
|
|
72
|
+
end
|
|
73
|
+
|
|
51
74
|
def name
|
|
52
75
|
guideline_class.name
|
|
53
76
|
end
|
|
@@ -56,13 +79,24 @@ class HeadMusic::Style::Annotation
|
|
|
56
79
|
end
|
|
57
80
|
|
|
58
81
|
def fitness
|
|
59
|
-
[marks].flatten.compact.map(&:fitness)
|
|
82
|
+
mark_fitnesses = [marks].flatten.compact.map(&:fitness)
|
|
83
|
+
return 1.0 if mark_fitnesses.empty?
|
|
84
|
+
|
|
85
|
+
mark_fitnesses.reduce(1, :*)**(1.0 / [fitness_denominator, 1].max)
|
|
60
86
|
end
|
|
61
87
|
|
|
62
88
|
def adherent?
|
|
63
89
|
fitness == 1
|
|
64
90
|
end
|
|
65
91
|
|
|
92
|
+
def weight
|
|
93
|
+
options.fetch(:weight, self.class.default_weight)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def gate?
|
|
97
|
+
options.fetch(:gate, self.class.default_gate?)
|
|
98
|
+
end
|
|
99
|
+
|
|
66
100
|
def has_notes?
|
|
67
101
|
!!first_note
|
|
68
102
|
end
|
|
@@ -91,6 +125,13 @@ class HeadMusic::Style::Annotation
|
|
|
91
125
|
|
|
92
126
|
attr_reader :options
|
|
93
127
|
|
|
128
|
+
# Normalization rate for the product of mark fitnesses. Subclasses override
|
|
129
|
+
# (e.g. with an opportunity count) to score by violation rate rather than
|
|
130
|
+
# raw violation count. The default of 1 preserves the raw product.
|
|
131
|
+
def fitness_denominator
|
|
132
|
+
1
|
|
133
|
+
end
|
|
134
|
+
|
|
94
135
|
def voices
|
|
95
136
|
@voices ||= voice.composition.voices
|
|
96
137
|
end
|
|
@@ -0,0 +1,140 @@
|
|
|
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
|
+
DEFAULT_WEIGHT = HeadMusic::GOLDEN_RATIO_INVERSE
|
|
12
|
+
|
|
13
|
+
def self.with(contour_key, **options)
|
|
14
|
+
contour = contour_key.to_s.downcase.to_sym
|
|
15
|
+
unless CONTOURS.include?(contour)
|
|
16
|
+
raise ArgumentError, "Contour must be one of: #{CONTOURS.join(", ")} (got #{contour_key.inspect})"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
super(contour: contour, **options)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def self.default_weight
|
|
23
|
+
DEFAULT_WEIGHT
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def marks
|
|
27
|
+
return if notes.empty? || matches_contour?
|
|
28
|
+
|
|
29
|
+
HeadMusic::Style::Mark.for_all(notes, fitness: HeadMusic::GOLDEN_RATIO_INVERSE**2)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def message
|
|
33
|
+
"Write a melody with the #{contour} contour."
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
# Validated again here because Contoured.new(voice, contour: :bogus) bypasses .with.
|
|
39
|
+
def contour
|
|
40
|
+
@contour ||= begin
|
|
41
|
+
key = options.fetch(:contour)
|
|
42
|
+
contour = key.to_s.downcase.to_sym
|
|
43
|
+
unless CONTOURS.include?(contour)
|
|
44
|
+
raise ArgumentError, "Contour must be one of: #{CONTOURS.join(", ")} (got #{key.inspect})"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
contour
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def matches_contour?
|
|
52
|
+
send("#{contour}?")
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def ascending?
|
|
56
|
+
first_note.pitch == lowest_pitch && last_note.pitch == highest_pitch
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def descending?
|
|
60
|
+
first_note.pitch == highest_pitch && last_note.pitch == lowest_pitch
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# The climax is by definition the maximum, so "net rise before, net fall after"
|
|
64
|
+
# is equivalent to both endpoints sitting below the climax pitch.
|
|
65
|
+
# Climax uniqueness and consonance remain ConsonantClimax's job.
|
|
66
|
+
def arch?
|
|
67
|
+
notes.length >= 3 && first_note.pitch < highest_pitch && last_note.pitch < highest_pitch
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def valley?
|
|
71
|
+
notes.length >= 3 && first_note.pitch > lowest_pitch && last_note.pitch > lowest_pitch
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def wave?
|
|
75
|
+
trend_directions.length >= 3
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def static?
|
|
79
|
+
range <= HeadMusic::Analysis::DiatonicInterval.get(:major_third) && !directional_endpoints?
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# The highest_pitch > lowest_pitch guard is load-bearing: without it, an
|
|
83
|
+
# all-same-pitch melody (first == lowest and last == highest simultaneously)
|
|
84
|
+
# would absurdly fail static.
|
|
85
|
+
def directional_endpoints?
|
|
86
|
+
highest_pitch > lowest_pitch &&
|
|
87
|
+
((first_note.pitch == lowest_pitch && last_note.pitch == highest_pitch) ||
|
|
88
|
+
(first_note.pitch == highest_pitch && last_note.pitch == lowest_pitch))
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def pitch_numbers
|
|
92
|
+
@pitch_numbers ||= notes.map { |note| note.pitch.midi_note_number }
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Zigzag walk: a trend reversal is confirmed only when the melody retraces at
|
|
96
|
+
# least TREND_REVERSAL_SEMITONES from the running extreme of the current trend,
|
|
97
|
+
# so stepwise neighbor-note undulation never registers as a trend change.
|
|
98
|
+
def trend_directions
|
|
99
|
+
@trend_directions ||= begin
|
|
100
|
+
directions = []
|
|
101
|
+
direction = nil
|
|
102
|
+
high = low = pitch_numbers.first
|
|
103
|
+
extreme = nil
|
|
104
|
+
pitch_numbers.drop(1).each do |number|
|
|
105
|
+
case direction
|
|
106
|
+
when nil # no trend confirmed yet
|
|
107
|
+
if number - low >= TREND_REVERSAL_SEMITONES
|
|
108
|
+
direction = :ascending
|
|
109
|
+
extreme = number
|
|
110
|
+
directions << direction
|
|
111
|
+
elsif high - number >= TREND_REVERSAL_SEMITONES
|
|
112
|
+
direction = :descending
|
|
113
|
+
extreme = number
|
|
114
|
+
directions << direction
|
|
115
|
+
else
|
|
116
|
+
high = [high, number].max
|
|
117
|
+
low = [low, number].min
|
|
118
|
+
end
|
|
119
|
+
when :ascending
|
|
120
|
+
if number > extreme
|
|
121
|
+
extreme = number
|
|
122
|
+
elsif extreme - number >= TREND_REVERSAL_SEMITONES
|
|
123
|
+
direction = :descending
|
|
124
|
+
extreme = number
|
|
125
|
+
directions << direction
|
|
126
|
+
end
|
|
127
|
+
when :descending
|
|
128
|
+
if number < extreme
|
|
129
|
+
extreme = number
|
|
130
|
+
elsif number - extreme >= TREND_REVERSAL_SEMITONES
|
|
131
|
+
direction = :ascending
|
|
132
|
+
extreme = number
|
|
133
|
+
directions << direction
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
directions
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
@@ -9,6 +9,14 @@ class HeadMusic::Style::Guidelines::Diatonic < HeadMusic::Style::Annotation
|
|
|
9
9
|
HeadMusic::Style::Mark.for_each(notes_not_in_key_excluding_penultimate_leading_tone)
|
|
10
10
|
end
|
|
11
11
|
|
|
12
|
+
protected
|
|
13
|
+
|
|
14
|
+
# Score by the rate of out-of-key notes rather than the raw count,
|
|
15
|
+
# so fitness is invariant to melody length.
|
|
16
|
+
def fitness_denominator
|
|
17
|
+
notes.length
|
|
18
|
+
end
|
|
19
|
+
|
|
12
20
|
private
|
|
13
21
|
|
|
14
22
|
def notes_not_in_key_excluding_penultimate_leading_tone
|
|
@@ -4,8 +4,8 @@ module HeadMusic::Style::Guidelines; end
|
|
|
4
4
|
# Flags a melody with more than the allowed number of notes.
|
|
5
5
|
# Configure the threshold with the factory, e.g. MaximumNotes.with(14).
|
|
6
6
|
class HeadMusic::Style::Guidelines::MaximumNotes < HeadMusic::Style::Annotation
|
|
7
|
-
def self.with(maximum)
|
|
8
|
-
super(maximum: maximum)
|
|
7
|
+
def self.with(maximum, **options)
|
|
8
|
+
super(maximum: maximum, **options)
|
|
9
9
|
end
|
|
10
10
|
|
|
11
11
|
def marks
|
|
@@ -16,6 +16,14 @@ class HeadMusic::Style::Guidelines::MaximumNotes < HeadMusic::Style::Annotation
|
|
|
16
16
|
"Write up to #{maximum.humanize} notes."
|
|
17
17
|
end
|
|
18
18
|
|
|
19
|
+
protected
|
|
20
|
+
|
|
21
|
+
# Score by the rate of overage notes rather than the raw count,
|
|
22
|
+
# so fitness is invariant to melody length.
|
|
23
|
+
def fitness_denominator
|
|
24
|
+
notes.length
|
|
25
|
+
end
|
|
26
|
+
|
|
19
27
|
private
|
|
20
28
|
|
|
21
29
|
def maximum
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# Module for style guidelines.
|
|
2
|
+
module HeadMusic::Style::Guidelines; end
|
|
3
|
+
|
|
4
|
+
# Flags a melody with fewer than the required number of moving melodic intervals.
|
|
5
|
+
# Repeated-note pairs don't count as motion, so an all-repeated-note line gates to 0.
|
|
6
|
+
# Configure the threshold with the factory, e.g. MinimumMelodicIntervals.with(2).
|
|
7
|
+
class HeadMusic::Style::Guidelines::MinimumMelodicIntervals < HeadMusic::Style::Annotation
|
|
8
|
+
def self.with(minimum, **options)
|
|
9
|
+
super(minimum: minimum, **options)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def self.default_gate?
|
|
13
|
+
true
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def marks
|
|
17
|
+
return no_motion_mark if moving_intervals.empty?
|
|
18
|
+
|
|
19
|
+
deficiency_mark
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def message
|
|
23
|
+
"Write at least #{minimum.humanize} melodic intervals."
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def minimum
|
|
29
|
+
options.fetch(:minimum)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def moving_intervals
|
|
33
|
+
melodic_intervals.select(&:moving?)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def no_motion_mark
|
|
37
|
+
return no_placements_mark if placements.empty?
|
|
38
|
+
|
|
39
|
+
HeadMusic::Style::Mark.for_all(placements, fitness: 0)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def no_placements_mark
|
|
43
|
+
HeadMusic::Style::Mark.new(
|
|
44
|
+
HeadMusic::Content::Position.new(composition, "1:1"),
|
|
45
|
+
HeadMusic::Content::Position.new(composition, "2:1"),
|
|
46
|
+
fitness: 0
|
|
47
|
+
)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def deficiency_mark
|
|
51
|
+
return unless moving_intervals.length < minimum
|
|
52
|
+
|
|
53
|
+
HeadMusic::Style::Mark.for_all(placements, fitness: moving_intervals.length.to_f / minimum)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -4,8 +4,12 @@ module HeadMusic::Style::Guidelines; end
|
|
|
4
4
|
# Flags a melody with fewer than the required number of notes.
|
|
5
5
|
# Configure the threshold with the factory, e.g. MinimumNotes.with(8).
|
|
6
6
|
class HeadMusic::Style::Guidelines::MinimumNotes < HeadMusic::Style::Annotation
|
|
7
|
-
def self.with(minimum)
|
|
8
|
-
super(minimum: minimum)
|
|
7
|
+
def self.with(minimum, **options)
|
|
8
|
+
super(minimum: minimum, **options)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def self.default_gate?
|
|
12
|
+
true
|
|
9
13
|
end
|
|
10
14
|
|
|
11
15
|
def marks
|
|
@@ -18,4 +18,28 @@ class HeadMusic::Style::Guides::DiatonicMelody < HeadMusic::Style::Guides::Speci
|
|
|
18
18
|
HeadMusic::Style::Guidelines::MinimumNotes.with(5),
|
|
19
19
|
HeadMusic::Style::Guidelines::MaximumNotes.with(32)
|
|
20
20
|
].freeze
|
|
21
|
+
|
|
22
|
+
# The non-gate peers of a contour guide share phi^-2 of rubric weight, so
|
|
23
|
+
# that with Contoured at its default weight of phi^-1 (and phi^-1 + phi^-2
|
|
24
|
+
# = 1), a wrong contour on an otherwise perfect line grades exactly phi^-1.
|
|
25
|
+
CONTOUR_PEER_WEIGHT_BUDGET = HeadMusic::GOLDEN_RATIO_INVERSE**2
|
|
26
|
+
|
|
27
|
+
# Builds a contour guide's ruleset from this guide's rules: gate entries
|
|
28
|
+
# pass through unchanged, non-gate peers split the peer weight budget
|
|
29
|
+
# evenly, and the contour guideline is appended at its own default weight.
|
|
30
|
+
# An optional motion gate excludes non-attempts (nil omits it, so a static
|
|
31
|
+
# contour can legitimately repeat a single pitch).
|
|
32
|
+
def self.contour_ruleset(contour_key, minimum_melodic_intervals: nil)
|
|
33
|
+
gates, peers = RULESET.partition(&:default_gate?)
|
|
34
|
+
peer_weight = CONTOUR_PEER_WEIGHT_BUDGET / peers.length
|
|
35
|
+
motion_gate =
|
|
36
|
+
minimum_melodic_intervals &&
|
|
37
|
+
HeadMusic::Style::Guidelines::MinimumMelodicIntervals.with(minimum_melodic_intervals)
|
|
38
|
+
[
|
|
39
|
+
*gates,
|
|
40
|
+
motion_gate,
|
|
41
|
+
*peers.map { |rule| rule.with(weight: peer_weight) },
|
|
42
|
+
HeadMusic::Style::Guidelines::Contoured.with(contour_key)
|
|
43
|
+
].compact.freeze
|
|
44
|
+
end
|
|
21
45
|
end
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
# A free diatonic melody with a static contour (narrow range, non-directional endpoints).
|
|
2
|
+
# No motion gate: an all-repeated-note line is a legitimate static contour.
|
|
3
|
+
class HeadMusic::Style::Guides::StaticContourMelody < HeadMusic::Style::Guides::DiatonicMelody
|
|
4
|
+
RULESET = contour_ruleset(:static)
|
|
5
|
+
end
|
data/lib/head_music/version.rb
CHANGED
data/lib/head_music.rb
CHANGED
|
@@ -154,11 +154,13 @@ require "head_music/style/mark"
|
|
|
154
154
|
# style guidelines
|
|
155
155
|
require "head_music/style/guidelines/always_move"
|
|
156
156
|
require "head_music/style/guidelines/approach_perfection_contrarily"
|
|
157
|
+
require "head_music/style/guidelines/minimum_melodic_intervals"
|
|
157
158
|
require "head_music/style/guidelines/minimum_notes"
|
|
158
159
|
require "head_music/style/guidelines/avoid_crossing_voices"
|
|
159
160
|
require "head_music/style/guidelines/avoid_overlapping_voices"
|
|
160
161
|
require "head_music/style/guidelines/consonant_climax"
|
|
161
162
|
require "head_music/style/guidelines/consonant_downbeats"
|
|
163
|
+
require "head_music/style/guidelines/contoured"
|
|
162
164
|
require "head_music/style/guidelines/diatonic"
|
|
163
165
|
require "head_music/style/guidelines/direction_changes"
|
|
164
166
|
require "head_music/style/guidelines/end_on_perfect_consonance"
|
|
@@ -219,6 +221,12 @@ require "head_music/style/guides/fux_cantus_firmus"
|
|
|
219
221
|
require "head_music/style/guides/salzer_schachter_cantus_firmus"
|
|
220
222
|
require "head_music/style/guides/first_species_melody"
|
|
221
223
|
require "head_music/style/guides/diatonic_melody"
|
|
224
|
+
require "head_music/style/guides/arch_contour_melody"
|
|
225
|
+
require "head_music/style/guides/ascending_contour_melody"
|
|
226
|
+
require "head_music/style/guides/descending_contour_melody"
|
|
227
|
+
require "head_music/style/guides/static_contour_melody"
|
|
228
|
+
require "head_music/style/guides/valley_contour_melody"
|
|
229
|
+
require "head_music/style/guides/wave_contour_melody"
|
|
222
230
|
require "head_music/style/guides/species_harmony"
|
|
223
231
|
require "head_music/style/guides/first_species_harmony"
|
|
224
232
|
require "head_music/style/guides/second_species_melody"
|