@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/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
+ }