head_music 17.3.0 → 17.5.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/workflows/ci.yml +3 -3
- data/.github/workflows/release.yml +1 -1
- data/.github/workflows/security.yml +2 -1
- data/.rubocop.yml +6 -0
- data/CHANGELOG.md +15 -0
- data/Gemfile.lock +68 -68
- data/head_music.gemspec +1 -1
- data/lib/head_music/analysis/chord_analysis.rb +123 -0
- data/lib/head_music/analysis/circle.rb +11 -3
- data/lib/head_music/analysis/diatonic_interval.rb +14 -11
- data/lib/head_music/analysis/dyad/chord_implication.rb +101 -0
- data/lib/head_music/analysis/dyad.rb +24 -104
- data/lib/head_music/analysis/interval_cycle.rb +9 -3
- data/lib/head_music/analysis/pitch_collection.rb +35 -109
- data/lib/head_music/analysis/sonority.rb +11 -21
- data/lib/head_music/content/composition/hash_deserializer.rb +113 -0
- data/lib/head_music/content/composition/schema_values.rb +167 -0
- data/lib/head_music/content/composition.rb +0 -216
- data/lib/head_music/content/placement.rb +31 -42
- data/lib/head_music/content/sound_resolver.rb +54 -0
- data/lib/head_music/content/syllable.rb +38 -0
- data/lib/head_music/content/voice/melodic_line.rb +32 -0
- data/lib/head_music/content/voice/melodic_note_pair.rb +33 -0
- data/lib/head_music/content/voice.rb +8 -59
- data/lib/head_music/instruments/alternate_tuning.rb +4 -6
- data/lib/head_music/instruments/catalog_lookup.rb +18 -0
- data/lib/head_music/instruments/instrument.rb +17 -117
- data/lib/head_music/instruments/instrument_catalog.rb +48 -0
- data/lib/head_music/instruments/instrument_configuration.rb +4 -6
- data/lib/head_music/instruments/instrument_family.rb +3 -8
- data/lib/head_music/instruments/instrument_name.rb +49 -0
- data/lib/head_music/instruments/score_order.rb +52 -42
- data/lib/head_music/instruments/staff_profile.rb +71 -0
- data/lib/head_music/instruments/stringing.rb +4 -6
- data/lib/head_music/instruments/stringing_course.rb +4 -6
- data/lib/head_music/instruments/variant.rb +4 -6
- data/lib/head_music/named/locale.rb +32 -0
- data/lib/head_music/named/localized_name.rb +15 -0
- data/lib/head_music/named.rb +1 -45
- data/lib/head_music/notation/abc/body_lexer.rb +48 -23
- data/lib/head_music/notation/abc/duration_resolver.rb +11 -1
- data/lib/head_music/notation/abc/duration_writer.rb +1 -8
- data/lib/head_music/notation/abc/header.rb +27 -16
- data/lib/head_music/notation/abc/parser.rb +44 -183
- data/lib/head_music/notation/abc/preflight.rb +48 -0
- data/lib/head_music/notation/abc/voice_state.rb +161 -0
- data/lib/head_music/notation/abc/writer.rb +34 -17
- data/lib/head_music/notation/dotted_duration.rb +19 -0
- data/lib/head_music/notation/instrument_notation.rb +5 -7
- data/lib/head_music/notation/music_xml/beam_grouper.rb +2 -2
- data/lib/head_music/notation/music_xml/duration_writer.rb +4 -9
- data/lib/head_music/notation/music_xml/preflight.rb +90 -0
- data/lib/head_music/notation/music_xml/render_plan.rb +161 -0
- data/lib/head_music/notation/music_xml/writer.rb +66 -209
- data/lib/head_music/notation/placement_validation.rb +19 -0
- data/lib/head_music/rudiment/enharmonic_equivalence.rb +33 -0
- data/lib/head_music/rudiment/key.rb +8 -36
- data/lib/head_music/rudiment/key_signature/enharmonic_equivalence.rb +21 -15
- data/lib/head_music/rudiment/key_signature.rb +18 -19
- data/lib/head_music/rudiment/meter.rb +17 -13
- data/lib/head_music/rudiment/mode.rb +33 -65
- data/lib/head_music/rudiment/note.rb +18 -21
- data/lib/head_music/rudiment/pitch/enharmonic_equivalence.rb +4 -15
- data/lib/head_music/rudiment/pitch/natural_step.rb +36 -0
- data/lib/head_music/rudiment/pitch/parser.rb +16 -5
- data/lib/head_music/rudiment/pitch.rb +11 -33
- data/lib/head_music/rudiment/qualified_diatonic_context.rb +61 -0
- data/lib/head_music/rudiment/rhythmic_unit/parser.rb +16 -12
- data/lib/head_music/rudiment/rhythmic_unit.rb +21 -37
- data/lib/head_music/rudiment/rhythmic_value/parser.rb +53 -46
- data/lib/head_music/rudiment/spelling/enharmonic_equivalence.rb +13 -0
- data/lib/head_music/rudiment/spelling.rb +0 -25
- data/lib/head_music/rudiment/tuning/just_intonation.rb +2 -4
- data/lib/head_music/rudiment/tuning/meantone.rb +2 -4
- data/lib/head_music/rudiment/tuning/pythagorean.rb +2 -4
- data/lib/head_music/rudiment/tuning/tonal_centered.rb +10 -0
- data/lib/head_music/rudiment/unpitched_note.rb +3 -5
- data/lib/head_music/style/annotation/configured.rb +35 -0
- data/lib/head_music/style/annotation.rb +32 -44
- data/lib/head_music/style/guidelines/allowed_rhythmic_values_for_fifth_species.rb +0 -10
- data/lib/head_music/style/guidelines/contoured.rb +65 -53
- data/lib/head_music/style/guidelines/dissonance_figure_detection.rb +55 -16
- data/lib/head_music/style/guidelines/florid_dissonance_treatment.rb +0 -37
- data/lib/head_music/style/guidelines/large_leaps.rb +10 -2
- data/lib/head_music/style/guidelines/minimum_melodic_intervals.rb +3 -25
- data/lib/head_music/style/guidelines/minimum_notes.rb +3 -25
- data/lib/head_music/style/guidelines/minimum_threshold.rb +38 -0
- data/lib/head_music/style/guidelines/no_parallel_perfect.rb +31 -0
- data/lib/head_music/style/guidelines/no_parallel_perfect_on_downbeats.rb +3 -17
- data/lib/head_music/style/guidelines/no_parallel_perfect_with_syncopation.rb +3 -17
- data/lib/head_music/style/guidelines/notes_same_length.rb +1 -1
- data/lib/head_music/style/guidelines/prepare_octave_leaps.rb +10 -7
- data/lib/head_music/style/guidelines/singable_intervals.rb +22 -8
- data/lib/head_music/style/guidelines/suspension_treatment.rb +30 -12
- data/lib/head_music/style/guidelines/third_species_dissonance_treatment.rb +0 -7
- data/lib/head_music/style/guidelines/triple_meter_dissonance_treatment.rb +0 -12
- data/lib/head_music/style/guidelines/weak_beat_dissonance_treatment.rb +2 -42
- data/lib/head_music/style/guides/fifth_species_melody.rb +3 -13
- data/lib/head_music/style/guides/fourth_species_melody.rb +3 -13
- data/lib/head_music/style/guides/second_species_harmony.rb +2 -5
- data/lib/head_music/style/guides/second_species_melody.rb +2 -12
- data/lib/head_music/style/guides/species_harmony.rb +14 -0
- data/lib/head_music/style/guides/species_melody.rb +21 -0
- data/lib/head_music/style/guides/third_species_harmony.rb +2 -5
- data/lib/head_music/style/guides/third_species_melody.rb +3 -13
- data/lib/head_music/style/guides/third_species_triple_meter_harmony.rb +2 -5
- data/lib/head_music/style/guides/third_species_triple_meter_melody.rb +2 -12
- data/lib/head_music/time/conductor.rb +19 -125
- data/lib/head_music/time/musical_position.rb +14 -26
- data/lib/head_music/time/musical_time_converter.rb +104 -0
- data/lib/head_music/time/radix_carry.rb +22 -0
- data/lib/head_music/time/smpte_converter.rb +51 -0
- data/lib/head_music/time/smpte_timecode.rb +13 -23
- data/lib/head_music/time.rb +3 -0
- data/lib/head_music/value_equality.rb +37 -0
- data/lib/head_music/version.rb +1 -1
- data/lib/head_music.rb +26 -1
- data/user-stories/done/lyrics.md +162 -0
- data/user-stories/index.html +7 -3
- metadata +45 -6
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5a439a4b39c708eb38984b3878e7bc0c02ce92d7c693d34041ed3b8c69acfffe
|
|
4
|
+
data.tar.gz: d0b702f38b7de8826fe7a345c3b17d0a8ea8c963bd922343889ba97a47f3538e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 266406dca2449e15a0d72e4ac315190929aa048d18c6236f07d5eea23d5b220dcc037408edb7f5a2a508e677dee5bbf70bb96b620e1bcdcf75df145623efff9e
|
|
7
|
+
data.tar.gz: 81fcf78c8ec6234b1478d0c6ca92acdc096bb714de76e33d1bd19bf21e52fce8c0b3831fa72b2e8ec2cbe4dad21d399886cd0e62c416934fd6778ab08d03308d
|
data/.github/workflows/ci.yml
CHANGED
|
@@ -16,7 +16,7 @@ jobs:
|
|
|
16
16
|
activesupport-version: ['7.2', '8.0']
|
|
17
17
|
|
|
18
18
|
steps:
|
|
19
|
-
- uses: actions/checkout@
|
|
19
|
+
- uses: actions/checkout@v5
|
|
20
20
|
|
|
21
21
|
- name: Set up Ruby ${{ matrix.ruby-version }}
|
|
22
22
|
uses: ruby/setup-ruby@v1
|
|
@@ -43,7 +43,7 @@ jobs:
|
|
|
43
43
|
lint:
|
|
44
44
|
runs-on: ubuntu-latest
|
|
45
45
|
steps:
|
|
46
|
-
- uses: actions/checkout@
|
|
46
|
+
- uses: actions/checkout@v5
|
|
47
47
|
|
|
48
48
|
- name: Set up Ruby
|
|
49
49
|
uses: ruby/setup-ruby@v1
|
|
@@ -57,7 +57,7 @@ jobs:
|
|
|
57
57
|
build:
|
|
58
58
|
runs-on: ubuntu-latest
|
|
59
59
|
steps:
|
|
60
|
-
- uses: actions/checkout@
|
|
60
|
+
- uses: actions/checkout@v5
|
|
61
61
|
|
|
62
62
|
- name: Set up Ruby
|
|
63
63
|
uses: ruby/setup-ruby@v1
|
|
@@ -8,13 +8,14 @@ on:
|
|
|
8
8
|
schedule:
|
|
9
9
|
# Run security checks daily at 9 AM UTC
|
|
10
10
|
- cron: '0 9 * * *'
|
|
11
|
+
workflow_dispatch:
|
|
11
12
|
|
|
12
13
|
jobs:
|
|
13
14
|
security:
|
|
14
15
|
runs-on: ubuntu-latest
|
|
15
16
|
|
|
16
17
|
steps:
|
|
17
|
-
- uses: actions/checkout@
|
|
18
|
+
- uses: actions/checkout@v5
|
|
18
19
|
|
|
19
20
|
- name: Set up Ruby
|
|
20
21
|
uses: ruby/setup-ruby@v1
|
data/.rubocop.yml
CHANGED
|
@@ -31,6 +31,12 @@ RSpec/ContextWording:
|
|
|
31
31
|
RSpec/MultipleExpectations:
|
|
32
32
|
Enabled: false
|
|
33
33
|
|
|
34
|
+
# Several data-driven specs build examples from collections resolved at
|
|
35
|
+
# definition time (e.g. `legacy.each`, `melodic_guides.each`), which requires
|
|
36
|
+
# those values to live as locals in the example-group scope.
|
|
37
|
+
RSpec/LeakyLocalVariable:
|
|
38
|
+
Enabled: false
|
|
39
|
+
|
|
34
40
|
RSpec/NestedGroups:
|
|
35
41
|
Max: 5
|
|
36
42
|
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [17.5.0] - 2026-07-21
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- Sung text (lyrics) can be attached to the notes of a voice. `HeadMusic::Content::Placement#sing(text, verse:, hyphen_after:)` assigns a syllable to a placement, keyed by verse so a note carries at most one syllable per verse and any number of verses (`glo` on verse 1, `peace` on verse 2 of the same note). A new immutable `HeadMusic::Content::Syllable` value object stores only the minimal linguistic fact — `text`, `verse`, and a `hyphen_after` boolean marking that the word continues onto the next sung note; the MusicXML `syllabic` value (`single`/`begin`/`middle`/`end`) is derived at render time rather than stored, and a melisma is represented by the absence of a syllable on the held notes rather than a stored flag. Syllables serialize through `Placement#to_h` and round-trip through the composition hash deserializer, validated at the import boundary by `Composition::SchemaValues` (non-empty text, a positive-integer verse, and no duplicate verse per placement).
|
|
15
|
+
- The MusicXML writer emits a `<lyric number="N">` element as the last child of each `<note>`, on the lead note of a chord only and the attack of a tied chain only, deriving `<syllabic>` from the `hyphen_after` booleans of the syllable and its predecessor in the same verse and XML-escaping the text. Held notes of a melisma carry no `<lyric>`, matching MusicXML's continuation-by-absence. ABC `w:` lyric-line input and the MusicXML `<extend/>` melisma line remain out of scope for a future release.
|
|
16
|
+
|
|
17
|
+
## [17.4.0] - 2026-07-20
|
|
18
|
+
|
|
19
|
+
### Changed
|
|
20
|
+
|
|
21
|
+
- Internal refactoring for clarity and maintainability, with no changes to public behavior or output. Complex classes were split along their natural seams by extracting focused collaborators and value objects, each with its own spec: `MusicXML::Preflight` and `MusicXML::RenderPlan` from the MusicXML `Writer`; `Instruments::InstrumentName`, `InstrumentCatalog`, and `StaffProfile` from `Instrument`; `Analysis::ChordAnalysis` from `PitchCollection`; `Content::SoundResolver` from `Placement`; `Time::SmpteConverter` and `MusicalTimeConverter` from `Conductor`; `Composition::SchemaValues` from `HashDeserializer`; `Pitch::NaturalStep` from `Pitch`; `Voice::MelodicLine` from `Voice`; `Dyad::ChordImplication` from `Dyad`; and `ABC::Preflight` plus per-voice note-assembly moved onto `ABC::VoiceState` in the ABC parser.
|
|
22
|
+
- Shared value-object equality was consolidated into a `ValueEquality` mixin, and duplication was reduced across the style guides, notation writers, and rudiments.
|
|
23
|
+
- Nested classes that had outgrown their host files were given their own files without changing their constant paths: `Named::Locale` and `Named::LocalizedName`, `Voice::MelodicNotePair`, `Spelling::EnharmonicEquivalence`, and `Style::Annotation::Configured`.
|
|
24
|
+
|
|
10
25
|
## [17.3.0] - 2026-07-19
|
|
11
26
|
|
|
12
27
|
### Added
|
data/Gemfile.lock
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
PATH
|
|
2
2
|
remote: .
|
|
3
3
|
specs:
|
|
4
|
-
head_music (17.
|
|
4
|
+
head_music (17.5.0)
|
|
5
5
|
activesupport (>= 7.0, < 10)
|
|
6
|
-
humanize (
|
|
6
|
+
humanize (>= 2, < 4)
|
|
7
7
|
i18n (~> 1.8)
|
|
8
8
|
|
|
9
9
|
GEM
|
|
@@ -30,98 +30,100 @@ GEM
|
|
|
30
30
|
ice_nine (~> 0.11.0)
|
|
31
31
|
thread_safe (~> 0.3, >= 0.3.1)
|
|
32
32
|
base64 (0.3.0)
|
|
33
|
-
bigdecimal (
|
|
34
|
-
bundler-audit (0.9.
|
|
35
|
-
bundler (>= 1.2.0
|
|
33
|
+
bigdecimal (4.1.2)
|
|
34
|
+
bundler-audit (0.9.3)
|
|
35
|
+
bundler (>= 1.2.0)
|
|
36
36
|
thor (~> 1.0)
|
|
37
37
|
childprocess (5.1.0)
|
|
38
38
|
logger (~> 1.5)
|
|
39
39
|
coercible (1.0.0)
|
|
40
40
|
descendants_tracker (~> 0.0.1)
|
|
41
|
-
concurrent-ruby (1.3.
|
|
42
|
-
connection_pool (
|
|
41
|
+
concurrent-ruby (1.3.8)
|
|
42
|
+
connection_pool (3.0.2)
|
|
43
43
|
descendants_tracker (0.0.4)
|
|
44
44
|
thread_safe (~> 0.3, >= 0.3.1)
|
|
45
45
|
diff-lcs (1.6.2)
|
|
46
|
-
docile (1.4.1)
|
|
47
46
|
drb (2.2.3)
|
|
48
|
-
dry-configurable (1.
|
|
49
|
-
dry-core (~> 1.
|
|
47
|
+
dry-configurable (1.4.0)
|
|
48
|
+
dry-core (~> 1.0)
|
|
50
49
|
zeitwerk (~> 2.6)
|
|
51
|
-
dry-core (1.
|
|
50
|
+
dry-core (1.2.0)
|
|
52
51
|
concurrent-ruby (~> 1.0)
|
|
53
52
|
logger
|
|
54
53
|
zeitwerk (~> 2.6)
|
|
55
|
-
dry-inflector (1.
|
|
54
|
+
dry-inflector (1.3.1)
|
|
56
55
|
dry-initializer (3.2.0)
|
|
57
56
|
dry-logic (1.6.0)
|
|
58
57
|
bigdecimal
|
|
59
58
|
concurrent-ruby (~> 1.0)
|
|
60
59
|
dry-core (~> 1.1)
|
|
61
60
|
zeitwerk (~> 2.6)
|
|
62
|
-
dry-schema (1.
|
|
61
|
+
dry-schema (1.16.0)
|
|
63
62
|
concurrent-ruby (~> 1.0)
|
|
64
63
|
dry-configurable (~> 1.0, >= 1.0.1)
|
|
65
64
|
dry-core (~> 1.1)
|
|
66
65
|
dry-initializer (~> 3.2)
|
|
67
|
-
dry-logic (~> 1.
|
|
68
|
-
dry-types (~> 1.
|
|
66
|
+
dry-logic (~> 1.6)
|
|
67
|
+
dry-types (~> 1.9, >= 1.9.1)
|
|
69
68
|
zeitwerk (~> 2.6)
|
|
70
|
-
dry-types (1.
|
|
71
|
-
bigdecimal (
|
|
69
|
+
dry-types (1.9.1)
|
|
70
|
+
bigdecimal (>= 3.0)
|
|
72
71
|
concurrent-ruby (~> 1.0)
|
|
73
72
|
dry-core (~> 1.0)
|
|
74
73
|
dry-inflector (~> 1.0)
|
|
75
74
|
dry-logic (~> 1.4)
|
|
76
75
|
zeitwerk (~> 2.6)
|
|
77
76
|
erubi (1.13.1)
|
|
78
|
-
flay (2.
|
|
77
|
+
flay (2.14.4)
|
|
79
78
|
erubi (~> 1.10)
|
|
80
|
-
path_expander (~>
|
|
81
|
-
|
|
79
|
+
path_expander (~> 2.0)
|
|
80
|
+
prism (~> 1.7)
|
|
82
81
|
sexp_processor (~> 4.0)
|
|
83
|
-
flog (4.
|
|
84
|
-
path_expander (~>
|
|
85
|
-
|
|
82
|
+
flog (4.9.4)
|
|
83
|
+
path_expander (~> 2.0)
|
|
84
|
+
prism (~> 1.7)
|
|
86
85
|
sexp_processor (~> 4.8)
|
|
87
|
-
humanize (
|
|
88
|
-
i18n (1.
|
|
86
|
+
humanize (3.1.0)
|
|
87
|
+
i18n (1.15.2)
|
|
89
88
|
concurrent-ruby (~> 1.0)
|
|
90
89
|
ice_nine (0.11.2)
|
|
91
90
|
json (2.21.1)
|
|
92
|
-
kramdown (2.5.
|
|
93
|
-
rexml (>= 3.
|
|
94
|
-
language_server-protocol (3.17.0.
|
|
91
|
+
kramdown (2.5.2)
|
|
92
|
+
rexml (>= 3.4.4)
|
|
93
|
+
language_server-protocol (3.17.0.6)
|
|
95
94
|
launchy (3.1.1)
|
|
96
95
|
addressable (~> 2.8)
|
|
97
96
|
childprocess (~> 5.0)
|
|
98
97
|
logger (~> 1.6)
|
|
99
98
|
lint_roller (1.1.0)
|
|
100
99
|
logger (1.7.0)
|
|
101
|
-
minitest (
|
|
102
|
-
|
|
103
|
-
|
|
100
|
+
minitest (6.0.6)
|
|
101
|
+
drb (~> 2.0)
|
|
102
|
+
prism (~> 1.5)
|
|
103
|
+
ostruct (0.6.3)
|
|
104
|
+
parallel (2.1.0)
|
|
105
|
+
parser (3.3.12.0)
|
|
104
106
|
ast (~> 2.4.1)
|
|
105
107
|
racc
|
|
106
|
-
path_expander (
|
|
107
|
-
prism (1.
|
|
108
|
-
public_suffix (
|
|
108
|
+
path_expander (2.0.1)
|
|
109
|
+
prism (1.9.0)
|
|
110
|
+
public_suffix (7.0.5)
|
|
109
111
|
racc (1.8.1)
|
|
110
112
|
rainbow (3.1.1)
|
|
111
|
-
rake (13.
|
|
113
|
+
rake (13.4.2)
|
|
112
114
|
reek (6.5.0)
|
|
113
115
|
dry-schema (~> 1.13)
|
|
114
116
|
logger (~> 1.6)
|
|
115
117
|
parser (~> 3.3.0)
|
|
116
118
|
rainbow (>= 2.0, < 4.0)
|
|
117
119
|
rexml (~> 3.1)
|
|
118
|
-
regexp_parser (2.
|
|
120
|
+
regexp_parser (2.12.0)
|
|
119
121
|
rexml (3.4.4)
|
|
120
|
-
rspec (3.13.
|
|
122
|
+
rspec (3.13.2)
|
|
121
123
|
rspec-core (~> 3.13.0)
|
|
122
124
|
rspec-expectations (~> 3.13.0)
|
|
123
125
|
rspec-mocks (~> 3.13.0)
|
|
124
|
-
rspec-core (3.13.
|
|
126
|
+
rspec-core (3.13.6)
|
|
125
127
|
rspec-support (~> 3.13.0)
|
|
126
128
|
rspec-expectations (3.13.5)
|
|
127
129
|
diff-lcs (>= 1.2.0, < 2.0)
|
|
@@ -129,43 +131,46 @@ GEM
|
|
|
129
131
|
rspec-its (2.0.0)
|
|
130
132
|
rspec-core (>= 3.13.0)
|
|
131
133
|
rspec-expectations (>= 3.13.0)
|
|
132
|
-
rspec-mocks (3.13.
|
|
134
|
+
rspec-mocks (3.13.8)
|
|
133
135
|
diff-lcs (>= 1.2.0, < 2.0)
|
|
134
136
|
rspec-support (~> 3.13.0)
|
|
135
|
-
rspec-support (3.13.
|
|
136
|
-
rubocop (1.
|
|
137
|
+
rspec-support (3.13.7)
|
|
138
|
+
rubocop (1.88.2)
|
|
137
139
|
json (~> 2.3)
|
|
138
140
|
language_server-protocol (~> 3.17.0.2)
|
|
139
141
|
lint_roller (~> 1.1.0)
|
|
140
|
-
parallel (
|
|
142
|
+
parallel (>= 1.10)
|
|
141
143
|
parser (>= 3.3.0.2)
|
|
142
144
|
rainbow (>= 2.2.2, < 4.0)
|
|
143
145
|
regexp_parser (>= 2.9.3, < 3.0)
|
|
144
|
-
rubocop-ast (>= 1.
|
|
146
|
+
rubocop-ast (>= 1.49.0, < 2.0)
|
|
145
147
|
ruby-progressbar (~> 1.7)
|
|
146
148
|
unicode-display_width (>= 2.4.0, < 4.0)
|
|
147
|
-
rubocop-ast (1.
|
|
149
|
+
rubocop-ast (1.50.0)
|
|
148
150
|
parser (>= 3.3.7.2)
|
|
149
|
-
prism (~> 1.
|
|
150
|
-
rubocop-performance (1.
|
|
151
|
+
prism (~> 1.7)
|
|
152
|
+
rubocop-performance (1.26.1)
|
|
151
153
|
lint_roller (~> 1.1)
|
|
152
154
|
rubocop (>= 1.75.0, < 2.0)
|
|
153
|
-
rubocop-ast (>= 1.
|
|
155
|
+
rubocop-ast (>= 1.47.1, < 2.0)
|
|
154
156
|
rubocop-rake (0.7.1)
|
|
155
157
|
lint_roller (~> 1.1)
|
|
156
158
|
rubocop (>= 1.72.1)
|
|
157
|
-
rubocop-rspec (3.
|
|
159
|
+
rubocop-rspec (3.10.2)
|
|
158
160
|
lint_roller (~> 1.1)
|
|
159
|
-
|
|
161
|
+
regexp_parser (>= 2.0)
|
|
162
|
+
rubocop (~> 1.86, >= 1.86.2)
|
|
160
163
|
ruby-progressbar (1.13.0)
|
|
161
|
-
ruby_parser (3.
|
|
164
|
+
ruby_parser (3.22.0)
|
|
162
165
|
racc (~> 1.5)
|
|
163
166
|
sexp_processor (~> 4.16)
|
|
164
|
-
rubycritic (
|
|
167
|
+
rubycritic (5.0.0)
|
|
165
168
|
flay (~> 2.13)
|
|
166
169
|
flog (~> 4.7)
|
|
167
170
|
launchy (>= 2.5.2)
|
|
171
|
+
ostruct
|
|
168
172
|
parser (>= 3.3.0.5)
|
|
173
|
+
prism (>= 1.6.0)
|
|
169
174
|
rainbow (~> 3.1.1)
|
|
170
175
|
reek (~> 6.5.0, < 7.0)
|
|
171
176
|
rexml
|
|
@@ -174,40 +179,35 @@ GEM
|
|
|
174
179
|
tty-which (~> 0.5.0)
|
|
175
180
|
virtus (~> 2.0)
|
|
176
181
|
securerandom (0.4.1)
|
|
177
|
-
sexp_processor (4.17.
|
|
178
|
-
simplecov (0.
|
|
179
|
-
|
|
180
|
-
simplecov-html (~> 0.11)
|
|
181
|
-
simplecov_json_formatter (~> 0.1)
|
|
182
|
-
simplecov-html (0.13.2)
|
|
183
|
-
simplecov_json_formatter (0.1.4)
|
|
184
|
-
standard (1.50.0)
|
|
182
|
+
sexp_processor (4.17.5)
|
|
183
|
+
simplecov (1.0.2)
|
|
184
|
+
standard (1.56.0)
|
|
185
185
|
language_server-protocol (~> 3.17.0.2)
|
|
186
186
|
lint_roller (~> 1.0)
|
|
187
|
-
rubocop (~> 1.
|
|
187
|
+
rubocop (~> 1.88.0)
|
|
188
188
|
standard-custom (~> 1.0.0)
|
|
189
189
|
standard-performance (~> 1.8)
|
|
190
190
|
standard-custom (1.0.2)
|
|
191
191
|
lint_roller (~> 1.0)
|
|
192
192
|
rubocop (~> 1.50)
|
|
193
|
-
standard-performance (1.
|
|
193
|
+
standard-performance (1.9.0)
|
|
194
194
|
lint_roller (~> 1.1)
|
|
195
|
-
rubocop-performance (~> 1.
|
|
196
|
-
thor (1.
|
|
195
|
+
rubocop-performance (~> 1.26.0)
|
|
196
|
+
thor (1.5.0)
|
|
197
197
|
thread_safe (0.3.6)
|
|
198
198
|
tty-which (0.5.0)
|
|
199
199
|
tzinfo (2.0.6)
|
|
200
200
|
concurrent-ruby (~> 1.0)
|
|
201
|
-
unicode-display_width (3.
|
|
202
|
-
unicode-emoji (~> 4.
|
|
203
|
-
unicode-emoji (4.0
|
|
201
|
+
unicode-display_width (3.2.0)
|
|
202
|
+
unicode-emoji (~> 4.1)
|
|
203
|
+
unicode-emoji (4.2.0)
|
|
204
204
|
uri (1.1.1)
|
|
205
205
|
virtus (2.0.0)
|
|
206
206
|
axiom-types (~> 0.1)
|
|
207
207
|
coercible (~> 1.0)
|
|
208
208
|
descendants_tracker (~> 0.0, >= 0.0.3)
|
|
209
|
-
yard (0.9.
|
|
210
|
-
zeitwerk (2.
|
|
209
|
+
yard (0.9.45)
|
|
210
|
+
zeitwerk (2.8.2)
|
|
211
211
|
|
|
212
212
|
PLATFORMS
|
|
213
213
|
arm64-darwin-22
|
data/head_music.gemspec
CHANGED
|
@@ -33,7 +33,7 @@ Gem::Specification.new do |spec|
|
|
|
33
33
|
spec.required_ruby_version = ">= 3.3.0"
|
|
34
34
|
|
|
35
35
|
spec.add_runtime_dependency "activesupport", ">= 7.0", "< 10"
|
|
36
|
-
spec.add_runtime_dependency "humanize", "
|
|
36
|
+
spec.add_runtime_dependency "humanize", ">= 2", "< 4"
|
|
37
37
|
spec.add_runtime_dependency "i18n", "~> 1.8"
|
|
38
38
|
|
|
39
39
|
spec.add_development_dependency "rake", "~> 13.0"
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# A module for musical analysis
|
|
2
|
+
module HeadMusic::Analysis; end
|
|
3
|
+
|
|
4
|
+
# Classifies a PitchCollection as a tertian chord: whether it is a triad,
|
|
5
|
+
# seventh, or extended chord; its triad quality (major/minor/diminished/
|
|
6
|
+
# augmented); and its inversion. "Tertian" means the pitches, once reduced to
|
|
7
|
+
# within an octave and rotated, stack in thirds.
|
|
8
|
+
class HeadMusic::Analysis::ChordAnalysis
|
|
9
|
+
# Scale-degree signatures (above the bass) of each tertian chord size, used
|
|
10
|
+
# to recognize a stack of thirds in any rotation.
|
|
11
|
+
TERTIAN_SONORITIES = {
|
|
12
|
+
implied_triad: [3],
|
|
13
|
+
triad: [3, 5],
|
|
14
|
+
seventh_chord: [3, 5, 7],
|
|
15
|
+
ninth_chord: [2, 3, 5, 7],
|
|
16
|
+
eleventh_chord: [2, 3, 4, 5, 7],
|
|
17
|
+
thirteenth_chord: [2, 3, 4, 5, 6, 7] # a.k.a. diatonic scale
|
|
18
|
+
}.freeze
|
|
19
|
+
|
|
20
|
+
# The interval-shorthand pairs (bass-to-third, third-to-fifth) that spell each
|
|
21
|
+
# triad quality, in every inversion.
|
|
22
|
+
TRIAD_PATTERNS = {
|
|
23
|
+
major: [%w[M3 m3], %w[m3 P4], %w[P4 M3]],
|
|
24
|
+
minor: [%w[m3 M3], %w[M3 P4], %w[P4 m3]],
|
|
25
|
+
diminished: [%w[m3 m3], %w[m3 A4], %w[A4 m3]],
|
|
26
|
+
augmented: [%w[M3 M3], %w[M3 d4], %w[d4 M3]]
|
|
27
|
+
}.freeze
|
|
28
|
+
|
|
29
|
+
attr_reader :collection
|
|
30
|
+
|
|
31
|
+
def initialize(collection)
|
|
32
|
+
@collection = collection
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def triad?
|
|
36
|
+
collection.trichord? && tertian?
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def consonant_triad?
|
|
40
|
+
major_triad? || minor_triad?
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def major_triad?
|
|
44
|
+
triad_type?(:major)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def minor_triad?
|
|
48
|
+
triad_type?(:minor)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def diminished_triad?
|
|
52
|
+
triad_type?(:diminished)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def augmented_triad?
|
|
56
|
+
triad_type?(:augmented)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def root_position_triad?
|
|
60
|
+
collection.trichord? && stacked_in_thirds?(collection.reduction)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def first_inversion_triad?
|
|
64
|
+
collection.trichord? && stacked_in_thirds?(collection.reduction.uninvert)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def second_inversion_triad?
|
|
68
|
+
collection.trichord? && stacked_in_thirds?(collection.reduction.invert)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def seventh_chord?
|
|
72
|
+
collection.tetrachord? && tertian?
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def root_position_seventh_chord?
|
|
76
|
+
collection.tetrachord? && stacked_in_thirds?(collection.reduction)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def first_inversion_seventh_chord?
|
|
80
|
+
collection.tetrachord? && stacked_in_thirds?(collection.reduction.uninvert)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def second_inversion_seventh_chord?
|
|
84
|
+
collection.tetrachord? && stacked_in_thirds?(collection.reduction.uninvert.uninvert)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def third_inversion_seventh_chord?
|
|
88
|
+
collection.tetrachord? && stacked_in_thirds?(collection.reduction.invert)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def ninth_chord?
|
|
92
|
+
collection.pentachord? && tertian?
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def eleventh_chord?
|
|
96
|
+
collection.hexachord? && tertian?
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def thirteenth_chord?
|
|
100
|
+
collection.heptachord? && tertian?
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def tertian?
|
|
104
|
+
return false unless collection.diatonic_intervals.any?
|
|
105
|
+
|
|
106
|
+
inversion = collection.reduction
|
|
107
|
+
collection.pitches.length.times do
|
|
108
|
+
return true if TERTIAN_SONORITIES.value?(inversion.scale_degrees_above_bass_pitch)
|
|
109
|
+
inversion = inversion.invert
|
|
110
|
+
end
|
|
111
|
+
false
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
private
|
|
115
|
+
|
|
116
|
+
def triad_type?(type)
|
|
117
|
+
TRIAD_PATTERNS[type].include?(collection.reduction_diatonic_intervals.map(&:shorthand))
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def stacked_in_thirds?(reduced_collection)
|
|
121
|
+
reduced_collection.diatonic_intervals.all?(&:third?)
|
|
122
|
+
end
|
|
123
|
+
end
|
|
@@ -46,14 +46,22 @@ class HeadMusic::Analysis::Circle < HeadMusic::Analysis::IntervalCycle
|
|
|
46
46
|
def pitches_down
|
|
47
47
|
@pitches_down ||= [starting_pitch].tap do |list|
|
|
48
48
|
loop do
|
|
49
|
-
next_pitch = list.last
|
|
50
|
-
|
|
51
|
-
break if next_pitch.pitch_class == list.first.pitch_class
|
|
49
|
+
next_pitch = folded_down(list.last)
|
|
50
|
+
break if next_pitch.pitch_class == starting_pitch.pitch_class
|
|
52
51
|
|
|
53
52
|
list << next_pitch
|
|
54
53
|
end
|
|
55
54
|
end
|
|
56
55
|
end
|
|
57
56
|
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
# The next pitch a cycle-interval below, folded up by octaves to stay within an octave of the start.
|
|
60
|
+
def folded_down(pitch)
|
|
61
|
+
next_pitch = pitch - interval
|
|
62
|
+
next_pitch += octave while starting_pitch - next_pitch > 12
|
|
63
|
+
next_pitch
|
|
64
|
+
end
|
|
65
|
+
|
|
58
66
|
private_class_method :new
|
|
59
67
|
end
|
|
@@ -59,15 +59,17 @@ class HeadMusic::Analysis::DiatonicInterval
|
|
|
59
59
|
|
|
60
60
|
# Override Named module method to try I18n and fall back to computed name
|
|
61
61
|
def name(locale_code: nil)
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
62
|
+
computed_name = naming.name
|
|
63
|
+
return computed_name unless locale_code
|
|
64
|
+
|
|
65
|
+
translations = I18n.backend.translations[locale_code]
|
|
66
|
+
return computed_name unless translations
|
|
67
|
+
|
|
68
|
+
name_key = HeadMusic::Utilities::HashKey.for(computed_name)
|
|
69
|
+
locale_data = translations[:head_music] || {}
|
|
70
|
+
locale_data.dig(:diatonic_intervals, name_key) ||
|
|
71
|
+
locale_data.dig(:chromatic_intervals, name_key) ||
|
|
72
|
+
computed_name
|
|
71
73
|
end
|
|
72
74
|
|
|
73
75
|
def to_s
|
|
@@ -79,8 +81,9 @@ class HeadMusic::Analysis::DiatonicInterval
|
|
|
79
81
|
if identifier.is_a?(String) || identifier.is_a?(Symbol)
|
|
80
82
|
name = Parser.new(identifier)
|
|
81
83
|
semitones = Semitones.new(name.degree_name.to_sym, name.quality_name).count
|
|
82
|
-
|
|
83
|
-
|
|
84
|
+
middle_c = HeadMusic::Rudiment::Pitch.middle_c
|
|
85
|
+
higher_pitch = HeadMusic::Rudiment::Pitch.from_number_and_letter(middle_c + semitones, name.higher_letter)
|
|
86
|
+
interval = new(middle_c, higher_pitch)
|
|
84
87
|
interval.ensure_localized_name(name: identifier.to_s)
|
|
85
88
|
interval
|
|
86
89
|
else
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
class HeadMusic::Analysis::Dyad
|
|
2
|
+
# Enumerates the chords (as PitchCollections) that contain a given dyad.
|
|
3
|
+
# Trichords and seventh chords are built at every chromatic root from a fixed
|
|
4
|
+
# table of interval stacks, then kept only when they actually span the dyad.
|
|
5
|
+
# An optional key narrows the results to diatonic chords and ranks the
|
|
6
|
+
# survivors by how many of their pitches the key contains.
|
|
7
|
+
class ChordImplication
|
|
8
|
+
TRICHORD_INTERVALS = [
|
|
9
|
+
%w[M3 P5], # major triad
|
|
10
|
+
%w[m3 P5], # minor triad
|
|
11
|
+
%w[m3 d5], # diminished triad
|
|
12
|
+
%w[M3 A5], # augmented triad
|
|
13
|
+
%w[P4 P5], # sus4 (not a triad)
|
|
14
|
+
%w[M2 P5] # sus2 (not a triad)
|
|
15
|
+
].freeze
|
|
16
|
+
|
|
17
|
+
SEVENTH_CHORD_INTERVALS = [
|
|
18
|
+
%w[M3 P5 M7], # major seventh
|
|
19
|
+
%w[M3 P5 m7], # dominant seventh (major-minor)
|
|
20
|
+
%w[m3 P5 m7], # minor seventh
|
|
21
|
+
%w[m3 P5 M7], # minor-major seventh
|
|
22
|
+
%w[m3 d5 m7], # half-diminished seventh
|
|
23
|
+
%w[m3 d5 d7], # diminished seventh
|
|
24
|
+
%w[M2 M3 P5 m7], # dominant ninth
|
|
25
|
+
%w[m2 M3 P5 m7], # dominant minor ninth
|
|
26
|
+
%w[M2 m3 P5 m7], # minor ninth
|
|
27
|
+
%w[M2 M3 P5 M7] # major ninth
|
|
28
|
+
].freeze
|
|
29
|
+
|
|
30
|
+
attr_reader :dyad_pitch_classes, :key
|
|
31
|
+
|
|
32
|
+
def initialize(dyad_pitch_classes, key)
|
|
33
|
+
@dyad_pitch_classes = dyad_pitch_classes
|
|
34
|
+
@key = key
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def trichords
|
|
38
|
+
@trichords ||= ranked(chords_from(TRICHORD_INTERVALS))
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def seventh_chords
|
|
42
|
+
@seventh_chords ||= ranked(chords_from(SEVENTH_CHORD_INTERVALS))
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
# Without a key every chord stands; with one, keep the diatonic chords and
|
|
48
|
+
# order them by how much they agree with the key. Concentrating the key
|
|
49
|
+
# check here keeps filter and sort free of their own guards.
|
|
50
|
+
def ranked(chords)
|
|
51
|
+
return chords unless key
|
|
52
|
+
|
|
53
|
+
sort_by_diatonic_agreement(filter_by_key(chords))
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def chords_from(interval_sets)
|
|
57
|
+
candidate_chords(interval_sets)
|
|
58
|
+
.select { |chord| includes_dyad?(chord) }
|
|
59
|
+
.uniq { |chord| chord.pitch_classes.sort.map(&:to_i) }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def candidate_chords(interval_sets)
|
|
63
|
+
candidate_roots.flat_map do |root_pitch|
|
|
64
|
+
interval_sets.map { |intervals| build_chord(root_pitch, intervals) }
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def candidate_roots
|
|
69
|
+
HeadMusic::Rudiment::Spelling::CHROMATIC_SPELLINGS.map do |root_spelling|
|
|
70
|
+
HeadMusic::Rudiment::Pitch.get("#{root_spelling}4")
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def build_chord(root_pitch, intervals)
|
|
75
|
+
chord_pitches = [root_pitch] + intervals.map do |name|
|
|
76
|
+
HeadMusic::Analysis::DiatonicInterval.get(name).above(root_pitch)
|
|
77
|
+
end
|
|
78
|
+
HeadMusic::Analysis::PitchCollection.new(chord_pitches)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def includes_dyad?(pitch_collection)
|
|
82
|
+
dyad_pitch_classes.all? { |pitch_class| pitch_collection.pitch_classes.include?(pitch_class) }
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def filter_by_key(pitch_collections)
|
|
86
|
+
pitch_collections.select do |pitch_collection|
|
|
87
|
+
pitch_collection.pitches.all? { |pitch| diatonic_spellings.include?(pitch.spelling) }
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def sort_by_diatonic_agreement(pitch_collections)
|
|
92
|
+
pitch_collections.sort_by do |pitch_collection|
|
|
93
|
+
-pitch_collection.pitches.count { |pitch| diatonic_spellings.include?(pitch.spelling) }
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def diatonic_spellings
|
|
98
|
+
@diatonic_spellings ||= key.scale.spellings
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|