@maheidem/pi-audio-transcribe 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +32 -0
- package/README.md +132 -0
- package/config.ts +174 -0
- package/index.ts +322 -0
- package/lib/json-store.ts +92 -0
- package/omlx.ts +295 -0
- package/package.json +69 -0
- package/paths.ts +128 -0
- package/pipeline.ts +252 -0
- package/settings.ts +130 -0
- package/sidecar.ts +123 -0
- package/ui/audio-panel.ts +104 -0
- package/ui/settings-panel.ts +393 -0
- package/validate.ts +388 -0
package/validate.ts
ADDED
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transcript validation.
|
|
3
|
+
*
|
|
4
|
+
* Two layers, both deterministic (no LLM):
|
|
5
|
+
*
|
|
6
|
+
* STRUCTURAL — inspects one ASR response for the failure modes that
|
|
7
|
+
* actually occur on oMLX/Qwen3-ASR:
|
|
8
|
+
* empty silence (verified: real silence returns text="" ,
|
|
9
|
+
* language=null, segments[0].language="None")
|
|
10
|
+
* repetition n-gram loops, the classic ASR hallucination
|
|
11
|
+
* garbled replacement/control characters
|
|
12
|
+
* duration segments[].end vs ffprobe ground truth
|
|
13
|
+
* rate words-per-second outside plausible human speech
|
|
14
|
+
* language reported label vs content heuristics (server answered
|
|
15
|
+
* "English" for a Portuguese clip on synthetic audio)
|
|
16
|
+
*
|
|
17
|
+
* CROSS-CHECK — re-transcribes with the language the server itself
|
|
18
|
+
* reported and compares. On real audio auto and explicit hints agree
|
|
19
|
+
* byte-for-byte, so divergence is a genuine instability signal rather
|
|
20
|
+
* than noise. That agreement is what makes "always auto-detect" safe.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import type { AsrResponse, AsrSegment } from "./omlx.ts";
|
|
24
|
+
import { audioDurationFromSegments, normalizeLanguage } from "./omlx.ts";
|
|
25
|
+
|
|
26
|
+
export type CheckStatus = "pass" | "warn" | "fail" | "info";
|
|
27
|
+
|
|
28
|
+
export interface Check {
|
|
29
|
+
id: string;
|
|
30
|
+
status: CheckStatus;
|
|
31
|
+
detail: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type Verdict = "ok" | "review" | "no_speech" | "failed";
|
|
35
|
+
|
|
36
|
+
export interface CrossCheckResult {
|
|
37
|
+
performed: boolean;
|
|
38
|
+
skippedReason?: string;
|
|
39
|
+
languageUsed?: string | null;
|
|
40
|
+
similarity?: number;
|
|
41
|
+
textChanged?: boolean;
|
|
42
|
+
/** Text from the second pass, kept when it differs so it is not lost. */
|
|
43
|
+
altText?: string;
|
|
44
|
+
latencySeconds?: number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface ValidationReport {
|
|
48
|
+
verdict: Verdict;
|
|
49
|
+
confidence: number;
|
|
50
|
+
checks: Check[];
|
|
51
|
+
recommendations: string[];
|
|
52
|
+
language: {
|
|
53
|
+
requested: string | null;
|
|
54
|
+
reported: string | null;
|
|
55
|
+
normalized: string | null;
|
|
56
|
+
heuristic: string | null;
|
|
57
|
+
consistent: boolean;
|
|
58
|
+
};
|
|
59
|
+
timings: { audioSeconds: number | null; asrLatencySeconds: number | null };
|
|
60
|
+
crossCheck: CrossCheckResult;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const WORD_RE = /[\p{L}\p{N}'’-]+/gu;
|
|
64
|
+
|
|
65
|
+
export function countWords(text: string): number {
|
|
66
|
+
return (text.match(WORD_RE) ?? []).length;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Stability threshold for the cross-check. */
|
|
70
|
+
export const SIMILARITY_WARN = 0.9;
|
|
71
|
+
export const SIMILARITY_FAIL = 0.7;
|
|
72
|
+
|
|
73
|
+
/** Normalize for comparison: case, accents, whitespace, punctuation. */
|
|
74
|
+
export function normalizeForCompare(text: string): string {
|
|
75
|
+
return text
|
|
76
|
+
.toLowerCase()
|
|
77
|
+
.normalize("NFD")
|
|
78
|
+
.replace(/[\u0300-\u036f]/g, "")
|
|
79
|
+
.replace(/[^\p{L}\p{N}\s]/gu, " ")
|
|
80
|
+
.replace(/\s+/g, " ")
|
|
81
|
+
.trim();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Sørensen–Dice coefficient over word bigrams; falls back to unigrams. */
|
|
85
|
+
export function similarity(a: string, b: string): number {
|
|
86
|
+
const na = normalizeForCompare(a);
|
|
87
|
+
const nb = normalizeForCompare(b);
|
|
88
|
+
if (na === nb) return 1;
|
|
89
|
+
const grams = (s: string): string[] => {
|
|
90
|
+
const w = s.split(" ").filter(Boolean);
|
|
91
|
+
if (w.length < 2) return w;
|
|
92
|
+
const out: string[] = [];
|
|
93
|
+
for (let i = 0; i < w.length - 1; i++) out.push(`${w[i]} ${w[i + 1]}`);
|
|
94
|
+
return out;
|
|
95
|
+
};
|
|
96
|
+
const A = grams(na);
|
|
97
|
+
const B = grams(nb);
|
|
98
|
+
if (!A.length || !B.length) return 0;
|
|
99
|
+
const counts = new Map<string, number>();
|
|
100
|
+
for (const g of A) counts.set(g, (counts.get(g) ?? 0) + 1);
|
|
101
|
+
let hits = 0;
|
|
102
|
+
for (const g of B) {
|
|
103
|
+
const c = counts.get(g) ?? 0;
|
|
104
|
+
if (c > 0) {
|
|
105
|
+
counts.set(g, c - 1);
|
|
106
|
+
hits++;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return (2 * hits) / (A.length + B.length);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Detect consecutive n-gram repetition (ASR loops).
|
|
114
|
+
* Tries phrase lengths 1..N so both single-word stuttering
|
|
115
|
+
* ("sim sim sim sim") and longer loops are caught.
|
|
116
|
+
* Returns the repeated phrase when it repeats back-to-back `minRepeats`+.
|
|
117
|
+
*/
|
|
118
|
+
export function detectRepetitionLoop(text: string, maxN = 4, minRepeats = 3): string | null {
|
|
119
|
+
const words = normalizeForCompare(text).split(" ").filter(Boolean);
|
|
120
|
+
for (let n = 1; n <= maxN; n++) {
|
|
121
|
+
if (words.length < n * minRepeats) continue;
|
|
122
|
+
for (let i = 0; i + n * minRepeats <= words.length; i++) {
|
|
123
|
+
const phrase = words.slice(i, i + n).join(" ");
|
|
124
|
+
let repeats = 1;
|
|
125
|
+
while (words.slice(i + repeats * n, i + (repeats + 1) * n).join(" ") === phrase) repeats++;
|
|
126
|
+
if (repeats >= minRepeats) return phrase;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Fraction of duplicate word bigrams — catches interleaved "sim sim sim". */
|
|
133
|
+
export function repetitionRatio(text: string): number {
|
|
134
|
+
const words = normalizeForCompare(text).split(" ").filter(Boolean);
|
|
135
|
+
if (words.length < 6) return 0;
|
|
136
|
+
const seen = new Set<string>();
|
|
137
|
+
let dup = 0;
|
|
138
|
+
for (let i = 0; i < words.length - 1; i++) {
|
|
139
|
+
const g = `${words[i]} ${words[i + 1]}`;
|
|
140
|
+
if (seen.has(g)) dup++;
|
|
141
|
+
else seen.add(g);
|
|
142
|
+
}
|
|
143
|
+
return dup / (words.length - 1);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const PT_MARKERS = [
|
|
147
|
+
"não", "nao", "está", "esta", "são", "sao", "gaveta", "medida", "talheres", "centímetro", "centimetro",
|
|
148
|
+
"milímetro", "milimetro", "vinte", "quarenta", "né", "nè", "também", "tambem", "muito", "obrigado",
|
|
149
|
+
"obrigada", "vocês", "voce", "pra", "pro", "aí", "ai", "então", "entao", "depois", "agora", "aqui",
|
|
150
|
+
];
|
|
151
|
+
const EN_MARKERS = ["the", "and", "this", "that", "with", "have", "would", "measure", "drawer", "about", "here"];
|
|
152
|
+
const ES_MARKERS = ["ñ", "está", "muy", "gracias", "también", "porque", "esto", "ese", "esa"];
|
|
153
|
+
|
|
154
|
+
/** Word-boundary containment, so "the" does not match "theme". */
|
|
155
|
+
function wordAt(haystack: string, word: string): boolean {
|
|
156
|
+
return new RegExp("\\b" + word.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + "\\b", "i").test(haystack);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Cheap language guess from content. Only distinguishes the languages in play here. */
|
|
160
|
+
export function heuristicLanguage(text: string): string | null {
|
|
161
|
+
const low = text.toLowerCase();
|
|
162
|
+
const hits: Array<[string, number]> = [
|
|
163
|
+
["pt", PT_MARKERS.reduce((a, m) => a + (low.includes(m) ? 1 : 0), 0)],
|
|
164
|
+
["en", EN_MARKERS.reduce((a, m) => a + (wordAt(low, m) ? 1 : 0), 0)],
|
|
165
|
+
["es", ES_MARKERS.reduce((a, m) => a + (low.includes(m) ? 1 : 0), 0)],
|
|
166
|
+
];
|
|
167
|
+
if (/[\u4e00-\u9fff]/.test(text)) return "zh";
|
|
168
|
+
if (/[\u3040-\u30ff]/.test(text)) return "ja";
|
|
169
|
+
if (/[\uac00-\ud7af]/.test(text)) return "ko";
|
|
170
|
+
if (/[\u0400-\u04ff]/.test(text)) return "ru";
|
|
171
|
+
hits.sort((a, b) => b[1] - a[1]);
|
|
172
|
+
return hits[0][1] > 0 ? hits[0][0] : null;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Characters that indicate a decoding/encoding problem rather than speech. */
|
|
176
|
+
export function garbleRatio(text: string): number {
|
|
177
|
+
if (!text.length) return 0;
|
|
178
|
+
let bad = 0;
|
|
179
|
+
for (const ch of text) {
|
|
180
|
+
const cp = ch.codePointAt(0) ?? 0;
|
|
181
|
+
if (cp === 0xfffd) bad++;
|
|
182
|
+
else if (cp < 32 && cp !== 9 && cp !== 10 && cp !== 13) bad++;
|
|
183
|
+
else if (cp >= 0xe000 && cp <= 0xf8ff) bad++; // private use area
|
|
184
|
+
}
|
|
185
|
+
return bad / [...text].length;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export interface ProbeInfo {
|
|
189
|
+
durationSeconds?: number | null;
|
|
190
|
+
sampleRateHz?: number | null;
|
|
191
|
+
channels?: number | null;
|
|
192
|
+
codec?: string | null;
|
|
193
|
+
available?: boolean;
|
|
194
|
+
error?: string;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export interface ValidateInput {
|
|
198
|
+
response: AsrResponse;
|
|
199
|
+
requestedLanguage?: string | null;
|
|
200
|
+
probe?: ProbeInfo;
|
|
201
|
+
crossCheck?: CrossCheckResult;
|
|
202
|
+
minWordsForReview?: number;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Run every structural check (plus the supplied cross-check result). */
|
|
206
|
+
export function validateSpeech(input: ValidateInput): ValidationReport {
|
|
207
|
+
const { response, requestedLanguage = null, probe, crossCheck } = input;
|
|
208
|
+
const text = typeof response.text === "string" ? response.text : "";
|
|
209
|
+
const trimmed = text.trim();
|
|
210
|
+
const checks: Check[] = [];
|
|
211
|
+
const recommendations: string[] = [];
|
|
212
|
+
|
|
213
|
+
const reported = normalizeLanguage(response.language);
|
|
214
|
+
const reportedRaw = response.language ?? null;
|
|
215
|
+
const heuristic = heuristicLanguage(trimmed);
|
|
216
|
+
const segDuration = audioDurationFromSegments(response.segments as AsrSegment[] | undefined);
|
|
217
|
+
const audioSeconds = probe?.durationSeconds ?? segDuration ?? null;
|
|
218
|
+
const asrLatencySeconds = typeof response.duration === "number" ? response.duration : null;
|
|
219
|
+
|
|
220
|
+
// 1. Empty / silence
|
|
221
|
+
if (!trimmed) {
|
|
222
|
+
const silenceEvidence = (response.segments ?? []).every((s) => normalizeLanguage(s.language) === null);
|
|
223
|
+
checks.push({
|
|
224
|
+
id: "speech_present",
|
|
225
|
+
status: silenceEvidence ? "warn" : "fail",
|
|
226
|
+
detail: silenceEvidence
|
|
227
|
+
? "No speech detected (server reported language 'None'); audio appears silent"
|
|
228
|
+
: "Empty transcript but language was reported — server returned nothing usable",
|
|
229
|
+
});
|
|
230
|
+
return {
|
|
231
|
+
verdict: "no_speech",
|
|
232
|
+
confidence: 0,
|
|
233
|
+
checks,
|
|
234
|
+
recommendations: ["Audio contains no recognizable speech. Check the recording level or re-record."],
|
|
235
|
+
language: { requested: requestedLanguage, reported: reportedRaw, normalized: null, heuristic: null, consistent: true },
|
|
236
|
+
timings: { audioSeconds, asrLatencySeconds },
|
|
237
|
+
crossCheck: crossCheck ?? { performed: false, skippedReason: "not run (no speech)" },
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
checks.push({ id: "speech_present", status: "pass", detail: `${countWords(trimmed)} words, ${trimmed.length} characters` });
|
|
241
|
+
|
|
242
|
+
// 2. Repetition loops
|
|
243
|
+
const loop = detectRepetitionLoop(trimmed);
|
|
244
|
+
if (loop) {
|
|
245
|
+
checks.push({ id: "repetition_loop", status: "fail", detail: `Repeated phrase detected: "${loop}" — likely ASR hallucination` });
|
|
246
|
+
recommendations.push("Transcript contains a repetition loop; treat the tail of the text as unreliable.");
|
|
247
|
+
} else {
|
|
248
|
+
const ratio = repetitionRatio(trimmed);
|
|
249
|
+
if (ratio > 0.35) checks.push({ id: "repetition_loop", status: "warn", detail: `High duplicate-bigram ratio (${(ratio * 100).toFixed(0)}%)` });
|
|
250
|
+
else checks.push({ id: "repetition_loop", status: "pass", detail: `No repetition loop (duplicate bigram ratio ${(ratio * 100).toFixed(0)}%)` });
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// 3. Garbled characters
|
|
254
|
+
const garble = garbleRatio(trimmed);
|
|
255
|
+
if (garble > 0.02) {
|
|
256
|
+
checks.push({ id: "encoding", status: "fail", detail: `${(garble * 100).toFixed(1)}% invalid characters — decoding problem` });
|
|
257
|
+
recommendations.push("Transcript has invalid characters; the audio may be corrupted or in an unsupported encoding.");
|
|
258
|
+
} else {
|
|
259
|
+
checks.push({ id: "encoding", status: "pass", detail: "Clean UTF-8 text" });
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// 4. Duration: ffprobe is ground truth; segments[].end is the server's own claim.
|
|
263
|
+
if (probe?.durationSeconds && segDuration != null) {
|
|
264
|
+
const delta = Math.abs(probe.durationSeconds - segDuration);
|
|
265
|
+
const pct = probe.durationSeconds > 0 ? delta / probe.durationSeconds : 0;
|
|
266
|
+
if (pct > 0.2) checks.push({ id: "duration", status: "warn", detail: `segments end at ${segDuration.toFixed(2)}s but file is ${probe.durationSeconds.toFixed(2)}s (${(pct * 100).toFixed(0)}% off) — audio after ${segDuration.toFixed(0)}s may be untranscribed` });
|
|
267
|
+
else checks.push({ id: "duration", status: "pass", detail: `Transcript covers ${segDuration.toFixed(2)}s of ${probe.durationSeconds.toFixed(2)}s` });
|
|
268
|
+
} else if (probe?.durationSeconds) {
|
|
269
|
+
checks.push({ id: "duration", status: "info", detail: `File is ${probe.durationSeconds.toFixed(2)}s; server returned no segment timing` });
|
|
270
|
+
} else {
|
|
271
|
+
checks.push({ id: "duration", status: "info", detail: "ffprobe unavailable; duration taken from segments[].end" });
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// 5. Speech rate
|
|
275
|
+
if (audioSeconds && audioSeconds > 0.5) {
|
|
276
|
+
const wps = countWords(trimmed) / audioSeconds;
|
|
277
|
+
if (wps < 0.5) {
|
|
278
|
+
checks.push({ id: "speech_rate", status: "warn", detail: `${wps.toFixed(2)} words/s — very sparse, long silence or missed speech` });
|
|
279
|
+
} else if (wps > 25) {
|
|
280
|
+
checks.push({ id: "speech_rate", status: "warn", detail: `${wps.toFixed(2)} words/s — implausibly fast, likely repetition loop` });
|
|
281
|
+
} else {
|
|
282
|
+
checks.push({ id: "speech_rate", status: "pass", detail: `${wps.toFixed(2)} words/s` });
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// 6. Language consistency (server has been known to label Portuguese as English)
|
|
287
|
+
const reportedNorm = reported;
|
|
288
|
+
let consistent = true;
|
|
289
|
+
if (requestedLanguage) {
|
|
290
|
+
const want = normalizeLanguage(requestedLanguage);
|
|
291
|
+
if (want && reportedNorm && want !== reportedNorm) {
|
|
292
|
+
consistent = false;
|
|
293
|
+
checks.push({ id: "language", status: "warn", detail: `Requested "${requestedLanguage}" but server reported "${reportedRaw}"` });
|
|
294
|
+
} else if (want && !reportedNorm) {
|
|
295
|
+
consistent = false;
|
|
296
|
+
checks.push({ id: "language", status: "warn", detail: `Requested "${requestedLanguage}" but server reported no language` });
|
|
297
|
+
} else {
|
|
298
|
+
checks.push({ id: "language", status: "pass", detail: `Language "${reportedRaw}" matches request` });
|
|
299
|
+
}
|
|
300
|
+
} else if (reportedNorm && heuristic && reportedNorm !== heuristic) {
|
|
301
|
+
consistent = false;
|
|
302
|
+
checks.push({ id: "language", status: "warn", detail: `Server reported "${reportedRaw}" but content looks ${heuristic} — auto-detect is unreliable here, retry with an explicit language` });
|
|
303
|
+
recommendations.push(`Auto-detected language disagrees with content. Re-run with language=${heuristic} to confirm.`);
|
|
304
|
+
} else if (reportedNorm && heuristic) {
|
|
305
|
+
checks.push({ id: "language", status: "pass", detail: `Auto-detected "${reportedRaw}" agrees with content` });
|
|
306
|
+
} else {
|
|
307
|
+
checks.push({ id: "language", status: "info", detail: `Server reported "${reportedRaw ?? "null"}"; content heuristic inconclusive` });
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// 7. Cross-check
|
|
311
|
+
if (crossCheck?.performed) {
|
|
312
|
+
const sim = crossCheck.similarity ?? 0;
|
|
313
|
+
if (sim >= SIMILARITY_WARN) checks.push({ id: "cross_check", status: "pass", detail: `Re-transcription with language=${crossCheck.languageUsed ?? "auto"} matches ${(sim * 100).toFixed(1)}%` });
|
|
314
|
+
else if (sim >= SIMILARITY_FAIL) checks.push({ id: "cross_check", status: "warn", detail: `Passes differ by ${((1 - sim) * 100).toFixed(1)}% — wording is not stable` });
|
|
315
|
+
else checks.push({ id: "cross_check", status: "fail", detail: `Passes diverge (${(sim * 100).toFixed(1)}% match) — low confidence, needs human review` });
|
|
316
|
+
if (sim < SIMILARITY_WARN) recommendations.push("Transcription is unstable across passes; verify against the audio or pin an explicit language.");
|
|
317
|
+
} else if (crossCheck && !crossCheck.performed) {
|
|
318
|
+
checks.push({ id: "cross_check", status: "info", detail: `Not run${crossCheck.skippedReason ? `: ${crossCheck.skippedReason}` : ""}` });
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// Score
|
|
322
|
+
let score = 1;
|
|
323
|
+
for (const c of checks) {
|
|
324
|
+
if (c.status === "fail") score -= 0.45;
|
|
325
|
+
else if (c.status === "warn") score -= 0.12;
|
|
326
|
+
}
|
|
327
|
+
const confidence = Math.max(0, Math.min(1, Number(score.toFixed(3))));
|
|
328
|
+
const hasFail = checks.some((c) => c.status === "fail");
|
|
329
|
+
const hasWarn = checks.some((c) => c.status === "warn");
|
|
330
|
+
const verdict: Verdict = hasFail ? "failed" : hasWarn ? "review" : "ok";
|
|
331
|
+
|
|
332
|
+
return {
|
|
333
|
+
verdict,
|
|
334
|
+
confidence,
|
|
335
|
+
checks,
|
|
336
|
+
recommendations,
|
|
337
|
+
language: { requested: requestedLanguage, reported: reportedRaw, normalized: reportedNorm, heuristic, consistent },
|
|
338
|
+
timings: { audioSeconds, asrLatencySeconds },
|
|
339
|
+
crossCheck: crossCheck ?? { performed: false, skippedReason: "disabled" },
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Extract spelled-out measurements as digits.
|
|
345
|
+
* The transcript keeps its original wording; this is additive derived data
|
|
346
|
+
* so nothing the user dictated is silently rewritten.
|
|
347
|
+
*/
|
|
348
|
+
export function extractMeasurements(text: string): Array<{ raw: string; value: number; unit: string }> {
|
|
349
|
+
const UNIT: Record<string, string> = { centimetro: "cm", centímetro: "cm", centimetros: "cm", centímetros: "cm", milimetro: "mm", milímetro: "mm", milimetros: "mm", milímetros: "mm", metro: "m", metros: "m", polegada: "in", polegadas: "in" };
|
|
350
|
+
const out: Array<{ raw: string; value: number; unit: string }> = [];
|
|
351
|
+
const low = text.toLowerCase();
|
|
352
|
+
const unitRe = "cent[ií]metros?|mil[ií]metros?|metros?|polegadas?";
|
|
353
|
+
|
|
354
|
+
// Numeric form, including the spoken decimal "26 ponto 2 centímetros".
|
|
355
|
+
const numRe = new RegExp(`(\\d+(?:[.,]\\d+)?)\\s*(?:ponto\\s+(\\d+))?\\s*(${unitRe})\\b`, "g");
|
|
356
|
+
let m: RegExpExecArray | null;
|
|
357
|
+
while ((m = numRe.exec(low))) {
|
|
358
|
+
const whole = Number(m[1]!.replace(",", "."));
|
|
359
|
+
const frac = m[2] ? Number(`0.${m[2]}`) : 0;
|
|
360
|
+
const value = whole + frac;
|
|
361
|
+
if (Number.isFinite(value)) out.push({ raw: m[0]!.trim(), value: Number(value.toFixed(3)), unit: UNIT[m[3]!] ?? m[3]! });
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// Spoken form: "vinte seis centímetros", "quarenta e um centímetros",
|
|
365
|
+
// "vinte seis centímetros ponto dois milímetros". Written Brazilian
|
|
366
|
+
// Portuguese inserts "e"; spoken usually does not, so it is optional.
|
|
367
|
+
const tensRe = "vinte|trinta|quarenta|cinquenta|sessenta|setenta|oitenta|noventa";
|
|
368
|
+
const onesRe = "dezasseis|dezesseis|dezessete|dezoito|dezenove|quinze|quatorze|catorze|treze|doze|onze|dez|nove|oito|sete|seis|cinco|quatro|tr[eê]s|duas|dois|uma|um";
|
|
369
|
+
const spelledRe = new RegExp(`\\b(?:(?<tens>${tensRe})(?:\\s+e)?(?:\\s+(?<ones>${onesRe}))?|(?<single>${onesRe}))\\s*(${unitRe})(?:\\s+ponto\\s+(?<frac>\\d+))?\\b`, "g");
|
|
370
|
+
while ((m = spelledRe.exec(low))) {
|
|
371
|
+
const g = m.groups ?? {};
|
|
372
|
+
const value = (tensValue(g.tens) + onesValue(g.ones)) || onesValue(g.single);
|
|
373
|
+
if (!value) continue;
|
|
374
|
+
const unit = UNIT[m[4]!] ?? m[4]!;
|
|
375
|
+
out.push({ raw: m[0]!.trim(), value, unit });
|
|
376
|
+
}
|
|
377
|
+
return out;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const TENS: Record<string, number> = { vinte: 20, trinta: 30, quarenta: 40, cinquenta: 50, sessenta: 60, setenta: 70, oitenta: 80, noventa: 90 };
|
|
381
|
+
const ONES: Record<string, number> = { um: 1, uma: 1, dois: 2, duas: 2, tres: 3, três: 3, quatro: 4, cinco: 5, seis: 6, sete: 7, oito: 8, nove: 9, dez: 10, onze: 11, doze: 12, treze: 13, catorze: 14, quatorze: 14, quinze: 15, dezasseis: 16, dezesseis: 16, dezessete: 17, dezoito: 18, dezenove: 19 };
|
|
382
|
+
|
|
383
|
+
function tensValue(w?: string): number {
|
|
384
|
+
return w ? TENS[w] ?? 0 : 0;
|
|
385
|
+
}
|
|
386
|
+
function onesValue(w?: string): number {
|
|
387
|
+
return w ? ONES[w] ?? 0 : 0;
|
|
388
|
+
}
|