head_music 19.0.0 → 20.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/.github/dependabot.yml +4 -0
- data/CHANGELOG.md +173 -4
- data/CLAUDE.md +1 -1
- data/Gemfile +6 -2
- data/Gemfile.lock +4 -5
- data/README.md +53 -4
- data/Rakefile +23 -0
- data/bin/guide_grade_corpus.rb +124 -0
- data/bin/guide_grade_table.rb +357 -0
- data/bin/guide_item_strings.rb +35 -0
- data/lib/head_music/analysis/diatonic_interval/consonance_questions.rb +43 -0
- data/lib/head_music/analysis/diatonic_interval/inversion.rb +31 -0
- data/lib/head_music/analysis/diatonic_interval/localization.rb +38 -0
- data/lib/head_music/analysis/diatonic_interval/parser.rb +20 -1
- data/lib/head_music/analysis/diatonic_interval.rb +24 -69
- data/lib/head_music/content/composition.rb +4 -0
- data/lib/head_music/content/voice/continuity.rb +47 -0
- data/lib/head_music/content/voice/melodic_line.rb +51 -4
- data/lib/head_music/content/voice.rb +23 -74
- data/lib/head_music/locales/de.yml +156 -0
- data/lib/head_music/locales/en.yml +431 -3
- data/lib/head_music/locales/en_GB.yml +168 -0
- data/lib/head_music/locales/es.yml +145 -0
- data/lib/head_music/locales/fr.yml +149 -0
- data/lib/head_music/locales/it.yml +144 -0
- data/lib/head_music/locales/ru.yml +173 -0
- data/lib/head_music/notation/abc/writer.rb +2 -22
- data/lib/head_music/notation/{music_xml/clef_selector.rb → clef_selector.rb} +2 -2
- data/lib/head_music/notation/lily_pond/duration_writer.rb +32 -0
- data/lib/head_music/notation/lily_pond/key_mapper.rb +38 -0
- data/lib/head_music/notation/lily_pond/pitch_writer.rb +36 -0
- data/lib/head_music/notation/lily_pond/preflight.rb +64 -0
- data/lib/head_music/notation/lily_pond/render_plan.rb +94 -0
- data/lib/head_music/notation/lily_pond/string_text.rb +12 -0
- data/lib/head_music/notation/lily_pond/writer.rb +127 -0
- data/lib/head_music/notation/lily_pond.rb +14 -0
- data/lib/head_music/notation/music_xml/duration_writer.rb +1 -7
- data/lib/head_music/notation/music_xml/preflight.rb +7 -42
- data/lib/head_music/notation/music_xml/render_plan.rb +1 -2
- data/lib/head_music/notation/music_xml/writer.rb +1 -1
- data/lib/head_music/notation/preflight_checks.rb +40 -0
- data/lib/head_music/notation.rb +2 -0
- data/lib/head_music/rudiment/base.rb +21 -0
- data/lib/head_music/rudiment/chromatic_interval.rb +1 -2
- data/lib/head_music/rudiment/consonance.rb +1 -2
- data/lib/head_music/rudiment/key_signature.rb +1 -3
- data/lib/head_music/rudiment/letter_name.rb +2 -4
- data/lib/head_music/rudiment/meter.rb +1 -2
- data/lib/head_music/rudiment/note.rb +1 -2
- data/lib/head_music/rudiment/pitch/arithmetic.rb +46 -0
- data/lib/head_music/rudiment/pitch/helmholtz_notation.rb +24 -0
- data/lib/head_music/rudiment/pitch/natural_letter_pitch.rb +35 -0
- data/lib/head_music/rudiment/pitch/natural_step.rb +7 -2
- data/lib/head_music/rudiment/pitch/step_distance.rb +36 -0
- data/lib/head_music/rudiment/pitch.rb +24 -66
- data/lib/head_music/rudiment/pitch_class.rb +1 -2
- data/lib/head_music/rudiment/qualified_diatonic_context.rb +1 -2
- data/lib/head_music/rudiment/quality.rb +1 -2
- data/lib/head_music/rudiment/register.rb +3 -5
- data/lib/head_music/rudiment/rest.rb +1 -3
- data/lib/head_music/rudiment/rhythmic_value.rb +8 -0
- data/lib/head_music/rudiment/scale.rb +1 -2
- data/lib/head_music/rudiment/scale_type.rb +1 -2
- data/lib/head_music/rudiment/spelling.rb +13 -5
- data/lib/head_music/rudiment/unpitched_note.rb +1 -2
- data/lib/head_music/style/composite_assessment.rb +97 -0
- data/lib/head_music/style/guide.rb +51 -29
- data/lib/head_music/style/guide_assessment.rb +138 -0
- data/lib/head_music/style/guide_item.rb +118 -0
- data/lib/head_music/style/guide_item_assessment.rb +63 -0
- data/lib/head_music/style/guideline/harmonic_context.rb +40 -0
- data/lib/head_music/style/guideline/melodic_context.rb +44 -0
- data/lib/head_music/style/guideline/strength.rb +71 -0
- data/lib/head_music/style/guideline/voice_context.rb +41 -0
- data/lib/head_music/style/guideline/wording.rb +79 -0
- data/lib/head_music/style/guideline.rb +128 -0
- data/lib/head_music/style/guidelines/{allowed_rhythmic_values_for_fifth_species.rb → allow_fifth_species_rhythmic_values.rb} +1 -4
- data/lib/head_music/style/guidelines/{allowed_rhythmic_values_for_combined123.rb → allow_whole_half_quarter_notes.rb} +2 -4
- data/lib/head_music/style/guidelines/always_move.rb +1 -3
- data/lib/head_music/style/guidelines/approach_perfection_contrarily.rb +1 -3
- data/lib/head_music/style/guidelines/avoid_crossing_voices.rb +1 -3
- data/lib/head_music/style/guidelines/avoid_overlapping_voices.rb +1 -3
- data/lib/head_music/style/guidelines/consonant_climax.rb +23 -2
- data/lib/head_music/style/guidelines/consonant_downbeats.rb +1 -3
- data/lib/head_music/style/guidelines/contoured.rb +11 -24
- data/lib/head_music/style/guidelines/diatonic.rb +1 -3
- data/lib/head_music/style/guidelines/direction_changes.rb +1 -3
- data/lib/head_music/style/guidelines/directional_step_to_final_note.rb +1 -1
- data/lib/head_music/style/guidelines/dissonance_figure_detection.rb +2 -7
- data/lib/head_music/style/guidelines/end_on_perfect_consonance.rb +1 -3
- data/lib/head_music/style/guidelines/end_on_tonic.rb +1 -3
- data/lib/head_music/style/guidelines/first_bar_entry.rb +1 -1
- data/lib/head_music/style/guidelines/first_bar_half_notes.rb +0 -2
- data/lib/head_music/style/guidelines/first_bar_quarter_notes.rb +0 -2
- data/lib/head_music/style/guidelines/first_bar_whole_note.rb +0 -2
- data/lib/head_music/style/guidelines/florid_dissonance_treatment.rb +1 -4
- data/lib/head_music/style/guidelines/frequent_direction_changes.rb +4 -1
- data/lib/head_music/style/guidelines/large_leaps.rb +12 -6
- data/lib/head_music/style/guidelines/limit_octave_leaps.rb +8 -3
- data/lib/head_music/style/guidelines/maximum_notes.rb +6 -3
- data/lib/head_music/style/guidelines/minimum_melodic_intervals.rb +5 -2
- data/lib/head_music/style/guidelines/minimum_notes.rb +5 -2
- data/lib/head_music/style/guidelines/minimum_threshold.rb +1 -13
- data/lib/head_music/style/guidelines/mixed_rhythmic_values.rb +1 -3
- data/lib/head_music/style/guidelines/moderate_direction_changes.rb +4 -1
- data/lib/head_music/style/guidelines/mostly_conjunct.rb +9 -3
- data/lib/head_music/style/guidelines/no_parallel_perfect.rb +1 -1
- data/lib/head_music/style/guidelines/no_parallel_perfect_across_barline.rb +1 -3
- data/lib/head_music/style/guidelines/no_parallel_perfect_on_downbeats.rb +0 -2
- data/lib/head_music/style/guidelines/no_parallel_perfect_with_syncopation.rb +0 -2
- data/lib/head_music/style/guidelines/no_rests.rb +1 -3
- data/lib/head_music/style/guidelines/no_rests_after_note.rb +1 -3
- data/lib/head_music/style/guidelines/no_strong_beat_unisons.rb +1 -3
- data/lib/head_music/style/guidelines/no_unisons_in_middle.rb +1 -3
- data/lib/head_music/style/guidelines/note_count_per_bar.rb +18 -3
- data/lib/head_music/style/guidelines/note_fills_final_bar.rb +1 -3
- data/lib/head_music/style/guidelines/notes_same_length.rb +1 -3
- data/lib/head_music/style/guidelines/one_to_one.rb +1 -3
- data/lib/head_music/style/guidelines/one_to_one_with_ties.rb +1 -3
- data/lib/head_music/style/guidelines/prefer_contrary_motion.rb +2 -2
- data/lib/head_music/style/guidelines/prefer_imperfect.rb +2 -2
- data/lib/head_music/style/guidelines/prepare_octave_leaps.rb +2 -2
- data/lib/head_music/style/guidelines/second_species_break.rb +0 -2
- data/lib/head_music/style/guidelines/set_against_another_voice.rb +22 -0
- data/lib/head_music/style/guidelines/singable_intervals.rb +22 -23
- data/lib/head_music/style/guidelines/singable_range.rb +7 -12
- data/lib/head_music/style/guidelines/start_on_perfect_consonance.rb +1 -3
- data/lib/head_music/style/guidelines/start_on_tonic.rb +1 -3
- data/lib/head_music/style/guidelines/step_down_to_final_note.rb +0 -2
- data/lib/head_music/style/guidelines/step_out_of_unison.rb +1 -3
- data/lib/head_music/style/guidelines/step_to_final_note.rb +1 -3
- data/lib/head_music/style/guidelines/step_up_to_final_note.rb +0 -2
- data/lib/head_music/style/guidelines/suspension_treatment.rb +1 -3
- data/lib/head_music/style/guidelines/third_species_dissonance_treatment.rb +0 -2
- data/lib/head_music/style/guidelines/triple_meter_dissonance_treatment.rb +0 -2
- data/lib/head_music/style/guidelines/weak_beat_dissonance_treatment.rb +1 -3
- data/lib/head_music/style/guides/assessment.rb +22 -0
- data/lib/head_music/style/guides/base.rb +132 -33
- data/lib/head_music/style/guides/composite_guide.rb +134 -0
- data/lib/head_music/style/guides/configured.rb +35 -13
- data/lib/head_music/style/guides/contour_melody.rb +26 -41
- data/lib/head_music/style/guides/diatonic_melody.rb +10 -3
- data/lib/head_music/style/guides/fifth_species_harmony.rb +10 -5
- data/lib/head_music/style/guides/fifth_species_melody.rb +6 -2
- data/lib/head_music/style/guides/first_species_harmony.rb +18 -4
- data/lib/head_music/style/guides/first_species_melody.rb +12 -4
- data/lib/head_music/style/guides/first_three_species_harmony.rb +14 -0
- data/lib/head_music/style/guides/{combined_first_second_third_species_melody.rb → first_three_species_melody.rb} +8 -5
- data/lib/head_music/style/guides/fourth_species_harmony.rb +10 -5
- data/lib/head_music/style/guides/fourth_species_melody.rb +5 -3
- data/lib/head_music/style/guides/fux_cantus_firmus.rb +8 -3
- data/lib/head_music/style/guides/salzer_schachter_cantus_firmus.rb +7 -2
- data/lib/head_music/style/guides/second_species_harmony.rb +5 -3
- data/lib/head_music/style/guides/second_species_melody.rb +5 -1
- data/lib/head_music/style/guides/species_harmony.rb +57 -11
- data/lib/head_music/style/guides/species_melody.rb +24 -11
- data/lib/head_music/style/guides/third_species_harmony.rb +5 -3
- data/lib/head_music/style/guides/third_species_melody.rb +5 -1
- data/lib/head_music/style/guides/third_species_triple_meter_harmony.rb +5 -3
- data/lib/head_music/style/guides/third_species_triple_meter_melody.rb +5 -1
- data/lib/head_music/style/mark.rb +1 -1
- data/lib/head_music/style/template.rb +161 -0
- data/lib/head_music/version.rb +1 -1
- data/lib/head_music.rb +32 -7
- data/references/fifth-species-counterpoint.md +4 -4
- data/references/fourth-species-counterpoint.md +22 -5
- data/references/note-values-by-language.md +173 -0
- data/references/third-species-counterpoint.md +7 -7
- data/user-stories/backlog/lilypond-interpreter.md +3 -1
- data/user-stories/backlog/organizing-content.md +7 -4
- data/user-stories/backlog/tell-the-species-apart.md +136 -0
- data/user-stories/done/british-note-names.md +834 -0
- data/user-stories/done/composite-guides.grades.md +308 -0
- data/user-stories/done/composite-guides.md +1138 -0
- data/user-stories/done/extract-the-harmonic-cores.grades.md +1073 -0
- data/user-stories/done/extract-the-harmonic-cores.md +1213 -0
- data/user-stories/done/first-class-guide-items.md +852 -0
- data/user-stories/done/guard-the-vocabulary-sweep-itself.md +600 -0
- data/user-stories/done/guideline-strings-into-i18n.md +931 -0
- data/user-stories/done/lilypond-export.md +260 -0
- data/user-stories/done/note-values-in-each-language.md +661 -0
- data/user-stories/done/re-tier-the-guides.grades.md +1979 -0
- data/user-stories/done/re-tier-the-guides.md +753 -0
- data/user-stories/done/release-notes-for-19-0-0.md +279 -0
- data/user-stories/done/rename-annotation-to-guideline.md +567 -0
- data/user-stories/done/species-guide-harmonic-weights.grades.md +353 -0
- data/user-stories/done/species-guide-harmonic-weights.md +599 -0
- data/user-stories/epics/style-assessment-model.md +386 -0
- data/user-stories/index.html +83 -11
- metadata +60 -11
- data/lib/head_music/style/analysis.rb +0 -59
- data/lib/head_music/style/annotation/configured.rb +0 -35
- data/lib/head_music/style/annotation.rb +0 -193
- data/lib/head_music/style/guides/combined_first_second_third_species_harmony.rb +0 -10
- data/user-stories/backlog/lilypond-export.md +0 -74
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e8a5d592c5994e59585e09db272d554d2aefcef0f337287ee0fd0206cbab498c
|
|
4
|
+
data.tar.gz: '0964c0d8e7ec6a753886ab34131705daacb51eefffc79f48d281ca6663099a4d'
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: dafb4e6534d8985a4563a650e141f61d652b40a76e7adf2d3ce7d8820ce5f41038dc1754f893b18756a2f30192813b8e752899656b6abab5bc5dd073a732abad
|
|
7
|
+
data.tar.gz: cdd300bd23ca8bbe011f7712385d7b7d80cebb99eae61281bc24049c01f535069116eff46a46fe296acf0c846f3a7e4a36d59562d5c03cb29a4ed4c7d5930ede
|
data/.github/dependabot.yml
CHANGED
|
@@ -37,6 +37,10 @@ updates:
|
|
|
37
37
|
update-types: ["version-update:semver-major"]
|
|
38
38
|
- dependency-name: "i18n"
|
|
39
39
|
update-types: ["version-update:semver-major"]
|
|
40
|
+
# simplecov 1.1.x is a SyntaxError on Ruby 3.3 (see the Gemfile comment).
|
|
41
|
+
# Dependabot rewrites a Gemfile constraint to get past it, so hold it here.
|
|
42
|
+
- dependency-name: "simplecov"
|
|
43
|
+
versions: [">= 1.1"]
|
|
40
44
|
|
|
41
45
|
# Enable version updates for GitHub Actions
|
|
42
46
|
- package-ecosystem: "github-actions"
|
data/CHANGELOG.md
CHANGED
|
@@ -5,7 +5,150 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
-
## [
|
|
8
|
+
## [20.0.0] - 2026-08-30
|
|
9
|
+
|
|
10
|
+
The [style assessment model](https://github.com/roberthead/head_music/tree/main/user-stories/epics/style-assessment-model.md) epic, released together. Five stories reshaped how a guide is declared, how it grades, what it says, and what a consumer asks for — so the breaking changes below are one migration rather than five. Two notation stories ride along: LilyPond export, and note values named in each reader's own language.
|
|
11
|
+
|
|
12
|
+
**Migrating from 19.0.0**, in the order a consumer will hit them:
|
|
13
|
+
|
|
14
|
+
1. `Style::Analysis` is `Style::GuideAssessment`; `Guide#analyze(voice)` is `#assess(voice)`; `Analysis#annotations` is `GuideAssessment#guide_item_assessments`. `Style::Annotation` is `Style::Guideline`, and `Annotation::Configured` is `Style::GuideItem`.
|
|
15
|
+
2. Ask for a species rather than pairing its halves: `Guide.get("first_species")` returns a composite that grades melody and harmony together and combines them geometrically. The seven composite keys are new; the two `combined_first_second_third_species_*` keys are now `first_three_species_*`.
|
|
16
|
+
3. Grades move. Re-tiering weighs what a guide teaches above the craft it inherits, so any stored fitness from 19.0.0 is not comparable to one from 20.0.0. Regrade rather than migrate.
|
|
17
|
+
4. Guideline strings are i18n templates. A consumer reading `MESSAGE` constants reads `GuideItemAssessment#message` instead.
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
|
|
21
|
+
- **Composite guides.** A species is a melody guide and a harmony guide, and a student submits one line to be judged by both. `Style::Guide.get("first_species")` now answers with a `Guides::CompositeGuide` over the two, and six siblings do the same: `second_species`, `third_species`, `third_species_triple_meter`, `fourth_species`, `fifth_species`, and `first_three_species`. `Guide.all` grows 23 → 30. Which two guides make up a species, and how their grades combine, is counterpoint pedagogy; it belongs here rather than in each consuming application.
|
|
22
|
+
|
|
23
|
+
A composite **composes grades, not items**. Merging its members' item lists cannot even be built — both members gate on `MinimumNotes.with(3)`, `GuideItem` equality is by value, and `Base.reject_duplicates` refuses the union — and would undo the tier budgets besides, putting nineteen primaries into one φ⁻¹ budget. The two levels grade by different arithmetic on purpose: rules inside a rubric trade off by weight, while a melody grade and a harmony grade must both hold.
|
|
24
|
+
|
|
25
|
+
- `Style::CompositeAssessment`, which a composite returns from `assess(voice)`. Its `fitness` is the **geometric mean** of its members' grades, so a perfect melody against a half-graded harmony reads 0.707 rather than 0.75, and either half at zero takes the whole grade to zero. `assessments` holds one `GuideAssessment` per member, and `fitness_by_category` splits the grade into the melody and harmony halves a consumer wants to show separately.
|
|
26
|
+
|
|
27
|
+
When any member is unassessable the composite is too, and it grades on its members' **gate factors alone** — `GuideAssessment`'s own rule with the nouns raised: one member failing a precondition means the composite has not earned a grade on the other members either.
|
|
28
|
+
|
|
29
|
+
- `GuideAssessment#assessments`, answering `[self]`, so a consumer walks a leaf assessment and a composite one the same way without asking which it holds. `GuideAssessment#fitness_by_category` answers the same shape, as one group of one.
|
|
30
|
+
|
|
31
|
+
- `GuideAssessment#gate_factor` is public, and returns a `Float` for a gate-less guide rather than the Integer `1` it used to compute internally. A composite reads it when a member is unassessable.
|
|
32
|
+
|
|
33
|
+
- `composite?` and `categories` on every guide. A composite spans its members' categories rather than claiming one, so its `category` is `nil` and a consumer grouping the registry by category gains a `nil` bucket; `categories` is what answers for it. A leaf answers `[category]`.
|
|
34
|
+
|
|
35
|
+
- The rubric gains a second axis, orthogonal to tier: **strength**. Within a tier, a `:strong` guideline weighs twice a `:weak` one, normalized by that tier's own total. `Guideline.strength` declares it — `strength :weak, because: "…"`, where the reason is required for `:weak` and refused for `:strong` — and it defaults to `:strong`, so the axis is inert until a guideline opts in. An all-strong rubric grades bit-identically to one with no strength axis at all.
|
|
36
|
+
|
|
37
|
+
Unlike tier, strength is a property of the guideline rather than of the list it was declared in: a preference is a preference in every guide that names it. It is never inherited by a subclass, because `WeakBeatDissonanceTreatment` bases two treatments that are the taught rule of their own guides. An item may override it — `Guideline.with(strength: :weak)` — for the tradition-dependent case, where `ApproachPerfectionContrarily` is prohibited in Fux and merely cautioned later.
|
|
38
|
+
|
|
39
|
+
Eight guidelines are classified `:weak`: `FrequentDirectionChanges`, `LargeLeaps`, `LimitOctaveLeaps`, `ModerateDirectionChanges`, `MostlyConjunct`, `PreferContraryMotion`, `PreferImperfect`, and `PrepareOctaveLeaps`.
|
|
40
|
+
|
|
41
|
+
- `GuideItem#strength` and `GuideItemAssessment#strength`. The assessment's is keyword-defaulted from the item rather than required, so existing direct-construction sites keep working, and validated there as well, since it is a seam a caller can reach without going through `GuideItem`; it is stamped rather than delegated so that re-classifying a guideline later cannot silently rewrite a persisted grade.
|
|
42
|
+
|
|
43
|
+
- `HeadMusic::Style::Guidelines::SetAgainstAnotherVoice` — the definitional precondition of a harmony guide: counterpoint is a relationship between voices, and a voice alone has no harmony to assess.
|
|
44
|
+
|
|
45
|
+
- `HeadMusic::Style::Template` — renders every customer-facing string in the style module, and refuses the four ways I18n fails quietly: a template rendered with no values keeps its `%{}` without raising, a value named for a reserved key hijacks the lookup, a missing key resolves to "Translation missing: …", and a word passed as `count` silently selects a plural. Every render passes `raise: true` and is checked for a surviving interpolation.
|
|
46
|
+
|
|
47
|
+
`Template.verify!` runs at load over the whole registry — twenty-three guide instructions, and every template the sixty-seven guide items can render, including the violation branches a guideline chooses between — so a missing entry stops `require` rather than reaching a student. It runs in English deliberately: a host application's locale must not decide whether the gem loads.
|
|
48
|
+
|
|
49
|
+
Where a locale has no plural data, `Template.pluralize` falls back to Ruby rather than raising, and records the key it fell back for.
|
|
50
|
+
|
|
51
|
+
- British spellings for the five style strings that have them — `neighbour`, `metre`, and a bar rather than a measure. `en_GB` sits mid-chain, so German, French, Italian and Russian pick these up on the way to `en`. Note that any pluralized `en_GB` entry must carry the complete set of forms: I18n stops at a plural hash that is present but incomplete rather than continuing past it, so a partial one would raise for those four languages and never for a British reader.
|
|
52
|
+
|
|
53
|
+
- **Note values in each language.** German, Spanish, French, Italian and Russian name note values as their own teachers do — *Viertel*, *negra*, *noire*, *semiminima*, *четвертная* — rather than inheriting British words on the way to `en`. No single English serves all four inheritors: the vocabulary splits into fractional, mensural-Latin, and shape families, and French *croche* is the **eighth** where its cognate *crotchet* is the quarter, so borrowed English actively misleads. The words, their derivation rules, plural behaviour, and the sources that disagree live in `references/note-values-by-language.md`.
|
|
54
|
+
|
|
55
|
+
The vocabulary itself lives under `head_music.rudiments`, beside the `rhythmic_unit` label already there, in three groups of eleven units each — `maxima` down to `hundred_twenty_eighth`: `rhythmic_units` (the bare unit, pluralizable, counted by `note_count_per_bar`), `note_values`, and `rest_values`. The three do not share a shape everywhere — a British note value drops the noun (*a crotchet*, not *a crotchet note*) while a British rest keeps it, and a French or Spanish rest names the concept (*soupir*, *silencio de negra*) rather than compounding the note value. Every locale also translates the words "note" and "rest" themselves. `Style::Template` gains a `scope:` keyword so style sentences borrow the rudiment vocabulary through the same seam that guards plural fallback and unfilled interpolations.
|
|
56
|
+
|
|
57
|
+
- **LilyPond export.** `HeadMusic::Content::Composition#to_lilypond` renders a composition as a complete LilyPond document string, delegating to `HeadMusic::Notation::LilyPond.render(composition, **options)` — the outward complement of the inward `Notation::<Format>.parse` interpreters, in the same facade-plus-helpers shape as the ABC and MusicXML writers. The document carries a `\version` line, a `\header` with the composition's title and composer, and a `\score` with one staff per voice in absolute pitch mode — key signature, meter, and a clef chosen per voice, with mid-piece `\key` and `\time` changes emitted at the bar where they occur, one line per bar with a trailing bar check.
|
|
58
|
+
|
|
59
|
+
Whole-composition problems raise `Notation::LilyPond::RenderError` before any assembly — a voiceless composition, positional gaps, notes crossing barlines, a voice that ends mid-bar, unpitched sounds, and unmappable keys, durations, or alterations — so a returned string is always a complete document. Generated fixtures compile under the LilyPond CLI in the specs.
|
|
60
|
+
|
|
61
|
+
- `Rudiment::RhythmicValue#tied_chain` — the value and every link tied after it, in order, so a writer walks a chain of tied values the same way it walks a chain of one.
|
|
62
|
+
|
|
63
|
+
### Changed
|
|
64
|
+
|
|
65
|
+
- **Breaking.** `GuideAssessment.new` raises `ArgumentError` when handed a composite guide, naming `guide.assess(voice)` as the seam that grades it correctly. Flattening a composite's items into one rubric would return a plausible number computed by the wrong arithmetic.
|
|
66
|
+
|
|
67
|
+
- **Breaking.** Four renames, freeing the word "combined", which named mixed rhythm on two guides and would have named a guide composed of members as well:
|
|
68
|
+
|
|
69
|
+
| Was | Is |
|
|
70
|
+
| --- | --- |
|
|
71
|
+
| `Guides::CombinedFirstSecondThirdSpeciesMelody` | `Guides::FirstThreeSpeciesMelody` |
|
|
72
|
+
| `Guides::CombinedFirstSecondThirdSpeciesHarmony` | `Guides::FirstThreeSpeciesHarmony` |
|
|
73
|
+
| `Guidelines::AllowedRhythmicValuesForCombined123` | `Guidelines::AllowWholeHalfQuarterNotes` |
|
|
74
|
+
| `Guidelines::AllowedRhythmicValuesForFifthSpecies` | `Guidelines::AllowFifthSpeciesRhythmicValues` |
|
|
75
|
+
|
|
76
|
+
The registry keys `combined_first_second_third_species_melody` and `..._harmony` become `first_three_species_melody` and `first_three_species_harmony`, and the locale keys move with them. The two guideline names take different shapes deliberately: the first three species allow a set small enough to say in a name, and fifth species allows that set plus eighths and ties under conditions the guideline itself decides.
|
|
77
|
+
|
|
78
|
+
- No existing guide's grade changes. Measured across the whole corpus — 3266 rows, 142 voices × 23 guides — every row is identical before and after.
|
|
79
|
+
|
|
80
|
+
- **Breaking.** `GuideItem#initialize` takes `strength:` as a keyword, so its configuration hash must now be passed explicitly — `GuideItem.new(SomeGuideline, {minimum: 3})` rather than `GuideItem.new(SomeGuideline, minimum: 3)`. `Guideline.with(minimum: 3)` is unaffected and remains the ordinary way to build one.
|
|
81
|
+
|
|
82
|
+
- **Breaking.** A guide that declares no `primary_items` raises `ArgumentError`, naming the guide and what it did declare. A guide that is all background has no subject, and grading it 1.0 in silence is the same "nothing to find fault in" confusion the gates fixed. Gate-only guides fall to the same check, deliberately. Every registered guide already declared a primary, so this closes a door rather than fixing a break.
|
|
83
|
+
|
|
84
|
+
- **Breaking.** The seven species harmony guides demote `SpeciesHarmony::HARMONIC_CORE`, `DIMINUTION_HARMONIC_CORE`, and `NoParallelPerfectWithSyncopation` to `secondary_items`, mirroring the melodic demotion. A harmony guide now weighs the dissonance treatment it teaches above the two-part craft every harmony guide shares. `SecondSpeciesHarmony` gave 9/10 of its grade to rules it did not write and now gives φ⁻¹ to `WeakBeatDissonanceTreatment` alone; a fixture failing that rule moves 0.824 → 0.698, and a parallel octave costs about half what it did.
|
|
85
|
+
|
|
86
|
+
Each guide declares its tiers outright — `primary_items` for what it teaches, `secondary_items` splatting the shared craft constants — so the tier of every item is readable at the call site, and the specs hold the guides to the policy that a shared-core member stays background. `FirstThreeSpeciesHarmony` gains the diminution core it was missing — it covers two diminution species — and is the only guide anywhere whose set of guidelines changed.
|
|
87
|
+
|
|
88
|
+
- **`NoParallelPerfectOnDownbeats` is a taught rule in first species harmony**, not inherited background — the one exception to the demotion above. It sits in the primary tier of `Guides::FirstSpeciesHarmony` alone, weighing 0.2060 beside `NoUnisonsInMiddle` and `OneToOne` at 0.2060 each, rather than the 0.0637 the shared harmonic core would give it; no other guide is affected.
|
|
89
|
+
|
|
90
|
+
A species guide is normally about the dissonance treatment its rhythm makes possible, and two-part craft is background. First species has no dissonance treatment, and its other two primaries are rhythm-and-texture bookkeeping — so note-against-note consonance handling is what the species teaches. Promoting the same rule in the six guides that *do* teach a dissonance treatment would weigh it as heavily as their subject, and would *raise* the grade of a submission already failing that subject by halving the weight it forgoes.
|
|
91
|
+
|
|
92
|
+
The exception is registered in `Guides::SpeciesHarmony::HARMONIC_CRAFT_PROMOTIONS`, and the specs hold every other harmony guide to the policy. A cantus firmus doubled an octave above itself grades 0.6674 where the shared-core weighting would read 0.8300, while Fux chapter one figure 5 as published still grades exactly 1.0.
|
|
93
|
+
|
|
94
|
+
- `MostlyConjunct` marks each skip and leap at the ordinary penalty rather than `SMALL_PENALTY_FACTOR`, and says it is soft with `strength :weak` instead. The two say different things: a mark's fitness compounds into the item's own grade, so it set both how bad one instance was and how fast the item collapsed on repeats. Six leaps now grade 0.056 rather than 0.236. `SMALL_PENALTY_FACTOR` is unchanged and still used by `SecondSpeciesBreak`, which holds two severities inside one guideline.
|
|
95
|
+
|
|
96
|
+
- **Breaking.** A guide declares its guidelines in three tiers rather than one flat `RULESET`, and the tier decides how much each one counts. `gate_items` are preconditions whose fitness multiplies the grade; `primary_items` are what the guide teaches and share φ⁻¹ of the rubric; `secondary_items` are background it inherits and share φ⁻². `Guides::Base.ruleset` and every `::RULESET` constant are removed — read `guide_items`, or one tier at a time.
|
|
97
|
+
|
|
98
|
+
Tier is the list an entry is declared in rather than a property of the entry, because the shared cores are shared objects: `SpeciesMelody::MELODIC_CORE` is splatted into six guides, and `ContourMelody` treats as background exactly what `DiatonicMelody` teaches. Guides whose tiers depend on configuration override `items_by_tier` with a keyword signature, as `ContourMelody` does.
|
|
99
|
+
|
|
100
|
+
- **Breaking.** `Style::Annotation::Configured` becomes `Style::GuideItem`: a guideline paired with the configuration one guide gives it, with `guideline` and `config` readers and value equality. It no longer answers `#new(voice)`, `#with`, or `#default_gate?`.
|
|
101
|
+
|
|
102
|
+
- **Breaking.** `Style::Analysis` becomes `Style::GuideAssessment`, and `#annotations` becomes `#guide_item_assessments`, which returns frozen `Style::GuideItemAssessment` values rather than live guideline instances. Each carries `tier`, `fitness`, `marks`, `message`, and the `guide_item` it came from.
|
|
103
|
+
|
|
104
|
+
- **Breaking.** `guide.analyze(voice)` is replaced by `guide.assess(voice)`, which returns a `GuideAssessment`, and `guide.assess_items(voice)`, which returns the assessments it grades. `Style::Guide.get` and `GuideAssessment.new` both duck-check `assess_items`: guidelines and guide items answer `assess` too, with different arguments.
|
|
105
|
+
|
|
106
|
+
- **Breaking.** Per-entry `weight` and `gate` are removed, along with `Guideline#weight`, `#gate?`, `.default_weight`, `.default_gate?`, `Contoured::DEFAULT_WEIGHT`, `MinimumThreshold.default_gate?`, and `ContourMelody::PEER_WEIGHT_BUDGET`. Tier replaces both. Whether a rule gates is the guide's editorial choice, not a property of the guideline — the same threshold can be a low gate in one guide and a stylistic minimum in another.
|
|
107
|
+
|
|
108
|
+
- **Breaking.** `Guideline.new` is private. A guideline instance is the analysis context, not a result; `Guideline.assess` is the seam, and what comes back is a `GuideItemAssessment`.
|
|
109
|
+
|
|
110
|
+
- **Breaking.** Every guide now declares a precondition, and failing one stops the assessment rather than scaling it. A voice that cannot be assessed reports `GuideAssessment#assessable? == false`, grades the product of its gates, and yields only gate assessments — the rubric is not computed. Previously a failed precondition multiplied a fully-computed rubric, so a four-note cantus firmus had its climax and leaps halved for being short.
|
|
111
|
+
|
|
112
|
+
`assess_items` therefore returns a variable-length list. A consumer upserting rows keyed by guide item must not read a missing row as a rule that was deleted.
|
|
113
|
+
|
|
114
|
+
- **Breaking.** Grades change outside the degenerate range, deliberately. Three sources, with every affected row recorded in the story's grade table:
|
|
115
|
+
|
|
116
|
+
| Change | Effect |
|
|
117
|
+
| --- | --- |
|
|
118
|
+
| The seven species harmony guides gain `SetAgainstAnotherVoice` and a three-note minimum | They raised `NoMethodError` for a voice with no companion, at every length. They grade it now. |
|
|
119
|
+
| The seven species melody guides gain a three-note minimum | Each graded an empty voice 1.000 — no fault found, because there was nothing to find fault in. |
|
|
120
|
+
| `FuxCantusFirmus`, `SalzerSchachterCantusFirmus` and `DiatonicMelody` split their note minimum | A three-note gate asks whether this is a melody; the eight- or five-note prescription stays a rubric item, matching `MaximumNotes`, which always was one. A four-note cantus firmus moves from 0.500 unassessable to 0.969 assessable. |
|
|
121
|
+
| The species guides demote the shared melodic cores to `secondary_items` | A guide weighs its own rhythmic rules above the craft it inherits. A valid first-species line scored 0.883 against `ThirdSpeciesMelody` and now scores 0.561. |
|
|
122
|
+
|
|
123
|
+
Grading was byte-identical to 19.0.0 through the guide-item refactor above; these are the deliberate corrections that followed it. The string changes below do not affect it.
|
|
124
|
+
|
|
125
|
+
- **Breaking.** Guideline strings move out of the classes and into the locale files. Every `MESSAGE` constant is removed. A guideline is addressed by the snake_case of its class name, so a new one needs no declaration — only entries under `head_music.style.guidelines.<key>`:
|
|
126
|
+
|
|
127
|
+
| Key | Reads |
|
|
128
|
+
| --- | --- |
|
|
129
|
+
| `name` | a short label, e.g. "Minimum of eight notes" |
|
|
130
|
+
| `instruction` | what to do |
|
|
131
|
+
| `violations.default` | what to do differently |
|
|
132
|
+
|
|
133
|
+
All three are templates. `GuideItem` renders them for its own configuration — `#name`, `#instruction`, `#violation_preview` — so the same guideline reads "at least three notes" in a gate and "at least eight" in a rubric. A guideline configured per guide supplies its interpolations from `self.template_values(config)`; a guide that wants a variant of the sentence names it with `violation_key:`, as `FuxCantusFirmus` does for `LargeLeaps`. A guideline that chooses between variants during the analysis itself declares them all with `.violation_keys`, as `ConsonantClimax` does for a climax dissonant with the tonic.
|
|
134
|
+
|
|
135
|
+
All fifty-five guidelines carry a `name` and an `instruction`. The name labels the rule — "No voice crossing", "Leap recovery" — and the instruction says what to write, where the violation says what to do differently: "Keep your line on its own side of the other voice" against "Avoid crossing voices". Both are a first draft, as the guide instructions are.
|
|
136
|
+
|
|
137
|
+
A rendered name is upcased on its first letter. A name may lead with an interpolation — `"%{contour} contour"` — whose value stays lowercase for the violation sentence that embeds it mid-clause, so "Arch contour" and "Write a melody with the arch contour" come from one locale value.
|
|
138
|
+
|
|
139
|
+
Both stay optional. A guideline with no `name` reads its class key as a sentence, and one with no `instruction` falls back to the violation, which is already phrased imperatively — so a guideline added before anyone writes it either one still reads. Until now that fallback was every guideline's only name: forty-six of the fifty-six in the registry rendered "Avoid crossing voices" or "Triple meter dissonance treatment" out of the class key, identically in every language, and the American spelling in that second one could not be regionalized because it never passed through a locale file at all.
|
|
140
|
+
|
|
141
|
+
The `message:` option that `SingableIntervals` and `LargeLeaps` accepted is removed with them: it passed an English sentence through the config hash, which is the thing this change exists to stop. A guide item declared with `message:` raises `ArgumentError` naming `violation_key:` rather than ignoring the key, since an unrecognized option would otherwise ride along in `config` and be dropped at render — a custom sentence vanishing with no error.
|
|
142
|
+
|
|
143
|
+
`GuideItemAssessment#message` is now `nil` for an adherent item rather than the message it would have printed. Read `GuideItem#violation_preview` for the sentence in the abstract.
|
|
144
|
+
|
|
145
|
+
`GuideItemAssessment#name`, and `#to_s` with it, answer the item's rendered name — "Minimum of eight notes" — rather than the guideline's class path. A consumer building a results list holds assessments rather than items, so that is where a rubric gets its labels.
|
|
146
|
+
|
|
147
|
+
- **Breaking.** Guides gain `#instruction` — what a guide asks a student to write, as distinct from how it grades what they wrote — and their names move under `head_music.style.guides.<key>.name` from the flat `<key>`. The twenty-three instructions are a first draft.
|
|
148
|
+
|
|
149
|
+
- **Breaking.** `Notation::MusicXML::ClefSelector` is now `Notation::ClefSelector`, and the old name no longer resolves. Choosing a clef for a voice's tessitura is format-independent, and the LilyPond writer reads it alongside the MusicXML one. The shared preflight checks the two writers agree on — contiguous placements, notes within barlines — move to `Notation::PreflightChecks` the same way.
|
|
150
|
+
|
|
151
|
+
## [19.0.0] - 2026-08-07
|
|
9
152
|
|
|
10
153
|
### Added
|
|
11
154
|
|
|
@@ -13,8 +156,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
13
156
|
|
|
14
157
|
| Category | Keys |
|
|
15
158
|
| --- | --- |
|
|
16
|
-
| `:melody` | `fux_cantus_firmus`, `salzer_schachter_cantus_firmus`, `diatonic_melody`, `first_species_melody`, `second_species_melody`, `third_species_melody`, `third_species_triple_meter_melody`, `fourth_species_melody`, `
|
|
17
|
-
| `:harmony` | `first_species_harmony`, `second_species_harmony`, `third_species_harmony`, `third_species_triple_meter_harmony`, `fourth_species_harmony`, `
|
|
159
|
+
| `:melody` | `fux_cantus_firmus`, `salzer_schachter_cantus_firmus`, `diatonic_melody`, `first_species_melody`, `second_species_melody`, `third_species_melody`, `third_species_triple_meter_melody`, `fourth_species_melody`, `first_three_species_melody`, `fifth_species_melody`, `arch_contour_melody`, `ascending_contour_melody`, `descending_contour_melody`, `static_contour_melody`, `valley_contour_melody`, `wave_contour_melody` |
|
|
160
|
+
| `:harmony` | `first_species_harmony`, `second_species_harmony`, `third_species_harmony`, `third_species_triple_meter_harmony`, `fourth_species_harmony`, `first_three_species_harmony`, `fifth_species_harmony` |
|
|
18
161
|
|
|
19
162
|
- Guides now carry identity: `.key` (snake_case of the class name), `.category` (`:melody` or `:harmony`, derived from the `SpeciesMelody`/`SpeciesHarmony` ancestry), and `.display_name` (localizable, with a computed English default). Consumers no longer need to hand-maintain a map of guide constants to categories.
|
|
20
163
|
- `HeadMusic::Style::Guides::Configured` — the guide-layer twin of `Annotation::Configured`. It pairs a guide class with options and answers `analyze(voice)`, so it drops into `Style::Analysis` wherever a guide class was expected. `Guides::Base.with(**options)` returns one, and `#with` layers further options without dropping earlier ones.
|
|
@@ -573,7 +716,33 @@ note = HeadMusic::Rudiment::Note.get("F#4 dotted-quarter")
|
|
|
573
716
|
|
|
574
717
|
For changes in versions prior to 0.28.0, please refer to the git history.
|
|
575
718
|
|
|
576
|
-
[Unreleased]: https://github.com/roberthead/head_music/compare/
|
|
719
|
+
[Unreleased]: https://github.com/roberthead/head_music/compare/v20.0.0...HEAD
|
|
720
|
+
[20.0.0]: https://github.com/roberthead/head_music/compare/v19.0.0...v20.0.0
|
|
721
|
+
[19.0.0]: https://github.com/roberthead/head_music/compare/v18.0.0...v19.0.0
|
|
722
|
+
[18.0.0]: https://github.com/roberthead/head_music/compare/v17.5.0...v18.0.0
|
|
723
|
+
[17.5.0]: https://github.com/roberthead/head_music/compare/v17.3.0...v17.5.0
|
|
724
|
+
[17.3.0]: https://github.com/roberthead/head_music/compare/v17.2.0...v17.3.0
|
|
725
|
+
[17.2.0]: https://github.com/roberthead/head_music/compare/v17.1.0...v17.2.0
|
|
726
|
+
[17.1.0]: https://github.com/roberthead/head_music/compare/v17.0.0...v17.1.0
|
|
727
|
+
[17.0.0]: https://github.com/roberthead/head_music/compare/v15.2.0...v17.0.0
|
|
728
|
+
[15.2.0]: https://github.com/roberthead/head_music/compare/v15.0.0...v15.2.0
|
|
729
|
+
[15.0.0]: https://github.com/roberthead/head_music/compare/v14.0.0...v15.0.0
|
|
730
|
+
[14.0.0]: https://github.com/roberthead/head_music/compare/v13.0.0...v14.0.0
|
|
731
|
+
[13.0.0]: https://github.com/roberthead/head_music/compare/v12.6.0...v13.0.0
|
|
732
|
+
[12.6.0]: https://github.com/roberthead/head_music/compare/v12.5.0...v12.6.0
|
|
733
|
+
[12.5.0]: https://github.com/roberthead/head_music/compare/v12.4.0...v12.5.0
|
|
734
|
+
[12.4.0]: https://github.com/roberthead/head_music/compare/v12.3.0...v12.4.0
|
|
735
|
+
[12.3.0]: https://github.com/roberthead/head_music/compare/v12.2.0...v12.3.0
|
|
736
|
+
[12.2.0]: https://github.com/roberthead/head_music/compare/v12.1.0...v12.2.0
|
|
737
|
+
[12.1.0]: https://github.com/roberthead/head_music/compare/v12.0.1...v12.1.0
|
|
738
|
+
[12.0.1]: https://github.com/roberthead/head_music/compare/v12.0.0...v12.0.1
|
|
739
|
+
[12.0.0]: https://github.com/roberthead/head_music/compare/v11.8.0...v12.0.0
|
|
740
|
+
[11.8.0]: https://github.com/roberthead/head_music/compare/v11.7.0...v11.8.0
|
|
741
|
+
[11.7.0]: https://github.com/roberthead/head_music/compare/v11.6.1...v11.7.0
|
|
742
|
+
[11.6.1]: https://github.com/roberthead/head_music/compare/v11.6.0...v11.6.1
|
|
743
|
+
[11.6.0]: https://github.com/roberthead/head_music/compare/v11.5.1...v11.6.0
|
|
744
|
+
[11.0.0]: https://github.com/roberthead/head_music/compare/v9.1.0...v11.0.0
|
|
745
|
+
[9.0.0]: https://github.com/roberthead/head_music/compare/v8.4.0...v9.0.0
|
|
577
746
|
[8.2.0]: https://github.com/roberthead/head_music/compare/v8.1.1...v8.2.0
|
|
578
747
|
[8.1.1]: https://github.com/roberthead/head_music/compare/v8.1.0...v8.1.1
|
|
579
748
|
[8.1.0]: https://github.com/roberthead/head_music/compare/v8.0.2...v8.1.0
|
data/CLAUDE.md
CHANGED
|
@@ -114,7 +114,7 @@ The codebase follows a domain-driven design with clear module boundaries:
|
|
|
114
114
|
7. **HeadMusic::Style** - Composition rules and guidelines
|
|
115
115
|
- Counterpoint rules (species counterpoint guides)
|
|
116
116
|
- Voice leading guidelines
|
|
117
|
-
- Style analysis and
|
|
117
|
+
- Style analysis and guidelines
|
|
118
118
|
- Historical traditions (medieval, renaissance, modern)
|
|
119
119
|
|
|
120
120
|
8. **HeadMusic::Utilities** - Shared helpers
|
data/Gemfile
CHANGED
|
@@ -13,10 +13,14 @@ end
|
|
|
13
13
|
gem "standard", require: false
|
|
14
14
|
|
|
15
15
|
group :test do
|
|
16
|
-
|
|
16
|
+
# rubocop is not listed directly: standard pins it (~> 1.88.0) and must
|
|
17
|
+
# lead. Listing it here let Dependabot bump it past standard, which the
|
|
18
|
+
# resolver "fixed" by downgrading standard 1.56 -> 1.35.
|
|
17
19
|
gem "rubocop-rspec", require: false
|
|
18
20
|
gem "rubocop-rake", require: false
|
|
19
|
-
|
|
21
|
+
# simplecov 1.1.x forwards an anonymous block inside a block, which is a
|
|
22
|
+
# syntax error on Ruby 3.3. Unpin once upstream drops that or we drop 3.3.
|
|
23
|
+
gem "simplecov", "< 1.1", require: false
|
|
20
24
|
end
|
|
21
25
|
|
|
22
26
|
group :development do
|
data/Gemfile.lock
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
PATH
|
|
2
2
|
remote: .
|
|
3
3
|
specs:
|
|
4
|
-
head_music (
|
|
4
|
+
head_music (20.0.0)
|
|
5
5
|
activesupport (>= 7.0, < 10)
|
|
6
6
|
humanize (>= 2, < 4)
|
|
7
7
|
i18n (~> 1.8)
|
|
@@ -9,7 +9,7 @@ PATH
|
|
|
9
9
|
GEM
|
|
10
10
|
remote: https://rubygems.org/
|
|
11
11
|
specs:
|
|
12
|
-
activesupport (8.1.3)
|
|
12
|
+
activesupport (8.1.3.1)
|
|
13
13
|
base64
|
|
14
14
|
bigdecimal
|
|
15
15
|
concurrent-ruby (~> 1.0, >= 1.3.1)
|
|
@@ -207,7 +207,7 @@ GEM
|
|
|
207
207
|
coercible (~> 1.0)
|
|
208
208
|
descendants_tracker (~> 0.0, >= 0.0.3)
|
|
209
209
|
yard (0.9.45)
|
|
210
|
-
zeitwerk (2.8.
|
|
210
|
+
zeitwerk (2.8.3)
|
|
211
211
|
|
|
212
212
|
PLATFORMS
|
|
213
213
|
arm64-darwin-22
|
|
@@ -223,11 +223,10 @@ DEPENDENCIES
|
|
|
223
223
|
rexml (~> 3.4)
|
|
224
224
|
rspec (~> 3.0)
|
|
225
225
|
rspec-its (~> 2.0)
|
|
226
|
-
rubocop
|
|
227
226
|
rubocop-rake
|
|
228
227
|
rubocop-rspec
|
|
229
228
|
rubycritic
|
|
230
|
-
simplecov
|
|
229
|
+
simplecov (< 1.1)
|
|
231
230
|
standard
|
|
232
231
|
yard
|
|
233
232
|
|
data/README.md
CHANGED
|
@@ -62,12 +62,61 @@ guide = HeadMusic::Style::Guide.get('first_species_harmony')
|
|
|
62
62
|
guide.category # => :harmony
|
|
63
63
|
guide.display_name # => "First Species Harmony"
|
|
64
64
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
65
|
+
assessment = guide.assess(voice)
|
|
66
|
+
assessment.fitness # => 0.0 to 1.0
|
|
67
|
+
assessment.messages # => ["Prefer contrary motion. Move voices in different melodic directions."]
|
|
68
68
|
```
|
|
69
69
|
|
|
70
|
-
|
|
70
|
+
A guide declares its guidelines in three tiers, and the tier decides how much each one counts:
|
|
71
|
+
|
|
72
|
+
```ruby
|
|
73
|
+
guide.gate_items # preconditions -- can this voice be assessed at all?
|
|
74
|
+
guide.primary_items # what the guide is about
|
|
75
|
+
guide.secondary_items # background craft it inherits rather than teaches
|
|
76
|
+
guide.guide_items # all three, in that order
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
A gate asks whether the voice can be assessed at all. Failing one **stops** the assessment — the
|
|
80
|
+
rubric is not computed, and the grade is the gates alone:
|
|
81
|
+
|
|
82
|
+
```ruby
|
|
83
|
+
assessment.assessable? # => false for a voice too short to judge, or with no companion voice
|
|
84
|
+
assessment.fitness # => the gates' product; the rubric was never reached
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Among the rules that are assessed, primaries share φ⁻¹ of the rubric and secondaries share φ⁻², which
|
|
88
|
+
is why a species guide weighs its own rules as heavily as all the craft it inherits put together. The
|
|
89
|
+
budgets are fixed rather than divided by item count, so what a guide teaches does not thin out as it
|
|
90
|
+
inherits more. A rubric that declares only one tier is renormalized to the full range.
|
|
91
|
+
|
|
92
|
+
Within a tier, a second axis: **strength**. A prohibition (`:strong`, the default) weighs twice a
|
|
93
|
+
preference (`:weak`), normalized by that tier's own total. Strength never crosses a tier boundary, and
|
|
94
|
+
it is inert on gates, which multiply the whole rubric:
|
|
95
|
+
|
|
96
|
+
```ruby
|
|
97
|
+
HeadMusic::Style::Guidelines::NoParallelPerfectOnDownbeats.strength # => :strong
|
|
98
|
+
HeadMusic::Style::Guidelines::PreferContraryMotion.strength # => :weak
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Unlike tier, strength is a property of the guideline rather than of the list it was declared in — a
|
|
102
|
+
preference is a preference in every guide that names it. An item may override it for the
|
|
103
|
+
tradition-dependent case, with `Guideline.with(strength: :weak)`.
|
|
104
|
+
|
|
105
|
+
Each entry is a `Style::GuideItem` — a guideline plus the configuration this guide gives it — and
|
|
106
|
+
assessing one yields a frozen `Style::GuideItemAssessment`:
|
|
107
|
+
|
|
108
|
+
```ruby
|
|
109
|
+
item = guide.primary_items.first
|
|
110
|
+
item.guideline # => HeadMusic::Style::Guidelines::NoUnisonsInMiddle
|
|
111
|
+
item.config # => {}
|
|
112
|
+
item.strength # => :strong
|
|
113
|
+
|
|
114
|
+
assessment.guide_item_assessments.first.tier # => :gate
|
|
115
|
+
assessment.guide_item_assessments.first.strength # => :strong
|
|
116
|
+
assessment.guide_item_assessments.first.fitness # => 0.0 to 1.0
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Guides whose tiers vary by configuration are built with `.with`. The six contour melodies are
|
|
71
120
|
registered under their own keys, and each key is exactly one such configuration:
|
|
72
121
|
|
|
73
122
|
```ruby
|
data/Rakefile
CHANGED
|
@@ -45,3 +45,26 @@ desc "Open coverage report in browser"
|
|
|
45
45
|
task :coverage do
|
|
46
46
|
sh "open coverage/index.html" if File.exist?("coverage/index.html")
|
|
47
47
|
end
|
|
48
|
+
|
|
49
|
+
namespace :style do
|
|
50
|
+
desc "Regenerate the pinned English guide strings (spec/fixtures/style/english_strings.yml)"
|
|
51
|
+
task :snapshot_english do
|
|
52
|
+
require "yaml"
|
|
53
|
+
$LOAD_PATH.unshift File.expand_path("lib", __dir__)
|
|
54
|
+
require "head_music"
|
|
55
|
+
|
|
56
|
+
guides = HeadMusic::Style::Guide::ALL
|
|
57
|
+
items = guides.flat_map(&:guide_items).uniq
|
|
58
|
+
snapshot = %i[en en_GB].to_h do |locale|
|
|
59
|
+
strings = I18n.with_locale(locale) do
|
|
60
|
+
guides.flat_map { |guide| [guide.display_name, guide.instruction] } +
|
|
61
|
+
items.flat_map { |item| [item.name, item.instruction] + item.violation_previews }
|
|
62
|
+
end
|
|
63
|
+
[locale.to_s, strings]
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
path = File.expand_path("spec/fixtures/style/english_strings.yml", __dir__)
|
|
67
|
+
File.write(path, snapshot.to_yaml)
|
|
68
|
+
puts "Wrote #{snapshot.values.sum(&:size)} strings to #{path}"
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# Grades a fixed corpus of voices against every registered guide and writes one
|
|
3
|
+
# JSON row per (corpus entry, guide).
|
|
4
|
+
#
|
|
5
|
+
# bundle exec ruby bin/guide_grade_corpus.rb out.json
|
|
6
|
+
#
|
|
7
|
+
# Written to run UNMODIFIED on both sides of a grading change. At the merge-base
|
|
8
|
+
# `assessable?` does not exist and the harmony guides raise on a solo voice, so
|
|
9
|
+
# the one is asked for conditionally and the other is recorded as a value rather
|
|
10
|
+
# than being allowed to stop the run. Everything else is common to both trees.
|
|
11
|
+
#
|
|
12
|
+
# The invariant that sentence protects is that each column is the same
|
|
13
|
+
# measurement made again -- not that the file is never edited. When a change
|
|
14
|
+
# needs a seam this script does not yet have, the edit lands BEFORE both
|
|
15
|
+
# captures and is proven a no-op by diffing a capture from either side of it.
|
|
16
|
+
#
|
|
17
|
+
# Loading the fixture exercises means loading spec_helper, which starts
|
|
18
|
+
# SimpleCov and rewrites coverage/.last_run.json. That file is restored on the
|
|
19
|
+
# way out, so a later `bundle exec rake` measures against the baseline it had.
|
|
20
|
+
|
|
21
|
+
require "json"
|
|
22
|
+
|
|
23
|
+
ROOT = File.expand_path("..", __dir__)
|
|
24
|
+
$LOAD_PATH.unshift File.join(ROOT, "lib"), File.join(ROOT, "spec")
|
|
25
|
+
|
|
26
|
+
COVERAGE_BASELINE = File.join(ROOT, "coverage", ".last_run.json")
|
|
27
|
+
SAVED_BASELINE = File.exist?(COVERAGE_BASELINE) ? File.read(COVERAGE_BASELINE) : nil
|
|
28
|
+
at_exit do
|
|
29
|
+
if SAVED_BASELINE
|
|
30
|
+
File.write(COVERAGE_BASELINE, SAVED_BASELINE)
|
|
31
|
+
elsif File.exist?(COVERAGE_BASELINE)
|
|
32
|
+
File.delete(COVERAGE_BASELINE)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
require "head_music"
|
|
37
|
+
require "composition_context"
|
|
38
|
+
require "spec_helper"
|
|
39
|
+
|
|
40
|
+
LADDER = %w[D4 F4 E4 G4 F4 A4 G4 F4].freeze
|
|
41
|
+
REPEATED = Array.new(8, "E4").freeze
|
|
42
|
+
CANTUS = %w[D4 F4 E4 D4 G4 F4 E4 D4].freeze
|
|
43
|
+
|
|
44
|
+
def composition(key: "D dorian")
|
|
45
|
+
HeadMusic::Content::Composition.new(name: "corpus", key_signature: key)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def place(voice, pitches)
|
|
49
|
+
pitches.each_with_index { |pitch, bar| voice.place("#{bar + 1}:1", :whole, pitch) }
|
|
50
|
+
voice
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# A voice alone in its composition: no companion, so the harmony guides have
|
|
54
|
+
# nothing to be set against.
|
|
55
|
+
def solo(pitches)
|
|
56
|
+
place(composition.add_voice(role: :counterpoint), pitches)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# A counterpoint voice with a companion, which may itself be empty.
|
|
60
|
+
def accompanied(pitches, companion_pitches)
|
|
61
|
+
comp = composition
|
|
62
|
+
place(comp.add_voice(role: "Cantus Firmus"), companion_pitches)
|
|
63
|
+
place(comp.add_voice(role: :counterpoint), pitches)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def corpus
|
|
67
|
+
entries = []
|
|
68
|
+
(0..8).each { |n| entries << ["solo-ascending-#{n}", solo(LADDER.first(n))] }
|
|
69
|
+
(0..8).each { |n| entries << ["solo-repeated-#{n}", solo(REPEATED.first(n))] }
|
|
70
|
+
[0, 1, 2, 4, 8].each { |n| entries << ["against-empty-#{n}", accompanied(LADDER.first(n), [])] }
|
|
71
|
+
[0, 1, 2, 4, 8].each { |n| entries << ["against-cantus-#{n}", accompanied(LADDER.first(n), CANTUS)] }
|
|
72
|
+
|
|
73
|
+
%w[
|
|
74
|
+
fux_cantus_firmus_examples clendinning_cantus_firmus_examples
|
|
75
|
+
schoenberg_cantus_firmus_examples davis_and_lybbert_cantus_firmus_examples
|
|
76
|
+
fux_cantus_firmus_examples_with_errors fux_first_species_examples
|
|
77
|
+
clendinning_first_species_examples davis_and_lybbert_first_species_examples
|
|
78
|
+
doubled_octave_examples
|
|
79
|
+
].each do |source|
|
|
80
|
+
Array(send(source)).each_with_index do |context, index|
|
|
81
|
+
context.composition.voices.each_with_index do |voice, position|
|
|
82
|
+
entries << ["#{source}-#{index}-v#{position}", voice]
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
entries
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def grade(guide, voice)
|
|
90
|
+
# Through the guide, not GuideAssessment.new: a composite guide grades its
|
|
91
|
+
# members separately and refuses that constructor, and the rescue below would
|
|
92
|
+
# have recorded the refusal as a per-row error while the run still exited 0.
|
|
93
|
+
# Identical for every leaf guide -- Guides::Base.assess and Configured#assess
|
|
94
|
+
# are both GuideAssessment.new(self, voice) -- so this edit was made before
|
|
95
|
+
# either capture was taken and proven a byte-identical no-op on the before
|
|
96
|
+
# tree.
|
|
97
|
+
assessment = guide.assess(voice)
|
|
98
|
+
items = assessment.guide_item_assessments
|
|
99
|
+
{
|
|
100
|
+
fitness: assessment.fitness.round(12),
|
|
101
|
+
adherent: assessment.adherent?,
|
|
102
|
+
message_count: assessment.messages.length,
|
|
103
|
+
item_count: items.length,
|
|
104
|
+
assessable: assessment.assessable?,
|
|
105
|
+
failed_gates: items.select { |item| item.gate? && !item.adherent? }.map { |item| item.guideline.name.split("::").last }.sort
|
|
106
|
+
}
|
|
107
|
+
rescue => error
|
|
108
|
+
{fitness: nil, adherent: nil, message_count: nil, item_count: nil, assessable: nil,
|
|
109
|
+
failed_gates: [], error: error.class.name}
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
rows = corpus.flat_map do |label, voice|
|
|
113
|
+
HeadMusic::Style::Guide::ALL.map do |guide|
|
|
114
|
+
{
|
|
115
|
+
corpus: label,
|
|
116
|
+
notes: voice.notes.length,
|
|
117
|
+
guide: HeadMusic::Style::Guide.key_for(guide)
|
|
118
|
+
}.merge(grade(guide, voice))
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
File.write(ARGV.fetch(0), JSON.pretty_generate(rows))
|
|
123
|
+
warn "rows=#{rows.length} corpus=#{corpus.length} guides=#{HeadMusic::Style::Guide::ALL.length} " \
|
|
124
|
+
"errors=#{rows.count { |row| row[:error] }}"
|