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