@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,241 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.validateRecitation = exports.defaultThreshold = void 0;
4
+ const levenshtein_1 = require("./levenshtein");
5
+ const arabic_1 = require("./arabic");
6
+ const wordAlternatives_1 = require("./wordAlternatives");
7
+ /**
8
+ * Standard threshold: 30% of the word's length, rounded up.
9
+ */
10
+ function defaultThreshold(word) {
11
+ return Math.ceil(word.length * 0.3);
12
+ }
13
+ exports.defaultThreshold = defaultThreshold;
14
+ function isBetterAsMerge(prevSpoken, currentSpoken, prevExpected, currentExpected) {
15
+ const currNorm = joinForAltCompare(currentSpoken);
16
+ if (currNorm.length === 0 || currNorm.length > 3)
17
+ return false;
18
+ const prevNorm = joinForAltCompare(prevSpoken);
19
+ const expPrevNorm = joinForAltCompare(prevExpected);
20
+ const expCurrNorm = joinForAltCompare(currentExpected);
21
+ if (prevNorm.length === 0 ||
22
+ expPrevNorm.length === 0 ||
23
+ expCurrNorm.length === 0) {
24
+ return false;
25
+ }
26
+ const allowedPrev = defaultThreshold(expPrevNorm);
27
+ const mergedLen = prevNorm.length + currNorm.length;
28
+ if (mergedLen > expPrevNorm.length + allowedPrev)
29
+ return false;
30
+ if (expPrevNorm.length > mergedLen + allowedPrev)
31
+ return false;
32
+ const dPrev = (0, levenshtein_1.levenshteinDistance)(prevNorm, expPrevNorm);
33
+ if (dPrev === 0)
34
+ return false;
35
+ const merged = prevNorm + currNorm;
36
+ const dMerged = (0, levenshtein_1.levenshteinDistance)(merged, expPrevNorm);
37
+ if (dMerged > allowedPrev || dMerged >= dPrev)
38
+ return false;
39
+ // Tiebreaker: only override CORRECT if merge fits prev STRICTLY better
40
+ // than current fits its own expected. Without this, a coincidentally
41
+ // similar current (e.g. "في" sharing a leading char with "ني" in "مكني")
42
+ // would falsely get absorbed.
43
+ const dCurrent = (0, levenshtein_1.levenshteinDistance)(currNorm, expCurrNorm);
44
+ return dMerged < dCurrent;
45
+ }
46
+ function matchesMain(spoken, expected) {
47
+ const a = (0, arabic_1.normalizeArabic)(spoken);
48
+ const b = (0, arabic_1.normalizeArabic)(expected);
49
+ return (0, levenshtein_1.levenshteinDistance)(a, b) <= defaultThreshold(b);
50
+ }
51
+ function isPrefixOfMain(spoken, expected) {
52
+ const a = (0, arabic_1.normalizeArabic)(spoken);
53
+ const b = (0, arabic_1.normalizeArabic)(expected);
54
+ if (a.length === 0 || a.length >= b.length)
55
+ return false;
56
+ const prefix = b.slice(0, a.length);
57
+ return (0, levenshtein_1.levenshteinDistance)(a, prefix) <= defaultThreshold(prefix);
58
+ }
59
+ function joinForAltCompare(s) {
60
+ return (0, arabic_1.normalizeArabic)(s).replace(/[\s\u200B-\u200F\u202A-\u202E\u00A0]/g, "");
61
+ }
62
+ function matchesAnyAlternative(spoken, alts) {
63
+ const a = joinForAltCompare(spoken);
64
+ for (const alt of alts) {
65
+ const altNorm = joinForAltCompare(alt);
66
+ const distance = (0, levenshtein_1.levenshteinDistance)(a, altNorm);
67
+ const allowed = defaultThreshold(altNorm);
68
+ const lenDiff = Math.abs(a.length - altNorm.length);
69
+ // For short joined alts (≤ 4 chars — the compact muqatta'at forms),
70
+ // require exact equality. Each char in a compact form represents one
71
+ // entire letter of the muqatta'at, so even a single edit completely
72
+ // changes the meaning. Without this, a single spoken letter like "الف"
73
+ // (3 chars) falsely full-matches the compact alt "الم" (3 chars, also
74
+ // 1 edit away), preventing the prefix path from collecting subsequent
75
+ // letters.
76
+ if (altNorm.length <= 4) {
77
+ if (distance === 0)
78
+ return true;
79
+ continue;
80
+ }
81
+ if (distance <= allowed && lenDiff <= Math.ceil(allowed / 2)) {
82
+ return true;
83
+ }
84
+ }
85
+ return false;
86
+ }
87
+ function isPrefixOfAnyAlternative(spoken, alts) {
88
+ const a = joinForAltCompare(spoken);
89
+ if (a.length === 0)
90
+ return false;
91
+ for (const alt of alts) {
92
+ const altNorm = joinForAltCompare(alt);
93
+ if (a.length >= altNorm.length)
94
+ continue;
95
+ const prefix = altNorm.slice(0, a.length);
96
+ if ((0, levenshtein_1.levenshteinDistance)(a, prefix) <= defaultThreshold(prefix))
97
+ return true;
98
+ }
99
+ return false;
100
+ }
101
+ /**
102
+ * Inverse of isPrefixOfMain: is the EXPECTED word a prefix of SPOKEN?
103
+ * Detects when STT combined two mushaf words into one token.
104
+ */
105
+ function isContainedIn(spoken, expected) {
106
+ const a = (0, arabic_1.normalizeArabic)(spoken);
107
+ const b = (0, arabic_1.normalizeArabic)(expected);
108
+ if (b.length < 2)
109
+ return false;
110
+ if (b.length >= a.length)
111
+ return false;
112
+ const remainderLen = a.length - b.length;
113
+ if (remainderLen < 2)
114
+ return false;
115
+ const prefix = a.slice(0, b.length);
116
+ // First-character anchor. The single most reliable signal that prefix
117
+ // is structurally aligned with expected, not coincidentally similar.
118
+ if (prefix[0] !== b[0])
119
+ return false;
120
+ return (0, levenshtein_1.levenshteinDistance)(prefix, b) <= defaultThreshold(b);
121
+ }
122
+ function splitContained(spoken, expected) {
123
+ const a = (0, arabic_1.normalizeArabic)(spoken);
124
+ const b = (0, arabic_1.normalizeArabic)(expected);
125
+ return a.slice(b.length);
126
+ }
127
+ /**
128
+ * Word-split merge detection.
129
+ *
130
+ * STT sometimes splits one mushaf word into two short tokens (e.g. "يَٰذَا"
131
+ * → "يا" + "ذا"). The first token may have been accepted as a fuzzy match
132
+ * for the mushaf word; the second now has nothing to match against.
133
+ *
134
+ * We detect this by checking whether `prevSpoken + currentSpoken` matches
135
+ * the previous expected word *strictly better* than `prevSpoken` alone.
136
+ *
137
+ * The strictness gate (dMerged < dPrev) is critical:
138
+ * - If prev was an exact match (dPrev = 0), no merge is possible — current
139
+ * must be a separate word.
140
+ * - If prev was fuzzy (dPrev > 0), merge fires only when adding current
141
+ * improves the distance, which is exactly the STT-split signal.
142
+ */
143
+ function matchesAsMerge(prevSpoken, currentSpoken, prevExpected) {
144
+ const prevNorm = joinForAltCompare(prevSpoken);
145
+ const currNorm = joinForAltCompare(currentSpoken);
146
+ const expNorm = joinForAltCompare(prevExpected);
147
+ if (prevNorm.length === 0 || currNorm.length === 0 || expNorm.length === 0) {
148
+ return false;
149
+ }
150
+ if (currNorm.length > expNorm.length) {
151
+ return false;
152
+ }
153
+ const allowed = defaultThreshold(expNorm);
154
+ const mergedLen = prevNorm.length + currNorm.length;
155
+ // Length-difference lower bound on Levenshtein. If merged is too far
156
+ // from expected in raw length, distance can't be within threshold.
157
+ if (mergedLen > expNorm.length + allowed)
158
+ return false;
159
+ if (expNorm.length > mergedLen + allowed)
160
+ return false;
161
+ const merged = prevNorm + currNorm;
162
+ const dMerged = (0, levenshtein_1.levenshteinDistance)(merged, expNorm);
163
+ if (dMerged > allowed)
164
+ return false;
165
+ const dPrev = (0, levenshtein_1.levenshteinDistance)(prevNorm, expNorm);
166
+ // Improvement gate: merged must be strictly closer, OR same distance
167
+ // with strictly more length (which means current contributed signal
168
+ // that aligned with the tail of expected).
169
+ return dMerged < dPrev || (dMerged === dPrev && mergedLen > prevNorm.length);
170
+ }
171
+ /**
172
+ * Validate a buffered spoken token against the expected word at currentIndex.
173
+ *
174
+ * @param lastCorrectToken The raw token that produced the most recent
175
+ * "correct" outcome at currentIndex - 1. Used by the merge mechanism
176
+ * to detect STT word-splits. Pass null if not tracking, or if the
177
+ * previous outcome was anything other than "correct".
178
+ */
179
+ function validateRecitation(buffer, expected, currentIndex, alternatives = wordAlternatives_1.wordAlternatives, lastCorrectToken = null) {
180
+ const trimmed = buffer.trim();
181
+ const target = expected[currentIndex];
182
+ if (!target) {
183
+ return { status: "wrong", advance: true, newBuffer: "" };
184
+ }
185
+ const position = { surah: target.surah, verse: target.verse };
186
+ const alts = (0, wordAlternatives_1.getAlternativesFor)(target.text, position.surah, position.verse, alternatives);
187
+ const tryMerge = () => {
188
+ if (!lastCorrectToken || currentIndex === 0)
189
+ return null;
190
+ const prevExpected = expected[currentIndex - 1];
191
+ if (!prevExpected)
192
+ return null;
193
+ if (matchesAsMerge(lastCorrectToken, trimmed, prevExpected.text)) {
194
+ return { status: "merge", advance: false, newBuffer: "" };
195
+ }
196
+ return null;
197
+ };
198
+ // === Muqatta'at path ===
199
+ if (alts.length > 0) {
200
+ if (matchesAnyAlternative(trimmed, alts)) {
201
+ return { status: "correct", advance: true, newBuffer: "" };
202
+ }
203
+ if ((0, arabic_1.normalizeArabic)(trimmed) === (0, arabic_1.normalizeArabic)(target.text)) {
204
+ return { status: "correct", advance: true, newBuffer: "" };
205
+ }
206
+ if (isPrefixOfAnyAlternative(trimmed, alts)) {
207
+ return { status: "pending", advance: false, newBuffer: trimmed };
208
+ }
209
+ const merge = tryMerge();
210
+ if (merge)
211
+ return merge;
212
+ return { status: "wrong", advance: true, newBuffer: "" };
213
+ }
214
+ // === Normal main-word path ===
215
+ if (matchesMain(trimmed, target.text)) {
216
+ if (lastCorrectToken && currentIndex > 0) {
217
+ const prevExpected = expected[currentIndex - 1];
218
+ if (prevExpected &&
219
+ isBetterAsMerge(lastCorrectToken, trimmed, prevExpected.text, target.text)) {
220
+ return { status: "merge", advance: false, newBuffer: "" };
221
+ }
222
+ }
223
+ return { status: "correct", advance: true, newBuffer: "" };
224
+ }
225
+ if (isContainedIn(trimmed, target.text)) {
226
+ return {
227
+ status: "split",
228
+ advance: true,
229
+ newBuffer: "",
230
+ remainder: splitContained(trimmed, target.text),
231
+ };
232
+ }
233
+ if (isPrefixOfMain(trimmed, target.text)) {
234
+ return { status: "pending", advance: false, newBuffer: trimmed };
235
+ }
236
+ const merge = tryMerge();
237
+ if (merge)
238
+ return merge;
239
+ return { status: "wrong", advance: true, newBuffer: "" };
240
+ }
241
+ exports.validateRecitation = validateRecitation;
@@ -0,0 +1,41 @@
1
+ export interface WordAlternative {
2
+ /** The expected word in the mushaf (or its normalized form). */
3
+ word: string;
4
+ /** Acceptable spoken alternatives. */
5
+ alternatives: string[];
6
+ /** Optional scope: only apply at this surah:verse. */
7
+ scope?: {
8
+ surah: number;
9
+ verse: number;
10
+ };
11
+ }
12
+ /**
13
+ * The الحروف المقطعة (muqatta'at) appear ONLY at ayah 1 of specific surahs
14
+ * (and in one case, ayah 2 of الشورى — عسق). They look identical to normal
15
+ * Arabic words like أَلَمْ in other contexts, so we must scope them.
16
+ *
17
+ * Each muqatta'at entry carries up to three alt forms, listed in order of
18
+ * expected real-world frequency. Whichever matches first wins, so order
19
+ * matters for performance only — correctness is unaffected by ordering.
20
+ *
21
+ * 1. Realistic hybrid — short letters compact (ك, ه, ط, ح, ر), long
22
+ * letters spelled out (ميم, لام, سين, عين, صاد, قاف, نون, كاف).
23
+ * This is what reciters actually say, and what STT actually delivers.
24
+ *
25
+ * 2. Pure compact — every letter as a single consonant char. After
26
+ * joinForAltCompare strips spaces this becomes the mushaf form
27
+ * itself, giving exact prefix matching for letter-by-letter input.
28
+ *
29
+ * 3. Full letter names — every letter spelled out (الف, ها, يا, etc.).
30
+ * Backstop for STT that delivers full phonetic forms.
31
+ *
32
+ * Single-letter muqatta'at (ص، ق، ن) only need two forms since compact
33
+ * and mushaf collapse to the same single character.
34
+ */
35
+ export declare const wordAlternatives: WordAlternative[];
36
+ /**
37
+ * Look up alternatives for an expected word at a specific position.
38
+ * Returns alternatives only if a scoped entry matches the surah:verse,
39
+ * or if there's an unscoped entry (for future generic alternatives).
40
+ */
41
+ export declare function getAlternativesFor(expected: string, surah: number, verse: number, alternatives?: WordAlternative[]): string[];
@@ -0,0 +1,230 @@
1
+ "use strict";
2
+ // utils/wordAlternatives.ts
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ exports.getAlternativesFor = exports.wordAlternatives = void 0;
5
+ const arabic_1 = require("./arabic");
6
+ /**
7
+ * The الحروف المقطعة (muqatta'at) appear ONLY at ayah 1 of specific surahs
8
+ * (and in one case, ayah 2 of الشورى — عسق). They look identical to normal
9
+ * Arabic words like أَلَمْ in other contexts, so we must scope them.
10
+ *
11
+ * Each muqatta'at entry carries up to three alt forms, listed in order of
12
+ * expected real-world frequency. Whichever matches first wins, so order
13
+ * matters for performance only — correctness is unaffected by ordering.
14
+ *
15
+ * 1. Realistic hybrid — short letters compact (ك, ه, ط, ح, ر), long
16
+ * letters spelled out (ميم, لام, سين, عين, صاد, قاف, نون, كاف).
17
+ * This is what reciters actually say, and what STT actually delivers.
18
+ *
19
+ * 2. Pure compact — every letter as a single consonant char. After
20
+ * joinForAltCompare strips spaces this becomes the mushaf form
21
+ * itself, giving exact prefix matching for letter-by-letter input.
22
+ *
23
+ * 3. Full letter names — every letter spelled out (الف, ها, يا, etc.).
24
+ * Backstop for STT that delivers full phonetic forms.
25
+ *
26
+ * Single-letter muqatta'at (ص، ق، ن) only need two forms since compact
27
+ * and mushaf collapse to the same single character.
28
+ */
29
+ exports.wordAlternatives = [
30
+ // البقرة 2:1, آل عمران 3:1, العنكبوت 29:1, الروم 30:1, لقمان 31:1, السجدة 32:1
31
+ {
32
+ word: "الم",
33
+ alternatives: ["ا لام ميم", "ا ل م", "الف لام ميم"],
34
+ scope: { surah: 2, verse: 1 },
35
+ },
36
+ {
37
+ word: "الم",
38
+ alternatives: ["ا لام ميم", "ا ل م", "الف لام ميم"],
39
+ scope: { surah: 3, verse: 1 },
40
+ },
41
+ {
42
+ word: "الم",
43
+ alternatives: ["ا لام ميم", "ا ل م", "الف لام ميم"],
44
+ scope: { surah: 29, verse: 1 },
45
+ },
46
+ {
47
+ word: "الم",
48
+ alternatives: ["ا لام ميم", "ا ل م", "الف لام ميم"],
49
+ scope: { surah: 30, verse: 1 },
50
+ },
51
+ {
52
+ word: "الم",
53
+ alternatives: ["ا لام ميم", "ا ل م", "الف لام ميم"],
54
+ scope: { surah: 31, verse: 1 },
55
+ },
56
+ {
57
+ word: "الم",
58
+ alternatives: ["ا لام ميم", "ا ل م", "الف لام ميم"],
59
+ scope: { surah: 32, verse: 1 },
60
+ },
61
+ // الأعراف 7:1
62
+ {
63
+ word: "المص",
64
+ alternatives: ["ا لام ميم صاد", "ا ل م ص", "الف لام ميم صاد"],
65
+ scope: { surah: 7, verse: 1 },
66
+ },
67
+ // يونس 10:1, هود 11:1, يوسف 12:1, إبراهيم 14:1, الحجر 15:1
68
+ {
69
+ word: "الر",
70
+ alternatives: ["ا لام را", "ا ل ر", "الف لام را", "الف لام راء"],
71
+ scope: { surah: 10, verse: 1 },
72
+ },
73
+ {
74
+ word: "الر",
75
+ alternatives: ["ا لام را", "ا ل ر", "الف لام را", "الف لام راء"],
76
+ scope: { surah: 11, verse: 1 },
77
+ },
78
+ {
79
+ word: "الر",
80
+ alternatives: ["ا لام را", "ا ل ر", "الف لام را", "الف لام راء"],
81
+ scope: { surah: 12, verse: 1 },
82
+ },
83
+ {
84
+ word: "الر",
85
+ alternatives: ["ا لام را", "ا ل ر", "الف لام را", "الف لام راء"],
86
+ scope: { surah: 14, verse: 1 },
87
+ },
88
+ {
89
+ word: "الر",
90
+ alternatives: ["ا لام را", "ا ل ر", "الف لام را", "الف لام راء"],
91
+ scope: { surah: 15, verse: 1 },
92
+ },
93
+ // الرعد 13:1
94
+ {
95
+ word: "المر",
96
+ alternatives: [
97
+ "ا لام ميم را",
98
+ "ا ل م ر",
99
+ "الف لام ميم را",
100
+ "الف لام ميم راء",
101
+ ],
102
+ scope: { surah: 13, verse: 1 },
103
+ },
104
+ // مريم 19:1
105
+ {
106
+ word: "كهيعص",
107
+ alternatives: [
108
+ "ك ه يا عين صاد",
109
+ "ك ه ي ع ص",
110
+ "كاف ها يا عين صاد",
111
+ "كاف هاء ياء عين صاد",
112
+ ],
113
+ scope: { surah: 19, verse: 1 },
114
+ },
115
+ // طه 20:1
116
+ {
117
+ word: "طه",
118
+ alternatives: ["ط ها", "ط ه", "طا ها", "طا هاء"],
119
+ scope: { surah: 20, verse: 1 },
120
+ },
121
+ // الشعراء 26:1, القصص 28:1
122
+ {
123
+ word: "طسم",
124
+ alternatives: ["ط سين ميم", "ط س م", "طا سين ميم"],
125
+ scope: { surah: 26, verse: 1 },
126
+ },
127
+ {
128
+ word: "طسم",
129
+ alternatives: ["ط سين ميم", "ط س م", "طا سين ميم"],
130
+ scope: { surah: 28, verse: 1 },
131
+ },
132
+ // النمل 27:1
133
+ {
134
+ word: "طس",
135
+ alternatives: ["ط سين", "ط س", "طا سين"],
136
+ scope: { surah: 27, verse: 1 },
137
+ },
138
+ // يس 36:1 — realistic and full collapse to "يا سين"; ياسين covers the
139
+ // run-on form some reciters/STT produce.
140
+ {
141
+ word: "يس",
142
+ alternatives: ["يا سين", "ي س", "ياسين"],
143
+ scope: { surah: 36, verse: 1 },
144
+ },
145
+ // ص 38:1 — single letter; compact and mushaf form are identical.
146
+ {
147
+ word: "ص",
148
+ alternatives: ["صاد", "ص"],
149
+ scope: { surah: 38, verse: 1 },
150
+ },
151
+ // غافر 40:1, فصلت 41:1, الزخرف 43:1, الدخان 44:1, الجاثية 45:1, الأحقاف 46:1
152
+ {
153
+ word: "حم",
154
+ alternatives: ["ح ميم", "ح م", "حا ميم", "حاء ميم"],
155
+ scope: { surah: 40, verse: 1 },
156
+ },
157
+ {
158
+ word: "حم",
159
+ alternatives: ["ح ميم", "ح م", "حا ميم", "حاء ميم"],
160
+ scope: { surah: 41, verse: 1 },
161
+ },
162
+ {
163
+ word: "حم",
164
+ alternatives: ["ح ميم", "ح م", "حا ميم", "حاء ميم"],
165
+ scope: { surah: 43, verse: 1 },
166
+ },
167
+ {
168
+ word: "حم",
169
+ alternatives: ["ح ميم", "ح م", "حا ميم", "حاء ميم"],
170
+ scope: { surah: 44, verse: 1 },
171
+ },
172
+ {
173
+ word: "حم",
174
+ alternatives: ["ح ميم", "ح م", "حا ميم", "حاء ميم"],
175
+ scope: { surah: 45, verse: 1 },
176
+ },
177
+ {
178
+ word: "حم",
179
+ alternatives: ["ح ميم", "ح م", "حا ميم", "حاء ميم"],
180
+ scope: { surah: 46, verse: 1 },
181
+ },
182
+ // الشورى 42:1 = حم, 42:2 = عسق (split across two ayahs)
183
+ {
184
+ word: "حم",
185
+ alternatives: ["ح ميم", "ح م", "حا ميم", "حاء ميم"],
186
+ scope: { surah: 42, verse: 1 },
187
+ },
188
+ {
189
+ word: "عسق",
190
+ alternatives: ["عين سين قاف", "ع س ق"],
191
+ scope: { surah: 42, verse: 2 },
192
+ },
193
+ // ق 50:1 — single letter; compact and mushaf collapse.
194
+ {
195
+ word: "ق",
196
+ alternatives: ["قاف", "ق"],
197
+ scope: { surah: 50, verse: 1 },
198
+ },
199
+ // القلم 68:1 — single letter; compact and mushaf collapse.
200
+ {
201
+ word: "ن",
202
+ alternatives: ["نون", "ن"],
203
+ scope: { surah: 68, verse: 1 },
204
+ },
205
+ ];
206
+ /**
207
+ * Look up alternatives for an expected word at a specific position.
208
+ * Returns alternatives only if a scoped entry matches the surah:verse,
209
+ * or if there's an unscoped entry (for future generic alternatives).
210
+ */
211
+ function getAlternativesFor(expected, surah, verse, alternatives = exports.wordAlternatives) {
212
+ const normalized = (0, arabic_1.normalizeArabic)(expected);
213
+ const out = [];
214
+ for (const entry of alternatives) {
215
+ const wordMatches = entry.word === expected || entry.word === normalized;
216
+ if (!wordMatches)
217
+ continue;
218
+ // Unscoped entries always apply.
219
+ if (!entry.scope) {
220
+ out.push(...entry.alternatives);
221
+ continue;
222
+ }
223
+ // Scoped entries only apply at their position.
224
+ if (entry.scope.surah === surah && entry.scope.verse === verse) {
225
+ out.push(...entry.alternatives);
226
+ }
227
+ }
228
+ return out;
229
+ }
230
+ exports.getAlternativesFor = getAlternativesFor;
@@ -0,0 +1 @@
1
+ export * from './useRecitationValidator';
@@ -0,0 +1 @@
1
+ export * from './useRecitationValidator';
@@ -0,0 +1,25 @@
1
+ import { WordAlternative } from "../utils/wordAlternatives";
2
+ export interface ExpectedWord {
3
+ text: string;
4
+ key: string;
5
+ surah: number;
6
+ verse: number;
7
+ }
8
+ type WordStatus = "correct" | "wrong" | "skipped" | "current";
9
+ export declare function useRecitationValidator(expectedWords: ExpectedWord[], alternatives?: WordAlternative[]): {
10
+ currentIndex: number;
11
+ buffer: string;
12
+ interim: string;
13
+ wordHighlights: Map<string, WordStatus>;
14
+ ingestFinal: (transcript: string) => void;
15
+ ingestInterim: (transcript: string) => void;
16
+ reset: () => void;
17
+ jumpTo: (i: number) => void;
18
+ reAnchor: () => void;
19
+ isComplete: boolean;
20
+ /** True while the engine is searching for the user's position.
21
+ * Toggles to false on successful anchor, true again on wrong-outcome
22
+ * or `reAnchor()` call. */
23
+ isAnchoring: boolean;
24
+ };
25
+ export {};