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.
Files changed (50) hide show
  1. package/.claude/settings.local.json +8 -0
  2. package/.opencode/skills/calliope-ts/SKILL.md +74 -0
  3. package/LICENSE +201 -0
  4. package/README.md +485 -0
  5. package/dist/depfix.d.ts +13 -0
  6. package/dist/depfix.d.ts.map +1 -0
  7. package/dist/depfix.js +84 -0
  8. package/dist/display.d.ts +38 -0
  9. package/dist/display.d.ts.map +1 -0
  10. package/dist/display.js +890 -0
  11. package/dist/index.d.ts +50 -0
  12. package/dist/index.d.ts.map +1 -0
  13. package/dist/index.js +504 -0
  14. package/dist/parser.d.ts +10 -0
  15. package/dist/parser.d.ts.map +1 -0
  16. package/dist/parser.js +688 -0
  17. package/dist/phonological.d.ts +41 -0
  18. package/dist/phonological.d.ts.map +1 -0
  19. package/dist/phonological.js +788 -0
  20. package/dist/rhyme.d.ts +26 -0
  21. package/dist/rhyme.d.ts.map +1 -0
  22. package/dist/rhyme.js +340 -0
  23. package/dist/scandroid.d.ts +17 -0
  24. package/dist/scandroid.d.ts.map +1 -0
  25. package/dist/scandroid.js +435 -0
  26. package/dist/scansion.d.ts +37 -0
  27. package/dist/scansion.d.ts.map +1 -0
  28. package/dist/scansion.js +1007 -0
  29. package/dist/scansion_debug.js +586 -0
  30. package/dist/stress.d.ts +32 -0
  31. package/dist/stress.d.ts.map +1 -0
  32. package/dist/stress.js +1372 -0
  33. package/dist/tagfix.d.ts +6 -0
  34. package/dist/tagfix.d.ts.map +1 -0
  35. package/dist/tagfix.js +101 -0
  36. package/dist/types.d.ts +173 -0
  37. package/dist/types.d.ts.map +1 -0
  38. package/dist/types.js +4 -0
  39. package/package.json +62 -0
  40. package/src/depfix.ts +88 -0
  41. package/src/display.ts +954 -0
  42. package/src/index.ts +541 -0
  43. package/src/parser.ts +837 -0
  44. package/src/phonological.ts +849 -0
  45. package/src/rhyme.ts +328 -0
  46. package/src/scandroid.ts +434 -0
  47. package/src/scansion.ts +1053 -0
  48. package/src/stress.ts +1381 -0
  49. package/src/tagfix.ts +104 -0
  50. package/src/types.ts +230 -0
