tonus 0.1.3 → 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 (65) 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.d.ts +8 -7
  29. package/dist/engines/imprint.js +31 -27
  30. package/dist/engines/planet/position.js +51 -5
  31. package/dist/engines/score/api.d.ts +4 -0
  32. package/dist/engines/score/api.js +11 -1
  33. package/dist/engines/score/articulation.js +60 -3
  34. package/dist/engines/score/cadence.js +3 -3
  35. package/dist/engines/score/emitters/midi.js +6 -2
  36. package/dist/engines/score/infer.js +13 -1
  37. package/dist/engines/score/ir.d.ts +2 -1
  38. package/dist/engines/score/ir.js +54 -12
  39. package/dist/engines/score/modulation.d.ts +20 -0
  40. package/dist/engines/score/modulation.js +74 -0
  41. package/dist/engines/score/parse.js +16 -8
  42. package/dist/engines/score/phrasing.js +26 -0
  43. package/dist/engines/score/prosody.js +5 -0
  44. package/dist/engines/score/tabula.d.ts +2 -0
  45. package/dist/engines/score/tabula.js +1 -0
  46. package/dist/engines/score/types.d.ts +11 -2
  47. package/dist/engines/temper/api.d.ts +17 -1
  48. package/dist/engines/temper/api.js +20 -2
  49. package/dist/engines/temper/data/guido.d.ts +3 -2
  50. package/dist/engines/temper/data/guido.js +30 -25
  51. package/dist/engines/temper/data/modes.js +3 -0
  52. package/dist/{data → engines/temper/data}/tones.js +1 -1
  53. package/dist/engines/temper/gabc.js +25 -2
  54. package/dist/engines/temper/guido.d.ts +3 -2
  55. package/dist/engines/temper/interval.js +12 -0
  56. package/dist/engines/temper/modality.d.ts +11 -0
  57. package/dist/engines/temper/modality.js +58 -0
  58. package/dist/engines/temper/scale.js +33 -2
  59. package/dist/engines/temper/step.d.ts +2 -2
  60. package/dist/index.d.ts +5 -5
  61. package/package.json +2 -1
  62. package/dist/data/office.d.ts +0 -12
  63. package/dist/data/office.js +0 -13052
  64. /package/dist/{data → engines/chant/data}/compline.js +0 -0
  65. /package/dist/{data → engines/temper/data}/tones.d.ts +0 -0
@@ -1,12 +1,33 @@
1
1
  // ---------------------------------------------------------------------------
2
2
  // engines/planet/position — Julian date, astro state, helio/geo position engine
3
3
  // ---------------------------------------------------------------------------
4
+ // Two source models sit side by side, and which one runs depends on the body:
5
+ //
6
+ // • Planets — the JPL/Standish Keplerian element tables [biblio: standish-jpl]
7
+ // in orbital.ts, solved with the iterative Newton kepler() solver. See
8
+ // planetPos.
9
+ // • Sun and Moon — Paul Schlyter's tutorial formulae [biblio:
10
+ // schlyter-positions], a lighter model with its own constants, a one-step
11
+ // eccentric-anomaly approximation (the Sun), and a named perturbation series
12
+ // (the Moon). See sunPos / moonPos.
13
+ //
14
+ // The split is deliberate: the JPL tables don't include the Moon, and Schlyter's
15
+ // Sun is accurate enough at the Sun's tiny eccentricity to skip Newton iteration.
16
+ // So the reader should not expect the same constants or solver across bodies.
17
+ //
18
+ // A few time-scale models here are standard astronomy but not yet catalogued in
19
+ // BIBLIOGRAPHY.md (marked "source TBD" at each): the ΔT (TT−UT) polynomial and
20
+ // the mean-obliquity expansion.
4
21
  import { sinDeg, cosDeg, atan2Deg, kepler, wrapAngle, toAu, toCartesian, toSpherical, toEquatorial } from "./math.js";
