@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.
- package/LICENSE.md +21 -0
- package/README.md +2 -0
- package/dist/cjs/hooks/index.d.ts +1 -0
- package/dist/cjs/hooks/index.js +4 -0
- package/dist/cjs/hooks/useRecitationValidator.d.ts +25 -0
- package/dist/cjs/hooks/useRecitationValidator.js +392 -0
- package/dist/cjs/index.d.ts +2 -0
- package/dist/cjs/index.js +5 -0
- package/dist/cjs/utils/arabic.d.ts +2 -0
- package/dist/cjs/utils/arabic.js +33 -0
- package/dist/cjs/utils/index.d.ts +4 -0
- package/dist/cjs/utils/index.js +7 -0
- package/dist/cjs/utils/levenshtein.d.ts +2 -0
- package/dist/cjs/utils/levenshtein.js +32 -0
- package/dist/cjs/utils/validation.d.ts +28 -0
- package/dist/cjs/utils/validation.js +241 -0
- package/dist/cjs/utils/wordAlternatives.d.ts +41 -0
- package/dist/cjs/utils/wordAlternatives.js +230 -0
- package/dist/esm/hooks/index.d.ts +1 -0
- package/dist/esm/hooks/index.js +1 -0
- package/dist/esm/hooks/useRecitationValidator.d.ts +25 -0
- package/dist/esm/hooks/useRecitationValidator.js +388 -0
- package/dist/esm/index.d.ts +2 -0
- package/dist/esm/index.js +2 -0
- package/dist/esm/utils/arabic.d.ts +2 -0
- package/dist/esm/utils/arabic.js +28 -0
- package/dist/esm/utils/index.d.ts +4 -0
- package/dist/esm/utils/index.js +4 -0
- package/dist/esm/utils/levenshtein.d.ts +2 -0
- package/dist/esm/utils/levenshtein.js +27 -0
- package/dist/esm/utils/validation.d.ts +28 -0
- package/dist/esm/utils/validation.js +236 -0
- package/dist/esm/utils/wordAlternatives.d.ts +41 -0
- package/dist/esm/utils/wordAlternatives.js +226 -0
- 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,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,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 {};
|