@@ -0,0 +1,788 @@
1
+ // phonological.ts — Constructs the prosodic hierarchy (CP, PP, IU)
2
+ // from the parsed sentence, replicating McAleese’s method.
3
+ import { isPunctuation } from './parser.js';
4
+ /**
5
+ * Build the full phonological hierarchy for a sentence.
6
+ *
7
+ * 1. Split into Intonational Units at punctuation tokens.
8
+ * 2. Within each IU, build Clitic Groups by attaching function words
9
+ * to their governing content word (contiguous grouping).
10
+ * 3. Group Clitic Groups into Phonological Phrases using the phrase
11
+ * structure tree (PPs correspond to VP and PP nodes).
12
+ */
13
+ export function buildPhonologicalHierarchy(sentence) {
14
+ const words = sentence.words;
15
+ if (words.length === 0)
16
+ return [];
17
+ // ---- Step 1: split into IU segments by punctuation ----
18
+ const iuSegments = splitByPunctuation(words);
19
+ const ius = [];
20
+ for (const seg of iuSegments) {
21
+ // ---- Step 2: build Clitic Groups for this segment ----
22
+ const cgs = buildCliticGroups(seg);
23
+ // ---- Step 3: group CPs into PPs using the phrase tree ----
24
+ const pps = groupIntoPhonologicalPhrases(cgs, seg, sentence.nodes);
25
+ ius.push({ phonologicalPhrases: pps });
26
+ }
27
+ return ius;
28
+ }
29
+ // ─── Intonational Unit splitting ───────────────────────────────
30
+ /** Punctuation POS tags that trigger an IU boundary. Quotation marks are
31
+ * deliberately EXCLUDED: quotes are not prosodic breaks (a quoted word inside
32
+ * a clause is read in one breath), and treating them as IU boundaries
33
+ * fragmented the line's phonological hierarchy — flipping meters. Parentheses
34
+ * stay: a parenthetical aside IS an intonational break. */
35
+ const PUNCT_TAGS = new Set([
36
+ '.', ',', ':', ';', '!', '?',
37
+ '-LRB-', '-RRB-', '(', ')', // parentheses (true parentheticals);
38
+ '[', ']', '{', '}', // FinNLP emits literal bracket tags
39
+ ]);
40
+ function splitByPunctuation(words) {
41
+ const segments = [];
42
+ let current = [];
43
+ for (const w of words) {
44
+ if (PUNCT_TAGS.has(w.lexicalClass)) {
45
+ // The punctuation token itself is not part of the prosodic
46
+ // hierarchy; it acts as a boundary.
47
+ if (current.length > 0) {
48
+ segments.push(current);
49
+ current = [];
50
+ }
51
+ }
52
+ else {
53
+ current.push(w);
54
+ }
55
+ }
56
+ if (current.length > 0)
57
+ segments.push(current);
58
+ return segments;
59
+ }
60
+ // ─── Clitic Group construction ────────────────────────────────
61
+ /**
62
+ * Content‑word POS tags (expand as needed).
63
+ * Content words serve as the head of a Clitic Group.
64
+ */
65
+ const CONTENT_TAGS = new Set([
66
+ 'NN', 'NNS', 'NNP', 'NNPS', // nouns
67
+ 'JJ', 'JJR', 'JJS', // adjectives
68
+ 'VB', 'VBD', 'VBG', 'VBN', 'VBP', 'VBZ', // verbs (excl. modals)
69
+ 'RB', 'RBR', 'RBS', // adverbs
70
+ 'CD', // cardinal numbers (content‑like)
71
+ ]);
72
+ function isContent(w) {
73
+ return CONTENT_TAGS.has(w.lexicalClass);
74
+ }
75
+ /**
76
+ * Build contiguous Clitic Groups for a segment of words.
77
+ *
78
+ * A CP consists of exactly one content word plus any contiguous
79
+ * function words that are dependents of that content word.
80
+ * Function words attach to the nearest content word to their right
81
+ * if they depend on it, or to the left content word otherwise.
82
+ */
83
+ function buildCliticGroups(words) {
84
+ const groups = [];
85
+ const assigned = new Set();
86
+ // First pass: create CPs for all content words and attach their dependents
87
+ for (let i = 0; i < words.length; i++) {
88
+ const w = words[i];
89
+ if (assigned.has(w))
90
+ continue;
91
+ if (isContent(w)) {
92
+ // Start a new CP with this content word.
93
+ const cpWords = [];
94
+ // Attach preceding unassigned function words that depend on w.
95
+ // Skip over already-assigned content words to reach function words.
96
+ let j = i - 1;
97
+ while (j >= 0) {
98
+ const prev = words[j];
99
+ if (assigned.has(prev)) {
100
+ j--;
101
+ continue; // skip assigned words (content or otherwise)
102
+ }
103
+ if (isContent(prev))
104
+ break; // unassigned content → stop
105
+ // prev is an unassigned function word
106
+ if (dependsOn(prev, w)) {
107
+ cpWords.unshift(prev);
108
+ assigned.add(prev);
109
+ }
110
+ else {
111
+ break;
112
+ }
113
+ j--;
114
+ }
115
+ // Add the content word itself.
116
+ cpWords.push(w);
117
+ assigned.add(w);
118
+ // Attach following unassigned function words that depend on w.
119
+ // Skip over already-assigned content words.
120
+ let k = i + 1;
121
+ while (k < words.length) {
122
+ const next = words[k];
123
+ if (assigned.has(next)) {
124
+ k++;
125
+ continue; // skip assigned words
126
+ }
127
+ if (isContent(next))
128
+ break; // unassigned content → stop
129
+ // next is an unassigned function word
130
+ if (dependsOn(next, w)) {
131
+ cpWords.push(next);
132
+ assigned.add(next);
133
+ }
134
+ else {
135
+ break;
136
+ }
137
+ k++;
138
+ }
139
+ groups.push({ tokens: cpWords });
140
+ }
141
+ }
142
+ // Second pass: any remaining unassigned function words become degenerate CPs
143
+ for (let i = 0; i < words.length; i++) {
144
+ const w = words[i];
145
+ if (!assigned.has(w)) {
146
+ groups.push({ tokens: [w] });
147
+ assigned.add(w);
148
+ }
149
+ }
150
+ // Sort groups by the index of their first token to maintain sentence order
151
+ groups.sort((a, b) => a.tokens[0].index - b.tokens[0].index);
152
+ return groups;
153
+ }
154
+ /** True if `dependent` has `head` as its direct governor. */
155
+ function dependsOn(dependent, head) {
156
+ const dep = dependent.dependency;
157
+ return !!(dep && dep.governor === head);
158
+ }
159
+ // ─── Phonological Phrase grouping via phrase tree ──────────────
160
+ /**
161
+ * Assigns each CP (identified by its head word) to a Phonological
162
+ * Phrase. The mapping uses the phrase‑structure tree: a PP node
163
+ * (or VP node) becomes a Phonological Phrase containing all CPs
164
+ * whose head words fall inside that node’s subtree.
165
+ */
166
+ function groupIntoPhonologicalPhrases(cgs, segmentWords, rootNode) {
167
+ if (!rootNode) {
168
+ // Fallback: every CP is its own PP.
169
+ return cgs.map(cg => ({ cliticGroups: [cg] }));
170
+ }
171
+ // Collect all phrase nodes that are candidates for PPs:
172
+ // VP and PP nodes (as in Antelope’s output, VP and PP are the
173
+ // maximal projections that McAleese uses as PPs).
174
+ const phraseNodes = collectPhraseNodes(rootNode);
175
+ // For each CP, determine which phrase node contains its head word,
176
+ // preferring the smallest (most specific) node.
177
+ const cpToPP = new Map();
178
+ for (const cg of cgs) {
179
+ const headWord = cg.tokens.find(w => isContent(w));
180
+ if (!headWord) {
181
+ cpToPP.set(cg, null);
182
+ continue;
183
+ }
184
+ const containingNode = findMinimalContainingNode(headWord, phraseNodes);
185
+ cpToPP.set(cg, containingNode);
186
+ }
187
+ // Build PP objects: each unique phrase node becomes a PP,
188
+ // containing all CPs assigned to it. CPs with no containing node
189
+ // are grouped into a single “orphan” PP.
190
+ const ppMap = new Map();
191
+ for (const cg of cgs) {
192
+ const node = cpToPP.get(cg) ?? null;
193
+ if (!ppMap.has(node))
194
+ ppMap.set(node, []);
195
+ ppMap.get(node).push(cg);
196
+ }
197
+ // Merge orphan CPs (node=null) into the PP of the nearest adjacent
198
+ // non-orphan CP within the same IU segment. This ensures function-word
199
+ // CPs (like determiners) that have no parse-tree node stay with the
200
+ // CPs they modify.
201
+ // Strategy: iterate CPs in order; if an orphan sits next to a non-orphan
202
+ // in the ordered list, merge it into that non-orphan's PP.
203
+ const orphanPPKey = { index: '__orphan_group__', nodeName: '__orphan_group__', parent: null, contains: [] };
204
+ if (ppMap.has(null)) {
205
+ const orphans = ppMap.get(null);
206
+ ppMap.delete(null);
207
+ // Create a synthetic key for all orphans so they merge with nearest adjacent PP.
208
+ // We'll merge them in the final ordering step below.
209
+ ppMap.set(orphanPPKey, []);
210
+ }
211
+ // Build PPs respecting order and merging adjacent orphans into
212
+ // the nearest non-orphan PP.
213
+ const cgOrder = [...cgs].sort((a, b) => a.tokens[0].index - b.tokens[0].index);
214
+ // Collect unique non-orphan node keys in order
215
+ const nodeKeysInOrder = [];
216
+ for (const cg of cgOrder) {
217
+ const node = cpToPP.get(cg) ?? null;
218
+ if (node === null)
219
+ continue; // orphans handled below
220
+ if (!nodeKeysInOrder.includes(node)) {
221
+ nodeKeysInOrder.push(node);
222
+ }
223
+ }
224
+ // Assign each orphan CG to the PP of the nearest adjacent non-orphan CG.
225
+ const orphanToNode = new Map();
226
+ for (const cg of cgOrder) {
227
+ const node = cpToPP.get(cg) ?? null;
228
+ if (node !== null)
229
+ continue; // not an orphan
230
+ // Look backward for nearest non-orphan CG
231
+ let foundNode = null;
232
+ for (let idx = cgOrder.indexOf(cg) - 1; idx >= 0; idx--) {
233
+ const n = cpToPP.get(cgOrder[idx]) ?? null;
234
+ if (n !== null) {
235
+ foundNode = n;
236
+ break;
237
+ }
238
+ }
239
+ // If none found backward, look forward
240
+ if (!foundNode) {
241
+ for (let idx = cgOrder.indexOf(cg) + 1; idx < cgOrder.length; idx++) {
242
+ const n = cpToPP.get(cgOrder[idx]) ?? null;
243
+ if (n !== null) {
244
+ foundNode = n;
245
+ break;
246
+ }
247
+ }
248
+ }
249
+ orphanToNode.set(cg, foundNode);
250
+ }
251
+ // Build PP objects: each unique phrase node becomes a PP,
252
+ // containing all CPs assigned to it (including merged orphans).
253
+ const finalPPMap = new Map();
254
+ for (const cg of cgOrder) {
255
+ const node = cpToPP.get(cg) ?? null;
256
+ const effectiveNode = node !== null ? node : (orphanToNode.get(cg) ?? orphanPPKey);
257
+ if (!finalPPMap.has(effectiveNode))
258
+ finalPPMap.set(effectiveNode, []);
259
+ finalPPMap.get(effectiveNode).push(cg);
260
+ }
261
+ const pps = [];
262
+ for (const [, cpList] of finalPPMap) {
263
+ cpList.sort((a, b) => a.tokens[0].index - b.tokens[0].index);
264
+ pps.push({ cliticGroups: cpList });
265
+ }
266
+ pps.sort((a, b) => a.cliticGroups[0].tokens[0].index - b.cliticGroups[0].tokens[0].index);
267
+ return pps;
268
+ }
269
+ /** Recursively collect all major syntactic constituent nodes (VP, PP, NP, ADJP, ADVP). */
270
+ function collectPhraseNodes(node) {
271
+ const result = [];
272
+ const phraseTypes = new Set(['VP', 'PP', 'NP', 'ADJP', 'ADVP']);
273
+ if (phraseTypes.has(node.nodeName)) {
274
+ result.push(node);
275
+ }
276
+ for (const child of node.contains) {
277
+ // Skip ClsWord leaves (they have a `word` property)
278
+ if (child.word !== undefined)
279
+ continue;
280
+ // Now child must be a ClsNode
281
+ const childNode = child;
282
+ if (childNode.nodeName !== undefined) {
283
+ result.push(...collectPhraseNodes(childNode));
284
+ }
285
+ }
286
+ return result;
287
+ }
288
+ /**
289
+ * Find the smallest phrase node (from the candidate list) that
290
+ * contains the given word, or null if none does.
291
+ */
292
+ function findMinimalContainingNode(word, phraseNodes) {
293
+ let best = null;
294
+ let bestSize = Infinity;
295
+ for (const node of phraseNodes) {
296
+ if (nodeContainsWord(node, word)) {
297
+ const size = nodeSize(node);
298
+ if (size < bestSize) {
299
+ bestSize = size;
300
+ best = node;
301
+ }
302
+ }
303
+ }
304
+ return best;
305
+ }
306
+ /** Check whether a node’s subtree includes the given word. */
307
+ function nodeContainsWord(node, word) {
308
+ for (const child of node.contains) {
309
+ if (child.word !== undefined && child.index !== undefined) {
310
+ if (child.index === word.index)
311
+ return true;
312
+ }
313
+ else if (child.nodeName !== undefined) {
314
+ if (nodeContainsWord(child, word))
315
+ return true;
316
+ }
317
+ }
318
+ return false;
319
+ }
320
+ /** Approximate size of a node’s subtree (number of word leaves). */
321
+ function nodeSize(node) {
322
+ let count = 0;
323
+ for (const child of node.contains) {
324
+ if (child.word !== undefined) {
325
+ // leaf word
326
+ count++;
327
+ }
328
+ else if (child.nodeName !== undefined) {
329
+ count += nodeSize(child);
330
+ }
331
+ }
332
+ return count;
333
+ }
334
+ // ─── Utility exports for scansion.ts and index.ts ─────────────
335
+ export function collectIUTokens(iu) {
336
+ const tokens = [];
337
+ for (const pp of iu.phonologicalPhrases) {
338
+ tokens.push(...collectPPTokens(pp));
339
+ }
340
+ return tokens;
341
+ }
342
+ export function collectPPTokens(pp) {
343
+ const tokens = [];
344
+ for (const cg of pp.cliticGroups) {
345
+ tokens.push(...cg.tokens);
346
+ }
347
+ return tokens;
348
+ }
349
+ function flattenWithMeta(words) {
350
+ const result = [];
351
+ let idx = 0;
352
+ for (const w of words) {
353
+ if (isPunctuation(w.lexicalClass))
354
+ continue;
355
+ const syls = w.syllables;
356
+ for (let i = 0; i < syls.length; i++) {
357
+ result.push({
358
+ stress: syls[i].relativeStress ?? 'w',
359
+ globalIndex: idx,
360
+ isFinalSylOfWord: i === syls.length - 1,
361
+ });
362
+ idx++;
363
+ }
364
+ }
365
+ return result;
366
+ }
367
+ /**
368
+ * Core renderer that walks the hierarchy and produces the bracket string.
369
+ * If `keySet` is given, only positions whose global index is in the set are
370
+ * shown with their actual stress; all other positions become 'x'.
371
+ */
372
+ function renderStressString(ius, flat, keySet) {
373
+ let result = '';
374
+ let sylIdx = 0; // pointer into flat array
375
+ for (const iu of ius) {
376
+ result += '<';
377
+ for (const pp of iu.phonologicalPhrases) {
378
+ result += '{';
379
+ for (const cg of pp.cliticGroups) {
380
+ result += '[';
381
+ let firstWord = true;
382
+ for (const word of cg.tokens) {
383
+ if (!firstWord)
384
+ result += '/'; // word break marker
385
+ firstWord = false;
386
+ const syls = word.syllables;
387
+ // polysyllabic word: insert '\' before first syllable
388
+ if (syls.length > 1)
389
+ result += '\\';
390
+ for (let s = 0; s < syls.length; s++) {
391
+ const meta = flat[sylIdx];
392
+ sylIdx++;
393
+ const stress = meta.stress;
394
+ if (keySet) {
395
+ result += keySet.has(meta.globalIndex) ? stress : 'x';
396
+ }
397
+ else {
398
+ result += stress;
399
+ }
400
+ }
401
+ }
402
+ result += ']';
403
+ }
404
+ result += '}';
405
+ }
406
+ result += '>';
407
+ }
408
+ return result;
409
+ }
410
+ /**
411
+ * Render the full phonological hierarchy into the bracket notation
412
+ * used by McAleese, e.g. "<{[nm/ws\n]}mn/sw\]m]}>".
413
+ */
414
+ export function renderHierarchy(ius, words) {
415
+ const flat = flattenWithMeta(words);
416
+ return renderStressString(ius, flat);
417
+ }
418
+ /**
419
+ * Render the key‑stress string: only syllables that participate in
420
+ * key‑stress patterns are shown with their stress symbol; all others become 'x'.
421
+ */
422
+ export function renderKeyStresses(ius, words, keyStresses) {
423
+ const flat = flattenWithMeta(words);
424
+ const keySet = new Set();
425
+ for (const ks of keyStresses) {
426
+ for (const pos of ks.positions) {
427
+ keySet.add(pos);
428
+ }
429
+ }
430
+ return renderStressString(ius, flat, keySet);
431
+ }
432
+ // ─── DISPLAY HELPERS ─────────────────────────────────────────────
433
+ /**
434
+ * Split a word into orthographic syllable chunks using the Maximum Onset Principle.
435
+ * Respects English phonotactics: digraphs stay together, consonants go to
436
+ * the onset of the following syllable when they form a legal cluster.
437
+ */
438
+ const VOWEL_CHARS = new Set('aeiouyAEIOUY');
439
+ const CONSONANT_DIGRAPHS = new Set(['th', 'sh', 'ch', 'wh', 'ph', 'gh', 'ck', 'ng', 'nk', 'tch', 'dge', 'sc', 'sk', 'sp', 'st']);
440
+ // ARPABET vowels, split into "free/long" (can end a syllable → favours an OPEN
441
+ // split: e·ven, ta·ble, o·pen) and "checked/short" (needs a coda → favours a
442
+ // CLOSED split: sev·en, prob·lem, rob·in). This is the vowel-length cue that
443
+ // orthography alone cannot supply; it comes from nounsing-pro's per-syllable
444
+ // phones. Display-only: it never affects meter scoring.
445
+ const ARPABET_VOWELS = new Set([
446
+ 'AA', 'AE', 'AH', 'AO', 'AW', 'AY', 'EH', 'ER', 'EY', 'IH', 'IY', 'OW', 'OY', 'UH', 'UW',
447
+ ]);
448
+ const FREE_VOWELS = new Set(['IY', 'EY', 'AY', 'OW', 'UW', 'AW', 'OY', 'ER', 'AO']);
449
+ /** Classify a syllable's vowel (from its ARPABET phones) as free/long vs checked/short. */
450
+ export function vowelLengthOf(phones) {
451
+ if (!phones)
452
+ return 'unknown';
453
+ // Per-syllable phones may be parenthesised and stress-digited, e.g. "(s EH)".
454
+ for (const tok of phones.trim().split(/\s+/)) {
455
+ const v = tok.replace(/[^A-Za-z]/g, '').toUpperCase(); // strip parens/digits
456
+ if (ARPABET_VOWELS.has(v))
457
+ return FREE_VOWELS.has(v) ? 'long' : 'short';
458
+ }
459
+ return 'unknown';
460
+ }
461
+ /** Per-syllable vowel lengths for a word, to guide open/closed syllabification. */
462
+ export function syllableVowelLengths(syllables) {
463
+ return syllables.map(s => {
464
+ const len = vowelLengthOf(s.phones);
465
+ const stressed = (s.lexicalStress ?? s.stress ?? 0) >= 1;
466
+ // Only a *stressed* checked vowel closes its syllable; a reduced/unstressed
467
+ // syllable stays open (beau·ti·ful, not beau·tif·ul; mem·o·ry, not mem·or·y).
468
+ if (len === 'short' && !stressed)
469
+ return 'unknown';
470
+ return len;
471
+ });
472
+ }
473
+ /**
474
+ * Opaque lexicalised compounds whose orthographic syllable boundary the
475
+ * maximal-onset syllabifier cuts in the wrong place (some·one → so·meone, because
476
+ * the lone medial 'm' is greedily taken as the onset of syllable 2). We supply
477
+ * the morpheme boundary explicitly: the constituents are real words, so each is
478
+ * syllabified on its own and re-joined. Applied ONLY when the parts' own
479
+ * syllable counts sum to the requested count, so a mismatched parse falls through
480
+ * to the general algorithm rather than mis-splitting. Display-only (never affects
481
+ * stress or meter, which derive from the CMU syllable count, not this chunking).
482
+ */
483
+ const LEXICAL_COMPOUND_PARTS = {
484
+ someone: ['some', 'one'], anyone: ['any', 'one'], everyone: ['every', 'one'], noone: ['no', 'one'],
485
+ something: ['some', 'thing'], anything: ['any', 'thing'], everything: ['every', 'thing'], nothing: ['no', 'thing'],
486
+ somebody: ['some', 'body'], anybody: ['any', 'body'], everybody: ['every', 'body'], nobody: ['no', 'body'],
487
+ somewhere: ['some', 'where'], anywhere: ['any', 'where'], everywhere: ['every', 'where'], nowhere: ['no', 'where'],
488
+ somehow: ['some', 'how'], somewhat: ['some', 'what'], someday: ['some', 'day'],
489
+ sometime: ['some', 'time'], sometimes: ['some', 'times'], someplace: ['some', 'place'],
490
+ itself: ['it', 'self'], himself: ['him', 'self'], herself: ['her', 'self'], myself: ['my', 'self'],
491
+ yourself: ['your', 'self'], oneself: ['one', 'self'],
492
+ themselves: ['them', 'selves'], ourselves: ['our', 'selves'], yourselves: ['your', 'selves'],
493
+ into: ['in', 'to'], onto: ['on', 'to'], unto: ['un', 'to'], upon: ['up', 'on'],
494
+ within: ['with', 'in'], without: ['with', 'out'], throughout: ['through', 'out'],
495
+ cannot: ['can', 'not'], become: ['be', 'come'], became: ['be', 'came'],
496
+ // Archaic/locative pronominal compounds (frequent in verse). The medial
497
+ // silent 'e' of the first element ("where·fore") otherwise inflates the
498
+ // orthographic vowel-group count and mis-places the boundary.
499
+ wherefore: ['where', 'fore'], therefore: ['there', 'fore'],
500
+ wherein: ['where', 'in'], therein: ['there', 'in'], herein: ['here', 'in'],
501
+ whereby: ['where', 'by'], thereby: ['there', 'by'], hereby: ['here', 'by'],
502
+ whereof: ['where', 'of'], thereof: ['there', 'of'], hereof: ['here', 'of'],
503
+ whereto: ['where', 'to'], thereto: ['there', 'to'], hereto: ['here', 'to'],
504
+ whereon: ['where', 'on'], thereon: ['there', 'on'],
505
+ whereat: ['where', 'at'], thereat: ['there', 'at'],
506
+ whereupon: ['where', 'upon'], thereupon: ['there', 'upon'], hereupon: ['here', 'upon'],
507
+ hereafter: ['here', 'after'], thereafter: ['there', 'after'], whereafter: ['where', 'after'],
508
+ heretofore: ['here', 'to', 'fore'], hitherto: ['hither', 'to'],
509
+ };
510
+ /** Orthographic syllable estimate for a single sub-word (silent-final-e aware). */
511
+ function quickSyllableCount(s) {
512
+ const lower = s.toLowerCase();
513
+ const pos = [];
514
+ let inV = false;
515
+ for (let i = 0; i < lower.length; i++) {
516
+ if (VOWEL_CHARS.has(lower[i])) {
517
+ if (!inV) {
518
+ pos.push(i);
519
+ inV = true;
520
+ }
521
+ }
522
+ else
523
+ inV = false;
524
+ }
525
+ let groups = pos.length;
526
+ if (groups >= 2 && lower.endsWith('e') && pos[pos.length - 1] === lower.length - 1)
527
+ groups--;
528
+ return Math.max(1, groups);
529
+ }
530
+ export function syllabifyWord(word, syllableCount, vowelLengths, morphSuffix) {
531
+ if (syllableCount <= 1)
532
+ return [word];
533
+ // Lexical compound boundary (someone → some·one, not so·meone). Only when the
534
+ // constituents' own syllable counts add up to the requested total.
535
+ {
536
+ const key = word.toLowerCase().replace(/[^a-z]/g, '');
537
+ const parts = LEXICAL_COMPOUND_PARTS[key];
538
+ if (parts && key === word.toLowerCase()) {
539
+ const counts = parts.map(quickSyllableCount);
540
+ if (counts.reduce((a, b) => a + b, 0) === syllableCount) {
541
+ const out = [];
542
+ let off = 0;
543
+ for (let p = 0; p < parts.length; p++) {
544
+ const seg = word.slice(off, off + parts[p].length);
545
+ off += parts[p].length;
546
+ out.push(...syllabifyWord(seg, counts[p]));
547
+ }
548
+ if (out.length === syllableCount)
549
+ return out;
550
+ }
551
+ }
552
+ }
553
+ // Morpheme-aware peel: when OOV stress assignment validated a productive
554
+ // archaic suffix (-est/-eth/-ith), split it off as the final syllable so the
555
+ // stem keeps its spelling (know·est, not kno·west; know·eth, not kno·weth).
556
+ if (morphSuffix && syllableCount >= 2
557
+ && word.toLowerCase().endsWith(morphSuffix)
558
+ && word.length > morphSuffix.length + 1) {
559
+ const stem = word.slice(0, word.length - morphSuffix.length);
560
+ const suffixChunk = word.slice(word.length - morphSuffix.length);
561
+ const stemChunks = syllabifyWord(stem, syllableCount - 1, vowelLengths?.slice(0, syllableCount - 1));
562
+ return [...stemChunks, suffixChunk];
563
+ }
564
+ // For hyphenated words, use hyphen as syllable boundary if counts match
565
+ if (word.includes('-')) {
566
+ const parts = word.split('-');
567
+ if (parts.length === syllableCount) {
568
+ return parts;
569
+ }
570
+ }
571
+ const cleanWord = word.replace(/-/g, '');
572
+ if (cleanWord.length <= syllableCount) {
573
+ if (cleanWord.length === syllableCount)
574
+ return cleanWord.split('');
575
+ return [word];
576
+ }
577
+ const hyphenMap = [];
578
+ for (let i = 0; i < word.length; i++) {
579
+ if (word[i] !== '-')
580
+ hyphenMap.push(i);
581
+ }
582
+ const lower = cleanWord.toLowerCase();
583
+ const n = lower.length;
584
+ // Common English consonant digraphs
585
+ const DIGRAPHS = new Set(['ch', 'sh', 'th', 'wh', 'ph', 'gh', 'ck', 'ng', 'wr', 'kn', 'gn']);
586
+ // Digraphs that commonly end syllables (codas)
587
+ const CODA_DIGRAPHS = new Set(['ch', 'sh', 'ck', 'ng', 'th']);
588
+ // "Muta cum liquida": an obstruent + liquid/glide that, between vowels, stays
589
+ // together as the onset of the following syllable (maximal-onset principle):
590
+ // se·cret, be·tween, chil·dren, pro·gram, re·gret. Deliberately EXCLUDES the
591
+ // s+stop clusters (st/sp/sc/sk), which in medial position split after a short
592
+ // vowel (mis·ter, dis·turb, whis·per) rather than maximising the onset.
593
+ const MEDIAL_ONSET = new Set([
594
+ 'bl', 'br', 'cl', 'cr', 'dr', 'dw', 'fl', 'fr', 'gl', 'gr',
595
+ 'pl', 'pr', 'tr', 'tw',
596
+ ]);
597
+ // Legal English 3-consonant onsets (s + voiceless stop + liquid/glide) plus
598
+ // the orthographic clusters thr/shr/chr/phr/sch (single onset phonemically).
599
+ const TRIPLE_ONSET = new Set([
600
+ 'str', 'spr', 'scr', 'spl', 'squ', 'thr', 'shr', 'chr', 'phr', 'sch',
601
+ ]);
602
+ // Final "consonant + le" forms its own syllable (ta·ble, lit·tle, ap·ple,
603
+ // tem·ple, bot·tle): the single consonant immediately before "le" joins it.
604
+ const endsConsonantLe = n >= 3 && lower.endsWith('le') && !VOWEL_CHARS.has(lower[n - 3]);
605
+ // Non-syllabic past-tense "-ed": the 'e' in a final "…Xed" (X a consonant other
606
+ // than t/d) is silent (re·turned, not re·tur·ned). After t/d it IS syllabic
607
+ // (want·ed, embed·ded), so those are excluded.
608
+ const endsSilentEd = n >= 3 && lower.endsWith('ed')
609
+ && !VOWEL_CHARS.has(lower[n - 3]) && lower[n - 3] !== 't' && lower[n - 3] !== 'd';
610
+ const nuclei = [];
611
+ let i = 0;
612
+ while (i < n) {
613
+ if (VOWEL_CHARS.has(lower[i])) {
614
+ const vs = i;
615
+ while (i < n && VOWEL_CHARS.has(lower[i]))
616
+ i++;
617
+ const isLoneFinalE = (i === n && (i - vs) === 1 && lower[vs] === 'e');
618
+ if (isLoneFinalE && nuclei.length >= 2) {
619
+ // silent-e: a lone 'e' at word end after 2+ nuclei is typically silent
620
+ }
621
+ else {
622
+ nuclei.push({ start: vs, end: i });
623
+ }
624
+ }
625
+ else {
626
+ i++;
627
+ }
628
+ }
629
+ if (nuclei.length === 0)
630
+ return [word];
631
+ // If we have a surplus nucleus and the word ends in a non-syllabic "-ed",
632
+ // drop that silent 'e' first (preferred over a generic consonant-count merge,
633
+ // which would otherwise mis-segment e.g. "returned" → "re·tur·ned").
634
+ if (nuclei.length > syllableCount && endsSilentEd) {
635
+ const last = nuclei[nuclei.length - 1];
636
+ if (last.start === n - 2 && last.end === n - 1)
637
+ nuclei.pop();
638
+ }
639
+ while (nuclei.length > syllableCount && nuclei.length > 1) {
640
+ let minConsonants = Infinity;
641
+ let mergeIdx = 0;
642
+ for (let j = 0; j < nuclei.length - 1; j++) {
643
+ const consonantsBetween = nuclei[j + 1].start - nuclei[j].end;
644
+ if (consonantsBetween < minConsonants) {
645
+ minConsonants = consonantsBetween;
646
+ mergeIdx = j;
647
+ }
648
+ }
649
+ nuclei[mergeIdx] = { start: nuclei[mergeIdx].start, end: nuclei[mergeIdx + 1].end };
650
+ nuclei.splice(mergeIdx + 1, 1);
651
+ }
652
+ const useWord = word;
653
+ const useN = n;
654
+ if (nuclei.length === syllableCount) {
655
+ const boundaries = [0];
656
+ for (let j = 0; j < nuclei.length - 1; j++) {
657
+ const gapStart = nuclei[j].end;
658
+ const gapEnd = nuclei[j + 1].start;
659
+ const consonants = gapEnd - gapStart;
660
+ let boundary;
661
+ if (consonants <= 0) {
662
+ boundary = gapEnd;
663
+ }
664
+ else if (consonants === 1) {
665
+ // Single intervocalic consonant: Maximal Onset (open, V·CV) by default,
666
+ // but a checked/short stressed vowel CLOSES the syllable (VC·V):
667
+ // sev·en / rob·in / lem·on, vs. open e·ven / o·pen / ro·bot after a free
668
+ // (long) vowel. Falls back to MOP when vowel length is unknown (OOV).
669
+ boundary = (vowelLengths && vowelLengths[j] === 'short') ? gapEnd : gapStart;
670
+ }
671
+ else if (consonants === 2) {
672
+ const pair = lower.substring(gapStart, gapEnd);
673
+ if (MEDIAL_ONSET.has(pair)) {
674
+ // Onset cluster (muta cum liquida) normally begins the next syllable
675
+ // (ta·ble, se·cret, pro·gram) — UNLESS a checked/short vowel closes the
676
+ // syllable, in which case one consonant stays behind (prob·lem, frac·ture).
677
+ boundary = (vowelLengths && vowelLengths[j] === 'short') ? gapStart + 1 : gapStart;
678
+ }
679
+ else if (DIGRAPHS.has(pair)) {
680
+ if (CODA_DIGRAPHS.has(pair)) {
681
+ // Common coda: digraph goes with preceding syllable
682
+ boundary = gapEnd;
683
+ }
684
+ else {
685
+ // Common onset: digraph goes with following syllable
686
+ boundary = gapStart;
687
+ }
688
+ }
689
+ else {
690
+ // Not a cluster/digraph: split (first consonant with prev, second with next)
691
+ boundary = gapStart + 1;
692
+ }
693
+ }
694
+ else {
695
+ // 3+ consonants: maximise the onset — a legal THREE-consonant onset
696
+ // (s + stop + liquid/glide) carries whole to the next syllable ONLY
697
+ // when the preceding vowel is known to be long/free (a stressed short
698
+ // vowel takes the s as its coda: mis·tress, but a free vowel opens:
699
+ // de·stroy with reduced e). Else a final 2-consonant onset cluster or
700
+ // digraph carries; otherwise only the last consonant (chil·dren).
701
+ const lastThree = lower.substring(gapEnd - 3, gapEnd);
702
+ const lastTwo = lower.substring(gapEnd - 2, gapEnd);
703
+ if (TRIPLE_ONSET.has(lastThree) && vowelLengths && vowelLengths[j] === 'long') {
704
+ boundary = gapEnd - 3;
705
+ }
706
+ else if (MEDIAL_ONSET.has(lastTwo) || DIGRAPHS.has(lastTwo)) {
707
+ boundary = gapEnd - 2;
708
+ }
709
+ else {
710
+ boundary = gapEnd - 1;
711
+ }
712
+ }
713
+ // Final "consonant + le" overrides: the consonant before "le" joins it.
714
+ if (endsConsonantLe && j === nuclei.length - 2) {
715
+ boundary = n - 3;
716
+ }
717
+ if (boundary >= n)
718
+ boundary = n - 1;
719
+ if (boundary <= boundaries[boundaries.length - 1]) {
720
+ boundary = boundaries[boundaries.length - 1] + 1;
721
+ }
722
+ boundaries.push(boundary);
723
+ }
724
+ boundaries.push(n);
725
+ const result = [];
726
+ for (let j = 0; j < boundaries.length - 1; j++) {
727
+ const origStart = hyphenMap.length > 0 ? hyphenMap[boundaries[j]] : boundaries[j];
728
+ const origEnd = hyphenMap.length > 0 ? (boundaries[j + 1] < hyphenMap.length ? hyphenMap[boundaries[j + 1]] : word.length) : boundaries[j + 1];
729
+ result.push(word.slice(origStart, origEnd));
730
+ }
731
+ while (result.length < syllableCount)
732
+ result.push('');
733
+ return result.slice(0, syllableCount);
734
+ }
735
+ const result = [];
736
+ let start = 0;
737
+ for (let s = 0; s < syllableCount - 1; s++) {
738
+ const remaining = syllableCount - s;
739
+ const remainingChars = n - start;
740
+ const idealLen = Math.round(remainingChars / remaining);
741
+ let end = start + Math.max(2, idealLen);
742
+ if (end > n - (remaining - 1) * 2)
743
+ end = n - (remaining - 1) * 2;
744
+ if (end <= start + 1)
745
+ end = start + 2;
746
+ if (end > n)
747
+ end = n;
748
+ const origStart = hyphenMap.length > 0 ? hyphenMap[start] : start;
749
+ const origEnd = hyphenMap.length > 0 ? (end < hyphenMap.length ? hyphenMap[end] : word.length) : end;
750
+ result.push(word.slice(origStart, origEnd));
751
+ start = end;
752
+ }
753
+ const origStart = hyphenMap.length > 0 ? hyphenMap[start] : start;
754
+ result.push(word.slice(origStart));
755
+ while (result.length < syllableCount)
756
+ result.push('');
757
+ return result.slice(0, syllableCount);
758
+ }
759
+ /**
760
+ * Flatten all syllables into display entries with word context.
761
+ * Each entry carries the original word text, the syllable text
762
+ * (orthographic chunk), the syllable's position within the word,
763
+ * and its relative stress level.
764
+ */
765
+ export function flattenDisplayEntries(words) {
766
+ const result = [];
767
+ let globalIdx = 0;
768
+ let wordIdx = 0;
769
+ for (const w of words) {
770
+ if (isPunctuation(w.lexicalClass))
771
+ continue;
772
+ const sylCount = w.syllables.length;
773
+ const chunks = syllabifyWord(w.word, sylCount, syllableVowelLengths(w.syllables), w.morphSuffix);
774
+ for (let si = 0; si < sylCount; si++) {
775
+ result.push({
776
+ wordText: w.word,
777
+ sylText: chunks[si],
778
+ sylIndex: si,
779
+ sylCount,
780
+ relativeStress: w.syllables[si].relativeStress ?? 'w',
781
+ globalIndex: globalIdx++,
782
+ wordIndex: wordIdx,
783
+ });
784
+ }
785
+ wordIdx++;
786
+ }
787
+ return result;
788
+ }