5
22
  import { ORBITAL_ELEMENTS } from "./orbital.js";
6
23
  const MS_PER_DAY = 86400000;
7
24
  // Precomputed Earth radius in AU (used for Moon distance conversion)
8
25
  const EARTH_ELEM = ORBITAL_ELEMENTS.get("Earth");
9
26
  export const EARTH_RADIUS_AU = toAu(EARTH_ELEM.radius);
27
+ // Mean obliquity of the ecliptic (the tilt used to rotate ecliptic → equatorial
28
+ // coordinates), in degrees. eps0 is the J2000 value 23°26′21.406″; the arcsecond
29
+ // series is the standard IAU 2006 expansion in Julian centuries T from J2000.
30
+ // (Standard astronomy; source not yet catalogued — TBD.)
10
31
  function meanObliquity(T) {
11
32
  const eps0 = 23 + 26 / 60 + 21.406 / 3600;
12
33
  const sec = -46.836769 * T -
@@ -17,8 +38,14 @@ function meanObliquity(T) {
17
38
  return eps0 + sec / 3600;
18
39
  }
19
40
  export function getState(ts) {
41
+ // 2440587.5 is the Julian Date of the Unix epoch; 2451545 is J2000.0 and 36525
42
+ // days is a Julian century — so _T is centuries from J2000 in UT.
20
43
  const JD = 2440587.5 + ts / MS_PER_DAY;
21
44
  const _T = (JD - 2451545) / 36525;
45
+ // ΔT (TT − UT), in seconds: the drift between civil UT and the uniform
46
+ // Terrestrial Time the ephemerides are reckoned in. This is a coarse local fit
47
+ // valid near the present, not a long-baseline model — a limitation for deep
48
+ // medieval dates. (Standard-form polynomial; source not yet catalogued — TBD.)
22
49
  const dT = 64.7 + 64.7 * _T - 0.6 * _T * _T; // seconds
23
50
  const TT = JD + dT / 86400;
24
51
  const J = TT - 2451545.0;
@@ -26,12 +53,16 @@ export function getState(ts) {
26
53
  const eps = meanObliquity(T);
27
54
  return { JD, TT, TS: ts, J, T, eps };
28
55
  }
29
- // ── Sun position ──
56
+ // ── Sun position ── (Schlyter's solar elements [biblio: schlyter-positions])
30
57
  export function sunPos(state) {
31
58
  const { J, eps } = state;
59
+ // omega = longitude of perihelion, e = eccentricity, M = mean anomaly, all as
60
+ // Schlyter's linear-in-J elements (J = days from J2000).
32
61
  const omega = 282.9404 + 4.70935e-5 * J;
33
62
  const e = 0.016709 - 1.151e-9 * J;
34
63
  const M = 356.047 + 0.9856002585 * J;
64
+ // Eccentric anomaly by a single first-order step, not Newton iteration: the
65
+ // Sun's eccentricity is small enough (~0.017) that one correction suffices.
35
66
  const E = M + (180 / Math.PI) * e * sinDeg(M) * (1 + e * cosDeg(M));
36
67
  const xp = cosDeg(E) - e;
37
68
  const yp = Math.sqrt(1 - e * e) * sinDeg(E);
@@ -54,9 +85,13 @@ export function sunPos(state) {
54
85
  },
55
86
  };
56
87
  }
57
- // ── Moon position ──
88
+ // ── Moon position ── (Schlyter's lunar elements [biblio: schlyter-positions])
58
89
  export function moonPos(state, sun) {
59
90
  const { J, eps } = state;
91
+ // Orbital elements (J = days from J2000): Omega = ascending node, I =
92
+ // inclination, omega = argument of perigee, a = semi-major axis IN EARTH RADII
93
+ // (not AU — the Moon is measured from Earth; the AU conversion happens at the
94
+ // end via EARTH_RADIUS_AU), e = eccentricity, M = mean anomaly.
60
95
  const Omega = wrapAngle(125.1228 - 0.0529538083 * J);
61
96
  const I = 5.1454;
62
97
  const omega = wrapAngle(318.0634 + 0.1643573223 * J);
@@ -73,12 +108,18 @@ export function moonPos(state, sun) {
73
108
  const y = (cw * sO + sw * cO * cI) * xh + (-sw * sO + cw * cO * cI) * yh;
74
109
  const z = sw * sI * xh + cw * sI * yh;
75
110
  const [lonE, latE, distE] = toSpherical(x, y, z);
111
+ // The perturbation arguments: Lm = Moon's mean longitude, Ms/Ls = Sun's mean
112
+ // anomaly/longitude, D = mean elongation (Moon − Sun), F = argument of latitude
113
+ // (Moon − node). The Sun's disturbing pull is expressed as sines of these.
76
114
  const Lm = wrapAngle(Omega + omega + M);
77
115
  const Ms = sun.orbit.M;
78
116
  const Ls = sun.orbit.L;
79
117
  const D = wrapAngle(Lm - Ls);
80
118
  const F = wrapAngle(Lm - Omega);
81
- // Lunar perturbations
119
+ // Lunar perturbations, in degrees — Schlyter's truncated series of the classical
120
+ // named terms. The three largest in longitude are the evection (−1.274·sin(M−2D),
121
+ // the Sun stretching the Moon's ellipse), the variation (+0.658·sin(2D)), and
122
+ // the annual equation (−0.186·sin Ms); the remainder are smaller corrections.
82
123
  const lonPerturb = -1.274 * sinDeg(M - 2 * D) + 0.658 * sinDeg(2 * D) - 0.186 * sinDeg(Ms) -
83
124
  0.059 * sinDeg(2 * M - 2 * D) - 0.057 * sinDeg(M - 2 * D + Ms) +
84
125
  0.053 * sinDeg(M + 2 * D) + 0.046 * sinDeg(2 * D - Ms) +
@@ -116,12 +157,17 @@ export function planetPos(name, state, sun) {
116
157
  throw new Error(`Unknown body: ${name}`);
117
158
  const { J, T, eps } = state;
118
159
  const oe = body.datasets;
119
- // Use higher-precision dataset (1800–2050) if in range, otherwise long-range dataset
160
+ // Two Standish element sets per body (see orbital.ts): [1] is fitted tightly
161
+ // for 1800–2050, [0] trades accuracy for 3000 BC–3000 AD coverage. The bounds
162
+ // are J (days from J2000): −73048.5 ≈ 1800, 18626.5 ≈ 2050. Inside the window
163
+ // use the precise set, outside fall back to the long-range one.
120
164
  const dataset = J > -73048.5 && J < 18626.5 ? oe[1] : oe[0];
121
165
  const [a, e, I, L, wBar, Omega] = dataset.map(([x0, x1]) => x0 + x1 * T);
122
166
  const omega = wBar - Omega; // argument of periapsis
123
167
  let M = L - wBar; // mean anomaly
124
- // Perturbation correction for outer planets
168
+ // Standish's great-inequality correction for the outer planets (Jupiter–Neptune):
169
+ // a secular b·T² plus a long-period cos/sin term at frequency f. Only bodies
170
+ // that carry a datasets[2] (see orbital.ts) get it. [biblio: standish-jpl]
125
171
  if (oe[2]) {
126
172
  const [b, c, s, f] = oe[2];
127
173
  M += b * T * T + c * cosDeg(f * T) + s * sinDeg(f * T);
@@ -1,6 +1,7 @@
1
1
  import { type Imprint } from "../imprint.js";
2
2
  import { type Prosody } from "./prosody.js";
3
3
  import { type Cadence } from "./cadence.js";
4
+ import { type Modulation } from "./modulation.js";
4
5
  import { type ChantTabulaRow } from "./tabula.js";
5
6
  import { type MidiOpts, type MidiEmitResult } from "./emitters/midi.js";
6
7
  import { type MusicXmlOpts, type MusicXmlEmitResult } from "./emitters/musicxml.js";
@@ -32,6 +33,8 @@ export interface Score {
32
33
  prosody: Prosody;
33
34
  /** Mode-specific cadence at each phrase-ending divisio. */
34
35
  cadences: Cadence[];
36
+ /** Passages where the tonal centre leans away from the home mode. */
37
+ modulations: Modulation[];
35
38
  imprint: Imprint;
36
39
  /**
37
40
  * Emit a Standard MIDI File from the score's tabula. Returns the file bytes
@@ -53,6 +56,7 @@ export interface Score {
53
56
  export declare function buildScore(chant: Chant, opts?: ScoreOpts): Score;
54
57
  export type { ParseError };
55
58
  export type { Cadence, CadenceTarget, CadenceApproach } from "./cadence.js";
59
+ export type { Modulation } from "./modulation.js";
56
60
  export type { MidiOpts, MidiEmitResult, MidiJsonResult, MidiJsonEvent } from "./emitters/midi.js";
57
61
  export type { MusicXmlOpts, MusicXmlEmitResult } from "./emitters/musicxml.js";
58
62
  //# sourceMappingURL=api.d.ts.map
@@ -8,6 +8,7 @@ import { computeMeta } from "./meta.js";
8
8
  import { computeImprint } from "../imprint.js";
9
9
  import { computeProsody } from "./prosody.js";
10
10
  import { detectCadences } from "./cadence.js";
11
+ import { detectModulations } from "./modulation.js";
11
12
  import { computeTabula } from "./tabula.js";
12
13
  import { MODES } from "../temper/modes.js";
13
14
  import { toMidi } from "./emitters/midi.js";
@@ -61,6 +62,8 @@ export function buildScore(chant, opts) {
61
62
  // Cadence detection runs here, where the resolved mode (and its cadence
62
63
  // figures) is in hand. Pure data — mirrors the arsis/thesis pass in ir.ts.
63
64
  const cadences = detectCadences(ir.phrases, meta.mode != null ? MODES.get(meta.mode) : undefined);
65
+ // Modulation: where the tonal centre leans away from the home mode.
66
+ const modulations = detectModulations(ir.phrases, meta.mode ?? undefined);
64
67
  const tabula = computeTabula(ir, {
65
68
  mode: meta.mode ?? undefined,
66
69
  a4Hz: opts?.temperamentum?.a4,
@@ -82,7 +85,14 @@ export function buildScore(chant, opts) {
82
85
  tabula,
83
86
  prosody: computeProsody(ir.phrases),
84
87
  cadences,
85
- imprint: computeImprint(ir.phrases, scale),
88
+ modulations,
89
+ imprint: computeImprint(ir.phrases, scale, {
90
+ // Each cadence's resolution note (its last) is the strongest modal anchor.
91
+ cadenceNotes: new Set(cadences.map((c) => {
92
+ const [pi, si, ni] = c.notes[c.notes.length - 1];
93
+ return `${pi}:${si}:${ni}`;
94
+ })),
95
+ }),
86
96
  midi(emitOpts) {
87
97
  return toMidi(tabula, emitOpts);
88
98
  },
@@ -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) {
@@ -0,0 +1,20 @@
1
+ import type { Phrase } from "./types.js";
2
+ export interface Modulation {
3
+ /** Phrase index where the modulation begins (inclusive). */
4
+ startPhrase: number;
5
+ /** Phrase index where it ends (inclusive). */
6
+ endPhrase: number;
7
+ /** The mode the passage leans toward (1–8). */
8
+ toMode: number;
9
+ /** 0–1: how strongly the foreign mode outscored the home mode, averaged. */
10
+ confidence: number;
11
+ }
12
+ /**
13
+ * Detect tonal-centre shifts. For each phrase, score it against every mode; a
14
+ * phrase whose top mode is not the home mode, and beats the home mode by MARGIN,
15
+ * "leans" toward that foreign mode. Consecutive phrases leaning to the same mode
16
+ * merge into one modulation span. `homeMode` is the chant's own mode (1–8); with
17
+ * no mode, nothing is detected.
18
+ */
19
+ export declare function detectModulations(phrases: Phrase[], homeMode: number | undefined): Modulation[];
20
+ //# sourceMappingURL=modulation.d.ts.map
@@ -0,0 +1,74 @@
1
+ import { computeModalAffinity } from "../temper/modality.js";
2
+ // How much a foreign mode must outscore the home mode (in normalised affinity)
3
+ // before a phrase counts as leaning away. Calibrated against Suñol's worked
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
+ const MARGIN = 0.25;
7
+ /** The pitch-class distribution of one phrase's notes (fractions summing to 1). */
8
+ function phrasePcDistribution(phrase) {
9
+ const counts = new Array(12).fill(0);
10
+ let total = 0;
11
+ for (const syl of phrase.syllables) {
12
+ for (const note of syl.notes) {
13
+ counts[note.pitch.pc]++;
14
+ total++;
15
+ }
16
+ }
17
+ const dist = {};
18
+ for (let pc = 0; pc < 12; pc++)
19
+ dist[pc] = total > 0 ? counts[pc] / total : 0;
20
+ return dist;
21
+ }
22
+ /**
23
+ * Detect tonal-centre shifts. For each phrase, score it against every mode; a
24
+ * phrase whose top mode is not the home mode, and beats the home mode by MARGIN,
25
+ * "leans" toward that foreign mode. Consecutive phrases leaning to the same mode
26
+ * merge into one modulation span. `homeMode` is the chant's own mode (1–8); with
27
+ * no mode, nothing is detected.
28
+ */
29
+ export function detectModulations(phrases, homeMode) {
30
+ if (homeMode == null)
31
+ return [];
32
+ // Per-phrase lean: the foreign mode a phrase favours, with its margin over
33
+ // the home mode — or null if the phrase stays home.
34
+ const leans = phrases.map((phrase) => {
35
+ if (phrase.syllables.every((s) => s.notes.length === 0))
36
+ return null;
37
+ const affinity = computeModalAffinity(phrasePcDistribution(phrase));
38
+ const top = affinity[0];
39
+ if (!top || top.mode === homeMode)
40
+ return null;
41
+ const home = affinity.find((a) => a.mode === homeMode);
42
+ const margin = top.score - (home?.score ?? 0);
43
+ return margin >= MARGIN ? { mode: top.mode, margin } : null;
44
+ });
45
+ // Merge consecutive phrases leaning to the same foreign mode into spans.
46
+ const modulations = [];
47
+ let run = null;
48
+ const flush = () => {
49
+ if (!run)
50
+ return;
51
+ const avg = run.margins.reduce((s, m) => s + m, 0) / run.margins.length;
52
+ modulations.push({
53
+ startPhrase: run.start,
54
+ endPhrase: run.start + run.margins.length - 1,
55
+ toMode: run.mode,
56
+ confidence: Math.min(1, Math.round(avg * 100) / 100),
57
+ });
58
+ run = null;
59
+ };
60
+ for (let i = 0; i < leans.length; i++) {
61
+ const lean = leans[i];
62
+ if (lean && run && lean.mode === run.mode) {
63
+ run.margins.push(lean.margin);
64
+ }
65
+ else {
66
+ flush();
67
+ if (lean)
68
+ run = { mode: lean.mode, start: i, margins: [lean.margin] };
69
+ }
70
+ }
71
+ flush();
72
+ return modulations;
73
+ }
74
+ //# sourceMappingURL=modulation.js.map
@@ -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;