tonus 0.1.4 → 0.1.6

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 (59) hide show
  1. package/BIBLIOGRAPHY.md +143 -96
  2. package/CHANGELOG.md +62 -0
  3. package/README.md +7 -9
  4. package/dist/data/am.d.ts +5 -0
  5. package/dist/data/am.js +14299 -0
  6. package/dist/data/office-monastic.d.ts +3 -0
  7. package/dist/data/office-monastic.js +6956 -0
  8. package/dist/data/office-psalms-monastic.d.ts +3 -0
  9. package/dist/data/office-psalms-monastic.js +21 -0
  10. package/dist/data/{office-psalms.d.ts → office-psalms-roman.d.ts} +1 -1
  11. package/dist/data/{office-psalms.js → office-psalms-roman.js} +3 -3
  12. package/dist/engines/cal/calendar.js +16 -1
  13. package/dist/engines/cal/date.js +5 -0
  14. package/dist/engines/chant/chant.js +2 -0
  15. package/dist/{data → engines/chant/data}/compline.d.ts +1 -1
  16. package/dist/{data → engines/chant/data}/masses.d.ts +1 -1
  17. package/dist/{data → engines/chant/data}/masses.js +1 -1
  18. package/dist/{data → engines/chant/data}/prime.d.ts +1 -1
  19. package/dist/{data → engines/chant/data}/prime.js +1 -1
  20. package/dist/engines/chant/hour.js +37 -22
  21. package/dist/engines/chant/intone.js +6 -1
  22. package/dist/engines/chant/ordinary.js +1 -1
  23. package/dist/engines/chant/psalm.d.ts +4 -4
  24. package/dist/engines/chant/psalm.js +6 -4
  25. package/dist/engines/chant/types.d.ts +5 -1
  26. package/dist/engines/harmonia/api.js +11 -0
  27. package/dist/engines/harmonia/data/doctrines.js +27 -1
  28. package/dist/engines/imprint.js +7 -1
  29. package/dist/engines/planet/position.js +51 -5
  30. package/dist/engines/score/articulation.js +60 -3
  31. package/dist/engines/score/cadence.js +3 -3
  32. package/dist/engines/score/emitters/midi.js +6 -2
  33. package/dist/engines/score/infer.js +13 -1
  34. package/dist/engines/score/ir.d.ts +2 -1
  35. package/dist/engines/score/ir.js +54 -12
  36. package/dist/engines/score/modulation.js +2 -2
  37. package/dist/engines/score/parse.js +16 -8
  38. package/dist/engines/score/phrasing.js +26 -0
  39. package/dist/engines/score/prosody.js +5 -0
  40. package/dist/engines/score/tabula.d.ts +2 -0
  41. package/dist/engines/score/tabula.js +1 -0
  42. package/dist/engines/score/types.d.ts +11 -2
  43. package/dist/engines/temper/api.js +1 -1
  44. package/dist/engines/temper/data/guido.d.ts +3 -2
  45. package/dist/engines/temper/data/guido.js +30 -25
  46. package/dist/engines/temper/data/modes.js +3 -0
  47. package/dist/{data → engines/temper/data}/tones.js +1 -1
  48. package/dist/engines/temper/gabc.js +25 -2
  49. package/dist/engines/temper/guido.d.ts +3 -2
  50. package/dist/engines/temper/interval.js +12 -0
  51. package/dist/engines/temper/modality.js +5 -4
  52. package/dist/engines/temper/scale.js +33 -2
  53. package/dist/engines/temper/step.d.ts +2 -2
  54. package/dist/index.d.ts +3 -3
  55. package/package.json +2 -1
  56. package/dist/data/office.d.ts +0 -12
  57. package/dist/data/office.js +0 -13052
  58. /package/dist/{data → engines/chant/data}/compline.js +0 -0
  59. /package/dist/{data → engines/temper/data}/tones.d.ts +0 -0
@@ -1,35 +1,92 @@
1
1
  // ---------------------------------------------------------------------------
2
2
  // engines/score/articulation — note-level articulation profiles
3
3
  // ---------------------------------------------------------------------------
