head_music 21.1.0 → 22.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/.claude/skills/release/SKILL.md +4 -3
- data/.github/workflows/ci.yml +5 -3
- data/CHANGELOG.md +66 -0
- data/CLAUDE.md +7 -5
- data/Gemfile.lock +1 -1
- data/Rakefile +3 -2
- data/bin/guide_grade_corpus.rb +0 -14
- data/lib/head_music/analysis/circle.rb +1 -8
- data/lib/head_music/analysis/diatonic_interval.rb +1 -1
- data/lib/head_music/analysis/interval_cycle.rb +7 -2
- data/lib/head_music/analysis/motion.rb +14 -11
- data/lib/head_music/analysis/sonority.rb +14 -30
- data/lib/head_music/content/credits.rb +3 -9
- data/lib/head_music/content/flow/hash_deserializer.rb +3 -3
- data/lib/head_music/content/flow.rb +7 -7
- data/lib/head_music/content/layout.rb +31 -20
- data/lib/head_music/content/part.rb +1 -1
- data/lib/head_music/content/placement.rb +4 -0
- data/lib/head_music/content/position.rb +9 -1
- data/lib/head_music/content/project.rb +10 -6
- data/lib/head_music/content/voice.rb +4 -0
- data/lib/head_music/locales/en.yml +21 -6
- data/lib/head_music/locales/en_GB.yml +2 -2
- data/lib/head_music/notation/abc/duration_writer.rb +6 -1
- data/lib/head_music/notation/abc/parser.rb +8 -6
- data/lib/head_music/notation/abc/voice_state.rb +36 -10
- data/lib/head_music/notation/abc/writer.rb +63 -34
- data/lib/head_music/notation/lily_pond/voice_stream.rb +13 -11
- data/lib/head_music/notation/lily_pond/voice_writer.rb +22 -21
- data/lib/head_music/rudiment/chromatic_interval.rb +2 -0
- data/lib/head_music/rudiment/key_signature.rb +9 -24
- data/lib/head_music/rudiment/letter_name.rb +1 -1
- data/lib/head_music/rudiment/pitch.rb +2 -2
- data/lib/head_music/rudiment/pitch_class.rb +2 -0
- data/lib/head_music/rudiment/rhythmic_unit/parser.rb +12 -11
- data/lib/head_music/rudiment/spelling.rb +1 -1
- data/lib/head_music/style/guide_item.rb +11 -16
- data/lib/head_music/style/guideline/bar_span.rb +40 -0
- data/lib/head_music/style/guidelines/allow_fifth_species_rhythmic_values.rb +1 -1
- data/lib/head_music/style/guidelines/allow_whole_half_quarter_notes.rb +1 -1
- data/lib/head_music/style/guidelines/always_move.rb +29 -0
- data/lib/head_music/style/guidelines/avoid_crossing_voices.rb +3 -6
- data/lib/head_music/style/guidelines/diatonic.rb +53 -9
- data/lib/head_music/style/guidelines/embellished_suspension_treatment.rb +36 -0
- data/lib/head_music/style/guidelines/florid_dissonance_treatment.rb +10 -34
- data/lib/head_music/style/guidelines/mix_species_textures.rb +84 -0
- data/lib/head_music/style/guidelines/note_count_per_bar.rb +6 -23
- data/lib/head_music/style/guidelines/prefer_long_before_short.rb +43 -0
- data/lib/head_music/style/guidelines/second_species_break.rb +3 -4
- data/lib/head_music/style/guidelines/step_out_of_unison.rb +14 -2
- data/lib/head_music/style/guidelines/suspension_treatment.rb +1 -3
- data/lib/head_music/style/guidelines/sustain_across_barlines.rb +43 -0
- data/lib/head_music/style/guidelines/weak_beat_dissonance_treatment.rb +1 -3
- data/lib/head_music/style/guides/base.rb +2 -4
- data/lib/head_music/style/guides/composite_guide.rb +2 -4
- data/lib/head_music/style/guides/configured.rb +5 -13
- data/lib/head_music/style/guides/fifth_species_harmony.rb +1 -1
- data/lib/head_music/style/guides/fifth_species_melody.rb +2 -2
- data/lib/head_music/style/guides/fourth_species_melody.rb +6 -1
- data/lib/head_music/style/guides/species_harmony.rb +5 -0
- data/lib/head_music/style/guides/tiered_items.rb +10 -0
- data/lib/head_music/time/musical_position.rb +6 -1
- data/lib/head_music/time/musical_time_converter.rb +11 -10
- data/lib/head_music/value_equality.rb +5 -0
- data/lib/head_music/version.rb +1 -1
- data/lib/head_music.rb +7 -2
- data/references/content-schema.md +2 -2
- data/references/fifth-species-counterpoint.md +29 -31
- data/user-stories/backlog/lower-the-contour-floor.md +69 -0
- data/user-stories/backlog/sixteenth-century-style.md +87 -0
- data/user-stories/done/embellish-fifth-species-suspensions.md +473 -0
- data/user-stories/done/extract-the-harmonic-cores.md +7 -7
- data/user-stories/done/fifth-species-guide-improvements.md +631 -0
- data/user-stories/done/grade-every-fux-fifth-species-figure.md +479 -0
- data/user-stories/done/require-the-species-rhythm.md +546 -0
- data/user-stories/index.html +262 -246
- metadata +13 -5
- data/lib/head_music/content/flow/v3_hash_deserializer.rb +0 -52
- data/lib/head_music/style/guidelines/mixed_rhythmic_values.rb +0 -19
- data/user-stories/backlog/tell-the-species-apart.md +0 -136
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4f716235926455127be636b1b1c2c575a4ce7f0f4b41f7952620dfda908a8671
|
|
4
|
+
data.tar.gz: 378c4749d7fbcc0ac7168f086cae549126d4fd8e648e5703d4311a2a9b63f5ad
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 433e4a30ce9a523d69e194b37b72f3cc5499c91d6e3605c1c901ed63ad9d2c308ae9c891876eb0cb9beef288450cc59287fbabb63e0c3115a160e8d111d032ad
|
|
7
|
+
data.tar.gz: 925aa64aabc77e319868ab18d71964bea6ee1aae5a02477a827283b9ce01c13eb1203b1d0ff87ae361f41c41bbc02965446acc77caa1f6a1c451fe227a718806
|
|
@@ -82,9 +82,10 @@ rubygems.org/gems/head_music/trusted_publishers, with repository
|
|
|
82
82
|
|
|
83
83
|
- `gh run watch` on the run that the tag started, or
|
|
84
84
|
`gh run list --workflow=release.yml --limit 1`. Report its status. Do not
|
|
85
|
-
assume it passed.
|
|
86
|
-
|
|
87
|
-
|
|
85
|
+
assume it passed.
|
|
86
|
+
- `curl -s https://rubygems.org/api/v1/versions/head_music/latest.json`
|
|
87
|
+
reports the new version. `gem search -r` lags behind the API by minutes,
|
|
88
|
+
so do not read a stale result there as a failed publish.
|
|
88
89
|
- `gh release view vX.Y.Z` shows the GitHub Release with the gem attached.
|
|
89
90
|
|
|
90
91
|
If the workflow fails after the tag is pushed, the fallback is
|
data/.github/workflows/ci.yml
CHANGED
|
@@ -57,6 +57,8 @@ jobs:
|
|
|
57
57
|
bundle install
|
|
58
58
|
|
|
59
59
|
- name: Run tests
|
|
60
|
+
env:
|
|
61
|
+
COVERAGE: ${{ matrix.coverage && 'true' || '' }}
|
|
60
62
|
run: bundle exec rspec
|
|
61
63
|
|
|
62
64
|
- name: Upload coverage to Codecov
|
|
@@ -99,8 +101,8 @@ jobs:
|
|
|
99
101
|
|
|
100
102
|
# The LilyPond specs treat the real binary as an oracle and skip when none is
|
|
101
103
|
# installed, so without this job nothing proves the rendered documents
|
|
102
|
-
# actually compile.
|
|
103
|
-
#
|
|
104
|
+
# actually compile. Only the specs that call the binary run here; the test
|
|
105
|
+
# job covers the rest.
|
|
104
106
|
test-lilypond:
|
|
105
107
|
name: test (with the lilypond toolchain)
|
|
106
108
|
runs-on: ubuntu-latest
|
|
@@ -121,7 +123,7 @@ jobs:
|
|
|
121
123
|
bundler-cache: true
|
|
122
124
|
|
|
123
125
|
- name: Run tests
|
|
124
|
-
run: bundle exec rspec
|
|
126
|
+
run: bundle exec rspec spec/head_music/notation/lily_pond_round_trip_spec.rb spec/head_music/notation/lily_pond
|
|
125
127
|
|
|
126
128
|
lint:
|
|
127
129
|
runs-on: ubuntu-latest
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,72 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [22.0.0] - 2026-09-24
|
|
11
|
+
|
|
12
|
+
Four counterpoint stories, [Require the Species Rhythm](https://github.com/roberthead/head_music/tree/main/user-stories/done/require-the-species-rhythm.md), [Embellish Fifth Species Suspensions](https://github.com/roberthead/head_music/tree/main/user-stories/done/embellish-fifth-species-suspensions.md), [Grade Every Fux Fifth-Species Figure](https://github.com/roberthead/head_music/tree/main/user-stories/done/grade-every-fux-fifth-species-figure.md), and [Fifth Species Guide Improvements](https://github.com/roberthead/head_music/tree/main/user-stories/done/fifth-species-guide-improvements.md). Every species guide now insists on the rhythm its species teaches, fifth species accepts the decorated suspensions Fux allows and requires the mixture that defines it, and every two-voice fifth-species figure in Gradus is graded and pinned. Fux's lines are graded as evidence, not as an oracle: where one of them outruns a rule that every other source keeps, the mark stands and is recorded as his liberty. Two grades move, both in the direction the pedagogy says they should, and the count guidelines now grade the solo and cantus-firmus lines they had passed on nothing; everything else is additive, including ties across bar lines in ABC.
|
|
13
|
+
|
|
14
|
+
Grading is also faster: the pinned corpus grades in about half the time, with no grade moving for it.
|
|
15
|
+
|
|
16
|
+
**This is a major release**, because two public classes are removed. A consumer that names neither and stores no schema-3 documents upgrades by upgrading.
|
|
17
|
+
|
|
18
|
+
**Migrating from 21.1.0**, in the order a consumer will hit them:
|
|
19
|
+
|
|
20
|
+
1. **`Style::Guidelines::MixedRhythmicValues` is gone.** A custom guide that names it raises `NameError` on load. `MixSpeciesTextures` replaces it in `FifthSpeciesMelody` and judges runs of one texture rather than counting distinct durations, so it is a successor rather than a rename.
|
|
21
|
+
|
|
22
|
+
2. **`Content::Flow.from_v3_h` is gone, as 21.0.0 announced.** A schema-3 document must be read with head_music 21.x and saved again before upgrading; `Flow.from_h` rejects one with an error saying so. Schema-4 documents are unaffected.
|
|
23
|
+
|
|
24
|
+
3. **Some grades move.** The rule changes under Added and Changed regrade existing lines: the fourth- and fifth-species guides most, and `Diatonic`, `StepOutOfUnison`, `AlwaysMove`, and the count guidelines in places. Stored fitness from 21.x is not comparable for the guides they touch.
|
|
25
|
+
|
|
26
|
+
4. **`Pitch#name` and `Spelling#name` return frozen strings.** Code that modifies the returned name in place raises `FrozenError`; `dup` it first.
|
|
27
|
+
|
|
28
|
+
### Added
|
|
29
|
+
|
|
30
|
+
- **`Style::Guidelines::MixSpeciesTextures`, strong and primary in `FifthSpeciesMelody` in place of `MixedRhythmicValues`.** Fifth species is the free mixture of the other four, and Salzer and Schachter allow "two, or at most, two-and-a-half measures of a single note value" (*Counterpoint in Composition*, pp. 101-102). Each bar from the voice's first to the one before its last is classified by the texture it shows: a note that fills the bar, halves, quarters and eighth pairs, or a ligature entered by tie. Every bar of a run of more than two bars in one texture is marked, configurable through `maximum_run:`. A bar that mixes textures is florid and an empty bar is left to the rest rules; either ends a run. It is strong although a proportion usually makes a guideline weak, because one texture running on decides whether a line is fifth species at all. All twelve Fux fifth-species counterpoints are adherent. Fux's fourth-species figure 73 falls from 0.929 to 0.827 on the `fifth_species` composite, its eight ligature bars marked in two runs on either side of an untied bar; his second- and third-species figures fall to 0.815 and 0.796, and every first-species counterpoint to between 0.59 and 0.63, and the species diagonal spec now asserts that every other species grades 0.85 or below there. In the pinned corpus, 175 rows move, all on `fifth_species_melody` and the `fifth_species` composite: every one-texture voice falls, including every cantus firmus, every first-species counterpoint, the solo and against-empty lines, and the cantus voices of the species fixtures; the triple-meter cantus counts too, since a dotted whole that fills its bar is the whole-note texture. Two three-bar solo lines rise on the melody guide and lose their one message on both guides, because two body bars cannot hold an over-long run, where the old rule marked any line with fewer than three durations. No other message or item count moves, and no harmony grade moves.
|
|
31
|
+
|
|
32
|
+
- **`Style::Guidelines::PreferLongBeforeShort`, secondary and weak in `FifthSpeciesMelody`.** A bar that opens with two quarters and closes with a longer note that does not tie forward is marked: Salzer and Schachter call that note "a static point" that "blocks the flow from the two quarters rather than channeling it into the following measure" (*Counterpoint in Composition*, p. 103), and Fux marks the shape N.B. in figure 88 bar 5 and offers figure 89 as better. A longer note that ties over the bar line is exempt, as is a bar entered by a tie. It is weak because Fux calls the alternative better rather than calling his own line wrong. Figure 88a is the only voice marked and falls from 1.000 to 0.995 on `fifth_species_melody` and to 0.997 on the `fifth_species` composite. The new item adds one to the item count of every gated row of those two guides, 250 in the pinned corpus; 176 of them rise by at most 0.005 because the secondary tier's weight is shared among one more item, and 72 move in item count only.
|
|
33
|
+
|
|
34
|
+
- **Every two-voice fifth-species figure of Gradus as a spec fixture.** Figures 83 through 88 in Mann's numbering join figure 82 in `spec/spec_helper.rb`: each of the six modes once with the counterpoint above the cantus firmus and once below, read from the scan and confirmed against the kern transcriptions in MarkGotham/species, except figure 87's upper counterpoint, which has no kern and is read from the scan alone. The kern drops the tie from bar 11 into bar 12 of figure 86a at a system break; the fixture follows the scan. `species_abc` takes an ABC key and draws the cantus firmus from `FUX_CANTUS_FIRMUS_ABC`, so a species fixture can sit on any of Fux's six cantus firmi. The pinned corpus gains 660 rows and moves none. Every new counterpoint clears both primary items of `FifthSpeciesHarmony` except figure 85b, whose suspension from bar 1 resolves as an eighth on beat 2 of bar 2 and has moved on by beat 3. No source but Fux sanctions that shape, so `EmbellishedSuspensionTreatment` keeps marking it, a spec context pins the mark, and the species diagonal spec excludes that one cell by name through a map of liberties rather than by loosening the comparison. It grades 0.916 on the `fifth_species` composite; the other eleven grade between 0.953 and 1.000.
|
|
35
|
+
|
|
36
|
+
- **`Style::Guidelines::SustainAcrossBarlines`, primary in `FourthSpeciesMelody`.** A bar is sustained when the note sounding at its downbeat began before it, so a whole note placed on beat three and a half tied to a half count the same. A quarter of the middle bars may break the syncopation, as Fux allows where no ligature will fit; past that, every bar entered without a ligature is marked. It is judged over the voice's own bars rather than the cantus firmus's, so a solo line is held to it too. `OneToOneWithTies` stays beside it: that rule bounds how many notes sound against each cantus note, and a plain first-species line passed it, which is why a first-species line graded 0.988 on the fourth-species composite.
|
|
37
|
+
|
|
38
|
+
- **`Style::Guidelines::EmbellishedSuspensionTreatment`, primary in `FifthSpeciesHarmony` in place of `SuspensionTreatment`.** Fifth species lets the space between a suspension and its resolution be decorated. The resolution is still the note sounding halfway through the bar, on its second strong beat: a descending step from the suspended pitch and consonant there, whether attacked on that beat or anticipated on beat two and held. That accepts all five shapes the fifth-species reference describes — the anticipated resolution, the anticipated resolution with an eighth-note lower neighbor, the escape tone, the consonant leap, and Fux's delayed resolution — and still marks an unprepared suspension, one left by leap, one resolving upward, one first resolving on beat four, one held through the bar, and an anticipation abandoned on beat three. What is attacked between the suspension and that beat is `FloridDissonanceTreatment`'s to judge, so a dissonant leapt-to quarter is marked once, not twice. The slot presupposes a suspension over a downbeat whose cantus note lasts to that beat; a cantus note attacked off the downbeat or gone before it, and a meter with no second strong beat, get the strict rule. Fourth species keeps `SuspensionTreatment` unchanged.
|
|
39
|
+
|
|
40
|
+
- **Ties across bar lines in ABC.** `Notation::ABC.parse` now carries a tie over a bar line into one placement whose rhythmic value keeps the authored split, where it used to raise "Ties across barlines are not yet supported"; the tie carries its accidental across the line, so `^D2-|D2` is one D-sharp. A tied note is kept pending across a volta bracket as well. The writer does the inverse: a placement that sounds across a bar line is written as one note per bar, tied, so a flow of fourth-species ligatures writes back as valid ABC and re-parses to the same placements. In-bar tie chains still collapse to one multiplier on output.
|
|
41
|
+
|
|
42
|
+
- **Fux's diminution species as spec fixtures.** `spec/spec_helper.rb` carries Mann's figures 33, 55, 73, and 82 — second, third, fourth, and fifth species above the D dorian cantus firmus — checked against the scan and against the kern transcriptions in MarkGotham/species, plus a constructed triple-meter third-species line labeled as such. They join the pinned corpus, and a new species diagonal spec asserts that every species composite and melody guide grades its own species' fixture at least as high as any other species' fixture.
|
|
43
|
+
|
|
44
|
+
### Changed
|
|
45
|
+
|
|
46
|
+
- **`Diatonic` accepts the accidentals the modes require.** Salzer and Schachter (*Counterpoint in Composition*, pp. 9-10 and 20) name four, and the guideline now exempts exactly those: a raised seventh that steps up to the tonic anywhere in the line, which subsumes the old exemption for a sharpened penultimate note; a raised sixth that steps up to a raised seventh; the lowered fourth in lydian; and the lowered sixth in dorian or lowered third in mixolydian, approached from above and left downward. Phrygian gets no B-flat. Every Fux fifth-species counterpoint now clears `Diatonic`: figure 82's B-flat in `d c _B G`, figure 86a's mid-line F-sharp, and the five lydian B-flats of 85a and 85b. The same exemption clears the descending B-flat of Fux's fourth-species figure 73 and two of the five B-flats in his third-species figure 55; the other three are not in descent and stay marked, as do the F-sharps of the cantus firmus written with chromatic notes added. In the pinned corpus, those six counterpoints rise under the 23 guides that carry `Diatonic`, 138 rows, by at most 0.002 on a species composite, where `Diatonic` is secondary; no row falls.
|
|
47
|
+
|
|
48
|
+
- **`StepOutOfUnison` exempts the opening note and a unison tied over the bar line.** Salzer and Schachter permit a unison "if tied over or followed by stepwise motion" (*Counterpoint in Composition*, p. 106), and the opening unison belongs to the rules for beginnings (p. 40). The rule stays in every moving species, which is where they state it. Fux's figures 83 and 86b, whose opening unisons are left by leap, now clear it; the interior unisons left by leap in figures 82 (twice), 84a, 85a, and 86a keep their marks as his liberties, pinned by name in the spec. In the pinned corpus, 120 rows rise, ten voices under the twelve guides that carry the rule: those two counterpoints, the cantus firmus of figures 83, 86b, and 87a, of Fux's third-species figure, and of two first-species figures, each of which opens in unison with its counterpoint, and the two against-cantus lines. No row falls.
|
|
49
|
+
|
|
50
|
+
- **`AlwaysMove` accepts the anticipated resolution.** A quarter on beat 2 that repeats as the beat-3 resolution of a suspension held from the previous bar, the shape Salzer and Schachter name "anticipation of tone of resolution" (*Counterpoint in Composition*, p. 104), is no longer marked as a repeated note, whether or not the resolution ties forward. A repeated half, a repeated quarter with no suspension before it, and a quarter approached from below stay marked, so figure 85b keeps its mark in bar 9. Fux's figures 86a, 87 upper, 87a, and 88b lose their one `AlwaysMove` mark and rise under every guide that grades a counterpoint with it, 56 rows in the pinned corpus, each one message fewer; nothing else moves.
|
|
51
|
+
|
|
52
|
+
- **`NoteCountPerBar` judges the voice's own bars.** `OnePerBar`, `TwoPerBar`, `ThreePerBar`, and `FourPerBar` borrowed the companion voice's note list to decide which bars to inspect, so a voice with no companion passed on nothing, a bar was charged once per companion note when the companion was florid, and an empty bar's mark landed on the companion's note. They now judge the bars between the voice's own first and last notes, as `SustainAcrossBarlines` does, mark an empty bar as a span with no placements, and leave an empty voice to the minimum-notes gate. In the pinned corpus, 362 rows move, all under the first- through third-species guides: solo lines and lines against an empty companion are now marked; every cantus firmus graded on its own, whose companion voice is empty, is now marked by the second- and third-species count rules; the cantus voices of the species fixtures rise by a few thousandths where a florid companion had charged them once per note; and a counterpoint shorter than its cantus firmus is no longer charged for bars it never wrote. No harmony grade moves, and no counterpoint that runs the length of its cantus firmus moves.
|
|
53
|
+
|
|
54
|
+
- **A first-species line now grades materially lower against fourth species.** Across the ten valid Fux first-species fixtures, `fourth_species_melody` falls from 0.986 to between 0.68 and 0.71 and the `fourth_species` composite from 0.988 to between 0.80 and 0.84, while Fux's own fourth-species line grades 0.999 on the melody guide. Second, third, triple-meter, and fifth species already discounted a first-species line by a quarter through their melody primaries and are unchanged. The harmony guides deliberately do not check rhythm; `SpeciesHarmony` records that the melody guide's primary tier is where the species' rhythm is required.
|
|
55
|
+
|
|
56
|
+
- **Fux's fifth-species example now grades as fifth species.** Figure 82 rises from 0.819 to 0.937 on `fifth_species_harmony` and from 0.891 to 0.953 on the `fifth_species` composite, above his fourth-species line's 0.929 there, because its bar 9 suspension, resolved through an interposed consonant third, is no longer marked. No other corpus grade moves.
|
|
57
|
+
|
|
58
|
+
- **`FloridDissonanceTreatment`'s strings no longer promise to prepare and resolve tied suspensions.** The guideline judges each note at its attack, so a held-over suspension never reached it and the branch that claimed to handle one was unreachable; the branch is deleted and the English and British sentences now end at "on a weak beat". No grade changes.
|
|
59
|
+
|
|
60
|
+
- **Guide grading allocates about a quarter as many objects.** Positions compare field by field rather than through arrays; pitch and spelling names, a pitch's natural, a letter name's pitch class, and a diatonic interval's quality are memoized; pitch classes and chromatic intervals looked up by number skip name parsing; rhythmic unit names are indexed once rather than normalized on every parse; and `AvoidCrossingVoices` finds the predominant orientation once per assessment. Grading the pinned corpus falls from about 18.5 to 9.4 seconds, and no grade moves. `Pitch#name` and `Spelling#name` now return frozen strings.
|
|
61
|
+
|
|
62
|
+
- **`Flow`, `Voice`, `Position`, and `Placement` inspect as one line.** Each shows its class and `to_s`, as in `#<HeadMusic::Content::Placement eighth F♯4 at 2:2:240>`. The default printed the whole composition, since every placement reaches its voice and every voice its flow: two melodic note pairs from a seven-note voice inspected to two megabytes, in irb, in failure messages, and in RSpec's generated example descriptions.
|
|
63
|
+
|
|
64
|
+
### Removed
|
|
65
|
+
|
|
66
|
+
- **`Content::Flow.from_v3_h` and `Content::Flow::V3HashDeserializer`,** as 21.0.0 announced. A schema-3 document is read with `Flow.from_v3_h` in head_music 21.x and saved again; `Flow.from_h` still rejects one with an error saying so.
|
|
67
|
+
|
|
68
|
+
- **`Style::Guidelines::MixedRhythmicValues`.** It asked only for three distinct durations and placed one mark when a line had fewer, which is why Fux's fourth-species line graded within 0.024 of his fifth-species line on the `fifth_species` composite. `MixSpeciesTextures` replaces it; no other guide used it.
|
|
69
|
+
|
|
70
|
+
### Fixed
|
|
71
|
+
|
|
72
|
+
- **ABC repeat and volta tagging for a note that outlasts its bar.** The parser counted completed bars from where the last note started, so a note longer than its bar, or one now tied across the line, shifted every following repeat flag by a bar. Bars are counted from where the last note ends.
|
|
73
|
+
|
|
74
|
+
- **`AvoidCrossingVoices` marks the bars that leave the predominant orientation.** It deduplicated the voices' orientations before counting them, so the orientation heard first always won, and a counterpoint that opened below the cantus firmus and then stayed above it had its in-order bars marked as the crossings. The most common orientation now wins, and a tie still goes to the one heard first. No row of the pinned corpus moves: every crossing voice in it opens in its predominant orientation.
|
|
75
|
+
|
|
10
76
|
## [21.1.0] - 2026-09-10
|
|
11
77
|
|
|
12
78
|
The [organizing content](https://github.com/roberthead/head_music/tree/main/user-stories/epics/organizing-content.md) epic's second story. 21.0.0 separated the document from the music; this separates the music from what it *is* and from how it is *shown*. A flow may now cite a `Work` — the piece, with its catalog number and its people — while a project credits whoever made *this version* of it, so Bach is credited for the work and Segovia for the arrangement. A `Layout` is a view of a project: which flows, which players, concert or written pitch, under what title. Two layouts over one project — a transposed score and a flute part book — are two documents from one body of music.
|
data/CLAUDE.md
CHANGED
|
@@ -10,16 +10,18 @@ HeadMusic is a Ruby gem for Western music theory. It provides a comprehensive to
|
|
|
10
10
|
|
|
11
11
|
### Essential Commands
|
|
12
12
|
|
|
13
|
-
`rake -T` lists the custom tasks.
|
|
13
|
+
`rake -T` lists the custom tasks.
|
|
14
14
|
|
|
15
15
|
```bash
|
|
16
|
-
bundle exec rake
|
|
17
|
-
bundle exec
|
|
16
|
+
bundle exec rake validate # tests with coverage, linting, security audit, code quality
|
|
17
|
+
bundle exec rake # tests WITH coverage (the default task, same as `rake spec`)
|
|
18
|
+
bundle exec rspec # tests WITHOUT coverage, so a single file can run on its own
|
|
19
|
+
bundle exec rake coverage # opens the last coverage report
|
|
18
20
|
```
|
|
19
21
|
|
|
20
22
|
### Git Etiquette
|
|
21
23
|
|
|
22
|
-
|
|
24
|
+
Commit when your judgment says the work is ready: a coherent, tested change, with one cause per commit where the work calls for it. You do not need to wait to be asked. Pushing, merging, and rewriting published history still wait for my go-ahead.
|
|
23
25
|
|
|
24
26
|
When composing git commit messages, follow best-practices. Describe the change itself — do not narrate the assistant's process in the message body. Attribution trailers (`Co-Authored-By`, `Claude-Session`) are added when the session's attribution setting asks for them.
|
|
25
27
|
|
|
@@ -39,7 +41,7 @@ Do not use an assignment inside a condition.
|
|
|
39
41
|
|
|
40
42
|
### Testing
|
|
41
43
|
|
|
42
|
-
Tests are written in RSpec and located in the `/spec` directory, mirroring the `/lib` structure. The project requires 90% code coverage minimum.
|
|
44
|
+
Tests are written in RSpec and located in the `/spec` directory, mirroring the `/lib` structure. The project requires 90% code coverage minimum. Coverage is opt-in: `rake spec` sets `COVERAGE=true`, and `spec_helper.rb` starts SimpleCov only then.
|
|
43
45
|
|
|
44
46
|
## Architecture
|
|
45
47
|
|
data/Gemfile.lock
CHANGED
data/Rakefile
CHANGED
|
@@ -2,7 +2,8 @@ require "bundler/gem_tasks"
|
|
|
2
2
|
require "rspec/core/rake_task"
|
|
3
3
|
require "standard/rake"
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
# The block runs when the task does, just before rspec is launched.
|
|
6
|
+
RSpec::Core::RakeTask.new(:spec) { ENV["COVERAGE"] = "true" }
|
|
6
7
|
|
|
7
8
|
begin
|
|
8
9
|
require "yard"
|
|
@@ -71,7 +72,7 @@ namespace :style do
|
|
|
71
72
|
desc "Regenerate the pinned corpus grading (spec/fixtures/style/corpus_fitness.json)"
|
|
72
73
|
task :snapshot_corpus_fitness do
|
|
73
74
|
path = File.expand_path("spec/fixtures/style/corpus_fitness.json", __dir__)
|
|
74
|
-
sh "bundle exec ruby bin/guide_grade_corpus.rb #{path}"
|
|
75
|
+
sh({"COVERAGE" => nil}, "bundle exec ruby bin/guide_grade_corpus.rb #{path}")
|
|
75
76
|
puts "Wrote #{path}"
|
|
76
77
|
end
|
|
77
78
|
end
|
data/bin/guide_grade_corpus.rb
CHANGED
|
@@ -13,26 +13,12 @@
|
|
|
13
13
|
# that the file is never edited. When a change needs a seam this script does not
|
|
14
14
|
# yet have, the edit lands BEFORE both captures and is proven a no-op by diffing
|
|
15
15
|
# 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
16
|
|
|
21
17
|
require "json"
|
|
22
18
|
|
|
23
19
|
ROOT = File.expand_path("..", __dir__)
|
|
24
20
|
$LOAD_PATH.unshift File.join(ROOT, "lib"), File.join(ROOT, "spec")
|
|
25
21
|
|
|
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
22
|
require "head_music"
|
|
37
23
|
require "flow_context"
|
|
38
24
|
require "spec_helper"
|
|
@@ -44,14 +44,7 @@ class HeadMusic::Analysis::Circle < HeadMusic::Analysis::IntervalCycle
|
|
|
44
44
|
end
|
|
45
45
|
|
|
46
46
|
def pitches_down
|
|
47
|
-
@pitches_down ||=
|
|
48
|
-
loop do
|
|
49
|
-
next_pitch = folded_down(list.last)
|
|
50
|
-
break if next_pitch.pitch_class == starting_pitch.pitch_class
|
|
51
|
-
|
|
52
|
-
list << next_pitch
|
|
53
|
-
end
|
|
54
|
-
end
|
|
47
|
+
@pitches_down ||= cycle_of_pitches { |pitch| folded_down(pitch) }
|
|
55
48
|
end
|
|
56
49
|
|
|
57
50
|
private
|
|
@@ -39,9 +39,14 @@ class HeadMusic::Analysis::IntervalCycle
|
|
|
39
39
|
protected
|
|
40
40
|
|
|
41
41
|
def pitches_up
|
|
42
|
-
@pitches_up ||=
|
|
42
|
+
@pitches_up ||= cycle_of_pitches { |pitch| folded_up(pitch) }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Steps from the starting pitch until the cycle returns to its pitch class.
|
|
46
|
+
def cycle_of_pitches
|
|
47
|
+
[starting_pitch].tap do |list|
|
|
43
48
|
loop do
|
|
44
|
-
next_pitch =
|
|
49
|
+
next_pitch = yield(list.last)
|
|
45
50
|
break if next_pitch.pitch_class == starting_pitch.pitch_class
|
|
46
51
|
|
|
47
52
|
list << next_pitch
|
|
@@ -24,20 +24,15 @@ class HeadMusic::Analysis::Motion
|
|
|
24
24
|
end
|
|
25
25
|
|
|
26
26
|
def parallel?
|
|
27
|
-
upper_melodic_interval.moving? &&
|
|
28
|
-
upper_melodic_interval.direction == lower_melodic_interval.direction &&
|
|
29
|
-
upper_melodic_interval.steps == lower_melodic_interval.steps
|
|
27
|
+
upper_melodic_interval.moving? && same_direction? && same_steps?
|
|
30
28
|
end
|
|
31
29
|
|
|
32
30
|
def similar?
|
|
33
|
-
|
|
34
|
-
upper_melodic_interval.steps != lower_melodic_interval.steps
|
|
31
|
+
same_direction? && !same_steps?
|
|
35
32
|
end
|
|
36
33
|
|
|
37
34
|
def contrary?
|
|
38
|
-
upper_melodic_interval.moving? &&
|
|
39
|
-
lower_melodic_interval.moving? &&
|
|
40
|
-
upper_melodic_interval.direction != lower_melodic_interval.direction
|
|
35
|
+
upper_melodic_interval.moving? && lower_melodic_interval.moving? && !same_direction?
|
|
41
36
|
end
|
|
42
37
|
|
|
43
38
|
def notes
|
|
@@ -51,19 +46,27 @@ class HeadMusic::Analysis::Motion
|
|
|
51
46
|
end
|
|
52
47
|
|
|
53
48
|
def to_s
|
|
54
|
-
return "repetition of a #{second_harmonic_interval}"
|
|
49
|
+
return "repetition of a #{second_harmonic_interval}" if contrapuntal_motion == :repetition
|
|
55
50
|
|
|
56
51
|
"#{contrapuntal_motion} motion from a #{first_harmonic_interval} to a #{second_harmonic_interval}"
|
|
57
52
|
end
|
|
58
53
|
|
|
59
54
|
private
|
|
60
55
|
|
|
56
|
+
def same_direction?
|
|
57
|
+
upper_melodic_interval.direction == lower_melodic_interval.direction
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def same_steps?
|
|
61
|
+
upper_melodic_interval.steps == lower_melodic_interval.steps
|
|
62
|
+
end
|
|
63
|
+
|
|
61
64
|
def upper_melodic_interval
|
|
62
|
-
HeadMusic::Analysis::MelodicInterval.new(upper_notes
|
|
65
|
+
@upper_melodic_interval ||= HeadMusic::Analysis::MelodicInterval.new(*upper_notes)
|
|
63
66
|
end
|
|
64
67
|
|
|
65
68
|
def lower_melodic_interval
|
|
66
|
-
HeadMusic::Analysis::MelodicInterval.new(lower_notes
|
|
69
|
+
@lower_melodic_interval ||= HeadMusic::Analysis::MelodicInterval.new(*lower_notes)
|
|
67
70
|
end
|
|
68
71
|
|
|
69
72
|
def upper_notes
|
|
@@ -37,25 +37,15 @@ class HeadMusic::Analysis::Sonority
|
|
|
37
37
|
# @param inversion [Integer] the inversion number (default: 0 for root position)
|
|
38
38
|
# @return [Sonority, nil] the sonority object, or nil if identifier not found
|
|
39
39
|
def self.get(identifier, root: DEFAULT_ROOT, inversion: 0)
|
|
40
|
-
|
|
41
|
-
return nil unless
|
|
40
|
+
interval_shorthands = SONORITIES[identifier.to_sym]
|
|
41
|
+
return nil unless interval_shorthands
|
|
42
42
|
|
|
43
43
|
root_pitch = HeadMusic::Rudiment::Pitch.get(root)
|
|
44
|
-
interval_shorthands = SONORITIES[identifier]
|
|
45
|
-
|
|
46
|
-
# Build pitches: root + intervals above root
|
|
47
44
|
pitches = [root_pitch] + interval_shorthands.map do |shorthand|
|
|
48
|
-
|
|
49
|
-
interval.above(root_pitch)
|
|
45
|
+
HeadMusic::Analysis::DiatonicInterval.get(shorthand).above(root_pitch)
|
|
50
46
|
end
|
|
51
|
-
|
|
52
47
|
pitch_collection = HeadMusic::Analysis::PitchCollection.new(pitches)
|
|
53
|
-
|
|
54
|
-
# Apply inversions if requested
|
|
55
|
-
inversion.times do
|
|
56
|
-
pitch_collection = pitch_collection.invert
|
|
57
|
-
end
|
|
58
|
-
|
|
48
|
+
inversion.times { pitch_collection = pitch_collection.invert }
|
|
59
49
|
new(pitch_collection)
|
|
60
50
|
end
|
|
61
51
|
|
|
@@ -83,28 +73,22 @@ class HeadMusic::Analysis::Sonority
|
|
|
83
73
|
def identifier
|
|
84
74
|
return @identifier if defined?(@identifier)
|
|
85
75
|
|
|
86
|
-
@identifier = SONORITIES.keys.detect
|
|
87
|
-
inversions.map do |inversion|
|
|
88
|
-
inversion.diatonic_intervals_above_bass_pitch.map(&:shorthand)
|
|
89
|
-
end.include?(SONORITIES[key])
|
|
90
|
-
end
|
|
76
|
+
@identifier = SONORITIES.keys.detect { |key| inversion_shorthands.include?(SONORITIES[key]) }
|
|
91
77
|
end
|
|
92
78
|
|
|
93
79
|
def inversion
|
|
94
|
-
@inversion ||=
|
|
95
|
-
SONORITIES[identifier] == inversion.diatonic_intervals_above_bass_pitch.map(&:shorthand)
|
|
96
|
-
end
|
|
80
|
+
@inversion ||= inversion_shorthands.index(SONORITIES[identifier])
|
|
97
81
|
end
|
|
98
82
|
|
|
99
83
|
def inversions
|
|
100
|
-
@inversions ||=
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
84
|
+
@inversions ||= reduction.pitches.each_with_object([]) do |_pitch, list|
|
|
85
|
+
list << (list.last&.uninvert || reduction)
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def inversion_shorthands
|
|
90
|
+
@inversion_shorthands ||= inversions.map do |inversion|
|
|
91
|
+
inversion.diatonic_intervals_above_bass_pitch.map(&:shorthand)
|
|
108
92
|
end
|
|
109
93
|
end
|
|
110
94
|
|
|
@@ -5,6 +5,7 @@ module HeadMusic::Content; end
|
|
|
5
5
|
# single place the level constraint is enforced.
|
|
6
6
|
class HeadMusic::Content::Credits
|
|
7
7
|
include Enumerable
|
|
8
|
+
include HeadMusic::ValueEquality
|
|
8
9
|
|
|
9
10
|
LEVELS = HeadMusic::Content::Role::LEVELS
|
|
10
11
|
|
|
@@ -14,6 +15,8 @@ class HeadMusic::Content::Credits
|
|
|
14
15
|
|
|
15
16
|
attr_reader :level, :credits
|
|
16
17
|
|
|
18
|
+
value_equality :level, :credits
|
|
19
|
+
|
|
17
20
|
def initialize(level, credits = [])
|
|
18
21
|
@level = level&.to_sym
|
|
19
22
|
raise ArgumentError, "unknown credit level: #{level.inspect}" unless LEVELS.include?(@level)
|
|
@@ -51,15 +54,6 @@ class HeadMusic::Content::Credits
|
|
|
51
54
|
credits.map(&:to_h)
|
|
52
55
|
end
|
|
53
56
|
|
|
54
|
-
def ==(other)
|
|
55
|
-
other.is_a?(self.class) && level == other.level && credits == other.credits
|
|
56
|
-
end
|
|
57
|
-
alias_method :eql?, :==
|
|
58
|
-
|
|
59
|
-
def hash
|
|
60
|
-
[self.class, level, credits].hash
|
|
61
|
-
end
|
|
62
|
-
|
|
63
57
|
private
|
|
64
58
|
|
|
65
59
|
def ensure_credit(credit)
|
|
@@ -9,11 +9,11 @@ class HeadMusic::Content::Flow
|
|
|
9
9
|
|
|
10
10
|
private
|
|
11
11
|
|
|
12
|
-
#
|
|
13
|
-
#
|
|
12
|
+
# No recipe migrates v3 in place, so a v3 document is told which release
|
|
13
|
+
# still reads it rather than merely rejected.
|
|
14
14
|
def unsupported_version_message(version)
|
|
15
15
|
message = super
|
|
16
|
-
message += "; read it with Flow.from_v3_h
|
|
16
|
+
message += "; read it with Flow.from_v3_h in head_music 21.x and save it again" if version == 3
|
|
17
17
|
message
|
|
18
18
|
end
|
|
19
19
|
|
|
@@ -23,12 +23,6 @@ class HeadMusic::Content::Flow
|
|
|
23
23
|
HashDeserializer.new(hash).flow
|
|
24
24
|
end
|
|
25
25
|
|
|
26
|
-
# Retained read-only through 21.x so that persisted v3 data can be migrated
|
|
27
|
-
# by reading and re-saving. Removed in 22.0.0.
|
|
28
|
-
def self.from_v3_h(hash)
|
|
29
|
-
V3HashDeserializer.new(hash).flow
|
|
30
|
-
end
|
|
31
|
-
|
|
32
26
|
def self.from_json(json)
|
|
33
27
|
from_h(JSON.parse(json))
|
|
34
28
|
end
|
|
@@ -145,7 +139,13 @@ class HeadMusic::Content::Flow
|
|
|
145
139
|
end
|
|
146
140
|
|
|
147
141
|
def to_s
|
|
148
|
-
"#{name} — #{voices.count} #{(voices.count
|
|
142
|
+
"#{name} — #{voices.count} #{"voice".pluralize(voices.count)}"
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# The default would print the whole composition, since every placement
|
|
146
|
+
# reaches its voice and every voice its flow.
|
|
147
|
+
def inspect
|
|
148
|
+
"#<#{self.class.name} #{self}>"
|
|
149
149
|
end
|
|
150
150
|
|
|
151
151
|
def to_abc(**options)
|
|
@@ -18,13 +18,18 @@ class HeadMusic::Content::Layout
|
|
|
18
18
|
hash = hash.transform_keys(&:to_s)
|
|
19
19
|
{
|
|
20
20
|
kind: (hash["kind"] || :custom).to_sym,
|
|
21
|
-
flows:
|
|
22
|
-
players:
|
|
21
|
+
flows: members_at(hash["flows"], project.flows),
|
|
22
|
+
players: members_at(hash["players"], project.players),
|
|
23
23
|
concert_pitch: hash.fetch("concert_pitch", true),
|
|
24
24
|
title_override: hash["title_override"]
|
|
25
25
|
}
|
|
26
26
|
end
|
|
27
27
|
|
|
28
|
+
def self.members_at(indexes, collection)
|
|
29
|
+
indexes && Array(indexes).map { |index| collection[index] }
|
|
30
|
+
end
|
|
31
|
+
private_class_method :members_at
|
|
32
|
+
|
|
28
33
|
def initialize(project:, kind: :custom, flows: nil, players: nil, concert_pitch: true, title_override: nil)
|
|
29
34
|
ensure_known_kind!(kind)
|
|
30
35
|
|
|
@@ -68,11 +73,12 @@ class HeadMusic::Content::Layout
|
|
|
68
73
|
Realization.new(self, flow).flow
|
|
69
74
|
end
|
|
70
75
|
|
|
76
|
+
# A selection holds only the project's players, never nil, so a part with no
|
|
77
|
+
# player is kept only when nothing is selected.
|
|
78
|
+
#
|
|
71
79
|
# @api private for Layout::Realization
|
|
72
80
|
def selects?(part)
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
!part.player.nil? && selected_players.include?(part.player)
|
|
81
|
+
selected_players.nil? || selected_players.include?(part.player)
|
|
76
82
|
end
|
|
77
83
|
|
|
78
84
|
# Which of the flow's parts survive the selection, by index, in the order they
|
|
@@ -95,9 +101,9 @@ class HeadMusic::Content::Layout
|
|
|
95
101
|
# rendered; each tune carries its own T:.
|
|
96
102
|
def to_abc
|
|
97
103
|
ensure_something_to_render!
|
|
98
|
-
rendered_flows.
|
|
99
|
-
|
|
100
|
-
|
|
104
|
+
rendered_flows.map.with_index(1) do |flow, number|
|
|
105
|
+
HeadMusic::Notation::ABC.render(realize(flow), reference_number: number, transposed: transposed?)
|
|
106
|
+
end.join("\n")
|
|
101
107
|
end
|
|
102
108
|
|
|
103
109
|
# A single flow renders exactly as the flow would on its own; several render
|
|
@@ -105,15 +111,16 @@ class HeadMusic::Content::Layout
|
|
|
105
111
|
def to_lilypond
|
|
106
112
|
ensure_something_to_render!
|
|
107
113
|
realized = rendered_flows.map { |flow| realize(flow) }
|
|
108
|
-
return HeadMusic::Notation::LilyPond.render(realized.first,
|
|
114
|
+
return HeadMusic::Notation::LilyPond.render(realized.first, **rendering_options) if realized.one?
|
|
109
115
|
|
|
110
|
-
HeadMusic::Notation::LilyPond::BookWriter.new(realized, title: title,
|
|
116
|
+
HeadMusic::Notation::LilyPond::BookWriter.new(realized, title: title, **rendering_options).to_s
|
|
111
117
|
end
|
|
112
118
|
|
|
113
119
|
def to_musicxml
|
|
114
|
-
|
|
120
|
+
count = rendered_flows.length
|
|
121
|
+
if count > 1
|
|
115
122
|
raise HeadMusic::Notation::RenderError,
|
|
116
|
-
"MusicXML holds one flow per document and this layout renders #{
|
|
123
|
+
"MusicXML holds one flow per document and this layout renders #{count}; use #to_musicxml_documents"
|
|
117
124
|
end
|
|
118
125
|
|
|
119
126
|
to_musicxml_documents.first
|
|
@@ -121,8 +128,8 @@ class HeadMusic::Content::Layout
|
|
|
121
128
|
|
|
122
129
|
def to_musicxml_documents
|
|
123
130
|
ensure_something_to_render!
|
|
124
|
-
rendered_flows.
|
|
125
|
-
HeadMusic::Notation::MusicXML.render(realize(flow), **musicxml_options(
|
|
131
|
+
rendered_flows.map.with_index(1) do |flow, number|
|
|
132
|
+
HeadMusic::Notation::MusicXML.render(realize(flow), **musicxml_options(number))
|
|
126
133
|
end
|
|
127
134
|
end
|
|
128
135
|
|
|
@@ -138,7 +145,7 @@ class HeadMusic::Content::Layout
|
|
|
138
145
|
end
|
|
139
146
|
|
|
140
147
|
def to_s
|
|
141
|
-
"#{title} — #{kind} layout of #{rendered_flows.length} #{(rendered_flows.length
|
|
148
|
+
"#{title} — #{kind} layout of #{rendered_flows.length} #{"flow".pluralize(rendered_flows.length)}"
|
|
142
149
|
end
|
|
143
150
|
|
|
144
151
|
private
|
|
@@ -167,10 +174,14 @@ class HeadMusic::Content::Layout
|
|
|
167
174
|
|
|
168
175
|
# A document standing alone names only itself, which is what keeps a one-flow
|
|
169
176
|
# layout byte-identical to the flow's own output.
|
|
170
|
-
def musicxml_options(
|
|
171
|
-
return
|
|
177
|
+
def musicxml_options(movement_number)
|
|
178
|
+
return rendering_options if single_flow?
|
|
179
|
+
|
|
180
|
+
rendering_options.merge(work_title: title, movement_number: movement_number)
|
|
181
|
+
end
|
|
172
182
|
|
|
173
|
-
|
|
183
|
+
def rendering_options
|
|
184
|
+
{transposed: transposed?, arranger: arranger}
|
|
174
185
|
end
|
|
175
186
|
|
|
176
187
|
# The project's arrangers, joined as the composer is: this version's credit,
|
|
@@ -187,8 +198,8 @@ class HeadMusic::Content::Layout
|
|
|
187
198
|
end
|
|
188
199
|
|
|
189
200
|
def shared_work_title
|
|
190
|
-
works = flows.map(&:work)
|
|
191
|
-
works.first&.title if works.
|
|
201
|
+
works = flows.map(&:work).uniq
|
|
202
|
+
works.first&.title if works.size == 1
|
|
192
203
|
end
|
|
193
204
|
|
|
194
205
|
# Positions, not objects: a player and a flow are identified in a document
|
|
@@ -81,7 +81,7 @@ class HeadMusic::Content::Part
|
|
|
81
81
|
end
|
|
82
82
|
|
|
83
83
|
def to_s
|
|
84
|
-
[player&.name, "#{voices.length} #{(voices.length
|
|
84
|
+
[player&.name, "#{voices.length} #{"voice".pluralize(voices.length)}"].compact.join(": ")
|
|
85
85
|
end
|
|
86
86
|
|
|
87
87
|
# Sparse: a part with no instrument and no authored staves serializes as its
|
|
@@ -119,6 +119,10 @@ class HeadMusic::Content::Placement
|
|
|
119
119
|
"#{rhythmic_value} #{sounds.any? ? sounds.map { |sound| sound_label(sound) }.join(" ") : "rest"} at #{position}"
|
|
120
120
|
end
|
|
121
121
|
|
|
122
|
+
def inspect
|
|
123
|
+
"#<#{self.class.name} #{self}>"
|
|
124
|
+
end
|
|
125
|
+
|
|
122
126
|
def to_h
|
|
123
127
|
hash = {
|
|
124
128
|
"position" => position.to_s,
|