head_music 20.1.0 → 21.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.
Files changed (93) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +100 -1
  3. data/CLAUDE.md +6 -48
  4. data/Gemfile.lock +1 -1
  5. data/Rakefile +7 -0
  6. data/bin/guide_grade_corpus.rb +14 -92
  7. data/bin/guide_grade_table.rb +1 -1
  8. data/lib/head_music/analysis/harmonic_interval.rb +1 -1
  9. data/lib/head_music/content/bar.rb +15 -14
  10. data/lib/head_music/content/cantus_firmus/example.rb +25 -0
  11. data/lib/head_music/content/comment.rb +7 -7
  12. data/lib/head_music/content/flow/deserializer.rb +98 -0
  13. data/lib/head_music/content/flow/hash_deserializer.rb +103 -0
  14. data/lib/head_music/content/{composition → flow}/schema_values.rb +68 -15
  15. data/lib/head_music/content/flow/staff_system_values.rb +69 -0
  16. data/lib/head_music/content/flow/timeline.rb +234 -0
  17. data/lib/head_music/content/flow/v3_hash_deserializer.rb +52 -0
  18. data/lib/head_music/content/flow.rb +223 -0
  19. data/lib/head_music/content/note.rb +1 -1
  20. data/lib/head_music/content/part.rb +122 -0
  21. data/lib/head_music/content/placement.rb +3 -3
  22. data/lib/head_music/content/player.rb +44 -0
  23. data/lib/head_music/content/position.rb +81 -39
  24. data/lib/head_music/content/project.rb +110 -0
  25. data/lib/head_music/content/staff.rb +59 -15
  26. data/lib/head_music/content/staff_system.rb +49 -0
  27. data/lib/head_music/content/voice/continuity.rb +4 -4
  28. data/lib/head_music/content/voice.rb +84 -10
  29. data/lib/head_music/notation/abc/book_parser.rb +4 -4
  30. data/lib/head_music/notation/abc/duration_resolver.rb +1 -1
  31. data/lib/head_music/notation/abc/duration_writer.rb +1 -1
  32. data/lib/head_music/notation/abc/header.rb +1 -1
  33. data/lib/head_music/notation/abc/parser.rb +7 -7
  34. data/lib/head_music/notation/abc/preflight.rb +1 -1
  35. data/lib/head_music/notation/abc/repeat_tagger.rb +6 -6
  36. data/lib/head_music/notation/abc/voice_registry.rb +4 -4
  37. data/lib/head_music/notation/abc/writer.rb +23 -25
  38. data/lib/head_music/notation/abc.rb +7 -7
  39. data/lib/head_music/notation/clef_selector.rb +5 -5
  40. data/lib/head_music/notation/lily_pond/document.rb +2 -2
  41. data/lib/head_music/notation/lily_pond/{composition_builder.rb → flow_builder.rb} +12 -12
  42. data/lib/head_music/notation/lily_pond/lexer.rb +43 -106
  43. data/lib/head_music/notation/lily_pond/music_reader.rb +1 -1
  44. data/lib/head_music/notation/lily_pond/parse_preflight.rb +1 -1
  45. data/lib/head_music/notation/lily_pond/parser.rb +7 -7
  46. data/lib/head_music/notation/lily_pond/preflight.rb +13 -13
  47. data/lib/head_music/notation/lily_pond/render_plan.rb +6 -4
  48. data/lib/head_music/notation/lily_pond/source_scanner.rb +90 -0
  49. data/lib/head_music/notation/lily_pond/voice_stream.rb +1 -1
  50. data/lib/head_music/notation/lily_pond/voice_writer.rb +120 -0
  51. data/lib/head_music/notation/lily_pond/writer.rb +72 -57
  52. data/lib/head_music/notation/lily_pond.rb +8 -8
  53. data/lib/head_music/notation/music_xml/attributes_writer.rb +103 -0
  54. data/lib/head_music/notation/music_xml/divisions.rb +9 -12
  55. data/lib/head_music/notation/music_xml/key_mapper.rb +12 -5
  56. data/lib/head_music/notation/music_xml/note_writer.rb +29 -4
  57. data/lib/head_music/notation/music_xml/preflight.rb +12 -12
  58. data/lib/head_music/notation/music_xml/render_plan.rb +19 -7
  59. data/lib/head_music/notation/music_xml/writer.rb +75 -90
  60. data/lib/head_music/notation/music_xml.rb +5 -5
  61. data/lib/head_music/notation/preflight_checks.rb +5 -5
  62. data/lib/head_music/notation/render_plan.rb +19 -16
  63. data/lib/head_music/rudiment/clefs.yml +0 -1
  64. data/lib/head_music/rudiment/key.rb +28 -0
  65. data/lib/head_music/rudiment/note.rb +2 -2
  66. data/lib/head_music/rudiment/unpitched_note.rb +1 -1
  67. data/lib/head_music/style/guideline/voice_context.rb +2 -2
  68. data/lib/head_music/style/guideline.rb +4 -4
  69. data/lib/head_music/style/guidelines/first_bar_entry.rb +1 -1
  70. data/lib/head_music/style/guidelines/note_fills_final_bar.rb +1 -1
  71. data/lib/head_music/time/conductor.rb +0 -3
  72. data/lib/head_music/time/event_map.rb +172 -0
  73. data/lib/head_music/time/key_signature_event.rb +111 -0
  74. data/lib/head_music/time/meter_event.rb +1 -1
  75. data/lib/head_music/time/meter_map.rb +20 -34
  76. data/lib/head_music/time/musical_position.rb +33 -46
  77. data/lib/head_music/time/musical_time_converter.rb +4 -4
  78. data/lib/head_music/time/radix_carry.rb +8 -3
  79. data/lib/head_music/time/tempo_map.rb +20 -49
  80. data/lib/head_music/time.rb +7 -3
  81. data/lib/head_music/version.rb +1 -1
  82. data/lib/head_music.rb +12 -4
  83. data/references/wemi.md +102 -0
  84. data/user-stories/backlog/ensemble-sessions.md +82 -0
  85. data/user-stories/backlog/identity-and-presentation.md +148 -0
  86. data/user-stories/done/content-architecture.md +834 -0
  87. data/user-stories/epics/organizing-content.md +137 -0
  88. data/user-stories/index.html +16 -8
  89. metadata +24 -8
  90. data/lib/head_music/content/composition/hash_deserializer.rb +0 -113
  91. data/lib/head_music/content/composition.rb +0 -149
  92. data/lib/head_music/time/event_map_support.rb +0 -29
  93. data/user-stories/backlog/organizing-content.md +0 -83
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b3714d51858bd368308d14a5f12544e6f4fb9dbecf0f0eacc1ad795c5e4b3d1e
4
- data.tar.gz: 99345ffa2b44dfbdc27836b7b7350a38af1665770a74490f52e22bd1a0aa3602
3
+ metadata.gz: 3adeb7e3fa4f5422977916729f285e9ebb50309409927b25b284dbbb35d2932d
4
+ data.tar.gz: 78f4b8b6922142b56e185cc23da551576ba3ffd375d51eb6aa34f3382e30530f
5
5
  SHA512:
