File size: 9,442 Bytes
0cfc3a7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
94873fd
0cfc3a7
 
 
f98e127
0cfc3a7
 
 
 
 
 
 
 
 
 
 
 
 
94873fd
0cfc3a7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
77ea3c2
0cfc3a7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
/**
 * The scorer's result for one recording, with the teacher's diagnostic trace: the data of the trace dashboard
 * (static/trace.html). Produced by Scorer.score(..., diagnostics=True) in app/scorer.py; POST /api/score returns it
 * to a teacher, and POST /api/lab/score to the lab. A mock from the real pipeline: scripts/mock_trace.py writes
 * static/assets/trace-mock.json.
 *
 * Units: scores are 0..100; probabilities 0..1 (posteriors of the wav2vec2 CTC model); `logp` and `gop` are natural
 * logs; frames are the model's (~20 ms each, `TraceFrames.ms`), [start, end) half-open.
 * IPA symbols are the phrase bank's. `sound` is the archi.py symbol of a letter with several correct realisations
 * (Q for ق: ʔ / k / q; K, TH, DH, ZH, J, E, O), and equals `ipa` for any other sound.
 * This file is documentation: nothing compiles it.
 */

export type Ipa = string;
/** "_" is "nothing heard" (a deletion). */
export type Heard = Ipa | "_";

/** The whole result of one recording. */
export interface ScoreResult {
  score: number;                  // the sentence's score (TraceSentence.final)
  variant: { ar: string; tr: string; ipa: string };  // the accepted reading the audio fit best
  heard_ipa: string;              // greedy decode: what the model heard, free of the expected text
  heard_tr: string;               // the same in Hebrew letters, cut at the aligned word starts
  unrelated: boolean;             // a different sentence (TraceUnrelated): score capped, no tips
  words: Word[];
  tips: Tip[];                    // the most crucial mistakes, severity first (at most config.max_tips)
  issues: Issue[];                // the rest of the imprecise sounds
  references: number;             // native TTS baselines used
  dialect?: "urban" | "rural";    // what the accepted realisations point to (never part of the score)
  mixed_dialect?: boolean;
  calibrated?: { expected: Ipa; heard: Heard; from: number; to: number }[];   // teacher floors that applied
  phoneme_heatmap?: Heatmap;
  trace?: Trace;                  // teachers only
  phrase?: { he: string; ar: string };   // the mock only: what was said
}

export interface Word {
  ar: string;
  tr: string;                     // Hebrew transliteration of the expected word
  heard_tr: string;
  score: number;                  // word_min_weight · min + (1 − word_min_weight) · mean of its phonemes
  letters: { t: string; score: number; why?: string; tip?: string }[];   // the transliteration letter by letter
  phonemes: Phoneme[];
}

export interface Phoneme {
  ipa: Ipa;
  word: number;
  score: number;
  flagged: boolean;               // under flag_below and beyond the pair's margin: a mistake
  heard: Heard | null;            // set when flagged
  sound?: string;                 // archi symbol, when not ipa
  realised?: Ipa;                 // a class sound: the realisation the model heard (ʔ for Q)
  inserted?: Ipa;                 // a vowel heard before this consonant (rule 3.10)
}

export interface Tip { title: string; text: string; key: string; severity: 1 | 2 | 3; word: string; word_tr: string; score: number }
export interface Issue { sound: string; word_tr: string; score: number; missing: boolean; heard: string | null }

export interface Heatmap {
  duration_ms: number | null;
  frames: number;
  frame_bins: number;             // ≤ 180: each bin is the max over its frames
  rows: { phoneme: string; expected: boolean; values: number[] }[];   // values 0..100, one per bin
  tracks: { expected: Span[]; heard: Span[] };
}
export interface Span { phoneme: Ipa; start: number; end: number }

// ---------------------------------------------------------------- the trace

export interface Trace {
  config: TraceConfig;
  /** Stage 0, Viterbi: every accepted reading (and its diphthong forms) the audio was aligned to. */
  variants: TraceVariant[];
  frames: TraceFrames;
  aipa: string;                   // the chosen reading with archi symbols
  unrelated: TraceUnrelated;
  references: { mean: number; sounds: number }[];
  /** Stages 1–5 per sound, in order: gop → exemption → insertion → baseline → calibration → final. */
  tokens: TraceToken[];
  words: TraceWord[];
  sentence: TraceSentence;
  tips: { key: string; severity: 1 | 2 | 3; score: number }[];
}

