compromise-speech 0.1.1 → 0.2.0
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/LICENSE +21 -0
- package/README.md +89 -21
- package/builds/compromise-speech.cjs +483 -279
- package/builds/compromise-speech.min.js +1 -1
- package/builds/compromise-speech.mjs +1 -1
- package/package.json +45 -30
- package/src/_version.js +1 -0
- package/src/api.js +5 -5
- package/src/plugin.js +9 -3
- package/src/{compute/soundsLike → sounds-like}/transformations.js +1 -1
- package/src/syllables/index.js +12 -0
- package/src/syllables/split/_lib.js +32 -0
- package/src/syllables/split/find-cuts/00-exceptions.js +81 -0
- package/src/syllables/split/find-cuts/01-vowel-span.js +155 -0
- package/src/syllables/split/find-cuts/02-boundaries.js +86 -0
- package/src/syllables/split/find-cuts/_compound-prefixes.js +22 -0
- package/src/syllables/split/find-cuts/index.js +27 -0
- package/src/syllables/split/index.js +22 -0
- package/types/index.d.cts +9 -0
- package/{index.d.ts → types/index.d.ts} +1 -1
- package/src/compute/index.js +0 -7
- package/src/compute/syllables/index.js +0 -13
- package/src/compute/syllables/postProcess.js +0 -107
- package/src/compute/syllables/syllables.js +0 -90
- /package/src/{compute/soundsLike → sounds-like}/index.js +0 -0
- /package/src/{compute/soundsLike → sounds-like}/metaphone.js +0 -0
|
@@ -4,13 +4,464 @@
|
|
|
4
4
|
(global = typeof globalThis !== 'undefined' ? globalThis : global || self, global.compromiseSpeech = factory());
|
|
5
5
|
})(this, (function () { 'use strict';
|
|
6
6
|
|
|
7
|
+
var version = '0.2.0';
|
|
8
|
+
|
|
9
|
+
const api = function (View) {
|
|
10
|
+
/** */
|
|
11
|
+
View.prototype.syllables = function (options = {}) {
|
|
12
|
+
this.compute('syllables', options);
|
|
13
|
+
const all = [];
|
|
14
|
+
this.docs.forEach(terms => {
|
|
15
|
+
let some = [];
|
|
16
|
+
terms.forEach(term => {
|
|
17
|
+
some = some.concat(term.syllables);
|
|
18
|
+
});
|
|
19
|
+
if (some.length > 0) {
|
|
20
|
+
all.push(some);
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
return all
|
|
24
|
+
};
|
|
25
|
+
/** */
|
|
26
|
+
View.prototype.soundsLike = function () {
|
|
27
|
+
this.compute('soundsLike');
|
|
28
|
+
const all = [];
|
|
29
|
+
this.docs.forEach(terms => {
|
|
30
|
+
let some = [];
|
|
31
|
+
terms.forEach(term => {
|
|
32
|
+
some = some.concat(term.soundsLike);
|
|
33
|
+
});
|
|
34
|
+
if (some.length > 0) {
|
|
35
|
+
all.push(some);
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
return all
|
|
39
|
+
};
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
// Whole-word splits that override the vowel-span and boundary rules.
|
|
43
|
+
// Keys are lowercase; values are interior cut offsets, excluding the word's end.
|
|
44
|
+
const exceptions = {
|
|
45
|
+
acre: [1], // a-cre: the final re carries a syllable
|
|
46
|
+
// Borrowed words whose final e is pronounced.
|
|
47
|
+
cafe: [2], // ca-fe
|
|
48
|
+
abalone: [2, 3, 5], // ab-a-lo-ne
|
|
49
|
+
aborigine: [2, 3, 6, 7], // ab-o-rig-i-ne
|
|
50
|
+
comanche: [2, 5], // co-man-che
|
|
51
|
+
dolce: [3], // dol-ce
|
|
52
|
+
// Borrowed vowel combinations do not follow the usual English pair rules.
|
|
53
|
+
acacia: [1, 3], // a-ca-cia
|
|
54
|
+
fuchsia: [4], // fuch-sia
|
|
55
|
+
concierge: [3, 5], // con-ci-erge
|
|
56
|
+
concierges: [3, 5], // con-ci-erges
|
|
57
|
+
comedienne: [2, 4, 6], // co-me-di-enne
|
|
58
|
+
// The final re in massacre carries a syllable, including its inflected forms.
|
|
59
|
+
massacre: [3, 5], // mas-sa-cre
|
|
60
|
+
massacred: [3, 5], // mas-sa-cred
|
|
61
|
+
massacring: [3, 5], // mas-sa-cring
|
|
62
|
+
// Silent consonants make these look like syllabic -le endings.
|
|
63
|
+
aisle: [],
|
|
64
|
+
isle: [],
|
|
65
|
+
wednesday: [6], // wednes-day
|
|
66
|
+
// Keep the spoken vowel groups together or apart explicitly.
|
|
67
|
+
buoyant: [4], // buoy-ant
|
|
68
|
+
buoyancy: [4, 6], // buoy-an-cy
|
|
69
|
+
acquiesce: [2, 5], // ac-qui-esce
|
|
70
|
+
acquiesced: [2, 5], // ac-qui-esced
|
|
71
|
+
acquiescent: [2, 5, 7], // ac-qui-es-cent
|
|
72
|
+
acquiescence: [2, 5, 7], // ac-qui-es-cence
|
|
73
|
+
people: [3], //'peo ple',
|
|
74
|
+
queue: [], //'queue',
|
|
75
|
+
business: [4], //'busi ness',
|
|
76
|
+
quiet: [3], //'qui et',
|
|
77
|
+
science: [3], //'sci ence',
|
|
78
|
+
poet: [2], //'po et',
|
|
79
|
+
poetry: [2, 4], //'po et ry',
|
|
80
|
+
every: [3], //'eve ry',
|
|
81
|
+
everything: [3, 5], // 'eve ry thing',
|
|
82
|
+
everybody: [3, 5, 7], //'eve ry bo dy',
|
|
83
|
+
ilyich: [2], // 'il yich': consonantal y in this name
|
|
84
|
+
juan: [], // 'juan':
|
|
85
|
+
wyatt: [2], // 'wy att'
|
|
86
|
+
// Place names retain their recognizable compound boundaries.
|
|
87
|
+
batesville: [5], // bates-ville
|
|
88
|
+
belleville: [5], // belle-ville
|
|
89
|
+
bridgeport: [6], // bridge-port
|
|
90
|
+
cambridgeport: [3, 9], // cam-bridge-port
|
|
91
|
+
cambridgeside: [3, 9], // cam-bridge-side
|
|
92
|
+
janesville: [5], // janes-ville
|
|
93
|
+
roseberry: [4, 7], // rose-ber-ry
|
|
94
|
+
roseville: [4], // rose-ville
|
|
95
|
+
// These names retain vowels that the general spelling rules suppress or blend.
|
|
96
|
+
belge: [3], // bel-ge
|
|
97
|
+
canadienne: [2, 4, 6], // ca-na-di-enne
|
|
98
|
+
mildred: [3], // mil-dred
|
|
99
|
+
nietzsche: [5], // nietz-sche
|
|
100
|
+
odea: [1, 3], // o-de-a
|
|
101
|
+
// The two written o's belong to separate syllables in these words.
|
|
102
|
+
zoologist: [2, 4, 5], // zo-ol-o-gist
|
|
103
|
+
zoology: [2, 4, 5], // zo-ol-o-gy
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const checkExceptions = str => {
|
|
107
|
+
if (Object.hasOwn(exceptions, str)) {
|
|
108
|
+
return exceptions[str]
|
|
109
|
+
}
|
|
110
|
+
// Reuse singular cuts for a plain -s ending; exact entries above take priority.
|
|
111
|
+
const singular = str.replace(/s$/, '');
|
|
112
|
+
if (Object.hasOwn(exceptions, singular)) {
|
|
113
|
+
// A sibilant before silent e adds a syllable: sci-ence becomes sci-en-ces.
|
|
114
|
+
if (/[csxz]e$/.test(singular)) {
|
|
115
|
+
return exceptions[singular].concat(singular.length - 2)
|
|
116
|
+
}
|
|
117
|
+
return exceptions[singular]
|
|
118
|
+
}
|
|
119
|
+
return null
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
// Match a compound's left component, including its silent e.
|
|
123
|
+
// Both vowel counting and boundary placement use these same spelling rules.
|
|
124
|
+
const compoundPrefixes = [
|
|
125
|
+
/^note(?=book)/, // note-book: silent e and a boundary after the complete base
|
|
126
|
+
/^side(?=kick|line|show|step|stream|track|walk|water|way|winder|wise)/,
|
|
127
|
+
/^some(?=bod|one|time|where)/,
|
|
128
|
+
/^none(?=such)/, // none-such retains the silent e of none
|
|
129
|
+
/^goose(?=berr)/, // goose-berry and goose-berries
|
|
130
|
+
/^above(?=board|ground)/,
|
|
131
|
+
/^a?fore(?=clos|taste|said|mentioned)/,
|
|
132
|
+
/^ware(?=house|housing)/,
|
|
133
|
+
/^space(?=port|ship)/,
|
|
134
|
+
/^race(?=horse)/,
|
|
135
|
+
/^lake(?=side)/,
|
|
136
|
+
/^white(?=side)/,
|
|
137
|
+
/^state(?=side)/,
|
|
138
|
+
/^juke(?=box)/,
|
|
139
|
+
/^police(?=woman|women|man|men)/,
|
|
140
|
+
/^(?:sale|spoke)s?(?=woman|women|man|men)/,
|
|
141
|
+
];
|
|
142
|
+
|
|
143
|
+
// Roots ending in ed keep their vowel; these are not regular past-tense suffixes.
|
|
144
|
+
const voicedEd = /(?:[^aeiouyl]led|^(?:hundred|kindred|sacred|wicked|rugged|ragged|wretched|naked|crooked|(?:a|em|death|river|water|flower|hot|sick|sun|sea)bed|(?:in|inter|thorough|cross|pure)bred|infrared|newlywed|premed))(?:['’](?:s|d|ll|re|ve))?$/;
|
|
145
|
+
|
|
146
|
+
// Find vowel spans in lowercase spelling; return character offsets, not syllables.
|
|
147
|
+
// Candidate vowel letters; ignored rules handle consonantal uses of u and y.
|
|
148
|
+
const isVowel = /[aeiouy]/;
|
|
149
|
+
|
|
150
|
+
// Ignore the character at match.index + offset. Rules may veto a whole word.
|
|
151
|
+
// Global patterns visit every occurrence; except prevents that rule from applying.
|
|
152
|
+
const ignored = [
|
|
153
|
+
{ match: /[^aeiou]e(?=['’](?:s|d|ll|re|ve)$|$)/g, offset: 1, except: /[^aeiouyl]le(?:['’](?:s|d|ll|re|ve))?$/ }, // silent e
|
|
154
|
+
{ match: /[^td]ed(?=['’](?:s|d|ll|re|ve)$|$)/g, offset: 1, except: voicedEd }, // walked vs hundred/bubbled
|
|
155
|
+
{ match: /es(?=['’](?:s|d|ll|re|ve)$|$)/g, offset: 0, except: /([sxzcg]|ch|sh|[^aeiouyl]l)es(?:['’](?:s|d|ll|re|ve))?$/ }, // cakes vs boxes/tables
|
|
156
|
+
{
|
|
157
|
+
match: /e(?=ments?$|thing$|s?man$)/g,
|
|
158
|
+
offset: 0,
|
|
159
|
+
except: /(?:[^aeiouyl]le(?:ments?|thing|s?man)|elements?)$/,
|
|
160
|
+
}, // movement(s), but settlement(s) and element(s)
|
|
161
|
+
// Silent e stays silent before consonant suffixes, but syllabic -le survives.
|
|
162
|
+
{
|
|
163
|
+
match: /[^aeiou]e(?=ness(?:es)?$|l(?:y|iness)$|ful(?:ly)?$|less$|ships?$|some$)/g,
|
|
164
|
+
offset: 1,
|
|
165
|
+
except: /[^aeiouyl]le(?:ness(?:es)?|l(?:y|iness)|ful(?:ly)?|less|ships?|some)$/,
|
|
166
|
+
},
|
|
167
|
+
{ match: /['’][rv]e$/g, offset: 2 }, // we're, they've: suffix e adds no syllable
|
|
168
|
+
{ match: /qu(?=[aeiouy])/g, offset: 1 }, // consonantal u
|
|
169
|
+
{ match: /que(?=s?$|ly$|ness$)/g, offset: 2 }, // opaque, opaques, opaquely
|
|
170
|
+
{ match: /^y(?=[aeiou])/g, offset: 0 }, // yard, yellow
|
|
171
|
+
{ match: /[aeiouwy]y(?=[aeiou])/g, offset: 1 }, // beyond, lawyer, doubled y
|
|
172
|
+
{ match: /[aeiou]ny(?=[aeiou])/g, offset: 2, except: /^any/ }, // canyon, but anyone
|
|
173
|
+
{ match: /gu(?=a)/g, offset: 1 }, // guard, language
|
|
174
|
+
{ match: /busi(?=ness)/g, offset: 3 }, // businesses, agribusiness: the i stays silent
|
|
175
|
+
{ match: /ever(?=y(?:body|thing|where|one|day|time|$))/g, offset: 2 }, // everywhere, everyone
|
|
176
|
+
];
|
|
177
|
+
|
|
178
|
+
// Mc contributes a spoken syllable despite having no written vowel.
|
|
179
|
+
const syllabicPrefix = /^mc(?=[a-z])/;
|
|
180
|
+
|
|
181
|
+
// These consonants carry a syllable despite having no written vowel.
|
|
182
|
+
// The captured consonant starts the span; surrounding letters provide context.
|
|
183
|
+
const syllabicConsonants = [
|
|
184
|
+
/(?:[sxz]e?|[cs]h|[cg]e)['’](s)$/, // horse's, church's
|
|
185
|
+
/[ai]s(m)(?:s|['’]s)?$/, // chasm, criticism, and plurals/possessives
|
|
186
|
+
/[iy]th(m)(?:s|['’]s)?$/, // algorithm, rhythms
|
|
187
|
+
/[^aeiouyn](n)['’]t$/, // couldn't, wasn't; not can't or hasn't
|
|
188
|
+
/^(?:could|should|would|might|must)['’](v)e$/, // modal + have: would-'ve, unlike we've
|
|
189
|
+
];
|
|
190
|
+
|
|
191
|
+
// Keep a boundary after these matched prefixes, even inside a vowel group.
|
|
192
|
+
const separate = [
|
|
193
|
+
/^(?:re)?cre(?=at[eio])/, // create, recreate, recreation (but not creature)
|
|
194
|
+
/^re(?=act)/, // re-act and re-action
|
|
195
|
+
/^re(?=absorb|app|elect|emerg|enter|establish|evalu|impos|invent|issue|us[ei])/, // reuse, reenter; not reed
|
|
196
|
+
/^pre(?=adolesc|ambl|arrang|eminen|empt|exist|ignition|invas)/, // preempt, preexisting; not preen
|
|
197
|
+
/^(?:micro|macro)(?=[aeiou])/, // micro-organism, macro-economic
|
|
198
|
+
/^co(?=operat|ordinat|alesc|erc)/, // cooperate, coercion; not cooperage
|
|
199
|
+
/^pro(?=act)/, // proactive; not approach
|
|
200
|
+
/^flu(?=id)/, // flu-id, unlike fruit
|
|
201
|
+
/^ru(?=in)/, // ru-in and ru-ined
|
|
202
|
+
/di(?=et|en[ct])/, // diet, audience, obedient; not died or soldier
|
|
203
|
+
/^ide(?=a)/, // idea, ideal; not deal
|
|
204
|
+
/^sovi(?=et)/, // Soviet and its derivatives retain separate i and e sounds
|
|
205
|
+
/^soci(?=et)/, // society, societies, societal; not social
|
|
206
|
+
/i(?=ell[aoi])/, // Gabriella, Gabrielli, -iello names; not field
|
|
207
|
+
/i(?=et(?:te)?s?$)/, // Juliet, Henriette
|
|
208
|
+
/^qui(?=et)/, // quiet keeps its separate vowels in quietly and quieter
|
|
209
|
+
/^acre(?=age)/, // acreage retains the vowel of acre before -age
|
|
210
|
+
/^sci(?=on)/, // scion has two vowels, unlike the ending in vision
|
|
211
|
+
/^intra(?=[aeiou])/, // intrauterine, intraocular
|
|
212
|
+
/re(?=al(?!ly|m))/, // cereal, reality; not really or realm
|
|
213
|
+
/[^q]u(?=en[ctdz])/, // fluent, innuendo, influenza; not subsequent
|
|
214
|
+
/[^g]u(?=ing)/, // suing, continuing; not plaguing or intriguing
|
|
215
|
+
/argu(?=ing)/, // arguing retains its pronounced u
|
|
216
|
+
/^lou(?=is)/, // Louis, Louisville
|
|
217
|
+
/[eo]u(?=illes?$)/, // separate the vowel groups in -euille/-ouille
|
|
218
|
+
/ou(?=ett)/, // silhouette, pirouette: ou and e are separate vowels
|
|
219
|
+
/o(?=as?$)/, // boa, boas; not boat
|
|
220
|
+
];
|
|
221
|
+
|
|
222
|
+
// Match a bounded window around the current vowel: [i - before, i + after).
|
|
223
|
+
const joined = [
|
|
224
|
+
// Common vowel pairs share a span unless a separate rule overrides them.
|
|
225
|
+
{ before: 1, after: 1, match: /^(ai|au|ay|ea|ee|ei|eu|ey|ie|oa|oe|oi|oo|ou|oy|ue|ui)$/ },
|
|
226
|
+
{ before: 3, after: 2, match: /^llion/ }, // million, unlike lion
|
|
227
|
+
{ before: 2, after: 3, match: /^[cst]i(?:on|an|al|ous)/ }, // nation, special, musician
|
|
228
|
+
{ before: 2, after: 4, match: /^cia(?:s|['’]s)?$/ }, // final -cia blends; association and glaciation keep separate vowels
|
|
229
|
+
{ before: 2, after: 1, match: /^eau$/ }, // extend ea to eau in beautiful
|
|
230
|
+
{ before: 1, after: 3, match: /^ae(?:r|st)/ }, // aerospace, aesthetic
|
|
231
|
+
{ before: 2, after: 3, match: /^[cg]eous/ }, // gorgeous, herbaceous; not courteous
|
|
232
|
+
{ before: 3, after: 3, match: /^hteous/ }, // righteous and its suffixes
|
|
233
|
+
];
|
|
234
|
+
|
|
235
|
+
// Mark pronounced vowel groups before choosing any consonant boundaries.
|
|
236
|
+
const vowelSpan = word => {
|
|
237
|
+
// Record ignored positions without deleting letters or shifting offsets.
|
|
238
|
+
const silent = new Set();
|
|
239
|
+
// A silent e remains silent inside a recognized compound.
|
|
240
|
+
compoundPrefixes.forEach(pattern => {
|
|
241
|
+
const match = pattern.exec(word);
|
|
242
|
+
if (match) {
|
|
243
|
+
silent.add(match[0].lastIndexOf('e'));
|
|
244
|
+
}
|
|
245
|
+
});
|
|
246
|
+
ignored.forEach(rule => {
|
|
247
|
+
if (!rule.except || !rule.except.test(word)) {
|
|
248
|
+
for (const match of word.matchAll(rule.match)) {
|
|
249
|
+
silent.add(match.index + rule.offset);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
});
|
|
253
|
+
// Forced separations take precedence over the joined-vowel rules below.
|
|
254
|
+
const breaks = new Set();
|
|
255
|
+
separate.forEach(pattern => {
|
|
256
|
+
const match = pattern.exec(word);
|
|
257
|
+
if (match) {
|
|
258
|
+
breaks.add(match.index + match[0].length);
|
|
259
|
+
}
|
|
260
|
+
});
|
|
261
|
+
// Spans use slice-compatible offsets: start is inclusive, end is exclusive.
|
|
262
|
+
const groups = [];
|
|
263
|
+
const prefix = syllabicPrefix.exec(word);
|
|
264
|
+
if (prefix) {
|
|
265
|
+
groups.push({ start: 0, end: prefix[0].length });
|
|
266
|
+
}
|
|
267
|
+
// Mark the captured consonants without inserting letters or shifting offsets.
|
|
268
|
+
const voiced = new Set();
|
|
269
|
+
syllabicConsonants.forEach(pattern => {
|
|
270
|
+
const match = pattern.exec(word);
|
|
271
|
+
if (match) {
|
|
272
|
+
voiced.add(match.index + match[0].lastIndexOf(match[1]));
|
|
273
|
+
}
|
|
274
|
+
});
|
|
275
|
+
for (let i = 0; i < word.length; i += 1) {
|
|
276
|
+
if ((!isVowel.test(word[i]) && !voiced.has(i)) || silent.has(i)) {
|
|
277
|
+
continue
|
|
278
|
+
}
|
|
279
|
+
const previous = groups[groups.length - 1];
|
|
280
|
+
// Only adjacent pronounced vowels may extend an existing span.
|
|
281
|
+
const blended =
|
|
282
|
+
previous &&
|
|
283
|
+
previous.end === i &&
|
|
284
|
+
!breaks.has(i) &&
|
|
285
|
+
joined.some(rule => rule.match.test(word.slice(Math.max(0, i - rule.before), i + rule.after)));
|
|
286
|
+
if (blended) {
|
|
287
|
+
previous.end = i + 1;
|
|
288
|
+
} else {
|
|
289
|
+
groups.push({ start: i, end: i + 1 });
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
return groups
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
// Turn vowel spans into cut positions by assigning the consonants between them.
|
|
296
|
+
// Consonant clusters allowed to start a chunk; the picker checks longest first.
|
|
297
|
+
const onset = new Set([
|
|
298
|
+
'bl', 'br', 'ch', 'cl', 'cr', 'dr', 'fl', 'fr', 'gl', 'gr',
|
|
299
|
+
'ph', 'pl', 'pr', 'qu', 'sh', 'sk', 'sl', 'sm', 'sn', 'sp',
|
|
300
|
+
'st', 'sw', 'th', 'tr', 'tw', 'wh', 'scr', 'shr', 'spl', 'spr', 'str', 'thr',
|
|
301
|
+
]);
|
|
302
|
+
|
|
303
|
+
// Applied in order after onset placement; later rules take precedence.
|
|
304
|
+
// left/right are offsets from the surrounding vowel groups' edges.
|
|
305
|
+
// minLeft only moves a boundary forward, preserving a longer consonant cluster.
|
|
306
|
+
// gap matches intervening letters; tail matches the complete remaining spelling.
|
|
307
|
+
const adjustments = [
|
|
308
|
+
{ minGap: 1, maxGap: 1, gap: /^x$/, left: 1 }, // box-es
|
|
309
|
+
{ minGap: 2, gap: /^(.)\1/, minLeft: 1 }, // rab-bit
|
|
310
|
+
{ minGap: 2, tail: /^ed$/, right: -1 }, // res-ted
|
|
311
|
+
{ minGap: 2, gap: /^sh$/, tail: /^(es|ing)$/, right: 0 }, // wish-ing
|
|
312
|
+
{ minGap: 2, gap: /l$/, tail: /^e(?:[sd]|['’](?:s|d|ll|re|ve))?$/, right: -2 }, // bot-tle, ta-bles, bub-bled
|
|
313
|
+
{ minGap: 2, gap: /l$/, tail: /^e(?:ments?|ships?|ness(?:es)?|less|ful(?:ly)?|ly)(?:['’]s)?$/, right: -2 }, // set-tle-ment, bat-tle-ships
|
|
314
|
+
{ minGap: 1, gap: /l$/, tail: /^ess(?:ly|ness(?:es)?)?$/, right: -1 }, // help-less: keep the suffix intact
|
|
315
|
+
{ minGap: 3, gap: /^ssl$/, tail: /^y$/, right: -1 }, // help-less-ly, not help-les-sly
|
|
316
|
+
{ minGap: 1, gap: /^(?:s|th)$/, tail: /^m(?:s|['’]s)?$/, right: 0 }, // chas-m, rhyth-m
|
|
317
|
+
{ minGap: 1, tail: /^n['’]t$/, right: 0 }, // could-n't: keep the base consonants together
|
|
318
|
+
{ minGap: 1, gap: /^[-‐‑–—]+$/, right: 0 }, // re-enter: keep the hyphen on the left
|
|
319
|
+
];
|
|
320
|
+
|
|
321
|
+
// Choose one cut between each pair of vowel spans, preserving their count.
|
|
322
|
+
const pickBoundaries = (word, vowelSpans) => {
|
|
323
|
+
const cuts = [];
|
|
324
|
+
// Keep recognized compound components intact, such as side-walk and some-one.
|
|
325
|
+
const compoundCuts = compoundPrefixes.flatMap(pattern => {
|
|
326
|
+
const match = pattern.exec(word);
|
|
327
|
+
return match ? [match[0].length] : []
|
|
328
|
+
});
|
|
329
|
+
for (let i = 1; i < vowelSpans.length; i += 1) {
|
|
330
|
+
// The gap contains the letters not assigned to either vowel span.
|
|
331
|
+
const left = vowelSpans[i - 1].end;
|
|
332
|
+
const right = vowelSpans[i].start;
|
|
333
|
+
const gap = word.slice(left, right);
|
|
334
|
+
// Match full suffixes so long endings are not mistaken for shorter ones.
|
|
335
|
+
const tail = word.slice(right);
|
|
336
|
+
// Adjacent vowels split directly; a single consonant starts the next chunk.
|
|
337
|
+
let cut = left;
|
|
338
|
+
if (gap.length > 1) {
|
|
339
|
+
cut = right - 1;
|
|
340
|
+
// Prefer the longest recognized consonant cluster at the next chunk's start.
|
|
341
|
+
for (let size = Math.min(3, gap.length); size > 1; size -= 1) {
|
|
342
|
+
if (onset.has(gap.slice(-size))) {
|
|
343
|
+
cut = right - size;
|
|
344
|
+
break
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
// Apply every matching adjustment in order, allowing specific rules to override.
|
|
349
|
+
adjustments.forEach(rule => {
|
|
350
|
+
if (
|
|
351
|
+
gap.length < rule.minGap ||
|
|
352
|
+
(rule.maxGap !== undefined && gap.length > rule.maxGap) ||
|
|
353
|
+
(rule.gap && !rule.gap.test(gap)) ||
|
|
354
|
+
(rule.tail && !rule.tail.test(tail))
|
|
355
|
+
) {
|
|
356
|
+
return
|
|
357
|
+
}
|
|
358
|
+
// Exact offsets replace the cut; minLeft only advances an earlier cut.
|
|
359
|
+
if (rule.left !== undefined) {
|
|
360
|
+
cut = left + rule.left;
|
|
361
|
+
} else if (rule.right !== undefined) {
|
|
362
|
+
cut = right + rule.right;
|
|
363
|
+
} else {
|
|
364
|
+
cut = Math.max(cut, left + rule.minLeft);
|
|
365
|
+
}
|
|
366
|
+
});
|
|
367
|
+
// A compound boundary overrides ordinary consonant placement within this gap.
|
|
368
|
+
compoundCuts.forEach(boundary => {
|
|
369
|
+
if (boundary >= left && boundary <= right) {
|
|
370
|
+
cut = boundary;
|
|
371
|
+
}
|
|
372
|
+
});
|
|
373
|
+
cuts.push(cut);
|
|
374
|
+
}
|
|
375
|
+
return cuts
|
|
376
|
+
};
|
|
377
|
+
|
|
378
|
+
const findCuts = str => {
|
|
379
|
+
// Exact exceptions and their plural forms take priority over spelling rules.
|
|
380
|
+
const found = checkExceptions(str);
|
|
381
|
+
if (found !== null) {
|
|
382
|
+
return found
|
|
383
|
+
}
|
|
384
|
+
// Keep apostrophe suffixes intact so vowel rules can recognize an extra syllable.
|
|
385
|
+
const vowelSpans = vowelSpan(str);
|
|
386
|
+
const cuts = pickBoundaries(str, vowelSpans);
|
|
387
|
+
// An exception may supply base-word cuts without overriding a syllabic suffix.
|
|
388
|
+
const key = str.replace(/['’](?:s|d|ll|re|ve)$/, '');
|
|
389
|
+
// Without a suffix, the initial lookup already checked this word.
|
|
390
|
+
if (key === str) {
|
|
391
|
+
return cuts
|
|
392
|
+
}
|
|
393
|
+
const baseCuts = checkExceptions(key);
|
|
394
|
+
if (baseCuts !== null) {
|
|
395
|
+
return baseCuts.concat(cuts.filter(cut => cut >= key.length))
|
|
396
|
+
}
|
|
397
|
+
return cuts
|
|
398
|
+
};
|
|
399
|
+
|
|
400
|
+
const trailingPunct = /(?<!\p{Punctuation})\p{Punctuation}+$/u;
|
|
401
|
+
const leadingPunct = /^[\p{Punctuation}$]+/u;
|
|
402
|
+
|
|
403
|
+
// Prepare spelling for rule matching; cuts are still applied to the original text.
|
|
404
|
+
const normalize = input => {
|
|
405
|
+
let txt = input.toLowerCase();
|
|
406
|
+
// Ignore trailing punctuation runs for analysis; internal apostrophes stay intact.
|
|
407
|
+
txt = txt.replace(trailingPunct, '');
|
|
408
|
+
// Keep the prefix length so cuts still align with the original spelling.
|
|
409
|
+
const prefix = txt.match(leadingPunct)?.[0] || '';
|
|
410
|
+
const clean = txt.slice(prefix.length);
|
|
411
|
+
return { clean, prefix }
|
|
412
|
+
};
|
|
413
|
+
|
|
414
|
+
const pushCutsBack = (cuts, prefix = '') => {
|
|
415
|
+
return cuts.map(cut => cut + prefix.length)
|
|
416
|
+
};
|
|
417
|
+
|
|
418
|
+
// split a string into chunks based on the given cuts
|
|
419
|
+
const applyCuts = (text, cuts) => {
|
|
420
|
+
let start = 0;
|
|
421
|
+
const parts = cuts.map(end => {
|
|
422
|
+
const part = text.slice(start, end);
|
|
423
|
+
start = end;
|
|
424
|
+
return part
|
|
425
|
+
});
|
|
426
|
+
// Cuts are interior boundaries; the final chunk always runs to the text's end.
|
|
427
|
+
parts.push(text.slice(start));
|
|
428
|
+
return parts
|
|
429
|
+
};
|
|
430
|
+
|
|
431
|
+
// Pass the original term text through; the tokenizer owns pre/post punctuation.
|
|
432
|
+
const splitTerm = (term, options = {}) => {
|
|
433
|
+
if (!term.text) {
|
|
434
|
+
return []
|
|
435
|
+
}
|
|
436
|
+
let str = term.text;
|
|
437
|
+
if (options.normalize) {
|
|
438
|
+
str = term.normal || term.text;
|
|
439
|
+
}
|
|
440
|
+
// we can split on a normalized string
|
|
441
|
+
const { clean, prefix } = normalize(str);
|
|
442
|
+
let cuts = findCuts(clean);
|
|
443
|
+
if (prefix && prefix.length > 0) {
|
|
444
|
+
cuts = pushCutsBack(cuts, prefix);
|
|
445
|
+
}
|
|
446
|
+
return applyCuts(str, cuts)
|
|
447
|
+
};
|
|
448
|
+
|
|
449
|
+
// Store syllables on each term; the public API collects them by sentence.
|
|
450
|
+
const syllables = function (view, options = {}) {
|
|
451
|
+
view.docs.forEach(terms => {
|
|
452
|
+
terms.forEach(term => {
|
|
453
|
+
term.syllables = splitTerm(term, options);
|
|
454
|
+
});
|
|
455
|
+
});
|
|
456
|
+
};
|
|
457
|
+
|
|
7
458
|
//individual manipulations of the text
|
|
8
459
|
const transformations = {
|
|
9
460
|
dedup: (s) => {
|
|
10
461
|
return s.replace(/([^c])\1/g, '$1')
|
|
11
462
|
},
|
|
12
463
|
dropInitialLetters: (s) => {
|
|
13
|
-
if (
|
|
464
|
+
if (/^(kn|gn|pn|ae|wr)/.test(s)) {
|
|
14
465
|
return s.substring(1, s.length - 1)
|
|
15
466
|
}
|
|
16
467
|
return s
|
|
@@ -86,299 +537,52 @@
|
|
|
86
537
|
return s //.charAt(0) + s.substr(1, s.length).replace(/[aeiou]/g, '');
|
|
87
538
|
},
|
|
88
539
|
};
|
|
89
|
-
var m = transformations;
|
|
90
540
|
|
|
91
541
|
//a js version of the metaphone (#1) algorithm
|
|
542
|
+
//adapted from the work of Chris Umbel
|
|
543
|
+
// https://github.com/NaturalNode/natural/blob/master/lib/natural/phonetics/metaphone.js
|
|
544
|
+
|
|
92
545
|
|
|
93
546
|
const metaphone = function (s) {
|
|
94
|
-
s =
|
|
95
|
-
s =
|
|
96
|
-
s =
|
|
97
|
-
s =
|
|
98
|
-
s =
|
|
99
|
-
s =
|
|
100
|
-
s =
|
|
101
|
-
s =
|
|
102
|
-
s =
|
|
103
|
-
s =
|
|
104
|
-
s =
|
|
105
|
-
s =
|
|
106
|
-
s =
|
|
107
|
-
s =
|
|
108
|
-
s =
|
|
109
|
-
s =
|
|
110
|
-
s =
|
|
111
|
-
s =
|
|
112
|
-
s =
|
|
113
|
-
s =
|
|
114
|
-
s =
|
|
547
|
+
s = transformations.dedup(s);
|
|
548
|
+
s = transformations.dropInitialLetters(s);
|
|
549
|
+
s = transformations.dropBafterMAtEnd(s);
|
|
550
|
+
s = transformations.changeCK(s);
|
|
551
|
+
s = transformations.cchange(s);
|
|
552
|
+
s = transformations.dchange(s);
|
|
553
|
+
s = transformations.dropG(s);
|
|
554
|
+
s = transformations.changeG(s);
|
|
555
|
+
s = transformations.dropH(s);
|
|
556
|
+
s = transformations.changePH(s);
|
|
557
|
+
s = transformations.changeQ(s);
|
|
558
|
+
s = transformations.changeS(s);
|
|
559
|
+
s = transformations.changeX(s);
|
|
560
|
+
s = transformations.changeT(s);
|
|
561
|
+
s = transformations.dropT(s);
|
|
562
|
+
s = transformations.changeV(s);
|
|
563
|
+
s = transformations.changeWH(s);
|
|
564
|
+
s = transformations.dropW(s);
|
|
565
|
+
s = transformations.dropY(s);
|
|
566
|
+
s = transformations.changeZ(s);
|
|
567
|
+
s = transformations.dropVowels(s);
|
|
115
568
|
return s.trim()
|
|
116
569
|
};
|
|
117
570
|
|
|
118
|
-
var metaphone$1 = metaphone;
|
|
119
|
-
|
|
120
571
|
const soundsLike = function (view) {
|
|
121
572
|
view.docs.forEach(terms => {
|
|
122
573
|
terms.forEach(term => {
|
|
123
|
-
term.soundsLike = metaphone
|
|
574
|
+
term.soundsLike = metaphone(term.normal || term.text);
|
|
124
575
|
});
|
|
125
576
|
});
|
|
126
577
|
};
|
|
127
578
|
|
|
128
|
-
var soundsLike$1 = soundsLike;
|
|
129
|
-
|
|
130
|
-
const starts_with_single_vowel_combos = /^(eu)/i;
|
|
131
|
-
const joining_consonant_vowel = /^[^aeiou]e([^d]|$)/;
|
|
132
|
-
const cvcv_same_consonant = /^([^aeiouy])[aeiouy]\1[aeiouy]/;
|
|
133
|
-
const cvcv_same_vowel = /^[^aeiouy]([aeiouy])[^aeiouy]\1/;
|
|
134
|
-
const cvcv_known_consonants = /^([tg][aeiouy]){2}/;
|
|
135
|
-
const only_one_or_more_c = /^[^aeiouy]+$/;
|
|
136
|
-
|
|
137
|
-
const ends_with_vowel$1 = /[aeiouy]$/;
|
|
138
|
-
const starts_with_consonant_vowel$1 = /^[^aeiouy]h?[aeiouy]/;
|
|
139
|
-
|
|
140
|
-
const ones = [
|
|
141
|
-
/^[^aeiou]?ion/,
|
|
142
|
-
/^[^aeiou]?ised/,
|
|
143
|
-
/^[^aeiou]?iled/,
|
|
144
|
-
|
|
145
|
-
// -ing, -ent
|
|
146
|
-
/[aeiou]n[gt]$/,
|
|
147
|
-
|
|
148
|
-
// -ate, -age
|
|
149
|
-
/\wa[gt]e$/,
|
|
150
|
-
];
|
|
151
|
-
|
|
152
|
-
//suffix fixes
|
|
153
|
-
const postprocess = function (arr) {
|
|
154
|
-
//trim whitespace
|
|
155
|
-
arr = arr.map(function (w) {
|
|
156
|
-
return w.trim()
|
|
157
|
-
});
|
|
158
|
-
arr = arr.filter(function (w) {
|
|
159
|
-
return w !== ''
|
|
160
|
-
});
|
|
161
|
-
// if (arr.length > 2) {
|
|
162
|
-
// return arr;
|
|
163
|
-
// }
|
|
164
|
-
let l = arr.length;
|
|
165
|
-
if (l > 1) {
|
|
166
|
-
let suffix = arr[l - 2] + arr[l - 1];
|
|
167
|
-
for (let i = 0; i < ones.length; i++) {
|
|
168
|
-
if (suffix.match(ones[i])) {
|
|
169
|
-
arr[l - 2] = arr[l - 2] + arr[l - 1];
|
|
170
|
-
arr.pop();
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
// since the open syllable detection is overzealous,
|
|
176
|
-
// sometimes need to rejoin incorrect splits
|
|
177
|
-
if (arr.length > 1) {
|
|
178
|
-
let first_is_open =
|
|
179
|
-
(arr[0].length === 1 || arr[0].match(starts_with_consonant_vowel$1)) &&
|
|
180
|
-
arr[0].match(ends_with_vowel$1);
|
|
181
|
-
let second_is_joining = arr[1].match(joining_consonant_vowel);
|
|
182
|
-
|
|
183
|
-
if (first_is_open && second_is_joining) {
|
|
184
|
-
let possible_combination = arr[0] + arr[1];
|
|
185
|
-
let probably_separate_syllables =
|
|
186
|
-
possible_combination.match(cvcv_same_consonant) ||
|
|
187
|
-
possible_combination.match(cvcv_same_vowel) ||
|
|
188
|
-
possible_combination.match(cvcv_known_consonants);
|
|
189
|
-
|
|
190
|
-
if (!probably_separate_syllables) {
|
|
191
|
-
arr[0] = arr[0] + arr[1];
|
|
192
|
-
arr.splice(1, 1);
|
|
193
|
-
}
|
|
194
|
-
}
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
if (arr.length > 1) {
|
|
198
|
-
let second_to_last_is_open =
|
|
199
|
-
arr[arr.length - 2].match(starts_with_consonant_vowel$1) &&
|
|
200
|
-
arr[arr.length - 2].match(ends_with_vowel$1);
|
|
201
|
-
let last_is_joining =
|
|
202
|
-
arr[arr.length - 1].match(joining_consonant_vowel) &&
|
|
203
|
-
ones.every(re => !arr[arr.length - 1].match(re));
|
|
204
|
-
|
|
205
|
-
if (second_to_last_is_open && last_is_joining) {
|
|
206
|
-
let possible_combination = arr[arr.length - 2] + arr[arr.length - 1];
|
|
207
|
-
let probably_separate_syllables =
|
|
208
|
-
possible_combination.match(cvcv_same_consonant) ||
|
|
209
|
-
possible_combination.match(cvcv_same_vowel) ||
|
|
210
|
-
possible_combination.match(cvcv_known_consonants);
|
|
211
|
-
|
|
212
|
-
if (!probably_separate_syllables) {
|
|
213
|
-
arr[arr.length - 2] = arr[arr.length - 2] + arr[arr.length - 1];
|
|
214
|
-
arr.splice(arr.length - 1, 1);
|
|
215
|
-
}
|
|
216
|
-
}
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
if (arr.length > 1) {
|
|
220
|
-
let single = arr[0] + arr[1];
|
|
221
|
-
if (single.match(starts_with_single_vowel_combos)) {
|
|
222
|
-
arr[0] = single;
|
|
223
|
-
arr.splice(1, 1);
|
|
224
|
-
}
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
if (arr.length > 1) {
|
|
228
|
-
if (arr[arr.length - 1].match(only_one_or_more_c)) {
|
|
229
|
-
arr[arr.length - 2] = arr[arr.length - 2] + arr[arr.length - 1];
|
|
230
|
-
arr.splice(arr.length - 1, 1);
|
|
231
|
-
}
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
return arr
|
|
235
|
-
};
|
|
236
|
-
var postProcess = postprocess;
|
|
237
|
-
|
|
238
|
-
//chop a string into pronounced syllables
|
|
239
|
-
|
|
240
|
-
const all_spaces = / +/g;
|
|
241
|
-
const ends_with_vowel = /[aeiouy]$/;
|
|
242
|
-
const starts_with_consonant_vowel = /^[^aeiouy]h?[aeiouy]/;
|
|
243
|
-
const starts_with_e_then_specials = /^e[sm]/;
|
|
244
|
-
const starts_with_e = /^e/;
|
|
245
|
-
const ends_with_noisy_vowel_combos = /(eo|eu|ia|oa|ua|ui)$/i;
|
|
246
|
-
const aiouy = /[aiouy]/;
|
|
247
|
-
const ends_with_ee = /ee$/;
|
|
248
|
-
// const whitespace_dash = /\s\-/
|
|
249
|
-
|
|
250
|
-
//method is nested because it's called recursively
|
|
251
|
-
const doWord = function (w) {
|
|
252
|
-
let all = [];
|
|
253
|
-
let chars = w.split('');
|
|
254
|
-
let before = '';
|
|
255
|
-
let after = '';
|
|
256
|
-
let current = '';
|
|
257
|
-
for (let i = 0; i < chars.length; i++) {
|
|
258
|
-
before = chars.slice(0, i).join('');
|
|
259
|
-
current = chars[i];
|
|
260
|
-
after = chars.slice(i + 1, chars.length).join('');
|
|
261
|
-
let candidate = before + chars[i];
|
|
262
|
-
|
|
263
|
-
//it's a consonant that comes after a vowel
|
|
264
|
-
if (before.match(ends_with_vowel) && !current.match(ends_with_vowel)) {
|
|
265
|
-
if (after.match(starts_with_e_then_specials)) {
|
|
266
|
-
candidate += 'e';
|
|
267
|
-
after = after.replace(starts_with_e, '');
|
|
268
|
-
}
|
|
269
|
-
all.push(candidate);
|
|
270
|
-
return all.concat(doWord(after))
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
//unblended vowels ('noisy' vowel combinations)
|
|
274
|
-
if (candidate.match(ends_with_noisy_vowel_combos)) {
|
|
275
|
-
//'io' is noisy, not in 'ion'
|
|
276
|
-
all.push(before);
|
|
277
|
-
all.push(current);
|
|
278
|
-
return all.concat(doWord(after)) //recursion
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
// if candidate is followed by a CV, assume consecutive open syllables
|
|
282
|
-
if (candidate.match(ends_with_vowel) && after.match(starts_with_consonant_vowel)) {
|
|
283
|
-
all.push(candidate);
|
|
284
|
-
return all.concat(doWord(after))
|
|
285
|
-
}
|
|
286
|
-
}
|
|
287
|
-
//if still running, end last syllable
|
|
288
|
-
if (w.match(aiouy) || w.match(ends_with_ee)) {
|
|
289
|
-
//allow silent trailing e
|
|
290
|
-
all.push(w);
|
|
291
|
-
} else if (w) {
|
|
292
|
-
let last = all.length - 1;
|
|
293
|
-
if (last < 0) {
|
|
294
|
-
last = 0;
|
|
295
|
-
}
|
|
296
|
-
all[last] = (all[last] || '') + w; //append it to the last one
|
|
297
|
-
}
|
|
298
|
-
return all
|
|
299
|
-
};
|
|
300
|
-
|
|
301
|
-
let syllables$2 = function (str) {
|
|
302
|
-
let all = [];
|
|
303
|
-
if (!str) {
|
|
304
|
-
return all
|
|
305
|
-
}
|
|
306
|
-
str = str.replace(/[.,?]/g, '');
|
|
307
|
-
str.split(all_spaces).map(s => {
|
|
308
|
-
all = all.concat(doWord(s));
|
|
309
|
-
});
|
|
310
|
-
|
|
311
|
-
// str.split(whitespace_dash).forEach(doWord)
|
|
312
|
-
all = postProcess(all);
|
|
313
|
-
|
|
314
|
-
//for words like 'tree' and 'free'
|
|
315
|
-
if (all.length === 0) {
|
|
316
|
-
all = [str];
|
|
317
|
-
}
|
|
318
|
-
//filter blanks
|
|
319
|
-
all = all.filter(s => s);
|
|
320
|
-
|
|
321
|
-
return all
|
|
322
|
-
};
|
|
323
|
-
|
|
324
|
-
// console.log(syllables('civilised'))
|
|
325
|
-
|
|
326
|
-
var getSyllables = syllables$2;
|
|
327
|
-
|
|
328
|
-
// const defaultObj = { normal: true, text: true, terms: false }
|
|
329
|
-
|
|
330
|
-
const syllables = function (view) {
|
|
331
|
-
view.docs.forEach(terms => {
|
|
332
|
-
terms.forEach(term => {
|
|
333
|
-
term.syllables = getSyllables(term.normal || term.text);
|
|
334
|
-
});
|
|
335
|
-
});
|
|
336
|
-
};
|
|
337
|
-
|
|
338
|
-
var syllables$1 = syllables;
|
|
339
|
-
|
|
340
|
-
var compute = {
|
|
341
|
-
soundsLike: soundsLike$1,
|
|
342
|
-
syllables: syllables$1
|
|
343
|
-
};
|
|
344
|
-
|
|
345
|
-
const api = function (View) {
|
|
346
|
-
/** */
|
|
347
|
-
View.prototype.syllables = function () {
|
|
348
|
-
this.compute('syllables');
|
|
349
|
-
let all = [];
|
|
350
|
-
this.docs.forEach(terms => {
|
|
351
|
-
let some = [];
|
|
352
|
-
terms.forEach(term => {
|
|
353
|
-
some = some.concat(term.syllables);
|
|
354
|
-
});
|
|
355
|
-
if (some.length > 0) {
|
|
356
|
-
all.push(some);
|
|
357
|
-
}
|
|
358
|
-
});
|
|
359
|
-
return all
|
|
360
|
-
};
|
|
361
|
-
/** */
|
|
362
|
-
View.prototype.soundsLike = function () {
|
|
363
|
-
this.compute('soundsLike');
|
|
364
|
-
let all = [];
|
|
365
|
-
this.docs.forEach(terms => {
|
|
366
|
-
let some = [];
|
|
367
|
-
terms.forEach(term => {
|
|
368
|
-
some = some.concat(term.soundsLike);
|
|
369
|
-
});
|
|
370
|
-
if (some.length > 0) {
|
|
371
|
-
all.push(some);
|
|
372
|
-
}
|
|
373
|
-
});
|
|
374
|
-
return all
|
|
375
|
-
};
|
|
376
|
-
};
|
|
377
|
-
var api$1 = api;
|
|
378
|
-
|
|
379
579
|
var plugin = {
|
|
380
|
-
|
|
381
|
-
|
|
580
|
+
version,
|
|
581
|
+
api,
|
|
582
|
+
compute: {
|
|
583
|
+
syllables,
|
|
584
|
+
soundsLike,
|
|
585
|
+
},
|
|
382
586
|
};
|
|
383
587
|
|
|
384
588
|
return plugin;
|