@sabiq/utils 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 (35) hide show
  1. package/LICENSE.md +21 -0
  2. package/README.md +2 -0
  3. package/dist/cjs/hooks/index.d.ts +1 -0
  4. package/dist/cjs/hooks/index.js +4 -0
  5. package/dist/cjs/hooks/useRecitationValidator.d.ts +25 -0
  6. package/dist/cjs/hooks/useRecitationValidator.js +392 -0
  7. package/dist/cjs/index.d.ts +2 -0
  8. package/dist/cjs/index.js +5 -0
  9. package/dist/cjs/utils/arabic.d.ts +2 -0
  10. package/dist/cjs/utils/arabic.js +33 -0
  11. package/dist/cjs/utils/index.d.ts +4 -0
  12. package/dist/cjs/utils/index.js +7 -0
  13. package/dist/cjs/utils/levenshtein.d.ts +2 -0
  14. package/dist/cjs/utils/levenshtein.js +32 -0
  15. package/dist/cjs/utils/validation.d.ts +28 -0
  16. package/dist/cjs/utils/validation.js +241 -0
  17. package/dist/cjs/utils/wordAlternatives.d.ts +41 -0
  18. package/dist/cjs/utils/wordAlternatives.js +230 -0
  19. package/dist/esm/hooks/index.d.ts +1 -0
  20. package/dist/esm/hooks/index.js +1 -0
  21. package/dist/esm/hooks/useRecitationValidator.d.ts +25 -0
  22. package/dist/esm/hooks/useRecitationValidator.js +388 -0
  23. package/dist/esm/index.d.ts +2 -0
  24. package/dist/esm/index.js +2 -0
  25. package/dist/esm/utils/arabic.d.ts +2 -0
  26. package/dist/esm/utils/arabic.js +28 -0
  27. package/dist/esm/utils/index.d.ts +4 -0
  28. package/dist/esm/utils/index.js +4 -0
  29. package/dist/esm/utils/levenshtein.d.ts +2 -0
  30. package/dist/esm/utils/levenshtein.js +27 -0
  31. package/dist/esm/utils/validation.d.ts +28 -0
  32. package/dist/esm/utils/validation.js +236 -0
  33. package/dist/esm/utils/wordAlternatives.d.ts +41 -0
  34. package/dist/esm/utils/wordAlternatives.js +226 -0
  35. package/package.json +58 -0