export interface TraceConfig {
  leniency: number;               // gop_factor = exp(gop · leniency)
  confident_peak: number;         // confidence = min(1, p_target / confident_peak)
  consonant_window: number | null; // max context frames on either side of a consonant span
  deleted_peak: number;           // deleted: p_target < deleted_peak and p_competitor < deleted_comp
  deleted_comp: number;
  flag_below: number;             // a sound under this (and beyond its margin) is flagged
  flag_below_sound: Record<string, number>; // per sound, in place of flag_below (set per model)
  issue_below: number;            // a sound under this is listed as imprecise
  near_vowel_floor: number;
  word_min_weight: number;
  vowel_weight: number;           // a vowel's weight in the sentence's phone mean
  unrelated_ratio: number;
  unrelated_cap: number;
  letter_good: number;
  max_tips: number;
  max_issues: number;
  ref_min_mean: number;
  margin_default: number;
  ayin_deleted_floor: number;
  margins: { target: Ipa; heard: Heard; margin: number }[];   // the pairs that need a clearer win (rules 6.2)
  exemptions: ("glottal_dropped" | "article_vowel_elided" | "near_vowel" | "unmarked_short_vowel" | "final_b_devoicing" | "word_final_vowel_linking")[];
  insertion: { score_cap: number; min_peak: number; min_frames: number } | null;
  disabled: string[];             // rule switches the teacher turned off
}

export interface TraceVariant {
  ar: string;
  ipa: string;
  logp: number | null;            // the CTC path's log-prob (null: the audio is too short for this reading)
  chosen: boolean;                // the highest logp wins
  tokens: string[];               // the path's tokens (archi symbols), in order
}

export interface TraceFrames {
  count: number;                  // T
  ms: number;                     // per frame
  energy: number[] | null;        // RMS per frame, 0..1 of the loudest (null when scored without the audio)
}

export interface TraceUnrelated {
  heard: string;                  // greedy decode
  distance: number;               // edit distance from the chosen reading (class members count as equal)
  tokens: number;
  ratio: number;                  // distance / tokens
  threshold: number;              // over this: a different sentence
  unrelated: boolean;
}

export type StageName = "gop" | "exemption" | "insertion" | "baseline" | "calibration";
export type Stage =
  | { stage: "gop"; score: number }
  | { stage: "exemption"; score: number; applied: TraceConfig["exemptions"][number] | null }
  | { stage: "insertion"; score: number; applied: Ipa | null }   // the vowel heard inside the cluster
  | { stage: "baseline"; score: number; ref: number | null; applied: boolean }   // ref: the native voice's score
  | { stage: "calibration"; score: number; floor: number | null; applied: boolean };

export interface TraceToken {
  i: number;
  word: number;
  ipa: Ipa;
  sound: string;
  class: Ipa[] | null;            // a class sound's accepted realisations
  realised: Ipa | null;
  span: [number, number];         // frames the CTC path emits it in
  window: [number, number];       // frames it was read from: the end of the sound before → the start of the one after
  curves: TraceCurves;
  p_target: number;               // peak posterior of the sound in its window
  competitor: Ipa | null;         // strongest other sound, neighbours and blank excluded (null: none, or unnamed)
  competitor_other: boolean;      // the strongest is a sound outside Arabic and Hebrew
  p_competitor: number;           // its peak posterior in the window
  competitor_frames: number;      // frames where it clears deleted_comp; one frame may be an onset transient
  gop: number;                    // ln(p_target / max(p_target, p_competitor)) ≤ 0
  confidence: number;
  gop_factor: number;
  confidence_factor: number;      // score = 100 · gop_factor · confidence_factor
  deleted: boolean;
  stages: Stage[];
  final: number;
  margin: { needed: number; got: number; beyond: boolean };   // the competitor's win against the pair's margin
  vowel: boolean;
  weight: number;
  flagged: boolean;
  rule: { id: string; title: string; severity: 1 | 2 | 3 } | null;   // pronunciation-rules.md
  tip: boolean;
  issue: boolean;
}

/** Posteriors frame by frame around a sound: frames curves.start .. start + length, its window ± 4. */
export interface TraceCurves {
  start: number;
  target: number[];
  competitor: number[] | null;
  prev: number[] | null;          // the sound before (masked: never a competitor)
  next: number[] | null;          // the sound after (masked too)
  blank: number[];
}

export interface TraceWord { tr: string; scores: number[]; min: number; mean: number; score: number }

export interface TraceSentence {
  phone_mean: number;             // weighted: vowels count vowel_weight
  word_mean: number;
  phone_weight: number;
  word_weight: number;
  before_cap: number;             // phone_weight · phone_mean + word_weight · word_mean
  final: number;
  capped: boolean;                // unrelated: capped at unrelated_cap
}