Upload 7 files
Browse files- README.md +41 -7
- acoustic_markers.py +258 -0
- alignment_markers.py +643 -0
- app.py +221 -0
- features.py +119 -0
- markers.py +515 -0
- requirements.txt +11 -0
README.md
CHANGED
|
@@ -1,13 +1,47 @@
|
|
| 1 |
---
|
| 2 |
-
title: Language Attrition
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: gradio
|
| 7 |
-
sdk_version:
|
| 8 |
-
python_version:
|
| 9 |
app_file: app.py
|
| 10 |
pinned: false
|
|
|
|
| 11 |
---
|
| 12 |
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|