akki2825 commited on
Commit
7cc1bbc
Β·
verified Β·
1 Parent(s): 8835d4b

Upload 7 files

Browse files
Files changed (7) hide show
  1. README.md +41 -7
  2. acoustic_markers.py +258 -0
  3. alignment_markers.py +643 -0
  4. app.py +221 -0
  5. features.py +119 -0
  6. markers.py +515 -0
  7. requirements.txt +11 -0
README.md CHANGED
@@ -1,13 +1,47 @@
1
  ---
2
- title: Language Attrition Speech Variables
3
- emoji: πŸ“ˆ
4
- colorFrom: red
5
- colorTo: red
6
  sdk: gradio
7
- sdk_version: 6.18.0
8
- python_version: '3.13'
9
  app_file: app.py
10
  pinned: false
 
11
  ---
12
 
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: Language Attrition - derive & model
3
+ emoji: πŸ“Š
4
+ colorFrom: indigo
5
+ colorTo: pink
6
  sdk: gradio
7
+ sdk_version: 4.44.1
8
+ python_version: "3.10"
9
  app_file: app.py
10
  pinned: false
11
+ license: mit
12
  ---
13
 
14
+ # Language Attrition: derive the numbers, then model them
15
+
16
+ A point-and-click tool for the *Bye-lingual* seminar. No transcribing, no
17
+ Spanish, no Praat, no code. Runs on CPU (it does **not** transcribe audio).
18
+
19
+ **Tab 1 - Derive.** Upload a recording **and its transcript** (the existing
20
+ word-timestamped `.json`). The app derives ~25 linguistic factors: disfluency
21
+ (filled / empty pauses, repetitions, retractions), fluency (speech /
22
+ articulation rate, phonation %), complexity (MLU, TTR, MTLD), and acoustics (F0,
23
+ jitter, shimmer, HNR, spectral tilt, MFCCs). Stack several speakers and download
24
+ the table.
25
+
26
+ **Tab 2 - Model.** Use the table you built (or upload a CSV joined with your
27
+ questionnaire predictors), pick two variables, and get the scatter, the
28
+ correlation (Pearson or Spearman), and the regression line. A correlation
29
+ heatmap shows all variables at once.
30
+
31
+ The numbers are identical to the course pipeline (same `markers.py` /
32
+ `acoustic_markers.py`). Transcription is **not** done here: this Space consumes an
33
+ existing transcript. Phase-2 markers (VOT, vowel space, rhythm) need forced
34
+ alignment (MFA) and are not produced.
35
+
36
+ ## Transcript format
37
+ A JSON file, either a bare list or `{"chunks": [...]}`, where each chunk is:
38
+ ```json
39
+ {"text": "Pues", "timestamp": {"start": 2.94, "end": 3.08}}
40
+ ```
41
+ The `[*]` disfluency markers must be kept. Per-speaker transcripts can be
42
+ exported from the pipeline with `pipeline/export_transcripts_json.py`.
43
+
44
+ ## Files
45
+ - `app.py` - Gradio UI (Derive + Model tabs)
46
+ - `features.py` - parse transcript β†’ extract_markers β†’ flat feature row
47
+ - `markers.py`, `acoustic_markers.py`, `alignment_markers.py` - vendored from `pipeline/`
acoustic_markers.py ADDED
@@ -0,0 +1,258 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Phase 1 acoustic markers: F0, voice quality, spectral tilt, and MFCCs.
2
+
3
+ Extracts features directly from the audio waveform using parselmouth (Praat)
4
+ and librosa. No forced alignment required β€” operates on the full recording
5
+ or on voiced segments detected by Praat's pitch algorithm.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import numpy as np
11
+ import parselmouth
12
+ from parselmouth.praat import call
13
+ import librosa
14
+
15
+
16
+ # ---------------------------------------------------------------------------
17
+ # Helpers
18
+ # ---------------------------------------------------------------------------
19
+
20
+ def _load_sound(audio_path: str) -> parselmouth.Sound:
21
+ """Load audio as a parselmouth Sound object."""
22
+ return parselmouth.Sound(audio_path)
23
+
24
+
25
+ def _voiced_f0(pitch: parselmouth.Pitch) -> np.ndarray:
26
+ """Return only the voiced (non-zero) F0 values from a Pitch object."""
27
+ f0 = pitch.selected_array["frequency"]
28
+ return f0[f0 > 0]
29
+
30
+
31
+ # ---------------------------------------------------------------------------
32
+ # F0 / pitch markers
33
+ # ---------------------------------------------------------------------------
34
+
35
+ def extract_f0(sound: parselmouth.Sound,
36
+ time_step: float = 0.01,
37
+ floor: float = 75.0,
38
+ ceiling: float = 600.0) -> dict:
39
+ """Extract fundamental frequency statistics.
40
+
41
+ Returns mean, median, SD, min, max, range, slope (Hz/s), and
42
+ the coefficient of variation for voiced frames.
43
+ """
44
+ pitch = sound.to_pitch_cc(time_step=time_step,
45
+ pitch_floor=floor,
46
+ pitch_ceiling=ceiling)
47
+ f0 = _voiced_f0(pitch)
48
+
49
+ if len(f0) < 2:
50
+ return {
51
+ "f0_mean_hz": None, "f0_median_hz": None,
52
+ "f0_sd_hz": None, "f0_min_hz": None,
53
+ "f0_max_hz": None, "f0_range_hz": None,
54
+ "f0_cv": None, "f0_slope_hz_per_s": None,
55
+ "voiced_frames": int(len(f0)),
56
+ }
57
+
58
+ # F0 slope: linear regression over voiced frames
59
+ times = np.arange(len(f0)) * time_step
60
+ slope = float(np.polyfit(times, f0, 1)[0])
61
+
62
+ return {
63
+ "f0_mean_hz": round(float(np.mean(f0)), 2),
64
+ "f0_median_hz": round(float(np.median(f0)), 2),
65
+ "f0_sd_hz": round(float(np.std(f0, ddof=1)), 2),
66
+ "f0_min_hz": round(float(np.min(f0)), 2),
67
+ "f0_max_hz": round(float(np.max(f0)), 2),
68
+ "f0_range_hz": round(float(np.max(f0) - np.min(f0)), 2),
69
+ "f0_cv": round(float(np.std(f0, ddof=1) / np.mean(f0)), 4),
70
+ "f0_slope_hz_per_s": round(slope, 4),
71
+ "voiced_frames": int(len(f0)),
72
+ }
73
+
74
+
75
+ # ---------------------------------------------------------------------------
76
+ # Voice quality markers (jitter, shimmer, HNR)
77
+ # ---------------------------------------------------------------------------
78
+
79
+ def extract_voice_quality(sound: parselmouth.Sound,
80
+ floor: float = 75.0,
81
+ ceiling: float = 600.0) -> dict:
82
+ """Extract jitter, shimmer, and harmonics-to-noise ratio.
83
+
84
+ Uses Praat's standard algorithms via parselmouth.
85
+ """
86
+ pitch = sound.to_pitch_cc(pitch_floor=floor, pitch_ceiling=ceiling)
87
+ point_process = call(sound, "To PointProcess (periodic, cc)",
88
+ floor, ceiling)
89
+
90
+ # Duration bounds for the measurements
91
+ t_start = sound.xmin
92
+ t_end = sound.xmax
93
+
94
+ # --- Jitter ---
95
+ jitter_local = call(point_process, "Get jitter (local)",
96
+ t_start, t_end, 0.0001, 0.02, 1.3)
97
+ jitter_rap = call(point_process, "Get jitter (rap)",
98
+ t_start, t_end, 0.0001, 0.02, 1.3)
99
+
100
+ # --- Shimmer ---
101
+ shimmer_local = call([sound, point_process], "Get shimmer (local)",
102
+ t_start, t_end, 0.0001, 0.02, 1.3, 1.6)
103
+ shimmer_local_db = call([sound, point_process], "Get shimmer (local_dB)",
104
+ t_start, t_end, 0.0001, 0.02, 1.3, 1.6)
105
+
106
+ # --- HNR ---
107
+ harmonicity = call(sound, "To Harmonicity (cc)",
108
+ 0.01, floor, 0.1, 1.0)
109
+ hnr_mean = call(harmonicity, "Get mean", t_start, t_end)
110
+
111
+ def _safe_round(v, n=4):
112
+ if v is None or (isinstance(v, float) and np.isnan(v)):
113
+ return None
114
+ return round(v, n)
115
+
116
+ return {
117
+ "jitter_local_pct": _safe_round(jitter_local * 100 if jitter_local else None),
118
+ "jitter_rap_pct": _safe_round(jitter_rap * 100 if jitter_rap else None),
119
+ "shimmer_local_pct": _safe_round(shimmer_local * 100 if shimmer_local else None),
120
+ "shimmer_local_db": _safe_round(shimmer_local_db),
121
+ "hnr_mean_db": _safe_round(hnr_mean, 2),
122
+ }
123
+
124
+
125
+ # ---------------------------------------------------------------------------
126
+ # Spectral tilt (H1-H2)
127
+ # ---------------------------------------------------------------------------
128
+
129
+ def extract_spectral_tilt(sound: parselmouth.Sound,
130
+ floor: float = 75.0,
131
+ ceiling: float = 600.0,
132
+ time_step: float = 0.01) -> dict:
133
+ """Estimate H1-H2 (difference between first and second harmonic amplitudes).
134
+
135
+ Measured at each voiced frame using the spectrum; returns the mean
136
+ across the recording. H1-H2 > 0 indicates breathier phonation.
137
+ """
138
+ pitch = sound.to_pitch_cc(time_step=time_step,
139
+ pitch_floor=floor,
140
+ pitch_ceiling=ceiling)
141
+
142
+ h1_h2_values = []
143
+ n_frames = pitch.get_number_of_frames()
144
+
145
+ for i in range(1, n_frames + 1):
146
+ f0_val = pitch.get_value_in_frame(i)
147
+ if f0_val == 0 or np.isnan(f0_val):
148
+ continue
149
+
150
+ t = pitch.get_time_from_frame_number(i)
151
+ # Extract a short window around this frame
152
+ win_start = max(sound.xmin, t - 0.025)
153
+ win_end = min(sound.xmax, t + 0.025)
154
+ if win_end - win_start < 0.02:
155
+ continue
156
+
157
+ try:
158
+ segment = sound.extract_part(win_start, win_end,
159
+ parselmouth.WindowShape.HANNING, 1.0, False)
160
+ spectrum = segment.to_spectrum()
161
+
162
+ # H1: amplitude at F0, H2: amplitude at 2*F0
163
+ h1_freq = f0_val
164
+ h2_freq = 2 * f0_val
165
+ # Get bin indices closest to H1 and H2
166
+ bin_h1 = max(1, round(h1_freq / spectrum.dx))
167
+ bin_h2 = max(1, round(h2_freq / spectrum.dx))
168
+ n_bins = spectrum.get_number_of_bins()
169
+
170
+ if bin_h1 > n_bins or bin_h2 > n_bins:
171
+ continue
172
+
173
+ amp_h1 = abs(spectrum.get_value_in_bin(bin_h1))
174
+ amp_h2 = abs(spectrum.get_value_in_bin(bin_h2))
175
+
176
+ if amp_h1 > 0 and amp_h2 > 0:
177
+ h1_db = 20 * np.log10(amp_h1)
178
+ h2_db = 20 * np.log10(amp_h2)
179
+ h1_h2_values.append(h1_db - h2_db)
180
+ except Exception:
181
+ continue
182
+
183
+ if not h1_h2_values:
184
+ return {"h1_h2_mean_db": None, "h1_h2_sd_db": None}
185
+
186
+ return {
187
+ "h1_h2_mean_db": round(float(np.mean(h1_h2_values)), 2),
188
+ "h1_h2_sd_db": round(float(np.std(h1_h2_values, ddof=1)), 2)
189
+ if len(h1_h2_values) > 1 else None,
190
+ }
191
+
192
+
193
+ # ---------------------------------------------------------------------------
194
+ # MFCCs (via librosa β€” already a project dependency)
195
+ # ---------------------------------------------------------------------------
196
+
197
+ def extract_mfccs(audio_path: str,
198
+ sr: int = 22050,
199
+ n_mfcc: int = 13,
200
+ n_fft: int = 2048,
201
+ hop_length: int = 512) -> dict:
202
+ """Extract MFCC summary statistics from the full recording.
203
+
204
+ Returns per-coefficient mean and SD, plus the overall mean of
205
+ delta and delta-delta MFCCs.
206
+ """
207
+ y, sr_actual = librosa.load(audio_path, sr=sr)
208
+
209
+ mfccs = librosa.feature.mfcc(y=y, sr=sr_actual,
210
+ n_mfcc=n_mfcc,
211
+ n_fft=n_fft,
212
+ hop_length=hop_length)
213
+ delta = librosa.feature.delta(mfccs)
214
+ delta2 = librosa.feature.delta(mfccs, order=2)
215
+
216
+ # Per-coefficient statistics (mean across time)
217
+ mfcc_means = np.mean(mfccs, axis=1)
218
+ mfcc_sds = np.std(mfccs, axis=1, ddof=1)
219
+
220
+ result: dict = {}
221
+
222
+ # Individual coefficient means (MFCC-1 through MFCC-13)
223
+ for i in range(n_mfcc):
224
+ result[f"mfcc_{i+1}_mean"] = round(float(mfcc_means[i]), 4)
225
+ result[f"mfcc_{i+1}_sd"] = round(float(mfcc_sds[i]), 4)
226
+
227
+ # Summary across all coefficients for delta and delta-delta
228
+ result["mfcc_delta_mean"] = round(float(np.mean(delta)), 6)
229
+ result["mfcc_delta_sd"] = round(float(np.std(delta, ddof=1)), 6)
230
+ result["mfcc_delta2_mean"] = round(float(np.mean(delta2)), 6)
231
+ result["mfcc_delta2_sd"] = round(float(np.std(delta2, ddof=1)), 6)
232
+
233
+ return result
234
+
235
+
236
+ # ---------------------------------------------------------------------------
237
+ # Public entry point
238
+ # ---------------------------------------------------------------------------
239
+
240
+ def extract_acoustic_markers(audio_path: str) -> dict:
241
+ """Extract all Phase-1 acoustic markers from an audio file.
242
+
243
+ Returns a dict with sub-keys: "f0", "voice_quality", "spectral_tilt",
244
+ and "mfcc".
245
+ """
246
+ sound = _load_sound(audio_path)
247
+
248
+ f0 = extract_f0(sound)
249
+ vq = extract_voice_quality(sound)
250
+ tilt = extract_spectral_tilt(sound)
251
+ mfcc = extract_mfccs(audio_path)
252
+
253
+ return {
254
+ "f0": f0,
255
+ "voice_quality": vq,
256
+ "spectral_tilt": tilt,
257
+ "mfcc": mfcc,
258
+ }
alignment_markers.py ADDED
@@ -0,0 +1,643 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Phase 2 alignment-based markers: VOT, vowel formants, rhythm metrics.
2
+
3
+ Requires:
4
+ - Montreal Forced Aligner (``mfa``) on PATH β€” install via conda:
5
+ conda install -c conda-forge montreal-forced-aligner
6
+ mfa model download acoustic spanish_mfa
7
+ mfa model download dictionary spanish_mfa
8
+ - praatio for TextGrid parsing (pip install praatio)
9
+ - parselmouth (already installed in Phase 1)
10
+
11
+ Workflow:
12
+ 1. ``prepare_corpus`` β€” write .lab files from Whisper transcripts
13
+ 2. ``run_mfa_alignment`` β€” call ``mfa align`` via subprocess
14
+ 3. ``extract_alignment_markers`` β€” read TextGrid + audio β†’ features
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import math
20
+ import shutil
21
+ import subprocess
22
+ import tempfile
23
+ from pathlib import Path
24
+
25
+ import numpy as np
26
+ import parselmouth
27
+ from parselmouth.praat import call
28
+ from praatio import textgrid as tgio
29
+
30
+ # ---------------------------------------------------------------------------
31
+ # Phone‐set definitions (MFA Spanish IPA inventory)
32
+ # ---------------------------------------------------------------------------
33
+
34
+ VOWELS = {"a", "e", "i", "o", "u",
35
+ "a\u02D0", "e\u02D0", "i\u02D0", "o\u02D0", "u\u02D0"} # long variants
36
+
37
+ # Voiceless stops β€” primary VOT targets (Spanish short-lag β†’ German long-lag)
38
+ # MFA Spanish uses dental tΜͺ and palatal c alongside plain p, k
39
+ VOICELESS_STOPS = {"p", "t", "k", "t\u032A", "c"} # tΜͺ = dental t
40
+ # Voiced stops β€” secondary VOT targets (Spanish lead voicing)
41
+ # MFA Spanish uses dental dΜͺ alongside plain b, Ι‘/g, and palatal ɟʝ
42
+ VOICED_STOPS = {"b", "d", "d\u032A", "\u0261", "g", "\u025F\u02DD"} # dΜͺ, Ι‘, ɟʝ
43
+ # Voiced stop approximant allophones (excluded from VOT)
44
+ APPROXIMANTS = {"\u03B2", "\u00F0", "\u0263", # Ξ² Γ° Ι£
45
+ "\u0279"} # ΙΉ (if present)
46
+
47
+ # Corner vowels for Vowel Space Area
48
+ CORNER_VOWELS = {"a", "i", "u"}
49
+
50
+ # All consonant-like phones (anything not a vowel and not silence)
51
+ _SILENCE_LABELS = {"", "sil", "sp", "spn", "<eps>"}
52
+
53
+
54
+ def _is_vowel(phone: str) -> bool:
55
+ """True if *phone* is a vowel (including long variants)."""
56
+ return phone.lower().strip() in VOWELS
57
+
58
+
59
+ def _is_silence(phone: str) -> bool:
60
+ return phone.lower().strip() in _SILENCE_LABELS
61
+
62
+
63
+ # ---------------------------------------------------------------------------
64
+ # 1. Corpus preparation (Whisper transcript β†’ .lab files)
65
+ # ---------------------------------------------------------------------------
66
+
67
+ def prepare_corpus(
68
+ transcripts: dict[str, dict],
69
+ corpus_dir: str | Path,
70
+ audio_dir: str | Path,
71
+ ) -> Path:
72
+ """Create an MFA-compatible corpus from Whisper transcripts.
73
+
74
+ Parameters
75
+ ----------
76
+ transcripts : dict
77
+ Mapping ``{speaker_id: transcript_dict}`` where each
78
+ ``transcript_dict`` is the output of ``transcribe.transcribe()``.
79
+ corpus_dir : path
80
+ Directory to write the corpus into (created if needed).
81
+ audio_dir : path
82
+ Directory containing the original WAV files.
83
+
84
+ Returns
85
+ -------
86
+ Path to the corpus directory.
87
+ """
88
+ corpus_dir = Path(corpus_dir)
89
+ corpus_dir.mkdir(parents=True, exist_ok=True)
90
+ audio_dir = Path(audio_dir)
91
+
92
+ for speaker_id, transcript in transcripts.items():
93
+ # Get clean text (strip disfluency markers, keep real words)
94
+ words = []
95
+ for chunk in transcript["chunks"]:
96
+ text = chunk["text"].strip()
97
+ if text and text != "[*]":
98
+ # Strip brackets from CrisperWhisper tokens like [UH]
99
+ clean = text.strip("[]")
100
+ if clean:
101
+ words.append(clean)
102
+ lab_text = " ".join(words)
103
+
104
+ # Resolve audio file
105
+ audio_path = Path(transcript["audio_path"])
106
+ if not audio_path.is_absolute():
107
+ audio_path = audio_dir / audio_path.name
108
+ if not audio_path.exists():
109
+ # Try matching by speaker ID
110
+ candidates = list(audio_dir.glob(f"{speaker_id}*.[Ww][Aa][Vv]"))
111
+ if candidates:
112
+ audio_path = candidates[0]
113
+
114
+ # Write symlink to audio + .lab file
115
+ dest_wav = corpus_dir / f"{speaker_id}.wav"
116
+ dest_lab = corpus_dir / f"{speaker_id}.lab"
117
+
118
+ if not dest_wav.exists():
119
+ # Symlink so we don't copy large files
120
+ dest_wav.symlink_to(audio_path.resolve())
121
+
122
+ dest_lab.write_text(lab_text, encoding="utf-8")
123
+
124
+ return corpus_dir
125
+
126
+
127
+ # ---------------------------------------------------------------------------
128
+ # 2. MFA alignment
129
+ # ---------------------------------------------------------------------------
130
+
131
+ def run_mfa_alignment(
132
+ corpus_dir: str | Path,
133
+ output_dir: str | Path,
134
+ dictionary: str = "spanish_mfa",
135
+ acoustic_model: str = "spanish_mfa",
136
+ num_jobs: int = 4,
137
+ clean: bool = True,
138
+ ) -> Path:
139
+ """Run ``mfa align`` on a prepared corpus.
140
+
141
+ Returns the output directory containing TextGrid files.
142
+ Raises RuntimeError if ``mfa`` is not found or alignment fails.
143
+ """
144
+ corpus_dir = Path(corpus_dir)
145
+ output_dir = Path(output_dir)
146
+ output_dir.mkdir(parents=True, exist_ok=True)
147
+
148
+ mfa_bin = shutil.which("mfa")
149
+ if mfa_bin is None:
150
+ raise RuntimeError(
151
+ "Montreal Forced Aligner (mfa) not found on PATH.\n"
152
+ "Install via conda:\n"
153
+ " conda install -c conda-forge montreal-forced-aligner\n"
154
+ " mfa model download acoustic spanish_mfa\n"
155
+ " mfa model download dictionary spanish_mfa"
156
+ )
157
+
158
+ cmd = [
159
+ mfa_bin, "align",
160
+ str(corpus_dir),
161
+ dictionary,
162
+ acoustic_model,
163
+ str(output_dir),
164
+ "--output_format", "long_textgrid",
165
+ "--num_jobs", str(num_jobs),
166
+ "--single_speaker",
167
+ ]
168
+ if clean:
169
+ cmd.append("--clean")
170
+
171
+ result = subprocess.run(cmd, capture_output=True, text=True, timeout=600)
172
+ if result.returncode != 0:
173
+ raise RuntimeError(
174
+ f"MFA alignment failed (exit {result.returncode}):\n"
175
+ f"STDOUT:\n{result.stdout[-2000:]}\n"
176
+ f"STDERR:\n{result.stderr[-2000:]}"
177
+ )
178
+
179
+ return output_dir
180
+
181
+
182
+ # ---------------------------------------------------------------------------
183
+ # 3. TextGrid parsing
184
+ # ---------------------------------------------------------------------------
185
+
186
+ def parse_textgrid(tg_path: str | Path) -> dict:
187
+ """Parse an MFA TextGrid into word and phone interval lists.
188
+
189
+ Returns
190
+ -------
191
+ dict with keys ``"words"`` and ``"phones"``, each a list of
192
+ ``(start, end, label)`` tuples.
193
+ """
194
+ tg = tgio.openTextgrid(str(tg_path), includeEmptyIntervals=False)
195
+
196
+ phones = []
197
+ words = []
198
+
199
+ for tier_name in tg.tierNames:
200
+ tier = tg.getTier(tier_name)
201
+ lower = tier_name.lower()
202
+ entries = [(float(s), float(e), label)
203
+ for s, e, label in tier.entries]
204
+ if "phone" in lower:
205
+ phones = entries
206
+ elif "word" in lower:
207
+ words = entries
208
+
209
+ return {"words": words, "phones": phones}
210
+
211
+
212
+ # ---------------------------------------------------------------------------
213
+ # 4a. VOT extraction
214
+ # ---------------------------------------------------------------------------
215
+
216
+ def _detect_burst_time(
217
+ sound: parselmouth.Sound,
218
+ start: float,
219
+ end: float,
220
+ ) -> float | None:
221
+ """Detect the burst release within a stop-consonant interval.
222
+
223
+ Uses the intensity contour: the burst is the sharpest intensity
224
+ rise within the stop interval. Returns the absolute time of the
225
+ burst, or None if detection fails.
226
+ """
227
+ duration = end - start
228
+ if duration < 0.010:
229
+ return None
230
+
231
+ try:
232
+ segment = sound.extract_part(start, end,
233
+ parselmouth.WindowShape.RECTANGULAR,
234
+ 1.0, False)
235
+ intensity = segment.to_intensity(minimum_pitch=400, time_step=0.0005)
236
+ except Exception:
237
+ return None
238
+
239
+ n = intensity.get_number_of_frames()
240
+ if n < 3:
241
+ return None
242
+
243
+ # Find the frame with the largest intensity rise (derivative)
244
+ best_rise = -np.inf
245
+ best_time = None
246
+ for i in range(2, n + 1):
247
+ t_prev = intensity.get_time_from_frame_number(i - 1)
248
+ t_cur = intensity.get_time_from_frame_number(i)
249
+ val_prev = intensity.get_value(t_prev)
250
+ val_cur = intensity.get_value(t_cur)
251
+
252
+ if np.isnan(val_prev) or np.isnan(val_cur):
253
+ continue
254
+
255
+ rise = val_cur - val_prev
256
+ if rise > best_rise:
257
+ best_rise = rise
258
+ best_time = t_cur
259
+
260
+ if best_time is None or best_rise < 1.0: # minimum 1 dB rise
261
+ return None
262
+
263
+ # Convert back to absolute time
264
+ return start + best_time
265
+
266
+
267
+ def extract_vot(
268
+ phones: list[tuple[float, float, str]],
269
+ sound: parselmouth.Sound,
270
+ ) -> dict:
271
+ """Measure Voice Onset Time for voiceless and voiced stops.
272
+
273
+ MFA places stop and vowel boundaries contiguously (no gap), so the
274
+ stop interval itself contains both closure and release/aspiration.
275
+
276
+ For voiceless stops /p, t, k/:
277
+ VOT = stop_end βˆ’ burst_time (positive: time from burst to vowel onset).
278
+ For voiced stops /b, d, g/:
279
+ VOT = stop_end βˆ’ burst_time (may be short if voicing starts early).
280
+
281
+ When burst detection fails, VOT is estimated as a fraction of the
282
+ stop duration (the release portion, typically the final ~40%).
283
+
284
+ Returns per-phone-type mean VOT and individual measurements.
285
+ """
286
+ measurements: list[dict] = []
287
+
288
+ for i, (start, end, label) in enumerate(phones):
289
+ phone = label.lower().strip()
290
+
291
+ # Only measure stops followed by a vowel
292
+ if phone not in VOICELESS_STOPS and phone not in VOICED_STOPS:
293
+ continue
294
+
295
+ # Find the next non-silence phone
296
+ next_phone = None
297
+ for j in range(i + 1, len(phones)):
298
+ _, _, nlabel = phones[j]
299
+ if not _is_silence(nlabel):
300
+ next_phone = phones[j]
301
+ break
302
+
303
+ if next_phone is None or not _is_vowel(next_phone[2]):
304
+ continue
305
+
306
+ vowel_start = next_phone[0]
307
+ stop_type = "voiceless" if phone in VOICELESS_STOPS else "voiced"
308
+ stop_dur_ms = (end - start) * 1000
309
+
310
+ # Detect burst within the stop interval
311
+ burst_time = _detect_burst_time(sound, start, end)
312
+
313
+ if burst_time is not None:
314
+ # VOT = time from burst to stop/vowel boundary
315
+ vot_ms = (end - burst_time) * 1000
316
+ else:
317
+ # Fallback: estimate VOT as the final 40% of the stop
318
+ # (closure is ~60%, release+aspiration is ~40%)
319
+ vot_ms = stop_dur_ms * 0.4
320
+ burst_time = start + (end - start) * 0.6
321
+
322
+ # Sanity: VOT shouldn't exceed the stop duration
323
+ vot_ms = min(vot_ms, stop_dur_ms)
324
+ vot_ms = max(vot_ms, 0.0)
325
+
326
+ measurements.append({
327
+ "phone": phone,
328
+ "type": stop_type,
329
+ "position_s": round(start, 3),
330
+ "stop_dur_ms": round(stop_dur_ms, 2),
331
+ "vot_ms": round(vot_ms, 2),
332
+ "burst_time_s": round(burst_time, 4),
333
+ "vowel_onset_s": round(vowel_start, 4),
334
+ })
335
+
336
+ # Aggregate by phone and type
337
+ voiceless_vots = [m["vot_ms"] for m in measurements
338
+ if m["type"] == "voiceless"]
339
+ voiced_vots = [m["vot_ms"] for m in measurements
340
+ if m["type"] == "voiced"]
341
+
342
+ per_phone: dict[str, list[float]] = {}
343
+ for m in measurements:
344
+ per_phone.setdefault(m["phone"], []).append(m["vot_ms"])
345
+
346
+ phone_means = {p: round(float(np.mean(vs)), 2)
347
+ for p, vs in per_phone.items()}
348
+
349
+ return {
350
+ "voiceless_mean_vot_ms": round(float(np.mean(voiceless_vots)), 2)
351
+ if voiceless_vots else None,
352
+ "voiceless_sd_vot_ms": round(float(np.std(voiceless_vots, ddof=1)), 2)
353
+ if len(voiceless_vots) > 1 else None,
354
+ "voiced_mean_vot_ms": round(float(np.mean(voiced_vots)), 2)
355
+ if voiced_vots else None,
356
+ "voiced_sd_vot_ms": round(float(np.std(voiced_vots, ddof=1)), 2)
357
+ if len(voiced_vots) > 1 else None,
358
+ "per_phone_mean_ms": phone_means,
359
+ "n_voiceless": len(voiceless_vots),
360
+ "n_voiced": len(voiced_vots),
361
+ "measurements": measurements,
362
+ }
363
+
364
+
365
+ # ---------------------------------------------------------------------------
366
+ # 4b. Vowel formant extraction + Vowel Space Area
367
+ # ---------------------------------------------------------------------------
368
+
369
+ def extract_vowel_formants(
370
+ phones: list[tuple[float, float, str]],
371
+ sound: parselmouth.Sound,
372
+ max_formant: float = 5500.0,
373
+ n_formants: int = 5,
374
+ ) -> dict:
375
+ """Extract F1/F2/F3 at the temporal midpoint of each vowel.
376
+
377
+ Parameters
378
+ ----------
379
+ max_formant : float
380
+ Maximum formant frequency for Burg analysis.
381
+ Use 5500 for female speakers, 5000 for male speakers.
382
+ Default 5500 (conservative for mixed/unknown gender).
383
+
384
+ Returns per-vowel-type mean formants, all individual measurements,
385
+ and Vowel Space Area computed from corner vowels /a, i, u/.
386
+ """
387
+ formant_obj = call(sound, "To Formant (burg)",
388
+ 0.025, n_formants, max_formant, 0.025, 50.0)
389
+
390
+ measurements: list[dict] = []
391
+
392
+ for start, end, label in phones:
393
+ phone = label.lower().strip()
394
+ if not _is_vowel(phone):
395
+ continue
396
+
397
+ # Strip length marks for grouping
398
+ vowel_id = phone.replace("\u02D0", "")
399
+ duration = end - start
400
+ if duration < 0.02:
401
+ continue
402
+
403
+ midpoint = (start + end) / 2
404
+
405
+ f1 = call(formant_obj, "Get value at time", 1, midpoint, "Hertz", "Linear")
406
+ f2 = call(formant_obj, "Get value at time", 2, midpoint, "Hertz", "Linear")
407
+ f3 = call(formant_obj, "Get value at time", 3, midpoint, "Hertz", "Linear")
408
+
409
+ if np.isnan(f1) or np.isnan(f2):
410
+ continue
411
+
412
+ measurements.append({
413
+ "vowel": vowel_id,
414
+ "midpoint_s": round(midpoint, 4),
415
+ "duration_ms": round(duration * 1000, 1),
416
+ "f1_hz": round(f1, 1),
417
+ "f2_hz": round(f2, 1),
418
+ "f3_hz": round(f3, 1) if not np.isnan(f3) else None,
419
+ })
420
+
421
+ # Per-vowel means
422
+ vowel_data: dict[str, list[dict]] = {}
423
+ for m in measurements:
424
+ vowel_data.setdefault(m["vowel"], []).append(m)
425
+
426
+ per_vowel: dict[str, dict] = {}
427
+ for v, items in vowel_data.items():
428
+ f1s = [it["f1_hz"] for it in items]
429
+ f2s = [it["f2_hz"] for it in items]
430
+ per_vowel[v] = {
431
+ "n": len(items),
432
+ "f1_mean_hz": round(float(np.mean(f1s)), 1),
433
+ "f1_sd_hz": round(float(np.std(f1s, ddof=1)), 1) if len(f1s) > 1 else None,
434
+ "f2_mean_hz": round(float(np.mean(f2s)), 1),
435
+ "f2_sd_hz": round(float(np.std(f2s, ddof=1)), 1) if len(f2s) > 1 else None,
436
+ }
437
+
438
+ # Vowel Space Area (triangle: /a/, /i/, /u/)
439
+ vsa = _compute_vsa(per_vowel)
440
+
441
+ # Vowel Formant Dispersion
442
+ vfd = _compute_vfd(per_vowel)
443
+
444
+ return {
445
+ "per_vowel": per_vowel,
446
+ "vowel_space_area": vsa,
447
+ "vowel_formant_dispersion": vfd,
448
+ "n_total": len(measurements),
449
+ "measurements": measurements,
450
+ }
451
+
452
+
453
+ def _compute_vsa(per_vowel: dict[str, dict]) -> float | None:
454
+ """Vowel Space Area β€” triangle formed by /a/, /i/, /u/ in F1Γ—F2 space.
455
+
456
+ Uses the Shoelace formula for the area of a triangle:
457
+ VSA = 0.5 * |F1a(F2i - F2u) + F1i(F2u - F2a) + F1u(F2a - F2i)|
458
+ """
459
+ corners = {}
460
+ for v in CORNER_VOWELS:
461
+ if v in per_vowel:
462
+ corners[v] = (per_vowel[v]["f1_mean_hz"],
463
+ per_vowel[v]["f2_mean_hz"])
464
+
465
+ if len(corners) < 3:
466
+ return None
467
+
468
+ a = corners["a"]
469
+ i = corners["i"]
470
+ u = corners["u"]
471
+
472
+ area = 0.5 * abs(
473
+ a[0] * (i[1] - u[1]) +
474
+ i[0] * (u[1] - a[1]) +
475
+ u[0] * (a[1] - i[1])
476
+ )
477
+ return round(area, 1)
478
+
479
+
480
+ def _compute_vfd(per_vowel: dict[str, dict]) -> float | None:
481
+ """Vowel Formant Dispersion β€” mean Euclidean distance from centroid."""
482
+ if not per_vowel:
483
+ return None
484
+
485
+ f1_all = [v["f1_mean_hz"] for v in per_vowel.values()]
486
+ f2_all = [v["f2_mean_hz"] for v in per_vowel.values()]
487
+
488
+ centroid_f1 = np.mean(f1_all)
489
+ centroid_f2 = np.mean(f2_all)
490
+
491
+ distances = []
492
+ for v in per_vowel.values():
493
+ d = math.sqrt((v["f1_mean_hz"] - centroid_f1) ** 2 +
494
+ (v["f2_mean_hz"] - centroid_f2) ** 2)
495
+ distances.append(d)
496
+
497
+ return round(float(np.mean(distances)), 1)
498
+
499
+
500
+ # ---------------------------------------------------------------------------
501
+ # 4c. Rhythm metrics
502
+ # ---------------------------------------------------------------------------
503
+
504
+ def extract_rhythm_metrics(
505
+ phones: list[tuple[float, float, str]],
506
+ ) -> dict:
507
+ """Compute rhythm metrics from phone-level intervals.
508
+
509
+ Returns %V, deltaC, deltaV, VarcoC, VarcoV, rPVI-C, nPVI-V.
510
+ """
511
+ # Step 1: classify each phone as C or V, skip silence
512
+ cv_intervals: list[tuple[float, float, str]] = []
513
+ for start, end, label in phones:
514
+ if _is_silence(label):
515
+ continue
516
+ category = "V" if _is_vowel(label) else "C"
517
+ cv_intervals.append((start, end, category))
518
+
519
+ if not cv_intervals:
520
+ return _empty_rhythm()
521
+
522
+ # Step 2: merge adjacent same-type intervals
523
+ merged: list[tuple[float, float, str]] = [cv_intervals[0]]
524
+ for start, end, cat in cv_intervals[1:]:
525
+ prev_start, prev_end, prev_cat = merged[-1]
526
+ if cat == prev_cat and abs(start - prev_end) < 0.001:
527
+ # Merge
528
+ merged[-1] = (prev_start, end, cat)
529
+ else:
530
+ merged.append((start, end, cat))
531
+
532
+ # Step 3: compute durations
533
+ c_durations = [(e - s) * 1000 for s, e, cat in merged if cat == "C"]
534
+ v_durations = [(e - s) * 1000 for s, e, cat in merged if cat == "V"]
535
+
536
+ if len(c_durations) < 2 or len(v_durations) < 2:
537
+ return _empty_rhythm()
538
+
539
+ c_arr = np.array(c_durations)
540
+ v_arr = np.array(v_durations)
541
+ total_dur = sum(c_durations) + sum(v_durations)
542
+
543
+ # %V β€” proportion of vocalic intervals
544
+ pct_v = (sum(v_durations) / total_dur) * 100 if total_dur > 0 else 0
545
+
546
+ # deltaC, deltaV β€” standard deviations
547
+ delta_c = float(np.std(c_arr, ddof=1))
548
+ delta_v = float(np.std(v_arr, ddof=1))
549
+
550
+ # VarcoC, VarcoV β€” variation coefficients (rate-normalized)
551
+ mean_c = float(np.mean(c_arr))
552
+ mean_v = float(np.mean(v_arr))
553
+ varco_c = (delta_c / mean_c) * 100 if mean_c > 0 else 0
554
+ varco_v = (delta_v / mean_v) * 100 if mean_v > 0 else 0
555
+
556
+ # rPVI-C β€” raw Pairwise Variability Index for consonants
557
+ rpvi_c = float(np.mean(np.abs(np.diff(c_arr))))
558
+
559
+ # nPVI-V β€” normalized PVI for vowels
560
+ npvi_v = _npvi(v_arr)
561
+
562
+ return {
563
+ "pct_v": round(pct_v, 2),
564
+ "delta_c_ms": round(delta_c, 2),
565
+ "delta_v_ms": round(delta_v, 2),
566
+ "varco_c": round(varco_c, 2),
567
+ "varco_v": round(varco_v, 2),
568
+ "rpvi_c": round(rpvi_c, 2),
569
+ "npvi_v": round(npvi_v, 2),
570
+ "n_c_intervals": len(c_durations),
571
+ "n_v_intervals": len(v_durations),
572
+ "mean_c_ms": round(mean_c, 2),
573
+ "mean_v_ms": round(mean_v, 2),
574
+ }
575
+
576
+
577
+ def _npvi(durations: np.ndarray) -> float:
578
+ """Normalized Pairwise Variability Index.
579
+
580
+ nPVI = 100 * (1/(n-1)) * Ξ£ |d_k - d_{k+1}| / ((d_k + d_{k+1}) / 2)
581
+ """
582
+ n = len(durations)
583
+ if n < 2:
584
+ return 0.0
585
+ total = 0.0
586
+ for k in range(n - 1):
587
+ avg = (durations[k] + durations[k + 1]) / 2
588
+ if avg > 0:
589
+ total += abs(durations[k] - durations[k + 1]) / avg
590
+ return 100.0 * total / (n - 1)
591
+
592
+
593
+ def _empty_rhythm() -> dict:
594
+ return {
595
+ "pct_v": None, "delta_c_ms": None, "delta_v_ms": None,
596
+ "varco_c": None, "varco_v": None,
597
+ "rpvi_c": None, "npvi_v": None,
598
+ "n_c_intervals": 0, "n_v_intervals": 0,
599
+ "mean_c_ms": None, "mean_v_ms": None,
600
+ }
601
+
602
+
603
+ # ---------------------------------------------------------------------------
604
+ # 5. Public entry point
605
+ # ---------------------------------------------------------------------------
606
+
607
+ def extract_alignment_markers(
608
+ audio_path: str | Path,
609
+ textgrid_path: str | Path,
610
+ max_formant: float = 5500.0,
611
+ ) -> dict:
612
+ """Extract all Phase-2 alignment-based markers.
613
+
614
+ Parameters
615
+ ----------
616
+ audio_path : path
617
+ Path to the WAV file.
618
+ textgrid_path : path
619
+ Path to the MFA-produced TextGrid.
620
+ max_formant : float
621
+ Maximum formant frequency for Burg analysis (5500 for female,
622
+ 5000 for male, default 5500).
623
+
624
+ Returns
625
+ -------
626
+ dict with keys ``"vot"``, ``"vowel_formants"``, ``"rhythm"``.
627
+ """
628
+ sound = parselmouth.Sound(str(audio_path))
629
+ tg_data = parse_textgrid(textgrid_path)
630
+ phones = tg_data["phones"]
631
+
632
+ if not phones:
633
+ return {"vot": {}, "vowel_formants": {}, "rhythm": _empty_rhythm()}
634
+
635
+ vot = extract_vot(phones, sound)
636
+ formants = extract_vowel_formants(phones, sound, max_formant=max_formant)
637
+ rhythm = extract_rhythm_metrics(phones)
638
+
639
+ return {
640
+ "vot": vot,
641
+ "vowel_formants": formants,
642
+ "rhythm": rhythm,
643
+ }
app.py ADDED
@@ -0,0 +1,221 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Language Attrition: derive linguistic factors, then model them.
2
+
3
+ Two tabs, both point-and-click (no Spanish, no Praat, no code needed):
4
+ 1. Derive β€” upload a recording, get its ~25 linguistic factors, stack them.
5
+ 2. Model β€” pick two variables, see the scatter, correlation and regression.
6
+
7
+ The numbers are identical to the course pipeline (same code under the hood).
8
+ Phase-2 markers (VOT, vowel space, rhythm) need forced alignment and are not
9
+ produced here.
10
+ """
11
+ import os
12
+ import tempfile
13
+
14
+ import gradio as gr
15
+ import librosa
16
+ import numpy as np
17
+ import pandas as pd
18
+ import matplotlib
19
+ matplotlib.use("Agg")
20
+ import matplotlib.pyplot as plt
21
+ from scipy import stats
22
+
23
+ from features import derive_features_with_text, FEATURE_COLUMNS
24
+
25
+ MAX_AUDIO_SECONDS = 600
26
+ PRIMARY, ACCENT = "#004782", "#d1495b"
27
+ ALL_COLS = ["Speaker"] + FEATURE_COLUMNS
28
+
29
+
30
+ # ----------------------------------------------------------------------------
31
+ # Tab 1: Derive
32
+ # ----------------------------------------------------------------------------
33
+ def _to_df(rows):
34
+ if not rows:
35
+ return pd.DataFrame(columns=ALL_COLS)
36
+ return pd.DataFrame(rows)[ALL_COLS]
37
+
38
+
39
+ def _save_csv(df, prefix="features"):
40
+ f = tempfile.NamedTemporaryFile(mode="w", suffix=".csv", prefix=f"{prefix}_", delete=False)
41
+ df.to_csv(f.name, index=False)
42
+ f.close()
43
+ return f.name
44
+
45
+
46
+ def add_recording(audio_path, transcript_path, label, rows):
47
+ rows = rows or []
48
+ if audio_path is None:
49
+ return rows, _to_df(rows), None, "", "Please upload an audio file first."
50
+ if transcript_path is None:
51
+ return rows, _to_df(rows), None, "", "Please upload the transcript (.json) for this speaker."
52
+ dur = librosa.get_duration(path=audio_path)
53
+ if dur > MAX_AUDIO_SECONDS:
54
+ return rows, _to_df(rows), None, "", f"Audio too long ({dur:.0f}s). Max is {MAX_AUDIO_SECONDS}s."
55
+ try:
56
+ row, text = derive_features_with_text(audio_path, transcript_path, (label or "").strip() or None)
57
+ except Exception as e: # noqa: BLE001
58
+ return rows, _to_df(rows), None, "", f"Error while processing: {e}"
59
+ rows = [r for r in rows if r["Speaker"] != row["Speaker"]] + [row]
60
+ df = _to_df(rows)
61
+ return rows, df, _save_csv(df), text, (
62
+ f"Added **{row['Speaker']}**. Table now has {len(rows)} recording(s). "
63
+ "Switch to the *Model* tab when you have a few."
64
+ )
65
+
66
+
67
+ def clear_table():
68
+ return [], _to_df([]), None, "", "Table cleared."
69
+
70
+
71
+ # ----------------------------------------------------------------------------
72
+ # Tab 2: Model
73
+ # ----------------------------------------------------------------------------
74
+ def load_for_modeling(rows, uploaded, source):
75
+ if source.startswith("Upload") and uploaded is not None:
76
+ try:
77
+ df = pd.read_csv(uploaded)
78
+ except Exception as e: # noqa: BLE001
79
+ return None, gr.update(choices=[]), gr.update(choices=[]), f"Could not read CSV: {e}"
80
+ else:
81
+ df = _to_df(rows or [])
82
+ num = [c for c in df.columns if pd.api.types.is_numeric_dtype(df[c])]
83
+ if len(num) < 2:
84
+ return df, gr.update(choices=num), gr.update(choices=num), (
85
+ "Need at least two numeric columns. Derive a few recordings first, "
86
+ "or upload a CSV that includes your predictor columns."
87
+ )
88
+ return (df,
89
+ gr.update(choices=num, value=num[0]),
90
+ gr.update(choices=num, value=num[1]),
91
+ f"Loaded {len(df)} rows and {len(num)} numeric variables. Pick two to compare.")
92
+
93
+
94
+ def _interpret(r, p, n):
95
+ strength = "weak" if abs(r) < 0.3 else "moderate" if abs(r) < 0.6 else "strong"
96
+ direction = "positive" if r > 0 else "negative"
97
+ sig = ("**statistically significant** (p < 0.05)" if p < 0.05
98
+ else "**not significant** (p β‰₯ 0.05) β€” could be chance, especially with few speakers")
99
+ return (f"_Reading: a {strength} {direction} relationship, {sig}._\n\n"
100
+ "With a small number of speakers this is a **hypothesis, not a finding**.")
101
+
102
+
103
+ def model_pair(df, x, y, method):
104
+ if df is None or not len(df) or x is None or y is None:
105
+ return None, "Load a table and pick two variables."
106
+ if x == y:
107
+ return None, "Pick two *different* variables."
108
+ sub = df[[x, y]].apply(pd.to_numeric, errors="coerce").dropna()
109
+ n = len(sub)
110
+ if n < 3:
111
+ return None, "Need at least 3 speakers with both values present."
112
+ pear = stats.pearsonr(sub[x], sub[y])
113
+ spear = stats.spearmanr(sub[x], sub[y])
114
+ b, a = np.polyfit(sub[x], sub[y], 1)
115
+ use_pear = method.startswith("Pearson")
116
+ r, p = (pear if use_pear else spear)
117
+
118
+ fig, ax = plt.subplots(figsize=(6.2, 4.6))
119
+ ax.scatter(sub[x], sub[y], s=85, color=PRIMARY, edgecolor="white", zorder=3)
120
+ xs = np.array([sub[x].min(), sub[x].max()])
121
+ ax.plot(xs, a + b * xs, color=ACCENT, lw=2)
122
+ ax.set_xlabel(x); ax.set_ylabel(y); ax.grid(alpha=0.3)
123
+ ax.set_title(f"{method.split()[0]} = {r:+.2f} (p = {p:.3f}, n = {n})", color=PRIMARY)
124
+ fig.tight_layout()
125
+
126
+ md = (f"### {x} vs {y}\n\n"
127
+ f"- **{method.split()[0]} correlation** = {r:+.3f} Β· p = {p:.3f} Β· n = {n}\n"
128
+ f"- **Regression line**: `{y} = {a:.2f} + {b:.3f} Γ— {x}`\n"
129
+ f"- Pearson r = {pear[0]:+.3f} (p={pear[1]:.3f}) · Spearman ρ = {spear[0]:+.3f} (p={spear[1]:.3f})\n\n"
130
+ + _interpret(r, p, n))
131
+ return fig, md
132
+
133
+
134
+ def corr_heatmap(df, method):
135
+ if df is None or not len(df):
136
+ return None, "Load a table first."
137
+ num = df.select_dtypes("number")
138
+ if num.shape[1] < 2:
139
+ return None, "Need at least two numeric columns."
140
+ C = num.corr(method="spearman" if method.startswith("Spearman") else "pearson")
141
+ sz = min(1.2 + 0.5 * len(C), 13)
142
+ fig, ax = plt.subplots(figsize=(sz, sz))
143
+ im = ax.imshow(C, vmin=-1, vmax=1, cmap="RdBu_r")
144
+ ax.set_xticks(range(len(C))); ax.set_xticklabels(C.columns, rotation=90, fontsize=7)
145
+ ax.set_yticks(range(len(C))); ax.set_yticklabels(C.columns, fontsize=7)
146
+ ax.set_title(f"{method.split()[0]} correlations across all variables", color=PRIMARY)
147
+ fig.colorbar(im, shrink=0.7, label="r")
148
+ fig.tight_layout()
149
+ return fig, f"Heatmap of {num.shape[1]} variables. Blue = move together, red = move apart."
150
+
151
+
152
+ # ----------------------------------------------------------------------------
153
+ # UI
154
+ # ----------------------------------------------------------------------------
155
+ with gr.Blocks(title="Language Attrition: derive & model") as demo:
156
+ gr.Markdown(
157
+ "# Language Attrition: derive the numbers, then model them\n"
158
+ "Upload a recording **and its transcript** and the app derives the linguistic "
159
+ "factors (disfluency, fluency, complexity, pitch and voice quality). Then compare "
160
+ "any two of them. No transcribing, no Spanish, no Praat, no code."
161
+ )
162
+ table_state = gr.State([]) # derived rows
163
+ model_df_state = gr.State() # dataframe loaded into the Model tab
164
+
165
+ with gr.Tab("1 Β· Derive features"):
166
+ with gr.Row():
167
+ with gr.Column():
168
+ audio = gr.Audio(type="filepath", label="Recording (≀ 10 min)")
169
+ transcript_in = gr.File(label="Transcript (.json, word-timestamped)",
170
+ file_types=[".json"])
171
+ label = gr.Textbox(label="Speaker label", placeholder="e.g. A014 (optional)")
172
+ with gr.Row():
173
+ add_btn = gr.Button("Derive features β†’ add to table", variant="primary")
174
+ clear_btn = gr.Button("Clear table")
175
+ status = gr.Markdown()
176
+ with gr.Column():
177
+ transcript_box = gr.Textbox(label="Transcript (from your upload)", lines=6)
178
+ table = gr.Dataframe(label="Your feature table", interactive=False, wrap=True)
179
+ csv_out = gr.File(label="Download feature table (.csv)")
180
+ add_btn.click(add_recording, [audio, transcript_in, label, table_state],
181
+ [table_state, table, csv_out, transcript_box, status])
182
+ clear_btn.click(clear_table, None,
183
+ [table_state, table, csv_out, transcript_box, status])
184
+
185
+ with gr.Tab("2 Β· Model"):
186
+ gr.Markdown(
187
+ "Model the table you built in tab 1, **or** upload a CSV "
188
+ "(e.g. the features joined with your questionnaire predictors)."
189
+ )
190
+ with gr.Row():
191
+ source = gr.Radio(["Use table from tab 1", "Upload a CSV"],
192
+ value="Use table from tab 1", label="Data source")
193
+ uploaded = gr.File(label="CSV (if uploading)", file_types=[".csv"])
194
+ load_btn = gr.Button("Load data", variant="primary")
195
+ load_status = gr.Markdown()
196
+ with gr.Row():
197
+ x_var = gr.Dropdown(label="X variable", choices=[])
198
+ y_var = gr.Dropdown(label="Y variable", choices=[])
199
+ method = gr.Radio(["Pearson (straight-line)", "Spearman (rank)"],
200
+ value="Spearman (rank)", label="Correlation type")
201
+ with gr.Row():
202
+ plot_btn = gr.Button("Plot & correlate", variant="primary")
203
+ heat_btn = gr.Button("Correlation heatmap (all variables)")
204
+ with gr.Row():
205
+ plot_out = gr.Plot(label="Scatter + regression line")
206
+ result_md = gr.Markdown()
207
+ heat_out = gr.Plot(label="Correlation heatmap")
208
+
209
+ load_btn.click(load_for_modeling, [table_state, uploaded, source],
210
+ [model_df_state, x_var, y_var, load_status])
211
+ plot_btn.click(model_pair, [model_df_state, x_var, y_var, method],
212
+ [plot_out, result_md])
213
+ heat_btn.click(corr_heatmap, [model_df_state, method], [heat_out, load_status])
214
+
215
+ gr.Markdown(
216
+ "---\n*Phase-2 markers (VOT, vowel space, rhythm) need forced alignment and "
217
+ "are not produced here. Numbers match the course pipeline exactly.*"
218
+ )
219
+
220
+ if __name__ == "__main__":
221
+ demo.queue(max_size=60, default_concurrency_limit=2).launch(share=True)
features.py ADDED
@@ -0,0 +1,119 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Derive the Phase-1 linguistic factors from an audio file + its transcript.
2
+
3
+ No transcription happens here: the student uploads the existing word-timestamped
4
+ transcript (JSON), and we run markers.extract_markers (disfluency, fluency,
5
+ complexity + Phase-1 acoustic). No MFA / forced alignment, so Phase-2 markers
6
+ (VOT, vowel space, rhythm) are not produced.
7
+
8
+ The output is one flat dict per recording, ready to stack into a table for
9
+ modeling. Column names are kept short and spreadsheet-friendly.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import os
15
+
16
+ import librosa
17
+
18
+ from markers import extract_markers
19
+
20
+ # (column label, dotted path into extract_markers() output, decimals)
21
+ FLATTEN = [
22
+ ("Words", "word_count_clean", 0),
23
+ ("Duration_s", "duration_s", 1),
24
+ # disfluency (per 100 words)
25
+ ("FP_per_100w", "filled_pauses.per_100_words", 2),
26
+ ("EP_per_100w", "empty_pauses.per_100_words", 2),
27
+ ("RP_per_100w", "repetitions.per_100_words", 2),
28
+ ("RT_per_100w", "retractions.per_100_words", 2),
29
+ ("PauseMean_ms", "temporal.pause_mean_ms", 1),
30
+ # fluency
31
+ ("SpeechRate_spm", "temporal.speech_rate_spm", 2),
32
+ ("ArticRate_spm", "temporal.articulation_rate_spm", 2),
33
+ ("Phonation_pct", "temporal.phonation_time_ratio_pct", 2),
34
+ # complexity
35
+ ("MLU", "temporal.mlu_words", 2),
36
+ ("TTR", "lexical_diversity.ttr", 4),
37
+ ("MTLD", "lexical_diversity.mtld", 2),
38
+ ("CodeSwitch", "code_switching.count", 0),
39
+ # acoustic: pitch
40
+ ("F0_Mean", "acoustic.f0.f0_mean_hz", 2),
41
+ ("F0_SD", "acoustic.f0.f0_sd_hz", 2),
42
+ ("F0_Range", "acoustic.f0.f0_range_hz", 2),
43
+ ("F0_CV", "acoustic.f0.f0_cv", 4),
44
+ ("F0_Slope", "acoustic.f0.f0_slope_hz_per_s", 4),
45
+ # acoustic: voice quality
46
+ ("Jitter_pct", "acoustic.voice_quality.jitter_local_pct", 4),
47
+ ("Shimmer_pct", "acoustic.voice_quality.shimmer_local_pct", 4),
48
+ ("HNR_dB", "acoustic.voice_quality.hnr_mean_db", 2),
49
+ ("H1H2_dB", "acoustic.spectral_tilt.h1_h2_mean_db", 2),
50
+ # acoustic: spectral shape
51
+ ("MFCC1", "acoustic.mfcc.mfcc_1_mean", 2),
52
+ ("MFCC2", "acoustic.mfcc.mfcc_2_mean", 2),
53
+ ("MFCC5", "acoustic.mfcc.mfcc_5_mean", 2),
54
+ ]
55
+
56
+ FEATURE_COLUMNS = [c for c, _, _ in FLATTEN]
57
+
58
+
59
+ def _dig(d: dict, dotted: str):
60
+ cur = d
61
+ for part in dotted.split("."):
62
+ if not isinstance(cur, dict):
63
+ return None
64
+ cur = cur.get(part)
65
+ return cur
66
+
67
+
68
+ def flatten_markers(markers: dict, label: str) -> dict:
69
+ """Turn the nested extract_markers() output into one flat row."""
70
+ row = {"Speaker": label}
71
+ for col, path, nd in FLATTEN:
72
+ v = _dig(markers, path)
73
+ if isinstance(v, (int, float)):
74
+ v = round(v, nd) if nd else int(round(v))
75
+ row[col] = v
76
+ return row
77
+
78
+
79
+ def load_chunks(transcript_path: str) -> list[dict]:
80
+ """Read an uploaded transcript JSON into the chunk format extract_markers wants.
81
+
82
+ Accepts either ``{"chunks": [...]}`` or a bare list. Each chunk needs a
83
+ ``text`` and a ``timestamp`` with ``start`` / ``end`` (seconds). The ``[*]``
84
+ disfluency markers must be kept: filled-pause and pause markers depend on them.
85
+ """
86
+ with open(transcript_path, encoding="utf-8") as f:
87
+ data = json.load(f)
88
+ raw = data.get("chunks", data) if isinstance(data, dict) else data
89
+ if not isinstance(raw, list):
90
+ raise ValueError("Transcript JSON must be a list of chunks or have a 'chunks' key.")
91
+ chunks = []
92
+ for c in raw:
93
+ ts = c.get("timestamp") or {}
94
+ chunks.append({
95
+ "text": c["text"],
96
+ "timestamp": {"start": ts.get("start"), "end": ts.get("end")},
97
+ "confidence": c.get("confidence"),
98
+ })
99
+ return chunks
100
+
101
+
102
+ def derive_features_with_text(audio_path: str, transcript_path: str, label: str | None = None):
103
+ """Derive features from (audio, transcript); return (flat row, transcript text)."""
104
+ if not label:
105
+ label = os.path.splitext(os.path.basename(audio_path))[0]
106
+ chunks = load_chunks(transcript_path)
107
+ duration_s = round(librosa.get_duration(path=audio_path), 3)
108
+ transcript = {"chunks": chunks, "duration_s": duration_s, "audio_path": audio_path}
109
+ markers = extract_markers(transcript)
110
+ full_text = " ".join(
111
+ c["text"].strip() for c in chunks if c["text"].strip() and c["text"] != "[*]"
112
+ )
113
+ return flatten_markers(markers, label), full_text
114
+
115
+
116
+ def derive_features(audio_path: str, transcript_path: str, label: str | None = None) -> dict:
117
+ """Derive features from (audio, transcript) and return the flat feature row."""
118
+ row, _text = derive_features_with_text(audio_path, transcript_path, label)
119
+ return row
markers.py ADDED
@@ -0,0 +1,515 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Extract language attrition markers from a whisper-timestamped transcript.
2
+
3
+ Covers the original 13 Schmid-style + temporal + lexical markers, plus
4
+ Phase-1 acoustic markers (F0, voice quality, spectral tilt, MFCCs).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import re
10
+ import statistics
11
+ from collections import Counter
12
+
13
+ import pyphen
14
+ from lexical_diversity import lex_div
15
+
16
+ from acoustic_markers import extract_acoustic_markers
17
+ from alignment_markers import extract_alignment_markers
18
+
19
+ # ---------------------------------------------------------------------------
20
+ # Word lists
21
+ # ---------------------------------------------------------------------------
22
+
23
+ SPANISH_FILLERS = {
24
+ "eh", "ah", "um", "uh", "este", "em", "mmm", "pues", "bueno",
25
+ "mm", "hm", "ehm", "uhm",
26
+ }
27
+
28
+ # Bracket tokens for filled pauses: CrisperWhisper [UH]/[UM] or
29
+ # whisper-timestamped [*] disfluency markers
30
+ _BRACKET_FP = re.compile(r"^\[U[HM]\]$", re.IGNORECASE)
31
+ _DISFLUENCY_MARKER = re.compile(r"^\[\*\]$")
32
+
33
+ SPANISH_ARTICLES = {"el", "la", "los", "las", "un", "una", "unos", "unas"}
34
+ SPANISH_PREPOSITIONS = {
35
+ "de", "en", "a", "con", "por", "para", "sin", "sobre",
36
+ "entre", "hacia", "hasta", "desde", "segΓΊn", "durante",
37
+ }
38
+ SPANISH_PRONOUNS = {
39
+ "yo", "tΓΊ", "Γ©l", "ella", "nosotros", "nosotras",
40
+ "ellos", "ellas", "usted", "ustedes",
41
+ "me", "te", "se", "nos", "le", "les", "lo", "la",
42
+ "mΓ­", "ti", "sΓ­", "conmigo", "contigo", "consigo",
43
+ "que", "quien", "cual", "donde", "como",
44
+ "este", "ese", "aquel", "esto", "eso", "aquello",
45
+ }
46
+
47
+ FUNCTION_WORD_SETS: dict[str, set[str]] = {
48
+ "article": SPANISH_ARTICLES,
49
+ "preposition": SPANISH_PREPOSITIONS,
50
+ "pronoun": SPANISH_PRONOUNS,
51
+ }
52
+
53
+ # Common Spanish verbs (high-frequency) for word-class tagging
54
+ SPANISH_VERBS = {
55
+ "es", "era", "fue", "ser", "estar", "estΓ‘", "estaba", "estuvo",
56
+ "tiene", "tenΓ­a", "tuvo", "tener", "hacer", "hace", "hizo",
57
+ "ir", "va", "iba", "fue", "viene", "venir", "dar", "dio",
58
+ "decir", "dice", "dijo", "saber", "sabe", "sabΓ­a",
59
+ "poder", "puede", "podΓ­a", "pudo", "querer", "quiere", "querΓ­a",
60
+ "ver", "ve", "vio", "veΓ­a", "poner", "pone", "puso",
61
+ "salir", "sale", "saliΓ³", "llegar", "llega", "llegΓ³",
62
+ "pasar", "pasa", "pasΓ³", "quedar", "queda", "quedΓ³",
63
+ "creer", "cree", "pensaba", "pensar", "piensa",
64
+ "hay", "habΓ­a", "haber", "han", "ha",
65
+ "entrar", "entra", "entrΓ³", "caer", "cae", "cayΓ³",
66
+ "llevar", "lleva", "llevΓ³", "seguir", "sigue", "siguiΓ³",
67
+ "encontrar", "encuentra", "encontrΓ³",
68
+ "empezar", "empieza", "empezΓ³", "intentar", "intenta", "intentΓ³",
69
+ "sentir", "siente", "sintiΓ³",
70
+ }
71
+
72
+ # German words unlikely to appear in Spanish β€” for code-switching detection
73
+ GERMAN_WORDS = {
74
+ "und", "aber", "auch", "schon", "jetzt", "dann", "nicht", "oder",
75
+ "weil", "dass", "noch", "hier", "dort", "halt", "genau",
76
+ "eigentlich", "vielleicht", "natΓΌrlich", "wirklich", "immer",
77
+ "wieder", "heute", "morgen", "gestern", "gerade", "trotzdem",
78
+ "deswegen", "deshalb", "allerdings", "ΓΌbrigens", "sozusagen",
79
+ "naja", "eben", "doch", "mal", "nur", "sehr", "ganz", "etwas",
80
+ "alles", "nichts", "niemand", "jemand", "etwas", "irgendwie",
81
+ "auf", "aus", "bei", "mit", "nach", "seit", "von", "zu",
82
+ "ΓΌber", "unter", "vor", "hinter", "neben", "zwischen",
83
+ "ich", "du", "wir", "ihr", "sie", "mein", "dein", "sein",
84
+ "kein", "diese", "jede", "welche", "solche",
85
+ "kΓΆnnen", "mΓΌssen", "sollen", "wollen", "dΓΌrfen", "mΓΆgen",
86
+ "machen", "gehen", "kommen", "sagen", "wissen", "sehen",
87
+ "geben", "nehmen", "finden", "denken", "stehen", "lassen",
88
+ "sprechen", "halten", "bringen", "leben", "fahren", "arbeiten",
89
+ "spielen", "laufen", "schreiben", "lesen", "essen", "trinken",
90
+ "schlafen", "kaufen", "verkaufen", "helfen", "lernen",
91
+ "arbeit", "schule", "kindergarten", "ausbildung", "beruf",
92
+ "wohnung", "straße", "stadt", "kirche",
93
+ "ja", "nein", "bitte", "danke", "stimmt", "klar", "richtig",
94
+ }
95
+
96
+ # ---------------------------------------------------------------------------
97
+ # Helpers
98
+ # ---------------------------------------------------------------------------
99
+
100
+ _pyphen_dic = pyphen.Pyphen(lang="es_ES")
101
+
102
+
103
+ def _clean_word(w: str) -> str:
104
+ """Lowercase and strip punctuation for comparison."""
105
+ return re.sub(r"[^\w]", "", w.lower())
106
+
107
+
108
+ def _is_disfluency_marker(word: str) -> bool:
109
+ """Check if word is a whisper-timestamped [*] disfluency marker."""
110
+ return bool(_DISFLUENCY_MARKER.match(word.strip()))
111
+
112
+
113
+ def _is_filler(word: str) -> bool:
114
+ """Check if word is a filled pause (bracket token, [*] marker, or Spanish filler)."""
115
+ w = word.strip()
116
+ if _BRACKET_FP.match(w):
117
+ return True
118
+ if _is_disfluency_marker(w):
119
+ return True
120
+ return _clean_word(w) in SPANISH_FILLERS
121
+
122
+
123
+ def _clean_tokens(chunks) -> list[str]:
124
+ """Return lowered, punctuation-stripped tokens excluding fillers and [*] markers."""
125
+ out = []
126
+ for c in chunks:
127
+ if _is_disfluency_marker(c["text"]):
128
+ continue
129
+ w = _clean_word(c["text"])
130
+ if w and not _is_filler(c["text"]):
131
+ out.append(w)
132
+ return out
133
+
134
+
135
+ def _syllable_count(word: str) -> int:
136
+ """Count syllables using pyphen Spanish dictionary."""
137
+ parts = _pyphen_dic.inserted(word).split("-")
138
+ return max(len(parts), 1)
139
+
140
+
141
+ def _word_class(word: str) -> str:
142
+ """Simple rule-based word-class tagger for Spanish."""
143
+ w = _clean_word(word)
144
+ if not w:
145
+ return "other"
146
+ if w in SPANISH_ARTICLES:
147
+ return "article"
148
+ if w in SPANISH_PREPOSITIONS:
149
+ return "preposition"
150
+ if w in SPANISH_PRONOUNS:
151
+ return "pronoun"
152
+ if w in SPANISH_VERBS:
153
+ return "verb"
154
+ # heuristic: treat remaining content words as nouns (open class)
155
+ return "noun"
156
+
157
+
158
+ def _function_word_category(word: str) -> str | None:
159
+ """Return function-word category or None if content word."""
160
+ w = _clean_word(word)
161
+ for cat, wordset in FUNCTION_WORD_SETS.items():
162
+ if w in wordset:
163
+ return cat
164
+ return None
165
+
166
+
167
+ # ---------------------------------------------------------------------------
168
+ # Marker extractors
169
+ # ---------------------------------------------------------------------------
170
+
171
+ def _filled_pauses(chunks) -> list[dict]:
172
+ """SDM-1: Filled pauses (Spanish fillers + [*] disfluency markers)."""
173
+ fps = []
174
+ for i, c in enumerate(chunks):
175
+ if _is_disfluency_marker(c["text"]):
176
+ fps.append({
177
+ "index": i,
178
+ "text": "[*]",
179
+ "source": "whisper-disfluency",
180
+ **c["timestamp"],
181
+ })
182
+ elif _is_filler(c["text"]):
183
+ fps.append({
184
+ "index": i,
185
+ "text": c["text"].strip(),
186
+ "source": "filler-word",
187
+ **c["timestamp"],
188
+ })
189
+ return fps
190
+
191
+
192
+ def _empty_pauses(chunks, threshold_ms: float = 300) -> list[dict]:
193
+ """CDM-2: Empty pauses (inter-word gaps >= threshold).
194
+
195
+ Gaps between real words are measured by looking through [*] markers β€”
196
+ the gap from the last real word's end to the next real word's start
197
+ captures the full silence including any [*] span.
198
+ """
199
+ eps = []
200
+ # Collect indices of real (non-[*]) words
201
+ real_indices = [
202
+ i for i, c in enumerate(chunks) if not _is_disfluency_marker(c["text"])
203
+ ]
204
+ for j in range(1, len(real_indices)):
205
+ prev_i = real_indices[j - 1]
206
+ cur_i = real_indices[j]
207
+ prev_end = chunks[prev_i]["timestamp"]["end"]
208
+ cur_start = chunks[cur_i]["timestamp"]["start"]
209
+ gap_ms = (cur_start - prev_end) * 1000
210
+ if gap_ms >= threshold_ms:
211
+ eps.append({
212
+ "after_index": prev_i,
213
+ "gap_ms": round(gap_ms, 1),
214
+ "start": prev_end,
215
+ "end": cur_start,
216
+ })
217
+ return eps
218
+
219
+
220
+ def _empty_pauses_real(chunks, threshold_ms: float = 300) -> list[dict]:
221
+ """Like _empty_pauses but for temporal calculations (same logic)."""
222
+ return _empty_pauses(chunks, threshold_ms)
223
+
224
+
225
+ def _repetitions(chunks) -> list[dict]:
226
+ """CDM-3: Consecutive identical words (1–3 word spans)."""
227
+ words = [_clean_word(c["text"]) for c in chunks]
228
+ reps = []
229
+ i = 0
230
+ while i < len(words):
231
+ if not words[i] or _is_filler(chunks[i]["text"]):
232
+ i += 1
233
+ continue
234
+ # check spans of length 1, 2, 3
235
+ found = False
236
+ for span in (3, 2, 1):
237
+ if i + 2 * span > len(words):
238
+ continue
239
+ pattern = words[i:i + span]
240
+ if all(pattern):
241
+ candidate = words[i + span:i + 2 * span]
242
+ if pattern == candidate:
243
+ reps.append({
244
+ "index": i,
245
+ "span": span,
246
+ "words": " ".join(pattern),
247
+ })
248
+ i += 2 * span
249
+ found = True
250
+ break
251
+ if not found:
252
+ i += 1
253
+ return reps
254
+
255
+
256
+ def _retractions(chunks) -> list[dict]:
257
+ """CDM-4: Consecutive same-category function words (heuristic)."""
258
+ rts = []
259
+ for i in range(1, len(chunks)):
260
+ if _is_filler(chunks[i]["text"]) or _is_filler(chunks[i - 1]["text"]):
261
+ continue
262
+ cat_prev = _function_word_category(chunks[i - 1]["text"])
263
+ cat_cur = _function_word_category(chunks[i]["text"])
264
+ if cat_prev and cat_cur and cat_prev == cat_cur:
265
+ w_prev = _clean_word(chunks[i - 1]["text"])
266
+ w_cur = _clean_word(chunks[i]["text"])
267
+ if w_prev != w_cur: # same word = repetition, not retraction
268
+ rts.append({
269
+ "index": i - 1,
270
+ "word1": w_prev,
271
+ "word2": w_cur,
272
+ "category": cat_prev,
273
+ "confidence": "medium",
274
+ })
275
+ return rts
276
+
277
+
278
+ def _temporal_markers(chunks, duration_s: float, clean_word_count: int):
279
+ """Markers 5–9: speech rate, articulation rate, phonation ratio, pause stats, MLU."""
280
+ # speaking segments (from first word start to last word end)
281
+ if not chunks:
282
+ return {}
283
+
284
+ total_speaking_time = 0.0
285
+ for c in chunks:
286
+ # Exclude [*] disfluency markers from speaking time β€” they span
287
+ # silence/hesitation gaps, not actual articulation
288
+ if _is_disfluency_marker(c["text"]):
289
+ continue
290
+ ts = c["timestamp"]
291
+ total_speaking_time += ts["end"] - ts["start"]
292
+
293
+ # empty pauses for pause stats β€” use gaps between real words,
294
+ # skipping over [*] markers to find true inter-word silences
295
+ eps = _empty_pauses_real(chunks)
296
+ pause_durations = [ep["gap_ms"] for ep in eps]
297
+
298
+ # utterance segmentation: split at pauses >= 1000ms between real words
299
+ utterances: list[list[str]] = []
300
+ current_utt: list[str] = []
301
+ prev_real_end: float | None = None
302
+ for i, c in enumerate(chunks):
303
+ if _is_disfluency_marker(c["text"]) or _is_filler(c["text"]):
304
+ continue
305
+ w = _clean_word(c["text"])
306
+ if not w:
307
+ continue
308
+ # check if there's a long pause before this word (from last real word)
309
+ if prev_real_end is not None:
310
+ gap_ms = (c["timestamp"]["start"] - prev_real_end) * 1000
311
+ if gap_ms >= 1000 and current_utt:
312
+ utterances.append(current_utt)
313
+ current_utt = []
314
+ current_utt.append(w)
315
+ prev_real_end = c["timestamp"]["end"]
316
+ if current_utt:
317
+ utterances.append(current_utt)
318
+
319
+ # syllable count for syllable-based rates
320
+ clean_words = _clean_tokens(chunks)
321
+ total_syllables = sum(_syllable_count(w) for w in clean_words)
322
+
323
+ speech_rate_wpm = (clean_word_count / duration_s) * 60 if duration_s > 0 else 0
324
+ speech_rate_spm = (total_syllables / duration_s) * 60 if duration_s > 0 else 0
325
+ art_rate_wpm = (
326
+ (clean_word_count / total_speaking_time) * 60
327
+ if total_speaking_time > 0 else 0
328
+ )
329
+ art_rate_spm = (
330
+ (total_syllables / total_speaking_time) * 60
331
+ if total_speaking_time > 0 else 0
332
+ )
333
+ phonation_ratio = (total_speaking_time / duration_s) * 100 if duration_s > 0 else 0
334
+
335
+ utt_lengths = [len(u) for u in utterances]
336
+ mlu = statistics.mean(utt_lengths) if utt_lengths else 0
337
+
338
+ return {
339
+ "speech_rate_wpm": round(speech_rate_wpm, 2),
340
+ "speech_rate_spm": round(speech_rate_spm, 2),
341
+ "articulation_rate_wpm": round(art_rate_wpm, 2),
342
+ "articulation_rate_spm": round(art_rate_spm, 2),
343
+ "phonation_time_ratio_pct": round(phonation_ratio, 2),
344
+ "pause_mean_ms": round(statistics.mean(pause_durations), 1) if pause_durations else None,
345
+ "pause_median_ms": round(statistics.median(pause_durations), 1) if pause_durations else None,
346
+ "pause_std_ms": round(statistics.stdev(pause_durations), 1) if len(pause_durations) > 1 else None,
347
+ "pause_count": len(pause_durations),
348
+ "mlu_words": round(mlu, 2),
349
+ "utterance_count": len(utterances),
350
+ "total_syllables": total_syllables,
351
+ }
352
+
353
+
354
+ def _lexical_diversity(chunks) -> dict:
355
+ """Markers 10–11: TTR and MTLD."""
356
+ tokens = _clean_tokens(chunks)
357
+ if not tokens:
358
+ return {"ttr": None, "mtld": None}
359
+ types = set(tokens)
360
+ ttr = len(types) / len(tokens)
361
+ try:
362
+ mtld = lex_div.mtld(tokens)
363
+ except Exception:
364
+ mtld = None
365
+ return {"ttr": round(ttr, 4), "mtld": round(mtld, 2) if mtld is not None else None}
366
+
367
+
368
+ def _code_switching(chunks) -> list[dict]:
369
+ """Marker 12: German words detected in Spanish speech."""
370
+ switches = []
371
+ for i, c in enumerate(chunks):
372
+ w = _clean_word(c["text"])
373
+ if w in GERMAN_WORDS:
374
+ switches.append({"index": i, "word": w, **c["timestamp"]})
375
+ return switches
376
+
377
+
378
+ def _disfluency_following(chunks, filled_pauses, empty_pauses) -> list[dict]:
379
+ """Marker 13: Word class of the word following each filled/empty pause."""
380
+ results = []
381
+ for fp in filled_pauses:
382
+ idx = fp["index"]
383
+ if idx + 1 < len(chunks):
384
+ next_word = chunks[idx + 1]["text"]
385
+ results.append({
386
+ "disfluency_type": "FP",
387
+ "disfluency": fp["text"],
388
+ "following_word": next_word.strip(),
389
+ "following_class": _word_class(next_word),
390
+ })
391
+ for ep in empty_pauses:
392
+ idx = ep["after_index"]
393
+ if idx + 1 < len(chunks):
394
+ next_word = chunks[idx + 1]["text"]
395
+ results.append({
396
+ "disfluency_type": "EP",
397
+ "disfluency": f"[pause {ep['gap_ms']:.0f}ms]",
398
+ "following_word": next_word.strip(),
399
+ "following_class": _word_class(next_word),
400
+ })
401
+ return results
402
+
403
+
404
+ # ---------------------------------------------------------------------------
405
+ # Main entry point
406
+ # ---------------------------------------------------------------------------
407
+
408
+ def extract_markers(
409
+ transcript: dict,
410
+ duration_s: float | None = None,
411
+ textgrid_path: str | None = None,
412
+ ) -> dict:
413
+ """Extract all markers from a transcript dict.
414
+
415
+ Includes the original Schmid-style + temporal + lexical markers,
416
+ Phase-1 acoustic markers (F0, voice quality, spectral tilt, MFCCs),
417
+ and β€” when a TextGrid is provided β€” Phase-2 alignment-based markers
418
+ (VOT, vowel formants, rhythm metrics).
419
+
420
+ Parameters
421
+ ----------
422
+ transcript : dict
423
+ Output of ``transcribe.transcribe()``.
424
+ duration_s : float, optional
425
+ Override audio duration (defaults to ``transcript["duration_s"]``).
426
+ textgrid_path : str, optional
427
+ Path to an MFA-produced TextGrid. When provided, alignment-based
428
+ markers (VOT, vowel formants/VSA, rhythm metrics) are extracted.
429
+
430
+ Returns
431
+ -------
432
+ dict with keys for every marker group.
433
+ """
434
+ chunks = transcript["chunks"]
435
+ if duration_s is None:
436
+ duration_s = transcript["duration_s"]
437
+
438
+ clean_words = _clean_tokens(chunks)
439
+ word_count = len(clean_words)
440
+
441
+ # A. Schmid disfluency markers
442
+ fps = _filled_pauses(chunks)
443
+ eps = _empty_pauses(chunks)
444
+ rps = _repetitions(chunks)
445
+ rts = _retractions(chunks)
446
+
447
+ per100 = lambda n: round((n / word_count) * 100, 2) if word_count else 0
448
+
449
+ # B. Temporal / fluency
450
+ temporal = _temporal_markers(chunks, duration_s, word_count)
451
+
452
+ # C. Lexical
453
+ lex = _lexical_diversity(chunks)
454
+ cs = _code_switching(chunks)
455
+
456
+ # D. Disfluency context
457
+ df_context = _disfluency_following(chunks, fps, eps)
458
+ class_counts = Counter(d["following_class"] for d in df_context)
459
+
460
+ # E. Acoustic markers (Phase 1)
461
+ audio_path = transcript.get("audio_path")
462
+ if audio_path:
463
+ acoustic = extract_acoustic_markers(audio_path)
464
+ else:
465
+ acoustic = {}
466
+
467
+ # F. Alignment-based markers (Phase 2) β€” requires MFA TextGrid
468
+ if textgrid_path and audio_path:
469
+ alignment = extract_alignment_markers(audio_path, textgrid_path)
470
+ else:
471
+ alignment = {}
472
+
473
+ return {
474
+ "word_count_clean": word_count,
475
+ "duration_s": duration_s,
476
+ # A β€” Schmid disfluency markers
477
+ "filled_pauses": {
478
+ "count": len(fps),
479
+ "per_100_words": per100(len(fps)),
480
+ "items": fps,
481
+ },
482
+ "empty_pauses": {
483
+ "count": len(eps),
484
+ "per_100_words": per100(len(eps)),
485
+ "threshold_ms": 300,
486
+ "items": eps,
487
+ },
488
+ "repetitions": {
489
+ "count": len(rps),
490
+ "per_100_words": per100(len(rps)),
491
+ "items": rps,
492
+ },
493
+ "retractions": {
494
+ "count": len(rts),
495
+ "per_100_words": per100(len(rts)),
496
+ "items": rts,
497
+ },
498
+ # B β€” Temporal / fluency
499
+ "temporal": temporal,
500
+ # C β€” Lexical
501
+ "lexical_diversity": lex,
502
+ "code_switching": {
503
+ "count": len(cs),
504
+ "items": cs,
505
+ },
506
+ # D β€” Disfluency context
507
+ "disfluency_following": {
508
+ "items": df_context,
509
+ "class_distribution": dict(class_counts),
510
+ },
511
+ # E β€” Acoustic markers (Phase 1)
512
+ "acoustic": acoustic,
513
+ # F β€” Alignment-based markers (Phase 2)
514
+ "alignment": alignment,
515
+ }
requirements.txt ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ gradio>=4.0
2
+ librosa>=0.10
3
+ soundfile>=0.12
4
+ praat-parselmouth>=0.4.3
5
+ praatio>=6.0
6
+ pyphen>=0.14
7
+ lexical-diversity>=0.1.1
8
+ pandas>=2.0
9
+ numpy>=1.24
10
+ scipy>=1.10
11
+ matplotlib>=3.6