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,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
|
+
}
|