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.
- package/BIBLIOGRAPHY.md +143 -96
- package/CHANGELOG.md +62 -0
- package/README.md +7 -9
- package/dist/data/am.d.ts +5 -0
- package/dist/data/am.js +14299 -0
- package/dist/data/office-monastic.d.ts +3 -0
- package/dist/data/office-monastic.js +6956 -0
- package/dist/data/office-psalms-monastic.d.ts +3 -0
- package/dist/data/office-psalms-monastic.js +21 -0
- package/dist/data/{office-psalms.d.ts → office-psalms-roman.d.ts} +1 -1
- package/dist/data/{office-psalms.js → office-psalms-roman.js} +3 -3
- package/dist/engines/cal/calendar.js +16 -1
- package/dist/engines/cal/date.js +5 -0
- package/dist/engines/chant/chant.js +2 -0
- package/dist/{data → engines/chant/data}/compline.d.ts +1 -1
- package/dist/{data → engines/chant/data}/masses.d.ts +1 -1
- package/dist/{data → engines/chant/data}/masses.js +1 -1
- package/dist/{data → engines/chant/data}/prime.d.ts +1 -1
- package/dist/{data → engines/chant/data}/prime.js +1 -1
- package/dist/engines/chant/hour.js +37 -22
- package/dist/engines/chant/intone.js +6 -1
- package/dist/engines/chant/ordinary.js +1 -1
- package/dist/engines/chant/psalm.d.ts +4 -4
- package/dist/engines/chant/psalm.js +6 -4
- package/dist/engines/chant/types.d.ts +5 -1
- package/dist/engines/harmonia/api.js +11 -0
- package/dist/engines/harmonia/data/doctrines.js +27 -1
- package/dist/engines/imprint.d.ts +8 -7
- package/dist/engines/imprint.js +31 -27
- package/dist/engines/planet/position.js +51 -5
- package/dist/engines/score/api.d.ts +4 -0
- package/dist/engines/score/api.js +11 -1
- package/dist/engines/score/articulation.js +60 -3
- package/dist/engines/score/cadence.js +3 -3
- package/dist/engines/score/emitters/midi.js +6 -2
- package/dist/engines/score/infer.js +13 -1
- package/dist/engines/score/ir.d.ts +2 -1
- package/dist/engines/score/ir.js +54 -12
- package/dist/engines/score/modulation.d.ts +20 -0
- package/dist/engines/score/modulation.js +74 -0
- package/dist/engines/score/parse.js +16 -8
- package/dist/engines/score/phrasing.js +26 -0
- package/dist/engines/score/prosody.js +5 -0
- package/dist/engines/score/tabula.d.ts +2 -0
- package/dist/engines/score/tabula.js +1 -0
- package/dist/engines/score/types.d.ts +11 -2
- package/dist/engines/temper/api.d.ts +17 -1
- package/dist/engines/temper/api.js +20 -2
- package/dist/engines/temper/data/guido.d.ts +3 -2
- package/dist/engines/temper/data/guido.js +30 -25
- package/dist/engines/temper/data/modes.js +3 -0
- package/dist/{data → engines/temper/data}/tones.js +1 -1
- package/dist/engines/temper/gabc.js +25 -2
- package/dist/engines/temper/guido.d.ts +3 -2
- package/dist/engines/temper/interval.js +12 -0
- package/dist/engines/temper/modality.d.ts +11 -0
- package/dist/engines/temper/modality.js +58 -0
- package/dist/engines/temper/scale.js +33 -2
- package/dist/engines/temper/step.d.ts +2 -2
- package/dist/index.d.ts +5 -5
- package/package.json +2 -1
- package/dist/data/office.d.ts +0 -12
- package/dist/data/office.js +0 -13052
- /package/dist/{data → engines/chant/data}/compline.js +0 -0
- /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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
|
2
|
-
//
|
|
3
|
-
//
|
|
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)
|
|
2
|
-
//
|
|
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
|
-
//
|
|
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
|
package/dist/engines/score/ir.js
CHANGED
|
@@ -24,13 +24,13 @@ function rawToNote(raw, scale) {
|
|
|
24
24
|
liquescent: raw.liquescent,
|
|
25
25
|
strophicus: raw.strophicus,
|
|
26
26
|
oriscus: raw.oriscus,
|
|
27
|
-
|
|
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
|
-
//
|
|
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
|
|
49
|
+
let hasDoubleMora = false;
|
|
50
50
|
for (const a of items) {
|
|
51
51
|
neumeTypes.add(a.neumeType);
|
|
52
|
-
if (a.note.context.
|
|
53
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
-
//
|
|
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
|
-
|
|
160
|
+
mora = 1;
|
|
152
161
|
if (modifiers.includes("..")) {
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
|
|
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
|
-
|
|
299
|
+
mora: note.mora,
|
|
292
300
|
};
|
|
293
301
|
});
|
|
294
302
|
return result;
|