calliope-ts 0.0.1
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/.claude/settings.local.json +8 -0
- package/.opencode/skills/calliope-ts/SKILL.md +74 -0
- package/LICENSE +201 -0
- package/README.md +485 -0
- package/dist/depfix.d.ts +13 -0
- package/dist/depfix.d.ts.map +1 -0
- package/dist/depfix.js +84 -0
- package/dist/display.d.ts +38 -0
- package/dist/display.d.ts.map +1 -0
- package/dist/display.js +890 -0
- package/dist/index.d.ts +50 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +504 -0
- package/dist/parser.d.ts +10 -0
- package/dist/parser.d.ts.map +1 -0
- package/dist/parser.js +688 -0
- package/dist/phonological.d.ts +41 -0
- package/dist/phonological.d.ts.map +1 -0
- package/dist/phonological.js +788 -0
- package/dist/rhyme.d.ts +26 -0
- package/dist/rhyme.d.ts.map +1 -0
- package/dist/rhyme.js +340 -0
- package/dist/scandroid.d.ts +17 -0
- package/dist/scandroid.d.ts.map +1 -0
- package/dist/scandroid.js +435 -0
- package/dist/scansion.d.ts +37 -0
- package/dist/scansion.d.ts.map +1 -0
- package/dist/scansion.js +1007 -0
- package/dist/scansion_debug.js +586 -0
- package/dist/stress.d.ts +32 -0
- package/dist/stress.d.ts.map +1 -0
- package/dist/stress.js +1372 -0
- package/dist/tagfix.d.ts +6 -0
- package/dist/tagfix.d.ts.map +1 -0
- package/dist/tagfix.js +101 -0
- package/dist/types.d.ts +173 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +4 -0
- package/package.json +62 -0
- package/src/depfix.ts +88 -0
- package/src/display.ts +954 -0
- package/src/index.ts +541 -0
- package/src/parser.ts +837 -0
- package/src/phonological.ts +849 -0
- package/src/rhyme.ts +328 -0
- package/src/scandroid.ts +434 -0
- package/src/scansion.ts +1053 -0
- package/src/stress.ts +1381 -0
- package/src/tagfix.ts +104 -0
- package/src/types.ts +230 -0
|
@@ -0,0 +1,586 @@
|
|
|
1
|
+
// scansion.ts — Key‑stress extraction, weighted metre scoring, and
|
|
2
|
+
// scansion assembly for McAleese’s phonological method.
|
|
3
|
+
import { isPunctuation } from './parser.js';
|
|
4
|
+
// ─── CONSTANTS: metre definitions & weights ──────────────────────
|
|
5
|
+
const METRES = {
|
|
6
|
+
iambic: { foot: 'ws', sylCount: 2 },
|
|
7
|
+
trochaic: { foot: 'sw', sylCount: 2 },
|
|
8
|
+
spondaic: { foot: 'ss', sylCount: 2 },
|
|
9
|
+
pyrrhic: { foot: 'ww', sylCount: 2 },
|
|
10
|
+
anapestic: { foot: 'wws', sylCount: 3 },
|
|
11
|
+
dactylic: { foot: 'sww', sylCount: 3 },
|
|
12
|
+
amphibrachic: { foot: 'wsw', sylCount: 3 },
|
|
13
|
+
bacchic: { foot: 'wss', sylCount: 3 },
|
|
14
|
+
};
|
|
15
|
+
const WEIGHT = {
|
|
16
|
+
IU: 2,
|
|
17
|
+
PP: 1,
|
|
18
|
+
PW3plus: 2,
|
|
19
|
+
PW2: 1,
|
|
20
|
+
CP: 1,
|
|
21
|
+
};
|
|
22
|
+
// ─── HELPER: stress compatibility ─────────────────────────────────
|
|
23
|
+
function stressCompatible(actual, expected) {
|
|
24
|
+
if (expected === 's')
|
|
25
|
+
return actual === 's' || actual === 'm';
|
|
26
|
+
// McAleese: 'w' matches anything except 's'.
|
|
27
|
+
return actual !== 's';
|
|
28
|
+
}
|
|
29
|
+
// ─── PATTERN MATCHING ─────────────────────────────────────────────
|
|
30
|
+
function patternMatches(pattern, foot, isForward = false) {
|
|
31
|
+
const plen = pattern.length;
|
|
32
|
+
const flen = foot.length;
|
|
33
|
+
// Single CP patterns: must match the STRONG position of the foot.
|
|
34
|
+
if (plen === 1) {
|
|
35
|
+
const single = pattern[0];
|
|
36
|
+
const strongPos = foot.indexOf('s');
|
|
37
|
+
if (strongPos !== -1) {
|
|
38
|
+
return stressCompatible(single, 's');
|
|
39
|
+
}
|
|
40
|
+
return foot.split('').some(expected => stressCompatible(single, expected));
|
|
41
|
+
}
|
|
42
|
+
// For shorter patterns, match against a contiguous substring of the foot
|
|
43
|
+
let tail;
|
|
44
|
+
if (plen < flen) {
|
|
45
|
+
// Only allow partial matching if pattern contains a strong syllable;
|
|
46
|
+
// otherwise a weak-only pattern gives no diagnostic rhythmic information.
|
|
47
|
+
if (!pattern.split('').some(c => c === 's' || c === 'm')) {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
tail = isForward ? foot.slice(0, plen) : foot.slice(-plen);
|
|
51
|
+
for (let i = 0; i < plen; i++) {
|
|
52
|
+
const actual = pattern[i];
|
|
53
|
+
const expected = tail[i];
|
|
54
|
+
if (!stressCompatible(actual, expected))
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
if (plen > flen) {
|
|
60
|
+
tail = isForward ? pattern.slice(0, flen) : pattern.slice(-flen);
|
|
61
|
+
}
|
|
62
|
+
else {
|
|
63
|
+
tail = pattern;
|
|
64
|
+
}
|
|
65
|
+
for (let i = 0; i < flen; i++) {
|
|
66
|
+
const actual = tail[i];
|
|
67
|
+
const expected = foot[i];
|
|
68
|
+
if (!stressCompatible(actual, expected))
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
// Reject strong dipping for triple metres (dactylic, anapestic)
|
|
72
|
+
if (flen === 3 && plen >= flen) {
|
|
73
|
+
const first = tail[0];
|
|
74
|
+
const middle = tail[1];
|
|
75
|
+
const final = tail[2];
|
|
76
|
+
if (foot === 'sww') {
|
|
77
|
+
if ((first === 's' || first === 'm') && (middle !== 's' && middle !== 'm') && (final === 'm' || final === 's')) {
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
if (foot === 'wws') {
|
|
82
|
+
if ((first === 'm' || first === 's') && (middle !== 's' && middle !== 'm') && (final === 's' || final === 'm')) {
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return true;
|
|
88
|
+
}
|
|
89
|
+
function flattenSyllables(words, ius) {
|
|
90
|
+
const ppStartWords = new Set();
|
|
91
|
+
if (ius) {
|
|
92
|
+
for (const iu of ius) {
|
|
93
|
+
for (const pp of iu.phonologicalPhrases) {
|
|
94
|
+
if (pp.cliticGroups.length > 0 && pp.cliticGroups[0].tokens.length > 0) {
|
|
95
|
+
ppStartWords.add(pp.cliticGroups[0].tokens[0]);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
const result = [];
|
|
101
|
+
let idx = 0;
|
|
102
|
+
let wordCounter = 0;
|
|
103
|
+
for (const w of words) {
|
|
104
|
+
if (isPunctuation(w.lexicalClass))
|
|
105
|
+
continue;
|
|
106
|
+
const isPoly = w.syllables.length > 1;
|
|
107
|
+
const isLeftEdgeWord = ppStartWords.has(w);
|
|
108
|
+
for (let si = 0; si < w.syllables.length; si++) {
|
|
109
|
+
const s = w.syllables[si];
|
|
110
|
+
result.push({
|
|
111
|
+
word: w,
|
|
112
|
+
stress: s.relativeStress ?? 'w',
|
|
113
|
+
globalIndex: idx++,
|
|
114
|
+
wordIdx: wordCounter,
|
|
115
|
+
isPoly,
|
|
116
|
+
weight: s.weight || 'L',
|
|
117
|
+
isLeftEdgePP: isLeftEdgeWord && (si === 0),
|
|
118
|
+
extrametrical: s.extrametrical,
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
wordCounter++;
|
|
122
|
+
}
|
|
123
|
+
return result;
|
|
124
|
+
}
|
|
125
|
+
// ─── RIGHT‑MOST STRESSED IN TOKEN GROUP ────────────────────────────
|
|
126
|
+
function rightmostStressed(tokens, flat, strongOnly = false) {
|
|
127
|
+
for (let i = flat.length - 1; i >= 0; i--) {
|
|
128
|
+
const fs = flat[i];
|
|
129
|
+
if (tokens.includes(fs.word)) {
|
|
130
|
+
if (strongOnly) {
|
|
131
|
+
if (fs.stress === 's' || fs.stress === 'm')
|
|
132
|
+
return fs;
|
|
133
|
+
}
|
|
134
|
+
else {
|
|
135
|
+
if (fs.stress !== 'w')
|
|
136
|
+
return fs;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return undefined;
|
|
141
|
+
}
|
|
142
|
+
function extractContour(syls) {
|
|
143
|
+
let headWordIdx = -1;
|
|
144
|
+
// Find the last content word
|
|
145
|
+
for (let i = syls.length - 1; i >= 0; i--) {
|
|
146
|
+
if (syls[i].word.isContent) {
|
|
147
|
+
headWordIdx = i;
|
|
148
|
+
break;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
if (headWordIdx === -1)
|
|
152
|
+
return syls; // No content word
|
|
153
|
+
// Find the start of this word
|
|
154
|
+
const targetWord = syls[headWordIdx].word;
|
|
155
|
+
let startIdx = headWordIdx;
|
|
156
|
+
while (startIdx > 0 && syls[startIdx - 1].word === targetWord) {
|
|
157
|
+
startIdx--;
|
|
158
|
+
}
|
|
159
|
+
// Return this word and all following syllables
|
|
160
|
+
return syls.slice(startIdx);
|
|
161
|
+
}
|
|
162
|
+
// ─── EXTRACT KEY STRESSES WITH POSITIONS ──────────────────────────
|
|
163
|
+
export function extractKeyStresses(ius, words) {
|
|
164
|
+
const result = [];
|
|
165
|
+
const flat = flattenSyllables(words);
|
|
166
|
+
// ── Polysyllabic words (PW) ──────────────────────────────
|
|
167
|
+
for (const w of words) {
|
|
168
|
+
if (isPunctuation(w.lexicalClass))
|
|
169
|
+
continue;
|
|
170
|
+
const sc = w.syllables.length;
|
|
171
|
+
if (sc < 2)
|
|
172
|
+
continue;
|
|
173
|
+
const pattern = w.syllables.map(s => s.relativeStress ?? 'w').join('');
|
|
174
|
+
const weight = sc >= 3 ? WEIGHT.PW3plus : WEIGHT.PW2;
|
|
175
|
+
const firstSylIdx = flat.findIndex(fs => fs.word === w);
|
|
176
|
+
const positions = [];
|
|
177
|
+
for (let j = 0; j < sc; j++) {
|
|
178
|
+
positions.push(firstSylIdx + j);
|
|
179
|
+
}
|
|
180
|
+
result.push({ unitType: 'PW', pattern, weight, positions });
|
|
181
|
+
}
|
|
182
|
+
// ── Clitic Groups, Phonological Phrases, Intonational Units ─
|
|
183
|
+
for (const iu of ius) {
|
|
184
|
+
const iuTokens = collectIUTokens(iu);
|
|
185
|
+
if (iuTokens.length === 0)
|
|
186
|
+
continue;
|
|
187
|
+
const unitSyllables = flat.filter(fs => iuTokens.includes(fs.word));
|
|
188
|
+
if (unitSyllables.length > 0) {
|
|
189
|
+
const tail = extractContour(unitSyllables);
|
|
190
|
+
const positions = tail.map(fs => fs.globalIndex);
|
|
191
|
+
const pattern = tail.map(fs => fs.stress).join('');
|
|
192
|
+
result.push({ unitType: 'IU', pattern, weight: WEIGHT.IU, positions });
|
|
193
|
+
}
|
|
194
|
+
for (const pp of iu.phonologicalPhrases) {
|
|
195
|
+
const ppTokens = collectPPTokens(pp);
|
|
196
|
+
if (ppTokens.length === 0)
|
|
197
|
+
continue;
|
|
198
|
+
const ppSyllables = flat.filter(fs => ppTokens.includes(fs.word));
|
|
199
|
+
if (ppSyllables.length > 0) {
|
|
200
|
+
const tail = extractContour(ppSyllables);
|
|
201
|
+
const positions = tail.map(fs => fs.globalIndex);
|
|
202
|
+
const pattern = tail.map(fs => fs.stress).join('');
|
|
203
|
+
result.push({ unitType: 'PP', pattern, weight: WEIGHT.PP, positions });
|
|
204
|
+
}
|
|
205
|
+
for (const cg of pp.cliticGroups) {
|
|
206
|
+
if (cg.tokens.length === 0)
|
|
207
|
+
continue;
|
|
208
|
+
const cpStressed = rightmostStressed(cg.tokens, flat, false);
|
|
209
|
+
if (cpStressed) {
|
|
210
|
+
result.push({
|
|
211
|
+
unitType: 'CP',
|
|
212
|
+
pattern: cpStressed.stress,
|
|
213
|
+
weight: WEIGHT.CP,
|
|
214
|
+
positions: [cpStressed.globalIndex],
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return result;
|
|
221
|
+
}
|
|
222
|
+
function collectIUTokens(iu) {
|
|
223
|
+
const tokens = [];
|
|
224
|
+
for (const pp of iu.phonologicalPhrases) {
|
|
225
|
+
tokens.push(...collectPPTokens(pp));
|
|
226
|
+
}
|
|
227
|
+
return tokens;
|
|
228
|
+
}
|
|
229
|
+
function collectPPTokens(pp) {
|
|
230
|
+
const tokens = [];
|
|
231
|
+
for (const cg of pp.cliticGroups) {
|
|
232
|
+
tokens.push(...cg.tokens);
|
|
233
|
+
}
|
|
234
|
+
return tokens;
|
|
235
|
+
}
|
|
236
|
+
// ─── POLYSYLLABIC RHYTHM BIAS ─────────────────────────────────────
|
|
237
|
+
function polysyllabicRhythmBias(words) {
|
|
238
|
+
const bias = { iambic: 0, trochaic: 0, anapestic: 0, dactylic: 0 };
|
|
239
|
+
for (const w of words) {
|
|
240
|
+
if (isPunctuation(w.lexicalClass))
|
|
241
|
+
continue;
|
|
242
|
+
if (w.syllables.length < 2 || !w.isContent)
|
|
243
|
+
continue;
|
|
244
|
+
const pat = w.syllables.map(s => s.relativeStress ?? 'w');
|
|
245
|
+
// end‑stressed → iambic / anapestic bias
|
|
246
|
+
if (isStrong(pat[pat.length - 1])) {
|
|
247
|
+
bias.iambic += 1;
|
|
248
|
+
bias.anapestic += 1;
|
|
249
|
+
}
|
|
250
|
+
// begin‑stressed → trochaic / dactylic bias
|
|
251
|
+
if (isStrong(pat[0])) {
|
|
252
|
+
bias.trochaic += 1;
|
|
253
|
+
bias.dactylic += 1;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
return bias;
|
|
257
|
+
}
|
|
258
|
+
// ─── SYLLABIC PATTERN PRE-CHECK FOR TRIPLE METERS ──────────────────
|
|
259
|
+
function isStrong(stress) {
|
|
260
|
+
return stress === 's' || stress === 'm';
|
|
261
|
+
}
|
|
262
|
+
function isWeak(stress) {
|
|
263
|
+
return stress === 'w' || stress === 'n';
|
|
264
|
+
}
|
|
265
|
+
// ─── METRE SCORING ────────────────────────────────────────────────
|
|
266
|
+
function lineLengthName(feet) {
|
|
267
|
+
const names = [
|
|
268
|
+
'', 'monometer', 'dimeter', 'trimeter', 'tetrameter',
|
|
269
|
+
'pentameter', 'hexameter', 'heptameter', 'octameter'
|
|
270
|
+
];
|
|
271
|
+
return names[feet] || `${feet}-feet`;
|
|
272
|
+
}
|
|
273
|
+
function generateSummary(matchCounts) {
|
|
274
|
+
return Object.entries(matchCounts)
|
|
275
|
+
.map(([unit, count]) => `${unit}=${count}`)
|
|
276
|
+
.join(' ') || 'no matches';
|
|
277
|
+
}
|
|
278
|
+
function isStrongStress(stress) {
|
|
279
|
+
return stress === 's' || stress === 'm';
|
|
280
|
+
}
|
|
281
|
+
function countSyllables(str) {
|
|
282
|
+
return str.replace(/[^wmsngWsS]/g, '').length;
|
|
283
|
+
}
|
|
284
|
+
function getTemplatesForMeter(meter) {
|
|
285
|
+
let tmpls = [];
|
|
286
|
+
if (meter === 'iambic') {
|
|
287
|
+
tmpls = [
|
|
288
|
+
{ pattern: ['W', 'S'], baseScore: 0 },
|
|
289
|
+
{ pattern: ['W', 'W', 'S'], baseScore: -1 },
|
|
290
|
+
{ pattern: ['S', 'W'], baseScore: -3, allowedAtStart: true },
|
|
291
|
+
{ pattern: ['S'], baseScore: 0, allowedAtEnd: true },
|
|
292
|
+
{ pattern: ['W', 'S', 'W'], baseScore: -1, allowedAtEnd: true },
|
|
293
|
+
{ pattern: ['W', 'W', 'S', 'W'], baseScore: -1, allowedAtEnd: true },
|
|
294
|
+
];
|
|
295
|
+
}
|
|
296
|
+
else if (meter === 'trochaic') {
|
|
297
|
+
tmpls = [
|
|
298
|
+
{ pattern: ['S', 'W'], baseScore: 0 },
|
|
299
|
+
{ pattern: ['S', 'W', 'W'], baseScore: -1 },
|
|
300
|
+
{ pattern: ['S'], baseScore: 0, allowedAtEnd: true },
|
|
301
|
+
{ pattern: ['W', 'S'], baseScore: -3 },
|
|
302
|
+
{ pattern: ['S', 'W', 'S'], baseScore: -1, allowedAtEnd: true },
|
|
303
|
+
];
|
|
304
|
+
}
|
|
305
|
+
else if (meter === 'anapestic') {
|
|
306
|
+
tmpls = [
|
|
307
|
+
{ pattern: ['W', 'W', 'S'], baseScore: 0 },
|
|
308
|
+
{ pattern: ['W', 'S'], baseScore: -1 },
|
|
309
|
+
{ pattern: ['S'], baseScore: 0, allowedAtEnd: true },
|
|
310
|
+
{ pattern: ['W', 'W', 'S', 'W'], baseScore: -1, allowedAtEnd: true },
|
|
311
|
+
{ pattern: ['W', 'S', 'W'], baseScore: -1, allowedAtEnd: true },
|
|
312
|
+
];
|
|
313
|
+
}
|
|
314
|
+
else if (meter === 'dactylic') {
|
|
315
|
+
tmpls = [
|
|
316
|
+
{ pattern: ['S', 'W', 'W'], baseScore: 0 },
|
|
317
|
+
{ pattern: ['S', 'W'], baseScore: 0 },
|
|
318
|
+
{ pattern: ['S'], baseScore: 0, allowedAtEnd: true },
|
|
319
|
+
{ pattern: ['S', 'W', 'S'], baseScore: -2, allowedAtEnd: true },
|
|
320
|
+
];
|
|
321
|
+
}
|
|
322
|
+
else if (meter === 'amphibrachic') {
|
|
323
|
+
tmpls = [
|
|
324
|
+
{ pattern: ['W', 'S', 'W'], baseScore: 0 },
|
|
325
|
+
{ pattern: ['W', 'S'], baseScore: -1 },
|
|
326
|
+
{ pattern: ['S', 'W'], baseScore: -2 },
|
|
327
|
+
{ pattern: ['S'], baseScore: 0, allowedAtEnd: true },
|
|
328
|
+
{ pattern: ['W', 'S', 'W', 'S'], baseScore: -1 },
|
|
329
|
+
];
|
|
330
|
+
}
|
|
331
|
+
else if (meter !== 'free verse') {
|
|
332
|
+
const footStr = METRES[meter]?.foot || 'ws';
|
|
333
|
+
const basePattern = footStr.split('').map(c => c === 's' ? 'S' : 'W');
|
|
334
|
+
tmpls = [
|
|
335
|
+
{ pattern: basePattern, baseScore: 0 },
|
|
336
|
+
{ pattern: ['S'], baseScore: -1, allowedAtEnd: true },
|
|
337
|
+
];
|
|
338
|
+
}
|
|
339
|
+
tmpls.push({ pattern: ['W'], baseScore: -10 });
|
|
340
|
+
tmpls.push({ pattern: ['S'], baseScore: -10 });
|
|
341
|
+
return tmpls;
|
|
342
|
+
}
|
|
343
|
+
function scoreSyllable(syl, expected) {
|
|
344
|
+
const actual = syl.stress;
|
|
345
|
+
let score = 0;
|
|
346
|
+
if (expected === 'S') {
|
|
347
|
+
if (actual === 's')
|
|
348
|
+
score = 4;
|
|
349
|
+
else if (actual === 'm')
|
|
350
|
+
score = 3;
|
|
351
|
+
else if (actual === 'n')
|
|
352
|
+
score = 1;
|
|
353
|
+
else
|
|
354
|
+
score = -2;
|
|
355
|
+
}
|
|
356
|
+
else {
|
|
357
|
+
if (actual === 'w')
|
|
358
|
+
score = 2;
|
|
359
|
+
else if (actual === 'n')
|
|
360
|
+
score = 1;
|
|
361
|
+
else if (actual === 'm')
|
|
362
|
+
score = -1;
|
|
363
|
+
else
|
|
364
|
+
score = -3;
|
|
365
|
+
// Fabb's constraint: heavy syllable in polysyllabic word must not
|
|
366
|
+
// occupy a weak metrical position, unless at the left edge of a PP.
|
|
367
|
+
if (syl.isPoly && syl.weight === 'H' && !syl.isLeftEdgePP) {
|
|
368
|
+
// Extrametrical syllables are already weight-invisible; reduce penalty.
|
|
369
|
+
if (syl.extrametrical) {
|
|
370
|
+
score -= 2;
|
|
371
|
+
}
|
|
372
|
+
else {
|
|
373
|
+
score -= 5;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
return score;
|
|
378
|
+
}
|
|
379
|
+
function assembleScansion(words, meter, ius) {
|
|
380
|
+
const syls = flattenSyllables(words, ius);
|
|
381
|
+
if (meter === 'free verse') {
|
|
382
|
+
let out = '';
|
|
383
|
+
for (let i = 0; i < syls.length; i++) {
|
|
384
|
+
out += syls[i].stress;
|
|
385
|
+
}
|
|
386
|
+
return { scansion: out, footCount: 0, rawScore: 0 };
|
|
387
|
+
}
|
|
388
|
+
const cpMembership = new Map();
|
|
389
|
+
if (ius) {
|
|
390
|
+
let cpId = 0;
|
|
391
|
+
const wordToCp = new Map();
|
|
392
|
+
for (const iu of ius) {
|
|
393
|
+
for (const pp of iu.phonologicalPhrases) {
|
|
394
|
+
for (const cg of pp.cliticGroups) {
|
|
395
|
+
for (const word of cg.tokens) {
|
|
396
|
+
wordToCp.set(word, cpId);
|
|
397
|
+
}
|
|
398
|
+
cpId++;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
for (let i = 0; i < syls.length; i++) {
|
|
403
|
+
const cp = wordToCp.get(syls[i].word);
|
|
404
|
+
if (cp !== undefined) {
|
|
405
|
+
cpMembership.set(i, cp);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
const templates = getTemplatesForMeter(meter);
|
|
410
|
+
const N = syls.length;
|
|
411
|
+
const memo = {};
|
|
412
|
+
function solve(index) {
|
|
413
|
+
if (index === N) {
|
|
414
|
+
return { score: 0, feet: [] };
|
|
415
|
+
}
|
|
416
|
+
if (memo[index])
|
|
417
|
+
return memo[index];
|
|
418
|
+
let bestScore = -Infinity;
|
|
419
|
+
let bestFeet = [];
|
|
420
|
+
const isStart = index === 0;
|
|
421
|
+
for (const t of templates) {
|
|
422
|
+
const L = t.pattern.length;
|
|
423
|
+
if (index + L > N)
|
|
424
|
+
continue;
|
|
425
|
+
const isEnd = (index + L === N);
|
|
426
|
+
if (t.allowedAtStart && !isStart)
|
|
427
|
+
continue;
|
|
428
|
+
if (t.allowedAtEnd && !isEnd)
|
|
429
|
+
continue;
|
|
430
|
+
let matchScore = t.baseScore;
|
|
431
|
+
let footStr = '';
|
|
432
|
+
for (let i = 0; i < L; i++) {
|
|
433
|
+
matchScore += scoreSyllable(syls[index + i], t.pattern[i]);
|
|
434
|
+
footStr += syls[index + i].stress;
|
|
435
|
+
}
|
|
436
|
+
// Word-boundary constraint: penalize splitting polysyllabic words across feet
|
|
437
|
+
// (Kiparsky 1975: stress patterns within a single word are regulated with special strictness)
|
|
438
|
+
if (index + L < N) {
|
|
439
|
+
const lastSylInFoot = syls[index + L - 1];
|
|
440
|
+
const firstSylAfterFoot = syls[index + L];
|
|
441
|
+
if (lastSylInFoot.wordIdx === firstSylAfterFoot.wordIdx && lastSylInFoot.isPoly) {
|
|
442
|
+
matchScore -= 8; // heavy penalty for splitting a polysyllabic word
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
const sub = solve(index + L);
|
|
446
|
+
if (sub.score !== -Infinity) {
|
|
447
|
+
const totalScore = matchScore + sub.score;
|
|
448
|
+
if (totalScore > bestScore) {
|
|
449
|
+
bestScore = totalScore;
|
|
450
|
+
bestFeet = [footStr, ...sub.feet];
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
memo[index] = { score: bestScore, feet: bestFeet };
|
|
455
|
+
return memo[index];
|
|
456
|
+
}
|
|
457
|
+
const result = solve(0);
|
|
458
|
+
let currentSylIdx = 0;
|
|
459
|
+
const finalFeet = [];
|
|
460
|
+
for (const footStr of result.feet) {
|
|
461
|
+
let newFootStr = '';
|
|
462
|
+
for (let i = 0; i < footStr.length; i++) {
|
|
463
|
+
const syl = syls[currentSylIdx];
|
|
464
|
+
if (currentSylIdx > 0 && isStrongStress(syl.stress)) {
|
|
465
|
+
const prevSyl = syls[currentSylIdx - 1];
|
|
466
|
+
if (isStrongStress(prevSyl.stress)) {
|
|
467
|
+
const sameCP = cpMembership.get(currentSylIdx - 1) === cpMembership.get(currentSylIdx);
|
|
468
|
+
const sameWord = prevSyl.wordIdx === syl.wordIdx;
|
|
469
|
+
if (sameCP || sameWord) {
|
|
470
|
+
newFootStr += '-';
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
newFootStr += syl.stress;
|
|
475
|
+
currentSylIdx++;
|
|
476
|
+
}
|
|
477
|
+
finalFeet.push(newFootStr);
|
|
478
|
+
}
|
|
479
|
+
return { scansion: finalFeet.join('|'), footCount: finalFeet.length, rawScore: result.score };
|
|
480
|
+
}
|
|
481
|
+
export function scoreMeters(keyStresses, words, ius) {
|
|
482
|
+
const totalSyls = words.reduce((sum, w) => sum + (isPunctuation(w.lexicalClass) ? 0 : w.syllables.length), 0);
|
|
483
|
+
if (totalSyls === 0) {
|
|
484
|
+
return {
|
|
485
|
+
all: '', keyStresses: '', meter: 'free verse',
|
|
486
|
+
meterName: 'free verse', footCount: 0,
|
|
487
|
+
summary: 'no syllables', scansion: '',
|
|
488
|
+
certainty: 0, weightScore: 0, maxPossibleWeight: 0,
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
const candidateMeters = ['iambic', 'trochaic', 'anapestic', 'dactylic', 'amphibrachic'];
|
|
492
|
+
// McAleese's weighted scoring using key stresses
|
|
493
|
+
// For each candidate meter, check how many key stress patterns match the meter's foot shape
|
|
494
|
+
// Weight by unit type: IU=3, PP=2, PW3+=2, CP=1, PW2=1
|
|
495
|
+
const KEY_WEIGHT = { IU: 3, PP: 2, PW3plus: 2, CP: 1, PW2: 1 };
|
|
496
|
+
// Build linear stress pattern for triple meter detection
|
|
497
|
+
let linearPattern = '';
|
|
498
|
+
for (const w of words) {
|
|
499
|
+
if (isPunctuation(w.lexicalClass))
|
|
500
|
+
continue;
|
|
501
|
+
for (const s of w.syllables) {
|
|
502
|
+
linearPattern += s.relativeStress ?? 'w';
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
function keyStressScore(meter) { console.log("Scoring meter:", meter);
|
|
506
|
+
const foot = METRES[meter].foot;
|
|
507
|
+
let score = 0;
|
|
508
|
+
let maxPossible = 0;
|
|
509
|
+
// For triple meters, also check linear pattern
|
|
510
|
+
if (foot.length === 3) {
|
|
511
|
+
let linearMatches = 0;
|
|
512
|
+
let totalPositions = 0;
|
|
513
|
+
for (let i = 0; i <= linearPattern.length - foot.length; i++) {
|
|
514
|
+
const substring = linearPattern.substring(i, i + foot.length);
|
|
515
|
+
if (patternMatches(substring, foot, false)) {
|
|
516
|
+
linearMatches++;
|
|
517
|
+
}
|
|
518
|
+
totalPositions++;
|
|
519
|
+
}
|
|
520
|
+
// Linear pattern matching contributes 50% of score for triple meters
|
|
521
|
+
const linearScore = totalPositions > 0 ? (linearMatches / totalPositions) * 50 : 0;
|
|
522
|
+
score += linearScore;
|
|
523
|
+
maxPossible += 50;
|
|
524
|
+
}
|
|
525
|
+
for (const ks of keyStresses) {
|
|
526
|
+
const weight = ks.unitType === 'PW'
|
|
527
|
+
? (ks.pattern.length >= 3 ? KEY_WEIGHT.PW3plus : KEY_WEIGHT.PW2)
|
|
528
|
+
: KEY_WEIGHT[ks.unitType] || 1;
|
|
529
|
+
maxPossible += weight;
|
|
530
|
+
if (patternMatches(ks.pattern, foot, false)) {
|
|
531
|
+
score += weight;
|
|
532
|
+
}
|
|
533
|
+
else if (patternMatches(ks.pattern, foot, true)) {
|
|
534
|
+
score += weight * 0.7; // forward scan gets 70% credit
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
const result = maxPossible > 0 ? (score / maxPossible) * 100 : 0; console.log(" score:", score, "maxPossible:", maxPossible, "result:", result); return result;
|
|
538
|
+
}
|
|
539
|
+
// Score each meter using key stresses
|
|
540
|
+
let bestMeter = 'free verse';
|
|
541
|
+
let bestKeyScore = 0;
|
|
542
|
+
for (const name of candidateMeters) {
|
|
543
|
+
const keyScore = keyStressScore(name);
|
|
544
|
+
if (keyScore > bestKeyScore) {
|
|
545
|
+
bestKeyScore = keyScore;
|
|
546
|
+
bestMeter = name;
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
// If no meter reaches threshold, use DP aligner as fallback
|
|
550
|
+
if (bestKeyScore < 40) {
|
|
551
|
+
let bestDPScore = -Infinity;
|
|
552
|
+
for (const name of candidateMeters) {
|
|
553
|
+
const assembly = assembleScansion(words, name, ius);
|
|
554
|
+
if (assembly.rawScore > bestDPScore) {
|
|
555
|
+
bestDPScore = assembly.rawScore;
|
|
556
|
+
bestMeter = name;
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
if (bestDPScore < 0) {
|
|
560
|
+
bestMeter = 'free verse';
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
// Assemble scansion using the detected meter
|
|
564
|
+
const assembly = assembleScansion(words, bestMeter, ius);
|
|
565
|
+
const bestScansion = assembly.scansion;
|
|
566
|
+
const bestFootCount = assembly.footCount;
|
|
567
|
+
const maxPossible = (bestFootCount * 4) + ((totalSyls - bestFootCount) * 2);
|
|
568
|
+
const certainty = maxPossible > 0 ? Math.max(0, Math.min(100, Math.round((assembly.rawScore / maxPossible) * 100))) : 0;
|
|
569
|
+
const footType = METRES[bestMeter];
|
|
570
|
+
const metreName = bestMeter === 'free verse'
|
|
571
|
+
? 'free verse'
|
|
572
|
+
: `${bestMeter} ${lineLengthName(bestFootCount)}`;
|
|
573
|
+
const summary = `IU=1 PP=${ius?.reduce((s, iu) => s + iu.phonologicalPhrases.length, 0) ?? 0} feet=${bestFootCount} ${footType?.sylCount ?? 0}syl`;
|
|
574
|
+
return {
|
|
575
|
+
all: '',
|
|
576
|
+
keyStresses: '',
|
|
577
|
+
meter: metreName,
|
|
578
|
+
meterName: bestMeter,
|
|
579
|
+
footCount: bestFootCount,
|
|
580
|
+
summary,
|
|
581
|
+
scansion: bestScansion,
|
|
582
|
+
certainty,
|
|
583
|
+
weightScore: assembly.rawScore,
|
|
584
|
+
maxPossibleWeight: maxPossible,
|
|
585
|
+
};
|
|
586
|
+
}
|
package/dist/stress.d.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { ClsWord, IntonationalUnit } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Assign per‑syllable lexical stress to each word in a sentence.
|
|
4
|
+
*
|
|
5
|
+
* Uses the first CMU pronunciation. Function words have their
|
|
6
|
+
* primary stress downgraded to secondary (2 → 1).
|
|
7
|
+
*/
|
|
8
|
+
export declare function assignLexicalStress(words: ClsWord[]): void;
|
|
9
|
+
/**
|
|
10
|
+
* Adjust stresses for nominal compounds.
|
|
11
|
+
*
|
|
12
|
+
* Right‑stressed by default: the second content word keeps primary (2),
|
|
13
|
+
* the first is reduced to secondary (1).
|
|
14
|
+
* Known left‑stressed compounds (material, time, measure, location, self)
|
|
15
|
+
* reverse the pattern.
|
|
16
|
+
*/
|
|
17
|
+
export declare function applyCompoundStress(ius: IntonationalUnit[]): void;
|
|
18
|
+
/**
|
|
19
|
+
* Recursively assign higher stress to content words from right to left.
|
|
20
|
+
* Only the rightmost content word receives a boost (+1 above lexical primary).
|
|
21
|
+
* All other content words keep their lexical stress.
|
|
22
|
+
* This preserves lexical stress for meter detection while still indicating
|
|
23
|
+
* the nuclear accent for phonological phrasing.
|
|
24
|
+
*/
|
|
25
|
+
export declare function applyNuclearStress(ius: IntonationalUnit[]): void;
|
|
26
|
+
/**
|
|
27
|
+
* Convert numeric per‑syllable stress to McAleese’s symbolic levels
|
|
28
|
+
* (w, n, m, s) and resolve adjacent identical stresses using dependency
|
|
29
|
+
* information.
|
|
30
|
+
*/
|
|
31
|
+
export declare function assignRelativeStresses(words: ClsWord[], ius: IntonationalUnit[]): void;
|
|
32
|
+
//# sourceMappingURL=stress.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"stress.d.ts","sourceRoot":"","sources":["../src/stress.ts"],"names":[],"mappings":"AAKA,OAAO,EAAE,OAAO,EAAyB,gBAAgB,EAAsB,MAAM,YAAY,CAAC;AAujBlG;;;;;GAKG;AACH,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,OAAO,EAAE,GAAG,IAAI,CA6Q1D;AAID;;;;;;;GAOG;AACH,wBAAgB,mBAAmB,CAAC,GAAG,EAAE,gBAAgB,EAAE,GAAG,IAAI,CAgCjE;AAmBD;;;;;;GAMG;AACH,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,gBAAgB,EAAE,GAAG,IAAI,CAyBhE;AAID;;;;GAIG;AACH,wBAAgB,sBAAsB,CAAC,KAAK,EAAE,OAAO,EAAE,EAAE,GAAG,EAAE,gBAAgB,EAAE,GAAG,IAAI,CA0GtF"}
|