head_music 19.0.0 → 20.1.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/.github/workflows/ci.yml +93 -8
- data/.github/workflows/release.yml +10 -9
- data/.github/workflows/security.yml +10 -8
- data/.rubocop.yml +8 -0
- data/CHANGELOG.md +189 -4
- data/CLAUDE.md +1 -1
- data/Gemfile +11 -6
- data/Gemfile.lock +8 -9
- data/README.md +67 -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 +31 -75
- 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/duration_resolver.rb +4 -64
- data/lib/head_music/notation/abc/voice_state.rb +1 -10
- 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/dotted_duration.rb +81 -2
- data/lib/head_music/notation/lily_pond/assignment_reader.rb +65 -0
- data/lib/head_music/notation/lily_pond/composition_builder.rb +159 -0
- data/lib/head_music/notation/lily_pond/context_reader.rb +85 -0
- data/lib/head_music/notation/lily_pond/document.rb +46 -0
- data/lib/head_music/notation/lily_pond/document_reader.rb +96 -0
- data/lib/head_music/notation/lily_pond/duration_reader.rb +67 -0
- 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/key_reader.rb +63 -0
- data/lib/head_music/notation/lily_pond/lexer.rb +198 -0
- data/lib/head_music/notation/lily_pond/meter_reader.rb +31 -0
- data/lib/head_music/notation/lily_pond/music_item_reader.rb +107 -0
- data/lib/head_music/notation/lily_pond/music_reader.rb +127 -0
- data/lib/head_music/notation/lily_pond/parse_preflight.rb +48 -0
- data/lib/head_music/notation/lily_pond/parser.rb +30 -0
- data/lib/head_music/notation/lily_pond/pitch_reader.rb +100 -0
- data/lib/head_music/notation/lily_pond/pitch_reader_stack.rb +31 -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 +50 -0
- data/lib/head_music/notation/lily_pond/string_text.rb +18 -0
- data/lib/head_music/notation/lily_pond/token.rb +20 -0
- data/lib/head_music/notation/lily_pond/token_cursor.rb +68 -0
- data/lib/head_music/notation/lily_pond/voice_context.rb +42 -0
- data/lib/head_music/notation/lily_pond/voice_stream.rb +110 -0
- data/lib/head_music/notation/lily_pond/writer.rb +127 -0
- data/lib/head_music/notation/lily_pond.rb +41 -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 +13 -60
- 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/render_plan.rb +71 -0
- data/lib/head_music/notation.rb +3 -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 +16 -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/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/lilypond-interpreter.md +395 -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 +86 -22
- metadata +81 -12
- 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
- data/user-stories/backlog/lilypond-interpreter.md +0 -75
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b3714d51858bd368308d14a5f12544e6f4fb9dbecf0f0eacc1ad795c5e4b3d1e
|
|
4
|
+
data.tar.gz: 99345ffa2b44dfbdc27836b7b7350a38af1665770a74490f52e22bd1a0aa3602
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d95aa74479fe498564dc7e1bc5427660b9687e286a31a4b5cbbb1c0a274bebe9413ae18e2176dbbf38c351e4b1517275d5bf8dce590642c88c2301ea83f038b6
|
|
7
|
+
data.tar.gz: 4446808775faf30ef42dceca7de10248a88d8c8265ab4faa3c11589bd6ddb7dd60ff9c10ce2cb34a68f9e039061bc3633484e01b2002f649bb3f885bd5d7e968
|
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/.github/workflows/ci.yml
CHANGED
|
@@ -6,14 +6,39 @@ on:
|
|
|
6
6
|
pull_request:
|
|
7
7
|
branches: [ main ]
|
|
8
8
|
|
|
9
|
+
permissions:
|
|
10
|
+
contents: read
|
|
11
|
+
|
|
12
|
+
# A rebase flow means force-pushes are routine; without this, each one leaves
|
|
13
|
+
# the superseded run burning minutes alongside its replacement.
|
|
14
|
+
concurrency:
|
|
15
|
+
group: ${{ github.workflow }}-${{ github.ref }}
|
|
16
|
+
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
|
17
|
+
|
|
9
18
|
jobs:
|
|
10
19
|
test:
|
|
20
|
+
name: test (ruby ${{ matrix.ruby-version }}, activesupport ${{ matrix.activesupport-version || 'latest' }})
|
|
11
21
|
runs-on: ubuntu-latest
|
|
22
|
+
timeout-minutes: 10
|
|
12
23
|
strategy:
|
|
13
24
|
fail-fast: false
|
|
14
25
|
matrix:
|
|
15
|
-
|
|
16
|
-
activesupport-version
|
|
26
|
+
# The corners of the supported range rather than a full cross product.
|
|
27
|
+
# An empty activesupport-version installs from the lockfile, covering
|
|
28
|
+
# the newest release the gemspec allows; ActiveSupport 7.2 is not
|
|
29
|
+
# paired with Ruby 4.0, which it predates.
|
|
30
|
+
include:
|
|
31
|
+
- ruby-version: '3.3'
|
|
32
|
+
activesupport-version: '7.2'
|
|
33
|
+
- ruby-version: '3.3'
|
|
34
|
+
activesupport-version: ''
|
|
35
|
+
coverage: true
|
|
36
|
+
- ruby-version: '3.4'
|
|
37
|
+
activesupport-version: '7.2'
|
|
38
|
+
- ruby-version: '3.4'
|
|
39
|
+
activesupport-version: '8.0'
|
|
40
|
+
- ruby-version: '4.0'
|
|
41
|
+
activesupport-version: ''
|
|
17
42
|
|
|
18
43
|
steps:
|
|
19
44
|
- uses: actions/checkout@v7
|
|
@@ -24,31 +49,90 @@ jobs:
|
|
|
24
49
|
ruby-version: ${{ matrix.ruby-version }}
|
|
25
50
|
bundler-cache: false
|
|
26
51
|
|
|
27
|
-
- name: Install dependencies with ActiveSupport ${{ matrix.activesupport-version }}
|
|
52
|
+
- name: Install dependencies with ActiveSupport ${{ matrix.activesupport-version || 'latest' }}
|
|
53
|
+
env:
|
|
54
|
+
ACTIVESUPPORT_VERSION: ${{ matrix.activesupport-version }}
|
|
28
55
|
run: |
|
|
29
56
|
bundle config set --local path vendor/bundle
|
|
30
|
-
|
|
57
|
+
bundle install
|
|
31
58
|
|
|
32
59
|
- name: Run tests
|
|
33
60
|
run: bundle exec rspec
|
|
34
61
|
|
|
35
62
|
- name: Upload coverage to Codecov
|
|
36
|
-
if: matrix.
|
|
63
|
+
if: matrix.coverage
|
|
37
64
|
uses: codecov/codecov-action@v7
|
|
38
65
|
with:
|
|
39
66
|
token: ${{ secrets.CODECOV_TOKEN }}
|
|
40
67
|
fail_ci_if_error: false
|
|
41
68
|
verbose: true
|
|
42
69
|
|
|
70
|
+
# Months of warning about upcoming Ruby releases, without a red check that
|
|
71
|
+
# blocks a merge.
|
|
72
|
+
test-ruby-head:
|
|
73
|
+
name: test (ruby-head, advisory)
|
|
74
|
+
runs-on: ubuntu-latest
|
|
75
|
+
timeout-minutes: 10
|
|
76
|
+
continue-on-error: true
|
|
77
|
+
env:
|
|
78
|
+
# Bundler otherwise switches to the lockfile's BUNDLED WITH, and 2.7.2
|
|
79
|
+
# raises "uninitialized constant Pathname::SEPARATOR_PAT" on a Ruby this
|
|
80
|
+
# new. A bundler bug is not the breakage this job exists to find.
|
|
81
|
+
BUNDLE_VERSION: system
|
|
82
|
+
steps:
|
|
83
|
+
- uses: actions/checkout@v7
|
|
84
|
+
|
|
85
|
+
- name: Set up Ruby head
|
|
86
|
+
uses: ruby/setup-ruby@v1
|
|
87
|
+
with:
|
|
88
|
+
ruby-version: head
|
|
89
|
+
bundler: latest
|
|
90
|
+
bundler-cache: false
|
|
91
|
+
|
|
92
|
+
- name: Install dependencies
|
|
93
|
+
run: |
|
|
94
|
+
bundle config set --local path vendor/bundle
|
|
95
|
+
bundle install
|
|
96
|
+
|
|
97
|
+
- name: Run tests
|
|
98
|
+
run: bundle exec rspec
|
|
99
|
+
|
|
100
|
+
# The LilyPond specs treat the real binary as an oracle and skip when none is
|
|
101
|
+
# installed, so without this job nothing proves the rendered documents
|
|
102
|
+
# actually compile. Runs the whole suite because SimpleCov's minimum applies
|
|
103
|
+
# to any rspec invocation.
|
|
104
|
+
test-lilypond:
|
|
105
|
+
name: test (with the lilypond toolchain)
|
|
106
|
+
runs-on: ubuntu-latest
|
|
107
|
+
timeout-minutes: 15
|
|
108
|
+
steps:
|
|
109
|
+
- uses: actions/checkout@v7
|
|
110
|
+
|
|
111
|
+
- name: Install LilyPond
|
|
112
|
+
run: |
|
|
113
|
+
sudo apt-get update
|
|
114
|
+
sudo apt-get install -y lilypond
|
|
115
|
+
lilypond --version
|
|
116
|
+
|
|
117
|
+
- name: Set up Ruby
|
|
118
|
+
uses: ruby/setup-ruby@v1
|
|
119
|
+
with:
|
|
120
|
+
ruby-version: .ruby-version
|
|
121
|
+
bundler-cache: true
|
|
122
|
+
|
|
123
|
+
- name: Run tests
|
|
124
|
+
run: bundle exec rspec
|
|
125
|
+
|
|
43
126
|
lint:
|
|
44
127
|
runs-on: ubuntu-latest
|
|
128
|
+
timeout-minutes: 5
|
|
45
129
|
steps:
|
|
46
130
|
- uses: actions/checkout@v7
|
|
47
131
|
|
|
48
132
|
- name: Set up Ruby
|
|
49
133
|
uses: ruby/setup-ruby@v1
|
|
50
134
|
with:
|
|
51
|
-
ruby-version:
|
|
135
|
+
ruby-version: .ruby-version
|
|
52
136
|
bundler-cache: true
|
|
53
137
|
|
|
54
138
|
- name: Run RuboCop
|
|
@@ -56,13 +140,14 @@ jobs:
|
|
|
56
140
|
|
|
57
141
|
build:
|
|
58
142
|
runs-on: ubuntu-latest
|
|
143
|
+
timeout-minutes: 5
|
|
59
144
|
steps:
|
|
60
145
|
- uses: actions/checkout@v7
|
|
61
146
|
|
|
62
147
|
- name: Set up Ruby
|
|
63
148
|
uses: ruby/setup-ruby@v1
|
|
64
149
|
with:
|
|
65
|
-
ruby-version:
|
|
150
|
+
ruby-version: .ruby-version
|
|
66
151
|
bundler-cache: true
|
|
67
152
|
|
|
68
153
|
- name: Build gem
|
|
@@ -71,4 +156,4 @@ jobs:
|
|
|
71
156
|
- name: Check gem
|
|
72
157
|
run: |
|
|
73
158
|
gem install *.gem
|
|
74
|
-
gem specification *.gem
|
|
159
|
+
gem specification *.gem
|
|
@@ -8,6 +8,7 @@ on:
|
|
|
8
8
|
jobs:
|
|
9
9
|
release:
|
|
10
10
|
runs-on: ubuntu-latest
|
|
11
|
+
timeout-minutes: 15
|
|
11
12
|
permissions:
|
|
12
13
|
contents: write
|
|
13
14
|
id-token: write
|
|
@@ -18,7 +19,7 @@ jobs:
|
|
|
18
19
|
- name: Set up Ruby
|
|
19
20
|
uses: ruby/setup-ruby@v1
|
|
20
21
|
with:
|
|
21
|
-
ruby-version:
|
|
22
|
+
ruby-version: .ruby-version
|
|
22
23
|
bundler-cache: true
|
|
23
24
|
|
|
24
25
|
- name: Run tests
|
|
@@ -38,12 +39,12 @@ jobs:
|
|
|
38
39
|
env:
|
|
39
40
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
40
41
|
|
|
42
|
+
# Trusted publishing: RubyGems exchanges this job's OIDC token for a
|
|
43
|
+
# short-lived credential, so there is no API key to store or rotate.
|
|
44
|
+
# Requires head_music to have this workflow registered as a trusted
|
|
45
|
+
# publisher at rubygems.org/gems/head_music/trusted_publishers.
|
|
46
|
+
- name: Configure RubyGems credentials
|
|
47
|
+
uses: rubygems/configure-rubygems-credentials@v2
|
|
48
|
+
|
|
41
49
|
- name: Publish to RubyGems
|
|
42
|
-
run:
|
|
43
|
-
mkdir -p $HOME/.gem
|
|
44
|
-
touch $HOME/.gem/credentials
|
|
45
|
-
chmod 0600 $HOME/.gem/credentials
|
|
46
|
-
printf -- "---\n:rubygems_api_key: ${GEM_HOST_API_KEY}\n" > $HOME/.gem/credentials
|
|
47
|
-
gem push *.gem
|
|
48
|
-
env:
|
|
49
|
-
GEM_HOST_API_KEY: ${{ secrets.RUBYGEMS_API_KEY }}
|
|
50
|
+
run: gem push *.gem
|
|
@@ -10,9 +10,17 @@ on:
|
|
|
10
10
|
- cron: '0 9 * * *'
|
|
11
11
|
workflow_dispatch:
|
|
12
12
|
|
|
13
|
+
permissions:
|
|
14
|
+
contents: read
|
|
15
|
+
|
|
16
|
+
concurrency:
|
|
17
|
+
group: ${{ github.workflow }}-${{ github.ref }}
|
|
18
|
+
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
|
19
|
+
|
|
13
20
|
jobs:
|
|
14
21
|
security:
|
|
15
22
|
runs-on: ubuntu-latest
|
|
23
|
+
timeout-minutes: 5
|
|
16
24
|
|
|
17
25
|
steps:
|
|
18
26
|
- uses: actions/checkout@v7
|
|
@@ -20,14 +28,8 @@ jobs:
|
|
|
20
28
|
- name: Set up Ruby
|
|
21
29
|
uses: ruby/setup-ruby@v1
|
|
22
30
|
with:
|
|
23
|
-
ruby-version:
|
|
31
|
+
ruby-version: .ruby-version
|
|
24
32
|
bundler-cache: true
|
|
25
33
|
|
|
26
|
-
- name: Install bundler-audit
|
|
27
|
-
run: gem install bundler-audit
|
|
28
|
-
|
|
29
34
|
- name: Run bundler-audit
|
|
30
|
-
run: bundle-audit check --update
|
|
31
|
-
|
|
32
|
-
- name: Run RuboCop Security
|
|
33
|
-
run: bundle exec rubocop --only Security
|
|
35
|
+
run: bundle exec bundle-audit check --update
|
data/.rubocop.yml
CHANGED
|
@@ -15,6 +15,14 @@ AllCops:
|
|
|
15
15
|
- vendor/**/*
|
|
16
16
|
TargetRubyVersion: 3.3.0
|
|
17
17
|
|
|
18
|
+
# Off in standard's config, so the lint job missed them; the security
|
|
19
|
+
# workflow's "--only Security" pass was the only thing covering them.
|
|
20
|
+
Security/IoMethods:
|
|
21
|
+
Enabled: true
|
|
22
|
+
|
|
23
|
+
Security/MarshalLoad:
|
|
24
|
+
Enabled: true
|
|
25
|
+
|
|
18
26
|
RSpec:
|
|
19
27
|
Enabled: true
|
|
20
28
|
|
data/CHANGELOG.md
CHANGED
|
@@ -7,14 +7,172 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [20.1.0] - 2026-09-05
|
|
11
|
+
|
|
12
|
+
The other half of the LilyPond export released in 20.0.0. A document written by the writer, or by hand, now reads back into a composition, so `parse(render(composition))` reproduces the music. Nothing in 20.0.0 changed shape; a consumer upgrades by upgrading.
|
|
13
|
+
|
|
14
|
+
### Added
|
|
15
|
+
|
|
16
|
+
- **LilyPond import.** `HeadMusic::Notation::LilyPond.parse(string)` reads a LilyPond document, or a bare music expression, into a `Content::Composition`: absolute and `\relative` pitches with `is`/`es` accidentals and Dutch contractions, durations with dots and carry-over, rests and whole-bar rests, chords, intra-bar ties, `\key`, `\time` (including mid-piece changes), `\clef`, bar checks, `\header` title and composer, and `\new Staff` / `\new Voice` contexts with `instrumentName` as the voice role. Everything the LilyPond writer emits reads back, so `parse(render(composition))` reproduces the music. `\version`, `\layout`, and `\midi` blocks are skipped whole, as are the header fields the reader does not use, so the Scheme in an everyday engraving preamble costs nothing. A bar-check mismatch raises `LilyPond::ParseError`; constructs outside the subset raise `LilyPond::UnsupportedFeatureError` rather than being skipped.
|
|
17
|
+
- `HeadMusic::Notation::DottedDuration.rhythmic_value_for(fraction)`, the inverse of `dotted_unit_fraction`, shared by the ABC and LilyPond readers.
|
|
18
|
+
|
|
19
|
+
### Changed
|
|
20
|
+
|
|
21
|
+
- **Placing a note no longer costs time linear in the voice's length.** `Content::Voice#place` scanned its placements from the front twice — once for a placement already at the position, once for the insertion point — so filling a long voice was quadratic in its length. Both are now binary searches over the position order the list already keeps. Reading a 28KB LilyPond score went from 76 seconds to under 9; the remaining time is in `Content::Position` arithmetic rather than here.
|
|
22
|
+
|
|
23
|
+
## [20.0.0] - 2026-08-30
|
|
24
|
+
|
|
25
|
+
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.
|
|
26
|
+
|
|
27
|
+
**Migrating from 19.0.0**, in the order a consumer will hit them:
|
|
28
|
+
|
|
29
|
+
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`.
|
|
30
|
+
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_*`.
|
|
31
|
+
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.
|
|
32
|
+
4. Guideline strings are i18n templates. A consumer reading `MESSAGE` constants reads `GuideItemAssessment#message` instead.
|
|
33
|
+
|
|
34
|
+
### Added
|
|
35
|
+
|
|
36
|
+
- **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.
|
|
37
|
+
|
|
38
|
+
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.
|
|
39
|
+
|
|
40
|
+
- `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.
|
|
41
|
+
|
|
42
|
+
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.
|
|
43
|
+
|
|
44
|
+
- `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.
|
|
45
|
+
|
|
46
|
+
- `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.
|
|
47
|
+
|
|
48
|
+
- `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]`.
|
|
49
|
+
|
|
50
|
+
- 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.
|
|
51
|
+
|
|
52
|
+
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.
|
|
53
|
+
|
|
54
|
+
Eight guidelines are classified `:weak`: `FrequentDirectionChanges`, `LargeLeaps`, `LimitOctaveLeaps`, `ModerateDirectionChanges`, `MostlyConjunct`, `PreferContraryMotion`, `PreferImperfect`, and `PrepareOctaveLeaps`.
|
|
55
|
+
|
|
56
|
+
- `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.
|
|
57
|
+
|
|
58
|
+
- `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.
|
|
59
|
+
|
|
60
|
+
- `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.
|
|
61
|
+
|
|
62
|
+
`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.
|
|
63
|
+
|
|
64
|
+
Where a locale has no plural data, `Template.pluralize` falls back to Ruby rather than raising, and records the key it fell back for.
|
|
65
|
+
|
|
66
|
+
- 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.
|
|
67
|
+
|
|
68
|
+
- **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`.
|
|
69
|
+
|
|
70
|
+
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.
|
|
71
|
+
|
|
72
|
+
- **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.
|
|
73
|
+
|
|
74
|
+
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.
|
|
75
|
+
|
|
76
|
+
- `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.
|
|
77
|
+
|
|
78
|
+
### Changed
|
|
79
|
+
|
|
80
|
+
- **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.
|
|
81
|
+
|
|
82
|
+
- **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:
|
|
83
|
+
|
|
84
|
+
| Was | Is |
|
|
85
|
+
| --- | --- |
|
|
86
|
+
| `Guides::CombinedFirstSecondThirdSpeciesMelody` | `Guides::FirstThreeSpeciesMelody` |
|
|
87
|
+
| `Guides::CombinedFirstSecondThirdSpeciesHarmony` | `Guides::FirstThreeSpeciesHarmony` |
|
|
88
|
+
| `Guidelines::AllowedRhythmicValuesForCombined123` | `Guidelines::AllowWholeHalfQuarterNotes` |
|
|
89
|
+
| `Guidelines::AllowedRhythmicValuesForFifthSpecies` | `Guidelines::AllowFifthSpeciesRhythmicValues` |
|
|
90
|
+
|
|
91
|
+
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.
|
|
92
|
+
|
|
93
|
+
- No existing guide's grade changes. Measured across the whole corpus — 3266 rows, 142 voices × 23 guides — every row is identical before and after.
|
|
94
|
+
|
|
95
|
+
- **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.
|
|
96
|
+
|
|
97
|
+
- **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.
|
|
98
|
+
|
|
99
|
+
- **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.
|
|
100
|
+
|
|
101
|
+
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.
|
|
102
|
+
|
|
103
|
+
- **`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.
|
|
104
|
+
|
|
105
|
+
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.
|
|
106
|
+
|
|
107
|
+
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.
|
|
108
|
+
|
|
109
|
+
- `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.
|
|
110
|
+
|
|
111
|
+
- **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.
|
|
112
|
+
|
|
113
|
+
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.
|
|
114
|
+
|
|
115
|
+
- **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?`.
|
|
116
|
+
|
|
117
|
+
- **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.
|
|
118
|
+
|
|
119
|
+
- **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.
|
|
120
|
+
|
|
121
|
+
- **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.
|
|
122
|
+
|
|
123
|
+
- **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`.
|
|
124
|
+
|
|
125
|
+
- **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.
|
|
126
|
+
|
|
127
|
+
`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.
|
|
128
|
+
|
|
129
|
+
- **Breaking.** Grades change outside the degenerate range, deliberately. Three sources, with every affected row recorded in the story's grade table:
|
|
130
|
+
|
|
131
|
+
| Change | Effect |
|
|
132
|
+
| --- | --- |
|
|
133
|
+
| 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. |
|
|
134
|
+
| 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. |
|
|
135
|
+
| `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. |
|
|
136
|
+
| 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. |
|
|
137
|
+
|
|
138
|
+
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.
|
|
139
|
+
|
|
140
|
+
- **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>`:
|
|
141
|
+
|
|
142
|
+
| Key | Reads |
|
|
143
|
+
| --- | --- |
|
|
144
|
+
| `name` | a short label, e.g. "Minimum of eight notes" |
|
|
145
|
+
| `instruction` | what to do |
|
|
146
|
+
| `violations.default` | what to do differently |
|
|
147
|
+
|
|
148
|
+
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.
|
|
149
|
+
|
|
150
|
+
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.
|
|
151
|
+
|
|
152
|
+
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.
|
|
153
|
+
|
|
154
|
+
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.
|
|
155
|
+
|
|
156
|
+
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.
|
|
157
|
+
|
|
158
|
+
`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.
|
|
159
|
+
|
|
160
|
+
`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.
|
|
161
|
+
|
|
162
|
+
- **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.
|
|
163
|
+
|
|
164
|
+
- **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.
|
|
165
|
+
|
|
166
|
+
## [19.0.0] - 2026-08-07
|
|
167
|
+
|
|
10
168
|
### Added
|
|
11
169
|
|
|
12
170
|
- `HeadMusic::Style::Guide` — a lookup facade over every style guide in the gem. `Guide.get("first_species_harmony")` resolves a key to a guide; an unknown key returns `nil` rather than raising, so a consumer can ask about a guide the gem does not have. `Guide.get!` raises `KeyError` instead, and `Guide.known?`, `.all`, `.keys`, and `.key_for` round out the surface. Keys are stable strings suitable for storing in a database. The registry holds twenty-three:
|
|
13
171
|
|
|
14
172
|
| Category | Keys |
|
|
15
173
|
| --- | --- |
|
|
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`, `
|
|
174
|
+
| `: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` |
|
|
175
|
+
| `: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
176
|
|
|
19
177
|
- 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
178
|
- `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 +731,34 @@ note = HeadMusic::Rudiment::Note.get("F#4 dotted-quarter")
|
|
|
573
731
|
|
|
574
732
|
For changes in versions prior to 0.28.0, please refer to the git history.
|
|
575
733
|
|
|
576
|
-
[Unreleased]: https://github.com/roberthead/head_music/compare/
|
|
734
|
+
[Unreleased]: https://github.com/roberthead/head_music/compare/v20.1.0...HEAD
|
|
735
|
+
[20.1.0]: https://github.com/roberthead/head_music/compare/v20.0.0...v20.1.0
|
|
736
|
+
[20.0.0]: https://github.com/roberthead/head_music/compare/v19.0.0...v20.0.0
|
|
737
|
+
[19.0.0]: https://github.com/roberthead/head_music/compare/v18.0.0...v19.0.0
|
|
738
|
+
[18.0.0]: https://github.com/roberthead/head_music/compare/v17.5.0...v18.0.0
|
|
739
|
+
[17.5.0]: https://github.com/roberthead/head_music/compare/v17.3.0...v17.5.0
|
|
740
|
+
[17.3.0]: https://github.com/roberthead/head_music/compare/v17.2.0...v17.3.0
|
|
741
|
+
[17.2.0]: https://github.com/roberthead/head_music/compare/v17.1.0...v17.2.0
|
|
742
|
+
[17.1.0]: https://github.com/roberthead/head_music/compare/v17.0.0...v17.1.0
|
|
743
|
+
[17.0.0]: https://github.com/roberthead/head_music/compare/v15.2.0...v17.0.0
|
|
744
|
+
[15.2.0]: https://github.com/roberthead/head_music/compare/v15.0.0...v15.2.0
|
|
745
|
+
[15.0.0]: https://github.com/roberthead/head_music/compare/v14.0.0...v15.0.0
|
|
746
|
+
[14.0.0]: https://github.com/roberthead/head_music/compare/v13.0.0...v14.0.0
|
|
747
|
+
[13.0.0]: https://github.com/roberthead/head_music/compare/v12.6.0...v13.0.0
|
|
748
|
+
[12.6.0]: https://github.com/roberthead/head_music/compare/v12.5.0...v12.6.0
|
|
749
|
+
[12.5.0]: https://github.com/roberthead/head_music/compare/v12.4.0...v12.5.0
|
|
750
|
+
[12.4.0]: https://github.com/roberthead/head_music/compare/v12.3.0...v12.4.0
|
|
751
|
+
[12.3.0]: https://github.com/roberthead/head_music/compare/v12.2.0...v12.3.0
|
|
752
|
+
[12.2.0]: https://github.com/roberthead/head_music/compare/v12.1.0...v12.2.0
|
|
753
|
+
[12.1.0]: https://github.com/roberthead/head_music/compare/v12.0.1...v12.1.0
|
|
754
|
+
[12.0.1]: https://github.com/roberthead/head_music/compare/v12.0.0...v12.0.1
|
|
755
|
+
[12.0.0]: https://github.com/roberthead/head_music/compare/v11.8.0...v12.0.0
|
|
756
|
+
[11.8.0]: https://github.com/roberthead/head_music/compare/v11.7.0...v11.8.0
|
|
757
|
+
[11.7.0]: https://github.com/roberthead/head_music/compare/v11.6.1...v11.7.0
|
|
758
|
+
[11.6.1]: https://github.com/roberthead/head_music/compare/v11.6.0...v11.6.1
|
|
759
|
+
[11.6.0]: https://github.com/roberthead/head_music/compare/v11.5.1...v11.6.0
|
|
760
|
+
[11.0.0]: https://github.com/roberthead/head_music/compare/v9.1.0...v11.0.0
|
|
761
|
+
[9.0.0]: https://github.com/roberthead/head_music/compare/v8.4.0...v9.0.0
|
|
577
762
|
[8.2.0]: https://github.com/roberthead/head_music/compare/v8.1.1...v8.2.0
|
|
578
763
|
[8.1.1]: https://github.com/roberthead/head_music/compare/v8.1.0...v8.1.1
|
|
579
764
|
[8.1.0]: https://github.com/roberthead/head_music/compare/v8.0.2...v8.1.0
|
|
@@ -595,4 +780,4 @@ For changes in versions prior to 0.28.0, please refer to the git history.
|
|
|
595
780
|
[2.0.0]: https://github.com/roberthead/head_music/compare/v1.0.0...v2.0.0
|
|
596
781
|
[1.0.0]: https://github.com/roberthead/head_music/compare/v0.29.0...v1.0.0
|
|
597
782
|
[0.29.0]: https://github.com/roberthead/head_music/compare/v0.28.0...v0.29.0
|
|
598
|
-
[0.28.0]: https://github.com/roberthead/head_music/releases/tag/v0.28.0
|
|
783
|
+
[0.28.0]: https://github.com/roberthead/head_music/releases/tag/v0.28.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
|
@@ -5,18 +5,23 @@ ruby ">= 3.3.0"
|
|
|
5
5
|
# Specify your gem's dependencies in head_music.gemspec
|
|
6
6
|
gemspec
|
|
7
7
|
|
|
8
|
-
# Allow CI to test against specific ActiveSupport versions
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
8
|
+
# Allow CI to test against specific ActiveSupport versions. An unset or empty
|
|
9
|
+
# value leaves the gemspec range alone -- empty is truthy in Ruby, so a matrix
|
|
10
|
+
# cell that sets the key to "" would otherwise resolve against "~> .0".
|
|
11
|
+
activesupport_version = ENV["ACTIVESUPPORT_VERSION"].to_s
|
|
12
|
+
gem "activesupport", "~> #{activesupport_version}.0" unless activesupport_version.empty?
|
|
12
13
|
|
|
13
14
|
gem "standard", require: false
|
|
14
15
|
|
|
15
16
|
group :test do
|
|
16
|
-
|
|
17
|
+
# rubocop is not listed directly: standard pins it (~> 1.88.0) and must
|
|
18
|
+
# lead. Listing it here let Dependabot bump it past standard, which the
|
|
19
|
+
# resolver "fixed" by downgrading standard 1.56 -> 1.35.
|
|
17
20
|
gem "rubocop-rspec", require: false
|
|
18
21
|
gem "rubocop-rake", require: false
|
|
19
|
-
|
|
22
|
+
# simplecov 1.1.x forwards an anonymous block inside a block, which is a
|
|
23
|
+
# syntax error on Ruby 3.3. Unpin once upstream drops that or we drop 3.3.
|
|
24
|
+
gem "simplecov", "< 1.1", require: false
|
|
20
25
|
end
|
|
21
26
|
|
|
22
27
|
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.1.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
|
|
@@ -216,23 +216,22 @@ PLATFORMS
|
|
|
216
216
|
x86_64-linux
|
|
217
217
|
|
|
218
218
|
DEPENDENCIES
|
|
219
|
-
bundler-audit
|
|
219
|
+
bundler-audit (~> 0.9, >= 0)
|
|
220
220
|
head_music!
|
|
221
221
|
kramdown
|
|
222
222
|
rake (~> 13.0)
|
|
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
|
-
yard
|
|
231
|
+
yard (~> 0.9, >= 0)
|
|
233
232
|
|
|
234
233
|
RUBY VERSION
|
|
235
|
-
|
|
234
|
+
ruby 3.3.0p0
|
|
236
235
|
|
|
237
236
|
BUNDLED WITH
|
|
238
|
-
|
|
237
|
+
4.0.20
|