4
+ // The pondus ("weight") tables. Each GABC performance mark — episema, quilisma,
5
+ // liquescent, strophicus, oriscus, ictus — carries a weight delta and a
6
+ // duration delta that the parser folds into a note's rhythmicShape (see the
7
+ // tanh compressor in parse.ts). The signs encode the semiological reading of
8
+ // the mark [biblio: cardine-semiology], and the durational values the Solesmes
9
+ // rhythmic tradition [biblio: desrocquettes-values, liber-usualis]: POSITIVE
10
+ // lengthens/stresses, NEGATIVE lightens/shortens. So an episema lengthens
11
+ // (+0.9 duration), a liquescent is a passing lightening (−0.4 weight), an
12
+ // uppercase-syllable note is de-stressed (−0.7).
13
+ //
14
+ // Four profiles select how strongly those rules speak, from a semiological
15
+ // restraint to full Solesmes-rule fidelity:
16
+ // restrained — Cardine's semiological reading: rules present but soft-spoken.
17
+ // balanced — the default; BASE_WEIGHTS unaltered.
18
+ // expressive — rules pushed (stronger ictus and accent, wider contour).
19
+ // strict — maximal Solesmes-rule fidelity (episema to a full double, a
20
+ // sharper quilisma). The differences live in the per-profile
21
+ // weight overrides below.
22
+ // The curve params (weightBase/Gain/Saturation) shape the tanh knee, not the
23
+ // marks; they are documented at the profile block. These magnitudes are tuned
24
+ // by ear, not taken from a table — the sources fix the direction, not the value.
4
25
  const BASE_WEIGHTS = {
26
+ // Ictus — the rhythmic footfall (parse.ts's three-rule assignment): a stress
27
+ // and a slight broadening.
5
28
  ictusWeight: 0.9,
6
29
  ictusDuration: 0.35,
30
+ // Episema — the horizontal episema is the Solesmes lengthening mark; the note
31
+ // is broadened but only lightly stressed. Double episema broadens further.
7
32
  episemaWeight: 0.6,
8
33
  episemaDuration: 0.9,
9
34
  episemaDoubleDuration: 0.6,
35
+ // Strophicus — repeated notes (distropha/tristropha); a soft repercussion
36
+ // that broadens; the triple adds duration.
10
37
  strophicusWeight: 0.4,
11
38
  strophicusDuration: 0.8,
12
39
  strophicusTripleDuration: 0.6,
40
+ // Quilisma — the mark itself is a light, tremulous passing note (−0.3), but
41
+ // the rule is that the *preceding* note receives the stress and lengthening.
42
+ // Hence prev is positive and the quilisma negative — not a sign error.
13
43
  quilismaPrevWeight: 0.6,
14
44
  quilismaWeight: -0.3,
45
+ // Liquescent — a diminished, half-voiced note easing a consonant cluster:
46
+ // lighter and shorter.
15
47
  liquescentWeight: -0.4,
16
48
  liquescentDuration: -0.35,
49
+ // Initio — the first note of a group/melisma gets a gentle onset stress.
17
50
  initioWeight: 0.6,
18
51
  initioMelismaWeight: 0.3,
19
52
  initioMelismaDuration: 0.1,
53
+ // Accent — a tonic word-accent lands a stress.
20
54
  accentWeight: 0.4,
55
+ // Uppercase — an emphasized-capital syllable in GABC is conventionally the
56
+ // quiet incipit letter of a chant, so it is de-stressed and slightly quickened.
21
57
  uppercaseWeight: -0.7,
22
58
  uppercaseDuration: -0.2,
59
+ // Repercussion (pressus) — like the quilisma, the weight falls on the note
60
+ // *before* the repercussed unison; the oriscus element itself is light.
23
61
  repercussionPrevWeight: 0.5,
24
62
  repercussionPrevDuration: 0.4,
25
63
  repercussionOriscusWeight: -0.5,
26
- oriscusWeight: -0.3, // soft, light note
27
- oriscusDuration: -0.15, // taken slightly faster (accelerando)
28
- oriscusPrevWeight: 0.3, // rhythmic support on the preceding note
64
+ // Oriscus — a soft, light note taken slightly faster (accelerando); its
65
+ // rhythmic support sits on the preceding note.
66
+ oriscusWeight: -0.3,
67
+ oriscusDuration: -0.15,
68
+ oriscusPrevWeight: 0.3,
69
+ // Break (a neume-internal division `!`/`/`) marks a fresh onset → a stress;
70
+ // dash (a bare context, e.g. an unaccented syllable) lightens and quickens.
29
71
  breakWeight: 0.6,
30
72
  dashWeight: -0.8,
31
73
  dashDuration: -0.2,
32
74
  };