@@ -0,0 +1,388 @@
1
+ import { useCallback, useMemo, useReducer, useState } from "react";
2
+ import { defaultThreshold, validateRecitation, } from "../utils/validation";
3
+ import { getAlternativesFor, wordAlternatives, } from "../utils/wordAlternatives";
4
+ import { levenshteinDistance } from "../utils/levenshtein";
5
+ import { normalizeArabic } from "../utils/arabic";
6
+ /** Maximum recursion depth for split-driven re-processing. */
7
+ const SPLIT_DEPTH_LIMIT = 5;
8
+ /* ------------------------------------------------------------------ */
9
+ /* Anchor detection */
10
+ /* ------------------------------------------------------------------ */
11
+ /** Minimum tokens we need before attempting position-finding. Below this
12
+ * the buffer just holds. Single-token anchoring is too easy to fool. */
13
+ const MIN_ANCHOR_TOKENS = 2;
14
+ /** Required count of consecutive matching expected-words from a candidate
15
+ * position. Two is the practical floor — common short words match
16
+ * everywhere, so we need at least a pair of aligned hits. */
17
+ const MIN_ANCHOR_CONSECUTIVE = 2;
18
+ /** Required total normalized-character length of the matched run.
19
+ * Filters out "في ما"-style runs of two very short words coincidentally
20
+ * matching in multiple page locations. */
21
+ const MIN_ANCHOR_TOTAL_SCORE = 6;
22
+ /** Cap on how many recent tokens we keep buffered while searching.
23
+ * Older tokens slide off so an unanchored session doesn't grow without
24
+ * bound. Also defines the point at which we stop deferring ambiguous
25
+ * ties and just commit to the first candidate. */
26
+ const MAX_ANCHOR_BUFFER = 8;
27
+ /** Re-anchor (mid-recitation) search radius. After the very first anchor
28
+ * has been found, subsequent re-anchors limit their scan to this many
29
+ * positions ahead of currentIndex. This prevents the dreaded backward
30
+ * jump (which would behave like the removed "rewind" feature) and also
31
+ * keeps the scan cheap once we know roughly where the user is. */
32
+ const SEARCH_FORWARD_WINDOW = 10;
33
+ /**
34
+ * Find the best position in `expected[searchStart, searchEnd)` where the
35
+ * buffered tokens align as a consecutive run.
36
+ *
37
+ * Returns null if no candidate clears the thresholds, OR if the top score
38
+ * is tied across multiple positions and we still have room in the buffer
39
+ * to wait for a disambiguating token.
40
+ *
41
+ * Reuses validateRecitation per (token, position) probe so muqatta'at
42
+ * and verse-scoped alternatives are honored during anchor detection too.
43
+ */
44
+ function findAnchor(tokens, expected, alternatives, searchStart, searchEnd) {
45
+ if (tokens.length < MIN_ANCHOR_TOKENS)
46
+ return null;
47
+ if (searchStart >= searchEnd)
48
+ return null;
49
+ const candidates = [];
50
+ for (let i = searchStart; i < searchEnd; i++) {
51
+ let consecutive = 0;
52
+ let score = 0;
53
+ for (let j = 0; j < tokens.length && i + j < expected.length; j++) {
54
+ const res = validateRecitation(tokens[j], expected, i + j, alternatives, null);
55
+ if (res.status === "correct" || res.status === "split") {
56
+ consecutive++;
57
+ score += normalizeArabic(expected[i + j].text).length;
58
+ }
59
+ else {
60
+ break;
61
+ }
62
+ }
63
+ if (consecutive >= MIN_ANCHOR_CONSECUTIVE &&
64
+ score >= MIN_ANCHOR_TOTAL_SCORE) {
65
+ candidates.push({ index: i, matchedCount: consecutive, score });
66
+ }
67
+ }
68
+ if (candidates.length === 0)
69
+ return null;
70
+ candidates.sort((a, b) => b.score - a.score || a.index - b.index);
71
+ const top = candidates[0];
72
+ // Ambiguity defer: multiple positions tied at top? Wait for another
73
+ // token unless the buffer is already at capacity.
74
+ const tied = candidates.filter((c) => c.score === top.score);
75
+ if (tied.length > 1 && tokens.length < MAX_ANCHOR_BUFFER) {
76
+ return null;
77
+ }
78
+ return top;
79
+ }
80
+ /* ------------------------------------------------------------------ */
81
+ /* Logging */
82
+ /* ------------------------------------------------------------------ */
83
+ function logDecision(spoken, target, result, alts) {
84
+ const icon = result.status === "wrong"
85
+ ? "❌"
86
+ : result.status === "split"
87
+ ? "✂️"
88
+ : result.status === "merge"
89
+ ? "🔗"
90
+ : "";
91
+ const color = result.status === "wrong"
92
+ ? "#d33"
93
+ : result.status === "split"
94
+ ? "#a60"
95
+ : result.status === "merge"
96
+ ? "#060"
97
+ : "green";
98
+ const headerSuffix = result.status === "split"
99
+ ? ` → split, remainder "${result.remainder}"`
100
+ : result.status === "merge"
101
+ ? ` → merged with previous`
102
+ : "";
103
+ console.groupCollapsed(`%c${icon} ${result.status.toUpperCase()} at ${target.surah}:${target.verse} (key ${target.key}) — "${spoken}" vs "${target.text}"${headerSuffix}`, `color: ${color}; font-weight: bold;`);
104
+ const spokenNorm = normalizeArabic(spoken);
105
+ const expectedNorm = normalizeArabic(target.text);
106
+ console.log(`Expected: "${target.text}" → norm "${expectedNorm}" (${expectedNorm.length} chars)`);
107
+ console.log(`Spoken: "${spoken}" → norm "${spokenNorm}" (${spokenNorm.length} chars)`);
108
+ const mainDist = levenshteinDistance(spokenNorm, expectedNorm);
109
+ const mainAllowed = defaultThreshold(expectedNorm);
110
+ console.log(`MAIN: distance ${mainDist} / allowed ${mainAllowed} → ${mainDist <= mainAllowed ? "✓ MATCH" : "✗"}`);
111
+ if (alts.length === 0) {
112
+ console.log("ALTERNATIVES: none in scope");
113
+ }
114
+ else {
115
+ console.log(`ALTERNATIVES (${alts.length} in scope):`);
116
+ for (const alt of alts) {
117
+ const altNorm = normalizeArabic(alt);
118
+ const aDist = levenshteinDistance(spokenNorm, altNorm);
119
+ const aAllowed = defaultThreshold(altNorm);
120
+ console.log(` • "${alt}" → norm "${altNorm}", distance ${aDist} / allowed ${aAllowed} → ${aDist <= aAllowed ? "✓ MATCH" : "✗"}`);
121
+ }
122
+ }
123
+ console.log(`→ verdict: ${result.status}, advance: ${result.advance}, newBuffer: "${result.newBuffer}"${result.remainder != null ? `, remainder: "${result.remainder}"` : ""}`);
124
+ console.groupEnd();
125
+ }
126
+ const initial = {
127
+ currentIndex: 0,
128
+ buffer: "",
129
+ statuses: new Map(),
130
+ lastCorrectToken: null,
131
+ anchorPending: true,
132
+ anchorTokens: [],
133
+ };
134
+ /* ------------------------------------------------------------------ */
135
+ /* Token processing */
136
+ /* ------------------------------------------------------------------ */
137
+ /**
138
+ * Process one token. Returns the new committed state.
139
+ *
140
+ * Status handling:
141
+ * - pending: hold the buffer, don't advance
142
+ * - split: consume prefix as current word, advance, recursively re-process
143
+ * the remainder (capped by SPLIT_DEPTH_LIMIT)
144
+ * - merge: token consumed as continuation of previous correct word;
145
+ * cursor unchanged, lastCorrectToken extended for chained merges
146
+ * - correct: mark and advance, update lastCorrectToken
147
+ * - wrong: mark, advance, clear lastCorrectToken, AND flip anchorPending
148
+ * true so the next token re-syncs to where the user actually is
149
+ */
150
+ function processToken(state, token, expected, alternatives, splitDepth = 0) {
151
+ if (state.currentIndex >= expected.length)
152
+ return state;
153
+ const target = expected[state.currentIndex];
154
+ const newBuffer = state.buffer ? `${state.buffer} ${token}` : token;
155
+ const result = validateRecitation(newBuffer, expected, state.currentIndex, alternatives, state.lastCorrectToken);
156
+ const altsInScope = getAlternativesFor(target.text, target.surah, target.verse, alternatives);
157
+ logDecision(newBuffer, target, result, altsInScope);
158
+ if (result.status === "pending") {
159
+ return Object.assign(Object.assign({}, state), { buffer: result.newBuffer });
160
+ }
161
+ if (result.status === "merge") {
162
+ const prevWord = expected[state.currentIndex - 1];
163
+ console.log(`%c🔗 MERGE at idx ${state.currentIndex}: "${state.lastCorrectToken}" + "${newBuffer}" completes prev word "${prevWord === null || prevWord === void 0 ? void 0 : prevWord.text}" — token consumed without advance`, "background: #efe; color: #060; padding: 2px 6px; font-weight: bold;");
164
+ return Object.assign(Object.assign({}, state), { buffer: "", lastCorrectToken: state.lastCorrectToken
165
+ ? `${state.lastCorrectToken} ${newBuffer}`
166
+ : newBuffer });
167
+ }
168
+ if (result.status === "split" && result.remainder != null) {
169
+ if (splitDepth >= SPLIT_DEPTH_LIMIT) {
170
+ console.warn(`%c⚠ split depth limit hit at idx ${state.currentIndex} — treating as wrong`, "color: #c80;");
171
+ const nextStatuses = new Map(state.statuses);
172
+ nextStatuses.set(target.key, "wrong");
173
+ console.log(`%c⟳ RE-ANCHOR triggered after wrong at idx ${state.currentIndex} (split-depth) — forward window from idx ${state.currentIndex + 1}`, "background: #fef5e0; color: #a60; padding: 2px 6px; font-weight: bold;");
174
+ return Object.assign(Object.assign({}, state), { currentIndex: state.currentIndex + 1, buffer: "", statuses: nextStatuses, lastCorrectToken: null, anchorPending: true, anchorTokens: [] });
175
+ }
176
+ const nextStatuses = new Map(state.statuses);
177
+ nextStatuses.set(target.key, "correct");
178
+ console.log(`%c✂️ SPLIT at idx ${state.currentIndex}: consumed "${target.text}", remainder "${result.remainder}" → next slot`, "background: #fef5e0; color: #a60; padding: 2px 6px; font-weight: bold;");
179
+ const advancedState = Object.assign(Object.assign({}, state), { currentIndex: state.currentIndex + 1, buffer: "", statuses: nextStatuses, lastCorrectToken: null });
180
+ return processToken(advancedState, result.remainder, expected, alternatives, splitDepth + 1);
181
+ }
182
+ // correct or wrong
183
+ const nextStatuses = new Map(state.statuses);
184
+ nextStatuses.set(target.key, result.status);
185
+ const advanced = result.advance ? state.currentIndex + 1 : state.currentIndex;
186
+ if (result.status === "wrong") {
187
+ // Auto re-anchor: STT may have mis-aligned us. Let the next 1–2 tokens
188
+ // resync to where the user actually is via the forward-windowed search.
189
+ console.log(`%c⟳ RE-ANCHOR triggered after wrong at idx ${state.currentIndex} — forward window from idx ${advanced}`, "background: #fef5e0; color: #a60; padding: 2px 6px; font-weight: bold;");
190
+ return Object.assign(Object.assign({}, state), { currentIndex: advanced, buffer: "", statuses: nextStatuses, lastCorrectToken: null, anchorPending: true, anchorTokens: [] });
191
+ }
192
+ return Object.assign(Object.assign({}, state), { currentIndex: advanced, buffer: "", statuses: nextStatuses, lastCorrectToken: result.status === "correct" ? newBuffer : null });
193
+ }
194
+ /**
195
+ * Entry point for one spoken token. Routes through anchor detection if
196
+ * we haven't locked the user's start position yet (or have re-entered
197
+ * anchor mode after silence / wrong), otherwise straight to per-target
198
+ * validation.
199
+ *
200
+ * Search strategy:
201
+ * - currentIndex === 0 → whole page (fresh start, no prior position
202
+ * to anchor relative to).
203
+ * - currentIndex > 0 → two phases:
204
+ * 1) Forward window [currentIndex, currentIndex + SEARCH_FORWARD_WINDOW).
205
+ * Prefers the cascade-recovery case (continuous reciting with an
206
+ * STT glitch). Fast and prevents tiny errors from throwing us
207
+ * back to an earlier verse.
208
+ * 2) If forward returns null, fall back to the whole page. Handles
209
+ * legitimate restart-from-elsewhere (silence then back-to-top,
210
+ * jump to a different verse, etc). Backward jumps land here, and
211
+ * the same anchor thresholds (2+ consecutive, 6+ char score,
212
+ * ambiguity defer) still gate them.
213
+ */
214
+ function ingestToken(state, token, expected, alternatives) {
215
+ if (!state.anchorPending) {
216
+ return processToken(state, token, expected, alternatives);
217
+ }
218
+ let buffered = [...state.anchorTokens, token];
219
+ if (buffered.length > MAX_ANCHOR_BUFFER) {
220
+ buffered = buffered.slice(-MAX_ANCHOR_BUFFER);
221
+ }
222
+ const isFreshStart = state.currentIndex === 0;
223
+ let anchor = null;
224
+ let scopeLabel;
225
+ if (isFreshStart) {
226
+ anchor = findAnchor(buffered, expected, alternatives, 0, expected.length);
227
+ scopeLabel = "whole page (fresh start)";
228
+ }
229
+ else {
230
+ // Phase 1: forward window
231
+ const fwdEnd = Math.min(expected.length, state.currentIndex + SEARCH_FORWARD_WINDOW);
232
+ anchor = findAnchor(buffered, expected, alternatives, state.currentIndex, fwdEnd);
233
+ if (anchor !== null) {
234
+ scopeLabel = `forward [${state.currentIndex}, ${fwdEnd})`;
235
+ }
236
+ else {
237
+ // Phase 2: whole-page fallback (covers backward restarts, jumps
238
+ // to different sections, etc — any position outside the forward
239
+ // window that the user might actually be reciting from).
240
+ anchor = findAnchor(buffered, expected, alternatives, 0, expected.length);
241
+ scopeLabel = anchor
242
+ ? "whole page (forward exhausted)"
243
+ : `forward [${state.currentIndex}, ${fwdEnd}) → whole page, both empty`;
244
+ }
245
+ }
246
+ if (anchor === null) {
247
+ console.log(`%c⚓ ANCHORING (${scopeLabel}) — ${buffered.length} token${buffered.length === 1 ? "" : "s"} buffered ("${buffered.join(" ")}") — no confident match yet`, "color: #999; font-style: italic;");
248
+ return Object.assign(Object.assign({}, state), { anchorTokens: buffered });
249
+ }
250
+ const at = expected[anchor.index];
251
+ const direction = !isFreshStart && anchor.index < state.currentIndex
252
+ ? " ⤺ BACKWARD"
253
+ : !isFreshStart &&
254
+ anchor.index > state.currentIndex + SEARCH_FORWARD_WINDOW - 1
255
+ ? " ⤻ DISTANT-FORWARD"
256
+ : "";
257
+ console.log(`%c⚓ ANCHOR FOUND${direction} at idx ${anchor.index} (${at.surah}:${at.verse}, "${at.text}") via ${scopeLabel} — matched ${anchor.matchedCount} word${anchor.matchedCount === 1 ? "" : "s"}, score ${anchor.score} — replaying ${buffered.length} buffered token${buffered.length === 1 ? "" : "s"}`, "background: #e7f3ff; color: #06a; padding: 2px 6px; font-weight: bold;");
258
+ // If the anchor jumped FORWARD past one or more positions (cascade
259
+ // recovery, or initial mid-page start), mark those intermediate words
260
+ // as "skipped" so they render distinctly (gray) instead of looking
261
+ // unrecited. Existing statuses are preserved — a word previously
262
+ // marked wrong stays wrong, a word marked correct stays correct.
263
+ let nextStatuses = state.statuses;
264
+ if (anchor.index > state.currentIndex) {
265
+ const updated = new Map(state.statuses);
266
+ let skippedCount = 0;
267
+ for (let i = state.currentIndex; i < anchor.index; i++) {
268
+ const key = expected[i].key;
269
+ if (!updated.has(key)) {
270
+ updated.set(key, "skipped");
271
+ skippedCount++;
272
+ }
273
+ }
274
+ if (skippedCount > 0) {
275
+ console.log(`%c⏭ SKIPPED ${skippedCount} word${skippedCount === 1 ? "" : "s"} at indices [${state.currentIndex}, ${anchor.index}) — marked "skipped"`, "color: #888; font-weight: bold;");
276
+ nextStatuses = updated;
277
+ }
278
+ }
279
+ let nextState = {
280
+ currentIndex: anchor.index,
281
+ buffer: "",
282
+ statuses: nextStatuses,
283
+ lastCorrectToken: null,
284
+ anchorPending: false,
285
+ anchorTokens: [],
286
+ };
287
+ for (const t of buffered) {
288
+ nextState = processToken(nextState, t, expected, alternatives);
289
+ }
290
+ return nextState;
291
+ }
292
+ function replay(start, transcript, expected, alternatives) {
293
+ const tokens = transcript.trim().split(/\s+/).filter(Boolean);
294
+ let state = start;
295
+ for (const token of tokens) {
296
+ state = ingestToken(state, token, expected, alternatives);
297
+ }
298
+ return state;
299
+ }
300
+ /* ------------------------------------------------------------------ */
301
+ /* Reducer */
302
+ /* ------------------------------------------------------------------ */
303
+ function reducer(state, action) {
304
+ switch (action.type) {
305
+ case "RESET":
306
+ return {
307
+ currentIndex: 0,
308
+ buffer: "",
309
+ statuses: new Map(),
310
+ lastCorrectToken: null,
311
+ anchorPending: true,
312
+ anchorTokens: [],
313
+ };
314
+ case "JUMP_TO":
315
+ return Object.assign(Object.assign({}, state), { currentIndex: action.index, buffer: "", lastCorrectToken: null, anchorPending: false, anchorTokens: [] });
316
+ case "RE_ANCHOR":
317
+ // No-op if we're already anchoring — just clear any half-collected
318
+ // tokens so the new pass starts fresh.
319
+ console.log(`%c⟳ RE-ANCHOR requested (silence/error) at idx ${state.currentIndex}${state.anchorPending ? " — already anchoring, clearing buffer" : ""}`, "background: #fef5e0; color: #a60; padding: 2px 6px; font-weight: bold;");
320
+ return Object.assign(Object.assign({}, state), { buffer: "", lastCorrectToken: null, anchorPending: true, anchorTokens: [] });
321
+ case "COMMIT_FINAL":
322
+ return replay(state, action.transcript, action.expected, action.alternatives);
323
+ }
324
+ }
325
+ /* ------------------------------------------------------------------ */
326
+ /* Hook */
327
+ /* ------------------------------------------------------------------ */
328
+ export function useRecitationValidator(expectedWords, alternatives = wordAlternatives) {
329
+ const [committed, dispatch] = useReducer(reducer, initial);
330
+ const [interimTranscript, setInterimTranscript] = useState("");
331
+ const ingestFinal = useCallback((transcript) => {
332
+ setInterimTranscript("");
333
+ dispatch({
334
+ type: "COMMIT_FINAL",
335
+ transcript,
336
+ expected: expectedWords,
337
+ alternatives,
338
+ });
339
+ }, [expectedWords, alternatives]);
340
+ const ingestInterim = useCallback((transcript) => {
341
+ setInterimTranscript(transcript);
342
+ }, []);
343
+ const reset = useCallback(() => {
344
+ setInterimTranscript("");
345
+ dispatch({ type: "RESET" });
346
+ }, []);
347
+ const jumpTo = useCallback((i) => dispatch({ type: "JUMP_TO", index: i }), []);
348
+ /**
349
+ * Re-enter anchor mode. Call after a detected silence / pause from STT,
350
+ * or after any external signal that the user may have shifted position.
351
+ * Wrong validations already trigger this automatically; this is the
352
+ * caller-driven path.
353
+ */
354
+ const reAnchor = useCallback(() => dispatch({ type: "RE_ANCHOR" }), []);
355
+ const wordHighlights = useMemo(() => {
356
+ const m = new Map(committed.statuses);
357
+ // Don't show a "current" indicator while we're still anchoring —
358
+ // the cursor isn't authoritative until anchor completes.
359
+ if (!committed.anchorPending) {
360
+ const cur = expectedWords[committed.currentIndex];
361
+ if (cur)
362
+ m.set(cur.key, "current");
363
+ }
364
+ return m;
365
+ }, [
366
+ committed.statuses,
367
+ committed.currentIndex,
368
+ committed.anchorPending,
369
+ expectedWords,
370
+ ]);
371
+ return {
372
+ currentIndex: committed.currentIndex,
373
+ buffer: committed.buffer,
374
+ interim: interimTranscript,
375
+ wordHighlights,
376
+ ingestFinal,
377
+ ingestInterim,
378
+ reset,
379
+ jumpTo,
380
+ reAnchor,
381
+ isComplete: !committed.anchorPending &&
382
+ committed.currentIndex >= expectedWords.length,
383
+ /** True while the engine is searching for the user's position.
384
+ * Toggles to false on successful anchor, true again on wrong-outcome
385
+ * or `reAnchor()` call. */
386
+ isAnchoring: committed.anchorPending,
387
+ };
388
+ }
@@ -0,0 +1,2 @@
1
+ export * from './utils';
2
+ export * from './hooks';
@@ -0,0 +1,2 @@
1
+ export * from './utils';
2
+ export * from './hooks';
@@ -0,0 +1,2 @@
1
+ export declare function removeTashkeel(text: string): string;
2
+ export declare function normalizeArabic(input: string): string;
@@ -0,0 +1,28 @@
1
+ const TASHKEEL = /[\u064B-\u065F\u0670\u06D6-\u06ED]/g;
2
+ const TATWEEL = /\u0640/g;
3
+ export function removeTashkeel(text) {
4
+ return text.replace(TASHKEEL, "").replace(TATWEEL, "");
5
+ }
6
+ export function normalizeArabic(input) {
7
+ if (!input)
8
+ return "";
9
+ return (input
10
+ // 1. Dagger alef → regular alef.
11
+ .replace(/\u0670/g, "\u0627")
12
+ // 2. Alef variants → bare alef.
13
+ .replace(/[\u0622\u0623\u0625\u0671]/g, "\u0627")
14
+ // 3a. Yaa maksura → yaa.
15
+ .replace(/\u0649/g, "\u064A")
16
+ // 3b. Taa marbuta → haa.
17
+ .replace(/\u0629/g, "\u0647")
18
+ // 4. Strip three diacritic / annotation ranges. The U+06D6..U+06ED
19
+ // range is the critical addition vs the previous version —
20
+ // without it, mushaf-style text retains marks like ۡ ۦ ۖ that
21
+ // inflate the expected-word length and prevent the
22
+ // prefix/contained matching from firing correctly.
23
+ .replace(/[\u0610-\u061A\u064B-\u065F\u06D6-\u06ED]/g, "")
24
+ // 5. Strip tatweel + zero-width + BIDI marks + NBSP + BOM.
25
+ .replace(/[\u0640\u00A0\u200B-\u200F\u202A-\u202E\uFEFF]/g, "")
26
+ // 6. Strip all whitespace runs.
27
+ .replace(/\s+/g, ""));
28
+ }
@@ -0,0 +1,4 @@
1
+ export * from './arabic';
2
+ export * from './levenshtein';
3
+ export * from './validation';
4
+ export * from './wordAlternatives';
@@ -0,0 +1,4 @@
1
+ export * from './arabic';
2
+ export * from './levenshtein';
3
+ export * from './validation';
4
+ export * from './wordAlternatives';
@@ -0,0 +1,2 @@
1
+ export declare function levenshteinDistance(a: string, b: string): number;
2
+ export declare function similarity(a: string, b: string): number;
@@ -0,0 +1,27 @@
1
+ export function levenshteinDistance(a, b) {
2
+ if (a === b)
3
+ return 0;
4
+ if (!a.length)
5
+ return b.length;
6
+ if (!b.length)
7
+ return a.length;
8
+ const m = a.length;
9
+ const n = b.length;
10
+ let prev = Array.from({ length: n + 1 }, (_, i) => i);
11
+ let curr = new Array(n + 1).fill(0);
12
+ for (let i = 1; i <= m; i++) {
13
+ curr[0] = i;
14
+ for (let j = 1; j <= n; j++) {
15
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
16
+ curr[j] = Math.min(prev[j] + 1, // deletion
17
+ curr[j - 1] + 1, // insertion
18
+ prev[j - 1] + cost);
19
+ }
20
+ [prev, curr] = [curr, prev];
21
+ }
22
+ return prev[n];
23
+ }
24
+ export function similarity(a, b) {
25
+ const maxLen = Math.max(a.length, b.length);
26
+ return maxLen === 0 ? 1 : 1 - levenshteinDistance(a, b) / maxLen;
27
+ }
@@ -0,0 +1,28 @@
1
+ import { WordAlternative } from "./wordAlternatives";
2
+ /**
3
+ * Standard threshold: 30% of the word's length, rounded up.
4
+ */
5
+ export declare function defaultThreshold(word: string): number;
6
+ interface ExpectedWordRef {
7
+ text: string;
8
+ surah: number;
9
+ verse: number;
10
+ }
11
+ export type ValidationStatus = "correct" | "wrong" | "pending" | "split" | "merge";
12
+ export interface ValidationResult {
13
+ status: ValidationStatus;
14
+ advance: boolean;
15
+ newBuffer: string;
16
+ /** Present when status is "split" — the leftover portion of spoken to re-feed. */
17
+ remainder?: string;
18
+ }
19
+ /**
20
+ * Validate a buffered spoken token against the expected word at currentIndex.
21
+ *
22
+ * @param lastCorrectToken The raw token that produced the most recent
23
+ * "correct" outcome at currentIndex - 1. Used by the merge mechanism
24
+ * to detect STT word-splits. Pass null if not tracking, or if the
25
+ * previous outcome was anything other than "correct".
26
+ */
27
+ export declare function validateRecitation(buffer: string, expected: ExpectedWordRef[], currentIndex: number, alternatives?: WordAlternative[], lastCorrectToken?: string | null): ValidationResult;
28
+ export {};