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