75
+ // Curve params (same fields on every profile below). They shape how the summed
76
+ // weight becomes a velocity, not what each mark contributes:
77
+ // weightBase — the velocity a note centres on; the tanh term swings ±gain
78
+ // around it, so output sits roughly in [base−gain, base+gain].
79
+ // weightGain — how loudly the rules speak: scales the tanh output (also
80
+ // multiplied by ruleGain). Rises restrained→expressive as rules
81
+ // gain authority (2.3 → 3 → 3.5), strict slightly under expressive.
82
+ // weightSaturation — the tanh knee divisor: larger = later compression, so louder
83
+ // peaks survive; expressive lowers it (2.8) for a sharper dynamic.
84
+ // durationBase/Gain/Min/Max — the same shape applied to duration deltas, clamped.
85
+ // ruleGain — global authority of the mark rules vs. positional shaping.
86
+ // contourScale — how much melodic contour (rise/fall) colours velocity.
87
+ // neumeArch/durArch— the within-neume rise-and-fall (see phrasing.ts's arch).
88
+ // ictusBoost — a final multiplier on ictus notes.
89
+ // All tuned by ear; the ordering across profiles is the doctrine, the values are not.
33
90
  const ARTICULATION_PROFILES = {
34
91
  restrained: {
35
92
  weights: { ...BASE_WEIGHTS, accentWeight: 0.25, ictusDuration: 0.2 },
@@ -1,6 +1,6 @@
1
- // Cadence formulae run four to ten notes (Homan, Cadence in Gregorian Chant,
2
- // 1961, p. xiii). Take a window at the upper end so the longest figures fit,
3
- // with room for the approach; tail-matching ignores the extra leading notes.
1
+ // Cadence formulae run four to ten notes [biblio: homan-cadence, p. xiii]. Take
2
+ // a window at the upper end so the longest figures fit, with room for the
3
+ // approach; tail-matching ignores the extra leading notes.
4
4
  const WINDOW = 8;
5
5
  /** Last up-to-WINDOW notes of a phrase, resolution note last. */
6
6
  function phraseFinalWindow(phrase) {
@@ -1,5 +1,9 @@
1
- // Divisio → rest duration (in the same beat units as note durations). Mirrors
2
- // DIVISIO_DURATIONS in parse.ts; a phrase's terminal divisio becomes a rest.
1
+ // Divisio → rest duration (in the same beat units as note durations); a phrase's
2
+ // terminal divisio becomes a rest of this length. This is the *durational*
3
+ // reading of the divisio hierarchy (canonical table in docs/score.md), distinct
4
+ // from prosody.ts's analytic weights and phrasing.ts's shaping strengths — three
5
+ // readings of the same bar-lines, each for its own purpose. Values mirror
6
+ // DIVISIO_DURATIONS in parse.ts (the source of the fractional beat lengths).
3
7
  const DIVISIO_REST = {
4
8
  ",": 0.54, "`": 0.33, ";": 0.8, ":": 1.1, "::": 1.8,
5
9
  };
@@ -1,5 +1,9 @@
1
1
  import { OFFICE_LABELS } from "../chant/types.js";
2
2
  import { MODES } from "../temper/modes.js";
3
+ // Identify an ordinary chant by its opening words. Two movements have a second,
4
+ // interior incipit because they are conventionally intoned from their second
5
+ // phrase: the Gloria's "Et in terra pax" (the celebrant sings "Gloria in
6
+ // excelsis") and the Credo's "Patrem omnipotentem" ("Credo in unum Deum").
3
7
  const ORDINARY_INCIPITS = [
4
8
  [/^kyrie/i, "ky"],
5
9
  [/^gloria/i, "gl"],
@@ -38,6 +42,9 @@ export function inferMode(ir) {
38
42
  }
39
43
  if (midis.length === 0)
40
44
  return undefined;
45
+ // The finalis is the last note of the chant — the fundamental assumption of
46
+ // modal analysis: a chant comes to rest on its mode's final [biblio:
47
+ // liber-usualis]. That pitch class picks the maneria (mode pair).
41
48
  const finalisPc = ((midis[midis.length - 1] % 12) + 12) % 12;
42
49
  const candidates = [];
43
50
  for (const [num, data] of MODES) {
@@ -48,7 +55,12 @@ export function inferMode(ir) {
48
55
  return undefined;
49
56
  if (candidates.length === 1)
50
57
  return candidates[0];
51
- // Authentic vs plagal: melody mean above finalis → authentic
58
+ // Within a maneria the authentic mode ranges a fifth-and-more ABOVE the final,
59
+ // the plagal one straddles it (roughly a fourth below to a fifth above) [biblio:
60
+ // sunol-textbook]. So the mean pitch's height above the final separates them.
61
+ // The thresholds are asymmetric and tuned: a melody must sit a clear +3
62
+ // semitones above the final to read authentic, but only dip −1 below to read
63
+ // plagal (plagal melodies dip under the final; authentic ones rarely do).
52
64
  const finalisStep = midis[midis.length - 1];
53
65
  const mean = midis.reduce((s, v) => s + v, 0) / midis.length;
54
66
  const offset = mean - finalisStep;
@@ -1,4 +1,5 @@
1
- import type { Score, ParseResult } from "./types.js";
1
+ import type { CompoundBeat, RhythmicType, Score, ParseResult } from "./types.js";
2
2
  import type { Scale } from "../temper/scale.js";
3
+ export declare function classifyRhythmicType(beats: CompoundBeat[]): RhythmicType;
3
4
  export declare function buildIR(parsed: ParseResult, chant: Score["chant"], scale: Scale): Score;
4
5
  //# sourceMappingURL=ir.d.ts.map
@@ -24,13 +24,13 @@ function rawToNote(raw, scale) {
24
24
  liquescent: raw.liquescent,
25
25
  strophicus: raw.strophicus,
26
26
  oriscus: raw.oriscus,
27
- doubleEpisema: raw.doubleEpisema,
27
+ mora: raw.mora,
28
28
  weight: raw.weight,
29
29
  },
30
30
  };
31
31
  }
32
- // The salicus ictus note (its second-to-last ascending note) is prolonged —
33
- // Suñol, Textbook Ch. V. Modest, in the spirit of an episema lengthening.
32
+ // The salicus ictus note (its second-to-last ascending note) is prolonged
33
+ // [biblio: sunol-textbook, Ch. V]. Modest, in the spirit of an episema.
34
34
  const SALICUS_PROLONGATION = 1.3;
35
35
  function makeSyllable(lyric, notes) {
36
36
  const neume = classifyNeume(notes);
@@ -46,16 +46,16 @@ function partitionByIctus(annotated) {
46
46
  let currentIctusMidi;
47
47
  const closeGroup = (items, ictusMidi) => {
48
48
  const neumeTypes = new Set();
49
- let hasDoubleEpisema = false;
49
+ let hasDoubleMora = false;
50
50
  for (const a of items) {
51
51
  neumeTypes.add(a.neumeType);
52
- if (a.note.context.doubleEpisema)
53
- hasDoubleEpisema = true;
52
+ if (a.note.context.mora === 2)
53
+ hasDoubleMora = true;
54
54
  }
55
55
  return {
56
56
  notes: items.map((a) => a.note),
57
57
  neumeTypes,
58
- hasDoubleEpisema,
58
+ hasDoubleMora,
59
59
  ictusMidi,
60
60
  shape: "arsic",
61
61
  };
@@ -85,7 +85,7 @@ function classifyGroup(group, prev, apexMidi) {
85
85
  // regardless of melodic context.
86
86
  if (group.neumeTypes.has("salicus"))
87
87
  return "arsic";
88
- if (group.hasDoubleEpisema && group.neumeTypes.has("clivis"))
88
+ if (group.hasDoubleMora && group.neumeTypes.has("clivis"))
89
89
  return "thetic";
90
90
  const groupIctusMidi = group.ictusMidi ?? group.notes[0].pitch.midi;
91
91
  // Rule 1: incise unity — at or after the apex, everything thetic.
@@ -111,14 +111,18 @@ function classifyGroup(group, prev, apexMidi) {
111
111
  // Tie-breaker: alternate from previous.
112
112
  return prev.shape === "arsic" ? "thetic" : "arsic";
113
113
  }
114
+ // Classify each compound beat, stamp its notes, and return the beat sequence —
115
+ // the A/T shape of the incise, which the rhythmic-type classifier and (later)
116
+ // the chironomy renderer both read.
114
117
  function classifyCompoundBeats(annotated) {
115
118
  if (annotated.length === 0)
116
- return;
119
+ return [];
117
120
  const groups = partitionByIctus(annotated);
118
121
  // Apex = highest-pitched ictus in the incise.
119
122
  const apexMidi = annotated
120
123
  .filter((a) => a.note.context.ictus)
121
124
  .reduce((max, a) => Math.max(max, a.note.pitch.midi), -Infinity);
125
+ const beats = [];
122
126
  for (let gi = 0; gi < groups.length; gi++) {
123
127
  const group = groups[gi];
124
128
  const prev = gi > 0 ? groups[gi - 1] : null;
@@ -127,7 +131,44 @@ function classifyCompoundBeats(annotated) {
127
131
  group.notes[ni].performance.rhythmicShape = group.shape;
128
132
  group.notes[ni].performance.rhythmicIndex = ni + 1;
129
133
  }
134
+ beats.push({ shape: group.shape, noteCount: group.notes.length });
130
135
  }
136
+ return beats;
137
+ }
138
+ // Le Guennant's rhythmic types (IV–VIII) from the beat sequence — how the incise's
139
+ // compound beats chain [biblio: carroll-chironomy, pp. 22–26]. A type is emitted
140
+ // only when the sequence genuinely fits; ambiguous shapes stay null (a wrong
141
+ // analytic label is worse than none). Contraction (Type VIII) is Suñol's local
142
+ // reading [biblio: sunol-textbook]: a thesis immediately followed by an arsis
143
+ // mid-incise means two simple rhythms overlap at a shared ictus.
144
+ export function classifyRhythmicType(beats) {
145
+ if (beats.length < 2)
146
+ return null; // a lone beat has no chaining; needs ≥ A–T
147
+ const seq = beats.map((b) => b.shape);
148
+ if (seq[0] !== "arsic")
149
+ return null; // an incise never begins thetic (Carroll p. 43)
150
+ const hasSeam = seq.some((s, i) => i > 0 && seq[i - 1] === "thetic" && s === "arsic");
151
+ if (!hasSeam) {
152
+ // A single rise-and-fall: arses, then theses, no interior thesis→arsis.
153
+ const arses = seq.filter((s) => s === "arsic").length;
154
+ const theses = seq.length - arses;
155
+ if (theses === 0)
156
+ return null; // all arsic, no resolution
157
+ if (arses === 1 && theses === 1)
158
+ return "IV"; // A–T
159
+ if (arses >= 2 && theses === 1)
160
+ return "V"; // A–A…–T
161
+ if (arses === 1 && theses >= 2)
162
+ return "VI"; // A–T–T…
163
+ return null;
164
+ }
165
+ // There is at least one thesis→arsis seam, so the incise chains multiple simple
166
+ // rhythms. Strict alternation A–T–A–T(…) is Carroll's Type VII; any other seam
167
+ // pattern is a contraction (Suñol) — Carroll's Type VIII.
168
+ const alternating = seq.every((s, i) => s === (i % 2 === 0 ? "arsic" : "thetic"));
169
+ if (alternating && seq.length >= 4)
170
+ return "VII";
171
+ return "VIII";
131
172
  }
132
173
  function applyCompoundBeats(phrases) {
133
174
  for (const phrase of phrases) {
@@ -137,12 +178,13 @@ function applyCompoundBeats(phrases) {
137
178
  annotated.push({ note, neumeType: syl.neume.type });
138
179
  }
139
180
  }
140
- classifyCompoundBeats(annotated);
181
+ phrase.beats = classifyCompoundBeats(annotated);
182
+ phrase.rhythmicType = classifyRhythmicType(phrase.beats);
141
183
  }
142
184
  }
143
185
  export function buildIR(parsed, chant, scale) {
144
186
  const phrases = [];
145
- let currentPhrase = { syllables: [] };
187
+ let currentPhrase = { syllables: [], beats: [], rhythmicType: null };
146
188
  let currentNotes = [];
147
189
  let currentLyric = null;
148
190
  for (const event of parsed.events) {
@@ -167,7 +209,7 @@ export function buildIR(parsed, chant, scale) {
167
209
  }
168
210
  currentPhrase.divisio = event;
169
211
  phrases.push(currentPhrase);
170
- currentPhrase = { syllables: [] };
212
+ currentPhrase = { syllables: [], beats: [], rhythmicType: null };
171
213
  }
172
214
  }
173
215
  if (currentLyric !== null && currentNotes.length > 0) {
@@ -1,8 +1,8 @@
1
1
  import { computeModalAffinity } from "../temper/modality.js";
2
2
  // How much a foreign mode must outscore the home mode (in normalised affinity)
3
3
  // before a phrase counts as leaning away. Calibrated against Suñol's worked
4
- // examples: at 0.25 the modulations he names in Christus resurgens (to mode 3)
5
- // register, while incidental modal colouring below that does not.
4
+ // examples [biblio: sunol-textbook]: at 0.25 the modulations he names in Christus
5
+ // resurgens (to mode 3) register, while incidental colouring below that does not.
6
6
  const MARGIN = 0.25;
7
7
  /** The pitch-class distribution of one phrase's notes (fractions summing to 1). */
8
8
  function phrasePcDistribution(phrase) {
@@ -5,6 +5,11 @@ const DEFAULT_OPTIONS = {
5
5
  oct: 3,
6
6
  useVowelAccent: true,
7
7
  };
8
+ // Per-clef diatonic-step offset. A GABC clef names the staff line it sits on
9
+ // (c1–c4, f1–f4; the `b` variants also declare a flat), which fixes which pitch
10
+ // the letters a–m read as. The value is subtracted from the letter's position
11
+ // when anchoring it onto the gamut (see `pos` below), so the same letter reads
12
+ // higher or lower depending on the clef in force.
8
13
  const CLEF_OFFSETS = new Map([
9
14
  ["c1", -3],
10
15
  ["c2", -1],
@@ -84,7 +89,7 @@ function parseNeume(notation, context) {
84
89
  let isLiquescent = false;
85
90
  let isStrophicus = false;
86
91
  let isOriscus = false;
87
- let isDoubleEpisema = false;
92
+ let mora = 0;
88
93
  // Dash prefix (weak note)
89
94
  if (token[0] === "-") {
90
95
  token = token.slice(1);
@@ -143,16 +148,19 @@ function parseNeume(notation, context) {
143
148
  durWeight += weights.ictusDuration;
144
149
  ictus = true;
145
150
  }
146
- // Episema (horizontal lengthening '.')
151
+ // Mora vocis — the GABC dot '.' lengthens THIS note (a held note before a
152
+ // pause). A double dot '..' is the double mora (a stronger lengthening at a
153
+ // major cadence); it adds to this note's duration, not the neume's first
154
+ // note — the dots belong to the note that carries them. (The horizontal
155
+ // episema is '_', handled with the ictus markers above.)
147
156
  if (modifiers.includes(".")) {
148
157
  w += weights.episemaWeight;
149
158
  durWeight += weights.episemaDuration;
150
159
  ictus = true;
151
- // Double episema '..' — boost first note of neume
160
+ mora = 1;
152
161
  if (modifiers.includes("..")) {
153
- isDoubleEpisema = true;
154
- if (intermed[0])
155
- intermed[0]._durWeight += weights.episemaDoubleDuration;
162
+ mora = 2;
163
+ durWeight += weights.episemaDoubleDuration;
156
164
  }
157
165
  }
158
166
  // Strophicus (ss or vv = repeated/tremolo notes)
@@ -233,7 +241,7 @@ function parseNeume(notation, context) {
233
241
  liquescent: isLiquescent,
234
242
  strophicus: isStrophicus,
235
243
  oriscus: isOriscus,
236
- doubleEpisema: isDoubleEpisema,
244
+ mora,
237
245
  _weight: w,
238
246
  _durWeight: durWeight,
239
247
  });
@@ -288,7 +296,7 @@ function parseNeume(notation, context) {
288
296
  liquescent: note.liquescent,
289
297
  strophicus: note.strophicus,
290
298
  oriscus: note.oriscus,
291
- doubleEpisema: note.doubleEpisema,
299
+ mora: note.mora,
292
300
  };
293
301
  });
294
302
  return result;
@@ -62,6 +62,9 @@ export function buildPhrasing(type = "lyrical", options = {}) {
62
62
  ...(options.overrides ?? {}),
63
63
  };
64
64
  }
65
+ // Authentic modes reach a fifth or more above the final and pull harder toward
66
+ // their tenor/reciting note; plagal modes straddle the final and pull less
67
+ // [biblio: sunol-textbook]. These nudge the tenor and cadence gains accordingly.
65
68
  const MODE_TYPE_TENOR_MULTIPLIER = {
66
69
  authentic: 1.08,
67
70
  plagal: 0.92,
@@ -70,12 +73,20 @@ const MODE_TYPE_CADENCE_MULTIPLIER = {
70
73
  authentic: 1.05,
71
74
  plagal: 0.95,
72
75
  };
76
+ // A more melismatic mode leans into its cadences; a syllabic one under-plays them.
73
77
  const MODE_TENDENCY_CADENCE_MULTIPLIER = {
74
78
  melismatic: 1.06,
75
79
  neumatic: 1.0,
76
80
  syllabic: 0.96,
77
81
  neutral: 1.0,
78
82
  };
83
+ // The modal ethos → performance mapping. Each mode carries a mood epithet (the
84
+ // Niedermeyer & d'Ortigue tradition [biblio: niedermeyer-ortigue], stored on
85
+ // ModeProfile in modes.ts); these translate that mood into small shaping deltas.
86
+ // The interpretation is tonus's own — the epithets are sourced, these numbers
87
+ // are a tuned editorial gesture, deliberately small. Note the coherence: sadder
88
+ // moods drop the velocity floor AND deepen the phrase curve (below), so they
89
+ // read quieter and more arched; brighter moods do the reverse.
79
90
  const MODE_MOOD_BASE_VELOCITY_DELTA = {
80
91
  neutral: 0,
81
92
  serious: -0.01,
@@ -144,9 +155,17 @@ const DURATION_ARCH_FACTOR = 0.2;
144
155
  const DURATION_MIN = 0.2;
145
156
  const DURATION_MAX = 4.0;
146
157
  const TENOR_GAIN = 0.05;
158
+ // Tenor pull falls linearly with pitch-class distance from the reciting tone and
159
+ // reaches zero at 6 semitones (a tritone) — the far side of the octave, past
160
+ // which a note no longer reads as gravitating to the tenor.
147
161
  const TENOR_DISTANCE_DIVISOR = 6;
148
162
  const CADENCE_VELOCITY_FACTOR = 0.5;
149
163
  const CADENCE_DURATION_FACTOR = 0.6;
164
+ // The phrasing-side reading of the divisio (bar-line) hierarchy. This is the same
165
+ // bar hierarchy tabulated canonically in docs/score.md and weighted for analysis
166
+ // by prosody.ts's cadenceWeight ladder — but here the weights differ on purpose:
167
+ // this is a *shaping* factor, so the virgula (`) gets 0 (a breath, no cadential
168
+ // stress), where the analytic ladder still counts it. Do not unify the three.
150
169
  const DIVISIO_STRENGTH = {
151
170
  "::": 1.0,
152
171
  ":": 0.7,
@@ -214,8 +233,15 @@ export function applyPhrasing(events, profile, tenorPc) {
214
233
  const arsis = note.context.weight;
215
234
  const arsisRelative = (arsis - minArsis) / arsisSpan;
216
235
  const contourRelative = (note.pitch.midi - minStep) / stepSpan;
236
+ // A raised-cosine (Hann) window over the phrase: 0 at the ends, 1 at the
237
+ // middle, so each phrase swells and subsides — the Solesmes grand rythme,
238
+ // the phrase-arch [biblio: mocquereau-nombre]. `t` is the note's fractional
239
+ // position through the phrase.
217
240
  const t = (order + 0.5) / (noteEntries.length + 0.0001);
218
241
  const arch = 0.5 - 0.5 * Math.cos(2 * Math.PI * t);
242
+ // Blend the note's intrinsic arsic weight against its positional arch;
243
+ // `curve` is the mix knob (0 = follow the note's own weight, 1 = follow the
244
+ // phrase arch). The result is then re-centred on 0.5 and spread by velSpread.
219
245
  let velocity = arsisRelative * (1 - profile.curve) + arch * profile.curve;
220
246
  velocity += (contourRelative - VELOCITY_CENTER) * profile.contourVel;
221
247
  velocity =
@@ -55,6 +55,11 @@ export function computeProsody(phrases) {
55
55
  }
56
56
  }
57
57
  if (phrase.divisio) {
58
+ // Analytic bar-importance weights, one rung per divisio in the bar-line
59
+ // hierarchy (the canonical table is in docs/score.md). These are a
60
+ // MEASUREMENT — every divisio counts, including the virgula (`) — distinct
61
+ // from phrasing.ts's DIVISIO_STRENGTH, which zeroes the virgula because it
62
+ // is a shaping factor, not a count.
58
63
  const d = phrase.divisio.divisio;
59
64
  if (d === "::") {
60
65
  cadDist.doubleBar++;
@@ -42,6 +42,8 @@ export interface ChantTabulaRow {
42
42
  liquescent: boolean;
43
43
  strophicus: boolean;
44
44
  oriscus: boolean;
45
+ /** Mora vocis count: 0 none, 1 dot '.', 2 double dot '..' (a stronger cadential hold). */
46
+ mora: 0 | 1 | 2;
45
47
  divisio: string | null;
46
48
  /** Index into score.cadences[] when this note forms a cadence; null otherwise. */
47
49
  cadenceRef: number | null;
@@ -104,6 +104,7 @@ export function computeTabula(ir, options = {}) {
104
104
  liquescent: n.context.liquescent,
105
105
  strophicus: n.context.strophicus,
106
106
  oriscus: n.context.oriscus,
107
+ mora: n.context.mora,
107
108
  divisio: a.divisio,
108
109
  cadenceRef: cadenceRefByPos.get(`${a.phraseIndex}:${a.syllableIndex}:${a.noteIndex}`) ?? null,
109
110
  role: n.step.role,
@@ -3,6 +3,11 @@ import type { Step } from "../temper/step.js";
3
3
  import type { NeumeShape } from "../temper/neume.js";
4
4
  import type { OfficeCode, OrdinaryCode } from "../chant/types.js";
5
5
  export type ArsisThesis = "arsic" | "thetic";
6
+ export type RhythmicType = "IV" | "V" | "VI" | "VII" | "VIII" | null;
7
+ export interface CompoundBeat {
8
+ shape: ArsisThesis;
9
+ noteCount: number;
10
+ }
6
11
  export interface Performance {
7
12
  velocity: number;
8
13
  duration: number;
@@ -21,7 +26,7 @@ export interface Context {
21
26
  liquescent: boolean;
22
27
  strophicus: boolean;
23
28
  oriscus: boolean;
24
- doubleEpisema: boolean;
29
+ mora: 0 | 1 | 2;
25
30
  weight: number;
26
31
  }
27
32
  export interface Note {
@@ -123,7 +128,7 @@ export interface ParsedNote {
123
128
  liquescent: boolean;
124
129
  strophicus: boolean;
125
130
  oriscus: boolean;
126
- doubleEpisema: boolean;
131
+ mora: 0 | 1 | 2;
127
132
  }
128
133
  export interface RestEvent {
129
134
  type: "rest";
@@ -151,6 +156,10 @@ export interface Syllable {
151
156
  export interface Phrase {
152
157
  syllables: Syllable[];
153
158
  divisio?: RestEvent;
159
+ /** The phrase's compound beats in order — the A/T sequence of the incise. */
160
+ beats: CompoundBeat[];
161
+ /** Le Guennant/Carroll rhythmic type of the incise; null if none fits. */
162
+ rhythmicType: RhythmicType;
154
163
  }
155
164
  export interface Score {
156
165
  chant: {
@@ -8,7 +8,7 @@ import { classifyInterval } from "./interval.js";
8
8
  import { buildNeume } from "./neume.js";
9
9
  import { buildGamut } from "./gamut.js";
10
10
  import { getMode } from "./modes.js";
11
- import { getTone, getDifferentia } from "../../data/tones.js";
11
+ import { getTone, getDifferentia } from "./data/tones.js";
12
12
  function resolveOpts(input) {
13
13
  if (!input)
14
14
  return {};
@@ -1,11 +1,12 @@
1
+ import type { Finger, Region } from "../step.js";
1
2
  export interface GuidonianVariant {
2
3
  hexachord: "durum" | "naturale" | "molle";
3
4
  solmization: "UT" | "RE" | "MI" | "FA" | "SOL" | "LA";
4
5
  }
5
6
  export interface GuidonianEntry {
6
7
  hand: {
7
- finger: string;
8
- region: string;
8
+ finger: Finger;
9
+ region: Region;
9
10
  };
10
11
  name: [string, string];
11
12
  variants: GuidonianVariant[];
@@ -1,29 +1,34 @@
1
- // ---------------------------------------------------------------------------
2
- // engines/temper/data/guido — Guidonian gamut and hexachord variants
3
- // ---------------------------------------------------------------------------
1
+ // The hand loci follow the CANONICAL Guidonian spiral, not a linear fill:
2
+ // begin at the thumb tip, come down the thumb, cross the finger BASES
3
+ // index→little, climb the little finger, return across the finger TIPS
4
+ // little→index, descend the index, then spiral inward; ee (76) floats above the
5
+ // middle fingertip (region "super"). The two b/♮ pairs (58/59, 70/71) share a
6
+ // locus by design — one joint, two readings (fa in molle, mi in durum), the
7
+ // tradition, not a duplication to fix. After Suñol [biblio: sunol-textbook] and
8
+ // Apel [biblio: apel-chant]; realized in working/diagram-hand-08.html.
4
9
  // prettier-ignore
5
10
  export const GUIDONIAN_DATA = new Map([
6
- [43, { hand: { finger: "base", region: "palm" }, name: ["Γ", "Gammaut"], variants: [{ hexachord: "durum", solmization: "UT" }] }],
7
- [45, { hand: { finger: "wrist", region: "center" }, name: ["A", "Are"], variants: [{ hexachord: "durum", solmization: "RE" }] }],
8
- [47, { hand: { finger: "wrist", region: "inner" }, name: ["B", "Bemi"], variants: [{ hexachord: "durum", solmization: "MI" }] }],
9
- [48, { hand: { finger: "palm", region: "base" }, name: ["C", "Cefaut"], variants: [{ hexachord: "durum", solmization: "FA" }, { hexachord: "naturale", solmization: "UT" }] }],
10
- [50, { hand: { finger: "palm", region: "middle" }, name: ["D", "Desolre"], variants: [{ hexachord: "durum", solmization: "SOL" }, { hexachord: "naturale", solmization: "RE" }] }],
11
- [52, { hand: { finger: "palm", region: "top" }, name: ["E", "Elami"], variants: [{ hexachord: "durum", solmization: "LA" }, { hexachord: "naturale", solmization: "MI" }] }],
12
- [53, { hand: { finger: "thumb", region: "base" }, name: ["F", "Fefaut"], variants: [{ hexachord: "naturale", solmization: "FA" }, { hexachord: "molle", solmization: "UT" }] }],
13
- [55, { hand: { finger: "thumb", region: "mid" }, name: ["G", "Gesolreut"], variants: [{ hexachord: "durum", solmization: "UT" }, { hexachord: "naturale", solmization: "SOL" }, { hexachord: "molle", solmization: "RE" }] }],
14
- [57, { hand: { finger: "thumb", region: "tip" }, name: ["a", "Alamire"], variants: [{ hexachord: "durum", solmization: "RE" }, { hexachord: "naturale", solmization: "LA" }, { hexachord: "molle", solmization: "MI" }] }],
15
- [58, { hand: { finger: "index", region: "base" }, name: ["b", "Befa"], variants: [{ hexachord: "molle", solmization: "FA" }] }],
16
- [59, { hand: { finger: "index", region: "mid" }, name: ["♮", "Bemi"], variants: [{ hexachord: "durum", solmization: "MI" }] }],
17
- [60, { hand: { finger: "index", region: "tip" }, name: ["c", "Cesolfaut"], variants: [{ hexachord: "durum", solmization: "FA" }, { hexachord: "naturale", solmization: "UT" }, { hexachord: "molle", solmization: "SOL" }] }],
18
- [62, { hand: { finger: "middle", region: "base" }, name: ["d", "Delasolre"], variants: [{ hexachord: "durum", solmization: "SOL" }, { hexachord: "naturale", solmization: "RE" }, { hexachord: "molle", solmization: "LA" }] }],
19
- [64, { hand: { finger: "middle", region: "mid" }, name: ["e", "Elami"], variants: [{ hexachord: "durum", solmization: "LA" }, { hexachord: "naturale", solmization: "MI" }] }],
20
- [65, { hand: { finger: "middle", region: "tip" }, name: ["f", "Fefaut"], variants: [{ hexachord: "naturale", solmization: "FA" }, { hexachord: "molle", solmization: "UT" }] }],
21
- [67, { hand: { finger: "ring", region: "base" }, name: ["g", "Gesolreut"], variants: [{ hexachord: "durum", solmization: "UT" }, { hexachord: "naturale", solmization: "SOL" }, { hexachord: "molle", solmization: "RE" }] }],
22
- [69, { hand: { finger: "ring", region: "mid" }, name: ["aa", "Alamire"], variants: [{ hexachord: "durum", solmization: "RE" }, { hexachord: "naturale", solmization: "LA" }, { hexachord: "molle", solmization: "MI" }] }],
23
- [70, { hand: { finger: "ring", region: "tip" }, name: ["bb", "Befa"], variants: [{ hexachord: "molle", solmization: "FA" }] }],
24
- [71, { hand: { finger: "pinky", region: "base" }, name: ["♮♮", "Bemi"], variants: [{ hexachord: "durum", solmization: "MI" }] }],
25
- [72, { hand: { finger: "pinky", region: "mid" }, name: ["cc", "Cesolfa"], variants: [{ hexachord: "durum", solmization: "FA" }, { hexachord: "naturale", solmization: "UT" }, { hexachord: "molle", solmization: "SOL" }] }],
26
- [74, { hand: { finger: "pinky", region: "top" }, name: ["dd", "Delasol"], variants: [{ hexachord: "durum", solmization: "SOL" }, { hexachord: "naturale", solmization: "RE" }, { hexachord: "molle", solmization: "LA" }] }],
27
- [76, { hand: { finger: "pinky", region: "tip" }, name: ["ee", "Ela"], variants: [{ hexachord: "durum", solmization: "LA" }, { hexachord: "naturale", solmization: "MI" }] }],
11
+ [43, { hand: { finger: "thumb", region: "tip" }, name: ["Γ", "Gammaut"], variants: [{ hexachord: "durum", solmization: "UT" }] }],
12
+ [45, { hand: { finger: "thumb", region: "mid" }, name: ["A", "Are"], variants: [{ hexachord: "durum", solmization: "RE" }] }],
13
+ [47, { hand: { finger: "thumb", region: "base" }, name: ["B", "Bemi"], variants: [{ hexachord: "durum", solmization: "MI" }] }],
14
+ [48, { hand: { finger: "index", region: "base" }, name: ["C", "Cefaut"], variants: [{ hexachord: "durum", solmization: "FA" }, { hexachord: "naturale", solmization: "UT" }] }],
15
+ [50, { hand: { finger: "middle", region: "base" }, name: ["D", "Desolre"], variants: [{ hexachord: "durum", solmization: "SOL" }, { hexachord: "naturale", solmization: "RE" }] }],
16
+ [52, { hand: { finger: "ring", region: "base" }, name: ["E", "Elami"], variants: [{ hexachord: "durum", solmization: "LA" }, { hexachord: "naturale", solmization: "MI" }] }],
17
+ [53, { hand: { finger: "pinky", region: "base" }, name: ["F", "Fefaut"], variants: [{ hexachord: "naturale", solmization: "FA" }, { hexachord: "molle", solmization: "UT" }] }],
18
+ [55, { hand: { finger: "pinky", region: "mid" }, name: ["G", "Gesolreut"], variants: [{ hexachord: "durum", solmization: "UT" }, { hexachord: "naturale", solmization: "SOL" }, { hexachord: "molle", solmization: "RE" }] }],
19
+ [57, { hand: { finger: "pinky", region: "top" }, name: ["a", "Alamire"], variants: [{ hexachord: "durum", solmization: "RE" }, { hexachord: "naturale", solmization: "LA" }, { hexachord: "molle", solmization: "MI" }] }],
20
+ [58, { hand: { finger: "pinky", region: "tip" }, name: ["b", "Befa"], variants: [{ hexachord: "molle", solmization: "FA" }] }],
21
+ [59, { hand: { finger: "pinky", region: "tip" }, name: ["♮", "Bemi"], variants: [{ hexachord: "durum", solmization: "MI" }] }],
22
+ [60, { hand: { finger: "ring", region: "tip" }, name: ["c", "Cesolfaut"], variants: [{ hexachord: "durum", solmization: "FA" }, { hexachord: "naturale", solmization: "UT" }, { hexachord: "molle", solmization: "SOL" }] }],
23
+ [62, { hand: { finger: "middle", region: "tip" }, name: ["d", "Delasolre"], variants: [{ hexachord: "durum", solmization: "SOL" }, { hexachord: "naturale", solmization: "RE" }, { hexachord: "molle", solmization: "LA" }] }],
24
+ [64, { hand: { finger: "index", region: "tip" }, name: ["e", "Elami"], variants: [{ hexachord: "durum", solmization: "LA" }, { hexachord: "naturale", solmization: "MI" }] }],
25
+ [65, { hand: { finger: "index", region: "top" }, name: ["f", "Fefaut"], variants: [{ hexachord: "naturale", solmization: "FA" }, { hexachord: "molle", solmization: "UT" }] }],
26
+ [67, { hand: { finger: "index", region: "mid" }, name: ["g", "Gesolreut"], variants: [{ hexachord: "durum", solmization: "UT" }, { hexachord: "naturale", solmization: "SOL" }, { hexachord: "molle", solmization: "RE" }] }],
27
+ [69, { hand: { finger: "middle", region: "mid" }, name: ["aa", "Alamire"], variants: [{ hexachord: "durum", solmization: "RE" }, { hexachord: "naturale", solmization: "LA" }, { hexachord: "molle", solmization: "MI" }] }],
28
+ [70, { hand: { finger: "ring", region: "mid" }, name: ["bb", "Befa"], variants: [{ hexachord: "molle", solmization: "FA" }] }],
29
+ [71, { hand: { finger: "ring", region: "mid" }, name: ["♮♮", "Bemi"], variants: [{ hexachord: "durum", solmization: "MI" }] }],
30
+ [72, { hand: { finger: "middle", region: "top" }, name: ["cc", "Cesolfa"], variants: [{ hexachord: "durum", solmization: "FA" }, { hexachord: "naturale", solmization: "UT" }, { hexachord: "molle", solmization: "SOL" }] }],
31
+ [74, { hand: { finger: "ring", region: "top" }, name: ["dd", "Delasol"], variants: [{ hexachord: "durum", solmization: "SOL" }, { hexachord: "naturale", solmization: "RE" }, { hexachord: "molle", solmization: "LA" }] }],
32
+ [76, { hand: { finger: "middle", region: "super" }, name: ["ee", "Ela"], variants: [{ hexachord: "durum", solmization: "LA" }, { hexachord: "naturale", solmization: "MI" }] }],
28
33
  ]);
29
34
  //# sourceMappingURL=guido.js.map
@@ -39,6 +39,9 @@ export const MODES = new Map([
39
39
  scalePcs: [2, 4, 5, 7, 9, 11, 0],
40
40
  hexachords: ["naturale"],
41
41
  profile: { mood: "sad", ethos: "tristis", phrasing: "lyrical", melodic: "arch", tendency: "neumatic" },
42
+ // Shared with mode 1 (both Protus). An earlier seed here was wrong — a copy
43
+ // of mode 5's figures, landing on F with an Ab outside hypodorian's scale;
44
+ // Suñol's tables surfaced it, and it was corrected to the Protus descent.
42
45
  cadences: [
43
46
  { id: "mi-re", steps: [1, 0] },
44
47
  { id: "ut-re", steps: [-1, 0] },
@@ -1,4 +1,4 @@
1
- // humana/data/tones — psalm tones (Graduale Romanum appendix)
1
+ // temper/data/tones — psalm tones (Graduale Romanum appendix)
2
2
  // Pitches as MIDI numbers; tone codes follow DO convention: "1g", "6F", "4e".
3
3
  // c4 clef reference: f=53 g=55 h=57 i=59 j=60 k=62 l=64 (F3–F4)
4
4
  const f = 53, g = 55, h = 57, i = 59, j = 60, k = 62, l = 64;