6
- metadata.gz: d95aa74479fe498564dc7e1bc5427660b9687e286a31a4b5cbbb1c0a274bebe9413ae18e2176dbbf38c351e4b1517275d5bf8dce590642c88c2301ea83f038b6
7
- data.tar.gz: 4446808775faf30ef42dceca7de10248a88d8c8265ab4faa3c11589bd6ddb7dd60ff9c10ce2cb34a68f9e039061bc3633484e01b2002f649bb3f885bd5d7e968
6
+ metadata.gz: 444f66354a57746f2c9acf747462bf3f2396f76b8b9c7fe16d4c2bc93020bbe178349f437d7135141e587ffb9fef09c57bdf6a9145159b47a88af908960b608b
7
+ data.tar.gz: 4c38611fa5aea3b2530310dc33644ab29af9c81c523eb37e39fe94298d79c71ee982791e1278c449cd4d91c99623e21f1a2f3c9f4882e648c8b1783f6ff94a7c
data/CHANGELOG.md CHANGED
@@ -7,6 +7,104 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [21.0.0] - 2026-09-06
11
+
12
+ The [organizing content](https://github.com/roberthead/head_music/tree/main/user-stories/epics/organizing-content.md) epic's first story. `Content::Composition` was the document, the movement, the timeline, and the credits at once, and its `Voice` was a bare melodic line with no instrument, no staff, and no performer — a shape adequate for two-voice species counterpoint and for almost nothing else. Content is now `Project` → `Flow` → `Part` → `Voice` → `Placement`, and a voice can cross between the staves of its part.
13
+
14
+ This is a breaking release. `Composition` is removed rather than deprecated, and the serialization schema goes to 4.
15
+
16
+ **Migrating from 20.1.0**, in the order a consumer will hit them:
17
+
18
+ 1. **`HeadMusic::Content::Composition` is `HeadMusic::Content::Flow`.** The constant is gone, not aliased. `Flow.new` takes the same keyword arguments, and `#add_voice`, `#voices`, `#bars`, `#to_h`, `#to_abc`, `#to_lilypond`, and `#to_musicxml` all behave as they did, so most call sites need only the constant renamed. `#add_voice` now mints a `Part` per voice behind the scenes; every document this gem produced before still renders byte-identically.
19
+
20
+ 2. **`LilyPond.parse` and `ABC.parse` return a `Flow`.** So do `ABC::BookParser#flows` (was `#compositions`) and `LilyPond::FlowBuilder` (was `CompositionBuilder`). These changed return type five days after the LilyPond reader shipped in 20.1.0; they are named individually here because a reader scanning for "Composition" will not otherwise notice that `parse` moved.
21
+
22
+ 3. **Persisted schema-3 documents need one read-and-re-save.** `Flow.from_h` rejects a v3 hash with an error naming `Flow.from_v3_h`, which is retained read-only for this reason and removed in 22.0.0. The previous bumps shipped a key-rename recipe — v2 to v3 was "rename each placement's `pitches` key to `sounds`", doable in SQL against a jsonb column — but v3 to v4 restructures the container, so no equivalent recipe can be written. **20.1.0 is the last version that reads v3 directly.**
23
+
24
+ 4. **`HeadMusic::Content::Staff` is a different class under the same name.** The 20.x `Content::Staff` was dead code, referenced by nothing but its own spec, and has been deleted; the constant is now the instance-layer staff described below. The new one takes only keyword arguments, so `Content::Staff.new(:bass_clef)` raises `ArgumentError` rather than quietly reading the clef as something else — but the readers changed too: `#default_clef` is `#clef` (and answers `nil` where none was authored, rather than falling back to treble), and `#instrument` is `#instruments_staff`, which references the catalog staff instead of an instrument.
25
+
26
+ 5. **`Time::MusicalPosition#beat` is `#count`**, and `FIRST_BEAT` is `FIRST_COUNT`. `Meter` already distinguished the two — 6/8 has two beats and six counts — so a position was misnamed against the gem's own vocabulary.
27
+
28
+ 6. **Grades do not move.** Every guide assesses every voice of the pinned corpus to the fitness it produced before the refactor began, which is asserted rather than assumed. Stored fitness from 20.x remains comparable.
29
+
30
+ ### Added
31
+
32
+ - **`HeadMusic::Content::Project`, `Flow`, `Part`, and `Player`.** A `Project` is the document: players and flows. A `Flow` is a continuous span of music owning its own timeline — a movement, a song, a cue, an exercise. A `Player` is a chair in the project ("Flute 1", "Piano"); a `Part` is that chair's music within one flow. Pairing them that way is what makes "the flute plays in movements 1 and 3" expressible without a nullable join — there is simply no `Part` for that player in movement 2 — and what makes an instrument change *within* a part honest, since conceptually it is still the same player.
33
+
34
+ **Containment is total; context is optional.** A voice is always in a part, always in a flow, so `voice.part.staff_system_at(bar)` never needs a nil check. What is optional is the upward reference: `Flow#project` and `Part#player` may be absent. A flow with no project is how a chunk of music lives outside a document — a cantus firmus, a scale, a parsed snippet — and it renders to ABC, LilyPond, and MusicXML with no project at all. `Project#add_flow` adopts such a flow, minting a player for each part that has none.
35
+
36
+ - **Voices orthogonal to staves.** A voice belongs to a part and *has* a staff at any given moment, so a piano voice can start in the bass staff and cross into the treble without leaving its part:
37
+
38
+ ```ruby
39
+ left_hand.cross_to(treble_staff, from: 5)
40
+ left_hand.cross_to(bass_staff, from: 9)
41
+ left_hand.staff_at(6) # => the treble staff
42
+ left_hand.staff_at(9) # => back to the bass staff
43
+ ```
44
+
45
+ A crossing is one event, not a span: a left hand that rises for four bars and comes back down is two crossings, each authored where it happens, and a single cross-staff note is a crossing and, a bar later, another. There is no note-level special case, and nothing to overlap.
46
+
47
+ MusicXML renders the part as one `<score-part>` with `<staves>`, a numbered `<clef>` per staff, `<voice>` per voice separated by `<backup>`, and `<staff>` per note. LilyPond renders a `\new PianoStaff` (or `\new StaffGroup` for a bracket) with one named `\new Staff` per staff and `\change Staff` at the span boundaries. Both elements are omitted for a one-voice, one-staff part, so existing output is unchanged.
48
+
49
+ - **`HeadMusic::Content::Staff` and `StaffSystem`** — the instance layer. A content staff has a line count, a clef map, and an optional reference to an `Instruments::Staff` for percussion mapping; the catalog class already owns the position-to-instrument mappings, so the instance layer references rather than re-implements it. A `StaffSystem` is an ordered set of staves with a brace, a bracket, or neither; `StaffSystem.grand_staff` and `.single_staff` are the two shapes almost everything uses.
50
+
51
+ - **`HeadMusic::Time::EventMap`** — an ordered list of `(position, value)` events answering what is in force at a position. Everything that changes partway through a flow is this shape, and it is now written once: meter, tempo, and key signature on `Flow::Timeline`; instrument and staff system on `Part`; clef on `Staff`; staff assignment on `Voice`. Lookup is a binary search over tuples computed at insert. `#change_at` answers whether a change *starts* at a position, as distinct from what is in force there, which is what a writer needs in order to decide whether to print a signature.
52
+
53
+ - **`HeadMusic::Time::KeySignatureEvent`**, carrying a signature as fifths and, optionally, the interpretation of it. Neither derives the other. A signature underdetermines its interpretation — two sharps is D major, B minor, E dorian, or A mixolydian — and an interpretation does not fix its signature either, because the two legitimately diverge: C dorian written in cantus mollis takes the parallel minor's three flats and naturalizes the sixth.
54
+
55
+ Fifths rather than a `KeySignature` because a `KeySignature` cannot be built from a bare signature: `KeySignature.get("3 flats")` raises, so naming three flats means naming an interpretation of it, after which the stored tonic and quality are wrong whenever the interpretation disagrees. Fifths is also exactly what MusicXML stores.
56
+
57
+ - **`HeadMusic::Rudiment::Key.for_fifths(n)`** — the conventional reading of a signature that carries no interpretation of its own, for the two consumers that cannot proceed without a tonic: LilyPond's `\key`, and the `Diatonic` guideline. Signatures themselves are unbounded, since a theoretical key such as G♯ major counts each double accidental twice and reaches eight; the table stops at ±7, because past that there is no conventional major key to name. So past ±7 a `tonal_context` is required, and `change_key_signature` raises at authoring time rather than leaving every reader that needs a tonic to raise later.
58
+
59
+ - **`HeadMusic::Content::CantusFirmus::Example#to_flow(rhythmic_value:, meter:)`.** An example was a catalog datum — a pitch list with a mode and a citation — that nothing in the gem turned into music. It now realizes as a standalone flow with one part, no player, and one note per bar. Rhythm and meter are the realization's choice rather than the datum's, so they are parameters.
60
+
61
+ - **`Project#to_h` / `.from_h` / `#to_json` / `.from_json`**, and `Flow.from_v3_h`. Schema 4 round-trips players, flows, parts, voices, staff assignments, instrument changes, staff systems and their changes, clef changes, tempo and tempo changes, subtick-precise positions, and repeat structure, and round-trips a standalone flow as its own document. A tempo serializes as `{"beat_value", "beats_per_minute"}` rather than as a `"quarter = 72"` string, because `Tempo.get` reads the number by stripping non-digits and would turn 72.5 into 725.
62
+ - **`Flow.new(tempo:)`, `Flow#tempo`, and `Flow#change_tempo`**, alongside the meter and key signature equivalents. A tempo change allocates its bar the way a meter change does, and accepts a `Tempo`, a tempo name, or a `"quarter = 96"` string.
63
+ - **`Flow#remove_meter_change`, `#remove_key_signature_change`, and `#remove_tempo_change`** un-author a change, answering the removed value. 20.x cleared a change by passing `nil` to `change_*`; that now raises an `ArgumentError` naming the remover, rather than reaching a rudiment getter that would raise something unrelated.
64
+
65
+ - `Flow#position`, `Content::Position#subtick`, `Voice#assign_staff`, `Part#instrument_at` / `#staff_system_at` / `#instruments`, `Player#instruments` / `#primary_instrument` (derived from the parts, so they cannot drift from the instrument changes authored on them).
66
+
67
+ ### Changed
68
+
69
+ - **Schema version 4.** `Flow#to_h` carries a `timeline` (opening meter and key signature, plus the changes to each) and `parts` (each with its instrument, instrument changes, staff system, and voices). Key and meter changes are no longer serialized per bar; a bar's own state is its repeat structure.
70
+
71
+ - **`Content::Bar` keeps only what is bar-shaped** — barlines, repeat structure, volta brackets. Its `#key_signature` and `#meter` are now derived reads of the change *authored in that bar*, and the writers `#key_signature=` and `#meter=` are gone; use `Flow#change_key_signature` and `#change_meter`. The bar had been carrying these in parallel with `Time::MeterMap` doing the same job properly and unused by `Content`.
72
+
73
+ - **`Clef.get(:tenor_clef)` is the C clef on the fourth line**, as in every theory text. It had been an alias of `vocal_tenor_clef`, the octave-down G clef of vocal scores, which won the lookup by appearing first in the catalog; that clef keeps its own name and its `tenor_g_clef` alias. `tenor_c_clef` still names the C clef too.
74
+ - **Meter and key signature changes take a bar number, and reject anything else.** Both are bar-aligned by definition, so `flow.change_meter("2:3", "3/4")` raises rather than rounding into a bar.
75
+
76
+ - **`Content::Position` wraps a `Time::MusicalPosition`.** It gains a subtick, and is normalized once at construction and then frozen — it is a sort key that `Voice#place` binary-searches over, and a mutable sort key is a wrong-note bug that raises nothing. `#eql?` and `#hash` are defined on the component tuple alongside `#<=>`; the flow deliberately takes no part in comparison, preserving the behavior `Voice#placement_at` has always guarded with. `#values` is removed in favor of `#to_a`, which now has four components. `#code` emits a subtick only when there is one, so the everyday `"bar:count:tick"` form is unchanged.
77
+
78
+ - **`Notation::RenderPlan#key_value` receives a key signature event** rather than a key signature, because the two formats want different things from it. `MusicXML::KeyMapper.mode` now takes a tonal context and returns `nil` where there is none, and the writer omits `<mode>` rather than inventing a major. LilyPond renders what is printed at the clef: the interpretation where it agrees with the signature, so a D dorian flow still prints `\key d \dorian`, and the signature where they diverge, so cantus mollis prints `\key c \minor`.
79
+
80
+ - **`Notation::ClefSelector` is demoted to a fallback.** When a staff has an authored clef the writers use it; the selector infers one from a voice's pitch range only for a part whose staves were never authored — an ABC import, a bare counterpoint exercise. The fallback stays in the writers rather than moving onto `Staff`, because it reads a *voice's* range and a staff has no back-reference to one.
81
+
82
+ - `Time::PPQN` is an alias of `Rudiment::Rhythm::PPQN` rather than a second literal 960.
83
+
84
+ ### Removed
85
+
86
+ - `HeadMusic::Content::Composition`, and the dead `HeadMusic::Content::Staff` (see migration note 4).
87
+ - `HeadMusic::Time::EventMapSupport`, superseded by `Time::EventMap`.
88
+ - `Time::MusicalPosition#to_total_subticks` and its `#to_i` alias, `TempoMap#normalize_position`, and `TempoMap#meter=` — all of which existed to support the comparator fixed below.
89
+
90
+ ### Fixed
91
+
92
+ - **`Time::MusicalPosition#normalize!` destroyed the last count of every bar.** `RadixCarry#carry` used `divmod`, which is correct for the 0-indexed tick and subtick and wrong for the 1-indexed count: in 3/4, `1:3:0:0` normalized to the invalid `2:0:0:0`, and so did `1:4:0:0` in 4/4 and `1:6:0:0` in 6/8. A 1-indexed component is now shifted into 0-indexed space and back.
93
+
94
+ - **`Time::MusicalPosition#<=>` was not a total order across a meter change.** It converted both positions to elapsed subticks through a single stored meter, assuming every prior bar had it, so a position in bar 4 of 4/4 compared *greater* than one in bar 5 of 7/8. Positions now compare their component tuple lexically, which needs no meter at all.
95
+
96
+ Both bugs were latent in 20.x only because `Time` was unused by `Content`. They are on the path every note travels now.
97
+
98
+ - **A `Content::Position` rolled across a meter change kept the origin bar's count unit.** The tick carry ran under the meter of the bar the position started in, so a quarter after `1:4:480` in 4/4 landed in a 6/8 bar spelled `2:1:480`, where the same instant is `2:2:000`; the two compared unequal, so a placement rolled into the bar and one authored there did not merge. A position is now carried again under the destination bar's meter until it lands in a bar it was carried under.
99
+
100
+ - **MusicXML `<backup>` rewound a whole measure regardless of what the preceding voice wrote.** In a multi-voice part whose earlier voice ended mid-bar, the cursor went negative -- invalid MusicXML, with no error. It now rewinds by the duration actually written. Whole-measure filler rests also carry `<voice>` and `<staff>`, where before a reader stacked them onto voice 1 of staff 1 and left a grand staff's bass staff empty.
101
+
102
+ - **Every clef the gem knows renders to LilyPond** by its LilyPond name -- alto, tenor, soprano, mezzosoprano, baritone, varbaritone, french, subbass, percussion, and the quoted octave clefs `"treble_8"` and `"treble^8"` -- where an authored clef other than bass had rendered as treble.
103
+
104
+ - **A part with no voices renders**, in both writers, as a staff of whole-measure rests under its clef, key, and time, so a tacet chair keeps its line in the score. LilyPond had dropped the part; MusicXML had raised `NoMethodError`.
105
+
106
+ - **A one-staff part holding several voices renders in LilyPond as one staff** with a `\new Voice` per voice in parallel, named for its part, as MusicXML already rendered it. It had rendered as one staff per voice.
107
+
10
108
  ## [20.1.0] - 2026-09-05
11
109
 
12
110
  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.
@@ -731,7 +829,8 @@ note = HeadMusic::Rudiment::Note.get("F#4 dotted-quarter")
731
829
 
732
830
  For changes in versions prior to 0.28.0, please refer to the git history.
733
831
 
734
- [Unreleased]: https://github.com/roberthead/head_music/compare/v20.1.0...HEAD
832
+ [Unreleased]: https://github.com/roberthead/head_music/compare/v21.0.0...HEAD
833
+ [21.0.0]: https://github.com/roberthead/head_music/compare/v20.1.0...v21.0.0
735
834
  [20.1.0]: https://github.com/roberthead/head_music/compare/v20.0.0...v20.1.0
736
835
  [20.0.0]: https://github.com/roberthead/head_music/compare/v19.0.0...v20.0.0
737
836
  [19.0.0]: https://github.com/roberthead/head_music/compare/v18.0.0...v19.0.0
data/CLAUDE.md CHANGED
@@ -10,42 +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
- ```bash
14
- # Install dependencies
15
- bin/setup
16
-
17
- # Run tests with coverage
18
- bundle exec rake
19
-
20
- # Run tests without coverage
21
- bundle exec rspec
22
-
23
- # Run a specific test file
24
- bundle exec rspec spec/head_music/rudiments/pitch_spec.rb
25
-
26
- # Run linting
27
- bundle exec rubocop
28
-
29
- # Run all validation checks (tests, linting, security)
30
- bundle exec rake validate
31
-
32
- # Open interactive console with gem loaded
33
- bin/console
34
- # or
35
- bundle exec rake console
13
+ `rake -T` lists the custom tasks. Two distinctions it does not make obvious:
36
14
 
37
- # Generate documentation
38
- bundle exec rake doc
39
-
40
- # Check documentation coverage
41
- bundle exec rake doc_stats
15
+ ```bash
16
+ bundle exec rake # tests WITH coverage (the default task)
17
+ bundle exec rspec # tests WITHOUT coverage
42
18
  ```
43
19
 
44
20
  ### Git Etiquette
45
21
 
46
22
  **IMPORTANT: Do not make a commit unless I explicitly ask you to.** Wait for explicit instruction before running `git commit`.
47
23
 
48
- When composing git commit messages, follow best-practices. However, do not mention yourself (claude) or list yourself as a co-author.
24
+ 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.
49
25
 
50
26
  This project uses a rebase flow and `main` as the mainline branch.
51
27
 
@@ -130,9 +106,7 @@ The codebase follows a domain-driven design with clear module boundaries:
130
106
 
131
107
  ### Entry Points
132
108
 
133
- - Main file: `lib/head_music.rb`
134
- - Module loading order is important and defined in the main file
135
- - Constants like GOLDEN_RATIO are defined at the top level
109
+ Module loading order matters and is defined in `lib/head_music.rb`.
136
110
 
137
111
  ## Important Implementation Details
138
112
 
@@ -186,22 +160,6 @@ This project deliberately deprioritizes formal documentation in favor of clear,
186
160
  - Prefer delegation over inheritance
187
161
  - Always run `bundle exec rubocop -a` after editing ruby code
188
162
 
189
- ## Common Development Tasks
190
-
191
- ### Adding a New Musical Concept
192
-
193
- 1. Create the class in the appropriate module
194
- 2. Include `HeadMusic::Named` if it needs internationalization
195
- 3. Add factory method `.get()` if appropriate
196
- 4. Create corresponding spec file
197
- 5. Add translations to locale files if using Named
198
-
199
- ### Modifying Existing Classes
200
-
201
- 1. Check for dependent classes that might be affected
202
- 2. Run tests for the specific module: `bundle exec rspec spec/head_music/[module_name]`
203
- 3. Ensure translations are updated if names change
204
-
205
163
  ## Reference Documents
206
164
 
207
165
  Domain reference materials live in `references/`.
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- head_music (20.1.0)
4
+ head_music (21.0.0)
5
5
  activesupport (>= 7.0, < 10)
6
6
  humanize (>= 2, < 4)
7
7
  i18n (~> 1.8)
data/Rakefile CHANGED
@@ -67,4 +67,11 @@ namespace :style do
67
67
  File.write(path, snapshot.to_yaml)
68
68
  puts "Wrote #{snapshot.values.sum(&:size)} strings to #{path}"
69
69
  end
70
+
71
+ desc "Regenerate the pinned corpus grading (spec/fixtures/style/corpus_fitness.json)"
72
+ task :snapshot_corpus_fitness do
73
+ path = File.expand_path("spec/fixtures/style/corpus_fitness.json", __dir__)
74
+ sh "bundle exec ruby bin/guide_grade_corpus.rb #{path}"
75
+ puts "Wrote #{path}"
76
+ end
70
77
  end
@@ -4,15 +4,15 @@
4
4
  #
5
5
  # bundle exec ruby bin/guide_grade_corpus.rb out.json
6
6
  #
7
- # Written to run UNMODIFIED on both sides of a grading change. At the merge-base
8
- # `assessable?` does not exist and the harmony guides raise on a solo voice, so
9
- # the one is asked for conditionally and the other is recorded as a value rather
10
- # than being allowed to stop the run. Everything else is common to both trees.
7
+ # Written to run on both sides of a grading change: the corpus and the grading
8
+ # live in spec/support/guide_grading.rb, and this script is only the file-writing
9
+ # wrapper around them. `rake style:snapshot_corpus_fitness` writes the pinned
10
+ # snapshot; this script exists for capturing a second one to diff against it.
11
11
  #
12
- # The invariant that sentence protects is that each column is the same
13
- # measurement made again -- not that the file is never edited. When a change
14
- # needs a seam this script does not yet have, the edit lands BEFORE both
15
- # captures and is proven a no-op by diffing a capture from either side of it.
12
+ # The invariant is that each column is the same measurement made again -- not
13
+ # that the file is never edited. When a change needs a seam this script does not
14
+ # yet have, the edit lands BEFORE both captures and is proven a no-op by diffing
15
+ # a capture from either side of it.
16
16
  #
17
17
  # Loading the fixture exercises means loading spec_helper, which starts
18
18
  # SimpleCov and rewrites coverage/.last_run.json. That file is restored on the
@@ -34,91 +34,13 @@ at_exit do
34
34
  end
35
35
 
36
36
  require "head_music"
37
- require "composition_context"
37
+ require "flow_context"
38
38
  require "spec_helper"
39
39
 
40
- LADDER = %w[D4 F4 E4 G4 F4 A4 G4 F4].freeze
41
- REPEATED = Array.new(8, "E4").freeze
42
- CANTUS = %w[D4 F4 E4 D4 G4 F4 E4 D4].freeze
40
+ rows = GuideGrading.rows
43
41
 
44
- def composition(key: "D dorian")
45
- HeadMusic::Content::Composition.new(name: "corpus", key_signature: key)
46
- end
47
-
48
- def place(voice, pitches)
49
- pitches.each_with_index { |pitch, bar| voice.place("#{bar + 1}:1", :whole, pitch) }
50
- voice
51
- end
52
-
53
- # A voice alone in its composition: no companion, so the harmony guides have
54
- # nothing to be set against.
55
- def solo(pitches)
56
- place(composition.add_voice(role: :counterpoint), pitches)
57
- end
58
-
59
- # A counterpoint voice with a companion, which may itself be empty.
60
- def accompanied(pitches, companion_pitches)
61
- comp = composition
62
- place(comp.add_voice(role: "Cantus Firmus"), companion_pitches)
63
- place(comp.add_voice(role: :counterpoint), pitches)
64
- end
65
-
66
- def corpus
67
- entries = []
68
- (0..8).each { |n| entries << ["solo-ascending-#{n}", solo(LADDER.first(n))] }
69
- (0..8).each { |n| entries << ["solo-repeated-#{n}", solo(REPEATED.first(n))] }
70
- [0, 1, 2, 4, 8].each { |n| entries << ["against-empty-#{n}", accompanied(LADDER.first(n), [])] }
71
- [0, 1, 2, 4, 8].each { |n| entries << ["against-cantus-#{n}", accompanied(LADDER.first(n), CANTUS)] }
72
-
73
- %w[
74
- fux_cantus_firmus_examples clendinning_cantus_firmus_examples
75
- schoenberg_cantus_firmus_examples davis_and_lybbert_cantus_firmus_examples
76
- fux_cantus_firmus_examples_with_errors fux_first_species_examples
77
- clendinning_first_species_examples davis_and_lybbert_first_species_examples
78
- doubled_octave_examples
79
- ].each do |source|
80
- Array(send(source)).each_with_index do |context, index|
81
- context.composition.voices.each_with_index do |voice, position|
82
- entries << ["#{source}-#{index}-v#{position}", voice]
83
- end
84
- end
85
- end
86
- entries
87
- end
88
-
89
- def grade(guide, voice)
90
- # Through the guide, not GuideAssessment.new: a composite guide grades its
91
- # members separately and refuses that constructor, and the rescue below would
92
- # have recorded the refusal as a per-row error while the run still exited 0.
93
- # Identical for every leaf guide -- Guides::Base.assess and Configured#assess
94
- # are both GuideAssessment.new(self, voice) -- so this edit was made before
95
- # either capture was taken and proven a byte-identical no-op on the before
96
- # tree.
97
- assessment = guide.assess(voice)
98
- items = assessment.guide_item_assessments
99
- {
100
- fitness: assessment.fitness.round(12),
101
- adherent: assessment.adherent?,
102
- message_count: assessment.messages.length,
103
- item_count: items.length,
104
- assessable: assessment.assessable?,
105
- failed_gates: items.select { |item| item.gate? && !item.adherent? }.map { |item| item.guideline.name.split("::").last }.sort
106
- }
107
- rescue => error
108
- {fitness: nil, adherent: nil, message_count: nil, item_count: nil, assessable: nil,
109
- failed_gates: [], error: error.class.name}
110
- end
111
-
112
- rows = corpus.flat_map do |label, voice|
113
- HeadMusic::Style::Guide::ALL.map do |guide|
114
- {
115
- corpus: label,
116
- notes: voice.notes.length,
117
- guide: HeadMusic::Style::Guide.key_for(guide)
118
- }.merge(grade(guide, voice))
119
- end
120
- end
121
-
122
- File.write(ARGV.fetch(0), JSON.pretty_generate(rows))
123
- warn "rows=#{rows.length} corpus=#{corpus.length} guides=#{HeadMusic::Style::Guide::ALL.length} " \
42
+ # One row per line: valid JSON, but a third the size of a pretty-printed dump
43
+ # and diffable a row at a time, which is how the snapshot is read.
44
+ File.write(ARGV.fetch(0), "[\n#{rows.map { |row| JSON.generate(row) }.join(",\n")}\n]\n")
45
+ warn "rows=#{rows.length} guides=#{HeadMusic::Style::Guide::ALL.length} " \
124
46
  "errors=#{rows.count { |row| row[:error] }}"
@@ -167,7 +167,7 @@ def composite_members(capture)
167
167
  end
168
168
 
169
169
  # Only assessable rows carry information. Every solo entry and every cantus
170
- # firmus fixture is a single-voice composition, so its harmony member gates out
170
+ # firmus fixture is a single-voice flow, so its harmony member gates out
171
171
  # and the composite reads 0.000 by construction.
172
172
  def composite_rows(capture)
173
173
  members = composite_members(capture)
@@ -8,7 +8,7 @@ class HeadMusic::Analysis::HarmonicInterval
8
8
  def initialize(voice1, voice2, position)
9
9
  @voice1 = voice1
10
10
  @voice2 = voice2
11
- @position = position.is_a?(String) ? HeadMusic::Content::Position.new(voice1.composition, position) : position
11
+ @position = position.is_a?(String) ? HeadMusic::Content::Position.new(voice1.flow, position) : position
12
12
  end
13
13
 
14
14
  def diatonic_interval
@@ -1,28 +1,30 @@
1
1
  # A module for musical content
2
2
  module HeadMusic::Content; end
3
3
 
4
- # Representation of a bar in a composition
4
+ # Representation of a bar in a flow
5
5
  # Encapsulates meter and key signature changes
6
6
  # and repeat structure (repeat barlines and volta brackets) as content semantics
7
7
  class HeadMusic::Content::Bar
8
- attr_reader :composition, :ends_repeat_after_num_plays, :plays_on_passes, :key_signature, :meter
8
+ attr_reader :flow, :number, :ends_repeat_after_num_plays, :plays_on_passes
9
9
  attr_writer :starts_repeat
10
10
 
11
- def initialize(composition, key_signature: nil, meter: nil)
12
- @composition = composition
13
- self.key_signature = key_signature
14
- self.meter = meter
11
+ def initialize(flow, number: HeadMusic::Time::MusicalPosition::DEFAULT_FIRST_BAR)
12
+ @flow = flow
13
+ @number = number
15
14
  @starts_repeat = false
16
15
  @ends_repeat_after_num_plays = nil
17
16
  @plays_on_passes = nil
18
17
  end
19
18
 
20
- def key_signature=(value)
21
- @key_signature = value ? HeadMusic::Rudiment::KeySignature.get(value) : nil
19
+ # The key signature and meter a bar reports are the changes authored here,
20
+ # read from the flow's timeline rather than stored -- nil where nothing was
21
+ # authored, which is what a writer reads to decide whether to print one.
22
+ def key_signature
23
+ flow.timeline.key_signature_change_at(number)&.key_signature
22
24
  end
23
25
 
24
- def meter=(value)
25
- @meter = value ? HeadMusic::Rudiment::Meter.get(value) : nil
26
+ def meter
27
+ flow.timeline.meter_change_at(number)
26
28
  end
27
29
 
28
30
  def starts_repeat?
@@ -56,12 +58,11 @@ class HeadMusic::Content::Bar
56
58
  end
57
59
 
58
60
  # Sparse serialization: only non-default state, so a default bar is {}.
59
- # KeySignature serializes via #name ("F♯ minor") because #to_s ("3 sharps")
60
- # cannot be parsed back by KeySignature.get.
61
+ #
62
+ # Key and meter changes are not here: they belong to the flow's timeline, and
63
+ # a bar merely reports the ones authored in it.
61
64
  def to_h
62
65
  hash = {}
63
- hash["key_signature"] = key_signature.name if key_signature
64
- hash["meter"] = meter.to_s if meter
65
66
  hash["starts_repeat"] = true if starts_repeat?
66
67
  hash["ends_repeat_after_num_plays"] = ends_repeat_after_num_plays if ends_repeat?
67
68
  hash["plays_on_passes"] = plays_on_passes.dup if plays_on_passes
@@ -52,6 +52,31 @@ module HeadMusic
52
52
  pitches.length
53
53
  end
54
54
 
55
+ # Realize the example as a standalone flow: one part, no player, one
56
+ # voice, one note per bar.
57
+ #
58
+ # An example is a catalog datum -- a pitch list with a mode and a
59
+ # citation -- not content, so rhythm and meter are the realization's
60
+ # choice rather than the datum's, and are parameters.
61
+ #
62
+ # The example's tonal center and mode land on the flow's opening key
63
+ # signature with no loss: the mode is carried by the tonal context, not
64
+ # inferred from the signature, which is what lets an example in
65
+ # E phrygian and one in D dorian share a signature of zero without
66
+ # collapsing into each other.
67
+ def to_flow(rhythmic_value: :whole, meter: "4/4")
68
+ flow = HeadMusic::Content::Flow.new(name: to_s, key_signature: key_signature_name, meter: meter)
69
+ voice = flow.add_voice(role: "cantus firmus")
70
+ pitches.each_with_index { |pitch, index| voice.place("#{index + 1}:1", rhythmic_value, pitch) }
71
+ flow
72
+ end
73
+
74
+ # The mode named on its own tonal center, which KeySignature reads as a
75
+ # collection while retaining the scale type.
76
+ def key_signature_name
77
+ [tonal_center, mode].compact.join(" ")
78
+ end
79
+
55
80
  def to_s
56
81
  "#{tonal_center} #{mode} (#{source})"
57
82
  end
@@ -1,12 +1,12 @@
1
1
  # A module for musical content
2
2
  module HeadMusic::Content; end
3
3
 
4
- # A comment is a free-text annotation, optionally anchored to a position in a composition.
4
+ # A comment is a free-text annotation, optionally anchored to a position in a flow.
5
5
  class HeadMusic::Content::Comment
6
- attr_reader :composition, :text, :position
6
+ attr_reader :flow, :text, :position
7
7
 
8
- def initialize(composition, text, position = nil)
9
- @composition = composition
8
+ def initialize(flow, text, position = nil)
9
+ @flow = flow
10
10
  @text = text
11
11
  ensure_position(position)
12
12
  end
@@ -25,12 +25,12 @@ class HeadMusic::Content::Comment
25
25
  return if position.nil?
26
26
 
27
27
  @position = if position.is_a?(HeadMusic::Content::Position)
28
- unless position.composition.equal?(composition)
29
- raise ArgumentError, "position belongs to a different composition"
28
+ unless position.flow.equal?(flow)
29
+ raise ArgumentError, "position belongs to a different flow"
30
30
  end
31
31
  position
32
32
  else
33
- HeadMusic::Content::Position.new(composition, position)
33
+ HeadMusic::Content::Position.new(flow, position)
34
34
  end
35
35
  end
36
36
  end
@@ -0,0 +1,98 @@
1
+ class HeadMusic::Content::Flow
2
+ # What every schema version's reader does the same way: check the version,
3
+ # build the base flow, and replay placements, repeat flags, and comments
4
+ # through the public builder API. A subclass names its SCHEMA_VERSION, says
5
+ # where the opening timeline values live, and walks its own containers.
6
+ #
7
+ # Raw values are validated at the boundary by SchemaValues, so corrupted
8
+ # input raises ArgumentError with path context instead of silently
9
+ # deserializing wrong.
10
+ class Deserializer
11
+ def initialize(hash)
12
+ raise ArgumentError, "expected a Hash, got #{hash.class}" unless hash.is_a?(Hash)
13
+
14
+ @hash = hash.deep_transform_keys(&:to_s)
15
+ validate_schema_version
16
+ end
17
+
18
+ def flow
19
+ @flow ||= build_base_flow.tap { |flow| build(flow) }
20
+ end
21
+
22
+ private
23
+
24
+ attr_reader :hash
25
+
26
+ def values
27
+ @values ||= SchemaValues.new
28
+ end
29
+
30
+ def validate_schema_version
31
+ version = hash["schema_version"]
32
+ return if version.is_a?(Integer) && version == self.class::SCHEMA_VERSION
33
+
34
+ raise ArgumentError, unsupported_version_message(version)
35
+ end
36
+
37
+ def unsupported_version_message(version)
38
+ "unsupported schema_version: #{version.inspect} (supported: #{self.class::SCHEMA_VERSION})"
39
+ end
40
+
41
+ def build_base_flow
42
+ HeadMusic::Content::Flow.new(
43
+ name: hash["name"],
44
+ key_signature: values.key_signature(timeline_hash["key_signature"], timeline_path("key_signature")),
45
+ meter: values.meter(timeline_hash["meter"], timeline_path("meter")),
46
+ tempo: values.tempo(timeline_hash["tempo"], timeline_path("tempo")),
47
+ composer: hash["composer"],
48
+ origin: hash["origin"]
49
+ )
50
+ end
51
+
52
+ def bar_hashes
53
+ @bar_hashes ||= Array(hash["bars"])
54
+ end
55
+
56
+ # A list of bar-keyed changes, each yielded with its validated bar number
57
+ # and its path for error messages.
58
+ def each_change(list, base)
59
+ Array(list).each_with_index do |change, index|
60
+ yield values.bar_number(change, index, base), change, "#{base}[#{index}]"
61
+ end
62
+ end
63
+
64
+ def build_placements(voice, voice_hash, voice_path)
65
+ Array(voice_hash["placements"]).each_with_index do |placement_hash, placement_index|
66
+ path = "#{voice_path}.placements[#{placement_index}]"
67
+ placement = voice.place(
68
+ values.position(placement_hash["position"], path),
69
+ values.rhythmic_value(placement_hash["rhythmic_value"], path),
70
+ values.placement_sounds(placement_hash, path)
71
+ )
72
+ placement.beam_break_before = placement_hash["beam_break_before"] if placement_hash.key?("beam_break_before")
73
+ values.placement_syllables(placement_hash, path).each do |syllable|
74
+ placement.sing(syllable.text, verse: syllable.verse, hyphen_after: syllable.hyphen_after)
75
+ end
76
+ end
77
+ end
78
+
79
+ def apply_repeat_flags(flow)
80
+ each_change(bar_hashes, "bars") do |bar_number, bar_hash, _path|
81
+ bar = flow.bars(bar_number).last
82
+ bar.starts_repeat = true if bar_hash["starts_repeat"]
83
+ ends_repeat = bar_hash["ends_repeat_after_num_plays"]
84
+ bar.ends_repeat_after_num_plays = ends_repeat if ends_repeat
85
+ plays_on_passes = bar_hash["plays_on_passes"]
86
+ bar.plays_on_passes = plays_on_passes if plays_on_passes
87
+ end
88
+ end
89
+
90
+ def add_comments(flow)
91
+ Array(hash["comments"]).each_with_index do |comment_hash, index|
92
+ raw_position = comment_hash["position"]
93
+ position = values.position(raw_position, "comments[#{index}]") if raw_position
94
+ flow.add_comment(comment_hash["text"], position)
95
+ end
96
+ end
97
+ end
98
+ end